@surea11y/core 1.2.0 → 1.4.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 (168) hide show
  1. package/CHANGELOG.md +81 -7
  2. package/LICENSE +373 -21
  3. package/README.md +175 -35
  4. package/bin/surea11y-core.js +20 -0
  5. package/docs/API_STABILITY.md +27 -1
  6. package/docs/BINDING_AUTHORS_GUIDE.md +9 -9
  7. package/docs/CI_INTEGRATIONS.md +103 -0
  8. package/docs/ENGINE_OPTIONS.md +2 -0
  9. package/docs/I18N.md +12 -9
  10. package/docs/INTEGRATION.md +19 -1
  11. package/docs/LIMITATIONS.md +1 -1
  12. package/docs/OUTPUT_SCHEMA.md +1 -1
  13. package/docs/REPORT.md +1 -1
  14. package/docs/RULE_CATALOG.md +1 -1
  15. package/docs/SARIF.md +59 -0
  16. package/package.json +63 -18
  17. package/src/baseline.js +0 -0
  18. package/src/checks/automatic/area-alt-present.js +63 -31
  19. package/src/checks/automatic/aria-allowed-attr.js +204 -80
  20. package/src/checks/automatic/aria-allowed-role.js +23 -7
  21. package/src/checks/automatic/aria-braille-equivalent.js +34 -10
  22. package/src/checks/automatic/aria-conditional-attr.js +32 -14
  23. package/src/checks/automatic/aria-deprecated-role.js +26 -11
  24. package/src/checks/automatic/aria-hidden-body.js +48 -23
  25. package/src/checks/automatic/aria-hidden-focus.js +420 -66
  26. package/src/checks/automatic/aria-prohibited-attr.js +327 -60
  27. package/src/checks/automatic/aria-prohibited-children.js +111 -103
  28. package/src/checks/automatic/aria-required-attr.js +29 -15
  29. package/src/checks/automatic/aria-required-children.js +44 -24
  30. package/src/checks/automatic/aria-required-parent.js +64 -35
  31. package/src/checks/automatic/aria-role-name-present.js +49 -21
  32. package/src/checks/automatic/aria-roles-valid.js +24 -12
  33. package/src/checks/automatic/aria-valid-attr-value.js +46 -22
  34. package/src/checks/automatic/aria-valid-attr.js +19 -5
  35. package/src/checks/automatic/autocomplete-valid.js +76 -16
  36. package/src/checks/automatic/avoid-inline-spacing.js +23 -8
  37. package/src/checks/automatic/binary-control-name-present.js +62 -50
  38. package/src/checks/automatic/button-name-present.js +54 -24
  39. package/src/checks/automatic/bypass-blocks-present.js +51 -32
  40. package/src/checks/automatic/canvas-text-alternative-present.js +59 -26
  41. package/src/checks/automatic/combobox-name-present.js +40 -45
  42. package/src/checks/automatic/contrast-computable.js +363 -341
  43. package/src/checks/automatic/contrast-enhanced.js +489 -466
  44. package/src/checks/automatic/contrast-minimum.js +488 -465
  45. package/src/checks/automatic/css-orientation-lock.js +51 -35
  46. package/src/checks/automatic/definition-list-children-valid.js +46 -25
  47. package/src/checks/automatic/deprecated-elements-not-used.js +25 -9
  48. package/src/checks/automatic/dialog-name-present.js +47 -85
  49. package/src/checks/automatic/dlitem-parent-valid.js +25 -8
  50. package/src/checks/automatic/duplicate-id-aria.js +28 -9
  51. package/src/checks/automatic/embed-text-alternative-present.js +88 -35
  52. package/src/checks/automatic/form-control-programmatic-label-present.js +81 -196
  53. package/src/checks/automatic/form-control-single-label.js +50 -14
  54. package/src/checks/automatic/html-xml-lang-mismatch.js +36 -18
  55. package/src/checks/automatic/iframe-focusable-content.js +265 -22
  56. package/src/checks/automatic/iframe-name-present.js +33 -9
  57. package/src/checks/automatic/iframe-title-unique.js +32 -9
  58. package/src/checks/automatic/img-alt-present.js +54 -52
  59. package/src/checks/automatic/input-image-alt-present.js +141 -112
  60. package/src/checks/automatic/label-in-name.js +65 -41
  61. package/src/checks/automatic/language-page-present.js +111 -109
  62. package/src/checks/automatic/link-in-text-block.js +61 -19
  63. package/src/checks/automatic/link-name-present.js +47 -14
  64. package/src/checks/automatic/list-children-valid.js +40 -33
  65. package/src/checks/automatic/listbox-name-present.js +41 -19
  66. package/src/checks/automatic/listitem-parent-valid.js +48 -13
  67. package/src/checks/automatic/menuitem-name-present.js +41 -61
  68. package/src/checks/automatic/meta-refresh-no-exceptions.js +32 -11
  69. package/src/checks/automatic/meta-refresh-timing-absent.js +22 -6
  70. package/src/checks/automatic/meta-viewport-zoom-enabled.js +26 -7
  71. package/src/checks/automatic/meter-name-present.js +40 -36
  72. package/src/checks/automatic/nested-interactive-controls-absent.js +58 -15
  73. package/src/checks/automatic/object-text-alternative-present.js +93 -39
  74. package/src/checks/automatic/option-name-present.js +40 -21
  75. package/src/checks/automatic/page-title-present.js +19 -6
  76. package/src/checks/automatic/progressbar-name-present.js +49 -44
  77. package/src/checks/automatic/role-img-alt-present.js +211 -159
  78. package/src/checks/automatic/searchbox-name-present.js +41 -19
  79. package/src/checks/automatic/server-side-image-map-absent.js +27 -11
  80. package/src/checks/automatic/slider-name-present.js +42 -47
  81. package/src/checks/automatic/spinbutton-name-present.js +41 -19
  82. package/src/checks/automatic/summary-name-present.js +39 -17
  83. package/src/checks/automatic/svg-image-text-alternative-present.js +116 -47
  84. package/src/checks/automatic/svg-text-alternative-present.js +262 -230
  85. package/src/checks/automatic/tab-name-present.js +39 -60
  86. package/src/checks/automatic/table-headers-attr-valid.js +27 -10
  87. package/src/checks/automatic/table-th-has-data-cells.js +24 -8
  88. package/src/checks/automatic/target-size-minimum.js +123 -48
  89. package/src/checks/automatic/td-has-header.js +53 -12
  90. package/src/checks/automatic/textbox-name-present.js +41 -19
  91. package/src/checks/automatic/tooltip-name-present.js +39 -18
  92. package/src/checks/automatic/treeitem-name-present.js +40 -21
  93. package/src/checks/automatic/valid-lang.js +22 -6
  94. package/src/checks/automatic/video-poster-text-alternative-present.js +81 -36
  95. package/src/checks/manual/accesskeys-manual.js +17 -6
  96. package/src/checks/manual/area-alt-decorative-manual.js +194 -193
  97. package/src/checks/manual/area-alt-quality-manual.js +184 -141
  98. package/src/checks/manual/aria-checked-state-mismatch-manual.js +48 -34
  99. package/src/checks/manual/aria-text-manual.js +20 -11
  100. package/src/checks/manual/canvas-text-alternative-quality-manual.js +151 -114
  101. package/src/checks/manual/css-hidden-focus.js +375 -169
  102. package/src/checks/manual/embed-text-alternative-quality-manual.js +178 -162
  103. package/src/checks/manual/empty-heading-manual.js +41 -24
  104. package/src/checks/manual/empty-table-header-manual.js +69 -31
  105. package/src/checks/manual/focus-order-semantics-manual.js +60 -13
  106. package/src/checks/manual/form-control-programmatic-label-quality-manual.js +209 -246
  107. package/src/checks/manual/heading-order-manual.js +50 -8
  108. package/src/checks/manual/identical-links-same-purpose-manual.js +36 -12
  109. package/src/checks/manual/image-redundant-alt-manual.js +38 -8
  110. package/src/checks/manual/img-alt-decorative-manual.js +133 -96
  111. package/src/checks/manual/img-alt-quality-manual.js +178 -127
  112. package/src/checks/manual/input-image-alt-decorative-manual.js +127 -92
  113. package/src/checks/manual/input-image-alt-quality-manual.js +127 -92
  114. package/src/checks/manual/label-title-only-manual.js +44 -28
  115. package/src/checks/manual/landmark-banner-is-top-level-manual.js +95 -38
  116. package/src/checks/manual/landmark-contentinfo-is-top-level-manual.js +85 -32
  117. package/src/checks/manual/landmark-main-is-top-level-manual.js +69 -27
  118. package/src/checks/manual/landmark-no-duplicate-banner-manual.js +45 -33
  119. package/src/checks/manual/landmark-no-duplicate-contentinfo-manual.js +43 -31
  120. package/src/checks/manual/landmark-no-duplicate-main-manual.js +27 -21
  121. package/src/checks/manual/landmark-one-main-manual.js +38 -43
  122. package/src/checks/manual/landmark-unique-manual.js +78 -67
  123. package/src/checks/manual/link-name-quality-manual.js +45 -12
  124. package/src/checks/manual/media-transcript-present-manual.js +37 -22
  125. package/src/checks/manual/meta-viewport-large-manual.js +19 -6
  126. package/src/checks/manual/mouse-only-event-handlers-manual.js +40 -11
  127. package/src/checks/manual/no-autoplay-audio-manual.js +22 -6
  128. package/src/checks/manual/object-text-alternative-quality-manual.js +177 -154
  129. package/src/checks/manual/p-as-heading-manual.js +24 -7
  130. package/src/checks/manual/page-has-heading-one-manual.js +42 -32
  131. package/src/checks/manual/page-title-patterns-manual.js +80 -50
  132. package/src/checks/manual/presentation-role-conflict-manual.js +101 -47
  133. package/src/checks/manual/region-manual.js +244 -60
  134. package/src/checks/manual/scope-attr-valid-manual.js +13 -4
  135. package/src/checks/manual/scrollable-region-focusable-manual.js +39 -11
  136. package/src/checks/manual/skip-link-manual.js +42 -18
  137. package/src/checks/manual/svg-text-alternative-quality-manual.js +208 -165
  138. package/src/checks/manual/tabindex-manual.js +13 -4
  139. package/src/checks/manual/table-duplicate-name-manual.js +22 -11
  140. package/src/checks/manual/table-fake-caption-manual.js +48 -10
  141. package/src/checks/manual/video-caption-manual.js +17 -4
  142. package/src/checks/manual-review.js +58 -12
  143. package/src/core.js +41705 -29650
  144. package/src/index.js +2 -0
  145. package/src/report.js +109 -47
  146. package/src/sarif.js +190 -0
  147. package/surea11y.browser.js +37774 -0
  148. package/bin/core.js +0 -348
  149. package/docs/CLI.md +0 -75
  150. package/src/catalogs/composites.wcag.js +0 -490
  151. package/src/checks/rules-and-tags.full.csv +0 -19
  152. package/src/checks/rules-and-tags.full.json +0 -259
  153. package/src/core/aria-helpers.js +0 -970
  154. package/src/core/contrast-helpers.js +0 -1147
  155. package/src/core/dom-helpers.js +0 -4235
  156. package/src/core/dom-runner.js +0 -671
  157. package/src/core/frame-messaging.js +0 -210
  158. package/src/core/frame-scan.js +0 -178
  159. package/src/core/rollup-composites.js +0 -135
  160. package/src/core/rule-meta.js +0 -159
  161. package/src/coverage/wcag-facets.js +0 -1079
  162. package/src/coverage/wcag-version-map.js +0 -84
  163. package/src/i18n/en.js +0 -923
  164. package/src/i18n/fr.js +0 -844
  165. package/src/policy/contracts.js +0 -18
  166. package/src/policy/resolvePolicy.js +0 -55
  167. package/src/policy/schemas/engine-options.schema.json +0 -103
  168. package/src/policy/schemas/policy-contract.schema.json +0 -40
