From adede04415f7e17b51200eadc3cede9b3e052ef0 Mon Sep 17 00:00:00 2001 From: Hao Yan Date: Fri, 10 Jul 2026 15:16:18 -0600 Subject: [PATCH 1/5] feat(render): add width-aware text wrapping with minimal markdown styling --- src/render/wrap.ts | 158 ++++++++++++++++++++++++++++++++++++++ tests/render/wrap.test.ts | 88 +++++++++++++++++++++ 2 files changed, 246 insertions(+) create mode 100644 src/render/wrap.ts create mode 100644 tests/render/wrap.test.ts diff --git a/src/render/wrap.ts b/src/render/wrap.ts new file mode 100644 index 0000000..0880d95 --- /dev/null +++ b/src/render/wrap.ts @@ -0,0 +1,158 @@ +/** + * 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. + * - 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; + +interface Word { + plain: string; + bold: boolean; +} + +/** 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 individual words, each carrying its emphasis. */ +function toWords(text: string): Word[] { + const words: Word[] = []; + for (const token of tokenizeInline(text)) { + for (const plain of token.text.split(/\s+/)) { + if (plain.length > 0) { + words.push({ plain, bold: token.bold }); + } + } + } + return words; +} + +/** + * 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) => (w.bold ? style(w.plain) : w.plain)).join(" ")); + current = []; + currentLen = contPrefix.length; + }; + + for (const word of words) { + const sep = current.length === 0 ? 0 : 1; + if (current.length > 0 && currentLen + sep + word.plain.length > safeWidth) { + flush(); + } + current.push(word); + currentLen += (current.length === 1 ? 0 : 1) + word.plain.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. + */ +export function wrapMarkdown(text: string, width: number, bold: StyleFn = (s) => s): string { + const out: string[] = []; + let paragraph: string[] = []; + + const flushParagraph = (): void => { + if (paragraph.length === 0) { + return; + } + const joined = paragraph.join(" ").trim(); + out.push(...fillLines(toWords(joined), width, bold, "", "")); + paragraph = []; + }; + + for (const rawLine of text.split("\n")) { + const line = rawLine.trimEnd(); + if (line.trim() === "") { + flushParagraph(); + out.push(""); + continue; + } + + const heading = HEADING_RE.exec(line); + if (heading) { + flushParagraph(); + const words = toWords((heading[2] ?? "").trim()).map((w) => ({ ...w, bold: true })); + out.push(...fillLines(words, width, bold, "", "")); + continue; + } + + const listItem = LIST_ITEM_RE.exec(line); + if (listItem) { + flushParagraph(); + const [, indent, marker, rest] = listItem; + const firstPrefix = `${indent ?? ""}${marker ?? ""} `; + const contPrefix = " ".repeat(firstPrefix.length); + out.push(...fillLines(toWords(rest ?? ""), width, bold, firstPrefix, contPrefix)); + continue; + } + + paragraph.push(line.trim()); + } + flushParagraph(); + + return out.join("\n"); +} diff --git a/tests/render/wrap.test.ts b/tests/render/wrap.test.ts new file mode 100644 index 0000000..5380236 --- /dev/null +++ b/tests/render/wrap.test.ts @@ -0,0 +1,88 @@ +import { describe, expect, it } from "vitest"; +import { wrapMarkdown } from "../../src/render/wrap.js"; + +const bold = (text: string): string => `**${text}**`; // fake styler distinct from markdown `**` + +describe("wrapMarkdown", () => { + it("wraps a long paragraph so no line exceeds the given width", () => { + const text = Array.from({ length: 30 }, (_, i) => `word${i}`).join(" "); + const wrapped = wrapMarkdown(text, 20); + for (const line of wrapped.split("\n")) { + expect(line.length).toBeLessThanOrEqual(20); + } + // no words dropped + for (let i = 0; i < 30; i++) { + expect(wrapped).toContain(`word${i}`); + } + }); + + it("joins single newlines within a paragraph before rewrapping", () => { + const text = "one two three\nfour five six"; + const wrapped = wrapMarkdown(text, 80); + expect(wrapped).toBe("one two three four five six"); + }); + + it("preserves blank lines as paragraph separators", () => { + const text = "first paragraph\n\nsecond paragraph"; + const wrapped = wrapMarkdown(text, 80); + expect(wrapped).toBe("first paragraph\n\nsecond paragraph"); + }); + + it("strips a leading heading marker and never prints it literally", () => { + const wrapped = wrapMarkdown("## Root Cause", 80, (s) => `[B]${s}[/B]`); + expect(wrapped).not.toContain("##"); + // Styled per word (visually equivalent bolding), not merged into one span. + expect(wrapped).toBe("[B]Root[/B] [B]Cause[/B]"); + }); + + it("leaves a heading as plain text when no styler is given", () => { + const wrapped = wrapMarkdown("# Title", 80); + expect(wrapped).toBe("Title"); + }); + + it("strips ** markers and styles the enclosed text as bold", () => { + const wrapped = wrapMarkdown("this is **important** text", 80, (s) => `[B]${s}[/B]`); + expect(wrapped).not.toContain("**"); + expect(wrapped).toBe("this is [B]important[/B] text"); + }); + + it("leaves bold text plain (markers stripped) when no styler is given", () => { + const wrapped = wrapMarkdown("this is **important** text", 80); + expect(wrapped).toBe("this is important text"); + }); + + it("strips inline code backticks without styling", () => { + const wrapped = wrapMarkdown("call `doThing()` now", 80, (s) => `[B]${s}[/B]`); + expect(wrapped).not.toContain("`"); + expect(wrapped).toBe("call doThing() now"); + }); + + it("wraps list items with a hanging indent instead of merging bullets", () => { + const wrapped = wrapMarkdown("- root cause one\n- root cause two", 80); + expect(wrapped.split("\n")).toEqual(["- root cause one", "- root cause two"]); + }); + + it("indents a wrapped list item's continuation line under its text", () => { + const wrapped = wrapMarkdown( + "- this is a fairly long list item that should wrap across more than one line", + 30, + ); + const lines = wrapped.split("\n"); + expect(lines.length).toBeGreaterThan(1); + expect(lines[0]?.startsWith("- ")).toBe(true); + for (const line of lines.slice(1)) { + expect(line.startsWith(" ")).toBe(true); + expect(line.startsWith("- ")).toBe(false); + } + }); + + it("keeps a single word longer than width on its own line (no infinite loop)", () => { + const longWord = "x".repeat(50); + const wrapped = wrapMarkdown(longWord, 10); + expect(wrapped).toBe(longWord); + }); + + it("defaults to the identity styler, never emitting empty output for input text", () => { + expect(wrapMarkdown("plain text", 80)).toBe("plain text"); + }); +}); From 687c8462d5891e267dabadef65a741bb0cb89152 Mon Sep 17 00:00:00 2001 From: Hao Yan Date: Fri, 10 Jul 2026 15:20:51 -0600 Subject: [PATCH 2/5] feat(findings): wrap RCA output and surface detector verdicts in findings get --- src/commands/findings/get.ts | 63 +++++++++++++++----- tests/commands/findings-get.test.ts | 90 ++++++++++++++++++++++++++++- 2 files changed, 136 insertions(+), 17 deletions(-) diff --git a/src/commands/findings/get.ts b/src/commands/findings/get.ts index 00d5ca2..7f43cbe 100644 --- a/src/commands/findings/get.ts +++ b/src/commands/findings/get.ts @@ -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; @@ -69,6 +73,34 @@ function categoryLabel(template: string | null | undefined): string { return CATEGORY_LABELS[template] ?? template.charAt(0).toUpperCase() + template.slice(1); } +/** + * 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).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, timeZone?: string): string { const styler = createStyler(writers.out); const label = (text: string): string => styler.bold(text); @@ -81,32 +113,35 @@ function renderFinding(finding: FindingDetail, writers: Writers, timeZone?: stri lines.push(`${label("Summary:")} ${finding.summary}`); // Per-detector, flush-left: `Detector: (