fix(artifacts): compare leaf inventories using explicit coverage

Classify coverage once at inventory production and require it in current outputs. Normalize older retained Evidence at the inventory parser, reject inconsistent page statuses, and remove comparison heuristics over limitation prose.

Validation: public CLI-to-MCP plist comparison, changed-behavior and unknowns workflow; boundary checks for legacy Evidence, partial coverage, page disagreement, content and executable changes; generated docs checks. Fixes #2173.
This commit is contained in:
morluto committed 2026-10-13 03:47:45 +08:00
1 parent 8d4d631606
commit ed485a3dc8
11 files changed
+379 -59

No files matched your search

+4 -4
View File
@@ -1,8 +1,8 @@
import { realpath } from "node:fs/promises";
import {
artifactInventoryResultSchema,
type ArtifactInventoryResult,
artifactInventoryOutputSchema,
type ArtifactInventoryOutput,
} from "../../domain/artifactGraph.js";
import { abortIfNeeded } from "../ArtifactHash.js";
import { scanCanonicalArtifactInventoryInScope } from "./scanCanonical.js";
@@ -13,9 +13,9 @@ import type { ArtifactInventorySnapshot } from "../../domain/artifactInventorySn
export const inventoryArtifact = async (
inputPath: string,
options: ArtifactInventoryOptions,
): Promise<ArtifactInventoryResult> => {
): Promise<ArtifactInventoryOutput> => {
const snapshot = await scanArtifactInventory(inputPath, options);
return artifactInventoryResultSchema.parse(snapshot);
return artifactInventoryOutputSchema.parse(snapshot);
};
/** Scan an artifact once and retain the complete immutable graph for projection. */
+40 -14
View File
@@ -4,7 +4,11 @@ import { DirectoryArtifactReader } from "../DirectoryArtifactReader.js";
import { MachOSliceArtifactReader } from "../MachOSliceArtifactReader.js";
import { NativeDmgArtifactReader } from "../NativeDmgArtifactReader.js";
import { ZipArtifactReader } from "../ZipArtifactReader.js";
import type { ArtifactOccurrence } from "../../domain/artifactGraph.js";
import type {
ArtifactInventoryCoverage,
ArtifactOccurrence,
IntegrityContradiction,
} from "../../domain/artifactGraph.js";
import {
isZipPackageFormat,
ZIP_NON_ENTRY_TAIL_LIMITATION,
@@ -36,20 +40,42 @@ export const createReader = (
}
};
export const inventoryLimitations = (
/** Classify source coverage and preserve its user-facing explanation once. */
export const inventoryAssessment = (
format: ArtifactOccurrence["artifact_format"],
reader: ArtifactReader | undefined,
): string[] => {
if (reader !== undefined)
return isZipPackageFormat(reader.format)
? [ZIP_NON_ENTRY_TAIL_LIMITATION]
: [];
// dmg and pkg reach here only after classifyRootFormat matched their bytes.
if (format === "dmg" || format === "pkg")
return [
integrityContradictions: readonly IntegrityContradiction[],
caseCollisionLimitation: string | undefined,
): {
readonly coverage_status: ArtifactInventoryCoverage;
readonly limitations: string[];
} => {
let coverageStatus: ArtifactInventoryCoverage = "complete";
const limitations: string[] = [];
if (reader !== undefined) {
if (isZipPackageFormat(reader.format))
limitations.push(ZIP_NON_ENTRY_TAIL_LIMITATION);
} else if (format === "dmg" || format === "pkg") {
coverageStatus = "partial";
limitations.push(
`${format.toUpperCase()} root hash is observed; child inventory requires a native macOS adapter.`,
];
if (format === "mach-o-universal" && process.platform !== "darwin")
return ["Universal Mach-O slices require the native macOS lipo adapter."];
return ["Artifact has no child container entries."];
);
} else if (format === "mach-o-universal" && process.platform !== "darwin") {
coverageStatus = "partial";
limitations.push(
"Universal Mach-O slices require the native macOS lipo adapter.",
);
} else {
// The graph's modeled scope is the root itself for recognized leaf formats.
limitations.push("Artifact has no child container entries.");
}
if (caseCollisionLimitation !== undefined) {
coverageStatus = "partial";
limitations.push(caseCollisionLimitation);
}
if (integrityContradictions.length > 0)
limitations.push(
`${String(integrityContradictions.length)} integrity contradiction(s) were recorded; mismatched content is observed-untrusted.`,
);
return { coverage_status: coverageStatus, limitations };
};
+9 -22
View File
@@ -40,7 +40,7 @@ import {
hashStableRootArtifact,
hashStableRootArtifactHandle,
} from "./hashStableRootArtifact.js";
import { createReader, inventoryLimitations } from "./reader.js";
import { createReader, inventoryAssessment } from "./reader.js";
import {
scanReader,
type PendingIntegrityContradiction,
@@ -268,6 +268,12 @@ const buildInventorySnapshot = async (
orderedOccurrences,
orderedEdges,
});
const assessment = inventoryAssessment(
rootFormat,
reader,
integrityContradictions,
input.caseCollisionLimitation,
);
return {
manifest,
nodes: orderedNodes,
@@ -275,12 +281,8 @@ const buildInventorySnapshot = async (
edges: orderedEdges,
provenance: reader?.provenance() ?? [],
integrity_contradictions: integrityContradictions,
limitations: buildLimitations(
rootFormat,
reader,
integrityContradictions,
input.caseCollisionLimitation,
),
coverage_status: assessment.coverage_status,
limitations: assessment.limitations,
};
};
@@ -393,18 +395,3 @@ const buildManifest = ({
occurrence_count: orderedOccurrences.length,
edge_count: orderedEdges.length,
});
const buildLimitations = (
rootFormat: ArtifactOccurrence["artifact_format"],
reader: ArtifactReader | undefined,
integrityContradictions: readonly IntegrityContradiction[],
caseCollisionLimitation: string | undefined,
): string[] => [
...inventoryLimitations(rootFormat, reader),
...(caseCollisionLimitation === undefined ? [] : [caseCollisionLimitation]),
...(integrityContradictions.length === 0
? []
: [
`${String(integrityContradictions.length)} integrity contradiction(s) were recorded; mismatched content is observed-untrusted.`,
]),
];
+2 -10
View File
@@ -13,7 +13,6 @@ import {
} from "./artifactInventoryEvidence.js";
import { prefixedDigestSchema } from "./../domain/digests.js";
import { canonicalJson } from "./comparisonSemantics.js";
import { ZIP_NON_ENTRY_TAIL_LIMITATION } from "./zipPackageFormat.js";
const evidenceIdSchema = prefixedDigestSchema("ev");
const comparisonStatusSchema = z.enum([
@@ -105,9 +104,9 @@ export const compareArtifacts = (
const leftComplete = left.inventory.complete;
const rightComplete = right.inventory.complete;
const leftCovered =
leftComplete && !hasCoverageLimitation(left.inventory.limitations);
leftComplete && left.inventory.coverage_status === "complete";
const rightCovered =
rightComplete && !hasCoverageLimitation(right.inventory.limitations);
rightComplete && right.inventory.coverage_status === "complete";
const limitations = [
...left.inventory.limitations.map((item) => `Left: ${item}`),
...right.inventory.limitations.map((item) => `Right: ${item}`),
@@ -433,13 +432,6 @@ const summarize = (
};
};
const hasCoverageLimitation = (limitations: readonly string[]): boolean =>
limitations.some(
(limitation) =>
limitation !== ZIP_NON_ENTRY_TAIL_LIMITATION &&
!/integrity contradiction\(s\) were recorded/u.test(limitation),
);
// Differing manifest root digests prove a root content change even when node
// details are unavailable. Fold that fact into an existing root row so one
// logical path keeps one classification.
+17
View File
@@ -188,6 +188,12 @@ export const artifactGraphManifestSchema = z.object({
edge_count: z.number().int().min(0),
});
/** Whether the inventory producer covered its modeled child-entry scope. */
export const artifactInventoryCoverageSchema = z.enum(["complete", "partial"]);
export type ArtifactInventoryCoverage = z.infer<
typeof artifactInventoryCoverageSchema
>;
/** Complete inventory observation containing the stable artifact graph. */
export const artifactInventoryResultSchema = z.object({
manifest: artifactGraphManifestSchema,
@@ -197,8 +203,19 @@ export const artifactInventoryResultSchema = z.object({
provenance: z.array(artifactCommandSchema),
integrity_contradictions: z.array(integrityContradictionSchema).default([]),
limitations: z.array(z.string()),
/** Optional only for persisted inventory Evidence written before this field. */
coverage_status: artifactInventoryCoverageSchema.optional(),
});
/** Current inventory producers always classify the coverage they examined. */
export const artifactInventoryOutputSchema =
artifactInventoryResultSchema.extend({
coverage_status: artifactInventoryCoverageSchema,
});
export type ArtifactInventoryOutput = z.infer<
typeof artifactInventoryOutputSchema
>;
/** One safely materialized artifact beneath caller-approved output root. */
const extractedArtifactSchema = z.object({
artifact_id: artifactIdSchema,
+2 -1
View File
@@ -1,6 +1,7 @@
import { z } from "zod";
import {
artifactInventoryOutputSchema,
artifactInventoryResultSchema,
type ArtifactInventoryResult,
} from "./artifactGraph.js";
@@ -114,7 +115,7 @@ export const artifactInspectionOutputSchema =
.array(
artifactInspectionResultSchema.shape.substeps.element.safeExtend({
evidence: evidenceSchema.safeExtend({
normalized_result: artifactInventoryResultSchema,
normalized_result: artifactInventoryOutputSchema,
}),
}),
)
+33 -5
View File
@@ -1,5 +1,6 @@
import {
artifactInventoryResultSchema,
type ArtifactInventoryCoverage,
type ArtifactInventoryResult,
type ArtifactNode,
type ArtifactOccurrence,
@@ -17,6 +18,7 @@ import {
} from "./artifactIdentity.js";
import { parseEvidence, type Evidence } from "./evidence.js";
import { artifactInspectionResultSchema } from "./artifactInspection.js";
import { ZIP_NON_ENTRY_TAIL_LIMITATION } from "./zipPackageFormat.js";
/** Complete or partially assembled inventory used by comparison workflows. */
export interface InventorySet {
@@ -27,6 +29,7 @@ export interface InventorySet {
readonly integrityContradictions: readonly IntegrityContradiction[];
readonly limitations: readonly string[];
readonly complete: boolean;
readonly coverage_status: ArtifactInventoryCoverage;
}
export interface ParsedInventorySet {
@@ -123,6 +126,10 @@ const assembleInventoryResults = (
const first = pages[0];
if (first === undefined)
throw new TypeError("Artifact inventory requires Evidence pages");
const coverageStatuses = new Set(pages.map(resolveInventoryCoverageStatus));
if (coverageStatuses.size !== 1)
throw new TypeError("Artifact inventory pages disagree on coverage status");
const coverageStatus = [...coverageStatuses][0]!;
const manifestJson = canonicalJson(first.manifest, "Artifact inventory");
const nodes = new Map<string, ArtifactNode>();
const occurrences = new Map<string, ArtifactOccurrence>();
@@ -152,11 +159,12 @@ const assembleInventoryResults = (
for (const edge of page.edges)
mergeExact(edges, edge.edge_id, edge, "edge");
}
const inventory = orderedInventory(first, pages, {
nodes,
occurrences,
edges,
});
const inventory = orderedInventory(
first,
pages,
{ nodes, occurrences, edges },
coverageStatus,
);
if (inventory.complete) validateCompleteInventory(inventory);
return inventory;
};
@@ -172,6 +180,7 @@ const orderedInventory = (
ArtifactInventoryResult["edges"][number]
>;
},
coverageStatus: ArtifactInventoryCoverage,
): InventorySet => {
const nodes = [...values.nodes.values()].sort((left, right) =>
compareUnicodeCodePoints(left.artifact_id, right.artifact_id),
@@ -195,9 +204,28 @@ const orderedInventory = (
nodes.length === first.manifest.node_count &&
occurrences.length === first.manifest.occurrence_count &&
edges.length === first.manifest.edge_count,
coverage_status: coverageStatus,
};
};
const resolveInventoryCoverageStatus = (
inventory: ArtifactInventoryResult,
): ArtifactInventoryCoverage => {
if (inventory.coverage_status !== undefined) return inventory.coverage_status;
// Older retained Evidence has no typed status. Keep only its known
// informational notices complete; unknown limitation text fails closed.
return inventory.limitations.every(isKnownNonCoverageNotice)
? "complete"
: "partial";
};
const isKnownNonCoverageNotice = (limitation: string): boolean =>
limitation === "Artifact has no child container entries." ||
limitation === ZIP_NON_ENTRY_TAIL_LIMITATION ||
/^\d+ integrity contradiction\(s\) were recorded; mismatched content is observed-untrusted\.$/u.test(
limitation,
);
const validateCompleteInventory = (inventory: InventorySet): void => {
const nodeIds = new Set(inventory.nodes.map(({ artifact_id: id }) => id));
const occurrenceIds = new Set(
+2
View File
@@ -1,4 +1,5 @@
import type {
ArtifactInventoryCoverage,
ArtifactInventoryResult,
ArtifactNode,
IntegrityContradiction,
@@ -6,6 +7,7 @@ import type {
/** Immutable inventory produced by one complete artifact scan. */
export interface ArtifactInventorySnapshot {
readonly coverage_status: ArtifactInventoryCoverage;
readonly manifest: ArtifactInventoryResult["manifest"];
readonly nodes: readonly ArtifactNode[];
readonly occurrences: ArtifactInventoryResult["occurrences"];