ultra11y 4.5.2 → 5.0.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -10,7 +10,7 @@
10
10
  "name": "ultra11y",
11
11
  "source": "./",
12
12
  "description": "Automatic accessibility review of the code under change before every commit, push and pull request, plus full WCAG 2.2 AA / RGAA audits and conformance deliverables on demand.",
13
- "version": "4.5.2",
13
+ "version": "5.0.1",
14
14
  "author": { "name": "maxgfr" },
15
15
  "homepage": "https://github.com/maxgfr/ultra11y",
16
16
  "repository": "https://github.com/maxgfr/ultra11y",
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "ultra11y",
3
3
  "description": "Accessibility for WCAG 2.2 AA and country standards (RGAA…): an automatic review of the code under change before every commit, push and pull request, plus full audits and conformance deliverables on demand.",
4
- "version": "4.5.2",
4
+ "version": "5.0.1",
5
5
  "author": { "name": "maxgfr" },
6
6
  "homepage": "https://github.com/maxgfr/ultra11y",
7
7
  "repository": "https://github.com/maxgfr/ultra11y",
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "ultra11y",
3
3
  "description": "Accessibility for WCAG 2.2 AA and country standards (RGAA…): an automatic review of the code under change before every commit, push and pull request, plus full audits and conformance deliverables on demand.",
4
- "version": "4.5.2",
4
+ "version": "5.0.1",
5
5
  "author": { "name": "maxgfr", "url": "https://github.com/maxgfr" },
6
6
  "homepage": "https://github.com/maxgfr/ultra11y",
7
7
  "repository": "https://github.com/maxgfr/ultra11y",
@@ -27,7 +27,7 @@ import { fileURLToPath } from "node:url";
27
27
  // Ownership marker: `ultra11y install --opencode` refuses to overwrite a plugin file that
28
28
  // does not carry it, so a user's own ultra11y.js is never clobbered.
29
29
  const ULTRA11Y_OPENCODE_PLUGIN = "ULTRA11Y_OPENCODE_PLUGIN";
30
- const ULTRA11Y_PLUGIN_VERSION = "4.5.2";
30
+ const ULTRA11Y_PLUGIN_VERSION = "5.0.1";
31
31
 
32
32
  const here = dirname(fileURLToPath(import.meta.url));
33
33
 
package/README.md CHANGED
@@ -125,7 +125,7 @@ claude mcp add ultra11y -- npx -y ultra11y mcp
125
125
  **As a GitHub Action** — audits the PR diff and, optionally, the served pages:
126
126
 
127
127
  ```yaml
128
- - uses: maxgfr/ultra11y@v4
128
+ - uses: maxgfr/ultra11y@v5
129
129
  with: { since: auto, standard: rgaa, fail-on: blocking }
130
130
  ```
131
131
 
