From aa79f59478c6a912823d18136d2e116a60f43bcd Mon Sep 17 00:00:00 2001 From: Kaoru0822 Date: Sun, 25 Oct 2026 05:23:23 -0700 Subject: [PATCH] fix(javascript): bind defaulted and destructured Webpack factory roles (#7117) * fix(javascript): bind defaulted and destructured Webpack factory roles A Webpack factory whose module parameter was defaulted (`e = {}`) or destructured (`{exports: x}`) reported an exact empty export list with complete coverage, because roles were taken only from plain identifiers. Bind factory roles once, by position, in javascriptBundlerFactoryRoles. Defaulted module/exports parameters keep their role (the convention always supplies the argument), `{exports: alias}` binds the alias to module.exports, and a repeated sloppy parameter name binds its last argument. Other role patterns (rest, array, nested or computed keys, a non-identifier loader) receive no role and count as an unknown finding, so the file's analysis is partial instead of claiming no exports. The export collector, the factory require name and the standalone bootstrap externals all use these parsed roles. Fixes #7113 Co-Authored-By: Claude Opus 5.5 * fix(javascript): keep factory receivers through unordered reads A defaulted role parameter kept the unused default in the binding facts passed to write ordering, so a receiver read inside a loop, nested block or closure was unresolved and its export silently dropped while the module stayed complete. The role owner now removes defaults the calling convention never runs before ordering. Separately, an export-shaped write through a role binding whose read stays unordered now counts as an unknown finding instead of being dropped, so an incomplete list is never reported as complete. Co-Authored-By: Claude Opus 5.5 * fix(javascript): treat never-run factory parameter defaults as dead code Removing unused defaults from binding facts let writes inside a default expression reach the export collector: `(e = {}, t = (() => { e.exports.ghost = 1; })())` reported `ghost` although Webpack supplies both arguments. Loader calls in such defaults also produced require references and async chunk keys. The role owner now records the default of every role parameter, any binding pattern included, as never run. The export collector, the async-chunk walk and the static source traversal skip those subtrees, so they yield no exports, references or chunk keys. Defaults of later, non-role parameters still run and are still reported. Co-Authored-By: Claude Opus 5.5 --------- Co-authored-by: Claude Opus 5.5 --- docs/javascript-artifact-reconstruction.md | 13 +- .../javascript/javascriptAstFingerprint.ts | 160 ++++++++----- .../javascriptBundledRequireOrigin.ts | 36 +-- .../javascriptBundlerFactoryRoles.ts | 149 ++++++++++++ .../javascriptFactoryExportRoles.test.ts | 212 ++++++++++++++++++ .../javascript/javascriptStaticAnalysis.ts | 2 + .../javascriptStaticAnalysisBundler.ts | 53 ++--- .../javascriptStaticAnalysisHelpers.ts | 9 - .../javascriptStaticAnalysisState.ts | 7 +- .../webpackRenamedExportsCli.test.ts | 47 ++++ 10 files changed, 569 insertions(+), 119 deletions(-) create mode 100644 src/domain/javascript/javascriptBundlerFactoryRoles.ts diff --git a/docs/javascript-artifact-reconstruction.md b/docs/javascript-artifact-reconstruction.md index 4a4af6564a..f7eaedaf45 100644 --- a/docs/javascript-artifact-reconstruction.md +++ b/docs/javascript-artifact-reconstruction.md @@ -426,8 +426,17 @@ and links whose callee was renamed by a minifier are not recognized. Bundler adapters own callback roles: Webpack supplies module, exports, then loader; esbuild CommonJS supplies exports, then module. Export extraction uses those binding identities for assignments and declarations, so renaming either -parameter does not change the recovered names. Global property-definition -intrinsics require lexical and visible-write proof. Factory export declarations +parameter does not change the recovered names. The convention always supplies +every role argument, so a role parameter's default never runs: a defaulted +module or exports parameter keeps its role, and the default expression yields +no exports, references or chunk keys. `{exports: alias}` in the module position +binds `alias` to `module.exports`. +Other role patterns, such as rest, array, nested or computed-key patterns or a +non-identifier loader, receive no role and count as unknown findings, so the +file's analysis is partial rather than an exact empty export list. An +export-shaped write through a role binding whose value at that read cannot be +ordered against a replacement is also an unknown finding. Global +property-definition intrinsics require lexical and visible-write proof. Factory export declarations also require the loader's `d` member to have no prior or unordered visible replacement, including writes through lexical aliases. Unrelated loader members do not invalidate declarations. Ordering describes declarations within one factory diff --git a/src/domain/javascript/javascriptAstFingerprint.ts b/src/domain/javascript/javascriptAstFingerprint.ts index ecf4b7405a..861f7651ab 100644 --- a/src/domain/javascript/javascriptAstFingerprint.ts +++ b/src/domain/javascript/javascriptAstFingerprint.ts @@ -10,6 +10,10 @@ import { traverseJavaScriptAst } from "./javascriptSemanticTraversal.js"; import { compareUnicodeCodePoints } from "../unicodeCodePointOrder.js"; import { semanticStaticPropertyKey } from "./javascriptAstValues.js"; import { stringValue } from "./javascriptStaticAnalysisHelpers.js"; +import { + withoutUnusedFactoryDefaults, + type JavaScriptBundlerFactoryRoles, +} from "./javascriptBundlerFactoryRoles.js"; import { resolveSemanticBindingState, type JavaScriptBindingIndex, @@ -18,6 +22,8 @@ import { /** Static CommonJS export names. */ export interface StaticExports { readonly values: readonly string[]; + /** Export-shaped writes whose receiver role could not be resolved. */ + readonly unknown: number; } /** Derive a rename-resistant syntax fingerprint without code execution. */ @@ -32,18 +38,14 @@ export const fingerprintJavaScriptAst = (node: t.Node): string => { return createHash("sha256").update(JSON.stringify(tokens)).digest("hex"); }; -/** Calling-convention roles established by the bundler adapter. */ -export interface JavaScriptFactoryExportBindings { - readonly exports: t.Identifier | undefined; - readonly module: t.Identifier | undefined; - readonly loader: t.Identifier | undefined; -} - /** Collect statically declared CommonJS/bundler export names from one factory. */ export const collectJavaScriptExports = ( node: t.Node, factory?: { - readonly roles: JavaScriptFactoryExportBindings; + readonly roles: Pick< + JavaScriptBundlerFactoryRoles, + "module" | "exports" | "loader" | "unusedDefaults" + >; readonly index: Pick< JavaScriptBindingIndex, "bindings" | "effectsForReferenceWithinActivationAt" @@ -54,47 +56,61 @@ export const collectJavaScriptExports = ( const index = factory?.index; const bindings = index?.bindings; const exports = new Set(); - const bindingId = ( - reference: t.Node | null | undefined, - ): string | undefined => - bindings !== undefined && t.isIdentifier(reference) - ? resolveSemanticBindingState(bindings, reference, reference.name) - ?.bindingId - : undefined; + let unknown = 0; + const bindingId = (reference: t.Identifier): string | undefined => + bindings === undefined + ? undefined + : resolveSemanticBindingState(bindings, reference, reference.name) + ?.bindingId; const identity = ( - role: keyof JavaScriptFactoryExportBindings, + parameters: readonly (t.Identifier | undefined)[], fallback: string, ) => { - const id = bindingId(roles?.[role]); - return (reference: t.Node | null): boolean => { - if (!t.isIdentifier(reference)) return false; - if (roles === undefined) return reference.name === fallback; - if (id === undefined || bindings === undefined) return false; + const ids = new Set( + parameters.flatMap((parameter) => { + const id = parameter === undefined ? undefined : bindingId(parameter); + return id === undefined ? [] : [id]; + }), + ); + return (reference: t.Node | null): ReceiverRole => { + if (!t.isIdentifier(reference)) return undefined; + if (roles === undefined) + return reference.name === fallback ? "role" : undefined; + if (bindings === undefined) return undefined; const binding = resolveSemanticBindingState( bindings, reference, reference.name, ); - if (binding?.bindingId !== id) return false; + if (binding === undefined || !ids.has(binding.bindingId)) + return undefined; // The calling convention owns the parameter's entry value, not every // later value assigned to the same lexical binding. const read = semanticBindingAtRead( - binding, + withoutUnusedFactoryDefaults(binding, roles), reference, bindings.parentsByNode, ); - return ( - read.status === "ordered" && - read.binding.initializers.length === 0 && + if (read.status !== "ordered") return "unresolved"; + return read.binding.initializers.length === 0 && read.binding.referenceInitializers.length === 0 - ); + ? "role" + : undefined; }; }; - const isExportsAlias = identity("exports", "exports"); - const isModuleAlias = identity("module", "module"); - const isLoaderAlias = identity("loader", "__webpack_require__"); - const receivers = { isExportsAlias, isModuleAlias }; + const isExportsAlias = identity(roles?.exports ?? [], "exports"); + const isModuleAlias = identity([roles?.module], "module"); + const isLoaderAlias = identity([roles?.loader], "__webpack_require__"); + const receivers: ExportReceivers = { + isExportsAlias, + isModuleAlias, + unresolved: () => { + unknown += 1; + }, + }; traverseJavaScriptAst(node, { + // A default the calling convention never runs declares no exports. + skip: (current) => roles?.unusedDefaults.has(current) === true, enter: (current) => { if (t.isAssignmentExpression(current)) collectAssignmentExports( @@ -111,31 +127,40 @@ export const collectJavaScriptExports = ( "defineProperty", ]); collectCallExports(current, exports, receivers, intrinsic, (root) => { - if (root === null || !isLoaderAlias(root)) return false; - if (index === undefined) return true; + const loader = isLoaderAlias(root); + if (loader !== "role" || root === null) return loader; + if (index === undefined) return "role"; const facts = index.effectsForReferenceWithinActivationAt( current, root, ["d"], ); - return ( - facts.memberEffectsIndexed && + return facts.memberEffectsIndexed && !facts.effects.some( (effect) => effect.kind === "member-write" && effect.timing !== "after", ) - ); + ? "role" + : undefined; }); } return undefined; }, }); - return { values: [...exports].sort(compareUnicodeCodePoints) }; + return { values: [...exports].sort(compareUnicodeCodePoints), unknown }; }; +/** + * `role` holds the parameter's entry value; `unresolved` reads its binding + * where write order is unknown, so the write may or may not be an export. + */ +type ReceiverRole = "role" | "unresolved" | undefined; + interface ExportReceivers { - readonly isExportsAlias: (root: t.Node | null) => boolean; - readonly isModuleAlias: (root: t.Node | null) => boolean; + readonly isExportsAlias: (root: t.Node | null) => ReceiverRole; + readonly isModuleAlias: (root: t.Node | null) => ReceiverRole; + /** Record an export-shaped write whose receiver could not be resolved. */ + readonly unresolved: () => void; } const semanticTokens = (node: t.Node): string[] => { @@ -204,19 +229,24 @@ const collectAssignmentExports = ( left: t.LVal | t.OptionalMemberExpression, right: t.Expression, output: Set, - { isExportsAlias, isModuleAlias }: ExportReceivers, + { isExportsAlias, isModuleAlias, unresolved }: ExportReceivers, ): 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 && exportsAlias) return; - const exportOffset = exportsAlias - ? 1 - : isModuleAlias(root) && path[1] === "exports" - ? 2 - : null; + if (path.length === 1) return; + const exportsAlias = isExportsAlias(root); + const moduleAlias = + exportsAlias === undefined && path[1] === "exports" + ? isModuleAlias(root) + : undefined; + if (exportsAlias === "unresolved" || moduleAlias === "unresolved") { + unresolved(); + return; + } + const exportOffset = + exportsAlias === "role" ? 1 : moduleAlias === "role" ? 2 : null; if (exportOffset === null) return; if (path.length === exportOffset) { if (t.isObjectExpression(right)) collectObjectKeys(right, output); @@ -232,27 +262,37 @@ const collectAssignmentExports = ( const collectCallExports = ( call: t.CallExpression, output: Set, - { isExportsAlias, isModuleAlias }: ExportReceivers, + { isExportsAlias, isModuleAlias, unresolved }: ExportReceivers, intrinsic: boolean, - isLoaderAlias: (root: t.Node | null) => boolean, + isLoaderAlias: (root: t.Node | null) => ReceiverRole, ): void => { const callee = memberPath(call.callee); if (callee === null) return; const definePropertyCall = intrinsic; const declarationCall = - callee.length === 2 && - callee[1] === "d" && - isLoaderAlias(memberRoot(call.callee)); - if (!definePropertyCall && !declarationCall) return; + !definePropertyCall && callee.length === 2 && callee[1] === "d" + ? isLoaderAlias(memberRoot(call.callee)) + : undefined; + if (!definePropertyCall && declarationCall === undefined) return; const targetNode = call.arguments[0]; const target = memberPath(targetNode); - const exportTarget = - isExportsAlias(targetNode ?? null) || - (target !== null && - target.length === 2 && - isModuleAlias(memberRoot(targetNode)) && - target[1] === "exports"); - if (!exportTarget) return; + const exportsAlias = isExportsAlias(targetNode ?? null); + const moduleAlias = + exportsAlias === undefined && + target !== null && + target.length === 2 && + target[1] === "exports" + ? isModuleAlias(memberRoot(targetNode)) + : undefined; + if (exportsAlias === undefined && moduleAlias === undefined) return; + if ( + exportsAlias === "unresolved" || + moduleAlias === "unresolved" || + declarationCall === "unresolved" + ) { + unresolved(); + return; + } if (definePropertyCall) { const name = stringValue(call.arguments[1]); if (name !== undefined) addExport(output, name); diff --git a/src/domain/javascript/javascriptBundledRequireOrigin.ts b/src/domain/javascript/javascriptBundledRequireOrigin.ts index 76fd433f23..bed7e9da70 100644 --- a/src/domain/javascript/javascriptBundledRequireOrigin.ts +++ b/src/domain/javascript/javascriptBundledRequireOrigin.ts @@ -1,6 +1,7 @@ import * as t from "@babel/types"; import { javascriptBootstrapAt } from "./javascriptBootstrap.js"; +import { javascriptBundlerFactoryRoles } from "./javascriptBundlerFactoryRoles.js"; import { proveJavaScriptBootstrapIntegrity } from "./javascriptBootstrapIntegrity.js"; import { semanticMemberChain, @@ -30,6 +31,9 @@ import { traverseJavaScriptValueAst, } from "./javascriptSemanticTraversal.js"; +/** A standalone Webpack bootstrap uses the module, exports, loader convention. */ +const BOOTSTRAP_RUNTIME = "webpack-bootstrap"; + /** Loader bindings and literal externals proven for one standalone bootstrap. */ interface BootstrapExternals { /** The runtime loader and every factory parameter it is passed as. */ @@ -367,14 +371,18 @@ const proveBootstrapExternals = ( }); for (const { factory } of factories) { // The bootstrap proves `table[key](module, module.exports, loader)`. - const loaderParameter = factory.params[2]; - const bindingId = t.isIdentifier(loaderParameter) - ? resolveSemanticBindingState( - state, - loaderParameter, - loaderParameter.name, - )?.bindingId - : undefined; + const loaderParameter = javascriptBundlerFactoryRoles( + factory, + BOOTSTRAP_RUNTIME, + ).loader; + const bindingId = + loaderParameter === undefined + ? undefined + : resolveSemanticBindingState( + state, + loaderParameter, + loaderParameter.name, + )?.bindingId; if ( bindingId !== undefined && singleDefinition(state, bindingId, "parameter") @@ -416,14 +424,12 @@ const externalSpecifier = ( factory: t.FunctionExpression | t.ArrowFunctionExpression | t.ObjectMethod, state: JavaScriptSemanticAnalysisState, ): string | undefined => { - const moduleParameter = factory.params[0]; - const names = factory.params.map((parameter) => - t.isIdentifier(parameter) ? parameter.name : null, - ); + const moduleParameter = javascriptBundlerFactoryRoles( + factory, + BOOTSTRAP_RUNTIME, + ).module; if ( - !t.isIdentifier(moduleParameter) || - // A duplicated name binds the later argument, not the module object. - new Set(names).size !== names.length || + moduleParameter === undefined || factory.generator || factory.async || (t.isObjectMethod(factory) && factory.kind !== "method") diff --git a/src/domain/javascript/javascriptBundlerFactoryRoles.ts b/src/domain/javascript/javascriptBundlerFactoryRoles.ts new file mode 100644 index 0000000000..56d696a180 --- /dev/null +++ b/src/domain/javascript/javascriptBundlerFactoryRoles.ts @@ -0,0 +1,149 @@ +import * as t from "@babel/types"; + +import { + runtimeFunctionParameters, + semanticStaticPropertyKey, +} from "./javascriptAstValues.js"; +import type { JavaScriptSemanticBindingState } from "./javascriptSemanticState.js"; + +/** Calling-convention roles bound once from one bundler factory's parameters. */ +export interface JavaScriptBundlerFactoryRoles { + /** The parameter bound to the module object. */ + readonly module: t.Identifier | undefined; + /** Every parameter binding whose entry value is `module.exports`. */ + readonly exports: readonly t.Identifier[]; + /** The parameter bound to the bundler loader (`__webpack_require__`). */ + readonly loader: t.Identifier | undefined; + /** + * Default expressions of role parameters and of `{exports: alias}` targets. + * The convention always supplies the module, its exports object and the + * loader, so these never run and yield no facts. + */ + readonly unusedDefaults: ReadonlySet; + /** + * False when a role parameter uses a pattern whose bindings cannot be + * matched exactly, such as a rest element or a computed or nested key. + * Facts read through the roles are then incomplete, not absent. + */ + readonly exact: boolean; +} + +type FactoryRole = "module" | "exports" | "loader"; + +/** Positional factory roles of a recognized bundler runtime. */ +const roleOrder = (runtime: string): readonly FactoryRole[] => + runtime === "esbuild-commonjs" + ? ["exports", "module"] + : runtime.startsWith("esbuild-") + ? [] + : ["module", "exports", "loader"]; + +/** Bind a factory's role parameters by position, never by spelling. */ +export const javascriptBundlerFactoryRoles = ( + factory: t.Function, + runtime: string, +): JavaScriptBundlerFactoryRoles => { + const parameters = runtimeFunctionParameters(factory); + const roles: { + module: t.Identifier | undefined; + exports: t.Identifier[]; + loader: t.Identifier | undefined; + } = { module: undefined, exports: [], loader: undefined }; + const unusedDefaults = new Set(); + let exact = true; + const bindingIdentifier = (node: t.Node): t.Identifier | undefined => { + if (t.isIdentifier(node)) return node; + if (t.isAssignmentPattern(node) && t.isIdentifier(node.left)) { + unusedDefaults.add(node.right); + return node.left; + } + return undefined; + }; + for (const [index, role] of roleOrder(runtime).entries()) { + const parameter = parameters[index]; + if (parameter === undefined) break; + // A rest element collects this and every later argument into an array. + if (t.isRestElement(parameter)) { + exact = false; + break; + } + // The convention always supplies every role argument, so a role + // parameter's default is dead code whatever its binding pattern. + const pattern = t.isAssignmentPattern(parameter) + ? parameter.left + : parameter; + if (t.isAssignmentPattern(parameter)) unusedDefaults.add(parameter.right); + // Loader consumers resolve its calls through the binding's own value + // facts, which cannot yet discount a default the convention never runs. + if ( + t.isIdentifier(pattern) && + (role !== "loader" || pattern === parameter) + ) { + // A repeated simple parameter name binds its last argument. + if (boundLater(parameters, index, pattern.name)) continue; + if (role === "exports") roles.exports.push(pattern); + else roles[role] = pattern; + continue; + } + if (role === "module" && t.isObjectPattern(pattern)) { + if (!moduleExportsAliases(pattern, bindingIdentifier, roles.exports)) + exact = false; + continue; + } + exact = false; + } + return { ...roles, unusedDefaults, exact }; +}; + +/** `{exports: alias}` binds `module.exports`; other static keys cannot reach it. */ +const moduleExportsAliases = ( + pattern: t.ObjectPattern, + bindingIdentifier: (node: t.Node) => t.Identifier | undefined, + output: t.Identifier[], +): boolean => { + for (const property of pattern.properties) { + // A shallow rest copy keeps the `exports` property's object. + if (t.isRestElement(property)) return false; + const key = semanticStaticPropertyKey(property.key, property.computed); + if (key === null) return false; + if (key !== "exports") continue; + const alias = bindingIdentifier(property.value); + if (alias === undefined) return false; + output.push(alias); + } + return true; +}; + +const boundLater = ( + parameters: t.Function["params"], + index: number, + name: string, +): boolean => + parameters + .slice(index + 1) + .some((parameter) => + Object.hasOwn(t.getBindingIdentifiers(parameter), name), + ); + +/** + * A role parameter's binding facts at entry. A default the convention never + * runs is not a possible value, so write ordering must not see it either. + */ +export const withoutUnusedFactoryDefaults = ( + binding: JavaScriptSemanticBindingState, + roles: Pick, +): JavaScriptSemanticBindingState => { + const unused = ({ + node, + assignment, + }: JavaScriptSemanticBindingState["referenceInitializers"][number]) => + assignment !== true && roles.unusedDefaults.has(node); + return binding.referenceInitializers.some(unused) + ? { + ...binding, + referenceInitializers: binding.referenceInitializers.filter( + (initializer) => !unused(initializer), + ), + } + : binding; +}; diff --git a/src/domain/javascript/javascriptFactoryExportRoles.test.ts b/src/domain/javascript/javascriptFactoryExportRoles.test.ts index 0cecbad025..045c2d715f 100644 --- a/src/domain/javascript/javascriptFactoryExportRoles.test.ts +++ b/src/domain/javascript/javascriptFactoryExportRoles.test.ts @@ -62,3 +62,215 @@ it.each([ "real", ]); }); + +const webpackChunk = (factory: string): string => + `globalThis.webpackChunkApp.push([[1], {17${factory}}]);`; + +/** Run the inert fixture factory under Webpack's calling convention. */ +const conventionExports = (factory: string): string[] => { + let table: Record void> = {}; + runInNewContext(webpackChunk(factory), { + globalThis: { + webpackChunkApp: { + push: ([, modules]: [unknown, typeof table]) => { + table = modules; + }, + }, + }, + }); + const module = { exports: {} }; + const loader = { + d: (target: object, getters: Record unknown>) => { + for (const [name, get] of Object.entries(getters)) + Object.defineProperty(target, name, { enumerable: true, get }); + }, + }; + table["17"]?.call(module.exports, module, module.exports, loader); + return Object.getOwnPropertyNames(module.exports).sort(); +}; + +it.each([ + "(e, t) { e.exports = {parse: value => value}; }", + "(e, t) { e.exports.member = 1; }", + "(e, t) { e['exports']['computed'] = 1; }", + "(e, t, n) { Object.defineProperty(e.exports, 'defined', {value: 1}); }", + "(e, t, n) { n.d(e.exports, {declared: () => 1}); }", + ": (e, t) => { e.exports = {arrow: 1}; }", + ": function (e, t) { e.exports = {expression: 1}; }", + "(e, t) { function inner(e) { e.exports = {ghost: 1}; } e.exports.outer = 1; }", + "(e, t) { { let e = {}; e.exports = {ghost: 1}; } }", + "(e, t) { e = {exports: {}}; e.exports.ghost = 1; }", + "(m, module) { module.exports = {ghost: 1}; }", + "() {}", + "(e = {}, t) { e.exports = {defaulted: 1}; }", + "(e = {exports: {}}, t) { e.exports.defaultedMember = 1; }", + "(e = {}, t) { e = {exports: {}}; e.exports.ghost = 1; }", + "(e, t = {}) { t.defaultedExports = 1; }", + "({exports: x}, t) { x.destructured = 1; }", + "({exports}, t) { exports.shorthand = 1; }", + "({exports: x = {}}, t) { x.destructuredDefault = 1; }", + "({'exports': x}, t) { x.stringKey = 1; }", + "({id, exports: x}, t) { x.withOther = 1; }", + "({id}, t) { t.otherKeyOnly = 1; }", + "({exports: x}, t) { x = {}; x.ghost = 1; }", + "({exports: x}, t) { function inner(x) { x.ghost = 1; } x.real = 1; }", + "({exports: x}, t, n) { n.d(x, {declared: () => 1}); Object.defineProperty(x, 'defined', {value: 2}); }", +])("binds factory roles by position for 17%s", (factory) => { + const analysis = analyzeJavaScriptStaticSource(webpackChunk(factory)); + expect({ + exports: analysis.bundler_registrations[0]?.modules[0]?.exports, + status: analysis.parse_status, + }).toEqual({ exports: conventionExports(factory), status: "complete" }); +}); + +it("ignores unordered replacements of a non-export member write", () => { + const analysis = analyzeJavaScriptStaticSource( + webpackChunk("(e, t) { e.id = 1; for (const k of [1]) { e = {}; } }"), + ); + expect(analysis.bundler_registrations[0]?.modules[0]?.exports).toEqual([]); + expect(analysis.parse_status).toBe("complete"); +}); + +it("binds a repeated sloppy parameter name to its last argument", () => { + const factory = + ": function (e, t, e) { e.exports = {ghost: 1}; t.second = 1; }"; + const analysis = analyzeJavaScriptStaticSource(webpackChunk(factory)); + // Module source is strict, so the parser recovers from the name clash. + expect(analysis.analysis_coverage?.syntax.status).toBe("recovered"); + expect(analysis.bundler_registrations[0]?.modules[0]).toMatchObject({ + exports: conventionExports(factory), + factory_require_name: "e", + }); +}); + +const receiverParameters = [ + ["(e, t)", "e.exports"], + ["(e = {}, t)", "e.exports"], + ["(e, t = {})", "t"], + ["({exports: x}, t)", "x"], +] as const; +const controlFlowWrites = [ + "for (const key of ['x']) { R.forOf = 1; }", + "for (let i = 0; i < 1; i++) { R.forLoop = 1; }", + "for (const key in {a: 1}) { R.forIn = 1; }", + "let i = 0; while (i++ < 1) { R.whileLoop = 1; }", + "do { R.doWhile = 1; } while (false);", + "try { R.tryBlock = 1; } finally {}", + "{ { R.nestedBlock = 1; } }", + "const later = () => { R.arrowClosure = 1; }; later();", + "function later() { R.declaredClosure = 1; } later();", + "if ([1].length > 0) R.conditional = 1;", +]; + +it.each( + receiverParameters.flatMap(([parameters, receiver]) => + controlFlowWrites.map((body) => [ + parameters, + body.replaceAll("R.", `${receiver}.`), + ]), + ), +)("keeps the entry receiver of 17%s through %s", (parameters, body) => { + const factory = `${parameters} { ${body} }`; + const analysis = analyzeJavaScriptStaticSource(webpackChunk(factory)); + expect({ + exports: analysis.bundler_registrations[0]?.modules[0]?.exports, + status: analysis.parse_status, + }).toEqual({ exports: conventionExports(factory), status: "complete" }); +}); + +it.each([ + "(...a) { a[0].exports.rest = 1; }", + "(e, ...r) { r[0].restExports = 1; }", + "({...rest}, t) { rest.exports.objectRest = 1; }", + "({exports: {inner}}, t) { t.nested = 1; }", + "({[k]: x}, t) { t.computedKey = 1; }", + "([m], t) { t.arrayPattern = 1; }", + "(e, {a}) { e.exports.exportsPattern = 1; }", + "(e, t, n = {}) { n.d(t, {defaultedLoader: () => 1}); }", + "(e, t, {d}) { d(t, {destructuredLoader: () => 1}); }", + "(e, t) { for (const k of [1]) { e.exports.beforeReplace = 1; e = {exports: {}}; } }", + "(e = {}, t) { for (const k of [1]) { e.exports.beforeReplace = 1; e = {exports: {}}; } }", + "(e, t) { for (const k of [1]) { t.beforeReplace = 1; t = {}; } }", + "(e, t, n) { for (const k of [1]) { n.d(t, {beforeReplace: () => 1}); n = {d() {}}; } }", + "(e, t) { if ([1].length > 0) e = {exports: {}}; e.exports.afterConditional = 1; }", + "(e, t) { const later = () => { e.exports.closure = 1; }; e = {exports: {}}; later(); }", +])("reports unresolved factory role or receiver 17%s as unknown", (factory) => { + const analysis = analyzeJavaScriptStaticSource(webpackChunk(factory)); + expect(analysis.bundler_registrations[0]?.modules).toHaveLength(1); + expect(analysis.parse_status).toBe("partial"); + expect(analysis.analysis_coverage?.semantics.unknown_findings).toBe(1); +}); + +/** Exports plus every loader call made under Webpack's calling convention. */ +const conventionRun = (factory: string) => { + let table: Record void> = {}; + runInNewContext(webpackChunk(factory), { + globalThis: { + webpackChunkApp: { + push: ([, modules]: [unknown, typeof table]) => { + table = modules; + }, + }, + }, + }); + const loads: string[] = []; + const chunks: string[] = []; + const loader = Object.assign( + (id: unknown) => { + loads.push(String(id)); + return {}; + }, + { + d: (target: object, getters: Record unknown>) => { + for (const [name, get] of Object.entries(getters)) + Object.defineProperty(target, name, { enumerable: true, get }); + }, + e: (id: unknown) => { + chunks.push(String(id)); + }, + }, + ); + const module = { exports: {} }; + table["17"]?.call(module.exports, module, module.exports, loader); + return { + exports: Object.getOwnPropertyNames(module.exports).sort(), + references: [...loads, ...chunks.map((id) => `chunk:${id}`)].sort(), + async_chunk_keys: chunks.sort(), + }; +}; + +it.each([ + [ + "(e = {}, t = (() => { for (const k of ['x']) { e.exports.ghost = 1; } })()) {}", + "complete", + ], + ["(e = (t.ghost = 1), t) {}", "complete"], + [ + "(e, t = Object.defineProperty(e.exports, 'ghost', {value: 1})) {}", + "complete", + ], + ["(e = n.d(t, {ghost: () => 1}), t, n) {}", "complete"], + ["(e = n(5), t, n) { t.real = 1; }", "complete"], + ["(e = n.e(7), t, n) { t.real = 1; }", "complete"], + ["(e, t = (n(8), {}), n) { t.real = 1; }", "complete"], + [ + "({exports: x} = (n.d(t, {ghost: () => 1}), {exports: {}}), t, n) { x.real = 1; }", + "complete", + ], + ["({exports: x = (n(6), {ghost: 1})}, t, n) { x.real = 1; }", "complete"], + ["(e, t, n, r = n(5)) { t.real = n.e(6); }", "complete"], + ["(e, t, n, r = (t.later = 1)) {}", "complete"], + ["(e, t, n = (t.ghost = 1, n(9), () => {})) { t.real = 1; }", "partial"], + ["(e, {a} = (e.exports.ghost = 1, {})) { e.exports.real = 1; }", "partial"], +])("yields no facts from never-run defaults in 17%s", (factory, status) => { + const analysis = analyzeJavaScriptStaticSource(webpackChunk(factory)); + const registration = analysis.bundler_registrations[0]; + expect({ + exports: registration?.modules[0]?.exports, + references: analysis.references + .map(({ specifier, expression }) => specifier ?? expression) + .sort(), + async_chunk_keys: registration?.async_chunk_keys, + status: analysis.parse_status, + }).toEqual({ ...conventionRun(factory), status }); +}); diff --git a/src/domain/javascript/javascriptStaticAnalysis.ts b/src/domain/javascript/javascriptStaticAnalysis.ts index 0bb911ceb2..416eecf3c3 100644 --- a/src/domain/javascript/javascriptStaticAnalysis.ts +++ b/src/domain/javascript/javascriptStaticAnalysis.ts @@ -176,6 +176,8 @@ function* traverseStaticSourceSteps( routeScan: JavaScriptRouteScan, ): Generator { yield* traverseJavaScriptAstSteps(file, { + // Code that never runs under the bundler convention yields no findings. + skip: (node) => findings.accumulator.unusedFactoryDefaults.has(node), enter: (node, parent) => { findings.accumulator.visitedNodes += 1; routeScan.inspect(node, parent); diff --git a/src/domain/javascript/javascriptStaticAnalysisBundler.ts b/src/domain/javascript/javascriptStaticAnalysisBundler.ts index b0b1bf37ea..dfb3f7cb74 100644 --- a/src/domain/javascript/javascriptStaticAnalysisBundler.ts +++ b/src/domain/javascript/javascriptStaticAnalysisBundler.ts @@ -10,6 +10,7 @@ import { semanticFactoryLoaderMemberAt } from "./javascriptSemanticRequireOrigin import { traverseJavaScriptAst } from "./javascriptSemanticTraversal.js"; +import { javascriptBundlerFactoryRoles } from "./javascriptBundlerFactoryRoles.js"; import { collectJavaScriptExports, fingerprintJavaScriptAst, @@ -20,7 +21,6 @@ import { argumentValue, calleeName, chunkRuntime, - factoryRequireName, moduleFactory, modulePropertyName, range, @@ -257,44 +257,29 @@ const recoverBundlerModule = ( if (key.startsWith("[computed@") || key.startsWith("[unknown@")) accumulator.unknownFindings += 1; const fingerprint = fingerprintJavaScriptAst(factory); - const parameters = runtimeFunctionParameters(factory); - const esbuild = runtime.startsWith("esbuild-"); - const identifierAt = (index: number): t.Identifier | undefined => { - const parameter = parameters[index]; - return t.isIdentifier(parameter) ? parameter : undefined; - }; - const roles = { - exports: - runtime === "esbuild-commonjs" - ? identifierAt(0) - : esbuild - ? undefined - : identifierAt(1), - module: - runtime === "esbuild-commonjs" - ? identifierAt(1) - : esbuild - ? undefined - : identifierAt(0), - loader: esbuild ? undefined : identifierAt(2), - }; + const roles = javascriptBundlerFactoryRoles(factory, runtime); + // An inexact role pattern leaves this module's export list incomplete. + if (!roles.exact) accumulator.unknownFindings += 1; + // Registration is inspected before its factories, so the source traversal + // skips these never-run defaults when it reaches them. + for (const unused of roles.unusedDefaults) + accumulator.unusedFactoryDefaults.add(unused); const exports: string[] = []; accumulator.pendingBundlerExports.push({ factory, roles, exports }); - const requireName = esbuild ? null : factoryRequireName(factory); - const requireParameter = esbuild ? undefined : parameters[2]; - const requireBindingId = t.isIdentifier(requireParameter) - ? (resolveSemanticBindingState( - bindings, - requireParameter, - requireParameter.name, - )?.bindingId ?? null) - : null; + const loader = roles.loader; + const requireName = loader?.name ?? null; + const requireBindingId = + loader === undefined + ? null + : (resolveSemanticBindingState(bindings, loader, loader.name) + ?.bindingId ?? null); if (requireBindingId !== null) accumulator.factoryRequireBindingIds.add(requireBindingId); const asyncChunks = collectBundlerAsyncChunkKeys( factory, requireBindingId, bindings, + roles.unusedDefaults, ); if (typeof factory.start === "number" && typeof factory.end === "number") { const moduleRange = { @@ -393,6 +378,7 @@ const collectBundlerAsyncChunkKeys = ( factory: BundlerFunction, requireBindingId: string | null, bindings: JavaScriptSemanticAnalysisState, + unusedDefaults: ReadonlySet, ): StaticValues => requireBindingId === null ? { values: [], unknown: 0 } @@ -401,6 +387,7 @@ const collectBundlerAsyncChunkKeys = ( requireBindingId, bindings, "e", + unusedDefaults, ); const collectBundlerCallArgumentValues = ( @@ -408,12 +395,14 @@ const collectBundlerCallArgumentValues = ( bindingId: string | null, bindings: JavaScriptSemanticAnalysisState, member: string | null, + unusedDefaults: ReadonlySet = new Set(), ): StaticValues => { if (bindingId === null) return { values: [], unknown: 0 }; const values: string[] = []; let unknown = 0; const bindingIds = new Set([bindingId]); traverseJavaScriptAst(factory, { + skip: (node) => unusedDefaults.has(node), enter: (node) => { if (!t.isCallExpression(node) && !t.isNewExpression(node)) return undefined; @@ -463,5 +452,7 @@ export const finishBundlerExports = ( index, }); for (const value of collected.values) pending.exports.push(value); + // An unordered receiver read leaves the export list incomplete. + accumulator.unknownFindings += collected.unknown; } }; diff --git a/src/domain/javascript/javascriptStaticAnalysisHelpers.ts b/src/domain/javascript/javascriptStaticAnalysisHelpers.ts index df4f39e096..5cc4187e74 100644 --- a/src/domain/javascript/javascriptStaticAnalysisHelpers.ts +++ b/src/domain/javascript/javascriptStaticAnalysisHelpers.ts @@ -13,7 +13,6 @@ import type { } from "./javascriptStaticAnalysisTypes.js"; import { readExactJavaScriptLiteral, - runtimeFunctionParameters, semanticStaticPropertyKey, semanticStaticPropertyName, unwrapJavaScriptExpression, @@ -151,14 +150,6 @@ export const modulePropertyName = ( : `[unknown@${String(property.start ?? -1)}]`; }; -/** Read the factory-local bundler require parameter when declared. */ -export const factoryRequireName = ( - factory: t.FunctionExpression | t.ArrowFunctionExpression | t.ObjectMethod, -): string | null => { - const parameter = runtimeFunctionParameters(factory)[2]; - return t.isIdentifier(parameter) ? parameter.name : null; -}; - /** Collect all unique static literal values from an array. */ export const staticArrayValues = ( array: t.ArrayExpression, diff --git a/src/domain/javascript/javascriptStaticAnalysisState.ts b/src/domain/javascript/javascriptStaticAnalysisState.ts index edbe450d71..e8b6b9bc93 100644 --- a/src/domain/javascript/javascriptStaticAnalysisState.ts +++ b/src/domain/javascript/javascriptStaticAnalysisState.ts @@ -1,5 +1,5 @@ import type * as t from "@babel/types"; -import type { JavaScriptFactoryExportBindings } from "./javascriptAstFingerprint.js"; +import type { JavaScriptBundlerFactoryRoles } from "./javascriptBundlerFactoryRoles.js"; import type { JavaScriptOpenReceiverFact } from "./javascriptSemanticAnalysis.js"; import type { JavaScriptRpcScan } from "./javascriptRpcEndpoints.js"; import type { JavaScriptStaticPathIntrinsicQuery } from "./javascriptStaticAnalysisHelpers.js"; @@ -44,9 +44,11 @@ export interface LocatedJavaScriptFinding { export interface JavaScriptAnalysisAccumulator { readonly pendingBundlerExports: { readonly factory: t.Node; - readonly roles: JavaScriptFactoryExportBindings; + readonly roles: JavaScriptBundlerFactoryRoles; readonly exports: string[]; }[]; + /** Factory parameter defaults the bundler calling convention never runs. */ + readonly unusedFactoryDefaults: Set; readonly pendingModuleRequireCalls: ( | t.CallExpression | t.OptionalCallExpression @@ -160,6 +162,7 @@ export const createJavaScriptAnalysisAccumulator = ( sourceLength: number, ): JavaScriptAnalysisAccumulator => ({ pendingBundlerExports: [], + unusedFactoryDefaults: new Set(), pendingModuleRequireCalls: [], appPathChanges: [], references: [], diff --git a/tests/acceptance/applications/webpackRenamedExportsCli.test.ts b/tests/acceptance/applications/webpackRenamedExportsCli.test.ts index 55ae5087b7..3e3d1d306a 100644 --- a/tests/acceptance/applications/webpackRenamedExportsCli.test.ts +++ b/tests/acceptance/applications/webpackRenamedExportsCli.test.ts @@ -83,3 +83,50 @@ cliTest.for(["CLI", "stdio MCP"] as const)( expect(result.graph.coverage.status).toBe("complete"); }, ); + +cliTest.for(["CLI", "stdio MCP"] as const)( + "reports Webpack exports through defaulted and destructured factory parameters through %s", + async (surface, { cli }) => { + const root = await createTestTempDirectory("rea-webpack-pattern-exports-"); + await mkdir(join(root, "src")); + await writeFile( + join(root, "src", "exact.js"), + `globalThis.webpackChunkApp.push([[1], { + 111(e = {}, t) { e.exports = {defaulted: 1}; }, + 112({exports: x}, t) { x.destructured = 1; }, + 114(e = {}, t) { for (const key of ["x"]) { e.exports.loop = 1; } } + }]);`, + ); + await writeFile( + join(root, "src", "rest.js"), + `globalThis.webpackChunkApp.push([[2], { + 113(...a) { a[0].exports.rest = 1; } + }]);`, + ); + const result = await analyzeJavaScriptDirectory(surface, cli, root); + const modules = Object.fromEntries( + result.graph.nodes + .filter(({ kind }) => kind === "javascript-module") + .flatMap(({ observations: [observation] }) => + typeof observation?.properties.module_key === "string" + ? [ + [ + observation.properties.module_key, + { + exports: observation.properties.exports, + coverage: observation.evidence.coverage.status, + }, + ], + ] + : [], + ), + ); + expect(modules).toEqual({ + "111": { exports: ["defaulted"], coverage: "complete" }, + "112": { exports: ["destructured"], coverage: "complete" }, + "113": { exports: [], coverage: "partial" }, + "114": { exports: ["loop"], coverage: "complete" }, + }); + expect(result.graph.coverage.status).toBe("partial"); + }, +);