@radicool/throughline 0.19.0 → 0.21.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 (45) hide show
  1. package/README.md +2 -2
  2. package/adapters/codex/prompts/component-builder.md +29 -2
  3. package/adapters/codex/prompts/document-component.md +10 -1
  4. package/adapters/codex/prompts/icon-system-builder.md +4 -0
  5. package/adapters/codex/prompts/storybook-chromatic-builder.md +114 -7
  6. package/adapters/codex/prompts/token-builder.md +110 -3
  7. package/adapters/codex/prompts/token-crosswalk-builder.md +2 -0
  8. package/adapters/codex/prompts/token-sheet-builder.md +4 -0
  9. package/adapters/codex/prompts/token-sync-layer.md +140 -20
  10. package/adapters/cursor/.cursor/commands/document-component.md +10 -1
  11. package/adapters/cursor/.cursor/rules/component-builder.mdc +29 -2
  12. package/adapters/cursor/.cursor/rules/icon-system-builder.mdc +4 -0
  13. package/adapters/cursor/.cursor/rules/storybook-chromatic-builder.mdc +114 -7
  14. package/adapters/cursor/.cursor/rules/token-builder.mdc +110 -3
  15. package/adapters/cursor/.cursor/rules/token-crosswalk-builder.mdc +2 -0
  16. package/adapters/cursor/.cursor/rules/token-sheet-builder.mdc +4 -0
  17. package/adapters/cursor/.cursor/rules/token-sync-layer.mdc +140 -20
  18. package/adapters/generic/commands/document-component.md +10 -1
  19. package/adapters/generic/skills/component-builder/SKILL.md +29 -2
  20. package/adapters/generic/skills/icon-system-builder/SKILL.md +4 -0
  21. package/adapters/generic/skills/storybook-chromatic-builder/SKILL.md +114 -7
  22. package/adapters/generic/skills/token-builder/SKILL.md +110 -3
  23. package/adapters/generic/skills/token-crosswalk-builder/SKILL.md +2 -0
  24. package/adapters/generic/skills/token-sheet-builder/SKILL.md +4 -0
  25. package/adapters/generic/skills/token-sync-layer/SKILL.md +140 -20
  26. package/package.json +1 -1
  27. package/references/component-doc-schema.md +11 -1
  28. package/references/figma-component-standards.md +61 -2
  29. package/references/figma-scripting.md +12 -2
  30. package/references/manifest-schema.md +42 -2
  31. package/references/proof-bundle.md +312 -0
  32. package/references/sync-adapters.md +38 -53
  33. package/scripts/README.md +150 -14
  34. package/scripts/build-doc-card-builder.mjs +1 -1
  35. package/scripts/build-docs-digest.mjs +1 -0
  36. package/scripts/grep-color-usage.mjs +10 -30
  37. package/scripts/guard-token-removal.mjs +10 -26
  38. package/scripts/lib/component-states.mjs +56 -0
  39. package/scripts/lib/contrast.mjs +117 -0
  40. package/scripts/lib/dtcg.mjs +3 -2
  41. package/scripts/lib/proof.mjs +186 -0
  42. package/scripts/lib/source-scan.mjs +45 -0
  43. package/scripts/validate-adherence.mjs +1178 -0
  44. package/scripts/validate-token-output.mjs +468 -63
  45. package/scripts/verify-check.mjs +718 -0
