ultra11y 5.0.0 → 5.1.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.0.0",
13
+ "version": "5.1.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.0.0",
4
+ "version": "5.1.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.0.0",
4
+ "version": "5.1.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.0.0";
30
+ const ULTRA11Y_PLUGIN_VERSION = "5.1.0";
31
31
 
32
32
  const here = dirname(fileURLToPath(import.meta.url));
33
33
 
package/README.md CHANGED
@@ -125,7 +125,7 @@ claude mcp add ultra11y -- npx -y ultra11y mcp
125
125
  **As a GitHub Action** — audits the PR diff and, optionally, the served pages:
126
126
 
127
127
  ```yaml
128
- - uses: maxgfr/ultra11y@v4
128
+ - uses: maxgfr/ultra11y@v5
129
129
  with: { since: auto, standard: rgaa, fail-on: blocking }
130
130
  ```
131
131
 
@@ -383,7 +383,7 @@ a test). See [`CONTRIBUTING.md`](CONTRIBUTING.md) and `skills/ultra11y/reference
383
383
  block. It re-decides nothing and invents no format, so a page sheet and the compliance report
384
384
  cannot disagree; `check` gates it against invented criteria like any other report.
385
385
  See `references/pages.md`.
386
- - **A shipped GitHub Action** — `uses: maxgfr/ultra11y@v4` audits the **code** (PR diff) and,
386
+ - **A shipped GitHub Action** — `uses: maxgfr/ultra11y@v5` audits the **code** (PR diff) and,
387
387
  optionally, the **pages** (it can start your app, wait for it, then scan real URLs or your
388
388
  declared sample). The engine ships inside the action, so there is nothing to install and no
389
389
  `setup-node`. The gate runs **last**, after SARIF, annotations, the summary, the sticky PR
@@ -1,4 +1,4 @@
1
- import { S as SnapshotPayload, F as FindingLike } from './payload-D0VQYlGS.js';
1
+ import { S as SnapshotPayload, F as FindingLike } from './payload-B6NDkLYJ.js';
2
2
 
3
3
  type On = (event: string, handler: any) => void;
4
4
  interface TaskPayload extends SnapshotPayload {
package/dist/cypress.d.ts CHANGED
@@ -1,5 +1,5 @@
1
- import { C as CheckOptions } from './payload-D0VQYlGS.js';
2
- export { s as slugify } from './payload-D0VQYlGS.js';
1
+ import { C as CheckOptions } from './payload-B6NDkLYJ.js';
2
+ export { s as slugify } from './payload-B6NDkLYJ.js';
3
3
 
4
4
  interface CypressCheckOptions extends CheckOptions {
5
5
  /** Capture a viewport screenshot so the pixel tier can run. On by default — the plugin
package/dist/cypress.mjs CHANGED
@@ -120,6 +120,20 @@ function slugify(url) {
120
120
  const slug = path.normalize("NFD").replace(new RegExp("\\p{Diacritic}", "gu"), "").toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-+|-+$/g, "");
121
121
  return slug || (path === "/" || path === "" ? "accueil" : "page");
122
122
  }
123
+ function stayedOnPage(expected, actual) {
124
+ if (!expected || !actual) return true;
125
+ const path = (u) => {
126
+ try {
127
+ return new URL(u, "http://x.invalid").pathname.replace(/\/+$/, "");
128
+ } catch {
129
+ return void 0;
130
+ }
131
+ };
132
+ const a = path(expected);
133
+ const b = path(actual);
134
+ if (a === void 0 || b === void 0) return true;
135
+ return a === b;
136
+ }
123
137
  function buildPayload(collected, url, runner, opts, screenshot) {
124
138
  const id = opts.as || slugify(url);
125
139
  return {
@@ -151,6 +165,11 @@ function registerUltra11yCommand() {
151
165
  return cy.window({ log: false }).then((win) => {
152
166
  const collected = win.eval(COLLECT_SNAPSHOT);
153
167
  const url = collected.url || win.location.href;
168
+ if (opts.expectPath && !stayedOnPage(opts.expectPath, url)) {
169
+ throw new Error(
170
+ `ultra11y: ${opts.expectPath} landed on ${url} \u2014 not recording it as "${opts.as ?? opts.name ?? "this page"}". The state that opens this route is not the one the test built; seed it first, or drop the page from the sample.`
171
+ );
172
+ }
154
173
  const payload = {
155
174
  ...buildPayload(collected, url, "cypress", opts),
156
175
  failOn: opts.failOn,
@@ -0,0 +1,51 @@
1
+ interface FindingLike {
2
+ ruleId: string;
3
+ criteriaId: string;
4
+ file: string;
5
+ line: number;
6
+ severity: string;
7
+ message: string;
8
+ advisory?: boolean;
9
+ origin?: {
10
+ sourceFile?: string;
11
+ sourceLine?: number;
12
+ };
13
+ }
14
+ interface AuditLike {
15
+ findings?: FindingLike[];
16
+ }
17
+ interface SnapshotPayload {
18
+ meta: Record<string, unknown>;
19
+ dom: string;
20
+ styles?: unknown;
21
+ boxes?: unknown;
22
+ css?: unknown;
23
+ screenshot?: string;
24
+ }
25
+ /** Same slug rule as the engine (src/snapshot.ts slugifyPageId): accent-folded URL path. */
26
+ declare function slugify(url: string): string;
27
+ interface CheckOptions {
28
+ /** The page id (directory name). Defaults to the slugified URL path. */
29
+ as?: string;
30
+ /** Human page name for the report. Defaults to the document title. */
31
+ name?: string;
32
+ /** Severity at which the check fails, or `false` to record without ever failing. */
33
+ failOn?: "blocking" | "major" | "minor" | false;
34
+ /** Mark the page as sitting behind authentication. */
35
+ auth?: boolean;
36
+ /** Source files that rendered it — findings are then reported against YOUR code. */
37
+ sources?: string[];
38
+ /** Reproduction notes carried into the auditor ticket. */
39
+ notes?: string;
40
+ /** Capture a viewport screenshot for the pixel tier (Playwright: on by default). */
41
+ screenshot?: boolean;
42
+ /** The path you NAVIGATED to. Given it, the fixture refuses to record a page the browser
43
+ * did not stay on — a guarded route that redirected, a session that expired — instead of
44
+ * filing that other screen under this page's `as`/`name`. Nothing about such a snapshot
45
+ * looks wrong, which is what makes it the worst thing an accessibility report can carry.
46
+ * Compared by path (a query or fragment the app appends to its own route is the same
47
+ * page); a full URL works too. `scan --sample` applies the same rule. */
48
+ expectPath?: string;
49
+ }
50
+
51
+ export { type AuditLike as A, type CheckOptions as C, type FindingLike as F, type SnapshotPayload as S, slugify as s };
@@ -1,4 +1,4 @@
1
- import { C as CheckOptions, A as AuditLike } from './payload-D0VQYlGS.js';
1
+ import { C as CheckOptions, A as AuditLike } from './payload-B6NDkLYJ.js';
2
2
 
3
3
  interface PlaywrightPage {
4
4
  evaluate(script: string): Promise<unknown>;
@@ -143,6 +143,20 @@ function slugify(url) {
143
143
  const slug = path.normalize("NFD").replace(new RegExp("\\p{Diacritic}", "gu"), "").toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-+|-+$/g, "");
144
144
  return slug || (path === "/" || path === "" ? "accueil" : "page");
145
145
  }
146
+ function stayedOnPage(expected, actual) {
147
+ if (!expected || !actual) return true;
148
+ const path = (u) => {
149
+ try {
150
+ return new URL(u, "http://x.invalid").pathname.replace(/\/+$/, "");
151
+ } catch {
152
+ return void 0;
153
+ }
154
+ };
155
+ const a = path(expected);
156
+ const b = path(actual);
157
+ if (a === void 0 || b === void 0) return true;
158
+ return a === b;
159
+ }
146
160
  function buildPayload(collected, url, runner, opts, screenshot) {
147
161
  const id = opts.as || slugify(url);
148
162
  return {
@@ -221,6 +235,11 @@ async function checkA11y(page, opts = {}) {
221
235
  }
222
236
  }
223
237
  const url = collected.url || page.url();
238
+ if (opts.expectPath && !stayedOnPage(opts.expectPath, url)) {
239
+ throw new Error(
240
+ `ultra11y: ${opts.expectPath} landed on ${url} \u2014 not recording it as "${opts.as ?? opts.name ?? "this page"}". The state that opens this route is not the one the test built; seed it first, or drop the page from the sample.`
241
+ );
242
+ }
224
243
  const payload = buildPayload(collected, url, "playwright", opts, shot);
225
244
  const result = auditSnapshot(payload);
226
245
  if (opts.report) writePagesReport(typeof opts.report === "object" ? opts.report : {});
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "ultra11y",
3
- "version": "5.0.0",
3
+ "version": "5.1.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 join50, relative as relative5, resolve as resolve15, sep as sep
6
6
  import { fileURLToPath as fileURLToPath5, pathToFileURL as pathToFileURL3 } from "url";
7
7
 
8
8
  // src/types.ts
9
- var VERSION = "5.0.0";
9
+ var VERSION = "5.1.0";
10
10
  var SCHEMA_VERSION = 2;
11
11
 
12
12
  // src/audit.ts
@@ -61923,6 +61923,20 @@ var ADJUDICATE_SCHEMA = {
61923
61923
  criteriaId: { type: "string" },
61924
61924
  verdict: { enum: ["C", "NC", "NA", "manual"] },
61925
61925
  justification: { type: "string", description: "REQUIRED for C and NA" },
61926
+ // REQUIRED by the fold alongside `justification`, for exactly the same reason
61927
+ // `normativeRef` is required on an NC: a cleared criterion has to name what it
61928
+ // cleared. Omitting it here (and from the adjudicator contract) made every
61929
+ // adjudication produce C/NA verdicts that `verify --apply` then refused wholesale —
61930
+ // 63 rejections out of 97 verdicts on the run that surfaced this.
61931
+ citations: {
61932
+ type: "array",
61933
+ description: "REQUIRED for C, and for NA when evidence was presented: the evidence items you cleared, `file`/`line` copied verbatim from this criterion's own evidence[]",
61934
+ items: {
61935
+ type: "object",
61936
+ required: ["file", "line"],
61937
+ properties: { file: { type: "string" }, line: { type: "number" }, selector: { type: "string" }, snippet: { type: "string" } }
61938
+ }
61939
+ },
61926
61940
  reason: { type: ["string", "null"], enum: ["needs-rendered-dom", "undecidable", null], description: "REQUIRED for a still-manual verdict" },
61927
61941
  findings: {
61928
61942
  type: "array",
@@ -62048,13 +62062,13 @@ For EACH of your criteria:
62048
62062
 
62049
62063
  1. Read its worklist entry. \`evidence[]\` holds source-anchored excerpts (\`file\`, \`line\`, \`selector\`, \`snippet\`) harvested from the audited code \u2014 open the cited files at the cited lines whenever the snippet alone cannot decide.
62050
62064
  2. Rule it (the apply gate is FAIL-CLOSED \u2014 a verdict missing its required field does not fold):
62051
- - \`C\` (conforming) \u2014 REQUIRES \`justification\` explaining why the evidence satisfies the criterion.
62065
+ - \`C\` (conforming) \u2014 REQUIRES \`justification\` explaining why the evidence satisfies the criterion, AND \`citations[]\` naming the evidence you cleared (\`file\`/\`line\` copied VERBATIM from this item's own \`evidence[]\`; an anchor that is not in that list is treated as fabricated). A criterion whose \`evidence[]\` is empty cannot be \`C\` at all \u2014 it is \`manual\` (\`undecidable\`), or \`NA\` if nothing in scope is concerned.
62052
62066
  - \`NC\` (non-conforming) \u2014 REQUIRES \`findings\`: at least one groundable \`{ file, line, selector?, message, snippet?, severity?, normativeRef }\` pointing at REAL source. The fold re-grounds every finding; an invented file:line is rejected. \`normativeRef\` MUST cite the precise failed test \u2014 under a country standard, one of the item's OWN tests, which the worklist lists for you under \xAB tests to rule on \xBB.
62053
- - \`NA\` (not applicable) \u2014 REQUIRES \`justification\`.
62067
+ - \`NA\` (not applicable) \u2014 REQUIRES \`justification\`, AND \`citations[]\` whenever evidence WAS presented, to say which of those items fall outside the criterion's scope.
62054
62068
  - \`manual\` (still undecidable) \u2014 REQUIRES \`reason\`: \`needs-rendered-dom\` (only a rendered DOM can decide, e.g. computed contrast) or \`undecidable\` (the evidence cannot settle it either way).
62055
62069
  3. Never guess. A criterion you cannot decide from real evidence stays \`manual\` with a reason \u2014 that is a valid, honest verdict; the scan tier or a human picks it up.
62056
62070
 
62057
- Return (structured output): \`{ "verdicts": [{ "criteriaId", "verdict", "justification", "reason", "findings" }] }\` \u2014 your ITEMS only, every field grounded in what you actually read, every NC finding carrying its \`normativeRef\`.
62071
+ Return (structured output): \`{ "verdicts": [{ "criteriaId", "verdict", "justification", "citations", "reason", "findings" }] }\` \u2014 your ITEMS only, every field grounded in what you actually read, every NC finding carrying its \`normativeRef\`, and every C (plus every evidenced NA) carrying its \`citations\`. A verdict that clears a criterion without naming what it cleared is refused by the fold, and one refusal discards the WHOLE adjudication \u2014 including every other verdict you got right.
62058
62072
  ${footer}`,
