@surea11y/core 1.2.0 → 1.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (157) hide show
  1. package/CHANGELOG.md +37 -7
  2. package/LICENSE +373 -21
  3. package/README.md +67 -1
  4. package/bin/core.js +144 -19
  5. package/docs/API_STABILITY.md +1 -1
  6. package/docs/BINDING_AUTHORS_GUIDE.md +9 -9
  7. package/docs/CI_INTEGRATIONS.md +103 -0
  8. package/docs/CLI.md +54 -1
  9. package/docs/ENGINE_OPTIONS.md +2 -0
  10. package/docs/INTEGRATION.md +18 -0
  11. package/docs/OUTPUT_SCHEMA.md +1 -1
  12. package/docs/RULE_CATALOG.md +1 -1
  13. package/docs/SARIF.md +59 -0
  14. package/package.json +15 -4
  15. package/src/baseline.js +0 -0
  16. package/src/catalogs/composites.wcag.js +414 -450
  17. package/src/checks/automatic/area-alt-present.js +59 -25
  18. package/src/checks/automatic/aria-allowed-attr.js +193 -33
  19. package/src/checks/automatic/aria-allowed-role.js +21 -7
  20. package/src/checks/automatic/aria-braille-equivalent.js +32 -10
  21. package/src/checks/automatic/aria-conditional-attr.js +24 -7
  22. package/src/checks/automatic/aria-deprecated-role.js +22 -8
  23. package/src/checks/automatic/aria-hidden-body.js +42 -19
  24. package/src/checks/automatic/aria-hidden-focus.js +408 -53
  25. package/src/checks/automatic/aria-prohibited-attr.js +296 -22
  26. package/src/checks/automatic/aria-prohibited-children.js +55 -16
  27. package/src/checks/automatic/aria-required-attr.js +23 -8
  28. package/src/checks/automatic/aria-required-children.js +37 -14
  29. package/src/checks/automatic/aria-required-parent.js +48 -14
  30. package/src/checks/automatic/aria-role-name-present.js +47 -21
  31. package/src/checks/automatic/aria-roles-valid.js +22 -12
  32. package/src/checks/automatic/aria-valid-attr-value.js +28 -7
  33. package/src/checks/automatic/aria-valid-attr.js +17 -5
  34. package/src/checks/automatic/autocomplete-valid.js +74 -16
  35. package/src/checks/automatic/avoid-inline-spacing.js +20 -6
  36. package/src/checks/automatic/binary-control-name-present.js +60 -50
  37. package/src/checks/automatic/button-name-present.js +48 -18
  38. package/src/checks/automatic/bypass-blocks-present.js +42 -25
  39. package/src/checks/automatic/canvas-text-alternative-present.js +57 -26
  40. package/src/checks/automatic/combobox-name-present.js +38 -45
  41. package/src/checks/automatic/contrast-computable.js +361 -341
  42. package/src/checks/automatic/contrast-enhanced.js +487 -466
  43. package/src/checks/automatic/contrast-minimum.js +486 -465
  44. package/src/checks/automatic/css-orientation-lock.js +30 -8
  45. package/src/checks/automatic/definition-list-children-valid.js +40 -19
  46. package/src/checks/automatic/deprecated-elements-not-used.js +21 -7
  47. package/src/checks/automatic/dialog-name-present.js +37 -75
  48. package/src/checks/automatic/dlitem-parent-valid.js +23 -8
  49. package/src/checks/automatic/duplicate-id-aria.js +24 -6
  50. package/src/checks/automatic/embed-text-alternative-present.js +86 -35
  51. package/src/checks/automatic/form-control-programmatic-label-present.js +79 -196
  52. package/src/checks/automatic/form-control-single-label.js +47 -10
  53. package/src/checks/automatic/html-xml-lang-mismatch.js +34 -18
  54. package/src/checks/automatic/iframe-focusable-content.js +26 -11
  55. package/src/checks/automatic/iframe-name-present.js +31 -9
  56. package/src/checks/automatic/iframe-title-unique.js +29 -8
  57. package/src/checks/automatic/img-alt-present.js +47 -43
  58. package/src/checks/automatic/input-image-alt-present.js +143 -112
  59. package/src/checks/automatic/label-in-name.js +50 -22
  60. package/src/checks/automatic/language-page-present.js +109 -109
  61. package/src/checks/automatic/link-in-text-block.js +59 -19
  62. package/src/checks/automatic/link-name-present.js +45 -14
  63. package/src/checks/automatic/list-children-valid.js +26 -9
  64. package/src/checks/automatic/listbox-name-present.js +39 -19
  65. package/src/checks/automatic/listitem-parent-valid.js +18 -6
  66. package/src/checks/automatic/menuitem-name-present.js +39 -61
  67. package/src/checks/automatic/meta-refresh-no-exceptions.js +28 -7
  68. package/src/checks/automatic/meta-refresh-timing-absent.js +20 -6
  69. package/src/checks/automatic/meta-viewport-zoom-enabled.js +24 -7
  70. package/src/checks/automatic/meter-name-present.js +36 -33
  71. package/src/checks/automatic/nested-interactive-controls-absent.js +31 -10
  72. package/src/checks/automatic/object-text-alternative-present.js +91 -39
  73. package/src/checks/automatic/option-name-present.js +38 -21
  74. package/src/checks/automatic/page-title-present.js +17 -6
  75. package/src/checks/automatic/progressbar-name-present.js +41 -34
  76. package/src/checks/automatic/role-img-alt-present.js +209 -157
  77. package/src/checks/automatic/searchbox-name-present.js +39 -19
  78. package/src/checks/automatic/server-side-image-map-absent.js +23 -8
  79. package/src/checks/automatic/slider-name-present.js +40 -47
  80. package/src/checks/automatic/spinbutton-name-present.js +39 -19
  81. package/src/checks/automatic/summary-name-present.js +37 -17
  82. package/src/checks/automatic/svg-image-text-alternative-present.js +114 -47
  83. package/src/checks/automatic/svg-text-alternative-present.js +246 -226
  84. package/src/checks/automatic/tab-name-present.js +37 -60
  85. package/src/checks/automatic/table-headers-attr-valid.js +24 -8
  86. package/src/checks/automatic/table-th-has-data-cells.js +22 -8
  87. package/src/checks/automatic/target-size-minimum.js +118 -48
  88. package/src/checks/automatic/td-has-header.js +29 -11
  89. package/src/checks/automatic/textbox-name-present.js +39 -19
  90. package/src/checks/automatic/tooltip-name-present.js +37 -18
  91. package/src/checks/automatic/treeitem-name-present.js +38 -21
  92. package/src/checks/automatic/valid-lang.js +20 -6
  93. package/src/checks/automatic/video-poster-text-alternative-present.js +79 -36
  94. package/src/checks/manual/accesskeys-manual.js +14 -5
  95. package/src/checks/manual/area-alt-decorative-manual.js +192 -193
  96. package/src/checks/manual/area-alt-quality-manual.js +182 -141
  97. package/src/checks/manual/aria-checked-state-mismatch-manual.js +34 -11
  98. package/src/checks/manual/aria-text-manual.js +14 -6
  99. package/src/checks/manual/canvas-text-alternative-quality-manual.js +149 -114
  100. package/src/checks/manual/css-hidden-focus.js +196 -165
  101. package/src/checks/manual/embed-text-alternative-quality-manual.js +171 -160
  102. package/src/checks/manual/empty-heading-manual.js +24 -7
  103. package/src/checks/manual/empty-table-header-manual.js +17 -6
  104. package/src/checks/manual/focus-order-semantics-manual.js +45 -10
  105. package/src/checks/manual/form-control-programmatic-label-quality-manual.js +207 -246
  106. package/src/checks/manual/heading-order-manual.js +22 -7
  107. package/src/checks/manual/identical-links-same-purpose-manual.js +34 -12
  108. package/src/checks/manual/image-redundant-alt-manual.js +17 -7
  109. package/src/checks/manual/img-alt-decorative-manual.js +131 -96
  110. package/src/checks/manual/img-alt-quality-manual.js +176 -127
  111. package/src/checks/manual/input-image-alt-decorative-manual.js +125 -92
  112. package/src/checks/manual/input-image-alt-quality-manual.js +125 -92
  113. package/src/checks/manual/label-title-only-manual.js +14 -5
  114. package/src/checks/manual/landmark-banner-is-top-level-manual.js +66 -16
  115. package/src/checks/manual/landmark-contentinfo-is-top-level-manual.js +53 -16
  116. package/src/checks/manual/landmark-main-is-top-level-manual.js +35 -10
  117. package/src/checks/manual/landmark-no-duplicate-banner-manual.js +29 -10
  118. package/src/checks/manual/landmark-no-duplicate-contentinfo-manual.js +29 -10
  119. package/src/checks/manual/landmark-no-duplicate-main-manual.js +17 -8
  120. package/src/checks/manual/landmark-one-main-manual.js +26 -20
  121. package/src/checks/manual/landmark-unique-manual.js +41 -15
  122. package/src/checks/manual/link-name-quality-manual.js +43 -12
  123. package/src/checks/manual/media-transcript-present-manual.js +35 -22
  124. package/src/checks/manual/meta-viewport-large-manual.js +16 -5
  125. package/src/checks/manual/mouse-only-event-handlers-manual.js +38 -11
  126. package/src/checks/manual/no-autoplay-audio-manual.js +20 -6
  127. package/src/checks/manual/object-text-alternative-quality-manual.js +175 -154
  128. package/src/checks/manual/p-as-heading-manual.js +22 -7
  129. package/src/checks/manual/page-has-heading-one-manual.js +31 -22
  130. package/src/checks/manual/page-title-patterns-manual.js +78 -50
  131. package/src/checks/manual/presentation-role-conflict-manual.js +59 -19
  132. package/src/checks/manual/region-manual.js +241 -48
  133. package/src/checks/manual/scope-attr-valid-manual.js +10 -3
  134. package/src/checks/manual/scrollable-region-focusable-manual.js +37 -11
  135. package/src/checks/manual/skip-link-manual.js +35 -12
  136. package/src/checks/manual/svg-text-alternative-quality-manual.js +206 -165
  137. package/src/checks/manual/tabindex-manual.js +10 -3
  138. package/src/checks/manual/table-duplicate-name-manual.js +17 -7
  139. package/src/checks/manual/table-fake-caption-manual.js +24 -7
  140. package/src/checks/manual/video-caption-manual.js +15 -4
  141. package/src/checks/manual-review.js +56 -12
  142. package/src/core/aria-helpers.js +1127 -886
  143. package/src/core/contrast-helpers.js +1217 -1062
  144. package/src/core/dom-helpers.js +4175 -3917
  145. package/src/core/dom-runner.js +720 -604
  146. package/src/core/frame-messaging.js +189 -138
  147. package/src/core/frame-scan.js +94 -82
  148. package/src/core/rollup-composites.js +94 -102
  149. package/src/core/rule-meta.js +58 -41
  150. package/src/core.js +36552 -29111
  151. package/src/i18n/en.js +1194 -889
  152. package/src/i18n/fr.js +1136 -795
  153. package/src/policy/contracts.js +13 -13
  154. package/src/policy/resolvePolicy.js +48 -44
  155. package/src/report.js +63 -43
  156. package/src/sarif.js +175 -0
  157. package/surea11y.browser.js +36042 -0
