Files
codegraph/__tests__/esm-unexported-binding.test.ts
Colby Mchenry d03e4dbf6f fix(resolution): an unexported ES module binding is not reachable cross-file (#2162)
The sealed-module rule only covered modules exporting nothing. Per symbol
now: a JS/TS top-level binding declared by its own statement (function,
const/let/var, class, interface, type, enum) in an ES module is reachable
from another file only if it's exported — the `export` keyword (node flag or
its line), an `export { a as b }` list, `export default x` or
`export default { a, b }`. Classic scripts, CommonJS, `declare global`,
`.d.ts`, qualified members, object-literal members (zustand store actions)
and `proto.x = function x()` are exempt. Applied at the reachability gate,
so the winner is rejected rather than another promoted.

A/B (edges removed / added): zod -229 (package `z` imports → a local `z`),
excalidraw -66 (DOM `Element` → a test helper's type), kit -50, typeorm -47
(`import { ColumnMetadata } from "typeorm"` → a fixture's local), trpc -26,
hono -8, axios -1; express byte-identical.

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-30 12:07:48 +00:00

78 lines
2.7 KiB
TypeScript

/**
* A top-level binding an ES module doesn't export can't be named from another
* file. sveltekit's `generate_manifest.js` keeps an unexported `resolve` that
* other files' `resolve(…)` calls went to; typeorm's test inputs' local
* `ColumnMetadata` took `import { ColumnMetadata } from "typeorm"`. Exported
* forms all still count: the `export` keyword, `export { a as b }`,
* `export default x`, `export default { a, b }`; so do classic scripts,
* CommonJS files and functions assigned onto a prototype.
*/
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;
beforeAll(async () => {
root = fs.mkdtempSync(path.join(os.tmpdir(), 'cg-esm-unexported-'));
const files: Record<string, string> = {
'package.json': JSON.stringify({ name: 'app', type: 'module' }),
'src/manifest.js': `import { join } from 'node:path';
function resolve(event) { return join(event); }
export function manifest() { return resolve('x'); }
`,
'src/adapters.js': `import { a } from './a.js';
function getAdapter(name) { return name; }
export default { getAdapter };
`,
'src/listed.js': `import { a } from './a.js';
function helper() { return 1; }
export { helper };
`,
'src/proto.js': `import { a } from './a.js';
function Params() {}
const prototype = Params.prototype;
prototype.render = function render() { return ''; };
export default Params;
`,
'src/a.js': `export const a = 1;
`,
'src/use.js': `import { helper } from './listed.js';
import Params from './proto.js';
export async function handle(event) {
await resolve(event);
getAdapter('xhr');
helper();
new Params().render();
}
`,
};
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 });
});
afterAll(() => {
cg?.close();
if (root) fs.rmSync(root, { recursive: true, force: true });
});
describe('unexported ES module bindings', () => {
it('are not reached from another file; every export form still is', () => {
const handle = cg.getNodesInFile('src/use.js').find((n) => n.name === 'handle')!;
const targets = cg
.getOutgoingEdges(handle.id)
.filter((e) => e.kind === 'calls')
.map((e) => `${cg.getNode(e.target)!.filePath}:${cg.getNode(e.target)!.name}`);
expect(targets).not.toContain('src/manifest.js:resolve');
expect(targets).toContain('src/adapters.js:getAdapter');
expect(targets).toContain('src/listed.js:helper');
expect(targets).toContain('src/proto.js:render');
});
});