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
package/src/axe-map.ts ADDED
@@ -0,0 +1,294 @@
1
+ // Curated axe-core rule → WCAG 2.2 success-criterion mapping. axe emits native
2
+ // `wcag<abc>` tags per rule, so this curated map only carries deliberate choices
3
+ // (e.g. color-contrast → 1.4.3, the needs-rendering SC the static engine can't
4
+ // decide); everything else falls back to axe's own wcag tags, then to 4.1.2.
5
+ import type { DynamicEngine, Severity } from "./types.js";
6
+ import { hasSC } from "./wcag.js";
7
+
8
+ // The local runtime's bespoke probes (scan --local) → the WCAG SC each evidences.
9
+ // These are the needs-rendering criteria axe-core does not decide on its own; the
10
+ // probe raises a definite NC only when it OBSERVES the failure in the rendered page
11
+ // (a clean probe leaves the SC `manual`, never silently Conforming).
12
+ export const PROBE_WCAG: Record<Exclude<DynamicEngine, "axe" | "reflow">, string> = {
13
+ "focus-visible": "2.4.7", // Focus Visible — focusing a control produces no visible change
14
+ "reflow-zoom": "1.4.4", // Resize Text / 200% zoom — content clipped/lost when enlarged
15
+ "text-spacing": "1.4.12", // Text Spacing — clipping/overlap under the WCAG spacing override
16
+ hover: "1.4.13", // Content on Hover or Focus — not dismissible/hoverable/persistent
17
+ // Stateful input-overflow probes — a FILLED input whose typed value is clipped/unreadable
18
+ // under a stress. Same defect, one SC per stress (so each folds onto the SC that stress
19
+ // actually evidences), so they stay unambiguous cross-standard (RGAA 10.11/10.4/10.12).
20
+ "input-overflow-reflow": "1.4.10", // typed value clipped at 320px width
21
+ "input-overflow-zoom": "1.4.4", // typed value clipped at 200% zoom
22
+ "input-overflow-spacing": "1.4.12", // typed value clipped under the WCAG text-spacing override
23
+ "live-region": "4.1.3", // Status Messages — content updated by an interaction outside any live region
24
+ };
25
+
26
+ // Probe severities are deliberately conservative: focus + zoom are real blockers
27
+ // (majeur); spacing/hover are heuristic, so minor by default (tunable).
28
+ export const PROBE_SEVERITY: Record<Exclude<DynamicEngine, "axe" | "reflow">, Severity> = {
29
+ "focus-visible": "majeur",
30
+ "reflow-zoom": "majeur",
31
+ "text-spacing": "mineur",
32
+ hover: "mineur",
33
+ // Input-overflow is a directly OBSERVED loss of content (the value the user typed is no
34
+ // longer visible), not a heuristic — the human auditor confirmed the real instance class
35
+ // — so it is majeur on every stress (incl. text-spacing, unlike the generic text-spacing
36
+ // probe which stays mineur). live-region is an honest heuristic → mineur.
37
+ "input-overflow-reflow": "majeur",
38
+ "input-overflow-zoom": "majeur",
39
+ "input-overflow-spacing": "majeur",
40
+ "live-region": "mineur",
41
+ };
42
+
43
+ export const AXE_WCAG: Record<string, string> = {
44
+ // images
45
+ "image-alt": "1.1.1",
46
+ "input-image-alt": "1.1.1",
47
+ "area-alt": "1.1.1",
48
+ "role-img-alt": "1.1.1",
49
+ "image-redundant-alt": "1.1.1",
50
+ "svg-img-alt": "1.1.1",
51
+ // frames
52
+ "frame-title": "4.1.2",
53
+ "frame-title-unique": "4.1.2",
54
+ // colour (the headline dynamic win)
55
+ "color-contrast": "1.4.3",
56
+ "color-contrast-enhanced": "1.4.3",
57
+ "link-in-text-block": "1.4.1",
58
+ // tables / structure
59
+ "td-headers-attr": "1.3.1",
60
+ "th-has-data-cells": "1.3.1",
61
+ "scope-attr-valid": "1.3.1",
62
+ "table-fake-caption": "1.3.1",
63
+ "td-has-header": "1.3.1",
64
+ "empty-table-header": "1.3.1",
65
+ // links & buttons
66
+ "link-name": "2.4.4",
67
+ "button-name": "4.1.2",
68
+ "input-button-name": "4.1.2",
69
+ // scripts / ARIA
70
+ "aria-allowed-attr": "4.1.2",
71
+ "aria-allowed-role": "4.1.2",
72
+ "aria-command-name": "4.1.2",
73
+ "aria-hidden-body": "4.1.2",
74
+ "aria-hidden-focus": "4.1.2",
75
+ "aria-input-field-name": "4.1.2",
76
+ "aria-required-attr": "4.1.2",
77
+ "aria-required-children": "4.1.2",
78
+ "aria-required-parent": "4.1.2",
79
+ "aria-roles": "4.1.2",
80
+ "aria-toggle-field-name": "4.1.2",
81
+ "aria-valid-attr": "4.1.2",
82
+ "aria-valid-attr-value": "4.1.2",
83
+ "nested-interactive": "4.1.2",
84
+ "aria-tooltip-name": "4.1.2",
85
+ "aria-meter-name": "4.1.2",
86
+ "aria-progressbar-name": "4.1.2",
87
+ "aria-dialog-name": "4.1.2",
88
+ "presentation-role-conflict": "4.1.2",
89
+ "duplicate-id-aria": "4.1.2",
90
+ // mandatory elements / language
91
+ "duplicate-id": "4.1.2",
92
+ "duplicate-id-active": "4.1.2",
93
+ "html-has-lang": "3.1.1",
94
+ "html-xml-lang-mismatch": "3.1.1",
95
+ "html-lang-valid": "3.1.1",
96
+ "valid-lang": "3.1.2",
97
+ "document-title": "2.4.2",
98
+ // structure
99
+ "heading-order": "1.3.1",
100
+ "empty-heading": "1.3.1",
101
+ "page-has-heading-one": "1.3.1",
102
+ list: "1.3.1",
103
+ listitem: "1.3.1",
104
+ "definition-list": "1.3.1",
105
+ dlitem: "1.3.1",
106
+ "landmark-one-main": "1.3.1",
107
+ region: "1.3.1",
108
+ // presentation / zoom
109
+ "meta-viewport": "1.4.4",
110
+ "meta-viewport-large": "1.4.4",
111
+ // forms
112
+ label: "4.1.2",
113
+ "form-field-multiple-labels": "4.1.2",
114
+ "select-name": "4.1.2",
115
+ "label-title-only": "4.1.2",
116
+ "autocomplete-valid": "1.3.5",
117
+ fieldset: "1.3.1",
118
+ // multimedia
119
+ "audio-caption": "1.2.2",
120
+ "video-caption": "1.2.2",
121
+ "no-autoplay-audio": "1.4.2",
122
+ blink: "2.2.2",
123
+ marquee: "2.2.2",
124
+ // navigation
125
+ tabindex: "2.4.3",
126
+ "skip-link": "2.4.1",
127
+ bypass: "2.4.1",
128
+ accesskeys: "2.1.1",
129
+ };
130
+
131
+ export const FALLBACK_SC = "4.1.2";
132
+
133
+ /** Read a WCAG SC from axe's native rule tags. axe tags each rule with `wcag<abc>`
134
+ * (e.g. "wcag111" → 1.1.1, "wcag1410" → 1.4.10) plus level umbrellas ("wcag2aa",
135
+ * "wcag22aa", "best-practice") that carry no SC and are skipped. Returns the first
136
+ * tag that resolves to a real SC in the WCAG 2.2 AA core (so AAA tags are ignored). */
137
+ export function scFromWcagTags(tags: string[] | undefined): string | null {
138
+ if (!tags) return null;
139
+ for (const t of tags) {
140
+ const m = /^wcag(\d)(\d)(\d+)$/.exec(t);
141
+ if (m) {
142
+ const sc = `${m[1]}.${m[2]}.${m[3]}`;
143
+ if (hasSC(sc)) return sc;
144
+ }
145
+ }
146
+ return null;
147
+ }
148
+
149
+ /** Map an axe impact to an ultra11y severity. */
150
+ export function severityFromImpact(impact: string | null | undefined): Severity {
151
+ switch (impact) {
152
+ case "critical":
153
+ case "serious":
154
+ return "bloquant";
155
+ case "moderate":
156
+ return "majeur";
157
+ default:
158
+ return "mineur";
159
+ }
160
+ }
161
+
162
+ export function scForAxeRule(ruleId: string): string {
163
+ return AXE_WCAG[ruleId] ?? FALLBACK_SC;
164
+ }
165
+
166
+ // Curated overrides of the tag-based advisory decision, keyed by axe rule id. Forces a
167
+ // rule to (non-)advisory regardless of its tags — a deliberate escape hatch for the rare
168
+ // case where axe's tagging disagrees with the normativity we want. `true` ⇒ always
169
+ // advisory, `false` ⇒ always normative.
170
+ //
171
+ // CROSS-CHANNEL NORMATIVITY CONSISTENCY: the tag-based `axeAdvisory` heuristic treats
172
+ // any axe rule with no `wcag<digits>` tag as best-practice-only (advisory). But this
173
+ // engine ALSO has a static counterpart for several of these rules (src/rules/*.ts) that
174
+ // we ship as NORMATIVE — the same defect must not be a definite NC when found statically
175
+ // (`audit`) yet a mere recommendation when found dynamically (`scan`/axe). The pin is a
176
+ // statement of INTENT, not a snapshot of axe's tagging: this defect has a normative
177
+ // static twin, so it must stay normative cross-channel REGARDLESS of how axe currently
178
+ // tags the rule. That drift-proofs the decision — the docker runner installs axe-core
179
+ // from a caret range ("^4.10.0"), so the tag set a user's build ships can change with no
180
+ // repo change; a pin can't be silently downgraded by such a re-tag, whereas relying on
181
+ // the tags could. The twin ids are documented inline and cross-checked by
182
+ // tests/axe-map.test.ts against the live registry; that guard is two-directional for
183
+ // EXACT-id matches (an AXE_WCAG key that is also a registered normative static rule id
184
+ // MUST be pinned). Near-twins whose ids differ (heading-order vs heading-order-skip…)
185
+ // are still curated by hand — a future differently-named twin is caught at review time,
186
+ // not by the test.
187
+ // - heading-order → static twin heading-order-skip (src/rules/headings.ts)
188
+ // - tabindex → static twin positive-tabindex (src/rules/navigation.ts)
189
+ // - skip-link → static twin skip-link-target-missing (src/rules/navigation.ts)
190
+ // - label-title-only → static twin control-name-title-only (src/rules/links.ts)
191
+ // - landmark-one-main → static twins missing-main-landmark /
192
+ // multiple-main-landmark (src/rules/navigation.ts)
193
+ // - empty-heading → static twin empty-heading (src/rules/headings.ts,
194
+ // identical rule id; the axe rule is best-practice-only tagged)
195
+ // - duplicate-id → static twin duplicate-id (src/rules/mandatory.ts,
196
+ // identical rule id; deprecated by axe-core ≥ 4.10 — harmless
197
+ // pin if the installed axe never fires it)
198
+ // - nested-interactive → static twin nested-interactive (src/rules/scripts-aria.ts,
199
+ // identical rule id)
200
+ // - form-field-multiple-labels → static twin of the same id (src/rules/forms.ts)
201
+ // - aria-required-children → static twin of the same id (src/rules/scripts-aria.ts)
202
+ // - aria-required-attr → static twin of the same id (src/rules/scripts-aria.ts)
203
+ // - aria-valid-attr → static twin invalid-aria-attr (src/rules/scripts-aria.ts)
204
+ // - aria-valid-attr-value → static twin invalid-aria-value (src/rules/scripts-aria.ts)
205
+ // - aria-required-parent → static twin of the same id (src/rules/scripts-aria.ts)
206
+ // - autocomplete-valid → static twin autocomplete-token-invalid (src/rules/forms.ts)
207
+ // - label-content-name-mismatch → static twin label-in-name-mismatch (src/rules/links.ts)
208
+ // - td-headers-attr → static twin headers-attr-dangling (src/rules/tables.ts)
209
+ // - th-has-data-cells → static twin th-no-data-cells (src/rules/tables.ts)
210
+ // - definition-list / dlitem → static twin dl-structure (src/rules/headings.ts)
211
+ // Deliberately NOT pinned (stay advisory, tag-based decision applies): empty-table-header,
212
+ // page-has-heading-one (consistent with h1-missing/h1-multiple now being advisory — see
213
+ // src/rules/headings.ts), region, accesskeys, image-redundant-alt — none of these has a
214
+ // normative static twin in this engine. Near-twins judged DIFFERENT defects (also not
215
+ // pinned): td-has-header / table-fake-caption vs the static data-table-no-headers /
216
+ // table-caption-missing — a data cell missing its per-cell header association vs a table
217
+ // with no headers at all, and a caption faked with styled markup vs a caption missing
218
+ // entirely.
219
+ export const AXE_ADVISORY_EXCEPTIONS: Record<string, boolean> = {
220
+ "heading-order": false,
221
+ tabindex: false,
222
+ "skip-link": false,
223
+ "label-title-only": false,
224
+ "landmark-one-main": false,
225
+ "empty-heading": false,
226
+ "duplicate-id": false,
227
+ "nested-interactive": false,
228
+ "form-field-multiple-labels": false,
229
+ "aria-required-children": false,
230
+ "aria-required-attr": false,
231
+ "aria-required-parent": false,
232
+ "aria-valid-attr": false,
233
+ "aria-valid-attr-value": false,
234
+ "autocomplete-valid": false,
235
+ "label-content-name-mismatch": false,
236
+ "td-headers-attr": false,
237
+ "th-has-data-cells": false,
238
+ "definition-list": false,
239
+ dlitem: false,
240
+ };
241
+
242
+ // Data companion to AXE_ADVISORY_EXCEPTIONS: which static rule id(s) (src/rules/*.ts,
243
+ // registered in src/rules/registry.ts) evidence the "same defect" as each pinned axe
244
+ // rule above. Kept as data (not just prose) so tests/axe-map.test.ts can DERIVE the
245
+ // consistency check — assert every twin is actually registered and normative — instead
246
+ // of hand-duplicating (and risking drift from) this table.
247
+ export const AXE_STATIC_TWIN: Record<string, string[]> = {
248
+ "heading-order": ["heading-order-skip"],
249
+ tabindex: ["positive-tabindex"],
250
+ "skip-link": ["skip-link-target-missing"],
251
+ "label-title-only": ["control-name-title-only"],
252
+ "landmark-one-main": ["missing-main-landmark", "multiple-main-landmark"],
253
+ "empty-heading": ["empty-heading"],
254
+ "duplicate-id": ["duplicate-id"],
255
+ "aria-required-attr": ["aria-required-attr"],
256
+ "aria-required-parent": ["aria-required-parent"],
257
+ "aria-valid-attr": ["invalid-aria-attr"],
258
+ "aria-valid-attr-value": ["invalid-aria-value"],
259
+ "autocomplete-valid": ["autocomplete-token-invalid"],
260
+ "label-content-name-mismatch": ["label-in-name-mismatch"],
261
+ "td-headers-attr": ["headers-attr-dangling"],
262
+ "th-has-data-cells": ["th-no-data-cells"],
263
+ "definition-list": ["dl-structure"],
264
+ dlitem: ["dl-structure"],
265
+ "nested-interactive": ["nested-interactive"],
266
+ "form-field-multiple-labels": ["form-field-multiple-labels"],
267
+ "aria-required-children": ["aria-required-children"],
268
+ };
269
+
270
+ /** A best-practice-only axe violation carries tags but NO `wcag<digits>` SC tag (only
271
+ * umbrellas like `wcag2aa`/`best-practice`/`cat.*`) — it evidences no testable WCAG
272
+ * success criterion, so it must fold as an ADVISORY recommendation, never a criterion NC.
273
+ * Covers `empty-table-header`, `page-has-heading-one`, `landmark-one-main`, `region`, …
274
+ * The `\d+` requires an ALL-DIGIT suffix, so umbrella tags ("wcag2aa", "wcag22aa") never
275
+ * count as a real SC tag. A TAG-LESS violation (no evidence either way — real axe always
276
+ * tags, so this is only a hand-built input) stays NORMATIVE: we never silently downgrade
277
+ * a finding to advisory without positive best-practice evidence. */
278
+ export function axeAdvisory(tags: string[] | undefined): boolean {
279
+ const t = tags ?? [];
280
+ if (t.length === 0) return false;
281
+ return !t.some((tag) => /^wcag\d+$/.test(tag));
282
+ }
283
+
284
+ /** Resolve whether an axe violation is advisory: the curated exception wins, else the
285
+ * tag-based `axeAdvisory` decides. This is what src/scan.ts stamps onto its findings. */
286
+ export function isAxeAdvisory(ruleId: string, tags: string[] | undefined): boolean {
287
+ return AXE_ADVISORY_EXCEPTIONS[ruleId] ?? axeAdvisory(tags);
288
+ }
289
+
290
+ /** Resolve an axe rule to a WCAG SC: the curated map first (deliberate, e.g.
291
+ * color-contrast → 1.4.3), then axe's own wcag tags, then the 4.1.2 fallback. */
292
+ export function scForAxe(ruleId: string, tags?: string[]): string {
293
+ return AXE_WCAG[ruleId] ?? scFromWcagTags(tags) ?? FALLBACK_SC;
294
+ }
@@ -0,0 +1,75 @@
1
+ // Regression-only gating for the repo automation (`init` hook / CI). A finding's
2
+ // identity is (ruleId, criteriaId, file, source-range) — NOT line/col (drifts on
3
+ // every edit) and NOT selectorHint (ambiguous). NEW findings are those in the
4
+ // current audit absent from the committed baseline; the gate fails only on new
5
+ // findings at/above `--fail-on`, so the existing backlog never blocks a commit.
6
+ import type { AuditResult, Finding, Severity } from "./types.js";
7
+
8
+ const RANK: Record<Severity, number> = { bloquant: 0, majeur: 1, mineur: 2 };
9
+
10
+ /** Stable identity surviving line drift: prefer source byte range, else fall back
11
+ * to line/col + selector (JSX/stdin findings, which carry no offsets). */
12
+ export function findingId(f: Finding): string {
13
+ const pos = f.sourceStart !== undefined ? `b${f.sourceStart}-${f.sourceEnd}` : `l${f.line}:${f.col}:${f.selectorHint}`;
14
+ return `${f.ruleId}|${f.criteriaId}|${f.file}|${pos}`;
15
+ }
16
+
17
+ // Accept English aliases (blocking|major|minor) on input — the tool is English-first —
18
+ // while the internal Severity union stays fr (bloquant|majeur|mineur). STRICT: an
19
+ // unrecognized value returns null (the caller reports and exits) rather than silently
20
+ // degrading to "bloquant" — a `--fail-on majr` typo must not weaken the gate.
21
+ export function parseFailOn(v: string | boolean | undefined): Severity | null {
22
+ if (v === undefined || v === true) return "bloquant"; // flag absent, or present with no value → default
23
+ if (v === "bloquant" || v === "blocking") return "bloquant";
24
+ if (v === "majeur" || v === "major") return "majeur";
25
+ if (v === "mineur" || v === "minor") return "mineur";
26
+ return null; // unrecognized token
27
+ }
28
+
29
+ /** Findings at or above a severity threshold — the standalone `audit --fail-on`
30
+ * gate (no baseline): unlike diffAgainstBaseline this counts ALL findings, not
31
+ * just newly-introduced ones. */
32
+ export function findingsAtOrAbove(findings: Finding[], failOn: Severity): Finding[] {
33
+ return findings.filter((f) => RANK[f.severity] <= RANK[failOn]);
34
+ }
35
+
36
+ export interface BaselineDiff {
37
+ newFindings: Finding[];
38
+ fixedFindings: Finding[];
39
+ ok: boolean; // no NEW finding at/above failOn
40
+ failOn: Severity;
41
+ }
42
+
43
+ export function diffAgainstBaseline(current: AuditResult, baseline: AuditResult | null, failOn: Severity = "bloquant"): BaselineDiff {
44
+ const baseIds = new Set((baseline?.findings ?? []).map(findingId));
45
+ const curIds = new Set(current.findings.map(findingId));
46
+ const newFindings = current.findings.filter((f) => !baseIds.has(findingId(f)));
47
+ // "fixed" is informational; scope it to files the current run actually touched
48
+ // (present in current findings) so unchanged files in --changed mode are never
49
+ // mis-reported as "fixed".
50
+ const auditedFiles = new Set(current.findings.map((f) => f.file));
51
+ const fixedFindings = (baseline?.findings ?? []).filter((f) => auditedFiles.has(f.file) && !curIds.has(findingId(f)));
52
+ const ok = !newFindings.some((f) => RANK[f.severity] <= RANK[failOn]);
53
+ return { newFindings, fixedFindings, ok, failOn };
54
+ }
55
+
56
+ /** Human gate report for the CLI / hook output. */
57
+ export function baselineSummary(diff: BaselineDiff, lang: "fr" | "en" = "fr"): string {
58
+ const out: string[] = [];
59
+ const blocking = diff.newFindings.filter((f) => RANK[f.severity] <= RANK[diff.failOn]);
60
+ if (diff.ok) {
61
+ out.push(
62
+ lang === "fr"
63
+ ? `✓ Aucune nouvelle non-conformité ≥ ${diff.failOn} (${diff.newFindings.length} nouvelle(s) au total, ${diff.fixedFindings.length} corrigée(s)).`
64
+ : `✓ No new non-conformity ≥ ${diff.failOn} (${diff.newFindings.length} new total, ${diff.fixedFindings.length} fixed).`,
65
+ );
66
+ } else {
67
+ out.push(
68
+ lang === "fr"
69
+ ? `✗ ${blocking.length} nouvelle(s) non-conformité(s) ≥ ${diff.failOn} introduite(s) :`
70
+ : `✗ ${blocking.length} new non-conformity(ies) ≥ ${diff.failOn} introduced:`,
71
+ );
72
+ for (const f of blocking) out.push(` [${f.severity}] ${f.ruleId} (WCAG ${f.criteriaId}) — ${f.file}:${f.line} (${f.selectorHint})`);
73
+ }
74
+ return out.join("\n");
75
+ }
package/src/capture.ts ADDED
@@ -0,0 +1,182 @@
1
+ // Rendered-DOM "captures": real HTML serialized from a component render (a test or a
2
+ // build) so the static engine can audit the TRUE markup a component library / SFC
3
+ // emits — the output that lives in node_modules at runtime and is invisible to
4
+ // source analysis. A capture carries a leading provenance comment
5
+ // <!-- ultra11y:capture v="1" source="src/Button.tsx" component="Button" test="…" name="…" -->
6
+ // linking the serialized DOM back to the source component. This module parses that
7
+ // provenance; coverage (which components have a capture) lives alongside it.
8
+ //
9
+ // NOTE: the harvester that WRITES captures is generated by captureSetup() in
10
+ // src/render.ts and written to `.ultra11y/capture-setup.mjs` in the target repo (by
11
+ // `render --setup`) — a separate, dependency-free module. It carries its own copy of
12
+ // the escaping below (it cannot import from src at runtime), so keep the two schemes
13
+ // byte-identical — tests/capture-escape-sync.test.ts locks this.
14
+ import type { CaptureProvenance } from "./parse/html.js";
15
+ import type { DepGraph } from "./graph/graph.js";
16
+ import type { ComponentDef } from "./graph/imports.js";
17
+ import type { Finding } from "./types.js";
18
+ import { toPosix, expandInputs } from "./glob.js";
19
+ import { readText } from "./util.js";
20
+
21
+ // A capture's provenance comment must contain no literal `"` (it would end an
22
+ // attribute value early) and no `--` digraph (it would end the HTML comment early).
23
+ // We entity-escape both — plus `& < >` — so the value round-trips and the surrounding
24
+ // file stays valid HTML. Single hyphens are preserved so kebab paths stay readable.
25
+ export function escapeCommentValue(s: string): string {
26
+ return s.replace(/&/g, "&amp;").replace(/"/g, "&quot;").replace(/</g, "&lt;").replace(/>/g, "&gt;").replace(/--/g, "&#45;&#45;");
27
+ }
28
+
29
+ export function unescapeCommentValue(s: string): string {
30
+ return s
31
+ .replace(/&#45;/g, "-")
32
+ .replace(/&gt;/g, ">")
33
+ .replace(/&lt;/g, "<")
34
+ .replace(/&quot;/g, '"')
35
+ .replace(/&amp;/g, "&");
36
+ }
37
+
38
+ /** Build a `<!-- ultra11y:capture … -->` provenance comment (escaped) — the inverse of
39
+ * parseCaptureProvenance. Used by producers that attribute HTML after the fact (e.g. the
40
+ * Storybook post-processor); the zero-touch harvester inlines the same scheme. */
41
+ export function formatCaptureComment(prov: CaptureProvenance): string {
42
+ const attrs = [`v="${prov.v || 1}"`];
43
+ if (prov.sourceFile) attrs.push(`source="${escapeCommentValue(prov.sourceFile)}"`);
44
+ if (prov.component) attrs.push(`component="${escapeCommentValue(prov.component)}"`);
45
+ if (prov.test) attrs.push(`test="${escapeCommentValue(prov.test)}"`);
46
+ if (prov.name) attrs.push(`name="${escapeCommentValue(prov.name)}"`);
47
+ return `<!-- ultra11y:capture ${attrs.join(" ")} -->`;
48
+ }
49
+
50
+ // Only the head of the file can hold the marker (the harvester writes it first), so
51
+ // scan a bounded prefix — never the whole (potentially large) capture.
52
+ const SCAN_LIMIT = 4096;
53
+ const MARKER = /<!--\s*ultra11y:capture\b([\s\S]*?)-->/;
54
+ const PAIR = /(\w+)="([^"]*)"/g;
55
+
56
+ /** Parse a capture's provenance comment, or return undefined when absent — so
57
+ * Storybook / manual dumps with no marker still audit as ordinary HTML. */
58
+ export function parseCaptureProvenance(source: string): CaptureProvenance | undefined {
59
+ const head = source.length > SCAN_LIMIT ? source.slice(0, SCAN_LIMIT) : source;
60
+ const m = MARKER.exec(head);
61
+ if (!m) return undefined;
62
+ const body = m[1] ?? "";
63
+ const kv = new Map<string, string>();
64
+ PAIR.lastIndex = 0;
65
+ for (let p = PAIR.exec(body); p; p = PAIR.exec(body)) {
66
+ kv.set(p[1] as string, unescapeCommentValue(p[2] as string));
67
+ }
68
+ const vRaw = kv.get("v");
69
+ const v = vRaw ? Number.parseInt(vRaw, 10) : 1;
70
+ return {
71
+ v: Number.isFinite(v) ? v : 1,
72
+ ...(kv.has("source") ? { sourceFile: kv.get("source") } : {}),
73
+ ...(kv.has("component") ? { component: kv.get("component") } : {}),
74
+ ...(kv.has("test") ? { test: kv.get("test") } : {}),
75
+ ...(kv.has("name") ? { name: kv.get("name") } : {}),
76
+ };
77
+ }
78
+
79
+ // ---- coverage: which components have a rendered capture vs which remain blind spots
80
+
81
+ export interface CaptureEntry {
82
+ file: string; // the capture file's path
83
+ provenance?: CaptureProvenance; // absent for un-tagged dumps (Storybook/manual)
84
+ }
85
+
86
+ export interface CaptureCoverage {
87
+ total: number; // components in scope that warrant a capture
88
+ covered: string[]; // "posix/path#Component" keys with a matching capture
89
+ blindSpots: string[]; // component keys still audited from opaque source only
90
+ unattributed: number; // capture files with no resolvable source component
91
+ }
92
+
93
+ // Capture provenance is repo-relative ("src/Button.tsx"); graph node paths may be
94
+ // cwd-relative or absolute. Match on a path suffix so either side resolves the other.
95
+ function pathMatch(a: string, b: string): boolean {
96
+ return a === b || a.endsWith(`/${b}`) || b.endsWith(`/${a}`);
97
+ }
98
+
99
+ /** Cross-reference the component graph against the captures present. A component needs a
100
+ * capture when its runtime a11y can't be judged from source: it renders an intrinsic
101
+ * control (whose accessible name may be injected) OR lives in a file that renders
102
+ * component-LIBRARY components (the real markup lives in node_modules). */
103
+ export function computeCaptureCoverage(graph: DepGraph, captures: CaptureEntry[]): CaptureCoverage {
104
+ const sigs = captures
105
+ .map((c) => c.provenance)
106
+ .filter((p): p is CaptureProvenance => !!p?.sourceFile)
107
+ .map((p) => ({ sourceFile: toPosix(p.sourceFile as string), component: p.component }));
108
+ const covered: string[] = [];
109
+ const blindSpots: string[] = [];
110
+ const seen = new Set<string>();
111
+ for (const node of graph.nodes.values()) {
112
+ const opaque = node.opaqueComponents.length > 0;
113
+ for (const def of new Set<ComponentDef>(node.components.values())) {
114
+ if (!def.hasControl && !opaque) continue; // nothing rendered worth capturing
115
+ const key = `${node.file}#${def.name}`;
116
+ if (seen.has(key)) continue;
117
+ seen.add(key);
118
+ const isCovered = sigs.some((s) => pathMatch(s.sourceFile, node.file) && (!s.component || s.component === def.name));
119
+ (isCovered ? covered : blindSpots).push(key);
120
+ }
121
+ }
122
+ covered.sort();
123
+ blindSpots.sort();
124
+ return { total: covered.length + blindSpots.length, covered, blindSpots, unattributed: captures.filter((c) => !c.provenance?.sourceFile).length };
125
+ }
126
+
127
+ /** Read every capture (`.html`/`.htm`) under a directory into entries with parsed
128
+ * provenance — the full repo-wide capture set for coverage, independent of what any
129
+ * particular audit run scoped to. Returns [] when the directory is absent/empty. */
130
+ export function readCaptureDir(dir: string): CaptureEntry[] {
131
+ let files: string[];
132
+ try {
133
+ files = expandInputs([dir]);
134
+ } catch {
135
+ return [];
136
+ }
137
+ const out: CaptureEntry[] = [];
138
+ for (const f of files) {
139
+ if (!/\.html?$/i.test(f)) continue;
140
+ try {
141
+ out.push({ file: toPosix(f), provenance: parseCaptureProvenance(readText(f)) });
142
+ } catch {
143
+ /* unreadable — skip */
144
+ }
145
+ }
146
+ return out;
147
+ }
148
+
149
+ /** Capture files (under `captureDir`) whose provenance `sourceFile` matches one of
150
+ * `sourceFiles` (path suffix, see `pathMatch`) — the captures relevant to a set of
151
+ * changed/staged source files. Used by `runAudit`'s diff-scoped modes (--changed/
152
+ * --staged/--since): a capture is rarely itself part of the diff (the SOURCE
153
+ * changed; its already-committed capture usually didn't), so ingesting the whole
154
+ * captures dir there would be wrong scope, but skipping captures entirely leaves
155
+ * the diff-scoped audit blind to the real rendered DOM for the touched components. */
156
+ export function capturesForSources(captureDir: string, sourceFiles: string[]): string[] {
157
+ const wanted = sourceFiles.map(toPosix);
158
+ return readCaptureDir(captureDir)
159
+ .filter((e) => {
160
+ const src = e.provenance?.sourceFile;
161
+ return src ? wanted.some((w) => pathMatch(toPosix(src), w)) : false;
162
+ })
163
+ .map((e) => e.file);
164
+ }
165
+
166
+ /** Resolve `origin.sourceLine` for capture findings from the component graph, so reports
167
+ * anchor at the source component's definition line (not just the file). Mutates in place;
168
+ * no-op for findings already resolved or without a source. */
169
+ export function enrichCaptureOrigins(findings: Finding[], graph: DepGraph): void {
170
+ for (const f of findings) {
171
+ const o = f.origin;
172
+ if (!o?.sourceFile || o.sourceLine !== undefined) continue;
173
+ for (const node of graph.nodes.values()) {
174
+ if (!pathMatch(toPosix(o.sourceFile), node.file)) continue;
175
+ const def = (o.component ? node.components.get(o.component) : undefined) ?? node.components.values().next().value;
176
+ if (def) {
177
+ o.sourceLine = def.line;
178
+ break;
179
+ }
180
+ }
181
+ }
182
+ }