@@ -25,267 +25,228 @@
25
25
  const id = 'form-control-programmatic-label-quality';
26
26
 
27
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
- }
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
+ {
40
+ standard: 'WCAG',
41
+ version: '2.2',
42
+ requirement: '4.1.2',
43
+ title: 'Name, Role, Value',
44
+ conformanceLevel: 'A'
45
+ }
46
+ ],
47
+ defaultSeverity: 'moderate',
48
+ category: 'robust',
49
+ type: 'manual',
50
+ defaultConfidence: 'medium',
51
+ coverage: {
52
+ facetsBySc: {
53
+ '4.1.2': ['form-control-name-quality']
49
54
  }
55
+ }
50
56
  };
51
57
 
52
58
  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
- }
59
+ const { document, root, helpers, rule } = ctx;
60
+ const safeRoot = root || document;
61
+
62
+ const queryAllSmart =
63
+ helpers && typeof helpers.queryAllSmart === 'function' ? helpers.queryAllSmart : null;
64
+
65
+ const isAccTreeEligible =
66
+ helpers && typeof helpers.isAccTreeEligible === 'function' ? helpers.isAccTreeEligible : null;
67
+ const getEligibilityInfo =
68
+ helpers && typeof helpers.getEligibilityInfo === 'function' ? helpers.getEligibilityInfo : null;
69
+
70
+ const getFocusableInfo =
71
+ helpers && typeof helpers.getFocusableInfo === 'function' ? helpers.getFocusableInfo : null;
72
+
73
+ const getLabelMethod =
74
+ helpers && typeof helpers.getLabelMethod === 'function' ? helpers.getLabelMethod : null;
75
+
76
+ const trim = (v) => (v == null ? '' : String(v)).trim();
77
+
78
+ const metrics = {
79
+ applicableCount: 0,
80
+ flaggedCount: 0,
81
+ byMethod: { label: 0, 'aria-labelledby': 0, 'aria-label': 0, title: 0, placeholder: 0, none: 0 }
82
+ };
83
+
84
+ function safeQueryAll(sel) {
85
+ try {
86
+ if (queryAllSmart) return Array.from(queryAllSmart(sel) || []);
87
+ return safeRoot && safeRoot.querySelectorAll
88
+ ? Array.from(safeRoot.querySelectorAll(sel))
89
+ : [];
90
+ } catch {
91
+ return [];
85
92
  }
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
- }
93
+ }
94
+
95
+ function isEligibleAcc(el) {
96
+ if (!isAccTreeEligible) return true;
97
+ try {
98
+ const r = isAccTreeEligible(el, ctx);
99
+ if (typeof r === 'boolean') return r;
100
+ return !!(r && r.eligible);
101
+ } catch {
102
+ return true;
143
103
  }
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
-
104
+ }
105
+
106
+ // getLabelMethod is provided by the shared dom-helpers bundle that
107
+ // dom-runner.js always constructs for every rule execution (built-in or
108
+ // custom) — see createDomHelpers's own getLabelMethod, which implements
109
+ // this exact <label>/aria-labelledby/aria-label/title/placeholder
110
+ // priority order. No local reimplementation is needed as a fallback.
111
+ function getLabelMethodSafe(el) {
112
+ if (!getLabelMethod) return { method: 'none', value: '' };
113
+ try {
114
+ const r = getLabelMethod(el, ctx);
115
+ const m = r && typeof r.method === 'string' ? r.method : 'none';
116
+ const v = r && r.value != null ? trim(r.value) : '';
117
+ if (!Object.prototype.hasOwnProperty.call(metrics.byMethod, m))
172
118
  return { method: 'none', value: '' };
119
+ return { method: m, value: v };
120
+ } catch {
121
+ return { method: 'none', value: '' };
173
122
  }
123
+ }
174
124
 
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';
125
+ // Native controls only (same as your current rule)
126
+ const selector =
127
+ 'input:not([type="hidden"]):not([type="submit"]):not([type="reset"]):not([type="button"]):not([type="image"]),select,textarea';
193
128
 
