mirror of
https://github.com/morluto/rea.git
synced 2026-11-07 05:56:19 -10:00
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:
13 files changed
+1014
-36
No files matched your search
+29
@@ -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
@@ -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;
|
||||
};
|
||||
@@ -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
@@ -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
@@ -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" },
|
||||
},
|
||||
);
|
||||
|
||||
@@ -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
@@ -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
@@ -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
|
||||
|
||||
@@ -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" },
|
||||
});
|
||||
});
|
||||
@@ -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),
|
||||
]);
|
||||
}
|
||||
},
|
||||
);
|
||||
@@ -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}]',
|
||||
|
||||
Reference in new issue
Block a user