fix(javascript): resolve exported callables through lexical bindings (#1351)

* fix(javascript): resolve exported callables through lexical bindings

* fix(javascript): preserve export scope and hoisted writes

* test(javascript): consolidate export binding assertions

* fix(javascript): account for skipped initializers and loop binding writes

* fix(javascript): preserve parameters copied into body variables
This commit is contained in:
You Wish 2026-10-09 18:39:56 -04:00 committed by GitHub
parent 1c0cc8a675
commit a9f5dad5a8
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
6 changed files with 929 additions and 96 deletions

View File

@ -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<t.Node, number>();
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,

View File

@ -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(

View File

@ -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<JavaScriptSemanticModuleLink, "callableId"> & {
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. */

View File

@ -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<string>,
seen: ReadonlySet<string>,
): 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,

View File

@ -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<string, t.Node>;
readonly moduleLinks: JavaScriptSemanticModuleLink[];
readonly moduleLinkBindings: WeakMap<
JavaScriptSemanticModuleLink,
JavaScriptSemanticBindingState
>;
readonly conditionalInitializers: WeakSet<t.Node>;
}
/** Return the active scope from a non-empty construction stack. */

View File

@ -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<ReturnType<typeof analyzeVersions>>,
expected: (count: number) => unknown,
): Promise<void> => {
// 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<ReturnType<typeof compareThroughCli>>,
): 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<ReturnType<typeof compareThroughCli>>,
): 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<unknown> => {
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<ReturnType<typeof analyzeVersions>>,
) => {
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<string, unknown>) => {
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<unknown> => {
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;
};