/** * cli:audit-ba — render/verdict-md.ts * * Renders the per-dimension `_audit/.md` verdicts in the EXACT existing * contract (doc-templates § Audit verdict — the shape ba-audit-pre-dev and * preflight-develop-plan parse): * line 1 * line 2 # Audit — * line 3 _ · Verdict : N warn · M err · K ok[ · J à arbitrer]_ * then ## ✅ Conforme / ## ⚠️ Avertissements / ## ❌ Bloquants * [## 🧠 Jugement requis] * Everything ADDED goes AFTER `K ok` (the downstream parsers read the three * counters in order) or into extra anchor attributes (`ruleset=`, `sources=` * — the freshness stamps; the existing readers only consume dimension/scope). */ import type { Dimension, Finding, JudgmentItem } from '../types.js' /** Anchor dimension token + human title per dimension (existing contract). */ const DIMENSION_META: Record = { menu: { anchor: 'menu', title: 'menu', file: 'menu.md' }, sections: { anchor: 'sections', title: 'sections', file: 'sections.md' }, actors: { anchor: 'actors', title: 'acteurs', file: 'actors.md' }, 'use-cases': { anchor: 'use-case', title: "cas d'usage", file: 'use-case.md' }, rules: { anchor: 'règles-métier', title: 'règles métier', file: 'règles-métier.md' }, rbac: { anchor: 'rbac', title: 'rbac', file: 'rbac.md' }, 'data-model': { anchor: 'entité', title: 'entité', file: 'entité.md' }, screens: { anchor: 'screen', title: 'screen', file: 'screen.md' }, 'cross-dimension': { anchor: 'cross-dimension', title: 'cross-dimension', file: 'cross-dimension.md' }, 'cross-ref-code': { anchor: 'cross-ref-code', title: 'cross-ref-code', file: 'cross-ref-code.md' }, // NB: the verdict anchor carries BOTH `dimension=sources` and the freshness // attribute `sources=` — different keys, no parser conflict (pre-dev // reads `dimension=`). sources: { anchor: 'sources', title: 'sources', file: 'sources.md' }, } export interface VerdictTarget { dimension: Dimension /** Path RELATIVE to baRoot (forward slashes). */ relPath: string /** Anchor scope token (`project`, `CRM`, `CRM/PIPELINE`). */ scope: string /** Header suffix (`projet`, `CRM`, `CRM / PIPELINE`). */ heading: string findings: Finding[] pending: JudgmentItem[] } /** Group the run's findings + pending judgments into verdict files. * Project-scoped findings of app/module dimensions are duplicated into no * file here — they belong to the project summary (audit-ba.md). */ export function groupVerdicts(findings: Finding[], pending: JudgmentItem[]): VerdictTarget[] { const targets = new Map() const ensure = (dimension: Dimension, scope: { app?: string; module?: string }): VerdictTarget | null => { const meta = DIMENSION_META[dimension] let relPath: string let scopeToken: string let heading: string if (dimension === 'actors' || (dimension === 'sources' && !scope.module)) { // actors: always project. sources: SRC-001/002/003/006/007 (project) and // SRC-004 (app grain) land in the project verdict; SRC-005 (module) falls // through to `//_audit/sources.md`. relPath = `_audit/${meta.file}` scopeToken = 'project' heading = 'projet' } else if (dimension === 'menu') { if (!scope.app) return null // project-scope menu findings → summary relPath = `${scope.app}/_audit/${meta.file}` scopeToken = scope.app heading = scope.app } else { if (!scope.app || !scope.module) return null // project scope → summary relPath = `${scope.app}/${scope.module}/_audit/${meta.file}` scopeToken = `${scope.app}/${scope.module}` heading = `${scope.app} / ${scope.module}` } const key = `${dimension}|${relPath}` if (!targets.has(key)) { targets.set(key, { dimension, relPath, scope: scopeToken, heading, findings: [], pending: [] }) } return targets.get(key)! } for (const f of findings) { if (f.ruleId.startsWith('CONV-')) continue // project summary owns these ensure(f.dimension, f.scope)?.findings.push(f) } for (const j of pending) { ensure(j.dimension, j.scope)?.pending.push(j) } return [...targets.values()].sort((a, b) => a.relPath.localeCompare(b.relPath)) } function bullet(f: Finding): string { const lines = [`- **${f.ruleId}** — ${f.message}`] for (const e of f.evidence ?? []) lines.push(` - ${e}`) return lines.join('\n') } export function renderVerdict( target: VerdictTarget, opts: { date: string; rulesetVersion: string; sourcesHash: string }, ): string { const meta = DIMENSION_META[target.dimension] const ok = target.findings.filter((f) => f.severity === 'ok') const warn = target.findings.filter((f) => f.severity === 'warn') const err = target.findings.filter((f) => f.severity === 'err') const emoji = err.length > 0 ? '❌' : warn.length > 0 ? '⚠️' : '✅' const pendingSuffix = target.pending.length > 0 ? ` · ${target.pending.length} à arbitrer` : '' const lines: string[] = [ ``, `# Audit ${meta.title} — ${target.heading}`, `_${opts.date} · Verdict : ${emoji} ${warn.length} warn · ${err.length} err · ${ok.length} ok${pendingSuffix}_`, '', '## ✅ Conforme', ...(ok.length > 0 ? ok.map(bullet) : ['- _(aucun)_']), '', '## ⚠️ Avertissements', ...(warn.length > 0 ? warn.map(bullet) : ['- _(aucun)_']), '', '## ❌ Bloquants', ...(err.length > 0 ? err.map(bullet) : ['- _(aucun)_']), ] if (target.pending.length > 0) { lines.push( '', ``, '## 🧠 Jugement requis', '> Points que le CLI ne tranche pas mécaniquement — arbitrés par le skill', '> `/ba-audit-*` de la dimension sur les extraits ci-dessous, puis fusionnés', '> via `audit-ba --judgments` (le CLI réécrit ce verdict, jamais le skill).', ...target.pending.map((p) => { const parts = [`- **${p.ruleId}** (max ${p.maxSeverity}) — ${p.question}`] for (const e of p.excerpts) parts.push(` - ${e.split('\n').join('\n ')}`) return parts.join('\n') }), ) } lines.push('') return lines.join('\n') } /** Project-level human summary of the run — a NEW file next to the existing * verdicts (`_audit/audit-ba.md`); the machine record is audit-ba.json. */ export function renderProjectSummary(args: { date: string rulesetVersion: string sourcesHash: string totalsLine: string conventionFindings: Finding[] projectFindings: Finding[] parseControlStatus: string pendingCount: number exitClass: string }): string { const lines = [ ``, '# Audit BA déterministe — synthèse projet', `_${args.date} · ${args.totalsLine} · parsing ${args.parseControlStatus} · ${args.pendingCount} jugement(s) en attente · classe ${args.exitClass}_`, '', '## Conventions projet (tolérance déclarée)', ...(args.conventionFindings.length > 0 ? args.conventionFindings.map(bullet) : ['- _(aucune divergence de convention détectée)_']), '', '## Findings de portée projet', ...(args.projectFindings.length > 0 ? args.projectFindings.map(bullet) : ['- _(aucun)_']), '', ] return lines.join('\n') }