diff --git a/docs/javascript-application-workflows.md b/docs/javascript-application-workflows.md index b2fb8c3f82..6f9fb270c7 100644 --- a/docs/javascript-application-workflows.md +++ b/docs/javascript-application-workflows.md @@ -198,6 +198,10 @@ rereading mutable files. A seed may identify a semantic or application node, literal, function fingerprint, property, endpoint, event, or boundary field. For a recovered parse, coercion, or validation boundary, its field value is the static operation's method name (for example, `Number`). +When a recovered boundary call directly supplies a scalar binding, the graph +connects the boundary to that binding; unresolved boundary calls remain +candidates. A boundary nested inside another expression or compound assignment +does not establish a direct result binding. The query declares backward provenance, forward influence, callers, or ownership and can restrict admitted relation kinds. REA returns every matching seed and the complete reachable subgraph; callers do not choose node, relation, diff --git a/src/application/javascript/JavaScriptSemanticGraphBuilder.test.ts b/src/application/javascript/JavaScriptSemanticGraphBuilder.test.ts index 84aee2f524..51f724ebeb 100644 --- a/src/application/javascript/JavaScriptSemanticGraphBuilder.test.ts +++ b/src/application/javascript/JavaScriptSemanticGraphBuilder.test.ts @@ -665,6 +665,108 @@ it("selects analyzer-produced boundaries by their method field", () => { ).toBe(0); }); +it.each(["Number", "JSON.parse"])( + "connects a resolved %s boundary to its assigned result and later read", + (method) => { + const graph = graphFor( + `function parse(raw) { const n = ${method}(raw); return n; }`, + ); + const boundary = graph.nodes.find( + (node) => node.kind === "boundary" && node.label === method, + ); + const binding = graph.nodes.find( + (node) => node.kind === "binding" && node.label === "n", + ); + const raw = graph.nodes.find( + (node) => node.kind === "parameter" && node.label === "raw", + ); + const read = graph.nodes.find((node) => + node.identity.role_key.endsWith("read:n"), + ); + if (!boundary || !binding || !raw || !read) + throw new Error("Expected boundary, input, result binding, and read"); + expect(graph.relations).toContainEqual( + expect.objectContaining({ + source_node_id: boundary.node_id, + target_node_id: binding.node_id, + relation: "defines", + resolution: "resolved", + }), + ); + const query = ( + nodeId: string, + direction: "forward-influence" | "backward-provenance", + ) => + queryJavaScriptSemanticGraph( + graph, + javaScriptSemanticQueryInputSchema.parse({ + seed: { kind: "semantic-node", node_id: nodeId }, + direction, + }), + ); + expect(query(boundary.node_id, "forward-influence").nodes).toEqual( + expect.arrayContaining([binding, read]), + ); + expect(query(read.node_id, "backward-provenance").nodes).toEqual( + expect.arrayContaining([boundary, raw]), + ); + }, +); + +it.each([ + "function parse(raw) { const n = Number(raw) + 1; return n; }", + "function parse(raw) { let n = 1; n += Number(raw); return n; }", + "function parse(raw) { let n = 1; n ||= Number(raw); return n; }", +])( + "does not treat a transformed or conditional boundary as its assigned value: %s", + (source) => { + const graph = graphFor(source); + const boundary = graph.nodes.find((node) => node.kind === "boundary"); + const binding = graph.nodes.find( + (node) => node.kind === "binding" && node.label === "n", + ); + if (!boundary || !binding) throw new Error("Expected boundary and binding"); + expect(graph.relations).not.toContainEqual( + expect.objectContaining({ + source_node_id: boundary.node_id, + target_node_id: binding.node_id, + relation: "defines", + }), + ); + }, +); + +it("keeps uncertain parser results as candidates and ignores shadowed coercions", () => { + const graph = graphFor(` + function run(raw, parser, Number) { + const parsed = parser.parse(raw); + const shadowed = Number(raw); + return parsed; + } + `); + const parser = graph.nodes.find( + (node) => node.kind === "boundary" && node.label === "parse", + ); + const parsed = graph.nodes.find( + (node) => node.kind === "binding" && node.label === "parsed", + ); + if (!parser || !parsed) + throw new Error("Expected parser boundary and binding"); + expect(graph.relations).toContainEqual( + expect.objectContaining({ + source_node_id: parser.node_id, + target_node_id: parsed.node_id, + relation: "defines", + resolution: "candidate", + }), + ); + expect( + graph.nodes.some( + (node) => node.kind === "boundary" && node.label === "Number", + ), + ).toBe(false); +}); + const semanticNodeFor = (roleKey: string) => { const file: JavaScriptArtifactFile = { path: "budget.js", diff --git a/src/application/javascript/JavaScriptSemanticGraphDataProjection.ts b/src/application/javascript/JavaScriptSemanticGraphDataProjection.ts index c5b7e9abb0..7489e601e2 100644 --- a/src/application/javascript/JavaScriptSemanticGraphDataProjection.ts +++ b/src/application/javascript/JavaScriptSemanticGraphDataProjection.ts @@ -237,6 +237,16 @@ export const projectSemanticBoundaries = ( resolution: operation.resolution === "complete" ? "resolved" : "candidate", }); + // The producer only supplies a scalar result binding when this call is + // the assigned value itself, rather than an operand of another expression. + if (operation.resultBindingId !== null) + addSemanticGraphRelation(context.state, { + source: boundary, + target: context.bindingNodes.get(operation.resultBindingId), + relation: "defines", + resolution: + operation.resolution === "complete" ? "resolved" : "candidate", + }); if (operation.resolution !== "complete") addDataUnknown({ context, diff --git a/src/domain/javascript/javascriptSemanticDataEffectHelpers.ts b/src/domain/javascript/javascriptSemanticDataEffectHelpers.ts index d349e7e491..2b4965eb13 100644 --- a/src/domain/javascript/javascriptSemanticDataEffectHelpers.ts +++ b/src/domain/javascript/javascriptSemanticDataEffectHelpers.ts @@ -57,7 +57,7 @@ export const assignedSemanticResultBindings = ( if (t.isVariableDeclarator(owner)) { pattern = owner.id; source = owner.init ?? null; - } else if (t.isAssignmentExpression(owner)) { + } else if (t.isAssignmentExpression(owner, { operator: "=" })) { pattern = owner.left; source = owner.right; } diff --git a/tests/acceptance/applications/javascriptGraphQueryCoverage.test.ts b/tests/acceptance/applications/javascriptGraphQueryCoverage.test.ts index edc7d38b18..c28b0add64 100644 --- a/tests/acceptance/applications/javascriptGraphQueryCoverage.test.ts +++ b/tests/acceptance/applications/javascriptGraphQueryCoverage.test.ts @@ -60,3 +60,52 @@ cliTest( } }, ); + +cliTest( + "traces a coercion boundary through its assigned result", + async ({ cli }) => { + const root = await createTestTempDirectory("rea-graph-boundary-result-"); + await writeFile( + join(root, "main.js"), + "function parse(raw) { const n = Number(raw); return n; } module.exports = parse;", + ); + const analysis = await cli.run({ + arguments: ["analyze-javascript-application", root, "--format", "json"], + }); + expect(analysis.exitCode, analysis.stderr).toBe(0); + const application = parseEvidence(analysis.json); + const graph = javascriptApplicationAnalysisResultSchema.parse( + application.normalized_result, + ).semantic_graph; + if (graph === null) throw new Error("Expected semantic graph"); + const read = graph.nodes.find((node) => + node.identity.role_key.endsWith("read:n"), + ); + if (read === undefined) throw new Error("Expected result read"); + const query = { + seed: { kind: "boundary-field", field: "Number" }, + direction: "forward-influence", + }; + const request = join(root, "request.json"); + await writeFile(request, JSON.stringify({ application, query })); + const result = await cli.run({ + arguments: ["trace-javascript-semantics", request, "--format", "json"], + }); + expect(result.exitCode, result.stderr).toBe(0); + const trace = javaScriptSemanticTraceResultSchema.parse( + parseEvidence(result.json).normalized_result, + ); + expect(trace.nodes).toContainEqual(read); + const call = await cli.sharedMcpCall?.(); + if (call === undefined) throw new Error("Expected shared MCP session"); + const mcp = await call("trace_javascript_semantics", { + application, + query, + }); + expect(mcp.isError, JSON.stringify(mcp.content)).not.toBe(true); + const mcpTrace = javaScriptSemanticTraceResultSchema.parse( + parseEvidence(mcp.structuredContent).normalized_result, + ); + expect(mcpTrace.nodes).toContainEqual(read); + }, +);