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/.claude-plugin/marketplace.json +1 -1
- package/.claude-plugin/plugin.json +1 -1
- package/.codex-plugin/plugin.json +1 -1
- package/.opencode/plugins/ultra11y.js +1 -1
- package/README.md +28 -0
- package/package.json +1 -1
- package/scripts/ultra11y.mjs +1685 -344
- package/skills/review-a11y/SKILL.md +1 -1
- package/skills/review-a11y/scripts/ultra11y.mjs +1685 -344
- package/skills/ultra11y/SKILL.md +1 -1
- package/skills/ultra11y/references/ci.md +109 -0
- package/skills/ultra11y/references/pages.md +76 -0
- package/skills/ultra11y/scripts/ultra11y.mjs +1685 -344
- package/src/annotate.ts +180 -15
- package/src/auditor.ts +115 -43
- package/src/cli.ts +187 -9
- package/src/dev.ts +23 -75
- package/src/evidence.ts +72 -37
- package/src/html-emit.ts +327 -0
- package/src/html-report.ts +482 -0
- package/src/html.ts +261 -0
- package/src/mcp/handlers.ts +11 -1
- package/src/mcp/tools.ts +10 -1
- package/src/pages-report.ts +40 -21
- package/src/pages.ts +28 -4
- package/src/report.ts +84 -31
- package/src/tickets/providers/github.ts +9 -4
- package/src/types.ts +1 -1
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
|
|
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
|
-
//
|
|
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} · **${
|
|
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
|
|
151
|
-
out.push(`### ${s.findings}
|
|
152
|
-
out.push(
|
|
153
|
-
|
|
154
|
-
|
|
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} - `);
|
|
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
|
|
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} ${
|
|
223
|
-
out.push(`> ${
|
|
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
|
-
|
|
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} (${
|
|
260
|
-
if (fixes.length) out.push(`**${s.expected} (${
|
|
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
|
|
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.
|
|
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.
|
|
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(
|
|
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:
|