@@ -383,7 +383,7 @@ a test). See [`CONTRIBUTING.md`](CONTRIBUTING.md) and `skills/ultra11y/reference
383
383
  block. It re-decides nothing and invents no format, so a page sheet and the compliance report
384
384
  cannot disagree; `check` gates it against invented criteria like any other report.
385
385
  See `references/pages.md`.
386
- - **A shipped GitHub Action** — `uses: maxgfr/ultra11y@v4` audits the **code** (PR diff) and,
386
+ - **A shipped GitHub Action** — `uses: maxgfr/ultra11y@v5` audits the **code** (PR diff) and,
387
387
  optionally, the **pages** (it can start your app, wait for it, then scan real URLs or your
388
388
  declared sample). The engine ships inside the action, so there is nothing to install and no
389
389
  `setup-node`. The gate runs **last**, after SARIF, annotations, the summary, the sticky PR
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "ultra11y",
3
- "version": "4.5.2",
3
+ "version": "5.0.1",
4
4
  "description": "Audit HTML/CSS/JSX against WCAG 2.2 AA and produce a dated compliance report, or author/review accessible markup (native-HTML-first). A skills.sh agent skill: an install-free static engine (bundled, no npm install) plus the agent's judgment, with check/verify gates against hallucinated non-conformities, and pluggable in-repo standards packs (RGAA France first; Section 508 & EN 301 549 next).",
5
5
  "type": "module",
6
6
  "packageManager": "pnpm@10.33.0",
@@ -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.2";
9
+ var VERSION = "5.0.1";
10
10
  var SCHEMA_VERSION = 2;
11
11
 
12
12
  // src/audit.ts
@@ -53203,6 +53203,20 @@ function pageGridModel(result, derived, standard, lang) {
53203
53203
  for (const p of derived) for (const pc of derivePackResults(pageView(result, p), standard)) put(pc.id, p.id, pc.status);
53204
53204
  return { rows, status };
53205
53205
  }
53206
+ function renderRedirected(redirected, lang = "en") {
53207
+ const fr = lang === "fr";
53208
+ const out2 = [
53209
+ fr ? `> \u26A0\uFE0F **${redirected.length} page(s) de l'\xE9chantillon n'ont pas \xE9t\xE9 enregistr\xE9es** \u2014 le navigateur n'est pas rest\xE9 sur l'adresse demand\xE9e. Les enregistrer aurait d\xE9crit un autre \xE9cran sous le nom demand\xE9. Elles ne comptent donc ni comme conformes ni comme non conformes : elles manquent.` : `> \u26A0\uFE0F **${redirected.length} sample page(s) were not recorded** \u2014 the browser did not stay on the address asked for. Recording them would have described another screen under the requested name. They count as neither conforming nor non-conforming: they are missing.`,
53210
+ "",
53211
+ fr ? "| Page | Demand\xE9 | Atteint | Motif |" : "| Page | Requested | Landed | Reason |",
53212
+ "| --- | --- | --- | --- |"
53213
+ ];
53214
+ for (const r of redirected) {
53215
+ const why = r.reason === "http-status" ? fr ? `HTTP ${r.status ?? "\u2265 400"} \u2014 page d'erreur rendue \xE0 la m\xEAme adresse` : `HTTP ${r.status ?? "\u2265 400"} \u2014 error page served at the same address` : fr ? "redirection" : "redirect";
53216
+ out2.push(`| ${r.name} (\`${r.id}\`) | \`${r.requested}\` | \`${r.landed}\` | ${why} |`);
53217
+ }
53218
+ return out2;
53219
+ }
53206
53220
  function renderPageGrid(result, pages, standard = CORE2, lang = "en") {
53207
53221
  const s = L3[lang];
53208
53222
  const out2 = [];
@@ -53445,6 +53459,7 @@ function render(r, lang, opts) {
53445
53459
  const derived = derivePages(r, pageScope);
53446
53460
  out2.push(`## \u{1F4C4} ${s.perPageTitle}`, "", `> ${s.perPageNote}`, "");
53447
53461
  if (r.scope.sample?.transverse?.length) out2.push(`> ${s.transverseNote(r.scope.sample.transverse.join(", "))}`, "");
53462
+ if (r.scope.redirected?.length) out2.push(...renderRedirected(r.scope.redirected, lang), "");
53448
53463
  const pack = isCore(opts.standard) ? void 0 : loadPack(opts.standard);
53449
53464
  for (const pg of derived) {
53450
53465
  const nc = pg.findings.filter((f) => !f.advisory);
@@ -57792,6 +57807,14 @@ function resolveHostAnchor(file, snippet2) {
57792
57807
  function mergeDynamic(audit2, dynamic, lang = "en") {
57793
57808
  const merged = JSON.parse(JSON.stringify(audit2));
57794
57809
  if (dynamic.sample) merged.scope.sample = dynamic.sample;
57810
+ if (dynamic.redirected?.length) {
57811
+ const byId3 = new Map((merged.scope.redirected ?? []).map((r) => [r.id, r]));
57812
+ for (const r of dynamic.redirected) byId3.set(r.id, r);
57813
+ const scannedIds = new Set((merged.scope.sample?.pages ?? []).map((p) => p.id));
57814
+ const still = [...byId3.values()].filter((r) => !scannedIds.has(r.id));
57815
+ if (still.length) merged.scope.redirected = still;
57816
+ else delete merged.scope.redirected;
57817
+ }
57795
57818
  if (dynamic.testedScs?.length) {
57796
57819
  const tested = /* @__PURE__ */ new Set([...merged.scope.scan?.testedScs ?? [], ...dynamic.testedScs]);
57797
57820
  merged.scope.scan = { testedScs: [...tested].sort() };
@@ -58354,17 +58377,23 @@ function clicksAllowed(storageState, interactClicks) {
58354
58377
  }
58355
58378
  function landedOnRequestedPage(requested, landed) {
58356
58379
  if (!landed || requested === landed) return true;
58357
- const path = (u) => {
58380
+ const parse2 = (u) => {
58358
58381
  try {
58359
- return new URL(u, "http://x.invalid").pathname.replace(/\/+$/, "");
58382
+ return new URL(u, "http://x.invalid");
58360
58383
  } catch {
58361
58384
  return void 0;
58362
58385
  }
58363
58386
  };
58364
- const a = path(requested);
58365
- const b = path(landed);
58366
- if (a === void 0 || b === void 0) return true;
58367
- return a === b;
58387
+ const a = parse2(requested);
58388
+ const b = parse2(landed);
58389
+ if (!a || !b) return true;
58390
+ const path = (u) => u.pathname.replace(/\/+$/, "");
58391
+ if (path(a) !== path(b)) return false;
58392
+ const host = (u) => u.hostname.replace(/^www\./, "");
58393
+ if (host(a) !== host(b)) return false;
58394
+ if (a.hash && a.hash !== b.hash) return false;
58395
+ if (a.search && a.search !== b.search) return false;
58396
+ return true;
58368
58397
  }
58369
58398
  async function probeLiveRegion(page, lang, allowClicks) {
58370
58399
  const detail = LIVE_REGION_DETAIL[lang] ?? LIVE_REGION_DETAIL.en;
@@ -58405,10 +58434,12 @@ async function runOnPage(browser, AxeBuilder, target, isFile, opts) {
58405
58434
  const empty = [];
58406
58435
  try {
58407
58436
  const url = isFile ? "file://" + resolve11(target) : target;
58408
- await page.goto(url, { waitUntil: "load", timeout: 45e3 });
58437
+ const response = await page.goto(url, { waitUntil: "load", timeout: 45e3 });
58409
58438
  await page.waitForLoadState("networkidle", { timeout: 8e3 }).catch(() => {
58410
58439
  });
58411
58440
  await page.waitForTimeout(1200);
58441
+ const landedUrl = page.url() || url;
58442
+ const httpStatus = typeof response?.status === "function" ? response.status() : void 0;
58412
58443
  let snapshot;
58413
58444
  let screenshot;
58414
58445
  if (opts.snapshot !== false) {
@@ -58454,6 +58485,8 @@ async function runOnPage(browser, AxeBuilder, target, isFile, opts) {
58454
58485
  const liveRegion = opts.interact ? await probeLiveRegion(page, l, opts.allowClicks).catch(() => empty) : [];
58455
58486
  return {
58456
58487
  url: page.url() || target,
58488
+ landedUrl,
58489
+ ...httpStatus !== void 0 ? { httpStatus } : {},
58457
58490
  violations,
58458
58491
  reflow,
58459
58492
  focusVisible: dialogFocus.length ? [...focusVisible, ...dialogFocus] : focusVisible,
@@ -58546,8 +58579,14 @@ async function runSampleScanLocal(pages, opts) {
58546
58579
  lang,
58547
58580
  snapshot: Boolean(opts.snapshotRoot)
58548
58581
  });
58549
- if (!landedOnRequestedPage(page.url, out2.url)) {
58550
- redirected.push({ id: page.id, name: page.name, requested: page.url, landed: out2.url });
58582
+ const requestedUrl = isFile ? "file://" + resolve11(page.url) : page.url;
58583
+ const landedUrl = out2.landedUrl ?? out2.url;
58584
+ if (!landedOnRequestedPage(requestedUrl, landedUrl)) {
58585
+ redirected.push({ id: page.id, name: page.name, requested: page.url, landed: landedUrl, reason: "redirect" });
58586
+ continue;
58587
+ }
58588
+ if (out2.httpStatus !== void 0 && out2.httpStatus >= 400) {
58589
+ redirected.push({ id: page.id, name: page.name, requested: page.url, landed: landedUrl, reason: "http-status", status: out2.httpStatus });
58551
58590
  continue;
58552
58591
  }
58553
58592
  findings.push(...tagSampleFindings(toDynamicResult(out2, page.url, lang, LOCAL_ENGINE).findings, page));
@@ -58557,13 +58596,15 @@ async function runSampleScanLocal(pages, opts) {
58557
58596
  } finally {
58558
58597
  await browser.close();
58559
58598
  }
58599
+ const dropped = new Set(redirected.map((r) => r.id));
58600
+ const scanned = pages.filter((p) => !dropped.has(p.id));
58560
58601
  return {
58561
58602
  tool: "ultra11y",
58562
58603
  engine: LOCAL_ENGINE,
58563
- target: `${pages.length} page(s) (\xE9chantillon)`,
58604
+ target: `${scanned.length}/${pages.length} page(s) (\xE9chantillon)`,
58564
58605
  date: today(),
58565
58606
  findings,
58566
- sample: sampleScope({ pages }),
58607
+ sample: sampleScope({ pages: scanned }),
58567
58608
  testedScs: localTestedScs(interact),
58568
58609
  ...snapshots.length ? { snapshots } : {},
58569
58610
  ...redirected.length ? { redirected } : {}
@@ -61882,6 +61923,20 @@ var ADJUDICATE_SCHEMA = {
61882
61923
  criteriaId: { type: "string" },
61883
61924
  verdict: { enum: ["C", "NC", "NA", "manual"] },
61884
61925
  justification: { type: "string", description: "REQUIRED for C and NA" },
61926
+ // REQUIRED by the fold alongside `justification`, for exactly the same reason
61927
+ // `normativeRef` is required on an NC: a cleared criterion has to name what it
61928
+ // cleared. Omitting it here (and from the adjudicator contract) made every
61929
+ // adjudication produce C/NA verdicts that `verify --apply` then refused wholesale —
61930
+ // 63 rejections out of 97 verdicts on the run that surfaced this.
61931
+ citations: {
61932
+ type: "array",
61933
+ description: "REQUIRED for C, and for NA when evidence was presented: the evidence items you cleared, `file`/`line` copied verbatim from this criterion's own evidence[]",
61934
+ items: {
61935
+ type: "object",
61936
+ required: ["file", "line"],
61937
+ properties: { file: { type: "string" }, line: { type: "number" }, selector: { type: "string" }, snippet: { type: "string" } }
61938
+ }
61939
+ },
61885
61940
  reason: { type: ["string", "null"], enum: ["needs-rendered-dom", "undecidable", null], description: "REQUIRED for a still-manual verdict" },
61886
61941
  findings: {
61887
61942
  type: "array",
@@ -62007,13 +62062,13 @@ For EACH of your criteria:
62007
62062
 
62008
62063
  1. Read its worklist entry. \`evidence[]\` holds source-anchored excerpts (\`file\`, \`line\`, \`selector\`, \`snippet\`) harvested from the audited code \u2014 open the cited files at the cited lines whenever the snippet alone cannot decide.
62009
62064
  2. Rule it (the apply gate is FAIL-CLOSED \u2014 a verdict missing its required field does not fold):
62010
- - \`C\` (conforming) \u2014 REQUIRES \`justification\` explaining why the evidence satisfies the criterion.
62065
+ - \`C\` (conforming) \u2014 REQUIRES \`justification\` explaining why the evidence satisfies the criterion, AND \`citations[]\` naming the evidence you cleared (\`file\`/\`line\` copied VERBATIM from this item's own \`evidence[]\`; an anchor that is not in that list is treated as fabricated). A criterion whose \`evidence[]\` is empty cannot be \`C\` at all \u2014 it is \`manual\` (\`undecidable\`), or \`NA\` if nothing in scope is concerned.
62011
62066
  - \`NC\` (non-conforming) \u2014 REQUIRES \`findings\`: at least one groundable \`{ file, line, selector?, message, snippet?, severity?, normativeRef }\` pointing at REAL source. The fold re-grounds every finding; an invented file:line is rejected. \`normativeRef\` MUST cite the precise failed test \u2014 under a country standard, one of the item's OWN tests, which the worklist lists for you under \xAB tests to rule on \xBB.
62012
- - \`NA\` (not applicable) \u2014 REQUIRES \`justification\`.
62067
+ - \`NA\` (not applicable) \u2014 REQUIRES \`justification\`, AND \`citations[]\` whenever evidence WAS presented, to say which of those items fall outside the criterion's scope.
62013
62068
  - \`manual\` (still undecidable) \u2014 REQUIRES \`reason\`: \`needs-rendered-dom\` (only a rendered DOM can decide, e.g. computed contrast) or \`undecidable\` (the evidence cannot settle it either way).
62014
62069
  3. Never guess. A criterion you cannot decide from real evidence stays \`manual\` with a reason \u2014 that is a valid, honest verdict; the scan tier or a human picks it up.
62015
62070
 
62016
- Return (structured output): \`{ "verdicts": [{ "criteriaId", "verdict", "justification", "reason", "findings" }] }\` \u2014 your ITEMS only, every field grounded in what you actually read, every NC finding carrying its \`normativeRef\`.
62071
+ Return (structured output): \`{ "verdicts": [{ "criteriaId", "verdict", "justification", "citations", "reason", "findings" }] }\` \u2014 your ITEMS only, every field grounded in what you actually read, every NC finding carrying its \`normativeRef\`, and every C (plus every evidenced NA) carrying its \`citations\`. A verdict that clears a criterion without naming what it cleared is refused by the fold, and one refusal discards the WHOLE adjudication \u2014 including every other verdict you got right.
62017
62072
  ${footer}`,
62018
62073
  refuter: `# Contract: refuter
62019
62074
 
@@ -66635,7 +66690,7 @@ async function cmdScan(p) {
66635
66690
  }
66636
66691
  sampleConfig = v.sample;
66637
66692
  if (!useLocal && sampleConfig.pages.some((pg) => pg.storageState)) {
66638
- const forced = typeof p.flags.runtime === "string" && p.flags.runtime === "docker";
66693
+ const forced = runtimeFlag === "docker";
66639
66694
  console.error(
66640
66695
  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."}`
66641
66696
  );
@@ -66668,10 +66723,17 @@ async function cmdScan(p) {
66668
66723
  }
66669
66724
  if (dynamic.redirected?.length) {
66670
66725
  for (const r of dynamic.redirected) {
66726
+ const why = r.reason === "http-status" ? lang === "fr" ? `a r\xE9pondu HTTP ${r.status} \xE0 la m\xEAme adresse` : `answered HTTP ${r.status} at the same address` : lang === "fr" ? `a redirig\xE9 vers ${r.landed}` : `redirected to ${r.landed}`;
66671
66727
  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.`
66728
+ lang === "fr" ? `\u26A0\uFE0F ultra11y scan : \xAB ${r.name} \xBB (${r.id}) non enregistr\xE9e \u2014 ${r.requested} ${why}. L'enregistrer aurait d\xE9crit cet \xE9cran sous le nom demand\xE9.` : `\u26A0\uFE0F ultra11y scan: "${r.name}" (${r.id}) not recorded \u2014 ${r.requested} ${why}. Recording it would have described that screen under the requested name.`
66673
66729
  );
66674
66730
  }
66731
+ if (useSample && sampleConfig && dynamic.redirected.length === sampleConfig.pages.length) {
66732
+ console.error(
66733
+ lang === "fr" ? `ultra11y scan : aucune des ${sampleConfig.pages.length} pages de l'\xE9chantillon n'a pu \xEAtre enregistr\xE9e \u2014 rien n'a \xE9t\xE9 mesur\xE9. V\xE9rifiez la session, l'URL de base et l'\xE9tat applicatif attendu par ces pages.` : `ultra11y scan: none of the ${sampleConfig.pages.length} sample pages could be recorded \u2014 nothing was measured. Check the session, the base URL, and the application state those pages expect.`
66734
+ );
66735
+ return 1;
66736
+ }
66675
66737
  }
66676
66738
  if (useSample && sampleConfig) {
66677
66739
  const lintKey = typeof p.flags.standard === "string" && p.flags.standard ? p.flags.standard : "rgaa";
@@ -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.2
8
+ version: 5.0.1
9
9
  ---
10
10
 
11
11
  # review-a11y — accessibility review of changed code
@@ -6,7 +6,7 @@ import { join as join50, relative as relative5, resolve as resolve15, sep as sep
6
6
  import { fileURLToPath as fileURLToPath5, pathToFileURL as pathToFileURL3 } from "url";
7
7
 
8
8
  // src/types.ts
9
- var VERSION = "4.5.2";
9
+ var VERSION = "5.0.1";
10
10
  var SCHEMA_VERSION = 2;
11
11
 
12
12
  // src/audit.ts
@@ -53203,6 +53203,20 @@ function pageGridModel(result, derived, standard, lang) {
53203
53203
  for (const p of derived) for (const pc of derivePackResults(pageView(result, p), standard)) put(pc.id, p.id, pc.status);
53204
53204
  return { rows, status };
53205
53205
  }
53206
+ function renderRedirected(redirected, lang = "en") {
53207
+ const fr = lang === "fr";
53208
+ const out2 = [
53209
+ fr ? `> \u26A0\uFE0F **${redirected.length} page(s) de l'\xE9chantillon n'ont pas \xE9t\xE9 enregistr\xE9es** \u2014 le navigateur n'est pas rest\xE9 sur l'adresse demand\xE9e. Les enregistrer aurait d\xE9crit un autre \xE9cran sous le nom demand\xE9. Elles ne comptent donc ni comme conformes ni comme non conformes : elles manquent.` : `> \u26A0\uFE0F **${redirected.length} sample page(s) were not recorded** \u2014 the browser did not stay on the address asked for. Recording them would have described another screen under the requested name. They count as neither conforming nor non-conforming: they are missing.`,
53210
+ "",
53211
+ fr ? "| Page | Demand\xE9 | Atteint | Motif |" : "| Page | Requested | Landed | Reason |",
53212
+ "| --- | --- | --- | --- |"
53213
+ ];
53214
+ for (const r of redirected) {
53215
+ const why = r.reason === "http-status" ? fr ? `HTTP ${r.status ?? "\u2265 400"} \u2014 page d'erreur rendue \xE0 la m\xEAme adresse` : `HTTP ${r.status ?? "\u2265 400"} \u2014 error page served at the same address` : fr ? "redirection" : "redirect";
53216
+ out2.push(`| ${r.name} (\`${r.id}\`) | \`${r.requested}\` | \`${r.landed}\` | ${why} |`);
53217
+ }
53218
+ return out2;
53219
+ }
53206
53220
  function renderPageGrid(result, pages, standard = CORE2, lang = "en") {
53207
53221
  const s = L3[lang];
53208
53222
  const out2 = [];
@@ -53445,6 +53459,7 @@ function render(r, lang, opts) {
53445
53459
  const derived = derivePages(r, pageScope);
53446
53460
  out2.push(`## \u{1F4C4} ${s.perPageTitle}`, "", `> ${s.perPageNote}`, "");
53447
53461
  if (r.scope.sample?.transverse?.length) out2.push(`> ${s.transverseNote(r.scope.sample.transverse.join(", "))}`, "");
53462
+ if (r.scope.redirected?.length) out2.push(...renderRedirected(r.scope.redirected, lang), "");
53448
53463
  const pack = isCore(opts.standard) ? void 0 : loadPack(opts.standard);
53449
53464
  for (const pg of derived) {
53450
53465
  const nc = pg.findings.filter((f) => !f.advisory);
@@ -57792,6 +57807,14 @@ function resolveHostAnchor(file, snippet2) {
57792
57807
  function mergeDynamic(audit2, dynamic, lang = "en") {
57793
57808
  const merged = JSON.parse(JSON.stringify(audit2));
57794
57809
  if (dynamic.sample) merged.scope.sample = dynamic.sample;
57810
+ if (dynamic.redirected?.length) {
57811
+ const byId3 = new Map((merged.scope.redirected ?? []).map((r) => [r.id, r]));
57812
+ for (const r of dynamic.redirected) byId3.set(r.id, r);
57813
+ const scannedIds = new Set((merged.scope.sample?.pages ?? []).map((p) => p.id));
57814
+ const still = [...byId3.values()].filter((r) => !scannedIds.has(r.id));
57815
+ if (still.length) merged.scope.redirected = still;
57816
+ else delete merged.scope.redirected;
57817
+ }
57795
57818
  if (dynamic.testedScs?.length) {
57796
57819
  const tested = /* @__PURE__ */ new Set([...merged.scope.scan?.testedScs ?? [], ...dynamic.testedScs]);
57797
57820
  merged.scope.scan = { testedScs: [...tested].sort() };
@@ -58354,17 +58377,23 @@ function clicksAllowed(storageState, interactClicks) {
58354
58377
  }
58355
58378
  function landedOnRequestedPage(requested, landed) {
58356
58379
  if (!landed || requested === landed) return true;
58357
- const path = (u) => {
58380
+ const parse2 = (u) => {
58358
58381
  try {
58359
- return new URL(u, "http://x.invalid").pathname.replace(/\/+$/, "");
58382
+ return new URL(u, "http://x.invalid");
58360
58383
  } catch {
58361
58384
  return void 0;
58362
58385
  }
58363
58386
  };
58364
- const a = path(requested);
58365
- const b = path(landed);
58366
- if (a === void 0 || b === void 0) return true;
58367
- return a === b;
58387
+ const a = parse2(requested);
58388
+ const b = parse2(landed);
58389
+ if (!a || !b) return true;
58390
+ const path = (u) => u.pathname.replace(/\/+$/, "");
58391
+ if (path(a) !== path(b)) return false;
58392
+ const host = (u) => u.hostname.replace(/^www\./, "");
58393
+ if (host(a) !== host(b)) return false;
58394
+ if (a.hash && a.hash !== b.hash) return false;
58395
+ if (a.search && a.search !== b.search) return false;
58396
+ return true;
58368
58397
  }
58369
58398
  async function probeLiveRegion(page, lang, allowClicks) {
58370
58399
  const detail = LIVE_REGION_DETAIL[lang] ?? LIVE_REGION_DETAIL.en;
@@ -58405,10 +58434,12 @@ async function runOnPage(browser, AxeBuilder, target, isFile, opts) {
58405
58434
  const empty = [];
58406
58435
  try {
58407
58436
  const url = isFile ? "file://" + resolve11(target) : target;
58408
- await page.goto(url, { waitUntil: "load", timeout: 45e3 });
58437
+ const response = await page.goto(url, { waitUntil: "load", timeout: 45e3 });
58409
58438
  await page.waitForLoadState("networkidle", { timeout: 8e3 }).catch(() => {
58410
58439
  });
58411
58440
  await page.waitForTimeout(1200);
58441
+ const landedUrl = page.url() || url;
58442
+ const httpStatus = typeof response?.status === "function" ? response.status() : void 0;
58412
58443
  let snapshot;
58413
58444
  let screenshot;
58414
58445
  if (opts.snapshot !== false) {
@@ -58454,6 +58485,8 @@ async function runOnPage(browser, AxeBuilder, target, isFile, opts) {
58454
58485
  const liveRegion = opts.interact ? await probeLiveRegion(page, l, opts.allowClicks).catch(() => empty) : [];
58455
58486
  return {
58456
58487
  url: page.url() || target,
58488
+ landedUrl,
58489
+ ...httpStatus !== void 0 ? { httpStatus } : {},
58457
58490
  violations,
58458
58491
  reflow,
58459
58492
  focusVisible: dialogFocus.length ? [...focusVisible, ...dialogFocus] : focusVisible,
@@ -58546,8 +58579,14 @@ async function runSampleScanLocal(pages, opts) {
58546
58579
  lang,
58547
58580
  snapshot: Boolean(opts.snapshotRoot)
58548
58581
  });
58549
- if (!landedOnRequestedPage(page.url, out2.url)) {
58550
- redirected.push({ id: page.id, name: page.name, requested: page.url, landed: out2.url });
58582
+ const requestedUrl = isFile ? "file://" + resolve11(page.url) : page.url;
58583
+ const landedUrl = out2.landedUrl ?? out2.url;
58584
+ if (!landedOnRequestedPage(requestedUrl, landedUrl)) {
58585
+ redirected.push({ id: page.id, name: page.name, requested: page.url, landed: landedUrl, reason: "redirect" });
58586
+ continue;
58587
+ }
58588
+ if (out2.httpStatus !== void 0 && out2.httpStatus >= 400) {
58589
+ redirected.push({ id: page.id, name: page.name, requested: page.url, landed: landedUrl, reason: "http-status", status: out2.httpStatus });
58551
58590
  continue;
58552
58591
  }
58553
58592
  findings.push(...tagSampleFindings(toDynamicResult(out2, page.url, lang, LOCAL_ENGINE).findings, page));
@@ -58557,13 +58596,15 @@ async function runSampleScanLocal(pages, opts) {
58557
58596
  } finally {
58558
58597
  await browser.close();
58559
58598
  }
58599
+ const dropped = new Set(redirected.map((r) => r.id));
58600
+ const scanned = pages.filter((p) => !dropped.has(p.id));
58560
58601
  return {
58561
58602
  tool: "ultra11y",
58562
58603
  engine: LOCAL_ENGINE,
58563
- target: `${pages.length} page(s) (\xE9chantillon)`,
58604
+ target: `${scanned.length}/${pages.length} page(s) (\xE9chantillon)`,
58564
58605
  date: today(),
58565
58606
  findings,
58566
- sample: sampleScope({ pages }),
58607
+ sample: sampleScope({ pages: scanned }),
58567
58608
  testedScs: localTestedScs(interact),
58568
58609
  ...snapshots.length ? { snapshots } : {},
58569
58610
  ...redirected.length ? { redirected } : {}
@@ -61882,6 +61923,20 @@ var ADJUDICATE_SCHEMA = {
61882
61923
  criteriaId: { type: "string" },
61883
61924
  verdict: { enum: ["C", "NC", "NA", "manual"] },
61884
61925
  justification: { type: "string", description: "REQUIRED for C and NA" },
61926
+ // REQUIRED by the fold alongside `justification`, for exactly the same reason
61927
+ // `normativeRef` is required on an NC: a cleared criterion has to name what it
61928
+ // cleared. Omitting it here (and from the adjudicator contract) made every
61929
+ // adjudication produce C/NA verdicts that `verify --apply` then refused wholesale —
61930
+ // 63 rejections out of 97 verdicts on the run that surfaced this.
61931
+ citations: {
61932
+ type: "array",
61933
+ description: "REQUIRED for C, and for NA when evidence was presented: the evidence items you cleared, `file`/`line` copied verbatim from this criterion's own evidence[]",
61934
+ items: {
61935
+ type: "object",
61936
+ required: ["file", "line"],
61937
+ properties: { file: { type: "string" }, line: { type: "number" }, selector: { type: "string" }, snippet: { type: "string" } }
61938
+ }
61939
+ },
61885
61940
  reason: { type: ["string", "null"], enum: ["needs-rendered-dom", "undecidable", null], description: "REQUIRED for a still-manual verdict" },
61886
61941
  findings: {
61887
61942
  type: "array",
@@ -62007,13 +62062,13 @@ For EACH of your criteria:
62007
62062
 
62008
62063
  1. Read its worklist entry. \`evidence[]\` holds source-anchored excerpts (\`file\`, \`line\`, \`selector\`, \`snippet\`) harvested from the audited code \u2014 open the cited files at the cited lines whenever the snippet alone cannot decide.
62009
62064
  2. Rule it (the apply gate is FAIL-CLOSED \u2014 a verdict missing its required field does not fold):
62010
- - \`C\` (conforming) \u2014 REQUIRES \`justification\` explaining why the evidence satisfies the criterion.
62065
+ - \`C\` (conforming) \u2014 REQUIRES \`justification\` explaining why the evidence satisfies the criterion, AND \`citations[]\` naming the evidence you cleared (\`file\`/\`line\` copied VERBATIM from this item's own \`evidence[]\`; an anchor that is not in that list is treated as fabricated). A criterion whose \`evidence[]\` is empty cannot be \`C\` at all \u2014 it is \`manual\` (\`undecidable\`), or \`NA\` if nothing in scope is concerned.
62011
62066
  - \`NC\` (non-conforming) \u2014 REQUIRES \`findings\`: at least one groundable \`{ file, line, selector?, message, snippet?, severity?, normativeRef }\` pointing at REAL source. The fold re-grounds every finding; an invented file:line is rejected. \`normativeRef\` MUST cite the precise failed test \u2014 under a country standard, one of the item's OWN tests, which the worklist lists for you under \xAB tests to rule on \xBB.
62012
- - \`NA\` (not applicable) \u2014 REQUIRES \`justification\`.
62067
+ - \`NA\` (not applicable) \u2014 REQUIRES \`justification\`, AND \`citations[]\` whenever evidence WAS presented, to say which of those items fall outside the criterion's scope.
62013
62068
  - \`manual\` (still undecidable) \u2014 REQUIRES \`reason\`: \`needs-rendered-dom\` (only a rendered DOM can decide, e.g. computed contrast) or \`undecidable\` (the evidence cannot settle it either way).
62014
62069
  3. Never guess. A criterion you cannot decide from real evidence stays \`manual\` with a reason \u2014 that is a valid, honest verdict; the scan tier or a human picks it up.
62015
62070
 
62016
- Return (structured output): \`{ "verdicts": [{ "criteriaId", "verdict", "justification", "reason", "findings" }] }\` \u2014 your ITEMS only, every field grounded in what you actually read, every NC finding carrying its \`normativeRef\`.
62071
+ Return (structured output): \`{ "verdicts": [{ "criteriaId", "verdict", "justification", "citations", "reason", "findings" }] }\` \u2014 your ITEMS only, every field grounded in what you actually read, every NC finding carrying its \`normativeRef\`, and every C (plus every evidenced NA) carrying its \`citations\`. A verdict that clears a criterion without naming what it cleared is refused by the fold, and one refusal discards the WHOLE adjudication \u2014 including every other verdict you got right.
62017
62072
  ${footer}`,
62018
62073
  refuter: `# Contract: refuter
62019
62074
 
@@ -66635,7 +66690,7 @@ async function cmdScan(p) {
66635
66690
  }
66636
66691
  sampleConfig = v.sample;
66637
66692
  if (!useLocal && sampleConfig.pages.some((pg) => pg.storageState)) {
66638
- const forced = typeof p.flags.runtime === "string" && p.flags.runtime === "docker";
66693
+ const forced = runtimeFlag === "docker";
66639
66694
  console.error(
66640
66695
  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."}`
66641
66696
  );
@@ -66668,10 +66723,17 @@ async function cmdScan(p) {
66668
66723
  }
66669
66724
  if (dynamic.redirected?.length) {
66670
66725
  for (const r of dynamic.redirected) {
66726
+ const why = r.reason === "http-status" ? lang === "fr" ? `a r\xE9pondu HTTP ${r.status} \xE0 la m\xEAme adresse` : `answered HTTP ${r.status} at the same address` : lang === "fr" ? `a redirig\xE9 vers ${r.landed}` : `redirected to ${r.landed}`;
66671
66727
  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.`
66728
+ lang === "fr" ? `\u26A0\uFE0F ultra11y scan : \xAB ${r.name} \xBB (${r.id}) non enregistr\xE9e \u2014 ${r.requested} ${why}. L'enregistrer aurait d\xE9crit cet \xE9cran sous le nom demand\xE9.` : `\u26A0\uFE0F ultra11y scan: "${r.name}" (${r.id}) not recorded \u2014 ${r.requested} ${why}. Recording it would have described that screen under the requested name.`
66673
66729
  );
66674
66730
  }
66731
+ if (useSample && sampleConfig && dynamic.redirected.length === sampleConfig.pages.length) {
66732
+ console.error(
66733
+ lang === "fr" ? `ultra11y scan : aucune des ${sampleConfig.pages.length} pages de l'\xE9chantillon n'a pu \xEAtre enregistr\xE9e \u2014 rien n'a \xE9t\xE9 mesur\xE9. V\xE9rifiez la session, l'URL de base et l'\xE9tat applicatif attendu par ces pages.` : `ultra11y scan: none of the ${sampleConfig.pages.length} sample pages could be recorded \u2014 nothing was measured. Check the session, the base URL, and the application state those pages expect.`
66734
+ );
66735
+ return 1;
66736
+ }
66675
66737
  }
66676
66738
  if (useSample && sampleConfig) {
66677
66739
  const lintKey = typeof p.flags.standard === "string" && p.flags.standard ? p.flags.standard : "rgaa";
@@ -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.2
7
+ version: 5.0.1
8
8
  ---
9
9
 
10
10
  # ultra11y — audit WCAG 2.2 AA and write accessible markup
@@ -15,7 +15,7 @@ permissions:
15
15
  steps:
16
16
  - uses: actions/checkout@v4
17
17
  with: { fetch-depth: 0 } # the diff gate needs the base ref
18
- - uses: maxgfr/ultra11y@v4 # or pin the exact version, as `init --ci` does
18
+ - uses: maxgfr/ultra11y@v5 # or pin the exact version, as `init --ci` does
19
19
  with:
20
20
  since: auto # the PR's base branch
21
21
  standard: rgaa
@@ -27,7 +27,7 @@ steps:
27
27
  Page by page, with a real browser, in the same step:
28
28
 
29
29
  ```yaml
30
- - uses: maxgfr/ultra11y@v4
30
+ - uses: maxgfr/ultra11y@v5
31
31
  with:
32
32
  standard: rgaa
33
33
  start: npm run start
@@ -37,7 +37,7 @@ Page by page, with a real browser, in the same step:
37
37
  ```
38
38
 
39
39
  `ultra11y init --ci` writes a workflow using it, **pinned to the exact engine version that
40
- generated the file** (`@v<that version>`) so a CI run stays reproducible. `@v<major>` — `@v3`
40
+ generated the file** (`@v<that version>`) so a CI run stays reproducible. `@v<major>` — `@v5`
41
41
  today — is a moving alias the release workflow repoints at each release, for teams who would
42
42
  rather take fixes automatically. It moves **within** a major only: when a breaking change cuts
43
43
  the next one, the old alias freezes where it is, so a pipeline pinned to it keeps working and
@@ -66,7 +66,7 @@ partial by construction. `adjudicate` closes that — opt-in, in two modes.
66
66
  env:
67
67
  ANTHROPIC_API_KEY: ${{ secrets.ANTHROPIC_API_KEY }} # the job's env, never an input
68
68
  steps:
69
- - uses: maxgfr/ultra11y@v4
69
+ - uses: maxgfr/ultra11y@v5
70
70
  with:
71
71
  standard: rgaa
72
72
  adjudicate: api # or `agent`
@@ -76,8 +76,17 @@ steps:
76
76
  |---|---|---|
77
77
  | `adjudicate` | `none` | `api` · `agent` · `none` |
78
78
  | `adjudicate-model` | *(empty)* | model id for `api`; else `$ULTRA11Y_LLM_MODEL`, else the engine default |
79
+ | `adjudicate-max-turns` | *(empty)* | turn budget for `agent`; empty derives it from the worklist |
79
80
  | `gate-adjudicated` | `false` | let a model-ruled NC fail the job |
80
81
 
82
+ **The turn budget is derived, and that matters more than it sounds.** The runbook is sequential
83
+ and the whole point of agent mode is that it opens the files a criterion cites, so the cost is
84
+ per item — under RGAA the worklist runs to ~80. A budget too small does not truncate the result,
85
+ it **loses** it: the fold is fail-closed on a single unfilled verdict, so a run cut short throws
86
+ away every verdict it had already produced, and you pay for all of them. The action therefore
87
+ counts the worklist and budgets from it. `adjudicate-max-turns` overrides that when you know
88
+ better.
89
+
81
90
  **`api`** sends the worklist to the Messages API in batches of 8 and folds the verdicts back.
82
91
  **`agent`** emits the worklist plus `orchestrate --eco`'s runbook and hands them to a
83
92
  `claude-code-action` run, then folds the same way. Both end in the **same fail-closed gate** an
@@ -264,7 +273,7 @@ The action audits the **code** and, when you point it at a served app, the **pag
264
273
  list can come from three places, and none of them has to be written by hand:
265
274
 
266
275
  ```yaml
267
- - uses: maxgfr/ultra11y@v4
276
+ - uses: maxgfr/ultra11y@v5
268
277
  with:
269
278
  standard: rgaa
270
279
  start: npm run start
@@ -303,7 +312,7 @@ the setup project their E2E suite depends on — and hand the action its path:
303
312
 
304
313
  ```yaml
305
314
  - run: npx playwright test --project=setup # your existing login, writing the session file
306
- - uses: maxgfr/ultra11y@v4
315
+ - uses: maxgfr/ultra11y@v5
307
316
  with:
308
317
  standard: rgaa
309
318
  sample: 'true'
@@ -458,9 +467,9 @@ one job — the code diff, then the served pages — died on a `409 Conflict`, w
458
467
  written and never uploaded. Pass `artifact-name` on each invocation:
459
468
 
460
469
  ```yaml
461
- - uses: maxgfr/ultra11y@v4
470
+ - uses: maxgfr/ultra11y@v5
462
471
  with: { since: auto, artifact-name: a11y-code }
463
- - uses: maxgfr/ultra11y@v4
472
+ - uses: maxgfr/ultra11y@v5
464
473
  with: { crawl: http://localhost:3000, artifact-name: a11y-pages }
465
474
  ```
466
475
 
@@ -222,15 +222,31 @@ the §1–5 structure of a conformance document.
222
222
 
223
223
  ### Four honesty rules
224
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.
225
+ **0. A page is recorded only if the browser stayed on it, and answered.** A sample page's
226
+ declared `id` and `name` are the identity the report speaks, and they are applied to whatever was
227
+ on screen. So a route that redirects — an expired session bouncing to sign-in, a wizard step the
228
+ application state does not open — would be filed under the requested page's name while showing
229
+ something else: a sheet, a screenshot and a rate that describe a different screen, with nothing
230
+ about the document looking wrong.
231
+
232
+ `scan --sample` refuses two ways:
233
+
234
+ - **It moved.** The address is compared as the app routes: the **path** always; the **fragment**
235
+ and the **query** when the request carried one, because a hash-router app puts the route there
236
+ and `#/admin` → `#/login` is exactly the bounce this rule exists for; the **host**, since a
237
+ same-path landing on an IdP is the same misattribution (a scheme, a port or a `www.` differ
238
+ freely — that is canonicalisation). The comparison uses the URL captured right after the
239
+ navigation settles, before any probe could click something that routes.
240
+ - **It answered an error.** A framework's own not-found page is a complete, valid document
241
+ served at the address you asked for — `notFound()` in Next is exactly that — so no address
242
+ comparison can see it. The HTTP status can: anything ≥ 400 is refused.
243
+
244
+ A refused page is **named in the report**, with what was asked, what was reached and why, and it
245
+ is removed from `scope.sample` — leaving it there would put it back in the grid with the same
246
+ basis as a page really visited, so the deliverable would claim we looked at it. A run where
247
+ *every* page was refused exits non-zero: that is not a partial result, it is a scan that measured
248
+ nothing. A page reported missing is a bug in the sample or in the state you seeded, and both are
249
+ fixable; a page reported under the wrong name is a false conformance claim.
234
250
 
235
251
  **1. A finding is attributed to a page only when something says so.** In order: the snapshot
236
252
  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.2";
9
+ var VERSION = "5.0.1";
10
10
  var SCHEMA_VERSION = 2;
11
11
 
12
12
  // src/audit.ts
@@ -53203,6 +53203,20 @@ function pageGridModel(result, derived, standard, lang) {
53203
53203
  for (const p of derived) for (const pc of derivePackResults(pageView(result, p), standard)) put(pc.id, p.id, pc.status);
53204
53204
  return { rows, status };
53205
53205
  }
53206
+ function renderRedirected(redirected, lang = "en") {
53207
+ const fr = lang === "fr";
53208
+ const out2 = [
53209
+ fr ? `> \u26A0\uFE0F **${redirected.length} page(s) de l'\xE9chantillon n'ont pas \xE9t\xE9 enregistr\xE9es** \u2014 le navigateur n'est pas rest\xE9 sur l'adresse demand\xE9e. Les enregistrer aurait d\xE9crit un autre \xE9cran sous le nom demand\xE9. Elles ne comptent donc ni comme conformes ni comme non conformes : elles manquent.` : `> \u26A0\uFE0F **${redirected.length} sample page(s) were not recorded** \u2014 the browser did not stay on the address asked for. Recording them would have described another screen under the requested name. They count as neither conforming nor non-conforming: they are missing.`,
53210
+ "",
53211
+ fr ? "| Page | Demand\xE9 | Atteint | Motif |" : "| Page | Requested | Landed | Reason |",
53212
+ "| --- | --- | --- | --- |"
53213
+ ];
53214
+ for (const r of redirected) {
53215
+ const why = r.reason === "http-status" ? fr ? `HTTP ${r.status ?? "\u2265 400"} \u2014 page d'erreur rendue \xE0 la m\xEAme adresse` : `HTTP ${r.status ?? "\u2265 400"} \u2014 error page served at the same address` : fr ? "redirection" : "redirect";
53216
+ out2.push(`| ${r.name} (\`${r.id}\`) | \`${r.requested}\` | \`${r.landed}\` | ${why} |`);
53217
+ }
53218
+ return out2;
53219
+ }
53206
53220
  function renderPageGrid(result, pages, standard = CORE2, lang = "en") {
53207
53221
  const s = L3[lang];
53208
53222
  const out2 = [];
@@ -53445,6 +53459,7 @@ function render(r, lang, opts) {
53445
53459
  const derived = derivePages(r, pageScope);
53446
53460
  out2.push(`## \u{1F4C4} ${s.perPageTitle}`, "", `> ${s.perPageNote}`, "");
53447
53461
  if (r.scope.sample?.transverse?.length) out2.push(`> ${s.transverseNote(r.scope.sample.transverse.join(", "))}`, "");
53462
+ if (r.scope.redirected?.length) out2.push(...renderRedirected(r.scope.redirected, lang), "");
53448
53463
  const pack = isCore(opts.standard) ? void 0 : loadPack(opts.standard);
53449
53464
  for (const pg of derived) {
53450
53465
  const nc = pg.findings.filter((f) => !f.advisory);
@@ -57792,6 +57807,14 @@ function resolveHostAnchor(file, snippet2) {
57792
57807
  function mergeDynamic(audit2, dynamic, lang = "en") {
57793
57808
  const merged = JSON.parse(JSON.stringify(audit2));
57794
57809
  if (dynamic.sample) merged.scope.sample = dynamic.sample;
57810
+ if (dynamic.redirected?.length) {
57811
+ const byId3 = new Map((merged.scope.redirected ?? []).map((r) => [r.id, r]));
57812
+ for (const r of dynamic.redirected) byId3.set(r.id, r);
57813
+ const scannedIds = new Set((merged.scope.sample?.pages ?? []).map((p) => p.id));
57814
+ const still = [...byId3.values()].filter((r) => !scannedIds.has(r.id));
57815
+ if (still.length) merged.scope.redirected = still;
57816
+ else delete merged.scope.redirected;
57817
+ }
57795
57818
  if (dynamic.testedScs?.length) {
57796
57819
  const tested = /* @__PURE__ */ new Set([...merged.scope.scan?.testedScs ?? [], ...dynamic.testedScs]);
57797
57820
  merged.scope.scan = { testedScs: [...tested].sort() };
@@ -58354,17 +58377,23 @@ function clicksAllowed(storageState, interactClicks) {
58354
58377
  }
58355
58378
  function landedOnRequestedPage(requested, landed) {
58356
58379
  if (!landed || requested === landed) return true;
58357
- const path = (u) => {
58380
+ const parse2 = (u) => {
58358
58381
  try {
58359
- return new URL(u, "http://x.invalid").pathname.replace(/\/+$/, "");
58382
+ return new URL(u, "http://x.invalid");
58360
58383
  } catch {
58361
58384
  return void 0;
58362
58385
  }
58363
58386
  };
58364
- const a = path(requested);
58365
- const b = path(landed);
58366
- if (a === void 0 || b === void 0) return true;
58367
- return a === b;
58387
+ const a = parse2(requested);
58388
+ const b = parse2(landed);
58389
+ if (!a || !b) return true;
58390
+ const path = (u) => u.pathname.replace(/\/+$/, "");
58391
+ if (path(a) !== path(b)) return false;
58392
+ const host = (u) => u.hostname.replace(/^www\./, "");
58393
+ if (host(a) !== host(b)) return false;
58394
+ if (a.hash && a.hash !== b.hash) return false;
58395
+ if (a.search && a.search !== b.search) return false;
58396
+ return true;
58368
58397
  }
58369
58398
  async function probeLiveRegion(page, lang, allowClicks) {
58370
58399
  const detail = LIVE_REGION_DETAIL[lang] ?? LIVE_REGION_DETAIL.en;
@@ -58405,10 +58434,12 @@ async function runOnPage(browser, AxeBuilder, target, isFile, opts) {
58405
58434
  const empty = [];
58406
58435
  try {
58407
58436
  const url = isFile ? "file://" + resolve11(target) : target;
58408
- await page.goto(url, { waitUntil: "load", timeout: 45e3 });
58437
+ const response = await page.goto(url, { waitUntil: "load", timeout: 45e3 });
58409
58438
  await page.waitForLoadState("networkidle", { timeout: 8e3 }).catch(() => {
58410
58439
  });
58411
58440
  await page.waitForTimeout(1200);
58441
+ const landedUrl = page.url() || url;
58442
+ const httpStatus = typeof response?.status === "function" ? response.status() : void 0;
58412
58443
  let snapshot;
58413
58444
  let screenshot;
58414
58445
  if (opts.snapshot !== false) {
@@ -58454,6 +58485,8 @@ async function runOnPage(browser, AxeBuilder, target, isFile, opts) {
58454
58485
  const liveRegion = opts.interact ? await probeLiveRegion(page, l, opts.allowClicks).catch(() => empty) : [];
58455
58486
  return {
58456
58487
  url: page.url() || target,
58488
+ landedUrl,
58489
+ ...httpStatus !== void 0 ? { httpStatus } : {},
58457
58490
  violations,
58458
58491
  reflow,
58459
58492
  focusVisible: dialogFocus.length ? [...focusVisible, ...dialogFocus] : focusVisible,
@@ -58546,8 +58579,14 @@ async function runSampleScanLocal(pages, opts) {
58546
58579
  lang,
58547
58580
  snapshot: Boolean(opts.snapshotRoot)
58548
58581
  });
58549
- if (!landedOnRequestedPage(page.url, out2.url)) {
58550
- redirected.push({ id: page.id, name: page.name, requested: page.url, landed: out2.url });
58582
+ const requestedUrl = isFile ? "file://" + resolve11(page.url) : page.url;
58583
+ const landedUrl = out2.landedUrl ?? out2.url;
58584
+ if (!landedOnRequestedPage(requestedUrl, landedUrl)) {
58585
+ redirected.push({ id: page.id, name: page.name, requested: page.url, landed: landedUrl, reason: "redirect" });
58586
+ continue;
58587
+ }
58588
+ if (out2.httpStatus !== void 0 && out2.httpStatus >= 400) {
58589
+ redirected.push({ id: page.id, name: page.name, requested: page.url, landed: landedUrl, reason: "http-status", status: out2.httpStatus });
58551
58590
  continue;
58552
58591
  }
58553
58592
  findings.push(...tagSampleFindings(toDynamicResult(out2, page.url, lang, LOCAL_ENGINE).findings, page));
@@ -58557,13 +58596,15 @@ async function runSampleScanLocal(pages, opts) {
58557
58596
  } finally {
58558
58597
  await browser.close();
58559
58598
  }
58599
+ const dropped = new Set(redirected.map((r) => r.id));
58600
+ const scanned = pages.filter((p) => !dropped.has(p.id));
58560
58601
  return {
58561
58602
  tool: "ultra11y",
58562
58603
  engine: LOCAL_ENGINE,
58563
- target: `${pages.length} page(s) (\xE9chantillon)`,
58604
+ target: `${scanned.length}/${pages.length} page(s) (\xE9chantillon)`,
58564
58605
  date: today(),
58565
58606
  findings,
58566
- sample: sampleScope({ pages }),
58607
+ sample: sampleScope({ pages: scanned }),
58567
58608
  testedScs: localTestedScs(interact),
58568
58609
  ...snapshots.length ? { snapshots } : {},
58569
58610
  ...redirected.length ? { redirected } : {}
@@ -61882,6 +61923,20 @@ var ADJUDICATE_SCHEMA = {
61882
61923
  criteriaId: { type: "string" },
61883
61924
  verdict: { enum: ["C", "NC", "NA", "manual"] },
61884
61925
  justification: { type: "string", description: "REQUIRED for C and NA" },
61926
+ // REQUIRED by the fold alongside `justification`, for exactly the same reason
61927
+ // `normativeRef` is required on an NC: a cleared criterion has to name what it
61928
+ // cleared. Omitting it here (and from the adjudicator contract) made every
61929
+ // adjudication produce C/NA verdicts that `verify --apply` then refused wholesale —
61930
+ // 63 rejections out of 97 verdicts on the run that surfaced this.
61931
+ citations: {
61932
+ type: "array",
61933
+ description: "REQUIRED for C, and for NA when evidence was presented: the evidence items you cleared, `file`/`line` copied verbatim from this criterion's own evidence[]",
61934
+ items: {
61935
+ type: "object",
61936
+ required: ["file", "line"],
61937
+ properties: { file: { type: "string" }, line: { type: "number" }, selector: { type: "string" }, snippet: { type: "string" } }
61938
+ }
61939
+ },
61885
61940
  reason: { type: ["string", "null"], enum: ["needs-rendered-dom", "undecidable", null], description: "REQUIRED for a still-manual verdict" },
61886
61941
  findings: {
61887
61942
  type: "array",
@@ -62007,13 +62062,13 @@ For EACH of your criteria:
62007
62062
 
62008
62063
  1. Read its worklist entry. \`evidence[]\` holds source-anchored excerpts (\`file\`, \`line\`, \`selector\`, \`snippet\`) harvested from the audited code \u2014 open the cited files at the cited lines whenever the snippet alone cannot decide.
62009
62064
  2. Rule it (the apply gate is FAIL-CLOSED \u2014 a verdict missing its required field does not fold):
62010
- - \`C\` (conforming) \u2014 REQUIRES \`justification\` explaining why the evidence satisfies the criterion.
62065
+ - \`C\` (conforming) \u2014 REQUIRES \`justification\` explaining why the evidence satisfies the criterion, AND \`citations[]\` naming the evidence you cleared (\`file\`/\`line\` copied VERBATIM from this item's own \`evidence[]\`; an anchor that is not in that list is treated as fabricated). A criterion whose \`evidence[]\` is empty cannot be \`C\` at all \u2014 it is \`manual\` (\`undecidable\`), or \`NA\` if nothing in scope is concerned.
62011
62066
  - \`NC\` (non-conforming) \u2014 REQUIRES \`findings\`: at least one groundable \`{ file, line, selector?, message, snippet?, severity?, normativeRef }\` pointing at REAL source. The fold re-grounds every finding; an invented file:line is rejected. \`normativeRef\` MUST cite the precise failed test \u2014 under a country standard, one of the item's OWN tests, which the worklist lists for you under \xAB tests to rule on \xBB.
62012
- - \`NA\` (not applicable) \u2014 REQUIRES \`justification\`.
62067
+ - \`NA\` (not applicable) \u2014 REQUIRES \`justification\`, AND \`citations[]\` whenever evidence WAS presented, to say which of those items fall outside the criterion's scope.
62013
62068
  - \`manual\` (still undecidable) \u2014 REQUIRES \`reason\`: \`needs-rendered-dom\` (only a rendered DOM can decide, e.g. computed contrast) or \`undecidable\` (the evidence cannot settle it either way).
62014
62069
  3. Never guess. A criterion you cannot decide from real evidence stays \`manual\` with a reason \u2014 that is a valid, honest verdict; the scan tier or a human picks it up.
62015
62070
 
62016
- Return (structured output): \`{ "verdicts": [{ "criteriaId", "verdict", "justification", "reason", "findings" }] }\` \u2014 your ITEMS only, every field grounded in what you actually read, every NC finding carrying its \`normativeRef\`.
62071
+ Return (structured output): \`{ "verdicts": [{ "criteriaId", "verdict", "justification", "citations", "reason", "findings" }] }\` \u2014 your ITEMS only, every field grounded in what you actually read, every NC finding carrying its \`normativeRef\`, and every C (plus every evidenced NA) carrying its \`citations\`. A verdict that clears a criterion without naming what it cleared is refused by the fold, and one refusal discards the WHOLE adjudication \u2014 including every other verdict you got right.
62017
62072
  ${footer}`,
62018
62073
  refuter: `# Contract: refuter
62019
62074
 
@@ -66635,7 +66690,7 @@ async function cmdScan(p) {
66635
66690
  }
66636
66691
  sampleConfig = v.sample;
66637
66692
  if (!useLocal && sampleConfig.pages.some((pg) => pg.storageState)) {
66638
- const forced = typeof p.flags.runtime === "string" && p.flags.runtime === "docker";
66693
+ const forced = runtimeFlag === "docker";
66639
66694
  console.error(
66640
66695
  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."}`
66641
66696
  );
@@ -66668,10 +66723,17 @@ async function cmdScan(p) {
66668
66723
  }
66669
66724
  if (dynamic.redirected?.length) {
66670
66725
  for (const r of dynamic.redirected) {
66726
+ const why = r.reason === "http-status" ? lang === "fr" ? `a r\xE9pondu HTTP ${r.status} \xE0 la m\xEAme adresse` : `answered HTTP ${r.status} at the same address` : lang === "fr" ? `a redirig\xE9 vers ${r.landed}` : `redirected to ${r.landed}`;
66671
66727
  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.`
66728
+ lang === "fr" ? `\u26A0\uFE0F ultra11y scan : \xAB ${r.name} \xBB (${r.id}) non enregistr\xE9e \u2014 ${r.requested} ${why}. L'enregistrer aurait d\xE9crit cet \xE9cran sous le nom demand\xE9.` : `\u26A0\uFE0F ultra11y scan: "${r.name}" (${r.id}) not recorded \u2014 ${r.requested} ${why}. Recording it would have described that screen under the requested name.`
66673
66729
  );
66674
66730
  }
66731
+ if (useSample && sampleConfig && dynamic.redirected.length === sampleConfig.pages.length) {
66732
+ console.error(
66733
+ lang === "fr" ? `ultra11y scan : aucune des ${sampleConfig.pages.length} pages de l'\xE9chantillon n'a pu \xEAtre enregistr\xE9e \u2014 rien n'a \xE9t\xE9 mesur\xE9. V\xE9rifiez la session, l'URL de base et l'\xE9tat applicatif attendu par ces pages.` : `ultra11y scan: none of the ${sampleConfig.pages.length} sample pages could be recorded \u2014 nothing was measured. Check the session, the base URL, and the application state those pages expect.`
66734
+ );
66735
+ return 1;
66736
+ }
66675
66737
  }
66676
66738
  if (useSample && sampleConfig) {
66677
66739
  const lintKey = typeof p.flags.standard === "string" && p.flags.standard ? p.flags.standard : "rgaa";
package/src/cli.ts CHANGED
@@ -3010,7 +3010,10 @@ async function cmdScan(p: ParsedArgs): Promise<number> {
3010
3010
  // of this message — "use --runtime local" alone sends an Action user to a flag they
3011
3011
  // cannot reach, and telling them to force a runtime that still cannot load would
3012
3012
  // exchange this error for a worse one.
3013
- const forced = typeof p.flags.runtime === "string" && p.flags.runtime === "docker";
3013
+ // `runtimeFlag`, not `p.flags.runtime`: `--docker` is a documented alias, and reading the
3014
+ // raw flag would hand a caller who typed it the OTHER half of this message — "install
3015
+ // these dependencies" when the fix is to drop the flag they just passed.
3016
+ const forced = runtimeFlag === "docker";
3014
3017
  console.error(
3015
3018
  lang === "fr"
3016
3019
  ? `ultra11y scan : l'échantillon comporte des pages authentifiées (storageState), non prises en charge par le runtime Docker.${
@@ -3064,12 +3067,32 @@ async function cmdScan(p: ParsedArgs): Promise<number> {
3064
3067
  // the cause is always fixable — an expired session, or state the wizard step needs.
3065
3068
  if (dynamic.redirected?.length) {
3066
3069
  for (const r of dynamic.redirected) {
3070
+ const why =
3071
+ r.reason === "http-status"
3072
+ ? lang === "fr"
3073
+ ? `a répondu HTTP ${r.status} à la même adresse`
3074
+ : `answered HTTP ${r.status} at the same address`
3075
+ : lang === "fr"
3076
+ ? `a redirigé vers ${r.landed}`
3077
+ : `redirected to ${r.landed}`;
3067
3078
  console.error(
3068
3079
  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.`,
3080
+ ? `⚠️ ultra11y scan : « ${r.name} » (${r.id}) non enregistrée — ${r.requested} ${why}. L'enregistrer aurait décrit cet écran sous le nom demandé.`
3081
+ : `⚠️ ultra11y scan: "${r.name}" (${r.id}) not recorded — ${r.requested} ${why}. Recording it would have described that screen under the requested name.`,
3071
3082
  );
3072
3083
  }
3084
+ // Every page refused is not a partial result, it is a scan that measured nothing. Exiting
3085
+ // 0 there would hand CI a green step and an empty report — the exact shape of a run that
3086
+ // passed. The usual cause is one fixable thing (an expired session, a wrong base URL),
3087
+ // so failing here is what makes it visible on the first run instead of the tenth.
3088
+ if (useSample && sampleConfig && dynamic.redirected.length === sampleConfig.pages.length) {
3089
+ console.error(
3090
+ lang === "fr"
3091
+ ? `ultra11y scan : aucune des ${sampleConfig.pages.length} pages de l'échantillon n'a pu être enregistrée — rien n'a été mesuré. Vérifiez la session, l'URL de base et l'état applicatif attendu par ces pages.`
3092
+ : `ultra11y scan: none of the ${sampleConfig.pages.length} sample pages could be recorded — nothing was measured. Check the session, the base URL, and the application state those pages expect.`,
3093
+ );
3094
+ return 1;
3095
+ }
3073
3096
  }
3074
3097
  // Advisory sample-methodology lint: which normative page KINDS the configured sample
3075
3098
  // lacks. Standard-agnostic mechanics + RGAA-specific required kinds; scan has no
@@ -34,6 +34,21 @@ const ADJUDICATE_SCHEMA = {
34
34
  criteriaId: { type: "string" },
35
35
  verdict: { enum: ["C", "NC", "NA", "manual"] },
36
36
  justification: { type: "string", description: "REQUIRED for C and NA" },
37
+ // REQUIRED by the fold alongside `justification`, for exactly the same reason
38
+ // `normativeRef` is required on an NC: a cleared criterion has to name what it
39
+ // cleared. Omitting it here (and from the adjudicator contract) made every
40
+ // adjudication produce C/NA verdicts that `verify --apply` then refused wholesale —
41
+ // 63 rejections out of 97 verdicts on the run that surfaced this.
42
+ citations: {
43
+ type: "array",
44
+ description:
45
+ "REQUIRED for C, and for NA when evidence was presented: the evidence items you cleared, `file`/`line` copied verbatim from this criterion's own evidence[]",
46
+ items: {
47
+ type: "object",
48
+ required: ["file", "line"],
49
+ properties: { file: { type: "string" }, line: { type: "number" }, selector: { type: "string" }, snippet: { type: "string" } },
50
+ },
51
+ },
37
52
  reason: { type: ["string", "null"], enum: ["needs-rendered-dom", "undecidable", null], description: "REQUIRED for a still-manual verdict" },
38
53
  findings: {
39
54
  type: "array",
@@ -176,13 +191,13 @@ For EACH of your criteria:
176
191
 
177
192
  1. Read its worklist entry. \`evidence[]\` holds source-anchored excerpts (\`file\`, \`line\`, \`selector\`, \`snippet\`) harvested from the audited code — open the cited files at the cited lines whenever the snippet alone cannot decide.
178
193
  2. Rule it (the apply gate is FAIL-CLOSED — a verdict missing its required field does not fold):
179
- - \`C\` (conforming) — REQUIRES \`justification\` explaining why the evidence satisfies the criterion.
194
+ - \`C\` (conforming) — REQUIRES \`justification\` explaining why the evidence satisfies the criterion, AND \`citations[]\` naming the evidence you cleared (\`file\`/\`line\` copied VERBATIM from this item's own \`evidence[]\`; an anchor that is not in that list is treated as fabricated). A criterion whose \`evidence[]\` is empty cannot be \`C\` at all — it is \`manual\` (\`undecidable\`), or \`NA\` if nothing in scope is concerned.
180
195
  - \`NC\` (non-conforming) — REQUIRES \`findings\`: at least one groundable \`{ file, line, selector?, message, snippet?, severity?, normativeRef }\` pointing at REAL source. The fold re-grounds every finding; an invented file:line is rejected. \`normativeRef\` MUST cite the precise failed test — under a country standard, one of the item's OWN tests, which the worklist lists for you under « tests to rule on ».
181
- - \`NA\` (not applicable) — REQUIRES \`justification\`.
196
+ - \`NA\` (not applicable) — REQUIRES \`justification\`, AND \`citations[]\` whenever evidence WAS presented, to say which of those items fall outside the criterion's scope.
182
197
  - \`manual\` (still undecidable) — REQUIRES \`reason\`: \`needs-rendered-dom\` (only a rendered DOM can decide, e.g. computed contrast) or \`undecidable\` (the evidence cannot settle it either way).
183
198
  3. Never guess. A criterion you cannot decide from real evidence stays \`manual\` with a reason — that is a valid, honest verdict; the scan tier or a human picks it up.
184
199
 
185
- Return (structured output): \`{ "verdicts": [{ "criteriaId", "verdict", "justification", "reason", "findings" }] }\` — your ITEMS only, every field grounded in what you actually read, every NC finding carrying its \`normativeRef\`.
200
+ Return (structured output): \`{ "verdicts": [{ "criteriaId", "verdict", "justification", "citations", "reason", "findings" }] }\` — your ITEMS only, every field grounded in what you actually read, every NC finding carrying its \`normativeRef\`, and every C (plus every evidenced NA) carrying its \`citations\`. A verdict that clears a criterion without naming what it cleared is refused by the fold, and one refusal discards the WHOLE adjudication — including every other verdict you got right.
186
201
  ${footer}`,
187
202
  refuter: `# Contract: refuter
188
203
 
package/src/pages.ts CHANGED
@@ -23,7 +23,7 @@
23
23
  // scored 100% on "does each image have a relevant alternative?". See `pageStatus`.
24
24
  import { snapshotPageId } from "./snapshot.js";
25
25
  import { CORE, type StandardId, derivePackResults, isCore, loadPack, themeName } from "./standards/index.js";
26
- import type { AuditResult, CriterionResult, Finding, Lang, PageResult, PageScope, Status } from "./types.js";
26
+ import type { AuditResult, CriterionResult, Finding, Lang, PageResult, PageScope, Status, ScanRedirect } from "./types.js";
27
27
  import { isUrlPath } from "./util.js";
28
28
  import { automatability, compareSC, scTitle } from "./wcag.js";
29
29
 
@@ -375,6 +375,37 @@ export function pageGridModel(result: AuditResult, derived: PageResult[], standa
375
375
  return { rows, status };
376
376
  }
377
377
 
378
+ /** The pages a scan REFUSED to record, named in the deliverable.
379
+ *
380
+ * Honesty rule 0 (see the header) removes these from `scope.sample`, because a page kept
381
+ * there is re-added to the grid with the same basis as one really visited. Removing it is
382
+ * correct and insufficient: the report would then just be shorter than the sample the
383
+ * project declares, and a silently shorter report reads as a complete one. So the drop is
384
+ * stated — which page, where it went, and why — because that is what makes it fixable. */
385
+ export function renderRedirected(redirected: ScanRedirect[], lang: Lang = "en"): string[] {
386
+ const fr = lang === "fr";
387
+ const out: string[] = [
388
+ fr
389
+ ? `> ⚠️ **${redirected.length} page(s) de l'échantillon n'ont pas été enregistrées** — le navigateur n'est pas resté sur l'adresse demandée. Les enregistrer aurait décrit un autre écran sous le nom demandé. Elles ne comptent donc ni comme conformes ni comme non conformes : elles manquent.`
390
+ : `> ⚠️ **${redirected.length} sample page(s) were not recorded** — the browser did not stay on the address asked for. Recording them would have described another screen under the requested name. They count as neither conforming nor non-conforming: they are missing.`,
391
+ "",
392
+ fr ? "| Page | Demandé | Atteint | Motif |" : "| Page | Requested | Landed | Reason |",
393
+ "| --- | --- | --- | --- |",
394
+ ];
395
+ for (const r of redirected) {
396
+ const why =
397
+ r.reason === "http-status"
398
+ ? fr
399
+ ? `HTTP ${r.status ?? "≥ 400"} — page d'erreur rendue à la même adresse`
400
+ : `HTTP ${r.status ?? "≥ 400"} — error page served at the same address`
401
+ : fr
402
+ ? "redirection"
403
+ : "redirect";
404
+ out.push(`| ${r.name} (\`${r.id}\`) | \`${r.requested}\` | \`${r.landed}\` | ${why} |`);
405
+ }
406
+ return out;
407
+ }
408
+
378
409
  /** The Markdown grid: one row per criterion, one column per page. */
379
410
  export function renderPageGrid(result: AuditResult, pages: PageScope[], standard: StandardId = CORE, lang: Lang = "en"): string {
380
411
  const s = L[lang];
package/src/report.ts CHANGED
@@ -11,7 +11,7 @@ import { guidelineTitle, scTitle } from "./wcag.js";
11
11
  import { prdUnits, partitionUnits } from "./prd.js";
12
12
  import { renderAuditorUnit, type AuditorCropLookup } from "./auditor.js";
13
13
  import { resolveMessage } from "./messages.js";
14
- import { attributePages, derivePages, pagesOf, renderPageGrid } from "./pages.js";
14
+ import { attributePages, derivePages, pagesOf, renderPageGrid, renderRedirected } from "./pages.js";
15
15
  import { PAGES_DIR } from "./snapshot.js";
16
16
  import {
17
17
  type StandardId,
@@ -376,6 +376,10 @@ function render(
376
376
  const derived = derivePages(r, pageScope);
377
377
  out.push(`## 📄 ${s.perPageTitle}`, "", `> ${s.perPageNote}`, "");
378
378
  if (r.scope.sample?.transverse?.length) out.push(`> ${s.transverseNote(r.scope.sample.transverse.join(", "))}`, "");
379
+ // Pages the scan refused to record. Named here, in the section a reader uses to judge
380
+ // coverage, because the alternative is a report that is simply shorter than the sample
381
+ // the project declares — and a silently shorter deliverable reads as a complete one.
382
+ if (r.scope.redirected?.length) out.push(...renderRedirected(r.scope.redirected, lang), "");
379
383
  // Standard-aware per-finding label: a pack report (RGAA, …) speaks its own criteria
380
384
  // everywhere else, so this per-page line should too, rather than the raw WCAG SC id.
381
385
  // `loadPack` once, outside the finding loop — never per-finding.
package/src/scan-local.ts CHANGED
@@ -614,24 +614,48 @@ export function clicksAllowed(storageState: string | undefined, interactClicks:
614
614
  * failure mode an accessibility report has: a reader sees a page sheet, a screenshot and a
615
615
  * conformance rate, and none of it describes the page named at the top.
616
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
617
+ * What counts as "the same page" is decided per component, because which component carries
618
+ * the route is the app's choice, not ours:
619
+ *
620
+ * - **path** always, trailing slash folded (`/aide/` ≡ `/aide`).
621
+ * - **fragment** only when the REQUEST had one. That is the hash-router case, where the
622
+ * fragment IS the route: asking for `#/admin` and landing on `#/login` is precisely the
623
+ * bounce this guard exists to catch, and comparing paths alone (both empty) would miss it.
624
+ * When the request carried no fragment, one the app appended to its own route is noise.
625
+ * - **query** on the same rule, for `?page=admin` style routing.
626
+ * - **host** always, since a same-path bounce to an IdP or a maintenance host is exactly the
627
+ * misattribution above. Only the scheme, the port and a `www.` prefix may differ — those
628
+ * are canonicalisation, not another page.
629
+ *
630
+ * `requested` must be the RESOLVED navigation target, not the raw config value: a relative
631
+ * `dist/index.html` becomes `file:///abs/dist/index.html` before `goto`, and comparing the
632
+ * two forms would drop every relative file target. Anything unparseable is treated as a
633
+ * match — this guard catches a redirect, it does not invent one. Exported for the
620
634
  * browser-free policy test. */
621
635
  export function landedOnRequestedPage(requested: string, landed: string): boolean {
622
636
  if (!landed || requested === landed) return true;
623
- const path = (u: string): string | undefined => {
637
+ const parse = (u: string): URL | undefined => {
624
638
  try {
625
- // `file://` targets and relative paths never redirect; base makes them parseable.
626
- return new URL(u, "http://x.invalid").pathname.replace(/\/+$/, "");
639
+ return new URL(u, "http://x.invalid");
627
640
  } catch {
628
641
  return undefined;
629
642
  }
630
643
  };
631
- const a = path(requested);
632
- const b = path(landed);
633
- if (a === undefined || b === undefined) return true;
634
- return a === b;
644
+ const a = parse(requested);
645
+ const b = parse(landed);
646
+ if (!a || !b) return true;
647
+
648
+ const path = (u: URL): string => u.pathname.replace(/\/+$/, "");
649
+ if (path(a) !== path(b)) return false;
650
+
651
+ // `www.` is canonicalisation; a different host is a different site.
652
+ const host = (u: URL): string => u.hostname.replace(/^www\./, "");
653
+ if (host(a) !== host(b)) return false;
654
+
655
+ // Only compared when the REQUEST carried one — see the doc block.
656
+ if (a.hash && a.hash !== b.hash) return false;
657
+ if (a.search && a.search !== b.search) return false;
658
+ return true;
635
659
  }
636
660
 
637
661
  async function probeLiveRegion(page: Any, lang: Lang, allowClicks: boolean): Promise<ProbeHit[]> {
@@ -716,13 +740,26 @@ async function runOnPage(
716
740
  const empty: ProbeHit[] = [];
717
741
  try {
718
742
  const url = isFile ? "file://" + resolve(target) : target;
719
- await page.goto(url, { waitUntil: "load", timeout: 45000 });
743
+ const response = await page.goto(url, { waitUntil: "load", timeout: 45000 });
720
744
  // Let the client hydrate and any framework JS inject content/landmarks (SPA routes,
721
745
  // DSFR JS) before measuring — otherwise axe/probes see the pre-hydration DOM and
722
746
  // report false "no h1 / not in a landmark". Bounded networkidle + a short settle.
723
747
  await page.waitForLoadState("networkidle", { timeout: 8000 }).catch(() => {});
724
748
  await page.waitForTimeout(1200);
725
749
 
750
+ // The identity of what is ABOUT to be measured, captured here and nowhere later. The
751
+ // probes below click buttons, and a click that routes (history.pushState) leaves
752
+ // `page.url()` on another route — read at the end, it would accuse the server of a
753
+ // redirect that never happened and throw away a page audited at the right address.
754
+ // `file://` is normalised through the same resolve() the navigation used, so a relative
755
+ // target compares against its own resolved form rather than against a bare path.
756
+ const landedUrl = (page.url() as string) || url;
757
+ // A framework can answer 404/500 with a full, valid document AT THE SAME URL —
758
+ // Next's `notFound()` is exactly that. The path guard cannot see it, and recording it
759
+ // would file an error page under a real page's name. `status()` is the only signal that
760
+ // separates them. `file://` navigations report no status; absent is not a failure.
761
+ const httpStatus: number | undefined = typeof response?.status === "function" ? (response.status() as number) : undefined;
762
+
726
763
  // THE SNAPSHOT IS COLLECTED FIRST, on the PRISTINE page — before axe injects its source,
727
764
  // before any probe fills an input, resizes the viewport or bolts on the text-spacing
728
765
  // stylesheet. A snapshot is meant to be "the page as the browser built it"; collected
@@ -792,6 +829,8 @@ async function runOnPage(
792
829
  const liveRegion = opts.interact ? await probeLiveRegion(page, l, opts.allowClicks).catch(() => empty) : [];
793
830
  return {
794
831
  url: (page.url() as string) || target,
832
+ landedUrl,
833
+ ...(httpStatus !== undefined ? { httpStatus } : {}),
795
834
  violations,
796
835
  reflow,
797
836
  focusVisible: dialogFocus.length ? [...focusVisible, ...dialogFocus] : focusVisible,
@@ -923,12 +962,23 @@ export async function runSampleScanLocal(pages: SamplePage[], opts: LocalManyOpt
923
962
  snapshot: Boolean(opts.snapshotRoot),
924
963
  });
925
964
  // 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 });
965
+ // that bounced elsewhere — or answered an error at the same address — would be filed
966
+ // under the name of the page nobody looked at. Drop it instead, and say which one and
967
+ // why: a page reported missing is a bug in the sample or the seeded state, and both
968
+ // are fixable. A page reported under the wrong name is neither — it is a false
969
+ // conformance claim.
970
+ //
971
+ // Compared against the RESOLVED target and the URL captured before the probes ran
972
+ // (`landedUrl`), never `out.url`: that one is read at the end of the run, so a probe
973
+ // click that routed would look exactly like a server redirect.
974
+ const requestedUrl = isFile ? "file://" + resolve(page.url) : page.url;
975
+ const landedUrl = out.landedUrl ?? out.url;
976
+ if (!landedOnRequestedPage(requestedUrl, landedUrl)) {
977
+ redirected.push({ id: page.id, name: page.name, requested: page.url, landed: landedUrl, reason: "redirect" });
978
+ continue;
979
+ }
980
+ if (out.httpStatus !== undefined && out.httpStatus >= 400) {
981
+ redirected.push({ id: page.id, name: page.name, requested: page.url, landed: landedUrl, reason: "http-status", status: out.httpStatus });
932
982
  continue;
933
983
  }
934
984
  findings.push(...tagSampleFindings(toDynamicResult(out, page.url, lang, LOCAL_ENGINE).findings, page));
@@ -940,13 +990,20 @@ export async function runSampleScanLocal(pages: SamplePage[], opts: LocalManyOpt
940
990
  } finally {
941
991
  await browser.close();
942
992
  }
993
+ // The sample RECORDED is the sample actually scanned. Keeping a dropped page here would be
994
+ // worse than losing it: `pagesOf` re-adds every declared page to the grid with
995
+ // `basis: "attributed"` — the same basis as a page that was really visited — so the
996
+ // deliverable would positively claim we looked at a page we refused to record. The pages
997
+ // that were dropped travel in `redirected`, which says so out loud.
998
+ const dropped = new Set(redirected.map((r) => r.id));
999
+ const scanned = pages.filter((p) => !dropped.has(p.id));
943
1000
  return {
944
1001
  tool: "ultra11y",
945
1002
  engine: LOCAL_ENGINE,
946
- target: `${pages.length} page(s) (échantillon)`,
1003
+ target: `${scanned.length}/${pages.length} page(s) (échantillon)`,
947
1004
  date: today(),
948
1005
  findings,
949
- sample: sampleScope({ pages }),
1006
+ sample: sampleScope({ pages: scanned }),
950
1007
  testedScs: localTestedScs(interact),
951
1008
  ...(snapshots.length ? { snapshots } : {}),
952
1009
  ...(redirected.length ? { redirected } : {}),
package/src/scan.ts CHANGED
@@ -171,6 +171,15 @@ export interface RunnerOutput {
171
171
  // base64). Absent when snapshotting was off or the collection failed — in which case the
172
172
  // page keeps its findings but earns no snapshot, and therefore no conforming-by-silence.
173
173
  snapshot?: CollectedPage & { screenshot?: string };
174
+ // WHERE the browser was when the measurement started — read right after goto + settle,
175
+ // before any probe could click something that routes. `url` above is read at the END and
176
+ // is the address the run finished on; only this one is the identity of what was audited.
177
+ // Local runtime only; absent from a Docker RunnerOutput.
178
+ landedUrl?: string;
179
+ // The navigation's HTTP status. A framework can answer 404/500 with a full document at the
180
+ // requested URL (Next's `notFound()`), which no URL comparison can detect. Absent for
181
+ // `file://` and for the Docker runner — absent is never treated as a failure.
182
+ httpStatus?: number;
174
183
  }
175
184
 
176
185
  /** The page id for a scanned target. A served URL slugifies from its PATH, which is the
@@ -541,6 +550,18 @@ export function mergeDynamic(audit: AuditResult, dynamic: DynamicResult, lang: L
541
550
  // Record the normative page sample the dynamic tier ran over (Task 5) — drives the
542
551
  // report's « Constats par page » section. Storage-state paths were already dropped upstream.
543
552
  if (dynamic.sample) merged.scope.sample = dynamic.sample;
553
+ // Pages the scan refused to record. They are NOT in `sample`, so without this the audit
554
+ // would simply be quietly shorter than the sample the repository declares — and a shorter
555
+ // deliverable that says nothing reads as a complete one. Unioned across merges by id, so a
556
+ // page recovered by a later scan stops being reported as missing.
557
+ if (dynamic.redirected?.length) {
558
+ const byId = new Map((merged.scope.redirected ?? []).map((r) => [r.id, r]));
559
+ for (const r of dynamic.redirected) byId.set(r.id, r);
560
+ const scannedIds = new Set((merged.scope.sample?.pages ?? []).map((p) => p.id));
561
+ const still = [...byId.values()].filter((r) => !scannedIds.has(r.id));
562
+ if (still.length) merged.scope.redirected = still;
563
+ else delete merged.scope.redirected;
564
+ }
544
565
  // Record which needs-rendering SCs this scan actually MEASURED (union across merges) —
545
566
  // the partial-audit advisory keys on this, so a Docker run (reflow only) never silently
546
567
  // suppresses the banner for the local-only probes it did not run.
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.2";
3
+ export const VERSION = "5.0.1";
4
4
  export const SCHEMA_VERSION = 2;
5
5
 
6
6
  // Lang = the UI FRAME's language — the `L` localization tables each module (report,
@@ -57,6 +57,12 @@ export interface ScanRedirect {
57
57
  name: string;
58
58
  requested: string;
59
59
  landed: string;
60
+ // WHY it was refused. `redirect`: the browser ended up at another address. `http-status`:
61
+ // it stayed at the requested one but the server answered an error — a framework's own
62
+ // not-found page is a full, valid document at the right URL, which no address comparison
63
+ // can tell from the real thing.
64
+ reason?: "redirect" | "http-status";
65
+ status?: number;
60
66
  }
61
67
 
62
68
  // ---- WCAG 2.2 canonical core (src/data/wcag.json, produced by scripts/build-standards.mjs)
@@ -478,6 +484,12 @@ export interface AuditResult {
478
484
  // merges. Drives the partial-audit advisory (src/report.ts untestedNeedsRendering) so
479
485
  // the banner never claims a probe ran when it did not. Optional/additive.
480
486
  scan?: { testedScs: string[] };
487
+ // Sample pages a `scan --sample` REFUSED to record, and why. They are deliberately absent
488
+ // from `sample` above — a page kept there would be re-added to the per-page grid with the
489
+ // same basis as one really visited, so the deliverable would claim a page nobody looked
490
+ // at. Carried here instead so the report can say what is missing rather than hide it.
491
+ // Optional/additive — absent when every page was reached.
492
+ redirected?: ScanRedirect[];
481
493
  };
482
494
  guidelines: GuidelineTally[];
483
495
  criteria: CriterionResult[];