ultra11y 5.13.0 → 5.14.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -10,7 +10,7 @@
10
10
  "name": "ultra11y",
11
11
  "source": "./",
12
12
  "description": "Automatic accessibility review of the code under change before every commit, push and pull request, plus full WCAG 2.2 AA / RGAA audits and conformance deliverables on demand.",
13
- "version": "5.13.0",
13
+ "version": "5.14.0",
14
14
  "author": { "name": "maxgfr" },
15
15
  "homepage": "https://github.com/maxgfr/ultra11y",
16
16
  "repository": "https://github.com/maxgfr/ultra11y",
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "ultra11y",
3
3
  "description": "Accessibility for WCAG 2.2 AA and country standards (RGAA…): an automatic review of the code under change before every commit, push and pull request, plus full audits and conformance deliverables on demand.",
4
- "version": "5.13.0",
4
+ "version": "5.14.0",
5
5
  "author": { "name": "maxgfr" },
6
6
  "homepage": "https://github.com/maxgfr/ultra11y",
7
7
  "repository": "https://github.com/maxgfr/ultra11y",
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "ultra11y",
3
3
  "description": "Accessibility for WCAG 2.2 AA and country standards (RGAA…): an automatic review of the code under change before every commit, push and pull request, plus full audits and conformance deliverables on demand.",
4
- "version": "5.13.0",
4
+ "version": "5.14.0",
5
5
  "author": { "name": "maxgfr", "url": "https://github.com/maxgfr" },
6
6
  "homepage": "https://github.com/maxgfr/ultra11y",
7
7
  "repository": "https://github.com/maxgfr/ultra11y",
@@ -27,7 +27,7 @@ import { fileURLToPath } from "node:url";
27
27
  // Ownership marker: `ultra11y install --opencode` refuses to overwrite a plugin file that
28
28
  // does not carry it, so a user's own ultra11y.js is never clobbered.
29
29
  const ULTRA11Y_OPENCODE_PLUGIN = "ULTRA11Y_OPENCODE_PLUGIN";
30
- const ULTRA11Y_PLUGIN_VERSION = "5.13.0";
30
+ const ULTRA11Y_PLUGIN_VERSION = "5.14.0";
31
31
 
32
32
  const here = dirname(fileURLToPath(import.meta.url));
33
33
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "ultra11y",
3
- "version": "5.13.0",
3
+ "version": "5.14.0",
4
4
  "description": "Audit HTML/CSS/JSX against WCAG 2.2 AA and produce a dated compliance report, or author/review accessible markup (native-HTML-first). A skills.sh agent skill: an install-free static engine (bundled, no npm install) plus the agent's judgment, with check/verify gates against hallucinated non-conformities, and pluggable in-repo standards packs (RGAA France first; Section 508 & EN 301 549 next).",
5
5
  "type": "module",
6
6
  "packageManager": "pnpm@10.33.0",
