diff --git a/src/application/AnalysisProvider.ts b/src/application/AnalysisProvider.ts index 7d316cb795..bbcb8f768f 100644 --- a/src/application/AnalysisProvider.ts +++ b/src/application/AnalysisProvider.ts @@ -9,9 +9,13 @@ import type { AnalysisError } from "../domain/analysisErrorBase.js"; import type { AnalysisProfileCommitment } from "../domain/analysisProfile.js"; import type { JsonValue } from "../domain/jsonValue.js"; import type { Result } from "../domain/result.js"; -import type { EvidenceLocation } from "../domain/evidence.js"; -import type { EvidenceSubjectTarget } from "../domain/evidence.js"; -import { jsonValueSchema } from "../domain/jsonValue.js"; +import { + createEvidence, + type Evidence, + type EvidenceLocation, + type EvidenceSubjectTarget, +} from "../domain/evidence.js"; +import { jsonObjectSchema, jsonValueSchema } from "../domain/jsonValue.js"; import type { ProgressReporter } from "./ProgressReporter.js"; import type { ProviderRejectionCode } from "../contracts/providerSelection.js"; import type { ProcessLineageObservation } from "../process/ProcessOwnership.js"; @@ -123,6 +127,26 @@ export function createAnalysisExecution( }; } +/** + * Compose one completed provider execution into its caller-visible Evidence, + * so success and a late cancellation that retains it carry identical facts. + */ +export const createAnalysisExecutionEvidence = ( + operation: string, + input: unknown, + execution: AnalysisExecution, + confidence: "observed" | "derived", +): Evidence => + createEvidence(execution.subject ?? undefined, execution.provider, { + operation, + parameters: jsonObjectSchema.parse(input), + result: execution.result, + rawResult: execution.rawResult, + limitations: execution.limitations, + locations: execution.locations, + confidence, + }); + /** Provider-neutral application capability for one analysis operation. */ export interface AnalysisOperationPort { execute( diff --git a/src/application/ProviderOperationExecution.ts b/src/application/ProviderOperationExecution.ts index b9d20f38ef..3cb46ee354 100644 --- a/src/application/ProviderOperationExecution.ts +++ b/src/application/ProviderOperationExecution.ts @@ -7,12 +7,12 @@ import { AnalysisCancelledError, AnalysisInputError, } from "../domain/analysisErrorCore.js"; -import { createEvidence, type Evidence } from "../domain/evidence.js"; -import { jsonObjectSchema } from "../domain/jsonValue.js"; +import type { Evidence } from "../domain/evidence.js"; import { err, ok, type Result } from "../domain/result.js"; -import type { - AnalysisExecution, - ExecutionOptions, +import { + createAnalysisExecutionEvidence, + type AnalysisExecution, + type ExecutionOptions, } from "./AnalysisProvider.js"; /** Validated caller-selected request carrying its operation input. */ @@ -55,19 +55,11 @@ export const executeProviderOperation = async < return err(new AnalysisInputError(operation, { cause: request.error })); const executed = await provider.execute(request.data, options); if (!executed.ok) return executed; - const execution = executed.value; - const evidence = createEvidence( - execution.subject ?? undefined, - execution.provider, - { - operation, - parameters: jsonObjectSchema.parse(request.data.input), - result: execution.result, - rawResult: execution.rawResult, - limitations: execution.limitations, - locations: execution.locations, - confidence, - }, + const evidence = createAnalysisExecutionEvidence( + operation, + request.data.input, + executed.value, + confidence, ); return completeProviderEvidence(evidence, options?.signal); }; diff --git a/src/flutter/FlutterBuildProvider.ts b/src/flutter/FlutterBuildProvider.ts index 11df7c0137..d50eaacf9b 100644 --- a/src/flutter/FlutterBuildProvider.ts +++ b/src/flutter/FlutterBuildProvider.ts @@ -12,6 +12,7 @@ import type { } from "../application/AnalysisProvider.js"; import { createAnalysisExecution, + createAnalysisExecutionEvidence, type AnalysisExecution, } from "../application/AnalysisProvider.js"; import type { FlutterRequest } from "../domain/flutter/flutterBuildAnalysis.js"; @@ -619,9 +620,23 @@ export class FlutterBuildProvider { } finally { if (snapshot !== undefined) await this.discardRoot(snapshot.root); } + // Cancellation observed after the awaited snapshot removal still keeps + // the completed facts, composed exactly as the uncancelled Evidence. if (signal?.aborted === true) return err( - new AnalysisCancelledError(request.operation, { cause: signal.reason }), + new AnalysisCancelledError(request.operation, { + cause: signal.reason, + ...(result.ok + ? { + partialObservation: createAnalysisExecutionEvidence( + request.operation, + request.input, + result.value, + "observed", + ), + } + : {}), + }), ); return result; } diff --git a/tests/boundary/filesystem/flutterBuildProvider.test.ts b/tests/boundary/filesystem/flutterBuildProvider.test.ts index a4f4deeac0..e9b61cac47 100644 --- a/tests/boundary/filesystem/flutterBuildProvider.test.ts +++ b/tests/boundary/filesystem/flutterBuildProvider.test.ts @@ -371,6 +371,48 @@ it.each(["close", "elf", "pool", "cleanup"] as const)( }, ); +it.each(["identify_flutter_build", "inspect_dart_aot"] as const)( + "%s keeps completed Evidence when cancelled during successful snapshot removal", + async (operation) => { + const path = await buildApk("removal-cancellation.apk", [ + { path: "lib/arm64-v8a/libapp.so", bytes: aotImage() }, + { path: "lib/arm64-v8a/libflutter.so", bytes: libflutter() }, + ]); + const controlProvider = new FlutterBuildProvider(); + onTestFinished(() => controlProvider.close()); + const control = await new FlutterBuildAnalysisService( + controlProvider, + ).execute(operation, { path }); + if (!control.ok) throw control.error; + + const controller = new AbortController(); + const reason = new Error("caller interrupted snapshot removal"); + const removedRoots: string[] = []; + const provider = new FlutterBuildProvider({ + removeRoot: async (root) => { + await rm(root, { recursive: true, force: true }); + removedRoots.push(root); + controller.abort(reason); + }, + }); + const result = await new FlutterBuildAnalysisService(provider).execute( + operation, + { path }, + { signal: controller.signal }, + ); + if (result.ok) throw new Error("cancellation must prevent success"); + expect(result.error).toBeInstanceOf(AnalysisCancelledError); + expect(result.error.cause).toBe(reason); + expect(result.error.partialObservation).toEqual(control.value); + expect(removedRoots).toHaveLength(1); + await expect(stat(removedRoots[0]!)).rejects.toMatchObject({ + code: "ENOENT", + }); + await provider.close(); + expect(removedRoots).toHaveLength(1); + }, +); + it("projects each payload shape through the owned snapshot", async () => { const hash = REFERENCE_HASH; const agreeBoth = await buildApk("agree.apk", [