ultra11y 5.2.0 → 5.3.1

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.
@@ -10,7 +10,7 @@
10
10
  "name": "ultra11y",
11
11
  "source": "./",
12
12
  "description": "Automatic accessibility review of the code under change before every commit, push and pull request, plus full WCAG 2.2 AA / RGAA audits and conformance deliverables on demand.",
13
- "version": "5.2.0",
13
+ "version": "5.3.1",
14
14
  "author": { "name": "maxgfr" },
15
15
  "homepage": "https://github.com/maxgfr/ultra11y",
16
16
  "repository": "https://github.com/maxgfr/ultra11y",
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "ultra11y",
3
3
  "description": "Accessibility for WCAG 2.2 AA and country standards (RGAA…): an automatic review of the code under change before every commit, push and pull request, plus full audits and conformance deliverables on demand.",
4
- "version": "5.2.0",
4
+ "version": "5.3.1",
5
5
  "author": { "name": "maxgfr" },
6
6
  "homepage": "https://github.com/maxgfr/ultra11y",
7
7
  "repository": "https://github.com/maxgfr/ultra11y",
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "ultra11y",
3
3
  "description": "Accessibility for WCAG 2.2 AA and country standards (RGAA…): an automatic review of the code under change before every commit, push and pull request, plus full audits and conformance deliverables on demand.",
4
- "version": "5.2.0",
4
+ "version": "5.3.1",
5
5
  "author": { "name": "maxgfr", "url": "https://github.com/maxgfr" },
6
6
  "homepage": "https://github.com/maxgfr/ultra11y",
7
7
  "repository": "https://github.com/maxgfr/ultra11y",
@@ -27,7 +27,7 @@ import { fileURLToPath } from "node:url";
27
27
  // Ownership marker: `ultra11y install --opencode` refuses to overwrite a plugin file that
28
28
  // does not carry it, so a user's own ultra11y.js is never clobbered.
29
29
  const ULTRA11Y_OPENCODE_PLUGIN = "ULTRA11Y_OPENCODE_PLUGIN";
30
- const ULTRA11Y_PLUGIN_VERSION = "5.2.0";
30
+ const ULTRA11Y_PLUGIN_VERSION = "5.3.1";
31
31
 
32
32
  const here = dirname(fileURLToPath(import.meta.url));
33
33
 
package/README.md CHANGED
@@ -391,6 +391,13 @@ a test). See [`CONTRIBUTING.md`](CONTRIBUTING.md) and `skills/ultra11y/reference
391
391
  rules on the judgment criteria that would otherwise stay « à évaluer » in CI, reading its key
392
392
  from the job environment and skipping itself without one. `ultra11y init --ci` writes a
393
393
  workflow using it. See `references/ci.md`.
394
+ - **Two PR comments, not one** — `comment-kind: digest` (the default) posts the distinct
395
+ defects a reviewer can act on; `comment-kind: pages` posts the **page-by-page grid**: one row
396
+ per page with its basis, its rate *and its denominator*, then the non-conforming criteria of
397
+ each failing page. Each kind is keyed by its own sticky marker, so a workflow that gates code
398
+ in one job and sweeps pages in another keeps **both** comments — sharing a marker is how a
399
+ 684-occurrence sweep silently overwrote a four-finding gate, and the actionable half is the
400
+ one that vanished.
394
401
  - **CI surfaces** — a red job says *that* the build broke, not *where*. `--format sarif` emits
395
402
  SARIF 2.1.0 for GitHub code scanning, so each finding lands as an **inline annotation on the
396
403
  causing line** of the PR; `--format github` does the same via `::error::` workflow commands
@@ -41,8 +41,13 @@ interface SweepOptions {
41
41
  only?: (page: SamplePageLike) => boolean;
42
42
  /** Awaited after navigation, before collecting. This is where a framework's readiness goes:
43
43
  * a design system that boots asynchronously will otherwise be serialized half-mounted, and
44
- * the audit reports non-conformities about markup no user ever meets. */
45
- settle?: (page: PlaywrightPage) => Promise<void>;
44
+ * the audit reports non-conformities about markup no user ever meets.
45
+ *
46
+ * Typed loosely on purpose. The caller writes this against Playwright's real `Page` — with
47
+ * `waitForLoadState`, `waitForFunction`, locators — and Playwright is not a dependency of
48
+ * this package, so the narrow structural shape the collector needs would reject the very
49
+ * function this option exists to take. Same reasoning as `test` below. */
50
+ settle?: (page: any) => Promise<void> | void;
46
51
  /** Passed to every `checkA11y`. `failOn: false` (the default here) records without
47
52
  * asserting: the durable output of a sweep is the snapshot, not a red test. */
48
53
  check?: Omit<PlaywrightCheckOptions, "as" | "name" | "auth" | "notes" | "sources" | "expectPath">;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "ultra11y",
3
- "version": "5.2.0",
3
+ "version": "5.3.1",
4
4
  "description": "Audit HTML/CSS/JSX against WCAG 2.2 AA and produce a dated compliance report, or author/review accessible markup (native-HTML-first). A skills.sh agent skill: an install-free static engine (bundled, no npm install) plus the agent's judgment, with check/verify gates against hallucinated non-conformities, and pluggable in-repo standards packs (RGAA France first; Section 508 & EN 301 549 next).",
5
5
  "type": "module",
6
6
  "packageManager": "pnpm@10.33.0",
@@ -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.0";
9
+ var VERSION = "5.3.1";
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,104 @@ 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(derived, s, lang), "", ...basisCaveats(result, derived, s, lang)].join("\n");
60227
+ }
60228
+ function scoreboardTable(derived, 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);
60209
60232
  out2.push(
60210
60233
  `| ${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")} |`
60211
60234
  );
