diff --git a/src/domain/javascript/javascriptCommonJsExportReceivers.ts b/src/domain/javascript/javascriptCommonJsExportReceivers.ts new file mode 100644 index 0000000000..9962a42d8e --- /dev/null +++ b/src/domain/javascript/javascriptCommonJsExportReceivers.ts @@ -0,0 +1,242 @@ +import * as t from "@babel/types"; + +import { + semanticStaticPropertyKey, + semanticWriteTargets, + unwrapJavaScriptExpression, +} from "./javascriptAstValues.js"; +import { + isUnshadowedGlobal, + type JavaScriptSemanticAnalysisState, +} from "./javascriptSemanticState.js"; +import { traverseJavaScriptAstSteps } from "./javascriptSemanticTraversal.js"; +import { semanticPropertyPathKeyMatches } from "./javascriptSemanticPropertyPaths.js"; + +export type CommonJsExportReceiverEffect = + | "connected" + | "detached" + | "uncertain"; + +const isCommonJsModuleExports = ( + node: t.Node, + state: JavaScriptSemanticAnalysisState, +): boolean => + (t.isMemberExpression(node) || t.isOptionalMemberExpression(node)) && + isUnshadowedGlobal(node.object, state, "module") && + semanticStaticPropertyKey(node.property, node.computed) === "exports"; + +/** One syntax owner for CJS export names and their receiver identities. */ +export const commonJsExportName = ( + node: t.Node, + state: JavaScriptSemanticAnalysisState, +): string | undefined => { + if (!t.isMemberExpression(node) && !t.isOptionalMemberExpression(node)) + return undefined; + const key = semanticStaticPropertyKey(node.property, node.computed); + if ( + isUnshadowedGlobal(node.object, state, "exports") || + isCommonJsModuleExports(node.object, state) + ) + return key === null ? "*" : key; + return isCommonJsModuleExports(node, state) ? "default" : undefined; +}; + +type Identity = t.Node | null; +type Execution = "ordered" | "conditional" | "deferred"; + +/** Pattern evaluation follows the RHS, unlike Babel's child visit order. */ +const hasDestructuringEvaluationOrder = (node: t.Node): boolean => { + const pattern = t.isAssignmentExpression(node) + ? unwrapJavaScriptExpression(node.left).node + : t.isVariableDeclarator(node) + ? node.id + : undefined; + return t.isObjectPattern(pattern) || t.isArrayPattern(pattern); +}; + +/** + * Capture receivers before their RHS, then compare with the final public + * identity. Fresh allocations prove distinctness; explicit wrapper reads + * preserve even opaque identities. Branches and deferred execution remain + * uncertain, as do local aliases. Facts live for one analysis and the + * cooperative traversal bounds work to the source tree size. + */ +export function* indexCommonJsExportReceiversSteps( + state: JavaScriptSemanticAnalysisState, +): Generator< + void, + ReadonlyMap +> { + const initial = state.program; + let publicIdentity: Identity = initial; + let exportsIdentity: Identity = initial; + let moduleIntact = true; + let deferredReplacement = false; + const fresh = new WeakSet([initial]); + const values = new WeakMap(); + const captures = new Map(); + const execution: Execution[] = []; + const publicCallWrites = new WeakSet(); + // Consume the shared write owner's resolved aliases, intrinsic invocation + // and keyed mutator facts instead of reinterpreting call names here. + for (const { node, path } of state.ambientWriteEffects) { + if ( + (t.isCallExpression(node) || t.isOptionalCallExpression(node)) && + path[0] === "commonjs-module" && + path.length <= 2 && + (path.length === 1 || + semanticPropertyPathKeyMatches(path[1] ?? null, "exports")) + ) + publicCallWrites.add(node); + } + const valueOf = (node: t.Node): Identity => { + const value = unwrapJavaScriptExpression(node).node; + // An opaque result may alias another object, but reading the current + // module.exports and explicitly reconnecting exports preserves that result. + return values.has(value) ? (values.get(value) ?? null) : value; + }; + + const write = (target: t.Node, identity: Identity, mode: Execution): void => { + target = unwrapJavaScriptExpression(target).node; + const possiblePublicWrite = + (t.isMemberExpression(target) || t.isOptionalMemberExpression(target)) && + isUnshadowedGlobal(target.object, state, "module") && + semanticStaticPropertyKey(target.property, target.computed) === null; + const publicWrite = + isCommonJsModuleExports(target, state) || possiblePublicWrite; + const exportsWrite = isUnshadowedGlobal(target, state, "exports"); + const moduleWrite = isUnshadowedGlobal(target, state, "module"); + if (!publicWrite && !exportsWrite && !moduleWrite) return; + if (mode === "deferred") { + // A source offset cannot order an invocation against module completion. + deferredReplacement = true; + return; + } + const selected = + mode === "ordered" && !possiblePublicWrite ? identity : null; + if (moduleWrite) moduleIntact = false; + if (publicWrite || moduleWrite) + publicIdentity = moduleIntact ? selected : null; + if (exportsWrite) exportsIdentity = selected; + }; + + yield* traverseJavaScriptAstSteps(state.program, { + enter: (node, parent) => { + const outer = execution.at(-1) ?? "ordered"; + const mode: Execution = + outer === "deferred" || t.isFunction(node) || t.isClass(node) + ? "deferred" + : outer === "conditional" || + // Mark the whole operation: a later tree-visited RHS must not + // restore certainty after an earlier tree-visited pattern write. + hasDestructuringEvaluationOrder(node) || + t.isIfStatement(node) || + t.isSwitchStatement(node) || + t.isLoop(node) || + t.isTryStatement(node) || + t.isObjectPattern(node) || + t.isArrayPattern(node) || + (t.isLogicalExpression(parent) && parent.right === node) || + (t.isAssignmentPattern(parent) && parent.right === node) || + (t.isAssignmentExpression(parent) && + ["&&=", "||=", "??="].includes(parent.operator) && + parent.right === node) || + t.isOptionalCallExpression(parent) || + (t.isOptionalMemberExpression(parent) && + parent.property === node) || + (t.isConditionalExpression(parent) && parent.test !== node) + ? "conditional" + : "ordered"; + execution.push(mode); + if ( + t.isAssignmentExpression(node) && + commonJsExportName(node.left, state) !== undefined && + !isCommonJsModuleExports(node.left, state) + ) { + const receiver = + t.isMemberExpression(node.left) || + t.isOptionalMemberExpression(node.left) + ? node.left.object + : null; + captures.set( + node, + mode !== "ordered" + ? null + : receiver !== null && + isUnshadowedGlobal(receiver, state, "exports") + ? exportsIdentity + : publicIdentity, + ); + } + }, + exit: (node) => { + const mode = execution.pop() ?? "conditional"; + if (publicCallWrites.has(node)) { + if (mode === "deferred") deferredReplacement = true; + else publicIdentity = null; + } + if (t.isAssignmentExpression(node)) { + const left = unwrapJavaScriptExpression(node.left).node; + const identity = + node.operator === "=" && + (t.isIdentifier(left) || + t.isMemberExpression(left) || + t.isOptionalMemberExpression(left)) + ? valueOf(node.right) + : null; + for (const target of semanticWriteTargets(node.left)) + write(target, identity, mode); + values.set(node, identity); + } else if (t.isVariableDeclarator(node) && node.init != null) { + for (const target of semanticWriteTargets(node.id)) + write( + target, + t.isIdentifier(node.id) ? valueOf(node.init) : null, + mode, + ); + } else if (t.isForOfStatement(node) || t.isForInStatement(node)) { + if (!t.isVariableDeclaration(node.left)) + for (const target of semanticWriteTargets(node.left)) + write(target, null, mode); + } else if ( + t.isUpdateExpression(node) || + t.isUnaryExpression(node, { operator: "delete" }) + ) { + write(node.argument, null, mode); + } else if (isUnshadowedGlobal(node, state, "exports")) { + values.set(node, exportsIdentity); + } else if (isCommonJsModuleExports(node, state)) { + values.set(node, publicIdentity); + } else if ( + t.isObjectExpression(node) || + t.isArrayExpression(node) || + t.isFunctionExpression(node) || + t.isArrowFunctionExpression(node) || + t.isClassExpression(node) + ) { + values.set(node, node); + fresh.add(node); + } else if (t.isSequenceExpression(node)) { + const last = node.expressions.at(-1); + values.set(node, last === undefined ? null : valueOf(last)); + } + }, + }); + const effects = new Map< + t.AssignmentExpression, + CommonJsExportReceiverEffect + >(); + for (const [node, receiver] of captures) { + effects.set( + node, + deferredReplacement || receiver === null || publicIdentity === null + ? "uncertain" + : receiver === publicIdentity + ? "connected" + : fresh.has(receiver) && fresh.has(publicIdentity) + ? "detached" + : "uncertain", + ); + } + return effects; +} diff --git a/src/domain/javascript/javascriptElectronMemberWrites.ts b/src/domain/javascript/javascriptElectronMemberWrites.ts index 9ca7c2d893..62a921b87d 100644 --- a/src/domain/javascript/javascriptElectronMemberWrites.ts +++ b/src/domain/javascript/javascriptElectronMemberWrites.ts @@ -128,7 +128,9 @@ export function* collectJavaScriptIdentityWritesSteps( if (identities.length > 0) { const key = keyedMutators.has(method ?? "") ? [staticString(arguments_?.[1]) ?? null] - : []; + : // Mutating a target's contents does not replace the target. + // Retain an unknown child key, rather than its own identity. + [null]; for (const identity of identities) retain(node, [...identity, ...key]); } else if (!freshObject(target)) { diff --git a/src/domain/javascript/javascriptSemanticAmbientWrites.test.ts b/src/domain/javascript/javascriptSemanticAmbientWrites.test.ts index 2eaec7a4b7..a9832ac959 100644 --- a/src/domain/javascript/javascriptSemanticAmbientWrites.test.ts +++ b/src/domain/javascript/javascriptSemanticAmbientWrites.test.ts @@ -1,6 +1,28 @@ import { expect, it } from "vitest"; import { analyzeJavaScriptStaticSource } from "./javascriptStaticAnalysis.js"; +import { indexJavaScriptBindingsSteps } from "./javascriptSemanticAnalysis.js"; +import { completeSemanticSteps } from "./javascriptSemanticTraversal.js"; +import { parseJavaScriptSource } from "./javascriptSourceParser.js"; + +it("keeps wrapper module aliases distinct from global-object module properties", () => { + const parsed = parseJavaScriptSource( + ` + const wrapper = module; + Object.defineProperty(wrapper, "exports", { value: exports }); + Object.defineProperty(globalThis.module, "exports", { value: exports }); + `, + "app.cjs", + ); + if (parsed === null) throw new Error("Expected parsed source"); + const state = completeSemanticSteps( + indexJavaScriptBindingsSteps(parsed.program), + ); + expect(state.ambientWriteEffects.map(({ path }) => path)).toEqual([ + ["commonjs-module", "exports"], + ["global", "module", "exports"], + ]); +}); it.each([ { diff --git a/src/domain/javascript/javascriptSemanticAmbientWrites.ts b/src/domain/javascript/javascriptSemanticAmbientWrites.ts index e582f07a85..3b52f2ccca 100644 --- a/src/domain/javascript/javascriptSemanticAmbientWrites.ts +++ b/src/domain/javascript/javascriptSemanticAmbientWrites.ts @@ -123,6 +123,12 @@ const createAmbientPathResolver = ( continue; } if (t.isIdentifier(root)) { + // The wrapper module object is distinct from globalThis.module. Keep + // its identity through the same alias/projection owner used for writes. + if (isUnshadowedGlobal(root, state, "module")) { + retain(["commonjs-module", ...(suffix ?? [])]); + continue; + } if (globalObjectName(root, state) !== undefined) { retain(["global", ...(suffix ?? [])]); continue; diff --git a/src/domain/javascript/javascriptSemanticAnalysis.require_links.test.ts b/src/domain/javascript/javascriptSemanticAnalysis.require_links.test.ts index f05d7a7587..eafb40255b 100644 --- a/src/domain/javascript/javascriptSemanticAnalysis.require_links.test.ts +++ b/src/domain/javascript/javascriptSemanticAnalysis.require_links.test.ts @@ -274,6 +274,7 @@ describe("CommonJS dependency links", () => { const bound = (require("bound") as unknown); const { left, right } = require("destructured"); module.exports = require("forwarded"); + exports = module.exports; exports.member = require("member").value; exports.first = exports.second = require("chained"); `); diff --git a/src/domain/javascript/javascriptSemanticAnalysis.structure.test.ts b/src/domain/javascript/javascriptSemanticAnalysis.structure.test.ts index 16b8c50910..b15c1a6814 100644 --- a/src/domain/javascript/javascriptSemanticAnalysis.structure.test.ts +++ b/src/domain/javascript/javascriptSemanticAnalysis.structure.test.ts @@ -393,7 +393,105 @@ describe("JavaScript semantic analysis: structure 2", () => { ]); }, ); +}); +describe("CommonJS receiver lifetimes", () => { + it.each([ + 'Object.defineProperty(module.exports, "other", { value: exports });', + "Object.assign(module.exports, { other: exports });", + ])( + "keeps the receiver identity intact when mutating its contents: %s", + (mutation) => { + const ir = analyzeJavaScriptSemantics( + `exports.parse = () => 1; ${mutation}`, + ); + const exported = ir.moduleLinks.find( + ({ exportedName }) => exportedName === "parse", + ); + expect(exported?.callableId).toEqual(expect.any(String)); + }, + ); + + it.each([ + "module.exports = {}; exports.parse = () => 1;", + "exports = {}; exports.parse = () => 1;", + "exports.parse = () => 1; module.exports = {};", + "module.exports.parse = () => 1; module.exports = [];", + 'module.exports = {}; exports.status = "enabled";', + "module.exports = exports = {}; module.exports = {}; exports.parse = () => 1;", + 'module.exports = {}; Reflect.set(module, "other", exports); exports.parse = () => 1;', + 'module.exports = {}; Object.defineProperty(globalThis.module, "exports", { value: exports }); exports.parse = () => 1;', + "module.exports = {}; let wrapper = module; wrapper = {}; Object.assign(wrapper, { exports }); exports.parse = () => 1;", + ])( + "excludes a detached CommonJS receiver across statements: %s", + (source) => { + const ir = analyzeJavaScriptSemantics(source); + expect( + ir.moduleLinks.filter( + ({ exportedName }) => + exportedName === "parse" || exportedName === "status", + ), + ).toEqual([]); + }, + ); + + it.each([ + "exports.parse = () => 1;", + "module.exports = {}; module.exports.parse = () => 1;", + "module.exports = {}; exports = module.exports; exports.parse = () => 1;", + "module.exports = exports = {}; exports.parse = () => 1;", + "exports = module.exports = {}; exports.parse = () => 1;", + "module.exports = module.exports; exports.parse = () => 1;", + "module.exports = external; exports = module.exports; exports.parse = () => 1;", + "module.exports = external; module.exports.parse = () => 1;", + "exports.parse = (exports = {} , () => 1);", + "exports.parse = (module.exports = {}); module.exports = exports;", + "(module.exports = {}, exports = module.exports, exports.parse = () => 1);", + ])("retains a connected CommonJS receiver: %s", (source) => { + expect(analyzeJavaScriptSemantics(source).moduleLinks).toContainEqual( + expect.objectContaining({ + kind: "commonjs-export", + exportedName: "parse", + }), + ); + }); + + it.each([ + "module.exports = external; exports.parse = () => 1;", + "if (flag) module.exports = {}; exports.parse = () => 1;", + "flag && (module.exports = {}); exports.parse = () => 1;", + "holder ||= (module.exports = {}); exports.parse = () => 1;", + "invoke?.(module.exports = {}); exports.parse = () => 1;", + "const { value = (module.exports = {}) } = source; exports.parse = () => 1;", + "module[key] = {}; exports.parse = () => 1;", + "exports.parse = () => 1; if (flag) module.exports = {};", + "function replace() { module.exports = {}; } exports.parse = () => 1; replace();", + "function replace() { exports = {}; } replace(); exports.parse = () => 1;", + "module.exports = {}; function install() { exports.parse = () => 1; } install();", + "for (exports of values) {} exports.parse = () => 1;", + "({ exports } = external); exports.parse = () => 1;", + "module = {}; module.exports = {}; module.exports.parse = () => 1;", + 'const holder = {}; ({ [(module.exports = exports, "x")]: holder.x } = (module.exports = {})); exports.parse = () => 1;', + 'const { [(module.exports = exports, "x")]: value } = (module.exports = {}); exports.parse = () => 1;', + "const [value = (module.exports = exports)] = (module.exports = []); exports.parse = () => 1;", + "module.exports = {}; Object.assign(module, { exports }); exports.parse = () => 1;", + 'module.exports = {}; Object.defineProperty(module, "exports", { value: exports }); exports.parse = () => 1;', + 'module.exports = {}; Reflect.set(module, "exports", exports); exports.parse = () => 1;', + "module.exports = {}; const assign = Object.assign; assign.call(null, module, { exports }); exports.parse = () => 1;", + "module.exports = {}; const wrapper = module; Object.assign(wrapper, { exports }); exports.parse = () => 1;", + ])("keeps uncertain CommonJS receiver identity unresolved: %s", (source) => { + expect(analyzeJavaScriptSemantics(source).moduleLinks).toContainEqual( + expect.objectContaining({ + kind: "commonjs-export", + exportedName: "parse", + callableId: null, + localName: null, + }), + ); + }); +}); + +describe("JavaScript semantic analysis: write references", () => { it("classifies destructuring and loop targets as writes, not reads", () => { const ir = analyzeJavaScriptSemantics(` let a, b, c; diff --git a/src/domain/javascript/javascriptSemanticAnalysis.ts b/src/domain/javascript/javascriptSemanticAnalysis.ts index 147d05f608..79074b474d 100644 --- a/src/domain/javascript/javascriptSemanticAnalysis.ts +++ b/src/domain/javascript/javascriptSemanticAnalysis.ts @@ -70,6 +70,7 @@ import { import { isJavaScriptRpcClientFactory } from "./javascriptRpcEndpoints.js"; import { createSemanticModuleInterop } from "./javascriptModuleInterop.js"; import { SemanticPropertyPathCoverage } from "./javascriptSemanticPropertyPaths.js"; +import { indexCommonJsExportReceiversSteps } from "./javascriptCommonJsExportReceivers.js"; import type { JavaScriptModuleResolutionMode } from "./javascriptModuleResolutionMode.js"; interface BindPatternInput { @@ -163,8 +164,9 @@ export function* analyzeParsedJavaScriptSemanticsSteps( yield* collectDefinitionsSteps(file.program, state); yield* indexJavaScriptAmbientWritesSteps(file.program, state); yield* collectSemanticMemberMutationsSteps(file.program, state); + const commonJsReceivers = yield* indexCommonJsExportReceiversSteps(state); yield* traverseJavaScriptAstSteps(file.program, { - enter: (node) => collectSemanticModuleLink(node, state), + enter: (node) => collectSemanticModuleLink(node, state, commonJsReceivers), }); yield; const references = yield* collectSemanticReferencesSteps(file.program, state); diff --git a/src/domain/javascript/javascriptSemanticProjection.ts b/src/domain/javascript/javascriptSemanticProjection.ts index 2493a05c65..9389427d8f 100644 --- a/src/domain/javascript/javascriptSemanticProjection.ts +++ b/src/domain/javascript/javascriptSemanticProjection.ts @@ -9,7 +9,6 @@ import type { import { resolveSemanticBindingState, semanticResolutionBlocked, - isUnshadowedGlobal, type JavaScriptSemanticAnalysisState, type JavaScriptSemanticScopeState, } from "./javascriptSemanticState.js"; @@ -38,6 +37,10 @@ import { isTypeOnlyImportDeclaration, isTypeOnlyImportSpecifier, } from "./javascriptModuleDeclaration.js"; +import { + commonJsExportName, + type CommonJsExportReceiverEffect, +} from "./javascriptCommonJsExportReceivers.js"; interface CollectCallableInput { readonly node: t.Node; @@ -78,6 +81,10 @@ export const collectSemanticCallable = (input: CollectCallableInput): void => { export const collectSemanticModuleLink = ( node: t.Node, state: JavaScriptSemanticAnalysisState, + commonJsReceivers: ReadonlyMap< + t.AssignmentExpression, + CommonJsExportReceiverEffect + >, ): void => { if (t.isTSImportEqualsDeclaration(node)) collectImportEquals(node, state); else if (t.isImportDeclaration(node)) collectImports(node, state); @@ -110,7 +117,8 @@ export const collectSemanticModuleLink = ( node, ); else if (t.isVariableDeclarator(node)) collectRequireLink(node, state); - else if (t.isAssignmentExpression(node)) collectCommonJsExport(node, state); + else if (t.isAssignmentExpression(node)) + collectCommonJsExport(node, state, commonJsReceivers.get(node)); else if (t.isCallExpression(node)) collectUnboundRequireLink(node, state); }; @@ -469,12 +477,10 @@ const requirePatternBindings = ( const collectCommonJsExport = ( node: t.AssignmentExpression, state: JavaScriptSemanticAnalysisState, + receiverEffect: CommonJsExportReceiverEffect | undefined, ): void => { const exportedName = commonJsExportName(node.left, state); if (exportedName === undefined) return; - const receiverEffect = isCommonJsModuleExports(node.left, state) - ? null - : commonJsExportReceiverEffect(node.right, state); 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. @@ -501,40 +507,6 @@ const collectCommonJsExport = ( if (required !== undefined) state.linkedRequireCalls.add(required.call); }; -/** - * An outer property assignment captures its receiver before evaluating the - * RHS. When that chain assigns a freshly created identity to `module.exports`, - * the outer write targets the old exports object and cannot define an export - * on the value ultimately returned by the CommonJS module. Reassignments from - * existing bindings may preserve identity, so this guard leaves them as - * export candidates. - */ -const commonJsExportReceiverEffect = ( - node: t.Node, - state: JavaScriptSemanticAnalysisState, -): "detached" | "uncertain" | null => { - let value = node; - while (t.isAssignmentExpression(value, { operator: "=" })) { - if (isCommonJsModuleExports(value.left, state)) - return isFreshAssignmentResult(value.right) ? "detached" : "uncertain"; - value = value.right; - } - return null; -}; - -const isFreshAssignmentResult = (node: t.Node): boolean => { - let value = node; - while (t.isAssignmentExpression(value, { operator: "=" })) - value = value.right; - return ( - t.isArrowFunctionExpression(value) || - t.isFunctionExpression(value) || - t.isClassExpression(value) || - t.isObjectExpression(value) || - t.isArrayExpression(value) - ); -}; - const addModuleLink = ( state: JavaScriptSemanticAnalysisState, link: Omit & { @@ -831,32 +803,3 @@ const defaultExportLocalName = ( ? value.id.name : null; }; - -const commonJsExportName = ( - node: t.Node, - state: JavaScriptSemanticAnalysisState, -): string | undefined => { - // Rebinding the CommonJS `exports` alias does not replace `module.exports`. - if (!t.isMemberExpression(node) && !t.isOptionalMemberExpression(node)) - return undefined; - const key = semanticStaticPropertyKey(node.property, node.computed); - // A computed nonliteral key is unknown, but the empty string is a real exact - // CommonJS export name and must not collapse into the wildcard/default. - if (isUnshadowedGlobal(node.object, state, "exports")) - return key === null ? "*" : key; - if ( - t.isMemberExpression(node.object) && - isCommonJsModuleExports(node.object, state) - ) - return key === null ? "*" : key; - if (isCommonJsModuleExports(node, state)) return "default"; - return undefined; -}; - -const isCommonJsModuleExports = ( - node: t.Node, - state: JavaScriptSemanticAnalysisState, -): boolean => - (t.isMemberExpression(node) || t.isOptionalMemberExpression(node)) && - isUnshadowedGlobal(node.object, state, "module") && - semanticStaticPropertyKey(node.property, node.computed) === "exports"; diff --git a/src/domain/javascript/javascriptSemanticState.ts b/src/domain/javascript/javascriptSemanticState.ts index 4f2b091a11..96fa37d679 100644 --- a/src/domain/javascript/javascriptSemanticState.ts +++ b/src/domain/javascript/javascriptSemanticState.ts @@ -103,7 +103,7 @@ export interface JavaScriptSemanticAnalysisState { readonly conditionallySkippedExpressions: WeakSet; /** Unbound identifier writes retain their source sites for capture ordering. */ readonly ambientBindingWrites: Map; - /** Source-local writes, tagged by global object or import.meta identity. */ + /** Source-local writes, tagged by global, import.meta or CJS module identity. */ readonly ambientWrites: SemanticPropertyPathCoverage; /** Source sites distinguish replacement before an ambient identity capture from later writes. */ readonly ambientWriteEffects: JavaScriptSemanticMemberEffect[]; diff --git a/tests/acceptance/applications/exportedFunctionBindingsCli.test.ts b/tests/acceptance/applications/exportedFunctionBindingsCli.test.ts index 0271a016f8..c9ee1aeb10 100644 --- a/tests/acceptance/applications/exportedFunctionBindingsCli.test.ts +++ b/tests/acceptance/applications/exportedFunctionBindingsCli.test.ts @@ -195,6 +195,121 @@ describe("exported function binding resolution through stdio MCP and CLI", () => ); }); +describe("CommonJS receiver lifetimes through stdio MCP and CLI", () => { + it.each([ + "module.exports = {}; exports.parse = VALUE;", + "exports = {}; exports.parse = VALUE;", + "exports.parse = VALUE; module.exports = {};", + "module.exports.parse = VALUE; module.exports = {};", + "module.exports = {}; exports = module.exports; module.exports = {}; exports.parse = VALUE;", + 'module.exports = {}; exports.parse = "enabled";', + 'module.exports = {}; Reflect.set(module, "other", exports); exports.parse = VALUE;', + "module.exports = {}; let wrapper = module; wrapper = {}; Object.assign(wrapper, { exports }); exports.parse = VALUE;", + ])("excludes detached public export receivers: %s", async (source) => { + const fixture = await analyzeVersions( + (count) => + source.replace( + "VALUE", + `() => ({ kind: "result", count: ${String(count)} })`, + ), + "parse", + "app.cjs", + ); + for (const path of [fixture.leftPath, fixture.rightPath]) { + const value = requireFixture(path); + expect(Object.hasOwn(value, "parse")).toBe(false); + expect(value.parse).toBeUndefined(); + } + for (const evidence of [fixture.input.left, fixture.input.right]) { + const { graph } = applicationEnvelope.parse(evidence).normalized_result; + expect( + graph.nodes + .flatMap(({ observations }) => observations) + .filter( + ({ properties }) => + properties.exported_name === "parse" && + ["export-binding", "export-return-shapes"].includes( + String(properties.semantic_role), + ), + ), + ).toEqual([]); + } + await expectCliAnalysisParity(fixture); + const comparison = await compareThroughMcp(fixture.input); + expect(comparison).toMatchObject({ + left: { status: "missing" }, + right: { status: "missing" }, + summary: { added: 0, removed: 0, changed: 0 }, + }); + expect(await compareThroughCli(fixture)).toEqual(comparison); + }); + + it.each([ + "exports.parse = VALUE;", + "module.exports = {}; module.exports.parse = VALUE;", + "module.exports = {}; exports = module.exports; exports.parse = VALUE;", + "exports.parse = VALUE; Object.assign(module.exports, { other: exports });", + ])("retains connected public export receivers: %s", async (source) => { + const fixture = await analyzeVersions( + (count) => + source.replace( + "VALUE", + `() => ({ kind: "result", count: ${String(count)} })`, + ), + "parse", + "app.cjs", + ); + for (const [path, count] of [ + [fixture.leftPath, 1], + [fixture.rightPath, 2], + ] as const) + expect(requireFixture(path).parse()).toEqual({ kind: "result", count }); + await expectCliAnalysisParity(fixture); + const comparison = await compareThroughMcp(fixture.input); + expectCountChange(comparison); + expect(await compareThroughCli(fixture)).toEqual(comparison); + }); + + it.each([ + "if (false) module.exports = {}; exports.parse = VALUE;", + "exports.parse = VALUE; if (false) module.exports = {};", + "function replace() { module.exports = {}; } exports.parse = VALUE;", + "function replace() { exports = {}; } exports.parse = VALUE;", + 'const holder = {}; ({ [(module.exports = exports, "x")]: holder.x } = (module.exports = {})); exports.parse = VALUE;', + 'const { [(module.exports = exports, "x")]: value } = (module.exports = {}); exports.parse = VALUE;', + "const [value = (module.exports = exports)] = (module.exports = []); exports.parse = VALUE;", + "module.exports = {}; Object.assign(module, { exports }); exports.parse = VALUE;", + 'module.exports = {}; Object.defineProperty(module, "exports", { value: exports }); exports.parse = VALUE;', + 'module.exports = {}; Reflect.set(module, "exports", exports); exports.parse = VALUE;', + "module.exports = {}; const assign = Object.assign; assign.call(null, module, { exports }); exports.parse = VALUE;", + "module.exports = {}; const wrapper = module; Object.assign(wrapper, { exports }); exports.parse = VALUE;", + ])( + "keeps execution-dependent receiver identity uncertain: %s", + async (source) => { + const fixture = await analyzeVersions( + (count) => + source.replace( + "VALUE", + `() => ({ kind: "result", count: ${String(count)} })`, + ), + "parse", + "app.cjs", + ); + for (const [path, count] of [ + [fixture.leftPath, 1], + [fixture.rightPath, 2], + ] as const) + expect(requireFixture(path).parse()).toEqual({ kind: "result", count }); + for (const evidence of [fixture.input.left, fixture.input.right]) + expectUnresolvedExportBinding(evidence, "parse"); + await expectCliAnalysisParity(fixture); + const comparison = await compareThroughMcp(fixture.input); + expectUncertainComparison(comparison); + expect(await compareThroughCli(fixture)).toEqual(comparison); + }, + ); +}); + describe("CommonJS assignment-chain receiver identity through stdio MCP", () => { it("does not export a named write whose captured module.exports receiver is replaced", async () => { const fixture = await analyzeVersions(