ultra11y 2.32.1 → 3.1.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.
@@ -10,7 +10,7 @@
10
10
  "name": "ultra11y",
11
11
  "source": "./",
12
12
  "description": "Automatic accessibility review of the code under change before every commit, push and pull request, plus full WCAG 2.2 AA / RGAA audits and conformance deliverables on demand.",
13
- "version": "2.32.1",
13
+ "version": "3.1.0",
14
14
  "author": { "name": "maxgfr" },
15
15
  "homepage": "https://github.com/maxgfr/ultra11y",
16
16
  "repository": "https://github.com/maxgfr/ultra11y",
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "ultra11y",
3
3
  "description": "Accessibility for WCAG 2.2 AA and country standards (RGAA…): an automatic review of the code under change before every commit, push and pull request, plus full audits and conformance deliverables on demand.",
4
- "version": "2.32.1",
4
+ "version": "3.1.0",
5
5
  "author": { "name": "maxgfr" },
6
6
  "homepage": "https://github.com/maxgfr/ultra11y",
7
7
  "repository": "https://github.com/maxgfr/ultra11y",
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "ultra11y",
3
3
  "description": "Accessibility for WCAG 2.2 AA and country standards (RGAA…): an automatic review of the code under change before every commit, push and pull request, plus full audits and conformance deliverables on demand.",
4
- "version": "2.32.1",
4
+ "version": "3.1.0",
5
5
  "author": { "name": "maxgfr", "url": "https://github.com/maxgfr" },
6
6
  "homepage": "https://github.com/maxgfr/ultra11y",
7
7
  "repository": "https://github.com/maxgfr/ultra11y",
@@ -27,7 +27,7 @@ import { fileURLToPath } from "node:url";
27
27
  // Ownership marker: `ultra11y install --opencode` refuses to overwrite a plugin file that
28
28
  // does not carry it, so a user's own ultra11y.js is never clobbered.
29
29
  const ULTRA11Y_OPENCODE_PLUGIN = "ULTRA11Y_OPENCODE_PLUGIN";
30
- const ULTRA11Y_PLUGIN_VERSION = "2.32.1";
30
+ const ULTRA11Y_PLUGIN_VERSION = "3.1.0";
31
31
 
32
32
  const here = dirname(fileURLToPath(import.meta.url));
33
33
 
package/README.md CHANGED
@@ -125,7 +125,7 @@ claude mcp add ultra11y -- npx -y ultra11y mcp
125
125
  **As a GitHub Action** — audits the PR diff and, optionally, the served pages:
126
126
 
127
127
  ```yaml
128
- - uses: maxgfr/ultra11y@v2
128
+ - uses: maxgfr/ultra11y@v3
129
129
  with: { since: auto, standard: rgaa, fail-on: blocking }
130
130
  ```
131
131
 
