ultra11y 5.35.4 → 5.36.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.35.4",
13
+ "version": "5.36.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.35.4",
4
+ "version": "5.36.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.35.4",
4
+ "version": "5.36.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.35.4";
30
+ const ULTRA11Y_PLUGIN_VERSION = "5.36.0";
31
31
 
32
32
  const here = dirname(fileURLToPath(import.meta.url));
33
33
 
package/README.md CHANGED
@@ -227,7 +227,7 @@ from auditing out of memory. `cwd` is optional on all five; it selects whose pac
227
227
  `ultra11y_method` is the one to call first. It partitions the standard into evidence tiers —
228
228
  `source`, `cross-file`, `rendered-page`, `browser`, `judgment`, `out-of-scope` — from the
229
229
  engine's own per-criterion rule applicability, never from guessing at the wording of a test.
230
- For RGAA it reports that **55 of 106 criteria declare that no engine rule can evidence them**.
230
+ For RGAA it reports that **56 of 106 criteria declare that no engine rule can evidence them**.
231
231
  That is the standard saying they are yours, and a plan that hides it reads as coverage that
232
232
  does not exist.
233
233
 
@@ -349,8 +349,10 @@ a test). See [`CONTRIBUTING.md`](CONTRIBUTING.md) and `skills/ultra11y/reference
349
349
  the per-page grid accumulating. The overlay detaches itself before collecting — otherwise it
350
350
  would audit itself and shift every element index — and renders nothing outside development.
351
351
  See `references/devtools.md`.
352
- - **A country standard is adjudicated at its OWN granularity** — 57 of RGAA's 106 criteria can
353
- only ever derive `manual`, so ~93% of an RGAA audit is settled by the agent. `verify --manual
352
+ - **A country standard is adjudicated at its OWN granularity** — 97 of RGAA's 106 criteria
353
+ carry at least one judgment test and can still need adjudication to earn C; decisive rules
354
+ can nevertheless prove their bounded failures.
355
+ `verify --manual
354
356
  --standard rgaa` now keys the worklist by **RGAA criteria**, each carrying its numbered tests
355
357
  in full, its technical note, its particular cases, its guidance and the glossary definitions
356
358
  its tests cite (119 entries that previously had no reader). A `normativeRef` must cite one of
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "ultra11y",
3
- "version": "5.35.4",
3
+ "version": "5.36.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",
@@ -1,9 +1,9 @@
1
1
  #!/usr/bin/env node
2
2
  // DEV-ONLY (not in `bin`). Builds the RGAA 4.1.2 STANDARDS PACK that ships at
3
3
  // src/data/standards/rgaa.json (+ rgaa.glossary.json). RGAA is the first of many
4
- // pluggable, in-repo country packs (see CONTRIBUTING.md): a pack does NOT carry the
5
- // engine's rules or automatability — it is a localized criterion set that maps each
6
- // of its criteria onto WCAG success criteria (bare SC ids). The WCAG↔rule coverage
4
+ // pluggable, in-repo country packs (see CONTRIBUTING.md): a pack carries a test-level
5
+ // automation contract, not executable engine code — it is a localized criterion set that
6
+ // maps each of its criteria onto WCAG success criteria (bare SC ids). The WCAG↔rule coverage
7
7
  // lives in the core (scripts/build-standards.mjs). The RGAA content is Licence
8
8
  // Ouverte / Etalab 2.0 — see NOTICE. The official source is vendored under
9
9
  // scripts/vendor/rgaa/ (criteres.json, glossaire.json, methodologies.json) so the
@@ -20,6 +20,7 @@ import { fileURLToPath } from "node:url";
20
20
 
21
21
  const root = join(dirname(fileURLToPath(import.meta.url)), "..");
22
22
  const OUT = join(root, "src", "data", "standards");
23
+ const AUTOMATION_REF = join(root, "skills", "ultra11y", "references", "rgaa-automation.md");
23
24
  const VENDOR = join(root, "scripts", "vendor", "rgaa");
24
25
  const BIOME = join(root, "node_modules", ".bin", "biome");
25
26
  const BASE = "https://raw.githubusercontent.com/DISIC/accessibilite.numerique.gouv.fr/main/RGAA";