194
- const nodes = safeQueryAll(selector);
129
+ const nodes = safeQueryAll(selector);
195
130
 
196
- if (!nodes.length) {
197
- return { ruleId: rule.ruleId, outcome: 'notApplicable', severity: 'minor', occurrences: [], data: { details: { metrics } } };
131
+ if (!nodes.length) {
132
+ return {
133
+ ruleId: rule.ruleId,
134
+ outcome: 'notApplicable',
135
+ severity: 'minor',
136
+ occurrences: [],
137
+ data: { details: { metrics } }
138
+ };
139
+ }
140
+
141
+ const occurrences = [];
142
+
143
+ for (const el of nodes) {
144
+ if (!el || !el.getAttribute) continue;
145
+
146
+ if (!isEligibleAcc(el)) continue;
147
+
148
+ const role = (() => {
149
+ try {
150
+ return trim(el.getAttribute('role')).toLowerCase();
151
+ } catch {
152
+ return '';
153
+ }
154
+ })();
155
+
156
+ let fi = null;
157
+ if (getFocusableInfo) {
158
+ try {
159
+ fi = getFocusableInfo(el, ctx);
160
+ } catch {
161
+ fi = null;
162
+ }
198
163
  }
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; }
164
+ const tabbable = !!(fi && fi.tabbable);
165
+
166
+ if ((role === 'presentation' || role === 'none') && !tabbable) continue;
167
+
168
+ metrics.applicableCount += 1;
169
+
170
+ const label = getLabelMethodSafe(el);
171
+ const method = label && typeof label.method === 'string' ? label.method : 'none';
172
+ if (Object.prototype.hasOwnProperty.call(metrics.byMethod, method))
173
+ metrics.byMethod[method] += 1;
174
+ else metrics.byMethod.none += 1;
175
+
176
+ // Flag only when the *primary* (best) method is title/placeholder
177
+ const isWeakPrimary = method === 'title' || method === 'placeholder';
178
+ if (!isWeakPrimary) continue;
179
+
180
+ metrics.flaggedCount += 1;
181
+
182
+ const vf = getEligibilityInfo ? getEligibilityInfo(el, ctx, { targetSet: 'acc' }) : null;
183
+
184
+ const reasonCode =
185
+ method === 'title' ? 'label_from_title_primary' : 'label_from_placeholder_primary';
186
+ const methodLabel =
187
+ method === 'title'
188
+ ? 'title'
189
+ : method === 'placeholder'
190
+ ? 'placeholder'
191
+ : 'title or placeholder';
192
+
193
+ const baseOccurrence = {
194
+ summary: 'Form control’s primary label is derived from title or placeholder.',
195
+ hint: 'Prefer a persistent <label> or aria-labelledby. Avoid relying on placeholder/title as the primary label.',
196
+ i18n: {
197
+ summaryKey: 'formControl_programmaticLabelQuality_summary_cantTell',
198
+ hintKey: 'formControl_programmaticLabelQuality_hint_cantTell',
199
+ params: { element: (el.tagName || '').toLowerCase(), method, methodLabel }
200
+ },
201
+ data: {
202
+ visibilityFilter: vf || { targetSet: 'acc', accEligible: null, reasons: [] },
203
+ details: {
204
+ reasonCode,
205
+ labelMethod: method,
206
+ labelStrength: 'weak',
207
+ recommendedMethods: ['label', 'aria-labelledby'],
208
+ sourceText: label && label.value ? String(label.value).slice(0, 120) : ''
218
209
  }
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
- }
210
+ }
211
+ };
274
212
 
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
- };
213
+ if (helpers && typeof helpers.reportOccurrence === 'function') {
214
+ occurrences.push(helpers.reportOccurrence(el, baseOccurrence));
215
+ } else {
216
+ occurrences.push({ selector: '', html: '', ...baseOccurrence });
283
217
  }
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 } } };
218
+ }
219
+
220
+ if (metrics.applicableCount === 0) {
221
+ return {
222
+ ruleId: rule.ruleId,
223
+ outcome: 'notApplicable',
224
+ severity: 'minor',
225
+ occurrences: [],
226
+ data: { details: { metrics } }
227
+ };
228
+ }
229
+
230
+ if (occurrences.length) {
231
+ return {
232
+ ruleId: rule.ruleId,
233
+ outcome: 'cantTell',
234
+ severity: rule.defaultSeverity || 'minor',
235
+ occurrences,
236
+ data: { details: { metrics } }
237
+ };
238
+ }
239
+
240
+ // Manual rules may only emit cantTell/notApplicable (never pass/fail) —
241
+ // no applicable control relied on a weak (title/placeholder) primary
242
+ // label, so there is nothing to flag for review.
243
+ return {
244
+ ruleId: rule.ruleId,
245
+ outcome: 'notApplicable',
246
+ severity: 'minor',
247
+ occurrences: [],
248
+ data: { details: { metrics } }
249
+ };
289
250
  }
