fix(javascript): preserve CommonJS assignment result and receiver uncertainty

Resolve values returned by simple assignment chains at the export boundary. Suppress property links proven to target a detached receiver; retain unresolved links when reassignment may preserve receiver identity. Identify root assignments from their AST target rather than the displayed export name.

Validation: 25 semantic tests, seven targeted compiled CLI/MCP scenarios with Node runtime controls, build, and check:fast passed. Existing full acceptance suite passed before receiver refinements; the final changes were checked with targeted scenarios rather than repeating its 130-second run.
This commit is contained in:
morluto
2026-10-11 09:47:41 +08:00
parent 7956934965
commit 1e51c732bb
3 changed files with 294 additions and 11 deletions
@@ -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;
@@ -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<JavaScriptSemanticModuleLink, "callableId"> & {
@@ -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";
@@ -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<ReturnType<typeof compareThroughCli>>,
): void => {