ultra11y 5.28.0 → 5.29.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.
@@ -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.28.0",
13
+ "version": "5.29.0",
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.28.0",
4
+ "version": "5.29.0",
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.28.0",
4
+ "version": "5.29.0",
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.28.0";
30
+ const ULTRA11Y_PLUGIN_VERSION = "5.29.0";
31
31
 
32
32
  const here = dirname(fileURLToPath(import.meta.url));
33
33
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "ultra11y",
3
- "version": "5.28.0",
3
+ "version": "5.29.0",
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",
@@ -57,6 +57,7 @@
57
57
  "verify:bundle": "node scripts/verify-skill-bundle.mjs",
58
58
  "check:build": "tsup && node scripts/copy-bundle.mjs && git diff --exit-code -- scripts/ultra11y.mjs skills/ultra11y/scripts/ultra11y.mjs skills/review-a11y/scripts/ultra11y.mjs dist && node scripts/build-extension-icons.mjs --check && node scripts/verify-skill-bundle.mjs && node scripts/sync-engine.mjs --check",
59
59
  "build:wcag": "node scripts/build-standards.mjs",
60
+ "check:standards": "node scripts/build-standards.mjs --check",
60
61
  "build:pack:rgaa": "node scripts/build-pack-rgaa.mjs",
61
62
  "check:pack:rgaa": "node scripts/build-pack-rgaa.mjs --check",
62
63
  "build:guidance:wcag": "node scripts/build-guidance-wcag.mjs",
@@ -42,6 +42,29 @@ function biomeFormat(text, relPath) {
42
42
  return execFileSync(BIOME, ["format", `--stdin-file-path=${relPath}`], { input: text, encoding: "utf8" });
43
43
  }
44
44
 
45
+ // CHECK MODE — the gate this generator was the only one of the three to lack.
46
+ //
47
+ // scripts/build-pack-rgaa.mjs and scripts/build-guidance-wcag.mjs both compare their
48
+ // generated text to the committed file and exit 1 on drift; this one only ever wrote. So a
49
+ // hand edit to src/data/wcag.json passed every gate and was found by the NIGHTLY refresh
50
+ // instead — on main, a day later, in a workflow nobody watches. Measured: SC 1.3.1 carried
51
+ // three presentational-* rules that RULE_SC_COVERAGE below never cited, and
52
+ // standards-refresh went red every night for two days before anyone read the log.
53
+ //
54
+ // Every write goes through `emit` so the check compares EXACTLY what a build would write —
55
+ // a check that recomputed the text a second way could agree with itself and still be wrong.
56
+ const CHECK = process.argv.includes("--check");
57
+ const drift = [];
58
+ function emit(relPath, text) {
59
+ const abs = join(root, relPath);
60
+ if (CHECK) {
61
+ if (!existsSync(abs) || readFileSync(abs, "utf8") !== text) drift.push(relPath);
62
+ return;
63
+ }
64
+ mkdirSync(dirname(abs), { recursive: true });
65
+ writeFileSync(abs, text);
66
+ }
67
+
45
68
  // ---------------------------------------------------------------------------
46
69
  // Refresh mode: parse a w3c/wcag checkout into the vendored SC snapshot.
47
70
  // SC numbers are positional within guidelines/index.html (principle → guideline →
@@ -623,6 +646,7 @@ const RULE_SC_COVERAGE = {
623
646
  "sortable-header-no-aria-sort", "nav-landmark-missing", "nav-landmark-unnamed", "aria-required-parent",
624
647
  "headers-attr-dangling", "th-no-data-cells",
625
648
  "radio-checkbox-group-ungrouped", "table-empty-data-cell", "css-generated-content-informative",
649
+ "presentational-element", "presentational-attribute", "presentational-spacing",
626
650
  ],
627
651
  "1.3.5": ["field-purpose-incomplete", "autocomplete-token-invalid"],
628
652
  "1.4.2": ["autoplay-media"],
@@ -708,8 +732,7 @@ function buildUniverse(shipped) {
708
732
  provenance: univ.provenance,
709
733
  criteria,
710
734
  };
711
- mkdirSync(DATA, { recursive: true });
712
- writeFileSync(join(DATA, "wcag-universe.json"), biomeFormat(JSON.stringify(out, null, 2) + "\n", "src/data/wcag-universe.json"));
735
+ emit("src/data/wcag-universe.json", biomeFormat(JSON.stringify(out, null, 2) + "\n", "src/data/wcag-universe.json"));
713
736
 
714
737
  const coreIds = new Set(criteria.filter((c) => c.status === "core-AA").map((c) => c.id));
715
738
  const shippedIds = new Set(shipped.map((c) => c.sc));
@@ -724,6 +747,7 @@ function buildUniverse(shipped) {
724
747
  }
725
748
  const tally = { "core-AA": 0, "out-of-core": 0, removed: 0 };
