@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.
Files changed (47) 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/native-adapter-config.md +112 -25
  32. package/references/proof-bundle.md +305 -0
  33. package/references/sync-adapters.md +38 -53
  34. package/scripts/README.md +140 -6
  35. package/scripts/build-doc-card-builder.mjs +1 -1
  36. package/scripts/grep-color-usage.mjs +10 -30
  37. package/scripts/guard-token-removal.mjs +10 -26
  38. package/scripts/lib/component-states.mjs +56 -0
  39. package/scripts/lib/contrast.mjs +117 -0
  40. package/scripts/lib/dtcg.mjs +123 -4
  41. package/scripts/lib/native-literal.mjs +22 -2
  42. package/scripts/lib/proof.mjs +186 -0
  43. package/scripts/lib/sd-native.mjs +112 -25
  44. package/scripts/lib/source-scan.mjs +45 -0
  45. package/scripts/validate-adherence.mjs +895 -0
  46. package/scripts/validate-token-output.mjs +611 -46
  47. package/scripts/verify-check.mjs +710 -0
@@ -1,22 +1,27 @@
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';
10
13
  import {
11
14
  flattenDtcg,
12
15
  flattenDtcgTypes,
16
+ flattenPipelineTypes,
17
+ findDualNodes,
13
18
  resolveValue,
14
19
  findModeCollisions,
15
20
  textRoleGraph,
16
21
  mergeDtcg,
17
22
  EXT_NS,
18
23
  } from './lib/dtcg.mjs';
19
- import { parseLiteral, isValidLiteral, GRAMMAR } from './lib/native-literal.mjs';
24
+ import { parseLiteral, isValidLiteral, GRAMMAR, CSS_CONSTRUCT_ANYWHERE } from './lib/native-literal.mjs';
20
25
 
21
26
  // Re-exported so consumers (and the test file) keep one import surface.
22
27
  export { flattenDtcg, flattenDtcgTypes, resolveValue, findModeCollisions };
@@ -29,12 +34,123 @@ const DECL = {
29
34
  'android-kotlin': /^\s*val\s+([A-Za-z_]\w*)\s*=\s*(.+?)\s*$/,
30
35
  };
31
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
+
32
46
  // Style Dictionary's ios-swift/enum.swift format emits an inline trailing
33
47
  // comment for any token carrying a $description ("... /** Small body text */").
34
48
  // Strip a TRAILING comment only — a value that legitimately contains "//"
35
49
  // inside a string literal must survive untouched.
36
50
  const TRAILING_COMMENT = /\s+(\/\*\*?[\s\S]*\*\/|\/\/.*)$/;
