fix(js,ts): a nested function takes calls to a name the file imports from a package

When a JavaScript or TypeScript file imports a name from outside the
repository, like import { resolve } from 'node:path', matchByExactName
returns no edge for a bare call of that name (the early return main added
for issue 2101). A call to a function declared inside the calling function
under that name, which shadows the import, lost its edge with it.

The candidates for such a call are now narrowed to the calling file's
declarations nested in a function or method (innerScopeShadows), instead
of giving up; a top-level one is the import's own binding. Lexical
reachability, applied later, keeps only the nested declarations visible
where the call is.

Our changes: tests for a sibling function's nested declaration and one
nested a level below the call, which neither may take the call (both hold
through the reachability rule), and for a call from a function nested in
the declaring one; the CHANGELOG bullet moved next to the JS/TS import
bullets, with credit.

Validation: the tests fail 2 of 6 on main and pass here. A/B against main
on 8 repos, kernel per arm: flask, gin, excalidraw, codegraph, pixijs,
pdf.js and three.js byte-identical; vite gains 1 right link, scanImports'
call to its nested scan() beside import { scan } from
'rolldown/experimental'. Index CPU
time on codegraph, test files full of vitest imports, within noise:
81.7 s to 81.9 s (median of 3, interleaved).

Original PR #1715 by @danusha2345.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
danusha2345
2026-10-23 18:58:14 +00:00
committed by Colby Mchenry
co-authored by Claude Opus 5.5
parent 0d46dba08f
commit 428b3c487d
3 changed files with 125 additions and 4 deletions
@@ -0,0 +1,99 @@
/**
* A bare JS/TS call through a name imported from outside the repository
* (`import { resolve } from 'node:path'`) means the package's function, never
* another file's same-named symbol. A declaration of that name in an inner
* scope of the calling file still shadows the import, and the call is that
* local declaration's.
*/
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;
function project(files: Record<string, string>): void {
tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'codegraph-import-shadow-'));
for (const [rel, content] of Object.entries(files)) {
const abs = path.join(tempDir, rel);
fs.mkdirSync(path.dirname(abs), { recursive: true });
fs.writeFileSync(abs, content);
}
}
async function callTargets(caller: string): Promise<string[]> {
cg = await CodeGraph.init(tempDir, { index: true });
cg.resolveReferences();
const from = cg.getNodesByKind('function').find((n) => n.name === caller)!;
expect(from).toBeDefined();
return cg.getOutgoingEdges(from.id).filter((e) => e.kind === 'calls')
.map((e) => { const t = cg!.getNode(e.target)!; return `${t.filePath}:${t.name}:${t.startLine}`; });
}
afterEach(() => {
cg?.close();
cg = null;
fs.rmSync(tempDir, { recursive: true, force: true });
});
describe('a name imported from outside the repository', () => {
it('is shadowed by a declaration in an inner scope of the calling file', async () => {
project({
'plugin.ts': "export function resolve() { return 'plugin'; }",
'config.ts':
"import { resolve } from 'node:path';\n" +
"export function configure() {\n function resolve() { return 'local'; }\n return resolve();\n}\n",
});
expect(await callTargets('configure')).toEqual(['config.ts:resolve:3']);
});
it('is not shadowed by a declaration nested in a sibling function', async () => {
// `b` calls the package's `resolve`: the one nested in `a` is out of scope there.
project({
'plugin.ts': "export function resolve() { return 'plugin'; }",
'config.ts':
"import { resolve } from 'node:path';\n" +
"export function a() {\n function resolve() { return 'local'; }\n return resolve();\n}\n" +
"export function b() {\n return resolve('src');\n}\n",
});
expect(await callTargets('b')).toEqual([]);
});
it('is not shadowed by a declaration nested a level below the call', async () => {
project({
'config.ts':
"import { resolve } from 'node:path';\n" +
"export function outer() {\n function mid() { function resolve() { return 1; } return resolve(); }\n return resolve('x') + mid();\n}\n",
});
expect((await callTargets('outer')).filter((t) => t.includes(':resolve:'))).toEqual([]);
});
it('is shadowed in a function nested inside the declaring one', async () => {
project({
'config.ts':
"import { resolve } from 'node:path';\n" +
"export function configure() {\n function resolve() { return 'local'; }\n function inner() { return resolve(); }\n return inner();\n}\n",
});
expect(await callTargets('inner')).toEqual(['config.ts:resolve:3']);
});
it('binds no other file\'s symbol of that name', async () => {
project({
'package.json': JSON.stringify({ name: 'app', dependencies: { 'external-resolver': '^1.0.0' } }),
'plugin.ts': "export function resolve() { return 'plugin'; }",
'config.ts': "import { resolve } from 'external-resolver';\nexport function configure() { return resolve('src'); }\n",
});
expect(await callTargets('configure')).toEqual([]);
});
it('is not shadowed by its own top-level require binding', async () => {
project({
'package.json': JSON.stringify({ name: 'app', devDependencies: { supertest: '^6' } }),
'app.js': "var request = require('supertest');\nfunction run(app) { return request(app); }\nmodule.exports = run;\n",
});
expect(await callTargets('run')).toEqual([]);
});
});