@surea11y/core 1.6.0 → 1.7.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 (59) hide show
  1. package/CHANGELOG.md +47 -0
  2. package/README.md +24 -38
  3. package/docs/ACT_RULE_MAPPING.md +8 -6
  4. package/docs/API_STABILITY.md +51 -3
  5. package/docs/BINDING_AUTHORS_GUIDE.md +104 -2
  6. package/docs/DESIGN_CHALLENGES.md +66 -0
  7. package/docs/EARL.md +100 -0
  8. package/docs/ENGINE_OPTIONS.md +28 -2
  9. package/docs/INTEGRATION.md +4 -2
  10. package/docs/LIMITATIONS.md +3 -1
  11. package/docs/OUTPUT_SCHEMA.md +44 -6
  12. package/docs/POLICY.md +1 -1
  13. package/docs/RULE_AUTHORING.md +11 -12
  14. package/docs/RULE_CATALOG.md +76 -26
  15. package/docs/RULE_HELPERS.md +333 -0
  16. package/docs/RULE_TAXONOMY.md +25 -4
  17. package/docs/SARIF.md +21 -2
  18. package/docs/WCAG_CONFORMANCE.md +9 -1
  19. package/package.json +9 -3
  20. package/src/checks/automatic/aria-allowed-attr.js +6 -0
  21. package/src/checks/automatic/aria-allowed-role.js +32 -23
  22. package/src/checks/automatic/aria-braille-equivalent.js +18 -10
  23. package/src/checks/automatic/aria-conditional-attr.js +17 -10
  24. package/src/checks/automatic/aria-deprecated-role.js +12 -0
  25. package/src/checks/automatic/aria-hidden-body.js +1 -1
  26. package/src/checks/automatic/aria-prohibited-attr.js +5 -0
  27. package/src/checks/automatic/aria-prohibited-children.js +6 -6
  28. package/src/checks/automatic/aria-required-attr.js +59 -12
  29. package/src/checks/automatic/aria-required-children.js +33 -16
  30. package/src/checks/automatic/aria-required-parent.js +32 -6
  31. package/src/checks/automatic/aria-role-name-present.js +1 -1
  32. package/src/checks/automatic/aria-roles-valid.js +52 -21
  33. package/src/checks/automatic/aria-valid-attr-value.js +74 -21
  34. package/src/checks/automatic/aria-valid-attr.js +14 -9
  35. package/src/checks/automatic/avoid-inline-spacing.js +133 -6
  36. package/src/checks/automatic/contrast-computable.js +10 -0
  37. package/src/checks/automatic/contrast-enhanced.js +12 -0
  38. package/src/checks/automatic/contrast-minimum.js +12 -0
  39. package/src/checks/automatic/css-orientation-lock.js +42 -5
  40. package/src/checks/automatic/duplicate-id-aria.js +5 -0
  41. package/src/checks/automatic/duplicate-id.js +13 -8
  42. package/src/checks/automatic/form-control-single-label.js +9 -0
  43. package/src/checks/automatic/identical-iframes-same-purpose.js +229 -0
  44. package/src/checks/automatic/iframe-focusable-content.js +5 -0
  45. package/src/checks/automatic/label-in-name.js +38 -56
  46. package/src/checks/automatic/link-in-text-block.js +279 -23
  47. package/src/checks/automatic/target-size-minimum.js +84 -5
  48. package/src/checks/automatic/td-has-header.js +19 -18
  49. package/src/checks/manual/form-control-label-quality-manual.js +134 -24
  50. package/src/checks/manual/landmark-complementary-is-top-level-manual.js +231 -0
  51. package/src/checks/manual/password-paste-enabled-manual.js +255 -0
  52. package/src/core.js +3863 -44184
  53. package/src/earl.js +144 -0
  54. package/src/sarif.js +22 -2
  55. package/surea11y.browser.js +10 -41039
  56. package/surea11y.i18n.de.js +2 -21
  57. package/surea11y.i18n.es.js +2 -21
  58. package/surea11y.i18n.fr.js +2 -21
  59. /package/src/checks/automatic/{role-img-alt-present.js → role-img-text-alternative-present.js} +0 -0
@@ -11,13 +11,34 @@ It reflects **what already exists in the rules and tests**, not theory.
11
11
  Encoded by: `meta.type`
12
12
 
13
13
  - `automatic`
