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
294 lines
11 KiB
TypeScript
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",
|
|
);
|
|
},
|
|
);
|