ultra11y 5.14.2 → 5.14.4

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.14.2",
13
+ "version": "5.14.4",
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.14.2",
4
+ "version": "5.14.4",
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.14.2",
4
+ "version": "5.14.4",
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.14.2";
30
+ const ULTRA11Y_PLUGIN_VERSION = "5.14.4";
31
31
 
32
32
  const here = dirname(fileURLToPath(import.meta.url));
33
33
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "ultra11y",
3
- "version": "5.14.2",
3
+ "version": "5.14.4",
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",
@@ -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.14.2";
9
+ var VERSION = "5.14.4";
10
10
  var SCHEMA_VERSION = 2;
11
11
  var INAPPLICABLE_STATUS = "C";
12
12
 
@@ -56864,12 +56864,27 @@ function sectionBody(md, n) {
56864
56864
  function checkSampleCaptured(root = ".", lang = "en") {
56865
56865
  const fr = lang === "fr";
56866
56866
  let declared = [];
56867
+ const rc = join34(root, ".ultra11yrc.json");
56868
+ let raw;
56867
56869
  try {
56868
- const cfg = JSON.parse(readFileSync18(join34(root, ".ultra11yrc.json"), "utf8"));
56869
- declared = cfg.sample?.pages ?? [];
56870
+ raw = readFileSync18(rc, "utf8");
56870
56871
  } catch {
56871
56872
  return { ok: true, issues: [], missing: [], declared: 0, captured: 0 };
56872
56873
  }
56874
+ try {
56875
+ const cfg = JSON.parse(raw);
56876
+ declared = cfg.sample?.pages ?? [];
56877
+ } catch (e) {
56878
+ return {
56879
+ ok: false,
56880
+ issues: [
56881
+ fr ? `.ultra11yrc.json est pr\xE9sent mais illisible (${e instanceof Error ? e.message : String(e)}) \u2014 impossible de savoir quelles pages ce run devait couvrir. La porte de couverture ne peut pas r\xE9pondre, et ne r\xE9pondra donc pas \xAB couvert \xBB.` : `.ultra11yrc.json is present but unreadable (${e instanceof Error ? e.message : String(e)}) \u2014 there is no way to know which pages this run was meant to cover. The coverage gate cannot answer, so it does not answer "covered".`
56882
+ ],
56883
+ missing: [],
56884
+ declared: 0,
56885
+ captured: 0
56886
+ };
56887
+ }
56873
56888
  if (!declared.length) return { ok: true, issues: [], missing: [], declared: 0, captured: 0 };
56874
56889
  let captured = /* @__PURE__ */ new Set();
56875
56890
  try {
@@ -59170,10 +59185,23 @@ var PROBE_FIELDS = [
59170
59185
  { key: "inputOverflowSpacing", engine: "input-overflow-spacing" },
59171
59186
  { key: "liveRegion", engine: "live-region" }
59172
59187
  ];
59188
+ function loopbackToHostGateway(target) {
59189
+ const m = /^http:\/\/(localhost|127(?:\.\d{1,3}){3})(:\d+)?(\/.*)?$/i.exec(target);
59190
+ if (!m) return { url: target, addHost: false };
59191
+ return { url: `http://host.docker.internal${m[2] ?? ""}${m[3] ?? ""}`, addHost: true };
59192
+ }
59173
59193
  function runRunner(target, isFile, tag, snapshot = true) {
59194
+ const hostTarget = target;
59174
59195
  const args2 = ["run", "--rm"];
59175
59196
  if (!snapshot) args2.push("-e", "ULTRA11Y_SNAPSHOT=0");
59176
59197
  if (isFile) args2.push("-v", `${resolve11(target)}:${MOUNT}:ro`);
59198
+ else {
59199
+ const gw = loopbackToHostGateway(target);
59200
+ if (gw.addHost && process.platform === "linux") {
59201
+ args2.push("--add-host", "host.docker.internal:host-gateway");
59202
+ }
59203
+ target = gw.url;
59204
+ }
59177
59205
  args2.push(tag, isFile ? MOUNT : target);
59178
59206
  let stdout;
59179
59207
  try {
@@ -59184,7 +59212,12 @@ function runRunner(target, isFile, tag, snapshot = true) {
59184
59212
  throw new Error(`docker run failed \u2014 ${detail}`);
59185
59213
  }
59186
59214
  const line = stdout.trim().split("\n").filter(Boolean).pop() ?? "{}";
59187
- return JSON.parse(line);
59215
+ const out2 = JSON.parse(line);
59216
+ if (loopbackToHostGateway(hostTarget).addHost && out2.url) {
59217
+ const hostSchemeName = hostTarget.match(/^http:\/\/[^:/]+/)?.[0] ?? hostTarget;
59218
+ out2.url = out2.url.replace(/^http:\/\/host\.docker\.internal(?=:\d+|\/|$)/i, hostSchemeName);
59219
+ }
59220
+ return out2;
59188
59221
  }
59189
59222
  function hostPageOf(url, target) {
59190
59223
  if (!url) return target;
@@ -59703,18 +59736,29 @@ function localTestedScs(interact) {
59703
59736
  }
59704
59737
  var PW_SPEC = "@playwright/test";
59705
59738
  var AXE_SPEC = "@axe-core/playwright";
59706
- function localAvailable(cwd) {
59739
+ function localTierStatus(cwd) {
59740
+ const req = createRequire(resolve12(cwd, "package.json"));
59741
+ for (const spec of [PW_SPEC, AXE_SPEC]) {
59742
+ try {
59743
+ req.resolve(spec);
59744
+ } catch {
59745
+ return { ok: false, reason: `${spec} does not resolve from ${cwd}` };
59746
+ }
59747
+ }
59707
59748
  try {
59708
- const req = createRequire(resolve12(cwd, "package.json"));
59709
- req.resolve(PW_SPEC);
59710
- req.resolve(AXE_SPEC);
59711
59749
  const pw = req(PW_SPEC);
59712
59750
  const bin = pw.chromium?.executablePath?.();
59713
- return typeof bin !== "string" || bin.length === 0 ? true : existsSync24(bin);
59714
- } catch {
59715
- return false;
59751
+ if (typeof bin === "string" && bin.length > 0 && !existsSync24(bin)) {
59752
+ return { ok: false, reason: `no browser binary at ${bin} \u2014 run \`npx playwright install chromium\`` };
59753
+ }
59754
+ return { ok: true };
59755
+ } catch (e) {
59756
+ return { ok: false, reason: `${PW_SPEC} failed to load: ${e.message.split("\n")[0]}` };
59716
59757
  }
59717
59758
  }
59759
+ function localAvailable(cwd) {
59760
+ return localTierStatus(cwd).ok;
59761
+ }
59718
59762
  function resolveLocalDeps(cwd) {
59719
59763
  let chromium;
59720
59764
  let AxeBuilder;
@@ -68653,8 +68697,13 @@ async function cmdScan(p) {
68653
68697
  if (runtimeFlag === "local") useLocal = true;
68654
68698
  else if (runtimeFlag === "docker") useLocal = false;
68655
68699
  else if (localAvailable(cwd)) useLocal = true;
68656
- else if (dockerAvailable()) useLocal = false;
68657
- else {
68700
+ else if (dockerAvailable()) {
68701
+ useLocal = false;
68702
+ const s = localTierStatus(cwd);
68703
+ console.error(
68704
+ lang === "fr" ? `ultra11y scan : tier local indisponible${s.ok ? "" : ` (${s.reason})`} \u2014 bascule sur Docker. Passez --runtime local pour en faire une erreur.` : `ultra11y scan: local tier unavailable${s.ok ? "" : ` (${s.reason})`} \u2014 falling back to Docker. Pass --runtime local to make this an error instead.`
68705
+ );
68706
+ } else {
68658
68707
  console.error(
68659
68708
  lang === "fr" ? "ultra11y scan : aucun runtime disponible \u2014 ni Playwright local (passez --cwd vers un projet avec @playwright/test + @axe-core/playwright install\xE9s), ni Docker. Voir --runtime." : "ultra11y scan: no runtime available \u2014 neither a local Playwright (pass --cwd at a project with @playwright/test + @axe-core/playwright installed) nor Docker. See --runtime."
68660
68709
  );
@@ -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.14.2
8
+ version: 5.14.4
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.14.2";
9
+ var VERSION = "5.14.4";
10
10
  var SCHEMA_VERSION = 2;
11
11
  var INAPPLICABLE_STATUS = "C";
12
12
 
@@ -56864,12 +56864,27 @@ function sectionBody(md, n) {
56864
56864
  function checkSampleCaptured(root = ".", lang = "en") {
56865
56865
  const fr = lang === "fr";
56866
56866
  let declared = [];
56867
+ const rc = join34(root, ".ultra11yrc.json");
56868
+ let raw;
56867
56869
  try {
56868
- const cfg = JSON.parse(readFileSync18(join34(root, ".ultra11yrc.json"), "utf8"));
56869
- declared = cfg.sample?.pages ?? [];
56870
+ raw = readFileSync18(rc, "utf8");
56870
56871
  } catch {
56871
56872
  return { ok: true, issues: [], missing: [], declared: 0, captured: 0 };
56872
56873
  }
56874
+ try {
56875
+ const cfg = JSON.parse(raw);
56876
+ declared = cfg.sample?.pages ?? [];
56877
+ } catch (e) {
56878
+ return {
56879
+ ok: false,
56880
+ issues: [
56881
+ fr ? `.ultra11yrc.json est pr\xE9sent mais illisible (${e instanceof Error ? e.message : String(e)}) \u2014 impossible de savoir quelles pages ce run devait couvrir. La porte de couverture ne peut pas r\xE9pondre, et ne r\xE9pondra donc pas \xAB couvert \xBB.` : `.ultra11yrc.json is present but unreadable (${e instanceof Error ? e.message : String(e)}) \u2014 there is no way to know which pages this run was meant to cover. The coverage gate cannot answer, so it does not answer "covered".`
56882
+ ],
56883
+ missing: [],
56884
+ declared: 0,
56885
+ captured: 0
56886
+ };
56887
+ }
56873
56888
  if (!declared.length) return { ok: true, issues: [], missing: [], declared: 0, captured: 0 };
56874
56889
  let captured = /* @__PURE__ */ new Set();
56875
56890
  try {
@@ -59170,10 +59185,23 @@ var PROBE_FIELDS = [
59170
59185
  { key: "inputOverflowSpacing", engine: "input-overflow-spacing" },
59171
59186
  { key: "liveRegion", engine: "live-region" }
59172
59187
  ];
59188
+ function loopbackToHostGateway(target) {
59189
+ const m = /^http:\/\/(localhost|127(?:\.\d{1,3}){3})(:\d+)?(\/.*)?$/i.exec(target);
59190
+ if (!m) return { url: target, addHost: false };
59191
+ return { url: `http://host.docker.internal${m[2] ?? ""}${m[3] ?? ""}`, addHost: true };
59192
+ }
59173
59193
  function runRunner(target, isFile, tag, snapshot = true) {
59194
+ const hostTarget = target;
59174
59195
  const args2 = ["run", "--rm"];
59175
59196
  if (!snapshot) args2.push("-e", "ULTRA11Y_SNAPSHOT=0");
59176
59197
  if (isFile) args2.push("-v", `${resolve11(target)}:${MOUNT}:ro`);
59198
+ else {
59199
+ const gw = loopbackToHostGateway(target);
59200
+ if (gw.addHost && process.platform === "linux") {
59201
+ args2.push("--add-host", "host.docker.internal:host-gateway");
59202
+ }
59203
+ target = gw.url;
59204
+ }
59177
59205
  args2.push(tag, isFile ? MOUNT : target);
59178
59206
  let stdout;
59179
59207
  try {
@@ -59184,7 +59212,12 @@ function runRunner(target, isFile, tag, snapshot = true) {
59184
59212
  throw new Error(`docker run failed \u2014 ${detail}`);
59185
59213
  }
59186
59214
  const line = stdout.trim().split("\n").filter(Boolean).pop() ?? "{}";
59187
- return JSON.parse(line);
59215
+ const out2 = JSON.parse(line);
59216
+ if (loopbackToHostGateway(hostTarget).addHost && out2.url) {
59217
+ const hostSchemeName = hostTarget.match(/^http:\/\/[^:/]+/)?.[0] ?? hostTarget;
59218
+ out2.url = out2.url.replace(/^http:\/\/host\.docker\.internal(?=:\d+|\/|$)/i, hostSchemeName);
59219
+ }
59220
+ return out2;
59188
59221
  }
59189
59222
  function hostPageOf(url, target) {
59190
59223
  if (!url) return target;
@@ -59703,18 +59736,29 @@ function localTestedScs(interact) {
59703
59736
  }
59704
59737
  var PW_SPEC = "@playwright/test";
59705
59738
  var AXE_SPEC = "@axe-core/playwright";
59706
- function localAvailable(cwd) {
59739
+ function localTierStatus(cwd) {
59740
+ const req = createRequire(resolve12(cwd, "package.json"));
59741
+ for (const spec of [PW_SPEC, AXE_SPEC]) {
59742
+ try {
59743
+ req.resolve(spec);
59744
+ } catch {
59745
+ return { ok: false, reason: `${spec} does not resolve from ${cwd}` };
59746
+ }
59747
+ }
59707
59748
  try {
59708
- const req = createRequire(resolve12(cwd, "package.json"));
59709
- req.resolve(PW_SPEC);
59710
- req.resolve(AXE_SPEC);
59711
59749
  const pw = req(PW_SPEC);
59712
59750
  const bin = pw.chromium?.executablePath?.();
59713
- return typeof bin !== "string" || bin.length === 0 ? true : existsSync24(bin);
59714
- } catch {
59715
- return false;
59751
+ if (typeof bin === "string" && bin.length > 0 && !existsSync24(bin)) {
59752
+ return { ok: false, reason: `no browser binary at ${bin} \u2014 run \`npx playwright install chromium\`` };
59753
+ }
59754
+ return { ok: true };
59755
+ } catch (e) {
59756
+ return { ok: false, reason: `${PW_SPEC} failed to load: ${e.message.split("\n")[0]}` };
59716
59757
  }
59717
59758
  }
59759
+ function localAvailable(cwd) {
59760
+ return localTierStatus(cwd).ok;
59761
+ }
59718
59762
  function resolveLocalDeps(cwd) {
59719
59763
  let chromium;
59720
59764
  let AxeBuilder;
@@ -68653,8 +68697,13 @@ async function cmdScan(p) {
68653
68697
  if (runtimeFlag === "local") useLocal = true;
68654
68698
  else if (runtimeFlag === "docker") useLocal = false;
68655
68699
  else if (localAvailable(cwd)) useLocal = true;
68656
- else if (dockerAvailable()) useLocal = false;
68657
- else {
68700
+ else if (dockerAvailable()) {
68701
+ useLocal = false;
68702
+ const s = localTierStatus(cwd);
68703
+ console.error(
68704
+ lang === "fr" ? `ultra11y scan : tier local indisponible${s.ok ? "" : ` (${s.reason})`} \u2014 bascule sur Docker. Passez --runtime local pour en faire une erreur.` : `ultra11y scan: local tier unavailable${s.ok ? "" : ` (${s.reason})`} \u2014 falling back to Docker. Pass --runtime local to make this an error instead.`
68705
+ );
68706
+ } else {
68658
68707
  console.error(
68659
68708
  lang === "fr" ? "ultra11y scan : aucun runtime disponible \u2014 ni Playwright local (passez --cwd vers un projet avec @playwright/test + @axe-core/playwright install\xE9s), ni Docker. Voir --runtime." : "ultra11y scan: no runtime available \u2014 neither a local Playwright (pass --cwd at a project with @playwright/test + @axe-core/playwright installed) nor Docker. See --runtime."
68660
68709
  );
@@ -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.14.2
7
+ version: 5.14.4
8
8
  ---
9
9
 
10
10
  # ultra11y — audit WCAG 2.2 AA and write accessible markup
@@ -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.14.2";
9
+ var VERSION = "5.14.4";
10
10
  var SCHEMA_VERSION = 2;
11
11
  var INAPPLICABLE_STATUS = "C";
12
12
 
@@ -56864,12 +56864,27 @@ function sectionBody(md, n) {
56864
56864
  function checkSampleCaptured(root = ".", lang = "en") {
56865
56865
  const fr = lang === "fr";
56866
56866
  let declared = [];
56867
+ const rc = join34(root, ".ultra11yrc.json");
56868
+ let raw;
56867
56869
  try {
56868
- const cfg = JSON.parse(readFileSync18(join34(root, ".ultra11yrc.json"), "utf8"));
56869
- declared = cfg.sample?.pages ?? [];
56870
+ raw = readFileSync18(rc, "utf8");
56870
56871
  } catch {
56871
56872
  return { ok: true, issues: [], missing: [], declared: 0, captured: 0 };
56872
56873
  }
56874
+ try {
56875
+ const cfg = JSON.parse(raw);
56876
+ declared = cfg.sample?.pages ?? [];
56877
+ } catch (e) {
56878
+ return {
56879
+ ok: false,
56880
+ issues: [
56881
+ fr ? `.ultra11yrc.json est pr\xE9sent mais illisible (${e instanceof Error ? e.message : String(e)}) \u2014 impossible de savoir quelles pages ce run devait couvrir. La porte de couverture ne peut pas r\xE9pondre, et ne r\xE9pondra donc pas \xAB couvert \xBB.` : `.ultra11yrc.json is present but unreadable (${e instanceof Error ? e.message : String(e)}) \u2014 there is no way to know which pages this run was meant to cover. The coverage gate cannot answer, so it does not answer "covered".`
56882
+ ],
56883
+ missing: [],
56884
+ declared: 0,
56885
+ captured: 0
56886
+ };
56887
+ }
56873
56888
  if (!declared.length) return { ok: true, issues: [], missing: [], declared: 0, captured: 0 };
56874
56889
  let captured = /* @__PURE__ */ new Set();
56875
56890
  try {
@@ -59170,10 +59185,23 @@ var PROBE_FIELDS = [
59170
59185
  { key: "inputOverflowSpacing", engine: "input-overflow-spacing" },
59171
59186
  { key: "liveRegion", engine: "live-region" }
59172
59187
  ];
59188
+ function loopbackToHostGateway(target) {
59189
+ const m = /^http:\/\/(localhost|127(?:\.\d{1,3}){3})(:\d+)?(\/.*)?$/i.exec(target);
59190
+ if (!m) return { url: target, addHost: false };
59191
+ return { url: `http://host.docker.internal${m[2] ?? ""}${m[3] ?? ""}`, addHost: true };
59192
+ }
59173
59193
  function runRunner(target, isFile, tag, snapshot = true) {
59194
+ const hostTarget = target;
59174
59195
  const args2 = ["run", "--rm"];
59175
59196
  if (!snapshot) args2.push("-e", "ULTRA11Y_SNAPSHOT=0");
59176
59197
  if (isFile) args2.push("-v", `${resolve11(target)}:${MOUNT}:ro`);
59198
+ else {
59199
+ const gw = loopbackToHostGateway(target);
59200
+ if (gw.addHost && process.platform === "linux") {
59201
+ args2.push("--add-host", "host.docker.internal:host-gateway");
59202
+ }
59203
+ target = gw.url;
59204
+ }
59177
59205
  args2.push(tag, isFile ? MOUNT : target);
59178
59206
  let stdout;
59179
59207
  try {
@@ -59184,7 +59212,12 @@ function runRunner(target, isFile, tag, snapshot = true) {
59184
59212
  throw new Error(`docker run failed \u2014 ${detail}`);
59185
59213
  }
59186
59214
  const line = stdout.trim().split("\n").filter(Boolean).pop() ?? "{}";
59187
- return JSON.parse(line);
59215
+ const out2 = JSON.parse(line);
59216
+ if (loopbackToHostGateway(hostTarget).addHost && out2.url) {
59217
+ const hostSchemeName = hostTarget.match(/^http:\/\/[^:/]+/)?.[0] ?? hostTarget;
59218
+ out2.url = out2.url.replace(/^http:\/\/host\.docker\.internal(?=:\d+|\/|$)/i, hostSchemeName);
59219
+ }
59220
+ return out2;
59188
59221
  }
59189
59222
  function hostPageOf(url, target) {
59190
59223
  if (!url) return target;
@@ -59703,18 +59736,29 @@ function localTestedScs(interact) {
59703
59736
  }
59704
59737
  var PW_SPEC = "@playwright/test";
59705
59738
  var AXE_SPEC = "@axe-core/playwright";
59706
- function localAvailable(cwd) {
59739
+ function localTierStatus(cwd) {
59740
+ const req = createRequire(resolve12(cwd, "package.json"));
59741
+ for (const spec of [PW_SPEC, AXE_SPEC]) {
59742
+ try {
59743
+ req.resolve(spec);
59744
+ } catch {
59745
+ return { ok: false, reason: `${spec} does not resolve from ${cwd}` };
59746
+ }
59747
+ }
59707
59748
  try {
59708
- const req = createRequire(resolve12(cwd, "package.json"));
59709
- req.resolve(PW_SPEC);
59710
- req.resolve(AXE_SPEC);
59711
59749
  const pw = req(PW_SPEC);
59712
59750
  const bin = pw.chromium?.executablePath?.();
59713
- return typeof bin !== "string" || bin.length === 0 ? true : existsSync24(bin);
59714
- } catch {
59715
- return false;
59751
+ if (typeof bin === "string" && bin.length > 0 && !existsSync24(bin)) {
59752
+ return { ok: false, reason: `no browser binary at ${bin} \u2014 run \`npx playwright install chromium\`` };
59753
+ }
59754
+ return { ok: true };
59755
+ } catch (e) {
59756
+ return { ok: false, reason: `${PW_SPEC} failed to load: ${e.message.split("\n")[0]}` };
59716
59757
  }
59717
59758
  }
59759
+ function localAvailable(cwd) {
59760
+ return localTierStatus(cwd).ok;
59761
+ }
59718
59762
  function resolveLocalDeps(cwd) {
59719
59763
  let chromium;
59720
59764
  let AxeBuilder;
@@ -68653,8 +68697,13 @@ async function cmdScan(p) {
68653
68697
  if (runtimeFlag === "local") useLocal = true;
68654
68698
  else if (runtimeFlag === "docker") useLocal = false;
68655
68699
  else if (localAvailable(cwd)) useLocal = true;
68656
- else if (dockerAvailable()) useLocal = false;
68657
- else {
68700
+ else if (dockerAvailable()) {
68701
+ useLocal = false;
68702
+ const s = localTierStatus(cwd);
68703
+ console.error(
68704
+ lang === "fr" ? `ultra11y scan : tier local indisponible${s.ok ? "" : ` (${s.reason})`} \u2014 bascule sur Docker. Passez --runtime local pour en faire une erreur.` : `ultra11y scan: local tier unavailable${s.ok ? "" : ` (${s.reason})`} \u2014 falling back to Docker. Pass --runtime local to make this an error instead.`
68705
+ );
68706
+ } else {
68658
68707
  console.error(
68659
68708
  lang === "fr" ? "ultra11y scan : aucun runtime disponible \u2014 ni Playwright local (passez --cwd vers un projet avec @playwright/test + @axe-core/playwright install\xE9s), ni Docker. Voir --runtime." : "ultra11y scan: no runtime available \u2014 neither a local Playwright (pass --cwd at a project with @playwright/test + @axe-core/playwright installed) nor Docker. See --runtime."
68660
68709
  );
package/src/check.ts CHANGED
@@ -362,14 +362,36 @@ export interface SampleCoverage {
362
362
  export function checkSampleCaptured(root = ".", lang: Lang = "en"): SampleCoverage {
363
363
  const fr = lang === "fr";
364
364
  let declared: { id: string; name?: string; url?: string }[] = [];
365
+ const rc = join(root, ".ultra11yrc.json");
366
+ let raw: string;
365
367
  try {
366
- const cfg = JSON.parse(readFileSync(join(root, ".ultra11yrc.json"), "utf8")) as {
367
- sample?: { pages?: { id: string; name?: string; url?: string }[] };
368
- };
369
- declared = cfg.sample?.pages ?? [];
368
+ raw = readFileSync(rc, "utf8");
370
369
  } catch {
370
+ // NO CONFIG AT ALL — nothing is declared, so there is nothing to answer for. This is the
371
+ // one silence that may pass: a gate that fails on its own absence is a gate that gets
372
+ // turned off.
371
373
  return { ok: true, issues: [], missing: [], declared: 0, captured: 0 };
372
374
  }
375
+ try {
376
+ const cfg = JSON.parse(raw) as { sample?: { pages?: { id: string; name?: string; url?: string }[] } };
377
+ declared = cfg.sample?.pages ?? [];
378
+ } catch (e) {
379
+ // A CONFIG THAT EXISTS AND CANNOT BE READ is the opposite case, and it must not pass. The
380
+ // file is where the sample is declared, so an unparseable one means this gate does not know
381
+ // what the run was supposed to cover — and answering "covered" to that is how a gate
382
+ // disarms itself at exactly the moment something is wrong.
383
+ return {
384
+ ok: false,
385
+ issues: [
386
+ fr
387
+ ? `.ultra11yrc.json est présent mais illisible (${e instanceof Error ? e.message : String(e)}) — impossible de savoir quelles pages ce run devait couvrir. La porte de couverture ne peut pas répondre, et ne répondra donc pas « couvert ».`
388
+ : `.ultra11yrc.json is present but unreadable (${e instanceof Error ? e.message : String(e)}) — there is no way to know which pages this run was meant to cover. The coverage gate cannot answer, so it does not answer "covered".`,
389
+ ],
390
+ missing: [],
391
+ declared: 0,
392
+ captured: 0,
393
+ };
394
+ }
373
395
  if (!declared.length) return { ok: true, issues: [], missing: [], declared: 0, captured: 0 };
374
396
 
375
397
  let captured = new Set<string>();
package/src/cli.ts CHANGED
@@ -53,7 +53,7 @@ import {
53
53
  import { entriesFrom, isLedger, ledgerPath, mergeLedger, readLedger, replayLedger, 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
- import { runScanLocal, runScanManyLocal, runCrawlScanLocal, runSampleScanLocal, localAvailable } from "./scan-local.js";
56
+ import { runScanLocal, runScanManyLocal, runCrawlScanLocal, runSampleScanLocal, localAvailable, localTierStatus } from "./scan-local.js";
57
57
  import { validateSample, lintSample, kindLabel, proposeSamplePages, mergeSample, sampleFromSnapshots, unionSample } from "./sample.js";
58
58
  import { createAdapter } from "./external/registry.js";
59
59
  import { diffSides, sideOfExternal, type DiffBucket } from "./external/diff.js";
@@ -3244,8 +3244,17 @@ async function cmdScan(p: ParsedArgs): Promise<number> {
3244
3244
  if (runtimeFlag === "local") useLocal = true;
3245
3245
  else if (runtimeFlag === "docker") useLocal = false;
3246
3246
  else if (localAvailable(cwd)) useLocal = true;
3247
- else if (dockerAvailable()) useLocal = false;
3248
- else {
3247
+ else if (dockerAvailable()) {
3248
+ useLocal = false;
3249
+ // A silent degrade is indistinguishable from a working fallback — name what was
3250
+ // missing, so the one line a reader gets answers "what do I install".
3251
+ const s = localTierStatus(cwd);
3252
+ console.error(
3253
+ lang === "fr"
3254
+ ? `ultra11y scan : tier local indisponible${s.ok ? "" : ` (${s.reason})`} — bascule sur Docker. Passez --runtime local pour en faire une erreur.`
3255
+ : `ultra11y scan: local tier unavailable${s.ok ? "" : ` (${s.reason})`} — falling back to Docker. Pass --runtime local to make this an error instead.`,
3256
+ );
3257
+ } else {
3249
3258
  console.error(
3250
3259
  lang === "fr"
3251
3260
  ? "ultra11y scan : aucun runtime disponible — ni Playwright local (passez --cwd vers un projet avec @playwright/test + @axe-core/playwright installés), ni Docker. Voir --runtime."
package/src/scan-local.ts CHANGED
@@ -59,6 +59,37 @@ interface LocalDeps {
59
59
  const PW_SPEC = "@playwright/test";
60
60
  const AXE_SPEC = "@axe-core/playwright";
61
61
 
62
+ /** Why the LOCAL tier is (un)usable from `cwd` — the one line a reader of a
63
+ * `runtime: auto` log gets before the Docker fallback takes over. A silent degrade is
64
+ * indistinguishable from a working fallback, so the reason must NAME what to install. */
65
+ export type LocalTierStatus = { ok: true } | { ok: false; reason: string };
66
+
67
+ /** The same probe as `localAvailable`, keeping the reason instead of collapsing it to a
68
+ * boolean. Drives the `runtime: auto` decision AND the message printed when it degrades
69
+ * to Docker. */
70
+ export function localTierStatus(cwd: string): LocalTierStatus {
71
+ const req = createRequire(resolve(cwd, "package.json"));
72
+ 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}` };
77
+ }
78
+ }
79
+ try {
80
+ const pw = req(PW_SPEC) as { chromium?: { executablePath?: () => string } };
81
+ const bin = pw.chromium?.executablePath?.();
82
+ // No path at all ⇒ a Playwright that cannot tell us; treat as available and let the
83
+ // launch report its own failure, rather than refusing a tier that might work.
84
+ if (typeof bin === "string" && bin.length > 0 && !existsSync(bin)) {
85
+ return { ok: false, reason: `no browser binary at ${bin} — run \`npx playwright install chromium\`` };
86
+ }
87
+ return { ok: true };
88
+ } catch (e) {
89
+ return { ok: false, reason: `${PW_SPEC} failed to load: ${(e as Error).message.split("\n")[0]}` };
90
+ }
91
+ }
92
+
62
93
  /** Is the LOCAL tier actually usable from `cwd`? Drives `runtime: auto`.
63
94
  *
64
95
  * Both packages must resolve — and the browser BINARY must be on disk. The second half is not
@@ -73,18 +104,7 @@ const AXE_SPEC = "@axe-core/playwright";
73
104
  * browser, and failed rather than degrading. `executablePath()` is a pure path computation, so
74
105
  * this stays cheap: no launch, no download, one `stat`. */
75
106
  export function localAvailable(cwd: string): boolean {
76
- try {
77
- const req = createRequire(resolve(cwd, "package.json"));
78
- req.resolve(PW_SPEC);
79
- req.resolve(AXE_SPEC);
80
- const pw = req(PW_SPEC) as { chromium?: { executablePath?: () => string } };
81
- const bin = pw.chromium?.executablePath?.();
82
- // No path at all ⇒ a Playwright that cannot tell us; treat as available and let the launch
83
- // report its own failure, rather than refusing a tier that might work.
84
- return typeof bin !== "string" || bin.length === 0 ? true : existsSync(bin);
85
- } catch {
86
- return false;
87
- }
107
+ return localTierStatus(cwd).ok;
88
108
  }
89
109
 
90
110
  /** Load Playwright `chromium` + the `AxeBuilder` class from the target project. */
package/src/scan.ts CHANGED
@@ -251,10 +251,34 @@ const PROBE_FIELDS: { key: keyof RunnerOutput; engine: Exclude<DynamicEngine, "a
251
251
  { key: "liveRegion", engine: "live-region" },
252
252
  ];
253
253
 
254
+ /** Rewrite a HOST loopback URL into one a container can reach. `docker run` gets no
255
+ * `--network host`, so the container's 127.0.0.1 is its own — a scan of
256
+ * `http://localhost:3000` (the action's `start:` + `urls:` shape) died on
257
+ * ERR_CONNECTION_REFUSED while the app ran the whole time, which is how this
258
+ * repository's CI stayed red for a day. `host.docker.internal` is natively resolved by
259
+ * Docker Desktop (macOS/Windows); on Linux the caller adds
260
+ * `--add-host host.docker.internal:host-gateway`. https:// loopback is deliberately NOT
261
+ * rewritten: the container would fail the self-signed certificate anyway. */
262
+ export function loopbackToHostGateway(target: string): { url: string; addHost: boolean } {
263
+ const m = /^http:\/\/(localhost|127(?:\.\d{1,3}){3})(:\d+)?(\/.*)?$/i.exec(target);
264
+ if (!m) return { url: target, addHost: false };
265
+ return { url: `http://host.docker.internal${m[2] ?? ""}${m[3] ?? ""}`, addHost: true };
266
+ }
267
+
254
268
  function runRunner(target: string, isFile: boolean, tag: string, snapshot = true): RunnerOutput {
269
+ const hostTarget = target;
255
270
  const args = ["run", "--rm"];
256
271
  if (!snapshot) args.push("-e", "ULTRA11Y_SNAPSHOT=0");
257
272
  if (isFile) args.push("-v", `${resolve(target)}:${MOUNT}:ro`);
273
+ else {
274
+ // The container must reach the HOST's loopback through host.docker.internal; on
275
+ // Linux that name needs the host-gateway mapping Docker Desktop provides natively.
276
+ const gw = loopbackToHostGateway(target);
277
+ if (gw.addHost && process.platform === "linux") {
278
+ args.push("--add-host", "host.docker.internal:host-gateway");
279
+ }
280
+ target = gw.url;
281
+ }
258
282
  args.push(tag, isFile ? MOUNT : target);
259
283
  let stdout: string;
260
284
  try {
@@ -272,7 +296,17 @@ function runRunner(target: string, isFile: boolean, tag: string, snapshot = true
272
296
  throw new Error(`docker run failed — ${detail}`);
273
297
  }
274
298
  const line = stdout.trim().split("\n").filter(Boolean).pop() ?? "{}";
275
- return JSON.parse(line) as RunnerOutput;
299
+ const out = JSON.parse(line) as RunnerOutput;
300
+ // Report what a HOST reader can open: the runner finished on host.docker.internal, and
301
+ // every downstream citation (hostPageOf, snapshot identity) derives from out.url. Only
302
+ // the HOSTNAME is restored — the port stays from the container URL, which was copied
303
+ // from the target, so pasting a full origin would double it
304
+ // (`http://127.0.0.1:8931:8931/…`).
305
+ if (loopbackToHostGateway(hostTarget).addHost && out.url) {
306
+ const hostSchemeName = hostTarget.match(/^http:\/\/[^:/]+/)?.[0] ?? hostTarget;
307
+ out.url = out.url.replace(/^http:\/\/host\.docker\.internal(?=:\d+|\/|$)/i, hostSchemeName);
308
+ }
309
+ return out;
276
310
  }
277
311
 
278
312
  /** Map the runner's reported page back to a HOST-meaningful citation: the container mount
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.14.2";
3
+ export const VERSION = "5.14.4";
4
4
  export const SCHEMA_VERSION = 2;
5
5
 
6
6
  // Lang = the UI FRAME's language — the `L` localization tables each module (report,