726
749
  for (const c of criteria) tally[c.status]++;
750
+ if (CHECK) return;
727
751
  console.log(
728
752
  `build-standards: ${criteria.length} WCAG 2.x SCs classified — core-AA ${tally["core-AA"]}, out-of-core ${tally["out-of-core"]}, removed ${tally.removed} → src/data/wcag-universe.json`,
729
753
  );
@@ -839,8 +863,7 @@ function build() {
839
863
  // resolve in either language off one list.
840
864
  glossaryFr: frGlossary,
841
865
  };
842
- mkdirSync(DATA, { recursive: true });
843
- writeFileSync(join(DATA, "wcag.json"), biomeFormat(JSON.stringify(out, null, 2) + "\n", "src/data/wcag.json"));
866
+ emit("src/data/wcag.json", biomeFormat(JSON.stringify(out, null, 2) + "\n", "src/data/wcag.json"));
844
867
 
845
868
  // --- guards
846
869
  const all = new Set(criteria.map((c) => c.sc));
@@ -863,6 +886,7 @@ function build() {
863
886
 
864
887
  const tally = { static: 0, "needs-rendering": 0, judgment: 0 };
865
888
  for (const c of criteria) tally[c.automatability]++;
889
+ if (CHECK) return void buildUniverse(criteria);
866
890
  console.log(`build-standards: ${criteria.length} WCAG 2.2 A/AA criteria → src/data/wcag.json`);
867
891
  console.log(`build-standards: automatability — static ${tally.static}, needs-rendering ${tally["needs-rendering"]}, judgment ${tally.judgment}`);
868
892
  console.log(`build-standards: seeded techniques from ${sources.length ? sources.map((s) => s.replace(root + "/", "")).join(", ") : "(no pack found — empty)"}`);
@@ -872,12 +896,26 @@ function build() {
872
896
 
873
897
  async function main() {
874
898
  const refreshIdx = process.argv.indexOf("--refresh");
899
+ // A check that first goes and fetches is not a check: it would compare the committed
900
+ // dataset against a source that moved under it, and call an upstream change a local drift.
901
+ // `--check` reads the vendored snapshots and nothing else.
902
+ const refreshing = refreshIdx !== -1 || process.argv.some((a) => a.startsWith("--refresh-"));
903
+ if (CHECK && refreshing) {
904
+ console.error("build-standards --check: --check is offline by construction; it cannot be combined with a --refresh flag.");
905
+ process.exit(1);
906
+ }
875
907
  if (refreshIdx !== -1) deriveSnapshot(process.argv[refreshIdx + 1]);
876
908
  if (process.argv.includes("--refresh-universe")) await deriveUniverse();
877
909
  if (process.argv.includes("--refresh-core")) refreshCore();
878
910
  if (process.argv.includes("--refresh-fr")) await deriveFr();
879
911
  if (process.argv.includes("--refresh-text")) await deriveText();
880
912
  build();
913
+ if (!CHECK) return;
914
+ if (drift.length) {
915
+ console.error(`build-standards --check: OUT OF DATE vs the vendored source — re-run \`pnpm run build:wcag\`: ${drift.join(", ")}`);
916
+ process.exit(1);
917
+ }
918
+ console.log("build-standards --check: src/data/wcag.json and src/data/wcag-universe.json match the vendored source.");
881
919
  }
882
920
 
883
921
  main().catch((e) => {
@@ -6,7 +6,7 @@ import { join as join51, relative as relative5, resolve as resolve16, 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.28.0";
9
+ var VERSION = "5.29.0";
10
10
  var SCHEMA_VERSION = 2;
11
11
  var INAPPLICABLE_STATUS = "C";
12
12
 
@@ -47016,14 +47016,14 @@ var tableEmptyDataCell = {
47016
47016
  const out2 = [];
47017
47017
  for (const t3 of doc.elements) {
47018
47018
  if (t3.tag !== "table" || isLayoutTable(t3)) continue;
47019
- for (const cell of descendants(t3)) {
47020
- if (cell.tag !== "td") continue;
47021
- if (mayInjectContent(cell)) continue;
47022
- if (cell.children.some((c2) => c2.type === "element")) continue;
47023
- if ((attr(cell, "aria-label") ?? "").trim() || hasAttr(cell, "aria-labelledby")) continue;
47024
- const txt = visibleText(cell);
47019
+ for (const cell2 of descendants(t3)) {
47020
+ if (cell2.tag !== "td") continue;
47021
+ if (mayInjectContent(cell2)) continue;
47022
+ if (cell2.children.some((c2) => c2.type === "element")) continue;
47023
+ if ((attr(cell2, "aria-label") ?? "").trim() || hasAttr(cell2, "aria-labelledby")) continue;
47024
+ const txt = visibleText(cell2);
47025
47025
  if (txt !== "" && txt !== "-") continue;
47026
- out2.push({ criteriaId: "1.3.1", el: cell, msgId: "table-empty-data-cell", advisory: true });
47026
+ out2.push({ criteriaId: "1.3.1", el: cell2, msgId: "table-empty-data-cell", advisory: true });
47027
47027
  }
47028
47028
  }
47029
47029
  return out2;
@@ -63298,8 +63298,9 @@ var S = {
63298
63298
  criterion: "Crit\xE8re",
63299
63299
  where: "Emplacement",
63300
63300
  what: "Constat",
63301
- more: (n) => `\u2026 et ${n} autre(s).`,
63302
- moreGroups: (n) => `\u2026 et ${n} autre(s) groupe(s) \u2014 voir le r\xE9sum\xE9 de job.`,
63301
+ moreCriteria: (n) => `\u2026 et ${n} autre(s) crit\xE8re(s).`,
63302
+ moreDefects: (n) => `\u2026 et ${n} autre(s) d\xE9faut(s) distinct(s) sur ce crit\xE8re \u2014 voir le rapport de l'artefact.`,
63303
+ moreGroups: (n) => `\u2026 et ${n} autre(s) crit\xE8re(s) \u2014 voir le r\xE9sum\xE9 de job.`,
63303
63304
  perPage: "Bilan page par page",
63304
63305
  pageCriteriaTitle: (name2) => `${name2} \u2014 le d\xE9tail crit\xE8re par crit\xE8re`,
63305
63306
  conformingList: "Conformes",
@@ -63314,14 +63315,16 @@ var S = {
63314
63315
  source: "source",
63315
63316
  occurrences: "Occ.",
63316
63317
  pagesCol: "Pages",
63317
- grouped: (groups, occ) => `${groups} d\xE9faut(s) distinct(s) \xB7 ${occ} occurrence(s)`,
63318
- groupNote: "Une ligne par (crit\xE8re, r\xE8gle, s\xE9lecteur) : un m\xEAme d\xE9faut de design system r\xE9p\xE9t\xE9 sur toutes les routes compte pour un. Les colonnes Occ. et Pages disent l'ampleur r\xE9elle.",
63318
+ defectsCol: "D\xE9fauts",
63319
+ byCriterion: (criteria, defects, occ) => `${criteria} crit\xE8re(s) \xB7 ${defects} d\xE9faut(s) distinct(s) \xB7 ${occ} occurrence(s)`,
63320
+ criterionDefects: (n) => `${n} d\xE9faut(s) distinct(s)`,
63321
+ criterionNote: "Une ligne par crit\xE8re du r\xE9f\xE9rentiel \u2014 d\xE9pliez-en un pour ses d\xE9fauts distincts (r\xE8gle, s\xE9lecteur, emplacement). Un m\xEAme d\xE9faut de design system r\xE9p\xE9t\xE9 sur toutes les routes compte pour un ; les colonnes Occ. et Pages disent l'ampleur r\xE9elle.",
63319
63322
  verdictFail: (n) => `\u{1F534} ${n} non-conformit\xE9(s) bloquante(s) \u2014 la porte est rouge.`,
63320
63323
  verdictWarn: "\u{1F7E0} Aucune non-conformit\xE9 bloquante ; des constats majeurs ou mineurs restent \xE0 traiter.",
63321
63324
  verdictPass: "\u2705 Aucune non-conformit\xE9 relev\xE9e par le moteur statique.",
63322
63325
  artifact: (name2) => `Rapport complet (HTML, captures annot\xE9es) : artefact **${name2}** du run.`,
63323
63326
  runLink: (url) => `[Voir le run et son r\xE9sum\xE9 de job](${url})`,
63324
- 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._`,
63327
+ clamped: (n) => `_${n} crit\xE8re(s) retir\xE9(s) de ce commentaire pour tenir dans la limite de GitHub \u2014 le r\xE9sum\xE9 de job les porte tous._`,
63325
63328
  sectionsDropped: (names) => `_Sections retir\xE9es de ce commentaire pour tenir dans la limite de GitHub (64 Kio), en entier et jamais tronqu\xE9es : ${names.map((n) => `**${n}**`).join(" \xB7 ")}. Elles sont dans le rapport de l'artefact, \xE0 l'identique._`,
63326
63329
  unanchored: (n) => `${n} constat(s) rattach\xE9(s) \xE0 une URL, sans ligne de code \xE0 annoter \u2014 voir le rapport.`,
63327
63330
  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.`,
@@ -63347,10 +63350,10 @@ var S = {
63347
63350
  pageDefects: "D\xE9fauts",
63348
63351
  fullGrid: "Grille compl\xE8te \u2014 chaque crit\xE8re du r\xE9f\xE9rentiel, page par page",
63349
63352
  allDefects: "D\xE9fauts distincts \u2014 o\xF9 corriger",
63350
- allDefectsNote: "Un d\xE9faut distinct = une (r\xE8gle, crit\xE8re, s\xE9lecteur) ; les occurrences r\xE9p\xE9t\xE9es sont repli\xE9es. C'est la moiti\xE9 \xAB actionnable \xBB du digest, reprise ici pour que ce commentaire soit le seul \xE0 lire.",
63353
+ allDefectsNote: "Group\xE9s par crit\xE8re : un d\xE9faut distinct = une (r\xE8gle, s\xE9lecteur), les occurrences r\xE9p\xE9t\xE9es sont repli\xE9es, et un crit\xE8re n'appara\xEEt qu'une fois. C'est la moiti\xE9 \xAB actionnable \xBB du digest, reprise ici pour que ce commentaire soit le seul \xE0 lire.",
63351
63354
  gridLegend: "`C` conforme \xB7 `NC` non conforme \xB7 `\u2014` non applicable \xB7 `?` \xE0 \xE9valuer",
63352
63355
  gridDropped: "_La grille compl\xE8te ne tient pas dans un commentaire GitHub (64 Kio) \u2014 elle est dans la fiche par page du livrable._",
63353
- pageMoreDefects: (n) => `_\u2026 et ${n} autre(s) d\xE9faut(s) distinct(s) sur cette page \u2014 voir la fiche de page dans l'artefact._`,
63356
+ pageMoreCriteria: (n) => `_\u2026 et ${n} autre(s) crit\xE8re(s) non conforme(s) sur cette page \u2014 voir la fiche de page dans l'artefact._`,
63354
63357
  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.`
63355
63358
  },
63356
63359
  en: {
@@ -63363,8 +63366,9 @@ var S = {
63363
63366
  criterion: "Criterion",
63364
63367
  where: "Location",
63365
63368
  what: "Finding",
63366
- more: (n) => `\u2026 and ${n} more.`,
63367
- moreGroups: (n) => `\u2026 and ${n} more group(s) \u2014 see the job summary.`,
63369
+ moreCriteria: (n) => `\u2026 and ${n} more criterion(ia).`,
63370
+ moreDefects: (n) => `\u2026 and ${n} more distinct defect(s) on this criterion \u2014 see the artifact's report.`,
63371
+ moreGroups: (n) => `\u2026 and ${n} more criterion(ia) \u2014 see the job summary.`,
63368
63372
  perPage: "Page-by-page scoreboard",
63369
63373
  pageCriteriaTitle: (name2) => `${name2} \u2014 criterion by criterion`,
63370
63374
  conformingList: "Conforming",
@@ -63379,14 +63383,16 @@ var S = {
63379
63383
  source: "source",
63380
63384
  occurrences: "Occ.",
63381
63385
  pagesCol: "Pages",
63382
- grouped: (groups, occ) => `${groups} distinct defect(s) \xB7 ${occ} occurrence(s)`,
63383
- groupNote: "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.",
63386
+ defectsCol: "Defects",
63387
+ byCriterion: (criteria, defects, occ) => `${criteria} criterion(ia) \xB7 ${defects} distinct defect(s) \xB7 ${occ} occurrence(s)`,
63388
+ criterionDefects: (n) => `${n} distinct defect(s)`,
63389
+ criterionNote: "One row per criterion of the standard \u2014 unfold one for its distinct defects (rule, selector, location). One design-system defect repeated across every route counts once; the Occ. and Pages columns carry the real scale.",
63384
63390
  verdictFail: (n) => `\u{1F534} ${n} blocking non-conformity(ies) \u2014 the gate is red.`,
63385
63391
  verdictWarn: "\u{1F7E0} No blocking non-conformity; major or minor findings remain.",
63386
63392
  verdictPass: "\u2705 No non-conformity found by the static engine.",
63387
63393
  artifact: (name2) => `Full report (HTML, annotated crops): artifact **${name2}** of this run.`,
63388
63394
  runLink: (url) => `[See the run and its job summary](${url})`,
63389
- clamped: (n) => `_${n} group(s) dropped from this comment to fit GitHub's limit \u2014 the job summary carries them all._`,
63395
+ clamped: (n) => `_${n} criterion(ia) dropped from this comment to fit GitHub's limit \u2014 the job summary carries them all._`,
63390
63396
  sectionsDropped: (names) => `_Sections dropped from this comment to fit GitHub's 64 KiB limit, whole and never truncated: ${names.map((n) => `**${n}**`).join(" \xB7 ")}. They are in the artifact's report, identical._`,
63391
63397
  unanchored: (n) => `${n} finding(s) keyed to a URL, with no code line to annotate \u2014 see the report.`,
63392
63398
  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.`,
@@ -63412,14 +63418,15 @@ var S = {
63412
63418
  pageDefects: "Defects",
63413
63419
  fullGrid: "Full grid \u2014 every criterion of the standard, page by page",
63414
63420
  allDefects: "Distinct defects \u2014 where to fix",
63415
- allDefectsNote: "One distinct defect = one (rule, criterion, selector); repeated occurrences are folded. This is the digest's actionable half, carried here so this comment is the only one to read.",
63421
+ allDefectsNote: "Grouped by criterion: one distinct defect = one (rule, selector), repeated occurrences are folded, and a criterion appears once. This is the digest's actionable half, carried here so this comment is the only one to read.",
63416
63422
  gridLegend: "`C` conforming \xB7 `NC` non-conforming \xB7 `\u2014` not applicable \xB7 `?` to assess",
63417
63423
  gridDropped: "_The full grid does not fit in a GitHub comment (64 KiB) \u2014 it is in the deliverable's per-page sheet._",
63418
- pageMoreDefects: (n) => `_\u2026 and ${n} more distinct defect(s) on this page \u2014 see its sheet in the artifact._`,
63424
+ pageMoreCriteria: (n) => `_\u2026 and ${n} more non-conforming criterion(ia) on this page \u2014 see its sheet in the artifact._`,
63419
63425
  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.`
63420
63426
  }
63421
63427
  };
63422
63428
  var MAX_ROWS = 50;
63429
+ var MAX_DEFECTS_PER_CRITERION = 20;
63423
63430
  var COMMENT_ROWS = 10;
63424
63431
  var COMMENT_LIMIT = 65536;
63425
63432
  var BYTES = new TextEncoder();
@@ -63449,7 +63456,29 @@ function groupFindings(findings, standard, lang, baseDir) {
63449
63456
  pageSet: new Set(f.page ? [f.page] : [])
63450
63457
  });
63451
63458
  }
63452
- return [...groups.values()].map(({ pageSet, ...g }) => ({ ...g, pages: pageSet.size })).sort((a, b) => SEV_ORDER4.indexOf(a.severity) - SEV_ORDER4.indexOf(b.severity) || b.occurrences - a.occurrences || a.criterion.localeCompare(b.criterion));
63459
+ return [...groups.values()].map(({ pageSet, ...g }) => ({ ...g, pages: pageSet.size, pageIds: [...pageSet].sort() })).sort((a, b) => SEV_ORDER4.indexOf(a.severity) - SEV_ORDER4.indexOf(b.severity) || b.occurrences - a.occurrences || a.criterion.localeCompare(b.criterion));
63460
+ }
63461
+ function groupByCriterion(groups) {
63462
+ const byCriterion = /* @__PURE__ */ new Map();
63463
+ for (const g of groups) {
63464
+ const c2 = byCriterion.get(g.criterion);
63465
+ if (c2) {
63466
+ c2.defects.push(g);
63467
+ c2.occurrences += g.occurrences;
63468
+ for (const p of g.pageIds) c2.pageSet.add(p);
63469
+ if (SEV_ORDER4.indexOf(g.severity) < SEV_ORDER4.indexOf(c2.severity)) c2.severity = g.severity;
63470
+ continue;
63471
+ }
63472
+ byCriterion.set(g.criterion, {
63473
+ criterion: g.criterion,
63474
+ severity: g.severity,
63475
+ defects: [g],
63476
+ occurrences: g.occurrences,
63477
+ pages: 0,
63478
+ pageSet: new Set(g.pageIds)
63479
+ });
63480
+ }
63481
+ return [...byCriterion.values()].map(({ pageSet, ...c2 }) => ({ ...c2, pages: pageSet.size })).sort((a, b) => SEV_ORDER4.indexOf(a.severity) - SEV_ORDER4.indexOf(b.severity) || b.occurrences - a.occurrences || a.criterion.localeCompare(b.criterion));
63453
63482
  }
63454
63483
  function runRate(result, standard, lang) {
63455
63484
  const groups = isCore(standard) ? reportGroups(result, lang) : packReportGroups(result, loadPack(standard), lang);
@@ -63457,15 +63486,37 @@ function runRate(result, standard, lang) {
63457
63486
  const agentRuled = groups.some((g) => g.rows.some((r) => r.decidedBy === "agent" && r.status === "C"));
63458
63487
  return { text: `${formatRate(decided === 0 ? null : result.conformancePct, decided, total)}${agentRuled ? "*" : ""}`, agentRuled };
63459
63488
  }
63460
- function groupTableHead(s) {
63461
- return [`| ${s.severity} | ${s.criterion} | ${s.where} | ${s.what} | ${s.occurrences} | ${s.pagesCol} |`, "| --- | --- | --- | --- | ---: | ---: |"];
63489
+ var cell = (v) => v.replace(/\|/g, "\\|");
63490
+ function criterionTableHead(s) {
63491
+ return [`| ${s.severity} | ${s.criterion} | ${s.defectsCol} | ${s.occurrences} | ${s.pagesCol} |`, "| --- | --- | ---: | ---: | ---: |"];
63462
63492
  }
63463
- function groupRow(g) {
63464
- const cell = (v) => v.replace(/\|/g, "\\|");
63465
- return `| ${ICON5[g.severity]} ${g.severity} | ${cell(g.criterion)} | \`${cell(g.where)}\` (\`${cell(g.selectorHint)}\`) | ${cell(mdText(g.message))} | ${g.occurrences} | ${g.pages || "\u2014"} |`;
63493
+ function criterionRow(c2) {
63494
+ return `| ${ICON5[c2.severity]} ${c2.severity} | ${cell(c2.criterion)} | ${c2.defects.length} | ${c2.occurrences} | ${c2.pages || "\u2014"} |`;
63466
63495
  }
63467
- function groupTable(rows, s) {
63468
- return [...groupTableHead(s), ...rows.map(groupRow)];
63496
+ function defectRow(g) {
63497
+ return `| ${ICON5[g.severity]} ${g.severity} | \`${cell(g.where)}\` (\`${cell(g.selectorHint)}\`) | ${cell(mdText(g.message))} | ${g.occurrences} | ${g.pages || "\u2014"} |`;
63498
+ }
63499
+ function criterionDetails(c2, s) {
63500
+ const out2 = [
63501
+ "<details>",
63502
+ `<summary><b>${cell(c2.criterion)}</b> \u2014 ${s.criterionDefects(c2.defects.length)}</summary>`,
63503
+ // GFM only renders Markdown inside <details> after a blank line — at EVERY level of
63504
+ // nesting, and these folds sit inside the page and digest folds.
63505
+ "",
63506
+ `| ${s.severity} | ${s.where} | ${s.what} | ${s.occurrences} | ${s.pagesCol} |`,
63507
+ "| --- | --- | --- | ---: | ---: |",
63508
+ ...c2.defects.slice(0, MAX_DEFECTS_PER_CRITERION).map(defectRow)
63509
+ ];
63510
+ if (c2.defects.length > MAX_DEFECTS_PER_CRITERION) out2.push("", s.moreDefects(c2.defects.length - MAX_DEFECTS_PER_CRITERION));
63511
+ out2.push("", "</details>");
63512
+ return out2;
63513
+ }
63514
+ function criterionSection(criteria, s, maxRows, more) {
63515
+ const shown = criteria.slice(0, maxRows);
63516
+ const out2 = [...criterionTableHead(s), ...shown.map(criterionRow)];
63517
+ if (criteria.length > maxRows) out2.push("", more(criteria.length - maxRows));
63518
+ for (const c2 of shown) out2.push("", ...criterionDetails(c2, s));
63519
+ return out2;
63469
63520
  }
63470
63521
  function stepSummary(result, opts = {}) {
63471
63522
  const standard = opts.standard ?? CORE2;
@@ -63485,10 +63536,10 @@ function stepSummary(result, opts = {}) {
63485
63536
  return out2.join("\n");
63486
63537
  }
63487
63538
  const grouped = groupFindings(all, standard, lang, baseDir);
63488
- out2.push(`### ${s.findings} \u2014 ${s.grouped(grouped.length, all.length)}`, "");
63489
- out2.push(`> ${s.groupNote}`, "");
63490
- out2.push(...groupTable(grouped.slice(0, MAX_ROWS), s));
63491
- if (grouped.length > MAX_ROWS) out2.push("", s.more(grouped.length - MAX_ROWS));
63539
+ const criteria = groupByCriterion(grouped);
63540
+ out2.push(`### ${s.findings} \u2014 ${s.byCriterion(criteria.length, grouped.length, all.length)}`, "");
63541
+ out2.push(`> ${s.criterionNote}`, "");
63542
+ out2.push(...criterionSection(criteria, s, MAX_ROWS, s.moreCriteria));
63492
63543
  out2.push("");
63493
63544
  const unanchored = all.filter((f) => isUrl2(f.file)).length;
63494
63545
  if (unanchored) out2.push(`> ${s.unanchored(unanchored)}`, "");
@@ -63529,6 +63580,7 @@ function prComment(result, opts = {}) {
63529
63580
  const blocking = normative.filter((f) => f.severity === "bloquant").length;
63530
63581
  const rate = runRate(result, standard, lang);
63531
63582
  const grouped = groupFindings(all, standard, lang, baseDir);
63583
+ const criteria = groupByCriterion(grouped);
63532
63584
  const orphans = unattributedFindings(result).filter((f) => !f.advisory).length;
63533
63585
  const head = [];
63534
63586
  head.push(`### ${s.title} \u2014 ${stdLabel}`, "");
@@ -63547,15 +63599,15 @@ function prComment(result, opts = {}) {
63547
63599
  if (sizeOf(assembled) <= COMMENT_LIMIT) return assembled;
63548
63600
  const assemble = (rows2) => {
63549
63601
  const digest = [];
63550
- if (grouped.length) {
63551
- digest.push(s.grouped(grouped.length, all.length), "");
63552
- digest.push(...groupTable(grouped.slice(0, rows2), s), "");
63553
- const omitted = grouped.length - rows2;
63602
+ if (criteria.length) {
63603
+ digest.push(s.byCriterion(criteria.length, grouped.length, all.length), "");
63604
+ digest.push(...criterionSection(criteria.slice(0, rows2), s, rows2, s.moreCriteria), "");
63605
+ const omitted = criteria.length - rows2;
63554
63606
  if (omitted > 0) digest.push(rows2 < COMMENT_ROWS ? s.clamped(omitted) : s.moreGroups(omitted), "");
63555
63607
  }
63556
63608
  return [...head, ...digest, ...tail].join("\n").trimEnd();
63557
63609
  };
63558
- let rows = Math.min(COMMENT_ROWS, grouped.length);
63610
+ let rows = Math.min(COMMENT_ROWS, criteria.length);
63559
63611
  while (rows > 0 && sizeOf(assemble(rows)) > COMMENT_LIMIT) rows--;
63560
63612
  return assemble(rows);
63561
63613
  }
@@ -63638,14 +63690,13 @@ function basisCaveats(result, derived, s, lang) {
63638
63690
  if (notAudited && derived.some((p) => p.basis === "not-audited")) out2.push(`> ${notAudited}`, "");
63639
63691
  return out2;
63640
63692
  }
63641
- var PAGE_DEFECTS_SHOWN = 6;
63693
+ var PAGE_CRITERIA_SHOWN = 6;
63642
63694
  function pageBlock(result, page, standard, lang, baseDir) {
63643
63695
  const s = S[lang];
63644
63696
  const rows = pageCriterionRows(result, page, standard, lang);
63645
63697
  const nc = rows.filter((r) => r.status === "NC");
63646
63698
  const occurrences = page.findings.filter((f) => !f.advisory).length;
63647
63699
  if (!nc.length && !occurrences) return void 0;
63648
- const cell = (v) => v.replace(/\|/g, "\\|");
63649
63700
  const counts = `\u{1F534} ${severityCount(page, "bloquant")} \xB7 \u{1F7E0} ${severityCount(page, "majeur")} \xB7 \u{1F7E1} ${severityCount(page, "mineur")}`;
63650
63701
  const withTests = nc.some((r) => r.tests.length);
63651
63702
  const out2 = [
@@ -63671,24 +63722,13 @@ function pageBlock(result, page, standard, lang, baseDir) {
63671
63722
  lang,
63672
63723
  baseDir
63673
63724
  );
63674
- const blocking = defects.filter((g) => g.severity === "bloquant");
63675
- const rest = defects.filter((g) => g.severity !== "bloquant");
63676
- const shownBlocking = Math.min(blocking.length, PAGE_DEFECTS_SHOWN);
63677
- const shownRest = Math.min(rest.length, Math.max(0, PAGE_DEFECTS_SHOWN - shownBlocking));
63725
+ const blocking = groupByCriterion(defects.filter((g) => g.severity === "bloquant"));
63726
+ const rest = groupByCriterion(defects.filter((g) => g.severity !== "bloquant"));
63727
+ const shownBlocking = Math.min(blocking.length, PAGE_CRITERIA_SHOWN);
63728
+ const shownRest = Math.min(rest.length, Math.max(0, PAGE_CRITERIA_SHOWN - shownBlocking));
63678
63729
  const half = (rows2, shown, title2) => {
63679
63730
  if (!rows2.length) return [];
63680
- const lines = [
63681
- "",
63682
- `**${title2}**`,
63683
- "",
63684
- `| ${s.severity} | ${s.where} | ${s.what} | ${s.occurrences} |`,
63685
- "| --- | --- | --- | ---: |",
63686
- ...rows2.slice(0, shown).map(
63687
- (g) => `| ${ICON5[g.severity]} ${g.severity} | \`${cell(g.where)}\` (\`${cell(g.selectorHint)}\`) | ${cell(mdText(g.message))} | ${g.occurrences} |`
63688
- )
63689
- ];
63690
- if (rows2.length > shown) lines.push("", s.pageMoreDefects(rows2.length - shown));
63691
- return lines;
63731
+ return ["", `**${title2}**`, "", ...criterionSection(rows2, s, shown, s.pageMoreCriteria)];
63692
63732
  };
63693
63733
  out2.push(...half(blocking, shownBlocking, s.blockingNc), ...half(rest, shownRest, s.nonBlockingNc));
63694
63734
  out2.push("", "</details>");
@@ -63697,7 +63737,6 @@ function pageBlock(result, page, standard, lang, baseDir) {
63697
63737
  function fullGridBlock(result, derived, standard, s, lang) {
63698
63738
  const { rows, status } = pageGridModel(result, derived, standard, lang);
63699
63739
  if (!rows.length || !derived.length) return [];
63700
- const cell = (v) => v.replace(/\|/g, "\\|");
63701
63740
  const origin = commonOrigin(derived);
63702
63741
  const originNote = pageOriginNote(origin, lang);
63703
63742
  const head = [s.criterion, ...derived.map((p) => cell(pageColumnLabel(p, origin)))];
@@ -63721,8 +63760,7 @@ function fullGridBlock(result, derived, standard, s, lang) {
63721
63760
  function orphansBlock(result, standard, s, lang, baseDir) {
63722
63761
  const orphans = unattributedFindings(result).filter((f) => !f.advisory);
63723
63762
  if (!orphans.length) return [];
63724
- const cell = (v) => v.replace(/\|/g, "\\|");
63725
- const groups = groupFindings(orphans, standard, lang, baseDir);
63763
+ const criteria = groupByCriterion(groupFindings(orphans, standard, lang, baseDir));
63726
63764
  const counts = SEV_ORDER4.map((sev) => `${ICON5[sev]} ${orphans.filter((f) => f.severity === sev).length}`).join(" \xB7 ");
63727
63765
  const out2 = [
63728
63766
  "<details>",
@@ -63731,40 +63769,26 @@ function orphansBlock(result, standard, s, lang, baseDir) {
63731
63769
  "",
63732
63770
  `> ${s.orphansNote}`,
63733
63771
  "",
63734
- `| ${s.severity} | ${s.criterion} | ${s.where} | ${s.what} | ${s.occurrences} |`,
63735
- "| --- | --- | --- | --- | ---: |"
63772
+ ...criterionSection(criteria, s, MAX_ROWS, s.moreCriteria)
63736
63773
  ];
63737
- for (const g of groups.slice(0, MAX_ROWS)) {
63738
- out2.push(
63739
- `| ${ICON5[g.severity]} ${g.severity} | ${cell(g.criterion)} | \`${cell(g.where)}\` (\`${cell(g.selectorHint)}\`) | ${cell(mdText(g.message))} | ${g.occurrences} |`
63740
- );
63741
- }
63742
- if (groups.length > MAX_ROWS) out2.push("", s.more(groups.length - MAX_ROWS));
63743
63774
  out2.push("", "</details>");
63744
63775
  return out2;
63745
63776
  }
63746
63777
  function allDefectsBlock(result, standard, s, lang, baseDir) {
63747
63778
  const all = findingsForStandard(result, standard).filter((f) => !f.advisory);
63748
63779
  if (!all.length) return [];
63749
- const cell = (v) => v.replace(/\|/g, "\\|");
63750
63780
  const groups = groupFindings(all, standard, lang, baseDir);
63781
+ const criteria = groupByCriterion(groups);
63751
63782
  const counts = SEV_ORDER4.map((sev) => `${ICON5[sev]} ${all.filter((f) => f.severity === sev).length}`).join(" \xB7 ");
63752
63783
  const out2 = [
63753
63784
  "<details>",
63754
- `<summary><b>${s.allDefects}</b> \u2014 ${groups.length} / ${all.length} \xB7 ${counts}</summary>`,
63785
+ `<summary><b>${s.allDefects}</b> \u2014 ${s.byCriterion(criteria.length, groups.length, all.length)} \xB7 ${counts}</summary>`,
63755
63786
  // GFM only renders Markdown inside <details> after a blank line.
63756
63787
  "",
63757
63788
  `> ${s.allDefectsNote}`,
63758
63789
  "",
63759
- `| ${s.severity} | ${s.criterion} | ${s.where} | ${s.what} | ${s.occurrences} |`,
63760
- "| --- | --- | --- | --- | ---: |"
63790
+ ...criterionSection(criteria, s, MAX_ROWS, s.moreCriteria)
63761
63791
  ];
63762
- for (const g of groups.slice(0, MAX_ROWS)) {
63763
- out2.push(
63764
- `| ${ICON5[g.severity]} ${g.severity} | ${cell(g.criterion)} | \`${cell(g.where)}\` (\`${cell(g.selectorHint)}\`) | ${cell(mdText(g.message))} | ${g.occurrences} |`
63765
- );
63766
- }
63767
- if (groups.length > MAX_ROWS) out2.push("", s.more(groups.length - MAX_ROWS));
63768
63792
  out2.push("", "</details>");
63769
63793
  return out2;
63770
63794
  }
@@ -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.28.0
8
+ version: 5.29.0
9
9
  ---
10
10
 
11
11
  # review-a11y — accessibility review of changed code