mirror of
https://github.com/colbymchenry/codegraph.git
synced 2026-10-17 05:07:37 +08:00
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:
@@ -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');
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user