@radicool/throughline 0.18.0 → 0.19.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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@radicool/throughline",
3
- "version": "0.18.0",
3
+ "version": "0.19.0",
4
4
  "description": "Build a complete design system end to end — author in Figma, sync tokens to code, generate Storybook. Usable from Claude Code, Cursor, Codex, or any AGENTS.md agent.",
5
5
  "type": "module",
6
6
  "bin": {
@@ -54,7 +54,8 @@ consumer's repo.
54
54
  import { readFileSync } from 'node:fs';
55
55
  import {
56
56
  flattenDtcg,
57
- flattenDtcgTypes,
57
+ flattenPipelineTypes,
58
+ extNamespace,
58
59
  resolveValue,
59
60
  findModeCollisions,
60
61
  TEXT_UNIT_NAMES,
@@ -152,10 +153,25 @@ Both are fixed before Style Dictionary sees the tree.
152
153
  // Marks a node whose AUTHORED $value was a whole-value reference, so the hoist
153
154
  // can decline to override the type DTCG 5.2.2 rule 1 already determined from the
154
155
  // referent. A WeakSet keyed on the node object, rather than a property written
155
- // onto it, holds structural idempotency exactly: structuredClone drops the
156
- // membership along with the rest of the identity, so preprocess(preprocess(x))
157
- // is deepEqual to preprocess(x) with no leak question to manage.
156
+ // onto it, keeps this mechanism from leaking across calls: structuredClone
157
+ // drops the membership along with the rest of the identity, so a second pass
158
+ // starts clean rather than inheriting the first pass's marks. That is exactly
159
+ // what a property written onto the node would get wrong. It is not on its own a
160
+ // guarantee that preprocess is idempotent — see the limit recorded at
161
+ // nativePlatform's preprocessors line (#90).
158
162
  const WAS_REF = new WeakSet();
163
+
164
+ // The path the AUTHOR wrote for a node the hoist has moved. hoistDualNodes
165
+ // recurses depth-first, so by the time an outer frame takes Object.entries it
166
+ // already contains names the inner frame synthesised — and a collision reported
167
+ // from that frame named a path appearing nowhere in the source (#61). Recording
168
+ // the authored path when a node is hoisted lets the outer frame report what the
169
+ // author can actually search for.
170
+ //
171
+ // Keyed on node identity, like WAS_REF, and safe across calls for the same
172
+ // reason: preprocess structuredClones its input, so every call works on fresh
173
+ // objects.
174
+ const AUTHORED_PATH = new WeakMap();
159
175
  const WHOLE_REF = /^\{[^}]+\}$/;
160
176
 
161
177
  function interpolate(value, flat) {
@@ -228,20 +244,27 @@ function hoistDualNodes(node, collisions, prefix = [], groupType = undefined) {
228
244
  for (const [childKey, childVal] of Object.entries(val)) {
229
245
  if (childKey.startsWith('$') || !childVal || typeof childVal !== 'object') continue;
230
246
  const hoisted = key + childKey[0].toUpperCase() + childKey.slice(1);
231
- const from = [...prefix, key, childKey].join('.');
247
+ const from = AUTHORED_PATH.get(childVal) ?? [...prefix, key, childKey].join('.');
232
248
  if (Object.hasOwn(node, hoisted)) {
233
249
  const existingNode = node[hoisted];
234
- const isGroup = existingNode !== null && typeof existingNode === 'object' && !('$value' in existingNode);
250
+ // An array has no $value either, and was therefore labelled "(a group)"
251
+ // (#61). It is neither — an array at this position is malformed DTCG.
252
+ // Say so, rather than naming a shape the author will go looking for.
253
+ const isArray = Array.isArray(existingNode);
254
+ const isGroup =
255
+ existingNode !== null && typeof existingNode === 'object' && !isArray && !('$value' in existingNode);
235
256
  collisions.push({
236
257
  from,
237
258
  onto: [...prefix, hoisted].join('.'),
238
259
  isGroup,
260
+ isArray,
239
261
  claimant: claimedBy.get(hoisted),
240
- existing: isGroup
241
- ? undefined
242
- : existingNode && typeof existingNode === 'object'
243
- ? existingNode.$value
244
- : existingNode,
262
+ existing:
263
+ isGroup || isArray
264
+ ? undefined
265
+ : existingNode && typeof existingNode === 'object'
266
+ ? existingNode.$value
267
+ : existingNode,
245
268
  });
246
269
  continue;
247
270
  }
@@ -264,8 +287,17 @@ function hoistDualNodes(node, collisions, prefix = [], groupType = undefined) {
264
287
  // type suits the child badly, because the hoist is not entitled to
265
288
  // improve on what the source says; but the carry firing at all is the
266
289
  // hoist saying something the source did not.
290
+ //
291
+ // A GROUP child is excluded (#67). The carry's premise is that the dual
292
+ // node was the child's closest $type-bearing ancestor as authored, and
293
+ // the hoist took that away. For a group child the premise never held:
294
+ // 5.2.2 inherits from the closest parent GROUP, the dual node is not
295
+ // one, so it was never that group's inheritance source and the hoist
296
+ // removed nothing. Carrying there does not repair, it invents — and it
297
+ // invents for every token beneath the group, not just one node.
267
298
  if (
268
299
  !('$type' in childVal) &&
300
+ '$value' in childVal &&
269
301
  '$type' in val &&
270
302
  !WAS_REF.has(childVal) &&
271
303
  inherited === undefined
@@ -273,6 +305,7 @@ function hoistDualNodes(node, collisions, prefix = [], groupType = undefined) {
273
305
  childVal.$type = val.$type;
274
306
  }
275
307
  node[hoisted] = childVal;
308
+ AUTHORED_PATH.set(childVal, from);
276
309
  delete val[childKey];
277
310
  claimedBy.set(hoisted, from);
278
311
  }
@@ -371,6 +404,9 @@ function classifyTextUnits(node, types, prefix = []) {
371
404
  types[path.join('.')] === 'dimension' &&
372
405
  TEXT_ROLE_UNIT.test(String(val.$value).trim())
373
406
  ) {
407
+ // extNamespace throws with the token path if the source authored either
408
+ // level as a primitive — legal DTCG this module does not handle (#62).
409
+ extNamespace(val, path.join('.'));
374
410
  val.$extensions ??= {};
375
411
  val.$extensions[EXT_NS] ??= {};
376
412
  const ns = val.$extensions[EXT_NS];
@@ -379,7 +415,9 @@ function classifyTextUnits(node, types, prefix = []) {
379
415
  // one, so a unitless value is declined by every size transform regardless
380
416
  // of what is stamped here (see isRatio, #52). Declining to overwrite IS
381
417
  // the feature: it costs no configuration parameter, and it is what makes
382
- // the pass idempotent.
418
+ // STAMPING idempotent — a second pass never rewrites a role a first pass
419
+ // set. What it does not settle is whether a second pass finds the same
420
+ // candidates; the hoist can rename a node between the two (#90).
383
421
  if (!('nativeUnit' in ns)) ns.nativeUnit = 'text';
384
422
  }
385
423
  classifyTextUnits(val, types, path);
@@ -402,7 +440,8 @@ function classifyTextUnits(node, types, prefix = []) {
402
440
  // A path may name no node at all. resolveInPlace deliberately leaves an
403
441
  // unresolvable reference in place for Style Dictionary to report, so the graph
404
442
  // can hold an edge to a token that does not exist. Skip it. This is also what
405
- // keeps the second preprocess pass from throwing, and idempotency with it.
443
+ // keeps the second preprocess pass from throwing which is a precondition for
444
+ // idempotency, not the whole of it (#90).
406
445
  function applyTextRoleGraph(node, typographic, types) {
407
446
  for (const path of typographic) {
408
447
  let target = node;
@@ -412,6 +451,7 @@ function applyTextRoleGraph(node, typographic, types) {
412
451
  if (!target || typeof target !== 'object' || !('$value' in target)) continue;
413
452
  if (types[path] !== 'dimension') continue;
414
453
  if (!TEXT_ROLE_UNIT.test(String(target.$value).trim())) continue;
454
+ extNamespace(target, path);
415
455
  target.$extensions ??= {};
416
456
  target.$extensions[EXT_NS] ??= {};
417
457
  const ns = target.$extensions[EXT_NS];
@@ -426,14 +466,16 @@ export function preprocess(dict) {
426
466
  // the graph is made of.
427
467
  const { typographic } = textRoleGraph(dict);
428
468
  const resolved = resolveInPlace(structuredClone(dict), flattenDtcg(dict));
429
- // DTCG 5.2.2 types for the whole tree, walked once and shared by both passes
430
- // below. Neither writes $type or moves a node, so one map is correct for
431
- // both, and sharing it is what makes them agree by construction rather than
432
- // by two copies of the same rule staying in step (#85).
469
+ // Types for the whole tree, walked once and shared by both passes below.
470
+ // Neither writes $type or moves a node, so one map is correct for both, and
471
+ // sharing it is what makes them agree by construction rather than by two
472
+ // copies of the same rule staying in step (#85).
433
473
  //
434
- // Computed on the RESOLVED clone, which is the tree both passes read.
435
- // resolveInPlace rewrites $value strings only, so the types are the source's.
436
- const types = flattenDtcgTypes(resolved);
474
+ // Computed on the RAW dict, not the resolved clone. resolveInPlace only
475
+ // rewrites $value strings, so paths and types are identical between the two —
476
+ // except for the carry, whose rule asks whether a value WAS a whole-value
477
+ // reference, and resolution has already destroyed that (#89).
478
+ const types = flattenPipelineTypes(dict);
437
479
  const out = hoistDualNodes(
438
480
  applyTextRoleGraph(classifyTextUnits(resolved, types), typographic, types),
439
481
  collisions,
@@ -443,6 +485,9 @@ export function preprocess(dict) {
443
485
  .slice(0, 5)
444
486
  .map((c) => {
445
487
  const line = ` ${c.from} -> ${c.onto}`;
488
+ if (c.isArray) {
489
+ return line + ' (an array — neither a token nor a group, and not valid DTCG here)';
490
+ }
446
491
  if (c.isGroup) {
447
492
  return line + (c.claimant ? ` (a group, already claimed by the hoist of ${c.claimant})` : ' (a group)');
448
493
  }
@@ -629,7 +674,14 @@ const DECLINED_STOCK_TRANSFORMS = {
629
674
  // Order is never compared: our lists are hand-ordered for our own reasons and
630
675
  // do not inherit stock order. Removals are never reported: a declined name
631
676
  // disappearing is a non-event.
632
- export function auditStockGroups(transformGroups) {
677
+ // `platforms` and `declined` are parameters with the shipped config as their
678
+ // default, so the contradiction branch below is reachable from a test. The
679
+ // single caller passes neither; the audit's contract is unchanged.
680
+ export function auditStockGroups(
681
+ transformGroups,
682
+ platforms = PLATFORMS,
683
+ declined = DECLINED_STOCK_TRANSFORMS,
684
+ ) {
633
685
  if (typeof transformGroups !== 'object' || transformGroups === null) {
634
686
  return [
635
687
  "throughline: could not read Style Dictionary's stock transform groups " +
@@ -638,7 +690,29 @@ export function auditStockGroups(transformGroups) {
638
690
  ];
639
691
  }
640
692
  const warnings = [];
641
- for (const [platform, preset] of Object.entries(PLATFORMS)) {
693
+ for (const [platform, preset] of Object.entries(platforms)) {
694
+ // A name in BOTH lists is a contradiction the unaccounted filter below
695
+ // cannot see, because either membership alone suppresses the warning (#75).
696
+ // The config would be saying "we run this" and "we deliberately do not" at
697
+ // once, and whichever is wrong is silently the loser: if the decline is
698
+ // right the transform still runs, and if the run is right the decline is a
699
+ // lie the next maintainer will read as settled. Reported here rather than
700
+ // guarded at the definition, so it travels with the rest of the audit.
701
+ //
702
+ // Checked before stockGroup, because this is wrong regardless of whether
703
+ // Style Dictionary still has the group to compare against.
704
+ if (Array.isArray(preset.transforms)) {
705
+ const contradictory = preset.transforms.filter((name) => Object.hasOwn(declined, name));
706
+ if (contradictory.length) {
707
+ warnings.push(
708
+ `throughline: PLATFORMS['${platform}'] both runs and declines ` +
709
+ `${contradictory.join(', ')}. A transform cannot be in transforms and in ` +
710
+ 'DECLINED_STOCK_TRANSFORMS at once — one of the two is wrong, and the ' +
711
+ 'audit cannot tell which. This is a throughline packaging defect — ' +
712
+ 'please report it.',
713
+ );
714
+ }
715
+ }
642
716
  const group = preset.stockGroup;
643
717
  if (!group || !Array.isArray(preset.transforms)) {
644
718
  warnings.push(
@@ -662,7 +736,7 @@ export function auditStockGroups(transformGroups) {
662
736
  const unaccounted = stock.filter(
663
737
  (name) =>
664
738
  !preset.transforms.includes(name) &&
665
- !Object.hasOwn(DECLINED_STOCK_TRANSFORMS, name),
739
+ !Object.hasOwn(declined, name),
666
740
  );
667
741
  if (unaccounted.length) {
668
742
  const n = unaccounted.length;
@@ -764,8 +838,21 @@ export function nativePlatform({ platform, buildPath, className = 'Tokens', pack
764
838
  // Carried here, not left to the caller: authored() reads the ORIGINAL
765
839
  // $value, so without this preprocessor every aliased dimension still holds
766
840
  // an unresolved {spacing.space.4}, no size transform fires, and the build
767
- // emits bare px literals. preprocess is idempotent, so a project that also
768
- // declares it at top level is harmless.
841
+ // emits bare px literals.
842
+ //
843
+ // A project that ALSO declares this preprocessor at top level runs it twice,
844
+ // which is harmless in every shape measured — and is the wiring our own
845
+ // usage snippet shows, so it is the common case rather than a corner.
846
+ //
847
+ // Stated no wider than it holds (#90): preprocess is idempotent except where
848
+ // the hoist invents a name the second pass then classifies. `a.font` with a
849
+ // child `size` camel-joins to `a.fontSize`; the first pass correctly declines
850
+ // `size`, and the second sees a typographic member name the source never
851
+ // authored. It changes no emitted output — Style Dictionary runs
852
+ // typeDtcgDelegate between the two passes and types that child anyway, so
853
+ // both passes reach the same file — and the repair belongs with the hoist,
854
+ // which has no way to record the names it invented. Pinned by test rather
855
+ // than papered over.
769
856
  preprocessors: ['dtcg/resolve-dual-node'],
770
857
  buildPath,
771
858
  options: { outputReferences: false },
@@ -4,6 +4,8 @@
4
4
 
5
5
  const REF = /^\{([^}]+)\}$/;
6
6
 
7
+ const isPlainObject = (v) => v !== null && typeof v === 'object' && !Array.isArray(v);
8
+
7
9
  // The typographic member names DTCG §9.8 fixes at MUST level, the unit gate a
8
10
  // text-role dimension must pass, and this project's $extensions namespace.
9
11
  //
@@ -16,6 +18,42 @@ export const TEXT_UNIT_NAMES = new Set(['fontSize', 'letterSpacing', 'lineHeight
16
18
  export const TEXT_ROLE_UNIT = /^-?(?:\d+(?:\.\d+)?|\.\d+)(?:px|rem|em)$/;
17
19
  export const EXT_NS = 'com.radicool.throughline';
18
20
 
21
+ // Read this project's $extensions namespace off a node, refusing a shape that
22
+ // cannot hold one.
23
+ //
24
+ // A DTCG $extensions namespace key may hold ANY JSON value, so a source that
25
+ // authored ours as a string is CONFORMANT input this module does not handle —
26
+ // not malformed input. That distinction is why it gets a diagnostic rather than
27
+ // a shrug: the module's contract is that it consumes conformant DTCG.
28
+ //
29
+ // Before #62 the three places that read this namespace all hit the `in`
30
+ // operator on a primitive and threw a bare TypeError naming no token, no path
31
+ // and no value — out of step with every other diagnostic here. nativeSources
32
+ // names the colliding path and both files; the hoist-collision throw names both
33
+ // paths and the value it would overwrite; nativePlatform names the unknown
34
+ // platform and the expected set.
35
+ export function extNamespace(node, path) {
36
+ const ext = node.$extensions;
37
+ if (ext === undefined) return undefined;
38
+ if (!isPlainObject(ext)) {
39
+ throw new Error(
40
+ `token "${path}" has a $extensions that is ${JSON.stringify(ext)}, not an object.\n` +
41
+ 'DTCG 5.4 makes $extensions a map of namespace keys. Remove it, or give it the shape ' +
42
+ `{ "${EXT_NS}": { "nativeUnit": "text" } }.`,
43
+ );
44
+ }
45
+ const ns = ext[EXT_NS];
46
+ if (ns === undefined) return undefined;
47
+ if (!isPlainObject(ns)) {
48
+ throw new Error(
49
+ `token "${path}" has $extensions["${EXT_NS}"] set to ${JSON.stringify(ns)}, not an object.\n` +
50
+ `The namespace holds named settings, so a bare value cannot be read. Write ` +
51
+ `{ "nativeUnit": ${JSON.stringify(ns)} } if that is the setting you meant.`,
52
+ );
53
+ }
54
+ return ns;
55
+ }
56
+
19
57
  // Flatten nested DTCG groups into { "dot.path": rawValue }. Skips $-prefixed meta keys.
20
58
  //
21
59
  // A node carrying BOTH a $value and children yields its own value AND is descended
@@ -63,6 +101,87 @@ export function flattenDtcgTypes(obj, prefix = [], out = {}, groupType = undefin
63
101
  return out;
64
102
  }
65
103
 
104
+ // DTCG 5.2.2 PLUS the one repair this pipeline applies on top of it, so that
105
+ // anything reasoning about what the build emits reads the same types the build
106
+ // used. flattenDtcgTypes is the spec; this is the spec as this build resolves it.
107
+ //
108
+ // The repair is hoistDualNodes' $type carry. A dual node is a token, not a group
109
+ // (DTCG 6.1), so it is not its children's inheritance source and 5.2.2 gives an
110
+ // untyped child of one no type at all. The hoist then makes that child a sibling
111
+ // of the dual node, which destroys the last relationship it had — so where NO
112
+ // enclosing group supplies a type, the hoist stamps the dual node's own. That is
113
+ // a repair for what the hoist broke, not a reading of the source, which is why
114
+ // it does not belong in flattenDtcgTypes.
115
+ //
116
+ // It has to be modelled somewhere, though, because two things that reason about
117
+ // types could not see it: classification, which runs before the hoist and so
118
+ // declined a child the pipeline goes on to type (#89), and the unitless-dimension
119
+ // advisory, which reads the raw source (#71). Both were silent on shapes the
120
+ // build handles.
121
+ //
122
+ // The conditions mirror hoistDualNodes exactly — an untyped TOKEN child (#67
123
+ // restricted it to those), a dual node that has a $type, no enclosing group type,
124
+ // and a value that is not a whole-value reference. That last one is why this must
125
+ // run on the RAW tree: resolveInPlace rewrites a reference to its literal, and
126
+ // the WeakSet the hoist consults for it does not survive into a fresh call.
127
+ //
128
+ // Keyed on pre-hoist paths, like flattenDtcgTypes, because every consumer of this
129
+ // map runs before the hoist or reports against source paths.
130
+ export function flattenPipelineTypes(dict) {
131
+ const types = flattenDtcgTypes(dict);
132
+ (function walk(node, prefix, groupType) {
133
+ const inherited = '$value' in node ? groupType : (node.$type ?? groupType);
134
+ for (const [key, val] of Object.entries(node)) {
135
+ if (key.startsWith('$') || !isPlainObject(val)) continue;
136
+ const path = [...prefix, key];
137
+ if ('$value' in val && '$type' in val && inherited === undefined) {
138
+ for (const [childKey, childVal] of Object.entries(val)) {
139
+ if (childKey.startsWith('$') || !isPlainObject(childVal)) continue;
140
+ if ('$value' in childVal && !('$type' in childVal) && !REF.test(String(childVal.$value).trim())) {
141
+ types[[...path, childKey].join('.')] = val.$type;
142
+ }
143
+ }
144
+ }
145
+ walk(val, path, inherited);
146
+ }
147
+ })(dict, [], undefined);
148
+ return types;
149
+ }
150
+
151
+ // Collect nodes carrying BOTH a $value and child tokens or groups.
152
+ //
153
+ // This shape is invalid DTCG. Format Module, Draft Community Group Report of
154
+ // 30 July 2026, §6.1: "The presence of a $value property definitively identifies
155
+ // an object as a token. If an object contains both $value and child
156
+ // tokens/groups, this creates an invalid structure where the object cannot be
157
+ // both a token and a group simultaneously. Tools MUST report this as an error."
158
+ // The prohibition is deliberate rather than an oversight — §6.2 defines $root as
159
+ // the sanctioned way for a group to carry a base value alongside children, which
160
+ // is exactly what a dual node is reaching for.
161
+ //
162
+ // Collecting them is NOT a step towards rejecting them. Figma-derived sources
163
+ // emit dual nodes by the dozen, hoistDualNodes exists precisely to handle them,
164
+ // and refusing them would make this tool useless against the sources it targets.
165
+ // That behaviour is unchanged. What was missing is telling the author their
166
+ // source is non-conforming, which nothing did — so someone hand-authoring
167
+ // text.sm with both a value and a lineHeight child had no way to learn that
168
+ // $root is the blessed spelling.
169
+ //
170
+ // Lives here because flattenDtcg already walks this tree and already descends
171
+ // into dual nodes on purpose, so the knowledge is present and only the reporting
172
+ // was absent.
173
+ export function findDualNodes(obj, prefix = [], out = []) {
174
+ for (const [key, val] of Object.entries(obj)) {
175
+ if (key.startsWith('$') || !isPlainObject(val)) continue;
176
+ const path = [...prefix, key];
177
+ if ('$value' in val && Object.entries(val).some(([k, v]) => !k.startsWith('$') && isPlainObject(v))) {
178
+ out.push(path.join('.'));
179
+ }
180
+ findDualNodes(val, path, out);
181
+ }
182
+ return out;
183
+ }
184
+
66
185
  // Follow {alias} chains to a leaf literal. Throws on missing or circular refs.
67
186
  export function resolveValue(name, flat, seen = new Set()) {
68
187
  if (!(name in flat)) throw new Error(`token "${name}" not found in DTCG source`);
@@ -104,7 +223,6 @@ export function findModeCollisions(sources) {
104
223
  //
105
224
  // Each source is cloned on the way in. Merging the caller's own objects would
106
225
  // mutate the token trees it still holds, and the validator reads them again.
107
- const isPlainObject = (v) => v !== null && typeof v === 'object' && !Array.isArray(v);
108
226
 
109
227
  function mergeInto(target, src) {
110
228
  for (const [key, val] of Object.entries(src)) {
@@ -196,7 +314,7 @@ export function textRoleGraph(dict) {
196
314
  types[dotted] === 'dimension' &&
197
315
  TEXT_ROLE_UNIT.test(String(val.$value).trim()) &&
198
316
  !referrers.has(dotted) &&
199
- !('nativeUnit' in (val.$extensions?.[EXT_NS] ?? {})) &&
317
+ !('nativeUnit' in (extNamespace(val, dotted) ?? {})) &&
200
318
  inferredGroups.has(group)
201
319
  ) {
202
320
  unreferencedSiblings.push({ path: dotted, group });
@@ -64,8 +64,28 @@ export const GRAMMAR = {
64
64
  // `var` are valid identifiers, and `color-mix` has a rescue in sd-native.mjs
65
65
  // that merely did not match this variant. So they must reach the output and
66
66
  // fail loudly under no-foreign-syntax, never be silently dropped by a filter.
67
- // Kept here, beside the grammar, so the build and the gate cannot drift apart.
68
- export const CSS_CONSTRUCT = /^(?:color-mix|calc|var)\s*\(/;
67
+ // Kept here, beside the grammar, so the build and the gate cannot drift apart
68
+ // which until #57 was a promise this file made and did not keep. The gate
69
+ // defined its own independent copy of the same alternation, so adding a fourth
70
+ // name here would have taught the filter to keep a construct the gate had never
71
+ // been taught to name. One list now, two anchorings derived from it.
72
+ //
73
+ // The BUILD anchors: only a construct that LEADS the value is exempt from the
74
+ // output filter, because only then is the whole value the unimplemented rescue.
75
+ // The GATE does not anchor: it names the construct wherever it appears.
76
+ //
77
+ // The asymmetry is deliberate and it is a stated limit, not a closed class.
78
+ // `rgba(var(--brand), 0.5)` is dropped by the filter rather than kept, because
79
+ // the module cannot tell an unimplemented rescue nested inside a rescuable
80
+ // function from one nested inside `linear-gradient(...)`, which has no native
81
+ // form at any depth. Closing that needs a notion of which outer functions are
82
+ // rescuable, which does not exist here. What #57 does instead is make the drop
83
+ // NAMED rather than counted — see the unemitted-token report in
84
+ // validate-token-output.mjs.
85
+ export const CSS_CONSTRUCT_NAMES = ['color-mix', 'calc', 'var'];
86
+ const CSS_CONSTRUCT_ALT = CSS_CONSTRUCT_NAMES.join('|');
87
+ export const CSS_CONSTRUCT = new RegExp(`^(?:${CSS_CONSTRUCT_ALT})\\s*\\(`);
88
+ export const CSS_CONSTRUCT_ANYWHERE = new RegExp(`(?:${CSS_CONSTRUCT_ALT})\\s*\\(`);
69
89
 
70
90
  export function parseLiteral(value, grammar = {}) {
71
91
  const s = String(value);
@@ -12,7 +12,8 @@
12
12
  import { readFileSync } from 'node:fs';
13
13
  import {
14
14
  flattenDtcg,
15
- flattenDtcgTypes,
15
+ flattenPipelineTypes,
16
+ extNamespace,
16
17
  resolveValue,
17
18
  findModeCollisions,
18
19
  TEXT_UNIT_NAMES,
@@ -83,10 +84,25 @@ export function colorMixToHex8(value) {
83
84
  // Marks a node whose AUTHORED $value was a whole-value reference, so the hoist
84
85
  // can decline to override the type DTCG 5.2.2 rule 1 already determined from the
85
86
  // referent. A WeakSet keyed on the node object, rather than a property written
86
- // onto it, holds structural idempotency exactly: structuredClone drops the
87
- // membership along with the rest of the identity, so preprocess(preprocess(x))
88
- // is deepEqual to preprocess(x) with no leak question to manage.
87
+ // onto it, keeps this mechanism from leaking across calls: structuredClone
88
+ // drops the membership along with the rest of the identity, so a second pass
89
+ // starts clean rather than inheriting the first pass's marks. That is exactly
90
+ // what a property written onto the node would get wrong. It is not on its own a
91
+ // guarantee that preprocess is idempotent — see the limit recorded at
92
+ // nativePlatform's preprocessors line (#90).
89
93
  const WAS_REF = new WeakSet();
94
+
95
+ // The path the AUTHOR wrote for a node the hoist has moved. hoistDualNodes
96
+ // recurses depth-first, so by the time an outer frame takes Object.entries it
97
+ // already contains names the inner frame synthesised — and a collision reported
98
+ // from that frame named a path appearing nowhere in the source (#61). Recording
99
+ // the authored path when a node is hoisted lets the outer frame report what the
100
+ // author can actually search for.
101
+ //
102
+ // Keyed on node identity, like WAS_REF, and safe across calls for the same
103
+ // reason: preprocess structuredClones its input, so every call works on fresh
104
+ // objects.
105
+ const AUTHORED_PATH = new WeakMap();
90
106
  const WHOLE_REF = /^\{[^}]+\}$/;
91
107
 
92
108
  function interpolate(value, flat) {
@@ -159,20 +175,27 @@ function hoistDualNodes(node, collisions, prefix = [], groupType = undefined) {
159
175
  for (const [childKey, childVal] of Object.entries(val)) {
160
176
  if (childKey.startsWith('$') || !childVal || typeof childVal !== 'object') continue;
161
177
  const hoisted = key + childKey[0].toUpperCase() + childKey.slice(1);
162
- const from = [...prefix, key, childKey].join('.');
178
+ const from = AUTHORED_PATH.get(childVal) ?? [...prefix, key, childKey].join('.');
163
179
  if (Object.hasOwn(node, hoisted)) {
164
180
  const existingNode = node[hoisted];
165
- const isGroup = existingNode !== null && typeof existingNode === 'object' && !('$value' in existingNode);
181
+ // An array has no $value either, and was therefore labelled "(a group)"
182
+ // (#61). It is neither — an array at this position is malformed DTCG.
183
+ // Say so, rather than naming a shape the author will go looking for.
184
+ const isArray = Array.isArray(existingNode);
185
+ const isGroup =
186
+ existingNode !== null && typeof existingNode === 'object' && !isArray && !('$value' in existingNode);
166
187
  collisions.push({
167
188
  from,
168
189
  onto: [...prefix, hoisted].join('.'),
169
190
  isGroup,
191
+ isArray,
170
192
  claimant: claimedBy.get(hoisted),
171
- existing: isGroup
172
- ? undefined
173
- : existingNode && typeof existingNode === 'object'
174
- ? existingNode.$value
175
- : existingNode,
193
+ existing:
194
+ isGroup || isArray
195
+ ? undefined
196
+ : existingNode && typeof existingNode === 'object'
197
+ ? existingNode.$value
198
+ : existingNode,
176
199
  });
177
200
  continue;
178
201
  }
@@ -195,8 +218,17 @@ function hoistDualNodes(node, collisions, prefix = [], groupType = undefined) {
195
218
  // type suits the child badly, because the hoist is not entitled to
196
219
  // improve on what the source says; but the carry firing at all is the
197
220
  // hoist saying something the source did not.
221
+ //
222
+ // A GROUP child is excluded (#67). The carry's premise is that the dual
223
+ // node was the child's closest $type-bearing ancestor as authored, and
224
+ // the hoist took that away. For a group child the premise never held:
225
+ // 5.2.2 inherits from the closest parent GROUP, the dual node is not
226
+ // one, so it was never that group's inheritance source and the hoist
227
+ // removed nothing. Carrying there does not repair, it invents — and it
228
+ // invents for every token beneath the group, not just one node.
198
229
  if (
199
230
  !('$type' in childVal) &&
231
+ '$value' in childVal &&
200
232
  '$type' in val &&
201
233
  !WAS_REF.has(childVal) &&
202
234
  inherited === undefined
@@ -204,6 +236,7 @@ function hoistDualNodes(node, collisions, prefix = [], groupType = undefined) {
204
236
  childVal.$type = val.$type;
205
237
  }
206
238
  node[hoisted] = childVal;
239
+ AUTHORED_PATH.set(childVal, from);
207
240
  delete val[childKey];
208
241
  claimedBy.set(hoisted, from);
209
242
  }
@@ -302,6 +335,9 @@ function classifyTextUnits(node, types, prefix = []) {
302
335
  types[path.join('.')] === 'dimension' &&
303
336
  TEXT_ROLE_UNIT.test(String(val.$value).trim())
304
337
  ) {
338
+ // extNamespace throws with the token path if the source authored either
339
+ // level as a primitive — legal DTCG this module does not handle (#62).
340
+ extNamespace(val, path.join('.'));
305
341
  val.$extensions ??= {};
306
342
  val.$extensions[EXT_NS] ??= {};
307
343
  const ns = val.$extensions[EXT_NS];
@@ -310,7 +346,9 @@ function classifyTextUnits(node, types, prefix = []) {
310
346
  // one, so a unitless value is declined by every size transform regardless
311
347
  // of what is stamped here (see isRatio, #52). Declining to overwrite IS
312
348
  // the feature: it costs no configuration parameter, and it is what makes
313
- // the pass idempotent.
349
+ // STAMPING idempotent — a second pass never rewrites a role a first pass
350
+ // set. What it does not settle is whether a second pass finds the same
351
+ // candidates; the hoist can rename a node between the two (#90).
314
352
  if (!('nativeUnit' in ns)) ns.nativeUnit = 'text';
315
353
  }
316
354
  classifyTextUnits(val, types, path);
@@ -333,7 +371,8 @@ function classifyTextUnits(node, types, prefix = []) {
333
371
  // A path may name no node at all. resolveInPlace deliberately leaves an
334
372
  // unresolvable reference in place for Style Dictionary to report, so the graph
335
373
  // can hold an edge to a token that does not exist. Skip it. This is also what
336
- // keeps the second preprocess pass from throwing, and idempotency with it.
374
+ // keeps the second preprocess pass from throwing which is a precondition for
375
+ // idempotency, not the whole of it (#90).
337
376
  function applyTextRoleGraph(node, typographic, types) {
338
377
  for (const path of typographic) {
339
378
  let target = node;
@@ -343,6 +382,7 @@ function applyTextRoleGraph(node, typographic, types) {
343
382
  if (!target || typeof target !== 'object' || !('$value' in target)) continue;
344
383
  if (types[path] !== 'dimension') continue;
345
384
  if (!TEXT_ROLE_UNIT.test(String(target.$value).trim())) continue;
385
+ extNamespace(target, path);
346
386
  target.$extensions ??= {};
347
387
  target.$extensions[EXT_NS] ??= {};
348
388
  const ns = target.$extensions[EXT_NS];
@@ -357,14 +397,16 @@ export function preprocess(dict) {
357
397
  // the graph is made of.
358
398
  const { typographic } = textRoleGraph(dict);
359
399
  const resolved = resolveInPlace(structuredClone(dict), flattenDtcg(dict));
360
- // DTCG 5.2.2 types for the whole tree, walked once and shared by both passes
361
- // below. Neither writes $type or moves a node, so one map is correct for
362
- // both, and sharing it is what makes them agree by construction rather than
363
- // by two copies of the same rule staying in step (#85).
400
+ // Types for the whole tree, walked once and shared by both passes below.
401
+ // Neither writes $type or moves a node, so one map is correct for both, and
402
+ // sharing it is what makes them agree by construction rather than by two
403
+ // copies of the same rule staying in step (#85).
364
404
  //
365
- // Computed on the RESOLVED clone, which is the tree both passes read.
366
- // resolveInPlace rewrites $value strings only, so the types are the source's.
367
- const types = flattenDtcgTypes(resolved);
405
+ // Computed on the RAW dict, not the resolved clone. resolveInPlace only
406
+ // rewrites $value strings, so paths and types are identical between the two —
407
+ // except for the carry, whose rule asks whether a value WAS a whole-value
408
+ // reference, and resolution has already destroyed that (#89).
409
+ const types = flattenPipelineTypes(dict);
368
410
  const out = hoistDualNodes(
369
411
  applyTextRoleGraph(classifyTextUnits(resolved, types), typographic, types),
370
412
  collisions,
@@ -374,6 +416,9 @@ export function preprocess(dict) {
374
416
  .slice(0, 5)
375
417
  .map((c) => {
376
418
  const line = ` ${c.from} -> ${c.onto}`;
419
+ if (c.isArray) {
420
+ return line + ' (an array — neither a token nor a group, and not valid DTCG here)';
421
+ }
377
422
  if (c.isGroup) {
378
423
  return line + (c.claimant ? ` (a group, already claimed by the hoist of ${c.claimant})` : ' (a group)');
379
424
  }
@@ -501,7 +546,14 @@ const DECLINED_STOCK_TRANSFORMS = {
501
546
  // Order is never compared: our lists are hand-ordered for our own reasons and
502
547
  // do not inherit stock order. Removals are never reported: a declined name
503
548
  // disappearing is a non-event.
504
- export function auditStockGroups(transformGroups) {
549
+ // `platforms` and `declined` are parameters with the shipped config as their
550
+ // default, so the contradiction branch below is reachable from a test. The
551
+ // single caller passes neither; the audit's contract is unchanged.
552
+ export function auditStockGroups(
553
+ transformGroups,
554
+ platforms = PLATFORMS,
555
+ declined = DECLINED_STOCK_TRANSFORMS,
556
+ ) {
505
557
  if (typeof transformGroups !== 'object' || transformGroups === null) {
506
558
  return [
507
559
  "throughline: could not read Style Dictionary's stock transform groups " +
@@ -510,7 +562,29 @@ export function auditStockGroups(transformGroups) {
510
562
  ];
511
563
  }
512
564
  const warnings = [];
513
- for (const [platform, preset] of Object.entries(PLATFORMS)) {
565
+ for (const [platform, preset] of Object.entries(platforms)) {
566
+ // A name in BOTH lists is a contradiction the unaccounted filter below
567
+ // cannot see, because either membership alone suppresses the warning (#75).
568
+ // The config would be saying "we run this" and "we deliberately do not" at
569
+ // once, and whichever is wrong is silently the loser: if the decline is
570
+ // right the transform still runs, and if the run is right the decline is a
571
+ // lie the next maintainer will read as settled. Reported here rather than
572
+ // guarded at the definition, so it travels with the rest of the audit.
573
+ //
574
+ // Checked before stockGroup, because this is wrong regardless of whether
575
+ // Style Dictionary still has the group to compare against.
576
+ if (Array.isArray(preset.transforms)) {
577
+ const contradictory = preset.transforms.filter((name) => Object.hasOwn(declined, name));
578
+ if (contradictory.length) {
579
+ warnings.push(
580
+ `throughline: PLATFORMS['${platform}'] both runs and declines ` +
581
+ `${contradictory.join(', ')}. A transform cannot be in transforms and in ` +
582
+ 'DECLINED_STOCK_TRANSFORMS at once — one of the two is wrong, and the ' +
583
+ 'audit cannot tell which. This is a throughline packaging defect — ' +
584
+ 'please report it.',
585
+ );
586
+ }
587
+ }
514
588
  const group = preset.stockGroup;
515
589
  if (!group || !Array.isArray(preset.transforms)) {
516
590
  warnings.push(
@@ -534,7 +608,7 @@ export function auditStockGroups(transformGroups) {
534
608
  const unaccounted = stock.filter(
535
609
  (name) =>
536
610
  !preset.transforms.includes(name) &&
537
- !Object.hasOwn(DECLINED_STOCK_TRANSFORMS, name),
611
+ !Object.hasOwn(declined, name),
538
612
  );
539
613
  if (unaccounted.length) {
540
614
  const n = unaccounted.length;
@@ -636,8 +710,21 @@ export function nativePlatform({ platform, buildPath, className = 'Tokens', pack
636
710
  // Carried here, not left to the caller: authored() reads the ORIGINAL
637
711
  // $value, so without this preprocessor every aliased dimension still holds
638
712
  // an unresolved {spacing.space.4}, no size transform fires, and the build
639
- // emits bare px literals. preprocess is idempotent, so a project that also
640
- // declares it at top level is harmless.
713
+ // emits bare px literals.
714
+ //
715
+ // A project that ALSO declares this preprocessor at top level runs it twice,
716
+ // which is harmless in every shape measured — and is the wiring our own
717
+ // usage snippet shows, so it is the common case rather than a corner.
718
+ //
719
+ // Stated no wider than it holds (#90): preprocess is idempotent except where
720
+ // the hoist invents a name the second pass then classifies. `a.font` with a
721
+ // child `size` camel-joins to `a.fontSize`; the first pass correctly declines
722
+ // `size`, and the second sees a typographic member name the source never
723
+ // authored. It changes no emitted output — Style Dictionary runs
724
+ // typeDtcgDelegate between the two passes and types that child anyway, so
725
+ // both passes reach the same file — and the repair belongs with the hoist,
726
+ // which has no way to record the names it invented. Pinned by test rather
727
+ // than papered over.
641
728
  preprocessors: ['dtcg/resolve-dual-node'],
642
729
  buildPath,
643
730
  options: { outputReferences: false },
@@ -10,13 +10,15 @@ import { pathToFileURL } from 'node:url';
10
10
  import {
11
11
  flattenDtcg,
12
12
  flattenDtcgTypes,
13
+ flattenPipelineTypes,
14
+ findDualNodes,
13
15
  resolveValue,
14
16
  findModeCollisions,
15
17
  textRoleGraph,
16
18
  mergeDtcg,
17
19
  EXT_NS,
18
20
  } from './lib/dtcg.mjs';
19
- import { parseLiteral, isValidLiteral, GRAMMAR } from './lib/native-literal.mjs';
21
+ import { parseLiteral, isValidLiteral, GRAMMAR, CSS_CONSTRUCT_ANYWHERE } from './lib/native-literal.mjs';
20
22
 
21
23
  // Re-exported so consumers (and the test file) keep one import surface.
22
24
  export { flattenDtcg, flattenDtcgTypes, resolveValue, findModeCollisions };
@@ -68,6 +70,34 @@ export function normalizeKey(s) {
68
70
  return String(s).toLowerCase().replace(/[^a-z0-9]/g, '');
69
71
  }
70
72
 
73
+ // Two source paths that normalize to one key, e.g. color.bg.canvas and
74
+ // colorBg.canvas -> colorbgcanvas. Same map-and-compare shape as
75
+ // findModeCollisions, keyed on the normalized form instead of the raw path.
76
+ //
77
+ // This is not only a matching problem, which is why it gates rather than
78
+ // advises. Measured through Style Dictionary on that exact pair: the build
79
+ // emits `val colorBgCanvas` TWICE and kotlinc rejects the file with
80
+ // "conflicting declarations". The source is ambiguous for native output, and
81
+ // the emitted file does not compile.
82
+ //
83
+ // Before this, the second path silently overwrote the first in byKey, with a
84
+ // consequence in each direction: the loser was never checked at all, and every
85
+ // emitted symbol sharing the key was compared against whichever path happened
86
+ // to sort last — which reported a unit-fidelity failure naming a token that was
87
+ // correct. A wrong diagnosis is worse than none, because it sends the author to
88
+ // the wrong file.
89
+ export function findNormalizationCollisions(paths) {
90
+ const byKey = new Map();
91
+ for (const path of paths) {
92
+ const key = normalizeKey(path);
93
+ if (!byKey.has(key)) byKey.set(key, []);
94
+ byKey.get(key).push(path);
95
+ }
96
+ return [...byKey]
97
+ .filter(([, ps]) => ps.length > 1)
98
+ .map(([key, ps]) => ({ key, paths: ps }));
99
+ }
100
+
71
101
  const UNIT = /^(-?(?:\d+(?:\.\d+)?|\.\d+))([a-z%]*)$/;
72
102
 
73
103
  // Expected native magnitude for an authored source value. iOS points and Android
@@ -101,7 +131,21 @@ export function expectedMagnitude(sourceValue) {
101
131
  // "var(" (e.g. a $type: string value describing CSS) would also match. The
102
132
  // isValidLiteral gate below is what tells those apart: a value the grammar
103
133
  // accepts as a literal is not foreign syntax, whatever text it contains.
104
- const FOREIGN = /(?:color-mix|calc|var)\s*\(/;
134
+ //
135
+ // That holds for Swift. It does NOT hold unconditionally for Kotlin (#57):
136
+ // `${...}` inside a Kotlin string is executable code, and the grammar accepts an
137
+ // unescaped `$`, so `"${calc(1)}"` parses as a valid literal and is exempted
138
+ // here. The exemption also covers a small set of values that are well-formed
139
+ // literals AND named-foreign — calc(2), var(1), and on Kotlin calc(2.dp) — which
140
+ // are kept, pass every rule, and do not compile. None is producible CSS, and
141
+ // this was shipped knowingly when the gate was added; it is written down so the
142
+ // next person meets it as a decision rather than a surprise.
143
+ // Imported, not redeclared (#57). This was an independent copy of the same
144
+ // alternation, while native-literal.mjs's comment promised the build and the
145
+ // gate could not drift apart. Adding a fourth construct name there would have
146
+ // taught the output filter to keep something this rule had never been taught to
147
+ // name — recreating exactly the unreachable-rule defect #56 fixed.
148
+ const FOREIGN = CSS_CONSTRUCT_ANYWHERE;
105
149
  const BARE_UNIT = /^-?(?:\d+(?:\.\d+)?|\.\d+)(?:px|rem|em|%)$/;
106
150
 
107
151
  // #52. A unitless value is a ratio, not a measurement: DTCG 8.2.1 requires a
@@ -117,6 +161,18 @@ const DIMENSIONAL = new Set(['dimension', 'fontSize']);
117
161
  // alone would flag both the alias and its referent for the same problem.
118
162
  const WHOLE_REF = /^\{[^}]+\}$/;
119
163
 
164
+ // Follow whole-value references to the token an advisory's fix belongs on.
165
+ // Stops at the first path that is not a whole-value reference, at an
166
+ // unresolvable one, and on a cycle — this reports, so it must never throw.
167
+ function referentOf(path, flat, seen = new Set()) {
168
+ const raw = String(flat[path] ?? '').trim();
169
+ if (!WHOLE_REF.test(raw)) return path;
170
+ const next = raw.slice(1, -1);
171
+ if (seen.has(path) || !(next in flat)) return path;
172
+ seen.add(path);
173
+ return referentOf(next, flat, seen);
174
+ }
175
+
120
176
  // Lines that are obviously not a would-be token declaration: braces-only,
121
177
  // comments, imports/package/annotations, or the container declarations
122
178
  // (enum/object/class) themselves. Anything else that DECL failed to match is
@@ -149,10 +205,33 @@ export function validate({ sources, output, platform, minMatch = 0.5 }) {
149
205
  for (const { dtcg } of sources) Object.assign(flat, flattenDtcg(dtcg));
150
206
 
151
207
  const types = {};
152
- for (const { dtcg } of sources) Object.assign(types, flattenDtcgTypes(dtcg));
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
+ // Collided keys are deliberately LEFT OUT of byKey. A symbol whose key is
222
+ // ambiguous then matches nothing, so it falls through to `continue` before any
223
+ // source comparison and is not counted as matched — which is the truth: it did
224
+ // not match a determinate token. That removes the false unit-fidelity failure
225
+ // without a special case in the loop below. The literal, foreign-syntax and
226
+ // bare-unit rules still run on it, because none of them reads the source.
227
+ const normalizationCollisions = findNormalizationCollisions(Object.keys(flat));
228
+ const collided = new Set(normalizationCollisions.map((c) => c.key));
153
229
 
154
230
  const byKey = new Map();
155
- for (const path of Object.keys(flat)) byKey.set(normalizeKey(path), path);
231
+ for (const path of Object.keys(flat)) {
232
+ const key = normalizeKey(path);
233
+ if (!collided.has(key)) byKey.set(key, path);
234
+ }
156
235
 
157
236
  const decls = extractDeclarations(output, platform);
158
237
  const failures = [];
@@ -197,19 +276,35 @@ export function validate({ sources, output, platform, minMatch = 0.5 }) {
197
276
  // Advisory, not a failure: the emitted value is correct under the ratio
198
277
  // reading this build applies, so it compiles and its magnitude matches.
199
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;
200
289
  if (
201
290
  UNITLESS.test(String(source).trim()) &&
202
291
  DIMENSIONAL.has(types[path]) &&
203
- !WHOLE_REF.test(String(flat[path]).trim())
292
+ !(aliased && DIMENSIONAL.has(types[target]))
204
293
  ) {
205
- advisories.push({ rule: 'unitless-dimension', symbol, token: path, source, emitted: value });
294
+ advisories.push({ rule: 'unitless-dimension', symbol, token: target, source, emitted: value });
206
295
  }
207
296
 
208
297
  const expected = expectedMagnitude(source);
209
298
  if (expected.skip) continue;
210
299
  const actual = magnitudeOf(value);
211
300
  if (actual === null) {
212
- failures.push({ rule: 'unverifiable-dimension', symbol, token: path, source, emitted: value });
301
+ // no-foreign-syntax already explains why the magnitude could not be read,
302
+ // and names the actual cause. "The token was never actually compared" beside
303
+ // it is a red herring pointing at the symptom (#57). Same suppression the
304
+ // three literal rules already apply to each other.
305
+ if (!foreign) {
306
+ failures.push({ rule: 'unverifiable-dimension', symbol, token: path, source, emitted: value });
307
+ }
213
308
  continue;
214
309
  }
215
310
  if (Math.abs(actual - expected.magnitude) > 0.001) {
@@ -227,6 +322,24 @@ export function validate({ sources, output, platform, minMatch = 0.5 }) {
227
322
  // build that actually ran, and a build merges with the later source winning.
228
323
  // A union would call a token referenced when this build did not reach it,
229
324
  // under-reporting the gap in the one direction that matters.
325
+ // #58. A node carrying both a $value and children is invalid DTCG (§6.1, and
326
+ // §6.2 defines $root as the sanctioned spelling), and nothing told the author
327
+ // so. ADVISORY, not a failure, and deliberately: every Figma-derived source
328
+ // has dozens — the real one this is validated against has 13 — so failing on
329
+ // it would make the gate useless on day one for exactly the people this tool
330
+ // targets. The build keeps handling them; the author now learns the shape is
331
+ // non-conforming and what to write instead.
332
+ //
333
+ // One advisory for the whole finding rather than one per node. It is a single
334
+ // structural fact about the source, and thirteen near-identical lines would
335
+ // bury the rest of the report.
336
+ //
337
+ // Read per source file, not from the merged dict: a merge can conceal a dual
338
+ // node whose children come from one file and whose $value comes from another,
339
+ // and the author fixes this file by file.
340
+ const dualNodes = [...new Set(sources.flatMap((s) => findDualNodes(s.dtcg)))];
341
+ if (dualNodes.length) advisories.push({ rule: 'dual-node', paths: dualNodes });
342
+
230
343
  const graph = textRoleGraph(mergeDtcg(sources.map((s) => s.dtcg)));
231
344
  for (const { path, group } of graph.unreferencedSiblings) {
232
345
  advisories.push({ rule: 'unreferenced-text-sibling', token: path, group });
@@ -236,14 +349,26 @@ export function validate({ sources, output, platform, minMatch = 0.5 }) {
236
349
  }
237
350
 
238
351
  const matchRate = decls.length ? matched / decls.length : 0;
239
- const ok = failures.length === 0 && collisions.length === 0 && matched > 0 && matchRate >= minMatch;
352
+ const ok =
353
+ failures.length === 0 &&
354
+ collisions.length === 0 &&
355
+ normalizationCollisions.length === 0 &&
356
+ matched > 0 &&
357
+ matchRate >= minMatch;
240
358
 
241
359
  const unparsedLines = countUnparsedLines(output, DECL[platform]);
360
+ // NAMED, not just counted (#57). A token with no native form is filtered out
361
+ // of native output, and a nested construct like rgba(var(--brand), 0.5) is one
362
+ // of them — the filter's exemption is anchored, deliberately, because the
363
+ // module cannot tell a rescuable outer function from linear-gradient(). Before
364
+ // this the only trace such a token left was a number, which is the same
365
+ // silence this release exists to remove everywhere else.
242
366
  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;
367
+ const unemittedPaths = [];
368
+ for (const [key, path] of byKey) if (!emittedKeys.has(key)) unemittedPaths.push(path);
369
+ const unemittedTokens = unemittedPaths.length;
245
370
 
246
- return { total: decls.length, matched, matchRate, failures, advisories, collisions, minMatch, ok, unparsedLines, unemittedTokens };
371
+ return { total: decls.length, matched, matchRate, failures, advisories, collisions, normalizationCollisions, minMatch, ok, unparsedLines, unemittedTokens, unemittedPaths };
247
372
  }
248
373
 
249
374
  export function formatReport(r) {
@@ -256,6 +381,17 @@ export function formatReport(r) {
256
381
  lines.push(` - ${c.path}: ${c.defs.map((d) => `${d.file}=${JSON.stringify(d.value)}`).join(', ')}`);
257
382
  }
258
383
  }
384
+ if (r.normalizationCollisions?.length) {
385
+ lines.push(
386
+ `\n${r.normalizationCollisions.length} name collision(s) — distinct source paths that reduce to one symbol name:`,
387
+ );
388
+ for (const c of r.normalizationCollisions) {
389
+ lines.push(` - ${c.key}: ${c.paths.join(' vs ')}`);
390
+ }
391
+ 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.`,
393
+ );
394
+ }
259
395
  if (r.failures.length) {
260
396
  lines.push(`\n${r.failures.length} rule failure(s):`);
261
397
  for (const f of r.failures) {
@@ -275,10 +411,20 @@ export function formatReport(r) {
275
411
  );
276
412
  }
277
413
  }
414
+ // The naming-convention diagnosis is wrong when collisions are what removed
415
+ // the tokens, and a confident wrong cause sends the author to the wrong file.
416
+ // Name the collisions instead, and only then fall back to the convention.
417
+ const collisionNote = r.normalizationCollisions?.length
418
+ ? ` The ${r.normalizationCollisions.length} name collision(s) above were excluded from matching, which may be the whole of it — resolve those first.`
419
+ : '';
278
420
  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 ...").`);
421
+ lines.push(
422
+ r.normalizationCollisions?.length
423
+ ? `\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 ...").`,
425
+ );
280
426
  } 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.`);
427
+ lines.push(`\nMatch rate ${pct}% is below the ${(r.minMatch * 100).toFixed(0)}% floor — most output went unchecked.${collisionNote}`);
282
428
  }
283
429
  if (r.unparsedLines) {
284
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.`);
@@ -292,6 +438,14 @@ export function formatReport(r) {
292
438
  );
293
439
  continue;
294
440
  }
441
+ if (a.rule === 'dual-node') {
442
+ const shown = a.paths.slice(0, 5).join(', ');
443
+ const more = a.paths.length > 5 ? `, ...and ${a.paths.length - 5} more` : '';
444
+ 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.`,
446
+ );
447
+ continue;
448
+ }
295
449
  if (a.rule === 'ambiguous-text-role') {
296
450
  lines.push(
297
451
  ` - [${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.`,
@@ -304,7 +458,13 @@ export function formatReport(r) {
304
458
  }
305
459
  }
306
460
  if (r.unemittedTokens) {
307
- lines.push(`\n${r.unemittedTokens} source token(s) had no matching emitted symbol.`);
461
+ const paths = r.unemittedPaths ?? [];
462
+ const shown = paths.slice(0, 10).join(', ');
463
+ const more = paths.length > 10 ? `, ...and ${paths.length - 10} more` : '';
464
+ 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.',
467
+ );
308
468
  }
309
469
  return lines;
310
470
  }