@surea11y/core 1.1.1 → 1.2.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 (113) hide show
  1. package/CHANGELOG.md +29 -0
  2. package/README.md +3 -0
  3. package/bin/core.js +107 -3
  4. package/docs/API_STABILITY.md +61 -0
  5. package/docs/BASELINE.md +66 -0
  6. package/docs/CLI.md +26 -0
  7. package/docs/ENGINE_OPTIONS.md +2 -0
  8. package/docs/INTEGRATION.md +1 -1
  9. package/docs/OUTPUT_SCHEMA.md +1 -1
  10. package/docs/REPORT.md +33 -0
  11. package/docs/RULE_AUTHORING.md +31 -0
  12. package/package.json +1 -1
  13. package/src/baseline.js +0 -0
  14. package/src/checks/automatic/aria-allowed-attr.js +2 -3
  15. package/src/checks/automatic/aria-allowed-role.js +2 -3
  16. package/src/checks/automatic/aria-braille-equivalent.js +3 -4
  17. package/src/checks/automatic/aria-conditional-attr.js +3 -4
  18. package/src/checks/automatic/aria-deprecated-role.js +2 -3
  19. package/src/checks/automatic/aria-hidden-body.js +9 -1
  20. package/src/checks/automatic/aria-prohibited-attr.js +2 -3
  21. package/src/checks/automatic/aria-prohibited-children.js +73 -17
  22. package/src/checks/automatic/aria-required-attr.js +2 -3
  23. package/src/checks/automatic/aria-required-children.js +2 -3
  24. package/src/checks/automatic/aria-required-parent.js +3 -4
  25. package/src/checks/automatic/aria-roles-valid.js +2 -3
  26. package/src/checks/automatic/aria-valid-attr-value.js +2 -3
  27. package/src/checks/automatic/aria-valid-attr.js +2 -3
  28. package/src/checks/automatic/autocomplete-valid.js +2 -3
  29. package/src/checks/automatic/avoid-inline-spacing.js +2 -3
  30. package/src/checks/automatic/binary-control-name-present.js +2 -3
  31. package/src/checks/automatic/button-name-present.js +14 -4
  32. package/src/checks/automatic/bypass-blocks-present.js +44 -8
  33. package/src/checks/automatic/combobox-name-present.js +2 -3
  34. package/src/checks/automatic/css-orientation-lock.js +9 -1
  35. package/src/checks/automatic/definition-list-children-valid.js +2 -3
  36. package/src/checks/automatic/deprecated-elements-not-used.js +2 -3
  37. package/src/checks/automatic/dialog-name-present.js +2 -3
  38. package/src/checks/automatic/dlitem-parent-valid.js +2 -3
  39. package/src/checks/automatic/form-control-single-label.js +2 -3
  40. package/src/checks/automatic/html-xml-lang-mismatch.js +9 -1
  41. package/src/checks/automatic/iframe-focusable-content.js +2 -3
  42. package/src/checks/automatic/iframe-name-present.js +2 -3
  43. package/src/checks/automatic/iframe-title-unique.js +2 -3
  44. package/src/checks/automatic/label-in-name.js +7 -8
  45. package/src/checks/automatic/language-page-present.js +9 -1
  46. package/src/checks/automatic/link-in-text-block.js +2 -3
  47. package/src/checks/automatic/link-name-present.js +16 -4
  48. package/src/checks/automatic/list-children-valid.js +2 -3
  49. package/src/checks/automatic/listbox-name-present.js +2 -3
  50. package/src/checks/automatic/listitem-parent-valid.js +2 -3
  51. package/src/checks/automatic/menuitem-name-present.js +2 -3
  52. package/src/checks/automatic/meta-refresh-no-exceptions.js +9 -1
  53. package/src/checks/automatic/meta-refresh-timing-absent.js +9 -1
  54. package/src/checks/automatic/meta-viewport-zoom-enabled.js +9 -1
  55. package/src/checks/automatic/meter-name-present.js +2 -3
  56. package/src/checks/automatic/nested-interactive-controls-absent.js +2 -3
  57. package/src/checks/automatic/option-name-present.js +2 -3
  58. package/src/checks/automatic/page-title-present.js +9 -1
  59. package/src/checks/automatic/progressbar-name-present.js +2 -3
  60. package/src/checks/automatic/searchbox-name-present.js +2 -3
  61. package/src/checks/automatic/server-side-image-map-absent.js +2 -3
  62. package/src/checks/automatic/slider-name-present.js +2 -3
  63. package/src/checks/automatic/spinbutton-name-present.js +2 -3
  64. package/src/checks/automatic/summary-name-present.js +2 -3
  65. package/src/checks/automatic/tab-name-present.js +2 -3
  66. package/src/checks/automatic/table-headers-attr-valid.js +2 -3
  67. package/src/checks/automatic/table-th-has-data-cells.js +2 -3
  68. package/src/checks/automatic/td-has-header.js +2 -3
  69. package/src/checks/automatic/textbox-name-present.js +2 -3
  70. package/src/checks/automatic/tooltip-name-present.js +2 -3
  71. package/src/checks/automatic/treeitem-name-present.js +2 -3
  72. package/src/checks/automatic/valid-lang.js +2 -3
  73. package/src/checks/manual/accesskeys-manual.js +1 -7
  74. package/src/checks/manual/aria-checked-state-mismatch-manual.js +3 -4
  75. package/src/checks/manual/aria-text-manual.js +2 -3
  76. package/src/checks/manual/empty-heading-manual.js +1 -6
  77. package/src/checks/manual/empty-table-header-manual.js +5 -9
  78. package/src/checks/manual/focus-order-semantics-manual.js +2 -3
  79. package/src/checks/manual/heading-order-manual.js +1 -6
  80. package/src/checks/manual/identical-links-same-purpose-manual.js +2 -3
  81. package/src/checks/manual/image-redundant-alt-manual.js +2 -3
  82. package/src/checks/manual/label-title-only-manual.js +2 -3
  83. package/src/checks/manual/landmark-banner-is-top-level-manual.js +30 -14
  84. package/src/checks/manual/landmark-contentinfo-is-top-level-manual.js +30 -14
  85. package/src/checks/manual/landmark-main-is-top-level-manual.js +30 -14
  86. package/src/checks/manual/landmark-no-duplicate-banner-manual.js +25 -13
  87. package/src/checks/manual/landmark-no-duplicate-contentinfo-manual.js +25 -13
  88. package/src/checks/manual/landmark-one-main-manual.js +9 -1
  89. package/src/checks/manual/landmark-unique-manual.js +32 -30
  90. package/src/checks/manual/link-name-quality-manual.js +2 -3
  91. package/src/checks/manual/meta-viewport-large-manual.js +9 -1
  92. package/src/checks/manual/mouse-only-event-handlers-manual.js +2 -3
  93. package/src/checks/manual/no-autoplay-audio-manual.js +2 -3
  94. package/src/checks/manual/p-as-heading-manual.js +2 -3
  95. package/src/checks/manual/page-has-heading-one-manual.js +40 -3
  96. package/src/checks/manual/page-title-patterns-manual.js +9 -1
  97. package/src/checks/manual/presentation-role-conflict-manual.js +3 -4
  98. package/src/checks/manual/region-manual.js +34 -14
  99. package/src/checks/manual/scope-attr-valid-manual.js +2 -7
  100. package/src/checks/manual/scrollable-region-focusable-manual.js +2 -3
  101. package/src/checks/manual/skip-link-manual.js +91 -9
  102. package/src/checks/manual/tabindex-manual.js +2 -7
  103. package/src/checks/manual/table-duplicate-name-manual.js +2 -3
  104. package/src/checks/manual/table-fake-caption-manual.js +2 -3
  105. package/src/checks/manual/video-caption-manual.js +2 -3
  106. package/src/core/aria-helpers.js +82 -18
  107. package/src/core/dom-helpers.js +88 -3
  108. package/src/core/dom-runner.js +23 -0
  109. package/src/core/rule-meta.js +19 -0
  110. package/src/core.js +2632 -885
  111. package/src/i18n/en.js +6 -2
  112. package/src/i18n/fr.js +6 -2
  113. package/src/report.js +482 -0
