fix(go): an unexported name links its own package's declaration, and a local binding shadows the package (#2658)

Go keeps an unexported name inside its package, yet prometheus tsdb's
`[]*memSeries` in head_append.go linked to nothing. Name matching scored
head.go's `memSeries` and `tsdb/agent`'s the same, because directory
proximity counts shared leading directories (and caps at 80), so a child
or deep sibling package ties with the package's own directory. The first
one met, `tsdb/agent`'s, was then vetoed as invisible. Parameters,
results, struct fields, composite literals and calls all failed this way:
prometheus's `memSeries` and `sample{…}` (1,185 literals), and in
kubernetes client-go's per-version informer types and many test helpers.

- matchByExactName drops a Go candidate the reference cannot see before
  the ranking (isGoNameVisible, shared with isVisibleAcrossFiles' Go rule),
  as it already did for C#, Java, PHP and Scala, so the package's own
  declaration wins.
- That lets a name with more namesakes than the ambiguity ceiling resolve
  to its package's declaration. It also exposed bare calls to a local
  closure named like a package function: client-go's `init := func(…)`
  then `init(t)` bound to `func init()`. gateTargetKind now leaves a bare
  Go call or function value unresolved when the function around it binds
  the name itself (isGoLocallyBound: a parameter, `:=`, `var`), as Go's
  scoping says. The same rule drops existing wrong links: a test's
  `volumeSpec = volumeSpec(…)` var passed on, or a local `errors` map that
  linked to kubectl's `errors` method.

A/B on main a944ec35:
- gin: unchanged.
- etcd: +2 / -25.
- prometheus: +1251 / -112.
- kubernetes: +670 / -542.
Every added link is the package's own declaration, or the right method
for the 5 calls written through a value. Every dropped one is a name the
enclosing function binds (checked by text, the rest by hand). The other
changes are the same targets at confidence 0.9 instead of 0.7.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Colby Mchenry
2026-10-19 10:13:17 +00:00
committed by GitHub
parent c38f2678ac
commit 6692e05107
4 changed files with 204 additions and 6 deletions
+1
View File
@@ -138,6 +138,7 @@ and adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
- In Go, a struct that embeds another is no longer treated as overriding the methods it redeclares. gin's `Engine` embeds `RouterGroup` and has a `Use` of its own, but calling `Use` on a `RouterGroup` only ever runs `RouterGroup.Use`; before, `codegraph_explore`'s call path and impact went on from it into `Engine.Use` and the code only the engine runs. The same held for every struct or defined type embedded in another that redeclares one of its methods, like etcd's `Mutex` inside `lockerMutex`. A call through an interface still reaches each type that implements it, the embedding struct included. Re-index Go projects after upgrading.
- In Go, a type defined from other types, like etcd's `type WatchChan <-chan WatchResponse` or gin's `type HandlerFunc func(*Context)` and `type HandlersChain []HandlerFunc`, now links to the types it is built from, so impact on `WatchResponse` or gin's `Context` reaches these types and the code that uses them. Before, such a type linked to nothing. One written through another package, like etcd's `type PutResponse pb.PutResponse`, links to that package's type, and a recursive one like `type stateFn func(*Lexer) stateFn` doesn't link to itself. Re-index Go projects after upgrading.
- In Go, a struct now links to the types its fields are declared with, like etcd's `PutResponse` holding a `*ResponseHeader` or gin's `Context` holding a `HandlersChain`, so impact on `ResponseHeader` or `HandlersChain` reaches every struct that holds one and the code that uses those structs, and `codegraph_node` lists a struct's field types. Before, a struct linked only to the types it embeds. A field written through another package, like `*pb.ResponseHeader`, links to that package's type, one from outside your project, like `context.Context`, links to nothing, the types an embedded generic is filled in with, like the `Pod` in client-go's `listers.ResourceIndexer[*v1.Pod]`, link too, and a struct that holds itself, like a list node's `next *node`, doesn't link to itself. `codegraph_explore` still answers a question about a flow with the code that runs it, not with the structs that merely hold one of its types. Re-index Go projects after upgrading.
- In Go, an unexported name used in another file of its package, like prometheus's `memSeries` in `tsdb/head_append.go`, now links to that package's own declaration when a package nested inside it, like `tsdb/agent`, declares the same name. Before, such a parameter, result, struct field, composite literal or call linked to nothing. A call or a function value that the surrounding function binds itself, like a test's `init := func(…)` followed by `init(t)`, or a local `volumeSpec` variable passed on, no longer links to the package's declaration of that name. Re-index Go projects after upgrading.
- In Go, a struct or defined type now counts as implementing an interface only when its methods take as many parameters and return as many results as the interface's. Before, matching method names was enough, so etcd's watch cache, whose `Get` takes a context, a key and options, was listed as a `peerGetter`, whose `Get` takes a peer ID, and a call through an interface could be shown reaching methods it can never reach. Look-alikes could also crowd out the real implementations, so `codegraph_explore` stopped at grpc-go's xDS `TransportBuilder` instead of reaching the transport it builds. A server still counts for the gRPC client interface of the service it serves, like etcd's `kvServer` for `KVClient`, so a client's call keeps leading to the handler that serves it. 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 `<Route {...rest}>`, 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 `<Link to="/counter">` links led nowhere. Each route links to the page its `element` renders, past a guard like `<ProtectedRoute>` or `<AdminWrapper>`, 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.
+171
View File
@@ -0,0 +1,171 @@
/**
* Go keeps an unexported name inside its package: prometheus tsdb's
* `[]*memSeries` in head_append.go can only mean the `memSeries` head.go
* declares, never the one in `tsdb/agent`. Name matching scored the two the
* same — directory proximity counts shared leading directories, so a child
* package's file ties with a file of the package's own directory — and the
* one met first, `tsdb/agent`'s, was then vetoed as invisible, so the
* reference linked to nothing. Parameters, results, struct fields, composite
* literals and calls all failed this way whenever a child package declared
* the same unexported name.
*
* A Go candidate the reference cannot see is now out of the pool before the
* ranking, as for C#, Java and PHP, so the package's own declaration wins.
*/
import { describe, it, expect, beforeAll, afterAll } from 'vitest';
import * as fs from 'fs';
import * as os from 'os';
import * as path from 'path';
import CodeGraph from '../src/index';
/** prometheus's shape: package tsdb, with namesakes in its child packages. */
const MODULE: Record<string, string> = {
'go.mod': 'module example.com/prom\n\ngo 1.24\n',
'tsdb/head.go': [
'package tsdb',
'',
'type Head struct {',
'\tseries map[uint64]*memSeries',
'}',
'',
'type memSeries struct {',
'\tref uint64',
'}',
'',
'type sample struct {',
'\tt int64',
'}',
'',
'func newSeries() *memSeries { return &memSeries{} }',
'',
'func init() {}',
'',
].join('\n'),
'tsdb/head_append.go': [
'package tsdb',
'',
'type appendBatch struct {',
'\tseries []*memSeries',
'\tsamples []sample',
'}',
'',
'func appendTo(h *Head, s *memSeries, xs []sample) *memSeries {',
'\tm := newSeries()',
'\t_ = memSeries{}',
'\treturn m',
'}',
'',
'// onlyAgent is declared in tsdb/agent alone: nothing here can name it.',
'func orphan(x *onlyAgent) {}',
'',
'func shadowed() *memSeries {',
'\tnewSeries := func() *memSeries { return nil }',
'\tinit := func() {}',
'\tinit()',
'\treturn newSeries()',
'}',
'',
].join('\n'),
'tsdb/agent/series.go': [
'package agent',
'',
'type memSeries struct{}',
'',
'type onlyAgent struct{}',
'',
'func newSeries() *memSeries { return &memSeries{} }',
'',
'func use(s *memSeries) { _ = newSeries() }',
'',
].join('\n'),
'tsdb/chunks/samples.go': [
'package chunks',
'',
'type sample struct{}',
'',
].join('\n'),
};
describe.each([
['default', 'LF'],
['wasm', 'LF'],
['default', 'CRLF'],
['wasm', 'CRLF'],
])('a Go unexported name is its own package’s, whatever a child package declares (%s, %s)', (backend, eol) => {
let root = '';
let cg: CodeGraph | undefined;
let kernel: string | undefined;
beforeAll(async () => {
kernel = process.env.CODEGRAPH_KERNEL;
if (backend === 'wasm') process.env.CODEGRAPH_KERNEL = '0';
else delete process.env.CODEGRAPH_KERNEL;
root = fs.mkdtempSync(path.join(os.tmpdir(), 'cg-go-namesake-'));
for (const [rel, content] of Object.entries(MODULE)) {
fs.mkdirSync(path.dirname(path.join(root, rel)), { recursive: true });
fs.writeFileSync(path.join(root, rel), eol === 'CRLF' ? content.replace(/\n/g, '\r\n') : content);
}
cg = await CodeGraph.init(root, { index: true });
}, 60_000);
afterAll(() => {
cg?.destroy();
if (root) fs.rmSync(root, { recursive: true, force: true });
if (kernel === undefined) delete process.env.CODEGRAPH_KERNEL;
else process.env.CODEGRAPH_KERNEL = kernel;
});
const nodeNamed = (file: string, name: string) => {
const nodes = cg!.getNodesInFile(file).filter((n) => n.name === name && n.kind !== 'import');
expect(nodes, `${name} in ${file}`).toHaveLength(1);
return nodes[0]!;
};
/** `kind file:name` of each non-contains edge out of `name` in `file`, sorted, duplicates kept. */
const linksFrom = (file: string, name: string) =>
cg!.getOutgoingEdgesFrom([nodeNamed(file, name).id])
.filter((e) => e.kind !== 'contains')
.map((e) => `${e.kind} ${cg!.getNode(e.target)!.filePath}:${cg!.getNode(e.target)!.name}`)
.sort();
const unresolvedFrom = (file: string, name: string) =>
cg!.getUnresolvedReferencesFrom(nodeNamed(file, name).id).map((r) => `${r.referenceKind} ${r.referenceName}`).sort();
it('a parameter, a result, a composite literal and a call name the package’s own declarations', () => {
expect(linksFrom('tsdb/head_append.go', 'appendTo')).toEqual([
'calls tsdb/head.go:newSeries',
'instantiates tsdb/head.go:memSeries',
'references tsdb/head.go:Head',
'references tsdb/head.go:memSeries',
'references tsdb/head.go:memSeries',
'references tsdb/head.go:sample',
]);
expect(unresolvedFrom('tsdb/head_append.go', 'appendTo')).toEqual([]);
});
it('a struct’s fields name the package’s own types', () => {
expect(linksFrom('tsdb/head_append.go', 'appendBatch')).toEqual([
'references tsdb/head.go:memSeries',
'references tsdb/head.go:sample',
]);
});
it('the child package keeps its own declarations', () => {
expect(linksFrom('tsdb/agent/series.go', 'use')).toEqual([
'calls tsdb/agent/series.go:newSeries',
'references tsdb/agent/series.go:memSeries',
]);
});
it('a name the function binds itself is that binding, not the package’s declaration', () => {
// Its result type still links; the two calls are to its own closures,
// neither to tsdb's `newSeries` nor to `func init()`, which Go never lets
// a program name.
expect(linksFrom('tsdb/head_append.go', 'shadowed')).toEqual(['references tsdb/head.go:memSeries']);
});
it('an unexported name only another package declares links to nothing', () => {
expect(linksFrom('tsdb/head_append.go', 'orphan')).toEqual([]);
expect(unresolvedFrom('tsdb/head_append.go', 'orphan')).toEqual(['references onlyAgent']);
});
});
+8 -1
View File
@@ -21,7 +21,7 @@ import {
isImportableKind,
CPP_DEFINE_SIGNATURE,
} from './types';
import { isPythonSelfCall, matchJsStoreBindingCall, isUnresolvedJsMemberCall, matchObjectPathCall, thisScopeCaller, isVisibleAcrossFiles, matchReference, matchByFilePath, matchFunctionRef, matchDottedCallChain, matchScopedCallChain, matchMethodCall, sameLanguageFamily, crossesCodeBoundary, gateLanguageMatch, dumpNameMatcherProfile, clearNameMatcherMemos, isRustNameInScope, CASE_INSENSITIVE_LANGUAGES, isDartMemberRead, matchDartMemberRead, isDartChainLink, matchDartChainLink, isDartAnnotation, matchDartAnnotation, isStdMethodName, isGoUnknownQualified, isGoProjectLocalCall, GO_STDLIB_PACKAGES, isGoBareName, goTypePositionTarget, GO_TYPE_KINDS, matchGoAssertedCall, isGoAssertedLocal } from './name-matcher';
import { isPythonSelfCall, matchJsStoreBindingCall, isUnresolvedJsMemberCall, matchObjectPathCall, thisScopeCaller, isVisibleAcrossFiles, matchReference, matchByFilePath, matchFunctionRef, matchDottedCallChain, matchScopedCallChain, matchMethodCall, sameLanguageFamily, crossesCodeBoundary, gateLanguageMatch, dumpNameMatcherProfile, clearNameMatcherMemos, isRustNameInScope, CASE_INSENSITIVE_LANGUAGES, isDartMemberRead, matchDartMemberRead, isDartChainLink, matchDartChainLink, isDartAnnotation, matchDartAnnotation, isStdMethodName, isGoUnknownQualified, isGoProjectLocalCall, GO_STDLIB_PACKAGES, isGoBareName, goTypePositionTarget, GO_TYPE_KINDS, matchGoAssertedCall, isGoAssertedLocal, isGoLocallyBound } from './name-matcher';
import { isVisibleCppMacro, clearCppMacroVisibility } from './cpp-macro-visibility';
import { isCppConstructorRef, matchCppConstructor } from './cpp-constructor';
import { isCppSupertypeRef, matchCppSupertype, clearCppSupertypeMemos } from './cpp-supertypes';
@@ -3299,6 +3299,13 @@ export class ReferenceResolver {
private gateTargetKind(result: ResolvedRef | null, ref: UnresolvedRef): ResolvedRef | null {
if (!result) return result;
// A Go name called or passed bare that the function around it binds
// itself — a parameter, a `:=`, a `var` — is that binding, never a
// declaration of the package: client-go's `init := func(…)` then
// `init(t)` calls no `func init()`.
if (ref.language === 'go' && (ref.referenceKind === 'calls' || ref.referenceKind === 'function_ref') &&
isGoBareName(ref, this.context) && isGoLocallyBound(ref, this.context)) return null;
// A `#define` is a value, never a callee (#1838): a macro defined only in
// an unrelated file is not what `NAME(x)` here expands to either.
if (ref.referenceKind === 'calls') {
+24 -5
View File
@@ -2366,6 +2366,15 @@ function goLocalDecl(name: string, ref: UnresolvedRef, context: ResolutionContex
return found?.[2];
}
/**
* Whether a Go reference's name is a parameter or local of the function
* around it, which shadows every declaration of its package: client-go's
* `init := func(t *testing.T) {…}` then `init(t)`.
*/
export function isGoLocallyBound(ref: UnresolvedRef, context: ResolutionContext): boolean {
return goLocalDecl(ref.referenceName, ref, context) !== undefined;
}
function goScopeIndex(filePath: string, context: ResolutionContext): GoScopeIndex | null {
let memo = GO_SCOPE_INDEXES.get(context);
if (!memo) GO_SCOPE_INDEXES.set(context, (memo = new Map()));
@@ -2938,6 +2947,16 @@ function isSfcPrivate(n: Node, context: ResolutionContext): boolean {
return true;
}
/**
* Whether Go lets `ref`'s file name the Go declaration `candidate`: an
* exported name from anywhere, an unexported one only inside its own package,
* which is a directory. By the name's first letter, not the extractor's flag:
* the flag is unset for every Go method, exported or not.
*/
function isGoNameVisible(candidate: Node, ref: UnresolvedRef): boolean {
return /^[A-Z]/.test(candidate.name) || path.posix.dirname(candidate.filePath) === path.posix.dirname(ref.filePath);
}
/**
* Whether `candidate` can be NAMED from a reference in `ref`'s file at all,
* given what its language says about the definition's visibility. A
@@ -3022,11 +3041,7 @@ export function isVisibleAcrossFiles(candidate: Node, ref: UnresolvedRef, contex
!cppIncluders(candidate.filePath, context).has(ref.filePath)) return false;
return !isCppTranslationUnitLocal(candidate, ref, context);
}
if (lang === 'go') {
// By the name's first letter, not the extractor's flag: the flag is unset
// for every Go method, exported or not.
return /^[A-Z]/.test(candidate.name) || path.posix.dirname(candidate.filePath) === path.posix.dirname(ref.filePath);
}
if (lang === 'go') return isGoNameVisible(candidate, ref);
if (lang === 'rust') {
if (candidate.visibility !== 'private') return true;
if (isRustTraitImplMethod(candidate, context)) return true;
@@ -7953,6 +7968,10 @@ export function matchByExactName(
// Likewise a bare Java type name: retrofit's tests' `new Builder()` is not
// a wire converter test's nested `CrashingPhone.Builder`.
isJavaTypeVisible(n, ref, context) &&
// And a Go name that isn't exported, only its own package's: prometheus
// tsdb's `[]*memSeries` is head.go's, not `tsdb/agent`'s, which scored the
// same (a child package's directory shares as much of the path) and won.
(n.language !== 'go' || isGoNameVisible(n, ref)) &&
dartExtensionDecl(n, context)?.named !== false &&
// A Scala package object's member, only where it is in scope — ahead of
// the ranking, so cats.laws' `Eq` can be the `cats` package object's.