37
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
+
38
154
  export function extractDeclarations(text, platform) {
39
155
  const re = DECL[platform];
40
156
  if (!re) throw new Error(`unknown platform "${platform}"`);
@@ -68,6 +184,34 @@ export function normalizeKey(s) {
68
184
  return String(s).toLowerCase().replace(/[^a-z0-9]/g, '');
69
185
  }
70
186
 
187
+ // Two source paths that normalize to one key, e.g. color.bg.canvas and
188
+ // colorBg.canvas -> colorbgcanvas. Same map-and-compare shape as
189
+ // findModeCollisions, keyed on the normalized form instead of the raw path.
190
+ //
191
+ // This is not only a matching problem, which is why it gates rather than
192
+ // advises. Measured through Style Dictionary on that exact pair: the build
193
+ // emits `val colorBgCanvas` TWICE and kotlinc rejects the file with
194
+ // "conflicting declarations". The source is ambiguous for native output, and
195
+ // the emitted file does not compile.
196
+ //
197
+ // Before this, the second path silently overwrote the first in byKey, with a
198
+ // consequence in each direction: the loser was never checked at all, and every
199
+ // emitted symbol sharing the key was compared against whichever path happened
200
+ // to sort last — which reported a unit-fidelity failure naming a token that was
201
+ // correct. A wrong diagnosis is worse than none, because it sends the author to
202
+ // the wrong file.
203
+ export function findNormalizationCollisions(paths) {
204
+ const byKey = new Map();
205
+ for (const path of paths) {
206
+ const key = normalizeKey(path);
207
+ if (!byKey.has(key)) byKey.set(key, []);
208
+ byKey.get(key).push(path);
209
+ }
210
+ return [...byKey]
211
+ .filter(([, ps]) => ps.length > 1)
212
+ .map(([key, ps]) => ({ key, paths: ps }));
213
+ }
214
+
71
215
  const UNIT = /^(-?(?:\d+(?:\.\d+)?|\.\d+))([a-z%]*)$/;
72
216
 
73
217
  // Expected native magnitude for an authored source value. iOS points and Android
@@ -101,7 +245,21 @@ export function expectedMagnitude(sourceValue) {
101
245
  // "var(" (e.g. a $type: string value describing CSS) would also match. The
102
246
  // isValidLiteral gate below is what tells those apart: a value the grammar
103
247
  // accepts as a literal is not foreign syntax, whatever text it contains.
104
- const FOREIGN = /(?:color-mix|calc|var)\s*\(/;
248
+ //
249
+ // That holds for Swift. It does NOT hold unconditionally for Kotlin (#57):
250
+ // `${...}` inside a Kotlin string is executable code, and the grammar accepts an
251
+ // unescaped `$`, so `"${calc(1)}"` parses as a valid literal and is exempted
252
+ // here. The exemption also covers a small set of values that are well-formed
253
+ // literals AND named-foreign — calc(2), var(1), and on Kotlin calc(2.dp) — which
254
+ // are kept, pass every rule, and do not compile. None is producible CSS, and
255
+ // this was shipped knowingly when the gate was added; it is written down so the
256
+ // next person meets it as a decision rather than a surprise.
257
+ // Imported, not redeclared (#57). This was an independent copy of the same
258
+ // alternation, while native-literal.mjs's comment promised the build and the
259
+ // gate could not drift apart. Adding a fourth construct name there would have
260
+ // taught the output filter to keep something this rule had never been taught to
261
+ // name — recreating exactly the unreachable-rule defect #56 fixed.
262
+ const FOREIGN = CSS_CONSTRUCT_ANYWHERE;
105
263
  const BARE_UNIT = /^-?(?:\d+(?:\.\d+)?|\.\d+)(?:px|rem|em|%)$/;
106
264
 
107
265
  // #52. A unitless value is a ratio, not a measurement: DTCG 8.2.1 requires a
@@ -117,6 +275,18 @@ const DIMENSIONAL = new Set(['dimension', 'fontSize']);
117
275
  // alone would flag both the alias and its referent for the same problem.
118
276
  const WHOLE_REF = /^\{[^}]+\}$/;
119
277
 
278
+ // Follow whole-value references to the token an advisory's fix belongs on.
279
+ // Stops at the first path that is not a whole-value reference, at an
280
+ // unresolvable one, and on a cycle — this reports, so it must never throw.
281
+ function referentOf(path, flat, seen = new Set()) {
282
+ const raw = String(flat[path] ?? '').trim();
283
+ if (!WHOLE_REF.test(raw)) return path;
284
+ const next = raw.slice(1, -1);
285
+ if (seen.has(path) || !(next in flat)) return path;
286
+ seen.add(path);
287
+ return referentOf(next, flat, seen);
288
+ }
289
+
120
290
  // Lines that are obviously not a would-be token declaration: braces-only,
121
291
  // comments, imports/package/annotations, or the container declarations
122
292
  // (enum/object/class) themselves. Anything else that DECL failed to match is
@@ -142,17 +312,279 @@ function countUnparsedLines(text, declRe) {
142
312
  return count;
143
313
  }
144
314
 
145
- 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) {
146
319
  const collisions = findModeCollisions(sources);
147
320
 
148
321
  const flat = {};
149
322
  for (const { dtcg } of sources) Object.assign(flat, flattenDtcg(dtcg));
150
323
 
324
+ // Collided keys are deliberately LEFT OUT of byKey. A symbol whose key is
325
+ // ambiguous then matches nothing, so it falls through to `continue` before any
326
+ // source comparison and is not counted as matched — which is the truth: it did
327
+ // not match a determinate token. That removes the false unit-fidelity failure
328
+ // without a special case in the loop below. The literal, foreign-syntax and
329
+ // bare-unit rules still run on it, because none of them reads the source.
330
+ const normalizationCollisions = findNormalizationCollisions(Object.keys(flat));
331
+ const collided = new Set(normalizationCollisions.map((c) => c.key));
332
+
333
+ const byKey = new Map();
334
+ for (const path of Object.keys(flat)) {
335
+ const key = normalizeKey(path);
336
+ if (!collided.has(key)) byKey.set(key, path);
337
+ }
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);
151
441
  const types = {};
152
442
  for (const { dtcg } of sources) Object.assign(types, flattenDtcgTypes(dtcg));
153
443
 
154
- const byKey = new Map();
155
- for (const path of Object.keys(flat)) byKey.set(normalizeKey(path), path);
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));
156
588
 
