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:
@@ -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 => {
|
||||
|
||||
Reference in New Issue
Block a user