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"); + }, +);