fix(cli): preserve explicit Evidence record pairing in bundle comparison

This commit is contained in:
morluto
2026-10-24 02:50:01 +08:00
parent 546b2a6cf6
commit b885f44bef
5 changed files with 130 additions and 9 deletions
+7
View File
@@ -445,8 +445,15 @@ canonical form, or compare two supplied bundles:
rea evidence-import /absolute/path/to/evidence/bundle.json
rea evidence-export /absolute/path/to/evidence/bundle.json /absolute/path/to/evidence/canonical.json
rea compare /absolute/path/to/evidence/left.json /absolute/path/to/evidence/right.json
rea compare left.json right.json --record_pairs '[{"left_evidence_id":"ev_<left-sha256>","right_evidence_id":"ev_<right-sha256>"}]' --json
```
`--record_pairs` supplies an explicit JSON array of observation pairs, matching
MCP `compare_bundles.record_pairs`. Pair IDs must exist on their named sides and
be one-to-one. Without pairs, one-sided membership remains unknown; REA does
not infer correspondence from position or display names.
Exports preserve an existing destination unless `--overwrite` is explicit.
For application traces and version comparisons, see
[JavaScript application workflows](javascript-application-workflows.md).
+19 -3
View File
@@ -7,7 +7,10 @@ import {
type PublishedEvidenceFile,
} from "../domain/evidenceErrors.js";
import type { AnalysisError } from "../domain/analysisErrorBase.js";
import { AnalysisCancelledError } from "../domain/analysisErrorCore.js";
import {
AnalysisCancelledError,
AnalysisInputError,
} from "../domain/analysisErrorCore.js";
import type { Evidence } from "../domain/evidence.js";
import {
createImmutableEvidenceBundle,
@@ -16,7 +19,10 @@ import {
import type { JsonValue } from "../domain/jsonValue.js";
import { jsonValueSchema } from "../domain/jsonValue.js";
import { err, ok, type Result } from "../domain/result.js";
import { compareBundles } from "../domain/bundleComparison.js";
import {
compareBundles,
type BundleRecordPair,
} from "../domain/bundleComparison.js";
import { ArtifactResourceScope } from "../artifacts/ArtifactResourceScope.js";
import {
readEvidenceBundle,
@@ -200,6 +206,7 @@ const persistEvidenceBundleCommand = async (
export const compareEvidenceBundlesCommand = async (input: {
readonly leftPath: string;
readonly rightPath: string;
readonly recordPairs?: readonly BundleRecordPair[];
}): Promise<Result<JsonValue, AnalysisError>> => {
// Sealed snapshots let comparison and its bundle digests reuse this
// authentication instead of re-validating every record twice more.
@@ -219,7 +226,16 @@ export const compareEvidenceBundlesCommand = async (input: {
]);
if (!left.ok) return left;
if (!right.ok) return right;
return ok(jsonValueSchema.parse(compareBundles(left.value, right.value)));
try {
return ok(
jsonValueSchema.parse(
compareBundles(left.value, right.value, input.recordPairs),
),
);
} catch (cause: unknown) {
if (cause instanceof AnalysisInputError) return err(cause);
throw cause;
}
};
const projectWrite = (
+44 -1
View File
@@ -1,3 +1,7 @@
import { safeParseJson } from "./domain/safeJson.js";
import { AnalysisInputError } from "./domain/analysisErrorCore.js";
import { analysisInputErrorFromIssues } from "./domain/inputIssueProjection.js";
import { bundleComparisonInputSchema } from "./domain/bundleComparison.js";
import { Cli, z } from "incur";
import {
@@ -54,11 +58,50 @@ export const registerEvidenceCommands = (
left: z.string().describe("Left Evidence bundle JSON path"),
right: z.string().describe("Right Evidence bundle JSON path"),
}),
run: ({ args }) =>
options: z.object({
record_pairs: z
.string()
.optional()
.describe(
"JSON array of explicit left_evidence_id/right_evidence_id observation pairs",
),
}),
run: ({ args, options }) =>
logCliCommand(logger, "compare", async () => {
const decoded =
options.record_pairs === undefined
? { ok: true as const, value: [] }
: safeParseJson(options.record_pairs);
if (!decoded.ok)
return cliError(
new AnalysisInputError(
"compare_bundles",
{ cause: decoded.cause },
[
{
path: ["record_pairs"],
reason: "invalid_format",
expected: "JSON array",
},
],
),
);
const input = { record_pairs: decoded.value };
const selected = bundleComparisonInputSchema
.pick({ record_pairs: true })
.safeParse(input);
if (!selected.success)
return cliError(
analysisInputErrorFromIssues(
"compare_bundles",
selected.error.issues,
input,
),
);
const compared = await compareEvidenceBundlesCommand({
leftPath: args.left,
rightPath: args.right,
recordPairs: selected.data.record_pairs,
});
return compared.ok ? compared.value : cliError(compared.error);
}),
+4 -4
View File
@@ -79,7 +79,7 @@ export type BundleComparisonResult = z.infer<
typeof bundleComparisonResultSchema
>;
type BundleChange = z.infer<typeof bundleChangeSchema>;
type RecordPair = z.infer<
export type BundleRecordPair = z.infer<
typeof bundleComparisonInputSchema
>["record_pairs"][number];
@@ -87,7 +87,7 @@ type RecordPair = z.infer<
export const compareBundles = (
leftInput: unknown,
rightInput: unknown,
recordPairs: readonly RecordPair[] = [],
recordPairs: readonly BundleRecordPair[] = [],
): BundleComparisonResult => {
const left = parseEvidenceBundle(leftInput);
const right = parseEvidenceBundle(rightInput);
@@ -123,7 +123,7 @@ export const compareBundles = (
const compareRecords = (
left: EvidenceBundle,
right: EvidenceBundle,
pairs: readonly RecordPair[],
pairs: readonly BundleRecordPair[],
) => {
const leftIds = new Set(left.records.map(({ evidence_id: id }) => id));
const rightIds = new Set(right.records.map(({ evidence_id: id }) => id));
@@ -212,7 +212,7 @@ const compareRecords = (
const recordPairFailure = (
index: number,
field: keyof RecordPair,
field: keyof BundleRecordPair,
message: string,
): AnalysisInputError =>
new AnalysisInputError("compare_bundles", { userMessage: message }, [
+56 -1
View File
@@ -6,7 +6,7 @@ import { Client } from "@modelcontextprotocol/client";
import { StdioClientTransport } from "@modelcontextprotocol/client/stdio";
import { expect, onTestFinished } from "vitest";
import { createEvidence } from "../../../src/domain/evidence.js";
import { createEvidence, parseEvidence } from "../../../src/domain/evidence.js";
import { bundleComparisonResultSchema } from "../../../src/domain/bundleComparison.js";
import {
createEvidenceBundle,
@@ -104,6 +104,61 @@ cliTest(
}),
],
});
const pairs = [
{
left_evidence_id: record.evidence_id,
right_evidence_id: different.records[0]!.evidence_id,
},
];
const paired = await cli.run({
arguments: [
"compare",
leftPath,
changedPath,
"--record_pairs",
JSON.stringify(pairs),
"--json",
],
});
expect(paired.exitCode).toBe(0);
const pairedResult = bundleComparisonResultSchema.parse(paired.json);
expect(pairedResult).toMatchObject({
status: "changed",
summary: { records_changed: 1, unresolved: 0 },
changes: [
{ classification: "changed", conclusion_kind: "observed_change" },
],
});
const call = await cli.sharedMcpCall?.();
if (call === undefined) throw new Error("Expected shared MCP session");
const mcp = await call("compare_bundles", {
left_bundle_path: leftPath,
right_bundle_path: changedPath,
record_pairs: pairs,
});
expect(mcp.isError).not.toBe(true);
expect(
bundleComparisonResultSchema.parse(
parseEvidence(mcp.structuredContent).normalized_result,
),
).toEqual(pairedResult);
const absentPair = await cli.run({
arguments: [
"compare",
leftPath,
changedPath,
"--record_pairs",
JSON.stringify([
{ ...pairs[0], left_evidence_id: `ev_${"f".repeat(64)}` },
]),
"--json",
],
});
expect(absentPair.exitCode).toBe(1);
expect(absentPair.json).toMatchObject({
code: "invalid_request",
details: { issues: [{ path: ["record_pairs", 0, "left_evidence_id"] }] },
});
},
);