From 78c80790b052b46ef5d0f08652217ad2e49c9f92 Mon Sep 17 00:00:00 2001 From: Colby McHenry Date: Wed, 7 Oct 2026 00:53:27 -0500 Subject: [PATCH 1/3] fix(go): a type alias `type A = B` is indexed and links to the type it names MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit tree-sitter-go parses `type Event = mvccpb.Event` as a `type_alias`, beside the `type_spec` of a defined type, and both extractors read `type_spec` only. etcd's client/v3 `Event` had no node; a use through its package (`clientv3.Event`, prometheus's `storage.AOptions{…}`) linked to nothing, and a use inside it linked by name to any type so named, another package's included. - Extraction (TS and kernel): an alias is a `type_alias` node referencing each type its right-hand side names, at the name so resolution reads the package back; an alias of a struct or interface literal is a struct or interface. The alias's own type parameters and Go's predeclared types are skipped. A generic alias, which tree-sitter-go 0.23 parses as a `type_spec` around an error, reads as an alias on wasm; the kernel defers its file. - A method called on an alias-typed value is the aliased type's: an alias of a project type is followed in its own package, through pointers and further aliases; one of an outside type resolves by name as before. - An alias's target written through a package that is none of the file's imports as indexed stays unresolved, as an embedded type's does, so the alias never links to itself or a namesake. Co-Authored-By: Claude Opus 5.5 --- CHANGELOG.md | 1 + __tests__/fixtures/kernel-parity/torture.go | 26 ++ __tests__/go-type-alias.test.ts | 367 ++++++++++++++++++++ codegraph-kernel/src/go.rs | 60 +++- src/extraction/languages/go.ts | 39 ++- src/extraction/tree-sitter.ts | 18 +- src/resolution/index.ts | 6 + src/resolution/name-matcher.ts | 55 ++- 8 files changed, 561 insertions(+), 11 deletions(-) create mode 100644 __tests__/go-type-alias.test.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index de748bde7..a0772f0a1 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -70,6 +70,7 @@ and adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). - 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. - In Go, working out which structs implement an interface now counts the methods of the interfaces it embeds, and the methods a struct gets from the types it embeds. Before, only each type's own methods counted: etcd's `AuthReadTx`, which embeds `UnsafeAuthReader` and adds `RLock` and `RUnlock`, was listed as implemented by every read-write lock in the project, an interface that only embeds others had no implementations at all, and a struct that gets its methods from an embedded base, like prometheus's service discoveries embedding `refresh.Discovery` or gin's `Engine` embedding `RouterGroup`, was missing. `codegraph_explore`, impact and the type hierarchy now list the right implementations, and a call through the interface reaches a method the embedding struct overrides, like gin's `Engine.Use`. Re-index Go projects after upgrading. +- In Go, a type alias like `type Event = mvccpb.Event`, alone or inside a `type ( … )` group, is now indexed and links to the type it names, so impact on that type reaches the code that uses the alias. Before, aliases were missing: a use written through the alias's package, like `clientv3.Event` or prometheus's `storage.AOptions{…}`, linked to nothing, and one inside the package, like `&PrometheusVersion{}` in prometheus's `web` package, linked to whichever type of that name the project had, there a test helper's struct. A method called on a value of an alias's type now reaches the aliased type's method rather than a namesake's, and a generic alias like `type Items[T any] = List[T]` links to `List`, so `xs.Len()` on one reaches `List.Len` instead of nothing. Re-index Go projects after upgrading. - In Angular templates, a property binding, an interpolation, a structural directive or a control-flow block that calls one of the component's own members, like `[name]="icon()"`, `{{ label() }}`, `*ngIf="isOpen()"` or `@if (loading()) {`, now links the component to that member, and so does reading a getter, like `[disabled]="!canSave"`, or handing a method to a child component, like `[displayWith]="displayFn"` or `trackBy: trackById`. Before, only event bindings such as `(click)="save()"` were read, so a signal, a `computed` value, a getter or a method used only from a template had no callers and looked unused. A pipe, a template variable, a call on another object such as `form.reset()`, and a plain field read like `[value]="title"` still link nothing. Re-index Angular projects after upgrading. - React Router routes kept in a table of their own are now indexed: the ASP.NET Core React template's `AppRoutes` array that `App.js` maps into ``, a `routes` array imported into `useRoutes(routes)` or `createBrowserRouter(routes)` or returned by a function as in `useRoutes(routes(isLoggedIn))`, route objects kept one per file and listed in `createBrowserRouter([MainRoutes, LoginRoutes])`, and routes written in place in `useRoutes([…])`. Before, route objects were read only from a file that itself creates a data router, so these apps had no routes, and their `navigate('/login')` calls and `` links led nowhere. Each route links to the page its `element` renders, past a guard like `` or ``, and through `lazy(() => import(…))` to the page it loads rather than a same-named page elsewhere in the repository. An `index: true` route is the page at its parent's address, and a parent route around others counts as their layout. A `{ path, element }` list that nothing hands to the router, such as a menu, still makes no routes. Re-index React projects after upgrading. diff --git a/__tests__/fixtures/kernel-parity/torture.go b/__tests__/fixtures/kernel-parity/torture.go index 3aada94af..9e1070456 100644 --- a/__tests__/fixtures/kernel-parity/torture.go +++ b/__tests__/fixtures/kernel-parity/torture.go @@ -45,6 +45,32 @@ type Exact interface{ int64 } type Dur int +// Aliases (`=`): the types they name are references; a literal makes a struct or interface. +type Alias = pkga.Widget + +type ( + LocalAlias = Widget + PtrAlias = *Stack[int] + FnAlias = func(w Widget) error + MapAlias = map[string][]pkga.Item + Defined Widget +) + +type AnonAlias = struct { + *Base + n int +} + +type IfaceAlias = interface { + Render() string +} + +type WordAlias = uint + +func useAlias(a *Alias) LocalAlias { + return LocalAlias{} +} + func NewRegistry() *Registry { w := Widget{name: "w"} q := pkga.Widget{} diff --git a/__tests__/go-type-alias.test.ts b/__tests__/go-type-alias.test.ts new file mode 100644 index 000000000..285236314 --- /dev/null +++ b/__tests__/go-type-alias.test.ts @@ -0,0 +1,367 @@ +/** + * A Go alias declaration (`type Event = mvccpb.Event`, `type ( A = B )`) was + * in neither extractor's graph. tree-sitter-go parses it as a `type_alias` + * beside the `type_spec` of a defined type (`type WatchChan <-chan + * WatchResponse`), and both extractors only read `type_spec`, so etcd's + * client/v3 `Event` had no node while the line below it did. + * + * An alias is now a node of the kind a declaration of its type gets: a + * `type_alias` that references the types it names, a `struct` or an + * `interface` when it names a struct or interface literal. Each reference sits + * on the type's name, where resolution reads a package qualifier back. A + * generic alias (`type Set[T any] = …`, Go 1.24) has no rule in this grammar: + * it parses as a `type_spec` around an error, so the kernel hands the file to + * the wasm extractor, which reads it as an alias too, without its type + * parameters. + * + * Resolution treats the alias as the type it names. A bare `Event{}` or + * `*Event` in its package links to the alias, a method called on an `*Event` + * is the aliased type's method, and an alias whose target is written through + * a package the index doesn't know (`yaml` under `go.yaml.in/yaml/v3`) never + * links to itself or to a namesake. + */ +import { describe, it, expect, beforeAll, beforeEach, afterEach, afterAll } 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 type { ExtractionResult } 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'; + +/** Every alias shape the grammar has a rule for, beside defined types. */ +const ALIAS_SOURCE = `package clientv3 + +import ( + "context" + + "go.etcd.io/etcd/api/v3/mvccpb" +) + +// Event is the event a watcher reports. +type Event = mvccpb.Event + +type WatchChan <-chan WatchResponse + +type WatchResponse struct { + Events []*Event +} + +type ( + // Local names a type of this package. + Local = WatchResponse + Ptr = *WatchResponse + Slice = []WatchResponse + Handler = func(ctx context.Context, r WatchResponse) error + Index = map[string]*mvccpb.KeyValue + Defined WatchResponse +) + +type Plain = List[WatchResponse] + +type Anon = struct { + Key string +} + +type Closer = interface { + Close() error +} + +type Type = string + +type Word = uint +`; + +/** Generic aliases: no grammar rule, so a parse error the kernel defers. */ +const GENERIC_SOURCE = `package clientv3 + +type List[T any] struct { + items []T +} + +// Set is a set of comparable values. +type Set[T comparable] = map[T]struct{} + +type Items[T any] = List[T] + +type Pairs[K comparable, V any] = map[K]List[V] +`; + +const ENV_KEYS = ['CODEGRAPH_KERNEL', 'CODEGRAPH_KERNEL_LANGS'] as const; + +/** ` ` of every node but the file and its imports, in source order. */ +function declarations(result: ExtractionResult): string[] { + return result.nodes.filter((n) => n.kind !== 'file' && n.kind !== 'import').map((n) => `${n.kind} ${n.name}`); +} + +/** ` ` for every `references` ref out of a type declaration, sorted. */ +function typeRefs(result: ExtractionResult): string[] { + const byId = new Map(result.nodes.map((n) => [n.id, n])); + return result.unresolvedReferences + .filter((r) => r.referenceKind === 'references' && ['type_alias', 'struct', 'interface'].includes(byId.get(r.fromNodeId)?.kind ?? '')) + .map((r) => `${byId.get(r.fromNodeId)!.name} ${r.referenceName}`) + .sort(); +} + +describe('Go alias declarations are extracted', () => { + 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!; + } + + /** Each type 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 === 'references'); + 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 alias is a node of its type's kind: %s${label}`, (backend) => { + const result = extract(backend, 'client/v3/watch.go', eol(ALIAS_SOURCE)); + expect(declarations(result)).toEqual([ + 'type_alias Event', + 'type_alias WatchChan', + 'struct WatchResponse', + 'type_alias Local', + 'type_alias Ptr', + 'type_alias Slice', + 'type_alias Handler', + 'type_alias Index', + 'type_alias Defined', + 'type_alias Plain', + 'struct Anon', + 'interface Closer', + 'method Close', + 'type_alias Type', + 'type_alias Word', + ]); + const event = result.nodes.find((n) => n.name === 'Event')!; + expect(event.isExported).toBe(true); + expect(event.startLine).toBe(10); + // A doc comment is read as for any type declaration: inside a group. + expect(result.nodes.find((n) => n.name === 'Local')!.docstring).toBe('Local names a type of this package.'); + // Nested under the file, as a defined type is. + const file = result.nodes.find((n) => n.kind === 'file')!; + expect(result.edges.some((e) => e.kind === 'contains' && e.source === file.id && e.target === event.id)).toBe(true); + }); + + it.each(backends)(`an alias references the types it names: %s${label}`, (backend) => { + const source = eol(ALIAS_SOURCE); + const result = extract(backend, 'client/v3/watch.go', source); + // The package of mvccpb.Event / context.Context stays in the source; + // predeclared types (string, uint, error) are no references. + expect(typeRefs(result)).toEqual([ + 'Event Event', + 'Handler Context', + 'Handler WatchResponse', + 'Index KeyValue', + 'Local WatchResponse', + 'Plain List', + 'Plain WatchResponse', + 'Ptr WatchResponse', + 'Slice WatchResponse', + ]); + expectRefsOnTheirNames(result, source); + }); + } + + it.each([false, true])('a generic alias is an alias without its type parameters (wasm; CRLF: %s)', (crlf) => { + const source = crlf ? GENERIC_SOURCE.replace(/\n/g, '\r\n') : GENERIC_SOURCE; + if (kernelAvailable) { + delete process.env.CODEGRAPH_KERNEL; + process.env.CODEGRAPH_KERNEL_LANGS = 'all'; + // No grammar rule: a parse error, which the kernel leaves to wasm. + expect(tryKernelExtract('client/v3/set.go', source, 'go')).toBeNull(); + } + const result = extract('wasm', 'client/v3/set.go', source); + expect(declarations(result)).toEqual(['struct List', 'type_alias Set', 'type_alias Items', 'type_alias Pairs']); + expect(result.nodes.find((n) => n.name === 'Set')!.isExported).toBe(true); + expect(typeRefs(result)).toEqual(['Items List', 'Pairs List']); + expectRefsOnTheirNames(result, source); + }); +}); + +describe('an indexed Go module resolves through its aliases', () => { + let root = ''; + let cg: CodeGraph; + + beforeAll(async () => { + root = fs.mkdtempSync(path.join(os.tmpdir(), 'cg-go-alias-')); + const files: Record = { + 'go.mod': 'module go.etcd.io/etcd\n\ngo 1.24\n', + 'api/v3/mvccpb/kv.go': [ + 'package mvccpb', + '', + 'type Event struct {', + '\tType int', + '}', + '', + 'func (e *Event) IsCreate() bool { return e.Type == 0 }', + '', + ].join('\n'), + // A namesake package that sorts first, so a lookup by name meets its + // `Event` and `Event::IsCreate` before mvccpb's. + 'alpha/event.go': [ + 'package alpha', + '', + 'type Event struct{}', + '', + 'func (e *Event) IsCreate() bool { return false }', + '', + 'type Node struct{}', + '', + ].join('\n'), + 'client/v3/watch.go': [ + 'package clientv3', + '', + 'import "go.etcd.io/etcd/api/v3/mvccpb"', + '', + 'type Event = mvccpb.Event', + '', + 'type WatchResponse struct {', + '\tEvents []*Event', + '}', + '', + 'func (wr *WatchResponse) Err() error { return nil }', + '', + 'type (', + '\tLocal = WatchResponse', + '\tPtr = *Local', + ')', + '', + 'func newEvent() Event { return Event{} }', + '', + 'func isCreate(e *Event) bool { return e.IsCreate() }', + '', + 'func check(p Ptr) error { return p.Err() }', + '', + ].join('\n'), + // A generic alias: the file parses with an error and goes to wasm. + 'client/v3/list.go': [ + 'package clientv3', + '', + 'type List[T any] struct{ items []T }', + '', + 'func (l *List[T]) Len() int { return len(l.items) }', + '', + 'type Items[T any] = List[T]', + '', + 'func count(xs *Items[int]) int { return xs.Len() }', + '', + ].join('\n'), + 'yamlish/node.go': [ + 'package yamlish', + '', + 'import "go.yaml.in/yaml/v3"', + '', + '// The package is yaml, though the index knows its import as v3.', + 'type Node = yaml.Node', + '', + ].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 }); + }, 60_000); + + afterAll(() => { + cg?.destroy(); + if (root) fs.rmSync(root, { recursive: true, force: true }); + }); + + /** `kind file:qualifiedName` of each non-contains edge out of `name` in `file`, sorted. */ + const linksFrom = (file: string, name: string) => { + const sources = cg.getNodesInFile(file).filter((n) => n.name === name && n.kind !== 'import'); + expect(sources, `${name} in ${file}`).toHaveLength(1); + const links = cg.getOutgoingEdgesFrom(sources.map((n) => n.id)) + .filter((e) => e.kind !== 'contains') + .map((e) => ({ kind: e.kind, target: cg.getNode(e.target)! })) + .map(({ kind, target }) => `${kind} ${target.filePath}:${target.qualifiedName}`); + return [...new Set(links)].sort(); + }; + + it('an alias references the type it names, in the package it is written through', () => { + expect(linksFrom('client/v3/watch.go', 'Event')).toEqual(['references api/v3/mvccpb/kv.go:Event']); + expect(linksFrom('client/v3/watch.go', 'Local')).toEqual(['references client/v3/watch.go:WatchResponse']); + expect(linksFrom('client/v3/watch.go', 'Ptr')).toEqual(['references client/v3/watch.go:Local']); + expect(linksFrom('client/v3/list.go', 'Items')).toEqual(['references client/v3/list.go:List']); + }); + + it('a bare name in the alias package is the alias', () => { + expect(linksFrom('client/v3/watch.go', 'newEvent')).toEqual([ + 'instantiates client/v3/watch.go:Event', + 'references client/v3/watch.go:Event', + ]); + }); + + it('a method called on an alias is the aliased type’s', () => { + expect(linksFrom('client/v3/watch.go', 'isCreate')).toEqual([ + 'calls api/v3/mvccpb/kv.go:Event::IsCreate', + 'references client/v3/watch.go:Event', + ]); + // Through two aliases, one of a pointer. + expect(linksFrom('client/v3/watch.go', 'check')).toEqual([ + 'calls client/v3/watch.go:WatchResponse::Err', + 'references client/v3/watch.go:Ptr', + ]); + expect(linksFrom('client/v3/list.go', 'count')).toEqual([ + 'calls client/v3/list.go:List::Len', + 'references client/v3/list.go:Items', + ]); + }); + + it('an alias written through a package the index does not know links to nothing', () => { + expect(linksFrom('yamlish/node.go', 'Node')).toEqual([]); + const node = cg.getNodesInFile('yamlish/node.go').find((n) => n.name === 'Node')!; + expect(cg.getUnresolvedReferencesFrom(node.id).map((r) => `${r.referenceKind} ${r.referenceName}`)).toEqual(['references Node']); + }); +}); diff --git a/codegraph-kernel/src/go.rs b/codegraph-kernel/src/go.rs index efc67971b..329a00104 100644 --- a/codegraph-kernel/src/go.rs +++ b/codegraph-kernel/src/go.rs @@ -395,7 +395,7 @@ impl<'t> Walker<'t> { } else if kind == "method_declaration" { self.extract_method(node); skip_children = true; - } else if kind == "type_spec" { + } else if kind == "type_spec" || kind == "type_alias" { skip_children = self.extract_type_alias(node); } else if matches!(kind, "var_declaration" | "short_var_declaration" | "const_declaration") && !self.inside_class_like() @@ -529,7 +529,8 @@ impl<'t> Walker<'t> { self.stack.pop(); } - /// extractTypeAlias for Go: type_spec → struct / interface / plain alias. + /// extractTypeAlias for Go: type_spec (`type A B`) and type_alias + /// (`type A = B`) → struct / interface / plain alias. fn extract_type_alias(&mut self, node: Node<'t>) -> bool { stack_guard!(); let name = self.extract_name(node); @@ -582,14 +583,22 @@ impl<'t> Walker<'t> { return true; } - self.create_node( + let row = self.create_node( "type_alias", &name, node, Extra { docstring, is_exported, ..Extra::default() }, ); - // (go type_spec has no `value` field — no type-ref walk; TS/tsx member - // extraction is TS-family-only) + // (go has no `value` field — no TS-style type-ref walk or member + // extraction.) An alias references what its `type` field names; a + // defined type (`type_spec`) declares a type of its own. + if let Some(row) = row { + let references = edge_kind_index("references").unwrap(); + for ty in self.alias_type_names(node) { + let text = self.text(ty).to_string(); + self.push_ref_at(row, &text, references, ty); + } + } false } @@ -863,6 +872,47 @@ impl<'t> Walker<'t> { } } + /// goAliasTypeNames (languages/go.ts): the name nodes of the types an + /// alias's `type` field names, in source order, but its own type + /// parameters and the predeclared types. Only a `type_alias` is one here: + /// a generic alias, which tree-sitter-go 0.23 parses as a `type_spec` + /// around an error, never reaches the kernel (its file defers to wasm). + fn alias_type_names(&self, node: Node<'t>) -> Vec> { + let mut names = Vec::new(); + if node.kind() != "type_alias" { + return names; + } + let Some(ty) = node.child_by_field_name("type") else { return names }; + let mut params: HashSet<&str> = HashSet::new(); + if let Some(list) = node.child_by_field_name("type_parameters") { + for decl in (0..list.named_child_count()).filter_map(|i| list.named_child(i)) { + for c in (0..decl.named_child_count()).filter_map(|j| decl.named_child(j)) { + if c.kind() == "identifier" { + params.insert(self.text(c)); + } + } + } + } + self.collect_alias_type_names(ty, ¶ms, &mut names); + names + } + + fn collect_alias_type_names(&self, node: Node<'t>, params: &HashSet<&str>, out: &mut Vec>) { + stack_guard!(); + if node.kind() == "type_identifier" { + let text = self.text(node); + if !params.contains(text) && !is_go_predeclared_type(text) { + out.push(node); + } + return; + } + for i in 0..node.named_child_count() { + if let Some(c) = node.named_child(i) { + self.collect_alias_type_names(c, params, out); + } + } + } + /// 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), diff --git a/src/extraction/languages/go.ts b/src/extraction/languages/go.ts index 1bad7ff22..1ecbf8612 100644 --- a/src/extraction/languages/go.ts +++ b/src/extraction/languages/go.ts @@ -64,6 +64,40 @@ export function goEmbeddedTypeName(type: SyntaxNode | null | undefined, source: return GO_PREDECLARED_TYPES.has(getNodeText(type, source)) ? undefined : type; } +/** + * The name nodes of the types a Go alias declaration names, in source order: + * `Event` in `type Event = mvccpb.Event`, `List` and `Event` in `type Page = + * List[Event]`, each where it is written, so resolution reads a package + * qualifier back. The alias's own type parameters (`T` in `type Items[T any] + * = List[T]`) and Go's predeclared types are not types it names. A generic + * alias has no rule in tree-sitter-go 0.23: it parses as a `type_spec` whose + * `=` is an error, and reads as an alias here too. Undefined for a defined + * type (`type WatchChan <-chan Event`), which declares a type of its own. + */ +export function goAliasTypeNames(node: SyntaxNode, source: string): SyntaxNode[] | undefined { + const isAlias = node.type === 'type_alias' || + (node.type === 'type_spec' && node.children.some((c: SyntaxNode) => c.type === 'ERROR' && getNodeText(c, source).trim() === '=')); + const type = isAlias ? getChildByField(node, 'type') : null; + if (!type) return undefined; + const params = new Set(); + for (const decl of getChildByField(node, 'type_parameters')?.namedChildren ?? []) { + for (const c of decl.namedChildren) { + if (c.type === 'identifier') params.add(getNodeText(c, source)); + } + } + const names: SyntaxNode[] = []; + const walk = (n: SyntaxNode): void => { + if (n.type === 'type_identifier') { + const text = getNodeText(n, source); + if (!params.has(text) && !GO_PREDECLARED_TYPES.has(text)) names.push(n); + return; + } + for (const c of n.namedChildren) walk(c); + }; + walk(type); + return names; +} + export const goExtractor: LanguageExtractor = { functionTypes: ['function_declaration'], classTypes: [], // Go doesn't have classes @@ -71,7 +105,7 @@ export const goExtractor: LanguageExtractor = { interfaceTypes: [], // Handled via type_spec → resolveTypeAliasKind structTypes: [], // Handled via type_spec → resolveTypeAliasKind enumTypes: [], - typeAliasTypes: ['type_spec'], // Go type declarations + typeAliasTypes: ['type_spec', 'type_alias'], // `type A B` and `type A = B` importTypes: ['import_declaration'], callTypes: ['call_expression'], variableTypes: ['var_declaration', 'short_var_declaration', 'const_declaration'], @@ -92,7 +126,8 @@ export const goExtractor: LanguageExtractor = { return sig; }, resolveTypeAliasKind: (node, _source) => { - // Go type_spec: `type Foo struct { ... }` or `type Bar interface { ... }` + // Go type_spec: `type Foo struct { ... }` or `type Bar interface { ... }`, + // and an alias of a literal (`type Foo = struct { ... }`) alike. // The inner type is in the 'type' field of the type_spec node const typeChild = getChildByField(node, 'type'); if (!typeChild) return undefined; diff --git a/src/extraction/tree-sitter.ts b/src/extraction/tree-sitter.ts index 997d73517..0434c73dd 100644 --- a/src/extraction/tree-sitter.ts +++ b/src/extraction/tree-sitter.ts @@ -23,7 +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 { goAliasTypeNames, goEmbeddedTypeName } from './languages/go'; import { dartMisparsedGenericCall, dartReceiverOf, isDartTypeName, pushDartTypeRefs } from './languages/dart'; import { LiquidExtractor } from './liquid-extractor'; import { RazorExtractor } from './razor-extractor'; @@ -1357,8 +1357,9 @@ export class TreeSitterExtractor { skipChildren = true; // extractEnum visits body children } // Check for type alias declarations (e.g. `type X = ...` in TypeScript) - // For Go, type_spec wraps struct/interface definitions — resolveTypeAliasKind - // detects these and extractTypeAlias creates the correct node kind. + // For Go, type_spec (and type_alias, `type A = B`) wraps struct/interface + // definitions — resolveTypeAliasKind detects these and extractTypeAlias + // creates the correct node kind. else if (this.extractor.typeAliasTypes.includes(nodeType)) { skipChildren = this.extractTypeAlias(node); } @@ -3786,6 +3787,17 @@ export class TreeSitterExtractor { // entry's string-literal name as a searchable member (issue #634). this.extractTsTupleContractNames(value, typeAliasNode); } + } else if (this.language === 'go') { + // Go's `type Event = mvccpb.Event` names its type in the `type` field. + for (const type of goAliasTypeNames(node, this.source) ?? []) { + this.unresolvedReferences.push({ + fromNodeId: typeAliasNode.id, + referenceName: getNodeText(type, this.source), + referenceKind: 'references', + line: type.startPosition.row + 1, + column: type.startPosition.column, + }); + } } } return false; diff --git a/src/resolution/index.ts b/src/resolution/index.ts index 2d1b50a13..49d0a1bab 100644 --- a/src/resolution/index.ts +++ b/src/resolution/index.ts @@ -3110,6 +3110,12 @@ export class ReferenceResolver { return target && !isImportableKind(target.kind) ? null : result; } + // A Go alias names its type as an embedding does (below): written through + // a package that is none of the file's imports as indexed, the type found + // by its bare name is another package's namesake, or the alias itself. + if (ref.language === 'go' && ref.referenceKind === 'references' && + this.nodeById(ref.fromNodeId)?.kind === 'type_alias' && isGoUnknownQualified(ref, this.context)) return null; + if (!isInheritanceRef(ref)) return result; const target = this.nodeById(result.targetNodeId); if (target && !isSupertypeTarget(target)) { diff --git a/src/resolution/name-matcher.ts b/src/resolution/name-matcher.ts index 045fe03fc..6f7ef8a5f 100644 --- a/src/resolution/name-matcher.ts +++ b/src/resolution/name-matcher.ts @@ -8978,6 +8978,7 @@ export function clearNameMatcherMemos(context: ResolutionContext): void { SCALA_OBJECT_PACKAGES.delete(context); GO_QUALIFIERS.delete(context); GO_EMBEDS.delete(context); + GO_ALIAS_TARGETS.delete(context); JAVA_FILE_SCOPES.delete(context); JAVA_ANCESTORS.delete(context); SCALA_SUPERS.delete(context); @@ -10680,7 +10681,18 @@ function resolveGoMethodInPackage( const types = goPackageTypes(typeName, pkgDir, context); if (types.length === 0) return undefined; if (depth >= 4) return null; + let unplaced = 0; for (const t of types) { + // An alias is the type it names, and has that type's methods. + const aliased = goAliasTarget(t, context); + if (aliased !== undefined) { + const via = aliased && resolveMethodOnType( + aliased.name, methodName, ref, context, confidence, resolvedBy, aliased.pkgDir, depth + 1, + ); + if (via) return via; + if (!aliased) unplaced++; + continue; + } for (const embedded of goEmbeddedTypes(t, context)) { const via = resolveMethodOnType( embedded.name, methodName, ref, context, confidence, resolvedBy, embedded.pkgDir, depth + 1, @@ -10688,7 +10700,48 @@ function resolveGoMethodInPackage( if (via) return via; } } - return null; + // An alias of a type from outside the project (`type Ctx = context.Context`) + // or of no named type declares nothing here: the method is looked up by name, + // as it was before aliases had nodes. + return unplaced === types.length ? undefined : null; +} + +const GO_ALIAS_TARGETS = new WeakMap>(); + +/** + * The type a Go alias names, with the directory of the package that declares + * it — `mvccpb.Event` for `type Event = mvccpb.Event`, `Local` for `type Ptr + * = *Local`, `List` for `type Items[T any] = List[T]` — read from the + * declaration, as an embedding is. Null for an alias of anything else: a type + * from outside the project's packages, a predeclared one, a `func(…)` or + * `map[…]…`. Undefined when the node is no alias: a struct, an interface, or a + * defined type (`type Dur int`), which declares a type of its own. + */ +function goAliasTarget(typeNode: Node, context: ResolutionContext): { name: string; pkgDir: string } | null | undefined { + if (typeNode.kind !== 'type_alias') return undefined; + let memo = GO_ALIAS_TARGETS.get(context); + if (!memo) GO_ALIAS_TARGETS.set(context, (memo = new Map())); + if (memo.has(typeNode.id)) return memo.get(typeNode.id); + const lines = context.getFileLines?.(typeNode.filePath) ?? context.readFile(typeNode.filePath)?.split(/\r?\n/) ?? []; + // From the alias's name, where the node starts, to the end of its type. + const decl = lines + .slice(Math.max(0, typeNode.startLine - 1), typeNode.endLine ?? typeNode.startLine) + .map((l, i) => (i === 0 ? l.slice(typeNode.startColumn ?? 0) : l).replace(/\/\/.*$/, '').replace(/\/\*.*?\*\//g, '')) + .join(' '); + // `Name =` or `Name[T any] =`; a defined type has no `=` there. + const head = /^\s*[A-Za-z_]\w*\s*(?:\[[^\]]*\])?\s*=/.exec(decl); + // `T`, `*T`, `pkg.T`, each perhaps with type arguments. + const m = head && /^\s*(?:\*\s*)?([A-Za-z_]\w*)(?:\s*\.\s*([A-Za-z_]\w*))?\s*(?:\[.*\])?\s*;?\s*$/.exec(decl.slice(head[0].length)); + let target: { name: string; pkgDir: string } | null | undefined; + if (m) { + const pkgDir = m[2] ? goImportPackageDir(m[1]!, typeNode.filePath, context) : goPackageDir(typeNode.filePath); + const name = m[2] ?? m[1]!; + target = pkgDir == null || (!m[2] && GO_BUILTIN_FIELD_TYPES.has(name)) ? null : { name, pkgDir }; + } else { + target = head ? null : undefined; + } + memo.set(typeNode.id, target); + return target; } /** The declarations of Go type `typeName` in the package at directory `pkgDir`. */ From 8f7c20b7dcc3b6b3ad065005c96f295f0370f029 Mon Sep 17 00:00:00 2001 From: Colby McHenry Date: Wed, 7 Oct 2026 03:50:04 -0500 Subject: [PATCH 2/3] test(go): exercise the alias unknown-package rule through clientv3, which #2410 keeps unknown #2410 makes an unaliased go.yaml.in/yaml/v3 known as yaml, so the test's yaml.Node alias no longer reached the rule. An unaliased go.etcd.io/etcd/client/v3 is known as v3 or client, never clientv3; with the rule disabled, this case fails. Co-Authored-By: Claude Opus 5.5 --- __tests__/go-type-alias.test.ts | 24 ++++++++++++------------ 1 file changed, 12 insertions(+), 12 deletions(-) diff --git a/__tests__/go-type-alias.test.ts b/__tests__/go-type-alias.test.ts index 285236314..af48910d1 100644 --- a/__tests__/go-type-alias.test.ts +++ b/__tests__/go-type-alias.test.ts @@ -17,8 +17,9 @@ * Resolution treats the alias as the type it names. A bare `Event{}` or * `*Event` in its package links to the alias, a method called on an `*Event` * is the aliased type's method, and an alias whose target is written through - * a package the index doesn't know (`yaml` under `go.yaml.in/yaml/v3`) never - * links to itself or to a namesake. + * a package the index doesn't know (`clientv3` under an unaliased + * `go.etcd.io/etcd/client/v3`, known as `v3` or `client`) never links to + * itself or to a namesake. */ import { describe, it, expect, beforeAll, beforeEach, afterEach, afterAll } from 'vitest'; import * as fs from 'fs'; @@ -255,8 +256,6 @@ describe('an indexed Go module resolves through its aliases', () => { '', 'func (e *Event) IsCreate() bool { return false }', '', - 'type Node struct{}', - '', ].join('\n'), 'client/v3/watch.go': [ 'package clientv3', @@ -296,13 +295,13 @@ describe('an indexed Go module resolves through its aliases', () => { 'func count(xs *Items[int]) int { return xs.Len() }', '', ].join('\n'), - 'yamlish/node.go': [ - 'package yamlish', + 'watcher/watcher.go': [ + 'package watcher', '', - 'import "go.yaml.in/yaml/v3"', + 'import "go.etcd.io/etcd/client/v3"', '', - '// The package is yaml, though the index knows its import as v3.', - 'type Node = yaml.Node', + '// The package is clientv3, though the index knows the import as v3 or client.', + 'type Event = clientv3.Event', '', ].join('\n'), }; @@ -360,8 +359,9 @@ describe('an indexed Go module resolves through its aliases', () => { }); it('an alias written through a package the index does not know links to nothing', () => { - expect(linksFrom('yamlish/node.go', 'Node')).toEqual([]); - const node = cg.getNodesInFile('yamlish/node.go').find((n) => n.name === 'Node')!; - expect(cg.getUnresolvedReferencesFrom(node.id).map((r) => `${r.referenceKind} ${r.referenceName}`)).toEqual(['references Node']); + // Not to itself, nor to client/v3's, mvccpb's or alpha's `Event`. + expect(linksFrom('watcher/watcher.go', 'Event')).toEqual([]); + const event = cg.getNodesInFile('watcher/watcher.go').find((n) => n.name === 'Event')!; + expect(cg.getUnresolvedReferencesFrom(event.id).map((r) => `${r.referenceKind} ${r.referenceName}`)).toEqual(['references Event']); }); }); From d688761162403af1dbbf080671af8baa98f9c9e2 Mon Sep 17 00:00:00 2001 From: Colby McHenry Date: Wed, 7 Oct 2026 05:04:40 -0500 Subject: [PATCH 3/3] test(go): an alias owning methods written on it implements what they satisfy With #2419, goImplementsEdges offers every Go type_alias as an implementer. A true alias now has a node, and owns the methods written with it as the receiver (prometheus's func (c *KumaSDConfig) Name(), KumaSDConfig = SDConfig), which Go gives the aliased type; so the alias implements discovery.Config in SDConfig's place. The doc comment said a true alias is not extracted. Co-Authored-By: Claude Opus 5.5 --- __tests__/go-type-alias.test.ts | 35 +++++++++++++++++++++++--- src/resolution/callback-synthesizer.ts | 4 ++- 2 files changed, 34 insertions(+), 5 deletions(-) diff --git a/__tests__/go-type-alias.test.ts b/__tests__/go-type-alias.test.ts index af48910d1..38f7e4443 100644 --- a/__tests__/go-type-alias.test.ts +++ b/__tests__/go-type-alias.test.ts @@ -16,10 +16,11 @@ * * Resolution treats the alias as the type it names. A bare `Event{}` or * `*Event` in its package links to the alias, a method called on an `*Event` - * is the aliased type's method, and an alias whose target is written through - * a package the index doesn't know (`clientv3` under an unaliased - * `go.etcd.io/etcd/client/v3`, known as `v3` or `client`) never links to - * itself or to a namesake. + * is the aliased type's method, methods written with the alias as the + * receiver make it implement what they satisfy, and an alias whose target is + * written through a package the index doesn't know (`clientv3` under an + * unaliased `go.etcd.io/etcd/client/v3`, known as `v3` or `client`) never + * links to itself or to a namesake. */ import { describe, it, expect, beforeAll, beforeEach, afterEach, afterAll } from 'vitest'; import * as fs from 'fs'; @@ -295,6 +296,21 @@ describe('an indexed Go module resolves through its aliases', () => { 'func count(xs *Items[int]) int { return xs.Len() }', '', ].join('\n'), + // prometheus's discovery/xds: methods written on the alias are SDConfig's. + 'discovery/kuma.go': [ + 'package discovery', + '', + 'type Config interface {', + '\tName() string', + '}', + '', + 'type SDConfig struct{}', + '', + 'type KumaSDConfig = SDConfig', + '', + 'func (*KumaSDConfig) Name() string { return "kuma" }', + '', + ].join('\n'), 'watcher/watcher.go': [ 'package watcher', '', @@ -358,6 +374,17 @@ describe('an indexed Go module resolves through its aliases', () => { ]); }); + it('an alias owning methods written on it implements what they satisfy', () => { + expect(linksFrom('discovery/kuma.go', 'KumaSDConfig')).toEqual([ + 'implements discovery/kuma.go:Config', + 'references discovery/kuma.go:SDConfig', + ]); + // A call through Config.Name reaches the method written on the alias. + const config = cg.getNodesInFile('discovery/kuma.go').find((n) => n.name === 'Config')!; + const name = cg.getOutgoingEdgesFrom([config.id], ['contains']).map((e) => cg.getNode(e.target)!).find((n) => n.name === 'Name')!; + expect(cg.getOutgoingEdgesFrom([name.id], ['calls']).map((e) => cg.getNode(e.target)?.qualifiedName)).toEqual(['KumaSDConfig::Name']); + }); + it('an alias written through a package the index does not know links to nothing', () => { // Not to itself, nor to client/v3's, mvccpb's or alpha's `Event`. expect(linksFrom('watcher/watcher.go', 'Event')).toEqual([]); diff --git a/src/resolution/callback-synthesizer.ts b/src/resolution/callback-synthesizer.ts index d84861728..87229132a 100644 --- a/src/resolution/callback-synthesizer.ts +++ b/src/resolution/callback-synthesizer.ts @@ -828,7 +828,9 @@ function isGoEmbedding(e: Edge, kindOf: (id: string) => NodeKind | null): boolea * `type_alias` that owns them through `contains` edges. Go gives it none of * the methods declared on the type it is written over, so it is matched by * the methods it declares. A true alias (`type A = B`) is B, not a type of its - * own, and is not extracted as one. + * own: its `type_alias` owns only methods written with it as the receiver + * (`func (c *KumaSDConfig) Name()`), which are B's, and stands in for B with + * those. One without any is no implementer. * * Both method sets include what embedding brings in, read off the declared * `extends`/`implements` edge each embedded type is. An interface has the