ultra11y 4.4.1 → 4.5.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -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": "4.4.1",
13
+ "version": "4.5.1",
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": "4.4.1",
4
+ "version": "4.5.1",
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": "4.4.1",
4
+ "version": "4.5.1",
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 = "4.4.1";
30
+ const ULTRA11Y_PLUGIN_VERSION = "4.5.1";
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@v3
128
+ - uses: maxgfr/ultra11y@v4
129
129
  with: { since: auto, standard: rgaa, fail-on: blocking }
130
130
  ```
131
131
 
@@ -383,7 +383,7 @@ a test). See [`CONTRIBUTING.md`](CONTRIBUTING.md) and `skills/ultra11y/reference
383
383
  block. It re-decides nothing and invents no format, so a page sheet and the compliance report
384
384
  cannot disagree; `check` gates it against invented criteria like any other report.
385
385
  See `references/pages.md`.
386
- - **A shipped GitHub Action** — `uses: maxgfr/ultra11y@main` audits the **code** (PR diff) and,
386
+ - **A shipped GitHub Action** — `uses: maxgfr/ultra11y@v4` audits the **code** (PR diff) and,
387
387
  optionally, the **pages** (it can start your app, wait for it, then scan real URLs or your
388
388
  declared sample). The engine ships inside the action, so there is nothing to install and no
389
389
  `setup-node`. The gate runs **last**, after SARIF, annotations, the summary, the sticky PR
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "ultra11y",
3
- "version": "4.4.1",
3
+ "version": "4.5.1",
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",
@@ -13,6 +13,7 @@
13
13
  // node scripts/build-standards.mjs --offline # same (alias; the snapshots are always local)
14
14
  // node scripts/build-standards.mjs --refresh <dir> # re-derive the vendored AA snapshot from a w3c/wcag checkout
15
15
  // node scripts/build-standards.mjs --refresh-universe # re-fetch (network) the vendored FULL SC universe (all levels + removed 4.1.1)
16
+ // node scripts/build-standards.mjs --refresh-core # re-derive the shipped AA snapshot from that universe (no checkout, no network)
16
17
  // node scripts/build-standards.mjs --refresh-fr # re-fetch (network) the vendored French SC/guideline/principle titles
17
18
  // node scripts/build-standards.mjs --refresh-text # re-fetch (network) the vendored NORMATIVE SC text + the WCAG glossary
18
19
  import { writeFileSync, readFileSync, readdirSync, existsSync, mkdirSync } from "node:fs";
@@ -398,6 +399,49 @@ async function deriveFr() {
398
399
  return out;
399
400
  }
400
401
 
402
+ // ---------------------------------------------------------------------------
403
+ // Core mode: the shipped AA snapshot, derived from the vendored UNIVERSE.
404
+ //
405
+ // `deriveSnapshot` above reads a local w3c/wcag checkout, which is why it was the one
406
+ // vendored source the daily refresh could not touch — a new or renamed success criterion
407
+ // would land in the universe and never reach the shipped core.
408
+ //
409
+ // It needs no checkout and no extra network: `--refresh-universe` already fetches the same
410
+ // numbering and titles for EVERY criterion, and records which are `core-AA`. The AA slice
411
+ // is that filter. Byte-identical to what the checkout produces (tests/wcag-core-sync).
412
+ //
413
+ // node scripts/build-standards.mjs --refresh-core
414
+ // ---------------------------------------------------------------------------
415
+ function deriveCoreFromUniverse() {
416
+ if (!existsSync(VENDOR_UNIVERSE)) {
417
+ console.error(`build-standards --refresh-core: missing ${VENDOR_UNIVERSE}. Run: node scripts/build-standards.mjs --refresh-universe`);
418
+ process.exit(1);
419
+ }
420
+ const universe = JSON.parse(readFileSync(VENDOR_UNIVERSE, "utf8"));
421
+ const aa = universe.criteria.filter((c) => c.status === "core-AA").map(({ status, ...rest }) => rest);
422
+ if (!aa.length) {
423
+ console.error(`build-standards --refresh-core: ${VENDOR_UNIVERSE} classifies no criterion as core-AA — refusing to write an empty core.`);
424
+ process.exit(1);
425
+ }
426
+ const used = new Set(aa.map((c) => c.guideline));
427
+ const snapshot = {
428
+ wcagVersion: universe.wcagVersion,
429
+ source: universe.source,
430
+ criteriaSource: universe.criteriaSource,
431
+ principles: universe.principles.map((p) => ({ number: p.number, title: p.title })),
432
+ guidelines: universe.guidelines.filter((g) => used.has(g.number)).map((g) => ({ number: g.number, title: g.title })),
433
+ criteria: aa,
434
+ };
435
+ return JSON.stringify(snapshot, null, 2) + "\n";
436
+ }
437
+
438
+ function refreshCore() {
439
+ const text = deriveCoreFromUniverse();
440
+ mkdirSync(dirname(VENDOR), { recursive: true });
441
+ writeFileSync(VENDOR, text);
442
+ console.log(`build-standards --refresh-core: ${JSON.parse(text).criteria.length} A/AA criteria derived from the vendored universe → ${VENDOR}`);
443
+ }
444
+
401
445
  // ---------------------------------------------------------------------------
402
446
  // Text mode: the NORMATIVE WORDING of every shipped success criterion, plus the terms
403
447
  // WCAG itself defines.
