diff --git a/src/domain/javascript/javascriptSemanticAnalysis.empty_keys.test.ts b/src/domain/javascript/javascriptSemanticAnalysis.empty_keys.test.ts index 168b3a36ca..1ef0d72af8 100644 --- a/src/domain/javascript/javascriptSemanticAnalysis.empty_keys.test.ts +++ b/src/domain/javascript/javascriptSemanticAnalysis.empty_keys.test.ts @@ -56,7 +56,7 @@ it("retains exact empty method names and environment keys", () => { ]); }); -it("retains exact empty require paths without dynamic-key links", () => { +it("retains exact empty require paths and dependencies without dynamic-key bindings", () => { const ir = analyzeJavaScriptSemantics(` const { '': fromPattern } = require("./table.js"); const fromMember = require("./table.js")['']; @@ -84,6 +84,18 @@ it("retains exact empty require paths without dynamic-key links", () => { localName: "fromMember", exportedName: null, }, + { + kind: "require", + importedName: null, + localName: null, + exportedName: null, + }, + { + kind: "require", + importedName: null, + localName: null, + exportedName: null, + }, { kind: "commonjs-export", importedName: "", diff --git a/src/domain/javascript/javascriptSemanticAnalysis.member_depth.test.ts b/src/domain/javascript/javascriptSemanticAnalysis.member_depth.test.ts index 9d62f0967d..964551a7d6 100644 --- a/src/domain/javascript/javascriptSemanticAnalysis.member_depth.test.ts +++ b/src/domain/javascript/javascriptSemanticAnalysis.member_depth.test.ts @@ -39,7 +39,7 @@ it("preserves a 12,000-member native import through both analyzers", () => { ]); }); -it("retains a deep CommonJS re-export path without inventing dynamic module links", () => { +it("retains deep CommonJS paths and dependencies without inventing dynamic bindings", () => { const ir = analyzeJavaScriptSemantics(` module.exports.result = require("./dependency.js")${ordinaryMemberChain}.last; const dynamic = require("./other.js")${ordinaryMemberChain}[key].last; @@ -51,6 +51,13 @@ it("retains a deep CommonJS re-export path without inventing dynamic module link importedName: "last", exportedName: "result", }), + expect.objectContaining({ + kind: "require", + specifier: "./other.js", + importedName: null, + localName: null, + exportedName: null, + }), ]); expect(onlyBinding(ir, "dynamic").provenance).toMatchObject({ status: "unknown", diff --git a/src/domain/javascript/javascriptSemanticAnalysis.require_links.test.ts b/src/domain/javascript/javascriptSemanticAnalysis.require_links.test.ts new file mode 100644 index 0000000000..830bc08ba8 --- /dev/null +++ b/src/domain/javascript/javascriptSemanticAnalysis.require_links.test.ts @@ -0,0 +1,154 @@ +import { describe, expect, it } from "vitest"; + +import { analyzeJavaScriptSemantics } from "./javascriptSemanticAnalysis.js"; +import { + origin, + topLevelBinding, +} from "./javascriptSemanticAnalysis.fixture.js"; + +describe("CommonJS dependency links", () => { + it.each([ + 'require("dependency");', + 'void require("dependency");', + 'consume(require("dependency"));', + 'require("dependency").member;', + 'require("dependency")[key];', + 'const {} = require("dependency");', + 'const [] = require("dependency");', + 'let assigned; assigned = require("dependency");', + 'function run(value = require("dependency")) {}', + '(require("dependency") as unknown);', + ])("retains an unbound dependency in %s", (source) => { + const ir = analyzeJavaScriptSemantics(source); + expect(ir.moduleLinks).toEqual([ + expect.objectContaining({ + kind: "require", + specifier: "dependency", + importedName: null, + localName: null, + exportedName: null, + callableId: null, + }), + ]); + }); + + it.each([ + 'function run(require) { require("dependency"); }', + 'require("dependency"); const require = loader;', + 'function run() { require("dependency"); var require; }', + 'with (environment) { require("dependency"); }', + "require(dynamicSpecifier);", + ])("does not invent a static global dependency in %s", (source) => { + expect(analyzeJavaScriptSemantics(source).moduleLinks).toEqual([]); + }); + + it("keeps bindings and re-exports without extra dependency links", () => { + const ir = analyzeJavaScriptSemantics(` + const bound = (require("bound") as unknown); + const { left, right } = require("destructured"); + module.exports = require("forwarded"); + exports.member = require("member").value; + exports.first = exports.second = require("chained"); + `); + expect( + ir.moduleLinks.map(({ kind, specifier, localName, exportedName }) => ({ + kind, + specifier, + localName, + exportedName, + })), + ).toEqual([ + { + kind: "require", + specifier: "bound", + localName: "bound", + exportedName: null, + }, + { + kind: "require", + specifier: "destructured", + localName: "left", + exportedName: null, + }, + { + kind: "require", + specifier: "destructured", + localName: "right", + exportedName: null, + }, + { + kind: "commonjs-export", + specifier: "forwarded", + localName: null, + exportedName: "default", + }, + { + kind: "commonjs-export", + specifier: "member", + localName: null, + exportedName: "member", + }, + { + kind: "commonjs-export", + specifier: "chained", + localName: null, + exportedName: "first", + }, + { + kind: "commonjs-export", + specifier: "chained", + localName: null, + exportedName: "second", + }, + ]); + expect( + ir.bindings.find(({ name }) => name === "bound")?.provenance.origins, + ).toEqual([{ specifier: "bound", importedPath: [] }]); + }); + + it.each([ + 'require("outer", require("inner"));', + 'const bound = require("outer", require("inner"));', + 'module.exports = require("outer", require("inner"));', + ])("retains a distinct nested call in %s", (source) => { + const links = analyzeJavaScriptSemantics(source).moduleLinks; + expect(links.map(({ specifier }) => specifier)).toEqual(["outer", "inner"]); + expect(links[1]).toMatchObject({ + kind: "require", + importedName: null, + localName: null, + exportedName: null, + }); + }); +}); + +it("preserves unshadowed require origins through aliases and assignments", () => { + const ir = analyzeJavaScriptSemantics(` + const { ipcMain: bus } = require("electron"); + const forwarded = bus; + let assigned; + assigned = require("electron").ipcMain; + { const require = localLoader; require("./local.js"); } + `); + for (const name of ["bus", "forwarded"]) + expect(origin(topLevelBinding(ir, name))).toEqual({ + specifier: "electron", + importedPath: ["ipcMain"], + }); + expect(topLevelBinding(ir, "assigned").provenance).toMatchObject({ + status: "ambiguous", + origins: [{ specifier: "electron", importedPath: ["ipcMain"] }], + }); + expect(ir.moduleLinks.filter(({ kind }) => kind === "require")).toEqual([ + expect.objectContaining({ + specifier: "electron", + importedName: "ipcMain", + localName: "bus", + }), + expect.objectContaining({ + specifier: "electron", + importedName: null, + localName: null, + }), + ]); +}); diff --git a/src/domain/javascript/javascriptSemanticAnalysis.structure.test.ts b/src/domain/javascript/javascriptSemanticAnalysis.structure.test.ts index 3afd181d28..7405bf60e2 100644 --- a/src/domain/javascript/javascriptSemanticAnalysis.structure.test.ts +++ b/src/domain/javascript/javascriptSemanticAnalysis.structure.test.ts @@ -112,28 +112,6 @@ describe("JavaScript semantic analysis: structure 1", () => { }, ); - it("preserves unshadowed require origins through aliases and assignments", () => { - const ir = analyzeJavaScriptSemantics(` - const { ipcMain: bus } = require("electron"); - const forwarded = bus; - let assigned; - assigned = require("electron").ipcMain; - { const require = localLoader; require("./local.js"); } - `); - for (const name of ["bus", "forwarded"]) - expect(origin(topLevelBinding(ir, name))).toEqual({ - specifier: "electron", - importedPath: ["ipcMain"], - }); - expect(topLevelBinding(ir, "assigned").provenance).toMatchObject({ - status: "ambiguous", - origins: [{ specifier: "electron", importedPath: ["ipcMain"] }], - }); - expect( - ir.moduleLinks.filter(({ kind }) => kind === "require"), - ).toHaveLength(1); - }); - it("propagates literal, template, object, conditional, and destructured values", () => { const ir = analyzeJavaScriptSemantics(` const prefix = "rea"; diff --git a/src/domain/javascript/javascriptSemanticAnalysis.ts b/src/domain/javascript/javascriptSemanticAnalysis.ts index 9503ab3a9e..425cbef455 100644 --- a/src/domain/javascript/javascriptSemanticAnalysis.ts +++ b/src/domain/javascript/javascriptSemanticAnalysis.ts @@ -384,7 +384,7 @@ export function* classifyParsedJavaScriptElectronBindingsSteps( } return exported ?? undefined; } - const origin = semanticRequireOrigin(root, state); + const origin = semanticRequireOrigin(root, state)?.origin; return origin !== undefined && ELECTRON_MODULE.test(origin.specifier) ? electronExportPath(origin.importedPath) : undefined; @@ -459,7 +459,7 @@ const bindingOrigin = ( state.conditionalInitializers.has(initializer.node) ) return undefined; - const origin = semanticRequireOrigin(initializer.node, state); + const origin = semanticRequireOrigin(initializer.node, state)?.origin; const projection = initializer.projection; return origin === undefined || !projection.every((key): key is string => typeof key === "string") @@ -485,6 +485,7 @@ const createState = (program: t.Program): JavaScriptSemanticAnalysisState => { callables: [], callableNodesById: new Map(), moduleLinks: [], + linkedRequireCalls: new WeakSet(), moduleLinkBindings: new WeakMap(), conditionalInitializers: new WeakSet(), }; diff --git a/src/domain/javascript/javascriptSemanticProjection.ts b/src/domain/javascript/javascriptSemanticProjection.ts index 3587e3c49d..e5ab86bbf2 100644 --- a/src/domain/javascript/javascriptSemanticProjection.ts +++ b/src/domain/javascript/javascriptSemanticProjection.ts @@ -100,6 +100,7 @@ export const collectSemanticModuleLink = ( ); else if (t.isVariableDeclarator(node)) collectRequireLink(node, state); else if (t.isAssignmentExpression(node)) collectCommonJsExport(node, state); + else if (t.isCallExpression(node)) collectUnboundRequireLink(node, state); }; /** Collect lexical references after definitions exist, optionally for one name. */ @@ -322,9 +323,11 @@ const collectRequireLink = ( node: t.VariableDeclarator, state: JavaScriptSemanticAnalysisState, ): void => { - const origin = semanticRequireOrigin(node.init, state); - if (origin === undefined) return; - for (const binding of requirePatternBindings(node.id, origin.importedPath)) + const required = semanticRequireOrigin(node.init, state); + if (required === undefined) return; + const { origin, call } = required; + const bindings = requirePatternBindings(node.id, origin.importedPath); + for (const binding of bindings) addModuleLink( state, { @@ -337,6 +340,29 @@ const collectRequireLink = ( }, node, ); + if (bindings.length > 0) state.linkedRequireCalls.add(call); +}; + +const collectUnboundRequireLink = ( + node: t.CallExpression, + state: JavaScriptSemanticAnalysisState, +): void => { + if (state.linkedRequireCalls.has(node)) return; + const required = semanticRequireOrigin(node, state); + if (required === undefined) return; + addModuleLink( + state, + { + kind: "require", + specifier: required.origin.specifier, + importedName: null, + localName: null, + exportedName: null, + location: range(node), + }, + node, + ); + state.linkedRequireCalls.add(node); }; const requirePatternBindings = ( @@ -382,8 +408,9 @@ const collectCommonJsExport = ( node.operator === "=" && receiverEffect !== "uncertain" ? node.right : null; while (t.isAssignmentExpression(value, { operator: "=" })) value = value.right; - const origin = + const required = value === null ? undefined : semanticRequireOrigin(value, state); + const origin = required?.origin; addModuleLink( state, { @@ -397,6 +424,7 @@ const collectCommonJsExport = ( }, node, ); + if (required !== undefined) state.linkedRequireCalls.add(required.call); }; /** diff --git a/src/domain/javascript/javascriptSemanticRequireOrigin.ts b/src/domain/javascript/javascriptSemanticRequireOrigin.ts index b5159afc89..00f0805ba1 100644 --- a/src/domain/javascript/javascriptSemanticRequireOrigin.ts +++ b/src/domain/javascript/javascriptSemanticRequireOrigin.ts @@ -12,11 +12,16 @@ import { } from "./javascriptAstValues.js"; import { stringValue } from "./javascriptStaticAnalysisHelpers.js"; +interface JavaScriptRequireOrigin { + readonly origin: JavaScriptModuleOrigin; + readonly call: t.CallExpression; +} + /** Recover an unshadowed literal require origin and its exact member path. */ export const semanticRequireOrigin = ( node: t.Node | null | undefined, state: JavaScriptSemanticAnalysisState, -): JavaScriptModuleOrigin | undefined => { +): JavaScriptRequireOrigin | undefined => { const members: string[] = []; let current = node; while (current !== null && current !== undefined) { @@ -48,5 +53,8 @@ export const semanticRequireOrigin = ( const specifier = stringValue(current.arguments[0]); return specifier === undefined ? undefined - : { specifier, importedPath: members.reverse() }; + : { + origin: { specifier, importedPath: members.reverse() }, + call: current, + }; }; diff --git a/src/domain/javascript/javascriptSemanticState.ts b/src/domain/javascript/javascriptSemanticState.ts index 30b787fdfd..2024d74867 100644 --- a/src/domain/javascript/javascriptSemanticState.ts +++ b/src/domain/javascript/javascriptSemanticState.ts @@ -66,6 +66,8 @@ export interface JavaScriptSemanticAnalysisState { readonly callables: JavaScriptSemanticCallable[]; readonly callableNodesById: Map; readonly moduleLinks: JavaScriptSemanticModuleLink[]; + /** Exact calls already represented by binding or dependency links. */ + readonly linkedRequireCalls: WeakSet; readonly moduleLinkBindings: WeakMap< JavaScriptSemanticModuleLink, JavaScriptSemanticBindingState diff --git a/src/domain/javascript/javascriptSemanticValues.ts b/src/domain/javascript/javascriptSemanticValues.ts index 2cc4ece92c..0eda528bef 100644 --- a/src/domain/javascript/javascriptSemanticValues.ts +++ b/src/domain/javascript/javascriptSemanticValues.ts @@ -664,7 +664,8 @@ const provenanceForExpression = ( }); } const required = semanticRequireOrigin(node, context.state); - if (required !== undefined) return semanticOriginsProvenance([required]); + if (required !== undefined) + return semanticOriginsProvenance([required.origin]); if (t.isIdentifier(node)) { const binding = resolveSemanticBindingState(context.state, node, node.name); return binding === undefined diff --git a/tests/acceptance/applications/unboundRequires.test.ts b/tests/acceptance/applications/unboundRequires.test.ts new file mode 100644 index 0000000000..a9f66e1b19 --- /dev/null +++ b/tests/acceptance/applications/unboundRequires.test.ts @@ -0,0 +1,164 @@ +import { createHash } from "node:crypto"; +import { writeFile } from "node:fs/promises"; +import { createRequire } from "node:module"; +import { join, relative } from "node:path"; + +import { expect } from "vitest"; + +import { parseEvidence } from "../../../src/domain/evidence.js"; +import { javascriptApplicationAnalysisResultSchema } from "../../../src/domain/javascript/javascriptApplicationAnalysis.js"; +import { connectLocalToolsMcp } from "../../fixtures/localToolsMcp.js"; +import { createTestTempDirectory } from "../../fixtures/temporaryDirectory.js"; +import { cliTest } from "../../support/cli/cliFixture.js"; + +cliTest.for(["CLI", "stdio MCP"] as const)( + "retains unbound CommonJS semantic relationships through %s", + async (surface, { cli }) => { + const root = await createTestTempDirectory("rea-unbound-requires-"); + const lines = [ + 'require("./dependency.cjs");', + 'void require("./dependency.cjs");', + 'consume(require("./dependency.cjs"));', + 'require("./dependency.cjs").member;', + 'const {} = require("./dependency.cjs");', + 'const bound = require("./dependency.cjs");', + 'const { left, right } = require("./dependency.cjs");', + 'module.exports = require("./dependency.cjs", require("./nested.cjs"));', + 'function local(require) { require("./shadow.cjs"); }', + "require(dynamicSpecifier);", + 'let assigned; assigned = require("./dependency.cjs");', + ] as const; + const source = lines.join("\n"); + const files = { + "main.cjs": source, + "imports.mjs": 'import "./dependency.cjs";', + "dependency.cjs": 'throw new Error("fixture must not execute");', + "nested.cjs": 'throw new Error("fixture must not execute");', + }; + await Promise.all( + Object.entries(files).map(([path, text]) => + writeFile(join(root, path), text), + ), + ); + const nativeRequire = createRequire(join(root, "main.cjs")); + for (const name of ["dependency", "nested"]) + expect( + relative(root, nativeRequire.resolve(`./${name}.cjs`)).replaceAll( + "\\", + "/", + ), + ).toBe(`${name}.cjs`); + + let document: unknown; + if (surface === "CLI") { + const response = await cli.run({ + arguments: ["analyze-javascript-application", root, "--format", "json"], + environment: { REA_LOG_LEVEL: "silent" }, + }); + expect(response.exitCode, response.stderr).toBe(0); + document = response.json; + } else { + const { call } = await connectLocalToolsMcp(); + const response = await call("analyze_javascript_application", { + input_path: root, + format: "directory", + }); + expect(response.isError, JSON.stringify(response)).not.toBe(true); + document = response.structuredContent; + } + const evidence = parseEvidence(document); + const { graph } = javascriptApplicationAnalysisResultSchema.parse( + evidence.normalized_result, + ); + const links = graph.edges.filter( + ({ relation, properties, evidence: edgeEvidence }) => + relation === "imports" && + properties.module_link_kind === "require" && + edgeEvidence.location.available && + edgeEvidence.location.value.kind === "source-range" && + edgeEvidence.location.value.source === "main.cjs", + ); + expect(links).toHaveLength(10); + for (const [line, count] of [ + [1, 1], + [2, 1], + [3, 1], + [4, 1], + [5, 1], + [6, 1], + [7, 2], + [8, 1], + [11, 1], + ] as const) + expect( + links.filter( + ({ evidence: edgeEvidence }) => + edgeEvidence.location.available && + edgeEvidence.location.value.kind === "source-range" && + edgeEvidence.location.value.start.line === line, + ), + `semantic links on line ${line}`, + ).toHaveLength(count); + const digest = createHash("sha256").update(source).digest("hex"); + for (const edge of links) { + expect(edge.evidence.artifact).toMatchObject({ + available: true, + sha256: digest, + }); + expect(edge.properties.resolution_status).toBe("resolved"); + expect(edge.properties.resolved_path).toBe( + edge.properties.specifier === "./nested.cjs" + ? "nested.cjs" + : "dependency.cjs", + ); + } + const bare = links.find( + ({ evidence: edgeEvidence }) => + edgeEvidence.location.available && + edgeEvidence.location.value.kind === "source-range" && + edgeEvidence.location.value.start.line === 1, + ); + expect(bare).toMatchObject({ + properties: { + imported_name: null, + local_name: null, + exported_name: null, + }, + evidence: { + location: { + available: true, + value: { + source: "main.cjs", + start: { line: 1, column: 0 }, + end: { line: 1, column: lines[0].length - 1 }, + }, + }, + }, + }); + expect( + graph.nodes + .flatMap(({ observations }) => observations) + .find( + ({ properties }) => + properties.semantic_role === "source-module" && + properties.path === "main.cjs", + )?.properties.import_relationships, + ).toBe(10); + expect( + graph.edges.some( + ({ relation, properties }) => + relation === "imports" && + properties.kind === "require" && + properties.specifier === "./dependency.cjs", + ), + ).toBe(true); + expect( + graph.edges.filter( + ({ relation, properties }) => + relation === "imports" && + properties.module_link_kind === "commonjs-export" && + properties.specifier === "./dependency.cjs", + ), + ).toHaveLength(1); + }, +);