@surea11y/core 1.7.0 → 1.8.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (103) hide show
  1. package/CHANGELOG.md +103 -1
  2. package/README.md +157 -54
  3. package/docs/ACT_RULE_MAPPING.md +8 -7
  4. package/docs/API_STABILITY.md +18 -5
  5. package/docs/BINDING_AUTHORS_GUIDE.md +2 -2
  6. package/docs/CI_INTEGRATIONS.md +43 -0
  7. package/docs/DESIGN_CHALLENGES.md +97 -3
  8. package/docs/EARL.md +2 -2
  9. package/docs/ENGINE_OPTIONS.md +81 -3
  10. package/docs/I18N.md +62 -20
  11. package/docs/JUNIT.md +73 -0
  12. package/docs/LIMITATIONS.md +1 -0
  13. package/docs/OUTPUT_SCHEMA.md +19 -6
  14. package/docs/REPORT.md +7 -2
  15. package/docs/RULE_AUTHORING.md +73 -6
  16. package/docs/RULE_CATALOG.md +139 -116
  17. package/docs/RULE_EXAMPLES.md +2189 -0
  18. package/docs/RULE_HELPERS.md +62 -5
  19. package/docs/RULE_TAXONOMY.md +2 -2
  20. package/docs/SARIF.md +2 -1
  21. package/docs/WCAG_CONFORMANCE.md +56 -3
  22. package/package.json +34 -11
  23. package/profiles/index.js +14 -0
  24. package/src/checks/automatic/area-alt-present.js +87 -31
  25. package/src/checks/automatic/aria-braille-equivalent.js +25 -7
  26. package/src/checks/automatic/aria-hidden-focus.js +74 -18
  27. package/src/checks/automatic/aria-prohibited-attr.js +17 -4
  28. package/src/checks/automatic/aria-required-attr.js +29 -0
  29. package/src/checks/automatic/aria-role-name-present.js +19 -2
  30. package/src/checks/automatic/aria-valid-attr-value.js +28 -16
  31. package/src/checks/automatic/autocomplete-valid.js +26 -11
  32. package/src/checks/automatic/avoid-inline-spacing.js +105 -40
  33. package/src/checks/automatic/button-name-present.js +2 -1
  34. package/src/checks/automatic/canvas-text-alternative-present.js +105 -14
  35. package/src/checks/automatic/combobox-name-present.js +34 -51
  36. package/src/checks/automatic/contrast-computable.js +35 -4
  37. package/src/checks/automatic/contrast-enhanced.js +4 -4
  38. package/src/checks/automatic/contrast-minimum.js +45 -11
  39. package/src/checks/automatic/css-orientation-lock.js +152 -30
  40. package/src/checks/automatic/definition-list-children-valid.js +67 -23
  41. package/src/checks/automatic/deprecated-elements-not-used.js +43 -38
  42. package/src/checks/automatic/dialog-name-present.js +28 -9
  43. package/src/checks/automatic/duplicate-id.js +6 -2
  44. package/src/checks/automatic/identical-iframes-same-purpose.js +4 -4
  45. package/src/checks/automatic/iframe-focusable-content.js +7 -4
  46. package/src/checks/automatic/iframe-title-unique.js +36 -81
  47. package/src/checks/automatic/input-image-alt-present.js +32 -20
  48. package/src/checks/automatic/label-in-name.js +40 -13
  49. package/src/checks/automatic/language-page-present.js +12 -6
  50. package/src/checks/automatic/link-in-text-block.js +272 -60
  51. package/src/checks/automatic/link-name-present.js +13 -5
  52. package/src/checks/automatic/list-children-valid.js +18 -1
  53. package/src/checks/automatic/listbox-name-present.js +19 -49
  54. package/src/checks/automatic/listitem-parent-valid.js +4 -3
  55. package/src/checks/automatic/meta-refresh-no-exceptions.js +3 -3
  56. package/src/checks/automatic/page-title-present.js +16 -4
  57. package/src/checks/automatic/progressbar-name-present.js +11 -1
  58. package/src/checks/automatic/role-img-text-alternative-present.js +9 -5
  59. package/src/checks/automatic/searchbox-name-present.js +32 -49
  60. package/src/checks/automatic/server-side-image-map-absent.js +48 -28
  61. package/src/checks/automatic/slider-name-present.js +38 -52
  62. package/src/checks/automatic/spinbutton-name-present.js +32 -49
  63. package/src/checks/automatic/target-size-minimum.js +0 -11
  64. package/src/checks/automatic/td-has-header.js +41 -5
  65. package/src/checks/automatic/text-spacing-content-loss.js +548 -0
  66. package/src/checks/automatic/textbox-name-present.js +32 -49
  67. package/src/checks/automatic/valid-lang.js +15 -10
  68. package/src/checks/manual/area-alt-quality-manual.js +113 -31
  69. package/src/checks/manual/canvas-text-alternative-quality-manual.js +0 -2
  70. package/src/checks/manual/css-focus-indicator-suppressed-manual.js +23 -10
  71. package/src/checks/manual/css-hidden-focus.js +215 -7
  72. package/src/checks/manual/form-control-label-quality-manual.js +109 -5
  73. package/src/checks/manual/heading-order-manual.js +9 -1
  74. package/src/checks/manual/heading-quality-manual.js +143 -9
  75. package/src/checks/manual/img-alt-decorative-manual.js +6 -3
  76. package/src/checks/manual/input-image-alt-quality-manual.js +81 -13
  77. package/src/checks/manual/link-name-quality-manual.js +130 -4
  78. package/src/checks/manual/media-transcript-present-manual.js +65 -8
  79. package/src/checks/manual/mouse-only-event-handlers-manual.js +66 -5
  80. package/src/checks/manual/no-autoplay-audio-manual.js +93 -6
  81. package/src/checks/manual/p-as-heading-manual.js +89 -44
  82. package/src/checks/manual/page-title-patterns-manual.js +77 -8
  83. package/src/checks/manual/skip-link-manual.js +42 -14
  84. package/src/checks/manual/table-fake-caption-manual.js +32 -1
  85. package/src/checks/manual/video-caption-manual.js +47 -24
  86. package/src/checks/manual-review.js +0 -4
  87. package/src/core.js +14285 -2219
  88. package/src/coverage/en301549-map.js +187 -0
  89. package/src/coverage/standards.js +279 -0
  90. package/src/coverage/wcag-facets.js +1119 -0
  91. package/src/coverage/wcag-version-map.js +101 -0
  92. package/src/en301549.js +33 -0
  93. package/src/junit.js +321 -0
  94. package/src/profile-kit.js +163 -0
  95. package/src/report.js +343 -74
  96. package/src/sarif.js +34 -3
  97. package/src/wcag.js +105 -0
  98. package/surea11y.browser.js +5 -4
  99. package/surea11y.i18n.de.js +1 -1
  100. package/surea11y.i18n.es.js +1 -1
  101. package/surea11y.i18n.fr.js +1 -1
  102. package/surea11y.i18n.ja.js +3 -0
  103. package/src/checks/manual/area-alt-decorative-manual.js +0 -255
@@ -6,8 +6,8 @@
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), `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
- - 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`.
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`, `engine.profile` (when present; the set of profile names may grow in a minor), `engine.optInRules` (when present; the set of tags may grow in a minor), `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
+ - Each `checksResults[i]` / `rulesResults[i]` entry: `ruleId`, `outcome`, `rollupIds` (check results only), `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.
13
13
 
@@ -23,10 +23,13 @@ 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/junit` | `src/junit.js` | `renderJunitReport()` |
26
27
  | `@surea11y/core/earl` | `src/earl.js` | `renderEarlReport()` |
28
+ | `@surea11y/core/en301549` | `src/en301549.js` | `EN301549_VERSIONS`, `EN301549_CLAUSES`, `en301549ClausesForSc()` |
29
+ | `@surea11y/core/wcag` | `src/wcag.js` | `WCAG_VERSIONS`, `wcagCriteria()`, `wcagCriterion()`, `wcagTags()` |
27
30
  | `@surea11y/core/browser` | `surea11y.browser.js` | the standalone browser bundle, for bundlers that resolve it as a module |