@@ -1,3 +1,5 @@
1
+ /* SPDX-License-Identifier: MPL-2.0 */
2
+
1
3
  'use strict';
2
4
 
3
5
  /**
@@ -7,64 +9,82 @@
7
9
  * @standard WCAG 2.2
8
10
  * @sc 4.1.2
9
11
  * @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
12
- * Properties" list (pure text-semantics / non-naming structural roles:
13
- * caption, code, deletion, emphasis, generic, insertion, mark, none,
14
- * paragraph, presentation, strong, subscript, suggestion, superscript,
15
- * time), and that also carry aria-label or aria-labelledby.
12
+ * Applies to (a) elements whose explicit, valid role is one of the ARIA
13
+ * 1.2 roles with a documented "Prohibited ARIA States and Properties"
14
+ * list for naming attributes (pure text-semantics / non-naming
15
+ * structural roles: caption, code, deletion, emphasis, generic,
16
+ * insertion, mark, none, paragraph, presentation, strong, subscript,
17
+ * suggestion, superscript, time), and (b) elements with no role at all —
18
+ * a curated set of native HTML tags verified to carry no implicit role
19
+ * (see ROLELESS_NATIVE_TAGS below), or any autonomous custom element (a
20
+ * hyphenated, author-defined tag per the Custom Elements spec; see
21
+ * isRolelessCustomElementTag below) — in both cases, only elements that
22
+ * also carry aria-label or aria-labelledby.
16
23
  * @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.
24
+ * Prohibited attributes must not be present on (a); for (b), the naming
25
+ * attribute is at best unreliable (nothing accessible-name-aware to hang
26
+ * it off) and at worst silently ignored by assistive technology — see the
27
+ * roleless-branch implementation note below for the confidence split
28
+ * this produces.
20
29
  * @implementation-notes
21
30
  * - Deliberately scoped to the single, well-established prohibition class
22
- * (naming attributes on pure text-semantics roles) rather than
23
- * attempting an exhaustive per-role prohibited-attribute table; see
31
+ * (naming attributes on pure text-semantics roles) rather than an
32
+ * exhaustive per-role prohibited-attribute table; see
24
33
  * src/core/aria-helpers.js file header for this engine's confidence-
25
- * scoping rationale.
26
- * - Role list widened 2026-07-19 (Tier 4) from 10 to 13 roles, adding
27
- * `mark`, `suggestion`, and `time` — the other ARIA 1.2 "HTML-alignment"
28
- * text-level roles that share the same documented prohibition as the
29
- * original 10. Still deliberately not claiming full parity with a widely-used
30
- * reference engine:
31
- * only roles/attrs this engine has high confidence in from the spec
32
- * text are included, per the file's own "wrong entries cause false-
33
- * positive fails" caution.
34
- * - Widened again 2026-07-21 to add `presentation`/`none`, verified
35
- * directly against a widely-used reference engine's own role data table
36
- * (both have `prohibitedAttrs: ['aria-label', 'aria-labelledby']`), and
37
- * corroborated by the W3C
38
- * WAI-ARIA 1.2 spec's own §5.2.8.6 "Roles which cannot be named"
39
- * listing `presentation` explicitly (`none` is `presentation`'s
40
- * documented 1.2-introduced alias, identical semantics). The
41
- * pre-existing `presentation-role-conflict` rule already treats
42
- * aria-label/aria-labelledby as conflicting on these two roles, but at
43
- * `manual`/cantTell confidence across a ~24-attribute general list —
44
- * this addition lets the specific, unambiguous naming-prohibition case
45
- * also fire as a hard, WCAG-normative `fail` via this rule, matching
46
- * this engine's "one rule = one normative decision" pattern rather than
47
- * only ever surfacing it as advisory.
48
- * - Investigated, but deliberately did NOT add, `definition`/`term`
49
- * despite both appearing on MDN's aria-label reference page's
50
- * "not supported" list: that MDN list is demonstrably wrong for these
51
- * two — a widely-used reference engine's own role data explicitly declares
52
- * `nameFrom: ['author']` (`definition`) / `nameFrom: ['author',
53
- * 'contents']` (`term`), and the W3C spec's own §5.2.8.4 "Roles
54
- * Supporting Name From Author" index lists both by name; MDN's
55
- * `definition_role` page even demonstrates `aria-labelledby` usage on
56
- * it directly. A real, confirmed documentation bug on MDN's side, not
57
- * a gap here.
34
+ * scoping rationale. The 14-role list comes from the W3C WAI-ARIA 1.2
35
+ * spec's §5.2.8.6 "Roles which cannot be named"; only roles/attrs with
36
+ * high confidence from the spec text are included, since a wrong entry
37
+ * here causes a false-positive fail.
38
+ * - The pre-existing presentation-role-conflict rule already treats
39
+ * aria-label/aria-labelledby as conflicting on presentation/none, but at
40
+ * manual/cantTell confidence across a broad attribute list; this rule's
41
+ * narrower, unambiguous naming-prohibition case fires as a hard,
42
+ * WCAG-normative fail instead, matching this engine's "one rule = one
43
+ * normative decision" pattern.
44
+ * - Deliberately excludes `definition`/`term` despite both appearing on
45
+ * MDN's aria-label "not supported" list: both support name from author
46
+ * (`nameFrom: ['author']` for definition, `['author', 'contents']` for
47
+ * term), and the W3C spec's §5.2.8.4 "Roles Supporting Name From Author"
48
+ * index lists both by name.
58
49
  * - Not rule-gated on isAccTreeEligible: this remains a static-markup
59
50
  * property, while engine-level hidden-subtree filtering still applies
60
51
  * unless engineOptions.includeHiddenElements is true.
52
+ * - Second, independent branch: naming attributes on ROLELESS elements (no
53
+ * explicit role="", no implicit/native role either) — e.g. icon-only
54
+ * `<span aria-label="...">` tiles with no other accessible-name source.
55
+ * ROLELESS_NATIVE_TAGS below is a curated, deliberately conservative
56
+ * list of native tags confirmed to carry no implicit role (common
57
+ * text-level tags like `<p>`/`<strong>`/`<em>`/`<code>`/`<mark>`/`<time>`
58
+ * resolve to role `null`, same as a bare `<div>`/`<span>`);
59
+ * `<section>`/`<form>`/`<a>` are excluded even though they can also
60
+ * resolve to no role, because their native role is conditional
61
+ * (name-dependent/href-dependent) and already has dedicated handling in
62
+ * `getElementRoleKey`'s `section`/`section[named]`/`header`/
63
+ * `header[toplevel]` branches that this rule doesn't duplicate.
64
+ * Two confidence tiers instead of a flat fail: if the element's subtree
65
+ * already produces a non-empty accessible name from its content (via
66
+ * `helpers.getContentNameInfo`, same as
67
+ * link-name-present/button-name-present), the naming attribute might
68
+ * just be a redundant/intentional override — reported as `cantTell`, not
69
+ * a hard fail. Only a roleless element with no other accessible-name
70
+ * source at all is a confident, deterministic `fail`. The
71
+ * widget-ancestor exemption (skip when the closest real ancestor role is
72
+ * a "widget"-type role) avoids over-flagging roleless helper spans/divs
73
+ * used as internal decoration inside a custom composite widget.
74
+ * - The Tier-2 "already has a role, not this branch's concern" guard
75
+ * validates the explicit role via `isValidConcreteRole` before treating
76
+ * it as real (matching `getNearestAncestorRole`'s own pattern): an
77
+ * unrecognized role token (e.g. `role="totally-bogus"`) is ignored by
78
+ * the accessibility tree, not honored, so the element is still
79
+ * effectively roleless and must still be checked by this branch.
61
80
  */