@@ -351,7 +351,9 @@ a test). See [`CONTRIBUTING.md`](CONTRIBUTING.md) and `skills/ultra11y/reference
351
351
  optionally, the **pages** (it can start your app, wait for it, then scan real URLs or your
352
352
  declared sample). The engine ships inside the action, so there is nothing to install and no
353
353
  `setup-node`. The gate runs **last**, after SARIF, annotations, the summary, the sticky PR
354
- comment and the report — a red job is never a dead end. `ultra11y init --ci` writes a
354
+ comment and the report — a red job is never a dead end. `adjudicate: api|agent` optionally
355
+ rules on the judgment criteria that would otherwise stay « à évaluer » in CI, reading its key
356
+ from the job environment and skipping itself without one. `ultra11y init --ci` writes a
355
357
  workflow using it. See `references/ci.md`.
356
358
  - **CI surfaces** — a red job says *that* the build broke, not *where*. `--format sarif` emits
357
359
  SARIF 2.1.0 for GitHub code scanning, so each finding lands as an **inline annotation on the
@@ -378,6 +380,15 @@ adjudication leaves the audit untouched. All-`manual` with reasons is accepted,
378
380
  a correct answer. It is the **only** part of the tool that takes an API key; without one the
379
381
  command explains itself and exits, and nothing else changes.
380
382
 
383
+ The GitHub Action wires this as `adjudicate: api`, and offers `adjudicate: agent` as the other
384
+ half — the same worklist handed to a `claude-code-action` run, which can **open the cited files**
385
+ instead of ruling from harvested evidence alone. Both end in the same fail-closed gate; they
386
+ differ in evidence, not in trust. Both read `ANTHROPIC_API_KEY` from the **job environment**,
387
+ never from an input, and **skip themselves when it is absent** — which is exactly what a fork's
388
+ pull request looks like, so the job stays green and the criteria stay « à évaluer ». See
389
+ `references/ci.md` for the cost per run and for `gate-adjudicated`, which lets a model-ruled
390
+ non-conformity fail the build at the price of a red/green that no longer reproduces.
391
+
381
392
  ### Optional dynamic tier (axe-core)
382
393
 
383
394
  `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`):
@@ -418,7 +429,8 @@ Releases are cut automatically by **semantic-release** on push to `main` (GitHub
418
429
  npm publish via OIDC trusted publishing, plus the moving `v2` major tag). The version bump is
419
430
  decided by **Conventional Commits**: a commit whose subject carries no `feat:`/`fix:` prefix
420
431
  is analysed as *no release*, so a change that must ship needs at least one conventional commit
421
- in the range — otherwise `@v2` and npm keep serving the previous version while `main` moves on.
432
+ in the range — otherwise the major alias and npm keep serving the previous version while `main`
433
+ moves on.
422
434
 
423
435
  ## Data & licensing
424
436
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "ultra11y",
3
- "version": "2.32.1",
3
+ "version": "3.1.0",
4
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
5
  "type": "module",
6
6
  "packageManager": "pnpm@10.33.0",
@@ -7,15 +7,22 @@
7
7
  // script produces — the same reproducibility contract the engine bundle is held to.
8
8
  //
9
9
  // The mark is an eye (the action's own branding icon), drawn as a filled lens with a pupil on
10
- // the purple the action already declares. Written with a hand-rolled PNG encoder because the
11
- // repo has no image dependency and is not about to grow one for four small files.
10
+ // the purple the action already declares. The PNG encoder is the engine's own
11
+ // (`src/pixel.ts`), imported through Node's type-stripping — the repo has no image dependency
12
+ // and is not about to grow one for four small files.
13
+ //
14
+ // That import runs both ways: because these four icons are committed, `--check` re-encodes
15
+ // them and proves `encodePng` still emits the exact bytes it emitted when they were drawn.
16
+ // A decode/encode round-trip could only prove the pair is self-consistent; this proves the
17
+ // encoder is PNG-correct against files a browser accepts. Keep `src/pixel.ts` a leaf (one
18
+ // runtime import, `node:zlib`) or this import stops resolving.
12
19
  //
13
20
  // node scripts/build-extension-icons.mjs # write
14
21
  // node scripts/build-extension-icons.mjs --check # fail if the committed files differ
15
- import { deflateSync } from "node:zlib";
16
22
  import { readFileSync, writeFileSync, mkdirSync } from "node:fs";
17
23
  import { dirname, join } from "node:path";
18
24
  import { fileURLToPath } from "node:url";
25
+ import { encodePng } from "../src/pixel.ts";
19
26
 
20
27
  const OUT = join(dirname(fileURLToPath(import.meta.url)), "..", "extension", "icons");
21
28
  const SIZES = [16, 32, 48, 128];
@@ -24,45 +31,6 @@ const SIZES = [16, 32, 48, 128];
24
31
  const BG = [107, 70, 193]; // #6b46c1
25
32
  const FG = [255, 255, 255];
26
33
 
27
- function crc32(buf) {
28
- let c = ~0;
29
- for (const b of buf) {
30
- c ^= b;
31
- for (let i = 0; i < 8; i++) c = (c >>> 1) ^ (0xedb88320 & -(c & 1));
32
- }
33
- return ~c >>> 0;
34
- }
35
-
36
- function chunk(type, data) {
37
- const len = Buffer.alloc(4);
38
- len.writeUInt32BE(data.length);
39
- const body = Buffer.concat([Buffer.from(type, "latin1"), data]);
40
- const crc = Buffer.alloc(4);
41
- crc.writeUInt32BE(crc32(body));
42
- return Buffer.concat([len, body, crc]);
43
- }
44
-
45
- /** An RGBA pixel buffer → a PNG. Colour type 6, 8-bit, one filter byte per row. */
46
- function png(size, rgba) {
47
- const raw = Buffer.alloc(size * (size * 4 + 1));
48
- for (let y = 0; y < size; y++) {
49
- raw[y * (size * 4 + 1)] = 0; // filter: none — the images are tiny, filtering buys nothing
50
- rgba.copy(raw, y * (size * 4 + 1) + 1, y * size * 4, (y + 1) * size * 4);
51
- }
52
- const ihdr = Buffer.alloc(13);
53
- ihdr.writeUInt32BE(size, 0);
54
- ihdr.writeUInt32BE(size, 4);
55
- ihdr[8] = 8; // bit depth
56
- ihdr[9] = 6; // RGBA
57
- return Buffer.concat([
58
- Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]),
59
- chunk("IHDR", ihdr),
60
- // level 9 so the bytes are a function of the pixels alone, not of zlib's default tuning.
61
- chunk("IDAT", deflateSync(raw, { level: 9 })),
62
- chunk("IEND", Buffer.alloc(0)),
63
- ]);
64
- }
65
-
66
34
  /** The eye mark, supersampled 4× so the curves do not alias at 16px. */
67
35
  function draw(size) {
68
36
  const S = 4;
@@ -118,7 +86,7 @@ mkdirSync(OUT, { recursive: true });
118
86
  let stale = [];
119
87
  for (const size of SIZES) {
120
88
  const file = join(OUT, `icon-${size}.png`);
121
- const bytes = png(size, draw(size));
89
+ const bytes = encodePng({ width: size, height: size, data: draw(size) });
122
90
  if (check) {
123
91
  let current = null;
124
92
  try {
@@ -6,7 +6,7 @@ import { join as join45, relative as relative5, sep as sep7, dirname as dirname1
6
6
  import { fileURLToPath as fileURLToPath5, pathToFileURL as pathToFileURL3 } from "url";
7
7
 
8
8
  // src/types.ts
9
- var VERSION = "2.32.1";
9
+ var VERSION = "3.1.0";
10
10
  var SCHEMA_VERSION = 2;
11
11
 
12
12
  // src/audit.ts
@@ -39131,7 +39131,7 @@ var colorsRules = [contrastLiteral];
39131
39131
  import { readFileSync as readFileSync12 } from "fs";
39132
39132
 
39133
39133
  // src/pixel.ts
39134
- import { inflateSync } from "zlib";
39134
+ import { deflateSync, inflateSync } from "zlib";
39135
39135
  var SIGNATURE = [137, 80, 78, 71, 13, 10, 26, 10];
39136
39136
  function paeth(a, b, c2) {
39137
39137
  const p = a + b - c2;
@@ -58277,6 +58277,7 @@ Usage:
58277
58277
  ultra11y audit [--changed | --since <ref> | --staged] [--max-files <n>] [--dedup exact|normalized|off] [--baseline <file>] [--fail-on blocking|major|minor]
58278
58278
  ultra11y audit [--captures <dir>] [--no-captures] [--require-captures] (rendered-DOM captures + .ultra11y/pages snapshots: audit real HTML)
58279
58279
  ultra11y audit [--format sarif|github] (CI: SARIF for code scanning, or inline annotations + job summary)
58280
+ ultra11y audit --in <audit.json> [--fail-on blocking|major|minor] [--format sarif|github] (re-gate an audit already computed \u2014 e.g. one carrying adjudicated verdicts \u2014 without a second detection pass)
58280
58281
  ultra11y report --in <audit.json> [--out <dir>] [--standard <pack>] [--format sarif|github] [--lang auto|en|fr]
58281
58282
  ultra11y prd --in <audit.json> [--out <dir>] [--split criterion] [--format audit|doc|remediation] [--no-technical] [--standard <pack>] [--lang auto|en|fr]
58282
58283
  ultra11y tickets --in <audit.json> [--provider auto|github|gitlab|jira] [--grain criterion|page|page-criterion|single|file] [--transport auto|cli|rest]
@@ -58869,7 +58870,73 @@ function emitCiFormat(result, format, standard, lang, failOn) {
58869
58870
  );
58870
58871
  }
58871
58872
  }
58873
+ async function cmdAuditFromFile(p, inPath) {
58874
+ const scoping = [
58875
+ "since",
58876
+ "changed",
58877
+ "staged",
58878
+ "baseline",
58879
+ "graph",
58880
+ "cross-file",
58881
+ "jsx",
58882
+ "include",
58883
+ "exclude",
58884
+ "ext",
58885
+ "max-files",
58886
+ "dedup",
58887
+ "captures",
58888
+ "no-captures",
58889
+ "require-captures",
58890
+ "no-default-excludes",
58891
+ "out"
58892
+ ];
58893
+ const offenders = scoping.filter((f) => p.flags[f] !== void 0);
58894
+ if (p.positionals.length) offenders.unshift("<paths>");
58895
+ if (offenders.length) {
58896
+ console.error(
58897
+ `ultra11y audit: --in re-gates an audit that already exists, so these have nothing left to change: ${offenders.join(", ")}. Drop them, or run a fresh audit without --in.`
58898
+ );
58899
+ return 2;
58900
+ }
58901
+ let result;
58902
+ try {
58903
+ const parsed = JSON.parse(inPath === "-" ? await readStdin() : readText(inPath));
58904
+ if (!isCurrentAudit(parsed)) {
58905
+ console.error("ultra11y audit: --in is not a current ultra11y AuditResult (WCAG-keyed, schema v2). Re-run `audit`.");
58906
+ return 2;
58907
+ }
58908
+ result = parsed;
58909
+ } catch {
58910
+ console.error(`ultra11y audit: --in file not found or not valid JSON: ${inPath}.`);
58911
+ return 2;
58912
+ }
58913
+ const lang = resolveLang(p.flags, { audit: result });
58914
+ const failOnRaw = p.flags["fail-on"];
58915
+ const failOnParsed = parseFailOn(failOnRaw);
58916
+ if (failOnRaw !== void 0 && failOnParsed === null) {
58917
+ console.error(`ultra11y audit: --fail-on must be blocking|major|minor (got "${String(failOnRaw)}").`);
58918
+ return 2;
58919
+ }
58920
+ const ciFormat = parseCiFormat(p.flags.format);
58921
+ if (ciFormat === null) {
58922
+ console.error(`ultra11y audit: --format must be sarif|github (got "${String(p.flags.format)}").`);
58923
+ return 2;
58924
+ }
58925
+ const failOnSet = failOnRaw !== void 0;
58926
+ const failOn = failOnSet ? failOnParsed ?? "bloquant" : void 0;
58927
+ const failing = failOn ? findingsAtOrAbove(result.findings, failOn) : [];
58928
+ if (ciFormat) emitCiFormat(result, ciFormat, CORE2, lang, failOn);
58929
+ else if (p.flags.json) console.log(JSON.stringify(result, null, 2));
58930
+ else {
58931
+ console.log(auditSummary(result, lang));
58932
+ if (failOnSet && failing.length)
58933
+ console.error(lang === "fr" ? `\u2717 ${failing.length} non-conformit\xE9(s) \u2265 ${failOn}.` : `\u2717 ${failing.length} non-conformity(ies) \u2265 ${failOn}.`);
58934
+ }
58935
+ return failing.length ? 1 : 0;
58936
+ }
58872
58937
  async function cmdAudit(p) {
58938
+ const inFlag = p.flags.in;
58939
+ if (typeof inFlag === "string" && inFlag) return cmdAuditFromFile(p, inFlag);
58873
58940
  const inputs = p.positionals.length ? p.positionals : ["."];
58874
58941
  if (inputs.length === 0) {
58875
58942
  console.error("ultra11y audit: provide files/globs, or '-' to read stdin.");
@@ -5,7 +5,7 @@ when_to_use: "Invoke WITHOUT being asked whenever changed front-end code should
5
5
  allowed-tools: Bash(node ${CLAUDE_SKILL_DIR}/scripts/ultra11y.mjs *), Read, Grep, Glob
6
6
  license: MIT
7
7
  metadata:
8
- version: 2.32.1
8
+ version: 3.1.0
9
9
  ---
10
10
 
11
11
  # review-a11y — accessibility review of changed code
@@ -6,7 +6,7 @@ import { join as join45, relative as relative5, sep as sep7, dirname as dirname1
6
6
  import { fileURLToPath as fileURLToPath5, pathToFileURL as pathToFileURL3 } from "url";
7
7
 
8
8
  // src/types.ts
9
- var VERSION = "2.32.1";
9
+ var VERSION = "3.1.0";
10
10
  var SCHEMA_VERSION = 2;
11
11
 
12
12
  // src/audit.ts
@@ -39131,7 +39131,7 @@ var colorsRules = [contrastLiteral];
39131
39131
  import { readFileSync as readFileSync12 } from "fs";
39132
39132
 
39133
39133
  // src/pixel.ts
39134
- import { inflateSync } from "zlib";
39134
+ import { deflateSync, inflateSync } from "zlib";
39135
39135
  var SIGNATURE = [137, 80, 78, 71, 13, 10, 26, 10];
39136
39136
  function paeth(a, b, c2) {
39137
39137
  const p = a + b - c2;
@@ -58277,6 +58277,7 @@ Usage:
58277
58277
  ultra11y audit [--changed | --since <ref> | --staged] [--max-files <n>] [--dedup exact|normalized|off] [--baseline <file>] [--fail-on blocking|major|minor]
58278
58278
  ultra11y audit [--captures <dir>] [--no-captures] [--require-captures] (rendered-DOM captures + .ultra11y/pages snapshots: audit real HTML)
58279
58279
  ultra11y audit [--format sarif|github] (CI: SARIF for code scanning, or inline annotations + job summary)
58280
+ ultra11y audit --in <audit.json> [--fail-on blocking|major|minor] [--format sarif|github] (re-gate an audit already computed \u2014 e.g. one carrying adjudicated verdicts \u2014 without a second detection pass)
58280
58281
  ultra11y report --in <audit.json> [--out <dir>] [--standard <pack>] [--format sarif|github] [--lang auto|en|fr]
58281
58282
  ultra11y prd --in <audit.json> [--out <dir>] [--split criterion] [--format audit|doc|remediation] [--no-technical] [--standard <pack>] [--lang auto|en|fr]
58282
58283
  ultra11y tickets --in <audit.json> [--provider auto|github|gitlab|jira] [--grain criterion|page|page-criterion|single|file] [--transport auto|cli|rest]
@@ -58869,7 +58870,73 @@ function emitCiFormat(result, format, standard, lang, failOn) {
58869
58870
  );
58870
58871
  }
58871
58872
  }
58873
+ async function cmdAuditFromFile(p, inPath) {
58874
+ const scoping = [
58875
+ "since",
58876
+ "changed",
58877
+ "staged",
58878
+ "baseline",
58879
+ "graph",
58880
+ "cross-file",
58881
+ "jsx",
58882
+ "include",
58883
+ "exclude",
58884
+ "ext",
58885
+ "max-files",
58886
+ "dedup",
58887
+ "captures",
58888
+ "no-captures",
58889
+ "require-captures",
58890
+ "no-default-excludes",
58891
+ "out"
58892
+ ];
58893
+ const offenders = scoping.filter((f) => p.flags[f] !== void 0);
58894
+ if (p.positionals.length) offenders.unshift("<paths>");
58895
+ if (offenders.length) {
58896
+ console.error(
58897
+ `ultra11y audit: --in re-gates an audit that already exists, so these have nothing left to change: ${offenders.join(", ")}. Drop them, or run a fresh audit without --in.`
58898
+ );
58899
+ return 2;
58900
+ }
58901
+ let result;
58902
+ try {
58903
+ const parsed = JSON.parse(inPath === "-" ? await readStdin() : readText(inPath));
58904
+ if (!isCurrentAudit(parsed)) {
58905
+ console.error("ultra11y audit: --in is not a current ultra11y AuditResult (WCAG-keyed, schema v2). Re-run `audit`.");
58906
+ return 2;
58907
+ }
58908
+ result = parsed;
58909
+ } catch {
58910
+ console.error(`ultra11y audit: --in file not found or not valid JSON: ${inPath}.`);
58911
+ return 2;
58912
+ }
58913
+ const lang = resolveLang(p.flags, { audit: result });
58914
+ const failOnRaw = p.flags["fail-on"];
58915
+ const failOnParsed = parseFailOn(failOnRaw);
58916
+ if (failOnRaw !== void 0 && failOnParsed === null) {
58917
+ console.error(`ultra11y audit: --fail-on must be blocking|major|minor (got "${String(failOnRaw)}").`);
58918
+ return 2;
58919
+ }
58920
+ const ciFormat = parseCiFormat(p.flags.format);
58921
+ if (ciFormat === null) {
58922
+ console.error(`ultra11y audit: --format must be sarif|github (got "${String(p.flags.format)}").`);
58923
+ return 2;
58924
+ }
58925
+ const failOnSet = failOnRaw !== void 0;
58926
+ const failOn = failOnSet ? failOnParsed ?? "bloquant" : void 0;
58927
+ const failing = failOn ? findingsAtOrAbove(result.findings, failOn) : [];
58928
+ if (ciFormat) emitCiFormat(result, ciFormat, CORE2, lang, failOn);
58929
+ else if (p.flags.json) console.log(JSON.stringify(result, null, 2));
58930
+ else {
58931
+ console.log(auditSummary(result, lang));
58932
+ if (failOnSet && failing.length)
58933
+ console.error(lang === "fr" ? `\u2717 ${failing.length} non-conformit\xE9(s) \u2265 ${failOn}.` : `\u2717 ${failing.length} non-conformity(ies) \u2265 ${failOn}.`);
58934
+ }
58935
+ return failing.length ? 1 : 0;
58936
+ }
58872
58937
  async function cmdAudit(p) {
58938
+ const inFlag = p.flags.in;
58939
+ if (typeof inFlag === "string" && inFlag) return cmdAuditFromFile(p, inFlag);
58873
58940
  const inputs = p.positionals.length ? p.positionals : ["."];
58874
58941
  if (inputs.length === 0) {
58875
58942
  console.error("ultra11y audit: provide files/globs, or '-' to read stdin.");
@@ -4,7 +4,7 @@ description: "Use to AUDIT existing HTML/CSS/JSX against WCAG 2.2 AA accessibili
4
4
  when_to_use: "Invoke when the user asks for an accessibility AUDIT or a formal deliverable: audit a repo, site or page against WCAG 2.2 AA or a country standard (RGAA, Section 508, EN 301 549); produce a dated conformance report, a criterion grid, a PRD backlog, or tickets (GitHub/GitLab/Jira); author accessible markup from scratch; wire the repo gate (init --hook/--ci) or a rendered-DOM capture pipeline. For a review of the code UNDER CHANGE (diff, branch, PR), use the `review-a11y` skill instead."
5
5
  license: MIT
6
6
  metadata:
7
- version: 2.32.1
7
+ version: 3.1.0
8
8
  ---
9
9
 
10
10
  # ultra11y — audit WCAG 2.2 AA and write accessible markup
@@ -15,7 +15,7 @@ permissions:
15
15
  steps:
16
16
  - uses: actions/checkout@v4
17
17
  with: { fetch-depth: 0 } # the diff gate needs the base ref
18
- - uses: maxgfr/ultra11y@v2 # or pin the exact version, as `init --ci` does
18
+ - uses: maxgfr/ultra11y@v3 # or pin the exact version, as `init --ci` does
19
19
  with:
20
20
  since: auto # the PR's base branch
21
21
  standard: rgaa
@@ -27,7 +27,7 @@ steps:
27
27
  Page by page, with a real browser, in the same step:
28
28
 
29
29
  ```yaml
