ultra11y 2.31.0 → 2.31.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -10,7 +10,7 @@
10
10
  "name": "ultra11y",
11
11
  "source": "./",
12
12
  "description": "Automatic accessibility review of the code under change before every commit, push and pull request, plus full WCAG 2.2 AA / RGAA audits and conformance deliverables on demand.",
13
- "version": "2.31.0",
13
+ "version": "2.31.2",
14
14
  "author": { "name": "maxgfr" },
15
15
  "homepage": "https://github.com/maxgfr/ultra11y",
16
16
  "repository": "https://github.com/maxgfr/ultra11y",
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "ultra11y",
3
3
  "description": "Accessibility for WCAG 2.2 AA and country standards (RGAA…): an automatic review of the code under change before every commit, push and pull request, plus full audits and conformance deliverables on demand.",
4
- "version": "2.31.0",
4
+ "version": "2.31.2",
5
5
  "author": { "name": "maxgfr" },
6
6
  "homepage": "https://github.com/maxgfr/ultra11y",
7
7
  "repository": "https://github.com/maxgfr/ultra11y",
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "ultra11y",
3
3
  "description": "Accessibility for WCAG 2.2 AA and country standards (RGAA…): an automatic review of the code under change before every commit, push and pull request, plus full audits and conformance deliverables on demand.",
4
- "version": "2.31.0",
4
+ "version": "2.31.2",
5
5
  "author": { "name": "maxgfr", "url": "https://github.com/maxgfr" },
6
6
  "homepage": "https://github.com/maxgfr/ultra11y",
7
7
  "repository": "https://github.com/maxgfr/ultra11y",
@@ -27,7 +27,7 @@ import { fileURLToPath } from "node:url";
27
27
  // Ownership marker: `ultra11y install --opencode` refuses to overwrite a plugin file that
28
28
  // does not carry it, so a user's own ultra11y.js is never clobbered.
29
29
  const ULTRA11Y_OPENCODE_PLUGIN = "ULTRA11Y_OPENCODE_PLUGIN";
30
- const ULTRA11Y_PLUGIN_VERSION = "2.31.0";
30
+ const ULTRA11Y_PLUGIN_VERSION = "2.31.2";
31
31
 
32
32
  const here = dirname(fileURLToPath(import.meta.url));
33
33
 
package/README.md CHANGED
@@ -195,7 +195,7 @@ the one output an accessibility tool must never produce.
195
195
 
196
196
  ### Tools
197
197
 
198
- Ten read tools. `ultra11y_audit` is the entry point:
198
+ Eleven read tools. `ultra11y_audit` is the entry point:
199
199
 
200
200
  | Tool | What it does |
201
201
  |------|--------------|
@@ -208,6 +208,7 @@ Ten read tools. `ultra11y_audit` is the entry point:
208
208
  | `ultra11y_verify` | Claim↔evidence worklist |
209
209
  | `ultra11y_pack_check` | Validate a country standards pack against what it really ships |
210
210
  | `ultra11y_sample_check` | Lint the normative page sample |
211
+ | `ultra11y_pages` | The per-page view: the criterion × page grid, or one dossier per page |
211
212
  | `ultra11y_read` | A file, or a line range, from the project |
212
213
 
213
214
  `--allow-write` additionally exposes `ultra11y_fix` (safe codemods, dry-run by
@@ -216,6 +217,10 @@ project. `ultra11y_scan` is declared but declines over MCP: it drives a headless
216
217
  browser and may pull a Docker image, which is not a subprocess lifecycle a
217
218
  long-lived server should own. It tells you to run it from the CLI and re-audit.
218
219
 
220
+ Every tool audits exactly what the CLI would: the rendered captures and the page
221
+ snapshots under `.ultra11y/` are ingested here too, so the server never reports a
222
+ smaller audit than the same command run by hand.
223
+
219
224
  Pass `--cwd <dir>` at startup to dedicate the server to one project — `cwd` then
220
225
  becomes optional on every tool.
221
226
 
@@ -5,6 +5,12 @@ interface TaskPayload extends SnapshotPayload {
5
5
  failOn?: string | false;
6
6
  /** The name `cy.screenshot()` was called with, when the browser half took one. */
7
7
  screenshotName?: string;
8
+ /** Forwarded from `cy.ultra11y({ report })` — the browser cannot write files, this side can. */
9
+ report?: boolean | {
10
+ out?: string;
11
+ standard?: string;
12
+ lang?: string;
13
+ };
8
14
  }
9
15
  interface TaskResult {
10
16
  findings: FindingLike[];
@@ -48,6 +48,18 @@ ${res.stderr || ""}`);
48
48
  ${res.stdout.slice(0, 500)}`);
49
49
  }
50
50
  }
51
+ function writePagesReport(opts = {}, engine = enginePath()) {
52
+ const args = [engine, "pages", "--in", "-", "--format", "report"];
53
+ if (opts.split !== false) args.push("--split", "page");
54
+ args.push("--out", opts.out ?? "audits/pages");
55
+ if (opts.standard) args.push("--standard", opts.standard);
56
+ if (opts.lang) args.push("--lang", opts.lang);
57
+ const audit = spawnSync(process.execPath, [engine, "audit", ".ultra11y/pages", "--json"], { encoding: "utf8", maxBuffer: 192 * 1024 * 1024 });
58
+ if (audit.error || !audit.stdout) return void 0;
59
+ const res = spawnSync(process.execPath, args, { input: audit.stdout, encoding: "utf8", maxBuffer: 64 * 1024 * 1024 });
60
+ if (res.error || res.status !== 0) return void 0;
61
+ return res.stdout.trim() || void 0;
62
+ }
51
63
 
52
64
  // src/integrations/cypress-plugin.ts
53
65
  function register(on) {
@@ -67,6 +79,7 @@ function register(on) {
67
79
  }
68
80
  }
69
81
  const result = auditSnapshot(withShot);
82
+ if (payload.report) writePagesReport(typeof payload.report === "object" ? payload.report : {});
70
83
  const failOn = payload.failOn === void 0 ? "blocking" : payload.failOn;
71
84
  const failing = failOn === false ? [] : failingFindings(result, failOn);
