ultra11y 4.0.1 → 4.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/src/annotate.ts CHANGED
@@ -4,18 +4,25 @@
4
4
  // renders these inline on the diff, so this is the fallback when SARIF upload is
5
5
  // unavailable (a private repo without Advanced Security).
6
6
  // • `stepSummary()` — the Markdown written to `$GITHUB_STEP_SUMMARY`: the run's headline,
7
- // a severity table, and — once the audit carries a page sample — the per-page synthesis.
7
+ // a grouped severity table, and — once the audit carries a page sample — the per-page
8
+ // synthesis. Long and complete; the step summary has a 1 MiB budget and no reader who
9
+ // reached it arrived by accident.
10
+ // • `prComment()` — the DIGEST posted on the pull request. A different document, not a
11
+ // truncation of the one above: a reviewer scanning a PR needs the verdict, the coverage
12
+ // and the handful of distinct defects, then a link. Posting the full summary there is how
13
+ // a real audit (472 findings over 7 distinct selectors) became a wall nobody read.
8
14
  //
9
- // Both are pure string builders. Nothing here decides anything: a finding that is not
15
+ // All are pure string builders. Nothing here decides anything: a finding that is not
10
16
  // anchorable in the repo tree (a URL-keyed dynamic result) is skipped rather than pinned
11
- // to an invented line.
17
+ // to an invented line, and every status comes from the shared projections.
12
18
  import { findingsAtOrAbove } from "./baseline.js";
13
19
  import { resolveMessage, resolveRemediation } from "./messages.js";
14
20
  import { findingsForStandard, packCriteriaForFinding } from "./standards/derive.js";
15
21
  import { CORE, type StandardId, isCore, loadPack } from "./standards/index.js";
22
+ import { packReportGroups, reportCoverage, reportGroups } from "./report.js";
16
23
  import type { AuditResult, Finding, Lang, Severity } from "./types.js";
17
24
  import { isUrlPath, repoRelative } from "./util.js";
18
- import { attributePages, basisLabel, derivePages, formatRate, pageBasisWarning, pagesOf, unattributedFindings } from "./pages.js";
25
+ import { agentMarkNote, attributePages, basisLabel, derivePages, formatRate, pageBasisWarning, pagesOf, unattributedFindings } from "./pages.js";
19
26
 
