From f998fcb1cca9994c3589fb70d6e0127fc215f76f Mon Sep 17 00:00:00 2001 From: morluto <76467478+morluto@users.noreply.github.com> Date: Sun, 11 Oct 2026 09:59:23 +0800 Subject: [PATCH] fix(reference-source): respect require bindings Reuse lexical ownership to exclude shadowed require calls while retaining native CommonJS and direct createRequire bindings. Preserve candidate edges when JavaScript or TypeScript runtime mode is unknown. Co-authored-by: Rudy Celekli --- .../javascript/javascriptSemanticAnalysis.ts | 5 +- .../javascript/javascriptSemanticState.ts | 4 + src/domain/referenceSourceImportParsing.ts | 15 +- src/domain/referenceSourceRequireBindings.ts | 192 ++++++++++++++++++ .../referenceSourceShadowedRequire.test.ts | 123 +++++++++++ 5 files changed, 335 insertions(+), 4 deletions(-) create mode 100644 src/domain/referenceSourceRequireBindings.ts create mode 100644 tests/boundary/filesystem/referenceSourceShadowedRequire.test.ts 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"], + ]); +});