ultra11y 2.22.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 (153) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +223 -0
  3. package/docker/Dockerfile +7 -0
  4. package/docker/package.json +9 -0
  5. package/docker/runner.mjs +23 -0
  6. package/package.json +73 -0
  7. package/scripts/bench.mjs +151 -0
  8. package/scripts/build-act-corpus.mjs +143 -0
  9. package/scripts/build-adjudication.mjs +503 -0
  10. package/scripts/build-pack-rgaa.mjs +397 -0
  11. package/scripts/build-standards.mjs +548 -0
  12. package/scripts/copy-bundle.mjs +39 -0
  13. package/scripts/import-pack.mjs +107 -0
  14. package/scripts/sync-engine.mjs +84 -0
  15. package/scripts/sync-version.mjs +43 -0
  16. package/scripts/ultra11y.mjs +52739 -0
  17. package/scripts/vendor/act-testcases.json +12297 -0
  18. package/scripts/vendor/rgaa/SOURCE.json +8 -0
  19. package/scripts/vendor/rgaa/criteres.json +4144 -0
  20. package/scripts/vendor/rgaa/glossaire.json +480 -0
  21. package/scripts/vendor/wcag-2.2-fr.json +82 -0
  22. package/scripts/vendor/wcag-2.2-sc-universe.json +950 -0
  23. package/scripts/vendor/wcag-2.2-sc.json +574 -0
  24. package/scripts/verify-rgaa-source.mjs +126 -0
  25. package/scripts/verify-skill-bundle.mjs +91 -0
  26. package/skills/review-a11y/SKILL.md +123 -0
  27. package/skills/review-a11y/scripts/ultra11y.mjs +52739 -0
  28. package/skills/ultra11y/SKILL.md +292 -0
  29. package/skills/ultra11y/references/act.md +163 -0
  30. package/skills/ultra11y/references/adjudication.md +599 -0
  31. package/skills/ultra11y/references/audit.md +100 -0
  32. package/skills/ultra11y/references/authoring.md +37 -0
  33. package/skills/ultra11y/references/automation.md +113 -0
  34. package/skills/ultra11y/references/correction.md +41 -0
  35. package/skills/ultra11y/references/criteria.md +129 -0
  36. package/skills/ultra11y/references/cross-file.md +82 -0
  37. package/skills/ultra11y/references/dynamic.md +152 -0
  38. package/skills/ultra11y/references/false-positives.md +81 -0
  39. package/skills/ultra11y/references/fix.md +46 -0
  40. package/skills/ultra11y/references/focus-and-logic.md +64 -0
  41. package/skills/ultra11y/references/forbidden-patterns.md +61 -0
  42. package/skills/ultra11y/references/forms-and-errors.md +147 -0
  43. package/skills/ultra11y/references/guidance.md +55 -0
  44. package/skills/ultra11y/references/judgment.md +90 -0
  45. package/skills/ultra11y/references/media-and-motion.md +116 -0
  46. package/skills/ultra11y/references/methodology.md +72 -0
  47. package/skills/ultra11y/references/naming.md +117 -0
  48. package/skills/ultra11y/references/orchestration.md +44 -0
  49. package/skills/ultra11y/references/packs.md +193 -0
  50. package/skills/ultra11y/references/prd.md +104 -0
  51. package/skills/ultra11y/references/rendered.md +110 -0
  52. package/skills/ultra11y/references/scale.md +49 -0
  53. package/skills/ultra11y/references/standards.md +97 -0
  54. package/skills/ultra11y/references/structure.md +138 -0
  55. package/skills/ultra11y/references/verify.md +88 -0
  56. package/skills/ultra11y/references/widgets.md +106 -0
  57. package/skills/ultra11y/scripts/ultra11y.mjs +52739 -0
  58. package/src/act.ts +441 -0
  59. package/src/adjudicate.ts +630 -0
  60. package/src/aria.ts +230 -0
  61. package/src/audit.ts +396 -0
  62. package/src/auditor.ts +399 -0
  63. package/src/autofill.ts +102 -0
  64. package/src/axe-map.ts +294 -0
  65. package/src/baseline.ts +75 -0
  66. package/src/capture.ts +182 -0
  67. package/src/check.ts +243 -0
  68. package/src/cli.ts +1927 -0
  69. package/src/color.ts +102 -0
  70. package/src/config.ts +182 -0
  71. package/src/crawl.ts +75 -0
  72. package/src/criteria.ts +183 -0
  73. package/src/data/adjudication.json +770 -0
  74. package/src/data/guidance/rgaa.json +2395 -0
  75. package/src/data/standards/rgaa.glossary.json +478 -0
  76. package/src/data/standards/rgaa.json +3454 -0
  77. package/src/data/wcag-universe.json +530 -0
  78. package/src/data/wcag.json +1178 -0
  79. package/src/discover.ts +203 -0
  80. package/src/fix/codemods.ts +76 -0
  81. package/src/fix/diff.ts +64 -0
  82. package/src/fix/edits.ts +126 -0
  83. package/src/fix.ts +215 -0
  84. package/src/gh.ts +214 -0
  85. package/src/glob.ts +206 -0
  86. package/src/graph/build.ts +114 -0
  87. package/src/graph/engine-resolve.ts +0 -0
  88. package/src/graph/graph.ts +126 -0
  89. package/src/graph/imports.ts +359 -0
  90. package/src/graph/resolve.ts +73 -0
  91. package/src/graph/tsconfig.ts +113 -0
  92. package/src/grounding.ts +119 -0
  93. package/src/guidance/index.ts +45 -0
  94. package/src/guidance/types.ts +36 -0
  95. package/src/init.ts +113 -0
  96. package/src/mcp/handlers.ts +388 -0
  97. package/src/mcp/http.ts +260 -0
  98. package/src/mcp/prompts.ts +183 -0
  99. package/src/mcp/protocol.ts +187 -0
  100. package/src/mcp/resources.ts +136 -0
  101. package/src/mcp/server.ts +228 -0
  102. package/src/mcp/stdio.ts +119 -0
  103. package/src/mcp/tools.ts +347 -0
  104. package/src/messages.ts +1011 -0
  105. package/src/name.ts +174 -0
  106. package/src/orchestrate-templates.ts +231 -0
  107. package/src/orchestrate.ts +186 -0
  108. package/src/output.ts +108 -0
  109. package/src/pack.ts +187 -0
  110. package/src/parse/html.ts +258 -0
  111. package/src/parse/jsx-ast.ts +67 -0
  112. package/src/parse/jsx-bridge.ts +247 -0
  113. package/src/parse/jsx.ts +19 -0
  114. package/src/parse/source.ts +75 -0
  115. package/src/prd.ts +406 -0
  116. package/src/project-lock.ts +42 -0
  117. package/src/render.ts +307 -0
  118. package/src/report.ts +399 -0
  119. package/src/rules/colors.ts +125 -0
  120. package/src/rules/cross-registry.ts +264 -0
  121. package/src/rules/cross-rule.ts +83 -0
  122. package/src/rules/forms.ts +590 -0
  123. package/src/rules/frames.ts +33 -0
  124. package/src/rules/headings.ts +227 -0
  125. package/src/rules/images.ts +200 -0
  126. package/src/rules/links.ts +193 -0
  127. package/src/rules/mandatory.ts +173 -0
  128. package/src/rules/multimedia.ts +77 -0
  129. package/src/rules/navigation.ts +190 -0
  130. package/src/rules/presentation.ts +75 -0
  131. package/src/rules/registry.ts +57 -0
  132. package/src/rules/rule.ts +122 -0
  133. package/src/rules/scripts-aria.ts +636 -0
  134. package/src/rules/tables.ts +264 -0
  135. package/src/rules/timing.ts +55 -0
  136. package/src/sample.ts +176 -0
  137. package/src/scan-local.ts +866 -0
  138. package/src/scan.ts +538 -0
  139. package/src/standards/derive.ts +195 -0
  140. package/src/standards/index.ts +31 -0
  141. package/src/standards/pack-rules.ts +111 -0
  142. package/src/standards/pack.ts +81 -0
  143. package/src/standards/registry.ts +100 -0
  144. package/src/standards/types.ts +202 -0
  145. package/src/standards/validate.ts +544 -0
  146. package/src/standards/vocabulary.ts +75 -0
  147. package/src/types.ts +355 -0
  148. package/src/util.ts +32 -0
  149. package/src/vendor/codeindex-engine.d.mts +1009 -0
  150. package/src/vendor/codeindex-engine.mjs +13701 -0
  151. package/src/vendor/engine.meta.json +9 -0
  152. package/src/verify.ts +275 -0
  153. package/src/wcag.ts +128 -0
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 maxgfr
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,223 @@
1
+ # ultra11y
2
+
3
+ > Audit HTML/CSS/JSX against **WCAG 2.2 AA** accessibility and produce a dated compliance report — or author/review accessible markup without regressions. A [skills.sh](https://skills.sh) agent skill: a deterministic, zero-dependency static engine **plus** the agent's judgment, with `check`/`verify` gates against hallucinated non-conformities. **The central deliverable is the auditor conformance block** — theme, criterion + official wording, test(s), WCAG mapping + level, finding, expected state, verification, `file:line` occurrences — rendered identically by the `report` (compliance doc), the `prd` backlog and the GitHub issues, in the active standard's vocabulary and **in your language** (`--lang auto` follows the conversation/repo). **WCAG is the worldwide core; country standards (RGAA, …) are pluggable in-repo packs.**
4
+
5
+ ultra11y is built around an honest **division of labour**. Automated tools only catch a fraction of accessibility problems, so the engine does the *mechanical* work — 72 machine-detectable static checks tied to the WCAG 2.2 success criteria — and is explicit about everything it can't decide statically. What it can't, the **AI agent adjudicates** (statically, from the evidence, gated by `verify`/`check`) — not a deferral to a human:
6
+
7
+ - **Automatable (engine):** missing `alt`/`lang`/`title`, unlabeled fields, empty links/buttons, icon-only controls, iframes without title, tables without headers, heading-level skips, empty/dangling headings & labels, duplicate ids, invalid/broken ARIA, positive `tabindex`, autoplay/timed-refresh/`blink`/`marquee` media…
8
+ - **Agent judgment (gated):** alt-text relevance, link purpose in context, reading/tab order, caption accuracy — the agent rules on these via `verify --manual` → `--apply`, each verdict carrying a justification (or a groundable NC), never a silent "conforming".
9
+ - **Needs rendering (scan tier):** computed contrast, visible focus, zoom/reflow, content on hover — decided by `scan` (axe-core in a real browser); until then they stay **residual risks**, never silently "conforming".
10
+ - **Advisory vs non-conformity:** a good practice with no failing normative test (an `advisory` pack rule, a best-practice-only axe violation, an agent recommendation) renders as « Recommandation (non normative) » — it never flips a criterion to `NC` nor enters the conformance rate. An NC always cites a `normativeRef`.
11
+ - **Stateful scan probes:** the local runtime drives the page with bounded, non-navigating interactions (fill inputs then re-measure overflow; a live-region probe for status messages 4.1.3) — `--no-interact` opts out; `--interact-clicks` re-enables button clicks on authenticated scans (destructive-named buttons are never clicked).
12
+ - **Normative page sample (échantillon):** a country-standard audit runs over a declared `sample.pages` set — `sample check` lints its coverage against the standard's required page kinds, `scan --sample` scans it, and an un-scanned `--standard rgaa` report is flagged **partial**.
13
+ - **Pack-only detection (declarative):** a standards pack can ship its own `rules` (a bounded, ReDoS-guarded matcher DSL — no code) and normativity/severity `overrides`, projecting onto its criteria without forking the engine.
14
+
15
+ ## Measured against a corpus it did not write
16
+
17
+ A self-authored fixture can only prove that a rule fires on the defect written for it. So the engine is also scored against the **[W3C ACT-Rules Community Group](https://act-rules.github.io) test corpus** — 1 134 `passed` / `failed` / `inapplicable` examples across 91 rules, authored independently of this project:
18
+
19
+ | | |
20
+ |---|---|
21
+ | Rules scored | **32** (the ACT rules an equivalent engine check exists for) |
22
+ | `failed` examples caught | **103 / 149** |
23
+ | `passed` / `inapplicable` examples left alone | **291 / 291** — no unexplained false positive |
24
+ | Deliberate divergences | 9, each argued on the record |
25
+ | Declared gaps (statically decidable, not implemented) | 13 |
26
+
27
+ Rules needing a rendered page (computed contrast, keyboard traps) or a human call (is this heading descriptive?) are **declared and routed**, not silently scored zero. The full per-rule matrix — including every gap and every divergence — is generated into [`skills/ultra11y/references/act.md`](skills/ultra11y/references/act.md) and re-checked on every build; the corpus itself is refreshed daily by a workflow, so the numbers cannot quietly go stale.
28
+
29
+ ## Install
30
+
31
+ ```sh
32
+ npx skills add maxgfr/ultra11y
33
+ ```
34
+
35
+ The repository ships **two skills** (pick at the prompt, or pass the skill name):
36
+ `ultra11y` — full audits, compliance reports, PRD backlogs, standards packs — and
37
+ `review-a11y` — the fast review loop that audits **exactly the code under change**
38
+ (staged files, working diff, or branch vs merge-base) and reports like a code reviewer.
39
+ Both bundle the same install-free engine.
40
+
41
+ Or clone and run the bundled engine directly (Node ≥ 22.18, zero dependencies):
42
+
43
+ ```sh
44
+ node scripts/ultra11y.mjs --help
45
+ ```
46
+
47
+ ## Commands
48
+
49
+ ```
50
+ ultra11y audit <globs… | -> [--out <dir>] [--include <glob>] [--exclude <glob>] [--ext <list>] [--jsx] [--graph] [--json] [--lang auto|en|fr] [--no-default-excludes]
51
+ ultra11y audit [--changed | --since <ref> | --staged] [--max-files <n>] [--dedup exact|normalized|off] [--baseline <file>] [--fail-on blocking|major|minor]
52
+ ultra11y audit [--captures <dir>] [--no-captures] [--require-captures] # audit rendered-DOM captures alongside source
53
+ ultra11y report --in <audit.json> [--out <dir>] [--standard <pack>] [--lang auto|en|fr]
54
+ ultra11y prd --in <audit.json> [--out <dir>] [--split criterion] [--format audit|doc|remediation] [--standard <pack>] [--gh-issues | --gh-single] [--lang auto|en|fr]
55
+ ultra11y render [<dir>] [--scaffold | --setup | --coverage | --storybook] [--captures <dir>] [--out <file>] [--json]
56
+ ultra11y criteria [<sc>] [--list] [--standard <pack> [--theme <N>]] [--generate] [--json] [--lang auto|en|fr]
57
+ ultra11y check --report <md> [--standard <pack>] [--quiet] [--json]
58
+ ultra11y verify --report <md> [--standard <pack>] [--semantic] [--apply <verdicts.json>] [--max-verify <n>] [--json]
59
+ ultra11y fix <globs… | -> [--write] [--iterate] [--changed | --since <ref> | --staged] [--safe] [--only <ids>] [--jsx] [--json]
60
+ ultra11y init [--hook] [--ci] [--baseline] [--fail-on blocking|major|minor]
61
+ ultra11y pack check <pack.json> [--guidance <g.json>] | scaffold # gate an (AI-)authored standards pack
62
+ ultra11y scan <url|file…> [--runtime auto|local|docker] [--cwd <dir>] [--storage-state <file>] [--merge <audit.json>] [--out <dir>] [--json]
63
+ ultra11y scan --sitemap <url> | --crawl <url> [--depth <n>] [--max <n>] [--runtime …] [--merge <audit.json>] [--json]
64
+ ultra11y mcp [--transport stdio|http] [--cwd <dir>] [--allow-write] [--port <n>] [--bind <addr>] [--allow-remote]
65
+
66
+ # global: --pack <paths> (load external standards pack(s) at runtime) · --override
67
+ ```
68
+
69
+ ## Use it as an MCP server
70
+
71
+ The skill shells out to the CLI and parses its output. An MCP server skips both:
72
+ your agent calls ultra11y as typed tools, with JSON schemas in and structured
73
+ results out. Same engine, same standards data, no wrapper.
74
+
75
+ ```bash
76
+ # stdio — the default, and what Claude Code / Claude Desktop / Cursor expect
77
+ claude mcp add ultra11y -- node /abs/path/to/scripts/ultra11y.mjs mcp
78
+
79
+ # or over HTTP, on loopback
80
+ node scripts/ultra11y.mjs mcp --transport http --port 7341
81
+ claude mcp add --transport http ultra11y http://127.0.0.1:7341/mcp
82
+ ```
83
+
84
+ ```jsonc
85
+ // Claude Desktop takes stdio servers only — a remote URL here will not work.
86
+ { "mcpServers": { "ultra11y": { "command": "node", "args": ["/abs/path/to/scripts/ultra11y.mjs", "mcp"] } } }
87
+ // Cursor, HTTP:
88
+ { "mcpServers": { "ultra11y": { "url": "http://127.0.0.1:7341/mcp" } } }
89
+ ```
90
+
91
+ It serves all three MCP primitives, because a skill is three things: the engine
92
+ (**tools**), the method (**prompts**), and the documentation the method refers
93
+ to (**resources**). A client given only the tools runs the audit, sees no
94
+ errors, and reports the page as accessible — a false conformance claim, which is
95
+ the one output an accessibility tool must never produce.
96
+
97
+ ### Tools
98
+
99
+ Ten read tools. `ultra11y_audit` is the entry point:
100
+
101
+ | Tool | What it does |
102
+ |------|--------------|
103
+ | `ultra11y_audit` | The static pass — findings keyed by success criterion |
104
+ | `ultra11y_adjudicate` | The criteria the engine **cannot** decide, with their evidence |
105
+ | `ultra11y_criteria` | The offline standards reference — look a criterion up, don't recall it |
106
+ | `ultra11y_report` | The dated conformance report, WCAG or a country pack |
107
+ | `ultra11y_prd` | Non-conformities → remediation units with effort |
108
+ | `ultra11y_check` | The gate: nothing asserted conformant that was never tested |
109
+ | `ultra11y_verify` | Claim↔evidence worklist |
110
+ | `ultra11y_pack_check` | Validate a country standards pack against what it really ships |
111
+ | `ultra11y_sample_check` | Lint the normative page sample |
112
+ | `ultra11y_read` | A file, or a line range, from the project |
113
+
114
+ `--allow-write` additionally exposes `ultra11y_fix` (safe codemods, dry-run by
115
+ default) and `ultra11y_init` (hook/CI/baseline) — the tools that change **your**
116
+ project. `ultra11y_scan` is declared but declines over MCP: it drives a headless
117
+ browser and may pull a Docker image, which is not a subprocess lifecycle a
118
+ long-lived server should own. It tells you to run it from the CLI and re-audit.
119
+
120
+ Pass `--cwd <dir>` at startup to dedicate the server to one project — `cwd` then
121
+ becomes optional on every tool.
122
+
123
+ ### Prompts — the workflow, not just the tools
124
+
125
+ | Prompt | Arguments | What it drives |
126
+ |--------|-----------|----------------|
127
+ | `audit_wcag` | `cwd`, `globs?`, `standard?` | audit → adjudicate → rule on each → report → check, with the untested criteria named |
128
+ | `adjudicate_criteria` | `cwd`, `standard?` | What alt-text relevance, link purpose, heading structure and reading order actually ask |
129
+ | `review_diff_a11y` | `cwd`, `since?` | Audit exactly the diff, refute the false positives, state the residual risks |
130
+
131
+ Each carries the coverage arithmetic: of the 55 WCAG 2.2 AA criteria the static
132
+ engine decides a handful, a browser decides fourteen, and **thirty-eight are
133
+ yours**.
134
+
135
+ ### Resources — the skill's own documentation
136
+
137
+ `SKILL.md` and all 28 `references/*.md` are served under `skill://`, read off
138
+ disk at request time — so a documentation fix reaches every client without a
139
+ rebuild.
140
+
141
+ Two things worth knowing:
142
+
143
+ - **A criterion nobody tested is untested, never conformant.** Every tool
144
+ description and every prompt says so, because the failure mode here is not a
145
+ wrong answer — it is a confident silence.
146
+ - **The HTTP transport binds `127.0.0.1` and refuses anything else** unless you
147
+ pass `--allow-remote`. This server reads local files; an exposed port is a
148
+ read-anything primitive for whoever finds it.
149
+
150
+ ## Standards packs (RGAA France first; add your country)
151
+
152
+ WCAG 2.2 AA is the engine's canonical key. Each country standard ships as an in-repo
153
+ **standards pack** — a small JSON that maps the national standard's criteria onto WCAG
154
+ success criteria — so the same audit re-keys to any standard:
155
+
156
+ ```sh
157
+ node scripts/ultra11y.mjs report --in audits/audit-latest.json --standard rgaa # → audits/rgaa-YYYY-MM-DD.md
158
+ node scripts/ultra11y.mjs criteria --standard rgaa 8.3 # a pack criterion (+ its WCAG SCs)
159
+ ```
160
+
161
+ **RGAA 4.1.2** (France) ships as the flagship pack. Section 508 (US), EN 301 549 (EU) and
162
+ others are welcome — adding a country is a single PR (pack JSON + one registration line +
163
+ a test). See [`CONTRIBUTING.md`](CONTRIBUTING.md) and `skills/ultra11y/references/standards.md`.
164
+
165
+ ### Scale, fixes, and repo automation
166
+
167
+ - **Scale** — the engine streams file-by-file (bounded memory), audits **only markup**,
168
+ and lets you focus: `--changed`/`--since` (git diff only), priority ordering
169
+ (layouts/templates/shared components first), content de-duplication, and an explicit
170
+ `--max-files` cap with logged truncation. See `references/scale.md`.
171
+ - **Fixes** — `fix` puts the fixes in place (native-first, anti-hallucination): deterministic
172
+ auto-codemods, fill-in `TODO` placeholders for the agent to complete, and judgment-only
173
+ proposals. `--dry-run` is the default; `--write` applies but only after a re-audit proves
174
+ no new non-conformity, and never on lossy JSX/TSX. See `references/fix.md`.
175
+ - **Automation** — `init --hook` (default) wires a zero-dependency git pre-commit gate over the
176
+ **strict staged snapshot**: it audits the exact index blobs, auto-applies the safe fixes and
177
+ re-stages them, and blocks only on judgment issues. `init --baseline`/`--ci` is the opt-in
178
+ regression variant (hook + committed baseline / GitHub Actions job) that fails only on **new**
179
+ non-conformities, not the existing backlog. See `references/automation.md`.
180
+
181
+ ### Optional dynamic tier (axe-core)
182
+
183
+ `scan` runs **axe-core in a headless browser** to decide the *needs-rendering* criteria the static engine leaves as residual risks — chiefly **computed colour contrast (1.4.3)** plus a 320px **reflow** check (1.4.10) and a render cross-check of the structural rules. Two runtimes (default `--runtime auto`): **`--runtime local`** uses a Playwright that resolves from your project (`--cwd`) — **no Docker** — and additionally probes focus visibility (2.4.7), 200% zoom (1.4.4), text spacing (1.4.12) and content-on-hover (1.4.13), plus **stateful** probes (fill inputs then re-measure overflow, and a live-region probe for status messages 4.1.3 — opt out with `--no-interact`, `--interact-clicks` for authed-scan button clicks), and takes `--storage-state` for authenticated pages; **`--runtime docker`** falls back to a self-contained image built on first use. `--merge` folds the findings into a static `AuditResult`, upgrading `manual` criteria to `C`/`NC`. A country-standard audit scans its declared page sample with `scan --sample` (lint it first with `sample check`):
184
+
185
+ ```sh
186
+ node scripts/ultra11y.mjs audit "src/**/*.html" --out audits --json > /dev/null
187
+ node scripts/ultra11y.mjs scan http://localhost:3000 --runtime local --cwd . --merge audits/audit-latest.json --out audits
188
+ node scripts/ultra11y.mjs report --in audits/audit-latest.json --out audits
189
+ ```
190
+
191
+ Only the Docker runtime needs Docker; `--runtime local` needs a project with `@playwright/test` + `@axe-core/playwright`. The rest of the skill is zero-dependency. The Docker runner + Dockerfile are embedded in the engine and mirrored under `docker/` (with a `docker-compose.yml`). See `skills/ultra11y/references/dynamic.md`.
192
+
193
+ Typical audit flow:
194
+
195
+ ```sh
196
+ node scripts/ultra11y.mjs audit "src/**/*.html" --json > audit.json
197
+ node scripts/ultra11y.mjs report --in audit.json --out audits # audits/wcag-YYYY-MM-DD.md
198
+ node scripts/ultra11y.mjs check --report audits/wcag-YYYY-MM-DD.md # integrity gate
199
+ ```
200
+
201
+ The skill (`skills/ultra11y/SKILL.md` + `references/`) teaches the agent when and how to run these, how to complete the manual criteria, and the native-first authoring doctrine.
202
+
203
+ ## Development
204
+
205
+ ```sh
206
+ pnpm install
207
+ pnpm test # vitest
208
+ pnpm run typecheck
209
+ pnpm run build # tsup → scripts/ultra11y.mjs, mirrored into skills/ultra11y/scripts/
210
+ pnpm run check:build # asserts the committed bundle is reproducible
211
+ pnpm run build:wcag # re-derive src/data/wcag.json from the vendored W3C source
212
+ pnpm run build:pack:rgaa # re-build the RGAA pack from the vendored DINUM source
213
+ pnpm run build:criteria # regenerate skills/ultra11y/references/criteria.md
214
+ ```
215
+
216
+ Releases are cut automatically by semantic-release on push to `main` (GitHub release + tarball, no npm publish).
217
+
218
+ ## Data & licensing
219
+
220
+ - ultra11y's code: **MIT** (see `LICENSE`).
221
+ - The **WCAG 2.2** success-criteria dataset (`src/data/wcag.json`) is derived from the official W3C source ([w3c/wcag](https://github.com/w3c/wcag)); WCAG 2.2 is © **W3C**, reused under the **W3C Document License** (only SC ids/titles/levels are reproduced) — see `NOTICE`.
222
+ - The **RGAA pack** (`src/data/standards/rgaa.json`, `rgaa.glossary.json`) is derived from the official **RGAA 4.1.2** reference published by DINUM/DISIC, under the **Licence Ouverte / Etalab 2.0** — see `NOTICE`. Attribution: « RGAA 4.1.2 — DINUM ».
223
+ - The report format is inspired by DINUM/etalab-ia audit conventions; the native-first authoring rules are adapted from the SocialGouv accessibility skill. No source code was copied.
@@ -0,0 +1,7 @@
1
+ FROM node:22-bookworm-slim
2
+ WORKDIR /app
3
+ COPY package.json ./
4
+ RUN npm install --omit=dev && npx playwright install --with-deps chromium
5
+ COPY runner.mjs ./
6
+ WORKDIR /work
7
+ ENTRYPOINT ["node", "/app/runner.mjs"]
@@ -0,0 +1,9 @@
1
+ {
2
+ "name": "ultra11y-dynamic",
3
+ "private": true,
4
+ "type": "module",
5
+ "dependencies": {
6
+ "playwright": "^1.49.0",
7
+ "axe-core": "4.12.1"
8
+ }
9
+ }
@@ -0,0 +1,23 @@
1
+ import { chromium } from "playwright";
2
+ import axe from "axe-core";
3
+ const target = process.argv[2];
4
+ const isFile = target.startsWith("/work/");
5
+ const browser = await chromium.launch({ args: ["--no-sandbox", "--disable-dev-shm-usage"] });
6
+ try {
7
+ const page = await browser.newPage();
8
+ await page.goto(isFile ? "file://" + target : target, { waitUntil: "load", timeout: 45000 });
9
+ await page.addScriptTag({ content: axe.source });
10
+ const axeRes = await page.evaluate(async () => await window.axe.run(document, { resultTypes: ["violations"] }));
11
+ await page.setViewportSize({ width: 320, height: 800 });
12
+ const reflow = await page.evaluate(() => {
13
+ const el = document.scrollingElement || document.documentElement;
14
+ return { horizontalScroll: el.scrollWidth > el.clientWidth + 2 };
15
+ });
16
+ const violations = axeRes.violations.map((v) => ({
17
+ id: v.id, impact: v.impact, help: v.help, tags: v.tags,
18
+ nodes: v.nodes.slice(0, 10).map((n) => ({ target: n.target, html: (n.html || "").slice(0, 200) })),
19
+ }));
20
+ console.log(JSON.stringify({ url: target, violations, reflow }));
21
+ } finally {
22
+ await browser.close();
23
+ }
package/package.json ADDED
@@ -0,0 +1,73 @@
1
+ {
2
+ "name": "ultra11y",
3
+ "version": "2.22.0",
4
+ "description": "Audit HTML/CSS/JSX against WCAG 2.2 AA and produce a dated compliance report, or author/review accessible markup (native-HTML-first). A skills.sh agent skill: an install-free static engine (bundled, no npm install) plus the agent's judgment, with check/verify gates against hallucinated non-conformities, and pluggable in-repo standards packs (RGAA France first; Section 508 & EN 301 549 next).",
5
+ "type": "module",
6
+ "packageManager": "pnpm@10.33.0",
7
+ "bin": {
8
+ "ultra11y": "scripts/ultra11y.mjs"
9
+ },
10
+ "files": [
11
+ "scripts",
12
+ "src",
13
+ "skills",
14
+ "docker"
15
+ ],
16
+ "engines": {
17
+ "node": ">=22.18.0"
18
+ },
19
+ "scripts": {
20
+ "build": "tsup && node scripts/copy-bundle.mjs",
21
+ "test": "vitest run",
22
+ "test:watch": "vitest",
23
+ "test:coverage": "vitest run --coverage",
24
+ "test:perf": "vitest run --config vitest.perf.config.ts",
25
+ "bench": "node scripts/bench.mjs",
26
+ "typecheck": "tsc --noEmit",
27
+ "lint": "biome ci .",
28
+ "lint:fix": "biome check --write .",
29
+ "format": "biome format --write .",
30
+ "verify:bundle": "node scripts/verify-skill-bundle.mjs",
31
+ "check:build": "tsup && node scripts/copy-bundle.mjs && git diff --exit-code -- scripts/ultra11y.mjs skills/ultra11y/scripts/ultra11y.mjs skills/review-a11y/scripts/ultra11y.mjs && node scripts/verify-skill-bundle.mjs && node scripts/sync-engine.mjs --check",
32
+ "build:wcag": "node scripts/build-standards.mjs",
33
+ "build:pack:rgaa": "node scripts/build-pack-rgaa.mjs",
34
+ "check:pack:rgaa": "node scripts/build-pack-rgaa.mjs --check",
35
+ "build:criteria": "node scripts/ultra11y.mjs criteria --generate > skills/ultra11y/references/criteria.md",
36
+ "build:act": "UPDATE_ACT_MATRIX=1 vitest run tests/act-matrix-sync.test.ts",
37
+ "build:act:refresh": "node scripts/build-act-corpus.mjs --refresh",
38
+ "build:adjudication": "node scripts/build-adjudication.mjs && UPDATE_ADJUDICATION_MD=1 vitest run tests/adjudication-md-sync.test.ts",
39
+ "check:adjudication": "node scripts/build-adjudication.mjs --check",
40
+ "demo": "node scripts/ultra11y.mjs audit tests/fixtures/non-conforming/bad.html --out /tmp/ultra11y-demo --json > /dev/null && node scripts/ultra11y.mjs report --in /tmp/ultra11y-demo/audit-latest.json --out /tmp/ultra11y-demo && node scripts/ultra11y.mjs check --report /tmp/ultra11y-demo/wcag-*.md && node scripts/ultra11y.mjs criteria --list",
41
+ "release": "semantic-release"
42
+ },
43
+ "license": "MIT",
44
+ "author": "maxgfr",
45
+ "repository": {
46
+ "type": "git",
47
+ "url": "https://github.com/maxgfr/ultra11y"
48
+ },
49
+ "dependencies": {
50
+ "@babel/parser": "^8.0.0",
51
+ "domhandler": "^6.0.1",
52
+ "htmlparser2": "^12.0.0"
53
+ },
54
+ "devDependencies": {
55
+ "@biomejs/biome": "^2.5.1",
56
+ "@semantic-release/changelog": "^6.0.3",
57
+ "@semantic-release/exec": "^7.1.0",
58
+ "@semantic-release/git": "^10.0.1",
59
+ "@types/node": "^26.0.1",
60
+ "@vitest/coverage-v8": "^4.1.9",
61
+ "jsdom": "^29.1.1",
62
+ "semantic-release": "^25.0.5",
63
+ "tsup": "^8.5.1",
64
+ "typescript": "^6.0.3",
65
+ "vitest": "^4.1.9",
66
+ "yaml": "^2.9.0"
67
+ },
68
+ "pnpm": {
69
+ "onlyBuiltDependencies": [
70
+ "esbuild"
71
+ ]
72
+ }
73
+ }
@@ -0,0 +1,151 @@
1
+ #!/usr/bin/env node
2
+ // DEV-ONLY (not in `bin`). Perf harness for the shipped bundle.
3
+ //
4
+ // Absolute timings are machine-specific, so this is NOT a CI gate — the automatic,
5
+ // machine-independent regression guard is tests/perf-shape.test.ts, which asserts the
6
+ // SHAPE of the cost curve (quadratic work fails, linear work passes). This script is
7
+ // the human-facing counterpart: it builds a deterministic synthetic repository whose
8
+ // proportions mirror a real front-end codebase, runs the real CLI over it as a
9
+ // subprocess, and prints wall times you can compare across two revisions.
10
+ //
11
+ // Usage:
12
+ // node scripts/bench.mjs # default corpus, table output
13
+ // node scripts/bench.mjs --scale 3 # 3x the file counts
14
+ // node scripts/bench.mjs --json # machine-readable
15
+ // node scripts/bench.mjs --engine <path.mjs> # bench another bundle (e.g. a git-restored one)
16
+ // node scripts/bench.mjs --compare <old.mjs> # run BOTH and print the delta per scenario
17
+ import { mkdtempSync, mkdirSync, writeFileSync, rmSync } from "node:fs";
18
+ import { spawnSync } from "node:child_process";
19
+ import { tmpdir } from "node:os";
20
+ import { dirname, join } from "node:path";
21
+ import { fileURLToPath } from "node:url";
22
+
23
+ const ROOT = join(dirname(fileURLToPath(import.meta.url)), "..");
24
+ const DEFAULT_ENGINE = join(ROOT, "scripts", "ultra11y.mjs");
25
+
26
+ const argv = process.argv.slice(2);
27
+ const flag = (name, fallback) => {
28
+ const i = argv.indexOf(name);
29
+ return i >= 0 && argv[i + 1] !== undefined ? argv[i + 1] : fallback;
30
+ };
31
+ const SCALE = Number(flag("--scale", "1")) || 1;
32
+ const AS_JSON = argv.includes("--json");
33
+ const ENGINE = flag("--engine", DEFAULT_ENGINE);
34
+ const COMPARE = flag("--compare", null);
35
+ const RUNS = Number(flag("--runs", "3")) || 3;
36
+
37
+ // Proportions of a mid-size front-end repo: many components, a smaller shared
38
+ // TS/JS layer the graph must resolve through, a handful of full HTML pages.
39
+ const COUNTS = { modules: Math.round(300 * SCALE), components: Math.round(400 * SCALE), pages: Math.round(120 * SCALE) };
40
+
41
+ /** A shared TS module: no markup, but real graph structure (exports the graph resolves). */
42
+ function tsModule(i) {
43
+ const body = Array.from(
44
+ { length: 12 },
45
+ (_, k) => `export function helper${i}_${k}(items: string[], opts: Record<string, number> = {}): string {\n` + ` return items.map((s) => s.trim()).join(String(opts.sep ?? ${k}));\n}`,
46
+ ).join("\n\n");
47
+ return `export const ANCHOR_${i} = "anchor-${i}";\nexport const LABEL_${i} = "Label ${i}";\n\n${body}\n`;
48
+ }
49
+
50
+ /** A realistic ~130-line TSX component carrying a handful of genuine defects. */
51
+ function component(i, moduleCount) {
52
+ const dep = i % moduleCount;
53
+ const rows = Array.from({ length: 10 }, (_, k) => ` <tr><td>{row.a${k}}</td><td>{row.b${k}}</td></tr>`).join("\n");
54
+ const fields = Array.from(
55
+ { length: 8 },
56
+ (_, k) => ` <div className="field">\n <label htmlFor="f${i}_${k}">Field ${k}</label>\n <input id="f${i}_${k}" type="text" name="n${i}_${k}" />\n </div>`,
57
+ ).join("\n");
58
+ return `import { ANCHOR_${dep}, LABEL_${dep} } from "../lib/mod${dep}";
59
+ import type { ReactNode } from "react";
60
+
61
+ interface Props {
62
+ label: string;
63
+ rows: { a0: string; b0: string }[];
64
+ children?: ReactNode;
65
+ }
66
+
67
+ export function C${i}({ label, rows, children }: Props) {
68
+ const title = label || LABEL_${dep};
69
+ return (
70
+ <section className="card" id={ANCHOR_${dep}}>
71
+ <header>
72
+ <h2>{title}</h2>
73
+ <img src="/thumb${i}.png" />
74
+ <button></button>
75
+ <a href="/detail/${i}"></a>
76
+ </header>
77
+ ${fields}
78
+ <table>
79
+ <tbody>
80
+ ${rows}
81
+ </tbody>
82
+ </table>
83
+ <footer>{children}</footer>
84
+ </section>
85
+ );
86
+ }
87
+
88
+ export default C${i};
89
+ `;
90
+ }
91
+
92
+ /** A full HTML page — the page-scoped rules (title, lang, landmarks) only fire on these. */
93
+ function page(i) {
94
+ const cells = Array.from({ length: 12 }, (_, k) => `<tr><td>${k}</td><td>value ${k}</td></tr>`).join("");
95
+ return `<!doctype html><html><head></head><body><main><img src="hero${i}.png"><a href="#missing${i}">Skip</a><table>${cells}</table><form><input type="text" name="q"><button></button></form></main></body></html>`;
96
+ }
97
+
98
+ function buildCorpus() {
99
+ const root = mkdtempSync(join(tmpdir(), "ultra11y-bench-"));
100
+ mkdirSync(join(root, "src", "lib"), { recursive: true });
101
+ mkdirSync(join(root, "src", "components"), { recursive: true });
102
+ mkdirSync(join(root, "src", "pages"), { recursive: true });
103
+ let bytes = 0;
104
+ const write = (p, s) => {
105
+ writeFileSync(p, s);
106
+ bytes += Buffer.byteLength(s);
107
+ };
108
+ for (let i = 0; i < COUNTS.modules; i++) write(join(root, "src", "lib", `mod${i}.ts`), tsModule(i));
109
+ for (let i = 0; i < COUNTS.components; i++) write(join(root, "src", "components", `C${i}.tsx`), component(i, COUNTS.modules));
110
+ for (let i = 0; i < COUNTS.pages; i++) write(join(root, "src", "pages", `p${i}.html`), page(i));
111
+ return { root, bytes };
112
+ }
113
+
114
+ function time(engine, args, cwd) {
115
+ const samples = [];
116
+ for (let i = 0; i < RUNS; i++) {
117
+ const t0 = performance.now();
118
+ const r = spawnSync(process.execPath, [engine, ...args], { cwd, stdio: "ignore", maxBuffer: 512 * 1024 * 1024 });
119
+ if (r.error) throw r.error;
120
+ samples.push(performance.now() - t0);
121
+ }
122
+ return Math.min(...samples); // best-of: the least noisy estimate of the real cost
123
+ }
124
+
125
+ const SCENARIOS = [
126
+ { id: "audit", args: ["audit", "src", "--json"] },
127
+ { id: "audit --graph", args: ["audit", "src", "--graph", "--json"] },
128
+ { id: "audit --max-files 50", args: ["audit", "src", "--max-files", "50", "--json"] },
129
+ { id: "fix (dry-run)", args: ["fix", "src", "--json"] },
130
+ ];
131
+
132
+ const { root, bytes } = buildCorpus();
133
+ try {
134
+ const files = COUNTS.modules + COUNTS.components + COUNTS.pages;
135
+ const results = SCENARIOS.map((s) => {
136
+ const ms = time(ENGINE, s.args, root);
137
+ return COMPARE ? { ...s, ms, baseMs: time(COMPARE, s.args, root) } : { ...s, ms };
138
+ });
139
+ if (AS_JSON) {
140
+ console.log(JSON.stringify({ corpus: { ...COUNTS, files, bytes }, engine: ENGINE, runs: RUNS, results }, null, 2));
141
+ } else {
142
+ console.log(`corpus: ${files} files (${COUNTS.modules} .ts, ${COUNTS.components} .tsx, ${COUNTS.pages} .html), ${(bytes / 1024 / 1024).toFixed(1)} MiB`);
143
+ console.log(`engine: ${ENGINE}${COMPARE ? `\nbaseline: ${COMPARE}` : ""}\nbest of ${RUNS} runs\n`);
144
+ for (const r of results) {
145
+ const base = r.baseMs === undefined ? "" : ` baseline ${r.baseMs.toFixed(0).padStart(6)} ms ${(((r.ms - r.baseMs) / r.baseMs) * 100).toFixed(1).padStart(6)} %`;
146
+ console.log(`${r.id.padEnd(22)} ${r.ms.toFixed(0).padStart(6)} ms${base}`);
147
+ }
148
+ }
149
+ } finally {
150
+ rmSync(root, { recursive: true, force: true });
151
+ }
@@ -0,0 +1,143 @@
1
+ #!/usr/bin/env node
2
+ // DEV-ONLY (not in `bin`). Vendors the OFFICIAL W3C ACT-Rules Community Group test
3
+ // corpus so the engine can be measured against third-party HTML nobody here wrote.
4
+ //
5
+ // Why this matters: the engine's own recall suite seeds one fixture per rule that
6
+ // EXISTS, so it can only ever prove "the rules we wrote fire on the defects we wrote".
7
+ // The ACT corpus is written by the ACT-Rules Community Group against the ACT Rules
8
+ // Format, independently of this project — it is the only external yardstick available
9
+ // for both recall (does a `failed` example get caught?) and precision (does a `passed`
10
+ // or `inapplicable` example stay clean?).
11
+ //
12
+ // The snapshot is COMMITTED (scripts/vendor/act-testcases.json) so the test suite stays
13
+ // offline and deterministic, exactly like the vendored WCAG/RGAA sources.
14
+ //
15
+ // Usage:
16
+ // node scripts/build-act-corpus.mjs # verify the vendored snapshot parses + summarize
17
+ // node scripts/build-act-corpus.mjs --refresh # re-fetch metadata AND every test case's HTML (network)
18
+ // node scripts/build-act-corpus.mjs --check # exit 1 if the snapshot is missing/!well-formed
19
+ import { writeFileSync, readFileSync, existsSync, mkdirSync } from "node:fs";
20
+ import { dirname, join } from "node:path";
21
+ import { fileURLToPath } from "node:url";
22
+
23
+ const root = join(dirname(fileURLToPath(import.meta.url)), "..");
24
+ const VENDOR_DIR = join(root, "scripts", "vendor");
25
+ const SNAPSHOT = join(VENDOR_DIR, "act-testcases.json");
26
+ const INDEX_URL = "https://act-rules.github.io/testcases.json";
27
+ const CONCURRENCY = 12;
28
+
29
+ const argv = process.argv.slice(2);
30
+ const REFRESH = argv.includes("--refresh");
31
+ const CHECK = argv.includes("--check");
32
+
33
+ /** Fetch with a small retry — a transient failure must not silently shrink the corpus. */
34
+ async function fetchText(url, attempts = 3) {
35
+ let lastErr;
36
+ for (let i = 0; i < attempts; i++) {
37
+ try {
38
+ const res = await fetch(url, { redirect: "follow" });
39
+ if (!res.ok) throw new Error(`HTTP ${res.status}`);
40
+ return await res.text();
41
+ } catch (e) {
42
+ lastErr = e;
43
+ }
44
+ }
45
+ throw new Error(`${url}: ${lastErr instanceof Error ? lastErr.message : lastErr}`);
46
+ }
47
+
48
+ /** Run `jobs` with a bounded number in flight (politeness + memory). */
49
+ async function pool(items, limit, fn) {
50
+ const out = new Array(items.length);
51
+ let next = 0;
52
+ await Promise.all(
53
+ Array.from({ length: Math.min(limit, items.length) }, async () => {
54
+ for (;;) {
55
+ const i = next++;
56
+ if (i >= items.length) return;
57
+ out[i] = await fn(items[i], i);
58
+ }
59
+ }),
60
+ );
61
+ return out;
62
+ }
63
+
64
+ async function refresh() {
65
+ process.stderr.write(`fetching ${INDEX_URL}\n`);
66
+ const index = JSON.parse(await fetchText(INDEX_URL));
67
+ const cases = index.testcases ?? [];
68
+ if (!cases.length) throw new Error("the ACT index returned no test cases — refusing to write an empty snapshot");
69
+ process.stderr.write(`fetching ${cases.length} test case documents (concurrency ${CONCURRENCY})…\n`);
70
+ let done = 0;
71
+ const html = await pool(cases, CONCURRENCY, async (tc) => {
72
+ const body = await fetchText(tc.url);
73
+ if (++done % 100 === 0) process.stderr.write(` ${done}/${cases.length}\n`);
74
+ return body;
75
+ });
76
+
77
+ // Keep only what a conformance run needs, in a stable order, so the snapshot diffs
78
+ // readably and a re-fetch of unchanged upstream data is a no-op.
79
+ const testcases = cases
80
+ .map((tc, i) => ({
81
+ ruleId: tc.ruleId,
82
+ ruleName: tc.ruleName,
83
+ testcaseId: tc.testcaseId,
84
+ title: tc.testcaseTitle,
85
+ expected: tc.expected,
86
+ // Which WCAG success criteria the rule is a test for — lets the corpus be
87
+ // cross-referenced with the engine's own criterion mapping.
88
+ wcag: Object.keys(tc.ruleAccessibilityRequirements ?? {})
89
+ .filter((k) => /^wcag\d*:\d/.test(k))
90
+ .map((k) => k.split(":")[1])
91
+ .sort(),
92
+ html: html[i],
93
+ }))
94
+ .sort((a, b) => a.ruleId.localeCompare(b.ruleId) || a.testcaseId.localeCompare(b.testcaseId));
95
+
96
+ const snapshot = {
97
+ source: INDEX_URL,
98
+ name: index.name,
99
+ website: index.website,
100
+ license: index.license,
101
+ fetchedCount: testcases.length,
102
+ rules: [...new Set(testcases.map((t) => t.ruleId))].length,
103
+ testcases,
104
+ };
105
+ mkdirSync(VENDOR_DIR, { recursive: true });
106
+ writeFileSync(SNAPSHOT, `${JSON.stringify(snapshot, null, 2)}\n`);
107
+ process.stderr.write(`wrote ${SNAPSHOT} — ${snapshot.rules} rules, ${snapshot.fetchedCount} cases\n`);
108
+ return snapshot;
109
+ }
110
+
111
+ function load() {
112
+ if (!existsSync(SNAPSHOT)) {
113
+ process.stderr.write(`missing ${SNAPSHOT} — run: node scripts/build-act-corpus.mjs --refresh\n`);
114
+ process.exitCode = 1;
115
+ return null;
116
+ }
117
+ const snap = JSON.parse(readFileSync(SNAPSHOT, "utf8"));
118
+ if (!Array.isArray(snap.testcases) || !snap.testcases.length) {
119
+ process.stderr.write(`${SNAPSHOT} holds no test cases\n`);
120
+ process.exitCode = 1;
121
+ return null;
122
+ }
123
+ for (const tc of snap.testcases) {
124
+ if (!tc.ruleId || !tc.expected || typeof tc.html !== "string") {
125
+ process.stderr.write(`${SNAPSHOT}: malformed test case ${tc.testcaseId ?? "?"}\n`);
126
+ process.exitCode = 1;
127
+ return null;
128
+ }
129
+ }
130
+ return snap;
131
+ }
132
+
133
+ const snap = REFRESH ? await refresh() : load();
134
+ if (snap && !CHECK) {
135
+ const byExpected = {};
136
+ for (const t of snap.testcases) byExpected[t.expected] = (byExpected[t.expected] ?? 0) + 1;
137
+ process.stdout.write(
138
+ `ACT corpus: ${snap.rules} rules, ${snap.testcases.length} cases ` +
139
+ `(${Object.entries(byExpected)
140
+ .map(([k, v]) => `${v} ${k}`)
141
+ .join(", ")})\n`,
142
+ );
143
+ }