@surea11y/core 1.2.0 → 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 (157) hide show
  1. package/CHANGELOG.md +37 -7
  2. package/LICENSE +373 -21
  3. package/README.md +67 -1
  4. package/bin/core.js +144 -19
  5. package/docs/API_STABILITY.md +1 -1
  6. package/docs/BINDING_AUTHORS_GUIDE.md +9 -9
  7. package/docs/CI_INTEGRATIONS.md +103 -0
  8. package/docs/CLI.md +54 -1
  9. package/docs/ENGINE_OPTIONS.md +2 -0
  10. package/docs/INTEGRATION.md +18 -0
  11. package/docs/OUTPUT_SCHEMA.md +1 -1
  12. package/docs/RULE_CATALOG.md +1 -1
  13. package/docs/SARIF.md +59 -0
  14. package/package.json +15 -4
  15. package/src/baseline.js +0 -0
  16. package/src/catalogs/composites.wcag.js +414 -450
  17. package/src/checks/automatic/area-alt-present.js +59 -25
  18. package/src/checks/automatic/aria-allowed-attr.js +193 -33
  19. package/src/checks/automatic/aria-allowed-role.js +21 -7
  20. package/src/checks/automatic/aria-braille-equivalent.js +32 -10
  21. package/src/checks/automatic/aria-conditional-attr.js +24 -7
  22. package/src/checks/automatic/aria-deprecated-role.js +22 -8
  23. package/src/checks/automatic/aria-hidden-body.js +42 -19
  24. package/src/checks/automatic/aria-hidden-focus.js +408 -53
  25. package/src/checks/automatic/aria-prohibited-attr.js +296 -22
  26. package/src/checks/automatic/aria-prohibited-children.js +55 -16
  27. package/src/checks/automatic/aria-required-attr.js +23 -8
  28. package/src/checks/automatic/aria-required-children.js +37 -14
  29. package/src/checks/automatic/aria-required-parent.js +48 -14
  30. package/src/checks/automatic/aria-role-name-present.js +47 -21
  31. package/src/checks/automatic/aria-roles-valid.js +22 -12
  32. package/src/checks/automatic/aria-valid-attr-value.js +28 -7
  33. package/src/checks/automatic/aria-valid-attr.js +17 -5
  34. package/src/checks/automatic/autocomplete-valid.js +74 -16
  35. package/src/checks/automatic/avoid-inline-spacing.js +20 -6
  36. package/src/checks/automatic/binary-control-name-present.js +60 -50
  37. package/src/checks/automatic/button-name-present.js +48 -18
  38. package/src/checks/automatic/bypass-blocks-present.js +42 -25
  39. package/src/checks/automatic/canvas-text-alternative-present.js +57 -26
  40. package/src/checks/automatic/combobox-name-present.js +38 -45
  41. package/src/checks/automatic/contrast-computable.js +361 -341
  42. package/src/checks/automatic/contrast-enhanced.js +487 -466
  43. package/src/checks/automatic/contrast-minimum.js +486 -465
  44. package/src/checks/automatic/css-orientation-lock.js +30 -8
  45. package/src/checks/automatic/definition-list-children-valid.js +40 -19
  46. package/src/checks/automatic/deprecated-elements-not-used.js +21 -7
  47. package/src/checks/automatic/dialog-name-present.js +37 -75
  48. package/src/checks/automatic/dlitem-parent-valid.js +23 -8
  49. package/src/checks/automatic/duplicate-id-aria.js +24 -6
  50. package/src/checks/automatic/embed-text-alternative-present.js +86 -35
  51. package/src/checks/automatic/form-control-programmatic-label-present.js +79 -196
  52. package/src/checks/automatic/form-control-single-label.js +47 -10
  53. package/src/checks/automatic/html-xml-lang-mismatch.js +34 -18
  54. package/src/checks/automatic/iframe-focusable-content.js +26 -11
  55. package/src/checks/automatic/iframe-name-present.js +31 -9
  56. package/src/checks/automatic/iframe-title-unique.js +29 -8
  57. package/src/checks/automatic/img-alt-present.js +47 -43
  58. package/src/checks/automatic/input-image-alt-present.js +143 -112
  59. package/src/checks/automatic/label-in-name.js +50 -22
  60. package/src/checks/automatic/language-page-present.js +109 -109
  61. package/src/checks/automatic/link-in-text-block.js +59 -19
  62. package/src/checks/automatic/link-name-present.js +45 -14
  63. package/src/checks/automatic/list-children-valid.js +26 -9
  64. package/src/checks/automatic/listbox-name-present.js +39 -19
  65. package/src/checks/automatic/listitem-parent-valid.js +18 -6
  66. package/src/checks/automatic/menuitem-name-present.js +39 -61
  67. package/src/checks/automatic/meta-refresh-no-exceptions.js +28 -7
  68. package/src/checks/automatic/meta-refresh-timing-absent.js +20 -6
  69. package/src/checks/automatic/meta-viewport-zoom-enabled.js +24 -7
  70. package/src/checks/automatic/meter-name-present.js +36 -33
  71. package/src/checks/automatic/nested-interactive-controls-absent.js +31 -10
  72. package/src/checks/automatic/object-text-alternative-present.js +91 -39
  73. package/src/checks/automatic/option-name-present.js +38 -21
  74. package/src/checks/automatic/page-title-present.js +17 -6
  75. package/src/checks/automatic/progressbar-name-present.js +41 -34
  76. package/src/checks/automatic/role-img-alt-present.js +209 -157
  77. package/src/checks/automatic/searchbox-name-present.js +39 -19
  78. package/src/checks/automatic/server-side-image-map-absent.js +23 -8
  79. package/src/checks/automatic/slider-name-present.js +40 -47
  80. package/src/checks/automatic/spinbutton-name-present.js +39 -19
  81. package/src/checks/automatic/summary-name-present.js +37 -17
  82. package/src/checks/automatic/svg-image-text-alternative-present.js +114 -47
  83. package/src/checks/automatic/svg-text-alternative-present.js +246 -226
  84. package/src/checks/automatic/tab-name-present.js +37 -60
  85. package/src/checks/automatic/table-headers-attr-valid.js +24 -8
  86. package/src/checks/automatic/table-th-has-data-cells.js +22 -8
  87. package/src/checks/automatic/target-size-minimum.js +118 -48
  88. package/src/checks/automatic/td-has-header.js +29 -11
  89. package/src/checks/automatic/textbox-name-present.js +39 -19
  90. package/src/checks/automatic/tooltip-name-present.js +37 -18
  91. package/src/checks/automatic/treeitem-name-present.js +38 -21
  92. package/src/checks/automatic/valid-lang.js +20 -6
  93. package/src/checks/automatic/video-poster-text-alternative-present.js +79 -36
  94. package/src/checks/manual/accesskeys-manual.js +14 -5
  95. package/src/checks/manual/area-alt-decorative-manual.js +192 -193
  96. package/src/checks/manual/area-alt-quality-manual.js +182 -141
  97. package/src/checks/manual/aria-checked-state-mismatch-manual.js +34 -11
  98. package/src/checks/manual/aria-text-manual.js +14 -6
  99. package/src/checks/manual/canvas-text-alternative-quality-manual.js +149 -114
  100. package/src/checks/manual/css-hidden-focus.js +196 -165
  101. package/src/checks/manual/embed-text-alternative-quality-manual.js +171 -160
  102. package/src/checks/manual/empty-heading-manual.js +24 -7
  103. package/src/checks/manual/empty-table-header-manual.js +17 -6
  104. package/src/checks/manual/focus-order-semantics-manual.js +45 -10
  105. package/src/checks/manual/form-control-programmatic-label-quality-manual.js +207 -246
  106. package/src/checks/manual/heading-order-manual.js +22 -7
  107. package/src/checks/manual/identical-links-same-purpose-manual.js +34 -12
  108. package/src/checks/manual/image-redundant-alt-manual.js +17 -7
  109. package/src/checks/manual/img-alt-decorative-manual.js +131 -96
  110. package/src/checks/manual/img-alt-quality-manual.js +176 -127
  111. package/src/checks/manual/input-image-alt-decorative-manual.js +125 -92
  112. package/src/checks/manual/input-image-alt-quality-manual.js +125 -92
  113. package/src/checks/manual/label-title-only-manual.js +14 -5
  114. package/src/checks/manual/landmark-banner-is-top-level-manual.js +66 -16
  115. package/src/checks/manual/landmark-contentinfo-is-top-level-manual.js +53 -16
  116. package/src/checks/manual/landmark-main-is-top-level-manual.js +35 -10
  117. package/src/checks/manual/landmark-no-duplicate-banner-manual.js +29 -10
  118. package/src/checks/manual/landmark-no-duplicate-contentinfo-manual.js +29 -10
  119. package/src/checks/manual/landmark-no-duplicate-main-manual.js +17 -8
  120. package/src/checks/manual/landmark-one-main-manual.js +26 -20
  121. package/src/checks/manual/landmark-unique-manual.js +41 -15
  122. package/src/checks/manual/link-name-quality-manual.js +43 -12
  123. package/src/checks/manual/media-transcript-present-manual.js +35 -22
  124. package/src/checks/manual/meta-viewport-large-manual.js +16 -5
  125. package/src/checks/manual/mouse-only-event-handlers-manual.js +38 -11
  126. package/src/checks/manual/no-autoplay-audio-manual.js +20 -6
  127. package/src/checks/manual/object-text-alternative-quality-manual.js +175 -154
  128. package/src/checks/manual/p-as-heading-manual.js +22 -7
  129. package/src/checks/manual/page-has-heading-one-manual.js +31 -22
  130. package/src/checks/manual/page-title-patterns-manual.js +78 -50
  131. package/src/checks/manual/presentation-role-conflict-manual.js +59 -19
  132. package/src/checks/manual/region-manual.js +241 -48
  133. package/src/checks/manual/scope-attr-valid-manual.js +10 -3
  134. package/src/checks/manual/scrollable-region-focusable-manual.js +37 -11
  135. package/src/checks/manual/skip-link-manual.js +35 -12
  136. package/src/checks/manual/svg-text-alternative-quality-manual.js +206 -165
  137. package/src/checks/manual/tabindex-manual.js +10 -3
  138. package/src/checks/manual/table-duplicate-name-manual.js +17 -7
  139. package/src/checks/manual/table-fake-caption-manual.js +24 -7
  140. package/src/checks/manual/video-caption-manual.js +15 -4
  141. package/src/checks/manual-review.js +56 -12
  142. package/src/core/aria-helpers.js +1127 -886
  143. package/src/core/contrast-helpers.js +1217 -1062
  144. package/src/core/dom-helpers.js +4175 -3917
  145. package/src/core/dom-runner.js +720 -604
  146. package/src/core/frame-messaging.js +189 -138
  147. package/src/core/frame-scan.js +94 -82
  148. package/src/core/rollup-composites.js +94 -102
  149. package/src/core/rule-meta.js +58 -41
  150. package/src/core.js +36552 -29111
  151. package/src/i18n/en.js +1194 -889
  152. package/src/i18n/fr.js +1136 -795
  153. package/src/policy/contracts.js +13 -13
  154. package/src/policy/resolvePolicy.js +48 -44
  155. package/src/report.js +63 -43
  156. package/src/sarif.js +175 -0
  157. 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 };
