ultra11y 2.22.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (153) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +223 -0
  3. package/docker/Dockerfile +7 -0
  4. package/docker/package.json +9 -0
  5. package/docker/runner.mjs +23 -0
  6. package/package.json +73 -0
  7. package/scripts/bench.mjs +151 -0
  8. package/scripts/build-act-corpus.mjs +143 -0
  9. package/scripts/build-adjudication.mjs +503 -0
  10. package/scripts/build-pack-rgaa.mjs +397 -0
  11. package/scripts/build-standards.mjs +548 -0
  12. package/scripts/copy-bundle.mjs +39 -0
  13. package/scripts/import-pack.mjs +107 -0
  14. package/scripts/sync-engine.mjs +84 -0
  15. package/scripts/sync-version.mjs +43 -0
  16. package/scripts/ultra11y.mjs +52739 -0
  17. package/scripts/vendor/act-testcases.json +12297 -0
  18. package/scripts/vendor/rgaa/SOURCE.json +8 -0
  19. package/scripts/vendor/rgaa/criteres.json +4144 -0
  20. package/scripts/vendor/rgaa/glossaire.json +480 -0
  21. package/scripts/vendor/wcag-2.2-fr.json +82 -0
  22. package/scripts/vendor/wcag-2.2-sc-universe.json +950 -0
  23. package/scripts/vendor/wcag-2.2-sc.json +574 -0
  24. package/scripts/verify-rgaa-source.mjs +126 -0
  25. package/scripts/verify-skill-bundle.mjs +91 -0
  26. package/skills/review-a11y/SKILL.md +123 -0
  27. package/skills/review-a11y/scripts/ultra11y.mjs +52739 -0
  28. package/skills/ultra11y/SKILL.md +292 -0
  29. package/skills/ultra11y/references/act.md +163 -0
  30. package/skills/ultra11y/references/adjudication.md +599 -0
  31. package/skills/ultra11y/references/audit.md +100 -0
  32. package/skills/ultra11y/references/authoring.md +37 -0
  33. package/skills/ultra11y/references/automation.md +113 -0
  34. package/skills/ultra11y/references/correction.md +41 -0
  35. package/skills/ultra11y/references/criteria.md +129 -0
  36. package/skills/ultra11y/references/cross-file.md +82 -0
  37. package/skills/ultra11y/references/dynamic.md +152 -0
  38. package/skills/ultra11y/references/false-positives.md +81 -0
  39. package/skills/ultra11y/references/fix.md +46 -0
  40. package/skills/ultra11y/references/focus-and-logic.md +64 -0
  41. package/skills/ultra11y/references/forbidden-patterns.md +61 -0
  42. package/skills/ultra11y/references/forms-and-errors.md +147 -0
  43. package/skills/ultra11y/references/guidance.md +55 -0
  44. package/skills/ultra11y/references/judgment.md +90 -0
  45. package/skills/ultra11y/references/media-and-motion.md +116 -0
  46. package/skills/ultra11y/references/methodology.md +72 -0
  47. package/skills/ultra11y/references/naming.md +117 -0
  48. package/skills/ultra11y/references/orchestration.md +44 -0
  49. package/skills/ultra11y/references/packs.md +193 -0
  50. package/skills/ultra11y/references/prd.md +104 -0
  51. package/skills/ultra11y/references/rendered.md +110 -0
  52. package/skills/ultra11y/references/scale.md +49 -0
  53. package/skills/ultra11y/references/standards.md +97 -0
  54. package/skills/ultra11y/references/structure.md +138 -0
  55. package/skills/ultra11y/references/verify.md +88 -0
  56. package/skills/ultra11y/references/widgets.md +106 -0
  57. package/skills/ultra11y/scripts/ultra11y.mjs +52739 -0
  58. package/src/act.ts +441 -0
  59. package/src/adjudicate.ts +630 -0
  60. package/src/aria.ts +230 -0
  61. package/src/audit.ts +396 -0
  62. package/src/auditor.ts +399 -0
  63. package/src/autofill.ts +102 -0
  64. package/src/axe-map.ts +294 -0
  65. package/src/baseline.ts +75 -0
  66. package/src/capture.ts +182 -0
  67. package/src/check.ts +243 -0
  68. package/src/cli.ts +1927 -0
  69. package/src/color.ts +102 -0
  70. package/src/config.ts +182 -0
  71. package/src/crawl.ts +75 -0
  72. package/src/criteria.ts +183 -0
  73. package/src/data/adjudication.json +770 -0
  74. package/src/data/guidance/rgaa.json +2395 -0
  75. package/src/data/standards/rgaa.glossary.json +478 -0
  76. package/src/data/standards/rgaa.json +3454 -0
  77. package/src/data/wcag-universe.json +530 -0
  78. package/src/data/wcag.json +1178 -0
  79. package/src/discover.ts +203 -0
  80. package/src/fix/codemods.ts +76 -0
  81. package/src/fix/diff.ts +64 -0
  82. package/src/fix/edits.ts +126 -0
  83. package/src/fix.ts +215 -0
  84. package/src/gh.ts +214 -0
  85. package/src/glob.ts +206 -0
  86. package/src/graph/build.ts +114 -0
  87. package/src/graph/engine-resolve.ts +0 -0
  88. package/src/graph/graph.ts +126 -0
  89. package/src/graph/imports.ts +359 -0
  90. package/src/graph/resolve.ts +73 -0
  91. package/src/graph/tsconfig.ts +113 -0
  92. package/src/grounding.ts +119 -0
  93. package/src/guidance/index.ts +45 -0
  94. package/src/guidance/types.ts +36 -0
  95. package/src/init.ts +113 -0
  96. package/src/mcp/handlers.ts +388 -0
  97. package/src/mcp/http.ts +260 -0
  98. package/src/mcp/prompts.ts +183 -0
  99. package/src/mcp/protocol.ts +187 -0
  100. package/src/mcp/resources.ts +136 -0
  101. package/src/mcp/server.ts +228 -0
  102. package/src/mcp/stdio.ts +119 -0
  103. package/src/mcp/tools.ts +347 -0
  104. package/src/messages.ts +1011 -0
  105. package/src/name.ts +174 -0
  106. package/src/orchestrate-templates.ts +231 -0
  107. package/src/orchestrate.ts +186 -0
  108. package/src/output.ts +108 -0
  109. package/src/pack.ts +187 -0
  110. package/src/parse/html.ts +258 -0
  111. package/src/parse/jsx-ast.ts +67 -0
  112. package/src/parse/jsx-bridge.ts +247 -0
  113. package/src/parse/jsx.ts +19 -0
  114. package/src/parse/source.ts +75 -0
  115. package/src/prd.ts +406 -0
  116. package/src/project-lock.ts +42 -0
  117. package/src/render.ts +307 -0
  118. package/src/report.ts +399 -0
  119. package/src/rules/colors.ts +125 -0
  120. package/src/rules/cross-registry.ts +264 -0
  121. package/src/rules/cross-rule.ts +83 -0
  122. package/src/rules/forms.ts +590 -0
  123. package/src/rules/frames.ts +33 -0
  124. package/src/rules/headings.ts +227 -0
  125. package/src/rules/images.ts +200 -0
  126. package/src/rules/links.ts +193 -0
  127. package/src/rules/mandatory.ts +173 -0
  128. package/src/rules/multimedia.ts +77 -0
  129. package/src/rules/navigation.ts +190 -0
  130. package/src/rules/presentation.ts +75 -0
  131. package/src/rules/registry.ts +57 -0
  132. package/src/rules/rule.ts +122 -0
  133. package/src/rules/scripts-aria.ts +636 -0
  134. package/src/rules/tables.ts +264 -0
  135. package/src/rules/timing.ts +55 -0
  136. package/src/sample.ts +176 -0
  137. package/src/scan-local.ts +866 -0
  138. package/src/scan.ts +538 -0
  139. package/src/standards/derive.ts +195 -0
  140. package/src/standards/index.ts +31 -0
  141. package/src/standards/pack-rules.ts +111 -0
  142. package/src/standards/pack.ts +81 -0
  143. package/src/standards/registry.ts +100 -0
  144. package/src/standards/types.ts +202 -0
  145. package/src/standards/validate.ts +544 -0
  146. package/src/standards/vocabulary.ts +75 -0
  147. package/src/types.ts +355 -0
  148. package/src/util.ts +32 -0
  149. package/src/vendor/codeindex-engine.d.mts +1009 -0
  150. package/src/vendor/codeindex-engine.mjs +13701 -0
  151. package/src/vendor/engine.meta.json +9 -0
  152. package/src/verify.ts +275 -0
  153. package/src/wcag.ts +128 -0
@@ -0,0 +1,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
+ }