ultra11y 5.2.1 → 5.3.2

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.
@@ -34,8 +34,15 @@ Page by page, with a real browser, in the same step:
34
34
  wait-on: http://localhost:3000
35
35
  urls: http://localhost:3000 http://localhost:3000/contact
36
36
  # …or `sample: 'true'` to scan the sample declared in .ultra11yrc.json
37
+ comment: 'true'
38
+ comment-kind: pages # the page-by-page grid, under its own sticky marker
37
39
  ```
38
40
 
41
+ A page sweep is usually a **second job** — it needs a built app, a database, a browser — and
42
+ it can comment alongside the code gate above without either overwriting the other, because
43
+ each kind has its own marker. Give the two jobs distinct `artifact-name`s too: artifact names
44
+ must be unique within a run.
45
+
39
46
  `ultra11y init --ci` writes a workflow using it, **pinned to the exact engine version that
40
47
  generated the file** (`@v<that version>`) so a CI run stays reproducible. `@v<major>` — `@v5`
41
48
  today — is a moving alias the release workflow repoints at each release, for teams who would
@@ -232,12 +239,27 @@ them as separate analyses instead of one overwriting the other.
232
239
 
233
240
  ## The sticky PR comment
234
241
 
235
- `ULTRA11Y_PR_COMMENT=1` (what the action's `comment: 'true'` sets) posts the job summary as a
236
- pull-request comment and **edits it in place** on every subsequent run — a CI job that appends
237
- a fresh comment on each push turns a busy PR into a wall of stale audits. The comment is keyed
238
- by an invisible marker that includes the standard, so a WCAG run and an RGAA run keep separate
239
- comments instead of overwriting each other, and a human's comment is never adopted (an edit is
240
- destructive, so the match has to be exact).
242
+ `ULTRA11Y_PR_COMMENT=1` (what the action's `comment: 'true'` sets) posts a pull-request comment
243
+ and **edits it in place** on every subsequent run — a CI job that appends a fresh comment on
244
+ each push turns a busy PR into a wall of stale audits. The comment is keyed by an invisible
245
+ marker, and a human's comment is never adopted (an edit is destructive, so the match has to be
246
+ exact).
247
+
248
+ It is **not** the job summary. That document has a 1 MiB budget and a reader who went looking
249
+ for it; a comment has 64 KiB and a reader scanning a diff. Two documents are available, chosen
250
+ with the action's `comment-kind` (`ULTRA11Y_PR_COMMENT_KIND`):
251
+
252
+ | kind | what it posts |
253
+ |---|---|
254
+ | `digest` (default) | The verdict, the coverage, and the distinct defects — one row per (criterion, rule, selector), so 472 occurrences of one design-system defect are one row. Then a link. |
255
+ | `pages` | The page-by-page grid: one row per page with its basis, its rate **and its denominator**, and its severities; then a collapsed block per failing page listing its non-conforming criteria. Needs pages in scope; with none it says so rather than posting an empty scoreboard. |
256
+
257
+ **The marker carries both the standard and the kind**, so a WCAG run and an RGAA run keep
258
+ separate comments — and so do the code digest and the page grid of one standard. That last
259
+ part is not hypothetical: a workflow whose gate job and page-sweep job both commented under
260
+ one marker had the sweep (337 files, 684 occurrences) overwrite the gate's four actionable
261
+ findings on every run, and the tier that got muzzled was the page sweep. The kinds are also
262
+ built so neither key is a substring of the other, because the lookup matches with `includes`.
241
263
 
242
264
  It is best-effort, like issue creation: off a pull request, or with no `gh`/no auth, it
243
265
  reports `skipped` and the run carries on. A comment is never worth failing a build over. If
@@ -20,6 +20,22 @@ node scripts/ultra11y.mjs orchestrate --run <dir> [--phase adjudicate|verify-rep
20
20
  | Subagents but no Workflow tool | Same `orchestrate`; dispatch one subagent per batch following `<RUN>/orchestration/agents/<role>.md` (the workflow script lists the batches and their prompts). One writer: you fold the results in. |
21
21
  | Eco mode, or no subagents | `orchestrate --run <RUN> --eco` → follow `<RUN>/orchestration/RUNBOOK.md` sequentially, playing each role yourself. Correctness-identical; only wall-clock differs. |
22
22
 
23
+ ## The other dispatch: the review
24
+
25
+ The judgment phases are not the only thing that leaves this skill. Once the audit has
26
+ produced fixes, the **`review-a11y` skill** reviews the code under change — dispatched the
27
+ same way, and read from the same table:
28
+
29
+ | Your harness | How to run the review |
30
+ |---|---|
31
+ | Subagents (Workflow tool or not) | One subagent, one prompt: use the `review-a11y` skill on the change, return its report verbatim. |
32
+ | No subagents | Invoke `review-a11y` directly, in this session. |
33
+
34
+ It differs from a judgment batch in one way that matters: **the review is not a worklist**.
35
+ Nothing is folded back, no gate re-reads it, and it writes nothing — so the one-writer rule
36
+ below is satisfied trivially, and there is no `--apply` step to keep. What comes back is a
37
+ report for a human.
38
+
23
39
  ## The rules that do not bend
24
40
 
25
41
  - **Subagents never write.** Every emitted contract ends with the one-writer rule, and the
@@ -6,7 +6,7 @@ import { join as join50, relative as relative5, resolve as resolve15, sep as sep
6
6
  import { fileURLToPath as fileURLToPath5, pathToFileURL as pathToFileURL3 } from "url";
7
7
 
8
8
  // src/types.ts
9
- var VERSION = "5.2.1";
9
+ var VERSION = "5.3.2";
10
10
  var SCHEMA_VERSION = 2;
11
11
 
12
12
  // src/audit.ts
@@ -53611,13 +53611,16 @@ function ghAvailable() {
53611
53611
  }
53612
53612
 
53613
53613
  // src/pr-comment.ts
53614
- function COMMENT_MARKER(standard) {
53615
- return `<!-- ultra11y:report standard="${standard}" -->`;
53614
+ function COMMENT_MARKER(standard, kind = "digest") {
53615
+ return `<!-- ultra11y:report standard="${standard}"${kind === "digest" ? "" : ` kind="${kind}"`} -->`;
53616
53616
  }
53617
- function stickyBody(markdown, standard) {
53618
- return `${COMMENT_MARKER(standard)}
53617
+ function stickyBody(markdown, standard, kind = "digest") {
53618
+ return `${COMMENT_MARKER(standard, kind)}
53619
53619
  ${markdown}`;
53620
53620
  }