62059
62073
  refuter: `# Contract: refuter
62060
62074
 
@@ -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.0.0
8
+ version: 5.1.0
9
9
  ---
10
10
 
11
11
  # review-a11y — accessibility review of changed code
@@ -6,7 +6,7 @@ import { join as join50, relative as relative5, resolve as resolve15, sep as sep
6
6
  import { fileURLToPath as fileURLToPath5, pathToFileURL as pathToFileURL3 } from "url";
7
7
 
8
8
  // src/types.ts
9
- var VERSION = "5.0.0";
9
+ var VERSION = "5.1.0";
10
10
  var SCHEMA_VERSION = 2;
11
11
 
12
12
  // src/audit.ts
@@ -61923,6 +61923,20 @@ var ADJUDICATE_SCHEMA = {
61923
61923
  criteriaId: { type: "string" },
61924
61924
  verdict: { enum: ["C", "NC", "NA", "manual"] },
61925
61925
  justification: { type: "string", description: "REQUIRED for C and NA" },
61926
+ // REQUIRED by the fold alongside `justification`, for exactly the same reason
61927
+ // `normativeRef` is required on an NC: a cleared criterion has to name what it
61928
+ // cleared. Omitting it here (and from the adjudicator contract) made every
61929
+ // adjudication produce C/NA verdicts that `verify --apply` then refused wholesale —
61930
+ // 63 rejections out of 97 verdicts on the run that surfaced this.
61931
+ citations: {
61932
+ type: "array",
61933
+ description: "REQUIRED for C, and for NA when evidence was presented: the evidence items you cleared, `file`/`line` copied verbatim from this criterion's own evidence[]",
61934
+ items: {
61935
+ type: "object",
61936
+ required: ["file", "line"],
61937
+ properties: { file: { type: "string" }, line: { type: "number" }, selector: { type: "string" }, snippet: { type: "string" } }
61938
+ }
61939
+ },
61926
61940
  reason: { type: ["string", "null"], enum: ["needs-rendered-dom", "undecidable", null], description: "REQUIRED for a still-manual verdict" },
61927
61941
  findings: {
61928
61942
  type: "array",
@@ -62048,13 +62062,13 @@ For EACH of your criteria:
62048
62062
 
62049
62063
  1. Read its worklist entry. \`evidence[]\` holds source-anchored excerpts (\`file\`, \`line\`, \`selector\`, \`snippet\`) harvested from the audited code \u2014 open the cited files at the cited lines whenever the snippet alone cannot decide.
62050
62064
  2. Rule it (the apply gate is FAIL-CLOSED \u2014 a verdict missing its required field does not fold):
62051
- - \`C\` (conforming) \u2014 REQUIRES \`justification\` explaining why the evidence satisfies the criterion.
62065
+ - \`C\` (conforming) \u2014 REQUIRES \`justification\` explaining why the evidence satisfies the criterion, AND \`citations[]\` naming the evidence you cleared (\`file\`/\`line\` copied VERBATIM from this item's own \`evidence[]\`; an anchor that is not in that list is treated as fabricated). A criterion whose \`evidence[]\` is empty cannot be \`C\` at all \u2014 it is \`manual\` (\`undecidable\`), or \`NA\` if nothing in scope is concerned.
62052
62066
  - \`NC\` (non-conforming) \u2014 REQUIRES \`findings\`: at least one groundable \`{ file, line, selector?, message, snippet?, severity?, normativeRef }\` pointing at REAL source. The fold re-grounds every finding; an invented file:line is rejected. \`normativeRef\` MUST cite the precise failed test \u2014 under a country standard, one of the item's OWN tests, which the worklist lists for you under \xAB tests to rule on \xBB.
62053
- - \`NA\` (not applicable) \u2014 REQUIRES \`justification\`.
62067
+ - \`NA\` (not applicable) \u2014 REQUIRES \`justification\`, AND \`citations[]\` whenever evidence WAS presented, to say which of those items fall outside the criterion's scope.
62054
62068
  - \`manual\` (still undecidable) \u2014 REQUIRES \`reason\`: \`needs-rendered-dom\` (only a rendered DOM can decide, e.g. computed contrast) or \`undecidable\` (the evidence cannot settle it either way).
62055
62069
  3. Never guess. A criterion you cannot decide from real evidence stays \`manual\` with a reason \u2014 that is a valid, honest verdict; the scan tier or a human picks it up.
62056
62070
 
62057
- Return (structured output): \`{ "verdicts": [{ "criteriaId", "verdict", "justification", "reason", "findings" }] }\` \u2014 your ITEMS only, every field grounded in what you actually read, every NC finding carrying its \`normativeRef\`.
62071
+ Return (structured output): \`{ "verdicts": [{ "criteriaId", "verdict", "justification", "citations", "reason", "findings" }] }\` \u2014 your ITEMS only, every field grounded in what you actually read, every NC finding carrying its \`normativeRef\`, and every C (plus every evidenced NA) carrying its \`citations\`. A verdict that clears a criterion without naming what it cleared is refused by the fold, and one refusal discards the WHOLE adjudication \u2014 including every other verdict you got right.
62058
62072
  ${footer}`,