30
- - uses: maxgfr/ultra11y@v2
30
+ - uses: maxgfr/ultra11y@v3
31
31
  with:
32
32
  standard: rgaa
33
33
  start: npm run start
@@ -37,9 +37,11 @@ Page by page, with a real browser, in the same step:
37
37
  ```
38
38
 
39
39
  `ultra11y init --ci` writes a workflow using it, **pinned to the exact engine version that
40
- generated the file** (`@v<that version>`) so a CI run stays reproducible. `@v2` is a moving major alias
41
- the release workflow keeps pointing at the latest release, for teams who would rather take
42
- fixes automatically. Never `@main`: it would change under you without a version to blame.
40
+ generated the file** (`@v<that version>`) so a CI run stays reproducible. `@v<major>` — `@v3`
41
+ today — is a moving alias the release workflow repoints at each release, for teams who would
42
+ rather take fixes automatically. It moves **within** a major only: when a breaking change cuts
43
+ the next one, the old alias freezes where it is, so a pipeline pinned to it keeps working and
44
+ stops receiving features. Never `@main`: it would change under you with no version to blame.
43
45
 
44
46
  **Order matters, and it is deliberate**: the audit runs first, then SARIF, annotations, the
45
47
  summary, the comment and the report — and the **gate runs last**. A failing audit has
@@ -53,6 +55,69 @@ run and an RGAA run coexist rather than overwriting each other. The Markdown rep
53
55
  through `check` before being uploaded, so CI cannot publish a report citing an invented
54
56
  criterion.
55
57
 
58
+ ## Adjudicating the judgment criteria in CI (`adjudicate`)
59
+
60
+ Of the 55 WCAG 2.2 AA criteria the engine decides a handful; **38 are judgment calls**, and
61
+ under RGAA **81 of 106** can only ever derive `manual`. Inside a coding agent the agent rules
62
+ on them. In CI nobody does, so they stay « à évaluer » and the published conformance rate is
63
+ partial by construction. `adjudicate` closes that — opt-in, in two modes.
64
+
65
+ ```yaml
66
+ env:
67
+ ANTHROPIC_API_KEY: ${{ secrets.ANTHROPIC_API_KEY }} # the job's env, never an input
68
+ steps:
69
+ - uses: maxgfr/ultra11y@v3
70
+ with:
71
+ standard: rgaa
72
+ adjudicate: api # or `agent`
73
+ ```
74
+
75
+ | input | default | |
76
+ |---|---|---|
77
+ | `adjudicate` | `none` | `api` · `agent` · `none` |
78
+ | `adjudicate-model` | *(empty)* | model id for `api`; else `$ULTRA11Y_LLM_MODEL`, else the engine default |
79
+ | `gate-adjudicated` | `false` | let a model-ruled NC fail the job |
80
+
81
+ **`api`** sends the worklist to the Messages API in batches of 8 and folds the verdicts back.
82
+ **`agent`** emits the worklist plus `orchestrate --eco`'s runbook and hands them to a
83
+ `claude-code-action` run, then folds the same way. Both end in the **same fail-closed gate** an
84
+ agent's verdicts pass through, so neither can assert a conformance the engine refuses.
85
+
86
+ The difference between them is **evidence, not trust**. `judge` rules from the harvested
87
+ evidence alone — capped at 30 items per criterion, snippets truncated — while the agent can
88
+ open the cited files and read around them, which is what *link purpose **in context*** actually
89
+ asks for. Expect the API mode to answer `manual` more often; that is it being honest, not
90
+ broken. The metric that separates them is the count of criteria left to assess.
91
+
92
+ **Both modes read `ANTHROPIC_API_KEY` from the job environment, never from an input** — a
93
+ composite action's steps inherit it, and a secret that never travels through an input is one
94
+ fewer secret to leak into a log. When the key is absent the tier **skips itself**: the job stays
95
+ green, the report is still written, and the criteria stay « à évaluer ». That is not an edge
96
+ case — it is precisely what a **fork's pull request** looks like, where secrets are never
97
+ exposed. An adjudication that fails mid-flight (a rate-limited batch refuses the whole fold) is
98
+ absorbed the same way, because losing the verdicts must never cost you the audit.
99
+
100
+ **Cost is per run and does not amortise.** Roughly $0.20 for a WCAG run and $0.50 for RGAA at
101
+ Sonnet pricing — the worklist is re-sent whole each time and no batch is large enough to earn
102
+ prompt caching. On a busy repository that is real money per push; this belongs on the default
103
+ branch or a schedule far more often than on every PR.
104
+
105
+ **`gate-adjudicated` trades reproducibility for reach.** By default the gate re-audits the
106
+ **source**, so the red/green is a pure function of the commit whatever a model said about it.
107
+ Turn it on and the gate evaluates the adjudicated audit instead (`audit --in`), letting a
108
+ model-ruled non-conformity fail the job — and accepting that two runs on the same commit may
109
+ now disagree. Default off, deliberately.
110
+
111
+ ```sh
112
+ # The same re-gate, outside the action:
113
+ node scripts/ultra11y.mjs audit --in audits/audit-latest.json --fail-on blocking
114
+ ```
115
+
116
+ `--in` re-gates an audit that already exists rather than computing one, which is the only way
117
+ to gate on verdicts a second detection pass would not see. It refuses every flag that would
118
+ change *what* is audited (`--since`, `--baseline`, paths…): a gate that silently dropped your
119
+ scoping would be a gate nobody could trust.
120
+
56
121
  # The `--format` renderings
57
122
 
58
123
  A gate tells a developer *that* the build failed; it does not put the non-conformity **on the
@@ -170,7 +235,7 @@ The action audits the **code** and, when you point it at a served app, the **pag
170
235
  list can come from three places, and none of them has to be written by hand:
171
236
 
172
237
  ```yaml
