ultra11y 2.22.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.
Files changed (153) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +223 -0
  3. package/docker/Dockerfile +7 -0
  4. package/docker/package.json +9 -0
  5. package/docker/runner.mjs +23 -0
  6. package/package.json +73 -0
  7. package/scripts/bench.mjs +151 -0
  8. package/scripts/build-act-corpus.mjs +143 -0
  9. package/scripts/build-adjudication.mjs +503 -0
  10. package/scripts/build-pack-rgaa.mjs +397 -0
  11. package/scripts/build-standards.mjs +548 -0
  12. package/scripts/copy-bundle.mjs +39 -0
  13. package/scripts/import-pack.mjs +107 -0
  14. package/scripts/sync-engine.mjs +84 -0
  15. package/scripts/sync-version.mjs +43 -0
  16. package/scripts/ultra11y.mjs +52739 -0
  17. package/scripts/vendor/act-testcases.json +12297 -0
  18. package/scripts/vendor/rgaa/SOURCE.json +8 -0
  19. package/scripts/vendor/rgaa/criteres.json +4144 -0
  20. package/scripts/vendor/rgaa/glossaire.json +480 -0
  21. package/scripts/vendor/wcag-2.2-fr.json +82 -0
  22. package/scripts/vendor/wcag-2.2-sc-universe.json +950 -0
  23. package/scripts/vendor/wcag-2.2-sc.json +574 -0
  24. package/scripts/verify-rgaa-source.mjs +126 -0
  25. package/scripts/verify-skill-bundle.mjs +91 -0
  26. package/skills/review-a11y/SKILL.md +123 -0
  27. package/skills/review-a11y/scripts/ultra11y.mjs +52739 -0
  28. package/skills/ultra11y/SKILL.md +292 -0
  29. package/skills/ultra11y/references/act.md +163 -0
  30. package/skills/ultra11y/references/adjudication.md +599 -0
  31. package/skills/ultra11y/references/audit.md +100 -0
  32. package/skills/ultra11y/references/authoring.md +37 -0
  33. package/skills/ultra11y/references/automation.md +113 -0
  34. package/skills/ultra11y/references/correction.md +41 -0
  35. package/skills/ultra11y/references/criteria.md +129 -0
  36. package/skills/ultra11y/references/cross-file.md +82 -0
  37. package/skills/ultra11y/references/dynamic.md +152 -0
  38. package/skills/ultra11y/references/false-positives.md +81 -0
  39. package/skills/ultra11y/references/fix.md +46 -0
  40. package/skills/ultra11y/references/focus-and-logic.md +64 -0
  41. package/skills/ultra11y/references/forbidden-patterns.md +61 -0
  42. package/skills/ultra11y/references/forms-and-errors.md +147 -0
  43. package/skills/ultra11y/references/guidance.md +55 -0
  44. package/skills/ultra11y/references/judgment.md +90 -0
  45. package/skills/ultra11y/references/media-and-motion.md +116 -0
  46. package/skills/ultra11y/references/methodology.md +72 -0
  47. package/skills/ultra11y/references/naming.md +117 -0
  48. package/skills/ultra11y/references/orchestration.md +44 -0
  49. package/skills/ultra11y/references/packs.md +193 -0
  50. package/skills/ultra11y/references/prd.md +104 -0
  51. package/skills/ultra11y/references/rendered.md +110 -0
  52. package/skills/ultra11y/references/scale.md +49 -0
  53. package/skills/ultra11y/references/standards.md +97 -0
  54. package/skills/ultra11y/references/structure.md +138 -0
  55. package/skills/ultra11y/references/verify.md +88 -0
  56. package/skills/ultra11y/references/widgets.md +106 -0
  57. package/skills/ultra11y/scripts/ultra11y.mjs +52739 -0
  58. package/src/act.ts +441 -0
  59. package/src/adjudicate.ts +630 -0
  60. package/src/aria.ts +230 -0
  61. package/src/audit.ts +396 -0
  62. package/src/auditor.ts +399 -0
  63. package/src/autofill.ts +102 -0
  64. package/src/axe-map.ts +294 -0
  65. package/src/baseline.ts +75 -0
  66. package/src/capture.ts +182 -0
  67. package/src/check.ts +243 -0
  68. package/src/cli.ts +1927 -0
  69. package/src/color.ts +102 -0
  70. package/src/config.ts +182 -0
  71. package/src/crawl.ts +75 -0
  72. package/src/criteria.ts +183 -0
  73. package/src/data/adjudication.json +770 -0
  74. package/src/data/guidance/rgaa.json +2395 -0
  75. package/src/data/standards/rgaa.glossary.json +478 -0
  76. package/src/data/standards/rgaa.json +3454 -0
  77. package/src/data/wcag-universe.json +530 -0
  78. package/src/data/wcag.json +1178 -0
  79. package/src/discover.ts +203 -0
  80. package/src/fix/codemods.ts +76 -0
  81. package/src/fix/diff.ts +64 -0
  82. package/src/fix/edits.ts +126 -0
  83. package/src/fix.ts +215 -0
  84. package/src/gh.ts +214 -0
  85. package/src/glob.ts +206 -0
  86. package/src/graph/build.ts +114 -0
  87. package/src/graph/engine-resolve.ts +0 -0
  88. package/src/graph/graph.ts +126 -0
  89. package/src/graph/imports.ts +359 -0
  90. package/src/graph/resolve.ts +73 -0
  91. package/src/graph/tsconfig.ts +113 -0
  92. package/src/grounding.ts +119 -0
  93. package/src/guidance/index.ts +45 -0
  94. package/src/guidance/types.ts +36 -0
  95. package/src/init.ts +113 -0
  96. package/src/mcp/handlers.ts +388 -0
  97. package/src/mcp/http.ts +260 -0
  98. package/src/mcp/prompts.ts +183 -0
  99. package/src/mcp/protocol.ts +187 -0
  100. package/src/mcp/resources.ts +136 -0
  101. package/src/mcp/server.ts +228 -0
  102. package/src/mcp/stdio.ts +119 -0
  103. package/src/mcp/tools.ts +347 -0
  104. package/src/messages.ts +1011 -0
  105. package/src/name.ts +174 -0
  106. package/src/orchestrate-templates.ts +231 -0
  107. package/src/orchestrate.ts +186 -0
  108. package/src/output.ts +108 -0
  109. package/src/pack.ts +187 -0
  110. package/src/parse/html.ts +258 -0
  111. package/src/parse/jsx-ast.ts +67 -0
  112. package/src/parse/jsx-bridge.ts +247 -0
  113. package/src/parse/jsx.ts +19 -0
  114. package/src/parse/source.ts +75 -0
  115. package/src/prd.ts +406 -0
  116. package/src/project-lock.ts +42 -0
  117. package/src/render.ts +307 -0
  118. package/src/report.ts +399 -0
  119. package/src/rules/colors.ts +125 -0
  120. package/src/rules/cross-registry.ts +264 -0
  121. package/src/rules/cross-rule.ts +83 -0
  122. package/src/rules/forms.ts +590 -0
  123. package/src/rules/frames.ts +33 -0
  124. package/src/rules/headings.ts +227 -0
  125. package/src/rules/images.ts +200 -0
  126. package/src/rules/links.ts +193 -0
  127. package/src/rules/mandatory.ts +173 -0
  128. package/src/rules/multimedia.ts +77 -0
  129. package/src/rules/navigation.ts +190 -0
  130. package/src/rules/presentation.ts +75 -0
  131. package/src/rules/registry.ts +57 -0
  132. package/src/rules/rule.ts +122 -0
  133. package/src/rules/scripts-aria.ts +636 -0
  134. package/src/rules/tables.ts +264 -0
  135. package/src/rules/timing.ts +55 -0
  136. package/src/sample.ts +176 -0
  137. package/src/scan-local.ts +866 -0
  138. package/src/scan.ts +538 -0
  139. package/src/standards/derive.ts +195 -0
  140. package/src/standards/index.ts +31 -0
  141. package/src/standards/pack-rules.ts +111 -0
  142. package/src/standards/pack.ts +81 -0
  143. package/src/standards/registry.ts +100 -0
  144. package/src/standards/types.ts +202 -0
  145. package/src/standards/validate.ts +544 -0
  146. package/src/standards/vocabulary.ts +75 -0
  147. package/src/types.ts +355 -0
  148. package/src/util.ts +32 -0
  149. package/src/vendor/codeindex-engine.d.mts +1009 -0
  150. package/src/vendor/codeindex-engine.mjs +13701 -0
  151. package/src/vendor/engine.meta.json +9 -0
  152. package/src/verify.ts +275 -0
  153. package/src/wcag.ts +128 -0
