From 258ecd361b90bffe8d82c105d5b61c3e50461ee9 Mon Sep 17 00:00:00 2001 From: Rudy Celekli Date: Fri, 9 Oct 2026 17:26:13 -0400 Subject: [PATCH] fix(javascript): preserve exact static CommonJS export keys (cherry picked from commit a47517c67c09aea5ad261f449faa0c624d641574) --- .../javascriptAstExports.exact_keys.test.ts | 45 ++++++++++ .../javascript/javascriptAstFingerprint.ts | 85 ++++++++++--------- ...ptStaticAnalysis.member_path_depth.test.ts | 8 +- 3 files changed, 94 insertions(+), 44 deletions(-) create mode 100644 src/domain/javascript/javascriptAstExports.exact_keys.test.ts diff --git a/src/domain/javascript/javascriptAstExports.exact_keys.test.ts b/src/domain/javascript/javascriptAstExports.exact_keys.test.ts new file mode 100644 index 0000000000..0a1bd40888 --- /dev/null +++ b/src/domain/javascript/javascriptAstExports.exact_keys.test.ts @@ -0,0 +1,45 @@ +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", {});', +])("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("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", + ]); +}); diff --git a/src/domain/javascript/javascriptAstFingerprint.ts b/src/domain/javascript/javascriptAstFingerprint.ts index 8447de0ebb..14dbdd7192 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,20 @@ const collectAssignmentExports = ( output: Set, ): void => { 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); 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)); + addExport(output, path.slice(exportOffset).join(".")); }; const collectCallExports = ( @@ -87,19 +95,26 @@ 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 target = memberPath(call.arguments[0]); - const declarations = call.arguments[1]; + const exportTarget = + target !== null && + ((target.length === 1 && target[0] === "exports") || + (target.length === 2 && + target[0] === "module" && + target[1] === "exports")); + if (!exportTarget) return; if ( - (target === "exports" || target === "module.exports") && - t.isObjectExpression(declarations) - ) + callee.length === 2 && + callee[0] === "Object" && + callee[1] === "defineProperty" + ) { + const name = stringValue(call.arguments[1]); + if (name !== undefined) addExport(output, name); + } + if (callee.at(-1) !== "d") return; + const declarations = call.arguments[1]; + if (t.isObjectExpression(declarations)) collectObjectKeys(declarations, output); }; @@ -109,8 +124,8 @@ 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); + if (name !== null) addExport(output, name); } }; @@ -118,7 +133,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 +143,15 @@ 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 || !t.isIdentifier(current)) + return null; + return [current.name, ...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 3d03133013..7454189b91 100644 --- a/src/domain/javascript/javascriptStaticAnalysis.member_path_depth.test.ts +++ b/src/domain/javascript/javascriptStaticAnalysis.member_path_depth.test.ts @@ -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(` exports[""].value = 1; exports[""] = 0; @@ -24,11 +24,7 @@ it("preserves empty, dynamic, this, and private member path behavior", () => { 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", - ]); + expect(collectJavaScriptExports(file).values).toEqual(["", ".value"]); }); it("keeps left-first and outer-member-first bundler runtime matches", () => {