@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.
- package/CHANGELOG.md +47 -0
- package/README.md +24 -38
- package/docs/ACT_RULE_MAPPING.md +8 -6
- package/docs/API_STABILITY.md +51 -3
- package/docs/BINDING_AUTHORS_GUIDE.md +104 -2
- package/docs/DESIGN_CHALLENGES.md +66 -0
- package/docs/EARL.md +100 -0
- package/docs/ENGINE_OPTIONS.md +28 -2
- package/docs/INTEGRATION.md +4 -2
- package/docs/LIMITATIONS.md +3 -1
- package/docs/OUTPUT_SCHEMA.md +44 -6
- package/docs/POLICY.md +1 -1
- package/docs/RULE_AUTHORING.md +11 -12
- package/docs/RULE_CATALOG.md +76 -26
- package/docs/RULE_HELPERS.md +333 -0
- package/docs/RULE_TAXONOMY.md +25 -4
- package/docs/SARIF.md +21 -2
- package/docs/WCAG_CONFORMANCE.md +9 -1
- package/package.json +9 -3
- package/src/checks/automatic/aria-allowed-attr.js +6 -0
- package/src/checks/automatic/aria-allowed-role.js +32 -23
- package/src/checks/automatic/aria-braille-equivalent.js +18 -10
- package/src/checks/automatic/aria-conditional-attr.js +17 -10
- package/src/checks/automatic/aria-deprecated-role.js +12 -0
- package/src/checks/automatic/aria-hidden-body.js +1 -1
- package/src/checks/automatic/aria-prohibited-attr.js +5 -0
- package/src/checks/automatic/aria-prohibited-children.js +6 -6
- package/src/checks/automatic/aria-required-attr.js +59 -12
- package/src/checks/automatic/aria-required-children.js +33 -16
- package/src/checks/automatic/aria-required-parent.js +32 -6
- package/src/checks/automatic/aria-role-name-present.js +1 -1
- package/src/checks/automatic/aria-roles-valid.js +52 -21
- package/src/checks/automatic/aria-valid-attr-value.js +74 -21
- package/src/checks/automatic/aria-valid-attr.js +14 -9
- package/src/checks/automatic/avoid-inline-spacing.js +133 -6
- package/src/checks/automatic/contrast-computable.js +10 -0
- package/src/checks/automatic/contrast-enhanced.js +12 -0
- package/src/checks/automatic/contrast-minimum.js +12 -0
- package/src/checks/automatic/css-orientation-lock.js +42 -5
- package/src/checks/automatic/duplicate-id-aria.js +5 -0
- package/src/checks/automatic/duplicate-id.js +13 -8
- package/src/checks/automatic/form-control-single-label.js +9 -0
- package/src/checks/automatic/identical-iframes-same-purpose.js +229 -0
- package/src/checks/automatic/iframe-focusable-content.js +5 -0
- package/src/checks/automatic/label-in-name.js +38 -56
- package/src/checks/automatic/link-in-text-block.js +279 -23
- package/src/checks/automatic/target-size-minimum.js +84 -5
- package/src/checks/automatic/td-has-header.js +19 -18
- package/src/checks/manual/form-control-label-quality-manual.js +134 -24
- package/src/checks/manual/landmark-complementary-is-top-level-manual.js +231 -0
- package/src/checks/manual/password-paste-enabled-manual.js +255 -0
- package/src/core.js +3863 -44184
- package/src/earl.js +144 -0
- package/src/sarif.js +22 -2
- package/surea11y.browser.js +10 -41039
- package/surea11y.i18n.de.js +2 -21
- package/surea11y.i18n.es.js +2 -21
- package/surea11y.i18n.fr.js +2 -21
- /package/src/checks/automatic/{role-img-alt-present.js → role-img-text-alternative-present.js} +0 -0
package/CHANGELOG.md
CHANGED
|
@@ -4,6 +4,53 @@ All notable changes to this project are documented here, in [Keep a Changelog](h
|
|
|
4
4
|
|
|
5
5
|
## [Unreleased]
|
|
6
6
|
|
|
7
|
+
## [1.7.0] - 2026-08-29
|
|
8
|
+
|
|
9
|
+
### Added
|
|
10
|
+
- `@surea11y/core/earl` renders scan results as an EARL 1.0 report in JSON-LD — the W3C vocabulary for stating what a tool tested and what it found, and the format the ACT Rules community group accepts as an implementation report. `renderEarlReport(results, { assertor, mode })` takes an array as readily as one result, groups the graph by `TestSubject` as the ACT context requires, and sorts subjects by source and assertions by rule id so the same inputs give byte-identical output and a diff between engine versions means something. Unlike the SARIF and HTML reporters, which carry violations only, every rule that ran becomes an assertion: `pass` and `inapplicable` are what distinguish "checked and found nothing to check" from "does not implement that rule". Success Criteria come out as `WCAG2:<criterion-id>` derived from the criterion's own title, reading only the mappings that state a conformance level, since `normativeMappings` also carries Understanding-document references and other standards under the same `standard: "WCAG"`. A rule mapping to no criterion omits `isPartOf` rather than asserting an empty list. See [`docs/EARL.md`](./docs/EARL.md).
|
|
11
|
+
- The engine's extension boundary is declared. `src/index.js` re-exports the generated core verbatim, so the public surface was whatever the build happened to emit — 19 symbols, of which the six first-party consumers use two, and one of which (`__internal`) hands out an engine internal. `docs/API_STABILITY.md` now names the supported set (`runa11yCoreInPage`, `runDomRulesInPage`, `runa11yCoreAcrossFrames`, `a11yCoreEnableFrameResponder`, `getChecksCatalog`, `getRulesCatalog`) and lists the rest as exported-but-internal, free to change in a minor. The classification lives in `scripts/data/public-api.json` and `tests/public-api.test.js` fails when a new export appears unclassified, so a leak has to be a decision. Nothing is removed: that is a candidate for the next major, and no consumer needs it yet.
|
|
12
|
+
- The custom-rule descriptor is covered by semver. `engineOptions.customRules` was documented in full but appeared nowhere in the stability contract, so the one real plugin API carried no promise. `id`, `meta`, `runInPage(ctx)`, the optional `applicability(ctx)` and `data`, the `ctx.helpers` a rule receives, the result it returns, and the function-or-source-string form a binding needs to cross a realm boundary are all stable now. `engineOptions.policyContract`/`policy` and the reporter entry points are documented as the other two extension points.
|
|
13
|
+
- `overriddenBuiltinIds` joins the stable top-level result fields. It is always emitted and fully documented in `OUTPUT_SCHEMA.md`, but was missing from the stable list despite being how a consumer detects a `customRules` entry shadowing a built-in id.
|
|
14
|
+
- Rule ids and reason codes are now a documented contract, inventoried in `scripts/data/finding-ids.json` and enforced by `tests/finding-ids.test.js`. Both feed the finding fingerprint — `computeBaselineKey(ruleId, reasonCode, html)`, which backs both stored baselines and the SARIF `partialFingerprints` GitHub Code Scanning tracks alerts by — so either one changing silently unsuppresses every baselined finding and makes every open alert close and reopen as new. `data.details.reasonCode` moves out of `API_STABILITY.md`'s explicitly-unstable list into the stable set, as a deliberate exception to the rest of `data.details`: a rule may gain a code in a minor release, but a shipped one does not change, and a published rule id is removed or renamed only through a `deprecated`/`replacedBy` entry. Regenerate with `npm run finding-ids`. The build already rejected a renamed rule that a composite references; the inventory covers the rules no composite mentions, which it did not. See [`docs/API_STABILITY.md`](./docs/API_STABILITY.md#finding-identity).
|
|
15
|
+
- A `cantTell` occurrence can now say why it could not be decided. `occurrences[i].uncertainty` carries a `code` from a closed vocabulary — `not-computable`, `runtime-dependent`, `spec-only`, `equivalence-unknown`, `judgement-required`, `out-of-scope` — alongside `needed`, one sentence naming what would settle the question, and `evidence`, what the rule did establish so a reviewer starts from the engine's work rather than repeating it. The reason for a `cantTell` was previously only in `data.details.reasonCode`, which is per-rule, free-form and documented as not a stable contract, so nothing could branch on it. The field is present only on a `cantTell`-tier occurrence: on a `fail`-tier one it would claim the rule both decided and did not, and the engine drops it. Every automatic rule that can report `cantTell` carries it — the ARIA family that this release regraded reports `spec-only`, the contrast and CSS rules that cannot read their inputs report `not-computable`, and the target-size and label rules report `judgement-required` or `equivalence-unknown`. The engine attaches `out-of-scope` itself to the occurrences behind a `wcagVersionScope` coercion. A test holds the line so a new rule cannot report `cantTell` without saying why, and `validate:rules` rejects a code outside the vocabulary. Manual rules do not carry it, since `judgement-required` is what `type: "manual"` already means. Purely additive, so no `schemaVersion` bump. See [`docs/OUTPUT_SCHEMA.md`](./docs/OUTPUT_SCHEMA.md#uncertainty-codes).
|
|
16
|
+
- `occurrences[i].occurrenceOutcome` is documented. It has been in the output since rules began grading findings into a confident `fail` tier and a needs-review `cantTell` tier, but `OUTPUT_SCHEMA.md` never described it, so the reason a `fail` result can carry `cantTell` occurrences was undocumented. No behaviour change.
|
|
17
|
+
- `engineOptions.wcagVersion` (`'2.0'`, `'2.1'` or `'2.2'`) sets which version of WCAG a scan is conformance-testing against. It defaults to whatever your version-origin tags imply, and to `'2.2'` when they imply nothing, and the resolved target comes back on every result as `engine.wcagVersion`. See [`docs/ENGINE_OPTIONS.md`](./docs/ENGINE_OPTIONS.md).
|
|
18
|
+
- `docs/RULE_HELPERS.md`: reference for every function on `ctx.helpers` available to a rule's `runInPage` — around 35 flat helpers plus the `contrast.*`/`aria.*` namespaces. `docs/RULE_AUTHORING.md` §6 previously named only a dozen of them inline; the rest were discoverable only by reading `src/core/dom-helpers.js` directly. §6 now points here instead.
|
|
19
|
+
- `password-paste-enabled` raises an authentication field carrying an inline paste handler for review, the first coverage of WCAG 3.3.8. A password manager, or the clipboard for a one-time code, is the mechanism the criterion asks for, and blocking paste removes it. Advisory and capped at `cantTell`: whether a handler really stops the user depends on script the markup does not carry, so the two cases are reported apart rather than decided — one that only cancels, and one that goes further and may be re-inserting the text. In scope are `current-password`, `new-password` and `one-time-code` fields, plus `<input type="password">` unless its `autocomplete` names another purpose.
|
|
20
|
+
- `landmark-complementary-is-top-level` reports a complementary landmark nested inside another landmark, completing a family that already covered banner, contentinfo and main. Advisory and capped at `cantTell`, like its siblings. An unnamed `<aside>` inside sectioning content is not reported, since HTML-AAM leaves it no complementary role to nest.
|
|
21
|
+
- `@surea11y/core/i18n/<locale>` resolves each locale side file by path, alongside the existing `@surea11y/core/browser`. A binding that injects the standalone bundle into a page needs the matching dictionary to honour `engineOptions.locale`, and the exports map previously put both out of reach. An unshipped locale fails to resolve rather than resolving to nothing, so a caller can tell the difference and fall back. See [`docs/BINDING_AUTHORS_GUIDE.md`](./docs/BINDING_AUTHORS_GUIDE.md).
|
|
22
|
+
- `identical-iframes-same-purpose` checks that `<iframe>`/`<frame>` elements sharing an accessible name embed the same resource, implementing ACT rule 4b1c6c. It sits alongside `iframe-title-unique`, which asks the stricter and different question of whether the `title` attribute repeats at all: this one keys on the computed accessible name, counts only frames included in the accessibility tree, and judges the resource behind the name. `src` values are compared as resolved absolute URLs with the fragment removed and a trailing slash normalised away, so one directory written both ways is a single resource. Frames resolving to different URLs are reported `cantTell`, never `fail`: different resources can still be equivalent — differently worded copies of a page, or two adverts serving the same purpose — and neither the markup nor the embedded documents settle it, since content differing is exactly what those equivalent cases look like.
|
|
23
|
+
|
|
24
|
+
### Changed
|
|
25
|
+
- A rule mapped only to a Success Criterion the target WCAG version removed can no longer report `fail`. Under the default 2.2 target that means `duplicate-id`: it still runs and still reports every duplicate it finds, but comes back `cantTell` with a `wcagVersionScope` field naming the criterion 2.2 dropped, so a default scan is never gated by SC 4.1.1. Coercing rather than excluding keeps the defect visible, since a duplicate id still breaks `<label for>`, fragment navigation and `getElementById` whatever the standard says. Target 2.0 or 2.1 for the real failure, or keep excluding the rule outright with `excludeTags: ['wcag22-removed']`.
|
|
26
|
+
- `aria-controls` pointing at an id no element has is no longer a failure of `aria-valid-attr-value`. The menu, listbox or panel it names is routinely built when the widget opens, so a static scan that cannot find it has not established a defect. A collapsed element (`aria-expanded="false"` or `aria-selected="false"`) passes outright, since the absence is what that state means; anything else is reported as `cantTell` for review. Every other ID-reference attribute is unchanged: a dangling `aria-labelledby` or `aria-owns` names content that was supposed to be there already.
|
|
27
|
+
- The standalone browser bundle and its locale side files are minified. `surea11y.browser.js` goes from 1430 KB to 706 KB, and from 294 KB to 165 KB over the wire. Most of a page's download was the 130 inlined rule bodies, and most of those were their own comments. The global, the API and the result are unchanged; the readable form of every rule remains its own module under `src/checks`.
|
|
28
|
+
- `runa11yCoreAcrossFrames` scans its own frame through `runa11yCoreInPage` instead of carrying a second copy of the rule catalog and the shared runner block. The generated `src/core.js` drops from 4.34 MB to 2.67 MB, and the published package from 7.7 MB to 5.3 MB unpacked. Both functions stay usable the bundler-free way they always were — raw source injected into a page — since `runa11yCoreInPage` is itself self-contained and free of `require()`.
|
|
29
|
+
- The composite rollup moved out of `runCore` into its own function, `rollupCompositeResults`, in `src/core/dom-runner.js`. Results are unchanged. It was a long inline block nothing else could reach; splitting it keeps `runCore` readable and lets the rollup be called on its own. Internal only, not part of the package's public exports.
|
|
30
|
+
- `aria-required-children`, `aria-prohibited-children` and `aria-required-parent` map to SC 1.3.1 Info and Relationships instead of 4.1.2 Name, Role, Value, and carry `wcag131` in place of `wcag412`. The ACT rules these three implement, `bc4a75` and `ff89c9`, name 1.3.1 as their only requirement, and it is the criterion the finding actually describes: a `role="listitem"` outside any list, or a `role="list"` owning no item, misstates the structure exposed to assistive technology rather than the name, role or value of a control. It also puts them beside the native-HTML checks that ask the same question, `listitem-parent-valid` and `list-children-valid`, which were already 1.3.1. Level A either way, and all three still run on a default scan and report exactly what they reported before; what moves is which composite the verdict lands in, `wcag-1.3.1-info-and-relationships` gaining the three and `wcag-4.1.2-aria-validity` losing them, and what a tag-filtered run selects, since `tags: ['wcag412']` no longer picks them up. Their coverage facets move to 1.3.1 with them.
|
|
31
|
+
- `aria-hidden-body` and `aria-role-name-present` carry the `aria` tag the rest of the family already had. Both are entirely about ARIA usage — `aria-hidden` on the document body, and the roles WAI-ARIA requires an accessible name for — so a run filtered on `tags: ['aria']` was silently missing two of them. No other tag changes, and no rule changes what it evaluates or reports.
|
|
32
|
+
- `aria-required-parent` honours `aria-busy="true"` on an ancestor, WAI-ARIA's own escape hatch for a widget script has not finished assembling. `aria-required-children` and `aria-prohibited-children` already read it off the container they check; from the item's side the marked element is an ancestor, so the walk looks up rather than at the element itself, and only the exact string `"true"` counts. It also outranks the roleless-generic-parent rule, since `aria-busy` is a global ARIA attribute and would otherwise block the context search and fail the very markup the spec says to mark. A `role="option"` inside a container still loading its `role="listbox"` wrapper is `notApplicable` now, not a failure.
|
|
33
|
+
- `docs/OUTPUT_SCHEMA.md` no longer defines `fail` as a high-confidence outcome. Eight automatic rules ship `fail` at `confidence: "medium"`, and that was never a contradiction: the outcome describes the decision procedure, which guesses at nothing, while `confidence` describes the model it decides against — curated WAI-ARIA tables, native-role mappings, an accessibility tree inferred from static markup. The outcome table, the `type: "manual"` note, `POLICY.md`, `SARIF.md` and `LIMITATIONS.md` all said "deterministic, high-confidence"; they say "deterministic" now, and the confidence section names the rules and explains what `medium` on a `fail` means. Documentation only, no behaviour change.
|
|
34
|
+
- `aria-required-children` no longer fails a container for being empty; it reports `cantTell` for every finding. The rule asks only whether the required content is present, and a container that owns nothing conveys nothing false — an empty `role="list"` is announced as a list with no items, which is what it is. Whether the content a container does own is valid is `aria-prohibited-children`'s decision, and that rule still fails, so a `role="button"` among list items or a `role="tablist"` of plain buttons is caught exactly as before, at the same criterion. This also settles an inconsistency with the native-HTML rules, which already judge only the children that exist: `<ul></ul>` passed while `<div role="list"></div>` failed. One shape loses its failure and is now reported for review instead: a container whose items never got their role, `<div role="list"><div>Item</div></div>`. Applicability, the `aria-busy` exemption and `aria-owns` resolution are unchanged.
|
|
35
|
+
- Six ARIA rules report `cantTell` where the violation leaves the exposed name, role and value intact, instead of `fail`. ACT maps five of them to WAI-ARIA author requirements rather than to WCAG, and lists 1.3.1/4.1.2 as "less strict" secondary requirements that an implicit role or a spec-supplied default can still satisfy; the engine was asserting a Level A failure on all of them. Two grade per finding: `aria-required-attr` fails where ARIA supplies no stand-in (`aria-checked` on checkbox/radio/switch/menuitem*, `aria-valuenow` on slider/scrollbar/meter and a focusable separator) and reports `cantTell` where it does (`aria-expanded` on combobox, `aria-level` on heading), the table generated from aria-query's `requiredProps` by `scripts/generate-aria-tables.js` so the two tiers cannot drift from the spec; `aria-roles-valid` fails on a roleless host left exposed as generic and reports `cantTell` where a native role survives the bad token. Four report `cantTell` throughout: `aria-valid-attr` (an undefined attribute is inert), `aria-allowed-role` (an ARIA-in-HTML author requirement with no ACT rule and no WCAG mapping anywhere), `aria-braille-equivalent` and `aria-conditional-attr`, the last two also dropping from `serious` to `moderate`. Every finding is still reported with the same occurrences; what changes is that a page whose only ARIA defects are of this kind comes back `cantTell` on `wcag-4.1.2-aria-validity` rather than `fail`, so a CI gate on `fail` stops gating on them. ACT `674b10`, `4e8ab6` and `5f99a7` still run clean (25 cases, 0 mismatches). Reasoning in [`docs/DESIGN_CHALLENGES.md`](./docs/DESIGN_CHALLENGES.md).
|
|
36
|
+
- `aria-allowed-role` no longer claims a WCAG Success Criterion. ARIA-in-HTML's permitted-roles table is an author conformance requirement of that specification: no ACT rule covers it, and no source maps it to a criterion, so declaring SC 4.1.2 at Level A overstated every finding it made. It now carries `best-practice` in place of `wcag2a`/`wcag412`, with `wcagSc: []`, no `normativeMappings` and no coverage facet, and it has left the `wcag-4.1.2-aria-validity` composite (13 contributors) and the 4.1.2 facet registry. It still runs on a default scan and still reports the same findings at `cantTell`; what changes is that a run filtered on `tags: ['wcag2a']` or `['wcag412']` no longer selects it, and a page whose only ARIA-in-HTML nit is this one now reaches `pass` on that composite instead of `cantTell`. This is the first automatic rule in the engine with no WCAG mapping, alongside the 25 manual `best-practice` rules that already had none.
|
|
37
|
+
- `docs/RULE_TAXONOMY.md` §1.1 no longer says an automatic rule may use `cantTell` only as a defensive fallback, "never as its primary intended path". Six rules now lead with it. The dividing line between `automatic` and `manual` is whether a rule can decide, not which outcome it reports: an automatic rule reporting `cantTell` has decided and is saying what it found, while a manual rule reports `cantTell` because the question is not decidable from markup. The three cases where `cantTell` is an automatic rule's primary path are listed there.
|
|
38
|
+
|
|
39
|
+
### Fixed
|
|
40
|
+
- A frame responder answered any window that could reach it, not just the frame embedding it. `a11yCoreEnableFrameResponder()` is documented as opting a frame in to being scanned *from above*, but the listener ran a scan for any sender: a sibling frame can obtain a reference through `parent.frames[i]` and `postMessage` across origins, and the reply carries `occurrences[].html` — DOM content the same-origin policy gives that sibling no way to read. An opener could do the same. A `run` command is now answered only when its sender is the direct parent, which the hop-by-hop relay makes the only legitimate case, and a window nothing embeds answers nobody. **Affects 1.3.0 through 1.6.0**, and only consumers that call `a11yCoreEnableFrameResponder()` in a framed page — the automation-driver patterns (`@surea11y/playwright`, `@surea11y/puppeteer`, the CLI) never use the responder and were never exposed.
|
|
41
|
+
- A reply could be accepted from a window the request never went to. Any window naming an in-flight `requestId` could settle it, forging a frame's scan result or its failure. Each pending request now records the window it was sent to and ignores answers from anywhere else, pings included — reachability is the addressed frame's verdict to give.
|
|
42
|
+
- `engineOptions.excludeSelectors` cost a multiple of the whole scan. Every rule queries through `isExcluded`, which walked an element's ancestor chain once per selector with nothing remembered between rules, so the work was repeated for all 130 of them: excluding a cookie banner and four ad slots turned a 2.3s scan of a 3574-element page into 10.5s, and 25 exclusions — an ordinary list for a real site — into 43s. Exclusion results are now memoized per element for the run, partitioned by the effective exclude list exactly as the selector cache already is, and the self-test uses `matches` against the element rather than `closest` against the chain, since a parent's answer already settles its descendants. The same page with 25 exclusions takes 3.1s, and the raw matching floor is 18ms against the 5588ms an equivalent scan used to spend. Rule-scoped `rules[ruleId].excludeSelectors` keep their own cache partition, and a malformed selector still excludes nothing without disabling the usable selectors beside it. `perfStats` gains `excluded.hit`/`excluded.miss`.
|
|
43
|
+
- A rule that could not check anything said so, and SARIF dropped it. The contrast rules attach an occurrence to their `notApplicable` result naming the eligible text count and pointing at `contrast-computable`; the HTML report showed it, SARIF did not, so a CI pipeline reading only SARIF saw no contrast alerts and had no way to tell a clean page from one where contrast was never computable. Those occurrences now reach SARIF as `note`-level entries in `runs[0].invocations[0].toolExecutionNotices`, each carrying `associatedRule.id`. They are deliberately not results: every SARIF result renders as an alert, and "not evaluated" is not one, so nothing new gates a build or appears in Code Scanning. The block is emitted only when there is something to say.
|
|
44
|
+
- `OUTPUT_SCHEMA.md`, `SARIF.md` and `EARL.md` all stated that a `notApplicable` result carries no occurrences. It can: a rule that had nothing to judge may attach one occurrence saying why, and the contrast rules do exactly that when no text had a computable background — the message names the eligible text count and points at `contrast-computable`. A consumer that took the documented shape at face value read `occurrences.length` as a violation count and got one for a rule that flagged nothing. The behaviour is deliberate and unchanged; the three documents now describe it, `OUTPUT_SCHEMA.md` notes that such an occurrence carries an empty `selector` because it describes the scan rather than an element, and `SARIF.md` states that SARIF omits it — a SARIF consumer treats every result as an alert, so a pipeline reading only SARIF cannot tell "checked, nothing to flag" from "could not check" and needs `checksResults` or the HTML report for that distinction.
|
|
45
|
+
- `OUTPUT_SCHEMA.md` claimed without qualification that the engine verifies a reported `selector` resolves to the element it names. `page-title-present` is the exception, reporting `head > title` and an `html` of `<title>(missing)</title>` for a page that has neither, since its finding is the absence itself. Both are constants and the baseline fingerprint they feed stays stable; the field now says so rather than promising a resolvable selector.
|
|
46
|
+
- `LIMITATIONS.md` now records that scan time under jsdom grows with the square of DOM depth. jsdom resolves inherited CSS by walking the ancestor chain on every `getComputedStyle` call, so 4000 elements in one chain cost 8.3s of `getComputedStyle` alone against 0.25s for the same 4000 as siblings, with no engine code involved. The engine's own walks are capped and stay linear. A real browser computes inherited style natively and does not have this shape.
|
|
47
|
+
- `buildStructuralPath` was the one ancestor walk here with no bound. A consistent tree ends it when an element is not found among its parent's children, but a parent chain that cycles while still reporting itself as each other's child never terminates. It now gives up past a depth no real document reaches and returns `null`, matching what the field already documents for a path it cannot determine.
|
|
48
|
+
- `avoid-inline-spacing` no longer fails text that cannot wrap. ACT 78fd32/24afc2/9e45ec apply only to text containing a soft wrap break, and running the official corpus turned this up as the engine's one false positive across 798 cases: a fixed-width paragraph inside a horizontally scrolling container, which never wraps however the viewport changes, was reported as a violation. Layout would settle whether text wraps and a static scan cannot, but two shapes do establish that no wrap is possible — text not allowed to wrap, and a fixed-width element inside a horizontally scrolling ancestor — and those now report `cantTell` with the `not-computable` uncertainty code and a new `INLINE_SPACING_NO_SOFT_WRAP` reason code. Everything else is still treated as wrapping, so an ordinary forced value below the metric fails exactly as before. A false positive is the one thing that blocks an ACT implementation report, which is why this is graded rather than left as the documented gap it had been.
|
|
49
|
+
- `link-in-text-block`, `avoid-inline-spacing` and `css-orientation-lock` reported `pass` for candidates they never evaluated. Each counted an element as applicable, met a condition it could not resolve, skipped it, and fell through to `pass` — the same clean result whether the element was checked and found sound or never checked at all. They report `cantTell` for those candidates now, the computability gate `RULE_TAXONOMY.md` §1.1 already allows automatic rules. A proven failure still outranks an undecided candidate, so a page with both fails as before. Concretely: `link-in-text-block` skipped a link whose contrast against the surrounding text was not computable, `avoid-inline-spacing` an `!important` spacing value that resolved to no ratio, and `css-orientation-lock` every cross-origin stylesheet — so a page with one readable stylesheet and three unreadable ones asserted no orientation lock existed.
|
|
50
|
+
- `link-in-text-block` treated every link as underlined outside a real browser. It read `text-decoration-line` and the `text-decoration` shorthand as one set of tokens, which only holds where the two agree. jsdom does not cascade the property: the shorthand reads back as the user agent's `underline` for every `<a>` whatever the author stylesheet says, and the longhand as `none` unless the author wrote the longhand themselves, so `text-decoration: underline` and `text-decoration: none` are indistinguishable in the computed style. Reading the longhand alone inverts the error into a false failure on correctly underlined links. A conforming CSSOM serialises the shorthand with the line value first, so disagreement between the two is now the signal to distrust both, and the rule resolves the declaration from the author stylesheets instead, as `css-orientation-lock` and `css-focus-indicator-suppressed` already read the CSSOM. `:hover` and other state rules are excluded, since they do not describe the link's resting appearance, and inline style outranks the stylesheets.
|
|
51
|
+
- `link-in-text-block` now tests the cues that do not depend on `text-decoration` first, so a link distinguished by font weight, font style or sufficient contrast is decided even where decoration cannot be read.
|
|
52
|
+
- `contrast-minimum`, `contrast-enhanced` and `contrast-computable` skipped text assigned straight to a shadow root. A text node whose parent is the shadow root itself has no parent element, and the scan resolved colors from the parent element alone, so `shadowRoot.textContent = 'Some text'` was walked and then dropped with no candidate recorded — a component rendering its text that way was reported `notApplicable` rather than checked. The host carries the inherited color and background that text renders with, so it is what the scan attributes the text to now. Text inside an element within a shadow root was always found and is unchanged.
|
|
53
|
+
|
|
7
54
|
## [1.6.0] - 2026-08-23
|
|
8
55
|
|
|
9
56
|
### Added
|
package/README.md
CHANGED
|
@@ -42,6 +42,20 @@ falls on. Each rule makes a single deterministic decision:
|
|
|
42
42
|
`cantTell` is the point of the project. An engine that quietly discards what it
|
|
43
43
|
cannot determine produces a shorter report and a false sense of coverage.
|
|
44
44
|
|
|
45
|
+
### Checked against the ACT corpus
|
|
46
|
+
|
|
47
|
+
Every rule with a [W3C ACT Rules](https://act-rules.github.io/) counterpart runs
|
|
48
|
+
against ACT's own published test cases: 798 examples across 58 rules. The engine
|
|
49
|
+
fails none of the examples ACT marks `passed` or `inapplicable`, so it reports no
|
|
50
|
+
false positives against that corpus. Where it cannot decide a case it returns
|
|
51
|
+
`cantTell`, which ACT permits for an automated implementation.
|
|
52
|
+
|
|
53
|
+
Thirty-one examples ACT marks `failed` go unflagged. Most are judgement calls,
|
|
54
|
+
such as whether a heading describes the content under it.
|
|
55
|
+
[`docs/ACT_RULE_MAPPING.md`](./docs/ACT_RULE_MAPPING.md) lists every one with the
|
|
56
|
+
reasoning, and `node scripts/act-testcase-check.js` reproduces the figures. They
|
|
57
|
+
cover the rules that have an ACT counterpart.
|
|
58
|
+
|
|
45
59
|
## What this engine does not detect
|
|
46
60
|
|
|
47
61
|
Keyboard traps, reflow and clipping at 400% zoom, anything that only exists
|
|
@@ -408,6 +422,7 @@ and progressively explore more advanced features.
|
|
|
408
422
|
| `docs/BASELINE.md` | CI baseline/allowlist: gate builds only on new violations. |
|
|
409
423
|
| `docs/REPORT.md` | Self-contained HTML report: browsable summary, WCAG rollup, filterable occurrence table. |
|
|
410
424
|
| `docs/SARIF.md` | SARIF 2.1.0 report for GitHub Code Scanning and other SARIF dashboards. |
|
|
425
|
+
| `docs/EARL.md` | EARL 1.0 report in JSON-LD: the W3C interchange format, and the ACT implementation-report format. |
|
|
411
426
|
| `docs/CI_INTEGRATIONS.md` | GitHub Actions and Bitbucket Pipelines templates wrapping the CLI. |
|
|
412
427
|
| `docs/ENGINE_OPTIONS.md` | Configuration, filtering, policies and localization. |
|
|
413
428
|
| `docs/INTEGRATION.md` | Using surea11y with jsdom, Playwright, Puppeteer, Selenium, Cypress and other drivers. |
|
|
@@ -419,6 +434,7 @@ and progressively explore more advanced features.
|
|
|
419
434
|
| `docs/LIMITATIONS.md` | Structural limitations of automated accessibility testing. |
|
|
420
435
|
| `docs/TROUBLESHOOTING.md` | Frequently asked questions and common issues. |
|
|
421
436
|
| `docs/RULE_AUTHORING.md` | Writing custom accessibility rules. |
|
|
437
|
+
| `docs/RULE_HELPERS.md` | Reference for every `ctx.helpers` function available to a rule. |
|
|
422
438
|
| `docs/RULE_TAXONOMY.md` | Rule categorization model. |
|
|
423
439
|
| `docs/ACT_RULE_MAPPING.md` | Which ACT rules this engine implements, which it doesn't, and where the two differ by design. |
|
|
424
440
|
| `docs/DESIGN_CHALLENGES.md` | Open and settled design questions, each with the reasoning behind the call. |
|
|
@@ -438,16 +454,13 @@ surea11y is built on a simple principle:
|
|
|
438
454
|
> Automate what can be determined objectively. Never pretend to automate
|
|
439
455
|
> what cannot.
|
|
440
456
|
|
|
441
|
-
|
|
442
|
-
|
|
443
|
-
|
|
444
|
-
|
|
457
|
+
Some WCAG requirements can be checked with complete confidence. Others
|
|
458
|
+
need human judgement, knowledge of context, or usability evaluation. A
|
|
459
|
+
single score or a pass/fail verdict flattens that difference; surea11y
|
|
460
|
+
reports it.
|
|
445
461
|
|
|
446
|
-
|
|
447
|
-
|
|
448
|
-
That philosophy influences every rule in the engine and is the reason
|
|
449
|
-
outcomes such as `cantTell` and `notApplicable` exist. They communicate
|
|
450
|
-
uncertainty honestly instead of encouraging misleading conclusions.
|
|
462
|
+
This is why `cantTell` and `notApplicable` exist as outcomes, and it
|
|
463
|
+
shapes every rule in the engine.
|
|
451
464
|
|
|
452
465
|
### What surea11y won't catch
|
|
453
466
|
|
|
@@ -468,11 +481,6 @@ These are the cases where the engine reports `cantTell`, and where a
|
|
|
468
481
|
human reviewer's judgement remains necessary. See
|
|
469
482
|
`docs/LIMITATIONS.md` for the complete list of structural limitations.
|
|
470
483
|
|
|
471
|
-
The objective of the project is not to replace accessibility experts. It
|
|
472
|
-
is to remove repetitive verification work, provide reliable automated
|
|
473
|
-
feedback to developers and help teams integrate accessibility into their
|
|
474
|
-
normal development process.
|
|
475
|
-
|
|
476
484
|
---
|
|
477
485
|
|
|
478
486
|
## Project Structure
|
|
@@ -490,6 +498,7 @@ src/
|
|
|
490
498
|
baseline.js # Baseline entry point (@surea11y/core/baseline)
|
|
491
499
|
report.js # HTML report entry point (@surea11y/core/report)
|
|
492
500
|
sarif.js # SARIF entry point (@surea11y/core/sarif)
|
|
501
|
+
earl.js # EARL entry point (@surea11y/core/earl)
|
|
493
502
|
|
|
494
503
|
checks/
|
|
495
504
|
automatic/ # Deterministic automated rules
|
|
@@ -550,9 +559,7 @@ engine consistency to ensure deterministic results across releases.
|
|
|
550
559
|
|
|
551
560
|
Contributions are welcome.
|
|
552
561
|
|
|
553
|
-
|
|
554
|
-
new accessibility rule, please keep the project's core principles in
|
|
555
|
-
mind:
|
|
562
|
+
Bug fix, documentation, or a new rule — the same principles apply:
|
|
556
563
|
|
|
557
564
|
- deterministic behaviour;
|
|
558
565
|
- objective rule evaluation;
|
|
@@ -603,24 +610,3 @@ This project is released under the Mozilla Public License 2.0 (MPL-2.0).
|
|
|
603
610
|
See the accompanying `LICENSE` file for the complete license text.
|
|
604
611
|
|
|
605
612
|
MPL-2.0 is file-level copyleft: it applies to `@surea11y/core`'s own source files, not to code that merely depends on it. A project that installs `@surea11y/core` as a normal package dependency and imports its public API — without copying or modifying this repository's source files — is unaffected by MPL-2.0 and may keep its own license (including a permissive one like MIT).
|
|
606
|
-
|
|
607
|
-
---
|
|
608
|
-
|
|
609
|
-
## Final Notes
|
|
610
|
-
|
|
611
|
-
surea11y was created with a simple goal: make accessibility testing
|
|
612
|
-
trustworthy enough to become part of everyday software engineering.
|
|
613
|
-
|
|
614
|
-
It does not attempt to replace manual accessibility reviews, usability
|
|
615
|
-
testing or expert judgement. Instead, it focuses on providing reliable
|
|
616
|
-
automated verification for the parts of accessibility that can be
|
|
617
|
-
evaluated objectively.
|
|
618
|
-
|
|
619
|
-
By combining deterministic rules, standards traceability, stable
|
|
620
|
-
machine-readable output and honest reporting of uncertainty, surea11y
|
|
621
|
-
enables teams to detect accessibility issues earlier, reduce regressions
|
|
622
|
-
and build more accessible products with confidence.
|
|
623
|
-
|
|
624
|
-
Accessibility is not a checkbox performed before release. It is an
|
|
625
|
-
engineering practice that benefits from continuous feedback, and
|
|
626
|
-
surea11y is designed to become one of those feedback loops.
|
package/docs/ACT_RULE_MAPPING.md
CHANGED
|
@@ -7,7 +7,7 @@ Cross-reference between the [W3C ACT Rules](https://act-rules.github.io/rules/)
|
|
|
7
7
|
- **~2** are covered structurally by our composite/rollup layer, not a named rule
|
|
8
8
|
- **~46 are gaps**, no corresponding rule in this repo, listed in [Gaps](#gaps-no-corresponding-rule) below
|
|
9
9
|
|
|
10
|
-
**Every matched rule has now been run through ACT's own official test-case corpus** (`scripts/act-testcase-check.js`, 713 test cases across the 51-rule matched set of the time). Started at 86 mismatches; real bugs were fixed, mapping errors corrected, and every remaining mismatch triaged into a scope difference, a jsdom/environment limit, or a genuine open design question (tracked in [`docs/DESIGN_CHALLENGES.md`](./DESIGN_CHALLENGES.md)). A second pass then re-ran the whole corpus from a local checkout (see "Second pass" below) and repeated the exercise on what it turned up. Current state: **798 examples across the 58-rule matched set,
|
|
10
|
+
**Every matched rule has now been run through ACT's own official test-case corpus** (`scripts/act-testcase-check.js`, 713 test cases across the 51-rule matched set of the time). Started at 86 mismatches; real bugs were fixed, mapping errors corrected, and every remaining mismatch triaged into a scope difference, a jsdom/environment limit, or a genuine open design question (tracked in [`docs/DESIGN_CHALLENGES.md`](./DESIGN_CHALLENGES.md)). A second pass then re-ran the whole corpus from a local checkout (see "Second pass" below) and repeated the exercise on what it turned up. Current state: **798 examples across the 58-rule matched set, 31 mismatches, all explained** below or in that file, and — the figure that matters for an implementation report — **zero false positives**: no example ACT declares `passed` or `inapplicable` is failed by this engine, so every remaining mismatch is a case it does not catch rather than one it gets wrong; see "Progress" further down for the full per-rule breakdown. (The exact example count drifts slightly over time as ACT's own published corpus gains or loses cases; re-run `scripts/act-testcase-check.js` for the live figure rather than trusting this number indefinitely.)
|
|
11
11
|
|
|
12
12
|
Real rule bugs found and fixed this way, in rough chronological order:
|
|
13
13
|
- `button-name-present` wasn't crediting the UA-default label on `input[type=submit]`/`input[type=reset]` with no `value`, and wasn't honoring `role="none"`/`role="presentation"` conflict-resolution.
|
|
@@ -31,6 +31,7 @@ Real rule bugs found and fixed this way, in rough chronological order:
|
|
|
31
31
|
- `getContainmentRole` handed several native tags an implicit role in every context, where HTML-AAM makes them conditional: `<li>` is a `listitem` only inside `<ul>`/`<ol>`/`<menu>`, `<option>` only inside `select`/`datalist`/`optgroup`, the table family only inside a real table. ACT `bc4a75` fails `<div role="list"><li>Item</li>…</div>` for exactly that reason and the engine passed it. The three rules built on that helper (`aria-required-children`, `aria-prohibited-children`, `aria-required-parent`) all inherit the fix.
|
|
32
32
|
- `identical-links-same-purpose`'s `a[href]`-only selector missed `role="link"` elements entirely; ACT `fd3a94`/`b20e66`'s own failed examples are `<span role="link" tabindex="0" onclick="location='...'">`. Widened to `a[href], [role="link"]`, with a regex fallback that reads a `location`/`location.href`/`location.assign(...)`/`location.replace(...)` destination straight out of the `onclick` attribute value, a literal string already present in markup, no script execution needed. This rule is `cantTell`-capped, so an unrecognized `onclick` shape just costs recall, not a false fail.
|
|
33
33
|
- `iframe-name-present`'s focusability exemption only applied inside the `role="none"`/`"presentation"` branch; a plain `<iframe tabindex="-1">` with no role at all (ACT cae760's own passed example) still demanded a name. Per cae760's own Applicability text, an iframe is only in scope when it is BOTH accessibility-tree-eligible AND reachable by sequential focus navigation, unconditionally, not only as a role="none" carve-out. The focusability check now gates applicability directly, regardless of role.
|
|
34
|
+
- `avoid-inline-spacing` failed text that can never take a soft wrap break, which ACT 78fd32/24afc2/9e45ec exclude from applicability altogether. This was the engine's last false positive on the corpus, and the rule's own header had already predicted it: layout settles whether text wraps and a static scan cannot. Two shapes do establish that no wrap is possible without layout — text not allowed to wrap, and a fixed-width element inside a horizontally scrolling ancestor — and those now report `cantTell` instead of `fail`. Everything else is still treated as wrapping, so an ordinary forced value below the metric fails as before.
|
|
34
35
|
|
|
35
36
|
Mapping-table corrections found this way (data-only, no rule-code change):
|
|
36
37
|
- `bc4a75` was mapped to `aria-required-children` alone, but this repo splits ACT's single question into two atomic decisions: "does a required child exist" and "is every owned child allowed," the second being `aria-prohibited-children`. Measuring one rule against a two-part expectation reported the missing half as an engine gap; it was a mapping gap. Now a family match.
|
|
@@ -47,7 +48,7 @@ Gaps closed since:
|
|
|
47
48
|
- `oj04fd` "Element in sequential focus order has visible focus" is now partly covered by the new `css-focus-indicator-suppressed` rule: it reads the page's own stylesheets for a `:focus`/`:focus-visible` rule that removes the outline, and reports the tab stops it matches unless some other focus rule draws a replacement for them. ACT's expectation is a pixel comparison between the focused and unfocused states, which no static check performs, and its passed examples paint their indicator from an `onfocus` handler onto a sibling, so the rule is `cantTell`-capped and the mapping is `partial`.
|
|
48
49
|
- `cc0f0a` "Form field label is descriptive" is now partly covered by the new `form-control-label-quality` rule. Three shapes of a bad label are decidable from markup: a placeholder string, a label repeated on several fields with no *visible* heading, legend or row text telling them apart, and a label split between visible and hidden parts. The second and third are what ACT's own failed examples 4 and 5 test, and the rule catches both; the remaining three failures turn on the meaning of an ordinary word, so the mapping is `partial` and the rule is `cantTell`-capped.
|
|
49
50
|
- `5effbb` "Link in context is descriptive" is now partly covered by `link-name-quality`, which already flagged a curated list of generic phrases ("click here", "read more", ...) with no regard for context. It gained a second curated list, bare file-format names ("HTML", "PDF", "EPUB", ...), and both lists now check for adjacent context (an `aria-describedby` target, the enclosing list item/table cell/paragraph's own text, or, for the format-name list only, a table's first-row header) before flagging, so a phrase that context already resolves is left alone. Clean on 17 of ACT's 18 examples; the remaining one needs to judge whether an ordinary word ("Workshop") actually relates to a nearby paragraph, a step beyond a phrase list.
|
|
50
|
-
- `3ea0c8` "Id attribute value is unique" is closed by the new `duplicate-id` rule, clean against all 10 of ACT's examples, including the per-tree scoping that keeps the same id inside two different shadow roots from counting as a duplicate. It is the first rule in the catalog whose Success Criterion no longer exists in the current WCAG version, so it carries `wcag22-removed` alongside its `wcag2a` origin tag;
|
|
51
|
+
- `3ea0c8` "Id attribute value is unique" is closed by the new `duplicate-id` rule, clean against all 10 of ACT's examples, including the per-tree scoping that keeps the same id inside two different shadow roots from counting as a duplicate. It is the first rule in the catalog whose Success Criterion no longer exists in the current WCAG version, so it carries `wcag22-removed` alongside its `wcag2a` origin tag; under the default 2.2 target the engine reports its findings as `cantTell` rather than `fail`. See `docs/ENGINE_OPTIONS.md` for that and for excluding the rule outright.
|
|
51
52
|
|
|
52
53
|
### Second pass: the corpus read from a local checkout
|
|
53
54
|
|
|
@@ -73,9 +74,11 @@ We also have automatic rules with **no ACT counterpart at all** (see [Extra cove
|
|
|
73
74
|
|
|
74
75
|
### Progress: full validation results, by ACT rule
|
|
75
76
|
|
|
76
|
-
**Clean (0 mismatches):** `5f99a7`, `80f0bf`, `4c31df`, `73f2c2`, `97a4e1`, `cf77f2`, `b40fd1`, `46ca7f`, `6cfa84`, `307n5z`, `4e8ab6`, `a25f45`, `ffd0e9`, `b5c3f8`, `2779a5`, `5b7ae0`, `bf051a`, `qt1vmo`, `59796f`, `23a2a8`, `24afc2`, `9e45ec`, `c487ae`, `m6b1q3`, `bc659a`, `bisz58`, `b4f0c3`, `674b10`, `0ssw9k`, `3ea0c8`, `5c01ea`, `bc4a75`, `2ee8b8`, `e88epe`, `7d6734`, `de46e4`, `6a7281`, `8fc3b6`, `akn7bn`, `fd3a94`, `b20e66`, `cae760` (
|
|
77
|
+
**Clean (0 mismatches):** `5f99a7`, `80f0bf`, `4c31df`, `73f2c2`, `97a4e1`, `cf77f2`, `b40fd1`, `46ca7f`, `6cfa84`, `307n5z`, `4e8ab6`, `a25f45`, `ffd0e9`, `b5c3f8`, `2779a5`, `5b7ae0`, `bf051a`, `qt1vmo`, `59796f`, `23a2a8`, `24afc2`, `9e45ec`, `c487ae`, `m6b1q3`, `bc659a`, `bisz58`, `b4f0c3`, `674b10`, `0ssw9k`, `3ea0c8`, `5c01ea`, `bc4a75`, `2ee8b8`, `e88epe`, `7d6734`, `de46e4`, `6a7281`, `8fc3b6`, `akn7bn`, `fd3a94`, `b20e66`, `cae760`, `78fd32` (43 of 58 matched rules).
|
|
77
78
|
|
|
78
|
-
|
|
79
|
+
Two rules changed what they report after this table was last regenerated, both from `fail` to `cantTell`: `3ea0c8`/`duplicate-id` under the default WCAG 2.2 target, and `6a7281`/`aria-valid-attr-value` for an unresolved `aria-controls` target (see `docs/DESIGN_CHALLENGES.md`). The verdicts above should still hold, since `evaluate()` in `scripts/act-testcase-check.js` counts a `cantTell` carrying occurrences as satisfying an ACT "failed" expectation and a `cantTell` never breaks a "passed" one, but neither was re-run against the live corpus at the time (the site was unreachable from that environment). Re-run both when convenient.
|
|
80
|
+
|
|
81
|
+
**Remaining mismatches (31 total), all triaged.** Every one is an ACT `failed` example this engine does not flag — a coverage gap, which a partially consistent implementation is allowed — not an example it fails wrongly:
|
|
79
82
|
|
|
80
83
|
| ACT ID | Mismatches | Category |
|
|
81
84
|
|---|---|---|
|
|
@@ -87,7 +90,6 @@ We also have automatic rules with **no ACT counterpart at all** (see [Extra cove
|
|
|
87
90
|
| `oj04fd` | 1 | env/harness limit: ACT's one failed example keeps its `outline: none` in a linked stylesheet, which the example runner does not fetch, so no focus rule is visible to parse at all. Inlining that same CSS reports the element (`tests/engine-checks/manual/css-focus-indicator-suppressed.test.js` pins it); a real page hands the engine its stylesheets through the CSSOM |
|
|
88
91
|
| `cc0f0a` | 3 | inherent limitation: `form-control-label-quality` catches the three deterministic shapes (a placeholder label, a label repeated with no visible heading/legend/row telling the fields apart, a label split between visible and hidden parts, which covers ACT's failed examples 4 and 5). The remaining three fail on the meaning of a well-formed word, `<label>Menu</label>` over a first-name field, which no markup-level check reaches |
|
|
89
92
|
| `b49b2e` | 5 | inherent limitation: `heading-quality` catches placeholder heading text (a generic word, a numbered template slot, a filename, a URL), which is the deterministic half of this rule; whether a well-formed heading actually describes the content after it is a reading-comprehension judgment, and all 5 of ACT's failed examples are exactly that shape ("Weather" over opening hours, across five different heading-naming mechanisms) |
|
|
90
|
-
| `78fd32` | 1 | documented limitation: `avoid-inline-spacing`'s own header comment already states it can't detect "text that never soft-wraps" without real layout |
|
|
91
93
|
| `aizyf1` | 2 | inherent tension with `5effbb`'s own examples, not a bug: both remaining cases (`<a>this product</a>` after "See the description of", and a format-name list under an "Ulysses" heading) are ACT's own *passed* examples for `5effbb` (context-aware) but *failed* examples for `aizyf1` (context-blind: the accessible name alone, ignoring what makes it clear, must already be descriptive). One shared rule can credit context or not, not both on the same markup; `link-name-quality` sides with `5effbb`'s reading, which is what its context-detection is for |
|
|
92
94
|
| `5effbb` | 1 | genuine judgment gap: the one remaining case (`<a>Workshop</a>` after an unrelated paragraph) needs to judge whether an ordinary word actually relates to nearby prose, not a phrase-list or context-structure question `link-name-quality` can answer |
|
|
93
95
|
| `d0f69e` | 3 | documented false-negative policy: `table-th-has-data-cells`'s own header comment explains it only catches the unambiguous "zero data cells anywhere" case, not real positional header-association (the new ARIA-grid coverage added during this pass is real but doesn't happen to close these 3 specific positional-mismatch cases) |
|
|
@@ -207,7 +209,7 @@ The goal is maximum automation, not just parity with ACT's own scope; several ga
|
|
|
207
209
|
- `c4a8a4` HTML page title is descriptive (title-vs-content relevance): harder to heuristic than the others in this tier (needs some notion of "does this title's vocabulary overlap with the page's own content," not just a pattern/word list), so lower confidence within this tier.
|
|
208
210
|
|
|
209
211
|
**Design decision needed before building (not purely a confidence question):**
|
|
210
|
-
- ~~`3ea0c8` Page-wide unique `id`~~: built as `duplicate-id`, tagged `wcag2a` plus the new `wcag22-removed`, which a 2.2 conformance run excludes and a 2.0/2.1 run keeps. The decision that unblocked it, and the reasoning, are in `docs/DESIGN_CHALLENGES.md`'s "Decided" section; the tag is documented in `docs/ENGINE_OPTIONS.md`.
|
|
212
|
+
- ~~`3ea0c8` Page-wide unique `id`~~: built as `duplicate-id`, tagged `wcag2a` plus the new `wcag22-removed`, which a 2.2 conformance run reports as `cantTell` (or excludes outright) and a 2.0/2.1 run keeps as a real failure. The decision that unblocked it, and the reasoning, are in `docs/DESIGN_CHALLENGES.md`'s "Decided" section; the tag is documented in `docs/ENGINE_OPTIONS.md`.
|
|
211
213
|
|
|
212
214
|
**Lower confidence, needs a different technique than the rest of the engine:**
|
|
213
215
|
- `e6952f` Attribute is not duplicated: only detectable from the raw HTML *text* (the DOM has already collapsed duplicate attributes by the time any DOM-based rule runs), a different input than every other rule in this engine uses. Feasible only where raw source is available (not guaranteed in every integration).
|
package/docs/API_STABILITY.md
CHANGED
|
@@ -6,7 +6,7 @@
|
|
|
6
6
|
|
|
7
7
|
Removing, renaming, or changing the type/meaning of any of these is a **major** version bump:
|
|
8
8
|
|
|
9
|
-
- Top-level result: `engine.tag`, `engine.schemaVersion`, `engine.locale` (the field and its `requested`/`resolved`/`reason` keys — the set of `reason` *values* is open and may gain entries in a minor), `url`, `checksResults` (an array), `rulesResults` (an array).
|
|
9
|
+
- Top-level result: `engine.tag`, `engine.schemaVersion`, `engine.locale` (the field and its `requested`/`resolved`/`reason` keys — the set of `reason` *values* is open and may gain entries in a minor), `engine.wcagVersion`, `url`, `checksResults` (an array), `rulesResults` (an array), `overriddenBuiltinIds` (an array, empty when no `customRules` entry shadowed a built-in id — part of the extension contract, see below).
|
|
10
10
|
- Each `checksResults[i]` / `rulesResults[i]` entry: `ruleId`, `outcome`, `outcomeNormalized`, `severity`, `confidence`, `type`, `title`, `description`, `meta` (including `meta.normativeMappings`, `meta.deprecated`/`.deprecation` — see below), `engineOptions`, `schemaVersion`.
|
|
11
11
|
- Each occurrence (`occurrences[i]`): `selector`, `html`, `summary`, `hint`, `i18n`, `structuralPath`.
|
|
12
12
|
- The rule **catalog** (`getChecksCatalog()`/`getRulesCatalog()`, a separate surface from a scan result — see `RULE_AUTHORING.md`): `ruleId`, `title`, `description`, `tags`, `wcagSc`, `normativeMappings`, `defaultSeverity`, `defaultConfidence`, `type`, `deprecated`/`.deprecation`. Note `tags` lives here, not on a per-scan `checksResults[i].meta` — the two surfaces intentionally carry different subsets of a rule's metadata.
|
|
@@ -23,25 +23,73 @@ Since 1.4.0 the package declares an explicit `exports` map. These are the only i
|
|
|
23
23
|
| `@surea11y/core/baseline` | `src/baseline.js` | `buildBaselineEntries()`, `matchBaseline()` |
|
|
24
24
|
| `@surea11y/core/report` | `src/report.js` | `renderHtmlReport()` |
|
|
25
25
|
| `@surea11y/core/sarif` | `src/sarif.js` | `renderSarifReport()` |
|
|
26
|
+
| `@surea11y/core/earl` | `src/earl.js` | `renderEarlReport()` |
|
|
26
27
|
| `@surea11y/core/browser` | `surea11y.browser.js` | the standalone browser bundle, for bundlers that resolve it as a module |
|
|
27
28
|
|
|
28
29
|
Anything **not** in that table — `src/core/*`, `src/checks/*`, `src/i18n/*`, `src/policy/*`, and the generated `src/core.js` itself — is internal. Before 1.4.0 there was no `exports` map, so those paths were technically reachable via deep `require()`; they were never documented as public and are no longer resolvable. The `<script src="node_modules/@surea11y/core/surea11y.browser.js">` form documented in the README is a filesystem path, not module resolution, and is unaffected.
|
|
29
30
|
|
|
30
31
|
Declaring this map is what lets the engine's internal file layout change without a major bump. Note that `src/checks/*` is still *shipped* (the generated bundle `require()`s it at runtime) — shipped is not the same as public.
|
|
31
32
|
|
|
33
|
+
## Extension points
|
|
34
|
+
|
|
35
|
+
The `exports` map above says which **paths** are importable. It does not say which **symbols** behind them are supported, and that distinction matters here: `src/index.js` re-exports the generated core verbatim, so every symbol the build emits reaches consumers whether or not it was meant for them. The classification lives in [`scripts/data/public-api.json`](../scripts/data/public-api.json) and is checked by `tests/public-api.test.js`, which fails when a new export appears unclassified — a leak has to be a decision, not an accident.
|
|
36
|
+
|
|
37
|
+
**Supported** — covered by semver, safe to build on:
|
|
38
|
+
|
|
39
|
+
| Export | For |
|
|
40
|
+
|---|---|
|
|
41
|
+
| `runa11yCoreInPage` | Scanning from another JS realm: the whole engine is inlined, so `fn.toString()` re-evaluated in a browser tab works. What all five browser bindings use. |
|
|
42
|
+
| `runDomRulesInPage` | Scanning in the same Node process, dispatching through real `require()`. What `@surea11y/test-matchers` uses. |
|
|
43
|
+
| `runa11yCoreAcrossFrames` / `a11yCoreEnableFrameResponder` | Cross-frame scanning without an automation driver. |
|
|
44
|
+
| `getChecksCatalog()` / `getRulesCatalog()` | Reading the rule catalog; its stable fields are listed above. |
|
|
45
|
+
|
|
46
|
+
**Exported but internal** — reachable today, not supported, and free to change or disappear in a minor: `CHECK_DEFS`, `TEST_DEFS`, `COMPOSITE_RULES`, `DEFAULT_POLICY`, `POLICY_CONTRACTS`, `ENGINE_TAG`, `SCHEMA_VERSION`, `resolvePolicy`, `getCheckDefById`, `getCompositeRuleById`, `getChecksForRunOnly`, `getTestsForRunOnly`, `__internal`.
|
|
47
|
+
|
|
48
|
+
They stay exported rather than being removed, because removing them is itself a breaking change and no consumer needs it yet; the honest fix for now is to say they are not part of the contract. Note the two constants have supported equivalents on every result — `engine.tag` and `engine.schemaVersion` — so read them from there rather than importing them. Curating this list down to the supported set is a candidate for the next major.
|
|
49
|
+
|
|
50
|
+
### Extending the engine
|
|
51
|
+
|
|
52
|
+
Three things are meant to be extended, and all three go through `engineOptions` or a separate entry point rather than through the exported symbols above:
|
|
53
|
+
|
|
54
|
+
- **`engineOptions.customRules`** — the plugin mechanism: an array of rule descriptors registered for one call, never added to the static catalog and never persisted between calls. **The descriptor contract is covered by semver**: `id`, `meta`, `runInPage(ctx)` and the optional `applicability(ctx)` and `data`, along with the `ctx.helpers` a rule receives and the `{outcome, severity, occurrences}` it returns. That `runInPage`/`applicability` may be passed as a function *or* as a function-source string is part of the contract too, not a convenience: `engineOptions` crossing into another realm (a Playwright `page.evaluate`, say) cannot carry a live `Function`, so a binding has no other way to register one. A custom rule that shadows a built-in id replaces it for that scan and is reported back in `overriddenBuiltinIds`, so an accidental collision is visible rather than silent. A custom rule written against today's contract keeps working across minors; requiring a new field of it is a major. The full descriptor shape is in [`ENGINE_OPTIONS.md`](./ENGINE_OPTIONS.md#customrules--runtime-registered-rules), the helpers in [`RULE_HELPERS.md`](./RULE_HELPERS.md), and the outcome rules a custom rule must obey in [`RULE_TAXONOMY.md`](./RULE_TAXONOMY.md).
|
|
55
|
+
- **`engineOptions.policyContract` / `engineOptions.policy`** — which outcomes and confidence values a scan may report, and whether a manual rule's would-be `fail` is coerced. The two option names, the built-in contract ids `'a11y'` and `'generic'`, and the inline-contract shape are supported; the `POLICY_CONTRACTS` export itself is not, since passing a string or an inline object is all a caller needs. See [`POLICY.md`](./POLICY.md).
|
|
56
|
+
- **Reporters** — `@surea11y/core/baseline`, `/report`, `/sarif` and `/earl` consume a result rather than hooking into the scan, which is why they are separate entry points. A consumer wanting a different output format reads the result shape above; nothing needs to be registered with the engine.
|
|
57
|
+
|
|
58
|
+
There is deliberately no hook for changing what a built-in rule decides. Overriding one means shipping a `customRules` entry that reuses its id, which the engine allows for a single call, warns about, and reports in `overriddenBuiltinIds` — so a scan that silently disagrees with the catalog is not possible.
|
|
59
|
+
|
|
32
60
|
## Explicitly unstable (not covered by semver)
|
|
33
61
|
|
|
34
62
|
- `perfStats` and `ruleTimings` — internal timing/debug counters, only present when `engineOptions.perfStats`/`.profileRules` is set. Shape not covered by this document.
|
|
35
|
-
- `occurrences[i].data.details` — rule-specific, non-normative extra context. Shape varies per rule and may change in a patch release; treat as best-effort, not a stable contract (this was already noted in `docs/OUTPUT_SCHEMA.md` before this document existed).
|
|
63
|
+
- `occurrences[i].data.details` — rule-specific, non-normative extra context. Shape varies per rule and may change in a patch release; treat as best-effort, not a stable contract (this was already noted in `docs/OUTPUT_SCHEMA.md` before this document existed). **`data.details.reasonCode` is the exception** and is stable — see [Finding identity](#finding-identity) below.
|
|
36
64
|
- `ruleInterfaceVersion` / `ruleVersion` on a rule's meta — currently unused scaffolding (every rule defaults to the same two static strings; nothing meaningfully sets or consumes them today). Not part of this contract until they're actually wired up to mean something.
|
|
37
65
|
|
|
66
|
+
## Finding identity
|
|
67
|
+
|
|
68
|
+
A consumer needs to know whether a finding it is looking at is the same one it saw last week. Two things in this package answer that, and both compute it the same way — `computeBaselineKey(ruleId, reasonCode, html)` in `src/baseline.js`:
|
|
69
|
+
|
|
70
|
+
- **Baselines.** `--write-baseline`/`--baseline` suppress known findings so a build only breaks on new ones.
|
|
71
|
+
- **SARIF.** `partialFingerprints['surea11y/violation/v1']`, which GitHub Code Scanning uses to decide whether an alert is the same alert or a new one.
|
|
72
|
+
|
|
73
|
+
So the identity is `ruleId` + `reasonCode` + the occurrence `html`, and two of those three are promises:
|
|
74
|
+
|
|
75
|
+
- **A rule id, once published, does not change.** Renaming or removing one is a major change. The supported path is to keep the id, mark it `deprecated` with `deprecation.replacedBy` naming the successor, and remove it only after the notice period.
|
|
76
|
+
- **A reason code, once a rule has shipped it, does not change.** This is a deliberate exception to the surrounding "`data.details` is unstable" rule: everything else under `data.details` is free-form, but `reasonCode` is load-bearing for identity, so it is pinned. Adding a new code to a rule is a minor change; changing or dropping an existing one is not, because every stored baseline entry and every open Code Scanning alert keyed on it stops matching.
|
|
77
|
+
|
|
78
|
+
Both are inventoried in [`scripts/data/finding-ids.json`](../scripts/data/finding-ids.json), regenerated with `npm run finding-ids` and checked by `tests/finding-ids.test.js`, which fails when a published rule id or reason code disappears. The inventory is the record of what has been promised; the test is what stops the promise being broken by accident.
|
|
79
|
+
|
|
80
|
+
Note what identity does **not** include: `selector` and `structuralPath` deliberately stay out of the fingerprint, because both change when the surrounding page is edited, which would make every finding look new after an unrelated refactor. `html` is in, so editing the flagged element itself does read as a new finding — that is the intended trade-off, since the element's markup is the thing the finding is about.
|
|
81
|
+
|
|
82
|
+
### A rename that predates this
|
|
83
|
+
|
|
84
|
+
`role-img-alt-present` became `role-img-text-alternative-present` with no deprecation entry and no major bump, before any of the above was written down. Anything holding the old id — a baseline entry, a `runOnly` list — silently matched nothing. The rename is not reversible now: the old id has been absent across every 1.x release, so a deprecation entry today would announce the retirement of something no current version answers to. It is recorded here instead, because it is the reason this section exists. Its source file, fixture and test kept the old name for a while afterwards, which is what made the rename easy to miss; they carry the rule's own id now.
|
|
85
|
+
|
|
38
86
|
## What triggers which version bump
|
|
39
87
|
|
|
40
88
|
- **Patch**: a correctness fix that changes *which* outcome a rule produces for the same input, without changing the shape or mechanism. Example: the fragment-scan applicability fix (`engineOptions.fragment`, see `ENGINE_OPTIONS.md`) changed several rules from incorrectly `fail`ing on a scoped subtree to correctly `notApplicable` — that's a patch, not a major bump, because no stable field's *shape* changed, only a bug got fixed. Don't over-index on "any output change = major" — bug fixes are expected to change output.
|
|
41
89
|
- **Minor**: adding a new stable field, adding a new rule to the catalog, or marking an existing rule `deprecated` (see below).
|
|
42
90
|
- **Major**: removing or renaming a stable field, changing a stable field's type or meaning, or removing a rule ID once its deprecation notice period has passed. Paired with an `engine.schemaVersion` bump specifically when the *shape* changes (as opposed to package-level major bumps for other reasons, e.g. dropping support for an old Node version).
|
|
43
91
|
|
|
44
|
-
`engine.schemaVersion` has been `"1.0.0"` since the engine's first release and has never needed a bump — nothing has changed a stable field's shape yet. Adding the `deprecated`/`deprecation` meta fields described below is purely additive (new optional fields, ignored safely by anything not looking for them), so it does **not** warrant a schema bump either — this is the policy's first real application. `engine.locale` is the second: a new field next to the existing ones, with no change to any field a consumer already reads.
|
|
92
|
+
`engine.schemaVersion` has been `"1.0.0"` since the engine's first release and has never needed a bump — nothing has changed a stable field's shape yet. Adding the `deprecated`/`deprecation` meta fields described below is purely additive (new optional fields, ignored safely by anything not looking for them), so it does **not** warrant a schema bump either — this is the policy's first real application. `engine.locale` is the second: a new field next to the existing ones, with no change to any field a consumer already reads. `engine.wcagVersion` and the optional per-result `wcagVersionScope` are the third, on the same reasoning — but note the *outcome* change that came with them (a rule mapped to the removed SC 4.1.1 now reports `cantTell` instead of `fail` under the default 2.2 target) is an outcome fix of the kind described above, not a shape change.
|
|
45
93
|
|
|
46
94
|
## Release cadence
|
|
47
95
|
|
|
@@ -36,6 +36,108 @@ A short list, derived from what the audit pass on `@surea11y/playwright` actuall
|
|
|
36
36
|
- [ ] If you support cross-frame scanning via your own driver, confirm each frame's result gets the same normalization (selector/structuralPath/severity) as a single-document scan — don't let a "per-frame" code path silently skip the shared result-shaping logic.
|
|
37
37
|
- [ ] TypeScript types (if you ship any) stay in sync with actual engine output — `structuralPath: number[] | null` and any new fields ([`OUTPUT_SCHEMA.md`](./OUTPUT_SCHEMA.md) is the source of truth) are easy to leave stale after an engine update.
|
|
38
38
|
|
|
39
|
-
##
|
|
39
|
+
## Getting the engine into the page without paying for it twice
|
|
40
40
|
|
|
41
|
-
|
|
41
|
+
A binding that crosses a realm boundary has to get the engine into the page
|
|
42
|
+
somehow. The obvious way — serialize `runa11yCoreInPage` with `.toString()` and
|
|
43
|
+
hand it to the driver's evaluate-in-page call — works, and is what
|
|
44
|
+
`@surea11y/playwright` did first, but it sends the whole engine **on every
|
|
45
|
+
call**: about 1.7MB per frame, per scan. A five-frame scan sends it five times,
|
|
46
|
+
and the next scan sends it all again.
|
|
47
|
+
|
|
48
|
+
The package ships a smaller way. `@surea11y/core/browser` is the standalone
|
|
49
|
+
bundle: the same `runa11yCoreInPage`, minified, about 707KB, which defines
|
|
50
|
+
`window.a11ycore`. Load it into the document once and every later scan costs a
|
|
51
|
+
few hundred bytes.
|
|
52
|
+
|
|
53
|
+
The shape below is deliberately driver-neutral — `evaluateInPage` stands for
|
|
54
|
+
whatever your driver calls it (`page.evaluate` in Playwright and Puppeteer,
|
|
55
|
+
`browser.execute` in WebdriverIO, `driver.executeScript` in Selenium, which
|
|
56
|
+
takes a *string* of script text rather than a function):
|
|
57
|
+
|
|
58
|
+
```js
|
|
59
|
+
const fs = require('fs');
|
|
60
|
+
const BUNDLE = fs.readFileSync(require.resolve('@surea11y/core/browser'), 'utf8');
|
|
61
|
+
|
|
62
|
+
// Returns true when window.a11ycore is ready to use in this document.
|
|
63
|
+
async function ensureEngine(target) {
|
|
64
|
+
if (await evaluateInPage(target, () => typeof window.a11ycore !== 'undefined')) return true;
|
|
65
|
+
try {
|
|
66
|
+
await evaluateInPage(target, (src) => (0, eval)(src), BUNDLE);
|
|
67
|
+
} catch {
|
|
68
|
+
return false;
|
|
69
|
+
}
|
|
70
|
+
// Confirm it actually landed rather than assuming: see the CSP note below.
|
|
71
|
+
return evaluateInPage(target, () => typeof window.a11ycore !== 'undefined');
|
|
72
|
+
}
|
|
73
|
+
```
|
|
74
|
+
|
|
75
|
+
Then use it if it is there, and keep the serialize path as the fallback:
|
|
76
|
+
|
|
77
|
+
```js
|
|
78
|
+
async function scan(target, args) {
|
|
79
|
+
if (await ensureEngine(target)) {
|
|
80
|
+
return evaluateInPage(
|
|
81
|
+
target,
|
|
82
|
+
(a) => window.a11ycore.runa11yCoreInPage(a.url, a.contextSelector, a.engineOptions, a.runOnly),
|
|
83
|
+
args
|
|
84
|
+
);
|
|
85
|
+
}
|
|
86
|
+
return evaluateInPage(target, serializedRunnerFn, args); // what your binding does today
|
|
87
|
+
}
|
|
88
|
+
```
|
|
89
|
+
|
|
90
|
+
Four things to know before adapting it:
|
|
91
|
+
|
|
92
|
+
- **Verify the global appeared; don't assume.** That is what makes this safe to
|
|
93
|
+
adopt across drivers without auditing each one's execution model. A driver
|
|
94
|
+
whose script execution is subject to the page's own CSP will fail to define
|
|
95
|
+
the global, `ensureEngine` returns false, and the scan falls back to the
|
|
96
|
+
payload it uses today rather than breaking.
|
|
97
|
+
- **Don't reach for `addScriptTag`.** It injects an inline `<script>`, which a
|
|
98
|
+
page serving `script-src 'self'` refuses — confirmed in Chromium against both
|
|
99
|
+
a page and a sub-frame. Playwright's and Puppeteer's `evaluate` run through
|
|
100
|
+
CDP, outside the page's CSP, so the snippet above was confirmed working under
|
|
101
|
+
`script-src 'self'` with no `unsafe-eval`. Other drivers execute scripts by
|
|
102
|
+
other means; the presence check above is what covers the difference.
|
|
103
|
+
- **A navigation clears it.** `window.a11ycore` belongs to the document, so the
|
|
104
|
+
check has to run per frame and after every navigation. That is why `scan`
|
|
105
|
+
calls `ensureEngine` unconditionally rather than caching a flag on the
|
|
106
|
+
binding.
|
|
107
|
+
- **The bundle carries English only.** Every other locale is a side file, so a
|
|
108
|
+
binding forwarding `engineOptions.locale` must load the matching one the same
|
|
109
|
+
way, before the scan:
|
|
110
|
+
|
|
111
|
+
```js
|
|
112
|
+
const primary = String(locale || 'en').trim().toLowerCase().split('-')[0];
|
|
113
|
+
if (primary && primary !== 'en') {
|
|
114
|
+
let localePath;
|
|
115
|
+
try {
|
|
116
|
+
localePath = require.resolve(`@surea11y/core/i18n/${primary}`);
|
|
117
|
+
} catch {
|
|
118
|
+
localePath = null; // not a locale this build ships; English is the fallback
|
|
119
|
+
}
|
|
120
|
+
if (localePath) {
|
|
121
|
+
await evaluateInPage(target, (src) => (0, eval)(src), fs.readFileSync(localePath, 'utf8'));
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
```
|
|
125
|
+
|
|
126
|
+
Without it the scan still succeeds, but in English, with
|
|
127
|
+
`engine.locale.reason` reporting `dictionary-not-loaded` — the engine saying
|
|
128
|
+
this step was missed, rather than a failure to swallow.
|
|
129
|
+
|
|
130
|
+
Results are identical either way: same rules, same composites, same `engine`
|
|
131
|
+
block, verified over a page seeded with a spread of violations. This is only
|
|
132
|
+
about what crosses the wire.
|
|
133
|
+
|
|
134
|
+
### Which situation is your binding in
|
|
135
|
+
|
|
136
|
+
Only the first row pays the serialization cost this section is about.
|
|
137
|
+
|
|
138
|
+
| Binding | Realm | What to do |
|
|
139
|
+
|---|---|---|
|
|
140
|
+
| Playwright, Puppeteer, Selenium, WebdriverIO | Node drives a separate browser realm | Everything above: load `@surea11y/core/browser` into the document once, scan through `window.a11ycore`, keep the serialize path as fallback. |
|
|
141
|
+
| Cypress | Test code already runs in the browser, with the app under test in a same-origin frame | No serialization boundary, so nothing crosses the wire — but the engine still has to be evaluated into the app's realm rather than called from the runner's, or it reads the wrong `document`. `require` the engine and `win.eval` its source there (what `@surea11y/cypress` does), or read the bundle with `cy.readFile` and evaluate that instead; both put it in the right realm. Size is not the deciding factor here. |
|
|
142
|
+
| Jest, Vitest, or anything else driving jsdom in-process | One realm, in Node | None of this applies. `require('@surea11y/core')` and call `runDomRulesInPage` against the DOM you already have — see [`INTEGRATION.md`](./INTEGRATION.md) Pattern 1. Injecting a bundle here would be strictly worse. |
|
|
143
|
+
| Browser extension, bookmarklet, injected script | Already in the page | Load the bundle once and call it; that is the case it was built for. |
|
|
@@ -28,6 +28,52 @@ A running log of engine design decisions worth re-examining: cases where an exis
|
|
|
28
28
|
|
|
29
29
|
## Decided
|
|
30
30
|
|
|
31
|
+
### `aria-required-children` failed a container for being empty, when its sibling already owns the question of whether the contents are valid, now advisory
|
|
32
|
+
|
|
33
|
+
**Decision as it stands (before the change):** the rule failed any container role with a "required owned elements" entry that had no descendant (or `aria-owns` target) carrying one of those roles. An empty `<div role="list">`, a `role="tablist"` before its tabs arrive, a `role="rowgroup"` with no rows: all `fail` at `moderate`, mapped to SC 1.3.1.
|
|
34
|
+
|
|
35
|
+
**Why it was questioned:** the rule asks one thing, whether the required content is PRESENT. Whether the content a container does own is VALID is `aria-prohibited-children`'s decision, and that rule fails independently. Absence conveys nothing false: an empty `role="list"` is announced as a list with no items, which is exactly what it is. The engine's own native-HTML rules already work this way, which made the ARIA side incoherent by comparison: `<ul></ul>` passes and `<div role="list"></div>` failed, same structure, same emptiness, opposite verdicts, with nothing in WCAG distinguishing them. ACT `bc4a75`, the authority this rule's `fail` rests on, turns out not to cover the shape at all: its Expectation is "each test target only owns elements with a semantic role from the required owned element list", which an empty container satisfies vacuously, and it publishes no empty-container example in either direction. The engine's clean run against that corpus was therefore silent about this case rather than confirming it.
|
|
36
|
+
|
|
37
|
+
**What was weighed:** three predicates were on the table. Cap the rule at `cantTell` outright; keep a `fail` for a container that owns roles but none of the required ones; or the stricter line used elsewhere in the industry, `cantTell` only when the container owns no content whatsoever, so a container of unroled elements still fails. The middle option was dropped once it was clear that every shape it would fail is already failed by `aria-prohibited-children`, making it a second rule agreeing with the first rather than a decision of its own, against this repo's one-rule-one-decision principle. The strict option was dropped because a container of unroled elements is equally "a broken list" and "an empty list with content inside it", and static markup does not settle which.
|
|
38
|
+
|
|
39
|
+
**Decision (2026-08-28):** the rule reports `cantTell` for every finding and can no longer `fail`. Applicability, the `aria-busy` escape hatch, accessibility-tree eligibility, `aria-owns` resolution and slot expansion are all unchanged, as is `aria-prohibited-children`.
|
|
40
|
+
|
|
41
|
+
**Accepted cost:** `<div role="list"><div>Item one</div><div>Item two</div></div>`, a list whose items never got their role, is now reported for review rather than failed, and no other rule fails it. That is the only shape that loses a failure; the fixture carries it as case 09 and a test pins the sibling rule still failing a genuinely disallowed child, so the safety net this depends on cannot be removed quietly.
|
|
42
|
+
|
|
43
|
+
**Status:** resolved 2026-08-28.
|
|
44
|
+
|
|
45
|
+
### The `aria-*` family reported ARIA-spec conformance as a WCAG 4.1.2 failure, where ACT's own mapping calls most of it "not required for conformance", now graded
|
|
46
|
+
|
|
47
|
+
**Decision as it stands (before the change):** 13 of the 16 automatic `aria-*` rules declared `wcagSc: ['4.1.2']` at level A (the other three were re-mapped to 1.3.1, see Status), `normative: true` (no rule anywhere in this repo sets `normative: false`) and `defaultConfidence: 'high'` — the exception being `aria-required-parent`, which is `medium` and still emits a flat `fail`. Eleven of the sixteen emit a flat `fail` with no second tier; five grade into a `cantTell` tier (`aria-allowed-attr`, `aria-deprecated-role`, `aria-prohibited-attr`, `aria-valid-attr-value`, `aria-hidden-focus`). Seventeen atomic rules feed one composite, `wcag-4.1.2-aria-validity`, whose own description says it rolls up checks "that ARIA role and attribute usage conforms to the WAI-ARIA specification"; any single contributor `fail` makes that SC verdict `fail`.
|
|
48
|
+
|
|
49
|
+
**Why it's being questioned:** the strictness is not in the detection logic, which is conservative and well guarded — `aria-required-children` honours `aria-busy`, accessibility-tree eligibility, `aria-owns` and slot projection; `REQUIRED_PROPS_BY_ROLE` deliberately omits context-dependent properties; `ALLOWED_ROLES_BY_ELEMENT` treats unmodelled elements as unconstrained. It is entirely in the verdict layer, and ACT's own Accessibility Requirements Mapping disagrees with it for most of the family:
|
|
50
|
+
|
|
51
|
+
| ACT rule | This repo | ACT's primary requirement | WCAG status per ACT |
|
|
52
|
+
|---|---|---|---|
|
|
53
|
+
| `4e8ab6` required states/properties | `aria-required-attr` | ARIA5 technique; ARIA 1.2 §5.2.2 | not required for WCAG conformance; 1.3.1/4.1.2 are secondary and "less strict", they "allow for fallback default values that may make some failures acceptable" |
|
|
54
|
+
| `5c01ea` property permitted | `aria-allowed-attr` | ARIA5 technique; ARIA 1.2 §8.6 | not required for WCAG conformance; 1.3.1/4.1.2 secondary, "less strict" |
|
|
55
|
+
| `5f99a7` attribute defined | `aria-valid-attr` | none | 1.3.1/4.1.2 secondary, "less strict" |
|
|
56
|
+
| `6a7281` valid value | `aria-valid-attr-value` | none | "not required for conformance to WCAG 2.1 at any level" |
|
|
57
|
+
| `674b10` role has valid value | `aria-roles-valid` | ARIA4, G108 techniques | not required for conformance to any W3C recommendation; 4.1.2 "can be satisfied through the implicit role" |
|
|
58
|
+
| `bc4a75` required owned elements | `aria-required-children`, `aria-prohibited-children` | **1.3.1 Info and Relationships** | required for conformance, on **1.3.1** |
|
|
59
|
+
| `ff89c9` required context role | `aria-required-parent` | **1.3.1 Info and Relationships** | required for conformance, on **1.3.1** |
|
|
60
|
+
|
|
61
|
+
Two separate problems fall out of that table. Five of the seven are ARIA *author* requirements that WCAG does not mandate, reported here as level-A WCAG failures at high confidence. The other two are conformance-required, but on 1.3.1, not the 4.1.2 all three rules declare: a plain SC misattribution that lands the verdict on the wrong criterion (the level is unaffected, both are A). `aria-allowed-role` is the weakest claim in the family — no ACT rule covers it and no external source maps ARIA-in-HTML's permitted-roles table to a Success Criterion, yet it fails at high confidence. Six ARIA rules have no ACT counterpart at all (`ACT_RULE_MAPPING.md`'s no-ground-truth list), which is exactly where the `fail` decision has nothing external checking it.
|
|
62
|
+
|
|
63
|
+
The distinction ACT is drawing is whether the exposed name, role and value survive the violation. `<button role="buton">` is still exposed as a button; `<div role="checkbox">` with no `aria-checked` gets ARIA's own `false` default, and whether that default is *wrong* is `aria-checked-state-mismatch`'s question, already capped at `cantTell`; `<div role="heading">` with no `aria-level` is still a heading at the user agent's default level; `aria-brailleroledescription` without `aria-roledescription` reaches no user at all and is currently `serious`. Against that, `aria-label` on a roleless `<span>` genuinely loses the name — and that case is already graded, by `aria-prohibited-attr`, on precisely this reasoning.
|
|
64
|
+
|
|
65
|
+
The engine's stated bar is that `fail` stays reserved for deterministic violations. The family currently reads that as high confidence *that the ARIA specification was violated*, which it reliably is; the argument here is that it should mean high confidence that *the Success Criterion the rule names is failed*, which for most of the family it is not.
|
|
66
|
+
|
|
67
|
+
**What the graded family looks like:** `aria-hidden-body`, `aria-hidden-focus` and `aria-role-name-present` stay `fail` unchanged — each is a present barrier, not a spec citation. `aria-required-children`/`-prohibited-children`/`-required-parent` stay `fail` on ACT's authority, and are the part of this entry already acted on. Four rules gain a second tier on the fallback-survival axis: `aria-roles-valid` (`fail` only where the host has no implicit role to fall back on, a distinction `getNativeRoleForElement` already computes), `aria-required-attr` (`fail` for `slider`/`scrollbar`/`meter` missing `aria-valuenow`, which is a genuinely absent value; `cantTell` for the roles ARIA gives a default), `aria-valid-attr` (an undefined attribute is inert, so `fail` only where the misspelling plausibly cost the element a name it does not otherwise have), and `aria-braille-equivalent` (the `aria-brailleroledescription` half has no user-facing consequence and should not be `serious`). The remaining question is whether the family needs a third outcome tier altogether — an ARIA-conformance finding that is real, reported, and does not claim a WCAG SC, expressible today as `normative: false` plus a WAI-ARIA `normativeMappings` entry, which would also keep it out of the SC composite.
|
|
68
|
+
|
|
69
|
+
**What was weighed against changing it:** it moves scans that are red today to yellow, which is a louder change for existing baselines than removing false positives ever was, and it weakens what integrators can gate CI on: `cantTell` is not enforceable the way `fail` is. There is also a real argument for the status quo — an ARIA spec violation is a decent leading indicator even when it is not itself a barrier, since undefined behaviour differs across assistive technology and today's harmless default is tomorrow's regression. The rebuttal is that the package's premise is telling you what it cannot tell you, and a graded `cantTell` carrying a reason code says more than a `fail` that overstates its own authority. Every mechanism this needs already ships: `aria-deprecated-role` grades on the strength of the spec's own statement (MUST NOT versus SHOULD NOT), `resolveTieredOutcome` carries both tiers, the 4.1.1 handling already coerces an out-of-scope SC to `cantTell` with a `wcagVersionScope` field, and `POLICY_CONTRACTS` exposes `allowedOutcomes`/`allowedConfidence`. What is missing is the second axis — grading on whether name, role and value survive, not only on how strongly ARIA words the requirement.
|
|
70
|
+
|
|
71
|
+
**Two by-products found while reading, both smaller and independently fixable:** `aria-hidden-body` and `aria-role-name-present` were missing the `aria` tag the rest of the family carries, so filtering by tag leaked; both carry it now. The second was wrong as written: `aria-required-parent` is not the only rule emitting a flat `fail` at `medium` confidence, it is one of six, three of them outside the ARIA family, so there is no anomaly at that rule to fix. What the reading did turn up is a documented contradiction, since `OUTPUT_SCHEMA.md` defined `fail` as a high-confidence outcome while six automatic rules shipped `fail` at `medium`; the outcome describes the decision procedure and `confidence` the model it decides against, and the docs say so now. `aria-required-parent` separately gained the `aria-busy` ancestor guard its two siblings already had. `nested-interactive-controls-absent` is untagged for `aria` too, left alone on purpose, since it covers native nesting as much as the ARIA kind.
|
|
72
|
+
|
|
73
|
+
**Status:** resolved 2026-08-28. The six rules above now grade, `aria-required-attr` and `aria-roles-valid` on a computed predicate and the other four wholesale, with the two implicit-value cases generated from aria-query so the tiers cannot drift from the spec by hand. Across the 137-fixture corpus the change moves 12 rule verdicts and one composite (`wcag-4.1.2-aria-validity`, `fail` to `cantTell` on 13 fixtures); no occurrence count changes anywhere, so nothing stopped being reported. ACT `674b10`, `4e8ab6` and `5f99a7` still run clean against their live corpora (25 cases, 0 mismatches), since the checker counts a `cantTell` carrying occurrences as satisfying a "failed" expectation.
|
|
74
|
+
|
|
75
|
+
Then the remaining overstatement went too. `aria-allowed-role` declared SC 4.1.2 with no ACT rule and no source mapping ARIA-in-HTML's permitted-roles table to a criterion; it now declares none, tagged `best-practice` with `wcagSc: []`, out of `wcag-4.1.2-aria-validity` and out of the facet registry. It is this engine's first automatic rule with no WCAG mapping, which `meta.normative` could not have expressed (that field is inert) and which needed the composites catalog, the facet registry and the level tags edited by hand. The rule still runs by default, still reports the same findings; on the fixture corpus the only effect is that `wcag-4.1.2-aria-validity` reaches `pass` on 8 fixtures where an ARIA-in-HTML nit was the sole remaining contributor. `docs/RULE_TAXONOMY.md` §1.1 was rewritten alongside it: the automatic/manual line is whether a rule can decide, not which outcome it reports, so a deterministic rule reporting `cantTell` is still automatic.
|
|
76
|
+
|
|
31
77
|
### `contrast-minimum`/`contrast-enhanced` treated symbol-only text as real text needing a contrast ratio, fixed
|
|
32
78
|
|
|
33
79
|
**Decision as it stands (before the fix):** the shared text scan's applicability gate (`isNonEmptyText` in `getTextScan()`, `src/core/contrast-helpers.js`) only checked for non-whitespace characters. A text node made entirely of punctuation/symbol glyphs (`----=====+++...±±±±@@@@@@@@`) counted the same as real words.
|
|
@@ -299,3 +345,23 @@ One existing test changed meaning with it: a bare `<option>` under `role="listbo
|
|
|
299
345
|
**Interaction with the item-wrapper fix above:** the two sets are used for different questions, on purpose. Only a *required* role makes a roleless wrapper an item wrapper, so a wrapper holding nothing but a separator is still interposed content and is still reported under a container that prohibits separators. The allowed set decides only the final verdict.
|
|
300
346
|
|
|
301
347
|
**Status:** resolved 2026-08-21. Separators in menus/menubars and captions on tables/grids pass; separators under `list`/`listbox`/`tablist`, captions under `treegrid`, and any stray role with no source behind it still fail. The reported allowed-roles list in the failure message now names the full allowed set rather than only the required roles.
|
|
348
|
+
|
|
349
|
+
### A dangling `aria-controls` was a `fail`, in a rule that cannot see the DOM the reference is about
|
|
350
|
+
|
|
351
|
+
**Decision as it stood:** `aria-valid-attr-value` treats every ID-reference attribute the same way. An idref-list whose tokens all fail to resolve is an invalid value, hence a `fail` under SC 4.1.2. Exactly one attribute already had a carve-out: `aria-errormessage`, on the strength of ACT 6a7281's own Background text, which names it as a non-required property whose target "may be created in response to an event that may or may not happen."
|
|
352
|
+
|
|
353
|
+
**Why it was questioned:** that carve-out's reasoning covers `aria-controls` at least as well. A disclosure button, a combobox, a menu button and a tab all name content the widget *builds when it opens*, so the reference is correct and the element genuinely is not in the DOM yet. A static scan that looks for it and does not find it has not established a defect; it has established that it looked at the wrong moment. axe-core reached the same conclusion independently: it never reports a violation for a missing `aria-controls` target, passing when the element is collapsed and returning incomplete otherwise.
|
|
354
|
+
|
|
355
|
+
**Decision (2026-08-27):** `aria-controls` no longer fails on an unresolved target. When the element carries `aria-expanded="false"` or `aria-selected="false"` the absence is exactly what that state means, so the rule passes outright; otherwise it reports `cantTell` for human review, with reason code `idref-controls-not-found`. Every other idref/idref-list attribute keeps its `fail`, since a dangling `aria-labelledby` or `aria-owns` names content that was supposed to be there already and no state excuses it. The rule now reports two tiers through `helpers.resolveTieredOutcome`, so a real invalid value elsewhere on the page still gates as `fail` and carries the `cantTell` occurrences along rather than dropping them.
|
|
356
|
+
|
|
357
|
+
**Status:** resolved 2026-08-27.
|
|
358
|
+
|
|
359
|
+
### SC 4.1.1 Parsing could still fail a WCAG 2.2 run, unless the caller remembered a tag
|
|
360
|
+
|
|
361
|
+
**Decision as it stood:** `duplicate-id` maps to SC 4.1.1 and carries `wcag22-removed` (see the entry above). The tag was inert engine-side: it existed for consumers to pass to `excludeTags` themselves, and `docs/ENGINE_OPTIONS.md` told them to.
|
|
362
|
+
|
|
363
|
+
**Why it was questioned:** the package describes itself as a WCAG 2.2 engine, and a plain run, with no tags and no options, still reported a `fail` against a criterion WCAG 2.2 does not contain. The correct behaviour was reachable but opt-in, which is backwards: the default should be right and the deviation should be the thing you ask for. Leaving it to the caller also meant the honesty the tag was created to protect, that "the conformance arithmetic stays honest for every version", only held for callers who knew the tag existed.
|
|
364
|
+
|
|
365
|
+
**Decision (2026-08-27):** the engine resolves a target WCAG version per run, from `engineOptions.wcagVersion`, else whatever the caller's own version-origin tags imply, else `2.2`, and reports it as `engine.wcagVersion`. Under a 2.2 target a `wcag22-removed` rule cannot report `fail`: it runs, keeps every occurrence, and its outcome is coerced to `cantTell` with a `wcagVersionScope` field naming the removed criterion. Coercing rather than excluding was the deliberate choice, since a duplicate id still breaks `<label for>`, fragment navigation and `getElementById`, so dropping the rule from a 2.2 run would hide a real defect, and this engine's whole premise is telling you what it cannot tell you. `excludeTags: ['wcag22-removed']` still removes it entirely for anyone who wants that. The coercion deliberately does not go through `error`, the channel the two existing coercions use, because consumers read a non-empty `error` as "this rule threw" and nothing went wrong here.
|
|
366
|
+
|
|
367
|
+
**Status:** resolved 2026-08-27.
|