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