diff --git a/src/domain/javascript/javascriptSemanticAnalysis.ts b/src/domain/javascript/javascriptSemanticAnalysis.ts index f922fa6e6..7bdbdcc09 100644 --- a/src/domain/javascript/javascriptSemanticAnalysis.ts +++ b/src/domain/javascript/javascriptSemanticAnalysis.ts @@ -286,6 +286,8 @@ const createState = (program: t.Program): JavaScriptSemanticAnalysisState => { callables: [], callableNodesById: new Map(), moduleLinks: [], + moduleLinkBindings: new WeakMap(), + conditionalInitializers: new WeakSet(), }; }; @@ -297,9 +299,34 @@ const collectDefinitions = ( currentSemanticScope(state.scopes), ]; const openedScopes = new WeakMap(); + const assignments: { + readonly node: t.Node; + readonly scope: JavaScriptSemanticScopeState; + }[] = []; + const functionBodies: { + readonly body: t.BlockStatement; + readonly scope: JavaScriptSemanticScopeState; + readonly parameters: t.Function["params"]; + }[] = []; traverseJavaScriptAst(program, { - enter: (node, parent) => { + enter: (node, parent, readAncestors) => { let parentScope = currentSemanticScope(stack); + if ( + parent !== null && + (t.isObjectMethod(parent) || t.isClassMethod(parent)) && + parent.computed && + parent.key === node && + parentScope.parentScopeId !== null + ) { + // A computed method key is evaluated before entering the method's + // parameter and body environments. + const keyScope = state.scopesById.get(parentScope.parentScopeId); + if (keyScope !== undefined) { + stack.push(keyScope); + openedScopes.set(node, 1); + parentScope = keyScope; + } + } if ( parent !== null && t.isWithStatement(parent) && @@ -324,6 +351,12 @@ const collectDefinitions = ( if (nested !== undefined) { stack.push(nested); openedScopes.set(node, (openedScopes.get(node) ?? 0) + 1); + if (t.isBlockStatement(node) && t.isFunction(parent)) + functionBodies.push({ + body: node, + scope: nested, + parameters: parent.params, + }); } const scope = currentSemanticScope(stack); state.scopeByNode.set(node, scope); @@ -335,12 +368,95 @@ const collectDefinitions = ( state, }); bindInnerDeclaration(node, parent, scope, state); + if ( + t.isVariableDeclarator(node) && + node.init != null && + parent !== null && + t.isVariableDeclaration(parent, { kind: "var" }) && + hasConditionalInitializer(readAncestors()) + ) + state.conditionalInitializers.add(node.init); + if ( + t.isAssignmentExpression(node) || + t.isUpdateExpression(node) || + t.isForOfStatement(node) || + t.isForInStatement(node) + ) + assignments.push({ node, scope }); }, exit: (node) => { for (let count = openedScopes.get(node) ?? 0; count > 0; count--) stack.pop(); }, }); + // All declarations must exist before resolving writes, including hoisted + // functions and local declarations that shadow an outer binding. + for (const { node, scope } of assignments) bindAssignment(node, scope, state); + for (const { body, scope, parameters } of functionBodies) + for (const parameter of parameters) + for (const identifier of assignedPatternIdentifiers( + t.isTSParameterProperty(parameter) ? parameter.parameter : parameter, + )) + copyParameterToBody({ identifier, body, scope, state }); +}; + +const copyParameterToBody = (input: { + readonly identifier: t.Identifier; + readonly body: t.BlockStatement; + readonly scope: JavaScriptSemanticScopeState; + readonly state: JavaScriptSemanticAnalysisState; +}): void => { + const { identifier, body, scope, state } = input; + const binding = scope.bindings.get(identifier.name); + if ( + binding === undefined || + binding.kind !== "variable" || + binding.definitions.some(({ kind }) => kind === "function") + ) + return; + // A separate body var environment starts with the parameter's value. The + // real parameter identifier retains its scope and source location; entryBody + // records when the copy occurs without inventing an expression in the AST. + binding.initializers.unshift({ + node: identifier, + projection: [], + entryBody: body, + }); + for (let index = binding.initializers.length - 1; index >= 0; index -= 1) { + const initializer = binding.initializers[index]; + if ( + initializer !== undefined && + initializer.entryBody === undefined && + initializer.projection.length === 0 && + t.isIdentifier(initializer.node) && + resolveSemanticBindingState( + state, + initializer.node, + initializer.node.name, + ) === binding + ) + // Direct self-initialization preserves the prior value, including any + // actual overwrite before it. Keep the source definition/reference facts. + binding.initializers.splice(index, 1); + } +}; + +const hasConditionalInitializer = (ancestors: readonly t.Node[]): boolean => { + // A hoisted var can remain undefined when control flow skips its initializer. + // Conditions outside the owning callable/static block do not govern its body. + for (let index = ancestors.length - 1; index >= 0; index -= 1) { + const node = ancestors[index]; + if (t.isFunction(node) || t.isProgram(node) || t.isStaticBlock(node)) + return false; + if ( + t.isIfStatement(node) || + t.isLoop(node) || + t.isSwitchCase(node) || + t.isTryStatement(node) + ) + return true; + } + return false; }; const bindOuterDeclaration = ( @@ -408,7 +524,14 @@ const bindInnerDeclaration = ( mutable: true, kind: "catch", }); - else if (t.isAssignmentExpression(node)) { +}; + +const bindAssignment = ( + node: t.Node, + scope: JavaScriptSemanticScopeState, + state: JavaScriptSemanticAnalysisState, +): void => { + if (t.isAssignmentExpression(node)) { if (t.isIdentifier(node.left)) addAssignment( node.left, @@ -426,8 +549,11 @@ const bindInnerDeclaration = ( } else if (t.isUpdateExpression(node) && t.isIdentifier(node.argument)) addAssignment(node.argument, node, scope, state); else if (t.isForOfStatement(node) || t.isForInStatement(node)) - for (const identifier of assignedPatternIdentifiers(node.left)) - addAssignment(identifier, node, scope, state); + for (const pattern of t.isVariableDeclaration(node.left) + ? node.left.declarations.map(({ id }) => id) + : [node.left]) + for (const identifier of assignedPatternIdentifiers(pattern)) + addAssignment(identifier, node, scope, state); }; const assignedPatternIdentifiers = ( @@ -539,12 +665,42 @@ const scopeKind = ( if (t.isStaticBlock(node)) return "static-block"; if ( t.isBlockStatement(node) && - !(parent !== null && t.isFunction(parent) && parent.body === node) - ) - return "block"; + parent !== null && + t.isFunction(parent) && + parent.body === node + ) { + // Parameter expressions cannot see declarations in the function body. + // Retain the outer function scope for parameters and callable ownership; + // body declarations belong to a separate variable environment. + return functionParametersHaveExpressions(parent) ? "function" : undefined; + } + if (t.isBlockStatement(node)) return "block"; return undefined; }; +const functionParametersHaveExpressions = (node: t.Function): boolean => { + const pending: t.Node[] = [...node.params]; + while (pending.length > 0) { + const parameter = pending.pop(); + if (t.isAssignmentPattern(parameter)) return true; + if (t.isTSParameterProperty(parameter)) pending.push(parameter.parameter); + else if (t.isRestElement(parameter)) pending.push(parameter.argument); + else if (t.isArrayPattern(parameter)) { + for (const element of parameter.elements) + if (element !== null) pending.push(element); + } else if (t.isObjectPattern(parameter)) { + for (const property of parameter.properties) { + if (t.isRestElement(property)) pending.push(property.argument); + else { + if (property.computed) return true; + pending.push(property.value); + } + } + } + } + return false; +}; + const bindImports = ( node: t.ImportDeclaration, scope: JavaScriptSemanticScopeState, diff --git a/src/domain/javascript/javascriptSemanticMemberMutations.ts b/src/domain/javascript/javascriptSemanticMemberMutations.ts index 0d3a00f22..c9fa49558 100644 --- a/src/domain/javascript/javascriptSemanticMemberMutations.ts +++ b/src/domain/javascript/javascriptSemanticMemberMutations.ts @@ -171,8 +171,10 @@ const mutationInitializers = ( if (binding.initializers.length < 2) return binding.initializers; const mutationPosition = directMutationPosition(mutation, parents); if (mutationPosition === null) return binding.initializers; - const positions = binding.initializers.map(({ node }) => - directInitializerPosition(node, parents), + const positions = binding.initializers.map(({ node, entryBody }) => + entryBody === undefined + ? directInitializerPosition(node, parents) + : { body: entryBody.body, index: -1 }, ); if ( positions.some( diff --git a/src/domain/javascript/javascriptSemanticProjection.ts b/src/domain/javascript/javascriptSemanticProjection.ts index 53881d632..89b1e9ed6 100644 --- a/src/domain/javascript/javascriptSemanticProjection.ts +++ b/src/domain/javascript/javascriptSemanticProjection.ts @@ -20,7 +20,7 @@ import { evaluateSemanticBinding, evaluateSemanticProvenance, } from "./javascriptSemanticValues.js"; -import { range, stringValue } from "./javascriptStaticAnalysisHelpers.js"; +import { range } from "./javascriptStaticAnalysisHelpers.js"; import { propertyName, semanticStaticPropertyKey, @@ -68,25 +68,33 @@ export const collectSemanticModuleLink = ( ): void => { if (t.isImportDeclaration(node)) collectImports(node, state); else if (t.isExportAllDeclaration(node)) - addModuleLink(state, { - kind: "re-export", - specifier: node.source.value, - importedName: "*", - localName: null, - exportedName: "*", - location: range(node), - }); + addModuleLink( + state, + { + kind: "re-export", + specifier: node.source.value, + importedName: "*", + localName: null, + exportedName: "*", + location: range(node), + }, + node, + ); else if (t.isExportNamedDeclaration(node)) collectNamedExports(node, state); else if (t.isExportDefaultDeclaration(node)) - addModuleLink(state, { - kind: "export", - specifier: null, - importedName: null, - localName: defaultDeclarationName(node.declaration), - exportedName: "default", - callableId: semanticCallableIdForNode(node.declaration), - location: range(node), - }); + addModuleLink( + state, + { + kind: "export", + specifier: null, + importedName: null, + localName: defaultDeclarationName(node.declaration), + exportedName: "default", + callableId: semanticCallableIdForNode(node.declaration), + location: range(node), + }, + node, + ); else if (t.isVariableDeclarator(node)) collectRequireLink(node, state); else if (t.isAssignmentExpression(node)) collectCommonJsExport(node, state); }; @@ -233,27 +241,35 @@ const collectImports = ( state: JavaScriptSemanticAnalysisState, ): void => { if (node.specifiers.length === 0) - addModuleLink(state, { - kind: "import", - specifier: node.source.value, - importedName: null, - localName: null, - exportedName: null, - location: range(node), - }); + addModuleLink( + state, + { + kind: "import", + specifier: node.source.value, + importedName: null, + localName: null, + exportedName: null, + location: range(node), + }, + node, + ); for (const specifier of node.specifiers) - addModuleLink(state, { - kind: "import", - specifier: node.source.value, - importedName: t.isImportDefaultSpecifier(specifier) - ? "default" - : t.isImportNamespaceSpecifier(specifier) - ? "*" - : propertyName(specifier.imported), - localName: specifier.local.name, - exportedName: null, - location: range(specifier), - }); + addModuleLink( + state, + { + kind: "import", + specifier: node.source.value, + importedName: t.isImportDefaultSpecifier(specifier) + ? "default" + : t.isImportNamespaceSpecifier(specifier) + ? "*" + : propertyName(specifier.imported), + localName: specifier.local.name, + exportedName: null, + location: range(specifier), + }, + node, + ); }; const collectNamedExports = ( @@ -262,25 +278,33 @@ const collectNamedExports = ( ): void => { if (node.declaration !== null && node.declaration !== undefined) for (const localName of declarationNames(node.declaration)) - addModuleLink(state, { - kind: "export", - specifier: null, - importedName: null, - localName, - exportedName: localName, - callableId: declarationCallableId(node.declaration, localName), - location: range(node.declaration), - }); + addModuleLink( + state, + { + kind: "export", + specifier: null, + importedName: null, + localName, + exportedName: localName, + callableId: declarationCallableId(node.declaration, localName), + location: range(node.declaration), + }, + node, + ); for (const specifier of node.specifiers) if (t.isExportSpecifier(specifier)) - addModuleLink(state, { - kind: node.source == null ? "export" : "re-export", - specifier: node.source?.value ?? null, - importedName: propertyName(specifier.local), - localName: node.source == null ? propertyName(specifier.local) : null, - exportedName: propertyName(specifier.exported), - location: range(specifier), - }); + addModuleLink( + state, + { + kind: node.source == null ? "export" : "re-export", + specifier: node.source?.value ?? null, + importedName: propertyName(specifier.local), + localName: node.source == null ? propertyName(specifier.local) : null, + exportedName: propertyName(specifier.exported), + location: range(specifier), + }, + node, + ); }; const collectRequireLink = ( @@ -290,14 +314,18 @@ const collectRequireLink = ( const origin = semanticRequireOrigin(node.init, state); if (origin === undefined) return; for (const binding of requirePatternBindings(node.id, origin.importedPath)) - addModuleLink(state, { - kind: "require", - specifier: origin.specifier, - importedName: binding.importedName, - localName: binding.localName, - exportedName: null, - location: range(node), - }); + addModuleLink( + state, + { + kind: "require", + specifier: origin.specifier, + importedName: binding.importedName, + localName: binding.localName, + exportedName: null, + location: range(node), + }, + node, + ); }; const requirePatternBindings = ( @@ -334,15 +362,19 @@ const collectCommonJsExport = ( const exportedName = commonJsExportName(node.left, state); if (exportedName === undefined) return; const origin = semanticRequireOrigin(node.right, 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, - exportedName, - callableId: semanticCallableIdForNode(node.right), - location: range(node), - }); + addModuleLink( + state, + { + kind: "commonjs-export", + specifier: origin?.specifier ?? null, + importedName: origin?.importedPath.at(-1) ?? null, + localName: t.isIdentifier(node.right) ? node.right.name : null, + exportedName, + callableId: semanticCallableIdForNode(node.right), + location: range(node), + }, + node, + ); }; const addModuleLink = ( @@ -350,8 +382,18 @@ const addModuleLink = ( link: Omit & { readonly callableId?: string | null; }, + referenceNode: t.Node, ): void => { - state.moduleLinks.push({ ...link, callableId: link.callableId ?? null }); + const collected = { ...link, callableId: link.callableId ?? null }; + state.moduleLinks.push(collected); + if (link.localName !== null) { + const binding = resolveSemanticBindingState( + state, + referenceNode, + link.localName, + ); + if (binding !== undefined) state.moduleLinkBindings.set(collected, binding); + } }; /** Deterministic callable identity shared by collection and return recovery. */ diff --git a/src/domain/javascript/javascriptSemanticReturns.ts b/src/domain/javascript/javascriptSemanticReturns.ts index 15b698a02..bb931fba5 100644 --- a/src/domain/javascript/javascriptSemanticReturns.ts +++ b/src/domain/javascript/javascriptSemanticReturns.ts @@ -93,19 +93,14 @@ export const resolveSemanticModuleCallables = ( callables: readonly JavaScriptSemanticCallable[], ): JavaScriptSemanticModuleLink[] => { const callableIds = new Set(callables.map(({ callableId }) => callableId)); - const root = state.scopes.find(({ kind }) => kind === "program"); return state.moduleLinks.map((link) => { - if (link.callableId !== null && callableIds.has(link.callableId)) - return link; - if (link.localName === null || root === undefined) - return { ...link, callableId: null }; - const named = callables.filter( - ({ name, containerScopeId }) => - name === link.localName && containerScopeId === root.scopeId, - ); - if (named.length === 1) - return { ...link, callableId: named[0]?.callableId ?? null }; - const binding = root.bindings.get(link.localName); + // 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) + return link.callableId !== null && callableIds.has(link.callableId) + ? link + : { ...link, callableId: null }; + const binding = state.moduleLinkBindings.get(link); const resolved = binding === undefined ? [] @@ -154,10 +149,24 @@ const callableIdsForBinding = ( admitted: ReadonlySet, seen: ReadonlySet, ): string[] => { - if (seen.has(binding.bindingId) || binding.initializers.length !== 1) + // 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 || + binding.definitions.some( + ({ kind }) => + kind === "assignment" || kind === "parameter" || kind === "catch", + ) + ) return []; const initializer = binding.initializers[0]; - if (initializer === undefined || initializer.projection.length > 0) return []; + if ( + initializer === undefined || + initializer.projection.length > 0 || + state.conditionalInitializers.has(initializer.node) + ) + return []; return callableIdsForNode( initializer.node, state, diff --git a/src/domain/javascript/javascriptSemanticState.ts b/src/domain/javascript/javascriptSemanticState.ts index 7f17308b2..cd5f40e4b 100644 --- a/src/domain/javascript/javascriptSemanticState.ts +++ b/src/domain/javascript/javascriptSemanticState.ts @@ -12,6 +12,7 @@ import type { interface JavaScriptSemanticInitializer { readonly node: t.Node; readonly projection: readonly (string | number | null)[]; + readonly entryBody?: t.BlockStatement; } /** Mutable binding state used only while constructing the immutable IR. */ @@ -46,6 +47,11 @@ export interface JavaScriptSemanticAnalysisState { readonly callables: JavaScriptSemanticCallable[]; readonly callableNodesById: Map; readonly moduleLinks: JavaScriptSemanticModuleLink[]; + readonly moduleLinkBindings: WeakMap< + JavaScriptSemanticModuleLink, + JavaScriptSemanticBindingState + >; + readonly conditionalInitializers: WeakSet; } /** Return the active scope from a non-empty construction stack. */ diff --git a/tests/acceptance/applications/exportedFunctionBindingsCli.test.ts b/tests/acceptance/applications/exportedFunctionBindingsCli.test.ts new file mode 100644 index 000000000..f32f9e5e2 --- /dev/null +++ b/tests/acceptance/applications/exportedFunctionBindingsCli.test.ts @@ -0,0 +1,618 @@ +import { execFile } from "node:child_process"; +import { mkdir, writeFile } from "node:fs/promises"; +import { join, resolve } from "node:path"; +import { promisify } from "node:util"; + +import { Client } from "@modelcontextprotocol/client"; +import { StdioClientTransport } from "@modelcontextprotocol/client/stdio"; +import { describe, expect, it } from "vitest"; +import { z } from "zod"; + +import { javascriptApplicationAnalysisResultSchema } from "../../../src/domain/javascript/javascriptApplicationAnalysis.js"; +import { javaScriptExportShapeComparisonResultSchema } from "../../../src/domain/javascript/javascriptExportShapeComparisonSchemas.js"; +import { createTestTempDirectory } from "../../fixtures/temporaryDirectory.js"; + +const execute = promisify(execFile); +const comparisonEnvelope = z.object({ + normalized_result: javaScriptExportShapeComparisonResultSchema, +}); + +describe("exported function binding resolution through the CLI", () => { + it.each([ + { + name: "a function declaration exported by a specifier", + exportName: "default", + source: (count: number) => + `function current() { return { count: 0 }; } + current = () => ({ count: ${String(count)} }); + export { current as default };`, + }, + { + name: "a directly exported function declaration", + exportName: "current", + source: (count: number) => + `export function current() { return { count: 0 }; } + current = () => ({ count: ${String(count)} });`, + }, + { + name: "a named default function declaration", + exportName: "default", + source: (count: number) => + `export default function current() { return { count: 0 }; } + current = () => ({ count: ${String(count)} });`, + }, + { + name: "a directly exported arrow binding", + exportName: "current", + source: (count: number) => + `export let current = () => ({ count: 0 }); + current = () => ({ count: ${String(count)} });`, + }, + { + name: "an arrow binding exported by a specifier", + exportName: "default", + source: (count: number) => + `let current = () => ({ count: 0 }); + current = () => ({ count: ${String(count)} }); + export { current as default };`, + }, + { + name: "a reassigned alias", + exportName: "default", + source: (count: number) => + `const original = () => ({ count: 0 }); + let current = original; + current = () => ({ count: ${String(count)} }); + export { current as default };`, + }, + { + name: "a conditional function reassignment", + exportName: "current", + source: (count: number) => + `export function current() { return { count: 0 }; } + if (true) current = () => ({ count: ${String(count)} });`, + }, + ])( + "keeps $name uncertain after reassignment", + async ({ source, exportName }) => { + const fixture = await analyzeVersions(source, exportName); + await expectRuntimeExports(fixture, (count) => ({ count })); + const result = await compareThroughCli(fixture); + expectUncertainComparison(result); + for (const evidence of [fixture.input.left, fixture.input.right]) { + const { normalized_result: analysis } = z + .object({ + normalized_result: javascriptApplicationAnalysisResultSchema, + }) + .parse(evidence); + expect( + analysis.graph.nodes + .flatMap(({ observations }) => observations) + .filter( + ({ properties }) => + properties.semantic_role === "export-return-shapes" && + properties.exported_name === exportName, + ), + ).toEqual([]); + } + }, + ); + + it("does not associate a private function name with a numeric export in CLI or MCP", async () => { + const fixture = await analyzeVersions( + (count) => + `const hidden = function current() { return { count: 0 }; }; + const current = ${String(count)}; + export { current as default };`, + "default", + ); + await expectRuntimeExports(fixture, (count) => count); + const cli = await compareThroughCli(fixture); + expectUncertainComparison(cli); + expect(await compareThroughMcp(fixture.input)).toEqual(cli); + }); +}); + +describe("stable exported function bindings through the CLI", () => { + it.each([ + { + name: "an anonymous default", + source: (count: number) => + `export default () => ({ kind: "result", count: ${String(count)} });`, + }, + { + name: "a named default declaration", + source: (count: number) => + `export default function current() { return { kind: "result", count: ${String(count)} }; }`, + }, + { + name: "a private named default expression", + source: (count: number) => + `export default (function current() { return { kind: "result", count: ${String(count)} }; });`, + }, + { + name: "a declaration alias", + source: (count: number) => + `function original() { return { kind: "result", count: ${String(count)} }; } + const current = original; export { current as default };`, + }, + { + name: "an unchanged mutable binding", + source: (count: number) => + `let current = () => ({ kind: "result", count: ${String(count)} }); export { current as default };`, + }, + { + name: "a private function reached through its real binding", + source: (count: number) => + `const current = function privateName() { return { kind: "result", count: ${String(count)} }; }; + const privateName = 99; const alias = current; export { alias as default };`, + }, + { + name: "a declaration sharing a private display name", + source: (count: number) => + `const hidden = function current() { return { count: 99 }; }; + function current() { return { kind: "result", count: ${String(count)} }; } + export { current as default };`, + }, + ])("retains exact comparison for $name", async ({ source }) => { + const fixture = await analyzeVersions(source, "default"); + expectCountChange(await compareThroughCli(fixture)); + }); +}); + +describe("export bindings with loop and assignment-only writes", () => { + it.each([ + ["a skipped branch", "if (false)"], + ["a zero-iteration loop", "for (; false;)"], + ["an unselected switch case", "switch (0) { case 1:"], + ["an interrupted try block", "try { throw 1;"], + ])("does not infer a callable initialized in %s", async (_, prefix) => { + const fixture = await analyzeVersions((count) => { + const initializer = `var current = () => ({ count: ${String(count)} });`; + const statement = prefix.startsWith("try") + ? `${prefix} ${initializer} } catch {}` + : prefix.startsWith("switch") + ? `${prefix} ${initializer} }` + : `${prefix} { ${initializer} }`; + return `${statement} export { current as default };`; + }, "default"); + await expectRuntimeExports(fixture, () => undefined); + expectUncertainComparison(await compareThroughCli(fixture)); + }); + + it("retains a var initializer in an unconditional nested block", async () => { + const fixture = await analyzeVersions( + (count) => + `{ var current = () => ({ kind: "result", count: ${String(count)} }); } + export { current as default };`, + "default", + ); + await expectRuntimeExports(fixture, (count) => ({ kind: "result", count })); + expectCountChange(await compareThroughCli(fixture)); + }); + + it.each([ + { + name: "a for-of var declaration", + loop: (count: number) => + `for (var current of [() => ({ count: ${String(count)} })]) {}`, + }, + { + name: "a destructured for-of var declaration", + loop: (count: number) => + `for (var { value: current } of [{ value: () => ({ count: ${String(count)} }) }]) {}`, + }, + ])("keeps $name uncertain", async ({ loop }) => { + const fixture = await analyzeVersions( + (count) => + `function current() { return { count: 0 }; } + ${loop(count)} + module.exports = current;`, + "default", + "app.cjs", + ); + await expectRuntimeExports(fixture, (count) => ({ count })); + expectUncertainComparison(await compareThroughCli(fixture)); + }); + + it("does not associate a for-in key with the overwritten function", async () => { + const fixture = await analyzeVersions( + (count) => + `function current() { return { count: 0 }; } + for (var current in { ${String(count)}: true }) {} + module.exports = current;`, + "default", + "app.cjs", + ); + await expectRuntimeExports(fixture, String); + expectUncertainComparison(await compareThroughCli(fixture)); + }); + + it("preserves an outer export shadowed by a lexical loop declaration", async () => { + const fixture = await analyzeVersions( + (count) => + `export function current() { return { kind: "result", count: ${String(count)} }; } + for (let current of [() => ({ count: 0 })]) { current = null; }`, + "current", + ); + await expectRuntimeExports(fixture, (count) => ({ kind: "result", count })); + expectCountChange(await compareThroughCli(fixture)); + }); + + it.each([ + { + name: "a parameter", + source: (count: number) => + `function install(current) { + if (false) current = () => ({ count: 0 }); + module.exports = current; + } + install(${String(count)});`, + }, + { + name: "a catch binding", + source: (count: number) => + `try { throw ${String(count)}; } catch (current) { + if (false) current = () => ({ count: 0 }); + module.exports = current; + }`, + }, + { + name: "a parameter redeclared with var", + source: (count: number) => + `function install(current) { + if (false) { var current = () => ({ count: 0 }); } + module.exports = current; + } + install(${String(count)});`, + }, + ])("does not infer $name from its only assignment", async ({ source }) => { + const fixture = await analyzeVersions(source, "default", "app.cjs"); + await expectRuntimeExports(fixture, (count) => count); + const cli = await compareThroughCli(fixture); + expectUncertainComparison(cli); + expect(await compareThroughMcp(fixture.input)).toEqual(cli); + }); +}); + +describe("export binding lexical scope and declaration order", () => { + it.each([ + { + name: "a write before a hoisted function declaration", + source: (count: number) => + `current = () => ({ count: ${String(count)} }); + export function current() { return { count: 0 }; }`, + }, + { + name: "a nested write collected before its outer declaration", + source: (count: number) => + `function replace() { current = () => ({ count: ${String(count)} }); } + export function current() { return { count: 0 }; } + replace();`, + }, + ])("keeps $name uncertain", async ({ source }) => { + const fixture = await analyzeVersions(source, "current"); + await expectRuntimeExports(fixture, (count) => ({ count })); + expectUncertainComparison(await compareThroughCli(fixture)); + }); + + it("does not let a later local declaration contaminate the exported outer binding", async () => { + const fixture = await analyzeVersions( + (count) => + `export function current() { return { kind: "result", count: ${String(count)} }; } + function isolated() { + current = () => ({ count: 9 }); + function current() { return { count: 0 }; } + } + isolated();`, + "current", + ); + await expectRuntimeExports(fixture, (count) => ({ kind: "result", count })); + expectCountChange(await compareThroughCli(fixture)); + }); + + it("does not associate a nested CommonJS numeric export with an outer function", async () => { + const fixture = await analyzeVersions( + (count) => + `function current() { return { count: 0 }; } + function install() { const current = ${String(count)}; module.exports = current; } + install();`, + "default", + "app.cjs", + ); + await expectRuntimeExports(fixture, (count) => count); + const cli = await compareThroughCli(fixture); + expectUncertainComparison(cli); + expect(await compareThroughMcp(fixture.input)).toEqual(cli); + }); + + it.each([ + { + name: "a nested function-local alias", + source: (count: number) => + `function current() { return { kind: "result", count: 0 }; } + function install() { + const actual = () => ({ kind: "result", count: ${String(count)} }); + const current = actual; module.exports = current; + } + install();`, + }, + { + name: "a block-local function", + source: (count: number) => + `const current = 0; + { const current = () => ({ kind: "result", count: ${String(count)} }); + module.exports = current; }`, + }, + ])("compares $name through its real CommonJS binding", async ({ source }) => { + const fixture = await analyzeVersions(source, "default", "app.cjs"); + await expectRuntimeExports(fixture, (count) => ({ kind: "result", count })); + expectCountChange(await compareThroughCli(fixture)); + }); +}); + +describe("export writes in function evaluation contexts", () => { + it.each([ + { body: "var arg; arg.value = COUNT;", uncertain: true }, + { body: "var arg = arg; arg.value = COUNT;", uncertain: true }, + { body: "arg.value = COUNT; var arg;", uncertain: true }, + { body: "arg.value = COUNT; var arg = {};", uncertain: true }, + { body: "var arg = {}; arg.value = COUNT;", uncertain: false }, + { body: "var arg; arg = {}; arg.value = COUNT;", uncertain: false }, + { body: "function arg() {} arg.value = COUNT;", uncertain: false }, + ])("retains parameter-copy timing for $body", async ({ body, uncertain }) => { + const fixture = await analyzeVersions( + (count) => + `const source = { value: 0 }; + export function current(arg = null, set = (arg = source)) { + ${body.replace("COUNT", String(count))} + return { kind: "result", count: source.value }; + }`, + "current", + ); + await expectRuntimeExports(fixture, (count) => ({ + kind: "result", + count: uncertain ? count : 0, + })); + for (const evidence of [fixture.input.left, fixture.input.right]) { + const { normalized_result: analysis } = z + .object({ + normalized_result: javascriptApplicationAnalysisResultSchema, + }) + .parse(evidence); + const observations = analysis.graph.nodes.flatMap( + ({ observations }) => observations, + ); + expect(observations).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + properties: expect.objectContaining({ + semantic_role: "export-return-shapes", + exported_name: "current", + static_return_shapes: [ + expect.objectContaining({ + fields: expect.arrayContaining([ + expect.objectContaining({ + path: "/count", + state: uncertain ? "unknown" : "literal", + value: uncertain ? null : 0, + }), + ]), + }), + ], + }), + }), + ]), + ); + } + }); + + it.each([ + ...["let current;", "var current;", "function current() {}"].map( + (body) => ({ + name: `a parameter default before ${body}`, + source: (count: number) => + `export function current() { return { count: 0 }; } + function replace(value = (current = () => ({ count: ${String(count)} }))) { + ${body} + } + replace();`, + }), + ), + { + name: "a closure invoked from a parameter default", + source: (count: number) => + `export function current() { return { count: 0 }; } + function replace(value = (() => { current = () => ({ count: ${String(count)} }); })()) { + let current; + } + replace();`, + }, + { + name: "a computed destructuring parameter key", + source: (count: number) => + `export function current() { return { count: 0 }; } + function replace({ [current = () => ({ count: ${String(count)} })]: value }) { + var current; + } + replace({});`, + }, + { + name: "an object method's computed key", + source: (count: number) => + `export function current() { return { count: 0 }; } + const object = { [current = () => ({ count: ${String(count)} })]() { let current; } };`, + }, + { + name: "a class method's computed key", + source: (count: number) => + `export function current() { return { count: 0 }; } + class Example { [current = () => ({ count: ${String(count)} })]() { var current; } }`, + }, + ])("keeps an outer export write in $name uncertain", async ({ source }) => { + const fixture = await analyzeVersions(source, "current"); + await expectRuntimeExports(fixture, (count) => ({ count })); + expectUncertainComparison(await compareThroughCli(fixture)); + }); + + it("resolves a CommonJS export in a parameter default outside the function body", async () => { + const fixture = await analyzeVersions( + (count) => + `function current() { return { kind: "result", count: ${String(count)} }; } + function install(value = (module.exports = current)) { var current = 9; } + install();`, + "default", + "app.cjs", + ); + await expectRuntimeExports(fixture, (count) => ({ kind: "result", count })); + expectCountChange(await compareThroughCli(fixture)); + }); +}); + +const expectRuntimeExports = async ( + fixture: Awaited>, + expected: (count: number) => unknown, +): Promise => { + // Execute only source-owned fixtures as an independent runtime oracle. + for (const [path, exportName, count] of [ + [fixture.leftPath, fixture.input.left_export_name, 1], + [fixture.rightPath, fixture.input.right_export_name, 2], + ] as const) + expect(await runtimeExport(path, exportName)).toEqual(expected(count)); +}; + +const expectUncertainComparison = ( + result: Awaited>, +): void => { + expect(result).toMatchObject({ + left: { status: "unavailable" }, + right: { status: "unavailable" }, + summary: { added: 0, removed: 0, changed: 0, unknown: 1 }, + changes: [{ status: "unknown", path: "" }], + coverage: { status: "partial" }, + }); + expect(result.property_inventories).toEqual([]); +}; + +const expectCountChange = ( + result: Awaited>, +): void => { + expect(result).toMatchObject({ + left: { status: "selected" }, + right: { status: "selected" }, + summary: { added: 0, removed: 0, changed: 1, unknown: 0 }, + changes: [ + { + path: "/count", + status: "changed", + left: { availability: "literal", value: 1 }, + right: { availability: "literal", value: 2 }, + }, + ], + coverage: { status: "complete-within-inputs" }, + }); +}; + +const runCli = async (args: readonly string[]): Promise => { + const { stdout } = await execute( + process.execPath, + [resolve("scripts/rea.mjs"), ...args], + { + maxBuffer: 16 * 1024 * 1024, + env: { ...process.env, REA_LOG_LEVEL: "silent" }, + }, + ); + return JSON.parse(stdout); +}; + +const analyzeVersions = async ( + source: (count: number) => string, + exportName: string, + fileName = "app.mjs", +) => { + const root = await createTestTempDirectory("rea-exported-function-bindings-"); + const leftRoot = join(root, "left"); + const rightRoot = join(root, "right"); + await Promise.all([mkdir(leftRoot), mkdir(rightRoot)]); + const leftPath = join(leftRoot, fileName); + const rightPath = join(rightRoot, fileName); + await Promise.all([ + writeFile(leftPath, source(1)), + writeFile(rightPath, source(2)), + ]); + const [left, right] = await Promise.all([ + runCli(["analyze-javascript-application", leftRoot, "--json"]), + runCli(["analyze-javascript-application", rightRoot, "--json"]), + ]); + return { + root, + leftPath, + rightPath, + input: { + left, + right, + left_module_path: fileName, + left_export_name: exportName, + right_module_path: fileName, + right_export_name: exportName, + }, + }; +}; + +const compareThroughCli = async ( + fixture: Awaited>, +) => { + const inputPath = join(fixture.root, "comparison.json"); + await writeFile(inputPath, JSON.stringify(fixture.input)); + return comparisonEnvelope.parse( + await runCli(["compare-javascript-export-shapes", inputPath, "--json"]), + ).normalized_result; +}; + +const compareThroughMcp = async (input: Record) => { + const transport = new StdioClientTransport({ + command: process.execPath, + args: [resolve("scripts/rea.mjs"), "mcp"], + cwd: process.cwd(), + env: { PATH: process.env.PATH ?? "", REA_LOG_LEVEL: "silent" }, + stderr: "pipe", + }); + const client = new Client({ + name: "exported-function-bindings", + version: "1", + }); + try { + await client.connect(transport); + const response = await client.callTool({ + name: "compare_javascript_export_shapes", + arguments: input, + }); + expect(response.isError).not.toBe(true); + return comparisonEnvelope.parse(response.structuredContent) + .normalized_result; + } finally { + try { + await client.close(); + } finally { + await transport.close(); + } + } +}; + +const runtimeExport = async ( + path: string, + exportName: string, +): Promise => { + const { stdout } = await execute(process.execPath, [ + "--input-type=module", + "-e", + `import { pathToFileURL } from 'node:url'; + const module = await import(pathToFileURL(process.argv[1]).href); + const value = module[process.argv[2]]; + console.log(JSON.stringify({ value: typeof value === 'function' ? value() : value }));`, + path, + exportName, + ]); + return z.object({ value: z.unknown().optional() }).parse(JSON.parse(stdout)) + .value; +};