@radicool/throughline 0.18.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.
- package/README.md +2 -2
- package/adapters/codex/prompts/component-builder.md +24 -1
- package/adapters/codex/prompts/document-component.md +10 -1
- package/adapters/codex/prompts/icon-system-builder.md +4 -0
- package/adapters/codex/prompts/storybook-chromatic-builder.md +114 -7
- package/adapters/codex/prompts/token-builder.md +110 -3
- package/adapters/codex/prompts/token-crosswalk-builder.md +2 -0
- package/adapters/codex/prompts/token-sheet-builder.md +4 -0
- package/adapters/codex/prompts/token-sync-layer.md +140 -20
- package/adapters/cursor/.cursor/commands/document-component.md +10 -1
- package/adapters/cursor/.cursor/rules/component-builder.mdc +24 -1
- package/adapters/cursor/.cursor/rules/icon-system-builder.mdc +4 -0
- package/adapters/cursor/.cursor/rules/storybook-chromatic-builder.mdc +114 -7
- package/adapters/cursor/.cursor/rules/token-builder.mdc +110 -3
- package/adapters/cursor/.cursor/rules/token-crosswalk-builder.mdc +2 -0
- package/adapters/cursor/.cursor/rules/token-sheet-builder.mdc +4 -0
- package/adapters/cursor/.cursor/rules/token-sync-layer.mdc +140 -20
- package/adapters/generic/commands/document-component.md +10 -1
- package/adapters/generic/skills/component-builder/SKILL.md +24 -1
- package/adapters/generic/skills/icon-system-builder/SKILL.md +4 -0
- package/adapters/generic/skills/storybook-chromatic-builder/SKILL.md +114 -7
- package/adapters/generic/skills/token-builder/SKILL.md +110 -3
- package/adapters/generic/skills/token-crosswalk-builder/SKILL.md +2 -0
- package/adapters/generic/skills/token-sheet-builder/SKILL.md +4 -0
- package/adapters/generic/skills/token-sync-layer/SKILL.md +140 -20
- package/package.json +1 -1
- package/references/component-doc-schema.md +11 -1
- package/references/figma-component-standards.md +14 -2
- package/references/figma-scripting.md +12 -2
- package/references/manifest-schema.md +42 -2
- package/references/native-adapter-config.md +112 -25
- package/references/proof-bundle.md +305 -0
- package/references/sync-adapters.md +38 -53
- package/scripts/README.md +140 -6
- package/scripts/build-doc-card-builder.mjs +1 -1
- package/scripts/grep-color-usage.mjs +10 -30
- package/scripts/guard-token-removal.mjs +10 -26
- package/scripts/lib/component-states.mjs +56 -0
- package/scripts/lib/contrast.mjs +117 -0
- package/scripts/lib/dtcg.mjs +123 -4
- package/scripts/lib/native-literal.mjs +22 -2
- package/scripts/lib/proof.mjs +186 -0
- package/scripts/lib/sd-native.mjs +112 -25
- package/scripts/lib/source-scan.mjs +45 -0
- package/scripts/validate-adherence.mjs +895 -0
- package/scripts/validate-token-output.mjs +611 -46
- 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 `⁠`, `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();
|