ultra11y 4.4.0 → 4.5.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.
@@ -10,7 +10,7 @@
10
10
  "name": "ultra11y",
11
11
  "source": "./",
12
12
  "description": "Automatic accessibility review of the code under change before every commit, push and pull request, plus full WCAG 2.2 AA / RGAA audits and conformance deliverables on demand.",
13
- "version": "4.4.0",
13
+ "version": "4.5.0",
14
14
  "author": { "name": "maxgfr" },
15
15
  "homepage": "https://github.com/maxgfr/ultra11y",
16
16
  "repository": "https://github.com/maxgfr/ultra11y",
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "ultra11y",
3
3
  "description": "Accessibility for WCAG 2.2 AA and country standards (RGAA…): an automatic review of the code under change before every commit, push and pull request, plus full audits and conformance deliverables on demand.",
4
- "version": "4.4.0",
4
+ "version": "4.5.0",
5
5
  "author": { "name": "maxgfr" },
6
6
  "homepage": "https://github.com/maxgfr/ultra11y",
7
7
  "repository": "https://github.com/maxgfr/ultra11y",
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "ultra11y",
3
3
  "description": "Accessibility for WCAG 2.2 AA and country standards (RGAA…): an automatic review of the code under change before every commit, push and pull request, plus full audits and conformance deliverables on demand.",
4
- "version": "4.4.0",
4
+ "version": "4.5.0",
5
5
  "author": { "name": "maxgfr", "url": "https://github.com/maxgfr" },
6
6
  "homepage": "https://github.com/maxgfr/ultra11y",
7
7
  "repository": "https://github.com/maxgfr/ultra11y",
@@ -27,7 +27,7 @@ import { fileURLToPath } from "node:url";
27
27
  // Ownership marker: `ultra11y install --opencode` refuses to overwrite a plugin file that
28
28
  // does not carry it, so a user's own ultra11y.js is never clobbered.
29
29
  const ULTRA11Y_OPENCODE_PLUGIN = "ULTRA11Y_OPENCODE_PLUGIN";
30
- const ULTRA11Y_PLUGIN_VERSION = "4.4.0";
30
+ const ULTRA11Y_PLUGIN_VERSION = "4.5.0";
31
31
 
32
32
  const here = dirname(fileURLToPath(import.meta.url));
33
33
 
package/README.md CHANGED
@@ -125,7 +125,7 @@ claude mcp add ultra11y -- npx -y ultra11y mcp
125
125
  **As a GitHub Action** — audits the PR diff and, optionally, the served pages:
126
126
 
127
127
  ```yaml
128
- - uses: maxgfr/ultra11y@v3
128
+ - uses: maxgfr/ultra11y@v4
129
129
  with: { since: auto, standard: rgaa, fail-on: blocking }
130
130
  ```
131
131
 
