@surea11y/core 1.5.0 → 1.7.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (157) hide show
  1. package/CHANGELOG.md +240 -149
  2. package/README.md +51 -44
  3. package/docs/ACT_RULE_MAPPING.md +245 -0
  4. package/docs/API_STABILITY.md +53 -5
  5. package/docs/BINDING_AUTHORS_GUIDE.md +106 -4
  6. package/docs/DESIGN_CHALLENGES.md +367 -0
  7. package/docs/EARL.md +100 -0
  8. package/docs/ENGINE_OPTIONS.md +42 -4
  9. package/docs/I18N.md +4 -4
  10. package/docs/INTEGRATION.md +4 -2
  11. package/docs/LIMITATIONS.md +9 -5
  12. package/docs/OUTPUT_SCHEMA.md +44 -6
  13. package/docs/POLICY.md +1 -1
  14. package/docs/REPORT.md +1 -1
  15. package/docs/RULE_AUTHORING.md +63 -36
  16. package/docs/RULE_CATALOG.md +1928 -169
  17. package/docs/RULE_HELPERS.md +333 -0
  18. package/docs/RULE_TAXONOMY.md +27 -6
  19. package/docs/SARIF.md +21 -2
  20. package/docs/TROUBLESHOOTING.md +2 -2
  21. package/docs/WCAG_CONFORMANCE.md +34 -10
  22. package/package.json +11 -9
  23. package/src/baseline.js +3 -3
  24. package/src/checks/automatic/area-alt-present.js +2 -2
  25. package/src/checks/automatic/aria-allowed-attr.js +74 -10
  26. package/src/checks/automatic/aria-allowed-role.js +34 -25
  27. package/src/checks/automatic/aria-braille-equivalent.js +21 -13
  28. package/src/checks/automatic/aria-conditional-attr.js +22 -15
  29. package/src/checks/automatic/aria-deprecated-role.js +13 -1
  30. package/src/checks/automatic/aria-hidden-body.js +3 -3
  31. package/src/checks/automatic/aria-hidden-focus.js +5 -5
  32. package/src/checks/automatic/aria-prohibited-attr.js +23 -18
  33. package/src/checks/automatic/aria-prohibited-children.js +136 -43
  34. package/src/checks/automatic/aria-required-attr.js +119 -24
  35. package/src/checks/automatic/aria-required-children.js +54 -30
  36. package/src/checks/automatic/aria-required-parent.js +93 -15
  37. package/src/checks/automatic/aria-role-name-present.js +37 -23
  38. package/src/checks/automatic/aria-roles-valid.js +52 -21
  39. package/src/checks/automatic/aria-valid-attr-value.js +89 -33
  40. package/src/checks/automatic/aria-valid-attr.js +15 -10
  41. package/src/checks/automatic/autocomplete-valid.js +2 -2
  42. package/src/checks/automatic/avoid-inline-spacing.js +133 -6
  43. package/src/checks/automatic/binary-control-name-present.js +27 -5
  44. package/src/checks/automatic/button-name-present.js +92 -6
  45. package/src/checks/automatic/combobox-name-present.js +26 -6
  46. package/src/checks/automatic/contrast-computable.js +42 -0
  47. package/src/checks/automatic/contrast-enhanced.js +33 -1
  48. package/src/checks/automatic/contrast-minimum.js +33 -1
  49. package/src/checks/automatic/css-orientation-lock.js +138 -24
  50. package/src/checks/automatic/definition-list-children-valid.js +7 -8
  51. package/src/checks/automatic/deprecated-elements-not-used.js +1 -1
  52. package/src/checks/automatic/dialog-name-present.js +20 -2
  53. package/src/checks/automatic/duplicate-id-aria.js +10 -3
  54. package/src/checks/automatic/duplicate-id.js +203 -0
  55. package/src/checks/automatic/embed-text-alternative-present.js +2 -2
  56. package/src/checks/automatic/form-control-programmatic-label-present.js +19 -2
  57. package/src/checks/automatic/form-control-single-label.js +10 -1
  58. package/src/checks/automatic/identical-iframes-same-purpose.js +229 -0
  59. package/src/checks/automatic/iframe-focusable-content.js +68 -7
  60. package/src/checks/automatic/iframe-name-present.js +37 -3
  61. package/src/checks/automatic/iframe-title-unique.js +1 -1
  62. package/src/checks/automatic/img-alt-present.js +12 -4
  63. package/src/checks/automatic/label-in-name.js +204 -68
  64. package/src/checks/automatic/link-in-text-block.js +285 -29
  65. package/src/checks/automatic/link-name-present.js +22 -1
  66. package/src/checks/automatic/list-children-valid.js +6 -6
  67. package/src/checks/automatic/listbox-name-present.js +28 -8
  68. package/src/checks/automatic/listitem-parent-valid.js +4 -4
  69. package/src/checks/automatic/menuitem-name-present.js +20 -2
  70. package/src/checks/automatic/meta-refresh-no-exceptions.js +25 -14
  71. package/src/checks/automatic/meta-refresh-timing-absent.js +14 -7
  72. package/src/checks/automatic/meter-name-present.js +23 -4
  73. package/src/checks/automatic/nested-interactive-controls-absent.js +5 -5
  74. package/src/checks/automatic/option-name-present.js +23 -4
  75. package/src/checks/automatic/page-title-present.js +21 -3
  76. package/src/checks/automatic/presentational-children-focusable-absent.js +330 -0
  77. package/src/checks/automatic/progressbar-name-present.js +23 -4
  78. package/src/checks/automatic/{role-img-alt-present.js → role-img-text-alternative-present.js} +64 -16
  79. package/src/checks/automatic/searchbox-name-present.js +28 -8
  80. package/src/checks/automatic/server-side-image-map-absent.js +1 -1
  81. package/src/checks/automatic/slider-name-present.js +27 -6
  82. package/src/checks/automatic/spinbutton-name-present.js +28 -8
  83. package/src/checks/automatic/summary-name-present.js +18 -2
  84. package/src/checks/automatic/svg-image-text-alternative-present.js +1 -1
  85. package/src/checks/automatic/svg-text-alternative-present.js +13 -10
  86. package/src/checks/automatic/tab-name-present.js +21 -2
  87. package/src/checks/automatic/table-headers-attr-valid.js +43 -8
  88. package/src/checks/automatic/table-th-has-data-cells.js +61 -5
  89. package/src/checks/automatic/target-size-minimum.js +155 -58
  90. package/src/checks/automatic/td-has-header.js +24 -23
  91. package/src/checks/automatic/textbox-name-present.js +28 -8
  92. package/src/checks/automatic/tooltip-name-present.js +21 -2
  93. package/src/checks/automatic/treeitem-name-present.js +23 -4
  94. package/src/checks/automatic/valid-lang.js +92 -7
  95. package/src/checks/automatic/video-poster-text-alternative-present.js +1 -1
  96. package/src/checks/manual/accesskeys-manual.js +3 -3
  97. package/src/checks/manual/area-alt-decorative-manual.js +7 -0
  98. package/src/checks/manual/area-alt-quality-manual.js +6 -0
  99. package/src/checks/manual/aria-checked-state-mismatch-manual.js +5 -5
  100. package/src/checks/manual/aria-text-manual.js +4 -4
  101. package/src/checks/manual/bypass-blocks-present-manual.js +44 -26
  102. package/src/checks/manual/canvas-text-alternative-quality-manual.js +8 -0
  103. package/src/checks/manual/css-focus-indicator-suppressed-manual.js +444 -0
  104. package/src/checks/manual/embed-text-alternative-quality-manual.js +8 -0
  105. package/src/checks/manual/empty-heading-manual.js +58 -11
  106. package/src/checks/manual/empty-table-header-manual.js +8 -8
  107. package/src/checks/manual/focus-order-semantics-manual.js +15 -15
  108. package/src/checks/manual/form-control-label-quality-manual.js +563 -0
  109. package/src/checks/manual/form-control-programmatic-label-quality-manual.js +2 -2
  110. package/src/checks/manual/heading-order-manual.js +3 -3
  111. package/src/checks/manual/heading-quality-manual.js +338 -0
  112. package/src/checks/manual/identical-links-same-purpose-manual.js +73 -12
  113. package/src/checks/manual/image-redundant-alt-manual.js +4 -4
  114. package/src/checks/manual/img-alt-decorative-manual.js +211 -52
  115. package/src/checks/manual/img-alt-quality-manual.js +7 -0
  116. package/src/checks/manual/input-image-alt-decorative-manual.js +8 -0
  117. package/src/checks/manual/input-image-alt-quality-manual.js +5 -0
  118. package/src/checks/manual/label-title-only-manual.js +4 -4
  119. package/src/checks/manual/landmark-banner-is-top-level-manual.js +7 -7
  120. package/src/checks/manual/landmark-complementary-is-top-level-manual.js +231 -0
  121. package/src/checks/manual/landmark-contentinfo-is-top-level-manual.js +9 -9
  122. package/src/checks/manual/landmark-main-is-top-level-manual.js +6 -6
  123. package/src/checks/manual/landmark-no-duplicate-banner-manual.js +6 -6
  124. package/src/checks/manual/landmark-no-duplicate-contentinfo-manual.js +6 -6
  125. package/src/checks/manual/landmark-no-duplicate-main-manual.js +2 -2
  126. package/src/checks/manual/landmark-one-main-manual.js +6 -6
  127. package/src/checks/manual/landmark-unique-manual.js +9 -9
  128. package/src/checks/manual/link-name-quality-manual.js +161 -32
  129. package/src/checks/manual/media-transcript-present-manual.js +2 -3
  130. package/src/checks/manual/meta-viewport-large-manual.js +2 -2
  131. package/src/checks/manual/mouse-only-event-handlers-manual.js +9 -9
  132. package/src/checks/manual/no-autoplay-audio-manual.js +3 -3
  133. package/src/checks/manual/object-text-alternative-quality-manual.js +8 -0
  134. package/src/checks/manual/p-as-heading-manual.js +4 -4
  135. package/src/checks/manual/page-has-heading-one-manual.js +6 -6
  136. package/src/checks/manual/page-title-patterns-manual.js +26 -3
  137. package/src/checks/manual/password-paste-enabled-manual.js +255 -0
  138. package/src/checks/manual/presentation-role-conflict-manual.js +55 -25
  139. package/src/checks/manual/region-manual.js +19 -19
  140. package/src/checks/manual/scope-attr-valid-manual.js +2 -2
  141. package/src/checks/manual/scrollable-region-focusable-manual.js +7 -7
  142. package/src/checks/manual/skip-link-manual.js +5 -5
  143. package/src/checks/manual/svg-text-alternative-quality-manual.js +9 -0
  144. package/src/checks/manual/tabindex-manual.js +2 -2
  145. package/src/checks/manual/table-duplicate-name-manual.js +2 -2
  146. package/src/checks/manual/table-fake-caption-manual.js +2 -2
  147. package/src/checks/manual/video-caption-manual.js +3 -3
  148. package/src/checks/manual-review.js +17 -1
  149. package/src/core.js +8880 -41883
  150. package/src/earl.js +144 -0
  151. package/src/report.js +2 -2
  152. package/src/sarif.js +22 -2
  153. package/surea11y.browser.js +10 -37882
  154. package/surea11y.i18n.de.js +2 -21
  155. package/surea11y.i18n.es.js +2 -21
  156. package/surea11y.i18n.fr.js +2 -21
  157. package/bin/surea11y-core.js +0 -20