62
81
 
63
82
  const id = 'aria-prohibited-attr';
64
83
 
65
84
  const meta = {
66
85
  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).',
86
+ description:
87
+ '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
88
  i18n: {
69
89
  titleKey: 'ariaProhibitedAttr_title',
70
90
  descriptionKey: 'ariaProhibitedAttr_description'
@@ -73,7 +93,13 @@ const meta = {
73
93
  tags: ['wcag2a', 'wcag412', 'aria', 'structure', 'atomic', 'automatic'],
74
94
  wcagSc: ['4.1.2'],
75
95
  normativeMappings: [
76
- { standard: 'WCAG', version: '2.2', requirement: '4.1.2', title: 'Name, Role, Value', conformanceLevel: 'A' }
96
+ {
97
+ standard: 'WCAG',
98
+ version: '2.2',
99
+ requirement: '4.1.2',
100
+ title: 'Name, Role, Value',
101
+ conformanceLevel: 'A'
102
+ }
77
103
  ],
78
104
  defaultSeverity: 'moderate',
79
105
  category: 'robust',
@@ -83,7 +109,7 @@ const meta = {
83
109
  };
84
110
 
85
111
  function runInPage(ctx) {
86
- const { document, helpers, rule } = ctx;
112
+ const { helpers, rule } = ctx;
87
113
 
88
114
  const ariaHelpers = helpers && helpers.aria ? helpers.aria : null;
89
115
  if (!ariaHelpers) {
@@ -96,19 +122,36 @@ function runInPage(ctx) {
96
122
  // scope) because the build inlines only this function's own source text
97
123
  // — see scripts/build-core.js header ("runInPage MUST be self-contained").
98
124
  const ROLES_PROHIBITING_NAME = new Set([
99
- 'caption', 'code', 'deletion', 'emphasis', 'generic', 'insertion',
100
- 'mark', 'none', 'paragraph', 'presentation', 'strong', 'subscript',
101
- 'suggestion', 'superscript', 'time'
125
+ 'caption',
126
+ 'code',
127
+ 'deletion',
128
+ 'emphasis',
129
+ 'generic',
130
+ 'insertion',
131
+ 'mark',
132
+ 'none',
133
+ 'paragraph',
134
+ 'presentation',
135
+ 'strong',
136
+ 'subscript',
137
+ 'suggestion',
138
+ 'superscript',
139
+ 'time'
102
140
  ]);
103
141
 
104
142
  const PROHIBITED_NAMING_ATTRS = ['aria-label', 'aria-labelledby'];
105
143
 
106
- const nodes = helpers.queryAllSmart ? helpers.queryAllSmart('[role]') : helpers.queryAll('[role]');
107
-
108
- const occurrences = [];
144
+ const failOccurrences = [];
145
+ const cantTellOccurrences = [];
109
146
  let applicableCount = 0;
110
147
 
111
- for (const el of nodes) {
148
+ // --- Tier 1: explicit, valid role from the naming-prohibited set ---
149
+
150
+ const roleNodes = helpers.queryAllSmart
151
+ ? helpers.queryAllSmart('[role]')
152
+ : helpers.queryAll('[role]');
153
+
154
+ for (const el of roleNodes) {
112
155
  if (!el || !el.getAttribute) continue;
113
156
 
114
157
  const role = ariaHelpers.getExplicitRole(el);
@@ -125,12 +168,13 @@ function runInPage(ctx) {
125
168
  if (!present.length) continue;
126
169
 
127
170
  const stableSelector = helpers.buildSelector ? helpers.buildSelector(el) : 'html';
128
- const html = helpers.getOuterHtmlSnippet ? helpers.getOuterHtmlSnippet(el) : (el.outerHTML || '');
171
+ const html = helpers.getOuterHtmlSnippet ? helpers.getOuterHtmlSnippet(el) : el.outerHTML || '';
129
172
 
130
173
  for (const attr of present) {
131
- occurrences.push({
174
+ failOccurrences.push({
132
175
  selector: stableSelector,
133
176
  html,
177
+ occurrenceOutcome: 'fail',
134
178
  summary: 'This attribute is prohibited on this element’s role.',
135
179
  hint: 'Remove this attribute; this role must not carry an accessible name.',
136
180
  i18n: {
@@ -145,13 +189,236 @@ function runInPage(ctx) {
145
189
  }
146
190
  }
147
191
 
192
+ // --- Tier 2: no role at all (see header comment for the full rationale
193
+ // and how ROLELESS_NATIVE_TAGS/WIDGET_TYPE_ROLES were derived) ---
194
+
195
+ // Small, curated set of native tags verified to carry no explicit or
196
+ // implicit ARIA role. Deliberately excludes <section>/<form>/<a> — all
197
+ // conditionally roleless too, but already handled with more nuance
198
+ // elsewhere in this engine (see header comment).
199
+ const ROLELESS_NATIVE_TAGS = new Set([
200
+ 'p',
201
+ 'b',
202
+ 'i',
203
+ 'em',
204
+ 'strong',
205
+ 'span',
206
+ 'div',
207
+ 'code',
208
+ 'mark',
209
+ 'time',
210
+ 'ins',
211
+ 'del',
212
+ 'small',
213
+ 'sub',
214
+ 'sup',
215
+ 'abbr',
216
+ 'cite',
217
+ 'q',
218
+ 'kbd',
219
+ 'samp',
220
+ 'var',
221
+ 'address',
222
+ 'blockquote',
223
+ 'pre',
224
+ 'figcaption',
225
+ 'picture',
226
+ 'template',
227
+ 'hgroup',
228
+ 'wbr',
229
+ 'br',
230
+ 'legend'
231
+ ]);
232
+
233
+ // WAI-ARIA roles typed as "widget" (the role set the roleless-branch
234
+ // exemption below branches on, not the six-category WAI-ARIA taxonomy).
235
+ const WIDGET_TYPE_ROLES = new Set([
236
+ 'alert',
237
+ 'alertdialog',
238
+ 'button',
239
+ 'checkbox',
240
+ 'combobox',
241
+ 'dialog',
242
+ 'gridcell',
243
+ 'link',
244
+ 'listbox',
245
+ 'log',
246
+ 'marquee',
247
+ 'menuitem',
248
+ 'menuitemcheckbox',
249
+ 'menuitemradio',
250
+ 'option',
251
+ 'progressbar',
252
+ 'radio',
253
+ 'scrollbar',
254
+ 'searchbox',
255
+ 'slider',
256
+ 'spinbutton',
257
+ 'status',
258
+ 'switch',
259
+ 'tab',
260
+ 'tabpanel',
261
+ 'textbox',
262
+ 'timer',
263
+ 'treeitem'
264
+ ]);
265
+
266
+ const getComposedParent =
267
+ helpers && typeof helpers.composedParent === 'function'
268
+ ? helpers.composedParent
269
+ : function (n) {
270
+ return n && n.parentElement ? n.parentElement : null;
271
+ };
272
+
273
+ // Nearest ancestor's real role (explicit-if-valid, else native/implicit),
274
+ // skipping roleless/presentation/none ancestors — used only to check
275
+ // whether that role is a "widget"-type one (the roleless-branch
276
+ // exemption). Not the same helper as aria-required-parent's containment
277
+ // walk: this one also accepts non-required-context roles.
278
+ function getNearestAncestorRole(el) {
279
+ let cur = getComposedParent(el);
280
+ let guard = 0;
281
+ while (cur && guard++ < 200) {
282
+ if (cur.nodeType !== 1) {
283
+ cur = getComposedParent(cur);
284
+ continue;
285
+ }
286
+ const explicit = ariaHelpers.getExplicitRole(cur);
287
+ const role =
288
+ explicit && ariaHelpers.isValidConcreteRole(explicit)
289
+ ? explicit
290
+ : ariaHelpers.getNativeRoleForElement(cur);
291
+ if (!role || role === 'presentation' || role === 'none') {
292
+ cur = getComposedParent(cur);
293
+ continue;
294
+ }
295
+ return role;
296
+ }
297
+ return '';
298
+ }
299
+
300
+ // A small, spec-reserved set of hyphenated tag names that are NOT
301
+ // autonomous custom elements despite containing a hyphen (legacy SVG/
302
+ // MathML tags predating the Custom Elements spec) — see
303
+ // https://html.spec.whatwg.org/#valid-custom-element-name's own
304
+ // exclusion list. Excluded so this doesn't misclassify them as
305
+ // always-roleless the same way a real custom element is.
306
+ const RESERVED_HYPHENATED_TAGS = new Set([
307
+ 'annotation-xml',
308
+ 'color-profile',
309
+ 'font-face',
310
+ 'font-face-src',
311
+ 'font-face-uri',
312
+ 'font-face-format',
313
+ 'font-face-name',
314
+ 'missing-glyph'
315
+ ]);
316
+
317
+ // An autonomous custom element (author-defined tag, always containing a
318
+ // hyphen per the Custom Elements spec's naming grammar) has no implicit
319
+ // ARIA role at all -- unlike native tags, there is no conditional-role
320
+ // nuance to worry about here (a native <a>/<section>/<form> can gain an
321
+ // implicit role depending on other attributes, which is exactly why
322
+ // ROLELESS_NATIVE_TAGS is a hand-verified allowlist rather than a
323
+ // blanket rule; a custom element has no such spec-defined conditional
324
+ // role logic whatsoever). Covers e.g. a `<play-button aria-label="...">`
325
+ // or `<app-carousel aria-label="...">` with no other name source, which
326
+ // the fixed native-tag allowlist below would otherwise skip.
327
+ function isRolelessCustomElementTag(tag) {
328
+ return tag.includes('-') && !RESERVED_HYPHENATED_TAGS.has(tag);
329
+ }
330
+
331
+ const namingSelector = '[aria-label],[aria-labelledby]';
332
+ const namingNodes = helpers.queryAllSmart
333
+ ? helpers.queryAllSmart(namingSelector)
334
+ : helpers.queryAll(namingSelector);
335
+
336
+ for (const el of namingNodes) {
337
+ if (!el || !el.getAttribute) continue;
338
+
339
+ const tag = String(el.tagName || '').toLowerCase();
340
+ if (!ROLELESS_NATIVE_TAGS.has(tag) && !isRolelessCustomElementTag(tag)) continue;
341
+ const explicitRole = ariaHelpers.getExplicitRole(el);
342
+ 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.
343
+ if (ariaHelpers.getNativeRoleForElement(el)) continue; // has a real implicit role after all — not this branch's concern
344
+
345
+ const present = [];
346
+ for (const attr of PROHIBITED_NAMING_ATTRS) {
347
+ const v = el.getAttribute(attr);
348
+ if (v != null && String(v).trim() !== '') present.push(attr);
349
+ }
350
+ if (!present.length) continue;
351
+
352
+ applicableCount += 1;
353
+
354
+ const ancestorRole = getNearestAncestorRole(el);
355
+ if (ancestorRole && WIDGET_TYPE_ROLES.has(ancestorRole)) continue; // roleless helper node inside a real widget — not flagged
356
+
357
+ const nameInfo = helpers.getContentNameInfo ? helpers.getContentNameInfo(el, ctx) : null;
358
+ const hasContentFallback = !!(
359
+ nameInfo &&
360
+ nameInfo.present &&
361
+ String(nameInfo.value || '').trim() !== ''
362
+ );
363
+
364
+ const stableSelector = helpers.buildSelector ? helpers.buildSelector(el) : 'html';
365
+ const html = helpers.getOuterHtmlSnippet ? helpers.getOuterHtmlSnippet(el) : el.outerHTML || '';
366
+
367
+ for (const attr of present) {
368
+ if (hasContentFallback) {
369
+ cantTellOccurrences.push({
370
+ selector: stableSelector,
371
+ html,
372
+ occurrenceOutcome: 'cantTell',
373
+ 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.`,
374
+ 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").',
375
+ i18n: {
376
+ summaryKey: 'ariaProhibitedAttr_summary_cantTell_roleless',
377
+ hintKey: 'ariaProhibitedAttr_hint_cantTell_roleless',
378
+ params: { attr, element: tag }
379
+ },
380
+ data: {
381
+ details: {
382
+ reasonCode: 'ARIA_ATTR_PROHIBITED_ROLELESS_NEEDS_REVIEW',
383
+ attr,
384
+ role: null,
385
+ element: tag
386
+ }
387
+ }
388
+ });
389
+ } else {
390
+ failOccurrences.push({
391
+ selector: stableSelector,
392
+ html,
393
+ occurrenceOutcome: 'fail',
394
+ summary: `This ${tag} has no role and no other accessible-name source, so ${attr} is not reliably exposed to assistive technology.`,
395
+ 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.',
396
+ i18n: {
397
+ summaryKey: 'ariaProhibitedAttr_summary_fail_roleless',
398
+ hintKey: 'ariaProhibitedAttr_hint_fail_roleless',
399
+ params: { attr, element: tag }
400
+ },
401
+ data: {
402
+ details: { reasonCode: 'ARIA_ATTR_PROHIBITED_ROLELESS', attr, role: null, element: tag }
403
+ }
404
+ });
405
+ }
406
+ }
407
+ }
408
+
148
409
  if (applicableCount === 0) {
149
410
  return { ruleId: rule.ruleId, outcome: 'notApplicable', severity: 'minor', occurrences: [] };
150
411
  }
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: [] };
412
+
413
+ // See helpers.resolveTieredOutcome's own header comment (src/core/dom-helpers.js):
414
+ // a fail-tier finding never silently discards cantTell-tier findings from
415
+ // the same run — both are returned together when the outcome is 'fail'.
416
+ const resolved = helpers.resolveTieredOutcome(
417
+ failOccurrences,
418
+ cantTellOccurrences,
419
+ rule.defaultSeverity || 'moderate'
420
+ );
421
+ return { ruleId: rule.ruleId, ...resolved };
155
422
  }
156
423
 
157
424
  module.exports = { id, meta, runInPage };