Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
95 changes: 79 additions & 16 deletions src/commands/findings/get.ts
Original file line number Diff line number Diff line change
@@ -1,11 +1,15 @@
import type { Command } from "commander";
import type { ApiClient, FindingDetail } from "../../api/client.js";
import { type Writers, CliError, defaultWriters, writeJson } from "../../output.js";
import { CliError, type Writers, defaultWriters, writeJson } from "../../output.js";
import { createStyler } from "../../render/style.js";
import { wrapMarkdown } from "../../render/wrap.js";
import { formatTimestamp } from "../../util/index.js";
import { contextFromCommand, requireApiClient } from "../shared.js";
import { onceOption } from "../traces/list.js";

/** Fallback RCA wrap width when the terminal doesn't report a column count. */
const DEFAULT_WRAP_WIDTH = 80;

/** Dependencies for the testable core of `findings get`. */
export interface RunGetDeps {
client: ApiClient;
Expand Down Expand Up @@ -45,7 +49,18 @@ export async function runGet(deps: RunGetDeps): Promise<void> {
return;
}

writers.out.write(`${renderFinding(finding, writers, timeZone)}\n`);
// Best-effort: fetch the trace purely to get its backend-provided `trace_url`
// for the footer link. Never let this fail the command — any error (network,
// 404, etc.) degrades to no link, with the next-step hints still shown.
let traceUrl: string | null = null;
try {
const trace = await client.getTrace(finding.trace_id);
traceUrl = trace.trace_url;
} catch {
traceUrl = null;
}

writers.out.write(`${renderFinding(finding, writers, traceUrl, timeZone)}\n`);
}

/**
Expand All @@ -69,7 +84,40 @@ function categoryLabel(template: string | null | undefined): string {
return CATEGORY_LABELS[template] ?? template.charAt(0).toUpperCase() + template.slice(1);
}

function renderFinding(finding: FindingDetail, writers: Writers, timeZone?: string): string {
/**
* The detector's own classification, from `result.data.category` (a loose,
* detector-defined payload — only a non-empty string `category` key is
* trusted). `null` when `data` doesn't carry one, so the caller falls back to
* the template-derived label.
*/
function dataCategory(data: unknown): string | null {
if (data === null || typeof data !== "object" || Array.isArray(data)) {
return null;
}
const value = (data as Record<string, unknown>).category;
return typeof value === "string" && value.trim() !== "" ? value : null;
}

/**
* The Category line's value: the detector's own conclusion (`data.category`)
* when present, annotated with the raw template id for precision, e.g.
* `Tool call error (template: failure)`. Falls back to the template-derived
* label alone when `data` has no usable category.
*/
function detectorCategory(result: FindingDetail["results"][number]): string {
const category = dataCategory(result.data);
if (category === null) {
return categoryLabel(result.template);
}
return result.template ? `${category} (template: ${result.template})` : category;
}

