diff --git a/src/application/javascript/JavaScriptSemanticGraphBuilder.test.ts b/src/application/javascript/JavaScriptSemanticGraphBuilder.test.ts index d9bcd6ca70..f28a450602 100644 --- a/src/application/javascript/JavaScriptSemanticGraphBuilder.test.ts +++ b/src/application/javascript/JavaScriptSemanticGraphBuilder.test.ts @@ -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; diff --git a/src/application/javascript/JavaScriptSemanticGraphBuilder.ts b/src/application/javascript/JavaScriptSemanticGraphBuilder.ts index 6a7d0920a0..c9208a39f7 100644 --- a/src/application/javascript/JavaScriptSemanticGraphBuilder.ts +++ b/src/application/javascript/JavaScriptSemanticGraphBuilder.ts @@ -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, diff --git a/tests/boundary/javascriptSemanticTraceEvidence.test.ts b/tests/boundary/javascriptSemanticTraceEvidence.test.ts index def3bde04d..91cb7585f5 100644 --- a/tests/boundary/javascriptSemanticTraceEvidence.test.ts +++ b/tests/boundary/javascriptSemanticTraceEvidence.test.ts @@ -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, harness: Awaited>, application: ReturnType, - query: { readonly seed: unknown; readonly direction: string }, -): Promise => { + query: { + readonly seed: unknown; + readonly direction: string; + readonly allowed_relations?: readonly string[]; + }, +): Promise> => { 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 = (