Files
codegraph/__tests__/bare-call-no-method.test.ts
T
cd4e65b59c fix(resolution): a receiver-less JS/TS call never binds to a method (#1759)
`serialize(this.raw)` inside `Record.serialize`, with a module-scope
`function serialize` in the same file, resolved onto the method itself:
both were exact-name candidates, both same-file, and findBestMatch's
line-proximity term always prefers the enclosing method (#1714). In JS/TS a
call written without a receiver cannot reach a method at all — methods
need `this.`, an object, or a bound reference.

The extractor emits `this.m()` and `super.m()` under the bare method name,
so the receiver is read back from the call site's own line: when the text
there begins with the name itself and nothing but whitespace, an operator
or an opener precedes it, the call is bare, and `method` nodes leave the
candidate set before ranking. matchFuzzy declines a lone `method` survivor
for the same ref. A name the file binds itself also has no cross-file
candidate for a bare call. `this.serialize()` and `other.serialize()` are
unchanged.

Rebased #1735 onto current main (resolved conflicts with sealed-module /
cross-file visibility guards from #1719/#1730/#1731).

Fixes #1714

Co-authored-by: Colby McHenry <colbymchenry@users.noreply.github.com>
Co-authored-by: danusha2345 <ewidusoc498@gmail.com>
2026-09-08 02:22:16 -05:00

149 lines
5.5 KiB
TypeScript

/**
* In JS/TS a receiver-less call can never bind to a class method: `serialize(x)`
* inside `Record.serialize` means the module-scope function, and the method
* itself — which the same-file proximity term used to pick, producing a
* self-edge — is not a candidate (#1714). `this.serialize(x)` still is.
*/
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 callsFromMethod(source: string, methodName: string): Promise<string[]> {
tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'codegraph-1714-'));
fs.writeFileSync(path.join(tempDir, 'record.ts'), source);
cg = await CodeGraph.init(tempDir, { index: true });
cg.resolveReferences();
const from = cg.getNodesByKind('method').find((n) => n.name === methodName)!;
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.qualifiedName ?? n.name}`);
}
afterEach(() => {
cg?.close();
cg = null;
fs.rmSync(tempDir, { recursive: true, force: true });
});
describe('a receiver-less JS/TS call never binds to a method (#1714)', () => {
it('resolves the bare call onto the module-scope function, not the enclosing method', async () => {
const callees = await callsFromMethod(
[
'function serialize(value: string): string {',
' return value.trim();',
'}',
'',
'export class Record {',
' constructor(private readonly raw: string) {}',
' serialize(): string {',
' return serialize(this.raw);',
' }',
'}',
'',
].join('\n'),
'serialize'
);
expect(callees).toContain('function:serialize');
expect(callees).not.toContain('method:Record::serialize');
});
it('keeps `this.serialize()` — a real recursive self-call', async () => {
const callees = await callsFromMethod(
[
'function serialize(value: string): string {',
' return value.trim();',
'}',
'',
'export class Record {',
' constructor(private readonly raw: string, private depth = 0) {}',
' serialize(): string {',
' if (this.depth > 0) return this.serialize();',
' return this.raw;',
' }',
'}',
'',
].join('\n'),
'serialize'
);
expect(callees).toContain('method:Record::serialize');
});
it('a bare call to a name the file binds itself has no cross-file candidate', async () => {
tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'codegraph-1714-'));
fs.writeFileSync(path.join(tempDir, 'config.ts'), 'export function resolve(p: string) { return p; }\nexport function transform(c: string) { return c; }\nexport function now() { return 0; }\n');
fs.writeFileSync(
path.join(tempDir, 'client.ts'),
[
'const transform = makeTransform();',
'export function ping(): Promise<void> {',
' return new Promise((resolve, reject) => {',
' setTimeout(() => resolve(), 10);',
' });',
'}',
'export function run(options: { now?: () => number }) {',
' const now = options.now || (() => Date.now());',
' return now() + transform("x").length;',
'}',
'',
].join('\n')
);
cg = await CodeGraph.init(tempDir, { index: true });
cg.resolveReferences();
const targets = cg.getNodesByKind('function').filter((n) => n.filePath === 'config.ts').map((n) => n.id);
const callers = cg.getNodesByKind('function').filter((n) => n.filePath === 'client.ts');
const crossFile = callers.flatMap((c) => cg!.getOutgoingEdges(c.id)).filter((e) => e.kind === 'calls' && targets.includes(e.target));
expect(crossFile).toEqual([]);
});
it('a destructured require or a string mentioning the name is not a local binding', async () => {
tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'codegraph-1714-'));
fs.writeFileSync(path.join(tempDir, 'public-ip.js'), 'function lookupPublicIPv4() { return "1.2.3.4"; }\nfunction test(name, fn) { return fn(); }\nmodule.exports = { lookupPublicIPv4, test };\n');
fs.writeFileSync(
path.join(tempDir, 'main.js'),
[
'const { lookupPublicIPv4 } = require("./public-ip");',
'const { test } = require("./public-ip");',
'async function prepare() {',
' const ip = await lookupPublicIPv4();',
' test("a test of the thing", () => {});',
' return ip;',
'}',
'module.exports = { prepare };',
'',
].join('\n')
);
cg = await CodeGraph.init(tempDir, { index: true });
cg.resolveReferences();
const prepare = cg.getNodesByKind('function').find((n) => n.name === 'prepare')!;
const names = cg.getOutgoingEdges(prepare.id).filter((e) => e.kind === 'calls').map((e) => cg!.getNode(e.target)?.name);
expect(names).toContain('lookupPublicIPv4');
expect(names).toContain('test');
});
it('keeps `other.serialize()` — a call through a receiver', async () => {
const callees = await callsFromMethod(
[
'export class Record {',
' serialize(): string { return ""; }',
' copyOf(other: Record): string {',
' return other.serialize();',
' }',
'}',
'',
].join('\n'),
'copyOf'
);
expect(callees).toContain('method:Record::serialize');
});
});