Compare commits

...
3 changed files with 108 additions and 44 deletions
@@ -0,0 +1,53 @@
import { parse } from "@babel/parser";
import { expect, it } from "vitest";
import { collectJavaScriptExports } from "./javascriptAstFingerprint.js";
import { analyzeJavaScriptStaticSource } from "./javascriptStaticAnalysis.js";
it.each([
"exports[key] = 1;",
"module[key].value = 1;",
'module["exports.value"] = 1;',
"module.exports = { [key]: 1 };",
'Object[method](exports, "value", {});',
"function d() {} d(exports, { invented: 1 });",
])("does not invent an export name for %s", (source) => {
expect(collectJavaScriptExports(parse(source)).values).toEqual([]);
});
it.each([
'exports[""] = 1;',
'module.exports = { "": 1 };',
'Object.defineProperty(exports, "", {});',
])("preserves the exact empty export name for %s", (source) => {
expect(collectJavaScriptExports(parse(source)).values).toEqual([""]);
});
it("retains static computed and ordinary names", () => {
expect(
collectJavaScriptExports(
parse('module["exports"]["ready"] = 1; exports.done = 2;'),
).values,
).toEqual(["done", "ready"]);
});
it("retains member helper exports on this", () => {
expect(
collectJavaScriptExports(parse("this.d(exports, { ready: () => 1 });"))
.values,
).toEqual(["ready"]);
});
it("does not expose invented names in bundler module analysis", () => {
const analysis = analyzeJavaScriptStaticSource(`
globalThis.webpackChunkApp.push([[1], { 1: function(module, exports) {
exports[key] = 1;
module["exports.value"] = 2;
module.exports = { [key]: 3, ready: 4 };
} }]);
`);
expect(analysis.parse_status).toBe("complete");
expect(analysis.bundler_registrations[0]?.modules[0]?.exports).toEqual([
"ready",
]);
});
@@ -5,7 +5,10 @@ import * as t from "@babel/types";
import { traverseJavaScriptAst } from "./javascriptSemanticTraversal.js"; import { traverseJavaScriptAst } from "./javascriptSemanticTraversal.js";
import { compareUnicodeCodePoints } from "../unicodeCodePointOrder.js"; import { compareUnicodeCodePoints } from "../unicodeCodePointOrder.js";
import { propertyName } from "./javascriptAstValues.js"; import {
propertyName,
semanticStaticPropertyKey,
} from "./javascriptAstValues.js";
import { stringValue } from "./javascriptStaticAnalysisHelpers.js"; import { stringValue } from "./javascriptStaticAnalysisHelpers.js";
/** Static CommonJS export names. */ /** Static CommonJS export names. */
@@ -71,15 +74,20 @@ const collectAssignmentExports = (
output: Set<string>, output: Set<string>,
): void => { ): void => {
const path = memberPath(left); const path = memberPath(left);
if (path === "exports" || path === "module.exports") { if (path === null) 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); if (t.isObjectExpression(right)) collectObjectKeys(right, output);
else addExport(output, "default"); else addExport(output, "default");
return; return;
} }
if (path.startsWith("exports.")) addExport(output, path.slice(exportOffset).join("."));
addExport(output, path.slice("exports.".length));
if (path.startsWith("module.exports."))
addExport(output, path.slice("module.exports.".length));
}; };
const collectCallExports = ( const collectCallExports = (
@@ -87,19 +95,28 @@ const collectCallExports = (
output: Set<string>, output: Set<string>,
): void => { ): void => {
const callee = memberPath(call.callee); const callee = memberPath(call.callee);
if (callee === "Object.defineProperty") { if (callee === null) return;
const target = memberPath(call.arguments[0]); const definePropertyCall =
const name = stringValue(call.arguments[1]); callee.length === 2 &&
if ((target === "exports" || target === "module.exports") && name) callee[0] === "Object" &&
addExport(output, name); callee[1] === "defineProperty";
} const declarationCall = callee.length >= 2 && callee.at(-1) === "d";
if (!callee.endsWith(".d")) return; if (!definePropertyCall && !declarationCall) return;
const target = memberPath(call.arguments[0]); 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]; const declarations = call.arguments[1];
if ( if (t.isObjectExpression(declarations))
(target === "exports" || target === "module.exports") &&
t.isObjectExpression(declarations)
)
collectObjectKeys(declarations, output); collectObjectKeys(declarations, output);
}; };
@@ -109,8 +126,8 @@ const collectObjectKeys = (
): void => { ): void => {
for (const property of object.properties) { for (const property of object.properties) {
if (!t.isObjectProperty(property) && !t.isObjectMethod(property)) continue; if (!t.isObjectProperty(property) && !t.isObjectMethod(property)) continue;
const name = propertyName(property.key); const name = semanticStaticPropertyKey(property.key, property.computed);
if (name !== "") addExport(output, name); if (name !== null) addExport(output, name);
} }
}; };
@@ -118,7 +135,9 @@ const addExport = (output: Set<string>, name: string): void => {
output.add(name); output.add(name);
}; };
const memberPath = (node: t.Node | null | undefined): string => { const memberPath = (
node: t.Node | null | undefined,
): readonly string[] | null => {
const properties: string[] = []; const properties: string[] = [];
let current = node; let current = node;
while ( while (
@@ -126,23 +145,19 @@ const memberPath = (node: t.Node | null | undefined): string => {
current !== null && current !== null &&
(t.isMemberExpression(current) || t.isOptionalMemberExpression(current)) (t.isMemberExpression(current) || t.isOptionalMemberExpression(current))
) { ) {
properties.push(propertyName(current.property)); const property = semanticStaticPropertyKey(
current = t.isNode(current.object) ? current.object : undefined; current.property,
current.computed,
);
if (property === null) return null;
properties.push(property);
current = current.object;
} }
if (current === undefined || current === null) return null;
const root = const root = t.isIdentifier(current)
current !== undefined && t.isIdentifier(current) ? current.name
? current.name : t.isThisExpression(current)
: current !== undefined && t.isThisExpression(current) ? "this"
? "this" : null;
: ""; return root === null ? null : [root, ...properties.reverse()];
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(".");
}; };
@@ -15,7 +15,7 @@ it("collects a 12,000-member CommonJS export path without recursion", () => {
]); ]);
}); });
it("preserves empty, dynamic, this, and private member path behavior", () => { it("preserves empty keys without inventing dynamic, this, or private exports", () => {
const file = parseJavaScriptSource(` const file = parseJavaScriptSource(`
exports[""].value = 1; exports[""].value = 1;
exports[""] = 0; exports[""] = 0;
@@ -24,11 +24,7 @@ it("preserves empty, dynamic, this, and private member path behavior", () => {
class Example { #private = 0; read() { this.#private = 1; } } class Example { #private = 0; read() { this.#private = 1; } }
`); `);
if (file === null) throw new Error("Expected valid JavaScript"); if (file === null) throw new Error("Expected valid JavaScript");
expect(collectJavaScriptExports(file).values).toEqual([ expect(collectJavaScriptExports(file).values).toEqual(["", ".value"]);
"",
".value",
"key.value",
]);
}); });
it("keeps left-first and outer-member-first bundler runtime matches", () => { it("keeps left-first and outer-member-first bundler runtime matches", () => {