@@ -181,11 +182,12 @@ const RULE_TO_CRITERIA = {
181
182
  "axe:object-alt": ["1.1"], "decorative-alt-misuse": ["1.2"], "axe:image-redundant-alt": ["1.2"],
182
183
  // Theme 2 — frames (2.1 frame title)
183
184
  "iframe-title-missing": ["2.1"], "axe:frame-title": ["2.1"], "axe:frame-title-unique": ["2.1"],
184
- // Theme 3 / 10.5 — colour contrast (3.2 text/bg, 10.5 CSS declarations)
185
- "contrast-literal": ["3.2", "10.5"], "axe:color-contrast": ["3.2", "10.5"], "axe:color-contrast-enhanced": ["3.2", "10.5"],
185
+ // Theme 3 — colour contrast. RGAA 10.5 asks about paired CSS declarations, not the
186
+ // resulting ratio, so contrast findings must not be presented as evidence for 10.5.
187
+ "contrast-literal": ["3.2"], "axe:color-contrast": ["3.2"], "axe:color-contrast-enhanced": ["3.2"],
186
188
  // Rendered tier: contrast measured on the real page (computed styles) and on the
187
189
  // screenshot, for the gradient/background-image case the CSSOM cannot express.
188
- "rendered-contrast": ["3.2", "10.5"], "rendered-contrast-pixel": ["3.2", "10.5"],
190
+ "rendered-contrast": ["3.2"], "rendered-contrast-pixel": ["3.2"],
189
191
  // 10.6 — a link in running text identified by colour alone. Deliberately NOT also
190
192
  // mapped to 3.1 (the general "information by colour alone"): 10.6 is RGAA's specific
191
193
  // criterion for links, and claiming both would count one defect twice.
@@ -202,10 +204,9 @@ const RULE_TO_CRITERIA = {
202
204
  // Theme 4 — multimedia (4.3 captions)
203
205
  "media-no-track": ["4.3"], "axe:audio-caption": ["4.3"], "axe:video-caption": ["4.3"],
204
206
  // Theme 5 — tables (5.4 title, 5.6/5.7 headers, 5.8 layout-table markup)
205
- "table-caption-missing": ["5.4"], "data-table-no-headers": ["5.6", "5.7"], "sortable-header-no-aria-sort": ["5.7"],
206
- "table-empty-data-cell": ["5.7"],
207
+ "table-caption-missing": ["5.4"], "data-table-no-headers": ["5.6", "5.7"],
207
208
  "layout-table-data-markup": ["5.8"], "axe:td-headers-attr": ["5.7"], "axe:th-has-data-cells": ["5.6"],
208
- "axe:scope-attr-valid": ["5.7"], "axe:td-has-header": ["5.6"], "axe:empty-table-header": ["5.6"], "axe:table-fake-caption": ["5.8"],
209
+ "axe:scope-attr-valid": ["5.7"], "axe:td-has-header": ["5.6"], "axe:table-fake-caption": ["5.8"],
209
210
  // Theme 6 — links (6.2 link label)
210
211
  "link-empty-name": ["6.2"], "icon-only-control-unnamed": ["6.2", "11.9"], "cross-icon-only-unnamed": ["11.9"],
211
212
  "axe:link-name": ["6.2"],
@@ -227,8 +228,8 @@ const RULE_TO_CRITERIA = {
227
228
  "axe:aria-hidden-focus": ["7.1"], "axe:presentation-role-conflict": ["7.1"],
228
229
  // Theme 8 — document (8.2 valid code, 8.3 default lang, 8.4 lang relevant, 8.5 title, 8.7/8.8 lang changes)
229
230
  "duplicate-id": ["8.2"], "axe:duplicate-id": ["8.2"], "axe:duplicate-id-aria": ["8.2"], "axe:duplicate-id-active": ["8.2"],
230
- "html-lang-missing": ["8.3"], "axe:html-has-lang": ["8.3"], "axe:html-xml-lang-mismatch": ["8.3"],
231
- "lang-invalid": ["8.4", "8.8"], "axe:html-lang-valid": ["8.4"], "axe:valid-lang": ["8.8"],
231
+ "html-lang-missing": ["8.3"], "axe:html-has-lang": ["8.3"],
232
+ "lang-invalid": ["8.4", "8.8"], "axe:html-lang-valid": ["8.4"], "axe:html-xml-lang-mismatch": ["8.4"], "axe:valid-lang": ["8.8"],
232
233
  "title-missing-empty": ["8.5"], "axe:document-title": ["8.5"], "inline-lang-change-missing": ["8.7"],
233
234
  // Theme 9 — structure (9.1 headings, 9.2 doc structure, 9.3 lists)
234
235
  "h1-missing": ["9.1"], "h1-multiple": ["9.1"], "heading-order-skip": ["9.1"], "empty-heading": ["9.1"],
@@ -262,10 +263,110 @@ const RULE_TO_CRITERIA = {
262
263
  // has none for keyboard traps, which is why the probe was written in the first place.
263
264
  "dyn-keyboard-trap": ["12.9"],
264
265
  // Theme 13 — consultation (13.1 time limits, 13.8 moving/blinking)
265
- "meta-refresh-redirect": ["13.1"], "blink-marquee": ["13.8"], "autoplay-media": ["4.10", "13.8"],
266
+ "meta-refresh-redirect": ["13.1"], "blink-marquee": ["13.8"], "autoplay-media": ["4.10"],
266
267
  "axe:no-autoplay-audio": ["4.10"], "axe:blink": ["13.8"], "axe:marquee": ["13.8"],
267
268
  };
268
269
 
270
+ // A rule firing is NOT automatically a complete RGAA failure. Most engine rules are useful
271
+ // evidence for a narrower precondition while the official test still asks about relevance,
272
+ // an allowed alternative, or a particular case. Those rules are routed to adjudication as
273
+ // `candidate` below. This deliberately small table contains only failures whose observation
274
+ // itself exhausts the cited numbered test.
275
+ const DECISIVE_RULE_TESTS = {
276
+ "1.1|axe:area-alt": ["2"],
277
+ "1.1|axe:input-image-alt": ["3"],
278
+ "1.1|input-image-alt-missing": ["3"],
279
+ "2.1|axe:frame-title": ["1"],
280
+ "2.1|iframe-title-missing": ["1"],
281
+ "6.2|link-empty-name": ["1"],
282
+ "6.2|axe:link-name": ["1"],
283
+ "8.2|duplicate-id": ["1"],
284
+ "8.2|axe:duplicate-id": ["1"],
285
+ "8.2|axe:duplicate-id-aria": ["1"],
286
+ "8.2|axe:duplicate-id-active": ["1"],
287
+ "8.4|axe:html-lang-valid": ["1"],
288
+ "8.4|lang-invalid": ["1"],
289
+ "8.5|title-missing-empty": ["1"],
290
+ "8.5|axe:document-title": ["1"],
291
+ "9.3|list-structure": ["1", "2", "3"],
292
+ "9.3|axe:list": ["1", "2"],
293
+ "9.3|axe:listitem": ["1", "2"],
294
+ "9.3|axe:definition-list": ["3"],
295
+ "9.3|axe:dlitem": ["3"],
296
+ "10.7|dyn-focus-visible": ["1"],
297
+ "11.1|axe:label": ["1"],
298
+ "11.1|axe:select-name": ["1"],
299
+ "11.1|control-label-missing": ["1"],
300
+ "11.6|axe:fieldset": ["1"],
301
+ "11.6|fieldset-legend-missing": ["1"],
302
+ "11.9|axe:button-name": ["1"],
303
+ "11.9|axe:input-button-name": ["1"],
304
+ "11.9|button-empty-name": ["1"],
305
+ };
306
+
307
+ // Candidate evidence is test-scoped too. The fallback remains all tests only when one rule id
308
+ // genuinely cannot distinguish the mechanism it observed; the entries below prevent a signal
309
+ // for one numbered test from being presented to the adjudicator as evidence for its siblings.
310
+ const CANDIDATE_RULE_TESTS = {
311
+ "1.1|axe:image-alt": ["1"],
312
+ "1.1|axe:role-img-alt": ["1"],
313
+ "1.1|img-alt-missing": ["1"],
314
+ "1.1|axe:svg-img-alt": ["5"],
315
+ "1.1|axe:object-alt": ["6"],
316
+ "1.1|object-embed-no-name": ["6", "7"],
317
+ "1.1|canvas-fallback-missing": ["8"],
318
+ "1.1|chart-no-accessible-name": ["1", "5", "8"],
319
+ "4.3|axe:audio-caption": ["1"],
320
+ "4.3|axe:video-caption": ["1"],
321
+ "4.3|media-no-track": ["1"],
322
+ "5.6|axe:td-has-header": ["1", "2"],
323
+ "5.6|axe:th-has-data-cells": ["1", "2", "3"],
324
+ "5.6|data-table-no-headers": ["1", "2", "3"],
325
+ "5.7|axe:scope-attr-valid": ["2", "3"],
326
+ "5.7|axe:td-headers-attr": ["4"],
327
+ "7.3|clickable-noninteractive": ["1"],
328
+ "8.4|axe:html-xml-lang-mismatch": ["1"],
329
+ "9.1|axe:empty-heading": ["2"],
330
+ "9.1|empty-heading": ["2"],
331
+ "9.1|axe:heading-order": ["1"],
332
+ "9.1|heading-order-skip": ["1"],
333
+ "9.1|axe:page-has-heading-one": ["1", "3"],
334
+ "9.1|h1-missing": ["1", "3"],
335
+ "9.1|h1-multiple": ["1"],
336
+ "10.4|axe:meta-viewport": ["2"],
337
+ "10.4|axe:meta-viewport-large": ["2"],
338
+ "10.4|meta-viewport-zoom-block": ["2"],
339
+ "10.4|dyn-input-overflow-zoom": ["1"],
340
+ "10.4|dyn-reflow-zoom": ["1"],
341
+ "10.11|dyn-input-overflow-reflow": ["1"],
342
+ "10.11|dyn-reflow": ["1"],
343
+ "11.1|axe:form-field-multiple-labels": ["2"],
344
+ "11.1|form-field-multiple-labels": ["2"],
345
+ "11.1|label-for-dangling": ["2"],
346
+ "11.1|axe:label-title-only": ["1", "3"],
347
+ "11.1|control-name-title-only": ["1", "3"],
348
+ "11.1|placeholder-as-label": ["1", "3"],
349
+ "11.1|field-purpose-incomplete": ["1", "3"],
350
+ "11.1|select-has-option": ["1"],
351
+ "11.9|cross-icon-only-unnamed": ["1"],
352
+ "11.9|icon-only-control-unnamed": ["1"],
353
+ "11.10|aria-invalid-no-description": ["3", "4", "6", "7"],
354
+ "11.10|error-not-associated": ["3", "4", "6", "7"],
355
+ "12.7|axe:bypass": ["1"],
356
+ "12.7|axe:skip-link": ["2"],
357
+ "12.7|skip-link-target-missing": ["2"],
358
+ "12.8|axe:tabindex": ["1"],
359
+ "12.8|positive-tabindex": ["1"],
360
+ "13.1|meta-refresh-redirect": ["2"],
361
+ "13.8|axe:blink": ["2"],
362
+ "13.8|axe:marquee": ["1"],
363
+ };
364
+
365
+ const COMPLETE_BY_SILENCE = new Set(["8.5", "10.1"]);
366
+ const renderedTier = (ruleId) =>
367
+ ruleId.startsWith("rendered-") || ruleId.startsWith("dyn-") || ruleId.startsWith("axe:") ? "rendered" : "static";
368
+ const mergeTestTier = (current, next) => (current === "static" || next === "static" ? "static" : next);
369
+
269
370
  async function main() {
270
371
  const criteres = await source("criteres.json");
271
372
  const glossaire = await source("glossaire.json");
@@ -389,8 +490,8 @@ const DOWNLOAD_EXT = "pdf|docx?|pptx?|xlsx?|odt|ods|odp|rtf|csv|zip|rar|7z|gz|ep
389
490
  // RGAA 8.10.2 — the FIRST of its two sub-conditions: "La valeur de l'attribut dir est
390
491
  // conforme (rtl ou ltr)". Mechanical. The second ("la valeur est pertinente") and 8.10.1
391
492
  // (is a reading-direction change signalled at all?) need language detection and stay
392
- // manual. Anchored negative lookahead so only a genuinely invalid value matches; `auto`
393
- // is valid HTML and is accepted.
493
+ // manual. RGAA 4.1.2 closes the accepted set to `rtl` and `ltr`: HTML's `auto` value is
494
+ // valid markup but does not satisfy this test, so it is intentionally reported here.
394
495
  {
395
496
  id: "dir-value-invalid",
396
497
  criterion: "8.10",
@@ -399,16 +500,16 @@ const DOWNLOAD_EXT = "pdf|docx?|pptx?|xlsx?|odt|ods|odp|rtf|csv|zip|rar|7z|gz|ep
399
500
  match: {
400
501
  attrs: [
401
502
  { name: "dir", op: "present" },
402
- { name: "dir", op: "matches", value: "^(?!(rtl|ltr|auto)$).+$" },
503
+ { name: "dir", op: "matches", value: "^(?!(rtl|ltr)$).+$" },
403
504
  ],
404
505
  },
405
506
  message: {
406
- en: "dir attribute with a value that is not rtl, ltr or auto — the reading direction change is not declared conformantly (RGAA test 8.10.2).",
407
- fr: "Attribut dir dont la valeur n’est ni rtl, ni ltr, ni auto — le changement de sens de lecture n’est pas déclaré conformément (test RGAA 8.10.2).",
507
+ en: "dir attribute with a value other than rtl or ltr — the reading direction change fails RGAA test 8.10.2.",
508
+ fr: "Attribut dir dont la valeur n’est ni rtl ni ltr — le changement de sens de lecture échoue au test RGAA 8.10.2.",
408
509
  },
409
510
  remediation: {
410
- en: 'Use dir="rtl" or dir="ltr" (or dir="auto"), matching the reading direction of the text it carries.',
411
- fr: 'Utilisez dir="rtl" ou dir="ltr" (ou dir="auto"), en accord avec le sens de lecture du texte porté.',
511
+ en: 'Use dir="rtl" or dir="ltr", matching the reading direction of the text it carries.',
512
+ fr: 'Utilisez dir="rtl" ou dir="ltr", en accord avec le sens de lecture du texte porté.',
412
513
  },
413
514
  },
414
515
  // RGAA 8.1 — « chaque page web est-elle définie par un type de document ? » THE criterion
@@ -421,14 +522,11 @@ const DOWNLOAD_EXT = "pdf|docx?|pptx?|xlsx?|odt|ods|odp|rtf|csv|zip|rar|7z|gz|ep
421
522
  // doctype is not part of `documentElement.outerHTML`. The collector records it beside the
422
523
  // DOM (SnapshotMeta.doctype), and this DOCUMENT-level rule reads it there.
423
524
  //
424
- // WHAT IT DECIDES, AND WHAT IT DOES NOT. Test 8.1.1 (is a doctype present?) is the whole
425
- // of it. 8.1.2 (is it valid?) and 8.1.3 (is it before <html>?) are not tested separately
426
- // and deliberately so: a doctype the browser PARSED — which is the only kind that reaches
427
- // `document.doctype` — is by construction well-formed and positioned ahead of the root
428
- // element; one written after <html>, or malformed, is ignored by the parser and arrives
429
- // here as the recorded empty string, i.e. as the absence this rule reports. Inventing a
430
- // separate "invalid" verdict would mean guessing at legacy but legitimate declarations,
431
- // and manufacturing a non-conformity is the one thing this tier must not do.
525
+ // WHAT IT DECIDES, AND WHAT IT DOES NOT. The signal exhausts test 8.1.1 only: no parsed
526
+ // doctype means the required declaration is absent. It does not retain enough source
527
+ // syntax to validate every accepted declaration (8.1.2) or independently prove its source
528
+ // position (8.1.3). Those two tests remain with the adjudicator, and 8.1 cannot earn C from
529
+ // this rule's silence. Inventing either conclusion would manufacture conformity.
432
530
  //
433
531
  // NORMATIVE, and signal-gated: it fires only where a capture recorded the field, so a
434
532
  // source file and a pre-field capture are both silence rather than a failure.
@@ -479,6 +577,66 @@ const DOWNLOAD_EXT = "pdf|docx?|pptx?|xlsx?|odt|ods|odp|rtf|csv|zip|rar|7z|gz|ep
479
577
  presentational.appliesTo = { ruleIds: [...new Set([...presentational.appliesTo.ruleIds, ...PRESENTATIONAL_RULE_IDS])].sort() };
480
578
  const overrides = Object.fromEntries(PRESENTATIONAL_RULE_IDS.map((id) => [id, { advisory: false, severity: "majeur" }]));
481
579
 
580
+ // ---- Test-level automation matrix -------------------------------------------------
581
+ // Start fail-closed: every one of DINUM's 258 tests is a judgment until a rule is
582
+ // explicitly tied to it below. Every applicable rule is also classified. Unlisted rules
583
+ // become candidates, never normative failures by accident.
584
+ const packRuleById = new Map(rules.map((rule) => [`pack:rgaa:${rule.id}`, rule]));
585
+ const packDecisive = {
586
+ "8.1|pack:rgaa:doctype-missing": ["1"],
587
+ "8.10|pack:rgaa:dir-value-invalid": ["2"],
588
+ "11.8|pack:rgaa:optgroup-without-label": ["2"],
589
+ "10.1|presentational-element": ["1"],
590
+ "10.1|presentational-attribute": ["2"],
591
+ "10.1|presentational-spacing": ["3"],
592
+ };
593
+ for (const [key, tests] of Object.entries(CANDIDATE_RULE_TESTS)) {
594
+ const split = key.indexOf("|");
595
+ const criterionId = key.slice(0, split);
596
+ const ruleId = key.slice(split + 1);
597
+ const criterion = criteria.find((entry) => entry.id === criterionId);
598
+ if (!criterion) throw new Error(`CANDIDATE_RULE_TESTS: unknown criterion "${criterionId}"`);
599
+ if (!criterion.appliesTo.ruleIds.includes(ruleId))
600
+ throw new Error(`CANDIDATE_RULE_TESTS: "${ruleId}" is not applicable to RGAA ${criterionId}`);
601
+ for (const test of tests) if (!(test in criterion.tests)) throw new Error(`CANDIDATE_RULE_TESTS: unknown test ${criterionId}.${test}`);
602
+ }
603
+ for (const c of criteria) {
604
+ const testKeys = Object.keys(c.tests ?? {});
605
+ const testTiers = Object.fromEntries(testKeys.map((key) => [key, "judgment"]));
606
+ const ruleContracts = c.appliesTo.ruleIds.map((id) => {
607
+ const decisiveTests = DECISIVE_RULE_TESTS[`${c.id}|${id}`] ?? packDecisive[`${c.id}|${id}`];
608
+ const advisory = packRuleById.get(id)?.advisory === true;
609
+ // Fail closed: a rule is decisive only when the exact RGAA numbered test is curated
610
+ // above. The old fallback made every unlisted rule decisive and then spread it over
611
+ // every test of the criterion — precisely the false-NC path this contract exists to
612
+ // prevent.
613
+ const effect = advisory ? "advisory" : decisiveTests ? "decisive-nc" : "candidate";
614
+ const touched = decisiveTests ?? CANDIDATE_RULE_TESTS[`${c.id}|${id}`] ?? testKeys;
615
+ if (effect === "decisive-nc") {
616
+ const tier = id === "pack:rgaa:doctype-missing" ? "rendered" : renderedTier(id);
617
+ for (const test of touched) testTiers[test] = mergeTestTier(testTiers[test], tier);
618
+ }
619
+ return {
620
+ id,
621
+ tests: touched,
622
+ effect,
623
+ rationale:
624
+ effect === "decisive-nc"
625
+ ? "The observed failure exhausts the cited RGAA test."
626
+ : effect === "candidate"
627
+ ? "Useful engine evidence, but the RGAA test still has an alternative, applicability condition, relevance judgment, or particular case to adjudicate."
628
+ : "Non-normative recommendation; it cannot affect the criterion verdict.",
629
+ };
630
+ });
631
+ c.automation = {
632
+ tests: testTiers,
633
+ rules: ruleContracts,
634
+ ...(COMPLETE_BY_SILENCE.has(c.id) ? { completeBySilence: true } : {}),
635
+ };
636
+ // The matrix supersedes the five historical exceptions: any criterion not explicitly
637
+ // complete by silence must not inherit C from a broader WCAG projection.
638
+ }
639
+
482
640
  const pack = {
483
641
  key: "rgaa",
484
642
  name: "RGAA",
@@ -571,6 +729,48 @@ const DOWNLOAD_EXT = "pdf|docx?|pptx?|xlsx?|odt|ods|odp|rtf|csv|zip|rar|7z|gz|ep
571
729
 
572
730
  const packText = biomeFormat(JSON.stringify(pack, null, 2) + "\n", "src/data/standards/rgaa.json");
573
731
  const glossaryText = biomeFormat(JSON.stringify(glossary, null, 2) + "\n", "src/data/standards/rgaa.glossary.json");
732
+ const automationLines = [
733
+ "<!-- GENERATED by `pnpm run build:pack:rgaa` from the vendored RGAA 4.1.2 data and the curated rule contracts. -->",
734
+ "",
735
+ "# RGAA 4.1.2 automation matrix",
736
+ "",
737
+ "Every one of the 258 official tests is classified. `static` and `rendered` mean that an explicitly mapped decisive rule can prove a failure; `judgment` remains for adjudication. A candidate signal is evidence only and never changes the verdict by itself. Conformity by silence is allowed only where explicitly shown.",
738
+ "",
739
+ "## Summary by criterion",
740
+ "",
741
+ "The 106 criterion rows below are the one-by-one routing review. “AI + signals” means deterministic evidence is forwarded without changing the verdict; “deterministic NC + AI residual” means a precise failure can be decided mechanically while every remaining condition still goes to adjudication.",
742
+ "",
743
+ "| Criterion | Tests (static / rendered / judgment) | Rules (decisive / candidate / advisory) | C by silence | Reviewed routing |",
744
+ "|---|---:|---:|:---:|---|",
745
+ ];
746
+ for (const c of criteria) {
747
+ const tiers = Object.values(c.automation.tests);
748
+ const effects = c.automation.rules.map((rule) => rule.effect);
749
+ const count = (values, value) => values.filter((entry) => entry === value).length;
750
+ automationLines.push(
751
+ `| ${c.id} | ${count(tiers, "static")} / ${count(tiers, "rendered")} / ${count(tiers, "judgment")} | ${count(effects, "decisive-nc")} / ${count(effects, "candidate")} / ${count(effects, "advisory")} | ${c.automation.completeBySilence ? "yes" : "no"} | ${
752
+ c.automation.completeBySilence
753
+ ? "deterministic"
754
+ : effects.includes("decisive-nc")
755
+ ? "deterministic NC + AI residual"
756
+ : effects.includes("candidate")
757
+ ? "AI + signals"
758
+ : "AI"
759
+ } |`,
760
+ );
761
+ }
762
+ automationLines.push("", "## Test-by-test contract", "");
763
+ automationLines.push("| Test | Owner | Decisive rules | Candidate signals |", "|---|---|---|---|");
764
+ for (const c of criteria) {
765
+ for (const [test, tier] of Object.entries(c.automation.tests)) {
766
+ const contracts = c.automation.rules.filter((rule) => rule.tests.includes(test));
767
+ const decisive = contracts.filter((rule) => rule.effect === "decisive-nc").map((rule) => `\`${rule.id}\``);
768
+ const candidates = contracts.filter((rule) => rule.effect === "candidate").map((rule) => `\`${rule.id}\``);
769
+ automationLines.push(`| ${c.id}.${test} | ${tier} | ${decisive.join(", ") || "—"} | ${candidates.join(", ") || "—"} |`);
770
+ }
771
+ }
772
+ automationLines.push("");
773
+ const automationText = automationLines.join("\n");
574
774
 
575
775
  if (doCheck) {
576
776
  const packPath = join(OUT, "rgaa.json");
@@ -579,6 +779,8 @@ const DOWNLOAD_EXT = "pdf|docx?|pptx?|xlsx?|odt|ods|odp|rtf|csv|zip|rar|7z|gz|ep
579
779
  if (!existsSync(packPath) || readFileSync(packPath, "utf8") !== packText) drift.push("src/data/standards/rgaa.json");
580
780
  if (!existsSync(glossaryPath) || readFileSync(glossaryPath, "utf8") !== glossaryText)
581
781
  drift.push("src/data/standards/rgaa.glossary.json");
782
+ if (!existsSync(AUTOMATION_REF) || readFileSync(AUTOMATION_REF, "utf8") !== automationText)
783
+ drift.push("skills/ultra11y/references/rgaa-automation.md");
582
784
  if (drift.length > 0) {
583
785
  console.error(`build-pack-rgaa --check: OUT OF DATE vs vendored source — re-run \`pnpm run build:pack:rgaa\`: ${drift.join(", ")}`);
584
786
  process.exit(1);
@@ -590,6 +792,7 @@ const DOWNLOAD_EXT = "pdf|docx?|pptx?|xlsx?|odt|ods|odp|rtf|csv|zip|rar|7z|gz|ep
590
792
  mkdirSync(OUT, { recursive: true });
591
793
  writeFileSync(join(OUT, "rgaa.json"), packText);
592
794
  writeFileSync(join(OUT, "rgaa.glossary.json"), glossaryText);
795
+ writeFileSync(AUTOMATION_REF, automationText);
593
796
 
594
797
  // A normative sentence that stringified to "[object Object]" is a DELETED sub-condition an
595
798
  // auditor would have had to apply. Fail the build rather than ship placeholder text.