ultra11y 4.5.0 → 4.5.2

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.5.0",
13
+ "version": "4.5.2",
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.5.0",
4
+ "version": "4.5.2",
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.5.0",
4
+ "version": "4.5.2",
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.5.0";
30
+ const ULTRA11Y_PLUGIN_VERSION = "4.5.2";
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": "4.5.0",
3
+ "version": "4.5.2",
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 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.5.0";
9
+ var VERSION = "4.5.2";
10
10
  var SCHEMA_VERSION = 2;
11
11
 
12
12
  // src/audit.ts
@@ -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) {
@@ -66615,8 +66635,9 @@ async function cmdScan(p) {
66615
66635
  }
66616
66636
  sampleConfig = v.sample;
66617
66637
  if (!useLocal && sampleConfig.pages.some((pg) => pg.storageState)) {
66638
+ const forced = typeof p.flags.runtime === "string" && p.flags.runtime === "docker";
66618
66639
  console.error(
66619
- lang === "fr" ? "ultra11y scan : l'\xE9chantillon comporte des pages authentifi\xE9es (storageState), non prises en charge par le runtime Docker. Utilisez --runtime local --cwd <projet>." : "ultra11y scan: the sample has authenticated pages (storageState), unsupported by the Docker runtime. Use --runtime local --cwd <project>."
66640
+ lang === "fr" ? `ultra11y scan : l'\xE9chantillon comporte des pages authentifi\xE9es (storageState), non prises en charge par le runtime Docker.${forced ? " Retirez --runtime docker." : " Le runtime local n'a pas pu \xEAtre r\xE9solu : installez @playwright/test et @axe-core/playwright dans le projet audit\xE9 (ou pointez --cwd dessus). Via l'Action, ce sont des d\xE9pendances du d\xE9p\xF4t."}` : `ultra11y scan: the sample has authenticated pages (storageState), unsupported by the Docker runtime.${forced ? " Drop --runtime docker." : " The local runtime could not be resolved: install @playwright/test and @axe-core/playwright in the audited project (or point --cwd at it). Through the Action, those are dependencies of the repository."}`
66620
66641
  );
66621
66642
  return 2;
66622
66643
  }
@@ -66645,6 +66666,13 @@ async function cmdScan(p) {
66645
66666
  console.error(`ultra11y scan: ${e instanceof Error ? e.message : String(e)}`);
66646
66667
  return 1;
66647
66668
  }
66669
+ if (dynamic.redirected?.length) {
66670
+ for (const r of dynamic.redirected) {
66671
+ console.error(
66672
+ 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.`
66673
+ );
66674
+ }
66675
+ }
66648
66676
  if (useSample && sampleConfig) {
66649
66677
  const lintKey = typeof p.flags.standard === "string" && p.flags.standard ? p.flags.standard : "rgaa";
66650
66678
  try {
@@ -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.5.0
8
+ version: 4.5.2
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.5.0";
9
+ var VERSION = "4.5.2";
10
10
  var SCHEMA_VERSION = 2;
11
11
 
12
12
  // src/audit.ts
@@ -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) {
@@ -66615,8 +66635,9 @@ async function cmdScan(p) {
66615
66635
  }
66616
66636
  sampleConfig = v.sample;
66617
66637
  if (!useLocal && sampleConfig.pages.some((pg) => pg.storageState)) {
66638
+ const forced = typeof p.flags.runtime === "string" && p.flags.runtime === "docker";
66618
66639
  console.error(
66619
- lang === "fr" ? "ultra11y scan : l'\xE9chantillon comporte des pages authentifi\xE9es (storageState), non prises en charge par le runtime Docker. Utilisez --runtime local --cwd <projet>." : "ultra11y scan: the sample has authenticated pages (storageState), unsupported by the Docker runtime. Use --runtime local --cwd <project>."
66640
+ lang === "fr" ? `ultra11y scan : l'\xE9chantillon comporte des pages authentifi\xE9es (storageState), non prises en charge par le runtime Docker.${forced ? " Retirez --runtime docker." : " Le runtime local n'a pas pu \xEAtre r\xE9solu : installez @playwright/test et @axe-core/playwright dans le projet audit\xE9 (ou pointez --cwd dessus). Via l'Action, ce sont des d\xE9pendances du d\xE9p\xF4t."}` : `ultra11y scan: the sample has authenticated pages (storageState), unsupported by the Docker runtime.${forced ? " Drop --runtime docker." : " The local runtime could not be resolved: install @playwright/test and @axe-core/playwright in the audited project (or point --cwd at it). Through the Action, those are dependencies of the repository."}`
66620
66641
  );
66621
66642
  return 2;
66622
66643
  }
@@ -66645,6 +66666,13 @@ async function cmdScan(p) {
66645
66666
  console.error(`ultra11y scan: ${e instanceof Error ? e.message : String(e)}`);
66646
66667
  return 1;
66647
66668
  }
66669
+ if (dynamic.redirected?.length) {
66670
+ for (const r of dynamic.redirected) {
66671
+ console.error(
66672
+ 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.`
66673
+ );
66674
+ }
66675
+ }
66648
66676
  if (useSample && sampleConfig) {
66649
66677
  const lintKey = typeof p.flags.standard === "string" && p.flags.standard ? p.flags.standard : "rgaa";
66650
66678
  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.5.0
7
+ version: 4.5.2
8
8
  ---
9
9
 
10
10
  # ultra11y — audit WCAG 2.2 AA and write accessible markup
@@ -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.5.0";
9
+ var VERSION = "4.5.2";
10
10
  var SCHEMA_VERSION = 2;
11
11
 
12
12
  // src/audit.ts
@@ -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) {
@@ -66615,8 +66635,9 @@ async function cmdScan(p) {
66615
66635
  }
66616
66636
  sampleConfig = v.sample;
66617
66637
  if (!useLocal && sampleConfig.pages.some((pg) => pg.storageState)) {
66638
+ const forced = typeof p.flags.runtime === "string" && p.flags.runtime === "docker";
66618
66639
  console.error(
66619
- lang === "fr" ? "ultra11y scan : l'\xE9chantillon comporte des pages authentifi\xE9es (storageState), non prises en charge par le runtime Docker. Utilisez --runtime local --cwd <projet>." : "ultra11y scan: the sample has authenticated pages (storageState), unsupported by the Docker runtime. Use --runtime local --cwd <project>."
66640
+ lang === "fr" ? `ultra11y scan : l'\xE9chantillon comporte des pages authentifi\xE9es (storageState), non prises en charge par le runtime Docker.${forced ? " Retirez --runtime docker." : " Le runtime local n'a pas pu \xEAtre r\xE9solu : installez @playwright/test et @axe-core/playwright dans le projet audit\xE9 (ou pointez --cwd dessus). Via l'Action, ce sont des d\xE9pendances du d\xE9p\xF4t."}` : `ultra11y scan: the sample has authenticated pages (storageState), unsupported by the Docker runtime.${forced ? " Drop --runtime docker." : " The local runtime could not be resolved: install @playwright/test and @axe-core/playwright in the audited project (or point --cwd at it). Through the Action, those are dependencies of the repository."}`
66620
66641
  );
66621
66642
  return 2;
66622
66643
  }
@@ -66645,6 +66666,13 @@ async function cmdScan(p) {
66645
66666
  console.error(`ultra11y scan: ${e instanceof Error ? e.message : String(e)}`);
66646
66667
  return 1;
66647
66668
  }
66669
+ if (dynamic.redirected?.length) {
66670
+ for (const r of dynamic.redirected) {
66671
+ console.error(
66672
+ 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.`
66673
+ );
66674
+ }
66675
+ }
66648
66676
  if (useSample && sampleConfig) {
66649
66677
  const lintKey = typeof p.flags.standard === "string" && p.flags.standard ? p.flags.standard : "rgaa";
66650
66678
  try {
package/src/cli.ts CHANGED
@@ -3004,10 +3004,25 @@ async function cmdScan(p: ParsedArgs): Promise<number> {
3004
3004
  }
3005
3005
  sampleConfig = v.sample;
3006
3006
  if (!useLocal && sampleConfig.pages.some((pg) => pg.storageState)) {
3007
+ // `--runtime local` is not always the caller's to pass: inside the GitHub Action the
3008
+ // scan is invoked for them. On `auto` the runtime fell back to Docker because the local
3009
+ // tier could not resolve, so naming the missing dependency is the only actionable half
3010
+ // of this message — "use --runtime local" alone sends an Action user to a flag they
3011
+ // cannot reach, and telling them to force a runtime that still cannot load would
3012
+ // exchange this error for a worse one.
3013
+ const forced = typeof p.flags.runtime === "string" && p.flags.runtime === "docker";
3007
3014
  console.error(
3008
3015
  lang === "fr"
3009
- ? "ultra11y scan : l'échantillon comporte des pages authentifiées (storageState), non prises en charge par le runtime Docker. Utilisez --runtime local --cwd <projet>."
3010
- : "ultra11y scan: the sample has authenticated pages (storageState), unsupported by the Docker runtime. Use --runtime local --cwd <project>.",
3016
+ ? `ultra11y scan : l'échantillon comporte des pages authentifiées (storageState), non prises en charge par le runtime Docker.${
3017
+ forced
3018
+ ? " Retirez --runtime docker."
3019
+ : " Le runtime local n'a pas pu être résolu : installez @playwright/test et @axe-core/playwright dans le projet audité (ou pointez --cwd dessus). Via l'Action, ce sont des dépendances du dépôt."
3020
+ }`
3021
+ : `ultra11y scan: the sample has authenticated pages (storageState), unsupported by the Docker runtime.${
3022
+ forced
3023
+ ? " Drop --runtime docker."
3024
+ : " The local runtime could not be resolved: install @playwright/test and @axe-core/playwright in the audited project (or point --cwd at it). Through the Action, those are dependencies of the repository."
3025
+ }`,
3011
3026
  );
3012
3027
  return 2;
3013
3028
  }
@@ -3044,6 +3059,18 @@ async function cmdScan(p: ParsedArgs): Promise<number> {
3044
3059
  console.error(`ultra11y scan: ${e instanceof Error ? e.message : String(e)}`);
3045
3060
  return 1;
3046
3061
  }
3062
+ // Pages the scan refused to file, because the browser ended up on another route. Loud, and
3063
+ // on stderr: a silently shorter sample is indistinguishable from a sample that passed, and
3064
+ // the cause is always fixable — an expired session, or state the wizard step needs.
3065
+ if (dynamic.redirected?.length) {
3066
+ for (const r of dynamic.redirected) {
3067
+ console.error(
3068
+ lang === "fr"
3069
+ ? `⚠️ 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.`
3070
+ : `⚠️ 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.`,
3071
+ );
3072
+ }
3073
+ }
3047
3074
  // Advisory sample-methodology lint: which normative page KINDS the configured sample
3048
3075
  // lacks. Standard-agnostic mechanics + RGAA-specific required kinds; scan has no
3049
3076
  // --standard, so we lint against the config's default standard (else RGAA if registered).
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.5.0";
3
+ export const VERSION = "4.5.2";
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
  }