fix(javascript): keep exports out of write relations

Project writes only from write-role references. Export references remain distinct facts and genuine assignments still traverse writes-only traces.

Validation: source semantic controls and public CLI/MCP writes-only traces.

Fixes #3207
This commit is contained in:
morluto committed 2026-10-14 16:54:08 +08:00
1 parent 7e5cffa2ae
commit 81f7638203
3 files changed
+118 -3

No files matched your search

@@ -25,6 +25,43 @@ import { semanticCoverageResourceLimits } from "../../domain/javascript/javascri
const SHA256 = "a".repeat(64);
it.each([
["plain export", "const answer = 42; export { answer };", 0],
["aliased export", "const answer = 42; export { answer as result };", 0],
["declaration only", "const answer = 42;", 0],
["genuine assignment", "let answer = 0; answer = 1;", 1],
])(
"projects writes only for actual writes: %s",
(_name, source, writeCount) => {
const graph = graphFor(source);
const binding = graph.nodes.find(
({ kind, label }) => kind === "binding" && label === "answer",
);
if (binding === undefined) throw new Error("Expected answer binding");
const writes = graph.relations.filter(
({ relation, target_node_id }) =>
relation === "writes" && target_node_id === binding.node_id,
);
expect(writes).toHaveLength(writeCount);
const exportReference = graph.nodes.find(
({ identity }) => identity.role_key === "reference:0:export:answer",
);
if (source.includes("export {")) expect(exportReference).toBeDefined();
const trace = queryJavaScriptSemanticGraph(graph, {
seed: { kind: "semantic-node", node_id: binding.node_id },
direction: "backward-provenance",
allowed_relations: ["writes"],
});
expect(trace.status).toBe("found");
expect(trace.summary).toMatchObject({
total_seed_matches: 1,
traversed_relations: writeCount,
});
expect(trace.relations).toHaveLength(writeCount);
},
);
it("projects closure and direct interprocedural flow without execution", () => {
const graph = graphFor(`
const outer = 2;
@@ -648,7 +648,7 @@ function* projectDefinitionsAndReferences(
resolution: "resolved",
evidence: inferredSemanticEvidenceAt(context.file, reference.location),
});
else
else if (reference.role === "write")
addRelation(context.state, {
source: expression,
target: binding,
@@ -12,6 +12,7 @@ import { createEvidence, parseEvidence } from "../../src/domain/evidence.js";
import type { JsonValue } from "../../src/domain/jsonValue.js";
import { analysisCliErrorEnvelopeSchema } from "../../src/contracts/errorSchemas.js";
import { toolContract } from "../../src/contracts/toolContracts.js";
import { javascriptApplicationAnalysisResultSchema } from "../../src/domain/javascript/javascriptApplicationAnalysis.js";
import { javaScriptSemanticTraceResultSchema } from "../../src/domain/javascript/javascriptSemanticTraceSchemas.js";
import { resolveJavaScriptSemanticEvidence } from "../../src/domain/javascript/javascriptSemanticGraph.js";
@@ -124,12 +125,88 @@ it("reports invalid nested semantic graph Evidence as caller input through CLI a
}
});
it("excludes local exports from writes-only traces through CLI and MCP", async () => {
const root = await createTestTempDirectory("rea-semantic-export-trace-");
await mkdir(join(root, "src"));
await writeFile(
join(root, "src", "main.js"),
[
"const answer = 42;",
"export { answer, answer as result };",
"let mutable = 0;",
"mutable = 1;",
"export { mutable };",
].join("\n"),
);
const cli = createCli({});
const analyzed = await serveCli(cli, [
"analyze-javascript-application",
root,
"--json",
]);
expect(analyzed.exitCode).toBe(0);
const application = parseEvidence(JSON.parse(analyzed.stdout));
const analysisResult = javascriptApplicationAnalysisResultSchema.parse(
application.normalized_result,
);
const bindingId = (label: string): string => {
const binding = analysisResult.semantic_graph.nodes.find(
(node) => node.kind === "binding" && node.label === label,
);
if (binding === undefined)
throw new Error(`CLI semantic graph must include the ${label} binding`);
return binding.node_id;
};
const harness = await createApplicationMcpHarness();
try {
const exportTrace = await assertSuccessfulTraceParity(
cli,
harness,
application,
{
seed: { kind: "semantic-node", node_id: bindingId("answer") },
direction: "backward-provenance",
allowed_relations: ["writes"],
},
);
expect(exportTrace.summary).toMatchObject({
total_seed_matches: 1,
traversed_relations: 0,
});
expect(exportTrace.relations).toEqual([]);
const assignmentTrace = await assertSuccessfulTraceParity(
cli,
harness,
application,
{
seed: { kind: "semantic-node", node_id: bindingId("mutable") },
direction: "backward-provenance",
allowed_relations: ["writes"],
},
);
expect(assignmentTrace.summary).toMatchObject({
total_seed_matches: 1,
traversed_relations: 1,
});
expect(assignmentTrace.relations).toHaveLength(1);
} finally {
await harness.close();
}
});
const assertSuccessfulTraceParity = async (
cli: ReturnType<typeof createCli>,
harness: Awaited<ReturnType<typeof createApplicationMcpHarness>>,
application: ReturnType<typeof parseEvidence>,
query: { readonly seed: unknown; readonly direction: string },
): Promise<void> => {
query: {
readonly seed: unknown;
readonly direction: string;
readonly allowed_relations?: readonly string[];
},
): Promise<ReturnType<typeof javaScriptSemanticTraceResultSchema.parse>> => {
const arguments_ = { application, query };
const cliResponse = await serveCli(cli, [
"trace-javascript-semantics",
@@ -162,6 +239,7 @@ const assertSuccessfulTraceParity = async (
).outputSchema.parse(mcpResponse.structuredContent);
expect(mcpEvidence.normalized_result).toEqual(cliResult);
expect(mcpEvidence).toEqual(cliEvidence);
return cliResult;
};
const assertTraceContextsResolve = (