From 89feab742a2af663a50adc2536e334ea32c42703 Mon Sep 17 00:00:00 2001 From: Kunal Roy Choudhury Date: Mon, 9 Nov 2026 21:16:54 +0530 Subject: [PATCH] fix(go): keep the receiver of a method on a generic type `func (s *Stack[T]) Push` has a generic_type receiver, which the receiver and type-name helpers did not unwrap. The method was minted without an owner (`Push`, then `Push~2` for the next generic type), calls on the receiver inside it were dropped, and `var s Stack[int]` or `&Queue[K, V]{}` bound nothing, so no call into a generic type's methods resolved. Erase the type arguments wherever a Go type name is read: the receiver, typed locals and fields, and composite literals. --- src/graph/bindings.ts | 14 ++++----- src/graph/extract.ts | 4 ++- test/graph-go-generics.test.ts | 55 ++++++++++++++++++++++++++++++++++ 3 files changed, 65 insertions(+), 8 deletions(-) create mode 100644 test/graph-go-generics.test.ts diff --git a/src/graph/bindings.ts b/src/graph/bindings.ts index f637ff449..d31d7002d 100644 --- a/src/graph/bindings.ts +++ b/src/graph/bindings.ts @@ -184,9 +184,7 @@ export function goReceiverVarOf(node: SyntaxNode): string | null { function goReceiverTypeOf(node: SyntaxNode): string | null { const recv = node.childForFieldName("receiver"); const param = recv?.namedChildren.find((c) => c.type === "parameter_declaration"); - let type = param?.childForFieldName("type"); - if (type?.type === "pointer_type") type = type.namedChildren.at(-1) ?? null; - return type?.type === "type_identifier" ? type.text : null; + return goTypeName(param?.childForFieldName("type") ?? null); } type RecvCtx = Pick; @@ -735,8 +733,7 @@ function handleGo(node: SyntaxNode, scope: string[], bindings: FileBindings): vo } let typeName: string | null = null; if (expr.type === "composite_literal") { - const t = expr.childForFieldName("type"); - typeName = t?.type === "type_identifier" ? t.text : null; + typeName = goTypeName(expr.childForFieldName("type")); } else if (expr.type === "call_expression") { const fn = expr.childForFieldName("function"); // Go convention: NewX(...) binds to X. @@ -746,7 +743,10 @@ function handleGo(node: SyntaxNode, scope: string[], bindings: FileBindings): vo } } -function goTypeName(type: SyntaxNode | null): string | null { - const base = type?.type === "pointer_type" ? type.namedChildren.at(-1) : type; +/** A Go type's bare name: a pointer and a type-argument list are both erased + * (`*Stack[int]` → `Stack`), since the method set belongs to the generic type. */ +function goTypeName(type: SyntaxNode | null | undefined): string | null { + let base = type?.type === "pointer_type" ? type.namedChildren.at(-1) : type; + if (base?.type === "generic_type") base = base.childForFieldName("type"); return base?.type === "type_identifier" ? base.text : null; } diff --git a/src/graph/extract.ts b/src/graph/extract.ts index b18a56c01..11f64b7e9 100644 --- a/src/graph/extract.ts +++ b/src/graph/extract.ts @@ -2073,12 +2073,14 @@ function swiftSuperClassName(node: SyntaxNode): string | null { } /** The receiver's base type name for a Go method, unwrapping a pointer receiver - * (`func (u *User) …` → `User`). Null if it can't be read. */ + * (`func (u *User) …` → `User`) and a generic one (`func (s *Stack[T]) …` → `Stack`). + * Null if it can't be read. */ function goReceiverType(node: SyntaxNode): string | null { const recv = node.childForFieldName("receiver"); // parameter_list const param = recv?.namedChildren.find((c) => c.type === "parameter_declaration"); let type = param?.childForFieldName("type"); if (type?.type === "pointer_type") type = type.namedChildren.at(-1) ?? null; + if (type?.type === "generic_type") type = type.childForFieldName("type"); return type?.type === "type_identifier" ? type.text : null; } diff --git a/test/graph-go-generics.test.ts b/test/graph-go-generics.test.ts new file mode 100644 index 000000000..4554c3342 --- /dev/null +++ b/test/graph-go-generics.test.ts @@ -0,0 +1,55 @@ +/** + * Go methods on generic types in the Tier-1 code graph. + */ +import { test } from "node:test"; +import assert from "node:assert/strict"; +import { mkdtempSync, writeFileSync, rmSync } from "node:fs"; +import { join } from "node:path"; +import { tmpdir } from "node:os"; +import { buildGraph } from "../src/graph/build.js"; +import { readGraph, wiringPath } from "../src/graph/write.js"; +import type { GraphV1, NodeV1 } from "../src/graph/types.js"; + +function nodeById(graph: GraphV1, id: string): NodeV1 | undefined { + return graph.nodes.find((n) => n.id === id); +} + +test("Go extraction: methods on a generic type keep their receiver and resolve through typed locals", async () => { + const dir = mkdtempSync(join(tmpdir(), "graft-go-generic-recv-")); + try { + writeFileSync(join(dir, "go.mod"), "module example.com/app\n\ngo 1.22\n"); + writeFileSync( + join(dir, "coll.go"), + "package main\n\n" + + "type Stack[T any] struct{ items []T }\n\n" + + "func (s *Stack[T]) Push(x T) { s.grow() }\nfunc (s *Stack[T]) grow() {}\n\n" + + "type Queue[K comparable, V any] struct{ m map[K]V }\n\n" + + "func (q Queue[K, V]) Push(k K, v V) { q.grow() }\nfunc (q Queue[K, V]) grow() {}\n\n" + + "func NewStack[T any]() *Stack[T] { return &Stack[T]{} }\n\n" + + "func declared() { var s Stack[int]; s.Push(1) }\n" + + "func literal() { q := &Queue[string, int]{}; q.Push(\"a\", 1) }\n" + + "func ctor() { s := NewStack[int](); s.Push(1) }\n", + ); + + await buildGraph(dir); + const graph = readGraph(wiringPath(join(dir, "graft")))!; + + for (const id of ["Stack.Push", "Stack.grow", "Queue.Push", "Queue.grow"]) { + const node = nodeById(graph, `coll.go#${id}`); + assert.equal(node?.kind, "method", id); + assert.equal(node?.owner, id.split(".")[0], id); + } + assert.equal(nodeById(graph, "coll.go#Push"), undefined, "no owner-less Push node"); + + const calls = (source: string) => + graph.edges.filter((e) => e.relation === "calls" && e.source === source).map((e) => e.target); + assert.deepEqual(calls("coll.go#Stack.Push"), ["coll.go#Stack.grow"]); + assert.deepEqual(calls("coll.go#Queue.Push"), ["coll.go#Queue.grow"]); + const pushes = (source: string) => calls(source).filter((t) => t.endsWith(".Push")); + assert.deepEqual(pushes("coll.go#declared"), ["coll.go#Stack.Push"], "var s Stack[int]"); + assert.deepEqual(pushes("coll.go#literal"), ["coll.go#Queue.Push"], "&Queue[string, int]{}"); + assert.deepEqual(pushes("coll.go#ctor"), ["coll.go#Stack.Push"], "NewStack[int]()"); + } finally { + rmSync(dir, { recursive: true, force: true }); + } +});