173
- - uses: maxgfr/ultra11y@v2
238
+ - uses: maxgfr/ultra11y@v3
174
239
  with:
175
240
  standard: rgaa
176
241
  start: npm run start
@@ -221,9 +286,9 @@ one job — the code diff, then the served pages — died on a `409 Conflict`, w
221
286
  written and never uploaded. Pass `artifact-name` on each invocation:
222
287
 
223
288
  ```yaml
224
- - uses: maxgfr/ultra11y@v2
289
+ - uses: maxgfr/ultra11y@v3
225
290
  with: { since: auto, artifact-name: a11y-code }
226
- - uses: maxgfr/ultra11y@v2
291
+ - uses: maxgfr/ultra11y@v3
227
292
  with: { crawl: http://localhost:3000, artifact-name: a11y-pages }
228
293
  ```
229
294
 
@@ -151,6 +151,14 @@ A rejected adjudication leaves `audit-latest.json` untouched.
151
151
  **Strictly opt-in.** With no key the command explains itself and exits 2; nothing else in the
152
152
  engine changes.
153
153
 
154
+ **In the GitHub Action.** `adjudicate: api` runs exactly the command above; `adjudicate: agent`
155
+ hands the same worklist — plus `orchestrate --eco`'s runbook and the adjudicator contract — to
156
+ a `claude-code-action` run, then folds it with `verify --apply`. The agent mode exists because
157
+ `judge` rules from the harvested evidence alone (30 items per criterion, snippets truncated)
158
+ while an agent can open the cited files, which is what *link purpose in context* really needs.
159
+ Both skip themselves when `ANTHROPIC_API_KEY` is absent from the job environment, and both
160
+ absorb their own failure rather than taking the audit down with them. See `references/ci.md`.
161
+
154
162
  `--max` bounds the spend and says out loud which criteria it did not submit. It is **refused
