From b6651cb99fba8fec3fd3efcb6794ae32b8a1525d Mon Sep 17 00:00:00 2001 From: morluto <76467478+morluto@users.noreply.github.com> Date: Mon, 12 Oct 2026 02:39:52 +0800 Subject: [PATCH] fix(javascript): retain completed facts through cancellation (#1934) --- docs/javascript-artifact-reconstruction.md | 8 +- .../JavaScriptApplicationService.ts | 58 ++- .../JavaScriptArtifactReconstruction.ts | 49 +-- .../javascriptCancellationCli.test.ts | 48 ++- .../javascriptApplicationCancellation.test.ts | 344 +++++++++++------- .../javascriptAnalysisCancellation.test.ts | 253 +++++++------ 6 files changed, 465 insertions(+), 295 deletions(-) diff --git a/docs/javascript-artifact-reconstruction.md b/docs/javascript-artifact-reconstruction.md index 0023b7e66..303e17f76 100644 --- a/docs/javascript-artifact-reconstruction.md +++ b/docs/javascript-artifact-reconstruction.md @@ -159,9 +159,11 @@ completed partial Evidence. MCP failure details return after recording succeeds, so `inspect_analysis_view` can open that analysis directly without serializing the entire partial graph into the error response. CLI failure output retains the complete partial Evidence. -Cancellation after source analysis begins likewise -retains completed facts; an SDK client may reject its cancelled request before -receiving that reply. On the same MCP connection, inspect the Evidence bundle +Cancellation after source analysis begins likewise retains completed facts, +including cancellation during analyzer cleanup, graph construction, or Evidence +sealing. REA finishes projecting those owned facts cooperatively to return partial +Evidence. An SDK client may reject its cancelled request before receiving that +reply. On the same MCP connection, inspect the Evidence bundle and use the retained analysis ID with `inspect_analysis_view`. Owned analyzer cleanup is verified before its private files are released. Cleanup uncertainty is reported separately and retains ownership for retry. diff --git a/src/application/javascript/JavaScriptApplicationService.ts b/src/application/javascript/JavaScriptApplicationService.ts index 04a2125c0..85705b03a 100644 --- a/src/application/javascript/JavaScriptApplicationService.ts +++ b/src/application/javascript/JavaScriptApplicationService.ts @@ -4,6 +4,7 @@ import { ArtifactReaderFailure } from "../../artifacts/ArtifactReader.js"; import type { ArtifactResourceScope } from "../../artifacts/ArtifactResourceScope.js"; import { analyzeJavaScriptApplicationInputSchema, + type JavaScriptApplicationAnalysisResult, parseOwnedJavaScriptApplicationAnalysisSteps, } from "../../domain/javascript/javascriptApplicationAnalysis.js"; import { AnalysisOutputError } from "../../domain/analysisErrorCore.js"; @@ -20,7 +21,10 @@ import { checkpointJavaScriptAnalysis, completeJavaScriptAnalysisSteps, } from "./JavaScriptAnalysisControl.js"; -import { reconstructJavaScriptArtifact } from "./JavaScriptArtifactReconstruction.js"; +import { + reconstructJavaScriptArtifact, + type JavaScriptArtifactReconstructionResult, +} from "./JavaScriptArtifactReconstruction.js"; import { JAVASCRIPT_APPLICATION_PROVIDER } from "../InvestigationProviders.js"; const OPERATION = "analyze_javascript_application" as const; @@ -45,6 +49,9 @@ export const analyzeJavaScriptApplicationValidated = async ( scope: ArtifactResourceScope, options: ExecutionOptions = {}, ): Promise> => { + let reconstructed: JavaScriptArtifactReconstructionResult | undefined; + let result: JavaScriptApplicationAnalysisResult | undefined; + let evidence: Evidence | undefined; try { assertJavaScriptAnalysisActive(options.signal); await options.progress?.report({ @@ -54,7 +61,7 @@ export const analyzeJavaScriptApplicationValidated = async ( message: "Inventorying and parsing application artifacts", }); await checkpointJavaScriptAnalysis(options.signal); - const reconstructed = await reconstructJavaScriptArtifact( + reconstructed = await reconstructJavaScriptArtifact( { input_path: input.input_path, format: input.format, @@ -78,7 +85,7 @@ export const analyzeJavaScriptApplicationValidated = async ( message: "Validating result metadata and cross-graph bindings", }); await checkpointJavaScriptAnalysis(options.signal); - const result = await completeJavaScriptAnalysisSteps( + result = await completeJavaScriptAnalysisSteps( parseOwnedJavaScriptApplicationAnalysisSteps({ ...application, summary, @@ -93,13 +100,12 @@ export const analyzeJavaScriptApplicationValidated = async ( message: "Creating and hashing application analysis Evidence", }); await checkpointJavaScriptAnalysis(options.signal); - const evidence = - await createOwnedJavaScriptApplicationEvidenceCooperatively( - input, - result, - options.signal, - options.progress, - ); + evidence = await createOwnedJavaScriptApplicationEvidenceCooperatively( + input, + result, + options.signal, + options.progress, + ); await checkpointJavaScriptAnalysis(options.signal); await options.progress?.report({ phase: "analyze_javascript_application", @@ -111,6 +117,38 @@ export const analyzeJavaScriptApplicationValidated = async ( assertJavaScriptAnalysisActive(options.signal); return ok(evidence); } catch (cause: unknown) { + if ( + cause instanceof ArtifactReaderFailure && + cause.reason === "cancelled" && + reconstructed !== undefined + ) { + try { + if (result === undefined) { + const { electron_summary: summary, ...application } = reconstructed; + result = await completeJavaScriptAnalysisSteps( + parseOwnedJavaScriptApplicationAnalysisSteps({ + ...application, + summary, + limitations: reconstructed.graph.limitations, + }), + ); + } + evidence ??= + await createOwnedJavaScriptApplicationEvidenceCooperatively( + input, + result, + ); + return err( + artifactFailureToOperationError(cause).retainPartialObservation( + evidence, + ), + ); + } catch (completionCause: unknown) { + // A validation or Evidence failure while finishing the partial result + // remains the primary failure instead of being hidden by cancellation. + cause = completionCause; + } + } // Preserve execution constraints and their completed partial Evidence. if (cause instanceof AnalysisError) return err(cause); if (cause instanceof ArtifactReaderFailure) diff --git a/src/application/javascript/JavaScriptArtifactReconstruction.ts b/src/application/javascript/JavaScriptArtifactReconstruction.ts index cd5b6c501..2de3e1e6b 100644 --- a/src/application/javascript/JavaScriptArtifactReconstruction.ts +++ b/src/application/javascript/JavaScriptArtifactReconstruction.ts @@ -1,5 +1,6 @@ import { constants } from "node:fs"; import { access, lstat, realpath } from "node:fs/promises"; +import { setImmediate } from "node:timers/promises"; import { ArtifactReaderFailure } from "../../artifacts/ArtifactReader.js"; import { @@ -13,6 +14,7 @@ import type { JavaScriptSemanticGraph } from "../../domain/javascript/javascript import type { ElectronBoundarySummary } from "../../domain/javascript/javascriptApplicationAnalysis.js"; import { parseOwnedJavaScriptApplicationAnalysisSteps } from "../../domain/javascript/javascriptApplicationAnalysis.js"; import { AnalysisError } from "../../domain/analysisErrorBase.js"; +import { ArtifactOperationError } from "../../domain/artifactOperationError.js"; import type { ArtifactInventorySnapshot } from "../../domain/artifactInventorySnapshot.js"; import type { ArtifactInventoryPartialObservation } from "../../domain/artifactPartialObservation.js"; import type { ArtifactResourceScope } from "../../artifacts/ArtifactResourceScope.js"; @@ -68,19 +70,17 @@ export const reconstructJavaScriptArtifact = async ( progress?: ProgressReporter, ): Promise => scope.run(async () => { - let effectiveSignal = signal; - let effectiveProgress = progress; const reportPhase = async ( phase: string, message: string, ): Promise => { - await effectiveProgress?.report({ + await progress?.report({ phase, completed: 0, total: 1, message, }); - await checkpointJavaScriptAnalysis(effectiveSignal); + await checkpointJavaScriptAnalysis(signal); }; const input = javascriptArtifactReconstructionInputSchema.parse(rawInput); abortIfNeeded(signal); @@ -182,14 +182,24 @@ export const reconstructJavaScriptArtifact = async ( } } const analysis = outcome.analysis; - const interruption = cleanupFailure ?? outcome.interruption; - if (interruption !== null && interruption !== undefined) { - effectiveSignal = undefined; - effectiveProgress = undefined; - } - await checkpointJavaScriptAnalysis(effectiveSignal); - abortIfNeeded(effectiveSignal); - await reportPhase( + let interruption = cleanupFailure ?? outcome.interruption; + // Source facts are owned now. Finish their projection cooperatively so a + // later cancellation can retain Evidence instead of discarding those facts. + const checkpointCompletedAnalysis = async (): Promise => { + await setImmediate(); + if (interruption == null && signal?.aborted === true) + interruption = new ArtifactOperationError(OPERATION, "cancelled"); + }; + const reportCompletedPhase = async ( + phase: string, + message: string, + ): Promise => { + await checkpointCompletedAnalysis(); + if (interruption == null) + await progress?.report({ phase, completed: 0, total: 1, message }); + await checkpointCompletedAnalysis(); + }; + await reportCompletedPhase( "build_javascript_application_graph", "Constructing application and Electron boundary relationships", ); @@ -198,15 +208,14 @@ export const reconstructJavaScriptArtifact = async ( files, analysis, ); - await reportPhase( + await reportCompletedPhase( "seal_javascript_application_graph", "Sealing the validated application graph", ); const graph = await completeJavaScriptAnalysisSteps( applicationGraphSteps, - effectiveSignal, ); - await reportPhase( + await reportCompletedPhase( "build_javascript_semantic_graph", "Binding and validating static semantic relationships", ); @@ -214,15 +223,13 @@ export const reconstructJavaScriptArtifact = async ( snapshot.manifest.root_sha256, graph, ); - await reportPhase( + await reportCompletedPhase( "seal_javascript_semantic_graph", "Validating and sealing the semantic graph", ); - const semanticGraph = await completeJavaScriptAnalysisSteps( - semanticGraphSteps, - effectiveSignal, - ); - await checkpointJavaScriptAnalysis(effectiveSignal); + const semanticGraph = + await completeJavaScriptAnalysisSteps(semanticGraphSteps); + await checkpointCompletedAnalysis(); const completed = { input_path: path, format, diff --git a/tests/acceptance/applications/javascriptCancellationCli.test.ts b/tests/acceptance/applications/javascriptCancellationCli.test.ts index 9201b2b17..c270e678c 100644 --- a/tests/acceptance/applications/javascriptCancellationCli.test.ts +++ b/tests/acceptance/applications/javascriptCancellationCli.test.ts @@ -36,11 +36,30 @@ it("returns a nonzero exit for a streamed JSON analysis failure", async () => { // `analyze` routes a JavaScript directory to the same workflow and must keep // its typed cancellation contract. -it - .skipIf(process.platform === "win32") - .each(["analyze-javascript-application", "analyze"])( - "returns a typed cancelled CLI result and exit 130 after SIGINT through %s", - async (command) => { +it.skipIf(process.platform === "win32").each([ + { + command: "analyze-javascript-application", + phase: "analyze_javascript_application", + parsedFiles: null, + }, + { + command: "analyze", + phase: "analyze_javascript_application", + parsedFiles: null, + }, + { + command: "analyze-javascript-application", + phase: "build_javascript_application_graph", + parsedFiles: 1, + }, + { + command: "analyze-javascript-application", + phase: "hash_javascript_application_evidence", + parsedFiles: 1, + }, +])( + "returns typed cancellation and exit 130 after SIGINT through $command during $phase", + async ({ command, phase, parsedFiles }) => { const root = await createTestTempDirectory("rea-cli-js-cancellation-"); await writeFile(join(root, "main.js"), "export const observed = 1;\n"); const child = spawn( @@ -70,7 +89,11 @@ it const line = partial.slice(0, newline); partial = partial.slice(newline + 1); const parsed = progressSchema.safeParse(JSON.parse(line)); - if (parsed.success && !requested) { + if ( + parsed.success && + parsed.data.rea_progress.phase === phase && + !requested + ) { requested = true; child.kill("SIGINT"); } @@ -89,5 +112,18 @@ it code: "cancelled", details: { reason: "cancelled" }, }); + if (parsedFiles !== null) + expect(JSON.parse(stdout)).toMatchObject({ + details: { + partial_observation: { + normalized_result: { + statistics: { + parsed_javascript_files: parsedFiles, + parse_failures: 0, + }, + }, + }, + }, + }); }, ); diff --git a/tests/boundary/javascript/javascriptApplicationCancellation.test.ts b/tests/boundary/javascript/javascriptApplicationCancellation.test.ts index 17c47c1c9..b8d35326a 100644 --- a/tests/boundary/javascript/javascriptApplicationCancellation.test.ts +++ b/tests/boundary/javascript/javascriptApplicationCancellation.test.ts @@ -1,13 +1,72 @@ import { setImmediate } from "node:timers"; import { mkdir, writeFile } from "node:fs/promises"; import { join } from "node:path"; -import { describe, expect, it } from "vitest"; +import { expect, it, onTestFinished } from "vitest"; import { z } from "zod"; +import { + ArtifactResourceScope, + type ArtifactResourceOwner, +} from "../../../src/artifacts/ArtifactResourceScope.js"; +import { analyzeJavaScriptApplication as analyzeOwnedApplication } from "../../../src/application/javascript/JavaScriptApplicationService.js"; import { javascriptApplicationAnalysisResultSchema } from "../../../src/domain/javascript/javascriptApplicationAnalysis.js"; import { createTestTempDirectory } from "../../fixtures/temporaryDirectory.js"; import { projectAnalysisError } from "../../../src/domain/analysisErrorProjection.js"; import { analyzeJavaScriptApplication } from "../../support/javascriptApplicationScope.js"; +it.each(["during cleanup", "on the next control turn"] as const)( + "retains the final source when cancellation arrives %s after analysis", + async (timing) => { + const root = await createTestTempDirectory("rea-js-cancel-handoff-"); + await writeFile( + join(root, "main.js"), + 'import { readFile } from "node:fs/promises"; export { readFile };\n', + ); + const controller = new AbortController(); + class CancellingCleanupScope extends ArtifactResourceScope { + override async release(owner: ArtifactResourceOwner) { + const result = await super.release(owner); + if (owner.kind === "javascript-source-analysis") { + if (timing === "during cleanup") controller.abort(); + else setImmediate(() => controller.abort()); + } + return result; + } + } + const scope = new CancellingCleanupScope(); + onTestFinished(() => scope.close()); + const result = await analyzeOwnedApplication( + { input_path: root, format: "directory" }, + scope, + { signal: controller.signal }, + ); + if (result.ok) throw new Error("Cancelled handoff must not succeed"); + const projected = projectAnalysisError(result.error); + expect(projected.code).toBe("cancelled"); + const partial = z + .object({ + details: z.object({ + partial_observation: z.object({ + normalized_result: javascriptApplicationAnalysisResultSchema, + }), + }), + }) + .parse(projected).details.partial_observation.normalized_result; + expect(partial.statistics).toMatchObject({ + relevant_files: 1, + parsed_javascript_files: 1, + parse_failures: 0, + }); + expect(partial.graph.edges).toContainEqual( + expect.objectContaining({ + properties: expect.objectContaining({ + specifier: "node:fs/promises", + module_link_kind: "import", + }), + }), + ); + }, +); + it("preserves imported manifest status when cancelled before visiting manifests", async () => { const root = await createTestTempDirectory("rea-js-cancel-package-"); const manifests = [ @@ -75,156 +134,169 @@ it("preserves imported manifest status when cancelled before visiting manifests" }); }); -describe("JavaScript application cancellation", () => { - it("interrupts semantic graph commitment before result validation", async () => { - const root = await createTestTempDirectory("rea-js-cancel-semantic-"); - await writeFile( - join(root, "main.js"), - Array.from( - { length: 500 }, - (_, index) => - `export function inspect${index}(value) { return JSON.stringify(value); }`, - ).join("\n"), - ); - const controller = new AbortController(); - const phases: string[] = []; - const result = await analyzeJavaScriptApplication( - { input_path: root, format: "directory" }, - { - signal: controller.signal, - progress: { - async report(event) { - phases.push(event.phase); - if (event.phase === "seal_javascript_semantic_graph") - setImmediate(() => controller.abort()); - }, +it("interrupts semantic graph commitment before result validation", async () => { + const root = await createTestTempDirectory("rea-js-cancel-semantic-"); + await writeFile( + join(root, "main.js"), + Array.from( + { length: 500 }, + (_, index) => + `export function inspect${index}(value) { return JSON.stringify(value); }`, + ).join("\n"), + ); + const controller = new AbortController(); + const phases: string[] = []; + const result = await analyzeJavaScriptApplication( + { input_path: root, format: "directory" }, + { + signal: controller.signal, + progress: { + async report(event) { + phases.push(event.phase); + if (event.phase === "seal_javascript_semantic_graph") + setImmediate(() => controller.abort()); }, }, + }, + ); + if (result.ok) + throw new Error( + "Cancelled commitment must not publish a successful analysis", ); - if (result.ok) - throw new Error( - "Cancelled commitment must not publish a successful analysis", - ); - expect(projectAnalysisError(result.error)).toMatchObject({ - code: "cancelled", - details: { reason: "cancelled" }, - }); - expect(phases).toContain("seal_javascript_semantic_graph"); - expect(phases).not.toContain("validate_javascript_application_result"); + expect(projectAnalysisError(result.error)).toMatchObject({ + code: "cancelled", + details: { reason: "cancelled" }, }); + expect(phases).toContain("seal_javascript_semantic_graph"); + expect(phases).not.toContain("validate_javascript_application_result"); +}); - it("honors control turns and cancellation while preparing one large source for owned analysis (#1462)", async () => { - const root = await createTestTempDirectory("rea-js-cancel-in-file-"); - await writeFile( - join(root, "index.js"), - Array.from( - { length: 1200 }, - (_, index) => - `export function f${index}(){return {name:'f${index}',value:${index}};}`, - ).join("\n"), - ); - const controller = new AbortController(); - const phases: string[] = []; - let turns = 0; - // Count event-loop turns once the file starts; parsing, analysis and - // projection of this one file must leave room for them. - const tick = (): void => { - turns += 1; - if (turns === 3) controller.abort(); - else if (!controller.signal.aborted) setImmediate(tick); - }; - const result = await analyzeJavaScriptApplication( - { input_path: root, format: "directory" }, - { - signal: controller.signal, - progress: { - async report(event) { - phases.push(event.phase); - if (event.phase === "parse_javascript_source") setImmediate(tick); - }, +it("honors control turns and cancellation while preparing one large source for owned analysis (#1462)", async () => { + const root = await createTestTempDirectory("rea-js-cancel-in-file-"); + await writeFile( + join(root, "index.js"), + Array.from( + { length: 1200 }, + (_, index) => + `export function f${index}(){return {name:'f${index}',value:${index}};}`, + ).join("\n"), + ); + const controller = new AbortController(); + const phases: string[] = []; + let turns = 0; + // Count event-loop turns once the file starts; parsing, analysis and + // projection of this one file must leave room for them. + const tick = (): void => { + turns += 1; + if (turns === 3) controller.abort(); + else if (!controller.signal.aborted) setImmediate(tick); + }; + const result = await analyzeJavaScriptApplication( + { input_path: root, format: "directory" }, + { + signal: controller.signal, + progress: { + async report(event) { + phases.push(event.phase); + if (event.phase === "parse_javascript_source") setImmediate(tick); }, }, - ); - if (result.ok) throw new Error("Cancelled analysis must not succeed"); - expect(projectAnalysisError(result.error)).toMatchObject({ - code: "cancelled", - }); - expect(phases).toContain("parse_javascript_source"); - expect(phases).not.toContain("build_javascript_application_graph"); + }, + ); + if (result.ok) throw new Error("Cancelled analysis must not succeed"); + expect(projectAnalysisError(result.error)).toMatchObject({ + code: "cancelled", }); + expect(phases).toContain("parse_javascript_source"); + expect(phases).not.toContain("build_javascript_application_graph"); +}); - it.each([ - "parse_javascript_source", - "build_javascript_application_graph", - "seal_javascript_application_graph", - "build_javascript_semantic_graph", - "seal_javascript_semantic_graph", - "validate_javascript_application_result", - "create_javascript_application_evidence", - "seal_javascript_application_result", - "validate_javascript_application_evidence", - "hash_javascript_application_evidence", - ])("rejects cancellation scheduled during %s", async (phase) => { - const root = await createTestTempDirectory("rea-js-cancel-phase-"); - await writeFile( - join(root, "main.js"), - "export function inspect(value) { return JSON.stringify(value); }\n", - ); - const controller = new AbortController(); - let requested = false; - const terminal: string[] = []; - const result = await analyzeJavaScriptApplication( - { input_path: root, format: "directory" }, - { - signal: controller.signal, - progress: { - async report(event) { - if (event.terminal) terminal.push(event.phase); - if (event.phase === phase) { - requested = true; - setImmediate(() => controller.abort()); - } +it.each([ + "parse_javascript_source", + "build_javascript_application_graph", + "seal_javascript_application_graph", + "build_javascript_semantic_graph", + "seal_javascript_semantic_graph", + "validate_javascript_application_result", + "create_javascript_application_evidence", + "seal_javascript_application_result", + "validate_javascript_application_evidence", + "hash_javascript_application_evidence", +])("rejects cancellation scheduled during %s", async (phase) => { + const root = await createTestTempDirectory("rea-js-cancel-phase-"); + await writeFile( + join(root, "main.js"), + "export function inspect(value) { return JSON.stringify(value); }\n", + ); + const controller = new AbortController(); + let requested = false; + const terminal: string[] = []; + const result = await analyzeJavaScriptApplication( + { input_path: root, format: "directory" }, + { + signal: controller.signal, + progress: { + async report(event) { + if (event.terminal) terminal.push(event.phase); + if (event.phase === phase) { + requested = true; + setImmediate(() => controller.abort()); + } + }, + }, + }, + ); + expect(requested).toBe(true); + if (result.ok) throw new Error("Cancelled analysis must return a failure"); + const failure = projectAnalysisError(result.error); + expect(failure).toMatchObject({ code: "cancelled" }); + expect(failure).toMatchObject({ + details: { + operation: "analyze_javascript_application", + partial_observation: { + normalized_result: { + statistics: { + relevant_files: 1, + parsed_javascript_files: + phase === "parse_javascript_source" ? 0 : 1, + parse_failures: 0, }, }, }, - ); - expect(requested).toBe(true); - if (result.ok) throw new Error("Cancelled analysis must return a failure"); - const failure = projectAnalysisError(result.error); - expect(failure).toMatchObject({ code: "cancelled" }); - if (phase === "parse_javascript_source") - expect(failure).toMatchObject({ - details: { - operation: "analyze_javascript_application", - cleanup: "complete", - partial_observation: { - normalized_result: { - statistics: { relevant_files: 1, parse_failures: 0 }, - }, - }, - }, - }); - expect(terminal).toEqual([]); + }, }); + if (phase === "parse_javascript_source") + expect(failure.details).toMatchObject({ cleanup: "complete" }); + expect(terminal).toEqual([]); +}); - it("honors cancellation observed during the terminal publication race", async () => { - const root = await createTestTempDirectory("rea-js-cancel-terminal-"); - await writeFile(join(root, "main.js"), "export const value = 1;\n"); - const controller = new AbortController(); - const result = await analyzeJavaScriptApplication( - { input_path: root, format: "directory" }, - { - signal: controller.signal, - progress: { - async report(event) { - if (event.terminal) controller.abort(); - }, +it("honors cancellation observed during the terminal publication race", async () => { + const root = await createTestTempDirectory("rea-js-cancel-terminal-"); + await writeFile(join(root, "main.js"), "export const value = 1;\n"); + const controller = new AbortController(); + const result = await analyzeJavaScriptApplication( + { input_path: root, format: "directory" }, + { + signal: controller.signal, + progress: { + async report(event) { + if (event.terminal) controller.abort(); }, }, - ); - if (result.ok) throw new Error("Expected cancellation before admission"); - expect(projectAnalysisError(result.error)).toMatchObject({ - details: { reason: "cancelled" }, - }); + }, + ); + if (result.ok) throw new Error("Expected cancellation before admission"); + const failure = projectAnalysisError(result.error); + expect(failure).toMatchObject({ + details: { reason: "cancelled" }, + }); + expect(failure).toMatchObject({ + details: { + partial_observation: { + normalized_result: { + statistics: { relevant_files: 1, parsed_javascript_files: 1 }, + }, + }, + }, }); }); diff --git a/tests/boundary/mcp/javascriptAnalysisCancellation.test.ts b/tests/boundary/mcp/javascriptAnalysisCancellation.test.ts index eb3763a81..e910ec411 100644 --- a/tests/boundary/mcp/javascriptAnalysisCancellation.test.ts +++ b/tests/boundary/mcp/javascriptAnalysisCancellation.test.ts @@ -12,128 +12,143 @@ import { } from "../../fixtures/binarySession.js"; import { createTestTempDirectory } from "../../fixtures/temporaryDirectory.js"; -it("retains completed source facts and prior Evidence after SDK cancellation, then accepts subsequent work", async () => { - 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(); - 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); +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 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 }, }, - }, - ); - const server = createServer({ kind: "session", session }, { logger }); - const client = new Client({ name: "javascript-cancellation", version: "1" }); - onTestFinished(async () => { - 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( - { + ); + expect(session.recordEvidence(prior).ok).toBe(true); + const completed = createDeferred(); + 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 () => { + 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")); + }, + }, + ); + 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"); + 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", }, - }, - { - signal: controller.signal, - onprogress(update) { - if ( - !update.message?.startsWith("parse_javascript_source:") || - !update.message.includes("second.js") - ) - return; - requested = true; - controller.abort(new Error("SDK cancellation verification")); - }, - }, - ); - 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"); - 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: 1, 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(); -}); + }); + expect(next.isError, JSON.stringify(next)).not.toBe(true); + await client.ping(); + }, +);