@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
@@ -1,9 +1,12 @@
1
- // Native token output validator: assert generated Swift/Kotlin matches its DTCG source.
2
- // Catches output that compiles but is wrong. Zero dependencies.
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
- export function validate({ sources, output, platform, minMatch = 0.5 }) {
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
- // Advisory, not a failure: the emitted value is correct under the ratio
277
- // reading this build applies, so it compiles and its magnitude matches.
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
- lines.push(`tokens:validate-output — ${r.matched}/${r.total} emitted symbols matched a source token (${pct}%)`);
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
- `\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.`,
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
- f.rule === 'invalid-literal'
400
- ? ` - [${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))})`
401
- : f.rule === 'unit-fidelity'
402
- ? ` - [${f.rule}] ${f.symbol}: source ${f.source} expects ${f.expected}, emitted ${f.emitted} (${f.actual})`
403
- : f.rule === 'unverifiable-dimension'
404
- ? ` - [${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`
405
- : ` - [${f.rule}] ${f.symbol}: ${f.emitted}`,
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
- : `\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 ...").`,
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(`\n${r.unparsedLines} unparsed line(s) — declaration-shaped lines the extractor could not read; they count in neither the numerator nor the denominator above.`);
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
- ` - [${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.`,
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
- ` - [${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.`,
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
- `\n${r.unemittedTokens} source token(s) had no matching emitted symbol${paths.length ? `: ${shown}${more}` : ''}.` +
466
- ' 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.',
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 <Tokens.swift|Tokens.kt> --platform <ios-swift|android-kotlin> [--min-match <ratio>]');
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
- let output;
505
- try {
506
- output = readFileSync(values.output, 'utf8');
507
- } catch (e) {
508
- console.error(`error reading output file ${values.output}: ${e.message}`);
509
- process.exit(2);
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);