ultra11y 5.8.0 → 5.9.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.8.0",
13
+ "version": "5.9.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.8.0",
4
+ "version": "5.9.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.8.0",
4
+ "version": "5.9.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.8.0";
30
+ const ULTRA11Y_PLUGIN_VERSION = "5.9.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.8.0",
3
+ "version": "5.9.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",
@@ -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.0";
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(":");
@@ -57775,7 +57830,20 @@ function applyAdjudication(audit2, adj, opts = {}) {
57775
57830
  if (!cites0(anchors, c2, citationDrift) && !scopeFiles().has(c2.file)) {
57776
57831
  blame(it.criteriaId, `criterion ${it.criteriaId}: citation ${c2.file}:${c2.line} is not among this criterion's harvested evidence (fabricated?)`);
57777
57832
  }
57778
- toGround(it.criteriaId, { file: c2.file, line: c2.line, selector: c2.selector, snippet: c2.snippet });
57833
+ const anchor = anchorFor(it.evidence, c2, citationDrift);
57834
+ if (anchor && !recognisablySame(c2, anchor.at)) {
57835
+ blame(
57836
+ it.criteriaId,
57837
+ `criterion ${it.criteriaId}: citation ${c2.file}:${c2.line} does not describe the element harvested there (${anchor.at.snippet ? `\`${anchor.at.snippet.slice(0, 80)}\`` : `<${tagOf2(anchor.at)}>`}) \u2014 cite the element you actually read, copying its \`snippet\` from the brief`
57838
+ );
57839
+ }
57840
+ if (anchor?.representative) {
57841
+ toGround(it.criteriaId, { file: anchor.at.file, line: anchor.at.line, selector: anchor.at.selector ?? c2.selector, snippet: anchor.at.snippet });
57842
+ } else if (anchor) {
57843
+ toGround(it.criteriaId, { file: c2.file, line: c2.line, selector: anchor.at.selector ?? c2.selector });
57844
+ } else {
57845
+ toGround(it.criteriaId, { file: c2.file, line: c2.line, selector: c2.selector, snippet: c2.snippet });
57846
+ }
57779
57847
  }
57780
57848
  }
