ultra11y 5.8.0 → 5.9.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.8.0",
13
+ "version": "5.9.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.8.0",
4
+ "version": "5.9.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.8.0",
4
+ "version": "5.9.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.8.0";
30
+ const ULTRA11Y_PLUGIN_VERSION = "5.9.1";
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.8.0",
3
+ "version": "5.9.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 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.8.0";
9
+ var VERSION = "5.9.1";
10
10
  var SCHEMA_VERSION = 2;
11
11
 
12
12
  // src/audit.ts
@@ -57504,7 +57504,7 @@ function adjudicationContract() {
57504
57504
  verdicts: [...VERDICTS],
57505
57505
  manualReasons: [...MANUAL_REASON_VALUES],
57506
57506
  requires: {
57507
- C: "a non-empty justification AND citations[] naming the harvested evidence it cleared (each anchor resolvable and drawn from this criterion's own evidence); a criterion with no harvested evidence cannot be C at all",
57507
+ C: "a non-empty justification AND citations[] naming the harvested evidence it cleared (each anchor resolvable and drawn from this criterion's own evidence, and about the same kind of element the harvest recorded there \u2014 copy the evidence's own `snippet` rather than retyping the element); a criterion with no harvested evidence cannot be C at all",
57508
57508
  NA: "a non-empty justification",
57509
57509
  NC: "at least one groundable finding, each citing a normativeRef that resolves against the active standard",
57510
57510
  manual: `a reason \u2208 {${MANUAL_REASON_VALUES.join(", ")}}`
@@ -57640,6 +57640,61 @@ function auditFiles(audit2, cwd) {
57640
57640
  return /* @__PURE__ */ new Set();
57641
57641
  }
57642
57642
  }
57643
+ function tagOf2(x) {
57644
+ const fromSnippet = /<\s*([a-zA-Z][\w-]*)/.exec(x.snippet ?? "");
57645
+ if (fromSnippet) return fromSnippet[1].toLowerCase();
57646
+ const fromSelector = /^\s*([a-zA-Z][\w-]*)/.exec(x.selector ?? "");
57647
+ return fromSelector ? fromSelector[1].toLowerCase() : void 0;
57648
+ }
57649
+ function contentTokens(markup) {
57650
+ const out2 = /* @__PURE__ */ new Set();
57651
+ const withoutTags = markup.replace(/<[^>]*>/g, " ");
57652
+ for (const m of markup.matchAll(/=\s*"([^"]*)"|=\s*'([^']*)'/g)) {
57653
+ for (const w of (m[1] ?? m[2] ?? "").split(/[\s/_-]+/)) if (w.length >= 3) out2.add(w.toLowerCase());
57654
+ }
57655
+ for (const w of withoutTags.split(/[\s/_-]+/)) if (w.length >= 3) out2.add(w.toLowerCase());
57656
+ return out2;
57657
+ }
57658
+ function recognisablySame(cite, anchor) {
57659
+ const ta = tagOf2(cite);
57660
+ const tb = tagOf2(anchor);
57661
+ if (ta !== void 0 && tb !== void 0 && ta !== tb) return false;
57662
+ if (!cite.snippet || !anchor.snippet) return true;
57663
+ const want = contentTokens(anchor.snippet);
57664
+ if (!want.size) return true;
57665
+ const got = contentTokens(cite.snippet);
57666
+ if (!got.size) return true;
57667
+ for (const w of got) if (want.has(w)) return true;
57668
+ return false;
57669
+ }
57670
+ function overlap(cite, anchor) {
57671
+ if (!cite.snippet || !anchor.snippet) return 0;
57672
+ const want = contentTokens(anchor.snippet);
57673
+ if (!want.size) return 0;
57674
+ let n = 0;
57675
+ for (const w of contentTokens(cite.snippet)) if (want.has(w)) n++;
57676
+ return n;
57677
+ }
57678
+ function anchorFor(evidence, c2, drift) {
57679
+ const reps = evidence.filter((e) => e.file === c2.file && Math.abs(e.line - c2.line) <= drift);
57680
+ const best = (cands) => {
57681
+ let top;
57682
+ let score = -1;
57683
+ for (const e of cands) {
57684
+ const n = overlap(c2, e);
57685
+ if (n > score) {
57686
+ score = n;
57687
+ top = e;
57688
+ }
57689
+ }
57690
+ return top;
57691
+ };
57692
+ const rep = best(reps);
57693
+ if (rep) return { at: rep, representative: true };
57694
+ const siblings = evidence.filter((e) => cites0(e.alsoAt ?? [], c2, drift));
57695
+ const sib = best(siblings);
57696
+ return sib ? { at: sib, representative: false } : void 0;
57697
+ }
57643
57698
  function cites0(anchors, c2, drift) {
57644
57699
  for (const a of anchors) {
57645
57700
  const at = a.lastIndexOf(":");
@@ -57739,10 +57794,11 @@ function applyAdjudication(audit2, adj, opts = {}) {
57739
57794
  let scopeCache;
57740
57795
  const { citationDrift } = adjudicationLimits(opts.cwd);
57741
57796
  const scopeFiles = () => scopeCache ??= auditFiles(audit2, opts.cwd);
57742
- const toGround = (criteriaId, g) => {
57797
+ const toGround = (criteriaId, g, fallback) => {
57798
+ const entry = { g, ...fallback ? { fallback } : {} };
57743
57799
  const list = groundInputs.get(criteriaId);
57744
- if (list) list.push(g);
57745
- else groundInputs.set(criteriaId, [g]);
57800
+ if (list) list.push(entry);
57801
+ else groundInputs.set(criteriaId, [entry]);
57746
57802
  };
57747
57803
  for (const it of adj.items) {
57748
57804
  const v = it.verdict;
@@ -57775,7 +57831,17 @@ function applyAdjudication(audit2, adj, opts = {}) {
57775
57831
  if (!cites0(anchors, c2, citationDrift) && !scopeFiles().has(c2.file)) {
57776
57832
  blame(it.criteriaId, `criterion ${it.criteriaId}: citation ${c2.file}:${c2.line} is not among this criterion's harvested evidence (fabricated?)`);
57777
57833
  }
57778
- toGround(it.criteriaId, { file: c2.file, line: c2.line, selector: c2.selector, snippet: c2.snippet });
57834
+ const anchor = anchorFor(it.evidence, c2, citationDrift);
57835
+ const cite = { file: c2.file, line: c2.line, selector: c2.selector, snippet: c2.snippet };
57836
+ if (anchor && recognisablySame(c2, anchor.at)) {
57837
+ toGround(
57838
+ it.criteriaId,
57839
+ cite,
57840
+ anchor.representative ? { file: anchor.at.file, line: anchor.at.line, selector: anchor.at.selector ?? c2.selector, snippet: anchor.at.snippet } : { file: c2.file, line: c2.line, selector: anchor.at.selector ?? c2.selector }
57841
+ );
57842
+ } else {
57843
+ toGround(it.criteriaId, cite);
57844
+ }
57779
57845
  }
57780
57846
  }
57781
57847
  } else if (v === "NC") {
@@ -57801,8 +57867,12 @@ function applyAdjudication(audit2, adj, opts = {}) {
57801
57867
  }
57802
57868
  const grounding = { grounded: 0, moved: 0, failed: 0, issues: [] };
57803
57869
  for (const [criteriaId, inputs] of groundInputs) {
57804
- for (const input of inputs) {
57805
- const r = groundFinding(input, { cwd: opts.cwd });
57870
+ for (const { g, fallback } of inputs) {
57871
+ let r = groundFinding(g, { cwd: opts.cwd });
57872
+ if (!r.ok && fallback) {
57873
+ const viaAnchor = groundFinding(fallback, { cwd: opts.cwd });
57874
+ if (viaAnchor.ok) r = { ok: true, moved: true };
57875
+ }
57806
57876
  if (r.ok) {
57807
57877
  grounding.grounded++;
57808
57878
  if (r.moved) grounding.moved++;
@@ -57941,6 +58011,7 @@ function recomputeTallies(a) {
57941
58011
  var residualScanReason = () => "Rendering criterion \u2014 decide on the rendered DOM (`scan`).";
57942
58012
  var residualUndecidableReason = () => "Left as an explicit residual risk (not decidable from the available evidence).";
57943
58013
  var residualRejectedReason = (why) => `Adjudication refused by the gate \u2014 ${why.replace(/^criterion \S+: /, "")}. Re-adjudicate this criterion.`;
58014
+ var SNIPPET_SHOWN = 200;
57944
58015
  var T2 = {
57945
58016
  fr: {
57946
58017
  title: "# Adjudication des crit\xE8res \xE0 \xE9valuer (ultra11y)",
@@ -57959,6 +58030,7 @@ var T2 = {
57959
58030
  pagesWord: "page(s)",
57960
58031
  on: "sur",
57961
58032
  alsoAt: "aussi en",
58033
+ snippetLabel: "`snippet` \xE0 copier dans la citation",
57962
58034
  incomplete: "LECTURE INCOMPL\xC8TE \u2014 un \xAB C \xBB sera refus\xE9 sur ce crit\xE8re",
57963
58035
  none: "(aucune \xE9vidence automatique \u2014 d\xE9cidez depuis la source, ou laissez `manual` avec une raison)",
57964
58036
  questions: "\xC0 v\xE9rifier manuellement",
@@ -57988,6 +58060,7 @@ var T2 = {
57988
58060
  pagesWord: "page(s)",
57989
58061
  on: "on",
57990
58062
  alsoAt: "also at",
58063
+ snippetLabel: "`snippet` to copy into the citation",
57991
58064
  incomplete: "INCOMPLETE READING \u2014 a C will be refused on this criterion",
57992
58065
  none: "(no automatic evidence \u2014 decide from source, or leave `manual` with a reason)",
57993
58066
  questions: "To verify manually",
@@ -58048,6 +58121,10 @@ function formatAdjudication(items, lang = "en", standard = CORE2, opts = {}) {
58048
58121
  e.alsoAt?.length ? `${s.alsoAt} ${e.alsoAt.slice(0, shown).join(", ")}${e.alsoAt.length > shown ? "\u2026" : ""}` : ""
58049
58122
  ].filter(Boolean).join(" ");
58050
58123
  out2.push(`- \`${e.file}:${e.line}\` (\`${e.selector}\`)${extra ? ` [${extra}]` : ""}${e.note ? ` \u2014 ${e.note}` : ""}`);
58124
+ if (e.snippet?.trim()) {
58125
+ const snip = e.snippet.trim().replace(/\s+/g, " ").slice(0, SNIPPET_SHOWN);
58126
+ out2.push(` - ${s.snippetLabel} : \`${snip}${e.snippet.trim().length > SNIPPET_SHOWN ? "\u2026" : ""}\``);
58127
+ }
58051
58128
  }
58052
58129
  out2.push("");
58053
58130
  }
@@ -61319,6 +61396,8 @@ var S = {
61319
61396
  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.",
61320
61397
  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._`,
61321
61398
  scoreboardClamped: (n) => `_${n} page(s) retir\xE9e(s) du tableau pour tenir dans la limite de GitHub \u2014 l'artefact les porte toutes._`,
61399
+ pageDefects: "D\xE9fauts",
61400
+ pageMoreDefects: (n) => `_\u2026 et ${n} autre(s) d\xE9faut(s) distinct(s) sur cette page \u2014 voir la fiche de page dans l'artefact._`,
61322
61401
  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.`
61323
61402
  },
61324
61403
  en: {
@@ -61364,6 +61443,8 @@ var S = {
61364
61443
  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.",
61365
61444
  pagesClamped: (n) => `_The detail of ${n} page(s) was dropped from this comment to fit GitHub's limit \u2014 the artifact carries them all._`,
61366
61445
  scoreboardClamped: (n) => `_${n} page(s) dropped from the table to fit GitHub's limit \u2014 the artifact carries them all._`,
61446
+ pageDefects: "Defects",
61447
+ pageMoreDefects: (n) => `_\u2026 and ${n} more distinct defect(s) on this page \u2014 see its sheet in the artifact._`,
61367
61448
  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.`
61368
61449
  }
61369
61450
  };
@@ -61508,7 +61589,8 @@ function basisCaveats(result, derived, s, lang) {
61508
61589
  if (notAudited && derived.some((p) => p.basis === "not-audited")) out2.push(`> ${notAudited}`, "");
61509
61590
  return out2;
61510
61591
  }
61511
- function pageBlock(result, page, standard, lang) {
61592
+ var PAGE_DEFECTS_SHOWN = 6;
61593
+ function pageBlock(result, page, standard, lang, baseDir) {
61512
61594
  const s = S[lang];
61513
61595
  const rows = pageCriterionRows(result, page, standard, lang);
61514
61596
  const nc = rows.filter((r) => r.status === "NC");
@@ -61534,6 +61616,19 @@ function pageBlock(result, page, standard, lang) {
61534
61616
  for (const r of nc) {
61535
61617
  out2.push(withTests ? `| ${cell(r.label)} | ${r.tests.map((t3) => `\`${t3}\``).join(" ")} |` : `| ${cell(r.label)} |`);
61536
61618
  }
61619
+ const defects = groupFindings(
61620
+ page.findings.filter((f) => !f.advisory),
61621
+ standard,
61622
+ lang,
61623
+ baseDir
61624
+ );
61625
+ if (defects.length) {
61626
+ out2.push("", `| ${s.severity} | ${s.where} | ${s.what} | ${s.occurrences} |`, "| --- | --- | --- | ---: |");
61627
+ for (const g of defects.slice(0, PAGE_DEFECTS_SHOWN)) {
61628
+ out2.push(`| ${ICON5[g.severity]} ${g.severity} | \`${cell(g.where)}\` (\`${cell(g.selectorHint)}\`) | ${cell(g.message)} | ${g.occurrences} |`);
61629
+ }
61630
+ if (defects.length > PAGE_DEFECTS_SHOWN) out2.push("", s.pageMoreDefects(defects.length - PAGE_DEFECTS_SHOWN));
61631
+ }
61537
61632
  out2.push("", "</details>");
61538
61633
  return out2.join("\n");
61539
61634
  }
@@ -61541,6 +61636,7 @@ function pagesComment(result, opts = {}) {
61541
61636
  const standard = opts.standard ?? CORE2;
61542
61637
  const lang = opts.lang ?? "en";
61543
61638
  const s = S[lang];
61639
+ const baseDir = opts.baseDir ?? process.cwd();
61544
61640
  const stdLabel = isCore(standard) ? "WCAG 2.2 AA" : loadPack(standard).name;
61545
61641
  const redirected = result.scope.redirected ?? [];
61546
61642
  const scope = pagesOf(result);
@@ -61563,7 +61659,7 @@ function pagesComment(result, opts = {}) {
61563
61659
  if (rate.agentRuled) head.push(`> ${agentMarkNote(lang)}`, "");
61564
61660
  const blocks = [...derived].sort(
61565
61661
  (a, b) => severityCount(b, "bloquant") - severityCount(a, "bloquant") || severityCount(b, "majeur") - severityCount(a, "majeur") || severityCount(b, "mineur") - severityCount(a, "mineur")
61566
- ).map((p) => pageBlock(result, p, standard, lang)).filter((b) => b !== void 0);
61662
+ ).map((p) => pageBlock(result, p, standard, lang, baseDir)).filter((b) => b !== void 0);
61567
61663
  const assemble = (nBlocks2, nRows2) => {
61568
61664
  const body3 = [...scoreboardTable(result, derived.slice(0, nRows2), standard, s, lang), ""];
61569
61665
  if (nRows2 < derived.length) body3.push(s.scoreboardClamped(derived.length - nRows2), "");
@@ -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.8.0
8
+ version: 5.9.1
9
9
  ---
10
10
 
11
11
  # review-a11y — accessibility review of changed code
@@ -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.8.0";
9
+ var VERSION = "5.9.1";
10
10
  var SCHEMA_VERSION = 2;
11
11
 
12
12
  // src/audit.ts
@@ -57504,7 +57504,7 @@ function adjudicationContract() {
57504
57504
  verdicts: [...VERDICTS],
57505
57505
  manualReasons: [...MANUAL_REASON_VALUES],
57506
57506
  requires: {
57507
- C: "a non-empty justification AND citations[] naming the harvested evidence it cleared (each anchor resolvable and drawn from this criterion's own evidence); a criterion with no harvested evidence cannot be C at all",
57507
+ C: "a non-empty justification AND citations[] naming the harvested evidence it cleared (each anchor resolvable and drawn from this criterion's own evidence, and about the same kind of element the harvest recorded there \u2014 copy the evidence's own `snippet` rather than retyping the element); a criterion with no harvested evidence cannot be C at all",
57508
57508
  NA: "a non-empty justification",
57509
57509
  NC: "at least one groundable finding, each citing a normativeRef that resolves against the active standard",
57510
57510
  manual: `a reason \u2208 {${MANUAL_REASON_VALUES.join(", ")}}`
@@ -57640,6 +57640,61 @@ function auditFiles(audit2, cwd) {
57640
57640
  return /* @__PURE__ */ new Set();
57641
57641
  }
57642
57642
  }
57643
+ function tagOf2(x) {
57644
+ const fromSnippet = /<\s*([a-zA-Z][\w-]*)/.exec(x.snippet ?? "");
57645
+ if (fromSnippet) return fromSnippet[1].toLowerCase();
57646
+ const fromSelector = /^\s*([a-zA-Z][\w-]*)/.exec(x.selector ?? "");
57647
+ return fromSelector ? fromSelector[1].toLowerCase() : void 0;
57648
+ }
57649
+ function contentTokens(markup) {
57650
+ const out2 = /* @__PURE__ */ new Set();
57651
+ const withoutTags = markup.replace(/<[^>]*>/g, " ");
57652
+ for (const m of markup.matchAll(/=\s*"([^"]*)"|=\s*'([^']*)'/g)) {
57653
+ for (const w of (m[1] ?? m[2] ?? "").split(/[\s/_-]+/)) if (w.length >= 3) out2.add(w.toLowerCase());
57654
+ }
57655
+ for (const w of withoutTags.split(/[\s/_-]+/)) if (w.length >= 3) out2.add(w.toLowerCase());
57656
+ return out2;
57657
+ }
57658
+ function recognisablySame(cite, anchor) {
57659
+ const ta = tagOf2(cite);
57660
+ const tb = tagOf2(anchor);
57661
+ if (ta !== void 0 && tb !== void 0 && ta !== tb) return false;
57662
+ if (!cite.snippet || !anchor.snippet) return true;
57663
+ const want = contentTokens(anchor.snippet);
57664
+ if (!want.size) return true;
57665
+ const got = contentTokens(cite.snippet);
57666
+ if (!got.size) return true;
57667
+ for (const w of got) if (want.has(w)) return true;
57668
+ return false;
57669
+ }
57670
+ function overlap(cite, anchor) {
57671
+ if (!cite.snippet || !anchor.snippet) return 0;
57672
+ const want = contentTokens(anchor.snippet);
57673
+ if (!want.size) return 0;
57674
+ let n = 0;
57675
+ for (const w of contentTokens(cite.snippet)) if (want.has(w)) n++;
57676
+ return n;
57677
+ }
57678
+ function anchorFor(evidence, c2, drift) {
57679
+ const reps = evidence.filter((e) => e.file === c2.file && Math.abs(e.line - c2.line) <= drift);
57680
+ const best = (cands) => {
57681
+ let top;
57682
+ let score = -1;
57683
+ for (const e of cands) {
57684
+ const n = overlap(c2, e);
57685
+ if (n > score) {
57686
+ score = n;
57687
+ top = e;
57688
+ }
57689
+ }
57690
+ return top;
57691
+ };
57692
+ const rep = best(reps);
57693
+ if (rep) return { at: rep, representative: true };
57694
+ const siblings = evidence.filter((e) => cites0(e.alsoAt ?? [], c2, drift));
57695
+ const sib = best(siblings);
57696
+ return sib ? { at: sib, representative: false } : void 0;
57697
+ }
57643
57698
  function cites0(anchors, c2, drift) {
57644
57699
  for (const a of anchors) {
57645
57700
  const at = a.lastIndexOf(":");
@@ -57739,10 +57794,11 @@ function applyAdjudication(audit2, adj, opts = {}) {
57739
57794
  let scopeCache;
57740
57795
  const { citationDrift } = adjudicationLimits(opts.cwd);
57741
57796
  const scopeFiles = () => scopeCache ??= auditFiles(audit2, opts.cwd);
57742
- const toGround = (criteriaId, g) => {
57797
+ const toGround = (criteriaId, g, fallback) => {
57798
+ const entry = { g, ...fallback ? { fallback } : {} };
57743
57799
  const list = groundInputs.get(criteriaId);
57744
- if (list) list.push(g);
57745
- else groundInputs.set(criteriaId, [g]);
57800
+ if (list) list.push(entry);
57801
+ else groundInputs.set(criteriaId, [entry]);
57746
57802
  };
57747
57803
  for (const it of adj.items) {
57748
57804
  const v = it.verdict;
@@ -57775,7 +57831,17 @@ function applyAdjudication(audit2, adj, opts = {}) {
57775
57831
  if (!cites0(anchors, c2, citationDrift) && !scopeFiles().has(c2.file)) {
57776
57832
  blame(it.criteriaId, `criterion ${it.criteriaId}: citation ${c2.file}:${c2.line} is not among this criterion's harvested evidence (fabricated?)`);
57777
57833
  }
57778
- toGround(it.criteriaId, { file: c2.file, line: c2.line, selector: c2.selector, snippet: c2.snippet });
57834
+ const anchor = anchorFor(it.evidence, c2, citationDrift);
57835
+ const cite = { file: c2.file, line: c2.line, selector: c2.selector, snippet: c2.snippet };
57836
+ if (anchor && recognisablySame(c2, anchor.at)) {
57837
+ toGround(
57838
+ it.criteriaId,
57839
+ cite,
57840
+ anchor.representative ? { file: anchor.at.file, line: anchor.at.line, selector: anchor.at.selector ?? c2.selector, snippet: anchor.at.snippet } : { file: c2.file, line: c2.line, selector: anchor.at.selector ?? c2.selector }
57841
+ );
57842
+ } else {
57843
+ toGround(it.criteriaId, cite);
57844
+ }
57779
57845
  }
57780
57846
  }
57781
57847
  } else if (v === "NC") {
@@ -57801,8 +57867,12 @@ function applyAdjudication(audit2, adj, opts = {}) {
57801
57867
  }
57802
57868
  const grounding = { grounded: 0, moved: 0, failed: 0, issues: [] };
57803
57869
  for (const [criteriaId, inputs] of groundInputs) {
57804
- for (const input of inputs) {
57805
- const r = groundFinding(input, { cwd: opts.cwd });
57870
+ for (const { g, fallback } of inputs) {
57871
+ let r = groundFinding(g, { cwd: opts.cwd });
57872
+ if (!r.ok && fallback) {
57873
+ const viaAnchor = groundFinding(fallback, { cwd: opts.cwd });
57874
+ if (viaAnchor.ok) r = { ok: true, moved: true };
57875
+ }
57806
57876
  if (r.ok) {
57807
57877
  grounding.grounded++;
57808
57878
  if (r.moved) grounding.moved++;
@@ -57941,6 +58011,7 @@ function recomputeTallies(a) {
57941
58011
  var residualScanReason = () => "Rendering criterion \u2014 decide on the rendered DOM (`scan`).";
57942
58012
  var residualUndecidableReason = () => "Left as an explicit residual risk (not decidable from the available evidence).";
57943
58013
  var residualRejectedReason = (why) => `Adjudication refused by the gate \u2014 ${why.replace(/^criterion \S+: /, "")}. Re-adjudicate this criterion.`;
58014
+ var SNIPPET_SHOWN = 200;
57944
58015
  var T2 = {
57945
58016
  fr: {
57946
58017
  title: "# Adjudication des crit\xE8res \xE0 \xE9valuer (ultra11y)",
@@ -57959,6 +58030,7 @@ var T2 = {
57959
58030
  pagesWord: "page(s)",
57960
58031
  on: "sur",
57961
58032
  alsoAt: "aussi en",
58033
+ snippetLabel: "`snippet` \xE0 copier dans la citation",
57962
58034
  incomplete: "LECTURE INCOMPL\xC8TE \u2014 un \xAB C \xBB sera refus\xE9 sur ce crit\xE8re",
57963
58035
  none: "(aucune \xE9vidence automatique \u2014 d\xE9cidez depuis la source, ou laissez `manual` avec une raison)",
57964
58036
  questions: "\xC0 v\xE9rifier manuellement",
@@ -57988,6 +58060,7 @@ var T2 = {
57988
58060
  pagesWord: "page(s)",
57989
58061
  on: "on",
57990
58062
  alsoAt: "also at",
58063
+ snippetLabel: "`snippet` to copy into the citation",
57991
58064
  incomplete: "INCOMPLETE READING \u2014 a C will be refused on this criterion",
57992
58065
  none: "(no automatic evidence \u2014 decide from source, or leave `manual` with a reason)",
57993
58066
  questions: "To verify manually",
@@ -58048,6 +58121,10 @@ function formatAdjudication(items, lang = "en", standard = CORE2, opts = {}) {
58048
58121
  e.alsoAt?.length ? `${s.alsoAt} ${e.alsoAt.slice(0, shown).join(", ")}${e.alsoAt.length > shown ? "\u2026" : ""}` : ""
58049
58122
  ].filter(Boolean).join(" ");
58050
58123
  out2.push(`- \`${e.file}:${e.line}\` (\`${e.selector}\`)${extra ? ` [${extra}]` : ""}${e.note ? ` \u2014 ${e.note}` : ""}`);
58124
+ if (e.snippet?.trim()) {
58125
+ const snip = e.snippet.trim().replace(/\s+/g, " ").slice(0, SNIPPET_SHOWN);
58126
+ out2.push(` - ${s.snippetLabel} : \`${snip}${e.snippet.trim().length > SNIPPET_SHOWN ? "\u2026" : ""}\``);
58127
+ }
58051
58128
  }
58052
58129
  out2.push("");
58053
58130
  }
@@ -61319,6 +61396,8 @@ var S = {
61319
61396
  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.",
61320
61397
  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._`,
61321
61398
  scoreboardClamped: (n) => `_${n} page(s) retir\xE9e(s) du tableau pour tenir dans la limite de GitHub \u2014 l'artefact les porte toutes._`,
61399
+ pageDefects: "D\xE9fauts",
61400
+ pageMoreDefects: (n) => `_\u2026 et ${n} autre(s) d\xE9faut(s) distinct(s) sur cette page \u2014 voir la fiche de page dans l'artefact._`,
61322
61401
  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.`
61323
61402
  },
61324
61403
  en: {
@@ -61364,6 +61443,8 @@ var S = {
61364
61443
  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.",
61365
61444
  pagesClamped: (n) => `_The detail of ${n} page(s) was dropped from this comment to fit GitHub's limit \u2014 the artifact carries them all._`,
61366
61445
  scoreboardClamped: (n) => `_${n} page(s) dropped from the table to fit GitHub's limit \u2014 the artifact carries them all._`,
61446
+ pageDefects: "Defects",
61447
+ pageMoreDefects: (n) => `_\u2026 and ${n} more distinct defect(s) on this page \u2014 see its sheet in the artifact._`,
61367
61448
  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.`
61368
61449
  }
61369
61450
  };
@@ -61508,7 +61589,8 @@ function basisCaveats(result, derived, s, lang) {
61508
61589
  if (notAudited && derived.some((p) => p.basis === "not-audited")) out2.push(`> ${notAudited}`, "");
61509
61590
  return out2;
61510
61591
  }
61511
- function pageBlock(result, page, standard, lang) {
61592
+ var PAGE_DEFECTS_SHOWN = 6;
61593
+ function pageBlock(result, page, standard, lang, baseDir) {
61512
61594
  const s = S[lang];
61513
61595
  const rows = pageCriterionRows(result, page, standard, lang);
61514
61596
  const nc = rows.filter((r) => r.status === "NC");
@@ -61534,6 +61616,19 @@ function pageBlock(result, page, standard, lang) {
61534
61616
  for (const r of nc) {
61535
61617
  out2.push(withTests ? `| ${cell(r.label)} | ${r.tests.map((t3) => `\`${t3}\``).join(" ")} |` : `| ${cell(r.label)} |`);
61536
61618
  }
61619
+ const defects = groupFindings(
61620
+ page.findings.filter((f) => !f.advisory),
61621
+ standard,
61622
+ lang,
61623
+ baseDir
61624
+ );
61625
+ if (defects.length) {
61626
+ out2.push("", `| ${s.severity} | ${s.where} | ${s.what} | ${s.occurrences} |`, "| --- | --- | --- | ---: |");
61627
+ for (const g of defects.slice(0, PAGE_DEFECTS_SHOWN)) {
61628
+ out2.push(`| ${ICON5[g.severity]} ${g.severity} | \`${cell(g.where)}\` (\`${cell(g.selectorHint)}\`) | ${cell(g.message)} | ${g.occurrences} |`);
61629
+ }
61630
+ if (defects.length > PAGE_DEFECTS_SHOWN) out2.push("", s.pageMoreDefects(defects.length - PAGE_DEFECTS_SHOWN));
61631
+ }
61537
61632
  out2.push("", "</details>");
61538
61633
  return out2.join("\n");
61539
61634
  }
@@ -61541,6 +61636,7 @@ function pagesComment(result, opts = {}) {
61541
61636
  const standard = opts.standard ?? CORE2;
61542
61637
  const lang = opts.lang ?? "en";
61543
61638
  const s = S[lang];
61639
+ const baseDir = opts.baseDir ?? process.cwd();
61544
61640
  const stdLabel = isCore(standard) ? "WCAG 2.2 AA" : loadPack(standard).name;
61545
61641
  const redirected = result.scope.redirected ?? [];
61546
61642
  const scope = pagesOf(result);
@@ -61563,7 +61659,7 @@ function pagesComment(result, opts = {}) {
61563
61659
  if (rate.agentRuled) head.push(`> ${agentMarkNote(lang)}`, "");
61564
61660
  const blocks = [...derived].sort(
61565
61661
  (a, b) => severityCount(b, "bloquant") - severityCount(a, "bloquant") || severityCount(b, "majeur") - severityCount(a, "majeur") || severityCount(b, "mineur") - severityCount(a, "mineur")
61566
- ).map((p) => pageBlock(result, p, standard, lang)).filter((b) => b !== void 0);
61662
+ ).map((p) => pageBlock(result, p, standard, lang, baseDir)).filter((b) => b !== void 0);
61567
61663
  const assemble = (nBlocks2, nRows2) => {
61568
61664
  const body3 = [...scoreboardTable(result, derived.slice(0, nRows2), standard, s, lang), ""];
61569
61665
  if (nRows2 < derived.length) body3.push(s.scoreboardClamped(derived.length - nRows2), "");
@@ -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.8.0
7
+ version: 5.9.1
8
8
  ---
9
9
 
10
10
  # ultra11y — audit WCAG 2.2 AA and write accessible markup
@@ -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.8.0";
9
+ var VERSION = "5.9.1";
10
10
  var SCHEMA_VERSION = 2;
11
11
 
12
12
  // src/audit.ts
@@ -57504,7 +57504,7 @@ function adjudicationContract() {
57504
57504
  verdicts: [...VERDICTS],
57505
57505
  manualReasons: [...MANUAL_REASON_VALUES],
57506
57506
  requires: {
57507
- C: "a non-empty justification AND citations[] naming the harvested evidence it cleared (each anchor resolvable and drawn from this criterion's own evidence); a criterion with no harvested evidence cannot be C at all",
57507
+ C: "a non-empty justification AND citations[] naming the harvested evidence it cleared (each anchor resolvable and drawn from this criterion's own evidence, and about the same kind of element the harvest recorded there \u2014 copy the evidence's own `snippet` rather than retyping the element); a criterion with no harvested evidence cannot be C at all",
57508
57508
  NA: "a non-empty justification",
57509
57509
  NC: "at least one groundable finding, each citing a normativeRef that resolves against the active standard",
57510
57510
  manual: `a reason \u2208 {${MANUAL_REASON_VALUES.join(", ")}}`
@@ -57640,6 +57640,61 @@ function auditFiles(audit2, cwd) {
57640
57640
  return /* @__PURE__ */ new Set();
57641
57641
  }
57642
57642
  }
57643
+ function tagOf2(x) {
57644
+ const fromSnippet = /<\s*([a-zA-Z][\w-]*)/.exec(x.snippet ?? "");
57645
+ if (fromSnippet) return fromSnippet[1].toLowerCase();
57646
+ const fromSelector = /^\s*([a-zA-Z][\w-]*)/.exec(x.selector ?? "");
57647
+ return fromSelector ? fromSelector[1].toLowerCase() : void 0;
57648
+ }
57649
+ function contentTokens(markup) {
57650
+ const out2 = /* @__PURE__ */ new Set();
57651
+ const withoutTags = markup.replace(/<[^>]*>/g, " ");
57652
+ for (const m of markup.matchAll(/=\s*"([^"]*)"|=\s*'([^']*)'/g)) {
57653
+ for (const w of (m[1] ?? m[2] ?? "").split(/[\s/_-]+/)) if (w.length >= 3) out2.add(w.toLowerCase());
57654
+ }
57655
+ for (const w of withoutTags.split(/[\s/_-]+/)) if (w.length >= 3) out2.add(w.toLowerCase());
57656
+ return out2;
57657
+ }
57658
+ function recognisablySame(cite, anchor) {
57659
+ const ta = tagOf2(cite);
57660
+ const tb = tagOf2(anchor);
57661
+ if (ta !== void 0 && tb !== void 0 && ta !== tb) return false;
57662
+ if (!cite.snippet || !anchor.snippet) return true;
57663
+ const want = contentTokens(anchor.snippet);
57664
+ if (!want.size) return true;
57665
+ const got = contentTokens(cite.snippet);
57666
+ if (!got.size) return true;
57667
+ for (const w of got) if (want.has(w)) return true;
57668
+ return false;
57669
+ }
57670
+ function overlap(cite, anchor) {
57671
+ if (!cite.snippet || !anchor.snippet) return 0;
57672
+ const want = contentTokens(anchor.snippet);
57673
+ if (!want.size) return 0;
57674
+ let n = 0;
57675
+ for (const w of contentTokens(cite.snippet)) if (want.has(w)) n++;
57676
+ return n;
57677
+ }
57678
+ function anchorFor(evidence, c2, drift) {
57679
+ const reps = evidence.filter((e) => e.file === c2.file && Math.abs(e.line - c2.line) <= drift);
57680
+ const best = (cands) => {
57681
+ let top;
57682
+ let score = -1;
57683
+ for (const e of cands) {
57684
+ const n = overlap(c2, e);
57685
+ if (n > score) {
57686
+ score = n;
57687
+ top = e;
57688
+ }
57689
+ }
57690
+ return top;
57691
+ };
57692
+ const rep = best(reps);
57693
+ if (rep) return { at: rep, representative: true };
57694
+ const siblings = evidence.filter((e) => cites0(e.alsoAt ?? [], c2, drift));
57695
+ const sib = best(siblings);
57696
+ return sib ? { at: sib, representative: false } : void 0;
57697
+ }
57643
57698
  function cites0(anchors, c2, drift) {
57644
57699
  for (const a of anchors) {
57645
57700
  const at = a.lastIndexOf(":");
@@ -57739,10 +57794,11 @@ function applyAdjudication(audit2, adj, opts = {}) {
57739
57794
  let scopeCache;
57740
57795
  const { citationDrift } = adjudicationLimits(opts.cwd);
57741
57796
  const scopeFiles = () => scopeCache ??= auditFiles(audit2, opts.cwd);
57742
- const toGround = (criteriaId, g) => {
57797
+ const toGround = (criteriaId, g, fallback) => {
57798
+ const entry = { g, ...fallback ? { fallback } : {} };
57743
57799
  const list = groundInputs.get(criteriaId);
57744
- if (list) list.push(g);
57745
- else groundInputs.set(criteriaId, [g]);
57800
+ if (list) list.push(entry);
57801
+ else groundInputs.set(criteriaId, [entry]);
57746
57802
  };
57747
57803
  for (const it of adj.items) {
57748
57804
  const v = it.verdict;
@@ -57775,7 +57831,17 @@ function applyAdjudication(audit2, adj, opts = {}) {
57775
57831
  if (!cites0(anchors, c2, citationDrift) && !scopeFiles().has(c2.file)) {
57776
57832
  blame(it.criteriaId, `criterion ${it.criteriaId}: citation ${c2.file}:${c2.line} is not among this criterion's harvested evidence (fabricated?)`);
57777
57833
  }
57778
- toGround(it.criteriaId, { file: c2.file, line: c2.line, selector: c2.selector, snippet: c2.snippet });
57834
+ const anchor = anchorFor(it.evidence, c2, citationDrift);
57835
+ const cite = { file: c2.file, line: c2.line, selector: c2.selector, snippet: c2.snippet };
57836
+ if (anchor && recognisablySame(c2, anchor.at)) {
57837
+ toGround(
57838
+ it.criteriaId,
57839
+ cite,
57840
+ anchor.representative ? { file: anchor.at.file, line: anchor.at.line, selector: anchor.at.selector ?? c2.selector, snippet: anchor.at.snippet } : { file: c2.file, line: c2.line, selector: anchor.at.selector ?? c2.selector }
57841
+ );
57842
+ } else {
57843
+ toGround(it.criteriaId, cite);
57844
+ }
57779
57845
  }
57780
57846
  }
57781
57847
  } else if (v === "NC") {
@@ -57801,8 +57867,12 @@ function applyAdjudication(audit2, adj, opts = {}) {
57801
57867
  }
57802
57868
  const grounding = { grounded: 0, moved: 0, failed: 0, issues: [] };
57803
57869
  for (const [criteriaId, inputs] of groundInputs) {
57804
- for (const input of inputs) {
57805
- const r = groundFinding(input, { cwd: opts.cwd });
57870
+ for (const { g, fallback } of inputs) {
57871
+ let r = groundFinding(g, { cwd: opts.cwd });
57872
+ if (!r.ok && fallback) {
57873
+ const viaAnchor = groundFinding(fallback, { cwd: opts.cwd });
57874
+ if (viaAnchor.ok) r = { ok: true, moved: true };
57875
+ }
57806
57876
  if (r.ok) {
57807
57877
  grounding.grounded++;
57808
57878
  if (r.moved) grounding.moved++;
@@ -57941,6 +58011,7 @@ function recomputeTallies(a) {
57941
58011
  var residualScanReason = () => "Rendering criterion \u2014 decide on the rendered DOM (`scan`).";
57942
58012
  var residualUndecidableReason = () => "Left as an explicit residual risk (not decidable from the available evidence).";
57943
58013
  var residualRejectedReason = (why) => `Adjudication refused by the gate \u2014 ${why.replace(/^criterion \S+: /, "")}. Re-adjudicate this criterion.`;
58014
+ var SNIPPET_SHOWN = 200;
57944
58015
  var T2 = {
57945
58016
  fr: {
57946
58017
  title: "# Adjudication des crit\xE8res \xE0 \xE9valuer (ultra11y)",
@@ -57959,6 +58030,7 @@ var T2 = {
57959
58030
  pagesWord: "page(s)",
57960
58031
  on: "sur",
57961
58032
  alsoAt: "aussi en",
58033
+ snippetLabel: "`snippet` \xE0 copier dans la citation",
57962
58034
  incomplete: "LECTURE INCOMPL\xC8TE \u2014 un \xAB C \xBB sera refus\xE9 sur ce crit\xE8re",
57963
58035
  none: "(aucune \xE9vidence automatique \u2014 d\xE9cidez depuis la source, ou laissez `manual` avec une raison)",
57964
58036
  questions: "\xC0 v\xE9rifier manuellement",
@@ -57988,6 +58060,7 @@ var T2 = {
57988
58060
  pagesWord: "page(s)",
57989
58061
  on: "on",
57990
58062
  alsoAt: "also at",
58063
+ snippetLabel: "`snippet` to copy into the citation",
57991
58064
  incomplete: "INCOMPLETE READING \u2014 a C will be refused on this criterion",
57992
58065
  none: "(no automatic evidence \u2014 decide from source, or leave `manual` with a reason)",
57993
58066
  questions: "To verify manually",
@@ -58048,6 +58121,10 @@ function formatAdjudication(items, lang = "en", standard = CORE2, opts = {}) {
58048
58121
  e.alsoAt?.length ? `${s.alsoAt} ${e.alsoAt.slice(0, shown).join(", ")}${e.alsoAt.length > shown ? "\u2026" : ""}` : ""
58049
58122
  ].filter(Boolean).join(" ");
58050
58123
  out2.push(`- \`${e.file}:${e.line}\` (\`${e.selector}\`)${extra ? ` [${extra}]` : ""}${e.note ? ` \u2014 ${e.note}` : ""}`);
58124
+ if (e.snippet?.trim()) {
58125
+ const snip = e.snippet.trim().replace(/\s+/g, " ").slice(0, SNIPPET_SHOWN);
58126
+ out2.push(` - ${s.snippetLabel} : \`${snip}${e.snippet.trim().length > SNIPPET_SHOWN ? "\u2026" : ""}\``);
58127
+ }
58051
58128
  }
58052
58129
  out2.push("");
58053
58130
  }
@@ -61319,6 +61396,8 @@ var S = {
61319
61396
  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.",
61320
61397
  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._`,
61321
61398
  scoreboardClamped: (n) => `_${n} page(s) retir\xE9e(s) du tableau pour tenir dans la limite de GitHub \u2014 l'artefact les porte toutes._`,
61399
+ pageDefects: "D\xE9fauts",
61400
+ pageMoreDefects: (n) => `_\u2026 et ${n} autre(s) d\xE9faut(s) distinct(s) sur cette page \u2014 voir la fiche de page dans l'artefact._`,
61322
61401
  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.`
61323
61402
  },
61324
61403
  en: {
@@ -61364,6 +61443,8 @@ var S = {
61364
61443
  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.",
61365
61444
  pagesClamped: (n) => `_The detail of ${n} page(s) was dropped from this comment to fit GitHub's limit \u2014 the artifact carries them all._`,
61366
61445
  scoreboardClamped: (n) => `_${n} page(s) dropped from the table to fit GitHub's limit \u2014 the artifact carries them all._`,
61446
+ pageDefects: "Defects",
61447
+ pageMoreDefects: (n) => `_\u2026 and ${n} more distinct defect(s) on this page \u2014 see its sheet in the artifact._`,
61367
61448
  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.`
61368
61449
  }
61369
61450
  };
@@ -61508,7 +61589,8 @@ function basisCaveats(result, derived, s, lang) {
61508
61589
  if (notAudited && derived.some((p) => p.basis === "not-audited")) out2.push(`> ${notAudited}`, "");
61509
61590
  return out2;
61510
61591
  }
61511
- function pageBlock(result, page, standard, lang) {
61592
+ var PAGE_DEFECTS_SHOWN = 6;
61593
+ function pageBlock(result, page, standard, lang, baseDir) {
61512
61594
  const s = S[lang];
61513
61595
  const rows = pageCriterionRows(result, page, standard, lang);
61514
61596
  const nc = rows.filter((r) => r.status === "NC");
@@ -61534,6 +61616,19 @@ function pageBlock(result, page, standard, lang) {
61534
61616
  for (const r of nc) {
61535
61617
  out2.push(withTests ? `| ${cell(r.label)} | ${r.tests.map((t3) => `\`${t3}\``).join(" ")} |` : `| ${cell(r.label)} |`);
61536
61618
  }
61619
+ const defects = groupFindings(
61620
+ page.findings.filter((f) => !f.advisory),
61621
+ standard,
61622
+ lang,
61623
+ baseDir
61624
+ );
61625
+ if (defects.length) {
61626
+ out2.push("", `| ${s.severity} | ${s.where} | ${s.what} | ${s.occurrences} |`, "| --- | --- | --- | ---: |");
61627
+ for (const g of defects.slice(0, PAGE_DEFECTS_SHOWN)) {
61628
+ out2.push(`| ${ICON5[g.severity]} ${g.severity} | \`${cell(g.where)}\` (\`${cell(g.selectorHint)}\`) | ${cell(g.message)} | ${g.occurrences} |`);
61629
+ }
61630
+ if (defects.length > PAGE_DEFECTS_SHOWN) out2.push("", s.pageMoreDefects(defects.length - PAGE_DEFECTS_SHOWN));
61631
+ }
61537
61632
  out2.push("", "</details>");
61538
61633
  return out2.join("\n");
61539
61634
  }
@@ -61541,6 +61636,7 @@ function pagesComment(result, opts = {}) {
61541
61636
  const standard = opts.standard ?? CORE2;
61542
61637
  const lang = opts.lang ?? "en";
61543
61638
  const s = S[lang];
61639
+ const baseDir = opts.baseDir ?? process.cwd();
61544
61640
  const stdLabel = isCore(standard) ? "WCAG 2.2 AA" : loadPack(standard).name;
61545
61641
  const redirected = result.scope.redirected ?? [];
61546
61642
  const scope = pagesOf(result);
@@ -61563,7 +61659,7 @@ function pagesComment(result, opts = {}) {
61563
61659
  if (rate.agentRuled) head.push(`> ${agentMarkNote(lang)}`, "");
61564
61660
  const blocks = [...derived].sort(
61565
61661
  (a, b) => severityCount(b, "bloquant") - severityCount(a, "bloquant") || severityCount(b, "majeur") - severityCount(a, "majeur") || severityCount(b, "mineur") - severityCount(a, "mineur")
61566
- ).map((p) => pageBlock(result, p, standard, lang)).filter((b) => b !== void 0);
61662
+ ).map((p) => pageBlock(result, p, standard, lang, baseDir)).filter((b) => b !== void 0);
61567
61663
  const assemble = (nBlocks2, nRows2) => {
61568
61664
  const body3 = [...scoreboardTable(result, derived.slice(0, nRows2), standard, s, lang), ""];
61569
61665
  if (nRows2 < derived.length) body3.push(s.scoreboardClamped(derived.length - nRows2), "");
package/src/adjudicate.ts CHANGED
@@ -183,7 +183,7 @@ export function adjudicationContract(): AdjudicationContract {
183
183
  verdicts: [...VERDICTS],
184
184
  manualReasons: [...MANUAL_REASON_VALUES],
185
185
  requires: {
186
- C: "a non-empty justification AND citations[] naming the harvested evidence it cleared (each anchor resolvable and drawn from this criterion's own evidence); a criterion with no harvested evidence cannot be C at all",
186
+ C: "a non-empty justification AND citations[] naming the harvested evidence it cleared (each anchor resolvable and drawn from this criterion's own evidence, and about the same kind of element the harvest recorded there — copy the evidence's own `snippet` rather than retyping the element); a criterion with no harvested evidence cannot be C at all",
187
187
  NA: "a non-empty justification",
188
188
  NC: "at least one groundable finding, each citing a normativeRef that resolves against the active standard",
189
189
  manual: `a reason ∈ {${MANUAL_REASON_VALUES.join(", ")}}`,
@@ -428,6 +428,109 @@ function auditFiles(audit: AuditResult, cwd?: string): Set<string> {
428
428
  * criterion was never given. And it is only half the gate — `groundFinding` still has to find
429
429
  * the cited content in the real source, so a plausible-looking file:line in the right
430
430
  * neighbourhood proves nothing on its own. */
431
+ /** The tag a citation or an anchor is about, from its snippet first and its selector second.
432
+ * Lowercased — HTML tag names are case-insensitive. Undefined when neither says. */
433
+ function tagOf(x: { snippet?: string; selector?: string }): string | undefined {
434
+ const fromSnippet = /<\s*([a-zA-Z][\w-]*)/.exec(x.snippet ?? "");
435
+ if (fromSnippet) return fromSnippet[1]!.toLowerCase();
436
+ const fromSelector = /^\s*([a-zA-Z][\w-]*)/.exec(x.selector ?? "");
437
+ return fromSelector ? fromSelector[1]!.toLowerCase() : undefined;
438
+ }
439
+
440
+ /** The distinctive words of a markup fragment: attribute VALUES and text, minus the noise.
441
+ *
442
+ * Values and text, never attribute names — every `<a>` has an `href`, so names carry no
443
+ * identity. Short tokens go too: `""`, `en`, `0` are shared by half a document. */
444
+ function contentTokens(markup: string): Set<string> {
445
+ const out = new Set<string>();
446
+ const withoutTags = markup.replace(/<[^>]*>/g, " ");
447
+ for (const m of markup.matchAll(/=\s*"([^"]*)"|=\s*'([^']*)'/g)) {
448
+ for (const w of (m[1] ?? m[2] ?? "").split(/[\s/_-]+/)) if (w.length >= 3) out.add(w.toLowerCase());
449
+ }
450
+ for (const w of withoutTags.split(/[\s/_-]+/)) if (w.length >= 3) out.add(w.toLowerCase());
451
+ return out;
452
+ }
453
+
454
+ /** Is the citation RECOGNISABLY the element the harvest recorded at that anchor?
455
+ *
456
+ * This is what survives when byte-exact snippet matching is given up, and it has to do two
457
+ * jobs at once. An adjudicator that retypes an element — attributes reordered, `class` left
458
+ * off — named the right thing and must not be refused. An adjudicator that writes down a
459
+ * DIFFERENT element at the same anchor (another link, another href, another label) has not
460
+ * read what it claims to have read, and must be.
461
+ *
462
+ * So: same tag, and at least one distinctive word in common — an attribute value or a word of
463
+ * the text. `<img alt="" src="/assets/help.svg">` against
464
+ * `<img src="/assets/help.svg" alt="" class="fr-responsive-img">` shares `assets` and
465
+ * `help.svg`; `<a href="/invented">Nowhere</a>` against `<a href="/pricing">Read more</a>`
466
+ * shares nothing at all.
467
+ *
468
+ * Silence is not contradiction: a citation with no snippet, or an anchor whose markup carries
469
+ * no distinctive word (`<br>`), is judged on the tag alone. Membership and the anchor's own
470
+ * grounding still stand behind every one of them. */
471
+ function recognisablySame(cite: { snippet?: string; selector?: string }, anchor: { snippet?: string; selector?: string }): boolean {
472
+ const ta = tagOf(cite);
473
+ const tb = tagOf(anchor);
474
+ if (ta !== undefined && tb !== undefined && ta !== tb) return false;
475
+ if (!cite.snippet || !anchor.snippet) return true;
476
+ const want = contentTokens(anchor.snippet);
477
+ if (!want.size) return true;
478
+ const got = contentTokens(cite.snippet);
479
+ if (!got.size) return true;
480
+ for (const w of got) if (want.has(w)) return true;
481
+ return false;
482
+ }
483
+
484
+ /** How much of the anchor's distinctive content the citation repeats. 0 = nothing in common. */
485
+ function overlap(cite: { snippet?: string }, anchor: { snippet?: string }): number {
486
+ if (!cite.snippet || !anchor.snippet) return 0;
487
+ const want = contentTokens(anchor.snippet);
488
+ if (!want.size) return 0;
489
+ let n = 0;
490
+ for (const w of contentTokens(cite.snippet)) if (want.has(w)) n++;
491
+ return n;
492
+ }
493
+
494
+ /** The harvested anchor a citation resolves to, or undefined.
495
+ *
496
+ * Two shapes, because a class carries two kinds of anchor. The REPRESENTATIVE has a snippet
497
+ * the engine read out of the file itself; a SIBLING in `alsoAt` is a bare `file:line` — the
498
+ * same content class at another occurrence, with no snippet recorded. Which one matched
499
+ * decides what can be grounded, so the caller is told.
500
+ *
501
+ * CONTENT BREAKS THE TIE, because the line often cannot. A page snapshot is
502
+ * `documentElement.outerHTML`: one line, the whole document. Every anchor harvested from it
503
+ * therefore sits at line 2, so "the anchor at the cited line" names dozens of elements at
504
+ * once and taking the first is a coin toss — measured on a real run, that is how a citation
505
+ * of an `<img>` came to be checked against an `<svg>` and refused for describing the wrong
506
+ * element. So among the anchors the line admits, the one the citation actually describes
507
+ * wins; ties and empty overlaps keep document order, which is what a single-anchor file
508
+ * always did. */
509
+ function anchorFor(
510
+ evidence: Evidence[],
511
+ c: { file: string; line: number; snippet?: string },
512
+ drift: number,
513
+ ): { at: Evidence; representative: boolean } | undefined {
514
+ const reps = evidence.filter((e) => e.file === c.file && Math.abs(e.line - c.line) <= drift);
515
+ const best = (cands: Evidence[]): Evidence | undefined => {
516
+ let top: Evidence | undefined;
517
+ let score = -1;
518
+ for (const e of cands) {
519
+ const n = overlap(c, e);
520
+ if (n > score) {
521
+ score = n;
522
+ top = e;
523
+ }
524
+ }
525
+ return top;
526
+ };
527
+ const rep = best(reps);
528
+ if (rep) return { at: rep, representative: true };
529
+ const siblings = evidence.filter((e) => cites0(e.alsoAt ?? [], c, drift));
530
+ const sib = best(siblings);
531
+ return sib ? { at: sib, representative: false } : undefined;
532
+ }
533
+
431
534
  function cites0(anchors: string[], c: { file: string; line: number }, drift: number): boolean {
432
535
  for (const a of anchors) {
433
536
  const at = a.lastIndexOf(":");
@@ -629,15 +732,21 @@ export function applyAdjudication(
629
732
  // Per-item fail-closed validation. Grounding inputs are collected PER ITEM (not into one
630
733
  // flat list) for the same reason as `blame`: a failed citation has to condemn its own
631
734
  // criterion and no other.
632
- const groundInputs = new Map<string, { file: string; line: number; selector?: string; snippet?: string }[]>();
735
+ type Ground = { file: string; line: number; selector?: string; snippet?: string };
736
+ const groundInputs = new Map<string, { g: Ground; fallback?: Ground }[]>();
633
737
  // Memoised: only a citation that missed its criterion's own anchors ever asks for it.
634
738
  let scopeCache: Set<string> | undefined;
635
739
  const { citationDrift } = adjudicationLimits(opts.cwd);
636
740
  const scopeFiles = (): Set<string> => (scopeCache ??= auditFiles(audit, opts.cwd));
637
- const toGround = (criteriaId: string, g: { file: string; line: number; selector?: string; snippet?: string }) => {
741
+ const toGround = (
742
+ criteriaId: string,
743
+ g: { file: string; line: number; selector?: string; snippet?: string },
744
+ fallback?: { file: string; line: number; selector?: string; snippet?: string },
745
+ ) => {
746
+ const entry = { g, ...(fallback ? { fallback } : {}) };
638
747
  const list = groundInputs.get(criteriaId);
639
- if (list) list.push(g);
640
- else groundInputs.set(criteriaId, [g]);
748
+ if (list) list.push(entry);
749
+ else groundInputs.set(criteriaId, [entry]);
641
750
  };
642
751
  for (const it of adj.items) {
643
752
  const v = it.verdict;
@@ -716,7 +825,54 @@ export function applyAdjudication(
716
825
  if (!cites0(anchors, c, citationDrift) && !scopeFiles().has(c.file)) {
717
826
  blame(it.criteriaId, `criterion ${it.criteriaId}: citation ${c.file}:${c.line} is not among this criterion's harvested evidence (fabricated?)`);
718
827
  }
719
- toGround(it.criteriaId, { file: c.file, line: c.line, selector: c.selector, snippet: c.snippet });
828
+ // GROUND THE ANCHOR, NOT THE TRANSCRIPTION.
829
+ //
830
+ // Once a citation lands on evidence this criterion was shown, the harvested anchor is
831
+ // the ground truth: the engine read it out of the file, so it is there by
832
+ // construction. Re-checking the agent's RETYPING of it against the file tests
833
+ // spelling, not evidence — and it fails constantly, because an adjudicator writes the
834
+ // element out from the brief rather than copying the `snippet` field byte for byte.
835
+ //
836
+ // Measured on a real run: 81 criteria adjudicated, 78 of 82 citations landing exactly
837
+ // on a harvested anchor, and 54 verdicts refused — every one of them for
838
+ // `cited snippet not found`. A rendered snapshot serializes the document on ONE line,
839
+ // so its anchors all sit at line 2 and the grounding window is the whole page: what
840
+ // failed was never the location, only the transcription of it.
841
+ //
842
+ // A sibling from `alsoAt` carries no snippet (it is a bare `file:line`), so there is
843
+ // nothing authoritative to ground — the selector probe runs against the real file
844
+ // instead, which is the same check a citation with no snippet has always had.
845
+ //
846
+ // Anything OUTSIDE the harvest keeps the strict check below, unchanged. That is where
847
+ // a fabricated location would hide, and this must not become a way to launder one.
848
+ // THE CITATION FIRST, THE ANCHOR ONLY AS A FALLBACK — and the order is the whole
849
+ // point.
850
+ //
851
+ // Whatever the agent wrote is checked against the real file, exactly as before. That
852
+ // is the strongest proof there is, and it covers the case the drift window exists
853
+ // for: RGAA 5.2 asks about a table's summary, the harvest anchors the `<table>`, and
854
+ // the honest citation is the `<caption>` a line below. Grounding the anchor INSTEAD
855
+ // refused precisely that — measured on a real run, 5.2, 5.4, 5.5, 11.6, 11.7 and
856
+ // 11.9 all died on it, each a correct citation of a neighbour.
857
+ //
858
+ // Only when the citation does not ground — which is what a RETYPING looks like, and
859
+ // on a one-line snapshot that is most of them — does the harvested anchor stand in.
860
+ // It is authoritative there: the engine read it out of the file itself. And only
861
+ // then does recognisability have anything to say, because only then is the anchor
862
+ // being used to vouch for something the file did not confirm on its own.
863
+ const anchor = anchorFor(it.evidence, c, citationDrift);
864
+ const cite = { file: c.file, line: c.line, selector: c.selector, snippet: c.snippet };
865
+ if (anchor && recognisablySame(c, anchor.at)) {
866
+ toGround(
867
+ it.criteriaId,
868
+ cite,
869
+ anchor.representative
870
+ ? { file: anchor.at.file, line: anchor.at.line, selector: anchor.at.selector ?? c.selector, snippet: anchor.at.snippet }
871
+ : { file: c.file, line: c.line, selector: anchor.at.selector ?? c.selector },
872
+ );
873
+ } else {
874
+ toGround(it.criteriaId, cite);
875
+ }
720
876
  }
721
877
  }
722
878
  } else if (v === "NC") {
@@ -757,8 +913,15 @@ export function applyAdjudication(
757
913
  // flat `groundItems` produced.
758
914
  const grounding: GroundingSummary = { grounded: 0, moved: 0, failed: 0, issues: [] };
759
915
  for (const [criteriaId, inputs] of groundInputs) {
760
- for (const input of inputs) {
761
- const r = groundFinding(input, { cwd: opts.cwd });
916
+ for (const { g, fallback } of inputs) {
917
+ let r = groundFinding(g, { cwd: opts.cwd });
918
+ // The harvested anchor vouches for a citation the file could not confirm on its own —
919
+ // an adjudicator's retyping of an element it did read. `moved` either way: what
920
+ // grounded is not verbatim what was cited.
921
+ if (!r.ok && fallback) {
922
+ const viaAnchor = groundFinding(fallback, { cwd: opts.cwd });
923
+ if (viaAnchor.ok) r = { ok: true, moved: true };
924
+ }
762
925
  if (r.ok) {
763
926
  grounding.grounded++;
764
927
  if (r.moved) grounding.moved++;
@@ -945,6 +1108,11 @@ const residualUndecidableReason = () => "Left as an explicit residual risk (not
945
1108
  * contract violation rather than a bare « to assess ». */
946
1109
  const residualRejectedReason = (why: string) => `Adjudication refused by the gate — ${why.replace(/^criterion \S+: /, "")}. Re-adjudicate this criterion.`;
947
1110
 
1111
+ /** Characters of a harvested snippet the brief prints. Long enough that the element is
1112
+ * unmistakable — its tag and its first distinctive attributes — and short enough that a page
1113
+ * snapshot's markup does not swamp a model's context. */
1114
+ const SNIPPET_SHOWN = 200;
1115
+
948
1116
  // ---- worklist file rendering ----
949
1117
  const T = {
950
1118
  fr: {
@@ -965,6 +1133,7 @@ const T = {
965
1133
  pagesWord: "page(s)",
966
1134
  on: "sur",
967
1135
  alsoAt: "aussi en",
1136
+ snippetLabel: "`snippet` à copier dans la citation",
968
1137
  incomplete: "LECTURE INCOMPLÈTE — un « C » sera refusé sur ce critère",
969
1138
  none: "(aucune évidence automatique — décidez depuis la source, ou laissez `manual` avec une raison)",
970
1139
  questions: "À vérifier manuellement",
@@ -995,6 +1164,7 @@ const T = {
995
1164
  pagesWord: "page(s)",
996
1165
  on: "on",
997
1166
  alsoAt: "also at",
1167
+ snippetLabel: "`snippet` to copy into the citation",
998
1168
  incomplete: "INCOMPLETE READING — a C will be refused on this criterion",
999
1169
  none: "(no automatic evidence — decide from source, or leave `manual` with a reason)",
1000
1170
  questions: "To verify manually",
@@ -1152,6 +1322,23 @@ export function formatAdjudication(
1152
1322
  .filter(Boolean)
1153
1323
  .join(" ");
1154
1324
  out.push(`- \`${e.file}:${e.line}\` (\`${e.selector}\`)${extra ? ` [${extra}]` : ""}${e.note ? ` — ${e.note}` : ""}`);
1325
+ // THE SNIPPET, SHOWN AND NAMED.
1326
+ //
1327
+ // The contract asks a citation to carry one, and this brief used to end each line with
1328
+ // the harvester's NOTE — which for an image subject reads `<svg> alt="" aria-label=""
1329
+ // src=""`. That is markup-shaped, and it was the only markup-shaped string on the line.
1330
+ // Measured on a real run: the adjudicator copied it into 34 citations, each one a tag
1331
+ // glued to three empty attributes, and the fold refused every one — correctly, and
1332
+ // uselessly, because a citation that repeats a template proves nothing. The agent was
1333
+ // copying the only thing it had been given.
1334
+ //
1335
+ // Its own line, and labelled, so it cannot be read as more of the note. Bounded because
1336
+ // a rendered snapshot's markup can be long and this file is read by a model with a
1337
+ // context to spend.
1338
+ if (e.snippet?.trim()) {
1339
+ const snip = e.snippet.trim().replace(/\s+/g, " ").slice(0, SNIPPET_SHOWN);
1340
+ out.push(` - ${s.snippetLabel} : \`${snip}${e.snippet.trim().length > SNIPPET_SHOWN ? "…" : ""}\``);
1341
+ }
1155
1342
  }
1156
1343
  out.push("");
1157
1344
  }
package/src/annotate.ts CHANGED
@@ -141,6 +141,8 @@ const S = {
141
141
  pagesClamped: (n: number) =>
142
142
  `_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._`,
143
143
  scoreboardClamped: (n: number) => `_${n} page(s) retirée(s) du tableau pour tenir dans la limite de GitHub — l'artefact les porte toutes._`,
144
+ pageDefects: "Défauts",
145
+ pageMoreDefects: (n: number) => `_… et ${n} autre(s) défaut(s) distinct(s) sur cette page — voir la fiche de page dans l'artefact._`,
144
146
  noCriterionForFindings: (n: number) =>
145
147
  `${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.`,
146
148
  },
@@ -193,6 +195,8 @@ const S = {
193
195
  "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.",
194
196
  pagesClamped: (n: number) => `_The detail of ${n} page(s) was dropped from this comment to fit GitHub's limit — the artifact carries them all._`,
195
197
  scoreboardClamped: (n: number) => `_${n} page(s) dropped from the table to fit GitHub's limit — the artifact carries them all._`,
198
+ pageDefects: "Defects",
199
+ pageMoreDefects: (n: number) => `_… and ${n} more distinct defect(s) on this page — see its sheet in the artifact._`,
196
200
  noCriterionForFindings: (n: number) =>
197
201
  `${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.`,
198
202
  },
@@ -445,6 +449,13 @@ function basisCaveats(result: AuditResult, derived: PageResult[], s: (typeof S)[
445
449
  return out;
446
450
  }
447
451
 
452
+ /** Distinct defects shown under one page before the block says how many it held back.
453
+ *
454
+ * Distinct, not occurrences — the grouping already folded the repeats. Six is what fits
455
+ * beside the criterion table without turning a 35-page comment into something nobody scrolls,
456
+ * and the ones held back are counted rather than dropped in silence. */
457
+ const PAGE_DEFECTS_SHOWN = 6;
458
+
448
459
  /** One page's collapsed block: its severity counts in the summary line, its standing as the
449
460
  * shared tally sentence, and the criteria that are actually NON-CONFORMING.
450
461
  *
@@ -453,7 +464,7 @@ function basisCaveats(result: AuditResult, derived: PageResult[], s: (typeof S)[
453
464
  * reviewer needs inline is which pages fail and on what — the tally line carries the rest
454
465
  * (conforming, not applicable, and how many nobody has ruled on yet) without pretending the
455
466
  * undecided ones are fine. */
456
- function pageBlock(result: AuditResult, page: PageResult, standard: StandardId, lang: Lang): string | undefined {
467
+ function pageBlock(result: AuditResult, page: PageResult, standard: StandardId, lang: Lang, baseDir: string): string | undefined {
457
468
  const s = S[lang];
458
469
  const rows = pageCriterionRows(result, page, standard, lang);
459
470
  const nc = rows.filter((r) => r.status === "NC");
@@ -484,6 +495,32 @@ function pageBlock(result: AuditResult, page: PageResult, standard: StandardId,
484
495
  for (const r of nc) {
485
496
  out.push(withTests ? `| ${cell(r.label)} | ${r.tests.map((t) => `\`${t}\``).join(" ")} |` : `| ${cell(r.label)} |`);
486
497
  }
498
+
499
+ // WHAT TO CHANGE, not only what was failed.
500
+ //
501
+ // The rows above name the criterion; a criterion is the norm, not the work. Measured on a
502
+ // real pull request: 506 lines, 35 pages, every non-conforming criterion listed — and not
503
+ // one file, line, selector or description of the defect anywhere in the document. A
504
+ // reviewer read « les couleurs sont-elles suffisamment contrastées ? » and had to download
505
+ // a 4 MB artifact to find out which element. The digest comment has carried location and
506
+ // defect since it existed; this is the same audit read page-first, and there is no reason
507
+ // for it to be the half that says nothing.
508
+ //
509
+ // Grouped, never listed: one design-system defect repeated on twenty rows is ONE thing to
510
+ // fix, and twenty identical lines is how a comment becomes unreadable and then gets muzzled.
511
+ const defects = groupFindings(
512
+ page.findings.filter((f) => !f.advisory),
513
+ standard,
514
+ lang,
515
+ baseDir,
516
+ );
517
+ if (defects.length) {
518
+ out.push("", `| ${s.severity} | ${s.where} | ${s.what} | ${s.occurrences} |`, "| --- | --- | --- | ---: |");
519
+ for (const g of defects.slice(0, PAGE_DEFECTS_SHOWN)) {
520
+ out.push(`| ${ICON[g.severity]} ${g.severity} | \`${cell(g.where)}\` (\`${cell(g.selectorHint)}\`) | ${cell(g.message)} | ${g.occurrences} |`);
521
+ }
522
+ if (defects.length > PAGE_DEFECTS_SHOWN) out.push("", s.pageMoreDefects(defects.length - PAGE_DEFECTS_SHOWN));
523
+ }
487
524
  out.push("", "</details>");
488
525
  return out.join("\n");
489
526
  }
@@ -504,6 +541,7 @@ export function pagesComment(result: AuditResult, opts: AnnotateOptions & { runU
504
541
  const standard = opts.standard ?? CORE;
505
542
  const lang = opts.lang ?? "en";
506
543
  const s = S[lang];
544
+ const baseDir = opts.baseDir ?? process.cwd();
507
545
  const stdLabel = isCore(standard) ? "WCAG 2.2 AA" : loadPack(standard).name;
508
546
  const redirected = result.scope.redirected ?? [];
509
547
  const scope = pagesOf(result);
@@ -539,7 +577,7 @@ export function pagesComment(result: AuditResult, opts: AnnotateOptions & { runU
539
577
  severityCount(b, "majeur") - severityCount(a, "majeur") ||
540
578
  severityCount(b, "mineur") - severityCount(a, "mineur"),
541
579
  )
542
- .map((p) => pageBlock(result, p, standard, lang))
580
+ .map((p) => pageBlock(result, p, standard, lang, baseDir))
543
581
  .filter((b): b is string => b !== undefined);
544
582
 
545
583
  // WHOLE blocks and WHOLE rows, never a slice of the finished string — cutting a rendered
package/src/types.ts CHANGED
@@ -1,6 +1,6 @@
1
1
  // Single source of truth for shared types + the embedded version. sync-version.mjs
2
2
  // rewrites VERSION at release time (kept in lockstep with package.json + SKILL.md).
3
- export const VERSION = "5.8.0";
3
+ export const VERSION = "5.9.1";
4
4
  export const SCHEMA_VERSION = 2;
5
5
 
6
6
  // Lang = the UI FRAME's language — the `L` localization tables each module (report,