@radicool/throughline 0.19.0 → 0.20.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 (44) hide show
  1. package/README.md +2 -2
  2. package/adapters/codex/prompts/component-builder.md +24 -1
  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 +24 -1
  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 +24 -1
  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 +14 -2
  29. package/references/figma-scripting.md +12 -2
  30. package/references/manifest-schema.md +42 -2
  31. package/references/proof-bundle.md +305 -0
  32. package/references/sync-adapters.md +38 -53
  33. package/scripts/README.md +140 -6
  34. package/scripts/build-doc-card-builder.mjs +1 -1
  35. package/scripts/grep-color-usage.mjs +10 -30
  36. package/scripts/guard-token-removal.mjs +10 -26
  37. package/scripts/lib/component-states.mjs +56 -0
  38. package/scripts/lib/contrast.mjs +117 -0
  39. package/scripts/lib/dtcg.mjs +3 -2
  40. package/scripts/lib/proof.mjs +186 -0
  41. package/scripts/lib/source-scan.mjs +45 -0
  42. package/scripts/validate-adherence.mjs +895 -0
  43. package/scripts/validate-token-output.mjs +468 -63
  44. package/scripts/verify-check.mjs +710 -0
@@ -0,0 +1,895 @@
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
+
20
+ // Named imports from one package, alias included: `{ Card as Panel }`.
21
+ const IMPORT = /import\s*\{([^}]*)\}\s*from\s*['"]([^'"]+)['"]/g;
22
+ // An opening JSX tag on a capitalised name, with an optional `.Member` chain
23
+ // and its attribute text, where a `<` straight after an identifier character
24
+ // is a type argument (`useState<Variant>`), not a tag.
25
+ const ELEMENT = /(?<![\w$.])<([A-Z][A-Za-z0-9]*)((?:\.[A-Za-z][A-Za-z0-9]*)*)\b([^>]*?)\/?>/g;
26
+ // One attribute: name="literal" or name={expression}. The capture is undefined
27
+ // for the expression form, which is how a blind spot stays visible.
28
+ const ATTR = /([a-zA-Z][a-zA-Z0-9_-]*)\s*=\s*(?:"([^"]*)"|'([^']*)'|\{)/g;
29
+ // Hex and opaque integer rgb()/rgba() are compared (see rgbToHex). hsl() and
30
+ // alpha below 1 are not.
31
+ const HEX = /#[0-9a-fA-F]{3,8}\b/g;
32
+ const RGB = /\brgba?\([^()]*\)/gi;
33
+
34
+ // Dimension rules (#39): docs/specs/2026-09-11-dimension-rules.md. A property
35
+ // name maps to its category once the punctuation is gone: lowercase, hyphens
36
+ // stripped, so `padding-inline-start` and `paddingInlineStart` are both
37
+ // `paddinginlinestart`.
38
+ const SPACING_SUFFIXES = [
39
+ '',
40
+ 'top',
41
+ 'right',
42
+ 'bottom',
43
+ 'left',
44
+ 'inline',
45
+ 'block',
46
+ 'inlinestart',
47
+ 'inlineend',
48
+ 'blockstart',
49
+ 'blockend',
50
+ ];
51
+ const RADIUS_CORNERS = ['', 'topleft', 'topright', 'bottomright', 'bottomleft', 'startstart', 'startend', 'endstart', 'endend'];
52
+ const PROPERTY_CATEGORIES = new Map();
53
+ for (const base of ['padding', 'margin']) {
54
+ for (const suffix of SPACING_SUFFIXES) PROPERTY_CATEGORIES.set(base + suffix, 'spacing');
55
+ }
56
+ for (const name of ['gap', 'rowgap', 'columngap', 'gridgap', 'gridrowgap', 'gridcolumngap']) {
57
+ PROPERTY_CATEGORIES.set(name, 'spacing');
58
+ }
59
+ for (const corner of RADIUS_CORNERS) {
60
+ PROPERTY_CATEGORIES.set(`border${corner}radius`, 'radius');
61
+ }
62
+ PROPERTY_CATEGORIES.set('fontsize', 'font-size');
63
+ PROPERTY_CATEGORIES.set('lineheight', 'line-height');
64
+ PROPERTY_CATEGORIES.set('letterspacing', 'letter-spacing');
65
+ PROPERTY_CATEGORIES.set('fontweight', 'font-weight');
66
+
67
+ // `property: value`, value running to the next `;`, `{`, `}`, `,` or newline —
68
+ // that comma is what separates `{ fontSize: 13, marginTop: 16 }` into two
69
+ // declarations rather than one.
70
+ const DECLARATION = /(?<![\w$@.#-])([a-zA-Z][a-zA-Z-]*)\s*:\s*([^;{}\n,]*)/g;
71
+ // A bare, px, rem, em or % number, not abutting a word character, `.`, `#`,
72
+ // `$`, `%` or `(` on either side — that keeps it out of `#3b82f6` and `calc(`.
73
+ const VALUE_NUMBER = /(?<![\w.#$%-])-?(?:\d+(?:\.\d+)?|\.\d+)(?:px|rem|em|%)?(?![\w.%(-])/g;
74
+ // A Tailwind arbitrary value, including after a variant like `md:`. `tw-`
75
+ // prefixes are excluded by the lookbehind: the character right before the
76
+ // utility can't be a word character or `-`.
77
+ const TAILWIND =
78
+ /(?<![\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;
79
+ const SCRIPT_FILE = /\.(tsx?|jsx?|mjs|cjs|vue|svelte)$/;
80
+
81
+ // One starting p, m, gap or space is spacing; rounded is radius; text, leading,
82
+ // tracking and font are the four type properties.
83
+ function tailwindCategory(utility) {
84
+ if (utility.startsWith('p') || utility.startsWith('m') || utility.startsWith('gap') || utility.startsWith('space')) {
85
+ return 'spacing';
86
+ }
87
+ if (utility.startsWith('rounded')) return 'radius';
88
+ if (utility === 'text') return 'font-size';
89
+ if (utility === 'leading') return 'line-height';
90
+ if (utility === 'tracking') return 'letter-spacing';
91
+ if (utility === 'font') return 'font-weight';
92
+ return null;
93
+ }
94
+
95
+ const lineOf = (text, index) => text.slice(0, index).split('\n').length;
96
+
97
+ // Every character but a newline becomes a space, so an index into the blanked
98
+ // text is the same line in the original.
99
+ const spaces = (s) => s.replace(/[^\n]/g, ' ');
100
+
101
+ // A hex inside a comment is not code: `var(--signal-500); /* #5B7FFF */` already
102
+ // uses the token. One pass, leftmost opener wins, so a `/*` inside a `//`
103
+ // comment cannot pair with a real `*/` lines later. `//` is not a comment in
104
+ // plain CSS or an HTML template, and after a `:` it is a URL. An unterminated `/*` is left alone,
105
+ // which errs toward flagging. Strings are not tracked; that would be a parser.
106
+ export function blankComments(text, path = '') {
107
+ const re = /\.(css|html)$/.test(path) ? /\/\*[\s\S]*?\*\//g : /\/\*[\s\S]*?\*\/|(?<!:)\/\/[^\n]*/g;
108
+ return text.replace(re, spaces);
109
+ }
110
+
111
+ // In `mask: linear-gradient(#fff 0 0) content-box, ...` only alpha matters, so
112
+ // the value of a `mask` or `-webkit-mask` declaration is not a colour. The
113
+ // lookbehind keeps `--mask`, `$mask` and `.mask` flagging; `mask-image` and the
114
+ // other `mask-*` properties never match. CSS, SCSS and a template's `style`
115
+ // attribute, which templateView ends with a `;`. Not `.sass`: it has no `;`
116
+ // to end the value, and in script a value ending at `}` could swallow a sibling
117
+ // property's real hex.
118
+ const MASK = /(?<![\w$@.#-])(?:-webkit-)?mask\s*:([^;{}]*)/g;
119
+ export function blankMasks(text, path = '') {
120
+ if (!/\.(s?css|html)$/.test(path)) return text;
121
+ return text.replace(MASK, (m, value) => m.slice(0, m.length - value.length) + spaces(value));
122
+ }
123
+
124
+ // `var(--space-4, 16px)` is correct code — the 16px is the token's fallback —
125
+ // and `calc()`, `clamp()`, `min()` and `max()` arguments are arithmetic, not
126
+ // scale steps. Every character between a `(` and its matching `)` becomes a
127
+ // space; an unclosed `(` blanks to the end of the value. Length is unchanged,
128
+ // so it stays safe to run before VALUE_NUMBER's index-based line lookup.
129
+ export function blankParens(value) {
130
+ let depth = 0;
131
+ let out = '';
132
+ for (const ch of value) {
133
+ if (ch === '(') {
134
+ depth += 1;
135
+ out += ' ';
136
+ } else if (ch === ')' && depth > 0) {
137
+ depth -= 1;
138
+ out += ' ';
139
+ } else if (depth > 0) {
140
+ out += ' ';
141
+ } else {
142
+ out += ch;
143
+ }
144
+ }
145
+ return out;
146
+ }
147
+
148
+ // The only false positives the dimension-rule prototype found were the
149
+ // `font-weight` descriptors inside `@font-face { … }`, which describe a font
150
+ // file, not a scale step. Blanked before dimensions are read, same shape as
151
+ // blankComments.
152
+ const FONT_FACE = /@font-face\s*\{[^}]*\}/g;
153
+ export function blankFontFaces(text) {
154
+ return text.replace(FONT_FACE, spaces);
155
+ }
156
+
157
+ // #abc -> #aabbcc; #aabbccff -> #aabbcc (opaque alpha carries no information);
158
+ // a real alpha is kept, because two colours differing only in alpha are two
159
+ // colours. Anything not hex returns null and is never compared.
160
+ export function normalizeHex(value) {
161
+ const v = String(value).trim().toLowerCase();
162
+ if (!/^#[0-9a-f]{3,8}$/.test(v)) return null;
163
+ let hex = v.slice(1);
164
+ if (hex.length === 3 || hex.length === 4) hex = [...hex].map((c) => c + c).join('');
165
+ if (hex.length === 8 && hex.endsWith('ff')) hex = hex.slice(0, 6);
166
+ if (hex.length !== 6 && hex.length !== 8) return null;
167
+ return '#' + hex;
168
+ }
169
+
170
+ // rgb(59, 130, 246) -> #3b82f6. Only whole channels 0-255 and an opaque alpha
171
+ // (absent, 1, 1.0 or 100%) are comparable; anything else — percentages,
172
+ // var(), a real alpha — returns null and is never guessed at.
173
+ export function rgbToHex(value) {
174
+ const m = String(value).trim().match(/^rgba?\(\s*([^()]*)\)$/i);
175
+ if (!m) return null;
176
+ const parts = m[1].split(/[\s,/]+/).filter(Boolean);
177
+ if (parts.length < 3 || parts.length > 4) return null;
178
+ const channels = parts.slice(0, 3);
179
+ for (const c of channels) {
180
+ if (!/^\d{1,3}$/.test(c) || Number(c) > 255) return null;
181
+ }
182
+ if (parts.length === 4 && !/^(1(\.0+)?|100%)$/.test(parts[3])) return null;
183
+ const hex = channels.map((c) => Number(c).toString(16).padStart(2, '0')).join('');
184
+ return normalizeHex('#' + hex);
185
+ }
186
+
187
+ // An Angular template carries styling in a few attributes and nowhere else, so
188
+ // only those are read (#132). Everything outside them — text content, which is
189
+ // where a brand guide prints `#080d14`, an entity like `&#8288;`, `data-*`,
190
+ // `href="#cafe"`, HTML comments — is blanked. Bindings read like their plain
191
+ // attribute: `[attr.fill]`, `[stroke]`, `[style.color]`, `[ngStyle]`. The rule
192
+ // that reads JSX tags does not apply; an Angular selector is a different mapping.
193
+ const TEMPLATE_ATTRS = new Set(['style', 'ngstyle', 'class', 'ngclass', 'fill', 'stroke', 'stop-color', 'flood-color', 'lighting-color', 'color']);
194
+ const HTML_TAG = /<[a-zA-Z][^\s/>]*((?:\s+[^\s=/>"']+(?:\s*=\s*(?:"[^"]*"|'[^']*'|[^\s>"'][^\s>]*))?)*)\s*\/?>/g;
195
+ const HTML_ATTR = /([^\s=/>"']+)\s*=\s*(?:"([^"]*)"|'([^']*)')/g;
196
+
197
+ function templateAttr(name) {
198
+ const bare = name.toLowerCase().replace(/^\[\(?|\)?\]$/g, '').replace(/^attr\./, '');
199
+ return bare.startsWith('style.') ? 'style' : bare.startsWith('class.') ? 'class' : bare;
200
+ }
201
+
202
+ export function templateView(text) {
203
+ const out = spaces(text).split('');
204
+ const code = text.replace(/<!--[\s\S]*?-->/g, spaces);
205
+ for (const tag of code.matchAll(HTML_TAG)) {
206
+ const attrsAt = tag.index + tag[0].indexOf(tag[1]);
207
+ for (const a of tag[1].matchAll(HTML_ATTR)) {
208
+ if (!TEMPLATE_ATTRS.has(templateAttr(a[1]))) continue;
209
+ const value = a[2] ?? a[3];
210
+ const at = attrsAt + a.index + a[0].length - 1 - value.length;
211
+ for (let i = 0; i < value.length; i += 1) out[at + i] = value[i];
212
+ // A style value's last declaration has no `;`, and would otherwise run on
213
+ // into the next kept value on the line. The closing quote becomes one.
214
+ if (['style', 'ngstyle'].includes(templateAttr(a[1]))) out[at + value.length] = ';';
215
+ }
216
+ }
217
+ return out.join('');
218
+ }
219
+
220
+ export function extract(text, pkg, path = '') {
221
+ if (path.endsWith('.html')) text = templateView(text);
222
+ const code = blankComments(text, path);
223
+
224
+ const imported = new Map();
225
+ for (const m of code.matchAll(IMPORT)) {
226
+ if (m[2] !== pkg) continue;
227
+ for (const part of m[1].split(',')) {
228
+ const [declared, local] = part.trim().split(/\s+as\s+/);
229
+ if (declared) imported.set((local ?? declared).trim(), declared.trim());
230
+ }
231
+ }
232
+
233
+ const elements = [];
234
+ const usages = [];
235
+ for (const el of code.matchAll(ELEMENT)) {
236
+ const declared = imported.get(el[1]);
237
+ if (!declared) continue;
238
+ // <Icons.Folder> is a member of something the system exports and is left
239
+ // alone (#120).
240
+ if (el[2]) continue;
241
+ const line = lineOf(code, el.index);
242
+ elements.push({ component: declared, line });
243
+ for (const a of el[3].matchAll(ATTR)) {
244
+ usages.push({ component: declared, attr: a[1], value: a[2] ?? a[3] ?? null, line });
245
+ }
246
+ }
247
+
248
+ // Import, element and hex extraction all read comment-blanked text, and
249
+ // only hex also has masks blanked.
250
+ const colourText = blankMasks(code, path);
251
+ const literals = [];
252
+ for (const h of colourText.matchAll(HEX)) {
253
+ const value = normalizeHex(h[0]);
254
+ if (value) literals.push({ value, line: lineOf(colourText, h.index) });
255
+ }
256
+ for (const m of colourText.matchAll(RGB)) {
257
+ const value = rgbToHex(m[0]);
258
+ if (value) literals.push({ value, line: lineOf(colourText, m.index) });
259
+ }
260
+
261
+ return { imported, elements, usages, literals, dimensions: extractDimensions(text, path) };
262
+ }
263
+
264
+ // value -> the token paths that hold it. Repeatable --tokens, because a real
265
+ // system spans mode files with different values for one path, which is why
266
+ // findModeCollisions exists at all; here every file simply contributes, and a
267
+ // value held by more than one token names all of them.
268
+ //
269
+ // resolveValue throws on an unknown path and on a cycle. A gate that reports
270
+ // must not die mid-report, so every resolution is wrapped and a token that
271
+ // cannot be resolved is skipped, and counted by skippedColourTokens.
272
+ export function buildTokenValues(dicts) {
273
+ const out = new Map();
274
+ for (const t of resolvedTokens(dicts)) {
275
+ if (!t.resolves) continue;
276
+ const hex = normalizeHex(t.value) ?? rgbToHex(t.value);
277
+ if (!hex) continue;
278
+ if (!out.has(hex)) out.set(hex, []);
279
+ out.get(hex).push(t.path);
280
+ }
281
+ return out;
282
+ }
283
+
284
+ // Every token in every --tokens file, resolved against all of them (#121). A
285
+ // semantic file aliases primitives in another file, and resolving each file
286
+ // alone skipped every one of those aliases. A file's own paths win over the
287
+ // pool, so two mode files defining one path differently each keep their own
288
+ // value; a path defined in several OTHER files resolves to the last one given.
289
+ // An alias that names no path in any file stays unresolvable. That includes a
290
+ // collection-relative `{canvas}` meaning `color-primitive.canvas`, which is an
291
+ // export quirk rather than DTCG, and is counted rather than guessed at.
292
+ //
293
+ // The `source` index is the dict a token came from, which is what lets a caller
294
+ // group a system's tokens by mode, since one --tokens file is one mode:
295
+ // scripts/verify-check.mjs reads it that way for the color-contrast rule.
296
+ export function* resolvedTokens(dicts) {
297
+ const flats = dicts.map((d) => flattenDtcg(d));
298
+ const pool = Object.assign({}, ...flats);
299
+ for (const [i, dict] of dicts.entries()) {
300
+ const flat = flats[i];
301
+ const lookup = { ...pool, ...flat };
302
+ const types = flattenDtcgTypes(dict);
303
+ for (const path of Object.keys(flat)) {
304
+ let value;
305
+ try {
306
+ value = resolveValue(path, lookup);
307
+ } catch {
308
+ yield { path, type: types[path], resolves: false, source: i };
309
+ continue;
310
+ }
311
+ yield { path, type: types[path], resolves: true, value, source: i };
312
+ }
313
+ }
314
+ }
315
+
316
+ // Decision 4: a colour token the gate cannot compare is skipped AND counted, so
317
+ // the report shows the blind spot. Only tokens whose effective $type is `color`
318
+ // are counted — an untyped token that resolves to hex is still compared, but
319
+ // one that does not could be anything, and counting a font family as a skipped
320
+ // colour would bury the number that matters.
321
+ export function skippedColourTokens(dicts) {
322
+ const skipped = { unresolvable: 0, nonHex: 0 };
323
+ for (const t of resolvedTokens(dicts)) {
324
+ if (t.type !== 'color') continue;
325
+ if (!t.resolves) skipped.unresolvable += 1;
326
+ else if (!(normalizeHex(t.value) ?? rgbToHex(t.value))) skipped.nonHex += 1;
327
+ }
328
+ return skipped;
329
+ }
330
+
331
+ // Dimension rules (#39): docs/specs/2026-09-11-dimension-rules.md. Same shape
332
+ // as buildTokenValues, one layer over: a token's category comes from the words
333
+ // in its path (DTCG's `dimension` type doesn't say what a length is for), and
334
+ // its raw value is canonicalised to a comparable string within that category
335
+ // before values across dicts are pooled.
336
+ export const DIMENSION_CATEGORIES = [
337
+ 'spacing',
338
+ 'radius',
339
+ 'font-size',
340
+ 'line-height',
341
+ 'letter-spacing',
342
+ 'font-weight',
343
+ ];
344
+
345
+ // space/spacing/gap/inset/stack/gutter/padding/margin -> spacing; radius/rounded/
346
+ // corner -> radius; fontsize/text -> font-size; lineheight/leading -> line-height;
347
+ // letterspacing/tracking -> letter-spacing; fontweight -> font-weight. `size` and
348
+ // `weight` are ambiguous on their own — a typographic qualifier earlier in the
349
+ // path decides them, or they decide nothing.
350
+ const DIMENSION_WORDS = {
351
+ space: 'spacing',
352
+ spacing: 'spacing',
353
+ gap: 'spacing',
354
+ inset: 'spacing',
355
+ stack: 'spacing',
356
+ gutter: 'spacing',
357
+ padding: 'spacing',
358
+ margin: 'spacing',
359
+ radius: 'radius',
360
+ rounded: 'radius',
361
+ corner: 'radius',
362
+ fontsize: 'font-size',
363
+ text: 'font-size',
364
+ lineheight: 'line-height',
365
+ leading: 'line-height',
366
+ letterspacing: 'letter-spacing',
367
+ tracking: 'letter-spacing',
368
+ fontweight: 'font-weight',
369
+ };
370
+ const TYPOGRAPHIC_QUALIFIERS = new Set(['font', 'text', 'typography', 'type']);
371
+
372
+ export function dimensionCategory(path, type) {
373
+ if (type !== undefined && type !== 'dimension' && type !== 'number' && type !== 'fontWeight') return null;
374
+ if (type === 'fontWeight') return 'font-weight';
375
+
376
+ const words = path
377
+ .split('.')
378
+ .flatMap((s) => s.toLowerCase().split(/[-_]/))
379
+ .filter(Boolean);
380
+
381
+ for (let i = words.length - 1; i >= 0; i--) {
382
+ const word = words[i];
383
+ if (word === 'size' || word === 'weight') {
384
+ const qualified = words.slice(0, i).some((w) => TYPOGRAPHIC_QUALIFIERS.has(w));
385
+ return qualified ? (word === 'size' ? 'font-size' : 'font-weight') : null;
386
+ }
387
+ if (word in DIMENSION_WORDS) return DIMENSION_WORDS[word];
388
+ }
389
+ return null;
390
+ }
391
+
392
+ // A dimension is comparable only within its own category, in one canonical
393
+ // unit per category: px (rem folds in at 16px per rem, per the system's own
394
+ // build), em standing alone, unitless line-height, and a whole-number
395
+ // font-weight string. `unitless` says what a bare number means for a length
396
+ // category — 'px' for a literal read from a script file's inline style, null
397
+ // everywhere else. Zero and percentages are never comparable, on either side.
398
+ export function canonicalDimension(raw, category, unitless) {
399
+ let n;
400
+ let unit;
401
+ if (typeof raw === 'number') {
402
+ n = raw;
403
+ unit = '';
404
+ } else if (typeof raw === 'string') {
405
+ const m = raw.trim().match(/^(-?(?:\d+(?:\.\d+)?|\.\d+))(px|rem|em|%)?$/);
406
+ if (!m) return null;
407
+ n = Number(m[1]);
408
+ unit = m[2] ?? '';
409
+ } else {
410
+ return null;
411
+ }
412
+
413
+ if (!Number.isFinite(n) || n === 0 || unit === '%') return null;
414
+ const r3 = (x) => {
415
+ const rounded = Math.round(x * 1000) / 1000;
416
+ return rounded === 0 ? 0 : rounded;
417
+ };
418
+
419
+ if (category === 'font-weight') {
420
+ return unit === '' && Number.isInteger(n) && n > 0 && n <= 1000 ? String(n) : null;
421
+ }
422
+ if (category === 'line-height' && unit === '') return String(r3(n));
423
+ if (unit === 'em') return `${r3(n)}em`;
424
+ if (unit === 'rem') return `${r3(n * 16)}px`;
425
+ if (unit === 'px') return `${r3(n)}px`;
426
+ return unitless === 'px' ? `${r3(n)}px` : null;
427
+ }
428
+
429
+ // Same shape as buildTokenValues: every dict contributes, a value maps to the
430
+ // token paths that hold it, and a token that cannot be resolved is skipped
431
+ // rather than thrown on. One category per token — matching within a category
432
+ // only is the point, per the spec's measurement.
433
+ export function buildDimensionValues(dicts) {
434
+ const out = new Map();
435
+ for (const t of resolvedTokens(dicts)) {
436
+ const category = dimensionCategory(t.path, t.type);
437
+ if (!category || !t.resolves) continue;
438
+ const value = canonicalDimension(t.value, category, 'px');
439
+ if (!value) continue;
440
+ if (!out.has(category)) out.set(category, new Map());
441
+ const inner = out.get(category);
442
+ if (!inner.has(value)) inner.set(value, []);
443
+ inner.get(value).push(t.path);
444
+ }
445
+ return out;
446
+ }
447
+
448
+ // Same two shapes source is read: a `property: value` declaration in CSS,
449
+ // SCSS, inline styles or CSS in strings, and a Tailwind arbitrary value like
450
+ // `p-[16px]`. Comments and @font-face descriptors are blanked first, same as
451
+ // the colour rule; numbers inside parentheses are blanked per-declaration, so
452
+ // a var() fallback or a calc() argument is never read as a scale step.
453
+ export function extractDimensions(text, path = '') {
454
+ const t = blankFontFaces(blankComments(text, path));
455
+ const out = [];
456
+
457
+ for (const m of t.matchAll(DECLARATION)) {
458
+ const category = PROPERTY_CATEGORIES.get(m[1].toLowerCase().replace(/-/g, ''));
459
+ if (!category) continue;
460
+ const quoted = /^\s*['"`]/.test(m[2]);
461
+ const v = blankParens(m[2].replace(/['"`]/g, ' '));
462
+ for (const n of v.matchAll(VALUE_NUMBER)) {
463
+ const value = canonicalDimension(n[0], category, SCRIPT_FILE.test(path) && !quoted ? 'px' : null);
464
+ if (value) out.push({ category, written: n[0], value, line: lineOf(t, m.index) });
465
+ }
466
+ }
467
+
468
+ for (const m of t.matchAll(TAILWIND)) {
469
+ if (m[1] === '-' && m[3].startsWith('-')) continue;
470
+ const category = tailwindCategory(m[2]);
471
+ if (!category) continue;
472
+ const value = canonicalDimension(m[1] + m[3], category, null);
473
+ if (value) out.push({ category, written: `${m[1]}${m[2]}-[${m[3]}]`, value, line: lineOf(t, m.index) });
474
+ }
475
+
476
+ return out;
477
+ }
478
+
479
+ // The package that owns a --tokens file is not its own consumer. Walked from
480
+ // libs/ or packages/, the gate otherwise reads the token package's generated
481
+ // css/tokens.css and fails every primitive for duplicating itself. The owner is
482
+ // the nearest directory at or above the file holding a package.json. It is kept
483
+ // only when it sits strictly beneath --root: an owner that is the root, or
484
+ // contains it, is a single-package app, and excluding it would exclude the app.
485
+ export function tokenPackageDirs(tokenFiles, root) {
486
+ const realRoot = realpathSync(root);
487
+ const dirs = new Set();
488
+ for (const file of tokenFiles) {
489
+ let dir = dirname(realpathSync(file));
490
+ while (!existsSync(join(dir, 'package.json'))) {
491
+ const up = dirname(dir);
492
+ if (up === dir) {
493
+ dir = null;
494
+ break;
495
+ }
496
+ dir = up;
497
+ }
498
+ if (dir && dir.startsWith(realRoot + sep)) dirs.add(dir);
499
+ }
500
+ return [...dirs];
501
+ }
502
+
503
+ // Component-rule narrowing (#120): docs/specs/2026-09-11-narrow-component-rule.md.
504
+ // `CardTitle` belongs to `Card`, and so does `ButtonX` — a part name only has to
505
+ // start with a built component's name and continue with another capital letter;
506
+ // it is not required to spell out a real word after it. The longest matching
507
+ // built name wins, so `ButtonGroupText` belongs to `ButtonGroup`, not `Button`.
508
+ export function partOwner(name, built) {
509
+ if (!/^[A-Za-z0-9]+$/.test(name)) return null;
510
+ const key = normalizeName(name);
511
+ let best = null;
512
+ let bestKey = '';
513
+ for (const b of built) {
514
+ const k = normalizeName(b);
515
+ if (k === '' || k.length >= key.length || !key.startsWith(k) || !/[A-Z]/.test(name[k.length])) continue;
516
+ if (k.length <= bestKey.length) continue;
517
+ best = b;
518
+ bestKey = k;
519
+ }
520
+ return best;
521
+ }
522
+
523
+ export function validate({
524
+ built = [],
525
+ index = { components: [] },
526
+ tokenValues = new Map(),
527
+ colourSkipped = { unresolvable: 0, nonHex: 0 },
528
+ dimensionValues = new Map(),
529
+ files = [],
530
+ walked = files.length,
531
+ excluded = [],
532
+ skip = [],
533
+ }) {
534
+ const off = new Set(skip);
535
+ const builtKeys = new Map(built.map((n) => [normalizeName(n), n]));
536
+ const records = new Map((index.components ?? []).map((c) => [normalizeName(c.name), c]));
537
+
538
+ const failures = [];
539
+ const advisories = [];
540
+ const stats = {
541
+ elements: 0,
542
+ usages: 0,
543
+ literals: 0,
544
+ dimensions: 0,
545
+ parts: new Map(),
546
+ colourTokens: [...tokenValues.values()].reduce((n, paths) => n + paths.length, 0),
547
+ colourSkipped,
548
+ dimensionTokens: Object.fromEntries(
549
+ DIMENSION_CATEGORIES.map((c) => [
550
+ c,
551
+ [...(dimensionValues.get(c)?.values() ?? [])].reduce((n, paths) => n + paths.length, 0),
552
+ ]),
553
+ ),
554
+ files: walked,
555
+ axisMatched: 0,
556
+ axisUnmatched: 0,
557
+ dynamic: 0,
558
+ knownComponents: new Set(),
559
+ undocumented: new Set(),
560
+ };
561
+
562
+ for (const { path, elements = [], usages, literals, dimensions = [] } of files) {
563
+ for (const e of elements) {
564
+ stats.elements += 1;
565
+ if (off.has('unknown-component') || builtKeys.has(normalizeName(e.component))) continue;
566
+ const owner = partOwner(e.component, built);
567
+ if (owner) {
568
+ if (!stats.parts.has(e.component)) stats.parts.set(e.component, owner);
569
+ continue;
570
+ }
571
+ failures.push({ rule: 'unknown-component', component: e.component, file: path, line: e.line });
572
+ }
573
+
574
+ for (const u of usages) {
575
+ stats.usages += 1;
576
+ const key = normalizeName(u.component);
577
+ const declaredName = builtKeys.get(key);
578
+
579
+ // Existence is checked once per tag, above. An attribute on an unknown
580
+ // component or a part is counted and nothing more.
581
+ if (!declaredName) continue;
582
+ stats.knownComponents.add(declaredName);
583
+
584
+ const record = records.get(key);
585
+ if (!record) {
586
+ if (!stats.undocumented.has(declaredName)) {
587
+ stats.undocumented.add(declaredName);
588
+ advisories.push({ rule: 'undocumented-component', component: declaredName });
589
+ }
590
+ continue;
591
+ }
592
+
593
+ if (u.value === null) {
594
+ stats.dynamic += 1;
595
+ advisories.push({ rule: 'dynamic-value', component: declaredName, attr: u.attr, file: path, line: u.line });
596
+ continue;
597
+ }
598
+
599
+ const axis = (record.variants ?? {})[u.attr];
600
+ if (axis) {
601
+ stats.axisMatched += 1;
602
+ if (!off.has('unknown-variant-value') && !Object.keys(axis).includes(u.value)) {
603
+ failures.push({
604
+ rule: 'unknown-variant-value',
605
+ component: declaredName,
606
+ attr: u.attr,
607
+ value: u.value,
608
+ declared: Object.keys(axis),
609
+ file: path,
610
+ line: u.line,
611
+ });
612
+ }
613
+ } else if (Object.keys(record.states ?? {}).includes(u.attr)) {
614
+ stats.axisMatched += 1;
615
+ } else {
616
+ stats.axisUnmatched += 1;
617
+ advisories.push({
618
+ rule: 'unmodelled-prop',
619
+ component: declaredName,
620
+ attr: u.attr,
621
+ axes: Object.keys(record.variants ?? {}),
622
+ file: path,
623
+ line: u.line,
624
+ });
625
+ }
626
+ }
627
+
628
+ for (const l of literals) {
629
+ stats.literals += 1;
630
+ if (off.has('token-exists-for-literal')) continue;
631
+ const tokens = tokenValues.get(l.value);
632
+ if (tokens) {
633
+ // A path two mode files both resolve to this value is one name to reach
634
+ // for, so it is named once.
635
+ failures.push({
636
+ rule: 'token-exists-for-literal',
637
+ value: l.value,
638
+ tokens: [...new Set(tokens)],
639
+ file: path,
640
+ line: l.line,
641
+ });
642
+ }
643
+ }
644
+
645
+ for (const d of dimensions) {
646
+ stats.dimensions += 1;
647
+ if (off.has('token-exists-for-dimension')) continue;
648
+ const tokens = dimensionValues.get(d.category)?.get(d.value);
649
+ if (tokens) {
650
+ failures.push({
651
+ rule: 'token-exists-for-dimension',
652
+ category: d.category,
653
+ written: d.written,
654
+ value: d.value,
655
+ tokens: [...new Set(tokens)],
656
+ file: path,
657
+ line: d.line,
658
+ });
659
+ }
660
+ }
661
+ }
662
+
663
+ // Decision 7 applied to the scan itself, not just to each rule. Every rule can
664
+ // be individually satisfied while the walk found no code — wrong --root, a
665
+ // --package specifier the app does not import under, an app directory that is
666
+ // empty. That run reported a clean pass having read nothing, which is the
667
+ // green light every other rule here exists to prevent.
668
+ if (stats.elements === 0 && stats.literals === 0 && stats.dimensions === 0) {
669
+ failures.push({ rule: 'nothing-scanned', files: stats.files });
670
+ }
671
+
672
+ // Decision 7, per rule. A rule with nothing to work on has verified nothing,
673
+ // and a green run that verified nothing is the failure class this project
674
+ // keeps filing issues about. A rule the caller switched off is absent, not
675
+ // inert.
676
+ // A --package the app never imports under leaves every literal rule busy and
677
+ // the component rules with nothing, so nothing-scanned stays quiet. Only fired
678
+ // when the walk found something else, so it never doubles nothing-scanned.
679
+ const scannedOther = stats.literals > 0 || stats.dimensions > 0;
680
+ if (!off.has('unknown-component') && stats.elements === 0 && scannedOther) {
681
+ failures.push({ rule: 'component-rule-inert', files: stats.files });
682
+ }
683
+ if (!off.has('token-exists-for-literal') && tokenValues.size === 0) {
684
+ failures.push({ rule: 'colour-rule-inert' });
685
+ }
686
+ if (!off.has('token-exists-for-dimension') && dimensionValues.size === 0) {
687
+ failures.push({ rule: 'dimension-rule-inert' });
688
+ }
689
+ if (!off.has('unknown-variant-value') && stats.knownComponents.size > 0 && stats.axisMatched === 0) {
690
+ // `undocumented` rides along because it is often the real cause: an
691
+ // undocumented component is skipped before any axis can match, so a run
692
+ // that referenced nothing else lands here with a message about disagreeing
693
+ // axis names that would send the reader looking in the wrong place.
694
+ failures.push({
695
+ rule: 'variant-rule-inert',
696
+ axes: [...records.values()].flatMap((r) => Object.keys(r.variants ?? {})),
697
+ undocumented: stats.undocumented.size,
698
+ });
699
+ }
700
+
701
+ return { ok: failures.length === 0, failures, advisories, stats, skipped: [...off], excluded };
702
+ }
703
+
704
+ export function formatReport(r) {
705
+ const s = r.stats;
706
+ const dimensionTotal = DIMENSION_CATEGORIES.reduce((n, c) => n + s.dimensionTokens[c], 0);
707
+ const lines = [
708
+ `tokens:validate-adherence — ${s.elements} component references, ${s.literals} colour literals, ${s.dimensions} dimension literals, ${s.files} files`,
709
+ ` components: ${s.knownComponents.size} referenced, ${s.undocumented.size} undocumented`,
710
+ ];
711
+ if (s.parts.size > 0) {
712
+ lines.push(
713
+ ` parts: ${[...s.parts].map(([p, o]) => `${p} (${o})`).join(', ')} — not in components.built, read as part of the built component each name starts with`,
714
+ );
715
+ }
716
+ lines.push(
717
+ ` variant axes: ${s.axisMatched} of ${s.axisMatched + s.axisUnmatched} literal attributes matched a declared axis`,
718
+ ` not read: ${s.dynamic} of ${s.usages} attributes are expressions, not literals`,
719
+ ` colour: ${s.colourTokens} token values comparable, ${s.colourSkipped.unresolvable} skipped as unresolvable, ${s.colourSkipped.nonHex} skipped as non-hex`,
720
+ ` dimensions: ${dimensionTotal} token values comparable — ${DIMENSION_CATEGORIES.map((c) => `${c} ${s.dimensionTokens[c]}`).join(', ')}`,
721
+ );
722
+ for (const e of r.excluded) {
723
+ lines.push(` excluded: ${e.files} file(s) in ${e.dir}, the package that owns --tokens`);
724
+ }
725
+ if (r.skipped.length) lines.push(` skipped: ${r.skipped.join(', ')}`);
726
+
727
+ if (r.failures.length) {
728
+ lines.push(`\n${r.failures.length} rule failure(s):`);
729
+ for (const f of r.failures) {
730
+ if (f.rule === 'unknown-variant-value') {
731
+ lines.push(
732
+ ` - [${f.rule}] ${f.component} ${f.attr}="${f.value}" at ${f.file}:${f.line} — declared values for "${f.attr}" are ${f.declared.join(', ')}`,
733
+ );
734
+ } else if (f.rule === 'token-exists-for-literal') {
735
+ lines.push(
736
+ ` - [${f.rule}] ${f.value} at ${f.file}:${f.line} — ${f.tokens.join(', ')} resolve${f.tokens.length === 1 ? 's' : ''} to exactly this value`,
737
+ );
738
+ } else if (f.rule === 'unknown-component') {
739
+ lines.push(
740
+ ` - [${f.rule}] <${f.component}> at ${f.file}:${f.line} — not in design-system.json components.built`,
741
+ );
742
+ } else if (f.rule === 'token-exists-for-dimension') {
743
+ lines.push(
744
+ ` - [${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`,
745
+ );
746
+ } else if (f.rule === 'nothing-scanned') {
747
+ lines.push(
748
+ ` - [${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.`,
749
+ );
750
+ } else if (f.rule === 'component-rule-inert') {
751
+ lines.push(
752
+ ` - [${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).`,
753
+ );
754
+ } else if (f.rule === 'colour-rule-inert') {
755
+ lines.push(
756
+ ` - [${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.`,
757
+ );
758
+ } else if (f.rule === 'dimension-rule-inert') {
759
+ lines.push(
760
+ ` - [${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.`,
761
+ );
762
+ } else if (f.rule === 'variant-rule-inert') {
763
+ // Lead with the undocumented count when there is one: it is the likeliest
764
+ // cause, and the axis-name explanation sends the reader elsewhere.
765
+ const cause = f.undocumented
766
+ ? `${f.undocumented} referenced component(s) have no doc record, so their variants could not be checked — run docs:digest. `
767
+ : '';
768
+ lines.push(
769
+ ` - [${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.`,
770
+ );
771
+ }
772
+ }
773
+ }
774
+
775
+ if (r.advisories.length) {
776
+ lines.push(`\n${r.advisories.length} advisory note(s) — reported, not gating:`);
777
+ for (const a of r.advisories) {
778
+ if (a.rule === 'unmodelled-prop') {
779
+ lines.push(
780
+ ` - [${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)'}`,
781
+ );
782
+ } else if (a.rule === 'undocumented-component') {
783
+ lines.push(
784
+ ` - [${a.rule}] ${a.component} — built, but has no doc record, so its variants cannot be checked. Run docs:digest after documenting it.`,
785
+ );
786
+ } else if (a.rule === 'dynamic-value') {
787
+ lines.push(
788
+ ` - [${a.rule}] ${a.component} ${a.attr}={…} at ${a.file}:${a.line} — not a literal, so its value could not be read`,
789
+ );
790
+ }
791
+ }
792
+ }
793
+ return lines;
794
+ }
795
+
796
+ export const SKIPPABLE = ['unknown-component', 'unknown-variant-value', 'token-exists-for-literal', 'token-exists-for-dimension'];
797
+
798
+ function main() {
799
+ let values;
800
+ try {
801
+ ({ values } = parseArgs({
802
+ options: {
803
+ root: { type: 'string' },
804
+ system: { type: 'string' },
805
+ package: { type: 'string' },
806
+ tokens: { type: 'string', multiple: true },
807
+ skip: { type: 'string', multiple: true, default: [] },
808
+ },
809
+ }));
810
+ } catch (e) {
811
+ console.error(e.message);
812
+ process.exit(2);
813
+ }
814
+
815
+ const unknown = values.skip.filter((rule) => !SKIPPABLE.includes(rule));
816
+ if (unknown.length) {
817
+ console.error(`--skip names no rule: ${unknown.join(', ')}. Skippable: ${SKIPPABLE.join(', ')}.`);
818
+ process.exit(2);
819
+ }
820
+
821
+ if (!values.root || !values.system || !values.package) {
822
+ console.error(
823
+ 'usage: validate-adherence.mjs --root <dir> --system <dir> --package <specifier> --tokens <file> [--tokens <file>...] [--skip <rule>]',
824
+ );
825
+ process.exit(2);
826
+ }
827
+
828
+ const read = (p, what) => {
829
+ try {
830
+ return JSON.parse(readFileSync(p, 'utf8'));
831
+ } catch (e) {
832
+ console.error(`cannot read ${what} at ${p}: ${e.message}`);
833
+ process.exit(2);
834
+ }
835
+ };
836
+
837
+ const manifest = read(join(values.system, 'design-system.json'), 'the manifest');
838
+ const index = read(
839
+ join(values.system, 'design-system/docs/index.json'),
840
+ 'the docs index (run docs:digest first)',
841
+ );
842
+ const tokenDicts = (values.tokens ?? []).map((f) => read(f, 'a token source'));
843
+ const tokenValues = buildTokenValues(tokenDicts);
844
+ const colourSkipped = skippedColourTokens(tokenDicts);
845
+ const dimensionValues = buildDimensionValues(tokenDicts);
846
+
847
+ const files = [];
848
+ let walked;
849
+ try {
850
+ walked = [...walk(values.root, { fileFilter: /\.(tsx?|jsx?|mjs|cjs|css|scss|sass|vue|svelte|html)$/ })];
851
+ } catch (e) {
852
+ console.error(`cannot scan --root ${values.root}: ${e.message}`);
853
+ process.exit(2);
854
+ }
855
+
856
+ // Partitioned here rather than passed to walk, so the report can count what it
857
+ // set aside. Paths are compared in real form: tmpdir and a symlinked checkout
858
+ // would otherwise never match the realpath'd package dirs.
859
+ const realRoot = realpathSync(values.root);
860
+ const ownerDirs = tokenPackageDirs(values.tokens ?? [], values.root);
861
+ const excludedCounts = new Map(ownerDirs.map((d) => [d, 0]));
862
+ const scanned = [];
863
+ for (const path of walked) {
864
+ const real = join(realRoot, relative(values.root, path));
865
+ const owner = ownerDirs.find((d) => real.startsWith(d + sep));
866
+ if (owner) excludedCounts.set(owner, excludedCounts.get(owner) + 1);
867
+ else scanned.push(path);
868
+ }
869
+ const excluded = [...excludedCounts]
870
+ .filter(([, n]) => n > 0)
871
+ .map(([dir, n]) => ({ dir: join(values.root, relative(realRoot, dir)), files: n }));
872
+
873
+ for (const path of scanned) {
874
+ const { elements, usages, literals, dimensions } = extract(readFileSync(path, 'utf8'), values.package, path);
875
+ if (elements.length || usages.length || literals.length || dimensions.length) {
876
+ files.push({ path, elements, usages, literals, dimensions });
877
+ }
878
+ }
879
+
880
+ const r = validate({
881
+ built: manifest.components?.built ?? [],
882
+ index,
883
+ tokenValues,
884
+ colourSkipped,
885
+ dimensionValues,
886
+ files,
887
+ walked: scanned.length,
888
+ excluded,
889
+ skip: values.skip,
890
+ });
891
+ for (const line of formatReport(r)) console.log(line);
892
+ process.exit(r.ok ? 0 : 1);
893
+ }
894
+
895
+ if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) main();