From 93a384db77a7bb7d079abd2ca36b9bb911cb913f Mon Sep 17 00:00:00 2001 From: morluto <76467478+morluto@users.noreply.github.com> Date: Fri, 23 Oct 2026 03:21:26 +0800 Subject: [PATCH] fix(evidence): classify derived failures at their owning boundary Parse caller selections and authenticated Evidence at their owners, reuse the parsed function and comparison facts, and preserve unexpected implementation failures. Remove downstream error guessing and redundant validation wrappers. Verify with 526 affected tests, source checks, docs conformance, test discovery and duplication checks. Seven public SDK regressions fail on the main baseline. No real-provider execution was performed. --- src/application/EvidenceBundleFiles.ts | 2 +- .../InventoryProjectionEvidence.ts | 33 +- .../ReconstructionObligationCandidates.ts | 2 +- .../android/AndroidApplicationService.ts | 1 - .../apple/AppleApplicationService.ts | 1 - .../harmony/HarmonyApplicationService.ts | 2 - src/application/process/ProcessCli.ts | 2 +- src/artifacts/ArtifactOperationFailure.ts | 23 +- src/contracts/investigationExamples.ts | 24 +- .../analysisView/artifactInventoryView.ts | 3 +- src/domain/android/androidApplication.ts | 17 +- src/domain/apple/appleApplication.test.ts | 26 +- src/domain/apple/appleApplication.ts | 17 +- src/domain/artifactComparison.ts | 19 +- src/domain/artifactInventoryEvidence.test.ts | 73 ++++- src/domain/artifactInventoryEvidence.ts | 181 ++++++++--- src/domain/callPath.test.ts | 9 +- src/domain/callPath.ts | 115 ++++--- src/domain/changedBehavior.test.ts | 77 ++++- src/domain/changedBehavior.ts | 123 ++++++-- src/domain/comparisonSourceEvidence.ts | 18 +- src/domain/evidenceBundle.ts | 13 +- src/domain/evidenceValidation.ts | 32 ++ src/domain/functionComparison.test.ts | 219 ++++++++------ src/domain/functionComparison.ts | 10 +- src/domain/functionDossierEvidence.fixture.ts | 14 + src/domain/functionDossierEvidence.ts | 53 +++- src/domain/harmony/harmonyApplication.ts | 17 +- ...uctionVerification.functionSources.test.ts | 74 +++-- src/domain/reconstructionVerification.test.ts | 52 +++- src/domain/reconstructionVerification.ts | 158 ++++++++-- src/domain/staticRuntimeCorrelation.test.ts | 141 ++++++++- src/domain/staticRuntimeCorrelation.ts | 284 +++++++++++++----- src/domain/validationFailure.ts | 13 + src/server/promptCompletion.ts | 13 +- src/server/registerArtifactComparisonTool.ts | 13 +- src/server/registerFunctionComparisonTool.ts | 64 ++-- src/server/registerInvestigationTools.ts | 3 +- .../registerInvestigationTools/helpers.ts | 3 - src/server/registerProcessComparisonTool.ts | 2 +- src/server/runDerivedOperation.test.ts | 39 +-- src/server/runDerivedOperation.ts | 23 +- .../filesystem/artifactComparison.test.ts | 16 +- .../mcp/functionComparisonMcp.test.ts | 19 +- .../processComparisonVerificationMcp.test.ts | 13 +- .../derivedOperationFailures.test.ts | 216 +++++++++++++ 46 files changed, 1706 insertions(+), 566 deletions(-) create mode 100644 src/domain/evidenceValidation.ts create mode 100644 src/domain/functionDossierEvidence.fixture.ts create mode 100644 src/domain/validationFailure.ts create mode 100644 tests/process-global/derivedOperationFailures.test.ts diff --git a/src/application/EvidenceBundleFiles.ts b/src/application/EvidenceBundleFiles.ts index 1662951765..762e811000 100644 --- a/src/application/EvidenceBundleFiles.ts +++ b/src/application/EvidenceBundleFiles.ts @@ -1,9 +1,9 @@ import { requireAuthenticationHeadroom } from "./JsonAuthenticationHeadroom.js"; import { absoluteCallerPath } from "../filesystem/callerPath.js"; +import { describeValidationFailure } from "../domain/validationFailure.js"; import { describeEvidenceBundleFailure, - describeValidationFailure, parseImmutableEvidenceBundle, parseEvidenceBundle, type EvidenceBundle, diff --git a/src/application/InventoryProjectionEvidence.ts b/src/application/InventoryProjectionEvidence.ts index d027febb57..b89059c15e 100644 --- a/src/application/InventoryProjectionEvidence.ts +++ b/src/application/InventoryProjectionEvidence.ts @@ -1,7 +1,7 @@ import { z } from "zod"; -import { AnalysisProtocolError } from "../domain/analysisErrorCore.js"; -import type { AnalysisError } from "../domain/analysisErrorBase.js"; +import { AnalysisInputError } from "../domain/analysisErrorCore.js"; +import { AnalysisError } from "../domain/analysisErrorBase.js"; import { createEvidence, type Evidence, @@ -11,7 +11,8 @@ import { import { jsonValueSchema } from "../domain/jsonValue.js"; import type { MobileApplicationProjectionWithSources } from "../domain/mobileApplicationGraphProjection.js"; import { err, ok, type Result } from "../domain/result.js"; -import { requestInputError, workflowInputError } from "./workflowInputError.js"; +import { requestInputError } from "./workflowInputError.js"; +import { ArtifactInventoryCarrierError } from "../domain/artifactInventoryEvidence.js"; interface InventoryProjectionInput { readonly inventory_evidence: readonly Evidence[]; @@ -42,7 +43,6 @@ interface InventoryProjectionOptions< readonly predicateType: string; readonly provider: EvidenceProvider; readonly subjectFormat: (first: Evidence) => EvidenceSubjectTarget["format"]; - readonly protocolError: string; } /** Parse one inventory projection and wrap its deterministic result in Evidence. */ @@ -92,10 +92,25 @@ export const projectInventoryEvidence = < sources: projected.sources, }); } catch (cause: unknown) { - return err( - cause instanceof TypeError || cause instanceof z.ZodError - ? workflowInputError(options.operation, cause) - : new AnalysisProtocolError(options.protocolError, { cause }), - ); + if (cause instanceof AnalysisError) return err(cause); + if (cause instanceof ArtifactInventoryCarrierError) + return err( + new AnalysisInputError( + options.operation, + { cause, userMessage: cause.message }, + [ + { + path: [ + "inventory_evidence", + ...(cause.index === undefined ? [] : [cause.index]), + ...(cause.index === undefined ? [] : ["operation"]), + ], + reason: "invalid_value", + message: cause.message, + }, + ], + ), + ); + throw cause; } }; diff --git a/src/application/ReconstructionObligationCandidates.ts b/src/application/ReconstructionObligationCandidates.ts index 7abc13d4d2..a6bae5d1d2 100644 --- a/src/application/ReconstructionObligationCandidates.ts +++ b/src/application/ReconstructionObligationCandidates.ts @@ -2,8 +2,8 @@ import { uniqueSorted, uniqueSortedBy } from "../domain/canonicalOrdering.js"; import { digestCanonicalValue } from "../domain/canonicalDigest.js"; import type { Evidence } from "../domain/evidence.js"; +import { describeValidationFailure } from "../domain/validationFailure.js"; import { - describeValidationFailure, parseEvidenceBundle, type EvidenceBundle, } from "../domain/evidenceBundle.js"; diff --git a/src/application/android/AndroidApplicationService.ts b/src/application/android/AndroidApplicationService.ts index de95c455f3..58f202d9b0 100644 --- a/src/application/android/AndroidApplicationService.ts +++ b/src/application/android/AndroidApplicationService.ts @@ -24,6 +24,5 @@ export const projectAndroidApplicationEvidence = ( predicateType: "rea.android-application-graph", provider: ANDROID_APPLICATION_PROVIDER, subjectFormat: () => "apk", - protocolError: "Android application projection produced an invalid result", }); }; diff --git a/src/application/apple/AppleApplicationService.ts b/src/application/apple/AppleApplicationService.ts index 25686959d3..34495afab6 100644 --- a/src/application/apple/AppleApplicationService.ts +++ b/src/application/apple/AppleApplicationService.ts @@ -24,6 +24,5 @@ export const projectAppleApplicationEvidence = ( predicateType: "rea.apple-application-graph", provider: APPLE_APPLICATION_PROVIDER, subjectFormat: (first) => first.subject?.format ?? "unknown", - protocolError: "Apple application projection produced an invalid result", }); }; diff --git a/src/application/harmony/HarmonyApplicationService.ts b/src/application/harmony/HarmonyApplicationService.ts index 03d79c98fb..7e277ba533 100644 --- a/src/application/harmony/HarmonyApplicationService.ts +++ b/src/application/harmony/HarmonyApplicationService.ts @@ -24,7 +24,5 @@ export const projectHarmonyApplicationEvidence = ( predicateType: "rea.harmony-application-graph", provider: HARMONY_APPLICATION_PROVIDER, subjectFormat: (first) => first.subject?.format ?? "unknown", - protocolError: - "HarmonyOS application projection produced an invalid result", }); }; diff --git a/src/application/process/ProcessCli.ts b/src/application/process/ProcessCli.ts index badbf5e13d..3d1ac08f50 100644 --- a/src/application/process/ProcessCli.ts +++ b/src/application/process/ProcessCli.ts @@ -10,7 +10,7 @@ import { projectAnalysisError } from "../../domain/analysisErrorProjection.js"; import type { AnalysisErrorProjection } from "../../domain/analysisErrorProjection.js"; import { ProviderAdapterError } from "../../domain/providerAdapterError.js"; import { createEvidence } from "../../domain/evidence.js"; -import { describeValidationFailure } from "../../domain/evidenceBundle.js"; +import { describeValidationFailure } from "../../domain/validationFailure.js"; import { jsonValueSchema } from "../../domain/jsonValue.js"; import { analysisInputErrorFromIssues, diff --git a/src/artifacts/ArtifactOperationFailure.ts b/src/artifacts/ArtifactOperationFailure.ts index 7a279e0bc3..aa2f718b10 100644 --- a/src/artifacts/ArtifactOperationFailure.ts +++ b/src/artifacts/ArtifactOperationFailure.ts @@ -1,14 +1,5 @@ -import type { AnalysisError } from "../domain/analysisErrorBase.js"; -import { - AnalysisInputError, - AnalysisCancelledError, - AnalysisUnsupportedTargetError, -} from "../domain/analysisErrorCore.js"; +import { AnalysisError } from "../domain/analysisErrorBase.js"; import { ArtifactOperationError } from "../domain/artifactOperationError.js"; -import { - BinaryTargetError, - ConfigurationError, -} from "../domain/configurationErrors.js"; import { ArtifactReaderFailure } from "./ArtifactReader.js"; import { artifactFileCancellationCause, @@ -43,15 +34,7 @@ export const translateArtifactFailure = ( bindArtifactFileCleanup(cause, error); return error; } - // Caller-selection, target, unsupported-target and configuration failures are already typed; - // keep their correction details instead of reducing them to an I/O failure. - if ( - cause instanceof AnalysisInputError || - cause instanceof AnalysisCancelledError || - cause instanceof AnalysisUnsupportedTargetError || - cause instanceof BinaryTargetError || - cause instanceof ConfigurationError - ) - return cause; + // Keep typed Evidence and other analysis failures instead of reducing them to I/O. + if (cause instanceof AnalysisError) return cause; return new ArtifactOperationError(operation, "io"); }; diff --git a/src/contracts/investigationExamples.ts b/src/contracts/investigationExamples.ts index 3ed326f7a7..44698c0245 100644 --- a/src/contracts/investigationExamples.ts +++ b/src/contracts/investigationExamples.ts @@ -6,11 +6,19 @@ import { parseProcessCapture } from "../domain/process/processCaptureParsing.js" import { FUNCTION_COMPARISON_EXAMPLE } from "./functionComparisonExample.js"; import { EMPTY_PROCESS_CAPTURE_EXAMPLE } from "../domain/process/processCaptureExample.js"; +import { parseFunctionEvidence } from "../domain/functionDossierEvidence.js"; import { compareFunctions } from "../domain/functionComparison.js"; -const comparison = compareFunctions( - FUNCTION_COMPARISON_EXAMPLE.left, - FUNCTION_COMPARISON_EXAMPLE.right, -); +const left = parseFunctionEvidence(FUNCTION_COMPARISON_EXAMPLE.left, { + operation: "compare_functions", + path: ["left"], +}); +if (!left.ok) throw left.error; +const right = parseFunctionEvidence(FUNCTION_COMPARISON_EXAMPLE.right, { + operation: "compare_functions", + path: ["right"], +}); +if (!right.ok) throw right.error; +const comparison = compareFunctions(left.value, right.value); export const FUNCTION_COMPARISON_EVIDENCE = createEvidence( undefined, @@ -19,16 +27,16 @@ export const FUNCTION_COMPARISON_EVIDENCE = createEvidence( predicateType: "rea.function-comparison", operation: "compare_functions", parameters: { - left_evidence_id: FUNCTION_COMPARISON_EXAMPLE.left.evidence_id, - right_evidence_id: FUNCTION_COMPARISON_EXAMPLE.right.evidence_id, + left_evidence_id: left.value.evidence.evidence_id, + right_evidence_id: right.value.evidence.evidence_id, }, result: jsonValueSchema.parse(comparison), confidence: "derived", authority: "analyst-inference", limitations: comparison.limitations, evidenceLinks: [ - FUNCTION_COMPARISON_EXAMPLE.left.evidence_id, - FUNCTION_COMPARISON_EXAMPLE.right.evidence_id, + left.value.evidence.evidence_id, + right.value.evidence.evidence_id, ], }, ); diff --git a/src/domain/analysisView/artifactInventoryView.ts b/src/domain/analysisView/artifactInventoryView.ts index 82018733b7..e819db137a 100644 --- a/src/domain/analysisView/artifactInventoryView.ts +++ b/src/domain/analysisView/artifactInventoryView.ts @@ -23,6 +23,7 @@ import { validateArtifactInventorySubject, parseArtifactInspectionInventoryResult, parseArtifactInventoryResult, + ArtifactInventoryValidationError, type InventorySet, } from "../artifactInventoryEvidence.js"; import type { ArtifactInspectionResult } from "../artifactInspection.js"; @@ -219,7 +220,7 @@ const sourceFailure = ( ): Result => { if (cause instanceof z.ZodError) return err(analysisViewResultInputError(input, cause)); - if (!(cause instanceof TypeError)) throw cause; + if (!(cause instanceof ArtifactInventoryValidationError)) throw cause; return err(inputError(cause.message)); }; diff --git a/src/domain/android/androidApplication.ts b/src/domain/android/androidApplication.ts index 13c6e53be3..0aa32e3b58 100644 --- a/src/domain/android/androidApplication.ts +++ b/src/domain/android/androidApplication.ts @@ -3,6 +3,7 @@ import { projectCartesianCandidates, } from "../bridgeCandidateProjection.js"; import { z } from "zod"; +import { AnalysisInputError } from "../analysisErrorCore.js"; import { hasCompleteInventoryCoverage, @@ -94,9 +95,19 @@ export const projectAndroidApplication = ( parsed.inventory_evidence, ); if (inventory.manifest.root_format !== "apk") - throw new TypeError( - `Android application projection requires APK inventory Evidence; the inventory root format is ${inventory.manifest.root_format}. APK is chosen for a ZIP archive whose name ends in .apk.`, - ); + throw new AnalysisInputError("project_android_application_graph", {}, [ + { + path: [ + "inventory_evidence", + 0, + "normalized_result", + "manifest", + "root_format", + ], + reason: "invalid_value", + message: `Android application projection requires APK inventory Evidence; the inventory root format is ${inventory.manifest.root_format}. APK is chosen for a ZIP archive whose name ends in .apk.`, + }, + ]); const entries = projectedInventoryEntries(inventory); const all = projectedFileComponents(entries); const unavailable = entries.flatMap(({ unavailable }) => diff --git a/src/domain/apple/appleApplication.test.ts b/src/domain/apple/appleApplication.test.ts index fc973ba732..85868bcdf9 100644 --- a/src/domain/apple/appleApplication.test.ts +++ b/src/domain/apple/appleApplication.test.ts @@ -5,6 +5,7 @@ import { describe, expect, it } from "vitest"; import type { ArtifactOccurrence } from "../artifactGraph.js"; import { projectAppleApplication } from "./appleApplication.js"; +import { AnalysisInputError } from "../analysisErrorCore.js"; import { compareUnicodeCodePoints } from "../unicodeCodePointOrder.js"; import { digestCanonicalValue } from "../canonicalDigest.js"; import { createEvidence, type Evidence } from "../evidence.js"; @@ -511,9 +512,28 @@ describe("Apple bundle layouts and containers", () => { }); it("rejects inventories that cannot contain an Apple application", () => { - expect(() => project(inventoryEvidence("asar", "app.asar", []))).toThrow( - "Apple application projection requires IPA, directory, ZIP, or DMG inventory Evidence (got asar)", - ); + try { + project(inventoryEvidence("asar", "app.asar", [])); + throw new Error("Expected an unsupported inventory format to fail"); + } catch (cause: unknown) { + expect(cause).toBeInstanceOf(AnalysisInputError); + expect(cause).toMatchObject({ + operation: "project_apple_application_graph", + issues: [ + expect.objectContaining({ + path: [ + "inventory_evidence", + 0, + "normalized_result", + "manifest", + "root_format", + ], + message: + "Apple application projection requires IPA, directory, ZIP, or DMG inventory Evidence (got asar)", + }), + ], + }); + } }); }); diff --git a/src/domain/apple/appleApplication.ts b/src/domain/apple/appleApplication.ts index 20ccc0db51..6d6a930c7e 100644 --- a/src/domain/apple/appleApplication.ts +++ b/src/domain/apple/appleApplication.ts @@ -1,4 +1,5 @@ import { z } from "zod"; +import { AnalysisInputError } from "../analysisErrorCore.js"; import { anatomyNameIs, @@ -237,9 +238,19 @@ const attributeComponents = ( const appleRootFormat = (format: string): RootFormat => { const root = APPLE_ROOT_FORMATS.find((candidate) => candidate === format); if (root === undefined) - throw new TypeError( - `Apple application projection requires IPA, directory, ZIP, or DMG inventory Evidence (got ${format})`, - ); + throw new AnalysisInputError("project_apple_application_graph", {}, [ + { + path: [ + "inventory_evidence", + 0, + "normalized_result", + "manifest", + "root_format", + ], + reason: "invalid_value", + message: `Apple application projection requires IPA, directory, ZIP, or DMG inventory Evidence (got ${format})`, + }, + ]); return root; }; diff --git a/src/domain/artifactComparison.ts b/src/domain/artifactComparison.ts index cf06268460..73084df6e7 100644 --- a/src/domain/artifactComparison.ts +++ b/src/domain/artifactComparison.ts @@ -6,8 +6,10 @@ import type { ArtifactNode, ArtifactOccurrence, } from "./artifactGraph.js"; +import { AnalysisInputError } from "./analysisErrorCore.js"; import { evidenceSchema } from "./evidence.js"; import { + ArtifactInventoryCarrierError, hasCompleteInventoryCoverage, parseArtifactInventoryEvidence, type InventorySet, @@ -95,13 +97,26 @@ export type ArtifactComparisonResult = z.infer< >; type ArtifactChange = z.infer; +const comparisonInventory = (input: unknown, side: "left" | "right") => { + try { + return parseArtifactInventoryEvidence(input); + } catch (cause: unknown) { + if (!(cause instanceof ArtifactInventoryCarrierError)) throw cause; + throw new AnalysisInputError( + "compare_artifacts", + { cause, userMessage: cause.message }, + [{ path: [side], reason: "invalid_value", message: cause.message }], + ); + } +}; + /** Compare complete inventory evidence without treating missing evidence as equality. */ export const compareArtifacts = ( leftEvidence: unknown, rightEvidence: unknown, ): ArtifactComparisonResult => { - const left = parseArtifactInventoryEvidence(leftEvidence); - const right = parseArtifactInventoryEvidence(rightEvidence); + const left = comparisonInventory(leftEvidence, "left"); + const right = comparisonInventory(rightEvidence, "right"); const leftComplete = left.inventory.complete; const rightComplete = right.inventory.complete; const sameScope = diff --git a/src/domain/artifactInventoryEvidence.test.ts b/src/domain/artifactInventoryEvidence.test.ts index 227c6dca81..9ab18f2d51 100644 --- a/src/domain/artifactInventoryEvidence.test.ts +++ b/src/domain/artifactInventoryEvidence.test.ts @@ -1,3 +1,6 @@ +import { artifactInventoryResultSchema } from "./artifactGraph.js"; +import { artifactManifestId } from "./artifactIdentity.js"; +import { EvidenceIntegrityError } from "./evidenceErrors.js"; import { freezeJsonSnapshot } from "./immutableJson.js"; import { expect, it } from "vitest"; import { z } from "zod"; @@ -5,6 +8,8 @@ import { z } from "zod"; import { ARTIFACT_COMPARISON_EXAMPLE } from "../contracts/artifactComparisonExample.js"; import { createArtifactInspection } from "./artifactInspection.js"; import { + ArtifactInventoryValidationError, + ArtifactInventoryCarrierError, parseArtifactInventoryEvidence, parseArtifactInspectionInventoryResult, } from "./artifactInventoryEvidence.js"; @@ -45,9 +50,17 @@ it("rejects an inspection carrier whose Evidence subject disagrees with its nest ...target, digest: { sha256: "a".repeat(64) }, }); - expect(() => parseArtifactInventoryEvidence(carrier)).toThrow( - "Artifact inspection Evidence subject does not match its root manifest", - ); + try { + parseArtifactInventoryEvidence(carrier); + throw new Error("Expected the mismatched subject to be rejected"); + } catch (cause: unknown) { + expect(cause).toBeInstanceOf(EvidenceIntegrityError); + expect(cause).toMatchObject({ + message: + "Artifact inspection Evidence subject does not match its root manifest", + cause: expect.any(ArtifactInventoryValidationError), + }); + } }); it("rejects an inspection declaration whose root manifest disagrees with its nested inventory", () => { @@ -112,3 +125,57 @@ it.each([0, 1])( ); }, ); + +it("keeps a commitment failure discovered across individually incomplete pages as Evidence integrity", () => { + const complete = artifactInventoryResultSchema.parse( + inventoryEvidence.normalized_result, + ); + const graphDigest = "f".repeat(64); + const manifest = { + ...complete.manifest, + graph_sha256: graphDigest, + manifest_id: artifactManifestId( + complete.manifest.root_artifact_id, + graphDigest, + ), + }; + const page = (includeNodes: boolean) => + createEvidence( + { + path: target.local_path, + sha256: target.digest.sha256, + format: target.format, + }, + inventoryEvidence.provider, + { + operation: "inventory_artifact", + parameters: {}, + confidence: "observed", + authority: "shipped-artifact", + result: jsonValueSchema.parse({ + ...complete, + manifest, + nodes: includeNodes ? complete.nodes : [], + occurrences: includeNodes ? [] : complete.occurrences, + }), + }, + ); + const nodes = page(true); + const occurrences = page(false); + expect(parseArtifactInventoryEvidence(nodes).inventory.complete).toBe(false); + expect(parseArtifactInventoryEvidence(occurrences).inventory.complete).toBe( + false, + ); + expect(() => parseArtifactInventoryEvidence([nodes, occurrences])).toThrow( + EvidenceIntegrityError, + ); +}); + +it("reports incompatible independently valid manifests as caller composition", () => { + expect(() => + parseArtifactInventoryEvidence([ + ARTIFACT_COMPARISON_EXAMPLE.left, + ARTIFACT_COMPARISON_EXAMPLE.right, + ]), + ).toThrow(ArtifactInventoryCarrierError); +}); diff --git a/src/domain/artifactInventoryEvidence.ts b/src/domain/artifactInventoryEvidence.ts index b2e7899eee..2c1e963ed8 100644 --- a/src/domain/artifactInventoryEvidence.ts +++ b/src/domain/artifactInventoryEvidence.ts @@ -24,6 +24,8 @@ import { } from "./artifactIdentity.js"; import { parseEvidence, type Evidence } from "./evidence.js"; import { z } from "zod"; +import { EvidenceIntegrityError } from "./evidenceErrors.js"; +import { readEvidence } from "./evidenceValidation.js"; import { artifactInspectionResultSchema, type ArtifactInspectionResult, @@ -56,6 +58,29 @@ export interface ParsedInventorySet { readonly inventory: InventorySet; } +/** Expected failure while validating inventory facts without an Evidence carrier. */ +export class ArtifactInventoryValidationError extends TypeError { + constructor(message: string, options?: ErrorOptions) { + super(message, options); + this.name = "ArtifactInventoryValidationError"; + } +} + +/** Individually valid pages disagree about the selected inventory. */ +class ArtifactInventoryCompositionError extends ArtifactInventoryValidationError {} + +/** Caller selected a carrier or page composition that cannot form an inventory. */ +export class ArtifactInventoryCarrierError extends Error { + constructor( + message: string, + readonly index: number | undefined = undefined, + options?: ErrorOptions, + ) { + super(message, options); + this.name = "ArtifactInventoryCarrierError"; + } +} + interface ParsedArtifactInspectionInventory { readonly inspection: ArtifactInspectionResult; readonly inventory: InventorySet; @@ -66,23 +91,64 @@ interface ParsedArtifactInspectionInventory { export const parseArtifactInventoryEvidence = ( input: unknown, ): ParsedInventorySet => { - const evidence = (Array.isArray(input) ? input : [input]).map(parseEvidence); - const pages = evidence.flatMap((carrier) => { - if (carrier.operation === "inventory_artifact") - return [parseInventoryEvidencePage(carrier)]; - if (carrier.operation !== "inspect_artifact") - throw new TypeError( - "Artifact comparison requires inspect_artifact Evidence", - ); - return parseInspectionInventoryPayload( - carrier.normalized_result, - carrier.subject, - ).pages; + const carriers = Array.isArray(input) ? input : [input]; + if (carriers.length === 0) + throw new ArtifactInventoryCarrierError( + "Artifact inventory requires at least one Evidence carrier", + ); + const evidence = carriers.map((carrier) => { + const parsed = readEvidence(carrier); + if (!parsed.ok) throw parsed.error; + return parsed.value; }); - return { - evidence, - inventory: assembleInventoryResults(pages), - }; + try { + const pageGroups = evidence.map((carrier, index) => { + if (carrier.operation === "inventory_artifact") + return [parseInventoryEvidencePage(carrier)]; + if (carrier.operation !== "inspect_artifact") + throw new ArtifactInventoryCarrierError( + "Artifact inventory requires inventory_artifact or inspect_artifact Evidence", + index, + ); + return parseInspectionEvidencePayload( + carrier.normalized_result, + carrier.subject, + ); + }); + for (const pages of pageGroups) + if (pages.length > 1) assembleInventoryResults(pages); + try { + return { + evidence, + inventory: assembleInventoryResults(pageGroups.flat()), + }; + } catch (cause: unknown) { + if (!(cause instanceof ArtifactInventoryCompositionError)) throw cause; + if (pageGroups.length === 1) throw cause; + throw new ArtifactInventoryCarrierError(cause.message, undefined, { + cause, + }); + } + } catch (cause: unknown) { + if (cause instanceof ArtifactInventoryCarrierError) throw cause; + if (!(cause instanceof ArtifactInventoryValidationError)) throw cause; + throw new EvidenceIntegrityError(cause.message, { + cause, + userMessage: cause.message, + }); + } +}; + +const parseInspectionEvidencePayload = ( + input: unknown, + subject: Evidence["subject"], +): readonly ArtifactInventoryResult[] => { + try { + return parseInspectionInventoryPayload(input, subject).pages; + } catch (cause: unknown) { + if (!(cause instanceof z.ZodError)) throw cause; + throw new ArtifactInventoryValidationError(cause.message, { cause }); + } }; /** Parse and authenticate an already-owned inspection payload and its pages. */ @@ -115,7 +181,7 @@ const parseInspectionInventoryPayload = ( : parsedInspection; const sealedSubsteps = immutableJsonSnapshotMember(inspection, "substeps"); if (inspection.coverage.substeps_completed !== inspection.substeps.length) - throw new TypeError( + throw new ArtifactInventoryValidationError( "Artifact inspection substep count does not match its coverage declaration", ); const substeps = inspection.substeps.map((step, index) => { @@ -145,7 +211,7 @@ const parseInspectionInventoryPayload = ( : parsedEvidence; immutableJsonSnapshotMember(evidence, "normalized_result"); if (step.evidence_id !== evidence.evidence_id) - throw new TypeError( + throw new ArtifactInventoryValidationError( "Artifact inspection substep evidence ID does not match nested Evidence", ); return { @@ -155,7 +221,9 @@ const parseInspectionInventoryPayload = ( }); const firstPage = substeps[0]?.page; if (firstPage === undefined) - throw new TypeError("Artifact inspection requires inventory substeps"); + throw new ArtifactInventoryValidationError( + "Artifact inspection requires inventory substeps", + ); validateInspectionSubject(subject, inspection, firstPage.manifest); return { inspection, @@ -176,7 +244,7 @@ const validateInspectionSubject = ( inspection.subject.root_sha256 !== manifest.root_sha256 || inspection.subject.root_format !== manifest.root_format ) - throw new TypeError( + throw new ArtifactInventoryValidationError( "Artifact inspection subject does not match its inventory manifest", ); if ( @@ -184,7 +252,7 @@ const validateInspectionSubject = ( subject.digest.sha256 !== manifest.root_sha256 || subject.format !== manifest.root_format ) - throw new TypeError( + throw new ArtifactInventoryValidationError( "Artifact inspection Evidence subject does not match its root manifest", ); }; @@ -217,10 +285,15 @@ const parseInventoryEvidencePage = ( evidence: Evidence, ): ArtifactInventoryResult => { if (evidence.operation !== "inventory_artifact") - throw new TypeError( + throw new ArtifactInventoryValidationError( "Artifact inventory page must be inventory_artifact Evidence", ); - return parseInventoryPage(evidence.normalized_result, evidence.subject); + try { + return parseInventoryPage(evidence.normalized_result, evidence.subject); + } catch (cause: unknown) { + if (!(cause instanceof z.ZodError)) throw cause; + throw new ArtifactInventoryValidationError(cause.message, { cause }); + } }; /** Both inventory carriers require the same authenticated root identity. */ @@ -233,7 +306,7 @@ export const validateArtifactInventorySubject = ( subject.digest.sha256 !== manifest.root_sha256 || subject.format !== manifest.root_format ) - throw new TypeError( + throw new ArtifactInventoryValidationError( "Artifact inventory Evidence subject does not match its root digest", ); }; @@ -253,7 +326,9 @@ const validateInventoryPage = (inventory: ArtifactInventoryResult): void => { ); for (const node of inventory.nodes) if (node.artifact_id !== artifactIdForContent(node.sha256)) - throw new TypeError("Artifact node ID is not content-addressed"); + throw new ArtifactInventoryValidationError( + "Artifact node ID is not content-addressed", + ); }; const assembleInventoryResults = ( @@ -261,7 +336,9 @@ const assembleInventoryResults = ( ): InventorySet => { const first = pages[0]; if (first === undefined) - throw new TypeError("Artifact inventory requires Evidence pages"); + throw new ArtifactInventoryValidationError( + "Artifact inventory requires Evidence pages", + ); const manifestJson = canonicalJson(first.manifest, "Artifact inventory"); const coverageJson = canonicalJson( first.coverage, @@ -272,19 +349,21 @@ const assembleInventoryResults = ( const edges = new Map(); for (const page of pages) { if (canonicalJson(page.manifest, "Artifact inventory") !== manifestJson) - throw new TypeError("Artifact inventory pages do not share one manifest"); + throw new ArtifactInventoryCompositionError( + "Artifact inventory pages do not share one manifest", + ); if ( canonicalJson(page.coverage, "Artifact inventory coverage") !== coverageJson ) - throw new TypeError( + throw new ArtifactInventoryCompositionError( "Artifact inventory pages do not share one coverage declaration", ); if ( canonicalJson(page.integrity_contradictions, "Artifact inventory") !== canonicalJson(first.integrity_contradictions, "Artifact inventory") ) - throw new TypeError( + throw new ArtifactInventoryCompositionError( "Artifact inventory pages do not share integrity contradictions", ); for (const node of page.nodes) @@ -396,7 +475,7 @@ const validateCompleteInventory = (inventory: InventorySet): void => { ({ artifact_id: id }) => id === inventory.manifest.root_artifact_id, ); if (rootNode?.sha256 !== inventory.manifest.root_sha256) - throw new TypeError( + throw new ArtifactInventoryValidationError( "Artifact root node does not match its manifest digest", ); const rootOccurrence = inventory.occurrences.find( @@ -406,7 +485,7 @@ const validateCompleteInventory = (inventory: InventorySet): void => { rootOccurrence?.artifact_id !== inventory.manifest.root_artifact_id || rootOccurrence.artifact_format !== inventory.manifest.root_format ) - throw new TypeError( + throw new ArtifactInventoryValidationError( "Artifact root occurrence does not match its manifest representation", ); const duplicateOrdinalByPath = new Map(); @@ -428,7 +507,7 @@ const validateCompleteInventory = (inventory: InventorySet): void => { !nodeIds.has(contradiction.parent_artifact_id) || !occurrenceIds.has(contradiction.occurrence_id) ) - throw new TypeError( + throw new ArtifactInventoryValidationError( "Integrity contradiction references a missing graph member", ); const expectedId = artifactContradictionId({ @@ -437,7 +516,7 @@ const validateCompleteInventory = (inventory: InventorySet): void => { integrity: contradiction, }); if (contradiction.contradiction_id !== expectedId) - throw new TypeError( + throw new ArtifactInventoryValidationError( "Integrity contradiction ID does not match its identity", ); } @@ -448,13 +527,17 @@ const validateCompleteInventory = (inventory: InventorySet): void => { contradictions: inventory.integrityContradictions, }); if (graphSha256 !== inventory.manifest.graph_sha256) - throw new TypeError("Artifact graph commitment does not match its members"); + throw new ArtifactInventoryValidationError( + "Artifact graph commitment does not match its members", + ); const manifestId = artifactManifestId( inventory.manifest.root_artifact_id, graphSha256, ); if (manifestId !== inventory.manifest.manifest_id) - throw new TypeError("Artifact manifest ID does not match its commitment"); + throw new ArtifactInventoryValidationError( + "Artifact manifest ID does not match its commitment", + ); }; const validateOccurrence = ( @@ -465,12 +548,16 @@ const validateOccurrence = ( duplicateOrdinal: number, ): void => { if (occurrence.artifact_id !== null && !nodeIds.has(occurrence.artifact_id)) - throw new TypeError("Artifact occurrence references a missing node"); + throw new ArtifactInventoryValidationError( + "Artifact occurrence references a missing node", + ); if ( occurrence.parent_occurrence_id !== null && !occurrenceIds.has(occurrence.parent_occurrence_id) ) - throw new TypeError("Artifact occurrence references a missing parent"); + throw new ArtifactInventoryValidationError( + "Artifact occurrence references a missing parent", + ); const expectedId = occurrenceIdForLocation({ rootArtifactId: inventory.manifest.root_artifact_id, logicalPath: occurrence.logical_path, @@ -478,7 +565,9 @@ const validateOccurrence = ( ...(duplicateOrdinal === 0 ? {} : { duplicateOrdinal }), }); if (occurrence.occurrence_id !== expectedId) - throw new TypeError("Artifact occurrence ID does not match its identity"); + throw new ArtifactInventoryValidationError( + "Artifact occurrence ID does not match its identity", + ); }; const validateEdge = ( @@ -493,9 +582,13 @@ const validateEdge = ( occurrence === undefined || !nodeIds.has(occurrence.artifact_id) ) - throw new TypeError("Artifact edge references a missing graph member"); + throw new ArtifactInventoryValidationError( + "Artifact edge references a missing graph member", + ); if (edge.edge_id !== artifactEdgeId(edge, occurrence)) - throw new TypeError("Artifact edge ID does not match its identity"); + throw new ArtifactInventoryValidationError( + "Artifact edge ID does not match its identity", + ); }; const mergeExact = ( @@ -510,11 +603,15 @@ const mergeExact = ( canonicalJson(previous, "Artifact inventory") !== canonicalJson(value, "Artifact inventory") ) - throw new TypeError(`Artifact inventory has conflicting ${label} pages`); + throw new ArtifactInventoryCompositionError( + `Artifact inventory has conflicting ${label} pages`, + ); output.set(key, value); }; const assertUnique = (values: readonly string[], label: string): void => { if (new Set(values).size !== values.length) - throw new TypeError(`Artifact inventory contains a duplicate ${label}`); + throw new ArtifactInventoryValidationError( + `Artifact inventory contains a duplicate ${label}`, + ); }; diff --git a/src/domain/callPath.test.ts b/src/domain/callPath.test.ts index 75ba7a92fb..11bada4a9e 100644 --- a/src/domain/callPath.test.ts +++ b/src/domain/callPath.test.ts @@ -1,5 +1,6 @@ import { describe, expect, it } from "vitest"; +import { AnalysisInputError } from "./analysisErrorCore.js"; import { buildCallPath } from "./callPath.js"; import { combineFunctionRelationshipCoverage } from "./functionCallCoverage.js"; import { callPathResultSchema, type CallPathInput } from "./callPathSchemas.js"; @@ -669,9 +670,9 @@ describe("call path evidence admission", () => { run([observe("0x1000", [])], { start: { address: "1000" } }), ).toThrow(); const one = observe("0x1000", []); - expect(() => run([one, one])).toThrow(/Duplicate/u); + expect(() => run([one, one])).toThrow(AnalysisInputError); expect(() => run([one, observe("0x2000", [], "other")])).toThrow( - /providers/u, + AnalysisInputError, ); }); @@ -696,9 +697,7 @@ describe("call path evidence admission", () => { }); it("requires an observed start function", () => { - expect(() => run([observe("0x2000", [])])).toThrow( - /supplied for start 0x1000/u, - ); + expect(() => run([observe("0x2000", [])])).toThrow(AnalysisInputError); }); }); diff --git a/src/domain/callPath.ts b/src/domain/callPath.ts index 8c70707e64..778d1964a9 100644 --- a/src/domain/callPath.ts +++ b/src/domain/callPath.ts @@ -1,12 +1,15 @@ +import { z } from "zod"; import { relationshipCoverageLimitations } from "./relationshipCoverage.js"; import { callPathInputSchema, callPathResultSchema, - type CallPathInput, type CallPathResult, type OutputCallPath, } from "./callPathSchemas.js"; -import { parseNativeAddress } from "./native/nativeAddress.js"; +import { AnalysisInputError } from "./analysisErrorCore.js"; +import { analysisInputErrorFromIssues } from "./inputIssueProjection.js"; +import { readEvidencePayload } from "./evidenceValidation.js"; +import { nativeAddressInputSchema } from "./native/nativeAddress.js"; import { parseFunctionEvidence, type FunctionSnapshot, @@ -78,9 +81,21 @@ interface SearchState { readonly limitations: readonly string[]; } +const callPathEnvelopeSchema = callPathInputSchema.extend({ + functions: z.array(z.unknown()).min(1), +}); + /** Reconstruct direct-callee paths from explicit analyze_function Evidence. */ -export const buildCallPath = (input: CallPathInput): CallPathResult => { - const parsed = callPathInputSchema.parse(input); +export const buildCallPath = (input: unknown): CallPathResult => { + const selected = callPathEnvelopeSchema.safeParse(input); + if (!selected.success) + throw analysisInputErrorFromIssues( + "build_call_path", + selected.error.issues, + input, + { cause: selected.error }, + ); + const parsed = selected.data; const { snapshots, graph } = prepareCallGraph(parsed); const search = inspectSearch({ graph, @@ -128,30 +143,55 @@ export const buildCallPath = (input: CallPathInput): CallPathResult => { }); }; -const prepareCallGraph = (input: CallPathInput) => { - const snapshots = parseSnapshots(input.functions); +const prepareCallGraph = (input: z.infer) => { + const { snapshots, graph } = parseSnapshots(input.functions); assertCompatible(snapshots); if (!snapshots.has(input.start.address)) - throw new TypeError( + throw invalidCallPathSelection( + ["start", "address"], `No analyze_function Evidence was supplied for start ${input.start.address}`, ); - return { snapshots, graph: createGraph(snapshots) }; + return { snapshots, graph }; }; const parseSnapshots = ( - groups: CallPathInput["functions"], -): Map => { + groups: readonly unknown[], +): { + readonly snapshots: Map; + readonly graph: CallGraph; +} => { const snapshots = new Map(); - for (const group of groups) { - const snapshot = parseFunctionEvidence(group); - const address = parseNativeAddress(snapshot.dossier.procedure.address); - for (const callee of snapshot.dossier.callees) - parseNativeAddress(callee.address); + const graph = new CallGraph(); + for (const [index, group] of groups.entries()) { + const parsed = parseFunctionEvidence(group, { + operation: "build_call_path", + path: ["functions", index], + }); + if (!parsed.ok) throw parsed.error; + const snapshot = parsed.value; + const parsedAddress = readEvidencePayload( + nativeAddressInputSchema, + snapshot.dossier.procedure.address, + ); + if (!parsedAddress.ok) throw parsedAddress.error; + const address = parsedAddress.value; if (snapshots.has(address)) - throw new TypeError(`Duplicate function Evidence for ${address}`); + throw invalidCallPathSelection( + ["functions", index], + `Duplicate function Evidence for ${address}`, + ); + graph.mergeNode(address); + for (const callee of snapshot.dossier.callees) { + const parsedCallee = readEvidencePayload( + nativeAddressInputSchema, + callee.address, + ); + if (!parsedCallee.ok) throw parsedCallee.error; + graph.mergeDirectedEdge(address, parsedCallee.value); + } snapshots.set(address, snapshot); } - return snapshots; + return { snapshots, graph }; }; const assertCompatible = ( @@ -166,30 +206,29 @@ const assertCompatible = ( snapshot.subject.format !== first.subject.format || snapshot.subject.architecture !== first.subject.architecture ) - throw new TypeError("Call-path Evidence mixes artifact subjects"); + throw invalidCallPathSelection( + ["functions"], + "Call-path Evidence mixes artifact subjects", + ); if ( snapshot.evidence.provider.id !== first.evidence.provider.id || snapshot.evidence.provider.name !== first.evidence.provider.name || snapshot.evidence.provider.version !== first.evidence.provider.version ) - throw new TypeError("Call-path Evidence mixes providers"); + throw invalidCallPathSelection( + ["functions"], + "Call-path Evidence mixes providers", + ); } }; -const createGraph = ( - snapshots: ReadonlyMap, -): CallGraph => { - const graph = new CallGraph(); - for (const [address, snapshot] of snapshots) { - graph.mergeNode(address); - for (const callee of snapshot.dossier.callees) { - const calleeAddress = parseNativeAddress(callee.address); - graph.mergeNode(calleeAddress); - graph.mergeDirectedEdge(address, calleeAddress); - } - } - return graph; -}; +const invalidCallPathSelection = ( + path: readonly (string | number)[], + message: string, +): AnalysisInputError => + new AnalysisInputError("build_call_path", { userMessage: message }, [ + { path, reason: "invalid_value", message }, + ]); interface SearchInput { readonly graph: CallGraph; @@ -204,16 +243,6 @@ const inspectSearch = ({ start, goal, }: SearchInput): SearchState => { - if (!graph.hasNode(start)) - return { - graph, - snapshots, - explored: { nodes: 0, edges: 0, depth_reached: 0 }, - exhaustive: false, - limitations: [ - `No analyze_function Evidence was supplied for start ${start}`, - ], - }; const reached = new Map([[start, 0]]); const queue = [start]; const limitations: string[] = []; diff --git a/src/domain/changedBehavior.test.ts b/src/domain/changedBehavior.test.ts index 6d99e828f9..adee822a15 100644 --- a/src/domain/changedBehavior.test.ts +++ b/src/domain/changedBehavior.test.ts @@ -1,5 +1,4 @@ import { describe, expect, it } from "vitest"; - import { changedBehaviorResultSchema, findChangedBehavior, @@ -236,12 +235,32 @@ describe("changed behavior", () => { ]), ); }); +}); - it("rejects bundles, duplicate comparisons, and malformed exact results", () => { - const valid = comparison("compare_process_captures", processResult()); - expect(() => aggregate([valid, valid])).toThrow( - /duplicate comparison Evidence/u, +describe("changed-behavior input and Evidence validation", () => { + it("rejects an empty comparison selection", () => { + expect(() => findChangedBehavior({ comparisons: [] })).toThrowError( + expect.objectContaining({ + _tag: "AnalysisInputError", + operation: "find_changed_behavior", + userMessage: "Provide at least one comparison Evidence item.", + issues: [expect.objectContaining({ path: ["comparisons"] })], + }), ); + }); + + it("rejects duplicate comparison selection with the repeated item path", () => { + const valid = comparison("compare_process_captures", processResult()); + expect(() => aggregate([valid, valid])).toThrowError( + expect.objectContaining({ + _tag: "AnalysisInputError", + operation: "find_changed_behavior", + issues: [expect.objectContaining({ path: ["comparisons", 1] })], + }), + ); + }); + + it("rejects a comparison operation unsupported by this workflow", () => { const bundle = createEvidence( undefined, { id: "rea-bundle-comparison", name: "Bundle", version: "1" }, @@ -255,26 +274,60 @@ describe("changed behavior", () => { evidenceLinks: [left.evidence_id, right.evidence_id], }, ); - expect(() => aggregate([bundle])).toThrow( - /requires process, artifact, or function/u, + expect(() => aggregate([bundle])).toThrowError( + expect.objectContaining({ + _tag: "AnalysisInputError", + userMessage: expect.stringContaining( + "requires process, artifact, or function", + ), + issues: [expect.objectContaining({ path: ["comparisons", 0] })], + }), ); + }); + + it("classifies a malformed Evidence envelope as an integrity failure", () => { + const valid = comparison("compare_process_captures", processResult()); + const malformedEvidence = { ...valid, evidence_id: "ev_invalid" }; + expect(() => aggregate([malformedEvidence])).toThrowError( + expect.objectContaining({ + _tag: "EvidenceIntegrityError", + cause: expect.any(Error), + }), + ); + }); + + it("classifies an invalid authenticated result payload as an integrity failure", () => { const malformed = comparison("compare_process_captures", { status: "unchanged", }); - expect(() => aggregate([malformed])).toThrow(); + expect(() => aggregate([malformed])).toThrowError( + expect.objectContaining({ + _tag: "EvidenceIntegrityError", + cause: expect.any(Error), + }), + ); + }); + + it("rejects contradictory dimensions in authenticated comparison results", () => { const contradictory = comparison( "compare_process_captures", processResult({ status: "unchanged", terminal: "changed" }), ); - expect(() => aggregate([contradictory])).toThrow( - /contradicts its dimensions/u, + expect(() => aggregate([contradictory])).toThrowError( + expect.objectContaining({ _tag: "EvidenceIntegrityError" }), ); + }); + + it("rejects nested citations outside the top-level Evidence closure", () => { const danglingNested = comparison( "compare_artifacts", artifactResult([`ev_${"f".repeat(64)}`, right.evidence_id]), ); - expect(() => aggregate([danglingNested])).toThrow( - /outside its top-level closure/u, + expect(() => aggregate([danglingNested])).toThrowError( + expect.toSatisfy( + (error: { _tag?: unknown; cause?: unknown }) => + error._tag === "EvidenceIntegrityError" && error.cause === undefined, + ), ); }); }); diff --git a/src/domain/changedBehavior.ts b/src/domain/changedBehavior.ts index 700f202b62..57aae499c6 100644 --- a/src/domain/changedBehavior.ts +++ b/src/domain/changedBehavior.ts @@ -10,7 +10,11 @@ import { assertComparisonFindingLinks, comparisonSourceEvidenceIds, } from "./comparisonSourceEvidence.js"; -import { evidenceSchema, parseEvidence, type Evidence } from "./evidence.js"; +import { evidenceSchema, type Evidence } from "./evidence.js"; +import { AnalysisInputError } from "./analysisErrorCore.js"; +import { EvidenceIntegrityError } from "./evidenceErrors.js"; +import { analysisInputErrorFromIssues } from "./inputIssueProjection.js"; +import { readEvidence, readEvidencePayload } from "./evidenceValidation.js"; import { hasComparisonEvidenceIdentity, isComparisonEvidenceOperation, @@ -118,16 +122,30 @@ interface FoundChangedBehavior { export const findChangedBehavior = (input: unknown): FoundChangedBehavior => { // The public input schema remains available to clients; this internal - // admission only checks the list shape because parseEvidence below owns + // admission only checks the list shape because readEvidence below owns // validation and authentication of each retained Evidence item. - const { comparisons } = z + const envelope = z .strictObject({ comparisons: z.array(z.unknown()).min(1) }) - .parse(input); - const evidence = comparisons.map(parseComparisonEvidence); - assertUnique( - evidence.map(({ evidence: { evidence_id: id } }) => id), - "comparison Evidence", + .safeParse(input); + if (!envelope.success) + throw inputSchemaError( + input, + envelope.error, + "Provide at least one comparison Evidence item.", + ); + const { comparisons } = envelope.data; + const evidence = comparisons.map((item, index) => + parseComparisonEvidence(item, input, index), ); + const duplicateIndex = firstDuplicateIndex( + evidence.map(({ evidence: { evidence_id: id } }) => id), + ); + if (duplicateIndex !== undefined) + throw callerInputError( + input, + ["comparisons", duplicateIndex], + "Changed-behavior analysis rejects duplicate comparison Evidence", + ); const findings = evidence.flatMap(findingsFor).sort(compareFindings); const runtimeStatuses = evidence.flatMap((comparison) => comparison.operation === "compare_process_captures" @@ -174,15 +192,23 @@ export const findChangedBehavior = (input: unknown): FoundChangedBehavior => { }; }; -const parseComparisonEvidence = (input: unknown): ParsedComparison => { - const evidence = parseEvidence(input); +const parseComparisonEvidence = ( + input: unknown, + fullInput: unknown, + index: number, +): ParsedComparison => { + const parsedEvidence = readEvidence(input); + if (!parsedEvidence.ok) throw parsedEvidence.error; + const evidence = parsedEvidence.value; if (!isComparisonEvidenceOperation(evidence.operation)) - throw new TypeError( + throw callerInputError( + fullInput, + ["comparisons", index], "Changed-behavior analysis requires process, artifact, or function comparison Evidence", ); const operation = evidence.operation; if (!hasComparisonEvidenceIdentity(evidence)) - throw new TypeError( + throw evidenceIntegrityError( "Comparison Evidence operation, predicate, and provider disagree", ); if ( @@ -190,7 +216,7 @@ const parseComparisonEvidence = (input: unknown): ParsedComparison => { evidence.evidence_links.includes(id), ) ) - throw new TypeError( + throw evidenceIntegrityError( "Comparison Evidence must cite both selected source observations", ); return parseResult(evidence, operation); @@ -204,29 +230,35 @@ const parseResult = ( | "compare_functions", ): ParsedComparison => { if (operation === "compare_process_captures") { - const result = processCaptureComparisonSchema.parse( + const result = readEvidencePayload( + processCaptureComparisonSchema, evidence.normalized_result, ); - return { operation, evidence, result }; + if (!result.ok) throw result.error; + return { operation, evidence, result: result.value }; } if (operation === "compare_functions") { - const result = functionComparisonResultSchema.parse( + const result = readEvidencePayload( + functionComparisonResultSchema, evidence.normalized_result, ); + if (!result.ok) throw result.error; assertComparisonFindingLinks( evidence, - result.dimensions.flatMap(({ evidence_links: links }) => links), + result.value.dimensions.flatMap(({ evidence_links: links }) => links), ); - return { operation, evidence, result }; + return { operation, evidence, result: result.value }; } - const result = artifactComparisonResultSchema.parse( + const result = readEvidencePayload( + artifactComparisonResultSchema, evidence.normalized_result, ); + if (!result.ok) throw result.error; assertComparisonFindingLinks( evidence, - result.changes.flatMap(({ evidence_links: links }) => links), + result.value.changes.flatMap(({ evidence_links: links }) => links), ); - return { operation, evidence, result }; + return { operation, evidence, result: result.value }; }; const findingsFor = (comparison: ParsedComparison): Finding[] => { @@ -384,7 +416,50 @@ const compareFindings = (left: Finding, right: Finding): number => compositeKey([right.scope, right.dimension, right.source_comparison_id]), ); -const assertUnique = (values: readonly string[], label: string): void => { - if (new Set(values).size !== values.length) - throw new TypeError(`Changed-behavior analysis rejects duplicate ${label}`); +const firstDuplicateIndex = (values: readonly string[]): number | undefined => { + const seen = new Set(); + const index = values.findIndex((value) => { + if (seen.has(value)) return true; + seen.add(value); + return false; + }); + return index < 0 ? undefined : index; }; + +const OPERATION = "find_changed_behavior"; + +const inputSchemaError = ( + input: unknown, + cause: z.ZodError, + userMessage: string, +): AnalysisInputError => { + const projected = analysisInputErrorFromIssues( + OPERATION, + cause.issues, + input, + { cause }, + ); + return new AnalysisInputError( + OPERATION, + { cause, userMessage }, + projected.issues, + ); +}; + +const callerInputError = ( + input: unknown, + path: readonly (string | number)[], + message: string, +): AnalysisInputError => { + const projected = analysisInputErrorFromIssues(OPERATION, [], input, { + additionalIssues: [{ path, reason: "invalid_value", message }], + }); + return new AnalysisInputError( + OPERATION, + { userMessage: message }, + projected.issues, + ); +}; + +const evidenceIntegrityError = (message: string): EvidenceIntegrityError => + new EvidenceIntegrityError(message, { userMessage: message }); diff --git a/src/domain/comparisonSourceEvidence.ts b/src/domain/comparisonSourceEvidence.ts index 096e765488..9b74752704 100644 --- a/src/domain/comparisonSourceEvidence.ts +++ b/src/domain/comparisonSourceEvidence.ts @@ -2,6 +2,8 @@ import { z } from "zod"; import { uniqueSorted } from "./canonicalOrdering.js"; import { prefixedDigestSchema } from "./digests.js"; +import { EvidenceIntegrityError } from "./evidenceErrors.js"; +import { readEvidencePayload } from "./evidenceValidation.js"; import type { Evidence } from "./evidence.js"; const evidenceIdSchema = prefixedDigestSchema("ev"); @@ -16,14 +18,16 @@ const sideParametersSchema = z.object({ export const comparisonSourceEvidenceSides = ( evidence: Evidence, ): { left: string[]; right: string[] } => { - const parameters = sideParametersSchema.parse(evidence.parameters); + const parsed = readEvidencePayload(sideParametersSchema, evidence.parameters); + if (!parsed.ok) throw parsed.error; + const parameters = parsed.value; if ( (parameters.left_evidence_id === undefined) !== (parameters.right_evidence_id === undefined) || (parameters.left_evidence_ids === undefined) !== (parameters.right_evidence_ids === undefined) ) - throw new TypeError( + throw new EvidenceIntegrityError( "Comparison Evidence source representation omitted one side", ); const side = (single: string | undefined, multiple: string[] | undefined) => { @@ -32,14 +36,16 @@ export const comparisonSourceEvidenceSides = ( multiple !== undefined && (multiple.length !== 1 || multiple[0] !== single) ) - throw new TypeError( + throw new EvidenceIntegrityError( "Comparison Evidence source representations disagree", ); const selected = single === undefined ? (multiple ?? []) : [single]; if (selected.length === 0) - throw new TypeError("Comparison Evidence omitted its source parameters"); + throw new EvidenceIntegrityError( + "Comparison Evidence omitted its source parameters", + ); if (new Set(selected).size !== selected.length) - throw new TypeError( + throw new EvidenceIntegrityError( "Comparison Evidence repeats a source within one side", ); return selected; @@ -63,7 +69,7 @@ export const assertComparisonFindingLinks = ( ): void => { const closure = new Set(evidence.evidence_links); if (nestedLinks.some((id) => !closure.has(id))) - throw new TypeError( + throw new EvidenceIntegrityError( "Comparison finding cites Evidence outside its top-level closure", ); }; diff --git a/src/domain/evidenceBundle.ts b/src/domain/evidenceBundle.ts index ba0a47f758..6f36d61bf6 100644 --- a/src/domain/evidenceBundle.ts +++ b/src/domain/evidenceBundle.ts @@ -1,3 +1,4 @@ +import { describeValidationFailure } from "./validationFailure.js"; import { equalJsonText } from "./jsonSerialization.js"; import { z } from "zod"; import canonicalize from "canonicalize"; @@ -264,18 +265,6 @@ export const describeEvidenceBundleFailure = ( } }; -/** Locate the first schema issue, or keep a thrown constraint message. */ -export const describeValidationFailure = (cause: unknown): string => { - if (!(cause instanceof z.ZodError)) - return cause instanceof Error ? cause.message : String(cause); - const [first, ...rest] = cause.issues; - const location = - first === undefined || first.path.length === 0 - ? "the document root" - : first.path.map(String).join("."); - return `at ${location}: ${first?.message ?? "invalid value"}${rest.length === 0 ? "" : ` (${String(rest.length)} more issues)`}`; -}; - /** Parse records, verify semantic IDs, and reject inconsistent manifests. */ export const parseEvidenceBundle = (input: unknown): EvidenceBundle => { return parseBundle(input, false); diff --git a/src/domain/evidenceValidation.ts b/src/domain/evidenceValidation.ts new file mode 100644 index 0000000000..52be1fe6bd --- /dev/null +++ b/src/domain/evidenceValidation.ts @@ -0,0 +1,32 @@ +import { z } from "zod"; + +import { parseEvidence, type Evidence } from "./evidence.js"; +import { EvidenceIntegrityError } from "./evidenceErrors.js"; +import { describeValidationFailure } from "./validationFailure.js"; +import { err, ok, type Result } from "./result.js"; + +/** Authenticate one source record, preserving unexpected implementation failures. */ +export const readEvidence = ( + input: unknown, +): Result => { + try { + return ok(parseEvidence(input)); + } catch (cause: unknown) { + if (!(cause instanceof z.ZodError)) throw cause; + return err(integrityFailure(cause)); + } +}; + +/** Validate an authenticated source payload, separately from generated output. */ +export const readEvidencePayload = ( + schema: z.ZodType, + input: unknown, +): Result => { + const parsed = schema.safeParse(input); + return parsed.success ? ok(parsed.data) : err(integrityFailure(parsed.error)); +}; + +const integrityFailure = (cause: z.ZodError): EvidenceIntegrityError => { + const message = `Evidence validation failed ${describeValidationFailure(cause)}`; + return new EvidenceIntegrityError(message, { cause, userMessage: message }); +}; diff --git a/src/domain/functionComparison.test.ts b/src/domain/functionComparison.test.ts index adcceaa524..4ebec103f6 100644 --- a/src/domain/functionComparison.test.ts +++ b/src/domain/functionComparison.test.ts @@ -1,3 +1,4 @@ +import { functionSnapshotFixture } from "./functionDossierEvidence.fixture.js"; import { describe, expect, it } from "vitest"; import { FUNCTION_COMPARISON_EXAMPLE } from "../contracts/functionComparisonExample.js"; @@ -154,8 +155,8 @@ const keepsGeneratedNamesAmbiguous = (): void => { }, }); const generated = compareFunctions( - observe("1", named("0x1000", "sub_1000")), - observe("2", named("0x2000", "sub_2000")), + functionSnapshotFixture(observe("1", named("0x1000", "sub_1000"))), + functionSnapshotFixture(observe("2", named("0x2000", "sub_2000"))), ); expect(generated.function_match.status).toBe("ambiguous"); expect(generated.status).toBe("unknown"); @@ -164,8 +165,8 @@ const keepsGeneratedNamesAmbiguous = (): void => { ).toMatchObject({ status: "unknown" }); const stable = compareFunctions( - observe("3", named("0x1000", "firstFunction")), - observe("4", named("0x2000", "secondFunction")), + functionSnapshotFixture(observe("3", named("0x1000", "firstFunction"))), + functionSnapshotFixture(observe("4", named("0x2000", "secondFunction"))), ); expect(stable.function_match.status).toBe("mismatched"); expect(stable.status).toBe("changed"); @@ -181,9 +182,10 @@ describe("function comparison normalized identity", () => { }; delete value.facet_availability; const legacy = observe("a", functionDossierSchema.parse(value), "ida"); - const calls = compareFunctions(legacy, legacy).dimensions.find( - ({ dimension }) => dimension === "calls", - ); + const calls = compareFunctions( + functionSnapshotFixture(legacy), + functionSnapshotFixture(legacy), + ).dimensions.find(({ dimension }) => dimension === "calls"); expect(calls).toMatchObject({ status: "unknown", limitations: expect.arrayContaining([ @@ -199,7 +201,10 @@ describe("function comparison normalized identity", () => { }; delete value.facet_availability; const legacy = observe("a", functionDossierSchema.parse(value), "ida"); - const result = compareFunctions(legacy, legacy); + const result = compareFunctions( + functionSnapshotFixture(legacy), + functionSnapshotFixture(legacy), + ); for (const dimension of [ "comments", "references", @@ -227,9 +232,10 @@ describe("function comparison normalized identity", () => { ); const right = observe("b", dossier("return 0;", "0x2000")); expect( - compareFunctions(left, right).dimensions.find( - ({ dimension }) => dimension === "comments", - ), + compareFunctions( + functionSnapshotFixture(left), + functionSnapshotFixture(right), + ).dimensions.find(({ dimension }) => dimension === "comments"), ).toMatchObject({ status: "unknown", left_count: null, right_count: null }); }); it( @@ -255,8 +261,8 @@ describe("function comparison normalized identity", () => { }, }); const result = compareFunctions( - observe("b", named("0x1000")), - observe("c", named("0x2000")), + functionSnapshotFixture(observe("b", named("0x1000"))), + functionSnapshotFixture(observe("c", named("0x2000"))), ); expect(result.function_match).toMatchObject({ status: "matched", @@ -282,8 +288,8 @@ describe("function comparison normalized identity", () => { }, }); const result = compareFunctions( - observe("d", generated("0x1000")), - observe("e", generated("0x2000")), + functionSnapshotFixture(observe("d", generated("0x1000"))), + functionSnapshotFixture(observe("e", generated("0x2000"))), ); expect(result.function_match.status).toBe("ambiguous"); expect( @@ -312,14 +318,16 @@ describe("function comparison address-derived call names", () => { callees: [procedure(base === "0x1000" ? "0x1010" : "0x2010", name)], }); const result = compareFunctions( - observe("6", calling("0x1000")), - observe( - "7", - calling("0x2000"), - "rea-workflow", - diagnostic === undefined - ? { status: "complete" } - : { status: "complete", diagnostics: [diagnostic] }, + functionSnapshotFixture(observe("6", calling("0x1000"))), + functionSnapshotFixture( + observe( + "7", + calling("0x2000"), + "rea-workflow", + diagnostic === undefined + ? { status: "complete" } + : { status: "complete", diagnostics: [diagnostic] }, + ), ), ); const calls = result.dimensions.find( @@ -368,7 +376,10 @@ describe("function call relationship coverage", () => { "rea-workflow", coverage, ); - const result = compareFunctions(left, right); + const result = compareFunctions( + functionSnapshotFixture(left), + functionSnapshotFixture(right), + ); const calls = result.dimensions.find( ({ dimension }) => dimension === "calls", ); @@ -401,9 +412,10 @@ describe("function call relationship coverage", () => { coverage, ); expect( - compareFunctions(left, right).dimensions.find( - ({ dimension }) => dimension === "calls", - ), + compareFunctions( + functionSnapshotFixture(left), + functionSnapshotFixture(right), + ).dimensions.find(({ dimension }) => dimension === "calls"), ).toMatchObject({ status: "unchanged", left_count: 0, right_count: 0 }); }); @@ -420,9 +432,10 @@ describe("function call relationship coverage", () => { }); const right = observe("f", rightDossier); expect( - compareFunctions(left, right).dimensions.find( - ({ dimension }) => dimension === "calls", - ), + compareFunctions( + functionSnapshotFixture(left), + functionSnapshotFixture(right), + ).dimensions.find(({ dimension }) => dimension === "calls"), ).toMatchObject({ status: "unknown", limitations: [ @@ -443,8 +456,8 @@ describe("function collection collation ties", () => { it("ignores reordered callees whose distinct names collate equally", () => { const result = compareFunctions( - observe("b", calling([composed, decomposed])), - observe("c", calling([decomposed, composed])), + functionSnapshotFixture(observe("b", calling([composed, decomposed]))), + functionSnapshotFixture(observe("c", calling([decomposed, composed]))), ); expect(result.status).toBe("unchanged"); const calls = result.dimensions.find( @@ -465,8 +478,8 @@ describe("function collection collation ties", () => { ["replace decomposed", [composed, composed]], ] as const)("preserves an exact name change: %s", (_label, changed) => { const result = compareFunctions( - observe("b", calling([composed, decomposed])), - observe("c", calling(changed)), + functionSnapshotFixture(observe("b", calling([composed, decomposed]))), + functionSnapshotFixture(observe("c", calling(changed))), ); expect(result.status).toBe("changed"); const calls = result.dimensions.find( @@ -486,8 +499,8 @@ describe("function collection collation ties", () => { compareUnicodeCodePoints(canonicalJson(left), canonicalJson(right)), ); const result = compareFunctions( - observe("b", calling(callees)), - observe("c", calling([...callees].reverse())), + functionSnapshotFixture(observe("b", calling(callees))), + functionSnapshotFixture(observe("c", calling([...callees].reverse()))), ); const calls = result.dimensions.find( ({ dimension }) => dimension === "calls", @@ -595,8 +608,8 @@ describe("function comparison ordering controls", () => { const rightWithoutNativeApi = { ...right, native_api: null }; const comparison = compareFunctions( - makeEvidence("d", left), - makeEvidence("e", rightWithoutNativeApi), + functionSnapshotFixture(makeEvidence("d", left)), + functionSnapshotFixture(makeEvidence("e", rightWithoutNativeApi)), ); for (const dimension of ["calls", "strings_names"] as const) @@ -636,8 +649,8 @@ describe("function comparison CFG address normalization", () => { }); expect( compareFunctions( - observe("b", make("0x1000")), - observe("c", make("0x2000")), + functionSnapshotFixture(observe("b", make("0x1000"))), + functionSnapshotFixture(observe("c", make("0x2000"))), ).dimensions.find(({ dimension }) => dimension === "cfg"), ).toMatchObject({ status: "unchanged" }); }); @@ -652,8 +665,8 @@ describe("function comparison CFG address normalization", () => { }); expect( compareFunctions( - observe("d", duplicate), - observe("e", duplicate), + functionSnapshotFixture(observe("d", duplicate)), + functionSnapshotFixture(observe("e", duplicate)), ).dimensions.find(({ dimension }) => dimension === "cfg"), ).toMatchObject({ status: "unknown" }); }); @@ -675,7 +688,10 @@ describe("function comparison unresolved text", () => { assembly: ["0x2000 call FUN_00002010", "0x2004 ret"], }), ); - const comparison = compareFunctions(left, right); + const comparison = compareFunctions( + functionSnapshotFixture(left), + functionSnapshotFixture(right), + ); for (const name of ["pseudocode", "assembly"]) { const dimension = comparison.dimensions.find( ({ dimension }) => dimension === name, @@ -697,7 +713,10 @@ describe("function comparison unresolved text", () => { it("counts Unicode text consistently for unresolved provider comparisons", () => { const left = observe("b", dossier("return '😀';", "0x1000"), "provider-a"); const right = observe("c", dossier("return '😀';", "0x2000"), "provider-b"); - const comparison = compareFunctions(left, right); + const comparison = compareFunctions( + functionSnapshotFixture(left), + functionSnapshotFixture(right), + ); expect( comparison.dimensions.find(({ dimension }) => dimension === "pseudocode"), ).toMatchObject({ status: "unknown", left_count: 11, right_count: 11 }); @@ -726,7 +745,10 @@ describe("function comparison", () => { status: "complete", diagnostics: [diagnostic], }); - const comparison = compareFunctions(left, right); + const comparison = compareFunctions( + functionSnapshotFixture(left), + functionSnapshotFixture(right), + ); expect( comparison.dimensions.find(({ dimension }) => dimension === "calls"), ).toMatchObject({ @@ -748,9 +770,10 @@ describe("function comparison", () => { diagnostics: [diagnostic], }); expect( - compareFunctions(left, right).dimensions.find( - ({ dimension }) => dimension === "calls", - ), + compareFunctions( + functionSnapshotFixture(left), + functionSnapshotFixture(right), + ).dimensions.find(({ dimension }) => dimension === "calls"), ).toMatchObject({ status: "unknown", limitations: expect.arrayContaining([ @@ -764,8 +787,8 @@ describe("function comparison", () => { it("preserves text changes alongside complete inline dossier facets", () => { const result = compareFunctions( - FUNCTION_COMPARISON_EXAMPLE.left, - FUNCTION_COMPARISON_EXAMPLE.right, + functionSnapshotFixture(FUNCTION_COMPARISON_EXAMPLE.left), + functionSnapshotFixture(FUNCTION_COMPARISON_EXAMPLE.right), ); expect(functionComparisonResultSchema.parse(result)).toMatchObject({ status: "changed", @@ -784,11 +807,16 @@ describe("function comparison", () => { expectInlineComparisonEvidence(result); expectFunctionDimensionAlgebra(result); }); +}); +describe("function comparison normalization and provenance", () => { it("normalizes CFG relocation but preserves changed edges and constants", () => { const left = observe("2", dossier("return 0x10;", "0x1000")); const relocated = observe("3", dossier("return 0x10;", "0x2000")); - const same = compareFunctions(left, relocated); + const same = compareFunctions( + functionSnapshotFixture(left), + functionSnapshotFixture(relocated), + ); expect( same.dimensions.find(({ dimension }) => dimension === "cfg"), ).toMatchObject({ @@ -799,7 +827,10 @@ describe("function comparison", () => { ).toMatchObject({ status: "unchanged" }); const changed = observe("4", dossier("return 0x20;", "0x2000", ["0x2000"])); - const comparison = compareFunctions(left, changed); + const comparison = compareFunctions( + functionSnapshotFixture(left), + functionSnapshotFixture(changed), + ); expect( comparison.dimensions.find(({ dimension }) => dimension === "cfg"), ).toMatchObject({ status: "changed" }); @@ -811,20 +842,29 @@ describe("function comparison", () => { it("treats provider-specific text as unknown and validates Evidence", () => { const left = observe("5", dossier("return 0;", "0x1000"), "provider-a"); const right = observe("6", dossier("return 1;", "0x2000"), "provider-b"); - const comparison = compareFunctions(left, right); + const comparison = compareFunctions( + functionSnapshotFixture(left), + functionSnapshotFixture(right), + ); expect( comparison.dimensions.find(({ dimension }) => dimension === "pseudocode"), ).toMatchObject({ status: "unknown" }); expect(comparison.changes).toHaveLength(2); expect(() => - compareFunctions({ ...left, operation: "binary_overview" }, right), + compareFunctions( + functionSnapshotFixture({ ...left, operation: "binary_overview" }), + functionSnapshotFixture(right), + ), ).toThrow(/identifier/u); }); it("reports fully observed identical dossiers as unchanged", () => { const left = observe("7", dossier("return 0;\n", "0x1000")); const right = observe("8", dossier("return 0;\n", "0x2000")); - const comparison = compareFunctions(left, right); + const comparison = compareFunctions( + functionSnapshotFixture(left), + functionSnapshotFixture(right), + ); expect(comparison.status).toBe("unchanged"); expect( comparison.dimensions.every(({ status }) => status === "unchanged"), @@ -835,16 +875,18 @@ describe("function comparison", () => { const left = observe("d", dossierWithReference("0x1000", "call")); const same = observe("e", dossierWithReference("0x2000", "call")); expect( - compareFunctions(left, same).dimensions.find( - ({ dimension }) => dimension === "references", - ), + compareFunctions( + functionSnapshotFixture(left), + functionSnapshotFixture(same), + ).dimensions.find(({ dimension }) => dimension === "references"), ).toMatchObject({ status: "unchanged" }); const changed = observe("f", dossierWithReference("0x2000", "data")); expect( - compareFunctions(left, changed).dimensions.find( - ({ dimension }) => dimension === "references", - ), + compareFunctions( + functionSnapshotFixture(left), + functionSnapshotFixture(changed), + ).dimensions.find(({ dimension }) => dimension === "references"), ).toMatchObject({ status: "changed" }); const unavailable = observe( @@ -852,9 +894,10 @@ describe("function comparison", () => { dossierWithReference("0x2000", "unavailable"), ); expect( - compareFunctions(left, unavailable).dimensions.find( - ({ dimension }) => dimension === "references", - ), + compareFunctions( + functionSnapshotFixture(left), + functionSnapshotFixture(unavailable), + ).dimensions.find(({ dimension }) => dimension === "references"), ).toMatchObject({ status: "unknown", limitations: [expect.stringContaining("did not expose reference kinds")], @@ -875,8 +918,8 @@ describe("function comparison identity and malformed CFG", () => { }, }); const comparison = compareFunctions( - observe("9", autoDossier("0x1000")), - observe("a", autoDossier("0x2000")), + functionSnapshotFixture(observe("9", autoDossier("0x1000"))), + functionSnapshotFixture(observe("a", autoDossier("0x2000"))), ); expect(comparison.function_match.status).toBe("ambiguous"); expect( @@ -884,13 +927,15 @@ describe("function comparison identity and malformed CFG", () => { ).toMatchObject({ status: "unknown" }); expect(comparison.status).toBe("unknown"); const changedText = compareFunctions( - observe("b", autoDossier("0x1000")), - observe( - "c", - functionDossierSchema.parse({ - ...autoDossier("0x2000"), - pseudocode: "return 1;", - }), + functionSnapshotFixture(observe("b", autoDossier("0x1000"))), + functionSnapshotFixture( + observe( + "c", + functionDossierSchema.parse({ + ...autoDossier("0x2000"), + pseudocode: "return 1;", + }), + ), ), ); expect(changedText.dimensions).toEqual( @@ -911,8 +956,8 @@ describe("function comparison identity and malformed CFG", () => { ], }); const comparison = compareFunctions( - observe("b", leftDossier), - observe("c", invalidCfg), + functionSnapshotFixture(observe("b", leftDossier)), + functionSnapshotFixture(observe("c", invalidCfg)), ); expect( comparison.dimensions.find(({ dimension }) => dimension === "cfg"), @@ -944,8 +989,8 @@ const stringsDimension = ( right: ReturnType | ReturnType, ) => compareFunctions( - observe(leftDigit, left), - observe(rightDigit, right), + functionSnapshotFixture(observe(leftDigit, left)), + functionSnapshotFixture(observe(rightDigit, right)), ).dimensions.find(({ dimension }) => dimension === "strings_names"); describe("function comparison Swift string literals", () => { @@ -1244,10 +1289,12 @@ it.each([ "validates $match match precedence with changed dimensions=$changedDimensions", ({ match, changedDimensions, expected }) => { const result = compareFunctions( - FUNCTION_COMPARISON_EXAMPLE.left, - changedDimensions - ? FUNCTION_COMPARISON_EXAMPLE.right - : FUNCTION_COMPARISON_EXAMPLE.left, + functionSnapshotFixture(FUNCTION_COMPARISON_EXAMPLE.left), + functionSnapshotFixture( + changedDimensions + ? FUNCTION_COMPARISON_EXAMPLE.right + : FUNCTION_COMPARISON_EXAMPLE.left, + ), ); const candidate = { ...result, @@ -1299,8 +1346,10 @@ describe("function comparison unnamed reference endpoints", () => { }); const reference = (left: string, right: string, digits = ["a", "a"]) => compareFunctions( - observe(digits[0] ?? "a", unnamedTarget(left)), - observe(digits[1] ?? "a", unnamedTarget(right)), + functionSnapshotFixture(observe(digits[0] ?? "a", unnamedTarget(left))), + functionSnapshotFixture( + observe(digits[1] ?? "a", unnamedTarget(right)), + ), ).dimensions.find(({ dimension }) => dimension === "references"); expect(reference("0x1010", "0x1020")).toMatchObject({ status: "changed" }); expect(reference("0x1010", "0x1010")).toMatchObject({ @@ -1322,8 +1371,8 @@ describe("function comparison unnamed reference endpoints", () => { }; expect( compareFunctions( - observe("a", unnamed("call")), - observe("b", unnamed("data")), + functionSnapshotFixture(observe("a", unnamed("call"))), + functionSnapshotFixture(observe("b", unnamed("data"))), ).dimensions.find(({ dimension }) => dimension === "references"), ).toMatchObject({ status: "changed" }); }); diff --git a/src/domain/functionComparison.ts b/src/domain/functionComparison.ts index 9eb8016946..9710fbe7d2 100644 --- a/src/domain/functionComparison.ts +++ b/src/domain/functionComparison.ts @@ -1,6 +1,6 @@ import { canonicalJson } from "./comparisonSemantics.js"; import { compareUnicodeCodePoints } from "./unicodeCodePointOrder.js"; -import { parseFunctionEvidence } from "./functionDossierEvidence.js"; +import type { FunctionSnapshot } from "./functionDossierEvidence.js"; import { functionMatch } from "./functionComparisonNormalization.js"; import { functionComparisonResultSchema, @@ -9,13 +9,11 @@ import { import { overallStatus, summarize } from "./functionComparisonResults.js"; import { compareDimensions } from "./functionComparisonDimensions.js"; -/** Compare two complete function Evidence records without fuzzy matching. */ +/** Compare two authenticated function snapshots without fuzzy matching. */ export const compareFunctions = ( - leftInput: unknown, - rightInput: unknown, + left: FunctionSnapshot, + right: FunctionSnapshot, ): FunctionComparisonResult => { - const left = parseFunctionEvidence(leftInput); - const right = parseFunctionEvidence(rightInput); const links = [left.evidence.evidence_id, right.evidence.evidence_id]; const providersDiffer = canonicalJson(left.evidence.provider, "Function comparison") !== diff --git a/src/domain/functionDossierEvidence.fixture.ts b/src/domain/functionDossierEvidence.fixture.ts new file mode 100644 index 0000000000..b59e99ef3a --- /dev/null +++ b/src/domain/functionDossierEvidence.fixture.ts @@ -0,0 +1,14 @@ +import { + parseFunctionEvidence, + type FunctionSnapshot, +} from "./functionDossierEvidence.js"; + +/** Authenticate fixture Evidence before passing its facts to pure comparisons. */ +export const functionSnapshotFixture = (input: unknown): FunctionSnapshot => { + const parsed = parseFunctionEvidence(input, { + operation: "compare_functions", + path: [], + }); + if (!parsed.ok) throw parsed.error; + return parsed.value; +}; diff --git a/src/domain/functionDossierEvidence.ts b/src/domain/functionDossierEvidence.ts index 9c2f906cce..9a991b2f07 100644 --- a/src/domain/functionDossierEvidence.ts +++ b/src/domain/functionDossierEvidence.ts @@ -1,4 +1,8 @@ -import { parseEvidence, type Evidence } from "./evidence.js"; +import type { Evidence } from "./evidence.js"; +import { AnalysisInputError } from "./analysisErrorCore.js"; +import type { EvidenceIntegrityError } from "./evidenceErrors.js"; +import { readEvidence, readEvidencePayload } from "./evidenceValidation.js"; +import { err, ok, type Result } from "./result.js"; import { compareUnicodeCodePoints } from "./unicodeCodePointOrder.js"; import { functionDossierSchema, type FunctionDossier } from "./hopperValues.js"; import { assessFunctionCallCoverage } from "./functionCallCoverage.js"; @@ -14,27 +18,50 @@ export interface FunctionSnapshot { } /** Parse one complete analyze_function Evidence record for comparison workflows. */ -export const parseFunctionEvidence = (input: unknown): FunctionSnapshot => { - const evidence = parseEvidence(input); - if (evidence.operation !== "analyze_function") - throw new TypeError( - "Function comparison requires analyze_function Evidence", +export const parseFunctionEvidence = ( + input: unknown, + selection: { + readonly operation: "compare_functions" | "build_call_path"; + readonly path: readonly (string | number)[]; + }, +): Result => { + const parsed = readEvidence(input); + if (!parsed.ok) return parsed; + const evidence = parsed.value; + const invalidSelection = (message: string) => + err( + new AnalysisInputError(selection.operation, { userMessage: message }, [ + { path: selection.path, reason: "invalid_value", message }, + ]), + ); + if ( + evidence.operation !== "analyze_function" || + evidence.predicate_type !== "rea.analysis" + ) + return invalidSelection( + "Function analysis requires analyze_function Evidence with the rea.analysis predicate", ); if (evidence.subject === null) - throw new TypeError("Function comparison requires artifact-bound Evidence"); - const dossier = functionDossierSchema.parse(evidence.normalized_result); + return invalidSelection( + "Function analysis requires artifact-bound Evidence", + ); + const dossier = readEvidencePayload( + functionDossierSchema, + evidence.normalized_result, + ); + if (!dossier.ok) return dossier; const callCoverage = assessFunctionCallCoverage( evidence.relationship_coverage, - dossier, + dossier.value, ); - return { + return ok({ evidence, subject: evidence.subject, - dossier, + dossier: dossier.value, limitations: [ - ...new Set([...evidence.limitations, ...dossier.limitations]), + ...new Set([...evidence.limitations, ...dossier.value.limitations]), ].sort((left, right) => compareUnicodeCodePoints(left, right)), callCoverageBlockers: callCoverage.blockers, callCoverageDiagnostics: callCoverage.diagnostics, - }; + }); }; diff --git a/src/domain/harmony/harmonyApplication.ts b/src/domain/harmony/harmonyApplication.ts index 3f9c2b2720..be3040fd45 100644 --- a/src/domain/harmony/harmonyApplication.ts +++ b/src/domain/harmony/harmonyApplication.ts @@ -3,6 +3,7 @@ import { projectCartesianCandidates, } from "../bridgeCandidateProjection.js"; import { z } from "zod"; +import { AnalysisInputError } from "../analysisErrorCore.js"; import { hasCompleteInventoryCoverage, @@ -160,9 +161,19 @@ export const projectHarmonyApplication = ( const harmonyRootFormat = (format: string): RootFormat => { const root = HARMONY_ROOT_FORMATS.find((candidate) => candidate === format); if (root === undefined) - throw new TypeError( - `HarmonyOS application projection requires HAP, HSP, or App Pack inventory Evidence (got ${format})`, - ); + throw new AnalysisInputError("project_harmony_application_graph", {}, [ + { + path: [ + "inventory_evidence", + 0, + "normalized_result", + "manifest", + "root_format", + ], + reason: "invalid_value", + message: `HarmonyOS application projection requires HAP, HSP, or App Pack inventory Evidence (got ${format})`, + }, + ]); return root; }; diff --git a/src/domain/reconstructionVerification.functionSources.test.ts b/src/domain/reconstructionVerification.functionSources.test.ts index 14220987de..eadb957c40 100644 --- a/src/domain/reconstructionVerification.functionSources.test.ts +++ b/src/domain/reconstructionVerification.functionSources.test.ts @@ -1,5 +1,7 @@ import { describe, expect, it } from "vitest"; +import { AnalysisInputError } from "./analysisErrorCore.js"; +import { EvidenceIntegrityError } from "./evidenceErrors.js"; import { createEvidence, parseEvidence, @@ -18,6 +20,7 @@ import { import { createResidualUnknown } from "./residualUnknown.js"; import { compareFunctions } from "./functionComparison.js"; +import { functionSnapshotFixture } from "./functionDossierEvidence.fixture.js"; import { verifyReconstruction } from "./reconstructionVerification.js"; const dossier = jsonValueSchema.parse( functionDossierSchema.parse({ @@ -103,7 +106,10 @@ const provider = { name: "REA function comparison", version: "1", }; -const baselineResult = compareFunctions(left, right); +const baselineResult = compareFunctions( + functionSnapshotFixture(left), + functionSnapshotFixture(right), +); const comparison = ( parameters: Record, @@ -261,7 +267,10 @@ describe("historical calls-only reconstruction changes", () => { true, coverage !== "missing", ); - const historicalResult = compareFunctions(left, complete); + const historicalResult = compareFunctions( + functionSnapshotFixture(left), + functionSnapshotFixture(complete), + ); expect(historicalResult).toMatchObject({ status: "changed", summary: { changed: 1 }, @@ -331,7 +340,10 @@ describe("function reconstruction call coverage", () => { const rightWithDiagnostic = source("b", { relationshipCoverage: { status: "complete", diagnostics: [diagnostic] }, }); - const observed = compareFunctions(leftWithDiagnostic, rightWithDiagnostic); + const observed = compareFunctions( + functionSnapshotFixture(leftWithDiagnostic), + functionSnapshotFixture(rightWithDiagnostic), + ); expect( observed.dimensions.find(({ dimension }) => dimension === "calls"), ).toMatchObject({ @@ -437,7 +449,10 @@ describe("function reconstruction call coverage", () => { reason: "Call graph unavailable.", }, }); - const changedResult = compareFunctions(left, changedComplete); + const changedResult = compareFunctions( + functionSnapshotFixture(left), + functionSnapshotFixture(changedComplete), + ); const oldChanged = comparison( { left_evidence_id: left.evidence_id, @@ -538,7 +553,9 @@ describe("function reconstruction source aliases", () => { ] satisfies [string, Record][])( "rejects %s after valid digest admission", (_name, parameters) => { - expect(admittedVerification(comparison(parameters))).toThrow(); + expect(admittedVerification(comparison(parameters))).toThrow( + EvidenceIntegrityError, + ); }, ); }); @@ -566,7 +583,7 @@ describe("function reconstruction malformed source IDs", () => { admittedVerification( comparison({ ...singular, ...aliases, [field]: invalid }), ), - ).toThrow(); + ).toThrow(EvidenceIntegrityError); } } }, @@ -595,7 +612,7 @@ describe("function reconstruction malformed source IDs", () => { admittedVerification( comparison({ ...plural, ...aliases, [field]: invalid }), ), - ).toThrow(); + ).toThrow(EvidenceIntegrityError); } } }, @@ -612,7 +629,7 @@ describe("function reconstruction malformed source IDs", () => { ], ])("rejects %s", (_name, parameters) => { expect(admittedVerification(comparison(parameters))).toThrow( - /repeats a source within one side/u, + EvidenceIntegrityError, ); }); }); @@ -625,7 +642,7 @@ describe("function reconstruction admission controls", () => { admittedVerification( comparison(parameters, { evidenceLinks: [left.evidence_id] }), ), - ).toThrow(/closure disagrees/u); + ).toThrow(EvidenceIntegrityError); expect( admittedVerification( comparison(parameters, { @@ -637,10 +654,23 @@ describe("function reconstruction admission controls", () => { }), [left, right, extra], ), - ).toThrow(/closure disagrees/u); - expect(admittedVerification(comparison(parameters), [left])).toThrow( - /Missing source Evidence/u, - ); + ).toThrow(EvidenceIntegrityError); + try { + admittedVerification(comparison(parameters), [left])(); + throw new Error("Expected a missing source to be rejected"); + } catch (error: unknown) { + expect(error).toBeInstanceOf(AnalysisInputError); + expect(error).toMatchObject({ + userMessage: expect.stringMatching(/Missing source Evidence/u), + issues: [ + { + path: ["claims", 0, "comparison_evidence_id"], + reason: "invalid_value", + message: expect.stringMatching(/Missing source Evidence/u), + }, + ], + }); + } }, ); @@ -655,7 +685,7 @@ describe("function reconstruction admission controls", () => { [wrong, right], ); expect(admittedVerification(evidence, [wrong, right])).toThrow( - /unexpected observation type/u, + EvidenceIntegrityError, ); }); @@ -670,7 +700,9 @@ describe("function reconstruction admission controls", () => { {}, [wrong, right], ); - expect(admittedVerification(evidence, [wrong, right])).toThrow(); + expect(admittedVerification(evidence, [wrong, right])).toThrow( + EvidenceIntegrityError, + ); }, ); @@ -727,7 +759,7 @@ describe("function reconstruction admission controls", () => { }), }); expect(admittedVerification(evidence, [left, right, extra])).toThrow( - /finding cites Evidence outside its top-level closure/u, + EvidenceIntegrityError, ); }); }); @@ -740,7 +772,7 @@ describe("function reconstruction evidence integrity", () => { { authority: "shipped-artifact" as const }, ])("rejects changed comparison identity or authority %j", (observation) => { expect(admittedVerification(comparison(singular, observation))).toThrow( - /identity or authority disagrees/u, + "operation" in observation ? AnalysisInputError : EvidenceIntegrityError, ); }); @@ -753,7 +785,7 @@ describe("function reconstruction evidence integrity", () => { admittedVerification( comparison(singular, {}, [left, right], { provider: identity }), ), - ).toThrow(/identity or authority disagrees/u); + ).toThrow(EvidenceIntegrityError); }); it("rejects an artifact-bound comparison", () => { @@ -764,9 +796,7 @@ describe("function reconstruction evidence integrity", () => { format: "file", }, }); - expect(admittedVerification(evidence)).toThrow( - /identity or authority disagrees/u, - ); + expect(admittedVerification(evidence)).toThrow(EvidenceIntegrityError); }); it("retains active source unknowns as a gating result", () => { @@ -817,6 +847,6 @@ describe("function reconstruction evidence integrity", () => { specification(evidence), createEvidenceBundle([left, right, tampered]), ), - ).toThrow(/semantic identifier does not match/u); + ).toThrow(EvidenceIntegrityError); }); }); diff --git a/src/domain/reconstructionVerification.test.ts b/src/domain/reconstructionVerification.test.ts index 159a817296..0fa4588f9b 100644 --- a/src/domain/reconstructionVerification.test.ts +++ b/src/domain/reconstructionVerification.test.ts @@ -1,7 +1,9 @@ import { describe, expect, it } from "vitest"; import { createEvidence, type Evidence } from "./evidence.js"; +import { AnalysisInputError } from "./analysisErrorCore.js"; import { createEvidenceBundle } from "./evidenceBundle.js"; +import { EvidenceIntegrityError } from "./evidenceErrors.js"; import { reconstructionVerificationResultSchema } from "./reconstructionVerificationSchemas.js"; import type { JsonValue } from "./jsonValue.js"; import { EMPTY_PROCESS_CAPTURE_EXAMPLE } from "./process/processCaptureExample.js"; @@ -329,10 +331,35 @@ describe("reconstruction process capture input", () => { behavioralSpec(comparison), createEvidenceBundle([comparison, left, right]), ), - ).toThrow(); + ).toThrow(EvidenceIntegrityError); }); }); +it("reports a missing comparison reference as caller input with its claim path", () => { + const left = source("1", "controlled-replay"); + const right = source("2", "controlled-replay"); + const comparison = processComparison(left, right, processResult()); + try { + verifyReconstruction( + behavioralSpec(comparison), + createEvidenceBundle([left, right]), + ); + throw new Error("Expected a missing comparison reference to be rejected"); + } catch (error: unknown) { + expect(error).toBeInstanceOf(AnalysisInputError); + expect(error).toMatchObject({ + userMessage: `Missing comparison Evidence ${comparison.evidence_id}`, + issues: [ + { + path: ["claims", 0, "comparison_evidence_id"], + reason: "invalid_value", + message: `Missing comparison Evidence ${comparison.evidence_id}`, + }, + ], + }); + } +}); + describe("reconstruction verification outcomes", () => { it("requires every selected behavioral source to share a replay environment", () => { const replay = (digit: string, id: string | null) => @@ -561,3 +588,26 @@ describe("reconstruction verification integrity", () => { expect(result.claims.items[0]?.unknown_ids).toHaveLength(101); }); }); + +it("keeps a valid comparison selected for the wrong claim kind as caller input", () => { + const left = ARTIFACT_COMPARISON_EXAMPLE.left; + const right = ARTIFACT_COMPARISON_EXAMPLE.right; + const comparison = artifactComparison(left, right, false); + try { + verifyReconstruction( + behavioralSpec(comparison), + createEvidenceBundle([left, right, comparison]), + ); + throw new Error("Expected a claim-selection error"); + } catch (cause: unknown) { + expect(cause).toBeInstanceOf(AnalysisInputError); + expect(cause).toMatchObject({ + operation: "verify_reconstruction", + issues: [ + expect.objectContaining({ + path: ["claims", 0, "comparison_evidence_id"], + }), + ], + }); + } +}); diff --git a/src/domain/reconstructionVerification.ts b/src/domain/reconstructionVerification.ts index b0e3c8f11d..2e517dae34 100644 --- a/src/domain/reconstructionVerification.ts +++ b/src/domain/reconstructionVerification.ts @@ -1,21 +1,33 @@ import { z } from "zod"; import { artifactComparisonResultSchema } from "./artifactComparison.js"; -import { parseArtifactInventoryEvidence } from "./artifactInventoryEvidence.js"; +import { + ArtifactInventoryCarrierError, + parseArtifactInventoryEvidence, +} from "./artifactInventoryEvidence.js"; import { uniqueSorted } from "./canonicalOrdering.js"; import { compareUnicodeCodePoints } from "./unicodeCodePointOrder.js"; import { - comparisonSourceEvidenceSides, assertComparisonFindingLinks, + comparisonSourceEvidenceSides, } from "./comparisonSourceEvidence.js"; -import { parseEvidence, type Evidence } from "./evidence.js"; +import type { Evidence } from "./evidence.js"; import { hasComparisonEvidenceIdentity } from "./comparisonEvidenceIdentity.js"; -import { parseEvidenceBundle } from "./evidenceBundle.js"; +import { + DuplicateEvidenceRecordError, + InvalidResidualUnknownGraphError, + NonCanonicalEvidenceBundleError, + parseEvidenceBundle, +} from "./evidenceBundle.js"; +import { AnalysisInputError } from "./analysisErrorCore.js"; +import { EvidenceIntegrityError } from "./evidenceErrors.js"; +import { projectInputIssues } from "./inputIssueProjection.js"; +import { readEvidencePayload } from "./evidenceValidation.js"; import { functionComparisonResultSchema } from "./functionComparisonSchemas.js"; import { functionDossierSchema } from "./hopperValues.js"; import { assessFunctionCallCoverage } from "./functionCallCoverage.js"; import { processCaptureComparisonSchema } from "./process/processComparison.js"; -import { parseProcessCapture } from "./process/processCaptureParsing.js"; +import { processCaptureSchema } from "./process/processCapture.js"; import { digestCanonicalValue } from "./canonicalDigest.js"; import { reconstructionClaimResultSchema, @@ -50,15 +62,22 @@ export const verifyReconstruction = ( specificationInput: unknown, bundleInput: unknown, ): ReconstructionVerificationResult => { - const specification = - reconstructionSpecificationSchema.parse(specificationInput); - const bundle = parseEvidenceBundle(bundleInput); + const parsedSpecification = + reconstructionSpecificationSchema.safeParse(specificationInput); + if (!parsedSpecification.success) + throw new AnalysisInputError( + "verify_reconstruction", + { cause: parsedSpecification.error }, + projectInputIssues(parsedSpecification.error.issues, specificationInput), + ); + const specification = parsedSpecification.data; + const bundle = readOwnedEvidenceBundle(bundleInput); const records = new Map( bundle.records.map((item) => [item.evidence_id, item]), ); const heads = reconstructionUnknownHeads(bundle.unknowns); const results = specification.claims - .map((claim) => evaluateClaim(claim, records, heads)) + .map((claim, index) => evaluateClaim(claim, index, records, heads)) .sort((left, right) => compareUnicodeCodePoints(left.claim_id, right.claim_id), ); @@ -101,25 +120,34 @@ export const verifyReconstruction = ( const evaluateClaim = ( claim: Claim, + claimIndex: number, records: ReadonlyMap, heads: ReadonlyMap, ): ClaimResult => { const comparison = records.get(claim.comparison_evidence_id); if (comparison === undefined) - throw new TypeError( + throw missingSourceInputError( + claimIndex, `Missing comparison Evidence ${claim.comparison_evidence_id}`, ); - validateComparisonIdentity(claim, comparison); + validateComparisonIdentity(claim, comparison, claimIndex); const sides = comparisonSourceEvidenceSides(comparison); const allSourceIds = uniqueSorted([...sides.left, ...sides.right]); if (!sameSet(comparison.evidence_links, allSourceIds)) - throw new TypeError( + throw new EvidenceIntegrityError( "Comparison Evidence closure disagrees with its parameters", + { + userMessage: + "Comparison Evidence closure disagrees with its parameters", + }, ); const sources = allSourceIds.map((id) => { const source = records.get(id); if (source === undefined) - throw new TypeError(`Missing source Evidence ${id}`); + throw missingSourceInputError( + claimIndex, + `Missing source Evidence ${id}`, + ); return source; }); const functionSourceFacts = readValidatedSourceFacts(claim, sources); @@ -188,15 +216,28 @@ const readValidatedSourceFacts = ( ); }; if (sources.some((source) => !isExpectedType(source))) - throw new TypeError( + throw new EvidenceIntegrityError( "Comparison source Evidence has an unexpected observation type", + { + userMessage: + "Comparison source Evidence has an unexpected observation type", + }, ); const functionSources: FunctionSourceFacts[] = []; for (const source of sources) { - if (claim.kind === "behavioral") - parseProcessCapture(source.normalized_result); - else if (claim.kind === "structural-function") { - const dossier = functionDossierSchema.parse(source.normalized_result); + if (claim.kind === "behavioral") { + const capture = readEvidencePayload( + processCaptureSchema, + source.normalized_result, + ); + if (!capture.ok) throw capture.error; + } else if (claim.kind === "structural-function") { + const parsedDossier = readEvidencePayload( + functionDossierSchema, + source.normalized_result, + ); + if (!parsedDossier.ok) throw parsedDossier.error; + const dossier = parsedDossier.value; const callCoverage = assessFunctionCallCoverage( source.relationship_coverage, dossier, @@ -206,19 +247,38 @@ const readValidatedSourceFacts = ( callCoverageBlockers: callCoverage.blockers, callCoverageDiagnostics: callCoverage.diagnostics, }); - } else parseArtifactInventoryEvidence(source); + } else { + try { + parseArtifactInventoryEvidence(source); + } catch (cause: unknown) { + if (cause instanceof EvidenceIntegrityError) throw cause; + if (cause instanceof ArtifactInventoryCarrierError) + throw new EvidenceIntegrityError(cause.message, { + cause, + userMessage: cause.message, + }); + throw cause; + } + } } return functionSources; }; -const validateComparisonIdentity = (claim: Claim, evidence: Evidence): void => { - parseEvidence(evidence); +const validateComparisonIdentity = ( + claim: Claim, + evidence: Evidence, + claimIndex: number, +): void => { const expectedOperation = comparisonOperationsByClaimKind[claim.kind]; - if ( - evidence.operation !== expectedOperation || - !hasComparisonEvidenceIdentity(evidence) - ) - throw new TypeError("Comparison Evidence identity or authority disagrees"); + if (evidence.operation !== expectedOperation) + throw missingSourceInputError( + claimIndex, + `Claim ${claim.claim_id} requires ${expectedOperation} Evidence; selected ${evidence.operation}`, + ); + if (!hasComparisonEvidenceIdentity(evidence)) { + const message = "Comparison Evidence identity or authority disagrees"; + throw new EvidenceIntegrityError(message, { userMessage: message }); + } }; const comparisonObservation = ( @@ -230,9 +290,12 @@ const comparisonObservation = ( readonly hasIndependentFunctionChange?: boolean; } => { if (claim.kind === "behavioral") { - const result = processCaptureComparisonSchema.parse( + const parsed = readEvidencePayload( + processCaptureComparisonSchema, evidence.normalized_result, ); + if (!parsed.ok) throw parsed.error; + const result = parsed.value; if (claim.dimension !== "overall") return { status: result[claim.dimension], limitations: [] }; if ( @@ -249,9 +312,12 @@ const comparisonObservation = ( }; } if (claim.kind === "structural-function") { - const result = functionComparisonResultSchema.parse( + const parsed = readEvidencePayload( + functionComparisonResultSchema, evidence.normalized_result, ); + if (!parsed.ok) throw parsed.error; + const result = parsed.value; assertComparisonFindingLinks( evidence, result.dimensions.flatMap(({ evidence_links }) => evidence_links), @@ -277,13 +343,18 @@ const comparisonObservation = ( const dimension = result.dimensions.find( (item) => item.dimension === claim.dimension, ); - if (dimension === undefined) - throw new TypeError("Function comparison dimension missing"); + if (dimension === undefined) { + const message = "Function comparison dimension missing"; + throw new EvidenceIntegrityError(message, { userMessage: message }); + } return { status: dimension.status, limitations: dimension.limitations }; } - const result = artifactComparisonResultSchema.parse( + const parsed = readEvidencePayload( + artifactComparisonResultSchema, evidence.normalized_result, ); + if (!parsed.ok) throw parsed.error; + const result = parsed.value; assertComparisonFindingLinks( evidence, result.changes.flatMap(({ evidence_links }) => evidence_links), @@ -291,6 +362,31 @@ const comparisonObservation = ( return { status: result.status, limitations: [] }; }; +const readOwnedEvidenceBundle = (input: unknown) => { + try { + return parseEvidenceBundle(input); + } catch (cause: unknown) { + if ( + !(cause instanceof z.ZodError) && + !(cause instanceof DuplicateEvidenceRecordError) && + !(cause instanceof InvalidResidualUnknownGraphError) && + !(cause instanceof NonCanonicalEvidenceBundleError) + ) + throw cause; + const message = cause.message; + throw new EvidenceIntegrityError(message, { cause, userMessage: message }); + } +}; + +const missingSourceInputError = (claimIndex: number, message: string) => + new AnalysisInputError("verify_reconstruction", { userMessage: message }, [ + { + path: ["claims", claimIndex, "comparison_evidence_id"], + reason: "invalid_value", + message, + }, + ]); + const authorityLimitations = ( claim: Claim, sources: readonly Evidence[], diff --git a/src/domain/staticRuntimeCorrelation.test.ts b/src/domain/staticRuntimeCorrelation.test.ts index 83051af7e6..0b457315a2 100644 --- a/src/domain/staticRuntimeCorrelation.test.ts +++ b/src/domain/staticRuntimeCorrelation.test.ts @@ -1,11 +1,11 @@ import { describe, expect, it } from "vitest"; - import { INVESTIGATION_EXAMPLES } from "../contracts/investigationExamples.js"; import { createEvidence, type Evidence } from "./evidence.js"; import { jsonValueSchema, type JsonValue } from "./jsonValue.js"; import { FUNCTION_COMPARISON_EXAMPLE } from "../contracts/functionComparisonExample.js"; import { functionDossierSchema } from "./hopperValues.js"; import { compareFunctions } from "./functionComparison.js"; +import { functionSnapshotFixture } from "./functionDossierEvidence.fixture.js"; import { correlateStaticAndRuntime, staticRuntimeCorrelationInputSchema, @@ -132,8 +132,12 @@ it("keeps text, assembly and unavailable CFG caveats on their selected correlati authority: source.authority, }, ); + const right = FUNCTION_COMPARISON_EXAMPLE.right; - const computed = compareFunctions(left, right); + const computed = compareFunctions( + functionSnapshotFixture(left), + functionSnapshotFixture(right), + ); const comparison = createEvidence(undefined, staticComparison.provider, { predicateType: "rea.function-comparison", operation: "compare_functions", @@ -194,6 +198,60 @@ describe("static/runtime correlation", () => { ).toBe(false); }); + it("rejects duplicate static comparison selection", () => { + const runtime = processComparison("changed"); + const base = mapping(runtime, "cochanged"); + expect(() => + correlateStaticAndRuntime({ + ...base, + static_comparisons: [staticComparison, staticComparison], + }), + ).toThrowError( + expect.objectContaining({ + _tag: "AnalysisInputError", + issues: [expect.objectContaining({ path: ["static_comparisons", 1] })], + }), + ); + }); + + it("rejects a static comparison with an unsupported operation", () => { + const runtime = processComparison("changed"); + const base = mapping(runtime, "cochanged"); + expect(() => + correlateStaticAndRuntime({ + ...base, + static_comparisons: [leftCapture], + }), + ).toThrowError( + expect.objectContaining({ + _tag: "AnalysisInputError", + issues: [expect.objectContaining({ path: ["static_comparisons", 0] })], + }), + ); + }); + + it("classifies malformed source Evidence as an integrity failure", () => { + const runtime = processComparison("changed"); + const base = mapping(runtime, "cochanged"); + const malformedEvidence = { + ...staticComparison, + evidence_id: "ev_invalid", + }; + expect(() => + correlateStaticAndRuntime({ + ...base, + static_comparisons: [malformedEvidence], + }), + ).toThrowError( + expect.objectContaining({ + _tag: "EvidenceIntegrityError", + cause: expect.any(Error), + }), + ); + }); +}); + +describe("static/runtime correlation outcomes", () => { it("keeps consistent cochange as a hypothesis with complete citations", () => { const runtime = processComparison("changed"); const result = correlateStaticAndRuntime(mapping(runtime, "cochanged")); @@ -257,7 +315,9 @@ describe("static/runtime correlation", () => { correlations: { items: [{ observed_pattern: "truncated" }] }, }); }); +}); +describe("static/runtime correlation selection and Evidence validation", () => { it("returns all correlations order-stably and rejects duplicates and absent selectors", () => { const runtime = processComparison("changed"); const base = mapping(runtime, "cochanged"); @@ -284,7 +344,14 @@ describe("static/runtime correlation", () => { ...base, mappings: [base.mappings[0], base.mappings[0]], }), - ).toThrow(/duplicate mappings/u); + ).toThrowError( + expect.objectContaining({ + _tag: "AnalysisInputError", + operation: "correlate_static_and_runtime", + userMessage: expect.stringContaining("duplicate mappings"), + issues: [expect.objectContaining({ path: ["mappings", 1] })], + }), + ); expect(() => correlateStaticAndRuntime({ ...base, @@ -312,7 +379,40 @@ describe("static/runtime correlation", () => { }, ], }), - ).toThrow(/absent static comparison/u); + ).toThrowError( + expect.objectContaining({ + _tag: "AnalysisInputError", + userMessage: expect.stringContaining("absent static comparison"), + issues: [ + expect.objectContaining({ + path: ["mappings", 0, "static", "comparison_evidence_id"], + }), + ], + }), + ); + expect(() => + correlateStaticAndRuntime({ + ...base, + mappings: [ + { + ...base.mappings[0], + runtime: { + ...base.mappings[0]!.runtime, + comparison_evidence_id: staticComparison.evidence_id, + }, + }, + ], + }), + ).toThrowError( + expect.objectContaining({ + _tag: "AnalysisInputError", + issues: [ + expect.objectContaining({ + path: ["mappings", 0, "runtime", "comparison_evidence_id"], + }), + ], + }), + ); }); it("rejects comparison citations that disagree with source parameters", () => { @@ -330,8 +430,37 @@ describe("static/runtime correlation", () => { evidenceLinks: runtime.evidence_links, }); const input = mapping(forged, "cochanged"); - expect(() => correlateStaticAndRuntime(input)).toThrow( - /closure disagrees with its source parameters/u, + expect(() => correlateStaticAndRuntime(input)).toThrowError( + expect.toSatisfy( + (error: { _tag?: unknown; userMessage?: unknown; cause?: unknown }) => + error._tag === "EvidenceIntegrityError" && + typeof error.userMessage === "string" && + error.userMessage.includes( + "closure disagrees with its source parameters", + ) && + error.cause === undefined, + ), + ); + }); + + it("retains invalid authenticated result payloads as Evidence failures", () => { + const valid = processComparison("changed"); + const malformed = createEvidence(undefined, valid.provider, { + predicateType: valid.predicate_type, + operation: valid.operation, + parameters: valid.parameters, + result: { status: "changed" }, + confidence: valid.confidence, + authority: valid.authority, + evidenceLinks: valid.evidence_links, + }); + expect(() => + correlateStaticAndRuntime(mapping(malformed, "cochanged")), + ).toThrowError( + expect.objectContaining({ + _tag: "EvidenceIntegrityError", + cause: expect.any(Error), + }), ); }); }); diff --git a/src/domain/staticRuntimeCorrelation.ts b/src/domain/staticRuntimeCorrelation.ts index 0bde9ea115..db82a04c8f 100644 --- a/src/domain/staticRuntimeCorrelation.ts +++ b/src/domain/staticRuntimeCorrelation.ts @@ -2,13 +2,17 @@ import { digestCanonicalValue } from "./canonicalDigest.js"; import { z } from "zod"; import { artifactComparisonResultSchema } from "./artifactComparison.js"; +import { AnalysisInputError } from "./analysisErrorCore.js"; +import { EvidenceIntegrityError } from "./evidenceErrors.js"; +import { analysisInputErrorFromIssues } from "./inputIssueProjection.js"; +import { readEvidence, readEvidencePayload } from "./evidenceValidation.js"; import { uniqueSorted } from "./canonicalOrdering.js"; import { compareUnicodeCodePoints } from "./unicodeCodePointOrder.js"; import { comparisonSourceEvidenceIds, assertComparisonFindingLinks, } from "./comparisonSourceEvidence.js"; -import { evidenceSchema, parseEvidence, type Evidence } from "./evidence.js"; +import { evidenceSchema, type Evidence } from "./evidence.js"; import { hasComparisonEvidenceIdentity } from "./comparisonEvidenceIdentity.js"; import { functionComparisonResultSchema } from "./functionComparisonSchemas.js"; import type { JsonValue } from "./jsonValue.js"; @@ -89,6 +93,14 @@ export const staticRuntimeCorrelationInputSchema = z mappings: z.array(mappingSchema).min(1), }) .strict(); +const staticRuntimeCorrelationEnvelopeSchema = + staticRuntimeCorrelationInputSchema + .omit({ static_comparisons: true, runtime_comparisons: true }) + .extend({ + static_comparisons: z.array(z.unknown()).min(1), + runtime_comparisons: z.array(z.unknown()).min(1), + }) + .strict(); const correlationItemSchema = z.object({ correlation_id: prefixedDigestSchema("cor"), @@ -139,30 +151,69 @@ export type StaticRuntimeCorrelationResult = z.infer< type Mapping = z.infer; type CorrelationItem = z.infer; type ComparisonStatus = z.infer; +type StaticComparison = + | { + readonly operation: "compare_artifacts"; + readonly evidence: Evidence; + readonly result: z.infer; + } + | { + readonly operation: "compare_functions"; + readonly evidence: Evidence; + readonly result: z.infer; + }; +interface RuntimeComparison { + readonly evidence: Evidence; + readonly result: z.infer; +} /** Correlate only caller-declared static/runtime hypotheses. */ export const correlateStaticAndRuntime = ( input: unknown, ): StaticRuntimeCorrelationResult => { - const parsed = staticRuntimeCorrelationInputSchema.parse(input); - const staticEvidence = parsed.static_comparisons.map(parseStatic); - const runtimeEvidence = parsed.runtime_comparisons.map(parseRuntime); - assertUnique(staticEvidence, "static comparison Evidence"); - assertUnique(runtimeEvidence, "runtime comparison Evidence"); + const parsed = staticRuntimeCorrelationEnvelopeSchema.safeParse(input); + if (!parsed.success) + throw inputSchemaError( + input, + parsed.error, + "Provide valid static and runtime comparison Evidence and mappings.", + ); + const { data } = parsed; + const staticEvidence = data.static_comparisons.map((item, index) => + parseStatic(item, input, index), + ); + const runtimeEvidence = data.runtime_comparisons.map((item, index) => + parseRuntime(item, input, index), + ); + assertUnique( + staticEvidence.map(({ evidence }) => evidence.evidence_id), + input, + ["static_comparisons"], + "Static/runtime correlation rejects duplicate static comparison Evidence", + ); + assertUnique( + runtimeEvidence.map(({ evidence }) => evidence.evidence_id), + input, + ["runtime_comparisons"], + "Static/runtime correlation rejects duplicate runtime comparison Evidence", + ); const staticById = byId(staticEvidence); const runtimeById = byId(runtimeEvidence); - const correlations = parsed.mappings - .map((mapping) => correlate(mapping, staticById, runtimeById)) - .sort((left, right) => - compareUnicodeCodePoints(left.correlation_id, right.correlation_id), - ); - if ( - new Set(correlations.map(({ correlation_id: id }) => id)).size !== - correlations.length - ) - throw new TypeError( + const correlations = data.mappings.map((mapping, index) => + correlate(mapping, staticById, runtimeById, input, index), + ); + const duplicateMappingIndex = firstDuplicateIndex( + correlations.map(({ correlation_id }) => correlation_id), + ); + if (duplicateMappingIndex !== undefined) + throw callerInputError( + input, + ["mappings", duplicateMappingIndex], "Static/runtime correlation rejects duplicate mappings", ); + correlations.sort((left, right) => + compareUnicodeCodePoints(left.correlation_id, right.correlation_id), + ); const links = uniqueSorted( correlations.flatMap(({ evidence_links: evidenceLinks }) => evidenceLinks), ); @@ -201,26 +252,32 @@ export const correlateStaticAndRuntime = ( const correlate = ( mapping: Mapping, - staticById: ReadonlyMap, - runtimeById: ReadonlyMap, + staticById: ReadonlyMap, + runtimeById: ReadonlyMap, + input: unknown, + mappingIndex: number, ): CorrelationItem => { const staticEvidence = required( staticById, mapping.static.comparison_evidence_id, "static comparison", + input, + ["mappings", mappingIndex, "static", "comparison_evidence_id"], ); const runtimeEvidence = required( runtimeById, mapping.runtime.comparison_evidence_id, "runtime comparison", + input, + ["mappings", mappingIndex, "runtime", "comparison_evidence_id"], ); const { limitations: selectedLimitations, ...selectedStatic } = selectStatic( staticEvidence, mapping.static.selector, + input, + ["mappings", mappingIndex, "static", "selector"], ); - const process = processCaptureComparisonSchema.parse( - runtimeEvidence.normalized_result, - ); + const process = runtimeEvidence.result; const runtimeStatus = process[mapping.runtime.dimension]; const pattern = observedPattern(selectedStatic.status, runtimeStatus); const classification = @@ -244,15 +301,15 @@ const correlate = ( observed_pattern: pattern, classification, evidence_links: uniqueSorted([ - staticEvidence.evidence_id, - ...staticEvidence.evidence_links, - runtimeEvidence.evidence_id, - ...runtimeEvidence.evidence_links, + staticEvidence.evidence.evidence_id, + ...staticEvidence.evidence.evidence_links, + runtimeEvidence.evidence.evidence_id, + ...runtimeEvidence.evidence.evidence_links, ]), limitations: uniqueSorted([ - ...staticEvidence.limitations, + ...staticEvidence.evidence.limitations, ...selectedLimitations, - ...runtimeEvidence.limitations, + ...runtimeEvidence.evidence.limitations, ...process.limitations, "This correlation tests an explicit hypothesis and does not establish causality.", "Side alignment is caller-declared and was not independently validated against source subjects or environments.", @@ -261,44 +318,52 @@ const correlate = ( }; const selectStatic = ( - evidence: Evidence, + comparison: StaticComparison, selector: z.infer, + input: unknown, + path: readonly (string | number)[], ): Pick & { readonly limitations: readonly string[]; } => { if (selector.kind === "function") { - if (evidence.operation !== "compare_functions") - throw new TypeError( + if (comparison.operation !== "compare_functions") + throw callerInputError( + input, + path, "Function selector requires function comparison Evidence", ); - const result = functionComparisonResultSchema.parse( - evidence.normalized_result, - ); + const result = comparison.result; const matches = result.dimensions.filter( ({ dimension }) => dimension === selector.dimension, ); const match = matches.at(0); if (matches.length !== 1 || match === undefined) - throw new TypeError("Function selector must resolve exactly once"); + throw callerInputError( + input, + path, + "Function selector must resolve exactly once", + ); return { status: match.status, conclusion_kind: match.conclusion_kind, limitations: match.limitations, }; } - if (evidence.operation !== "compare_artifacts") - throw new TypeError( + if (comparison.operation !== "compare_artifacts") + throw callerInputError( + input, + path, "Artifact selector requires artifact comparison Evidence", ); - const result = artifactComparisonResultSchema.parse( - evidence.normalized_result, - ); + const result = comparison.result; const matches = result.changes.filter( ({ logical_path: path }) => path === selector.logical_path, ); const match = matches.at(0); if (matches.length !== 1 || match === undefined) - throw new TypeError( + throw callerInputError( + input, + path, "Artifact selector must resolve exactly one reported delta", ); const status = match.classification; @@ -314,50 +379,75 @@ const selectStatic = ( }; }; -const parseStatic = (input: unknown): Evidence => { - const evidence = parseEvidence(input); +const parseStatic = ( + raw: unknown, + fullInput: unknown, + index: number, +): StaticComparison => { + const parsed = readEvidence(raw); + if (!parsed.ok) throw parsed.error; + const evidence = parsed.value; if ( evidence.operation !== "compare_artifacts" && evidence.operation !== "compare_functions" ) - throw new TypeError( + throw callerInputError( + fullInput, + ["static_comparisons", index], "Static/runtime correlation requires artifact or function comparison Evidence", ); assertComparisonIdentity(evidence); if (evidence.operation === "compare_artifacts") { - const result = artifactComparisonResultSchema.parse( + const result = readEvidencePayload( + artifactComparisonResultSchema, evidence.normalized_result, ); + if (!result.ok) throw result.error; assertComparisonFindingLinks( evidence, - result.changes.flatMap(({ evidence_links: links }) => links), + result.value.changes.flatMap(({ evidence_links: links }) => links), ); + return { operation: evidence.operation, evidence, result: result.value }; } else { - const result = functionComparisonResultSchema.parse( + const result = readEvidencePayload( + functionComparisonResultSchema, evidence.normalized_result, ); + if (!result.ok) throw result.error; assertComparisonFindingLinks( evidence, - result.dimensions.flatMap(({ evidence_links: links }) => links), + result.value.dimensions.flatMap(({ evidence_links: links }) => links), ); + return { operation: evidence.operation, evidence, result: result.value }; } - return evidence; }; -const parseRuntime = (input: unknown): Evidence => { - const evidence = parseEvidence(input); +const parseRuntime = ( + raw: unknown, + fullInput: unknown, + index: number, +): RuntimeComparison => { + const parsed = readEvidence(raw); + if (!parsed.ok) throw parsed.error; + const evidence = parsed.value; if (evidence.operation !== "compare_process_captures") - throw new TypeError( + throw callerInputError( + fullInput, + ["runtime_comparisons", index], "Static/runtime correlation requires process comparison Evidence", ); assertComparisonIdentity(evidence); - processCaptureComparisonSchema.parse(evidence.normalized_result); - return evidence; + const result = readEvidencePayload( + processCaptureComparisonSchema, + evidence.normalized_result, + ); + if (!result.ok) throw result.error; + return { evidence, result: result.value }; }; const assertComparisonIdentity = (evidence: Evidence): void => { if (!hasComparisonEvidenceIdentity(evidence)) - throw new TypeError( + throw evidenceIntegrityError( "Comparison Evidence operation, predicate, provider, or citations disagree", ); const expectedLinks = comparisonSourceEvidenceIds(evidence); @@ -366,7 +456,7 @@ const assertComparisonIdentity = (evidence: Evidence): void => { expectedLinks.length !== actualLinks.length || expectedLinks.some((id, index) => id !== actualLinks[index]) ) - throw new TypeError( + throw evidenceIntegrityError( "Comparison Evidence closure disagrees with its source parameters", ); }; @@ -387,25 +477,83 @@ const observedPattern = ( return "both_unchanged"; }; -const required = ( - values: ReadonlyMap, +const required = ( + values: ReadonlyMap, id: string, label: string, -): Evidence => { + input: unknown, + path: readonly (string | number)[], +): Comparison => { const value = values.get(id); if (value === undefined) - throw new TypeError(`Mapping references absent ${label} Evidence`); + throw callerInputError( + input, + path, + `Mapping references absent ${label} Evidence`, + ); return value; }; -const byId = (evidence: readonly Evidence[]): Map => - new Map(evidence.map((item) => [item.evidence_id, item])); +const byId = ( + comparisons: readonly Comparison[], +): Map => + new Map(comparisons.map((item) => [item.evidence.evidence_id, item])); -const assertUnique = (evidence: readonly Evidence[], label: string): void => { - if ( - new Set(evidence.map(({ evidence_id: id }) => id)).size !== evidence.length - ) - throw new TypeError( - `Static/runtime correlation rejects duplicate ${label}`, - ); +const assertUnique = ( + values: readonly string[], + input: unknown, + path: readonly (string | number)[], + message: string, +): void => { + const duplicateIndex = firstDuplicateIndex(values); + if (duplicateIndex !== undefined) + throw callerInputError(input, [...path, duplicateIndex], message); }; + +const firstDuplicateIndex = (values: readonly string[]): number | undefined => { + const seen = new Set(); + const index = values.findIndex((value) => { + if (seen.has(value)) return true; + seen.add(value); + return false; + }); + return index < 0 ? undefined : index; +}; + +const OPERATION = "correlate_static_and_runtime"; + +const inputSchemaError = ( + input: unknown, + cause: z.ZodError, + userMessage: string, +): AnalysisInputError => { + const projected = analysisInputErrorFromIssues( + OPERATION, + cause.issues, + input, + { cause }, + ); + return new AnalysisInputError( + OPERATION, + { cause, userMessage }, + projected.issues, + ); +}; + +const callerInputError = ( + input: unknown, + path: readonly (string | number)[], + message: string, +): AnalysisInputError => { + const projected = analysisInputErrorFromIssues(OPERATION, [], input, { + additionalIssues: [{ path, reason: "invalid_value", message }], + }); + return new AnalysisInputError( + OPERATION, + { userMessage: message }, + projected.issues, + ); +}; + +const evidenceIntegrityError = (message: string): EvidenceIntegrityError => + new EvidenceIntegrityError(message, { userMessage: message }); diff --git a/src/domain/validationFailure.ts b/src/domain/validationFailure.ts new file mode 100644 index 0000000000..4b3e6b301d --- /dev/null +++ b/src/domain/validationFailure.ts @@ -0,0 +1,13 @@ +import { z } from "zod"; + +/** Locate the first schema issue, or keep a thrown constraint message. */ +export const describeValidationFailure = (cause: unknown): string => { + if (!(cause instanceof z.ZodError)) + return cause instanceof Error ? cause.message : String(cause); + const [first, ...rest] = cause.issues; + const location = + first === undefined || first.path.length === 0 + ? "the document root" + : first.path.map(String).join("."); + return `at ${location}: ${first?.message ?? "invalid value"}${rest.length === 0 ? "" : ` (${String(rest.length)} more issues)`}`; +}; diff --git a/src/server/promptCompletion.ts b/src/server/promptCompletion.ts index b10bae84c1..f23b30ce03 100644 --- a/src/server/promptCompletion.ts +++ b/src/server/promptCompletion.ts @@ -205,12 +205,7 @@ const artifactInventoryValues = ( ? [inventory.manifest.manifest_id] : inventory.occurrences.map(({ occurrence_id: id }) => id); } catch (cause: unknown) { - if ( - cause instanceof TypeError || - cause instanceof z.ZodError || - cause instanceof EvidenceIntegrityError - ) - return []; + if (cause instanceof EvidenceIntegrityError) return []; throw cause; } }; @@ -224,11 +219,7 @@ const isProcessCaptureEvidence = (evidence: LedgerEvidence): boolean => { return false; case "invalid-evidence": case "invalid-capture": - if ( - parsed.error.cause instanceof TypeError || - parsed.error.cause instanceof z.ZodError - ) - return false; + if (parsed.error.cause instanceof z.ZodError) return false; throw parsed.error.cause; } }; diff --git a/src/server/registerArtifactComparisonTool.ts b/src/server/registerArtifactComparisonTool.ts index 9f62daec11..dcb8501af1 100644 --- a/src/server/registerArtifactComparisonTool.ts +++ b/src/server/registerArtifactComparisonTool.ts @@ -4,7 +4,8 @@ import type { BinarySessionPort } from "../application/binary/BinarySessionPort. import { toolContract } from "../contracts/toolContracts.js"; import { compareArtifacts } from "../domain/artifactComparison.js"; import { ARTIFACT_COMPARISON_PROVIDER } from "../domain/comparisonEvidenceIdentity.js"; -import { createEvidence, parseEvidence } from "../domain/evidence.js"; +import { createEvidence } from "../domain/evidence.js"; +import { readEvidence } from "../domain/evidenceValidation.js"; import { jsonValueSchema } from "../domain/jsonValue.js"; import { resolveArtifactComparisonEvidence } from "../application/artifacts/ArtifactComparisonEvidence.js"; import type { RecordUnknownInput } from "../domain/residualUnknown.js"; @@ -39,15 +40,17 @@ export const registerArtifactComparisonTool = ( context, contract.name, () => { - const suppliedLeft = parseEvidence(input.left); - const suppliedRight = parseEvidence(input.right); + const suppliedLeft = readEvidence(input.left); + if (!suppliedLeft.ok) throw suppliedLeft.error; + const suppliedRight = readEvidence(input.right); + if (!suppliedRight.ok) throw suppliedRight.error; const resolvedLeft = resolveArtifactComparisonEvidence( - suppliedLeft, + suppliedLeft.value, evidenceById, ); if (!resolvedLeft.ok) throw resolvedLeft.error; const resolvedRight = resolveArtifactComparisonEvidence( - suppliedRight, + suppliedRight.value, evidenceById, ); if (!resolvedRight.ok) throw resolvedRight.error; diff --git a/src/server/registerFunctionComparisonTool.ts b/src/server/registerFunctionComparisonTool.ts index c20eb92c08..eabd80f2d4 100644 --- a/src/server/registerFunctionComparisonTool.ts +++ b/src/server/registerFunctionComparisonTool.ts @@ -3,14 +3,9 @@ import type { EvidenceMcpServer } from "./EvidenceMcpServer.js"; import type { BinarySessionPort } from "../application/binary/BinarySessionPort.js"; import { toolContract } from "../contracts/toolContracts.js"; -import { - createEvidence, - parseEvidence, - type Evidence, -} from "../domain/evidence.js"; +import { createEvidence } from "../domain/evidence.js"; +import { parseFunctionEvidence } from "../domain/functionDossierEvidence.js"; import { FUNCTION_COMPARISON_PROVIDER } from "../domain/comparisonEvidenceIdentity.js"; -import { EvidenceIntegrityError } from "../domain/evidenceErrors.js"; -import { err } from "../domain/result.js"; import { jsonValueSchema } from "../domain/jsonValue.js"; import type { RecordUnknownInput } from "../domain/residualUnknown.js"; import { recordDerivedEvidence } from "./recordDerivedEvidence.js"; @@ -46,45 +41,34 @@ export const registerFunctionComparisonTool = ( ); if (!resolved.ok) return server.delivery.toCallToolResult(resolved, contract); - let leftEvidence: Evidence; - let rightEvidence: Evidence; - try { - leftEvidence = parseEvidence(resolved.value.left); - rightEvidence = parseEvidence(resolved.value.right); - } catch (cause: unknown) { - return server.delivery.toCallToolResult( - err( - new EvidenceIntegrityError( - cause instanceof Error ? cause.message : "Invalid Evidence", - ), - ), - contract, - ); - } - if ( - leftEvidence.operation !== "analyze_function" || - rightEvidence.operation !== "analyze_function" || - leftEvidence.predicate_type !== "rea.analysis" || - rightEvidence.predicate_type !== "rea.analysis" - ) - return server.delivery.toCallToolResult( - err( - new EvidenceIntegrityError( - "Expected analyze_function Evidence", - ), - ), - contract, - ); - const leftIds = [leftEvidence.evidence_id]; - const rightIds = [rightEvidence.evidence_id]; const computed = await runDerivedOperation( context, contract.name, - () => compareFunctions(leftEvidence, rightEvidence), + () => { + const left = parseFunctionEvidence(resolved.value.left, { + operation: contract.name, + path: ["left"], + }); + if (!left.ok) throw left.error; + const right = parseFunctionEvidence(resolved.value.right, { + operation: contract.name, + path: ["right"], + }); + if (!right.ok) throw right.error; + return { + left: left.value, + right: right.value, + comparison: compareFunctions(left.value, right.value), + }; + }, ); if (!computed.ok) return server.delivery.toCallToolResult(computed, contract); - const comparison = computed.value; + const { left, right, comparison } = computed.value; + const leftEvidence = left.evidence; + const rightEvidence = right.evidence; + const leftIds = [leftEvidence.evidence_id]; + const rightIds = [rightEvidence.evidence_id]; const recordedSources = recordSessionEvidenceSources( (evidence) => session.recordEvidence(evidence), [leftEvidence, rightEvidence], diff --git a/src/server/registerInvestigationTools.ts b/src/server/registerInvestigationTools.ts index 234e60e043..0329d991f7 100644 --- a/src/server/registerInvestigationTools.ts +++ b/src/server/registerInvestigationTools.ts @@ -10,7 +10,6 @@ import { correlateStaticAndRuntime } from "../domain/staticRuntimeCorrelation.js import { comparisonClosure, evidenceClosure, - functionEvidenceIds, isIncomplete, recordWorkflowEvidence, } from "./registerInvestigationTools/helpers.js"; @@ -139,7 +138,7 @@ const registerCallPath = ( async () => { const closure = evidenceClosure( session, - functionEvidenceIds(input.functions), + input.functions.map(({ evidence_id }) => evidence_id), ); if (!closure.ok) return server.delivery.toCallToolResult(closure, contract); diff --git a/src/server/registerInvestigationTools/helpers.ts b/src/server/registerInvestigationTools/helpers.ts index 556225d55a..df3ac3efd4 100644 --- a/src/server/registerInvestigationTools/helpers.ts +++ b/src/server/registerInvestigationTools/helpers.ts @@ -19,9 +19,6 @@ export const comparisonClosure = (comparisons: readonly Evidence[]): string[] => ]), ); -export const functionEvidenceIds = (records: readonly Evidence[]): string[] => - uniqueIds(records.map(({ evidence_id }) => evidence_id)); - const uniqueIds = (ids: readonly string[]): string[] => [...new Set(ids)].sort((left, right) => compareUnicodeCodePoints(left, right), diff --git a/src/server/registerProcessComparisonTool.ts b/src/server/registerProcessComparisonTool.ts index d27ab9d81f..f720211ac3 100644 --- a/src/server/registerProcessComparisonTool.ts +++ b/src/server/registerProcessComparisonTool.ts @@ -4,7 +4,7 @@ import type { BinarySessionPort } from "../application/binary/BinarySessionPort. import { toolContract } from "../contracts/toolContracts.js"; import { AnalysisInputError } from "../domain/analysisErrorCore.js"; import type { AnalysisError } from "../domain/analysisErrorBase.js"; -import { describeValidationFailure } from "../domain/evidenceBundle.js"; +import { describeValidationFailure } from "../domain/validationFailure.js"; import { EvidenceIntegrityError } from "../domain/evidenceErrors.js"; import { createEvidence, diff --git a/src/server/runDerivedOperation.test.ts b/src/server/runDerivedOperation.test.ts index b2ac6258b7..8ee6308632 100644 --- a/src/server/runDerivedOperation.test.ts +++ b/src/server/runDerivedOperation.test.ts @@ -147,30 +147,23 @@ describe("derived MCP operation boundary", () => { ).toBe(true); }); - it("projects synchronous computation failures into a typed result", async () => { - const result = await runDerivedOperation( - context(new AbortController()), - "compare_artifacts", - () => { - throw new TypeError("malformed comparison evidence"); - }, - ); - - expect(result).toMatchObject({ - ok: false, - error: { - _tag: "AnalysisInputError", - operation: "compare_artifacts", - issues: [ - { - path: [], - reason: "invalid_value", - message: "malformed comparison evidence", + it.each([ + new TypeError("internal type failure"), + new Error("internal computation failure"), + ])( + "preserves an untagged computation failure without calling it invalid input: $name", + async (cause) => { + await expect( + runDerivedOperation( + context(new AbortController()), + "compare_artifacts", + () => { + throw cause; }, - ], - }, - }); - }); + ), + ).rejects.toBe(cause); + }, + ); it("preserves a tagged integrity failure instead of replacing it with invalid input", async () => { const failure = new EvidenceIntegrityError( diff --git a/src/server/runDerivedOperation.ts b/src/server/runDerivedOperation.ts index 6d2eaffd8e..f984e81ea5 100644 --- a/src/server/runDerivedOperation.ts +++ b/src/server/runDerivedOperation.ts @@ -1,13 +1,8 @@ import type { ServerContext } from "@modelcontextprotocol/server"; import { setImmediate } from "node:timers/promises"; -import { z } from "zod"; import { AnalysisError } from "../domain/analysisErrorBase.js"; -import { - AnalysisCancelledError, - AnalysisInputError, -} from "../domain/analysisErrorCore.js"; -import { describeValidationFailure } from "../domain/evidenceBundle.js"; +import { AnalysisCancelledError } from "../domain/analysisErrorCore.js"; import { err, ok, type Result } from "../domain/result.js"; import { mcpProgressReporter, type McpProgressContext } from "./mcpProgress.js"; @@ -42,21 +37,7 @@ export const runDerivedOperation = async ( } catch (cause: unknown) { // Typed causes already name the failed constraint; keep their issues. if (cause instanceof AnalysisError) return err(cause); - return err( - new AnalysisInputError( - operation, - { cause }, - cause instanceof TypeError || cause instanceof z.ZodError - ? [ - { - path: [], - reason: "invalid_value", - message: describeValidationFailure(cause), - }, - ] - : [], - ), - ); + throw cause; } await progress.report({ phase: "compute", diff --git a/tests/boundary/filesystem/artifactComparison.test.ts b/tests/boundary/filesystem/artifactComparison.test.ts index b29d4aef6f..a34cb46758 100644 --- a/tests/boundary/filesystem/artifactComparison.test.ts +++ b/tests/boundary/filesystem/artifactComparison.test.ts @@ -830,7 +830,13 @@ describe("artifact comparison fragment integrity", () => { [conflictingScope, ...leftFragments.slice(1)], rightFragments, ), - ).toThrow(/coverage declaration/u); + ).toThrowError( + expect.objectContaining({ + _tag: "AnalysisInputError", + userMessage: expect.stringContaining("coverage declaration"), + issues: [expect.objectContaining({ path: ["left"] })], + }), + ); }, 15_000); it("rejects non-inventory and tampered Evidence", async () => { @@ -844,8 +850,12 @@ describe("artifact comparison fragment integrity", () => { parameters: {}, result: evidence.normalized_result, }); - expect(() => compareArtifacts(wrongOperation, evidence)).toThrow( - /inspect_artifact/u, + expect(() => compareArtifacts(wrongOperation, evidence)).toThrowError( + expect.objectContaining({ + _tag: "AnalysisInputError", + userMessage: expect.stringContaining("inspect_artifact"), + issues: [expect.objectContaining({ path: ["left"] })], + }), ); const mismatchedSubject = createEvidence( { path: root, sha256: "f".repeat(64), format: "directory" }, diff --git a/tests/boundary/mcp/functionComparisonMcp.test.ts b/tests/boundary/mcp/functionComparisonMcp.test.ts index 89b3f14a6a..d44031898b 100644 --- a/tests/boundary/mcp/functionComparisonMcp.test.ts +++ b/tests/boundary/mcp/functionComparisonMcp.test.ts @@ -1,3 +1,4 @@ +import { functionSnapshotFixture } from "../../../src/domain/functionDossierEvidence.fixture.js"; import { ok as resultOk } from "../../../src/domain/result.js"; import { Client, InMemoryTransport } from "@modelcontextprotocol/client"; import { describe, expect, it, onTestFinished } from "vitest"; @@ -338,7 +339,10 @@ it.each([ relationshipCoverage: { status: "complete" }, }, ); - const result = compareFunctions(left, right); + const result = compareFunctions( + functionSnapshotFixture(left), + functionSnapshotFixture(right), + ); expect(result.status).toBe(status); const provider = { id: "rea-function-comparison", @@ -411,16 +415,11 @@ it.each([ if (inconsistent !== null) { const rejected = await verification(inconsistent.evidence_id); expect(parseMcpToolError(rejected).error).toMatchObject({ - code: "invalid_request", + code: "evidence_integrity_mismatch", details: { - issues: [ - { - reason: "invalid_value", - message: expect.stringContaining( - "Function comparison status contradicts its dimensions or function match", - ), - }, - ], + reason: expect.stringContaining( + "Function comparison status contradicts its dimensions or function match", + ), }, }); expect(harness.session.evidenceById(inconsistent.evidence_id)).toEqual( diff --git a/tests/boundary/mcp/processComparisonVerificationMcp.test.ts b/tests/boundary/mcp/processComparisonVerificationMcp.test.ts index 618bc33ae1..e906811d76 100644 --- a/tests/boundary/mcp/processComparisonVerificationMcp.test.ts +++ b/tests/boundary/mcp/processComparisonVerificationMcp.test.ts @@ -100,16 +100,11 @@ it("rejects an inconsistent imported process comparison while retaining its Evid for (const selected of ["overall", dimension] as const) { const rejected = await verify(inconsistent.evidence_id, selected); expect(parseMcpToolError(rejected).error).toMatchObject({ - code: "invalid_request", + code: "evidence_integrity_mismatch", details: { - issues: [ - { - reason: "invalid_value", - message: expect.stringContaining( - "Process comparison status contradicts its dimensions", - ), - }, - ], + reason: expect.stringContaining( + "Process comparison status contradicts its dimensions", + ), }, }); const failed = await verify(consistent.evidence_id, selected); diff --git a/tests/process-global/derivedOperationFailures.test.ts b/tests/process-global/derivedOperationFailures.test.ts new file mode 100644 index 0000000000..7a740ec630 --- /dev/null +++ b/tests/process-global/derivedOperationFailures.test.ts @@ -0,0 +1,216 @@ +import { InMemoryTransport, Client } from "@modelcontextprotocol/client"; +import { afterEach, describe, expect, it, vi } from "vitest"; +import { z } from "zod"; + +import { ARTIFACT_COMPARISON_EXAMPLE } from "../../src/contracts/artifactComparisonExample.js"; +import { FUNCTION_COMPARISON_EXAMPLE } from "../../src/contracts/functionComparisonExample.js"; +import { createEvidence } from "../../src/domain/evidence.js"; +import { functionComparisonResultSchema } from "../../src/domain/functionComparisonSchemas.js"; +import { createServer } from "../../src/server/createServer.js"; +import { createTestBinarySession } from "../fixtures/binarySession.js"; +import { parseMcpToolError } from "../fixtures/mcpToolError.js"; + +const invalidFunctionOperationEvidence = () => { + const left = FUNCTION_COMPARISON_EXAMPLE.left; + if (left.subject === null) throw new Error("Expected a function subject"); + return createEvidence( + { + path: "/tmp/wrong-function-operation", + sha256: left.subject.digest.sha256, + format: left.subject.format, + ...(left.subject.architecture === null + ? {} + : { architecture: left.subject.architecture }), + }, + left.provider, + { + predicateType: left.predicate_type, + operation: "inspect_analysis_view", + parameters: left.parameters, + result: left.normalized_result, + confidence: left.confidence, + authority: left.authority, + ...(left.relationship_coverage === null + ? {} + : { relationshipCoverage: left.relationship_coverage }), + }, + ); +}; + +const malformedFunctionResultEvidence = () => { + const left = FUNCTION_COMPARISON_EXAMPLE.left; + if (left.subject === null) throw new Error("Expected a function subject"); + return createEvidence( + { + path: "/tmp/malformed-function-result", + sha256: left.subject.digest.sha256, + format: left.subject.format, + ...(left.subject.architecture === null + ? {} + : { architecture: left.subject.architecture }), + }, + left.provider, + { + predicateType: left.predicate_type, + operation: "analyze_function", + parameters: left.parameters, + result: { malformed: true }, + confidence: left.confidence, + authority: left.authority, + ...(left.relationship_coverage === null + ? {} + : { relationshipCoverage: left.relationship_coverage }), + }, + ); +}; + +const withSdk = async ( + run: (input: { + readonly client: Client; + readonly session: ReturnType; + }) => Promise, +): Promise => { + const session = createTestBinarySession(() => { + throw new Error( + "These deterministic Evidence tools must not launch a provider", + ); + }); + const server = createServer({ kind: "session", session }); + const client = new Client({ + name: "derived-operation-failures", + version: "1", + }); + const [clientTransport, serverTransport] = + InMemoryTransport.createLinkedPair(); + try { + await server.connect(serverTransport); + await client.connect(clientTransport); + return await run({ client, session }); + } finally { + await Promise.allSettled([client.close(), server.close(), session.close()]); + } +}; + +describe("derived operation failures through the public SDK", () => { + afterEach(() => vi.restoreAllMocks()); + + it("keeps a wrong function operation as a caller constraint at left", async () => { + await withSdk(async ({ client, session }) => { + const response = await client.callTool({ + name: "compare_functions", + arguments: { + left: invalidFunctionOperationEvidence(), + right: FUNCTION_COMPARISON_EXAMPLE.right, + }, + }); + + expect(parseMcpToolError(response).error).toMatchObject({ + code: "invalid_request", + details: { + issues: [expect.objectContaining({ path: ["left"] })], + }, + }); + expect(session.exportEvidenceBundle().records).toEqual([]); + }); + }); + + it("keeps malformed analyze_function payloads as Evidence integrity failures", async () => { + await withSdk(async ({ client, session }) => { + const response = await client.callTool({ + name: "compare_functions", + arguments: { + left: malformedFunctionResultEvidence(), + right: FUNCTION_COMPARISON_EXAMPLE.right, + }, + }); + + expect(parseMcpToolError(response).error.code).toBe( + "evidence_integrity_mismatch", + ); + expect(session.exportEvidenceBundle().records).toEqual([]); + }); + }); + + it("classifies a duplicated call-path function at its second selection", async () => { + await withSdk(async ({ client, session }) => { + const source = FUNCTION_COMPARISON_EXAMPLE.left; + expect(session.recordEvidence(source).ok).toBe(true); + const before = session.exportEvidenceBundle(); + const response = await client.callTool({ + name: "build_call_path", + arguments: { + functions: [source, source], + start: { address: "0x1000" }, + goal: { address: "0x1001" }, + }, + }); + + expect(parseMcpToolError(response).error).toMatchObject({ + code: "invalid_request", + details: { + issues: [expect.objectContaining({ path: ["functions", 1] })], + }, + }); + expect(session.exportEvidenceBundle()).toEqual(before); + }); + }); + + it("classifies a non-inventory artifact carrier at left", async () => { + await withSdk(async ({ client, session }) => { + const response = await client.callTool({ + name: "compare_artifacts", + arguments: { + left: FUNCTION_COMPARISON_EXAMPLE.left, + right: ARTIFACT_COMPARISON_EXAMPLE.right, + }, + }); + + expect(parseMcpToolError(response).error).toMatchObject({ + code: "invalid_request", + details: { + issues: [expect.objectContaining({ path: ["left"] })], + }, + }); + expect(session.exportEvidenceBundle().records).toEqual([]); + }); + }); + + it.each([ + ["TypeError", () => new TypeError("injected result parser TypeError")], + ["Error", () => new Error("injected result parser Error")], + [ + "ZodError", + () => { + const result = z.string().safeParse(1); + if (result.success) throw new Error("Expected an invalid string parse"); + return result.error; + }, + ], + ])( + "preserves an unexpected %s from result validation", + async (_label, createFailure) => { + await withSdk(async ({ client, session }) => { + const failure = createFailure(); + vi.spyOn(functionComparisonResultSchema, "parse").mockImplementation( + () => { + throw failure; + }, + ); + const response = await client.callTool({ + name: "compare_functions", + arguments: { + left: FUNCTION_COMPARISON_EXAMPLE.left, + right: FUNCTION_COMPARISON_EXAMPLE.right, + }, + }); + + expect(response).toMatchObject({ + isError: true, + content: [{ type: "text", text: failure.message }], + }); + expect(response.structuredContent).toBeUndefined(); + expect(session.exportEvidenceBundle().records).toEqual([]); + }); + }, + ); +});