mirror of
https://github.com/morluto/rea.git
synced 2026-10-12 02:19:24 +00:00
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:
@@ -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([
|
||||
|
||||
Reference in New Issue
Block a user