@surea11y/core 1.1.2 → 1.3.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 (160) hide show
  1. package/CHANGELOG.md +48 -4
  2. package/LICENSE +373 -21
  3. package/README.md +70 -1
  4. package/bin/core.js +240 -11
  5. package/docs/API_STABILITY.md +61 -0
  6. package/docs/BASELINE.md +66 -0
  7. package/docs/BINDING_AUTHORS_GUIDE.md +9 -9
  8. package/docs/CI_INTEGRATIONS.md +103 -0
  9. package/docs/CLI.md +80 -1
  10. package/docs/ENGINE_OPTIONS.md +4 -0
  11. package/docs/INTEGRATION.md +19 -1
  12. package/docs/OUTPUT_SCHEMA.md +2 -2
  13. package/docs/REPORT.md +33 -0
  14. package/docs/RULE_AUTHORING.md +31 -0
  15. package/docs/RULE_CATALOG.md +1 -1
  16. package/docs/SARIF.md +59 -0
  17. package/package.json +15 -4
  18. package/src/baseline.js +0 -0
  19. package/src/catalogs/composites.wcag.js +414 -450
  20. package/src/checks/automatic/area-alt-present.js +59 -25
  21. package/src/checks/automatic/aria-allowed-attr.js +193 -33
  22. package/src/checks/automatic/aria-allowed-role.js +21 -7
  23. package/src/checks/automatic/aria-braille-equivalent.js +32 -10
  24. package/src/checks/automatic/aria-conditional-attr.js +24 -7
  25. package/src/checks/automatic/aria-deprecated-role.js +22 -8
  26. package/src/checks/automatic/aria-hidden-body.js +50 -19
  27. package/src/checks/automatic/aria-hidden-focus.js +408 -53
  28. package/src/checks/automatic/aria-prohibited-attr.js +296 -22
  29. package/src/checks/automatic/aria-prohibited-children.js +125 -29
  30. package/src/checks/automatic/aria-required-attr.js +23 -8
  31. package/src/checks/automatic/aria-required-children.js +37 -14
  32. package/src/checks/automatic/aria-required-parent.js +48 -14
  33. package/src/checks/automatic/aria-role-name-present.js +47 -21
  34. package/src/checks/automatic/aria-roles-valid.js +22 -12
  35. package/src/checks/automatic/aria-valid-attr-value.js +28 -7
  36. package/src/checks/automatic/aria-valid-attr.js +17 -5
  37. package/src/checks/automatic/autocomplete-valid.js +74 -16
  38. package/src/checks/automatic/avoid-inline-spacing.js +20 -6
  39. package/src/checks/automatic/binary-control-name-present.js +60 -50
  40. package/src/checks/automatic/button-name-present.js +48 -18
  41. package/src/checks/automatic/bypass-blocks-present.js +50 -25
  42. package/src/checks/automatic/canvas-text-alternative-present.js +57 -26
  43. package/src/checks/automatic/combobox-name-present.js +38 -45
  44. package/src/checks/automatic/contrast-computable.js +361 -341
  45. package/src/checks/automatic/contrast-enhanced.js +487 -466
  46. package/src/checks/automatic/contrast-minimum.js +486 -465
  47. package/src/checks/automatic/css-orientation-lock.js +39 -9
  48. package/src/checks/automatic/definition-list-children-valid.js +40 -19
  49. package/src/checks/automatic/deprecated-elements-not-used.js +21 -7
  50. package/src/checks/automatic/dialog-name-present.js +37 -75
  51. package/src/checks/automatic/dlitem-parent-valid.js +23 -8
  52. package/src/checks/automatic/duplicate-id-aria.js +24 -6
  53. package/src/checks/automatic/embed-text-alternative-present.js +86 -35
  54. package/src/checks/automatic/form-control-programmatic-label-present.js +79 -196
  55. package/src/checks/automatic/form-control-single-label.js +47 -10
  56. package/src/checks/automatic/html-xml-lang-mismatch.js +43 -19
  57. package/src/checks/automatic/iframe-focusable-content.js +26 -11
  58. package/src/checks/automatic/iframe-name-present.js +31 -9
  59. package/src/checks/automatic/iframe-title-unique.js +29 -8
  60. package/src/checks/automatic/img-alt-present.js +47 -43
  61. package/src/checks/automatic/input-image-alt-present.js +143 -112
  62. package/src/checks/automatic/label-in-name.js +50 -22
  63. package/src/checks/automatic/language-page-present.js +117 -109
  64. package/src/checks/automatic/link-in-text-block.js +59 -19
  65. package/src/checks/automatic/link-name-present.js +45 -14
  66. package/src/checks/automatic/list-children-valid.js +26 -9
  67. package/src/checks/automatic/listbox-name-present.js +39 -19
  68. package/src/checks/automatic/listitem-parent-valid.js +18 -6
  69. package/src/checks/automatic/menuitem-name-present.js +39 -61
  70. package/src/checks/automatic/meta-refresh-no-exceptions.js +37 -8
  71. package/src/checks/automatic/meta-refresh-timing-absent.js +28 -6
  72. package/src/checks/automatic/meta-viewport-zoom-enabled.js +32 -7
  73. package/src/checks/automatic/meter-name-present.js +36 -33
  74. package/src/checks/automatic/nested-interactive-controls-absent.js +31 -10
  75. package/src/checks/automatic/object-text-alternative-present.js +91 -39
  76. package/src/checks/automatic/option-name-present.js +38 -21
  77. package/src/checks/automatic/page-title-present.js +26 -7
  78. package/src/checks/automatic/progressbar-name-present.js +41 -34
  79. package/src/checks/automatic/role-img-alt-present.js +209 -157
  80. package/src/checks/automatic/searchbox-name-present.js +39 -19
  81. package/src/checks/automatic/server-side-image-map-absent.js +23 -8
  82. package/src/checks/automatic/slider-name-present.js +40 -47
  83. package/src/checks/automatic/spinbutton-name-present.js +39 -19
  84. package/src/checks/automatic/summary-name-present.js +37 -17
  85. package/src/checks/automatic/svg-image-text-alternative-present.js +114 -47
  86. package/src/checks/automatic/svg-text-alternative-present.js +246 -226
  87. package/src/checks/automatic/tab-name-present.js +37 -60
  88. package/src/checks/automatic/table-headers-attr-valid.js +24 -8
  89. package/src/checks/automatic/table-th-has-data-cells.js +22 -8
  90. package/src/checks/automatic/target-size-minimum.js +118 -48
  91. package/src/checks/automatic/td-has-header.js +29 -11
  92. package/src/checks/automatic/textbox-name-present.js +39 -19
  93. package/src/checks/automatic/tooltip-name-present.js +37 -18
  94. package/src/checks/automatic/treeitem-name-present.js +38 -21
  95. package/src/checks/automatic/valid-lang.js +20 -6
  96. package/src/checks/automatic/video-poster-text-alternative-present.js +79 -36
  97. package/src/checks/manual/accesskeys-manual.js +14 -5
  98. package/src/checks/manual/area-alt-decorative-manual.js +192 -193
  99. package/src/checks/manual/area-alt-quality-manual.js +182 -141
  100. package/src/checks/manual/aria-checked-state-mismatch-manual.js +34 -11
  101. package/src/checks/manual/aria-text-manual.js +14 -6
  102. package/src/checks/manual/canvas-text-alternative-quality-manual.js +149 -114
  103. package/src/checks/manual/css-hidden-focus.js +196 -165
  104. package/src/checks/manual/embed-text-alternative-quality-manual.js +171 -160
  105. package/src/checks/manual/empty-heading-manual.js +24 -12
  106. package/src/checks/manual/empty-table-header-manual.js +21 -14
  107. package/src/checks/manual/focus-order-semantics-manual.js +45 -10
  108. package/src/checks/manual/form-control-programmatic-label-quality-manual.js +207 -246
  109. package/src/checks/manual/heading-order-manual.js +22 -12
  110. package/src/checks/manual/identical-links-same-purpose-manual.js +34 -12
  111. package/src/checks/manual/image-redundant-alt-manual.js +17 -7
  112. package/src/checks/manual/img-alt-decorative-manual.js +131 -96
  113. package/src/checks/manual/img-alt-quality-manual.js +176 -127
  114. package/src/checks/manual/input-image-alt-decorative-manual.js +125 -92
  115. package/src/checks/manual/input-image-alt-quality-manual.js +125 -92
  116. package/src/checks/manual/label-title-only-manual.js +14 -5
  117. package/src/checks/manual/landmark-banner-is-top-level-manual.js +95 -29
  118. package/src/checks/manual/landmark-contentinfo-is-top-level-manual.js +82 -29
  119. package/src/checks/manual/landmark-main-is-top-level-manual.js +64 -23
  120. package/src/checks/manual/landmark-no-duplicate-banner-manual.js +54 -23
  121. package/src/checks/manual/landmark-no-duplicate-contentinfo-manual.js +54 -23
  122. package/src/checks/manual/landmark-no-duplicate-main-manual.js +17 -8
  123. package/src/checks/manual/landmark-one-main-manual.js +35 -21
  124. package/src/checks/manual/landmark-unique-manual.js +73 -45
  125. package/src/checks/manual/link-name-quality-manual.js +43 -12
  126. package/src/checks/manual/media-transcript-present-manual.js +35 -22
  127. package/src/checks/manual/meta-viewport-large-manual.js +25 -6
  128. package/src/checks/manual/mouse-only-event-handlers-manual.js +38 -11
  129. package/src/checks/manual/no-autoplay-audio-manual.js +20 -6
  130. package/src/checks/manual/object-text-alternative-quality-manual.js +175 -154
  131. package/src/checks/manual/p-as-heading-manual.js +22 -7
  132. package/src/checks/manual/page-has-heading-one-manual.js +40 -23
  133. package/src/checks/manual/page-title-patterns-manual.js +87 -51
  134. package/src/checks/manual/presentation-role-conflict-manual.js +59 -19
  135. package/src/checks/manual/region-manual.js +275 -62
  136. package/src/checks/manual/scope-attr-valid-manual.js +11 -9
  137. package/src/checks/manual/scrollable-region-focusable-manual.js +37 -11
  138. package/src/checks/manual/skip-link-manual.js +35 -12
  139. package/src/checks/manual/svg-text-alternative-quality-manual.js +206 -165
  140. package/src/checks/manual/tabindex-manual.js +11 -9
  141. package/src/checks/manual/table-duplicate-name-manual.js +17 -7
  142. package/src/checks/manual/table-fake-caption-manual.js +24 -7
  143. package/src/checks/manual/video-caption-manual.js +15 -4
  144. package/src/checks/manual-review.js +56 -12
  145. package/src/core/aria-helpers.js +1128 -823
  146. package/src/core/contrast-helpers.js +1217 -1062
  147. package/src/core/dom-helpers.js +4176 -3886
  148. package/src/core/dom-runner.js +720 -596
  149. package/src/core/frame-messaging.js +189 -138
  150. package/src/core/frame-scan.js +94 -82
  151. package/src/core/rollup-composites.js +94 -102
  152. package/src/core/rule-meta.js +71 -35
  153. package/src/core.js +37939 -29121
  154. package/src/i18n/en.js +1194 -887
  155. package/src/i18n/fr.js +1136 -793
  156. package/src/policy/contracts.js +13 -13
  157. package/src/policy/resolvePolicy.js +48 -44
  158. package/src/report.js +502 -0
  159. package/src/sarif.js +175 -0
  160. package/surea11y.browser.js +36042 -0
