ultra11y 5.15.0 → 5.16.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": "5.15.0",
13
+ "version": "5.16.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": "5.15.0",
4
+ "version": "5.16.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": "5.15.0",
4
+ "version": "5.16.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 = "5.15.0";
30
+ const ULTRA11Y_PLUGIN_VERSION = "5.16.0";
31
31
 
32
32
  const here = dirname(fileURLToPath(import.meta.url));
33
33
 
package/README.md CHANGED
@@ -442,7 +442,7 @@ node scripts/ultra11y.mjs scan http://localhost:3000 --runtime local --cwd . --m
442
442
  node scripts/ultra11y.mjs report --in audits/audit-latest.json --out audits
443
443
  ```
444
444
 
445
- 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`.
445
+ Only the Docker runtime needs Docker. `--runtime local` needs a Chromium binary (`npx playwright install chromium`) and nothing else: `@playwright/test` and `@axe-core/playwright` are dependencies of the package, and the audited project's own copies take precedence when it has them. The engine bundle itself stays dependency-free. The Docker runner + Dockerfile are embedded in the engine and mirrored under `docker/` (with a `docker-compose.yml`). See `skills/ultra11y/references/dynamic.md`.
446
446
 
447
447
  Typical audit flow:
448
448
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "ultra11y",
3
- "version": "5.15.0",
3
+ "version": "5.16.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",
@@ -77,14 +77,14 @@
77
77
  "url": "https://github.com/maxgfr/ultra11y"
78
78
  },
79
79
  "dependencies": {
80
+ "@axe-core/playwright": "^4.13.0",
80
81
  "@babel/parser": "^8.0.0",
82
+ "@playwright/test": "^1.62.1",
81
83
  "domhandler": "^6.0.1",
82
84
  "htmlparser2": "^12.0.0"
83
85
  },
