From 0f8523d2de1eb00a3d2431ef778a1a50d3f2bc45 Mon Sep 17 00:00:00 2001 From: Tirth Kanani Date: Sun, 14 Jun 2026 18:01:21 +0100 Subject: [PATCH 1/3] fix(go-extractor): attach methods on generic receivers and handle grouped type declarations extractReceiverType only unwrapped a bare type_identifier or a pointer_type directly wrapping one. Generic receivers like `(s *Stack[T])` or `(s Stack[T])` are parsed as generic_type nodes (optionally pointer-wrapped), so the base type name was never found and the method was never attached to its struct (methods stayed []). The fix iteratively unwraps pointer_type and generic_type layers before grabbing the base type_identifier. extractTypeDeclaration used findChild(node, "type_spec"), which only returns the first type_spec. Grouped declarations (`type ( Foo struct{...}; Bar struct{...} )`) contain multiple type_spec children, so every type after the first was silently dropped from classes and exports. The fix iterates over all type_spec children via findChildren and passes each typeSpec as the decl node for accurate per-type line ranges. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../extractors/__tests__/go-extractor.test.ts | 51 +++++++++++++++++++ .../src/plugins/extractors/go-extractor.ts | 44 +++++++++------- 2 files changed, 77 insertions(+), 18 deletions(-) diff --git a/understand-anything-plugin/packages/core/src/plugins/extractors/__tests__/go-extractor.test.ts b/understand-anything-plugin/packages/core/src/plugins/extractors/__tests__/go-extractor.test.ts index 2cf1b772f..61490a2dd 100644 --- a/understand-anything-plugin/packages/core/src/plugins/extractors/__tests__/go-extractor.test.ts +++ b/understand-anything-plugin/packages/core/src/plugins/extractors/__tests__/go-extractor.test.ts @@ -596,4 +596,55 @@ func helper(x int) string { parser.delete(); }); }); + + // ---- Generic receivers ---- + + describe("extractStructure - generic receivers", () => { + it("attaches methods on generic receivers to their struct", () => { + const { tree, parser, root } = parse(`package main + +type Stack[T any] struct { + items []T +} + +func (s *Stack[T]) Push(item T) {} + +func (s Stack[T]) Len() int { + return 0 +} +`); + const result = extractor.extractStructure(root); + + expect(result.classes).toHaveLength(1); + expect(result.classes[0].name).toBe("Stack"); + expect(result.classes[0].methods.sort()).toEqual(["Len", "Push"]); + + tree.delete(); + parser.delete(); + }); + }); + + // ---- Grouped type declarations ---- + + describe("extractStructure - grouped type declarations", () => { + it("handles grouped type declarations", () => { + const { tree, parser, root } = parse(`package main + +type ( + Foo struct { A int } + Bar struct { B int } +) +`); + const result = extractor.extractStructure(root); + + expect(result.classes.map((c) => c.name)).toEqual(["Foo", "Bar"]); + + const exportNames = result.exports.map((e) => e.name); + expect(exportNames).toContain("Foo"); + expect(exportNames).toContain("Bar"); + + tree.delete(); + parser.delete(); + }); + }); }); diff --git a/understand-anything-plugin/packages/core/src/plugins/extractors/go-extractor.ts b/understand-anything-plugin/packages/core/src/plugins/extractors/go-extractor.ts index 53e3e95aa..b41355cc0 100644 --- a/understand-anything-plugin/packages/core/src/plugins/extractors/go-extractor.ts +++ b/understand-anything-plugin/packages/core/src/plugins/extractors/go-extractor.ts @@ -50,16 +50,21 @@ function extractReceiverType(receiverNode: TreeSitterNode): string | undefined { const decl = findChild(receiverNode, "parameter_declaration"); if (!decl) return undefined; - // Look for type_identifier directly or inside pointer_type + // Look for type_identifier directly or inside pointer_type / generic_type. + // For generic receivers the receiver is wrapped in a generic_type (and may be + // pointer-wrapped too), e.g. `(s *Stack[T])`, so unwrap those layers before + // grabbing the base type_identifier. for (let i = 0; i < decl.childCount; i++) { - const child = decl.child(i); + let child: TreeSitterNode | null = decl.child(i); if (!child) continue; - if (child.type === "type_identifier") { - return child.text; + while (child && (child.type === "pointer_type" || child.type === "generic_type")) { + child = + child.type === "generic_type" + ? child.childForFieldName("type") + : findChild(child, "type_identifier") ?? findChild(child, "generic_type"); } - if (child.type === "pointer_type") { - const typeId = findChild(child, "type_identifier"); - if (typeId) return typeId.text; + if (child && child.type === "type_identifier") { + return child.text; } } return undefined; @@ -261,17 +266,20 @@ export class GoExtractor implements LanguageExtractor { classes: StructuralAnalysis["classes"], exports: StructuralAnalysis["exports"], ): void { - const typeSpec = findChild(node, "type_spec"); - if (!typeSpec) return; - - const nameNode = typeSpec.childForFieldName("name"); - const typeNode = typeSpec.childForFieldName("type"); - if (!nameNode || !typeNode) return; - - if (typeNode.type === "struct_type") { - this.extractStruct(node, nameNode, typeNode, classes, exports); - } else if (typeNode.type === "interface_type") { - this.extractInterface(node, nameNode, typeNode, classes, exports); + // A type_declaration can hold multiple type_spec children when types are + // grouped, e.g. `type ( Foo struct{...}; Bar struct{...} )`. Iterate over + // all of them so every grouped type is captured, not just the first. + const typeSpecs = findChildren(node, "type_spec"); + for (const typeSpec of typeSpecs) { + const nameNode = typeSpec.childForFieldName("name"); + const typeNode = typeSpec.childForFieldName("type"); + if (!nameNode || !typeNode) continue; + + if (typeNode.type === "struct_type") { + this.extractStruct(typeSpec, nameNode, typeNode, classes, exports); + } else if (typeNode.type === "interface_type") { + this.extractInterface(typeSpec, nameNode, typeNode, classes, exports); + } } } From 45b11f74d249acf7454ef728885354bb0f986ede Mon Sep 17 00:00:00 2001 From: Tirth Kanani Date: Tue, 16 Jun 2026 23:30:36 +0100 Subject: [PATCH 2/3] test(go-extractor): cover grouped struct+interface and generic receiver cases Add tests pinning the interface branch inside grouped type blocks (mixed struct+interface and two-interface groups) and multi-parameter / constrained generic method receivers (Box[K, V], Result[T comparable]). Document in extractTypeDeclaration that non-struct/non-interface specs (named-primitive type_spec and type_alias nodes) are intentionally not modeled as classes, and that the per-spec declNode lets grouped types report their own line ranges. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../extractors/__tests__/go-extractor.test.ts | 95 +++++++++++++++++++ .../src/plugins/extractors/go-extractor.ts | 15 +++ 2 files changed, 110 insertions(+) diff --git a/understand-anything-plugin/packages/core/src/plugins/extractors/__tests__/go-extractor.test.ts b/understand-anything-plugin/packages/core/src/plugins/extractors/__tests__/go-extractor.test.ts index 61490a2dd..af15a1ef3 100644 --- a/understand-anything-plugin/packages/core/src/plugins/extractors/__tests__/go-extractor.test.ts +++ b/understand-anything-plugin/packages/core/src/plugins/extractors/__tests__/go-extractor.test.ts @@ -622,6 +622,53 @@ func (s Stack[T]) Len() int { tree.delete(); parser.delete(); }); + + it("attaches methods on multi-parameter generic receivers", () => { + const { tree, parser, root } = parse(`package main + +type Box[K comparable, V any] struct { + data map[K]V +} + +func (b *Box[K, V]) Get(k K) V { + var zero V + return zero +} + +func (b Box[K, V]) Len() int { + return 0 +} +`); + const result = extractor.extractStructure(root); + + expect(result.classes).toHaveLength(1); + expect(result.classes[0].name).toBe("Box"); + expect(result.classes[0].methods.sort()).toEqual(["Get", "Len"]); + + tree.delete(); + parser.delete(); + }); + + it("attaches methods on constrained generic receivers", () => { + const { tree, parser, root } = parse(`package main + +type Result[T comparable] struct { + value T +} + +func (r Result[T comparable]) Ok() bool { + return true +} +`); + const result = extractor.extractStructure(root); + + expect(result.classes).toHaveLength(1); + expect(result.classes[0].name).toBe("Result"); + expect(result.classes[0].methods).toContain("Ok"); + + tree.delete(); + parser.delete(); + }); }); // ---- Grouped type declarations ---- @@ -646,5 +693,53 @@ type ( tree.delete(); parser.delete(); }); + + it("handles a grouped block mixing struct and interface specs", () => { + const { tree, parser, root } = parse(`package main + +type ( + Foo struct { A int } + Bar interface { Read() error } +) +`); + const result = extractor.extractStructure(root); + + expect(result.classes.map((c) => c.name)).toEqual(["Foo", "Bar"]); + + const foo = result.classes.find((c) => c.name === "Foo")!; + expect(foo.properties).toEqual(["A"]); + expect(foo.methods).toEqual([]); + + const bar = result.classes.find((c) => c.name === "Bar")!; + expect(bar.methods).toContain("Read"); + expect(bar.properties).toEqual([]); + + tree.delete(); + parser.delete(); + }); + + it("handles a grouped block of two interfaces", () => { + const { tree, parser, root } = parse(`package main + +type ( + Reader interface { Read() error } + Writer interface { Write(p []byte) (int, error) } +) +`); + const result = extractor.extractStructure(root); + + expect(result.classes.map((c) => c.name)).toEqual(["Reader", "Writer"]); + + const reader = result.classes.find((c) => c.name === "Reader")!; + expect(reader.methods).toEqual(["Read"]); + expect(reader.properties).toEqual([]); + + const writer = result.classes.find((c) => c.name === "Writer")!; + expect(writer.methods).toEqual(["Write"]); + expect(writer.properties).toEqual([]); + + tree.delete(); + parser.delete(); + }); }); }); diff --git a/understand-anything-plugin/packages/core/src/plugins/extractors/go-extractor.ts b/understand-anything-plugin/packages/core/src/plugins/extractors/go-extractor.ts index b41355cc0..873d626e9 100644 --- a/understand-anything-plugin/packages/core/src/plugins/extractors/go-extractor.ts +++ b/understand-anything-plugin/packages/core/src/plugins/extractors/go-extractor.ts @@ -269,6 +269,21 @@ export class GoExtractor implements LanguageExtractor { // A type_declaration can hold multiple type_spec children when types are // grouped, e.g. `type ( Foo struct{...}; Bar struct{...} )`. Iterate over // all of them so every grouped type is captured, not just the first. + // + // We use the per-spec `type_spec` node (not the outer `type_declaration`) + // as the declNode so each grouped type reports its own line range; for a + // non-grouped `type Foo struct{...}` the spec and the `type` keyword sit on + // the same physical line, so the line range is unchanged. + // + // Only struct_type / interface_type specs are modeled as `classes`. Other + // forms are intentionally not captured (mirroring the pre-fix behavior): + // - named-primitive / defined-type specs (`type Count int`) whose `type` + // field is a type_identifier, qualified_type, etc. + // - type alias specs (`type MyID = string`), which the grammar parses as + // a separate `type_alias` node rather than `type_spec`, so they are not + // even visited by this loop. + // Modeling these as graph nodes is a broader type-model question; see the + // PR discussion / a follow-up issue if alias/defined-type capture is wanted. const typeSpecs = findChildren(node, "type_spec"); for (const typeSpec of typeSpecs) { const nameNode = typeSpec.childForFieldName("name"); From a9eba94174bf2ce565af280d1b4e64eed3119d06 Mon Sep 17 00:00:00 2001 From: Tirth Kanani Date: Wed, 17 Jun 2026 13:27:20 +0100 Subject: [PATCH 3/3] fix(go-extractor): surface exported defined types and type aliases in exports MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Previously only struct_type / interface_type specs were captured; exported defined types (`type Count int`) and type aliases (`type MyID = string`, which parse as a separate `type_alias` node) were silently dropped from `exports` — both in grouped `type ( ... )` blocks and as standalone declarations. These are public package symbols and now appear in `exports` (still not in `classes`, since they have no fields/methods). Unexported (lowercase) names remain excluded. Addresses review concern #1. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../extractors/__tests__/go-extractor.test.ts | 54 +++++++++++++++++++ .../src/plugins/extractors/go-extractor.ts | 37 +++++++++---- 2 files changed, 80 insertions(+), 11 deletions(-) diff --git a/understand-anything-plugin/packages/core/src/plugins/extractors/__tests__/go-extractor.test.ts b/understand-anything-plugin/packages/core/src/plugins/extractors/__tests__/go-extractor.test.ts index af15a1ef3..53e1055c9 100644 --- a/understand-anything-plugin/packages/core/src/plugins/extractors/__tests__/go-extractor.test.ts +++ b/understand-anything-plugin/packages/core/src/plugins/extractors/__tests__/go-extractor.test.ts @@ -425,6 +425,60 @@ type reader interface { tree.delete(); parser.delete(); }); + + it("exports defined types and type aliases (not just structs/interfaces)", () => { + const { tree, parser, root } = parse(`package main + +type Count int +type Celsius float64 +type ID = string + +type count int +type id = string +`); + const result = extractor.extractStructure(root); + + const exportNames = result.exports.map((e) => e.name); + // Exported defined types and aliases are surfaced... + expect(exportNames).toContain("Count"); + expect(exportNames).toContain("Celsius"); + expect(exportNames).toContain("ID"); + // ...lowercase (unexported) ones are not. + expect(exportNames).not.toContain("count"); + expect(exportNames).not.toContain("id"); + // They are not modeled as classes (no fields/methods). + expect(result.classes.map((c) => c.name)).not.toContain("Count"); + + tree.delete(); + parser.delete(); + }); + + it("captures every kind in a grouped type block (struct, interface, defined, alias)", () => { + const { tree, parser, root } = parse(`package main + +type ( + Foo struct{ x int } + Bar interface{ M() } + Gid = string + N int +) +`); + const result = extractor.extractStructure(root); + + const exportNames = result.exports.map((e) => e.name); + expect(exportNames).toEqual( + expect.arrayContaining(["Foo", "Bar", "Gid", "N"]), + ); + // Struct/interface are modeled as classes; defined-type/alias are not. + expect(result.classes.map((c) => c.name)).toEqual( + expect.arrayContaining(["Foo", "Bar"]), + ); + expect(result.classes.map((c) => c.name)).not.toContain("Gid"); + expect(result.classes.map((c) => c.name)).not.toContain("N"); + + tree.delete(); + parser.delete(); + }); }); // ---- Call Graph ---- diff --git a/understand-anything-plugin/packages/core/src/plugins/extractors/go-extractor.ts b/understand-anything-plugin/packages/core/src/plugins/extractors/go-extractor.ts index 873d626e9..26c257d66 100644 --- a/understand-anything-plugin/packages/core/src/plugins/extractors/go-extractor.ts +++ b/understand-anything-plugin/packages/core/src/plugins/extractors/go-extractor.ts @@ -270,20 +270,17 @@ export class GoExtractor implements LanguageExtractor { // grouped, e.g. `type ( Foo struct{...}; Bar struct{...} )`. Iterate over // all of them so every grouped type is captured, not just the first. // - // We use the per-spec `type_spec` node (not the outer `type_declaration`) - // as the declNode so each grouped type reports its own line range; for a + // We use the per-spec node (not the outer `type_declaration`) as the + // declNode so each grouped type reports its own line range; for a // non-grouped `type Foo struct{...}` the spec and the `type` keyword sit on // the same physical line, so the line range is unchanged. // - // Only struct_type / interface_type specs are modeled as `classes`. Other - // forms are intentionally not captured (mirroring the pre-fix behavior): - // - named-primitive / defined-type specs (`type Count int`) whose `type` - // field is a type_identifier, qualified_type, etc. - // - type alias specs (`type MyID = string`), which the grammar parses as - // a separate `type_alias` node rather than `type_spec`, so they are not - // even visited by this loop. - // Modeling these as graph nodes is a broader type-model question; see the - // PR discussion / a follow-up issue if alias/defined-type capture is wanted. + // struct_type / interface_type specs are modeled as `classes` (with their + // fields/methods). Defined types (`type Count int`) and type aliases + // (`type MyID = string`, parsed as a separate `type_alias` node) have no + // fields or methods to model, but they are still top-level package + // declarations: when exported they are surfaced in `exports` so public + // types aren't silently dropped from the structural view. const typeSpecs = findChildren(node, "type_spec"); for (const typeSpec of typeSpecs) { const nameNode = typeSpec.childForFieldName("name"); @@ -294,6 +291,24 @@ export class GoExtractor implements LanguageExtractor { this.extractStruct(typeSpec, nameNode, typeNode, classes, exports); } else if (typeNode.type === "interface_type") { this.extractInterface(typeSpec, nameNode, typeNode, classes, exports); + } else if (isExported(nameNode.text)) { + // Defined type, e.g. `type Count int` / `type Celsius float64`. + exports.push({ + name: nameNode.text, + lineNumber: typeSpec.startPosition.row + 1, + }); + } + } + + // Type aliases (`type MyID = string`) parse as `type_alias`, a sibling of + // `type_spec` under `type_declaration`, so they are not visited above. + for (const alias of findChildren(node, "type_alias")) { + const aliasName = alias.childForFieldName("name"); + if (aliasName && isExported(aliasName.text)) { + exports.push({ + name: aliasName.text, + lineNumber: alias.startPosition.row + 1, + }); } } }