@@ -383,7 +383,7 @@ a test). See [`CONTRIBUTING.md`](CONTRIBUTING.md) and `skills/ultra11y/reference
383
383
  block. It re-decides nothing and invents no format, so a page sheet and the compliance report
384
384
  cannot disagree; `check` gates it against invented criteria like any other report.
385
385
  See `references/pages.md`.
386
- - **A shipped GitHub Action** — `uses: maxgfr/ultra11y@main` audits the **code** (PR diff) and,
386
+ - **A shipped GitHub Action** — `uses: maxgfr/ultra11y@v4` audits the **code** (PR diff) and,
387
387
  optionally, the **pages** (it can start your app, wait for it, then scan real URLs or your
388
388
  declared sample). The engine ships inside the action, so there is nothing to install and no
389
389
  `setup-node`. The gate runs **last**, after SARIF, annotations, the summary, the sticky PR
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "ultra11y",
3
- "version": "4.4.0",
3
+ "version": "4.5.0",
4
4
  "description": "Audit HTML/CSS/JSX against WCAG 2.2 AA and produce a dated compliance report, or author/review accessible markup (native-HTML-first). A skills.sh agent skill: an install-free static engine (bundled, no npm install) plus the agent's judgment, with check/verify gates against hallucinated non-conformities, and pluggable in-repo standards packs (RGAA France first; Section 508 & EN 301 549 next).",
5
5
  "type": "module",
6
6
  "packageManager": "pnpm@10.33.0",
@@ -58,6 +58,8 @@
58
58
  "build:wcag": "node scripts/build-standards.mjs",
59
59
  "build:pack:rgaa": "node scripts/build-pack-rgaa.mjs",
60
60
  "check:pack:rgaa": "node scripts/build-pack-rgaa.mjs --check",
61
+ "build:guidance:wcag": "node scripts/build-guidance-wcag.mjs",
62
+ "check:guidance:wcag": "node scripts/build-guidance-wcag.mjs --check",
61
63
  "build:criteria": "node scripts/ultra11y.mjs criteria --generate > skills/ultra11y/references/criteria.md",
62
64
  "build:act": "UPDATE_ACT_MATRIX=1 vitest run tests/act-matrix-sync.test.ts",
63
65
  "build:act:refresh": "node scripts/build-act-corpus.mjs --refresh",
@@ -0,0 +1,335 @@
1
+ #!/usr/bin/env node
2
+ // DEV-ONLY (not in `bin`). Builds src/data/guidance/wcag.json — the WCAG-keyed
3
+ // implementation guidance — from the W3C's own Techniques and Understanding documents.
4
+ //
5
+ // Nothing here is hand-written. The RGAA guidance is derived from an upstream rule source
6
+ // and rebuilt by a script; this one had been authored by hand, which made it the only
7
+ // dataset in the repo whose content nobody could re-derive or check against a source. Now
8
+ // it is generated the same way everything else is:
9
+ //
10
+ // · WHICH techniques implement a criterion — understanding/understanding.11tydata.js,
11
+ // the W3C's own machine-readable association map (sufficient / advisory / failure).
12
+ // · WHAT the criterion asks — the "In brief" block of understanding/<ver>/<slug>.html
13
+ // (Goal / What to do / Why it's important), written by the WCAG working group.
14
+ // · The GOOD example — the first sufficient technique carrying an HTML or CSS code
15
+ // sample, in the W3C's own listing order.
16
+ // · The BAD example — the first documented FAILURE carrying one.
17
+ //
18
+ // The selection is positional, never editorial: "the first one that has a code sample",
19
+ // in the order W3C lists them. A criterion whose techniques carry no HTML/CSS sample gets
20
+ // an entry with the official summary and no example, rather than an invented one.
21
+ //
22
+ // WCAG 2.2 © W3C, reused under the W3C Document License; the code samples are Code
23
+ // Components under the W3C Software License. See NOTICE.
24
+ //
25
+ // Usage:
26
+ // node scripts/build-guidance-wcag.mjs --refresh # re-fetch (network) the vendored snapshot
27
+ // node scripts/build-guidance-wcag.mjs # emit the dataset from the snapshot
28
+ // node scripts/build-guidance-wcag.mjs --check # verify the committed dataset matches
29
+ import { writeFileSync, readFileSync, existsSync, mkdirSync, rmSync } from "node:fs";
30
+ import { execFileSync } from "node:child_process";
31
+ import { dirname, join } from "node:path";
32
+ import { fileURLToPath } from "node:url";
33
+ import { tmpdir } from "node:os";
34
+
35
+ const root = join(dirname(fileURLToPath(import.meta.url)), "..");
36
+ const VENDOR = join(root, "scripts", "vendor", "wcag-2.2-techniques.json");
37
+ const SNAPSHOT = join(root, "scripts", "vendor", "wcag-2.2-sc.json");
38
+ const OUT = join(root, "src", "data", "guidance", "wcag.json");
39
+ const BIOME = join(root, "node_modules", ".bin", "biome");
40
+ const RAW = "https://raw.githubusercontent.com/w3c/wcag/main";
41
+ const VER_OF = { "2.0": "20", "2.1": "21", "2.2": "22" };
42
+
43
+ // Technique id → the directory it lives in. The W3C names these by technology, and the
44
+ // prefix is the technology — no directory listing needed, and no id ever special-cased.
45
+ const TECH_DIR = {
46
+ ARIA: "aria",
47
+ C: "css",
48
+ F: "failures",
49
+ FLASH: "flash",
50
+ G: "general",
51
+ H: "html",
52
+ PDF: "pdf",
53
+ SCR: "client-side-script",
54
+ SL: "silverlight",
55
+ SM: "smil",
56
+ SVR: "server-side-script",
57
+ T: "text",
58
+ };
59
+
60
+ function techDir(id) {
61
+ const prefix = (id.match(/^[A-Z]+/) || [])[0];
62
+ return TECH_DIR[prefix];
63
+ }
64
+
65
+ function biomeFormat(text, relPath) {
66
+ return execFileSync(BIOME, ["format", `--stdin-file-path=${relPath}`], { input: text, encoding: "utf8" });
67
+ }
68
+
69
+ async function fetchText(path) {
70
+ const r = await fetch(`${RAW}/${path}`);
71
+ if (!r.ok) return null; // a technique listed but not present is data, not a crash
72
+ return r.text();
73
+ }
74
+
75
+ const ENTITIES = {
76
+ "&nbsp;": " ",
77
+ "&amp;": "&",
78
+ "&lt;": "<",
79
+ "&gt;": ">",
80
+ "&quot;": '"',
81
+ "&apos;": "'",
82
+ "&rsquo;": "’",
83
+ "&lsquo;": "‘",
84
+ "&ldquo;": "“",
85
+ "&rdquo;": "”",
86
+ "&mdash;": "—",
87
+ "&ndash;": "–",
88
+ "&hellip;": "…",
89
+ };
90
+ function decodeEntities(s) {
91
+ return s
92
+ .replace(/&#(\d+);/g, (_, d) => String.fromCodePoint(Number(d)))
93
+ .replace(/&#x([0-9a-f]+);/gi, (_, h) => String.fromCodePoint(Number.parseInt(h, 16)))
94
+ .replace(/&[a-z]+;/gi, (m) => ENTITIES[m] ?? m);
95
+ }
96
+
97
+ /** Tags out, entities in, whitespace collapsed — for prose, never for code. */
98
+ function plain(html) {
99
+ return decodeEntities(html.replace(/<[^>]+>/g, " "))
100
+ .replace(/\s+/g, " ")
101
+ .trim();
102
+ }
103
+
104
+ /**
105
+ * The W3C's own association map, read by EVALUATING the module rather than regexing it.
106
+ *
107
+ * It is an ES module exporting a function of 11ty's data cascade; calling it with a stub
108
+ * yields the map exactly as the W3C's own site build sees it. Parsing this shape with a
109
+ * regex would be guesswork, and it is dev-only code from the same repo this project already
110
+ * derives its entire WCAG dataset from.
111
+ */
112
+ async function fetchAssociations() {
113
+ const src = await fetchText("understanding/understanding.11tydata.js");
114
+ if (!src) throw new Error("build-guidance-wcag: understanding.11tydata.js not found upstream");
115
+ const tmp = join(tmpdir(), `u11y-assoc-${process.pid}.mjs`);
116
+ writeFileSync(tmp, src);
117
+ try {
118
+ const mod = await import(`file://${tmp}`);
119
+ return mod.default({ understandingUrl: "" }).associatedTechniques ?? {};
120
+ } finally {
121
+ rmSync(tmp, { force: true });
122
+ }
123
+ }
124
+
125
+ /** Every technique id an entry mentions, flattened, in the W3C's own listing order. */
126
+ function flattenTechniques(node, out = []) {
127
+ if (node === null || node === undefined) return out;
128
+ if (typeof node === "string") {
129
+ // Ids only: the map also carries free prose ("Providing a descriptive label").
130
+ if (/^[A-Z]+\d+$/.test(node)) out.push(node);
131
+ return out;
132
+ }
133
+ if (Array.isArray(node)) {
134
+ for (const n of node) flattenTechniques(n, out);
135
+ return out;
136
+ }
137
+ if (typeof node === "object") {
138
+ // { and: [...] } / { title, techniques, groups } — order matters, so walk explicitly.
139
+ for (const key of ["and", "techniques", "groups", "using"]) {
140
+ if (node[key]) flattenTechniques(node[key], out);
141
+ }
142
+ return out;
143
+ }
144
+ return out;
145
+ }
146
+
147
+ /** The first HTML or CSS code sample in a technique document, with the prose above it. */
148
+ function firstCodeExample(html) {
149
+ const EX = /<section class="example">([\s\S]*?)<\/section>/g;
150
+ let m;
151
+ while ((m = EX.exec(html))) {
152
+ const block = m[1];
153
+ const code = /<pre><code class="language-(html|css)">([\s\S]*?)<\/code><\/pre>/.exec(block);
154
+ if (!code) continue;
155
+ const snippet = decodeEntities(code[2]).trim();
156
+ if (!snippet) continue;
157
+ const before = block.slice(0, code.index);
158
+ return { lang: code[1], code: snippet, note: plain(before) };
159
+ }
160
+ return null;
161
+ }
162
+
163
+ function techniqueTitle(html) {
164
+ const h1 = /<h1>([\s\S]*?)<\/h1>/.exec(html);
165
+ return h1 ? plain(h1[1]) : "";
166
+ }
167
+
168
+ /** The "In brief" block: the working group's own Goal / What to do / Why. */
169
+ function briefOf(html) {
170
+ const sec = /<section id="brief">([\s\S]*?)<\/section>/.exec(html);
171
+ if (!sec) return null;
172
+ const out = {};
173
+ const DL = /<dt>([\s\S]*?)<\/dt>\s*<dd>([\s\S]*?)<\/dd>/g;
174
+ let m;
175
+ while ((m = DL.exec(sec[1]))) out[plain(m[1]).toLowerCase().replace(/[^a-z]+/g, "")] = plain(m[2]);
176
+ return Object.keys(out).length ? out : null;
177
+ }
178
+
179
+ async function refresh() {
180
+ if (!existsSync(SNAPSHOT)) {
181
+ console.error(`build-guidance-wcag --refresh: missing ${SNAPSHOT}. Run: node scripts/build-standards.mjs --refresh <w3c/wcag checkout>`);
182
+ process.exit(1);
183
+ }
184
+ const snap = JSON.parse(readFileSync(SNAPSHOT, "utf8"));
185
+ const assoc = await fetchAssociations();
186
+
187
+ const cache = new Map();
188
+ const techniqueDoc = async (id) => {
189
+ if (cache.has(id)) return cache.get(id);
190
+ const dir = techDir(id);
191
+ const doc = dir ? await fetchText(`techniques/${dir}/${id}.html`) : null;
192
+ cache.set(id, doc);
193
+ return doc;
194
+ };
195
+
196
+ const criteria = {};
197
+ for (const c of snap.criteria) {
198
+ const a = assoc[c.slug];
199
+ const understanding = await fetchText(`understanding/${VER_OF[c.addedIn]}/${c.slug}.html`);
200
+ const brief = understanding ? briefOf(understanding) : null;
201
+
202
+ // Selection: the first technique that carries a code sample, taking the technologies in
203
+ // a fixed order rather than purely positionally.
204
+ //
205
+ // Order matters because the W3C lists techniques by conformance strategy, not by how
206
+ // well they READ as an example. For 1.1.1 the first listed with a sample is ARIA6,
207
+ // whose own example is a navigation landmark — true to the technique, useless as an
208
+ // illustration of a text alternative. Preferring HTML picks H37 (`alt` on `img`)
209
+ // instead. This is one uniform rule applied to every criterion, not a per-criterion
210
+ // choice: within a technology the W3C's own order still decides.
211
+ const TECH_ORDER = ["html", "css", "aria", "client-side-script", "general", "text"];
212
+ const pick = async (ids) => {
213
+ const byTech = new Map();
214
+ for (const id of ids) {
215
+ const dir = techDir(id);
216
+ if (!dir) continue;
217
+ if (!byTech.has(dir)) byTech.set(dir, []);
218
+ byTech.get(dir).push(id);
219
+ }
220
+ const ordered = [...TECH_ORDER.flatMap((t) => byTech.get(t) ?? []), ...ids.filter((id) => !TECH_ORDER.includes(techDir(id)))];
221
+ for (const id of ordered) {
222
+ const doc = await techniqueDoc(id);
223
+ if (!doc) continue;
224
+ const ex = firstCodeExample(doc);
225
+ if (ex) return { id, title: techniqueTitle(doc), ...ex };
226
+ }
227
+ return null;
228
+ };
229
+
230
+ const good = a ? await pick(flattenTechniques(a.sufficient)) : null;
231
+ const bad = a ? await pick(flattenTechniques(a.failure)) : null;
232
+
233
+ criteria[c.sc] = {
234
+ slug: c.slug,
235
+ ...(brief ? { brief } : {}),
236
+ ...(good ? { good } : {}),
237
+ ...(bad ? { bad } : {}),
238
+ };
239
+ }
240
+
241
+ const out = {
242
+ source: "https://github.com/w3c/wcag",
243
+ understanding: "https://www.w3.org/WAI/WCAG22/Understanding/",
244
+ license: "W3C Document License (code samples: W3C Software License)",
245
+ fetchedAt: new Date().toISOString().slice(0, 10),
246
+ criteria,
247
+ };
248
+ mkdirSync(dirname(VENDOR), { recursive: true });
249
+ writeFileSync(VENDOR, JSON.stringify(out, null, 2) + "\n");
250
+ const withGood = Object.values(criteria).filter((x) => x.good).length;
251
+ const withBad = Object.values(criteria).filter((x) => x.bad).length;
252
+ const withBrief = Object.values(criteria).filter((x) => x.brief).length;
253
+ console.log(`build-guidance-wcag --refresh: ${Object.keys(criteria).length} SCs — ${withBrief} briefs, ${withGood} good samples, ${withBad} failure samples → ${VENDOR}`);
254
+ return out;
255
+ }
256
+
257
+ /** The dataset, from the vendored snapshot. Offline and deterministic. */
258
+ function build() {
259
+ if (!existsSync(VENDOR)) {
260
+ console.error(`build-guidance-wcag: missing ${VENDOR}. Run: node scripts/build-guidance-wcag.mjs --refresh`);
261
+ process.exit(1);
262
+ }
263
+ const snap = JSON.parse(readFileSync(VENDOR, "utf8"));
264
+ const core = JSON.parse(readFileSync(join(root, "src", "data", "wcag.json"), "utf8"));
265
+ const titleOf = new Map(core.criteria.map((c) => [c.sc, c]));
266
+
267
+ const entries = [];
268
+ for (const [sc, d] of Object.entries(snap.criteria)) {
269
+ const meta = titleOf.get(sc);
270
+ if (!meta) continue; // not in the shipped AA core
271
+ const summary = d.brief
272
+ ? [d.brief.whattodo, d.brief.goal, d.brief.whyitsimportant].filter(Boolean).join(" ")
273
+ : undefined;
274
+ // A `bad`/`good` pair shares ONE `lang`, so the two halves may only be paired when they
275
+ // are the same technology. A CSS failure beside an HTML fix, labelled `html`, would be
276
+ // a snippet the reader cannot trust — so they are emitted separately instead.
277
+ const label = (t) => ({ en: `${t.kind}: ${t.title} (${t.id}).` });
278
+ const examples = [];
279
+ if (d.good && d.bad && d.good.lang === d.bad.lang) {
280
+ examples.push({
281
+ lang: d.good.lang,
282
+ bad: d.bad.code,
283
+ good: d.good.code,
284
+ note: { en: `Good: ${d.good.title} (${d.good.id}). Bad: ${d.bad.title} (${d.bad.id}).` },
285
+ });
286
+ } else {
287
+ if (d.good) examples.push({ lang: d.good.lang, good: d.good.code, note: label({ kind: "Good", ...d.good }) });
288
+ if (d.bad) examples.push({ lang: d.bad.lang, bad: d.bad.code, note: label({ kind: "Bad", ...d.bad }) });
289
+ }
290
+ entries.push({
291
+ id: `wcag-${d.slug}`,
292
+ criterionId: sc,
293
+ wcag: [sc],
294
+ title: { en: meta.title, ...(meta.titleFr ? { fr: meta.titleFr } : {}) },
295
+ ...(summary ? { summary: { en: summary } } : { summary: {} }),
296
+ examples,
297
+ reference: `https://www.w3.org/WAI/WCAG22/Understanding/${d.slug}.html`,
298
+ });
299
+ }
300
+ entries.sort((a, b) => a.criterionId.localeCompare(b.criterionId, "en", { numeric: true }));
301
+
302
+ const out = {
303
+ pack: "wcag",
304
+ source: snap.understanding,
305
+ license: snap.license,
306
+ attribution:
307
+ "Derived from the W3C Techniques for WCAG 2.2 and the Understanding documents (https://github.com/w3c/wcag). WCAG 2.2 © W3C. " +
308
+ "Summaries are the working group's own 'In brief' text; code samples are the techniques' own examples. Generated by scripts/build-guidance-wcag.mjs — never hand-edited.",
309
+ entries,
310
+ };
311
+ return biomeFormat(JSON.stringify(out, null, 2) + "\n", "src/data/guidance/wcag.json");
312
+ }
313
+
314
+ async function main() {
315
+ if (process.argv.includes("--refresh")) await refresh();
316
+ const text = build();
317
+ if (process.argv.includes("--check")) {
318
+ const current = existsSync(OUT) ? readFileSync(OUT, "utf8") : "";
319
+ if (current !== text) {
320
+ console.error("build-guidance-wcag --check: src/data/guidance/wcag.json does not match the vendored W3C source. Re-run: node scripts/build-guidance-wcag.mjs");
321
+ process.exit(1);
322
+ }
323
+ console.log("build-guidance-wcag --check: src/data/guidance/wcag.json matches the vendored source.");
324
+ return;
325
+ }
326
+ mkdirSync(dirname(OUT), { recursive: true });
327
+ writeFileSync(OUT, text);
328
+ const n = JSON.parse(text).entries.length;
329
+ console.log(`build-guidance-wcag: ${n} entries → src/data/guidance/wcag.json`);
330
+ }
331
+
332
+ main().catch((e) => {
333
+ console.error(e);
334
+ process.exit(1);
335
+ });
@@ -13,6 +13,7 @@
13
13
  // node scripts/build-standards.mjs --offline # same (alias; the snapshots are always local)