84
86
  "devDependencies": {
85
- "@axe-core/playwright": "^4.13.0",
86
87
  "@biomejs/biome": "^2.5.1",
87
- "@playwright/test": "^1.62.1",
88
88
  "@semantic-release/changelog": "^6.0.3",
89
89
  "@semantic-release/exec": "^7.1.0",
90
90
  "@semantic-release/git": "^10.0.1",
@@ -6,7 +6,7 @@ import { join as join51, relative as relative5, resolve as resolve16, sep as sep
6
6
  import { fileURLToPath as fileURLToPath5, pathToFileURL as pathToFileURL3 } from "url";
7
7
 
8
8
  // src/types.ts
9
- var VERSION = "5.15.0";
9
+ var VERSION = "5.16.0";
10
10
  var SCHEMA_VERSION = 2;
11
11
  var INAPPLICABLE_STATUS = "C";
12
12
 
@@ -2290,7 +2290,7 @@ var wcag_universe_default = {
2290
2290
  wcagVersion: "2.2",
2291
2291
  source: "https://www.w3.org/TR/WCAG22/",
2292
2292
  criteriaSource: "https://github.com/w3c/wcag",
2293
- 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-19 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
+ 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-20 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).",
2294
2294
  criteria: [
2295
2295
  {
2296
2296
  id: "1.1.1",
@@ -58678,6 +58678,9 @@ function readLedger(path) {
58678
58678
  return void 0;
58679
58679
  }
58680
58680
  }
58681
+ function unreadableCaptures(audit2, cwd = ".") {
58682
+ return (audit2.scope.pagesAudited ?? []).filter((id) => !existsSync21(join36(cwd, PAGES_DIR, id, "dom.html")));
58683
+ }
58681
58684
  function writeLedger(path, ledger) {
58682
58685
  mkdirSync10(dirname10(path), { recursive: true });
58683
58686
  const sorted = { ...ledger, entries: [...ledger.entries].sort((a, b) => a.criteriaId.localeCompare(b.criteriaId, "en", { numeric: true })) };
@@ -59891,17 +59894,38 @@ function localTestedScs(interact) {
59891
59894
  }
59892
59895
  var PW_SPEC = "@playwright/test";
59893
59896
  var AXE_SPEC = "@axe-core/playwright";
59894
- function localTierStatus(cwd) {
59895
- const req = createRequire(resolve12(cwd, "package.json"));
59896
- for (const spec of [PW_SPEC, AXE_SPEC]) {
59897
+ function resolveAnchors(cwd) {
59898
+ return [resolve12(cwd, "package.json"), import.meta.url];
59899
+ }
59900
+ function requireRuntime(cwd, spec) {
59901
+ let last;
59902
+ for (const from of resolveAnchors(cwd)) {
59897
59903
  try {
59898
- req.resolve(spec);
59904
+ return createRequire(from)(spec);
59905
+ } catch (e) {
59906
+ last = e;
59907
+ }
59908
+ }
59909
+ throw last instanceof Error ? last : new Error(String(last));
59910
+ }
59911
+ function resolvesRuntime(cwd, spec) {
59912
+ return resolveAnchors(cwd).some((from) => {
59913
+ try {
59914
+ createRequire(from).resolve(spec);
59915
+ return true;
59899
59916
  } catch {
59900
- return { ok: false, reason: `${spec} does not resolve from ${cwd}` };
59917
+ return false;
59918
+ }
59919
+ });
59920
+ }
59921
+ function localTierStatus(cwd) {
59922
+ for (const spec of [PW_SPEC, AXE_SPEC]) {
59923
+ if (!resolvesRuntime(cwd, spec)) {
59924
+ return { ok: false, reason: `${spec} resolves neither from ${cwd} nor from ultra11y itself` };
59901
59925
  }
59902
59926
  }
59903
59927
  try {
59904
- const pw = req(PW_SPEC);
59928
+ const pw = requireRuntime(cwd, PW_SPEC);
59905
59929
  const bin = pw.chromium?.executablePath?.();
59906
59930
  if (typeof bin === "string" && bin.length > 0 && !existsSync24(bin)) {
59907
59931
  return { ok: false, reason: `no browser binary at ${bin} \u2014 run \`npx playwright install chromium\`` };
@@ -59918,15 +59942,14 @@ function resolveLocalDeps(cwd) {
59918
59942
  let chromium;
59919
59943
  let AxeBuilder;
59920
59944
  try {
59921
- const req = createRequire(resolve12(cwd, "package.json"));
59922
- const pw = req(PW_SPEC);
59923
- const axeMod = req(AXE_SPEC);
59945
+ const pw = requireRuntime(cwd, PW_SPEC);
59946
+ const axeMod = requireRuntime(cwd, AXE_SPEC);
59924
59947
  chromium = pw.chromium;
59925
59948
  AxeBuilder = axeMod.default ?? axeMod;
59926
59949
  } catch (e) {
59927
59950
  const msg = e instanceof Error ? e.message : String(e);
59928
59951
  throw new Error(
59929
- `Playwright not resolvable from "${cwd}". Pass --cwd <dir> at a project with @playwright/test + @axe-core/playwright installed (e.g. --cwd packages/app), or use --runtime docker. (${msg})`
59952
+ `Playwright resolves neither from "${cwd}" nor from ultra11y itself. Both are dependencies of this package, so the usual cause is the standalone engine bundle running with no node_modules beside it: pass --cwd <dir> at a project that has @playwright/test + @axe-core/playwright (e.g. --cwd packages/app), or use --runtime docker. (${msg})`
59930
59953
  );
59931
59954
  }
59932
59955
  if (!chromium || typeof AxeBuilder !== "function") {
@@ -68713,6 +68736,11 @@ function applyAdjudicationFile(p, adj, lang) {
68713
68736
  writeFileSync20(auditPath, JSON.stringify(r.audit, null, 2) + "\n");
68714
68737
  const ledgerOut = ledgerTarget(p, adj.standard);
68715
68738
  if (ledgerOut) {
68739
+ const blind = unreadableCaptures(r.audit);
68740
+ if (blind.length)
68741
+ console.error(
68742
+ lang === "fr" ? `\u26A0 Registre \xE9crit sans les instantan\xE9s de ${blind.length} page(s) que cet audit dit avoir lues (${blind.slice(0, 5).join(", ")}${blind.length > 5 ? "\u2026" : ""}). L'\xE9vidence moissonn\xE9e ici est plus petite que celle qu'un run reconstruira, donc ces verdicts seront d\xE9clar\xE9s P\xC9RIM\xC9S au rejeu. Adjugez l\xE0 o\xF9 \`.ultra11y/pages/\` existe.` : `\u26A0 Ledger written without the snapshots of ${blind.length} page(s) this audit says it read (${blind.slice(0, 5).join(", ")}${blind.length > 5 ? "\u2026" : ""}). The evidence harvested here is smaller than the one a run will rebuild, so these verdicts will be dropped as STALE on replay. Adjudicate where \`.ultra11y/pages/\` exists.`
68743
+ );
68716
68744
  const refused = new Set(r.rejectedCriteria);
68717
68745
  const accepted = new Set(adj.items.map((it) => it.criteriaId).filter((id) => !refused.has(id)));
68718
68746
  const fresh = entriesFrom(adj, accepted, r.audit.date);
@@ -68845,6 +68873,11 @@ async function cmdJudge(p) {
68845
68873
  `);
68846
68874
  const ledgerOut = ledgerTarget(p, adj.standard);
68847
68875
  if (ledgerOut) {
68876
+ const blind = unreadableCaptures(r.audit);
68877
+ if (blind.length)
68878
+ console.error(
68879
+ lang === "fr" ? `\u26A0 Registre \xE9crit sans les instantan\xE9s de ${blind.length} page(s) que cet audit dit avoir lues (${blind.slice(0, 5).join(", ")}${blind.length > 5 ? "\u2026" : ""}). L'\xE9vidence moissonn\xE9e ici est plus petite que celle qu'un run reconstruira, donc ces verdicts seront d\xE9clar\xE9s P\xC9RIM\xC9S au rejeu. Adjugez l\xE0 o\xF9 \`.ultra11y/pages/\` existe.` : `\u26A0 Ledger written without the snapshots of ${blind.length} page(s) this audit says it read (${blind.slice(0, 5).join(", ")}${blind.length > 5 ? "\u2026" : ""}). The evidence harvested here is smaller than the one a run will rebuild, so these verdicts will be dropped as STALE on replay. Adjudicate where \`.ultra11y/pages/\` exists.`
68880
+ );
68848
68881
  const refused = new Set(r.rejectedCriteria);
68849
68882
  const accepted = new Set(adj.items.map((it) => it.criteriaId).filter((id) => !refused.has(id)));
68850
68883
  const fresh = entriesFrom(adj, accepted, r.audit.date);
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "source": "https://www.w3.org/Translations/WCAG22-fr/",
3
- "fetchedAt": "2026-08-19",
3
+ "fetchedAt": "2026-08-20",
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-19 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-20 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-19",
5
+ "fetchedAt": "2026-08-20",
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-19",
4
+ "fetchedAt": "2026-08-20",
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 Name, Role, Value 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: 5.15.0
8
+ version: 5.16.0
9
9
  ---
10
10
 
11
11
  # review-a11y — accessibility review of changed code
@@ -6,7 +6,7 @@ import { join as join51, relative as relative5, resolve as resolve16, sep as sep
6
6
  import { fileURLToPath as fileURLToPath5, pathToFileURL as pathToFileURL3 } from "url";
7
7
 
8
8
  // src/types.ts
9
- var VERSION = "5.15.0";
9
+ var VERSION = "5.16.0";
10
10
  var SCHEMA_VERSION = 2;
11
11
  var INAPPLICABLE_STATUS = "C";
12
12
 
@@ -2290,7 +2290,7 @@ var wcag_universe_default = {
2290
2290
  wcagVersion: "2.2",
2291
2291
  source: "https://www.w3.org/TR/WCAG22/",
2292
2292
  criteriaSource: "https://github.com/w3c/wcag",
2293
- 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-19 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
+ 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-20 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).",
2294
2294
  criteria: [
2295
2295
  {
2296
2296
  id: "1.1.1",
@@ -58678,6 +58678,9 @@ function readLedger(path) {
58678
58678
  return void 0;
58679
58679
  }
58680
58680
  }
58681
+ function unreadableCaptures(audit2, cwd = ".") {
58682
+ return (audit2.scope.pagesAudited ?? []).filter((id) => !existsSync21(join36(cwd, PAGES_DIR, id, "dom.html")));
58683
+ }
58681
58684
  function writeLedger(path, ledger) {
58682
58685
  mkdirSync10(dirname10(path), { recursive: true });
58683
58686
  const sorted = { ...ledger, entries: [...ledger.entries].sort((a, b) => a.criteriaId.localeCompare(b.criteriaId, "en", { numeric: true })) };
@@ -59891,17 +59894,38 @@ function localTestedScs(interact) {
59891
59894
  }
59892
59895
  var PW_SPEC = "@playwright/test";
59893
59896
  var AXE_SPEC = "@axe-core/playwright";
59894
- function localTierStatus(cwd) {
59895
- const req = createRequire(resolve12(cwd, "package.json"));
59896
- for (const spec of [PW_SPEC, AXE_SPEC]) {
59897
+ function resolveAnchors(cwd) {
59898
+ return [resolve12(cwd, "package.json"), import.meta.url];
59899
+ }
59900
+ function requireRuntime(cwd, spec) {
59901
+ let last;
59902
+ for (const from of resolveAnchors(cwd)) {
59897
59903
  try {
59898
- req.resolve(spec);
59904
+ return createRequire(from)(spec);
59905
+ } catch (e) {
59906
+ last = e;
59907
+ }
59908
+ }
59909
+ throw last instanceof Error ? last : new Error(String(last));
59910
+ }
59911
+ function resolvesRuntime(cwd, spec) {
59912
+ return resolveAnchors(cwd).some((from) => {
59913
+ try {
59914
+ createRequire(from).resolve(spec);
59915
+ return true;
59899
59916
  } catch {
59900
- return { ok: false, reason: `${spec} does not resolve from ${cwd}` };
59917
+ return false;
59918
+ }
59919
+ });
59920
+ }
59921
+ function localTierStatus(cwd) {
59922
+ for (const spec of [PW_SPEC, AXE_SPEC]) {
59923
+ if (!resolvesRuntime(cwd, spec)) {
59924
+ return { ok: false, reason: `${spec} resolves neither from ${cwd} nor from ultra11y itself` };
59901
59925
  }
59902
59926
  }
59903
59927
  try {
59904
- const pw = req(PW_SPEC);
59928
+ const pw = requireRuntime(cwd, PW_SPEC);
59905
59929
  const bin = pw.chromium?.executablePath?.();
59906
59930
  if (typeof bin === "string" && bin.length > 0 && !existsSync24(bin)) {
59907
59931
  return { ok: false, reason: `no browser binary at ${bin} \u2014 run \`npx playwright install chromium\`` };
@@ -59918,15 +59942,14 @@ function resolveLocalDeps(cwd) {
59918
59942
  let chromium;
59919
59943
  let AxeBuilder;
59920
59944
  try {
59921
- const req = createRequire(resolve12(cwd, "package.json"));
59922
- const pw = req(PW_SPEC);
59923
- const axeMod = req(AXE_SPEC);
59945
+ const pw = requireRuntime(cwd, PW_SPEC);
59946
+ const axeMod = requireRuntime(cwd, AXE_SPEC);
59924
59947
  chromium = pw.chromium;
59925
59948
  AxeBuilder = axeMod.default ?? axeMod;
59926
59949
  } catch (e) {
59927
59950
  const msg = e instanceof Error ? e.message : String(e);
59928
59951
  throw new Error(
59929
- `Playwright not resolvable from "${cwd}". Pass --cwd <dir> at a project with @playwright/test + @axe-core/playwright installed (e.g. --cwd packages/app), or use --runtime docker. (${msg})`
59952
+ `Playwright resolves neither from "${cwd}" nor from ultra11y itself. Both are dependencies of this package, so the usual cause is the standalone engine bundle running with no node_modules beside it: pass --cwd <dir> at a project that has @playwright/test + @axe-core/playwright (e.g. --cwd packages/app), or use --runtime docker. (${msg})`
59930
59953
  );
59931
59954
  }
59932
59955
  if (!chromium || typeof AxeBuilder !== "function") {
@@ -68713,6 +68736,11 @@ function applyAdjudicationFile(p, adj, lang) {
68713
68736
  writeFileSync20(auditPath, JSON.stringify(r.audit, null, 2) + "\n");
68714
68737
  const ledgerOut = ledgerTarget(p, adj.standard);
68715
68738
  if (ledgerOut) {
68739
+ const blind = unreadableCaptures(r.audit);
68740
+ if (blind.length)
68741
+ console.error(
68742
+ lang === "fr" ? `\u26A0 Registre \xE9crit sans les instantan\xE9s de ${blind.length} page(s) que cet audit dit avoir lues (${blind.slice(0, 5).join(", ")}${blind.length > 5 ? "\u2026" : ""}). L'\xE9vidence moissonn\xE9e ici est plus petite que celle qu'un run reconstruira, donc ces verdicts seront d\xE9clar\xE9s P\xC9RIM\xC9S au rejeu. Adjugez l\xE0 o\xF9 \`.ultra11y/pages/\` existe.` : `\u26A0 Ledger written without the snapshots of ${blind.length} page(s) this audit says it read (${blind.slice(0, 5).join(", ")}${blind.length > 5 ? "\u2026" : ""}). The evidence harvested here is smaller than the one a run will rebuild, so these verdicts will be dropped as STALE on replay. Adjudicate where \`.ultra11y/pages/\` exists.`
68743
+ );
68716
68744
  const refused = new Set(r.rejectedCriteria);
68717
68745
  const accepted = new Set(adj.items.map((it) => it.criteriaId).filter((id) => !refused.has(id)));
68718
68746
  const fresh = entriesFrom(adj, accepted, r.audit.date);
@@ -68845,6 +68873,11 @@ async function cmdJudge(p) {
68845
68873
  `);
68846
68874
  const ledgerOut = ledgerTarget(p, adj.standard);
68847
68875
  if (ledgerOut) {
68876
+ const blind = unreadableCaptures(r.audit);
68877
+ if (blind.length)
68878
+ console.error(
68879
+ lang === "fr" ? `\u26A0 Registre \xE9crit sans les instantan\xE9s de ${blind.length} page(s) que cet audit dit avoir lues (${blind.slice(0, 5).join(", ")}${blind.length > 5 ? "\u2026" : ""}). L'\xE9vidence moissonn\xE9e ici est plus petite que celle qu'un run reconstruira, donc ces verdicts seront d\xE9clar\xE9s P\xC9RIM\xC9S au rejeu. Adjugez l\xE0 o\xF9 \`.ultra11y/pages/\` existe.` : `\u26A0 Ledger written without the snapshots of ${blind.length} page(s) this audit says it read (${blind.slice(0, 5).join(", ")}${blind.length > 5 ? "\u2026" : ""}). The evidence harvested here is smaller than the one a run will rebuild, so these verdicts will be dropped as STALE on replay. Adjudicate where \`.ultra11y/pages/\` exists.`
68880
+ );
68848
68881
  const refused = new Set(r.rejectedCriteria);
68849
68882
  const accepted = new Set(adj.items.map((it) => it.criteriaId).filter((id) => !refused.has(id)));
68850
68883
  const fresh = entriesFrom(adj, accepted, r.audit.date);
@@ -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: 5.15.0
7
+ version: 5.16.0
8
8
  ---
9
9
 
10
10
  # ultra11y — audit WCAG 2.2 AA and write accessible markup
@@ -5,18 +5,23 @@ contrast (1.4.3), focus visible (2.4.7), reflow/zoom (1.4.4/1.4.10), text spacin
5
5
  content on hover (1.4.13), target size (2.5.8). The dynamic tier decides them by running
6
6
  **axe-core in a real headless browser** (Playwright). Two runtimes, same finding shape:
7
7
 
8
- - **`--runtime local`** (recommended): resolves a host/target Playwright + `@axe-core/playwright`
9
- **at runtime** from `--cwd` (no Docker, no global install). Most projects with Playwright
10
- e2e tests already have both deps. Adds the residual-criteria **probes** (below).
8
+ - **`--runtime local`** (recommended): resolves Playwright + `@axe-core/playwright`
9
+ **at runtime**, from `--cwd` first and from ultra11y's own install second (no Docker, no
10
+ global install). Both are dependencies of this package, so a project that installed ultra11y
11
+ alone still gets the tier; a project that pins its own Playwright keeps it, because `--cwd`
12
+ is tried first. Adds the residual-criteria **probes** (below).
11
13
  - **`--runtime docker`**: runs axe-core in a Docker image auto-built on first use (runner +
12
14
  Dockerfile embedded in the bundle). No host deps beyond Docker. Axe + 320px reflow only.
13
- - **`--runtime auto`** (default): local if Playwright resolves from `--cwd`, else Docker, else
14
- an actionable error.
15
+ - **`--runtime auto`** (default): local if Playwright resolves **and** a browser binary is on
16
+ disk, else Docker, else an actionable error. Since the packages ship with ultra11y, what
17
+ decides `auto` in practice is the browser, not the modules.
15
18
 
16
19
  ## Prerequisites
17
20
 
18
- - **local**: a project reachable from `--cwd` with `@playwright/test` + `@axe-core/playwright`
19
- installed, and a Chromium browser (`npx playwright install chromium`).
21
+ - **local**: a Chromium browser — `npx playwright install chromium`. The two npm packages
22
+ (`@playwright/test`, `@axe-core/playwright`) come with ultra11y and need no separate install;
23
+ installing them in the audited project is still supported and takes precedence, which is what
24
+ a repository with its own pinned Playwright wants.
20
25
  - **docker**: Docker running.
21
26
 
22
27
  The rest of the skill (static audit) needs neither.
@@ -6,7 +6,7 @@ import { join as join51, relative as relative5, resolve as resolve16, sep as sep
6
6
  import { fileURLToPath as fileURLToPath5, pathToFileURL as pathToFileURL3 } from "url";
7
7
 
8
8
  // src/types.ts
9
- var VERSION = "5.15.0";
9
+ var VERSION = "5.16.0";
10
10
  var SCHEMA_VERSION = 2;
11
11
  var INAPPLICABLE_STATUS = "C";
12
12
 
@@ -2290,7 +2290,7 @@ var wcag_universe_default = {
2290
2290
  wcagVersion: "2.2",
2291
2291
  source: "https://www.w3.org/TR/WCAG22/",
2292
2292
  criteriaSource: "https://github.com/w3c/wcag",
2293
- 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-19 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
+ 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-20 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).",
2294
2294
  criteria: [
2295
2295
  {
2296
2296
  id: "1.1.1",
@@ -58678,6 +58678,9 @@ function readLedger(path) {
58678
58678
  return void 0;
58679
58679
  }
58680
58680
  }
58681
+ function unreadableCaptures(audit2, cwd = ".") {
58682
+ return (audit2.scope.pagesAudited ?? []).filter((id) => !existsSync21(join36(cwd, PAGES_DIR, id, "dom.html")));
58683
+ }
58681
58684
  function writeLedger(path, ledger) {
58682
58685
  mkdirSync10(dirname10(path), { recursive: true });
58683
58686
  const sorted = { ...ledger, entries: [...ledger.entries].sort((a, b) => a.criteriaId.localeCompare(b.criteriaId, "en", { numeric: true })) };
@@ -59891,17 +59894,38 @@ function localTestedScs(interact) {
59891
59894
  }
59892
59895
  var PW_SPEC = "@playwright/test";
59893
59896
  var AXE_SPEC = "@axe-core/playwright";
59894
- function localTierStatus(cwd) {
59895
- const req = createRequire(resolve12(cwd, "package.json"));
59896
- for (const spec of [PW_SPEC, AXE_SPEC]) {
59897
+ function resolveAnchors(cwd) {
59898
+ return [resolve12(cwd, "package.json"), import.meta.url];
59899
+ }
59900
+ function requireRuntime(cwd, spec) {
59901
+ let last;
59902
+ for (const from of resolveAnchors(cwd)) {
59897
59903
  try {
59898
- req.resolve(spec);
59904
+ return createRequire(from)(spec);
59905
+ } catch (e) {
59906
+ last = e;
59907
+ }
59908
+ }
59909
+ throw last instanceof Error ? last : new Error(String(last));
59910
+ }
59911
+ function resolvesRuntime(cwd, spec) {
59912
+ return resolveAnchors(cwd).some((from) => {
59913
+ try {
59914
+ createRequire(from).resolve(spec);
59915
+ return true;
59899
59916
  } catch {
59900
- return { ok: false, reason: `${spec} does not resolve from ${cwd}` };
59917
+ return false;
59918
+ }
59919
+ });
59920
+ }
59921
+ function localTierStatus(cwd) {
59922
+ for (const spec of [PW_SPEC, AXE_SPEC]) {
59923
+ if (!resolvesRuntime(cwd, spec)) {
59924
+ return { ok: false, reason: `${spec} resolves neither from ${cwd} nor from ultra11y itself` };
59901
59925
  }
59902
59926
  }
59903
59927
  try {
59904
- const pw = req(PW_SPEC);
59928
+ const pw = requireRuntime(cwd, PW_SPEC);
59905
59929
  const bin = pw.chromium?.executablePath?.();
59906
59930
  if (typeof bin === "string" && bin.length > 0 && !existsSync24(bin)) {
59907
59931
  return { ok: false, reason: `no browser binary at ${bin} \u2014 run \`npx playwright install chromium\`` };
@@ -59918,15 +59942,14 @@ function resolveLocalDeps(cwd) {
59918
59942
  let chromium;
59919
59943
  let AxeBuilder;
59920
59944
  try {
59921
- const req = createRequire(resolve12(cwd, "package.json"));
59922
- const pw = req(PW_SPEC);
59923
- const axeMod = req(AXE_SPEC);
59945
+ const pw = requireRuntime(cwd, PW_SPEC);
59946
+ const axeMod = requireRuntime(cwd, AXE_SPEC);
59924
59947
  chromium = pw.chromium;
59925
59948
  AxeBuilder = axeMod.default ?? axeMod;
59926
59949
  } catch (e) {
59927
59950
  const msg = e instanceof Error ? e.message : String(e);
59928
59951
  throw new Error(
59929
- `Playwright not resolvable from "${cwd}". Pass --cwd <dir> at a project with @playwright/test + @axe-core/playwright installed (e.g. --cwd packages/app), or use --runtime docker. (${msg})`
59952
+ `Playwright resolves neither from "${cwd}" nor from ultra11y itself. Both are dependencies of this package, so the usual cause is the standalone engine bundle running with no node_modules beside it: pass --cwd <dir> at a project that has @playwright/test + @axe-core/playwright (e.g. --cwd packages/app), or use --runtime docker. (${msg})`
59930
59953
  );
59931
59954
  }
59932
59955
  if (!chromium || typeof AxeBuilder !== "function") {
@@ -68713,6 +68736,11 @@ function applyAdjudicationFile(p, adj, lang) {
68713
68736
  writeFileSync20(auditPath, JSON.stringify(r.audit, null, 2) + "\n");
68714
68737
  const ledgerOut = ledgerTarget(p, adj.standard);
68715
68738
  if (ledgerOut) {
68739
+ const blind = unreadableCaptures(r.audit);
68740
+ if (blind.length)
68741
+ console.error(
68742
+ lang === "fr" ? `\u26A0 Registre \xE9crit sans les instantan\xE9s de ${blind.length} page(s) que cet audit dit avoir lues (${blind.slice(0, 5).join(", ")}${blind.length > 5 ? "\u2026" : ""}). L'\xE9vidence moissonn\xE9e ici est plus petite que celle qu'un run reconstruira, donc ces verdicts seront d\xE9clar\xE9s P\xC9RIM\xC9S au rejeu. Adjugez l\xE0 o\xF9 \`.ultra11y/pages/\` existe.` : `\u26A0 Ledger written without the snapshots of ${blind.length} page(s) this audit says it read (${blind.slice(0, 5).join(", ")}${blind.length > 5 ? "\u2026" : ""}). The evidence harvested here is smaller than the one a run will rebuild, so these verdicts will be dropped as STALE on replay. Adjudicate where \`.ultra11y/pages/\` exists.`
68743
+ );
68716
68744
  const refused = new Set(r.rejectedCriteria);
68717
68745
  const accepted = new Set(adj.items.map((it) => it.criteriaId).filter((id) => !refused.has(id)));
68718
68746
  const fresh = entriesFrom(adj, accepted, r.audit.date);
@@ -68845,6 +68873,11 @@ async function cmdJudge(p) {
68845
68873
  `);
68846
68874
  const ledgerOut = ledgerTarget(p, adj.standard);
68847
68875
  if (ledgerOut) {
68876
+ const blind = unreadableCaptures(r.audit);
68877
+ if (blind.length)
68878
+ console.error(
68879
+ lang === "fr" ? `\u26A0 Registre \xE9crit sans les instantan\xE9s de ${blind.length} page(s) que cet audit dit avoir lues (${blind.slice(0, 5).join(", ")}${blind.length > 5 ? "\u2026" : ""}). L'\xE9vidence moissonn\xE9e ici est plus petite que celle qu'un run reconstruira, donc ces verdicts seront d\xE9clar\xE9s P\xC9RIM\xC9S au rejeu. Adjugez l\xE0 o\xF9 \`.ultra11y/pages/\` existe.` : `\u26A0 Ledger written without the snapshots of ${blind.length} page(s) this audit says it read (${blind.slice(0, 5).join(", ")}${blind.length > 5 ? "\u2026" : ""}). The evidence harvested here is smaller than the one a run will rebuild, so these verdicts will be dropped as STALE on replay. Adjudicate where \`.ultra11y/pages/\` exists.`
68880
+ );
68848
68881
  const refused = new Set(r.rejectedCriteria);
68849
68882
  const accepted = new Set(adj.items.map((it) => it.criteriaId).filter((id) => !refused.has(id)));
68850
68883
  const fresh = entriesFrom(adj, accepted, r.audit.date);
package/src/cli.ts CHANGED
@@ -50,7 +50,7 @@ import {
50
50
  hydrateAdjudication,
51
51
  type AdjudicationFile,
52
52
  } from "./adjudicate.js";
53
- import { entriesFrom, isLedger, ledgerPath, mergeLedger, readLedger, replayLedger, type VerdictLedger, writeLedger } from "./ledger.js";
53
+ import { entriesFrom, isLedger, ledgerPath, mergeLedger, readLedger, replayLedger, unreadableCaptures, type VerdictLedger, writeLedger } from "./ledger.js";
54
54
  import { BATCH_SIZE, apiKeyFromEnv, applyRawVerdicts, judgeAll, modelFromEnv } from "./llm.js";
55
55
  import { runScan, runScanMany, runCrawlScan, runSampleScan, mergeDynamic, mergeSnapshotAudit, cleanDynamic, dockerAvailable } from "./scan.js";
56
56
  import { runScanLocal, runScanManyLocal, runCrawlScanLocal, runSampleScanLocal, localAvailable, localTierStatus } from "./scan-local.js";
@@ -2940,6 +2940,25 @@ function applyAdjudicationFile(p: ParsedArgs, adj: AdjudicationFile, lang: Lang)
2940
2940
  // next replay, which is the whole thing the gate exists to prevent.
2941
2941
  const ledgerOut = ledgerTarget(p, adj.standard);
2942
2942
  if (ledgerOut) {
2943
+ // A LEDGER WRITTEN WITHOUT THE CAPTURES IS STALE ON ARRIVAL.
2944
+ //
2945
+ // The fingerprint is over the evidence the harvest READ FROM DISK, so recording a verdict
2946
+ // where the audit's page captures are absent fingerprints a strictly smaller set than the
2947
+ // one CI rebuilds — and the replay drops it as stale, every run, silently. Measured on a
2948
+ // real repository: RGAA 12.3 recorded over 13 evidence items where the run harvests 22.
2949
+ // The entry looked well-formed, the fold accepted it, it was committed and reviewed, and
2950
+ // it never once replayed.
2951
+ //
2952
+ // A warning, not a refusal: the verdict itself is sound and still lands on THIS audit, and
2953
+ // a caller who genuinely has no captures (a source-only repository) has nothing to fix.
2954
+ // What must not happen is writing it and saying nothing.
2955
+ const blind = unreadableCaptures(r.audit);
2956
+ if (blind.length)
2957
+ console.error(
2958
+ lang === "fr"
2959
+ ? `⚠ Registre écrit sans les instantanés de ${blind.length} page(s) que cet audit dit avoir lues (${blind.slice(0, 5).join(", ")}${blind.length > 5 ? "…" : ""}). L'évidence moissonnée ici est plus petite que celle qu'un run reconstruira, donc ces verdicts seront déclarés PÉRIMÉS au rejeu. Adjugez là où \`.ultra11y/pages/\` existe.`
2960
+ : `⚠ Ledger written without the snapshots of ${blind.length} page(s) this audit says it read (${blind.slice(0, 5).join(", ")}${blind.length > 5 ? "…" : ""}). The evidence harvested here is smaller than the one a run will rebuild, so these verdicts will be dropped as STALE on replay. Adjudicate where \`.ultra11y/pages/\` exists.`,
2961
+ );
2943
2962
  const refused = new Set(r.rejectedCriteria);
2944
2963
  const accepted = new Set(adj.items.map((it) => it.criteriaId).filter((id) => !refused.has(id)));
2945
2964
  const fresh = entriesFrom(adj, accepted, r.audit.date);
@@ -3129,6 +3148,25 @@ async function cmdJudge(p: ParsedArgs): Promise<number> {
3129
3148
  // run can replay it for free.
3130
3149
  const ledgerOut = ledgerTarget(p, adj.standard);
3131
3150
  if (ledgerOut) {
3151
+ // A LEDGER WRITTEN WITHOUT THE CAPTURES IS STALE ON ARRIVAL.
3152
+ //
3153
+ // The fingerprint is over the evidence the harvest READ FROM DISK, so recording a verdict
3154
+ // where the audit's page captures are absent fingerprints a strictly smaller set than the
3155
+ // one CI rebuilds — and the replay drops it as stale, every run, silently. Measured on a
3156
+ // real repository: RGAA 12.3 recorded over 13 evidence items where the run harvests 22.
3157
+ // The entry looked well-formed, the fold accepted it, it was committed and reviewed, and
3158
+ // it never once replayed.
3159
+ //
3160
+ // A warning, not a refusal: the verdict itself is sound and still lands on THIS audit, and
3161
+ // a caller who genuinely has no captures (a source-only repository) has nothing to fix.
3162
+ // What must not happen is writing it and saying nothing.
3163
+ const blind = unreadableCaptures(r.audit);
3164
+ if (blind.length)
3165
+ console.error(
3166
+ lang === "fr"
3167
+ ? `⚠ Registre écrit sans les instantanés de ${blind.length} page(s) que cet audit dit avoir lues (${blind.slice(0, 5).join(", ")}${blind.length > 5 ? "…" : ""}). L'évidence moissonnée ici est plus petite que celle qu'un run reconstruira, donc ces verdicts seront déclarés PÉRIMÉS au rejeu. Adjugez là où \`.ultra11y/pages/\` existe.`
3168
+ : `⚠ Ledger written without the snapshots of ${blind.length} page(s) this audit says it read (${blind.slice(0, 5).join(", ")}${blind.length > 5 ? "…" : ""}). The evidence harvested here is smaller than the one a run will rebuild, so these verdicts will be dropped as STALE on replay. Adjudicate where \`.ultra11y/pages/\` exists.`,
3169
+ );
3132
3170
  const refused = new Set(r.rejectedCriteria);
3133
3171
  const accepted = new Set(adj.items.map((it) => it.criteriaId).filter((id) => !refused.has(id)));
3134
3172
  const fresh = entriesFrom(adj, accepted, r.audit.date);
@@ -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-19 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-20 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/ledger.ts CHANGED
@@ -31,6 +31,7 @@ import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
31
31
  import { dirname, join } from "node:path";
32
32
 
33
33
  import { type AdjudicationFile, type AdjudicationItem, type AgentFinding, buildAdjudicationWorklist, type Evidence, readCitation } from "./adjudicate.js";
34
+ import { PAGES_DIR } from "./snapshot.js";
34
35
  import { CORE, type StandardId } from "./standards/index.js";
35
36
  import type { AuditResult } from "./types.js";
36
37
  import { SCHEMA_VERSION } from "./types.js";
@@ -127,6 +128,25 @@ export function readLedger(path: string): VerdictLedger | undefined {
127
128
  }
128
129
  }
129
130
 
131
+ /** Would a ledger written from THIS harvest be replayable? Names what is missing when not.
132
+ *
133
+ * A ledger entry is keyed on a fingerprint of the evidence the verdict was ruled against, and
134
+ * the harvest RE-READS the audited files from disk. So a verdict recorded where the audit's
135
+ * page captures are absent is fingerprinted over a strictly smaller evidence set than the one
136
+ * CI will rebuild — and it is stale on arrival, every time, silently.
137
+ *
138
+ * Measured while adjudicating a real repository from a checkout with no `.ultra11y/pages/`:
139
+ * RGAA 12.3 was recorded over 13 evidence items where the run harvests 22. The entry looked
140
+ * perfectly well-formed, the fold accepted it, the file was committed and reviewed — and the
141
+ * replay dropped it as stale on every subsequent run, with nothing anywhere saying why.
142
+ *
143
+ * Returns the page ids the audit says it read and whose capture is not on disk. Empty when
144
+ * there is nothing to warn about — including on a source-only audit, which legitimately has
145
+ * no captures to miss. */
146
+ export function unreadableCaptures(audit: AuditResult, cwd = "."): string[] {
147
+ return (audit.scope.pagesAudited ?? []).filter((id) => !existsSync(join(cwd, PAGES_DIR, id, "dom.html")));
148
+ }
149
+
130
150
  export function writeLedger(path: string, ledger: VerdictLedger): void {
131
151
  mkdirSync(dirname(path), { recursive: true });
132
152
  // Entries id-sorted so the committed file has a stable diff — a ledger that reshuffles on
package/src/scan-local.ts CHANGED
@@ -1,6 +1,7 @@
1
- // `scan --runtime local` — the dynamic tier WITHOUT Docker. Resolves a host/target
2
- // Playwright + @axe-core/playwright AT RUNTIME (createRequire, a node: builtin) from
3
- // `--cwd`, so the zero-dep static engine bundle never gains a static browser import.
1
+ // `scan --runtime local` — the dynamic tier WITHOUT Docker. Resolves Playwright +
2
+ // @axe-core/playwright AT RUNTIME (createRequire, a node: builtin) from `--cwd` first and
3
+ // from this package second, so the zero-dep static engine bundle never gains a static
4
+ // browser import while a project that installed ultra11y alone still gets the tier.
4
5
  // It runs the SAME axe-core pass as the Docker RUNNER, then the residual-criteria
5
6
  // probes axe cannot decide (focus visibility, 200% zoom, text spacing, content on
6
7
  // hover, target size). The Docker RUNNER / docker/* files are untouched (docker-sync).
@@ -59,6 +60,46 @@ interface LocalDeps {
59
60
  const PW_SPEC = "@playwright/test";
60
61
  const AXE_SPEC = "@axe-core/playwright";
61
62
 
63
+ /** Where a runtime dependency may live, in the order that must be tried.
64
+ *
65
+ * THE TARGET PROJECT FIRST, then this package — the same order `src/integrations/playwright.ts`
66
+ * uses, and for the same two reasons. A repository that pins its own Playwright must keep it:
67
+ * two copies in one process hand out `Page` objects the other one's fixtures do not recognise.
68
+ * And a repository that has none must still get the tier, now that `@playwright/test` and
69
+ * `@axe-core/playwright` are dependencies of ultra11y instead of something every caller had to
70
+ * duplicate into its own manifest. Anchoring on `cwd` ALONE was the older contract, and it made
71
+ * `--runtime local` unavailable to exactly the projects that had not read the prerequisites. */
72
+ function resolveAnchors(cwd: string): string[] {
73
+ return [resolve(cwd, "package.json"), import.meta.url];
74
+ }
75
+
76
+ /** Load `spec` from the target project, else from this package. Rethrows the LAST failure, so
77
+ * the message names the spec the caller asked for rather than the first anchor that missed. */
78
+ function requireRuntime(cwd: string, spec: string): Any {
79
+ let last: unknown;
80
+ for (const from of resolveAnchors(cwd)) {
81
+ try {
82
+ return createRequire(from)(spec);
83
+ } catch (e) {
84
+ last = e;
85
+ }
86
+ }
87
+ throw last instanceof Error ? last : new Error(String(last));
88
+ }
89
+
90
+ /** Can `spec` be resolved from either anchor? Resolution only — never loads the module, so
91
+ * probing the tier stays as cheap as it was when it only looked at `cwd`. */
92
+ function resolvesRuntime(cwd: string, spec: string): boolean {
93
+ return resolveAnchors(cwd).some((from) => {
94
+ try {
95
+ createRequire(from).resolve(spec);
96
+ return true;
97
+ } catch {
98
+ return false;
99
+ }
100
+ });
101
+ }
102
+
62
103
  /** Why the LOCAL tier is (un)usable from `cwd` — the one line a reader of a
63
104
  * `runtime: auto` log gets before the Docker fallback takes over. A silent degrade is
64
105
  * indistinguishable from a working fallback, so the reason must NAME what to install. */
@@ -68,16 +109,13 @@ export type LocalTierStatus = { ok: true } | { ok: false; reason: string };
68
109
  * boolean. Drives the `runtime: auto` decision AND the message printed when it degrades
69
110
  * to Docker. */
70
111
  export function localTierStatus(cwd: string): LocalTierStatus {
71
- const req = createRequire(resolve(cwd, "package.json"));
72
112
  for (const spec of [PW_SPEC, AXE_SPEC]) {
73
- try {
74
- req.resolve(spec);
75
- } catch {
76
- return { ok: false, reason: `${spec} does not resolve from ${cwd}` };
113
+ if (!resolvesRuntime(cwd, spec)) {
114
+ return { ok: false, reason: `${spec} resolves neither from ${cwd} nor from ultra11y itself` };
77
115
  }
78
116
  }
79
117
  try {
80
- const pw = req(PW_SPEC) as { chromium?: { executablePath?: () => string } };
118
+ const pw = requireRuntime(cwd, PW_SPEC) as { chromium?: { executablePath?: () => string } };
81
119
  const bin = pw.chromium?.executablePath?.();
82
120
  // No path at all ⇒ a Playwright that cannot tell us; treat as available and let the
83
121
  // launch report its own failure, rather than refusing a tier that might work.
@@ -112,15 +150,14 @@ export function resolveLocalDeps(cwd: string): LocalDeps {
112
150
  let chromium: Any;
113
151
  let AxeBuilder: Any;
114
152
  try {
115
- const req = createRequire(resolve(cwd, "package.json"));
116
- const pw = req(PW_SPEC);
117
- const axeMod = req(AXE_SPEC);
153
+ const pw = requireRuntime(cwd, PW_SPEC);
154
+ const axeMod = requireRuntime(cwd, AXE_SPEC);
118
155
  chromium = pw.chromium;
119
156
  AxeBuilder = axeMod.default ?? axeMod;
120
157
  } catch (e) {
121
158
  const msg = e instanceof Error ? e.message : String(e);
122
159
  throw new Error(
123
- `Playwright not resolvable from "${cwd}". Pass --cwd <dir> at a project with @playwright/test + @axe-core/playwright installed (e.g. --cwd packages/app), or use --runtime docker. (${msg})`,
160
+ `Playwright resolves neither from "${cwd}" nor from ultra11y itself. Both are dependencies of this package, so the usual cause is the standalone engine bundle running with no node_modules beside it: pass --cwd <dir> at a project that has @playwright/test + @axe-core/playwright (e.g. --cwd packages/app), or use --runtime docker. (${msg})`,
124
161
  );
125
162
  }
126
163
  if (!chromium || typeof AxeBuilder !== "function") {
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 = "5.15.0";
3
+ export const VERSION = "5.16.0";
4
4
  export const SCHEMA_VERSION = 2;
5
5
 
6
6
  // Lang = the UI FRAME's language — the `L` localization tables each module (report,