From 24501fa66c11a81da5a499425b5eabc79a2fb3f5 Mon Sep 17 00:00:00 2001 From: Colby Mchenry Date: Wed, 7 Oct 2026 00:29:50 +0000 Subject: [PATCH] fix(go): an interface links the interfaces it embeds (#2397) extractInheritance's Go interface-embedding branch matched a `constraint_elem`, a node type neither the vendored tree-sitter-go wasm nor the kernel's tree-sitter-go 0.23 crate has: both parse an embedded interface as a `type_elem` holding one type. So no Go interface ever extended anything, in either engine; gin's IRouter never reached IRoutes. Read an embedded type the same way in an interface and in a struct: `T`, `*T`, `pkg.T` and `T[X]` all embed `T`, recorded at the name so resolution reads the package back from the source (#2322). A type_elem holding a union, a `~T` term or a lone predeclared type is a type-set constraint and records nothing, and predeclared names (`error`, `any`) are skipped in structs too. Qualified and generic struct embeddings, skipped until now, link the same way. Resolution keeps an embedded type in its package. The Go framework resolver's naming heuristics no longer take extends/implements refs: its PascalCase-model pattern saw structs only, so an embedded interface went to another package's same-named struct (promql/parser's Node, etcd's Lease), as #1827 did for Spring. And a Go supertype written through a package that is none of the file's imports as indexed (`yaml` under an unaliased go.yaml.in/yaml/v3, registered as `v3`) stays unresolved instead of binding by its bare name. Validated on gin, prometheus and etcd (before/after edge diffs, every added supertype edge checked against its package; kernel/wasm parity sweeps unchanged). Co-Authored-By: Claude Opus 5.5 --- CHANGELOG.md | 1 + __tests__/fixtures/kernel-parity/torture.go | 13 + __tests__/go-interface-embedding.test.ts | 425 ++++++++++++++++++++ codegraph-kernel/src/go.rs | 56 ++- src/extraction/languages/go.ts | 26 ++ src/extraction/tree-sitter.ts | 42 +- src/resolution/frameworks/go.ts | 9 + src/resolution/index.ts | 7 +- src/resolution/name-matcher.ts | 59 ++- 9 files changed, 586 insertions(+), 52 deletions(-) create mode 100644 __tests__/go-interface-embedding.test.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index f6c0e7116..a474c4953 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -66,6 +66,7 @@ and adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). - In a repository that holds more than one Shopify theme, a theme's `{% render %}`, `{% include %}` and `{% section %}` tags and the sections its JSON templates list now link only to that theme's own snippets and sections, which is where Shopify looks for them. Before, a section or snippet the theme doesn't have was linked to another theme's file of the same name, and a theme kept in a subfolder could link to the root theme's copy instead of its own. A reference the theme can't satisfy now links to nothing. Projects with a single theme are indexed as before. Re-index projects with several Shopify themes after upgrading. - In C, C++ and Objective-C, a struct or class member declared as a pointer, reference, array or function, like `SharedState* shared;`, `jv elements[];` or `virtual Status Put(…) = 0;`, no longer makes the struct look like it inherits from the member's type. Before, `codegraph_explore` and impact listed those types as base classes, every struct holding a pointer to a type as one of its subclasses, and a method of that struct as an override of the type's method with the same name. Real base classes like `class Derived : public Base`, Objective-C superclasses and Go's embedded fields are unchanged. Re-index C, C++ and Objective-C projects after upgrading. - Framework support now switches on when an app's `package.json` sits three or more folders deep, as with an ASP.NET solution's Angular or React app in `src/Web/ClientApp/`, or a React app in a workspace folder like `web/ui/mantine-ui/`. Before, only the project root and the first two folder levels were read, so frameworks such as Angular, React Router, Vue Router, SvelteKit and TanStack Router could go undetected there: an Angular app's routes, its `routerLink` and `router.navigate(…)` navigation and its template's event bindings were missing, so a handler used only from a `(click)` binding had no callers, and a React app's `navigate('/login')` calls didn't reach their route. Re-index projects laid out this way after upgrading. +- In Go, an interface that embeds another, like gin's `IRouter` embedding `IRoutes`, now links to it, so `codegraph_explore` and impact show the embedded interface as its base. Before, every embedded interface was dropped. An embedded field written through another package of your project, like `*refresh.Discovery` or `storage.Appender`, or with type arguments, like `List[int]`, now links too. One from the standard library or another module, like `io.Closer` or `sync.Mutex`, links to nothing rather than to a project type that shares its name, and a type-set constraint like `~int | ~float64` is not an embedding. An embedded type written without a package now always links to its own package's type: before, etcd's `Client`, which embeds its own `Lease` interface, was linked to a `Lease` struct in another package. Re-index Go projects after upgrading. ## [1.6.2] - 2026-10-03 diff --git a/__tests__/fixtures/kernel-parity/torture.go b/__tests__/fixtures/kernel-parity/torture.go index f89b8d429..3aada94af 100644 --- a/__tests__/fixtures/kernel-parity/torture.go +++ b/__tests__/fixtures/kernel-parity/torture.go @@ -17,6 +17,10 @@ var handlerTable = map[string]func(int){ type Widget struct { *Base Queryable + pkga.Embedded + *pkga.Pointer `json:"-"` + Stack[int] + error name string } @@ -26,10 +30,19 @@ type Stack[T any] struct { type Core interface { Reader + pkga.Closer // qualified + Lister[int] + error Marshal(v any) ([]byte, error) Unmarshal(data []byte) error } +type Number interface { + ~int | ~float64 +} + +type Exact interface{ int64 } + type Dur int func NewRegistry() *Registry { diff --git a/__tests__/go-interface-embedding.test.ts b/__tests__/go-interface-embedding.test.ts new file mode 100644 index 000000000..fece4451e --- /dev/null +++ b/__tests__/go-interface-embedding.test.ts @@ -0,0 +1,425 @@ +/** + * Go interface embedding produced no supertype edge in either extractor: + * + * type IRouter interface { // gin-gonic/gin: IRouter never reached IRoutes + * IRoutes + * Group(string, ...HandlerFunc) *RouterGroup + * } + * + * extractInheritance looked for a `constraint_elem`, a node type that neither + * the vendored tree-sitter-go wasm nor the kernel's grammar crate has. Both + * parse an embedded interface as a `type_elem` holding one type: a + * `type_identifier` for `IRoutes`, a `qualified_type` for `io.Closer`. A + * `type_elem` is also how a type-set constraint is written (`~int | ~string`, + * `float32 | float64`, a lone `int64`), and a constraint is no supertype. + * + * An embedded type is now read the same way in an interface and in a struct: + * `T`, `*T`, `pkg.T` and `T[X]` all embed `T`, recorded at the name itself so + * resolution reads the package back from the source. `storage.LabelQuerier` + * reaches that package's interface; `io.Closer` and `sync.Mutex` stay + * unresolved rather than landing on a project type that shares the name. + * Go's predeclared types are never recorded: `int64` is a constraint's term, + * and `error`, `any` and `comparable` exist in no graph. Runs against the + * native kernel (when built) and the wasm extractor, which must agree. + * + * Resolution keeps an embedded type in its package. A bare name is its own + * package's type, not another package's struct of that name, which the Go + * framework heuristics preferred (promql/parser's `Node` interface, etcd's + * `Lease`). A package that is none of the file's imports as indexed (`yaml` + * under an unaliased `go.yaml.in/yaml/v3`) leaves the type unresolved. + */ +import { describe, it, expect, beforeAll, beforeEach, afterEach } from 'vitest'; +import * as fs from 'fs'; +import * as os from 'os'; +import * as path from 'path'; +import CodeGraph from '../src/index'; +import { extractFromSource } from '../src/extraction'; +import { initGrammars, loadGrammarsForLanguages } from '../src/extraction/grammars'; +import { tryKernelExtract, resetKernelForTests } from '../src/extraction/kernel'; +import { buildTypeHierarchy } from '../src/graph/type-hierarchy'; +import type { ExtractionResult, Node } from '../src/types'; + +const KERNEL_PATH = path.join( + __dirname, + '..', + 'codegraph-kernel', + 'prebuilds', + `${process.platform}-${process.arch}`, + 'codegraph-kernel.node' +); +const kernelAvailable = fs.existsSync(KERNEL_PATH) || process.env.CODEGRAPH_KERNEL_EXPECT === '1'; + +/** Embedded interfaces of every shape, beside constraints that embed nothing. */ +const INTERFACE_SOURCE = `package store + +import ( + "io" + + "example.com/app/storage" +) + +type Queryable interface{ Q() } + +type Querier interface { + Queryable + io.Closer + storage.LabelQuerier // a project package's interface + Select(sorted bool) int +} + +type Lister[T any] interface{ List() []T } + +type Pager interface { + Lister[int] + Page() int +} + +type Timeout interface { + error + Timeout() bool +} + +type Number interface { + ~int | ~float64 +} + +type Float interface{ float32 | float64 } + +type Exact interface{ int64 } + +type Text interface{ ~string } + +type Ordered interface { + Number | Float | ~string +} +`; + +/** gin-gonic/gin's router interfaces. */ +const GIN_SOURCE = `package gin + +type HandlerFunc func(*Context) + +type IRoutes interface { + Use(...HandlerFunc) IRoutes + Handle(string, string, ...HandlerFunc) IRoutes +} + +type IRouter interface { + IRoutes + Group(string, ...HandlerFunc) *RouterGroup +} +`; + +/** Struct embedding takes the same shapes, and keeps its named fields out. */ +const STRUCT_SOURCE = `package store + +import ( + "sync" + + "example.com/app/storage" +) + +type Store struct { + *Engine + Reader + sync.Mutex + *storage.Base + List[int] + error + name string + engine *Engine + items []storage.Base +} +`; + +const ENV_KEYS = ['CODEGRAPH_KERNEL', 'CODEGRAPH_KERNEL_LANGS'] as const; + +/** ` ` for every extends/implements ref, sorted. */ +function supertypeRefs(result: ExtractionResult): string[] { + const byId = new Map(result.nodes.map((n) => [n.id, n])); + return result.unresolvedReferences + .filter((r) => r.referenceKind === 'extends' || r.referenceKind === 'implements') + .map((r) => `${byId.get(r.fromNodeId)?.name ?? r.fromNodeId} ${r.referenceKind} ${r.referenceName}`) + .sort(); +} + +describe('Go embedded types are supertypes', () => { + let savedEnv: Record = {}; + + beforeAll(async () => { + await initGrammars(); + await loadGrammarsForLanguages(['go']); + }); + + beforeEach(() => { + savedEnv = Object.fromEntries(ENV_KEYS.map((k) => [k, process.env[k]])); + resetKernelForTests(); + }); + + afterEach(() => { + for (const k of ENV_KEYS) { + if (savedEnv[k] === undefined) delete process.env[k]; + else process.env[k] = savedEnv[k]; + } + resetKernelForTests(); + }); + + function extract(backend: 'kernel' | 'wasm', file: string, source: string): ExtractionResult { + if (backend === 'wasm') { + process.env.CODEGRAPH_KERNEL = '0'; + return extractFromSource(file, source, 'go'); + } + delete process.env.CODEGRAPH_KERNEL; + process.env.CODEGRAPH_KERNEL_LANGS = 'all'; + const result = tryKernelExtract(file, source, 'go'); + expect(result, `kernel extraction of ${file}`).not.toBeNull(); + return result!; + } + + /** Every supertype ref sits on its name, where resolution reads a package qualifier back. */ + function expectRefsOnTheirNames(result: ExtractionResult, source: string): void { + const lines = source.split(/\r?\n/); + const refs = result.unresolvedReferences.filter((r) => r.referenceKind === 'extends'); + expect(refs.length).toBeGreaterThan(0); + for (const r of refs) { + expect(lines[r.line - 1]!.startsWith(r.referenceName, r.column), `${r.referenceName} at ${r.line}:${r.column}`).toBe(true); + } + } + + const backends = kernelAvailable ? (['kernel', 'wasm'] as const) : (['wasm'] as const); + + for (const crlf of [false, true]) { + const eol = (s: string) => (crlf ? s.replace(/\n/g, '\r\n') : s); + const label = crlf ? ' (CRLF)' : ''; + + it.each(backends)(`an embedded interface extends; a constraint does not: %s${label}`, (backend) => { + const source = eol(INTERFACE_SOURCE); + const result = extract(backend, 'store/querier.go', source); + expect(result.nodes.filter((n) => n.kind === 'interface').map((n) => n.name).sort()).toEqual( + ['Exact', 'Float', 'Lister', 'Number', 'Ordered', 'Pager', 'Querier', 'Queryable', 'Text', 'Timeout'] + ); + expect(supertypeRefs(result)).toEqual([ + 'Pager extends Lister', + 'Querier extends Closer', + 'Querier extends LabelQuerier', + 'Querier extends Queryable', + ]); + expectRefsOnTheirNames(result, source); + // The interfaces' own methods are still its members, the embeddings are not. + const querier = result.nodes.find((n) => n.name === 'Querier')!; + const members = result.edges + .filter((e) => e.kind === 'contains' && e.source === querier.id) + .map((e) => result.nodes.find((n) => n.id === e.target)?.name); + expect(members).toEqual(['Select']); + }); + + it.each(backends)(`gin's IRouter extends IRoutes: %s${label}`, (backend) => { + const result = extract(backend, 'routergroup.go', eol(GIN_SOURCE)); + expect(supertypeRefs(result)).toEqual(['IRouter extends IRoutes']); + }); + + it.each(backends)(`an embedded struct field of any shape extends: %s${label}`, (backend) => { + const source = eol(STRUCT_SOURCE); + const result = extract(backend, 'store/store.go', source); + expect(supertypeRefs(result)).toEqual([ + 'Store extends Base', + 'Store extends Engine', + 'Store extends List', + 'Store extends Mutex', + 'Store extends Reader', + ]); + expectRefsOnTheirNames(result, source); + }); + } +}); + +describe('the type hierarchy of an indexed Go module', () => { + let root = ''; + let cg: CodeGraph | undefined; + + afterEach(() => { + cg?.destroy(); + cg = undefined; + if (root) fs.rmSync(root, { recursive: true, force: true }); + root = ''; + }); + + it('links embedded interfaces and structs to the package that declares them', async () => { + root = fs.mkdtempSync(path.join(os.tmpdir(), 'cg-go-embedding-')); + const files: Record = { + 'go.mod': 'module example.com/app\n\ngo 1.22\n', + 'storage/storage.go': [ + 'package storage', + '', + 'type LabelQuerier interface {', + '\tLabelValues(name string) []string', + '}', + '', + 'type Base struct{}', + '', + 'func (b *Base) Close() error { return nil }', + '', + ].join('\n'), + // Named like io.Closer, io.Reader, sync.Mutex and yaml.Node: never what those name. + 'other/other.go': [ + 'package other', + '', + 'type Closer interface{ Close() error }', + '', + 'type Mutex struct{}', + '', + 'type Reader interface{ Read(p []byte) (int, error) }', + '', + 'type Node struct{}', + '', + ].join('\n'), + 'store/store.go': [ + 'package store', + '', + 'import (', + '\t"io"', + '\t"sync"', + '', + '\t"example.com/app/storage"', + '\t"go.yaml.in/yaml/v3"', + ')', + '', + '// The package is yaml, though the index knows its import as v3.', + 'type RuleGroupNode struct {', + '\tyaml.Node', + '\tName string', + '}', + '', + 'type Queryable interface{ Q() }', + '', + 'type Querier interface {', + '\tQueryable', + '\tio.Closer', + '\tstorage.LabelQuerier', + '\tSelect(sorted bool) int', + '}', + '', + 'type Number interface {', + '\t~int | ~float64', + '}', + '', + 'type Engine struct{}', + '', + 'type Reader interface{ Read() }', + '', + 'type List[T any] struct{ items []T }', + '', + 'type Store struct {', + '\t*Engine', + '\tReader', + '\tsync.Mutex', + '\t*storage.Base', + '\tList[int]', + '}', + '', + ].join('\n'), + // Named like the io.Reader it embeds. + 'wrap/wrap.go': [ + 'package wrap', + '', + 'import "io"', + '', + 'type Reader interface {', + '\tio.Reader', + '\tPeek() byte', + '}', + '', + ].join('\n'), + 'gin/routergroup.go': GIN_SOURCE, + // prometheus's promql/parser: its own `Node` interface, while other/ has a `Node` struct. + 'parser/ast.go': [ + 'package parser', + '', + 'type Node interface{ String() string }', + '', + 'type Expr interface {', + '\tNode', + '\tType() string', + '}', + '', + 'type exprWrapper struct {', + '\tNode', + '}', + '', + ].join('\n'), + }; + for (const [rel, content] of Object.entries(files)) { + fs.mkdirSync(path.dirname(path.join(root, rel)), { recursive: true }); + fs.writeFileSync(path.join(root, rel), content); + } + cg = await CodeGraph.init(root, { index: true }); + const graph = cg; + + const one = (name: string, file: string): Node => { + const found = graph.getNodesByName(name).filter((n) => n.filePath === file && n.kind !== 'import'); + expect(found, `${name} in ${file}`).toHaveLength(1); + return found[0]!; + }; + const where = (n: Node | null) => `${n?.name}@${n?.filePath}`; + + const sources = [ + one('Querier', 'store/store.go'), + one('Number', 'store/store.go'), + one('Store', 'store/store.go'), + one('RuleGroupNode', 'store/store.go'), + one('Reader', 'wrap/wrap.go'), + one('IRouter', 'gin/routergroup.go'), + one('Expr', 'parser/ast.go'), + one('exprWrapper', 'parser/ast.go'), + ]; + const supertypes = graph + .getOutgoingEdgesFrom(sources.map((n) => n.id), ['extends', 'implements']) + .map((e) => `${graph.getNode(e.source)?.name} ${e.kind} ${where(graph.getNode(e.target))}`) + .sort(); + // An embedded interface of a struct resolves as `implements`; one of an + // interface stays `extends`. io.Closer, sync.Mutex, io.Reader and yaml.Node + // are outside the project, so nothing named like them is linked. A bare + // name is its own package's type, whatever kind another package's is. + expect(supertypes).toEqual([ + 'Expr extends Node@parser/ast.go', + 'IRouter extends IRoutes@gin/routergroup.go', + 'Querier extends LabelQuerier@storage/storage.go', + 'Querier extends Queryable@store/store.go', + 'Store extends Base@storage/storage.go', + 'Store extends Engine@store/store.go', + 'Store extends List@store/store.go', + 'Store implements Reader@store/store.go', + 'exprWrapper implements Node@parser/ast.go', + ]); + + // The outside supertypes are kept as unresolved references, not dropped. + const unresolved = (n: Node) => + graph + .getUnresolvedReferencesFrom(n.id) + .filter((r) => r.referenceKind === 'extends') + .map((r) => r.referenceName) + .sort(); + expect(unresolved(one('Querier', 'store/store.go'))).toEqual(['Closer']); + expect(unresolved(one('Store', 'store/store.go'))).toEqual(['Mutex']); + expect(unresolved(one('RuleGroupNode', 'store/store.go'))).toEqual(['Node']); + expect(unresolved(one('Reader', 'wrap/wrap.go'))).toEqual(['Reader']); + + // What the viewer's Type hierarchy and codegraph_explore read. Declared + // subtypes only: Go's implicit-satisfaction edges are synthesized from + // method names (Base's Close() satisfies other.Closer) and drawn apart. + const subtypes = (n: Node) => + (buildTypeHierarchy(graph, n)?.descendants ?? []) + .filter((d) => !d.synthesized) + .map((d) => `${d.relation} ${where(d.node)}`); + expect(subtypes(one('IRoutes', 'gin/routergroup.go'))).toEqual(['extends IRouter@gin/routergroup.go']); + expect(subtypes(one('LabelQuerier', 'storage/storage.go'))).toEqual(['extends Querier@store/store.go']); + expect(subtypes(one('Base', 'storage/storage.go'))).toEqual(['extends Store@store/store.go']); + const declaredInto = graph + .getIncomingEdgesTo(['Closer', 'Mutex', 'Reader', 'Node'].map((name) => one(name, 'other/other.go').id), ['extends', 'implements']) + .filter((e) => e.provenance !== 'heuristic'); + expect(declaredInto).toEqual([]); + expect( + (buildTypeHierarchy(graph, one('IRouter', 'gin/routergroup.go'))?.ancestors ?? []).map((a) => `${a.relation} ${where(a.node)}`) + ).toEqual(['extends IRoutes@gin/routergroup.go']); + }, 60_000); +}); diff --git a/codegraph-kernel/src/go.rs b/codegraph-kernel/src/go.rs index 239c788a6..efc67971b 100644 --- a/codegraph-kernel/src/go.rs +++ b/codegraph-kernel/src/go.rs @@ -4,8 +4,8 @@ //! Go's shape quirks, mirrored exactly: methods are top-level with a receiver //! (qualifiedName override `Recv::name` + a contains edge to the FIRST //! earlier-in-file struct of that name), structs/interfaces arrive as -//! `type_spec` and classify via the inner type node (struct embedding → -//! extends; interface method_elems become method nodes), composite literals +//! `type_spec` and classify via the inner type node (struct and interface +//! embedding → extends; interface method_elems become method nodes), composite literals //! (`pkga.Widget{}`) keep their package qualifier as `instantiates` refs, //! top-level var/const specs walk their initializers ATTRIBUTED to the //! declared symbol (#693), 2-hop field chains (`t.conn.Exec`) keep the chain @@ -846,20 +846,42 @@ impl<'t> Walker<'t> { } } - /// extractInheritance — the Go branches: interface embedding - /// (constraint_elem) and struct embedding (field_declaration without a - /// field_identifier), plus the field_declaration_list recursion. + /// goEmbeddedTypeName (languages/go.ts): the name node of the type an + /// embedding names — `T`, `*T` (the `*` is a sibling token), `pkg.T` and + /// `T[X]` all embed `T`. A qualified type yields its name; resolution reads + /// the package back from the source at that position. None for any other + /// type and for a predeclared one. + fn embedded_type_name(&self, ty: Option>) -> Option> { + let mut ty = ty?; + if ty.kind() == "generic_type" { + ty = ty.child_by_field_name("type")?; + } + match ty.kind() { + "qualified_type" => ty.child_by_field_name("name"), + "type_identifier" if !is_go_predeclared_type(self.text(ty)) => Some(ty), + _ => None, + } + } + + /// extractInheritance — the Go branches: interface embedding (a type_elem + /// holding one named type; a union, `~T` or basic type is a constraint) + /// and struct embedding (field_declaration without a field_identifier), + /// plus the field_declaration_list recursion. fn extract_inheritance(&mut self, node: Node<'t>, class_row: u32) { stack_guard!(); let extends_kind = edge_kind_index("extends").unwrap(); for i in 0..node.named_child_count() { let Some(child) = node.named_child(i) else { continue }; match child.kind() { - "constraint_elem" => { - let type_id = (0..child.named_child_count()) + "type_elem" => { + let mut terms = (0..child.named_child_count()) .filter_map(|j| child.named_child(j)) - .find(|c| c.kind() == "type_identifier"); - if let Some(type_id) = type_id { + .filter(|c| c.kind() != "comment"); + let only = match (terms.next(), terms.next()) { + (Some(term), None) => Some(term), + _ => None, + }; + if let Some(type_id) = self.embedded_type_name(only) { let name = self.text(type_id).to_string(); self.push_ref_at(class_row, &name, extends_kind, type_id); } @@ -869,10 +891,7 @@ impl<'t> Walker<'t> { .filter_map(|j| child.named_child(j)) .any(|c| c.kind() == "field_identifier"); if !has_field_identifier { - let type_id = (0..child.named_child_count()) - .filter_map(|j| child.named_child(j)) - .find(|c| c.kind() == "type_identifier"); - if let Some(type_id) = type_id { + if let Some(type_id) = self.embedded_type_name(child.child_by_field_name("type")) { let name = self.text(type_id).to_string(); self.push_ref_at(class_row, &name, extends_kind, type_id); } @@ -1253,6 +1272,17 @@ fn is_builtin_type(name: &str) -> bool { ) } +/// GO_PREDECLARED_TYPES (languages/go.ts): never a node in the graph, and as +/// the lone term of an interface a basic type is a constraint, not an embedding. +fn is_go_predeclared_type(name: &str) -> bool { + matches!( + name, + "any" | "bool" | "byte" | "comparable" | "complex64" | "complex128" | "error" | "float32" + | "float64" | "int" | "int8" | "int16" | "int32" | "int64" | "rune" | "string" | "uint" + | "uint8" | "uint16" | "uint32" | "uint64" | "uintptr" + ) +} + fn opt_str(arena: &mut Arena, s: Option<&str>) -> StrRef { match s { Some(s) => arena.put(s), diff --git a/src/extraction/languages/go.ts b/src/extraction/languages/go.ts index 5e41fbfaa..1bad7ff22 100644 --- a/src/extraction/languages/go.ts +++ b/src/extraction/languages/go.ts @@ -38,6 +38,32 @@ function extractGoReturnType(node: SyntaxNode, source: string): string | undefin return last; } +/** + * Go's predeclared types. None is ever a node in the graph, and as the lone + * term of an interface (`interface{ int64 }`) a basic type is a type-set + * constraint, not an embedded interface. + */ +const GO_PREDECLARED_TYPES: ReadonlySet = new Set([ + 'any', 'bool', 'byte', 'comparable', 'complex64', 'complex128', 'error', 'float32', 'float64', + 'int', 'int8', 'int16', 'int32', 'int64', 'rune', 'string', 'uint', 'uint8', 'uint16', 'uint32', + 'uint64', 'uintptr', +]); + +/** + * The name node of the type a Go embedding names, in a struct or an interface: + * `Base`, `*Base` (the `*` is a sibling token), `pkg.Base` and `Base[T]` all + * embed `Base`. A qualified type yields its name, not its package: the package + * stays in the source text, and resolution reads it back from the reference's + * position (#2322). Undefined for any other type (a literal, `~T`, a union's + * term) and for a predeclared one. + */ +export function goEmbeddedTypeName(type: SyntaxNode | null | undefined, source: string): SyntaxNode | undefined { + if (type?.type === 'generic_type') type = getChildByField(type, 'type'); + if (type?.type === 'qualified_type') return getChildByField(type, 'name') ?? undefined; + if (type?.type !== 'type_identifier') return undefined; + return GO_PREDECLARED_TYPES.has(getNodeText(type, source)) ? undefined : type; +} + export const goExtractor: LanguageExtractor = { functionTypes: ['function_declaration'], classTypes: [], // Go doesn't have classes diff --git a/src/extraction/tree-sitter.ts b/src/extraction/tree-sitter.ts index 9ea465cad..997d73517 100644 --- a/src/extraction/tree-sitter.ts +++ b/src/extraction/tree-sitter.ts @@ -23,6 +23,7 @@ import type { LanguageExtractor, ExtractorContext } from './tree-sitter-types'; import { EXTRACTORS } from './languages'; import { stripCppTemplateArgs, isCppConstructorDeclaration } from './languages/c-cpp'; import { rustImplTypeName } from './languages/rust'; +import { goEmbeddedTypeName } from './languages/go'; import { dartMisparsedGenericCall, dartReceiverOf, isDartTypeName, pushDartTypeRefs } from './languages/dart'; import { LiquidExtractor } from './liquid-extractor'; import { RazorExtractor } from './razor-extractor'; @@ -3795,7 +3796,7 @@ export class TreeSitterExtractor { * contained by the interface (e.g. `Marshal`, `Unmarshal` of a `Core` * interface). tree-sitter-go names these `method_elem` (newer) or * `method_spec` (older). Embedded interfaces (`Reader` inside `ReadWriter`) - * are `type_identifier`s, not methods, and are left to inheritance extraction. + * are `type_elem`s, not methods, and are left to inheritance extraction. */ private extractGoInterfaceMethods(interfaceType: SyntaxNode, ifaceId: string): void { this.nodeStack.push(ifaceId); @@ -6984,15 +6985,18 @@ export class TreeSitterExtractor { } } - // Go interface embedding: `type Querier interface { LabelQuerier; ... }` - // constraint_elem wraps the embedded interface type identifier - if (child.type === 'constraint_elem') { - const typeId = child.namedChildren.find((c: SyntaxNode) => c.type === 'type_identifier'); + // Go interface embedding: `type IRouter interface { IRoutes; Group(…) }`. + // tree-sitter-go parses every element of an interface that is not a + // method as a `type_elem`; one holding a single named type embeds that + // interface. A union (`float32 | float64`), an underlying type (`~int`) + // or a lone basic type (`int64`) is a type-set constraint instead. + if (this.language === 'go' && child.type === 'type_elem') { + const terms = child.namedChildren.filter((c: SyntaxNode) => c.type !== 'comment'); + const typeId = terms.length === 1 ? goEmbeddedTypeName(terms[0], this.source) : undefined; if (typeId) { - const name = getNodeText(typeId, this.source); this.unresolvedReferences.push({ fromNodeId: classId, - referenceName: name, + referenceName: getNodeText(typeId, this.source), referenceKind: 'extends', line: typeId.startPosition.row + 1, column: typeId.startPosition.column, @@ -7001,25 +7005,23 @@ export class TreeSitterExtractor { } // Go struct embedding: field_declaration without field_identifier - // e.g. `type DB struct { *Head; Queryable }` — no field name means embedded type. + // e.g. `type DB struct { *Head; Queryable; sync.Mutex }` — no field name + // means an embedded type, read the way an interface's is. // Go only: C, C++ and Objective-C members are field_declarations too, but // nest their name inside the declarator (`Cache* cache_;`, `jv elems[];`, // `Status Get(int);`), so this test read every such member's type as a // supertype. Their supertypes come from base_class_clause / superclass. if (this.language === 'go' && child.type === 'field_declaration') { const hasFieldIdentifier = child.namedChildren.some((c: SyntaxNode) => c.type === 'field_identifier'); - if (!hasFieldIdentifier) { - const typeId = child.namedChildren.find((c: SyntaxNode) => c.type === 'type_identifier'); - if (typeId) { - const name = getNodeText(typeId, this.source); - this.unresolvedReferences.push({ - fromNodeId: classId, - referenceName: name, - referenceKind: 'extends', - line: typeId.startPosition.row + 1, - column: typeId.startPosition.column, - }); - } + const typeId = hasFieldIdentifier ? undefined : goEmbeddedTypeName(getChildByField(child, 'type'), this.source); + if (typeId) { + this.unresolvedReferences.push({ + fromNodeId: classId, + referenceName: getNodeText(typeId, this.source), + referenceKind: 'extends', + line: typeId.startPosition.row + 1, + column: typeId.startPosition.column, + }); } } diff --git a/src/resolution/frameworks/go.ts b/src/resolution/frameworks/go.ts index 5e819060e..b90d20dea 100644 --- a/src/resolution/frameworks/go.ts +++ b/src/resolution/frameworks/go.ts @@ -26,6 +26,15 @@ export const goResolver: FrameworkResolver = { }, resolve(ref: UnresolvedRef, context: ResolutionContext): ResolvedRef | null { + // An embedded type is the one its package declares under that name: the + // same package's for `Node`, the import's for `pkg.Node`, which name + // matching and the import resolver work out. The patterns below pick among + // every same-named node by folder convention, and Pattern 4 sees structs + // only: `Node` embedded beside promql/parser's `type Node interface` went + // to discovery/kubernetes's struct `Node`, etcd's `Client` embedding its + // own `Lease` interface to the server's `Lease` struct. + if (ref.referenceKind === 'extends' || ref.referenceKind === 'implements') return null; + // Pattern 1: Handler references if (ref.referenceName.endsWith('Handler') || ref.referenceName.startsWith('Handle')) { const result = resolveByNameAndKind(ref, 'function', HANDLER_DIRS, context); diff --git a/src/resolution/index.ts b/src/resolution/index.ts index 5da83c0a2..bf0917ad7 100644 --- a/src/resolution/index.ts +++ b/src/resolution/index.ts @@ -21,7 +21,7 @@ import { isImportableKind, CPP_DEFINE_SIGNATURE, } from './types'; -import { isPythonSelfCall, matchJsStoreBindingCall, isUnresolvedJsMemberCall, matchObjectPathCall, thisScopeCaller, isVisibleAcrossFiles, matchReference, matchFunctionRef, matchDottedCallChain, matchScopedCallChain, matchMethodCall, sameLanguageFamily, crossesCodeBoundary, gateLanguageMatch, dumpNameMatcherProfile, clearNameMatcherMemos, isRustNameInScope, CASE_INSENSITIVE_LANGUAGES, isDartMemberRead, matchDartMemberRead, isDartChainLink, matchDartChainLink, isDartAnnotation, matchDartAnnotation, isStdMethodName } from './name-matcher'; +import { isPythonSelfCall, matchJsStoreBindingCall, isUnresolvedJsMemberCall, matchObjectPathCall, thisScopeCaller, isVisibleAcrossFiles, matchReference, matchFunctionRef, matchDottedCallChain, matchScopedCallChain, matchMethodCall, sameLanguageFamily, crossesCodeBoundary, gateLanguageMatch, dumpNameMatcherProfile, clearNameMatcherMemos, isRustNameInScope, CASE_INSENSITIVE_LANGUAGES, isDartMemberRead, matchDartMemberRead, isDartChainLink, matchDartChainLink, isDartAnnotation, matchDartAnnotation, isStdMethodName, isGoUnknownQualified } from './name-matcher'; import { isVisibleCppMacro, clearCppMacroVisibility } from './cpp-macro-visibility'; import { isCppConstructorRef, matchCppConstructor } from './cpp-constructor'; import { gateSwiftTypeTarget, clearSwiftTypeVisibility, swiftExtendedConformances } from './swift-type-visibility'; @@ -3056,6 +3056,11 @@ export class ReferenceResolver { result = { ...result, targetNodeId: type.id }; } if (isBoundToOutOfRepoImport(ref, this.context)) return null; + // A Go type embedded through a package (`yaml.Node`) is that package's. + // When the package is none of the file's imports as indexed, a type found + // by its bare name is some other package's namesake: prometheus's + // `RuleGroupNode` embeds yaml's `Node`, not discovery/kubernetes's. + if (ref.language === 'go' && isGoUnknownQualified(ref, this.context)) return null; return result; } diff --git a/src/resolution/name-matcher.ts b/src/resolution/name-matcher.ts index d86047ebd..794d2af01 100644 --- a/src/resolution/name-matcher.ts +++ b/src/resolution/name-matcher.ts @@ -1883,7 +1883,35 @@ function rustModuleDir(filePath: string): string { return path.posix.join(dir, base.replace(/\.rs$/, '')); } -const GO_QUALIFIERS = new WeakMap>(); +interface GoQualification { + /** The package name written before the reference's name, as spelled. */ + written?: string; + /** The file's import that name is. */ + imported?: ImportMapping; +} + +const GO_QUALIFIERS = new WeakMap>(); + +function goRefQualification(ref: UnresolvedRef, context: ResolutionContext): GoQualification { + if (ref.referenceKind === 'imports') return {}; + const name = ref.referenceName.split('.').pop()!; + if (!/^[A-Za-z_]\w*$/.test(name)) return {}; + let memo = GO_QUALIFIERS.get(context); + if (!memo) GO_QUALIFIERS.set(context, (memo = new Map())); + const key = `${ref.filePath}\0${ref.line}\0${ref.column}\0${ref.referenceName}`; + const hit = memo.get(key); + if (hit !== undefined) return hit; + const line = context.getFileLines?.(ref.filePath)?.[ref.line - 1] ?? context.readFile(ref.filePath)?.split(/\r?\n/)[ref.line - 1] ?? ''; + const at = Math.max(0, ref.column); + // The qualifier right before the name at the reference's column, or the + // line's only spelling of the name. + const written = line.startsWith(name, at) ? /(?:^|[^\w.])([A-Za-z_]\w*)\.$/.exec(line.slice(0, at))?.[1] + : !new RegExp(`(? m.localName === written) : undefined; + const found = { written, imported }; + memo.set(key, found); + return found; +} /** * The import a Go reference is written through — `context` in @@ -1892,23 +1920,18 @@ const GO_QUALIFIERS = new WeakMap m.localName === before) : undefined; - memo.set(key, imported ?? null); - return imported; + return goRefQualification(ref, context).imported; +} + +/** + * Whether a Go reference is written through a package that is none of its + * file's imports as the index knows them: `yaml` in `yaml.Node` under an + * unaliased `import "go.yaml.in/yaml/v3"`, which is recorded under its last + * path element, `v3`. Which package that is cannot be told from here. + */ +export function isGoUnknownQualified(ref: UnresolvedRef, context: ResolutionContext): boolean { + const { written, imported } = goRefQualification(ref, context); + return written !== undefined && imported === undefined; } /**