@surea11y/core 1.6.0 → 1.8.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 (120) hide show
  1. package/CHANGELOG.md +140 -0
  2. package/README.md +179 -90
  3. package/docs/ACT_RULE_MAPPING.md +10 -8
  4. package/docs/API_STABILITY.md +67 -6
  5. package/docs/BINDING_AUTHORS_GUIDE.md +104 -2
  6. package/docs/CI_INTEGRATIONS.md +43 -0
  7. package/docs/DESIGN_CHALLENGES.md +162 -2
  8. package/docs/EARL.md +100 -0
  9. package/docs/ENGINE_OPTIONS.md +109 -5
  10. package/docs/I18N.md +62 -20
  11. package/docs/INTEGRATION.md +4 -2
  12. package/docs/JUNIT.md +73 -0
  13. package/docs/LIMITATIONS.md +4 -1
  14. package/docs/OUTPUT_SCHEMA.md +62 -11
  15. package/docs/POLICY.md +1 -1
  16. package/docs/REPORT.md +7 -2
  17. package/docs/RULE_AUTHORING.md +83 -17
  18. package/docs/RULE_CATALOG.md +212 -139
  19. package/docs/RULE_EXAMPLES.md +2189 -0
  20. package/docs/RULE_HELPERS.md +390 -0
  21. package/docs/RULE_TAXONOMY.md +27 -6
  22. package/docs/SARIF.md +23 -3
  23. package/docs/WCAG_CONFORMANCE.md +64 -3
  24. package/package.json +41 -12
  25. package/profiles/index.js +14 -0
  26. package/src/checks/automatic/area-alt-present.js +87 -31
  27. package/src/checks/automatic/aria-allowed-attr.js +6 -0
  28. package/src/checks/automatic/aria-allowed-role.js +32 -23
  29. package/src/checks/automatic/aria-braille-equivalent.js +43 -17
  30. package/src/checks/automatic/aria-conditional-attr.js +17 -10
  31. package/src/checks/automatic/aria-deprecated-role.js +12 -0
  32. package/src/checks/automatic/aria-hidden-body.js +1 -1
  33. package/src/checks/automatic/aria-hidden-focus.js +74 -18
  34. package/src/checks/automatic/aria-prohibited-attr.js +22 -4
  35. package/src/checks/automatic/aria-prohibited-children.js +6 -6
  36. package/src/checks/automatic/aria-required-attr.js +88 -12
  37. package/src/checks/automatic/aria-required-children.js +33 -16
  38. package/src/checks/automatic/aria-required-parent.js +32 -6
  39. package/src/checks/automatic/aria-role-name-present.js +20 -3
  40. package/src/checks/automatic/aria-roles-valid.js +52 -21
  41. package/src/checks/automatic/aria-valid-attr-value.js +89 -24
  42. package/src/checks/automatic/aria-valid-attr.js +14 -9
  43. package/src/checks/automatic/autocomplete-valid.js +152 -26
  44. package/src/checks/automatic/avoid-inline-spacing.js +207 -15
  45. package/src/checks/automatic/button-name-present.js +2 -1
  46. package/src/checks/automatic/canvas-text-alternative-present.js +105 -14
  47. package/src/checks/automatic/combobox-name-present.js +34 -51
  48. package/src/checks/automatic/contrast-computable.js +45 -4
  49. package/src/checks/automatic/contrast-enhanced.js +16 -4
  50. package/src/checks/automatic/contrast-minimum.js +57 -11
  51. package/src/checks/automatic/css-orientation-lock.js +171 -12
  52. package/src/checks/automatic/definition-list-children-valid.js +67 -23
  53. package/src/checks/automatic/deprecated-elements-not-used.js +43 -38
  54. package/src/checks/automatic/dialog-name-present.js +28 -9
  55. package/src/checks/automatic/duplicate-id-aria.js +5 -0
  56. package/src/checks/automatic/duplicate-id.js +19 -10
  57. package/src/checks/automatic/form-control-single-label.js +9 -0
  58. package/src/checks/automatic/identical-iframes-same-purpose.js +229 -0
  59. package/src/checks/automatic/iframe-focusable-content.js +12 -4
  60. package/src/checks/automatic/iframe-title-unique.js +36 -81
  61. package/src/checks/automatic/input-image-alt-present.js +32 -20
  62. package/src/checks/automatic/label-in-name.js +78 -69
  63. package/src/checks/automatic/language-page-present.js +12 -6
  64. package/src/checks/automatic/link-in-text-block.js +512 -44
  65. package/src/checks/automatic/link-name-present.js +13 -5
  66. package/src/checks/automatic/list-children-valid.js +18 -1
  67. package/src/checks/automatic/listbox-name-present.js +19 -49
  68. package/src/checks/automatic/listitem-parent-valid.js +4 -3
  69. package/src/checks/automatic/meta-refresh-no-exceptions.js +3 -3
  70. package/src/checks/automatic/page-title-present.js +16 -4
  71. package/src/checks/automatic/progressbar-name-present.js +11 -1
  72. package/src/checks/automatic/{role-img-alt-present.js → role-img-text-alternative-present.js} +9 -5
  73. package/src/checks/automatic/searchbox-name-present.js +32 -49
  74. package/src/checks/automatic/server-side-image-map-absent.js +48 -28
  75. package/src/checks/automatic/slider-name-present.js +38 -52
  76. package/src/checks/automatic/spinbutton-name-present.js +32 -49
  77. package/src/checks/automatic/target-size-minimum.js +84 -16
  78. package/src/checks/automatic/td-has-header.js +60 -23
  79. package/src/checks/automatic/text-spacing-content-loss.js +548 -0
  80. package/src/checks/automatic/textbox-name-present.js +32 -49
  81. package/src/checks/automatic/valid-lang.js +15 -10
  82. package/src/checks/manual/area-alt-quality-manual.js +113 -31
  83. package/src/checks/manual/canvas-text-alternative-quality-manual.js +0 -2
  84. package/src/checks/manual/css-focus-indicator-suppressed-manual.js +23 -10
  85. package/src/checks/manual/css-hidden-focus.js +215 -7
  86. package/src/checks/manual/form-control-label-quality-manual.js +243 -29
  87. package/src/checks/manual/heading-order-manual.js +9 -1
  88. package/src/checks/manual/heading-quality-manual.js +143 -9
  89. package/src/checks/manual/img-alt-decorative-manual.js +6 -3
  90. package/src/checks/manual/input-image-alt-quality-manual.js +81 -13
  91. package/src/checks/manual/landmark-complementary-is-top-level-manual.js +231 -0
  92. package/src/checks/manual/link-name-quality-manual.js +130 -4
  93. package/src/checks/manual/media-transcript-present-manual.js +65 -8
  94. package/src/checks/manual/mouse-only-event-handlers-manual.js +66 -5
  95. package/src/checks/manual/no-autoplay-audio-manual.js +93 -6
  96. package/src/checks/manual/p-as-heading-manual.js +89 -44
  97. package/src/checks/manual/page-title-patterns-manual.js +77 -8
  98. package/src/checks/manual/password-paste-enabled-manual.js +255 -0
  99. package/src/checks/manual/skip-link-manual.js +42 -14
  100. package/src/checks/manual/table-fake-caption-manual.js +32 -1
  101. package/src/checks/manual/video-caption-manual.js +47 -24
  102. package/src/checks/manual-review.js +0 -4
  103. package/src/core.js +18061 -46194
  104. package/src/coverage/en301549-map.js +187 -0
  105. package/src/coverage/standards.js +279 -0
  106. package/src/coverage/wcag-facets.js +1119 -0
  107. package/src/coverage/wcag-version-map.js +101 -0
  108. package/src/earl.js +144 -0
  109. package/src/en301549.js +33 -0
  110. package/src/junit.js +321 -0
  111. package/src/profile-kit.js +163 -0
  112. package/src/report.js +343 -74
  113. package/src/sarif.js +56 -5
  114. package/src/wcag.js +105 -0
  115. package/surea11y.browser.js +11 -41039
  116. package/surea11y.i18n.de.js +2 -21
  117. package/surea11y.i18n.es.js +2 -21
  118. package/surea11y.i18n.fr.js +2 -21
  119. package/surea11y.i18n.ja.js +3 -0
  120. package/src/checks/manual/area-alt-decorative-manual.js +0 -255