@@ -7,16 +7,21 @@
7
7
  * @standard WCAG 2.2
8
8
  * @sc 4.1.2
9
9
  * @applicability
10
- * Applies to elements whose explicit, valid role is one of the small set
11
- * of WAI-ARIA 1.2 roles with a documented "Prohibited ARIA States and
10
+ * Applies to (a) elements whose explicit, valid role is one of the small
11
+ * set of WAI-ARIA 1.2 roles with a documented "Prohibited ARIA States and
12
12
  * Properties" list (pure text-semantics / non-naming structural roles:
13
13
  * caption, code, deletion, emphasis, generic, insertion, mark, none,
14
14
  * paragraph, presentation, strong, subscript, suggestion, superscript,
15
- * time), and that also carry aria-label or aria-labelledby.
15
+ * time), and (b) a small, curated set of native HTML tags verified to
16
+ * carry no explicit or implicit ARIA role at all (see ROLELESS_NATIVE_TAGS
17
+ * below) — in both cases, only elements that also carry aria-label or
18
+ * aria-labelledby.
16
19
  * @expectation
17
- * Prohibited attributes must not be present, since these roles are
18
- * defined to never carry an accessible name — a naming attribute on them
19
- * is a real, deterministic spec violation, not a style preference.
20
+ * Prohibited attributes must not be present on (a); for (b), the naming
21
+ * attribute is at best unreliable (nothing accessible-name-aware to hang
22
+ * it off) and at worst silently ignored by assistive technology — see the
23
+ * roleless-branch implementation note below for the confidence split
24
+ * this produces.
20
25
  * @implementation-notes