290
251
 
291
252
  module.exports = { id, meta, runInPage };
@@ -47,10 +47,12 @@ const meta = {
47
47
  };
48
48
 
49
49
  function runInPage(ctx) {
50
- const { document, helpers, rule } = ctx;
50
+ const { helpers, rule } = ctx;
51
51
 
52
52
  function normalizeWs(s) {
53
- return String(s || '').replace(/\s+/g, ' ').trim();
53
+ return String(s || '')
54
+ .replace(/\s+/g, ' ')
55
+ .trim();
54
56
  }
55
57
 
56
58
  function getExplicitRoleToken(el) {
@@ -65,14 +67,16 @@ function runInPage(ctx) {
65
67
  if (explicit !== 'heading') return 0;
66
68
  const raw = normalizeWs(el.getAttribute && el.getAttribute('aria-level'));
67
69
  const n = parseInt(raw, 10);
68
- return (Number.isFinite(n) && n >= 1) ? n : 2;
70
+ return Number.isFinite(n) && n >= 1 ? n : 2;
69
71
  }
70
72
  const tag = el.tagName ? el.tagName.toLowerCase() : '';
71
73
  const m = /^h([1-6])$/.exec(tag);
72
74
  return m ? parseInt(m[1], 10) : 0;
73
75
  }
74
76
 
75
- const nodes = helpers.queryAllSmart ? helpers.queryAllSmart('h1, h2, h3, h4, h5, h6, [role]') : helpers.queryAll('h1, h2, h3, h4, h5, h6, [role]');
77
+ const nodes = helpers.queryAllSmart
78
+ ? helpers.queryAllSmart('h1, h2, h3, h4, h5, h6, [role]')
79
+ : helpers.queryAll('h1, h2, h3, h4, h5, h6, [role]');
76
80
 
77
81
  const headings = [];
78
82
  const seen = new Set();
@@ -95,7 +99,9 @@ function runInPage(ctx) {
95
99
 
96
100
  if (level > highestSoFar + 1) {
97
101
  const stableSelector = helpers.buildSelector ? helpers.buildSelector(el) : 'html';
98
- const html = helpers.getOuterHtmlSnippet ? helpers.getOuterHtmlSnippet(el) : (el.outerHTML || '');
102
+ const html = helpers.getOuterHtmlSnippet
103
+ ? helpers.getOuterHtmlSnippet(el)
104
+ : el.outerHTML || '';
99
105
 
100
106
  occurrences.push({
101
107
  selector: stableSelector,
@@ -108,7 +114,11 @@ function runInPage(ctx) {
108
114
  params: { fromLevel: String(highestSoFar), toLevel: String(level) }
109
115
  },
110
116
  data: {
111
- details: { reasonCode: 'HEADING_ORDER_SKIPPED_LEVEL', fromLevel: highestSoFar, toLevel: level }
117
+ details: {
118
+ reasonCode: 'HEADING_ORDER_SKIPPED_LEVEL',
119
+ fromLevel: highestSoFar,
120
+ toLevel: level
121
+ }
112
122
  }
113
123
  });
114
124
  }
@@ -117,7 +127,12 @@ function runInPage(ctx) {
117
127
  }
118
128
 
119
129
  if (occurrences.length) {
120
- return { ruleId: rule.ruleId, outcome: 'cantTell', severity: rule.defaultSeverity || 'minor', occurrences };
130
+ return {
131
+ ruleId: rule.ruleId,
132
+ outcome: 'cantTell',
133
+ severity: rule.defaultSeverity || 'minor',
134
+ occurrences
135
+ };
121
136
  }
122
137
  return { ruleId: rule.ruleId, outcome: 'notApplicable', severity: 'minor', occurrences: [] };
123
138
  }
@@ -42,7 +42,13 @@ const meta = {
42
42
  tags: ['wcag2aaa', 'wcag249', 'navigation', 'atomic', 'manual'],
43
43
  wcagSc: ['2.4.9'],
44
44
  normativeMappings: [
45
- { standard: 'WCAG', version: '2.2', requirement: '2.4.9', title: 'Link Purpose (Link Only)', conformanceLevel: 'AAA' }
45
+ {
46
+ standard: 'WCAG',
47
+ version: '2.2',
48
+ requirement: '2.4.9',
49
+ title: 'Link Purpose (Link Only)',
50
+ conformanceLevel: 'AAA'
51
+ }
46
52
  ],
47
53
  defaultSeverity: 'minor',
48
54
  category: 'operable',
@@ -52,13 +58,15 @@ const meta = {
52
58
  };
53
59
 
54
60
  function runInPage(ctx) {
55
- const { document, helpers, rule } = ctx;
61
+ const { helpers, rule } = ctx;
56
62
 
57
63
  function normName(s) {
58
64
  return (s == null ? '' : String(s)).replace(/\s+/g, ' ').trim().toLowerCase();
59
65
  }
60
66
 
61
- const nodes = helpers.queryAllSmart ? helpers.queryAllSmart('a[href]') : helpers.queryAll('a[href]');
67
+ const nodes = helpers.queryAllSmart
68
+ ? helpers.queryAllSmart('a[href]')
69
+ : helpers.queryAll('a[href]');
62
70
 
63
71
  const groups = new Map(); // normName -> [{ el, href }]
64
72
  let applicableCount = 0;
@@ -67,18 +75,24 @@ function runInPage(ctx) {
67
75
  if (!el || !el.getAttribute) continue;
68
76
 
69
77
  const eligResult = helpers.isAccTreeEligible ? helpers.isAccTreeEligible(el, ctx) : true;
70
- const eligible = typeof eligResult === 'boolean' ? eligResult : !!(eligResult && eligResult.eligible);
78
+ const eligible =
79
+ typeof eligResult === 'boolean' ? eligResult : !!(eligResult && eligResult.eligible);
71
80
  if (!eligible) continue;
72
81
 
73
82
  const nameInfo = helpers.getAccessibleNameInfo ? helpers.getAccessibleNameInfo(el, ctx) : null;
74
- const rawName = (nameInfo && typeof nameInfo.value === 'string' && nameInfo.value.trim())
75
- ? nameInfo.value
76
- : (el.textContent || '');
83
+ const rawName =
84
+ nameInfo && typeof nameInfo.value === 'string' && nameInfo.value.trim()
85
+ ? nameInfo.value
86
+ : el.textContent || '';
77
87
  const name = normName(rawName);
78
88
  if (!name) continue;
79
89
 
80
- let href = '';
81
- try { href = String(el.href || ''); } catch { href = ''; }
90
+ let href;
91
+ try {
92
+ href = String(el.href || '');
93
+ } catch {
94
+ href = '';
95
+ }
82
96
  if (!href) continue;
83
97
 
84
98
  applicableCount += 1;
@@ -95,12 +109,15 @@ function runInPage(ctx) {
95
109
 
96
110
  for (const { el, href } of entries) {
97
111
  const stableSelector = helpers.buildSelector ? helpers.buildSelector(el) : 'html';
98
- const html = helpers.getOuterHtmlSnippet ? helpers.getOuterHtmlSnippet(el) : (el.outerHTML || '');
112
+ const html = helpers.getOuterHtmlSnippet
113
+ ? helpers.getOuterHtmlSnippet(el)
114
+ : el.outerHTML || '';
99
115
 
100
116
  const baseOccurrence = {
101
117
  selector: stableSelector,
102
118
  html,
103
- summary: 'This link shares an accessible name with other links on the page that lead to a different destination.',
119
+ summary:
120
+ 'This link shares an accessible name with other links on the page that lead to a different destination.',
104
121
  hint: 'Ensure links with the same text serve the same purpose, or make the link text distinct enough to describe each destination.',
105
122
  i18n: {
106
123
  summaryKey: 'identicalLinksSamePurpose_summary_cantTell',
@@ -130,7 +147,12 @@ function runInPage(ctx) {
130
147
  }
131
148
 
132
149
  if (occurrences.length) {
133
- return { ruleId: rule.ruleId, outcome: 'cantTell', severity: rule.defaultSeverity || 'minor', occurrences };
150
+ return {
151
+ ruleId: rule.ruleId,
152
+ outcome: 'cantTell',
153
+ severity: rule.defaultSeverity || 'minor',
154
+ occurrences
155
+ };
134
156
  }
135
157
 
136
158
  // Manual rules may only emit cantTell/notApplicable (never pass/fail):