155
163
  together with `--apply`**, before any call is made: the gate rejects an incomplete
156
164
  adjudication by design, so the pair could only ever bill you for a guaranteed failure. Drop
@@ -64,6 +64,13 @@ verdict with no justification, an NC with no resolvable `normativeRef`, a criter
64
64
  from the adjudication — each is refused by name, and the run stops. That is what keeps the
65
65
  final report defensible.
66
66
 
67
+ **The shipped GitHub Action now runs this pipeline for you.** `adjudicate: agent` performs
68
+ steps 2–4 — `verify --manual`, then `orchestrate --run audits --phase adjudicate --eco`, then a
69
+ `claude-code-action` run pointed at the emitted `RUNBOOK.md`, then `verify --apply`. Reach for
70
+ the raw pipeline above when your orchestrator is *not* GitHub Actions, or when you want to fan
71
+ step 3 out across subagents rather than walk the eco path. Either way steps 1, 2, 4, 5 and 6
72
+ stay deterministic, and only step 3 involves a model.
73
+
67
74
  ## The adjudication contract
68
75
 
69
76
  `ADJUDICATE.todo.json` declares what may be written into it, so the filler never has to read
@@ -6,7 +6,7 @@ import { join as join45, relative as relative5, sep as sep7, dirname as dirname1
6
6
  import { fileURLToPath as fileURLToPath5, pathToFileURL as pathToFileURL3 } from "url";
7
7
 
8
8
  // src/types.ts
9
- var VERSION = "2.32.1";
9
+ var VERSION = "3.1.0";
10
10
  var SCHEMA_VERSION = 2;
11
11
 
12
12
  // src/audit.ts
@@ -39131,7 +39131,7 @@ var colorsRules = [contrastLiteral];
39131
39131
  import { readFileSync as readFileSync12 } from "fs";
39132
39132
 
39133
39133
  // src/pixel.ts
39134
- import { inflateSync } from "zlib";
39134
+ import { deflateSync, inflateSync } from "zlib";
39135
39135
  var SIGNATURE = [137, 80, 78, 71, 13, 10, 26, 10];
39136
39136
  function paeth(a, b, c2) {
39137
39137
  const p = a + b - c2;
@@ -58277,6 +58277,7 @@ Usage:
58277
58277
  ultra11y audit [--changed | --since <ref> | --staged] [--max-files <n>] [--dedup exact|normalized|off] [--baseline <file>] [--fail-on blocking|major|minor]
58278
58278
  ultra11y audit [--captures <dir>] [--no-captures] [--require-captures] (rendered-DOM captures + .ultra11y/pages snapshots: audit real HTML)
58279
58279
  ultra11y audit [--format sarif|github] (CI: SARIF for code scanning, or inline annotations + job summary)
58280
+ ultra11y audit --in <audit.json> [--fail-on blocking|major|minor] [--format sarif|github] (re-gate an audit already computed \u2014 e.g. one carrying adjudicated verdicts \u2014 without a second detection pass)
58280
58281
  ultra11y report --in <audit.json> [--out <dir>] [--standard <pack>] [--format sarif|github] [--lang auto|en|fr]
58281
58282
  ultra11y prd --in <audit.json> [--out <dir>] [--split criterion] [--format audit|doc|remediation] [--no-technical] [--standard <pack>] [--lang auto|en|fr]
58282
58283
  ultra11y tickets --in <audit.json> [--provider auto|github|gitlab|jira] [--grain criterion|page|page-criterion|single|file] [--transport auto|cli|rest]
@@ -58869,7 +58870,73 @@ function emitCiFormat(result, format, standard, lang, failOn) {
58869
58870
  );
58870
58871
  }
58871
58872
  }
