Files
rea/tests/acceptance/applications/javascriptCallbackTargets.test.ts
Ysopwnable 42fc6c53ae fix(javascript): preserve incoming callback target uncertainty (#4243)
Preserve incoming callback uncertainty and resolve proven preceding writes.
Share read-position and initializer identity facts with returned-reference
analysis; cache completed projected work with actual cycle dependencies.

Fixes #3880
2026-10-16 10:09:22 +08:00

294 lines
11 KiB
TypeScript

import { execFile } from "node:child_process";
import { writeFile } from "node:fs/promises";
import { join } from "node:path";
import { promisify } from "node:util";
import { expect } from "vitest";
import { toolContract } from "../../../src/contracts/toolContracts.js";
import { javascriptApplicationAnalysisResultSchema } from "../../../src/domain/javascript/javascriptApplicationAnalysis.js";
import { javaScriptSemanticTraceResultSchema } from "../../../src/domain/javascript/javascriptSemanticTraceSchemas.js";
import { connectLocalToolsMcp } from "../../fixtures/localToolsMcp.js";
import { verifyJavaScriptReturnShapes } from "../../fixtures/javascriptReturnShapes.js";
import { createTestTempDirectory } from "../../fixtures/temporaryDirectory.js";
import { cliTest } from "../../support/cli/cliFixture.js";
const createCallbackFixture = async () => {
const root = await createTestTempDirectory("rea-callback-targets-");
const file = join(root, "app.mjs");
await writeFile(
file,
`
function fallback() { return "local"; }
function original() { return "original"; }
export function choose(cb, flag) { if (flag) cb=fallback; return cb(); }
export function logical(cb, flag) { flag && (cb=fallback); return cb(); }
export function definite(cb) { cb=fallback; return cb(); }
export function beforeAfter(cb) { const before=cb(); cb=fallback; return [before,cb()]; }
export function captured(cb) { const saved=cb; cb=fallback; return saved(); }
export function delayed(cb) { function replace() { cb=fallback; } return cb(); }
export function caught(cb,flag) { try { throw cb; } catch (cb) { if(flag)cb=fallback; return cb(); } }
export function defaulted(cb=original,flag) { if(flag)cb=fallback; return cb(); }
export function local(flag) { let cb=original; if(flag)cb=fallback; return cb(); }
export function replaced() { let cb=original; cb=fallback; return cb(); }
export function guarded(cb,flag) { if(flag){cb=fallback;return cb();}return "skipped"; }
export function guardedVar(flag) { if(flag){var cb=fallback;return cb();}return "skipped"; }
export function logicalAssign(cb) { cb ||= fallback;return cb(); }
export function restored() { let cb=fallback;const saved=cb;cb=saved;return cb(); }
export function emptyOr() { let cb;cb ||= fallback;return cb(); }
export function emptyNullish() { let cb;cb ??= fallback;return cb(); }
export function knownLocal(flag) { let cb=fallback;if(flag)cb=fallback;return cb(); }
export function finallyDefinite(cb,flag) { if(flag)cb=fallback;cb=fallback;return cb(); }
`,
);
const oracle = await promisify(execFile)(process.execPath, [
"--input-type=module",
"-e",
`
import {pathToFileURL} from "node:url";
const m=await import(pathToFileURL(process.argv[1]));
const cb=()=>"incoming";
console.log(JSON.stringify([
m.choose(cb,false),m.choose(cb,true),m.logical(cb,false),m.definite(cb),
m.beforeAfter(cb),m.captured(cb),m.delayed(cb),m.caught(cb,false),
m.defaulted(cb,false),m.defaulted(undefined,false),m.local(false),m.local(true),m.replaced(),
m.guarded(cb,false),m.guarded(cb,true),m.guardedVar(false),m.guardedVar(true),m.logicalAssign(cb),m.restored(),m.emptyOr(),m.emptyNullish(),m.knownLocal(false),m.knownLocal(true),m.finallyDefinite(cb,false)
]));`,
file,
]);
expect(JSON.parse(oracle.stdout)).toEqual([
"incoming",
"local",
"incoming",
"local",
["incoming", "local"],
"incoming",
"incoming",
"incoming",
"incoming",
"original",
"original",
"local",
"local",
"skipped",
"local",
"skipped",
"local",
"incoming",
"local",
"local",
"local",
"local",
"local",
"local",
]);
return root;
};
const verifyCallbackGraph = (
graph: ReturnType<
typeof javascriptApplicationAnalysisResultSchema.parse
>["semantic_graph"],
) => {
const nodes = new Map(graph.nodes.map((node) => [node.node_id, node]));
const calls = (name: string) =>
graph.nodes
.filter(
(node) =>
node.kind === "call-site" &&
node.function_node_id !== null &&
nodes.get(node.function_node_id)?.properties.name === name,
)
.sort(
(a, b) =>
(a.identity.source_range?.start.column ?? 0) -
(b.identity.source_range?.start.column ?? 0),
);
const targets = (nodeId: string) =>
graph.relations
.filter(
(relation) =>
relation.relation === "calls" && relation.source_node_id === nodeId,
)
.map((relation) => ({
target: nodes.get(relation.target_node_id)?.label,
resolution: relation.resolution,
}))
.sort((a, b) => String(a.target).localeCompare(String(b.target)));
for (const name of ["choose", "logical", "delayed", "caught", "defaulted"]) {
const call = calls(name)[0];
if (call === undefined) throw new Error(`Missing ${name} call`);
expect(targets(call.node_id), name).toContainEqual({
target: "fallback",
resolution: "candidate",
});
expect(
targets(call.node_id).every((target) => target.resolution !== "resolved"),
name,
).toBe(true);
}
for (const name of [
"definite",
"replaced",
"guarded",
"guardedVar",
"restored",
"emptyOr",
"emptyNullish",
"knownLocal",
"finallyDefinite",
]) {
const call = calls(name)[0];
if (call === undefined) throw new Error(`Missing ${name} call`);
expect(targets(call.node_id), name).toEqual([
{ target: "fallback", resolution: "resolved" },
]);
}
expect(targets(calls("logicalAssign")[0]!.node_id)).toEqual([]);
expect(calls("beforeAfter")).toHaveLength(2);
expect(targets(calls("beforeAfter")[0]!.node_id)).toEqual([]);
expect(targets(calls("beforeAfter")[1]!.node_id)).toEqual([
{ target: "fallback", resolution: "resolved" },
]);
expect(targets(calls("captured")[0]!.node_id)).toEqual([]);
expect(targets(calls("local")[0]!.node_id)).toEqual([
{ target: "fallback", resolution: "candidate" },
{ target: "original", resolution: "candidate" },
]);
return calls;
};
cliTest(
"preserves incoming callback uncertainty through CLI and retained MCP traces",
async ({ cli }) => {
const root = await createCallbackFixture();
const analyzed = await cli.run({
arguments: [
"analyze-javascript-application",
root,
"--artifact-format",
"directory",
"--json",
],
});
expect(analyzed.exitCode, analyzed.stderr).toBe(0);
const contract = toolContract("analyze_javascript_application");
const evidence = contract.outputSchema.parse(analyzed.json);
const graph = javascriptApplicationAnalysisResultSchema.parse(
evidence.normalized_result,
).semantic_graph;
const calls = verifyCallbackGraph(graph);
const { client, call } = await connectLocalToolsMcp();
const retainedResponse = await call("analyze_javascript_application", {
input_path: root,
format: "directory",
detail: "summary",
});
expect(retainedResponse.isError).not.toBe(true);
expect(retainedResponse.structuredContent).toMatchObject({
normalized_result: { parent_evidence_id: evidence.evidence_id },
});
// Summary replies keep the complete Evidence available for subsequent queries.
const retainedId = evidence.evidence_id;
const selected = calls("choose")[0]!;
const query = {
seed: { kind: "semantic-node" as const, node_id: selected.node_id },
direction: "forward-influence" as const,
allowed_relations: ["calls" as const],
};
const traced = await call("trace_javascript_semantics", {
application: { kind: "retained-evidence", evidence_id: retainedId },
query,
});
expect(traced.isError).not.toBe(true);
const traceEvidence = toolContract(
"trace_javascript_semantics",
).outputSchema.parse(traced.structuredContent);
const trace = javaScriptSemanticTraceResultSchema.parse(
traceEvidence.normalized_result,
);
expect(trace.status).toBe("ambiguous");
expect(trace.relations).toEqual([]);
expect(trace.coverage.status).toBe("partial");
expect(graph.unknowns.length).toBeGreaterThan(0);
expect(trace.source_graph_id).toBe(graph.graph_id);
expect(trace.evidence_contexts.length).toBeGreaterThan(0);
const request = join(root, "trace.json");
await writeFile(request, JSON.stringify({ application: evidence, query }));
const cliTrace = await cli.run({
arguments: ["trace-javascript-semantics", request, "--json"],
});
expect(cliTrace.exitCode, cliTrace.stderr).toBe(0);
expect(
toolContract("trace_javascript_semantics").outputSchema.parse(
cliTrace.json,
).evidence_id,
).toBe(traceEvidence.evidence_id);
const candidates = await call("trace_javascript_semantics", {
application: { kind: "retained-evidence", evidence_id: retainedId },
query: { ...query, include_ambiguous_dynamic_edges: true },
});
expect(candidates.isError).not.toBe(true);
const candidateTrace = javaScriptSemanticTraceResultSchema.parse(
toolContract("trace_javascript_semantics").outputSchema.parse(
candidates.structuredContent,
).normalized_result,
);
expect(candidateTrace.status).toBe("ambiguous");
expect(candidateTrace.relations).toEqual(
expect.arrayContaining([
expect.objectContaining({ relation: "calls", resolution: "candidate" }),
]),
);
await client.ping();
},
);
cliTest(
"applies returned-reference effects to the callback selected at the call",
async ({ cli }) => {
const source = `
const original={value:1},replacement={value:2};
function first(){return original;}
function second(){return replacement;}
export function inspect(){let cb=first;const result=cb();cb=second;result.value=3;return {original:original.value,replacement:replacement.value};}
const restoredObject={value:1};
function create(){return restoredObject;}
export function restored(){let cb=create;const saved=cb;cb=saved;cb().value=3;return {value:restoredObject.value};}
`;
const oracle = await promisify(execFile)(process.execPath, [
"--input-type=module",
"-e",
source + "console.log(JSON.stringify([inspect(),restored()]));",
]);
expect(JSON.parse(oracle.stdout)).toEqual([
{ original: 3, replacement: 2 },
{ value: 3 },
]);
await verifyJavaScriptReturnShapes(
cli,
source,
(fields) => {
expect(fields("restored")).toContainEqual(
expect.objectContaining({ path: "/value", state: "unknown" }),
);
expect(fields("inspect")).toEqual(
expect.arrayContaining([
expect.objectContaining({
path: "/replacement",
state: "literal",
value: 2,
}),
expect.objectContaining({
path: "/original",
state: "unknown",
}),
]),
);
},
"app.mjs",
);
},
);