157
589
  const decls = extractDeclarations(output, platform);
158
590
  const failures = [];
@@ -194,22 +626,20 @@ export function validate({ sources, output, platform, minMatch = 0.5 }) {
194
626
  }
195
627
  matched += 1;
196
628
 
197
- // Advisory, not a failure: the emitted value is correct under the ratio
198
- // reading this build applies, so it compiles and its magnitude matches.
199
- // What is wrong is the SOURCE's $type, which only the author can settle.
200
- if (
201
- UNITLESS.test(String(source).trim()) &&
202
- DIMENSIONAL.has(types[path]) &&
203
- !WHOLE_REF.test(String(flat[path]).trim())
204
- ) {
205
- advisories.push({ rule: 'unitless-dimension', symbol, token: path, source, emitted: value });
206
- }
629
+ const advisory = unitlessDimensionAdvisory({ path, flat, types, symbol, source, emitted: value });
630
+ if (advisory) advisories.push(advisory);
207
631
 
208
632
  const expected = expectedMagnitude(source);
209
633
  if (expected.skip) continue;
210
634
  const actual = magnitudeOf(value);
211
635
  if (actual === null) {
212
- failures.push({ rule: 'unverifiable-dimension', symbol, token: path, source, emitted: value });
636
+ // no-foreign-syntax already explains why the magnitude could not be read,
637
+ // and names the actual cause. "The token was never actually compared" beside
638
+ // it is a red herring pointing at the symptom (#57). Same suppression the
639
+ // three literal rules already apply to each other.
640
+ if (!foreign) {
641
+ failures.push({ rule: 'unverifiable-dimension', symbol, token: path, source, emitted: value });
642
+ }
213
643
  continue;
214
644
  }
