* feat(cli): add JSON Lines output for row-oriented results (#5487) - Support opt-in JSON Lines format (--format jsonl) for row-oriented results and collection views with clear record boundaries - Preserve stable source identities and stream records deterministically line-by-line - Emit final coverage and limitation metadata as a distinct line with https://rea.dev/schemas/jsonl-coverage-v1.json to prevent confusion with data rows - Distinguish error outputs with https://rea.dev/schemas/jsonl-error-v1.json - Reject non-row-oriented results that lack record boundaries with an unreadable_output error - Document JSON Lines streaming conventions in docs/cli.md - Add boundary tests covering empty results, multiline strings, partial coverage, large fields, and non-row rejection * fix(cli): preserve diagnostics, provenance, and nested collections in JSONL * fix(cli): frame classified failures and reject unrepresented JSONL facets --------- Co-authored-by: gensokyo-dev <342580529+gensokyo-dev@users.noreply.github.com>
112 lines
3.7 KiB
TypeScript
112 lines
3.7 KiB
TypeScript
import { constants } from "node:buffer";
|
|
import type { Writable } from "node:stream";
|
|
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;
|
|
}
|
|
|
|
/** 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 }),
|
|
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;
|
|
};
|