@@ -0,0 +1,1178 @@
1
+ // Code adherence gate: does the code using this design system still use it?
2
+ //
3
+ // Same shape as validate-token-output.mjs — pure extract/validate/formatReport
4
+ // plus a thin CLI — one layer further out. That gate checks generated token
5
+ // output against its source; this one checks hand-written or generated APP code
6
+ // against the system's own records.
7
+ //
8
+ // Spec: docs/superpowers/specs/2026-08-31-code-adherence-gate-design.md
9
+ // Colour-rule narrowing (#123): docs/specs/2026-09-11-narrow-colour-rule.md
10
+ // Dimension rules (#39): docs/specs/2026-09-11-dimension-rules.md
11
+ // Component-rule narrowing (#120): docs/specs/2026-09-11-narrow-component-rule.md
12
+ // Angular templates (#132): templateView, below.
13
+ import { readFileSync, realpathSync, existsSync } from 'node:fs';
14
+ import { parseArgs } from 'node:util';
15
+ import { pathToFileURL } from 'node:url';
16
+ import { join, dirname, relative, sep } from 'node:path';
17
+ import { walk, normalizeName } from './lib/source-scan.mjs';
18
+ import { flattenDtcg, flattenDtcgTypes, resolveValue } from './lib/dtcg.mjs';
19
+ import { resolveArchetype } from './lib/component-states.mjs';
20
+
21
+ // Named imports from one package, alias included: `{ Card as Panel }`.
22
+ const IMPORT = /import\s*\{([^}]*)\}\s*from\s*['"]([^'"]+)['"]/g;
23
+ // An opening JSX tag on a capitalised name, with an optional `.Member` chain
24
+ // and its attribute text, where a `<` straight after an identifier character
25
+ // is a type argument (`useState<Variant>`), not a tag.
26
+ const ELEMENT = /(?<![\w$.])<([A-Z][A-Za-z0-9]*)((?:\.[A-Za-z][A-Za-z0-9]*)*)\b([^>]*?)\/?>/g;
27
+ // One attribute: name="literal" or name={expression}. The capture is undefined
28
+ // for the expression form, which is how a blind spot stays visible.
29
+ const ATTR = /([a-zA-Z][a-zA-Z0-9_-]*)\s*=\s*(?:"([^"]*)"|'([^']*)'|\{)/g;
30
+ // Hex and opaque integer rgb()/rgba() are compared (see rgbToHex). hsl() and
31
+ // alpha below 1 are not.
32
+ const HEX = /#[0-9a-fA-F]{3,8}\b/g;
33
+ const RGB = /\brgba?\([^()]*\)/gi;
34
+
35
+ // Dimension rules (#39): docs/specs/2026-09-11-dimension-rules.md. A property
36
+ // name maps to its category once the punctuation is gone: lowercase, hyphens
37
+ // stripped, so `padding-inline-start` and `paddingInlineStart` are both
38
+ // `paddinginlinestart`.
39
+ const SPACING_SUFFIXES = [
40
+ '',
41
+ 'top',
42
+ 'right',
43
+ 'bottom',
44
+ 'left',
45
+ 'inline',
46
+ 'block',
47
+ 'inlinestart',
48
+ 'inlineend',
49
+ 'blockstart',
50
+ 'blockend',
51
+ ];
52
+ const RADIUS_CORNERS = ['', 'topleft', 'topright', 'bottomright', 'bottomleft', 'startstart', 'startend', 'endstart', 'endend'];
53
+ const PROPERTY_CATEGORIES = new Map();
54
+ for (const base of ['padding', 'margin']) {
55
+ for (const suffix of SPACING_SUFFIXES) PROPERTY_CATEGORIES.set(base + suffix, 'spacing');
56
+ }
57
+ for (const name of ['gap', 'rowgap', 'columngap', 'gridgap', 'gridrowgap', 'gridcolumngap']) {
58
+ PROPERTY_CATEGORIES.set(name, 'spacing');
59
+ }
60
+ for (const corner of RADIUS_CORNERS) {
61
+ PROPERTY_CATEGORIES.set(`border${corner}radius`, 'radius');
62
+ }
63
+ PROPERTY_CATEGORIES.set('fontsize', 'font-size');
64
+ PROPERTY_CATEGORIES.set('lineheight', 'line-height');
65
+ PROPERTY_CATEGORIES.set('letterspacing', 'letter-spacing');
66
+ PROPERTY_CATEGORIES.set('fontweight', 'font-weight');
67
+
68
+ // `property: value`, value running to the next `;`, `{`, `}`, `,` or newline —
69
+ // that comma is what separates `{ fontSize: 13, marginTop: 16 }` into two
70
+ // declarations rather than one.
71
+ const DECLARATION = /(?<![\w$@.#-])([a-zA-Z][a-zA-Z-]*)\s*:\s*([^;{}\n,]*)/g;
72
+ // A bare, px, rem, em or % number, not abutting a word character, `.`, `#`,
73
+ // `$`, `%` or `(` on either side — that keeps it out of `#3b82f6` and `calc(`.
74
+ const VALUE_NUMBER = /(?<![\w.#$%-])-?(?:\d+(?:\.\d+)?|\.\d+)(?:px|rem|em|%)?(?![\w.%(-])/g;
75
+ // A Tailwind arbitrary value, including after a variant like `md:`. `tw-`
76
+ // prefixes are excluded by the lookbehind: the character right before the
77
+ // utility can't be a word character or `-`.
78
+ const TAILWIND =
79
+ /(?<![\w-])(-?)(p[xytrblse]?|m[xytrblse]?|gap(?:-[xy])?|space-[xy]|rounded(?:-(?:t|r|b|l|s|e|tl|tr|br|bl|ss|se|es|ee))?|text|leading|tracking|font)-\[([^\]\s]+)\]/g;
80
+ const SCRIPT_FILE = /\.(tsx?|jsx?|mjs|cjs|vue|svelte)$/;
81
+
82
+ // One starting p, m, gap or space is spacing; rounded is radius; text, leading,
83
+ // tracking and font are the four type properties.
84
+ function tailwindCategory(utility) {
85
+ if (utility.startsWith('p') || utility.startsWith('m') || utility.startsWith('gap') || utility.startsWith('space')) {
86
+ return 'spacing';
87
+ }
88
+ if (utility.startsWith('rounded')) return 'radius';
89
+ if (utility === 'text') return 'font-size';
90
+ if (utility === 'leading') return 'line-height';
91
+ if (utility === 'tracking') return 'letter-spacing';
92
+ if (utility === 'font') return 'font-weight';
93
+ return null;
94
+ }
95
+
96
+ const lineOf = (text, index) => text.slice(0, index).split('\n').length;
97
+
98
+ // Every character but a newline becomes a space, so an index into the blanked
99
+ // text is the same line in the original.
100
+ const spaces = (s) => s.replace(/[^\n]/g, ' ');
101
+
102
+ // A hex inside a comment is not code: `var(--signal-500); /* #5B7FFF */` already
103
+ // uses the token. One pass, leftmost opener wins, so a `/*` inside a `//`
104
+ // comment cannot pair with a real `*/` lines later. `//` is not a comment in
105
+ // plain CSS or an HTML template, and after a `:` it is a URL. An unterminated `/*` is left alone,
106
+ // which errs toward flagging. Strings are not tracked; that would be a parser.
107
+ export function blankComments(text, path = '') {
108
+ const re = /\.(css|html)$/.test(path) ? /\/\*[\s\S]*?\*\//g : /\/\*[\s\S]*?\*\/|(?<!:)\/\/[^\n]*/g;
109
+ return text.replace(re, spaces);
110
+ }
111
+
112
+ // In `mask: linear-gradient(#fff 0 0) content-box, ...` only alpha matters, so
113
+ // the value of a `mask` or `-webkit-mask` declaration is not a colour. The
114
+ // lookbehind keeps `--mask`, `$mask` and `.mask` flagging; `mask-image` and the
115
+ // other `mask-*` properties never match. CSS, SCSS and a template's `style`
116
+ // attribute, which templateView ends with a `;`. Not `.sass`: it has no `;`
117
+ // to end the value, and in script a value ending at `}` could swallow a sibling
118
+ // property's real hex.
119
+ const MASK = /(?<![\w$@.#-])(?:-webkit-)?mask\s*:([^;{}]*)/g;
120
+ export function blankMasks(text, path = '') {
121
+ if (!/\.(s?css|html)$/.test(path)) return text;
122
+ return text.replace(MASK, (m, value) => m.slice(0, m.length - value.length) + spaces(value));
123
+ }
124
+
125
+ // `var(--space-4, 16px)` is correct code — the 16px is the token's fallback —
126
+ // and `calc()`, `clamp()`, `min()` and `max()` arguments are arithmetic, not
127
+ // scale steps. Every character between a `(` and its matching `)` becomes a
128
+ // space; an unclosed `(` blanks to the end of the value. Length is unchanged,
129
+ // so it stays safe to run before VALUE_NUMBER's index-based line lookup.
130
+ export function blankParens(value) {
131
+ let depth = 0;
132
+ let out = '';
133
+ for (const ch of value) {
134
+ if (ch === '(') {
135
+ depth += 1;
136
+ out += ' ';
137
+ } else if (ch === ')' && depth > 0) {
138
+ depth -= 1;
139
+ out += ' ';
140
+ } else if (depth > 0) {
141
+ out += ' ';
142
+ } else {
143
+ out += ch;
144
+ }
145
+ }
146
+ return out;
147
+ }
148
+
149
+ // The only false positives the dimension-rule prototype found were the
150
+ // `font-weight` descriptors inside `@font-face { … }`, which describe a font
151
+ // file, not a scale step. Blanked before dimensions are read, same shape as
152
+ // blankComments.
153
+ const FONT_FACE = /@font-face\s*\{[^}]*\}/g;
154
+ export function blankFontFaces(text) {
155
+ return text.replace(FONT_FACE, spaces);
156
+ }
157
+
158
+ // #abc -> #aabbcc; #aabbccff -> #aabbcc (opaque alpha carries no information);
159
+ // a real alpha is kept, because two colours differing only in alpha are two
160
+ // colours. Anything not hex returns null and is never compared.
161
+ export function normalizeHex(value) {
162
+ const v = String(value).trim().toLowerCase();
163
+ if (!/^#[0-9a-f]{3,8}$/.test(v)) return null;
164
+ let hex = v.slice(1);
165
+ if (hex.length === 3 || hex.length === 4) hex = [...hex].map((c) => c + c).join('');
166
+ if (hex.length === 8 && hex.endsWith('ff')) hex = hex.slice(0, 6);
167
+ if (hex.length !== 6 && hex.length !== 8) return null;
168
+ return '#' + hex;
169
+ }
170
+
171
+ // rgb(59, 130, 246) -> #3b82f6. Only whole channels 0-255 and an opaque alpha
172
+ // (absent, 1, 1.0 or 100%) are comparable; anything else — percentages,
173
+ // var(), a real alpha — returns null and is never guessed at.
174
+ export function rgbToHex(value) {
175
+ const m = String(value).trim().match(/^rgba?\(\s*([^()]*)\)$/i);
176
+ if (!m) return null;
177
+ const parts = m[1].split(/[\s,/]+/).filter(Boolean);
178
+ if (parts.length < 3 || parts.length > 4) return null;
179
+ const channels = parts.slice(0, 3);
180
+ for (const c of channels) {
181
+ if (!/^\d{1,3}$/.test(c) || Number(c) > 255) return null;
182
+ }
183
+ if (parts.length === 4 && !/^(1(\.0+)?|100%)$/.test(parts[3])) return null;
184
+ const hex = channels.map((c) => Number(c).toString(16).padStart(2, '0')).join('');
185
+ return normalizeHex('#' + hex);
186
+ }
187
+
188
+ // An Angular template carries styling in a few attributes and nowhere else, so
189
+ // only those are read (#132). Everything outside them — text content, which is
190
+ // where a brand guide prints `#080d14`, an entity like `&#8288;`, `data-*`,
191
+ // `href="#cafe"`, HTML comments — is blanked. Bindings read like their plain
192
+ // attribute: `[attr.fill]`, `[stroke]`, `[style.color]`, `[ngStyle]`. The rule
193
+ // that reads JSX tags does not apply; an Angular selector is a different mapping.
194
+ const TEMPLATE_ATTRS = new Set(['style', 'ngstyle', 'class', 'ngclass', 'fill', 'stroke', 'stop-color', 'flood-color', 'lighting-color', 'color']);
195
+ const HTML_TAG = /<[a-zA-Z][^\s/>]*((?:\s+[^\s=/>"']+(?:\s*=\s*(?:"[^"]*"|'[^']*'|[^\s>"'][^\s>]*))?)*)\s*\/?>/g;
196
+ const HTML_ATTR = /([^\s=/>"']+)\s*=\s*(?:"([^"]*)"|'([^']*)')/g;
197
+
198
+ function templateAttr(name) {
199
+ const bare = name.toLowerCase().replace(/^\[\(?|\)?\]$/g, '').replace(/^attr\./, '');
200
+ return bare.startsWith('style.') ? 'style' : bare.startsWith('class.') ? 'class' : bare;
201
+ }
202
+
203
+ export function templateView(text) {
204
+ const out = spaces(text).split('');
205
+ const code = text.replace(/<!--[\s\S]*?-->/g, spaces);
206
+ for (const tag of code.matchAll(HTML_TAG)) {
207
+ const attrsAt = tag.index + tag[0].indexOf(tag[1]);
208
+ for (const a of tag[1].matchAll(HTML_ATTR)) {
209
+ if (!TEMPLATE_ATTRS.has(templateAttr(a[1]))) continue;
210
+ const value = a[2] ?? a[3];
211
+ const at = attrsAt + a.index + a[0].length - 1 - value.length;
212
+ for (let i = 0; i < value.length; i += 1) out[at + i] = value[i];
213
+ // A style value's last declaration has no `;`, and would otherwise run on
214
+ // into the next kept value on the line. The closing quote becomes one.
215
+ if (['style', 'ngstyle'].includes(templateAttr(a[1]))) out[at + value.length] = ';';
216
+ }
217
+ }
218
+ return out.join('');
219
+ }
220
+
221
+ // The JSX subtree reader (#140): docs/specs/2026-09-29-component-accessibility-checks.md.
222
+ // It runs alongside ELEMENT, not in place of it: only the usage rules read it.
223
+ // Every function returns null on anything it can't pair cleanly, and the caller
224
+ // turns that into `paired: false`, never a guess.
225
+ const IDENT_START = /[A-Za-z_$]/;
226
+ const NAME_CHAR = /[\w$.:-]/;
227
+
228
+ // i is at an opening quote; returns the index just past its close.
229
+ function skipString(code, i) {
230
+ const q = code[i];
231
+ for (let j = i + 1; j < code.length; j++) {
232
+ if (code[j] === '\\') j++;
233
+ else if (q === '`' && code[j] === '$' && code[j + 1] === '{') {
234
+ const end = skipBraces(code, j + 1);
235
+ if (end === null) return null;
236
+ j = end - 1;
237
+ } else if (code[j] === q) return j + 1;
238
+ }
239
+ return null;
240
+ }
241
+
242
+ // i is at `{`; returns the index just past its matching `}`. JSX inside an
243
+ // expression (`{ok && <Badge>Done</Badge>}`) is read as JSX, so an apostrophe
244
+ // in its text isn't taken for a string. A `<` after an identifier character is
245
+ // a comparison or a type argument.
246
+ function skipBraces(code, i) {
247
+ let depth = 0;
248
+ for (let j = i; j < code.length; j++) {
249
+ const c = code[j];
250
+ if (c === '"' || c === "'" || c === '`') {
251
+ const end = skipString(code, j);
252
+ if (end === null) return null;
253
+ j = end - 1;
254
+ } else if (c === '<' && !/[\w$.)\]]/.test(code[j - 1] ?? '') && /[A-Za-z>]/.test(code[j + 1] ?? '')) {
255
+ const el = parseElement(code, j);
256
+ if (el === null) return null;
257
+ j = el.end - 1;
258
+ } else if (c === '{') depth++;
259
+ else if (c === '}' && --depth === 0) return j + 1;
260
+ }
261
+ return null;
262
+ }
263
+
264
+ // i is at `<`. Reads the name and attributes up to `>` or `/>`.
265
+ function parseTag(code, i) {
266
+ let j = i + 1;
267
+ let name = '';
268
+ while (j < code.length && NAME_CHAR.test(code[j])) name += code[j++];
269
+ const attrs = [];
270
+ let spread = false;
271
+ for (;;) {
272
+ while (/\s/.test(code[j] ?? '')) j++;
273
+ if (j >= code.length) return null;
274
+ if (code.startsWith('/>', j)) return { name, attrs, spread, selfClosing: true, end: j + 2 };
275
+ if (code[j] === '>') return { name, attrs, spread, selfClosing: false, end: j + 1 };
276
+ if (code[j] === '{') {
277
+ const end = skipBraces(code, j);
278
+ if (end === null || !code.slice(j + 1, end - 1).trim().startsWith('...')) return null;
279
+ spread = true;
280
+ j = end;
281
+ continue;
282
+ }
283
+ if (!IDENT_START.test(code[j])) return null;
284
+ let attr = '';
285
+ while (j < code.length && NAME_CHAR.test(code[j])) attr += code[j++];
286
+ while (/\s/.test(code[j] ?? '')) j++;
287
+ if (code[j] !== '=') {
288
+ attrs.push({ name: attr, kind: 'bare', value: null });
289
+ continue;
290
+ }
291
+ j++;
292
+ while (/\s/.test(code[j] ?? '')) j++;
293
+ if (code[j] === '"' || code[j] === "'") {
294
+ const end = skipString(code, j);
295
+ if (end === null) return null;
296
+ attrs.push({ name: attr, kind: 'literal', value: code.slice(j + 1, end - 1) });
297
+ j = end;
298
+ } else if (code[j] === '{') {
299
+ const end = skipBraces(code, j);
300
+ if (end === null) return null;
301
+ attrs.push({ name: attr, kind: 'expr', value: null });
302
+ j = end;
303
+ } else return null;
304
+ }
305
+ }
306
+
307
+ // i is just past an opening tag named `name`; reads to its matching close.
308
+ function parseChildren(code, i, name) {
309
+ const children = [];
310
+ let j = i;
311
+ while (j < code.length) {
312
+ if (code[j] === '<') {
313
+ if (code[j + 1] === '/') {
314
+ const close = code.indexOf('>', j);
315
+ if (close === -1 || code.slice(j + 2, close).trim() !== name) return null;
316
+ return { children, end: close + 1 };
317
+ }
318
+ const el = parseElement(code, j);
319
+ if (el === null) return null;
320
+ children.push({ type: 'element', name: el.name, attrs: el.attrs, spread: el.spread, selfClosing: el.selfClosing, children: el.children });
321
+ j = el.end;
322
+ } else if (code[j] === '{') {
323
+ const end = skipBraces(code, j);
324
+ if (end === null) return null;
325
+ // A JSX comment `{/* */}` is blank by now, and names nothing.
326
+ if (code.slice(j + 1, end - 1).trim()) children.push({ type: 'expr', value: code.slice(j + 1, end - 1) });
327
+ j = end;
328
+ } else {
329
+ let k = j;
330
+ while (k < code.length && code[k] !== '<' && code[k] !== '{') k++;
331
+ children.push({ type: 'text', value: code.slice(j, k) });
332
+ j = k;
333
+ }
334
+ }
335
+ return null;
336
+ }
337
+
338
+ function parseElement(code, i) {
339
+ const tag = parseTag(code, i);
340
+ if (tag === null) return null;
341
+ if (tag.selfClosing) return { ...tag, children: [] };
342
+ const body = parseChildren(code, tag.end, tag.name);
343
+ if (body === null) return null;
344
+ return { ...tag, children: body.children, end: body.end };
345
+ }
346
+
347
+ // One entry per opening tag ELEMENT matches on an imported name, nested ones
348
+ // included: a Button inside a Card is its own entry and also one of the Card's
349
+ // children. `code` is comment-blanked.
350
+ export function readSubtrees(code, imported) {
351
+ const subtrees = [];
352
+ for (const el of code.matchAll(ELEMENT)) {
353
+ const declared = imported.get(el[1]);
354
+ if (!declared || el[2]) continue;
355
+ const line = lineOf(code, el.index);
356
+ let parsed;
357
+ try {
358
+ parsed = parseElement(code, el.index);
359
+ } catch (e) {
360
+ // Nesting past the stack's depth abstains like anything else unpaired.
361
+ if (!(e instanceof RangeError)) throw e;
362
+ parsed = null;
363
+ }
364
+ if (parsed === null) {
365
+ let tag = null;
366
+ try {
367
+ tag = parseTag(code, el.index);
368
+ } catch (e) {
369
+ if (!(e instanceof RangeError)) throw e;
370
+ }
371
+ subtrees.push({
372
+ component: declared,
373
+ line,
374
+ selfClosing: tag?.selfClosing ?? false,
375
+ paired: false,
376
+ attrs: tag?.attrs ?? [],
377
+ spread: tag?.spread ?? false,
378
+ children: [],
379
+ });
380
+ continue;
381
+ }
382
+ subtrees.push({
383
+ component: declared,
384
+ line,
385
+ selfClosing: parsed.selfClosing,
386
+ paired: true,
387
+ attrs: parsed.attrs,
388
+ spread: parsed.spread,
389
+ children: parsed.children,
390
+ });
391
+ }
392
+ return subtrees;
393
+ }
394
+
395
+ export function extract(text, pkg, path = '') {
396
+ if (path.endsWith('.html')) text = templateView(text);
397
+ const code = blankComments(text, path);
398
+
399
+ const imported = new Map();
400
+ for (const m of code.matchAll(IMPORT)) {
401
+ if (m[2] !== pkg) continue;
402
+ for (const part of m[1].split(',')) {
403
+ const [declared, local] = part.trim().split(/\s+as\s+/);
404
+ if (declared) imported.set((local ?? declared).trim(), declared.trim());
405
+ }
406
+ }
407
+
408
+ const elements = [];
409
+ const usages = [];
410
+ for (const el of code.matchAll(ELEMENT)) {
411
+ const declared = imported.get(el[1]);
412
+ if (!declared) continue;
413
+ // <Icons.Folder> is a member of something the system exports and is left
414
+ // alone (#120).
415
+ if (el[2]) continue;
416
+ const line = lineOf(code, el.index);
417
+ elements.push({ component: declared, line });
418
+ for (const a of el[3].matchAll(ATTR)) {
419
+ usages.push({ component: declared, attr: a[1], value: a[2] ?? a[3] ?? null, line });
420
+ }
421
+ }
422
+
423
+ // Import, element and hex extraction all read comment-blanked text, and
424
+ // only hex also has masks blanked.
425
+ const colourText = blankMasks(code, path);
426
+ const literals = [];
427
+ for (const h of colourText.matchAll(HEX)) {
428
+ const value = normalizeHex(h[0]);
429
+ if (value) literals.push({ value, line: lineOf(colourText, h.index) });
430
+ }
431
+ for (const m of colourText.matchAll(RGB)) {
432
+ const value = rgbToHex(m[0]);
433
+ if (value) literals.push({ value, line: lineOf(colourText, m.index) });
434
+ }
435
+
436
+ const subtrees = /\.(jsx?|tsx?|mjs|cjs)$/.test(path) ? readSubtrees(code, imported) : [];
437
+
438
+ return { imported, elements, usages, literals, dimensions: extractDimensions(text, path), subtrees };
439
+ }
440
+
441
+ // value -> the token paths that hold it. Repeatable --tokens, because a real
442
+ // system spans mode files with different values for one path, which is why
443
+ // findModeCollisions exists at all; here every file simply contributes, and a
444
+ // value held by more than one token names all of them.
445
+ //
446
+ // resolveValue throws on an unknown path and on a cycle. A gate that reports
447
+ // must not die mid-report, so every resolution is wrapped and a token that
448
+ // cannot be resolved is skipped, and counted by skippedColourTokens.
449
+ export function buildTokenValues(dicts) {
450
+ const out = new Map();
451
+ for (const t of resolvedTokens(dicts)) {
452
+ if (!t.resolves) continue;
453
+ const hex = normalizeHex(t.value) ?? rgbToHex(t.value);
454
+ if (!hex) continue;
455
+ if (!out.has(hex)) out.set(hex, []);
456
+ out.get(hex).push(t.path);
457
+ }
458
+ return out;
459
+ }
460
+
461
+ // Every token in every --tokens file, resolved against all of them (#121). A
462
+ // semantic file aliases primitives in another file, and resolving each file
463
+ // alone skipped every one of those aliases. A file's own paths win over the
464
+ // pool, so two mode files defining one path differently each keep their own
465
+ // value; a path defined in several OTHER files resolves to the last one given.
466
+ // An alias that names no path in any file stays unresolvable. That includes a
467
+ // collection-relative `{canvas}` meaning `color-primitive.canvas`, which is an
468
+ // export quirk rather than DTCG, and is counted rather than guessed at.
469
+ //
470
+ // The `source` index is the dict a token came from, which is what lets a caller
471
+ // group a system's tokens by mode, since one --tokens file is one mode:
472
+ // scripts/verify-check.mjs reads it that way for the color-contrast rule.
473
+ export function* resolvedTokens(dicts) {
474
+ const flats = dicts.map((d) => flattenDtcg(d));
475
+ const pool = Object.assign({}, ...flats);
476
+ for (const [i, dict] of dicts.entries()) {
477
+ const flat = flats[i];
478
+ const lookup = { ...pool, ...flat };
479
+ const types = flattenDtcgTypes(dict);
480
+ for (const path of Object.keys(flat)) {
481
+ let value;
482
+ try {
483
+ value = resolveValue(path, lookup);
484
+ } catch {
485
+ yield { path, type: types[path], resolves: false, source: i };
486
+ continue;
487
+ }
488
+ yield { path, type: types[path], resolves: true, value, source: i };
489
+ }
490
+ }
491
+ }
492
+
493
+ // Decision 4: a colour token the gate cannot compare is skipped AND counted, so
494
+ // the report shows the blind spot. Only tokens whose effective $type is `color`
495
+ // are counted — an untyped token that resolves to hex is still compared, but
496
+ // one that does not could be anything, and counting a font family as a skipped
497
+ // colour would bury the number that matters.
498
+ export function skippedColourTokens(dicts) {
499
+ const skipped = { unresolvable: 0, nonHex: 0 };
500
+ for (const t of resolvedTokens(dicts)) {
501
+ if (t.type !== 'color') continue;
502
+ if (!t.resolves) skipped.unresolvable += 1;
503
+ else if (!(normalizeHex(t.value) ?? rgbToHex(t.value))) skipped.nonHex += 1;
504
+ }
505
+ return skipped;
506
+ }
507
+
508
+ // Dimension rules (#39): docs/specs/2026-09-11-dimension-rules.md. Same shape
509
+ // as buildTokenValues, one layer over: a token's category comes from the words
510
+ // in its path (DTCG's `dimension` type doesn't say what a length is for), and
511
+ // its raw value is canonicalised to a comparable string within that category
512
+ // before values across dicts are pooled.
513
+ export const DIMENSION_CATEGORIES = [
514
+ 'spacing',
515
+ 'radius',
516
+ 'font-size',
517
+ 'line-height',
518
+ 'letter-spacing',
519
+ 'font-weight',
520
+ ];
521
+
522
+ // space/spacing/gap/inset/stack/gutter/padding/margin -> spacing; radius/rounded/
523
+ // corner -> radius; fontsize/text -> font-size; lineheight/leading -> line-height;
524
+ // letterspacing/tracking -> letter-spacing; fontweight -> font-weight. `size` and
525
+ // `weight` are ambiguous on their own — a typographic qualifier earlier in the
526
+ // path decides them, or they decide nothing.
527
+ const DIMENSION_WORDS = {
528
+ space: 'spacing',
529
+ spacing: 'spacing',
530
+ gap: 'spacing',
531
+ inset: 'spacing',
532
+ stack: 'spacing',
533
+ gutter: 'spacing',
534
+ padding: 'spacing',
535
+ margin: 'spacing',
536
+ radius: 'radius',
537
+ rounded: 'radius',
538
+ corner: 'radius',
539
+ fontsize: 'font-size',
540
+ text: 'font-size',
541
+ lineheight: 'line-height',
542
+ leading: 'line-height',
543
+ letterspacing: 'letter-spacing',
544
+ tracking: 'letter-spacing',
545
+ fontweight: 'font-weight',
546
+ };
547
+ const TYPOGRAPHIC_QUALIFIERS = new Set(['font', 'text', 'typography', 'type']);
548
+
549
+ export function dimensionCategory(path, type) {
550
+ if (type !== undefined && type !== 'dimension' && type !== 'number' && type !== 'fontWeight') return null;
551
+ if (type === 'fontWeight') return 'font-weight';
552
+
553
+ const words = path
554
+ .split('.')
555
+ .flatMap((s) => s.toLowerCase().split(/[-_]/))
556
+ .filter(Boolean);
557
+
558
+ for (let i = words.length - 1; i >= 0; i--) {
559
+ const word = words[i];
560
+ if (word === 'size' || word === 'weight') {
561
+ const qualified = words.slice(0, i).some((w) => TYPOGRAPHIC_QUALIFIERS.has(w));
562
+ return qualified ? (word === 'size' ? 'font-size' : 'font-weight') : null;
563
+ }
564
+ if (word in DIMENSION_WORDS) return DIMENSION_WORDS[word];
565
+ }
566
+ return null;
567
+ }
568
+
569
+ // A dimension is comparable only within its own category, in one canonical
570
+ // unit per category: px (rem folds in at 16px per rem, per the system's own
571
+ // build), em standing alone, unitless line-height, and a whole-number
572
+ // font-weight string. `unitless` says what a bare number means for a length
573
+ // category — 'px' for a literal read from a script file's inline style, null
574
+ // everywhere else. Zero and percentages are never comparable, on either side.
575
+ export function canonicalDimension(raw, category, unitless) {
576
+ let n;
577
+ let unit;
578
+ if (typeof raw === 'number') {
579
+ n = raw;
580
+ unit = '';
581
+ } else if (typeof raw === 'string') {
582
+ const m = raw.trim().match(/^(-?(?:\d+(?:\.\d+)?|\.\d+))(px|rem|em|%)?$/);
583
+ if (!m) return null;
584
+ n = Number(m[1]);
585
+ unit = m[2] ?? '';
586
+ } else {
587
+ return null;
588
+ }
589
+
590
+ if (!Number.isFinite(n) || n === 0 || unit === '%') return null;
591
+ const r3 = (x) => {
592
+ const rounded = Math.round(x * 1000) / 1000;
593
+ return rounded === 0 ? 0 : rounded;
594
+ };
595
+
596
+ if (category === 'font-weight') {
597
+ return unit === '' && Number.isInteger(n) && n > 0 && n <= 1000 ? String(n) : null;
598
+ }
599
+ if (category === 'line-height' && unit === '') return String(r3(n));
600
+ if (unit === 'em') return `${r3(n)}em`;
601
+ if (unit === 'rem') return `${r3(n * 16)}px`;
602
+ if (unit === 'px') return `${r3(n)}px`;
603
+ return unitless === 'px' ? `${r3(n)}px` : null;
604
+ }
605
+
606
+ // Same shape as buildTokenValues: every dict contributes, a value maps to the
607
+ // token paths that hold it, and a token that cannot be resolved is skipped
608
+ // rather than thrown on. One category per token — matching within a category
609
+ // only is the point, per the spec's measurement.
610
+ export function buildDimensionValues(dicts) {
611
+ const out = new Map();
612
+ for (const t of resolvedTokens(dicts)) {
613
+ const category = dimensionCategory(t.path, t.type);
614
+ if (!category || !t.resolves) continue;
615
+ const value = canonicalDimension(t.value, category, 'px');
616
+ if (!value) continue;
617
+ if (!out.has(category)) out.set(category, new Map());
618
+ const inner = out.get(category);
619
+ if (!inner.has(value)) inner.set(value, []);
620
+ inner.get(value).push(t.path);
621
+ }
622
+ return out;
623
+ }
624
+
625
+ // Same two shapes source is read: a `property: value` declaration in CSS,
626
+ // SCSS, inline styles or CSS in strings, and a Tailwind arbitrary value like
627
+ // `p-[16px]`. Comments and @font-face descriptors are blanked first, same as
628
+ // the colour rule; numbers inside parentheses are blanked per-declaration, so
629
+ // a var() fallback or a calc() argument is never read as a scale step.
630
+ export function extractDimensions(text, path = '') {
631
+ const t = blankFontFaces(blankComments(text, path));
632
+ const out = [];
633
+
634
+ for (const m of t.matchAll(DECLARATION)) {
635
+ const category = PROPERTY_CATEGORIES.get(m[1].toLowerCase().replace(/-/g, ''));
636
+ if (!category) continue;
637
+ const quoted = /^\s*['"`]/.test(m[2]);
638
+ const v = blankParens(m[2].replace(/['"`]/g, ' '));
639
+ for (const n of v.matchAll(VALUE_NUMBER)) {
640
+ const value = canonicalDimension(n[0], category, SCRIPT_FILE.test(path) && !quoted ? 'px' : null);
641
+ if (value) out.push({ category, written: n[0], value, line: lineOf(t, m.index) });
642
+ }
643
+ }
644
+
645
+ for (const m of t.matchAll(TAILWIND)) {
646
+ if (m[1] === '-' && m[3].startsWith('-')) continue;
647
+ const category = tailwindCategory(m[2]);
648
+ if (!category) continue;
649
+ const value = canonicalDimension(m[1] + m[3], category, null);
650
+ if (value) out.push({ category, written: `${m[1]}${m[2]}-[${m[3]}]`, value, line: lineOf(t, m.index) });
651
+ }
652
+
653
+ return out;
654
+ }
655
+
656
+ // The package that owns a --tokens file is not its own consumer. Walked from
657
+ // libs/ or packages/, the gate otherwise reads the token package's generated
658
+ // css/tokens.css and fails every primitive for duplicating itself. The owner is
659
+ // the nearest directory at or above the file holding a package.json. It is kept
660
+ // only when it sits strictly beneath --root: an owner that is the root, or
661
+ // contains it, is a single-package app, and excluding it would exclude the app.
662
+ export function tokenPackageDirs(tokenFiles, root) {
663
+ const realRoot = realpathSync(root);
664
+ const dirs = new Set();
665
+ for (const file of tokenFiles) {
666
+ let dir = dirname(realpathSync(file));
667
+ while (!existsSync(join(dir, 'package.json'))) {
668
+ const up = dirname(dir);
669
+ if (up === dir) {
670
+ dir = null;
671
+ break;
672
+ }
673
+ dir = up;
674
+ }
675
+ if (dir && dir.startsWith(realRoot + sep)) dirs.add(dir);
676
+ }
677
+ return [...dirs];
678
+ }
679
+
680
+ // Component-rule narrowing (#120): docs/specs/2026-09-11-narrow-component-rule.md.
681
+ // `CardTitle` belongs to `Card`, and so does `ButtonX` — a part name only has to
682
+ // start with a built component's name and continue with another capital letter;
683
+ // it is not required to spell out a real word after it. The longest matching
684
+ // built name wins, so `ButtonGroupText` belongs to `ButtonGroup`, not `Button`.
685
+ export function partOwner(name, built) {
686
+ if (!/^[A-Za-z0-9]+$/.test(name)) return null;
687
+ const key = normalizeName(name);
688
+ let best = null;
689
+ let bestKey = '';
690
+ for (const b of built) {
691
+ const k = normalizeName(b);
692
+ if (k === '' || k.length >= key.length || !key.startsWith(k) || !/[A-Z]/.test(name[k.length])) continue;
693
+ if (k.length <= bestKey.length) continue;
694
+ best = b;
695
+ bestKey = k;
696
+ }
697
+ return best;
698
+ }
699
+
700
+ // The usage rules (#140). Both judge an element the same way and differ only in
701
+ // which components they target. The order matters: abstain before naming, so a
702
+ // spread that may carry the label is never read as unnamed.
703
+ export const A11Y_RULES = ['unnamed-control', 'colour-only-status'];
704
+ const NAMING = ['aria-label', 'aria-labelledby', 'title'];
705
+ // On a descendant, a prop that may render text: an icon's `size` or `color`
706
+ // can't, an i18n component's `i18nKey` or `id` can.
707
+ const LABEL_LIKE = new Set(['label', 'alt', 'text', 'i18nKey', 'defaultMessage', 'message', 'id']);
708
+ const isLabelLike = (name) => LABEL_LIKE.has(name) || /(Label|Text)$/.test(name);
709
+ // On an element with nothing inside, props that can never be its label. An icon
710
+ // prop takes a component, not text.
711
+ const NEVER_LABEL = new Set([
712
+ 'className',
713
+ 'style',
714
+ 'disabled',
715
+ 'type',
716
+ 'key',
717
+ 'ref',
718
+ 'id',
719
+ 'aria-hidden',
720
+ 'asChild',
721
+ 'icon',
722
+ 'leadingIcon',
723
+ 'trailingIcon',
724
+ ...NAMING,
725
+ ]);
726
+
727
+ function namesItself(attrs) {
728
+ return attrs.some((a) => NAMING.includes(a.name) && (a.kind === 'expr' || (a.kind === 'literal' && a.value.trim() !== '')));
729
+ }
730
+
731
+ function* descendants(children) {
732
+ for (const c of children) {
733
+ if (c.type !== 'element') continue;
734
+ yield c;
735
+ yield* descendants(c.children);
736
+ }
737
+ }
738
+
739
+ // JSX drops whitespace-only text, and an expression may be the label.
740
+ function hasText(children) {
741
+ return children.some(
742
+ (c) => c.type === 'expr' || (c.type === 'text' && c.value.trim() !== '') || (c.type === 'element' && hasText(c.children)),
743
+ );
744
+ }
745
+
746
+ // 'named', 'unnamed', or 'abstain' when the name could arrive some way the
747
+ // reader can't see.
748
+ export function judgeName(el, record) {
749
+ if (!el.paired || el.spread) return 'abstain';
750
+ const inside = [...descendants(el.children)];
751
+ if (inside.some((d) => d.spread || d.attrs.some((a) => isLabelLike(a.name)))) return 'abstain';
752
+ if (namesItself(el.attrs) || inside.some((d) => namesItself(d.attrs))) return 'named';
753
+ if (hasText(el.children)) return 'named';
754
+ // `<Button label="Save"></Button>` has no children to judge by either, so it
755
+ // gets the same scrutiny as `<Button label="Save" />`. JSX drops whitespace-only
756
+ // text, so that counts as nothing inside too.
757
+ if (el.children.every((c) => c.type === 'text' && c.value.trim() === '')) {
758
+ // With no record there's no telling a variant from a label prop.
759
+ const modelled = new Set([...Object.keys(record?.variants ?? {}), ...Object.keys(record?.states ?? {})]);
760
+ const unknown = (a) => !modelled.has(a.name) && !NEVER_LABEL.has(a.name) && !/^on[A-Z]/.test(a.name) && !/^data-/.test(a.name);
761
+ if (el.attrs.some(unknown)) return 'abstain';
762
+ }
763
+ return 'unnamed';
764
+ }
765
+
766
+ export function validate({
767
+ built = [],
768
+ index = { components: [] },
769
+ tokenValues = new Map(),
770
+ colourSkipped = { unresolvable: 0, nonHex: 0 },
771
+ dimensionValues = new Map(),
772
+ files = [],
773
+ walked = files.length,
774
+ excluded = [],
775
+ skip = [],
776
+ }) {
777
+ const off = new Set(skip);
778
+ const builtKeys = new Map(built.map((n) => [normalizeName(n), n]));
779
+ const records = new Map((index.components ?? []).map((c) => [normalizeName(c.name), c]));
780
+
781
+ const failures = [];
782
+ const advisories = [];
783
+ const stats = {
784
+ elements: 0,
785
+ usages: 0,
786
+ literals: 0,
787
+ dimensions: 0,
788
+ parts: new Map(),
789
+ colourTokens: [...tokenValues.values()].reduce((n, paths) => n + paths.length, 0),
790
+ colourSkipped,
791
+ dimensionTokens: Object.fromEntries(
792
+ DIMENSION_CATEGORIES.map((c) => [
793
+ c,
794
+ [...(dimensionValues.get(c)?.values() ?? [])].reduce((n, paths) => n + paths.length, 0),
795
+ ]),
796
+ ),
797
+ files: walked,
798
+ axisMatched: 0,
799
+ axisUnmatched: 0,
800
+ dynamic: 0,
801
+ knownComponents: new Set(),
802
+ undocumented: new Set(),
803
+ // No inert failure for these: an app with no Badge isn't misconfigured. A
804
+ // rule that checked nothing shows as 0 checked instead.
805
+ a11y: Object.fromEntries(A11Y_RULES.filter((rule) => !off.has(rule)).map((rule) => [rule, { checked: 0, abstained: 0 }])),
806
+ };
807
+
808
+ for (const { path, elements = [], usages, literals, dimensions = [], subtrees = [] } of files) {
809
+ for (const el of subtrees) {
810
+ const key = normalizeName(el.component);
811
+ const record = records.get(key);
812
+ const targeted = {
813
+ 'unnamed-control': key === 'button' || key === 'iconbutton' || record?.archetype === 'button',
814
+ 'colour-only-status': resolveArchetype(record ?? { name: el.component }) === 'badge',
815
+ };
816
+ for (const rule of A11Y_RULES) {
817
+ if (!targeted[rule] || !stats.a11y[rule]) continue;
818
+ const verdict = judgeName(el, record);
819
+ if (verdict === 'abstain') {
820
+ stats.a11y[rule].abstained += 1;
821
+ continue;
822
+ }
823
+ stats.a11y[rule].checked += 1;
824
+ if (verdict === 'unnamed') failures.push({ rule, component: el.component, file: path, line: el.line });
825
+ }
826
+ }
827
+
828
+ for (const e of elements) {
829
+ stats.elements += 1;
830
+ if (off.has('unknown-component') || builtKeys.has(normalizeName(e.component))) continue;
831
+ const owner = partOwner(e.component, built);
832
+ if (owner) {
833
+ if (!stats.parts.has(e.component)) stats.parts.set(e.component, owner);
834
+ continue;
835
+ }
836
+ failures.push({ rule: 'unknown-component', component: e.component, file: path, line: e.line });
837
+ }
838
+
839
+ for (const u of usages) {
840
+ stats.usages += 1;
841
+ const key = normalizeName(u.component);
842
+ const declaredName = builtKeys.get(key);
843
+
844
+ // Existence is checked once per tag, above. An attribute on an unknown
845
+ // component or a part is counted and nothing more.
846
+ if (!declaredName) continue;
847
+ stats.knownComponents.add(declaredName);
848
+
849
+ const record = records.get(key);
850
+ if (!record) {
851
+ if (!stats.undocumented.has(declaredName)) {
852
+ stats.undocumented.add(declaredName);
853
+ advisories.push({ rule: 'undocumented-component', component: declaredName });
854
+ }
855
+ continue;
856
+ }
857
+
858
+ if (u.value === null) {
859
+ stats.dynamic += 1;
860
+ advisories.push({ rule: 'dynamic-value', component: declaredName, attr: u.attr, file: path, line: u.line });
861
+ continue;
862
+ }
863
+
864
+ const axis = (record.variants ?? {})[u.attr];
865
+ if (axis) {
866
+ stats.axisMatched += 1;
867
+ if (!off.has('unknown-variant-value') && !Object.keys(axis).includes(u.value)) {
868
+ failures.push({
869
+ rule: 'unknown-variant-value',
870
+ component: declaredName,
871
+ attr: u.attr,
872
+ value: u.value,
873
+ declared: Object.keys(axis),
874
+ file: path,
875
+ line: u.line,
876
+ });
877
+ }
878
+ } else if (Object.keys(record.states ?? {}).includes(u.attr)) {
879
+ stats.axisMatched += 1;
880
+ } else {
881
+ stats.axisUnmatched += 1;
882
+ advisories.push({
883
+ rule: 'unmodelled-prop',
884
+ component: declaredName,
885
+ attr: u.attr,
886
+ axes: Object.keys(record.variants ?? {}),
887
+ file: path,
888
+ line: u.line,
889
+ });
890
+ }
891
+ }
892
+
893
+ for (const l of literals) {
894
+ stats.literals += 1;
895
+ if (off.has('token-exists-for-literal')) continue;
896
+ const tokens = tokenValues.get(l.value);
897
+ if (tokens) {
898
+ // A path two mode files both resolve to this value is one name to reach
899
+ // for, so it is named once.
900
+ failures.push({
901
+ rule: 'token-exists-for-literal',
902
+ value: l.value,
903
+ tokens: [...new Set(tokens)],
904
+ file: path,
905
+ line: l.line,
906
+ });
907
+ }
908
+ }
909
+
910
+ for (const d of dimensions) {
911
+ stats.dimensions += 1;
912
+ if (off.has('token-exists-for-dimension')) continue;
913
+ const tokens = dimensionValues.get(d.category)?.get(d.value);
914
+ if (tokens) {
915
+ failures.push({
916
+ rule: 'token-exists-for-dimension',
917
+ category: d.category,
918
+ written: d.written,
919
+ value: d.value,
920
+ tokens: [...new Set(tokens)],
921
+ file: path,
922
+ line: d.line,
923
+ });
924
+ }
925
+ }
926
+ }
927
+
928
+ // Decision 7 applied to the scan itself, not just to each rule. Every rule can
929
+ // be individually satisfied while the walk found no code — wrong --root, a
930
+ // --package specifier the app does not import under, an app directory that is
931
+ // empty. That run reported a clean pass having read nothing, which is the
932
+ // green light every other rule here exists to prevent.
933
+ if (stats.elements === 0 && stats.literals === 0 && stats.dimensions === 0) {
934
+ failures.push({ rule: 'nothing-scanned', files: stats.files });
935
+ }
936
+
937
+ // Decision 7, per rule. A rule with nothing to work on has verified nothing,
938
+ // and a green run that verified nothing is the failure class this project
939
+ // keeps filing issues about. A rule the caller switched off is absent, not
940
+ // inert.
941
+ // A --package the app never imports under leaves every literal rule busy and
942
+ // the component rules with nothing, so nothing-scanned stays quiet. Only fired
943
+ // when the walk found something else, so it never doubles nothing-scanned.
944
+ const scannedOther = stats.literals > 0 || stats.dimensions > 0;
945
+ if (!off.has('unknown-component') && stats.elements === 0 && scannedOther) {
946
+ failures.push({ rule: 'component-rule-inert', files: stats.files });
947
+ }
948
+ if (!off.has('token-exists-for-literal') && tokenValues.size === 0) {
949
+ failures.push({ rule: 'colour-rule-inert' });
950
+ }
951
+ if (!off.has('token-exists-for-dimension') && dimensionValues.size === 0) {
952
+ failures.push({ rule: 'dimension-rule-inert' });
953
+ }
954
+ if (!off.has('unknown-variant-value') && stats.knownComponents.size > 0 && stats.axisMatched === 0) {
955
+ // `undocumented` rides along because it is often the real cause: an
956
+ // undocumented component is skipped before any axis can match, so a run
957
+ // that referenced nothing else lands here with a message about disagreeing
958
+ // axis names that would send the reader looking in the wrong place.
959
+ failures.push({
960
+ rule: 'variant-rule-inert',
961
+ axes: [...records.values()].flatMap((r) => Object.keys(r.variants ?? {})),
962
+ undocumented: stats.undocumented.size,
963
+ });
964
+ }
965
+
966
+ return { ok: failures.length === 0, failures, advisories, stats, skipped: [...off], excluded };
967
+ }
968
+
969
+ export function formatReport(r) {
970
+ const s = r.stats;
971
+ const dimensionTotal = DIMENSION_CATEGORIES.reduce((n, c) => n + s.dimensionTokens[c], 0);
972
+ const lines = [
973
+ `tokens:validate-adherence — ${s.elements} component references, ${s.literals} colour literals, ${s.dimensions} dimension literals, ${s.files} files`,
974
+ ` components: ${s.knownComponents.size} referenced, ${s.undocumented.size} undocumented`,
975
+ ];
976
+ if (s.parts.size > 0) {
977
+ lines.push(
978
+ ` parts: ${[...s.parts].map(([p, o]) => `${p} (${o})`).join(', ')} — not in components.built, read as part of the built component each name starts with`,
979
+ );
980
+ }
981
+ lines.push(
982
+ ` variant axes: ${s.axisMatched} of ${s.axisMatched + s.axisUnmatched} literal attributes matched a declared axis`,
983
+ ` not read: ${s.dynamic} of ${s.usages} attributes are expressions, not literals`,
984
+ ` colour: ${s.colourTokens} token values comparable, ${s.colourSkipped.unresolvable} skipped as unresolvable, ${s.colourSkipped.nonHex} skipped as non-hex`,
985
+ ` dimensions: ${dimensionTotal} token values comparable — ${DIMENSION_CATEGORIES.map((c) => `${c} ${s.dimensionTokens[c]}`).join(', ')}`,
986
+ );
987
+ const a11y = Object.entries(s.a11y);
988
+ if (a11y.length) {
989
+ lines.push(` a11y: ${a11y.map(([rule, c]) => `${rule} ${c.checked} checked, ${c.abstained} abstained`).join('; ')}`);
990
+ }
991
+ for (const e of r.excluded) {
992
+ lines.push(` excluded: ${e.files} file(s) in ${e.dir}, the package that owns --tokens`);
993
+ }
994
+ if (r.skipped.length) lines.push(` skipped: ${r.skipped.join(', ')}`);
995
+
996
+ if (r.failures.length) {
997
+ lines.push(`\n${r.failures.length} rule failure(s):`);
998
+ for (const f of r.failures) {
999
+ if (f.rule === 'unknown-variant-value') {
1000
+ lines.push(
1001
+ ` - [${f.rule}] ${f.component} ${f.attr}="${f.value}" at ${f.file}:${f.line} — declared values for "${f.attr}" are ${f.declared.join(', ')}`,
1002
+ );
1003
+ } else if (f.rule === 'token-exists-for-literal') {
1004
+ lines.push(
1005
+ ` - [${f.rule}] ${f.value} at ${f.file}:${f.line} — ${f.tokens.join(', ')} resolve${f.tokens.length === 1 ? 's' : ''} to exactly this value`,
1006
+ );
1007
+ } else if (f.rule === 'unknown-component') {
1008
+ lines.push(
1009
+ ` - [${f.rule}] <${f.component}> at ${f.file}:${f.line} — not in design-system.json components.built`,
1010
+ );
1011
+ } else if (f.rule === 'token-exists-for-dimension') {
1012
+ lines.push(
1013
+ ` - [${f.rule}] ${f.category} ${f.written}${f.written === f.value ? '' : ` (${f.value})`} at ${f.file}:${f.line} — ${f.tokens.join(', ')} resolve${f.tokens.length === 1 ? 's' : ''} to exactly this value`,
1014
+ );
1015
+ } else if (f.rule === 'unnamed-control') {
1016
+ lines.push(
1017
+ ` - [${f.rule}] <${f.component}> at ${f.file}:${f.line} — no accessible name: no text, and no aria-label, aria-labelledby or title on it or anything inside it`,
1018
+ );
1019
+ } else if (f.rule === 'colour-only-status') {
1020
+ lines.push(
1021
+ ` - [${f.rule}] <${f.component}> at ${f.file}:${f.line} — status in colour alone: no text, and no named icon inside it`,
1022
+ );
1023
+ } else if (f.rule === 'nothing-scanned') {
1024
+ lines.push(
1025
+ ` - [${f.rule}] ${f.files} file(s) yielded no component reference, colour literal or dimension literal, so this run verified nothing. Check --root points at the consuming app, and that --package is the specifier that app actually imports from.`,
1026
+ );
1027
+ } else if (f.rule === 'component-rule-inert') {
1028
+ lines.push(
1029
+ ` - [${f.rule}] ${f.files} file(s) held literals but no component imported from --package, so unknown-component and unknown-variant-value verified nothing. Check --package is the specifier the app imports the system under, or --skip both rules for an app with no JSX (Vue, Svelte, Angular).`,
1030
+ );
1031
+ } else if (f.rule === 'colour-rule-inert') {
1032
+ lines.push(
1033
+ ` - [${f.rule}] no token file yielded a comparable hex value, so nothing was checked against. Pass --tokens, or --skip token-exists-for-literal if this system has no colour tokens.`,
1034
+ );
1035
+ } else if (f.rule === 'dimension-rule-inert') {
1036
+ lines.push(
1037
+ ` - [${f.rule}] no token file yielded a comparable spacing, radius or type value, so nothing was checked against. Pass the --tokens file that holds them, or --skip token-exists-for-dimension if this system has none.`,
1038
+ );
1039
+ } else if (f.rule === 'variant-rule-inert') {
1040
+ // Lead with the undocumented count when there is one: it is the likeliest
1041
+ // cause, and the axis-name explanation sends the reader elsewhere.
1042
+ const cause = f.undocumented
1043
+ ? `${f.undocumented} referenced component(s) have no doc record, so their variants could not be checked — run docs:digest. `
1044
+ : '';
1045
+ lines.push(
1046
+ ` - [${f.rule}] components were found but no attribute matched a declared axis, so this rule verified nothing. ${cause}The system declares ${f.axes.join(', ') || '(no axes at all)'}. Either the records and the code disagree about names, or this system's axes are conceptual — --skip unknown-variant-value if so.`,
1047
+ );
1048
+ }
1049
+ }
1050
+ }
1051
+
1052
+ if (r.advisories.length) {
1053
+ lines.push(`\n${r.advisories.length} advisory note(s) — reported, not gating:`);
1054
+ for (const a of r.advisories) {
1055
+ if (a.rule === 'unmodelled-prop') {
1056
+ lines.push(
1057
+ ` - [${a.rule}] ${a.component} "${a.attr}" at ${a.file}:${a.line} — no declared axis or state of that name; the system models this component's axes as ${a.axes.join(', ') || '(none)'}`,
1058
+ );
1059
+ } else if (a.rule === 'undocumented-component') {
1060
+ lines.push(
1061
+ ` - [${a.rule}] ${a.component} — built, but has no doc record, so its variants cannot be checked. Run docs:digest after documenting it.`,
1062
+ );
1063
+ } else if (a.rule === 'dynamic-value') {
1064
+ lines.push(
1065
+ ` - [${a.rule}] ${a.component} ${a.attr}={…} at ${a.file}:${a.line} — not a literal, so its value could not be read`,
1066
+ );
1067
+ }
1068
+ }
1069
+ }
1070
+ return lines;
1071
+ }
1072
+
1073
+ export const SKIPPABLE = [
1074
+ 'unknown-component',
1075
+ 'unknown-variant-value',
1076
+ 'token-exists-for-literal',
1077
+ 'token-exists-for-dimension',
1078
+ ...A11Y_RULES,
1079
+ ];
1080
+
1081
+ function main() {
1082
+ let values;
1083
+ try {
1084
+ ({ values } = parseArgs({
1085
+ options: {
1086
+ root: { type: 'string' },
1087
+ system: { type: 'string' },
1088
+ package: { type: 'string' },
1089
+ tokens: { type: 'string', multiple: true },
1090
+ skip: { type: 'string', multiple: true, default: [] },
1091
+ },
1092
+ }));
1093
+ } catch (e) {
1094
+ console.error(e.message);
1095
+ process.exit(2);
1096
+ }
1097
+
1098
+ const unknown = values.skip.filter((rule) => !SKIPPABLE.includes(rule));
1099
+ if (unknown.length) {
1100
+ console.error(`--skip names no rule: ${unknown.join(', ')}. Skippable: ${SKIPPABLE.join(', ')}.`);
1101
+ process.exit(2);
1102
+ }
1103
+
1104
+ if (!values.root || !values.system || !values.package) {
1105
+ console.error(
1106
+ 'usage: validate-adherence.mjs --root <dir> --system <dir> --package <specifier> --tokens <file> [--tokens <file>...] [--skip <rule>]',
1107
+ );
1108
+ process.exit(2);
1109
+ }
1110
+
1111
+ const read = (p, what) => {
1112
+ try {
1113
+ return JSON.parse(readFileSync(p, 'utf8'));
1114
+ } catch (e) {
1115
+ console.error(`cannot read ${what} at ${p}: ${e.message}`);
1116
+ process.exit(2);
1117
+ }
1118
+ };
1119
+
1120
+ const manifest = read(join(values.system, 'design-system.json'), 'the manifest');
1121
+ const index = read(
1122
+ join(values.system, 'design-system/docs/index.json'),
1123
+ 'the docs index (run docs:digest first)',
1124
+ );
1125
+ const tokenDicts = (values.tokens ?? []).map((f) => read(f, 'a token source'));
1126
+ const tokenValues = buildTokenValues(tokenDicts);
1127
+ const colourSkipped = skippedColourTokens(tokenDicts);
1128
+ const dimensionValues = buildDimensionValues(tokenDicts);
1129
+
1130
+ const files = [];
1131
+ let walked;
1132
+ try {
1133
+ walked = [...walk(values.root, { fileFilter: /\.(tsx?|jsx?|mjs|cjs|css|scss|sass|vue|svelte|html)$/ })];
1134
+ } catch (e) {
1135
+ console.error(`cannot scan --root ${values.root}: ${e.message}`);
1136
+ process.exit(2);
1137
+ }
1138
+
1139
+ // Partitioned here rather than passed to walk, so the report can count what it
1140
+ // set aside. Paths are compared in real form: tmpdir and a symlinked checkout
1141
+ // would otherwise never match the realpath'd package dirs.
1142
+ const realRoot = realpathSync(values.root);
1143
+ const ownerDirs = tokenPackageDirs(values.tokens ?? [], values.root);
1144
+ const excludedCounts = new Map(ownerDirs.map((d) => [d, 0]));
1145
+ const scanned = [];
1146
+ for (const path of walked) {
1147
+ const real = join(realRoot, relative(values.root, path));
1148
+ const owner = ownerDirs.find((d) => real.startsWith(d + sep));
1149
+ if (owner) excludedCounts.set(owner, excludedCounts.get(owner) + 1);
1150
+ else scanned.push(path);
1151
+ }
1152
+ const excluded = [...excludedCounts]
1153
+ .filter(([, n]) => n > 0)
1154
+ .map(([dir, n]) => ({ dir: join(values.root, relative(realRoot, dir)), files: n }));
1155
+
1156
+ for (const path of scanned) {
1157
+ const { elements, usages, literals, dimensions, subtrees } = extract(readFileSync(path, 'utf8'), values.package, path);
1158
+ if (elements.length || usages.length || literals.length || dimensions.length || subtrees.length) {
1159
+ files.push({ path, elements, usages, literals, dimensions, subtrees });
1160
+ }
1161
+ }
1162
+
1163
+ const r = validate({
1164
+ built: manifest.components?.built ?? [],
1165
+ index,
1166
+ tokenValues,
1167
+ colourSkipped,
1168
+ dimensionValues,
1169
+ files,
1170
+ walked: scanned.length,
1171
+ excluded,
1172
+ skip: values.skip,
1173
+ });
1174
+ for (const line of formatReport(r)) console.log(line);
1175
+ process.exit(r.ok ? 0 : 1);
1176
+ }
1177
+
1178
+ if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) main();