60212
60235
  }
60213
- out2.push("");
60236
+ return out2;
60237
+ }
60238
+ function severityCount(pg, sev) {
60239
+ return pg.findings.filter((f) => !f.advisory && f.severity === sev).length;
60240
+ }
60241
+ function basisCaveats(result, derived, s, lang) {
60242
+ const out2 = [];
60214
60243
  const orphans = unattributedFindings(result).filter((f) => !f.advisory).length;
60215
60244
  if (orphans) out2.push(`> ${s.unattributed(orphans)}`, "");
60216
60245
  if (derived.some((p) => p.basis === "attributed")) out2.push(`> ${s.sourceBasis}`, "");
60217
60246
  const notAudited = pageBasisWarning("not-audited", lang);
60218
60247
  if (notAudited && derived.some((p) => p.basis === "not-audited")) out2.push(`> ${notAudited}`, "");
60248
+ return out2;
60249
+ }
60250
+ function pageBlock(result, page, standard, lang) {
60251
+ const s = S[lang];
60252
+ const rows = pageCriterionRows(result, page, standard, lang);
60253
+ const nc = rows.filter((r) => r.status === "NC");
60254
+ const occurrences = page.findings.filter((f) => !f.advisory).length;
60255
+ if (!nc.length && !occurrences) return void 0;
60256
+ const cell = (v) => v.replace(/\|/g, "\\|");
60257
+ const counts = `\u{1F534} ${severityCount(page, "bloquant")} \xB7 \u{1F7E0} ${severityCount(page, "majeur")} \xB7 \u{1F7E1} ${severityCount(page, "mineur")}`;
60258
+ const withTests = nc.some((r) => r.tests.length);
60259
+ const out2 = [
60260
+ "<details>",
60261
+ `<summary><b>${cell(page.name)}</b>${page.auth ? " \u{1F512}" : ""} \u2014 ${counts}</summary>`,
60262
+ // GFM only renders Markdown inside <details> after a blank line; without it the table
60263
+ // ships to the reader as literal pipes.
60264
+ "",
60265
+ `${pageTallyNote(pageTally(rows), lang)}`,
60266
+ ""
60267
+ ];
60268
+ if (!nc.length) {
60269
+ out2.push(s.noCriterionForFindings(occurrences), "", "</details>");
60270
+ return out2.join("\n");
60271
+ }
60272
+ out2.push(withTests ? `| ${s.criterion} | ${s.testsCol} |` : `| ${s.criterion} |`, withTests ? "| --- | --- |" : "| --- |");
60273
+ for (const r of nc) {
60274
+ out2.push(withTests ? `| ${cell(r.label)} | ${r.tests.map((t2) => `\`${t2}\``).join(" ")} |` : `| ${cell(r.label)} |`);
60275
+ }
60276
+ out2.push("", "</details>");
60219
60277
  return out2.join("\n");
60220
60278
  }
60279
+ function pagesComment(result, opts = {}) {
60280
+ const standard = opts.standard ?? CORE2;
60281
+ const lang = opts.lang ?? "en";
60282
+ const s = S[lang];
60283
+ const stdLabel = isCore(standard) ? "WCAG 2.2 AA" : loadPack(standard).name;
60284
+ const redirected = result.scope.redirected ?? [];
60285
+ const scope = pagesOf(result);
60286
+ const head = [`### ${s.title} \u2014 ${stdLabel} \xB7 ${s.pageByPage}`, ""];
60287
+ const tail = [];
60288
+ if (opts.artifactName) tail.push(s.artifact(opts.artifactName), "");
60289
+ if (opts.runUrl) tail.push(s.runLink(opts.runUrl), "");
60290
+ if (!scope.length) {
60291
+ head.push(s.noPages, "");
60292
+ if (redirected.length) head.push(...renderRedirected(redirected, lang), "");
60293
+ return [...head, ...tail].join("\n").trimEnd();
60294
+ }
60295
+ attributePages(result, scope);
60296
+ const derived = derivePages(result, scope);
60297
+ const normative = findingsForStandard(result, standard).filter((f) => !f.advisory);
60298
+ const blocking = normative.filter((f) => f.severity === "bloquant").length;
60299
+ const rate = runRate(result, standard, lang);
60300
+ head.push(blocking ? s.verdictFail(blocking) : normative.length ? s.verdictWarn : s.verdictPass, "");
60301
+ head.push(`\`${result.date}\` \xB7 ${s.pagesCount(derived.length)} \xB7 **${rate.text}** ${s.rate}`, "");
60302
+ if (rate.agentRuled) head.push(`> ${agentMarkNote(lang)}`, "");
60303
+ const blocks = [...derived].sort(
60304
+ (a, b) => severityCount(b, "bloquant") - severityCount(a, "bloquant") || severityCount(b, "majeur") - severityCount(a, "majeur") || severityCount(b, "mineur") - severityCount(a, "mineur")
60305
+ ).map((p) => pageBlock(result, p, standard, lang)).filter((b) => b !== void 0);
60306
+ const assemble = (nBlocks2, nRows2) => {
60307
+ const body3 = [...scoreboardTable(derived.slice(0, nRows2), s, lang), ""];
60308
+ if (nRows2 < derived.length) body3.push(s.scoreboardClamped(derived.length - nRows2), "");
60309
+ body3.push(...basisCaveats(result, derived, s, lang));
60310
+ if (redirected.length) body3.push(...renderRedirected(redirected, lang), "");
60311
+ if (blocks.length) {
60312
+ body3.push(`> ${s.pagesDetailNote}`, "");
60313
+ body3.push(...blocks.slice(0, nBlocks2).flatMap((b) => [b, ""]));
60314
+ if (nBlocks2 < blocks.length) body3.push(s.pagesClamped(blocks.length - nBlocks2), "");
60315
+ }
60316
+ return [...head, ...body3, ...tail].join("\n").trimEnd();
60317
+ };
60318
+ let nBlocks = blocks.length;
60319
+ let nRows = derived.length;
60320
+ while (nBlocks > 0 && assemble(nBlocks, nRows).length > COMMENT_LIMIT) nBlocks--;
60321
+ while (nRows > 0 && assemble(nBlocks, nRows).length > COMMENT_LIMIT) nRows--;
60322
+ return assemble(nBlocks, nRows);
60323
+ }
60221
60324
 