28
31
 
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.
32
+ Anything **not** in that table — `src/core/*`, `src/checks/*`, `src/i18n/*`, `src/policy/*`, `profiles/*` (a profile's tables and rules, which the engine reads), 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.
30
33
 
31
34
  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.
32
35
 
@@ -59,6 +62,7 @@ There is deliberately no hook for changing what a built-in rule decides. Overrid
59
62
 
60
63
  ## Explicitly unstable (not covered by semver)
61
64
 
65
+ - **What profiles build on: a rule's `settings` and `src/profile-kit.js`.** A rule's settings are the thresholds a built-in rule declares it reads from `ctx.config` (`contrast-minimum`'s `boldLargeMinPx`, `largeTextRatio`, `normalTextRatio`), for its variants ([`RULE_AUTHORING.md`](./RULE_AUTHORING.md#rule-variants)); a scan's `engineOptions.rules` cannot set them. `src/profile-kit.js` is the mapping a profile made with `npm run profile:new` uses, and is not exported. Both serve the profiles in this repository, which change with them, so they stay outside this contract until a profile can live outside it ([`profiles/README.md`](../profiles/README.md)).
62
66
  - `perfStats` and `ruleTimings` — internal timing/debug counters, only present when `engineOptions.perfStats`/`.profileRules` is set. Shape not covered by this document.
63
67
  - `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.
64
68
  - `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.
@@ -74,11 +78,18 @@ So the identity is `ruleId` + `reasonCode` + the occurrence `html`, and two of t
74
78
 
75
79
  - **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
80
  - **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.
81
+ - **A reason code retires only with the finding it named.** The promise is that a finding the engine still makes keeps its identity, not that a finding is made forever. When a correctness fix (a patch, see [below](#what-triggers-which-version-bump)) changes what a rule reports for an element, the finding the old code named no longer exists, and its code may go with it: a baseline entry or alert for it then closes, as it would for any fixed bug, rather than silently stopping to match a finding that is still there. Renaming a code for a finding that stays, or dropping one the rule still has a case for, is never allowed. A retirement is recorded, with its reason, in `CHANGELOG.md`, and under `retired` in `scripts/data/released-finding-ids.json` until the next release.
77
82
 
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.
83
+ What the last release shipped is frozen in [`scripts/data/released-finding-ids.json`](../scripts/data/released-finding-ids.json), written by `npm run finding-ids:release -- <version>` as part of each release, and `tests/released-finding-ids.test.js` fails when a rule id or reason code from it is missing and not listed under `retired` with its reason. No commit rewrites that file between releases, so an identity cannot be dropped and the inventory regenerated in the same change without the test noticing. Each release freezes a new inventory and starts the `retired` list empty again, since what it held was not in that release; the changelog keeps the reasons.
84
+
85
+ Both are inventoried in [`scripts/data/finding-ids.json`](../scripts/data/finding-ids.json) for core's rules, and in each profile's own `scripts/data/finding-ids.json` for its rules, 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
86
 
80
87
  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
88
 
89
+ ### A removal that predates this guard
90
+
91
+ `area-alt-decorative`, shipped in 1.7.0, was removed afterwards without the deprecation period described [below](#rule-id-deprecation-policy). It asked a human whether an `<area>` with an empty `alt` was decorative, a question with no legitimate "yes": `area-alt-present` now fails that case outright ([`DESIGN_CHALLENGES.md`](./DESIGN_CHALLENGES.md)). The removal was accepted as an exception rather than reverted, and is recorded in the 1.8.0 changelog. Anything holding its id — a `runOnly` list, a baseline entry — matches nothing from the release after 1.7.0; the empty-`alt` area it asked about is reported by `area-alt-present` instead.
92
+
82
93
  ### A rename that predates this
83
94
 
84
95
  `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.
@@ -101,6 +112,8 @@ The version number is the contract — not a measure of how much has changed or
101
112
 
102
113
  Because every `1.x` release is backward-compatible, a consumer pinned to a `^1.y.0` range is never broken by an upgrade within the line — so a steady stream of patch/minor releases reflects active maintenance and prompt fixes, not instability. Frequency of releases is not a signal of churn; a change to a **major** version is.
103
114
 
115
+ Each release freezes the finding identities it ships: run `npm run finding-ids:release -- <version>` and commit `scripts/data/released-finding-ids.json` with the release (see [Finding identity](#finding-identity)).
116
+
104
117
  ## Rule-ID deprecation policy
105
118
 
106
119
  A rule can be marked deprecated in its own `meta`:
@@ -126,7 +139,7 @@ The process:
126
139
  2. Leave it running normally for at least one full minor version cycle after the deprecation, so integrators pinned to `^x.y.0` have a real chance to see it before it's gone.
127
140
  3. Remove the rule file entirely in a future **major** version, documented under `### Removed`.
128
141
 
129
- No rule has been deprecated yet as of this document's introduction — this is the mechanism, ready for the first real case.
142
+ `iframe-title-unique` was the first rule to use this mechanism, deprecated in 1.8.0 in favour of `identical-iframes-same-purpose` (see `DESIGN_CHALLENGES.md`). It also reports `notApplicable` on every page, because the `fail` it used to report was not a WCAG violation and waiting for 2.0.0 to stop reporting one was not acceptable. That is a property of the retired check, not of deprecation: a deprecated rule whose results are still correct keeps producing them, as described above. Its reason code, `IFRAME_TITLE_DUPLICATE`, is no longer emitted, so it retired with the finding it named, as the 1.8.0 changelog records (see [Finding identity](#finding-identity)).
130
143
 
131
144
  ## See also
132
145
 
@@ -42,11 +42,11 @@ A binding that crosses a realm boundary has to get the engine into the page
42
42
  somehow. The obvious way — serialize `runa11yCoreInPage` with `.toString()` and
43
43
  hand it to the driver's evaluate-in-page call — works, and is what
44
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,
45
+ call**: about 2.1MB per frame, per scan. A five-frame scan sends it five times,
46
46
  and the next scan sends it all again.
47
47
 
48
48
  The package ships a smaller way. `@surea11y/core/browser` is the standalone
49
- bundle: the same `runa11yCoreInPage`, minified, about 707KB, which defines
49
+ bundle: the same `runa11yCoreInPage`, minified, about 780KB, which defines
50
50
  `window.a11ycore`. Load it into the document once and every later scan costs a
51
51
  few hundred bytes.
52
52
 
@@ -98,6 +98,49 @@ pipelines:
98
98
 
99
99
  The step fails the pipeline on the CLI's exit code exactly like any other `script` entry; `a11y-report.html` (see [`REPORT.md`](./REPORT.md)) is attached as a downloadable build artifact so a reviewer can open it without re-running the scan locally.
100
100
 
101
+ ## JUnit test reports
102
+
103
+ GitLab, Azure DevOps, Jenkins and CircleCI show JUnit XML in their own test views: each WCAG criterion becomes a suite and each rule a test (see [`JUNIT.md`](./JUNIT.md)). Save the scan as JSON, render it with `@surea11y/core/junit`, and keep the CLI's exit code for gating. The render step needs `@surea11y/core` in the project's own `devDependencies`.
104
+
105
+ ### GitLab CI
106
+
107
+ ```yaml
108
+ a11y:
109
+ image: node:20
110
+ script:
111
+ - npm ci
112
+ - npm run build
113
+ - npx @surea11y/cli scan ./dist/index.html --json > a11y-scan.json || scan_exit=$?
114
+ - node -e "const fs = require('fs'); const { renderJunitReport } = require('@surea11y/core/junit'); fs.writeFileSync('a11y.junit.xml', renderJunitReport(JSON.parse(fs.readFileSync('a11y-scan.json', 'utf8'))))"
115
+ - exit ${scan_exit:-0}
116
+ artifacts:
117
+ when: always
118
+ reports:
119
+ junit: a11y.junit.xml
120
+ ```
121
+
122
+ `when: always` uploads the report even when the scan's exit code fails the job, which is exactly when the merge request widget is worth reading.
123
+
124
+ ### Azure DevOps
125
+
126
+ ```yaml
127
+ steps:
128
+ - script: |
129
+ npm ci
130
+ npm run build
131
+ npx @surea11y/cli scan ./dist/index.html --json > a11y-scan.json || scan_exit=$?
132
+ node -e "const fs = require('fs'); const { renderJunitReport } = require('@surea11y/core/junit'); fs.writeFileSync('a11y.junit.xml', renderJunitReport(JSON.parse(fs.readFileSync('a11y-scan.json', 'utf8'))))"
133
+ exit ${scan_exit:-0}
134
+ displayName: Accessibility scan
135
+ - task: PublishTestResults@2
136
+ condition: succeededOrFailed()
137
+ inputs:
138
+ testResultsFormat: JUnit
139
+ testResultsFiles: a11y.junit.xml
140
+ ```
141
+
142
+ `cantTell` rules arrive as skipped tests, never failures. To gate on them too, pass `{ cantTellAs: 'failure' }` as the second argument to `renderJunitReport`; note that it changes the report, not the CLI's exit code.
143
+
101
144
  ## Free-tier/private-repo minute limits
102
145
 
103
146
  If your pipeline provider's free tier is minute-limited (Bitbucket Pipelines' free tier is 50 build-minutes/month on private workspaces, for example), a `jsdom`-based scan of static/server-rendered HTML (what the CLI does) is far cheaper than driving a real browser — see [the CLI docs](https://github.com/SureA11y/cli/blob/main/docs/CLI.md#what-it-can-and-cant-scan) for what that trades away (no client-rendered content, no real CSS layout).
@@ -2,6 +2,18 @@
2
2
 
3
3
  A running log of engine design decisions worth re-examining: cases where an existing choice turned out to conflict with a ground-truth source (usually the W3C ACT rules test corpus), or just looks questionable on a second look. Not all of these are bugs; some are tradeoffs that deserve a second opinion before being confirmed or overturned. Each entry has the decision as it stands, why it's being questioned, and its current status. Settled entries move to [Decided](#decided) at the bottom, with the reasoning kept, since a decision is only useful later if the argument behind it survives with it.
4
4
 
5
+ ## Method
6
+
7
+ Before changing behavior that already ships, or agreeing that a challenge to it is right, establish why the current behavior exists rather than assuming it was either careless or considered.
8
+
9
+ - **Read the history first.** `git log -L <start>,<end>:<file>` (or `git log -S<token>`) on the exact lines, and check whether the commit message, this document, `RULE_AUTHORING.md`, or `CHANGELOG.md` already recorded a reason. A comment that states what the code does is not evidence of why it does it — say explicitly when no rationale can be found instead of inventing one to fill the gap.
10
+ - **Check for a second implementation of the same concept.** A question this engine has needed to answer more than once (eligibility, focusability, naming, inertness) is sometimes reimplemented locally inside one rule rather than shared through `dom-helpers.js`. When two independent implementations disagree on the same input, that disagreement is stronger evidence of a real problem than either implementation's own comment defending itself.
11
+ - **Weigh it against ground truth, not intuition:** the WCAG Understanding documents and Techniques, the ACT rules corpus (this repo's primary source, see the intro above), and the HTML/ARIA spec text for the exact mechanism in question — what `inert`, `aria-hidden`, or a native role default actually do, not what seems reasonable.
12
+ - **Compare against other accessibility testing engines where practical**, as one more data point on how the same ambiguity is usually resolved in the field — never as an authority on its own, and never named or quoted in a rule's comments, commits, or docs (this repo never names competing engines).
13
+ - **State the counter-argument before concluding.** Write down why the current behavior might be right, and why it hasn't already been revisited, before deciding it should change. "Nobody thought this through" and "this was already considered and rejected" call for different next steps.
14
+ - **Land the decision somewhere.** Fix it with a `CHANGELOG.md` entry that states the reasoning, or add/update an entry in this file's [Open](#open) or [Decided](#decided) section, so the argument survives with the decision the next time someone re-examines it.
15
+ - **After changing a rule's behavior**, regenerate every generated artifact that describes it, not just the code and tests: `npm run docs:rule-catalog`, `npm run coverage`, `npm run fixtures:index`, `npm run fixtures:markers`, `npm run finding-ids`, and `npm run docs:rule-review` (the by-hand review page). None of these run automatically from a source edit, and a stale one is easy to miss since nothing fails loudly except `fixtures:markers:check`/`coverage:check`/`finding-ids`'s own test.
16
+
5
17
  ## Open
6
18
 
7
19
  ### `label-in-name` compares against accessibility-tree text, where ACT uses *visible* inner text
@@ -118,7 +130,7 @@ Then the remaining overstatement went too. `aria-allowed-role` declared SC 4.1.2
118
130
 
119
131
  **Decision as it stands (before the fix):** `hasHeading()` credited any `<h1>`-`<h6>`/`[role="heading"]` that was included in the accessibility tree; an off-screen-positioned, clipped, opacity:0, or zero-size-overflow-hidden heading counted the same as a fully visible one. This mismatch was never individually triaged; it sat inside `ye5d6e`/`047fe0`'s combined "1, 2" mismatch count, both filed under one blanket "deliberate leniency" reason that only actually described a *different* shape (a heading positioned inside the repeated content it's supposed to be an escape from).
120
132
 
121
- **Why it was questioned:** re-fetching `047fe0`'s live corpus surfaced a case that reason doesn't cover at all: `<h1 class="off-screen">` inside `<div id="main">`, correctly positioned *after* the repeated nav, still expected **failed** by ACT. Its own Expectation text requires the heading to be both "included in the accessibility tree" *and* "[visible](#visible)." A screen-reader-only heading gives sighted keyboard users no equivalent way to locate the start of non-repeated content, which is exactly the gap `047fe0` is checking for.
133
+ **Why it was questioned:** re-fetching `047fe0`'s live corpus surfaced a case that reason doesn't cover at all: `<h1 class="off-screen">` inside `<div id="main">`, correctly positioned *after* the repeated nav, still expected **failed** by ACT. Its own Expectation text requires the heading to be both "included in the accessibility tree" *and* "[visible](https://www.w3.org/WAI/standards-guidelines/act/rules/047fe0/#visible)." A screen-reader-only heading gives sighted keyboard users no equivalent way to locate the start of non-repeated content, which is exactly the gap `047fe0` is checking for.
122
134
 
123
135
  **Decision (2026-08-19):** `hasHeading()` now also requires the heading to carry no CSS-hiding hint (`helpers.getVisibilityHintsInfo`: off-screen, clipped, opacity:0, zero-size-overflow-hidden). The sibling `hasMainLandmark()`/`hasWorkingAnchorLink()` checks are left untouched; `cf77f2`'s own live text doesn't carry the same visibility requirement for a `<main>` landmark, confirmed by an existing regression test that pins a clipped-but-accessible `<main>` as still credited.
124
136
 
@@ -218,7 +230,7 @@ Then the remaining overstatement went too. `aria-allowed-role` declared SC 4.1.2
218
230
 
219
231
  **Decision as it stands:** `src/checks/automatic/duplicate-id-aria.js` only flags a duplicate `id` when it's referenced by an ARIA ID-reference attribute. Its header comment: "Scoped to ids referenced by ARIA, not the broader/deprecated page-wide duplicate-id check (see ROADMAP.md's 'Skip' list)." That `ROADMAP.md` no longer exists in the repo (not found in the working tree or as a tracked file in `git log`, likely a local planning doc that was never committed), so the original reasoning behind "skip" isn't recoverable verbatim, only the pointer to it.
220
232
 
221
- **Why it's being questioned:** while mining ACT's gap list (per the user's request to find gaps worth turning into new rules), `3ea0c8` "Id attribute value is unique" is exactly this broader page-wide check, and it's detectable with a simple, deterministic document-wide scan. Checked ACT's own SC mapping for it: `3ea0c8` maps to **WCAG 4.1.1 Parsing**, which the Working Group formally **removed in WCAG 2.2** (browsers/AT no longer depend on strict-parsing conformance the way they did when that SC was written), and axe-core deprecated its own equivalent broad `duplicate-id` check around the same time, for the same reason. So the original "skip" call was well-founded *for WCAG 2.2 conformance scoring specifically*.
233
+ **Why it's being questioned:** while mining ACT's gap list for gaps worth turning into new rules, `3ea0c8` "Id attribute value is unique" is exactly this broader page-wide check, and it's detectable with a simple, deterministic document-wide scan. Checked ACT's own SC mapping for it: `3ea0c8` maps to **WCAG 4.1.1 Parsing**, which the Working Group formally **removed in WCAG 2.2** (browsers/AT no longer depend on strict-parsing conformance the way they did when that SC was written) and other accessibility engines deprecated their equivalent broad `duplicate-id` checks around the same time, for the same reason. So the original "skip" call was well-founded *for WCAG 2.2 conformance scoring specifically*.
222
234
 
223
235
  That said, duplicate IDs are still a real, practical bug independent of which SC currently covers them: they break `<label for>` association, fragment navigation, and any `getElementById`/`querySelector('#...')` call, not just ARIA references. This engine already supports WCAG-version-scoped tagging (`wcag2a`/`wcag21a`/`wcag22aa`-style tags, see `docs/ENGINE_OPTIONS.md`'s WCAG-version filtering). A page-wide duplicate-id rule could be added and tagged as WCAG 2.0/2.1-only (`wcag411`-style, excluded from WCAG 2.2 tag sets) rather than either fully skipped or wrongly counted against 2.2 conformance, the two options the original either/or "skip" decision didn't have room for.
224
236
 
@@ -350,7 +362,7 @@ One existing test changed meaning with it: a bare `<option>` under `role="listbo
350
362
 
351
363
  **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
364
 
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.
365
+ **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. Other accessibility engines reach the same conclusion: they do not report a missing `aria-controls` target as a violation, and at most ask for a review when the widget is expanded.
354
366
 
355
367
  **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
368
 
@@ -365,3 +377,85 @@ One existing test changed meaning with it: a bare `<option>` under `role="listbo
365
377
  **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
378
 
367
379
  **Status:** resolved 2026-08-27.
380
+
381
+ ### `iframe-title-unique` failed any repeated frame `title`, filed as a deliberate stricter-than-ACT check, now deprecated in favour of `identical-iframes-same-purpose`
382
+
383
+ **Decision as it stood:** `ACT_RULE_MAPPING.md` recorded that `iframe-title-unique` flags any duplicate `title` attribute on `<iframe>`/`<frame>` elements "by design", a stricter check with no ACT counterpart of its own, and that `4b1c6c` would be closed by a separate rule. `identical-iframes-same-purpose` later closed it, and the two ran side by side, the older one reporting `fail` with `defaultConfidence: 'high'` under a normative mapping to WCAG 4.1.2 level A.
384
+
385
+ **Why it was questioned:** running ACT 4b1c6c's own test cases against the engine (#16) showed seven of its ten passed examples and one inapplicable example reported as `fail` by `iframe-title-unique`: two frames titled "List of Contributors" both showing `page-one.html`, a directory written with and without its trailing slash, the same page under two paths. WCAG 4.1.2 asks that a frame's name be programmatically determinable, not that it be unique, so a duplicate title establishes no violation, and `POLICY.md` promises that `fail` means a deterministic normative violation. The rule was breaking the engine's own contract, not only disagreeing with ACT. The first proposal (#17 as opened) kept the rule and gave it the sibling's verdict on the `title` attribute: pass when every frame in a set resolves to one resource, `cantTell` otherwise. But a frame's `title` is its accessible name unless `aria-label`/`aria-labelledby` overrides it, and in the one case where the two differ the title becomes the accessible description, a duplicate of which 4.1.2 does not forbid either. Reshaped, the rule reported the same elements, with the same outcome and uncertainty code, as `identical-iframes-same-purpose`, and copied its accessibility-tree gating to do so.
386
+
387
+ **Decision (2026-09-03):** the rule is deprecated, `replacedBy: 'identical-iframes-same-purpose'`, `sinceVersion: '1.8.0'`, the first use of the mechanism in `API_STABILITY.md`. Because a deprecated rule keeps running and reporting normally, deprecation alone would have left the wrong `fail` alive until 2.0.0, so `runInPage` is reduced to `notApplicable` on every page. The id stays in the catalog until the file is removed in 2.0.0, so a `runOnly` list holding it keeps resolving. `IFRAME_TITLE_DUPLICATE` is no longer emitted and retires with the finding it named: it was listed, with its reason, under `retired` in `scripts/data/released-finding-ids.json` until the 1.8.0 release froze its own inventory, so a stored baseline entry or open Code Scanning alert for it closes, as for a fixed false failure. The rule's facet under SC 4.1.2 is retired and its `coverage.facetsBySc` points at the successor's facet, which the validator requires to be non-empty for a rule with a WCAG mapping; the coverage report leaves deprecated rules out of a facet's coverage (#31), so it does not count as covering it. The scenario fixture is removed rather than left claiming outcomes the rule no longer produces, which is what the fixture-marker check exists to catch; the one case the sibling's tests lacked, several same-named sets on one page where only the set whose resources differ is reported, moved to its test file. This is recorded as a change of mind, not a bug fix: the old behaviour was chosen on purpose, and a stricter check still has to rest on a requirement.
388
+
389
+ **Revised (2026-10-02):** how the retired code is recorded. The PR first kept `IFRAME_TITLE_DUPLICATE` in `scripts/data/finding-ids.json` through an exemption in `tests/finding-ids.test.js`. Review found that `npm run finding-ids` still dropped it with every test passing, and agreed that the rule would declare its retired codes for the generator to read. Before the PR landed, `main` gained `scripts/data/released-finding-ids.json` and `tests/released-finding-ids.test.js`: what a release shipped is frozen there, no regeneration rewrites it, and a code can go only when it is listed under `retired` with its reason. That closes the same gap without a new deprecation field, so the code is retired there and the exemption was dropped.
390
+
391
+ **Status:** resolved 2026-09-03. Over the 23 ACT 4b1c6c test cases, passed/inapplicable fixtures yielding `fail` went 8 → 0; every failed example either iframe rule caught before is still `cantTell` from `identical-iframes-same-purpose`.
392
+
393
+ ### `area-alt-present` treated `<area alt="">` as satisfying its check, borrowing `<img>`'s decorative marker for an element that can't be decorative
394
+
395
+ **Decision as it stands (before the change):** an `<area>` with a present-but-empty `alt` passed `area-alt-present` outright, the same treatment `<img alt="">` gets. A separate manual rule, `area-alt-decorative`, then asked a human to confirm the empty-`alt` area really was decorative.
396
+
397
+ **Why it was questioned:** `<img alt="">` has a real decorative use: the image can carry zero information while everything else on the page still works. An `<area>` has no equivalent — it exists in a used `<map>` only to be a hyperlink hotspot (that's the whole point of the `usemap`/`href` mechanism), so its HTML-AAM role is `link` and an empty `alt` just means an unnamed link, not a decorative one. There is no redundant sibling content standing in for it the way there is for a decorative image; the geometry itself is invisible.
398
+
399
+ **Decision (2026-09-14):** `area-alt-present` now fails an `<area>` with `alt=""` and no other accessible name (`aria-label`/`aria-labelledby`/`title` still count, same fallback order as before), with its own summary/hint distinct from the missing-`alt` case. `area-alt-decorative` is retired rather than kept dormant, since "is this decorative" never had a legitimate yes for this element — see [Removed] in `CHANGELOG.md`. `area-alt-quality` (non-empty `alt`, asking whether the text is accurate) is unaffected and stays a legitimate manual question.
400
+
401
+ **Accepted cost:** `scripts/data/finding-ids.json`'s rule-id inventory drops from 133 to 132. A stored baseline holding a `cantTell` finding against `area-alt-decorative` finds the id gone, not resolved.
402
+
403
+ **Status:** resolved 2026-09-14.
404
+
405
+ ### `hasBlockingInert` ignored `inert` on an `<area>` itself or on its `<map>`, undocumented since the project's first commit
406
+
407
+ **Decision as it stands (before the change):** `hasBlockingInert` in `dom-helpers.js` carried an `<area>`-specific exception: `inert` on the area itself, or on its closest `<map>`, was ignored; only an `inert` ancestor outside that chain excluded the area. Present since the very first commit (`4961752`), pinned by a pointed unit test, with no rationale recorded in the commit, this file, or `RULE_AUTHORING.md`.
408
+
409
+ **Why it was questioned:** the shape reads like a generalization of a different, correct rule — that `aria-hidden` on a focusable element does not remove it from eligibility, since a real user can still Tab onto it (the exact pattern `aria-hidden-focus` exists to catch). `inert` is not `aria-hidden`: the HTML spec has it remove focusability directly, for every element type, with no image-map carve-out in the algorithm, so the "still really reachable" premise that justifies the `aria-hidden` exception does not hold for `inert`. More directly: `aria-hidden-focus.js` already implements this exact question independently (`hasInertAncestor`, walking the element itself and every ancestor) with no `<area>`/`<map>` exception at all — two pieces of code in the same repo disagreed on the identical input, `<area inert>`.
410
+
411
+ **Decision (2026-09-14):** the exception is removed. `inert` on the `<area>`, its `<map>`, or any ancestor now excludes the area uniformly, matching every other element's default handling and matching `aria-hidden-focus.js`'s own independent check. A genuinely inert `<area>` is `notApplicable` rather than a reported failure.
412
+
413
+ **Correction (2026-09-14, same day):** wrong. This reasoned from spec text without checking what a real browser actually does, which is exactly the thing the Method section above says to verify. Tested via real keyboard Tab navigation in Chromium and Firefox: an `<area inert>` and an `<area>` inside an `inert <map>` both stay in the tab order. `<area>`/`<map>` generate no box, so a browser's image-map hit-testing sits outside the pipeline `inert` operates on — the original, undocumented exception was empirically correct despite having no stated reason, and the `aria-hidden-focus.js` comparison that seemed to settle the question was comparing the wrong thing: that rule never evaluates `<area>` in practice, so it never had occasion to get this right or wrong. Re-reverted; see the entry below for the full, verified picture, which also turned up two related gaps this entry didn't touch.
414
+
415
+ **Status:** superseded 2026-09-14 by the entry below.
416
+
417
+ ### `isPlatformFocusable` treated every `<area>` in a used map as focusable, `href` or not, unlike its own `<a>` branch three lines above it, fixed
418
+
419
+ **Decision as it stands (before the change):** `isPlatformFocusable`'s `tag === 'area'` branch (`dom-helpers.js`) treated an `<area>` as focusable purely for belonging to a `<map>` a rendered `<img usemap>` references — no `href` check. The `tag === 'a'` branch immediately above it requires a non-empty `href` before returning focusable. `area-alt-present` and `area-alt-quality` inherited this: both evaluated any `<area>` in a used map, `href` or not, at their own applicability gate (each rule's local `getReferencingImgForArea`/used-map lookup, not `isPlatformFocusable` itself for the baseline case).
420
+
421
+ **Why it was questioned:** per the HTML spec, an `<area>` with no `href` does not represent a hyperlink at all — "represents merely a region of the map that has no associated action" — so it is not focusable, not in the accessibility tree as a link, and has no accessible-name requirement to fail. Run against `scripts/other-engine-corpus-check.js` (built 2026-09-14) scoped to a third-party scanner's own area-alt rule, `area-alt-present`'s fixture agreed on exactly one of its seven failing cases — the one case that happened to carry `href` (`area_case_16`). The other six (missing alt, empty alt, aria-hidden-but-focusable, `role="presentation"`, `aria-disabled`, a dangling `aria-labelledby`) all omitted `href` entirely and the other tool didn't flag any of them. `area-alt-present` has no ACT counterpart (`docs/ACT_RULE_MAPPING.md`'s "Extra coverage beyond ACT" list), so this never surfaced through that comparison either — first time this rule had been checked against any outside ground truth. The `<area>` branch's own comment ("Engine policy: treat `<area>` as focusable when it's part of a *used* image map") dated to the project's first commit, with nothing explaining why `href` was left out unlike the `<a>` branch beside it.
422
+
423
+ **Decision (2026-09-14):** `isPlatformFocusable`'s `<area>` branch now requires a non-empty `href` before doing the used-map lookup, matching the `<a>` branch. `area-alt-present.js` and `area-alt-quality-manual.js` each gained the same `href` check at their own applicability gate, right after the existing used-map lookup, since that gate — not `isPlatformFocusable` — is what actually governs a case's baseline applicability; `isPlatformFocusable`'s branch only mattered for the `aria-hidden`-override and `role="presentation"`-exclusion paths. Both rules' fixtures had `href` retrofitted onto every case testing naming/eligibility logic (all but one case in each fixture previously omitted it), and `area_case_16` in `area-alt-present`'s fixture — previously "href present, still fails" — was repurposed to cover the newly-distinct branch, an `<area>` with no `href` in a used map, since its original point had become redundant with `area_case_01` once every other case also gained `href`.
424
+
425
+ **Status:** resolved 2026-09-14.
426
+
427
+ ### `<area>`/`<map>` eligibility was checked against the wrong ground truth: spec text and `isAccTreeEligible`'s own layered design, not what a real browser does with a non-rendered element pair
428
+
429
+ **Decision as it stands (before the change):** three related exclusions, none verified against a real browser. `hidden`/`display:none`/`inert` on the `<area>` itself, or `display:none`/`inert` on its `<map>`, all excluded the area (the entry above covers `inert` specifically; `hidden` and `display:none` on the `<map>` were separate, pre-existing gaps with no carve-out at all, not something this session introduced). Separately, `area-alt-present.js`/`area-alt-quality-manual.js` gated the area's applicability on `isAccTreeEligible(img, ctx)` for the referencing `<img>`, which folds in `aria-hidden`.
430
+
431
+ **Why it was questioned:** the entry above records getting the `inert` question wrong once already by reasoning from spec text instead of testing it. Doing the same check for `hidden`/`display:none` this time, and testing all of it directly: loaded each scenario in a real page and drove actual keyboard Tab navigation in Chromium and Firefox (WebKit's headless tab handling looked broken in the same harness, so not counted). Result: `hidden` on the `<area>`, and `display:none` or `inert` on the `<map>`, all leave the area fully reachable — same non-rendered-element reasoning as `inert`, just never applied to the other two mechanisms. Separately, `aria-hidden` on the referencing `<img>` also leaves the area reachable, for a different reason: `<area>` is not a DOM descendant of `<img>`, only linked by the `usemap` IDREF, so `aria-hidden` has no ancestor relationship to propagate along, and the img is still rendered regardless of its ARIA state. Gating the area's applicability on the img's full `isAccTreeEligible` — which folds in `aria-hidden` — conflated two different questions: is the img actually rendered (genuinely relevant, since that's what the hotspot geometry depends on) versus is the img exposed to the accessibility tree (irrelevant here).
432
+
433
+ **Decision (2026-09-14):** `hasBlockingInert`'s `<area>` carve-out is restored (see entry above) with the verified reason recorded this time. The structural `hidden`-attribute check and the CSS `display:none` ancestor check in `isAccTreeEligible` both gained the same carve-out, extended to a `<map>` ancestor as well as the area itself (`visibility:hidden` needed no change — it was already skipped for `<area>` nodes and already gave the right answer). `area-alt-present.js`/`area-alt-quality-manual.js`'s referencing-`<img>` gate switched from `isAccTreeEligible` to `isDomVisibleEligible` (`{ visibilityMode: 'styleOnly', disableGeometry: true }`), the same DOM-only helper `aria-hidden-focus.js` already uses for this exact distinction — `hidden`/`display:none`/`visibility` on the img still excludes the area; `aria-hidden` on the img no longer does. Both fixtures gained the corrected titles/spans and cases, and a note in the `hidden_css`/`inertness` section headings that these mechanisms are ignored on the area/map itself but still block on a genuine outside ancestor.
434
+
435
+ **Accepted cost:** `area-alt-present`'s fixture goes from 6 failing cases to 11; `area-alt-quality`'s goes from 1 applicable case to 4. Both increases are real markup this rule should have been flagging all along, not new false positives — a genuinely inert or `hidden`-marked `<area>` in a used map, or one behind an `aria-hidden` image, is still a real, reachable, unnamed link.
436
+
437
+ **Status:** resolved 2026-09-14.
438
+
439
+ ### Ancestor/group `opacity` was treated as an unconditional contrast-computability blocker, even when the backdrop is a single flat resolvable color, fixed
440
+
441
+ **Decision as it stands (before the change):** `contrast-computable`/`contrast-minimum`/`contrast-enhanced` all reported `cantTell` for any text with a fractional-`opacity` ancestor (group opacity), unconditionally. `contrast-all-scenarios.html`'s `case-blocker-ancestor-opacity` was the documented example: a `<p>` with `color:#000000; background-color:#ffffff; opacity:1` inside a `<div style="opacity:0.5">`, itself on a flat white (`bgWhite`) section.
442
+
443
+ **Why it was questioned:** run through `scripts/other-engine-corpus-check.js` unscoped against every fixture with a case, this was one of a small number of gaps that survived filtering out same-page noise and matched a same-topic finding (`color-contrast`) from the other tool. Verified directly rather than trusting either side: rendered the fixture in real Chromium and sampled the actual composited pixel color at the text (`(126,126,126)`) against the background (`(255,255,255)`) — a deterministic WCAG contrast ratio of ~4.06, which fails AA's 4.5:1 for normal text. The existing `getComputabilityBlocker` code comment gave the real reason it stayed unconditional: naively combining the existing per-element opacity product (used for the foreground) with the existing ancestor-opacity-aware background walk double-counts the ancestor's opacity, confirmed by hand with a second scenario (a colored ancestor background rather than white-on-white, where the two independently-computed colors landed on a visibly different, wrong answer from real compositing).
444
+
445
+ **Decision (2026-09-14):** `resolveGroupOpacityColors` in `contrast-helpers.js` resolves both colors in a single walk instead of combining two independently-computed ones after the fact: a background accumulator (as `computeEffectiveBackground` already builds) and a parallel foreground accumulator that receives the text's own color as its innermost layer, with every ancestor's own background-color and opacity applied to *both* accumulators in lockstep. Nothing is combined after the fact, so there is nothing left to double-count, and it handles any number of nested opacity ancestors — with or without their own background-color — uniformly. It only bails (falls back to the existing `ANCESTOR_OPACITY` `cantTell`, unchanged) for a blend-mode/filter/background-image anywhere in the chain, a missing declared text color, or a background that never reaches full opacity even after the whole chain is walked — the genuinely hard general case (a group over a gradient or other unresolvable content) stays exactly as conservative as before. `getComputabilityBlocker`, `computeEffectiveForeground` and `computeEffectiveBackground` all consult it transparently, so no call site elsewhere needed to change. `contrast-all-scenarios.html` gained a second ancestor-opacity case (a gradient sitting further out, beyond the resolvable opacity ancestor) specifically to keep `ANCESTOR_OPACITY` reachable in the fixture corpus `scripts/generate-finding-ids.js` scans — removing that reachability would have silently dropped a still-real reason code from the finding-ids inventory.
446
+
447
+ **Accepted cost:** the fixture's `case-blocker-ancestor-opacity` moves from `cantTell` (computable) to `pass` for `contrast-computable`, and from excluded to `fail` for `contrast-minimum`/`contrast-enhanced` — a real finding these rules should have caught all along, not a new false positive.
448
+
449
+ **Status:** resolved 2026-09-14.
450
+
451
+ ### Six ARIA-widget naming rules credited a `<label>` to elements it never actually named, via an unguarded local copy of the label-association lookup
452
+
453
+ **Decision as it stands (before the change):** `combobox-name-present`, `listbox-name-present`, `searchbox-name-present`, `spinbutton-name-present`, `textbox-name-present` and `slider-name-present` each carried an identical local `getNativeLabelText(el)`, falling back to `el.closest('label')` (wrapping) or a `label[for]` map (`buildLabelForMap`) with no check that `el` was actually a labelable element. Each rule's own JSDoc already stated the intended contract correctly ("On a labelable element (`<input role="combobox">`) an associated `<label>` counts as well") — the implementation just never enforced the "labelable" half of it.
454
+
455
+ **Why it was questioned:** run through `scripts/other-engine-corpus-check.js` unscoped, `combobox-name-present`/`listbox-name-present`/`searchbox-name-present`/`spinbutton-name-present`/`textbox-name-present` all disagreed with the *same* other-tool rule, `aria-input-field-name` — a recurring pattern across five rules pointed at one systemic thing rather than five coincidences. Verified directly: rendered `<label>Wrapped label text <div role="combobox" tabindex="0"></div></label>` in real Chromium and Firefox and read the actual accessibility tree (`ariaSnapshot()`) — the combobox has no accessible name in either browser, wrapping or `for`-based. `slider-name-present` shared the same buggy local function but never actually triggered it: its own call site already gates the label lookup behind `kind === 'native-slider'`, so it was dead code there, not a live bug — its fixture already had the correct expectation on record.
456
+
457
+ **Decision (2026-09-14):** all six rules' local `getNativeLabelText` now delegate to the shared `getAssociatedLabelElements` (`dom-helpers.js`), which already correctly restricted the *wrapping* case to `wrap.querySelector(LABELABLE_SELECTOR) === el` but, it turned out, never applied the equivalent check to the `for`-based case — so `getAssociatedLabelElements` itself gained a labelable check up front, benefiting every existing caller, not just these six. That surfaced a second instance of the identical bug one level down: a pinned unit test in `tests/core/dom-helpers-name-computation.test.js` explicitly asserted that `<label for="x">Name</label><div id="x" role="button">` resolves to the name `"Name"` — verified against real Chromium/Firefox as wrong the same way, and corrected alongside a new test pinning the correct behavior for a genuinely labelable target.
458
+
459
+ **Accepted cost:** each of the five previously-affected rules (not `slider-name-present`) gains two new failing fixture cases — a wrapping-`<label>` case and a `label[for]`-plus-empty-content-title-fallback case — both real markup a screen reader user hears as unnamed, not new false positives.
460
+
461
+ **Status:** resolved 2026-09-14. `form-control-single-label.js` and `binary-control-name-present.js` have their own separate, similarly-unguarded `closest('label')` fallbacks, not touched here since their applicability already appears scoped to genuinely labelable elements (not verified) — worth a look if they ever start evaluating non-labelable targets.
package/docs/EARL.md CHANGED
@@ -17,7 +17,7 @@ fs.writeFileSync('earl.jsonld', JSON.stringify(report, null, 2));
17
17
 
18
18
  Two audiences, and they want the same document for different reasons.
19
19
 
20
- An **implementation report** tells the ACT Rules community group how this engine behaves against their test cases, which is what gets an engine listed alongside the other implementations. Listing is not endorsement and the W3C does not verify the data — the accurate phrasing is *"listed as an ACT implementation"*, never *"W3C certified"*.
20
+ An **implementation report** tells the ACT Rules community group how this engine behaves against their test cases, which is what gets an engine listed alongside the other implementations. Listing is not endorsement and the W3C does not verify the data — the accurate phrasing is *"listed as an ACT implementation"*, never *"W3C certified"*. This engine's own report is published at <https://surea11y.github.io/act-report/act-report.jsonld> and regenerated by `scripts/act-report.js`.
21
21
 
22
22
  A **consumer** gets an interchange format. EARL is what accessibility tooling reads when it has to combine results from more than one source — an automated scan and a manual audit, say, or several engines — because every assertion carries who asserted it and how. That is worth having whether or not anything is ever submitted anywhere.
23
23
 
@@ -72,7 +72,7 @@ Criterion ids are derived from the criterion's own title (`Non-text Content` →
72
72
 
73
73
  `earl:untested` has no counterpart: a rule that did not run produces no result to assert on, so it contributes no assertion rather than an untested one.
74
74
 
75
- **`cantTell` does not cost conformance credit.** ACT's own consistency rules allow an automated implementation to report "cannot tell" on some — though not all — examples and still count as consistent. What a partially consistent implementation may *not* do is produce a false positive: failing an example the rule says should pass, or that is inapplicable. That is the gate worth watching, and it is a property of the rules rather than of this reporter. As of the last full run the engine produces **zero false positives** across the 798 ACT examples covering its 58 matched rules — see [`ACT_RULE_MAPPING.md`](./ACT_RULE_MAPPING.md), and re-run `scripts/act-testcase-check.js` for the live figure rather than trusting this one indefinitely.
75
+ **`cantTell` does not cost conformance credit.** ACT's own consistency rules allow an automated implementation to report "cannot tell" on some — though not all — examples and still count as consistent. What a partially consistent implementation may *not* do is produce a false positive: failing an example the rule says should pass, or that is inapplicable. That is the gate worth watching, and it is a property of the rules rather than of this reporter. As of the last full run the engine produces **zero false positives** across the 821 ACT examples covering its 59 matched rules — see [`ACT_RULE_MAPPING.md`](./ACT_RULE_MAPPING.md), and re-run `scripts/act-testcase-check.js` for the live figure rather than trusting this one indefinitely.
76
76
 
77
77
  ## Several results, one report
78
78
 
@@ -91,6 +91,78 @@ If you would rather not see the rule at all under 2.2, exclude it outright — t
91
91
 
92
92
  `duplicate-id` is the only rule carrying that tag today. Left in, it still reports something real — a duplicate id breaks `<label for>`, fragment links and `getElementById` whatever the standard says — it just is not a 2.2 conformance failure.
93
93
 
94
+ ### Conformance profiles
95
+
96
+ `engineOptions.profile` names a conformance target instead of spelling out its tag set:
97
+
98
+ | Profile | Runs the rules tagged | WCAG target | Why |
99
+ |---|---|---|---|
100
+ | `wcag22-aa` | `wcag2a`, `wcag2aa`, `wcag21a`, `wcag21aa`, `wcag22a`, `wcag22aa` | 2.2 | WCAG 2.2 Level A and AA |
101
+ | `en301549-v4.1.1` | same as `wcag22-aa` | 2.2 | EN 301 549 V4.1.1 chapter 9 restates WCAG 2.2 A and AA |
102
+ | `en301549-v3.2.1` | `wcag2a`, `wcag2aa`, `wcag21a`, `wcag21aa` | 2.1 | EN 301 549 V3.2.1 chapter 9 restates WCAG 2.1 A and AA, including 4.1.1 Parsing |
103
+ | `section508` | `wcag2a`, `wcag2aa` | 2.0 | The Revised 508 Standards incorporate WCAG 2.0 A and AA |
104
+
105
+ ```js
106
+ runDomRulesInPage(url, null, { profile: 'en301549-v3.2.1' }, null);
107
+ ```
108
+
109
+ A standard registered as a profile under `profiles/` brings its own profiles to this list ([`WCAG_CONFORMANCE.md`](./WCAG_CONFORMANCE.md#adding-another-standard)).
110
+
111
+ The WCAG target follows from the tags the same way it does for a hand-written set (see [Filtering by WCAG version](#filtering-by-wcag-version-21-vs-22)), so under `en301549-v3.2.1` a duplicate id can still `fail`, and under `en301549-v4.1.1` it cannot. Names are matched case-insensitively. A run that used a profile reports it back as `engine.profile`.
112
+
113
+ Precedence: anything that *includes* rules selects them instead of the profile — a `runOnly` with `tags`, `includeRuleIds` or `includeTestIds`, or an `include` in `engineOptions.rules`/`.tags`/`.tests`. Excludes still apply on top of the profile, whether they come from `runOnly` (`excludeTags`, `excludeRuleIds`, `excludeTestIds`, so a binding's `disableTags()` narrows the profile rather than replacing it) or, when there is no `runOnly` filter, from `engineOptions` (`tags.exclude`, `rules.exclude`, `tests.exclude`), and an explicit `engineOptions.wcagVersion` still wins over the version the profile implies. A profile that does not take effect — an unknown name, or one overridden as above — is not an error: the run proceeds as if none was given, logs a `console.warn` saying why, and carries no `engine.profile`.
114
+
115
+ A standard's profile may also leave rules out, when its standard waives a WCAG criterion or replaces a WCAG check with its own (`exclude` in the registry, [`WCAG_CONFORMANCE.md`](./WCAG_CONFORMANCE.md#adding-another-standard)): those rules and their WCAG rollups do not run, the rule catalog leaves them out too, and `engine.profileExcludes` names what was left out. No built-in profile excludes anything today.
116
+
117
+ A profile only chooses which rules run. It says nothing about whether passing them meets the standard it is named after: most Success Criteria need human judgement no automated rule covers (see [`WCAG_CONFORMANCE.md`](./WCAG_CONFORMANCE.md#what-this-engine-cannot-tell-you)).
118
+
119
+ An EN 301 549 profile also switches on the EN 301 549 clauses of the version it targets, as if `mappings: ['en301549:V4.1.1']` (or `V3.2.1`) had been passed; see the next section. Any standard's profile switches on its own version the same way. A profile with `mappedRules` also runs, whatever their tags, the rules its standard is mapped to: the ones with no WCAG mapping, such as `heading-order` and `skip-link`, have no WCAG tag to be selected by.
120
+
121
+ ### Opt-in rules
122
+
123
+ Some rules check a standard's own requirements, ones WCAG does not make: a national standard may require a doctype, or forbid presentational attributes such as `bgcolor`. A `fail` from such a rule is a failure of that standard, not of WCAG, so these rules are **off by default**. Each carries its standard's rule tag and runs only when the selection asks for it:
124
+
125
+ - through the standard's profile, which lists the tag,
126
+ - by that tag (`tags: { include: '<tag>' }`, alone or with others), or
127
+ - by its id (`rules: { include: '<rule id>' }`, or `runOnly.includeRuleIds`),
128
+ - by the id of one of its standard's own rollups that groups it (`rules: { include: '<rollup id>' }` runs that rollup's rules, opt-in or not), or
129
+ - by unlocking it with `engineOptions.optInRules`, below.
130
+
131
+ Nothing else selects one: not a default run, not a WCAG tag set, not a WCAG or EN 301 549 profile, not a WCAG rollup id. A standard's own rollups carry the same tag and follow the same rule. Excludes apply to them as to any rule. This holds for a rule added through `customRules` that carries the tag too. The tags come from `ruleTag` in `src/coverage/standards.js`, one per registered standard that has rules of its own; core's built-in standards have none.
132
+
133
+ The tag selects the opt-in rules and nothing more. `tags: { include: '<tag>' }` alone runs that standard's own rules, not the WCAG and best-practice rules it is also mapped to, so it answers "what does the standard require beyond WCAG" and is not an audit against it. Its own rollups report `cantTell` for any requirement whose rules did not run (`missingChild`). For an audit, use the standard's profile.
134
+
135
+ #### Running every rule (`optInRules`)
136
+
137
+ To run every rule the engine has, whatever standard it belongs to, leave out the profile and unlock the opt-in rules:
138
+
139
+ ```js
140
+ runDomRulesInPage(url, null, { optInRules: 'all' }, null);
141
+ ```
142
+
143
+ `'all'` unlocks every standard's opt-in rules; a list of tags (`['<tag>']`, or `'<tag>'`) unlocks only those. It lifts the gate and nothing else: the rest of the selection still decides which rules run. So with no filter and no profile, as above, the run covers every rule and every standard's rollups; under `profile: 'wcag22-aa'` it still runs the WCAG rules only, since opt-in rules carry no WCAG tag, and under a standard's own profile it changes nothing for that standard's rules. Excludes apply as usual. A tag that is not an opt-in tag is ignored with a `console.warn`.
144
+
145
+ This is for seeing everything the engine can report, during development or when exploring a site. It is not a conformance target: such a run fails a page for things WCAG allows and only another standard forbids. The result says so: `engine.optInRules` lists the tags whose rules ran, and the HTML report, SARIF and JUnit show it next to the profile. It adds no other standard's numbers to results; pass `mappings` for that, for example `{ optInRules: 'all', mappings: ['<key>'] }` to see a standard's requirements each rule checks.
146
+
147
+ ### Other standards (`mappings`)
148
+
149
+ Every result's `meta.normativeMappings` names the WCAG Success Criteria it tests. `engineOptions.mappings` adds the requirement of another standard that corresponds to each one. By default it adds none: a clause of a standard you do not audit against is noise in every SARIF tag, JUnit property and report.
150
+
151
+ | Value | Adds |
152
+ |---|---|
153
+ | `'en301549'` | The EN 301 549 chapter 9 clause for each criterion, in every version that has it (V3.2.1 and V4.1.1) |
154
+ | `'en301549:V3.2.1'`, `'en301549:V4.1.1'` | The same, for that version only |
155
+
156
+ ```js
157
+ runDomRulesInPage(url, null, { mappings: ['en301549:V3.2.1'] }, null);
158
+ ```
159
+
160
+ A standard registered as a profile adds its own key (and `<key>:<version>`), with the requirements it maps each rule to.
161
+
162
+ It takes an array or a comma-separated string, names and versions matched case-insensitively. What it adds to comes from the profile too: under `profile: 'en301549-v4.1.1'` the V4.1.1 clauses are there without asking, and `mappings` can add more. A name or version the engine has no table for is ignored with a `console.warn`. A run that carries any other standard reports which in `engine.mappings`, canonically spelled (`["en301549:V3.2.1"]`, or `["en301549"]` for every version).
163
+
164
+ It changes only what a result names, never which rules run or their outcomes. The rule catalog follows the same option: `getChecksCatalog(engineOptions)`, `getCheckDefById(ruleId, engineOptions)`, `getChecksForRunOnly(runOnly, engineOptions)`, `getRulesCatalog(engineOptions)` and `getCompositeRuleById(id, engineOptions)` name the standards a scan with those options would, a profile included when it would apply, so a catalog entry and a result always agree. With no options they name WCAG only; pass every standard's key in `mappings` for all of them. The published tables (`@surea11y/core/en301549`) are not filtered. A rule added through `customRules` keeps exactly the mappings it declares, whatever this option says.
165
+
94
166
  ### Via `engineOptions` (no `runOnly`)
95
167
 
96
168
  Same filtering, expressed as comma-separated strings (or arrays) nested in `engineOptions`:
@@ -111,6 +183,9 @@ runDomRulesInPage(url, null, {
111
183
  const engineOptions = {
112
184
  locale: 'en', // default 'en'; de-DE falls back to de, then to en per string
113
185
  wcagVersion: '2.2', // default '2.2' — the conformance target, see "Filtering by WCAG version" above
186
+ profile: 'en301549-v4.1.1', // optional — a named conformance target, see "Conformance profiles" above
187
+ mappings: ['en301549'], // optional — standards besides WCAG to name on each result, see "Other standards" above
188
+ optInRules: 'all', // optional — also run rules other standards add beyond WCAG, see "Running every rule" above
114
189
  messages: { de: { /* key: text */ } }, // optional caller-supplied dictionaries; win over built-in ones
115
190
  includeHiddenElements: false, // default false — set true to evaluate hidden/collapsed subtrees too
116
191
  includeShadowDom: true, // default true — opt OUT with `false` to skip open shadow roots
@@ -157,6 +232,9 @@ const engineOptions = {
157
232
  |---|---|
158
233
  | `locale` | Any string. A code with a subtag falls back to its base language first, so `de-DE` uses `de`; failing that, English. Individual strings then fall back the same way (chosen locale → `en` → the rule's literal English text), so a partly-translated locale never produces missing text. All of that is silent in the strings themselves, so the result reports what actually happened in `engine.locale` — check it if you need to know whether you got the language you asked for. See [`I18N.md`](./I18N.md). |
159
234
  | `wcagVersion` | `'2.0'`, `'2.1'` or `'2.2'` — which version of WCAG the run is conformance-testing against. Defaults to whatever your version-origin tags imply, and to `'2.2'` when they imply nothing. The only thing it currently changes is SC 4.1.1 Parsing, removed in 2.2: under a 2.2 target a rule tagged `wcag22-removed` still runs and still reports its occurrences, but cannot `fail` — see ["Filtering by WCAG version"](#filtering-by-wcag-version-21-vs-22) above. Any other value is ignored and the default applies. |
235
+ | `profile` | Optional named conformance target: `'wcag22-aa'`, `'en301549-v4.1.1'`, `'en301549-v3.2.1'`, `'section508'`, or a registered standard's own. Selects rules by the matching tag set when nothing else includes any, and the WCAG target follows from those tags. Reported back as `engine.profile` when it took effect; otherwise ignored with a console warning. See ["Conformance profiles"](#conformance-profiles) above. |
236
+ | `mappings` | Optional standards besides WCAG whose requirements `meta.normativeMappings` names: `'en301549'`, `'en301549:V3.2.1'`, `'en301549:V4.1.1'`, or a registered standard's key, as an array or comma-separated string. Default none, so results name WCAG only; an EN 301 549 profile adds its own version. Reported back as `engine.mappings` when any applies. See ["Other standards"](#other-standards-mappings) above. |
237
+ | `optInRules` | Optional: `'all'`, or a list of opt-in rule tags, as an array or comma-separated string. Unlocks those rules outside their standard's profile; the rest of the selection still decides what runs. Reported back as `engine.optInRules` when it ran a rule the selection would not have run otherwise; unknown tags are ignored with a console warning. See ["Running every rule"](#running-every-rule-optinrules) above. |
160
238
  | `messages` | Optional `{ [locale]: { key: text } }`. Checked before the engine's own tables, so it can override individual strings or supply a language the build does not carry. Keys you omit fall back normally, so a partial override is fine. This is how the standalone browser bundle receives a locale side file, and it is the only way to get a dictionary into a page context, since the in-page runner is serialized and cannot read files. See [`I18N.md`](./I18N.md). |
161
239
  | `includeHiddenElements` | Default `false`: helper queries exclude elements hidden by structural/CSS mechanisms such as `display:none`, `[hidden]`, closed `<details>`, and hidden rendering-only host elements (with descendants excluded too). Set `true` to include those hidden/collapsed subtrees in evaluation (legacy/static-markup behavior). |
162
240
  | `includeShadowDom` | Default `true`: rules using `helpers.queryAllSmart` traverse into open shadow roots. Set `false` to scan only the light DOM. Closed shadow roots are never reachable either way (no DOM API exposes them). |
@@ -167,9 +245,9 @@ const engineOptions = {
167
245
  | `contrast.rootCanvasFallback` | The assumed page background color when it's not computable at all — only matters in `auditorAssist` mode. |
168
246
  | `visibilityMode` | Controls how strict the three contrast rules (`contrast-minimum`, `contrast-enhanced`, `contrast-computable`) are about deciding a text node is actually eligible to check. **Not read by any other rule.** `'styleOnly'` (default): eligibility is CSS-only — `display`, `visibility`, `opacity`, ancestor-hiding, etc. `'styleAndGeometry'`: adds real layout checks (`getClientRects()`/`getBoundingClientRect()`) on top of that — text with no client rects, or zero width/height, is excluded too. Reach for `'styleAndGeometry'` when running under a real browser/Playwright-Puppeteer (`runa11yCoreInPage`) and you want contrast findings to reflect actual rendered layout rather than just computed style; under plain jsdom (`runDomRulesInPage`) there's no real layout engine, so `'styleAndGeometry'` mostly just adds `getBoundingClientRect()` zero-size checks, not true clipping/overflow detection — see [`LIMITATIONS.md`](./LIMITATIONS.md). |
169
247
  | `policyContract` / `policy` | See [`POLICY.md`](./POLICY.md) — controls which outcomes/confidence values are allowed and whether manual rules' would-be `fail`s get coerced to `cantTell`. |
170
- | `output.includeSelector` / `.includeHtml` | Suppresses the engine's automatic `selector`/`html` fill-in. Since every rule was migrated to report its element rather than build occurrences by hand (1.5.0), that fill-in is the path almost all of them take: setting `includeSelector: false` strips selectors from 117 of the 130 rules, and `includeHtml: false` strips HTML snippets from 122. The remainder still assemble those fields themselves inside `runInPage` and are unaffected — among them `contrast-minimum`/`contrast-enhanced` (whose findings are text runs, not elements), `page-title-present` and `identical-links-same-purpose`. So this narrows output substantially but is still not a guarantee of *no* selectors or HTML anywhere in the result. |
171
- | `rules[ruleId]` | Passed through to that rule as `ctx.config`, and — for `excludeSelectors` specifically — read by the engine itself before the rule ever runs. See "Rule-scoped `excludeSelectors`" below. Any other key is passthrough only: **no shipped rule currently reads `ctx.config`** for anything besides `excludeSelectors`. |
172
- | `probes` | An optional, JSON-safe evidence object your host application can supply (depth- and size-capped by the engine before rules see it, via `ctx.inputs.probes`) — for future rules that might accept externally-supplied signals (e.g. real layout measurements a static DOM scan can't compute itself). Not consumed by any current rule. |
248
+ | `output.includeSelector` / `.includeHtml` | Suppresses the engine's automatic `selector`/`html` fill-in. Since every rule was migrated to report its element rather than build occurrences by hand (1.5.0), that fill-in is the path almost all of them take: setting `includeSelector: false` strips selectors from nearly every rule, and `includeHtml: false` HTML snippets. The remainder still assemble those fields themselves inside `runInPage` and are unaffected — among them `contrast-minimum`/`contrast-enhanced` (whose findings are text runs, not elements), `page-title-present` and `identical-links-same-purpose`. So this narrows output substantially but is still not a guarantee of *no* selectors or HTML anywhere in the result. |
249
+ | `rules[ruleId]` | Passed through to that rule as `ctx.config`, and — for `excludeSelectors` specifically — read by the engine itself before the rule ever runs. See "Rule-scoped `excludeSelectors`" below. Any other key is passthrough only: **no shipped rule takes anything else from it.** A rule's declared settings, such as `contrast-minimum`'s thresholds, are its standard's, so a caller's value for one is dropped: a result naming WCAG 1.4.3 is always decided at 4.5:1. A standard with other thresholds has its own rule, a variant ([`RULE_AUTHORING.md`](./RULE_AUTHORING.md#rule-variants)). |
250
+ | `probes` | An optional, JSON-safe evidence object your host application supplies, for what a scan of one page cannot see, such as the site's other pages. The engine caps it before rules read it (`ctx.inputs.probes`): six levels deep, 200 items per array, 50 keys per object, 2,000 characters per string. `crawl.pageTitles` (`{ pages: [{ url, title }] }`) is read by `page-title-patterns`, to look for generic and templated titles across a site. A profile's rules may read probes of their own, which its documentation describes. |
173
251
  | `perfStats` / `profileRules` | Debug-only. `perfStats: true` returns internal counters on the result's `perfStats` field; `profileRules: true` **additionally** adds a per-rule timing breakdown there. `profileRules` on its own does nothing — `perfStats` is what creates the object the breakdown lives in. Shape is not part of the stable output contract — don't build on it. Note also that `profileRules` is the one option that makes output non-deterministic: counters are stable across identical runs, wall-clock timings are not. Leave it off if you diff results between runs. |
174
252
  | `pingWaitTime` / `frameWaitTime` | Only read by `runa11yCoreAcrossFrames` (see [`INTEGRATION.md`](./INTEGRATION.md#cross-frame-scanning-including-cross-origin)) — how long to wait for a child frame to answer a ping (default `500`ms) and a full run request (default `60000`ms) before treating it as unreachable. Ignored by `runDomRulesInPage`/`runa11yCoreInPage`. |
175
253