From 5ab0cbb48ea5a4f11542eac44479b9af48f1dabd Mon Sep 17 00:00:00 2001 From: morluto <76467478+morluto@users.noreply.github.com> Date: Tue, 13 Oct 2026 04:12:14 +0800 Subject: [PATCH] fix(javascript): resolve bundled exports by binding identity Identify the factory exports parameter through the existing semantic binding owner, including renamed and minified Webpack parameters. Reject nested targets, bare alias rebinding, and shadowed identifiers. Prune the duplicated source happy path after validating the public CLI workflow. Validation: public analyze-javascript-application CLI acceptance and focused semantic traversal/module-index tests. Fixes #2171 --- .../javascript/javascriptAstFingerprint.ts | 79 +++++++++++++++---- .../javascript/javascriptStaticAnalysis.ts | 4 +- .../javascriptStaticAnalysisBundler.ts | 28 +++++-- ...avascriptStaticAnalysisModuleIndex.test.ts | 12 +++ .../webpackRenamedExportsCli.test.ts | 52 ++++++++++++ 5 files changed, 150 insertions(+), 25 deletions(-) create mode 100644 tests/acceptance/applications/webpackRenamedExportsCli.test.ts diff --git a/src/domain/javascript/javascriptAstFingerprint.ts b/src/domain/javascript/javascriptAstFingerprint.ts index 9b6a83730..c85700da9 100644 --- a/src/domain/javascript/javascriptAstFingerprint.ts +++ b/src/domain/javascript/javascriptAstFingerprint.ts @@ -10,6 +10,10 @@ import { semanticStaticPropertyKey, } from "./javascriptAstValues.js"; import { stringValue } from "./javascriptStaticAnalysisHelpers.js"; +import { + resolveSemanticBindingState, + type JavaScriptSemanticAnalysisState, +} from "./javascriptSemanticState.js"; /** Static CommonJS export names. */ export interface StaticExports { @@ -29,13 +33,41 @@ export const fingerprintJavaScriptAst = (node: t.Node): string => { }; /** Collect statically declared CommonJS/bundler export names from one factory. */ -export const collectJavaScriptExports = (node: t.Node): StaticExports => { +export const collectJavaScriptExports = ( + node: t.Node, + bindings?: JavaScriptSemanticAnalysisState, +): StaticExports => { const exports = new Set(); + const factoryExports = + bindings !== undefined && + t.isFunction(node) && + t.isIdentifier(node.params[1]) + ? resolveSemanticBindingState( + bindings, + node.params[1], + node.params[1].name, + )?.bindingId + : undefined; + const isExportsAlias = (reference: t.Node | null): boolean => { + if (!t.isIdentifier(reference)) return false; + if (factoryExports === undefined) return reference.name === "exports"; + return ( + bindings !== undefined && + resolveSemanticBindingState(bindings, reference, reference.name) + ?.bindingId === factoryExports + ); + }; traverseJavaScriptAst(node, { enter: (current) => { if (t.isAssignmentExpression(current)) - collectAssignmentExports(current.left, current.right, exports); - if (t.isCallExpression(current)) collectCallExports(current, exports); + collectAssignmentExports( + current.left, + current.right, + exports, + isExportsAlias, + ); + if (t.isCallExpression(current)) + collectCallExports(current, exports, isExportsAlias); return undefined; }, }); @@ -72,17 +104,19 @@ const collectAssignmentExports = ( left: t.LVal | t.OptionalMemberExpression, right: t.Expression, output: Set, + isExportsAlias: (root: t.Node | null) => boolean, ): void => { const path = memberPath(left); if (path === null) return; + const root = memberRoot(left); + const exportsAlias = isExportsAlias(root); // Rebinding the CommonJS alias does not replace module.exports. - if (path.length === 1 && path[0] === "exports") return; - const exportOffset = - path[0] === "exports" - ? 1 - : path[0] === "module" && path[1] === "exports" - ? 2 - : null; + if (path.length === 1 && exportsAlias) return; + const exportOffset = exportsAlias + ? 1 + : path[0] === "module" && path[1] === "exports" + ? 2 + : null; if (exportOffset === null) return; if (path.length === exportOffset) { if (t.isObjectExpression(right)) collectObjectKeys(right, output); @@ -98,6 +132,7 @@ const collectAssignmentExports = ( const collectCallExports = ( call: t.CallExpression, output: Set, + isExportsAlias: (root: t.Node | null) => boolean, ): void => { const callee = memberPath(call.callee); if (callee === null) return; @@ -107,13 +142,14 @@ const collectCallExports = ( callee[1] === "defineProperty"; const declarationCall = callee.length >= 2 && callee.at(-1) === "d"; if (!definePropertyCall && !declarationCall) return; - const target = memberPath(call.arguments[0]); + const targetNode = call.arguments[0]; + const target = memberPath(targetNode); const exportTarget = - target !== null && - ((target.length === 1 && target[0] === "exports") || - (target.length === 2 && - target[0] === "module" && - target[1] === "exports")); + isExportsAlias(targetNode ?? null) || + (target !== null && + target.length === 2 && + target[0] === "module" && + target[1] === "exports"); if (!exportTarget) return; if (definePropertyCall) { const name = stringValue(call.arguments[1]); @@ -125,6 +161,17 @@ const collectCallExports = ( collectObjectKeys(declarations, output); }; +const memberRoot = (node: t.Node | null | undefined): t.Node | null => { + let current = node; + while ( + current !== null && + current !== undefined && + (t.isMemberExpression(current) || t.isOptionalMemberExpression(current)) + ) + current = current.object; + return current ?? null; +}; + const collectObjectKeys = ( object: t.ObjectExpression, output: Set, diff --git a/src/domain/javascript/javascriptStaticAnalysis.ts b/src/domain/javascript/javascriptStaticAnalysis.ts index 5586adf84..ea712f658 100644 --- a/src/domain/javascript/javascriptStaticAnalysis.ts +++ b/src/domain/javascript/javascriptStaticAnalysis.ts @@ -232,8 +232,8 @@ const inspectNode = ( inspectElectronStaticNode(node, findings); if (t.isCallExpression(node)) { inspectBundlerBootstrap(source, node, accumulator, bindings); - inspectBundlerRegistration(source, node, accumulator); - inspectEsbuildWrapper(source, node, accumulator); + inspectBundlerRegistration(source, node, accumulator, bindings); + inspectEsbuildWrapper(source, node, accumulator, bindings); inspectCall(source, node, findings); } else if (t.isOptionalCallExpression(node)) { inspectCall(source, node, findings); diff --git a/src/domain/javascript/javascriptStaticAnalysisBundler.ts b/src/domain/javascript/javascriptStaticAnalysisBundler.ts index fc9705696..773f88d7d 100644 --- a/src/domain/javascript/javascriptStaticAnalysisBundler.ts +++ b/src/domain/javascript/javascriptStaticAnalysisBundler.ts @@ -37,6 +37,7 @@ export const inspectBundlerRegistration = ( source: string, call: t.CallExpression, accumulator: AnalysisAccumulator, + bindings: JavaScriptSemanticAnalysisState, ): void => { const runtime = chunkRuntime(call); const entry = call.arguments[0]; @@ -45,7 +46,10 @@ export const inspectBundlerRegistration = ( const table = entry.elements[1]; const runtimeValue = runtimeMetadata(entry.elements[2]); if (!t.isArrayExpression(chunkIds) || !t.isObjectExpression(table)) return; - const recovered = recoverBundlerModules(source, table, runtime, accumulator); + const recovered = recoverBundlerModules(source, table, accumulator, { + runtime, + bindings, + }); const chunkKeys = staticArrayValues(chunkIds); accumulator.unknownFindings += chunkKeys.unknown; accumulator.unknownFindings += @@ -98,8 +102,8 @@ export const inspectBundlerBootstrap = ( const recovered = recoverBundlerModules( source, bootstrap.table, - runtime, accumulator, + { runtime, bindings }, ); const entries: string[] = []; let unknownEntries = 0; @@ -148,6 +152,7 @@ export const inspectEsbuildWrapper = ( source: string, call: t.CallExpression, accumulator: AnalysisAccumulator, + bindings: JavaScriptSemanticAnalysisState, ): void => { const name = calleeName(call.callee); const wrapperKind = @@ -159,7 +164,10 @@ export const inspectEsbuildWrapper = ( const table = call.arguments[0]; if (wrapperKind === null || !t.isObjectExpression(table)) return; const runtime = `esbuild-${wrapperKind}`; - const recovered = recoverBundlerModules(source, table, runtime, accumulator); + const recovered = recoverBundlerModules(source, table, accumulator, { + runtime, + bindings, + }); if (recovered.modules.length === 0) return; accumulator.unknownFindings += recovered.unknownAsyncChunkKeys; const registration: JavaScriptBundlerRegistration = { @@ -191,11 +199,16 @@ interface RecoveredBundlerModules { readonly unknownAsyncChunkKeys: number; } +interface BundlerRecoveryContext { + readonly runtime: string; + readonly bindings: JavaScriptSemanticAnalysisState; +} + const recoverBundlerModules = ( source: string, table: t.ObjectExpression, - runtime: string, accumulator: AnalysisAccumulator, + context: BundlerRecoveryContext, ): RecoveredBundlerModules => { const modules: JavaScriptBundlerModule[] = []; const asyncChunkKeys: string[] = []; @@ -204,8 +217,8 @@ const recoverBundlerModules = ( const recovered = recoverBundlerModule( source, property, - runtime, accumulator, + context, ); if (recovered === null) continue; modules.push(recovered.module); @@ -222,20 +235,21 @@ const recoverBundlerModules = ( const recoverBundlerModule = ( source: string, property: t.ObjectMethod | t.ObjectProperty | t.SpreadElement, - runtime: string, accumulator: AnalysisAccumulator, + context: BundlerRecoveryContext, ): { readonly module: JavaScriptBundlerModule; readonly asyncChunkKeys: readonly string[]; readonly unknownAsyncChunkKeys: number; } | null => { + const { runtime, bindings } = context; const factory = moduleFactory(property); if (factory === undefined) return null; const key = modulePropertyName(property); if (key.startsWith("[computed@") || key.startsWith("[unknown@")) accumulator.unknownFindings += 1; const fingerprint = fingerprintJavaScriptAst(factory); - const exportsValue = collectJavaScriptExports(factory); + const exportsValue = collectJavaScriptExports(factory, bindings); const requireName = factoryRequireName(factory); const asyncChunks = collectBundlerAsyncChunkKeys(factory, requireName); if (typeof factory.start === "number" && typeof factory.end === "number") { diff --git a/src/domain/javascript/javascriptStaticAnalysisModuleIndex.test.ts b/src/domain/javascript/javascriptStaticAnalysisModuleIndex.test.ts index f5f51aa1d..cb1515947 100644 --- a/src/domain/javascript/javascriptStaticAnalysisModuleIndex.test.ts +++ b/src/domain/javascript/javascriptStaticAnalysisModuleIndex.test.ts @@ -50,6 +50,18 @@ it("does not infer a bootstrap from a function table without a matching loader", expect(analysis.bundler_registrations).toEqual([]); }); +it("does not treat a same-named shadow as the factory exports parameter", () => { + const analysis = + analyzeJavaScriptStaticSource(`globalThis.webpackChunkApp.push([[1], { + first(module, e, require) { + { const e = {}; require.d(e, { shadowed: () => 1 }); e.other = 2; } + } + }]);`); + expect(analysis.bundler_registrations[0]?.modules).toMatchObject([ + { module_key: "first", exports: [] }, + ]); +}); + 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/tests/acceptance/applications/webpackRenamedExportsCli.test.ts b/tests/acceptance/applications/webpackRenamedExportsCli.test.ts new file mode 100644 index 000000000..33a1bd56c --- /dev/null +++ b/tests/acceptance/applications/webpackRenamedExportsCli.test.ts @@ -0,0 +1,52 @@ +import { mkdir, writeFile } from "node:fs/promises"; +import { join } from "node:path"; + +import { expect } from "vitest"; + +import { parseEvidence } from "../../../src/domain/evidence.js"; +import { javascriptApplicationAnalysisResultSchema } from "../../../src/domain/javascript/javascriptApplicationAnalysis.js"; +import { createTestTempDirectory } from "../../fixtures/temporaryDirectory.js"; +import { cliTest } from "../../support/cli/cliFixture.js"; + +cliTest( + "reports Webpack exports attached to renamed factory parameters", + async ({ cli }) => { + const root = await createTestTempDirectory("rea-webpack-renamed-exports-"); + await mkdir(join(root, "src")); + await writeFile( + join(root, "src", "chunk.js"), + `globalThis.webpackChunkApp.push([[1], { + 17(module, __webpack_exports__, __webpack_require__) { + __webpack_require__.d(__webpack_exports__, { alpha: () => 1, "beta.gamma": () => 2 }); + Object.defineProperty(__webpack_exports__, "delta", { enumerable: true, get() { return 3; } }); + __webpack_require__.d(__webpack_exports__.nested, { nested_target: () => 0 }); + __webpack_exports__ = { rebound: () => 0 }; + __webpack_require__.d({}, { unrelated: () => 0 }); + { const exports = {}; __webpack_require__.d(exports, { shadowed_exports: () => 5 }); } + }, + 23(m, e, r) { r.d(e, { alpha: () => 1 }); } + }]);`, + ); + const response = await cli.run({ + arguments: ["analyze-javascript-application", root, "--format", "json"], + environment: { REA_LOG_LEVEL: "silent" }, + }); + expect(response.exitCode).toBe(0); + const result = javascriptApplicationAnalysisResultSchema.parse( + parseEvidence(response.json).normalized_result, + ); + const modules = result.graph.nodes + .filter(({ kind }) => kind === "javascript-module") + .map(({ observations }) => observations[0]?.properties); + expect(modules).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + module_key: "17", + exports: ["alpha", "beta.gamma", "delta"], + }), + expect.objectContaining({ module_key: "23", exports: ["alpha"] }), + ]), + ); + expect(result.graph.coverage.status).toBe("complete"); + }, +);