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.
@@ -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.0",
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.0",
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.0",
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.0";
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.0",
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",
@@ -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.0";
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
- const mechanical = (x) => !x.judgment && (x.appliesTo?.ruleIds ?? []).length > 0;
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 shared = (x) => (x.wcag ?? []).filter((w) => mine.has(w)).length;
50096
- return pack.criteria.filter((x) => x.id !== c2.id && x.theme === c2.theme && shared(x) > 0 && mechanical(x) !== mechanical(c2)).sort((a, b) => shared(b) - shared(a) || a.id.localeCompare(b.id, void 0, { numeric: true })).slice(0, MAX_SIBLINGS).map((x) => ({
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: mechanical(x) ? "mechanical" : "judgment"
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 mechanical = siblingCriteria(loadPack(adj.standard), it.criteriaId).filter((sib) => sib.role === "mechanical");
59603
- if (mechanical.length) {
59604
- const owners = engineNcAt(anchorKey(f.file, f.line, f.selector ?? ""));
59605
- const clash = mechanical.find((sib) => owners.has(sib.id));
59606
- if (clash)
59607
- blame(
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.0
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.0";
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
- const mechanical = (x) => !x.judgment && (x.appliesTo?.ruleIds ?? []).length > 0;
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 shared = (x) => (x.wcag ?? []).filter((w) => mine.has(w)).length;
50096
- return pack.criteria.filter((x) => x.id !== c2.id && x.theme === c2.theme && shared(x) > 0 && mechanical(x) !== mechanical(c2)).sort((a, b) => shared(b) - shared(a) || a.id.localeCompare(b.id, void 0, { numeric: true })).slice(0, MAX_SIBLINGS).map((x) => ({
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: mechanical(x) ? "mechanical" : "judgment"
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 mechanical = siblingCriteria(loadPack(adj.standard), it.criteriaId).filter((sib) => sib.role === "mechanical");
59603
- if (mechanical.length) {
59604
- const owners = engineNcAt(anchorKey(f.file, f.line, f.selector ?? ""));
59605
- const clash = mechanical.find((sib) => owners.has(sib.id));
59606
- if (clash)
59607
- blame(
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
  }
@@ -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.0
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.0";
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
- const mechanical = (x) => !x.judgment && (x.appliesTo?.ruleIds ?? []).length > 0;
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 shared = (x) => (x.wcag ?? []).filter((w) => mine.has(w)).length;
50096
- return pack.criteria.filter((x) => x.id !== c2.id && x.theme === c2.theme && shared(x) > 0 && mechanical(x) !== mechanical(c2)).sort((a, b) => shared(b) - shared(a) || a.id.localeCompare(b.id, void 0, { numeric: true })).slice(0, MAX_SIBLINGS).map((x) => ({
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: mechanical(x) ? "mechanical" : "judgment"
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 mechanical = siblingCriteria(loadPack(adj.standard), it.criteriaId).filter((sib) => sib.role === "mechanical");
59603
- if (mechanical.length) {
59604
- const owners = engineNcAt(anchorKey(f.file, f.line, f.selector ?? ""));
59605
- const clash = mechanical.find((sib) => owners.has(sib.id));
59606
- if (clash)
59607
- blame(
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 has a MECHANICAL
996
- // neighbour (`siblingCriteria` — same theme, shared success criterion, opposite side of
997
- // the line, which by construction means this criterion carries no engine rule of its
998
- // own), the engine has already ruled that neighbour non-conformant, and the anchor is
999
- // literally the same file, line and selector. Two criteria failing the same element for
1000
- // genuinely different reasons keep different anchors, or the neighbour is not
1001
- // mechanical, and neither reaches here.
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 mechanical = siblingCriteria(loadPack(adj.standard), it.criteriaId).filter((sib) => sib.role === "mechanical");
1007
- if (mechanical.length) {
1008
- const owners = engineNcAt(anchorKey(f.file, f.line, f.selector ?? ""));
1009
- const clash = mechanical.find((sib) => owners.has(sib.id));
1010
- if (clash)
1011
- blame(
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
  }
@@ -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
- const mechanical = (x: PackCriterion) => !x.judgment && (x.appliesTo?.ruleIds ?? []).length > 0;
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 shared = (x: PackCriterion) => (x.wcag ?? []).filter((w) => mine.has(w)).length;
159
+ const mech = isMechanical(c);
140
160
  return pack.criteria
141
- .filter((x) => x.id !== c.id && x.theme === c.theme && shared(x) > 0 && mechanical(x) !== mechanical(c))
142
- .sort((a, b) => shared(b) - shared(a) || a.id.localeCompare(b.id, undefined, { numeric: true }))
143
- .slice(0, MAX_SIBLINGS)
144
- .map((x) => ({
145
- id: x.id,
146
- // The plain title is the whole argument: « a-t-il une étiquette ? » beside « cette
147
- // étiquette est-elle pertinente ? » explains the split better than any prose could.
148
- title: localize(pack, x.titlePlain ?? x.title, lang),
149
- role: mechanical(x) ? ("mechanical" as const) : ("judgment" as const),
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.0";
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,