* 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>
117 lines
3.9 KiB
TypeScript
117 lines
3.9 KiB
TypeScript
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";
|
|
import {
|
|
projectAnalysisError,
|
|
type AnalysisErrorProjection,
|
|
} from "../domain/analysisErrorProjection.js";
|
|
import {
|
|
jsonLineParts,
|
|
jsonLinesErrorRecord,
|
|
jsonLinesParts,
|
|
jsonLinesRecords,
|
|
prepareJsonLines,
|
|
} from "./jsonLines.js";
|
|
import {
|
|
writeJsonOutput,
|
|
writeOutputParts,
|
|
writeOutputText,
|
|
} from "./jsonOutput.js";
|
|
|
|
interface CliJsonLinesInput {
|
|
readonly source: unknown;
|
|
readonly selected: unknown;
|
|
readonly operationFailure: unknown;
|
|
readonly command: string;
|
|
readonly duration: string;
|
|
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. */
|
|
export const writeCliJsonLines = async (
|
|
options: CliJsonLinesInput,
|
|
destination: Writable,
|
|
): Promise<AnalysisErrorProjection | undefined> => {
|
|
const { source, selected, operationFailure } = options;
|
|
let error: AnalysisErrorProjection | undefined;
|
|
const meta = { command: options.command, duration: options.duration };
|
|
const envelope = (data: unknown) =>
|
|
options.fullOutput ? { ok: true, data, meta } : data;
|
|
let failureRecord =
|
|
operationFailure === undefined
|
|
? undefined
|
|
: jsonLinesErrorRecord(
|
|
options.fullOutput
|
|
? { ok: false, error: operationFailure, meta }
|
|
: operationFailure,
|
|
);
|
|
const plan =
|
|
failureRecord === undefined
|
|
? prepareJsonLines(envelope(selected), {
|
|
command: options.command,
|
|
source: envelope(source),
|
|
...(options.filterOutput === undefined
|
|
? {}
|
|
: { filter: options.filterOutput }),
|
|
...(options.filterPaths === undefined
|
|
? {}
|
|
: { filterPaths: options.filterPaths }),
|
|
fullOutput: options.fullOutput,
|
|
})
|
|
: undefined;
|
|
if (plan?.ok === false) {
|
|
error = projectAnalysisError(plan.error);
|
|
failureRecord = jsonLinesErrorRecord(
|
|
options.fullOutput ? { ok: false, error, meta } : error,
|
|
);
|
|
}
|
|
if (failureRecord !== undefined) {
|
|
await writeJsonOutput(failureRecord, destination, "jsonl");
|
|
} else if (plan?.ok === true) {
|
|
if (options.tokenCount) {
|
|
let count = 0;
|
|
for (const record of jsonLinesRecords(plan.value)) {
|
|
let characters = 0;
|
|
for (const part of jsonLineParts(record)) characters += part.length;
|
|
if (characters > constants.MAX_STRING_LENGTH) {
|
|
error = projectAnalysisError(
|
|
new AnalysisResourceConstraintError(
|
|
options.command,
|
|
"memory",
|
|
"A JSON Lines record exceeds the token estimator's runtime string limit.",
|
|
{
|
|
formatted_characters: characters,
|
|
max_string_characters: constants.MAX_STRING_LENGTH,
|
|
},
|
|
{
|
|
remediationAction:
|
|
"Remove --token-count to stream the records, or select smaller fields with --filter-output.",
|
|
},
|
|
),
|
|
);
|
|
break;
|
|
}
|
|
// Newlines split tokenx segments, so summing complete lines is
|
|
// identical to estimating the emitted stream, with one-line memory.
|
|
count += estimateTokenCount([...jsonLineParts(record)].join(""));
|
|
}
|
|
if (error !== undefined)
|
|
await writeJsonOutput(
|
|
jsonLinesErrorRecord(
|
|
options.fullOutput ? { ok: false, error, meta } : error,
|
|
),
|
|
destination,
|
|
"jsonl",
|
|
);
|
|
else await writeOutputText(destination, `${count}\n`);
|
|
} else await writeOutputParts(destination, jsonLinesParts(plan.value));
|
|
}
|
|
return error;
|
|
};
|