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.
- package/.claude-plugin/marketplace.json +1 -1
- package/.claude-plugin/plugin.json +1 -1
- package/.codex-plugin/plugin.json +1 -1
- package/.opencode/plugins/ultra11y.js +1 -1
- package/package.json +1 -1
- package/scripts/ultra11y.mjs +99 -5
- package/skills/review-a11y/SKILL.md +1 -1
- package/skills/review-a11y/scripts/ultra11y.mjs +99 -5
- package/skills/ultra11y/SKILL.md +1 -1
- package/skills/ultra11y/scripts/ultra11y.mjs +99 -5
- package/src/adjudicate.ts +162 -2
- package/src/annotate.ts +40 -2
- package/src/types.ts +1 -1
|
@@ -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.
|
|
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.
|
|
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.
|
|
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.
|
|
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.
|
|
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",
|
package/scripts/ultra11y.mjs
CHANGED
|
@@ -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.
|
|
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
|
-
|
|
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
|
-
|
|
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
|
+
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.
|
|
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
|
-
|
|
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
|
-
|
|
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/skills/ultra11y/SKILL.md
CHANGED
|
@@ -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.
|
|
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.
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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.
|
|
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,
|