@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.
- 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/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 +3 -2
- package/scripts/lib/proof.mjs +186 -0
- package/scripts/lib/source-scan.mjs +45 -0
- package/scripts/validate-adherence.mjs +895 -0
- package/scripts/validate-token-output.mjs +468 -63
- package/scripts/verify-check.mjs +710 -0
|
@@ -1,9 +1,12 @@
|
|
|
1
|
-
//
|
|
2
|
-
// Catches output that compiles but is
|
|
1
|
+
// Token output validator: assert generated Swift/Kotlin, and web CSS custom
|
|
2
|
+
// properties, match their DTCG source. Catches output that compiles but is
|
|
3
|
+
// wrong. Zero dependencies.
|
|
3
4
|
//
|
|
4
5
|
// Usage:
|
|
5
6
|
// node validate-token-output.mjs --source a.json --source b.json \
|
|
6
7
|
// --output Tokens.swift --platform ios-swift [--min-match 0.5]
|
|
8
|
+
// node validate-token-output.mjs --source a.json --output tokens.css \
|
|
9
|
+
// --platform shadcn --block .dark
|
|
7
10
|
import { readFileSync } from 'node:fs';
|
|
8
11
|
import { parseArgs } from 'node:util';
|
|
9
12
|
import { pathToFileURL } from 'node:url';
|
|
@@ -31,12 +34,123 @@ const DECL = {
|
|
|
31
34
|
'android-kotlin': /^\s*val\s+([A-Za-z_]\w*)\s*=\s*(.+?)\s*$/,
|
|
32
35
|
};
|
|
33
36
|
|
|
37
|
+
// The web platforms this gate reads, and whether each one has an alias
|
|
38
|
+
// layer — a hand-authored custom property (e.g. shadcn/tailwind's
|
|
39
|
+
// `--background: var(--color-bg)`) that has no source token to match.
|
|
40
|
+
export const WEB_PLATFORMS = {
|
|
41
|
+
shadcn: { aliasLayer: true },
|
|
42
|
+
tailwind: { aliasLayer: true },
|
|
43
|
+
'vanilla-css': { aliasLayer: false },
|
|
44
|
+
};
|
|
45
|
+
|
|
34
46
|
// Style Dictionary's ios-swift/enum.swift format emits an inline trailing
|
|
35
47
|
// comment for any token carrying a $description ("... /** Small body text */").
|
|
36
48
|
// Strip a TRAILING comment only — a value that legitimately contains "//"
|
|
37
49
|
// inside a string literal must survive untouched.
|
|
38
50
|
const TRAILING_COMMENT = /\s+(\/\*\*?[\s\S]*\*\/|\/\/.*)$/;
|
|
39
51
|
|
|
52
|
+
// Adapters name blocks differently ("@media (min-width: 768px) :root" vs
|
|
53
|
+
// ":root") but the same block reached two different ways must compare equal —
|
|
54
|
+
// collapse whitespace and normalize the comma spacing a multi-selector block
|
|
55
|
+
// (":root,\n.dark") is written with.
|
|
56
|
+
export function normalizeBlock(s) {
|
|
57
|
+
return String(s).replace(/\s+/g, ' ').replace(/\s*,\s*/g, ', ').trim();
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
// Web output for tokens:validate-output (docs/specs/2026-09-11-web-token-output-validation.md),
|
|
61
|
+
// Step 2. A scanner, not a CSS parser: the only structure this needs is which
|
|
62
|
+
// declarations sit inside which selector/at-rule nesting, and a hand-rolled
|
|
63
|
+
// state machine over `stack` models that directly without pulling in a real
|
|
64
|
+
// CSS grammar. A `{` found while `seg` already looks like a custom property's
|
|
65
|
+
// name and colon is kept IN the value rather than opened as a new block — an
|
|
66
|
+
// unresolved reference such as `{text.xs.lineHeight}` (left raw by a broken
|
|
67
|
+
// Style Dictionary build) must stay readable as the value it is, not be
|
|
68
|
+
// misread as a nested block.
|
|
69
|
+
export function extractCustomProperties(text) {
|
|
70
|
+
const declarations = [];
|
|
71
|
+
let unparsed = 0;
|
|
72
|
+
const stack = [];
|
|
73
|
+
let seg = '';
|
|
74
|
+
|
|
75
|
+
function flush() {
|
|
76
|
+
const s = seg.trim();
|
|
77
|
+
seg = '';
|
|
78
|
+
if (!s || stack.length === 0) return;
|
|
79
|
+
const m = s.match(/^(--[A-Za-z0-9_-]+)\s*:([\s\S]*)$/);
|
|
80
|
+
if (m) {
|
|
81
|
+
declarations.push({ block: stack.join(' '), name: m[1], value: m[2].trim() });
|
|
82
|
+
} else {
|
|
83
|
+
unparsed += 1;
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
let i = 0;
|
|
88
|
+
while (i < text.length) {
|
|
89
|
+
const c = text[i];
|
|
90
|
+
if (c === '/' && text[i + 1] === '*') {
|
|
91
|
+
const end = text.indexOf('*/', i + 2);
|
|
92
|
+
i = end === -1 ? text.length : end + 2;
|
|
93
|
+
seg += ' ';
|
|
94
|
+
continue;
|
|
95
|
+
}
|
|
96
|
+
if (c === '"' || c === "'") {
|
|
97
|
+
const quote = c;
|
|
98
|
+
seg += c;
|
|
99
|
+
i += 1;
|
|
100
|
+
while (i < text.length) {
|
|
101
|
+
const ch = text[i];
|
|
102
|
+
seg += ch;
|
|
103
|
+
if (ch === '\\') {
|
|
104
|
+
i += 1;
|
|
105
|
+
if (i < text.length) {
|
|
106
|
+
seg += text[i];
|
|
107
|
+
i += 1;
|
|
108
|
+
}
|
|
109
|
+
continue;
|
|
110
|
+
}
|
|
111
|
+
i += 1;
|
|
112
|
+
if (ch === quote || ch === '\n') break;
|
|
113
|
+
}
|
|
114
|
+
continue;
|
|
115
|
+
}
|
|
116
|
+
if (c === '{') {
|
|
117
|
+
if (/^\s*--[A-Za-z0-9_-]+\s*:/.test(seg)) {
|
|
118
|
+
let depth = 1;
|
|
119
|
+
seg += c;
|
|
120
|
+
i += 1;
|
|
121
|
+
while (i < text.length && depth > 0) {
|
|
122
|
+
const ch = text[i];
|
|
123
|
+
seg += ch;
|
|
124
|
+
if (ch === '{') depth += 1;
|
|
125
|
+
else if (ch === '}') depth -= 1;
|
|
126
|
+
i += 1;
|
|
127
|
+
}
|
|
128
|
+
continue;
|
|
129
|
+
}
|
|
130
|
+
stack.push(normalizeBlock(seg));
|
|
131
|
+
seg = '';
|
|
132
|
+
i += 1;
|
|
133
|
+
continue;
|
|
134
|
+
}
|
|
135
|
+
if (c === '}') {
|
|
136
|
+
flush();
|
|
137
|
+
stack.pop();
|
|
138
|
+
i += 1;
|
|
139
|
+
continue;
|
|
140
|
+
}
|
|
141
|
+
if (c === ';') {
|
|
142
|
+
flush();
|
|
143
|
+
i += 1;
|
|
144
|
+
continue;
|
|
145
|
+
}
|
|
146
|
+
seg += c;
|
|
147
|
+
i += 1;
|
|
148
|
+
}
|
|
149
|
+
flush();
|
|
150
|
+
|
|
151
|
+
return { declarations, unparsed };
|
|
152
|
+
}
|
|
153
|
+
|
|
40
154
|
export function extractDeclarations(text, platform) {
|
|
41
155
|
const re = DECL[platform];
|
|
42
156
|
if (!re) throw new Error(`unknown platform "${platform}"`);
|
|
@@ -198,26 +312,15 @@ function countUnparsedLines(text, declRe) {
|
|
|
198
312
|
return count;
|
|
199
313
|
}
|
|
200
314
|
|
|
201
|
-
|
|
315
|
+
// The source side every platform reads the same way: mode collisions, the merged
|
|
316
|
+
// flat map, name collisions, and the key-to-path index a declaration matches
|
|
317
|
+
// through.
|
|
318
|
+
function indexSources(sources) {
|
|
202
319
|
const collisions = findModeCollisions(sources);
|
|
203
320
|
|
|
204
321
|
const flat = {};
|
|
205
322
|
for (const { dtcg } of sources) Object.assign(flat, flattenDtcg(dtcg));
|
|
206
323
|
|
|
207
|
-
const types = {};
|
|
208
|
-
// The PIPELINE's types, not the spec's alone (#71). flattenDtcgTypes reads the
|
|
209
|
-
// raw source, where hoistDualNodes' $type carry has not run — so a unitless,
|
|
210
|
-
// untyped child of a dimension-typed dual node was a dimension to the build
|
|
211
|
-
// and a nothing to this gate, which is the silent case this rule most exists
|
|
212
|
-
// to catch.
|
|
213
|
-
//
|
|
214
|
-
// The issue framed the only fix as running this gate against the PREPROCESSED
|
|
215
|
-
// tree, and rejected it, because the gate would stop checking emitted output
|
|
216
|
-
// against what the author actually wrote. flattenPipelineTypes is a third
|
|
217
|
-
// option that keeps that property: it reads the raw source and MODELS the
|
|
218
|
-
// carry rather than applying it. The gate still reads what the author wrote.
|
|
219
|
-
for (const { dtcg } of sources) Object.assign(types, flattenPipelineTypes(dtcg));
|
|
220
|
-
|
|
221
324
|
// Collided keys are deliberately LEFT OUT of byKey. A symbol whose key is
|
|
222
325
|
// ambiguous then matches nothing, so it falls through to `continue` before any
|
|
223
326
|
// source comparison and is not counted as matched — which is the truth: it did
|
|
@@ -233,6 +336,256 @@ export function validate({ sources, output, platform, minMatch = 0.5 }) {
|
|
|
233
336
|
if (!collided.has(key)) byKey.set(key, path);
|
|
234
337
|
}
|
|
235
338
|
|
|
339
|
+
return { collisions, flat, normalizationCollisions, byKey };
|
|
340
|
+
}
|
|
341
|
+
|
|
342
|
+
// Advisory, not a failure: the emitted value is correct under the ratio
|
|
343
|
+
// reading this build applies, so it compiles and its magnitude matches.
|
|
344
|
+
// What is wrong is the SOURCE's $type, which only the author can settle.
|
|
345
|
+
//
|
|
346
|
+
// An alias is skipped only when its REFERENT is itself dimension-typed, so
|
|
347
|
+
// the referent's own symbol reports it — that is #69's de-duplication, kept.
|
|
348
|
+
// A blanket skip on any whole-value reference (#72) meant an untyped base
|
|
349
|
+
// behind a typed alias was reported nowhere: the base is not dimension-typed
|
|
350
|
+
// so it never fires, and the alias was skipped for being a reference. The
|
|
351
|
+
// advisory is attributed to the referent either way, because that is the
|
|
352
|
+
// token whose $type or unit the author has to change.
|
|
353
|
+
function unitlessDimensionAdvisory({ path, flat, types, symbol, source, emitted }) {
|
|
354
|
+
const aliased = WHOLE_REF.test(String(flat[path]).trim());
|
|
355
|
+
const target = aliased ? referentOf(path, flat) : path;
|
|
356
|
+
if (
|
|
357
|
+
UNITLESS.test(String(source).trim()) &&
|
|
358
|
+
DIMENSIONAL.has(types[path]) &&
|
|
359
|
+
!(aliased && DIMENSIONAL.has(types[target]))
|
|
360
|
+
) {
|
|
361
|
+
return { rule: 'unitless-dimension', symbol, token: target, source, emitted };
|
|
362
|
+
}
|
|
363
|
+
return null;
|
|
364
|
+
}
|
|
365
|
+
|
|
366
|
+
// A CSS magnitude: a bare number, or a number with a px/rem/em/% unit. `null`
|
|
367
|
+
// for anything else (a colour, a var(), an unresolved reference) — those are
|
|
368
|
+
// not dimensions and unit-fidelity has nothing to compare.
|
|
369
|
+
export function cssMagnitude(value) {
|
|
370
|
+
if (typeof value === 'number') return { n: value, unit: '' };
|
|
371
|
+
if (typeof value !== 'string') return null;
|
|
372
|
+
const m = value.trim().match(/^(-?(?:\d+(?:\.\d+)?|\.\d+))(px|rem|em|%)?$/);
|
|
373
|
+
if (!m) return null;
|
|
374
|
+
return { n: Number(m[1]), unit: m[2] ?? '' };
|
|
375
|
+
}
|
|
376
|
+
|
|
377
|
+
// Web's analogue of expectedMagnitude/magnitudeOf, but two-sided: CSS, unlike
|
|
378
|
+
// Swift/Kotlin, can emit px OR rem for the same source value, and em/% have no
|
|
379
|
+
// unit-less native equivalent to fall back to, so the comparison has to know
|
|
380
|
+
// which unit the SOURCE was authored in, not just read a bare number back out.
|
|
381
|
+
export function webUnitFidelity(source, emitted, type) {
|
|
382
|
+
const px = (x) => (x.unit === 'rem' ? x.n * 16 : x.n);
|
|
383
|
+
const close = (a, b) => Math.abs(a - b) <= 0.001;
|
|
384
|
+
if (type === 'number' || type === 'fontWeight') {
|
|
385
|
+
return emitted.unit === '' && close(emitted.n, px(source));
|
|
386
|
+
}
|
|
387
|
+
if (source.unit === 'em' || source.unit === '%') {
|
|
388
|
+
return emitted.unit === source.unit && close(emitted.n, source.n);
|
|
389
|
+
}
|
|
390
|
+
if (source.unit === 'px' || source.unit === 'rem') {
|
|
391
|
+
return (
|
|
392
|
+
(emitted.unit === 'px' || emitted.unit === 'rem' || (emitted.unit === '' && emitted.n === 0)) &&
|
|
393
|
+
close(px(emitted), px(source))
|
|
394
|
+
);
|
|
395
|
+
}
|
|
396
|
+
// A unitless source is a ratio (unitless-dimension advisory catches the
|
|
397
|
+
// authoring problem separately); accept it read back as px, rem, or bare.
|
|
398
|
+
return ['', 'px', 'rem'].includes(emitted.unit) && close(px(emitted), source.n);
|
|
399
|
+
}
|
|
400
|
+
|
|
401
|
+
// Web output for tokens:validate-output (docs/specs/2026-09-11-web-token-output-validation.md),
|
|
402
|
+
// Step 4. `no-foreign-syntax` and `no-bare-units` do not run here: both exist
|
|
403
|
+
// to catch CSS syntax LEAKING into a Swift/Kotlin literal, where it cannot
|
|
404
|
+
// compile — but CSS is the target language on this path, so `calc(...)`,
|
|
405
|
+
// `var(...)` and a bare `16px` are exactly the forms a correct declaration is
|
|
406
|
+
// expected to contain. There is no foreign syntax to leak (#37).
|
|
407
|
+
// A block key is printed for pasting back as --block, so it is quoted the way a
|
|
408
|
+
// POSIX shell reads it. JSON.stringify escaped the quotes in `[data-theme="light"]`
|
|
409
|
+
// and a copied key matched no block (#133).
|
|
410
|
+
function shellQuote(s) {
|
|
411
|
+
return `'${s.replaceAll("'", `'\\''`)}'`;
|
|
412
|
+
}
|
|
413
|
+
|
|
414
|
+
function validateWeb({ sources, output, platform, minMatch, block }) {
|
|
415
|
+
const { declarations, unparsed } = extractCustomProperties(output);
|
|
416
|
+
const counts = new Map();
|
|
417
|
+
for (const d of declarations) counts.set(d.block, (counts.get(d.block) ?? 0) + 1);
|
|
418
|
+
const list = [...counts].map(([k, c]) => `${shellQuote(k)} (${c})`).join(', ');
|
|
419
|
+
|
|
420
|
+
let key;
|
|
421
|
+
if (block === undefined) {
|
|
422
|
+
if (counts.size === 1) {
|
|
423
|
+
key = [...counts.keys()][0];
|
|
424
|
+
} else if (counts.size > 1) {
|
|
425
|
+
throw new Error(`the output declares custom properties in ${counts.size} blocks — pass --block with one of: ${list}`);
|
|
426
|
+
}
|
|
427
|
+
} else {
|
|
428
|
+
key = normalizeBlock(block);
|
|
429
|
+
if (counts.size > 0 && !counts.has(key)) {
|
|
430
|
+
throw new Error(`no block ${shellQuote(key)} in the output — its blocks are: ${list}`);
|
|
431
|
+
}
|
|
432
|
+
}
|
|
433
|
+
|
|
434
|
+
const declared = new Set(declarations.map((d) => d.name));
|
|
435
|
+
const selected = new Map();
|
|
436
|
+
for (const d of declarations) {
|
|
437
|
+
if (d.block === key) selected.set(d.name, d.value);
|
|
438
|
+
}
|
|
439
|
+
|
|
440
|
+
const { collisions, flat, normalizationCollisions, byKey } = indexSources(sources);
|
|
441
|
+
const types = {};
|
|
442
|
+
for (const { dtcg } of sources) Object.assign(types, flattenDtcgTypes(dtcg));
|
|
443
|
+
|
|
444
|
+
function strings(v) {
|
|
445
|
+
if (typeof v === 'string') return [v];
|
|
446
|
+
if (v && typeof v === 'object') return Object.values(v).flatMap(strings);
|
|
447
|
+
return [];
|
|
448
|
+
}
|
|
449
|
+
|
|
450
|
+
const failures = [];
|
|
451
|
+
const advisories = [];
|
|
452
|
+
let matched = 0;
|
|
453
|
+
let aliases = 0;
|
|
454
|
+
|
|
455
|
+
for (const [name, value] of selected) {
|
|
456
|
+
const unquoted = value.replace(/"(?:[^"\\]|\\.)*"|'(?:[^'\\]|\\.)*'/g, '""');
|
|
457
|
+
const emittedVars = [...unquoted.matchAll(/var\(\s*(--[A-Za-z0-9_-]+)/g)].map((m) => m[1]);
|
|
458
|
+
|
|
459
|
+
const path = byKey.get(normalizeKey(name));
|
|
460
|
+
const raw = path === undefined ? '' : strings(flat[path]).join(' ');
|
|
461
|
+
const authored = new Set([...raw.matchAll(/var\(\s*(--[A-Za-z0-9_-]+)/g)].map((m) => m[1]));
|
|
462
|
+
|
|
463
|
+
for (const v of new Set(emittedVars)) {
|
|
464
|
+
if (!declared.has(v) && !authored.has(v)) {
|
|
465
|
+
failures.push({ rule: 'dangling-reference', symbol: name, reference: v });
|
|
466
|
+
}
|
|
467
|
+
}
|
|
468
|
+
|
|
469
|
+
const unresolved = /\{[^{}]*\}/.test(unquoted);
|
|
470
|
+
if (unresolved) failures.push({ rule: 'no-unresolved-reference', symbol: name, emitted: value });
|
|
471
|
+
|
|
472
|
+
const invalid = /\[object Object\]|\bNaN\b|\bundefined\b/.test(unquoted);
|
|
473
|
+
if (invalid) failures.push({ rule: 'invalid-value', symbol: name, emitted: value });
|
|
474
|
+
|
|
475
|
+
if (path === undefined) {
|
|
476
|
+
if (
|
|
477
|
+
WEB_PLATFORMS[platform].aliasLayer &&
|
|
478
|
+
/^var\(\s*--[A-Za-z0-9_-]+\s*\)$/.test(value.trim()) &&
|
|
479
|
+
declared.has(emittedVars[0])
|
|
480
|
+
) {
|
|
481
|
+
aliases += 1;
|
|
482
|
+
}
|
|
483
|
+
continue;
|
|
484
|
+
}
|
|
485
|
+
|
|
486
|
+
let source;
|
|
487
|
+
try {
|
|
488
|
+
source = resolveValue(path, flat);
|
|
489
|
+
} catch {
|
|
490
|
+
continue;
|
|
491
|
+
}
|
|
492
|
+
matched += 1;
|
|
493
|
+
|
|
494
|
+
const advisory = unitlessDimensionAdvisory({ path, flat, types, symbol: name, source, emitted: value });
|
|
495
|
+
if (advisory) advisories.push(advisory);
|
|
496
|
+
|
|
497
|
+
if (emittedVars.length) {
|
|
498
|
+
const wantMatches = [...raw.matchAll(/\{([^{}]+)\}|var\(\s*(--[A-Za-z0-9_-]+)/g)];
|
|
499
|
+
const want = wantMatches.map((m) => normalizeKey(m[1] ?? m[2])).sort();
|
|
500
|
+
const got = emittedVars.map(normalizeKey).sort();
|
|
501
|
+
if (JSON.stringify(want) !== JSON.stringify(got)) {
|
|
502
|
+
failures.push({
|
|
503
|
+
rule: 'reference-fidelity',
|
|
504
|
+
symbol: name,
|
|
505
|
+
token: path,
|
|
506
|
+
source: typeof flat[path] === 'string' ? flat[path] : JSON.stringify(flat[path]),
|
|
507
|
+
emitted: value,
|
|
508
|
+
});
|
|
509
|
+
}
|
|
510
|
+
continue;
|
|
511
|
+
}
|
|
512
|
+
|
|
513
|
+
const s = cssMagnitude(source);
|
|
514
|
+
if (s === null) continue;
|
|
515
|
+
const e = cssMagnitude(value);
|
|
516
|
+
if (e === null) {
|
|
517
|
+
if (!unresolved && !invalid) {
|
|
518
|
+
failures.push({ rule: 'unverifiable-dimension', symbol: name, token: path, source, emitted: value });
|
|
519
|
+
}
|
|
520
|
+
continue;
|
|
521
|
+
}
|
|
522
|
+
if (!webUnitFidelity(s, e, types[path])) {
|
|
523
|
+
failures.push({ rule: 'unit-fidelity', symbol: name, token: path, source, emitted: value });
|
|
524
|
+
}
|
|
525
|
+
}
|
|
526
|
+
|
|
527
|
+
const dualNodes = [...new Set(sources.flatMap((s) => findDualNodes(s.dtcg)))];
|
|
528
|
+
if (dualNodes.length) advisories.push({ rule: 'dual-node', paths: dualNodes });
|
|
529
|
+
|
|
530
|
+
const total = selected.size;
|
|
531
|
+
const denominator = total - aliases;
|
|
532
|
+
const matchRate = denominator ? matched / denominator : 0;
|
|
533
|
+
const ok =
|
|
534
|
+
failures.length === 0 &&
|
|
535
|
+
collisions.length === 0 &&
|
|
536
|
+
normalizationCollisions.length === 0 &&
|
|
537
|
+
matched > 0 &&
|
|
538
|
+
matchRate >= minMatch;
|
|
539
|
+
|
|
540
|
+
const declaredKeys = new Set([...declared].map(normalizeKey));
|
|
541
|
+
const unemittedPaths = [];
|
|
542
|
+
for (const [k, path] of byKey) if (!declaredKeys.has(k)) unemittedPaths.push(path);
|
|
543
|
+
|
|
544
|
+
return {
|
|
545
|
+
platform,
|
|
546
|
+
block: key ?? null,
|
|
547
|
+
total,
|
|
548
|
+
aliases,
|
|
549
|
+
matched,
|
|
550
|
+
matchRate,
|
|
551
|
+
failures,
|
|
552
|
+
advisories,
|
|
553
|
+
collisions,
|
|
554
|
+
normalizationCollisions,
|
|
555
|
+
minMatch,
|
|
556
|
+
ok,
|
|
557
|
+
unparsedLines: unparsed,
|
|
558
|
+
unemittedTokens: unemittedPaths.length,
|
|
559
|
+
unemittedPaths,
|
|
560
|
+
};
|
|
561
|
+
}
|
|
562
|
+
|
|
563
|
+
export function validate({ sources, output, platform, minMatch = 0.5, block }) {
|
|
564
|
+
if (platform === 'mui') {
|
|
565
|
+
throw new Error(
|
|
566
|
+
'--platform mui is not supported: a MUI theme is a JavaScript object, and this gate reads CSS custom properties. See #127.',
|
|
567
|
+
);
|
|
568
|
+
}
|
|
569
|
+
if (platform in WEB_PLATFORMS) {
|
|
570
|
+
return validateWeb({ sources, output, platform, minMatch, block });
|
|
571
|
+
}
|
|
572
|
+
|
|
573
|
+
const { collisions, flat, normalizationCollisions, byKey } = indexSources(sources);
|
|
574
|
+
|
|
575
|
+
const types = {};
|
|
576
|
+
// The PIPELINE's types, not the spec's alone (#71). flattenDtcgTypes reads the
|
|
577
|
+
// raw source, where hoistDualNodes' $type carry has not run — so a unitless,
|
|
578
|
+
// untyped child of a dimension-typed dual node was a dimension to the build
|
|
579
|
+
// and a nothing to this gate, which is the silent case this rule most exists
|
|
580
|
+
// to catch.
|
|
581
|
+
//
|
|
582
|
+
// The issue framed the only fix as running this gate against the PREPROCESSED
|
|
583
|
+
// tree, and rejected it, because the gate would stop checking emitted output
|
|
584
|
+
// against what the author actually wrote. flattenPipelineTypes is a third
|
|
585
|
+
// option that keeps that property: it reads the raw source and MODELS the
|
|
586
|
+
// carry rather than applying it. The gate still reads what the author wrote.
|
|
587
|
+
for (const { dtcg } of sources) Object.assign(types, flattenPipelineTypes(dtcg));
|
|
588
|
+
|
|
236
589
|
const decls = extractDeclarations(output, platform);
|
|
237
590
|
const failures = [];
|
|
238
591
|
const advisories = [];
|
|
@@ -273,26 +626,8 @@ export function validate({ sources, output, platform, minMatch = 0.5 }) {
|
|
|
273
626
|
}
|
|
274
627
|
matched += 1;
|
|
275
628
|
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
// What is wrong is the SOURCE's $type, which only the author can settle.
|
|
279
|
-
//
|
|
280
|
-
// An alias is skipped only when its REFERENT is itself dimension-typed, so
|
|
281
|
-
// the referent's own symbol reports it — that is #69's de-duplication, kept.
|
|
282
|
-
// A blanket skip on any whole-value reference (#72) meant an untyped base
|
|
283
|
-
// behind a typed alias was reported nowhere: the base is not dimension-typed
|
|
284
|
-
// so it never fires, and the alias was skipped for being a reference. The
|
|
285
|
-
// advisory is attributed to the referent either way, because that is the
|
|
286
|
-
// token whose $type or unit the author has to change.
|
|
287
|
-
const aliased = WHOLE_REF.test(String(flat[path]).trim());
|
|
288
|
-
const target = aliased ? referentOf(path, flat) : path;
|
|
289
|
-
if (
|
|
290
|
-
UNITLESS.test(String(source).trim()) &&
|
|
291
|
-
DIMENSIONAL.has(types[path]) &&
|
|
292
|
-
!(aliased && DIMENSIONAL.has(types[target]))
|
|
293
|
-
) {
|
|
294
|
-
advisories.push({ rule: 'unitless-dimension', symbol, token: target, source, emitted: value });
|
|
295
|
-
}
|
|
629
|
+
const advisory = unitlessDimensionAdvisory({ path, flat, types, symbol, source, emitted: value });
|
|
630
|
+
if (advisory) advisories.push(advisory);
|
|
296
631
|
|
|
297
632
|
const expected = expectedMagnitude(source);
|
|
298
633
|
if (expected.skip) continue;
|
|
@@ -373,8 +708,24 @@ export function validate({ sources, output, platform, minMatch = 0.5 }) {
|
|
|
373
708
|
|
|
374
709
|
export function formatReport(r) {
|
|
375
710
|
const lines = [];
|
|
711
|
+
// Step 5 (docs/specs/2026-09-11-web-token-output-validation.md). Native
|
|
712
|
+
// results carry no `platform` field at all, so `web` is false for every
|
|
713
|
+
// pre-existing caller and every line below stays on its original,
|
|
714
|
+
// byte-identical branch.
|
|
715
|
+
const web = r.platform in WEB_PLATFORMS;
|
|
376
716
|
const pct = (r.matchRate * 100).toFixed(0);
|
|
377
|
-
|
|
717
|
+
if (web) {
|
|
718
|
+
lines.push(
|
|
719
|
+
`tokens:validate-output — ${r.matched}/${r.total - r.aliases} declarations in ${r.block ?? 'the output'} matched a source token (${pct}%)`,
|
|
720
|
+
);
|
|
721
|
+
if (r.aliases) {
|
|
722
|
+
lines.push(
|
|
723
|
+
`\n${r.aliases} alias declaration(s) with no source token — each is a var() to a variable the output declares, so they are left out of the match rate.`,
|
|
724
|
+
);
|
|
725
|
+
}
|
|
726
|
+
} else {
|
|
727
|
+
lines.push(`tokens:validate-output — ${r.matched}/${r.total} emitted symbols matched a source token (${pct}%)`);
|
|
728
|
+
}
|
|
378
729
|
if (r.collisions.length) {
|
|
379
730
|
lines.push(`\n${r.collisions.length} mode collision(s) — the source list spans modes:`);
|
|
380
731
|
for (const c of r.collisions) {
|
|
@@ -389,27 +740,55 @@ export function formatReport(r) {
|
|
|
389
740
|
lines.push(` - ${c.key}: ${c.paths.join(' vs ')}`);
|
|
390
741
|
}
|
|
391
742
|
lines.push(
|
|
392
|
-
|
|
743
|
+
web
|
|
744
|
+
? `\nThese reduce to the same variable name, so the output declares it twice and the later one silently wins. They are also excluded from matching above, because there is no way to tell which source token a declaration came from. Rename one side in source.`
|
|
745
|
+
: `\nThese emit the same symbol name, so the generated file declares it more than once and will not compile. They are also excluded from matching above, because there is no way to tell which source token an emitted symbol came from. Rename one side in source.`,
|
|
393
746
|
);
|
|
394
747
|
}
|
|
395
748
|
if (r.failures.length) {
|
|
396
749
|
lines.push(`\n${r.failures.length} rule failure(s):`);
|
|
397
750
|
for (const f of r.failures) {
|
|
398
751
|
lines.push(
|
|
399
|
-
|
|
400
|
-
?
|
|
401
|
-
|
|
402
|
-
|
|
403
|
-
|
|
404
|
-
|
|
405
|
-
|
|
752
|
+
web
|
|
753
|
+
? f.rule === 'unit-fidelity'
|
|
754
|
+
? ` - [unit-fidelity] ${f.symbol}: source ${f.source} for ${f.token}, emitted ${f.emitted}`
|
|
755
|
+
: f.rule === 'reference-fidelity'
|
|
756
|
+
? ` - [reference-fidelity] ${f.symbol}: source ${f.source} for ${f.token}, emitted ${f.emitted} — its var() references don't name the tokens the source references`
|
|
757
|
+
: f.rule === 'dangling-reference'
|
|
758
|
+
? ` - [dangling-reference] ${f.symbol}: references ${f.reference}, which no --output declares`
|
|
759
|
+
: f.rule === 'no-unresolved-reference'
|
|
760
|
+
? ` - [no-unresolved-reference] ${f.symbol}: ${f.emitted} — a {reference} reached the output unresolved`
|
|
761
|
+
: f.rule === 'invalid-value'
|
|
762
|
+
? ` - [invalid-value] ${f.symbol}: ${f.emitted} — a JavaScript value was written into the CSS`
|
|
763
|
+
: f.rule === 'unverifiable-dimension'
|
|
764
|
+
? ` - [${f.rule}] ${f.symbol}: source ${f.source} has a dimension magnitude but emitted ${f.emitted} could not be read — the token was never actually compared`
|
|
765
|
+
: ` - [${f.rule}] ${f.symbol}: ${f.emitted}`
|
|
766
|
+
: f.rule === 'invalid-literal'
|
|
767
|
+
? ` - [${f.rule}] ${f.symbol}: emitted \`${f.emitted}\` is not a valid ${f.platform} literal — parsing stopped at offset ${f.offset} (${JSON.stringify(f.rest.slice(0, 30))})`
|
|
768
|
+
: f.rule === 'unit-fidelity'
|
|
769
|
+
? ` - [${f.rule}] ${f.symbol}: source ${f.source} expects ${f.expected}, emitted ${f.emitted} (${f.actual})`
|
|
770
|
+
: f.rule === 'unverifiable-dimension'
|
|
771
|
+
? ` - [${f.rule}] ${f.symbol}: source ${f.source} has a dimension magnitude but emitted ${f.emitted} could not be read — the token was never actually compared`
|
|
772
|
+
: ` - [${f.rule}] ${f.symbol}: ${f.emitted}`,
|
|
406
773
|
);
|
|
407
774
|
}
|
|
408
|
-
if (r.failures.some((f) => f.rule === 'invalid-literal')) {
|
|
775
|
+
if (!web && r.failures.some((f) => f.rule === 'invalid-literal')) {
|
|
409
776
|
lines.push(
|
|
410
777
|
`\nAn invalid-literal value will not compile. A string value must be quoted — add its $type to the quoting transform in lib/sd-native.mjs. A CSS construct such as linear-gradient() has no native form and should be filtered out of native builds instead.`,
|
|
411
778
|
);
|
|
412
779
|
}
|
|
780
|
+
if (web) {
|
|
781
|
+
if (r.failures.some((f) => f.rule === 'no-unresolved-reference' || f.rule === 'dangling-reference')) {
|
|
782
|
+
lines.push(
|
|
783
|
+
`\nA {reference} left in the output, or a var() to a variable nothing declares, usually means the build did not reach a token that is a child of a node carrying its own $value. A build split across files must pass each file it ships as another --output.`,
|
|
784
|
+
);
|
|
785
|
+
}
|
|
786
|
+
if (r.failures.some((f) => f.rule === 'invalid-value')) {
|
|
787
|
+
lines.push(
|
|
788
|
+
`\nAn [object Object] value is a composite token, such as typography or a shadow, with no shorthand transform registered for this platform.`,
|
|
789
|
+
);
|
|
790
|
+
}
|
|
791
|
+
}
|
|
413
792
|
}
|
|
414
793
|
// The naming-convention diagnosis is wrong when collisions are what removed
|
|
415
794
|
// the tokens, and a confident wrong cause sends the author to the wrong file.
|
|
@@ -421,13 +800,19 @@ export function formatReport(r) {
|
|
|
421
800
|
lines.push(
|
|
422
801
|
r.normalizationCollisions?.length
|
|
423
802
|
? `\nNo emitted symbol matched any source token, so nothing was actually verified.${collisionNote}`
|
|
424
|
-
:
|
|
803
|
+
: web
|
|
804
|
+
? `\nNo declaration matched any source token, so nothing was verified. The variable names don't line up with the source token paths — a likely cause is a name transform that doesn't build the name from the whole path, as Style Dictionary's name/kebab does. A JavaScript theme file has no custom properties to read at all.`
|
|
805
|
+
: `\nNo emitted symbol matched any source token — the adapter's naming convention does not line up, so nothing was actually verified. A likely cause is a declaration form the DECL pattern does not match (e.g. a different accessControl such as "internal static let ...").`,
|
|
425
806
|
);
|
|
426
807
|
} else if (r.matchRate < r.minMatch) {
|
|
427
808
|
lines.push(`\nMatch rate ${pct}% is below the ${(r.minMatch * 100).toFixed(0)}% floor — most output went unchecked.${collisionNote}`);
|
|
428
809
|
}
|
|
429
810
|
if (r.unparsedLines) {
|
|
430
|
-
lines.push(
|
|
811
|
+
lines.push(
|
|
812
|
+
web
|
|
813
|
+
? `\n${r.unparsedLines} declaration(s) that are not custom properties — not checked, and not counted above.`
|
|
814
|
+
: `\n${r.unparsedLines} unparsed line(s) — declaration-shaped lines the extractor could not read; they count in neither the numerator nor the denominator above.`,
|
|
815
|
+
);
|
|
431
816
|
}
|
|
432
817
|
if (r.advisories?.length) {
|
|
433
818
|
lines.push(`\n${r.advisories.length} advisory note(s) — reported, not gating:`);
|
|
@@ -442,7 +827,9 @@ export function formatReport(r) {
|
|
|
442
827
|
const shown = a.paths.slice(0, 5).join(', ');
|
|
443
828
|
const more = a.paths.length > 5 ? `, ...and ${a.paths.length - 5} more` : '';
|
|
444
829
|
lines.push(
|
|
445
|
-
|
|
830
|
+
web
|
|
831
|
+
? ` - [${a.rule}] ${a.paths.length} node(s) carry both a $value and child tokens: ${shown}${more}. DTCG §6.1 makes that invalid, and §6.2 defines $root as the way a group carries a base value alongside children. Stock Style Dictionary does not descend into these nodes, so their children can be missing from web output. If a no-unresolved-reference or dangling-reference failure appears above, that is why.`
|
|
832
|
+
: ` - [${a.rule}] ${a.paths.length} node(s) carry both a $value and child tokens: ${shown}${more}. DTCG §6.1 makes that invalid — an object cannot be both a token and a group — and §6.2 defines $root as the way a group carries a base value alongside children. The build handles this shape and will keep handling it; nothing here is broken. Rewrite them as $root only if you want the source to conform.`,
|
|
446
833
|
);
|
|
447
834
|
continue;
|
|
448
835
|
}
|
|
@@ -453,7 +840,9 @@ export function formatReport(r) {
|
|
|
453
840
|
continue;
|
|
454
841
|
}
|
|
455
842
|
lines.push(
|
|
456
|
-
|
|
843
|
+
web
|
|
844
|
+
? ` - [${a.rule}] ${a.symbol}: source ${JSON.stringify(a.source)} for ${a.token} is a dimension with no unit, which DTCG §8.2.1 does not permit. It emitted ${a.emitted}. If it is a ratio, type it "number" (§8.7); if it is a measurement, add the unit you meant.`
|
|
845
|
+
: ` - [${a.rule}] ${a.symbol}: source ${JSON.stringify(a.source)} for ${a.token} is a dimension with no unit, which DTCG §8.2.1 does not permit. It emitted ${a.emitted}, read as a ratio. If it is a ratio, type it "number" (§8.7); if it is a measurement, add the unit you meant.`,
|
|
457
846
|
);
|
|
458
847
|
}
|
|
459
848
|
}
|
|
@@ -462,8 +851,10 @@ export function formatReport(r) {
|
|
|
462
851
|
const shown = paths.slice(0, 10).join(', ');
|
|
463
852
|
const more = paths.length > 10 ? `, ...and ${paths.length - 10} more` : '';
|
|
464
853
|
lines.push(
|
|
465
|
-
|
|
466
|
-
|
|
854
|
+
web
|
|
855
|
+
? `\n${r.unemittedTokens} source token(s) are declared nowhere in the output${paths.length ? `: ${shown}${more}` : ''}. A token this build filtered out, or the child of a node carrying its own $value that the build did not reach, shows up here.`
|
|
856
|
+
: `\n${r.unemittedTokens} source token(s) had no matching emitted symbol${paths.length ? `: ${shown}${more}` : ''}.` +
|
|
857
|
+
' A value with no native form is filtered out of native output rather than emitted broken — a CSS construct nested inside another function is the common case.',
|
|
467
858
|
);
|
|
468
859
|
}
|
|
469
860
|
return lines;
|
|
@@ -475,8 +866,9 @@ function main() {
|
|
|
475
866
|
const parsed = parseArgs({
|
|
476
867
|
options: {
|
|
477
868
|
source: { type: 'string', multiple: true },
|
|
478
|
-
output: { type: 'string' },
|
|
869
|
+
output: { type: 'string', multiple: true },
|
|
479
870
|
platform: { type: 'string' },
|
|
871
|
+
block: { type: 'string' },
|
|
480
872
|
'min-match': { type: 'string' },
|
|
481
873
|
},
|
|
482
874
|
});
|
|
@@ -486,8 +878,18 @@ function main() {
|
|
|
486
878
|
process.exit(2);
|
|
487
879
|
}
|
|
488
880
|
|
|
489
|
-
if (!values.source?.length || !values.output || !values.platform) {
|
|
490
|
-
console.error('usage: validate-token-output.mjs --source <a.json> [--source <b.json>...] --output <
|
|
881
|
+
if (!values.source?.length || !values.output?.length || !values.platform) {
|
|
882
|
+
console.error('usage: validate-token-output.mjs --source <a.json> [--source <b.json>...] --output <file> [--output <file>...] --platform <ios-swift|android-kotlin|shadcn|tailwind|vanilla-css> [--block <selector>] [--min-match <ratio>]');
|
|
883
|
+
process.exit(2);
|
|
884
|
+
}
|
|
885
|
+
|
|
886
|
+
const web = values.platform in WEB_PLATFORMS;
|
|
887
|
+
if (!web && values.output.length > 1) {
|
|
888
|
+
console.error('--output may be given more than once only for a web platform (shadcn, tailwind, vanilla-css)');
|
|
889
|
+
process.exit(2);
|
|
890
|
+
}
|
|
891
|
+
if (!web && values.block !== undefined) {
|
|
892
|
+
console.error('--block applies only to a web platform (shadcn, tailwind, vanilla-css)');
|
|
491
893
|
process.exit(2);
|
|
492
894
|
}
|
|
493
895
|
|
|
@@ -501,13 +903,16 @@ function main() {
|
|
|
501
903
|
}
|
|
502
904
|
}
|
|
503
905
|
|
|
504
|
-
|
|
505
|
-
|
|
506
|
-
|
|
507
|
-
|
|
508
|
-
|
|
509
|
-
|
|
906
|
+
const outputs = [];
|
|
907
|
+
for (const file of values.output) {
|
|
908
|
+
try {
|
|
909
|
+
outputs.push(readFileSync(file, 'utf8'));
|
|
910
|
+
} catch (e) {
|
|
911
|
+
console.error(`error reading output file ${file}: ${e.message}`);
|
|
912
|
+
process.exit(2);
|
|
913
|
+
}
|
|
510
914
|
}
|
|
915
|
+
const output = outputs.join('\n');
|
|
511
916
|
|
|
512
917
|
let minMatch;
|
|
513
918
|
try {
|
|
@@ -522,7 +927,7 @@ function main() {
|
|
|
522
927
|
|
|
523
928
|
let r;
|
|
524
929
|
try {
|
|
525
|
-
r = validate({ sources, output, platform: values.platform, minMatch });
|
|
930
|
+
r = validate({ sources, output, platform: values.platform, minMatch, block: values.block });
|
|
526
931
|
} catch (e) {
|
|
527
932
|
console.error(e.message);
|
|
528
933
|
process.exit(2);
|