62059
62073
  refuter: `# Contract: refuter
62060
62074
 
@@ -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.0.0
7
+ version: 5.1.0
8
8
  ---
9
9
 
10
10
  # ultra11y — audit WCAG 2.2 AA and write accessible markup
@@ -15,7 +15,7 @@ permissions:
15
15
  steps:
16
16
  - uses: actions/checkout@v4
17
17
  with: { fetch-depth: 0 } # the diff gate needs the base ref
18
- - uses: maxgfr/ultra11y@v4 # or pin the exact version, as `init --ci` does
18
+ - uses: maxgfr/ultra11y@v5 # or pin the exact version, as `init --ci` does
19
19
  with:
20
20
  since: auto # the PR's base branch
21
21
  standard: rgaa
@@ -27,7 +27,7 @@ steps:
27
27
  Page by page, with a real browser, in the same step:
28
28
 
29
29
  ```yaml
30
- - uses: maxgfr/ultra11y@v4
30
+ - uses: maxgfr/ultra11y@v5
31
31
  with:
32
32
  standard: rgaa
33
33
  start: npm run start
@@ -37,7 +37,7 @@ Page by page, with a real browser, in the same step:
37
37
  ```
38
38
 
39
39
  `ultra11y init --ci` writes a workflow using it, **pinned to the exact engine version that
40
- generated the file** (`@v<that version>`) so a CI run stays reproducible. `@v<major>` — `@v3`
40
+ generated the file** (`@v<that version>`) so a CI run stays reproducible. `@v<major>` — `@v5`
41
41
  today — is a moving alias the release workflow repoints at each release, for teams who would
42
42
  rather take fixes automatically. It moves **within** a major only: when a breaking change cuts
43
43
  the next one, the old alias freezes where it is, so a pipeline pinned to it keeps working and
@@ -66,7 +66,7 @@ partial by construction. `adjudicate` closes that — opt-in, in two modes.
66
66
  env:
67
67
  ANTHROPIC_API_KEY: ${{ secrets.ANTHROPIC_API_KEY }} # the job's env, never an input
68
68
  steps:
69
- - uses: maxgfr/ultra11y@v4
69
+ - uses: maxgfr/ultra11y@v5
70
70
  with:
71
71
  standard: rgaa
72
72
  adjudicate: api # or `agent`
@@ -273,7 +273,7 @@ The action audits the **code** and, when you point it at a served app, the **pag
273
273
  list can come from three places, and none of them has to be written by hand:
274
274
 
275
275
  ```yaml
