fix(evidence): classify derived failures at their owning boundary

Parse caller selections and authenticated Evidence at their owners, reuse the parsed function and comparison facts, and preserve unexpected implementation failures. Remove downstream error guessing and redundant validation wrappers.

Verify with 526 affected tests, source checks, docs conformance, test discovery and duplication checks. Seven public SDK regressions fail on the main baseline. No real-provider execution was performed.
This commit is contained in:
morluto
2026-10-23 03:21:26 +08:00
parent 7091be291c
commit 93a384db77
46 changed files with 1706 additions and 566 deletions
+1 -1
View File
@@ -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,
+24 -9
View File
@@ -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;
}
};
@@ -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";
@@ -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",
});
};
@@ -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",
});
};
@@ -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",
});
};
+1 -1
View File
@@ -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,
+3 -20
View File
@@ -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");
};
+16 -8
View File
@@ -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,
],
},
);
@@ -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<never, AnalysisError> => {
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));
};
+14 -3
View File
@@ -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 }) =>
+23 -3
View File
@@ -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)",
}),
],
});
}
});
});
+14 -3
View File
@@ -1,4 +1,5 @@
import { z } from "zod";
import { AnalysisInputError } from "../analysisErrorCore.js";
import {
anatomyNameIs,
@@ -237,9 +238,19 @@ const attributeComponents = <Entry extends { readonly path: string }>(
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;
};
+17 -2
View File
@@ -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<typeof artifactChangeSchema>;
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 =
+70 -3
View File
@@ -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);
});
+139 -42
View File
@@ -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<string, ArtifactInventoryResult["edges"][number]>();
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<string, number>();
@@ -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 = <Value>(
@@ -510,11 +603,15 @@ const mergeExact = <Value>(
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}`,
);
};
+4 -5
View File
@@ -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);
});
});
+72 -43
View File
@@ -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<typeof callPathEnvelopeSchema>) => {
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<string, FunctionSnapshot> => {
groups: readonly unknown[],
): {
readonly snapshots: Map<string, FunctionSnapshot>;
readonly graph: CallGraph;
} => {
const snapshots = new Map<string, FunctionSnapshot>();
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<string, FunctionSnapshot>,
): 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<string, number>([[start, 0]]);
const queue = [start];
const limitations: string[] = [];
+65 -12
View File
@@ -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,
),
);
});
});
+99 -24
View File
@@ -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<string>();
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 });
+12 -6
View File
@@ -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",
);
};
+1 -12
View File
@@ -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);
+32
View File
@@ -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<Evidence, EvidenceIntegrityError> => {
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 = <Output>(
schema: z.ZodType<Output>,
input: unknown,
): Result<Output, EvidenceIntegrityError> => {
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 });
};
+134 -85
View File
@@ -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<typeof withLiterals> | ReturnType<typeof dossier>,
) =>
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" });
});
+4 -6
View File
@@ -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") !==
@@ -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;
};
+40 -13
View File
@@ -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<FunctionSnapshot, AnalysisInputError | EvidenceIntegrityError> => {
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,
};
});
};
+14 -3
View File
@@ -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;
};
@@ -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<string, JsonValue>,
@@ -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<string, JsonValue>][])(
"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);
});
});
+51 -1
View File
@@ -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"],
}),
],
});
}
});
+127 -31
View File
@@ -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<string, Evidence>,
heads: ReadonlyMap<string, ResidualUnknown>,
): 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[],
+135 -6
View File
@@ -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),
}),
);
});
});
+216 -68
View File
@@ -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<typeof mappingSchema>;
type CorrelationItem = z.infer<typeof correlationItemSchema>;
type ComparisonStatus = z.infer<typeof comparisonStatusSchema>;
type StaticComparison =
| {
readonly operation: "compare_artifacts";
readonly evidence: Evidence;
readonly result: z.infer<typeof artifactComparisonResultSchema>;
}
| {
readonly operation: "compare_functions";
readonly evidence: Evidence;
readonly result: z.infer<typeof functionComparisonResultSchema>;
};
interface RuntimeComparison {
readonly evidence: Evidence;
readonly result: z.infer<typeof processCaptureComparisonSchema>;
}
/** 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<string, Evidence>,
runtimeById: ReadonlyMap<string, Evidence>,
staticById: ReadonlyMap<string, StaticComparison>,
runtimeById: ReadonlyMap<string, RuntimeComparison>,
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<typeof staticSelectorSchema>,
input: unknown,
path: readonly (string | number)[],
): Pick<CorrelationItem["static"], "status" | "conclusion_kind"> & {
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<string, Evidence>,
const required = <Comparison>(
values: ReadonlyMap<string, Comparison>,
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<string, Evidence> =>
new Map(evidence.map((item) => [item.evidence_id, item]));
const byId = <Comparison extends StaticComparison | RuntimeComparison>(
comparisons: readonly Comparison[],
): Map<string, Comparison> =>
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<string>();
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 });
+13
View File
@@ -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)`}`;
};
+2 -11
View File
@@ -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;
}
};
+8 -5
View File
@@ -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;
+24 -40
View File
@@ -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],
+1 -2
View File
@@ -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);
@@ -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),
+1 -1
View File
@@ -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,
+16 -23
View File
@@ -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(
+2 -21
View File
@@ -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 <Value>(
} 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",
@@ -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" },
@@ -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(
@@ -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);
@@ -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 <Value>(
run: (input: {
readonly client: Client;
readonly session: ReturnType<typeof createTestBinarySession>;
}) => Promise<Value>,
): Promise<Value> => {
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([]);
});
},
);
});