fix(javascript): trace boundary results to assigned bindings (#7912)

This commit is contained in:
morluto authored and GitHub committed 2026-10-29 07:58:57 +08:00
1 parent 2129c17925
commit 794907fa3d
5 files changed
+166 -1

No files matched your search

+4
View File
@@ -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,
@@ -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",
@@ -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,
@@ -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;
}
@@ -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);
},
);