feat(cli): add CSV and TSV analysis views (#7254)

* feat(cli): add CSV and TSV analysis views

Co-authored-by: Copilot <[email protected]>

* Update tests/boundary/cli/filteredArrayOutput.test.ts

Co-authored-by: greptile-apps[bot] <165735046+greptile-apps[bot]@users.noreply.github.com>

* Update src/cli.ts

Co-authored-by: greptile-apps[bot] <165735046+greptile-apps[bot]@users.noreply.github.com>

* fix(cli): address delimited export review feedback

* fix(cli): format delimited output block in cliResultOutput

* fix(cli): retain delimited view uncertainty and align completions

---------

Co-authored-by: Copilot <[email protected]>
Co-authored-by: greptile-apps[bot] <165735046+greptile-apps[bot]@users.noreply.github.com>
Co-authored-by: gensokyo-dev <[email protected]>
This commit is contained in:
authored and GitHub committed 2026-10-28 11:27:40 +08:00
1 parent 40b9c03f99
commit aa6f6436c9
13 files changed
+1014 -36

No files matched your search

+29
View File
@@ -972,6 +972,35 @@ values instead of replacing them with an object label. Table widths are measured
iteratively, so an admitted row page is not limited by the JavaScript function
argument count.
`--format csv` and `--format tsv` export one selected row collection. Select an
exact array path with `--filter-output` and provide comma-separated direct
scalar columns with `--columns`; column order is the output column order:
```bash
rea inspect-analysis-view request.json --format csv \
--filter-output normalized_result.rows --columns name,address
```
The first line is a REA metadata record,
`# rea-delimited-v1 <JSON>`. It carries the selected path, view request, limitations and unknowns,
coverage, source coverage and completeness, command, and duration. A metadata
value of `null` means that the selected row collection did not provide that
fact; it does not mean complete coverage. The following line is the header.
CSV records use CRLF line endings; TSV records use LF. Cells containing the
delimiter, quotes, or line breaks are quoted and embedded quotes are doubled.
Values are emitted as UTF-8 without Unicode normalization. Missing and `null`
cells are empty; booleans and finite numbers use their ordinary text forms.
String cells and headers that begin with spreadsheet formula syntax are refused
with a structured error; JSON retains those values unchanged.
Delimited output does not flatten nested Evidence or nested field values.
It returns a structured error and a nonzero status if the filter does not
select exactly one row array, a row is not an object, or a selected cell is
nested or formula-leading. JSON remains the complete canonical output; use it
to retain fields outside the selected columns or preserve exact formula-leading
text. Token counting and token windows are not
available for CSV/TSV because slicing would corrupt records or metadata.
`--filter-output` selects fields from successful command data. Classified failures
retain their complete typed diagnostic projection even when the selector matches
a field such as `code` or `message`, or names a missing field. `--full-output`
+8 -2
View File
@@ -20,7 +20,10 @@ import { registerBinaryDiagnosticsCommands } from "./cli/binaryDiagnosticsComman
import { registerFridaCommands } from "./cli/fridaCommands.js";
import type { FridaInstrumentationPort } from "./application/frida/FridaInstrumentationPort.js";
import { Cli } from "incur";
import { normalizeCliArguments } from "./cliBuiltinArguments.js";
import {
adaptCliOutputArgumentsForIncur,
normalizeCliArguments,
} from "./cliBuiltinArguments.js";
import { createLogger } from "./logger.js";
import { parseLogLevel } from "./config/processStart.js";
@@ -177,7 +180,10 @@ export const createCli = (
: serveStrict(
normalizeCliArguments(arguments_),
options ?? {},
(tokens, selected) => serveDeclared(tokens, selected, serve),
(tokens, selected) =>
serveDeclared(tokens, selected, (declared, declaredOptions) =>
serve(adaptCliOutputArgumentsForIncur(declared), declaredOptions),
),
);
return cli;
};
+193 -3
View File
@@ -22,12 +22,202 @@
*/
import { Formatter } from "incur";
import { AnalysisInputError } from "../domain/analysisErrorCore.js";
import { projectAnalysisError } from "../domain/analysisErrorProjection.js";
import { err, ok, type Result } from "../domain/result.js";
export interface CliDelimitedOutputOptions {
readonly fields: readonly string[];
readonly metadata: Readonly<Record<string, unknown>>;
}
type DelimitedScalar = string | number | boolean | null | undefined;
export interface PreparedDelimitedOutput {
readonly encodedHeaders: readonly string[];
readonly rows: readonly (readonly DelimitedScalar[])[];
readonly metadata: Readonly<Record<string, unknown>>;
readonly delimiter: string;
readonly lineEnding: string;
readonly format: "csv" | "tsv";
}
/** Keep REA's Markdown layout compatible with Incur while owning table safety. */
export const formatCliOutput = (
value: unknown,
format: Formatter.Format,
): string =>
format === "md" ? formatMarkdown(value) : Formatter.format(value, format);
format: Formatter.Format | "csv" | "tsv",
delimited?: CliDelimitedOutputOptions,
): string => {
if (format === "csv" || format === "tsv") {
const prepared = prepareDelimitedOutput(
value,
format,
delimited ?? {
fields: [],
metadata: {},
},
);
return prepared.ok
? [...formatDelimitedOutputParts(prepared.value)].join("")
: Formatter.format(projectAnalysisError(prepared.error), "json");
}
return format === "md"
? formatMarkdown(value)
: Formatter.format(value, format);
};
/** Validate a complete row page before the caller writes any output. */
export const prepareDelimitedOutput = (
rows: unknown,
format: "csv" | "tsv",
{ fields, metadata }: CliDelimitedOutputOptions,
): Result<PreparedDelimitedOutput, AnalysisInputError> => {
const operation =
typeof metadata.command === "string"
? metadata.command
: "render-cli-delimited-output";
if (!isPlainObjectArray(rows))
return err(
delimitedOutputInputError(
["--filter-output"],
Array.isArray(rows)
? "CSV/TSV output requires every selected row to be a plain object."
: "The selected output path does not contain a row array.",
operation,
),
);
if (fields.length === 0)
return err(
delimitedOutputInputError(
["--columns"],
"CSV/TSV output requires at least one explicitly selected field.",
operation,
),
);
const encodedHeaders: string[] = [];
for (const field of fields) {
if (isSpreadsheetFormula(field))
return err(
delimitedOutputInputError(
["--columns", field],
"CSV/TSV output refuses spreadsheet formula-leading text; JSON retains the exact value.",
operation,
),
);
encodedHeaders.push(encodeDelimitedText(field, format));
}
const preparedRows: DelimitedScalar[][] = [];
for (const record of rows) {
const preparedRow: DelimitedScalar[] = [];
for (const field of fields) {
const value = Object.hasOwn(record, field) ? record[field] : undefined;
if (
value !== null &&
value !== undefined &&
typeof value !== "string" &&
typeof value !== "boolean" &&
!(typeof value === "number" && Number.isFinite(value))
)
return err(
delimitedOutputInputError(
["--columns", field],
"CSV/TSV cells must be scalar values; nested objects and arrays are not flattened.",
operation,
),
);
if (typeof value === "string" && isSpreadsheetFormula(value))
return err(
delimitedOutputInputError(
["--columns", field],
"CSV/TSV output refuses spreadsheet formula-leading text; JSON retains the exact value.",
operation,
),
);
preparedRow.push(value);
}
preparedRows.push(preparedRow);
}
return ok({
encodedHeaders,
rows: preparedRows,
metadata,
delimiter: format === "csv" ? "," : "\t",
lineEnding: format === "csv" ? "\r\n" : "\n",
format,
});
};
export const delimitedOutputInputError = (
path: readonly (string | number)[],
message: string,
operation = "render-cli-delimited-output",
): AnalysisInputError =>
new AnalysisInputError(operation, { userMessage: message }, [
{ path, reason: "invalid_value", message },
]);
/** Render a validated row page, preceded by a versioned metadata record. */
export const formatDelimitedRows = (
rows: unknown,
format: "csv" | "tsv",
options: CliDelimitedOutputOptions,
): Result<string, AnalysisInputError> => {
const prepared = prepareDelimitedOutput(rows, format, options);
return prepared.ok
? ok([...formatDelimitedOutputParts(prepared.value)].join(""))
: prepared;
};
/** Stream a page after its rows and cells have been validated and snapshotted. */
export function* formatDelimitedOutputParts({
encodedHeaders,
rows,
metadata,
delimiter,
lineEnding,
format,
}: PreparedDelimitedOutput): Generator<string> {
yield `# rea-delimited-v1 ${JSON.stringify(metadata)}${lineEnding}`;
yield `${encodedHeaders.join(delimiter)}${lineEnding}`;
for (const row of rows)
yield `${row
.map((value) => encodeDelimitedScalar(value, format))
.join(delimiter)}${lineEnding}`;
}
const encodeDelimitedScalar = (
value: DelimitedScalar,
format: "csv" | "tsv",
): string => {
if (value === null || value === undefined) return "";
const text = String(value);
return encodeDelimitedText(text, format);
};
const encodeDelimitedText = (text: string, format: "csv" | "tsv"): string => {
const needsQuotes =
format === "csv" ? /[",\r\n]/u.test(text) : /["\t\r\n]/u.test(text);
return needsQuotes ? `"${text.replace(/"/gu, '""')}"` : text;
};
const isSpreadsheetFormula = (value: string): boolean =>
/^[\u0000-\u0020]*[=+\-@]/u.test(value) || /^[\t\r]/u.test(value);
const isPlainObjectArray = (
value: unknown,
): value is readonly Readonly<Record<string, unknown>>[] =>
Array.isArray(value) &&
value.every(
(row: unknown) =>
typeof row === "object" &&
row !== null &&
!Array.isArray(row) &&
(Object.getPrototypeOf(row) === Object.prototype ||
Object.getPrototypeOf(row) === null),
);
const isScalar = (value: unknown): boolean =>
value === null || value === undefined || typeof value !== "object";
+190 -4
View File
@@ -5,6 +5,7 @@ import { Filter } from "incur";
import { estimateTokenCount } from "tokenx";
import type { ParsedCliOutputArguments } from "../cliOutput.js";
import { analysisErrorProjectionSchema } from "../contracts/errorSchemas.js";
import {
cliOperationFailureIdentity,
cliOperationFailurePayload,
@@ -19,13 +20,19 @@ import {
projectAnalysisError,
type AnalysisErrorProjection,
} from "../domain/analysisErrorProjection.js";
import { formatCliOutput } from "./cliOutputFormatter.js";
import {
formatDelimitedOutputParts,
formatCliOutput,
prepareDelimitedOutput,
delimitedOutputInputError,
} from "./cliOutputFormatter.js";
import { jsonLinesErrorRecord } from "./jsonLines.js";
import { writeCliJsonLines } from "./jsonLinesOutput.js";
import {
jsonOutputParts,
isTerminalOutput,
writeJsonOutput,
writeOutputParts,
writeOutputText,
} from "./jsonOutput.js";
import type { CliInstance } from "./types.js";
@@ -121,7 +128,14 @@ export const createCliResultOutput = (
): CliResultOutput | undefined => {
let options = initialOptions;
if (!options.validation.ok) return undefined;
let format = options.format as "json" | "jsonl" | "yaml" | "toon" | "md";
let format = options.format as
| "json"
| "jsonl"
| "yaml"
| "toon"
| "md"
| "csv"
| "tsv";
let handled = false;
let failed = false;
let error: AnalysisErrorProjection | undefined;
@@ -180,9 +194,11 @@ export const createCliResultOutput = (
: error;
// Recovery must remain actionable even when a token window/count was
// requested. Do not apply those controls to this small typed failure.
const recoveryFormat =
format === "csv" || format === "tsv" ? "json" : format;
const text = formatCliOutput(
format === "jsonl" ? jsonLinesErrorRecord(document) : document,
format,
recoveryFormat,
);
handled = true;
try {
@@ -198,7 +214,11 @@ export const createCliResultOutput = (
metadata: CliResultOutputMetadata,
): Promise<boolean> => {
completed = undefined;
if (metadata.format !== format) return false;
if (
metadata.format !== format &&
!((format === "csv" || format === "tsv") && metadata.format === "json")
)
return false;
try {
return await writeResult(value, metadata);
} catch (cause: unknown) {
@@ -305,6 +325,74 @@ export const createCliResultOutput = (
!options.tokenCount
)
return false;
if (format === "csv" || format === "tsv") {
const meta = {
command: metadata.command,
duration: metadata.duration,
};
if (operationFailure !== undefined) {
const parsedFailure =
analysisErrorProjectionSchema.safeParse(operationFailure);
error = parsedFailure.success
? parsedFailure.data
: projectAnalysisError(
delimitedOutputInputError(
["--format"],
"CSV/TSV output was not produced because the command did not return a successful row collection.",
metadata.command,
),
);
handled = true;
try {
await writeJsonOutput(
options.fullOutput
? { ok: false, error: operationFailure, meta }
: operationFailure,
destination,
);
} catch (cause: unknown) {
failed = true;
throw cause;
}
return true;
}
const selection = resolveDelimitedRows(
value,
filterPaths,
metadata.command,
);
const prepared = selection.ok
? prepareDelimitedOutput(selection.value.rows, format, {
fields: options.columns ?? [],
metadata: delimitedMetadata(selection.value, format, metadata),
})
: selection;
if (!prepared.ok) {
error = projectAnalysisError(prepared.error);
handled = true;
try {
await writeJsonOutput(
options.fullOutput ? { ok: false, error, meta } : error,
destination,
);
} catch (writeFailure: unknown) {
failed = true;
throw writeFailure;
}
} else {
handled = true;
try {
await writeOutputParts(
destination,
formatDelimitedOutputParts(prepared.value),
);
} catch (cause: unknown) {
failed = true;
throw cause;
}
}
return true;
}
const selection =
operationFailure !== undefined || filterPaths === undefined
? ok(operationFailure ?? value)
@@ -585,6 +673,104 @@ const fullFailureEnvelope = (
type FilterPaths = ReturnType<typeof Filter.parse>;
type FilterPath = FilterPaths[number];
interface DelimitedRowsSelection {
readonly rows: readonly unknown[];
readonly path: readonly string[];
readonly ancestors: readonly Readonly<Record<string, unknown>>[];
}
const resolveDelimitedRows = (
source: unknown,
paths: FilterPaths | undefined,
operation: string,
): Result<DelimitedRowsSelection, AnalysisInputError> => {
const invalidSelection = (): Result<never, AnalysisInputError> =>
err(
delimitedOutputInputError(
["--filter-output"],
"--filter-output must select exactly one row-array path using object-field segments only.",
operation,
),
);
if (paths === undefined || paths.length !== 1) return invalidSelection();
const path: string[] = [];
for (const segment of paths[0] ?? []) {
if (!("key" in segment)) return invalidSelection();
path.push(segment.key);
}
if (path.length === 0) return invalidSelection();
let current: unknown = source;
const ancestors: Readonly<Record<string, unknown>>[] = [];
for (const key of path) {
if (
typeof current !== "object" ||
current === null ||
Array.isArray(current) ||
!Object.hasOwn(current, key)
)
return invalidSelection();
const owner = current as Readonly<Record<string, unknown>>;
ancestors.push(owner);
current = owner[key];
}
return Array.isArray(current)
? ok({ rows: current, path, ancestors })
: invalidSelection();
};
const delimitedMetadata = (
selection: DelimitedRowsSelection,
format: "csv" | "tsv",
metadata: CliResultOutputMetadata,
): Readonly<Record<string, unknown>> => {
const valueFromOwner = (
key: string,
): { readonly found: boolean; readonly value: unknown } => {
for (let index = selection.ancestors.length - 1; index >= 0; index -= 1) {
const owner = selection.ancestors[index]!;
if (Object.hasOwn(owner, key)) return { found: true, value: owner[key] };
}
return { found: false, value: undefined };
};
const projectOwnFields = (
keys: readonly string[],
): Readonly<Record<string, unknown>> =>
Object.fromEntries(
keys.flatMap((key) => {
const found = valueFromOwner(key);
return found.found ? [[key, found.value]] : [];
}),
);
const metadataValue = (key: string): unknown => valueFromOwner(key).value;
return {
version: 1,
format,
command: metadata.command,
duration: metadata.duration,
selected_path: selection.path,
coverage: metadataValue("coverage") ?? null,
source_coverage: metadataValue("source_coverage") ?? null,
source_complete: metadataValue("source_complete") ?? null,
view: metadataValue("view") ?? null,
limitations: metadataValue("limitations") ?? null,
unknowns: metadataValue("unknowns") ?? null,
provenance: projectOwnFields([
"parent_evidence_id",
"parent_operation",
"parent_digest",
"inventory_evidence_ids",
]),
pagination: projectOwnFields([
"offset",
"limit",
"total",
"next_offset",
"truncated",
"pagination",
]),
};
};
/** Apply Incur's parsed path grammar while merging sibling array selections. */
type FilterProjection = Result<unknown, string>;
+15 -4
View File
@@ -9,7 +9,11 @@ import {
normalizeCliArguments,
splitIncurBuiltinArguments,
} from "../cliBuiltinArguments.js";
import { CLI_OUTPUT_FORMATS, parseCliOutputArguments } from "../cliOutput.js";
import {
CLI_DOCUMENT_OUTPUT_FORMATS,
CLI_OUTPUT_FORMATS,
parseCliOutputArguments,
} from "../cliOutput.js";
import { bashCompletionScript } from "./bashCompletionRegistration.js";
type Commands = Parameters<typeof Completions.complete>[0];
@@ -17,7 +21,7 @@ type CompletionCli = Parameters<typeof Cli.toCommands.get>[0];
type ServeOptions = NonNullable<Parameters<CompletionCli["serve"]>[1]>;
const ROOT_FLAGS = new Set(["--mcp", "--update", "--version"]);
const standardGlobals = (root: boolean): z.ZodObject =>
const standardGlobals = (root: boolean, doctor: boolean): z.ZodObject =>
z.object(
Object.fromEntries([
...[...VALUED_OUTPUT_FLAGS].map(
@@ -25,7 +29,11 @@ const standardGlobals = (root: boolean): z.ZodObject =>
[
flag.slice(2),
flag === "--format"
? z.enum(CLI_OUTPUT_FORMATS).optional()
? z
.enum(
doctor ? CLI_DOCUMENT_OUTPUT_FORMATS : CLI_OUTPUT_FORMATS,
)
.optional()
: z.string().optional(),
] as const,
),
@@ -111,7 +119,10 @@ export const createCliCompletions = (
input,
input.length - 1,
{
schema: standardGlobals(before.length === 0),
schema: standardGlobals(
before.length === 0,
before[0] === "mcp" && before[1] === "doctor",
),
alias: { help: "h" },
},
);
+31
View File
@@ -2,6 +2,7 @@
export const VALUED_OUTPUT_FLAGS: ReadonlySet<string> = new Set([
"--format",
"--filter-output",
"--columns",
"--token-limit",
"--token-offset",
]);
@@ -105,6 +106,36 @@ export const normalizeCliArguments = (
: normalized;
};
/** Keep REA-owned delimited formats out of Incur's narrower formatter surface. */
export const adaptCliOutputArgumentsForIncur = (
arguments_: readonly string[],
): string[] => {
const adapted: string[] = [];
for (let index = 0; index < arguments_.length; index += 1) {
const argument = arguments_[index]!;
const value = arguments_[index + 1];
if (VALUED_OUTPUT_FLAGS.has(argument)) {
if (argument === "--columns") {
if (value !== undefined) index += 1;
continue;
}
if (argument === "--format" && (value === "csv" || value === "tsv")) {
adapted.push("--format", "json");
index += 1;
continue;
}
adapted.push(argument);
if (value) {
adapted.push(value);
index += 1;
}
continue;
}
adapted.push(argument);
}
return adapted;
};
/** Select production doctor using the same normalized builtin argument owner as CLI dispatch. */
export const productionMcpDoctorArguments = (
arguments_: readonly string[],
+89 -14
View File
@@ -19,19 +19,25 @@ const UNSUPPORTED_OUTPUT_COMBINATION_MESSAGE =
"Token windows cannot preserve structured output. Remove --token-limit/--token-offset and use --filter-output or command pagination.";
type StructuredOutputFormat = "json" | "jsonl" | "yaml";
/** Output formats Incur renders, in its documented order. */
export const CLI_OUTPUT_FORMATS = [
/** Formats that render a complete document, including target-free commands. */
export const CLI_DOCUMENT_OUTPUT_FORMATS = [
"toon",
"json",
"yaml",
"md",
"jsonl",
] as const;
/** Delimited formats require an explicitly selected row collection. */
export const CLI_OUTPUT_FORMATS = [
...CLI_DOCUMENT_OUTPUT_FORMATS,
"csv",
"tsv",
] as const;
export type CliOutputFormat = (typeof CLI_OUTPUT_FORMATS)[number];
const INCUR_OUTPUT_FORMATS: ReadonlySet<string> = new Set(CLI_OUTPUT_FORMATS);
const CLI_OUTPUT_FORMAT_SET: ReadonlySet<string> = new Set(CLI_OUTPUT_FORMATS);
const isCliOutputFormat = (value: string): value is CliOutputFormat =>
INCUR_OUTPUT_FORMATS.has(value);
CLI_OUTPUT_FORMAT_SET.has(value);
/**
* Render a document REA writes outside Incur's command pipeline. JSON follows
@@ -54,9 +60,17 @@ export const cliOutputValueRejection = (
value: string,
): string | undefined => {
if (flag === "--format")
return INCUR_OUTPUT_FORMATS.has(value)
return CLI_OUTPUT_FORMAT_SET.has(value)
? undefined
: `Invalid format: "${value}". Expected one of: ${[...INCUR_OUTPUT_FORMATS].join(", ")}`;
: `Invalid format: "${value}". Expected one of: ${[...CLI_OUTPUT_FORMAT_SET].join(", ")}`;
if (flag === "--columns") {
const columns = value.split(",").map((column) => column.trim());
return columns.length > 0 &&
columns.every((column) => column.length > 0) &&
new Set(columns).size === columns.length
? undefined
: `Invalid value for --columns: "${value}". Expected unique, nonempty comma-separated column names.`;
}
if (
(flag === "--token-limit" || flag === "--token-offset") &&
(!Number.isSafeInteger(Number(value)) ||
@@ -82,6 +96,7 @@ export type CliOutputArgumentValidation =
interface CliOutputControls {
readonly filterOutput: string | undefined;
readonly columns: readonly string[] | undefined;
readonly format: string;
/** Whether `--json` or `--format` selected the format, rather than the default. */
readonly formatExplicit: boolean;
@@ -110,6 +125,7 @@ export const parseCliOutputArguments = (
let versionEligible = true;
let formatExplicit = false;
let filterOutput: string | undefined;
let columns: readonly string[] | undefined;
let fullOutput = false;
let missingValue: string | undefined;
let invalidValue: string | undefined;
@@ -120,6 +136,7 @@ export const parseCliOutputArguments = (
const parseError = invalidValue !== undefined;
const controls: CliOutputControls = {
filterOutput,
columns,
format,
formatExplicit,
fullOutput,
@@ -141,13 +158,15 @@ export const parseCliOutputArguments = (
};
return {
...controls,
validation: validateCliOutputControls(controls),
validation: validateCliOutputControls(controls, rootVersion),
versionReportFormat:
rootVersion &&
versionEligible &&
formatExplicit &&
!parseError &&
missingValue === undefined &&
format !== "csv" &&
format !== "tsv" &&
isCliOutputFormat(format)
? format
: undefined,
@@ -190,6 +209,16 @@ export const parseCliOutputArguments = (
} else missingValue ??= argument;
continue;
}
if (argument === "--columns") {
const value = arguments_[index + 1];
if (value) {
const rejection = cliOutputValueRejection(argument, value);
if (rejection !== undefined) invalidValue ??= rejection;
else columns = value.split(",").map((column) => column.trim());
index += 1;
} else missingValue ??= argument;
continue;
}
if (argument === "--token-limit" || argument === "--token-offset") {
const value = arguments_[index + 1];
if (value) {
@@ -230,13 +259,18 @@ export const missingCliOutputValueMessage = (flag: string): string =>
`${flag} requires a value, as in ${flag} <value> or ${flag}=<value>.`;
/** Reject output controls that cannot safely produce a complete result. */
const validateCliOutputControls = ({
filterOutput,
format,
missingValue,
invalidValue,
tokenWindow,
}: CliOutputControls): CliOutputArgumentValidation => {
const validateCliOutputControls = (
{
filterOutput,
columns,
format,
missingValue,
invalidValue,
tokenWindow,
tokenCount,
}: CliOutputControls,
rootVersion: boolean,
): CliOutputArgumentValidation => {
if (invalidValue !== undefined)
return {
ok: false,
@@ -252,6 +286,47 @@ const validateCliOutputControls = ({
message:
"The --filter-output expression has a slice without a closing ]. Close the slice or remove --filter-output.",
};
const delimited = format === "csv" || format === "tsv";
// Name missing delimited controls before their combination checks; preserve
// existing precedence for Incur's other output flags.
if (missingValue !== undefined && (delimited || missingValue === "--columns"))
return {
ok: false,
format: format === "yaml" || format === "jsonl" ? format : "json",
code: "MISSING_OUTPUT_VALUE",
message: missingCliOutputValueMessage(missingValue),
};
if (delimited && rootVersion)
return {
ok: false,
format: "json",
code: "UNSUPPORTED_OUTPUT_COMBINATION",
message:
"CSV/TSV output is available only for selected row collections, not --version.",
};
if (delimited && (filterOutput === undefined || columns === undefined))
return {
ok: false,
format: "json",
code: "UNSUPPORTED_OUTPUT_COMBINATION",
message:
"CSV/TSV output requires --filter-output <row-array-path> and --columns <column,...>; nested Evidence is not flattened.",
};
if (!delimited && columns !== undefined)
return {
ok: false,
format: format === "yaml" || format === "jsonl" ? format : "json",
code: "UNSUPPORTED_OUTPUT_COMBINATION",
message: "--columns is available only with --format csv or --format tsv.",
};
if (delimited && (tokenWindow || tokenCount))
return {
ok: false,
format: "json",
code: "UNSUPPORTED_OUTPUT_COMBINATION",
message:
"Token counting and token windows cannot preserve CSV/TSV records or metadata. Remove --token-count/--token-limit/--token-offset.",
};
if (
tokenWindow &&
(format === "json" || format === "jsonl" || format === "yaml")
+3 -4
View File
@@ -8,8 +8,7 @@ import { StringDecoder } from "node:string_decoder";
import { createCatalogIdentity } from "./catalogIdentity.js";
import { jsonLinesErrorRecord } from "./cli/jsonLines.js";
import {
CLI_OUTPUT_FORMATS,
type CliOutputFormat,
CLI_DOCUMENT_OUTPUT_FORMATS,
missingCliOutputValueMessage,
renderCliDocument,
} from "./cliOutput.js";
@@ -32,8 +31,8 @@ import {
mcpDoctorDeadlineRecovery,
} from "./mcpStartupPolicy.js";
const OUTPUT_FORMATS = CLI_OUTPUT_FORMATS;
type OutputFormat = CliOutputFormat;
const OUTPUT_FORMATS = CLI_DOCUMENT_OUTPUT_FORMATS;
type OutputFormat = (typeof OUTPUT_FORMATS)[number];
/**
* Canonical `rea mcp doctor` options. This schema is the single source of
+90
View File
@@ -14,10 +14,89 @@ import {
renderVersionReport,
parseCliOutputArguments,
} from "../../../src/cliOutput.js";
import { adaptCliOutputArgumentsForIncur } from "../../../src/cliBuiltinArguments.js";
const CLI_INTEGRATION_TIMEOUT_MS = 60_000;
describe("CLI output argument boundary", () => {
it("requires an exact row selector and explicit columns for delimited formats", () => {
expect(
parseCliOutputArguments(["rows", "--format", "csv"]).validation,
).toMatchObject({
ok: false,
code: "UNSUPPORTED_OUTPUT_COMBINATION",
});
expect(
parseCliOutputArguments([
"rows",
"--format",
"tsv",
"--filter-output",
"normalized_result.items",
"--columns",
"name, version",
]),
).toMatchObject({
columns: ["name", "version"],
validation: { ok: true },
});
for (const fields of ["name,,version", "name,name"])
expect(
parseCliOutputArguments([
"rows",
"--format",
"csv",
"--filter-output",
"items",
"--columns",
fields,
]).validation,
).toMatchObject({ ok: false, code: "INVALID_OUTPUT_VALUE" });
expect(
parseCliOutputArguments([
"rows",
"--format",
"csv",
"--filter-output",
"items",
"--columns",
"",
]).validation,
).toMatchObject({ ok: false, code: "MISSING_OUTPUT_VALUE" });
expect(
parseCliOutputArguments([
"rows",
"--format",
"csv",
"--filter-output",
"items",
"--columns",
"name",
"--token-count",
]).validation,
).toMatchObject({ ok: false, code: "UNSUPPORTED_OUTPUT_COMBINATION" });
});
it("adapts REA-owned delimited options only at the Incur boundary", () => {
expect(
adaptCliOutputArgumentsForIncur([
"inspect-analysis-view",
"--format",
"csv",
"--columns",
"name,version",
"--filter-output",
"normalized_result.items",
]),
).toEqual([
"inspect-analysis-view",
"--format",
"json",
"--filter-output",
"normalized_result.items",
]);
});
it("uses the shared Markdown table formatter for standalone reports", () => {
const value = { text: "left|right\\path\nnext" };
expect(renderCliDocument(value, "md", false)).toContain(
@@ -95,6 +174,17 @@ describe("CLI output argument boundary", () => {
});
});
it("preserves flag-shaped values consumed by valued output options", () => {
expect(
adaptCliOutputArgumentsForIncur([
"--filter-output",
"--columns",
"providers",
"--json",
]),
).toEqual(["--filter-output", "--columns", "providers", "--json"]);
});
describe("CLI output value admission", () => {
it.each(["--token-limit", "--token-offset"])(
"rejects invalid %s values before dispatch and preserves later JSON selection",
@@ -0,0 +1,136 @@
import { expect, it } from "vitest";
import { formatDelimitedRows } from "../../../src/cli/cliOutputFormatter.js";
const parseDelimited = (source: string, delimiter: string): string[][] => {
const records: string[][] = [];
let record: string[] = [];
let cell = "";
let quoted = false;
for (let index = 0; index < source.length; index += 1) {
const character = source[index]!;
if (quoted) {
if (character === '"') {
if (source[index + 1] === '"') {
cell += '"';
index += 1;
} else quoted = false;
} else cell += character;
continue;
}
if (character === '"' && cell.length === 0) {
quoted = true;
continue;
}
if (character === delimiter) {
record.push(cell);
cell = "";
continue;
}
if (character === "\r" || character === "\n") {
if (character === "\r" && source[index + 1] === "\n") index += 1;
record.push(cell);
records.push(record);
record = [];
cell = "";
continue;
}
cell += character;
}
if (cell.length > 0 || record.length > 0) {
record.push(cell);
records.push(record);
}
return records;
};
it.each([
{ format: "csv" as const, delimiter: "," },
{ format: "tsv" as const, delimiter: "\t" },
])(
"round-trips escaped $format fields and Unicode values",
({ format, delimiter }) => {
const rows = [
{
name: 'A, "quoted" name',
note: "first line\r\nsecond\tline — café 雪",
count: 0,
ignored: { nested: true },
},
{ name: "", note: "", count: false },
{ name: "欠", count: null },
];
const output = formatDelimitedRows(rows, format, {
fields: ["name", "note", "count"],
metadata: {
version: 1,
coverage: { status: "page", examined: 3, total: 8, next_offset: 3 },
view: { offset: 0, limit: 3 },
},
});
expect(output.ok).toBe(true);
if (!output.ok) throw output.error;
const lineEnding = format === "csv" ? "\r\n" : "\n";
const metadataEnd = output.value.indexOf(lineEnding);
const metadataLine = output.value.slice(0, metadataEnd);
const records = output.value
.slice(metadataEnd + lineEnding.length)
.replace(/\r?\n$/u, "");
expect(
JSON.parse(metadataLine!.slice("# rea-delimited-v1 ".length)),
).toEqual({
version: 1,
coverage: { status: "page", examined: 3, total: 8, next_offset: 3 },
view: { offset: 0, limit: 3 },
});
expect(parseDelimited(records, delimiter)).toEqual([
["name", "note", "count"],
['A, "quoted" name', "first line\r\nsecond\tline — café 雪", "0"],
["", "", "false"],
["欠", "", ""],
]);
},
);
it("does not flatten nested selected field values", () => {
expect(
formatDelimitedRows([{ details: { name: "nested" } }], "csv", {
fields: ["details"],
metadata: {},
}),
).toMatchObject({
ok: false,
error: { _tag: "AnalysisInputError" },
});
});
it.each(["=1+1", " =1+1", "+1", "-1", "@SUM(A1:A2)"])(
"refuses formula-leading string cells without changing the source value %j",
(value) => {
const row = { target: value };
const result = formatDelimitedRows([row], "csv", {
fields: ["target"],
metadata: {},
});
expect(result).toMatchObject({
ok: false,
error: {
_tag: "AnalysisInputError",
userMessage: expect.stringContaining("formula-leading"),
},
});
expect(row.target).toBe(value);
},
);
it("refuses formula-leading column headers", () => {
expect(
formatDelimitedRows([{ value: "safe" }], "csv", {
fields: ["=header"],
metadata: {},
}),
).toMatchObject({
ok: false,
error: { _tag: "AnalysisInputError" },
});
});
+14 -2
View File
@@ -42,7 +42,10 @@ it("resolves enabled builtin scopes in the same tree as registered commands", ()
expect(complete(["rea", "mcp"], 2)).toEqual(["add", "doctor"]);
expect(complete(["rea", ""])).toEqual(["instructions", "mcp", "completions"]);
expect(complete(["rea", "completions", "b"])).toEqual(["bash"]);
expect(complete(["rea", "mcp", "add", "--c"])).toEqual(["--command"]);
expect(complete(["rea", "mcp", "add", "--c"])).toEqual([
"--command",
"--columns",
]);
});
it("uses canonical global flag metadata and retains command options and enum hints", () => {
@@ -66,6 +69,15 @@ it("uses canonical global flag metadata and retains command options and enum hin
"yaml",
"md",
"jsonl",
"csv",
"tsv",
]);
expect(complete(["rea", "mcp", "doctor", "--format", ""])).toEqual([
"toon",
"json",
"yaml",
"md",
"jsonl",
]);
expect(complete(["rea", "--filter-output", "mcp", ""])).toEqual([
"instructions",
@@ -139,6 +151,6 @@ printf 'format=%s\\n' "\${COMPREPLY[*]}"
expect(result.exitCode, result.stderr).toBe(0);
expect(result.stderr).toBe("");
expect(result.stdout).toContain("mcp=add doctor\n");
expect(result.stdout).toContain("format=toon json yaml md jsonl\n");
expect(result.stdout).toContain("format=toon json yaml md jsonl csv tsv\n");
},
);
@@ -0,0 +1,71 @@
import { writeFile } from "node:fs/promises";
import { join } from "node:path";
import { expect } from "vitest";
import { ARTIFACT_COMPARISON_EXAMPLE } from "../../../src/contracts/artifactComparisonExample.js";
import { createTestTempDirectory } from "../../fixtures/temporaryDirectory.js";
import { cliTest } from "../../support/cli/cliFixture.js";
cliTest(
"exports selected artifact inventory columns through the public CLI",
async ({ cli }) => {
const root = await createTestTempDirectory("rea-delimited-view-cli-");
const request = join(root, "view.json");
const parent = ARTIFACT_COMPARISON_EXAMPLE.left;
await writeFile(
request,
JSON.stringify({
source: { kind: "inline", evidence: parent },
view: {
kind: "inventory",
collection: "occurrences",
fields: ["logical_path", "artifact_id"],
},
}),
);
for (const format of ["csv", "tsv"] as const) {
const delimiter = format === "csv" ? "," : "\t";
const result = await cli.run({
arguments: [
"inspect-analysis-view",
request,
"--format",
format,
"--filter-output",
"normalized_result.items",
"--columns",
"logical_path,artifact_id",
],
});
expect(result.exitCode).toBe(0);
const lineEnding = format === "csv" ? "\r\n" : "\n";
const metadataEnd = result.stdout.indexOf(lineEnding);
const [header, row] = result.stdout
.slice(metadataEnd + lineEnding.length)
.trimEnd()
.split(/\r?\n/u);
expect(
JSON.parse(
result.stdout
.slice(0, metadataEnd)
.slice("# rea-delimited-v1 ".length),
),
).toMatchObject({
format,
selected_path: ["normalized_result", "items"],
coverage: { total: 1, exhausted: true },
provenance: {
parent_evidence_id: parent.evidence_id,
parent_operation: "inventory_artifact",
},
});
expect(header).toBe(["logical_path", "artifact_id"].join(delimiter));
expect(row?.split(delimiter)).toEqual([
".",
expect.stringMatching(/^art_[a-f0-9]{64}$/u),
]);
}
},
);
+145 -3
View File
@@ -8,6 +8,7 @@ import {
parseCliOutputArguments,
renderCliDocument,
} from "../../../src/cliOutput.js";
import { adaptCliOutputArgumentsForIncur } from "../../../src/cliBuiltinArguments.js";
import {
createCliResultOutput,
streamCliCommandResults,
@@ -24,8 +25,10 @@ const freezeRecursively = <T>(value: T): T => {
const runFilteredCli = async (
value: unknown,
format: "json" | "jsonl" | "yaml" | "toon" | "md",
format: "json" | "jsonl" | "yaml" | "toon" | "md" | "csv" | "tsv",
filter?: string,
fields?: readonly string[],
expectedExitCode = 0,
): Promise<string> => {
const cli = Cli.create("filtered-array-test", { sync: false });
cli.command("rows", { run: () => value });
@@ -34,6 +37,7 @@ const runFilteredCli = async (
"--format",
format,
...(filter === undefined ? [] : ["--filter-output", filter]),
...(fields === undefined ? [] : ["--columns", fields.join(",")]),
];
const chunks: Buffer[] = [];
const output = createCliResultOutput(
@@ -48,9 +52,9 @@ const runFilteredCli = async (
if (output === undefined) throw new Error("Missing CLI output owner");
streamCliCommandResults(cli, output);
let fallback = "";
await cli.serve(args, {
await cli.serve(adaptCliOutputArgumentsForIncur(args), {
exit: (code) => {
expect(code).toBe(0);
expect(code).toBe(expectedExitCode);
},
stdout: (text) => {
if (!output.handled) fallback += text;
@@ -62,6 +66,144 @@ const runFilteredCli = async (
return Buffer.concat(chunks).toString("utf8");
};
it.each(["csv", "tsv"] as const)(
"exports only explicitly selected %s fields and retains page metadata",
async (format) => {
const value = {
normalized_result: {
rows: [{ name: "alpha", note: "café", other: "not selected" }],
coverage: {
status: "page",
examined: 1,
total: 3,
next_offset: 1,
exhausted: false,
},
source_coverage: { status: "partial" },
source_complete: false,
view: { offset: 0, limit: 1, fields: ["name", "note"] },
},
};
const output = await runFilteredCli(
value,
format,
"normalized_result.rows",
["note", "name"],
);
const [metadataLine, header, row] = output.trimEnd().split(/\r?\n/u);
expect(
JSON.parse(metadataLine!.slice("# rea-delimited-v1 ".length)),
).toMatchObject({
selected_path: ["normalized_result", "rows"],
coverage: { total: 3, next_offset: 1 },
source_coverage: { status: "partial" },
source_complete: false,
view: { offset: 0, limit: 1 },
});
expect(header).toBe(format === "csv" ? "note,name" : "note\tname");
expect(row).toBe(format === "csv" ? "café,alpha" : "café\talpha");
expect(output).not.toContain("not selected");
},
);
it("retains owning-view metadata when rows are selected through a nested page", async () => {
const value = {
normalized_result: {
kind: "managed-members",
parent_evidence_id: "evd_parent",
parent_operation: "inspect_managed_members",
parent_digest: "parent",
coverage: { total: 4, next_offset: 2, exhausted: false },
source_coverage: { status: "partial" },
limitations: ["Source bodies were not available"],
unknowns: [{ reason: "body_unavailable", member: "Run" }],
view: {
kind: "managed-members",
collection: "methods",
offset: 1,
limit: 1,
},
page: { collection: "methods", rows: [{ name: "Run" }] },
},
};
const output = await runFilteredCli(
value,
"csv",
"normalized_result.page.rows",
["name"],
);
const [metadataLine] = output.split(/\r?\n/u);
expect(
JSON.parse(metadataLine!.slice("# rea-delimited-v1 ".length)),
).toMatchObject({
selected_path: ["normalized_result", "page", "rows"],
coverage: { total: 4, next_offset: 2 },
source_coverage: { status: "partial" },
limitations: ["Source bodies were not available"],
unknowns: [{ reason: "body_unavailable", member: "Run" }],
view: { collection: "methods", offset: 1, limit: 1 },
provenance: {
parent_evidence_id: "evd_parent",
parent_operation: "inspect_managed_members",
parent_digest: "parent",
},
});
});
it("refuses spreadsheet formula-leading text in CSV but preserves it in JSON", async () => {
const value = { rows: [{ logical_path: "=1+1" }] };
const csv = await runFilteredCli(value, "csv", "rows", ["logical_path"], 1);
expect(JSON.parse(csv)).toMatchObject({
code: "invalid_request",
category: "invalid_input",
message: expect.stringContaining("formula-leading"),
});
const json = await runFilteredCli(value, "json");
expect(JSON.parse(json)).toEqual(value);
});
it("returns a structured limitation when a selected CSV cell is nested", async () => {
const output = await runFilteredCli(
{ normalized_result: { rows: [{ nested: { value: "not flattened" } }] } },
"csv",
"normalized_result.rows",
["nested"],
1,
);
expect(JSON.parse(output)).toMatchObject({
code: "invalid_request",
category: "invalid_input",
details: {
issues: [
{
message: expect.stringContaining("not flattened"),
},
],
},
});
});
it("returns a structured limitation when the selected view is not a row array", async () => {
const output = await runFilteredCli(
{ normalized_result: { summary: { count: 1 } } },
"tsv",
"normalized_result.summary",
["count"],
1,
);
expect(JSON.parse(output)).toMatchObject({
code: "invalid_request",
category: "invalid_input",
details: {
issues: [
{
message: expect.stringContaining("row-array path"),
},
],
},
});
});
it("renders only each record's own fields in heterogeneous Markdown tables", async () => {
const value: unknown = JSON.parse(
'[{"name":"first","constructor":"ctor","toString":"text","__proto__":"proto","enabled":false,"count":0},{"name":"second"},{"name":"third","constructor":0,"toString":false,"__proto__":null}]',