diff --git a/docs/cli.md b/docs/cli.md index 3c743cd68a..d7414f514e 100644 --- a/docs/cli.md +++ b/docs/cli.md @@ -744,10 +744,12 @@ not read artifact contents: the inventories of two builds are unresolved records, or one opaque change when you pair them. Use `compare-artifacts`, described at the end of this section, to compare their contents. -`--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. +`--record_pairs` accepts an inline JSON array or the path to a local JSON +file containing that array, matching MCP `compare_bundles.record_pairs`. For a +file, use `rea compare left.json right.json --record_pairs pairs.json --json`. +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. diff --git a/src/cliEvidenceCommands.ts b/src/cliEvidenceCommands.ts index 1442ddd5ed..93ead9c8e5 100644 --- a/src/cliEvidenceCommands.ts +++ b/src/cliEvidenceCommands.ts @@ -1,6 +1,5 @@ import { withCommandCancellation } from "./cli/commandCancellation.js"; -import { safeParseJson } from "./domain/safeJson.js"; -import { AnalysisInputError } from "./domain/analysisErrorCore.js"; +import { parseCliJsonInput } from "./cliJsonInput.js"; import { analysisInputErrorFromIssues } from "./domain/inputIssueProjection.js"; import { bundleComparisonInputSchema } from "./domain/bundleComparison.js"; import { Cli, z } from "incur"; @@ -72,7 +71,7 @@ export const registerEvidenceCommands = ( .string() .optional() .describe( - "JSON array of explicit left_evidence_id/right_evidence_id observation pairs", + "Inline JSON array or local JSON file of left_evidence_id/right_evidence_id pairs", ), }), run: ({ args, options }) => @@ -81,21 +80,12 @@ export const registerEvidenceCommands = ( 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", - }, - ], - ), - ); + : await parseCliJsonInput( + options.record_pairs, + "compare_bundles", + signal, + ); + if (!decoded.ok) return decoded.error; const input = { record_pairs: decoded.value }; const selected = bundleComparisonInputSchema .pick({ record_pairs: true }) diff --git a/tests/boundary/cli/bundleComparisonCli.test.ts b/tests/boundary/cli/bundleComparisonCli.test.ts index fcf164b79b..d09f466eff 100644 --- a/tests/boundary/cli/bundleComparisonCli.test.ts +++ b/tests/boundary/cli/bundleComparisonCli.test.ts @@ -123,16 +123,10 @@ cliTest( right_evidence_id: different.records[0]!.evidence_id, }, ]; - const paired = await cli.run({ - arguments: [ - "compare", - leftPath, - changedPath, - "--record_pairs", - JSON.stringify(pairs), - "--json", - ], - }); + const pairArgs = ["compare", leftPath, changedPath, "--record_pairs"]; + const compareWithPairs = (value: string) => + cli.run({ arguments: [...pairArgs, value, "--json"] }); + const paired = await compareWithPairs(JSON.stringify(pairs)); expect(paired.exitCode).toBe(0); const pairedResult = bundleComparisonResultSchema.parse(paired.json); expect(pairedResult).toMatchObject({ @@ -142,6 +136,21 @@ cliTest( { classification: "changed", conclusion_kind: "observed_change" }, ], }); + const pairFile = join(root, "pairs.json"); + await writeFile(pairFile, JSON.stringify(pairs)); + const fromFile = await compareWithPairs(pairFile); + expect(fromFile.exitCode).toBe(0); + expect(bundleComparisonResultSchema.parse(fromFile.json)).toEqual( + pairedResult, + ); + const missingFile = join(root, "missing-pairs.json"); + const absentFile = await compareWithPairs(missingFile); + expect(absentFile.exitCode).toBe(1); + expect(absentFile.json).toMatchObject({ + code: "invalid_request", + input_path: missingFile, + input_reason: "read-failed", + }); const call = await cli.sharedMcpCall?.(); if (call === undefined) throw new Error("Expected shared MCP session"); const mcp = await call("compare_bundles", { @@ -155,18 +164,11 @@ cliTest( 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", - ], - }); + const absentPair = await compareWithPairs( + JSON.stringify([ + { ...pairs[0], left_evidence_id: `ev_${"f".repeat(64)}` }, + ]), + ); expect(absentPair.exitCode).toBe(1); expect(absentPair.json).toMatchObject({ code: "invalid_request",