@radicool/throughline 0.17.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.17.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,6 +54,8 @@ consumer's repo.
54
54
  import { readFileSync } from 'node:fs';
55
55
  import {
56
56
  flattenDtcg,
57
+ flattenPipelineTypes,
58
+ extNamespace,
57
59
  resolveValue,
58
60
  findModeCollisions,
59
61
  TEXT_UNIT_NAMES,
@@ -151,10 +153,25 @@ Both are fixed before Style Dictionary sees the tree.
151
153
  // Marks a node whose AUTHORED $value was a whole-value reference, so the hoist
152
154
  // can decline to override the type DTCG 5.2.2 rule 1 already determined from the
153
155
  // referent. A WeakSet keyed on the node object, rather than a property written
154
- // onto it, holds structural idempotency exactly: structuredClone drops the
155
- // membership along with the rest of the identity, so preprocess(preprocess(x))
156
- // 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).
157
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();
158
175
  const WHOLE_REF = /^\{[^}]+\}$/;
159
176
 
160
177
  function interpolate(value, flat) {
@@ -227,20 +244,27 @@ function hoistDualNodes(node, collisions, prefix = [], groupType = undefined) {
227
244
  for (const [childKey, childVal] of Object.entries(val)) {
228
245
  if (childKey.startsWith('$') || !childVal || typeof childVal !== 'object') continue;
229
246
  const hoisted = key + childKey[0].toUpperCase() + childKey.slice(1);
230
- const from = [...prefix, key, childKey].join('.');
247
+ const from = AUTHORED_PATH.get(childVal) ?? [...prefix, key, childKey].join('.');
231
248
  if (Object.hasOwn(node, hoisted)) {
232
249
  const existingNode = node[hoisted];
233
- 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);
234
256
  collisions.push({
235
257
  from,
236
258
  onto: [...prefix, hoisted].join('.'),
237
259
  isGroup,
260
+ isArray,
238
261
  claimant: claimedBy.get(hoisted),
239
- existing: isGroup
240
- ? undefined
241
- : existingNode && typeof existingNode === 'object'
242
- ? existingNode.$value
243
- : existingNode,
262
+ existing:
263
+ isGroup || isArray
264
+ ? undefined
265
+ : existingNode && typeof existingNode === 'object'
266
+ ? existingNode.$value
267
+ : existingNode,
244
268
  });
245
269
  continue;
246
270
  }
@@ -263,8 +287,17 @@ function hoistDualNodes(node, collisions, prefix = [], groupType = undefined) {
263
287
  // type suits the child badly, because the hoist is not entitled to
264
288
  // improve on what the source says; but the carry firing at all is the
265
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.
266
298
  if (
267
299
  !('$type' in childVal) &&
300
+ '$value' in childVal &&
268
301
  '$type' in val &&
269
302
  !WAS_REF.has(childVal) &&
270
303
  inherited === undefined
@@ -272,6 +305,7 @@ function hoistDualNodes(node, collisions, prefix = [], groupType = undefined) {
272
305
  childVal.$type = val.$type;
273
306
  }
274
307
  node[hoisted] = childVal;
308
+ AUTHORED_PATH.set(childVal, from);
275
309
  delete val[childKey];
276
310
  claimedBy.set(hoisted, from);
277
311
  }
@@ -342,15 +376,37 @@ export { EXT_NS };
342
376
  // on is gone. Matching a suffix against the camel-joined name instead would
343
377
  // couple the rule to the hoist's naming scheme, and case-insensitively it
344
378
  // false-positives on names like baselineHeight.
345
- function classifyTextUnits(node) {
379
+ //
380
+ // The type comes from `types`, a DTCG 5.2.2 resolution of the whole tree, not
381
+ // from the token's own literal $type. Reading val.$type made this pass blind to
382
+ // a source that declares $type once on the group — legal DTCG, and on such a
383
+ // source the reference-graph inference stamped nothing at all (#85). A token's
384
+ // own $type still wins; the group's applies only where the token states none.
385
+ //
386
+ // WHERE THIS MATTERS, measured rather than assumed. Style Dictionary runs
387
+ // global preprocessors, THEN its own typeDtcgDelegate — which is 5.2.2, pushing
388
+ // each group's $type onto its descendants — then platform preprocessors
389
+ // (StyleDictionary.js:340, :348, :440 in 4.4.0). nativePlatform registers this
390
+ // preprocessor at PLATFORM level, downstream of that delegation, so a build
391
+ // wired only through nativePlatform never saw the defect: on a group-typed
392
+ // re-encoding of a real system it emits the same 208 declarations and 48 sp
393
+ // either way. The defect reaches the build that ALSO declares this preprocessor
394
+ // at top level, which is the wiring the usage snippet above shows — there the
395
+ // first pass runs before any delegation. Resolving the type here makes both
396
+ // wirings agree instead of depending on which one a consumer copied.
397
+ function classifyTextUnits(node, types, prefix = []) {
346
398
  for (const [key, val] of Object.entries(node)) {
347
399
  if (key.startsWith('$') || !val || typeof val !== 'object') continue;
400
+ const path = [...prefix, key];
348
401
  if (
349
402
  TEXT_UNIT_NAMES.has(key) &&
350
403
  '$value' in val &&
351
- val.$type === 'dimension' &&
404
+ types[path.join('.')] === 'dimension' &&
352
405
  TEXT_ROLE_UNIT.test(String(val.$value).trim())
353
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('.'));
354
410
  val.$extensions ??= {};
355
411
  val.$extensions[EXT_NS] ??= {};
356
412
  const ns = val.$extensions[EXT_NS];
@@ -359,10 +415,12 @@ function classifyTextUnits(node) {
359
415
  // one, so a unitless value is declined by every size transform regardless
360
416
  // of what is stamped here (see isRatio, #52). Declining to overwrite IS
361
417
  // the feature: it costs no configuration parameter, and it is what makes
362
- // 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).
363
421
  if (!('nativeUnit' in ns)) ns.nativeUnit = 'text';
364
422
  }
365
- classifyTextUnits(val);
423
+ classifyTextUnits(val, types, path);
366
424
  }
367
425
  return node;
368
426
  }
@@ -373,23 +431,27 @@ function classifyTextUnits(node) {
373
431
  // text.xsLineHeight and the graph's paths are written in pre-hoist names.
374
432
  //
375
433
  // The three gates are classifyTextUnits's, verbatim — a dimension, a value with
376
- // a unit, and no role already recorded. A unitless value is never stamped: no
434
+ // a unit, and no role already recorded. Both passes read the SAME resolved-type
435
+ // map, which is what makes them agree by construction rather than by two copies
436
+ // of DTCG 5.2.2 staying in step. A unitless value is never stamped: no
377
437
  // size transform claims one since #52, and stamping a ratio as text would still
378
438
  // be a claim the source never made.
379
439
  //
380
440
  // A path may name no node at all. resolveInPlace deliberately leaves an
381
441
  // unresolvable reference in place for Style Dictionary to report, so the graph
382
442
  // can hold an edge to a token that does not exist. Skip it. This is also what
383
- // keeps the second preprocess pass from throwing, and idempotency with it.
384
- function applyTextRoleGraph(node, typographic) {
443
+ // keeps the second preprocess pass from throwing which is a precondition for
444
+ // idempotency, not the whole of it (#90).
445
+ function applyTextRoleGraph(node, typographic, types) {
385
446
  for (const path of typographic) {
386
447
  let target = node;
387
448
  for (const segment of path.split('.')) {
388
449
  target = target && typeof target === 'object' ? target[segment] : undefined;
389
450
  }
390
451
  if (!target || typeof target !== 'object' || !('$value' in target)) continue;
391
- if (target.$type !== 'dimension') continue;
452
+ if (types[path] !== 'dimension') continue;
392
453
  if (!TEXT_ROLE_UNIT.test(String(target.$value).trim())) continue;
454
+ extNamespace(target, path);
393
455
  target.$extensions ??= {};
394
456
  target.$extensions[EXT_NS] ??= {};
395
457
  const ns = target.$extensions[EXT_NS];
@@ -403,11 +465,19 @@ export function preprocess(dict) {
403
465
  // Read from the UNRESOLVED dict, before resolveInPlace flattens the aliases
404
466
  // the graph is made of.
405
467
  const { typographic } = textRoleGraph(dict);
468
+ const resolved = resolveInPlace(structuredClone(dict), flattenDtcg(dict));
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).
473
+ //
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);
406
479
  const out = hoistDualNodes(
407
- applyTextRoleGraph(
408
- classifyTextUnits(resolveInPlace(structuredClone(dict), flattenDtcg(dict))),
409
- typographic,
410
- ),
480
+ applyTextRoleGraph(classifyTextUnits(resolved, types), typographic, types),
411
481
  collisions,
412
482
  );
413
483
  if (collisions.length) {
@@ -415,6 +485,9 @@ export function preprocess(dict) {
415
485
  .slice(0, 5)
416
486
  .map((c) => {
417
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
+ }
418
491
  if (c.isGroup) {
419
492
  return line + (c.claimant ? ` (a group, already claimed by the hoist of ${c.claimant})` : ' (a group)');
420
493
  }
@@ -601,7 +674,14 @@ const DECLINED_STOCK_TRANSFORMS = {
601
674
  // Order is never compared: our lists are hand-ordered for our own reasons and
602
675
  // do not inherit stock order. Removals are never reported: a declined name
603
676
  // disappearing is a non-event.
604
- 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
+ ) {
605
685
  if (typeof transformGroups !== 'object' || transformGroups === null) {
606
686
  return [
607
687
  "throughline: could not read Style Dictionary's stock transform groups " +
@@ -610,7 +690,29 @@ export function auditStockGroups(transformGroups) {
610
690
  ];
611
691
  }
612
692
  const warnings = [];
613
- 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
+ }
614
716
  const group = preset.stockGroup;
615
717
  if (!group || !Array.isArray(preset.transforms)) {
616
718
  warnings.push(
@@ -634,7 +736,7 @@ export function auditStockGroups(transformGroups) {
634
736
  const unaccounted = stock.filter(
635
737
  (name) =>
636
738
  !preset.transforms.includes(name) &&
637
- !Object.hasOwn(DECLINED_STOCK_TRANSFORMS, name),
739
+ !Object.hasOwn(declined, name),
638
740
  );
639
741
  if (unaccounted.length) {
640
742
  const n = unaccounted.length;
@@ -736,8 +838,21 @@ export function nativePlatform({ platform, buildPath, className = 'Tokens', pack
736
838
  // Carried here, not left to the caller: authored() reads the ORIGINAL
737
839
  // $value, so without this preprocessor every aliased dimension still holds
738
840
  // an unresolved {spacing.space.4}, no size transform fires, and the build
739
- // emits bare px literals. preprocess is idempotent, so a project that also
740
- // 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.
741
856
  preprocessors: ['dtcg/resolve-dual-node'],
742
857
  buildPath,
743
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)) {
@@ -178,6 +296,12 @@ export function textRoleGraph(dict) {
178
296
  // failure this module exists to prevent. A token whose source already stamps
179
297
  // nativeUnit is closed and is not reported.
180
298
  const inferredGroups = new Set([...typographic].map((p) => p.split('.').slice(0, -1).join('.')));
299
+ // DTCG 5.2.2, not the token's own literal $type: a source that declares
300
+ // $type once on the group and not on each token is legal DTCG, and gating on
301
+ // val.$type made this walk blind to it — so the advisory that exists to name
302
+ // a silent gap was itself silent on the shape where the whole pipeline goes
303
+ // quiet (#85).
304
+ const types = flattenDtcgTypes(dict);
181
305
  const unreferencedSiblings = [];
182
306
  (function walk(node, prefix) {
183
307
  for (const [key, val] of Object.entries(node)) {
@@ -187,10 +311,10 @@ export function textRoleGraph(dict) {
187
311
  const group = prefix.join('.');
188
312
  if (
189
313
  '$value' in val &&
190
- val.$type === 'dimension' &&
314
+ types[dotted] === 'dimension' &&
191
315
  TEXT_ROLE_UNIT.test(String(val.$value).trim()) &&
192
316
  !referrers.has(dotted) &&
193
- !('nativeUnit' in (val.$extensions?.[EXT_NS] ?? {})) &&
317
+ !('nativeUnit' in (extNamespace(val, dotted) ?? {})) &&
194
318
  inferredGroups.has(group)
195
319
  ) {
196
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,6 +12,8 @@
12
12
  import { readFileSync } from 'node:fs';
13
13
  import {
14
14
  flattenDtcg,
15
+ flattenPipelineTypes,
16
+ extNamespace,
15
17
  resolveValue,
16
18
  findModeCollisions,
17
19
  TEXT_UNIT_NAMES,
@@ -82,10 +84,25 @@ export function colorMixToHex8(value) {
82
84
  // Marks a node whose AUTHORED $value was a whole-value reference, so the hoist
83
85
  // can decline to override the type DTCG 5.2.2 rule 1 already determined from the
84
86
  // referent. A WeakSet keyed on the node object, rather than a property written
85
- // onto it, holds structural idempotency exactly: structuredClone drops the
86
- // membership along with the rest of the identity, so preprocess(preprocess(x))
87
- // 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).
88
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();
89
106
  const WHOLE_REF = /^\{[^}]+\}$/;
90
107
 
91
108
  function interpolate(value, flat) {
@@ -158,20 +175,27 @@ function hoistDualNodes(node, collisions, prefix = [], groupType = undefined) {
158
175
  for (const [childKey, childVal] of Object.entries(val)) {
159
176
  if (childKey.startsWith('$') || !childVal || typeof childVal !== 'object') continue;
160
177
  const hoisted = key + childKey[0].toUpperCase() + childKey.slice(1);
161
- const from = [...prefix, key, childKey].join('.');
178
+ const from = AUTHORED_PATH.get(childVal) ?? [...prefix, key, childKey].join('.');
162
179
  if (Object.hasOwn(node, hoisted)) {
163
180
  const existingNode = node[hoisted];
164
- 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);
165
187
  collisions.push({
166
188
  from,
167
189
  onto: [...prefix, hoisted].join('.'),
168
190
  isGroup,
191
+ isArray,
169
192
  claimant: claimedBy.get(hoisted),
170
- existing: isGroup
171
- ? undefined
172
- : existingNode && typeof existingNode === 'object'
173
- ? existingNode.$value
174
- : existingNode,
193
+ existing:
194
+ isGroup || isArray
195
+ ? undefined
196
+ : existingNode && typeof existingNode === 'object'
197
+ ? existingNode.$value
198
+ : existingNode,
175
199
  });
176
200
  continue;
177
201
  }
@@ -194,8 +218,17 @@ function hoistDualNodes(node, collisions, prefix = [], groupType = undefined) {
194
218
  // type suits the child badly, because the hoist is not entitled to
195
219
  // improve on what the source says; but the carry firing at all is the
196
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.
197
229
  if (
198
230
  !('$type' in childVal) &&
231
+ '$value' in childVal &&
199
232
  '$type' in val &&
200
233
  !WAS_REF.has(childVal) &&
201
234
  inherited === undefined
@@ -203,6 +236,7 @@ function hoistDualNodes(node, collisions, prefix = [], groupType = undefined) {
203
236
  childVal.$type = val.$type;
204
237
  }
205
238
  node[hoisted] = childVal;
239
+ AUTHORED_PATH.set(childVal, from);
206
240
  delete val[childKey];
207
241
  claimedBy.set(hoisted, from);
208
242
  }
@@ -273,15 +307,37 @@ export { EXT_NS };
273
307
  // on is gone. Matching a suffix against the camel-joined name instead would
274
308
  // couple the rule to the hoist's naming scheme, and case-insensitively it
275
309
  // false-positives on names like baselineHeight.
276
- function classifyTextUnits(node) {
310
+ //
311
+ // The type comes from `types`, a DTCG 5.2.2 resolution of the whole tree, not
312
+ // from the token's own literal $type. Reading val.$type made this pass blind to
313
+ // a source that declares $type once on the group — legal DTCG, and on such a
314
+ // source the reference-graph inference stamped nothing at all (#85). A token's
315
+ // own $type still wins; the group's applies only where the token states none.
316
+ //
317
+ // WHERE THIS MATTERS, measured rather than assumed. Style Dictionary runs
318
+ // global preprocessors, THEN its own typeDtcgDelegate — which is 5.2.2, pushing
319
+ // each group's $type onto its descendants — then platform preprocessors
320
+ // (StyleDictionary.js:340, :348, :440 in 4.4.0). nativePlatform registers this
321
+ // preprocessor at PLATFORM level, downstream of that delegation, so a build
322
+ // wired only through nativePlatform never saw the defect: on a group-typed
323
+ // re-encoding of a real system it emits the same 208 declarations and 48 sp
324
+ // either way. The defect reaches the build that ALSO declares this preprocessor
325
+ // at top level, which is the wiring the usage snippet above shows — there the
326
+ // first pass runs before any delegation. Resolving the type here makes both
327
+ // wirings agree instead of depending on which one a consumer copied.
328
+ function classifyTextUnits(node, types, prefix = []) {
277
329
  for (const [key, val] of Object.entries(node)) {
278
330
  if (key.startsWith('$') || !val || typeof val !== 'object') continue;
331
+ const path = [...prefix, key];
279
332
  if (
280
333
  TEXT_UNIT_NAMES.has(key) &&
281
334
  '$value' in val &&
282
- val.$type === 'dimension' &&
335
+ types[path.join('.')] === 'dimension' &&
283
336
  TEXT_ROLE_UNIT.test(String(val.$value).trim())
284
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('.'));
285
341
  val.$extensions ??= {};
286
342
  val.$extensions[EXT_NS] ??= {};
287
343
  const ns = val.$extensions[EXT_NS];
@@ -290,10 +346,12 @@ function classifyTextUnits(node) {
290
346
  // one, so a unitless value is declined by every size transform regardless
291
347
  // of what is stamped here (see isRatio, #52). Declining to overwrite IS
292
348
  // the feature: it costs no configuration parameter, and it is what makes
293
- // 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).
294
352
  if (!('nativeUnit' in ns)) ns.nativeUnit = 'text';
295
353
  }
296
- classifyTextUnits(val);
354
+ classifyTextUnits(val, types, path);
297
355
  }
298
356
  return node;
299
357
  }
@@ -304,23 +362,27 @@ function classifyTextUnits(node) {
304
362
  // text.xsLineHeight and the graph's paths are written in pre-hoist names.
305
363
  //
306
364
  // The three gates are classifyTextUnits's, verbatim — a dimension, a value with
307
- // a unit, and no role already recorded. A unitless value is never stamped: no
365
+ // a unit, and no role already recorded. Both passes read the SAME resolved-type
366
+ // map, which is what makes them agree by construction rather than by two copies
367
+ // of DTCG 5.2.2 staying in step. A unitless value is never stamped: no
308
368
  // size transform claims one since #52, and stamping a ratio as text would still
309
369
  // be a claim the source never made.
310
370
  //
311
371
  // A path may name no node at all. resolveInPlace deliberately leaves an
312
372
  // unresolvable reference in place for Style Dictionary to report, so the graph
313
373
  // can hold an edge to a token that does not exist. Skip it. This is also what
314
- // keeps the second preprocess pass from throwing, and idempotency with it.
315
- function applyTextRoleGraph(node, typographic) {
374
+ // keeps the second preprocess pass from throwing which is a precondition for
375
+ // idempotency, not the whole of it (#90).
376
+ function applyTextRoleGraph(node, typographic, types) {
316
377
  for (const path of typographic) {
317
378
  let target = node;
318
379
  for (const segment of path.split('.')) {
319
380
  target = target && typeof target === 'object' ? target[segment] : undefined;
320
381
  }
321
382
  if (!target || typeof target !== 'object' || !('$value' in target)) continue;
322
- if (target.$type !== 'dimension') continue;
383
+ if (types[path] !== 'dimension') continue;
323
384
  if (!TEXT_ROLE_UNIT.test(String(target.$value).trim())) continue;
385
+ extNamespace(target, path);
324
386
  target.$extensions ??= {};
325
387
  target.$extensions[EXT_NS] ??= {};
326
388
  const ns = target.$extensions[EXT_NS];
@@ -334,11 +396,19 @@ export function preprocess(dict) {
334
396
  // Read from the UNRESOLVED dict, before resolveInPlace flattens the aliases
335
397
  // the graph is made of.
336
398
  const { typographic } = textRoleGraph(dict);
399
+ const resolved = resolveInPlace(structuredClone(dict), flattenDtcg(dict));
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).
404
+ //
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);
337
410
  const out = hoistDualNodes(
338
- applyTextRoleGraph(
339
- classifyTextUnits(resolveInPlace(structuredClone(dict), flattenDtcg(dict))),
340
- typographic,
341
- ),
411
+ applyTextRoleGraph(classifyTextUnits(resolved, types), typographic, types),
342
412
  collisions,
343
413
  );
344
414
  if (collisions.length) {
@@ -346,6 +416,9 @@ export function preprocess(dict) {
346
416
  .slice(0, 5)
347
417
  .map((c) => {
348
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
+ }
349
422
  if (c.isGroup) {
350
423
  return line + (c.claimant ? ` (a group, already claimed by the hoist of ${c.claimant})` : ' (a group)');
351
424
  }
@@ -473,7 +546,14 @@ const DECLINED_STOCK_TRANSFORMS = {
473
546
  // Order is never compared: our lists are hand-ordered for our own reasons and
474
547
  // do not inherit stock order. Removals are never reported: a declined name
475
548
  // disappearing is a non-event.
476
- 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
+ ) {
477
557
  if (typeof transformGroups !== 'object' || transformGroups === null) {
478
558
  return [
479
559
  "throughline: could not read Style Dictionary's stock transform groups " +
@@ -482,7 +562,29 @@ export function auditStockGroups(transformGroups) {
482
562
  ];
483
563
  }
484
564
  const warnings = [];
485
- 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
+ }
486
588
  const group = preset.stockGroup;
487
589
  if (!group || !Array.isArray(preset.transforms)) {
488
590
  warnings.push(
@@ -506,7 +608,7 @@ export function auditStockGroups(transformGroups) {
506
608
  const unaccounted = stock.filter(
507
609
  (name) =>
508
610
  !preset.transforms.includes(name) &&
509
- !Object.hasOwn(DECLINED_STOCK_TRANSFORMS, name),
611
+ !Object.hasOwn(declined, name),
510
612
  );
511
613
  if (unaccounted.length) {
512
614
  const n = unaccounted.length;
@@ -608,8 +710,21 @@ export function nativePlatform({ platform, buildPath, className = 'Tokens', pack
608
710
  // Carried here, not left to the caller: authored() reads the ORIGINAL
609
711
  // $value, so without this preprocessor every aliased dimension still holds
610
712
  // an unresolved {spacing.space.4}, no size transform fires, and the build
611
- // emits bare px literals. preprocess is idempotent, so a project that also
612
- // 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.
613
728
  preprocessors: ['dtcg/resolve-dual-node'],
614
729
  buildPath,
615
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
  }