@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,201 @@
1
+ 'use strict';
2
+
3
+ /**
4
+ * Generic name-presence rule for selected ARIA composite/widget roles that should expose an accessible name.
5
+ *
6
+ * Notes:
7
+ * - This rule intentionally focuses on author-provided naming mechanisms:
8
+ * aria-label, aria-labelledby, and title.
9
+ * - It does NOT treat descendant text content as a valid name source for these roles
10
+ * (to avoid false passes from labelled children inside composite widgets).
11
+ * - Eligibility is based on helpers.isAccTreeEligible(node, ctx) per engine checks.
12
+ */
13
+
14
+ const id = 'aria-role-name-present';
15
+
16
+ const meta = {
17
+ title: 'ARIA widget/container roles have an accessible name',
18
+ description: 'Checks that selected ARIA widget/container roles expose a non-empty accessible name.',
19
+ i18n: {
20
+ titleKey: 'ariaRoleNamePresent_title',
21
+ descriptionKey: 'ariaRoleNamePresent_description'
22
+ },
23
+ helpUrl: null,
24
+ tags: ['wcag2a', 'wcag412', 'navigation', 'atomic', 'automatic', 'name'],
25
+ wcagSc: ['4.1.2'],
26
+ normativeMappings: [
27
+ { standard: 'WCAG', version: '2.2', requirement: '4.1.2', title: 'Name, Role, Value', conformanceLevel: 'A' }
28
+ ],
29
+ defaultSeverity: 'serious',
30
+ category: 'robust',
31
+ type: 'automatic',
32
+ defaultConfidence: 'high',
33
+ coverage: {
34
+ facetsBySc: {
35
+ '4.1.2': ['aria-role-name-present']
36
+ }
37
+ }
38
+ };
39
+
40
+ function runInPage(ctx) {
41
+ const { document, root, helpers, rule } = ctx;
42
+ const safeRoot = root || document;
43
+
44
+ const queryAllSmart = helpers && typeof helpers.queryAllSmart === 'function' ? helpers.queryAllSmart : null;
45
+ const queryAll = helpers && typeof helpers.queryAll === 'function'
46
+ ? helpers.queryAll
47
+ : (sel, rt) => {
48
+ try {
49
+ const scope = rt || safeRoot;
50
+ return scope && scope.querySelectorAll ? Array.from(scope.querySelectorAll(sel)) : [];
51
+ } catch {
52
+ return [];
53
+ }
54
+ };
55
+
56
+ const isAccTreeEligible =
57
+ helpers && typeof helpers.isAccTreeEligible === 'function' ? helpers.isAccTreeEligible : null;
58
+
59
+ const getEligibilityInfo =
60
+ helpers && typeof helpers.getEligibilityInfo === 'function' ? helpers.getEligibilityInfo : null;
61
+
62
+ const getAriaLabelledByInfo =
63
+ helpers && typeof helpers.getAriaLabelledByInfo === 'function' ? helpers.getAriaLabelledByInfo : null;
64
+
65
+ const normalizeWs = (s) => String(s || '').replace(/\s+/g, ' ').trim();
66
+
67
+ const getAttr = (el, name) => {
68
+ try {
69
+ if (!el || !el.getAttribute) return '';
70
+ return normalizeWs(el.getAttribute(name));
71
+ } catch {
72
+ return '';
73
+ }
74
+ };
75
+
76
+
77
+ // aria-labelledby resolution: delegate to the shared helper (used by
78
+ // button-name-present et al.), which correctly includes hidden/aria-hidden
79
+ // referenced nodes per the Accessible Name and Description Computation
80
+ // spec (a hidden node directly referenced by aria-labelledby still
81
+ // supplies its text — this is a standard visually-hidden-label pattern).
82
+ const resolveLabelledby = (el, maxRefs) => {
83
+ if (!getAriaLabelledByInfo) return '';
84
+ try {
85
+ const info = getAriaLabelledByInfo(el, ctx, { maxRefs: Math.max(1, maxRefs || 8) });
86
+ return info && info.present ? normalizeWs(info.value) : '';
87
+ } catch {
88
+ return '';
89
+ }
90
+ };
91
+
92
+ const isEligible = (el) => {
93
+ if (!isAccTreeEligible) return true;
94
+ try {
95
+ const r = isAccTreeEligible(el, ctx);
96
+ if (typeof r === 'boolean') return r;
97
+ return !!(r && r.eligible);
98
+ } catch {
99
+ return true;
100
+ }
101
+ };
102
+
103
+ // Frozen allowlist of roles to check in this generic rule.
104
+ // (Keep this small, explicit, and standards-based for determinism.)
105
+ const roleSet = new Set([
106
+ 'scrollbar',
107
+ 'toolbar',
108
+ 'tablist',
109
+ 'radiogroup',
110
+ 'tree',
111
+ 'grid',
112
+ 'menu',
113
+ 'menubar',
114
+ 'meter',
115
+ 'progressbar'
116
+ ]);
117
+
118
+ const selector =
119
+ '[role="scrollbar"],[role="toolbar"],[role="tablist"],[role="radiogroup"],[role="tree"],[role="grid"],[role="menu"],[role="menubar"],[role="meter"],[role="progressbar"]';
120
+
121
+ const nodes = (() => {
122
+ try {
123
+ return (queryAllSmart ? queryAllSmart(selector, safeRoot) : queryAll(selector, safeRoot)) || [];
124
+ } catch {
125
+ return queryAll(selector, safeRoot);
126
+ }
127
+ })();
128
+
129
+ if (!nodes.length) {
130
+ return { ruleId: rule.ruleId, outcome: 'notApplicable', severity: 'minor', occurrences: [] };
131
+ }
132
+
133
+ const occurrences = [];
134
+ let applicableCount = 0;
135
+
136
+ for (const el of nodes) {
137
+ if (!el || !el.getAttribute) continue;
138
+
139
+ // Role normalization + allowlist check (defensive).
140
+ const role = (() => {
141
+ try {
142
+ return normalizeWs(el.getAttribute('role')).toLowerCase();
143
+ } catch {
144
+ return '';
145
+ }
146
+ })();
147
+ if (!roleSet.has(role)) continue;
148
+
149
+ if (!isEligible(el)) continue;
150
+
151
+ applicableCount += 1;
152
+
153
+ const ariaLabel = getAttr(el, 'aria-label');
154
+ const labelled = ariaLabel ? '' : resolveLabelledby(el, 8);
155
+
156
+ const title = ariaLabel || labelled ? '' : getAttr(el, 'title');
157
+
158
+ const ok = !!(ariaLabel || labelled || title);
159
+ if (ok) continue;
160
+
161
+ const eligInfo = getEligibilityInfo ? (() => {
162
+ try { return getEligibilityInfo(el, ctx, { targetSet: 'acc' }); } catch { return null; }
163
+ })() : null;
164
+
165
+ const baseOccurrence = {
166
+ summary: 'This element has no accessible name.',
167
+ hint: 'Provide aria-label or aria-labelledby (preferred), or a non-empty title attribute.',
168
+ i18n: {
169
+ summaryKey: 'ariaRoleNamePresent_summary_fail',
170
+ hintKey: 'ariaRoleNamePresent_hint_fail',
171
+ params: { role }
172
+ },
173
+ data: {
174
+ visibilityFilter: eligInfo || { targetSet: 'acc', accEligible: null, reasons: [] },
175
+ details: {
176
+ reasonCode: 'name_missing',
177
+ role,
178
+ nameSourcesChecked: ['aria-label', 'aria-labelledby', 'title']
179
+ }
180
+ }
181
+ };
182
+
183
+ if (helpers && typeof helpers.reportOccurrence === 'function') {
184
+ occurrences.push(helpers.reportOccurrence(el, baseOccurrence));
185
+ } else {
186
+ occurrences.push({ selector: '', html: '', ...baseOccurrence });
187
+ }
188
+ }
189
+
190
+ if (applicableCount === 0) {
191
+ return { ruleId: rule.ruleId, outcome: 'notApplicable', severity: 'minor', occurrences: [] };
192
+ }
193
+
194
+ if (!occurrences.length) {
195
+ return { ruleId: rule.ruleId, outcome: 'pass', severity: 'minor', occurrences: [] };
196
+ }
197
+
198
+ return { ruleId: rule.ruleId, outcome: 'fail', severity: rule.defaultSeverity || 'minor', occurrences };
199
+ }
200
+
201
+ module.exports = { id, meta, runInPage };
@@ -0,0 +1,110 @@
1
+ 'use strict';
2
+
3
+ /**
4
+ * @check aria-roles-valid
5
+ * @atomic true
6
+ * @summary An explicit role attribute must resolve to a real, non-abstract ARIA role
7
+ * @standard WCAG 2.2
8
+ * @sc 4.1.2
9
+ * @applicability
10
+ * Applies to any element with a non-empty role="" attribute in the composed DOM.
11
+ * @expectation
12
+ * The role attribute's first token (the role actually used by assistive technology;
13
+ * later space-separated tokens are author-supplied fallbacks and are not evaluated
14
+ * here) must be a real WAI-ARIA role name, and must not be an abstract role
15
+ * (abstract roles exist only for the specification's own role taxonomy and must
16
+ * never be used directly in markup).
17
+ * @implementation-notes
18
+ * - Unlike this engine's accessible-name rules, ARIA validity is a static markup
19
+ * property independent of current visibility/eligibility, so this rule does not
20
+ * gate on isAccTreeEligible: an invalid role is a defect whether or not the
21
+ * element happens to be hidden right now.
22
+ */
23
+
24
+ const id = 'aria-roles-valid';
25
+
26
+ const meta = {
27
+ title: 'role attribute must be a valid, non-abstract ARIA role',
28
+ description: 'Checks that an explicit role="" attribute resolves to a real, non-abstract WAI-ARIA role.',
29
+ i18n: {
30
+ titleKey: 'ariaRolesValid_title',
31
+ descriptionKey: 'ariaRolesValid_description'
32
+ },
33
+ helpUrl: null,
34
+ tags: ['wcag2a', 'wcag412', 'aria', 'structure', 'atomic', 'automatic'],
35
+ wcagSc: ['4.1.2'],
36
+ normativeMappings: [
37
+ { standard: 'WCAG', version: '2.2', requirement: '4.1.2', title: 'Name, Role, Value', conformanceLevel: 'A' }
38
+ ],
39
+ defaultSeverity: 'serious',
40
+ category: 'robust',
41
+ type: 'automatic',
42
+ defaultConfidence: 'high',
43
+ coverage: { facetsBySc: { '4.1.2': ['aria-role-valid'] } }
44
+ };
45
+
46
+ function runInPage(ctx) {
47
+ const { document, root, helpers, rule } = ctx;
48
+ const safeRoot = root || document;
49
+
50
+ const ariaHelpers = helpers && helpers.aria ? helpers.aria : null;
51
+ if (!ariaHelpers) {
52
+ return { ruleId: rule.ruleId, outcome: 'notApplicable', severity: 'minor', occurrences: [] };
53
+ }
54
+
55
+ const nodes = helpers.queryAllSmart ? helpers.queryAllSmart('[role]', safeRoot) : helpers.queryAll('[role]', safeRoot);
56
+
57
+ const occurrences = [];
58
+ let applicableCount = 0;
59
+
60
+ for (const el of nodes) {
61
+ if (!el || !el.getAttribute) continue;
62
+
63
+ const role = ariaHelpers.getExplicitRole(el);
64
+ if (!role) continue; // role="" or whitespace-only: not this rule's concern
65
+
66
+ applicableCount += 1;
67
+
68
+ const isAbstract = ariaHelpers.isAbstractRole(role);
69
+ const isKnown = ariaHelpers.isKnownRole(role);
70
+
71
+ if (isKnown && !isAbstract) continue;
72
+
73
+ const reasonCode = !isKnown ? 'ARIA_ROLE_INVALID' : 'ARIA_ROLE_ABSTRACT';
74
+ const stableSelector = helpers.buildSelector ? helpers.buildSelector(el) : 'html';
75
+ const html = helpers.getOuterHtmlSnippet ? helpers.getOuterHtmlSnippet(el) : (el.outerHTML || '');
76
+
77
+ occurrences.push({
78
+ selector: stableSelector,
79
+ html,
80
+ summary: !isKnown
81
+ ? 'The role attribute value is not a recognized ARIA role.'
82
+ : 'The role attribute value is an abstract ARIA role, which must not be used directly.',
83
+ hint: !isKnown
84
+ ? 'Use a valid ARIA role token, or remove the role attribute if none applies.'
85
+ : 'Replace this abstract role with a concrete role appropriate for the widget/structure.',
86
+ i18n: {
87
+ summaryKey: !isKnown
88
+ ? 'ariaRolesValid_summary_invalid'
89
+ : 'ariaRolesValid_summary_abstract',
90
+ hintKey: !isKnown
91
+ ? 'ariaRolesValid_hint_invalid'
92
+ : 'ariaRolesValid_hint_abstract',
93
+ params: { role }
94
+ },
95
+ data: {
96
+ details: { reasonCode, role }
97
+ }
98
+ });
99
+ }
100
+
101
+ if (applicableCount === 0) {
102
+ return { ruleId: rule.ruleId, outcome: 'notApplicable', severity: 'minor', occurrences: [] };
103
+ }
104
+ if (occurrences.length) {
105
+ return { ruleId: rule.ruleId, outcome: 'fail', severity: rule.defaultSeverity || 'serious', occurrences };
106
+ }
107
+ return { ruleId: rule.ruleId, outcome: 'pass', severity: 'minor', occurrences: [] };
108
+ }
109
+
110
+ module.exports = { id, meta, runInPage };
@@ -0,0 +1,123 @@
1
+ 'use strict';
2
+
3
+ /**
4
+ * @check aria-valid-attr-value
5
+ * @atomic true
6
+ * @summary Every recognized aria-* attribute must have a value matching its declared type
7
+ * @standard WCAG 2.2
8
+ * @sc 4.1.2
9
+ * @applicability
10
+ * Applies to any element carrying at least one recognized aria-* attribute
11
+ * (unrecognized attribute names are aria-valid-attr's concern,
12
+ * not evaluated here).
13
+ * @expectation
14
+ * Each attribute's value conforms to its WAI-ARIA-declared value type:
15
+ * boolean ("true"/"false"), tristate ("true"/"false"/"mixed"), a token
16
+ * from a fixed enumerated set, an integer, a real number, or a non-empty
17
+ * ID reference (list) that resolves to an existing element in the
18
+ * document.
19
+ * @implementation-notes
20
+ * - Not gated on isAccTreeEligible: this is a static markup property.
21
+ * - ID-reference resolution (added 2026-07-20, see aria-helpers.js's
22
+ * idExists) only flags idref-list attributes (aria-labelledby,
23
+ * aria-describedby, aria-controls, aria-owns, etc.) when NONE of the
24
+ * space-separated ids resolve — a partially-dangling list (some ids
25
+ * exist, some don't) is left unflagged. Verified 2026-07-21 directly
26
+ * against a widely-used reference engine's own `validateAttrValue` source:
27
+ * this is not a conservative guess, it's an exact match for that engine's
28
+ * own behavior (`idrefs(vNode, attr).some(node => !!node)` — that engine
29
+ * itself only invalidates when every token fails to resolve). Single-idref
30
+ * attributes (aria-activedescendant, aria-errormessage) are flagged
31
+ * whenever their one id doesn't resolve, also matching that engine's `idref`
32
+ * case exactly.
33
+ */
34
+
35
+ const id = 'aria-valid-attr-value';
36
+
37
+ const meta = {
38
+ title: 'aria-* attribute values must match their declared type',
39
+ description: 'Checks that every recognized aria-* attribute has a value conforming to its WAI-ARIA-declared value type (boolean, tristate, token, integer, number, or ID reference).',
40
+ i18n: {
41
+ titleKey: 'ariaValidAttrValue_title',
42
+ descriptionKey: 'ariaValidAttrValue_description'
43
+ },
44
+ helpUrl: null,
45
+ tags: ['wcag2a', 'wcag412', 'aria', 'structure', 'atomic', 'automatic'],
46
+ wcagSc: ['4.1.2'],
47
+ normativeMappings: [
48
+ { standard: 'WCAG', version: '2.2', requirement: '4.1.2', title: 'Name, Role, Value', conformanceLevel: 'A' }
49
+ ],
50
+ defaultSeverity: 'serious',
51
+ category: 'robust',
52
+ type: 'automatic',
53
+ defaultConfidence: 'high',
54
+ coverage: { facetsBySc: { '4.1.2': ['aria-attr-value-valid'] } }
55
+ };
56
+
57
+ function runInPage(ctx) {
58
+ const { document, root, helpers, rule } = ctx;
59
+ const safeRoot = root || document;
60
+
61
+ const ariaHelpers = helpers && helpers.aria ? helpers.aria : null;
62
+ if (!ariaHelpers) {
63
+ return { ruleId: rule.ruleId, outcome: 'notApplicable', severity: 'minor', occurrences: [] };
64
+ }
65
+
66
+ const nodes = helpers.queryAllSmart ? helpers.queryAllSmart('*', safeRoot) : helpers.queryAll('*', safeRoot);
67
+
68
+ const occurrences = [];
69
+ let applicableCount = 0;
70
+
71
+ for (const el of nodes) {
72
+ if (!el || !el.attributes || !el.getAttribute) continue;
73
+
74
+ let invalid = null;
75
+ const attrs = el.attributes;
76
+ for (let i = 0; i < attrs.length; i++) {
77
+ const name = String(attrs[i].name || '').toLowerCase();
78
+ if (name.slice(0, 5) !== 'aria-') continue;
79
+ if (!ariaHelpers.isValidAriaAttrName(name)) continue; // aria-valid-attr's concern
80
+
81
+ applicableCount += 1;
82
+
83
+ const rawValue = el.getAttribute(name);
84
+ const result = ariaHelpers.validateAttrValue(name, rawValue);
85
+ if (!result.valid) {
86
+ if (!invalid) invalid = [];
87
+ invalid.push({ name, value: rawValue == null ? '' : String(rawValue), reason: result.reason });
88
+ }
89
+ }
90
+
91
+ if (!invalid || !invalid.length) continue;
92
+
93
+ const stableSelector = helpers.buildSelector ? helpers.buildSelector(el) : 'html';
94
+ const html = helpers.getOuterHtmlSnippet ? helpers.getOuterHtmlSnippet(el) : (el.outerHTML || '');
95
+
96
+ for (const item of invalid) {
97
+ occurrences.push({
98
+ selector: stableSelector,
99
+ html,
100
+ summary: 'This element has an ARIA attribute with an invalid value.',
101
+ hint: 'Use a value that matches the attribute’s expected type (see the WAI-ARIA specification for this attribute).',
102
+ i18n: {
103
+ summaryKey: 'ariaValidAttrValue_summary_fail',
104
+ hintKey: 'ariaValidAttrValue_hint_fail',
105
+ params: { attr: item.name, value: item.value }
106
+ },
107
+ data: {
108
+ details: { reasonCode: 'ARIA_ATTR_VALUE_INVALID', attr: item.name, value: item.value, valueReason: item.reason }
109
+ }
110
+ });
111
+ }
112
+ }
113
+
114
+ if (applicableCount === 0) {
115
+ return { ruleId: rule.ruleId, outcome: 'notApplicable', severity: 'minor', occurrences: [] };
116
+ }
117
+ if (occurrences.length) {
118
+ return { ruleId: rule.ruleId, outcome: 'fail', severity: rule.defaultSeverity || 'serious', occurrences };
119
+ }
120
+ return { ruleId: rule.ruleId, outcome: 'pass', severity: 'minor', occurrences: [] };
121
+ }
122
+
123
+ module.exports = { id, meta, runInPage };
@@ -0,0 +1,109 @@
1
+ 'use strict';
2
+
3
+ /**
4
+ * @check aria-valid-attr
5
+ * @atomic true
6
+ * @summary Every aria-* attribute present must be a real, defined ARIA attribute
7
+ * @standard WCAG 2.2
8
+ * @sc 4.1.2
9
+ * @applicability
10
+ * Applies to any element in the composed DOM that carries at least one
11
+ * attribute whose name starts with "aria-".
12
+ * @expectation
13
+ * Each aria-* attribute name is a real attribute defined by the WAI-ARIA
14
+ * specification (catches typos / made-up attribute names, which are
15
+ * silently ignored by assistive technology and therefore a real,
16
+ * deterministic defect).
17
+ * @implementation-notes
18
+ * - Distinct from aria-valid-attr-value (which validates the VALUE
19
+ * of a recognized attribute) — this rule only validates the attribute
20
+ * NAME.
21
+ * - Not gated on isAccTreeEligible: this is a static markup property.
22
+ */
23
+
24
+ const id = 'aria-valid-attr';
25
+
26
+ const meta = {
27
+ title: 'aria-* attributes must be real, defined ARIA attributes',
28
+ description: 'Checks that every aria-* attribute name present in the DOM is a real attribute defined by the WAI-ARIA specification.',
29
+ i18n: {
30
+ titleKey: 'ariaValidAttr_title',
31
+ descriptionKey: 'ariaValidAttr_description'
32
+ },
33
+ helpUrl: null,
34
+ tags: ['wcag2a', 'wcag412', 'aria', 'structure', 'atomic', 'automatic'],
35
+ wcagSc: ['4.1.2'],
36
+ normativeMappings: [
37
+ { standard: 'WCAG', version: '2.2', requirement: '4.1.2', title: 'Name, Role, Value', conformanceLevel: 'A' }
38
+ ],
39
+ defaultSeverity: 'serious',
40
+ category: 'robust',
41
+ type: 'automatic',
42
+ defaultConfidence: 'high',
43
+ coverage: { facetsBySc: { '4.1.2': ['aria-attr-name-valid'] } }
44
+ };
45
+
46
+ function runInPage(ctx) {
47
+ const { document, root, helpers, rule } = ctx;
48
+ const safeRoot = root || document;
49
+
50
+ const ariaHelpers = helpers && helpers.aria ? helpers.aria : null;
51
+ if (!ariaHelpers) {
52
+ return { ruleId: rule.ruleId, outcome: 'notApplicable', severity: 'minor', occurrences: [] };
53
+ }
54
+
55
+ const nodes = helpers.queryAllSmart ? helpers.queryAllSmart('*', safeRoot) : helpers.queryAll('*', safeRoot);
56
+
57
+ const occurrences = [];
58
+ let applicableCount = 0;
59
+
60
+ for (const el of nodes) {
61
+ if (!el || !el.attributes) continue;
62
+
63
+ let invalidNames = null;
64
+ const attrs = el.attributes;
65
+ for (let i = 0; i < attrs.length; i++) {
66
+ const name = String(attrs[i].name || '').toLowerCase();
67
+ if (name.slice(0, 5) !== 'aria-') continue;
68
+
69
+ applicableCount += 1;
70
+
71
+ if (!ariaHelpers.isValidAriaAttrName(name)) {
72
+ if (!invalidNames) invalidNames = [];
73
+ invalidNames.push(name);
74
+ }
75
+ }
76
+
77
+ if (!invalidNames || !invalidNames.length) continue;
78
+
79
+ const stableSelector = helpers.buildSelector ? helpers.buildSelector(el) : 'html';
80
+ const html = helpers.getOuterHtmlSnippet ? helpers.getOuterHtmlSnippet(el) : (el.outerHTML || '');
81
+
82
+ for (const name of invalidNames) {
83
+ occurrences.push({
84
+ selector: stableSelector,
85
+ html,
86
+ summary: 'This element has an attribute that is not a recognized ARIA attribute.',
87
+ hint: 'Correct the attribute name (check for typos), or remove it if not needed.',
88
+ i18n: {
89
+ summaryKey: 'ariaValidAttr_summary_fail',
90
+ hintKey: 'ariaValidAttr_hint_fail',
91
+ params: { attr: name }
92
+ },
93
+ data: {
94
+ details: { reasonCode: 'ARIA_ATTR_INVALID', attr: name }
95
+ }
96
+ });
97
+ }
98
+ }
99
+
100
+ if (applicableCount === 0) {
101
+ return { ruleId: rule.ruleId, outcome: 'notApplicable', severity: 'minor', occurrences: [] };
102
+ }
103
+ if (occurrences.length) {
104
+ return { ruleId: rule.ruleId, outcome: 'fail', severity: rule.defaultSeverity || 'serious', occurrences };
105
+ }
106
+ return { ruleId: rule.ruleId, outcome: 'pass', severity: 'minor', occurrences: [] };
107
+ }
108
+
109
+ module.exports = { id, meta, runInPage };
@@ -0,0 +1,134 @@
1
+ 'use strict';
2
+
3
+ /**
4
+ * @check autocomplete-valid
5
+ * @atomic true
6
+ * @summary A non-empty autocomplete attribute must follow the WHATWG autofill grammar
7
+ * @standard WCAG 2.2
8
+ * @sc 1.3.5
9
+ * @applicability
10
+ * Applies to form controls (input, select, textarea) with a non-empty
11
+ * autocomplete attribute.
12
+ * @expectation
13
+ * The value is "on"/"off" alone, or a well-formed autofill detail
14
+ * token list: an optional "section-*" token, then an optional
15
+ * "shipping"/"billing" token, then an optional contact-modality token
16
+ * (home/work/mobile/fax/pager/impp), then exactly one recognized
17
+ * field-name token (name, email, street-address, cc-number, tel, ...),
18
+ * optionally followed by "webauthn". A malformed value means the field
19
+ * is not reliably identified for assistive technology that relies on
20
+ * autocomplete to describe the expected input purpose.
21
+ * @implementation-notes
22
+ * - Implements the structural shape of the WHATWG autofill grammar
23
+ * (section/mode/contact-modality prefixes + one field-name token, in
24
+ * order) with the full fixed field-name vocabulary from the HTML
25
+ * Standard, rather than validating every field-specific constraint
26
+ * (e.g. which contact-modality tokens are legal for which field
27
+ * names) — matches this engine's established "deliberately scoped"
28
+ * precedent (see aria-helpers.js) for keeping high-confidence fail
29
+ * without reimplementing the entire spec.
30
+ */
31
+
32
+ const id = 'autocomplete-valid';
33
+
34
+ const meta = {
35
+ title: 'autocomplete attribute must be a valid autofill value',
36
+ description: 'Checks that a non-empty autocomplete attribute is "on"/"off" or a well-formed autofill detail token list.',
37
+ i18n: {
38
+ titleKey: 'autocompleteValid_title',
39
+ descriptionKey: 'autocompleteValid_description'
40
+ },
41
+ helpUrl: null,
42
+ tags: ['wcag21aa', 'wcag135', 'forms', 'atomic', 'automatic'],
43
+ wcagSc: ['1.3.5'],
44
+ normativeMappings: [
45
+ { standard: 'WCAG', version: '2.2', requirement: '1.3.5', title: 'Identify Input Purpose', conformanceLevel: 'AA' }
46
+ ],
47
+ defaultSeverity: 'moderate',
48
+ category: 'perceivable',
49
+ type: 'automatic',
50
+ defaultConfidence: 'high',
51
+ coverage: { facetsBySc: { '1.3.5': ['autocomplete-valid'] } }
52
+ };
53
+
54
+ function runInPage(ctx) {
55
+ const { document, root, helpers, rule } = ctx;
56
+ const safeRoot = root || document;
57
+
58
+ // Declared inside runInPage — see scripts/build-core.js header
59
+ // ("runInPage MUST be self-contained").
60
+ const FIELD_NAMES = new Set([
61
+ 'name', 'honorific-prefix', 'given-name', 'additional-name', 'family-name', 'honorific-suffix',
62
+ 'nickname', 'username', 'new-password', 'current-password', 'one-time-code', 'organization-title',
63
+ 'organization', 'street-address', 'address-line1', 'address-line2', 'address-line3',
64
+ 'address-level4', 'address-level3', 'address-level2', 'address-level1', 'country', 'country-name',
65
+ 'postal-code', 'cc-name', 'cc-given-name', 'cc-additional-name', 'cc-family-name', 'cc-number',
66
+ 'cc-exp', 'cc-exp-month', 'cc-exp-year', 'cc-csc', 'cc-type', 'transaction-currency',
67
+ 'transaction-amount', 'language', 'bday', 'bday-day', 'bday-month', 'bday-year', 'sex',
68
+ 'tel', 'tel-country-code', 'tel-national', 'tel-area-code', 'tel-local', 'tel-extension',
69
+ 'email', 'impp', 'url', 'photo'
70
+ ]);
71
+ const CONTACT_MODALITY = new Set(['home', 'work', 'mobile', 'fax', 'pager', 'impp']);
72
+
73
+ function isValidAutocomplete(raw) {
74
+ const tokens = raw.trim().toLowerCase().split(/\s+/).filter(Boolean);
75
+ if (!tokens.length) return false;
76
+ if (tokens.length === 1 && (tokens[0] === 'on' || tokens[0] === 'off')) return true;
77
+
78
+ let i = 0;
79
+ if (tokens[i] && tokens[i].startsWith('section-') && tokens[i].length > 'section-'.length) i += 1;
80
+ if (tokens[i] === 'shipping' || tokens[i] === 'billing') i += 1;
81
+ if (CONTACT_MODALITY.has(tokens[i])) i += 1;
82
+
83
+ let end = tokens.length;
84
+ if (tokens[end - 1] === 'webauthn') end -= 1;
85
+
86
+ const remaining = tokens.slice(i, end);
87
+ if (remaining.length !== 1) return false;
88
+ return FIELD_NAMES.has(remaining[0]);
89
+ }
90
+
91
+ const nodes = helpers.queryAllSmart ? helpers.queryAllSmart('input, select, textarea', safeRoot) : helpers.queryAll('input, select, textarea', safeRoot);
92
+
93
+ const occurrences = [];
94
+ let applicableCount = 0;
95
+
96
+ for (const el of nodes) {
97
+ if (!el || !el.getAttribute) continue;
98
+ const raw = String(el.getAttribute('autocomplete') || '').trim();
99
+ if (!raw) continue;
100
+
101
+ applicableCount += 1;
102
+
103
+ if (isValidAutocomplete(raw)) continue;
104
+
105
+ const tag = el.tagName.toLowerCase();
106
+ const stableSelector = helpers.buildSelector ? helpers.buildSelector(el) : 'html';
107
+ const html = helpers.getOuterHtmlSnippet ? helpers.getOuterHtmlSnippet(el) : (el.outerHTML || '');
108
+
109
+ occurrences.push({
110
+ selector: stableSelector,
111
+ html,
112
+ summary: 'This autocomplete attribute value is not a valid autofill value.',
113
+ hint: 'Use "on"/"off", or a valid autofill token list (e.g. "shipping street-address", "cc-number").',
114
+ i18n: {
115
+ summaryKey: 'autocompleteValid_summary_fail',
116
+ hintKey: 'autocompleteValid_hint_fail',
117
+ params: { element: tag, value: raw }
118
+ },
119
+ data: {
120
+ details: { reasonCode: 'AUTOCOMPLETE_VALUE_INVALID', element: tag, value: raw }
121
+ }
122
+ });
123
+ }
124
+
125
+ if (applicableCount === 0) {
126
+ return { ruleId: rule.ruleId, outcome: 'notApplicable', severity: 'minor', occurrences: [] };
127
+ }
128
+ if (occurrences.length) {
129
+ return { ruleId: rule.ruleId, outcome: 'fail', severity: rule.defaultSeverity || 'moderate', occurrences };
130
+ }
131
+ return { ruleId: rule.ruleId, outcome: 'pass', severity: 'minor', occurrences: [] };
132
+ }
133
+
134
+ module.exports = { id, meta, runInPage };