14
- - Rule makes a **normative decision**
15
- - Allowed outcomes: `pass`, `fail`, `notApplicable`, and `cantTell` as a defensive fallback only (e.g. an internal-failure safety net, or a computability gate a rule can't resolve — see `contrast-minimum.js`/`contrast-enhanced.js`/`contrast-computable.js`/`target-size-minimum.js`), never as its primary intended path
14
+ - Rule **decides deterministically**, with no heuristics and no guessing
15
+ - Allowed outcomes: `pass`, `fail`, `notApplicable`, `cantTell`
16
+ - `cantTell` is the primary path in two distinct cases, and a defensive
17
+ fallback in a third:
18
+ - the rule decides that a real violation exists, but the violation does
19
+ not on its own establish that the mapped criterion fails — an ARIA
20
+ author requirement the exposed name, role and value survive
21
+ (`aria-valid-attr`, `aria-braille-equivalent`,
22
+ `aria-conditional-attr`), or the graded tier of a rule that fails
23
+ elsewhere (`aria-required-attr`, `aria-roles-valid`)
24
+ - the rule decides deterministically but claims no Success Criterion at
25
+ all (`aria-allowed-role`, tagged `best-practice`)
26
+ - the rule asks whether something is PRESENT, and its absence conveys
27
+ nothing false, while the question of whether what IS present is valid
28
+ belongs to a sibling rule that still fails
29
+ (`aria-required-children`, paired with `aria-prohibited-children`)
30
+ - a computability gate the rule cannot resolve, or an internal-failure
31
+ safety net (`contrast-minimum.js`/`contrast-enhanced.js`/
32
+ `contrast-computable.js`/`target-size-minimum.js`)
16
33
  - `manual`
17
- - Rule signals **human review required**
34
+ - Rule signals **human review required**: it cannot decide at all
18
35
  - Allowed outcomes: `cantTell`, `notApplicable`
19
36
 
20
- Manual rules MUST NOT make normative failure decisions.
37
+ Manual rules MUST NOT make normative failure decisions. The dividing line
38
+ between the two types is whether the rule can decide, not which outcome it
39
+ reports: an automatic rule that reports `cantTell` has decided, and is saying
40
+ what it found; a manual rule reports `cantTell` because the question is not
41
+ decidable from markup.
21
42
 
22
43
  ---
23
44
 
package/docs/SARIF.md CHANGED
@@ -14,11 +14,30 @@ See [`CI_INTEGRATIONS.md`](./CI_INTEGRATIONS.md) for a ready-to-paste GitHub Act
14
14
 
15
15
  ## What becomes a SARIF result
16
16
 
17
- Only `fail`/`cantTell` occurrences produce SARIF results — a `pass`/`notApplicable` check has no occurrences to report at all (same "violations only" framing as [`REPORT.md`](./REPORT.md)'s HTML report).
17
+ Only `fail`/`cantTell` occurrences produce SARIF results (same "violations only" framing as [`REPORT.md`](./REPORT.md)'s HTML report).
18
+
19
+ A `notApplicable` check is not always empty: a rule may attach one occurrence explaining why it had nothing to judge, which the contrast rules do when no text had a computable background. Those never become results — a consumer treats every result as an alert, and "this was not evaluated" is not one — but they are not dropped either. They are carried as `note`-level entries in `runs[0].invocations[0].toolExecutionNotices`, each naming the rule it came from via `associatedRule.id`:
20
+
21
+ ```json
22
+ "invocations": [
23
+ {
24
+ "executionSuccessful": true,
25
+ "toolExecutionNotices": [
26
+ {
27
+ "level": "note",
28
+ "message": { "text": "No eligible text had computable contrast (eligible text nodes: 13). See the contrast computability rule for details." },
29
+ "associatedRule": { "id": "contrast-minimum" }
30
+ }
31
+ ]
32
+ }
33
+ ]
34
+ ```
35
+
36
+ That keeps a SARIF-only pipeline from reading silence as a clean bill of health: no contrast alerts can mean the page is fine, or that contrast was never computable, and only the notice separates the two. The block is emitted only when there is something to say, so a run with nothing to report has no `invocations` key at all.
18
37
 
19
38
  | Engine outcome | SARIF `level` | Meaning |
20
39
  |---|---|---|
21
- | `fail` | `error` | Deterministic, high-confidence violation — the CI-gating case. |
40
+ | `fail` | `error` | Deterministic violation — the CI-gating case. |
22
41
  | `cantTell` | `warning` | Needs human review — surfaced, but shouldn't block a build on its own. |
23
42
 
24
43
  Every rule that ran (regardless of whether it produced a result) is listed once in `runs[0].tool.driver.rules`, with `defaultConfiguration.level` set from the rule's `type`: `automatic` (fail-capable) → `error`, `manual` (capped at `cantTell`) → `warning`.
@@ -44,6 +44,14 @@ only its own origin tag — so a full conformance target is a union of tag sets.
44
44
  ready-made sets per version, including the one criterion WCAG 2.2 removed rather than
45
45
  added.
46
46
 
47
+ **The removed criterion is handled for you.** Every run resolves a target WCAG version
48
+ (`engineOptions.wcagVersion`, else whatever your version tags imply, else `2.2`) and
49
+ reports it back as `engine.wcagVersion`. Under a 2.2 target, a rule mapped only to SC
50
+ 4.1.1 Parsing cannot report `fail` — it runs, reports its occurrences, and comes back
51
+ `cantTell` with a `wcagVersionScope` field explaining the coercion. So a default scan
52
+ never gates on a criterion WCAG 2.2 does not contain, and a 2.0/2.1 scan still gets a
53
+ real 4.1.1 verdict.
54
+
47
55
  Composites, unlike atomic rules, *are* filtered cumulatively. The runner reads the
48
56
  highest level named in `tags` and drops every composite above it, so requesting
49
57
  `['wcag2a', 'wcag2aa']` returns no `rulesResults` entry for an AAA-only SC. That is
@@ -58,7 +66,7 @@ suppression.
58
66
  No automated tool — this one included — can certify full WCAG conformance. That's not a limitation specific to surea11y; it's inherent to WCAG itself; a meaningful fraction of Success Criteria require human judgment (is this alt text *accurate*, not just *present*; is this error message *understandable*) or dynamic testing this engine's static-DOM-scan architecture cannot do at all (keyboard-trap detection, real layout/reflow at zoom). See [`LIMITATIONS.md`](./LIMITATIONS.md) for the full, explicit list of what's out of scope and why.
59
67
 
60
68
  What surea11y *can* give you, honestly:
61
- - Every `fail` is a real, deterministic, normative violation — never a guess.
69
+ - Every `fail` is a real, deterministic, normative violation under the version you targeted — never a guess.
62
70
  - Every `cantTell` is an explicit flag for human review, not a swallowed uncertainty.
63
71
  - The facet coverage table tells you exactly which parts of which SCs have zero automated coverage, so you know where a `pass` is silent rather than exhaustive.
64
72
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@surea11y/core",
3
- "version": "1.6.0",
3
+ "version": "1.7.0",
4
4
  "description": "Deterministic WCAG 2.2 accessibility engine that tells you what it can't tell you. Zero dependencies.",
5
5
  "keywords": [
6
6
  "accessibility",
@@ -30,7 +30,9 @@
30
30
  "./baseline": "./src/baseline.js",
31
31
  "./report": "./src/report.js",
32
32
  "./sarif": "./src/sarif.js",
33
+ "./earl": "./src/earl.js",
33
34
  "./browser": "./surea11y.browser.js",
35
+ "./i18n/*": "./surea11y.i18n.*.js",
34
36
  "./package.json": "./package.json"
35
37
  },
36
38
  "author": "Jorge Rumoroso",
@@ -55,6 +57,7 @@
55
57
  "src/baseline.js",
56
58
  "src/report.js",
57
59
  "src/sarif.js",
60
+ "src/earl.js",
58
61
  "src/checks/**/*.js",
59
62
  "surea11y.browser.js",
60
63
  "surea11y.i18n.*.js",
@@ -74,7 +77,7 @@
74
77
  "format:check": "prettier --check \"src/**/*.js\" \"scripts/**/*.js\" \"tests/**/*.js\" \"eslint.config.js\"",
75
78
  "build": "node scripts/build-core.js && node scripts/build-browser.js",
76
79
  "pretest": "playwright install chromium",
77
- "test": "npm run format:check && npm run build && node scripts/run-tests.js",
80
+ "test": "npm run lint && npm run format:check && npm run build && npm run validate:rules && node scripts/run-tests.js",
78
81
  "test:coverage": "npm run build && node scripts/run-tests.js --experimental-test-coverage --test-coverage-include=src/**/*.js",
79
82
  "test:contrast-helpers": "node tests/contrast-helpers.test.js",
80
83
  "helpers-perf-bench": "node --expose-gc scripts/dom-helpers-perf-bench.js",
@@ -85,11 +88,13 @@
85
88
  "coverage": "node scripts/generate-wcag-coverage.js --rulesDir src/checks",
86
89
  "coverage:strict": "node scripts/generate-wcag-coverage.js --rulesDir src/checks --strictFacets",
87
90
  "coverage:check": "node scripts/generate-wcag-coverage.js --rulesDir src/checks --check",
91
+ "finding-ids": "npm run build && node scripts/generate-finding-ids.js",
88
92
  "fixtures:index": "node scripts/generate-fixture-index.js",
93
+ "fixtures:check": "node scripts/generate-fixture-index.js --check",
89
94
  "docs:rule-catalog": "npm run build && node scripts/generate-rule-catalog.js",
90
95
  "validate:automatic-rules": "npm run build && node scripts/validate-all-rules.js src/checks/automatic",
91
96
  "validate:manual-rules": "npm run build && node scripts/validate-all-rules.js src/checks/manual",
92
- "validate:rules": "npm run validate:automatic-rules && npm run validate:manual-rules",
97
+ "validate:rules": "npm run build && node scripts/validate-all-rules.js src/checks/automatic src/checks/manual",
93
98
  "i18n:new": "node scripts/i18n-scaffold.js",
94
99
  "i18n:sync": "node scripts/i18n-sync.js",
95
100
  "i18n:check": "node scripts/i18n-sync.js --check",
@@ -98,6 +103,7 @@
98
103
  "devDependencies": {
99
104
  "@eslint/js": "^10.0.1",
100
105
  "aria-query": "^5.3.2",
106
+ "esbuild": "^0.28.2",
101
107
  "eslint": "^10.8.0",
102
108
  "eslint-config-prettier": "^10.1.8",
103
109
  "globals": "^17.8.0",
@@ -934,6 +934,12 @@ function runInPage(ctx) {
934
934
  hintKey: 'ariaAllowedAttr_hint_cantTell',
935
935
  params: { attr: name, role }
936
936
  },
937
+ uncertainty: {
938
+ code: 'spec-only',
939
+ needed:
940
+ 'Whether the deprecated attribute is still honoured by the assistive technology in use.',
941
+ evidence: { attribute: name, role, status: 'deprecated-for-role' }
942
+ },
937
943
  data: {
938
944
  details: { reasonCode: 'ARIA_ATTR_DEPRECATED', attr: name, role }
939
945
  }
@@ -6,8 +6,7 @@
6
6
  * @check aria-allowed-role
7
7
  * @atomic true
8
8
  * @summary Explicit role must be permitted by the ARIA-in-HTML spec for its host element
9
- * @standard WCAG 2.2
10
- * @sc 4.1.2
9
+ * @standard Best Practices (no formal WCAG Success Criterion)
11
10
  * @applicability
12
11
  * Applies to elements with an explicit, valid, non-abstract role, where
13
12
  * the host element/attribute combination has an asserted permitted-roles
@@ -16,7 +15,18 @@
16
15
  * @expectation
17
16
  * The explicit role is one of the roles the ARIA-in-HTML specification
18
17
  * permits for that host element.
18
+ * Reported at CANTTELL rather than FAIL: ARIA-in-HTML's permitted-roles
19
+ * table is an author conformance requirement with no ACT rule and no WCAG
20
+ * mapping in any source. The role the author asked for is still the role
21
+ * assistive technology exposes, so whether the combination harms anyone
22
+ * depends on the widget, not on the table.
19
23
  * @implementation-notes
24
+ * - Not WCAG-normative. ARIA-in-HTML's permitted-roles table is an author
25
+ * conformance requirement of that specification; no ACT rule covers it and
26
+ * no source maps it to a Success Criterion, so the rule reports the
27
+ * violation without claiming a criterion is failed. Deterministic all the
28
+ * same, so it stays `type: 'automatic'` and keeps deciding rather than
29
+ * deferring to a reviewer -- see docs/RULE_TAXONOMY.md 1.1.
20
30
  * - Scoped to elements present in ALLOWED_ROLES_BY_ELEMENT;
21
31
  * elements without an asserted constraint are treated as "no constraint"
22
32
  * (not flagged) rather than guessed at, see that table's header comment.
@@ -36,22 +46,14 @@ const meta = {
36
46
  descriptionKey: 'ariaAllowedRole_description'
37
47
  },
38
48
  helpUrl: null,
39
- tags: ['wcag2a', 'wcag412', 'aria', 'structure', 'atomic', 'automatic'],
40
- wcagSc: ['4.1.2'],
41
- normativeMappings: [
42
- {
43
- standard: 'WCAG',
44
- version: '2.2',
45
- requirement: '4.1.2',
46
- title: 'Name, Role, Value',
47
- conformanceLevel: 'A'
48
- }
49
- ],
49
+ tags: ['best-practice', 'aria', 'structure', 'atomic', 'automatic'],
50
+ wcagSc: [],
51
+ normativeMappings: [],
50
52
  defaultSeverity: 'moderate',
51
53
  category: 'robust',
52
54
  type: 'automatic',
53
55
  defaultConfidence: 'high',
54
- coverage: { facetsBySc: { '4.1.2': ['aria-role-allowed-for-element'] } }
56
+ coverage: {}
55
57
  };
56
58
 
57
59
  function runInPage(ctx) {
@@ -93,6 +95,16 @@ function runInPage(ctx) {
93
95
  hintKey: 'ariaAllowedRole_hint_fail',
94
96
  params: { role, element: tag }
95
97
  },
98
+ uncertainty: {
99
+ code: 'spec-only',
100
+ needed: 'Whether the role misstates this element to assistive technology in practice.',
101
+ evidence: {
102
+ role,
103
+ element: tag,
104
+ source: 'ARIA in HTML permitted-roles table',
105
+ wcagSc: []
106
+ }
107
+ },
96
108
  data: {
97
109
  details: { reasonCode: 'ARIA_ROLE_NOT_ALLOWED_FOR_ELEMENT', role, element: tag }
98
110
  }
@@ -103,15 +115,12 @@ function runInPage(ctx) {
103
115
  if (applicableCount === 0) {
104
116
  return { ruleId: rule.ruleId, outcome: 'notApplicable', severity: 'minor', occurrences: [] };
105
117
  }
106
- if (occurrences.length) {
107
- return {
108
- ruleId: rule.ruleId,
109
- outcome: 'fail',
110
- severity: rule.defaultSeverity || 'moderate',
111
- occurrences
112
- };
113
- }
114
- return { ruleId: rule.ruleId, outcome: 'pass', severity: 'minor', occurrences: [] };
118
+ const resolved = helpers.resolveTieredOutcome(
119
+ [],
120
+ occurrences,
121
+ rule.defaultSeverity || 'moderate'
122
+ );
123
+ return { ruleId: rule.ruleId, ...resolved };
115
124
  }
116
125
 
117
126
  module.exports = { id, meta, runInPage };
@@ -23,6 +23,11 @@
23
23
  * Using either braille-specific attribute as the ONLY naming mechanism
24
24
  * leaves non-braille assistive technology (most screen readers, voice
25
25
  * control, etc.) with no accessible name/role description at all.
26
+ * Reported at CANTTELL rather than FAIL: aria-brailleroledescription
27
+ * without aria-roledescription reaches no user at all, and a missing
28
+ * accessible name is the naming rules' decision for the roles that require
29
+ * one. The braille attribute being unpaired is worth surfacing, but it is
30
+ * not itself a criterion failing.
26
31
  * @implementation-notes
27
32
  * - `aria-braillelabel`/`aria-brailleroledescription` do not participate
28
33
  * in the standard accessible-name computation, so
@@ -52,7 +57,7 @@ const meta = {
52
57
  conformanceLevel: 'A'
53
58
  }
54
59
  ],
55
- defaultSeverity: 'serious',
60
+ defaultSeverity: 'moderate',
56
61
  category: 'robust',
57
62
  type: 'automatic',
58
63
  defaultConfidence: 'high',
@@ -131,6 +136,12 @@ function runInPage(ctx) {
131
136
  hintKey: 'ariaBrailleEquivalent_hint_fail',
132
137
  params: { element: tag, attr: m.attr, requires: m.requires }
133
138
  },
139
+ uncertainty: {
140
+ code: 'spec-only',
141
+ needed:
142
+ 'Whether any user reaches this attribute, given no braille equivalent is exposed.',
143
+ evidence: { element: tag, attribute: m.attr, requires: m.requires }
144
+ },
134
145
  data: {
135
146
  details: {
136
147
  reasonCode: 'BRAILLE_ATTR_WITHOUT_EQUIVALENT',
@@ -146,15 +157,12 @@ function runInPage(ctx) {
146
157
  if (applicableCount === 0) {
147
158
  return { ruleId: rule.ruleId, outcome: 'notApplicable', severity: 'minor', occurrences: [] };
148
159
  }
149
- if (occurrences.length) {
150
- return {
151
- ruleId: rule.ruleId,
152
- outcome: 'fail',
153
- severity: rule.defaultSeverity || 'serious',
154
- occurrences
155
- };
156
- }
157
- return { ruleId: rule.ruleId, outcome: 'pass', severity: 'minor', occurrences: [] };
160
+ const resolved = helpers.resolveTieredOutcome(
161
+ [],
162
+ occurrences,
163
+ rule.defaultSeverity || 'moderate'
164
+ );
165
+ return { ruleId: rule.ruleId, ...resolved };
158
166
  }
159
167
 
160
168
  module.exports = { id, meta, runInPage };
@@ -17,6 +17,11 @@
17
17
  * An element with `aria-errormessage` but `aria-invalid` absent or
18
18
  * `"false"` silently drops the error message from the accessibility
19
19
  * tree, authors almost always intend it to be exposed.
20
+ * Reported at CANTTELL rather than FAIL: aria-errormessage is only
21
+ * exposed once aria-invalid is set, so the reference is currently inert.
22
+ * Whether that costs the user anything depends on whether the message is
23
+ * conveyed some other way (visible text next to the field, aria-describedby),
24
+ * which static markup does not settle.
20
25
  * @implementation-notes
21
26
  * - This is narrow: the broader space is a table of many
22
27
  * attribute/condition pairs. This rule implements only the one pairing
@@ -52,7 +57,7 @@ const meta = {
52
57
  conformanceLevel: 'A'
53
58
  }
54
59
  ],
55
- defaultSeverity: 'serious',
60
+ defaultSeverity: 'moderate',
56
61
  category: 'robust',
57
62
  type: 'automatic',
58
63
  defaultConfidence: 'high',
@@ -98,6 +103,11 @@ function runInPage(ctx) {
98
103
  hintKey: 'ariaConditionalAttr_hint_fail',
99
104
  params: { element: tag, ariaInvalid: invalidValue || '(absent)' }
100
105
  },
106
+ uncertainty: {
107
+ code: 'spec-only',
108
+ needed: 'Whether the field is ever in an invalid state that should expose this message.',
109
+ evidence: { element: tag, ariaInvalid: invalidValue || null }
110
+ },
101
111
  data: {
102
112
  details: {
103
113
  reasonCode: 'ARIA_ERRORMESSAGE_WITHOUT_TRUTHY_INVALID',
@@ -111,15 +121,12 @@ function runInPage(ctx) {
111
121
  if (applicableCount === 0) {
112
122
  return { ruleId: rule.ruleId, outcome: 'notApplicable', severity: 'minor', occurrences: [] };
113
123
  }
114
- if (occurrences.length) {
115
- return {
116
- ruleId: rule.ruleId,
117
- outcome: 'fail',
118
- severity: rule.defaultSeverity || 'serious',
119
- occurrences
120
- };
121
- }
122
- return { ruleId: rule.ruleId, outcome: 'pass', severity: 'minor', occurrences: [] };
124
+ const resolved = helpers.resolveTieredOutcome(
125
+ [],
126
+ occurrences,
127
+ rule.defaultSeverity || 'moderate'
128
+ );
129
+ return { ruleId: rule.ruleId, ...resolved };
123
130
  }
124
131
 
125
132
  module.exports = { id, meta, runInPage };
@@ -161,6 +161,12 @@ function runInPage(ctx) {
161
161
  hintKey: guidance.key,
162
162
  params: { role }
163
163
  },
164
+ uncertainty: {
165
+ code: 'spec-only',
166
+ needed:
167
+ 'Whether this user-agent-reserved role misleads the assistive technology in use.',
168
+ evidence: { role, ariaStatus: 'discouraged-for-authors' }
169
+ },
164
170
  data: {
165
171
  details: { reasonCode: 'ARIA_ROLE_AUTHOR_DISCOURAGED', role, guidance: guidance.text }
166
172
  }
@@ -178,6 +184,12 @@ function runInPage(ctx) {
178
184
  hintKey: guidance.key,
179
185
  params: { role }
180
186
  },
187
+ uncertainty: {
188
+ code: 'spec-only',
189
+ needed:
190
+ 'Whether the deprecated role is still honoured, and what it should be replaced with.',
191
+ evidence: { role, ariaStatus: 'deprecated' }
192
+ },
181
193
  data: {
182
194
  details: { reasonCode: 'ARIA_ROLE_DEPRECATED', role, guidance: guidance.text }
183
195
  }
@@ -33,7 +33,7 @@ const meta = {
33
33
  descriptionKey: 'ariaHiddenBody_description'
34
34
  },
35
35
  helpUrl: null,
36
- tags: ['wcag2a', 'wcag131', 'wcag412', 'structure', 'atomic', 'automatic'],
36
+ tags: ['wcag2a', 'wcag131', 'wcag412', 'aria', 'structure', 'atomic', 'automatic'],
37
37
  wcagSc: ['1.3.1', '4.1.2'],
38
38
  normativeMappings: [
39
39
  {
@@ -370,6 +370,11 @@ function runInPage(ctx) {
370
370
  hintKey: 'ariaProhibitedAttr_hint_cantTell_roleless',
371
371
  params: { attr, element: tag }
372
372
  },
373
+ uncertainty: {
374
+ code: 'judgement-required',
375
+ needed: 'Whether the element’s own content already serves as its label.',
376
+ evidence: { attribute: attr, element: tag, role: null, hasOwnContent: true }
377
+ },
373
378
  data: {
374
379
  details: {
375
380
  reasonCode: 'ARIA_ATTR_PROHIBITED_ROLELESS_NEEDS_REVIEW',
@@ -7,7 +7,7 @@
7
7
  * @atomic true
8
8
  * @summary Container roles must not own an accessible-tree child with a disallowed role
9
9
  * @standard WCAG 2.2
10
- * @sc 4.1.2
10
+ * @sc 1.3.1
11
11
  * @applicability
12
12
  * Applies to elements with an explicit, valid role that is one of the
13
13
  * container roles with a documented "required owned elements" entry
@@ -126,14 +126,14 @@ const meta = {
126
126
  descriptionKey: 'ariaProhibitedChildren_description'
127
127
  },
128
128
  helpUrl: null,
129
- tags: ['wcag2a', 'wcag412', 'aria', 'structure', 'atomic', 'automatic'],
130
- wcagSc: ['4.1.2'],
129
+ tags: ['wcag2a', 'wcag131', 'aria', 'structure', 'atomic', 'automatic'],
130
+ wcagSc: ['1.3.1'],
131
131
  normativeMappings: [
132
132
  {
133
133
  standard: 'WCAG',
134
134
  version: '2.2',
135
- requirement: '4.1.2',
136
- title: 'Name, Role, Value',
135
+ requirement: '1.3.1',
136
+ title: 'Info and Relationships',
137
137
  conformanceLevel: 'A'
138
138
  }
139
139
  ],
@@ -141,7 +141,7 @@ const meta = {
141
141
  category: 'robust',
142
142
  type: 'automatic',
143
143
  defaultConfidence: 'medium',
144
- coverage: { facetsBySc: { '4.1.2': ['aria-role-owned-children-allowed'] } }
144
+ coverage: { facetsBySc: { '1.3.1': ['aria-role-owned-children-allowed'] } }
145
145
  };
146
146
 
147
147
  function runInPage(ctx) {
@@ -19,8 +19,22 @@
19
19
  * native control's own state exposure already covers it; no aria-checked
20
20
  * is required. helpers.aria.getNativeRoleForElement resolves this).
21
21
  * @expectation
22
- * Every required aria-* attribute for that role is present (and non-empty).
22
+ * Every required state/property for that role is present and non-empty.
23
+ * Graded by whether ARIA supplies a stand-in for the missing attribute:
24
+ * - FAIL where it does not, so the state is simply not exposed
25
+ * (aria-checked on checkbox/radio/switch/menuitemcheckbox/menuitemradio,
26
+ * aria-valuenow on slider/scrollbar/meter and on a focusable separator).
27
+ * - CANTTELL where ARIA defines an implicit value the role falls back to
28
+ * (aria-expanded on combobox, aria-level on heading), so the role still
29
+ * exposes a value and only the author knows whether it is the right one.
23
30
  * @implementation-notes
31
+ * - The implicit-value table is generated from aria-query's requiredProps by
32
+ * scripts/generate-aria-tables.js (REQUIRED_PROP_IMPLICIT_VALUES in
33
+ * src/core/aria-helpers.js), so the two tiers cannot drift apart from the
34
+ * spec by hand. ACT 4e8ab6 maps this rule's requirement to WAI-ARIA rather
35
+ * than to WCAG, and names 1.3.1/4.1.2 as "less strict" precisely because
36
+ * they "allow for fallback default values"; the cantTell tier is that
37
+ * carve-out, not a softening of the fail tier.
24
38
  * - Scoped to REQUIRED_PROPS_BY_ROLE in src/core/aria-helpers.js,
25
39
  * which only lists a required property when the spec is unambiguous and
26
40
  * context-independent, see that file's header for the rationale.
@@ -131,7 +145,8 @@ function runInPage(ctx) {
131
145
  ? helpers.queryAllSmart('[role]')
132
146
  : helpers.queryAll('[role]');
133
147
 
134
- const occurrences = [];
148
+ const failOccurrences = [];
149
+ const cantTellOccurrences = [];
135
150
  let applicableCount = 0;
136
151
 
137
152
  for (const el of nodes) {
@@ -178,8 +193,43 @@ function runInPage(ctx) {
178
193
  if (!missing.length) continue;
179
194
 
180
195
  for (const attr of missing) {
181
- occurrences.push(
196
+ const implicit =
197
+ typeof ariaHelpers.getRequiredAttrImplicitValue === 'function'
198
+ ? ariaHelpers.getRequiredAttrImplicitValue(role, attr)
199
+ : null;
200
+
201
+ if (implicit) {
202
+ cantTellOccurrences.push(
203
+ helpers.reportOccurrence(el, {
204
+ occurrenceOutcome: 'cantTell',
205
+ summary: `This attribute is required for this element’s role and is missing, but ARIA falls back to "${implicit}".`,
206
+ hint: 'Set the attribute explicitly if the implicit value is not the state you mean.',
207
+ i18n: {
208
+ summaryKey: 'ariaRequiredAttr_summary_cantTell',
209
+ hintKey: 'ariaRequiredAttr_hint_cantTell',
210
+ params: { attr, role, implicit }
211
+ },
212
+ uncertainty: {
213
+ code: 'spec-only',
214
+ needed: 'Whether the implicit fallback is the state the author meant.',
215
+ evidence: { attribute: attr, role, implicitValue: implicit }
216
+ },
217
+ data: {
218
+ details: {
219
+ reasonCode: 'ARIA_ATTR_REQUIRED_MISSING_IMPLICIT',
220
+ attr,
221
+ role,
222
+ implicitValue: implicit
223
+ }
224
+ }
225
+ })
226
+ );
227
+ continue;
228
+ }
229
+
230
+ failOccurrences.push(
182
231
  helpers.reportOccurrence(el, {
232
+ occurrenceOutcome: 'fail',
183
233
  summary: 'This attribute is required for this element’s role, but is missing.',
184
234
  hint: 'Add this attribute with a valid value for this role.',
185
235
  i18n: {
@@ -198,15 +248,12 @@ function runInPage(ctx) {
198
248
  if (applicableCount === 0) {
199
249
  return { ruleId: rule.ruleId, outcome: 'notApplicable', severity: 'minor', occurrences: [] };
200
250
  }
201
- if (occurrences.length) {
202
- return {
203
- ruleId: rule.ruleId,
204
- outcome: 'fail',
205
- severity: rule.defaultSeverity || 'serious',
206
- occurrences
207
- };
208
- }
209
- return { ruleId: rule.ruleId, outcome: 'pass', severity: 'minor', occurrences: [] };
251
+ const resolved = helpers.resolveTieredOutcome(
252
+ failOccurrences,
253
+ cantTellOccurrences,
254
+ rule.defaultSeverity || 'serious'
255
+ );
256
+ return { ruleId: rule.ruleId, ...resolved };
210
257
  }
211
258
 
212
259
  module.exports = { id, meta, runInPage };
@@ -7,7 +7,7 @@
7
7
  * @atomic true
8
8
  * @summary Container roles that require specific owned elements must contain at least one
9
9
  * @standard WCAG 2.2
10
- * @sc 4.1.2
10
+ * @sc 1.3.1
11
11
  * @applicability
12
12
  * Applies to elements with an explicit, valid, non-abstract role that is
13
13
  * also one of the container roles with a documented "required owned
@@ -15,7 +15,17 @@
15
15
  * table, grid, treegrid, tablist, tree, row).
16
16
  * @expectation
17
17
  * At least one descendant, or one aria-owns-referenced element, has one
18
- * of the acceptable owned roles for that container role.
18
+ * of the acceptable owned roles for that container role. Reported at
19
+ * CANTTELL, never FAIL: this rule asks only whether the required content
20
+ * is PRESENT, and a container that owns nothing conveys nothing false --
21
+ * an empty role="list" is announced as a list with no items, which is what
22
+ * it is. Whether the content a container does own is VALID is
23
+ * aria-prohibited-children's decision, and that rule still fails, so a
24
+ * genuinely misdescribed structure (a role="button" among list items, a
25
+ * tablist of plain buttons) is caught with the same strength as before.
26
+ * The native-HTML equivalents already work this way: nothing in this
27
+ * ruleset fails an empty <ul>, and list-children-valid judges only the
28
+ * children that exist.
19
29
  * @implementation-notes
20
30
  * - Scoped to REQUIRED_OWNED_ROLES in src/core/aria-helpers.js
21
31
  * (see that file's header for the conservative-scope rationale).
@@ -75,14 +85,14 @@ const meta = {
75
85
  descriptionKey: 'ariaRequiredChildren_description'
76
86
  },
77
87
  helpUrl: null,
78
- tags: ['wcag2a', 'wcag412', 'aria', 'structure', 'atomic', 'automatic'],
79
- wcagSc: ['4.1.2'],
88
+ tags: ['wcag2a', 'wcag131', 'aria', 'structure', 'atomic', 'automatic'],
89
+ wcagSc: ['1.3.1'],
80
90
  normativeMappings: [
81
91
  {
82
92
  standard: 'WCAG',
83
93
  version: '2.2',
84
- requirement: '4.1.2',
85
- title: 'Name, Role, Value',
94
+ requirement: '1.3.1',
95
+ title: 'Info and Relationships',
86
96
  conformanceLevel: 'A'
87
97
  }
88
98
  ],
@@ -90,7 +100,7 @@ const meta = {
90
100
  category: 'robust',
91
101
  type: 'automatic',
92
102
  defaultConfidence: 'medium',
93
- coverage: { facetsBySc: { '4.1.2': ['aria-role-required-owned-children'] } }
103
+ coverage: { facetsBySc: { '1.3.1': ['aria-role-required-owned-children'] } }
94
104
  };
95
105
 
96
106
  function runInPage(ctx) {
@@ -269,6 +279,16 @@ function runInPage(ctx) {
269
279
  hintKey: 'ariaRequiredChildren_hint_fail',
270
280
  params: { role, requiredRoles: requiredOwned.join(', ') }
271
281
  },
282
+ uncertainty: {
283
+ code: 'spec-only',
284
+ needed:
285
+ 'Whether this container is legitimately empty, or holds items that never got their role.',
286
+ evidence: {
287
+ role,
288
+ requiredOwnedRoles: requiredOwned,
289
+ childElementCount: el.children ? el.children.length : null
290
+ }
291
+ },
272
292
  data: {
273
293
  details: {
274
294
  reasonCode: 'ARIA_REQUIRED_CHILD_MISSING',
@@ -283,15 +303,12 @@ function runInPage(ctx) {
283
303
  if (applicableCount === 0) {
284
304
  return { ruleId: rule.ruleId, outcome: 'notApplicable', severity: 'minor', occurrences: [] };
285
305
  }
286
- if (occurrences.length) {
287
- return {
288
- ruleId: rule.ruleId,
289
- outcome: 'fail',
290
- severity: rule.defaultSeverity || 'moderate',
291
- occurrences
292
- };
293
- }
294
- return { ruleId: rule.ruleId, outcome: 'pass', severity: 'minor', occurrences: [] };
306
+ const resolved = helpers.resolveTieredOutcome(
307
+ [],
308
+ occurrences,
309
+ rule.defaultSeverity || 'moderate'
310
+ );
311
+ return { ruleId: rule.ruleId, ...resolved };
295
312
  }
296
313
 
297
314
  module.exports = { id, meta, runInPage };