@radicool/throughline 0.18.0 → 0.20.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (47) hide show
  1. package/README.md +2 -2
  2. package/adapters/codex/prompts/component-builder.md +24 -1
  3. package/adapters/codex/prompts/document-component.md +10 -1
  4. package/adapters/codex/prompts/icon-system-builder.md +4 -0
  5. package/adapters/codex/prompts/storybook-chromatic-builder.md +114 -7
  6. package/adapters/codex/prompts/token-builder.md +110 -3
  7. package/adapters/codex/prompts/token-crosswalk-builder.md +2 -0
  8. package/adapters/codex/prompts/token-sheet-builder.md +4 -0
  9. package/adapters/codex/prompts/token-sync-layer.md +140 -20
  10. package/adapters/cursor/.cursor/commands/document-component.md +10 -1
  11. package/adapters/cursor/.cursor/rules/component-builder.mdc +24 -1
  12. package/adapters/cursor/.cursor/rules/icon-system-builder.mdc +4 -0
  13. package/adapters/cursor/.cursor/rules/storybook-chromatic-builder.mdc +114 -7
  14. package/adapters/cursor/.cursor/rules/token-builder.mdc +110 -3
  15. package/adapters/cursor/.cursor/rules/token-crosswalk-builder.mdc +2 -0
  16. package/adapters/cursor/.cursor/rules/token-sheet-builder.mdc +4 -0
  17. package/adapters/cursor/.cursor/rules/token-sync-layer.mdc +140 -20
  18. package/adapters/generic/commands/document-component.md +10 -1
  19. package/adapters/generic/skills/component-builder/SKILL.md +24 -1
  20. package/adapters/generic/skills/icon-system-builder/SKILL.md +4 -0
  21. package/adapters/generic/skills/storybook-chromatic-builder/SKILL.md +114 -7
  22. package/adapters/generic/skills/token-builder/SKILL.md +110 -3
  23. package/adapters/generic/skills/token-crosswalk-builder/SKILL.md +2 -0
  24. package/adapters/generic/skills/token-sheet-builder/SKILL.md +4 -0
  25. package/adapters/generic/skills/token-sync-layer/SKILL.md +140 -20
  26. package/package.json +1 -1
  27. package/references/component-doc-schema.md +11 -1
  28. package/references/figma-component-standards.md +14 -2
  29. package/references/figma-scripting.md +12 -2
  30. package/references/manifest-schema.md +42 -2
  31. package/references/native-adapter-config.md +112 -25
  32. package/references/proof-bundle.md +305 -0
  33. package/references/sync-adapters.md +38 -53
  34. package/scripts/README.md +140 -6
  35. package/scripts/build-doc-card-builder.mjs +1 -1
  36. package/scripts/grep-color-usage.mjs +10 -30
  37. package/scripts/guard-token-removal.mjs +10 -26
  38. package/scripts/lib/component-states.mjs +56 -0
  39. package/scripts/lib/contrast.mjs +117 -0
  40. package/scripts/lib/dtcg.mjs +123 -4
  41. package/scripts/lib/native-literal.mjs +22 -2
  42. package/scripts/lib/proof.mjs +186 -0
  43. package/scripts/lib/sd-native.mjs +112 -25
  44. package/scripts/lib/source-scan.mjs +45 -0
  45. package/scripts/validate-adherence.mjs +895 -0
  46. package/scripts/validate-token-output.mjs +611 -46
  47. package/scripts/verify-check.mjs +710 -0
@@ -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 },
@@ -0,0 +1,45 @@
1
+ // Walking a consumer's source tree, shared by every gate that scans one.
2
+ //
3
+ // grep-color-usage.mjs and guard-token-removal.mjs each carried their own copy
4
+ // of this — identical excludes, and a walk that differed only in which file
5
+ // extensions it yielded. The duplication was the shape #57 was filed for: two
6
+ // definitions of one rule, agreeing today, with nothing keeping them in step.
7
+ //
8
+ // The file filter is the caller's, not a built-in: a colour scan wants
9
+ // stylesheets and a symbol guard does not, and neither should have to fork the
10
+ // walker to say so.
11
+ import { readdirSync, statSync } from 'node:fs';
12
+ import { join } from 'node:path';
13
+
14
+ export const DEFAULT_EXCLUDES = [
15
+ /(^|\/)node_modules(\/|$)/,
16
+ /(^|\/)generated(\/|$)/,
17
+ /\.generated\./,
18
+ /\.test\./,
19
+ /\.spec\./,
20
+ /(^|\/)__tests__(\/|$)/,
21
+ /(^|\/)dist(\/|$)/,
22
+ /(^|\/)\.next(\/|$)/,
23
+ /(^|\/)storybook-static(\/|$)/,
24
+ ];
25
+
26
+ // Every text file a design system's values can hide in.
27
+ export const SOURCE_EXT = /\.(scss|sass|css|tsx?|jsx?|mjs|cjs|vue|svelte|html|svg)$/;
28
+
29
+ export function* walk(root, { excludes = DEFAULT_EXCLUDES, fileFilter = SOURCE_EXT } = {}) {
30
+ for (const entry of readdirSync(root)) {
31
+ const full = join(root, entry);
32
+ if (excludes.some((re) => re.test(full))) continue;
33
+ const st = statSync(full);
34
+ if (st.isDirectory()) yield* walk(full, { excludes, fileFilter });
35
+ else if (fileFilter.test(full)) yield full;
36
+ }
37
+ }
38
+
39
+ // A design system records a component's DISPLAY name — a real manifest holds
40
+ // "Select Menu" — while the code exports <SelectMenu>. Nothing in the schema
41
+ // says `name` is a code identifier, and nothing should be changed to make it
42
+ // one: a display name is right for a doc surface. So the comparison normalises,
43
+ // the same way validate-token-output.mjs already folds adapter naming
44
+ // conventions onto each other.
45
+ export const normalizeName = (s) => String(s).toLowerCase().replace(/[^a-z0-9]/g, '');