From eef4a0bfb4f005dcfb0d16cf6a6d5bc85d743e23 Mon Sep 17 00:00:00 2001 From: morluto <76467478+morluto@users.noreply.github.com> Date: Wed, 14 Oct 2026 16:00:20 +0800 Subject: [PATCH] fix(javascript): derive loader references from lexical binding ownership Use shared lexical bindings and recovered bundler identities for require and chunk references. Preserve uncertain writes as unresolved evidence; remove name-only loader aliases and repeated definition scans. Reviewed-PR: #2989 Co-authored-by: Hussnain Ahmad --- .../javascript/javascriptSemanticAnalysis.ts | 11 + ...scriptSemanticMutationInitializers.test.ts | 1 + .../javascriptSemanticRequireOrigin.ts | 164 ++++++++++++++ .../javascript/javascriptSemanticState.ts | 2 + .../javascriptStaticAnalysisBundler.ts | 79 ++++++- .../javascriptStaticAnalysisCalls.ts | 77 +++++-- ...avascriptStaticAnalysisModuleIndex.test.ts | 204 ++++++++++++++++++ .../javascriptStaticAnalysisState.ts | 3 + 8 files changed, 509 insertions(+), 32 deletions(-) diff --git a/src/domain/javascript/javascriptSemanticAnalysis.ts b/src/domain/javascript/javascriptSemanticAnalysis.ts index 7698b04d1..9947191e8 100644 --- a/src/domain/javascript/javascriptSemanticAnalysis.ts +++ b/src/domain/javascript/javascriptSemanticAnalysis.ts @@ -78,6 +78,7 @@ interface BindPatternInput { readonly scope: JavaScriptSemanticScopeState; readonly state: JavaScriptSemanticAnalysisState; readonly mutable: boolean; + readonly declarationKind?: "var" | "let" | "const" | "using" | "await using"; readonly kind?: JavaScriptSemanticDefinition["kind"]; readonly projection?: readonly (string | number | null)[]; readonly referenceOnly?: boolean; @@ -101,6 +102,7 @@ interface AddBindingInput { readonly name: string; readonly kind: JavaScriptSemanticDefinition["kind"]; readonly mutable: boolean; + readonly declarationKind?: "var" | "let" | "const" | "using" | "await using"; readonly definitionNode: t.Node; readonly initializer: t.Node | null; readonly directOrigin?: JavaScriptModuleOrigin; @@ -976,6 +978,9 @@ const bindInnerDeclaration = ( parent !== null && t.isVariableDeclaration(parent) ? parent.kind !== "const" : true, + ...(parent !== null && t.isVariableDeclaration(parent) + ? { declarationKind: parent.kind } + : {}), }); else if (t.isClassExpression(node) && t.isIdentifier(node.id)) addBinding({ @@ -1258,6 +1263,7 @@ const bindPattern = (input: BindPatternInput): void => { scope, state, mutable, + declarationKind, kind = "variable", projection = [], referenceOnly = false, @@ -1300,6 +1306,7 @@ const bindPattern = (input: BindPatternInput): void => { name: pattern.name, kind, mutable, + ...(declarationKind === undefined ? {} : { declarationKind }), definitionNode: pattern, initializer, projection, @@ -1402,6 +1409,7 @@ const addBinding = (input: AddBindingInput): void => { name, kind, mutable, + declarationKind, definitionNode, initializer, directOrigin, @@ -1416,6 +1424,8 @@ const addBinding = (input: AddBindingInput): void => { state.bindingsById.set(binding.bindingId, binding); } binding.mutable ||= mutable; + if (declarationKind !== undefined) + binding.declarationKinds.add(declarationKind); binding.definitions.push({ kind, location: range(definitionNode) }); if (initializer !== null) binding.initializers.push({ node: initializer, projection }); @@ -1443,6 +1453,7 @@ const createBinding = ( name, kind, mutable, + declarationKinds: new Set(), memberWrites: [], escapedPaths: [], definitions: [], diff --git a/src/domain/javascript/javascriptSemanticMutationInitializers.test.ts b/src/domain/javascript/javascriptSemanticMutationInitializers.test.ts index 8e1c92d5f..8a6179243 100644 --- a/src/domain/javascript/javascriptSemanticMutationInitializers.test.ts +++ b/src/domain/javascript/javascriptSemanticMutationInitializers.test.ts @@ -47,6 +47,7 @@ describe("parameter references copied into function body bindings", () => { scopeId: "body", name: "arg", kind: "variable", + declarationKinds: new Set(), mutable: true, memberWrites: [], escapedPaths: [], diff --git a/src/domain/javascript/javascriptSemanticRequireOrigin.ts b/src/domain/javascript/javascriptSemanticRequireOrigin.ts index b11d64af2..1612a797c 100644 --- a/src/domain/javascript/javascriptSemanticRequireOrigin.ts +++ b/src/domain/javascript/javascriptSemanticRequireOrigin.ts @@ -6,6 +6,7 @@ import type { JavaScriptModuleOrigin } from "./javascriptSemanticIr.js"; import { resolveSemanticBindingState, semanticResolutionBlocked, + semanticStableInitializer, type JavaScriptSemanticAnalysisState, } from "./javascriptSemanticState.js"; import { @@ -22,6 +23,169 @@ export interface JavaScriptRequireOrigin { readonly call: t.CallExpression; } +export type JavaScriptRequireLoaderFact = + | "commonjs-loader" + | "factory-loader" + | "create-require" + | "local" + | "unknown"; + +/** Classify a bare `require(...)` using the shared lexical binding owner. */ +export const semanticRequireLoaderAt = ( + node: t.CallExpression | t.OptionalCallExpression | t.NewExpression, + state: JavaScriptSemanticAnalysisState, + factoryBindingIds: ReadonlySet, +): JavaScriptRequireLoaderFact => { + if (!t.isIdentifier(node.callee, { name: "require" })) return "local"; + const binding = resolveSemanticBindingState(state, node.callee, "require"); + if (binding === undefined) + return semanticResolutionBlocked(state, node.callee, "require") + ? "unknown" + : "commonjs-loader"; + if (factoryBindingIds.has(binding.bindingId)) + return binding.initializers.length === 0 ? "factory-loader" : "unknown"; + if (isWrapperRequireInitializerCall(binding, node, state)) + return "commonjs-loader"; + const scope = state.scopesById.get(binding.scopeId); + if ( + scope?.kind === "program" && + binding.declarationKinds.size > 0 && + [...binding.declarationKinds].every((kind) => kind === "var") && + binding.initializers.length === 0 + ) + return "commonjs-loader"; + if (isSemanticCreateRequireBinding(binding, state)) return "create-require"; + const initializer = semanticStableInitializer(binding, state)?.node; + if ( + (binding.kind === "function" || binding.kind === "class") && + initializer !== undefined + ) + return "local"; + if ( + binding.declarationKinds.size === 1 && + binding.declarationKinds.has("const") && + (t.isFunctionExpression(initializer) || + t.isArrowFunctionExpression(initializer)) + ) + return "local"; + return "unknown"; +}; + +/** Classify a call through an exact recovered factory-loader binding. */ +export const semanticFactoryRequireLoaderAt = ( + node: t.CallExpression | t.OptionalCallExpression | t.NewExpression, + state: JavaScriptSemanticAnalysisState, + bindingIds: ReadonlySet, +): JavaScriptRequireLoaderFact | undefined => { + const callee = unwrapJavaScriptExpression(node.callee).node; + if (t.isIdentifier(callee)) + return semanticFactoryLoaderMemberAt(node, state, bindingIds, null); + if ( + (t.isMemberExpression(callee) || t.isOptionalMemberExpression(callee)) && + semanticStaticPropertyKey(callee.property, callee.computed) === "bind" + ) + return semanticFactoryLoaderMemberAt(node, state, bindingIds, "bind"); + return undefined; +}; + +/** Classify an exact loader call by binding identity and expected member. */ +export const semanticFactoryLoaderMemberAt = ( + node: t.CallExpression | t.OptionalCallExpression | t.NewExpression, + state: JavaScriptSemanticAnalysisState, + bindingIds: ReadonlySet, + member: string | null, +): JavaScriptRequireLoaderFact | undefined => { + const callee = unwrapJavaScriptExpression(node.callee).node; + let loader: t.Identifier | undefined; + if (member === null) { + if (t.isIdentifier(callee)) loader = callee; + } else if ( + (t.isMemberExpression(callee) || t.isOptionalMemberExpression(callee)) && + semanticStaticPropertyKey(callee.property, callee.computed) === member + ) { + const root = unwrapJavaScriptExpression(callee.object).node; + if (t.isIdentifier(root)) loader = root; + } + if (loader === undefined) return undefined; + const binding = resolveSemanticBindingState(state, loader, loader.name); + if (binding === undefined || !bindingIds.has(binding.bindingId)) + return undefined; + return binding.initializers.length === 0 ? "factory-loader" : "unknown"; +}; + +const isSemanticCreateRequireBinding = ( + binding: NonNullable>, + state: JavaScriptSemanticAnalysisState, +): boolean => { + if (binding.initializers.length !== 1) return false; + const initializer = semanticStableInitializer(binding, state)?.node; + if (!t.isCallExpression(initializer)) return false; + const callee = unwrapJavaScriptExpression(initializer.callee).node; + if (t.isIdentifier(callee)) { + const origins = resolveSemanticBindingState( + state, + callee, + callee.name, + )?.directOrigins; + if (origins?.length !== 1) return false; + const [origin] = origins; + return ( + origin?.importedPath.at(-1) === "createRequire" && + ["module", "node:module"].includes(origin.specifier) + ); + } + const chain = semanticMemberChain(callee); + if (chain === undefined || chain.members.at(-1) !== "createRequire") + return false; + if (!t.isNode(chain.root)) return false; + const root = unwrapJavaScriptExpression(chain.root).node; + if (!t.isIdentifier(root)) return false; + const origins = resolveSemanticBindingState( + state, + root, + root.name, + )?.directOrigins; + if (origins?.length !== 1) return false; + const [origin] = origins; + return ( + origin?.importedPath.length === 0 && + ["module", "node:module"].includes(origin.specifier) + ); +}; + +/** The RHS call runs before its assignment replaces Node's wrapper loader. */ +const isWrapperRequireInitializerCall = ( + binding: NonNullable>, + node: t.CallExpression | t.OptionalCallExpression | t.NewExpression, + state: JavaScriptSemanticAnalysisState, +): boolean => { + const scope = state.scopesById.get(binding.scopeId); + if ( + scope?.kind !== "program" || + binding.kind !== "variable" || + binding.declarationKinds.size !== 1 || + !binding.declarationKinds.has("var") || + binding.initializers.length !== 1 || + binding.initializers[0]?.node !== node + ) + return false; + const assignment = state.parentsByNode.get(node); + if ( + !t.isAssignmentExpression(assignment) || + assignment.right !== node || + state.conditionallySkippedExpressions.has(node) + ) + return false; + const statement = state.parentsByNode.get(assignment); + if (!t.isExpressionStatement(statement)) return false; + if (state.parentsByNode.get(statement) !== state.program) return false; + const target = unwrapJavaScriptExpression(assignment.left).node; + return ( + t.isIdentifier(target, { name: "require" }) && + resolveSemanticBindingState(state, target, target.name) === binding + ); +}; + // Ambient indexing finishes before these derived facts are consumed. Keep only // replacement sites, rather than rescanning unrelated global member writes. const globalProcessReplacements = new WeakMap< diff --git a/src/domain/javascript/javascriptSemanticState.ts b/src/domain/javascript/javascriptSemanticState.ts index 86183d089..ca9a74523 100644 --- a/src/domain/javascript/javascriptSemanticState.ts +++ b/src/domain/javascript/javascriptSemanticState.ts @@ -46,6 +46,8 @@ export interface JavaScriptSemanticBindingState { readonly scopeId: string; readonly name: string; kind: JavaScriptSemanticDefinition["kind"]; + /** Source declaration form retained internally for CommonJS wrapper bindings. */ + declarationKinds: Set<"var" | "let" | "const" | "using" | "await using">; mutable: boolean; readonly memberWrites: JavaScriptSemanticMemberEffect[]; readonly escapedPaths: JavaScriptSemanticMemberEffect[]; diff --git a/src/domain/javascript/javascriptStaticAnalysisBundler.ts b/src/domain/javascript/javascriptStaticAnalysisBundler.ts index 0d51cacd0..11d127401 100644 --- a/src/domain/javascript/javascriptStaticAnalysisBundler.ts +++ b/src/domain/javascript/javascriptStaticAnalysisBundler.ts @@ -5,6 +5,7 @@ import { resolveSemanticBindingState, type JavaScriptSemanticAnalysisState, } from "./javascriptSemanticState.js"; +import { semanticFactoryLoaderMemberAt } from "./javascriptSemanticRequireOrigin.js"; import { traverseJavaScriptAst } from "./javascriptSemanticTraversal.js"; @@ -46,7 +47,11 @@ export const inspectBundlerRegistration = ( if (runtime === undefined || !t.isArrayExpression(entry)) return; const chunkIds = entry.elements[0]; const table = entry.elements[1]; - const runtimeValue = runtimeMetadata(entry.elements[2]); + const runtimeValue = runtimeMetadata( + entry.elements[2], + bindings, + accumulator, + ); if (!t.isArrayExpression(chunkIds) || !t.isObjectExpression(table)) return; const recovered = recoverBundlerModules(source, table, accumulator, { runtime, @@ -253,7 +258,21 @@ const recoverBundlerModule = ( const fingerprint = fingerprintJavaScriptAst(factory); const exportsValue = collectJavaScriptExports(factory, bindings); const requireName = factoryRequireName(factory); - const asyncChunks = collectBundlerAsyncChunkKeys(factory, requireName); + const requireParameter = runtimeFunctionParameters(factory)[2]; + const requireBindingId = t.isIdentifier(requireParameter) + ? (resolveSemanticBindingState( + bindings, + requireParameter, + requireParameter.name, + )?.bindingId ?? null) + : null; + if (requireBindingId !== null) + accumulator.factoryRequireBindingIds.add(requireBindingId); + const asyncChunks = collectBundlerAsyncChunkKeys( + factory, + requireBindingId, + bindings, + ); if (typeof factory.start === "number" && typeof factory.end === "number") { const moduleRange = { start: factory.start, @@ -297,7 +316,11 @@ type BundlerFunction = | t.ArrowFunctionExpression | t.ObjectMethod; -const runtimeMetadata = (node: t.Node | null | undefined): RuntimeMetadata => { +const runtimeMetadata = ( + node: t.Node | null | undefined, + bindings: JavaScriptSemanticAnalysisState, + accumulator: AnalysisAccumulator, +): RuntimeMetadata => { if (!isBundlerFunction(node)) return { requireName: null, @@ -308,6 +331,12 @@ const runtimeMetadata = (node: t.Node | null | undefined): RuntimeMetadata => { }; const parameter = runtimeFunctionParameters(node)[0]; const requireName = t.isIdentifier(parameter) ? parameter.name : null; + const requireBindingId = t.isIdentifier(parameter) + ? (resolveSemanticBindingState(bindings, parameter, parameter.name) + ?.bindingId ?? null) + : null; + if (requireBindingId !== null) + accumulator.factoryRequireBindingIds.add(requireBindingId); if (requireName === null) return { requireName, @@ -316,8 +345,18 @@ const runtimeMetadata = (node: t.Node | null | undefined): RuntimeMetadata => { asyncChunkKeys: [], unknownAsyncChunkKeys: 0, }; - const entries = collectBundlerCallArgumentValues(node, requireName); - const asyncChunks = collectBundlerAsyncChunkKeys(node, requireName); + const entries = collectBundlerCallArgumentValues( + node, + requireBindingId, + bindings, + null, + ); + const asyncChunks = collectBundlerCallArgumentValues( + node, + requireBindingId, + bindings, + "e", + ); return { requireName, entryModuleKeys: entries.values, @@ -329,23 +368,43 @@ const runtimeMetadata = (node: t.Node | null | undefined): RuntimeMetadata => { const collectBundlerAsyncChunkKeys = ( factory: BundlerFunction, - requireName: string | null, + requireBindingId: string | null, + bindings: JavaScriptSemanticAnalysisState, ): StaticValues => - requireName === null + requireBindingId === null ? { values: [], unknown: 0 } - : collectBundlerCallArgumentValues(factory, `${requireName}.e`); + : collectBundlerCallArgumentValues( + factory, + requireBindingId, + bindings, + "e", + ); const collectBundlerCallArgumentValues = ( factory: BundlerFunction, - callee: string, + bindingId: string | null, + bindings: JavaScriptSemanticAnalysisState, + member: string | null, ): StaticValues => { + if (bindingId === null) return { values: [], unknown: 0 }; const values: string[] = []; let unknown = 0; + const bindingIds = new Set([bindingId]); traverseJavaScriptAst(factory, { enter: (node) => { if (!t.isCallExpression(node) && !t.isNewExpression(node)) return undefined; - if (calleeName(node.callee) !== callee) return undefined; + const loader = semanticFactoryLoaderMemberAt( + node, + bindings, + bindingIds, + member, + ); + if (loader === undefined) return undefined; + if (loader === "unknown") { + unknown += 1; + return undefined; + } const value = argumentValue(node.arguments[0]); if (value === undefined) unknown += 1; else values.push(value); diff --git a/src/domain/javascript/javascriptStaticAnalysisCalls.ts b/src/domain/javascript/javascriptStaticAnalysisCalls.ts index a37896589..816ba3eaf 100644 --- a/src/domain/javascript/javascriptStaticAnalysisCalls.ts +++ b/src/domain/javascript/javascriptStaticAnalysisCalls.ts @@ -40,6 +40,11 @@ import { isUnmodifiedGlobalReference, isUnmodifiedImportMetaUrl, } from "./javascriptSemanticAmbientWrites.js"; +import { + semanticFactoryLoaderMemberAt, + semanticFactoryRequireLoaderAt, + semanticRequireLoaderAt, +} from "./javascriptSemanticRequireOrigin.js"; type JavaScriptCallLike = | t.CallExpression @@ -56,16 +61,14 @@ export const inspectCall = ( const { accumulator } = context; const name = calleeName(node.callee); 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, bindings, context); if (requireCall !== null) addReference(context, { node, kind: "require", specifier: requireCall.specifier, }); - const chunkLoad = chunkLoadSpecifier(node, name, moduleRequireName); + const chunkLoad = chunkLoadSpecifier(node, bindings, context); if (chunkLoad !== null) addReference(context, { node, @@ -224,31 +227,61 @@ const noteMediaElementPreloads = ( const requireCallSpecifier = ( node: JavaScriptCallLike, - name: string, - moduleRequireName: string | null, + bindings: JavaScriptSemanticAnalysisState, + context: FindingContext, ): { readonly specifier: string | undefined } | null => { - if (name === "require" || name.endsWith(".require")) - return { specifier: argumentValue(node.arguments[0]) }; - if (name === "__webpack_require__" || name === moduleRequireName) - return { specifier: argumentValue(node.arguments[0]) }; - if ( - name === "__webpack_require__.bind" || - (moduleRequireName !== null && name === `${moduleRequireName}.bind`) - ) - return { specifier: argumentValue(node.arguments[1]) }; + const bareRequire = t.isIdentifier(node.callee, { name: "require" }); + if (bareRequire) { + const loader = semanticRequireLoaderAt( + node, + bindings, + context.accumulator.factoryRequireBindingIds, + ); + if (loader === "local") return null; + if (loader === "unknown") context.accumulator.unknownFindings += 1; + return { + specifier: + loader === "unknown" ? undefined : argumentValue(node.arguments[0]), + }; + } + const factoryLoader = semanticFactoryRequireLoaderAt( + node, + bindings, + context.accumulator.factoryRequireBindingIds, + ); + if (factoryLoader === "factory-loader") + return { + specifier: argumentValue( + node.arguments[ + t.isMemberExpression(unwrapJavaScriptExpression(node.callee).node) || + t.isOptionalMemberExpression( + unwrapJavaScriptExpression(node.callee).node, + ) + ? 1 + : 0 + ], + ), + }; + if (factoryLoader === "unknown") { + context.accumulator.unknownFindings += 1; + return { specifier: undefined }; + } return null; }; const chunkLoadSpecifier = ( node: JavaScriptCallLike, - name: string, - moduleRequireName: string | null, + bindings: JavaScriptSemanticAnalysisState, + context: FindingContext, ): { readonly specifier: string | undefined } | null => { - if ( - name !== "__webpack_require__.e" && - (moduleRequireName === null || name !== `${moduleRequireName}.e`) - ) - return null; + const loader = semanticFactoryLoaderMemberAt( + node, + bindings, + context.accumulator.factoryRequireBindingIds, + "e", + ); + if (loader === undefined) return null; + if (loader === "unknown") return { specifier: undefined }; return { specifier: prefixedArgument("chunk:", node.arguments[0]) }; }; diff --git a/src/domain/javascript/javascriptStaticAnalysisModuleIndex.test.ts b/src/domain/javascript/javascriptStaticAnalysisModuleIndex.test.ts index cb1515947..f22aa55f2 100644 --- a/src/domain/javascript/javascriptStaticAnalysisModuleIndex.test.ts +++ b/src/domain/javascript/javascriptStaticAnalysisModuleIndex.test.ts @@ -62,6 +62,210 @@ it("does not treat a same-named shadow as the factory exports parameter", () => ]); }); +it("does not resolve a parameter named require as a module dependency", () => { + const analysis = analyzeJavaScriptStaticSource( + `function run(require) { return require("./ghost.cjs"); }`, + ); + expect(analysis.references).not.toContainEqual( + expect.objectContaining({ specifier: "./ghost.cjs" }), + ); + expect(analysis.references).toContainEqual( + expect.objectContaining({ + kind: "require", + specifier: null, + expression: 'require("./ghost.cjs")', + }), + ); +}); + +it("keeps a top-level wrapper require redeclaration", () => { + const analysis = analyzeJavaScriptStaticSource( + `var require; require("./wrapper.cjs");`, + ); + expect(analysis.references).toContainEqual( + expect.objectContaining({ specifier: "./wrapper.cjs" }), + ); +}); + +it("retains the wrapper dependency evaluated inside its replacing assignment", () => { + const analysis = analyzeJavaScriptStaticSource( + `var require; require = require("./before-replacement.cjs");`, + ); + expect(analysis.references).toContainEqual( + expect.objectContaining({ specifier: "./before-replacement.cjs" }), + ); +}); + +it.each([ + [ + "a deferred function assignment", + `var require; function load() { require = require("./maybe.cjs"); } load(); load();`, + ], + [ + "an assignment repeated by a loop", + `var require; while (condition) { require = require("./maybe.cjs"); }`, + ], +])("keeps %s unknown", (_label, source) => { + const analysis = analyzeJavaScriptStaticSource(source); + expect(analysis.references).not.toContainEqual( + expect.objectContaining({ specifier: "./maybe.cjs" }), + ); + expect(analysis.references).toContainEqual( + expect.objectContaining({ kind: "require", specifier: null }), + ); +}); + +it.each([ + [ + "a skipped wrapper write", + `var require; if (false) require = localLoader; require("./ghost.cjs");`, + "./ghost.cjs", + ], + [ + "a skipped createRequire write", + `import { createRequire } from "node:module"; let require = () => "local"; if (false) require = createRequire(import.meta.url); require("./ghost.cjs");`, + "./ghost.cjs", + ], + [ + "a class static field write before its computed key in source order", + `var require; class C { static value = (require = () => "local"); [require("./computed-key.cjs")]() {} }`, + "./computed-key.cjs", + ], + [ + "a loop condition write before a later call", + `var require; while ((require = () => "local", false)) {} require("./after-loop.cjs");`, + "./after-loop.cjs", + ], +])("preserves uncertainty for %s", (_label, source, specifier) => { + const analysis = analyzeJavaScriptStaticSource(source); + expect(analysis.references).not.toContainEqual( + expect.objectContaining({ specifier }), + ); + expect(analysis.references).toContainEqual( + expect.objectContaining({ kind: "require", specifier: null }), + ); +}); + +it("keeps a proven createRequire binding", () => { + const analysis = analyzeJavaScriptStaticSource(` + import { createRequire as makeRequire } from "node:module"; + const require = makeRequire(import.meta.url); + require("./created.cjs"); + `); + expect(analysis.references).toEqual( + expect.arrayContaining([ + expect.objectContaining({ specifier: "./created.cjs" }), + ]), + ); +}); + +it("recognizes only factory loader calls and supported bind references", () => { + const analysis = analyzeJavaScriptStaticSource(` + globalThis.webpackChunkApp.push([[1], { + "factory": function (module, exports, loader) { + loader.resolve("./not-a-dependency.cjs"); + loader("./dependency.cjs"); + loader.bind(module, "./bound-dependency.cjs"); + } + }]); + `); + expect(analysis.references.map(({ specifier }) => specifier)).toEqual( + expect.arrayContaining(["./dependency.cjs", "./bound-dependency.cjs"]), + ); + expect(analysis.references).not.toContainEqual( + expect.objectContaining({ specifier: "./not-a-dependency.cjs" }), + ); +}); + +it("keeps reassigned factory loaders unknown instead of resolving imports", () => { + const analysis = analyzeJavaScriptStaticSource(` + globalThis.webpackChunkApp.push([[1], { + "factory": function (module, exports, loader) { + loader = userInput; + loader("./uncertain.cjs"); + } + }]); + `); + expect(analysis.references).not.toContainEqual( + expect.objectContaining({ specifier: "./uncertain.cjs" }), + ); + expect(analysis.references).toContainEqual( + expect.objectContaining({ kind: "require", specifier: null }), + ); +}); + +it("keeps a reassigned function named require unknown", () => { + const analysis = analyzeJavaScriptStaticSource(` + function require() { return userInput; } + require = replacement; + require("./uncertain.cjs"); + `); + expect(analysis.references).not.toContainEqual( + expect.objectContaining({ specifier: "./uncertain.cjs" }), + ); + expect(analysis.references).toContainEqual( + expect.objectContaining({ kind: "require", specifier: null }), + ); +}); + +it("does not treat a shadowed webpack loader name as bundler ownership", () => { + const analysis = analyzeJavaScriptStaticSource(` + function local(__webpack_require__) { + __webpack_require__("./ghost.cjs"); + __webpack_require__.e("ghost-chunk"); + } + `); + expect(analysis.references).not.toContainEqual( + expect.objectContaining({ specifier: "./ghost.cjs" }), + ); + expect(analysis.references).not.toContainEqual( + expect.objectContaining({ specifier: "chunk:ghost-chunk" }), + ); +}); + +it("uses recovered factory and runtime loader identities for webpack imports and chunks", () => { + const analysis = analyzeJavaScriptStaticSource(` + globalThis.webpackChunkApp.push([[1], { + "factory": function (module, exports, __webpack_require__) { + __webpack_require__("./factory-dependency.cjs"); + __webpack_require__.e("factory-chunk"); + } + }, function (__webpack_require__) { + __webpack_require__("factory"); + __webpack_require__.e("runtime-chunk"); + }]); + `); + expect(analysis.references.map(({ specifier }) => specifier)).toEqual( + expect.arrayContaining([ + "./factory-dependency.cjs", + "chunk:factory-chunk", + "chunk:runtime-chunk", + ]), + ); + expect(analysis.bundler_registrations[0]?.async_chunk_keys).toEqual( + expect.arrayContaining(["factory-chunk", "runtime-chunk"]), + ); +}); + +it("does not attribute a shadowed factory loader parameter to async chunks", () => { + const analysis = analyzeJavaScriptStaticSource(` + globalThis.webpackChunkApp.push([[1], { + "factory": function (module, exports, loader) { + function local(__webpack_require__) { + __webpack_require__.e("ghost-chunk"); + } + loader.e("real-chunk"); + } + }]); + `); + expect(analysis.bundler_registrations[0]?.async_chunk_keys).toEqual([ + "real-chunk", + ]); + expect(analysis.references).not.toContainEqual( + expect.objectContaining({ specifier: "chunk:ghost-chunk" }), + ); +}); + it("does not confuse a shadowed dispatch table with the outer module table", () => { const analysis = analyzeJavaScriptStaticSource(`(() => { const modules = { 17(module) { module.exports = require("electron"); } }; diff --git a/src/domain/javascript/javascriptStaticAnalysisState.ts b/src/domain/javascript/javascriptStaticAnalysisState.ts index c60b76763..70e1eb086 100644 --- a/src/domain/javascript/javascriptStaticAnalysisState.ts +++ b/src/domain/javascript/javascriptStaticAnalysisState.ts @@ -53,6 +53,8 @@ export interface JavaScriptAnalysisAccumulator { readonly utilityProcesses: LocatedJavaScriptFinding[]; readonly nativeAddonBindings: LocatedJavaScriptFinding[]; readonly moduleRangeIndex: JavaScriptModuleRangeIndex; + /** Binding identities proven as third-parameter loaders of recovered factories. */ + readonly factoryRequireBindingIds: Set; /** `preload` attributes of audio and video elements, which are not scripts. */ readonly mediaElementPreloads: WeakSet; readonly seen: Set; @@ -114,6 +116,7 @@ export const createJavaScriptAnalysisAccumulator = ( utilityProcesses: [], nativeAddonBindings: [], moduleRangeIndex: new JavaScriptModuleRangeIndex(sourceLength), + factoryRequireBindingIds: new Set(), mediaElementPreloads: new WeakSet(), seen: new Set(), visitedNodes: 0,