diff --git a/docs/cli.md b/docs/cli.md index ab92ba594e..0e0259f267 100644 --- a/docs/cli.md +++ b/docs/cli.md @@ -73,9 +73,14 @@ arbitrary nested graph array. A top-level array emits its elements in source order. An object must have one clear `rows`, `items`, `records`, or `occurrences` array, directly or under `page`. Evidence uses the same boundaries inside `normalized_result`. -An arbitrary report or a scalar selection is refused with an actionable error; -use `--format json` for a complete document. If several collections exist, -select one with `--filter-output`, for example `normalized_result.rows`. +Any other selection is refused before a record is written, including an +arbitrary report, a scalar, a nested array elsewhere, and a columnar object such +as the `inspect-sqlite-database --table` `normalized_result.rows` +(`{columns, values}`). The refusal is the caller error `invalid_request` +(`invalid_input`) and names the selection and its observed shape; the analysis +itself succeeded, so use `--format json` for the complete document. If several +collections exist, select one with `--filter-output`, for example +`normalized_result.rows` for an analysis view. Object collections finish with a control record: diff --git a/src/cli/cliResultOutput.ts b/src/cli/cliResultOutput.ts index 0f91fe29c8..e0ada1b8d8 100644 --- a/src/cli/cliResultOutput.ts +++ b/src/cli/cliResultOutput.ts @@ -337,6 +337,7 @@ export const createCliResultOutput = ( ...options, source: value, selected: filtered, + filterPaths, operationFailure, command: metadata.command, duration: metadata.duration, diff --git a/src/cli/jsonLines.ts b/src/cli/jsonLines.ts index f4ecdab3e1..3911476f60 100644 --- a/src/cli/jsonLines.ts +++ b/src/cli/jsonLines.ts @@ -1,6 +1,8 @@ +import type { Filter } from "incur"; + import { err, ok, type Result } from "../domain/result.js"; -import { AnalysisOutputError } from "../domain/analysisErrorCore.js"; +import { AnalysisInputError } from "../domain/analysisErrorCore.js"; import { jsonParts } from "../domain/jsonSerialization.js"; /** Reserved control-record marker, not an advertised external JSON Schema. */ @@ -27,6 +29,21 @@ interface JsonLinesMetadataRecord { const COLLECTION_KEYS = ["rows", "items", "records", "occurrences"] as const; +const RECORD_BOUNDARIES = + "JSON Lines needs a top-level array, or exactly one rows, items, records, or occurrences array directly in the result, under page, or under normalized_result."; + +/** Objects whose collection fields JSON Lines admits, relative to the result. */ +const RECORD_BOUNDARY_PATHS = [ + [], + ["page"], + ["normalized_result"], + ["normalized_result", "page"], +] as const; + +/** Recovery for every JSON Lines selection refusal; the analysis result itself is valid. */ +export const JSON_LINES_SELECTION_REMEDIATION = + "Use --format json for the complete document, or select one record collection with --filter-output."; + interface Collection { readonly records: readonly unknown[]; readonly path: readonly string[]; @@ -50,42 +67,100 @@ const atPath = (value: unknown, path: readonly string[]): unknown => { return value; }; -const collections = ( - value: unknown, - prefix: readonly string[], -): Collection[] => { - if (Array.isArray(value)) return [{ records: value, path: prefix }]; - if (!isRecord(value)) return []; - const found: Collection[] = []; - const collect = ( - object: Record, - path: readonly string[], - ) => { - for (const key of COLLECTION_KEYS) { - const records = object[key]; - if (Object.hasOwn(object, key) && Array.isArray(records)) - found.push({ records, path: [...path, key] }); - } - }; - collect(value, prefix); - if (Object.hasOwn(value, "page") && isRecord(value.page)) - collect(value.page, [...prefix, "page"]); - return found; -}; +interface BoundaryExamination { + readonly found: readonly Collection[]; + /** Collection fields that exist but are not arrays. */ + readonly rejected: readonly Observation[]; +} -const documentCollections = ( +/** Examine exactly the documented record boundaries, once, for admission and refusal. */ +const examineBoundaries = ( value: unknown, fullOutput: boolean, -): Collection[] => { +): BoundaryExamination => { const prefix = fullOutput ? ["data"] : []; const data = atPath(value, prefix); - return [ - ...collections(data, prefix), - ...collections(atPath(data, ["normalized_result"]), [ - ...prefix, - "normalized_result", - ]), - ]; + const found: Collection[] = []; + const rejected: Observation[] = []; + for (const boundary of RECORD_BOUNDARY_PATHS) { + const object = atPath(data, boundary); + const path = [...prefix, ...boundary]; + // A result or normalized_result may itself be the array; a page is a container. + if (Array.isArray(object) && boundary.at(-1) !== "page") + found.push({ records: object, path }); + if (!isRecord(object)) continue; + for (const key of COLLECTION_KEYS) { + if (!Object.hasOwn(object, key)) continue; + const records = object[key]; + if (Array.isArray(records)) found.push({ records, path: [...path, key] }); + else rejected.push(observe([...path, key], records)); + } + } + return { found, rejected }; +}; + +type Shape = + | "absent" + | "null" + | "array" + | "object" + | "string" + | "number" + | "boolean"; + +/** One examined value that JSON Lines refused, at its emitted-document path. */ +interface Observation { + readonly path: readonly (string | number)[]; + readonly shape: Shape; +} + +const shapeOf = (value: unknown): Shape => { + if (value === null) return "null"; + if (Array.isArray(value)) return "array"; + switch (typeof value) { + case "object": + return "object"; + case "string": + return "string"; + case "boolean": + return "boolean"; + case "number": + case "bigint": + return "number"; + default: + // JSON omits undefined, functions and symbols. + return "absent"; + } +}; + +const observe = ( + path: readonly (string | number)[], + value: unknown, +): Observation => ({ path, shape: shapeOf(value) }); + +const describe = ({ path, shape }: Observation): string => + `${path.length === 0 ? "the result" : path.join(".")} is ${ + shape === "absent" || shape === "null" + ? shape + : shape === "array" || shape === "object" + ? `an ${shape}` + : `a ${shape}` + }`; + +/** Caller-visible key paths, before any slice, selected by --filter-output. */ +const selectionPaths = ( + paths: ReturnType | undefined, +): string[][] => { + const selected = new Map(); + for (const path of paths ?? []) { + const keys: string[] = []; + for (const segment of path) { + if (!("key" in segment)) break; + keys.push(segment.key); + } + selected.set(JSON.stringify(keys), keys); + } + return [...selected.values()]; }; /** Shallow copies only ancestors of the emitted array; every other fact survives. */ @@ -123,40 +198,116 @@ const withoutRecords = ( return context; }; -/** Project a clear collection without interpreting or whitelisting its context. */ -export const prepareJsonLines = ( +interface JsonLinesOptions { + readonly command: string; + readonly source?: unknown; + readonly filter?: string; + /** Parsed --filter-output paths, used only to name the refused selection. */ + readonly filterPaths?: ReturnType; + readonly fullOutput?: boolean; +} + +/** + * Choose the one clear collection, or refuse the caller's selection. The + * structured `observed` facts are the contract; the message derives from them. + */ +const selectCollection = ( selected: unknown, - options: { - readonly command: string; - readonly source?: unknown; - readonly filter?: string; - readonly fullOutput?: boolean; - }, -): Result => { - const found = documentCollections(selected, options.fullOutput === true); - const reject = (reason: string) => - err( - new AnalysisOutputError(options.command, reason, { - remediationAction: - "Use --format json for the complete document, or select one record collection with --filter-output.", - }), + source: unknown, + options: JsonLinesOptions, + { found, rejected }: BoundaryExamination, +): Result => { + const prefix = options.fullOutput === true ? ["data"] : []; + const filtered = options.filter !== undefined; + const selections = selectionPaths(options.filterPaths).filter( + (path) => path.length > 0, + ); + const subject = + filtered && selections.length > 0 + ? `The --filter-output selection ${selections.map((path) => path.join(".")).join(", ")}` + : "The command result"; + const refuse = ( + statement: string, + observed: readonly Observation[], + rule: string, + ) => { + const observation = `${subject} ${statement}: ${observed.map(describe).join("; ")}.`; + return err( + new AnalysisInputError( + options.command, + { + userMessage: `${observation} ${rule}`, + remediationAction: JSON_LINES_SELECTION_REMEDIATION, + }, + [ + { + path: [filtered ? "filter-output" : "format"], + reason: "invalid_value", + message: observation, + observed: observed.map(({ path, shape }) => ({ + path: [...path], + shape, + })), + }, + ], + ), ); - if (found.length !== 1) - return reject( - found.length === 0 - ? "JSON Lines requires an array or one explicit rows, items, records, or occurrences collection." - : "JSON Lines cannot choose between multiple record collections. Select one collection explicitly.", + }; + if (found.length > 1) + return refuse( + "is not one JSON Lines record collection", + found.map(({ path }) => observe(path, [])), + "Select one with --filter-output.", ); - const collection = found[0]; - if (collection === undefined) - return reject("The record collection is unavailable."); - for (const record of collection.records) { + const [collection] = found; + if (collection === undefined) { + const [selection] = selections; + // A non-array collection field is the most specific fact. Otherwise one + // named selection is described by its source value, since a filter that + // matches nothing leaves no selected value to describe. + const observed = + rejected.length > 0 + ? rejected + : [ + selections.length === 1 && selection !== undefined + ? observe( + [...prefix, ...selection], + atPath(atPath(source, prefix), selection), + ) + : observe(prefix, atPath(selected, prefix)), + ]; + return refuse( + "is not a JSON Lines record collection", + observed, + RECORD_BOUNDARIES, + ); + } + for (const [index, record] of collection.records.entries()) { if (isRecord(record) && Object.hasOwn(record, "$rea")) - return reject( - "A data record uses the reserved $rea JSON Lines control marker.", + return refuse( + "uses the field JSON Lines reserves for control records", + [observe([...collection.path, index, "$rea"], record.$rea)], + "Nested $rea values are ordinary data.", ); } + return ok(collection); +}; + +/** + * Project a clear collection without interpreting or whitelisting its context. + * A refusal is the caller's output selection, not a failed analysis: the same + * result remains available through --format json. + */ +export const prepareJsonLines = ( + selected: unknown, + options: JsonLinesOptions, +): Result => { + const fullOutput = options.fullOutput === true; + const examination = examineBoundaries(selected, fullOutput); const source = Object.hasOwn(options, "source") ? options.source : selected; + const chosen = selectCollection(selected, source, options, examination); + if (!chosen.ok) return chosen; + const collection = chosen.value; const originalRecords = atPath(source, collection.path); const sourceMatches = Array.isArray(originalRecords); let context = withoutRecords( @@ -168,10 +319,7 @@ export const prepareJsonLines = ( if (options.filter !== undefined && sourceMatches) { // Excluded peer rows are data, not context. A filter can disambiguate them // without restoring an unselected collection into the final record. - for (const peer of documentCollections( - source, - options.fullOutput === true, - )) { + for (const peer of examineBoundaries(source, fullOutput).found) { if (peer.path.join(".") !== collection.path.join(".")) context = withoutRecords(context, selected, peer.path, true); } diff --git a/src/cli/jsonLinesOutput.ts b/src/cli/jsonLinesOutput.ts index 397b5d4664..150ccaa277 100644 --- a/src/cli/jsonLinesOutput.ts +++ b/src/cli/jsonLinesOutput.ts @@ -1,5 +1,6 @@ import { constants } from "node:buffer"; import type { Writable } from "node:stream"; +import type { Filter } from "incur"; import { estimateTokenCount } from "tokenx"; import { AnalysisResourceConstraintError } from "../domain/analysisErrorCore.js"; @@ -29,6 +30,7 @@ interface CliJsonLinesInput { readonly fullOutput: boolean; readonly tokenCount: boolean; readonly filterOutput: string | undefined; + readonly filterPaths: ReturnType | undefined; } /** Render one already classified result, sharing a plan for output and counting. */ @@ -57,6 +59,9 @@ export const writeCliJsonLines = async ( ...(options.filterOutput === undefined ? {} : { filter: options.filterOutput }), + ...(options.filterPaths === undefined + ? {} + : { filterPaths: options.filterPaths }), fullOutput: options.fullOutput, }) : undefined; diff --git a/src/domain/analysisErrorCore.ts b/src/domain/analysisErrorCore.ts index 1a08034263..c998455519 100644 --- a/src/domain/analysisErrorCore.ts +++ b/src/domain/analysisErrorCore.ts @@ -108,14 +108,20 @@ export class AnalysisProtocolError extends AnalysisError { export class AnalysisInputError extends AnalysisError { readonly _tag = "AnalysisInputError"; override readonly userMessage: string | undefined; + /** Refusal-specific correction, when the generic input-contract advice cannot fix it. */ + readonly remediationAction: string | undefined; constructor( readonly operation: string, - options?: AnalysisErrorOptions & { readonly userMessage?: string }, + options?: AnalysisErrorOptions & { + readonly userMessage?: string; + readonly remediationAction?: string; + }, readonly issues: readonly AnalysisInputIssue[] = [], ) { super(`Invalid analysis input for ${operation}`, options); this.userMessage = options?.userMessage; + this.remediationAction = options?.remediationAction; } } @@ -132,6 +138,8 @@ export interface AnalysisInputIssue { /** Schema-authored correction guidance for cross-field or custom checks. */ readonly message?: string; readonly expected?: JsonValue; + /** Producer-examined facts about the refused value, such as its location and shape. */ + readonly observed?: JsonValue; readonly minimum?: number; readonly maximum?: number; } diff --git a/src/domain/analysisErrorPresentation.ts b/src/domain/analysisErrorPresentation.ts index f71615d0de..1cc6317db9 100644 --- a/src/domain/analysisErrorPresentation.ts +++ b/src/domain/analysisErrorPresentation.ts @@ -161,7 +161,10 @@ export const analysisErrorRemediationAction = ( if (error instanceof ConfigurationError && error.settings.length > 0) return `Correct ${[...new Set(error.settings.map(({ setting }) => setting))].join(", ")} in the environment that starts REA (your shell or the MCP client's registration), then rerun.`; if (error instanceof AnalysisInputError) - return `Read the declared input schema or CLI usage for ${error.operation}; if using MCP and the catalog may be stale, call tools/list. Correct the listed fields, then retry. Do not repeat the unchanged call.`; + return ( + error.remediationAction ?? + `Read the declared input schema or CLI usage for ${error.operation}; if using MCP and the catalog may be stale, call tools/list. Correct the listed fields, then retry. Do not repeat the unchanged call.` + ); if ( error instanceof AnalysisOutputError && error.remediationAction !== undefined diff --git a/src/domain/analysisErrorProjection.ts b/src/domain/analysisErrorProjection.ts index 4cfb9c0566..a852e2cd66 100644 --- a/src/domain/analysisErrorProjection.ts +++ b/src/domain/analysisErrorProjection.ts @@ -368,6 +368,7 @@ const requestErrorDetails = ( reason: issue.reason, ...(issue.message === undefined ? {} : { message: issue.message }), ...(issue.expected === undefined ? {} : { expected: issue.expected }), + ...(issue.observed === undefined ? {} : { observed: issue.observed }), ...(issue.minimum === undefined ? {} : { minimum: issue.minimum }), ...(issue.maximum === undefined ? {} : { maximum: issue.maximum }), })), diff --git a/src/domain/explicitSensitiveFailure.ts b/src/domain/explicitSensitiveFailure.ts index 5c8d4c8136..df7a5626df 100644 --- a/src/domain/explicitSensitiveFailure.ts +++ b/src/domain/explicitSensitiveFailure.ts @@ -164,6 +164,9 @@ const redactFailure = ( ...(issue.expected === undefined ? {} : { expected: visit(issue.expected) }), + ...(issue.observed === undefined + ? {} + : { observed: visit(issue.observed) }), })), ); if (error instanceof AnalysisOutputError) diff --git a/tests/acceptance/jsonLinesCli.test.ts b/tests/acceptance/jsonLinesCli.test.ts index ba4690ebb4..ee379f8ee1 100644 --- a/tests/acceptance/jsonLinesCli.test.ts +++ b/tests/acceptance/jsonLinesCli.test.ts @@ -1,8 +1,10 @@ import { writeFile } from "node:fs/promises"; import { join } from "node:path"; +import { DatabaseSync } from "node:sqlite"; import { expect } from "vitest"; +import { JSON_LINES_SELECTION_REMEDIATION } from "../../src/cli/jsonLines.js"; import { parseEvidence } from "../../src/domain/evidence.js"; import { analysisViewJavaScriptEvidence } from "../fixtures/analysisView.js"; import { createTestTempDirectory } from "../fixtures/temporaryDirectory.js"; @@ -87,6 +89,98 @@ cliTest( // convenient fields from a shortened view-shaped object. expect(parseEvidence(reconstructed)).toEqual(expected); expect(records.length).toBe(where === undefined ? 1 : 0); + + // The documented disambiguating selection exports the same view rows. + const filter = "normalized_result.rows"; + const selected = await cli.run({ + arguments: [ + "inspect-analysis-view", + requestPath, + "--format", + "jsonl", + "--filter-output", + filter, + ], + }); + expect(selected.exitCode, selected.stderr).toBe(0); + const selectedRecords = selected.stdout + .trimEnd() + .split("\n") + .map((line) => JSON.parse(line)); + expect(selectedRecords.pop()).toMatchObject({ + type: "metadata", + records: { + path: ["normalized_result", "rows"], + emitted: records.length, + source: records.length, + filter, + }, + }); + expect(selectedRecords).toEqual(records); + } + }, +); + +cliTest( + "refuses non-record SQLite selections as caller output errors while the analysis succeeds", + async ({ cli }) => { + const root = await createTestTempDirectory("rea-json-lines-sqlite-"); + const path = join(root, "config.sqlite"); + const database = new DatabaseSync(path); + try { + database.exec( + "CREATE TABLE configInts(name TEXT PRIMARY KEY, value INTEGER); INSERT INTO configInts VALUES ('a', 1), ('b', 2);", + ); + } finally { + database.close(); + } + const table = ["--table", "configInts", "--row-limit", "3"]; + const json = await cli.run({ + arguments: ["inspect-sqlite-database", path, ...table, "--json"], + }); + expect(json.exitCode, json.stderr).toBe(0); + const sqliteJsonLines = async (selection: readonly string[]) => { + const result = await cli.run({ + arguments: [ + "inspect-sqlite-database", + path, + ...selection, + "--format", + "jsonl", + ], + }); + expect(result.exitCode).toBe(1); + expect(result.stdout.trimEnd().split("\n")).toHaveLength(1); + return result.json; + }; + for (const [selection, option, observed] of [ + [[], "format", [{ path: ["normalized_result", "rows"], shape: "null" }]], + [ + [...table, "--filter-output", "normalized_result.rows"], + "filter-output", + [{ path: ["normalized_result", "rows"], shape: "object" }], + ], + [ + ["--filter-output", "normalized_result.schema.tables"], + "filter-output", + [{ path: ["normalized_result", "schema", "tables"], shape: "array" }], + ], + ] as const) { + expect(await sqliteJsonLines(selection)).toMatchObject({ + $rea: "jsonl/v1", + type: "error", + ok: false, + result: { + code: "invalid_request", + category: "invalid_input", + retryable: false, + remediation: { action: JSON_LINES_SELECTION_REMEDIATION }, + details: { + operation: "inspect-sqlite-database", + issues: [{ path: [option], reason: "invalid_value", observed }], + }, + }, + }); } }, ); diff --git a/tests/acceptance/managedCliOutput.test.ts b/tests/acceptance/managedCliOutput.test.ts index 7fece14501..978f141e2b 100644 --- a/tests/acceptance/managedCliOutput.test.ts +++ b/tests/acceptance/managedCliOutput.test.ts @@ -4,6 +4,7 @@ import { join } from "node:path"; import { expect } from "vitest"; +import { JSON_LINES_SELECTION_REMEDIATION } from "../../src/cli/jsonLines.js"; import { elf, thinMach } from "../../src/domain/binaryTarget.fixture.js"; import { buildManagedPeFixture } from "../../src/dotnet/ManagedPe.fixture.js"; import { createTestTempDirectory } from "../fixtures/temporaryDirectory.js"; @@ -401,8 +402,18 @@ cliTest( $rea: "jsonl/v1", type: "error", result: { - code: "unreadable_output", - remediation: { action: expect.stringContaining("--format json") }, + code: "invalid_request", + category: "invalid_input", + remediation: { action: JSON_LINES_SELECTION_REMEDIATION }, + details: { + issues: [ + { + path: ["filter-output"], + reason: "invalid_value", + observed: [{ path: ["operation"], shape: "string" }], + }, + ], + }, }, }); }, diff --git a/tests/boundary/cli/javascriptApplicationJsonOutput.test.ts b/tests/boundary/cli/javascriptApplicationJsonOutput.test.ts index 5dfd117db2..c0a9d62127 100644 --- a/tests/boundary/cli/javascriptApplicationJsonOutput.test.ts +++ b/tests/boundary/cli/javascriptApplicationJsonOutput.test.ts @@ -4,6 +4,7 @@ import { join } from "node:path"; import { expect } from "vitest"; import { z } from "zod"; +import { JSON_LINES_SELECTION_REMEDIATION } from "../../../src/cli/jsonLines.js"; import { javascriptApplicationAnalysisResultSchema } from "../../../src/domain/javascript/javascriptApplicationAnalysis.js"; import { analyzeJavaScriptApplication } from "../../support/javascriptApplicationScope.js"; import { createTestTempDirectory } from "../../fixtures/temporaryDirectory.js"; @@ -66,9 +67,17 @@ for (const scenario of [ .parse(record.result).error : record.result; expect(failure).toMatchObject({ - code: "unreadable_output", - remediation: { action: expect.stringContaining("--format json") }, - details: { reason: expect.stringContaining("JSON Lines requires") }, + code: "invalid_request", + category: "invalid_input", + remediation: { action: JSON_LINES_SELECTION_REMEDIATION }, + details: { + issues: [ + { + path: [scenario.filtered ? "filter-output" : "format"], + reason: "invalid_value", + }, + ], + }, }); return; } diff --git a/tests/boundary/cli/jsonLinesOutput.test.ts b/tests/boundary/cli/jsonLinesOutput.test.ts index aa77b1e6f6..44ac337ccd 100644 --- a/tests/boundary/cli/jsonLinesOutput.test.ts +++ b/tests/boundary/cli/jsonLinesOutput.test.ts @@ -8,7 +8,10 @@ import { createCliResultOutput, streamCliCommandResults, } from "../../../src/cli/cliResultOutput.js"; -import { jsonLinesErrorRecord } from "../../../src/cli/jsonLines.js"; +import { + JSON_LINES_SELECTION_REMEDIATION, + jsonLinesErrorRecord, +} from "../../../src/cli/jsonLines.js"; import { parseCliOutputArguments } from "../../../src/cliOutput.js"; import { logCliCommand } from "../../../src/cliLogging.js"; import { silentLogger } from "../../../src/logger.js"; @@ -119,44 +122,174 @@ describe("row-oriented CLI JSON Lines", () => { ); }); -describe("JSON Lines admission and selection", () => { - it.each([ - null, - {}, - "scalar", - { rows: [], items: [] }, - { rows: [], normalized_result: { rows: [] } }, - Object.create({ rows: [{ inherited: true }] }), - { rows: [{ $rea: "collision" }] }, +type Observed = readonly { path: (string | number)[]; shape: string }[]; +const at = (shape: string, ...path: (string | number)[]) => ({ path, shape }); + +/** Assert the caller-error classification and its structured facts, not prose. */ +const selectionRefusal = ( + stream: ReturnType, + option: "format" | "filter-output", + observed: Observed, +) => { + const error = stream.output.error; + expect(lines(stream.text())).toEqual([jsonLinesErrorRecord(error)]); + // A completed analysis refused only by its output selection is a request the + // caller changes, not unreadable provider output. + expect(error).toMatchObject({ + code: "invalid_request", + category: "invalid_input", + retryable: false, + remediation: { action: JSON_LINES_SELECTION_REMEDIATION }, + recovery: { kind: "inspect_input_contract", operation: "fixture" }, + details: { + operation: "fixture", + issues: [{ path: [option], reason: "invalid_value", observed }], + }, + }); + const [issue] = z + .array(z.object({ message: z.string().min(1) })) + .parse(error?.details?.issues); + // The issue explanation is delivered in the top-level message. + expect(error?.message).toContain(issue?.message); +}; + +describe("JSON Lines selection refusals", () => { + it.each<[unknown, Observed]>([ + [null, [at("null")]], + [{}, [at("object")]], + ["scalar", [at("string")]], + [ + { normalized_result: { schema: { tables: [{ name: "t" }] } } }, + [at("object")], + ], + [ + { rows: null, page: { items: 3 } }, + [at("null", "rows"), at("number", "page", "items")], + ], + [ + { normalized_result: { rows: { columns: ["a"], values: [[1]] } } }, + [at("object", "normalized_result", "rows")], + ], + [{ rows: [], items: [] }, [at("array", "rows"), at("array", "items")]], + [ + { rows: [], normalized_result: { page: { records: [] } } }, + [ + at("array", "rows"), + at("array", "normalized_result", "page", "records"), + ], + ], + [Object.create({ rows: [{ inherited: true }] }), [at("object")]], + [ + { rows: [{ id: 1 }, { $rea: "collision" }] }, + [at("string", "rows", 1, "$rea")], + ], + [[{ $rea: { nested: true } }], [at("object", 0, "$rea")]], ])( - "refuses an unclear or reserved boundary before writing rows: %j", - async (value) => { + "refuses an unclear or reserved boundary as a caller selection before writing rows: %j", + async (value, observed) => { const stream = capture(["--format", "jsonl", "--token-count"]); await stream.output.write(value, metadata); - expect(lines(stream.text())).toEqual([ - jsonLinesErrorRecord(stream.output.error), - ]); - expect(stream.output.error?.code).toBe("unreadable_output"); + selectionRefusal(stream, "format", observed); }, ); - it.each(["missing", "count", "nested.value"])( - "does not admit a scalar/missing selection merely because a filter exists: %s", - async (filter) => { - const stream = capture(["--format", "jsonl", "--filter-output", filter]); + it.each<[string[], "format" | "filter-output", Observed]>([ + [[], "format", [at("null", "data", "normalized_result", "rows")]], + [ + ["--filter-output", "normalized_result.missing"], + "filter-output", + [at("absent", "data", "normalized_result", "missing")], + ], + ])( + "locates the refusal in the enveloped document with --full-output: %j", + async (filter, option, observed) => { + const stream = capture(["--format", "jsonl", "--full-output", ...filter]); await stream.output.write( - { rows: [{ id: 1 }], count: 0, nested: { value: null } }, + { evidence_id: "ev:parent", normalized_result: { rows: null } }, metadata, ); - expect(lines(stream.text())).toHaveLength(1); - expect(lines(stream.text())[0]).toMatchObject({ - $rea: "jsonl/v1", - type: "error", - result: { code: "unreadable_output" }, + expect(lines(stream.text())).toEqual([ + jsonLinesErrorRecord({ + ok: false, + error: stream.output.error, + meta: { command: "fixture", duration: "1ms" }, + }), + ]); + expect(stream.output.error).toMatchObject({ + code: "invalid_request", + details: { issues: [{ path: [option], observed }] }, }); }, ); + it.each<[string, Observed]>([ + ["missing", [at("absent", "missing")]], + ["count", [at("number", "count")]], + ["nested.value", [at("null", "nested", "value")]], + ["nested.absent", [at("absent", "nested", "absent")]], + ["nested.list[0,1]", [at("array", "nested", "list")]], + ["table", [at("object", "table")]], + ["table.values", [at("array", "table", "values")]], + ["rows,items", [at("array", "rows"), at("array", "items")]], + ["count,nested", [at("object")]], + ])( + "does not admit a selection merely because a filter exists: %s", + async (filter, observed) => { + const stream = capture(["--format", "jsonl", "--filter-output", filter]); + await stream.output.write( + { + rows: [{ id: 1 }], + items: [{ id: 2 }], + count: 0, + nested: { value: null, list: [1, 2] }, + table: { + columns: ["id"], + values: [[{ type: "integer", value: "1" }]], + }, + }, + metadata, + ); + selectionRefusal(stream, "filter-output", observed); + }, + ); +}); + +describe("JSON Lines admission and selection", () => { + it.each([ + { value: [1, "two", null, false, [3]] }, + { value: { rows: [1, "two", null, false, [3]] } }, + ])( + "emits primitive and array records verbatim, one per line: %j", + async ({ value }) => { + const stream = capture(["--format", "jsonl"]); + await stream.output.write(value, metadata); + const records = lines(stream.text()); + expect(records.slice(0, 5)).toEqual([1, "two", null, false, [3]]); + expect(records.length).toBe(Array.isArray(value) ? 5 : 6); + expect(stream.output.error).toBeUndefined(); + }, + ); + + it("emits no lines for an empty top-level array and only metadata for an empty collection", async () => { + const bare = capture(["--format", "jsonl"]); + await bare.output.write([], metadata); + expect(bare.text()).toBe(""); + expect(bare.output.error).toBeUndefined(); + const wrapped = capture(["--format", "jsonl"]); + await wrapped.output.write( + { items: [], coverage: { examined: 0 } }, + metadata, + ); + expect(lines(wrapped.text())).toEqual([ + { + $rea: "jsonl/v1", + type: "metadata", + records: { path: ["items"], emitted: 0, source: 0 }, + context: { coverage: { examined: 0 } }, + }, + ]); + }); + it("records output selection separately from source coverage and keeps provenance", async () => { const value = { evidence_id: "ev:parent", diff --git a/tests/boundary/cli/jsonOutput.test.ts b/tests/boundary/cli/jsonOutput.test.ts index 5f6b795585..c5f18af6a0 100644 --- a/tests/boundary/cli/jsonOutput.test.ts +++ b/tests/boundary/cli/jsonOutput.test.ts @@ -785,7 +785,7 @@ describe("streamed CLI filtered selections", () => { expect(JSON.parse(text)).toMatchObject({ $rea: "jsonl/v1", type: "error", - result: { code: "unreadable_output" }, + result: { code: "invalid_request", category: "invalid_input" }, }); else expect(text).toBe(expected); },