60222
60325
  // src/evidence.ts
60223
60326
  import { createHash as createHash6 } from "crypto";
@@ -65032,7 +65135,9 @@ function emitCiFormat(result, format, standard, lang, failOn) {
65032
65135
  console.error(md);
65033
65136
  }
65034
65137
  if (process.env.ULTRA11Y_PR_COMMENT === "1") {
65035
- const digest = prComment(result, {
65138
+ const kind = commentKindFrom(process.env.ULTRA11Y_PR_COMMENT_KIND);
65139
+ const render2 = kind === "pages" ? pagesComment : prComment;
65140
+ const body3 = render2(result, {
65036
65141
  standard,
65037
65142
  lang,
65038
65143
  // The run is known before the artifact exists, so the link is always safe. The artifact
@@ -65041,7 +65146,7 @@ function emitCiFormat(result, format, standard, lang, failOn) {
65041
65146
  ...process.env.ULTRA11Y_RUN_URL ? { runUrl: process.env.ULTRA11Y_RUN_URL } : {},
65042
65147
  ...process.env.ULTRA11Y_ARTIFACT_NAME ? { artifactName: process.env.ULTRA11Y_ARTIFACT_NAME } : {}
65043
65148
  });
65044
- const c2 = pushPrComment(digest, standard);
65149
+ const c2 = pushPrComment(body3, standard, kind);
65045
65150
  console.error(
65046
65151
  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
65152
  );
@@ -5,7 +5,7 @@ when_to_use: "Invoke WITHOUT being asked whenever changed front-end code should
5
5
  allowed-tools: Bash(node ${CLAUDE_SKILL_DIR}/scripts/ultra11y.mjs *), Read, Grep, Glob
6
6
  license: MIT
7
7
  metadata:
8
- version: 5.2.0
8
+ version: 5.3.1
9
9
  ---
10
10
 
11
11
  # review-a11y — accessibility review of changed code
@@ -110,6 +110,14 @@ rendered DOM; residual risks listed above.
110
110
  and criterion-grounded.
111
111
  - Pre-existing issues spotted in passing go in ONE final line, out of the verdict.
112
112
 
113
+ **Running as a subagent.** This skill is often dispatched rather than typed: by the plugin
114
+ hook on a pending `git commit` / `git push` / `gh pr create`, or by the `ultra11y` skill once
115
+ its audit has produced fixes. When it is, the report above **is** the return value — it
116
+ becomes a tool result for the caller, not a message to a human. So return it whole and
117
+ nothing else: no preamble, no "I ran the engine and found…", no offer to fix. A caller that
118
+ receives a summary has to guess at the findings, which is the one thing this skill exists to
119
+ stop. Everything in §4 stays opt-in and belongs to the caller, not to you.
120
+
113
121
  ## 4. After the review (opt-in)
114
122
 
115
123
  - **Safe mechanical fixes**: `fix --staged --write --safe` (auto-applies the safe codemods
@@ -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.0";
9
+ var VERSION = "5.3.1";
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,104 @@ 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(derived, s, lang), "", ...basisCaveats(result, derived, s, lang)].join("\n");
60227
+ }
60228
+ function scoreboardTable(derived, 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);
60209
60232
  out2.push(
60210
60233
  `| ${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")} |`
60211
60234
  );
60212
60235
  }
60213
- out2.push("");
60236
+ return out2;
60237
+ }
60238
+ function severityCount(pg, sev) {
60239
+ return pg.findings.filter((f) => !f.advisory && f.severity === sev).length;
60240
+ }
60241
+ function basisCaveats(result, derived, s, lang) {
60242
+ const out2 = [];
60214
60243
  const orphans = unattributedFindings(result).filter((f) => !f.advisory).length;
60215
60244
  if (orphans) out2.push(`> ${s.unattributed(orphans)}`, "");
60216
60245
  if (derived.some((p) => p.basis === "attributed")) out2.push(`> ${s.sourceBasis}`, "");
60217
60246
  const notAudited = pageBasisWarning("not-audited", lang);
60218
60247
  if (notAudited && derived.some((p) => p.basis === "not-audited")) out2.push(`> ${notAudited}`, "");
60248
+ return out2;
60249
+ }
60250
+ function pageBlock(result, page, standard, lang) {
60251
+ const s = S[lang];
60252
+ const rows = pageCriterionRows(result, page, standard, lang);
60253
+ const nc = rows.filter((r) => r.status === "NC");
60254
+ const occurrences = page.findings.filter((f) => !f.advisory).length;
60255
+ if (!nc.length && !occurrences) return void 0;
60256
+ const cell = (v) => v.replace(/\|/g, "\\|");
60257
+ const counts = `\u{1F534} ${severityCount(page, "bloquant")} \xB7 \u{1F7E0} ${severityCount(page, "majeur")} \xB7 \u{1F7E1} ${severityCount(page, "mineur")}`;
60258
+ const withTests = nc.some((r) => r.tests.length);
60259
+ const out2 = [
60260
+ "<details>",
60261
+ `<summary><b>${cell(page.name)}</b>${page.auth ? " \u{1F512}" : ""} \u2014 ${counts}</summary>`,
60262
+ // GFM only renders Markdown inside <details> after a blank line; without it the table
60263
+ // ships to the reader as literal pipes.
60264
+ "",
60265
+ `${pageTallyNote(pageTally(rows), lang)}`,
60266
+ ""
60267
+ ];
60268
+ if (!nc.length) {
60269
+ out2.push(s.noCriterionForFindings(occurrences), "", "</details>");
60270
+ return out2.join("\n");
60271
+ }
60272
+ out2.push(withTests ? `| ${s.criterion} | ${s.testsCol} |` : `| ${s.criterion} |`, withTests ? "| --- | --- |" : "| --- |");
60273
+ for (const r of nc) {
60274
+ out2.push(withTests ? `| ${cell(r.label)} | ${r.tests.map((t2) => `\`${t2}\``).join(" ")} |` : `| ${cell(r.label)} |`);
60275
+ }
60276
+ out2.push("", "</details>");
60219
60277
  return out2.join("\n");
60220
60278
  }
60279
+ function pagesComment(result, opts = {}) {
60280
+ const standard = opts.standard ?? CORE2;
60281
+ const lang = opts.lang ?? "en";
60282
+ const s = S[lang];
60283
+ const stdLabel = isCore(standard) ? "WCAG 2.2 AA" : loadPack(standard).name;
60284
+ const redirected = result.scope.redirected ?? [];
60285
+ const scope = pagesOf(result);
60286
+ const head = [`### ${s.title} \u2014 ${stdLabel} \xB7 ${s.pageByPage}`, ""];
60287
+ const tail = [];
60288
+ if (opts.artifactName) tail.push(s.artifact(opts.artifactName), "");
60289
+ if (opts.runUrl) tail.push(s.runLink(opts.runUrl), "");
60290
+ if (!scope.length) {
60291
+ head.push(s.noPages, "");
60292
+ if (redirected.length) head.push(...renderRedirected(redirected, lang), "");
60293
+ return [...head, ...tail].join("\n").trimEnd();
60294
+ }
60295
+ attributePages(result, scope);
60296
+ const derived = derivePages(result, scope);
60297
+ const normative = findingsForStandard(result, standard).filter((f) => !f.advisory);
60298
+ const blocking = normative.filter((f) => f.severity === "bloquant").length;
60299
+ const rate = runRate(result, standard, lang);
60300
+ head.push(blocking ? s.verdictFail(blocking) : normative.length ? s.verdictWarn : s.verdictPass, "");
60301
+ head.push(`\`${result.date}\` \xB7 ${s.pagesCount(derived.length)} \xB7 **${rate.text}** ${s.rate}`, "");
60302
+ if (rate.agentRuled) head.push(`> ${agentMarkNote(lang)}`, "");
60303
+ const blocks = [...derived].sort(
60304
+ (a, b) => severityCount(b, "bloquant") - severityCount(a, "bloquant") || severityCount(b, "majeur") - severityCount(a, "majeur") || severityCount(b, "mineur") - severityCount(a, "mineur")
60305
+ ).map((p) => pageBlock(result, p, standard, lang)).filter((b) => b !== void 0);
60306
+ const assemble = (nBlocks2, nRows2) => {
60307
+ const body3 = [...scoreboardTable(derived.slice(0, nRows2), s, lang), ""];
60308
+ if (nRows2 < derived.length) body3.push(s.scoreboardClamped(derived.length - nRows2), "");
60309
+ body3.push(...basisCaveats(result, derived, s, lang));
60310
+ if (redirected.length) body3.push(...renderRedirected(redirected, lang), "");
60311
+ if (blocks.length) {
60312
+ body3.push(`> ${s.pagesDetailNote}`, "");
60313
+ body3.push(...blocks.slice(0, nBlocks2).flatMap((b) => [b, ""]));
60314
+ if (nBlocks2 < blocks.length) body3.push(s.pagesClamped(blocks.length - nBlocks2), "");
60315
+ }
60316
+ return [...head, ...body3, ...tail].join("\n").trimEnd();
60317
+ };
60318
+ let nBlocks = blocks.length;
60319
+ let nRows = derived.length;
60320
+ while (nBlocks > 0 && assemble(nBlocks, nRows).length > COMMENT_LIMIT) nBlocks--;
60321
+ while (nRows > 0 && assemble(nBlocks, nRows).length > COMMENT_LIMIT) nRows--;
60322
+ return assemble(nBlocks, nRows);
60323
+ }
60221
60324
 
60222
60325
  // src/evidence.ts
60223
60326
  import { createHash as createHash6 } from "crypto";
@@ -65032,7 +65135,9 @@ function emitCiFormat(result, format, standard, lang, failOn) {
65032
65135
  console.error(md);
65033
65136
  }
65034
65137
  if (process.env.ULTRA11Y_PR_COMMENT === "1") {
65035
- const digest = prComment(result, {
65138
+ const kind = commentKindFrom(process.env.ULTRA11Y_PR_COMMENT_KIND);
65139
+ const render2 = kind === "pages" ? pagesComment : prComment;
65140
+ const body3 = render2(result, {
65036
65141
  standard,
65037
65142
  lang,
65038
65143
  // The run is known before the artifact exists, so the link is always safe. The artifact
@@ -65041,7 +65146,7 @@ function emitCiFormat(result, format, standard, lang, failOn) {
65041
65146
  ...process.env.ULTRA11Y_RUN_URL ? { runUrl: process.env.ULTRA11Y_RUN_URL } : {},
65042
65147
  ...process.env.ULTRA11Y_ARTIFACT_NAME ? { artifactName: process.env.ULTRA11Y_ARTIFACT_NAME } : {}
65043
65148
  });
65044
- const c2 = pushPrComment(digest, standard);
65149
+ const c2 = pushPrComment(body3, standard, kind);
65045
65150
  console.error(
65046
65151
  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
65152
  );
@@ -4,7 +4,7 @@ description: "Use to AUDIT existing HTML/CSS/JSX against WCAG 2.2 AA accessibili
4
4
  when_to_use: "Invoke when the user asks for an accessibility AUDIT or a formal deliverable: audit a repo, site or page against WCAG 2.2 AA or a country standard (RGAA, Section 508, EN 301 549); produce a dated conformance report, a criterion grid, a PRD backlog, or tickets (GitHub/GitLab/Jira); author accessible markup from scratch; wire the repo gate (init --hook/--ci) or a rendered-DOM capture pipeline. For a review of the code UNDER CHANGE (diff, branch, PR), use the `review-a11y` skill instead."
5
5
  license: MIT
6
6
  metadata:
7
- version: 5.2.0
7
+ version: 5.3.1
8
8
  ---
9
9
 
10
10
  # ultra11y — audit WCAG 2.2 AA and write accessible markup
@@ -332,10 +332,42 @@ drive the judgment and content stages:
332
332
  ONE building block, in the language of this conversation (pass `--lang` explicitly —
333
333
  Core rule 5).
334
334
 
335
+ 6. **Hand the change over to `review-a11y`, in a subagent.** Once the deliverable is out,
336
+ the code you just fixed still has to be reviewed as a *change*, and that is the other
337
+ skill's job — see **Handing over to `review-a11y`** below.
338
+
335
339
  **Stop** when `check` and `verify --apply` are green again and only explicitly-named
336
340
  residual risks remain. (To automate the outer cadence, the harness `/loop` command can
337
341
  re-run this cycle.)
338
342
 
343
+ ## Handing over to `review-a11y`
344
+
345
+ This skill does the analysis end to end: audit, render, adjudicate, fix, re-audit, deliver.
346
+ When it has produced fixes, **dispatch `review-a11y` on the code under change as a subagent**,
347
+ and report what it returns.
348
+
349
+ ```
350
+ Agent(subagent_type: "general-purpose", prompt: "Use the review-a11y skill on the working diff. Return its report verbatim.")
351
+ ```
352
+
353
+ Two skills, two scopes, and the split is not cosmetic:
354
+
355
+ | | scope | asks |
356
+ |---|---|---|
357
+ | `ultra11y` (this one) | the repository, a page sample, a standard | *does this product conform, and where is the proof?* |
358
+ | `review-a11y` | exactly the code under change (staged, diff, or branch vs merge-base) | *is this change safe to merge?* |
359
+
360
+ **Why a subagent rather than an inline pass.** A full audit fills a context with a 3 MB
361
+ engine's output, dozens of reference files and a page sample; a review has to read the diff
362
+ closely with none of that in the way. Handing it over keeps the review's judgment
363
+ independent of the audit that just ran — the same reason `verify` refutes findings instead
364
+ of trusting them. If the harness has no subagent tool, invoke `review-a11y` directly: the
365
+ routing table in `references/orchestration.md` names the fallbacks, and the review is
366
+ identical either way.
367
+
368
+ **Do not paraphrase its verdict.** It returns a severity-ranked review with `file:line` and a
369
+ one-line verdict; that is the deliverable, not a summary of it.
370
+
339
371
  ## Combining engine, judgment and residual risk
340
372
 
341
373
  `audit` classes each criterion `C`/`NC`/`NA` for the static subset and `manual` for the rest,