@@ -0,0 +1,390 @@
1
+ # RULE_HELPERS.md — `ctx.helpers` Reference (Canonical, repo-derived)
2
+
3
+ This is the full reference for `ctx.helpers`, the object every rule's `runInPage(ctx)`
4
+ receives (`const { helpers } = ctx`, per [`RULE_AUTHORING.md`](./RULE_AUTHORING.md) §6).
5
+ It exists because that doc's own helpers section only calls out the dozen or so most
6
+ common ones — the underlying object (`createDomHelpers()` in `src/core/dom-helpers.js`)
7
+ exports around 35, and several of them replace logic a new rule would otherwise
8
+ duplicate (often incorrectly — see the naming helpers below).
9
+
10
+ Read [`RULE_AUTHORING.md`](./RULE_AUTHORING.md) first for the rule contract itself
11
+ (module shape, `runInPage` constraints, occurrence reporting). This doc only covers
12
+ what each helper does and when to reach for it.
13
+
14
+ All helpers below are called as `helpers.<name>(...)` from inside `runInPage`. None of
15
+ them may be assigned to a module-scope variable and closed over — same
16
+ serialization constraint as everything else in `runInPage` (§1 of `RULE_AUTHORING.md`).
17
+
18
+ ---
19
+
20
+ ## 1) Query & traversal
21
+
22
+ ### `queryAll(selector)` → `Element[]`
23
+ Plain `querySelectorAll(selector)` across the resolved context root(s), deduped, with
24
+ self-match included (a root element matching `selector` itself is returned, which
25
+ `querySelectorAll` alone never does). Light DOM only.
26
+
27
+ ### `queryAllDeep(selector)` → `Element[]`
28
+ Same as `queryAll`, but also descends into open shadow roots (BFS over discovered
29
+ `shadowRoot`s). Ignores `includeShadowDom`/hidden-content policy — it's the raw
30
+ traversal `queryAllSmart` builds on.
31
+
32
+ ### `queryAllSmart(selector)` → `Element[]`
33
+ **The one almost every rule should use.** Honors `engineOptions.includeShadowDom`
34
+ (shadow-aware by default), applies the default hidden-content policy (filters out
35
+ `display:none`/`hidden`/etc. unless `includeHiddenElements:true`), and applies any
36
+ rule-scoped `excludeSelectors`. See `RULE_AUTHORING.md` §6.1 — write rules assuming
37
+ open shadow roots are in scope and let this helper honor the caller's choice.
38
+
39
+ ```js
40
+ const nodes = helpers.queryAllSmart ? helpers.queryAllSmart('img') : helpers.queryAll('img');
41
+ ```
42
+
43
+ ### `queryAllSource(selector)` → `Element[]`
44
+ The same query as `queryAllSmart` (shadow roots per `includeShadowDom`, context scope,
45
+ `excludeSelectors`) with no hidden-content filter: elements inside `hidden`,
46
+ `display:none`, closed `<details>` and the like are returned whatever
47
+ `includeHiddenElements` says. Only for rules that judge the markup itself rather than
48
+ what is rendered, such as a standard's tests on the generated source.
49
+ `<template>` content is not in the DOM tree and stays out. A WCAG rule should not use
50
+ it: hidden content is not presented to users.
51
+
52
+ ### `getDoctypeInfo()` → `{ kind, name, publicId, systemId }`
53
+ The document's doctype, classified by HTML version. `kind` is one of:
54
+
55
+ | `kind` | Doctype |
56
+ |---|---|
57
+ | `'html5'` | name `html`, no public id, and no system id or `about:legacy-compat` |
58
+ | `'xhtml10'` | a public id of XHTML 1.0 (strict, transitional or frameset) |
59
+ | `'xhtml11'` | any other W3C XHTML public id: XHTML 1.1, XHTML Basic, XHTML 1.1 plus MathML (and SVG), XHTML+RDFa |
60
+ | `'html4'` | a W3C or IETF HTML public id: HTML 2.0, 3.2, 4.0, 4.01, HTML 4.01+RDFa |
61
+ | `'other'` | any other doctype, including one whose name is not `html` |
62
+ | `'none'` | no doctype |
63
+
64
+ Public ids are compared without regard to case, as the HTML parser does. `name`,
65
+ `publicId` and `systemId` are the doctype's own values (empty strings when there is
66
+ none). For rules whose verdict depends on the HTML version: a requirement that
67
+ applies to HTML5 only, or `lang` read as `lang` or `xml:lang` by version. Whether a
68
+ doctype is valid at all is not this helper's question.
69
+
70
+ ### `composedParent(node)` → `Node | null`
71
+ One step up the *flat tree*: `assignedSlot` first (a slotted node's rendered parent is
72
+ its slot, not its light-DOM `parentNode`), then `parentNode`, then `.host` once you're
73
+ at a `ShadowRoot`. Use this instead of `parentNode`/`closest` for any ancestor walk that
74
+ must work correctly across shadow boundaries and slots.
75
+
76
+ ### `buildSimpleSelector(el, fallbackTag)` → `string`
77
+ A short, non-unique selector for an element: `#id`, else a `data-testid`/`data-test`/
78
+ `data-cy`/`data-qa` attribute selector, else `tag[name="..."]`, else just the tag name.
79
+ Cheap; not guaranteed unique. Prefer `reportOccurrence` (§6 below) over building
80
+ selectors by hand — see the perf note there.
81
+
82
+ ### `buildSelector(el)` → `string`
83
+ The engine's real, best-effort-unique CSS selector builder (cached per element per
84
+ run). Used internally by `reportOccurrence`'s finalization; rules generally don't need
85
+ to call this directly.
86
+
87
+ ### `getOuterHtmlSnippet(el)` → `string`
88
+ `el.outerHTML`, truncated to 2000 characters (with a trailing `…`) and cached per
89
+ element per run. `reportOccurrence` (§6 below) already fills in an occurrence's `html`
90
+ from the reported element, so most rules never call this directly — it's for the rare
91
+ case where a rule needs the HTML snippet itself, not just an occurrence carrying it.
92
+
93
+ ### `isExcluded(el)` → `boolean`
94
+ Whether `el` matches the currently effective `excludeSelectors` (global config ∪ the
95
+ active rule's own `engineOptions.rules[ruleId].excludeSelectors`), via `closest()`.
96
+ `queryAllSmart` already applies this filtering for you; reach for `isExcluded` directly
97
+ only if a rule walks the DOM some other way (e.g. following `composedParent`) and still
98
+ needs to respect exclusions on nodes found off that path.
99
+
100
+ ### `buildStructuralPath(node, selector)` → `number[] | null`
101
+ Sibling-index path from the document root to `node` (or, given only a `selector`,
102
+ re-resolves the element first — the same fallback `reportOccurrence` triggers, and the
103
+ same one `perfStats.counters['structuralPath.selectorFallback']` counts, per
104
+ `RULE_AUTHORING.md` §4.3). Rules don't normally call this either — it's what backs
105
+ `selector`/`structuralPath` finalization for reported occurrences.
106
+
107
+ ---
108
+
109
+ ## 2) Eligibility & visibility
110
+
111
+ These answer "is this node in scope" from different angles. They are easy to reach for
112
+ the wrong one, so the distinctions matter:
113
+
114
+ ### `isAccTreeEligible(node)` → `{ eligible, reasons[] }`
115
+ Whether a node is eligible for the accessibility tree, per an ordered set of checks
116
+ (display/visibility, `hidden`, `inert`, closed `<details>`, template content, etc.).
117
+ Deliberately keeps a focusable-but-`aria-hidden` element *eligible* — see the header
118
+ comment on `isIncludedInAccessibilityTree` below for why.
119
+
120
+ ### `isIncludedInAccessibilityTree(el)` → `boolean`
121
+ The narrower question most accessible-name rules actually want: `isAccTreeEligible`,
122
+ minus anything eligible only because of an `ariaHiddenOverridden*` reason. ACT scopes
123
+ name-computation rules (button-name, link-name, etc.) to elements genuinely included in
124
+ the accessibility tree; a focusable element hidden by `aria-hidden` is a real
125
+ issue, but it's `aria-hidden-focus`'s issue (WCAG 4.1.2), not a naming rule's — so
126
+ naming rules should check this, not `isAccTreeEligible`, to avoid double-flagging the
127
+ same element under the wrong rule.
128
+
129
+ ### `isDomVisibleEligible(node, ctx, opts)` → `{ eligible, reasons[], metrics }`
130
+ Style/geometry-only visibility (not accessibility-tree membership): `display`,
131
+ `visibility`, size/position, opacity, etc. `opts.visibilityMode` (`'styleOnly'` |
132
+ `'styleAndGeometry'`), `opts.disableGeometry`, `opts.ignoreOpacity` tune what's checked.
133
+ Use when a rule cares about visual rendering specifically, not AT exposure.
134
+
135
+ ### `getEligibilityInfo(node, ctx, opts)` → `{ eligible, reasons[], targetSet, accEligible }`
136
+ A single wrapper choosing between the two above via `opts.targetSet` (`'acc'` |
137
+ `'dom'`, default `'dom'`). **This is also the shape `RULE_AUTHORING.md` §7 requires every
138
+ occurrence to carry** as `data.visibilityFilter` — call this once per element and pass
139
+ its result straight through.
140
+
141
+ ### `getVisibilityHintsInfo(el, ctx, opts)` → `{ hints[], metrics, flags[] }`
142
+ Style-only visibility *hints* for triage/diagnostics (`opacityZero`, `clipped`, etc.) —
143
+ explicitly does **not** decide eligibility; a rule's own logic still owns the outcome.
144
+
145
+ ### `isWholeDocumentScope()` → `boolean`
146
+ `true` unless `engineOptions.fragment: true` was set, or `contextSelector` scoped the
147
+ run narrower than the whole document. Required for any rule checking a page-wide,
148
+ one-per-document property (`<title>`, `<html lang>`, landmark structure) — gate it with
149
+ an `applicability(ctx)` export using this, per `RULE_AUTHORING.md` §4.2/§11.2, or the
150
+ rule will wrongly fault a scoped subtree for lacking something it was never meant to
151
+ have.
152
+
153
+ ### `hasTruncatedAncestorWalk` (internal)
154
+ Backs confidence scoring during result normalization when a 200-step ancestor walk
155
+ didn't reach the root. Not something a rule calls directly.
156
+
157
+ ---
158
+
159
+ ## 3) Accessible naming & labeling
160
+
161
+ Naming is layered — each function below builds on the ones above it — and several exist
162
+ specifically to replace a naive reimplementation that gets an edge case wrong. Reach for
163
+ the highest-level one that answers your actual question before dropping to a primitive.
164
+
165
+ ### `getAriaLabelInfo(el)` → `{ present, value, mechanism, flags[] }`
166
+ Just `aria-label`, trimmed, presence-checked.
167
+
168
+ ### `getAriaLabelledByInfo(el, ctx, opts)` → `{ present, value, mechanism, refsCount, missing[], flags[] }`
169
+ Resolves `aria-labelledby` through `getTextFromIdRefs` (recursive, accname-aligned — see
170
+ §4 below), not raw `textContent`.
171
+
172
+ ### `getAriaNameInfo(el, ctx, opts)` → `{ present, value, mechanism, flags[] }`
173
+ ARIA-only name with correct precedence: `aria-labelledby` (if it resolves non-empty)
174
+ wins over `aria-label`. Use this rather than checking the two attributes yourself when
175
+ you specifically want "does ARIA name this," excluding native `<label>`/content/title.
176
+
177
+ ### `getLandmarkNameInfo(el, ctx)` → `{ present, value, mechanism, flags[] }`
178
+ Landmark-role naming (`nav`/`main`/`region`/`banner`/`contentinfo`, etc.): ARIA name,
179
+ then `title` — landmark roles don't get a name from content, and `title` must be
180
+ included or two landmarks distinguished only by `title` both read as unnamed and get
181
+ flagged as duplicates. Shared by all 7 landmark rule files; use this rather than
182
+ reimplementing landmark naming in a new landmark rule.
183
+
184
+ ### `getAccessibleNameInfo(el, ctx, opts)` → `{ present, value, mechanism, flags[] }`
185
+ The general-purpose accessible name: ARIA name → native `<label>` association → `alt`
186
+ (image-like elements) → `title` (flagged `title-used` — see the policy note in the
187
+ source; `title` is accepted per spec but is a weak mechanism in practice). This is what
188
+ almost every naming rule should call.
189
+
190
+ ### `getAccessibleDescriptionInfo(el, ctx, opts)` → `{ present, value, mechanism, flags[] }`
191
+ `aria-describedby` (via `getTextFromIdRefs`), then optionally `title` if
192
+ `opts.allowTitle === true`.
193
+
194
+ ### `getTextAlternativeInfo(el, ctx, opts)` → `{ present, value, mechanism, requiredMechanism, flags[] }`
195
+ Mechanism-aware text alternative for elements with a *specific required* mechanism:
196
+ `alt` for `img`/`area`/`input[type=image]` (missing `alt` is flagged even when an
197
+ accessible name exists elsewhere — that's still a real alt-text violation), fallback
198
+ content or ARIA/`title` for `<canvas>`. Use this for alt-text-shaped rules, not
199
+ `getAccessibleNameInfo`, when the rule cares which mechanism was used, not just whether
200
+ a name exists.
201
+
202
+ ### `getContentNameInfo(el, ctx, opts)` → `{ present, value, mechanism, flags[] }`
203
+ Recursive "name from content" (accname step 2F): walks children using each child's
204
+ *own* accessible name (not just literal text), so `<a href="…"><img alt="Company
205
+ Name"></a>` and `<button><span aria-label="Close"></span></button>` both name correctly.
206
+ A plain `TreeWalker(SHOW_TEXT)` walk misses both. An SVG element with a `<title>` child
207
+ speaks for itself through that title (SVG-AAM), after its own `aria-labelledby` and
208
+ `aria-label`, so `<button><svg><title>Search</title></svg></button>` is named "Search".
209
+
210
+ ### `getAssociatedLabelElements(el)` → `Element[]`
211
+ Real `<label>` element(s) associated with `el` — a `<label for="id">` pointing at it,
212
+ plus a wrapping `<label>` whose first labelable descendant it is. **Does not call the
213
+ native `.labels`/`.control` API** — in this project's supported jsdom runtime,
214
+ `.labels` is an expensive whole-document walk per element (`.control` resolution is
215
+ another one), which used to dominate whole-engine runtime on form-heavy pages. Use this
216
+ whenever a rule needs the actual label element(s), not just a yes/no.
217
+
218
+ ### `getNativeHostNameInfo(el, ctx, opts)` → `{ present, value, mechanism }`
219
+ The name an element gets from its HTML host markup rather than from ARIA: an associated
220
+ `<label>` on a labelable element, the first child `<legend>` of a `<fieldset>`, the first
221
+ child `<caption>` of a `<table>`, and, only with `opts.placeholder: true`, the
222
+ `placeholder` of a text-like `<input>` or a `<textarea>` (HTML-AAM's last name source).
223
+ `mechanism` is `'label'`, `'legend'`, `'caption'`, `'placeholder'` or `'none'`. For rules
224
+ on name-from-author-only roles (`role="textbox"`, `"slider"`, `"radiogroup"`, …) whose
225
+ ARIA check does not read host markup, although the browser still computes it.
226
+
227
+ ### `labelContributesAccessibleName(labelEl)` → `boolean`
228
+ Whether a `<label>` element itself carries text that would name its control: own
229
+ ARIA name, else rendered content (`getContentNameInfo`, so `aria-hidden`/`display:none`
230
+ descendants are correctly excluded), else its own `title`. Shared by
231
+ `form-control-single-label` and `form-control-programmatic-label-present` so they agree
232
+ on what "a label with content" means.
233
+
234
+ ### `getLabelMethod(el, ctx, opts)` → `{ method, value }`
235
+ Which mechanism actually labels `el` — `'label'` | `'aria-labelledby'` |
236
+ `'aria-label'` | `'title'` | `'placeholder'` | `'none'` — checked in that precedence
237
+ order.
238
+
239
+ ### `getLabelStrength(method)` → `'strong' | 'medium' | 'weak' | 'none'`
240
+ Policy classification of a `getLabelMethod` result (`label`/`aria-labelledby` →
241
+ strong, `aria-label` → medium, `title`/`placeholder` → weak). Deterministic and
242
+ intentionally tweakable in one place rather than per rule.
243
+
244
+ ### `hasAccessibleName(el)` → `boolean`
245
+ Back-compat convenience: `!!getAccessibleNameInfo(el).value`.
246
+
247
+ ---
248
+
249
+ ## 4) IDREF resolution
250
+
251
+ Backs `aria-labelledby`/`aria-describedby` and any other space-separated ID-reference
252
+ attribute.
253
+
254
+ ### `resolveIdRefs(idrefString, ctx, opts)` → `{ refs: Element[], missing: string[], flags[] }`
255
+ Splits and resolves a space-separated ID list to elements (deduped, cached per scope).
256
+ `opts.maxRefs` truncates deterministically (adds `'truncated'` to `flags`).
257
+
258
+ ### `getTextFromIdRefs(idrefString, ctx, opts)` → `{ text, refsCount, missing[], flags[] }`
259
+ Resolves refs, then computes **each target's own text alternative** recursively
260
+ (accname-aligned — a referenced element's name is recomputed, not read as raw
261
+ `textContent`), joins with spaces. This is what `getAriaLabelledByInfo`/
262
+ `getAccessibleDescriptionInfo` call internally.
263
+
264
+ ### `getTextFromIdRefsIdrefEligible(idrefString, ctx, opts)` → `{ text, refsCount, missing[], excluded[], flags[] }`
265
+ Same, but under IDREF eligibility rules specifically: hidden/`aria-hidden`/collapsed
266
+ targets are still included (IDREF targets aren't scoped by visibility the way rendered
267
+ content is — see the `root` note in the source), only `inert` targets are excluded.
268
+ `excluded` lists `{ id, reasons }` for anything dropped.
269
+
270
+ ---
271
+
272
+ ## 5) Role & focusability
273
+
274
+ ### `getRoleInfo(el, ctx, opts)` → `{ role, source, flags[] }`
275
+ Explicit `role` attribute if present (flags `'presentation'` for
276
+ `presentation`/`none`, `'multiple-roles'` if it contains whitespace), else a small,
277
+ deliberately minimal implicit-role mapping (`a[href]`→`link`, `button`→`button`,
278
+ `input[type=checkbox]`→`checkbox`, etc.) unless `opts.disallowImplicit`.
279
+
280
+ ### `getFocusableInfo(el, ctx, opts)` → `{ focusable, tabbable, mechanism, flags[] }`
281
+ Platform focusability: whether `el` can receive focus at all, and whether it's in the
282
+ default tab order.
283
+
284
+ ### `hasLandmarkScopingAncestor(el, ctx)` → `boolean`
285
+ Whether `el` sits inside a landmark-scoping ancestor — the role-aware
286
+ sectioning-content/`<main>` check backing `<header>`/`<footer>`/`<aside>`'s conditional
287
+ implicit roles (their implicit landmark role only applies when *not* nested inside
288
+ certain ancestors). Available both as `helpers.hasLandmarkScopingAncestor` and
289
+ `helpers.aria.hasLandmarkScopingAncestor` — same function, re-exported at the top level
290
+ so landmark-check files don't need to reach into `aria.*` for it.
291
+
292
+ ---
293
+
294
+ ## 6) Attributes, language, reporting, outcomes
295
+
296
+ ### `getAttributeInfo(el, attrName)` → `{ present, value, mechanism, flags[] }`
297
+ Generic trimmed-attribute presence/value check. Use for any plain attribute a rule
298
+ inspects directly (not one of the naming/ARIA attributes above, which have their own
299
+ dedicated helpers).
300
+
301
+ ### `isValidLanguageTag(value)` / `isRegisteredLanguageSubtag(subtag)` → `boolean`
302
+ BCP 47 well-formedness **plus** a real IANA subtag-registry check — shape alone accepts
303
+ `"eng"` or `"em-US"`, which look like language tags but use an unregistered primary
304
+ subtag (the registry only lists a three-letter subtag when no two-letter one exists,
305
+ so `"en"` is registered and `"eng"` is not). Use `isValidLanguageTag` for any
306
+ `lang`/`xml:lang`-checking rule instead of a regex-only check.
307
+
308
+ ### `hasSkipLinkWording(text)` → `boolean`
309
+ Whether a link's text reads as a skip link ("Skip to content", "Aller au contenu",
310
+ "Zum Inhalt", "Saltar al contenido", "本文へ"...), in the languages the engine ships.
311
+ One list for every rule that looks for a skip link, core's `skip-link` and any a
312
+ profile brings, so they recognise the same links; add a phrasing here, not in a
313
+ rule.
314
+
315
+ ### `reportOccurrence(node, partial)` → occurrence object
316
+ **Use this to build every occurrence.** Attaches the element so the engine fills in
317
+ `selector`, `html`, and `structuralPath` centrally — see `RULE_AUTHORING.md` §4.3/§9 for
318
+ the full contract and why hand-building these fields yourself is a real (measured:
319
+ 4 min → <1 s) performance regression on any rule reporting many occurrences.
320
+
321
+ ```js
322
+ occurrences.push(helpers.reportOccurrence(el, {
323
+ summary: '…',
324
+ hint: '…',
325
+ i18n: { summaryKey: '…', hintKey: '…', params: { element: 'img' } },
326
+ data: { visibilityFilter: eligInfo }
327
+ }));
328
+ ```
329
+
330
+ ### `resolveTieredOutcome(failOccurrences, cantTellOccurrences, severity)` → `{ outcome, severity, occurrences }`
331
+ For a rule that collects two confidence tiers in one run — some findings confident
332
+ enough for `fail`, others only `cantTell` ("needs human review"). Returns `fail` (with
333
+ **both** tiers' occurrences, tagged via `occurrenceOutcome`) whenever any fail-tier
334
+ finding exists, `cantTell` when only cantTell-tier findings exist, `pass` otherwise.
335
+ Reach for this instead of the naive `if (fails.length) return fail(fails); else if
336
+ (cantTells.length) return cantTell(cantTells);`, which silently drops every cantTell
337
+ finding whenever at least one fail finding also exists on the page.
338
+
339
+ ### `getPerfStats()` / `resetPerfStats()`
340
+ Only populated when the engine is run with `perfStats: true`. Not for rule logic —
341
+ useful when profiling a new rule's hot paths during development.
342
+
343
+ ---
344
+
345
+ ## 7) Namespaced helper groups
346
+
347
+ Two larger helper sets are exposed as namespaces rather than flattened, since their
348
+ member counts and internal cohesion (contrast math, ARIA validity data) don't fit the
349
+ flat `helpers.*` list above:
350
+
351
+ ### `helpers.contrast.*`
352
+ Color/contrast math and text-run analysis: `parseCssColorToRgba`, `compositeRgba`,
353
+ `relativeLuminance`, `contrastRatio`, `requiredRatio`, `isLargeText`,
354
+ `computeEffectiveForeground`/`computeEffectiveBackground`, `getComputabilityBlocker`,
355
+ `hasBackgroundImageOrGradient`, `hasBlendMode`, `hasFilter`, `computeOpacityProduct`,
356
+ `getTextScan`, `isInactiveUiComponent`, plus small numeric/formatting utilities
357
+ (`clamp01`, `clamp255`, `round2`, `toHex2`, `rgbToHex`, `rgbaToString`, `parsePx`,
358
+ `normalizeFontWeight`, `pxToPt`, `fontWeightLabel`). That is the whole namespace,
359
+ apart from `sharedCache`: a plain object that lives for one scan and lets the contrast
360
+ rules reuse per-element work. Treat it as an optimisation, never as data a rule
361
+ depends on: a key may be absent, and a rule stores only under keys of its own unless
362
+ it computes exactly what that key's other users compute (`contrast-minimum`,
363
+ `contrast-enhanced` and `contrast-computable`, and `contrast-minimum`'s variants,
364
+ share `__elBgCache`, `__elFgCache` and `__elBlockerCache`,
365
+ WeakMaps of each element's effective background, foreground and computability blocker;
366
+ a variant with other thresholds keeps its own font and analysis caches, keyed by them). Backs the
367
+ `contrast-*` rule family (`contrast-minimum` and its variants, `contrast-enhanced`,
368
+ `contrast-computable`) — see `src/core/contrast-helpers.js`
369
+ if you're extending that family specifically. `isLargeText(fontSizePx, fontWeightNum,
370
+ boldLargeMinPx)` takes an optional third argument, the size from which bold text is large:
371
+ WCAG's 14pt when it is left out, 18.5 for a standard that puts it there.
372
+
373
+ ### `helpers.aria.*`
374
+ ARIA validity/taxonomy data and checks: `isValidAriaAttrName`, `getAttrValueType`,
375
+ `validateAttrValue`, `getExplicitRole`, `getAllRoleTokens`, `isAbstractRole`,
376
+ `isDeprecatedRole`, `isAuthorDiscouragedRole`, `isAuthorProhibitedRole`,
377
+ `isDeprecatedAttr`, `getDeprecatedRoleGuidance`, `isKnownRole`, `isValidConcreteRole`,
378
+ `getRequiredAttrsForRole`, `getRequiredOwnedRoles`, `getRequiredContextRoles`,
379
+ `isRoleAllowedOnElement`, `getContainmentRole`, `getNativeRoleForElement`,
380
+ `hasLandmarkScopingAncestor` (also re-exported flat, see §5). Backs the whole
381
+ `aria-*` rule family — check here before hand-rolling role/attribute validity logic in
382
+ a new ARIA rule.
383
+
384
+ ---
385
+
386
+ ## 8) Not for rule use
387
+
388
+ `helpers.__setActiveRuleExcludeSelectors` exists on the object but is engine-internal —
389
+ `dom-runner.js` calls it before invoking each rule to scope that rule's
390
+ `engineOptions.rules[ruleId].excludeSelectors`. A rule itself never calls it.
@@ -11,20 +11,41 @@ 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
 
24
45
  ### 1.2 Intent
25
46
  Encoded by: **rule id suffix**
26
47
 
27
- Illustrative intents (from the image-alternatives family used as the running example in §2, not an exhaustive list — the ruleset's 130 rules use dozens of distinct suffixes; `docs/RULE_CATALOG.md` is the generated, always-current list):
48
+ Illustrative intents (from the image-alternatives family used as the running example in §2, not an exhaustive list — the ruleset's rules use dozens of distinct suffixes; `docs/RULE_CATALOG.md` is the generated, always-current list):
28
49
 
29
50
  - `present`
30
51
  - Verifies that a required **mechanism exists**
@@ -43,7 +64,7 @@ Intent determines whether a rule can be automatic.
43
64
  ### 1.3 Target Family
44
65
  Encoded by: **rule id prefix**
45
66
 
46
- Illustrative families, from the image-alternatives cluster (WCAG 1.1.1) used as the running example in §2 — not an exhaustive list. The ruleset's 130 rules span dozens of families (`aria-*`, `contrast-*`, `dialog-*`, `iframe-*`, `label-*`, `link-*`, `list-*`, `landmark-*`, and more); see `docs/RULE_CATALOG.md` for the generated, always-current list:
67
+ Illustrative families, from the image-alternatives cluster (WCAG 1.1.1) used as the running example in §2 — not an exhaustive list. The ruleset's rules span dozens of families (`aria-*`, `contrast-*`, `dialog-*`, `iframe-*`, `label-*`, `link-*`, `list-*`, `landmark-*`, and more); see `docs/RULE_CATALOG.md` for the generated, always-current list:
47
68
 
48
69
  - `img`
49
70
  - `area`
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`.
@@ -33,7 +52,8 @@ Every rule that ran (regardless of whether it produced a result) is listed once
33
52
  | `results[].locations[].logicalLocations[].fullyQualifiedName` | `occurrence.selector`, when present. |
34
53
  | `results[].partialFingerprints["surea11y/violation/v1"]` | The same `ruleId + reasonCode + html` identity key used by [`BASELINE.md`](./BASELINE.md) (`computeBaselineKey`) — a stable, content-based fingerprint rather than a position-based one. |
35
54
  | `results[].properties.severity` / `.confidence` | `checksResults[i].severity` / `.confidence` — informational, not part of SARIF's own schema. |
36
- | `tool.driver.rules[].properties.tags` | `accessibility`, `automatic`/`manual`, and a `wcag-<SC>` tag per `meta.normativeMappings[].requirement`. |
55
+ | `tool.driver.rules[].properties.tags` | `accessibility`, `automatic`/`manual`, and a `wcag-<SC>` tag per WCAG Success Criterion in `meta.normativeMappings`. Understanding-document entries get no tag. Each EN 301 549 clause the result carries (only when the scan asked for them, see [`ENGINE_OPTIONS.md`](./ENGINE_OPTIONS.md#other-standards-mappings)) gets an `en301549-<clause>` tag, e.g. `en301549-9.1.1.1`: clause numbers are the same in every version that has them, so the tag carries no version. |
56
+ | `runs[0].properties` | `wcagVersion`, `profile` and `optInRules` from the result's `engine`: the conformance target the run used, so a dashboard can tell a WCAG 2.1 run from a 2.2 one, and the opt-in rule tags it added beyond that target when it added any. Omitted for results from engines that predate those fields. |
37
57
 
38
58
  ## Locations
39
59
 
@@ -4,9 +4,9 @@ How individual rule results relate to a WCAG Success Criterion (SC), and what su
4
4
 
5
5
  ## The three layers
6
6
 
7
- 1. **Atomic rules** (`checksResults[]`) — one normative decision each, e.g. "does this `<img>` have an `alt` attribute." See [`RULE_CATALOG.md`](./RULE_CATALOG.md) for all 130.
7
+ 1. **Atomic rules** (`checksResults[]`) — one normative decision each, e.g. "does this `<img>` have an `alt` attribute." See [`RULE_CATALOG.md`](./RULE_CATALOG.md) for every one.
8
8
  2. **Facets** — a WCAG SC is usually bigger than any one rule can decide deterministically. Internally, each SC is broken into named "facets" (e.g. 1.1.1 Non-text Content has facets like `img-alt-attr-present`, `text-alternative-quality`, `decorative-null`) tracked in `src/coverage/wcag-facets.js`, each marked `full` (a rule decides it with high confidence), `partial` (a rule decides *part* of it — see each rule's own scope notes), or `manual` (no safe automated heuristic exists at all). Run `npm run coverage` to regenerate `coverage/coverage-report.md`, the per-SC facet breakdown.
9
- 3. **Composite (WCAG-SC rollup) rules** (`rulesResults[]`) — a generated aggregate of every atomic rule mapped to one SC, giving you one pass/fail/cantTell/notApplicable verdict per SC instead of having to roll up dozens of atomic results yourself. See [`RULE_CATALOG.md`](./RULE_CATALOG.md#composite-wcag-sc-rollup-rules-33) for the full list (e.g. `wcag-1.1.1-non-text-content` rolls up 22 atomic rules).
9
+ 3. **Composite (WCAG-SC rollup) rules** (`rulesResults[]`) — a generated aggregate of every atomic rule mapped to one SC, giving you one pass/fail/cantTell/notApplicable verdict per SC instead of having to roll up dozens of atomic results yourself. See the composite section of [`RULE_CATALOG.md`](./RULE_CATALOG.md) for the full list (e.g. `wcag-1.1.1-non-text-content` rolls up 22 atomic rules).
10
10
 
11
11
  ## How a composite's outcome is computed
12
12
 
@@ -37,6 +37,8 @@ carries one level tag per Success Criterion it maps to, and nothing more: a rule
37
37
  mapped only to an AA criterion is tagged `wcag2aa` and *not* `wcag2a`. Asking for
38
38
  `{ tags: ['wcag2aa'] }` on its own therefore runs the 10 rules mapped to a 2.0 AA
39
39
  criterion, not the ~100 that make up an A + AA target. List every level you mean.
40
+ `engineOptions.profile` (`wcag22-aa`, `en301549-v4.1.1`, `en301549-v3.2.1`, `section508`) does
41
+ this for you; see [`ENGINE_OPTIONS.md`](./ENGINE_OPTIONS.md#conformance-profiles).
40
42
 
41
43
  The same applies across WCAG versions — a criterion introduced in 2.1 or 2.2 carries
42
44
  only its own origin tag — so a full conformance target is a union of tag sets. See
@@ -44,6 +46,14 @@ only its own origin tag — so a full conformance target is a union of tag sets.
44
46
  ready-made sets per version, including the one criterion WCAG 2.2 removed rather than
45
47
  added.
46
48
 
49
+ **The removed criterion is handled for you.** Every run resolves a target WCAG version
50
+ (`engineOptions.wcagVersion`, else whatever your version tags imply, else `2.2`) and
51
+ reports it back as `engine.wcagVersion`. Under a 2.2 target, a rule mapped only to SC
52
+ 4.1.1 Parsing cannot report `fail` — it runs, reports its occurrences, and comes back
53
+ `cantTell` with a `wcagVersionScope` field explaining the coercion. So a default scan
54
+ never gates on a criterion WCAG 2.2 does not contain, and a 2.0/2.1 scan still gets a
55
+ real 4.1.1 verdict.
56
+
47
57
  Composites, unlike atomic rules, *are* filtered cumulatively. The runner reads the
48
58
  highest level named in `tags` and drops every composite above it, so requesting
49
59
  `['wcag2a', 'wcag2aa']` returns no `rulesResults` entry for an AAA-only SC. That is
@@ -53,12 +63,63 @@ you need the exact precedence.
53
63
  Omit `tags` entirely (the default) and every rule at every level runs, with no composite
54
64
  suppression.
55
65
 
66
+ ## EN 301 549
67
+
68
+ Chapter 9 of EN 301 549 restates the WCAG Level A and AA Success Criteria as clauses numbered `9.` plus the criterion's own number: WCAG 1.4.3 is clause 9.1.4.3. When a scan asks for them, every atomic and composite result carries, after its WCAG entries in `meta.normativeMappings`, the clause for each of its criteria, once per version of the standard that includes that criterion. A scan asks with `engineOptions.mappings: ['en301549']` (both versions) or `['en301549:V3.2.1']` (one), or by targeting an EN 301 549 profile, which adds the version it names; by default results name WCAG only (see [`ENGINE_OPTIONS.md`](./ENGINE_OPTIONS.md#other-standards-mappings)):
69
+
70
+ | Version | Built on | Differs from the other in |
71
+ |---|---|---|
72
+ | V3.2.1 (2021-03) | WCAG 2.1 A and AA, 50 criteria | Includes 9.4.1.1 Parsing |
73
+ | V4.1.1 (2026-09) | WCAG 2.2 A and AA, 55 criteria | Adds 2.4.11, 2.5.7, 2.5.8, 3.2.6, 3.3.7, 3.3.8; 9.4.1.1 is void |
74
+
75
+ Each clause entry also names the criterion it restates, as `wcagSc: ["1.4.3"]`, so a view grouped by criterion (JUnit's suites, the HTML report's rollup) puts it under the right one even on a rule mapped to several.
76
+
77
+ AAA criteria have no clause in either version. The table lives in `src/coverage/en301549-map.js`, taken from the ETSI text; rules never declare these entries themselves, the build derives them from each rule's WCAG mapping. A rule added through `engineOptions.customRules` keeps exactly the mappings it declares. The catalogs name the clauses under the same options: `getChecksCatalog({ mappings: ['en301549'] })` in each entry's `normativeMappings`, and composite entries returned by `getRulesCatalog({ mappings: ['en301549'] })` as `meta.standardMappings`, so the clauses a criterion maps to can be read without running a scan. Called with no options, they name WCAG only, as a result does.
78
+
79
+ The table is public as `@surea11y/core/en301549`, for tools that need the reverse view, such as which criteria a version requires that a scan did not cover:
80
+
81
+ ```js
82
+ const { EN301549_CLAUSES, en301549ClausesForSc } = require('@surea11y/core/en301549');
83
+
84
+ Object.keys(EN301549_CLAUSES['V3.2.1']); // the 50 WCAG 2.1 A and AA criteria V3.2.1 restates
85
+ en301549ClausesForSc('2.5.8'); // [{ version: 'V4.1.1', clause: '9.2.5.8', title: 'Target size (minimum)' }]
86
+ ```
87
+
88
+ This is a correspondence between two published documents, not a conformance claim: a clause on a result says which EN 301 549 requirement that WCAG criterion is, nothing more. Which version a given law requires is outside the engine.
89
+
90
+ ## Adding another standard
91
+
92
+ EN 301 549 is an entry in a registry, `src/coverage/standards.js`. The build, the runner, the rule catalog and the reporters read it, so a new standard goes the same way. Where its entry lives depends on the standard:
93
+
94
+ - A standard that restates WCAG one criterion at a time, as EN 301 549 does, only renumbers WCAG's verdicts. Its table goes in `src/coverage/<name>-map.js` and its entry in `NORMATIVE_STANDARDS`, next to EN 301 549's.
95
+ - A standard with verdicts of its own is a **profile**: a folder under `profiles/` holding its entry, its tables, and the scripts and tests that go with them, listed in `profiles/index.js`. The registry appends each profile's entry after its own. `npm run profile:new -- <key>` creates one, working and empty, to fill in. See [`profiles/README.md`](../profiles/README.md) for the layout.
96
+
97
+ Either way, the entry needs:
98
+
99
+ 1. A table taken from the published text, with a function that returns a rule's entries given the rule's id and WCAG criteria. Each entry is `{ standard, version, requirement, title, wcagSc }`, where `wcagSc` lists the WCAG criteria that requirement corresponds to. A standard that restates WCAG derives its entries from the criteria. One organised differently looks the rule up by id, in a table of the rules it maps.
100
+ 2. Its `key` (what `engineOptions.mappings` accepts, and the SARIF tag prefix and JUnit property name), its `standard` (the name its entries carry and the report shows), its `versions`, and that function as `mappingsFor`.
101
+
102
+ The rest is optional, and the comment at the top of the registry describes each field:
103
+
104
+ - `profiles`: named conformance targets. Each gives the WCAG tags it runs and the version it targets, and switches that version's mappings on. With `mappedRules: true` it also runs every rule the standard maps, which matters when the standard requires things WCAG leaves to best practice. With `exclude: { rules, criteria }` it leaves rules out: the rules it names, and the WCAG criteria a standard narrower than WCAG waives (their WCAG rollups, and every rule that checks nothing else). The build refuses an unknown rule or criterion, and a rule the profile both maps and excludes. A standard that replaces a WCAG check with a stricter one of its own excludes the WCAG rule and maps its own.
105
+ - `ruleTag`: a tag for rules that check requirements only this standard makes. Another standard may not map them or derive variants from them; the build refuses it, so standards stay independent of each other, and a rule two of them need belongs in core. These rules are opt-in (see [`RULE_AUTHORING.md`](./RULE_AUTHORING.md)), so a WCAG scan never runs them. In a profile, they go in its `rules/` folder, which its `index.js` exports as `rulesDir`.
106
+ - `ruleMapped: true`: the entries come from each rule rather than from its WCAG criterion, so a WCAG rollup names only the entries of the rules that decided its outcome. A standard mapped that way for some requirements and restating WCAG for others lists the prefixes of the restating ones in `restatedPrefixes` (`['A.']`): a rollup names those whatever decided it, as it names EN 301 549's.
107
+ - `composites()`: rollups of the standard's own, such as one per requirement. They carry the rule tag, so only a run that asks for the standard produces them, and the HTML report shows them in a section of their own.
108
+ - `report`: the dictionary key of the note above that section (`noteKey`), and the language of the rollup titles when it is not the scan's (`titleLang`).
109
+ - `validate(rules)`: checks the standard's own tables against the rules that exist. The build fails on any problem it returns.
110
+
111
+ A standard built on a WCAG version reads that version from `@surea11y/core/wcag` (`src/wcag.js`) rather than keeping its own copy: `wcagCriteria('2.1', { levels: ['A', 'AA'] })` lists the criteria in force in 2.1 with their 2.1 titles and levels (4.1.1 Parsing is Level A there, and gone in 2.2), `wcagCriterion(sc, version)` looks one up, and `wcagTags('2.1')` gives the rule tags a profile on 2.1 A and AA selects, as EN 301 549's profiles do. It is the one core module a profile's own tables may require (see [`profiles/README.md`](../profiles/README.md#what-a-profile-may-use)).
112
+
113
+ `tests/coverage/standards.test.js` holds every registered standard to the contract. What stays per standard is its table, its tests, its rules, and a public export if tools need the reverse view, as `@surea11y/core/en301549` does.
114
+
115
+ A standard is compiled into the engine. At run time, a custom rule (`engineOptions.customRules`) can name any standard in its own `normativeMappings`, and the result keeps those entries as written, but only registered standards get a profile, a `mappings` switch, opt-in rules, rollups, or a place in SARIF, JUnit and the HTML report.
116
+
56
117
  ## What this engine cannot tell you
57
118
 
58
119
  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
120
 
60
121
  What surea11y *can* give you, honestly:
61
- - Every `fail` is a real, deterministic, normative violation — never a guess.
122
+ - Every `fail` is a real, deterministic, normative violation of the standard and version you targeted — never a guess. By default that standard is WCAG: a rule for a requirement only another standard makes (a rule for a national standard's own requirement, say) is opt-in and runs only when you target that standard ([`ENGINE_OPTIONS.md`](./ENGINE_OPTIONS.md#opt-in-rules)), so a WCAG scan never fails a page for something WCAG does not require. The one exception is a run that asks for every rule (`engineOptions.optInRules`): it targets no single standard, and says so in `engine.optInRules`.
62
123
  - Every `cantTell` is an explicit flag for human review, not a swallowed uncertainty.
63
124
  - 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
125