58873
+ async function cmdAuditFromFile(p, inPath) {
58874
+ const scoping = [
58875
+ "since",
58876
+ "changed",
58877
+ "staged",
58878
+ "baseline",
58879
+ "graph",
58880
+ "cross-file",
58881
+ "jsx",
58882
+ "include",
58883
+ "exclude",
58884
+ "ext",
58885
+ "max-files",
58886
+ "dedup",
58887
+ "captures",
58888
+ "no-captures",
58889
+ "require-captures",
58890
+ "no-default-excludes",
58891
+ "out"
58892
+ ];
58893
+ const offenders = scoping.filter((f) => p.flags[f] !== void 0);
58894
+ if (p.positionals.length) offenders.unshift("<paths>");
58895
+ if (offenders.length) {
58896
+ console.error(
58897
+ `ultra11y audit: --in re-gates an audit that already exists, so these have nothing left to change: ${offenders.join(", ")}. Drop them, or run a fresh audit without --in.`
58898
+ );
58899
+ return 2;
58900
+ }
58901
+ let result;
58902
+ try {
58903
+ const parsed = JSON.parse(inPath === "-" ? await readStdin() : readText(inPath));
58904
+ if (!isCurrentAudit(parsed)) {
58905
+ console.error("ultra11y audit: --in is not a current ultra11y AuditResult (WCAG-keyed, schema v2). Re-run `audit`.");
58906
+ return 2;
58907
+ }
58908
+ result = parsed;
58909
+ } catch {
58910
+ console.error(`ultra11y audit: --in file not found or not valid JSON: ${inPath}.`);
58911
+ return 2;
58912
+ }
58913
+ const lang = resolveLang(p.flags, { audit: result });
58914
+ const failOnRaw = p.flags["fail-on"];
58915
+ const failOnParsed = parseFailOn(failOnRaw);
58916
+ if (failOnRaw !== void 0 && failOnParsed === null) {
58917
+ console.error(`ultra11y audit: --fail-on must be blocking|major|minor (got "${String(failOnRaw)}").`);
58918
+ return 2;
58919
+ }
58920
+ const ciFormat = parseCiFormat(p.flags.format);
58921
+ if (ciFormat === null) {
58922
+ console.error(`ultra11y audit: --format must be sarif|github (got "${String(p.flags.format)}").`);
58923
+ return 2;
58924
+ }
58925
+ const failOnSet = failOnRaw !== void 0;
58926
+ const failOn = failOnSet ? failOnParsed ?? "bloquant" : void 0;
58927
+ const failing = failOn ? findingsAtOrAbove(result.findings, failOn) : [];
58928
+ if (ciFormat) emitCiFormat(result, ciFormat, CORE2, lang, failOn);
58929
+ else if (p.flags.json) console.log(JSON.stringify(result, null, 2));
58930
+ else {
58931
+ console.log(auditSummary(result, lang));
58932
+ if (failOnSet && failing.length)
58933
+ console.error(lang === "fr" ? `\u2717 ${failing.length} non-conformit\xE9(s) \u2265 ${failOn}.` : `\u2717 ${failing.length} non-conformity(ies) \u2265 ${failOn}.`);
58934
+ }
58935
+ return failing.length ? 1 : 0;
58936
+ }
58872
58937
  async function cmdAudit(p) {
58938
+ const inFlag = p.flags.in;
58939
+ if (typeof inFlag === "string" && inFlag) return cmdAuditFromFile(p, inFlag);
58873
58940
  const inputs = p.positionals.length ? p.positionals : ["."];
58874
58941
  if (inputs.length === 0) {
58875
58942
  console.error("ultra11y audit: provide files/globs, or '-' to read stdin.");