ultra11y 5.33.0 → 5.33.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 +28 -20
- package/skills/review-a11y/SKILL.md +1 -1
- package/skills/review-a11y/scripts/ultra11y.mjs +28 -20
- package/skills/ultra11y/SKILL.md +1 -1
- package/skills/ultra11y/scripts/ultra11y.mjs +28 -20
- package/src/adjudicate.ts +20 -17
- package/src/standards/pack.ts +53 -12
- 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.33.
|
|
13
|
+
"version": "5.33.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.33.
|
|
4
|
+
"version": "5.33.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.33.
|
|
4
|
+
"version": "5.33.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.33.
|
|
30
|
+
const ULTRA11Y_PLUGIN_VERSION = "5.33.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.33.
|
|
3
|
+
"version": "5.33.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 join52, 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.33.
|
|
9
|
+
var VERSION = "5.33.1";
|
|
10
10
|
var SCHEMA_VERSION = 2;
|
|
11
11
|
var INAPPLICABLE_STATUS = "C";
|
|
12
12
|
|
|
@@ -50090,16 +50090,28 @@ var MAX_SIBLINGS = 3;
|
|
|
50090
50090
|
function siblingCriteria(pack, id, lang = pack.defaultLocale) {
|
|
50091
50091
|
const c2 = getCriterion(pack, id);
|
|
50092
50092
|
if (!c2) return [];
|
|
50093
|
-
|
|
50093
|
+
return neighbourhood(pack, c2).slice(0, MAX_SIBLINGS).map(({ crit }) => sibling(pack, crit, lang));
|
|
50094
|
+
}
|
|
50095
|
+
var isMechanical = (x) => !x.judgment && (x.appliesTo?.ruleIds ?? []).length > 0;
|
|
50096
|
+
var sibling = (pack, x, lang) => ({
|
|
50097
|
+
id: x.id,
|
|
50098
|
+
// The plain title is the whole argument: « a-t-il une étiquette ? » beside « cette
|
|
50099
|
+
// étiquette est-elle pertinente ? » explains the split better than any prose could.
|
|
50100
|
+
title: localize(pack, x.titlePlain ?? x.title, lang),
|
|
50101
|
+
role: isMechanical(x) ? "mechanical" : "judgment"
|
|
50102
|
+
});
|
|
50103
|
+
function neighbourhood(pack, c2) {
|
|
50094
50104
|
const mine = new Set(c2.wcag ?? []);
|
|
50095
|
-
const
|
|
50096
|
-
return pack.criteria.filter((
|
|
50097
|
-
|
|
50098
|
-
|
|
50099
|
-
|
|
50100
|
-
|
|
50101
|
-
|
|
50102
|
-
|
|
50105
|
+
const mech = isMechanical(c2);
|
|
50106
|
+
return pack.criteria.map((crit) => ({ crit, shared: (crit.wcag ?? []).filter((w) => mine.has(w)).length })).filter(({ crit, shared }) => crit.id !== c2.id && crit.theme === c2.theme && shared > 0 && isMechanical(crit) !== mech).sort((a, b) => b.shared - a.shared || a.crit.id.localeCompare(b.crit.id, void 0, { numeric: true }));
|
|
50107
|
+
}
|
|
50108
|
+
function presupposedCriterion(pack, id, lang = pack.defaultLocale) {
|
|
50109
|
+
const c2 = getCriterion(pack, id);
|
|
50110
|
+
if (!c2 || isMechanical(c2)) return void 0;
|
|
50111
|
+
const ranked = neighbourhood(pack, c2).filter(({ crit }) => isMechanical(crit));
|
|
50112
|
+
const first = ranked[0];
|
|
50113
|
+
if (!first || ranked[1]?.shared === first.shared) return void 0;
|
|
50114
|
+
return sibling(pack, first.crit, lang);
|
|
50103
50115
|
}
|
|
50104
50116
|
function criterionUrl(pack, id) {
|
|
50105
50117
|
return pack.criterionUrl ? pack.criterionUrl.replaceAll("{id}", id) : void 0;
|
|
@@ -59599,16 +59611,12 @@ function applyAdjudication(audit2, adj, opts = {}) {
|
|
|
59599
59611
|
);
|
|
59600
59612
|
}
|
|
59601
59613
|
if (!isCore(adj.standard) && f.file?.trim()) {
|
|
59602
|
-
const
|
|
59603
|
-
if (
|
|
59604
|
-
|
|
59605
|
-
|
|
59606
|
-
|
|
59607
|
-
|
|
59608
|
-
it.criteriaId,
|
|
59609
|
-
`criterion ${it.criteriaId}: this anchor (${f.file}:${f.line}) is already the engine's non-conformity on ${adj.standard} ${clash.id} \u2014 \xAB ${clash.title} \xBB. ${it.criteriaId} asks the NEXT question about the same subject and presupposes it is there, so on this element it is not non-conformant: it has no subject. Report it on ${clash.id} (the engine already did), or rule ${it.criteriaId} on a different element.`
|
|
59610
|
-
);
|
|
59611
|
-
}
|
|
59614
|
+
const presupposed = presupposedCriterion(loadPack(adj.standard), it.criteriaId);
|
|
59615
|
+
if (presupposed && engineNcAt(anchorKey(f.file, f.line, f.selector ?? "")).has(presupposed.id))
|
|
59616
|
+
blame(
|
|
59617
|
+
it.criteriaId,
|
|
59618
|
+
`criterion ${it.criteriaId}: this anchor (${f.file}:${f.line}) is already the engine's non-conformity on ${adj.standard} ${presupposed.id} \u2014 \xAB ${presupposed.title} \xBB. ${it.criteriaId} asks the NEXT question about the same subject and presupposes it is there, so on this element it is not non-conformant: it has no subject. Report it on ${presupposed.id} (the engine already did), or rule ${it.criteriaId} on a different element.`
|
|
59619
|
+
);
|
|
59612
59620
|
}
|
|
59613
59621
|
toGround(it.criteriaId, { file: f.file, line: f.line, selector: f.selector, snippet: f.snippet });
|
|
59614
59622
|
}
|
|
@@ -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.33.
|
|
8
|
+
version: 5.33.1
|
|
9
9
|
---
|
|
10
10
|
|
|
11
11
|
# review-a11y — accessibility review of changed code
|
|
@@ -6,7 +6,7 @@ import { join as join52, 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.33.
|
|
9
|
+
var VERSION = "5.33.1";
|
|
10
10
|
var SCHEMA_VERSION = 2;
|
|
11
11
|
var INAPPLICABLE_STATUS = "C";
|
|
12
12
|
|
|
@@ -50090,16 +50090,28 @@ var MAX_SIBLINGS = 3;
|
|
|
50090
50090
|
function siblingCriteria(pack, id, lang = pack.defaultLocale) {
|
|
50091
50091
|
const c2 = getCriterion(pack, id);
|
|
50092
50092
|
if (!c2) return [];
|
|
50093
|
-
|
|
50093
|
+
return neighbourhood(pack, c2).slice(0, MAX_SIBLINGS).map(({ crit }) => sibling(pack, crit, lang));
|
|
50094
|
+
}
|
|
50095
|
+
var isMechanical = (x) => !x.judgment && (x.appliesTo?.ruleIds ?? []).length > 0;
|
|
50096
|
+
var sibling = (pack, x, lang) => ({
|
|
50097
|
+
id: x.id,
|
|
50098
|
+
// The plain title is the whole argument: « a-t-il une étiquette ? » beside « cette
|
|
50099
|
+
// étiquette est-elle pertinente ? » explains the split better than any prose could.
|
|
50100
|
+
title: localize(pack, x.titlePlain ?? x.title, lang),
|
|
50101
|
+
role: isMechanical(x) ? "mechanical" : "judgment"
|
|
50102
|
+
});
|
|
50103
|
+
function neighbourhood(pack, c2) {
|
|
50094
50104
|
const mine = new Set(c2.wcag ?? []);
|
|
50095
|
-
const
|
|
50096
|
-
return pack.criteria.filter((
|
|
50097
|
-
|
|
50098
|
-
|
|
50099
|
-
|
|
50100
|
-
|
|
50101
|
-
|
|
50102
|
-
|
|
50105
|
+
const mech = isMechanical(c2);
|
|
50106
|
+
return pack.criteria.map((crit) => ({ crit, shared: (crit.wcag ?? []).filter((w) => mine.has(w)).length })).filter(({ crit, shared }) => crit.id !== c2.id && crit.theme === c2.theme && shared > 0 && isMechanical(crit) !== mech).sort((a, b) => b.shared - a.shared || a.crit.id.localeCompare(b.crit.id, void 0, { numeric: true }));
|
|
50107
|
+
}
|
|
50108
|
+
function presupposedCriterion(pack, id, lang = pack.defaultLocale) {
|
|
50109
|
+
const c2 = getCriterion(pack, id);
|
|
50110
|
+
if (!c2 || isMechanical(c2)) return void 0;
|
|
50111
|
+
const ranked = neighbourhood(pack, c2).filter(({ crit }) => isMechanical(crit));
|
|
50112
|
+
const first = ranked[0];
|
|
50113
|
+
if (!first || ranked[1]?.shared === first.shared) return void 0;
|
|
50114
|
+
return sibling(pack, first.crit, lang);
|
|
50103
50115
|
}
|
|
50104
50116
|
function criterionUrl(pack, id) {
|
|
50105
50117
|
return pack.criterionUrl ? pack.criterionUrl.replaceAll("{id}", id) : void 0;
|
|
@@ -59599,16 +59611,12 @@ function applyAdjudication(audit2, adj, opts = {}) {
|
|
|
59599
59611
|
);
|
|
59600
59612
|
}
|
|
59601
59613
|
if (!isCore(adj.standard) && f.file?.trim()) {
|
|
59602
|
-
const
|
|
59603
|
-
if (
|
|
59604
|
-
|
|
59605
|
-
|
|
59606
|
-
|
|
59607
|
-
|
|
59608
|
-
it.criteriaId,
|
|
59609
|
-
`criterion ${it.criteriaId}: this anchor (${f.file}:${f.line}) is already the engine's non-conformity on ${adj.standard} ${clash.id} \u2014 \xAB ${clash.title} \xBB. ${it.criteriaId} asks the NEXT question about the same subject and presupposes it is there, so on this element it is not non-conformant: it has no subject. Report it on ${clash.id} (the engine already did), or rule ${it.criteriaId} on a different element.`
|
|
59610
|
-
);
|
|
59611
|
-
}
|
|
59614
|
+
const presupposed = presupposedCriterion(loadPack(adj.standard), it.criteriaId);
|
|
59615
|
+
if (presupposed && engineNcAt(anchorKey(f.file, f.line, f.selector ?? "")).has(presupposed.id))
|
|
59616
|
+
blame(
|
|
59617
|
+
it.criteriaId,
|
|
59618
|
+
`criterion ${it.criteriaId}: this anchor (${f.file}:${f.line}) is already the engine's non-conformity on ${adj.standard} ${presupposed.id} \u2014 \xAB ${presupposed.title} \xBB. ${it.criteriaId} asks the NEXT question about the same subject and presupposes it is there, so on this element it is not non-conformant: it has no subject. Report it on ${presupposed.id} (the engine already did), or rule ${it.criteriaId} on a different element.`
|
|
59619
|
+
);
|
|
59612
59620
|
}
|
|
59613
59621
|
toGround(it.criteriaId, { file: f.file, line: f.line, selector: f.selector, snippet: f.snippet });
|
|
59614
59622
|
}
|
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.33.
|
|
7
|
+
version: 5.33.1
|
|
8
8
|
---
|
|
9
9
|
|
|
10
10
|
# ultra11y — audit WCAG 2.2 AA and write accessible markup
|
|
@@ -6,7 +6,7 @@ import { join as join52, 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.33.
|
|
9
|
+
var VERSION = "5.33.1";
|
|
10
10
|
var SCHEMA_VERSION = 2;
|
|
11
11
|
var INAPPLICABLE_STATUS = "C";
|
|
12
12
|
|
|
@@ -50090,16 +50090,28 @@ var MAX_SIBLINGS = 3;
|
|
|
50090
50090
|
function siblingCriteria(pack, id, lang = pack.defaultLocale) {
|
|
50091
50091
|
const c2 = getCriterion(pack, id);
|
|
50092
50092
|
if (!c2) return [];
|
|
50093
|
-
|
|
50093
|
+
return neighbourhood(pack, c2).slice(0, MAX_SIBLINGS).map(({ crit }) => sibling(pack, crit, lang));
|
|
50094
|
+
}
|
|
50095
|
+
var isMechanical = (x) => !x.judgment && (x.appliesTo?.ruleIds ?? []).length > 0;
|
|
50096
|
+
var sibling = (pack, x, lang) => ({
|
|
50097
|
+
id: x.id,
|
|
50098
|
+
// The plain title is the whole argument: « a-t-il une étiquette ? » beside « cette
|
|
50099
|
+
// étiquette est-elle pertinente ? » explains the split better than any prose could.
|
|
50100
|
+
title: localize(pack, x.titlePlain ?? x.title, lang),
|
|
50101
|
+
role: isMechanical(x) ? "mechanical" : "judgment"
|
|
50102
|
+
});
|
|
50103
|
+
function neighbourhood(pack, c2) {
|
|
50094
50104
|
const mine = new Set(c2.wcag ?? []);
|
|
50095
|
-
const
|
|
50096
|
-
return pack.criteria.filter((
|
|
50097
|
-
|
|
50098
|
-
|
|
50099
|
-
|
|
50100
|
-
|
|
50101
|
-
|
|
50102
|
-
|
|
50105
|
+
const mech = isMechanical(c2);
|
|
50106
|
+
return pack.criteria.map((crit) => ({ crit, shared: (crit.wcag ?? []).filter((w) => mine.has(w)).length })).filter(({ crit, shared }) => crit.id !== c2.id && crit.theme === c2.theme && shared > 0 && isMechanical(crit) !== mech).sort((a, b) => b.shared - a.shared || a.crit.id.localeCompare(b.crit.id, void 0, { numeric: true }));
|
|
50107
|
+
}
|
|
50108
|
+
function presupposedCriterion(pack, id, lang = pack.defaultLocale) {
|
|
50109
|
+
const c2 = getCriterion(pack, id);
|
|
50110
|
+
if (!c2 || isMechanical(c2)) return void 0;
|
|
50111
|
+
const ranked = neighbourhood(pack, c2).filter(({ crit }) => isMechanical(crit));
|
|
50112
|
+
const first = ranked[0];
|
|
50113
|
+
if (!first || ranked[1]?.shared === first.shared) return void 0;
|
|
50114
|
+
return sibling(pack, first.crit, lang);
|
|
50103
50115
|
}
|
|
50104
50116
|
function criterionUrl(pack, id) {
|
|
50105
50117
|
return pack.criterionUrl ? pack.criterionUrl.replaceAll("{id}", id) : void 0;
|
|
@@ -59599,16 +59611,12 @@ function applyAdjudication(audit2, adj, opts = {}) {
|
|
|
59599
59611
|
);
|
|
59600
59612
|
}
|
|
59601
59613
|
if (!isCore(adj.standard) && f.file?.trim()) {
|
|
59602
|
-
const
|
|
59603
|
-
if (
|
|
59604
|
-
|
|
59605
|
-
|
|
59606
|
-
|
|
59607
|
-
|
|
59608
|
-
it.criteriaId,
|
|
59609
|
-
`criterion ${it.criteriaId}: this anchor (${f.file}:${f.line}) is already the engine's non-conformity on ${adj.standard} ${clash.id} \u2014 \xAB ${clash.title} \xBB. ${it.criteriaId} asks the NEXT question about the same subject and presupposes it is there, so on this element it is not non-conformant: it has no subject. Report it on ${clash.id} (the engine already did), or rule ${it.criteriaId} on a different element.`
|
|
59610
|
-
);
|
|
59611
|
-
}
|
|
59614
|
+
const presupposed = presupposedCriterion(loadPack(adj.standard), it.criteriaId);
|
|
59615
|
+
if (presupposed && engineNcAt(anchorKey(f.file, f.line, f.selector ?? "")).has(presupposed.id))
|
|
59616
|
+
blame(
|
|
59617
|
+
it.criteriaId,
|
|
59618
|
+
`criterion ${it.criteriaId}: this anchor (${f.file}:${f.line}) is already the engine's non-conformity on ${adj.standard} ${presupposed.id} \u2014 \xAB ${presupposed.title} \xBB. ${it.criteriaId} asks the NEXT question about the same subject and presupposes it is there, so on this element it is not non-conformant: it has no subject. Report it on ${presupposed.id} (the engine already did), or rule ${it.criteriaId} on a different element.`
|
|
59619
|
+
);
|
|
59612
59620
|
}
|
|
59613
59621
|
toGround(it.criteriaId, { file: f.file, line: f.line, selector: f.selector, snippet: f.snippet });
|
|
59614
59622
|
}
|
package/src/adjudicate.ts
CHANGED
|
@@ -36,6 +36,7 @@ import {
|
|
|
36
36
|
localize,
|
|
37
37
|
resolveGlossary,
|
|
38
38
|
siblingCriteria,
|
|
39
|
+
presupposedCriterion,
|
|
39
40
|
type StandardPack,
|
|
40
41
|
type PackCriterion,
|
|
41
42
|
} from "./standards/index.js";
|
|
@@ -992,27 +993,29 @@ export function applyAdjudication(
|
|
|
992
993
|
// really does ground.
|
|
993
994
|
//
|
|
994
995
|
// Narrow on purpose, and this is the one check here that could refuse a true finding.
|
|
995
|
-
// It fires only when all three hold: the criterion under verdict
|
|
996
|
-
// neighbour (`
|
|
997
|
-
//
|
|
998
|
-
//
|
|
999
|
-
//
|
|
1000
|
-
//
|
|
1001
|
-
//
|
|
996
|
+
// It fires only when all three hold: the criterion under verdict PRESUPPOSES a
|
|
997
|
+
// mechanical neighbour's subject (`presupposedCriterion`), the engine has already ruled
|
|
998
|
+
// that neighbour non-conformant, and the anchor is literally the same file, line and
|
|
999
|
+
// selector.
|
|
1000
|
+
//
|
|
1001
|
+
// It read `siblingCriteria` before, and that was too wide by exactly the margin that
|
|
1002
|
+
// matters. The neighbourhood is a reading aid — up to three criteria, no claim that any
|
|
1003
|
+
// of them comes first — so 11.4 (« l'étiquette est-elle accolée à son champ ? ») counted
|
|
1004
|
+
// 11.5 (« les champs de même nature sont-ils regroupés ? ») as a question it
|
|
1005
|
+
// presupposes, and a radio group the engine ruled ungrouped became immune to 11.4 on
|
|
1006
|
+
// the same control. Two defects on one element are not one defect charged twice.
|
|
1007
|
+
// `presupposedCriterion` answers only where the pack singles ONE neighbour out and
|
|
1008
|
+
// stands down on a tie, which keeps 11.2/11.1 and 8.6/8.5 and drops the guesses.
|
|
1002
1009
|
//
|
|
1003
1010
|
// Refused per verdict like everything else: the criterion returns to « to assess »
|
|
1004
1011
|
// carrying the reason, naming the neighbour, so the next pass can file it correctly.
|
|
1005
1012
|
if (!isCore(adj.standard) && f.file?.trim()) {
|
|
1006
|
-
const
|
|
1007
|
-
if (
|
|
1008
|
-
|
|
1009
|
-
|
|
1010
|
-
|
|
1011
|
-
|
|
1012
|
-
it.criteriaId,
|
|
1013
|
-
`criterion ${it.criteriaId}: this anchor (${f.file}:${f.line}) is already the engine's non-conformity on ${adj.standard} ${clash.id} — « ${clash.title} ». ${it.criteriaId} asks the NEXT question about the same subject and presupposes it is there, so on this element it is not non-conformant: it has no subject. Report it on ${clash.id} (the engine already did), or rule ${it.criteriaId} on a different element.`,
|
|
1014
|
-
);
|
|
1015
|
-
}
|
|
1013
|
+
const presupposed = presupposedCriterion(loadPack(adj.standard), it.criteriaId);
|
|
1014
|
+
if (presupposed && engineNcAt(anchorKey(f.file, f.line, f.selector ?? "")).has(presupposed.id))
|
|
1015
|
+
blame(
|
|
1016
|
+
it.criteriaId,
|
|
1017
|
+
`criterion ${it.criteriaId}: this anchor (${f.file}:${f.line}) is already the engine's non-conformity on ${adj.standard} ${presupposed.id} — « ${presupposed.title} ». ${it.criteriaId} asks the NEXT question about the same subject and presupposes it is there, so on this element it is not non-conformant: it has no subject. Report it on ${presupposed.id} (the engine already did), or rule ${it.criteriaId} on a different element.`,
|
|
1018
|
+
);
|
|
1016
1019
|
}
|
|
1017
1020
|
toGround(it.criteriaId, { file: f.file, line: f.line, selector: f.selector, snippet: f.snippet });
|
|
1018
1021
|
}
|
package/src/standards/pack.ts
CHANGED
|
@@ -134,20 +134,61 @@ export interface SiblingCriterion {
|
|
|
134
134
|
export function siblingCriteria(pack: StandardPack, id: string, lang: string = pack.defaultLocale): SiblingCriterion[] {
|
|
135
135
|
const c = getCriterion(pack, id);
|
|
136
136
|
if (!c) return [];
|
|
137
|
-
|
|
137
|
+
return neighbourhood(pack, c)
|
|
138
|
+
.slice(0, MAX_SIBLINGS)
|
|
139
|
+
.map(({ crit }) => sibling(pack, crit, lang));
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
/** Mechanical: an engine rule can fail it. A criterion the pack declares `judgment`, and one
|
|
143
|
+
* that simply carries no rule, are both on the other side of the line. */
|
|
144
|
+
const isMechanical = (x: PackCriterion) => !x.judgment && (x.appliesTo?.ruleIds ?? []).length > 0;
|
|
145
|
+
|
|
146
|
+
const sibling = (pack: StandardPack, x: PackCriterion, lang: string): SiblingCriterion => ({
|
|
147
|
+
id: x.id,
|
|
148
|
+
// The plain title is the whole argument: « a-t-il une étiquette ? » beside « cette
|
|
149
|
+
// étiquette est-elle pertinente ? » explains the split better than any prose could.
|
|
150
|
+
title: localize(pack, x.titlePlain ?? x.title, lang),
|
|
151
|
+
role: isMechanical(x) ? "mechanical" : "judgment",
|
|
152
|
+
});
|
|
153
|
+
|
|
154
|
+
/** The full ranked neighbourhood, BEFORE the reading-aid cap — closest first, ties in
|
|
155
|
+
* criterion order. `siblingCriteria` shows the top of it; `presupposedCriterion` needs the
|
|
156
|
+
* whole thing, because a cap can hide the very tie that disqualifies a refusal. */
|
|
157
|
+
function neighbourhood(pack: StandardPack, c: PackCriterion): { crit: PackCriterion; shared: number }[] {
|
|
138
158
|
const mine = new Set(c.wcag ?? []);
|
|
139
|
-
const
|
|
159
|
+
const mech = isMechanical(c);
|
|
140
160
|
return pack.criteria
|
|
141
|
-
.
|
|
142
|
-
.
|
|
143
|
-
.
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
161
|
+
.map((crit) => ({ crit, shared: (crit.wcag ?? []).filter((w) => mine.has(w)).length }))
|
|
162
|
+
.filter(({ crit, shared }) => crit.id !== c.id && crit.theme === c.theme && shared > 0 && isMechanical(crit) !== mech)
|
|
163
|
+
.sort((a, b) => b.shared - a.shared || a.crit.id.localeCompare(b.crit.id, undefined, { numeric: true }));
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
/** THE NEIGHBOUR WHOSE SUBJECT THIS CRITERION PRESUPPOSES — or none, and none is the honest
|
|
167
|
+
* answer far more often than the neighbourhood is empty.
|
|
168
|
+
*
|
|
169
|
+
* `siblingCriteria` is a READING AID: « this observation may belong next door », three
|
|
170
|
+
* candidates, no claim that any one of them comes first. That breadth is right for a brief
|
|
171
|
+
* and wrong for a refusal, and the difference cost a true finding. RGAA 11.4 (« l'étiquette
|
|
172
|
+
* est-elle accolée à son champ ? ») neighbours 11.1, 11.5 and 11.6, each exactly one success
|
|
173
|
+
* criterion away. Only 11.1 owns 11.4's subject: with no label there is nothing to place.
|
|
174
|
+
* 11.5 (« les champs de même nature sont-ils regroupés ? ») owns none of it — a radio group
|
|
175
|
+
* that was never grouped still has labels, and they are still placed well or badly. Reading
|
|
176
|
+
* the neighbourhood as a presupposition made an ungrouped radio immune to 11.4.
|
|
177
|
+
*
|
|
178
|
+
* So: ONE neighbour, strictly closer than every other, mechanical (the side that can be
|
|
179
|
+
* failed without a model, which is what makes « the engine already owns this anchor » sayable
|
|
180
|
+
* at all). A tie is the pack declining to say which question comes first, and a refusal on a
|
|
181
|
+
* tie is a guess made against a finding that may well be true. On RGAA the rule keeps both
|
|
182
|
+
* pairs the split was drawn for — 11.2 → 11.1 (two shared success criteria against one) and
|
|
183
|
+
* 8.6 → 8.5 (sole neighbour) — and stands down on the thirteen criteria whose neighbours are
|
|
184
|
+
* equidistant. */
|
|
185
|
+
export function presupposedCriterion(pack: StandardPack, id: string, lang: string = pack.defaultLocale): SiblingCriterion | undefined {
|
|
186
|
+
const c = getCriterion(pack, id);
|
|
187
|
+
if (!c || isMechanical(c)) return undefined;
|
|
188
|
+
const ranked = neighbourhood(pack, c).filter(({ crit }) => isMechanical(crit));
|
|
189
|
+
const first = ranked[0];
|
|
190
|
+
if (!first || ranked[1]?.shared === first.shared) return undefined;
|
|
191
|
+
return sibling(pack, first.crit, lang);
|
|
151
192
|
}
|
|
152
193
|
|
|
153
194
|
/** Where the standard publishes this criterion, from the pack's `criterionUrl` template.
|
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.33.
|
|
3
|
+
export const VERSION = "5.33.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,
|