215
645
  if (Math.abs(actual - expected.magnitude) > 0.001) {
@@ -227,6 +657,24 @@ export function validate({ sources, output, platform, minMatch = 0.5 }) {
227
657
  // build that actually ran, and a build merges with the later source winning.
228
658
  // A union would call a token referenced when this build did not reach it,
229
659
  // under-reporting the gap in the one direction that matters.
660
+ // #58. A node carrying both a $value and children is invalid DTCG (§6.1, and
661
+ // §6.2 defines $root as the sanctioned spelling), and nothing told the author
662
+ // so. ADVISORY, not a failure, and deliberately: every Figma-derived source
663
+ // has dozens — the real one this is validated against has 13 — so failing on
664
+ // it would make the gate useless on day one for exactly the people this tool
665
+ // targets. The build keeps handling them; the author now learns the shape is
666
+ // non-conforming and what to write instead.
667
+ //
668
+ // One advisory for the whole finding rather than one per node. It is a single
669
+ // structural fact about the source, and thirteen near-identical lines would
670
+ // bury the rest of the report.
671
+ //
672
+ // Read per source file, not from the merged dict: a merge can conceal a dual
673
+ // node whose children come from one file and whose $value comes from another,
674
+ // and the author fixes this file by file.
675
+ const dualNodes = [...new Set(sources.flatMap((s) => findDualNodes(s.dtcg)))];
676
+ if (dualNodes.length) advisories.push({ rule: 'dual-node', paths: dualNodes });
677
+
230
678
  const graph = textRoleGraph(mergeDtcg(sources.map((s) => s.dtcg)));
231
679
  for (const { path, group } of graph.unreferencedSiblings) {
232
680
  advisories.push({ rule: 'unreferenced-text-sibling', token: path, group });
@@ -236,52 +684,135 @@ export function validate({ sources, output, platform, minMatch = 0.5 }) {
236
684
  }
237
685
 
238
686
  const matchRate = decls.length ? matched / decls.length : 0;
239
- const ok = failures.length === 0 && collisions.length === 0 && matched > 0 && matchRate >= minMatch;
687
+ const ok =
688
+ failures.length === 0 &&
689
+ collisions.length === 0 &&
690
+ normalizationCollisions.length === 0 &&
691
+ matched > 0 &&
692
+ matchRate >= minMatch;
240
693
 
241
694
  const unparsedLines = countUnparsedLines(output, DECL[platform]);
695
+ // NAMED, not just counted (#57). A token with no native form is filtered out
696
+ // of native output, and a nested construct like rgba(var(--brand), 0.5) is one
697
+ // of them — the filter's exemption is anchored, deliberately, because the
698
+ // module cannot tell a rescuable outer function from linear-gradient(). Before
699
+ // this the only trace such a token left was a number, which is the same
700
+ // silence this release exists to remove everywhere else.
242
701
  const emittedKeys = new Set(decls.map((d) => normalizeKey(d.symbol)));
243
- let unemittedTokens = 0;
244
- for (const key of byKey.keys()) if (!emittedKeys.has(key)) unemittedTokens += 1;
702
+ const unemittedPaths = [];
703
+ for (const [key, path] of byKey) if (!emittedKeys.has(key)) unemittedPaths.push(path);
704
+ const unemittedTokens = unemittedPaths.length;
245
705
 
246
- return { total: decls.length, matched, matchRate, failures, advisories, collisions, minMatch, ok, unparsedLines, unemittedTokens };
706
+ return { total: decls.length, matched, matchRate, failures, advisories, collisions, normalizationCollisions, minMatch, ok, unparsedLines, unemittedTokens, unemittedPaths };
247
707
  }
248
708
 
249
709
  export function formatReport(r) {
250
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;
251
716
  const pct = (r.matchRate * 100).toFixed(0);
252
- 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
+ }
253
729
  if (r.collisions.length) {
254
730
  lines.push(`\n${r.collisions.length} mode collision(s) — the source list spans modes:`);
255
731
  for (const c of r.collisions) {
256
732
  lines.push(` - ${c.path}: ${c.defs.map((d) => `${d.file}=${JSON.stringify(d.value)}`).join(', ')}`);
257
733
  }
258
734
  }
735
+ if (r.normalizationCollisions?.length) {
736
+ lines.push(
737
+ `\n${r.normalizationCollisions.length} name collision(s) — distinct source paths that reduce to one symbol name:`,
738
+ );
739
+ for (const c of r.normalizationCollisions) {
740
+ lines.push(` - ${c.key}: ${c.paths.join(' vs ')}`);
741
+ }
742
+ lines.push(
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.`,
746
+ );
747
+ }
259
748
  if (r.failures.length) {
260
749
  lines.push(`\n${r.failures.length} rule failure(s):`);
261
750
  for (const f of r.failures) {
262
751
  lines.push(
263
- f.rule === 'invalid-literal'
264
- ? ` - [${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))})`
265
- : f.rule === 'unit-fidelity'
266
- ? ` - [${f.rule}] ${f.symbol}: source ${f.source} expects ${f.expected}, emitted ${f.emitted} (${f.actual})`
267
- : f.rule === 'unverifiable-dimension'
268
- ? ` - [${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`
269
- : ` - [${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}`,
270
773
  );
271
774
  }
272
- if (r.failures.some((f) => f.rule === 'invalid-literal')) {
775
+ if (!web && r.failures.some((f) => f.rule === 'invalid-literal')) {
273
776
  lines.push(
274
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.`,
275
778
  );
276
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
+ }
277
792
  }
793
+ // The naming-convention diagnosis is wrong when collisions are what removed
794
+ // the tokens, and a confident wrong cause sends the author to the wrong file.
795
+ // Name the collisions instead, and only then fall back to the convention.
796
+ const collisionNote = r.normalizationCollisions?.length
797
+ ? ` The ${r.normalizationCollisions.length} name collision(s) above were excluded from matching, which may be the whole of it — resolve those first.`
798
+ : '';
278
799
  if (r.matched === 0) {
279
- lines.push(`\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 ...").`);
800
+ lines.push(
801
+ r.normalizationCollisions?.length
802
+ ? `\nNo emitted symbol matched any source token, so nothing was actually verified.${collisionNote}`
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 ...").`,
806
+ );
280
807
  } else if (r.matchRate < r.minMatch) {
281
- lines.push(`\nMatch rate ${pct}% is below the ${(r.minMatch * 100).toFixed(0)}% floor — most output went unchecked.`);
808
+ lines.push(`\nMatch rate ${pct}% is below the ${(r.minMatch * 100).toFixed(0)}% floor — most output went unchecked.${collisionNote}`);
282
809
  }
283
810
  if (r.unparsedLines) {
284
- 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
+ );
285
816
  }
286
817
  if (r.advisories?.length) {
287
818
  lines.push(`\n${r.advisories.length} advisory note(s) — reported, not gating:`);
@@ -292,6 +823,16 @@ export function formatReport(r) {
292
823
  );
293
824
  continue;
294
825
  }
826
+ if (a.rule === 'dual-node') {
827
+ const shown = a.paths.slice(0, 5).join(', ');
828
+ const more = a.paths.length > 5 ? `, ...and ${a.paths.length - 5} more` : '';
829
+ lines.push(
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.`,
833
+ );
834
+ continue;
835
+ }
295
836
  if (a.rule === 'ambiguous-text-role') {
296
837
  lines.push(
297
838
  ` - [${a.rule}] ${a.token}: referenced both by typographic member(s) [${a.textLeaves.join(', ')}] and by [${a.otherLeaves.join(', ')}], so no role was inferred rather than a role being guessed. Stamp $extensions["${EXT_NS}"].nativeUnit in source to settle it.`,
@@ -299,12 +840,22 @@ export function formatReport(r) {
299
840
  continue;
300
841
  }
301
842
  lines.push(
302
- ` - [${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.`,
303
846
  );
304
847
  }
305
848
  }
306
849
  if (r.unemittedTokens) {
307
- lines.push(`\n${r.unemittedTokens} source token(s) had no matching emitted symbol.`);
850
+ const paths = r.unemittedPaths ?? [];
851
+ const shown = paths.slice(0, 10).join(', ');
852
+ const more = paths.length > 10 ? `, ...and ${paths.length - 10} more` : '';
853
+ lines.push(
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.',
858
+ );
308
859
  }
309
860
  return lines;
310
861
  }
@@ -315,8 +866,9 @@ function main() {
315
866
  const parsed = parseArgs({
316
867
  options: {
317
868
  source: { type: 'string', multiple: true },
318
- output: { type: 'string' },
869
+ output: { type: 'string', multiple: true },
319
870
  platform: { type: 'string' },
871
+ block: { type: 'string' },
320
872
  'min-match': { type: 'string' },
321
873
  },
322
874
  });
@@ -326,8 +878,18 @@ function main() {
326
878
  process.exit(2);
327
879
  }
328
880
 
329
- if (!values.source?.length || !values.output || !values.platform) {
330
- 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)');
331
893
  process.exit(2);
332
894
  }
333
895
 
@@ -341,13 +903,16 @@ function main() {
341
903
  }
342
904
  }
343
905
 
344
- let output;
345
- try {
346
- output = readFileSync(values.output, 'utf8');
347
- } catch (e) {
348
- console.error(`error reading output file ${values.output}: ${e.message}`);
349
- 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
+ }
350
914
  }
915
+ const output = outputs.join('\n');
351
916
 
352
917
  let minMatch;
353
918
  try {
@@ -362,7 +927,7 @@ function main() {
362
927
 
363
928
  let r;
364
929
  try {
365
- r = validate({ sources, output, platform: values.platform, minMatch });
930
+ r = validate({ sources, output, platform: values.platform, minMatch, block: values.block });
366
931
  } catch (e) {
367
932
  console.error(e.message);
368
933
  process.exit(2);