Files
codegraph/__tests__/dynamic-import-binding.test.ts
T
Colby MchenryandClaude Opus 5.5 7180fd0666 fix(js,ts): a dynamic import from Node or a package no longer links to a project namesake (#2788)
Issue 2491 was reported against 1.6.1: `import { resolve } from 'node:path'`,
bare `'fs'` and `require('node:fs')` bindings linked their calls to
same-named project functions. Main no longer does that for any static
form. PR 2120 (released in 1.6.2) declines a name match for a name the
file imports from outside the repository. PR 2437 made that hold in
projects with a catch-all tsconfig path alias, and PR 2671 extended it to
function values such as `promisify(readFile)`.

What still linked is the same binding written as a dynamic import. The
out-of-repo check reads the file's static import mappings only. The name
matcher deliberately treats `const { x } = await import('./m')` as an
import, not a local shadow, so the call fell through to a same-named
project function. vite's
`const { createServer } = await import('node:http'); return createServer(app)`
linked to vite's own `createServer`.

Fix (name-matcher.ts):
- isOutOfRepoBinding also asks dynamicImportSource for the module a
  dynamic import binds the name from, where the reference sits.
- A per-file scan (FIFO of 64, cleared with the other name-matcher memos)
  finds `const { a, b: c, default: d } = await import('x')` declarations
  and `import('x').then(({ a }) => ...)` callbacks on the raw text. Only
  files with a match are comment- and string-blanked, to drop matches in
  comments and strings and to find where each binding's scope ends: the
  enclosing block (a top-level await binds the whole module) or the
  callback body.
- A binding counts only when the reference sits inside that scope after
  it, and the innermost one wins, also over a static import of the name.
  nitro's `async function createWebSocketProxy()` imports crossws's
  `createWebSocketProxy` inside itself: only that inner call is dropped,
  and the file's other calls still reach the local function.
- jsBlockEnd and jsBodyEnd are the forward forms of jsBlockOpenAtEnd and
  jsBodyOpenAtEnd, which now call them.

Validation:
- __tests__/dynamic-import-binding.test.ts: 5 of 6 tests fail with main's
  name-matcher.ts (the project-module control passes there); all 6 pass on
  the kernel and with CODEGRAPH_KERNEL=0. Four mutations each fail their
  test: no scope end, no callback form, no dynamic source, and no memo
  clearing (the in-process sync test).
- Sync equals a fresh index (CLI) for adding and removing the dynamic
  import, for the namesake file arriving later, and for the nitro shape.
- A/B on 9 TS/JS repos plus 2 controls, both arms with the kernel: 6 edges
  removed, 0 added, 0 retargeted, all 6 wrong. vite has 3 (node:http
  createServer, node:fs/promises readFile, rolldown watch), memfs 2
  (node:util promisify), nitro 1 (the crossws self-loop). pathe, c12,
  unbuild, fs-extra, unenv and nuxt are byte-identical, as are the flask
  and gin controls.
- Cost: fresh index median of 3, interleaved: vite 9.17 s main vs 9.50 s,
  nitro 7.43 s vs 7.12 s (noise on a shared box). The scan itself over
  every vite source file takes 40 ms (24 files reach the blanking pass).
- Kernel parity is unchanged: 772/776 files byte-parity, 4 deferred,
  over nitro/src and vite's src (this is a resolver-only change).

Fixes #2491

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-22 07:10:12 +00:00

169 lines
6.8 KiB
TypeScript

/**
* Issue #2491: a function imported from a Node built-in names nothing in the
* project. The static forms (`import { resolve } from 'node:path'`, bare
* `'fs'`, `require('node:fs')`) stopped linking to same-named project
* functions in #2120; a DYNAMIC import still did. vite's
* `const { createServer } = await import('node:http'); return createServer(app)`
* linked to vite's own `createServer`, and `readFile` from
* `await import('node:fs/promises')` to a playground helper.
*
* The binding counts only where it is in scope: nitro's
* `async function createWebSocketProxy()` imports crossws's
* `createWebSocketProxy` inside itself, so the call there is crossws's, while
* the file's other calls still mean the local function.
*/
import { describe, it, expect, afterAll, beforeAll } from 'vitest';
import * as fs from 'fs';
import * as os from 'os';
import * as path from 'path';
import { CodeGraph } from '../src';
let root = '';
let cg: CodeGraph;
const FILES: Record<string, string> = {
'package.json': JSON.stringify({ name: 'app', type: 'module', dependencies: { crossws: '^0.3.0', chalk: '^5.0.0' } }),
'src/server/index.ts': `export function createServer(app: unknown): unknown { return app; }
`,
'src/io.ts': `export function readFile(p: string): string { return p; }
export function chalk(s: string): string { return s; }
export class Worker { constructor(readonly file: string) {} }
`,
'src/helper.ts': `export function helper(): number { return 1; }
`,
'src/http.ts': `export async function resolveHttpServer(app: unknown) {
const { createServer } = await import('node:http');
return createServer(app as never);
}
`,
'src/wasm.ts': `const instantiateFromFile = async (fileUrl: string) => {
const { readFile: read, readFile } = await import('node:fs/promises');
await read(fileUrl);
return readFile(fileUrl);
};
export async function paint(s: string) {
const { default: chalk } = await import('chalk');
return chalk(s);
}
export function later(p: string) {
return import('node:fs/promises').then(({ readFile }) => readFile(p));
}
export async function spawn() {
const { Worker } = await import('node:worker_threads');
return new Worker('x');
}
export async function wrap() {
const { readFile } = await import('node:fs/promises');
return [readFile].map((f) => f);
}
export { instantiateFromFile };
`,
'src/proxy.ts': `async function createWebSocketProxy(opts: string) {
const { createWebSocketProxy } = await import('crossws');
return createWebSocketProxy(opts as never);
}
export function outer(o: string) { return createWebSocketProxy(o); }
export async function inner(p: string) {
if (p) {
const { readFile } = await import('node:fs/promises');
await readFile(p);
}
return readFile(p);
}
export async function project() {
const { helper } = await import('./helper');
return helper();
}
`,
'src/top.mjs': `const { readFile } = await import('node:fs/promises');
export async function fromTop(p) { return readFile(p); }
`,
};
beforeAll(async () => {
root = fs.mkdtempSync(path.join(os.tmpdir(), 'cg-dynamic-import-'));
for (const [rel, content] of Object.entries(FILES)) {
fs.mkdirSync(path.dirname(path.join(root, rel)), { recursive: true });
fs.writeFileSync(path.join(root, rel), content);
}
cg = await CodeGraph.init(root, { index: true });
}, 60_000);
afterAll(() => {
cg?.close();
if (root) fs.rmSync(root, { recursive: true, force: true });
});
/** `file:line name` of every edge of `kinds` leaving `file`. */
function edgesFrom(file: string, kinds: string[]): string[] {
const out: string[] = [];
for (const node of cg.getNodesInFile(file)) {
for (const e of cg.getOutgoingEdges(node.id)) {
if (!kinds.includes(e.kind)) continue;
const target = cg.getNode(e.target)!;
out.push(`${e.line} ${target.filePath}:${target.name}`);
}
}
return out;
}
describe('a name a dynamic import binds from outside the project (#2491)', () => {
it("never links to a same-named project function: vite's `await import('node:http')`", () => {
expect(edgesFrom('src/http.ts', ['calls'])).not.toContain('3 src/server/index.ts:createServer');
});
it('holds for renamed and default bindings, `.then` destructuring, constructors and function values', () => {
const edges = edgesFrom('src/wasm.ts', ['calls', 'instantiates', 'references', 'function_ref']);
expect(edges.filter((e) => e.includes('src/io.ts'))).toEqual([]);
});
it('holds for a top-level `await import()` binding', () => {
expect(edgesFrom('src/top.mjs', ['calls']).filter((e) => e.includes('src/io.ts'))).toEqual([]);
});
it("binds only where it is in scope: nitro's function importing its own name", () => {
const edges = edgesFrom('src/proxy.ts', ['calls']);
// Inside the function, `createWebSocketProxy(opts)` is crossws's: no self-loop.
expect(edges).not.toContain('3 src/proxy.ts:createWebSocketProxy');
// Outside it, the call still means the file's own function.
expect(edges).toContain('5 src/proxy.ts:createWebSocketProxy');
// Inside the `if` block the name is node's; after the block it is bound by nothing, as before.
expect(edges).not.toContain('9 src/io.ts:readFile');
expect(edges).toContain('11 src/io.ts:readFile');
});
it("keeps a dynamic import of the project's own module", () => {
expect(edgesFrom('src/proxy.ts', ['calls'])).toContain('15 src/helper.ts:helper');
});
});
describe('sync reads the dynamic import an edit adds or removes (#2491)', () => {
it('drops the name match when the import appears and restores it when it goes', async () => {
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'cg-dynamic-import-sync-'));
const write = (rel: string, content: string): void => {
fs.mkdirSync(path.dirname(path.join(dir, rel)), { recursive: true });
fs.writeFileSync(path.join(dir, rel), content);
};
const plain = 'export async function a(p: string) {\n return readFile(p);\n}\n';
const dynamic = "export async function a(p: string) {\n const { readFile } = await import('node:fs/promises');\n return readFile(p);\n}\n";
write('src/io.ts', 'export function readFile(p: string): string { return p; }\n');
write('src/use.ts', plain);
const graph = await CodeGraph.init(dir, { index: true });
const calls = (): string[] =>
graph.getNodesInFile('src/use.ts').flatMap((n) =>
graph.getOutgoingEdges(n.id).filter((e) => e.kind === 'calls').map((e) => `${graph.getNode(e.target)!.filePath}:${graph.getNode(e.target)!.name}`));
try {
expect(calls()).toContain('src/io.ts:readFile');
write('src/use.ts', dynamic);
await graph.sync();
expect(calls()).not.toContain('src/io.ts:readFile');
write('src/use.ts', plain);
await graph.sync();
expect(calls()).toContain('src/io.ts:readFile');
} finally {
graph.close();
fs.rmSync(dir, { recursive: true, force: true });
}
}, 60_000);
});