72
85
  return {
package/dist/cypress.d.ts CHANGED
@@ -5,6 +5,17 @@ interface CypressCheckOptions extends CheckOptions {
5
5
  /** Capture a viewport screenshot so the pixel tier can run. On by default — the plugin
6
6
  * reads it back through Cypress's `after:screenshot` event. */
7
7
  screenshot?: boolean;
8
+ /** Also write the per-page report once this page is recorded. Off by default: a report per
9
+ * checked page would be wasteful in a suite, so turn it on in a final test.
10
+ *
11
+ * Cypress test code runs in the browser and cannot write files, so the option is only
12
+ * DECLARED here and forwarded — the Node half does the work. Same option, same behaviour
13
+ * as Playwright's; a runner that silently ignored it would be worse than one that lacked it. */
14
+ report?: boolean | {
15
+ out?: string;
16
+ standard?: string;
17
+ lang?: string;
18
+ };
8
19
  }
9
20
  declare function registerUltra11yCommand(): void;
10
21
 
package/dist/cypress.mjs CHANGED
@@ -154,6 +154,7 @@ function registerUltra11yCommand() {
154
154
  const payload = {
155
155
  ...buildPayload(collected, url, "cypress", opts),
156
156
  failOn: opts.failOn,
157
+ ...opts.report ? { report: opts.report } : {},
157
158
  ...shotName ? { screenshotName: shotName } : {}
158
159
  };
159
160
  return cy.task("ultra11ySnapshot", payload, { log: false }).then((res) => {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "ultra11y",
3
- "version": "2.31.0",
3
+ "version": "2.31.2",
4
4
  "description": "Audit HTML/CSS/JSX against WCAG 2.2 AA and produce a dated compliance report, or author/review accessible markup (native-HTML-first). A skills.sh agent skill: an install-free static engine (bundled, no npm install) plus the agent's judgment, with check/verify gates against hallucinated non-conformities, and pluggable in-repo standards packs (RGAA France first; Section 508 & EN 301 549 next).",
5
5
  "type": "module",
6
6
  "packageManager": "pnpm@10.33.0",
@@ -6,7 +6,7 @@ import { join as join45, relative as relative5, sep as sep7, dirname as dirname1
6
6
  import { fileURLToPath as fileURLToPath5, pathToFileURL as pathToFileURL3 } from "url";
7
7
 
8
8
  // src/types.ts
9
- var VERSION = "2.31.0";
9
+ var VERSION = "2.31.2";
10
10
  var SCHEMA_VERSION = 2;
11
11
 
12
12
  // src/audit.ts
@@ -55414,7 +55414,15 @@ function startDevServer(opts) {
55414
55414
  }
55415
55415
  if (url.pathname === "/health") {
55416
55416
  res.writeHead(200, { "content-type": "application/json", "cache-control": "no-store" }).end(
55417
- JSON.stringify({ ok: true, tool: "ultra11y", version: VERSION, standard: opts.standard, lang: opts.lang, pages: readSnapshots(opts.root).length })
55417
+ JSON.stringify({
55418
+ ok: true,
55419
+ tool: "ultra11y",
55420
+ version: VERSION,
55421
+ port: opts.port,
55422
+ standard: opts.standard,
55423
+ lang: opts.lang,
55424
+ pages: readSnapshots(opts.root).length
55425
+ })
55418
55426
  );
55419
55427
  return;
55420
55428
  }
@@ -56150,6 +56158,7 @@ function noop() {
56150
56158
  }
56151
56159
 
56152
56160
  // src/mcp/handlers.ts
56161
+ var CAPTURES_DIR = ".ultra11y/captures";
56153
56162
  var ToolError = class extends Error {
56154
56163
  };
56155
56164
  var MAX_READ_LINES = 2e3;
@@ -56220,6 +56229,8 @@ async function dispatch(name2, args2, cwd) {
56220
56229
  return handlePackCheck(args2, cwd);
56221
56230
  case "ultra11y_sample_check":
56222
56231
  return handleSampleCheck(cwd);
56232
+ case "ultra11y_pages":
56233
+ return handlePages(args2, cwd);
56223
56234
  case "ultra11y_read":
56224
56235
  return handleRead(args2, cwd);
56225
56236
  case "ultra11y_fix":
@@ -56240,8 +56251,11 @@ function audit(args2, cwd) {
56240
56251
  const warnings = [];
56241
56252
  try {
56242
56253
  process.chdir(cwd);
56243
- return runAudit({
56244
- inputs: strArray2(args2.globs) ?? DEFAULT_GLOBS,
56254
+ const inputs = strArray2(args2.globs) ?? DEFAULT_GLOBS;
56255
+ const scopedToDiff = bool(args2.changed) || bool(args2.staged) || str2(args2.since) !== void 0;
56256
+ const extra = [CAPTURES_DIR, PAGES_DIR].filter((d) => !scopedToDiff && existsSync29(d) && !inputs.includes(d));
56257
+ const result = runAudit({
56258
+ inputs: [...inputs, ...extra],
56245
56259
  include: strArray2(args2.include),
56246
56260
  exclude: strArray2(args2.exclude),
56247
56261
  forceJsx: bool(args2.jsx),
@@ -56252,6 +56266,14 @@ function audit(args2, cwd) {
56252
56266
  maxFiles: positive(args2.max_files, "max_files"),
56253
56267
  onWarn: (m) => warnings.push(m)
56254
56268
  });
56269
+ if (extra.includes(PAGES_DIR)) {
56270
+ const scope = pageScopesFrom(readSnapshots("."));
56271
+ if (scope.length) {
56272
+ result.scope.pages = scope;
56273
+ attributePages(result, scope);
56274
+ }
56275
+ }
56276
+ return result;
56255
56277
  } finally {
56256
56278
  process.chdir(prev);
56257
56279
  }
@@ -56337,6 +56359,30 @@ function handleSampleCheck(cwd) {
56337
56359
  }
56338
56360
  return { cwd, config: raw, next: "Check the sample covers every page kind the methodology requires, not just the convenient ones." };
56339
56361
  }
56362
+ function handlePages(args2, cwd) {
56363
+ const standard = standardOf(args2);
56364
+ const lang = langOf(args2);
56365
+ const r = audit(args2, cwd);
56366
+ const scope = pagesOf(r);
56367
+ if (!scope.length) {
56368
+ throw new ToolError(
56369
+ "no page in scope: this audit covers no snapshot (.ultra11y/pages) and no scanned sample. Capture pages with the E2E plugins, `ultra11y dev`, or `scan` (which snapshots every page it visits), then re-run."
56370
+ );
56371
+ }
56372
+ attributePages(r, scope);
56373
+ const derived = derivePages(r, scope);
56374
+ const format = typeof args2.format === "string" ? args2.format : "grid";
56375
+ const markdown = format === "report" ? renderPagesDocument(r, derived, { standard, lang }) : renderPageGrid(r, scope, standard, lang);
56376
+ return {
56377
+ cwd,
56378
+ standard,
56379
+ lang,
56380
+ markdown,
56381
+ pages: derived.map((p) => ({ id: p.id, name: p.name, url: p.url, basis: p.basis, conformancePct: p.conformancePct, findings: p.findings.length })),
56382
+ unattributed: unattributedFindings(r).length,
56383
+ next: 'A page whose basis is "attributed" has no snapshot: absence of a finding there is NOT conformity. Its undecided criteria stay yours to adjudicate.'
56384
+ };
56385
+ }
56340
56386
  function handleFix(args2, cwd) {
56341
56387
  const prev = process.cwd();
56342
56388
  try {
@@ -56667,6 +56713,26 @@ var TOOLS2 = [
56667
56713
  description: "Validate a project's `.ultra11yrc.json` page sample against the methodology: a conformance claim covering only the pages that happened to be easy is not a conformance claim. Reads the config; changes nothing.",
56668
56714
  inputSchema: { type: "object", properties: { cwd: cwdProp }, required: ["cwd"] }
56669
56715
  },
56716
+ {
56717
+ name: "ultra11y_pages",
56718
+ title: "The per-page view of the audit",
56719
+ description: "RGAA \u2014 like every country standard \u2014 is a PER-PAGE norm, but the engine's verdict is scope-wide. This projects the audit onto the pages it covers: the criterion \xD7 page grid, or the per-page report (one dossier per page, every criterion of the standard with its status on THAT page). Two rules make it trustworthy: a finding belongs to a page only when something says so \u2014 never spread across pages \u2014 and a criterion is conforming by silence only on a page whose real rendered DOM was audited, and only for the criteria the engine can actually decide. Everything else stays \xAB to assess \xBB, which is what it is. Pages come from snapshots (.ultra11y/pages) or a scanned sample; with neither, there is nothing to project.",
56720
+ inputSchema: {
56721
+ type: "object",
56722
+ properties: {
56723
+ cwd: cwdProp,
56724
+ globs: globsProp,
56725
+ standard: standardProp,
56726
+ lang: langProp,
56727
+ format: {
56728
+ type: "string",
56729
+ enum: ["grid", "report"],
56730
+ description: "`grid` (default) is the criterion \xD7 page matrix; `report` is the per-page dossiers."
56731
+ }
56732
+ },
56733
+ required: ["cwd"]
56734
+ }
56735
+ },
56670
56736
  {
56671
56737
  name: "ultra11y_read",
56672
56738
  title: "Read a file from the project",
@@ -56754,6 +56820,7 @@ var TOOL_META2 = {
56754
56820
  ultra11y_adjudicate: { openWorld: false },
56755
56821
  ultra11y_pack_check: { openWorld: false },
56756
56822
  ultra11y_sample_check: { openWorld: false },
56823
+ ultra11y_pages: { openWorld: false },
56757
56824
  ultra11y_read: { openWorld: false },
56758
56825
  // Rewrites source files. Not destructive in the delete sense, and it verifies
56759
56826
  // its own work — but it is the user's code.
@@ -59052,6 +59119,14 @@ async function cmdJudge(p) {
59052
59119
  }
59053
59120
  const max = typeof p.flags.max === "string" ? Number(p.flags.max) : void 0;
59054
59121
  const truncated = max !== void 0 && Number.isFinite(max) && max > 0 && items.length > max;
59122
+ if (truncated && p.flags.apply === true) {
59123
+ console.error(
59124
+ lang === "fr" ? `ultra11y judge : --max ${max} ne couvre que ${max} des ${items.length} crit\xE8res, et --apply exige une adjudication COMPL\xC8TE (le gate refuse une couverture partielle).
59125
+ Retirez --max pour tout adjuger, ou retirez --apply pour produire la worklist et l'appliquer plus tard.` : `ultra11y judge: --max ${max} covers only ${max} of ${items.length} criteria, and --apply requires a COMPLETE adjudication (the gate refuses partial coverage).
59126
+ Drop --max to adjudicate everything, or drop --apply to produce the worklist and apply it later.`
59127
+ );
59128
+ return 2;
59129
+ }
59055
59130
  if (truncated) items = items.slice(0, max);
59056
59131
  const batches = [];
59057
59132
  for (let i2 = 0; i2 < items.length; i2 += BATCH_SIZE) {
@@ -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: 2.31.0
8
+ version: 2.31.2
9
9
  ---
10
10
 
11
11
  # review-a11y — accessibility review of changed code
@@ -6,7 +6,7 @@ import { join as join45, relative as relative5, sep as sep7, dirname as dirname1
6
6
  import { fileURLToPath as fileURLToPath5, pathToFileURL as pathToFileURL3 } from "url";
7
7
 
8
8
  // src/types.ts
9
- var VERSION = "2.31.0";
9
+ var VERSION = "2.31.2";
10
10
  var SCHEMA_VERSION = 2;
11
11
 
12
12
  // src/audit.ts
@@ -55414,7 +55414,15 @@ function startDevServer(opts) {
55414
55414
  }
55415
55415
  if (url.pathname === "/health") {
55416
55416
  res.writeHead(200, { "content-type": "application/json", "cache-control": "no-store" }).end(
55417
- JSON.stringify({ ok: true, tool: "ultra11y", version: VERSION, standard: opts.standard, lang: opts.lang, pages: readSnapshots(opts.root).length })
55417
+ JSON.stringify({
55418
+ ok: true,
55419
+ tool: "ultra11y",
55420
+ version: VERSION,
55421
+ port: opts.port,
55422
+ standard: opts.standard,
55423
+ lang: opts.lang,
55424
+ pages: readSnapshots(opts.root).length
55425
+ })
55418
55426
  );
55419
55427
  return;
55420
55428
  }
@@ -56150,6 +56158,7 @@ function noop() {
56150
56158
  }
56151
56159
 
56152
56160
  // src/mcp/handlers.ts
56161
+ var CAPTURES_DIR = ".ultra11y/captures";
56153
56162
  var ToolError = class extends Error {
56154
56163
  };
56155
56164
  var MAX_READ_LINES = 2e3;
@@ -56220,6 +56229,8 @@ async function dispatch(name2, args2, cwd) {
56220
56229
  return handlePackCheck(args2, cwd);
56221
56230
  case "ultra11y_sample_check":
56222
56231
  return handleSampleCheck(cwd);
56232
+ case "ultra11y_pages":
56233
+ return handlePages(args2, cwd);
56223
56234
  case "ultra11y_read":
56224
56235
  return handleRead(args2, cwd);
56225
56236
  case "ultra11y_fix":
@@ -56240,8 +56251,11 @@ function audit(args2, cwd) {
56240
56251
  const warnings = [];
56241
56252
  try {
56242
56253
  process.chdir(cwd);
56243
- return runAudit({
56244
- inputs: strArray2(args2.globs) ?? DEFAULT_GLOBS,
56254
+ const inputs = strArray2(args2.globs) ?? DEFAULT_GLOBS;
56255
+ const scopedToDiff = bool(args2.changed) || bool(args2.staged) || str2(args2.since) !== void 0;
56256
+ const extra = [CAPTURES_DIR, PAGES_DIR].filter((d) => !scopedToDiff && existsSync29(d) && !inputs.includes(d));
56257
+ const result = runAudit({
56258
+ inputs: [...inputs, ...extra],
56245
56259
  include: strArray2(args2.include),
56246
56260
  exclude: strArray2(args2.exclude),
56247
56261
  forceJsx: bool(args2.jsx),
@@ -56252,6 +56266,14 @@ function audit(args2, cwd) {
56252
56266
  maxFiles: positive(args2.max_files, "max_files"),
56253
56267
  onWarn: (m) => warnings.push(m)
56254
56268
  });
56269
+ if (extra.includes(PAGES_DIR)) {
56270
+ const scope = pageScopesFrom(readSnapshots("."));
56271
+ if (scope.length) {
56272
+ result.scope.pages = scope;
56273
+ attributePages(result, scope);
56274
+ }
56275
+ }
56276
+ return result;
56255
56277
  } finally {
56256
56278
  process.chdir(prev);
56257
56279
  }
@@ -56337,6 +56359,30 @@ function handleSampleCheck(cwd) {
56337
56359
  }
56338
56360
  return { cwd, config: raw, next: "Check the sample covers every page kind the methodology requires, not just the convenient ones." };
56339
56361
  }
56362
+ function handlePages(args2, cwd) {
56363
+ const standard = standardOf(args2);
56364
+ const lang = langOf(args2);
56365
+ const r = audit(args2, cwd);
56366
+ const scope = pagesOf(r);
56367
+ if (!scope.length) {
56368
+ throw new ToolError(
56369
+ "no page in scope: this audit covers no snapshot (.ultra11y/pages) and no scanned sample. Capture pages with the E2E plugins, `ultra11y dev`, or `scan` (which snapshots every page it visits), then re-run."
56370
+ );
56371
+ }
56372
+ attributePages(r, scope);
56373
+ const derived = derivePages(r, scope);
56374
+ const format = typeof args2.format === "string" ? args2.format : "grid";
56375
+ const markdown = format === "report" ? renderPagesDocument(r, derived, { standard, lang }) : renderPageGrid(r, scope, standard, lang);
56376
+ return {
56377
+ cwd,
56378
+ standard,
56379
+ lang,
56380
+ markdown,
56381
+ pages: derived.map((p) => ({ id: p.id, name: p.name, url: p.url, basis: p.basis, conformancePct: p.conformancePct, findings: p.findings.length })),
56382
+ unattributed: unattributedFindings(r).length,
56383
+ next: 'A page whose basis is "attributed" has no snapshot: absence of a finding there is NOT conformity. Its undecided criteria stay yours to adjudicate.'
56384
+ };
56385
+ }
56340
56386
  function handleFix(args2, cwd) {
56341
56387
  const prev = process.cwd();
56342
56388
  try {
@@ -56667,6 +56713,26 @@ var TOOLS2 = [
56667
56713
  description: "Validate a project's `.ultra11yrc.json` page sample against the methodology: a conformance claim covering only the pages that happened to be easy is not a conformance claim. Reads the config; changes nothing.",
56668
56714
  inputSchema: { type: "object", properties: { cwd: cwdProp }, required: ["cwd"] }
56669
56715
  },
56716
+ {
56717
+ name: "ultra11y_pages",
56718
+ title: "The per-page view of the audit",
56719
+ description: "RGAA \u2014 like every country standard \u2014 is a PER-PAGE norm, but the engine's verdict is scope-wide. This projects the audit onto the pages it covers: the criterion \xD7 page grid, or the per-page report (one dossier per page, every criterion of the standard with its status on THAT page). Two rules make it trustworthy: a finding belongs to a page only when something says so \u2014 never spread across pages \u2014 and a criterion is conforming by silence only on a page whose real rendered DOM was audited, and only for the criteria the engine can actually decide. Everything else stays \xAB to assess \xBB, which is what it is. Pages come from snapshots (.ultra11y/pages) or a scanned sample; with neither, there is nothing to project.",
56720
+ inputSchema: {
56721
+ type: "object",
56722
+ properties: {
56723
+ cwd: cwdProp,
56724
+ globs: globsProp,
56725
+ standard: standardProp,
56726
+ lang: langProp,
56727
+ format: {
56728
+ type: "string",
56729
+ enum: ["grid", "report"],
56730
+ description: "`grid` (default) is the criterion \xD7 page matrix; `report` is the per-page dossiers."
56731
+ }
56732
+ },
56733
+ required: ["cwd"]
56734
+ }
56735
+ },
56670
56736
  {
56671
56737
  name: "ultra11y_read",
56672
56738
  title: "Read a file from the project",
@@ -56754,6 +56820,7 @@ var TOOL_META2 = {
56754
56820
  ultra11y_adjudicate: { openWorld: false },
56755
56821
  ultra11y_pack_check: { openWorld: false },
56756
56822
  ultra11y_sample_check: { openWorld: false },
56823
+ ultra11y_pages: { openWorld: false },
56757
56824
  ultra11y_read: { openWorld: false },
56758
56825
  // Rewrites source files. Not destructive in the delete sense, and it verifies
56759
56826
  // its own work — but it is the user's code.
@@ -59052,6 +59119,14 @@ async function cmdJudge(p) {
59052
59119
  }
59053
59120
  const max = typeof p.flags.max === "string" ? Number(p.flags.max) : void 0;
59054
59121
  const truncated = max !== void 0 && Number.isFinite(max) && max > 0 && items.length > max;
59122
+ if (truncated && p.flags.apply === true) {
59123
+ console.error(
59124
+ lang === "fr" ? `ultra11y judge : --max ${max} ne couvre que ${max} des ${items.length} crit\xE8res, et --apply exige une adjudication COMPL\xC8TE (le gate refuse une couverture partielle).
59125
+ Retirez --max pour tout adjuger, ou retirez --apply pour produire la worklist et l'appliquer plus tard.` : `ultra11y judge: --max ${max} covers only ${max} of ${items.length} criteria, and --apply requires a COMPLETE adjudication (the gate refuses partial coverage).
59126
+ Drop --max to adjudicate everything, or drop --apply to produce the worklist and apply it later.`
59127
+ );
59128
+ return 2;
59129
+ }
59055
59130
  if (truncated) items = items.slice(0, max);
59056
59131
  const batches = [];
59057
59132
  for (let i2 = 0; i2 < items.length; i2 += BATCH_SIZE) {
@@ -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 remediation backlog or GitHub issues; 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: 2.31.0
7
+ version: 2.31.2
8
8
  ---
9
9
 
10
10
  # ultra11y — audit WCAG 2.2 AA and write accessible markup
@@ -189,6 +189,29 @@ Domain knowledge first, then the tooling. Read the one that matches the question
189
189
  something SAYS so (else it is reported as unattributed, never spread), and `C` by silence is
190
190
  earned only by a page whose real DOM was audited — a source-only page keeps its undecided
191
191
  criteria « à évaluer »; read **`references/pages.md`**.
192
+ - **"I want a report PER PAGE, not a matrix"** → `pages --in audits/audit-latest.json
193
+ --standard rgaa --format report --split page --out audits/pages`: one dossier per page —
194
+ identity, its screenshot, its rate, EVERY criterion of the standard with its status on that
195
+ page, then each non-conformity as the ordinary auditor block. Nothing is re-decided (the
196
+ same projection as the grid) and no format is invented (the same auditor block as the
197
+ report), so a page sheet and the compliance report cannot disagree. `check` gates it against
198
+ invented criteria like any other report; read **`references/pages.md`**.
199
+ - **"Which pages should I even audit?"** → `pages discover --crawl http://localhost:3000
200
+ --write` (or `--sitemap <url>`) writes the `sample.pages` block for you, taking each page's
201
+ name from its served `<title>` and NEVER overwriting the auth/storageState/notes already
202
+ declared. Then `sample check` lints the coverage and `scan --sample` scans and snapshots it.
203
+ A client-rendered SPA exposes no routes in its served HTML — use a sitemap there; read
204
+ **`references/dynamic.md`**.
205
+ - **"Audit these URLs page by page, in CI"** → the shipped Action takes `urls`, `sitemap`,
206
+ `crawl` or `sample`, snapshots every page it visits, writes the per-page dossiers into its
207
+ artifact and adds a per-page scoreboard to the job summary and the PR comment; read
208
+ **`references/ci.md`**.
209
+ - **"Nobody is here to rule on the judgment criteria"** → `judge --in audits/audit-latest.json
210
+ --standard rgaa [--apply]` adjudicates them with a model, for a run with no coding agent in
211
+ the loop (CI, the extension, an E2E run). It is a CALLER, not a second judge: same worklist,
212
+ same evidence, same prompt, and the verdicts pass the same fail-closed gate yours do. The
213
+ only command that takes an API key — inside an agent, use `verify --manual` instead and rule
214
+ yourself; read **`references/judgment.md`**.
192
215
  - **"Show the findings ON the pull request, not just a red job"** → `--format sarif` (upload
193
216
  to code scanning → inline annotations at the right file:line) or `--format github`
194
217
  (`::error::` workflow commands + a `$GITHUB_STEP_SUMMARY` table) — from `report
@@ -196,3 +196,37 @@ Two surfaces come out of it:
196
196
  standard with its status on that page, and each non-conformity as the ordinary auditor block.
197
197
 
198
198
  A run with no page in scope is not a failure: the report step says so and the job carries on.
199
+
200
+ ## The action is executed in ITS OWN CI, not just parsed
201
+
202
+ `tests/action.test.ts` reads `action.yml` and gates its shape. That cannot prove the thing
203
+ works: a composite action is bash — arrays, `set -e`, quoting — and the whole file could stay
204
+ green while the action was broken for every consumer.
205
+
206
+ So `ci.yml` has an `action` job that `uses: ./` for real, twice: report-only over a
207
+ non-conforming fixture (asserting the declared outputs, the written report, and that the
208
+ per-page step DEGRADES rather than failing when nothing was scanned), then the page-by-page
209
+ path — a served two-page site, crawled, scanned, snapshotted, folded in and rendered as
210
+ per-page dossiers, asserting that each crawled page is snapshot-based and that the finding on
211
+ one page did not leak onto the clean one. Both run with `sarif: false`, so the job needs no
212
+ `security-events: write` and works on a fork's pull request.
213
+
214
+ Two traps are worth naming, because running it is what found them.
215
+
216
+ `[ cond ] && cmd` returns 1 when the condition is false, and `-e` turns that into a dead job.
217
+ A test now forbids the form in `action.yml` **and** in every CI workflow.
218
+
219
+ And **artifact names are unique per workflow RUN, not per step**. Using the action twice in
220
+ one job — the code diff, then the served pages — died on a `409 Conflict`, with the report
221
+ written and never uploaded. Pass `artifact-name` on each invocation:
222
+
223
+ ```yaml
224
+ - uses: maxgfr/ultra11y@v2
225
+ with: { since: auto, artifact-name: a11y-code }
226
+ - uses: maxgfr/ultra11y@v2
227
+ with: { crawl: http://localhost:3000, artifact-name: a11y-pages }
228
+ ```
229
+
230
+ Left unset it keeps the historical `ultra11y-<standard>`, so a single-invocation workflow is
231
+ unaffected.
232
+
@@ -114,10 +114,13 @@ between the two.
114
114
 
115
115
  ## A report straight out of the test run
116
116
 
117
- `checkA11y(page, { report: true })` (Playwright) writes the per-page report once the page is
118
- recorded — `audits/pages/index.md` plus one sheet per page, screenshots included. Off by
117
+ `checkA11y(page, { report: true })` — and `cy.ultra11y({ report: true })` — writes the per-page
118
+ report once the page is recorded — `audits/pages/index.md` plus one sheet per page, screenshots included. Off by
119
119
  default: a report per checked page would be wasteful in a suite, so turn it on in a final
120
- test, or run `pages --format report` yourself afterwards.
120
+ test, or run `pages --format report` yourself afterwards. Cypress test code cannot write
121
+ files, so there the option is declared in the browser and honoured by the Node plugin — same
122
+ option, same behaviour, because a runner that silently ignored it would be worse than one
123
+ that lacked it.
121
124
 
122
125
  ## Why the artefact matters more than the assertion
123
126
 
@@ -18,7 +18,7 @@ node scripts/ultra11y.mjs dev --standard rgaa # the side-car (loopback only
18
18
  |---|---|
19
19
  | **Audit this page** | collects the page with the engine's own collector, posts it to the side-car, which persists it as a snapshot and audits it — the findings come back and are listed |
20
20
  | **Adjudicate with AI** | asks the side-car to rule on the judgment criteria (see `judgment.md`) |
21
- | **Page-by-page grid** | opens the dashboard, where every page you audited accumulates |
21
+ | **Page-by-page grid** | opens the dashboard, where every page you audited accumulates — on the port the server actually answered on, not a default |
22
22
 
23
23
  Because the page is persisted as an ordinary snapshot, everything else already works on it:
24
24
  `ultra11y audit` re-audits it offline, `pages --format report` writes its dossier, and it
@@ -149,5 +149,10 @@ gate refuses:
149
149
  A rejected adjudication leaves `audit-latest.json` untouched.
150
150
 
151
151
  **Strictly opt-in.** With no key the command explains itself and exits 2; nothing else in the
152
- engine changes. `--max` bounds the spend and says out loud which criteria it did not submit —
153
- and a bounded run then fails the coverage gate, which is the correct outcome.
152
+ engine changes.
153
+
154
+ `--max` bounds the spend and says out loud which criteria it did not submit. It is **refused
155
+ together with `--apply`**, before any call is made: the gate rejects an incomplete
156
+ adjudication by design, so the pair could only ever bill you for a guaranteed failure. Drop
157
+ `--max` to adjudicate everything, or drop `--apply` to produce the worklist now and fold it
158
+ in later.
@@ -6,7 +6,7 @@ import { join as join45, relative as relative5, sep as sep7, dirname as dirname1
6
6
  import { fileURLToPath as fileURLToPath5, pathToFileURL as pathToFileURL3 } from "url";
7
7
 
8
8
  // src/types.ts
9
- var VERSION = "2.31.0";
9
+ var VERSION = "2.31.2";
10
10
  var SCHEMA_VERSION = 2;
11
11
 
12
12
  // src/audit.ts
@@ -55414,7 +55414,15 @@ function startDevServer(opts) {
55414
55414
  }
55415
55415
  if (url.pathname === "/health") {
55416
55416
  res.writeHead(200, { "content-type": "application/json", "cache-control": "no-store" }).end(
55417
- JSON.stringify({ ok: true, tool: "ultra11y", version: VERSION, standard: opts.standard, lang: opts.lang, pages: readSnapshots(opts.root).length })
55417
+ JSON.stringify({
55418
+ ok: true,
55419
+ tool: "ultra11y",
55420
+ version: VERSION,
55421
+ port: opts.port,
55422
+ standard: opts.standard,
55423
+ lang: opts.lang,
55424
+ pages: readSnapshots(opts.root).length
55425
+ })
55418
55426
  );
55419
55427
  return;
55420
55428
  }
@@ -56150,6 +56158,7 @@ function noop() {
56150
56158
  }
56151
56159
 
56152
56160
  // src/mcp/handlers.ts
56161
+ var CAPTURES_DIR = ".ultra11y/captures";
56153
56162
  var ToolError = class extends Error {
56154
56163
  };
56155
56164
  var MAX_READ_LINES = 2e3;
@@ -56220,6 +56229,8 @@ async function dispatch(name2, args2, cwd) {
56220
56229
  return handlePackCheck(args2, cwd);
56221
56230
  case "ultra11y_sample_check":
56222
56231
  return handleSampleCheck(cwd);
56232
+ case "ultra11y_pages":
56233
+ return handlePages(args2, cwd);
56223
56234
  case "ultra11y_read":
56224
56235
  return handleRead(args2, cwd);
56225
56236
  case "ultra11y_fix":
@@ -56240,8 +56251,11 @@ function audit(args2, cwd) {
56240
56251
  const warnings = [];
56241
56252
  try {
56242
56253
  process.chdir(cwd);
56243
- return runAudit({
56244
- inputs: strArray2(args2.globs) ?? DEFAULT_GLOBS,
56254
+ const inputs = strArray2(args2.globs) ?? DEFAULT_GLOBS;
56255
+ const scopedToDiff = bool(args2.changed) || bool(args2.staged) || str2(args2.since) !== void 0;
56256
+ const extra = [CAPTURES_DIR, PAGES_DIR].filter((d) => !scopedToDiff && existsSync29(d) && !inputs.includes(d));
56257
+ const result = runAudit({
56258
+ inputs: [...inputs, ...extra],
56245
56259
  include: strArray2(args2.include),
56246
56260
  exclude: strArray2(args2.exclude),
56247
56261
  forceJsx: bool(args2.jsx),
@@ -56252,6 +56266,14 @@ function audit(args2, cwd) {
56252
56266
  maxFiles: positive(args2.max_files, "max_files"),
56253
56267
  onWarn: (m) => warnings.push(m)
56254
56268
  });
56269
+ if (extra.includes(PAGES_DIR)) {
56270
+ const scope = pageScopesFrom(readSnapshots("."));
56271
+ if (scope.length) {
56272
+ result.scope.pages = scope;
56273
+ attributePages(result, scope);
56274
+ }
56275
+ }
56276
+ return result;
56255
56277
  } finally {
56256
56278
  process.chdir(prev);
56257
56279
  }
@@ -56337,6 +56359,30 @@ function handleSampleCheck(cwd) {
56337
56359
  }
56338
56360
  return { cwd, config: raw, next: "Check the sample covers every page kind the methodology requires, not just the convenient ones." };
56339
56361
  }
56362
+ function handlePages(args2, cwd) {
56363
+ const standard = standardOf(args2);
56364
+ const lang = langOf(args2);
56365
+ const r = audit(args2, cwd);
56366
+ const scope = pagesOf(r);
56367
+ if (!scope.length) {
56368
+ throw new ToolError(
56369
+ "no page in scope: this audit covers no snapshot (.ultra11y/pages) and no scanned sample. Capture pages with the E2E plugins, `ultra11y dev`, or `scan` (which snapshots every page it visits), then re-run."
56370
+ );
56371
+ }
56372
+ attributePages(r, scope);
56373
+ const derived = derivePages(r, scope);
56374
+ const format = typeof args2.format === "string" ? args2.format : "grid";
56375
+ const markdown = format === "report" ? renderPagesDocument(r, derived, { standard, lang }) : renderPageGrid(r, scope, standard, lang);
56376
+ return {
56377
+ cwd,
56378
+ standard,
56379
+ lang,
56380
+ markdown,
56381
+ pages: derived.map((p) => ({ id: p.id, name: p.name, url: p.url, basis: p.basis, conformancePct: p.conformancePct, findings: p.findings.length })),
56382
+ unattributed: unattributedFindings(r).length,
56383
+ next: 'A page whose basis is "attributed" has no snapshot: absence of a finding there is NOT conformity. Its undecided criteria stay yours to adjudicate.'
56384
+ };
56385
+ }
56340
56386
  function handleFix(args2, cwd) {
56341
56387
  const prev = process.cwd();
56342
56388
  try {
@@ -56667,6 +56713,26 @@ var TOOLS2 = [
56667
56713
  description: "Validate a project's `.ultra11yrc.json` page sample against the methodology: a conformance claim covering only the pages that happened to be easy is not a conformance claim. Reads the config; changes nothing.",
56668
56714
  inputSchema: { type: "object", properties: { cwd: cwdProp }, required: ["cwd"] }
56669
56715
  },
56716
+ {
56717
+ name: "ultra11y_pages",
56718
+ title: "The per-page view of the audit",
56719
+ description: "RGAA \u2014 like every country standard \u2014 is a PER-PAGE norm, but the engine's verdict is scope-wide. This projects the audit onto the pages it covers: the criterion \xD7 page grid, or the per-page report (one dossier per page, every criterion of the standard with its status on THAT page). Two rules make it trustworthy: a finding belongs to a page only when something says so \u2014 never spread across pages \u2014 and a criterion is conforming by silence only on a page whose real rendered DOM was audited, and only for the criteria the engine can actually decide. Everything else stays \xAB to assess \xBB, which is what it is. Pages come from snapshots (.ultra11y/pages) or a scanned sample; with neither, there is nothing to project.",
56720
+ inputSchema: {
56721
+ type: "object",
56722
+ properties: {
56723
+ cwd: cwdProp,
56724
+ globs: globsProp,
56725
+ standard: standardProp,
56726
+ lang: langProp,
56727
+ format: {
56728
+ type: "string",
56729
+ enum: ["grid", "report"],
56730
+ description: "`grid` (default) is the criterion \xD7 page matrix; `report` is the per-page dossiers."
56731
+ }
56732
+ },
56733
+ required: ["cwd"]
56734
+ }
56735
+ },
56670
56736
  {
56671
56737
  name: "ultra11y_read",
56672
56738
  title: "Read a file from the project",
@@ -56754,6 +56820,7 @@ var TOOL_META2 = {
56754
56820
  ultra11y_adjudicate: { openWorld: false },
56755
56821
  ultra11y_pack_check: { openWorld: false },
56756
56822
  ultra11y_sample_check: { openWorld: false },
56823
+ ultra11y_pages: { openWorld: false },
56757
56824
  ultra11y_read: { openWorld: false },
56758
56825
  // Rewrites source files. Not destructive in the delete sense, and it verifies
56759
56826
  // its own work — but it is the user's code.
@@ -59052,6 +59119,14 @@ async function cmdJudge(p) {
59052
59119
  }
59053
59120
  const max = typeof p.flags.max === "string" ? Number(p.flags.max) : void 0;
59054
59121
  const truncated = max !== void 0 && Number.isFinite(max) && max > 0 && items.length > max;
59122
+ if (truncated && p.flags.apply === true) {
59123
+ console.error(
59124
+ lang === "fr" ? `ultra11y judge : --max ${max} ne couvre que ${max} des ${items.length} crit\xE8res, et --apply exige une adjudication COMPL\xC8TE (le gate refuse une couverture partielle).
59125
+ Retirez --max pour tout adjuger, ou retirez --apply pour produire la worklist et l'appliquer plus tard.` : `ultra11y judge: --max ${max} covers only ${max} of ${items.length} criteria, and --apply requires a COMPLETE adjudication (the gate refuses partial coverage).
59126
+ Drop --max to adjudicate everything, or drop --apply to produce the worklist and apply it later.`
59127
+ );
59128
+ return 2;
59129
+ }
59055
59130
  if (truncated) items = items.slice(0, max);
59056
59131
  const batches = [];
59057
59132
  for (let i2 = 0; i2 < items.length; i2 += BATCH_SIZE) {
package/src/cli.ts CHANGED
@@ -2124,6 +2124,19 @@ async function cmdJudge(p: ParsedArgs): Promise<number> {
2124
2124
  }
2125
2125
  const max = typeof p.flags.max === "string" ? Number(p.flags.max) : undefined;
2126
2126
  const truncated = max !== undefined && Number.isFinite(max) && max > 0 && items.length > max;
2127
+ // `--max` bounds the spend; `--apply` folds the verdicts through a gate that REFUSES an
2128
+ // incomplete adjudication. Together they can never succeed — so say so before spending
2129
+ // anything, instead of billing a full run and failing on coverage at the end.
2130
+ if (truncated && p.flags.apply === true) {
2131
+ console.error(
2132
+ lang === "fr"
2133
+ ? `ultra11y judge : --max ${max} ne couvre que ${max} des ${items.length} critères, et --apply exige une adjudication COMPLÈTE (le gate refuse une couverture partielle).\n` +
2134
+ " Retirez --max pour tout adjuger, ou retirez --apply pour produire la worklist et l'appliquer plus tard."
2135
+ : `ultra11y judge: --max ${max} covers only ${max} of ${items.length} criteria, and --apply requires a COMPLETE adjudication (the gate refuses partial coverage).\n` +
2136
+ " Drop --max to adjudicate everything, or drop --apply to produce the worklist and apply it later.",
2137
+ );
2138
+ return 2;
2139
+ }
2127
2140
  if (truncated) items = items.slice(0, max);
2128
2141
 
2129
2142
  // Batch, and render EACH batch through the very worklist formatter the agent reads. There
package/src/dev.ts CHANGED
@@ -424,11 +424,17 @@ export function startDevServer(opts: DevOptions): Promise<DevServer> {
424
424
  }
425
425
  // A liveness probe, so a client can say "run `ultra11y dev`" instead of failing silently.
426
426
  if (url.pathname === "/health") {
427
- res
428
- .writeHead(200, { "content-type": "application/json", "cache-control": "no-store" })
429
- .end(
430
- JSON.stringify({ ok: true, tool: "ultra11y", version: VERSION, standard: opts.standard, lang: opts.lang, pages: readSnapshots(opts.root).length }),
431
- );
427
+ res.writeHead(200, { "content-type": "application/json", "cache-control": "no-store" }).end(
428
+ JSON.stringify({
429
+ ok: true,
430
+ tool: "ultra11y",
431
+ version: VERSION,
432
+ port: opts.port,
433
+ standard: opts.standard,
434
+ lang: opts.lang,
435
+ pages: readSnapshots(opts.root).length,
436
+ }),
437
+ );
432
438
  return;
433
439
  }
434
440
  if (url.pathname === "/snapshot" && req.method === "POST") {
@@ -15,7 +15,7 @@
15
15
  // If that event never fires, the page is recorded without a screenshot — the criterion stays
16
16
  // undecided, exactly as before, and is never guessed.
17
17
  import { readFileSync } from "node:fs";
18
- import { type AuditLike, type FindingLike, type SnapshotPayload, auditSnapshot, failingFindings, formatFailure } from "./core.js";
18
+ import { type AuditLike, type FindingLike, type SnapshotPayload, auditSnapshot, failingFindings, formatFailure, writePagesReport } from "./core.js";
19
19
 
20
20
  interface ScreenshotDetails {
21
21
  name?: string;
@@ -29,6 +29,8 @@ export interface TaskPayload extends SnapshotPayload {
29
29
  failOn?: string | false;
30
30
  /** The name `cy.screenshot()` was called with, when the browser half took one. */
31
31
  screenshotName?: string;
32
+ /** Forwarded from `cy.ultra11y({ report })` — the browser cannot write files, this side can. */
33
+ report?: boolean | { out?: string; standard?: string; lang?: string };
32
34
  }
33
35
 
34
36
  export interface TaskResult {
@@ -61,6 +63,7 @@ export default function register(on: On): void {
61
63
  }
62
64
  }
63
65
  const result: AuditLike = auditSnapshot(withShot);
66
+ if (payload.report) writePagesReport(typeof payload.report === "object" ? payload.report : {});
64
67
  const failOn = payload.failOn === undefined ? "blocking" : payload.failOn;
65
68
  const failing = failOn === false ? [] : failingFindings(result, failOn);
66
69
  // Return rather than throw: the browser half raises the assertion, so the failure is
@@ -24,6 +24,13 @@ export interface CypressCheckOptions extends CheckOptions {
24
24
  /** Capture a viewport screenshot so the pixel tier can run. On by default — the plugin
25
25
  * reads it back through Cypress's `after:screenshot` event. */
26
26
  screenshot?: boolean;
27
+ /** Also write the per-page report once this page is recorded. Off by default: a report per
28
+ * checked page would be wasteful in a suite, so turn it on in a final test.
29
+ *
30
+ * Cypress test code runs in the browser and cannot write files, so the option is only
31
+ * DECLARED here and forwarded — the Node half does the work. Same option, same behaviour
32
+ * as Playwright's; a runner that silently ignored it would be worse than one that lacked it. */
33
+ report?: boolean | { out?: string; standard?: string; lang?: string };
27
34
  }
28
35
 
29
36
  export function registerUltra11yCommand(): void {
@@ -41,6 +48,7 @@ export function registerUltra11yCommand(): void {
41
48
  const payload = {
42
49
  ...buildPayload(collected, url, "cypress", opts),
43
50
  failOn: opts.failOn,
51
+ ...(opts.report ? { report: opts.report } : {}),
44
52
  ...(shotName ? { screenshotName: shotName } : {}),
45
53
  };
46
54
  return cy.task("ultra11ySnapshot", payload, { log: false }).then((res: { failing?: unknown[]; message?: string }) => {
@@ -12,6 +12,13 @@ import { renderReport, renderPackReport } from "../report.js";
12
12
  import { buildWorklist, formatWorklist } from "../verify.js";
13
13
  import { allSC, getSC } from "../wcag.js";
14
14
  import { loadPack } from "../standards/index.js";
15
+ import { attributePages, derivePages, pageScopesFrom, pagesOf, renderPageGrid, unattributedFindings } from "../pages.js";
16
+ import { PAGES_DIR, readSnapshots } from "../snapshot.js";
17
+
18
+ // The CLI's default capture directory (`--captures` overrides it there; the MCP surface has
19
+ // no such flag, so the default is the contract).
20
+ const CAPTURES_DIR = ".ultra11y/captures";
21
+ import { renderPagesDocument } from "../pages-report.js";
15
22
  import { withProjectLock } from "../project-lock.js";
16
23
  import type { AuditResult, Lang } from "../types.js";
17
24
  import type { StandardId } from "../standards/index.js";
@@ -132,6 +139,8 @@ async function dispatch(name: string, args: Record<string, unknown>, cwd: string
132
139
  return handlePackCheck(args, cwd);
133
140
  case "ultra11y_sample_check":
134
141
  return handleSampleCheck(cwd);
142
+ case "ultra11y_pages":
143
+ return handlePages(args, cwd);
135
144
  case "ultra11y_read":
136
145
  return handleRead(args, cwd);
137
146
  case "ultra11y_fix":
@@ -161,8 +170,18 @@ function audit(args: Record<string, unknown>, cwd: string): AuditResult {
161
170
  const warnings: string[] = [];
162
171
  try {
163
172
  process.chdir(cwd);
164
- return runAudit({
165
- inputs: strArray(args.globs) ?? DEFAULT_GLOBS,
173
+ const inputs = strArray(args.globs) ?? DEFAULT_GLOBS;
174
+ const scopedToDiff = bool(args.changed) || bool(args.staged) || str(args.since) !== undefined;
175
+
176
+ // RENDERED ARTEFACTS. `cmdAudit` appends `.ultra11y/captures` and `.ultra11y/pages` to the
177
+ // inputs and records the page scope; this helper did not, so every MCP tool audited LESS
178
+ // than the same command on the CLI: no rendered tier, none of the page-scoped rules
179
+ // (lang, title, main landmark), and no page dimension at all. An agent driving the server
180
+ // was quietly getting a smaller audit than the one it would have run itself.
181
+ const extra = [CAPTURES_DIR, PAGES_DIR].filter((d) => !scopedToDiff && existsSync(d) && !inputs.includes(d));
182
+
183
+ const result = runAudit({
184
+ inputs: [...inputs, ...extra],
166
185
  include: strArray(args.include),
167
186
  exclude: strArray(args.exclude),
168
187
  forceJsx: bool(args.jsx),
@@ -173,6 +192,17 @@ function audit(args: Record<string, unknown>, cwd: string): AuditResult {
173
192
  maxFiles: positive(args.max_files, "max_files"),
174
193
  onWarn: (m) => warnings.push(m),
175
194
  });
195
+
196
+ // Record the pages in scope so the projection can rebuild from this result alone — the
197
+ // same step cmdAudit takes right after runAudit.
198
+ if (extra.includes(PAGES_DIR)) {
199
+ const scope = pageScopesFrom(readSnapshots("."));
200
+ if (scope.length) {
201
+ result.scope.pages = scope;
202
+ attributePages(result, scope);
203
+ }
204
+ }
205
+ return result;
176
206
  } finally {
177
207
  process.chdir(prev);
178
208
  }
@@ -271,6 +301,34 @@ function handleSampleCheck(cwd: string): unknown {
271
301
  return { cwd, config: raw, next: "Check the sample covers every page kind the methodology requires, not just the convenient ones." };
272
302
  }
273
303
 
304
+ /** The page dimension. Everything it needs is already on the AuditResult, so it re-measures
305
+ * nothing — the same projection the report and the CLI use. */
306
+ function handlePages(args: Record<string, unknown>, cwd: string): unknown {
307
+ const standard = standardOf(args);
308
+ const lang = langOf(args);
309
+ const r = audit(args, cwd);
310
+ const scope = pagesOf(r);
311
+ if (!scope.length) {
312
+ throw new ToolError(
313
+ "no page in scope: this audit covers no snapshot (.ultra11y/pages) and no scanned sample. " +
314
+ "Capture pages with the E2E plugins, `ultra11y dev`, or `scan` (which snapshots every page it visits), then re-run.",
315
+ );
316
+ }
317
+ attributePages(r, scope);
318
+ const derived = derivePages(r, scope);
319
+ const format = typeof args.format === "string" ? args.format : "grid";
320
+ const markdown = format === "report" ? renderPagesDocument(r, derived, { standard, lang }) : renderPageGrid(r, scope, standard, lang);
321
+ return {
322
+ cwd,
323
+ standard,
324
+ lang,
325
+ markdown,
326
+ pages: derived.map((p) => ({ id: p.id, name: p.name, url: p.url, basis: p.basis, conformancePct: p.conformancePct, findings: p.findings.length })),
327
+ unattributed: unattributedFindings(r).length,
328
+ next: 'A page whose basis is "attributed" has no snapshot: absence of a finding there is NOT conformity. Its undecided criteria stay yours to adjudicate.',
329
+ };
330
+ }
331
+
274
332
  function handleFix(args: Record<string, unknown>, cwd: string): unknown {
275
333
  const prev = process.cwd();
276
334
  try {
package/src/mcp/tools.ts CHANGED
@@ -178,6 +178,31 @@ export const TOOLS: ToolDecl[] = [
178
178
  "is not a conformance claim. Reads the config; changes nothing.",
179
179
  inputSchema: { type: "object", properties: { cwd: cwdProp }, required: ["cwd"] },
180
180
  },
181
+ {
182
+ name: "ultra11y_pages",
183
+ title: "The per-page view of the audit",
184
+ description:
185
+ "RGAA — like every country standard — is a PER-PAGE norm, but the engine's verdict is scope-wide. This projects the audit onto the pages it covers: " +
186
+ "the criterion × page grid, or the per-page report (one dossier per page, every criterion of the standard with its status on THAT page). " +
187
+ "Two rules make it trustworthy: a finding belongs to a page only when something says so — never spread across pages — and a criterion is conforming " +
188
+ "by silence only on a page whose real rendered DOM was audited, and only for the criteria the engine can actually decide. Everything else stays " +
189
+ "« to assess », which is what it is. Pages come from snapshots (.ultra11y/pages) or a scanned sample; with neither, there is nothing to project.",
190
+ inputSchema: {
191
+ type: "object",
192
+ properties: {
193
+ cwd: cwdProp,
194
+ globs: globsProp,
195
+ standard: standardProp,
196
+ lang: langProp,
197
+ format: {
198
+ type: "string",
199
+ enum: ["grid", "report"],
200
+ description: "`grid` (default) is the criterion × page matrix; `report` is the per-page dossiers.",
201
+ },
202
+ },
203
+ required: ["cwd"],
204
+ },
205
+ },
181
206
  {
182
207
  name: "ultra11y_read",
183
208
  title: "Read a file from the project",
@@ -283,6 +308,7 @@ export const TOOL_META: Record<string, { write?: boolean; destructive?: boolean;
283
308
  ultra11y_adjudicate: { openWorld: false },
284
309
  ultra11y_pack_check: { openWorld: false },
285
310
  ultra11y_sample_check: { openWorld: false },
311
+ ultra11y_pages: { openWorld: false },
286
312
  ultra11y_read: { openWorld: false },
287
313
  // Rewrites source files. Not destructive in the delete sense, and it verifies
288
314
  // its own work — but it is the user's code.
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 = "2.31.0";
3
+ export const VERSION = "2.31.2";
4
4
  export const SCHEMA_VERSION = 2;
5
5
 
6
6
  // Lang = the UI FRAME's language — the `L` localization tables each module (report,