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