21
26
  * - Deliberately scoped to the single, well-established prohibition class
22
27
  * (naming attributes on pure text-semantics roles) rather than
@@ -58,13 +63,66 @@
58
63
  * - Not rule-gated on isAccTreeEligible: this remains a static-markup
59
64
  * property, while engine-level hidden-subtree filtering still applies
60
65
  * unless engineOptions.includeHiddenElements is true.
66
+ * - Widened 2026-07-31 to add a second, independent branch covering
67
+ * naming attributes on ROLELESS elements (no explicit role="", no
68
+ * implicit/native role either) — found on the emoji-mart demo page
69
+ * (missive.github.io/emoji-mart): hundreds of
70
+ * `<span aria-label="party_parrot" class="emoji-mart-emoji...">` tiles,
71
+ * plain roleless spans with no other accessible-name source, which this
72
+ * rule previously ignored entirely, since its own Tier-1 branch only ever
73
+ * looked at the EXPLICIT role="" attribute, never at "no role at all."
74
+ * Empirically determined (not guessed) which native tags genuinely carry
75
+ * no role at all, by resolving each candidate tag's role against a live
76
+ * Chromium page — several surprises: common text-level tags like `<p>`,
77
+ * `<strong>`, `<em>`, `<code>`, `<mark>`, `<time>` have no implicit role
78
+ * at all (their prohibited-attrs entries only ever matter for an
79
+ * EXPLICIT `role="paragraph"`/`role="strong"`/etc. restatement, a rare
80
+ * case — the native tag itself resolves to role `null`, same as a bare
81
+ * `<div>`/`<span>`, and falls into this same roleless branch). See
82
+ * ROLELESS_NATIVE_TAGS below for the resulting curated list —
83
+ * deliberately conservative: `<section>`/`<form>`/`<a>` are excluded
84
+ * even though they can also resolve to no role, because their native
85
+ * role is conditional (name-dependent/href-dependent) and already has
86
+ * dedicated, more nuanced handling elsewhere in this engine
87
+ * (`getElementRoleKey`'s `section`/`section[named]`/`header`/
88
+ * `header[toplevel]` branches) that this rule doesn't attempt to
89
+ * duplicate.
90
+ * Two confidence tiers instead of a flat fail: if the element's subtree
91
+ * ALREADY produces a non-empty accessible name from its content
92
+ * (computed the same way link-name-present/button-name-present do, via
93
+ * `helpers.getContentNameInfo`), the naming attribute might just be a
94
+ * redundant/intentional override — reported as `cantTell`, not a hard
95
+ * fail. Only a roleless element with NO other accessible-name source at
96
+ * all (the emoji-mart case: an icon-only span, background-image styled,
97
+ * no text anywhere in its subtree) is a confident, deterministic `fail`
98
+ * — nothing else could ever expose this element's name, and no role
99
+ * exists to make it a Name/Role/Value candidate in the first place.
100
+ * The widget-ancestor exemption (skip when the closest real ancestor role
101
+ * is a "widget"-type role) avoids over-flagging roleless helper
102
+ * spans/divs used as internal decoration inside a custom composite
103
+ * widget.
104
+ * - Fixed 2026-07-31 (same day as introduced): the Tier-2 "already has a
105
+ * role, not this branch's concern" guard checked only whether `role=""`
106
+ * was present (`getExplicitRole`), not whether the value was a real,
107
+ * recognized ARIA role. An invalid/typo'd role token (e.g.
108
+ * `role="totally-bogus"`) therefore silently suppressed detection of an
109
+ * otherwise-flaggable roleless naming attribute — identical markup with
110
+ * the bogus role attribute removed entirely correctly failed, but with
111
+ * it present the element was skipped as if it had a real role. Per spec
112
+ * (and per this same file's own `getNearestAncestorRole` helper a few
113
+ * lines below, which already gets this right), an unrecognized role
114
+ * token is ignored by the accessibility tree, not honored — the element
115
+ * is still effectively roleless. Now validates via the existing
116
+ * `isValidConcreteRole` before treating an explicit role as real,
117
+ * matching `getNearestAncestorRole`'s own pattern.
61
118
  */
62
119
 
63
120
  const id = 'aria-prohibited-attr';
64
121
 
