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,590 @@
1
+ // Theme 11 — Forms: every field has a programmatic label + group/structure checks.
2
+ import type { Doc, El } from "../parse/html.js";
3
+ import { attr, hasAttr, hasDynamicSpread, descendants, ancestors, visibleText, textContent } from "../parse/html.js";
4
+ import { isIntrinsic } from "../parse/jsx-bridge.js";
5
+ import { controlLabel, isFormField, mayInjectContent, isNameExempt } from "../name.js";
6
+ import { isValidAutocomplete, CREDENTIAL_FIELDS } from "../autofill.js";
7
+
8
+ // The element's WHOLE content is a slot/snippet passthrough — a DIRECT `<slot>` child or
9
+ // a `{@render …}`/`{children}` render-expression text node — so the legend the caller
10
+ // provides is invisible here. (Must be a direct child: a fieldset that merely *contains*
11
+ // component fields with no legend is a real violation, not a passthrough.)
12
+ function contentInjected(el: El): boolean {
13
+ return el.children.some((c) => (c.type === "element" && c.tag === "slot") || (c.type === "text" && /\{@render|\{children/.test(c.data)));
14
+ }
15
+ import type { Rule, RuleFinding } from "./rule.js";
16
+
17
+ // title/placeholder are NOT real labels for these rules
18
+ const REAL_LABEL = new Set(["for", "wrapping", "aria-label", "aria-labelledby"]);
19
+
20
+ const controlLabelMissing: Rule = {
21
+ id: "control-label-missing",
22
+ criteria: ["4.1.2"],
23
+ severity: "bloquant",
24
+ run(doc: Doc): RuleFinding[] {
25
+ const out: RuleFinding[] = [];
26
+ for (const el of doc.elements) {
27
+ if (!isFormField(el)) continue;
28
+ if (isNameExempt(el)) continue; // not exposed, or explicitly presentational
29
+ const { via } = controlLabel(el, doc);
30
+ if (via && REAL_LABEL.has(via)) continue;
31
+ // title yields an accessible name (accname fallback / WCAG H65) — a weak one, surfaced
32
+ // as a minor control-name-title-only finding, not a blocking "no label".
33
+ if (via === "title") continue;
34
+ // A spread (JSX {...props} / Vue v-bind="…" / Svelte {...rest}) may inject
35
+ // aria-label/aria-labelledby we can't see — don't assert a missing label.
36
+ if (hasDynamicSpread(el)) continue;
37
+ if (hasAttr(el, "placeholder")) continue; // reported by placeholder-as-label
38
+ out.push({
39
+ criteriaId: "4.1.2",
40
+ el,
41
+ msgId: "control-label-missing",
42
+ params: { tag: el.tag },
43
+ });
44
+ }
45
+ return out;
46
+ },
47
+ };
48
+
49
+ const placeholderAsLabel: Rule = {
50
+ id: "placeholder-as-label",
51
+ criteria: ["4.1.2"],
52
+ severity: "majeur",
53
+ run(doc: Doc): RuleFinding[] {
54
+ const out: RuleFinding[] = [];
55
+ for (const el of doc.elements) {
56
+ if (!isFormField(el)) continue;
57
+ if (!hasAttr(el, "placeholder")) continue;
58
+ const { via } = controlLabel(el, doc);
59
+ if (via && REAL_LABEL.has(via)) continue;
60
+ out.push({
61
+ criteriaId: "4.1.2",
62
+ el,
63
+ msgId: "placeholder-as-label",
64
+ params: { value: attr(el, "placeholder") ?? "" },
65
+ });
66
+ }
67
+ return out;
68
+ },
69
+ };
70
+
71
+ const fieldsetLegendMissing: Rule = {
72
+ id: "fieldset-legend-missing",
73
+ criteria: ["1.3.1"],
74
+ severity: "majeur",
75
+ run(doc: Doc): RuleFinding[] {
76
+ const out: RuleFinding[] = [];
77
+ for (const el of doc.elements) {
78
+ if (el.tag !== "fieldset") continue;
79
+ const legend = el.children.find((c) => c.type === "element" && c.tag === "legend");
80
+ if (legend && legend.type === "element" && visibleText(legend)) continue;
81
+ if (hasAttr(el, "aria-label") || hasAttr(el, "aria-labelledby")) continue;
82
+ if (contentInjected(el)) continue; // legend supplied via slot/{@render children()}/a component
83
+ out.push({
84
+ criteriaId: "1.3.1",
85
+ el,
86
+ msgId: "fieldset-legend-missing",
87
+ });
88
+ }
89
+ return out;
90
+ },
91
+ };
92
+
93
+ const formFieldMultipleLabels: Rule = {
94
+ id: "form-field-multiple-labels",
95
+ criteria: ["4.1.2"],
96
+ severity: "mineur",
97
+ run(doc: Doc): RuleFinding[] {
98
+ const counts = new Map<string, number>();
99
+ for (const el of doc.elements) {
100
+ if (el.tag !== "label") continue;
101
+ const f = attr(el, "for");
102
+ if (f) counts.set(f, (counts.get(f) ?? 0) + 1);
103
+ }
104
+ const out: RuleFinding[] = [];
105
+ for (const el of doc.elements) {
106
+ if (!isFormField(el)) continue;
107
+ const id = attr(el, "id");
108
+ if (id && (counts.get(id) ?? 0) > 1) {
109
+ out.push({
110
+ criteriaId: "4.1.2",
111
+ el,
112
+ msgId: "form-field-multiple-labels",
113
+ params: { tag: el.tag, count: counts.get(id) ?? 0, id },
114
+ });
115
+ }
116
+ }
117
+ return out;
118
+ },
119
+ };
120
+
121
+ const selectHasOption: Rule = {
122
+ id: "select-has-option",
123
+ criteria: ["4.1.2"],
124
+ severity: "mineur",
125
+ run(doc: Doc): RuleFinding[] {
126
+ const out: RuleFinding[] = [];
127
+ for (const el of doc.elements) {
128
+ if (el.tag !== "select") continue;
129
+ if (descendants(el).some((d) => d.tag === "option")) continue;
130
+ if (mayInjectContent(el) || contentInjected(el)) continue; // options injected via <slot>/component/{@render}
131
+ out.push({
132
+ criteriaId: "4.1.2",
133
+ el,
134
+ msgId: "select-has-option",
135
+ });
136
+ }
137
+ return out;
138
+ },
139
+ };
140
+
141
+ // An attribute key that carries an element id (`id`, `inputId`, `data-testid`, `htmlFor`…):
142
+ // the signature a design-system component uses to hand an id down to the control it renders.
143
+ const ID_ATTR_KEY = /id/i;
144
+
145
+ // A <label for="x"> whose target id exists nowhere in the document — the visible label
146
+ // is not programmatically associated with any field (a common typo'd for/id pairing).
147
+ // Same-document only; cross-file label/field association is out of scope (see references/cross-file.md).
148
+ const labelForDangling: Rule = {
149
+ id: "label-for-dangling",
150
+ criteria: ["1.3.1"],
151
+ severity: "majeur",
152
+ run(doc: Doc): RuleFinding[] {
153
+ const out: RuleFinding[] = [];
154
+ // value -> the elements carrying it under an id-bearing attribute key, built ONCE
155
+ // and only when a dangling `for` is actually seen. The probe below used to be a full
156
+ // `doc.elements.some(... Object.entries(e.attribs) ...)` scan *inside* the label loop:
157
+ // quadratic, and on a form-heavy page it dominated the entire audit (~8 s for 8k labels).
158
+ let idProps: Map<string, El[]> | undefined;
159
+ const idPropCarriers = (value: string): El[] | undefined => {
160
+ if (!idProps) {
161
+ idProps = new Map();
162
+ for (const e of doc.elements) {
163
+ for (const k in e.attribs) {
164
+ if (!ID_ATTR_KEY.test(k)) continue;
165
+ const v = e.attribs[k];
166
+ if (!v) continue;
167
+ const bucket = idProps.get(v);
168
+ // Attributes of one element are consecutive, so comparing the tail is enough
169
+ // to keep an element from being counted twice (e.g. id="x" AND data-id="x").
170
+ if (!bucket) idProps.set(v, [e]);
171
+ else if (bucket[bucket.length - 1] !== e) bucket.push(e);
172
+ }
173
+ }
174
+ }
175
+ return idProps.get(value);
176
+ };
177
+ for (const el of doc.elements) {
178
+ if (el.tag !== "label") continue;
179
+ const f = (attr(el, "for") ?? "").trim();
180
+ if (!f || f.includes("{")) continue; // empty, or a JSX expression remnant (dynamic for=)
181
+ if (doc.byId.has(f)) continue;
182
+ // Wired via an id-bearing prop on a sibling component? Design-system field/upload
183
+ // components take the input id as a prop (e.g. <FileUpload inputId="x"/>) and render
184
+ // the <input id="x"> internally — the association is real, just not a literal id here.
185
+ const passedAsIdProp = idPropCarriers(f)?.some((e) => e !== el) ?? false;
186
+ if (passedAsIdProp) continue;
187
+ out.push({
188
+ criteriaId: "1.3.1",
189
+ el,
190
+ msgId: "label-for-dangling",
191
+ params: { id: f },
192
+ });
193
+ }
194
+ return out;
195
+ },
196
+ };
197
+
198
+ // WCAG 3.3.1 Error Identification: a control flagged aria-invalid="true" must point AT
199
+ // the error text (aria-describedby or aria-errormessage) so assistive tech can restitute
200
+ // *why* it is invalid. aria-invalid alone announces "invalid" with no reason — the error
201
+ // is signalled but not associated (cross-referenced by RGAA 11.10). The inverse case
202
+ // (visible error text not linked at all) is a judgment call left to the auditor.
203
+ const ariaInvalidNoDescription: Rule = {
204
+ id: "aria-invalid-no-description",
205
+ criteria: ["3.3.1"],
206
+ severity: "majeur",
207
+ run(doc: Doc): RuleFinding[] {
208
+ const out: RuleFinding[] = [];
209
+ for (const el of doc.elements) {
210
+ if (!isFormField(el)) continue;
211
+ const invalid = attr(el, "aria-invalid");
212
+ if (invalid === undefined || invalid.includes("{")) continue; // absent or dynamic value — nothing to assert
213
+ if (invalid.trim().toLowerCase() !== "true") continue; // "false"/"grammar"/"spelling" are not unlinked errors
214
+ if (hasAttr(el, "aria-describedby") || hasAttr(el, "aria-errormessage")) continue; // error text is linked
215
+ if (hasDynamicSpread(el)) continue; // a spread (JSX {...props} / v-bind) may inject the description
216
+ out.push({
217
+ criteriaId: "3.3.1",
218
+ el,
219
+ msgId: "aria-invalid-no-description",
220
+ params: { tag: el.tag },
221
+ });
222
+ }
223
+ return out;
224
+ },
225
+ };
226
+
227
+ // The complement of aria-invalid-no-description: a VISIBLE error-text node that carries
228
+ // a stable id but is referenced by NO field (no aria-describedby/aria-errormessage points
229
+ // at it). The error is shown but not programmatically tied to its field (WCAG 3.3.1).
230
+ // Only fired on a literal, present id with no literal referrer — provable, low FP.
231
+ const ERROR_TEXT_CLASS = /(^|[-_ ])(fr-error-text|fr-message--error|error-text|error-message|field-error|invalid-feedback)/i;
232
+
233
+ const errorNotAssociated: Rule = {
234
+ id: "error-not-associated",
235
+ criteria: ["3.3.1"],
236
+ severity: "majeur",
237
+ run(doc: Doc): RuleFinding[] {
238
+ const referenced = new Set<string>();
239
+ // Ids targeted by a same-document in-page anchor (<a href="#id">). An error SUMMARY that
240
+ // a "jump to error" link points at is a page-level status region (G139), not a per-field
241
+ // association gap — its restitution comes from the anchor + role/aria-live, not describedby.
242
+ const anchorTargets = new Set<string>();
243
+ for (const el of doc.elements) {
244
+ if (el.tag === "a") {
245
+ const href = (attr(el, "href") ?? "").trim();
246
+ if (href.startsWith("#") && href.length > 1) anchorTargets.add(href.slice(1));
247
+ }
248
+ for (const a of ["aria-describedby", "aria-errormessage"]) {
249
+ const v = attr(el, a);
250
+ if (!v) continue;
251
+ if (!v.includes("{")) {
252
+ for (const id of v.split(/\s+/).filter(Boolean)) referenced.add(id);
253
+ } else {
254
+ // Dynamic value, e.g. aria-describedby={error ? "women-error" : undefined}. The
255
+ // JSX parser keeps the raw `{…}` slice, so pull out any string-literal ids: a
256
+ // conditionally-applied describedby still associates the error (common React
257
+ // pattern) — otherwise the matching <p id="women-error"> is a false positive.
258
+ for (const m of v.matchAll(/["'`]([^"'`]+)["'`]/g)) for (const id of m[1]!.split(/\s+/).filter(Boolean)) referenced.add(id);
259
+ }
260
+ }
261
+ }
262
+ const out: RuleFinding[] = [];
263
+ for (const el of doc.elements) {
264
+ const cls = attr(el, "class");
265
+ if (!cls || !ERROR_TEXT_CLASS.test(cls)) continue;
266
+ const id = (attr(el, "id") ?? "").trim();
267
+ if (!id || id.includes("{")) continue; // no stable literal id to reference — not provable
268
+ if (referenced.has(id)) continue; // already linked to a field
269
+ // A live/alert summary region announces itself; it is not a per-field association gap.
270
+ const role = (attr(el, "role") ?? "").trim();
271
+ if (role === "alert" || role === "status" || hasAttr(el, "aria-live")) continue;
272
+ if (anchorTargets.has(id)) continue; // an in-page "jump to error" link targets it — a summary, not a field error
273
+ if (hasDynamicSpread(el)) continue;
274
+ out.push({
275
+ criteriaId: "3.3.1",
276
+ el,
277
+ msgId: "error-not-associated",
278
+ params: { id },
279
+ });
280
+ }
281
+ return out;
282
+ },
283
+ };
284
+
285
+ // WCAG 1.3.5 (identify input purpose) + 4.1.2 (state). Two provable, conservative gaps:
286
+ // (i) an identify-purpose <input> (email/tel, or a purpose token in name/id) with no
287
+ // autocomplete attribute — the field's purpose is not exposed programmatically.
288
+ // (ii) a CUSTOM widget (role=textbox/combobox/checkbox/… on an intrinsic non-native
289
+ // element) marked required by class but missing aria-required — required state lost.
290
+ const PURPOSE_TYPES = new Set(["email", "tel"]);
291
+ // Whole-token match (split on separators AND camelCase), so `city` doesn't match veloCITY
292
+ // and `tel` doesn't match hoTEL — an unanchored substring regex mislabels them 1.3.5 fields.
293
+ const PURPOSE_WORDS = new Set(["email", "tel", "telephone", "phone", "mobile", "postal", "zip", "address", "street", "city", "country"]);
294
+ const hasPurposeToken = (nameId: string): boolean => nameId.split(/[-_\s]+|(?<=[a-z0-9])(?=[A-Z])/).some((t) => PURPOSE_WORDS.has(t.toLowerCase()));
295
+ const SKIP_INPUT_TYPES = new Set(["search", "hidden", "submit", "reset", "button", "password", "checkbox", "radio", "file", "range", "color"]);
296
+ const CUSTOM_WIDGET_ROLES = new Set(["textbox", "combobox", "checkbox", "radio", "switch", "spinbutton"]);
297
+ const REQUIRED_SIGNAL = /(^|[-_ ])(required|mandatory|is-required)/i;
298
+
299
+ const fieldPurposeIncomplete: Rule = {
300
+ id: "field-purpose-incomplete",
301
+ criteria: ["1.3.5", "4.1.2"],
302
+ severity: "mineur",
303
+ run(doc: Doc): RuleFinding[] {
304
+ const out: RuleFinding[] = [];
305
+ for (const el of doc.elements) {
306
+ // (i) identify-purpose input without autocomplete (1.3.5)
307
+ if (el.tag === "input") {
308
+ const type = (attr(el, "type") ?? "text").toLowerCase();
309
+ if (!type.includes("{") && !SKIP_INPUT_TYPES.has(type)) {
310
+ const nameId = `${attr(el, "name") ?? ""} ${attr(el, "id") ?? ""}`;
311
+ const purpose = PURPOSE_TYPES.has(type) || hasPurposeToken(nameId);
312
+ if (purpose && !hasAttr(el, "autocomplete") && !hasDynamicSpread(el)) {
313
+ out.push({
314
+ criteriaId: "1.3.5",
315
+ el,
316
+ msgId: "field-purpose-incomplete.autocomplete",
317
+ params: { type },
318
+ });
319
+ }
320
+ }
321
+ }
322
+ // (ii) custom required widget without aria-required (4.1.2)
323
+ const role = (attr(el, "role") ?? "").trim().toLowerCase();
324
+ if (isIntrinsic(el.tag) && CUSTOM_WIDGET_ROLES.has(role) && REQUIRED_SIGNAL.test(attr(el, "class") ?? "")) {
325
+ if (!hasAttr(el, "aria-required") && !hasAttr(el, "required") && !hasDynamicSpread(el)) {
326
+ out.push({
327
+ criteriaId: "4.1.2",
328
+ el,
329
+ msgId: "field-purpose-incomplete.aria-required",
330
+ params: { role },
331
+ });
332
+ }
333
+ }
334
+ }
335
+ return out;
336
+ },
337
+ };
338
+
339
+ // ---- Grouping / read-only-context checks (RGAA 11.5 / 7.1 / 10.8) ----------------
340
+
341
+ // A group of controls shares a grouping ancestor when a <fieldset>, role="radiogroup"
342
+ // or role="group" wraps them — the relationship AT relies on to announce "1 of N".
343
+ const GROUPING_ROLES = new Set(["radiogroup", "group"]);
344
+ function inGroupingContext(el: El): boolean {
345
+ return ancestors(el).some((a) => a.tag === "fieldset" || GROUPING_ROLES.has((attr(a, "role") ?? "").trim().toLowerCase()));
346
+ }
347
+
348
+ // A read-only recap wrapped in <fieldset disabled> or an [inert] container still holds
349
+ // controls the user perceives, but AT may skip them or announce them as unavailable
350
+ // (RGAA 7.1 + 10.8, WCAG 4.1.2). Suppressed when the disable is a TRANSIENT submission
351
+ // guard — aria-busy on the container/an ancestor, or a submit/reset control in the group
352
+ // (the whole form is momentarily locked while it posts), not a permanent read-only recap.
353
+ function isSubmitControl(el: El): boolean {
354
+ if (el.tag === "button") return (attr(el, "type") ?? "submit").trim().toLowerCase() !== "button"; // bare <button> defaults to submit
355
+ if (el.tag === "input") return ["submit", "reset"].includes((attr(el, "type") ?? "").trim().toLowerCase());
356
+ return false;
357
+ }
358
+ function ariaBusyInScope(el: El): boolean {
359
+ if ((attr(el, "aria-busy") ?? "").trim().toLowerCase() === "true") return true;
360
+ return ancestors(el).some((a) => (attr(a, "aria-busy") ?? "").trim().toLowerCase() === "true");
361
+ }
362
+
363
+ const disabledContextContent: Rule = {
364
+ id: "disabled-context-content",
365
+ criteria: ["4.1.2"],
366
+ severity: "majeur",
367
+ run(doc: Doc): RuleFinding[] {
368
+ const out: RuleFinding[] = [];
369
+ for (const el of doc.elements) {
370
+ const isDisabledFieldset = el.tag === "fieldset" && hasAttr(el, "disabled");
371
+ const isInert = hasAttr(el, "inert");
372
+ if (!isDisabledFieldset && !isInert) continue;
373
+ const desc = descendants(el);
374
+ if (!desc.some(isFormField)) continue; // no wrapped controls — nothing removed from the a11y tree
375
+ if (hasDynamicSpread(el)) continue; // a spread may inject aria-disabled semantics we can't see
376
+ if (ariaBusyInScope(el)) continue; // transient submission state, not a read-only recap
377
+ if (desc.some(isSubmitControl)) continue; // a submit/reset in the group ⇒ form locked while posting
378
+ if (isInert) out.push({ criteriaId: "4.1.2", el, msgId: "disabled-context-content.inert" });
379
+ else out.push({ criteriaId: "4.1.2", el, msgId: "disabled-context-content.fieldset" });
380
+ }
381
+ return out;
382
+ },
383
+ };
384
+
385
+ // ≥2 radios (or a same-name checkbox cluster) sharing a `name` but NOT inside a
386
+ // <fieldset>/role="radiogroup"/role="group" — the group relationship is lost (RGAA 11.5,
387
+ // WCAG 1.3.1/3.3.2). Complements `fieldset-legend-missing` (which fires only once a
388
+ // fieldset already exists). Fires once per group, anchored on its first control.
389
+ const radioCheckboxGroupUngrouped: Rule = {
390
+ id: "radio-checkbox-group-ungrouped",
391
+ criteria: ["1.3.1", "3.3.2"],
392
+ severity: "majeur",
393
+ run(doc: Doc): RuleFinding[] {
394
+ const groups = new Map<string, El[]>();
395
+ for (const el of doc.elements) {
396
+ if (el.tag !== "input") continue;
397
+ const type = (attr(el, "type") ?? "").trim().toLowerCase();
398
+ if (type !== "radio" && type !== "checkbox") continue;
399
+ const name = (attr(el, "name") ?? "").trim();
400
+ if (!name || name.includes("{")) continue; // no name, or a dynamic {expr} — not a provable group
401
+ const key = `${type}::${name}`;
402
+ const list = groups.get(key);
403
+ if (list) list.push(el);
404
+ else groups.set(key, [el]);
405
+ }
406
+ const out: RuleFinding[] = [];
407
+ for (const [key, members] of groups) {
408
+ if (members.length < 2) continue; // a lone control is not a group
409
+ if (members.some(hasDynamicSpread)) continue; // a spread may inject role/group context
410
+ if (members.some(inGroupingContext)) continue; // already grouped
411
+ const type = key.slice(0, key.indexOf("::"));
412
+ out.push({
413
+ criteriaId: "1.3.1",
414
+ el: members[0]!,
415
+ msgId: "radio-checkbox-group-ungrouped",
416
+ params: { type, name: key.slice(key.indexOf("::") + 2), count: members.length },
417
+ });
418
+ }
419
+ return out;
420
+ },
421
+ };
422
+
423
+ // ≥2 sibling date fields ungrouped (RGAA 11.5, WCAG 3.3.2): native type=date/month, or a
424
+ // NARROW fr/en date lexicon on the field's name/id/label. Fires once per parent cluster —
425
+ // the same-immediate-parent requirement keeps it to tight day/month/year or début/fin
426
+ // clusters and off unrelated dates spread across separate rows.
427
+ const DATE_TOKENS = new Set(["jour", "mois", "annee", "année", "debut", "début", "fin", "day", "month", "year"]);
428
+ const NON_DATE_INPUT_TYPES = new Set([
429
+ "hidden",
430
+ "submit",
431
+ "reset",
432
+ "button",
433
+ "checkbox",
434
+ "radio",
435
+ "file",
436
+ "password",
437
+ "range",
438
+ "color",
439
+ "email",
440
+ "tel",
441
+ "url",
442
+ "search",
443
+ ]);
444
+
445
+ function tokenize(s: string): string[] {
446
+ return s
447
+ .toLowerCase()
448
+ .split(/[-_\s.]+|(?<=[a-z0-9])(?=[A-Z])/)
449
+ .filter(Boolean);
450
+ }
451
+ function fieldText(el: El, doc: Doc): string {
452
+ const id = attr(el, "id");
453
+ const forLabel = id ? doc.elements.find((e) => e.tag === "label" && attr(e, "for") === id) : undefined;
454
+ const wrapping = ancestors(el).find((a) => a.tag === "label");
455
+ const labelText = forLabel ? textContent(forLabel) : wrapping ? textContent(wrapping) : "";
456
+ return `${attr(el, "name") ?? ""} ${attr(el, "id") ?? ""} ${labelText}`;
457
+ }
458
+ function isDateField(el: El, doc: Doc): boolean {
459
+ if (el.tag !== "input") return false;
460
+ const type = (attr(el, "type") ?? "text").trim().toLowerCase();
461
+ if (type === "date" || type === "month") return true;
462
+ if (type.includes("{") || NON_DATE_INPUT_TYPES.has(type)) return false;
463
+ return tokenize(fieldText(el, doc)).some((t) => DATE_TOKENS.has(t));
464
+ }
465
+
466
+ const dateFieldsUngrouped: Rule = {
467
+ id: "date-fields-ungrouped",
468
+ criteria: ["3.3.2"],
469
+ severity: "majeur",
470
+ run(doc: Doc): RuleFinding[] {
471
+ const byParent = new Map<El, El[]>();
472
+ for (const el of doc.elements) {
473
+ if (!isDateField(el, doc)) continue;
474
+ if (hasDynamicSpread(el) || inGroupingContext(el)) continue; // already grouped, or spread-injected context
475
+ const p = el.parent;
476
+ if (!p) continue;
477
+ const list = byParent.get(p);
478
+ if (list) list.push(el);
479
+ else byParent.set(p, [el]);
480
+ }
481
+ const out: RuleFinding[] = [];
482
+ for (const fields of byParent.values()) {
483
+ if (fields.length < 2) continue;
484
+ out.push({ criteriaId: "3.3.2", el: fields[0]!, msgId: "date-fields-ungrouped", params: { count: fields.length } });
485
+ }
486
+ return out;
487
+ },
488
+ };
489
+
490
+ // WCAG 1.3.5 asks for a token from the HTML autofill vocabulary. A MISSPELLED or invented
491
+ // value ("nope", "user-name", "emailaddress") is worse than none: it reads as compliant,
492
+ // autofills nothing, and no assistive technology can infer the field's purpose from it.
493
+ const autocompleteTokenInvalid: Rule = {
494
+ id: "autocomplete-token-invalid",
495
+ criteria: ["1.3.5"],
496
+ severity: "majeur",
497
+ run(doc: Doc): RuleFinding[] {
498
+ const out: RuleFinding[] = [];
499
+ for (const el of doc.elements) {
500
+ if (!isIntrinsic(el.tag) || !["input", "select", "textarea"].includes(el.tag)) continue;
501
+ const value = attr(el, "autocomplete");
502
+ if (value === undefined || value.includes("{")) continue; // absent, or a dynamic binding
503
+ if (value.trim() === "") continue; // empty is "no declaration", not a wrong one
504
+ if (hasAttr(el, "disabled") || attr(el, "aria-disabled") === "true") continue; // a disabled control collects nothing
505
+ if (isNameExempt(el)) continue; // not exposed, or explicitly presentational
506
+ if (isValidAutocomplete(value)) continue;
507
+ out.push({ criteriaId: "1.3.5", el, msgId: "autocomplete-token-invalid", params: { value: value.trim() } });
508
+ }
509
+ return out;
510
+ },
511
+ };
512
+
513
+ /** The autofill field name a control declares, ignoring qualifiers. "" when absent. */
514
+ function autofillField(el: El): string {
515
+ const tokens = (attr(el, "autocomplete") ?? "").trim().toLowerCase().split(/\s+/).filter(Boolean);
516
+ return tokens.find((t) => CREDENTIAL_FIELDS.has(t)) ?? "";
517
+ }
518
+
519
+ // WCAG 2.2 — 3.3.8 Accessible Authentication (Minimum). An authentication step must not
520
+ // force the user to reproduce a secret from memory or by hand. Blocking paste on a password
521
+ // field, or switching autofill off on a credential field, does exactly that: it breaks
522
+ // password managers and makes the step a cognitive-function test.
523
+ const credentialEntryBlocked: Rule = {
524
+ id: "credential-entry-blocked",
525
+ criteria: ["3.3.8"],
526
+ severity: "bloquant",
527
+ run(doc: Doc): RuleFinding[] {
528
+ const out: RuleFinding[] = [];
529
+ for (const el of doc.elements) {
530
+ if (!isIntrinsic(el.tag) || el.tag !== "input") continue;
531
+ const type = (attr(el, "type") ?? "text").toLowerCase();
532
+ const field = autofillField(el);
533
+ const isCredential = type === "password" || field !== "";
534
+ if (!isCredential) continue;
535
+ // Paste blocked by a handler that cancels the event. Only a literal, self-evident
536
+ // cancellation counts — an arbitrary handler may well permit the paste.
537
+ const paste = (attr(el, "onpaste") ?? attr(el, "onPaste") ?? "").replace(/\s+/g, "");
538
+ if (/preventDefault|returnfalse/i.test(paste)) {
539
+ out.push({ criteriaId: "3.3.8", el, msgId: "credential-entry-blocked.paste", params: { type } });
540
+ continue;
541
+ }
542
+ if ((attr(el, "autocomplete") ?? "").trim().toLowerCase() === "off") {
543
+ out.push({ criteriaId: "3.3.8", el, msgId: "credential-entry-blocked.autocomplete", params: { type } });
544
+ }
545
+ }
546
+ return out;
547
+ },
548
+ };
549
+
550
+ // WCAG 3.2.2 On Input: changing a control's value must not, on its own, submit the form or
551
+ // navigate. Only literal, unmistakable handlers are reported — anything else is the agent's
552
+ // call, not the engine's.
553
+ const CONTEXT_CHANGE = /\b(submit\(\)|form\.submit|location\s*[.=]|location\.href|window\.open|router\.(push|replace)|navigate\()/;
554
+
555
+ const onInputContextChange: Rule = {
556
+ id: "on-input-context-change",
557
+ criteria: ["3.2.2"],
558
+ severity: "majeur",
559
+ run(doc: Doc): RuleFinding[] {
560
+ const out: RuleFinding[] = [];
561
+ for (const el of doc.elements) {
562
+ if (!isIntrinsic(el.tag) || !["input", "select", "textarea"].includes(el.tag)) continue;
563
+ for (const key of ["onchange", "onChange"]) {
564
+ const handler = attr(el, key);
565
+ if (!handler || !CONTEXT_CHANGE.test(handler)) continue;
566
+ out.push({ criteriaId: "3.2.2", el, msgId: "on-input-context-change", params: { tag: el.tag } });
567
+ break;
568
+ }
569
+ }
570
+ return out;
571
+ },
572
+ };
573
+
574
+ export const formsRules: Rule[] = [
575
+ controlLabelMissing,
576
+ placeholderAsLabel,
577
+ fieldsetLegendMissing,
578
+ formFieldMultipleLabels,
579
+ selectHasOption,
580
+ labelForDangling,
581
+ ariaInvalidNoDescription,
582
+ errorNotAssociated,
583
+ fieldPurposeIncomplete,
584
+ disabledContextContent,
585
+ radioCheckboxGroupUngrouped,
586
+ dateFieldsUngrouped,
587
+ autocompleteTokenInvalid,
588
+ credentialEntryBlocked,
589
+ onInputContextChange,
590
+ ];
@@ -0,0 +1,33 @@
1
+ // Theme 2 — Frames.
2
+ import type { Doc, El } from "../parse/html.js";
3
+ import { attr, hasBoundAttr } from "../parse/html.js";
4
+ import { isNameExempt } from "../name.js";
5
+ import type { Rule, RuleFinding } from "./rule.js";
6
+
7
+ const iframeTitleMissing: Rule = {
8
+ id: "iframe-title-missing",
9
+ criteria: ["4.1.2"],
10
+ severity: "bloquant",
11
+ run(doc: Doc): RuleFinding[] {
12
+ const out: RuleFinding[] = [];
13
+ for (const el of doc.elements) {
14
+ if (el.tag !== "iframe") continue;
15
+ if (isNameExempt(el)) continue; // not exposed, or explicitly presentational
16
+ const title = (attr(el, "title") ?? "").trim();
17
+ const aria = (attr(el, "aria-label") ?? "").trim();
18
+ // aria-labelledby is the highest-precedence accname source. A present value names the
19
+ // frame; whether its ids resolve is aria-ref-missing-id's job, not a second "no title".
20
+ const labelledby = (attr(el, "aria-labelledby") ?? "").trim();
21
+ if (title || aria || labelledby) continue;
22
+ if (hasBoundAttr(el, "aria-labelledby")) continue; // dynamic :aria-labelledby / aria-labelledby={x}
23
+ out.push({
24
+ criteriaId: "4.1.2",
25
+ el: el as El,
26
+ msgId: "iframe-title-missing",
27
+ });
28
+ }
29
+ return out;
30
+ },
31
+ };
32
+
33
+ export const framesRules: Rule[] = [iframeTitleMissing];