57781
57849
  } else if (v === "NC") {
@@ -57941,6 +58009,7 @@ function recomputeTallies(a) {
57941
58009
  var residualScanReason = () => "Rendering criterion \u2014 decide on the rendered DOM (`scan`).";
57942
58010
  var residualUndecidableReason = () => "Left as an explicit residual risk (not decidable from the available evidence).";
57943
58011
  var residualRejectedReason = (why) => `Adjudication refused by the gate \u2014 ${why.replace(/^criterion \S+: /, "")}. Re-adjudicate this criterion.`;
58012
+ var SNIPPET_SHOWN = 200;
57944
58013
  var T2 = {
57945
58014
  fr: {
57946
58015
  title: "# Adjudication des crit\xE8res \xE0 \xE9valuer (ultra11y)",
@@ -57959,6 +58028,7 @@ var T2 = {
57959
58028
  pagesWord: "page(s)",
57960
58029
  on: "sur",
57961
58030
  alsoAt: "aussi en",
58031
+ snippetLabel: "`snippet` \xE0 copier dans la citation",
57962
58032
  incomplete: "LECTURE INCOMPL\xC8TE \u2014 un \xAB C \xBB sera refus\xE9 sur ce crit\xE8re",
57963
58033
  none: "(aucune \xE9vidence automatique \u2014 d\xE9cidez depuis la source, ou laissez `manual` avec une raison)",
57964
58034
  questions: "\xC0 v\xE9rifier manuellement",
@@ -57988,6 +58058,7 @@ var T2 = {
57988
58058
  pagesWord: "page(s)",
57989
58059
  on: "on",
57990
58060
  alsoAt: "also at",
58061
+ snippetLabel: "`snippet` to copy into the citation",
57991
58062
  incomplete: "INCOMPLETE READING \u2014 a C will be refused on this criterion",
57992
58063
  none: "(no automatic evidence \u2014 decide from source, or leave `manual` with a reason)",
57993
58064
  questions: "To verify manually",
@@ -58048,6 +58119,10 @@ function formatAdjudication(items, lang = "en", standard = CORE2, opts = {}) {
58048
58119
  e.alsoAt?.length ? `${s.alsoAt} ${e.alsoAt.slice(0, shown).join(", ")}${e.alsoAt.length > shown ? "\u2026" : ""}` : ""
58049
58120
  ].filter(Boolean).join(" ");
58050
58121
  out2.push(`- \`${e.file}:${e.line}\` (\`${e.selector}\`)${extra ? ` [${extra}]` : ""}${e.note ? ` \u2014 ${e.note}` : ""}`);
58122
+ if (e.snippet?.trim()) {
58123
+ const snip = e.snippet.trim().replace(/\s+/g, " ").slice(0, SNIPPET_SHOWN);
58124
+ out2.push(` - ${s.snippetLabel} : \`${snip}${e.snippet.trim().length > SNIPPET_SHOWN ? "\u2026" : ""}\``);
58125
+ }
58051
58126
  }
58052
58127
  out2.push("");
58053
58128
  }
@@ -61319,6 +61394,8 @@ var S = {
61319
61394
  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
61395
  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
61396
  scoreboardClamped: (n) => `_${n} page(s) retir\xE9e(s) du tableau pour tenir dans la limite de GitHub \u2014 l'artefact les porte toutes._`,
61397
+ pageDefects: "D\xE9fauts",
61398
+ 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
61399
  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
61400
  },
61324
61401
  en: {
@@ -61364,6 +61441,8 @@ var S = {
61364
61441
  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
61442
  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
61443
  scoreboardClamped: (n) => `_${n} page(s) dropped from the table to fit GitHub's limit \u2014 the artifact carries them all._`,
61444
+ pageDefects: "Defects",
61445
+ pageMoreDefects: (n) => `_\u2026 and ${n} more distinct defect(s) on this page \u2014 see its sheet in the artifact._`,
61367
61446
  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
61447
  }
61369
61448
  };
@@ -61508,7 +61587,8 @@ function basisCaveats(result, derived, s, lang) {
61508
61587
  if (notAudited && derived.some((p) => p.basis === "not-audited")) out2.push(`> ${notAudited}`, "");
61509
61588
  return out2;
61510
61589
  }
61511
- function pageBlock(result, page, standard, lang) {
61590
+ var PAGE_DEFECTS_SHOWN = 6;
61591
+ function pageBlock(result, page, standard, lang, baseDir) {
61512
61592
  const s = S[lang];
61513
61593
  const rows = pageCriterionRows(result, page, standard, lang);
61514
61594
  const nc = rows.filter((r) => r.status === "NC");
@@ -61534,6 +61614,19 @@ function pageBlock(result, page, standard, lang) {
61534
61614
  for (const r of nc) {
61535
61615
  out2.push(withTests ? `| ${cell(r.label)} | ${r.tests.map((t3) => `\`${t3}\``).join(" ")} |` : `| ${cell(r.label)} |`);
61536
61616
  }
61617
+ const defects = groupFindings(
61618
+ page.findings.filter((f) => !f.advisory),
61619
+ standard,
61620
+ lang,
61621
+ baseDir
61622
+ );
61623
+ if (defects.length) {
61624
+ out2.push("", `| ${s.severity} | ${s.where} | ${s.what} | ${s.occurrences} |`, "| --- | --- | --- | ---: |");
61625
+ for (const g of defects.slice(0, PAGE_DEFECTS_SHOWN)) {
61626
+ out2.push(`| ${ICON5[g.severity]} ${g.severity} | \`${cell(g.where)}\` (\`${cell(g.selectorHint)}\`) | ${cell(g.message)} | ${g.occurrences} |`);
61627
+ }
61628
+ if (defects.length > PAGE_DEFECTS_SHOWN) out2.push("", s.pageMoreDefects(defects.length - PAGE_DEFECTS_SHOWN));
61629
+ }
61537
61630
  out2.push("", "</details>");
61538
61631
  return out2.join("\n");
61539
61632
  }
@@ -61541,6 +61634,7 @@ function pagesComment(result, opts = {}) {
61541
61634
  const standard = opts.standard ?? CORE2;
61542
61635
  const lang = opts.lang ?? "en";
61543
61636
  const s = S[lang];
61637
+ const baseDir = opts.baseDir ?? process.cwd();
61544
61638
  const stdLabel = isCore(standard) ? "WCAG 2.2 AA" : loadPack(standard).name;
61545
61639
  const redirected = result.scope.redirected ?? [];
61546
61640
  const scope = pagesOf(result);
@@ -61563,7 +61657,7 @@ function pagesComment(result, opts = {}) {
61563
61657
  if (rate.agentRuled) head.push(`> ${agentMarkNote(lang)}`, "");
61564
61658
  const blocks = [...derived].sort(
61565
61659
  (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);
61660
+ ).map((p) => pageBlock(result, p, standard, lang, baseDir)).filter((b) => b !== void 0);
61567
61661
  const assemble = (nBlocks2, nRows2) => {
61568
61662
  const body3 = [...scoreboardTable(result, derived.slice(0, nRows2), standard, s, lang), ""];
61569
61663
  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.0
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.0";
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(":");
@@ -57775,7 +57830,20 @@ function applyAdjudication(audit2, adj, opts = {}) {
57775
57830
  if (!cites0(anchors, c2, citationDrift) && !scopeFiles().has(c2.file)) {
57776
57831
  blame(it.criteriaId, `criterion ${it.criteriaId}: citation ${c2.file}:${c2.line} is not among this criterion's harvested evidence (fabricated?)`);
57777
57832
  }
57778
- toGround(it.criteriaId, { file: c2.file, line: c2.line, selector: c2.selector, snippet: c2.snippet });
57833
+ const anchor = anchorFor(it.evidence, c2, citationDrift);
57834
+ if (anchor && !recognisablySame(c2, anchor.at)) {
57835
+ blame(
57836
+ it.criteriaId,
57837
+ `criterion ${it.criteriaId}: citation ${c2.file}:${c2.line} does not describe the element harvested there (${anchor.at.snippet ? `\`${anchor.at.snippet.slice(0, 80)}\`` : `<${tagOf2(anchor.at)}>`}) \u2014 cite the element you actually read, copying its \`snippet\` from the brief`
57838
+ );
57839
+ }
57840
+ if (anchor?.representative) {
57841
+ toGround(it.criteriaId, { file: anchor.at.file, line: anchor.at.line, selector: anchor.at.selector ?? c2.selector, snippet: anchor.at.snippet });
57842
+ } else if (anchor) {
57843
+ toGround(it.criteriaId, { file: c2.file, line: c2.line, selector: anchor.at.selector ?? c2.selector });
57844
+ } else {
57845
+ toGround(it.criteriaId, { file: c2.file, line: c2.line, selector: c2.selector, snippet: c2.snippet });
57846
+ }
57779
57847
  }
57780
57848
  }
57781
57849
  } else if (v === "NC") {
@@ -57941,6 +58009,7 @@ function recomputeTallies(a) {
57941
58009
  var residualScanReason = () => "Rendering criterion \u2014 decide on the rendered DOM (`scan`).";
57942
58010
  var residualUndecidableReason = () => "Left as an explicit residual risk (not decidable from the available evidence).";
57943
58011
  var residualRejectedReason = (why) => `Adjudication refused by the gate \u2014 ${why.replace(/^criterion \S+: /, "")}. Re-adjudicate this criterion.`;
58012
+ var SNIPPET_SHOWN = 200;
57944
58013
  var T2 = {
57945
58014
  fr: {
57946
58015
  title: "# Adjudication des crit\xE8res \xE0 \xE9valuer (ultra11y)",
@@ -57959,6 +58028,7 @@ var T2 = {
57959
58028
  pagesWord: "page(s)",
57960
58029
  on: "sur",
57961
58030
  alsoAt: "aussi en",
58031
+ snippetLabel: "`snippet` \xE0 copier dans la citation",
57962
58032
  incomplete: "LECTURE INCOMPL\xC8TE \u2014 un \xAB C \xBB sera refus\xE9 sur ce crit\xE8re",
57963
58033
  none: "(aucune \xE9vidence automatique \u2014 d\xE9cidez depuis la source, ou laissez `manual` avec une raison)",
57964
58034
  questions: "\xC0 v\xE9rifier manuellement",
@@ -57988,6 +58058,7 @@ var T2 = {
57988
58058
  pagesWord: "page(s)",
57989
58059
  on: "on",
57990
58060
  alsoAt: "also at",
58061
+ snippetLabel: "`snippet` to copy into the citation",
57991
58062
  incomplete: "INCOMPLETE READING \u2014 a C will be refused on this criterion",
57992
58063
  none: "(no automatic evidence \u2014 decide from source, or leave `manual` with a reason)",
57993
58064
  questions: "To verify manually",
@@ -58048,6 +58119,10 @@ function formatAdjudication(items, lang = "en", standard = CORE2, opts = {}) {
58048
58119
  e.alsoAt?.length ? `${s.alsoAt} ${e.alsoAt.slice(0, shown).join(", ")}${e.alsoAt.length > shown ? "\u2026" : ""}` : ""
58049
58120
  ].filter(Boolean).join(" ");
58050
58121
  out2.push(`- \`${e.file}:${e.line}\` (\`${e.selector}\`)${extra ? ` [${extra}]` : ""}${e.note ? ` \u2014 ${e.note}` : ""}`);
58122
+ if (e.snippet?.trim()) {
58123
+ const snip = e.snippet.trim().replace(/\s+/g, " ").slice(0, SNIPPET_SHOWN);
58124
+ out2.push(` - ${s.snippetLabel} : \`${snip}${e.snippet.trim().length > SNIPPET_SHOWN ? "\u2026" : ""}\``);
58125
+ }
58051
58126
  }
58052
58127
  out2.push("");
58053
58128
  }
@@ -61319,6 +61394,8 @@ var S = {
61319
61394
  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
61395
  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
61396
  scoreboardClamped: (n) => `_${n} page(s) retir\xE9e(s) du tableau pour tenir dans la limite de GitHub \u2014 l'artefact les porte toutes._`,
61397
+ pageDefects: "D\xE9fauts",
61398
+ 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
61399
  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
61400
  },
61324
61401
  en: {
@@ -61364,6 +61441,8 @@ var S = {
61364
61441
  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
61442
  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
61443
  scoreboardClamped: (n) => `_${n} page(s) dropped from the table to fit GitHub's limit \u2014 the artifact carries them all._`,
61444
+ pageDefects: "Defects",
61445
+ pageMoreDefects: (n) => `_\u2026 and ${n} more distinct defect(s) on this page \u2014 see its sheet in the artifact._`,
61367
61446
  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
61447
  }
61369
61448
  };
@@ -61508,7 +61587,8 @@ function basisCaveats(result, derived, s, lang) {
61508
61587
  if (notAudited && derived.some((p) => p.basis === "not-audited")) out2.push(`> ${notAudited}`, "");
61509
61588
  return out2;
61510
61589
  }
61511
- function pageBlock(result, page, standard, lang) {
61590
+ var PAGE_DEFECTS_SHOWN = 6;
61591
+ function pageBlock(result, page, standard, lang, baseDir) {
61512
61592
  const s = S[lang];
61513
61593
  const rows = pageCriterionRows(result, page, standard, lang);
61514
61594
  const nc = rows.filter((r) => r.status === "NC");
@@ -61534,6 +61614,19 @@ function pageBlock(result, page, standard, lang) {
61534
61614
  for (const r of nc) {
61535
61615
  out2.push(withTests ? `| ${cell(r.label)} | ${r.tests.map((t3) => `\`${t3}\``).join(" ")} |` : `| ${cell(r.label)} |`);
61536
61616
  }
61617
+ const defects = groupFindings(
61618
+ page.findings.filter((f) => !f.advisory),
61619
+ standard,
61620
+ lang,
61621
+ baseDir
61622
+ );
61623
+ if (defects.length) {
61624
+ out2.push("", `| ${s.severity} | ${s.where} | ${s.what} | ${s.occurrences} |`, "| --- | --- | --- | ---: |");
61625
+ for (const g of defects.slice(0, PAGE_DEFECTS_SHOWN)) {
61626
+ out2.push(`| ${ICON5[g.severity]} ${g.severity} | \`${cell(g.where)}\` (\`${cell(g.selectorHint)}\`) | ${cell(g.message)} | ${g.occurrences} |`);
61627
+ }
61628
+ if (defects.length > PAGE_DEFECTS_SHOWN) out2.push("", s.pageMoreDefects(defects.length - PAGE_DEFECTS_SHOWN));
61629
+ }
61537
61630
  out2.push("", "</details>");
61538
61631
  return out2.join("\n");
61539
61632
  }
@@ -61541,6 +61634,7 @@ function pagesComment(result, opts = {}) {
61541
61634
  const standard = opts.standard ?? CORE2;
61542
61635
  const lang = opts.lang ?? "en";
61543
61636
  const s = S[lang];
61637
+ const baseDir = opts.baseDir ?? process.cwd();
61544
61638
  const stdLabel = isCore(standard) ? "WCAG 2.2 AA" : loadPack(standard).name;
61545
61639
  const redirected = result.scope.redirected ?? [];
61546
61640
  const scope = pagesOf(result);
@@ -61563,7 +61657,7 @@ function pagesComment(result, opts = {}) {
61563
61657
  if (rate.agentRuled) head.push(`> ${agentMarkNote(lang)}`, "");
61564
61658
  const blocks = [...derived].sort(
61565
61659
  (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);
61660
+ ).map((p) => pageBlock(result, p, standard, lang, baseDir)).filter((b) => b !== void 0);
61567
61661
  const assemble = (nBlocks2, nRows2) => {
61568
61662
  const body3 = [...scoreboardTable(result, derived.slice(0, nRows2), standard, s, lang), ""];
61569
61663
  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.0
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.0";
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(":");
@@ -57775,7 +57830,20 @@ function applyAdjudication(audit2, adj, opts = {}) {
57775
57830
  if (!cites0(anchors, c2, citationDrift) && !scopeFiles().has(c2.file)) {
57776
57831
  blame(it.criteriaId, `criterion ${it.criteriaId}: citation ${c2.file}:${c2.line} is not among this criterion's harvested evidence (fabricated?)`);
57777
57832
  }
57778
- toGround(it.criteriaId, { file: c2.file, line: c2.line, selector: c2.selector, snippet: c2.snippet });
57833
+ const anchor = anchorFor(it.evidence, c2, citationDrift);
57834
+ if (anchor && !recognisablySame(c2, anchor.at)) {
57835
+ blame(
57836
+ it.criteriaId,
57837
+ `criterion ${it.criteriaId}: citation ${c2.file}:${c2.line} does not describe the element harvested there (${anchor.at.snippet ? `\`${anchor.at.snippet.slice(0, 80)}\`` : `<${tagOf2(anchor.at)}>`}) \u2014 cite the element you actually read, copying its \`snippet\` from the brief`
57838
+ );
57839
+ }
57840
+ if (anchor?.representative) {
57841
+ toGround(it.criteriaId, { file: anchor.at.file, line: anchor.at.line, selector: anchor.at.selector ?? c2.selector, snippet: anchor.at.snippet });
57842
+ } else if (anchor) {
57843
+ toGround(it.criteriaId, { file: c2.file, line: c2.line, selector: anchor.at.selector ?? c2.selector });
57844
+ } else {
57845
+ toGround(it.criteriaId, { file: c2.file, line: c2.line, selector: c2.selector, snippet: c2.snippet });
57846
+ }
57779
57847
  }
57780
57848
  }
57781
57849
  } else if (v === "NC") {
@@ -57941,6 +58009,7 @@ function recomputeTallies(a) {
57941
58009
  var residualScanReason = () => "Rendering criterion \u2014 decide on the rendered DOM (`scan`).";
57942
58010
  var residualUndecidableReason = () => "Left as an explicit residual risk (not decidable from the available evidence).";
57943
58011
  var residualRejectedReason = (why) => `Adjudication refused by the gate \u2014 ${why.replace(/^criterion \S+: /, "")}. Re-adjudicate this criterion.`;
58012
+ var SNIPPET_SHOWN = 200;
57944
58013
  var T2 = {
57945
58014
  fr: {
57946
58015
  title: "# Adjudication des crit\xE8res \xE0 \xE9valuer (ultra11y)",
@@ -57959,6 +58028,7 @@ var T2 = {
57959
58028
  pagesWord: "page(s)",
57960
58029
  on: "sur",
57961
58030
  alsoAt: "aussi en",
58031
+ snippetLabel: "`snippet` \xE0 copier dans la citation",
57962
58032
  incomplete: "LECTURE INCOMPL\xC8TE \u2014 un \xAB C \xBB sera refus\xE9 sur ce crit\xE8re",
57963
58033
  none: "(aucune \xE9vidence automatique \u2014 d\xE9cidez depuis la source, ou laissez `manual` avec une raison)",
57964
58034
  questions: "\xC0 v\xE9rifier manuellement",
@@ -57988,6 +58058,7 @@ var T2 = {
57988
58058
  pagesWord: "page(s)",
57989
58059
  on: "on",
57990
58060
  alsoAt: "also at",
58061
+ snippetLabel: "`snippet` to copy into the citation",
57991
58062
  incomplete: "INCOMPLETE READING \u2014 a C will be refused on this criterion",
57992
58063
  none: "(no automatic evidence \u2014 decide from source, or leave `manual` with a reason)",
57993
58064
  questions: "To verify manually",
@@ -58048,6 +58119,10 @@ function formatAdjudication(items, lang = "en", standard = CORE2, opts = {}) {
58048
58119
  e.alsoAt?.length ? `${s.alsoAt} ${e.alsoAt.slice(0, shown).join(", ")}${e.alsoAt.length > shown ? "\u2026" : ""}` : ""
58049
58120
  ].filter(Boolean).join(" ");
58050
58121
  out2.push(`- \`${e.file}:${e.line}\` (\`${e.selector}\`)${extra ? ` [${extra}]` : ""}${e.note ? ` \u2014 ${e.note}` : ""}`);
58122
+ if (e.snippet?.trim()) {
58123
+ const snip = e.snippet.trim().replace(/\s+/g, " ").slice(0, SNIPPET_SHOWN);
58124
+ out2.push(` - ${s.snippetLabel} : \`${snip}${e.snippet.trim().length > SNIPPET_SHOWN ? "\u2026" : ""}\``);
58125
+ }
58051
58126
  }
58052
58127
  out2.push("");
58053
58128
  }
@@ -61319,6 +61394,8 @@ var S = {
61319
61394
  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
61395
  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
61396
  scoreboardClamped: (n) => `_${n} page(s) retir\xE9e(s) du tableau pour tenir dans la limite de GitHub \u2014 l'artefact les porte toutes._`,
61397
+ pageDefects: "D\xE9fauts",
61398
+ 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
61399
  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
61400
  },
61324
61401
  en: {
@@ -61364,6 +61441,8 @@ var S = {
61364
61441
  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
61442
  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
61443
  scoreboardClamped: (n) => `_${n} page(s) dropped from the table to fit GitHub's limit \u2014 the artifact carries them all._`,
61444
+ pageDefects: "Defects",
61445
+ pageMoreDefects: (n) => `_\u2026 and ${n} more distinct defect(s) on this page \u2014 see its sheet in the artifact._`,
61367
61446
  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
61447
  }
61369
61448
  };
@@ -61508,7 +61587,8 @@ function basisCaveats(result, derived, s, lang) {
61508
61587
  if (notAudited && derived.some((p) => p.basis === "not-audited")) out2.push(`> ${notAudited}`, "");
61509
61588
  return out2;
61510
61589
  }
61511
- function pageBlock(result, page, standard, lang) {
61590
+ var PAGE_DEFECTS_SHOWN = 6;
61591
+ function pageBlock(result, page, standard, lang, baseDir) {
61512
61592
  const s = S[lang];
61513
61593
  const rows = pageCriterionRows(result, page, standard, lang);
61514
61594
  const nc = rows.filter((r) => r.status === "NC");
@@ -61534,6 +61614,19 @@ function pageBlock(result, page, standard, lang) {
61534
61614
  for (const r of nc) {
61535
61615
  out2.push(withTests ? `| ${cell(r.label)} | ${r.tests.map((t3) => `\`${t3}\``).join(" ")} |` : `| ${cell(r.label)} |`);
61536
61616
  }
61617
+ const defects = groupFindings(
61618
+ page.findings.filter((f) => !f.advisory),
61619
+ standard,
61620
+ lang,
61621
+ baseDir
61622
+ );
61623
+ if (defects.length) {
61624
+ out2.push("", `| ${s.severity} | ${s.where} | ${s.what} | ${s.occurrences} |`, "| --- | --- | --- | ---: |");
61625
+ for (const g of defects.slice(0, PAGE_DEFECTS_SHOWN)) {
61626
+ out2.push(`| ${ICON5[g.severity]} ${g.severity} | \`${cell(g.where)}\` (\`${cell(g.selectorHint)}\`) | ${cell(g.message)} | ${g.occurrences} |`);
61627
+ }
61628
+ if (defects.length > PAGE_DEFECTS_SHOWN) out2.push("", s.pageMoreDefects(defects.length - PAGE_DEFECTS_SHOWN));
61629
+ }
61537
61630
  out2.push("", "</details>");
61538
61631
  return out2.join("\n");
61539
61632
  }
@@ -61541,6 +61634,7 @@ function pagesComment(result, opts = {}) {
61541
61634
  const standard = opts.standard ?? CORE2;
61542
61635
  const lang = opts.lang ?? "en";
61543
61636
  const s = S[lang];
61637
+ const baseDir = opts.baseDir ?? process.cwd();
61544
61638
  const stdLabel = isCore(standard) ? "WCAG 2.2 AA" : loadPack(standard).name;
61545
61639
  const redirected = result.scope.redirected ?? [];
61546
61640
  const scope = pagesOf(result);
@@ -61563,7 +61657,7 @@ function pagesComment(result, opts = {}) {
61563
61657
  if (rate.agentRuled) head.push(`> ${agentMarkNote(lang)}`, "");
61564
61658
  const blocks = [...derived].sort(
61565
61659
  (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);
61660
+ ).map((p) => pageBlock(result, p, standard, lang, baseDir)).filter((b) => b !== void 0);
61567
61661
  const assemble = (nBlocks2, nRows2) => {
61568
61662
  const body3 = [...scoreboardTable(result, derived.slice(0, nRows2), standard, s, lang), ""];
61569
61663
  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(":");
@@ -716,7 +819,40 @@ export function applyAdjudication(
716
819
  if (!cites0(anchors, c, citationDrift) && !scopeFiles().has(c.file)) {
717
820
  blame(it.criteriaId, `criterion ${it.criteriaId}: citation ${c.file}:${c.line} is not among this criterion's harvested evidence (fabricated?)`);
718
821
  }
719
- toGround(it.criteriaId, { file: c.file, line: c.line, selector: c.selector, snippet: c.snippet });
822
+ // GROUND THE ANCHOR, NOT THE TRANSCRIPTION.
823
+ //
824
+ // Once a citation lands on evidence this criterion was shown, the harvested anchor is
825
+ // the ground truth: the engine read it out of the file, so it is there by
826
+ // construction. Re-checking the agent's RETYPING of it against the file tests
827
+ // spelling, not evidence — and it fails constantly, because an adjudicator writes the
828
+ // element out from the brief rather than copying the `snippet` field byte for byte.
829
+ //
830
+ // Measured on a real run: 81 criteria adjudicated, 78 of 82 citations landing exactly
831
+ // on a harvested anchor, and 54 verdicts refused — every one of them for
832
+ // `cited snippet not found`. A rendered snapshot serializes the document on ONE line,
833
+ // so its anchors all sit at line 2 and the grounding window is the whole page: what
834
+ // failed was never the location, only the transcription of it.
835
+ //
836
+ // A sibling from `alsoAt` carries no snippet (it is a bare `file:line`), so there is
837
+ // nothing authoritative to ground — the selector probe runs against the real file
838
+ // instead, which is the same check a citation with no snippet has always had.
839
+ //
840
+ // Anything OUTSIDE the harvest keeps the strict check below, unchanged. That is where
841
+ // a fabricated location would hide, and this must not become a way to launder one.
842
+ const anchor = anchorFor(it.evidence, c, citationDrift);
843
+ if (anchor && !recognisablySame(c, anchor.at)) {
844
+ blame(
845
+ it.criteriaId,
846
+ `criterion ${it.criteriaId}: citation ${c.file}:${c.line} does not describe the element harvested there (${anchor.at.snippet ? `\`${anchor.at.snippet.slice(0, 80)}\`` : `<${tagOf(anchor.at)}>`}) — cite the element you actually read, copying its \`snippet\` from the brief`,
847
+ );
848
+ }
849
+ if (anchor?.representative) {
850
+ toGround(it.criteriaId, { file: anchor.at.file, line: anchor.at.line, selector: anchor.at.selector ?? c.selector, snippet: anchor.at.snippet });
851
+ } else if (anchor) {
852
+ toGround(it.criteriaId, { file: c.file, line: c.line, selector: anchor.at.selector ?? c.selector });
853
+ } else {
854
+ toGround(it.criteriaId, { file: c.file, line: c.line, selector: c.selector, snippet: c.snippet });
855
+ }
720
856
  }
721
857
  }
722
858
  } else if (v === "NC") {
@@ -945,6 +1081,11 @@ const residualUndecidableReason = () => "Left as an explicit residual risk (not
945
1081
  * contract violation rather than a bare « to assess ». */
946
1082
  const residualRejectedReason = (why: string) => `Adjudication refused by the gate — ${why.replace(/^criterion \S+: /, "")}. Re-adjudicate this criterion.`;
947
1083
 
1084
+ /** Characters of a harvested snippet the brief prints. Long enough that the element is
1085
+ * unmistakable — its tag and its first distinctive attributes — and short enough that a page
1086
+ * snapshot's markup does not swamp a model's context. */
1087
+ const SNIPPET_SHOWN = 200;
1088
+
948
1089
  // ---- worklist file rendering ----
949
1090
  const T = {
950
1091
  fr: {
@@ -965,6 +1106,7 @@ const T = {
965
1106
  pagesWord: "page(s)",
966
1107
  on: "sur",
967
1108
  alsoAt: "aussi en",
1109
+ snippetLabel: "`snippet` à copier dans la citation",
968
1110
  incomplete: "LECTURE INCOMPLÈTE — un « C » sera refusé sur ce critère",
969
1111
  none: "(aucune évidence automatique — décidez depuis la source, ou laissez `manual` avec une raison)",
970
1112
  questions: "À vérifier manuellement",
@@ -995,6 +1137,7 @@ const T = {
995
1137
  pagesWord: "page(s)",
996
1138
  on: "on",
997
1139
  alsoAt: "also at",
1140
+ snippetLabel: "`snippet` to copy into the citation",
998
1141
  incomplete: "INCOMPLETE READING — a C will be refused on this criterion",
999
1142
  none: "(no automatic evidence — decide from source, or leave `manual` with a reason)",
1000
1143
  questions: "To verify manually",
@@ -1152,6 +1295,23 @@ export function formatAdjudication(
1152
1295
  .filter(Boolean)
1153
1296
  .join(" ");
1154
1297
  out.push(`- \`${e.file}:${e.line}\` (\`${e.selector}\`)${extra ? ` [${extra}]` : ""}${e.note ? ` — ${e.note}` : ""}`);
1298
+ // THE SNIPPET, SHOWN AND NAMED.
1299
+ //
1300
+ // The contract asks a citation to carry one, and this brief used to end each line with
1301
+ // the harvester's NOTE — which for an image subject reads `<svg> alt="" aria-label=""
1302
+ // src=""`. That is markup-shaped, and it was the only markup-shaped string on the line.
1303
+ // Measured on a real run: the adjudicator copied it into 34 citations, each one a tag
1304
+ // glued to three empty attributes, and the fold refused every one — correctly, and
1305
+ // uselessly, because a citation that repeats a template proves nothing. The agent was
1306
+ // copying the only thing it had been given.
1307
+ //
1308
+ // Its own line, and labelled, so it cannot be read as more of the note. Bounded because
1309
+ // a rendered snapshot's markup can be long and this file is read by a model with a
1310
+ // context to spend.
1311
+ if (e.snippet?.trim()) {
1312
+ const snip = e.snippet.trim().replace(/\s+/g, " ").slice(0, SNIPPET_SHOWN);
1313
+ out.push(` - ${s.snippetLabel} : \`${snip}${e.snippet.trim().length > SNIPPET_SHOWN ? "…" : ""}\``);
1314
+ }
1155
1315
  }
1156
1316
  out.push("");
1157
1317
  }
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.0";
4
4
  export const SCHEMA_VERSION = 2;
5
5
 
6
6
  // Lang = the UI FRAME's language — the `L` localization tables each module (report,