Files
rea/tests/process-global/javascriptAnalysisCancellation.test.ts
Iven Koo 9df94a62cd feat(javascript): link assignment right-hand sides to their writes (#6206)
* feat(javascript): link assignment right-hand sides to their writes

A declaration carries its value forward, but a later assignment to a
variable, a property or through a compound operator stopped the forward
trace at the right-hand-side read. The semantic IR now records one
syntactic assignment flow per simple assignment target (ranges, operator,
owning callable), and the graph projection connects each retained read
of the right-hand side to the write occurrence through a defines
relation. Compound assignments also consume the previous value of the
target; the written value itself stays unknown, keeping the boundary of
#4342 intact.

Fixes #6193

* fix(javascript): preserve computed key assignment ownership

* test(javascript): retain nested callback assignment ownership

* docs(javascript): record assignment influence verification

* test(mcp): isolate mocks and acknowledge cancellation boundaries

* test(javascript): retain CLI diagnostics and current-main verification

---------

Co-authored-by: gensokyo-dev <342580529+gensokyo-dev@users.noreply.github.com>
2026-10-24 10:04:38 +08:00

227 lines
7.3 KiB
TypeScript

import { Client, InMemoryTransport } from "@modelcontextprotocol/client";
import { writeFile } from "node:fs/promises";
import { join } from "node:path";
import pino from "pino";
import { expect, it, onTestFinished, vi } from "vitest";
import { z } from "zod";
import { createEvidence } from "../../src/domain/evidence.js";
import { createServer } from "../../src/server/createServer.js";
import {
createDeferred,
createTestBinarySession,
} from "../fixtures/binarySession.js";
import { createTestTempDirectory } from "../fixtures/temporaryDirectory.js";
import type { ProgressUpdate } from "../../src/application/ProgressReporter.js";
const progressBoundary = vi.hoisted(() => ({
observe: undefined as ((update: ProgressUpdate) => Promise<void>) | undefined,
}));
// Observe each application boundary deterministically; transport rate limiting
// is covered by reporter tests and must not select the cancellation site.
vi.mock("../../src/application/ProgressReporter.js", async (original) => {
const actual =
await original<
typeof import("../../src/application/ProgressReporter.js")
>();
return {
...actual,
createProgressReporter: (
...args: Parameters<typeof actual.createProgressReporter>
) =>
actual.createProgressReporter(
async (update) => {
await args[0](update);
await progressBoundary.observe?.(update);
},
{
...args[1],
minimumIntervalMs: 0,
},
),
};
});
const createCancellationBoundary = (phase: string, message: string) => {
const released = createDeferred<void>();
let heldAtBoundary = false;
let acknowledgedCancellation = false;
let acknowledgementError: unknown;
const release = () => released.resolve(undefined);
return {
release,
async observe(update: ProgressUpdate) {
if (update.phase === phase && update.message.includes(message)) {
heldAtBoundary = true;
await released.promise;
}
},
acknowledge(client: Client) {
// Ordered in-memory messages make the ping acknowledge the cancellation
// before the producer crosses the held boundary.
void client.ping().then(
() => {
acknowledgedCancellation = true;
release();
},
(error: unknown) => {
acknowledgementError = error;
release();
},
);
},
expectAcknowledged() {
expect(heldAtBoundary).toBe(true);
expect(acknowledgedCancellation).toBe(true);
expect(acknowledgementError).toBeUndefined();
},
};
};
it.each([
{ phase: "parse_javascript_source", message: "second.js", parsedFiles: 1 },
{
phase: "analyze_javascript_application",
message: "Application graph and Electron boundaries reconstructed",
parsedFiles: 2,
},
])(
"retains completed facts and prior Evidence after SDK cancellation during $phase, then accepts subsequent work",
async ({ phase, message, parsedFiles }) => {
const boundary = createCancellationBoundary(phase, message);
progressBoundary.observe = boundary.observe;
const root = await createTestTempDirectory("rea-mcp-js-cancellation-");
await writeFile(join(root, "main.js"), "export const observed = 1;\n");
await writeFile(join(root, "second.js"), "export const pending = 2;\n");
const session = createTestBinarySession(() => {
throw new Error(
"Static JavaScript analysis must not start a binary provider",
);
});
const prior = createEvidence(
undefined,
{ id: "fixture", name: "Fixture", version: "1" },
{
operation: "prior_observation",
parameters: {},
result: { preserved: true },
},
);
expect(session.recordEvidence(prior).ok).toBe(true);
const completed = createDeferred<string>();
const completionSchema = z.object({
tool: z.string(),
status: z.string(),
msg: z.string(),
});
const logger = pino(
{ level: "info" },
{
write(line) {
const value: unknown = JSON.parse(line);
const parsed = completionSchema.safeParse(value);
if (
parsed.success &&
parsed.data.tool === "analyze_javascript_application" &&
parsed.data.msg === "MCP tool execution completed"
)
completed.resolve(parsed.data.status);
},
},
);
const server = createServer({ kind: "session", session }, { logger });
const client = new Client({
name: "javascript-cancellation",
version: "1",
});
onTestFinished(async () => {
boundary.release();
progressBoundary.observe = undefined;
await client.close();
await server.close();
await session.close();
});
const [clientTransport, serverTransport] =
InMemoryTransport.createLinkedPair();
await server.connect(serverTransport);
await client.connect(clientTransport);
const controller = new AbortController();
let requested = false;
const call = client.callTool(
{
name: "analyze_javascript_application",
arguments: {
input_path: root,
format: "directory",
},
},
{
signal: controller.signal,
onprogress(update) {
if (
!update.message?.startsWith(`${phase}:`) ||
!update.message.includes(message)
)
return;
requested = true;
controller.abort(new Error("SDK cancellation verification"));
boundary.acknowledge(client);
},
},
);
await expect(call).rejects.toThrow("SDK cancellation verification");
expect(requested).toBe(true);
// A rejected client promise alone does not prove that the server stopped.
expect(await completed.promise).toBe("error");
boundary.expectAcknowledged();
await client.ping();
const bundle = await client.callTool({
name: "get_evidence_bundle",
arguments: {},
});
const records = z
.object({
result: z.object({
records: z.array(z.object({ evidence_id: z.string() })),
}),
})
.parse(bundle.structuredContent).result.records;
expect(records).toContainEqual({ evidence_id: prior.evidence_id });
const partial = records.find(
({ evidence_id }) => evidence_id !== prior.evidence_id,
);
if (partial === undefined)
throw new Error(
"Cancelled analysis discarded its completed source facts",
);
expect(
z
.object({
statistics: z.object({
parsed_javascript_files: z.number(),
parse_failures: z.number(),
}),
})
.parse(session.evidenceById(partial.evidence_id)?.normalized_result)
.statistics,
).toEqual({ parsed_javascript_files: parsedFiles, parse_failures: 0 });
const view = await client.callTool({
name: "inspect_analysis_view",
arguments: {
source: { kind: "retained-evidence", evidence_id: partial.evidence_id },
view: { kind: "summary" },
},
});
expect(view.isError, JSON.stringify(view)).not.toBe(true);
const next = await client.callTool({
name: "analyze_javascript_application",
arguments: {
input_path: root,
format: "directory",
},
});
expect(next.isError, JSON.stringify(next)).not.toBe(true);
await client.ping();
},
);