fix(resolution): a receiver-less Go call never binds to a method (#1857) (#1999)

A Go method is only reachable through a value or a method expression, so a
bare call such as `relogin(ctx)` on a func parameter was being bound to a
same-named method in its own package, or (when capitalised) to an exported
method of a package the file does not import. Extend the #1714 receiver-less
check to Go, reading the call site back from source so the
`pkg.Factory().Method()` chain that reaches the resolver as a bare `Method`
ref keeps its edge.


Claude-Session: https://claude.ai/code/session_01MuSia5rNjiWCqzqW9iTJGD
(cherry picked from commit 812cf0d45f)

Co-authored-by: Max Hsu <maxmilian@gmail.com>
Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
Colby Mchenry
2026-09-27 12:24:33 +00:00
committed by GitHub
parent 5aa7193f7a
commit 53fe53deb5
2 changed files with 204 additions and 4 deletions
+181
View File
@@ -0,0 +1,181 @@
/**
* In Go a receiver-less call — `relogin(ctx)` — can never reach a method:
* a method is only callable through a value (`l.relogin(ctx)`) or a method
* expression (`(*Loop).relogin`), never by its bare name. So a bare call to a
* function parameter or a local func value must not be bound to a same-named
* method, whether it sits in the same package or — capitalised — in a package
* the file does not import (#1857). Real method calls, including the
* `pkg.Factory().Method()` chain the extractor emits under the bare method
* name, keep their edges.
*/
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';
let tempDir: string;
let cg: CodeGraph | null = null;
async function callees(files: Record<string, string>, fromName: string): Promise<string[]> {
tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'codegraph-1857-'));
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'), ...cg.getNodesByKind('method')].find((n) => n.name === fromName)!;
expect(from).toBeDefined();
return cg
.getOutgoingEdges(from.id)
.filter((e) => e.kind === 'calls')
.map((e) => cg!.getNode(e.target))
.filter((n): n is NonNullable<typeof n> => !!n)
.map((n) => `${n.kind}:${n.name}@${n.filePath}`);
}
afterEach(() => {
cg?.close();
cg = null;
fs.rmSync(tempDir, { recursive: true, force: true });
});
const GO_MOD = 'module example.com/app\n\ngo 1.22\n';
describe('a receiver-less Go call never binds to a method (#1857)', () => {
it('does not bind a call to a func parameter onto a same-package method', async () => {
const out = await callees(
{
'go.mod': GO_MOD,
'loop/loop.go': [
'package loop',
'',
'type Loop struct{}',
'',
'func (l *Loop) relogin() error { return nil }',
'',
].join('\n'),
'loop/sched.go': [
'package loop',
'',
'func runScheduler(relogin func() error) error {',
'\tdefer relogin()',
'\tgo relogin()',
'\treturn relogin()',
'}',
'',
].join('\n'),
},
'runScheduler'
);
expect(out.filter((c) => c.startsWith('method:'))).toEqual([]);
});
it('does not bind a capitalised bare call onto an exported method of an unimported package', async () => {
const out = await callees(
{
'go.mod': GO_MOD,
'impulse/loop.go': [
'package impulse',
'',
'type Loop struct{}',
'',
'func (l *Loop) Relogin() error { return nil }',
'',
].join('\n'),
'synapse/sched.go': [
'package synapse',
'',
'func RunScheduler(Relogin func() error) error {',
'\tif err := Relogin(); err != nil {',
'\t\treturn err',
'\t}',
'\treturn nil',
'}',
'',
].join('\n'),
},
'RunScheduler'
);
expect(out.filter((c) => c.startsWith('method:'))).toEqual([]);
});
it('keeps a bare call onto a same-package function', async () => {
const out = await callees(
{
'go.mod': GO_MOD,
'loop/loop.go': [
'package loop',
'',
'type Loop struct{}',
'',
'func (l *Loop) flush() {}',
'',
'func flush() {}',
'',
'func run() {',
'\tflush()',
'}',
'',
].join('\n'),
},
'run'
);
expect(out).toContain('function:flush@loop/loop.go');
expect(out.filter((c) => c.startsWith('method:'))).toEqual([]);
});
it('keeps a real method call through a receiver', async () => {
const out = await callees(
{
'go.mod': GO_MOD,
'loop/loop.go': [
'package loop',
'',
'type Loop struct{}',
'',
'func (l *Loop) relogin() error { return nil }',
'',
'func (l *Loop) run() error {',
'\treturn l.relogin()',
'}',
'',
].join('\n'),
},
'run'
);
expect(out).toContain('method:relogin@loop/loop.go');
});
it('keeps a package-qualified factory chain onto the method', async () => {
const out = await callees(
{
'go.mod': GO_MOD,
'service/table.go': [
'package service',
'',
'type Table struct{}',
'',
'func (t *Table) Reload() error { return nil }',
'',
'func SysTable() *Table { return &Table{} }',
'',
].join('\n'),
'controller/ctl.go': [
'package controller',
'',
'import "example.com/app/service"',
'',
'func Handle() error {',
'\treturn service.SysTable().Reload()',
'}',
'',
].join('\n'),
},
'Handle'
);
expect(out).toContain('method:Reload@service/table.go');
});
});
+23 -4
View File
@@ -669,7 +669,24 @@ const JS_FAMILY = new Set<string>(['typescript', 'tsx', 'javascript', 'jsx']);
* used to pick over the module-scope function the call actually means.
*/
function isBareJsCall(ref: UnresolvedRef, context: ResolutionContext): boolean {
if (ref.referenceKind !== 'calls' || !JS_FAMILY.has(ref.language)) return false;
return JS_FAMILY.has(ref.language) && isReceiverLessCall(ref, context);
}
/**
* Whether a Go `calls` ref is receiver-less — `relogin(ctx)`, not
* `l.relogin(ctx)`. A Go method is only reachable through a value or a method
* expression, so a bare call (a func parameter, a local func value, a
* package-level function) is never a method, in its own package or in one the
* file does not import (#1857). Read from the source line like the JS/TS
* check, because `pkg.Factory().Method()` reaches the resolver as a bare
* `Method` ref too.
*/
function isBareGoCall(ref: UnresolvedRef, context: ResolutionContext): boolean {
return ref.language === 'go' && isReceiverLessCall(ref, context);
}
function isReceiverLessCall(ref: UnresolvedRef, context: ResolutionContext): boolean {
if (ref.referenceKind !== 'calls') return false;
if (ref.referenceName.includes('.')) return false;
const line = context.getFileLines?.(ref.filePath)?.[ref.line - 1]
?? context.readFile(ref.filePath)?.split('\n')[ref.line - 1];
@@ -678,7 +695,7 @@ function isBareJsCall(ref: UnresolvedRef, context: ResolutionContext): boolean {
const nameEsc = ref.referenceName.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
if (!new RegExp('^' + nameEsc + '\\s*[(<]').test(at)) return false;
// Nothing but whitespace, an operator or an opener may precede a bare call.
return !/[.\w$\]\)]\s*$/.test(line.slice(0, ref.column)) || /\b(?:return|await|yield|typeof|void|new|else|case|throw|in|of|instanceof)\s*$/.test(line.slice(0, ref.column));
return !/[.\w$\]\)]\s*$/.test(line.slice(0, ref.column)) || /\b(?:return|await|yield|typeof|void|new|else|case|throw|in|of|instanceof|go|defer)\s*$/.test(line.slice(0, ref.column));
}
/** Per-context memo: `file\0name` → "the file binds this name locally". */
@@ -751,6 +768,7 @@ export function matchByExactName(
// findBestMatch — O(K²) per package, the dominant cost of "Resolving refs" on
// large import-heavy (front-end + back-end) repos (#915).
const bareJs = isBareJsCall(ref, context);
const bareGo = isBareGoCall(ref, context);
if (bareJs) {
const storeAction = matchJsStoreBindingCall(ref, context);
if (storeAction) return storeAction;
@@ -762,8 +780,8 @@ export function matchByExactName(
// Preserve import ranking; calls reject the winner without promoting another.
.filter((n) => ref.referenceKind !== 'imports' || n.filePath === ref.filePath ||
!ESM_FAMILY.has(n.language) || !isSealedModule(n.filePath, context))
// A receiver-less JS/TS call cannot reach a method (#1714).
.filter((n) => !(bareJs && n.kind === 'method'))
// A receiver-less JS/TS or Go call cannot reach a method (#1714, #1857).
.filter((n) => !((bareJs || bareGo) && n.kind === 'method'))
// A name the file binds itself (a parameter, a const) shadows every other
// file's symbol of that name, so a bare call has no cross-file candidate.
.filter((n) => !(bareJs && n.filePath !== ref.filePath && isLocallyBoundJsName(ref.referenceName, ref.filePath, context)))
@@ -3426,6 +3444,7 @@ export function matchFuzzy(
!(isBareJsCall(ref, context) &&
(finalCandidates[0]!.kind === 'method' ||
(finalCandidates[0]!.filePath !== ref.filePath && isLocallyBoundJsName(ref.referenceName, ref.filePath, context)))) &&
!(finalCandidates[0]!.kind === 'method' && isBareGoCall(ref, context)) &&
isLexicallyReachable(finalCandidates[0]!, ref, context)
) {
const isCrossLanguage = finalCandidates[0]!.language !== ref.language;