ultra11y 2.22.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (153) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +223 -0
  3. package/docker/Dockerfile +7 -0
  4. package/docker/package.json +9 -0
  5. package/docker/runner.mjs +23 -0
  6. package/package.json +73 -0
  7. package/scripts/bench.mjs +151 -0
  8. package/scripts/build-act-corpus.mjs +143 -0
  9. package/scripts/build-adjudication.mjs +503 -0
  10. package/scripts/build-pack-rgaa.mjs +397 -0
  11. package/scripts/build-standards.mjs +548 -0
  12. package/scripts/copy-bundle.mjs +39 -0
  13. package/scripts/import-pack.mjs +107 -0
  14. package/scripts/sync-engine.mjs +84 -0
  15. package/scripts/sync-version.mjs +43 -0
  16. package/scripts/ultra11y.mjs +52739 -0
  17. package/scripts/vendor/act-testcases.json +12297 -0
  18. package/scripts/vendor/rgaa/SOURCE.json +8 -0
  19. package/scripts/vendor/rgaa/criteres.json +4144 -0
  20. package/scripts/vendor/rgaa/glossaire.json +480 -0
  21. package/scripts/vendor/wcag-2.2-fr.json +82 -0
  22. package/scripts/vendor/wcag-2.2-sc-universe.json +950 -0
  23. package/scripts/vendor/wcag-2.2-sc.json +574 -0
  24. package/scripts/verify-rgaa-source.mjs +126 -0
  25. package/scripts/verify-skill-bundle.mjs +91 -0
  26. package/skills/review-a11y/SKILL.md +123 -0
  27. package/skills/review-a11y/scripts/ultra11y.mjs +52739 -0
  28. package/skills/ultra11y/SKILL.md +292 -0
  29. package/skills/ultra11y/references/act.md +163 -0
  30. package/skills/ultra11y/references/adjudication.md +599 -0
  31. package/skills/ultra11y/references/audit.md +100 -0
  32. package/skills/ultra11y/references/authoring.md +37 -0
  33. package/skills/ultra11y/references/automation.md +113 -0
  34. package/skills/ultra11y/references/correction.md +41 -0
  35. package/skills/ultra11y/references/criteria.md +129 -0
  36. package/skills/ultra11y/references/cross-file.md +82 -0
  37. package/skills/ultra11y/references/dynamic.md +152 -0
  38. package/skills/ultra11y/references/false-positives.md +81 -0
  39. package/skills/ultra11y/references/fix.md +46 -0
  40. package/skills/ultra11y/references/focus-and-logic.md +64 -0
  41. package/skills/ultra11y/references/forbidden-patterns.md +61 -0
  42. package/skills/ultra11y/references/forms-and-errors.md +147 -0
  43. package/skills/ultra11y/references/guidance.md +55 -0
  44. package/skills/ultra11y/references/judgment.md +90 -0
  45. package/skills/ultra11y/references/media-and-motion.md +116 -0
  46. package/skills/ultra11y/references/methodology.md +72 -0
  47. package/skills/ultra11y/references/naming.md +117 -0
  48. package/skills/ultra11y/references/orchestration.md +44 -0
  49. package/skills/ultra11y/references/packs.md +193 -0
  50. package/skills/ultra11y/references/prd.md +104 -0
  51. package/skills/ultra11y/references/rendered.md +110 -0
  52. package/skills/ultra11y/references/scale.md +49 -0
  53. package/skills/ultra11y/references/standards.md +97 -0
  54. package/skills/ultra11y/references/structure.md +138 -0
  55. package/skills/ultra11y/references/verify.md +88 -0
  56. package/skills/ultra11y/references/widgets.md +106 -0
  57. package/skills/ultra11y/scripts/ultra11y.mjs +52739 -0
  58. package/src/act.ts +441 -0
  59. package/src/adjudicate.ts +630 -0
  60. package/src/aria.ts +230 -0
  61. package/src/audit.ts +396 -0
  62. package/src/auditor.ts +399 -0
  63. package/src/autofill.ts +102 -0
  64. package/src/axe-map.ts +294 -0
  65. package/src/baseline.ts +75 -0
  66. package/src/capture.ts +182 -0
  67. package/src/check.ts +243 -0
  68. package/src/cli.ts +1927 -0
  69. package/src/color.ts +102 -0
  70. package/src/config.ts +182 -0
  71. package/src/crawl.ts +75 -0
  72. package/src/criteria.ts +183 -0
  73. package/src/data/adjudication.json +770 -0
  74. package/src/data/guidance/rgaa.json +2395 -0
  75. package/src/data/standards/rgaa.glossary.json +478 -0
  76. package/src/data/standards/rgaa.json +3454 -0
  77. package/src/data/wcag-universe.json +530 -0
  78. package/src/data/wcag.json +1178 -0
  79. package/src/discover.ts +203 -0
  80. package/src/fix/codemods.ts +76 -0
  81. package/src/fix/diff.ts +64 -0
  82. package/src/fix/edits.ts +126 -0
  83. package/src/fix.ts +215 -0
  84. package/src/gh.ts +214 -0
  85. package/src/glob.ts +206 -0
  86. package/src/graph/build.ts +114 -0
  87. package/src/graph/engine-resolve.ts +0 -0
  88. package/src/graph/graph.ts +126 -0
  89. package/src/graph/imports.ts +359 -0
  90. package/src/graph/resolve.ts +73 -0
  91. package/src/graph/tsconfig.ts +113 -0
  92. package/src/grounding.ts +119 -0
  93. package/src/guidance/index.ts +45 -0
  94. package/src/guidance/types.ts +36 -0
  95. package/src/init.ts +113 -0
  96. package/src/mcp/handlers.ts +388 -0
  97. package/src/mcp/http.ts +260 -0
  98. package/src/mcp/prompts.ts +183 -0
  99. package/src/mcp/protocol.ts +187 -0
  100. package/src/mcp/resources.ts +136 -0
  101. package/src/mcp/server.ts +228 -0
  102. package/src/mcp/stdio.ts +119 -0
  103. package/src/mcp/tools.ts +347 -0
  104. package/src/messages.ts +1011 -0
  105. package/src/name.ts +174 -0
  106. package/src/orchestrate-templates.ts +231 -0
  107. package/src/orchestrate.ts +186 -0
  108. package/src/output.ts +108 -0
  109. package/src/pack.ts +187 -0
  110. package/src/parse/html.ts +258 -0
  111. package/src/parse/jsx-ast.ts +67 -0
  112. package/src/parse/jsx-bridge.ts +247 -0
  113. package/src/parse/jsx.ts +19 -0
  114. package/src/parse/source.ts +75 -0
  115. package/src/prd.ts +406 -0
  116. package/src/project-lock.ts +42 -0
  117. package/src/render.ts +307 -0
  118. package/src/report.ts +399 -0
  119. package/src/rules/colors.ts +125 -0
  120. package/src/rules/cross-registry.ts +264 -0
  121. package/src/rules/cross-rule.ts +83 -0
  122. package/src/rules/forms.ts +590 -0
  123. package/src/rules/frames.ts +33 -0
  124. package/src/rules/headings.ts +227 -0
  125. package/src/rules/images.ts +200 -0
  126. package/src/rules/links.ts +193 -0
  127. package/src/rules/mandatory.ts +173 -0
  128. package/src/rules/multimedia.ts +77 -0
  129. package/src/rules/navigation.ts +190 -0
  130. package/src/rules/presentation.ts +75 -0
  131. package/src/rules/registry.ts +57 -0
  132. package/src/rules/rule.ts +122 -0
  133. package/src/rules/scripts-aria.ts +636 -0
  134. package/src/rules/tables.ts +264 -0
  135. package/src/rules/timing.ts +55 -0
  136. package/src/sample.ts +176 -0
  137. package/src/scan-local.ts +866 -0
  138. package/src/scan.ts +538 -0
  139. package/src/standards/derive.ts +195 -0
  140. package/src/standards/index.ts +31 -0
  141. package/src/standards/pack-rules.ts +111 -0
  142. package/src/standards/pack.ts +81 -0
  143. package/src/standards/registry.ts +100 -0
  144. package/src/standards/types.ts +202 -0
  145. package/src/standards/validate.ts +544 -0
  146. package/src/standards/vocabulary.ts +75 -0
  147. package/src/types.ts +355 -0
  148. package/src/util.ts +32 -0
  149. package/src/vendor/codeindex-engine.d.mts +1009 -0
  150. package/src/vendor/codeindex-engine.mjs +13701 -0
  151. package/src/vendor/engine.meta.json +9 -0
  152. package/src/verify.ts +275 -0
  153. package/src/wcag.ts +128 -0