65
122
  const meta = {
66
123
  title: 'ARIA naming attributes must not be used on roles that prohibit them',
67
- description: 'Checks that aria-label/aria-labelledby are not present on WAI-ARIA roles whose specification explicitly prohibits ARIA naming (e.g. generic, emphasis, strong, paragraph).',
124
+ description:
125
+ 'Checks that aria-label/aria-labelledby are not present on WAI-ARIA roles whose specification explicitly prohibits ARIA naming (e.g. generic, emphasis, strong, paragraph).',
68
126
  i18n: {
69
127
  titleKey: 'ariaProhibitedAttr_title',
70
128
  descriptionKey: 'ariaProhibitedAttr_description'
@@ -73,7 +131,13 @@ const meta = {
73
131
  tags: ['wcag2a', 'wcag412', 'aria', 'structure', 'atomic', 'automatic'],
74
132
  wcagSc: ['4.1.2'],
75
133
  normativeMappings: [
76
- { standard: 'WCAG', version: '2.2', requirement: '4.1.2', title: 'Name, Role, Value', conformanceLevel: 'A' }
134
+ {
135
+ standard: 'WCAG',
136
+ version: '2.2',
137
+ requirement: '4.1.2',
138
+ title: 'Name, Role, Value',
139
+ conformanceLevel: 'A'
140
+ }
77
141
  ],
78
142
  defaultSeverity: 'moderate',
79
143
  category: 'robust',
@@ -83,7 +147,7 @@ const meta = {
83
147
  };
84
148
 
85
149
  function runInPage(ctx) {
86
- const { document, helpers, rule } = ctx;
150
+ const { helpers, rule } = ctx;
87
151
 
88
152
  const ariaHelpers = helpers && helpers.aria ? helpers.aria : null;
89
153
  if (!ariaHelpers) {
@@ -96,19 +160,36 @@ function runInPage(ctx) {
96
160
  // scope) because the build inlines only this function's own source text
97
161
  // — see scripts/build-core.js header ("runInPage MUST be self-contained").
98
162
  const ROLES_PROHIBITING_NAME = new Set([
99
- 'caption', 'code', 'deletion', 'emphasis', 'generic', 'insertion',
100
- 'mark', 'none', 'paragraph', 'presentation', 'strong', 'subscript',
101
- 'suggestion', 'superscript', 'time'
163
+ 'caption',
164
+ 'code',
165
+ 'deletion',
166
+ 'emphasis',
167
+ 'generic',
168
+ 'insertion',
169
+ 'mark',
170
+ 'none',
171
+ 'paragraph',
172
+ 'presentation',
173
+ 'strong',
174
+ 'subscript',
175
+ 'suggestion',
176
+ 'superscript',
177
+ 'time'
102
178
  ]);
103
179
 
104
180
  const PROHIBITED_NAMING_ATTRS = ['aria-label', 'aria-labelledby'];
105
181
 
106
- const nodes = helpers.queryAllSmart ? helpers.queryAllSmart('[role]') : helpers.queryAll('[role]');
107
-
108
- const occurrences = [];
182
+ const failOccurrences = [];
183
+ const cantTellOccurrences = [];
109
184
  let applicableCount = 0;
110
185
 
111
- for (const el of nodes) {
186
+ // --- Tier 1: explicit, valid role from the naming-prohibited set ---
187
+
188
+ const roleNodes = helpers.queryAllSmart
189
+ ? helpers.queryAllSmart('[role]')
190
+ : helpers.queryAll('[role]');
191
+
192
+ for (const el of roleNodes) {
112
193
  if (!el || !el.getAttribute) continue;
113
194
 
114
195
  const role = ariaHelpers.getExplicitRole(el);
@@ -125,10 +206,10 @@ function runInPage(ctx) {
125
206
  if (!present.length) continue;
126
207
 
127
208
  const stableSelector = helpers.buildSelector ? helpers.buildSelector(el) : 'html';
128
- const html = helpers.getOuterHtmlSnippet ? helpers.getOuterHtmlSnippet(el) : (el.outerHTML || '');
209
+ const html = helpers.getOuterHtmlSnippet ? helpers.getOuterHtmlSnippet(el) : el.outerHTML || '';
129
210
 
130
211
  for (const attr of present) {
131
- occurrences.push({
212
+ failOccurrences.push({
132
213
  selector: stableSelector,
133
214
  html,
134
215
  summary: 'This attribute is prohibited on this element’s role.',
@@ -145,13 +226,206 @@ function runInPage(ctx) {
145
226
  }
146
227
  }
147
228
 
229
+ // --- Tier 2: no role at all (see header comment for the full rationale
230
+ // and how ROLELESS_NATIVE_TAGS/WIDGET_TYPE_ROLES were derived) ---
231
+
232
+ // Small, curated set of native tags empirically verified (against a
233
+ // widely-used reference engine's own getRole() at runtime, not guessed)
234
+ // to carry no explicit or implicit ARIA role. Deliberately excludes
235
+ // <section>/<form>/<a> — all conditionally roleless too, but already
236
+ // handled with more nuance elsewhere in this engine (see header comment).
237
+ const ROLELESS_NATIVE_TAGS = new Set([
238
+ 'p',
239
+ 'b',
240
+ 'i',
241
+ 'em',
242
+ 'strong',
243
+ 'span',
244
+ 'div',
245
+ 'code',
246
+ 'mark',
247
+ 'time',
248
+ 'ins',
249
+ 'del',
250
+ 'small',
251
+ 'sub',
252
+ 'sup',
253
+ 'abbr',
254
+ 'cite',
255
+ 'q',
256
+ 'kbd',
257
+ 'samp',
258
+ 'var',
259
+ 'address',
260
+ 'blockquote',
261
+ 'pre',
262
+ 'figcaption',
263
+ 'picture',
264
+ 'template',
265
+ 'hgroup',
266
+ 'wbr',
267
+ 'br',
268
+ 'legend'
269
+ ]);
270
+
271
+ // WAI-ARIA roles a widely-used reference engine's own role table types as
272
+ // "widget" (verified directly against its source, not the six-category
273
+ // WAI-ARIA taxonomy — this engine's algorithm branches on its own `type`
274
+ // field, so parity means matching that field exactly).
275
+ const WIDGET_TYPE_ROLES = new Set([
276
+ 'alert',
277
+ 'alertdialog',
278
+ 'button',
279
+ 'checkbox',
280
+ 'combobox',
281
+ 'dialog',
282
+ 'gridcell',
283
+ 'link',
284
+ 'listbox',
285
+ 'log',
286
+ 'marquee',
287
+ 'menuitem',
288
+ 'menuitemcheckbox',
289
+ 'menuitemradio',
290
+ 'option',
291
+ 'progressbar',
292
+ 'radio',
293
+ 'scrollbar',
294
+ 'searchbox',
295
+ 'slider',
296
+ 'spinbutton',
297
+ 'status',
298
+ 'switch',
299
+ 'tab',
300
+ 'tabpanel',
301
+ 'textbox',
302
+ 'timer',
303
+ 'treeitem'
304
+ ]);
305
+
306
+ const getComposedParent =
307
+ helpers && typeof helpers.composedParent === 'function'
308
+ ? helpers.composedParent
309
+ : function (n) {
310
+ return n && n.parentElement ? n.parentElement : null;
311
+ };
312
+
313
+ // Nearest ancestor's real role (explicit-if-valid, else native/implicit),
314
+ // skipping roleless/presentation/none ancestors — used only to check
315
+ // whether that role is a "widget"-type one (the roleless-branch
316
+ // exemption). Not the same helper as aria-required-parent's containment
317
+ // walk: this one also accepts non-required-context roles.
318
+ function getNearestAncestorRole(el) {
319
+ let cur = getComposedParent(el);
320
+ let guard = 0;
321
+ while (cur && guard++ < 200) {
322
+ if (cur.nodeType !== 1) {
323
+ cur = getComposedParent(cur);
324
+ continue;
325
+ }
326
+ const explicit = ariaHelpers.getExplicitRole(cur);
327
+ const role =
328
+ explicit && ariaHelpers.isValidConcreteRole(explicit)
329
+ ? explicit
330
+ : ariaHelpers.getNativeRoleForElement(cur);
331
+ if (!role || role === 'presentation' || role === 'none') {
332
+ cur = getComposedParent(cur);
333
+ continue;
334
+ }
335
+ return role;
336
+ }
337
+ return '';
338
+ }
339
+
340
+ const namingSelector = '[aria-label],[aria-labelledby]';
341
+ const namingNodes = helpers.queryAllSmart
342
+ ? helpers.queryAllSmart(namingSelector)
343
+ : helpers.queryAll(namingSelector);
344
+
345
+ for (const el of namingNodes) {
346
+ if (!el || !el.getAttribute) continue;
347
+
348
+ const tag = String(el.tagName || '').toLowerCase();
349
+ if (!ROLELESS_NATIVE_TAGS.has(tag)) continue;
350
+ const explicitRole = ariaHelpers.getExplicitRole(el);
351
+ if (explicitRole && ariaHelpers.isValidConcreteRole(explicitRole)) continue; // has a real, recognized role — Tier 1's concern (if in ROLES_PROHIBITING_NAME) or a role this rule has no opinion on. An INVALID role token (e.g. a typo) is ignored per spec, same as no role attribute at all, and must still fall through to this branch.
352
+ if (ariaHelpers.getNativeRoleForElement(el)) continue; // has a real implicit role after all — not this branch's concern
353
+
354
+ const present = [];
355
+ for (const attr of PROHIBITED_NAMING_ATTRS) {
356
+ const v = el.getAttribute(attr);
357
+ if (v != null && String(v).trim() !== '') present.push(attr);
358
+ }
359
+ if (!present.length) continue;
360
+
361
+ applicableCount += 1;
362
+
363
+ const ancestorRole = getNearestAncestorRole(el);
364
+ if (ancestorRole && WIDGET_TYPE_ROLES.has(ancestorRole)) continue; // roleless helper node inside a real widget — not flagged
365
+
366
+ const nameInfo = helpers.getContentNameInfo ? helpers.getContentNameInfo(el, ctx) : null;
367
+ const hasContentFallback = !!(
368
+ nameInfo &&
369
+ nameInfo.present &&
370
+ String(nameInfo.value || '').trim() !== ''
371
+ );
372
+
373
+ const stableSelector = helpers.buildSelector ? helpers.buildSelector(el) : 'html';
374
+ const html = helpers.getOuterHtmlSnippet ? helpers.getOuterHtmlSnippet(el) : el.outerHTML || '';
375
+
376
+ for (const attr of present) {
377
+ if (hasContentFallback) {
378
+ cantTellOccurrences.push({
379
+ selector: stableSelector,
380
+ html,
381
+ summary: `This ${tag} has no role, so ${attr} may not be exposed as its accessible name by assistive technology — but the element's own content already provides one.`,
382
+ hint: 'Verify whether the existing text content already serves as this element’s label; if so the naming attribute is redundant, otherwise give the element a role that supports naming (e.g. role="img").',
383
+ i18n: {
384
+ summaryKey: 'ariaProhibitedAttr_summary_cantTell_roleless',
385
+ hintKey: 'ariaProhibitedAttr_hint_cantTell_roleless',
386
+ params: { attr, element: tag }
387
+ },
388
+ data: {
389
+ details: {
390
+ reasonCode: 'ARIA_ATTR_PROHIBITED_ROLELESS_NEEDS_REVIEW',
391
+ attr,
392
+ role: null,
393
+ element: tag
394
+ }
395
+ }
396
+ });
397
+ } else {
398
+ failOccurrences.push({
399
+ selector: stableSelector,
400
+ html,
401
+ summary: `This ${tag} has no role and no other accessible-name source, so ${attr} is not reliably exposed to assistive technology.`,
402
+ hint: 'Give this element a role that supports an accessible name (e.g. role="img"/"button"), or remove this attribute if it serves no purpose without one.',
403
+ i18n: {
404
+ summaryKey: 'ariaProhibitedAttr_summary_fail_roleless',
405
+ hintKey: 'ariaProhibitedAttr_hint_fail_roleless',
406
+ params: { attr, element: tag }
407
+ },
408
+ data: {
409
+ details: { reasonCode: 'ARIA_ATTR_PROHIBITED_ROLELESS', attr, role: null, element: tag }
410
+ }
411
+ });
412
+ }
413
+ }
414
+ }
415
+
148
416
  if (applicableCount === 0) {
149
417
  return { ruleId: rule.ruleId, outcome: 'notApplicable', severity: 'minor', occurrences: [] };
150
418
  }
151
- if (occurrences.length) {
152
- return { ruleId: rule.ruleId, outcome: 'fail', severity: rule.defaultSeverity || 'moderate', occurrences };
153
- }
154
- return { ruleId: rule.ruleId, outcome: 'pass', severity: 'minor', occurrences: [] };
419
+
420
+ // See helpers.resolveTieredOutcome's own header comment (src/core/dom-helpers.js):
421
+ // a fail-tier finding never silently discards cantTell-tier findings from
422
+ // the same run — both are returned together when the outcome is 'fail'.
423
+ const resolved = helpers.resolveTieredOutcome(
424
+ failOccurrences,
425
+ cantTellOccurrences,
426
+ rule.defaultSeverity || 'moderate'
427
+ );
428
+ return { ruleId: rule.ruleId, ...resolved };
155
429
  }
156
430
 
157
431
  module.exports = { id, meta, runInPage };
@@ -52,12 +52,42 @@
52
52
  * focusability) are static, declarative markup facts with no live-DOM/
53
53
  * hydration risk, unlike e.g. `aria-checked-state-mismatch`'s DOM-
54
54
  * property comparison.
55
+ * - Fixed 2026-07-30: a roleless-but-focusable descendant's message and
56
+ * `data.details.attr` used to always claim "carries tabindex" even when
57
+ * the element had no tabindex attribute at all and was only focusable
58
+ * natively (e.g. an <a href> link). `helpers.getFocusableInfo`'s
59
+ * `mechanism` field ('tabindex' | 'native' | ...) is now used to tell
60
+ * the two apart, with a distinct `nativeFocusable` attr/message for the
61
+ * native case. Found via a real Angular app: a routerLink <a> inside a
62
+ * role="list" was reported as "carries tabindex" though the rendered
63
+ * markup had no such attribute.
55
64
  * - Recursion stops at the first non-transparent role boundary, same as
56
65
  * that reference engine: a nested container with its own real role (e.g. a
57
66
  * <div role="listbox"> inside a menubar) is evaluated as ITS OWN
58
67
  * owned-role entry against the outer container (and, separately, gets
59
68
  * its own applicability pass as a container in the same rule run) —
60
69
  * its descendants are never misattributed to the outer container.
70
+ * - Fixed 2026-07-31: child-role resolution used `ariaHelpers.getExplicitRole`
71
+ * (explicit role="" attribute only), unlike aria-required-children's
72
+ * descendant matching which uses `ariaHelpers.getContainmentRole` (explicit
73
+ * role, falling back to the native-tag map — li/tr/td/th/tbody/ul/ol/
74
+ * table/select/input[type=radio] — see that helper's own header comment).
75
+ * A bare `<li>` with no role="" attribute — the common CSS-reset
76
+ * workaround `<ul role="list"><li>...</li></ul>` that getContainmentRole
77
+ * exists specifically to handle — was therefore read as roleless here,
78
+ * making it structurally transparent: the walk recursed straight through
79
+ * the listitem boundary into its subtree and could report a focusable
80
+ * descendant several levels down as a disallowed owned child of the list,
81
+ * instead of stopping at the (implicit) listitem the way
82
+ * aria-required-children already does. Switched to getContainmentRole so
83
+ * both rules resolve an owned child's role identically. This is a general
84
+ * fix, not list/listitem-specific: it applies to every container role in
85
+ * REQUIRED_OWNED_ROLES whose native-tag counterpart the child map covers
86
+ * (e.g. a bare `<tr>`/`<td>` under a role="table"/"grid"/"row" container
87
+ * with no explicit role="" was subject to the same flattening bug). Found
88
+ * via a real Angular Material-style component library: an `<a routerlink>`
89
+ * several DOM levels inside a bare `<li>` under `<ul role="list">` was
90
+ * reported as an unallowed owned child of the list.
61
91
  * - Gated on isAccTreeEligible for the container itself, matching the fix
62
92
  * applied to aria-required-children (see that rule's header): the
63
93
  * original "not gated" note here just cited that rule's reasoning
@@ -81,7 +111,8 @@ const id = 'aria-prohibited-children';
81
111
 
82
112
  const meta = {
83
113
  title: 'Container roles must not own a child with a disallowed role',
84
- description: 'Checks that every accessible-tree-owned child of a container role (list, listbox, menu, menubar, radiogroup, rowgroup, table, grid, treegrid, tablist, tree, row) has one of that role\'s allowed owned roles — the same set as its required owned roles.',
114
+ description:
115
+ "Checks that every accessible-tree-owned child of a container role (list, listbox, menu, menubar, radiogroup, rowgroup, table, grid, treegrid, tablist, tree, row) has one of that role's allowed owned roles — the same set as its required owned roles.",
85
116
  i18n: {
86
117
  titleKey: 'ariaProhibitedChildren_title',
87
118
  descriptionKey: 'ariaProhibitedChildren_description'
@@ -90,7 +121,13 @@ const meta = {
90
121
  tags: ['wcag2a', 'wcag412', 'aria', 'structure', 'atomic', 'automatic'],
91
122
  wcagSc: ['4.1.2'],
92
123
  normativeMappings: [
93
- { standard: 'WCAG', version: '2.2', requirement: '4.1.2', title: 'Name, Role, Value', conformanceLevel: 'A' }
124
+ {
125
+ standard: 'WCAG',
126
+ version: '2.2',
127
+ requirement: '4.1.2',
128
+ title: 'Name, Role, Value',
129
+ conformanceLevel: 'A'
130
+ }
94
131
  ],
95
132
  defaultSeverity: 'moderate',
96
133
  category: 'robust',
@@ -100,7 +137,7 @@ const meta = {
100
137
  };
101
138
 
102
139
  function runInPage(ctx) {
103
- const { document, helpers, rule } = ctx;
140
+ const { helpers, rule } = ctx;
104
141
 
105
142
  const ariaHelpers = helpers && helpers.aria ? helpers.aria : null;
106
143
  if (!ariaHelpers) {
@@ -108,7 +145,8 @@ function runInPage(ctx) {
108
145
  }
109
146
 
110
147
  function isEligibleAcc(el) {
111
- const fn = helpers && typeof helpers.isAccTreeEligible === 'function' ? helpers.isAccTreeEligible : null;
148
+ const fn =
149
+ helpers && typeof helpers.isAccTreeEligible === 'function' ? helpers.isAccTreeEligible : null;
112
150
  if (!fn) return true;
113
151
  try {
114
152
  const r = fn(el, ctx);
@@ -125,12 +163,30 @@ function runInPage(ctx) {
125
163
  // roleless descendant carrying any of these is a real accessible-tree
126
164
  // node a widely-used reference engine's getOwnedRoles also flags, not a transparent wrapper.
127
165
  const GLOBAL_ARIA_ATTRS = [
128
- 'aria-atomic', 'aria-braillelabel', 'aria-brailleroledescription', 'aria-busy',
129
- 'aria-controls', 'aria-current', 'aria-describedby', 'aria-description',
130
- 'aria-details', 'aria-disabled', 'aria-dropeffect', 'aria-errormessage',
131
- 'aria-flowto', 'aria-grabbed', 'aria-haspopup', 'aria-hidden', 'aria-invalid',
132
- 'aria-keyshortcuts', 'aria-label', 'aria-labelledby', 'aria-live', 'aria-owns',
133
- 'aria-relevant', 'aria-roledescription'
166
+ 'aria-atomic',
167
+ 'aria-braillelabel',
168
+ 'aria-brailleroledescription',
169
+ 'aria-busy',
170
+ 'aria-controls',
171
+ 'aria-current',
172
+ 'aria-describedby',
173
+ 'aria-description',
174
+ 'aria-details',
175
+ 'aria-disabled',
176
+ 'aria-dropeffect',
177
+ 'aria-errormessage',
178
+ 'aria-flowto',
179
+ 'aria-grabbed',
180
+ 'aria-haspopup',
181
+ 'aria-hidden',
182
+ 'aria-invalid',
183
+ 'aria-keyshortcuts',
184
+ 'aria-label',
185
+ 'aria-labelledby',
186
+ 'aria-live',
187
+ 'aria-owns',
188
+ 'aria-relevant',
189
+ 'aria-roledescription'
134
190
  ];
135
191
 
136
192
  function getGlobalAriaAttr(el) {
@@ -151,6 +207,11 @@ function runInPage(ctx) {
151
207
  // entry with role: null, which can never satisfy a required-role set)
152
208
  // when it carries a global aria-* attribute or is focusable — matches
153
209
  // a widely-used reference engine's own getOwnedRoles exactly (see header comment).
210
+ // kidRole comes from getContainmentRole, not getExplicitRole (see header
211
+ // comment's 2026-07-31 fix): "roleless" here means neither an explicit
212
+ // role="" NOR one of the native containment tags (li, tr, td, ...), so a
213
+ // bare <li>/<tr>/... is a real listitem/row boundary, not a transparent
214
+ // wrapper the walk should pass through.
154
215
  function collectOwnedRoles(el, requiredSet, out, depth) {
155
216
  if (depth > MAX_DEPTH) return;
156
217
  const kids = el.children ? Array.prototype.slice.call(el.children) : [];
@@ -158,21 +219,31 @@ function runInPage(ctx) {
158
219
  if (!kid || kid.nodeType !== 1) continue;
159
220
  if (!isEligibleAcc(kid)) continue;
160
221
 
161
- const kidRole = ariaHelpers.getExplicitRole(kid);
222
+ const kidRole = ariaHelpers.getContainmentRole(kid);
162
223
  const isPresentational = kidRole === 'presentation' || kidRole === 'none';
163
- const isTransparentGroup = (kidRole === 'group' || kidRole === 'rowgroup') && requiredSet.has(kidRole);
224
+ const isTransparentGroup =
225
+ (kidRole === 'group' || kidRole === 'rowgroup') && requiredSet.has(kidRole);
164
226
 
165
227
  if (!kidRole && !isPresentational) {
166
228
  const globalAttr = getGlobalAriaAttr(kid);
167
- let focusable = false;
229
+ let mechanism;
168
230
  try {
169
- const fi = helpers && typeof helpers.getFocusableInfo === 'function' ? helpers.getFocusableInfo(kid, ctx) : null;
170
- focusable = !!(fi && fi.focusable);
231
+ const fi =
232
+ helpers && typeof helpers.getFocusableInfo === 'function'
233
+ ? helpers.getFocusableInfo(kid, ctx)
234
+ : null;
235
+ mechanism = (fi && fi.focusable && fi.mechanism) || 'none';
171
236
  } catch {
172
- focusable = false;
237
+ mechanism = 'none';
173
238
  }
174
- if (globalAttr || focusable) {
175
- out.push({ el: kid, role: null, attr: globalAttr || 'tabindex' });
239
+ if (globalAttr || mechanism !== 'none') {
240
+ // `mechanism` distinguishes an actual tabindex="" attribute from
241
+ // native focusability (e.g. <a href>, <button>, <input>) — these
242
+ // are different facts and must not be reported as the same
243
+ // "carries tabindex" claim (a native anchor with no tabindex
244
+ // attribute at all is not "carrying tabindex").
245
+ const attr = globalAttr || (mechanism === 'tabindex' ? 'tabindex' : 'nativeFocusable');
246
+ out.push({ el: kid, role: null, attr });
176
247
  continue; // real accessible-tree node: stop here, do not recurse further
177
248
  }
178
249
  }
@@ -189,7 +260,9 @@ function runInPage(ctx) {
189
260
  }
190
261
  }
191
262
 
192
- const nodes = helpers.queryAllSmart ? helpers.queryAllSmart('[role]') : helpers.queryAll('[role]');
263
+ const nodes = helpers.queryAllSmart
264
+ ? helpers.queryAllSmart('[role]')
265
+ : helpers.queryAll('[role]');
193
266
 
194
267
  const occurrences = [];
195
268
  let applicableCount = 0;
@@ -215,16 +288,34 @@ function runInPage(ctx) {
215
288
  if (entry.role && requiredSet.has(entry.role)) continue;
216
289
 
217
290
  const stableSelector = helpers.buildSelector ? helpers.buildSelector(entry.el) : 'html';
218
- const html = helpers.getOuterHtmlSnippet ? helpers.getOuterHtmlSnippet(entry.el) : (entry.el.outerHTML || '');
291
+ const html = helpers.getOuterHtmlSnippet
292
+ ? helpers.getOuterHtmlSnippet(entry.el)
293
+ : entry.el.outerHTML || '';
219
294
  const containerSelector = helpers.buildSelector ? helpers.buildSelector(el) : 'html';
220
295
 
221
296
  const isRoleless = !entry.role;
222
- const summary = isRoleless
223
- ? `This element has no explicit role but carries ${entry.attr}, making it a real accessible-tree node that is not an allowed owned child of the enclosing role="${role}" container.`
224
- : `This element has role="${entry.role}", which is not an allowed owned child of the enclosing role="${role}" container.`;
225
- const hint = isRoleless
226
- ? `Remove ${entry.attr} (or the role="${role}" container ownership), or give this element role="presentation"/"none" if it isn't meant to be its own accessible-tree node.`
227
- : `Remove or change this role so it matches one of the container's allowed owned roles (${requiredOwned.join(', ')}), or move this element outside the ${role} container.`;
297
+ const isNativeFocusable = entry.attr === 'nativeFocusable';
298
+
299
+ let summary;
300
+ let hint;
301
+ let summaryKey;
302
+ let hintKey;
303
+ if (isNativeFocusable) {
304
+ summary = `This element has no explicit role but is natively focusable, making it a real accessible-tree node that is not an allowed owned child of the enclosing role="${role}" container.`;
305
+ hint = `Give this element role="presentation"/"none", remove its native focusability (e.g. drop the href/tabindex-granting attribute), or move it outside the ${role} container.`;
306
+ summaryKey = 'ariaProhibitedChildren_summary_fail_native_focusable';
307
+ hintKey = 'ariaProhibitedChildren_hint_fail_native_focusable';
308
+ } else if (isRoleless) {
309
+ summary = `This element has no explicit role but carries ${entry.attr}, making it a real accessible-tree node that is not an allowed owned child of the enclosing role="${role}" container.`;
310
+ hint = `Remove ${entry.attr} (or the role="${role}" container ownership), or give this element role="presentation"/"none" if it isn't meant to be its own accessible-tree node.`;
311
+ summaryKey = 'ariaProhibitedChildren_summary_fail_roleless';
312
+ hintKey = 'ariaProhibitedChildren_hint_fail_roleless';
313
+ } else {
314
+ summary = `This element has role="${entry.role}", which is not an allowed owned child of the enclosing role="${role}" container.`;
315
+ hint = `Remove or change this role so it matches one of the container's allowed owned roles (${requiredOwned.join(', ')}), or move this element outside the ${role} container.`;
316
+ summaryKey = 'ariaProhibitedChildren_summary_fail';
317
+ hintKey = 'ariaProhibitedChildren_hint_fail';
318
+ }
228
319
 
229
320
  occurrences.push({
230
321
  selector: stableSelector,
@@ -232,8 +323,8 @@ function runInPage(ctx) {
232
323
  summary,
233
324
  hint,
234
325
  i18n: {
235
- summaryKey: isRoleless ? 'ariaProhibitedChildren_summary_fail_roleless' : 'ariaProhibitedChildren_summary_fail',
236
- hintKey: isRoleless ? 'ariaProhibitedChildren_hint_fail_roleless' : 'ariaProhibitedChildren_hint_fail',
326
+ summaryKey,
327
+ hintKey,
237
328
  params: isRoleless
238
329
  ? { attr: entry.attr, containerRole: role }
239
330
  : { childRole: entry.role, containerRole: role, allowedRoles: requiredOwned.join(', ') }
@@ -256,7 +347,12 @@ function runInPage(ctx) {
256
347
  return { ruleId: rule.ruleId, outcome: 'notApplicable', severity: 'minor', occurrences: [] };
257
348
  }
258
349
  if (occurrences.length) {
259
- return { ruleId: rule.ruleId, outcome: 'fail', severity: rule.defaultSeverity || 'moderate', occurrences };
350
+ return {
351
+ ruleId: rule.ruleId,
352
+ outcome: 'fail',
353
+ severity: rule.defaultSeverity || 'moderate',
354
+ occurrences
355
+ };
260
356
  }
261
357
  return { ruleId: rule.ruleId, outcome: 'pass', severity: 'minor', occurrences: [] };
262
358
  }