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(`