package/README.md CHANGED
@@ -1,6 +1,7 @@
1
1
  # @surea11y/core
2
2
 
3
3
  <a href="https://www.npmjs.com/package/@surea11y/core"><picture><source media="(prefers-color-scheme: dark)" srcset="https://raw.githubusercontent.com/SureA11y/core/main/docs/assets/brand-tag-dark.svg"><img alt="surea11y core" src="https://raw.githubusercontent.com/SureA11y/core/main/docs/assets/brand-tag-light.svg"></picture></a>
4
+ [![Test](https://github.com/SureA11y/core/actions/workflows/test.yml/badge.svg?branch=main)](https://github.com/SureA11y/core/actions/workflows/test.yml)
4
5
  [![npm](https://img.shields.io/npm/v/@surea11y/core?style=flat-square&label=npm&labelColor=101413&color=3A4441)](https://www.npmjs.com/package/@surea11y/core)
5
6
  [![node](https://img.shields.io/node/v/@surea11y/core?style=flat-square&label=node&labelColor=101413&color=3A4441)](package.json)
6
7
  [![license](https://img.shields.io/badge/license-MPL--2.0-3A4441?style=flat-square&labelColor=101413)](LICENSE)
@@ -41,6 +42,20 @@ falls on. Each rule makes a single deterministic decision:
41
42
  `cantTell` is the point of the project. An engine that quietly discards what it
42
43
  cannot determine produces a shorter report and a false sense of coverage.
43
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
+
44
59
  ## What this engine does not detect
45
60
 
46
61
  Keyboard traps, reflow and clipping at 400% zoom, anything that only exists
@@ -341,7 +356,8 @@ A simplified example looks like this:
341
356
  {
342
357
  "engine": {
343
358
  "tag": "a11ycore",
344
- "schemaVersion": "1.0.0"
359
+ "schemaVersion": "1.0.0",
360
+ "locale": { "requested": "en", "resolved": "en", "reason": "ok" }
345
361
  },
346
362
  "url": "https://example.com/",
347
363
  "checksResults": [
@@ -376,7 +392,7 @@ A simplified example looks like this:
376
392
  ```
377
393
 
378
394
  The second result illustrates the engine's conservative stance: it can
379
- confirm a link has text, but whether that text is genuinely descriptive
395
+ confirm a link has text, but whether that text is actually descriptive
380
396
  requires human judgement, so it reports `cantTell` instead of guessing.
381
397
 
382
398
  Each finding contains enough information to answer four questions:
@@ -406,6 +422,7 @@ and progressively explore more advanced features.
406
422
  | `docs/BASELINE.md` | CI baseline/allowlist: gate builds only on new violations. |
407
423
  | `docs/REPORT.md` | Self-contained HTML report: browsable summary, WCAG rollup, filterable occurrence table. |
408
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. |
409
426
  | `docs/CI_INTEGRATIONS.md` | GitHub Actions and Bitbucket Pipelines templates wrapping the CLI. |
410
427
  | `docs/ENGINE_OPTIONS.md` | Configuration, filtering, policies and localization. |
411
428
  | `docs/INTEGRATION.md` | Using surea11y with jsdom, Playwright, Puppeteer, Selenium, Cypress and other drivers. |
@@ -417,8 +434,14 @@ and progressively explore more advanced features.
417
434
  | `docs/LIMITATIONS.md` | Structural limitations of automated accessibility testing. |
418
435
  | `docs/TROUBLESHOOTING.md` | Frequently asked questions and common issues. |
419
436
  | `docs/RULE_AUTHORING.md` | Writing custom accessibility rules. |
437
+ | `docs/RULE_HELPERS.md` | Reference for every `ctx.helpers` function available to a rule. |
420
438
  | `docs/RULE_TAXONOMY.md` | Rule categorization model. |
439
+ | `docs/ACT_RULE_MAPPING.md` | Which ACT rules this engine implements, which it doesn't, and where the two differ by design. |
440
+ | `docs/DESIGN_CHALLENGES.md` | Open and settled design questions, each with the reasoning behind the call. |
441
+ | `docs/ARIA_DEPRECATION.md` | How deprecated ARIA roles and attributes are graded, and how to apply a later spec revision. |
421
442
  | `CONTRIBUTING.md` | Contributing guidelines. |
443
+ | `GOVERNANCE.md` | Who decides what, and the license commitment. |
444
+ | `SUPPORT.md` | Where to ask, and what response to expect. |
422
445
  | `SECURITY.md` | Security policy and vulnerability reporting. |
423
446
  | `CHANGELOG.md` | Release history. |
424
447
 
@@ -431,16 +454,13 @@ surea11y is built on a simple principle:
431
454
  > Automate what can be determined objectively. Never pretend to automate
432
455
  > what cannot.
433
456
 
434
- Accessibility is not something that can be reduced to a single score or
435
- a binary pass/fail result. Some WCAG requirements can be evaluated with
436
- complete confidence, while others require human judgement, knowledge of
437
- context or usability evaluation.
438
-
439
- Rather than hiding that distinction, surea11y makes it explicit.
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.
440
461
 
441
- That philosophy influences every rule in the engine and is the reason
442
- outcomes such as `cantTell` and `notApplicable` exist. They communicate
443
- 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.
444
464
 
445
465
  ### What surea11y won't catch
446
466
 
@@ -461,11 +481,6 @@ These are the cases where the engine reports `cantTell`, and where a
461
481
  human reviewer's judgement remains necessary. See
462
482
  `docs/LIMITATIONS.md` for the complete list of structural limitations.
463
483
 
464
- The objective of the project is not to replace accessibility experts. It
465
- is to remove repetitive verification work, provide reliable automated
466
- feedback to developers and help teams integrate accessibility into their
467
- normal development process.
468
-
469
484
  ---
470
485
 
471
486
  ## Project Structure
@@ -480,6 +495,10 @@ surea11y.i18n.<locale>.js # Generated per-locale side files for that bundle
480
495
  src/
481
496
  index.js # Public API
482
497
  core.js # Generated runtime bundle
498
+ baseline.js # Baseline entry point (@surea11y/core/baseline)
499
+ report.js # HTML report entry point (@surea11y/core/report)
500
+ sarif.js # SARIF entry point (@surea11y/core/sarif)
501
+ earl.js # EARL entry point (@surea11y/core/earl)
483
502
 
484
503
  checks/
485
504
  automatic/ # Deterministic automated rules
@@ -487,20 +506,31 @@ src/
487
506
 
488
507
  core/ # Shared engine runtime
489
508
  policy/ # Policy implementations
490
- i18n/ # Localized messages
509
+ i18n/ # Localized messages (JSON, one file per locale)
491
510
  coverage/ # WCAG coverage definitions
492
511
  catalogs/ # Composite rule catalogs
512
+ explain/ # Occurrence grouping, internal
493
513
 
494
514
  scripts/
495
- build-core.js # Generates the runtime bundle
515
+ build-core.js # Generates src/core.js
516
+ build-browser.js # Generates the browser bundle and its locale side files
517
+ generate-*.js # Generated docs and data tables (each supports --check)
518
+ validate-*.js # Rule module contract checks
519
+ i18n-*.js # Locale scaffolding, sync and coverage reporting
496
520
 
521
+ coverage/ # Generated WCAG facet coverage report
497
522
  docs/ # Project documentation
498
523
 
499
524
  tests/
500
- fixtures/ # Rule fixtures
501
- engine-checks/ # Engine and rule tests
525
+ fixtures/ # Rule fixtures, plus the generated fixture index
526
+ engine-checks/ # Per-rule tests
527
+ core/ helpers/ i18n/ # Engine internals, shared test helpers, locale tests
502
528
  ```
503
529
 
530
+ Everything under `src/` other than the entry points above is internal — see
531
+ [`docs/API_STABILITY.md`](./docs/API_STABILITY.md) for what the `exports` map
532
+ actually promises.
533
+
504
534
  This separation allows the engine to evolve independently from framework
505
535
  integrations while keeping the rule authoring experience consistent.
506
536
 
@@ -529,9 +559,7 @@ engine consistency to ensure deterministic results across releases.
529
559
 
530
560
  Contributions are welcome.
531
561
 
532
- Whether you are fixing a bug, improving documentation or implementing a
533
- new accessibility rule, please keep the project's core principles in
534
- mind:
562
+ Bug fix, documentation, or a new rule — the same principles apply:
535
563
 
536
564
  - deterministic behaviour;
537
565
  - objective rule evaluation;
@@ -582,24 +610,3 @@ This project is released under the Mozilla Public License 2.0 (MPL-2.0).
582
610
  See the accompanying `LICENSE` file for the complete license text.
583
611
 
584
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).
585
-
586
- ---
587
-
588
- ## Final Notes
589
-
590
- surea11y was created with a simple goal: make accessibility testing
591
- trustworthy enough to become part of everyday software engineering.
592
-
593
- It does not attempt to replace manual accessibility reviews, usability
594
- testing or expert judgement. Instead, it focuses on providing reliable
595
- automated verification for the parts of accessibility that can be
596
- evaluated objectively.
597
-
598
- By combining deterministic rules, standards traceability, stable
599
- machine-readable output and honest reporting of uncertainty, surea11y
600
- enables teams to detect accessibility issues earlier, reduce regressions
601
- and build more accessible products with confidence.
602
-
603
- Accessibility is not a checkbox performed before release. It is an
604
- engineering practice that benefits from continuous feedback, and
605
- surea11y is designed to become one of those feedback loops.
@@ -0,0 +1,245 @@
1
+ # ACT rule mapping
2
+
3
+ Cross-reference between the [W3C ACT Rules](https://act-rules.github.io/rules/) (as published at act-rules.github.io) and this repo's rule catalog (`docs/RULE_CATALOG.md`). Built by matching rule names/descriptions and WCAG SC, not machine-generated, so treat close calls as a starting point for review rather than ground truth.
4
+
5
+ **Summary (117 active ACT rules, excludes 3 deprecated):**
6
+ - **58 confirmed direct, family, or partial matches** in our automatic/manual rules (`scripts/data/act-rule-map.json` is the machine-readable version of the table below)
7
+ - **~2** are covered structurally by our composite/rollup layer, not a named rule
8
+ - **~46 are gaps**, no corresponding rule in this repo, listed in [Gaps](#gaps-no-corresponding-rule) below
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, 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
+
12
+ Real rule bugs found and fixed this way, in rough chronological order:
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.
14
+ - `link-name-quality` was scoped to `a[href]` only; widened to `a[href], area[href], [role="link"]` to match ACT's "any semantic link" applicability (safe here, its logic is name-text-only, no destination resolution).
15
+ - `contrast-minimum`/`contrast-enhanced` (shared `isLargeText` helper): a hardcoded `18.6667px` bold-large-text threshold was a floating-point hair above the true value of `14pt` converted to px, so text sized exactly at the boundary via `pt` units (the common real-world case) silently fell through to the stricter normal-text ratio. Fixed by deriving the threshold from the same `parsePx()` conversion instead of a decimal literal.
16
+ - `contrast-minimum`/`contrast-enhanced` (shared `isInactiveUiComponent` helper): the WCAG 1.4.3/1.4.6 "inactive UI component" exception only walked the text's own ancestor chain for `:disabled`/`aria-disabled`, missing the case where the low-contrast text is a `<label>` (native association or `aria-labelledby`-referenced) for a *sibling* disabled widget rather than a descendant of one.
17
+ - `aria-required-parent`: a roleless ancestor carrying a global ARIA attribute (e.g. `aria-live`) is still included in the accessibility tree, so it should block the required-context-role search the same way a real role would; it was being treated as transparent instead.
18
+ - `dom-helpers.js`'s `inClosedDetailsContent()`: a closed `<details>` element was judging its own accessibility-tree eligibility against its own `open` state (`closest('details')` matches the node itself), hiding the `<details>`/`<summary>` toggle along with the content it's supposed to keep hiding.
19
+ - `img-alt-present`: `alt=" "` (whitespace-only) was treated the same as `alt=""` (the real decorative marker). Per HTML-AAM the img-to-presentation role flip only fires on the literal empty string, so a whitespace-only alt keeps the `img` role with an empty computed name, a real failure.
20
+ - `role-img-text-alternative-present` (then named `role-img-alt-present`): an inline SVG's own `<title>` child element (the standard SVG-AAM naming mechanism) wasn't recognized as a name source, separate from the HTML `title` attribute.
21
+ - `table-headers-attr-valid`: cells inside a `role="presentation"`/`"none"` table are out of scope entirely, and a referenced header can be any cell (`td` or `th`) of the same table, not only a `th`.
22
+ - `dom-helpers.js`'s offscreen heuristic: `em`/`rem` values (e.g. `top: -999em`) were compared against the `-5000` px threshold as a bare number, so they never registered as offscreen.
23
+ - `getAccessibleNameInfo` never consulted the `alt` attribute for `img`/`area`/`input[type=image]`, so e.g. an `<area>` named only by `alt` had no computed name anywhere outside the img-specific rules.
24
+ - `getContentNameInfo`: a `role="presentation"`/`"none"` image-like descendant was still contributing its `alt` text to an ancestor's content name, even though `alt` doesn't trigger presentational-roles conflict resolution.
25
+ - `identical-links-same-purpose`: fell back to raw `el.textContent` instead of the content-aware name helper, so a link named only by a descendant image's `alt` (no text nodes at all) was silently skipped. Also fixed SVG `<a>`'s `.href`, which is an `SVGAnimatedString` rather than a plain string, so the destination comparison was reading a useless stringified wrapper for every SVG link.
26
+ - `meta-refresh-timing-absent` / `meta-refresh-no-exceptions`: only the first valid meta refresh in a document is ever acted on by a browser; both rules were evaluating every matching `<meta>` tag independently. `meta-refresh-no-exceptions`'s own header comment also claimed AAA drops the zero-delay exception; it doesn't, since an immediate redirect isn't a timed interruption at any level.
27
+ - `table-th-has-data-cells`: extended the existing "`<th>` with zero `<td>` anywhere" check to its ARIA `role="grid"`/`"treegrid"` equivalent.
28
+ - `empty-heading`: a heading whose only content is a `role="presentation"` image no longer gets that image's `alt` text as its name; a native heading tag marked `role="none"`/`"presentation"` but carrying a global ARIA attribute (even an empty one) is still evaluated as a heading, per conflict resolution.
29
+ - `aria-required-attr`: an explicit role identical to an element's own native role is now exempt (e.g. `<input type="checkbox" role="checkbox">` needs no `aria-checked`); `role="combobox"` now requires `aria-controls` once `aria-expanded="true"`.
30
+ - `aria-allowed-attr` had no answer for an element HTML-AAM maps to no ARIA role at all: `<audio controls aria-orientation="horizontal">` (ACT `5c01ea`'s own failed example) was skipped, because an empty implicit-role lookup was indistinguishable from "a role this table does not model." A generated `ROLELESS_ELEMENTS` set makes the absence itself the answer. `<div>`/`<span>` also joined the context-free table as `generic`, so a role-specific attribute on a bare div is now reported rather than passed over.
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
+ - `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
+ - `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.
35
+
36
+ Mapping-table corrections found this way (data-only, no rule-code change):
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.
38
+ - `qt1vmo` and `23a2a8` were missing existing sibling rules from their `ourRuleIds` family (`canvas-text-alternative-quality`/`svg-text-alternative-quality`, and `role-img-text-alternative-present`, respectively); the code to catch these cases already existed, just wasn't wired into the mapping.
39
+ - `bf051a` was mapped to `valid-lang` (which skips the `<html>` element by design) instead of `html-lang-attr-present`, which actually validates it.
40
+ - `b40fd1` was mapped to `region` (an unrelated best-practice check) instead of `bypass-blocks-present`, which already implements this exact WCAG 2.4.1 technique alongside its `cf77f2`/`ye5d6e`/`047fe0` siblings.
41
+ - `oj04fd` was mapped to `css-hidden-focus` on a surface keyword match ("focus," "visible"); the two check unrelated things (element visibility while focused vs. whether a focus indicator is suppressed by CSS). Removed from the matched table; see the Gaps section, where it's also flagged as a plausible new-rule candidate.
42
+ - `cc0f0a` and `c4a8a4` were mapped to `form-control-programmatic-label-quality` and `page-title-patterns`, both of which only catch weak-primary-mechanism/generic-pattern cases, never real label-text-vs-field or title-vs-content relevance, un-covered by either rule. Moved both ACT ids to Gaps and both of our rules to [Extra coverage](#extra-coverage-beyond-act) (they remain valid, independent checks with no ACT counterpart of their own).
43
+
44
+ Gaps closed since:
45
+ - `307n5z` "Element with presentational children has no focusable content" is now implemented by the new `presentational-children-focusable-absent` rule. The gap entry it replaces described the wrong mechanism; it read the rule as being about an explicit `role="presentation"`/`"none"` attribute, which is the exact misreading ACT's own Background section warns against. The rule is about the *implicit* presentational-children trait a role carries (`button`, `checkbox`, `img`, `option`, `tab`, ...): those roles drop their whole subtree from the accessibility tree, so a descendant that still takes a tab stop receives focus with no role and no name. Clean against all 11 of ACT's examples.
46
+ - `46ca7f` "Element marked as decorative is not exposed" needed no new rule at all: `presentation-role-conflict` already implements it, end to end, and was simply never mapped, the gap entry was a mapping miss, same class as the `qt1vmo`/`23a2a8` misses above. It runs clean against all 10 of ACT's examples, and the one scope difference the corpus exposed (an `<img alt="">` carrying an explicit role of its own is not "marked as decorative," since the explicit role wins over the presentation role empty alt would confer) is now fixed in the rule.
47
+ - `b49b2e` "Heading is descriptive" is now half-covered by the new `heading-quality` rule: a heading whose accessible name is a placeholder, a generic word ("Heading", "Untitled"), a numbered template slot ("Section 2"), a filename, or a URL, cannot describe anything, and that much is deterministic. Whether a well-formed heading is *about* the content after it is not, so the rule is `cantTell`-capped and `b49b2e` is mapped `partial`. Clean on all 6 passed and both inapplicable examples (no false positives); the 5 failed examples (grown from 4 as the live corpus gained a case since this was last checked) are all the topic-relevance shape.
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`.
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.
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.
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.
52
+
53
+ ### Second pass: the corpus read from a local checkout
54
+
55
+ act-rules.github.io is unreachable from the environment this pass ran in, so the examples were read straight out of a local checkout of the `act-rules/act-rules.github.io` repository (`_rules/*.md`) instead of the generated per-case pages `scripts/act-testcase-check.js` fetches. Same corpus, different entry point: running the manifest's existing entries through it reproduces the published per-rule results (`97a4e1` clean, `6cfa84` clean, `d0f69e` 3 mismatches, ...), which is what makes the new results trustworthy.
56
+
57
+ It carries 821 examples against the 713 test-case pages the first pass fetched. The gap is not explained from here, the published pages can't be diffed against without network access, so everything it surfaced beyond the 49 already-triaged mismatches was read on its own merits rather than assumed to be a regression. What it found, in four groups:
58
+
59
+ Real rule bugs, fixed:
60
+ - `table-headers-attr-valid` only took a table out of scope for `role="presentation"`/`"none"`. Any explicit role replaces the native table semantics, so `<table role="heading">` has no table for a cell's `headers` attribute to describe either; the applicability now keeps only `table`/`grid`/`treegrid`, matching ACT a25f45.
61
+ - `aria-required-attr` never required `aria-valuenow` on a `separator`. A plain separator is a structural divider that needs no value, but a focusable one is a splitter the user can move, and WAI-ARIA requires the value then, the same conditional shape as `combobox`'s `aria-controls`, which the rule already handles.
62
+ - `iframe-name-present` demanded a name from an iframe the author had marked decorative with `role="none"`/`"presentation"`. ACT cae760 excludes those outright, and the contradiction between "decorative" and a restored role is `presentation-role-conflict`'s report, not this rule's.
63
+
64
+ Mapping fix (data-only): `e086e5`'s family was missing `binary-control-name-present` and `menuitem-name-present`, so ACT's `checkbox`/`radio`/`switch`/`menuitemcheckbox`/`menuitemradio` cases looked uncovered when the rules for them already existed, the same shape as the `qt1vmo`/`23a2a8` misses above.
65
+
66
+ Re-checked against the live rule page (not just the local checkout) and settled, all in `docs/DESIGN_CHALLENGES.md`'s Decided section:
67
+ - `aria-required-children`/`aria-prohibited-children` only evaluating containers with an explicit `role` isn't a gap; ACT `bc4a75`'s own Applicability text requires one, and its Inapplicable Example 2 is a bare `<ul><li>`.
68
+ - `aria-prohibited-children` was a real bug: it only treated `role="group"`/`role="rowgroup"` as transparent when the container's own required-owned set named `group`/`rowgroup`, so `role="list"` wrapping valid `listitem`s in a `role="group"` was wrongly flagged. Group/rowgroup are transparent unconditionally. `bc4a75` now runs clean.
69
+ - `label-in-name` had no exemption for "non-text content" characters; ACT `2ee8b8`'s own failed examples for us, `<button aria-label="close">X</button>` and a Material-Icons-font-remapped `search`->magnifying-glass button, are both text standing in for an icon rather than literal words. The live corpus's actual 13 examples don't exercise the separately-claimed `aria-hidden`/visually-hidden/inline-concatenation divergence at all (unsubstantiated against current ground truth, likely a local-checkout artifact same as the `bc4a75` case); that narrower part stays open in `docs/DESIGN_CHALLENGES.md` on its own merits, not as an ACT mismatch. `2ee8b8` now runs clean.
70
+
71
+ The `afw4f7`/`09o5cg` same-color note and the `8fc3b6` "data URL" note that used to sit here are both gone: the former was a misreading (see `docs/DESIGN_CHALLENGES.md`'s Decided section) and is fixed, and the latter no longer reproduces against the live corpus (re-verified 2026-08-19, 0/14), corpus drift, not a code change on our side.
72
+
73
+ We also have automatic rules with **no ACT counterpart at all** (see [Extra coverage](#extra-coverage-beyond-act)), mostly a finer-grained decomposition of ACT's single "form field has accessible name" rule into one rule per ARIA widget role.
74
+
75
+ ### Progress: full validation results, by ACT rule
76
+
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).
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:
82
+
83
+ | ACT ID | Mismatches | Category |
84
+ |---|---|---|
85
+ | `ff89c9` | 1 | env/harness limit: jsdom doesn't execute inline `<script>`, so a runtime-created shadow root is invisible to the test fetcher (not the real engine, which runs after page scripts) |
86
+ | `aaa1bf` | 1 | different question, not a gap in ours: `aaa1bf`'s own applicability/expectation is purely about clip *duration* ("does the audio stay under 3s," explicitly not exempted by a `controls` mechanism); `no-autoplay-audio` answers WCAG 1.4.2's other disjunct instead (does a pause/stop/volume mechanism exist). Duration isn't knowable from static markup regardless, no browser decodes media at scan time, so this mismatch can't close even in principle, not because our rule falls short of it |
87
+ | `ye5d6e` | 1 | scoped leniency: whether repeated-boilerplate content wraps the skip target is a cross-page judgment undecidable from one document; the rule's own header comment already reasons through this trade-off |
88
+ | `047fe0` | 2 | one of each: scoped leniency (a heading sitting inside the repeated `<nav>` block itself, same cross-page judgment as `ye5d6e` above), and an env/harness limit (a heading positioned off-screen via a `<link>`ed external stylesheet the test fetcher doesn't load, same class as `oj04fd` below; `tests/engine-checks/manual/bypass-blocks-present.test.js` pins the real, fixed behavior with the same CSS inlined) |
89
+ | `e086e5` | 2 | accepted divergence, decided 2026-08-19, see `docs/DESIGN_CHALLENGES.md`'s "Decided" section: `<label for>`/wrapping association stays honoured on non-natively-labelable ARIA widgets, because a screen reader that announces such a label makes "no accessible name" a false positive. Real AT behaviour wins over the spec reading here |
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 |
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 |
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) |
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 |
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 |
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) |
96
+ | `09o5cg`, `afw4f7` | 4, 4 | environment-dependent: gradient/image backgrounds not yet decomposed into solid contributing colors, and jsdom not executing inline `<script>` (same class as `ff89c9`, not really about `includeShadowDom`); the `text-shadow`-as-contrast-aid case is fixed (`contrast-computable` now reports `cantTell` for it, see `docs/LIMITATIONS.md` for the jsdom double-read bug this uncovered) |
97
+ | `f51b46` | 1 | inherent limitation: `video-caption`'s own header comment states it can't verify a caption track's *content* accuracy, only that one is declared, a human-judgment task |
98
+
99
+ ## Matched rules
100
+
101
+ | ACT ID | ACT rule name | Our rule(s) | Match |
102
+ |---|---|---|---|
103
+ | `5f99a7` | ARIA attribute is defined in WAI-ARIA | `aria-valid-attr` | exact |
104
+ | `ff89c9` | ARIA required context role | `aria-required-parent` | exact |
105
+ | `bc4a75` | ARIA required owned elements | `aria-required-children`, `aria-prohibited-children` | family (we split by decision) |
106
+ | `6a7281` | ARIA state or property has valid value | `aria-valid-attr-value` | exact |
107
+ | `5c01ea` | ARIA state or property is permitted | `aria-allowed-attr` | exact |
108
+ | `80f0bf` | Audio/video avoids autoplaying audio | `no-autoplay-audio` (manual) | family |
109
+ | `4c31df` | Autoplaying audio/video has a control mechanism | `no-autoplay-audio` (manual) | family |
110
+ | `aaa1bf` | Autoplaying audio/video has no audio > 3s | `no-autoplay-audio` (manual) | family |
111
+ | `73f2c2` | Autocomplete attribute has valid value | `autocomplete-valid` | exact |
112
+ | `97a4e1` | Button has non-empty accessible name | `button-name-present` | exact |
113
+ | `cf77f2` | Bypass Blocks of Repeated Content | `bypass-blocks-present` (manual) | exact |
114
+ | `ye5d6e` | Instrument to move focus to non-repeated content | `bypass-blocks-present` (manual) | family |
115
+ | `047fe0` | Document has heading for non-repeated content | `bypass-blocks-present` (manual) | family |
116
+ | `b40fd1` | Document has a landmark with non-repeated content | `bypass-blocks-present` (manual) | family |
117
+ | `46ca7f` | Element marked as decorative is not exposed | `presentation-role-conflict` (manual) | exact |
118
+ | `oj04fd` | Element in sequential focus order has visible focus | `css-focus-indicator-suppressed` (manual) | partial |
119
+ | `6cfa84` | Element with aria-hidden has no content in sequential focus nav | `aria-hidden-focus` | exact |
120
+ | `de46e4` | Element with lang attribute has valid language tag | `valid-lang` | exact |
121
+ | `307n5z` | Element with presentational children has no focusable content | `presentational-children-focusable-absent` | exact |
122
+ | `4e8ab6` | Element with role attribute has required states/properties | `aria-required-attr` | exact |
123
+ | `e086e5` | Form field has non-empty accessible name | `form-control-programmatic-label-present`, `textbox-name-present`, `combobox-name-present`, `listbox-name-present`, `searchbox-name-present`, `slider-name-present`, `spinbutton-name-present` | family (we split by widget role) |
124
+ | `cc0f0a` | Form field label is descriptive | `form-control-label-quality` (manual) | partial |
125
+ | `a25f45` | Headers attribute refers to cells in same table | `table-headers-attr-valid` | exact |
126
+ | `ffd0e9` | Heading has non-empty accessible name | `empty-heading` (manual) | family |
127
+ | `b49b2e` | Heading is descriptive | `heading-quality` (manual) | partial |
128
+ | `b5c3f8` | HTML page has lang attribute | `html-lang-attr-present` | exact |
129
+ | `2779a5` | HTML page has non-empty title | `page-title-present` | exact |
130
+ | `5b7ae0` | HTML page lang/xml:lang attributes match | `html-xml-lang-mismatch` | exact |
131
+ | `bf051a` | HTML page lang attribute has valid language tag | `html-lang-attr-present` | exact |
132
+ | `3ea0c8` | Id attribute value is unique | `duplicate-id` | exact |
133
+ | `cae760` | Iframe element has non-empty accessible name | `iframe-name-present` | exact |
134
+ | `akn7bn` | Iframe with negative tabindex has no interactive content | `iframe-focusable-content` | exact |
135
+ | `qt1vmo` | Image accessible name is descriptive | `img-alt-quality`, `canvas-text-alternative-quality`, `svg-text-alternative-quality` (all manual) | family |
136
+ | `59796f` | Image button has non-empty accessible name | `input-image-alt-present` | exact |
137
+ | `23a2a8` | Image has non-empty accessible name | `img-alt-present`, `role-img-text-alternative-present` | family |
138
+ | `e88epe` | Image not in the accessibility tree is decorative | `img-alt-decorative` (manual) | exact |
139
+ | `24afc2` | Letter spacing in style attrs not `!important` | `avoid-inline-spacing` | exact |
140
+ | `78fd32` | Line height in style attrs not `!important` | `avoid-inline-spacing` | exact (combined rule) |
141
+ | `9e45ec` | Word spacing in style attrs not `!important` | `avoid-inline-spacing` | exact (combined rule) |
142
+ | `c487ae` | Link has non-empty accessible name | `link-name-present` | exact |
143
+ | `aizyf1` | Link is descriptive | `link-name-quality` (manual) | exact |
144
+ | `5effbb` | Link in context is descriptive | `link-name-quality` (manual) | partial |
145
+ | `fd3a94` | Links with identical names + same context, equivalent purpose | `identical-links-same-purpose` (manual) | exact |
146
+ | `b20e66` | Links with identical accessible names, equivalent purpose | `identical-links-same-purpose` (manual) | exact |
147
+ | `m6b1q3` | Menuitem has non-empty accessible name | `menuitem-name-present` | exact |
148
+ | `bc659a` | Meta element has no refresh delay | `meta-refresh-timing-absent` | exact |
149
+ | `bisz58` | Meta element has no refresh delay (no exception) | `meta-refresh-no-exceptions` | exact |
150
+ | `b4f0c3` | Meta viewport allows for zoom | `meta-viewport-zoom-enabled` | exact |
151
+ | `8fc3b6` | Object element rendering non-text content has accessible name | `object-text-alternative-present` | exact |
152
+ | `b33eff` | Orientation not restricted via CSS transform | `css-orientation-lock` | exact |
153
+ | `674b10` | Role attribute has valid value | `aria-roles-valid` | exact |
154
+ | `0ssw9k` | Scrollable element is keyboard accessible | `scrollable-region-focusable` (manual) | exact |
155
+ | `7d6734` | SVG element with explicit role has accessible name | `svg-text-alternative-present`, `role-img-text-alternative-present` | family |
156
+ | `d0f69e` | Table header cell has assigned cells | `table-th-has-data-cells` | exact |
157
+ | `09o5cg` | Text has enhanced contrast | `contrast-enhanced` | exact |
158
+ | `afw4f7` | Text has minimum contrast | `contrast-minimum` | exact |
159
+ | `f51b46` | Video auditory content has captions | `video-caption` (manual) | partial |
160
+ | `2ee8b8` | Visible label is part of accessible name | `label-in-name` | exact |
161
+
162
+ Structural (not a named rule, but the check exists via a different mechanism):
163
+ - `off6ek` / `ucwvc8` (language subtag matches page/default language): partially overlaps `html-xml-lang-mismatch` + `valid-lang` but not a full match.
164
+
165
+ ## Gaps (no corresponding rule)
166
+
167
+ Grouped by theme, with WCAG SC where ACT declares one:
168
+
169
+ **Audio/video alternatives (1.2.x)**, largest gap cluster, 12 ACT rules: `1a02b0`, `e7aa44`, `2eb176`, `afb423`, `eac66b`, `ab4d13`, `c5a4ea`, `1ea59c`, `1ec09b`, `c3232f`, `d7ba54`, `ee13b5`, `fd26cf`. We only have `video-caption` and `media-alternative-transcript-evidence` (both manual/low-confidence); full media-alternative testing (transcripts, audio description, sign language equivalence) is unimplemented.
170
+
171
+ **Keyboard trap (2.1.2)**, 3 rules: `80af7b`, `ebe86a`, `a1b64e`. No automated keyboard-trap detection at all today; see [Keyboard trap detection: scoping notes](#keyboard-trap-detection-scoping-notes) below.
172
+
173
+ **Sensory/visual gaps:**
174
+ - `9bd38c` Content has alternative for visual reference (1.3.3, sensory characteristics)
175
+ - `0va7u6` HTML graphics contain no text (1.4.5, images of text)
176
+ - `59br37` Zoomed text node not clipped by CSS overflow (1.4.10, reflow)
177
+ - `36b590` Error message describes invalid form field value (3.3.1)
178
+ - `c4a8a4` HTML page title is descriptive (2.4.2): `page-title-patterns` only catches generic/templated title *patterns*, never whether a plausible-looking title actually matches the page's content; see the judgment-call note below
179
+
180
+ **Motion/input (2.5.4, 2.1.4):**
181
+ - `7677a9` / `c249d5` Device motion actuation has UI alternative / can be disabled
182
+ - `ffbc54` No keyboard shortcut uses only printable characters
183
+
184
+ **Structural/HTML validity:**
185
+ - `e6952f` Attribute is not duplicated (raw HTML parsing-level check)
186
+ - `efbfc7` Auto-updating text content can be paused/stopped/hidden (2.2.2, beyond meta-refresh)
187
+ - `3e12e1` Block of repeated content is collapsible
188
+
189
+ **Judgment-call gaps found during test-case validation:**
190
+ - `4b1c6c` "Iframes with identical accessible names have equivalent purpose" was originally mapped to `iframe-title-unique`, but running ACT's own test cases against it exposed that they test different things: ACT's rule accepts a duplicate name when the two iframes point to equivalent content (same resource, mirrors, equivalent ads/sections) and only fails when duplicate-named iframes point to genuinely different content, a content-equivalence judgment call, the same class of check as our existing manual `identical-links-same-purpose`. `iframe-title-unique` instead flags *any* duplicate `title` attribute outright, by design (see its own header comment), a stricter, different, independently-valid check with no ACT counterpart of its own. Moved to "Extra coverage" below; `4b1c6c` itself stays a gap, closing it for real would mean a new manual `identical-iframes-same-purpose`-style rule, not a fix to `iframe-title-unique`.
191
+ - `5effbb` "Link in context is descriptive" was originally mapped to `link-in-text-block` on a name-similarity guess ("link" + "context/text"); its real applicability/expectation (fetched directly from act-rules.github.io) is "the accessible name together with its programmatically determined link context describes the purpose of the link," WCAG 2.4.4, the *context-aware* sibling of `aizyf1`/`link-name-quality`, unrelated to `link-in-text-block`'s WCAG 1.4.1 color-distinguishability check (which is itself correctly scoped to `a[href]` only, per its own header comment, not a bug). This was later closed by teaching `link-name-quality` to weigh adjacent context; see "Gaps closed since" below.
192
+ - `oj04fd` "Element in sequential focus order has visible focus" was originally mapped to `css-hidden-focus` on a surface keyword match ("focus," "visible"); its real applicability/expectation is about whether a browser draws *any* visible focus indicator for a normally-visible, normally-positioned element (i.e. `:focus`/`:focus-visible` CSS suppressing the outline with no replacement), a completely different concern from `css-hidden-focus`'s actual check (a keyboard-focusable element that is itself visually hidden by CSS, e.g. `opacity:0`/clip/off-screen). Removed from the matched table and moved to Gaps; a plausible new-rule candidate, not a fix to `css-hidden-focus`, and built as one since, `css-focus-indicator-suppressed`, which is what `oj04fd` maps to now.
193
+ - `cc0f0a` "Form field label is descriptive" and `c4a8a4` "HTML page title is descriptive" were both mapped to rules that only catch a narrower, adjacent concern: `form-control-programmatic-label-quality` flags a *weak primary labeling mechanism* (title/placeholder used instead of a real label), never whether a properly-associated label's own text is relevant to the field; `page-title-patterns` flags generic/templated title *patterns* (e.g. "Home", "Untitled"), never whether a specific, plausible-looking title actually matches the page's content (ACT's own failed example: `<title>Apple harvesting season</title>` on a page about clementines, a real title/content mismatch neither pattern-list nor mechanism-check was ever designed to catch). Both ACT ids moved to Gaps; both of our rules moved to [Extra coverage](#extra-coverage-beyond-act) as valid, independent, narrower checks with no ACT counterpart of their own. `cc0f0a` has since been picked up by a new rule of its own, `form-control-label-quality`; `c4a8a4` remains a gap.
194
+ - `d0f69e` "Table header cell has assigned cells" is a confirmed correct match, but `table-th-has-data-cells`'s own header comment already documents its scope as limited to the single unambiguous case (a table/ARIA-grid with header cells but *zero* data cells anywhere) rather than the full HTML5 header-association algorithm, 3 of ACT's test cases specifically probe the excluded "some particular header doesn't actually describe any cell, even though the table has data cells elsewhere" shape (both for native `<table>` and the ARIA `role="grid"` equivalent added during this validation pass), which stays an accepted, documented false negative rather than a bug.
195
+
196
+ ### Gaps ranked for automatability
197
+
198
+ The goal is maximum automation, not just parity with ACT's own scope; several gaps above are worth turning into new rules even though they were never going to be "quick" ACT-mapping fixes. Ranked by confidence that a deterministic (or manual/`cantTell`-heuristic) DOM check can actually catch them:
199
+
200
+ **High confidence, new automatic rule, deterministic DOM check, both now closed, see the notes under "Matched rules":**
201
+ - ~~`307n5z` Element with presentational children has no focusable content~~: closed by the new `presentational-children-focusable-absent` rule.
202
+ - ~~`46ca7f` Element marked as decorative is not exposed~~: no new rule needed; the existing `presentation-role-conflict` already implements it (mapping miss, not a coverage gap).
203
+
204
+ **Medium confidence, new manual/`cantTell` rule (same pattern as existing quality checks):**
205
+ - ~~`b49b2e` Heading is descriptive~~: closed by the new `heading-quality` rule, modelled on `link-name-quality`'s curated-phrase heuristic. It reaches the placeholder half only (see the mismatch table above), so `b49b2e` is a partial match rather than an exact one. One thing the original plan here got wrong: it proposed flagging single characters, and ACT's own passed example is `<h1>A</h1>` above a glossary; length carries no signal for headings, unlike page titles.
206
+ - ~~`5effbb` Link in context is descriptive~~: closed by teaching `link-name-quality` to weigh adjacent context: an `aria-describedby` target, the direct text of the enclosing list item/table cell/paragraph, or (for a new bare-format-name phrase list only, e.g. "HTML"/"EPUB") a table's first-row header. A phrase-list match with adequate context found nearby is no longer flagged. Runs clean against 17 of ACT's own 18 examples; the one remaining case (`<a>Workshop</a>` after an unrelated paragraph) needs to judge whether an ordinary word actually relates to nearby prose, a step beyond what a phrase list or context-structure check can do.
207
+ - ~~`oj04fd` Focus indicator suppressed via CSS~~: closed by the new `css-focus-indicator-suppressed` rule. The CSSOM parsing turned out to be the easy half; what needed the care was deciding which rule a suppression belongs to (only the selector's subject, so `.card:focus .link` is not `.link`'s own suppression) and crediting a replacement drawn anywhere the focused element causes it, its own rule, a pseudo-element, a sibling.
208
+ - ~~`cc0f0a` Form field label is descriptive~~: closed by the new `form-control-label-quality` rule, sitting alongside `form-control-programmatic-label-quality` rather than replacing it (one judges the label's text, the other the labelling mechanism). The curated placeholder list turned out to be the weakest of its three signals; the duplicate-label and split-label checks are what actually catch ACT's own failed examples.
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.
210
+
211
+ **Design decision needed before building (not purely a confidence question):**
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`.
213
+
214
+ **Lower confidence, needs a different technique than the rest of the engine:**
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).
216
+ - `efbfc7` Auto-updating content can be paused/stopped/hidden, `7677a9`/`c249d5` device-motion actuation: both require observing *behavior over time* (a MutationObserver window, or JS event-listener presence), not a markup snapshot. Could become a low-confidence manual heuristic (e.g. flag `<marquee>`, `role="marquee"`/`role="timer"` without a visible pause control) but real coverage is inherently limited.
217
+ - `ffbc54` No single-printable-character keyboard shortcut: HTML `accesskey` values are statically visible, but most JS-implemented character-key shortcuts (the actual common real-world case) live in event-handler logic this engine doesn't execute or trace.
218
+ - Audio/video alternatives (1.2.x, 12 rules), `9bd38c`, `0va7u6`, `3e12e1`: these require judging semantic *accuracy* (does the transcript match the audio, is the image text redundant, is a "read more" toggle actually collapsible) that's beyond markup-level heuristics; stay manual/out of scope for now.
219
+
220
+ ## Keyboard trap detection: scoping notes
221
+
222
+ `80af7b`/`ebe86a`/`a1b64e` (SC 2.1.2) all ask the same underlying question: can focus reach every element and cycle back out to the browser chrome using standard Tab/Shift+Tab, and if a widget intentionally intercepts that (a modal, a canvas-based editor), does it announce and honor an escape mechanism. None of that is decidable from a single read of the DOM/CSSOM, which is what every rule in this engine does today (`runInPage(ctx)` runs once, synchronously, and never mutates the page it's inspecting). Answering it needs to actually drive focus around the page and watch what happens, a different kind of check than anything built so far.
223
+
224
+ **What the technique would look like.** jsdom's `.focus()`/`.blur()` already fire real, synchronous `blur`/`focus`/`focusin`/`focusout` events per spec; what's missing is the browser's own behavior of *advancing* focus on Tab in the first place, which jsdom never implements. A simulator would need to: (1) compute the document's own sequential focus order (reusing the existing focusability/tabindex helpers), (2) for each focusable element, focus it and dispatch a synthetic `keydown` (`key: 'Tab'`) at it, (3) if nothing calls `preventDefault()`, apply the browser's own default itself by focusing the next element in that order, then check where focus actually landed, a bounce-back trap (ACT's own failed example: an `onblur` handler that refocuses itself) shows up right there, synchronously, because the refocus happens inside the same call stack as step 3's `.focus()`. A widget that *does* call `preventDefault()` (a custom focus-trap library) would need a second pass trying whatever escape combination its own visible/accessible help text claims (e.g. Ctrl+M) and checking again.
225
+
226
+ **Why this is a different risk class, not just more work:**
227
+ - **It's the first mutating check in this engine.** Every other rule is read-only against whatever DOM it's handed; this one would need to move real focus and dispatch real events on the live page being scanned, then restore the original focus state afterward. A `blur`/`focus`/`keydown` handler on a real production page can have side effects well beyond focus management (analytics, animations, state changes) that a scan has never before been able to trigger.
228
+ - **Cost scales with the page.** Every other check here is a handful of DOM queries; this one is inherently O(number of focusable elements) live focus/event cycles, expensive on a large real page, not something to run by default in every scan the way `runOnly`-less calls do today.
229
+ - **A new jsdom-vs-real-browser fidelity gap.** Several already-fixed mismatches in this file turned out to be simulation quirks, not real bugs (`css-orientation-lock`'s tolerance window, `iframe-focusable-content`'s `srcdoc` handling). A hand-rolled Tab-order simulator is exactly the kind of thing likely to diverge from what a real browser does at the edges (shadow DOM, iframes, `contenteditable`, native `<video>`/`<audio>` controls), and there's no ACT-corpus-style ground truth to validate the simulator itself against beyond the 3 rules' own small example sets.
230
+ - **"Cycle to the browser UI,"** the last leg of `a1b64e`/`80af7b`, is genuinely outside the DOM: it means focus leaving the document into the browser chrome (address bar, tab strip), which no DOM-level tool, including a real headless-browser automation layer, can directly observe from inside the page. The best a simulator can do is confirm the *last* forward tab stop and *first* backward tab stop don't refuse to yield focus, not that a real browser chrome would actually receive it next.
231
+
232
+ **Recommendation, not yet built:** if this goes ahead, it belongs behind an explicit opt-in (default off) rather than folded into a normal scan, something like a new rule `type` distinct from `automatic`/`manual` (both currently synchronous and non-mutating by contract), gated by an `engineOptions` flag a caller sets on purpose, documented as changing live focus state during the scan. That's a call for whoever owns this engine's safety contract with its integrators, not something to land quietly inside the existing rule set.
233
+
234
+ ## Extra coverage beyond ACT
235
+
236
+ Rules in this repo with no ACT counterpart, mostly finer decomposition of ACT's single `e086e5` "form field has accessible name" rule into one rule per ARIA widget type, plus some ARIA-validity and structural rules ACT doesn't break out separately:
237
+
238
+ `aria-hidden-body`, `aria-braille-equivalent`, `aria-conditional-attr`, `aria-deprecated-role`, `aria-prohibited-attr`, `aria-prohibited-children`, `aria-role-name-present`, `binary-control-name-present`, `canvas-text-alternative-present`, `combobox-name-present`, `contrast-computable`, `definition-list-children-valid`, `deprecated-elements-not-used`, `dialog-name-present`, `dlitem-parent-valid`, `duplicate-id-aria`, `embed-text-alternative-present`, `form-control-programmatic-label-quality`, `form-control-single-label`, `iframe-title-unique`, `list-children-valid`, `listbox-name-present`, `listitem-parent-valid`, `meter-name-present`, `nested-interactive-controls-absent`, `option-name-present`, `page-title-patterns`, `progressbar-name-present`, `searchbox-name-present`, `server-side-image-map-absent`, `slider-name-present`, `spinbutton-name-present`, `summary-name-present`, `svg-image-text-alternative-present`, `tab-name-present`, `target-size-minimum`, `td-has-header`, `textbox-name-present`, `tooltip-name-present`, `treeitem-name-present`, `video-poster-text-alternative-present`, `area-alt-present`.
239
+
240
+ ## Next steps
241
+
242
+ 1. **Validate matched rules against ACT's official test cases**: done for the full matched set (see "Progress" above); re-run `scripts/act-testcase-check.js` after any future change to a matched rule to catch regressions.
243
+ 2. **Resolve the open design questions in `docs/DESIGN_CHALLENGES.md`**: these are the highest-value remaining item, each is a confirmed, understood defect or scope gap in an already-matched rule, just deferred because the fix is a real behavior change (not a quick patch) that deserves a careful look and fixture coverage before landing.
244
+ 3. **Build the highest-confidence automatable gaps**: `307n5z` and `46ca7f` are done (see above). `3ea0c8` (page-wide unique id) is done as well, once the WCAG-version-tagging question it was blocked on was decided. `b49b2e` (heading quality), `oj04fd` (suppressed focus indicator), `cc0f0a` (label-text relevance) and `5effbb` (link-in-context) are done too, which empties the manual/`cantTell` tier except `c4a8a4` (page-title-vs-content relevance), the one remaining candidate in that list, harder to heuristic than the others, since it needs some notion of vocabulary overlap between a title and the page's own content rather than a phrase/pattern list.
245
+ 4. **Keyboard trap detection** (`80af7b`/`ebe86a`/`a1b64e`) remains a large, currently-unaddressed gap with no automated detection at all. Scoped, not yet built; see [Keyboard trap detection: scoping notes](#keyboard-trap-detection-scoping-notes): a workable technique exists (drive focus/Tab events and observe where focus lands), but it would be this engine's first mutating, page-state-changing check, with cost and fidelity tradeoffs unlike anything built so far, a call for whoever owns the safety contract with integrators before writing code.
@@ -6,12 +6,12 @@
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.
13
13
 
14
- This list is deliberately not a new, invented guarantee — it codifies what the 6 real consumers above (and `docs/OUTPUT_SCHEMA.md`'s own worked examples) already depend on today, either directly or as documented shape.
14
+ This list isn't a new, invented guarantee: it codifies what the 6 real consumers above (and `docs/OUTPUT_SCHEMA.md`'s own worked examples) already depend on today, either directly or as documented shape.
15
15
 
16
16
  ## Package entry points (covered by semver)
17
17
 
@@ -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
 
@@ -71,7 +119,7 @@ const meta = {
71
119
 
72
120
  `meta.deprecated: true` requires both `deprecation.reason` and `deprecation.sinceVersion` — `normalizeRuleMeta` (`src/core/rule-meta.js`) throws a clear build-time error otherwise, the same way it already validates `meta.i18n.titleKey`.
73
121
 
74
- **A deprecated rule keeps running and producing results completely normally** — `pass`/`fail`/`cantTell`/`notApplicable` exactly as before. Deprecation is a catalog-level signal (visible via `getChecksCatalog()`, and in `docs/RULE_CATALOG.md`) for integrators to plan a migration on their own schedule — deliberately **not** an automatic exclusion (there is no `engineOptions.excludeDeprecated` flag). Silently dropping a rule's results the moment it's deprecated would be exactly the kind of surprise this document exists to prevent.
122
+ **A deprecated rule keeps running and producing results completely normally** — `pass`/`fail`/`cantTell`/`notApplicable` exactly as before. Deprecation is a catalog-level signal (visible via `getChecksCatalog()`, and in `docs/RULE_CATALOG.md`) for integrators to plan a migration on their own schedule, **not** an automatic exclusion (there is no `engineOptions.excludeDeprecated` flag). Silently dropping a rule's results the moment it's deprecated would be exactly the kind of surprise this document exists to prevent.
75
123
 
76
124
  The process:
77
125
  1. Mark the rule `deprecated: true` with `deprecation.reason`/`.replacedBy`/`.sinceVersion` set. Document it under `CHANGELOG.md`'s `### Deprecated` section (a standard Keep-a-Changelog category that's been in this project's changelog template since the beginning but never actually used until now).