@surea11y/core 1.1.2 → 1.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (160) hide show
  1. package/CHANGELOG.md +48 -4
  2. package/LICENSE +373 -21
  3. package/README.md +70 -1
  4. package/bin/core.js +240 -11
  5. package/docs/API_STABILITY.md +61 -0
  6. package/docs/BASELINE.md +66 -0
  7. package/docs/BINDING_AUTHORS_GUIDE.md +9 -9
  8. package/docs/CI_INTEGRATIONS.md +103 -0
  9. package/docs/CLI.md +80 -1
  10. package/docs/ENGINE_OPTIONS.md +4 -0
  11. package/docs/INTEGRATION.md +19 -1
  12. package/docs/OUTPUT_SCHEMA.md +2 -2
  13. package/docs/REPORT.md +33 -0
  14. package/docs/RULE_AUTHORING.md +31 -0
  15. package/docs/RULE_CATALOG.md +1 -1
  16. package/docs/SARIF.md +59 -0
  17. package/package.json +15 -4
  18. package/src/baseline.js +0 -0
  19. package/src/catalogs/composites.wcag.js +414 -450
  20. package/src/checks/automatic/area-alt-present.js +59 -25
  21. package/src/checks/automatic/aria-allowed-attr.js +193 -33
  22. package/src/checks/automatic/aria-allowed-role.js +21 -7
  23. package/src/checks/automatic/aria-braille-equivalent.js +32 -10
  24. package/src/checks/automatic/aria-conditional-attr.js +24 -7
  25. package/src/checks/automatic/aria-deprecated-role.js +22 -8
  26. package/src/checks/automatic/aria-hidden-body.js +50 -19
  27. package/src/checks/automatic/aria-hidden-focus.js +408 -53
  28. package/src/checks/automatic/aria-prohibited-attr.js +296 -22
  29. package/src/checks/automatic/aria-prohibited-children.js +125 -29
  30. package/src/checks/automatic/aria-required-attr.js +23 -8
  31. package/src/checks/automatic/aria-required-children.js +37 -14
  32. package/src/checks/automatic/aria-required-parent.js +48 -14
  33. package/src/checks/automatic/aria-role-name-present.js +47 -21
  34. package/src/checks/automatic/aria-roles-valid.js +22 -12
  35. package/src/checks/automatic/aria-valid-attr-value.js +28 -7
  36. package/src/checks/automatic/aria-valid-attr.js +17 -5
  37. package/src/checks/automatic/autocomplete-valid.js +74 -16
  38. package/src/checks/automatic/avoid-inline-spacing.js +20 -6
  39. package/src/checks/automatic/binary-control-name-present.js +60 -50
  40. package/src/checks/automatic/button-name-present.js +48 -18
  41. package/src/checks/automatic/bypass-blocks-present.js +50 -25
  42. package/src/checks/automatic/canvas-text-alternative-present.js +57 -26
  43. package/src/checks/automatic/combobox-name-present.js +38 -45
  44. package/src/checks/automatic/contrast-computable.js +361 -341
  45. package/src/checks/automatic/contrast-enhanced.js +487 -466
  46. package/src/checks/automatic/contrast-minimum.js +486 -465
  47. package/src/checks/automatic/css-orientation-lock.js +39 -9
  48. package/src/checks/automatic/definition-list-children-valid.js +40 -19
  49. package/src/checks/automatic/deprecated-elements-not-used.js +21 -7
  50. package/src/checks/automatic/dialog-name-present.js +37 -75
  51. package/src/checks/automatic/dlitem-parent-valid.js +23 -8
  52. package/src/checks/automatic/duplicate-id-aria.js +24 -6
  53. package/src/checks/automatic/embed-text-alternative-present.js +86 -35
  54. package/src/checks/automatic/form-control-programmatic-label-present.js +79 -196
  55. package/src/checks/automatic/form-control-single-label.js +47 -10
  56. package/src/checks/automatic/html-xml-lang-mismatch.js +43 -19
  57. package/src/checks/automatic/iframe-focusable-content.js +26 -11
  58. package/src/checks/automatic/iframe-name-present.js +31 -9
  59. package/src/checks/automatic/iframe-title-unique.js +29 -8
  60. package/src/checks/automatic/img-alt-present.js +47 -43
  61. package/src/checks/automatic/input-image-alt-present.js +143 -112
  62. package/src/checks/automatic/label-in-name.js +50 -22
  63. package/src/checks/automatic/language-page-present.js +117 -109
  64. package/src/checks/automatic/link-in-text-block.js +59 -19
  65. package/src/checks/automatic/link-name-present.js +45 -14
  66. package/src/checks/automatic/list-children-valid.js +26 -9
  67. package/src/checks/automatic/listbox-name-present.js +39 -19
  68. package/src/checks/automatic/listitem-parent-valid.js +18 -6
  69. package/src/checks/automatic/menuitem-name-present.js +39 -61
  70. package/src/checks/automatic/meta-refresh-no-exceptions.js +37 -8
  71. package/src/checks/automatic/meta-refresh-timing-absent.js +28 -6
  72. package/src/checks/automatic/meta-viewport-zoom-enabled.js +32 -7
  73. package/src/checks/automatic/meter-name-present.js +36 -33
  74. package/src/checks/automatic/nested-interactive-controls-absent.js +31 -10
  75. package/src/checks/automatic/object-text-alternative-present.js +91 -39
  76. package/src/checks/automatic/option-name-present.js +38 -21
  77. package/src/checks/automatic/page-title-present.js +26 -7
  78. package/src/checks/automatic/progressbar-name-present.js +41 -34
  79. package/src/checks/automatic/role-img-alt-present.js +209 -157
  80. package/src/checks/automatic/searchbox-name-present.js +39 -19
  81. package/src/checks/automatic/server-side-image-map-absent.js +23 -8
  82. package/src/checks/automatic/slider-name-present.js +40 -47
  83. package/src/checks/automatic/spinbutton-name-present.js +39 -19
  84. package/src/checks/automatic/summary-name-present.js +37 -17
  85. package/src/checks/automatic/svg-image-text-alternative-present.js +114 -47
  86. package/src/checks/automatic/svg-text-alternative-present.js +246 -226
  87. package/src/checks/automatic/tab-name-present.js +37 -60
  88. package/src/checks/automatic/table-headers-attr-valid.js +24 -8
  89. package/src/checks/automatic/table-th-has-data-cells.js +22 -8
  90. package/src/checks/automatic/target-size-minimum.js +118 -48
  91. package/src/checks/automatic/td-has-header.js +29 -11
  92. package/src/checks/automatic/textbox-name-present.js +39 -19
  93. package/src/checks/automatic/tooltip-name-present.js +37 -18
  94. package/src/checks/automatic/treeitem-name-present.js +38 -21
  95. package/src/checks/automatic/valid-lang.js +20 -6
  96. package/src/checks/automatic/video-poster-text-alternative-present.js +79 -36
  97. package/src/checks/manual/accesskeys-manual.js +14 -5
  98. package/src/checks/manual/area-alt-decorative-manual.js +192 -193
  99. package/src/checks/manual/area-alt-quality-manual.js +182 -141
  100. package/src/checks/manual/aria-checked-state-mismatch-manual.js +34 -11
  101. package/src/checks/manual/aria-text-manual.js +14 -6
  102. package/src/checks/manual/canvas-text-alternative-quality-manual.js +149 -114
  103. package/src/checks/manual/css-hidden-focus.js +196 -165
  104. package/src/checks/manual/embed-text-alternative-quality-manual.js +171 -160
  105. package/src/checks/manual/empty-heading-manual.js +24 -12
  106. package/src/checks/manual/empty-table-header-manual.js +21 -14
  107. package/src/checks/manual/focus-order-semantics-manual.js +45 -10
  108. package/src/checks/manual/form-control-programmatic-label-quality-manual.js +207 -246
  109. package/src/checks/manual/heading-order-manual.js +22 -12
  110. package/src/checks/manual/identical-links-same-purpose-manual.js +34 -12
  111. package/src/checks/manual/image-redundant-alt-manual.js +17 -7
  112. package/src/checks/manual/img-alt-decorative-manual.js +131 -96
  113. package/src/checks/manual/img-alt-quality-manual.js +176 -127
  114. package/src/checks/manual/input-image-alt-decorative-manual.js +125 -92
  115. package/src/checks/manual/input-image-alt-quality-manual.js +125 -92
  116. package/src/checks/manual/label-title-only-manual.js +14 -5
  117. package/src/checks/manual/landmark-banner-is-top-level-manual.js +95 -29
  118. package/src/checks/manual/landmark-contentinfo-is-top-level-manual.js +82 -29
  119. package/src/checks/manual/landmark-main-is-top-level-manual.js +64 -23
  120. package/src/checks/manual/landmark-no-duplicate-banner-manual.js +54 -23
  121. package/src/checks/manual/landmark-no-duplicate-contentinfo-manual.js +54 -23
  122. package/src/checks/manual/landmark-no-duplicate-main-manual.js +17 -8
  123. package/src/checks/manual/landmark-one-main-manual.js +35 -21
  124. package/src/checks/manual/landmark-unique-manual.js +73 -45
  125. package/src/checks/manual/link-name-quality-manual.js +43 -12
  126. package/src/checks/manual/media-transcript-present-manual.js +35 -22
  127. package/src/checks/manual/meta-viewport-large-manual.js +25 -6
  128. package/src/checks/manual/mouse-only-event-handlers-manual.js +38 -11
  129. package/src/checks/manual/no-autoplay-audio-manual.js +20 -6
  130. package/src/checks/manual/object-text-alternative-quality-manual.js +175 -154
  131. package/src/checks/manual/p-as-heading-manual.js +22 -7
  132. package/src/checks/manual/page-has-heading-one-manual.js +40 -23
  133. package/src/checks/manual/page-title-patterns-manual.js +87 -51
  134. package/src/checks/manual/presentation-role-conflict-manual.js +59 -19
  135. package/src/checks/manual/region-manual.js +275 -62
  136. package/src/checks/manual/scope-attr-valid-manual.js +11 -9
  137. package/src/checks/manual/scrollable-region-focusable-manual.js +37 -11
  138. package/src/checks/manual/skip-link-manual.js +35 -12
  139. package/src/checks/manual/svg-text-alternative-quality-manual.js +206 -165
  140. package/src/checks/manual/tabindex-manual.js +11 -9
  141. package/src/checks/manual/table-duplicate-name-manual.js +17 -7
  142. package/src/checks/manual/table-fake-caption-manual.js +24 -7
  143. package/src/checks/manual/video-caption-manual.js +15 -4
  144. package/src/checks/manual-review.js +56 -12
  145. package/src/core/aria-helpers.js +1128 -823
  146. package/src/core/contrast-helpers.js +1217 -1062
  147. package/src/core/dom-helpers.js +4176 -3886
  148. package/src/core/dom-runner.js +720 -596
  149. package/src/core/frame-messaging.js +189 -138
  150. package/src/core/frame-scan.js +94 -82
  151. package/src/core/rollup-composites.js +94 -102
  152. package/src/core/rule-meta.js +71 -35
  153. package/src/core.js +37939 -29121
  154. package/src/i18n/en.js +1194 -887
  155. package/src/i18n/fr.js +1136 -793
  156. package/src/policy/contracts.js +13 -13
  157. package/src/policy/resolvePolicy.js +48 -44
  158. package/src/report.js +502 -0
  159. package/src/sarif.js +175 -0
  160. package/surea11y.browser.js +36042 -0
@@ -15,7 +15,13 @@ const meta = {
15
15
  tags: ['wcag2a', 'wcag242', 'titles', 'atomic', 'navigation', 'manual'],
16
16
  wcagSc: ['2.4.2'],
17
17
  normativeMappings: [
18
- { standard: 'WCAG', version: '2.2', requirement: '2.4.2', title: 'Page Titled', conformanceLevel: 'A' }
18
+ {
19
+ standard: 'WCAG',
20
+ version: '2.2',
21
+ requirement: '2.4.2',
22
+ title: 'Page Titled',
23
+ conformanceLevel: 'A'
24
+ }
19
25
  ],
20
26
  defaultSeverity: 'minor',
21
27
  category: 'operable',
@@ -24,13 +30,23 @@ const meta = {
24
30
  coverage: { facetsBySc: { '2.4.2': ['page-title-patterns'] } }
25
31
  };
26
32
 
33
+ // This check is inherently whole-document (does the PAGE have this
34
+ // property?), not evaluable per-subtree -- notApplicable when contextSelector
35
+ // scoped this run narrower than the whole document, or when
36
+ // engineOptions.fragment:true was set (see helpers.isWholeDocumentScope).
37
+ function applicability(ctx) {
38
+ return ctx.helpers.isWholeDocumentScope ? ctx.helpers.isWholeDocumentScope() : true;
39
+ }
40
+
27
41
  function runInPage(ctx) {
28
42
  const { document, helpers, rule } = ctx;
29
- const probes = ctx && ctx.inputs && ctx.inputs.probes && typeof ctx.inputs.probes === 'object'
43
+ const probes =
44
+ ctx && ctx.inputs && ctx.inputs.probes && typeof ctx.inputs.probes === 'object'
30
45
  ? ctx.inputs.probes
31
46
  : null;
32
47
 
33
- const pageTitlesProbe = probes && probes['crawl.pageTitles'] && typeof probes['crawl.pageTitles'] === 'object'
48
+ const pageTitlesProbe =
49
+ probes && probes['crawl.pageTitles'] && typeof probes['crawl.pageTitles'] === 'object'
34
50
  ? probes['crawl.pageTitles']
35
51
  : null;
36
52
 
@@ -45,16 +61,16 @@ function runInPage(ctx) {
45
61
  // Cross-page pattern analysis (preferred) if crawl.pageTitles probe is provided
46
62
  // =========================
47
63
  if (pageTitlesProbe && Array.isArray(pageTitlesProbe.pages)) {
48
- const pages = pageTitlesProbe.pages.filter(p => p && typeof p === 'object');
64
+ const pages = pageTitlesProbe.pages.filter((p) => p && typeof p === 'object');
49
65
 
50
66
  // Require enough data to avoid noisy conclusions
51
67
  const MIN_PAGES = 10;
52
68
  const analyzable = pages
53
- .map(p => ({
54
- url: p.url ? String(p.url) : null,
55
- title: typeof p.title === 'string' ? p.title.replace(/\s+/g, ' ').trim() : ''
56
- }))
57
- .filter(p => p.url && p.title);
69
+ .map((p) => ({
70
+ url: p.url ? String(p.url) : null,
71
+ title: typeof p.title === 'string' ? p.title.replace(/\s+/g, ' ').trim() : ''
72
+ }))
73
+ .filter((p) => p.url && p.title);
58
74
 
59
75
  if (analyzable.length >= MIN_PAGES) {
60
76
  // Build normalized title groups (case-insensitive)
@@ -66,11 +82,11 @@ function runInPage(ctx) {
66
82
  }
67
83
 
68
84
  // Duplicate titles across distinct URLs is a strong "review" signal (not a guaranteed failure)
69
- const dupGroups = Array.from(groups.values()).filter(g => g.urls.length >= 2);
85
+ const dupGroups = Array.from(groups.values()).filter((g) => g.urls.length >= 2);
70
86
 
71
87
  // Boilerplate-ish: detect a long common suffix/prefix across most titles.
72
88
  // Keep conservative: only flag if the common part is long and shared by many.
73
- const titles = analyzable.map(p => p.title);
89
+ const titles = analyzable.map((p) => p.title);
74
90
  function commonPrefix(a, b) {
75
91
  const n = Math.min(a.length, b.length);
76
92
  let i = 0;
@@ -95,35 +111,41 @@ function runInPage(ctx) {
95
111
  const suffixLen = sharedSuffix.trim().length;
96
112
 
97
113
  const hasStrongTemplateSignal =
98
- (prefixLen >= 12 || suffixLen >= 12) &&
99
- (prefixLen >= 12 ? sharedPrefix.trim().length : 0) + (suffixLen >= 12 ? sharedSuffix.trim().length : 0) >= 12;
114
+ (prefixLen >= 12 || suffixLen >= 12) &&
115
+ (prefixLen >= 12 ? sharedPrefix.trim().length : 0) +
116
+ (suffixLen >= 12 ? sharedSuffix.trim().length : 0) >=
117
+ 12;
100
118
 
101
119
  // If any cross-page signal exists, emit cantTell occurrence(s)
102
120
  if (dupGroups.length || hasStrongTemplateSignal) {
103
121
  const reasonCode = dupGroups.length
104
- ? 'duplicateTitlesAcrossPages'
105
- : 'templatedTitlesAcrossPages';
122
+ ? 'duplicateTitlesAcrossPages'
123
+ : 'templatedTitlesAcrossPages';
106
124
 
107
125
  // Deterministic example title for i18n params (lexicographic, case-insensitive)
108
126
  const exampleTitle = dupGroups.length
109
- ? dupGroups
110
- .map(g => String(g.title || '').replace(/\s+/g, ' ').trim())
111
- .filter(Boolean)
112
- .sort((a, b) => a.localeCompare(b, undefined, { sensitivity: 'base' }))[0] || ''
113
- : '';
127
+ ? dupGroups
128
+ .map((g) =>
129
+ String(g.title || '')
130
+ .replace(/\s+/g, ' ')
131
+ .trim()
132
+ )
133
+ .filter(Boolean)
134
+ .sort((a, b) => a.localeCompare(b, undefined, { sensitivity: 'base' }))[0] || ''
135
+ : '';
114
136
 
115
137
  const summaryKey = dupGroups.length
116
- ? 'pageTitlePatterns_summary_cantTell_duplicateAcrossPages'
117
- : 'pageTitlePatterns_summary_cantTell_templatedAcrossPages';
138
+ ? 'pageTitlePatterns_summary_cantTell_duplicateAcrossPages'
139
+ : 'pageTitlePatterns_summary_cantTell_templatedAcrossPages';
118
140
 
119
141
  const i18nParams = dupGroups.length
120
- ? {
142
+ ? {
121
143
  reasonCode,
122
144
  pagesAnalyzed: analyzable.length,
123
145
  duplicateGroups: dupGroups.length,
124
146
  exampleTitle
125
147
  }
126
- : {
148
+ : {
127
149
  reasonCode,
128
150
  pagesAnalyzed: analyzable.length
129
151
  };
@@ -131,7 +153,8 @@ function runInPage(ctx) {
131
153
  const occBase = {
132
154
  selector: 'head > title',
133
155
  html: '',
134
- summary: 'The set of page titles may not be descriptive enough to distinguish pages by topic or purpose.',
156
+ summary:
157
+ 'The set of page titles may not be descriptive enough to distinguish pages by topic or purpose.',
135
158
  hint: 'Ensure each page title is sufficiently descriptive and helps users distinguish pages (avoid identical or overly templated titles across many pages).',
136
159
  i18n: {
137
160
  summaryKey,
@@ -144,12 +167,14 @@ function runInPage(ctx) {
144
167
  metrics: {
145
168
  pagesAnalyzed: analyzable.length,
146
169
  duplicateGroups: dupGroups.length,
147
- largestDuplicateGroupSize: dupGroups.length ? Math.max(...dupGroups.map(g => g.urls.length)) : 0,
170
+ largestDuplicateGroupSize: dupGroups.length
171
+ ? Math.max(...dupGroups.map((g) => g.urls.length))
172
+ : 0,
148
173
  sharedPrefix: prefixLen >= 12 ? sharedPrefix.trim() : '',
149
174
  sharedSuffix: suffixLen >= 12 ? sharedSuffix.trim() : ''
150
175
  },
151
176
  refs: {
152
- exampleDuplicateTitles: dupGroups.slice(0, 3).map(g => ({
177
+ exampleDuplicateTitles: dupGroups.slice(0, 3).map((g) => ({
153
178
  title: g.title,
154
179
  urls: g.urls.slice(0, 5)
155
180
  }))
@@ -165,12 +190,20 @@ function runInPage(ctx) {
165
190
  // No node available: keep deterministic fallback snippet (no helper calls).
166
191
  occurrences.push({
167
192
  ...occBase,
168
- html: titleEl && titleEl.outerHTML ? String(titleEl.outerHTML).slice(0, 2000) : '<title>(unknown)</title>'
193
+ html:
194
+ titleEl && titleEl.outerHTML
195
+ ? String(titleEl.outerHTML).slice(0, 2000)
196
+ : '<title>(unknown)</title>'
169
197
  });
170
198
  }
171
199
 
172
200
  // Cross-page analysis is authoritative when present; do not also run single-page heuristics.
173
- return { ruleId: rule.ruleId, outcome: 'cantTell', severity: rule.defaultSeverity || 'minor', occurrences };
201
+ return {
202
+ ruleId: rule.ruleId,
203
+ outcome: 'cantTell',
204
+ severity: rule.defaultSeverity || 'minor',
205
+ occurrences
206
+ };
174
207
  }
175
208
 
176
209
  // If we had enough pages and found no review signal, there is nothing
@@ -186,14 +219,7 @@ function runInPage(ctx) {
186
219
  return { ruleId: rule.ruleId, outcome: 'notApplicable', severity: 'minor', occurrences: [] };
187
220
  }
188
221
 
189
- const GENERIC_TITLES = new Set([
190
- 'home',
191
- 'homepage',
192
- 'welcome',
193
- 'untitled',
194
- 'page',
195
- 'document'
196
- ]);
222
+ const GENERIC_TITLES = new Set(['home', 'homepage', 'welcome', 'untitled', 'page', 'document']);
197
223
 
198
224
  // Conservative signals:
199
225
  // - very short title (likely non-descriptive)
@@ -202,27 +228,29 @@ function runInPage(ctx) {
202
228
  const isGeneric = GENERIC_TITLES.has(titleLc);
203
229
 
204
230
  // Template-like: "Brand | Home" or "Home - Brand" where the page-specific part is a generic token.
205
- const templateLike = /\b(home|homepage|welcome)\b\s*(\||-|—|:)\s*.+/i.test(titleText) ||
206
- /.+\s*(\||-|—|:)\s*\b(home|homepage|welcome)\b/i.test(titleText);
231
+ const templateLike =
232
+ /\b(home|homepage|welcome)\b\s*(\||-|—|:)\s*.+/i.test(titleText) ||
233
+ /.+\s*(\||-|—|:)\s*\b(home|homepage|welcome)\b/i.test(titleText);
207
234
 
208
235
  if (isGeneric || isVeryShort || templateLike) {
209
236
  const reasonCode = isGeneric
210
237
  ? 'genericTitle'
211
- : (isVeryShort ? 'veryShortTitle' : 'templateLikeTitle');
238
+ : isVeryShort
239
+ ? 'veryShortTitle'
240
+ : 'templateLikeTitle';
212
241
 
213
242
  const summaryKey =
214
- reasonCode === 'genericTitle'
215
- ? 'pageTitlePatterns_summary_cantTell_generic'
216
- : (reasonCode === 'veryShortTitle'
217
- ? 'pageTitlePatterns_summary_cantTell_veryShort'
218
- : 'pageTitlePatterns_summary_cantTell_templateLike');
243
+ reasonCode === 'genericTitle'
244
+ ? 'pageTitlePatterns_summary_cantTell_generic'
245
+ : reasonCode === 'veryShortTitle'
246
+ ? 'pageTitlePatterns_summary_cantTell_veryShort'
247
+ : 'pageTitlePatterns_summary_cantTell_templateLike';
219
248
  const occBase = {
220
249
  selector: 'head > title',
221
250
  html: '',
222
251
  summary:
223
- 'The page title may not be descriptive enough to identify the page topic or purpose.',
224
- hint:
225
- 'Use a more specific title that identifies the page topic or purpose (for example, include the section name or task).',
252
+ 'The page title may not be descriptive enough to identify the page topic or purpose.',
253
+ hint: 'Use a more specific title that identifies the page topic or purpose (for example, include the section name or task).',
226
254
  i18n: {
227
255
  summaryKey,
228
256
  hintKey: 'pageTitlePatterns_hint_cantTell',
@@ -242,7 +270,10 @@ function runInPage(ctx) {
242
270
  } else {
243
271
  occurrences.push({
244
272
  ...occBase,
245
- html: titleEl && titleEl.outerHTML ? String(titleEl.outerHTML).slice(0, 2000) : '<title>(unknown)</title>'
273
+ html:
274
+ titleEl && titleEl.outerHTML
275
+ ? String(titleEl.outerHTML).slice(0, 2000)
276
+ : '<title>(unknown)</title>'
246
277
  });
247
278
  }
248
279
  }
@@ -253,10 +284,15 @@ function runInPage(ctx) {
253
284
 
254
285
  if (occurrences.length) {
255
286
  // Patterns are review signals: cantTell rather than fail.
256
- return { ruleId: rule.ruleId, outcome: 'cantTell', severity: rule.defaultSeverity || 'minor', occurrences };
287
+ return {
288
+ ruleId: rule.ruleId,
289
+ outcome: 'cantTell',
290
+ severity: rule.defaultSeverity || 'minor',
291
+ occurrences
292
+ };
257
293
  }
258
294
 
259
295
  return { ruleId: rule.ruleId, outcome: 'notApplicable', severity: 'minor', occurrences: [] };
260
296
  }
261
297
 
262
- module.exports = { id, meta, runInPage };
298
+ module.exports = { id, meta, runInPage, applicability };
@@ -43,10 +43,14 @@
43
43
  * native implicit role to suppress in the first place (e.g.
44
44
  * `<div role="presentation" aria-hidden="true">` — a <div> has no native
45
45
  * role, so there's nothing for the presentational role to "conflict"
46
- * with per that engine's own scope decision). Replicating that would require a
47
- * full native-implicit-role table and isn't evidenced by any real false
48
- * positive in this corpus yet — surea11y stays broader/more cautious
49
- * here rather than narrower, which is the safer direction to diverge in.
46
+ * with per that engine's own scope decision). surea11y stays
47
+ * broader/more cautious here rather than narrower, which is the safer
48
+ * direction to diverge in, and no real false positive from staying
49
+ * broad has surfaced in any corpus round to date.
50
+ * - The native-implicit-role table needed to replicate that gate (if this
51
+ * scope decision is ever revisited) has since been produced — see
52
+ * ROADMAP.md §7 item 9 (2026-07-31) for the full table and the open
53
+ * maintainer decision; not implemented here pending that call.
50
54
  * - Focusability is computed via helpers.getFocusableInfo (native +
51
55
  * tabindex), same helper aria-hidden-focus already relies on — a
52
56
  * `:disabled` or otherwise non-focusable element is not flagged.
@@ -56,7 +60,8 @@ const id = 'presentation-role-conflict';
56
60
 
57
61
  const meta = {
58
62
  title: 'Presentational role must not conflict with a global ARIA attribute or focusability',
59
- description: 'Checks that role="presentation"/"none" (including an <img alt=""> implicit presentation role) is not combined with a global ARIA attribute (aria-label, aria-hidden, aria-describedby, ...) or focusability (tabindex/native).',
63
+ description:
64
+ 'Checks that role="presentation"/"none" (including an <img alt=""> implicit presentation role) is not combined with a global ARIA attribute (aria-label, aria-hidden, aria-describedby, ...) or focusability (tabindex/native).',
60
65
  i18n: {
61
66
  titleKey: 'presentationRoleConflict_title',
62
67
  descriptionKey: 'presentationRoleConflict_description'
@@ -73,25 +78,45 @@ const meta = {
73
78
  };
74
79
 
75
80
  function runInPage(ctx) {
76
- const { document, helpers, rule } = ctx;
81
+ const { helpers, rule } = ctx;
77
82
 
78
83
  // The full set of ARIA attributes marked `global: true` per the WAI-ARIA
79
84
  // spec (confirmed against a widely-used reference engine's own
80
85
  // `standards.ariaAttrs` data at runtime, 2026-07-20) — any of these present on a presentational
81
86
  // element restores its implicit role, not just the naming ones.
82
87
  const CONFLICTING_ATTRS = [
83
- 'aria-atomic', 'aria-braillelabel', 'aria-brailleroledescription', 'aria-busy',
84
- 'aria-controls', 'aria-current', 'aria-describedby', 'aria-description', 'aria-details',
85
- 'aria-disabled', 'aria-dropeffect', 'aria-errormessage', 'aria-flowto', 'aria-grabbed',
86
- 'aria-haspopup', 'aria-hidden', 'aria-invalid', 'aria-keyshortcuts', 'aria-label',
87
- 'aria-labelledby', 'aria-live', 'aria-owns', 'aria-relevant', 'aria-roledescription'
88
+ 'aria-atomic',
89
+ 'aria-braillelabel',
90
+ 'aria-brailleroledescription',
91
+ 'aria-busy',
92
+ 'aria-controls',
93
+ 'aria-current',
94
+ 'aria-describedby',
95
+ 'aria-description',
96
+ 'aria-details',
97
+ 'aria-disabled',
98
+ 'aria-dropeffect',
99
+ 'aria-errormessage',
100
+ 'aria-flowto',
101
+ 'aria-grabbed',
102
+ 'aria-haspopup',
103
+ 'aria-hidden',
104
+ 'aria-invalid',
105
+ 'aria-keyshortcuts',
106
+ 'aria-label',
107
+ 'aria-labelledby',
108
+ 'aria-live',
109
+ 'aria-owns',
110
+ 'aria-relevant',
111
+ 'aria-roledescription'
88
112
  ];
89
113
 
90
- const getFocusableInfo = helpers && typeof helpers.getFocusableInfo === 'function' ? helpers.getFocusableInfo : null;
114
+ const getFocusableInfo =
115
+ helpers && typeof helpers.getFocusableInfo === 'function' ? helpers.getFocusableInfo : null;
91
116
 
92
117
  const nodes = helpers.queryAllSmart
93
- ? helpers.queryAllSmart('[role="presentation"], [role="none"], img[alt=""]')
94
- : helpers.queryAll('[role="presentation"], [role="none"], img[alt=""]');
118
+ ? helpers.queryAllSmart('[role="presentation"], [role="none"], img[alt=""]')
119
+ : helpers.queryAll('[role="presentation"], [role="none"], img[alt=""]');
95
120
 
96
121
  const occurrences = [];
97
122
  let applicableCount = 0;
@@ -107,7 +132,9 @@ function runInPage(ctx) {
107
132
  // <img alt="" aria-hidden="">, where aria-hidden="" (empty string) is
108
133
  // still a specified attribute. A truthy-value check would have missed
109
134
  // this even after aria-hidden was added to CONFLICTING_ATTRS above.
110
- const present = CONFLICTING_ATTRS.filter((attr) => (el.hasAttribute ? el.hasAttribute(attr) : el.getAttribute(attr) != null));
135
+ const present = CONFLICTING_ATTRS.filter((attr) =>
136
+ el.hasAttribute ? el.hasAttribute(attr) : el.getAttribute(attr) != null
137
+ );
111
138
 
112
139
  let isFocusable = false;
113
140
  if (getFocusableInfo) {
@@ -126,9 +153,12 @@ function runInPage(ctx) {
126
153
 
127
154
  // No explicit role attribute means this matched via the img[alt=""]
128
155
  // implicit-presentation case.
129
- const role = String(el.getAttribute('role') || '').trim().toLowerCase() || 'presentation';
156
+ const role =
157
+ String(el.getAttribute('role') || '')
158
+ .trim()
159
+ .toLowerCase() || 'presentation';
130
160
  const stableSelector = helpers.buildSelector ? helpers.buildSelector(el) : 'html';
131
- const html = helpers.getOuterHtmlSnippet ? helpers.getOuterHtmlSnippet(el) : (el.outerHTML || '');
161
+ const html = helpers.getOuterHtmlSnippet ? helpers.getOuterHtmlSnippet(el) : el.outerHTML || '';
132
162
 
133
163
  occurrences.push({
134
164
  selector: stableSelector,
@@ -141,7 +171,12 @@ function runInPage(ctx) {
141
171
  params: { role, attrs: parts.join(', ') }
142
172
  },
143
173
  data: {
144
- details: { reasonCode: 'PRESENTATION_ROLE_CONFLICT', role, conflictingAttrs: present, focusable: isFocusable }
174
+ details: {
175
+ reasonCode: 'PRESENTATION_ROLE_CONFLICT',
176
+ role,
177
+ conflictingAttrs: present,
178
+ focusable: isFocusable
179
+ }
145
180
  }
146
181
  });
147
182
  }
@@ -150,7 +185,12 @@ function runInPage(ctx) {
150
185
  return { ruleId: rule.ruleId, outcome: 'notApplicable', severity: 'minor', occurrences: [] };
151
186
  }
152
187
  if (occurrences.length) {
153
- return { ruleId: rule.ruleId, outcome: 'cantTell', severity: rule.defaultSeverity || 'minor', occurrences };
188
+ return {
189
+ ruleId: rule.ruleId,
190
+ outcome: 'cantTell',
191
+ severity: rule.defaultSeverity || 'minor',
192
+ occurrences
193
+ };
154
194
  }
155
195
  return { ruleId: rule.ruleId, outcome: 'notApplicable', severity: 'minor', occurrences: [] };
156
196
  }