@@ -6,7 +6,7 @@ import { join as join51, relative as relative5, resolve as resolve16, sep as sep
6
6
  import { fileURLToPath as fileURLToPath5, pathToFileURL as pathToFileURL3 } from "url";
7
7
 
8
8
  // src/types.ts
9
- var VERSION = "5.13.0";
9
+ var VERSION = "5.14.0";
10
10
  var SCHEMA_VERSION = 2;
11
11
  var INAPPLICABLE_STATUS = "C";
12
12
 
@@ -54373,6 +54373,7 @@ function pageStatus(c2, pageFindings, basis) {
54373
54373
  if (pageFindings.some((f) => !f.advisory)) return "NC";
54374
54374
  if (c2.status === "manual") return "manual";
54375
54375
  if (c2.status === "NA") return "NA";
54376
+ if (c2.inapplicable) return c2.status;
54376
54377
  if (c2.decidedBy === "agent" || c2.decidedBy === "scan") return c2.status;
54377
54378
  if (automatability(c2.id) !== "static") return "manual";
54378
54379
  return basis === "snapshot" ? "C" : "manual";
@@ -54390,13 +54391,17 @@ function derivePages(result, pages) {
54390
54391
  const own = result.findings.filter((f) => f.page === p.id);
54391
54392
  const criteria = result.criteria.map((c2) => {
54392
54393
  const pf = own.filter((f) => f.criteriaId === c2.id);
54394
+ const status = pageStatus(c2, pf, p.basis);
54393
54395
  return {
54394
54396
  id: c2.id,
54395
54397
  guideline: c2.guideline,
54396
- status: pageStatus(c2, pf, p.basis),
54398
+ status,
54397
54399
  findings: pf,
54398
54400
  ...c2.justification ? { justification: c2.justification } : {},
54399
- ...c2.decidedBy ? { decidedBy: c2.decidedBy } : {}
54401
+ ...c2.decidedBy ? { decidedBy: c2.decidedBy } : {},
54402
+ // Carried, not recomputed: a finding on THIS page proves the subject exists after all,
54403
+ // and `pageStatus` has already turned that into an NC above.
54404
+ ...c2.inapplicable && status === c2.status ? { inapplicable: true } : {}
54400
54405
  };
54401
54406
  });
54402
54407
  const { rate, decided, total } = pct(criteria);
@@ -56434,7 +56439,7 @@ function renderCriteriaReference() {
56434
56439
  }
56435
56440
 
56436
56441
  // src/check.ts
56437
- import { existsSync as existsSync20, readFileSync as readFileSync18 } from "fs";
56442
+ import { existsSync as existsSync20, readFileSync as readFileSync18, readdirSync as readdirSync5 } from "fs";
56438
56443
  import { dirname as dirname9, join as join34 } from "path";
56439
56444
 
56440
56445
  // src/verify.ts
@@ -56855,6 +56860,33 @@ function sectionBody(md, n) {
56855
56860
  const next = /^##\s+/m.exec(md.slice(from));
56856
56861
  return next ? md.slice(from, from + next.index) : md.slice(from);
56857
56862
  }
56863
+ function checkSampleCaptured(root = ".", lang = "en") {
56864
+ const fr = lang === "fr";
56865
+ let declared = [];
56866
+ try {
56867
+ const cfg = JSON.parse(readFileSync18(join34(root, ".ultra11yrc.json"), "utf8"));
56868
+ declared = cfg.sample?.pages ?? [];
56869
+ } catch {
56870
+ return { ok: true, issues: [], missing: [], declared: 0, captured: 0 };
56871
+ }
56872
+ if (!declared.length) return { ok: true, issues: [], missing: [], declared: 0, captured: 0 };
56873
+ let captured = /* @__PURE__ */ new Set();
56874
+ try {
56875
+ captured = new Set(
56876
+ readdirSync5(join34(root, PAGES_DIR), { withFileTypes: true }).filter((d) => d.isDirectory()).map((d) => d.name).filter((id) => existsSync20(join34(root, PAGES_DIR, id, "dom.html")))
56877
+ );
56878
+ } catch {
56879
+ }
56880
+ const missing = declared.filter((p) => !captured.has(p.id)).map((p) => ({ id: p.id, name: p.name ?? p.id, url: p.url ?? "" }));
56881
+ const issues = [];
56882
+ if (missing.length) {
56883
+ const kept = declared.length - missing.length;
56884
+ issues.push(
56885
+ fr ? `${kept}/${declared.length} page(s) d\xE9clar\xE9e(s) ont \xE9t\xE9 captur\xE9es. Aucune capture pour : ${missing.map((m) => `${m.name} (${m.id})`).join(", ")}. Une page d\xE9clar\xE9e sans capture n'est pas une page conforme, c'est une page que personne n'a regard\xE9e.` : `${kept}/${declared.length} declared page(s) were captured. No capture for: ${missing.map((m) => `${m.name} (${m.id})`).join(", ")}. A declared page with no capture is not a page that passed \u2014 it is a page nobody looked at.`
56886
+ );
56887
+ }
56888
+ return { ok: issues.length === 0, issues, missing, declared: declared.length, captured: captured.size };
56889
+ }
56858
56890
  function byCriterionId(a, b) {
56859
56891
  const pa = a.split(".").map(Number);
56860
56892
  const pb = b.split(".").map(Number);
@@ -58735,7 +58767,7 @@ function applyRawVerdicts(items, verdicts) {
58735
58767
 
58736
58768
  // src/scan.ts
58737
58769
  import { execFileSync as execFileSync6 } from "child_process";
58738
- import { mkdtempSync as mkdtempSync2, writeFileSync as writeFileSync12, existsSync as existsSync23, statSync as statSync8, readdirSync as readdirSync5, rmSync as rmSync3, readFileSync as readFileSync20 } from "fs";
58770
+ import { mkdtempSync as mkdtempSync2, writeFileSync as writeFileSync12, existsSync as existsSync23, statSync as statSync8, readdirSync as readdirSync6, rmSync as rmSync3, readFileSync as readFileSync20 } from "fs";
58739
58771
  import { tmpdir as tmpdir2 } from "os";
58740
58772
  import { join as join37, resolve as resolve11 } from "path";
58741
58773
  import { fileURLToPath as fileURLToPath3 } from "url";
@@ -59059,7 +59091,7 @@ function buildImage(tag = IMAGE_TAG) {
59059
59091
  function cleanTempContexts() {
59060
59092
  let removed = 0;
59061
59093
  const dir = tmpdir2();
59062
- for (const name2 of readdirSync5(dir)) {
59094
+ for (const name2 of readdirSync6(dir)) {
59063
59095
  if (!name2.startsWith(CTX_PREFIX)) continue;
59064
59096
  rmSync3(join37(dir, name2), { recursive: true, force: true });
59065
59097
  removed++;
@@ -65558,7 +65590,7 @@ function str3(v) {
65558
65590
  var DECLARED = new Set([...TOOLS2, ...WRITE_TOOLS].map((t3) => t3.name));
65559
65591
 
65560
65592
  // src/mcp/resources.ts
65561
- import { existsSync as existsSync34, readdirSync as readdirSync6, readFileSync as readFileSync32, realpathSync as realpathSync5, statSync as statSync13 } from "fs";
65593
+ import { existsSync as existsSync34, readdirSync as readdirSync7, readFileSync as readFileSync32, realpathSync as realpathSync5, statSync as statSync13 } from "fs";
65562
65594
  import { basename as basename3, dirname as dirname14, join as join50, resolve as resolve15, sep as sep6 } from "path";
65563
65595
  import { fileURLToPath as fileURLToPath4 } from "url";
65564
65596
  var SKILL_NAME = "ultra11y";
@@ -65574,7 +65606,7 @@ function listResources(moduleDir) {
65574
65606
  const out2 = [describe(root, "SKILL.md", `${SKILL_NAME}: the skill`)];
65575
65607
  const refDir = join50(root, "references");
65576
65608
  if (!existsSync34(refDir)) return out2;
65577
- for (const file of readdirSync6(refDir).sort()) {
65609
+ for (const file of readdirSync7(refDir).sort()) {
65578
65610
  if (!file.endsWith(".md")) continue;
65579
65611
  out2.push(describe(root, join50("references", file), `${SKILL_NAME} reference: ${basename3(file, ".md")}`));
65580
65612
  }
@@ -66452,6 +66484,10 @@ Options:
66452
66484
  dropped as stale and its criterion says so
66453
66485
  --max-verify <n> verify: cap the worklist size; 0 = no cap (default: 40)
66454
66486
  --verdicts <file> check --semantic: the adjudicated verdicts artifact
66487
+ --require-sample check: fail while a page DECLARED in .ultra11yrc.json has no capture under
66488
+ .ultra11y/pages/. Coverage, one level below --require-decided: a sweep that
66489
+ loses pages produces a report that is merely SHORTER, and a shorter
66490
+ deliverable reads exactly like a complete one.
66455
66491
  --require-decided check: fail while ANY criterion of the standard is still \xAB to assess \xBB.
66456
66492
  Needs --in. A green job does not otherwise mean the grid was filled:
66457
66493
  --fail-on governs non-conformities, and an adjudication that lands
@@ -66678,6 +66714,7 @@ var BOOLEAN_FLAGS = /* @__PURE__ */ new Set([
66678
66714
  "semantic",
66679
66715
  // `check`: fail while any criterion of the standard is still « to assess ».
66680
66716
  "require-decided",
66717
+ "require-sample",
66681
66718
  "manual",
66682
66719
  // `verify --apply` / `judge --apply`: restore the all-or-nothing fold, where one refused
66683
66720
  // verdict discards the whole adjudication. The default is per-verdict.
@@ -68054,9 +68091,10 @@ function cmdCheck(p) {
68054
68091
  const standard = stdOf(p, "check");
68055
68092
  if (standard === null) return 2;
68056
68093
  const requireDecided = p.flags["require-decided"] === true;
68094
+ const requireSample = p.flags["require-sample"] === true;
68057
68095
  const rep = p.flags.report;
68058
68096
  if (typeof rep !== "string" || !rep) {
68059
- if (!requireDecided) {
68097
+ if (!requireDecided && !requireSample) {
68060
68098
  console.error("ultra11y check: --report <md> is required.");
68061
68099
  return 2;
68062
68100
  }
@@ -68101,6 +68139,7 @@ function cmdCheck(p) {
68101
68139
  }
68102
68140
  }
68103
68141
  const decided = requireDecided && audit2 ? checkDecided(audit2, standard, lang, { allow }) : null;
68142
+ const covered = requireSample ? checkSampleCaptured(".", lang) : null;
68104
68143
  const res = md ? checkReport(md, standard, lang, { audit: audit2 }) : { ok: true, issues: [] };
68105
68144
  const sem = p.flags.semantic === true && typeof rep === "string" && rep ? checkSemantic(md, {
68106
68145
  reportPath: rep,
@@ -68108,9 +68147,9 @@ function cmdCheck(p) {
68108
68147
  standard,
68109
68148
  lang
68110
68149
  }) : null;
68111
- const ok = res.ok && (sem === null || sem.ok) && (decided === null || decided.ok);
68150
+ const ok = res.ok && (sem === null || sem.ok) && (decided === null || decided.ok) && (covered === null || covered.ok);
68112
68151
  if (p.flags.json) {
68113
- console.log(JSON.stringify({ ...res, ok, ...sem ? { semantic: sem } : {}, ...decided ? { decided } : {} }, null, 2));
68152
+ console.log(JSON.stringify({ ...res, ok, ...sem ? { semantic: sem } : {}, ...decided ? { decided } : {}, ...covered ? { covered } : {} }, null, 2));
68114
68153
  } else if (!p.flags.quiet) {
68115
68154
  if (decided) {
68116
68155
  const allowedNote = decided.allowed.length ? lang === "fr" ? ` (${decided.allowed.length} crit\xE8re(s) d\xE9clar\xE9(s) ind\xE9cidable(s) : ${decided.allowed.map((a) => `${a.criteriaId} \u2014 ${a.reason}`).join(" \xB7 ")})` : ` (${decided.allowed.length} criterion(ia) declared undecidable: ${decided.allowed.map((a) => `${a.criteriaId} \u2014 ${a.reason}`).join(" \xB7 ")})` : "";
@@ -68119,11 +68158,16 @@ function cmdCheck(p) {
68119
68158
  lang === "fr" ? `\u2713 Grille compl\xE8te : les ${decided.total} crit\xE8res portent un verdict${allowedNote}.` : `\u2713 Complete grid: all ${decided.total} criteria carry a verdict${allowedNote}.`
68120
68159
  );
68121
68160
  }
68161
+ if (covered?.ok) {
68162
+ console.log(
68163
+ lang === "fr" ? `\u2713 \xC9chantillon couvert : les ${covered.declared} page(s) d\xE9clar\xE9e(s) ont une capture.` : `\u2713 Sample covered: all ${covered.declared} declared page(s) have a capture.`
68164
+ );
68165
+ }
68122
68166
  if (ok)
68123
68167
  console.log(
68124
68168
  sem ? lang === "fr" ? `\u2713 Rapport valide + gate s\xE9mantique engag\xE9e : ${sem.total} verdict(s), ${sem.grounded} ancr\xE9(s) dans la source${sem.moved ? ` (${sem.moved} d\xE9plac\xE9(s))` : ""}.` : `\u2713 Report valid + semantic gate engaged: ${sem.total} verdict(s), ${sem.grounded} grounded in source${sem.moved ? ` (${sem.moved} moved)` : ""}.` : lang === "fr" ? "\u2713 Rapport valide : sections, crit\xE8res cit\xE9s et justifications NA coh\xE9rents." : "\u2713 Report valid: sections, cited criteria and NA justifications are consistent."
68125
68169
  );
68126
- else for (const i2 of [...res.issues, ...sem?.issues ?? [], ...decided?.issues ?? []]) console.error(`\u2717 ${i2}`);
68170
+ else for (const i2 of [...res.issues, ...sem?.issues ?? [], ...decided?.issues ?? [], ...covered?.issues ?? []]) console.error(`\u2717 ${i2}`);
68127
68171
  }
68128
68172
  return ok ? 0 : 1;
68129
68173
  }
@@ -5,7 +5,7 @@ when_to_use: "Invoke WITHOUT being asked whenever changed front-end code should
5
5
  allowed-tools: Bash(node ${CLAUDE_SKILL_DIR}/scripts/ultra11y.mjs *), Read, Grep, Glob
6
6
  license: MIT
7
7
  metadata:
8
- version: 5.13.0
8
+ version: 5.14.0
9
9
  ---
10
10
 
11
11
  # review-a11y — accessibility review of changed code
@@ -6,7 +6,7 @@ import { join as join51, relative as relative5, resolve as resolve16, sep as sep
6
6
  import { fileURLToPath as fileURLToPath5, pathToFileURL as pathToFileURL3 } from "url";
7
7
 
8
8
  // src/types.ts
9
- var VERSION = "5.13.0";
9
+ var VERSION = "5.14.0";
10
10
  var SCHEMA_VERSION = 2;
11
11
  var INAPPLICABLE_STATUS = "C";
12
12
 
@@ -54373,6 +54373,7 @@ function pageStatus(c2, pageFindings, basis) {
54373
54373
  if (pageFindings.some((f) => !f.advisory)) return "NC";
54374
54374
  if (c2.status === "manual") return "manual";
54375
54375
  if (c2.status === "NA") return "NA";
54376
+ if (c2.inapplicable) return c2.status;
54376
54377
  if (c2.decidedBy === "agent" || c2.decidedBy === "scan") return c2.status;
54377
54378
  if (automatability(c2.id) !== "static") return "manual";
54378
54379
  return basis === "snapshot" ? "C" : "manual";
@@ -54390,13 +54391,17 @@ function derivePages(result, pages) {
54390
54391
  const own = result.findings.filter((f) => f.page === p.id);
54391
54392
  const criteria = result.criteria.map((c2) => {
54392
54393
  const pf = own.filter((f) => f.criteriaId === c2.id);
54394
+ const status = pageStatus(c2, pf, p.basis);
54393
54395
  return {
54394
54396
  id: c2.id,
54395
54397
  guideline: c2.guideline,
54396
- status: pageStatus(c2, pf, p.basis),
54398
+ status,
54397
54399
  findings: pf,
54398
54400
  ...c2.justification ? { justification: c2.justification } : {},
54399
- ...c2.decidedBy ? { decidedBy: c2.decidedBy } : {}
54401
+ ...c2.decidedBy ? { decidedBy: c2.decidedBy } : {},
54402
+ // Carried, not recomputed: a finding on THIS page proves the subject exists after all,
54403
+ // and `pageStatus` has already turned that into an NC above.
54404
+ ...c2.inapplicable && status === c2.status ? { inapplicable: true } : {}
54400
54405
  };
54401
54406
  });
54402
54407
  const { rate, decided, total } = pct(criteria);
@@ -56434,7 +56439,7 @@ function renderCriteriaReference() {
56434
56439
  }
56435
56440
 
56436
56441
  // src/check.ts
56437
- import { existsSync as existsSync20, readFileSync as readFileSync18 } from "fs";
56442
+ import { existsSync as existsSync20, readFileSync as readFileSync18, readdirSync as readdirSync5 } from "fs";
56438
56443
  import { dirname as dirname9, join as join34 } from "path";
56439
56444
 
56440
56445
  // src/verify.ts
@@ -56855,6 +56860,33 @@ function sectionBody(md, n) {
56855
56860
  const next = /^##\s+/m.exec(md.slice(from));
56856
56861
  return next ? md.slice(from, from + next.index) : md.slice(from);
56857
56862
  }
56863
+ function checkSampleCaptured(root = ".", lang = "en") {
56864
+ const fr = lang === "fr";
56865
+ let declared = [];
56866
+ try {
56867
+ const cfg = JSON.parse(readFileSync18(join34(root, ".ultra11yrc.json"), "utf8"));
56868
+ declared = cfg.sample?.pages ?? [];
56869
+ } catch {
56870
+ return { ok: true, issues: [], missing: [], declared: 0, captured: 0 };
56871
+ }
56872
+ if (!declared.length) return { ok: true, issues: [], missing: [], declared: 0, captured: 0 };
56873
+ let captured = /* @__PURE__ */ new Set();
56874
+ try {
56875
+ captured = new Set(
56876
+ readdirSync5(join34(root, PAGES_DIR), { withFileTypes: true }).filter((d) => d.isDirectory()).map((d) => d.name).filter((id) => existsSync20(join34(root, PAGES_DIR, id, "dom.html")))
56877
+ );
56878
+ } catch {
56879
+ }
56880
+ const missing = declared.filter((p) => !captured.has(p.id)).map((p) => ({ id: p.id, name: p.name ?? p.id, url: p.url ?? "" }));
56881
+ const issues = [];
56882
+ if (missing.length) {
56883
+ const kept = declared.length - missing.length;
56884
+ issues.push(
56885
+ fr ? `${kept}/${declared.length} page(s) d\xE9clar\xE9e(s) ont \xE9t\xE9 captur\xE9es. Aucune capture pour : ${missing.map((m) => `${m.name} (${m.id})`).join(", ")}. Une page d\xE9clar\xE9e sans capture n'est pas une page conforme, c'est une page que personne n'a regard\xE9e.` : `${kept}/${declared.length} declared page(s) were captured. No capture for: ${missing.map((m) => `${m.name} (${m.id})`).join(", ")}. A declared page with no capture is not a page that passed \u2014 it is a page nobody looked at.`
56886
+ );
56887
+ }
56888
+ return { ok: issues.length === 0, issues, missing, declared: declared.length, captured: captured.size };
56889
+ }
56858
56890
  function byCriterionId(a, b) {
56859
56891
  const pa = a.split(".").map(Number);
56860
56892
  const pb = b.split(".").map(Number);
@@ -58735,7 +58767,7 @@ function applyRawVerdicts(items, verdicts) {
58735
58767
 
58736
58768
  // src/scan.ts
58737
58769
  import { execFileSync as execFileSync6 } from "child_process";
58738
- import { mkdtempSync as mkdtempSync2, writeFileSync as writeFileSync12, existsSync as existsSync23, statSync as statSync8, readdirSync as readdirSync5, rmSync as rmSync3, readFileSync as readFileSync20 } from "fs";
58770
+ import { mkdtempSync as mkdtempSync2, writeFileSync as writeFileSync12, existsSync as existsSync23, statSync as statSync8, readdirSync as readdirSync6, rmSync as rmSync3, readFileSync as readFileSync20 } from "fs";
58739
58771
  import { tmpdir as tmpdir2 } from "os";
58740
58772
  import { join as join37, resolve as resolve11 } from "path";
58741
58773
  import { fileURLToPath as fileURLToPath3 } from "url";
@@ -59059,7 +59091,7 @@ function buildImage(tag = IMAGE_TAG) {
59059
59091
  function cleanTempContexts() {
59060
59092
  let removed = 0;
59061
59093
  const dir = tmpdir2();
59062
- for (const name2 of readdirSync5(dir)) {
59094
+ for (const name2 of readdirSync6(dir)) {
59063
59095
  if (!name2.startsWith(CTX_PREFIX)) continue;
59064
59096
  rmSync3(join37(dir, name2), { recursive: true, force: true });
59065
59097
  removed++;
@@ -65558,7 +65590,7 @@ function str3(v) {
65558
65590
  var DECLARED = new Set([...TOOLS2, ...WRITE_TOOLS].map((t3) => t3.name));
65559
65591
 
65560
65592
  // src/mcp/resources.ts
65561
- import { existsSync as existsSync34, readdirSync as readdirSync6, readFileSync as readFileSync32, realpathSync as realpathSync5, statSync as statSync13 } from "fs";
65593
+ import { existsSync as existsSync34, readdirSync as readdirSync7, readFileSync as readFileSync32, realpathSync as realpathSync5, statSync as statSync13 } from "fs";
65562
65594
  import { basename as basename3, dirname as dirname14, join as join50, resolve as resolve15, sep as sep6 } from "path";
65563
65595
  import { fileURLToPath as fileURLToPath4 } from "url";
65564
65596
  var SKILL_NAME = "ultra11y";
@@ -65574,7 +65606,7 @@ function listResources(moduleDir) {
65574
65606
  const out2 = [describe(root, "SKILL.md", `${SKILL_NAME}: the skill`)];
65575
65607
  const refDir = join50(root, "references");
65576
65608
  if (!existsSync34(refDir)) return out2;
65577
- for (const file of readdirSync6(refDir).sort()) {
65609
+ for (const file of readdirSync7(refDir).sort()) {
65578
65610
  if (!file.endsWith(".md")) continue;
65579
65611
  out2.push(describe(root, join50("references", file), `${SKILL_NAME} reference: ${basename3(file, ".md")}`));
65580
65612
  }
@@ -66452,6 +66484,10 @@ Options:
66452
66484
  dropped as stale and its criterion says so
66453
66485
  --max-verify <n> verify: cap the worklist size; 0 = no cap (default: 40)
66454
66486
  --verdicts <file> check --semantic: the adjudicated verdicts artifact
66487
+ --require-sample check: fail while a page DECLARED in .ultra11yrc.json has no capture under
66488
+ .ultra11y/pages/. Coverage, one level below --require-decided: a sweep that
66489
+ loses pages produces a report that is merely SHORTER, and a shorter
66490
+ deliverable reads exactly like a complete one.
66455
66491
  --require-decided check: fail while ANY criterion of the standard is still \xAB to assess \xBB.
66456
66492
  Needs --in. A green job does not otherwise mean the grid was filled:
66457
66493
  --fail-on governs non-conformities, and an adjudication that lands
@@ -66678,6 +66714,7 @@ var BOOLEAN_FLAGS = /* @__PURE__ */ new Set([
66678
66714
  "semantic",
66679
66715
  // `check`: fail while any criterion of the standard is still « to assess ».
66680
66716
  "require-decided",
66717
+ "require-sample",
66681
66718
  "manual",
66682
66719
  // `verify --apply` / `judge --apply`: restore the all-or-nothing fold, where one refused
66683
66720
  // verdict discards the whole adjudication. The default is per-verdict.
@@ -68054,9 +68091,10 @@ function cmdCheck(p) {
68054
68091
  const standard = stdOf(p, "check");
68055
68092
  if (standard === null) return 2;
68056
68093
  const requireDecided = p.flags["require-decided"] === true;
68094
+ const requireSample = p.flags["require-sample"] === true;
68057
68095
  const rep = p.flags.report;
68058
68096
  if (typeof rep !== "string" || !rep) {
68059
- if (!requireDecided) {
68097
+ if (!requireDecided && !requireSample) {
68060
68098
  console.error("ultra11y check: --report <md> is required.");
68061
68099
  return 2;
68062
68100
  }
@@ -68101,6 +68139,7 @@ function cmdCheck(p) {
68101
68139
  }
68102
68140
  }
68103
68141
  const decided = requireDecided && audit2 ? checkDecided(audit2, standard, lang, { allow }) : null;
68142
+ const covered = requireSample ? checkSampleCaptured(".", lang) : null;
68104
68143
  const res = md ? checkReport(md, standard, lang, { audit: audit2 }) : { ok: true, issues: [] };
68105
68144
  const sem = p.flags.semantic === true && typeof rep === "string" && rep ? checkSemantic(md, {
68106
68145
  reportPath: rep,
@@ -68108,9 +68147,9 @@ function cmdCheck(p) {
68108
68147
  standard,
68109
68148
  lang
68110
68149
  }) : null;
68111
- const ok = res.ok && (sem === null || sem.ok) && (decided === null || decided.ok);
68150
+ const ok = res.ok && (sem === null || sem.ok) && (decided === null || decided.ok) && (covered === null || covered.ok);
68112
68151
  if (p.flags.json) {
68113
- console.log(JSON.stringify({ ...res, ok, ...sem ? { semantic: sem } : {}, ...decided ? { decided } : {} }, null, 2));
68152
+ console.log(JSON.stringify({ ...res, ok, ...sem ? { semantic: sem } : {}, ...decided ? { decided } : {}, ...covered ? { covered } : {} }, null, 2));
68114
68153
  } else if (!p.flags.quiet) {
68115
68154
  if (decided) {
68116
68155
  const allowedNote = decided.allowed.length ? lang === "fr" ? ` (${decided.allowed.length} crit\xE8re(s) d\xE9clar\xE9(s) ind\xE9cidable(s) : ${decided.allowed.map((a) => `${a.criteriaId} \u2014 ${a.reason}`).join(" \xB7 ")})` : ` (${decided.allowed.length} criterion(ia) declared undecidable: ${decided.allowed.map((a) => `${a.criteriaId} \u2014 ${a.reason}`).join(" \xB7 ")})` : "";
@@ -68119,11 +68158,16 @@ function cmdCheck(p) {
68119
68158
  lang === "fr" ? `\u2713 Grille compl\xE8te : les ${decided.total} crit\xE8res portent un verdict${allowedNote}.` : `\u2713 Complete grid: all ${decided.total} criteria carry a verdict${allowedNote}.`
68120
68159
  );
68121
68160
  }
68161
+ if (covered?.ok) {
68162
+ console.log(
68163
+ lang === "fr" ? `\u2713 \xC9chantillon couvert : les ${covered.declared} page(s) d\xE9clar\xE9e(s) ont une capture.` : `\u2713 Sample covered: all ${covered.declared} declared page(s) have a capture.`
68164
+ );
68165
+ }
68122
68166
  if (ok)
68123
68167
  console.log(
68124
68168
  sem ? lang === "fr" ? `\u2713 Rapport valide + gate s\xE9mantique engag\xE9e : ${sem.total} verdict(s), ${sem.grounded} ancr\xE9(s) dans la source${sem.moved ? ` (${sem.moved} d\xE9plac\xE9(s))` : ""}.` : `\u2713 Report valid + semantic gate engaged: ${sem.total} verdict(s), ${sem.grounded} grounded in source${sem.moved ? ` (${sem.moved} moved)` : ""}.` : lang === "fr" ? "\u2713 Rapport valide : sections, crit\xE8res cit\xE9s et justifications NA coh\xE9rents." : "\u2713 Report valid: sections, cited criteria and NA justifications are consistent."
68125
68169
  );
68126
- else for (const i2 of [...res.issues, ...sem?.issues ?? [], ...decided?.issues ?? []]) console.error(`\u2717 ${i2}`);
68170
+ else for (const i2 of [...res.issues, ...sem?.issues ?? [], ...decided?.issues ?? [], ...covered?.issues ?? []]) console.error(`\u2717 ${i2}`);
68127
68171
  }
68128
68172
  return ok ? 0 : 1;
68129
68173
  }
@@ -4,7 +4,7 @@ description: "Use to AUDIT existing HTML/CSS/JSX against WCAG 2.2 AA accessibili
4
4
  when_to_use: "Invoke when the user asks for an accessibility AUDIT or a formal deliverable: audit a repo, site or page against WCAG 2.2 AA or a country standard (RGAA, Section 508, EN 301 549); produce a dated conformance report, a criterion grid, a PRD backlog, or tickets (GitHub/GitLab/Jira); author accessible markup from scratch; wire the repo gate (init --hook/--ci) or a rendered-DOM capture pipeline. For a review of the code UNDER CHANGE (diff, branch, PR), use the `review-a11y` skill instead."
5
5
  license: MIT
6
6
  metadata:
7
- version: 5.13.0
7
+ version: 5.14.0
8
8
  ---
9
9
 
10
10
  # ultra11y — audit WCAG 2.2 AA and write accessible markup
@@ -6,7 +6,7 @@ import { join as join51, relative as relative5, resolve as resolve16, sep as sep
6
6
  import { fileURLToPath as fileURLToPath5, pathToFileURL as pathToFileURL3 } from "url";
7
7
 
8
8
  // src/types.ts
9
- var VERSION = "5.13.0";
9
+ var VERSION = "5.14.0";
10
10
  var SCHEMA_VERSION = 2;
11
11
  var INAPPLICABLE_STATUS = "C";
12
12
 
@@ -54373,6 +54373,7 @@ function pageStatus(c2, pageFindings, basis) {
54373
54373
  if (pageFindings.some((f) => !f.advisory)) return "NC";
54374
54374
  if (c2.status === "manual") return "manual";
54375
54375
  if (c2.status === "NA") return "NA";
54376
+ if (c2.inapplicable) return c2.status;
54376
54377
  if (c2.decidedBy === "agent" || c2.decidedBy === "scan") return c2.status;
54377
54378
  if (automatability(c2.id) !== "static") return "manual";
54378
54379
  return basis === "snapshot" ? "C" : "manual";
@@ -54390,13 +54391,17 @@ function derivePages(result, pages) {
54390
54391
  const own = result.findings.filter((f) => f.page === p.id);
54391
54392
  const criteria = result.criteria.map((c2) => {
54392
54393
  const pf = own.filter((f) => f.criteriaId === c2.id);
54394
+ const status = pageStatus(c2, pf, p.basis);
54393
54395
  return {
54394
54396
  id: c2.id,
54395
54397
  guideline: c2.guideline,
54396
- status: pageStatus(c2, pf, p.basis),
54398
+ status,
54397
54399
  findings: pf,
54398
54400
  ...c2.justification ? { justification: c2.justification } : {},
54399
- ...c2.decidedBy ? { decidedBy: c2.decidedBy } : {}
54401
+ ...c2.decidedBy ? { decidedBy: c2.decidedBy } : {},
54402
+ // Carried, not recomputed: a finding on THIS page proves the subject exists after all,
54403
+ // and `pageStatus` has already turned that into an NC above.
54404
+ ...c2.inapplicable && status === c2.status ? { inapplicable: true } : {}
54400
54405
  };
54401
54406
  });
54402
54407
  const { rate, decided, total } = pct(criteria);
@@ -56434,7 +56439,7 @@ function renderCriteriaReference() {
56434
56439
  }
56435
56440
 
56436
56441
  // src/check.ts
56437
- import { existsSync as existsSync20, readFileSync as readFileSync18 } from "fs";
56442
+ import { existsSync as existsSync20, readFileSync as readFileSync18, readdirSync as readdirSync5 } from "fs";
56438
56443
  import { dirname as dirname9, join as join34 } from "path";
56439
56444
 
56440
56445
  // src/verify.ts
@@ -56855,6 +56860,33 @@ function sectionBody(md, n) {
56855
56860
  const next = /^##\s+/m.exec(md.slice(from));
56856
56861
  return next ? md.slice(from, from + next.index) : md.slice(from);
56857
56862
  }
56863
+ function checkSampleCaptured(root = ".", lang = "en") {
56864
+ const fr = lang === "fr";
56865
+ let declared = [];
56866
+ try {
56867
+ const cfg = JSON.parse(readFileSync18(join34(root, ".ultra11yrc.json"), "utf8"));
56868
+ declared = cfg.sample?.pages ?? [];
56869
+ } catch {
56870
+ return { ok: true, issues: [], missing: [], declared: 0, captured: 0 };
56871
+ }
56872
+ if (!declared.length) return { ok: true, issues: [], missing: [], declared: 0, captured: 0 };
56873
+ let captured = /* @__PURE__ */ new Set();
56874
+ try {
56875
+ captured = new Set(
56876
+ readdirSync5(join34(root, PAGES_DIR), { withFileTypes: true }).filter((d) => d.isDirectory()).map((d) => d.name).filter((id) => existsSync20(join34(root, PAGES_DIR, id, "dom.html")))
56877
+ );
56878
+ } catch {
56879
+ }
56880
+ const missing = declared.filter((p) => !captured.has(p.id)).map((p) => ({ id: p.id, name: p.name ?? p.id, url: p.url ?? "" }));
56881
+ const issues = [];
56882
+ if (missing.length) {
56883
+ const kept = declared.length - missing.length;
56884
+ issues.push(
56885
+ fr ? `${kept}/${declared.length} page(s) d\xE9clar\xE9e(s) ont \xE9t\xE9 captur\xE9es. Aucune capture pour : ${missing.map((m) => `${m.name} (${m.id})`).join(", ")}. Une page d\xE9clar\xE9e sans capture n'est pas une page conforme, c'est une page que personne n'a regard\xE9e.` : `${kept}/${declared.length} declared page(s) were captured. No capture for: ${missing.map((m) => `${m.name} (${m.id})`).join(", ")}. A declared page with no capture is not a page that passed \u2014 it is a page nobody looked at.`
56886
+ );
56887
+ }
56888
+ return { ok: issues.length === 0, issues, missing, declared: declared.length, captured: captured.size };
56889
+ }
56858
56890
  function byCriterionId(a, b) {
56859
56891
  const pa = a.split(".").map(Number);
56860
56892
  const pb = b.split(".").map(Number);
@@ -58735,7 +58767,7 @@ function applyRawVerdicts(items, verdicts) {
58735
58767
 
58736
58768
  // src/scan.ts
58737
58769
  import { execFileSync as execFileSync6 } from "child_process";
58738
- import { mkdtempSync as mkdtempSync2, writeFileSync as writeFileSync12, existsSync as existsSync23, statSync as statSync8, readdirSync as readdirSync5, rmSync as rmSync3, readFileSync as readFileSync20 } from "fs";
58770
+ import { mkdtempSync as mkdtempSync2, writeFileSync as writeFileSync12, existsSync as existsSync23, statSync as statSync8, readdirSync as readdirSync6, rmSync as rmSync3, readFileSync as readFileSync20 } from "fs";
58739
58771
  import { tmpdir as tmpdir2 } from "os";
58740
58772
  import { join as join37, resolve as resolve11 } from "path";
58741
58773
  import { fileURLToPath as fileURLToPath3 } from "url";
@@ -59059,7 +59091,7 @@ function buildImage(tag = IMAGE_TAG) {
59059
59091
  function cleanTempContexts() {
59060
59092
  let removed = 0;
59061
59093
  const dir = tmpdir2();
59062
- for (const name2 of readdirSync5(dir)) {
59094
+ for (const name2 of readdirSync6(dir)) {
59063
59095
  if (!name2.startsWith(CTX_PREFIX)) continue;
59064
59096
  rmSync3(join37(dir, name2), { recursive: true, force: true });
59065
59097
  removed++;
@@ -65558,7 +65590,7 @@ function str3(v) {
65558
65590
  var DECLARED = new Set([...TOOLS2, ...WRITE_TOOLS].map((t3) => t3.name));
65559
65591
 
65560
65592
  // src/mcp/resources.ts
65561
- import { existsSync as existsSync34, readdirSync as readdirSync6, readFileSync as readFileSync32, realpathSync as realpathSync5, statSync as statSync13 } from "fs";
65593
+ import { existsSync as existsSync34, readdirSync as readdirSync7, readFileSync as readFileSync32, realpathSync as realpathSync5, statSync as statSync13 } from "fs";
65562
65594
  import { basename as basename3, dirname as dirname14, join as join50, resolve as resolve15, sep as sep6 } from "path";
65563
65595
  import { fileURLToPath as fileURLToPath4 } from "url";
65564
65596
  var SKILL_NAME = "ultra11y";
@@ -65574,7 +65606,7 @@ function listResources(moduleDir) {
65574
65606
  const out2 = [describe(root, "SKILL.md", `${SKILL_NAME}: the skill`)];
65575
65607
  const refDir = join50(root, "references");
65576
65608
  if (!existsSync34(refDir)) return out2;
65577
- for (const file of readdirSync6(refDir).sort()) {
65609
+ for (const file of readdirSync7(refDir).sort()) {
65578
65610
  if (!file.endsWith(".md")) continue;
65579
65611
  out2.push(describe(root, join50("references", file), `${SKILL_NAME} reference: ${basename3(file, ".md")}`));
65580
65612
  }
@@ -66452,6 +66484,10 @@ Options:
66452
66484
  dropped as stale and its criterion says so
66453
66485
  --max-verify <n> verify: cap the worklist size; 0 = no cap (default: 40)
66454
66486
  --verdicts <file> check --semantic: the adjudicated verdicts artifact
66487
+ --require-sample check: fail while a page DECLARED in .ultra11yrc.json has no capture under
66488
+ .ultra11y/pages/. Coverage, one level below --require-decided: a sweep that
66489
+ loses pages produces a report that is merely SHORTER, and a shorter
66490
+ deliverable reads exactly like a complete one.
66455
66491
  --require-decided check: fail while ANY criterion of the standard is still \xAB to assess \xBB.
66456
66492
  Needs --in. A green job does not otherwise mean the grid was filled:
66457
66493
  --fail-on governs non-conformities, and an adjudication that lands
@@ -66678,6 +66714,7 @@ var BOOLEAN_FLAGS = /* @__PURE__ */ new Set([
66678
66714
  "semantic",
66679
66715
  // `check`: fail while any criterion of the standard is still « to assess ».
66680
66716
  "require-decided",
66717
+ "require-sample",
66681
66718
  "manual",
66682
66719
  // `verify --apply` / `judge --apply`: restore the all-or-nothing fold, where one refused
66683
66720
  // verdict discards the whole adjudication. The default is per-verdict.
@@ -68054,9 +68091,10 @@ function cmdCheck(p) {
68054
68091
  const standard = stdOf(p, "check");
68055
68092
  if (standard === null) return 2;
68056
68093
  const requireDecided = p.flags["require-decided"] === true;
68094
+ const requireSample = p.flags["require-sample"] === true;
68057
68095
  const rep = p.flags.report;
68058
68096
  if (typeof rep !== "string" || !rep) {
68059
- if (!requireDecided) {
68097
+ if (!requireDecided && !requireSample) {
68060
68098
  console.error("ultra11y check: --report <md> is required.");
68061
68099
  return 2;
68062
68100
  }
@@ -68101,6 +68139,7 @@ function cmdCheck(p) {
68101
68139
  }
68102
68140
  }
68103
68141
  const decided = requireDecided && audit2 ? checkDecided(audit2, standard, lang, { allow }) : null;
68142
+ const covered = requireSample ? checkSampleCaptured(".", lang) : null;
68104
68143
  const res = md ? checkReport(md, standard, lang, { audit: audit2 }) : { ok: true, issues: [] };
68105
68144
  const sem = p.flags.semantic === true && typeof rep === "string" && rep ? checkSemantic(md, {
68106
68145
  reportPath: rep,
@@ -68108,9 +68147,9 @@ function cmdCheck(p) {
68108
68147
  standard,
68109
68148
  lang
68110
68149
  }) : null;
68111
- const ok = res.ok && (sem === null || sem.ok) && (decided === null || decided.ok);
68150
+ const ok = res.ok && (sem === null || sem.ok) && (decided === null || decided.ok) && (covered === null || covered.ok);
68112
68151
  if (p.flags.json) {
68113
- console.log(JSON.stringify({ ...res, ok, ...sem ? { semantic: sem } : {}, ...decided ? { decided } : {} }, null, 2));
68152
+ console.log(JSON.stringify({ ...res, ok, ...sem ? { semantic: sem } : {}, ...decided ? { decided } : {}, ...covered ? { covered } : {} }, null, 2));
68114
68153
  } else if (!p.flags.quiet) {
68115
68154
  if (decided) {
68116
68155
  const allowedNote = decided.allowed.length ? lang === "fr" ? ` (${decided.allowed.length} crit\xE8re(s) d\xE9clar\xE9(s) ind\xE9cidable(s) : ${decided.allowed.map((a) => `${a.criteriaId} \u2014 ${a.reason}`).join(" \xB7 ")})` : ` (${decided.allowed.length} criterion(ia) declared undecidable: ${decided.allowed.map((a) => `${a.criteriaId} \u2014 ${a.reason}`).join(" \xB7 ")})` : "";
@@ -68119,11 +68158,16 @@ function cmdCheck(p) {
68119
68158
  lang === "fr" ? `\u2713 Grille compl\xE8te : les ${decided.total} crit\xE8res portent un verdict${allowedNote}.` : `\u2713 Complete grid: all ${decided.total} criteria carry a verdict${allowedNote}.`
68120
68159
  );
68121
68160
  }
68161
+ if (covered?.ok) {
68162
+ console.log(
68163
+ lang === "fr" ? `\u2713 \xC9chantillon couvert : les ${covered.declared} page(s) d\xE9clar\xE9e(s) ont une capture.` : `\u2713 Sample covered: all ${covered.declared} declared page(s) have a capture.`
68164
+ );
68165
+ }
68122
68166
  if (ok)
68123
68167
  console.log(
68124
68168
  sem ? lang === "fr" ? `\u2713 Rapport valide + gate s\xE9mantique engag\xE9e : ${sem.total} verdict(s), ${sem.grounded} ancr\xE9(s) dans la source${sem.moved ? ` (${sem.moved} d\xE9plac\xE9(s))` : ""}.` : `\u2713 Report valid + semantic gate engaged: ${sem.total} verdict(s), ${sem.grounded} grounded in source${sem.moved ? ` (${sem.moved} moved)` : ""}.` : lang === "fr" ? "\u2713 Rapport valide : sections, crit\xE8res cit\xE9s et justifications NA coh\xE9rents." : "\u2713 Report valid: sections, cited criteria and NA justifications are consistent."
68125
68169
  );
68126
- else for (const i2 of [...res.issues, ...sem?.issues ?? [], ...decided?.issues ?? []]) console.error(`\u2717 ${i2}`);
68170
+ else for (const i2 of [...res.issues, ...sem?.issues ?? [], ...decided?.issues ?? [], ...covered?.issues ?? []]) console.error(`\u2717 ${i2}`);
68127
68171
  }
68128
68172
  return ok ? 0 : 1;
68129
68173
  }
package/src/check.ts CHANGED
@@ -5,7 +5,7 @@
5
5
  // The canonical WCAG report is keyed by 3-segment success criteria (1.4.3); a pack
6
6
  // report by the pack's own 2-segment ids (RGAA 8.3) — the id grammar is per-standard
7
7
  // so the version token "WCAG 2.2 —" can never be mistaken for a criterion.
8
- import { existsSync, readFileSync } from "node:fs";
8
+ import { existsSync, readFileSync, readdirSync } from "node:fs";
9
9
  import { dirname, join } from "node:path";
10
10
  import type { AuditResult, Lang } from "./types.js";
11
11
  import { hasSC } from "./wcag.js";
@@ -13,6 +13,7 @@ import { type StandardId, CORE, criterionCoverage, isCore, loadPack, hasId, idCa
13
13
  import { buildWorklist, applyVerdicts, type VerifyItem } from "./verify.js";
14
14
  import { groundItems } from "./grounding.js";
15
15
  import { isPagesReport } from "./pages-report.js";
16
+ import { PAGES_DIR } from "./snapshot.js";
16
17
 
17
18
  export interface CheckResult {
18
19
  ok: boolean;
@@ -325,6 +326,77 @@ function sectionBody(md: string, n: number): string {
325
326
  // threshold would hide. An entry states which criterion and why; an entry with no reason
326
327
  // fails the gate, because "documented" has to mean something.
327
328
 
329
+ // ---- the COVERAGE gate ------------------------------------------------------------------
330
+ //
331
+ // `--require-decided` asks whether every criterion carries a verdict. This asks the question one
332
+ // level below it, and it is the one that went unasked for a long time: a sweep that loses pages
333
+ // produces a report that is simply SHORTER, and a shorter deliverable reads exactly like a
334
+ // complete one. Measured on a real run — a hung probe killed two specs, a serial group took
335
+ // fifteen more with it, and the RGAA report shipped with 20 of the 35 declared pages, green,
336
+ // with nothing anywhere naming the missing fifteen or saying that any were missing.
337
+ //
338
+ // A declared page with no capture is not a page that passed. It is a page nobody looked at.
339
+
340
+ /** One declared page that produced no capture. */
341
+ export interface UncapturedPage {
342
+ id: string;
343
+ name: string;
344
+ url: string;
345
+ }
346
+
347
+ export interface SampleCoverage {
348
+ ok: boolean;
349
+ issues: string[];
350
+ /** Declared pages with no snapshot under `.ultra11y/pages/`. */
351
+ missing: UncapturedPage[];
352
+ declared: number;
353
+ captured: number;
354
+ }
355
+
356
+ /** Hold the sample a repository DECLARES against the snapshots a run actually produced.
357
+ *
358
+ * Reads `.ultra11yrc.json` for the declaration and the pages directory for the captures, so it
359
+ * answers for the working tree rather than for an audit JSON that may have been produced
360
+ * elsewhere. A repository that declares no sample has nothing to answer — a gate that fails on
361
+ * its own absence is a gate that gets turned off. */
362
+ export function checkSampleCaptured(root = ".", lang: Lang = "en"): SampleCoverage {
363
+ const fr = lang === "fr";
364
+ let declared: { id: string; name?: string; url?: string }[] = [];
365
+ try {
366
+ const cfg = JSON.parse(readFileSync(join(root, ".ultra11yrc.json"), "utf8")) as {
367
+ sample?: { pages?: { id: string; name?: string; url?: string }[] };
368
+ };
369
+ declared = cfg.sample?.pages ?? [];
370
+ } catch {
371
+ return { ok: true, issues: [], missing: [], declared: 0, captured: 0 };
372
+ }
373
+ if (!declared.length) return { ok: true, issues: [], missing: [], declared: 0, captured: 0 };
374
+
375
+ let captured = new Set<string>();
376
+ try {
377
+ captured = new Set(
378
+ readdirSync(join(root, PAGES_DIR), { withFileTypes: true })
379
+ .filter((d) => d.isDirectory())
380
+ .map((d) => d.name)
381
+ .filter((id) => existsSync(join(root, PAGES_DIR, id, "dom.html"))),
382
+ );
383
+ } catch {
384
+ /* no pages directory at all — every declared page is missing, which is what we report */
385
+ }
386
+ const missing = declared.filter((p) => !captured.has(p.id)).map((p) => ({ id: p.id, name: p.name ?? p.id, url: p.url ?? "" }));
387
+
388
+ const issues: string[] = [];
389
+ if (missing.length) {
390
+ const kept = declared.length - missing.length;
391
+ issues.push(
392
+ fr
393
+ ? `${kept}/${declared.length} page(s) déclarée(s) ont été capturées. Aucune capture pour : ${missing.map((m) => `${m.name} (${m.id})`).join(", ")}. Une page déclarée sans capture n'est pas une page conforme, c'est une page que personne n'a regardée.`
394
+ : `${kept}/${declared.length} declared page(s) were captured. No capture for: ${missing.map((m) => `${m.name} (${m.id})`).join(", ")}. A declared page with no capture is not a page that passed — it is a page nobody looked at.`,
395
+ );
396
+ }
397
+ return { ok: issues.length === 0, issues, missing, declared: declared.length, captured: captured.size };
398
+ }
399
+
328
400
  /** Numeric-segment order, so 2.1 comes before 10.1. */
329
401
  function byCriterionId(a: string, b: string): number {
330
402
  const pa = a.split(".").map(Number);
package/src/cli.ts CHANGED
@@ -39,7 +39,7 @@ import { buildGraphStreaming } from "./graph/build.js";
39
39
  import { discover } from "./discover.js";
40
40
  import { toPosix, GRAPH_ONLY_EXT } from "./glob.js";
41
41
  import { runCriteria, renderCriteriaReference } from "./criteria.js";
42
- import { checkDecided, checkReport, checkSemantic, isUndecidedFile, type UndecidedFile } from "./check.js";
42
+ import { checkSampleCaptured, checkDecided, checkReport, checkSemantic, isUndecidedFile, type UndecidedFile } from "./check.js";
43
43
  import { buildWorklist, writeWorklist, applyVerdicts, VERIFY_MAX, type VerifyItem } from "./verify.js";
44
44
  import { groundItems } from "./grounding.js";
45
45
  import {
@@ -397,6 +397,10 @@ Options:
397
397
  dropped as stale and its criterion says so
398
398
  --max-verify <n> verify: cap the worklist size; 0 = no cap (default: 40)
399
399
  --verdicts <file> check --semantic: the adjudicated verdicts artifact
400
+ --require-sample check: fail while a page DECLARED in .ultra11yrc.json has no capture under
401
+ .ultra11y/pages/. Coverage, one level below --require-decided: a sweep that
402
+ loses pages produces a report that is merely SHORTER, and a shorter
403
+ deliverable reads exactly like a complete one.
400
404
  --require-decided check: fail while ANY criterion of the standard is still « to assess ».
401
405
  Needs --in. A green job does not otherwise mean the grid was filled:
402
406
  --fail-on governs non-conformities, and an adjudication that lands
@@ -640,6 +644,7 @@ const BOOLEAN_FLAGS = new Set([
640
644
  "semantic",
641
645
  // `check`: fail while any criterion of the standard is still « to assess ».
642
646
  "require-decided",
647
+ "require-sample",
643
648
  "manual",
644
649
  // `verify --apply` / `judge --apply`: restore the all-or-nothing fold, where one refused
645
650
  // verdict discards the whole adjudication. The default is per-verdict.
@@ -2487,9 +2492,12 @@ function cmdCheck(p: ParsedArgs): number {
2487
2492
  // active standard carries a verdict. It therefore needs --in and not --report, and stands on
2488
2493
  // its own — a project can demand a complete grid long before it publishes a deliverable.
2489
2494
  const requireDecided = p.flags["require-decided"] === true;
2495
+ // Same reasoning for coverage: whether the sweep captured every declared page is a fact
2496
+ // about the working tree, answerable long before there is a deliverable to validate.
2497
+ const requireSample = p.flags["require-sample"] === true;
2490
2498
  const rep = p.flags.report;
2491
2499
  if (typeof rep !== "string" || !rep) {
2492
- if (!requireDecided) {
2500
+ if (!requireDecided && !requireSample) {
2493
2501
  console.error("ultra11y check: --report <md> is required.");
2494
2502
  return 2;
2495
2503
  }
@@ -2539,6 +2547,10 @@ function cmdCheck(p: ParsedArgs): number {
2539
2547
  }
2540
2548
  }
2541
2549
  const decided = requireDecided && audit ? checkDecided(audit, standard, lang, { allow }) : null;
2550
+ // COVERAGE, one level below completeness: did the run capture every page it says it audits?
2551
+ // Needs no audit JSON — it holds the repository's declared sample against the snapshots on
2552
+ // disk, which is what a sweep either produced or did not.
2553
+ const covered = requireSample ? checkSampleCaptured(".", lang) : null;
2542
2554
  const res = md ? checkReport(md, standard, lang, { audit }) : { ok: true, issues: [] };
2543
2555
  // --semantic: the support-level gate ON TOP of the structural check. Fails closed —
2544
2556
  // a green exit must always mean the gate engaged (family P0: never green-but-inactive).
@@ -2552,9 +2564,9 @@ function cmdCheck(p: ParsedArgs): number {
2552
2564
  lang,
2553
2565
  })
2554
2566
  : null;
2555
- const ok = res.ok && (sem === null || sem.ok) && (decided === null || decided.ok);
2567
+ const ok = res.ok && (sem === null || sem.ok) && (decided === null || decided.ok) && (covered === null || covered.ok);
2556
2568
  if (p.flags.json) {
2557
- console.log(JSON.stringify({ ...res, ok, ...(sem ? { semantic: sem } : {}), ...(decided ? { decided } : {}) }, null, 2));
2569
+ console.log(JSON.stringify({ ...res, ok, ...(sem ? { semantic: sem } : {}), ...(decided ? { decided } : {}), ...(covered ? { covered } : {}) }, null, 2));
2558
2570
  } else if (!p.flags.quiet) {
2559
2571
  if (decided) {
2560
2572
  // Say what the gate engaged on, green or red. A completeness gate that reports only its
@@ -2571,6 +2583,15 @@ function cmdCheck(p: ParsedArgs): number {
2571
2583
  : `✓ Complete grid: all ${decided.total} criteria carry a verdict${allowedNote}.`,
2572
2584
  );
2573
2585
  }
2586
+ if (covered?.ok) {
2587
+ // Green or red, say what it engaged on — a gate that reports only its failures cannot be
2588
+ // told apart from one that never ran.
2589
+ console.log(
2590
+ lang === "fr"
2591
+ ? `✓ Échantillon couvert : les ${covered.declared} page(s) déclarée(s) ont une capture.`
2592
+ : `✓ Sample covered: all ${covered.declared} declared page(s) have a capture.`,
2593
+ );
2594
+ }
2574
2595
  if (ok)
2575
2596
  console.log(
2576
2597
  sem
@@ -2581,7 +2602,7 @@ function cmdCheck(p: ParsedArgs): number {
2581
2602
  ? "✓ Rapport valide : sections, critères cités et justifications NA cohérents."
2582
2603
  : "✓ Report valid: sections, cited criteria and NA justifications are consistent.",
2583
2604
  );
2584
- else for (const i of [...res.issues, ...(sem?.issues ?? []), ...(decided?.issues ?? [])]) console.error(`✗ ${i}`);
2605
+ else for (const i of [...res.issues, ...(sem?.issues ?? []), ...(decided?.issues ?? []), ...(covered?.issues ?? [])]) console.error(`✗ ${i}`);
2585
2606
  }
2586
2607
  return ok ? 0 : 1;
2587
2608
  }
package/src/pages.ts CHANGED
@@ -165,7 +165,16 @@ function pageStatus(c: CriterionResult, pageFindings: Finding[], basis: PageScop
165
165
  // A non-normative recommendation can never flip a criterion to NC — same rule as core.
166
166
  if (pageFindings.some((f) => !f.advisory)) return "NC";
167
167
  if (c.status === "manual") return "manual"; // the engine cannot decide it anywhere
168
- if (c.status === "NA") return "NA"; // not applicable in scope ⇒ not applicable here
168
+ if (c.status === "NA") return "NA"; // legacy audits only — the engine no longer emits it
169
+ // NOTHING OF THAT KIND EXISTS ANYWHERE IN SCOPE, so there is none on this page either.
170
+ //
171
+ // Checked BEFORE rule 3 below, and it is not an exception to it: rule 3 refuses to conclude
172
+ // from silence, and this is not silence — it is a scope-wide absence the engine can point at,
173
+ // carrying the justification that names the subject it looked for. Left to fall through, every
174
+ // media, table and form criterion came back « à évaluer » on each page while the run-wide grid
175
+ // had them settled: measured on a three-page sample, 75 of 106 RGAA criteria open per page
176
+ // against 26 for the run.
177
+ if (c.inapplicable) return c.status;
169
178
 
170
179
  // A verdict SOMEBODY RECORDED is not silence, and rule 3 below is only about silence.
171
180
  //
@@ -225,13 +234,17 @@ export function derivePages(result: AuditResult, pages: PageScope[]): PageResult
225
234
  const own = result.findings.filter((f) => f.page === p.id);
226
235
  const criteria: CriterionResult[] = result.criteria.map((c) => {
227
236
  const pf = own.filter((f) => f.criteriaId === c.id);
237
+ const status = pageStatus(c, pf, p.basis);
228
238
  return {
229
239
  id: c.id,
230
240
  guideline: c.guideline,
231
- status: pageStatus(c, pf, p.basis),
241
+ status,
232
242
  findings: pf,
233
243
  ...(c.justification ? { justification: c.justification } : {}),
234
244
  ...(c.decidedBy ? { decidedBy: c.decidedBy } : {}),
245
+ // Carried, not recomputed: a finding on THIS page proves the subject exists after all,
246
+ // and `pageStatus` has already turned that into an NC above.
247
+ ...(c.inapplicable && status === c.status ? { inapplicable: true } : {}),
235
248
  };
236
249
  });
237
250
  const { rate, decided, total } = pct(criteria);
package/src/types.ts CHANGED
@@ -1,6 +1,6 @@
1
1
  // Single source of truth for shared types + the embedded version. sync-version.mjs
2
2
  // rewrites VERSION at release time (kept in lockstep with package.json + SKILL.md).
3
- export const VERSION = "5.13.0";
3
+ export const VERSION = "5.14.0";
4
4
  export const SCHEMA_VERSION = 2;
5
5
 
6
6
  // Lang = the UI FRAME's language — the `L` localization tables each module (report,