diff --git a/src/domain/javascript/javascriptSemanticAnalysis.ts b/src/domain/javascript/javascriptSemanticAnalysis.ts index 7698b04d169..39e1fc5f127 100644 --- a/src/domain/javascript/javascriptSemanticAnalysis.ts +++ b/src/domain/javascript/javascriptSemanticAnalysis.ts @@ -79,6 +79,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"; @@ -103,6 +104,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)[]; @@ -976,6 +978,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({ @@ -1259,6 +1269,7 @@ const bindPattern = (input: BindPatternInput): void => { state, mutable, kind = "variable", + declarationKind, projection = [], referenceOnly = false, copyKind, @@ -1303,6 +1314,7 @@ const bindPattern = (input: BindPatternInput): void => { definitionNode: pattern, initializer, projection, + ...(declarationKind === undefined ? {} : { declarationKind }), }); return; } @@ -1404,6 +1416,7 @@ const addBinding = (input: AddBindingInput): void => { mutable, definitionNode, initializer, + declarationKind, directOrigin, runtimeImport, projection = [], @@ -1416,7 +1429,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 0d51cacd0e2..3a9543651c0 100644 --- a/src/domain/javascript/javascriptStaticAnalysisBundler.ts +++ b/src/domain/javascript/javascriptStaticAnalysisBundler.ts @@ -3,6 +3,7 @@ import { runtimeFunctionParameters } from "./javascriptAstValues.js"; import { javascriptBootstrapAt } from "./javascriptBootstrap.js"; import { resolveSemanticBindingState, + semanticScopeId, type JavaScriptSemanticAnalysisState, } from "./javascriptSemanticState.js"; @@ -261,6 +262,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 a37896589f8..854ef14709d 100644 --- a/src/domain/javascript/javascriptStaticAnalysisCalls.ts +++ b/src/domain/javascript/javascriptStaticAnalysisCalls.ts @@ -13,6 +13,7 @@ import { argumentValue, calleeName, endpointArgument as endpointArgumentHelper, + isFileIdentity, prefixedArgument, range, rangeForOffsets, @@ -30,11 +31,14 @@ import type { JavaScriptAnalysisAccumulator as AnalysisAccumulator, JavaScriptEndpointInput as EndpointInput, JavaScriptFindingContext as FindingContext, + JavaScriptModuleRange, JavaScriptReferenceInput as ReferenceInput, } from "./javascriptStaticAnalysisState.js"; import { resolveSemanticBindingState, + semanticResolutionBlocked, type JavaScriptSemanticAnalysisState, + type JavaScriptSemanticBindingState, } from "./javascriptSemanticState.js"; import { isUnmodifiedGlobalReference, @@ -58,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); + const requireCall = requireCallSpecifier(node, name, module, bindings); if (requireCall !== null) addReference(context, { node, @@ -225,10 +229,20 @@ const noteMediaElementPreloads = ( const requireCallSpecifier = ( node: JavaScriptCallLike, name: string, - moduleRequireName: string | null, + module: JavaScriptModuleRange | undefined, + bindings: JavaScriptSemanticAnalysisState, ): { readonly specifier: string | undefined } | null => { - if (name === "require" || name.endsWith(".require")) + 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, module, bindings)) + return null; return { specifier: argumentValue(node.arguments[0]) }; + } if (name === "__webpack_require__" || name === moduleRequireName) return { specifier: argumentValue(node.arguments[0]) }; if ( @@ -239,6 +253,660 @@ 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, + module: JavaScriptModuleRange | undefined, + 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; + // A bundle factory whose loader parameter is named `require` (e.g. + // webpack's `(module, exports, require)`) is the recovered module loader, + // 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 && + isLoaderRetainingParameterBinding(binding) + ) + 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 && + // 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, + ) + ); +}; + +const isRetainedRequireLoader = ( + binding: JavaScriptSemanticBindingState, + node: JavaScriptCallLike, + state: JavaScriptSemanticAnalysisState, +): boolean => + isDirectCreateRequireBinding(binding, state, node) || + isUninitializedTopLevelVarRequire(binding, state, node); + +/** + * `const require = createRequire(import.meta.url)` aliases the ESM loader. + * 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. + * + * 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. 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, + state: JavaScriptSemanticAnalysisState, + node: JavaScriptCallLike, +): boolean => { + if ( + !binding.definitions.every( + ({ kind }) => kind === "variable" || kind === "assignment", + ) + ) + return false; + const { line, column } = range(node).start; + const callFunction = nearestFunctionNode(node, state); + const isDirectAliasWrite = ( + initializer: (typeof binding.initializers)[number], + ): boolean => { + // 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) && + // 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. 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); + if (establishing === undefined) return false; + // 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. 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 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); + const installed = !( + callBeforeEstablishing && establishingFact.functionNode === callFunction + ); + return installed && !requireWriteShadowsCall(laterWrites, node, state); +}; + +/** + * Match `createRequire` by its imported origin rather than one call spelling: + * 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, + 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; + 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. + */ +const isUninitializedTopLevelVarRequire = ( + binding: JavaScriptSemanticBindingState, + state: JavaScriptSemanticAnalysisState, + node: JavaScriptCallLike, +): boolean => { + const facts = retainedRequireFacts(binding, state); + if (!facts.declarationsOk) return false; + return !requireWriteShadowsCall(facts.writes, node, state); +}; + +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; + /** The write node itself, for conditional-ancestor checks. */ + readonly node: t.Node; +} + +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 declarationsOk = + binding.kind === "variable" && + binding.definitions.every( + ({ kind, declarationKind }) => + kind === "assignment" || + (kind === "variable" && declarationKind === "var"), + ) && + // 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: [] }; + return { declarationsOk: true, writes: requireWriteFacts(binding, state) }; +}; + +/** + * 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[] => + 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), + 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 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. + * + * 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, + node: JavaScriptCallLike, + callFunction: t.Node | null, + 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 + * 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 + * that may never execute cannot hide the dependency: the call may still run + * with the loader in place. + */ +const requireWriteShadowsCall = ( + writes: readonly RequireWriteFact[], + node: JavaScriptCallLike, + state: JavaScriptSemanticAnalysisState, +): boolean => { + const callFunction = nearestFunctionNode(node, state); + return writes.some( + (write) => + writeFactPrecedesCall(write, node, callFunction, state) && + 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. 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. 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, + 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)) && + // 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; + 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 + * a function (e.g. `require = () => "local"`) still takes effect in the + * 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 + * instance field initializer only runs when the class is instantiated, so a + * 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. + */ +const nearestFunctionNode = ( + node: t.Node, + state: JavaScriptSemanticAnalysisState, +): t.Node | null => { + let child: t.Node = node; + let current: t.Node | undefined = state.parentsByNode.get(child); + while (current !== undefined) { + if (t.isFunction(current) && !isComputedKeyOf(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 + * 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) || + t.isClassMethod(parent) || + t.isClassPrivateMethod(parent)) && + parent.computed && + parent.key === child; + const chunkLoadSpecifier = ( node: JavaScriptCallLike, name: string, diff --git a/src/domain/javascript/javascriptStaticAnalysisHelpers.ts b/src/domain/javascript/javascriptStaticAnalysisHelpers.ts index 3b7e79c1dcc..7004ecd3203 100644 --- a/src/domain/javascript/javascriptStaticAnalysisHelpers.ts +++ b/src/domain/javascript/javascriptStaticAnalysisHelpers.ts @@ -412,7 +412,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/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 new file mode 100644 index 00000000000..b88dfcbddfe --- /dev/null +++ b/src/domain/javascript/javascriptStaticAnalysisShadowedRequire.test.ts @@ -0,0 +1,434 @@ +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); + +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"); } }]);`, + ], + [ + "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"); } }]);`, + ], + [ + "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");`, + ], + [ + "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");`, + ], + [ + "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"); }`, + ], + [ + "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");`, + ], + [ + "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"); }`, + ], + [ + "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"); }`, + ], + [ + "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 = [ + [ + "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 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"], + ], + [ + "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");`, + ["./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;`, + ["./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"], + ], + [ + "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");`, + ["./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"], + ], + [ + "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 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");`, + ["./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"], + ], + [ + "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"], + ], + [ + "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();`, + ["./dep.cjs"], + ], +]; + +describe("lexically shadowed require calls", () => { + it.each(shadowedCases)( + "emits no require reference for a %s shadow", + (_label, source) => { + expect(requireSpecifiers(source)).toEqual([]); + }, + ); + + 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( + 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"]); + }); + + 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( + `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 c60b7676373..98e4e543da9 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. */