@@ -45,15 +45,14 @@ const meta = {
45
45
  };
46
46
 
47
47
  function runInPage(ctx) {
48
- const { document, root, helpers, rule } = ctx;
49
- const safeRoot = root || document;
48
+ const { document, helpers, rule } = ctx;
50
49
 
51
50
  const ariaHelpers = helpers && helpers.aria ? helpers.aria : null;
52
51
  if (!ariaHelpers) {
53
52
  return { ruleId: rule.ruleId, outcome: 'notApplicable', severity: 'minor', occurrences: [] };
54
53
  }
55
54
 
56
- const nodes = helpers.queryAllSmart ? helpers.queryAllSmart('[role]', safeRoot) : helpers.queryAll('[role]', safeRoot);
55
+ const nodes = helpers.queryAllSmart ? helpers.queryAllSmart('[role]') : helpers.queryAll('[role]');
57
56
 
58
57
  const occurrences = [];
59
58
  let applicableCount = 0;
@@ -48,6 +48,14 @@ const meta = {
48
48
  }
49
49
  };
50
50
 
51
+ // This check is inherently whole-document (does the PAGE have this
52
+ // property?), not evaluable per-subtree -- notApplicable when contextSelector
53
+ // scoped this run narrower than the whole document, or when
54
+ // engineOptions.fragment:true was set (see helpers.isWholeDocumentScope).
55
+ function applicability(ctx) {
56
+ return ctx.helpers.isWholeDocumentScope ? ctx.helpers.isWholeDocumentScope() : true;
57
+ }
58
+
51
59
  function runInPage(ctx) {
52
60
  const { document, helpers, rule } = ctx;
53
61
 
@@ -84,4 +92,4 @@ function runInPage(ctx) {
84
92
  return { ruleId: rule.ruleId, outcome: 'fail', severity: rule.defaultSeverity || 'critical', occurrences };
85
93
  }
86
94
 
87
- module.exports = { id, meta, runInPage };
95
+ module.exports = { id, meta, runInPage, applicability };
@@ -83,8 +83,7 @@ const meta = {
83
83
  };
84
84
 
85
85
  function runInPage(ctx) {
86
- const { document, root, helpers, rule } = ctx;
87
- const safeRoot = root || document;
86
+ const { document, helpers, rule } = ctx;
88
87
 
89
88
  const ariaHelpers = helpers && helpers.aria ? helpers.aria : null;
90
89
  if (!ariaHelpers) {
@@ -104,7 +103,7 @@ function runInPage(ctx) {
104
103
 
105
104
  const PROHIBITED_NAMING_ATTRS = ['aria-label', 'aria-labelledby'];
106
105
 
107
- const nodes = helpers.queryAllSmart ? helpers.queryAllSmart('[role]', safeRoot) : helpers.queryAll('[role]', safeRoot);
106
+ const nodes = helpers.queryAllSmart ? helpers.queryAllSmart('[role]') : helpers.queryAll('[role]');
108
107
 
109
108
  const occurrences = [];
110
109
  let applicableCount = 0;
@@ -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
@@ -100,8 +130,7 @@ const meta = {
100
130
  };
101
131
 
102
132
  function runInPage(ctx) {
103
- const { document, root, helpers, rule } = ctx;
104
- const safeRoot = root || document;
133
+ const { document, helpers, rule } = ctx;
105
134
 
106
135
  const ariaHelpers = helpers && helpers.aria ? helpers.aria : null;
107
136
  if (!ariaHelpers) {
@@ -152,6 +181,11 @@ function runInPage(ctx) {
152
181
  // entry with role: null, which can never satisfy a required-role set)
153
182
  // when it carries a global aria-* attribute or is focusable — matches
154
183
  // a widely-used reference engine's own getOwnedRoles exactly (see header comment).
184
+ // kidRole comes from getContainmentRole, not getExplicitRole (see header
185
+ // comment's 2026-07-31 fix): "roleless" here means neither an explicit
186
+ // role="" NOR one of the native containment tags (li, tr, td, ...), so a
187
+ // bare <li>/<tr>/... is a real listitem/row boundary, not a transparent
188
+ // wrapper the walk should pass through.
155
189
  function collectOwnedRoles(el, requiredSet, out, depth) {
156
190
  if (depth > MAX_DEPTH) return;
157
191
  const kids = el.children ? Array.prototype.slice.call(el.children) : [];
@@ -159,21 +193,27 @@ function runInPage(ctx) {
159
193
  if (!kid || kid.nodeType !== 1) continue;
160
194
  if (!isEligibleAcc(kid)) continue;
161
195
 
162
- const kidRole = ariaHelpers.getExplicitRole(kid);
196
+ const kidRole = ariaHelpers.getContainmentRole(kid);
163
197
  const isPresentational = kidRole === 'presentation' || kidRole === 'none';
164
198
  const isTransparentGroup = (kidRole === 'group' || kidRole === 'rowgroup') && requiredSet.has(kidRole);
165
199
 
166
200
  if (!kidRole && !isPresentational) {
167
201
  const globalAttr = getGlobalAriaAttr(kid);
168
- let focusable = false;
202
+ let mechanism = 'none';
169
203
  try {
170
204
  const fi = helpers && typeof helpers.getFocusableInfo === 'function' ? helpers.getFocusableInfo(kid, ctx) : null;
171
- focusable = !!(fi && fi.focusable);
205
+ mechanism = (fi && fi.focusable && fi.mechanism) || 'none';
172
206
  } catch {
173
- focusable = false;
207
+ mechanism = 'none';
174
208
  }
175
- if (globalAttr || focusable) {
176
- out.push({ el: kid, role: null, attr: globalAttr || 'tabindex' });
209
+ if (globalAttr || mechanism !== 'none') {
210
+ // `mechanism` distinguishes an actual tabindex="" attribute from
211
+ // native focusability (e.g. <a href>, <button>, <input>) — these
212
+ // are different facts and must not be reported as the same
213
+ // "carries tabindex" claim (a native anchor with no tabindex
214
+ // attribute at all is not "carrying tabindex").
215
+ const attr = globalAttr || (mechanism === 'tabindex' ? 'tabindex' : 'nativeFocusable');
216
+ out.push({ el: kid, role: null, attr });
177
217
  continue; // real accessible-tree node: stop here, do not recurse further
178
218
  }
179
219
  }
@@ -190,7 +230,7 @@ function runInPage(ctx) {
190
230
  }
191
231
  }
192
232
 
193
- const nodes = helpers.queryAllSmart ? helpers.queryAllSmart('[role]', safeRoot) : helpers.queryAll('[role]', safeRoot);
233
+ const nodes = helpers.queryAllSmart ? helpers.queryAllSmart('[role]') : helpers.queryAll('[role]');
194
234
 
195
235
  const occurrences = [];
196
236
  let applicableCount = 0;
@@ -220,12 +260,28 @@ function runInPage(ctx) {
220
260
  const containerSelector = helpers.buildSelector ? helpers.buildSelector(el) : 'html';
221
261
 
222
262
  const isRoleless = !entry.role;
223
- const summary = isRoleless
224
- ? `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.`
225
- : `This element has role="${entry.role}", which is not an allowed owned child of the enclosing role="${role}" container.`;
226
- const hint = isRoleless
227
- ? `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.`
228
- : `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.`;
263
+ const isNativeFocusable = entry.attr === 'nativeFocusable';
264
+
265
+ let summary;
266
+ let hint;
267
+ let summaryKey;
268
+ let hintKey;
269
+ if (isNativeFocusable) {
270
+ 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.`;
271
+ 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.`;
272
+ summaryKey = 'ariaProhibitedChildren_summary_fail_native_focusable';
273
+ hintKey = 'ariaProhibitedChildren_hint_fail_native_focusable';
274
+ } else if (isRoleless) {
275
+ 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.`;
276
+ 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.`;
277
+ summaryKey = 'ariaProhibitedChildren_summary_fail_roleless';
278
+ hintKey = 'ariaProhibitedChildren_hint_fail_roleless';
279
+ } else {
280
+ summary = `This element has role="${entry.role}", which is not an allowed owned child of the enclosing role="${role}" container.`;
281
+ 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.`;
282
+ summaryKey = 'ariaProhibitedChildren_summary_fail';
283
+ hintKey = 'ariaProhibitedChildren_hint_fail';
284
+ }
229
285
 
230
286
  occurrences.push({
231
287
  selector: stableSelector,
@@ -233,8 +289,8 @@ function runInPage(ctx) {
233
289
  summary,
234
290
  hint,
235
291
  i18n: {
236
- summaryKey: isRoleless ? 'ariaProhibitedChildren_summary_fail_roleless' : 'ariaProhibitedChildren_summary_fail',
237
- hintKey: isRoleless ? 'ariaProhibitedChildren_hint_fail_roleless' : 'ariaProhibitedChildren_hint_fail',
292
+ summaryKey,
293
+ hintKey,
238
294
  params: isRoleless
239
295
  ? { attr: entry.attr, containerRole: role }
240
296
  : { childRole: entry.role, containerRole: role, allowedRoles: requiredOwned.join(', ') }
@@ -69,8 +69,7 @@ const meta = {
69
69
  };
70
70
 
71
71
  function runInPage(ctx) {
72
- const { document, root, helpers, rule } = ctx;
73
- const safeRoot = root || document;
72
+ const { document, helpers, rule } = ctx;
74
73
 
75
74
  const ariaHelpers = helpers && helpers.aria ? helpers.aria : null;
76
75
  if (!ariaHelpers) {
@@ -94,7 +93,7 @@ function runInPage(ctx) {
94
93
  return v != null && String(v).trim().toLowerCase() === 'true';
95
94
  }
96
95
 
97
- const nodes = helpers.queryAllSmart ? helpers.queryAllSmart('[role]', safeRoot) : helpers.queryAll('[role]', safeRoot);
96
+ const nodes = helpers.queryAllSmart ? helpers.queryAllSmart('[role]') : helpers.queryAll('[role]');
98
97
 
99
98
  const occurrences = [];
100
99
  let applicableCount = 0;
@@ -80,8 +80,7 @@ const meta = {
80
80
  };
81
81
 
82
82
  function runInPage(ctx) {
83
- const { document, root, helpers, rule } = ctx;
84
- const safeRoot = root || document;
83
+ const { document, helpers, rule } = ctx;
85
84
 
86
85
  const ariaHelpers = helpers && helpers.aria ? helpers.aria : null;
87
86
  if (!ariaHelpers) {
@@ -112,7 +111,7 @@ function runInPage(ctx) {
112
111
  // ("runInPage MUST be self-contained").
113
112
  const CANDIDATE_SELECTOR = '[role], li, option, tr, td, th, thead, tbody, tfoot, ul, ol, table, select, input[type="radio"]';
114
113
 
115
- const nodes = helpers.queryAllSmart ? helpers.queryAllSmart('[role]', safeRoot) : helpers.queryAll('[role]', safeRoot);
114
+ const nodes = helpers.queryAllSmart ? helpers.queryAllSmart('[role]') : helpers.queryAll('[role]');
116
115
 
117
116
  const occurrences = [];
118
117
  let applicableCount = 0;
@@ -66,8 +66,7 @@ const meta = {
66
66
  };
67
67
 
68
68
  function runInPage(ctx) {
69
- const { document, root, helpers, rule } = ctx;
70
- const safeRoot = root || document;
69
+ const { document, helpers, rule } = ctx;
71
70
 
72
71
  const ariaHelpers = helpers && helpers.aria ? helpers.aria : null;
73
72
  if (!ariaHelpers) {
@@ -153,7 +152,7 @@ function runInPage(ctx) {
153
152
  const idTok = elId && String(elId).trim();
154
153
  if (!idTok) return false;
155
154
 
156
- const owners = helpers.queryAllSmart ? helpers.queryAllSmart('[aria-owns]', safeRoot) : helpers.queryAll('[aria-owns]', safeRoot);
155
+ const owners = helpers.queryAllSmart ? helpers.queryAllSmart('[aria-owns]') : helpers.queryAll('[aria-owns]');
157
156
  for (const owner of owners) {
158
157
  if (!owner || !owner.getAttribute) continue;
159
158
  const ownsAttr = owner.getAttribute('aria-owns') || '';
@@ -166,7 +165,7 @@ function runInPage(ctx) {
166
165
  return false;
167
166
  }
168
167
 
169
- const nodes = helpers.queryAllSmart ? helpers.queryAllSmart('[role]', safeRoot) : helpers.queryAll('[role]', safeRoot);
168
+ const nodes = helpers.queryAllSmart ? helpers.queryAllSmart('[role]') : helpers.queryAll('[role]');
170
169
 
171
170
  const occurrences = [];
172
171
  let applicableCount = 0;
@@ -44,15 +44,14 @@ const meta = {
44
44
  };
45
45
 
46
46
  function runInPage(ctx) {
47
- const { document, root, helpers, rule } = ctx;
48
- const safeRoot = root || document;
47
+ const { document, helpers, rule } = ctx;
49
48
 
50
49
  const ariaHelpers = helpers && helpers.aria ? helpers.aria : null;
51
50
  if (!ariaHelpers) {
52
51
  return { ruleId: rule.ruleId, outcome: 'notApplicable', severity: 'minor', occurrences: [] };
53
52
  }
54
53
 
55
- const nodes = helpers.queryAllSmart ? helpers.queryAllSmart('[role]', safeRoot) : helpers.queryAll('[role]', safeRoot);
54
+ const nodes = helpers.queryAllSmart ? helpers.queryAllSmart('[role]') : helpers.queryAll('[role]');
56
55
 
57
56
  const occurrences = [];
58
57
  let applicableCount = 0;
@@ -57,15 +57,14 @@ const meta = {
57
57
  };
58
58
 
59
59
  function runInPage(ctx) {
60
- const { document, root, helpers, rule } = ctx;
61
- const safeRoot = root || document;
60
+ const { document, helpers, rule } = ctx;
62
61
 
63
62
  const ariaHelpers = helpers && helpers.aria ? helpers.aria : null;
64
63
  if (!ariaHelpers) {
65
64
  return { ruleId: rule.ruleId, outcome: 'notApplicable', severity: 'minor', occurrences: [] };
66
65
  }
67
66
 
68
- const nodes = helpers.queryAllSmart ? helpers.queryAllSmart('*', safeRoot) : helpers.queryAll('*', safeRoot);
67
+ const nodes = helpers.queryAllSmart ? helpers.queryAllSmart('*') : helpers.queryAll('*');
69
68
 
70
69
  const occurrences = [];
71
70
  let applicableCount = 0;
@@ -46,15 +46,14 @@ const meta = {
46
46
  };
47
47
 
48
48
  function runInPage(ctx) {
49
- const { document, root, helpers, rule } = ctx;
50
- const safeRoot = root || document;
49
+ const { document, helpers, rule } = ctx;
51
50
 
52
51
  const ariaHelpers = helpers && helpers.aria ? helpers.aria : null;
53
52
  if (!ariaHelpers) {
54
53
  return { ruleId: rule.ruleId, outcome: 'notApplicable', severity: 'minor', occurrences: [] };
55
54
  }
56
55
 
57
- const nodes = helpers.queryAllSmart ? helpers.queryAllSmart('*', safeRoot) : helpers.queryAll('*', safeRoot);
56
+ const nodes = helpers.queryAllSmart ? helpers.queryAllSmart('*') : helpers.queryAll('*');
58
57
 
59
58
  const occurrences = [];
60
59
  let applicableCount = 0;
@@ -52,8 +52,7 @@ const meta = {
52
52
  };
53
53
 
54
54
  function runInPage(ctx) {
55
- const { document, root, helpers, rule } = ctx;
56
- const safeRoot = root || document;
55
+ const { document, helpers, rule } = ctx;
57
56
 
58
57
  // Declared inside runInPage — see scripts/build-core.js header
59
58
  // ("runInPage MUST be self-contained").
@@ -88,7 +87,7 @@ function runInPage(ctx) {
88
87
  return FIELD_NAMES.has(remaining[0]);
89
88
  }
90
89
 
91
- const nodes = helpers.queryAllSmart ? helpers.queryAllSmart('input, select, textarea', safeRoot) : helpers.queryAll('input, select, textarea', safeRoot);
90
+ const nodes = helpers.queryAllSmart ? helpers.queryAllSmart('input, select, textarea') : helpers.queryAll('input, select, textarea');
92
91
 
93
92
  const occurrences = [];
94
93
  let applicableCount = 0;
@@ -46,12 +46,11 @@ const meta = {
46
46
  };
47
47
 
48
48
  function runInPage(ctx) {
49
- const { document, root, helpers, rule } = ctx;
50
- const safeRoot = root || document;
49
+ const { document, helpers, rule } = ctx;
51
50
 
52
51
  const SPACING_PROPS = ['line-height', 'letter-spacing', 'word-spacing'];
53
52
 
54
- const nodes = helpers.queryAllSmart ? helpers.queryAllSmart('[style]', safeRoot) : helpers.queryAll('[style]', safeRoot);
53
+ const nodes = helpers.queryAllSmart ? helpers.queryAllSmart('[style]') : helpers.queryAll('[style]');
55
54
 
56
55
  const occurrences = [];
57
56
  let applicableCount = 0;
@@ -23,11 +23,10 @@ const meta = {
23
23
  };
24
24
 
25
25
  function runInPage(ctx) {
26
- const { document, root, helpers, rule } = ctx;
26
+ const { document, helpers, rule } = ctx;
27
27
  const getEligibilityInfo = helpers && typeof helpers.getEligibilityInfo === 'function'
28
28
  ? helpers.getEligibilityInfo
29
29
  : null;
30
- const safeRoot = root || document;
31
30
 
32
31
 
33
32
  function normalizeWs(s) {
@@ -159,7 +158,7 @@ function runInPage(ctx) {
159
158
  let applicableCount = 0;
160
159
 
161
160
  const selector = 'input[type="checkbox"], input[type="radio"], [role="checkbox"], [role="radio"], [role="switch"]';
162
- const nodes = helpers.queryAllSmart ? helpers.queryAllSmart(selector, safeRoot) : helpers.queryAll(selector, safeRoot);
161
+ const nodes = helpers.queryAllSmart ? helpers.queryAllSmart(selector) : helpers.queryAll(selector);
163
162
 
164
163
 
165
164
  // Precompute label[for] associations once for speed/determinism.
@@ -25,8 +25,7 @@ const meta = {
25
25
  };
26
26
 
27
27
  function runInPage(ctx) {
28
- const { document, root, helpers, rule } = ctx;
29
- const safeRoot = root || document;
28
+ const { document, helpers, rule } = ctx;
30
29
 
31
30
  const occurrences = [];
32
31
  let applicableCount = 0;
@@ -63,7 +62,7 @@ function runInPage(ctx) {
63
62
  }
64
63
 
65
64
  const selector = 'button, input[type="button"], input[type="submit"], input[type="reset"], [role="button"]';
66
- const nodes = helpers.queryAllSmart ? helpers.queryAllSmart(selector, safeRoot) : helpers.queryAll(selector, safeRoot);
65
+ const nodes = helpers.queryAllSmart ? helpers.queryAllSmart(selector) : helpers.queryAll(selector);
67
66
 
68
67
  for (const el of nodes) {
69
68
  // isAccTreeEligible returns { eligible, reasons }, not a boolean.
@@ -75,6 +74,7 @@ function runInPage(ctx) {
75
74
 
76
75
  const tag = (el.tagName || '').toLowerCase();
77
76
  const role = el.getAttribute ? el.getAttribute('role') : null;
77
+ const roleNorm = normalizeWs(role).toLowerCase();
78
78
 
79
79
  const nameInfo = helpers.getAccessibleNameInfo ? helpers.getAccessibleNameInfo(el, ctx) : null;
80
80
 
@@ -90,7 +90,17 @@ function runInPage(ctx) {
90
90
  inputValueName = getInputButtonValueName(el);
91
91
  }
92
92
 
93
- const isContentNameCandidate = tag === 'button' || role === 'button';
93
+ // A native <button> (or [role="button"]) whose role has been overridden to
94
+ // one of these roles is no longer semantically a button — per the WAI-ARIA
95
+ // Accessible Name and Description Computation spec these roles are
96
+ // name-from-author-only, and their rendered content represents a VALUE,
97
+ // not a NAME (mirrors axe-core's controlValueRoles, verified against its
98
+ // source). Found on a real page (Spotify's "sort by" control): a
99
+ // <button role="combobox">List</button> where "List" is the combobox's
100
+ // currently selected value, not a label for what the combobox is —
101
+ // crediting it as the accessible name masked a real missing-name bug.
102
+ const VALUE_ROLES = ['textbox', 'progressbar', 'scrollbar', 'slider', 'spinbutton', 'combobox', 'listbox'];
103
+ const isContentNameCandidate = (tag === 'button' || role === 'button') && !VALUE_ROLES.includes(roleNorm);
94
104
  const contentName =
95
105
  (!trustedProgrammaticName && !inputValueName && isContentNameCandidate)
96
106
  ? getConservativeSubtreeText(el)
@@ -67,6 +67,14 @@ const meta = {
67
67
  coverage: { facetsBySc: { '2.4.1': ['bypass-blocks-present'] } }
68
68
  };
69
69
 
70
+ // This check is inherently whole-document (does the PAGE have this
71
+ // property?), not evaluable per-subtree -- notApplicable when contextSelector
72
+ // scoped this run narrower than the whole document, or when
73
+ // engineOptions.fragment:true was set (see helpers.isWholeDocumentScope).
74
+ function applicability(ctx) {
75
+ return ctx.helpers.isWholeDocumentScope ? ctx.helpers.isWholeDocumentScope() : true;
76
+ }
77
+
70
78
  function runInPage(ctx) {
71
79
  const { document, helpers, rule } = ctx;
72
80
 
@@ -75,14 +83,43 @@ function runInPage(ctx) {
75
83
  return { ruleId: rule.ruleId, outcome: 'notApplicable', severity: 'minor', occurrences: [] };
76
84
  }
77
85
 
78
- function hasMainLandmark() {
86
+ const isAccTreeEligible = helpers && typeof helpers.isAccTreeEligible === 'function' ? helpers.isAccTreeEligible : null;
87
+
88
+ function isExposedToAt(el) {
89
+ if (!isAccTreeEligible) return true;
90
+ try {
91
+ const r = isAccTreeEligible(el, ctx);
92
+ if (typeof r === 'boolean') return r;
93
+ return !!(r && r.eligible);
94
+ } catch {
95
+ return true;
96
+ }
97
+ }
98
+
99
+ function queryAll(selector) {
79
100
  try {
80
- return !!document.querySelector('main, [role="main"]');
101
+ return helpers && typeof helpers.queryAllSmart === 'function'
102
+ ? helpers.queryAllSmart(selector)
103
+ : document.querySelectorAll(selector);
81
104
  } catch {
82
- return false;
105
+ return [];
83
106
  }
84
107
  }
85
108
 
109
+ // Filters through isAccTreeEligible (hidden/aria-hidden/display:none/inert
110
+ // don't count as "a bypass mechanism is present") -- see page-has-heading-
111
+ // one-manual.js's identical fix for the real-world trigger (CDC's flu
112
+ // page, 2026-07-30: its only <h1> sits inside a display:none ancestor,
113
+ // unreachable by sighted and screen reader users alike). A fully
114
+ // non-rendered <main>/heading was previously credited here too, wrongly
115
+ // returning `pass` for a page with zero actual bypass mechanisms.
116
+ function hasMainLandmark() {
117
+ for (const el of queryAll('main, [role="main"]')) {
118
+ if (el && isExposedToAt(el)) return true;
119
+ }
120
+ return false;
121
+ }
122
+
86
123
  function hasWorkingAnchorLink() {
87
124
  let links = [];
88
125
  try {
@@ -122,11 +159,10 @@ function runInPage(ctx) {
122
159
  }
123
160
 
124
161
  function hasHeading() {
125
- try {
126
- return !!document.querySelector('h1, h2, h3, h4, h5, h6, [role="heading"]');
127
- } catch {
128
- return false;
162
+ for (const el of queryAll('h1, h2, h3, h4, h5, h6, [role="heading"]')) {
163
+ if (el && isExposedToAt(el)) return true;
129
164
  }
165
+ return false;
130
166
  }
131
167
 
132
168
  const mainLandmark = hasMainLandmark();
@@ -159,4 +195,4 @@ function runInPage(ctx) {
159
195
  return { ruleId: rule.ruleId, outcome: 'fail', severity: rule.defaultSeverity || 'serious', occurrences };
160
196
  }
161
197
 
162
- module.exports = { id, meta, runInPage };
198
+ module.exports = { id, meta, runInPage, applicability };
@@ -23,11 +23,10 @@ const meta = {
23
23
  };
24
24
 
25
25
  function runInPage(ctx) {
26
- const { document, root, helpers, rule } = ctx;
26
+ const { document, helpers, rule } = ctx;
27
27
  const getEligibilityInfo = helpers && typeof helpers.getEligibilityInfo === 'function'
28
28
  ? helpers.getEligibilityInfo
29
29
  : null;
30
- const safeRoot = root || document;
31
30
 
32
31
 
33
32
  function normalizeWs(s) {
@@ -159,7 +158,7 @@ function runInPage(ctx) {
159
158
  let applicableCount = 0;
160
159
 
161
160
  const selector = '[role="combobox"]';
162
- const nodes = helpers.queryAllSmart ? helpers.queryAllSmart(selector, safeRoot) : helpers.queryAll(selector, safeRoot);
161
+ const nodes = helpers.queryAllSmart ? helpers.queryAllSmart(selector) : helpers.queryAll(selector);
163
162
 
164
163
  // Precompute label[for] map for combobox elements that are labelable
165
164
  // native form controls (e.g. <input role="combobox">).
@@ -73,6 +73,14 @@ const meta = {
73
73
  coverage: { facetsBySc: { '1.3.4': ['css-orientation-lock'] } }
74
74
  };
75
75
 
76
+ // This check is inherently whole-document (does the PAGE have this
77
+ // property?), not evaluable per-subtree -- notApplicable when contextSelector
78
+ // scoped this run narrower than the whole document, or when
79
+ // engineOptions.fragment:true was set (see helpers.isWholeDocumentScope).
80
+ function applicability(ctx) {
81
+ return ctx.helpers.isWholeDocumentScope ? ctx.helpers.isWholeDocumentScope() : true;
82
+ }
83
+
76
84
  function runInPage(ctx) {
77
85
  const { document, helpers, rule } = ctx;
78
86
 
@@ -203,4 +211,4 @@ function runInPage(ctx) {
203
211
  return { ruleId: rule.ruleId, outcome: 'fail', severity: rule.defaultSeverity || 'serious', occurrences };
204
212
  }
205
213
 
206
- module.exports = { id, meta, runInPage };
214
+ module.exports = { id, meta, runInPage, applicability };
@@ -54,14 +54,13 @@ const meta = {
54
54
  };
55
55
 
56
56
  function runInPage(ctx) {
57
- const { document, root, helpers, rule } = ctx;
58
- const safeRoot = root || document;
57
+ const { document, helpers, rule } = ctx;
59
58
 
60
59
  // Declared inside runInPage — see scripts/build-core.js header
61
60
  // ("runInPage MUST be self-contained").
62
61
  const PASSTHROUGH_TAGS = new Set(['dt', 'dd', 'script', 'template', 'style']);
63
62
 
64
- const nodes = helpers.queryAllSmart ? helpers.queryAllSmart('dl', safeRoot) : helpers.queryAll('dl', safeRoot);
63
+ const nodes = helpers.queryAllSmart ? helpers.queryAllSmart('dl') : helpers.queryAll('dl');
65
64
 
66
65
  const occurrences = [];
67
66
  let applicableCount = 0;
@@ -48,10 +48,9 @@ const meta = {
48
48
  };
49
49
 
50
50
  function runInPage(ctx) {
51
- const { document, root, helpers, rule } = ctx;
52
- const safeRoot = root || document;
51
+ const { document, helpers, rule } = ctx;
53
52
 
54
- const nodes = helpers.queryAllSmart ? helpers.queryAllSmart('blink, marquee', safeRoot) : helpers.queryAll('blink, marquee', safeRoot);
53
+ const nodes = helpers.queryAllSmart ? helpers.queryAllSmart('blink, marquee') : helpers.queryAll('blink, marquee');
55
54
 
56
55
  const occurrences = [];
57
56
  let applicableCount = 0;
@@ -23,11 +23,10 @@ const meta = {
23
23
  };
24
24
 
25
25
  function runInPage(ctx) {
26
- const { document, root, helpers, rule } = ctx;
26
+ const { document, helpers, rule } = ctx;
27
27
  const getEligibilityInfo = helpers && typeof helpers.getEligibilityInfo === 'function'
28
28
  ? helpers.getEligibilityInfo
29
29
  : null;
30
- const safeRoot = root || document;
31
30
 
32
31
 
33
32
  function normalizeWs(s) {
@@ -141,7 +140,7 @@ function runInPage(ctx) {
141
140
  let applicableCount = 0;
142
141
 
143
142
  const selector = '[role="dialog"],[role="alertdialog"]';
144
- const nodes = helpers.queryAllSmart ? helpers.queryAllSmart(selector, safeRoot) : helpers.queryAll(selector, safeRoot);
143
+ const nodes = helpers.queryAllSmart ? helpers.queryAllSmart(selector) : helpers.queryAll(selector);
145
144
 
146
145
 
147
146
  function evaluate(el) {
@@ -42,10 +42,9 @@ const meta = {
42
42
  };
43
43
 
44
44
  function runInPage(ctx) {
45
- const { document, root, helpers, rule } = ctx;
46
- const safeRoot = root || document;
45
+ const { document, helpers, rule } = ctx;
47
46
 
48
- const nodes = helpers.queryAllSmart ? helpers.queryAllSmart('dt, dd', safeRoot) : helpers.queryAll('dt, dd', safeRoot);
47
+ const nodes = helpers.queryAllSmart ? helpers.queryAllSmart('dt, dd') : helpers.queryAll('dt, dd');
49
48
 
50
49
  const occurrences = [];
51
50
  let applicableCount = 0;
@@ -46,11 +46,10 @@ const meta = {
46
46
  };
47
47
 
48
48
  function runInPage(ctx) {
49
- const { document, root, helpers, rule } = ctx;
50
- const safeRoot = root || document;
49
+ const { document, helpers, rule } = ctx;
51
50
 
52
51
  const selector = 'input:not([type="hidden"]):not([type="submit"]):not([type="reset"]):not([type="button"]):not([type="image"]),select,textarea';
53
- const nodes = helpers.queryAllSmart ? helpers.queryAllSmart(selector, safeRoot) : helpers.queryAll(selector, safeRoot);
52
+ const nodes = helpers.queryAllSmart ? helpers.queryAllSmart(selector) : helpers.queryAll(selector);
54
53
 
55
54
  // Build a for-value -> label[] map once (avoids per-control dynamic
56
55
  // attribute-selector construction / CSS.escape, which is not guaranteed