diff --git a/src/commands/findings/get.ts b/src/commands/findings/get.ts index 00d5ca2..3f8c0fd 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; @@ -45,7 +49,18 @@ export async function runGet(deps: RunGetDeps): Promise { 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`); } /** @@ -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).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[] = []; @@ -81,36 +129,51 @@ function renderFinding(finding: FindingDetail, writers: Writers, timeZone?: stri lines.push(`${label("Summary:")} ${finding.summary}`); // Per-detector, flush-left: `Detector: (