fix(go): a local's scope ends at its case clause, and a func-typed result's parameters are not locals (#2597)

Main's gate for a Go call to a local func value or parameter that
shadows a package function (PR 2658) reads scopes through the Go scope
reader, and two of its readings were too wide. A name declared in one
case of a switch or select stayed in scope in the clauses after it, and
the parameter names inside a func-typed result, like
func() func(run string), *func(...) or chan<- func(...), were read as
the function's own. Either made a later call to the package function
look shadowed and drop its link.

The PR's own earlier shadowing check is left out: the gate already
covers every case its tests pin. Go A/B on gin, cobra, etcd, prometheus
and hugo is byte-identical.

Fixes issue 2580.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Eric Minish
2026-10-20 17:06:31 +00:00
committed by GitHub
co-authored by Claude Opus 5.5
parent 23ff26be68
commit e3f23cc4bc
3 changed files with 105 additions and 4 deletions
+1 -1
View File
@@ -154,7 +154,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, 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. A name bound in one `case` of a `switch` or `select`, or a parameter named inside a function-typed result like `func() func(run string)`, is not such a binding anywhere else, so a call there still links. Thanks @inth3shadows. (#2580) Re-index Go projects after upgrading.
- In Go, a method an interface declares, like etcd's `KV.Put(ctx context.Context, key, val string, opts ...OpOption) (*PutResponse, error)`, now links to the types its parameters and results name, so impact on `PutResponse` or `OpOption` reaches the interface method and every caller that goes through the interface. Before, an interface's methods linked to no types at all. Re-index Go projects after upgrading.
- In Go, a package imported under an explicit name, like `v3 "go.etcd.io/etcd/client/v3"`, now owns that name even when another import's path ends in it, like `"github.com/cheggaaa/pb/v3"` (package `pb`) in etcd's benchmark tool. Before, whichever of the two came first took every `v3.` name, so `v3.Client` and `v3.Op` could link to nothing. 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.
@@ -0,0 +1,99 @@
/**
* In Go a parameter or local declared in a function shadows a package-level
* function of the same name for the rest of its block, so a bare call to it
* — `run("x")` after `run := func…` or with a `run func(…)` parameter — calls
* the local func value, never the package-level `run` (#2580). The graph has
* no node for the local, so the call gets no edge. Outside the local's scope,
* the package-level function keeps its callers.
*/
import { describe, it, expect, afterEach } from 'vitest';
import * as fs from 'fs';
import * as os from 'os';
import * as path from 'path';
import CodeGraph from '../src/index';
const GO_MOD = 'module example.com/app\n\ngo 1.22\n';
const RUN = 'func run(a, b int) int { return a + b }';
let tempDir: string;
let cg: CodeGraph | null = null;
async function edges(files: Record<string, string>, fromName: string): Promise<string[]> {
tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'codegraph-2580-'));
for (const [rel, source] of Object.entries(files)) {
fs.mkdirSync(path.dirname(path.join(tempDir, rel)), { recursive: true });
fs.writeFileSync(path.join(tempDir, rel), source);
}
cg = await CodeGraph.init(tempDir, { index: true });
cg.resolveReferences();
const from = cg.getNodesByKind('function').find((n) => n.name === fromName)!;
expect(from).toBeDefined();
return cg
.getOutgoingEdges(from.id)
.filter((e) => e.kind === 'calls')
.map((e) => [e, cg!.getNode(e.target)] as const)
.filter(([, n]) => n?.kind === 'function')
.map(([e, n]) => `${e.kind} ${n!.name} L${e.line}`);
}
/** `package main` with the package-level `run`, `body`, and a `main` that calls `call`. */
function mainGo(body: string, call: string): Record<string, string> {
return { 'go.mod': GO_MOD, 'main.go': `package main\n\n${RUN}\n\n${body}\n\nfunc main() { ${call} }\n` };
}
afterEach(() => {
cg?.close();
cg = null;
fs.rmSync(tempDir, { recursive: true, force: true });
});
describe('a Go local or parameter shadows a same-named package function (#2580)', () => {
it.each([
['a := closure', 'func use() string {\n\trun := func(s string) string { return s }\n\treturn run("x")\n}', 'use()'],
['a func parameter', 'func use(run func(string) string) string {\n\treturn run("x")\n}', 'use(nil)'],
['a var closure', 'func use() string {\n\tvar run = func(s string) string { return s }\n\treturn run("x")\n}', 'use()'],
['an if-header local', 'func use() string {\n\tif run := func(s string) string { return s }; true {\n\t\treturn run("x")\n\t}\n\treturn ""\n}', 'use()'],
['a named result', 'func use() (run func(string) string) {\n\trun = func(s string) string { return s }\n\trun("x")\n\treturn\n}', 'use()'],
['a range value', 'func use(fns []func()) {\n\tfor _, run := range fns {\n\t\trun()\n\t}\n}', 'use(nil)'],
])('%s, in the same file', async (_label, body, call) => {
expect((await edges(mainGo(body, call), 'use')).filter((e) => / run /.test(e))).toEqual([]);
});
it('shadows a package function declared in another file', async () => {
const out = await edges(
{
'go.mod': GO_MOD,
'run.go': `package main\n\n${RUN}\n`,
'use.go': 'package main\n\nfunc use(run func(string) string) string {\n\treturn run("x")\n}\n\nfunc main() { use(nil) }\n',
},
'use'
);
expect(out).toEqual([]);
});
it('shadows a name a framework pattern would resolve', async () => {
const out = await edges(
{
'go.mod': GO_MOD,
'main.go': 'package main\n\nfunc errHandler(e error) {}\n\nfunc use() {\n\terrHandler := func(e error) {}\n\terrHandler(nil)\n}\n\nfunc main() { use() }\n',
},
'use'
);
expect(out).toEqual([]);
});
it.each([
['a call before the local is declared', 'func use() {\n\trun(1, 2)\n\trun := func() {}\n\trun()\n}', ['calls run L6']],
['a local initialised from the package function', 'func use() {\n\trun := run(1, 2)\n\t_ = run\n}', ['calls run L6']],
['a call after the block declaring the local', 'func use() {\n\tif true {\n\t\trun := func() {}\n\t\trun()\n\t}\n\trun(1, 2)\n}', ['calls run L10']],
['a sibling case clause', 'func use(x int) int {\n\tswitch x {\n\tcase 1:\n\t\trun := func(a, b int) int { return a }\n\t\treturn run(1, 2)\n\tcase 2:\n\t\treturn run(1, 2)\n\t}\n\treturn 0\n}', ['calls run L11']],
['a select default after a case declaring the local', 'func use(ch chan int) int {\n\tselect {\n\tcase <-ch:\n\t\trun := func(a, b int) int { return a }\n\t\treturn run(1, 2)\n\tdefault:\n\t\treturn run(1, 2)\n\t}\n}', ['calls run L11']],
['a pointer-to-func result naming a parameter run', 'func use() *func(run int) int {\n\t_ = run(1, 2)\n\treturn nil\n}', ['calls run L6']],
['a send-only channel-of-func result naming a parameter run', 'func use() chan<- func(run int) {\n\t_ = run(1, 2)\n\treturn nil\n}', ['calls run L6']],
['a func-type result naming a parameter run', 'func use() func(run int) int {\n\t_ = run(1, 2)\n\treturn nil\n}', ['calls run L6']],
])('keeps the edge for %s', async (_label, body, expected) => {
expect(await edges(mainGo(body, 'use()'), 'use')).toEqual(expected);
});
});
+5 -3
View File
@@ -2743,9 +2743,10 @@ function readGoScopes(code: string): GoScopeIndex {
}
}
// Each `func`'s receiver, parameters and results, bound in its body. A
// `[]func(…)` or `chan func(…)` is a type, whatever brace follows it.
// `[]func(…)` or `chan func(…)` is a type, whatever brace follows it, and
// so is a `func(…)` result type after a signature's `)` (#2580).
for (const m of code.matchAll(/(?<![\w.])func\b/g)) {
if (/(?:\]|\bchan)[ \t]*$/.test(code.slice(Math.max(0, m.index! - 8), m.index!))) continue;
if (/(?:[\]*)]|\bchan(?:[ \t]*<-)?)[ \t]*$/.test(code.slice(Math.max(0, m.index! - 8), m.index!))) continue;
const sig = goSignature(code, m.index! + 4);
if (!sig) continue;
for (const [from, to] of sig.groups) {
@@ -2928,7 +2929,8 @@ function goScopesOf(index: GoScopeIndex, name: string): Array<[number, number, G
}
const from = goStatementEnd(index.code, d.at);
let to = block[1];
if (d.kind === 'case') {
// A `case` clause's names, header or body, end at the next clause.
{
const clause = /\n[ \t]*(?:case\b|default[ \t]*:)/g;
clause.lastIndex = from;
for (let m = clause.exec(index.code); m && m.index < to; m = clause.exec(index.code)) {