From bec88b9d773cbff0d28b70475059901c416183f6 Mon Sep 17 00:00:00 2001 From: rodricksz4h5 Date: Sat, 17 Oct 2026 20:53:47 +0530 Subject: [PATCH] fix(javascript): retain CommonJS exports defined with Object.defineProperty (#4725) * fix(javascript): retain CommonJS exports defined with Object.defineProperty tsc, Babel and Rollup emit Object.defineProperty(exports, name, descriptor) for CommonJS re-exports. The semantic projection only recognised assignments, so these exports were missing from export_names and exposes edges. Recognise the call on exports or module.exports with the existing unmodified-global and receiver checks, track its receiver lifetime like an assignment, and link a data descriptor's value like the assigned value; an accessor's result stays unknown. * fix(javascript): unwrap assignment chains in defined CommonJS export values A descriptor value such as holder.parse = require("./parser").parse lost its require origin because only the assignment path took the rightmost assigned value. Do it once in the shared export builder. --- .../javascriptCommonJsExportReceivers.ts | 68 ++++++++++- ...vascriptSemanticAnalysis.structure.test.ts | 114 ++++++++++++++++++ .../javascriptSemanticProjection.ts | 43 ++++++- 3 files changed, 214 insertions(+), 11 deletions(-) diff --git a/src/domain/javascript/javascriptCommonJsExportReceivers.ts b/src/domain/javascript/javascriptCommonJsExportReceivers.ts index 9962a42d8e..848f5a8d6a 100644 --- a/src/domain/javascript/javascriptCommonJsExportReceivers.ts +++ b/src/domain/javascript/javascriptCommonJsExportReceivers.ts @@ -9,6 +9,7 @@ import { isUnshadowedGlobal, type JavaScriptSemanticAnalysisState, } from "./javascriptSemanticState.js"; +import { isUnmodifiedGlobalReference } from "./javascriptSemanticAmbientWrites.js"; import { traverseJavaScriptAstSteps } from "./javascriptSemanticTraversal.js"; import { semanticPropertyPathKeyMatches } from "./javascriptSemanticPropertyPaths.js"; @@ -17,6 +18,9 @@ export type CommonJsExportReceiverEffect = | "detached" | "uncertain"; +/** A CommonJS export assignment or `Object.defineProperty` export call. */ +export type CommonJsExportSite = t.AssignmentExpression | t.CallExpression; + const isCommonJsModuleExports = ( node: t.Node, state: JavaScriptSemanticAnalysisState, @@ -41,6 +45,51 @@ export const commonJsExportName = ( return isCommonJsModuleExports(node, state) ? "default" : undefined; }; +/** + * `Object.defineProperty(exports, key, descriptor)`, as tsc and Babel emit + * for CommonJS re-exports, defines the same export as an assignment. + */ +export const commonJsDefinedExport = ( + node: t.Node, + state: JavaScriptSemanticAnalysisState, +): + | { + readonly exportedName: string; + readonly receiver: t.Node; + readonly descriptor: t.Node; + } + | undefined => { + if (!t.isCallExpression(node)) return undefined; + const [target, key, descriptor] = node.arguments; + if ( + target === undefined || + key === undefined || + descriptor === undefined || + t.isSpreadElement(target) || + t.isSpreadElement(key) || + t.isSpreadElement(descriptor) || + t.isArgumentPlaceholder(target) || + t.isArgumentPlaceholder(key) || + t.isArgumentPlaceholder(descriptor) + ) + return undefined; + const receiver = unwrapJavaScriptExpression(target).node; + if ( + (!isUnshadowedGlobal(receiver, state, "exports") && + !isCommonJsModuleExports(receiver, state)) || + !isUnmodifiedGlobalReference(node.callee, state, [ + "Object", + "defineProperty", + ]) + ) + return undefined; + return { + exportedName: semanticStaticPropertyKey(key, true) ?? "*", + receiver, + descriptor, + }; +}; + type Identity = t.Node | null; type Execution = "ordered" | "conditional" | "deferred"; @@ -65,7 +114,7 @@ export function* indexCommonJsExportReceiversSteps( state: JavaScriptSemanticAnalysisState, ): Generator< void, - ReadonlyMap + ReadonlyMap > { const initial = state.program; let publicIdentity: Identity = initial; @@ -74,7 +123,7 @@ export function* indexCommonJsExportReceiversSteps( let deferredReplacement = false; const fresh = new WeakSet([initial]); const values = new WeakMap(); - const captures = new Map(); + const captures = new Map(); const execution: Execution[] = []; const publicCallWrites = new WeakSet(); // Consume the shared write owner's resolved aliases, intrinsic invocation @@ -168,6 +217,16 @@ export function* indexCommonJsExportReceiversSteps( : publicIdentity, ); } + const defined = commonJsDefinedExport(node, state); + if (defined !== undefined && t.isCallExpression(node)) + captures.set( + node, + mode !== "ordered" + ? null + : isUnshadowedGlobal(defined.receiver, state, "exports") + ? exportsIdentity + : publicIdentity, + ); }, exit: (node) => { const mode = execution.pop() ?? "conditional"; @@ -222,10 +281,7 @@ export function* indexCommonJsExportReceiversSteps( } }, }); - const effects = new Map< - t.AssignmentExpression, - CommonJsExportReceiverEffect - >(); + const effects = new Map(); for (const [node, receiver] of captures) { effects.set( node, diff --git a/src/domain/javascript/javascriptSemanticAnalysis.structure.test.ts b/src/domain/javascript/javascriptSemanticAnalysis.structure.test.ts index 5024be3a01..089c0b269b 100644 --- a/src/domain/javascript/javascriptSemanticAnalysis.structure.test.ts +++ b/src/domain/javascript/javascriptSemanticAnalysis.structure.test.ts @@ -559,6 +559,120 @@ describe("CommonJS receiver lifetimes", () => { }); }); +describe("CommonJS exports defined with Object.defineProperty", () => { + const exportsOf = (source: string) => + analyzeJavaScriptSemantics(source).moduleLinks.filter( + ({ kind }) => kind === "commonjs-export", + ); + const defineParse = + 'Object.defineProperty(exports, "parse", { value: () => 1 });'; + + it("retains tsc CommonJS re-exports and data descriptors", () => { + const links = exportsOf(` + "use strict"; + Object.defineProperty(exports, "__esModule", { value: true }); + exports.VERSION = void 0; + exports.VERSION = "1.0"; + var client_1 = require("./client"); + Object.defineProperty(exports, "Client", { enumerable: true, get: function () { return client_1.Client; } }); + `); + expect(links.map(({ exportedName }) => exportedName)).toEqual([ + "__esModule", + "VERSION", + "VERSION", + "Client", + ]); + // An accessor's result is not proven by the descriptor. + expect(links.at(-1)).toMatchObject({ + localName: null, + callableId: null, + specifier: null, + }); + }); + + it.each([ + ["() => 1", "parse"], + ['require("./parser")', "parse"], + ['require("./parser").parse', "parse"], + ["parse", "parse"], + ['holder.parse = require("./parser").parse', "parse"], + ["first = second = () => 1", "parse"], + ])( + "links a data descriptor value %s like the equivalent assignment", + (value, name) => { + const strip = (links: ReturnType) => + links.map(({ location: _location, callableId, ...link }) => ({ + ...link, + callable: callableId !== null, + })); + const assigned = exportsOf(`exports.${name} = ${value};`); + const defined = exportsOf( + `Object.defineProperty(exports, "${name}", { value: ${value} });`, + ); + expect(strip(defined)).toEqual(strip(assigned)); + expect(defined).toHaveLength(1); + }, + ); + + it.each([ + defineParse, + `module.exports = {}; ${defineParse.replace("(exports", "(module.exports")}`, + `module.exports = {}; exports = module.exports; ${defineParse}`, + ])("retains a connected receiver: %s", (source) => { + expect(exportsOf(source)).toContainEqual( + expect.objectContaining({ + exportedName: "parse", + callableId: expect.any(String), + }), + ); + }); + + it.each([ + `module.exports = {}; ${defineParse}`, + `exports = {}; ${defineParse}`, + `${defineParse} module.exports = {};`, + ])("excludes a detached receiver: %s", (source) => { + expect( + exportsOf(source).filter(({ exportedName }) => exportedName === "parse"), + ).toEqual([]); + }); + + it.each([ + `if (flag) module.exports = {}; ${defineParse}`, + `function replace() { exports = {}; } replace(); ${defineParse}`, + `module.exports = {}; Object.defineProperty(module, "exports", { value: exports }); ${defineParse}`, + `if (flag) { ${defineParse} }`, + ])("keeps an uncertain receiver unresolved: %s", (source) => { + expect(exportsOf(source)).toContainEqual( + expect.objectContaining({ + exportedName: "parse", + callableId: null, + localName: null, + }), + ); + }); + + it.each([ + 'const Object = { defineProperty() {} }; Object.defineProperty(exports, "parse", { value: 1 });', + 'Object.defineProperty = patched; Object.defineProperty(exports, "parse", { value: 1 });', + 'Object.defineProperty(other, "parse", { value: 1 });', + 'Object.defineProperty(module, "parse", { value: 1 });', + 'function local(exports) { Object.defineProperty(exports, "parse", { value: 1 }); }', + 'Object.defineProperty(exports, "parse");', + "Object.defineProperty(...args);", + ])("does not treat other defineProperty calls as exports: %s", (source) => { + expect( + exportsOf(source).filter(({ exportedName }) => exportedName === "parse"), + ).toEqual([]); + }); + + it("keeps a dynamic defined key unknown", () => { + expect( + exportsOf("Object.defineProperty(exports, key, { value: 1 });"), + ).toEqual([expect.objectContaining({ exportedName: "*" })]); + }); +}); + describe("JavaScript semantic analysis: write references", () => { it("classifies destructuring and loop targets as writes, not reads", () => { const ir = analyzeJavaScriptSemantics(` diff --git a/src/domain/javascript/javascriptSemanticProjection.ts b/src/domain/javascript/javascriptSemanticProjection.ts index 3099aadb06..25a065e57f 100644 --- a/src/domain/javascript/javascriptSemanticProjection.ts +++ b/src/domain/javascript/javascriptSemanticProjection.ts @@ -25,6 +25,7 @@ import { import { range } from "./javascriptStaticAnalysisHelpers.js"; import { propertyName, + semanticFinalObjectProperty, semanticStaticPropertyKey, semanticWrappedWriteSite, semanticWriteSiteReadsFirst, @@ -39,8 +40,10 @@ import { semanticImportEqualsSpecifier, } from "./javascriptModuleDeclaration.js"; import { + commonJsDefinedExport, commonJsExportName, type CommonJsExportReceiverEffect, + type CommonJsExportSite, } from "./javascriptCommonJsExportReceivers.js"; interface CollectCallableInput { @@ -83,7 +86,7 @@ export const collectSemanticModuleLink = ( node: t.Node, state: JavaScriptSemanticAnalysisState, commonJsReceivers: ReadonlyMap< - t.AssignmentExpression, + CommonJsExportSite, CommonJsExportReceiverEffect >, ): void => { @@ -120,7 +123,10 @@ export const collectSemanticModuleLink = ( else if (t.isVariableDeclarator(node)) collectRequireLink(node, state); else if (t.isAssignmentExpression(node)) collectCommonJsExport(node, state, commonJsReceivers.get(node)); - else if (t.isCallExpression(node)) collectUnboundRequireLink(node, state); + else if (t.isCallExpression(node)) { + collectDefinedCommonJsExport(node, state, commonJsReceivers.get(node)); + collectUnboundRequireLink(node, state); + } }; const collectImportEquals = ( @@ -473,10 +479,37 @@ const collectCommonJsExport = ( const exportedName = commonJsExportName(node.left, state); if (exportedName === undefined) return; if (receiverEffect === "detached") return; - // Simple assignment chains yield their rightmost assigned value. Compound - // and logical writes also depend on the previous value, so remain unknown. - let value = + // Compound and logical writes also depend on the previous value, so remain + // unknown. + const value = node.operator === "=" && receiverEffect !== "uncertain" ? node.right : null; + addCommonJsExport(node, state, exportedName, value); +}; + +const collectDefinedCommonJsExport = ( + node: t.CallExpression, + state: JavaScriptSemanticAnalysisState, + receiverEffect: CommonJsExportReceiverEffect | undefined, +): void => { + const defined = commonJsDefinedExport(node, state); + if (defined === undefined || receiverEffect === "detached") return; + // A data descriptor exports its value; an accessor's result stays unknown. + const descriptor = unwrapJavaScriptExpression(defined.descriptor).node; + const value = + receiverEffect !== "uncertain" && t.isObjectExpression(descriptor) + ? (semanticFinalObjectProperty(descriptor, "value") ?? null) + : null; + addCommonJsExport(node, state, defined.exportedName, value); +}; + +const addCommonJsExport = ( + node: CommonJsExportSite, + state: JavaScriptSemanticAnalysisState, + exportedName: string, + exported: t.Node | null, +): void => { + // Simple assignment chains yield their rightmost assigned value. + let value = exported; while (t.isAssignmentExpression(value, { operator: "=" })) value = value.right; const required =