From b885f44bef05f4f0b0edc36d8846f035e770a3e9 Mon Sep 17 00:00:00 2001 From: morluto <76467478+morluto@users.noreply.github.com> Date: Sat, 24 Oct 2026 02:50:01 +0800 Subject: [PATCH] fix(cli): preserve explicit Evidence record pairing in bundle comparison --- docs/cli.md | 7 +++ src/application/EvidenceBundleCommands.ts | 22 ++++++- src/cliEvidenceCommands.ts | 45 ++++++++++++++- src/domain/bundleComparison.ts | 8 +-- .../boundary/cli/bundleComparisonCli.test.ts | 57 ++++++++++++++++++- 5 files changed, 130 insertions(+), 9 deletions(-) diff --git a/docs/cli.md b/docs/cli.md index 29a0e28949..7a08713774 100644 --- a/docs/cli.md +++ b/docs/cli.md @@ -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_","right_evidence_id":"ev_"}]' --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). diff --git a/src/application/EvidenceBundleCommands.ts b/src/application/EvidenceBundleCommands.ts index 85299c80d7..cacb1d6b86 100644 --- a/src/application/EvidenceBundleCommands.ts +++ b/src/application/EvidenceBundleCommands.ts @@ -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> => { // 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 = ( diff --git a/src/cliEvidenceCommands.ts b/src/cliEvidenceCommands.ts index 308a793ec3..599e97f468 100644 --- a/src/cliEvidenceCommands.ts +++ b/src/cliEvidenceCommands.ts @@ -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); }), diff --git a/src/domain/bundleComparison.ts b/src/domain/bundleComparison.ts index f2b5dd9905..b4b35561aa 100644 --- a/src/domain/bundleComparison.ts +++ b/src/domain/bundleComparison.ts @@ -79,7 +79,7 @@ export type BundleComparisonResult = z.infer< typeof bundleComparisonResultSchema >; type BundleChange = z.infer; -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 }, [ diff --git a/tests/boundary/cli/bundleComparisonCli.test.ts b/tests/boundary/cli/bundleComparisonCli.test.ts index 17faf22b67..5fdc1bd606 100644 --- a/tests/boundary/cli/bundleComparisonCli.test.ts +++ b/tests/boundary/cli/bundleComparisonCli.test.ts @@ -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"] }] }, + }); }, );