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.
- package/LICENSE +21 -0
- package/README.md +223 -0
- package/docker/Dockerfile +7 -0
- package/docker/package.json +9 -0
- package/docker/runner.mjs +23 -0
- package/package.json +73 -0
- package/scripts/bench.mjs +151 -0
- package/scripts/build-act-corpus.mjs +143 -0
- package/scripts/build-adjudication.mjs +503 -0
- package/scripts/build-pack-rgaa.mjs +397 -0
- package/scripts/build-standards.mjs +548 -0
- package/scripts/copy-bundle.mjs +39 -0
- package/scripts/import-pack.mjs +107 -0
- package/scripts/sync-engine.mjs +84 -0
- package/scripts/sync-version.mjs +43 -0
- package/scripts/ultra11y.mjs +52739 -0
- package/scripts/vendor/act-testcases.json +12297 -0
- package/scripts/vendor/rgaa/SOURCE.json +8 -0
- package/scripts/vendor/rgaa/criteres.json +4144 -0
- package/scripts/vendor/rgaa/glossaire.json +480 -0
- package/scripts/vendor/wcag-2.2-fr.json +82 -0
- package/scripts/vendor/wcag-2.2-sc-universe.json +950 -0
- package/scripts/vendor/wcag-2.2-sc.json +574 -0
- package/scripts/verify-rgaa-source.mjs +126 -0
- package/scripts/verify-skill-bundle.mjs +91 -0
- package/skills/review-a11y/SKILL.md +123 -0
- package/skills/review-a11y/scripts/ultra11y.mjs +52739 -0
- package/skills/ultra11y/SKILL.md +292 -0
- package/skills/ultra11y/references/act.md +163 -0
- package/skills/ultra11y/references/adjudication.md +599 -0
- package/skills/ultra11y/references/audit.md +100 -0
- package/skills/ultra11y/references/authoring.md +37 -0
- package/skills/ultra11y/references/automation.md +113 -0
- package/skills/ultra11y/references/correction.md +41 -0
- package/skills/ultra11y/references/criteria.md +129 -0
- package/skills/ultra11y/references/cross-file.md +82 -0
- package/skills/ultra11y/references/dynamic.md +152 -0
- package/skills/ultra11y/references/false-positives.md +81 -0
- package/skills/ultra11y/references/fix.md +46 -0
- package/skills/ultra11y/references/focus-and-logic.md +64 -0
- package/skills/ultra11y/references/forbidden-patterns.md +61 -0
- package/skills/ultra11y/references/forms-and-errors.md +147 -0
- package/skills/ultra11y/references/guidance.md +55 -0
- package/skills/ultra11y/references/judgment.md +90 -0
- package/skills/ultra11y/references/media-and-motion.md +116 -0
- package/skills/ultra11y/references/methodology.md +72 -0
- package/skills/ultra11y/references/naming.md +117 -0
- package/skills/ultra11y/references/orchestration.md +44 -0
- package/skills/ultra11y/references/packs.md +193 -0
- package/skills/ultra11y/references/prd.md +104 -0
- package/skills/ultra11y/references/rendered.md +110 -0
- package/skills/ultra11y/references/scale.md +49 -0
- package/skills/ultra11y/references/standards.md +97 -0
- package/skills/ultra11y/references/structure.md +138 -0
- package/skills/ultra11y/references/verify.md +88 -0
- package/skills/ultra11y/references/widgets.md +106 -0
- package/skills/ultra11y/scripts/ultra11y.mjs +52739 -0
- package/src/act.ts +441 -0
- package/src/adjudicate.ts +630 -0
- package/src/aria.ts +230 -0
- package/src/audit.ts +396 -0
- package/src/auditor.ts +399 -0
- package/src/autofill.ts +102 -0
- package/src/axe-map.ts +294 -0
- package/src/baseline.ts +75 -0
- package/src/capture.ts +182 -0
- package/src/check.ts +243 -0
- package/src/cli.ts +1927 -0
- package/src/color.ts +102 -0
- package/src/config.ts +182 -0
- package/src/crawl.ts +75 -0
- package/src/criteria.ts +183 -0
- package/src/data/adjudication.json +770 -0
- package/src/data/guidance/rgaa.json +2395 -0
- package/src/data/standards/rgaa.glossary.json +478 -0
- package/src/data/standards/rgaa.json +3454 -0
- package/src/data/wcag-universe.json +530 -0
- package/src/data/wcag.json +1178 -0
- package/src/discover.ts +203 -0
- package/src/fix/codemods.ts +76 -0
- package/src/fix/diff.ts +64 -0
- package/src/fix/edits.ts +126 -0
- package/src/fix.ts +215 -0
- package/src/gh.ts +214 -0
- package/src/glob.ts +206 -0
- package/src/graph/build.ts +114 -0
- package/src/graph/engine-resolve.ts +0 -0
- package/src/graph/graph.ts +126 -0
- package/src/graph/imports.ts +359 -0
- package/src/graph/resolve.ts +73 -0
- package/src/graph/tsconfig.ts +113 -0
- package/src/grounding.ts +119 -0
- package/src/guidance/index.ts +45 -0
- package/src/guidance/types.ts +36 -0
- package/src/init.ts +113 -0
- package/src/mcp/handlers.ts +388 -0
- package/src/mcp/http.ts +260 -0
- package/src/mcp/prompts.ts +183 -0
- package/src/mcp/protocol.ts +187 -0
- package/src/mcp/resources.ts +136 -0
- package/src/mcp/server.ts +228 -0
- package/src/mcp/stdio.ts +119 -0
- package/src/mcp/tools.ts +347 -0
- package/src/messages.ts +1011 -0
- package/src/name.ts +174 -0
- package/src/orchestrate-templates.ts +231 -0
- package/src/orchestrate.ts +186 -0
- package/src/output.ts +108 -0
- package/src/pack.ts +187 -0
- package/src/parse/html.ts +258 -0
- package/src/parse/jsx-ast.ts +67 -0
- package/src/parse/jsx-bridge.ts +247 -0
- package/src/parse/jsx.ts +19 -0
- package/src/parse/source.ts +75 -0
- package/src/prd.ts +406 -0
- package/src/project-lock.ts +42 -0
- package/src/render.ts +307 -0
- package/src/report.ts +399 -0
- package/src/rules/colors.ts +125 -0
- package/src/rules/cross-registry.ts +264 -0
- package/src/rules/cross-rule.ts +83 -0
- package/src/rules/forms.ts +590 -0
- package/src/rules/frames.ts +33 -0
- package/src/rules/headings.ts +227 -0
- package/src/rules/images.ts +200 -0
- package/src/rules/links.ts +193 -0
- package/src/rules/mandatory.ts +173 -0
- package/src/rules/multimedia.ts +77 -0
- package/src/rules/navigation.ts +190 -0
- package/src/rules/presentation.ts +75 -0
- package/src/rules/registry.ts +57 -0
- package/src/rules/rule.ts +122 -0
- package/src/rules/scripts-aria.ts +636 -0
- package/src/rules/tables.ts +264 -0
- package/src/rules/timing.ts +55 -0
- package/src/sample.ts +176 -0
- package/src/scan-local.ts +866 -0
- package/src/scan.ts +538 -0
- package/src/standards/derive.ts +195 -0
- package/src/standards/index.ts +31 -0
- package/src/standards/pack-rules.ts +111 -0
- package/src/standards/pack.ts +81 -0
- package/src/standards/registry.ts +100 -0
- package/src/standards/types.ts +202 -0
- package/src/standards/validate.ts +544 -0
- package/src/standards/vocabulary.ts +75 -0
- package/src/types.ts +355 -0
- package/src/util.ts +32 -0
- package/src/vendor/codeindex-engine.d.mts +1009 -0
- package/src/vendor/codeindex-engine.mjs +13701 -0
- package/src/vendor/engine.meta.json +9 -0
- package/src/verify.ts +275 -0
- 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
|
+
}
|
package/src/baseline.ts
ADDED
|
@@ -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, "&").replace(/"/g, """).replace(/</g, "<").replace(/>/g, ">").replace(/--/g, "--");
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export function unescapeCommentValue(s: string): string {
|
|
30
|
+
return s
|
|
31
|
+
.replace(/-/g, "-")
|
|
32
|
+
.replace(/>/g, ">")
|
|
33
|
+
.replace(/</g, "<")
|
|
34
|
+
.replace(/"/g, '"')
|
|
35
|
+
.replace(/&/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
|
+
}
|