14
14
  // node scripts/build-standards.mjs --refresh <dir> # re-derive the vendored AA snapshot from a w3c/wcag checkout
15
15
  // node scripts/build-standards.mjs --refresh-universe # re-fetch (network) the vendored FULL SC universe (all levels + removed 4.1.1)
16
+ // node scripts/build-standards.mjs --refresh-core # re-derive the shipped AA snapshot from that universe (no checkout, no network)
16
17
  // node scripts/build-standards.mjs --refresh-fr # re-fetch (network) the vendored French SC/guideline/principle titles
17
18
  // node scripts/build-standards.mjs --refresh-text # re-fetch (network) the vendored NORMATIVE SC text + the WCAG glossary
18
19
  import { writeFileSync, readFileSync, readdirSync, existsSync, mkdirSync } from "node:fs";
@@ -398,6 +399,49 @@ async function deriveFr() {
398
399
  return out;
399
400
  }
400
401
 
402
+ // ---------------------------------------------------------------------------
403
+ // Core mode: the shipped AA snapshot, derived from the vendored UNIVERSE.
404
+ //
405
+ // `deriveSnapshot` above reads a local w3c/wcag checkout, which is why it was the one
406
+ // vendored source the daily refresh could not touch — a new or renamed success criterion
407
+ // would land in the universe and never reach the shipped core.
408
+ //
409
+ // It needs no checkout and no extra network: `--refresh-universe` already fetches the same
410
+ // numbering and titles for EVERY criterion, and records which are `core-AA`. The AA slice
411
+ // is that filter. Byte-identical to what the checkout produces (tests/wcag-core-sync).
412
+ //
413
+ // node scripts/build-standards.mjs --refresh-core
414
+ // ---------------------------------------------------------------------------
415
+ function deriveCoreFromUniverse() {
416
+ if (!existsSync(VENDOR_UNIVERSE)) {
417
+ console.error(`build-standards --refresh-core: missing ${VENDOR_UNIVERSE}. Run: node scripts/build-standards.mjs --refresh-universe`);
418
+ process.exit(1);
419
+ }
420
+ const universe = JSON.parse(readFileSync(VENDOR_UNIVERSE, "utf8"));
421
+ const aa = universe.criteria.filter((c) => c.status === "core-AA").map(({ status, ...rest }) => rest);
422
+ if (!aa.length) {
423
+ console.error(`build-standards --refresh-core: ${VENDOR_UNIVERSE} classifies no criterion as core-AA — refusing to write an empty core.`);
424
+ process.exit(1);
425
+ }
426
+ const used = new Set(aa.map((c) => c.guideline));
427
+ const snapshot = {
428
+ wcagVersion: universe.wcagVersion,
429
+ source: universe.source,
430
+ criteriaSource: universe.criteriaSource,
431
+ principles: universe.principles.map((p) => ({ number: p.number, title: p.title })),
432
+ guidelines: universe.guidelines.filter((g) => used.has(g.number)).map((g) => ({ number: g.number, title: g.title })),
433
+ criteria: aa,
434
+ };
435
+ return JSON.stringify(snapshot, null, 2) + "\n";
436
+ }
437
+
438
+ function refreshCore() {
439
+ const text = deriveCoreFromUniverse();
440
+ mkdirSync(dirname(VENDOR), { recursive: true });
441
+ writeFileSync(VENDOR, text);
442
+ console.log(`build-standards --refresh-core: ${JSON.parse(text).criteria.length} A/AA criteria derived from the vendored universe → ${VENDOR}`);
443
+ }
444
+
401
445
  // ---------------------------------------------------------------------------
402
446
  // Text mode: the NORMATIVE WORDING of every shipped success criterion, plus the terms
403
447
  // WCAG itself defines.
@@ -830,6 +874,7 @@ async function main() {
830
874
  const refreshIdx = process.argv.indexOf("--refresh");
831
875
  if (refreshIdx !== -1) deriveSnapshot(process.argv[refreshIdx + 1]);
832
876
  if (process.argv.includes("--refresh-universe")) await deriveUniverse();
877
+ if (process.argv.includes("--refresh-core")) refreshCore();
833
878
  if (process.argv.includes("--refresh-fr")) await deriveFr();
834
879
  if (process.argv.includes("--refresh-text")) await deriveText();
835
880
  build();