fix(javascript): preserve exact CommonJS export keys

Retain literal property keys as member segments, omit unresolved computed keys, and distinguish nested writes from dotted export names. Preserve empty keys and omit object-literal prototype setters. Verify bundler observations through Node, CLI, and MCP consumers.
This commit is contained in:
morluto
2026-10-11 07:59:47 +08:00
parent c916850852
commit 5485483f7d
3 changed files with 273 additions and 44 deletions
@@ -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<string>,
): 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<string>,
): 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<string>, 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()];
};
@@ -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() {} }]);
@@ -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<string, unknown>;
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<typeof applicationEvidenceSchema>;
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([