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.
- package/LICENSE +21 -0
- package/README.md +223 -0
- package/docker/Dockerfile +7 -0
- package/docker/package.json +9 -0
- package/docker/runner.mjs +23 -0
- package/package.json +73 -0
- package/scripts/bench.mjs +151 -0
- package/scripts/build-act-corpus.mjs +143 -0
- package/scripts/build-adjudication.mjs +503 -0
- package/scripts/build-pack-rgaa.mjs +397 -0
- package/scripts/build-standards.mjs +548 -0
- package/scripts/copy-bundle.mjs +39 -0
- package/scripts/import-pack.mjs +107 -0
- package/scripts/sync-engine.mjs +84 -0
- package/scripts/sync-version.mjs +43 -0
- package/scripts/ultra11y.mjs +52739 -0
- package/scripts/vendor/act-testcases.json +12297 -0
- package/scripts/vendor/rgaa/SOURCE.json +8 -0
- package/scripts/vendor/rgaa/criteres.json +4144 -0
- package/scripts/vendor/rgaa/glossaire.json +480 -0
- package/scripts/vendor/wcag-2.2-fr.json +82 -0
- package/scripts/vendor/wcag-2.2-sc-universe.json +950 -0
- package/scripts/vendor/wcag-2.2-sc.json +574 -0
- package/scripts/verify-rgaa-source.mjs +126 -0
- package/scripts/verify-skill-bundle.mjs +91 -0
- package/skills/review-a11y/SKILL.md +123 -0
- package/skills/review-a11y/scripts/ultra11y.mjs +52739 -0
- package/skills/ultra11y/SKILL.md +292 -0
- package/skills/ultra11y/references/act.md +163 -0
- package/skills/ultra11y/references/adjudication.md +599 -0
- package/skills/ultra11y/references/audit.md +100 -0
- package/skills/ultra11y/references/authoring.md +37 -0
- package/skills/ultra11y/references/automation.md +113 -0
- package/skills/ultra11y/references/correction.md +41 -0
- package/skills/ultra11y/references/criteria.md +129 -0
- package/skills/ultra11y/references/cross-file.md +82 -0
- package/skills/ultra11y/references/dynamic.md +152 -0
- package/skills/ultra11y/references/false-positives.md +81 -0
- package/skills/ultra11y/references/fix.md +46 -0
- package/skills/ultra11y/references/focus-and-logic.md +64 -0
- package/skills/ultra11y/references/forbidden-patterns.md +61 -0
- package/skills/ultra11y/references/forms-and-errors.md +147 -0
- package/skills/ultra11y/references/guidance.md +55 -0
- package/skills/ultra11y/references/judgment.md +90 -0
- package/skills/ultra11y/references/media-and-motion.md +116 -0
- package/skills/ultra11y/references/methodology.md +72 -0
- package/skills/ultra11y/references/naming.md +117 -0
- package/skills/ultra11y/references/orchestration.md +44 -0
- package/skills/ultra11y/references/packs.md +193 -0
- package/skills/ultra11y/references/prd.md +104 -0
- package/skills/ultra11y/references/rendered.md +110 -0
- package/skills/ultra11y/references/scale.md +49 -0
- package/skills/ultra11y/references/standards.md +97 -0
- package/skills/ultra11y/references/structure.md +138 -0
- package/skills/ultra11y/references/verify.md +88 -0
- package/skills/ultra11y/references/widgets.md +106 -0
- package/skills/ultra11y/scripts/ultra11y.mjs +52739 -0
- package/src/act.ts +441 -0
- package/src/adjudicate.ts +630 -0
- package/src/aria.ts +230 -0
- package/src/audit.ts +396 -0
- package/src/auditor.ts +399 -0
- package/src/autofill.ts +102 -0
- package/src/axe-map.ts +294 -0
- package/src/baseline.ts +75 -0
- package/src/capture.ts +182 -0
- package/src/check.ts +243 -0
- package/src/cli.ts +1927 -0
- package/src/color.ts +102 -0
- package/src/config.ts +182 -0
- package/src/crawl.ts +75 -0
- package/src/criteria.ts +183 -0
- package/src/data/adjudication.json +770 -0
- package/src/data/guidance/rgaa.json +2395 -0
- package/src/data/standards/rgaa.glossary.json +478 -0
- package/src/data/standards/rgaa.json +3454 -0
- package/src/data/wcag-universe.json +530 -0
- package/src/data/wcag.json +1178 -0
- package/src/discover.ts +203 -0
- package/src/fix/codemods.ts +76 -0
- package/src/fix/diff.ts +64 -0
- package/src/fix/edits.ts +126 -0
- package/src/fix.ts +215 -0
- package/src/gh.ts +214 -0
- package/src/glob.ts +206 -0
- package/src/graph/build.ts +114 -0
- package/src/graph/engine-resolve.ts +0 -0
- package/src/graph/graph.ts +126 -0
- package/src/graph/imports.ts +359 -0
- package/src/graph/resolve.ts +73 -0
- package/src/graph/tsconfig.ts +113 -0
- package/src/grounding.ts +119 -0
- package/src/guidance/index.ts +45 -0
- package/src/guidance/types.ts +36 -0
- package/src/init.ts +113 -0
- package/src/mcp/handlers.ts +388 -0
- package/src/mcp/http.ts +260 -0
- package/src/mcp/prompts.ts +183 -0
- package/src/mcp/protocol.ts +187 -0
- package/src/mcp/resources.ts +136 -0
- package/src/mcp/server.ts +228 -0
- package/src/mcp/stdio.ts +119 -0
- package/src/mcp/tools.ts +347 -0
- package/src/messages.ts +1011 -0
- package/src/name.ts +174 -0
- package/src/orchestrate-templates.ts +231 -0
- package/src/orchestrate.ts +186 -0
- package/src/output.ts +108 -0
- package/src/pack.ts +187 -0
- package/src/parse/html.ts +258 -0
- package/src/parse/jsx-ast.ts +67 -0
- package/src/parse/jsx-bridge.ts +247 -0
- package/src/parse/jsx.ts +19 -0
- package/src/parse/source.ts +75 -0
- package/src/prd.ts +406 -0
- package/src/project-lock.ts +42 -0
- package/src/render.ts +307 -0
- package/src/report.ts +399 -0
- package/src/rules/colors.ts +125 -0
- package/src/rules/cross-registry.ts +264 -0
- package/src/rules/cross-rule.ts +83 -0
- package/src/rules/forms.ts +590 -0
- package/src/rules/frames.ts +33 -0
- package/src/rules/headings.ts +227 -0
- package/src/rules/images.ts +200 -0
- package/src/rules/links.ts +193 -0
- package/src/rules/mandatory.ts +173 -0
- package/src/rules/multimedia.ts +77 -0
- package/src/rules/navigation.ts +190 -0
- package/src/rules/presentation.ts +75 -0
- package/src/rules/registry.ts +57 -0
- package/src/rules/rule.ts +122 -0
- package/src/rules/scripts-aria.ts +636 -0
- package/src/rules/tables.ts +264 -0
- package/src/rules/timing.ts +55 -0
- package/src/sample.ts +176 -0
- package/src/scan-local.ts +866 -0
- package/src/scan.ts +538 -0
- package/src/standards/derive.ts +195 -0
- package/src/standards/index.ts +31 -0
- package/src/standards/pack-rules.ts +111 -0
- package/src/standards/pack.ts +81 -0
- package/src/standards/registry.ts +100 -0
- package/src/standards/types.ts +202 -0
- package/src/standards/validate.ts +544 -0
- package/src/standards/vocabulary.ts +75 -0
- package/src/types.ts +355 -0
- package/src/util.ts +32 -0
- package/src/vendor/codeindex-engine.d.mts +1009 -0
- package/src/vendor/codeindex-engine.mjs +13701 -0
- package/src/vendor/engine.meta.json +9 -0
- package/src/verify.ts +275 -0
- 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,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
|
+
}
|