@@ -111,7 +111,8 @@ const id = 'aria-prohibited-children';
111
111
 
112
112
  const meta = {
113
113
  title: 'Container roles must not own a child with a disallowed role',
114
- 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.",
115
116
  i18n: {
116
117
  titleKey: 'ariaProhibitedChildren_title',
117
118
  descriptionKey: 'ariaProhibitedChildren_description'
@@ -120,7 +121,13 @@ const meta = {
120
121
  tags: ['wcag2a', 'wcag412', 'aria', 'structure', 'atomic', 'automatic'],
121
122
  wcagSc: ['4.1.2'],
122
123
  normativeMappings: [
123
- { 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
+ }
124
131
  ],
125
132
  defaultSeverity: 'moderate',
126
133
  category: 'robust',
@@ -130,7 +137,7 @@ const meta = {
130
137
  };
131
138
 
132
139
  function runInPage(ctx) {
133
- const { document, helpers, rule } = ctx;
140
+ const { helpers, rule } = ctx;
134
141
 
135
142
  const ariaHelpers = helpers && helpers.aria ? helpers.aria : null;
136
143
  if (!ariaHelpers) {
@@ -138,7 +145,8 @@ function runInPage(ctx) {
138
145
  }
139
146
 
140
147
  function isEligibleAcc(el) {
141
- const fn = helpers && typeof helpers.isAccTreeEligible === 'function' ? helpers.isAccTreeEligible : null;
148
+ const fn =
149
+ helpers && typeof helpers.isAccTreeEligible === 'function' ? helpers.isAccTreeEligible : null;
142
150
  if (!fn) return true;
143
151
  try {
144
152
  const r = fn(el, ctx);
@@ -155,12 +163,30 @@ function runInPage(ctx) {
155
163
  // roleless descendant carrying any of these is a real accessible-tree
156
164
  // node a widely-used reference engine's getOwnedRoles also flags, not a transparent wrapper.
157
165
  const GLOBAL_ARIA_ATTRS = [
158
- 'aria-atomic', 'aria-braillelabel', 'aria-brailleroledescription', 'aria-busy',
159
- 'aria-controls', 'aria-current', 'aria-describedby', 'aria-description',
160
- 'aria-details', 'aria-disabled', 'aria-dropeffect', 'aria-errormessage',
161
- 'aria-flowto', 'aria-grabbed', 'aria-haspopup', 'aria-hidden', 'aria-invalid',
162
- 'aria-keyshortcuts', 'aria-label', 'aria-labelledby', 'aria-live', 'aria-owns',
163
- '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'
164
190
  ];
165
191
 
166
192
  function getGlobalAriaAttr(el) {
@@ -195,13 +221,17 @@ function runInPage(ctx) {
195
221
 
196
222
  const kidRole = ariaHelpers.getContainmentRole(kid);
197
223
  const isPresentational = kidRole === 'presentation' || kidRole === 'none';
198
- const isTransparentGroup = (kidRole === 'group' || kidRole === 'rowgroup') && requiredSet.has(kidRole);
224
+ const isTransparentGroup =
225
+ (kidRole === 'group' || kidRole === 'rowgroup') && requiredSet.has(kidRole);
199
226
 
200
227
  if (!kidRole && !isPresentational) {
201
228
  const globalAttr = getGlobalAriaAttr(kid);
202
- let mechanism = 'none';
229
+ let mechanism;
203
230
  try {
204
- const fi = helpers && typeof helpers.getFocusableInfo === 'function' ? helpers.getFocusableInfo(kid, ctx) : null;
231
+ const fi =
232
+ helpers && typeof helpers.getFocusableInfo === 'function'
233
+ ? helpers.getFocusableInfo(kid, ctx)
234
+ : null;
205
235
  mechanism = (fi && fi.focusable && fi.mechanism) || 'none';
206
236
  } catch {
207
237
  mechanism = 'none';
@@ -230,7 +260,9 @@ function runInPage(ctx) {
230
260
  }
231
261
  }
232
262
 
233
- const nodes = helpers.queryAllSmart ? helpers.queryAllSmart('[role]') : helpers.queryAll('[role]');
263
+ const nodes = helpers.queryAllSmart
264
+ ? helpers.queryAllSmart('[role]')
265
+ : helpers.queryAll('[role]');
234
266
 
235
267
  const occurrences = [];
236
268
  let applicableCount = 0;
@@ -256,7 +288,9 @@ function runInPage(ctx) {
256
288
  if (entry.role && requiredSet.has(entry.role)) continue;
257
289
 
258
290
  const stableSelector = helpers.buildSelector ? helpers.buildSelector(entry.el) : 'html';
259
- 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 || '';
260
294
  const containerSelector = helpers.buildSelector ? helpers.buildSelector(el) : 'html';
261
295
 
262
296
  const isRoleless = !entry.role;
@@ -313,7 +347,12 @@ function runInPage(ctx) {
313
347
  return { ruleId: rule.ruleId, outcome: 'notApplicable', severity: 'minor', occurrences: [] };
314
348
  }
315
349
  if (occurrences.length) {
316
- 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
+ };
317
356
  }
318
357
  return { ruleId: rule.ruleId, outcome: 'pass', severity: 'minor', occurrences: [] };
319
358
  }
@@ -50,7 +50,8 @@ const id = 'aria-required-attr';
50
50
 
51
51
  const meta = {
52
52
  title: 'Roles with a required ARIA state/property must carry it',
53
- description: 'Checks that elements with an explicit role carry every unambiguous, context-independent required aria-* state/property for that role (e.g. role="checkbox" must have aria-checked).',
53
+ description:
54
+ 'Checks that elements with an explicit role carry every unambiguous, context-independent required aria-* state/property for that role (e.g. role="checkbox" must have aria-checked).',
54
55
  i18n: {
55
56
  titleKey: 'ariaRequiredAttr_title',
56
57
  descriptionKey: 'ariaRequiredAttr_description'
@@ -59,7 +60,13 @@ const meta = {
59
60
  tags: ['wcag2a', 'wcag412', 'aria', 'structure', 'atomic', 'automatic'],
60
61
  wcagSc: ['4.1.2'],
61
62
  normativeMappings: [
62
- { standard: 'WCAG', version: '2.2', requirement: '4.1.2', title: 'Name, Role, Value', conformanceLevel: 'A' }
63
+ {
64
+ standard: 'WCAG',
65
+ version: '2.2',
66
+ requirement: '4.1.2',
67
+ title: 'Name, Role, Value',
68
+ conformanceLevel: 'A'
69
+ }
63
70
  ],
64
71
  defaultSeverity: 'serious',
65
72
  category: 'robust',
@@ -69,7 +76,7 @@ const meta = {
69
76
  };
70
77
 
71
78
  function runInPage(ctx) {
72
- const { document, helpers, rule } = ctx;
79
+ const { helpers, rule } = ctx;
73
80
 
74
81
  const ariaHelpers = helpers && helpers.aria ? helpers.aria : null;
75
82
  if (!ariaHelpers) {
@@ -77,7 +84,8 @@ function runInPage(ctx) {
77
84
  }
78
85
 
79
86
  function isEligibleAcc(el) {
80
- const fn = helpers && typeof helpers.isAccTreeEligible === 'function' ? helpers.isAccTreeEligible : null;
87
+ const fn =
88
+ helpers && typeof helpers.isAccTreeEligible === 'function' ? helpers.isAccTreeEligible : null;
81
89
  if (!fn) return true;
82
90
  try {
83
91
  const r = fn(el, ctx);
@@ -93,7 +101,9 @@ function runInPage(ctx) {
93
101
  return v != null && String(v).trim().toLowerCase() === 'true';
94
102
  }
95
103
 
96
- const nodes = helpers.queryAllSmart ? helpers.queryAllSmart('[role]') : helpers.queryAll('[role]');
104
+ const nodes = helpers.queryAllSmart
105
+ ? helpers.queryAllSmart('[role]')
106
+ : helpers.queryAll('[role]');
97
107
 
98
108
  const occurrences = [];
99
109
  let applicableCount = 0;
@@ -121,7 +131,7 @@ function runInPage(ctx) {
121
131
  if (!missing.length) continue;
122
132
 
123
133
  const stableSelector = helpers.buildSelector ? helpers.buildSelector(el) : 'html';
124
- const html = helpers.getOuterHtmlSnippet ? helpers.getOuterHtmlSnippet(el) : (el.outerHTML || '');
134
+ const html = helpers.getOuterHtmlSnippet ? helpers.getOuterHtmlSnippet(el) : el.outerHTML || '';
125
135
 
126
136
  for (const attr of missing) {
127
137
  occurrences.push({
@@ -145,9 +155,14 @@ function runInPage(ctx) {
145
155
  return { ruleId: rule.ruleId, outcome: 'notApplicable', severity: 'minor', occurrences: [] };
146
156
  }
147
157
  if (occurrences.length) {
148
- return { ruleId: rule.ruleId, outcome: 'fail', severity: rule.defaultSeverity || 'serious', occurrences };
158
+ return {
159
+ ruleId: rule.ruleId,
160
+ outcome: 'fail',
161
+ severity: rule.defaultSeverity || 'serious',
162
+ occurrences
163
+ };
149
164
  }
150
165
  return { ruleId: rule.ruleId, outcome: 'pass', severity: 'minor', occurrences: [] };
151
166
  }
152
167
 
153
- module.exports = { id, meta, runInPage };
168
+ module.exports = { id, meta, runInPage };