function renderFinding(
finding: FindingDetail,
writers: Writers,
traceUrl: string | null,
timeZone?: string,
): string {
const styler = createStyler(writers.out);
const label = (text: string): string => styler.bold(text);
const lines: string[] = [];
Expand All @@ -81,36 +129,51 @@ function renderFinding(finding: FindingDetail, writers: Writers, timeZone?: stri
lines.push(`${label("Summary:")} ${finding.summary}`);

// Per-detector, flush-left: `Detector: <name> (<template>)`, then the unique id
// (disambiguates same-named detectors) and the human-readable category.
// Multiple detectors are separated by a blank line; per-detector summary/data
// stay in `--json` only.
// (disambiguates same-named detectors), whether the detector actually fired
// (`Identified:`), and the Category line — the detector's own conclusion
// (`result.data.category`) when present, else the template-derived label.
// Multiple detectors are separated by a blank line; the raw `summary` /
// `data` payload stays in `--json` only.
lines.push("");
finding.results.forEach((result, i) => {
if (i > 0) {
lines.push("");
}
const template = result.template ? ` (${result.template})` : "";
lines.push(`${label("Detector:")} ${result.detector_name}${template}`);
lines.push(`${label("ID:")} ${result.detector_id}`);
lines.push(`${label("Category:")} ${categoryLabel(result.template)}`);
lines.push(`${label("Detector:")} ${result.detector_name}${template}`);
lines.push(`${label("ID:")} ${result.detector_id}`);
lines.push(`${label("Identified:")} ${result.identified ? "yes" : "no"}`);
lines.push(`${label("Category:")} ${detectorCategory(result)}`);
});

// RCA, flush-left. With a result: a bare `RCA:` header, then the result verbatim
// (it already carries its own formatting — usually a markdown list — so no added
// bullets, or the markers double up). No RCA → `RCA: none`; an in-progress RCA
// with no result yet keeps its status (e.g. `RCA: processing`).
// RCA, flush-left. With a result: a bare `RCA:` header, then the result
// wrapped to the terminal width with minimal markdown treatment (headings /
// `**bold**` / `` `code` `` styled-or-stripped rather than printed literally;
// see `render/wrap.ts`). No RCA → `RCA: none`; an in-progress RCA with no
// result yet keeps its status (e.g. `RCA: processing`).
lines.push("");
if (!finding.rca) {
lines.push(`${label("RCA:")} none`);
} else if (finding.rca.result) {
lines.push(label("RCA:"));
for (const resultLine of finding.rca.result.trim().split("\n")) {
lines.push(resultLine);
}
const width = process.stdout.columns ?? DEFAULT_WRAP_WIDTH;
lines.push(wrapMarkdown(finding.rca.result.trim(), width, styler.bold));
} else {
lines.push(`${label("RCA:")} ${finding.rca.status}`);
}

// Footer: never dead-end. A backend-provided trace link when available (best
// effort — never hand-construct a frontend URL), and always the next-step
// hints, matching the `styler.warn` idiom `traces get` uses.
lines.push("");
if (traceUrl !== null) {
lines.push(`${label("View in TraceRoot:")} ${styler.link(traceUrl)}`);
}
lines.push(styler.warn(`run 'traceroot traces get ${finding.trace_id}' for spans and context`));
lines.push(
styler.warn(`run 'traceroot traces export ${finding.trace_id}' to save a full bundle`),
);

return lines.join("\n");
}

Expand Down
237 changes: 237 additions & 0 deletions src/render/wrap.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,237 @@
/**
* Width-aware text wrapping with minimal markdown treatment, for rendering
* free-form text (e.g. an RCA result) that may contain light markdown from an
* LLM. Pure string transform — no I/O, no width detection — so callers pass in
* an explicit width (typically `process.stdout.columns ?? 80`) and a `bold`
* styler (typically `createStyler(sink).bold`), keeping this testable without a
* real TTY.
*
* Handled, deliberately minimally:
* - `# `..`###### ` headings: the `#` marker is stripped and the heading text
* is styled bold (or left plain when `bold` is a no-op).
* - `**bold**` spans: the `**` markers are stripped and the enclosed text is
* styled bold.
* - `` `code` `` spans: the backticks are stripped (left plain).
* - `-`/`*`/`1.`-style list items: wrapped with a hanging indent so
* continuation lines align under the item's text rather than the marker;
* indented source lines directly under a bullet are folded into that item.
* - Plain paragraphs (consecutive non-blank, non-list, non-heading lines):
* joined and re-wrapped to `width`; blank lines are preserved as paragraph
* separators.
*
* Never emits a literal `#`/`**`/backtick marker for the constructs above.
*/

/** A styling function, e.g. `Styler["bold"]`; identity for plain-text output. */
export type StyleFn = (text: string) => string;

const HEADING_RE = /^(#{1,6})\s+(.*)$/;
const LIST_ITEM_RE = /^(\s*)([-*]|\d+\.)\s+(.*)$/;
const BOLD_SPAN_RE = /\*\*(.+?)\*\*/g;
const CODE_SPAN_RE = /`([^`]+)`/g;

/** A run of same-emphasis characters within a {@link Word}. */
interface Piece {
plain: string;
bold: boolean;
}

/**
* One wrappable unit: a maximal run of non-whitespace characters. A word can
* span emphasis boundaries (e.g. `(**timeout**),` is one word of three pieces:
* `(`, bold `timeout`, `),`), so punctuation glued to a bold span in the source
* stays glued in the output. `length` is the visible (unstyled) width.
*/
interface Word {
pieces: Piece[];
length: number;
}

/** Strips inline code backticks (left plain) then splits `**bold**` spans out. */
function tokenizeInline(text: string): Array<{ text: string; bold: boolean }> {
const noCode = text.replace(CODE_SPAN_RE, "$1");
const tokens: Array<{ text: string; bold: boolean }> = [];
let last = 0;
for (const match of noCode.matchAll(BOLD_SPAN_RE)) {
const index = match.index ?? 0;
if (index > last) {
tokens.push({ text: noCode.slice(last, index), bold: false });
}
tokens.push({ text: match[1] ?? "", bold: true });
last = index + match[0].length;
}
if (last < noCode.length) {
tokens.push({ text: noCode.slice(last), bold: false });
}
return tokens;
}

/**
* Splits inline-tokenized text into words. Whitespace in the SOURCE is the only
* word boundary: an emphasis edge with no whitespace around it (e.g.
* `**Note**:` or `pre**bold**post`) continues the current word, so no space is
* invented next to punctuation when the pieces are rejoined.
*/
function toWords(text: string): Word[] {
const words: Word[] = [];
let pieces: Piece[] = [];

const flushWord = (): void => {
if (pieces.length > 0) {
words.push({ pieces, length: pieces.reduce((n, p) => n + p.plain.length, 0) });
pieces = [];
}
};

for (const token of tokenizeInline(text)) {
// Split into alternating whitespace / non-whitespace runs (both kept):
// whitespace ends the current word; a non-whitespace run extends it.
for (const run of token.text.split(/(\s+)/)) {
if (run === "") {
continue;
}
if (/^\s/.test(run)) {
flushWord();
} else {
pieces.push({ plain: run, bold: token.bold });
}
}
}
flushWord();
return words;
}

/** Renders a word's pieces, applying `style` to the bold ones. */
function renderWord(word: Word, style: StyleFn): string {
return word.pieces.map((p) => (p.bold ? style(p.plain) : p.plain)).join("");
}

/** A copy of `word` with every piece marked bold (for headings). */
function boldWord(word: Word): Word {
return { pieces: word.pieces.map((p) => ({ ...p, bold: true })), length: word.length };
}

/**
* Greedily fills lines up to `width` visible columns (ANSI escapes added by
* `style` don't count toward width, since wrapping decisions use `plain`
* lengths). `firstPrefix` prefixes the first output line (e.g. a list marker);
* `contPrefix` prefixes every following line (e.g. matching spaces), so
* continuation lines align under the first line's text.
*/
function fillLines(
words: Word[],
width: number,
style: StyleFn,
firstPrefix: string,
contPrefix: string,
): string[] {
const safeWidth = Math.max(1, width);
const lines: string[] = [];
let current: Word[] = [];
let currentLen = firstPrefix.length;

const flush = (): void => {
const prefix = lines.length === 0 ? firstPrefix : contPrefix;
lines.push(prefix + current.map((w) => renderWord(w, style)).join(" "));
current = [];
currentLen = contPrefix.length;
};

for (const word of words) {
const sep = current.length === 0 ? 0 : 1;
if (current.length > 0 && currentLen + sep + word.length > safeWidth) {
flush();
}
current.push(word);
currentLen += (current.length === 1 ? 0 : 1) + word.length;
}
if (current.length > 0 || lines.length === 0) {
flush();
}
return lines;
}

/**
* Wraps `text` to `width` columns, applying minimal markdown styling (see
* module doc). `bold` is applied to headings and `**bold**` spans; pass the
* identity function for a plain-text (no-ANSI) fallback.
*/
/** An in-progress list item: its marker prefixes plus accumulated text lines. */
interface OpenListItem {
firstPrefix: string;
contPrefix: string;
text: string[];
}

export function wrapMarkdown(text: string, width: number, bold: StyleFn = (s) => s): string {
const out: string[] = [];
let paragraph: string[] = [];
let listItem: OpenListItem | null = null;

const flushParagraph = (): void => {
if (paragraph.length === 0) {
return;
}
const joined = paragraph.join(" ").trim();
out.push(...fillLines(toWords(joined), width, bold, "", ""));
paragraph = [];
};

const flushListItem = (): void => {
if (listItem === null) {
return;
}
const joined = listItem.text.join(" ").trim();
out.push(...fillLines(toWords(joined), width, bold, listItem.firstPrefix, listItem.contPrefix));
listItem = null;
};

// At most one of paragraph / listItem is open at a time; flush both at any
// block boundary (blank line, heading, new bullet).
const flushBlocks = (): void => {
flushParagraph();
flushListItem();
};

for (const rawLine of text.split("\n")) {
const line = rawLine.trimEnd();
if (line.trim() === "") {
flushBlocks();
out.push("");
continue;
}

const heading = HEADING_RE.exec(line);
if (heading) {
flushBlocks();
const words = toWords((heading[2] ?? "").trim()).map(boldWord);
out.push(...fillLines(words, width, bold, "", ""));
continue;
}

const marker = LIST_ITEM_RE.exec(line);
if (marker) {
flushBlocks();
const firstPrefix = `${marker[1] ?? ""}${marker[2] ?? ""} `;
listItem = {
firstPrefix,
contPrefix: " ".repeat(firstPrefix.length),
text: [marker[3] ?? ""],
};
continue;
}

// An indented line directly under an open bullet continues that item, so
// it keeps the item's hanging indent instead of falling out flush-left.
if (listItem !== null && /^\s/.test(line)) {
listItem.text.push(line.trim());
continue;
}

flushListItem();
paragraph.push(line.trim());
Comment thread
dark-sorceror marked this conversation as resolved.
}
flushBlocks();

return out.join("\n");
}
Loading
Loading