53621
+ function commentKindFrom(value) {
53622
+ return value === "pages" ? "pages" : "digest";
53623
+ }
53621
53624
  function prNumberFromEnv(env = process.env) {
53622
53625
  const explicit = Number.parseInt(env.ULTRA11Y_PR ?? "", 10);
53623
53626
  if (Number.isFinite(explicit) && explicit > 0) return explicit;
@@ -53628,11 +53631,11 @@ function prNumberFromEnv(env = process.env) {
53628
53631
  function pickExistingComment(comments, marker) {
53629
53632
  return comments.find((c2) => typeof c2.body === "string" && c2.body.includes(marker));
53630
53633
  }
53631
- function pushPrComment(markdown, standard = "wcag") {
53634
+ function pushPrComment(markdown, standard = "wcag", kind = "digest") {
53632
53635
  const pr = prNumberFromEnv();
53633
53636
  if (pr === void 0) return { ok: true, action: "skipped", reason: "not a pull-request run" };
53634
- const marker = COMMENT_MARKER(standard);
53635
- const body3 = stickyBody(markdown, standard);
53637
+ const marker = COMMENT_MARKER(standard, kind);
53638
+ const body3 = stickyBody(markdown, standard, kind);
53636
53639
  try {
53637
53640
  const repo = ghExec(["repo", "view", "--json", "nameWithOwner", "-q", ".nameWithOwner"]).trim();
53638
53641
  let existing;
@@ -55184,6 +55187,9 @@ function pageTally(rows) {
55184
55187
  manual: rows.filter((r) => r.status === "manual").length
55185
55188
  };
55186
55189
  }
55190
+ function pageTallyNote(t2, lang) {
55191
+ return L7[lang].tally(t2.c, t2.nc, t2.na, t2.manual);
55192
+ }
55187
55193
  function pageCoverage(rows) {
55188
55194
  const t2 = pageTally(rows);
55189
55195
  return { decided: t2.c + t2.nc, total: rows.length };
@@ -55209,7 +55215,7 @@ function renderPageReport(result, page, opts = {}) {
55209
55215
  const cov = pageCoverage(rows);
55210
55216
  const rate = pageRatePct(rows);
55211
55217
  out2.push(`- **${s.rate}** : **${rate === null ? "\u2014" : `${rate} %`}** _(${s.rateNote})_`);
55212
- out2.push(`- ${s.tally(t2.c, t2.nc, t2.na, t2.manual)}`);
55218
+ out2.push(`- ${pageTallyNote(t2, lang)}`);
55213
55219
  out2.push(`- ${s.coverage(cov.decided, cov.total)}`, "");
55214
55220
  if (page.basis !== "snapshot") out2.push(`> \u26A0\uFE0F ${s.sourceWarn}`, "");
55215
55221
  const shot = opts.screenshots?.get(page.id);
@@ -60052,7 +60058,15 @@ var S = {
60052
60058
  clamped: (n) => `_${n} groupe(s) retir\xE9(s) de ce commentaire pour tenir dans la limite de GitHub \u2014 le r\xE9sum\xE9 de job les porte tous._`,
60053
60059
  unanchored: (n) => `${n} constat(s) rattach\xE9(s) \xE0 une URL, sans ligne de code \xE0 annoter \u2014 voir le rapport.`,
60054
60060
  unattributed: (n) => `${n} constat(s) ne sont rattach\xE9s \xE0 aucune page (code partag\xE9, fichier hors routes) \u2014 compt\xE9s dans l'audit global, jamais r\xE9partis d'office.`,
60055
- sourceBasis: "Une page marqu\xE9e \xAB source \xBB n'a pas d'instantan\xE9 : l'absence de constat n'y vaut PAS conformit\xE9, et son taux ne porte que sur ce que le moteur a pu d\xE9cider ailleurs."
60061
+ sourceBasis: "Une page marqu\xE9e \xAB source \xBB n'a pas d'instantan\xE9 : l'absence de constat n'y vaut PAS conformit\xE9, et son taux ne porte que sur ce que le moteur a pu d\xE9cider ailleurs.",
60062
+ pageByPage: "page par page",
60063
+ pagesCount: (n) => `${n} page(s)`,
60064
+ testsCol: "Tests",
60065
+ noPages: "Aucune page dans le p\xE9rim\xE8tre de ce run : le balayage n'a produit aucun instantan\xE9. Ce n'est pas un bilan vide, c'est un bilan absent \u2014 les crit\xE8res au rendu restent \xE0 \xE9valuer.",
60066
+ pagesDetailNote: "Un bloc par page portant au moins une non-conformit\xE9, et seulement ses crit\xE8res **non conformes** \u2014 la grille compl\xE8te (tous les crit\xE8res de chaque page, avec leurs tests et leurs captures) vit dans l'artefact.",
60067
+ pagesClamped: (n) => `_Le d\xE9tail de ${n} page(s) a \xE9t\xE9 retir\xE9 de ce commentaire pour tenir dans la limite de GitHub \u2014 l'artefact les porte toutes._`,
60068
+ scoreboardClamped: (n) => `_${n} page(s) retir\xE9e(s) du tableau pour tenir dans la limite de GitHub \u2014 l'artefact les porte toutes._`,
60069
+ noCriterionForFindings: (n) => `${n} constat(s) sur cette page ne rendent aucun crit\xE8re du r\xE9f\xE9rentiel non conforme : leur r\xE8gle sort du p\xE9rim\xE8tre d'application de chacun. Ils comptent dans les colonnes ci-dessus, et sont d\xE9taill\xE9s dans l'artefact.`
60056
60070
  },
60057
60071
  en: {
60058
60072
  title: "ultra11y accessibility audit",
@@ -60085,7 +60099,15 @@ var S = {
60085
60099
  clamped: (n) => `_${n} group(s) dropped from this comment to fit GitHub's limit \u2014 the job summary carries them all._`,
60086
60100
  unanchored: (n) => `${n} finding(s) keyed to a URL, with no code line to annotate \u2014 see the report.`,
60087
60101
  unattributed: (n) => `${n} finding(s) are attributed to no page (shared code, file outside any route) \u2014 counted in the overall audit, never spread across pages.`,
60088
- sourceBasis: 'A page marked "source" has no snapshot: the absence of a finding there does NOT mean conforming, and its rate covers only what the engine could decide elsewhere.'
60102
+ sourceBasis: 'A page marked "source" has no snapshot: the absence of a finding there does NOT mean conforming, and its rate covers only what the engine could decide elsewhere.',
60103
+ pageByPage: "page by page",
60104
+ pagesCount: (n) => `${n} page(s)`,
60105
+ testsCol: "Tests",
60106
+ noPages: "No page in this run's scope: the sweep produced no snapshot. This is not an empty scoreboard, it is a missing one \u2014 the rendering criteria stay to assess.",
60107
+ pagesDetailNote: "One block per page carrying at least one non-conformity, and only its **non-conforming** criteria \u2014 the full grid (every criterion of every page, with its tests and its screenshot) lives in the artifact.",
60108
+ pagesClamped: (n) => `_The detail of ${n} page(s) was dropped from this comment to fit GitHub's limit \u2014 the artifact carries them all._`,
60109
+ scoreboardClamped: (n) => `_${n} page(s) dropped from the table to fit GitHub's limit \u2014 the artifact carries them all._`,
60110
+ noCriterionForFindings: (n) => `${n} finding(s) on this page make no criterion of the standard non-conforming: their rule falls outside every criterion's applicability. They are counted in the columns above, and detailed in the artifact.`
60089
60111
  }
60090
60112
  };
60091
60113
  var MAX_ROWS = 50;
@@ -60201,23 +60223,106 @@ function perPageTable(result, standard = CORE2, lang = "en") {
60201
60223
  if (!scope.length) return "";
60202
60224
  attributePages(result, scope);
60203
60225
  const derived = derivePages(result, scope);
60204
- const out2 = [`### ${s.perPage}`, ""];
60205
- out2.push(`| ${s.page} | ${s.basis} | ${s.pageRate} | \u{1F534} | \u{1F7E0} | \u{1F7E1} |`, "| --- | --- | ---: | ---: | ---: | ---: |");
60226
+ return [`### ${s.perPage}`, "", ...scoreboardTable(result, derived, standard, s, lang), "", ...basisCaveats(result, derived, s, lang)].join("\n");
60227
+ }
60228
+ function scoreboardTable(result, derived, standard, s, lang) {
60229
+ const out2 = [`| ${s.page} | ${s.basis} | ${s.pageRate} | \u{1F534} | \u{1F7E0} | \u{1F7E1} |`, "| --- | --- | ---: | ---: | ---: | ---: |"];
60206
60230
  for (const pg of derived) {
60207
- const nc = pg.findings.filter((f) => !f.advisory);
60208
- const n = (sev) => nc.filter((f) => f.severity === sev).length;
60231
+ const n = (sev) => severityCount(pg, sev);
60232
+ const rows = pageCriterionRows(result, pg, standard, lang);
60233
+ const cov = pageCoverage(rows);
60209
60234
  out2.push(
60210
- `| ${pg.name}${pg.auth ? " \u{1F512}" : ""} \u2014 \`${pg.url}\` | ${basisLabel(pg.basis, lang)} | ${formatRate(pg.conformancePct, pg.decided, pg.total)} | ${n("bloquant")} | ${n("majeur")} | ${n("mineur")} |`
60235
+ `| ${pg.name}${pg.auth ? " \u{1F512}" : ""} \u2014 \`${pg.url}\` | ${basisLabel(pg.basis, lang)} | ${formatRate(pageRatePct(rows), cov.decided, cov.total)} | ${n("bloquant")} | ${n("majeur")} | ${n("mineur")} |`
60211
60236
  );
60212
60237
  }
60213
- out2.push("");
60238
+ return out2;
60239
+ }
60240
+ function severityCount(pg, sev) {
60241
+ return pg.findings.filter((f) => !f.advisory && f.severity === sev).length;
60242
+ }
60243
+ function basisCaveats(result, derived, s, lang) {
60244
+ const out2 = [];
60214
60245
  const orphans = unattributedFindings(result).filter((f) => !f.advisory).length;
60215
60246
  if (orphans) out2.push(`> ${s.unattributed(orphans)}`, "");
60216
60247
  if (derived.some((p) => p.basis === "attributed")) out2.push(`> ${s.sourceBasis}`, "");
60217
60248
  const notAudited = pageBasisWarning("not-audited", lang);
60218
60249
  if (notAudited && derived.some((p) => p.basis === "not-audited")) out2.push(`> ${notAudited}`, "");
60250
+ return out2;
60251
+ }
60252
+ function pageBlock(result, page, standard, lang) {
60253
+ const s = S[lang];
60254
+ const rows = pageCriterionRows(result, page, standard, lang);
60255
+ const nc = rows.filter((r) => r.status === "NC");
60256
+ const occurrences = page.findings.filter((f) => !f.advisory).length;
60257
+ if (!nc.length && !occurrences) return void 0;
60258
+ const cell = (v) => v.replace(/\|/g, "\\|");
60259
+ const counts = `\u{1F534} ${severityCount(page, "bloquant")} \xB7 \u{1F7E0} ${severityCount(page, "majeur")} \xB7 \u{1F7E1} ${severityCount(page, "mineur")}`;
60260
+ const withTests = nc.some((r) => r.tests.length);
60261
+ const out2 = [
60262
+ "<details>",
60263
+ `<summary><b>${cell(page.name)}</b>${page.auth ? " \u{1F512}" : ""} \u2014 ${counts}</summary>`,
60264
+ // GFM only renders Markdown inside <details> after a blank line; without it the table
60265
+ // ships to the reader as literal pipes.
60266
+ "",
60267
+ `${pageTallyNote(pageTally(rows), lang)}`,
60268
+ ""
60269
+ ];
60270
+ if (!nc.length) {
60271
+ out2.push(s.noCriterionForFindings(occurrences), "", "</details>");
60272
+ return out2.join("\n");
60273
+ }
60274
+ out2.push(withTests ? `| ${s.criterion} | ${s.testsCol} |` : `| ${s.criterion} |`, withTests ? "| --- | --- |" : "| --- |");
60275
+ for (const r of nc) {
60276
+ out2.push(withTests ? `| ${cell(r.label)} | ${r.tests.map((t2) => `\`${t2}\``).join(" ")} |` : `| ${cell(r.label)} |`);
60277
+ }
60278
+ out2.push("", "</details>");
60219
60279
  return out2.join("\n");
60220
60280
  }
60281
+ function pagesComment(result, opts = {}) {
60282
+ const standard = opts.standard ?? CORE2;
60283
+ const lang = opts.lang ?? "en";
60284
+ const s = S[lang];
60285
+ const stdLabel = isCore(standard) ? "WCAG 2.2 AA" : loadPack(standard).name;
60286
+ const redirected = result.scope.redirected ?? [];
60287
+ const scope = pagesOf(result);
60288
+ const head = [`### ${s.title} \u2014 ${stdLabel} \xB7 ${s.pageByPage}`, ""];
60289
+ const tail = [];
60290
+ if (opts.artifactName) tail.push(s.artifact(opts.artifactName), "");
60291
+ if (opts.runUrl) tail.push(s.runLink(opts.runUrl), "");
60292
+ if (!scope.length) {
60293
+ head.push(s.noPages, "");
60294
+ if (redirected.length) head.push(...renderRedirected(redirected, lang), "");
60295
+ return [...head, ...tail].join("\n").trimEnd();
60296
+ }
60297
+ attributePages(result, scope);
60298
+ const derived = derivePages(result, scope);
60299
+ const normative = findingsForStandard(result, standard).filter((f) => !f.advisory);
60300
+ const blocking = normative.filter((f) => f.severity === "bloquant").length;
60301
+ const rate = runRate(result, standard, lang);
60302
+ head.push(blocking ? s.verdictFail(blocking) : normative.length ? s.verdictWarn : s.verdictPass, "");
60303
+ head.push(`\`${result.date}\` \xB7 ${s.pagesCount(derived.length)} \xB7 **${rate.text}** ${s.rate}`, "");
60304
+ if (rate.agentRuled) head.push(`> ${agentMarkNote(lang)}`, "");
60305
+ const blocks = [...derived].sort(
60306
+ (a, b) => severityCount(b, "bloquant") - severityCount(a, "bloquant") || severityCount(b, "majeur") - severityCount(a, "majeur") || severityCount(b, "mineur") - severityCount(a, "mineur")
60307
+ ).map((p) => pageBlock(result, p, standard, lang)).filter((b) => b !== void 0);
60308
+ const assemble = (nBlocks2, nRows2) => {
60309
+ const body3 = [...scoreboardTable(result, derived.slice(0, nRows2), standard, s, lang), ""];
60310
+ if (nRows2 < derived.length) body3.push(s.scoreboardClamped(derived.length - nRows2), "");
60311
+ body3.push(...basisCaveats(result, derived, s, lang));
60312
+ if (redirected.length) body3.push(...renderRedirected(redirected, lang), "");
60313
+ if (blocks.length) {
60314
+ body3.push(`> ${s.pagesDetailNote}`, "");
60315
+ body3.push(...blocks.slice(0, nBlocks2).flatMap((b) => [b, ""]));
60316
+ if (nBlocks2 < blocks.length) body3.push(s.pagesClamped(blocks.length - nBlocks2), "");
60317
+ }
60318
+ return [...head, ...body3, ...tail].join("\n").trimEnd();
60319
+ };
60320
+ let nBlocks = blocks.length;
60321
+ let nRows = derived.length;
60322
+ while (nBlocks > 0 && assemble(nBlocks, nRows).length > COMMENT_LIMIT) nBlocks--;
60323
+ while (nRows > 0 && assemble(nBlocks, nRows).length > COMMENT_LIMIT) nRows--;
60324
+ return assemble(nBlocks, nRows);
60325
+ }
60221
60326
 
60222
60327
  // src/evidence.ts
60223
60328
  import { createHash as createHash6 } from "crypto";
@@ -65032,7 +65137,9 @@ function emitCiFormat(result, format, standard, lang, failOn) {
65032
65137
  console.error(md);
65033
65138
  }
65034
65139
  if (process.env.ULTRA11Y_PR_COMMENT === "1") {
65035
- const digest = prComment(result, {
65140
+ const kind = commentKindFrom(process.env.ULTRA11Y_PR_COMMENT_KIND);
65141
+ const render2 = kind === "pages" ? pagesComment : prComment;
65142
+ const body3 = render2(result, {
65036
65143
  standard,
65037
65144
  lang,
65038
65145
  // The run is known before the artifact exists, so the link is always safe. The artifact
@@ -65041,7 +65148,7 @@ function emitCiFormat(result, format, standard, lang, failOn) {
65041
65148
  ...process.env.ULTRA11Y_RUN_URL ? { runUrl: process.env.ULTRA11Y_RUN_URL } : {},
65042
65149
  ...process.env.ULTRA11Y_ARTIFACT_NAME ? { artifactName: process.env.ULTRA11Y_ARTIFACT_NAME } : {}
65043
65150
  });
65044
- const c2 = pushPrComment(digest, standard);
65151
+ const c2 = pushPrComment(body3, standard, kind);
65045
65152
  console.error(
65046
65153
  c2.ok ? lang === "fr" ? `ultra11y : commentaire de PR ${c2.action === "updated" ? "mis \xE0 jour" : c2.action === "created" ? "cr\xE9\xE9" : "ignor\xE9"}${c2.reason ? ` (${c2.reason})` : ""}.` : `ultra11y: PR comment ${c2.action}${c2.reason ? ` (${c2.reason})` : ""}.` : lang === "fr" ? `ultra11y : commentaire de PR impossible${c2.reason ? ` \u2014 ${c2.reason}` : ""}.` : `ultra11y: PR comment failed${c2.reason ? ` \u2014 ${c2.reason}` : ""}.`
65047
65154
  );
package/src/annotate.ts CHANGED
@@ -11,6 +11,9 @@
11
11
  // truncation of the one above: a reviewer scanning a PR needs the verdict, the coverage
12
12
  // and the handful of distinct defects, then a link. Posting the full summary there is how
13
13
  // a real audit (472 findings over 7 distinct selectors) became a wall nobody read.
14
+ // • `pagesComment()` — the PAGE-BY-PAGE grid, posted under its OWN sticky marker. The tier
15
+ // that sweeps pages and the tier that gates code both comment on the same pull request,
16
+ // and they answer different questions; sharing a marker made the sweep overwrite the gate.
14
17
  //
15
18
  // All are pure string builders. Nothing here decides anything: a finding that is not
16
19
  // anchorable in the repo tree (a URL-keyed dynamic result) is skipped rather than pinned
@@ -20,9 +23,20 @@ import { resolveMessage, resolveRemediation } from "./messages.js";
20
23
  import { findingsForStandard, packCriteriaForFinding } from "./standards/derive.js";
21
24
  import { CORE, type StandardId, isCore, loadPack } from "./standards/index.js";
22
25
  import { packReportGroups, reportCoverage, reportGroups } from "./report.js";
23
- import type { AuditResult, Finding, Lang, Severity } from "./types.js";
26
+ import type { AuditResult, Finding, Lang, PageResult, Severity } from "./types.js";
24
27
  import { isUrlPath, repoRelative } from "./util.js";
25
- import { agentMarkNote, attributePages, basisLabel, derivePages, formatRate, pageBasisWarning, pagesOf, unattributedFindings } from "./pages.js";
28
+ import {
29
+ agentMarkNote,
30
+ attributePages,
31
+ basisLabel,
32
+ derivePages,
33
+ formatRate,
34
+ pageBasisWarning,
35
+ pagesOf,
36
+ renderRedirected,
37
+ unattributedFindings,
38
+ } from "./pages.js";
39
+ import { pageCoverage, pageCriterionRows, pageRatePct, pageTally, pageTallyNote } from "./pages-report.js";
26
40
 
27
41
  export interface AnnotateOptions {
28
42
  standard?: StandardId;
@@ -112,6 +126,18 @@ const S = {
112
126
  `${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.`,
113
127
  sourceBasis:
114
128
  "Une page marquée « source » n'a pas d'instantané : l'absence de constat n'y vaut PAS conformité, et son taux ne porte que sur ce que le moteur a pu décider ailleurs.",
129
+ pageByPage: "page par page",
130
+ pagesCount: (n: number) => `${n} page(s)`,
131
+ testsCol: "Tests",
132
+ noPages:
133
+ "Aucune page dans le périmètre de ce run : le balayage n'a produit aucun instantané. Ce n'est pas un bilan vide, c'est un bilan absent — les critères au rendu restent à évaluer.",
134
+ pagesDetailNote:
135
+ "Un bloc par page portant au moins une non-conformité, et seulement ses critères **non conformes** — la grille complète (tous les critères de chaque page, avec leurs tests et leurs captures) vit dans l'artefact.",
136
+ pagesClamped: (n: number) =>
137
+ `_Le détail de ${n} page(s) a été retiré de ce commentaire pour tenir dans la limite de GitHub — l'artefact les porte toutes._`,
138
+ scoreboardClamped: (n: number) => `_${n} page(s) retirée(s) du tableau pour tenir dans la limite de GitHub — l'artefact les porte toutes._`,
139
+ noCriterionForFindings: (n: number) =>
140
+ `${n} constat(s) sur cette page ne rendent aucun critère du référentiel non conforme : leur règle sort du périmètre d'application de chacun. Ils comptent dans les colonnes ci-dessus, et sont détaillés dans l'artefact.`,
115
141
  },
116
142
  en: {
117
143
  title: "ultra11y accessibility audit",
@@ -148,6 +174,17 @@ const S = {
148
174
  `${n} finding(s) are attributed to no page (shared code, file outside any route) — counted in the overall audit, never spread across pages.`,
149
175
  sourceBasis:
150
176
  'A page marked "source" has no snapshot: the absence of a finding there does NOT mean conforming, and its rate covers only what the engine could decide elsewhere.',
177
+ pageByPage: "page by page",
178
+ pagesCount: (n: number) => `${n} page(s)`,
179
+ testsCol: "Tests",
180
+ noPages:
181
+ "No page in this run's scope: the sweep produced no snapshot. This is not an empty scoreboard, it is a missing one — the rendering criteria stay to assess.",
182
+ pagesDetailNote:
183
+ "One block per page carrying at least one non-conformity, and only its **non-conforming** criteria — the full grid (every criterion of every page, with its tests and its screenshot) lives in the artifact.",
184
+ pagesClamped: (n: number) => `_The detail of ${n} page(s) was dropped from this comment to fit GitHub's limit — the artifact carries them all._`,
185
+ scoreboardClamped: (n: number) => `_${n} page(s) dropped from the table to fit GitHub's limit — the artifact carries them all._`,
186
+ noCriterionForFindings: (n: number) =>
187
+ `${n} finding(s) on this page make no criterion of the standard non-conforming: their rule falls outside every criterion's applicability. They are counted in the columns above, and detailed in the artifact.`,
151
188
  },
152
189
  } as const;
153
190
 
@@ -346,22 +383,167 @@ export function perPageTable(result: AuditResult, standard: StandardId = CORE, l
346
383
  if (!scope.length) return "";
347
384
  attributePages(result, scope);
348
385
  const derived = derivePages(result, scope);
349
- const out: string[] = [`### ${s.perPage}`, ""];
350
- out.push(`| ${s.page} | ${s.basis} | ${s.pageRate} | 🔴 | 🟠 | 🟡 |`, "| --- | --- | ---: | ---: | ---: | ---: |");
386
+ // The summary has a 1 MiB budget: it never clamps, and passes every page.
387
+ return [`### ${s.perPage}`, "", ...scoreboardTable(result, derived, standard, s, lang), "", ...basisCaveats(result, derived, s, lang)].join("\n");
388
+ }
389
+
390
+ /** The scoreboard's rows. Extracted so the job summary and the page-by-page pull-request
391
+ * comment draw ONE table from one projection — a second copy of this loop is a second chance
392
+ * to disagree about what a page's rate is.
393
+ *
394
+ * The rate comes from `pageCriterionRows`, in the ACTIVE standard's vocabulary — NOT from
395
+ * `PageResult.conformancePct`, which is always WCAG-keyed. Under a pack the two count
396
+ * different things, and the gap is visible in one glance: a page rendered « 50 % (4/55) »
397
+ * here while its own sheet in the artifact counted those 4 against RGAA's 106, and the run
398
+ * headline right above said `(9/106)`. One document, one standard, two denominators —
399
+ * exactly the drift `formatRate` exists to prevent. */
400
+ function scoreboardTable(result: AuditResult, derived: PageResult[], standard: StandardId, s: (typeof S)[Lang], lang: Lang): string[] {
401
+ const out: string[] = [`| ${s.page} | ${s.basis} | ${s.pageRate} | 🔴 | 🟠 | 🟡 |`, "| --- | --- | ---: | ---: | ---: | ---: |"];
351
402
  for (const pg of derived) {
352
- const nc = pg.findings.filter((f) => !f.advisory);
353
- const n = (sev: Severity): number => nc.filter((f) => f.severity === sev).length;
403
+ const n = (sev: Severity): number => severityCount(pg, sev);
404
+ const rows = pageCriterionRows(result, pg, standard, lang);
405
+ const cov = pageCoverage(rows);
354
406
  out.push(
355
- `| ${pg.name}${pg.auth ? " 🔒" : ""} — \`${pg.url}\` | ${basisLabel(pg.basis, lang)} | ${formatRate(pg.conformancePct, pg.decided, pg.total)} | ${n("bloquant")} | ${n("majeur")} | ${n("mineur")} |`,
407
+ `| ${pg.name}${pg.auth ? " 🔒" : ""} — \`${pg.url}\` | ${basisLabel(pg.basis, lang)} | ${formatRate(pageRatePct(rows), cov.decided, cov.total)} | ${n("bloquant")} | ${n("majeur")} | ${n("mineur")} |`,
356
408
  );
357
409
  }
358
- out.push("");
410
+ return out;
411
+ }
412
+
413
+ function severityCount(pg: PageResult, sev: Severity): number {
414
+ return pg.findings.filter((f) => !f.advisory && f.severity === sev).length;
415
+ }
416
+
417
+ /** The caveats a scoreboard must carry: findings no page could claim, and every basis weaker
418
+ * than a snapshot actually present. One caveat per basis, from the shared sentences — a
419
+ * « non audité » page must not be explained by the note that asserts it has no snapshot. */
420
+ function basisCaveats(result: AuditResult, derived: PageResult[], s: (typeof S)[Lang], lang: Lang): string[] {
421
+ const out: string[] = [];
359
422
  const orphans = unattributedFindings(result).filter((f) => !f.advisory).length;
360
423
  if (orphans) out.push(`> ${s.unattributed(orphans)}`, "");
361
- // One caveat per basis actually present, from the shared sentences — a « non audité » page must
362
- // not be explained by the note that asserts it has no snapshot.
363
424
  if (derived.some((p) => p.basis === "attributed")) out.push(`> ${s.sourceBasis}`, "");
364
425
  const notAudited = pageBasisWarning("not-audited", lang);
365
426
  if (notAudited && derived.some((p) => p.basis === "not-audited")) out.push(`> ${notAudited}`, "");
427
+ return out;
428
+ }
429
+
430
+ /** One page's collapsed block: its severity counts in the summary line, its standing as the
431
+ * shared tally sentence, and the criteria that are actually NON-CONFORMING.
432
+ *
433
+ * Only the NC rows. A per-page norm has ~106 criteria and a sample has dozens of pages: the
434
+ * full grid is thousands of rows, which is why it is an artifact and not a comment. What a
435
+ * reviewer needs inline is which pages fail and on what — the tally line carries the rest
436
+ * (conforming, not applicable, and how many nobody has ruled on yet) without pretending the
437
+ * undecided ones are fine. */
438
+ function pageBlock(result: AuditResult, page: PageResult, standard: StandardId, lang: Lang): string | undefined {
439
+ const s = S[lang];
440
+ const rows = pageCriterionRows(result, page, standard, lang);
441
+ const nc = rows.filter((r) => r.status === "NC");
442
+ const occurrences = page.findings.filter((f) => !f.advisory).length;
443
+ if (!nc.length && !occurrences) return undefined;
444
+ const cell = (v: string): string => v.replace(/\|/g, "\\|");
445
+ const counts = `🔴 ${severityCount(page, "bloquant")} · 🟠 ${severityCount(page, "majeur")} · 🟡 ${severityCount(page, "mineur")}`;
446
+ const withTests = nc.some((r) => r.tests.length);
447
+ const out: string[] = [
448
+ "<details>",
449
+ `<summary><b>${cell(page.name)}</b>${page.auth ? " 🔒" : ""} — ${counts}</summary>`,
450
+ // GFM only renders Markdown inside <details> after a blank line; without it the table
451
+ // ships to the reader as literal pipes.
452
+ "",
453
+ `${pageTallyNote(pageTally(rows), lang)}`,
454
+ "",
455
+ ];
456
+ // The summary line counts OCCURRENCES; the table below lists CRITERIA. They are two
457
+ // projections, and they can legitimately disagree: a finding whose rule sits outside every
458
+ // criterion's applicability under this standard is counted and decides nothing. Left
459
+ // unsaid, that page shows « 🟠 1 » over an empty block and the reader is owed an
460
+ // explanation rather than an inference.
461
+ if (!nc.length) {
462
+ out.push(s.noCriterionForFindings(occurrences), "", "</details>");
463
+ return out.join("\n");
464
+ }
465
+ out.push(withTests ? `| ${s.criterion} | ${s.testsCol} |` : `| ${s.criterion} |`, withTests ? "| --- | --- |" : "| --- |");
466
+ for (const r of nc) {
467
+ out.push(withTests ? `| ${cell(r.label)} | ${r.tests.map((t) => `\`${t}\``).join(" ")} |` : `| ${cell(r.label)} |`);
468
+ }
469
+ out.push("", "</details>");
366
470
  return out.join("\n");
367
471
  }
472
+
473
+ /** The PAGE-BY-PAGE pull-request comment.
474
+ *
475
+ * A second document under a second marker, for a second tier. The digest above answers
476
+ * "what is broken in this diff"; this one answers "which pages conform, and on which
477
+ * criteria" — the question a per-page standard like RGAA is actually written in. They cannot
478
+ * share a sticky, and they did: the sweep's 337 files and 684 occurrences overwrote the four
479
+ * actionable findings of the code gate on every run, which is why the sweep was muzzled
480
+ * rather than fixed.
481
+ *
482
+ * Nothing is decided here either. The scoreboard is the projection the job summary draws,
483
+ * and each page's non-conformities come from `pageCriterionRows` — the very rows the per-page
484
+ * sheet renders into the artifact. */
485
+ export function pagesComment(result: AuditResult, opts: AnnotateOptions & { runUrl?: string; artifactName?: string } = {}): string {
486
+ const standard = opts.standard ?? CORE;
487
+ const lang = opts.lang ?? "en";
488
+ const s = S[lang];
489
+ const stdLabel = isCore(standard) ? "WCAG 2.2 AA" : loadPack(standard).name;
490
+ const redirected = result.scope.redirected ?? [];
491
+ const scope = pagesOf(result);
492
+
493
+ const head: string[] = [`### ${s.title} — ${stdLabel} · ${s.pageByPage}`, ""];
494
+ const tail: string[] = [];
495
+ if (opts.artifactName) tail.push(s.artifact(opts.artifactName), "");
496
+ if (opts.runUrl) tail.push(s.runLink(opts.runUrl), "");
497
+
498
+ // No page in scope is not a clean report, it is an ABSENT one. Saying so is what tells a
499
+ // reviewer the tier ran at all — silence here reads as "nothing to report".
500
+ if (!scope.length) {
501
+ head.push(s.noPages, "");
502
+ if (redirected.length) head.push(...renderRedirected(redirected, lang), "");
503
+ return [...head, ...tail].join("\n").trimEnd();
504
+ }
505
+
506
+ attributePages(result, scope);
507
+ const derived = derivePages(result, scope);
508
+ const normative = findingsForStandard(result, standard).filter((f) => !f.advisory);
509
+ const blocking = normative.filter((f) => f.severity === "bloquant").length;
510
+ const rate = runRate(result, standard, lang);
511
+
512
+ head.push(blocking ? s.verdictFail(blocking) : normative.length ? s.verdictWarn : s.verdictPass, "");
513
+ head.push(`\`${result.date}\` · ${s.pagesCount(derived.length)} · **${rate.text}** ${s.rate}`, "");
514
+ if (rate.agentRuled) head.push(`> ${agentMarkNote(lang)}`, "");
515
+
516
+ // Worst first, so the pages the size clamp drops are the least severe ones.
517
+ const blocks = [...derived]
518
+ .sort(
519
+ (a, b) =>
520
+ severityCount(b, "bloquant") - severityCount(a, "bloquant") ||
521
+ severityCount(b, "majeur") - severityCount(a, "majeur") ||
522
+ severityCount(b, "mineur") - severityCount(a, "mineur"),
523
+ )
524
+ .map((p) => pageBlock(result, p, standard, lang))
525
+ .filter((b): b is string => b !== undefined);
526
+
527
+ // WHOLE blocks and WHOLE rows, never a slice of the finished string — cutting a rendered
528
+ // document at a byte offset lands mid-row and GFM then renders a broken table. The detail
529
+ // goes first because the scoreboard is the half that cannot be reconstructed from the
530
+ // artifact link alone.
531
+ const assemble = (nBlocks: number, nRows: number): string => {
532
+ const body: string[] = [...scoreboardTable(result, derived.slice(0, nRows), standard, s, lang), ""];
533
+ if (nRows < derived.length) body.push(s.scoreboardClamped(derived.length - nRows), "");
534
+ body.push(...basisCaveats(result, derived, s, lang));
535
+ if (redirected.length) body.push(...renderRedirected(redirected, lang), "");
536
+ if (blocks.length) {
537
+ body.push(`> ${s.pagesDetailNote}`, "");
538
+ body.push(...blocks.slice(0, nBlocks).flatMap((b) => [b, ""]));
539
+ if (nBlocks < blocks.length) body.push(s.pagesClamped(blocks.length - nBlocks), "");
540
+ }
541
+ return [...head, ...body, ...tail].join("\n").trimEnd();
542
+ };
543
+
544
+ let nBlocks = blocks.length;
545
+ let nRows = derived.length;
546
+ while (nBlocks > 0 && assemble(nBlocks, nRows).length > COMMENT_LIMIT) nBlocks--;
547
+ while (nRows > 0 && assemble(nBlocks, nRows).length > COMMENT_LIMIT) nRows--;
548
+ return assemble(nBlocks, nRows);
549
+ }
package/src/cli.ts CHANGED
@@ -18,7 +18,7 @@ import { runAudit } from "./audit.js";
18
18
  import { decide, type PreToolUsePayload } from "./hook.js";
19
19
  import { writeReport, untestedNeedsRendering, partialAuditBanner } from "./report.js";
20
20
  import { writePrd, prdUnits, type PrdFormat } from "./prd.js";
21
- import { pushPrComment } from "./pr-comment.js";
21
+ import { commentKindFrom, pushPrComment } from "./pr-comment.js";
22
22
  import { buildTickets } from "./tickets/grain.js";
23
23
  import { pushTickets } from "./tickets/push.js";
24
24
  import { autoProvider, createProvider, isProviderId } from "./tickets/registry.js";
@@ -57,7 +57,7 @@ import { installForTargets, parseTargets, statusReport, uninstallForTargets } fr
57
57
  import { agentsMdBlock } from "./install/agents-md.js";
58
58
  import { auditSummary, captureCoverageSummary } from "./output.js";
59
59
  import { toSarif } from "./sarif.js";
60
- import { annotations, prComment, stepSummary } from "./annotate.js";
60
+ import { annotations, pagesComment, prComment, stepSummary } from "./annotate.js";
61
61
  import { evidenceNotice, writeEvidence } from "./evidence.js";
62
62
  import { writeHtml } from "./html-emit.js";
63
63
  import { PAGES_DIR, readSnapshots, validateSnapshotMeta, writeSnapshot, type AxNode, type BoxDigest, type CssDigest, type StyleDigest } from "./snapshot.js";
@@ -789,7 +789,12 @@ function emitCiFormat(result: AuditResult, format: CiFormat, standard: StandardI
789
789
  // reader who went looking for it; a PR comment has 64 KiB and a reader scanning a diff.
790
790
  // Posting one string to both is what turned a 700-finding audit into a wall on the PR.
791
791
  if (process.env.ULTRA11Y_PR_COMMENT === "1") {
792
- const digest = prComment(result, {
792
+ // WHICH document this run posts. Anything but an explicit `pages` is the historical
793
+ // digest under the historical marker: an unset — or misspelled — variable must degrade to
794
+ // the behaviour every existing workflow already depends on, never to silence.
795
+ const kind = commentKindFrom(process.env.ULTRA11Y_PR_COMMENT_KIND);
796
+ const render = kind === "pages" ? pagesComment : prComment;
797
+ const body = render(result, {
793
798
  standard,
794
799
  lang,
795
800
  // The run is known before the artifact exists, so the link is always safe. The artifact
@@ -798,7 +803,7 @@ function emitCiFormat(result: AuditResult, format: CiFormat, standard: StandardI
798
803
  ...(process.env.ULTRA11Y_RUN_URL ? { runUrl: process.env.ULTRA11Y_RUN_URL } : {}),
799
804
  ...(process.env.ULTRA11Y_ARTIFACT_NAME ? { artifactName: process.env.ULTRA11Y_ARTIFACT_NAME } : {}),
800
805
  });
801
- const c = pushPrComment(digest, standard);
806
+ const c = pushPrComment(body, standard, kind);
802
807
  console.error(
803
808
  c.ok
804
809
  ? lang === "fr"
@@ -218,6 +218,15 @@ export function pageTally(rows: PageCriterionRow[]): { c: number; nc: number; na
218
218
  };
219
219
  }
220
220
 
221
+ /** The tally as one sentence — « X conforme(s) · Y non conforme(s) · … ». Exported for the
222
+ * same reason `formatRate` is: the sheet, the HTML and the pull-request grid all state a
223
+ * page's standing, and a surface that phrases its own is a surface that will drift. The
224
+ * « à évaluer » count is the half readers drop, and it is the one that says how much of the
225
+ * page nobody has ruled on yet. */
226
+ export function pageTallyNote(t: { c: number; nc: number; na: number; manual: number }, lang: Lang): string {
227
+ return L[lang].tally(t.c, t.nc, t.na, t.manual);
228
+ }
229
+
221
230
  /** The rate's denominator, extracted so the sheet's « Couverture » line and the index cell are
222
231
  * computed ONCE. They disagreed before: the sheet said 2/106 while the index printed a bare
223
232
  * 100 %, and the index is the artefact people paste into a pull request. */
@@ -259,7 +268,7 @@ export function renderPageReport(result: AuditResult, page: PageResult, opts: Pa
259
268
  const cov = pageCoverage(rows);
260
269
  const rate = pageRatePct(rows);
261
270
  out.push(`- **${s.rate}** : **${rate === null ? "—" : `${rate} %`}** _(${s.rateNote})_`);
262
- out.push(`- ${s.tally(t.c, t.nc, t.na, t.manual)}`);
271
+ out.push(`- ${pageTallyNote(t, lang)}`);
263
272
  // The rate alone reads as a verdict on the page. Naming the denominator next to it is
264
273
  // what stops "100 %" over six decided criteria from being quoted as a conformant page.
265
274
  out.push(`- ${s.coverage(cov.decided, cov.total)}`, "");