diff --git a/src/domain/javascript/javascriptAstFingerprint.ts b/src/domain/javascript/javascriptAstFingerprint.ts index 8447de0eb..9b6a83730 100644 --- a/src/domain/javascript/javascriptAstFingerprint.ts +++ b/src/domain/javascript/javascriptAstFingerprint.ts @@ -5,7 +5,10 @@ import * as t from "@babel/types"; import { traverseJavaScriptAst } from "./javascriptSemanticTraversal.js"; import { compareUnicodeCodePoints } from "../unicodeCodePointOrder.js"; -import { propertyName } from "./javascriptAstValues.js"; +import { + propertyName, + semanticStaticPropertyKey, +} from "./javascriptAstValues.js"; import { stringValue } from "./javascriptStaticAnalysisHelpers.js"; /** Static CommonJS export names. */ @@ -71,15 +74,25 @@ const collectAssignmentExports = ( output: Set, ): void => { const path = memberPath(left); - if (path === "exports" || path === "module.exports") { + if (path === null) return; + // 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 (exportOffset === null) return; + if (path.length === exportOffset) { if (t.isObjectExpression(right)) collectObjectKeys(right, output); else addExport(output, "default"); return; } - if (path.startsWith("exports.")) - addExport(output, path.slice("exports.".length)); - if (path.startsWith("module.exports.")) - addExport(output, path.slice("module.exports.".length)); + // A CommonJS export assignment exposes the first property below the + // exports object. Deeper writes mutate that exported value, rather than + // creating an export whose name is the dotted member path. + addExport(output, path[exportOffset]!); }; const collectCallExports = ( @@ -87,19 +100,28 @@ const collectCallExports = ( output: Set, ): void => { const callee = memberPath(call.callee); - if (callee === "Object.defineProperty") { - const target = memberPath(call.arguments[0]); - const name = stringValue(call.arguments[1]); - if ((target === "exports" || target === "module.exports") && name) - addExport(output, name); - } - if (!callee.endsWith(".d")) return; + if (callee === null) return; + const definePropertyCall = + callee.length === 2 && + callee[0] === "Object" && + callee[1] === "defineProperty"; + const declarationCall = callee.length >= 2 && callee.at(-1) === "d"; + if (!definePropertyCall && !declarationCall) return; const target = memberPath(call.arguments[0]); + const exportTarget = + target !== null && + ((target.length === 1 && target[0] === "exports") || + (target.length === 2 && + target[0] === "module" && + target[1] === "exports")); + if (!exportTarget) return; + if (definePropertyCall) { + const name = stringValue(call.arguments[1]); + if (name !== undefined) addExport(output, name); + return; + } const declarations = call.arguments[1]; - if ( - (target === "exports" || target === "module.exports") && - t.isObjectExpression(declarations) - ) + if (t.isObjectExpression(declarations)) collectObjectKeys(declarations, output); }; @@ -109,8 +131,20 @@ const collectObjectKeys = ( ): void => { for (const property of object.properties) { if (!t.isObjectProperty(property) && !t.isObjectMethod(property)) continue; - const name = propertyName(property.key); - if (name !== "") addExport(output, name); + const name = semanticStaticPropertyKey(property.key, property.computed); + // In an object literal, a non-computed `__proto__: value` sets the + // prototype instead of creating an own property. Shorthand, computed, + // and method forms do create own properties. + if ( + name !== null && + !( + t.isObjectProperty(property) && + !property.computed && + !property.shorthand && + name === "__proto__" + ) + ) + addExport(output, name); } }; @@ -118,7 +152,9 @@ const addExport = (output: Set, name: string): void => { output.add(name); }; -const memberPath = (node: t.Node | null | undefined): string => { +const memberPath = ( + node: t.Node | null | undefined, +): readonly string[] | null => { const properties: string[] = []; let current = node; while ( @@ -126,23 +162,19 @@ const memberPath = (node: t.Node | null | undefined): string => { current !== null && (t.isMemberExpression(current) || t.isOptionalMemberExpression(current)) ) { - properties.push(propertyName(current.property)); - current = t.isNode(current.object) ? current.object : undefined; + const property = semanticStaticPropertyKey( + current.property, + current.computed, + ); + if (property === null) return null; + properties.push(property); + current = current.object; } - - const root = - current !== undefined && t.isIdentifier(current) - ? current.name - : current !== undefined && t.isThisExpression(current) - ? "this" - : ""; - if (properties.length === 0) return root; - - properties.reverse(); - const path = root === "" ? [] : [root]; - for (const property of properties) { - if (path.length === 0 && property === "") continue; - path.push(property); - } - return path.join("."); + if (current === undefined || current === null) return null; + const root = t.isIdentifier(current) + ? current.name + : t.isThisExpression(current) + ? "this" + : null; + return root === null ? null : [root, ...properties.reverse()]; }; diff --git a/src/domain/javascript/javascriptStaticAnalysis.member_path_depth.test.ts b/src/domain/javascript/javascriptStaticAnalysis.member_path_depth.test.ts index 3d0313301..4dc7bccff 100644 --- a/src/domain/javascript/javascriptStaticAnalysis.member_path_depth.test.ts +++ b/src/domain/javascript/javascriptStaticAnalysis.member_path_depth.test.ts @@ -10,27 +10,41 @@ const ordinaryMemberChain = ".next".repeat(64); it("collects a 12,000-member CommonJS export path without recursion", () => { const file = parseJavaScriptSource(`exports${memberChain}.last = 1;`); if (file === null) throw new Error("Expected valid JavaScript"); - expect(collectJavaScriptExports(file).values).toEqual([ - `${"next.".repeat(12_000)}last`, - ]); + expect(collectJavaScriptExports(file).values).toEqual(["next"]); }); -it("preserves empty, dynamic, this, and private member path behavior", () => { +it("preserves exact empty keys without inventing dynamic, this, or private exports", () => { const file = parseJavaScriptSource(` exports[""].value = 1; exports[""] = 0; exports[key].value = 2; + exports.nested = {}; + exports.nested.child = 3; + exports["nested.child"] = 4; this.exports.ignored = 3; class Example { #private = 0; read() { this.#private = 1; } } `); if (file === null) throw new Error("Expected valid JavaScript"); expect(collectJavaScriptExports(file).values).toEqual([ "", - ".value", - "key.value", + "nested", + "nested.child", ]); }); +it("distinguishes the object literal prototype setter from own export keys", () => { + const prototypeSetter = parseJavaScriptSource( + "module.exports = { __proto__: {} };", + ); + const ownKeys = parseJavaScriptSource( + 'module.exports = { ["__proto__"]: 1, __proto__() {} };', + ); + if (prototypeSetter === null || ownKeys === null) + throw new Error("Expected valid JavaScript"); + expect(collectJavaScriptExports(prototypeSetter).values).toEqual([]); + expect(collectJavaScriptExports(ownKeys).values).toEqual(["__proto__"]); +}); + it("keeps left-first and outer-member-first bundler runtime matches", () => { const source = ` (globalThis.webpackChunkLeft || globalThis.webpackChunkRight).push([[1], { 1: function() {} }]); diff --git a/tests/acceptance/applications/applicationWorkflowCli.test.ts b/tests/acceptance/applications/applicationWorkflowCli.test.ts index 34719f173..5df8813ea 100644 --- a/tests/acceptance/applications/applicationWorkflowCli.test.ts +++ b/tests/acceptance/applications/applicationWorkflowCli.test.ts @@ -1,5 +1,6 @@ import { execFile } from "node:child_process"; import { copyFile, mkdir, rm, writeFile } from "node:fs/promises"; +import { createRequire } from "node:module"; import { join, relative, resolve } from "node:path"; import { promisify } from "node:util"; @@ -23,7 +24,35 @@ import { import { z } from "zod"; const execute = promisify(execFile); +const requireFixture = createRequire(import.meta.url); const temporary: string[] = []; +const WEBPACK_FACTORY_BODY = ` + const key = "dynamic"; + const __webpack_require__ = { + d(target, definitions) { + for (const name of Object.keys(definitions)) + Object.defineProperty(target, name, { + enumerable: true, + get: definitions[name], + }); + }, + }; + exports[key] = 1; + exports["dot.key"] = 2; + exports[""] = 3; + exports.nested = {}; + exports.nested.child = 6; + exports["nested.child"] = 7; + module.exports["module.dot"] = 5; + module["exports.decoy"] = 99; + Object.defineProperty(exports, "", { + value: 4, + configurable: true, + enumerable: true, + }); + __webpack_require__.d(exports, { "from.helper": () => 7 }); + exports = function ignored() { return 8; }; +`; afterEach(async () => { await Promise.all( @@ -441,6 +470,160 @@ describe("empty property key application CLI", () => { ); }); +describe("Webpack factory CommonJS export keys", () => { + it("keeps exact static keys and omits computed names through CLI and MCP", async () => { + const root = await createTestTempDirectory("rea-webpack-export-keys-"); + temporary.push(root); + const bundle = `globalThis.webpackChunkStaticExports.push([["main"], { + 1: function(module, exports, __webpack_require__) {${WEBPACK_FACTORY_BODY}}, + 2: function(module) { module.exports = function realDefault() {}; }, + 3: function(module, exports) { exports = function ignoredDefault() {}; }, + 4: function(module) { + const key = "dynamic"; + module.exports = { [key]: 1, stable: 2 }; + }, + 5: function(module) { module.exports = { __proto__: {} }; }, + 6: function(module) { + module.exports = { ["__proto__"]: 1, __proto__() {} }; + } + }]);`; + await writeFile(join(root, "bundle.js"), bundle); + + // Execute the same factory body as CommonJS, outside the analyzer, to + // verify Node's alias behavior and the exact runtime object keys. + const oracleRoot = await createTestTempDirectory( + "rea-webpack-export-oracle-", + ); + temporary.push(oracleRoot); + const oraclePath = join(oracleRoot, "oracle.cjs"); + await writeFile(oraclePath, WEBPACK_FACTORY_BODY); + const actual = requireFixture(oraclePath) as Record; + expect(Reflect.ownKeys(actual).sort()).toEqual([ + "", + "dot.key", + "dynamic", + "from.helper", + "module.dot", + "nested", + "nested.child", + ]); + expect(actual).toMatchObject({ + "": 4, + "dot.key": 2, + dynamic: 1, + "from.helper": 7, + "module.dot": 5, + nested: { child: 6 }, + "nested.child": 7, + }); + const prototypeSetterPath = join(oracleRoot, "prototype-setter.cjs"); + const ownPrototypeKeyPath = join(oracleRoot, "own-prototype-key.cjs"); + await Promise.all([ + writeFile(prototypeSetterPath, "module.exports = { __proto__: {} }"), + writeFile( + ownPrototypeKeyPath, + 'module.exports = { ["__proto__"]: 1, __proto__() {} }', + ), + ]); + expect( + Reflect.ownKeys(requireFixture(prototypeSetterPath) as object), + ).toEqual([]); + expect( + Reflect.ownKeys(requireFixture(ownPrototypeKeyPath) as object), + ).toEqual(["__proto__"]); + + const aliasPath = join(oracleRoot, "alias.cjs"); + const replacementPath = join(oracleRoot, "replacement.cjs"); + await Promise.all([ + writeFile(aliasPath, "exports = function ignored() {};"), + writeFile( + replacementPath, + "module.exports = function actual() { return 42; };", + ), + ]); + expect(requireFixture(aliasPath)).toEqual({}); + const replacement: unknown = requireFixture(replacementPath); + if (typeof replacement !== "function") + throw new Error("Expected Node to load the replacement export"); + expect(replacement()).toBe(42); + + const applicationEvidenceSchema = z.object({ + evidence_id: z.string(), + normalized_result: javascriptApplicationAnalysisResultSchema, + }); + const cliEvidence = applicationEvidenceSchema.parse( + await runCli([ + "analyze-javascript-application", + root, + "--artifact-format", + "directory", + "--json", + ]), + ); + const transport = new StdioClientTransport({ + command: process.execPath, + args: [resolve("scripts/rea.mjs"), "mcp"], + cwd: process.cwd(), + env: { PATH: process.env.PATH ?? "", REA_LOG_LEVEL: "silent" }, + stderr: "pipe", + }); + transport.stderr?.on("data", () => undefined); + const client = new Client({ name: "webpack-export-keys", version: "1" }); + let mcpEvidence: z.infer; + try { + await client.connect(transport); + const response = await client.callTool({ + name: "analyze_javascript_application", + arguments: { input_path: root, format: "directory" }, + }); + expect(response.isError).not.toBe(true); + mcpEvidence = applicationEvidenceSchema.parse(response.structuredContent); + expect(await client.ping()).toEqual({}); + } finally { + try { + await client.close(); + } finally { + await transport.close(); + } + } + expect(mcpEvidence.evidence_id).toBe(cliEvidence.evidence_id); + + const expectBundleExports = ( + evidence: typeof cliEvidence, + moduleKey: string, + expected: readonly string[], + ) => { + const module = evidence.normalized_result.graph.nodes.find( + ({ kind, observations }) => + kind === "javascript-module" && + observations.some( + ({ properties }) => + properties.runtime === "webpackChunkStaticExports" && + properties.module_key === moduleKey, + ), + ); + if (module === undefined) + throw new Error(`Missing webpack factory ${moduleKey}`); + expect(module.observations[0]?.properties.exports).toEqual(expected); + }; + for (const evidence of [cliEvidence, mcpEvidence]) { + expectBundleExports(evidence, "1", [ + "", + "dot.key", + "from.helper", + "module.dot", + "nested", + "nested.child", + ]); + expectBundleExports(evidence, "2", ["default"]); + expectBundleExports(evidence, "3", []); + expectBundleExports(evidence, "4", ["stable"]); + expectBundleExports(evidence, "5", []); + expectBundleExports(evidence, "6", ["__proto__"]); + } + }, 20_000); +}); + describe("application workflow CLI input", () => { it("rejects Evidence ID-only workflow inputs", async () => { const result = await runCli([