From 40df65e2bb0da72eecb4a892baeaac2475c7e80b Mon Sep 17 00:00:00 2001 From: hussnainahmedd Date: Tue, 13 Oct 2026 15:08:09 -0400 Subject: [PATCH 01/13] fix(graph): skip import edges for lexically shadowed require (#2890) Do not emit a CommonJS import edge when the callee of a bare require call resolves to a local binding (parameter, hoisted function, catch binding or block binding). The lexical binding index is consulted at the call site, so no file-wide AST walk is needed. Unbound require, an uninitialized top-level var require, and direct createRequire(import.meta.url) aliases keep their edges; write handling is positional so loader calls before a reassignment (including eagerly evaluated computed method keys) are preserved. --- .../javascriptStaticAnalysisCalls.ts | 106 +++++++++++++++++- ...criptStaticAnalysisShadowedRequire.test.ts | 106 ++++++++++++++++++ 2 files changed, 210 insertions(+), 2 deletions(-) create mode 100644 src/domain/javascript/javascriptStaticAnalysisShadowedRequire.test.ts diff --git a/src/domain/javascript/javascriptStaticAnalysisCalls.ts b/src/domain/javascript/javascriptStaticAnalysisCalls.ts index b098b3c26d7..4dcfcdd412f 100644 --- a/src/domain/javascript/javascriptStaticAnalysisCalls.ts +++ b/src/domain/javascript/javascriptStaticAnalysisCalls.ts @@ -12,6 +12,7 @@ import { argumentValue, calleeName, endpointArgument as endpointArgumentHelper, + isImportMetaUrl, prefixedArgument, range, rangeForOffsets, @@ -33,7 +34,10 @@ import type { } from "./javascriptStaticAnalysisState.js"; import { resolveSemanticBindingState, + semanticResolutionBlocked, + semanticStableInitializer, type JavaScriptSemanticAnalysisState, + type JavaScriptSemanticBindingState, } from "./javascriptSemanticState.js"; import { isUnmodifiedGlobalReference, @@ -57,7 +61,7 @@ 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, bindings); if (requireCall !== null) addReference(context, { node, @@ -214,9 +218,18 @@ const requireCallSpecifier = ( node: JavaScriptCallLike, name: string, moduleRequireName: string | null, + bindings: JavaScriptSemanticAnalysisState, ): { readonly specifier: string | undefined } | null => { - if (name === "require" || name.endsWith(".require")) + if (name === "require" || name.endsWith(".require")) { + // A bare `require` is the CommonJS loader only while its callee + // identifier is lexically unbound. A local parameter, hoisted function, + // catch binding or block binding shadows the wrapper loader, so the call + // cannot be a module load. The binding index is consulted at the call + // site; no file-wide walk is needed. + if (name === "require" && isShadowedRequireCallee(node, bindings)) + return null; return { specifier: argumentValue(node.arguments[0]) }; + } if (name === "__webpack_require__" || name === moduleRequireName) return { specifier: argumentValue(node.arguments[0]) }; if ( @@ -227,6 +240,95 @@ const requireCallSpecifier = ( return null; }; +/** + * Decide whether a bare `require(...)` / `require?.(...)` callee resolves to + * a local binding that shadows the CommonJS loader. Unbound identifiers keep + * the loader; only a binding that provably retains or aliases the loader + * (uninitialized top-level `var require`, direct `createRequire` alias) + * preserves the candidate. + */ +const isShadowedRequireCallee = ( + node: JavaScriptCallLike, + bindings: JavaScriptSemanticAnalysisState, +): boolean => { + const callee = unwrapJavaScriptExpression(node.callee).node; + if (!t.isIdentifier(callee, { name: "require" })) return false; + // An incomplete scope cannot prove shadowing; keep the candidate. + if (semanticResolutionBlocked(bindings, callee, "require")) return false; + const binding = resolveSemanticBindingState(bindings, callee, "require"); + if (binding === undefined) return false; + return !isRetainedRequireLoader(binding, node, bindings); +}; + +const isRetainedRequireLoader = ( + binding: JavaScriptSemanticBindingState, + node: JavaScriptCallLike, + state: JavaScriptSemanticAnalysisState, +): boolean => + isDirectCreateRequireBinding(binding, state) || + isUninitializedTopLevelVarRequire(binding, state, node); + +/** + * `const require = createRequire(import.meta.url)` aliases the ESM loader. + * Erased TypeScript syntax around the initializer (e.g. `as NodeRequire`) + * is unwrapped before matching, mirroring the reference-source classifier. + */ +const isDirectCreateRequireBinding = ( + binding: JavaScriptSemanticBindingState, + state: JavaScriptSemanticAnalysisState, +): boolean => { + const initializer = semanticStableInitializer(binding, state); + if (initializer === undefined) return false; + const init = unwrapJavaScriptExpression(initializer.node).node; + if (!t.isCallExpression(init)) return false; + const callee = unwrapJavaScriptExpression(init.callee).node; + if (!t.isIdentifier(callee)) return false; + const calleeBinding = resolveSemanticBindingState(state, callee, callee.name); + const [origin] = calleeBinding?.directOrigins ?? []; + return ( + calleeBinding?.runtimeImport === true && + origin !== undefined && + (origin.specifier === "node:module" || origin.specifier === "module") && + origin.importedPath.length === 1 && + origin.importedPath[0] === "createRequire" && + isImportMetaUrl(init.arguments[0]) + ); +}; + +/** + * An uninitialized top-level `var require` retains Node's wrapper loader, but + * a later write replaces it. Write handling is positional: a call that runs + * before every write still sees the loader (this covers loader calls before + * reassignment and eagerly evaluated computed method keys without classifying + * enclosing functions as deferred). A call at or after a write is shadowed. + */ +const isUninitializedTopLevelVarRequire = ( + binding: JavaScriptSemanticBindingState, + state: JavaScriptSemanticAnalysisState, + node: JavaScriptCallLike, +): boolean => { + const assignmentCount = binding.definitions.filter( + ({ kind }) => kind === "assignment", + ).length; + if ( + binding.kind !== "variable" || + !binding.definitions.every( + ({ kind }) => kind === "variable" || kind === "assignment", + ) || + // A declaration initializer means the loader was replaced at declaration. + binding.initializers.length !== assignmentCount || + state.scopesById.get(binding.scopeId)?.kind !== "program" + ) + return false; + const { line, column } = range(node).start; + return !binding.definitions.some( + ({ kind, location }) => + kind === "assignment" && + (location.start.line < line || + (location.start.line === line && location.start.column <= column)), + ); +}; + const chunkLoadSpecifier = ( node: JavaScriptCallLike, name: string, diff --git a/src/domain/javascript/javascriptStaticAnalysisShadowedRequire.test.ts b/src/domain/javascript/javascriptStaticAnalysisShadowedRequire.test.ts new file mode 100644 index 00000000000..49fb567282e --- /dev/null +++ b/src/domain/javascript/javascriptStaticAnalysisShadowedRequire.test.ts @@ -0,0 +1,106 @@ +import { describe, expect, it } from "vitest"; + +import { analyzeJavaScriptStaticSource } from "./javascriptStaticAnalysis.js"; + +/** + * Regression tests for https://github.com/morluto/rea/issues/2890: the + * application graph must not invent a CommonJS `imports` edge for a `require` + * call whose callee resolves to a lexically shadowing local binding. The + * static findings are the producing boundary, so the tests assert on the + * recovered `require` references directly. + */ +const requireSpecifiers = (source: string): readonly (string | null)[] => + analyzeJavaScriptStaticSource(source) + .references.filter((reference) => reference.kind === "require") + .map((reference) => reference.specifier); + +describe("lexically shadowed require calls", () => { + it.each([ + [ + "function parameter", + `function run(require) {\n return require("./ghost.cjs");\n}\nconsole.log(run(() => "local"));`, + ], + [ + "optional call with function parameter", + `function run(require) {\n return require?.("./ghost.cjs");\n}`, + ], + [ + "destructured parameter", + `function load({ require }) { return require("./throwing.cjs"); }\nconsole.log(load({ require: () => "local" }));`, + ], + [ + "hoisted function declaration", + `function load() {\n console.log(require("./throwing.cjs"));\n function require() { return "local"; }\n}\nload();`, + ], + [ + "catch binding", + `try { throw () => "local"; }\ncatch (require) { console.log(require("./throwing.cjs")); }`, + ], + [ + "block-scoped binding", + `{ const require = () => "local";\n console.log(require("./throwing.cjs")); }`, + ], + [ + "initialized top-level var", + `var require = () => "local";\nconsole.log(require("./throwing.cjs"));`, + ], + ])("emits no require reference for a %s shadow", (_label, source) => { + expect(requireSpecifiers(source)).toEqual([]); + }); + + it.each([ + [ + "ordinary unbound call", + `const value = require("./real.cjs");`, + ["./real.cjs"], + ], + [ + "optional unbound call", + `const value = require?.("./real.cjs");`, + ["./real.cjs"], + ], + [ + "uninitialized top-level var retains the wrapper loader", + `var require;\nconsole.log(require("./loaded.cjs"));`, + ["./loaded.cjs"], + ], + [ + "direct createRequire binding", + `import { createRequire } from "node:module";\nconst require = createRequire(import.meta.url);\nconst value = require("./dep.cjs");`, + ["./dep.cjs"], + ], + [ + "createRequire binding behind erased TypeScript syntax", + `import { createRequire } from "node:module";\nconst require = createRequire(import.meta.url) as NodeRequire;\nconst value = require("./dep.cjs");`, + ["./dep.cjs"], + ], + [ + "loader call before reassignment", + `require?.("./dep");\nrequire = localLoader;`, + ["./dep"], + ], + [ + "eager computed method key before reassignment", + `const routes = { [require?.("./dep")]() {} };\nrequire = localLoader;`, + ["./dep"], + ], + ])("keeps the require reference for %s", (_label, source, expected) => { + expect(requireSpecifiers(source)).toEqual(expected); + }); + + it("keeps only the genuine call in a mixed file", () => { + expect( + requireSpecifiers( + `console.log(require("./loaded.cjs"));\nfunction load(require) { return require("./throwing.cjs"); }\nconsole.log(load(() => "local"));`, + ), + ).toEqual(["./loaded.cjs"]); + }); + + it("keeps the loader call before a write but not the call after it", () => { + expect( + requireSpecifiers( + `var require;\nrequire("./before.cjs");\nrequire = localLoader;\nrequire("./after.cjs");`, + ), + ).toEqual(["./before.cjs"]); + }); +}); From 4c4c273cbffacffed396a9ae59a242edc3c7d40a Mon Sep 17 00:00:00 2001 From: hussnainahmedd Date: Tue, 13 Oct 2026 20:32:17 +0000 Subject: [PATCH 02/13] fix(graph): address review findings on shadowed-require detection - Preserve calls resolving to a bundle factory's loader binding when the factory names its parameter require (e.g. webpack (module, exports, require)); nested bindings with the same name stay shadowed. - Restrict the uninitialized top-level wrapper-loader exception to var: record the declaration kind in the binding index so let require no longer invents a dependency. - Treat a write as taking effect at the end of its assignment expression, so a call on the right-hand side (require = require("./dep.cjs")) still sees the loader; writes inside a function body only shadow calls in that same body. - Accept createRequire through a module namespace import (ns.createRequire) by resolving its imported origin. - Cache per-binding declaration facts instead of rescanning definitions for every bare require call. --- .../javascript/javascriptSemanticAnalysis.ts | 19 +- src/domain/javascript/javascriptSemanticIr.ts | 6 + .../javascriptStaticAnalysisBundler.ts | 6 + .../javascriptStaticAnalysisCalls.ts | 208 +++++++++++++++--- ...avascriptStaticAnalysisModuleIndex.test.ts | 4 + ...criptStaticAnalysisShadowedRequire.test.ts | 41 ++++ .../javascriptStaticAnalysisState.ts | 8 + 7 files changed, 257 insertions(+), 35 deletions(-) diff --git a/src/domain/javascript/javascriptSemanticAnalysis.ts b/src/domain/javascript/javascriptSemanticAnalysis.ts index 119fbdd4533..883978b7f6e 100644 --- a/src/domain/javascript/javascriptSemanticAnalysis.ts +++ b/src/domain/javascript/javascriptSemanticAnalysis.ts @@ -77,6 +77,7 @@ interface BindPatternInput { readonly state: JavaScriptSemanticAnalysisState; readonly mutable: boolean; readonly kind?: JavaScriptSemanticDefinition["kind"]; + readonly declarationKind?: JavaScriptSemanticDefinition["declarationKind"]; readonly projection?: readonly (string | number | null)[]; readonly referenceOnly?: boolean; readonly copyKind?: "object-rest" | "array-rest"; @@ -101,6 +102,7 @@ interface AddBindingInput { readonly mutable: boolean; readonly definitionNode: t.Node; readonly initializer: t.Node | null; + readonly declarationKind?: JavaScriptSemanticDefinition["declarationKind"]; readonly directOrigin?: JavaScriptModuleOrigin; readonly runtimeImport?: boolean; readonly projection?: readonly (string | number | null)[]; @@ -877,6 +879,14 @@ const bindInnerDeclaration = ( parent !== null && t.isVariableDeclaration(parent) ? parent.kind !== "const" : true, + declarationKind: + parent !== null && t.isVariableDeclaration(parent) + ? parent.kind === "var" || + parent.kind === "let" || + parent.kind === "const" + ? parent.kind + : undefined + : undefined, }); else if (t.isClassExpression(node) && t.isIdentifier(node.id)) addBinding({ @@ -1155,6 +1165,7 @@ const bindPattern = (input: BindPatternInput): void => { state, mutable, kind = "variable", + declarationKind, projection = [], referenceOnly = false, copyKind, @@ -1199,6 +1210,7 @@ const bindPattern = (input: BindPatternInput): void => { definitionNode: pattern, initializer, projection, + ...(declarationKind === undefined ? {} : { declarationKind }), }); return; } @@ -1300,6 +1312,7 @@ const addBinding = (input: AddBindingInput): void => { mutable, definitionNode, initializer, + declarationKind, directOrigin, runtimeImport, projection = [], @@ -1312,7 +1325,11 @@ const addBinding = (input: AddBindingInput): void => { state.bindingsById.set(binding.bindingId, binding); } binding.mutable ||= mutable; - binding.definitions.push({ kind, location: range(definitionNode) }); + binding.definitions.push({ + kind, + location: range(definitionNode), + ...(declarationKind === undefined ? {} : { declarationKind }), + }); if (initializer !== null) binding.initializers.push({ node: initializer, projection }); if (directOrigin !== undefined) diff --git a/src/domain/javascript/javascriptSemanticIr.ts b/src/domain/javascript/javascriptSemanticIr.ts index d8349e895d1..20e65a7f77c 100644 --- a/src/domain/javascript/javascriptSemanticIr.ts +++ b/src/domain/javascript/javascriptSemanticIr.ts @@ -66,6 +66,12 @@ export interface JavaScriptSemanticDefinition { | "catch" | "assignment"; readonly location: JavaScriptSourceRange; + /** + * The `VariableDeclaration` kind (`var`, `let`, `const`) for a `variable` + * definition. Recorded so consumers can distinguish hoisted `var` + * declarations from block-scoped `let`/`const` without reparsing. + */ + readonly declarationKind?: "var" | "let" | "const"; } /** One resolved lexical binding plus static value and provenance. */ diff --git a/src/domain/javascript/javascriptStaticAnalysisBundler.ts b/src/domain/javascript/javascriptStaticAnalysisBundler.ts index 773f88d7d20..8442650ee0d 100644 --- a/src/domain/javascript/javascriptStaticAnalysisBundler.ts +++ b/src/domain/javascript/javascriptStaticAnalysisBundler.ts @@ -2,6 +2,7 @@ import * as t from "@babel/types"; import { javascriptBootstrapAt } from "./javascriptBootstrap.js"; import { resolveSemanticBindingState, + semanticScopeId, type JavaScriptSemanticAnalysisState, } from "./javascriptSemanticState.js"; @@ -259,6 +260,11 @@ const recoverBundlerModule = ( key, runtime, requireName, + // The factory's own scope always exists: the semantic pass creates one + // function scope per visited function node, and the factory is part of + // the analyzed source. + loaderScopeId: + requireName === "require" ? semanticScopeId("function", factory) : null, }; accumulator.moduleRangeIndex.add(moduleRange); } diff --git a/src/domain/javascript/javascriptStaticAnalysisCalls.ts b/src/domain/javascript/javascriptStaticAnalysisCalls.ts index 4dcfcdd412f..e42ad615277 100644 --- a/src/domain/javascript/javascriptStaticAnalysisCalls.ts +++ b/src/domain/javascript/javascriptStaticAnalysisCalls.ts @@ -30,6 +30,7 @@ import type { JavaScriptAnalysisAccumulator as AnalysisAccumulator, JavaScriptEndpointInput as EndpointInput, JavaScriptFindingContext as FindingContext, + JavaScriptModuleRange, JavaScriptReferenceInput as ReferenceInput, } from "./javascriptStaticAnalysisState.js"; import { @@ -61,7 +62,7 @@ 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, bindings); + const requireCall = requireCallSpecifier(node, name, module, bindings); if (requireCall !== null) addReference(context, { node, @@ -217,16 +218,17 @@ const noteMediaElementPreloads = ( const requireCallSpecifier = ( node: JavaScriptCallLike, name: string, - moduleRequireName: string | null, + module: JavaScriptModuleRange | undefined, bindings: JavaScriptSemanticAnalysisState, ): { readonly specifier: string | undefined } | null => { + const moduleRequireName = module?.requireName ?? null; if (name === "require" || name.endsWith(".require")) { // A bare `require` is the CommonJS loader only while its callee // identifier is lexically unbound. A local parameter, hoisted function, // catch binding or block binding shadows the wrapper loader, so the call // cannot be a module load. The binding index is consulted at the call // site; no file-wide walk is needed. - if (name === "require" && isShadowedRequireCallee(node, bindings)) + if (name === "require" && isShadowedRequireCallee(node, module, bindings)) return null; return { specifier: argumentValue(node.arguments[0]) }; } @@ -249,6 +251,7 @@ const requireCallSpecifier = ( */ const isShadowedRequireCallee = ( node: JavaScriptCallLike, + module: JavaScriptModuleRange | undefined, bindings: JavaScriptSemanticAnalysisState, ): boolean => { const callee = unwrapJavaScriptExpression(node.callee).node; @@ -257,6 +260,16 @@ const isShadowedRequireCallee = ( if (semanticResolutionBlocked(bindings, callee, "require")) return false; const binding = resolveSemanticBindingState(bindings, callee, "require"); if (binding === undefined) return false; + // A bundle factory whose loader parameter is named `require` (e.g. + // webpack's `(module, exports, require)`) is the recovered module loader, + // not a shadow. Only the exact factory binding qualifies: a nested binding + // with the same name resolves to a different scope and stays shadowed. + if ( + module?.requireName === "require" && + module.loaderScopeId !== null && + binding.scopeId === module.loaderScopeId + ) + return false; return !isRetainedRequireLoader(binding, node, bindings); }; @@ -281,52 +294,179 @@ const isDirectCreateRequireBinding = ( if (initializer === undefined) return false; const init = unwrapJavaScriptExpression(initializer.node).node; if (!t.isCallExpression(init)) return false; - const callee = unwrapJavaScriptExpression(init.callee).node; - if (!t.isIdentifier(callee)) return false; - const calleeBinding = resolveSemanticBindingState(state, callee, callee.name); - const [origin] = calleeBinding?.directOrigins ?? []; - return ( - calleeBinding?.runtimeImport === true && - origin !== undefined && - (origin.specifier === "node:module" || origin.specifier === "module") && - origin.importedPath.length === 1 && - origin.importedPath[0] === "createRequire" && - isImportMetaUrl(init.arguments[0]) + if (!isImportMetaUrl(init.arguments[0])) return false; + return isCreateRequireCallee( + unwrapJavaScriptExpression(init.callee).node, + state, ); }; +/** + * Match `createRequire` by its imported origin rather than one call spelling: + * the named import (`createRequire(...)`) and the namespace form + * (`ns.createRequire(...)` where `ns` is `import * as ns from "node:module"`). + * Named ESM exports and module namespace objects are read-only, so their + * identity is stable; default imports can be replaced and stay excluded. + */ +const isCreateRequireCallee = ( + callee: t.Node, + state: JavaScriptSemanticAnalysisState, +): boolean => { + if (t.isIdentifier(callee)) { + const calleeBinding = resolveSemanticBindingState( + state, + callee, + callee.name, + ); + const [origin] = calleeBinding?.directOrigins ?? []; + return ( + calleeBinding?.runtimeImport === true && + origin !== undefined && + (origin.specifier === "node:module" || origin.specifier === "module") && + origin.importedPath.length === 1 && + origin.importedPath[0] === "createRequire" + ); + } + if (t.isMemberExpression(callee)) { + if ( + semanticStaticPropertyName(callee.property, callee.computed) !== + "createRequire" + ) + return false; + const object = unwrapJavaScriptExpression(callee.object).node; + if (!t.isIdentifier(object)) return false; + const objectBinding = resolveSemanticBindingState( + state, + object, + object.name, + ); + const [origin] = objectBinding?.directOrigins ?? []; + return ( + objectBinding?.runtimeImport === true && + origin !== undefined && + (origin.specifier === "node:module" || origin.specifier === "module") && + origin.importedPath.length === 0 + ); + } + return false; +}; + /** * An uninitialized top-level `var require` retains Node's wrapper loader, but - * a later write replaces it. Write handling is positional: a call that runs - * before every write still sees the loader (this covers loader calls before - * reassignment and eagerly evaluated computed method keys without classifying - * enclosing functions as deferred). A call at or after a write is shadowed. + * a later write replaces it. Only `var` qualifies: a block-scoped `let` + * declaration never receives the wrapper loader, so `let require;` followed + * by a call cannot be a module load. + * + * Write handling accounts for evaluation order. A write takes effect when its + * assignment expression finishes evaluating, so a call inside the write's own + * right-hand side (e.g. `require = require("./dep.cjs")`) still sees the + * loader. A write inside a function body only shadows calls in that same + * body: source order cannot establish when the body runs relative to an + * outer call. A call at or after an effective write is shadowed. */ const isUninitializedTopLevelVarRequire = ( binding: JavaScriptSemanticBindingState, state: JavaScriptSemanticAnalysisState, node: JavaScriptCallLike, ): boolean => { + const facts = retainedRequireFacts(binding, state); + if (!facts.declarationsOk) return false; + const { line, column } = range(node).start; + const callFunction = nearestFunctionNode(node, state); + return !facts.writes.some( + (write) => + (write.endLine < line || + (write.endLine === line && write.endColumn <= column)) && + (write.functionNode === null || write.functionNode === callFunction), + ); +}; + +interface RequireWriteFact { + readonly endLine: number; + readonly endColumn: number; + /** Nearest enclosing function of the write, or null at program top level. */ + readonly functionNode: t.Node | null; +} + +interface RetainedRequireFacts { + readonly declarationsOk: boolean; + readonly writes: readonly RequireWriteFact[]; +} + +/** + * Per-binding declaration facts for the uninitialized-`var`-require check. + * Bindings are created once per analysis, so the definition scan and write + * indexing run once per binding instead of once per bare `require` call. + */ +const retainedRequireFactsCache = new WeakMap< + JavaScriptSemanticBindingState, + RetainedRequireFacts +>(); + +const retainedRequireFacts = ( + binding: JavaScriptSemanticBindingState, + state: JavaScriptSemanticAnalysisState, +): RetainedRequireFacts => { + const cached = retainedRequireFactsCache.get(binding); + if (cached !== undefined) return cached; + const facts = computeRetainedRequireFacts(binding, state); + retainedRequireFactsCache.set(binding, facts); + return facts; +}; + +const computeRetainedRequireFacts = ( + binding: JavaScriptSemanticBindingState, + state: JavaScriptSemanticAnalysisState, +): RetainedRequireFacts => { const assignmentCount = binding.definitions.filter( ({ kind }) => kind === "assignment", ).length; - if ( - binding.kind !== "variable" || - !binding.definitions.every( - ({ kind }) => kind === "variable" || kind === "assignment", - ) || + const declarationsOk = + binding.kind === "variable" && + binding.definitions.every( + ({ kind, declarationKind }) => + kind === "assignment" || + (kind === "variable" && declarationKind === "var"), + ) && // A declaration initializer means the loader was replaced at declaration. - binding.initializers.length !== assignmentCount || - state.scopesById.get(binding.scopeId)?.kind !== "program" - ) - return false; - const { line, column } = range(node).start; - return !binding.definitions.some( - ({ kind, location }) => - kind === "assignment" && - (location.start.line < line || - (location.start.line === line && location.start.column <= column)), - ); + binding.initializers.length === assignmentCount && + state.scopesById.get(binding.scopeId)?.kind === "program"; + if (!declarationsOk) return { declarationsOk, writes: [] }; + // Each assignment pushes exactly one initializer holding the full + // assignment expression, in definition order, so the i-th assignment pairs + // with initializers[i]. + let assignmentIndex = 0; + const writes: RequireWriteFact[] = []; + for (const definition of binding.definitions) { + if (definition.kind !== "assignment") continue; + const initializer = binding.initializers[assignmentIndex]; + assignmentIndex += 1; + if (initializer === undefined) continue; + const { line, column } = range(initializer.node).end; + writes.push({ + endLine: line, + endColumn: column, + functionNode: nearestFunctionNode(initializer.node, state), + }); + } + return { declarationsOk: true, writes }; +}; + +/** + * Nearest enclosing function of a node, or null at program top level. An + * incomplete parent chain falls back to null, which keeps the previous + * positional behavior for that write or call. + */ +const nearestFunctionNode = ( + node: t.Node, + state: JavaScriptSemanticAnalysisState, +): t.Node | null => { + let current: t.Node | undefined = node; + while (current !== undefined) { + if (t.isFunction(current)) return current; + current = state.parentsByNode.get(current); + } + return null; }; const chunkLoadSpecifier = ( diff --git a/src/domain/javascript/javascriptStaticAnalysisModuleIndex.test.ts b/src/domain/javascript/javascriptStaticAnalysisModuleIndex.test.ts index cb151594761..324b35260f9 100644 --- a/src/domain/javascript/javascriptStaticAnalysisModuleIndex.test.ts +++ b/src/domain/javascript/javascriptStaticAnalysisModuleIndex.test.ts @@ -116,6 +116,7 @@ it("preserves first insertion precedence and inclusive interval bounds", () => { key: "outer-first", runtime: "webpackChunkApp", requireName: null, + loaderScopeId: null, }); nested.add({ start: 20, @@ -123,6 +124,7 @@ it("preserves first insertion precedence and inclusive interval bounds", () => { key: "inner-second", runtime: "webpackChunkApp", requireName: null, + loaderScopeId: null, }); nested.add({ start: 30, @@ -130,6 +132,7 @@ it("preserves first insertion precedence and inclusive interval bounds", () => { key: "later-overlap", runtime: "webpackChunkApp", requireName: null, + loaderScopeId: null, }); expect(nested.find(9)).toBeUndefined(); expect(nested.find(10)?.key).toBe("outer-first"); @@ -148,6 +151,7 @@ it("rejects missing and out-of-range lookup offsets", () => { key: "bounded", runtime: "webpackChunkApp", requireName: null, + loaderScopeId: null, }); expect(index.find(undefined)).toBeUndefined(); expect(index.find(null)).toBeUndefined(); diff --git a/src/domain/javascript/javascriptStaticAnalysisShadowedRequire.test.ts b/src/domain/javascript/javascriptStaticAnalysisShadowedRequire.test.ts index 49fb567282e..f813a30d70f 100644 --- a/src/domain/javascript/javascriptStaticAnalysisShadowedRequire.test.ts +++ b/src/domain/javascript/javascriptStaticAnalysisShadowedRequire.test.ts @@ -44,6 +44,14 @@ describe("lexically shadowed require calls", () => { "initialized top-level var", `var require = () => "local";\nconsole.log(require("./throwing.cjs"));`, ], + [ + "top-level let binding", + `export {}; let require; require?.("./ghost.cjs");`, + ], + [ + "nested shadow inside a require-named bundle factory", + `globalThis.webpackChunkApp.push([[1], { entry(module, exports, require) { function inner(require) { return require("./nested.cjs"); } } }]);`, + ], ])("emits no require reference for a %s shadow", (_label, source) => { expect(requireSpecifiers(source)).toEqual([]); }); @@ -84,6 +92,31 @@ describe("lexically shadowed require calls", () => { `const routes = { [require?.("./dep")]() {} };\nrequire = localLoader;`, ["./dep"], ], + [ + "bundle factory whose loader parameter is named require", + `globalThis.webpackChunkApp.push([[1], { entry(module, exports, require) { require("dep"); } }]);`, + ["dep"], + ], + [ + "bundle factory loader call next to a nested shadow", + `globalThis.webpackChunkApp.push([[1], { entry(module, exports, require) { require("dep"); function inner(require) { return require("./nested.cjs"); } } }]);`, + ["dep"], + ], + [ + "namespace createRequire binding", + `import * as module from "node:module";\nconst require = module.createRequire(import.meta.url);\nrequire("./dep.cjs");`, + ["./dep.cjs"], + ], + [ + "call inside the right-hand side of its own reassignment", + `var require; require = require("./dep.cjs");`, + ["./dep.cjs"], + ], + [ + "top-level call before a deferred write", + `var require;\nfunction f() { require = localLoader; }\nrequire("./dep.cjs");`, + ["./dep.cjs"], + ], ])("keeps the require reference for %s", (_label, source, expected) => { expect(requireSpecifiers(source)).toEqual(expected); }); @@ -103,4 +136,12 @@ describe("lexically shadowed require calls", () => { ), ).toEqual(["./before.cjs"]); }); + + it("drops the call after a write in the same function body", () => { + expect( + requireSpecifiers( + `var require;\nfunction f() { require = localLoader; return require("./x.cjs"); }\nf();`, + ), + ).toEqual([]); + }); }); diff --git a/src/domain/javascript/javascriptStaticAnalysisState.ts b/src/domain/javascript/javascriptStaticAnalysisState.ts index 5173362faf0..6ee68ecaead 100644 --- a/src/domain/javascript/javascriptStaticAnalysisState.ts +++ b/src/domain/javascript/javascriptStaticAnalysisState.ts @@ -28,6 +28,14 @@ export interface JavaScriptModuleRange { readonly key: string; readonly runtime: string; readonly requireName: string | null; + /** + * Scope id of the factory function's own scope when the factory names its + * loader parameter `require`. A bare `require(...)` call resolving to a + * binding in exactly this scope is the recovered bundle loader, not a + * lexical shadow; a nested binding with the same name resolves to a + * different scope and stays shadowed. + */ + readonly loaderScopeId: string | null; } /** Static finding retained with its source offset until module attribution. */ From 3e26716e63c3482baf6b7f75f2ddb9347d1b5d60 Mon Sep 17 00:00:00 2001 From: Hussnain Ahmad Date: Tue, 13 Oct 2026 17:24:36 -0400 Subject: [PATCH 03/13] fix(graph): address greptile review findings on loader exemptions Verify the factory binding still holds the loader parameter before exempting it, require an empty projection for direct createRequire aliases, reject rest-element copies as retained loaders, and model for...of/for...in writes at body entry. --- .../javascriptStaticAnalysisCalls.ts | 33 ++- ...criptStaticAnalysisShadowedRequire.test.ts | 261 +++++++++++------- 2 files changed, 187 insertions(+), 107 deletions(-) diff --git a/src/domain/javascript/javascriptStaticAnalysisCalls.ts b/src/domain/javascript/javascriptStaticAnalysisCalls.ts index e42ad615277..a7167cfcb3e 100644 --- a/src/domain/javascript/javascriptStaticAnalysisCalls.ts +++ b/src/domain/javascript/javascriptStaticAnalysisCalls.ts @@ -262,12 +262,20 @@ const isShadowedRequireCallee = ( if (binding === undefined) return false; // A bundle factory whose loader parameter is named `require` (e.g. // webpack's `(module, exports, require)`) is the recovered module loader, - // not a shadow. Only the exact factory binding qualifies: a nested binding + // not a shadow. Only a binding that still holds the loader parameter + // qualifies: a hoisted `function`/`class` or initialized variable + // declaration in the same scope replaces the parameter for the whole body, + // so a merged binding is a local shadow, not the loader. A nested binding // with the same name resolves to a different scope and stays shadowed. + // Assignments keep the current positional behavior; they do not prove the + // loader is gone at this call. if ( module?.requireName === "require" && module.loaderScopeId !== null && - binding.scopeId === module.loaderScopeId + binding.scopeId === module.loaderScopeId && + binding.definitions.every( + ({ kind }) => kind === "parameter" || kind === "assignment", + ) ) return false; return !isRetainedRequireLoader(binding, node, bindings); @@ -292,6 +300,10 @@ const isDirectCreateRequireBinding = ( ): boolean => { const initializer = semanticStableInitializer(binding, state); if (initializer === undefined) return false; + // Only a direct alias holds the loader: a destructured property of the + // created require (e.g. `const { missing: require } = createRequire(...)`) + // keeps a non-empty projection and is not the loader itself. + if (initializer.projection.length > 0) return false; const init = unwrapJavaScriptExpression(initializer.node).node; if (!t.isCallExpression(init)) return false; if (!isImportMetaUrl(init.arguments[0])) return false; @@ -430,6 +442,12 @@ const computeRetainedRequireFacts = ( ) && // A declaration initializer means the loader was replaced at declaration. binding.initializers.length === assignmentCount && + // A rest-element declaration (`var {...require} = {}`) copies an object + // or array into the binding through a reference initializer instead of a + // value initializer, so the binding never holds the wrapper loader. + !binding.referenceInitializers.some( + (initializer) => initializer.copyKind !== undefined, + ) && state.scopesById.get(binding.scopeId)?.kind === "program"; if (!declarationsOk) return { declarationsOk, writes: [] }; // Each assignment pushes exactly one initializer holding the full @@ -442,7 +460,16 @@ const computeRetainedRequireFacts = ( const initializer = binding.initializers[assignmentIndex]; assignmentIndex += 1; if (initializer === undefined) continue; - const { line, column } = range(initializer.node).end; + // A `for...of`/`for...in` write assigns the loop variable before each + // body execution, so model the write at body entry: the statement end + // would place a call inside the body before the write and invent a false + // dependency. The iterable expression still evaluates before the write. + const writeNode = initializer.node; + const writePosition = + t.isForOfStatement(writeNode) || t.isForInStatement(writeNode) + ? range(writeNode.body).start + : range(writeNode).end; + const { line, column } = writePosition; writes.push({ endLine: line, endColumn: column, diff --git a/src/domain/javascript/javascriptStaticAnalysisShadowedRequire.test.ts b/src/domain/javascript/javascriptStaticAnalysisShadowedRequire.test.ts index f813a30d70f..de752bd9437 100644 --- a/src/domain/javascript/javascriptStaticAnalysisShadowedRequire.test.ts +++ b/src/domain/javascript/javascriptStaticAnalysisShadowedRequire.test.ts @@ -14,112 +14,165 @@ const requireSpecifiers = (source: string): readonly (string | null)[] => .references.filter((reference) => reference.kind === "require") .map((reference) => reference.specifier); +const shadowedCases = [ + [ + "function parameter", + `function run(require) {\n return require("./ghost.cjs");\n}\nconsole.log(run(() => "local"));`, + ], + [ + "optional call with function parameter", + `function run(require) {\n return require?.("./ghost.cjs");\n}`, + ], + [ + "destructured parameter", + `function load({ require }) { return require("./throwing.cjs"); }\nconsole.log(load({ require: () => "local" }));`, + ], + [ + "hoisted function declaration", + `function load() {\n console.log(require("./throwing.cjs"));\n function require() { return "local"; }\n}\nload();`, + ], + [ + "catch binding", + `try { throw () => "local"; }\ncatch (require) { console.log(require("./throwing.cjs")); }`, + ], + [ + "block-scoped binding", + `{ const require = () => "local";\n console.log(require("./throwing.cjs")); }`, + ], + [ + "initialized top-level var", + `var require = () => "local";\nconsole.log(require("./throwing.cjs"));`, + ], + [ + "top-level let binding", + `export {}; let require; require?.("./ghost.cjs");`, + ], + [ + "nested shadow inside a require-named bundle factory", + `globalThis.webpackChunkApp.push([[1], { entry(module, exports, require) { function inner(require) { return require("./nested.cjs"); } } }]);`, + ], + [ + "factory loader parameter replaced by a hoisted function declaration", + `globalThis.webpackChunkApp.push([[1], { entry(module, exports, require) { function require() { return "local"; } require("ghost"); } }]);`, + ], + [ + "destructured missing property of createRequire", + `import { createRequire } from "node:module";\nconst { missing: require } = createRequire(import.meta.url);\nrequire?.("./ghost.cjs");`, + ], + [ + "object-rest declaration", + `var {...require} = {};\nrequire?.("./ghost.cjs");`, + ], + [ + "array-rest declaration", + `var [...require] = [];\nrequire?.("./ghost.cjs");`, + ], + [ + "for-of write before the call in the loop body", + `var require;\nfor (require of [() => "local"]) { require("./ghost.cjs"); }`, + ], + [ + "for-in write before the call in the loop body", + `var require;\nfor (require in { a: 1 }) { require("./ghost.cjs"); }`, + ], + [ + "call after a for-of write", + `var require;\nfor (require of [() => "local"]) {}\nrequire("./after.cjs");`, + ], +]; + +const loaderCases = [ + [ + "ordinary unbound call", + `const value = require("./real.cjs");`, + ["./real.cjs"], + ], + [ + "optional unbound call", + `const value = require?.("./real.cjs");`, + ["./real.cjs"], + ], + [ + "uninitialized top-level var retains the wrapper loader", + `var require;\nconsole.log(require("./loaded.cjs"));`, + ["./loaded.cjs"], + ], + [ + "direct createRequire binding", + `import { createRequire } from "node:module";\nconst require = createRequire(import.meta.url);\nconst value = require("./dep.cjs");`, + ["./dep.cjs"], + ], + [ + "createRequire binding behind erased TypeScript syntax", + `import { createRequire } from "node:module";\nconst require = createRequire(import.meta.url) as NodeRequire;\nconst value = require("./dep.cjs");`, + ["./dep.cjs"], + ], + [ + "loader call before reassignment", + `require?.("./dep");\nrequire = localLoader;`, + ["./dep"], + ], + [ + "eager computed method key before reassignment", + `const routes = { [require?.("./dep")]() {} };\nrequire = localLoader;`, + ["./dep"], + ], + [ + "bundle factory whose loader parameter is named require", + `globalThis.webpackChunkApp.push([[1], { entry(module, exports, require) { require("dep"); } }]);`, + ["dep"], + ], + [ + "bundle factory loader call next to a nested shadow", + `globalThis.webpackChunkApp.push([[1], { entry(module, exports, require) { require("dep"); function inner(require) { return require("./nested.cjs"); } } }]);`, + ["dep"], + ], + [ + "namespace createRequire binding", + `import * as module from "node:module";\nconst require = module.createRequire(import.meta.url);\nrequire("./dep.cjs");`, + ["./dep.cjs"], + ], + [ + "call inside the right-hand side of its own reassignment", + `var require; require = require("./dep.cjs");`, + ["./dep.cjs"], + ], + [ + "top-level call before a deferred write", + `var require;\nfunction f() { require = localLoader; }\nrequire("./dep.cjs");`, + ["./dep.cjs"], + ], + [ + "call before a for-of write", + `var require;\nrequire("./before.cjs");\nfor (require of [() => "local"]) { require("./ghost.cjs"); }`, + ["./before.cjs"], + ], + [ + "call before a for-in write", + `var require;\nrequire("./before.cjs");\nfor (require in { a: 1 }) { require("./ghost.cjs"); }`, + ["./before.cjs"], + ], + [ + "iterable call of a for-of loop sees the loader", + `var require;\nfor (require of require("./iter.cjs")) { require("./ghost.cjs"); }`, + ["./iter.cjs"], + ], +]; + describe("lexically shadowed require calls", () => { - it.each([ - [ - "function parameter", - `function run(require) {\n return require("./ghost.cjs");\n}\nconsole.log(run(() => "local"));`, - ], - [ - "optional call with function parameter", - `function run(require) {\n return require?.("./ghost.cjs");\n}`, - ], - [ - "destructured parameter", - `function load({ require }) { return require("./throwing.cjs"); }\nconsole.log(load({ require: () => "local" }));`, - ], - [ - "hoisted function declaration", - `function load() {\n console.log(require("./throwing.cjs"));\n function require() { return "local"; }\n}\nload();`, - ], - [ - "catch binding", - `try { throw () => "local"; }\ncatch (require) { console.log(require("./throwing.cjs")); }`, - ], - [ - "block-scoped binding", - `{ const require = () => "local";\n console.log(require("./throwing.cjs")); }`, - ], - [ - "initialized top-level var", - `var require = () => "local";\nconsole.log(require("./throwing.cjs"));`, - ], - [ - "top-level let binding", - `export {}; let require; require?.("./ghost.cjs");`, - ], - [ - "nested shadow inside a require-named bundle factory", - `globalThis.webpackChunkApp.push([[1], { entry(module, exports, require) { function inner(require) { return require("./nested.cjs"); } } }]);`, - ], - ])("emits no require reference for a %s shadow", (_label, source) => { - expect(requireSpecifiers(source)).toEqual([]); - }); + it.each(shadowedCases)( + "emits no require reference for a %s shadow", + (_label, source) => { + expect(requireSpecifiers(source)).toEqual([]); + }, + ); - it.each([ - [ - "ordinary unbound call", - `const value = require("./real.cjs");`, - ["./real.cjs"], - ], - [ - "optional unbound call", - `const value = require?.("./real.cjs");`, - ["./real.cjs"], - ], - [ - "uninitialized top-level var retains the wrapper loader", - `var require;\nconsole.log(require("./loaded.cjs"));`, - ["./loaded.cjs"], - ], - [ - "direct createRequire binding", - `import { createRequire } from "node:module";\nconst require = createRequire(import.meta.url);\nconst value = require("./dep.cjs");`, - ["./dep.cjs"], - ], - [ - "createRequire binding behind erased TypeScript syntax", - `import { createRequire } from "node:module";\nconst require = createRequire(import.meta.url) as NodeRequire;\nconst value = require("./dep.cjs");`, - ["./dep.cjs"], - ], - [ - "loader call before reassignment", - `require?.("./dep");\nrequire = localLoader;`, - ["./dep"], - ], - [ - "eager computed method key before reassignment", - `const routes = { [require?.("./dep")]() {} };\nrequire = localLoader;`, - ["./dep"], - ], - [ - "bundle factory whose loader parameter is named require", - `globalThis.webpackChunkApp.push([[1], { entry(module, exports, require) { require("dep"); } }]);`, - ["dep"], - ], - [ - "bundle factory loader call next to a nested shadow", - `globalThis.webpackChunkApp.push([[1], { entry(module, exports, require) { require("dep"); function inner(require) { return require("./nested.cjs"); } } }]);`, - ["dep"], - ], - [ - "namespace createRequire binding", - `import * as module from "node:module";\nconst require = module.createRequire(import.meta.url);\nrequire("./dep.cjs");`, - ["./dep.cjs"], - ], - [ - "call inside the right-hand side of its own reassignment", - `var require; require = require("./dep.cjs");`, - ["./dep.cjs"], - ], - [ - "top-level call before a deferred write", - `var require;\nfunction f() { require = localLoader; }\nrequire("./dep.cjs");`, - ["./dep.cjs"], - ], - ])("keeps the require reference for %s", (_label, source, expected) => { - expect(requireSpecifiers(source)).toEqual(expected); - }); + it.each(loaderCases)( + "keeps the require reference for %s", + (_label, source, expected) => { + expect(requireSpecifiers(source)).toEqual(expected); + }, + ); it("keeps only the genuine call in a mixed file", () => { expect( From 3d0f980477bbab9ebf1f0cd1f5c749e5c2c02348 Mon Sep 17 00:00:00 2001 From: hussnainahmedd Date: Tue, 13 Oct 2026 23:35:16 +0000 Subject: [PATCH 04/13] fix(graph): verify loader-parameter bindings positionally and accept more createRequire forms MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The bundle-factory loader-parameter gate now treats an uninitialized var redeclaration as loader-retaining (var hoists without touching the parameter value) instead of dropping the dependency, and applies the existing write-order analysis to assignments: a call at or after an effective write in the same function body is shadowed, earlier calls keep the loader. Also fixed nearestFunctionNode to walk from the node's parent so a write whose RHS is itself a function is attributed to the enclosing body. The createRequire alias gate now accepts the default-import form (import mod from 'node:module'; mod.createRequire(...)) — ESM import bindings are read-only, so the default export's identity is as stable as the namespace form — and file-identity arguments (fileURLToPath(import.meta.url), __filename) via the shared isFileIdentity predicate. Regression tests added for each case; full javascript domain suite green. --- .../javascriptStaticAnalysisCalls.ts | 178 ++++++++++++------ .../javascriptStaticAnalysisHelpers.ts | 2 +- ...criptStaticAnalysisShadowedRequire.test.ts | 28 +++ 3 files changed, 154 insertions(+), 54 deletions(-) diff --git a/src/domain/javascript/javascriptStaticAnalysisCalls.ts b/src/domain/javascript/javascriptStaticAnalysisCalls.ts index a7167cfcb3e..4903fe9daf8 100644 --- a/src/domain/javascript/javascriptStaticAnalysisCalls.ts +++ b/src/domain/javascript/javascriptStaticAnalysisCalls.ts @@ -12,7 +12,7 @@ import { argumentValue, calleeName, endpointArgument as endpointArgumentHelper, - isImportMetaUrl, + isFileIdentity, prefixedArgument, range, rangeForOffsets, @@ -262,25 +262,52 @@ const isShadowedRequireCallee = ( if (binding === undefined) return false; // A bundle factory whose loader parameter is named `require` (e.g. // webpack's `(module, exports, require)`) is the recovered module loader, - // not a shadow. Only a binding that still holds the loader parameter - // qualifies: a hoisted `function`/`class` or initialized variable - // declaration in the same scope replaces the parameter for the whole body, - // so a merged binding is a local shadow, not the loader. A nested binding - // with the same name resolves to a different scope and stays shadowed. - // Assignments keep the current positional behavior; they do not prove the - // loader is gone at this call. + // not a shadow — while the binding still holds the loader parameter. A + // hoisted `function`/`class` or initialized variable declaration in the + // same scope replaces the parameter for the whole body, so a merged binding + // with those is a local shadow, not the loader; an uninitialized `var` + // redeclaration hoists without touching the parameter value, so it stays + // loader-retaining. A nested binding with the same name resolves to a + // different scope and stays shadowed. A write that takes effect before the + // call replaces the loader for that call, so the call is shadowed; a later + // write leaves earlier calls on the loader. if ( module?.requireName === "require" && module.loaderScopeId !== null && binding.scopeId === module.loaderScopeId && - binding.definitions.every( - ({ kind }) => kind === "parameter" || kind === "assignment", - ) + isLoaderRetainingParameterBinding(binding) ) - return false; + return requireWriteShadowsCall( + requireWriteFacts(binding, bindings), + node, + bindings, + ); return !isRetainedRequireLoader(binding, node, bindings); }; +/** + * The loader parameter still holds the loader when every definition is the + * parameter itself, a plain assignment, or an uninitialized `var` + * redeclaration — `var require;` hoists without touching the parameter value. + * A declaration initializer would have replaced the parameter at declaration, + * so the initializer list must hold exactly the assignment expressions. + */ +const isLoaderRetainingParameterBinding = ( + binding: JavaScriptSemanticBindingState, +): boolean => { + const assignmentCount = binding.definitions.filter( + ({ kind }) => kind === "assignment", + ).length; + return ( + binding.definitions.every( + ({ kind, declarationKind }) => + kind === "parameter" || + kind === "assignment" || + (kind === "variable" && declarationKind === "var"), + ) && binding.initializers.length === assignmentCount + ); +}; + const isRetainedRequireLoader = ( binding: JavaScriptSemanticBindingState, node: JavaScriptCallLike, @@ -291,8 +318,11 @@ const isRetainedRequireLoader = ( /** * `const require = createRequire(import.meta.url)` aliases the ESM loader. - * Erased TypeScript syntax around the initializer (e.g. `as NodeRequire`) - * is unwrapped before matching, mirroring the reference-source classifier. + * The argument only selects the resolution base — `createRequire` returns a + * loader for any file identity, so `__filename` and + * `fileURLToPath(import.meta.url)` spellings qualify too. Erased TypeScript + * syntax around the initializer (e.g. `as NodeRequire`) is unwrapped before + * matching, mirroring the reference-source classifier. */ const isDirectCreateRequireBinding = ( binding: JavaScriptSemanticBindingState, @@ -306,7 +336,12 @@ const isDirectCreateRequireBinding = ( if (initializer.projection.length > 0) return false; const init = unwrapJavaScriptExpression(initializer.node).node; if (!t.isCallExpression(init)) return false; - if (!isImportMetaUrl(init.arguments[0])) return false; + const createRequireArgument = init.arguments[0]; + if ( + createRequireArgument === undefined || + !isFileIdentity(unwrapJavaScriptExpression(createRequireArgument).node) + ) + return false; return isCreateRequireCallee( unwrapJavaScriptExpression(init.callee).node, state, @@ -315,10 +350,12 @@ const isDirectCreateRequireBinding = ( /** * Match `createRequire` by its imported origin rather than one call spelling: - * the named import (`createRequire(...)`) and the namespace form - * (`ns.createRequire(...)` where `ns` is `import * as ns from "node:module"`). - * Named ESM exports and module namespace objects are read-only, so their - * identity is stable; default imports can be replaced and stay excluded. + * the named import (`createRequire(...)`), the namespace form + * (`ns.createRequire(...)` where `ns` is `import * as ns from "node:module"`), + * and the default-import form (`mod.createRequire(...)` where `mod` is + * `import mod from "node:module"`). ESM import bindings are read-only, so + * each form's identity is stable — including the default export, which is the + * builtin module object itself. */ const isCreateRequireCallee = ( callee: t.Node, @@ -347,34 +384,38 @@ const isCreateRequireCallee = ( return false; const object = unwrapJavaScriptExpression(callee.object).node; if (!t.isIdentifier(object)) return false; - const objectBinding = resolveSemanticBindingState( - state, - object, - object.name, - ); - const [origin] = objectBinding?.directOrigins ?? []; - return ( - objectBinding?.runtimeImport === true && - origin !== undefined && - (origin.specifier === "node:module" || origin.specifier === "module") && - origin.importedPath.length === 0 + return isNodeModuleObjectBinding( + resolveSemanticBindingState(state, object, object.name), ); } return false; }; +/** + * The builtin module object behind `import * as ns from "node:module"` or + * `import mod from "node:module"`. ESM import bindings are read-only, so the + * object's identity is stable in both forms — including the default export, + * which is the builtin module object itself. + */ +const isNodeModuleObjectBinding = ( + binding: JavaScriptSemanticBindingState | undefined, +): boolean => { + const [origin] = binding?.directOrigins ?? []; + return ( + binding?.runtimeImport === true && + origin !== undefined && + (origin.specifier === "node:module" || origin.specifier === "module") && + (origin.importedPath.length === 0 || + (origin.importedPath.length === 1 && + origin.importedPath[0] === "default")) + ); +}; + /** * An uninitialized top-level `var require` retains Node's wrapper loader, but * a later write replaces it. Only `var` qualifies: a block-scoped `let` * declaration never receives the wrapper loader, so `let require;` followed * by a call cannot be a module load. - * - * Write handling accounts for evaluation order. A write takes effect when its - * assignment expression finishes evaluating, so a call inside the write's own - * right-hand side (e.g. `require = require("./dep.cjs")`) still sees the - * loader. A write inside a function body only shadows calls in that same - * body: source order cannot establish when the body runs relative to an - * outer call. A call at or after an effective write is shadowed. */ const isUninitializedTopLevelVarRequire = ( binding: JavaScriptSemanticBindingState, @@ -383,14 +424,7 @@ const isUninitializedTopLevelVarRequire = ( ): boolean => { const facts = retainedRequireFacts(binding, state); if (!facts.declarationsOk) return false; - const { line, column } = range(node).start; - const callFunction = nearestFunctionNode(node, state); - return !facts.writes.some( - (write) => - (write.endLine < line || - (write.endLine === line && write.endColumn <= column)) && - (write.functionNode === null || write.functionNode === callFunction), - ); + return !requireWriteShadowsCall(facts.writes, node, state); }; interface RequireWriteFact { @@ -450,9 +484,21 @@ const computeRetainedRequireFacts = ( ) && state.scopesById.get(binding.scopeId)?.kind === "program"; if (!declarationsOk) return { declarationsOk, writes: [] }; - // Each assignment pushes exactly one initializer holding the full - // assignment expression, in definition order, so the i-th assignment pairs - // with initializers[i]. + return { declarationsOk: true, writes: requireWriteFacts(binding, state) }; +}; + +/** + * Positional write facts for a binding whose value flows only through plain + * assignments. Each assignment pushes exactly one initializer (the + * right-hand side for plain `=`, the full expression otherwise), in + * definition order, so the i-th assignment pairs with initializers[i]. + * Callers must ensure no declaration initializer is present + * (initializers.length === assignment count) before calling. + */ +const requireWriteFacts = ( + binding: JavaScriptSemanticBindingState, + state: JavaScriptSemanticAnalysisState, +): readonly RequireWriteFact[] => { let assignmentIndex = 0; const writes: RequireWriteFact[] = []; for (const definition of binding.definitions) { @@ -476,19 +522,45 @@ const computeRetainedRequireFacts = ( functionNode: nearestFunctionNode(initializer.node, state), }); } - return { declarationsOk: true, writes }; + return writes; +}; + +/** + * A call at or after an effective write in the same function scope no longer + * sees the loader. A write takes effect when its assignment expression + * finishes evaluating, so a call inside the write's own right-hand side + * (e.g. `require = require("./dep.cjs")`) still sees the loader. A write + * inside a function body only shadows calls in that same body: source order + * cannot establish when the body runs relative to an outer call. + */ +const requireWriteShadowsCall = ( + writes: readonly RequireWriteFact[], + node: JavaScriptCallLike, + state: JavaScriptSemanticAnalysisState, +): boolean => { + const { line, column } = range(node).start; + const callFunction = nearestFunctionNode(node, state); + return writes.some( + (write) => + (write.endLine < line || + (write.endLine === line && write.endColumn <= column)) && + (write.functionNode === null || write.functionNode === callFunction), + ); }; /** - * Nearest enclosing function of a node, or null at program top level. An - * incomplete parent chain falls back to null, which keeps the previous - * positional behavior for that write or call. + * Nearest enclosing function of a node, or null at program top level. The + * walk starts at the node's parent: a write whose right-hand side is itself + * a function (e.g. `require = () => "local"`) still takes effect in the + * enclosing body, not inside the assigned function. An incomplete parent + * chain falls back to null, which keeps the previous positional behavior for + * that write or call. */ const nearestFunctionNode = ( node: t.Node, state: JavaScriptSemanticAnalysisState, ): t.Node | null => { - let current: t.Node | undefined = node; + let current: t.Node | undefined = state.parentsByNode.get(node); while (current !== undefined) { if (t.isFunction(current)) return current; current = state.parentsByNode.get(current); diff --git a/src/domain/javascript/javascriptStaticAnalysisHelpers.ts b/src/domain/javascript/javascriptStaticAnalysisHelpers.ts index 71628bc8ce0..bc4a66c7efd 100644 --- a/src/domain/javascript/javascriptStaticAnalysisHelpers.ts +++ b/src/domain/javascript/javascriptStaticAnalysisHelpers.ts @@ -386,7 +386,7 @@ export const isImportMetaUrl = (node: t.Node | undefined): boolean => { ); }; -const isFileIdentity = (node: t.Node | undefined): boolean => { +export const isFileIdentity = (node: t.Node | undefined): boolean => { if (t.isIdentifier(node, { name: "__filename" }) || isImportMetaUrl(node)) return true; if (!t.isCallExpression(node)) return false; diff --git a/src/domain/javascript/javascriptStaticAnalysisShadowedRequire.test.ts b/src/domain/javascript/javascriptStaticAnalysisShadowedRequire.test.ts index de752bd9437..7a98aa530d4 100644 --- a/src/domain/javascript/javascriptStaticAnalysisShadowedRequire.test.ts +++ b/src/domain/javascript/javascriptStaticAnalysisShadowedRequire.test.ts @@ -55,6 +55,14 @@ const shadowedCases = [ "factory loader parameter replaced by a hoisted function declaration", `globalThis.webpackChunkApp.push([[1], { entry(module, exports, require) { function require() { return "local"; } require("ghost"); } }]);`, ], + [ + "factory loader parameter replaced by an initialized var redeclaration", + `globalThis.webpackChunkApp.push([[1], { entry(module, exports, require) { var require = () => "local"; require("./ghost.cjs"); } }]);`, + ], + [ + "factory loader parameter reassigned before the call", + `globalThis.webpackChunkApp.push([[1], { entry(module, exports, require) { require = () => "local"; require("./ghost.cjs"); } }]);`, + ], [ "destructured missing property of createRequire", `import { createRequire } from "node:module";\nconst { missing: require } = createRequire(import.meta.url);\nrequire?.("./ghost.cjs");`, @@ -127,6 +135,26 @@ const loaderCases = [ `globalThis.webpackChunkApp.push([[1], { entry(module, exports, require) { require("dep"); function inner(require) { return require("./nested.cjs"); } } }]);`, ["dep"], ], + [ + "factory loader parameter with an uninitialized var redeclaration", + `globalThis.webpackChunkApp.push([[1], { entry(module, exports, require) { var require; require("dep"); } }]);`, + ["dep"], + ], + [ + "factory loader parameter call before its reassignment", + `globalThis.webpackChunkApp.push([[1], { entry(module, exports, require) { require("dep"); require = () => "local"; } }]);`, + ["dep"], + ], + [ + "default-import createRequire binding", + `import module from "node:module";\nconst require = module.createRequire(import.meta.url);\nrequire("./dep.cjs");`, + ["./dep.cjs"], + ], + [ + "createRequire with fileURLToPath(import.meta.url)", + `import { createRequire } from "node:module";\nimport { fileURLToPath } from "node:url";\nconst require = createRequire(fileURLToPath(import.meta.url));\nrequire("./dep.cjs");`, + ["./dep.cjs"], + ], [ "namespace createRequire binding", `import * as module from "node:module";\nconst require = module.createRequire(import.meta.url);\nrequire("./dep.cjs");`, From 84ea39f7120d088e40195f4a1beae7d2ab259777 Mon Sep 17 00:00:00 2001 From: hussnainahmedd Date: Wed, 14 Oct 2026 00:21:47 +0000 Subject: [PATCH 05/13] fix(graph): make require-loader checks positional for writes, keys, and rest copies nearestFunctionNode now skips a computed method key, which executes in the enclosing scope rather than inside the method it names, so a key call after a factory write is correctly treated as shadowed. isLoaderRetainingParameterBinding now rejects rest-element copies in referenceInitializers, mirroring the top-level loader path: a rest copy replaces the loader parameter. isDirectCreateRequireBinding and the uninitialized-var loader check now model declaration initializers as positional writes instead of rejecting the binding file-wide: calls before a later write keep their dependency, calls at or after it are shadowed. --- .../javascriptStaticAnalysisCalls.ts | 138 +++++++++++------- ...criptStaticAnalysisShadowedRequire.test.ts | 39 +++++ 2 files changed, 125 insertions(+), 52 deletions(-) diff --git a/src/domain/javascript/javascriptStaticAnalysisCalls.ts b/src/domain/javascript/javascriptStaticAnalysisCalls.ts index 4903fe9daf8..2fa472ccdeb 100644 --- a/src/domain/javascript/javascriptStaticAnalysisCalls.ts +++ b/src/domain/javascript/javascriptStaticAnalysisCalls.ts @@ -36,7 +36,6 @@ import type { import { resolveSemanticBindingState, semanticResolutionBlocked, - semanticStableInitializer, type JavaScriptSemanticAnalysisState, type JavaScriptSemanticBindingState, } from "./javascriptSemanticState.js"; @@ -304,7 +303,15 @@ const isLoaderRetainingParameterBinding = ( kind === "parameter" || kind === "assignment" || (kind === "variable" && declarationKind === "var"), - ) && binding.initializers.length === assignmentCount + ) && + binding.initializers.length === assignmentCount && + // A rest-element declaration (`var {...require} = {}`) copies an object + // or array into the binding through a reference initializer instead of a + // value initializer, so the binding no longer holds the loader + // parameter — mirroring the top-level loader path's rest-copy check. + !binding.referenceInitializers.some( + (initializer) => initializer.copyKind !== undefined, + ) ); }; @@ -313,7 +320,7 @@ const isRetainedRequireLoader = ( node: JavaScriptCallLike, state: JavaScriptSemanticAnalysisState, ): boolean => - isDirectCreateRequireBinding(binding, state) || + isDirectCreateRequireBinding(binding, state, node) || isUninitializedTopLevelVarRequire(binding, state, node); /** @@ -323,18 +330,31 @@ const isRetainedRequireLoader = ( * `fileURLToPath(import.meta.url)` spellings qualify too. Erased TypeScript * syntax around the initializer (e.g. `as NodeRequire`) is unwrapped before * matching, mirroring the reference-source classifier. + * + * The alias holds from its establishing write — the first declaration + * initializer or assignment — so a later write (`require = localLoader`) + * only shadows calls at or after it instead of rejecting the binding for + * the whole file. */ const isDirectCreateRequireBinding = ( binding: JavaScriptSemanticBindingState, state: JavaScriptSemanticAnalysisState, + node: JavaScriptCallLike, ): boolean => { - const initializer = semanticStableInitializer(binding, state); - if (initializer === undefined) return false; + if ( + !binding.definitions.every( + ({ kind }) => kind === "variable" || kind === "assignment", + ) + ) + return false; + const [establishing] = binding.initializers; + if (establishing === undefined) return false; + if (state.conditionalInitializers.has(establishing.node)) return false; // Only a direct alias holds the loader: a destructured property of the // created require (e.g. `const { missing: require } = createRequire(...)`) // keeps a non-empty projection and is not the loader itself. - if (initializer.projection.length > 0) return false; - const init = unwrapJavaScriptExpression(initializer.node).node; + if (establishing.projection.length > 0) return false; + const init = unwrapJavaScriptExpression(establishing.node).node; if (!t.isCallExpression(init)) return false; const createRequireArgument = init.arguments[0]; if ( @@ -342,10 +362,16 @@ const isDirectCreateRequireBinding = ( !isFileIdentity(unwrapJavaScriptExpression(createRequireArgument).node) ) return false; - return isCreateRequireCallee( - unwrapJavaScriptExpression(init.callee).node, - state, - ); + if ( + !isCreateRequireCallee(unwrapJavaScriptExpression(init.callee).node, state) + ) + return false; + // The establishing write installs the loader; only later writes replace + // it, so calls before them still see the loader. + const laterWrites = binding.initializers + .filter((initializer) => initializer !== establishing) + .map((initializer) => requireWriteFact(initializer.node, state)); + return !requireWriteShadowsCall(laterWrites, node, state); }; /** @@ -464,9 +490,6 @@ const computeRetainedRequireFacts = ( binding: JavaScriptSemanticBindingState, state: JavaScriptSemanticAnalysisState, ): RetainedRequireFacts => { - const assignmentCount = binding.definitions.filter( - ({ kind }) => kind === "assignment", - ).length; const declarationsOk = binding.kind === "variable" && binding.definitions.every( @@ -474,8 +497,6 @@ const computeRetainedRequireFacts = ( kind === "assignment" || (kind === "variable" && declarationKind === "var"), ) && - // A declaration initializer means the loader was replaced at declaration. - binding.initializers.length === assignmentCount && // A rest-element declaration (`var {...require} = {}`) copies an object // or array into the binding through a reference initializer instead of a // value initializer, so the binding never holds the wrapper loader. @@ -488,41 +509,41 @@ const computeRetainedRequireFacts = ( }; /** - * Positional write facts for a binding whose value flows only through plain - * assignments. Each assignment pushes exactly one initializer (the - * right-hand side for plain `=`, the full expression otherwise), in - * definition order, so the i-th assignment pairs with initializers[i]. - * Callers must ensure no declaration initializer is present - * (initializers.length === assignment count) before calling. + * Positional write facts for every value write to the binding: plain + * assignments and variable-declaration initializers alike. Each write takes + * effect where its expression finishes evaluating, so a declaration + * initializer is a write at its own position — a call before + * `var require = localLoader` still sees the loader. */ const requireWriteFacts = ( binding: JavaScriptSemanticBindingState, state: JavaScriptSemanticAnalysisState, -): readonly RequireWriteFact[] => { - let assignmentIndex = 0; - const writes: RequireWriteFact[] = []; - for (const definition of binding.definitions) { - if (definition.kind !== "assignment") continue; - const initializer = binding.initializers[assignmentIndex]; - assignmentIndex += 1; - if (initializer === undefined) continue; - // A `for...of`/`for...in` write assigns the loop variable before each - // body execution, so model the write at body entry: the statement end - // would place a call inside the body before the write and invent a false - // dependency. The iterable expression still evaluates before the write. - const writeNode = initializer.node; - const writePosition = - t.isForOfStatement(writeNode) || t.isForInStatement(writeNode) - ? range(writeNode.body).start - : range(writeNode).end; - const { line, column } = writePosition; - writes.push({ - endLine: line, - endColumn: column, - functionNode: nearestFunctionNode(initializer.node, state), - }); - } - return writes; +): readonly RequireWriteFact[] => + binding.initializers.map((initializer) => + requireWriteFact(initializer.node, state), + ); + +/** + * One positional write fact. A `for...of`/`for...in` write assigns the loop + * variable before each body execution, so the write is modeled at body + * entry: the statement end would place a call inside the body before the + * write and invent a false dependency. The iterable expression still + * evaluates before the write. + */ +const requireWriteFact = ( + writeNode: t.Node, + state: JavaScriptSemanticAnalysisState, +): RequireWriteFact => { + const writePosition = + t.isForOfStatement(writeNode) || t.isForInStatement(writeNode) + ? range(writeNode.body).start + : range(writeNode).end; + const { line, column } = writePosition; + return { + endLine: line, + endColumn: column, + functionNode: nearestFunctionNode(writeNode, state), + }; }; /** @@ -552,22 +573,35 @@ const requireWriteShadowsCall = ( * Nearest enclosing function of a node, or null at program top level. The * walk starts at the node's parent: a write whose right-hand side is itself * a function (e.g. `require = () => "local"`) still takes effect in the - * enclosing body, not inside the assigned function. An incomplete parent - * chain falls back to null, which keeps the previous positional behavior for - * that write or call. + * enclosing body, not inside the assigned function. A computed method key + * (e.g. the key of `{ [require("ghost")]() {} }`) executes in the enclosing + * scope, not inside the method it names, so the walk skips that method. An + * incomplete parent chain falls back to null, which keeps the previous + * positional behavior for that write or call. */ const nearestFunctionNode = ( node: t.Node, state: JavaScriptSemanticAnalysisState, ): t.Node | null => { - let current: t.Node | undefined = state.parentsByNode.get(node); + let child: t.Node = node; + let current: t.Node | undefined = state.parentsByNode.get(child); while (current !== undefined) { - if (t.isFunction(current)) return current; + if (t.isFunction(current) && !isComputedKeyOf(child, current)) + return current; + child = current; current = state.parentsByNode.get(current); } return null; }; +/** A computed method key runs in the enclosing scope, not inside the method. */ +const isComputedKeyOf = (child: t.Node, parent: t.Node): boolean => + (t.isObjectMethod(parent) || + t.isClassMethod(parent) || + t.isClassPrivateMethod(parent)) && + parent.computed && + parent.key === child; + const chunkLoadSpecifier = ( node: JavaScriptCallLike, name: string, diff --git a/src/domain/javascript/javascriptStaticAnalysisShadowedRequire.test.ts b/src/domain/javascript/javascriptStaticAnalysisShadowedRequire.test.ts index 7a98aa530d4..3211ed79f93 100644 --- a/src/domain/javascript/javascriptStaticAnalysisShadowedRequire.test.ts +++ b/src/domain/javascript/javascriptStaticAnalysisShadowedRequire.test.ts @@ -59,6 +59,22 @@ const shadowedCases = [ "factory loader parameter replaced by an initialized var redeclaration", `globalThis.webpackChunkApp.push([[1], { entry(module, exports, require) { var require = () => "local"; require("./ghost.cjs"); } }]);`, ], + [ + "computed method key after a factory write", + `globalThis.webpackChunkApp.push([[1], { entry(module, exports, require) { require = () => "local"; const obj = { [require("ghost")]() {} }; } }]);`, + ], + [ + "computed class method key after a factory write", + `globalThis.webpackChunkApp.push([[1], { entry(module, exports, require) { require = () => "local"; class Routes { [require("ghost")]() {} } } }]);`, + ], + [ + "factory rest copy replaces the loader parameter", + `globalThis.webpackChunkApp.push([[1], { entry(module, exports, require) { var {...require} = {}; require?.("ghost"); } }]);`, + ], + [ + "call after a createRequire alias is replaced", + `import { createRequire } from "node:module";\nlet require = createRequire(import.meta.url);\nrequire = localLoader;\nrequire("./ghost.cjs");`, + ], [ "factory loader parameter reassigned before the call", `globalThis.webpackChunkApp.push([[1], { entry(module, exports, require) { require = () => "local"; require("./ghost.cjs"); } }]);`, @@ -110,6 +126,21 @@ const loaderCases = [ `import { createRequire } from "node:module";\nconst require = createRequire(import.meta.url);\nconst value = require("./dep.cjs");`, ["./dep.cjs"], ], + [ + "createRequire alias keeps the call before a later write", + `import { createRequire } from "node:module";\nlet require = createRequire(import.meta.url);\nrequire("./dep.cjs");\nrequire = localLoader;`, + ["./dep.cjs"], + ], + [ + "createRequire alias established by an assignment", + `import { createRequire } from "node:module";\nvar require;\nrequire = createRequire(import.meta.url);\nrequire("./dep.cjs");`, + ["./dep.cjs"], + ], + [ + "top-level call before a var declaration initializer", + `require("./dep.cjs");\nvar require = localLoader;`, + ["./dep.cjs"], + ], [ "createRequire binding behind erased TypeScript syntax", `import { createRequire } from "node:module";\nconst require = createRequire(import.meta.url) as NodeRequire;\nconst value = require("./dep.cjs");`, @@ -218,6 +249,14 @@ describe("lexically shadowed require calls", () => { ).toEqual(["./before.cjs"]); }); + it("keeps the createRequire call before a later write but not the call after it", () => { + expect( + requireSpecifiers( + `import { createRequire } from "node:module";\nlet require = createRequire(import.meta.url);\nrequire("./before.cjs");\nrequire = localLoader;\nrequire("./after.cjs");`, + ), + ).toEqual(["./before.cjs"]); + }); + it("drops the call after a write in the same function body", () => { expect( requireSpecifiers( From 0c1aca461df08d6ebf5e9124181c5e4dd6a2fcf1 Mon Sep 17 00:00:00 2001 From: hussnainahmedd Date: Wed, 14 Oct 2026 01:24:14 +0000 Subject: [PATCH 06/13] fix(graph): require writes to provably run before shadowing a call - A write inside an if branch, loop body, switch case, or try block only shadows a call in the same branch after it; a skipped write (e.g. if (false) require = localLoader) no longer hides a real dependency. - A createRequire alias only preserves calls at or after its establishing write in the same straight-line scope; calls before installation can no longer invent dependencies. - Instance class-field initializers are deferred work scoped to their field, so a never-instantiated class no longer hides a later import edge. Static fields, computed keys, and decorators keep declaration-time behavior. --- .../javascriptStaticAnalysisCalls.ts | 116 ++++++++++++++++-- ...criptStaticAnalysisShadowedRequire.test.ts | 32 +++++ 2 files changed, 139 insertions(+), 9 deletions(-) diff --git a/src/domain/javascript/javascriptStaticAnalysisCalls.ts b/src/domain/javascript/javascriptStaticAnalysisCalls.ts index 2fa472ccdeb..82107321fe8 100644 --- a/src/domain/javascript/javascriptStaticAnalysisCalls.ts +++ b/src/domain/javascript/javascriptStaticAnalysisCalls.ts @@ -367,11 +367,26 @@ const isDirectCreateRequireBinding = ( ) return false; // The establishing write installs the loader; only later writes replace - // it, so calls before them still see the loader. + // it, so calls before them still see the loader. A call that provably runs + // before the establishing write — textually before it in the same + // straight-line scope — runs before the loader exists: it throws or, for + // `require?.(...)`, no-ops, so it cannot be a module load and must not + // invent a dependency. Any other timing (a call in another function, a + // deferred field initializer) is unknowable from source order, so the + // loader is treated as installed, keeping the previous behavior there. const laterWrites = binding.initializers .filter((initializer) => initializer !== establishing) .map((initializer) => requireWriteFact(initializer.node, state)); - return !requireWriteShadowsCall(laterWrites, node, state); + const establishingFact = requireWriteFact(establishing.node, state); + const { line, column } = range(node).start; + const callFunction = nearestFunctionNode(node, state); + const callBeforeEstablishing = + establishingFact.endLine > line || + (establishingFact.endLine === line && establishingFact.endColumn > column); + const installed = !( + callBeforeEstablishing && establishingFact.functionNode === callFunction + ); + return installed && !requireWriteShadowsCall(laterWrites, node, state); }; /** @@ -458,6 +473,8 @@ interface RequireWriteFact { readonly endColumn: number; /** Nearest enclosing function of the write, or null at program top level. */ readonly functionNode: t.Node | null; + /** The write node itself, for conditional-ancestor checks. */ + readonly node: t.Node; } interface RetainedRequireFacts { @@ -543,16 +560,34 @@ const requireWriteFact = ( endLine: line, endColumn: column, functionNode: nearestFunctionNode(writeNode, state), + node: writeNode, }; }; +/** + * A write fact textually precedes a call in the same function scope when the + * call starts at or after the write's end position. A write at program top + * level precedes calls anywhere after it; a write inside a function body + * only precedes calls in that same body, since source order cannot establish + * when the body runs relative to an outer call. + */ +const writeFactPrecedesCall = ( + write: RequireWriteFact, + line: number, + column: number, + callFunction: t.Node | null, +): boolean => + (write.endLine < line || + (write.endLine === line && write.endColumn <= column)) && + (write.functionNode === null || write.functionNode === callFunction); + /** * A call at or after an effective write in the same function scope no longer * sees the loader. A write takes effect when its assignment expression * finishes evaluating, so a call inside the write's own right-hand side * (e.g. `require = require("./dep.cjs")`) still sees the loader. A write - * inside a function body only shadows calls in that same body: source order - * cannot establish when the body runs relative to an outer call. + * that may never execute cannot hide the dependency: the call may still run + * with the loader in place. */ const requireWriteShadowsCall = ( writes: readonly RequireWriteFact[], @@ -563,12 +598,56 @@ const requireWriteShadowsCall = ( const callFunction = nearestFunctionNode(node, state); return writes.some( (write) => - (write.endLine < line || - (write.endLine === line && write.endColumn <= column)) && - (write.functionNode === null || write.functionNode === callFunction), + writeFactPrecedesCall(write, line, column, callFunction) && + writeMustRunBeforeCall(write.node, node, callFunction, state), ); }; +/** + * A write shadows a call only when it must execute before the call. Each + * conditional construct (`if`, loop, `switch` case, `try`) on the path from + * the write up to the shared function scope may skip the write — `if (false) + * require = localLoader` leaves the loader in place — so the write only + * counts when the call sits in the same branch after it. There the write runs + * first on every execution that reaches the call (e.g. a write and call + * together in a loop body). A `for...of`/`for...in` write is modeled at the + * loop statement itself, so the loop head is not treated as conditional for + * its own write. + */ +const writeMustRunBeforeCall = ( + writeNode: t.Node, + node: JavaScriptCallLike, + callFunction: t.Node | null, + state: JavaScriptSemanticAnalysisState, +): boolean => { + const writeBranches = new Map(); + let child: t.Node = writeNode; + let current: t.Node | undefined = state.parentsByNode.get(child); + while (current !== undefined && current !== callFunction) { + if ( + t.isIfStatement(current) || + t.isLoop(current) || + t.isSwitchCase(current) || + t.isTryStatement(current) + ) + writeBranches.set(current, child); + child = current; + current = state.parentsByNode.get(current); + } + if (writeBranches.size === 0) return true; + const callParents = new Map(); + let callChild: t.Node = node; + let callCurrent: t.Node | undefined = state.parentsByNode.get(callChild); + while (callCurrent !== undefined) { + callParents.set(callCurrent, callChild); + callChild = callCurrent; + callCurrent = state.parentsByNode.get(callCurrent); + } + for (const [ancestor, writeChild] of writeBranches) + if (callParents.get(ancestor) !== writeChild) return false; + return true; +}; + /** * Nearest enclosing function of a node, or null at program top level. The * walk starts at the node's parent: a write whose right-hand side is itself @@ -576,8 +655,13 @@ const requireWriteShadowsCall = ( * enclosing body, not inside the assigned function. A computed method key * (e.g. the key of `{ [require("ghost")]() {} }`) executes in the enclosing * scope, not inside the method it names, so the walk skips that method. An - * incomplete parent chain falls back to null, which keeps the previous - * positional behavior for that write or call. + * instance field initializer only runs when the class is instantiated, so a + * write there is deferred work scoped to its field — not a write at class + * declaration — which keeps an uninstantiated class from hiding a later + * dependency. Static field initializers, computed keys, and decorators run + * when the class is defined and keep the previous behavior. An incomplete + * parent chain falls back to null, which keeps the previous positional + * behavior for that write or call. */ const nearestFunctionNode = ( node: t.Node, @@ -588,12 +672,26 @@ const nearestFunctionNode = ( while (current !== undefined) { if (t.isFunction(current) && !isComputedKeyOf(child, current)) return current; + if (isDeferredInstanceFieldValue(child, current)) return current; child = current; current = state.parentsByNode.get(current); } return null; }; +/** + * An instance field value runs at instantiation, not at class declaration. + * Static fields initialize with the class, computed keys evaluate during + * class definition, and decorators run at definition time, so only a + * non-static field's own value counts as deferred. + */ +const isDeferredInstanceFieldValue = (child: t.Node, parent: t.Node): boolean => + (t.isClassProperty(parent) || + t.isClassPrivateProperty(parent) || + t.isClassAccessorProperty(parent)) && + !parent.static && + parent.value === child; + /** A computed method key runs in the enclosing scope, not inside the method. */ const isComputedKeyOf = (child: t.Node, parent: t.Node): boolean => (t.isObjectMethod(parent) || diff --git a/src/domain/javascript/javascriptStaticAnalysisShadowedRequire.test.ts b/src/domain/javascript/javascriptStaticAnalysisShadowedRequire.test.ts index 3211ed79f93..1848e85b642 100644 --- a/src/domain/javascript/javascriptStaticAnalysisShadowedRequire.test.ts +++ b/src/domain/javascript/javascriptStaticAnalysisShadowedRequire.test.ts @@ -103,6 +103,18 @@ const shadowedCases = [ "call after a for-of write", `var require;\nfor (require of [() => "local"]) {}\nrequire("./after.cjs");`, ], + [ + "call before the createRequire establishing write invents no dependency", + `import { createRequire } from "node:module";\nlet require;\nrequire?.("./ghost.cjs");\nrequire = createRequire(import.meta.url);`, + ], + [ + "call after a write in a static class field", + `var require;\nclass C { static initialized = (require = () => "local"); }\nrequire("./ghost.cjs");`, + ], + [ + "call after a write in the same if branch", + `var require;\nif (condition) { require = localLoader; require("./ghost.cjs"); }`, + ], ]; const loaderCases = [ @@ -216,6 +228,26 @@ const loaderCases = [ `var require;\nfor (require of require("./iter.cjs")) { require("./ghost.cjs"); }`, ["./iter.cjs"], ], + [ + "write inside if(false) does not hide the dependency", + `var require;\nif (false) require = localLoader;\nrequire("./dep.cjs");`, + ["./dep.cjs"], + ], + [ + "write inside a never-entered loop does not hide the dependency", + `var require;\nwhile (false) { require = localLoader; }\nrequire("./dep.cjs");`, + ["./dep.cjs"], + ], + [ + "write in a skipped if branch does not hide the dependency", + `var require;\nif (condition) require = localLoader; else require = otherLoader;\nrequire("./dep.cjs");`, + ["./dep.cjs"], + ], + [ + "write in an uninstantiated class field does not hide the dependency", + `var require;\nclass C { value = (require = () => "local"); }\nrequire("./dep.cjs");`, + ["./dep.cjs"], + ], ]; describe("lexically shadowed require calls", () => { From dd6c1a289a199b83c5cbcee6d9b9d1be8c0aa45a Mon Sep 17 00:00:00 2001 From: hussnainahmedd Date: Tue, 13 Oct 2026 22:17:13 -0400 Subject: [PATCH 07/13] fix(graph): instance field writes shadow calls in later fields --- .../javascriptStaticAnalysisCalls.ts | 36 +++++++++++++++---- ...criptStaticAnalysisShadowedRequire.test.ts | 23 ++++++++++++ 2 files changed, 52 insertions(+), 7 deletions(-) diff --git a/src/domain/javascript/javascriptStaticAnalysisCalls.ts b/src/domain/javascript/javascriptStaticAnalysisCalls.ts index 9642abd3096..ba4379d7645 100644 --- a/src/domain/javascript/javascriptStaticAnalysisCalls.ts +++ b/src/domain/javascript/javascriptStaticAnalysisCalls.ts @@ -579,9 +579,10 @@ const requireWriteFact = ( /** * A write fact textually precedes a call in the same function scope when the * call starts at or after the write's end position. A write at program top - * level precedes calls anywhere after it; a write inside a function body - * only precedes calls in that same body, since source order cannot establish - * when the body runs relative to an outer call. + * level precedes calls anywhere after it; a write inside a function body or a + * class's instance initialization only precedes calls in that same scope, + * since source order cannot establish when the scope runs relative to an + * outer call. */ const writeFactPrecedesCall = ( write: RequireWriteFact, @@ -668,9 +669,12 @@ const writeMustRunBeforeCall = ( * (e.g. the key of `{ [require("ghost")]() {} }`) executes in the enclosing * scope, not inside the method it names, so the walk skips that method. An * instance field initializer only runs when the class is instantiated, so a - * write there is deferred work scoped to its field — not a write at class - * declaration — which keeps an uninstantiated class from hiding a later - * dependency. Static field initializers, computed keys, and decorators run + * write there is deferred work scoped to its class's instance initialization — + * not a write at class declaration — which keeps an uninstantiated class from + * hiding a later dependency. Instance field initializers run in textual order + * during construction, so a write in one field and a call in a later field + * share the class as their scope and the write's position establishes the + * order. Static field initializers, computed keys, and decorators run * when the class is defined and keep the previous behavior. An incomplete * parent chain falls back to null, which keeps the previous positional * behavior for that write or call. @@ -684,13 +688,31 @@ const nearestFunctionNode = ( while (current !== undefined) { if (t.isFunction(current) && !isComputedKeyOf(child, current)) return current; - if (isDeferredInstanceFieldValue(child, current)) return current; + if (isDeferredInstanceFieldValue(child, current)) + return enclosingClassNode(current, state) ?? current; child = current; current = state.parentsByNode.get(current); } return null; }; +/** + * The class owning a deferred instance field. A field's parent chain always + * passes through its class body to the class itself; on an incomplete chain + * the caller falls back to the field node, keeping the previous behavior. + */ +const enclosingClassNode = ( + field: t.Node, + state: JavaScriptSemanticAnalysisState, +): t.Node | null => { + let current: t.Node | undefined = state.parentsByNode.get(field); + while (current !== undefined) { + if (t.isClass(current)) return current; + current = state.parentsByNode.get(current); + } + return null; +}; + /** * An instance field value runs at instantiation, not at class declaration. * Static fields initialize with the class, computed keys evaluate during diff --git a/src/domain/javascript/javascriptStaticAnalysisShadowedRequire.test.ts b/src/domain/javascript/javascriptStaticAnalysisShadowedRequire.test.ts index 1848e85b642..2d44d18bb6e 100644 --- a/src/domain/javascript/javascriptStaticAnalysisShadowedRequire.test.ts +++ b/src/domain/javascript/javascriptStaticAnalysisShadowedRequire.test.ts @@ -79,6 +79,14 @@ const shadowedCases = [ "factory loader parameter reassigned before the call", `globalThis.webpackChunkApp.push([[1], { entry(module, exports, require) { require = () => "local"; require("./ghost.cjs"); } }]);`, ], + [ + "write in an earlier instance field shadows a call in a later field", + `var require;\nclass C {\n first = (require = () => "local");\n second = require("./ghost.cjs");\n}\nnew C();`, + ], + [ + "write and call in the same instance field", + `var require;\nclass C {\n first = (require = () => "local", require("./ghost.cjs"));\n}\nnew C();`, + ], [ "destructured missing property of createRequire", `import { createRequire } from "node:module";\nconst { missing: require } = createRequire(import.meta.url);\nrequire?.("./ghost.cjs");`, @@ -248,6 +256,21 @@ const loaderCases = [ `var require;\nclass C { value = (require = () => "local"); }\nrequire("./dep.cjs");`, ["./dep.cjs"], ], + [ + "write in a later instance field does not hide an earlier field's call", + `var require;\nclass C {\n first = require("./dep.cjs");\n second = (require = () => "local");\n}\nnew C();`, + ["./dep.cjs"], + ], + [ + "instance field write does not hide a call in another class's field", + `var require;\nclass A { first = (require = () => "local"); }\nclass B { second = require("./dep.cjs"); }\nnew A();\nnew B();`, + ["./dep.cjs"], + ], + [ + "instance field write does not hide a call in a class method", + `var require;\nclass C {\n first = (require = () => "local");\n m() { return require("./dep.cjs"); }\n}\nnew C().m();`, + ["./dep.cjs"], + ], ]; describe("lexically shadowed require calls", () => { From 9a09003278ce3084869c821cc595ed684119b6d4 Mon Sep 17 00:00:00 2001 From: hussnainahmedd Date: Wed, 14 Oct 2026 03:13:19 +0000 Subject: [PATCH 08/13] fix(graph): restore loader on later createRequire writes The direct-alias gate only considered the first initializer, so `let require = () => "local"; require = createRequire(import.meta.url);` dropped the real dependency. The establishing write is now the last createRequire-alias write that takes effect before the call; writes after it still shadow, writes before it were already overwritten. Regression tests: restored-loader keeps the import, re-shadowed stays dropped, calls on either side of a restore both keep theirs. --- .../javascriptStaticAnalysisCalls.ts | 84 ++++++++++++------- ...criptStaticAnalysisShadowedRequire.test.ts | 14 ++++ 2 files changed, 69 insertions(+), 29 deletions(-) diff --git a/src/domain/javascript/javascriptStaticAnalysisCalls.ts b/src/domain/javascript/javascriptStaticAnalysisCalls.ts index afd61834a09..74d36ebb7c0 100644 --- a/src/domain/javascript/javascriptStaticAnalysisCalls.ts +++ b/src/domain/javascript/javascriptStaticAnalysisCalls.ts @@ -343,10 +343,13 @@ const isRetainedRequireLoader = ( * syntax around the initializer (e.g. `as NodeRequire`) is unwrapped before * matching, mirroring the reference-source classifier. * - * The alias holds from its establishing write — the first declaration - * initializer or assignment — so a later write (`require = localLoader`) - * only shadows calls at or after it instead of rejecting the binding for - * the whole file. + * The alias holds from its establishing write, so a later write + * (`require = localLoader`) only shadows calls at or after it instead of + * rejecting the binding for the whole file. A later write can also restore + * the loader (`require = createRequire(import.meta.url)` after + * `let require = () => "local"`), so the establishing write is the last + * direct-alias write that takes effect before the call — not just the first + * initializer. */ const isDirectCreateRequireBinding = ( binding: JavaScriptSemanticBindingState, @@ -359,39 +362,62 @@ const isDirectCreateRequireBinding = ( ) ) return false; - const [establishing] = binding.initializers; + const { line, column } = range(node).start; + const callFunction = nearestFunctionNode(node, state); + const isDirectAliasWrite = ( + initializer: (typeof binding.initializers)[number], + ): boolean => { + if (state.conditionalInitializers.has(initializer.node)) return false; + // Only a direct alias holds the loader: a destructured property of the + // created require (e.g. `const { missing: require } = createRequire(...)`) + // keeps a non-empty projection and is not the loader itself. + if (initializer.projection.length > 0) return false; + const init = unwrapJavaScriptExpression(initializer.node).node; + if (!t.isCallExpression(init)) return false; + const createRequireArgument = init.arguments[0]; + if ( + createRequireArgument === undefined || + !isFileIdentity(unwrapJavaScriptExpression(createRequireArgument).node) + ) + return false; + return isCreateRequireCallee( + unwrapJavaScriptExpression(init.callee).node, + state, + ); + }; + // A write provably after the call — textually after it in the same + // straight-line scope — cannot have taken effect yet, so it can neither + // establish nor shadow the loader for this call. + const callBeforeWrite = (writeNode: t.Node): boolean => { + const fact = requireWriteFact(writeNode, state); + return ( + (fact.endLine > line || + (fact.endLine === line && fact.endColumn > column)) && + fact.functionNode === callFunction + ); + }; + const establishing = binding.initializers + .filter( + (initializer) => + isDirectAliasWrite(initializer) && !callBeforeWrite(initializer.node), + ) + .at(-1); if (establishing === undefined) return false; - if (state.conditionalInitializers.has(establishing.node)) return false; - // Only a direct alias holds the loader: a destructured property of the - // created require (e.g. `const { missing: require } = createRequire(...)`) - // keeps a non-empty projection and is not the loader itself. - if (establishing.projection.length > 0) return false; - const init = unwrapJavaScriptExpression(establishing.node).node; - if (!t.isCallExpression(init)) return false; - const createRequireArgument = init.arguments[0]; - if ( - createRequireArgument === undefined || - !isFileIdentity(unwrapJavaScriptExpression(createRequireArgument).node) - ) - return false; - if ( - !isCreateRequireCallee(unwrapJavaScriptExpression(init.callee).node, state) - ) - return false; - // The establishing write installs the loader; only later writes replace + // The establishing write installs the loader; only writes after it replace // it, so calls before them still see the loader. A call that provably runs // before the establishing write — textually before it in the same // straight-line scope — runs before the loader exists: it throws or, for // `require?.(...)`, no-ops, so it cannot be a module load and must not - // invent a dependency. Any other timing (a call in another function, a - // deferred field initializer) is unknowable from source order, so the - // loader is treated as installed, keeping the previous behavior there. + // invent a dependency. The establishing write is selected above to exclude + // exactly those provably-later writes, so this stays true by construction. + // Any other timing (a call in another function, a deferred field + // initializer) is unknowable from source order, so the loader is treated + // as installed, keeping the previous behavior there. + const establishingIndex = binding.initializers.indexOf(establishing); const laterWrites = binding.initializers - .filter((initializer) => initializer !== establishing) + .slice(establishingIndex + 1) .map((initializer) => requireWriteFact(initializer.node, state)); const establishingFact = requireWriteFact(establishing.node, state); - const { line, column } = range(node).start; - const callFunction = nearestFunctionNode(node, state); const callBeforeEstablishing = establishingFact.endLine > line || (establishingFact.endLine === line && establishingFact.endColumn > column); diff --git a/src/domain/javascript/javascriptStaticAnalysisShadowedRequire.test.ts b/src/domain/javascript/javascriptStaticAnalysisShadowedRequire.test.ts index 2d44d18bb6e..8cb893db84c 100644 --- a/src/domain/javascript/javascriptStaticAnalysisShadowedRequire.test.ts +++ b/src/domain/javascript/javascriptStaticAnalysisShadowedRequire.test.ts @@ -123,6 +123,10 @@ const shadowedCases = [ "call after a write in the same if branch", `var require;\nif (condition) { require = localLoader; require("./ghost.cjs"); }`, ], + [ + "loader restored by a later createRequire write then shadowed again", + `import { createRequire } from "node:module";\nlet require = () => "local";\nrequire = createRequire(import.meta.url);\nrequire = otherLoader;\nrequire("./ghost.cjs");`, + ], ]; const loaderCases = [ @@ -151,6 +155,16 @@ const loaderCases = [ `import { createRequire } from "node:module";\nlet require = createRequire(import.meta.url);\nrequire("./dep.cjs");\nrequire = localLoader;`, ["./dep.cjs"], ], + [ + "loader restored by a later createRequire write after a local value", + `import { createRequire } from "node:module";\nlet require = () => "local";\nrequire = createRequire(import.meta.url);\nrequire("./dep.cjs");`, + ["./dep.cjs"], + ], + [ + "calls on either side of a restored createRequire write", + `import { createRequire } from "node:module";\nlet require = createRequire(import.meta.url);\nrequire("./before.cjs");\nrequire = () => "local";\nrequire = createRequire(import.meta.url);\nrequire("./after.cjs");`, + ["./before.cjs", "./after.cjs"], + ], [ "createRequire alias established by an assignment", `import { createRequire } from "node:module";\nvar require;\nrequire = createRequire(import.meta.url);\nrequire("./dep.cjs");`, From a6e65557848d4d81c63902e7d1a34ae3202d0684 Mon Sep 17 00:00:00 2001 From: hussnainahmedd Date: Wed, 14 Oct 2026 04:10:44 +0000 Subject: [PATCH 09/13] fix(graph): guard createRequire establishing writes by must-run and position A conditional createRequire assignment (e.g. inside an if) could be selected as the establishing write even though it may never execute, inventing an import edge for a local call. The establishing write must now provably run before the call (branch alignment), covering conditional assignments that conditionalInitializers does not mark. binding.initializers is also not in execution order (declaration initializers precede assignment writes), so later replacement writes are now found by execution position instead of array index. Regression tests: conditional createRequire write invents no dependency; var declaration initializer after the createRequire write shadows it. --- .../javascriptStaticAnalysisCalls.ts | 26 +++++++++++++++---- ...criptStaticAnalysisShadowedRequire.test.ts | 8 ++++++ 2 files changed, 29 insertions(+), 5 deletions(-) diff --git a/src/domain/javascript/javascriptStaticAnalysisCalls.ts b/src/domain/javascript/javascriptStaticAnalysisCalls.ts index 74d36ebb7c0..3dfe5354142 100644 --- a/src/domain/javascript/javascriptStaticAnalysisCalls.ts +++ b/src/domain/javascript/javascriptStaticAnalysisCalls.ts @@ -399,7 +399,14 @@ const isDirectCreateRequireBinding = ( const establishing = binding.initializers .filter( (initializer) => - isDirectAliasWrite(initializer) && !callBeforeWrite(initializer.node), + isDirectAliasWrite(initializer) && + !callBeforeWrite(initializer.node) && + // A conditional write (e.g. `if (x) require = createRequire(u)`) + // may never run, so it cannot install the loader: the write must + // provably execute before the call. `conditionalInitializers` + // covers conditional `var` declarations, but not conditional + // assignments, so this branch-alignment check handles both. + writeMustRunBeforeCall(initializer.node, node, callFunction, state), ) .at(-1); if (establishing === undefined) return false; @@ -413,11 +420,20 @@ const isDirectCreateRequireBinding = ( // Any other timing (a call in another function, a deferred field // initializer) is unknowable from source order, so the loader is treated // as installed, keeping the previous behavior there. - const establishingIndex = binding.initializers.indexOf(establishing); - const laterWrites = binding.initializers - .slice(establishingIndex + 1) - .map((initializer) => requireWriteFact(initializer.node, state)); const establishingFact = requireWriteFact(establishing.node, state); + // `initializers` is not in execution order — declaration initializers are + // collected before assignment writes — so a later replacement cannot be + // found by array index. Compare by write position instead: any write that + // takes effect after the establishing write replaces the loader. + const laterWrites = binding.initializers + .map((initializer) => requireWriteFact(initializer.node, state)) + .filter( + (write) => + write.node !== establishing.node && + (write.endLine > establishingFact.endLine || + (write.endLine === establishingFact.endLine && + write.endColumn > establishingFact.endColumn)), + ); const callBeforeEstablishing = establishingFact.endLine > line || (establishingFact.endLine === line && establishingFact.endColumn > column); diff --git a/src/domain/javascript/javascriptStaticAnalysisShadowedRequire.test.ts b/src/domain/javascript/javascriptStaticAnalysisShadowedRequire.test.ts index 8cb893db84c..645a492316b 100644 --- a/src/domain/javascript/javascriptStaticAnalysisShadowedRequire.test.ts +++ b/src/domain/javascript/javascriptStaticAnalysisShadowedRequire.test.ts @@ -127,6 +127,14 @@ const shadowedCases = [ "loader restored by a later createRequire write then shadowed again", `import { createRequire } from "node:module";\nlet require = () => "local";\nrequire = createRequire(import.meta.url);\nrequire = otherLoader;\nrequire("./ghost.cjs");`, ], + [ + "conditional createRequire write invents no dependency", + `import { createRequire } from "node:module";\nlet require = () => "local";\nif (condition) require = createRequire(import.meta.url);\nrequire("./ghost.cjs");`, + ], + [ + "var declaration initializer after the createRequire write shadows it", + `import { createRequire } from "node:module";\nrequire = createRequire(import.meta.url);\nvar require = () => "local";\nrequire("./ghost.cjs");`, + ], ]; const loaderCases = [ From 5b495246e70b21c44610a95550eb2d8191271b21 Mon Sep 17 00:00:00 2001 From: Hussnain Ahmad Date: Wed, 14 Oct 2026 01:17:44 -0400 Subject: [PATCH 10/13] fix(graph): honor for-initializer and class-evaluation order in shadowed-require analysis --- .../javascriptStaticAnalysisCalls.ts | 128 ++++++++++++++++-- ...criptStaticAnalysisShadowedRequire.test.ts | 32 +++++ 2 files changed, 147 insertions(+), 13 deletions(-) diff --git a/src/domain/javascript/javascriptStaticAnalysisCalls.ts b/src/domain/javascript/javascriptStaticAnalysisCalls.ts index 3dfe5354142..cbbe7d312f6 100644 --- a/src/domain/javascript/javascriptStaticAnalysisCalls.ts +++ b/src/domain/javascript/javascriptStaticAnalysisCalls.ts @@ -625,16 +625,114 @@ const requireWriteFact = ( * class's instance initialization only precedes calls in that same scope, * since source order cannot establish when the scope runs relative to an * outer call. + * + * Class definition evaluation does not follow source order: computed method + * keys evaluate before static field initializers and static blocks run + * (verified against the runtime), so a static-field write cannot shadow a + * call in a computed key of the same class, while a computed-key write does + * shadow a later static initializer. Within one evaluation phase, source + * order still decides. */ const writeFactPrecedesCall = ( write: RequireWriteFact, - line: number, - column: number, + node: JavaScriptCallLike, callFunction: t.Node | null, -): boolean => - (write.endLine < line || - (write.endLine === line && write.endColumn <= column)) && - (write.functionNode === null || write.functionNode === callFunction); + state: JavaScriptSemanticAnalysisState, +): boolean => { + const scopeOk = + write.functionNode === null || write.functionNode === callFunction; + const classOrder = classDefinitionPrecedence(write.node, node, state); + if (classOrder !== null) return scopeOk && classOrder; + const { line, column } = range(node).start; + return ( + scopeOk && + (write.endLine < line || + (write.endLine === line && write.endColumn <= column)) + ); +}; + +/** + * Class-definition evaluation slots whose relative order contradicts source + * order. Computed method keys evaluate while the class is being defined, + * before static field initializers and static blocks run. Instance field + * initializers run at instantiation instead, so they keep the existing + * deferred handling and never report a slot here. + */ +type ClassDefinitionSlot = "computed-key" | "static-initializer"; + +interface ClassDefinitionPosition { + readonly classNode: t.Node; + readonly slot: ClassDefinitionSlot; +} + +/** + * The class-definition evaluation slot of a node that runs while its class is + * being defined, or null when the node does not run at class-definition time + * (instance field values, method bodies, nested functions) or the walk cannot + * classify it. Only the two slots whose relative order contradicts source + * order are distinguished; anything else falls back to positional order. + */ +const classDefinitionPosition = ( + node: t.Node, + state: JavaScriptSemanticAnalysisState, +): ClassDefinitionPosition | null => { + let slot: ClassDefinitionSlot | null = null; + let child: t.Node = node; + let current: t.Node | undefined = state.parentsByNode.get(child); + while (current !== undefined) { + if ( + (t.isClassMethod(current) || t.isClassPrivateMethod(current)) && + current.computed && + current.key === child + ) + slot = "computed-key"; + else if ( + ((t.isClassProperty(current) || + t.isClassPrivateProperty(current) || + t.isClassAccessorProperty(current)) && + current.static && + current.value === child) || + t.isStaticBlock(current) + ) + slot = "static-initializer"; + if (t.isClass(current)) + return slot === null ? null : { classNode: current, slot }; + // A computed method key executes in the enclosing scope, so the walk + // passes through the method it names; any other function boundary (or an + // instance field value, which runs at instantiation) means the node does + // not run at class-definition time. + if (t.isFunction(current) && !isComputedKeyOf(child, current)) return null; + if (isDeferredInstanceFieldValue(child, current)) return null; + child = current; + current = state.parentsByNode.get(current); + } + return null; +}; + +/** + * Whether a write shadows a call across class-definition evaluation slots. + * Returns null when source order still decides: different classes, the same + * evaluation phase, or either side not running at class-definition time. + */ +const classDefinitionPrecedence = ( + writeNode: t.Node, + node: JavaScriptCallLike, + state: JavaScriptSemanticAnalysisState, +): boolean | null => { + const writePosition = classDefinitionPosition(writeNode, state); + const callPosition = classDefinitionPosition(node, state); + if ( + writePosition === null || + callPosition === null || + writePosition.classNode !== callPosition.classNode || + writePosition.slot === callPosition.slot + ) + return null; + return ( + writePosition.slot === "computed-key" && + callPosition.slot === "static-initializer" + ); +}; /** * A call at or after an effective write in the same function scope no longer @@ -649,11 +747,10 @@ const requireWriteShadowsCall = ( node: JavaScriptCallLike, state: JavaScriptSemanticAnalysisState, ): boolean => { - const { line, column } = range(node).start; const callFunction = nearestFunctionNode(node, state); return writes.some( (write) => - writeFactPrecedesCall(write, line, column, callFunction) && + writeFactPrecedesCall(write, node, callFunction, state) && writeMustRunBeforeCall(write.node, node, callFunction, state), ); }; @@ -667,7 +764,9 @@ const requireWriteShadowsCall = ( * first on every execution that reaches the call (e.g. a write and call * together in a loop body). A `for...of`/`for...in` write is modeled at the * loop statement itself, so the loop head is not treated as conditional for - * its own write. + * its own write. A classic `for` initializer runs exactly once before the + * test and body, so the loop is not conditional for a write in its own head + * either — `for (var require = local; false;) {}` still replaces the loader. */ const writeMustRunBeforeCall = ( writeNode: t.Node, @@ -680,10 +779,13 @@ const writeMustRunBeforeCall = ( let current: t.Node | undefined = state.parentsByNode.get(child); while (current !== undefined && current !== callFunction) { if ( - t.isIfStatement(current) || - t.isLoop(current) || - t.isSwitchCase(current) || - t.isTryStatement(current) + (t.isIfStatement(current) || + t.isLoop(current) || + t.isSwitchCase(current) || + t.isTryStatement(current)) && + // A `for` initializer always runs once before the loop, so a write in + // the loop head is not work the loop may skip. + !(t.isForStatement(current) && current.init === child) ) writeBranches.set(current, child); child = current; diff --git a/src/domain/javascript/javascriptStaticAnalysisShadowedRequire.test.ts b/src/domain/javascript/javascriptStaticAnalysisShadowedRequire.test.ts index 645a492316b..5b8d16e4a0f 100644 --- a/src/domain/javascript/javascriptStaticAnalysisShadowedRequire.test.ts +++ b/src/domain/javascript/javascriptStaticAnalysisShadowedRequire.test.ts @@ -135,6 +135,18 @@ const shadowedCases = [ "var declaration initializer after the createRequire write shadows it", `import { createRequire } from "node:module";\nrequire = createRequire(import.meta.url);\nvar require = () => "local";\nrequire("./ghost.cjs");`, ], + [ + "write in a classic for initializer shadows a later call", + `for (var require = () => "local"; false;) {} require("./ghost.cjs");`, + ], + [ + "write in a classic for initializer shadows a call in the loop body", + `for (var require = () => "local"; false;) { require("./ghost.cjs"); }`, + ], + [ + "computed key write shadows a call in a later static field", + `var require;\nclass C { [(require = () => "local", "k")]() {} static value = require("./ghost.cjs"); }`, + ], ]; const loaderCases = [ @@ -288,6 +300,26 @@ const loaderCases = [ `var require;\nclass A { first = (require = () => "local"); }\nclass B { second = require("./dep.cjs"); }\nnew A();\nnew B();`, ["./dep.cjs"], ], + [ + "write in a classic for update does not hide a later call", + `var require;\nfor (;false; require = () => "local") {} require("./ghost.cjs");`, + ["./ghost.cjs"], + ], + [ + "computed class method key runs before a static field write", + `var require;\nclass C { static value = (require = () => "local"); [require("./real.cjs")]() {} }`, + ["./real.cjs"], + ], + [ + "computed class method key runs before a static field write in either order", + `var require;\nclass C { [require("./real.cjs")]() {} static value = (require = () => "local"); }`, + ["./real.cjs"], + ], + [ + "computed class method key runs before a static block write", + `var require;\nclass C { [require("./real.cjs")]() {} static { require = () => "local"; } }`, + ["./real.cjs"], + ], [ "instance field write does not hide a call in a class method", `var require;\nclass C {\n first = (require = () => "local");\n m() { return require("./dep.cjs"); }\n}\nnew C().m();`, From 68cb6c697f55367c532104c9c655d8810910f8f2 Mon Sep 17 00:00:00 2001 From: hussnainahmedd Date: Wed, 14 Oct 2026 02:11:57 -0400 Subject: [PATCH 11/13] fix(graph): honor do-while body execution in shadowed-require analysis A do...while body always runs once, so a write inside it provably runs before any call after the loop. writeMustRunBeforeCall no longer treats the loop as a conditional construct for a write in its own body, matching the existing for-initializer exception. Other loops (while, classic for bodies, for...of/for...in) may still skip their body and stay conditional. --- .../javascriptStaticAnalysisCalls.ts | 14 ++++++++++-- ...criptStaticAnalysisShadowedRequire.test.ts | 22 +++++++++++++++++++ 2 files changed, 34 insertions(+), 2 deletions(-) diff --git a/src/domain/javascript/javascriptStaticAnalysisCalls.ts b/src/domain/javascript/javascriptStaticAnalysisCalls.ts index cbbe7d312f6..23352efc105 100644 --- a/src/domain/javascript/javascriptStaticAnalysisCalls.ts +++ b/src/domain/javascript/javascriptStaticAnalysisCalls.ts @@ -767,6 +767,11 @@ const requireWriteShadowsCall = ( * its own write. A classic `for` initializer runs exactly once before the * test and body, so the loop is not conditional for a write in its own head * either — `for (var require = local; false;) {}` still replaces the loader. + * A `do...while` body always runs once, so the loop is not conditional for a + * write in its own body either — `do { require = local; } while (false); + * require("./ghost.cjs")` still replaces the loader. Other loops (`while`, + * classic `for` bodies, `for...of`/`for...in`) may skip their body entirely, + * so they stay conditional. */ const writeMustRunBeforeCall = ( writeNode: t.Node, @@ -784,8 +789,13 @@ const writeMustRunBeforeCall = ( t.isSwitchCase(current) || t.isTryStatement(current)) && // A `for` initializer always runs once before the loop, so a write in - // the loop head is not work the loop may skip. - !(t.isForStatement(current) && current.init === child) + // the loop head is not work the loop may skip. A `do...while` body + // always runs once too, so a write in the body is not work the loop + // may skip either — the write provably runs before any call after the + // loop. Other loops may skip their body entirely, so they stay + // conditional. + !(t.isForStatement(current) && current.init === child) && + !(t.isDoWhileStatement(current) && current.body === child) ) writeBranches.set(current, child); child = current; diff --git a/src/domain/javascript/javascriptStaticAnalysisShadowedRequire.test.ts b/src/domain/javascript/javascriptStaticAnalysisShadowedRequire.test.ts index 5b8d16e4a0f..e61f12d8c23 100644 --- a/src/domain/javascript/javascriptStaticAnalysisShadowedRequire.test.ts +++ b/src/domain/javascript/javascriptStaticAnalysisShadowedRequire.test.ts @@ -143,6 +143,18 @@ const shadowedCases = [ "write in a classic for initializer shadows a call in the loop body", `for (var require = () => "local"; false;) { require("./ghost.cjs"); }`, ], + [ + "write in a do-while body shadows a later call", + `var require;\ndo { require = () => "local"; } while (false);\nrequire("./ghost.cjs");`, + ], + [ + "write in a looping do-while body shadows a later call", + `var require;\nlet i = 0;\ndo { require = () => "local"; i += 1; } while (i < 1);\nrequire("./ghost.cjs");`, + ], + [ + "write in a do-while body shadows a call in the loop test", + `var require;\ndo { require = () => "local"; } while (require("./ghost.cjs"), false);`, + ], [ "computed key write shadows a call in a later static field", `var require;\nclass C { [(require = () => "local", "k")]() {} static value = require("./ghost.cjs"); }`, @@ -280,6 +292,16 @@ const loaderCases = [ `var require;\nwhile (false) { require = localLoader; }\nrequire("./dep.cjs");`, ["./dep.cjs"], ], + [ + "write in a do-while body inside a skipped if does not hide the dependency", + `var require;\nif (false) { do { require = localLoader; } while (false); }\nrequire("./dep.cjs");`, + ["./dep.cjs"], + ], + [ + "call before a do-while write keeps the loader", + `var require;\nrequire("./before.cjs");\ndo { require = localLoader; } while (false);`, + ["./before.cjs"], + ], [ "write in a skipped if branch does not hide the dependency", `var require;\nif (condition) require = localLoader; else require = otherLoader;\nrequire("./dep.cjs");`, From f3a44634a52ff3e8f1ab6f68cb8a5352ec791413 Mon Sep 17 00:00:00 2001 From: Hussnain Ahmad Date: Wed, 14 Oct 2026 07:11:54 +0000 Subject: [PATCH 12/13] fix(graph): honor createRequire alias established inside a branch --- .../javascriptStaticAnalysisCalls.ts | 14 ++++++++----- ...criptStaticAnalysisShadowedRequire.test.ts | 20 +++++++++++++++++++ 2 files changed, 29 insertions(+), 5 deletions(-) diff --git a/src/domain/javascript/javascriptStaticAnalysisCalls.ts b/src/domain/javascript/javascriptStaticAnalysisCalls.ts index 23352efc105..68f6907c93c 100644 --- a/src/domain/javascript/javascriptStaticAnalysisCalls.ts +++ b/src/domain/javascript/javascriptStaticAnalysisCalls.ts @@ -349,7 +349,10 @@ const isRetainedRequireLoader = ( * the loader (`require = createRequire(import.meta.url)` after * `let require = () => "local"`), so the establishing write is the last * direct-alias write that takes effect before the call — not just the first - * initializer. + * initializer. A `createRequire` initializer inside a conditional branch + * (`if (x) { var require = createRequire(u); require("./d.cjs"); }`) + * establishes the alias too, but only when the call sits in that same branch + * — any execution reaching the call provably runs the initializer first. */ const isDirectCreateRequireBinding = ( binding: JavaScriptSemanticBindingState, @@ -367,7 +370,6 @@ const isDirectCreateRequireBinding = ( const isDirectAliasWrite = ( initializer: (typeof binding.initializers)[number], ): boolean => { - if (state.conditionalInitializers.has(initializer.node)) return false; // Only a direct alias holds the loader: a destructured property of the // created require (e.g. `const { missing: require } = createRequire(...)`) // keeps a non-empty projection and is not the loader itself. @@ -403,9 +405,11 @@ const isDirectCreateRequireBinding = ( !callBeforeWrite(initializer.node) && // A conditional write (e.g. `if (x) require = createRequire(u)`) // may never run, so it cannot install the loader: the write must - // provably execute before the call. `conditionalInitializers` - // covers conditional `var` declarations, but not conditional - // assignments, so this branch-alignment check handles both. + // provably execute before the call. The branch-alignment check + // covers conditional `var` declarations as well as conditional + // assignments — an initializer inside a branch only counts when the + // call sits in that same branch, where every execution reaching the + // call runs the initializer first. writeMustRunBeforeCall(initializer.node, node, callFunction, state), ) .at(-1); diff --git a/src/domain/javascript/javascriptStaticAnalysisShadowedRequire.test.ts b/src/domain/javascript/javascriptStaticAnalysisShadowedRequire.test.ts index e61f12d8c23..b48ddfdcac5 100644 --- a/src/domain/javascript/javascriptStaticAnalysisShadowedRequire.test.ts +++ b/src/domain/javascript/javascriptStaticAnalysisShadowedRequire.test.ts @@ -202,6 +202,26 @@ const loaderCases = [ `import { createRequire } from "node:module";\nvar require;\nrequire = createRequire(import.meta.url);\nrequire("./dep.cjs");`, ["./dep.cjs"], ], + [ + "conditional createRequire initializer cannot hide the loader for a call outside the branch", + `import { createRequire } from "node:module";\nif (condition) { var require = createRequire(import.meta.url); }\nrequire("./dep.cjs");`, + ["./dep.cjs"], + ], + [ + "createRequire initializer in the other branch cannot hide the loader", + `import { createRequire } from "node:module";\nif (condition) { require("./dep.cjs"); } else { var require = createRequire(import.meta.url); }`, + ["./dep.cjs"], + ], + [ + "createRequire initializer with the call in the same branch", + `import { createRequire } from "node:module";\nif (condition) { var require = createRequire(import.meta.url); require("./dep.cjs"); }`, + ["./dep.cjs"], + ], + [ + "conditional createRequire assignment with the call in the same branch", + `import { createRequire } from "node:module";\nlet require;\nif (condition) { require = createRequire(import.meta.url); require("./dep.cjs"); }`, + ["./dep.cjs"], + ], [ "top-level call before a var declaration initializer", `require("./dep.cjs");\nvar require = localLoader;`, From 6b9f061f47a4135799f59a327b7e4d4c0acd03d4 Mon Sep 17 00:00:00 2001 From: Hussnain Ahmad Date: Wed, 14 Oct 2026 04:09:48 -0400 Subject: [PATCH 13/13] fix(graph): honor loop-condition writes in shadowed-require analysis --- .../javascriptStaticAnalysisCalls.ts | 37 +++++++++++++------ ...criptStaticAnalysisShadowedRequire.test.ts | 16 ++++++++ 2 files changed, 42 insertions(+), 11 deletions(-) diff --git a/src/domain/javascript/javascriptStaticAnalysisCalls.ts b/src/domain/javascript/javascriptStaticAnalysisCalls.ts index 68f6907c93c..854ef14709d 100644 --- a/src/domain/javascript/javascriptStaticAnalysisCalls.ts +++ b/src/domain/javascript/javascriptStaticAnalysisCalls.ts @@ -773,9 +773,14 @@ const requireWriteShadowsCall = ( * either — `for (var require = local; false;) {}` still replaces the loader. * A `do...while` body always runs once, so the loop is not conditional for a * write in its own body either — `do { require = local; } while (false); - * require("./ghost.cjs")` still replaces the loader. Other loops (`while`, - * classic `for` bodies, `for...of`/`for...in`) may skip their body entirely, - * so they stay conditional. + * require("./ghost.cjs")` still replaces the loader. Loop conditions always + * evaluate at least once when the loop statement runs — a `while` test, a + * classic `for` test after its initializer, a `do...while` test after its + * body — unlike loop bodies which may never run, so the loop is not + * conditional for a write in its own condition either: `while + * ((require = local, false)) {}); require("./ghost.cjs")` still replaces + * the loader. A classic `for` update only runs after a body iteration, so it + * stays conditional, as do `while` and `for...of`/`for...in` bodies. */ const writeMustRunBeforeCall = ( writeNode: t.Node, @@ -792,14 +797,24 @@ const writeMustRunBeforeCall = ( t.isLoop(current) || t.isSwitchCase(current) || t.isTryStatement(current)) && - // A `for` initializer always runs once before the loop, so a write in - // the loop head is not work the loop may skip. A `do...while` body - // always runs once too, so a write in the body is not work the loop - // may skip either — the write provably runs before any call after the - // loop. Other loops may skip their body entirely, so they stay - // conditional. - !(t.isForStatement(current) && current.init === child) && - !(t.isDoWhileStatement(current) && current.body === child) + // A `for` initializer always runs once before the loop, and a `for` + // test always evaluates at least once after the initializer, so a + // write in either is not work the loop may skip. A `do...while` body + // always runs once and its test always evaluates after it, so a write + // in either is not work the loop may skip either. A `while` test + // always evaluates at least once too. The write provably runs before + // any call after the loop. Other loops may skip their body entirely + // (and a classic `for` update only runs after a body iteration), so + // they stay conditional. + !( + t.isForStatement(current) && + (current.init === child || current.test === child) + ) && + !( + t.isDoWhileStatement(current) && + (current.body === child || current.test === child) + ) && + !(t.isWhileStatement(current) && current.test === child) ) writeBranches.set(current, child); child = current; diff --git a/src/domain/javascript/javascriptStaticAnalysisShadowedRequire.test.ts b/src/domain/javascript/javascriptStaticAnalysisShadowedRequire.test.ts index b48ddfdcac5..b88dfcbddfe 100644 --- a/src/domain/javascript/javascriptStaticAnalysisShadowedRequire.test.ts +++ b/src/domain/javascript/javascriptStaticAnalysisShadowedRequire.test.ts @@ -159,6 +159,22 @@ const shadowedCases = [ "computed key write shadows a call in a later static field", `var require;\nclass C { [(require = () => "local", "k")]() {} static value = require("./ghost.cjs"); }`, ], + [ + "write in a while condition shadows a later call", + `var require;\nwhile ((require = () => "local", false)) {}\nrequire("./ghost.cjs");`, + ], + [ + "write in a while condition shadows a call in the loop body", + `var require;\nwhile ((require = () => "local", keepGoing())) { require("./ghost.cjs"); }`, + ], + [ + "write in a classic for condition shadows a later call", + `var require;\nfor (var i = 0; (require = () => "local", false), i < 0; i += 1) {}\nrequire("./ghost.cjs");`, + ], + [ + "write in a do-while test shadows a later call", + `var require;\ndo {} while ((require = () => "local", false));\nrequire("./ghost.cjs");`, + ], ]; const loaderCases = [