diff --git a/src/domain/javascript/javascriptSemanticAnalysis.require_links.test.ts b/src/domain/javascript/javascriptSemanticAnalysis.require_links.test.ts index 831c9ebc41..f05d7a7587 100644 --- a/src/domain/javascript/javascriptSemanticAnalysis.require_links.test.ts +++ b/src/domain/javascript/javascriptSemanticAnalysis.require_links.test.ts @@ -62,6 +62,23 @@ describe("TypeScript import-equals links", () => { }); describe("CommonJS wrapper parameter redeclarations", () => { + it("does not infer a CommonJS wrapper from resolution mode in an explicit module", () => { + const path = "app/main.mjs"; + const parsed = parseJavaScriptSource( + 'var require; require("./dependency.cjs");', + path, + ); + expect(parsed).not.toBeNull(); + expect(parsed?.program.sourceType).toBe("module"); + const ir = analyzeParsedJavaScriptSemantics( + parsed!, + moduleResolutionModeForJavaScriptReference(path, "static-import"), + ); + expect(ir.moduleLinks).not.toContainEqual( + expect.objectContaining({ specifier: "./dependency.cjs" }), + ); + }); + it("retains ambient exports and require through uninitialized top-level vars", () => { const path = "app/main.cjs"; const parsed = parseJavaScriptSource( diff --git a/src/domain/javascript/javascriptSemanticAnalysis.ts b/src/domain/javascript/javascriptSemanticAnalysis.ts index 9947191e80..147d05f608 100644 --- a/src/domain/javascript/javascriptSemanticAnalysis.ts +++ b/src/domain/javascript/javascriptSemanticAnalysis.ts @@ -687,6 +687,7 @@ const createState = ( moduleLinks: [], linkedRequireCalls: new WeakSet(), moduleLinkBindings: new WeakMap(), + defaultExportExpressions: new WeakMap(), conditionallySkippedExpressions: new WeakSet(), requiredModuleEffects: new Map(), ambientBindingWrites: new Map(), diff --git a/src/domain/javascript/javascriptSemanticProjection.ts b/src/domain/javascript/javascriptSemanticProjection.ts index 59e92b98dd..2493a05c65 100644 --- a/src/domain/javascript/javascriptSemanticProjection.ts +++ b/src/domain/javascript/javascriptSemanticProjection.ts @@ -102,7 +102,7 @@ export const collectSemanticModuleLink = ( kind: "export", specifier: null, importedName: null, - localName: defaultDeclarationName(node.declaration), + localName: defaultExportLocalName(node.declaration), exportedName: "default", callableId: semanticCallableIdForNode(node.declaration), location: range(node), @@ -557,6 +557,11 @@ const addModuleLink = ( callableId: link.callableId ?? null, }; state.moduleLinks.push(collected); + if ( + t.isExportDefaultDeclaration(referenceNode) && + t.isExpression(referenceNode.declaration) + ) + state.defaultExportExpressions.set(collected, referenceNode.declaration); if (link.localName !== null) { const binding = resolveSemanticBindingState( state, @@ -816,13 +821,16 @@ const declarationNames = (declaration: t.Declaration): string[] => { return []; }; -const defaultDeclarationName = ( +const defaultExportLocalName = ( declaration: t.ExportDefaultDeclaration["declaration"], -): string | null => - (t.isFunctionDeclaration(declaration) || t.isClassDeclaration(declaration)) && - t.isIdentifier(declaration.id) - ? declaration.id.name +): string | null => { + const value = unwrapJavaScriptExpression(declaration).node; + if (t.isIdentifier(value)) return value.name; + return (t.isFunctionDeclaration(value) || t.isClassDeclaration(value)) && + t.isIdentifier(value.id) + ? value.id.name : null; +}; const commonJsExportName = ( node: t.Node, diff --git a/src/domain/javascript/javascriptSemanticReturns.ts b/src/domain/javascript/javascriptSemanticReturns.ts index 11aaddc09d..50adf38ef1 100644 --- a/src/domain/javascript/javascriptSemanticReturns.ts +++ b/src/domain/javascript/javascriptSemanticReturns.ts @@ -28,6 +28,7 @@ import { evaluateSemanticExpression } from "./javascriptSemanticValues.js"; import { range } from "./javascriptStaticAnalysisHelpers.js"; import { semanticReturnCoverage } from "./javascriptSemanticCoverage.js"; import { semanticCallableResultExpressions } from "./javascriptSemanticCallableResults.js"; +import { semanticReadInitializers } from "./javascriptSemanticMutationInitializers.js"; interface ReturnExpression { readonly node: t.Node | null; @@ -104,15 +105,18 @@ export const resolveSemanticModuleCallables = ( if (link.kind === "type-import") return { ...link, callableId: null }; // Named exports follow their lexical binding, including every assignment. // A declaration's ID or a callable's display name cannot prove that link. - if (link.localName === null) + const captured = state.defaultExportExpressions.get(link); + if (link.localName === null && captured === undefined) return link.callableId !== null && callableIds.has(link.callableId) ? link : { ...link, callableId: null }; const binding = state.moduleLinkBindings.get(link); const resolved = - binding === undefined - ? [] - : callableIdsForBinding(binding, state, callableIds, new Set()); + captured !== undefined + ? callableIdsForNode(captured, state, callableIds, new Set(), true) + : binding === undefined + ? [] + : callableIdsForBinding(binding, state, callableIds, new Set()); const provenance = binding === undefined || link.specifier !== null ? undefined @@ -139,21 +143,29 @@ const callableIdsForBinding = ( state: JavaScriptSemanticAnalysisState, admitted: ReadonlySet, seen: ReadonlySet, + read?: t.Node, ): string[] => { // A single initializer does not prove an assignment executes or replace // the unknown incoming value of a parameter/catch binding. if ( seen.has(binding.bindingId) || - binding.initializers.length !== 1 || + (read === undefined && binding.initializers.length !== 1) || binding.definitions.some( ({ kind }) => - kind === "assignment" || kind === "parameter" || kind === "catch", + (read === undefined && kind === "assignment") || + kind === "parameter" || + kind === "catch", ) ) return []; - const initializer = binding.initializers[0]; + const selected = + read === undefined + ? binding + : semanticReadInitializers(binding, read, state.parentsByNode); + const initializer = selected.initializers[0]; if ( initializer === undefined || + selected.initializers.length !== 1 || initializer.projection.length > 0 || state.conditionallySkippedExpressions.has(initializer.node) ) @@ -163,6 +175,7 @@ const callableIdsForBinding = ( state, admitted, new Set([...seen, binding.bindingId]), + read !== undefined, ); }; @@ -171,17 +184,24 @@ const callableIdsForNode = ( state: JavaScriptSemanticAnalysisState, admitted: ReadonlySet, seen: ReadonlySet, + snapshot = false, ): string[] => { const unwrapped = unwrapJavaScriptExpression(node).node; if (unwrapped !== node) - return callableIdsForNode(unwrapped, state, admitted, seen); + return callableIdsForNode(unwrapped, state, admitted, seen, snapshot); const direct = semanticCallableIdForNode(node); if (direct !== null && admitted.has(direct)) return [direct]; if (t.isIdentifier(node)) { const binding = resolveSemanticBindingState(state, node, node.name); return binding === undefined ? [] - : callableIdsForBinding(binding, state, admitted, seen); + : callableIdsForBinding( + binding, + state, + admitted, + seen, + snapshot ? node : undefined, + ); } return []; }; diff --git a/src/domain/javascript/javascriptSemanticState.ts b/src/domain/javascript/javascriptSemanticState.ts index 2d38c45964..4f2b091a11 100644 --- a/src/domain/javascript/javascriptSemanticState.ts +++ b/src/domain/javascript/javascriptSemanticState.ts @@ -94,6 +94,11 @@ export interface JavaScriptSemanticAnalysisState { JavaScriptSemanticModuleLink, JavaScriptSemanticBindingState >; + /** Default expressions capture their value at the export statement. */ + readonly defaultExportExpressions: WeakMap< + JavaScriptSemanticModuleLink, + t.Node + >; /** Initializer expressions whose write can be skipped by enclosing control flow. */ readonly conditionallySkippedExpressions: WeakSet; /** Unbound identifier writes retain their source sites for capture ordering. */ @@ -263,8 +268,10 @@ export const isUnmodifiedCommonJsWrapperBinding = ( name: string, state: JavaScriptSemanticAnalysisState, ): boolean => { + // Parser sourceType separates explicit modules from script/CommonJS source; + // specifier resolution mode does not establish that distinction. if ( - state.staticImportResolutionMode !== "require" || + state.program.sourceType !== "script" || !["module", "exports", "require"].includes(name) || !t.isIdentifier(node, { name }) ) diff --git a/tests/acceptance/applications/exportedFunctionBindingsCli.test.ts b/tests/acceptance/applications/exportedFunctionBindingsCli.test.ts index d7fac06987..0271a016f8 100644 --- a/tests/acceptance/applications/exportedFunctionBindingsCli.test.ts +++ b/tests/acceptance/applications/exportedFunctionBindingsCli.test.ts @@ -462,6 +462,48 @@ describe("stable exported function bindings through stdio MCP", () => { const fixture = await analyzeVersions(source, "default"); expectCountChange(await compareThroughMcp(fixture.input)); }); + + it.each([ + { + name: "a local callable captured by an identifier default export", + source: (count: number) => + `const run = () => ({ kind: "result", count: ${String(count)} });\nexport default run;`, + runtimeValue: (count: number) => ({ kind: "result", count }), + expected: "known", + }, + { + name: "the latest callable assigned before the default export", + source: (count: number) => + `let run = () => ({ count: 0 });\nrun = () => ({ kind: "result", count: ${String(count)} });\nexport default run;`, + runtimeValue: (count: number) => ({ kind: "result", count }), + expected: "known", + }, + { + name: "a callable assigned only after the default export capture", + source: (count: number) => + `var run;\nexport default run;\nrun = () => ({ kind: "result", count: ${String(count)} });`, + runtimeValue: () => undefined, + expected: "unknown", + }, + { + name: "a reassignment after the default export capture", + source: (count: number) => + `let run = () => ({ kind: "result", count: ${String(count)} });\nexport default run;\nrun = () => ({ count: 99 });`, + runtimeValue: (count: number) => ({ kind: "result", count }), + expected: "known", + }, + ] as const)( + "uses the value at $name", + async ({ source, runtimeValue, expected }) => { + const fixture = await analyzeVersions(source, "default"); + await expectRuntimeExports(fixture, runtimeValue); + const mcp = await compareThroughMcp(fixture.input); + const cli = await compareThroughCli(fixture); + expect(cli).toEqual(mcp); + if (expected === "known") expectCountChange(mcp); + else expectUncertainComparison(mcp); + }, + ); }); describe("export bindings with loop and assignment-only writes", () => {