diff --git a/src/domain/javascript/javascriptSemanticAnalysis.ts b/src/domain/javascript/javascriptSemanticAnalysis.ts index 82f7e62447..b0e8e5ee76 100644 --- a/src/domain/javascript/javascriptSemanticAnalysis.ts +++ b/src/domain/javascript/javascriptSemanticAnalysis.ts @@ -29,6 +29,7 @@ import { resolveSemanticBindingState, semanticResolutionBlocked, resolveSemanticBindingFromScope, + semanticBindingId, semanticScopeId, semanticVariableScope, } from "./javascriptSemanticState.js"; @@ -105,7 +106,7 @@ export const analyzeJavaScriptSemantics = ( /** Recover lexical references, optionally for one name, without evaluating values or provenance. */ export const analyzeParsedJavaScriptReferences = ( - file: ParsedJavaScriptSource, + file: Pick, name?: string, ): readonly JavaScriptSemanticReference[] => { const state = createState(file.program); @@ -1101,7 +1102,7 @@ const createBinding = ( kind: JavaScriptSemanticDefinition["kind"], mutable: boolean, ): JavaScriptSemanticBindingState => ({ - bindingId: `${scope.scopeId}:binding:${encodeURIComponent(name)}`, + bindingId: semanticBindingId(scope.scopeId, name), scopeId: scope.scopeId, name, kind, diff --git a/src/domain/javascript/javascriptSemanticState.ts b/src/domain/javascript/javascriptSemanticState.ts index f7ef253196..30b787fdfd 100644 --- a/src/domain/javascript/javascriptSemanticState.ts +++ b/src/domain/javascript/javascriptSemanticState.ts @@ -89,6 +89,10 @@ export const semanticScopeId = ( ): string => `scope:${kind}:${String(node.start ?? -1)}:${String(node.end ?? -1)}`; +/** Create the canonical identity used by one binding in a lexical scope. */ +export const semanticBindingId = (scopeId: string, name: string): string => + `${scopeId}:binding:${encodeURIComponent(name)}`; + /** Resolve one lexical name from the scope containing a node. */ export const resolveSemanticBindingState = ( state: JavaScriptSemanticAnalysisState, diff --git a/src/domain/referenceSourceImportParsing.ts b/src/domain/referenceSourceImportParsing.ts index e897072576..62ad226dd5 100644 --- a/src/domain/referenceSourceImportParsing.ts +++ b/src/domain/referenceSourceImportParsing.ts @@ -8,6 +8,7 @@ import type { } from "@babel/types"; import { traverseJavaScriptAst } from "./javascript/javascriptSemanticTraversal.js"; import { parserPluginsForPath } from "./javascript/javascriptSourceParser.js"; +import { unshadowedReferenceSourceRequireCalls } from "./referenceSourceRequireBindings.js"; import { isCallExpression, isExportAllDeclaration, @@ -206,6 +207,7 @@ const extractRequireAndDynamicImports = ( body: readonly Node[], from_path: string, relationships: ReferenceSourceImportRelationship[], + unshadowedRequireCalls: ReadonlySet, ): void => { // Single traversal owner: iterative, VISITOR_KEYS-gated, no recursion over // loc/comment objects. Survives deeply nested generated member chains that @@ -232,7 +234,11 @@ const extractRequireAndDynamicImports = ( continue; } const first = call.arguments[0]; - if (isRequireCallee(call.callee) && isModuleExpression(first)) { + if ( + isRequireCallee(call.callee) && + unshadowedRequireCalls.has(call) && + isModuleExpression(first) + ) { const result = moduleSpecifierFromExpression(first); if (result !== undefined) { appendRelationship(relationships, { @@ -332,7 +338,12 @@ export const parseReferenceSourceImports = ( const relationships: ReferenceSourceImportRelationship[] = []; extractImportDeclarations(ast.program.body, path, relationships); - extractRequireAndDynamicImports(ast.program.body, path, relationships); + extractRequireAndDynamicImports( + ast.program.body, + path, + relationships, + unshadowedReferenceSourceRequireCalls(ast, path), + ); return { relationships: relationships.map((relationship) => ({ diff --git a/src/domain/referenceSourceRequireBindings.ts b/src/domain/referenceSourceRequireBindings.ts new file mode 100644 index 0000000000..d7c8cea494 --- /dev/null +++ b/src/domain/referenceSourceRequireBindings.ts @@ -0,0 +1,192 @@ +import * as t from "@babel/types"; + +import { analyzeParsedJavaScriptReferences } from "./javascript/javascriptSemanticAnalysis.js"; +import { + semanticBindingId, + semanticScopeId, +} from "./javascript/javascriptSemanticState.js"; +import { traverseJavaScriptAst } from "./javascript/javascriptSemanticTraversal.js"; +import type { JavaScriptSemanticReference } from "./javascript/javascriptSemanticIr.js"; +import type { ParsedJavaScriptSource } from "./javascript/javascriptSourceParser.js"; + +/** + * Keep calls that resolve either to Node's CommonJS wrapper loader or to a + * direct ESM `createRequire(import.meta.url)` binding. The shared lexical + * analyzer supplies binding ownership; this boundary accounts for the two + * Node loader bindings without evaluating arbitrary values. + */ +export const unshadowedReferenceSourceRequireCalls = ( + file: Pick, + path: string, +): ReadonlySet => { + const references = new Map( + analyzeParsedJavaScriptReferences(file).map((reference) => [ + `${reference.location.start.line}:${reference.location.start.column}`, + reference, + ]), + ); + const programRequireBinding = semanticBindingId( + semanticScopeId("program", file.program), + "require", + ); + const allowsUnboundRequire = !isExplicitModuleSource(path); + const hasTopLevelVarRequire = + allowsUnboundRequire && hasUninitializedTopLevelVarRequire(file.program); + const createRequireBinding = directCreateRequireBinding( + file.program, + references, + ); + const requireLoaderWasWritten = [...references.values()].some( + (reference) => + reference.name === "require" && + reference.role === "write" && + (reference.resolution === "unbound" || + reference.bindingId === programRequireBinding), + ); + const calls = new Set(); + + traverseJavaScriptAst(file.program, { + enter: (node, _parent, readAncestors) => { + if (!t.isCallExpression(node)) return; + const identifier = requireCalleeIdentifier(node.callee); + const start = identifier?.loc?.start; + if (start === undefined) return; + if ( + readAncestors().some( + (ancestor) => t.isWithStatement(ancestor) && contains(ancestor.body, node), + ) + ) + return; + const reference = references.get(`${start.line}:${start.column}`); + if ( + (allowsUnboundRequire && + !requireLoaderWasWritten && + reference?.resolution === "unbound") || + (((hasTopLevelVarRequire && !requireLoaderWasWritten) || + createRequireBinding) && + reference?.resolution === "resolved" && + reference.bindingId === programRequireBinding) + ) + calls.add(node); + }, + }); + return calls; +}; + +const contains = (container: t.Node, node: t.Node): boolean => + typeof container.start === "number" && + typeof container.end === "number" && + typeof node.start === "number" && + typeof node.end === "number" && + container.start <= node.start && + node.end <= container.end; + +const isExplicitModuleSource = (path: string): boolean => { + const lower = path.toLowerCase(); + if (lower.endsWith(".mjs") || lower.endsWith(".mts")) return true; + if (lower.endsWith(".cjs") || lower.endsWith(".cts")) return false; + // Syntax alone cannot establish Node's runtime mode: `.js` depends on its + // package scope, and `.ts` may be compiled with a CommonJS module target. + // Keep unmarked paths runtime-unknown and preserve candidate require edges. + return false; +}; + +const hasUninitializedTopLevelVarRequire = ( + program: t.Program, +): boolean => { + let found = false; + let initialized = false; + traverseJavaScriptAst(program, { + enter: (node, _parent, readAncestors) => { + if (!t.isVariableDeclaration(node, { kind: "var" })) return; + if ( + readAncestors().some( + (ancestor) => t.isFunction(ancestor) || t.isStaticBlock(ancestor), + ) + ) + return; + for (const declaration of node.declarations) { + if (!Object.hasOwn(t.getBindingIdentifiers(declaration.id), "require")) + continue; + found = true; + initialized ||= declaration.init !== null; + } + }, + }); + return found && !initialized; +}; + +const directCreateRequireBinding = ( + program: t.Program, + references: ReadonlyMap, +): boolean => { + const programScope = semanticScopeId("program", program); + const importedNames = new Set(); + for (const declaration of program.body) { + if ( + !t.isImportDeclaration(declaration) || + (declaration.source.value !== "node:module" && + declaration.source.value !== "module") + ) + continue; + for (const specifier of declaration.specifiers) { + if ( + t.isImportSpecifier(specifier) && + t.isIdentifier(specifier.imported, { name: "createRequire" }) + ) + importedNames.add(specifier.local.name); + } + } + + return program.body.some( + (statement) => + t.isVariableDeclaration(statement, { kind: "const" }) && + statement.declarations.some((declaration) => { + if ( + !t.isIdentifier(declaration.id, { name: "require" }) || + !t.isCallExpression(declaration.init) || + !t.isIdentifier(declaration.init.callee) || + !importedNames.has(declaration.init.callee.name) || + !isImportMetaUrl(declaration.init.arguments[0]) + ) + return false; + const start = declaration.init.callee.loc?.start; + if (start === undefined) return false; + const reference = references.get(`${start.line}:${start.column}`); + return ( + reference?.resolution === "resolved" && + reference.bindingId === + semanticBindingId(programScope, declaration.init.callee.name) + ); + }), + ); +}; + +const isImportMetaUrl = (node: t.Node | undefined): boolean => + t.isMemberExpression(node) && + !node.computed && + t.isIdentifier(node.property, { name: "url" }) && + t.isMetaProperty(node.object) && + t.isIdentifier(node.object.meta, { name: "import" }) && + t.isIdentifier(node.object.property, { name: "meta" }); + +const requireCalleeIdentifier = ( + callee: t.Node | null | undefined, +): t.Identifier | undefined => { + if (t.isIdentifier(callee, { name: "require" })) return callee; + if ( + !t.isMemberExpression(callee) || + !t.isIdentifier(callee.object, { name: "require" }) || + ((!t.isIdentifier(callee.property) || callee.computed) && + !t.isStringLiteral(callee.property)) + ) + return undefined; + const propertyName = t.isIdentifier(callee.property) + ? callee.property.name + : t.isStringLiteral(callee.property) + ? callee.property.value + : undefined; + return propertyName === "resolve" || propertyName === "main" + ? callee.object + : undefined; +}; diff --git a/tests/boundary/filesystem/referenceSourceShadowedRequire.test.ts b/tests/boundary/filesystem/referenceSourceShadowedRequire.test.ts new file mode 100644 index 0000000000..9ffe7ef95b --- /dev/null +++ b/tests/boundary/filesystem/referenceSourceShadowedRequire.test.ts @@ -0,0 +1,123 @@ +import { execFile } from "node:child_process"; +import { writeFile } from "node:fs/promises"; +import { join } from "node:path"; +import { promisify } from "node:util"; + +import { expect, it } from "vitest"; + +import { createTestTempDirectory } from "../../fixtures/temporaryDirectory.js"; +import { importReferenceSource } from "../../support/referenceSourceResourceScope.js"; + +it("resolves require calls by lexical binding and source mode", async () => { + const root = await createTestTempDirectory("rea-reference-require-scope-"); + const sources: Record = { + "parameter.cjs": [ + "function load(require) { return require('./throwing.cjs'); }", + "console.log(load(() => 'local'));", + ].join("\n"), + "destructured.cjs": [ + "function load({ require }) { return require('./throwing.cjs'); }", + "console.log(load({ require: () => 'local' }));", + ].join("\n"), + "hoisted.cjs": [ + "function load() {", + " console.log(require('./throwing.cjs'));", + " function require() { return 'local'; }", + "}", + "load();", + ].join("\n"), + "catch.cjs": [ + "try { throw () => 'local'; }", + "catch (require) { console.log(require('./throwing.cjs')); }", + ].join("\n"), + "nested.cjs": [ + "console.log(require('./loaded.cjs'));", + "function load(require) { return require('./throwing.cjs'); }", + "console.log(load(() => 'local'));", + ].join("\n"), + "dynamic-with.cjs": [ + "with ({ require: () => 'local' }) {", + " console.log(require('./throwing.cjs'));", + "}", + ].join("\n"), + "top-level-var.cjs": [ + "var require;", + "console.log(require('./loaded.cjs'));", + ].join("\n"), + "initialized-var.cjs": [ + "var require = () => 'local';", + "console.log(require('./throwing.cjs'));", + ].join("\n"), + "assigned-var.cjs": [ + "require = () => 'local';", + "console.log(require('./throwing.cjs'));", + ].join("\n"), + "unbound-esm.mjs": [ + "try { require('./throwing.cjs'); }", + "catch (error) {", + " if (!(error instanceof ReferenceError)) throw error;", + " console.log('no-global-require');", + "}", + ].join("\n"), + "shadowed-esm.mjs": [ + "const require = () => 'local';", + "console.log(require('./throwing.cjs'));", + ].join("\n"), + "create-require-esm.mjs": [ + "import { createRequire as makeRequire } from 'node:module';", + "const require = makeRequire(import.meta.url);", + "console.log(require('./loaded.cjs'));", + ].join("\n"), + "loaded.cjs": "console.log('loaded');\nmodule.exports = 'real';\n", + "throwing.cjs": "throw new Error('A shadowed require must not load me');\n", + }; + + await Promise.all( + Object.entries(sources).map(([path, source]) => + writeFile(join(root, path), source), + ), + ); + + const runNode = promisify(execFile); + for (const [path, stdout] of [ + ["parameter.cjs", "local\n"], + ["destructured.cjs", "local\n"], + ["hoisted.cjs", "local\n"], + ["catch.cjs", "local\n"], + ["nested.cjs", "loaded\nreal\nlocal\n"], + ["dynamic-with.cjs", "local\n"], + ["top-level-var.cjs", "loaded\nreal\n"], + ["initialized-var.cjs", "local\n"], + ["assigned-var.cjs", "local\n"], + ["unbound-esm.mjs", "no-global-require\n"], + ["shadowed-esm.mjs", "local\n"], + ["create-require-esm.mjs", "loaded\nreal\n"], + ] as const) { + const executed = await runNode(process.execPath, [join(root, path)], { + cwd: root, + timeout: 5_000, + }); + expect(executed.stdout).toBe(stdout); + expect(executed.stderr).toBe(""); + } + + const result = await importReferenceSource({ + root, + caller: "reference-shadowed-require-test", + policy: { secretPatterns: [] }, + }); + if (!result.ok) throw result.error; + + expect( + result.value.relationships + .filter(({ kind }) => kind === "requires") + .map(({ from_path, to }) => [from_path, to]) + .sort((left, right) => + (left[0] ?? "").localeCompare(right[0] ?? ""), + ), + ).toEqual([ + ["create-require-esm.mjs", "loaded.cjs"], + ["nested.cjs", "loaded.cjs"], + ["top-level-var.cjs", "loaded.cjs"], + ]); +});