ultra11y 4.2.0 → 4.4.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/.claude-plugin/marketplace.json +1 -1
- package/.claude-plugin/plugin.json +1 -1
- package/.codex-plugin/plugin.json +1 -1
- package/.opencode/plugins/ultra11y.js +1 -1
- package/README.md +40 -4
- package/package.json +1 -1
- package/scripts/build-standards.mjs +290 -1
- package/scripts/ultra11y.mjs +4131 -786
- package/scripts/vendor/wcag-2.2-fr.json +945 -1
- package/scripts/vendor/wcag-2.2-sc-universe.json +1 -1
- package/scripts/vendor/wcag-2.2-text.json +749 -0
- package/skills/review-a11y/SKILL.md +1 -1
- package/skills/review-a11y/scripts/ultra11y.mjs +4131 -786
- package/skills/ultra11y/SKILL.md +13 -4
- package/skills/ultra11y/references/guidance.md +33 -7
- package/skills/ultra11y/references/harnesses.md +10 -0
- package/skills/ultra11y/references/mcp.md +215 -0
- package/skills/ultra11y/references/packs.md +8 -0
- package/skills/ultra11y/references/standards.md +18 -0
- package/skills/ultra11y/scripts/ultra11y.mjs +4131 -786
- package/src/adjudicate.ts +9 -15
- package/src/adjudication-data.ts +38 -0
- package/src/check.ts +51 -1
- package/src/cli.ts +6 -2
- package/src/config.ts +27 -4
- package/src/criteria-view.ts +463 -0
- package/src/criteria.ts +14 -11
- package/src/data/guidance/wcag.json +268 -0
- package/src/data/wcag-universe.json +1 -1
- package/src/data/wcag.json +1112 -55
- package/src/guidance/index.ts +11 -0
- package/src/guidance/resolve.ts +71 -0
- package/src/mcp/handlers.ts +160 -15
- package/src/mcp/prompts.ts +13 -2
- package/src/mcp/server.ts +18 -4
- package/src/mcp/standards-resources.ts +174 -0
- package/src/mcp/tools.ts +140 -6
- package/src/method-view.ts +227 -0
- package/src/prd.ts +5 -16
- package/src/standards/coverage.ts +237 -0
- package/src/standards/index.ts +1 -0
- package/src/standards/pack.ts +19 -0
- package/src/standards/registry.ts +115 -9
- package/src/types.ts +24 -1
- package/src/vendor/codeindex-engine.d.mts +324 -3
- package/src/vendor/codeindex-engine.mjs +1146 -175
- package/src/vendor/engine.meta.json +5 -5
- package/src/wcag.ts +21 -1
|
@@ -0,0 +1,237 @@
|
|
|
1
|
+
// What it actually takes to decide a criterion — derived, never guessed.
|
|
2
|
+
//
|
|
3
|
+
// Before auditing anything, an auditor needs the plan: which criteria the static engine
|
|
4
|
+
// settles from source alone, which need a captured page or a real browser, and which are
|
|
5
|
+
// judgment calls no tool can make. Getting that wrong in either direction is expensive — a
|
|
6
|
+
// criterion wrongly filed as "the engine has it" becomes a conformance claim nobody tested.
|
|
7
|
+
//
|
|
8
|
+
// So every classification here is a LOOKUP over data the repo already ships:
|
|
9
|
+
//
|
|
10
|
+
// - `automatability` per WCAG success criterion (src/data/wcag.json): 3 static,
|
|
11
|
+
// 14 needs-rendering, 38 judgment — the 3/14/38 arithmetic quoted across the tool.
|
|
12
|
+
// - `appliesTo.ruleIds` per pack criterion: which engine rules can evidence it. EMPTY is
|
|
13
|
+
// a statement, not a gap — 58 of RGAA's 106 criteria say "no engine rule can attest
|
|
14
|
+
// this", and that fact is the single most useful thing in an audit plan.
|
|
15
|
+
// - `judgment: true` per pack criterion: the criterion asks more than its mapped SCs, so
|
|
16
|
+
// `judgmentGuard` in derive.ts refuses to let it inherit a Conforming verdict.
|
|
17
|
+
// - which registry a rule id belongs to, for the tier that produces it.
|
|
18
|
+
//
|
|
19
|
+
// Nothing here reads the WORDING of a test.
|
|
20
|
+
import { ALL_RULES } from "../rules/registry.js";
|
|
21
|
+
import { CROSS_RULES } from "../rules/cross-registry.js";
|
|
22
|
+
import { allSC, getSC, knownScStatus } from "../wcag.js";
|
|
23
|
+
import { allCriteria, getCriterion } from "./pack.js";
|
|
24
|
+
import { isCore, loadPack } from "./registry.js";
|
|
25
|
+
import type { StandardId } from "./index.js";
|
|
26
|
+
import type { PackCriterion, StandardPack } from "./types.js";
|
|
27
|
+
|
|
28
|
+
/** The evidence source a verdict rests on, cheapest first. */
|
|
29
|
+
export type Tier = "source" | "cross-file" | "rendered-page" | "browser" | "judgment" | "out-of-scope";
|
|
30
|
+
|
|
31
|
+
/** Tiers ordered by cost. `judgment` and `out-of-scope` are terminal, not cheaper/dearer. */
|
|
32
|
+
const TIER_ORDER: Tier[] = ["source", "cross-file", "rendered-page", "browser"];
|
|
33
|
+
|
|
34
|
+
export interface Coverage {
|
|
35
|
+
/** The cheapest tier that can prove this criterion CONFORMANT. */
|
|
36
|
+
tier: Tier;
|
|
37
|
+
/** True only when the source tree alone settles it. */
|
|
38
|
+
sourceIsEnough: boolean;
|
|
39
|
+
/**
|
|
40
|
+
* The tiers that can raise a NON-conformity on it — deliberately a separate axis.
|
|
41
|
+
* RGAA 4.10 and 8.4 are `judgment: true` (so no tool may declare them conformant) and
|
|
42
|
+
* still carry real engine rules that can fail them. Collapsing the two axes into one
|
|
43
|
+
* boolean, as a wording-heuristic must, throws that coverage away.
|
|
44
|
+
*/
|
|
45
|
+
canFailFrom: Tier[];
|
|
46
|
+
/** Tiers beyond `tier` that also carry evidence. */
|
|
47
|
+
alsoNeeds: Tier[];
|
|
48
|
+
/** The engine rules declared applicable to this criterion. */
|
|
49
|
+
engineRules: string[];
|
|
50
|
+
/** False when the pack declares no applicability at all — the plan is then coarser. */
|
|
51
|
+
applicabilityDeclared: boolean;
|
|
52
|
+
/** One English sentence naming the datum that decided this. */
|
|
53
|
+
why: string;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
const STATIC_RULE_IDS = new Set(ALL_RULES.map((r) => r.id));
|
|
57
|
+
const CROSS_RULE_IDS = new Set(CROSS_RULES.map((r) => r.id));
|
|
58
|
+
|
|
59
|
+
/**
|
|
60
|
+
* The tier that produces a finding with this rule id. Namespace dispatch — no regex over
|
|
61
|
+
* prose, no per-id special cases.
|
|
62
|
+
*/
|
|
63
|
+
export function ruleTier(ruleId: string, pack?: StandardPack): Tier {
|
|
64
|
+
// A pack's own declarative rule runs inside the static pipeline.
|
|
65
|
+
if (ruleId.startsWith("pack:")) return "source";
|
|
66
|
+
// Agent findings are the model's ruling, by construction.
|
|
67
|
+
if (ruleId.startsWith("agent:")) return "judgment";
|
|
68
|
+
// axe checks and the dynamic probes only ever come back from `scan`.
|
|
69
|
+
if (ruleId.startsWith("axe:") || ruleId.startsWith("dyn-")) return "browser";
|
|
70
|
+
// A rendered rule reads a snapshot's computed styles, boxes and screenshot — a captured
|
|
71
|
+
// page, but no live browser.
|
|
72
|
+
if (ruleId.startsWith("rendered-")) return "rendered-page";
|
|
73
|
+
if (CROSS_RULE_IDS.has(ruleId)) return "cross-file";
|
|
74
|
+
if (STATIC_RULE_IDS.has(ruleId)) return "source";
|
|
75
|
+
// A wildcard (`*`) is the legacy fan-out: it matches every engine finding, so the static
|
|
76
|
+
// tier is the honest floor. Anything else is an id no registry claims.
|
|
77
|
+
if (pack && ruleId.endsWith("*")) return "source";
|
|
78
|
+
return "source";
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
function cheapest(tiers: Tier[]): Tier | undefined {
|
|
82
|
+
for (const t of TIER_ORDER) if (tiers.includes(t)) return t;
|
|
83
|
+
return undefined;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
function dedupe(tiers: Tier[]): Tier[] {
|
|
87
|
+
return TIER_ORDER.filter((t) => tiers.includes(t));
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
/** Coverage for one WCAG success criterion. */
|
|
91
|
+
function coreCoverage(sc: string): Coverage | undefined {
|
|
92
|
+
const c = getSC(sc);
|
|
93
|
+
if (!c) return undefined;
|
|
94
|
+
const engineRules = [...c.ruleIds];
|
|
95
|
+
const tiers = engineRules.map((r) => ruleTier(r));
|
|
96
|
+
|
|
97
|
+
if (c.automatability === "judgment") {
|
|
98
|
+
return {
|
|
99
|
+
tier: "judgment",
|
|
100
|
+
sourceIsEnough: false,
|
|
101
|
+
canFailFrom: dedupe(tiers),
|
|
102
|
+
alsoNeeds: [],
|
|
103
|
+
engineRules,
|
|
104
|
+
applicabilityDeclared: true,
|
|
105
|
+
why: "WCAG automatability class `judgment`: the evidence can be gathered, but the verdict is a reading of meaning.",
|
|
106
|
+
};
|
|
107
|
+
}
|
|
108
|
+
if (c.automatability === "needs-rendering") {
|
|
109
|
+
const tier: Tier = tiers.includes("rendered-page") ? "rendered-page" : "browser";
|
|
110
|
+
return {
|
|
111
|
+
tier,
|
|
112
|
+
sourceIsEnough: false,
|
|
113
|
+
canFailFrom: dedupe(tiers),
|
|
114
|
+
alsoNeeds: dedupe(tiers).filter((t) => t !== tier),
|
|
115
|
+
engineRules,
|
|
116
|
+
applicabilityDeclared: true,
|
|
117
|
+
why:
|
|
118
|
+
tier === "rendered-page"
|
|
119
|
+
? "WCAG automatability class `needs-rendering`, and a rendered-* rule covers it: a captured page snapshot is enough, no live browser."
|
|
120
|
+
: "WCAG automatability class `needs-rendering`: only a real browser produces this evidence.",
|
|
121
|
+
};
|
|
122
|
+
}
|
|
123
|
+
const tier = cheapest(tiers) ?? "source";
|
|
124
|
+
return {
|
|
125
|
+
tier,
|
|
126
|
+
sourceIsEnough: tier === "source",
|
|
127
|
+
canFailFrom: dedupe(tiers),
|
|
128
|
+
alsoNeeds: dedupe(tiers).filter((t) => t !== tier),
|
|
129
|
+
engineRules,
|
|
130
|
+
applicabilityDeclared: true,
|
|
131
|
+
why: "WCAG automatability class `static`: an engine rule decides it from the source tree.",
|
|
132
|
+
};
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
/**
|
|
136
|
+
* Every mapped SC sits outside the engine's WCAG 2.2 AA core (AAA, or removed).
|
|
137
|
+
*
|
|
138
|
+
* Deliberately the SAME predicate as `derivePackResults` in derive.ts — a criterion the
|
|
139
|
+
* projection files as out of scope must be the one the plan files as out of scope, or the
|
|
140
|
+
* plan is telling the auditor to gather evidence for a verdict that can never be rendered.
|
|
141
|
+
*/
|
|
142
|
+
function outOfCore(pc: PackCriterion): boolean {
|
|
143
|
+
return pc.wcag.every((sc) => {
|
|
144
|
+
const s = knownScStatus(sc);
|
|
145
|
+
return s === "out-of-core" || s === "removed";
|
|
146
|
+
});
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
function packCoverage(pack: StandardPack, pc: PackCriterion): Coverage {
|
|
150
|
+
if (outOfCore(pc)) {
|
|
151
|
+
return {
|
|
152
|
+
tier: "out-of-scope",
|
|
153
|
+
sourceIsEnough: false,
|
|
154
|
+
canFailFrom: [],
|
|
155
|
+
alsoNeeds: [],
|
|
156
|
+
engineRules: [],
|
|
157
|
+
applicabilityDeclared: true,
|
|
158
|
+
why: `every WCAG success criterion this maps to (${pc.wcag.join(", ")}) is outside the engine's 2.2 AA core — AAA or removed. Declare it out of scope; never claim conformity.`,
|
|
159
|
+
};
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
const declared = pc.appliesTo?.ruleIds;
|
|
163
|
+
if (declared === undefined) {
|
|
164
|
+
// No applicability at all: classify from the mapped SCs alone, and say the plan is
|
|
165
|
+
// coarser than it would be for a pack that declares one.
|
|
166
|
+
const tiers = pc.wcag.map((sc) => coreCoverage(sc)?.tier).filter((t): t is Tier => t !== undefined);
|
|
167
|
+
const tier = tiers.includes("judgment") ? "judgment" : (cheapest(tiers) ?? "judgment");
|
|
168
|
+
return {
|
|
169
|
+
tier,
|
|
170
|
+
sourceIsEnough: tier === "source",
|
|
171
|
+
canFailFrom: dedupe(tiers),
|
|
172
|
+
alsoNeeds: dedupe(tiers).filter((t) => t !== tier),
|
|
173
|
+
engineRules: [],
|
|
174
|
+
applicabilityDeclared: false,
|
|
175
|
+
why: "the pack declares no rule applicability, so this is inferred from the WCAG success criteria it maps to.",
|
|
176
|
+
};
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
const engineRules = [...declared];
|
|
180
|
+
const tiers = engineRules.map((r) => ruleTier(r, pack));
|
|
181
|
+
|
|
182
|
+
if (pc.judgment) {
|
|
183
|
+
return {
|
|
184
|
+
tier: "judgment",
|
|
185
|
+
sourceIsEnough: false,
|
|
186
|
+
canFailFrom: dedupe(tiers),
|
|
187
|
+
alsoNeeds: [],
|
|
188
|
+
engineRules,
|
|
189
|
+
applicabilityDeclared: true,
|
|
190
|
+
why: "the standard flags this criterion as judgment: it asks more than its mapped success criteria, so it never inherits a Conforming verdict — though an engine rule can still fail it.",
|
|
191
|
+
};
|
|
192
|
+
}
|
|
193
|
+
if (!engineRules.length) {
|
|
194
|
+
return {
|
|
195
|
+
tier: "judgment",
|
|
196
|
+
sourceIsEnough: false,
|
|
197
|
+
canFailFrom: [],
|
|
198
|
+
alsoNeeds: [],
|
|
199
|
+
engineRules,
|
|
200
|
+
applicabilityDeclared: true,
|
|
201
|
+
why: "the standard declares that no engine rule can evidence this criterion — it is yours to rule on.",
|
|
202
|
+
};
|
|
203
|
+
}
|
|
204
|
+
const tier = cheapest(tiers) ?? "judgment";
|
|
205
|
+
return {
|
|
206
|
+
tier,
|
|
207
|
+
sourceIsEnough: tier === "source",
|
|
208
|
+
canFailFrom: dedupe(tiers),
|
|
209
|
+
alsoNeeds: dedupe(tiers).filter((t) => t !== tier),
|
|
210
|
+
engineRules,
|
|
211
|
+
applicabilityDeclared: true,
|
|
212
|
+
why: `the standard declares ${engineRules.length} applicable engine rule${engineRules.length === 1 ? "" : "s"}; the cheapest evidence tier among them is \`${tier}\`.`,
|
|
213
|
+
};
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
/** Coverage for one criterion of any standard. Undefined when the id does not exist. */
|
|
217
|
+
export function criterionCoverage(standard: StandardId, id: string): Coverage | undefined {
|
|
218
|
+
if (isCore(standard)) return coreCoverage(id);
|
|
219
|
+
const pack = loadPack(standard);
|
|
220
|
+
const pc = getCriterion(pack, id);
|
|
221
|
+
return pc ? packCoverage(pack, pc) : undefined;
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
/** Coverage for every criterion of a standard, keyed by criterion id. */
|
|
225
|
+
export function standardCoverage(standard: StandardId): Map<string, Coverage> {
|
|
226
|
+
const out = new Map<string, Coverage>();
|
|
227
|
+
if (isCore(standard)) {
|
|
228
|
+
for (const c of allSC()) {
|
|
229
|
+
const cov = coreCoverage(c.sc);
|
|
230
|
+
if (cov) out.set(c.sc, cov);
|
|
231
|
+
}
|
|
232
|
+
return out;
|
|
233
|
+
}
|
|
234
|
+
const pack = loadPack(standard);
|
|
235
|
+
for (const pc of allCriteria(pack)) out.set(pc.id, packCoverage(pack, pc));
|
|
236
|
+
return out;
|
|
237
|
+
}
|
package/src/standards/index.ts
CHANGED
package/src/standards/pack.ts
CHANGED
|
@@ -58,6 +58,25 @@ export function resolveGlossary(packKey: string, anchor: string): GlossaryEntry
|
|
|
58
58
|
return packGlossary(packKey)?.[anchor];
|
|
59
59
|
}
|
|
60
60
|
|
|
61
|
+
// A criterion's tests refer constantly to normatively-defined terms
|
|
62
|
+
// (`[alternative textuelle](#alternative-textuelle-image)`). The definitions live in the
|
|
63
|
+
// pack's glossary — 119 entries for RGAA — and they are normative: "relevant" and "if
|
|
64
|
+
// necessary" mean what the glossary says they mean. Attaching the ones THIS criterion's
|
|
65
|
+
// tests actually cite makes a criterion lookup self-sufficient.
|
|
66
|
+
const GLOSSARY_REF = /\[[^\]]+\]\(#([^)]+)\)/g;
|
|
67
|
+
|
|
68
|
+
/** The glossary anchors a criterion's tests / notes / particular cases refer to, in order. */
|
|
69
|
+
export function glossaryAnchorsOf(crit: { tests?: Record<string, string[]>; technicalNote?: string[]; particularCases?: string[] } | undefined): string[] {
|
|
70
|
+
if (!crit) return [];
|
|
71
|
+
const texts = [...Object.values(crit.tests ?? {}).flat(), ...(crit.technicalNote ?? []), ...(crit.particularCases ?? [])];
|
|
72
|
+
const seen = new Set<string>();
|
|
73
|
+
for (const t of texts) {
|
|
74
|
+
GLOSSARY_REF.lastIndex = 0;
|
|
75
|
+
for (let m = GLOSSARY_REF.exec(t); m; m = GLOSSARY_REF.exec(t)) if (m[1]) seen.add(m[1]);
|
|
76
|
+
}
|
|
77
|
+
return [...seen];
|
|
78
|
+
}
|
|
79
|
+
|
|
61
80
|
/** Replace every unescaped `(` that does NOT open a special construct with a
|
|
62
81
|
* non-capturing `(?:` — i.e. neutralize the pattern's OWN capturing groups.
|
|
63
82
|
* Leaves alone: an escaped literal (`\(`) and any `(?...` construct (`(?:`, `(?=`,
|
|
@@ -2,6 +2,7 @@
|
|
|
2
2
|
// plus the statically-imported country PACKS. Adding a country = drop a pack JSON under
|
|
3
3
|
// src/data/standards/ and add one `register(...)` line here (static imports are required
|
|
4
4
|
// so tsup inlines every pack into the single zero-dependency bundle — no runtime fs/glob).
|
|
5
|
+
import { AsyncLocalStorage } from "node:async_hooks";
|
|
5
6
|
import rgaaPack from "../data/standards/rgaa.json";
|
|
6
7
|
import rgaaGlossary from "../data/standards/rgaa.glossary.json";
|
|
7
8
|
import type { LocaleString, StandardPack } from "./types.js";
|
|
@@ -17,6 +18,111 @@ interface Registered {
|
|
|
17
18
|
|
|
18
19
|
const registry = new Map<string, Registered>();
|
|
19
20
|
|
|
21
|
+
// ---- project scopes -------------------------------------------------------
|
|
22
|
+
//
|
|
23
|
+
// The registry above is process-wide, which is right for a CLI run: one process,
|
|
24
|
+
// one project, packs resolved once at startup. A long-lived MCP server is the
|
|
25
|
+
// other shape — it takes a `cwd` PER CALL, so two projects can each declare a
|
|
26
|
+
// pack under the same key, and the second must not overwrite the first.
|
|
27
|
+
//
|
|
28
|
+
// So a scope is an OVERLAY: packs registered for one project root, consulted
|
|
29
|
+
// before the global registry and invisible to every other root. Nothing that
|
|
30
|
+
// runs outside a scope changes behaviour.
|
|
31
|
+
//
|
|
32
|
+
// The ambient scope travels in an AsyncLocalStorage rather than a module-level
|
|
33
|
+
// variable, and that is load-bearing. The `audit()` helper in src/mcp/handlers.ts
|
|
34
|
+
// gets away with a process-global `process.chdir` only because `runAudit` is
|
|
35
|
+
// synchronous — no await point between the chdir and its `finally`, so the event
|
|
36
|
+
// loop cannot interleave another project. The MCP dispatch IS async: a plain
|
|
37
|
+
// "current scope" variable would race across concurrent project roots.
|
|
38
|
+
const overlays = new Map<string, Map<string, Registered>>();
|
|
39
|
+
const scopeStore = new AsyncLocalStorage<string>();
|
|
40
|
+
|
|
41
|
+
function currentOverlay(): Map<string, Registered> | undefined {
|
|
42
|
+
const scope = scopeStore.getStore();
|
|
43
|
+
return scope === undefined ? undefined : overlays.get(scope);
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/** The overlay's entry for `key`, else the global one. */
|
|
47
|
+
function lookup(key: string): Registered | undefined {
|
|
48
|
+
return currentOverlay()?.get(key) ?? registry.get(key);
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/** Global registrations, with the current scope's overlay laid on top. */
|
|
52
|
+
function visible(): Map<string, Registered> {
|
|
53
|
+
const overlay = currentOverlay();
|
|
54
|
+
if (!overlay?.size) return registry;
|
|
55
|
+
const merged = new Map(registry);
|
|
56
|
+
for (const [key, entry] of overlay) merged.set(key, entry);
|
|
57
|
+
return merged;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/** Run `fn` with `scope`'s packs laid over the global registry. No scope ⇒ unchanged. */
|
|
61
|
+
export function withScope<T>(scope: string | undefined, fn: () => T): T {
|
|
62
|
+
return scope === undefined ? fn() : scopeStore.run(scope, fn);
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/**
|
|
66
|
+
* Register an external pack INTO one project scope — `registerRuntimePack`'s per-project
|
|
67
|
+
* counterpart, with the same validation guardrail. Collisions are judged against what THIS
|
|
68
|
+
* scope can see (the core, the built-ins, and packs already in this overlay), so two
|
|
69
|
+
* projects may each ship a different `section508` without either shadowing the other.
|
|
70
|
+
*/
|
|
71
|
+
export function registerScoped(scope: string, raw: unknown, glossary: Glossary = {}, opts: { override?: boolean } = {}): PackValidation {
|
|
72
|
+
const overlay = ensureScope(scope);
|
|
73
|
+
const known = new Set([CORE_KEY, ...registry.keys(), ...overlay.keys()]);
|
|
74
|
+
const v = validatePack(raw, { knownKeys: known, allowOverride: opts.override });
|
|
75
|
+
if (v.ok && v.pack) overlay.set(v.pack.key, { pack: v.pack, glossary });
|
|
76
|
+
return v;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/**
|
|
80
|
+
* The pack object to MUTATE — `enableSecondaryMapping`'s copy-on-write guard.
|
|
81
|
+
*
|
|
82
|
+
* Inside a project scope, a built-in resolves from the shared global registry, and
|
|
83
|
+
* `enableSecondaryMapping` edits its `secondaryMappings` in place. One project's
|
|
84
|
+
* `.ultra11yrc.json` would then re-key the built-in RGAA pack for every OTHER project the
|
|
85
|
+
* same server is serving — a silently different projection, which is precisely the failure
|
|
86
|
+
* this tool must never produce. So a global pack is cloned into the overlay first, and the
|
|
87
|
+
* project edits its own copy.
|
|
88
|
+
*/
|
|
89
|
+
function packForMutation(key: string): StandardPack {
|
|
90
|
+
const overlay = currentOverlay();
|
|
91
|
+
if (!overlay) return loadPack(key);
|
|
92
|
+
const own = overlay.get(key);
|
|
93
|
+
if (own) return own.pack;
|
|
94
|
+
const shared = registry.get(key);
|
|
95
|
+
if (!shared) return loadPack(key); // throws, with the known-packs list
|
|
96
|
+
const copy: Registered = { pack: structuredClone(shared.pack), glossary: shared.glossary };
|
|
97
|
+
overlay.set(key, copy);
|
|
98
|
+
return copy.pack;
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
/** Create the scope's (possibly empty) overlay and return it. */
|
|
102
|
+
export function ensureScope(scope: string): Map<string, Registered> {
|
|
103
|
+
let overlay = overlays.get(scope);
|
|
104
|
+
if (!overlay) {
|
|
105
|
+
overlay = new Map();
|
|
106
|
+
overlays.set(scope, overlay);
|
|
107
|
+
}
|
|
108
|
+
return overlay;
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
/** Whether this scope's packs have already been resolved — the memo the MCP server reads. */
|
|
112
|
+
export function scopeLoaded(scope: string): boolean {
|
|
113
|
+
return overlays.has(scope);
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
/** Forget one scope, so a failed load is retried rather than memoized as "done". */
|
|
117
|
+
export function dropScope(scope: string): void {
|
|
118
|
+
overlays.delete(scope);
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
/** Test seam: forget every project scope. Never call this from product code. */
|
|
122
|
+
export function resetScopes(): void {
|
|
123
|
+
overlays.clear();
|
|
124
|
+
}
|
|
125
|
+
|
|
20
126
|
function register(pack: StandardPack, glossary: Glossary): void {
|
|
21
127
|
if (pack.key === CORE_KEY) throw new Error(`pack key "${CORE_KEY}" is reserved for the WCAG core`);
|
|
22
128
|
registry.set(pack.key, { pack, glossary });
|
|
@@ -46,7 +152,7 @@ export function registerRuntimePack(raw: unknown, glossary: Glossary = {}, opts:
|
|
|
46
152
|
* pack key (never a silent no-op). Mutates the registered pack in place, by design.
|
|
47
153
|
*/
|
|
48
154
|
export function enableSecondaryMapping(packKey: string, m: { ruleId: string; criterion: string; note?: LocaleString }): void {
|
|
49
|
-
const pack =
|
|
155
|
+
const pack = packForMutation(packKey); // throws on unknown key
|
|
50
156
|
const list = (pack.secondaryMappings ??= []);
|
|
51
157
|
const existing = list.find((x) => x.ruleId === m.ruleId && x.criterion === m.criterion);
|
|
52
158
|
if (existing) {
|
|
@@ -62,37 +168,37 @@ export function isCore(key: string): boolean {
|
|
|
62
168
|
}
|
|
63
169
|
|
|
64
170
|
export function hasStandard(key: string): boolean {
|
|
65
|
-
return key === CORE_KEY ||
|
|
171
|
+
return key === CORE_KEY || lookup(key) !== undefined;
|
|
66
172
|
}
|
|
67
173
|
|
|
68
174
|
/** Resolve a pack by key; throws on unknown (never silently falls back to the core). */
|
|
69
175
|
export function loadPack(key: string): StandardPack {
|
|
70
|
-
const r =
|
|
71
|
-
if (!r) throw new Error(`unknown standards pack "${key}" (known packs: ${[...
|
|
176
|
+
const r = lookup(key);
|
|
177
|
+
if (!r) throw new Error(`unknown standards pack "${key}" (known packs: ${[...visible().keys()].join(", ") || "none"})`);
|
|
72
178
|
return r.pack;
|
|
73
179
|
}
|
|
74
180
|
|
|
75
181
|
export function getPack(key: string): StandardPack | undefined {
|
|
76
|
-
return
|
|
182
|
+
return lookup(key)?.pack;
|
|
77
183
|
}
|
|
78
184
|
|
|
79
185
|
export function packGlossary(key: string): Glossary | undefined {
|
|
80
|
-
return
|
|
186
|
+
return lookup(key)?.glossary;
|
|
81
187
|
}
|
|
82
188
|
|
|
83
189
|
/** All standard keys: the core first, then every registered pack. */
|
|
84
190
|
export function listStandards(): string[] {
|
|
85
|
-
return [CORE_KEY, ...
|
|
191
|
+
return [CORE_KEY, ...visible().keys()];
|
|
86
192
|
}
|
|
87
193
|
|
|
88
194
|
export function listPacks(): StandardPack[] {
|
|
89
|
-
return [...
|
|
195
|
+
return [...visible().values()].map((r) => r.pack);
|
|
90
196
|
}
|
|
91
197
|
|
|
92
198
|
/** Reverse cross-reference: which packs (and their criterion ids) map to a WCAG SC. */
|
|
93
199
|
export function packsForSc(sc: string): { key: string; ids: string[] }[] {
|
|
94
200
|
const out: { key: string; ids: string[] }[] = [];
|
|
95
|
-
for (const { pack } of
|
|
201
|
+
for (const { pack } of visible().values()) {
|
|
96
202
|
const ids = pack.criteria.filter((c) => c.wcag.includes(sc)).map((c) => c.id);
|
|
97
203
|
if (ids.length) out.push({ key: pack.key, ids });
|
|
98
204
|
}
|
package/src/types.ts
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
// Single source of truth for shared types + the embedded version. sync-version.mjs
|
|
2
2
|
// rewrites VERSION at release time (kept in lockstep with package.json + SKILL.md).
|
|
3
|
-
export const VERSION = "4.
|
|
3
|
+
export const VERSION = "4.4.0";
|
|
4
4
|
export const SCHEMA_VERSION = 2;
|
|
5
5
|
|
|
6
6
|
// Lang = the UI FRAME's language — the `L` localization tables each module (report,
|
|
@@ -69,6 +69,23 @@ export interface Sc {
|
|
|
69
69
|
ruleIds: string[]; // engine rules contributing to this SC
|
|
70
70
|
understanding: string; // W3C Understanding doc URL (manual-check grounding)
|
|
71
71
|
techniques?: string[]; // language-neutral W3C technique codes
|
|
72
|
+
// The criterion's NORMATIVE wording, verbatim from the W3C source, with its exceptions
|
|
73
|
+
// and notes as labelled lines. Optional only so an older wcag.json snapshot still
|
|
74
|
+
// parses — a freshly built dataset carries it for every criterion or the build fails.
|
|
75
|
+
text?: string;
|
|
76
|
+
// The same wording from the W3C AUTHORIZED French translation. Held to the same
|
|
77
|
+
// completeness rule as `titleFr`: the build fails rather than let `--lang fr` render a
|
|
78
|
+
// French heading over English requirement prose.
|
|
79
|
+
textFr?: string;
|
|
80
|
+
// The glossary slugs this criterion's wording LINKS to in the W3C source — the defined
|
|
81
|
+
// terms it actually cites, not words that happen to appear in it.
|
|
82
|
+
terms?: string[];
|
|
83
|
+
// The same, for the French translation. The two pages do NOT share slugs — the FR page
|
|
84
|
+
// names several definitions in the plural ("user-agents" where the English source file is
|
|
85
|
+
// "user-agent") and a few differently outright ("purpose-of-each-link" for "link-purpose").
|
|
86
|
+
// Mapping one onto the other would be a guess, so each language keeps its own list and
|
|
87
|
+
// resolves against its own glossary.
|
|
88
|
+
termsFr?: string[];
|
|
72
89
|
tests?: string[]; // optional manual-check lines
|
|
73
90
|
notes?: string;
|
|
74
91
|
}
|
|
@@ -93,6 +110,12 @@ export interface WcagData {
|
|
|
93
110
|
principles: WcagPrinciple[];
|
|
94
111
|
guidelines: WcagGuideline[];
|
|
95
112
|
criteria: Sc[];
|
|
113
|
+
// The terms WCAG itself normatively defines ("text alternative", "programmatically
|
|
114
|
+
// determined"…), keyed by the W3C's own dfn slug. Optional so an older wcag.json
|
|
115
|
+
// snapshot still parses; see src/wcag.ts `coreGlossary`.
|
|
116
|
+
glossary?: Glossary;
|
|
117
|
+
// The same terms from the W3C authorized French translation, under the SAME slugs.
|
|
118
|
+
glossaryFr?: Glossary;
|
|
96
119
|
}
|
|
97
120
|
|
|
98
121
|
// ---- WCAG SC universe (src/data/wcag-universe.json, produced by
|