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
|
@@ -0,0 +1,195 @@
|
|
|
1
|
+
// Project a WCAG-keyed AuditResult DOWN onto a pack's criteria — the inverse of the old
|
|
2
|
+
// presentation-only WCAG view (former src/standard.ts). For each pack criterion, gather
|
|
3
|
+
// the results of the WCAG SCs it maps to and fold them with the same NC-dominates rule.
|
|
4
|
+
// Presentation-only: the canonical, gated verdict lives on the WCAG core.
|
|
5
|
+
import type { AuditResult, CriterionResult, Status, Finding, Severity } from "../types.js";
|
|
6
|
+
import { loadPack } from "./registry.js";
|
|
7
|
+
import { knownScStatus } from "../wcag.js";
|
|
8
|
+
import type { LocaleString, PackCriterion, PackOverride, SecondaryMapping, StandardPack } from "./types.js";
|
|
9
|
+
|
|
10
|
+
export interface PackCriterionResult {
|
|
11
|
+
id: string;
|
|
12
|
+
theme: number;
|
|
13
|
+
status: Status;
|
|
14
|
+
findings: Finding[];
|
|
15
|
+
scs: string[]; // contributing WCAG SCs
|
|
16
|
+
// Set when EVERY WCAG SC this criterion maps to is outside the engine's WCAG 2.2 AA
|
|
17
|
+
// core (out-of-core AAA, or removed) — the engine has no core SC to project a verdict
|
|
18
|
+
// from, so it's neither a genuine C/NC/NA, just permanently out of scope (status
|
|
19
|
+
// "manual" — see src/report.ts renderPackReport for the dedicated justification).
|
|
20
|
+
outOfScope?: boolean;
|
|
21
|
+
// Set when a mapped SC DID fail, but on elements outside this criterion's applicability
|
|
22
|
+
// scope (per the pack's `appliesTo`) — so the criterion is NOT non-conformant off a
|
|
23
|
+
// sibling's failure. It derives as `manual` (assess separately) with a dedicated
|
|
24
|
+
// scoped-out justification (see src/report.ts renderPackReport).
|
|
25
|
+
scopedOut?: boolean;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
// NC dominates (a real failure anywhere fails the criterion); then a decided C; then
|
|
29
|
+
// manual (residual); else NA. Mirrors the core's aggregation.
|
|
30
|
+
function aggregate(statuses: Status[]): Status {
|
|
31
|
+
if (statuses.includes("NC")) return "NC";
|
|
32
|
+
if (statuses.includes("C")) return "C";
|
|
33
|
+
if (statuses.includes("manual")) return "manual";
|
|
34
|
+
return "NA";
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/** Does a finding's ruleId satisfy one of a criterion's applicability patterns?
|
|
38
|
+
* Exact match, or a "prefix:*" wildcard (axe:* / dyn-* / agent:*), or a bare "*". */
|
|
39
|
+
function ruleMatches(ruleId: string, patterns: string[]): boolean {
|
|
40
|
+
for (const p of patterns) {
|
|
41
|
+
if (p === "*") return true;
|
|
42
|
+
if (p.endsWith(":*") || p.endsWith("-*")) {
|
|
43
|
+
if (ruleId.startsWith(p.slice(0, -1))) return true;
|
|
44
|
+
} else if (p === ruleId) return true;
|
|
45
|
+
}
|
|
46
|
+
return false;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/** Which of a pack's criterion ids a single finding maps onto — the same wcag/appliesTo
|
|
50
|
+
* projection `derivePackResults` runs per-criterion, exposed per-finding for callers that
|
|
51
|
+
* render one finding at a time (e.g. src/report.ts's per-page section) rather than the
|
|
52
|
+
* full aggregated view. A criterion's id is included iff its `wcag` list contains the
|
|
53
|
+
* finding's SC AND (legacy fan-out when the criterion has no `appliesTo`, or the
|
|
54
|
+
* finding's ruleId matches one of its `appliesTo.ruleIds` patterns via `ruleMatches`).
|
|
55
|
+
* Usually one id; can be several (e.g. `contrast-literal` → RGAA 3.2 + 10.5); can be
|
|
56
|
+
* none when the pack has no criterion mapped to this finding's SC (caller falls back to
|
|
57
|
+
* the WCAG SC id). */
|
|
58
|
+
export function packCriteriaForFinding(pack: StandardPack, finding: Finding): string[] {
|
|
59
|
+
return pack.criteria
|
|
60
|
+
.filter((pc) => pc.wcag.includes(finding.criteriaId) && (!pc.appliesTo || ruleMatches(finding.ruleId, pc.appliesTo.ruleIds)))
|
|
61
|
+
.map((pc) => pc.id);
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/** The pack-projection pass rate: C ÷ (C + NC) over the pack's own criteria (NOT the core
|
|
65
|
+
* WCAG criteria) — the same basis the pack report's per-criterion NC table already uses.
|
|
66
|
+
* Mirrors the core denominator-zero convention (src/audit.ts conformancePct): no decided
|
|
67
|
+
* criterion ⇒ 100, never a divide-by-zero. `manual`/`NA` criteria don't enter the ratio,
|
|
68
|
+
* same as core. Exists so a pack report's header rate can't drift from its own table once
|
|
69
|
+
* pack overrides (advisory/severity flips) make the two diverge from core `conformancePct`. */
|
|
70
|
+
export function packConformancePct(derived: PackCriterionResult[]): number {
|
|
71
|
+
const c = derived.filter((d) => d.status === "C").length;
|
|
72
|
+
const nc = derived.filter((d) => d.status === "NC").length;
|
|
73
|
+
return c + nc === 0 ? 100 : Math.round((c / (c + nc)) * 100);
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/** Apply a pack's normativity/severity overrides to a finding WITHIN the pack projection.
|
|
77
|
+
* Returns a COPY when an override applies (the core finding is never mutated), or the
|
|
78
|
+
* original reference when there is nothing to change. */
|
|
79
|
+
function overrideFinding(f: Finding, overrides: Record<string, PackOverride> | undefined): Finding {
|
|
80
|
+
const o = overrides?.[f.ruleId];
|
|
81
|
+
if (!o) return f;
|
|
82
|
+
const patched: Finding = { ...f };
|
|
83
|
+
if (o.advisory !== undefined) patched.advisory = o.advisory;
|
|
84
|
+
if (o.severity) patched.severity = o.severity as Severity;
|
|
85
|
+
return patched;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
/** Resolve a mapping's localized note to a single display string at derive time (no lang in
|
|
89
|
+
* scope): prefer the pack's default locale, then en/fr, then any present value. */
|
|
90
|
+
function pickLocale(ls: LocaleString | undefined, preferred: string): string | undefined {
|
|
91
|
+
if (!ls) return undefined;
|
|
92
|
+
return ls[preferred] ?? ls.en ?? ls.fr ?? Object.values(ls).find((v): v is string => typeof v === "string");
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
/** Copy-tag a finding as projected via a secondary mapping (copy-on-write — the core
|
|
96
|
+
* finding is never mutated, mirroring `overrideFinding`). Carries the resolved deviation
|
|
97
|
+
* note so the auditor block can append it. */
|
|
98
|
+
function tagSecondary(f: Finding, note: string | undefined): Finding {
|
|
99
|
+
return { ...f, secondary: note ? { note } : {} };
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
/** Apply a pack's ENABLED secondary crosswalk mappings to one criterion's base result. For
|
|
103
|
+
* every enabled mapping targeting `pc`, gather source findings whose ruleId matches EXACTLY
|
|
104
|
+
* (bypassing both the SC gate and the appliesTo/ruleMatches gate), copy-tag them, attach,
|
|
105
|
+
* and let a normative one drive the criterion to NC (re-aggregation: NC dominates). A base
|
|
106
|
+
* with no active/matching mapping is returned untouched. */
|
|
107
|
+
function applySecondaryMappings(
|
|
108
|
+
base: PackCriterionResult,
|
|
109
|
+
pc: PackCriterion,
|
|
110
|
+
enabled: SecondaryMapping[],
|
|
111
|
+
sources: Finding[],
|
|
112
|
+
defaultLocale: string,
|
|
113
|
+
): PackCriterionResult {
|
|
114
|
+
const active = enabled.filter((m) => m.criterion === pc.id);
|
|
115
|
+
if (!active.length) return base;
|
|
116
|
+
const added: Finding[] = [];
|
|
117
|
+
for (const m of active) for (const f of sources) if (f.ruleId === m.ruleId) added.push(tagSecondary(f, pickLocale(m.note, defaultLocale)));
|
|
118
|
+
if (!added.length) return base;
|
|
119
|
+
const findings = [...base.findings, ...added];
|
|
120
|
+
// A normative secondary finding drives NC (aggregate: NC dominates), and supersedes an
|
|
121
|
+
// out-of-scope/scoped-out base verdict; an advisory-only one just rides along for display.
|
|
122
|
+
if (added.some((f) => !f.advisory)) return { id: pc.id, theme: pc.theme, status: aggregate([base.status, "NC"]), findings, scs: base.scs };
|
|
123
|
+
return { ...base, findings };
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
export function derivePackResults(audit: AuditResult, packKey: string): PackCriterionResult[] {
|
|
127
|
+
const pack = loadPack(packKey);
|
|
128
|
+
const byScId = new Map(audit.criteria.map((c) => [c.id, c]));
|
|
129
|
+
// Declarative pack-rule findings belonging to THIS pack (namespaced pack:<key>:). They
|
|
130
|
+
// ride in the audit's dedicated `packFindings` list (never the core criteria), and are
|
|
131
|
+
// routed onto a criterion here via the same appliesTo/ruleMatches machinery as engine
|
|
132
|
+
// findings, keyed by the SC the rule declared.
|
|
133
|
+
const myPackFindings = (audit.packFindings ?? []).filter((f) => f.ruleId.startsWith(`pack:${packKey}:`));
|
|
134
|
+
const overrides = pack.overrides;
|
|
135
|
+
const deriveBase = (pc: PackCriterion): PackCriterionResult => {
|
|
136
|
+
// A criterion whose WCAG mapping is ENTIRELY outside the engine's core (e.g. RGAA 8.1
|
|
137
|
+
// → only the removed 4.1.1, or a hypothetical pack criterion citing only an AAA SC)
|
|
138
|
+
// has no core SC the engine could ever audit — it's out of scope, not a silent NA.
|
|
139
|
+
const outOfScope = pc.wcag.every((sc) => {
|
|
140
|
+
const s = knownScStatus(sc);
|
|
141
|
+
return s === "out-of-core" || s === "removed";
|
|
142
|
+
});
|
|
143
|
+
if (outOfScope) {
|
|
144
|
+
return { id: pc.id, theme: pc.theme, status: "manual" as Status, findings: [], scs: pc.wcag, outOfScope: true };
|
|
145
|
+
}
|
|
146
|
+
const scResults = pc.wcag.map((sc) => byScId.get(sc)).filter((x): x is CriterionResult => !!x);
|
|
147
|
+
// Declarative pack findings whose declared SC this criterion maps to — merged with the
|
|
148
|
+
// core SC findings, then run through the pack's overrides (advisory/severity flips that
|
|
149
|
+
// apply ONLY in this projection; the core copies are never mutated).
|
|
150
|
+
const packFs = myPackFindings.filter((f) => pc.wcag.includes(f.criteriaId));
|
|
151
|
+
const allFindings = [...scResults.flatMap((r) => r.findings), ...packFs].map((f) => overrideFinding(f, overrides));
|
|
152
|
+
|
|
153
|
+
// No applicability data (third-party pack) → legacy fan-out: every mapped SC's
|
|
154
|
+
// findings attach (advisory ones project too, so a pack view can render the
|
|
155
|
+
// recommendation) and the SC statuses aggregate directly. The SC status already
|
|
156
|
+
// excludes advisory findings (src/audit.ts finalize), so the aggregate is NC-clean.
|
|
157
|
+
if (!pc.appliesTo) {
|
|
158
|
+
const status: Status = scResults.length ? aggregate(scResults.map((r) => r.status)) : "NA";
|
|
159
|
+
return { id: pc.id, theme: pc.theme, status, findings: allFindings, scs: pc.wcag };
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
// Applicability-aware projection: a finding attaches ONLY if its rule is one this
|
|
163
|
+
// criterion can actually be non-conformant on. NC is driven by NON-ADVISORY findings
|
|
164
|
+
// only; advisory findings still attach (so the pack report/PRD renders the
|
|
165
|
+
// recommendation) but never flip the criterion to NC.
|
|
166
|
+
const findings = allFindings.filter((f) => ruleMatches(f.ruleId, pc.appliesTo!.ruleIds));
|
|
167
|
+
const normativeFindings = findings.filter((f) => !f.advisory);
|
|
168
|
+
if (normativeFindings.length) {
|
|
169
|
+
return { id: pc.id, theme: pc.theme, status: "NC" as Status, findings, scs: pc.wcag };
|
|
170
|
+
}
|
|
171
|
+
// No NORMATIVE finding attaches. A mapped SC may still be NC, but on out-of-scope
|
|
172
|
+
// elements (a sibling criterion's failure) — that NC is NOT ours: derive as manual
|
|
173
|
+
// (assess separately), never a foreign NC. Any advisory findings that DO belong here
|
|
174
|
+
// still attach for display (a recommendation, never a non-conformity). This ordering
|
|
175
|
+
// matters: an attached advisory recommendation must not let a scoped-out sibling NC
|
|
176
|
+
// silently flip us to a foreign verdict.
|
|
177
|
+
if (scResults.some((r) => r.status === "NC")) {
|
|
178
|
+
return { id: pc.id, theme: pc.theme, status: "manual" as Status, findings, scs: pc.wcag, scopedOut: true };
|
|
179
|
+
}
|
|
180
|
+
// Otherwise the ordinary non-NC aggregate (C / manual / NA) over the mapped SCs, with
|
|
181
|
+
// any advisory findings kept on the result so the pack view surfaces them.
|
|
182
|
+
const status: Status = scResults.length ? aggregate(scResults.map((r) => r.status)) : "NA";
|
|
183
|
+
return { id: pc.id, theme: pc.theme, status, findings, scs: pc.wcag };
|
|
184
|
+
};
|
|
185
|
+
|
|
186
|
+
// Secondary crosswalk projections (opt-in, config-enabled). Sourced from EVERY audit
|
|
187
|
+
// finding (core SC findings + this pack's declarative pack findings), matched by EXACT
|
|
188
|
+
// ruleId — so a sibling rule on the same SC is never pulled onto the secondary criterion.
|
|
189
|
+
const enabledSecondary = (pack.secondaryMappings ?? []).filter((m) => m.enabled === true);
|
|
190
|
+
const secondarySources = enabledSecondary.length ? [...audit.criteria.flatMap((c) => c.findings), ...myPackFindings] : [];
|
|
191
|
+
return pack.criteria.map((pc) => {
|
|
192
|
+
const base = deriveBase(pc);
|
|
193
|
+
return enabledSecondary.length ? applySecondaryMappings(base, pc, enabledSecondary, secondarySources, pack.defaultLocale) : base;
|
|
194
|
+
});
|
|
195
|
+
}
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
// Barrel + the `--standard <id>` resolver. `StandardId` is either the reserved core key
|
|
2
|
+
// "wcag" or a registered pack key. The default is the WCAG core; an unknown value throws
|
|
3
|
+
// (never a silent fallback).
|
|
4
|
+
import { CORE_KEY, hasStandard, isCore, listStandards, loadPack } from "./registry.js";
|
|
5
|
+
|
|
6
|
+
export type StandardId = string;
|
|
7
|
+
export const CORE: StandardId = CORE_KEY;
|
|
8
|
+
|
|
9
|
+
export function resolveStandard(flag: string | boolean | undefined): StandardId {
|
|
10
|
+
if (flag === undefined || flag === true || flag === "") return CORE_KEY;
|
|
11
|
+
const key = String(flag).toLowerCase();
|
|
12
|
+
if (!hasStandard(key)) {
|
|
13
|
+
throw new Error(`unknown standard "${key}" (known: ${listStandards().join(", ")})`);
|
|
14
|
+
}
|
|
15
|
+
return key;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
/** Display label for the active standard: "WCAG 2.2 AA" for the core, "<pack name>
|
|
19
|
+
* <baseVersion>" for a pack (e.g. "RGAA 4.1.2"). Was duplicated as `stdLabel()` in both
|
|
20
|
+
* auditor.ts and prd.ts. */
|
|
21
|
+
export function standardLabel(standard: StandardId): string {
|
|
22
|
+
if (isCore(standard)) return "WCAG 2.2 AA";
|
|
23
|
+
const p = loadPack(standard);
|
|
24
|
+
return `${p.name} ${p.baseVersion}`;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export * from "./types.js";
|
|
28
|
+
export * from "./registry.js";
|
|
29
|
+
export * from "./pack.js";
|
|
30
|
+
export * from "./derive.js";
|
|
31
|
+
export * from "./vocabulary.js";
|
|
@@ -0,0 +1,111 @@
|
|
|
1
|
+
// The bounded interpreter for a pack's declarative RULES (src/standards/types.ts
|
|
2
|
+
// `PackRule`). It runs AFTER the core engine rules in the audit pipeline (src/audit.ts
|
|
3
|
+
// foldDoc) over the SAME parsed Doc, and turns each matching element into a namespaced
|
|
4
|
+
// `pack:<packKey>:<id>` Finding. There is NO code path here that executes pack-supplied
|
|
5
|
+
// code — a rule is pure data (tag / attribute / visible-text predicates + bounded
|
|
6
|
+
// descendant conditions), so a `--pack`-loaded pack stays fully validatable (see
|
|
7
|
+
// validatePack) and can never run arbitrary logic.
|
|
8
|
+
import type { El, Doc } from "../parse/html.js";
|
|
9
|
+
import { descendants, visibleText, snippet as sourceSnippet } from "../parse/html.js";
|
|
10
|
+
import { isFullDocument, selectorOf } from "../rules/rule.js";
|
|
11
|
+
import type { Finding } from "../types.js";
|
|
12
|
+
import type { StandardPack, PackRule, MatchNode, MatchAttr, MatchText } from "./types.js";
|
|
13
|
+
|
|
14
|
+
// Mirrors the validator's depth cap (src/standards/validate.ts MAX_MATCH_DEPTH): a
|
|
15
|
+
// registered pack is already validated, so the interpreter never sees over-deep nesting —
|
|
16
|
+
// this is a defensive belt only.
|
|
17
|
+
const MAX_MATCH_DEPTH = 3;
|
|
18
|
+
|
|
19
|
+
// Compile the (already ReDoS-validated) regexes case-insensitively, memoized per pattern
|
|
20
|
+
// so a rule's regex is built once, not once per element.
|
|
21
|
+
const regexCache = new Map<string, RegExp>();
|
|
22
|
+
function compile(pattern: string): RegExp {
|
|
23
|
+
let re = regexCache.get(pattern);
|
|
24
|
+
if (!re) {
|
|
25
|
+
re = new RegExp(pattern, "i");
|
|
26
|
+
regexCache.set(pattern, re);
|
|
27
|
+
}
|
|
28
|
+
return re;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
function matchAttr(el: El, a: MatchAttr): boolean {
|
|
32
|
+
const v = el.attribs[a.name.toLowerCase()];
|
|
33
|
+
switch (a.op) {
|
|
34
|
+
case "present":
|
|
35
|
+
return v !== undefined;
|
|
36
|
+
case "absent":
|
|
37
|
+
return v === undefined;
|
|
38
|
+
case "equals":
|
|
39
|
+
return v !== undefined && v === a.value;
|
|
40
|
+
case "matches":
|
|
41
|
+
return v !== undefined && a.value !== undefined && compile(a.value).test(v);
|
|
42
|
+
default:
|
|
43
|
+
return false;
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
function matchText(el: El, t: MatchText): boolean {
|
|
48
|
+
const text = visibleText(el);
|
|
49
|
+
const hit = compile(t.value).test(text);
|
|
50
|
+
return t.op === "matches" ? hit : !hit;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
function matchNode(el: El, node: MatchNode, depth: number): boolean {
|
|
54
|
+
if (depth > MAX_MATCH_DEPTH) return false;
|
|
55
|
+
if (node.tag && el.tag.toLowerCase() !== node.tag.toLowerCase()) return false;
|
|
56
|
+
if (node.attrs && !node.attrs.every((a) => matchAttr(el, a))) return false;
|
|
57
|
+
if (node.text && !matchText(el, node.text)) return false;
|
|
58
|
+
// `has`: every listed sub-condition must match SOME descendant.
|
|
59
|
+
if (node.has || node.lacks) {
|
|
60
|
+
const desc = descendants(el);
|
|
61
|
+
if (node.has && !node.has.every((sub) => desc.some((d) => matchNode(d, sub, depth + 1)))) return false;
|
|
62
|
+
// `lacks`: NO listed sub-condition may match ANY descendant.
|
|
63
|
+
if (node.lacks && node.lacks.some((sub) => desc.some((d) => matchNode(d, sub, depth + 1)))) return false;
|
|
64
|
+
}
|
|
65
|
+
return true;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
function toFinding(doc: Doc, el: El, rule: PackRule, packKey: string): Finding {
|
|
69
|
+
// Canonical English bake per the Finding contract (message = AI-facing English); the
|
|
70
|
+
// validator guarantees both en + fr are present. The localized {en,fr} pair also rides
|
|
71
|
+
// on `i18n` so a renderer at `--lang fr` resolves the French text (src/messages.ts
|
|
72
|
+
// resolveMessage), rather than falling back to this English bake.
|
|
73
|
+
const message = rule.message.en ?? rule.message.fr ?? rule.id;
|
|
74
|
+
const remediation = rule.remediation.en ?? rule.remediation.fr ?? "";
|
|
75
|
+
return {
|
|
76
|
+
ruleId: `pack:${packKey}:${rule.id}`,
|
|
77
|
+
criteriaId: rule.wcag[0]!,
|
|
78
|
+
file: doc.file,
|
|
79
|
+
line: el.line,
|
|
80
|
+
col: el.col,
|
|
81
|
+
selectorHint: selectorOf(el),
|
|
82
|
+
severity: rule.severity,
|
|
83
|
+
message,
|
|
84
|
+
remediation,
|
|
85
|
+
snippet: sourceSnippet(doc, el),
|
|
86
|
+
sourceStart: el.start,
|
|
87
|
+
sourceEnd: el.end,
|
|
88
|
+
i18n: {
|
|
89
|
+
message: { en: rule.message.en, fr: rule.message.fr },
|
|
90
|
+
remediation: { en: rule.remediation.en, fr: rule.remediation.fr },
|
|
91
|
+
},
|
|
92
|
+
...(rule.advisory ? { advisory: true } : {}),
|
|
93
|
+
};
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
/** Run every declarative rule of `pack` over `doc`, returning the namespaced findings
|
|
97
|
+
* (empty when the pack ships no rules). Standard-agnostic: the audit pass runs this for
|
|
98
|
+
* every registered pack; each pack's findings surface only in ITS OWN projection. */
|
|
99
|
+
export function runPackRules(doc: Doc, pack: StandardPack): Finding[] {
|
|
100
|
+
const rules = pack.rules;
|
|
101
|
+
if (!rules || rules.length === 0) return [];
|
|
102
|
+
const out: Finding[] = [];
|
|
103
|
+
const fullDoc = isFullDocument(doc);
|
|
104
|
+
for (const rule of rules) {
|
|
105
|
+
if (rule.match.scope === "page" && !fullDoc) continue;
|
|
106
|
+
for (const el of doc.elements) {
|
|
107
|
+
if (matchNode(el, rule.match, 1)) out.push(toFinding(doc, el, rule, pack.key));
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
return out;
|
|
111
|
+
}
|
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
// Generic pack loader: localized lookups over a StandardPack (the genericized
|
|
2
|
+
// successor of the old src/rgaa.ts criterion/theme/glossary helpers).
|
|
3
|
+
import type { StandardPack, PackCriterion, LocaleString } from "./types.js";
|
|
4
|
+
import type { Lang, GlossaryEntry } from "../types.js";
|
|
5
|
+
import { packGlossary } from "./registry.js";
|
|
6
|
+
|
|
7
|
+
/** Resolve a localized string: requested lang → pack default → English → first available.
|
|
8
|
+
* `lang` is deliberately `string`, not the UI frame's `Lang` — a pack's own locales
|
|
9
|
+
* (`LocaleString` keys) are not constrained to "fr"|"en" (see src/standards/types.ts). */
|
|
10
|
+
export function localize(pack: StandardPack, s: LocaleString | undefined, lang: string): string {
|
|
11
|
+
if (!s) return "";
|
|
12
|
+
return s[lang] ?? s[pack.defaultLocale] ?? s.en ?? Object.values(s)[0] ?? "";
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
export function allCriteria(pack: StandardPack): PackCriterion[] {
|
|
16
|
+
return pack.criteria;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export function getCriterion(pack: StandardPack, id: string): PackCriterion | undefined {
|
|
20
|
+
return pack.criteria.find((c) => c.id === id);
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export function hasId(pack: StandardPack, id: string): boolean {
|
|
24
|
+
return pack.criteria.some((c) => c.id === id);
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export function listTheme(pack: StandardPack, n: number): PackCriterion[] {
|
|
28
|
+
return pack.criteria.filter((c) => c.theme === n);
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
export function themeName(pack: StandardPack, n: number, lang: Lang): string | undefined {
|
|
32
|
+
const t = pack.themes.find((x) => x.number === n);
|
|
33
|
+
return t ? localize(pack, t.name, lang) : undefined;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
export function title(pack: StandardPack, c: PackCriterion, lang: Lang): string {
|
|
37
|
+
return localize(pack, c.title, lang);
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
export function titlePlain(pack: StandardPack, c: PackCriterion, lang: Lang): string {
|
|
41
|
+
return localize(pack, c.titlePlain, lang);
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
export function resolveGlossary(packKey: string, anchor: string): GlossaryEntry | undefined {
|
|
45
|
+
return packGlossary(packKey)?.[anchor];
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/** Replace every unescaped `(` that does NOT open a special construct with a
|
|
49
|
+
* non-capturing `(?:` — i.e. neutralize the pattern's OWN capturing groups.
|
|
50
|
+
* Leaves alone: an escaped literal (`\(`) and any `(?...` construct (`(?:`, `(?=`,
|
|
51
|
+
* `(?!`, `(?<=`, `(?<!`, a named group `(?<name>`…). Used by `idCaptureSource` so a
|
|
52
|
+
* pack `idPattern` that itself contains capturing groups (e.g. `^E(\d+)\.(\d+)$`,
|
|
53
|
+
* legal and accepted by `validatePack`) can never shift the positional captures of a
|
|
54
|
+
* caller (check.ts/verify.ts's NC-header regex reads title/file/line/selector by
|
|
55
|
+
* index) — after neutralization, embedding the result as ONE outer group always
|
|
56
|
+
* contributes exactly that one capturing group, regardless of pack authoring. */
|
|
57
|
+
export function neutralizeCaptureGroups(pattern: string): string {
|
|
58
|
+
let out = "";
|
|
59
|
+
for (let i = 0; i < pattern.length; i++) {
|
|
60
|
+
const ch = pattern[i]!;
|
|
61
|
+
if (ch === "\\") {
|
|
62
|
+
out += ch + (pattern[i + 1] ?? "");
|
|
63
|
+
i++;
|
|
64
|
+
continue;
|
|
65
|
+
}
|
|
66
|
+
out += ch === "(" && pattern[i + 1] !== "?" ? "(?:" : ch;
|
|
67
|
+
}
|
|
68
|
+
return out;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/** The pack's `idPattern` with its full-match anchors (`^`/`$`) stripped and its own
|
|
72
|
+
* capturing groups neutralized (see `neutralizeCaptureGroups`), for embedding as a
|
|
73
|
+
* SINGLE capture group inside a larger line pattern — the generic seam `check.ts`/
|
|
74
|
+
* `verify.ts` use to recognize THIS standard's own criterion-id grammar in a rendered
|
|
75
|
+
* report (WCAG's fixed 3-segment "1.4.3", or a pack's own shape: RGAA "8.3", a
|
|
76
|
+
* hypothetical Section 508 "E205.4"…). `idPattern` is validated compilable by
|
|
77
|
+
* `validatePack` before a pack is ever registered, so this is always a legal regex
|
|
78
|
+
* source. */
|
|
79
|
+
export function idCaptureSource(pack: StandardPack): string {
|
|
80
|
+
return neutralizeCaptureGroups(pack.idPattern.replace(/^\^/, "").replace(/\$$/, ""));
|
|
81
|
+
}
|
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
// The standards registry: the WCAG 2.2 core ("wcag", canonical, lives in src/wcag.ts)
|
|
2
|
+
// plus the statically-imported country PACKS. Adding a country = drop a pack JSON under
|
|
3
|
+
// src/data/standards/ and add one `register(...)` line here (static imports are required
|
|
4
|
+
// so tsup inlines every pack into the single zero-dependency bundle — no runtime fs/glob).
|
|
5
|
+
import rgaaPack from "../data/standards/rgaa.json";
|
|
6
|
+
import rgaaGlossary from "../data/standards/rgaa.glossary.json";
|
|
7
|
+
import type { LocaleString, StandardPack } from "./types.js";
|
|
8
|
+
import type { Glossary } from "../types.js";
|
|
9
|
+
import { validatePack, type PackValidation } from "./validate.js";
|
|
10
|
+
|
|
11
|
+
export const CORE_KEY = "wcag";
|
|
12
|
+
|
|
13
|
+
interface Registered {
|
|
14
|
+
pack: StandardPack;
|
|
15
|
+
glossary: Glossary;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
const registry = new Map<string, Registered>();
|
|
19
|
+
|
|
20
|
+
function register(pack: StandardPack, glossary: Glossary): void {
|
|
21
|
+
if (pack.key === CORE_KEY) throw new Error(`pack key "${CORE_KEY}" is reserved for the WCAG core`);
|
|
22
|
+
registry.set(pack.key, { pack, glossary });
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
register(rgaaPack as unknown as StandardPack, rgaaGlossary as unknown as Glossary);
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* Register an EXTERNAL pack at runtime (from `--pack` / `.ultra11yrc.json`) — the
|
|
29
|
+
* pluggable counterpart to the build-time `register` above. It runs the shared
|
|
30
|
+
* `validatePack` guardrail first; on ANY error-severity issue it does not register and
|
|
31
|
+
* returns the validation so the caller can fail loudly (never a silent accept). A key
|
|
32
|
+
* that collides with a built-in/loaded standard is an error unless `opts.override`.
|
|
33
|
+
*/
|
|
34
|
+
export function registerRuntimePack(raw: unknown, glossary: Glossary = {}, opts: { override?: boolean } = {}): PackValidation {
|
|
35
|
+
const v = validatePack(raw, { knownKeys: new Set(listStandards()), allowOverride: opts.override });
|
|
36
|
+
if (v.ok && v.pack) registry.set(v.pack.key, { pack: v.pack, glossary });
|
|
37
|
+
return v;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* Activate a SECONDARY crosswalk mapping on an already-registered pack (from
|
|
42
|
+
* `.ultra11yrc.json` — see src/config.ts `loadRuntimeStandards`). Flips `enabled: true` on
|
|
43
|
+
* a pack-shipped mapping matching the (ruleId, criterion) pair, or APPENDS a new enabled
|
|
44
|
+
* one when the pack ships none — the opt-in switch that lets a config activate the
|
|
45
|
+
* WCAG-crosswalk-bypassing projection out-of-box packs ship DISABLED. Throws on an unknown
|
|
46
|
+
* pack key (never a silent no-op). Mutates the registered pack in place, by design.
|
|
47
|
+
*/
|
|
48
|
+
export function enableSecondaryMapping(packKey: string, m: { ruleId: string; criterion: string; note?: LocaleString }): void {
|
|
49
|
+
const pack = loadPack(packKey); // throws on unknown key
|
|
50
|
+
const list = (pack.secondaryMappings ??= []);
|
|
51
|
+
const existing = list.find((x) => x.ruleId === m.ruleId && x.criterion === m.criterion);
|
|
52
|
+
if (existing) {
|
|
53
|
+
existing.enabled = true;
|
|
54
|
+
if (m.note) existing.note = m.note;
|
|
55
|
+
} else {
|
|
56
|
+
list.push({ ruleId: m.ruleId, criterion: m.criterion, ...(m.note ? { note: m.note } : {}), enabled: true });
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
export function isCore(key: string): boolean {
|
|
61
|
+
return key === CORE_KEY;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
export function hasStandard(key: string): boolean {
|
|
65
|
+
return key === CORE_KEY || registry.has(key);
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/** Resolve a pack by key; throws on unknown (never silently falls back to the core). */
|
|
69
|
+
export function loadPack(key: string): StandardPack {
|
|
70
|
+
const r = registry.get(key);
|
|
71
|
+
if (!r) throw new Error(`unknown standards pack "${key}" (known packs: ${[...registry.keys()].join(", ") || "none"})`);
|
|
72
|
+
return r.pack;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
export function getPack(key: string): StandardPack | undefined {
|
|
76
|
+
return registry.get(key)?.pack;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
export function packGlossary(key: string): Glossary | undefined {
|
|
80
|
+
return registry.get(key)?.glossary;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
/** All standard keys: the core first, then every registered pack. */
|
|
84
|
+
export function listStandards(): string[] {
|
|
85
|
+
return [CORE_KEY, ...registry.keys()];
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
export function listPacks(): StandardPack[] {
|
|
89
|
+
return [...registry.values()].map((r) => r.pack);
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
/** Reverse cross-reference: which packs (and their criterion ids) map to a WCAG SC. */
|
|
93
|
+
export function packsForSc(sc: string): { key: string; ids: string[] }[] {
|
|
94
|
+
const out: { key: string; ids: string[] }[] = [];
|
|
95
|
+
for (const { pack } of registry.values()) {
|
|
96
|
+
const ids = pack.criteria.filter((c) => c.wcag.includes(sc)).map((c) => c.id);
|
|
97
|
+
if (ids.length) out.push({ key: pack.key, ids });
|
|
98
|
+
}
|
|
99
|
+
return out;
|
|
100
|
+
}
|