@@ -0,0 +1,397 @@
1
+ #!/usr/bin/env node
2
+ // DEV-ONLY (not in `bin`). Builds the RGAA 4.1.2 STANDARDS PACK that ships at
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
7
+ // lives in the core (scripts/build-standards.mjs). The RGAA content is Licence
8
+ // Ouverte / Etalab 2.0 — see NOTICE. The official source is vendored under
9
+ // scripts/vendor/rgaa/ so the build is reproducible offline.
10
+ // node scripts/build-pack-rgaa.mjs # build from the vendored source
11
+ // node scripts/build-pack-rgaa.mjs --offline # alias (the source is always local)
12
+ // node scripts/build-pack-rgaa.mjs --fetch # refresh the vendored source from DINUM, then build
13
+ // node scripts/build-pack-rgaa.mjs --check # rebuild in memory and byte-compare vs the committed
14
+ // # pack; no writes; exit 1 on drift (CI gate)
15
+ import { writeFileSync, readFileSync, mkdirSync, existsSync } from "node:fs";
16
+ import { execFileSync } from "node:child_process";
17
+ import { dirname, join } from "node:path";
18
+ import { fileURLToPath } from "node:url";
19
+
20
+ const root = join(dirname(fileURLToPath(import.meta.url)), "..");
21
+ const OUT = join(root, "src", "data", "standards");
22
+ const VENDOR = join(root, "scripts", "vendor", "rgaa");
23
+ const BIOME = join(root, "node_modules", ".bin", "biome");
24
+ const BASE = "https://raw.githubusercontent.com/DISIC/accessibilite.numerique.gouv.fr/main/RGAA";
25
+ const doFetch = process.argv.includes("--fetch");
26
+ const doCheck = process.argv.includes("--check");
27
+
28
+ // The committed pack JSON is biome-formatted (short arrays collapse onto one line —
29
+ // see biome.json's default `--expand=auto`), not raw `JSON.stringify` output. Route
30
+ // both the write path and the --check comparison through biome so the two can never
31
+ // disagree over whitespace alone; `relPath` (project-relative, e.g.
32
+ // "src/data/standards/rgaa.json") only picks the JSON formatter, no file is touched.
33
+ function biomeFormat(text, relPath) {
34
+ return execFileSync(BIOME, ["format", `--stdin-file-path=${relPath}`], { input: text, encoding: "utf8" });
35
+ }
36
+
37
+ async function source(name) {
38
+ if (doFetch) {
39
+ const r = await fetch(`${BASE}/${name}`);
40
+ if (!r.ok) throw new Error(`build-pack-rgaa: ${name} HTTP ${r.status}`);
41
+ const text = await r.text();
42
+ mkdirSync(VENDOR, { recursive: true });
43
+ writeFileSync(join(VENDOR, name), text);
44
+ return JSON.parse(text);
45
+ }
46
+ return JSON.parse(readFileSync(join(VENDOR, name), "utf8"));
47
+ }
48
+
49
+ // Slug used by the official site for glossary anchors.
50
+ const slug = (s) =>
51
+ s
52
+ .normalize("NFD")
53
+ .replace(/\p{Diacritic}/gu, "")
54
+ .toLowerCase()
55
+ .replace(/['’]/g, " ")
56
+ .replace(/[^a-z0-9]+/g, "-")
57
+ .replace(/^-+|-+$/g, "");
58
+
59
+ // [term](#anchor) -> term
60
+ const plain = (s) => s.replace(/\[([^\]]+)\]\(#[^)]*\)/g, "$1");
61
+ const toArr = (v) => (Array.isArray(v) ? v.map(String) : v == null ? [] : [String(v)]);
62
+
63
+ // crude HTML -> plaintext for glossary bodies
64
+ const deHtml = (s) =>
65
+ s
66
+ .replace(/<\/(p|li|ul|ol|div|tr|h[1-6])>/gi, "\n")
67
+ .replace(/<[^>]+>/g, " ")
68
+ .replace(/&nbsp;/g, " ")
69
+ .replace(/&amp;/g, "&")
70
+ .replace(/&lt;/g, "<")
71
+ .replace(/&gt;/g, ">")
72
+ .replace(/&#39;|&rsquo;|&#8217;/g, "’")
73
+ .replace(/&quot;/g, '"')
74
+ .replace(/[ \t]+/g, " ")
75
+ .replace(/\n{3,}/g, "\n\n")
76
+ .trim();
77
+
78
+ // "1.1.1 Non-text Content (A)" -> "1.1.1" (bare WCAG SC id; the WCAG core owns titles/levels)
79
+ const bareSc = (w) => String(w).trim().split(/\s+/)[0];
80
+
81
+ // ---- Applicability (R1 fix): which ENGINE RULE ids can make each RGAA criterion NC ----
82
+ // A single WCAG SC maps to many RGAA criteria (1.1.1 → 19 criteria: informative image,
83
+ // CAPTCHA, detailed description, layout tables, downloadable documents…). Without this,
84
+ // one `img-alt-missing` failure fanned out to ALL of them. This CURATED table names, per
85
+ // engine rule, the RGAA criterion(s) whose element/context that rule actually evidences;
86
+ // the inverse (criterion → ruleIds) is written to each criterion's `appliesTo`. Static
87
+ // engine rule ids are validated below (must exist + share a WCAG SC with the criterion);
88
+ // axe:*/dyn-* ids (dynamic tier) are namespaced and tolerated as-is.
89
+ //
90
+ // The WCAG SC(s) each STATIC rule can emit — used only to self-validate the table below
91
+ // (a listed (rule, criterion) pair must share an SC, else the entry would be inert).
92
+ const RULE_SC = {
93
+ "aria-hidden-focusable": ["4.1.2"], "aria-invalid-no-description": ["3.3.1"], "aria-ref-missing-id": ["4.1.2"],
94
+ "aria-required-children": ["4.1.2"], "autoplay-media": ["1.4.2", "2.2.2"], "blink-marquee": ["2.2.2"],
95
+ "button-empty-name": ["4.1.2"], "canvas-fallback-missing": ["1.1.1"], "chart-no-accessible-name": ["1.1.1"],
96
+ "clickable-noninteractive": ["4.1.2", "2.1.1"], "contrast-literal": ["1.4.3"], "control-label-missing": ["4.1.2"],
97
+ "css-generated-content-informative": ["1.3.1"], "date-fields-ungrouped": ["3.3.2"], "disabled-context-content": ["4.1.2"],
98
+ "control-name-title-only": ["4.1.2"], "cross-icon-only-unnamed": ["4.1.2"],
99
+ "cross-prop-drilled-name-lost": ["4.1.2"], "data-table-no-headers": ["1.3.1"],
100
+ "decorative-alt-misuse": ["1.1.1"], "duplicate-id": ["4.1.2"], "empty-heading": ["1.3.1"], "error-not-associated": ["3.3.1"],
101
+ "field-purpose-incomplete": ["1.3.5", "4.1.2"], "fieldset-legend-missing": ["1.3.1"], "form-field-multiple-labels": ["4.1.2"],
102
+ "h1-missing": ["1.3.1"], "h1-multiple": ["1.3.1"], "heading-order-skip": ["1.3.1"], "html-lang-missing": ["3.1.1"],
103
+ "icon-only-control-unnamed": ["2.4.4", "4.1.2"], "iframe-title-missing": ["4.1.2"], "img-alt-missing": ["1.1.1"],
104
+ "inline-lang-change-missing": ["3.1.2"], "input-image-alt-missing": ["1.1.1"], "invalid-aria-role": ["4.1.2"],
105
+ "label-for-dangling": ["1.3.1"], "lang-invalid": ["3.1.1", "3.1.2"], "layout-table-data-markup": ["1.3.1"],
106
+ "link-empty-name": ["2.4.4"], "list-structure": ["1.3.1"], "live-region-conflict": ["4.1.3"], "media-no-track": ["1.2.2"],
107
+ "meta-refresh-redirect": ["2.2.1"], "meta-viewport-zoom-block": ["1.4.4"], "missing-main-landmark": ["1.3.1"],
108
+ "multiple-main-landmark": ["1.3.1"], "nav-landmark-missing": ["1.3.1"], "nav-landmark-unnamed": ["1.3.1"],
109
+ "nested-interactive": ["4.1.2"], "object-embed-no-name": ["1.1.1"], "radio-checkbox-group-ungrouped": ["1.3.1", "3.3.2"],
110
+ "placeholder-as-label": ["4.1.2"], "positive-tabindex": ["2.4.3"], "redundant-aria": ["4.1.2"], "select-has-option": ["4.1.2"],
111
+ "skip-link-target-missing": ["2.4.1"], "sortable-header-no-aria-sort": ["1.3.1"], "status-message-not-assertive": ["4.1.3"],
112
+ "table-caption-missing": ["1.3.1"], "table-empty-data-cell": ["1.3.1"], "title-missing-empty": ["2.4.2"],
113
+ };
114
+
115
+ // ruleId → RGAA criterion ids it evidences. Static rules + their axe:/dyn- equivalents so
116
+ // the dynamic-tier merge keeps the same mapping (an axe:color-contrast NC still lands on
117
+ // RGAA 3.2/10.5, not fanned out).
118
+ const RULE_TO_CRITERIA = {
119
+ // Theme 1 — images (1.1 informative alt, 1.2 decorative)
120
+ "img-alt-missing": ["1.1"], "input-image-alt-missing": ["1.1"], "object-embed-no-name": ["1.1"],
121
+ "chart-no-accessible-name": ["1.1"], "canvas-fallback-missing": ["1.1"], "axe:image-alt": ["1.1"],
122
+ "axe:input-image-alt": ["1.1"], "axe:area-alt": ["1.1"], "axe:role-img-alt": ["1.1"], "axe:svg-img-alt": ["1.1"],
123
+ "axe:object-alt": ["1.1"], "decorative-alt-misuse": ["1.2"], "axe:image-redundant-alt": ["1.2"],
124
+ // Theme 2 — frames (2.1 frame title)
125
+ "iframe-title-missing": ["2.1"], "axe:frame-title": ["2.1"], "axe:frame-title-unique": ["2.1"],
126
+ // Theme 3 / 10.5 — colour contrast (3.2 text/bg, 10.5 CSS declarations)
127
+ "contrast-literal": ["3.2", "10.5"], "axe:color-contrast": ["3.2", "10.5"], "axe:color-contrast-enhanced": ["3.2", "10.5"],
128
+ // Theme 4 — multimedia (4.3 captions)
129
+ "media-no-track": ["4.3"], "axe:audio-caption": ["4.3"], "axe:video-caption": ["4.3"],
130
+ // Theme 5 — tables (5.4 title, 5.6/5.7 headers, 5.8 layout-table markup)
131
+ "table-caption-missing": ["5.4"], "data-table-no-headers": ["5.6", "5.7"], "sortable-header-no-aria-sort": ["5.7"],
132
+ "table-empty-data-cell": ["5.7"],
133
+ "layout-table-data-markup": ["5.8"], "axe:td-headers-attr": ["5.7"], "axe:th-has-data-cells": ["5.6"],
134
+ "axe:scope-attr-valid": ["5.7"], "axe:td-has-header": ["5.6"], "axe:empty-table-header": ["5.6"], "axe:table-fake-caption": ["5.8"],
135
+ // Theme 6 — links (6.2 link label)
136
+ "link-empty-name": ["6.2"], "icon-only-control-unnamed": ["6.2", "11.9"], "cross-icon-only-unnamed": ["11.9"],
137
+ "axe:link-name": ["6.2"],
138
+ // Theme 7 — scripts/ARIA (7.1 AT-compat, 7.3 keyboard, 7.5 status messages)
139
+ "invalid-aria-role": ["7.1"], "aria-ref-missing-id": ["7.1"], "aria-required-children": ["7.1"],
140
+ "aria-hidden-focusable": ["7.1"], "redundant-aria": ["7.1"], "nested-interactive": ["7.1"],
141
+ "cross-prop-drilled-name-lost": ["7.1"],
142
+ "clickable-noninteractive": ["7.3"], "live-region-conflict": ["7.5"], "status-message-not-assertive": ["7.5"],
143
+ // Dynamic tier (scan --local): the live-region probe projects onto WCAG 4.1.3 → RGAA 7.5
144
+ // (status messages) — the WCAG-faithful home. Ara ALSO classifies the source finding under
145
+ // RGAA 7.4 (change of context, WCAG 3.2.1/3.2.2); that deviation from the 4.1.3 crosswalk
146
+ // ships as an opt-in `secondaryMappings` entry below (dyn-live-region → 7.4, DISABLED by
147
+ // default), never hardcoded here, so the out-of-box projection stays WCAG-faithful (7.5).
148
+ "dyn-live-region": ["7.5"],
149
+ "disabled-context-content": ["7.1", "10.8"],
150
+ "axe:aria-allowed-attr": ["7.1"], "axe:aria-allowed-role": ["7.1"], "axe:aria-roles": ["7.1"],
151
+ "axe:aria-required-attr": ["7.1"], "axe:aria-required-children": ["7.1"], "axe:aria-required-parent": ["7.1"],
152
+ "axe:aria-valid-attr": ["7.1"], "axe:aria-valid-attr-value": ["7.1"], "axe:nested-interactive": ["7.1"],
153
+ "axe:aria-hidden-focus": ["7.1"], "axe:presentation-role-conflict": ["7.1"],
154
+ // Theme 8 — document (8.2 valid code, 8.3 default lang, 8.4 lang relevant, 8.5 title, 8.7/8.8 lang changes)
155
+ "duplicate-id": ["8.2"], "axe:duplicate-id": ["8.2"], "axe:duplicate-id-aria": ["8.2"], "axe:duplicate-id-active": ["8.2"],
156
+ "html-lang-missing": ["8.3"], "axe:html-has-lang": ["8.3"], "axe:html-xml-lang-mismatch": ["8.3"],
157
+ "lang-invalid": ["8.4", "8.8"], "axe:html-lang-valid": ["8.4"], "axe:valid-lang": ["8.8"],
158
+ "title-missing-empty": ["8.5"], "axe:document-title": ["8.5"], "inline-lang-change-missing": ["8.7"],
159
+ // Theme 9 — structure (9.1 headings, 9.2 doc structure, 9.3 lists)
160
+ "h1-missing": ["9.1"], "h1-multiple": ["9.1"], "heading-order-skip": ["9.1"], "empty-heading": ["9.1"],
161
+ "axe:heading-order": ["9.1"], "axe:empty-heading": ["9.1"], "axe:page-has-heading-one": ["9.1"],
162
+ "missing-main-landmark": ["9.2", "12.6"], "multiple-main-landmark": ["9.2", "12.6"], "axe:landmark-one-main": ["12.6"],
163
+ "nav-landmark-missing": ["9.2", "12.6"], "nav-landmark-unnamed": ["12.6"],
164
+ "list-structure": ["9.3"], "axe:list": ["9.3"], "axe:listitem": ["9.3"], "axe:definition-list": ["9.3"], "axe:dlitem": ["9.3"],
165
+ // Theme 10 — presentation (10.2 CSS-off content, 10.4 zoom, 10.7 focus, 10.11 reflow, 10.12 text-spacing)
166
+ "css-generated-content-informative": ["10.2"],
167
+ "meta-viewport-zoom-block": ["10.4"], "axe:meta-viewport": ["10.4"], "axe:meta-viewport-large": ["10.4"],
168
+ "dyn-reflow": ["10.11"], "dyn-reflow-zoom": ["10.4"], "dyn-focus-visible": ["10.7"], "dyn-text-spacing": ["10.12"], "dyn-hover": ["10.13"],
169
+ // Stateful input-overflow probes — a filled input clipped under each stress, same RGAA
170
+ // theme as the corresponding reflow/zoom/text-spacing residual probe above.
171
+ "dyn-input-overflow-reflow": ["10.11"], "dyn-input-overflow-zoom": ["10.4"], "dyn-input-overflow-spacing": ["10.12"],
172
+ // Theme 11 — forms (11.1 field label, 11.5 field grouping, 11.6 fieldset legend, 11.9 button label, 11.10 input control, 11.13 autocomplete)
173
+ "control-label-missing": ["11.1"], "label-for-dangling": ["11.1"], "placeholder-as-label": ["11.1"],
174
+ "form-field-multiple-labels": ["11.1"], "select-has-option": ["11.1"], "control-name-title-only": ["11.1"],
175
+ "radio-checkbox-group-ungrouped": ["11.5"], "date-fields-ungrouped": ["11.5"],
176
+ "field-purpose-incomplete": ["11.1", "11.13"], "fieldset-legend-missing": ["11.6"], "button-empty-name": ["11.9"],
177
+ "error-not-associated": ["11.10"], "aria-invalid-no-description": ["11.10"],
178
+ "axe:label": ["11.1"], "axe:form-field-multiple-labels": ["11.1"], "axe:select-name": ["11.1"], "axe:label-title-only": ["11.1"],
179
+ "axe:autocomplete-valid": ["11.13"], "axe:fieldset": ["11.6"], "axe:input-button-name": ["11.9"], "axe:button-name": ["11.9"],
180
+ // Theme 12 — navigation (12.7 skip link, 12.8 tab order)
181
+ "skip-link-target-missing": ["12.7"], "axe:skip-link": ["12.7"], "axe:bypass": ["12.7"],
182
+ "positive-tabindex": ["12.8"], "axe:tabindex": ["12.8"],
183
+ // Theme 13 — consultation (13.1 time limits, 13.8 moving/blinking)
184
+ "meta-refresh-redirect": ["13.1"], "blink-marquee": ["13.8"], "autoplay-media": ["4.10", "13.8"],
185
+ "axe:no-autoplay-audio": ["4.10"], "axe:blink": ["13.8"], "axe:marquee": ["13.8"],
186
+ };
187
+
188
+ async function main() {
189
+ const criteres = await source("criteres.json");
190
+ const glossaire = await source("glossaire.json");
191
+
192
+ const glossary = {};
193
+ for (const e of glossaire.glossary) glossary[slug(e.title)] = { title: e.title, body: deHtml(e.body) };
194
+
195
+ const themes = [];
196
+ const criteria = [];
197
+ for (const topic of criteres.topics) {
198
+ let count = 0;
199
+ for (const { criterium } of topic.criteria) {
200
+ const id = `${topic.number}.${criterium.number}`;
201
+ const wcag = new Set();
202
+ const techniques = new Set();
203
+ for (const ref of criterium.references || []) {
204
+ for (const w of ref.wcag || []) wcag.add(bareSc(w));
205
+ for (const t of ref.techniques || []) techniques.add(String(t));
206
+ }
207
+ criteria.push({
208
+ id,
209
+ theme: topic.number,
210
+ title: { fr: criterium.title },
211
+ titlePlain: { fr: plain(criterium.title) },
212
+ tests: criterium.tests || {},
213
+ techniques: [...techniques],
214
+ ...(criterium.technicalNote ? { technicalNote: toArr(criterium.technicalNote) } : {}),
215
+ ...(criterium.particularCases ? { particularCases: toArr(criterium.particularCases) } : {}),
216
+ wcag: [...wcag],
217
+ });
218
+ count++;
219
+ }
220
+ themes.push({ number: topic.number, name: { fr: topic.topic }, count });
221
+ }
222
+
223
+ // ---- Applicability: invert RULE_TO_CRITERIA and attach an explicit `appliesTo` to EVERY
224
+ // criterion (empty when no engine rule can evidence it, so an SC-sibling can never leak a
225
+ // foreign finding). Self-validate that each STATIC rule shares a WCAG SC with the criteria
226
+ // it's listed under (an inert entry is almost always a curation mistake). ----
227
+ const wcagById = new Map(criteria.map((c) => [c.id, new Set(c.wcag)]));
228
+ const critRules = {};
229
+ for (const [ruleId, critIds] of Object.entries(RULE_TO_CRITERIA)) {
230
+ const scs = RULE_SC[ruleId]; // undefined for axe:/dyn- (namespaced, not statically validated)
231
+ for (const cid of critIds) {
232
+ if (!wcagById.has(cid)) throw new Error(`RULE_TO_CRITERIA: rule "${ruleId}" → unknown RGAA criterion "${cid}"`);
233
+ if (scs && !scs.some((sc) => wcagById.get(cid).has(sc))) {
234
+ throw new Error(`RULE_TO_CRITERIA: "${ruleId}" (SC ${scs.join(",")}) under RGAA ${cid} (WCAG ${[...wcagById.get(cid)].join(",")}) — no shared SC, entry is inert`);
235
+ }
236
+ (critRules[cid] ||= new Set()).add(ruleId);
237
+ }
238
+ }
239
+ for (const c of criteria) c.appliesTo = { ruleIds: [...(critRules[c.id] ?? [])].sort() };
240
+
241
+ // ---- Declarative pack RULE (usage proof): an RGAA-only ADVISORY recommendation, run by
242
+ // the bounded interpreter (src/standards/pack-rules.ts) AFTER the core engine rules. It
243
+ // flags a download link (`a[href$=".pdf"]` & co) whose VISIBLE TEXT states neither the
244
+ // file format nor its weight — the DSFR/RGAA auditor recommendation under criterion 6.1
245
+ // (link explicitness, WCAG 2.4.4). ADVISORY: it surfaces as a recommendation in the RGAA
246
+ // projection and NEVER makes 6.1 non-conformant. This proves a pack ships its OWN
247
+ // detection without forking the engine (see skills/ultra11y/references/packs.md).
248
+ const DOWNLOAD_EXT = "pdf|docx?|pptx?|xlsx?|odt|ods|odp|rtf|csv|zip|rar|7z|gz|epub|mp3|mp4|avi|mov";
249
+ const rules = [
250
+ {
251
+ id: "download-link-format",
252
+ criterion: "6.1",
253
+ wcag: ["2.4.4"],
254
+ severity: "mineur",
255
+ advisory: true,
256
+ match: {
257
+ tag: "a",
258
+ attrs: [{ name: "href", op: "matches", value: `\\.(${DOWNLOAD_EXT})(\\?|#|$)` }],
259
+ text: { op: "lacks", value: `(${DOWNLOAD_EXT}|\\d+\\s*(ko|mo|go|kb|mb|gb|octets?|bytes?))` },
260
+ },
261
+ message: {
262
+ en: "Download link whose visible text states neither the file format nor its size.",
263
+ fr: "Lien de téléchargement dont l’intitulé ne précise ni le format ni le poids du fichier.",
264
+ },
265
+ remediation: {
266
+ en: "State the file format and size in the link text, e.g. “Annual report (PDF, 2 MB)”.",
267
+ fr: "Indiquez le format et le poids du fichier dans l’intitulé du lien, par exemple « Rapport annuel (PDF, 2 Mo) ».",
268
+ },
269
+ },
270
+ ];
271
+ // Wire each rule's namespaced id (`pack:rgaa:<id>`) into the criterion it reports under,
272
+ // so derivePackResults routes its finding onto that criterion through the same
273
+ // appliesTo/ruleMatches machinery as engine findings (6.1 evidences no engine rule).
274
+ for (const rule of rules) {
275
+ const c = criteria.find((x) => x.id === rule.criterion);
276
+ if (!c) throw new Error(`build-pack-rgaa: rule "${rule.id}" reports under unknown criterion "${rule.criterion}"`);
277
+ c.appliesTo = { ruleIds: [...new Set([...c.appliesTo.ruleIds, `pack:rgaa:${rule.id}`])].sort() };
278
+ }
279
+
280
+ const pack = {
281
+ key: "rgaa",
282
+ name: "RGAA",
283
+ fullName: "Référentiel général d’amélioration de l’accessibilité",
284
+ org: "DINUM",
285
+ country: "FR",
286
+ baseVersion: "4.1.2",
287
+ wcagVersion: String(criteres.wcag.version),
288
+ locales: ["fr"],
289
+ defaultLocale: "fr",
290
+ license: "Licence Ouverte / Etalab 2.0",
291
+ source: "https://github.com/DISIC/accessibilite.numerique.gouv.fr",
292
+ attribution: "RGAA 4.1.2 © DINUM (Direction interministérielle du numérique) — Licence Ouverte / Etalab 2.0",
293
+ idPattern: "^\\d+\\.\\d+$",
294
+ // Auditor-display vocabulary (FR): the nouns an RGAA auditor reads. Rendered by the
295
+ // `prd` auditor block + GitHub issues; see src/standards/vocabulary.ts.
296
+ vocabulary: {
297
+ theme: { fr: "Thématique" },
298
+ criterion: { fr: "Critère" },
299
+ test: { fr: "Test" },
300
+ conformant: { fr: "Conforme (C)" },
301
+ nonConformant: { fr: "Non conforme (NC)" },
302
+ notApplicable: { fr: "Non applicable (NA)" },
303
+ auditorHeading: { fr: "Critère d’accessibilité" },
304
+ },
305
+ // Normative page-sample methodology (RGAA): the REQUIRED page KINDS a real audit must
306
+ // cover — its representative sample. Standard-agnostic sample MECHANICS live in the core
307
+ // (src/sample.ts + Ultra11yConfig.sample); this carries only RGAA's own required-kinds
308
+ // list. Drives the advisory `sample check` / `scan --sample` lint (fuzzy match on a
309
+ // page's name/notes/url — short keywords match whole words only), never a hard gate.
310
+ // Keywords are accent-insensitive, fr + en. Ambiguous single words are deliberately NOT
311
+ // keywords ("plan" alone credited "Plan de formation" to plan-du-site; "support"
312
+ // credited "Support RH" to aide) — the multi-word canonical phrases carry those kinds.
313
+ sampleMethodology: {
314
+ requiredKinds: [
315
+ { id: "accueil", label: { fr: "Page d’accueil" }, keywords: ["accueil", "home", "index", "racine", "homepage"] },
316
+ { id: "contact", label: { fr: "Contact" }, keywords: ["contact", "nous contacter", "nous ecrire", "coordonnees"] },
317
+ { id: "mentions-legales", label: { fr: "Mentions légales" }, keywords: ["mentions legales", "mentions", "legal notice", "legal"] },
318
+ {
319
+ id: "declaration-accessibilite",
320
+ label: { fr: "Déclaration d’accessibilité" },
321
+ keywords: ["declaration d accessibilite", "declaration accessibilite", "accessibilite", "accessibility statement", "accessibility"],
322
+ },
323
+ { id: "plan-du-site", label: { fr: "Plan du site" }, keywords: ["plan du site", "sitemap", "site map"] },
324
+ { id: "aide", label: { fr: "Aide" }, keywords: ["aide", "help", "faq", "assistance"] },
325
+ {
326
+ id: "authentification",
327
+ label: { fr: "Authentification" },
328
+ keywords: ["authentification", "authentication", "connexion", "identification", "login", "log in", "sign in", "se connecter", "auth"],
329
+ },
330
+ {
331
+ id: "pages-representatives",
332
+ label: { fr: "Pages représentatives" },
333
+ keywords: ["representative", "representatif", "representatives", "gabarit", "template", "modele", "formulaire", "recherche", "resultats"],
334
+ },
335
+ {
336
+ id: "elements-transverses",
337
+ label: { fr: "Éléments transverses" },
338
+ keywords: ["transverse", "transversaux", "en-tete", "entete", "header", "navigation", "menu", "pied de page", "footer"],
339
+ },
340
+ ],
341
+ },
342
+ // Opt-in SECONDARY crosswalk mapping (Task 13): the live-region probe keys on WCAG 4.1.3,
343
+ // whose WCAG-faithful RGAA home is 7.5 (status messages). Ara additionally classifies the
344
+ // same finding under 7.4 (change of context). That is a DELIBERATE deviation from the SC
345
+ // crosswalk, so it ships DISABLED — the default projection stays WCAG-faithful (7.5 only).
346
+ // Enable per-project via `.ultra11yrc.json`:
347
+ // { "secondaryMappings": [{ "standard": "rgaa", "ruleId": "dyn-live-region", "criterion": "7.4" }] }
348
+ // (see src/standards/types.ts SecondaryMapping + src/config.ts). EXACT-ruleId match means
349
+ // the other 4.1.3 rules (status-message-not-assertive, live-region-conflict) never cross over.
350
+ secondaryMappings: [
351
+ {
352
+ ruleId: "dyn-live-region",
353
+ criterion: "7.4",
354
+ note: {
355
+ fr: "Relève aussi de 7.4 (changement de contexte) selon le classement Ara ; projection WCAG-fidèle = 7.5.",
356
+ en: "Also classified under 7.4 (change of context) per Ara; the WCAG-faithful projection is 7.5.",
357
+ },
358
+ enabled: false,
359
+ },
360
+ ],
361
+ rules,
362
+ themes,
363
+ criteria,
364
+ };
365
+
366
+ const packText = biomeFormat(JSON.stringify(pack, null, 2) + "\n", "src/data/standards/rgaa.json");
367
+ const glossaryText = biomeFormat(JSON.stringify(glossary, null, 2) + "\n", "src/data/standards/rgaa.glossary.json");
368
+
369
+ if (doCheck) {
370
+ const packPath = join(OUT, "rgaa.json");
371
+ const glossaryPath = join(OUT, "rgaa.glossary.json");
372
+ const drift = [];
373
+ if (!existsSync(packPath) || readFileSync(packPath, "utf8") !== packText) drift.push("src/data/standards/rgaa.json");
374
+ if (!existsSync(glossaryPath) || readFileSync(glossaryPath, "utf8") !== glossaryText)
375
+ drift.push("src/data/standards/rgaa.glossary.json");
376
+ if (drift.length > 0) {
377
+ console.error(`build-pack-rgaa --check: OUT OF DATE vs vendored source — re-run \`pnpm run build:pack:rgaa\`: ${drift.join(", ")}`);
378
+ process.exit(1);
379
+ }
380
+ console.log("build-pack-rgaa --check: src/data/standards/rgaa.json and rgaa.glossary.json match the vendored source.");
381
+ return;
382
+ }
383
+
384
+ mkdirSync(OUT, { recursive: true });
385
+ writeFileSync(join(OUT, "rgaa.json"), packText);
386
+ writeFileSync(join(OUT, "rgaa.glossary.json"), glossaryText);
387
+
388
+ const noWcag = criteria.filter((c) => c.wcag.length === 0).map((c) => c.id);
389
+ console.log(`build-pack-rgaa: ${themes.length} themes, ${criteria.length} criteria → src/data/standards/rgaa.json`);
390
+ console.log(`build-pack-rgaa: glossary ${Object.keys(glossary).length} entries → src/data/standards/rgaa.glossary.json`);
391
+ console.log(`build-pack-rgaa: criteria with no WCAG mapping (pack-local): ${noWcag.length ? noWcag.join(", ") : "none"} (${noWcag.length})`);
392
+ }
393
+
394
+ main().catch((e) => {
395
+ console.error(e);
396
+ process.exit(1);
397
+ });