diff --git a/src/domain/javascript/javascriptSemanticAnalysis.structure.test.ts b/src/domain/javascript/javascriptSemanticAnalysis.structure.test.ts index cd3b85932f..3afd181d28 100644 --- a/src/domain/javascript/javascriptSemanticAnalysis.structure.test.ts +++ b/src/domain/javascript/javascriptSemanticAnalysis.structure.test.ts @@ -342,6 +342,38 @@ describe("JavaScript semantic analysis: structure 2", () => { ); }); + it("resolves the value of a simple assignment expression exported through CommonJS", () => { + const ir = analyzeJavaScriptSemantics(` + function parse() { return { kind: "result" }; } + const holder = {}; + module.exports = holder.value = parse; + `); + expect(ir.moduleLinks).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + kind: "commonjs-export", + localName: "parse", + exportedName: "default", + }), + ]), + ); + }); + + it.each(["parse", "default"])( + "does not export a %s property write to a replaced module.exports receiver", + (name) => { + const ir = analyzeJavaScriptSemantics(` + module.exports.${name} = (module.exports = () => ({ kind: "result" })); + `); + expect(ir.moduleLinks).toEqual([ + expect.objectContaining({ + kind: "commonjs-export", + exportedName: "default", + }), + ]); + }, + ); + it("classifies destructuring and loop targets as writes, not reads", () => { const ir = analyzeJavaScriptSemantics(` let a, b, c; diff --git a/src/domain/javascript/javascriptSemanticProjection.ts b/src/domain/javascript/javascriptSemanticProjection.ts index f52d4495f3..3587e3c49d 100644 --- a/src/domain/javascript/javascriptSemanticProjection.ts +++ b/src/domain/javascript/javascriptSemanticProjection.ts @@ -372,22 +372,67 @@ const collectCommonJsExport = ( ): void => { const exportedName = commonJsExportName(node.left, state); if (exportedName === undefined) return; - const origin = semanticRequireOrigin(node.right, state); + 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. + let value = + node.operator === "=" && receiverEffect !== "uncertain" ? node.right : null; + while (t.isAssignmentExpression(value, { operator: "=" })) + value = value.right; + const origin = + value === null ? undefined : semanticRequireOrigin(value, state); addModuleLink( state, { kind: "commonjs-export", specifier: origin?.specifier ?? null, importedName: origin?.importedPath.at(-1) ?? null, - localName: t.isIdentifier(node.right) ? node.right.name : null, + localName: value !== null && t.isIdentifier(value) ? value.name : null, exportedName, - callableId: semanticCallableIdForNode(node.right), + callableId: value === null ? null : semanticCallableIdForNode(value), location: range(node), }, node, ); }; +/** + * 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 & { @@ -652,15 +697,17 @@ const commonJsExportName = ( return key === null ? "*" : key || "*"; if ( t.isMemberExpression(node.object) && - isUnshadowedGlobal(node.object.object, state, "module") && - semanticStaticPropertyKey(node.object.property, node.object.computed) === - "exports" + isCommonJsModuleExports(node.object, state) ) return key === null ? "*" : key || "default"; - if ( - isUnshadowedGlobal(node.object, state, "module") && - semanticStaticPropertyKey(node.property, node.computed) === "exports" - ) - return "default"; + 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/tests/acceptance/applications/exportedFunctionBindingsCli.test.ts b/tests/acceptance/applications/exportedFunctionBindingsCli.test.ts index bb2b10c34b..dece16d1b0 100644 --- a/tests/acceptance/applications/exportedFunctionBindingsCli.test.ts +++ b/tests/acceptance/applications/exportedFunctionBindingsCli.test.ts @@ -135,6 +135,158 @@ describe("exported function binding resolution through the CLI", () => { }); expect(await compareThroughMcp(fixture.input)).toEqual(cli); }); + + it.each([ + ["the CommonJS exports alias", "exports"], + ["an ordinary object property", "holder.value"], + ])( + "resolves exported callable values through = chains ending at %s", + async (_, target) => { + const fixture = await analyzeVersions( + (count) => + `const holder = {};\nfunction parse() { return { kind: "result", count: ${String(count)} }; }\nmodule.exports = ${target} = parse;`, + "default", + "app.cjs", + ); + expect(requireFixture(fixture.leftPath)()).toEqual({ + kind: "result", + count: 1, + }); + expect(requireFixture(fixture.rightPath)()).toEqual({ + kind: "result", + count: 2, + }); + for (const evidence of [fixture.input.left, fixture.input.right]) + expectExportedCallableBinding(evidence, "parse"); + const cli = await compareThroughCli(fixture); + expectCountChange(cli); + expect(await compareThroughMcp(fixture.input)).toEqual(cli); + }, + ); +}); + +describe("CommonJS assignment-chain receiver identity through the CLI", () => { + it("does not export a named write whose captured module.exports receiver is replaced", async () => { + const fixture = await analyzeVersions( + (count) => + `const VERSION = ${String(count)};\nmodule.exports.parse = (module.exports = () => ({ count: VERSION }));`, + "parse", + "app.cjs", + ); + for (const path of [fixture.leftPath, fixture.rightPath]) { + const value = requireFixture(path); + expect(typeof value).toBe("function"); + expect(Reflect.ownKeys(value)).toEqual(["length", "name"]); + } + for (const evidence of [fixture.input.left, fixture.input.right]) { + const analysis = z + .object({ + normalized_result: javascriptApplicationAnalysisResultSchema, + }) + .parse(evidence).normalized_result; + expect( + analysis.graph.nodes + .flatMap(({ observations }) => observations) + .filter( + ({ properties }) => + properties.semantic_role === "export-binding" && + properties.exported_name === "parse", + ), + ).toEqual([]); + } + const cli = await compareThroughCli(fixture); + expect(cli).toMatchObject({ + left: { status: "missing" }, + right: { status: "missing" }, + summary: { added: 0, removed: 0, changed: 0 }, + }); + expect(await compareThroughMcp(fixture.input)).toEqual(cli); + }); + + it("keeps a named property export when its receiver remains stable through the RHS chain", async () => { + const fixture = await analyzeVersions( + (count) => + `const holder = {};\nfunction parse() { return { kind: "result", count: ${String(count)} }; }\nmodule.exports.parse = holder.value = parse;`, + "parse", + "app.cjs", + ); + for (const path of [fixture.leftPath, fixture.rightPath]) + expect(requireFixture(path).parse()).toEqual({ + kind: "result", + count: path === fixture.leftPath ? 1 : 2, + }); + const cli = await compareThroughCli(fixture); + expectCountChange(cli); + expect(await compareThroughMcp(fixture.input)).toEqual(cli); + }); + + it("keeps receiver identity unresolved when module.exports is reassigned to the same function", async () => { + const fixture = await analyzeVersions( + (count) => + `const VERSION = ${String(count)};\nfunction parse() { return { count: VERSION }; }\nmodule.exports = parse;\nmodule.exports.parse = (module.exports = parse);`, + "parse", + "app.cjs", + ); + for (const path of [fixture.leftPath, fixture.rightPath]) { + const value = requireFixture(path); + expect(value.parse).toBe(value); + expect(value.parse()).toEqual({ + count: path === fixture.leftPath ? 1 : 2, + }); + } + for (const evidence of [fixture.input.left, fixture.input.right]) + expectUnresolvedExportBinding(evidence, "parse"); + const cli = await compareThroughCli(fixture); + expect(cli).toMatchObject({ + left: { status: "unavailable" }, + right: { status: "unavailable" }, + }); + expect(await compareThroughMcp(fixture.input)).toEqual(cli); + }); + + it("keeps receiver replacement by an existing function unresolved", async () => { + const fixture = await analyzeVersions( + (count) => + `const VERSION = ${String(count)};\nfunction parse() { return { count: VERSION }; }\nmodule.exports.parse = (module.exports = parse);`, + "parse", + "app.cjs", + ); + for (const path of [fixture.leftPath, fixture.rightPath]) { + const value = requireFixture(path); + expect(typeof value).toBe("function"); + expect(value.parse).toBeUndefined(); + expect(value()).toEqual({ + count: path === fixture.leftPath ? 1 : 2, + }); + } + for (const evidence of [fixture.input.left, fixture.input.right]) + expectUnresolvedExportBinding(evidence, "parse"); + const cli = await compareThroughCli(fixture); + expect(cli).toMatchObject({ + left: { status: "unavailable" }, + right: { status: "unavailable" }, + }); + expect(await compareThroughMcp(fixture.input)).toEqual(cli); + }); + + it("resolves a nested lexical parse binding in an ordinary module.exports chain", async () => { + const fixture = await analyzeVersions( + (count) => + `function parse() { return { count: VERSION }; }\nfunction configure() { const parse = () => ({ count: 7 }); let local; module.exports = local = parse; }\nconst VERSION = ${String(count)};\nconfigure();`, + "default", + "app.cjs", + ); + for (const path of [fixture.leftPath, fixture.rightPath]) + expect(requireFixture(path)()).toEqual({ count: 7 }); + for (const evidence of [fixture.input.left, fixture.input.right]) + expectExportedCallableBinding(evidence, "parse"); + const cli = await compareThroughCli(fixture); + expect(cli).toMatchObject({ + summary: { added: 0, removed: 0, changed: 0, unknown: 0 }, + changes: [], + }); + expect(await compareThroughMcp(fixture.input)).toEqual(cli); + }); }); describe("stable exported function bindings through the CLI", () => { @@ -505,6 +657,58 @@ const expectRuntimeExports = async ( expect(await runtimeExport(path, exportName)).toEqual(expected(count)); }; +const expectExportedCallableBinding = ( + evidence: unknown, + localName: string, +): void => { + const analysis = z + .object({ + normalized_result: javascriptApplicationAnalysisResultSchema, + }) + .parse(evidence).normalized_result; + const exportBinding = analysis.graph.nodes + .flatMap(({ observations }) => observations) + .find( + ({ properties }) => + properties.semantic_role === "export-binding" && + properties.exported_name === "default", + ); + expect(exportBinding?.properties.local_name).toBe(localName); + expect(analysis.semantic_graph.nodes).toEqual( + expect.arrayContaining([ + expect.objectContaining({ kind: "function", label: localName }), + ]), + ); +}; + +const expectUnresolvedExportBinding = ( + evidence: unknown, + exportName: string, +): void => { + const analysis = z + .object({ + normalized_result: javascriptApplicationAnalysisResultSchema, + }) + .parse(evidence).normalized_result; + const observations = analysis.graph.nodes.flatMap( + ({ observations }) => observations, + ); + expect( + observations.filter( + ({ properties }) => + properties.semantic_role === "export-binding" && + properties.exported_name === exportName, + ), + ).not.toHaveLength(0); + expect( + observations.filter( + ({ properties }) => + properties.semantic_role === "export-return-shapes" && + properties.exported_name === exportName, + ), + ).toEqual([]); +}; + const expectUncertainComparison = ( result: Awaited>, ): void => {