mirror of
https://github.com/morluto/rea
synced 2026-10-25 22:08:11 -04:00
fix(cli): preserve explicit Evidence record pairing in bundle comparison
This commit is contained in:
@@ -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).
|
||||
|
||||
@@ -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 = (
|
||||
|
||||
@@ -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);
|
||||
}),
|
||||
|
||||
@@ -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 }, [
|
||||
|
||||
@@ -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"] }] },
|
||||
});
|
||||
},
|
||||
);
|
||||
|
||||
|
||||
Reference in New Issue
Block a user