276
- - uses: maxgfr/ultra11y@v4
276
+ - uses: maxgfr/ultra11y@v5
277
277
  with:
278
278
  standard: rgaa
279
279
  start: npm run start
@@ -312,7 +312,7 @@ the setup project their E2E suite depends on — and hand the action its path:
312
312
 
313
313
  ```yaml
314
314
  - run: npx playwright test --project=setup # your existing login, writing the session file
315
- - uses: maxgfr/ultra11y@v4
315
+ - uses: maxgfr/ultra11y@v5
316
316
  with:
317
317
  standard: rgaa
318
318
  sample: 'true'
@@ -467,9 +467,9 @@ one job — the code diff, then the served pages — died on a `409 Conflict`, w
467
467
  written and never uploaded. Pass `artifact-name` on each invocation:
468
468
 
469
469
  ```yaml
470
- - uses: maxgfr/ultra11y@v4
470
+ - uses: maxgfr/ultra11y@v5
471
471
  with: { since: auto, artifact-name: a11y-code }
472
- - uses: maxgfr/ultra11y@v4
472
+ - uses: maxgfr/ultra11y@v5
473
473
  with: { crawl: http://localhost:3000, artifact-name: a11y-pages }
474
474
  ```
475
475
 
@@ -33,6 +33,27 @@ than restating them, and a test evaluates the generated `failingFindings` alongs
33
33
  one over the same findings. A fixture that gated differently from the published plugin would
34
34
  fail two projects differently while claiming to be the same tool.
35
35
 
36
+
37
+ ## A page the browser did not stay on (`expectPath`)
38
+
39
+ `as`/`name` is the identity the report speaks, and the fixture applies it to whatever is on
40
+ screen. So a route your test could not open — a guarded step, an expired session — gets filed
41
+ under the requested page's name, and its sheet, screenshot and rate all describe another
42
+ screen. Nothing about the document looks wrong.
43
+
44
+ Pass the path you navigated to and the fixture refuses instead:
45
+
46
+ ```js
47
+ await checkA11y(page, { as: "declaration-etape-4", expectPath: "/declaration/etape/4" });
48
+ // throws: /declaration/etape/4 landed on /declaration/etape/1 — not recording it as
49
+ // "declaration-etape-4". The state that opens this route is not the one the test built.
50
+ ```
51
+
52
+ Opt-in, and path-only: a query or fragment the app appends to its own route is the same page.
53
+ `scan --sample` applies the same rule without being asked, because there the URL *is* the
54
+ instruction. A page reported missing is a bug in your seeding; a page reported under the wrong
55
+ name is a false conformance claim.
56
+
36
57
  ## Playwright
37
58
 
38
59
  ```js
@@ -6,7 +6,7 @@ import { join as join50, relative as relative5, resolve as resolve15, sep as sep
6
6
  import { fileURLToPath as fileURLToPath5, pathToFileURL as pathToFileURL3 } from "url";
7
7
 
8
8
  // src/types.ts
9
- var VERSION = "5.0.0";
9
+ var VERSION = "5.1.0";
10
10
  var SCHEMA_VERSION = 2;
11
11
 
12
12
  // src/audit.ts
@@ -61923,6 +61923,20 @@ var ADJUDICATE_SCHEMA = {
61923
61923
  criteriaId: { type: "string" },
61924
61924
  verdict: { enum: ["C", "NC", "NA", "manual"] },
61925
61925
  justification: { type: "string", description: "REQUIRED for C and NA" },
61926
+ // REQUIRED by the fold alongside `justification`, for exactly the same reason
61927
+ // `normativeRef` is required on an NC: a cleared criterion has to name what it
61928
+ // cleared. Omitting it here (and from the adjudicator contract) made every
61929
+ // adjudication produce C/NA verdicts that `verify --apply` then refused wholesale —
61930
+ // 63 rejections out of 97 verdicts on the run that surfaced this.
61931
+ citations: {
61932
+ type: "array",
61933
+ description: "REQUIRED for C, and for NA when evidence was presented: the evidence items you cleared, `file`/`line` copied verbatim from this criterion's own evidence[]",
61934
+ items: {
61935
+ type: "object",
61936
+ required: ["file", "line"],
61937
+ properties: { file: { type: "string" }, line: { type: "number" }, selector: { type: "string" }, snippet: { type: "string" } }
61938
+ }
61939
+ },
61926
61940
  reason: { type: ["string", "null"], enum: ["needs-rendered-dom", "undecidable", null], description: "REQUIRED for a still-manual verdict" },
61927
61941
  findings: {
61928
61942
  type: "array",
@@ -62048,13 +62062,13 @@ For EACH of your criteria:
62048
62062
 
62049
62063
  1. Read its worklist entry. \`evidence[]\` holds source-anchored excerpts (\`file\`, \`line\`, \`selector\`, \`snippet\`) harvested from the audited code \u2014 open the cited files at the cited lines whenever the snippet alone cannot decide.
62050
62064
  2. Rule it (the apply gate is FAIL-CLOSED \u2014 a verdict missing its required field does not fold):
62051
- - \`C\` (conforming) \u2014 REQUIRES \`justification\` explaining why the evidence satisfies the criterion.
62065
+ - \`C\` (conforming) \u2014 REQUIRES \`justification\` explaining why the evidence satisfies the criterion, AND \`citations[]\` naming the evidence you cleared (\`file\`/\`line\` copied VERBATIM from this item's own \`evidence[]\`; an anchor that is not in that list is treated as fabricated). A criterion whose \`evidence[]\` is empty cannot be \`C\` at all \u2014 it is \`manual\` (\`undecidable\`), or \`NA\` if nothing in scope is concerned.
62052
62066
  - \`NC\` (non-conforming) \u2014 REQUIRES \`findings\`: at least one groundable \`{ file, line, selector?, message, snippet?, severity?, normativeRef }\` pointing at REAL source. The fold re-grounds every finding; an invented file:line is rejected. \`normativeRef\` MUST cite the precise failed test \u2014 under a country standard, one of the item's OWN tests, which the worklist lists for you under \xAB tests to rule on \xBB.
62053
- - \`NA\` (not applicable) \u2014 REQUIRES \`justification\`.
62067
+ - \`NA\` (not applicable) \u2014 REQUIRES \`justification\`, AND \`citations[]\` whenever evidence WAS presented, to say which of those items fall outside the criterion's scope.
62054
62068
  - \`manual\` (still undecidable) \u2014 REQUIRES \`reason\`: \`needs-rendered-dom\` (only a rendered DOM can decide, e.g. computed contrast) or \`undecidable\` (the evidence cannot settle it either way).
62055
62069
  3. Never guess. A criterion you cannot decide from real evidence stays \`manual\` with a reason \u2014 that is a valid, honest verdict; the scan tier or a human picks it up.
62056
62070
 
62057
- Return (structured output): \`{ "verdicts": [{ "criteriaId", "verdict", "justification", "reason", "findings" }] }\` \u2014 your ITEMS only, every field grounded in what you actually read, every NC finding carrying its \`normativeRef\`.
62071
+ Return (structured output): \`{ "verdicts": [{ "criteriaId", "verdict", "justification", "citations", "reason", "findings" }] }\` \u2014 your ITEMS only, every field grounded in what you actually read, every NC finding carrying its \`normativeRef\`, and every C (plus every evidenced NA) carrying its \`citations\`. A verdict that clears a criterion without naming what it cleared is refused by the fold, and one refusal discards the WHOLE adjudication \u2014 including every other verdict you got right.
62058
62072
  ${footer}`,
62059
62073
  refuter: `# Contract: refuter
62060
62074
 
@@ -10,7 +10,7 @@
10
10
  // must be wired in `cypress.config.js` too — Cypress test code cannot write to disk, so the
11
11
  // collected page round-trips through a task.
12
12
  import { COLLECT_SNAPSHOT } from "../collector.js";
13
- import { type CheckOptions, buildPayload, slugify } from "./payload.js";
13
+ import { type CheckOptions, buildPayload, slugify, stayedOnPage } from "./payload.js";
14
14
 
15
15
  // Cypress's globals are not a dependency of this package; they exist at run time only.
16
16
  declare const Cypress: {
@@ -45,6 +45,14 @@ export function registerUltra11yCommand(): void {
45
45
  return cy.window({ log: false }).then((win: { eval(s: string): unknown; location: { href: string } }) => {
46
46
  const collected = win.eval(COLLECT_SNAPSHOT) as Parameters<typeof buildPayload>[0];
47
47
  const url = collected.url || win.location.href;
48
+ // Same refusal as the Playwright fixture: a page the browser did not stay on must not
49
+ // be filed under the requested page's identity. See CheckOptions.expectPath.
50
+ if (opts.expectPath && !stayedOnPage(opts.expectPath, url)) {
51
+ throw new Error(
52
+ `ultra11y: ${opts.expectPath} landed on ${url} — not recording it as "${opts.as ?? opts.name ?? "this page"}". ` +
53
+ `The state that opens this route is not the one the test built; seed it first, or drop the page from the sample.`,
54
+ );
55
+ }
48
56
  const payload = {
49
57
  ...buildPayload(collected, url, "cypress", opts),
50
58
  failOn: opts.failOn,
@@ -98,6 +98,30 @@ export interface CheckOptions {
98
98
  notes?: string;
99
99
  /** Capture a viewport screenshot for the pixel tier (Playwright: on by default). */
100
100
  screenshot?: boolean;
101
+ /** The path you NAVIGATED to. Given it, the fixture refuses to record a page the browser
102
+ * did not stay on — a guarded route that redirected, a session that expired — instead of
103
+ * filing that other screen under this page's `as`/`name`. Nothing about such a snapshot
104
+ * looks wrong, which is what makes it the worst thing an accessibility report can carry.
105
+ * Compared by path (a query or fragment the app appends to its own route is the same
106
+ * page); a full URL works too. `scan --sample` applies the same rule. */
107
+ expectPath?: string;
108
+ }
109
+
110
+ /** Did the browser stay on the page the caller asked for? Path-only, trailing slash folded;
111
+ * anything unparseable passes — this catches a redirect, it does not invent one. */
112
+ export function stayedOnPage(expected: string, actual: string): boolean {
113
+ if (!expected || !actual) return true;
114
+ const path = (u: string): string | undefined => {
115
+ try {
116
+ return new URL(u, "http://x.invalid").pathname.replace(/\/+$/, "");
117
+ } catch {
118
+ return undefined;
119
+ }
120
+ };
121
+ const a = path(expected);
122
+ const b = path(actual);
123
+ if (a === undefined || b === undefined) return true;
124
+ return a === b;
101
125
  }
102
126
 
103
127
  /** Build the snapshot payload from a collected page. Shared so every runner stamps the
@@ -18,7 +18,7 @@
18
18
  // criteria without booting the app, and how the report speaks page by page. Commit it.
19
19
  import { createRequire } from "node:module";
20
20
  import { COLLECT_SNAPSHOT } from "../collector.js";
21
- import { type AuditLike, type CheckOptions, auditSnapshot, buildPayload, gate, writePagesReport } from "./core.js";
21
+ import { type AuditLike, type CheckOptions, auditSnapshot, buildPayload, gate, stayedOnPage, writePagesReport } from "./core.js";
22
22
 
23
23
  // Playwright's own types are not a dependency of this package (it is a peer of YOUR repo),
24
24
  // so the page object is structurally typed to exactly what is used.
@@ -52,6 +52,16 @@ export async function checkA11y(page: PlaywrightPage, opts: PlaywrightCheckOptio
52
52
  }
53
53
 
54
54
  const url = collected.url || page.url();
55
+ // Refuse a page the browser did not stay on, when the caller said where it went. The
56
+ // snapshot's identity is `as`/`name`, applied to whatever is on screen — so a guarded route
57
+ // that redirected would be filed under the requested page's name, and the resulting sheet,
58
+ // screenshot and rate would all describe another screen. Skipping loudly beats that.
59
+ if (opts.expectPath && !stayedOnPage(opts.expectPath, url)) {
60
+ throw new Error(
61
+ `ultra11y: ${opts.expectPath} landed on ${url} — not recording it as "${opts.as ?? opts.name ?? "this page"}". ` +
62
+ `The state that opens this route is not the one the test built; seed it first, or drop the page from the sample.`,
63
+ );
64
+ }
55
65
  const payload = buildPayload(collected, url, "playwright", opts, shot);
56
66
  const result = auditSnapshot(payload);
57
67
  if (opts.report) writePagesReport(typeof opts.report === "object" ? opts.report : {});
@@ -34,6 +34,21 @@ const ADJUDICATE_SCHEMA = {
34
34
  criteriaId: { type: "string" },
35
35
  verdict: { enum: ["C", "NC", "NA", "manual"] },
36
36
  justification: { type: "string", description: "REQUIRED for C and NA" },
37
+ // REQUIRED by the fold alongside `justification`, for exactly the same reason
38
+ // `normativeRef` is required on an NC: a cleared criterion has to name what it
39
+ // cleared. Omitting it here (and from the adjudicator contract) made every
40
+ // adjudication produce C/NA verdicts that `verify --apply` then refused wholesale —
41
+ // 63 rejections out of 97 verdicts on the run that surfaced this.
42
+ citations: {
43
+ type: "array",
44
+ description:
45
+ "REQUIRED for C, and for NA when evidence was presented: the evidence items you cleared, `file`/`line` copied verbatim from this criterion's own evidence[]",
46
+ items: {
47
+ type: "object",
48
+ required: ["file", "line"],
49
+ properties: { file: { type: "string" }, line: { type: "number" }, selector: { type: "string" }, snippet: { type: "string" } },
50
+ },
51
+ },
37
52
  reason: { type: ["string", "null"], enum: ["needs-rendered-dom", "undecidable", null], description: "REQUIRED for a still-manual verdict" },
38
53
  findings: {
39
54
  type: "array",
@@ -176,13 +191,13 @@ For EACH of your criteria:
176
191
 
177
192
  1. Read its worklist entry. \`evidence[]\` holds source-anchored excerpts (\`file\`, \`line\`, \`selector\`, \`snippet\`) harvested from the audited code — open the cited files at the cited lines whenever the snippet alone cannot decide.
178
193
  2. Rule it (the apply gate is FAIL-CLOSED — a verdict missing its required field does not fold):
179
- - \`C\` (conforming) — REQUIRES \`justification\` explaining why the evidence satisfies the criterion.
194
+ - \`C\` (conforming) — REQUIRES \`justification\` explaining why the evidence satisfies the criterion, AND \`citations[]\` naming the evidence you cleared (\`file\`/\`line\` copied VERBATIM from this item's own \`evidence[]\`; an anchor that is not in that list is treated as fabricated). A criterion whose \`evidence[]\` is empty cannot be \`C\` at all — it is \`manual\` (\`undecidable\`), or \`NA\` if nothing in scope is concerned.
180
195
  - \`NC\` (non-conforming) — REQUIRES \`findings\`: at least one groundable \`{ file, line, selector?, message, snippet?, severity?, normativeRef }\` pointing at REAL source. The fold re-grounds every finding; an invented file:line is rejected. \`normativeRef\` MUST cite the precise failed test — under a country standard, one of the item's OWN tests, which the worklist lists for you under « tests to rule on ».
181
- - \`NA\` (not applicable) — REQUIRES \`justification\`.
196
+ - \`NA\` (not applicable) — REQUIRES \`justification\`, AND \`citations[]\` whenever evidence WAS presented, to say which of those items fall outside the criterion's scope.
182
197
  - \`manual\` (still undecidable) — REQUIRES \`reason\`: \`needs-rendered-dom\` (only a rendered DOM can decide, e.g. computed contrast) or \`undecidable\` (the evidence cannot settle it either way).
183
198
  3. Never guess. A criterion you cannot decide from real evidence stays \`manual\` with a reason — that is a valid, honest verdict; the scan tier or a human picks it up.
184
199
 
185
- Return (structured output): \`{ "verdicts": [{ "criteriaId", "verdict", "justification", "reason", "findings" }] }\` — your ITEMS only, every field grounded in what you actually read, every NC finding carrying its \`normativeRef\`.
200
+ Return (structured output): \`{ "verdicts": [{ "criteriaId", "verdict", "justification", "citations", "reason", "findings" }] }\` — your ITEMS only, every field grounded in what you actually read, every NC finding carrying its \`normativeRef\`, and every C (plus every evidenced NA) carrying its \`citations\`. A verdict that clears a criterion without naming what it cleared is refused by the fold, and one refusal discards the WHOLE adjudication — including every other verdict you got right.
186
201
  ${footer}`,
187
202
  refuter: `# Contract: refuter
188
203
 
package/src/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.0.0";
3
+ export const VERSION = "5.1.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,