diff --git a/src/graph/extract/go.ts b/src/graph/extract/go.ts index 0d6142e54..4f954a31e 100644 --- a/src/graph/extract/go.ts +++ b/src/graph/extract/go.ts @@ -123,7 +123,7 @@ function collectDecls( } function extractReceiverType(receiver: TSNode): string | null { - // parameter_list → parameter_declaration → pointer_type or type_identifier + // parameter_list → parameter_declaration → pointer_type, generic_type or type_identifier for (let i = 0; i < receiver.namedChildCount; i++) { const param = receiver.namedChild(i); /* c8 ignore next */ @@ -131,14 +131,17 @@ function extractReceiverType(receiver: TSNode): string | null { const typeField = param.childForFieldName("type"); /* c8 ignore next */ if (typeField === null) continue; - if (typeField.type === "type_identifier") return typeField.text; + const base = receiverBaseType(typeField); + if (base !== null) return base; /* c8 ignore next */ if (typeField.type === "pointer_type") { - // *Foo → Foo + // *Foo → Foo, *Foo[T] → Foo for (let j = 0; j < typeField.namedChildCount; j++) { const inner = typeField.namedChild(j); /* c8 ignore next */ - if (inner !== null && inner.type === "type_identifier") return inner.text; + if (inner === null) continue; + const innerBase = receiverBaseType(inner); + if (innerBase !== null) return innerBase; } } } @@ -146,6 +149,16 @@ function extractReceiverType(receiver: TSNode): string | null { return null; } +/** Resolve a plain or generic local receiver base; unsupported shapes stay unresolved. */ +function receiverBaseType(typeNode: TSNode): string | null { + if (typeNode.type === "type_identifier") return typeNode.text; + if (typeNode.type === "generic_type") { + const base = typeNode.childForFieldName("type"); + if (base !== null && base.type === "type_identifier") return base.text; + } + return null; +} + function collectGoImports( node: TSNode, result: FileExtraction, diff --git a/tests/shared/graph/go.test.ts b/tests/shared/graph/go.test.ts index 084993bae..23ec5dc97 100644 --- a/tests/shared/graph/go.test.ts +++ b/tests/shared/graph/go.test.ts @@ -127,6 +127,80 @@ describe("Go extraction", () => { expect(call).toBeDefined(); }); + describe("generic method receivers", () => { + const src = [ + "package main", + "type Box[T any] struct { v T }", + "type Pair[K comparable, V any] struct { k K; v V }", + "type Plain struct{}", + "func boxHelper() {}", + "func pairHelper() {}", + "func (b Box[T]) Get() T { boxHelper(); return b.v }", + "func (b *Box[T]) Set(v T) { b.v = v }", + "func (p Pair[K, V]) Get() V { pairHelper(); return p.v }", + "func (p *Pair[K, V]) Set(v V) { p.v = v }", + "func (p Plain) Get() int { return 0 }", + "func (p *Plain) Set(v int) {}", + "", + ].join("\n"); + const ex = extractGo(src, "pkg/gen.go"); + const methodOf = (id: string) => + ex.edges.filter(e => e.relation === "method_of" && e.target === id).map(e => e.source); + + it("parses generic declarations without errors", () => { + expect(ex.parse_errors).toHaveLength(0); + expect(ex.nodes.some(n => n.id === "pkg/gen.go:Box:class")).toBe(true); + expect(ex.nodes.some(n => n.id === "pkg/gen.go:Pair:class")).toBe(true); + }); + + it("keys same-named methods on different generic types by their base type", () => { + const ids = ex.nodes.filter(n => n.kind === "method").map(n => n.id).sort(); + expect(ids).toEqual([ + "pkg/gen.go:Box.Get:method", + "pkg/gen.go:Box.Set:method", + "pkg/gen.go:Pair.Get:method", + "pkg/gen.go:Pair.Set:method", + "pkg/gen.go:Plain.Get:method", + "pkg/gen.go:Plain.Set:method", + ]); + expect(ex.nodes.some(n => n.id === "pkg/gen.go:Get:method")).toBe(false); + expect(ex.nodes.some(n => n.id === "pkg/gen.go:Set:method")).toBe(false); + }); + + it("emits method_of from the base type for value and pointer generic receivers", () => { + expect(methodOf("pkg/gen.go:Box.Get:method")).toEqual(["pkg/gen.go:Box:class"]); + expect(methodOf("pkg/gen.go:Box.Set:method")).toEqual(["pkg/gen.go:Box:class"]); + expect(methodOf("pkg/gen.go:Pair.Get:method")).toEqual(["pkg/gen.go:Pair:class"]); + expect(methodOf("pkg/gen.go:Pair.Set:method")).toEqual(["pkg/gen.go:Pair:class"]); + }); + + it("still resolves ordinary value and pointer receivers alongside generics", () => { + expect(methodOf("pkg/gen.go:Plain.Get:method")).toEqual(["pkg/gen.go:Plain:class"]); + expect(methodOf("pkg/gen.go:Plain.Set:method")).toEqual(["pkg/gen.go:Plain:class"]); + }); + + it("attributes calls inside generic methods to the owning method", () => { + const calls = ex.edges + .filter(e => e.relation === "calls") + .map(e => `${e.source} -> ${e.target}`) + .sort(); + expect(calls).toEqual([ + "pkg/gen.go:Box.Get:method -> pkg/gen.go:boxHelper:function", + "pkg/gen.go:Pair.Get:method -> pkg/gen.go:pairHelper:function", + ]); + }); + }); + + it("leaves a parenthesized receiver type unresolved (no method_of edge)", () => { + const ex = extractGo( + `package main\ntype Box[T any] struct{}\nfunc (b (Box[T])) Odd() {}\n`, + "pkg/odd.go", + ); + const odd = ex.nodes.find(n => n.label === "Odd" && n.kind === "method"); + expect(odd).toBeDefined(); + expect(ex.edges.some(e => e.relation === "method_of" && e.target === odd!.id)).toBe(false); + }); + it("includes a module node for the file", () => { const ex = extractGo(`package main\n`, "pkg/a.go"); expect(ex.nodes.some(n => n.kind === "module" && n.id === "pkg/a.go::module")).toBe(true);