20
27
  export interface AnnotateOptions {
21
28
  standard?: StandardId;
@@ -81,6 +88,7 @@ const S = {
81
88
  where: "Emplacement",
82
89
  what: "Constat",
83
90
  more: (n: number) => `… et ${n} autre(s).`,
91
+ moreGroups: (n: number) => `… et ${n} autre(s) groupe(s) — voir le résumé de job.`,
84
92
  perPage: "Bilan page par page",
85
93
  page: "Page",
86
94
  count: "Constats",
@@ -88,6 +96,17 @@ const S = {
88
96
  pageRate: "Taux",
89
97
  snapshot: "instantané",
90
98
  source: "source",
99
+ occurrences: "Occ.",
100
+ pagesCol: "Pages",
101
+ grouped: (groups: number, occ: number) => `${groups} défaut(s) distinct(s) · ${occ} occurrence(s)`,
102
+ groupNote:
103
+ "Une ligne par (critère, règle, sélecteur) : un même défaut de design system répété sur toutes les routes compte pour un. Les colonnes Occ. et Pages disent l'ampleur réelle.",
104
+ verdictFail: (n: number) => `🔴 ${n} non-conformité(s) bloquante(s) — la porte est rouge.`,
105
+ verdictWarn: "🟠 Aucune non-conformité bloquante ; des constats majeurs ou mineurs restent à traiter.",
106
+ verdictPass: "✅ Aucune non-conformité relevée par le moteur statique.",
107
+ artifact: (name: string) => `Rapport complet (HTML, captures annotées) : artefact **${name}** du run.`,
108
+ runLink: (url: string) => `[Voir le run et son résumé de job](${url})`,
109
+ clamped: (n: number) => `_${n} groupe(s) retiré(s) de ce commentaire pour tenir dans la limite de GitHub — le résumé de job les porte tous._`,
91
110
  unanchored: (n: number) => `${n} constat(s) rattaché(s) à une URL, sans ligne de code à annoter — voir le rapport.`,
92
111
  unattributed: (n: number) =>
93
112
  `${n} constat(s) ne sont rattachés à aucune page (code partagé, fichier hors routes) — comptés dans l'audit global, jamais répartis d'office.`,
@@ -105,6 +124,7 @@ const S = {
105
124
  where: "Location",
106
125
  what: "Finding",
107
126
  more: (n: number) => `… and ${n} more.`,
127
+ moreGroups: (n: number) => `… and ${n} more group(s) — see the job summary.`,
108
128
  perPage: "Page-by-page scoreboard",
109
129
  page: "Page",
110
130
  count: "Findings",
@@ -112,6 +132,17 @@ const S = {
112
132
  pageRate: "Rate",
113
133
  snapshot: "snapshot",
114
134
  source: "source",
135
+ occurrences: "Occ.",
136
+ pagesCol: "Pages",
137
+ grouped: (groups: number, occ: number) => `${groups} distinct defect(s) · ${occ} occurrence(s)`,
138
+ groupNote:
139
+ "One row per (criterion, rule, selector): one design-system defect repeated across every route counts once. The Occ. and Pages columns carry the real scale.",
140
+ verdictFail: (n: number) => `🔴 ${n} blocking non-conformity(ies) — the gate is red.`,
141
+ verdictWarn: "🟠 No blocking non-conformity; major or minor findings remain.",
142
+ verdictPass: "✅ No non-conformity found by the static engine.",
143
+ artifact: (name: string) => `Full report (HTML, annotated crops): artifact **${name}** of this run.`,
144
+ runLink: (url: string) => `[See the run and its job summary](${url})`,
145
+ clamped: (n: number) => `_${n} group(s) dropped from this comment to fit GitHub's limit — the job summary carries them all._`,
115
146
  unanchored: (n: number) => `${n} finding(s) keyed to a URL, with no code line to annotate — see the report.`,
116
147
  unattributed: (n: number) =>
117
148
  `${n} finding(s) are attributed to no page (shared code, file outside any route) — counted in the overall audit, never spread across pages.`,
@@ -120,7 +151,89 @@ const S = {
120
151
  },
121
152
  } as const;
122
153
 
154
+ /** Group rows kept on the job summary. Groups, not findings — 472 occurrences of one
155
+ * design-system defect are one row, so this ceiling is reached by real variety only. */
123
156
  const MAX_ROWS = 50;
157
+ /** Group rows kept in the pull-request digest, before the size clamp gets a say. */
158
+ const COMMENT_ROWS = 10;
159
+ /** GitHub refuses an issue-comment body past this. Documented for tickets at
160
+ * src/tickets/providers/github.ts; the report surface was never clamped at all, so a wide
161
+ * audit posted a body the API rejected with a 422 and the run reported "comment failed". */
162
+ const COMMENT_LIMIT = 65_536;
163
+
164
+ /** One (criterion, rule, selector) defect, however many times it occurs. */
165
+ export interface FindingGroup {
166
+ criterion: string;
167
+ ruleId: string;
168
+ selectorHint: string;
169
+ severity: Severity;
170
+ message: string;
171
+ /** A representative location — the first occurrence. The count says how many follow. */
172
+ where: string;
173
+ occurrences: number;
174
+ /** Distinct pages the defect was raised on. 0 when nothing is page-attributed. */
175
+ pages: number;
176
+ }
177
+
178
+ /** Fold findings by what is actually WRONG rather than by where it shows up.
179
+ *
180
+ * The audit that motivated this had 472 findings of one rule across 38 routes, for SEVEN
181
+ * distinct selectors: a single design-system defect, multiplied. Listing the occurrences
182
+ * produced a table nobody could read, and cutting it at 50 rows produced a table that lied
183
+ * about the shape of the problem. Grouping shows seven defects and says, in its own columns,
184
+ * that they occur 472 times over 38 pages. */
185
+ export function groupFindings(findings: Finding[], standard: StandardId, lang: Lang, baseDir: string): FindingGroup[] {
186
+ const groups = new Map<string, FindingGroup & { pageSet: Set<string> }>();
187
+ for (const f of findings) {
188
+ const criterion = criterionLabel(f, standard);
189
+ const key = `${criterion}${f.ruleId}${f.selectorHint}`;
190
+ const g = groups.get(key);
191
+ if (g) {
192
+ g.occurrences++;
193
+ if (f.page) g.pageSet.add(f.page);
194
+ continue;
195
+ }
196
+ groups.set(key, {
197
+ criterion,
198
+ ruleId: f.ruleId,
199
+ selectorHint: f.selectorHint,
200
+ severity: f.severity,
201
+ message: resolveMessage(f, lang),
202
+ where: isUrl(f.file) ? f.file : `${repoRelative(f.file, baseDir)}:${Math.max(1, f.line)}`,
203
+ occurrences: 1,
204
+ pages: 0,
205
+ pageSet: new Set(f.page ? [f.page] : []),
206
+ });
207
+ }
208
+ return [...groups.values()]
209
+ .map(({ pageSet, ...g }) => ({ ...g, pages: pageSet.size }))
210
+ .sort((a, b) => SEV_ORDER.indexOf(a.severity) - SEV_ORDER.indexOf(b.severity) || b.occurrences - a.occurrences || a.criterion.localeCompare(b.criterion));
211
+ }
212
+
213
+ /** The run-wide rate, WITH the denominator it never carried, and the `*` when an agent ruled
214
+ * a conformity the engine did not prove. `conformancePct` is computed over the decided set
215
+ * (src/audit.ts) and then discarded; the count comes back from the shared report model, so
216
+ * this number and the Markdown report's §1 total cannot disagree. */
217
+ export function runRate(result: AuditResult, standard: StandardId, lang: Lang): { text: string; agentRuled: boolean } {
218
+ const groups = isCore(standard) ? reportGroups(result, lang) : packReportGroups(result, loadPack(standard), lang);
219
+ const { decided, total } = reportCoverage(groups);
220
+ const agentRuled = groups.some((g) => g.rows.some((r) => r.decidedBy === "agent" && r.status === "C"));
221
+ return { text: `${formatRate(decided === 0 ? null : result.conformancePct, decided, total)}${agentRuled ? "*" : ""}`, agentRuled };
222
+ }
223
+
224
+ function groupTableHead(s: (typeof S)[Lang]): string[] {
225
+ return [`| ${s.severity} | ${s.criterion} | ${s.where} | ${s.what} | ${s.occurrences} | ${s.pagesCol} |`, "| --- | --- | --- | --- | ---: | ---: |"];
226
+ }
227
+
228
+ function groupRow(g: FindingGroup): string {
229
+ // Pipes inside a cell would break the table.
230
+ const cell = (v: string): string => v.replace(/\|/g, "\\|");
231
+ return `| ${ICON[g.severity]} ${g.severity} | ${cell(g.criterion)} | \`${cell(g.where)}\` (\`${cell(g.selectorHint)}\`) | ${cell(g.message)} | ${g.occurrences} | ${g.pages || "—"} |`;
232
+ }
233
+
234
+ function groupTable(rows: FindingGroup[], s: (typeof S)[Lang]): string[] {
235
+ return [...groupTableHead(s), ...rows.map(groupRow)];
236
+ }
124
237
 
125
238
  /** Markdown for `$GITHUB_STEP_SUMMARY`. */
126
239
  export function stepSummary(result: AuditResult, opts: AnnotateOptions = {}): string {
@@ -128,10 +241,12 @@ export function stepSummary(result: AuditResult, opts: AnnotateOptions = {}): st
128
241
  const lang = opts.lang ?? "en";
129
242
  const s = S[lang];
130
243
  const stdLabel = isCore(standard) ? "WCAG 2.2 AA" : loadPack(standard).name;
244
+ const rate = runRate(result, standard, lang);
131
245
 
132
246
  const out: string[] = [];
133
247
  out.push(`## ${s.title} — ${stdLabel}`, "");
134
- out.push(`\`${result.date}\` · ${result.scope.files} ${s.files} · **${result.conformancePct}%** ${s.rate}`, "");
248
+ out.push(`\`${result.date}\` · ${result.scope.files} ${s.files} · **${rate.text}** ${s.rate}`, "");
249
+ if (rate.agentRuled) out.push(`> ${agentMarkNote(lang)}`, "");
135
250
 
136
251
  // Resolve the standard's findings BEFORE the empty check: a page whose only defect comes
137
252
  // from a declarative pack rule has an empty `result.findings` and would otherwise be
@@ -147,16 +262,11 @@ export function stepSummary(result: AuditResult, opts: AnnotateOptions = {}): st
147
262
  return out.join("\n");
148
263
  }
149
264
 
150
- const sorted = [...all].sort((a, b) => SEV_ORDER.indexOf(a.severity) - SEV_ORDER.indexOf(b.severity));
151
- out.push(`### ${s.findings} (${sorted.length})`, "");
152
- out.push(`| ${s.severity} | ${s.criterion} | ${s.where} | ${s.what} |`, "| --- | --- | --- | --- |");
153
- for (const f of sorted.slice(0, MAX_ROWS)) {
154
- const where = isUrl(f.file) ? f.file : `${repoRelative(f.file, baseDir)}:${Math.max(1, f.line)}`;
155
- // Pipes inside a cell would break the table.
156
- const msg = resolveMessage(f, lang).replace(/\|/g, "\\|");
157
- out.push(`| ${ICON[f.severity]} ${f.severity} | ${criterionLabel(f, standard)} | \`${where}\` | ${msg} |`);
158
- }
159
- if (sorted.length > MAX_ROWS) out.push("", s.more(sorted.length - MAX_ROWS));
265
+ const grouped = groupFindings(all, standard, lang, baseDir);
266
+ out.push(`### ${s.findings} — ${s.grouped(grouped.length, all.length)}`, "");
267
+ out.push(`> ${s.groupNote}`, "");
268
+ out.push(...groupTable(grouped.slice(0, MAX_ROWS), s));
269
+ if (grouped.length > MAX_ROWS) out.push("", s.more(grouped.length - MAX_ROWS));
160
270
  out.push("");
161
271
 
162
272
  const unanchored = all.filter((f) => isUrl(f.file)).length;
@@ -166,6 +276,61 @@ export function stepSummary(result: AuditResult, opts: AnnotateOptions = {}): st
166
276
  return out.join("\n");
167
277
  }
168
278
 
279
+ /** The pull-request digest.
280
+ *
281
+ * Deliberately NOT the job summary. A reviewer wants the verdict, how much of the standard
282
+ * was actually decided, the distinct defects, and a way to reach the rest — in a comment
283
+ * short enough to read without collapsing. It LINKS to the artifact rather than embedding
284
+ * crops: an artifact is not addressable by URL while its run is in flight, so an inline
285
+ * image would render as a broken box on every pull request.
286
+ *
287
+ * `runUrl` / `artifactName` come from the caller (the action's env), never from this module. */
288
+ export function prComment(result: AuditResult, opts: AnnotateOptions & { runUrl?: string; artifactName?: string } = {}): string {
289
+ const standard = opts.standard ?? CORE;
290
+ const lang = opts.lang ?? "en";
291
+ const s = S[lang];
292
+ const baseDir = opts.baseDir ?? process.cwd();
293
+ const stdLabel = isCore(standard) ? "WCAG 2.2 AA" : loadPack(standard).name;
294
+ const all = findingsForStandard(result, standard);
295
+ const normative = all.filter((f) => !f.advisory);
296
+ const blocking = normative.filter((f) => f.severity === "bloquant").length;
297
+ const rate = runRate(result, standard, lang);
298
+ const grouped = groupFindings(all, standard, lang, baseDir);
299
+ const orphans = unattributedFindings(result).filter((f) => !f.advisory).length;
300
+
301
+ const head: string[] = [];
302
+ head.push(`### ${s.title} — ${stdLabel}`, "");
303
+ head.push(blocking ? s.verdictFail(blocking) : normative.length ? s.verdictWarn : s.verdictPass, "");
304
+ head.push(`\`${result.date}\` · ${result.scope.files} ${s.files} · **${rate.text}** ${s.rate}`, "");
305
+ if (rate.agentRuled) head.push(`> ${agentMarkNote(lang)}`, "");
306
+ if (orphans) head.push(`> ${s.unattributed(orphans)}`, "");
307
+
308
+ const tail: string[] = [];
309
+ if (opts.artifactName) tail.push(s.artifact(opts.artifactName), "");
310
+ if (opts.runUrl) tail.push(s.runLink(opts.runUrl), "");
311
+
312
+ // WHOLE ROWS, never a slice of the finished string. Cutting a rendered document at a byte
313
+ // offset can land mid-row — GFM then renders a broken table — or inside an unterminated
314
+ // fence, where it swallows everything after it. So the body is assembled from a row count
315
+ // that comes down until the whole document fits, and the verdict, the rate and the link are
316
+ // never candidates: a comment that fits but says nothing about where to look is worse than
317
+ // no comment at all.
318
+ const assemble = (rows: number): string => {
319
+ const body: string[] = [];
320
+ if (grouped.length) {
321
+ body.push(s.grouped(grouped.length, all.length), "");
322
+ body.push(...groupTable(grouped.slice(0, rows), s), "");
323
+ const omitted = grouped.length - rows;
324
+ if (omitted > 0) body.push(rows < COMMENT_ROWS ? s.clamped(omitted) : s.moreGroups(omitted), "");
325
+ }
326
+ return [...head, ...body, ...tail].join("\n").trimEnd();
327
+ };
328
+
329
+ let rows = Math.min(COMMENT_ROWS, grouped.length);
330
+ while (rows > 0 && assemble(rows).length > COMMENT_LIMIT) rows--;
331
+ return assemble(rows);
332
+ }
333
+
169
334
  /** The per-page scoreboard — the surface a reviewer actually scans on a PR, one row per page
170
335
  * with its rate and its findings by severity.
171
336
  *
package/src/auditor.ts CHANGED
@@ -106,6 +106,12 @@ const L = {
106
106
 
107
107
  const uniq = (xs: string[]): string[] => [...new Set(xs.filter(Boolean))];
108
108
 
109
+ /** Resolves the annotated crop illustrating one occurrence, or nothing when the evidence tier
110
+ * could not draw it. A LOOKUP rather than a manifest so this module stays ignorant of how a
111
+ * finding is keyed (src/baseline.ts `findingId`) and of the evidence tier entirely — passing a
112
+ * manifest would couple the auditor block to a key format it has no business knowing. */
113
+ export type AuditorCropLookup = (f: Finding) => { href: string; alt: string } | undefined;
114
+
109
115
  export interface AuditorUnitOpts {
110
116
  heading?: string; // "###"/"##" → emit a criterion heading; omit for a bare issue body
111
117
  // Emit the technical ticket sections (Partie technique + Contexte de reproduction).
@@ -116,6 +122,9 @@ export interface AuditorUnitOpts {
116
122
  // only the per-page sheet turns it on, because that is where one design-system defect
117
123
  // multiplied by every route using it produces a page of near-identical lines.
118
124
  collapse?: boolean;
125
+ // Hang the annotated crop under each occurrence that has one. Absent ⇒ the block is
126
+ // byte-identical to what it was before evidence existed.
127
+ cropFor?: AuditorCropLookup;
119
128
  }
120
129
 
121
130
  /** Repeated occurrences of ONE (rule, selector) on one file, folded into groups.
@@ -146,10 +155,16 @@ function groupOccurrences(findings: Finding[], collapse: boolean): Finding[][] {
146
155
  /** The inert sub-bullets that hang off one occurrence: the crosswalk deviation note, the related
147
156
  * occurrence, the capture provenance. Extracted so the grouped and ungrouped paths cannot drift,
148
157
  * and so the indent is applied in exactly one place. */
149
- function renderOccurrenceDetails(out: string[], f: Finding, lang: Lang, s: (typeof L)[Lang], indent: string): void {
158
+ function renderOccurrenceDetails(out: string[], f: Finding, lang: Lang, s: (typeof L)[Lang], indent: string, cropFor?: AuditorCropLookup): void {
150
159
  // A finding projected via an opt-in secondary crosswalk mapping carries a note explaining the
151
160
  // deviation from the SC-faithful projection — an inert (worklist-ignored) sub-bullet.
152
161
  if (f.secondary?.note) out.push(`${indent} - ↳ ${f.secondary.note}`);
162
+ // THE ONLY PLACE a crop is hung off an occurrence. `report §2`, `prd`, every tracker issue
163
+ // body and every per-page sheet inherit it here, at once. The bullet carries no `[ ]`, which
164
+ // is what keeps it out of verify.ts's worklist — indentation alone would NOT, since
165
+ // AUDITOR_OCCURRENCE is anchored `^\s*-\s\[ \]` and tolerates leading space.
166
+ const crop = cropFor?.(f);
167
+ if (crop) out.push(`${indent} - ![${crop.alt}](${crop.href})`);
153
168
  if (f.related) out.push(indent + relatedLine(f.related, lang, { selector: true }));
154
169
  if (f.origin) {
155
170
  const comp = f.origin.component ?? f.origin.sourceFile ?? f.file;
@@ -216,80 +231,137 @@ export function relatedLine(related: NonNullable<Finding["related"]>, lang: Lang
216
231
  export function renderAuditorUnit(unit: PrdUnit, standard: StandardId, lang: Lang, opts: AuditorUnitOpts = {}): string[] {
217
232
  const s = L[lang];
218
233
  if (unit.advisory) return renderAdvisoryUnit(unit, standard, lang, opts);
219
- const v = vocabularyFor(standard, lang);
220
- const technical = opts.technical ?? true;
234
+ const m = auditorUnitModel(unit, standard, lang, opts);
221
235
  const out: string[] = [];
222
- if (opts.heading) out.push(`${opts.heading} ${ICON[unit.severity]} ${unit.label}`, "");
223
- out.push(`> ${v.normativeNote ?? `${s.lead} — ${standardLabel(standard)}. ${s.tail}`}`, "");
236
+ if (opts.heading) out.push(`${opts.heading} ${m.icon} ${m.label}`, "");
237
+ out.push(`> ${m.normativeNote}`, "");
224
238
 
225
239
  // ---- 1. criterion block (unchanged) + the explicit Priorité line ----
226
- if (isCore(standard)) {
227
- const sc = getSC(unit.criteriaId);
228
- if (sc) {
229
- const pr = `${sc.principle} ${principleTitle(sc.principle, lang) ?? ""}`.trim();
230
- const gl = `${sc.guideline} ${guidelineTitle(sc.guideline, lang) ?? ""}`.trim();
231
- out.push(`**${v.theme}** : ${[pr, gl].filter(Boolean).join(" · ")}`);
232
- }
233
- out.push(`**${v.criterion}** : ${unit.criteriaId}${sc ? ` — ${unit.title}` : ""}`);
234
- const techs = scTechniques(unit.criteriaId);
235
- if (techs.length) out.push(`**${v.test}** : ${techs.join(", ")}`);
236
- out.push(`**WCAG** : ${unit.criteriaId}${sc ? ` (${sc.level})` : ""}`);
237
- } else {
238
- const pack = loadPack(standard);
239
- const pc = pack.criteria.find((c) => c.id === unit.criteriaId);
240
- if (pc) out.push(`**${v.theme}** : ${pc.theme}. ${themeName(pack, pc.theme, lang) ?? ""}`.trimEnd());
241
- out.push(`**${v.criterion}** : ${unit.criteriaId} — ${unit.title}`);
242
- const testNums = packTestIds(pack, unit.criteriaId);
243
- if (testNums.length) out.push(`**${v.test}(s)** : ${testNums.join(" · ")}`);
244
- if (unit.refs.length) out.push(`**WCAG** : ${unit.refs.map((sc) => `${sc}${scLevel(sc)}`).join(" · ")}`);
245
- }
246
- out.push(`**${s.priority}** : ${ICON[unit.severity]} ${SEV_LABEL[lang][unit.severity]}`);
240
+ for (const f of m.fields) out.push(`**${f.label}** : ${f.value}`);
247
241
 
248
242
  // ---- 2. finding / expected / verification + the PARSEABLE occurrence checklist ----
249
243
  // Advisory findings riding along in a MIXED unit are excluded from the checklist (they are
250
244
  // recommendations, never non-conformities) and rendered below in a distinct, NON-parseable
251
245
  // sub-list — so verify.ts's worklist never captures a recommendation as an NC claim. Its
252
246
  // POSITION (before any newly-introduced heading) is load-bearing for the parser.
253
- const normative = unit.findings.filter((f) => !f.advisory);
254
- const advisories = unit.findings.filter((f) => f.advisory);
255
- const ncView: PrdUnit = { ...unit, findings: normative };
256
- const messages = uniq(normative.map((f) => resolveMessage(f, lang)));
257
- const fixes = uniq(normative.map((f) => resolveRemediation(f, lang)));
258
247
  out.push("");
259
- out.push(`**${s.finding} (${v.nonConformant})** : ${normative.length} ${s.occ} — ${messages.join(" ; ")}`);
260
- if (fixes.length) out.push(`**${s.expected} (${v.conformant})** : ${fixes.join(" ; ")}`);
248
+ out.push(`**${s.finding} (${m.conformanceTerms.nonConformant})** : ${m.occurrences} ${s.occ} — ${m.messages.join(" ; ")}`);
249
+ if (m.fixes.length) out.push(`**${s.expected} (${m.conformanceTerms.conformant})** : ${m.fixes.join(" ; ")}`);
261
250
  out.push(`**${s.verification}** : ${s.verify}`, "");
262
- for (const group of groupOccurrences(normative, opts.collapse === true)) {
251
+ for (const group of m.groups) {
263
252
  // A GROUP HEADER, when several occurrences share a rule and a selector. It is deliberately
264
253
  // NOT checkbox-shaped: `verify` must see one item per claimed non-conformity, not one per
265
254
  // group, so the fold changes what the reader scans and never what the gate adjudicates.
266
- if (group.length > 1) out.push(`- **\`${group[0]!.selectorHint}\`** — ${resolveMessage(group[0]!, lang)} · ×${group.length}`);
267
- for (const f of group) {
255
+ if (group.count > 1) out.push(`- **\`${group.lead.selectorHint}\`** — ${resolveMessage(group.lead, lang)} · ×${group.count}`);
256
+ for (const f of group.findings) {
268
257
  // Members of a real group are indented under it; a lone occurrence stays flush, so an
269
258
  // ungrouped block is byte-identical to what it was before collapsing existed.
270
- const indent = group.length > 1 ? " " : "";
259
+ const indent = group.count > 1 ? " " : "";
271
260
  out.push(indent + occurrenceLine(f, lang, { marker: "checkbox" }));
272
- renderOccurrenceDetails(out, f, lang, s, indent);
261
+ renderOccurrenceDetails(out, f, lang, s, indent, opts.cropFor);
273
262
  }
274
263
  }
275
264
  out.push("");
276
265
 
277
266
  // Associated recommendations (mixed unit only): a visually distinct, NON-parseable list —
278
267
  // the `- 💡` bullet deliberately avoids the `- [ ] \`file:line\`` checklist grammar.
279
- if (advisories.length) {
268
+ if (m.advisories.length) {
280
269
  out.push(`_${s.associatedRec}_`, "");
281
- for (const f of advisories) out.push(occurrenceLine(f, lang, { marker: "advisory" }));
270
+ for (const f of m.advisories) out.push(occurrenceLine(f, lang, { marker: "advisory" }));
282
271
  out.push("");
283
272
  }
284
273
 
285
274
  // ---- 3 + 4. technical ticket sections (opt-out via prd --no-technical) ----
286
- if (technical) {
287
- out.push(...renderTechnicalSection(ncView, unit, standard, lang, opts));
288
- out.push(...renderReproductionContext(normative, lang));
275
+ if (opts.technical ?? true) {
276
+ out.push(...renderTechnicalSection({ ...unit, findings: m.normative }, unit, standard, lang, opts));
277
+ out.push(...renderReproductionContext(m.normative, lang));
289
278
  }
290
279
  return out;
291
280
  }
292
281
 
282
+ /** One `**label** : value` line of the criterion block, before it is serialized. */
283
+ export interface AuditorField {
284
+ label: string;
285
+ value: string;
286
+ }
287
+
288
+ /** Occurrences of one (file, rule, selector), as the block will present them. `count > 1` is
289
+ * what earns a group header; a lone occurrence renders flush. */
290
+ export interface AuditorOccurrenceGroup {
291
+ lead: Finding;
292
+ count: number;
293
+ findings: Finding[];
294
+ }
295
+
296
+ /** Everything `renderAuditorUnit` DECIDES, before anything is turned into Markdown.
297
+ *
298
+ * The split exists so a second surface — the HTML report — can present the same criterion
299
+ * without re-resolving the active standard's vocabulary, re-picking the theme line, or
300
+ * re-deciding which findings are normative. `renderAuditorUnit` is now the Markdown
301
+ * serializer of this model, and `tests/__snapshots__/auditor.test.ts.snap` pins that the
302
+ * serialization did not move a byte. */
303
+ export interface AuditorUnitModel {
304
+ severity: Severity;
305
+ icon: string;
306
+ label: string;
307
+ normativeNote: string;
308
+ fields: AuditorField[];
309
+ conformanceTerms: { conformant: string; nonConformant: string };
310
+ /** Normative findings only — an advisory can never be counted as a non-conformity. */
311
+ normative: Finding[];
312
+ advisories: Finding[];
313
+ occurrences: number;
314
+ messages: string[];
315
+ fixes: string[];
316
+ groups: AuditorOccurrenceGroup[];
317
+ }
318
+
319
+ export function auditorUnitModel(unit: PrdUnit, standard: StandardId, lang: Lang, opts: AuditorUnitOpts = {}): AuditorUnitModel {
320
+ const s = L[lang];
321
+ const v = vocabularyFor(standard, lang);
322
+ const fields: AuditorField[] = [];
323
+
324
+ if (isCore(standard)) {
325
+ const sc = getSC(unit.criteriaId);
326
+ if (sc) {
327
+ const pr = `${sc.principle} ${principleTitle(sc.principle, lang) ?? ""}`.trim();
328
+ const gl = `${sc.guideline} ${guidelineTitle(sc.guideline, lang) ?? ""}`.trim();
329
+ fields.push({ label: v.theme, value: [pr, gl].filter(Boolean).join(" · ") });
330
+ }
331
+ fields.push({ label: v.criterion, value: `${unit.criteriaId}${sc ? ` — ${unit.title}` : ""}` });
332
+ const techs = scTechniques(unit.criteriaId);
333
+ if (techs.length) fields.push({ label: v.test, value: techs.join(", ") });
334
+ fields.push({ label: "WCAG", value: `${unit.criteriaId}${sc ? ` (${sc.level})` : ""}` });
335
+ } else {
336
+ const pack = loadPack(standard);
337
+ const pc = pack.criteria.find((c) => c.id === unit.criteriaId);
338
+ // `.trimEnd()` on the VALUE, not on the rendered line: a pack whose theme has no localized
339
+ // name must not leave a trailing space behind the colon.
340
+ if (pc) fields.push({ label: v.theme, value: `${pc.theme}. ${themeName(pack, pc.theme, lang) ?? ""}`.trimEnd() });
341
+ fields.push({ label: v.criterion, value: `${unit.criteriaId} — ${unit.title}` });
342
+ const testNums = packTestIds(pack, unit.criteriaId);
343
+ if (testNums.length) fields.push({ label: `${v.test}(s)`, value: testNums.join(" · ") });
344
+ if (unit.refs.length) fields.push({ label: "WCAG", value: unit.refs.map((sc) => `${sc}${scLevel(sc)}`).join(" · ") });
345
+ }
346
+ fields.push({ label: s.priority, value: `${ICON[unit.severity]} ${SEV_LABEL[lang][unit.severity]}` });
347
+
348
+ const normative = unit.findings.filter((f) => !f.advisory);
349
+ return {
350
+ severity: unit.severity,
351
+ icon: ICON[unit.severity],
352
+ label: unit.label,
353
+ normativeNote: v.normativeNote ?? `${s.lead} — ${standardLabel(standard)}. ${s.tail}`,
354
+ fields,
355
+ conformanceTerms: { conformant: v.conformant, nonConformant: v.nonConformant },
356
+ normative,
357
+ advisories: unit.findings.filter((f) => f.advisory),
358
+ occurrences: normative.length,
359
+ messages: uniq(normative.map((f) => resolveMessage(f, lang))),
360
+ fixes: uniq(normative.map((f) => resolveRemediation(f, lang))),
361
+ groups: groupOccurrences(normative, opts.collapse === true).map((g) => ({ lead: g[0]!, count: g.length, findings: g })),
362
+ };
363
+ }
364
+
293
365
  /** Section 3 — Partie technique: impacted files, impacted pages/URLs (with Task-5 sample
294
366
  * provenance when present), the expected change + guidance example, Given/When/Then
295
367
  * acceptance criteria, and the deterministic complexity. All lines here are worklist-inert: