@surea11y/core 1.0.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 (164) hide show
  1. package/CHANGELOG.md +49 -0
  2. package/LICENSE +21 -0
  3. package/README.md +145 -0
  4. package/bin/core.js +244 -0
  5. package/docs/BINDING_AUTHORS_GUIDE.md +41 -0
  6. package/docs/CLI.md +49 -0
  7. package/docs/ENGINE_OPTIONS.md +155 -0
  8. package/docs/I18N.md +47 -0
  9. package/docs/INTEGRATION.md +156 -0
  10. package/docs/LIMITATIONS.md +31 -0
  11. package/docs/OUTPUT_SCHEMA.md +237 -0
  12. package/docs/POLICY.md +71 -0
  13. package/docs/RULE_AUTHORING.md +375 -0
  14. package/docs/RULE_CATALOG.md +180 -0
  15. package/docs/RULE_TAXONOMY.md +145 -0
  16. package/docs/TROUBLESHOOTING.md +48 -0
  17. package/docs/WCAG_CONFORMANCE.md +49 -0
  18. package/package.json +60 -0
  19. package/src/catalogs/composites.wcag.js +490 -0
  20. package/src/checks/automatic/area-alt-present.js +225 -0
  21. package/src/checks/automatic/aria-allowed-attr.js +206 -0
  22. package/src/checks/automatic/aria-allowed-role.js +102 -0
  23. package/src/checks/automatic/aria-braille-equivalent.js +139 -0
  24. package/src/checks/automatic/aria-conditional-attr.js +110 -0
  25. package/src/checks/automatic/aria-deprecated-role.js +106 -0
  26. package/src/checks/automatic/aria-hidden-body.js +87 -0
  27. package/src/checks/automatic/aria-hidden-focus.js +480 -0
  28. package/src/checks/automatic/aria-prohibited-attr.js +156 -0
  29. package/src/checks/automatic/aria-prohibited-children.js +265 -0
  30. package/src/checks/automatic/aria-required-attr.js +154 -0
  31. package/src/checks/automatic/aria-required-children.js +274 -0
  32. package/src/checks/automatic/aria-required-parent.js +222 -0
  33. package/src/checks/automatic/aria-role-name-present.js +201 -0
  34. package/src/checks/automatic/aria-roles-valid.js +110 -0
  35. package/src/checks/automatic/aria-valid-attr-value.js +123 -0
  36. package/src/checks/automatic/aria-valid-attr.js +109 -0
  37. package/src/checks/automatic/autocomplete-valid.js +134 -0
  38. package/src/checks/automatic/avoid-inline-spacing.js +107 -0
  39. package/src/checks/automatic/binary-control-name-present.js +294 -0
  40. package/src/checks/automatic/button-name-present.js +146 -0
  41. package/src/checks/automatic/bypass-blocks-present.js +162 -0
  42. package/src/checks/automatic/canvas-text-alternative-present.js +140 -0
  43. package/src/checks/automatic/combobox-name-present.js +267 -0
  44. package/src/checks/automatic/contrast-computable.js +378 -0
  45. package/src/checks/automatic/contrast-enhanced.js +517 -0
  46. package/src/checks/automatic/contrast-minimum.js +512 -0
  47. package/src/checks/automatic/css-orientation-lock.js +206 -0
  48. package/src/checks/automatic/definition-list-children-valid.js +148 -0
  49. package/src/checks/automatic/deprecated-elements-not-used.js +91 -0
  50. package/src/checks/automatic/dialog-name-present.js +209 -0
  51. package/src/checks/automatic/dlitem-parent-valid.js +100 -0
  52. package/src/checks/automatic/duplicate-id-aria.js +126 -0
  53. package/src/checks/automatic/embed-text-alternative-present.js +190 -0
  54. package/src/checks/automatic/form-control-programmatic-label-present.js +409 -0
  55. package/src/checks/automatic/form-control-single-label.js +117 -0
  56. package/src/checks/automatic/html-xml-lang-mismatch.js +91 -0
  57. package/src/checks/automatic/iframe-focusable-content.js +141 -0
  58. package/src/checks/automatic/iframe-name-present.js +102 -0
  59. package/src/checks/automatic/iframe-title-unique.js +107 -0
  60. package/src/checks/automatic/img-alt-present.js +223 -0
  61. package/src/checks/automatic/input-image-alt-present.js +155 -0
  62. package/src/checks/automatic/label-in-name.js +326 -0
  63. package/src/checks/automatic/language-page-present.js +159 -0
  64. package/src/checks/automatic/link-in-text-block.js +218 -0
  65. package/src/checks/automatic/link-name-present.js +114 -0
  66. package/src/checks/automatic/list-children-valid.js +152 -0
  67. package/src/checks/automatic/listbox-name-present.js +236 -0
  68. package/src/checks/automatic/listitem-parent-valid.js +118 -0
  69. package/src/checks/automatic/menuitem-name-present.js +201 -0
  70. package/src/checks/automatic/meta-refresh-no-exceptions.js +105 -0
  71. package/src/checks/automatic/meta-refresh-timing-absent.js +107 -0
  72. package/src/checks/automatic/meta-viewport-zoom-enabled.js +118 -0
  73. package/src/checks/automatic/meter-name-present.js +160 -0
  74. package/src/checks/automatic/nested-interactive-controls-absent.js +135 -0
  75. package/src/checks/automatic/object-text-alternative-present.js +193 -0
  76. package/src/checks/automatic/option-name-present.js +157 -0
  77. package/src/checks/automatic/page-title-present.js +86 -0
  78. package/src/checks/automatic/progressbar-name-present.js +165 -0
  79. package/src/checks/automatic/role-img-alt-present.js +206 -0
  80. package/src/checks/automatic/searchbox-name-present.js +236 -0
  81. package/src/checks/automatic/server-side-image-map-absent.js +88 -0
  82. package/src/checks/automatic/slider-name-present.js +276 -0
  83. package/src/checks/automatic/spinbutton-name-present.js +236 -0
  84. package/src/checks/automatic/summary-name-present.js +153 -0
  85. package/src/checks/automatic/svg-image-text-alternative-present.js +220 -0
  86. package/src/checks/automatic/svg-text-alternative-present.js +298 -0
  87. package/src/checks/automatic/tab-name-present.js +200 -0
  88. package/src/checks/automatic/table-headers-attr-valid.js +122 -0
  89. package/src/checks/automatic/table-th-has-data-cells.js +117 -0
  90. package/src/checks/automatic/target-size-minimum.js +605 -0
  91. package/src/checks/automatic/td-has-header.js +151 -0
  92. package/src/checks/automatic/textbox-name-present.js +236 -0
  93. package/src/checks/automatic/tooltip-name-present.js +158 -0
  94. package/src/checks/automatic/treeitem-name-present.js +157 -0
  95. package/src/checks/automatic/valid-lang.js +100 -0
  96. package/src/checks/automatic/video-poster-text-alternative-present.js +193 -0
  97. package/src/checks/manual/accesskeys-manual.js +93 -0
  98. package/src/checks/manual/area-alt-decorative-manual.js +247 -0
  99. package/src/checks/manual/area-alt-quality-manual.js +204 -0
  100. package/src/checks/manual/aria-checked-state-mismatch-manual.js +141 -0
  101. package/src/checks/manual/aria-text-manual.js +109 -0
  102. package/src/checks/manual/canvas-text-alternative-quality-manual.js +170 -0
  103. package/src/checks/manual/css-hidden-focus.js +259 -0
  104. package/src/checks/manual/embed-text-alternative-quality-manual.js +204 -0
  105. package/src/checks/manual/empty-heading-manual.js +182 -0
  106. package/src/checks/manual/empty-table-header-manual.js +163 -0
  107. package/src/checks/manual/focus-order-semantics-manual.js +117 -0
  108. package/src/checks/manual/form-control-programmatic-label-quality-manual.js +291 -0
  109. package/src/checks/manual/heading-order-manual.js +130 -0
  110. package/src/checks/manual/identical-links-same-purpose-manual.js +142 -0
  111. package/src/checks/manual/image-redundant-alt-manual.js +118 -0
  112. package/src/checks/manual/img-alt-decorative-manual.js +148 -0
  113. package/src/checks/manual/img-alt-quality-manual.js +182 -0
  114. package/src/checks/manual/input-image-alt-decorative-manual.js +144 -0
  115. package/src/checks/manual/input-image-alt-quality-manual.js +144 -0
  116. package/src/checks/manual/label-title-only-manual.js +115 -0
  117. package/src/checks/manual/landmark-banner-is-top-level-manual.js +180 -0
  118. package/src/checks/manual/landmark-contentinfo-is-top-level-manual.js +169 -0
  119. package/src/checks/manual/landmark-main-is-top-level-manual.js +167 -0
  120. package/src/checks/manual/landmark-no-duplicate-banner-manual.js +177 -0
  121. package/src/checks/manual/landmark-no-duplicate-contentinfo-manual.js +169 -0
  122. package/src/checks/manual/landmark-no-duplicate-main-manual.js +132 -0
  123. package/src/checks/manual/landmark-one-main-manual.js +151 -0
  124. package/src/checks/manual/landmark-unique-manual.js +252 -0
  125. package/src/checks/manual/link-name-quality-manual.js +143 -0
  126. package/src/checks/manual/media-transcript-present-manual.js +373 -0
  127. package/src/checks/manual/meta-viewport-large-manual.js +119 -0
  128. package/src/checks/manual/mouse-only-event-handlers-manual.js +134 -0
  129. package/src/checks/manual/no-autoplay-audio-manual.js +116 -0
  130. package/src/checks/manual/object-text-alternative-quality-manual.js +194 -0
  131. package/src/checks/manual/p-as-heading-manual.js +163 -0
  132. package/src/checks/manual/page-has-heading-one-manual.js +110 -0
  133. package/src/checks/manual/page-title-patterns-manual.js +262 -0
  134. package/src/checks/manual/presentation-role-conflict-manual.js +159 -0
  135. package/src/checks/manual/region-manual.js +183 -0
  136. package/src/checks/manual/scope-attr-valid-manual.js +93 -0
  137. package/src/checks/manual/scrollable-region-focusable-manual.js +168 -0
  138. package/src/checks/manual/skip-link-manual.js +150 -0
  139. package/src/checks/manual/svg-text-alternative-quality-manual.js +209 -0
  140. package/src/checks/manual/tabindex-manual.js +94 -0
  141. package/src/checks/manual/table-duplicate-name-manual.js +99 -0
  142. package/src/checks/manual/table-fake-caption-manual.js +122 -0
  143. package/src/checks/manual/video-caption-manual.js +118 -0
  144. package/src/checks/manual-review.js +95 -0
  145. package/src/checks/rules-and-tags.full.csv +19 -0
  146. package/src/checks/rules-and-tags.full.json +259 -0
  147. package/src/core/aria-helpers.js +906 -0
  148. package/src/core/contrast-helpers.js +1147 -0
  149. package/src/core/dom-helpers.js +4085 -0
  150. package/src/core/dom-runner.js +627 -0
  151. package/src/core/frame-messaging.js +210 -0
  152. package/src/core/frame-scan.js +178 -0
  153. package/src/core/rollup-composites.js +135 -0
  154. package/src/core/rule-meta.js +140 -0
  155. package/src/core.js +79055 -0
  156. package/src/coverage/wcag-facets.js +1079 -0
  157. package/src/coverage/wcag-version-map.js +84 -0
  158. package/src/i18n/en.js +919 -0
  159. package/src/i18n/fr.js +527 -0
  160. package/src/index.js +4 -0
  161. package/src/policy/contracts.js +18 -0
  162. package/src/policy/resolvePolicy.js +55 -0
  163. package/src/policy/schemas/engine-options.schema.json +103 -0
  164. package/src/policy/schemas/policy-contract.schema.json +40 -0
@@ -0,0 +1,291 @@
1
+ 'use strict';
2
+
3
+ /**
4
+ * @check form-control-programmatic-label-quality
5
+ * @atomic true
6
+ * @summary Form controls should not rely on placeholder or title as the primary label
7
+ * @standard WCAG 2.2
8
+ * @sc 4.1.2
9
+ * @applicability
10
+ * Applies to labelable native form controls exposed to assistive technologies:
11
+ * - input (excluding type=hidden|submit|reset|button|image)
12
+ * - select
13
+ * - textarea
14
+ * role="presentation"/"none" are excluded only when not focusable.
15
+ * @expectation
16
+ * If a control has a programmatic name, it should not rely ONLY on:
17
+ * - placeholder (non-empty)
18
+ * - title (non-empty)
19
+ * Prefer an associated <label> or aria-labelledby.
20
+ * @note
21
+ * This is a quality/best-practice signal. Controls may still meet SC 4.1.2
22
+ * while relying on placeholder/title; this rule surfaces that risk as cantTell.
23
+ */
24
+
25
+ const id = 'form-control-programmatic-label-quality';
26
+
27
+ const meta = {
28
+ title: 'Form controls should not rely on placeholder or title as the primary label',
29
+ description:
30
+ 'Flags form controls whose computed accessible name relies on placeholder or title as the primary labeling method. Prefer <label> or aria-labelledby.',
31
+ i18n: {
32
+ titleKey: 'formControl_programmaticLabelQuality_title',
33
+ descriptionKey: 'formControl_programmaticLabelQuality_description'
34
+ },
35
+ helpUrl: null,
36
+ tags: ['wcag2a', 'wcag412', 'forms', 'labels', 'quality', 'atomic', 'manual'],
37
+ wcagSc: ['4.1.2'],
38
+ normativeMappings: [
39
+ { standard: 'WCAG', version: '2.2', requirement: '4.1.2', title: 'Name, Role, Value', conformanceLevel: 'A' }
40
+ ],
41
+ defaultSeverity: 'moderate',
42
+ category: 'robust',
43
+ type: 'manual',
44
+ defaultConfidence: 'medium',
45
+ coverage: {
46
+ facetsBySc: {
47
+ '4.1.2': ['form-control-name-quality']
48
+ }
49
+ }
50
+ };
51
+
52
+ function runInPage(ctx) {
53
+ const { document, root, helpers, rule } = ctx;
54
+ const safeRoot = root || document;
55
+
56
+ const queryAllSmart = helpers && typeof helpers.queryAllSmart === 'function' ? helpers.queryAllSmart : null;
57
+
58
+ const isAccTreeEligible = helpers && typeof helpers.isAccTreeEligible === 'function' ? helpers.isAccTreeEligible : null;
59
+ const getEligibilityInfo = helpers && typeof helpers.getEligibilityInfo === 'function' ? helpers.getEligibilityInfo : null;
60
+
61
+ const getFocusableInfo = helpers && typeof helpers.getFocusableInfo === 'function' ? helpers.getFocusableInfo : null;
62
+ const getAriaLabelInfo = helpers && typeof helpers.getAriaLabelInfo === 'function' ? helpers.getAriaLabelInfo : null;
63
+ const getAriaLabelledByInfo = helpers && typeof helpers.getAriaLabelledByInfo === 'function' ? helpers.getAriaLabelledByInfo : null;
64
+
65
+ const getAttributeInfo = helpers && typeof helpers.getAttributeInfo === 'function' ? helpers.getAttributeInfo : null;
66
+
67
+ // Optional helper; we validate/normalize if present.
68
+ const getLabelMethod = helpers && typeof helpers.getLabelMethod === 'function' ? helpers.getLabelMethod : null;
69
+
70
+ const trim = (v) => (v == null ? '' : String(v)).trim();
71
+
72
+ const metrics = {
73
+ applicableCount: 0,
74
+ flaggedCount: 0,
75
+ byMethod: { label: 0, 'aria-labelledby': 0, 'aria-label': 0, title: 0, placeholder: 0, none: 0 }
76
+ };
77
+
78
+ function safeQueryAll(sel) {
79
+ try {
80
+ if (queryAllSmart) return Array.from(queryAllSmart(sel) || []);
81
+ return safeRoot && safeRoot.querySelectorAll ? Array.from(safeRoot.querySelectorAll(sel)) : [];
82
+ } catch {
83
+ return [];
84
+ }
85
+ }
86
+
87
+ function isEligibleAcc(el) {
88
+ if (!isAccTreeEligible) return true;
89
+ try {
90
+ const r = isAccTreeEligible(el, ctx);
91
+ if (typeof r === 'boolean') return r;
92
+ return !!(r && r.eligible);
93
+ } catch {
94
+ return true;
95
+ }
96
+ }
97
+
98
+ function isFocusable(el) {
99
+ if (!getFocusableInfo) return false;
100
+ try {
101
+ const fi = getFocusableInfo(el, ctx);
102
+ return !!(fi && fi.focusable);
103
+ } catch {
104
+ return false;
105
+ }
106
+ }
107
+
108
+ function getNonEmptyAttr(el, name) {
109
+ if (!getAttributeInfo) return '';
110
+ try {
111
+ const info = getAttributeInfo(el, name);
112
+ return info && info.present ? trim(info.value) : '';
113
+ } catch {
114
+ return '';
115
+ }
116
+ }
117
+
118
+ function hasLabelAssociation(el) {
119
+ // 1) Native labels API
120
+ try {
121
+ if (el && 'labels' in el && el.labels && el.labels.length) return true;
122
+ } catch {}
123
+
124
+ // 2) Wrapped by <label>
125
+ try {
126
+ if (el && el.closest) {
127
+ const wrap = el.closest('label');
128
+ if (wrap) return true;
129
+ }
130
+ } catch {}
131
+
132
+ // 3) <label for="id">
133
+ try {
134
+ const idAttr = el && el.getAttribute ? trim(el.getAttribute('id')) : '';
135
+ if (!idAttr || !document || !document.querySelector) return false;
136
+
137
+ const esc = idAttr.replace(/\\/g, '\\\\').replace(/"/g, '\\"');
138
+ const sel = `label[for="${esc}"]`;
139
+ return !!document.querySelector(sel);
140
+ } catch {
141
+ return false;
142
+ }
143
+ }
144
+
145
+ function computeLabelMethodFallback(el) {
146
+ // Deterministic priority order:
147
+ // label > aria-labelledby > aria-label > title > placeholder
148
+ if (hasLabelAssociation(el)) return { method: 'label', value: '' };
149
+
150
+ if (getAriaLabelledByInfo) {
151
+ try {
152
+ const info = getAriaLabelledByInfo(el, ctx, { maxRefs: 8 });
153
+ const v = info && info.present ? trim(info.value) : '';
154
+ if (v) return { method: 'aria-labelledby', value: v };
155
+ } catch {}
156
+ }
157
+
158
+ if (getAriaLabelInfo) {
159
+ try {
160
+ const info = getAriaLabelInfo(el, ctx);
161
+ const v = info && info.present ? trim(info.value) : '';
162
+ if (v) return { method: 'aria-label', value: v };
163
+ } catch {}
164
+ }
165
+
166
+ const titleV = getNonEmptyAttr(el, 'title');
167
+ if (titleV) return { method: 'title', value: titleV };
168
+
169
+ const phV = getNonEmptyAttr(el, 'placeholder');
170
+ if (phV) return { method: 'placeholder', value: phV };
171
+
172
+ return { method: 'none', value: '' };
173
+ }
174
+
175
+ function getLabelMethodSafe(el) {
176
+ if (getLabelMethod) {
177
+ try {
178
+ const r = getLabelMethod(el, ctx);
179
+ const m = r && typeof r.method === 'string' ? r.method : 'none';
180
+ const v = r && r.value != null ? trim(r.value) : '';
181
+ if (!Object.prototype.hasOwnProperty.call(metrics.byMethod, m)) return { method: 'none', value: '' };
182
+ return { method: m, value: v };
183
+ } catch {
184
+ // fall through
185
+ }
186
+ }
187
+ return computeLabelMethodFallback(el);
188
+ }
189
+
190
+ // Native controls only (same as your current rule)
191
+ const selector =
192
+ 'input:not([type="hidden"]):not([type="submit"]):not([type="reset"]):not([type="button"]):not([type="image"]),select,textarea';
193
+
194
+ const nodes = safeQueryAll(selector);
195
+
196
+ if (!nodes.length) {
197
+ return { ruleId: rule.ruleId, outcome: 'notApplicable', severity: 'minor', occurrences: [], data: { details: { metrics } } };
198
+ }
199
+
200
+ const occurrences = [];
201
+
202
+ for (const el of nodes) {
203
+ if (!el || !el.getAttribute) continue;
204
+
205
+ if (!isEligibleAcc(el)) continue;
206
+
207
+ const role = (() => {
208
+ try {
209
+ return trim(el.getAttribute('role')).toLowerCase();
210
+ } catch {
211
+ return '';
212
+ }
213
+ })();
214
+
215
+ let fi = null;
216
+ if (getFocusableInfo) {
217
+ try { fi = getFocusableInfo(el, ctx); } catch { fi = null; }
218
+ }
219
+ const tabbable = !!(fi && fi.tabbable);
220
+
221
+ if ((role === 'presentation' || role === 'none') && !tabbable) continue;
222
+
223
+ metrics.applicableCount += 1;
224
+
225
+ const label = getLabelMethodSafe(el);
226
+ const method = label && typeof label.method === 'string' ? label.method : 'none';
227
+ if (Object.prototype.hasOwnProperty.call(metrics.byMethod, method)) metrics.byMethod[method] += 1;
228
+ else metrics.byMethod.none += 1;
229
+
230
+ // Flag only when the *primary* (best) method is title/placeholder
231
+ const isWeakPrimary = method === 'title' || method === 'placeholder';
232
+ if (!isWeakPrimary) continue;
233
+
234
+ metrics.flaggedCount += 1;
235
+
236
+ const vf = getEligibilityInfo ? getEligibilityInfo(el, ctx, { targetSet: 'acc' }) : null;
237
+
238
+ const reasonCode = method === 'title' ? 'label_from_title_primary' : 'label_from_placeholder_primary';
239
+ const methodLabel =
240
+ method === 'title' ? 'title'
241
+ : method === 'placeholder' ? 'placeholder'
242
+ : 'title or placeholder';
243
+
244
+ const baseOccurrence = {
245
+ summary: 'Form control’s primary label is derived from title or placeholder.',
246
+ hint: 'Prefer a persistent <label> or aria-labelledby. Avoid relying on placeholder/title as the primary label.',
247
+ i18n: {
248
+ summaryKey: 'formControl_programmaticLabelQuality_summary_cantTell',
249
+ hintKey: 'formControl_programmaticLabelQuality_hint_cantTell',
250
+ params: { element: (el.tagName || '').toLowerCase(), method, methodLabel }
251
+ },
252
+ data: {
253
+ visibilityFilter: vf || { targetSet: 'acc', accEligible: null, reasons: [] },
254
+ details: {
255
+ reasonCode,
256
+ labelMethod: method,
257
+ labelStrength: 'weak',
258
+ recommendedMethods: ['label', 'aria-labelledby'],
259
+ sourceText: (label && label.value ? String(label.value).slice(0, 120) : '')
260
+ }
261
+ }
262
+ };
263
+
264
+ if (helpers && typeof helpers.reportOccurrence === 'function') {
265
+ occurrences.push(helpers.reportOccurrence(el, baseOccurrence));
266
+ } else {
267
+ occurrences.push({ selector: '', html: '', ...baseOccurrence });
268
+ }
269
+ }
270
+
271
+ if (metrics.applicableCount === 0) {
272
+ return { ruleId: rule.ruleId, outcome: 'notApplicable', severity: 'minor', occurrences: [], data: { details: { metrics } } };
273
+ }
274
+
275
+ if (occurrences.length) {
276
+ return {
277
+ ruleId: rule.ruleId,
278
+ outcome: 'cantTell',
279
+ severity: rule.defaultSeverity || 'minor',
280
+ occurrences,
281
+ data: { details: { metrics } }
282
+ };
283
+ }
284
+
285
+ // Manual rules may only emit cantTell/notApplicable (never pass/fail) —
286
+ // no applicable control relied on a weak (title/placeholder) primary
287
+ // label, so there is nothing to flag for review.
288
+ return { ruleId: rule.ruleId, outcome: 'notApplicable', severity: 'minor', occurrences: [], data: { details: { metrics } } };
289
+ }
290
+
291
+ module.exports = { id, meta, runInPage };
@@ -0,0 +1,130 @@
1
+ 'use strict';
2
+
3
+ /**
4
+ * @check heading-order
5
+ * @atomic true
6
+ * @summary Heading levels must not skip a level going deeper
7
+ * @standard Best Practices (a widely-used reference engine's classification; no formal WCAG Success Criterion — see ROADMAP.md Tier 1b)
8
+ * @applicability
9
+ * Applies whenever the page contains two or more heading elements
10
+ * (native <h1>-<h6>, or explicit role="heading" with aria-level —
11
+ * default level 2 per the ARIA spec when aria-level is absent/invalid).
12
+ * @expectation
13
+ * In document order, each heading's level is no more than one greater
14
+ * than the highest heading level seen so far. Jumping deeper by more
15
+ * than one level (e.g. an <h1> followed directly by an <h3>, skipping
16
+ * <h2>) breaks the document outline assistive technology users rely on
17
+ * when navigating by heading. Going back to a shallower level at any
18
+ * point is always fine.
19
+ * @implementation-notes
20
+ * - Not WCAG-normative — authored as an advisory, cantTell-capped
21
+ * `type: 'manual'` rule; see landmark-banner-is-top-level's
22
+ * header comment for the shared rationale/precedent.
23
+ * - Tracks the highest level reached so far (not just the immediately
24
+ * previous heading's level), so a sequence like h1, h2, h3, h2, h4 is
25
+ * correctly treated as valid (the h4 follows an h3 having already been
26
+ * reached, even though the immediately preceding heading was h2).
27
+ */
28
+
29
+ const id = 'heading-order';
30
+
31
+ const meta = {
32
+ title: 'Heading levels must not skip a level',
33
+ description: 'Checks that heading levels increase by at most one at a time in document order.',
34
+ i18n: {
35
+ titleKey: 'headingOrder_title',
36
+ descriptionKey: 'headingOrder_description'
37
+ },
38
+ helpUrl: null,
39
+ tags: ['best-practice', 'headings', 'structure', 'atomic', 'manual'],
40
+ wcagSc: [],
41
+ normativeMappings: [],
42
+ defaultSeverity: 'minor',
43
+ category: 'operable',
44
+ type: 'manual',
45
+ defaultConfidence: 'medium',
46
+ coverage: {}
47
+ };
48
+
49
+ function runInPage(ctx) {
50
+ const { document, helpers, rule } = ctx;
51
+
52
+ function normalizeWs(s) {
53
+ return String(s || '').replace(/\s+/g, ' ').trim();
54
+ }
55
+
56
+ function getExplicitRoleToken(el) {
57
+ const raw = normalizeWs(el.getAttribute && el.getAttribute('role'));
58
+ if (!raw) return '';
59
+ return raw.split(/\s+/)[0].toLowerCase();
60
+ }
61
+
62
+ function getHeadingLevel(el) {
63
+ const explicit = getExplicitRoleToken(el);
64
+ if (explicit) {
65
+ if (explicit !== 'heading') return 0;
66
+ const raw = normalizeWs(el.getAttribute && el.getAttribute('aria-level'));
67
+ const n = parseInt(raw, 10);
68
+ return (Number.isFinite(n) && n >= 1) ? n : 2;
69
+ }
70
+ const tag = el.tagName ? el.tagName.toLowerCase() : '';
71
+ const m = /^h([1-6])$/.exec(tag);
72
+ return m ? parseInt(m[1], 10) : 0;
73
+ }
74
+
75
+ let nodes = [];
76
+ try {
77
+ nodes = document.querySelectorAll('h1, h2, h3, h4, h5, h6, [role]');
78
+ } catch {
79
+ nodes = [];
80
+ }
81
+
82
+ const headings = [];
83
+ const seen = new Set();
84
+ for (const el of nodes) {
85
+ if (!el || seen.has(el)) continue;
86
+ seen.add(el);
87
+ const level = getHeadingLevel(el);
88
+ if (level > 0) headings.push({ el, level });
89
+ }
90
+
91
+ if (headings.length < 2) {
92
+ return { ruleId: rule.ruleId, outcome: 'notApplicable', severity: 'minor', occurrences: [] };
93
+ }
94
+
95
+ const occurrences = [];
96
+ let highestSoFar = headings[0].level;
97
+
98
+ for (let i = 1; i < headings.length; i += 1) {
99
+ const { el, level } = headings[i];
100
+
101
+ if (level > highestSoFar + 1) {
102
+ const stableSelector = helpers.buildSelector ? helpers.buildSelector(el) : 'html';
103
+ const html = helpers.getOuterHtmlSnippet ? helpers.getOuterHtmlSnippet(el) : (el.outerHTML || '');
104
+
105
+ occurrences.push({
106
+ selector: stableSelector,
107
+ html,
108
+ summary: `This heading jumps from level ${highestSoFar} to level ${level}, skipping a level.`,
109
+ hint: 'Use consecutive heading levels (do not skip a level when going deeper) so the document outline stays predictable.',
110
+ i18n: {
111
+ summaryKey: 'headingOrder_summary_cantTell',
112
+ hintKey: 'headingOrder_hint_cantTell',
113
+ params: { fromLevel: String(highestSoFar), toLevel: String(level) }
114
+ },
115
+ data: {
116
+ details: { reasonCode: 'HEADING_ORDER_SKIPPED_LEVEL', fromLevel: highestSoFar, toLevel: level }
117
+ }
118
+ });
119
+ }
120
+
121
+ if (level > highestSoFar) highestSoFar = level;
122
+ }
123
+
124
+ if (occurrences.length) {
125
+ return { ruleId: rule.ruleId, outcome: 'cantTell', severity: rule.defaultSeverity || 'minor', occurrences };
126
+ }
127
+ return { ruleId: rule.ruleId, outcome: 'notApplicable', severity: 'minor', occurrences: [] };
128
+ }
129
+
130
+ module.exports = { id, meta, runInPage };
@@ -0,0 +1,142 @@
1
+ 'use strict';
2
+
3
+ /**
4
+ * @check identical-links-same-purpose
5
+ * @atomic true
6
+ * @summary Links with the same accessible name should lead to the same destination
7
+ * @standard WCAG 2.2
8
+ * @sc 2.4.9
9
+ * @applicability
10
+ * Any `a[href]` with a non-empty accessible name, grouped by that name
11
+ * (trimmed, whitespace-collapsed, case-folded).
12
+ * @expectation
13
+ * Within a page, links that share the same accessible name are expected
14
+ * to serve the same purpose (i.e. resolve to the same destination — the
15
+ * full resolved URL, including any fragment). Same-text-different-
16
+ * destination links are common and frequently intentional in real
17
+ * sites (e.g. repeated "Read more" links per article card), so this is
18
+ * authored as `type: 'manual'` (cantTell-capped, never fail) rather
19
+ * than a hard fail — flagging a real name/destination mismatch for
20
+ * human judgment instead of guessing intent.
21
+ * @implementation-notes
22
+ * - Destination comparison uses the DOM `.href` property (already
23
+ * resolved to an absolute URL by the engine/browser), not the raw
24
+ * `href` attribute — so relative vs. absolute forms of the same target
25
+ * are correctly treated as identical.
26
+ * - Only flags when a name group actually contains more than one
27
+ * distinct destination; a name reused for links that all point to the
28
+ * same place is not flagged.
29
+ */
30
+
31
+ const id = 'identical-links-same-purpose';
32
+
33
+ const meta = {
34
+ title: 'Links with the same accessible name should lead to the same destination',
35
+ description:
36
+ 'Flags groups of links that share the same accessible name but resolve to more than one distinct destination, for manual review of whether they serve the same purpose.',
37
+ i18n: {
38
+ titleKey: 'identicalLinksSamePurpose_title',
39
+ descriptionKey: 'identicalLinksSamePurpose_description'
40
+ },
41
+ helpUrl: null,
42
+ tags: ['wcag2aaa', 'wcag249', 'navigation', 'atomic', 'manual'],
43
+ wcagSc: ['2.4.9'],
44
+ normativeMappings: [
45
+ { standard: 'WCAG', version: '2.2', requirement: '2.4.9', title: 'Link Purpose (Link Only)', conformanceLevel: 'AAA' }
46
+ ],
47
+ defaultSeverity: 'minor',
48
+ category: 'operable',
49
+ type: 'manual',
50
+ defaultConfidence: 'low',
51
+ coverage: { facetsBySc: { '2.4.9': ['identical-links-same-purpose-evidence'] } }
52
+ };
53
+
54
+ function runInPage(ctx) {
55
+ const { document, root, helpers, rule } = ctx;
56
+ const safeRoot = root || document;
57
+
58
+ function normName(s) {
59
+ return (s == null ? '' : String(s)).replace(/\s+/g, ' ').trim().toLowerCase();
60
+ }
61
+
62
+ const nodes = helpers.queryAllSmart ? helpers.queryAllSmart('a[href]', safeRoot) : helpers.queryAll('a[href]', safeRoot);
63
+
64
+ const groups = new Map(); // normName -> [{ el, href }]
65
+ let applicableCount = 0;
66
+
67
+ for (const el of nodes) {
68
+ if (!el || !el.getAttribute) continue;
69
+
70
+ const eligResult = helpers.isAccTreeEligible ? helpers.isAccTreeEligible(el, ctx) : true;
71
+ const eligible = typeof eligResult === 'boolean' ? eligResult : !!(eligResult && eligResult.eligible);
72
+ if (!eligible) continue;
73
+
74
+ const nameInfo = helpers.getAccessibleNameInfo ? helpers.getAccessibleNameInfo(el, ctx) : null;
75
+ const rawName = (nameInfo && typeof nameInfo.value === 'string' && nameInfo.value.trim())
76
+ ? nameInfo.value
77
+ : (el.textContent || '');
78
+ const name = normName(rawName);
79
+ if (!name) continue;
80
+
81
+ let href = '';
82
+ try { href = String(el.href || ''); } catch { href = ''; }
83
+ if (!href) continue;
84
+
85
+ applicableCount += 1;
86
+
87
+ if (!groups.has(name)) groups.set(name, []);
88
+ groups.get(name).push({ el, href });
89
+ }
90
+
91
+ const occurrences = [];
92
+
93
+ for (const [name, entries] of groups) {
94
+ const distinctHrefs = new Set(entries.map((e) => e.href));
95
+ if (distinctHrefs.size <= 1) continue;
96
+
97
+ for (const { el, href } of entries) {
98
+ const stableSelector = helpers.buildSelector ? helpers.buildSelector(el) : 'html';
99
+ const html = helpers.getOuterHtmlSnippet ? helpers.getOuterHtmlSnippet(el) : (el.outerHTML || '');
100
+
101
+ const baseOccurrence = {
102
+ selector: stableSelector,
103
+ html,
104
+ summary: 'This link shares an accessible name with other links on the page that lead to a different destination.',
105
+ hint: 'Ensure links with the same text serve the same purpose, or make the link text distinct enough to describe each destination.',
106
+ i18n: {
107
+ summaryKey: 'identicalLinksSamePurpose_summary_cantTell',
108
+ hintKey: 'identicalLinksSamePurpose_hint_cantTell',
109
+ params: { name, destinationCount: String(distinctHrefs.size) }
110
+ },
111
+ data: {
112
+ details: {
113
+ reasonCode: 'SAME_NAME_DIFFERENT_DESTINATION',
114
+ name,
115
+ href,
116
+ distinctDestinationCount: distinctHrefs.size
117
+ }
118
+ }
119
+ };
120
+
121
+ if (helpers && typeof helpers.reportOccurrence === 'function') {
122
+ occurrences.push(helpers.reportOccurrence(el, baseOccurrence));
123
+ } else {
124
+ occurrences.push(baseOccurrence);
125
+ }
126
+ }
127
+ }
128
+
129
+ if (applicableCount === 0) {
130
+ return { ruleId: rule.ruleId, outcome: 'notApplicable', severity: 'minor', occurrences: [] };
131
+ }
132
+
133
+ if (occurrences.length) {
134
+ return { ruleId: rule.ruleId, outcome: 'cantTell', severity: rule.defaultSeverity || 'minor', occurrences };
135
+ }
136
+
137
+ // Manual rules may only emit cantTell/notApplicable (never pass/fail):
138
+ // every name group already resolves to a single shared destination.
139
+ return { ruleId: rule.ruleId, outcome: 'notApplicable', severity: 'minor', occurrences: [] };
140
+ }
141
+
142
+ module.exports = { id, meta, runInPage };
@@ -0,0 +1,118 @@
1
+ 'use strict';
2
+
3
+ /**
4
+ * @check image-redundant-alt
5
+ * @atomic true
6
+ * @summary An image's alt text must not duplicate adjacent visible text
7
+ * @standard Best Practices (a widely-used reference engine's classification; no formal WCAG Success Criterion — see ROADMAP.md Tier 1b)
8
+ * @applicability
9
+ * Applies to <img> elements with non-empty alt text whose immediate
10
+ * parent element also has other visible text content (i.e. text nodes
11
+ * besides the image itself — commonly an <a> or <button> wrapping both
12
+ * an icon image and a text label).
13
+ * @expectation
14
+ * The image's alt text is not the same (case-insensitive, normalized)
15
+ * as the other visible text already in the same parent. When both are
16
+ * present, assistive technology announces the same words twice for a
17
+ * single control (e.g. an icon-plus-text link where the icon's alt
18
+ * duplicates the link text).
19
+ * @implementation-notes
20
+ * - Not WCAG-normative — authored as an advisory, cantTell-capped
21
+ * `type: 'manual'` rule; see landmark-banner-is-top-level's
22
+ * header comment for the shared rationale/precedent.
23
+ * - Deliberately scoped to the immediate parent only (not the whole
24
+ * ancestor chain or arbitrary siblings), to keep the "redundant"
25
+ * judgment high-confidence and avoid false positives from unrelated
26
+ * text elsewhere on the page that coincidentally matches.
27
+ */
28
+
29
+ const id = 'image-redundant-alt';
30
+
31
+ const meta = {
32
+ title: 'Image alt text must not duplicate adjacent visible text',
33
+ description: 'Checks that an <img> alt text is not identical to other visible text already present in its immediate parent element.',
34
+ i18n: {
35
+ titleKey: 'imageRedundantAlt_title',
36
+ descriptionKey: 'imageRedundantAlt_description'
37
+ },
38
+ helpUrl: null,
39
+ tags: ['best-practice', 'images', 'nontext', 'atomic', 'manual'],
40
+ wcagSc: [],
41
+ normativeMappings: [],
42
+ defaultSeverity: 'minor',
43
+ category: 'perceivable',
44
+ type: 'manual',
45
+ defaultConfidence: 'medium',
46
+ coverage: {}
47
+ };
48
+
49
+ function runInPage(ctx) {
50
+ const { document, root, helpers, rule } = ctx;
51
+ const safeRoot = root || document;
52
+
53
+ function normalizeWs(s) {
54
+ return String(s || '').replace(/\s+/g, ' ').trim();
55
+ }
56
+
57
+ function getOwnTextExcludingImg(parent, imgEl) {
58
+ let text = '';
59
+ for (const child of parent.childNodes || []) {
60
+ if (child === imgEl) continue;
61
+ if (child.nodeType === 3) {
62
+ text += ' ' + (child.nodeValue || '');
63
+ } else if (child.nodeType === 1 && child !== imgEl) {
64
+ text += ' ' + (child.textContent || '');
65
+ }
66
+ }
67
+ return normalizeWs(text);
68
+ }
69
+
70
+ const nodes = helpers.queryAllSmart ? helpers.queryAllSmart('img[alt]', safeRoot) : helpers.queryAll('img[alt]', safeRoot);
71
+
72
+ const occurrences = [];
73
+ let applicableCount = 0;
74
+
75
+ for (const el of nodes) {
76
+ if (!el || !el.getAttribute) continue;
77
+ const alt = normalizeWs(el.getAttribute('alt'));
78
+ if (!alt) continue;
79
+
80
+ const parent = el.parentElement;
81
+ if (!parent) continue;
82
+
83
+ const otherText = getOwnTextExcludingImg(parent, el);
84
+ if (!otherText) continue;
85
+
86
+ applicableCount += 1;
87
+
88
+ if (otherText.toLowerCase() !== alt.toLowerCase()) continue;
89
+
90
+ const stableSelector = helpers.buildSelector ? helpers.buildSelector(el) : 'html';
91
+ const html = helpers.getOuterHtmlSnippet ? helpers.getOuterHtmlSnippet(el) : (el.outerHTML || '');
92
+
93
+ occurrences.push({
94
+ selector: stableSelector,
95
+ html,
96
+ summary: 'This image\'s alt text duplicates other visible text right next to it.',
97
+ hint: 'Make the alt text empty (alt="") if the image is purely decorative alongside the text, or remove the redundant duplication.',
98
+ i18n: {
99
+ summaryKey: 'imageRedundantAlt_summary_cantTell',
100
+ hintKey: 'imageRedundantAlt_hint_cantTell',
101
+ params: { alt }
102
+ },
103
+ data: {
104
+ details: { reasonCode: 'IMAGE_ALT_REDUNDANT', alt }
105
+ }
106
+ });
107
+ }
108
+
109
+ if (applicableCount === 0) {
110
+ return { ruleId: rule.ruleId, outcome: 'notApplicable', severity: 'minor', occurrences: [] };
111
+ }
112
+ if (occurrences.length) {
113
+ return { ruleId: rule.ruleId, outcome: 'cantTell', severity: rule.defaultSeverity || 'minor', occurrences };
114
+ }
115
+ return { ruleId: rule.ruleId, outcome: 'notApplicable', severity: 'minor', occurrences: [] };
116
+ }
117
+
118
+ module.exports = { id, meta, runInPage };