/** * Markdown report generator (the "minimized" mode) — renders the shared * report model as Markdown that pastes cleanly into a PR or issue. * * - **Quick wins (deterministic)** lead, per file, with one combined unified * diff so the report doubles as a ready-to-apply patch. * - **Needs review (guidance)** and **Report-only (hygiene)** collapse into * `
` so a pasted report opens on the actionable fix. * * For a presentable/branded report, see `renderHtml` (report-html.ts). */ import type { AuditFinding } from "./core"; import { ruleDocUrl, type RuleMeta } from "./catalog"; import type { ProveOptions } from "./proof"; import { buildReportModel, type EnrichedFinding, type GuidanceCluster, type QuickWinFile } from "./report-model"; /** A rule id as a Markdown link to its reference entry. */ function ruleLink(id: string): string { return `[${id}](${ruleDocUrl(id)})`; } /** External backing for a merge-worthy rule (OSSF Scorecard, CIS, vendor docs). */ function authorityLinks(meta: RuleMeta): string { if (!meta.authority?.length) return ""; return ` — per ${meta.authority.map((a) => `[${a.name}](${a.url})`).join(", ")}`; } export interface RenderOptions { /** Repo URL or path shown in the header. */ target?: string; /** Audited file contents (path → YAML), enabling inline fix diffs. */ files?: Array<{ path: string; content: string }>; /** Resolve an action ref to a SHA so pin fixes can be diffed. */ resolveSha?: ProveOptions["resolveSha"]; /** Resolve a container image to a digest so image-pin fixes can be diffed. */ resolveDigest?: ProveOptions["resolveDigest"]; /** Coverage caveats shown near the top (e.g. unresolved GitLab includes). */ notes?: string[]; /** Resolved audit catalog (core static + active lexicons' contributions, #687). Defaults to core's static catalog. */ catalog?: Record; } function escapeCell(s: string): string { return s.replace(/\|/g, "\\|").replace(/\n/g, " ").trim(); } function renderQuickWins(files: QuickWinFile[]): string[] { const lines: string[] = ["## Quick wins (deterministic)", "", "Safe mechanical fixes — the diff changes only the flagged lines.", ""]; for (const qw of files) { lines.push(`### \`${qw.file}\``); if (qw.diff) { const labels = qw.addressed.map((m) => `${ruleLink(m.id)} (${m.title})${authorityLinks(m)}`).join(", "); lines.push("", `Addresses ${labels}:`, "", "```diff", qw.diff, "```"); } if (qw.needsInput.length > 0) { lines.push("", "Needs a value before it can be auto-patched:"); for (const f of qw.needsInput) { const where = f.entity ? ` (\`${f.entity}\`)` : ""; lines.push(`- **${ruleLink(f.checkId)}**${where} — ${f.meta.remediation}${authorityLinks(f.meta)}`); } } lines.push(""); } return lines; } function renderNeedsReview(clusters: GuidanceCluster[]): string[] { const lines: string[] = ["These need a judgement call — remediation guidance, not an auto-fix.", ""]; for (const cluster of clusters) { lines.push(`### ${cluster.url ? `[${cluster.name}](${cluster.url})` : cluster.name}`, ""); for (const { meta, findings } of cluster.rules) { lines.push(`- **${ruleLink(meta.id)}** — ${meta.title} (${findings[0].severity}). ${meta.remediation}${authorityLinks(meta)}`); for (const f of findings) { const where = f.entity ? `\`${f.file}\` (\`${f.entity}\`)` : `\`${f.file}\``; lines.push(` - ${where} — ${escapeCell(f.message)}`); } } lines.push(""); } return lines; } function renderReportOnly(findings: EnrichedFinding[]): string[] { const lines: string[] = ["", "| Rule | Title | File | Detail |", "| --- | --- | --- | --- |"]; for (const f of findings) { lines.push(`| ${ruleLink(f.checkId)} | ${escapeCell(f.meta.title)} | \`${f.file}\` | ${escapeCell(f.message)} |`); } lines.push(""); return lines; } /** Render an audit report as Markdown. */ export function renderMarkdown(findings: AuditFinding[], opts: RenderOptions = {}): string { const model = buildReportModel(findings, opts); const { counts } = model; const lines: string[] = ["# chant audit"]; if (opts.target) lines.push("", `Target: ${opts.target}`); lines.push(""); for (const note of opts.notes ?? []) lines.push(`> Note: ${note}`, ""); if (counts.total === 0) { lines.push("No issues found.", "", "---", "Generated by [chant audit](https://intentius.io/chant/cli/audit/).", ""); return lines.join("\n"); } lines.push( `${counts.total} finding${counts.total === 1 ? "" : "s"} — ` + `${counts.quickWin} quick-win, ${counts.needsReview} needs-review, ${counts.reportOnly} report-only ` + `(${counts.errors} error, ${counts.warnings} warning, ${counts.infos} info).`, "", `By category: ${counts.security} security, ${counts.correctness} correctness, ${counts.bestPractice} best-practice.`, "", ); if (model.quickWins.length > 0) lines.push(...renderQuickWins(model.quickWins)); if (model.needsReview.length > 0) { lines.push("
", `Needs review (guidance) — ${counts.needsReview}`, ""); lines.push(...renderNeedsReview(model.needsReview)); lines.push("
", ""); } if (model.reportOnly.length > 0) { lines.push("
", `Report-only (hygiene) — ${counts.reportOnly}`, ""); lines.push(...renderReportOnly(model.reportOnly)); lines.push("
", ""); } lines.push("---", "Generated by [chant audit](https://intentius.io/chant/cli/audit/).", ""); return lines.join("\n"); }