@@ -0,0 +1,636 @@
1
+ // Theme 7 — Scripts / ARIA correctness (the statically-checkable slice).
2
+ import type { Doc, El } from "../parse/html.js";
3
+ import { attr, hasAttr, hasBoundAttr, hasDynamicSpread, descendants, ancestors } from "../parse/html.js";
4
+ import { isIntrinsic } from "../parse/jsx-bridge.js";
5
+ import { mayInjectContent, isHiddenFromAT, isDisplayHidden, isPresentational } from "../name.js";
6
+ import { ARIA_ATTRS, ARIA_REQUIRED_ATTRS, ARIA_REQUIRED_PARENT, IMPLICIT_CONTAINER_ROLE, NAME_PROHIBITED_ROLES, isValidAriaValue } from "../aria.js";
7
+ import type { Rule, RuleFinding } from "./rule.js";
8
+
9
+ const INTERACTIVE_ROLES = [
10
+ "button",
11
+ "link",
12
+ "checkbox",
13
+ "radio",
14
+ "tab",
15
+ "menuitem",
16
+ "menuitemcheckbox",
17
+ "menuitemradio",
18
+ "option",
19
+ "switch",
20
+ "textbox",
21
+ "combobox",
22
+ "slider",
23
+ "spinbutton",
24
+ ];
25
+
26
+ function isInteractive(el: El): boolean {
27
+ if (el.tag === "a") return hasAttr(el, "href");
28
+ if (["button", "select", "textarea"].includes(el.tag)) return true;
29
+ if (el.tag === "input") return (attr(el, "type") ?? "text").toLowerCase() !== "hidden";
30
+ return INTERACTIVE_ROLES.includes((attr(el, "role") ?? "").trim());
31
+ }
32
+
33
+ const DISABLEABLE = new Set(["button", "input", "select", "textarea", "fieldset", "optgroup", "option"]);
34
+ const isDisabled = (el: El): boolean => DISABLEABLE.has(el.tag) && hasAttr(el, "disabled");
35
+
36
+ function isFocusable(el: El): boolean {
37
+ // An explicit tabindex wins: -1 removes an element from the tab order (not keyboard-
38
+ // focusable), >=0 puts it in — even on an intrinsically-interactive element.
39
+ const tiRaw = attr(el, "tabindex");
40
+ if (tiRaw !== undefined) {
41
+ const ti = Number(tiRaw.trim());
42
+ if (!Number.isNaN(ti)) return ti >= 0;
43
+ }
44
+ if (isDisabled(el)) return false; // a disabled control is not focusable
45
+ if (isInteractive(el)) return true;
46
+ return hasAttr(el, "contenteditable") && attr(el, "contenteditable") !== "false";
47
+ }
48
+
49
+ // Concrete WAI-ARIA 1.2 roles (abstract roles like "widget"/"range" are invalid as author values).
50
+ const VALID_ROLES = new Set([
51
+ "alert",
52
+ "alertdialog",
53
+ "application",
54
+ "article",
55
+ "banner",
56
+ "blockquote",
57
+ "button",
58
+ "caption",
59
+ "cell",
60
+ "checkbox",
61
+ "code",
62
+ "columnheader",
63
+ "combobox",
64
+ "complementary",
65
+ "contentinfo",
66
+ "definition",
67
+ "deletion",
68
+ "dialog",
69
+ "directory",
70
+ "document",
71
+ "emphasis",
72
+ "feed",
73
+ "figure",
74
+ "form",
75
+ "generic",
76
+ "grid",
77
+ "gridcell",
78
+ "group",
79
+ "heading",
80
+ "img",
81
+ "insertion",
82
+ "link",
83
+ "list",
84
+ "listbox",
85
+ "listitem",
86
+ "log",
87
+ "main",
88
+ "marquee",
89
+ "math",
90
+ "menu",
91
+ "menubar",
92
+ "menuitem",
93
+ "menuitemcheckbox",
94
+ "menuitemradio",
95
+ "meter",
96
+ "navigation",
97
+ "none",
98
+ "note",
99
+ "option",
100
+ "paragraph",
101
+ "presentation",
102
+ "progressbar",
103
+ "radio",
104
+ "radiogroup",
105
+ "region",
106
+ "row",
107
+ "rowgroup",
108
+ "rowheader",
109
+ "scrollbar",
110
+ "search",
111
+ "searchbox",
112
+ "separator",
113
+ "slider",
114
+ "spinbutton",
115
+ "status",
116
+ "strong",
117
+ "subscript",
118
+ "superscript",
119
+ "switch",
120
+ "tab",
121
+ "table",
122
+ "tablist",
123
+ "tabpanel",
124
+ "term",
125
+ "textbox",
126
+ "time",
127
+ "timer",
128
+ "toolbar",
129
+ "tooltip",
130
+ "tree",
131
+ "treegrid",
132
+ "treeitem",
133
+ ]);
134
+
135
+ const IDREF_ATTRS = ["aria-labelledby", "aria-describedby", "aria-controls", "aria-owns", "aria-details", "aria-errormessage", "aria-flowto"];
136
+ const IDREF_SINGLE = ["aria-activedescendant"];
137
+
138
+ const invalidAriaRole: Rule = {
139
+ id: "invalid-aria-role",
140
+ criteria: ["4.1.2"],
141
+ severity: "majeur",
142
+ run(doc: Doc): RuleFinding[] {
143
+ const out: RuleFinding[] = [];
144
+ for (const el of doc.elements) {
145
+ if (!isIntrinsic(el.tag)) continue; // a component's `role` is a prop, not the HTML role attr
146
+ const role = (attr(el, "role") ?? "").trim();
147
+ if (!role) continue;
148
+ if (role.includes("{")) continue; // dynamic role expression (role={…}) — value unknown, can't validate
149
+ // A `role` attribute is a FALLBACK LIST: the first token that names a real role
150
+ // wins, so `role="doc-biblioref link"` is valid even though the first token is not
151
+ // a concrete WAI-ARIA role here. Only a list where NOTHING resolves is a defect.
152
+ const tokens = role.split(/\s+/);
153
+ const bad = tokens.filter((t) => !VALID_ROLES.has(t.toLowerCase()));
154
+ if (bad.length === tokens.length) {
155
+ out.push({
156
+ criteriaId: "4.1.2",
157
+ el,
158
+ msgId: "invalid-aria-role",
159
+ params: { roles: bad.join(" ") },
160
+ });
161
+ }
162
+ }
163
+ return out;
164
+ },
165
+ };
166
+
167
+ const ariaRefMissingId: Rule = {
168
+ id: "aria-ref-missing-id",
169
+ criteria: ["4.1.2"],
170
+ severity: "bloquant",
171
+ run(doc: Doc): RuleFinding[] {
172
+ const out: RuleFinding[] = [];
173
+ for (const el of doc.elements) {
174
+ const refs: { attr: string; ids: string[] }[] = [];
175
+ for (const a of IDREF_ATTRS) {
176
+ const v = attr(el, a);
177
+ // A JSX expression value (e.g. aria-describedby={err ? "x" : undefined}) is dynamic,
178
+ // not a literal id list — never split/verify it (would yield garbage ids).
179
+ if (v && !v.includes("{")) refs.push({ attr: a, ids: v.split(/\s+/).filter(Boolean) });
180
+ }
181
+ for (const a of IDREF_SINGLE) {
182
+ const v = (attr(el, a) ?? "").trim();
183
+ if (v && !v.includes("{")) refs.push({ attr: a, ids: [v] });
184
+ }
185
+ for (const { attr: a, ids } of refs) {
186
+ const missing = ids.filter((id) => !doc.byId.has(id));
187
+ if (missing.length) {
188
+ out.push({
189
+ criteriaId: "4.1.2",
190
+ el,
191
+ msgId: "aria-ref-missing-id",
192
+ params: { attr: a, ids: missing.map((m) => `#${m}`).join(", ") },
193
+ });
194
+ }
195
+ }
196
+ }
197
+ return out;
198
+ },
199
+ };
200
+
201
+ // implicit roles that make role="…" redundant
202
+ const IMPLICIT_ROLE: Record<string, string> = {
203
+ button: "button",
204
+ nav: "navigation",
205
+ main: "main",
206
+ ul: "list",
207
+ ol: "list",
208
+ li: "listitem",
209
+ table: "table",
210
+ h1: "heading",
211
+ h2: "heading",
212
+ h3: "heading",
213
+ h4: "heading",
214
+ h5: "heading",
215
+ h6: "heading",
216
+ form: "form",
217
+ article: "article",
218
+ aside: "complementary",
219
+ dialog: "dialog",
220
+ figure: "figure",
221
+ };
222
+
223
+ const redundantAria: Rule = {
224
+ id: "redundant-aria",
225
+ criteria: ["4.1.2"],
226
+ severity: "mineur",
227
+ run(doc: Doc): RuleFinding[] {
228
+ const out: RuleFinding[] = [];
229
+ for (const el of doc.elements) {
230
+ const role = (attr(el, "role") ?? "").trim().toLowerCase();
231
+ if (!role) continue;
232
+ let implicit = IMPLICIT_ROLE[el.tag];
233
+ if (el.tag === "a" && hasAttr(el, "href")) implicit = "link";
234
+ if (implicit && implicit === role) {
235
+ out.push({
236
+ criteriaId: "4.1.2",
237
+ el,
238
+ msgId: "redundant-aria",
239
+ params: { role, tag: el.tag },
240
+ });
241
+ }
242
+ }
243
+ return out;
244
+ },
245
+ };
246
+
247
+ const NONINTERACTIVE = new Set(["div", "span"]);
248
+
249
+ const clickableNoninteractive: Rule = {
250
+ id: "clickable-noninteractive",
251
+ criteria: ["4.1.2", "2.1.1"],
252
+ severity: "majeur",
253
+ run(doc: Doc): RuleFinding[] {
254
+ const out: RuleFinding[] = [];
255
+ for (const el of doc.elements) {
256
+ if (!NONINTERACTIVE.has(el.tag)) continue;
257
+ if (!hasAttr(el, "onclick")) continue;
258
+ const role = (attr(el, "role") ?? "").trim();
259
+ // Keyboard-focusable via tabindex means value >= 0 (a negative tabindex is not in the
260
+ // tab order); check the VALUE, not mere presence.
261
+ const tiRaw = (attr(el, "tabindex") ?? "").trim();
262
+ const hasTab = tiRaw !== "" && Number(tiRaw) >= 0;
263
+ const interactiveRole = INTERACTIVE_ROLES.includes(role); // the canonical list (radio/slider/…), not an ad-hoc subset
264
+ if (interactiveRole && hasTab) continue; // properly upgraded (interactive role + keyboard-focusable)
265
+ const noKeyboard = !hasTab;
266
+ out.push({
267
+ criteriaId: noKeyboard ? "2.1.1" : "4.1.2",
268
+ el,
269
+ msgId: "clickable-noninteractive",
270
+ params: { tag: el.tag, focusIssue: noKeyboard ? "no-tabindex" : "no-role" },
271
+ });
272
+ }
273
+ return out;
274
+ },
275
+ };
276
+
277
+ const REQUIRED_CHILDREN: Record<string, string[]> = {
278
+ list: ["listitem"],
279
+ tablist: ["tab"],
280
+ radiogroup: ["radio"],
281
+ tree: ["treeitem"],
282
+ menu: ["menuitem", "menuitemcheckbox", "menuitemradio"],
283
+ menubar: ["menuitem", "menuitemcheckbox", "menuitemradio"],
284
+ };
285
+
286
+ function satisfiesChild(d: El, reqRoles: string[]): boolean {
287
+ const role = (attr(d, "role") ?? "").trim();
288
+ if (reqRoles.includes(role)) return true;
289
+ if (reqRoles.includes("listitem") && d.tag === "li") return true;
290
+ if (reqRoles.includes("radio") && d.tag === "input" && (attr(d, "type") ?? "").toLowerCase() === "radio") return true;
291
+ return false;
292
+ }
293
+
294
+ const ariaRequiredChildren: Rule = {
295
+ id: "aria-required-children",
296
+ criteria: ["4.1.2"],
297
+ severity: "majeur",
298
+ run(doc: Doc): RuleFinding[] {
299
+ const out: RuleFinding[] = [];
300
+ for (const el of doc.elements) {
301
+ const role = (attr(el, "role") ?? "").trim();
302
+ const req = REQUIRED_CHILDREN[role];
303
+ if (!req) continue;
304
+ if (hasAttr(el, "aria-owns")) continue; // children may be referenced elsewhere
305
+ if (attr(el, "aria-busy") === "true") continue; // still loading — the tree is knowingly incomplete
306
+ if (isHiddenFromAT(el)) continue; // not exposed: no owned elements to require
307
+ if (descendants(el).some((d) => satisfiesChild(d, req))) continue;
308
+ if (mayInjectContent(el)) continue; // required children injected via <slot>/component/{@render}
309
+ out.push({
310
+ criteriaId: "4.1.2",
311
+ el,
312
+ msgId: "aria-required-children",
313
+ params: { role, req: req.join("/") },
314
+ });
315
+ }
316
+ return out;
317
+ },
318
+ };
319
+
320
+ const ariaHiddenFocusable: Rule = {
321
+ id: "aria-hidden-focusable",
322
+ criteria: ["4.1.2"],
323
+ severity: "majeur",
324
+ run(doc: Doc): RuleFinding[] {
325
+ const out: RuleFinding[] = [];
326
+ for (const el of doc.elements) {
327
+ if (attr(el, "aria-hidden") !== "true") continue;
328
+ // `aria-hidden` is this rule's subject, so the shared exposure guard cannot apply —
329
+ // but an element the source hides outright (inline display:none, [hidden]) is not in
330
+ // the focus order either, and reporting it would be a defect nobody can reach.
331
+ if (isDisplayHidden(el)) continue;
332
+ const focusable = (n: El): boolean => isFocusable(n) && !isDisplayHidden(n);
333
+ const focusableHere = focusable(el) || descendants(el).some(focusable);
334
+ if (!focusableHere) continue;
335
+ out.push({
336
+ criteriaId: "4.1.2",
337
+ el,
338
+ msgId: "aria-hidden-focusable",
339
+ });
340
+ }
341
+ return out;
342
+ },
343
+ };
344
+
345
+ const nestedInteractive: Rule = {
346
+ id: "nested-interactive",
347
+ criteria: ["4.1.2"],
348
+ severity: "majeur",
349
+ run(doc: Doc): RuleFinding[] {
350
+ const out: RuleFinding[] = [];
351
+ for (const el of doc.elements) {
352
+ if (!isInteractive(el)) continue;
353
+ if (!ancestors(el).some(isInteractive)) continue;
354
+ out.push({
355
+ criteriaId: "4.1.2",
356
+ el,
357
+ msgId: "nested-interactive",
358
+ params: { tag: el.tag },
359
+ });
360
+ }
361
+ return out;
362
+ },
363
+ };
364
+
365
+ // WAI-ARIA implicit live-region politeness per role. A status message whose role and
366
+ // aria-live disagree — or whose aria-live value is invalid — is restituted incoherently
367
+ // (an alert announced "polite" is queued and may be missed; an invalid value is ignored).
368
+ const ROLE_LIVENESS: Record<string, "assertive" | "polite" | "off"> = {
369
+ alert: "assertive",
370
+ alertdialog: "assertive",
371
+ status: "polite",
372
+ log: "polite",
373
+ timer: "off",
374
+ marquee: "off",
375
+ };
376
+ const VALID_LIVE = new Set(["off", "polite", "assertive"]);
377
+
378
+ const liveRegionConflict: Rule = {
379
+ id: "live-region-conflict",
380
+ criteria: ["4.1.3"],
381
+ severity: "majeur",
382
+ run(doc: Doc): RuleFinding[] {
383
+ const out: RuleFinding[] = [];
384
+ for (const el of doc.elements) {
385
+ if (!isIntrinsic(el.tag)) continue; // a component's aria-live/role is a prop, not the HTML attr
386
+ const liveRaw = attr(el, "aria-live");
387
+ if (liveRaw === undefined || liveRaw.includes("{")) continue; // absent or dynamic value — nothing to assert
388
+ const live = liveRaw.trim().toLowerCase();
389
+ if (!live) continue;
390
+ if (!VALID_LIVE.has(live)) {
391
+ out.push({
392
+ criteriaId: "4.1.3",
393
+ el,
394
+ msgId: "live-region-conflict.invalid-value",
395
+ params: { value: liveRaw },
396
+ });
397
+ continue;
398
+ }
399
+ const role = (attr(el, "role") ?? "").trim().toLowerCase();
400
+ if (role.includes("{")) continue; // dynamic role expression — can't compare
401
+ const want = ROLE_LIVENESS[role];
402
+ // Overriding an 'off'-default role (timer/marquee) up to polite/assertive is a
403
+ // spec-permitted author choice (aria-live overrides the implicit default), not a
404
+ // conflict — so only alert/alertdialog/status/log (want !== "off") are checked.
405
+ if (want && want !== "off" && want !== live) {
406
+ out.push({
407
+ criteriaId: "4.1.3",
408
+ el,
409
+ severity: want === "assertive" ? "majeur" : "mineur",
410
+ msgId: "live-region-conflict.mismatch",
411
+ params: { role, want, live },
412
+ });
413
+ }
414
+ }
415
+ return out;
416
+ },
417
+ };
418
+
419
+ // An error/alert container announced politely (aria-live="polite") instead of
420
+ // assertively. A status MESSAGE that reports an error should interrupt
421
+ // (role="alert" / aria-live="assertive") so it is not queued and missed. This is the
422
+ // `polite`-with-no-role case the existing live-region-conflict (role↔aria-live) leaves
423
+ // open; only fired when the container's class/id marks it as an ERROR specifically.
424
+ // The bare `alert`/`fr-alert` tokens are intentionally excluded: DSFR (and Bootstrap)
425
+ // reuse them for success/info/warning variants — `fr-alert fr-alert--success` with
426
+ // aria-live="polite" is the CORRECT status pattern, not a defect. `error`/`danger`/
427
+ // `invalid` still match error variants (`fr-alert--error`, `fr-error`, `alert-danger`).
428
+ const ERROR_ALERT_CONTAINER = /(^|[-_ ])(error|danger|invalid)/i;
429
+
430
+ const statusMessageNotAssertive: Rule = {
431
+ id: "status-message-not-assertive",
432
+ criteria: ["4.1.3"],
433
+ severity: "mineur",
434
+ run(doc: Doc): RuleFinding[] {
435
+ const out: RuleFinding[] = [];
436
+ for (const el of doc.elements) {
437
+ if (!isIntrinsic(el.tag)) continue; // a component's aria-live/role is a prop, not the HTML attr
438
+ const liveRaw = attr(el, "aria-live");
439
+ if (liveRaw === undefined || liveRaw.includes("{")) continue; // absent or dynamic value
440
+ if (liveRaw.trim().toLowerCase() !== "polite") continue; // only polite is at issue here
441
+ if (hasAttr(el, "role")) continue; // role-driven liveness is live-region-conflict's job
442
+ const signal = (attr(el, "class") ?? "") + " " + (attr(el, "id") ?? "");
443
+ if (!ERROR_ALERT_CONTAINER.test(signal)) continue;
444
+ out.push({
445
+ criteriaId: "4.1.3",
446
+ el,
447
+ msgId: "status-message-not-assertive",
448
+ });
449
+ }
450
+ return out;
451
+ },
452
+ };
453
+
454
+ // ---- ARIA vocabulary validation (WAI-ARIA 1.2 tables live in src/aria.ts) -------------
455
+
456
+ /** Every literal `aria-*` attribute on an element, skipping dynamic bindings whose value
457
+ * (or very presence) the static parse cannot resolve. */
458
+ function literalAriaAttrs(el: El): [string, string][] {
459
+ const out: [string, string][] = [];
460
+ for (const key in el.attribs) {
461
+ const k = key.toLowerCase();
462
+ if (!k.startsWith("aria-")) continue;
463
+ const v = el.attribs[key] ?? "";
464
+ if (v.includes("{")) continue; // JSX/SFC expression — value unknown, never guess
465
+ out.push([k, v]);
466
+ }
467
+ return out;
468
+ }
469
+
470
+ // A MISSPELLED aria-* attribute (`aria-labeledby`, `aria-role`) is inert: the browser
471
+ // exposes nothing, and the author believes the element is named. Only literal, intrinsic
472
+ // elements are checked — a component prop named `aria-foo` is that component's business.
473
+ const invalidAriaAttr: Rule = {
474
+ id: "invalid-aria-attr",
475
+ criteria: ["4.1.2"],
476
+ severity: "majeur",
477
+ run(doc: Doc): RuleFinding[] {
478
+ const out: RuleFinding[] = [];
479
+ for (const el of doc.elements) {
480
+ if (!isIntrinsic(el.tag)) continue;
481
+ for (const [name] of literalAriaAttrs(el)) {
482
+ if (ARIA_ATTRS.has(name)) continue;
483
+ out.push({ criteriaId: "4.1.2", el, msgId: "invalid-aria-attr", params: { attr: name } });
484
+ }
485
+ }
486
+ return out;
487
+ },
488
+ };
489
+
490
+ // An out-of-vocabulary VALUE (`aria-expanded="yes"`, `aria-level="0"`) is treated as if the
491
+ // attribute were absent, so the state the author meant to expose never reaches the user.
492
+ const invalidAriaValue: Rule = {
493
+ id: "invalid-aria-value",
494
+ criteria: ["4.1.2"],
495
+ severity: "majeur",
496
+ run(doc: Doc): RuleFinding[] {
497
+ const out: RuleFinding[] = [];
498
+ for (const el of doc.elements) {
499
+ if (!isIntrinsic(el.tag)) continue;
500
+ for (const [name, value] of literalAriaAttrs(el)) {
501
+ if (!ARIA_ATTRS.has(name)) continue; // invalid-aria-attr owns unknown names
502
+ if (hasBoundAttr(el, name) && !value) continue; // dynamically bound: present, value unknown
503
+ if (isValidAriaValue(name, value)) continue;
504
+ out.push({ criteriaId: "4.1.2", el, msgId: "invalid-aria-value", params: { attr: name, value } });
505
+ }
506
+ }
507
+ return out;
508
+ },
509
+ };
510
+
511
+ /** Does the element's NATIVE semantics already expose this state? A `<input
512
+ * type="checkbox">` maintains its own checked state, an `<h2>` its own level — re-declaring
513
+ * the role does not create an obligation to also hand-maintain the ARIA mirror. */
514
+ function nativelyProvides(el: El, ariaAttr: string): boolean {
515
+ const type = (attr(el, "type") ?? "").toLowerCase();
516
+ switch (ariaAttr) {
517
+ case "aria-checked":
518
+ return el.tag === "input" && (type === "checkbox" || type === "radio");
519
+ case "aria-level":
520
+ return /^h[1-6]$/.test(el.tag);
521
+ case "aria-valuenow":
522
+ return el.tag === "progress" || el.tag === "meter" || (el.tag === "input" && type === "range");
523
+ case "aria-selected":
524
+ return el.tag === "option";
525
+ case "aria-expanded":
526
+ return el.tag === "details" || el.tag === "select";
527
+ default:
528
+ return false;
529
+ }
530
+ }
531
+
532
+ // A role whose ARIA definition REQUIRES a state, used without it: `role="checkbox"` with no
533
+ // aria-checked announces a checkbox whose state is unknowable.
534
+ const ariaRequiredAttr: Rule = {
535
+ id: "aria-required-attr",
536
+ criteria: ["4.1.2"],
537
+ severity: "majeur",
538
+ run(doc: Doc): RuleFinding[] {
539
+ const out: RuleFinding[] = [];
540
+ for (const el of doc.elements) {
541
+ if (!isIntrinsic(el.tag)) continue;
542
+ const role = (attr(el, "role") ?? "").trim().toLowerCase();
543
+ if (!role || role.includes("{")) continue;
544
+ const required = ARIA_REQUIRED_ATTRS[role.split(/\s+/)[0] ?? ""];
545
+ if (!required) continue;
546
+ if (isHiddenFromAT(el)) continue; // not exposed: no state to expose
547
+ if (hasDynamicSpread(el)) continue; // a spread may carry the state
548
+ const missing = required.filter((a) => !hasBoundAttr(el, a) && !nativelyProvides(el, a));
549
+ if (!missing.length) continue;
550
+ out.push({ criteriaId: "4.1.2", el, msgId: "aria-required-attr", params: { role, attrs: missing.join(", ") } });
551
+ }
552
+ return out;
553
+ },
554
+ };
555
+
556
+ /** The role an element exposes: explicit `role`, else the unambiguous native one. */
557
+ function effectiveRole(el: El): string {
558
+ const explicit = (attr(el, "role") ?? "").trim().toLowerCase();
559
+ if (explicit && !explicit.includes("{")) return explicit.split(/\s+/)[0] ?? "";
560
+ return IMPLICIT_CONTAINER_ROLE[el.tag] ?? "";
561
+ }
562
+
563
+ // A role that only means something inside a specific container (`tab` outside a `tablist`,
564
+ // `option` outside a `listbox`) is not exposed as that role at all.
565
+ const ariaRequiredParent: Rule = {
566
+ id: "aria-required-parent",
567
+ criteria: ["1.3.1"],
568
+ severity: "majeur",
569
+ run(doc: Doc): RuleFinding[] {
570
+ const out: RuleFinding[] = [];
571
+ for (const el of doc.elements) {
572
+ if (!isIntrinsic(el.tag)) continue;
573
+ const role = (attr(el, "role") ?? "").trim().toLowerCase();
574
+ if (!role || role.includes("{")) continue;
575
+ const accepted = ARIA_REQUIRED_PARENT[role.split(/\s+/)[0] ?? ""];
576
+ if (!accepted) continue;
577
+ if (isHiddenFromAT(el)) continue;
578
+ const chain = ancestors(el);
579
+ // A component ancestor may render the required container around this element, and
580
+ // aria-owns can supply the relationship from elsewhere — neither is visible here.
581
+ if (chain.some((a) => !isIntrinsic(a.tag))) continue;
582
+ if (doc.elements.some((e) => hasAttr(e, "aria-owns"))) continue;
583
+ // ARIA ownership stops at the first ancestor that DECLARES a role: a `role="listitem"`
584
+ // wrapped in a `role="tabpanel"` is owned by the tabpanel, not by the list further up.
585
+ // Role-less wrapper elements are traversed through — generic <div>s between a list and
586
+ // its items are everywhere in real markup, and browsers repair them.
587
+ // `role="presentation"`/`none` removes the element's semantics, so it is transparent
588
+ // for ownership — the list below it still owns the items.
589
+ const owner = chain.find((a) => !isPresentational(a) && ((attr(a, "role") ?? "").trim() !== "" || IMPLICIT_CONTAINER_ROLE[a.tag]));
590
+ if (owner && accepted.includes(effectiveRole(owner))) continue;
591
+ if (!owner && chain.some((a) => accepted.includes(effectiveRole(a)))) continue;
592
+ out.push({ criteriaId: "1.3.1", el, msgId: "aria-required-parent", params: { role, parents: accepted.join(" / ") } });
593
+ }
594
+ return out;
595
+ },
596
+ };
597
+
598
+ // Naming a role that PROHIBITS a name (a paragraph, an emphasis, a presentational element)
599
+ // hands assistive tech text it has nowhere to put — dropped by some, announced out of
600
+ // context by others. `role="presentation"`/`none` additionally LOSES its own effect as soon
601
+ // as it carries a name, so the element silently keeps its semantics.
602
+ const ariaProhibitedAttr: Rule = {
603
+ id: "aria-prohibited-attr",
604
+ criteria: ["4.1.2"],
605
+ severity: "mineur",
606
+ run(doc: Doc): RuleFinding[] {
607
+ const out: RuleFinding[] = [];
608
+ for (const el of doc.elements) {
609
+ if (!isIntrinsic(el.tag)) continue;
610
+ const role = (attr(el, "role") ?? "").trim().toLowerCase();
611
+ if (!role || role.includes("{")) continue;
612
+ if (!NAME_PROHIBITED_ROLES.has(role)) continue;
613
+ const named = ["aria-label", "aria-labelledby"].filter((a) => (attr(el, a) ?? "").trim() !== "");
614
+ if (!named.length) continue;
615
+ out.push({ criteriaId: "4.1.2", el, msgId: "aria-prohibited-attr", params: { role, attrs: named.join(", ") } });
616
+ }
617
+ return out;
618
+ },
619
+ };
620
+
621
+ export const scriptsAriaRules: Rule[] = [
622
+ invalidAriaRole,
623
+ ariaRefMissingId,
624
+ redundantAria,
625
+ clickableNoninteractive,
626
+ ariaRequiredChildren,
627
+ ariaHiddenFocusable,
628
+ nestedInteractive,
629
+ liveRegionConflict,
630
+ statusMessageNotAssertive,
631
+ invalidAriaAttr,
632
+ invalidAriaValue,
633
+ ariaRequiredAttr,
634
+ ariaRequiredParent,
635
+ ariaProhibitedAttr,
636
+ ];