mirror of
https://github.com/morluto/rea
synced 2026-10-25 22:08:11 -04:00
fix(cli): report JSON Lines selection refusals as caller errors (#7124)
* fix(cli): report JSON Lines selection refusals as caller errors A --format jsonl refusal because the selected value is not a record collection is the caller's output selection, not unreadable analysis output. Report it as invalid_request/invalid_input that names the selection and its observed shape, keep the --format json / --filter-output remediation, and drop the rea doctor advice. AnalysisInputError gains an optional producer remediation, mirroring the other refusal classes. The JSON Lines owner examines its documented boundaries once for both admission and refusal facts. docs/cli.md now states that columnar results such as inspect-sqlite-database --table rows are not record collections. Fixes #7110 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * fix(cli): carry JSON Lines refusal facts as structured observations JSON Lines refusals now put the location and shape of each refused value in details.issues[].observed, and the message is built from those facts. Tests assert the classification, issue path, observed facts, and the shared remediation constant instead of fixed sentences. The filtered analysis-view export check now uses a non-empty request and compares the emitted rows with the unfiltered export. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
+8
-3
@@ -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:
|
||||
|
||||
|
||||
@@ -337,6 +337,7 @@ export const createCliResultOutput = (
|
||||
...options,
|
||||
source: value,
|
||||
selected: filtered,
|
||||
filterPaths,
|
||||
operationFailure,
|
||||
command: metadata.command,
|
||||
duration: metadata.duration,
|
||||
|
||||
+211
-63
@@ -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<string, unknown>,
|
||||
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<typeof Filter.parse> | undefined,
|
||||
): string[][] => {
|
||||
const selected = new Map<string, string[]>();
|
||||
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<typeof Filter.parse>;
|
||||
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<JsonLinesPlan, AnalysisOutputError> => {
|
||||
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<Collection, AnalysisInputError> => {
|
||||
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<JsonLinesPlan, AnalysisInputError> => {
|
||||
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);
|
||||
}
|
||||
|
||||
@@ -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<typeof Filter.parse> | 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;
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 }),
|
||||
})),
|
||||
|
||||
@@ -164,6 +164,9 @@ const redactFailure = (
|
||||
...(issue.expected === undefined
|
||||
? {}
|
||||
: { expected: visit(issue.expected) }),
|
||||
...(issue.observed === undefined
|
||||
? {}
|
||||
: { observed: visit(issue.observed) }),
|
||||
})),
|
||||
);
|
||||
if (error instanceof AnalysisOutputError)
|
||||
|
||||
@@ -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 }],
|
||||
},
|
||||
},
|
||||
});
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
@@ -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" }],
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
});
|
||||
},
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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<typeof capture>,
|
||||
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",
|
||||
|
||||
@@ -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);
|
||||
},
|
||||
|
||||
Reference in New Issue
Block a user