From c05f7eb723bceafd1bb3546f8b623195d6e872c3 Mon Sep 17 00:00:00 2001 From: jfs Date: Tue, 13 Oct 2026 23:47:22 +0800 Subject: [PATCH 1/2] fix(javascript): honor require bindings in static graphs Share the existing Node loader classification with static findings and reuse the source's binding index. Keep ordinary, optional, wrapper-var, createRequire and bundled-loader behavior while excluding local calls. Fixes #2890 --- .../javascriptRequireBindings.ts} | 33 ++++--- .../javascript/javascriptStaticAnalysis.ts | 15 +++- .../javascriptStaticAnalysisCalls.ts | 15 +++- .../javascriptStaticAnalysisState.ts | 1 + src/domain/referenceSourceImportParsing.ts | 6 +- .../applications/shadowedRequireCli.test.ts | 87 +++++++++++++++++++ 6 files changed, 138 insertions(+), 19 deletions(-) rename src/domain/{referenceSourceRequireBindings.ts => javascript/javascriptRequireBindings.ts} (87%) create mode 100644 tests/acceptance/applications/shadowedRequireCli.test.ts diff --git a/src/domain/referenceSourceRequireBindings.ts b/src/domain/javascript/javascriptRequireBindings.ts similarity index 87% rename from src/domain/referenceSourceRequireBindings.ts rename to src/domain/javascript/javascriptRequireBindings.ts index f2f8d1ef54d..b0279509373 100644 --- a/src/domain/referenceSourceRequireBindings.ts +++ b/src/domain/javascript/javascriptRequireBindings.ts @@ -1,16 +1,16 @@ import * as t from "@babel/types"; -import { analyzeParsedJavaScriptReferences } from "./javascript/javascriptSemanticAnalysis.js"; +import { analyzeParsedJavaScriptReferences } from "./javascriptSemanticAnalysis.js"; import { semanticBindingId, semanticScopeId, -} from "./javascript/javascriptSemanticState.js"; -import { traverseJavaScriptAst } from "./javascript/javascriptSemanticTraversal.js"; -import type { JavaScriptSemanticReference } from "./javascript/javascriptSemanticIr.js"; +} from "./javascriptSemanticState.js"; +import { traverseJavaScriptAst } from "./javascriptSemanticTraversal.js"; +import type { JavaScriptSemanticReference } from "./javascriptSemanticIr.js"; import { isExplicitModuleSourcePath, type ParsedJavaScriptSource, -} from "./javascript/javascriptSourceParser.js"; +} from "./javascriptSourceParser.js"; /** * Keep calls that resolve either to Node's CommonJS wrapper loader or to a @@ -18,12 +18,15 @@ import { * analyzer supplies binding ownership; this boundary accounts for the two * Node loader bindings without evaluating arbitrary values. */ -export const unshadowedReferenceSourceRequireCalls = ( +export const unshadowedJavaScriptRequireCalls = ( file: Pick, - path: string, -): ReadonlySet => { + sourcePath: string | undefined, + referenceFacts: readonly JavaScriptSemanticReference[] = analyzeParsedJavaScriptReferences( + file, + ), +): ReadonlySet => { const references = new Map( - analyzeParsedJavaScriptReferences(file).map((reference) => [ + referenceFacts.map((reference) => [ `${reference.location.start.line}:${reference.location.start.column}`, reference, ]), @@ -35,7 +38,8 @@ export const unshadowedReferenceSourceRequireCalls = ( // 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. - const allowsUnboundRequire = !isExplicitModuleSourcePath(path); + const allowsUnboundRequire = + sourcePath === undefined || !isExplicitModuleSourcePath(sourcePath); const hasTopLevelVarRequire = allowsUnboundRequire && hasUninitializedTopLevelVarRequire(file.program); const createRequireBinding = directCreateRequireBinding( @@ -49,11 +53,16 @@ export const unshadowedReferenceSourceRequireCalls = ( (reference.resolution === "unbound" || reference.bindingId === programRequireBinding), ); - const calls = new Set(); + const calls = new Set(); traverseJavaScriptAst(file.program, { enter: (node, _parent, readAncestors) => { - if (!t.isCallExpression(node)) return; + if ( + !t.isCallExpression(node) && + !t.isOptionalCallExpression(node) && + !t.isNewExpression(node) + ) + return; const identifier = requireCalleeIdentifier(node.callee); const start = identifier?.loc?.start; if (start === undefined) return; diff --git a/src/domain/javascript/javascriptStaticAnalysis.ts b/src/domain/javascript/javascriptStaticAnalysis.ts index c3446249800..4800faa57d0 100644 --- a/src/domain/javascript/javascriptStaticAnalysis.ts +++ b/src/domain/javascript/javascriptStaticAnalysis.ts @@ -52,7 +52,11 @@ import { } from "./javascriptSourceParser.js"; import { createJavaScriptRpcScan } from "./javascriptRpcEndpoints.js"; -import { semanticImportEqualsSpecifier } from "./javascriptSemanticProjection.js"; +import { + collectSemanticReferences, + semanticImportEqualsSpecifier, +} from "./javascriptSemanticProjection.js"; +import { unshadowedJavaScriptRequireCalls } from "./javascriptRequireBindings.js"; import { hasUncertainTypeOnlyModuleSideEffect, isTypeOnlyExportDeclaration, @@ -96,6 +100,7 @@ export function* analyzeParsedJavaScriptStaticSourceSteps( bindings, ); const clientRoots = yield* classifyParsedJavaScriptRpcClientRootsSteps(file); + let requireCalls: ReadonlySet | undefined; yield* traverseStaticSourceSteps( file, { @@ -103,6 +108,14 @@ export function* analyzeParsedJavaScriptStaticSourceSteps( accumulator, ...(openReceiverFacts === undefined ? {} : { openReceiverFacts }), electronBindings: electronIdentity.bindings, + isUnshadowedRequireCall: (node) => { + requireCalls ??= unshadowedJavaScriptRequireCalls( + file, + undefined, + collectSemanticReferences(file.program, bindings), + ); + return requireCalls.has(node); + }, rpcScan: createJavaScriptRpcScan(source, clientRoots), }, bindings, diff --git a/src/domain/javascript/javascriptStaticAnalysisCalls.ts b/src/domain/javascript/javascriptStaticAnalysisCalls.ts index 148f7fb7e5e..acbb9e9a9e5 100644 --- a/src/domain/javascript/javascriptStaticAnalysisCalls.ts +++ b/src/domain/javascript/javascriptStaticAnalysisCalls.ts @@ -57,7 +57,12 @@ export const inspectCall = ( const first = stringValue(node.arguments[0]); const module = context.accumulator.moduleRangeIndex.find(node.start); const moduleRequireName = module?.requireName ?? null; - const requireCall = requireCallSpecifier(node, name, moduleRequireName); + const requireCall = requireCallSpecifier( + node, + name, + moduleRequireName, + context.isUnshadowedRequireCall, + ); if (requireCall !== null) addReference(context, { node, @@ -214,11 +219,15 @@ const requireCallSpecifier = ( node: JavaScriptCallLike, name: string, moduleRequireName: string | null, + isUnshadowedRequireCall: (node: t.Node) => boolean, ): { readonly specifier: string | undefined } | null => { - if (name === "require" || name.endsWith(".require")) - return { specifier: argumentValue(node.arguments[0]) }; if (name === "__webpack_require__" || name === moduleRequireName) return { specifier: argumentValue(node.arguments[0]) }; + if ( + (name === "require" && isUnshadowedRequireCall(node)) || + name.endsWith(".require") + ) + return { specifier: argumentValue(node.arguments[0]) }; if ( name === "__webpack_require__.bind" || (moduleRequireName !== null && name === `${moduleRequireName}.bind`) diff --git a/src/domain/javascript/javascriptStaticAnalysisState.ts b/src/domain/javascript/javascriptStaticAnalysisState.ts index 5173362faf0..6ae47a4769a 100644 --- a/src/domain/javascript/javascriptStaticAnalysisState.ts +++ b/src/domain/javascript/javascriptStaticAnalysisState.ts @@ -63,6 +63,7 @@ export interface JavaScriptAnalysisAccumulator { /** Shared source and accumulator for helper inspections. */ export interface JavaScriptFindingContext { readonly rpcScan: JavaScriptRpcScan; + readonly isUnshadowedRequireCall: (node: t.Node) => boolean; readonly source: string; readonly accumulator: JavaScriptAnalysisAccumulator; readonly openReceiverFacts?: ReadonlyMap; diff --git a/src/domain/referenceSourceImportParsing.ts b/src/domain/referenceSourceImportParsing.ts index 18f76a35878..7d9eb7b7fef 100644 --- a/src/domain/referenceSourceImportParsing.ts +++ b/src/domain/referenceSourceImportParsing.ts @@ -10,7 +10,7 @@ import { parseRecoveredJavaScript, parserPluginsForPath, } from "./javascript/javascriptSourceParser.js"; -import { unshadowedReferenceSourceRequireCalls } from "./referenceSourceRequireBindings.js"; +import { unshadowedJavaScriptRequireCalls } from "./javascript/javascriptRequireBindings.js"; import { isRelativeJavaScriptModuleSpecifier } from "./javascript/javascriptModuleSpecifier.js"; import { isCallExpression, @@ -214,7 +214,7 @@ const extractRequireAndDynamicImports = ( body: readonly Node[], from_path: string, relationships: ReferenceSourceImportRelationship[], - unshadowedRequireCalls: ReadonlySet, + unshadowedRequireCalls: ReadonlySet, ): void => { // Single traversal owner: iterative, VISITOR_KEYS-gated, no recursion over // loc/comment objects. Survives deeply nested generated member chains that @@ -369,7 +369,7 @@ export const parseReferenceSourceImports = ( ast.program.body, path, relationships, - unshadowedReferenceSourceRequireCalls(ast, path), + unshadowedJavaScriptRequireCalls(ast, path), ); return { diff --git a/tests/acceptance/applications/shadowedRequireCli.test.ts b/tests/acceptance/applications/shadowedRequireCli.test.ts new file mode 100644 index 00000000000..1e56f132a95 --- /dev/null +++ b/tests/acceptance/applications/shadowedRequireCli.test.ts @@ -0,0 +1,87 @@ +import { execFile } from "node:child_process"; +import { writeFile } from "node:fs/promises"; +import { join } from "node:path"; +import { promisify } from "node:util"; + +import { expect } from "vitest"; + +import { createTestTempDirectory } from "../../fixtures/temporaryDirectory.js"; +import { cliTest } from "../../support/cli/cliFixture.js"; +import { analyzeJavaScriptDirectory } from "../../support/cli/javascriptApplication.js"; + +cliTest.for(["CLI", "stdio MCP"] as const)( + "excludes shadowed require calls from application imports through %s", + { timeout: 60_000 }, + async (surface, { cli }) => { + const root = await createTestTempDirectory("rea-static-require-scope-"); + const files: Readonly> = { + "parameter.cjs": + 'function run(require) { return require("./ghost.cjs"); } console.log(run(() => "local"));', + "optional.cjs": + 'function run(require) { return require?.("./ghost.cjs"); } console.log(run(() => "local"));', + "hoisted.cjs": + 'function run() { return require("./ghost.cjs"); function require() { return "local"; } } console.log(run());', + "catch.cjs": + 'try { throw () => "local"; } catch (require) { console.log(require("./ghost.cjs")); }', + "block.cjs": + '{ const require = () => "local"; console.log(require("./ghost.cjs")); }', + "real.cjs": 'console.log(require("./real-dependency.cjs"));', + "real-optional.cjs": 'console.log(require?.("./real-dependency.cjs"));', + "var.cjs": 'var require; console.log(require("./real-dependency.cjs"));', + "create-require.mjs": + 'import {createRequire} from "node:module"; const require = createRequire(import.meta.url); console.log(require("./real-dependency.cjs"));', + "real-dependency.cjs": 'module.exports = "real";', + "ghost.cjs": + 'throw new Error("A local function must not load this module");', + }; + await Promise.all( + Object.entries(files).map(([path, source]) => + writeFile(join(root, path), source), + ), + ); + for (const [path, value] of [ + ["parameter.cjs", "local"], + ["optional.cjs", "local"], + ["hoisted.cjs", "local"], + ["catch.cjs", "local"], + ["block.cjs", "local"], + ["real.cjs", "real"], + ["real-optional.cjs", "real"], + ["var.cjs", "real"], + ["create-require.mjs", "real"], + ] as const) + expect( + ( + await promisify(execFile)(process.execPath, [join(root, path)], { + timeout: 5000, + }) + ).stdout, + ).toBe(`${value}\n`); + const result = await analyzeJavaScriptDirectory(surface, cli, root); + const requires = result.graph.edges.filter( + ({ relation, properties }) => + relation === "imports" && properties.kind === "require", + ); + expect( + requires.filter( + ({ properties }) => properties.specifier === "./ghost.cjs", + ), + ).toEqual([]); + const realSources = requires + .filter( + ({ properties }) => properties.specifier === "./real-dependency.cjs", + ) + .map(({ evidence }) => { + const location = evidence.location; + return location.available && location.value.kind === "source-range" + ? location.value.source + : null; + }); + expect(realSources.sort()).toEqual([ + "create-require.mjs", + "real-optional.cjs", + "real.cjs", + "var.cjs", + ]); + }, +); From 9f0cf34787a650bd54e6a74d0202d2b3418840c0 Mon Sep 17 00:00:00 2001 From: jfs Date: Wed, 14 Oct 2026 00:21:26 +0800 Subject: [PATCH 2/2] fix(javascript): yield and order require classification Yield reference indexing and loader classification to the analysis scheduler. Keep visitor callbacks limited to the completed call set. Preserve loader calls before later program-level writes. Keep deferred and same-statement execution order conservative, and cover both public adapters and reference-source imports with real Node controls. --- .../javascript/javascriptRequireBindings.ts | 276 ++++++++++++------ ...vascriptStaticAnalysis.cooperative.test.ts | 43 +++ .../javascript/javascriptStaticAnalysis.ts | 19 +- .../applications/shadowedRequireCli.test.ts | 21 ++ .../referenceSourceShadowedRequire.test.ts | 7 + 5 files changed, 266 insertions(+), 100 deletions(-) create mode 100644 src/domain/javascript/javascriptStaticAnalysis.cooperative.test.ts diff --git a/src/domain/javascript/javascriptRequireBindings.ts b/src/domain/javascript/javascriptRequireBindings.ts index b0279509373..ea800d57f49 100644 --- a/src/domain/javascript/javascriptRequireBindings.ts +++ b/src/domain/javascript/javascriptRequireBindings.ts @@ -5,7 +5,10 @@ import { semanticBindingId, semanticScopeId, } from "./javascriptSemanticState.js"; -import { traverseJavaScriptAst } from "./javascriptSemanticTraversal.js"; +import { + completeSemanticSteps, + traverseJavaScriptAstSteps, +} from "./javascriptSemanticTraversal.js"; import type { JavaScriptSemanticReference } from "./javascriptSemanticIr.js"; import { isExplicitModuleSourcePath, @@ -21,42 +24,136 @@ import { export const unshadowedJavaScriptRequireCalls = ( file: Pick, sourcePath: string | undefined, - referenceFacts: readonly JavaScriptSemanticReference[] = analyzeParsedJavaScriptReferences( - file, - ), -): ReadonlySet => { - const references = new Map( - referenceFacts.map((reference) => [ - `${reference.location.start.line}:${reference.location.start.column}`, - reference, - ]), +): ReadonlySet => + completeSemanticSteps( + unshadowedJavaScriptRequireCallsSteps( + file, + sourcePath, + analyzeParsedJavaScriptReferences(file), + ), ); - const programRequireBinding = semanticBindingId( + +interface RequireCallSite { + readonly node: t.Node; + readonly reference: JavaScriptSemanticReference | undefined; + readonly statement: number; + readonly deferred: boolean; +} + +/** Classify loader calls without draining whole-source work inside a visitor. */ +export function* unshadowedJavaScriptRequireCallsSteps( + file: Pick, + sourcePath: string | undefined, + referenceFacts: readonly JavaScriptSemanticReference[], +): Generator> { + const references = new Map(); + const writeLocations = new Set(); + const programBinding = semanticBindingId( semanticScopeId("program", file.program), "require", ); - // 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. - const allowsUnboundRequire = + let hasRequire = false; + for (const reference of referenceFacts) { + yield; + const key = locationKey(reference.location.start); + references.set(key, reference); + if (reference.name !== "require") continue; + hasRequire = true; + if ( + reference.role === "write" && + (reference.resolution === "unbound" || + reference.bindingId === programBinding) + ) + writeLocations.add(key); + } + if (!hasRequire) return new Set(); + const allowsUnbound = sourcePath === undefined || !isExplicitModuleSourcePath(sourcePath); - const hasTopLevelVarRequire = - allowsUnboundRequire && hasUninitializedTopLevelVarRequire(file.program); - const createRequireBinding = directCreateRequireBinding( + const createRequireBinding = yield* directCreateRequireBindingSteps( file.program, references, ); - const requireLoaderWasWritten = [...references.values()].some( - (reference) => - reference.name === "require" && - reference.role === "write" && - (reference.resolution === "unbound" || - reference.bindingId === programRequireBinding), + const scan = yield* scanRequireSitesSteps( + file.program, + references, + writeLocations, ); const calls = new Set(); + for (const site of scan.sites) { + yield; + const reference = site.reference; + const loaderIntact = + !scan.unknownWrite && + (scan.firstWrite === null || + (!site.deferred && site.statement < scan.firstWrite)); + if ( + (allowsUnbound && loaderIntact && reference?.resolution === "unbound") || + (((allowsUnbound && scan.wrapperVar && loaderIntact) || + createRequireBinding) && + reference?.resolution === "resolved" && + reference.bindingId === programBinding) + ) + calls.add(site.node); + } + return calls; +} - traverseJavaScriptAst(file.program, { - enter: (node, _parent, readAncestors) => { +const locationKey = (position: { + readonly line: number; + readonly column: number; +}): string => `${String(position.line)}:${String(position.column)}`; + +function* scanRequireSitesSteps( + program: t.Program, + references: ReadonlyMap, + writeLocations: ReadonlySet, +): Generator< + void, + { + readonly sites: readonly RequireCallSite[]; + readonly wrapperVar: boolean; + readonly firstWrite: number | null; + readonly unknownWrite: boolean; + } +> { + const sites: RequireCallSite[] = []; + let statement = -1; + let wrapperVar = false; + let firstWrite: number | null = null; + let unknownWrite = false; + const locatedWrites = new Set(); + const writeAt = (node: t.Node, ancestors: readonly t.Node[]): void => { + if (statement < 0 || deferredExecution(node, ancestors)) + unknownWrite = true; + else firstWrite = Math.min(firstWrite ?? statement, statement); + }; + yield* traverseJavaScriptAstSteps(program, { + enter: (node, parent, readAncestors) => { + if (parent === program && t.isStatement(node)) statement += 1; + if ( + t.isIdentifier(node, { name: "require" }) && + node.loc !== null && + node.loc !== undefined && + writeLocations.has(locationKey(node.loc.start)) + ) { + locatedWrites.add(locationKey(node.loc.start)); + writeAt(node, readAncestors()); + } + if ( + t.isVariableDeclarator(node) && + t.isVariableDeclaration(parent, { kind: "var" }) && + Object.hasOwn(t.getBindingIdentifiers(node.id), "require") + ) { + const ancestors = readAncestors(); + if ( + !ancestors.some( + (ancestor) => t.isFunction(ancestor) || t.isStaticBlock(ancestor), + ) + ) { + wrapperVar = true; + if (node.init !== null) writeAt(node, ancestors); + } + } if ( !t.isCallExpression(node) && !t.isOptionalCallExpression(node) && @@ -66,28 +163,47 @@ export const unshadowedJavaScriptRequireCalls = ( const identifier = requireCalleeIdentifier(node.callee); const start = identifier?.loc?.start; if (start === undefined) return; + const ancestors = readAncestors(); if ( - readAncestors().some( + ancestors.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); + sites.push({ + node, + reference: references.get(locationKey(start)), + statement, + deferred: deferredExecution(node, ancestors), + }); }, }); - return calls; -}; + return { + sites, + wrapperVar, + firstWrite, + unknownWrite: unknownWrite || locatedWrites.size !== writeLocations.size, + }; +} + +// Textual order does not establish execution order across a callable or an +// instance initializer. Same-statement writes also remain conservative; only +// a distinct later program statement proves that a write follows this call. +const deferredExecution = ( + node: t.Node, + ancestors: readonly t.Node[], +): boolean => + ancestors.some( + (ancestor) => + t.isFunction(ancestor) || + ((t.isClassProperty(ancestor) || + t.isClassPrivateProperty(ancestor) || + t.isClassAccessorProperty(ancestor)) && + !ancestor.static && + ancestor.value != null && + contains(ancestor.value, node)), + ); const contains = (container: t.Node, node: t.Node): boolean => typeof container.start === "number" && @@ -97,36 +213,14 @@ const contains = (container: t.Node, node: t.Node): boolean => container.start <= node.start && node.end <= container.end; -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 = ( +function* directCreateRequireBindingSteps( program: t.Program, references: ReadonlyMap, -): boolean => { +): Generator { const programScope = semanticScopeId("program", program); const importedNames = new Set(); for (const declaration of program.body) { + yield; if ( !t.isImportDeclaration(declaration) || (declaration.source.value !== "node:module" && @@ -134,6 +228,7 @@ const directCreateRequireBinding = ( ) continue; for (const specifier of declaration.specifiers) { + yield; if ( t.isImportSpecifier(specifier) && t.isIdentifier(specifier.imported, { name: "createRequire" }) @@ -141,30 +236,33 @@ const directCreateRequireBinding = ( 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) - ); - }), - ); -}; + if (importedNames.size === 0) return false; + for (const statement of program.body) { + yield; + if (!t.isVariableDeclaration(statement, { kind: "const" })) continue; + for (const declaration of statement.declarations) { + yield; + 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]) + ) + continue; + const start = declaration.init.callee.loc?.start; + if (start === undefined) continue; + const reference = references.get(locationKey(start)); + if ( + reference?.resolution === "resolved" && + reference.bindingId === + semanticBindingId(programScope, declaration.init.callee.name) + ) + return true; + } + } + return false; +} const isImportMetaUrl = (node: t.Node | undefined): boolean => t.isMemberExpression(node) && diff --git a/src/domain/javascript/javascriptStaticAnalysis.cooperative.test.ts b/src/domain/javascript/javascriptStaticAnalysis.cooperative.test.ts new file mode 100644 index 00000000000..9a2f3b940cf --- /dev/null +++ b/src/domain/javascript/javascriptStaticAnalysis.cooperative.test.ts @@ -0,0 +1,43 @@ +import * as t from "@babel/types"; +import { expect, it } from "vitest"; + +import { parseJavaScriptSource } from "./javascriptSourceParser.js"; +import { analyzeParsedJavaScriptStaticSourceSteps } from "./javascriptStaticAnalysis.js"; +import { traverseJavaScriptAst } from "./javascriptSemanticTraversal.js"; + +it("bounds identifier work between static traversal checkpoints", () => { + const source = Array.from( + { length: 5000 }, + (_, i) => `require("./part-${String(i)}.cjs");`, + ).join("\n"); + const parsed = parseJavaScriptSource(source); + if (parsed === null) throw new Error("Fixture failed to parse"); + const reads = new Set(); + traverseJavaScriptAst(parsed, { + enter(node) { + if (!t.isIdentifier(node)) return; + const name = node.name; + Object.defineProperty(node, "name", { + get() { + reads.add(node); + return name; + }, + }); + }, + }); + const steps = analyzeParsedJavaScriptStaticSourceSteps(source, parsed); + let maximumReads = 0; + for (;;) { + reads.clear(); + const step = steps.next(); + if (step.done) { + expect(step.value.references).toHaveLength(5000); + break; + } + maximumReads = Math.max(maximumReads, reads.size); + } + // Allow the tail of one 1,024-node traversal and the next traversal's + // first chunk. Draining a whole 5,000-call classification violates this + // bound independently of CPU speed. Final result projection is separate. + expect(maximumReads).toBeLessThanOrEqual(2048); +}); diff --git a/src/domain/javascript/javascriptStaticAnalysis.ts b/src/domain/javascript/javascriptStaticAnalysis.ts index 4800faa57d0..a8f046b7451 100644 --- a/src/domain/javascript/javascriptStaticAnalysis.ts +++ b/src/domain/javascript/javascriptStaticAnalysis.ts @@ -53,10 +53,10 @@ import { import { createJavaScriptRpcScan } from "./javascriptRpcEndpoints.js"; import { - collectSemanticReferences, + collectSemanticReferencesSteps, semanticImportEqualsSpecifier, } from "./javascriptSemanticProjection.js"; -import { unshadowedJavaScriptRequireCalls } from "./javascriptRequireBindings.js"; +import { unshadowedJavaScriptRequireCallsSteps } from "./javascriptRequireBindings.js"; import { hasUncertainTypeOnlyModuleSideEffect, isTypeOnlyExportDeclaration, @@ -100,7 +100,11 @@ export function* analyzeParsedJavaScriptStaticSourceSteps( bindings, ); const clientRoots = yield* classifyParsedJavaScriptRpcClientRootsSteps(file); - let requireCalls: ReadonlySet | undefined; + const requireCalls = yield* unshadowedJavaScriptRequireCallsSteps( + file, + undefined, + yield* collectSemanticReferencesSteps(file.program, bindings), + ); yield* traverseStaticSourceSteps( file, { @@ -108,14 +112,7 @@ export function* analyzeParsedJavaScriptStaticSourceSteps( accumulator, ...(openReceiverFacts === undefined ? {} : { openReceiverFacts }), electronBindings: electronIdentity.bindings, - isUnshadowedRequireCall: (node) => { - requireCalls ??= unshadowedJavaScriptRequireCalls( - file, - undefined, - collectSemanticReferences(file.program, bindings), - ); - return requireCalls.has(node); - }, + isUnshadowedRequireCall: (node) => requireCalls.has(node), rpcScan: createJavaScriptRpcScan(source, clientRoots), }, bindings, diff --git a/tests/acceptance/applications/shadowedRequireCli.test.ts b/tests/acceptance/applications/shadowedRequireCli.test.ts index 1e56f132a95..374e0c3c385 100644 --- a/tests/acceptance/applications/shadowedRequireCli.test.ts +++ b/tests/acceptance/applications/shadowedRequireCli.test.ts @@ -30,6 +30,18 @@ cliTest.for(["CLI", "stdio MCP"] as const)( "var.cjs": 'var require; console.log(require("./real-dependency.cjs"));', "create-require.mjs": 'import {createRequire} from "node:module"; const require = createRequire(import.meta.url); console.log(require("./real-dependency.cjs"));', + "before-reassign.cjs": + 'console.log(require?.("./real-dependency.cjs")); require = () => "local"; console.log(require?.("./ghost.cjs"));', + "before-var-initializer.cjs": + 'console.log(require?.("./real-dependency.cjs")); var require = () => "local"; console.log(require?.("./ghost.cjs"));', + "before-destructuring.cjs": + 'console.log(require?.("./real-dependency.cjs")); ({require} = {require: () => "local"}); console.log(require?.("./ghost.cjs"));', + "deferred.cjs": + 'function run() { return require?.("./ghost.cjs"); } require = () => "local"; console.log(run());', + "deferred-write.cjs": + 'reset(); console.log(require?.("./ghost.cjs")); function reset() { require = () => "local"; }', + "instance-field.cjs": + 'class Loader { value = require?.("./ghost.cjs"); } require = () => "local"; console.log(new Loader().value);', "real-dependency.cjs": 'module.exports = "real";', "ghost.cjs": 'throw new Error("A local function must not load this module");', @@ -49,6 +61,12 @@ cliTest.for(["CLI", "stdio MCP"] as const)( ["real-optional.cjs", "real"], ["var.cjs", "real"], ["create-require.mjs", "real"], + ["before-reassign.cjs", "real\nlocal"], + ["before-var-initializer.cjs", "real\nlocal"], + ["before-destructuring.cjs", "real\nlocal"], + ["deferred.cjs", "local"], + ["deferred-write.cjs", "local"], + ["instance-field.cjs", "local"], ] as const) expect( ( @@ -78,6 +96,9 @@ cliTest.for(["CLI", "stdio MCP"] as const)( : null; }); expect(realSources.sort()).toEqual([ + "before-destructuring.cjs", + "before-reassign.cjs", + "before-var-initializer.cjs", "create-require.mjs", "real-optional.cjs", "real.cjs", diff --git a/tests/boundary/filesystem/referenceSourceShadowedRequire.test.ts b/tests/boundary/filesystem/referenceSourceShadowedRequire.test.ts index 8c438e64737..572562f81b5 100644 --- a/tests/boundary/filesystem/referenceSourceShadowedRequire.test.ts +++ b/tests/boundary/filesystem/referenceSourceShadowedRequire.test.ts @@ -52,6 +52,11 @@ it("resolves require calls by lexical binding and source mode", async () => { "require = () => 'local';", "console.log(require('./throwing.cjs'));", ].join("\n"), + "before-write.cjs": [ + "console.log(require('./loaded.cjs'));", + "require = () => 'local';", + "console.log(require('./throwing.cjs'));", + ].join("\n"), "unbound-esm.mjs": [ "try { require('./throwing.cjs'); }", "catch (error) {", @@ -89,6 +94,7 @@ it("resolves require calls by lexical binding and source mode", async () => { ["top-level-var.cjs", "loaded\nreal\n"], ["initialized-var.cjs", "local\n"], ["assigned-var.cjs", "local\n"], + ["before-write.cjs", "loaded\nreal\nlocal\n"], ["unbound-esm.mjs", "no-global-require\n"], ["shadowed-esm.mjs", "local\n"], ["create-require-esm.mjs", "loaded\nreal\n"], @@ -114,6 +120,7 @@ it("resolves require calls by lexical binding and source mode", async () => { .map(({ from_path, to }) => [from_path, to]) .sort((left, right) => (left[0] ?? "").localeCompare(right[0] ?? "")), ).toEqual([ + ["before-write.cjs", "loaded.cjs"], ["create-require-esm.mjs", "loaded.cjs"], ["nested.cjs", "loaded.cjs"], ["top-level-var.cjs", "loaded.cjs"],