@@ -830,6 +874,7 @@ async function main() {
830
874
  const refreshIdx = process.argv.indexOf("--refresh");
831
875
  if (refreshIdx !== -1) deriveSnapshot(process.argv[refreshIdx + 1]);
832
876
  if (process.argv.includes("--refresh-universe")) await deriveUniverse();
877
+ if (process.argv.includes("--refresh-core")) refreshCore();
833
878
  if (process.argv.includes("--refresh-fr")) await deriveFr();
834
879
  if (process.argv.includes("--refresh-text")) await deriveText();
835
880
  build();
@@ -6,7 +6,7 @@ import { join as join50, relative as relative5, resolve as resolve15, sep as sep
6
6
  import { fileURLToPath as fileURLToPath5, pathToFileURL as pathToFileURL3 } from "url";
7
7
 
8
8
  // src/types.ts
9
- var VERSION = "4.4.1";
9
+ var VERSION = "4.5.1";
10
10
  var SCHEMA_VERSION = 2;
11
11
 
12
12
  // src/audit.ts
@@ -2289,7 +2289,7 @@ var wcag_universe_default = {
2289
2289
  wcagVersion: "2.2",
2290
2290
  source: "https://www.w3.org/TR/WCAG22/",
2291
2291
  criteriaSource: "https://github.com/w3c/wcag",
2292
- provenance: "Full WCAG 2.x SC universe (all levels incl. AAA, and the removed 4.1.1 Parsing) fetched from raw.githubusercontent.com/w3c/wcag@main on 2026-08-14 via `node scripts/build-standards.mjs --refresh-universe`. Classification: core-AA = ships in src/data/wcag.json (the shipped WCAG 2.2 AA core); out-of-core = WCAG AAA; removed = obsolete (4.1.1).",
2292
+ provenance: "Full WCAG 2.x SC universe (all levels incl. AAA, and the removed 4.1.1 Parsing) fetched from raw.githubusercontent.com/w3c/wcag@main on 2026-08-17 via `node scripts/build-standards.mjs --refresh-universe`. Classification: core-AA = ships in src/data/wcag.json (the shipped WCAG 2.2 AA core); out-of-core = WCAG AAA; removed = obsolete (4.1.1).",
2293
2293
  criteria: [
2294
2294
  {
2295
2295
  id: "1.1.1",
@@ -58352,6 +58352,20 @@ var LIVE_REGION_DETAIL = {
58352
58352
  function clicksAllowed(storageState, interactClicks) {
58353
58353
  return interactClicks === true || !storageState;
58354
58354
  }
58355
+ function landedOnRequestedPage(requested, landed) {
58356
+ if (!landed || requested === landed) return true;
58357
+ const path = (u) => {
58358
+ try {
58359
+ return new URL(u, "http://x.invalid").pathname.replace(/\/+$/, "");
58360
+ } catch {
58361
+ return void 0;
58362
+ }
58363
+ };
58364
+ const a = path(requested);
58365
+ const b = path(landed);
58366
+ if (a === void 0 || b === void 0) return true;
58367
+ return a === b;
58368
+ }
58355
58369
  async function probeLiveRegion(page, lang, allowClicks) {
58356
58370
  const detail = LIVE_REGION_DETAIL[lang] ?? LIVE_REGION_DETAIL.en;
58357
58371
  return await page.evaluate(liveRegionExpr(detail, allowClicks)).catch(() => []);
@@ -58520,6 +58534,7 @@ async function runSampleScanLocal(pages, opts) {
58520
58534
  const browser = await launchChromium(chromium);
58521
58535
  const findings = [];
58522
58536
  const snapshots = [];
58537
+ const redirected = [];
58523
58538
  try {
58524
58539
  for (const page of pages) {
58525
58540
  const storageState = page.storageState ?? opts.storageState;
@@ -58531,6 +58546,10 @@ async function runSampleScanLocal(pages, opts) {
58531
58546
  lang,
58532
58547
  snapshot: Boolean(opts.snapshotRoot)
58533
58548
  });
58549
+ if (!landedOnRequestedPage(page.url, out2.url)) {
58550
+ redirected.push({ id: page.id, name: page.name, requested: page.url, landed: out2.url });
58551
+ continue;
58552
+ }
58534
58553
  findings.push(...tagSampleFindings(toDynamicResult(out2, page.url, lang, LOCAL_ENGINE).findings, page));
58535
58554
  const id = opts.snapshotRoot ? writeRunnerSnapshot(opts.snapshotRoot, out2, page.url, page) : void 0;
58536
58555
  if (id) snapshots.push(id);
@@ -58546,7 +58565,8 @@ async function runSampleScanLocal(pages, opts) {
58546
58565
  findings,
58547
58566
  sample: sampleScope({ pages }),
58548
58567
  testedScs: localTestedScs(interact),
58549
- ...snapshots.length ? { snapshots } : {}
58568
+ ...snapshots.length ? { snapshots } : {},
58569
+ ...redirected.length ? { redirected } : {}
58550
58570
  };
58551
58571
  }
58552
58572
  async function runCrawlScanLocal(opts) {
@@ -66645,6 +66665,13 @@ async function cmdScan(p) {
66645
66665
  console.error(`ultra11y scan: ${e instanceof Error ? e.message : String(e)}`);
66646
66666
  return 1;
66647
66667
  }
66668
+ if (dynamic.redirected?.length) {
66669
+ for (const r of dynamic.redirected) {
66670
+ console.error(
66671
+ lang === "fr" ? `\u26A0\uFE0F ultra11y scan : \xAB ${r.name} \xBB (${r.id}) non enregistr\xE9e \u2014 ${r.requested} a redirig\xE9 vers ${r.landed}. L'enregistrer aurait d\xE9crit cet \xE9cran sous le nom du premier.` : `\u26A0\uFE0F ultra11y scan: "${r.name}" (${r.id}) not recorded \u2014 ${r.requested} redirected to ${r.landed}. Recording it would have described that screen under the first one's name.`
66672
+ );
66673
+ }
66674
+ }
66648
66675
  if (useSample && sampleConfig) {
66649
66676
  const lintKey = typeof p.flags.standard === "string" && p.flags.standard ? p.flags.standard : "rgaa";
66650
66677
  try {
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "source": "https://www.w3.org/Translations/WCAG22-fr/",
3
- "fetchedAt": "2026-08-14",
3
+ "fetchedAt": "2026-08-17",
4
4
  "principles": {
5
5
  "1": "Perceptible",
6
6
  "2": "Utilisable",
@@ -2,7 +2,7 @@
2
2
  "wcagVersion": "2.2",
3
3
  "source": "https://www.w3.org/TR/WCAG22/",
4
4
  "criteriaSource": "https://github.com/w3c/wcag",
5
- "provenance": "Full WCAG 2.x SC universe (all levels incl. AAA, and the removed 4.1.1 Parsing) fetched from raw.githubusercontent.com/w3c/wcag@main on 2026-08-14 via `node scripts/build-standards.mjs --refresh-universe`. Classification: core-AA = ships in src/data/wcag.json (the shipped WCAG 2.2 AA core); out-of-core = WCAG AAA; removed = obsolete (4.1.1).",
5
+ "provenance": "Full WCAG 2.x SC universe (all levels incl. AAA, and the removed 4.1.1 Parsing) fetched from raw.githubusercontent.com/w3c/wcag@main on 2026-08-17 via `node scripts/build-standards.mjs --refresh-universe`. Classification: core-AA = ships in src/data/wcag.json (the shipped WCAG 2.2 AA core); out-of-core = WCAG AAA; removed = obsolete (4.1.1).",
6
6
  "principles": [
7
7
  {
8
8
  "number": 1,
@@ -2,7 +2,7 @@
2
2
  "source": "https://github.com/w3c/wcag",
3
3
  "understanding": "https://www.w3.org/WAI/WCAG22/Understanding/",
4
4
  "license": "W3C Document License (code samples: W3C Software License)",
5
- "fetchedAt": "2026-08-14",
5
+ "fetchedAt": "2026-08-17",
6
6
  "criteria": {
7
7
  "1.1.1": {
8
8
  "slug": "non-text-content",
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "source": "https://github.com/w3c/wcag",
3
3
  "license": "W3C Document License",
4
- "fetchedAt": "2026-08-14",
4
+ "fetchedAt": "2026-08-17",
5
5
  "criteria": {
6
6
  "1.1.1": "All non-text content that is presented to the user has a text alternative that serves the equivalent purpose, except for the situations listed below.\nControls, Input:\nIf non-text content is a control or accepts user input, then it has a name that describes its purpose. (Refer to Success Criterion 4.1.2 for additional requirements for controls and content that accepts user input.)\nTime-Based Media:\nIf non-text content is time-based media, then text alternatives at least provide descriptive identification of the non-text content. (Refer to Guideline 1.2 for additional requirements for media.)\nTest:\nIf non-text content is a test or exercise that would be invalid if presented in text, then text alternatives at least provide descriptive identification of the non-text content.\nSensory:\nIf non-text content is primarily intended to create a specific sensory experience, then text alternatives at least provide descriptive identification of the non-text content.\nCAPTCHA:\nIf the purpose of non-text content is to confirm that content is being accessed by a person rather than a computer, then text alternatives that identify and describe the purpose of the non-text content are provided, and alternative forms of CAPTCHA using output modes for different types of sensory perception are provided to accommodate different disabilities.\nDecoration, Formatting, Invisible:\nIf non-text content is pure decoration, is used only for visual formatting, or is not presented to users, then it is implemented in a way that it can be ignored by assistive technology.",
7
7
  "1.2.1": "For prerecorded audio-only and prerecorded video-only media, the following are true, except when the audio or video is a media alternative for text and is clearly labeled as such:\nPrerecorded Audio-only:\nAn alternative for time-based media is provided that presents equivalent information for prerecorded audio-only content.\nPrerecorded Video-only:\nEither an alternative for time-based media or an audio track is provided that presents equivalent information for prerecorded video-only content.",
@@ -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: 4.4.1
8
+ version: 4.5.1
9
9
  ---
10
10
 
11
11
  # review-a11y — accessibility review of changed code
@@ -6,7 +6,7 @@ import { join as join50, relative as relative5, resolve as resolve15, sep as sep
6
6
  import { fileURLToPath as fileURLToPath5, pathToFileURL as pathToFileURL3 } from "url";
7
7
 
8
8
  // src/types.ts
9
- var VERSION = "4.4.1";
9
+ var VERSION = "4.5.1";
10
10
  var SCHEMA_VERSION = 2;
11
11
 
12
12
  // src/audit.ts
@@ -2289,7 +2289,7 @@ var wcag_universe_default = {
2289
2289
  wcagVersion: "2.2",
2290
2290
  source: "https://www.w3.org/TR/WCAG22/",
2291
2291
  criteriaSource: "https://github.com/w3c/wcag",
2292
- provenance: "Full WCAG 2.x SC universe (all levels incl. AAA, and the removed 4.1.1 Parsing) fetched from raw.githubusercontent.com/w3c/wcag@main on 2026-08-14 via `node scripts/build-standards.mjs --refresh-universe`. Classification: core-AA = ships in src/data/wcag.json (the shipped WCAG 2.2 AA core); out-of-core = WCAG AAA; removed = obsolete (4.1.1).",
2292
+ provenance: "Full WCAG 2.x SC universe (all levels incl. AAA, and the removed 4.1.1 Parsing) fetched from raw.githubusercontent.com/w3c/wcag@main on 2026-08-17 via `node scripts/build-standards.mjs --refresh-universe`. Classification: core-AA = ships in src/data/wcag.json (the shipped WCAG 2.2 AA core); out-of-core = WCAG AAA; removed = obsolete (4.1.1).",
2293
2293
  criteria: [
2294
2294
  {
2295
2295
  id: "1.1.1",
@@ -58352,6 +58352,20 @@ var LIVE_REGION_DETAIL = {
58352
58352
  function clicksAllowed(storageState, interactClicks) {
58353
58353
  return interactClicks === true || !storageState;
58354
58354
  }
58355
+ function landedOnRequestedPage(requested, landed) {
58356
+ if (!landed || requested === landed) return true;
58357
+ const path = (u) => {
58358
+ try {
58359
+ return new URL(u, "http://x.invalid").pathname.replace(/\/+$/, "");
58360
+ } catch {
58361
+ return void 0;
58362
+ }
58363
+ };
58364
+ const a = path(requested);
58365
+ const b = path(landed);
58366
+ if (a === void 0 || b === void 0) return true;
58367
+ return a === b;
58368
+ }
58355
58369
  async function probeLiveRegion(page, lang, allowClicks) {
58356
58370
  const detail = LIVE_REGION_DETAIL[lang] ?? LIVE_REGION_DETAIL.en;
58357
58371
  return await page.evaluate(liveRegionExpr(detail, allowClicks)).catch(() => []);
@@ -58520,6 +58534,7 @@ async function runSampleScanLocal(pages, opts) {
58520
58534
  const browser = await launchChromium(chromium);
58521
58535
  const findings = [];
58522
58536
  const snapshots = [];
58537
+ const redirected = [];
58523
58538
  try {
58524
58539
  for (const page of pages) {
58525
58540
  const storageState = page.storageState ?? opts.storageState;
@@ -58531,6 +58546,10 @@ async function runSampleScanLocal(pages, opts) {
58531
58546
  lang,
58532
58547
  snapshot: Boolean(opts.snapshotRoot)
58533
58548
  });
58549
+ if (!landedOnRequestedPage(page.url, out2.url)) {
58550
+ redirected.push({ id: page.id, name: page.name, requested: page.url, landed: out2.url });
58551
+ continue;
58552
+ }
58534
58553
  findings.push(...tagSampleFindings(toDynamicResult(out2, page.url, lang, LOCAL_ENGINE).findings, page));
58535
58554
  const id = opts.snapshotRoot ? writeRunnerSnapshot(opts.snapshotRoot, out2, page.url, page) : void 0;
58536
58555
  if (id) snapshots.push(id);
@@ -58546,7 +58565,8 @@ async function runSampleScanLocal(pages, opts) {
58546
58565
  findings,
58547
58566
  sample: sampleScope({ pages }),
58548
58567
  testedScs: localTestedScs(interact),
58549
- ...snapshots.length ? { snapshots } : {}
58568
+ ...snapshots.length ? { snapshots } : {},
58569
+ ...redirected.length ? { redirected } : {}
58550
58570
  };
58551
58571
  }
58552
58572
  async function runCrawlScanLocal(opts) {
@@ -66645,6 +66665,13 @@ async function cmdScan(p) {
66645
66665
  console.error(`ultra11y scan: ${e instanceof Error ? e.message : String(e)}`);
66646
66666
  return 1;
66647
66667
  }
66668
+ if (dynamic.redirected?.length) {
66669
+ for (const r of dynamic.redirected) {
66670
+ console.error(
66671
+ lang === "fr" ? `\u26A0\uFE0F ultra11y scan : \xAB ${r.name} \xBB (${r.id}) non enregistr\xE9e \u2014 ${r.requested} a redirig\xE9 vers ${r.landed}. L'enregistrer aurait d\xE9crit cet \xE9cran sous le nom du premier.` : `\u26A0\uFE0F ultra11y scan: "${r.name}" (${r.id}) not recorded \u2014 ${r.requested} redirected to ${r.landed}. Recording it would have described that screen under the first one's name.`
66672
+ );
66673
+ }
66674
+ }
66648
66675
  if (useSample && sampleConfig) {
66649
66676
  const lintKey = typeof p.flags.standard === "string" && p.flags.standard ? p.flags.standard : "rgaa";
66650
66677
  try {
@@ -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: 4.4.1
7
+ version: 4.5.1
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@v3 # or pin the exact version, as `init --ci` does
18
+ - uses: maxgfr/ultra11y@v4 # 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@v3
30
+ - uses: maxgfr/ultra11y@v4
31
31
  with:
32
32
  standard: rgaa
33
33
  start: npm run start
@@ -66,7 +66,7 @@ partial by construction. `adjudicate` closes that — opt-in, in two modes.
66
66
  env:
67
67
  ANTHROPIC_API_KEY: ${{ secrets.ANTHROPIC_API_KEY }} # the job's env, never an input
68
68
  steps:
69
- - uses: maxgfr/ultra11y@v3
69
+ - uses: maxgfr/ultra11y@v4
70
70
  with:
71
71
  standard: rgaa
72
72
  adjudicate: api # or `agent`
@@ -89,14 +89,43 @@ open the cited files and read around them, which is what *link purpose **in cont
89
89
  asks for. Expect the API mode to answer `manual` more often; that is it being honest, not
90
90
  broken. The metric that separates them is the count of criteria left to assess.
91
91
 
92
- **Both modes read `ANTHROPIC_API_KEY` from the job environment, never from an input** — a
92
+ **Both modes read their credential from the job environment, never from an input** — a
93
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
94
+ fewer secret to leak into a log. When it is absent the tier **skips itself**: the job stays
95
95
  green, the report is still written, and the criteria stay « à évaluer ». That is not an edge
96
96
  case — it is precisely what a **fork's pull request** looks like, where secrets are never
97
97
  exposed. An adjudication that fails mid-flight (a rate-limited batch refuses the whole fold) is
98
98
  absorbed the same way, because losing the verdicts must never cost you the audit.
99
99
 
100
+ **The two modes do not take the same credential.** `api` runs `judge`, which speaks `x-api-key`
101
+ to `api.anthropic.com`; nothing else stands in for `ANTHROPIC_API_KEY` there. `agent` shells out
102
+ to `claude-code-action`, which accepts **either** an API key **or** the OAuth token a Pro/Max
103
+ subscriber mints with `claude setup-token` — so a repository that already runs Claude Code in CI
104
+ and holds only `CLAUDE_CODE_OAUTH_TOKEN` can adjudicate without buying API credit. Set whichever
105
+ you have; set both and the API key wins.
106
+
107
+ ```yaml
108
+ env:
109
+ # `agent` takes either of these. `api` takes only the first.
110
+ ANTHROPIC_API_KEY: ${{ secrets.ANTHROPIC_API_KEY }}
111
+ CLAUDE_CODE_OAUTH_TOKEN: ${{ secrets.CLAUDE_CODE_OAUTH_TOKEN }}
112
+ ```
113
+
114
+ Asking for `api` with only the OAuth token is a warning, not a failure — the tier skips and says
115
+ which credential it would have needed, rather than failing a request the Messages API was always
116
+ going to reject.
117
+
118
+ **`agent` cannot run on every event.** `claude-code-action` resolves the GitHub event context
119
+ *before* it reads the prompt, and throws `Unsupported event type: <event>` on anything outside
120
+ `pull_request`, `pull_request_target`, `pull_request_review(_comment)`, `issues`,
121
+ `issue_comment`, `workflow_dispatch`, `repository_dispatch`, `schedule` and `workflow_run`.
122
+ **`push` is not in that list** — and `push` is exactly what an accessibility gate runs on. So the
123
+ tier checks the event and degrades to a warning there, the same way it does without a credential:
124
+ an a11y job must never go red for a reason that has nothing to do with accessibility. Put the
125
+ agent tier on the `pull_request` or `schedule` job, or use `adjudicate: api`, which has no such
126
+ constraint. The action also hands `claude-code-action` the job's own `GITHUB_TOKEN`, so this tier
127
+ never obliges you to add `id-token: write` for a step that makes no GitHub API call.
128
+
100
129
  **Cost is per run and does not amortise.** Roughly $0.20 for a WCAG run and $0.50 for RGAA at
101
130
  Sonnet pricing — the worklist is re-sent whole each time and no batch is large enough to earn
102
131
  prompt caching. On a busy repository that is real money per push; this belongs on the default
@@ -235,7 +264,7 @@ The action audits the **code** and, when you point it at a served app, the **pag
235
264
  list can come from three places, and none of them has to be written by hand:
236
265
 
237
266
  ```yaml
238
- - uses: maxgfr/ultra11y@v3
267
+ - uses: maxgfr/ultra11y@v4
239
268
  with:
240
269
  standard: rgaa
241
270
  start: npm run start
@@ -262,6 +291,40 @@ Two surfaces come out of it:
262
291
 
263
292
  A run with no page in scope is not a failure: the report step says so and the job carries on.
264
293
 
294
+ ### Pages behind a login (`storage-state`)
295
+
296
+ The pages worth auditing are rarely the public ones. A form-heavy funnel, a dashboard, a
297
+ back-office — all of them sit behind a sign-in, and a scan that reaches them anonymously does
298
+ not fail: it records **the sign-in screen** under the name of the page you asked for. That is
299
+ the worst failure mode an accessibility report has, because the document looks complete.
300
+
301
+ Produce a Playwright `storageState` in the job — most repositories already have one, written by
302
+ the setup project their E2E suite depends on — and hand the action its path:
303
+
304
+ ```yaml
305
+ - run: npx playwright test --project=setup # your existing login, writing the session file
306
+ - uses: maxgfr/ultra11y@v4
307
+ with:
308
+ standard: rgaa
309
+ sample: 'true'
310
+ storage-state: test-results/.auth/user.json
311
+ ```
312
+
313
+ The path is forwarded to Playwright as a path and never read by the engine, so a session file
314
+ cannot end up in the job log. A per-page `storageState` in `.ultra11yrc.json` **overrides** this
315
+ one, so a sample can mix public and signed-in pages in a single run.
316
+
317
+ Two things worth knowing about an authenticated scan:
318
+
319
+ - **The click probe is off.** With a session loaded, a click can trigger a real mutation on a
320
+ real record. The live-region probe still fills inputs and re-measures, but it does not click
321
+ buttons unless you set `interact-clicks: 'true'`. (Buttons whose accessible name reads
322
+ destructive are never clicked, session or not.)
323
+ - **A session is not a state machine.** Being signed in gets you past the login; it does not put
324
+ a multi-step funnel on step 4. If a route is guarded by application state, seed that state in
325
+ the job before scanning — otherwise the page redirects and you are back to auditing the wrong
326
+ screen under the right name.
327
+
265
328
  ## What the artifact looks like when you open it
266
329
 
267
330
  `audits/` used to hold JSON, SARIF and Markdown: everything a machine needs and nothing that
@@ -395,9 +458,9 @@ one job — the code diff, then the served pages — died on a `409 Conflict`, w
395
458
  written and never uploaded. Pass `artifact-name` on each invocation:
396
459
 
397
460
  ```yaml
398
- - uses: maxgfr/ultra11y@v3
461
+ - uses: maxgfr/ultra11y@v4
399
462
  with: { since: auto, artifact-name: a11y-code }
400
- - uses: maxgfr/ultra11y@v3
463
+ - uses: maxgfr/ultra11y@v4
401
464
  with: { crawl: http://localhost:3000, artifact-name: a11y-pages }
402
465
  ```
403
466
 
@@ -220,7 +220,17 @@ Sheets are named `page-<id>.md`: a page whose id is `index` — the ordinary id
220
220
  gates that mean something here — above all **no invented criterion** — instead of demanding
221
221
  the §1–5 structure of a conformance document.
222
222
 
223
- ### Three honesty rules
223
+ ### Four honesty rules
224
+
225
+ **0. A page is recorded only if the browser stayed on it.** A sample page's declared `id` and
226
+ `name` are the identity the report speaks, and they are applied to whatever was on screen. So a
227
+ route that redirects — an expired session bouncing to sign-in, a wizard step the application
228
+ state does not open — would be filed under the requested page's name while showing something
229
+ else: a sheet, a screenshot and a rate that describe a different screen, with nothing about the
230
+ document looking wrong. `scan --sample` compares the landed **path** against the requested one
231
+ (a query string or fragment the app appends is the same page) and **drops** a page that moved,
232
+ naming it and where it went. A page reported missing is a bug in the sample or in the state you
233
+ seeded, and both are fixable; a page reported under the wrong name is a false conformance claim.
224
234
 
225
235
  **1. A finding is attributed to a page only when something says so.** In order: the snapshot
226
236
  path it was raised on (`.ultra11y/pages/<id>/dom.html` — the id is *in the path*, which is what
@@ -6,7 +6,7 @@ import { join as join50, relative as relative5, resolve as resolve15, sep as sep
6
6
  import { fileURLToPath as fileURLToPath5, pathToFileURL as pathToFileURL3 } from "url";
7
7
 
8
8
  // src/types.ts
9
- var VERSION = "4.4.1";
9
+ var VERSION = "4.5.1";
10
10
  var SCHEMA_VERSION = 2;
11
11
 
12
12
  // src/audit.ts
@@ -2289,7 +2289,7 @@ var wcag_universe_default = {
2289
2289
  wcagVersion: "2.2",
2290
2290
  source: "https://www.w3.org/TR/WCAG22/",
2291
2291
  criteriaSource: "https://github.com/w3c/wcag",
2292
- provenance: "Full WCAG 2.x SC universe (all levels incl. AAA, and the removed 4.1.1 Parsing) fetched from raw.githubusercontent.com/w3c/wcag@main on 2026-08-14 via `node scripts/build-standards.mjs --refresh-universe`. Classification: core-AA = ships in src/data/wcag.json (the shipped WCAG 2.2 AA core); out-of-core = WCAG AAA; removed = obsolete (4.1.1).",
2292
+ provenance: "Full WCAG 2.x SC universe (all levels incl. AAA, and the removed 4.1.1 Parsing) fetched from raw.githubusercontent.com/w3c/wcag@main on 2026-08-17 via `node scripts/build-standards.mjs --refresh-universe`. Classification: core-AA = ships in src/data/wcag.json (the shipped WCAG 2.2 AA core); out-of-core = WCAG AAA; removed = obsolete (4.1.1).",
2293
2293
  criteria: [
2294
2294
  {
2295
2295
  id: "1.1.1",
@@ -58352,6 +58352,20 @@ var LIVE_REGION_DETAIL = {
58352
58352
  function clicksAllowed(storageState, interactClicks) {
58353
58353
  return interactClicks === true || !storageState;
58354
58354
  }
58355
+ function landedOnRequestedPage(requested, landed) {
58356
+ if (!landed || requested === landed) return true;
58357
+ const path = (u) => {
58358
+ try {
58359
+ return new URL(u, "http://x.invalid").pathname.replace(/\/+$/, "");
58360
+ } catch {
58361
+ return void 0;
58362
+ }
58363
+ };
58364
+ const a = path(requested);
58365
+ const b = path(landed);
58366
+ if (a === void 0 || b === void 0) return true;
58367
+ return a === b;
58368
+ }
58355
58369
  async function probeLiveRegion(page, lang, allowClicks) {
58356
58370
  const detail = LIVE_REGION_DETAIL[lang] ?? LIVE_REGION_DETAIL.en;
58357
58371
  return await page.evaluate(liveRegionExpr(detail, allowClicks)).catch(() => []);
@@ -58520,6 +58534,7 @@ async function runSampleScanLocal(pages, opts) {
58520
58534
  const browser = await launchChromium(chromium);
58521
58535
  const findings = [];
58522
58536
  const snapshots = [];
58537
+ const redirected = [];
58523
58538
  try {
58524
58539
  for (const page of pages) {
58525
58540
  const storageState = page.storageState ?? opts.storageState;
@@ -58531,6 +58546,10 @@ async function runSampleScanLocal(pages, opts) {
58531
58546
  lang,
58532
58547
  snapshot: Boolean(opts.snapshotRoot)
58533
58548
  });
58549
+ if (!landedOnRequestedPage(page.url, out2.url)) {
58550
+ redirected.push({ id: page.id, name: page.name, requested: page.url, landed: out2.url });
58551
+ continue;
58552
+ }
58534
58553
  findings.push(...tagSampleFindings(toDynamicResult(out2, page.url, lang, LOCAL_ENGINE).findings, page));
58535
58554
  const id = opts.snapshotRoot ? writeRunnerSnapshot(opts.snapshotRoot, out2, page.url, page) : void 0;
58536
58555
  if (id) snapshots.push(id);
@@ -58546,7 +58565,8 @@ async function runSampleScanLocal(pages, opts) {
58546
58565
  findings,
58547
58566
  sample: sampleScope({ pages }),
58548
58567
  testedScs: localTestedScs(interact),
58549
- ...snapshots.length ? { snapshots } : {}
58568
+ ...snapshots.length ? { snapshots } : {},
58569
+ ...redirected.length ? { redirected } : {}
58550
58570
  };
58551
58571
  }
58552
58572
  async function runCrawlScanLocal(opts) {
@@ -66645,6 +66665,13 @@ async function cmdScan(p) {
66645
66665
  console.error(`ultra11y scan: ${e instanceof Error ? e.message : String(e)}`);
66646
66666
  return 1;
66647
66667
  }
66668
+ if (dynamic.redirected?.length) {
66669
+ for (const r of dynamic.redirected) {
66670
+ console.error(
66671
+ lang === "fr" ? `\u26A0\uFE0F ultra11y scan : \xAB ${r.name} \xBB (${r.id}) non enregistr\xE9e \u2014 ${r.requested} a redirig\xE9 vers ${r.landed}. L'enregistrer aurait d\xE9crit cet \xE9cran sous le nom du premier.` : `\u26A0\uFE0F ultra11y scan: "${r.name}" (${r.id}) not recorded \u2014 ${r.requested} redirected to ${r.landed}. Recording it would have described that screen under the first one's name.`
66672
+ );
66673
+ }
66674
+ }
66648
66675
  if (useSample && sampleConfig) {
66649
66676
  const lintKey = typeof p.flags.standard === "string" && p.flags.standard ? p.flags.standard : "rgaa";
66650
66677
  try {
package/src/cli.ts CHANGED
@@ -3044,6 +3044,18 @@ async function cmdScan(p: ParsedArgs): Promise<number> {
3044
3044
  console.error(`ultra11y scan: ${e instanceof Error ? e.message : String(e)}`);
3045
3045
  return 1;
3046
3046
  }
3047
+ // Pages the scan refused to file, because the browser ended up on another route. Loud, and
3048
+ // on stderr: a silently shorter sample is indistinguishable from a sample that passed, and
3049
+ // the cause is always fixable — an expired session, or state the wizard step needs.
3050
+ if (dynamic.redirected?.length) {
3051
+ for (const r of dynamic.redirected) {
3052
+ console.error(
3053
+ lang === "fr"
3054
+ ? `⚠️ ultra11y scan : « ${r.name} » (${r.id}) non enregistrée — ${r.requested} a redirigé vers ${r.landed}. L'enregistrer aurait décrit cet écran sous le nom du premier.`
3055
+ : `⚠️ ultra11y scan: "${r.name}" (${r.id}) not recorded — ${r.requested} redirected to ${r.landed}. Recording it would have described that screen under the first one's name.`,
3056
+ );
3057
+ }
3058
+ }
3047
3059
  // Advisory sample-methodology lint: which normative page KINDS the configured sample
3048
3060
  // lacks. Standard-agnostic mechanics + RGAA-specific required kinds; scan has no
3049
3061
  // --standard, so we lint against the config's default standard (else RGAA if registered).
@@ -2,7 +2,7 @@
2
2
  "wcagVersion": "2.2",
3
3
  "source": "https://www.w3.org/TR/WCAG22/",
4
4
  "criteriaSource": "https://github.com/w3c/wcag",
5
- "provenance": "Full WCAG 2.x SC universe (all levels incl. AAA, and the removed 4.1.1 Parsing) fetched from raw.githubusercontent.com/w3c/wcag@main on 2026-08-14 via `node scripts/build-standards.mjs --refresh-universe`. Classification: core-AA = ships in src/data/wcag.json (the shipped WCAG 2.2 AA core); out-of-core = WCAG AAA; removed = obsolete (4.1.1).",
5
+ "provenance": "Full WCAG 2.x SC universe (all levels incl. AAA, and the removed 4.1.1 Parsing) fetched from raw.githubusercontent.com/w3c/wcag@main on 2026-08-17 via `node scripts/build-standards.mjs --refresh-universe`. Classification: core-AA = ships in src/data/wcag.json (the shipped WCAG 2.2 AA core); out-of-core = WCAG AAA; removed = obsolete (4.1.1).",
6
6
  "criteria": [
7
7
  {
8
8
  "id": "1.1.1",
package/src/scan-local.ts CHANGED
@@ -11,7 +11,7 @@
11
11
  import { existsSync, statSync } from "node:fs";
12
12
  import { createRequire } from "node:module";
13
13
  import { resolve } from "node:path";
14
- import type { DynamicFinding, DynamicResult, Lang, SamplePage } from "./types.js";
14
+ import type { DynamicFinding, DynamicResult, Lang, SamplePage, ScanRedirect } from "./types.js";
15
15
  import { type DiscoverOpts, discoverUrls, type ProbeHit, type RunnerOutput, tagSampleFindings, toDynamicResult, writeRunnerSnapshot } from "./scan.js";
16
16
  import { sampleScope } from "./sample.js";
17
17
  import { COLLECT_SNAPSHOT, type CollectedPage } from "./snapshot.js";
@@ -604,6 +604,36 @@ export function clicksAllowed(storageState: string | undefined, interactClicks:
604
604
  return interactClicks === true || !storageState;
605
605
  }
606
606
 
607
+ /** Did the browser end up on the page we ASKED for?
608
+ *
609
+ * A sample page's declared id/name is the identity the report and the per-page grid speak,
610
+ * and it is applied to whatever the browser had on screen. So a route that redirects — an
611
+ * expired session bouncing to /login, a wizard step the application state does not open —
612
+ * gets recorded under the requested page's name while showing another screen entirely.
613
+ * Nothing about the resulting document looks wrong, which is what makes it the worst
614
+ * failure mode an accessibility report has: a reader sees a page sheet, a screenshot and a
615
+ * conformance rate, and none of it describes the page named at the top.
616
+ *
617
+ * Only the PATH is compared. A query string or fragment the app appends to its own route is
618
+ * the same page; a different path is a different page. Anything unparseable is treated as a
619
+ * match — this guard exists to catch a redirect, not to invent one. Exported for the
620
+ * browser-free policy test. */
621
+ export function landedOnRequestedPage(requested: string, landed: string): boolean {
622
+ if (!landed || requested === landed) return true;
623
+ const path = (u: string): string | undefined => {
624
+ try {
625
+ // `file://` targets and relative paths never redirect; base makes them parseable.
626
+ return new URL(u, "http://x.invalid").pathname.replace(/\/+$/, "");
627
+ } catch {
628
+ return undefined;
629
+ }
630
+ };
631
+ const a = path(requested);
632
+ const b = path(landed);
633
+ if (a === undefined || b === undefined) return true;
634
+ return a === b;
635
+ }
636
+
607
637
  async function probeLiveRegion(page: Any, lang: Lang, allowClicks: boolean): Promise<ProbeHit[]> {
608
638
  const detail = LIVE_REGION_DETAIL[lang] ?? LIVE_REGION_DETAIL.en;
609
639
  return (await page.evaluate(liveRegionExpr(detail, allowClicks)).catch(() => [])) as ProbeHit[];
@@ -880,6 +910,7 @@ export async function runSampleScanLocal(pages: SamplePage[], opts: LocalManyOpt
880
910
  const browser = await launchChromium(chromium);
881
911
  const findings: DynamicFinding[] = [];
882
912
  const snapshots: string[] = [];
913
+ const redirected: ScanRedirect[] = [];
883
914
  try {
884
915
  for (const page of pages) {
885
916
  const storageState = page.storageState ?? opts.storageState; // per-page override
@@ -891,6 +922,15 @@ export async function runSampleScanLocal(pages: SamplePage[], opts: LocalManyOpt
891
922
  lang,
892
923
  snapshot: Boolean(opts.snapshotRoot),
893
924
  });
925
+ // The declared identity is applied to whatever the browser had on screen, so a page
926
+ // that bounced elsewhere would be filed under the name of the page nobody looked at.
927
+ // Drop it instead, and say which one and where it went: a page reported missing is a
928
+ // bug in the sample or the seeded state, and both are fixable. A page reported under
929
+ // the wrong name is neither — it is a false conformance claim.
930
+ if (!landedOnRequestedPage(page.url, out.url)) {
931
+ redirected.push({ id: page.id, name: page.name, requested: page.url, landed: out.url });
932
+ continue;
933
+ }
894
934
  findings.push(...tagSampleFindings(toDynamicResult(out, page.url, lang, LOCAL_ENGINE).findings, page));
895
935
  // The sample page's declared id/name/auth/notes win over anything derived from the
896
936
  // URL: that identity is what the report and the per-page grid speak.
@@ -909,6 +949,7 @@ export async function runSampleScanLocal(pages: SamplePage[], opts: LocalManyOpt
909
949
  sample: sampleScope({ pages }),
910
950
  testedScs: localTestedScs(interact),
911
951
  ...(snapshots.length ? { snapshots } : {}),
952
+ ...(redirected.length ? { redirected } : {}),
912
953
  };
913
954
  }
914
955
 
package/src/types.ts CHANGED
@@ -1,6 +1,6 @@
1
1
  // Single source of truth for shared types + the embedded version. sync-version.mjs
2
2
  // rewrites VERSION at release time (kept in lockstep with package.json + SKILL.md).
3
- export const VERSION = "4.4.1";
3
+ export const VERSION = "4.5.1";
4
4
  export const SCHEMA_VERSION = 2;
5
5
 
6
6
  // Lang = the UI FRAME's language — the `L` localization tables each module (report,
@@ -47,6 +47,18 @@ export interface SampleScope {
47
47
  transverse?: string[];
48
48
  }
49
49
 
50
+ // A sample page the scan REFUSED to record, because the browser ended up somewhere else —
51
+ // a session that had expired, a wizard step the application state does not open. Recording
52
+ // it would have filed another screen under this page's name, which reads as a complete
53
+ // report and is a false conformance claim. Reported so the sample or the seeded state can
54
+ // be fixed; never silently dropped.
55
+ export interface ScanRedirect {
56
+ id: string;
57
+ name: string;
58
+ requested: string;
59
+ landed: string;
60
+ }
61
+
50
62
  // ---- WCAG 2.2 canonical core (src/data/wcag.json, produced by scripts/build-standards.mjs)
51
63
  // The engine's canonical key. Success-criterion ids/titles/levels are derived from the
52
64
  // official W3C source (https://github.com/w3c/wcag); rule coverage + automatability are
@@ -560,4 +572,7 @@ export interface DynamicResult {
560
572
  // static rules never ran against its DOM, so src/pages.ts keeps it `manual` forever).
561
573
  // Empty/absent when snapshotting was off or every collection failed. Optional/additive.
562
574
  snapshots?: string[];
575
+ // Sample pages dropped because the browser landed on a different path than the one asked
576
+ // for. Absent when every page was reached. Optional/additive — see ScanRedirect.
577
+ redirected?: ScanRedirect[];
563
578
  }