@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
@@ -372,8 +372,20 @@ re-describing it:
372
372
  `${CLAUDE_PLUGIN_ROOT}/references/figma-scripting.md` — `getNodeByIdAsync`, and
373
373
  an explicit `timeout` since multi-card font-loading writes blow past the default
374
374
  ~5s budget), then inside it:
375
- - set the `Status Label` text to the new status (e.g. `stable`);
376
- - re-bind the `Status` chip fill to the matching semantic color variable
375
+ - locate the status chip under either header shape, the way the doc-card
376
+ builder's header guard does: find the header band structurally (the card's
377
+ child frame that is neither `Usage` nor the specimen nor an ancestor of it),
378
+ then search its first child, the title row, for a descendant named `Status`
379
+ (to-spec cards) or `Status Pill` (legacy cards). The chip's label is its
380
+ `Status Label` text node; a legacy pill has no named label, so use its one
381
+ TEXT descendant. If no chip is found, or the label doesn't resolve to exactly
382
+ one text node, **skip that card** without writing anything to it and record
383
+ its name — catch per card, so one miss never aborts the rest of a multi-card
384
+ write. After the loop, tell the user by name every card that still shows the
385
+ old status in Figma — the manifest already says `stable`, so a silent skip
386
+ leaves the card lying;
387
+ - set the label text to the new status (e.g. `stable`);
388
+ - re-bind the chip fill to the matching semantic color variable
377
389
  (`stable` → success, `draft`/`beta` → warning, `deprecated` → neutral/danger)
378
390
  — re-bind the variable, don't hardcode a hex, so it stays mode-aware.
379
391
  Then re-run the canonical doc-card builder
@@ -51,8 +51,9 @@ wrong document.
51
51
 
52
52
  **So the preflight must assert the active file's *identity*, not just connection:**
53
53
 
54
- 1. **Know your target `fileKey`** before writing (the one your architect read, or
55
- the one the workflow is scoped to).
54
+ 1. **Know your target `fileKey`** before writing. It is `figma.fileKey` in
55
+ `design-system.json` at the working-directory root. If that field is absent,
56
+ stop and ask which file to write to — do not assume the active one is right.
56
57
  2. **Read the active file and compare.** `figma_get_status` reports
57
58
  `currentFileKey` / `currentFileName`; `figma_list_open_files` shows every
58
59
  connected file and which is active. Confirm the active `fileKey` equals your
@@ -61,6 +62,15 @@ wrong document.
61
62
  3. **If it drifted, navigate — don't just retry.** `figma_navigate` to the target
62
63
  file URL, then re-read status to confirm the switch took, then write. A bare
63
64
  re-run without navigating will hit the wrong file again.
65
+ 4. **If it still doesn't match, stop.** Name both files. The usual cause is that
66
+ the target file does not have the Desktop Bridge plugin open.
67
+
68
+ Run this before the first write, and again right before any replace — the
69
+ build-verify-then-replace finalize deletes the existing node, so it is the write
70
+ that destroys work in the wrong file. The `figma-executor` agent runs it as step 1
71
+ of its contract. **When a skill builds inline instead** (a host with no subagent
72
+ dispatch, or a write-back the skill does itself), nothing runs it for you: the
73
+ skill has to.
64
74
 
65
75
  This is the file-level analogue of the live-instance discipline above: the
66
76
  instance preflight guards against *two* live bridges; this guards against the *one*
@@ -11,11 +11,11 @@ what changed. Gating decisions are made by reading this file: if a prerequisite
11
11
  field is unset, the skill **offers** to run the prerequisite skill rather than
12
12
  bailing or running silently.
13
13
 
14
- ## Schema (schemaVersion 6)
14
+ ## Schema (schemaVersion 7)
15
15
 
16
16
  ```json
17
17
  {
18
- "schemaVersion": 6,
18
+ "schemaVersion": 7,
19
19
  "user": {
20
20
  "codingLevel": "new"
21
21
  },
@@ -102,6 +102,7 @@ bailing or running silently.
102
102
  "completedAt": null,
103
103
  "journalScaffolded": false
104
104
  },
105
+ "verification": null,
105
106
  "completedSkills": []
106
107
  }
107
108
  ```
@@ -346,6 +347,41 @@ retrofit stands so a later session can resume.
346
347
  - `journalScaffolded` — whether the `docs/design-system/` decision journal has been
347
348
  created for this retrofit (offered default-on by `retrofit-planner`).
348
349
 
350
+ ### `verification`
351
+ The pointer at the folder-resident proof store, where each stage records what it
352
+ changed, which checks ran and why it was allowed to advance. `null` until the
353
+ first stage records an entry. See
354
+ `${CLAUDE_PLUGIN_ROOT}/references/proof-bundle.md`.
355
+ - `path` — the store directory (`design-system/proof`).
356
+ - `stages` — object keyed by stage name, each holding
357
+ `{ at, adoptedAt, exempt?, fingerprint }`.
358
+ - `fingerprint` — computed over the **whole** stage file, so a hand-edited entry
359
+ is caught (`proof-stale`), the same way `docs:check` catches a hand-edited
360
+ surface.
361
+ - `at` — the latest recorded entry's timestamp. It moves with every record.
362
+ - `adoptedAt` — the first entry's timestamp: **write-once**, never rewritten, the
363
+ human-readable "when did this system opt in". No gate reads it.
364
+ - `exempt` — present only on the per-component stages (`component-builder`,
365
+ `storybook-chromatic-builder`) and **capture-once-then-shrink**: the stage's
366
+ first record captures every name then in `components.built`, and every record,
367
+ the first included, removes its own subject. The list never gains a name. It is
368
+ what scopes the per-component `proof-missing` rule to components the stage has
369
+ not yet proven, so adopting the bundle does not light up a failure for every
370
+ component built before it existed. A listed stage with **no** `exempt` key
371
+ exempts **nothing**, not everything — a missing list makes the gate louder, not
372
+ quieter.
373
+
374
+ **Pointers and hashes, never entries** — the entries themselves live in the
375
+ store, for the same reason `components.meta[name].doc` keeps content out of the
376
+ manifest. Skills write all of this only through `verify-check.mjs --record`; none
377
+ hand-writes a stage file or computes a fingerprint by hand.
378
+
379
+ No gate compares `components.meta[name].updatedAt` against `adoptedAt`. That
380
+ field is refreshed by a status promotion as well as by a rebuild, so a stage
381
+ other than the one that owes the entry can move it — and a date comparison would
382
+ fail a pre-existing component the first time an unrelated storying run promotes
383
+ it.
384
+
349
385
  ### `completedSkills`
350
386
  - Append-only list of skill identifiers that have run to completion at least
351
387
  once. Useful for the `/design-system-status` command and for friendly
@@ -379,3 +415,7 @@ are authored. Bump `schemaVersion` to `5`. No existing field changes.
379
415
  **v5 → v6 migration:** add `figma.docCardVariables` (default `null`), populated
380
416
  on the first doc-card render. Bump `schemaVersion` to `6`. No existing field
381
417
  changes.
418
+
419
+ **v6 → v7 migration:** add `verification` (default `null`), populated on the
420
+ first recorded stage entry. Bump `schemaVersion` to `7`. No existing field
421
+ changes.
@@ -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 },