diff --git a/__tests__/commonjs-eval-facts.test.ts b/__tests__/commonjs-eval-facts.test.ts
new file mode 100644
index 0000000000..4aaf03690b
--- /dev/null
+++ b/__tests__/commonjs-eval-facts.test.ts
@@ -0,0 +1,58 @@
+import { afterEach, beforeEach, describe, expect, it } from 'vitest';
+import * as fs from 'fs';
+import * as os from 'os';
+import * as path from 'path';
+import { DatabaseConnection } from '../src/db';
+import { QueryBuilder } from '../src/db/queries';
+import type { FileRecord } from '../src/types';
+
+describe('stored CommonJS eval facts', () => {
+ let root: string;
+ let db: DatabaseConnection;
+ let queries: QueryBuilder;
+ const file = (contentHash: string, value?: boolean | null): FileRecord => ({
+ path: 'm.js', contentHash, language: 'javascript', size: 1,
+ modifiedAt: 1, indexedAt: 1, nodeCount: 0, commonJsEvalCall: value,
+ });
+ beforeEach(() => {
+ root = fs.mkdtempSync(path.join(os.tmpdir(), 'cg-eval-facts-'));
+ db = DatabaseConnection.initialize(path.join(root, 'graph.db'));
+ queries = new QueryBuilder(db.getDb());
+ });
+ afterEach(() => { db.close(); fs.rmSync(root, { recursive: true, force: true }); });
+
+ it('does not prove absence from missing, malformed, or inconclusive facts', () => {
+ expect(queries.getCommonJsEvalCall('m.js', 'v1')).toBe(null);
+ queries.upsertFile(file('v1', null));
+ expect(queries.getCommonJsEvalCall('m.js', 'v1')).toBe(null);
+ queries.setMetadata('commonjs_eval:m.js', '{broken');
+ expect(queries.getCommonJsEvalCall('m.js', 'v1')).toBe(null);
+ });
+
+ it('requires an exact source hash and replaces the verdict on a new store', () => {
+ queries.upsertFile(file('v1', false));
+ expect(queries.getCommonJsEvalCall('m.js', 'v1')).toBe(false);
+ expect(queries.getCommonJsEvalCall('m.js', 'v2')).toBe(null);
+ queries.upsertFile(file('v2', true));
+ expect(queries.getCommonJsEvalCall('m.js', 'v1')).toBe(null);
+ expect(queries.getCommonJsEvalCall('m.js', 'v2')).toBe(true);
+ });
+
+ it('rolls back the fact with the surrounding file-store transaction', () => {
+ queries.upsertFile(file('v1', false));
+ expect(() => queries.runInTransaction(() => {
+ queries.upsertFile(file('v2', true));
+ throw new Error('store failed');
+ })).toThrow('store failed');
+ expect(queries.getFileByPath('m.js')!.contentHash).toBe('v1');
+ expect(queries.getCommonJsEvalCall('m.js', 'v1')).toBe(false);
+ });
+
+ it('removes facts with their file instead of leaking them to a replacement', () => {
+ queries.upsertFile(file('v1', true));
+ queries.deleteFile('m.js');
+ expect(queries.getCommonJsEvalCall('m.js', 'v1')).toBe(null);
+ queries.upsertFile(file('v1'));
+ expect(queries.getCommonJsEvalCall('m.js', 'v1')).toBe(null);
+ });
+});
diff --git a/__tests__/commonjs-eval.test.ts b/__tests__/commonjs-eval.test.ts
new file mode 100644
index 0000000000..6f5b7096db
--- /dev/null
+++ b/__tests__/commonjs-eval.test.ts
@@ -0,0 +1,67 @@
+import { beforeAll, describe, expect, it } from 'vitest';
+import { getParser, loadGrammarsForLanguages } from '../src/extraction/grammars';
+import { hasCommonJsEvalCall } from '../src/extraction/commonjs-eval';
+import type { Language } from '../src/types';
+
+beforeAll(async () => { await loadGrammarsForLanguages(['javascript', 'typescript', 'tsx']); });
+
+function read(source: string, language: Language = 'javascript'): boolean | null {
+ const tree = getParser(language)!.parse(source)!;
+ try { return hasCommonJsEvalCall(tree.rootNode); }
+ finally { tree.delete(); }
+}
+
+describe('AST recognition of CommonJS eval calls', () => {
+ it.each([
+ "eval('exports.load = Store.prototype.load;');",
+ "((eval))(('exports.load = Store.prototype.load;'));",
+ 'eval(`exports.load = Store.prototype.load;`);',
+ 'eval(`exports.load = ${code};`);',
+ "if (true) (eval)('exports.load = Store.prototype.load;');",
+ "for (let i = 0; i < 1; i++) ((eval))('exports.load = Store.prototype.load;');",
+ "function setup() { return (eval)('exports.load = Store.prototype.load;'); }",
+ "function* setup() { yield (eval)('exports.load = Store.prototype.load;'); }",
+ "const setup = { *run() { yield (eval)('exports.load = Store.prototype.load;'); } };",
+ "function* setup() { const obj = { [yield (eval)('exports.load = Store.prototype.load;')]() {} }; }",
+ "function yield(value) {} yield(eval('exports.load = Store.prototype.load;'));",
+ 'const text = `${eval(code)}`;',
+ 'eval(code);',
+ ])('finds a real callee: %s', (source) => { expect(read(source)).toBe(true); });
+
+ it.each([
+ 'wrap(eval)("ignored");',
+ 'wrap((eval))(("ignored"));',
+ '(makeWrap())(eval)("ignored");',
+ 'wrap["call"](eval)("ignored");',
+ 'wrap?.(eval)("ignored");',
+ 'wrap`tag`(eval)("ignored");',
+ 'if (eval) ("ignored");',
+ 'function await(value) { return function ignore(code) {}; } await(eval)("ignored");',
+ 'function yield(value) { return function ignore(code) {}; } yield(eval)("ignored");',
+ 'function outer() { function yield(value) {} return yield(eval)("ignored"); }',
+ 'function* outer() { function nested() { function yield(value) {} yield(eval)("ignored"); } }',
+ 'function yield(value) {} const obj = { *[yield(eval)("ignored")]() {} };',
+ 'class Evaluator { eval(code) {} }',
+ 'const text = "eval(code)";',
+ 'const text = `eval(code)`;',
+ '/* eval(code) */',
+ 'const pattern = /eval(code)/;',
+ 'holder.eval(code);',
+ ])('does not confuse another expression with the callee: %s', (source) => {
+ expect(read(source)).toBe(false);
+ });
+
+ it.each(['typescript', 'tsx'] as const)('uses the existing %s grammar', (language) => {
+ expect(read("if (true) (eval)('exports.load = Store.prototype.load;');", language)).toBe(true);
+ expect(read('wrap(eval)("ignored");', language)).toBe(false);
+ });
+
+ it('keeps a real call in a JSX expression', () => {
+ expect(read('
{(eval)(code)}
', 'tsx')).toBe(true);
+ expect(read('eval(code)
', 'tsx')).toBe(false);
+ });
+
+ it('does not prove absence from malformed source', () => {
+ expect(read('function broken( {')).toBe(null);
+ });
+});
diff --git a/__tests__/commonjs-export-evidence-integration.test.ts b/__tests__/commonjs-export-evidence-integration.test.ts
new file mode 100644
index 0000000000..d0889fc466
--- /dev/null
+++ b/__tests__/commonjs-export-evidence-integration.test.ts
@@ -0,0 +1,330 @@
+import { afterEach, describe, expect, it } from 'vitest';
+import * as fs from 'fs';
+import * as os from 'os';
+import * as path from 'path';
+import { CodeGraph } from '../src';
+import { runInNewContext } from 'node:vm';
+import { commonJsValueCases } from './fixtures/commonjs-value-cases';
+import { collectCommonJsExportEvidence as collect } from '../src/resolution/commonjs-export-evidence';
+
+const projects: Array<{ root: string; graph: CodeGraph }> = [];
+afterEach(() => {
+ for (const { root, graph } of projects.splice(0)) {
+ graph.close();
+ fs.rmSync(root, { recursive: true, force: true });
+ }
+});
+
+async function index(files: Record): Promise {
+ const root = fs.mkdtempSync(path.join(os.tmpdir(), 'cg-export-evidence-'));
+ for (const [file, source] of Object.entries(files)) fs.writeFileSync(path.join(root, file), source);
+ const graph = await CodeGraph.init(root, { index: true });
+ projects.push({ root, graph });
+ return graph;
+}
+
+function calls(graph: CodeGraph, name: string): string[] {
+ const node = graph.getNodesByName(name).find(n => n.kind === 'function')!;
+ expect(node).toBeDefined();
+ return graph.getOutgoingEdges(node.id).filter(e => e.kind === 'calls').map(e => {
+ const target = graph.getNode(e.target)!;
+ return `${target.filePath}:${target.qualifiedName}`;
+ }).sort();
+}
+
+describe('export evidence at the real import boundary', () => {
+ describe.each(['./m', './index'])('value identity through %s', specifier => {
+ it.each([
+ 'const api={};api.load=Store.prototype.load;module.exports=api;',
+ 'class Factory{};Factory.load=Store.prototype.load;module.exports=Factory;',
+ 'function Factory(){};Factory.load=Store.prototype.load;module.exports=Factory;',
+ 'const api={};const alias=api;alias.load=Store.prototype.load;module.exports=api;',
+ 'const api={};module.exports=api;api.load=Store.prototype.load;',
+ ])('preserves F14 callable ownership: %s', async assignment => {
+ const source = `class Store { load(){return 42;} }\n${assignment}`;
+ const module = { exports: {} as { load(): number } };
+ runInNewContext(source, { module, exports: module.exports });
+ expect(module.exports.load()).toBe(42);
+ const graph = await index({
+ 'm.js': source,
+ 'index.js': "module.exports=require('./m');",
+ 'decoy.js': 'class Decoy { load() {} }',
+ 'use.js': `const named=require('${specifier}');function run(){return named.load();}`,
+ });
+ expect(calls(graph, 'run')).toEqual(['m.js:Store::load']);
+ });
+
+ it.each(['const value=42;exports.load=value;', 'const value="payload";module.exports={load:value};'])(
+ 'preserves F15 data without creating a call: %s', async source => {
+ const module = { exports: {} as { load(): unknown } };
+ runInNewContext(source, { module, exports: module.exports });
+ expect(() => module.exports.load()).toThrow(/not a function/);
+ const graph = await index({
+ 'm.js': source, 'index.js': "module.exports=require('./m');",
+ 'use.js': `const named=require('${specifier}');function run(){return named.load();}`,
+ });
+ expect(calls(graph, 'run')).toEqual([]);
+ });
+
+ it('follows an assigned callable rather than its storage variable', async () => {
+ const graph = await index({
+ 'm.js': 'function real(){return 42;}let value;value=real;exports.load=value;',
+ 'index.js': "module.exports=require('./m');",
+ 'use.js': `const named=require('${specifier}');function run(){return named.load();}`,
+ });
+ expect(calls(graph, 'run')).toEqual(['m.js:real']);
+ });
+ });
+ it.each([
+ 'const alias = Store.prototype.load; exports.load = alias;',
+ 'const alias = Store.prototype.load; module.exports = { load: alias };',
+ 'const alias = () => 42; module.exports = { load: alias };',
+ ])('follows the exported callable alias: %s', async (assignment) => {
+ const graph = await index({
+ 'm.js': `class Store { load() {} }\n${assignment}`,
+ 'decoy.js': 'class Decoy { load() {} }',
+ 'use.js': "const named = require('./m'); function run() { named.load(); }",
+ });
+ expect(calls(graph, 'run')).toEqual([assignment.includes('=>') ? 'm.js:alias' : 'm.js:Store::load']);
+ });
+
+ it('honors a typed parameter that shadows the import at the call site', async () => {
+ const graph = await index({
+ 'm.js': 'class Store { load() {} } exports.load = Store.prototype.load;',
+ 'use.ts': "import named from './m';\nclass Local { load() {} }\nexport function run(named: Local) { named.load(); }",
+ });
+ expect(calls(graph, 'run')).toEqual(['use.ts:Local::load']);
+ });
+
+ it('honors a lexical class that shadows the import at the call site', async () => {
+ const graph = await index({
+ 'm.js': 'class Store { load() {} } exports.load = Store.prototype.load;',
+ 'use.js': "const named = require('./m');\nfunction outer() { class named { static load() {} } function run() { named.load(); } return run; }",
+ });
+ expect(calls(graph, 'run')).toEqual(['use.js:outer::named::load']);
+ });
+
+ it.each([
+ 'module.exports = makeUnknown();',
+ 'eval(code);',
+ "function eval(code) {} eval('exports.load = Store.prototype.load;');",
+ ])('does not supply a global target for unsupported export behavior: %s', async (mutation) => {
+ const graph = await index({
+ 'm.js': `class Store { load() {} }\n${mutation}`,
+ 'decoy.js': 'class Decoy { load() {} }',
+ 'use.js': "const named = require('./m'); function run() { named.load(); }",
+ });
+ expect(calls(graph, 'run')).toEqual([]);
+ });
+});
+
+
+describe.each(['./m', './index'])('production value contract through %s', specifier => {
+ it.each(commonJsValueCases)('%s', async (_label, source, status, selector) => {
+ const graph = await index({
+ 'm.js': source,
+ 'index.js': "module.exports=require('./m');",
+ 'decoy.js': 'class Decoy { load() {} }',
+ 'use.js': `const named=require('${specifier}');function run(){return named.load();}`,
+ });
+ expect(calls(graph, 'run')).toEqual(status === 'positive' ? [selector] : []);
+ });
+});
+
+describe('callable evidence across import syntax and sync', () => {
+ it.each([
+ "const {load}=require('./index');",
+ "import {load} from './index';",
+ "import load from './index';",
+ ])('does not borrow a global callable for data: %s', async declaration => {
+ const graph = await index({
+ 'm.js': 'const value=42;exports.load=value;',
+ 'index.js': "module.exports=require('./m');",
+ 'decoy.js': 'function load() {}',
+ 'use.js': `${declaration}function run(){return load();}`,
+ });
+ expect(calls(graph, 'run')).toEqual([]);
+ });
+
+ it('retains a local function shadow of a destructured require', async () => {
+ const graph = await index({
+ 'm.js': 'exports.load=42;',
+ 'use.js': "const {load}=require('./m');function outer(){function load(){}function run(){load();}return run;}",
+ });
+ expect(calls(graph, 'run')).toEqual(['use.js:outer::load']);
+ });
+
+ it('recomputes forwarded identity and callability after source edits', async () => {
+ const graph = await index({
+ 'm.js': 'class Store{load(){}}const api={};api.load=Store.prototype.load;module.exports=api;',
+ 'index.js': "module.exports=require('./m');",
+ 'use.js': "const named=require('./index');function run(){named.load();}",
+ });
+ expect(calls(graph, 'run')).toEqual(['m.js:Store::load']);
+ const { root } = projects.find(project => project.graph === graph)!;
+ for (const [source, targets] of [
+ ['const value=42;exports.load=value;', []],
+ ['function real(){}let value;value=real;exports.load=value;', ['m.js:real']],
+ ['function real(){}let value=42;exports.load=value;', []],
+ ['function real(){}let value=real;exports.load=value;', ['m.js:real']],
+ ['const api={};module.exports=api;', []],
+ ['class Store{load(){}}const api={};module.exports=api;api.load=Store.prototype.load;', ['m.js:Store::load']],
+ ] as const) {
+ fs.writeFileSync(path.join(root, 'm.js'), source);
+ await graph.sync();
+ expect(calls(graph, 'run')).toEqual(targets);
+ }
+ });
+});
+
+
+describe.each(['./m', './index'])('named CommonJS values through %s', specifier => {
+ it.each([
+ `const {load}=require('${specifier}');`,
+ `import {load} from '${specifier}';`,
+ ])('follows the stored callable through %s', async declaration => {
+ const graph = await index({
+ 'm.js': 'function real(){}let value;value=real;exports.load=value;',
+ 'index.js': "module.exports=require('./m');",
+ 'use.js': `${declaration}function run(){load();}`,
+ });
+ expect(calls(graph, 'run')).toEqual(['m.js:real']);
+ });
+
+ it('selects a callable property of a named object import', async () => {
+ const graph = await index({
+ 'm.js': 'function real(){}const api={load:real};exports.api=api;',
+ 'index.js': "module.exports=require('./m');",
+ 'use.js': `import {api} from '${specifier}';function run(){api.load();}`,
+ 'decoy.js': 'class Decoy{load(){}}',
+ });
+ expect(calls(graph, 'run')).toEqual(['m.js:real']);
+ });
+
+ it('keeps class construction separate from ordinary calls', async () => {
+ const graph = await index({
+ 'm.js': 'class Store{}module.exports=Store;',
+ 'index.js': "module.exports=require('./m');",
+ 'use.js': `const Made=require('${specifier}');function run(){Made();}function construct(){return new Made();}`,
+ 'decoy.js': 'function Made(){}',
+ });
+ expect(calls(graph, 'run')).toEqual([]);
+ const construct = graph.getNodesByName('construct').find(node => node.kind === 'function')!;
+ expect(graph.getOutgoingEdges(construct.id).filter(edge => edge.kind === 'instantiates').map(edge => graph.getNode(edge.target)?.qualifiedName)).toEqual(['Store']);
+ });
+});
+
+describe('opaque writes on a known value stay no-edge', () => {
+ it.each([
+ 'const api={load:Store.prototype.load};opaque(api);module.exports=api;',
+ 'const api={load:Store.prototype.load};api[key]=42;module.exports=api;',
+ ])('never resolves to the storage variable: %s', async exportSource => {
+ const graph = await index({ 'm.js': `class Store { load() { return 42; } }\n${exportSource}`,
+ 'use.js': "const named=require('./m');function run(){return named.load();}" });
+ expect(calls(graph, 'run')).toEqual([]);
+ });
+});
+
+describe('sloppy-mode CommonJS stays in the model (F18)', () => {
+ const use = "const svc=require('./m');const {helper}=require('./m');function run(){helper();return svc.main();}";
+ const body = 'function main(){ return 1; }\nfunction helper(){ return 2; }\nmodule.exports = { main, helper };\n';
+ it.each([
+ ['hashbang', `#!/usr/bin/env node\n${body}`],
+ ['hashbang after a BOM', `\uFEFF#!/usr/bin/env node\n${body}`],
+ ['legacy octal literal', `var mode = 0755;\n${body}`],
+ ['legacy octal escape', `var s = "\\101";\n${body}`],
+ ['leading-zero decimal', `var n = 08;\n${body}`],
+ ['escape \\8', `var s = "\\8";\n${body}`],
+ ['Flow annotations', '// @flow\nfunction main(x: number): number { return x; }\nfunction helper(){ return 2; }\nmodule.exports = { main, helper };\n'],
+ ])('%s', async (_label, source) => {
+ expect(collect(source, 'm.js').commonJs).toBe(true);
+ expect(calls(await index({ 'm.js': source, 'use.js': use }), 'run')).toEqual(['m.js:helper', 'm.js:main']);
+ });
+
+ it('a hashbang ended by a lone CR stays modelled and resolves as before', async () => {
+ // The extractor reads `function main` as part of the hashbang line, as it
+ // did before the model; only helper has a node to resolve to.
+ const source = `#!/usr/bin/env node\r${body}`;
+ expect(collect(source, 'm.js').members).toHaveProperty('main');
+ expect(calls(await index({ 'm.js': source, 'use.js': use }), 'run')).toEqual(['m.js:helper']);
+ });
+
+ it('an unparsable file leaves the model', () => {
+ expect(collect(`