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([]); + }); + }, + ); +});