@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.
- package/README.md +2 -2
- package/adapters/codex/prompts/component-builder.md +24 -1
- package/adapters/codex/prompts/document-component.md +10 -1
- package/adapters/codex/prompts/icon-system-builder.md +4 -0
- package/adapters/codex/prompts/storybook-chromatic-builder.md +114 -7
- package/adapters/codex/prompts/token-builder.md +110 -3
- package/adapters/codex/prompts/token-crosswalk-builder.md +2 -0
- package/adapters/codex/prompts/token-sheet-builder.md +4 -0
- package/adapters/codex/prompts/token-sync-layer.md +140 -20
- package/adapters/cursor/.cursor/commands/document-component.md +10 -1
- package/adapters/cursor/.cursor/rules/component-builder.mdc +24 -1
- package/adapters/cursor/.cursor/rules/icon-system-builder.mdc +4 -0
- package/adapters/cursor/.cursor/rules/storybook-chromatic-builder.mdc +114 -7
- package/adapters/cursor/.cursor/rules/token-builder.mdc +110 -3
- package/adapters/cursor/.cursor/rules/token-crosswalk-builder.mdc +2 -0
- package/adapters/cursor/.cursor/rules/token-sheet-builder.mdc +4 -0
- package/adapters/cursor/.cursor/rules/token-sync-layer.mdc +140 -20
- package/adapters/generic/commands/document-component.md +10 -1
- package/adapters/generic/skills/component-builder/SKILL.md +24 -1
- package/adapters/generic/skills/icon-system-builder/SKILL.md +4 -0
- package/adapters/generic/skills/storybook-chromatic-builder/SKILL.md +114 -7
- package/adapters/generic/skills/token-builder/SKILL.md +110 -3
- package/adapters/generic/skills/token-crosswalk-builder/SKILL.md +2 -0
- package/adapters/generic/skills/token-sheet-builder/SKILL.md +4 -0
- package/adapters/generic/skills/token-sync-layer/SKILL.md +140 -20
- package/package.json +1 -1
- package/references/component-doc-schema.md +11 -1
- package/references/figma-component-standards.md +14 -2
- package/references/figma-scripting.md +12 -2
- package/references/manifest-schema.md +42 -2
- package/references/native-adapter-config.md +112 -25
- package/references/proof-bundle.md +305 -0
- package/references/sync-adapters.md +38 -53
- package/scripts/README.md +140 -6
- package/scripts/build-doc-card-builder.mjs +1 -1
- package/scripts/grep-color-usage.mjs +10 -30
- package/scripts/guard-token-removal.mjs +10 -26
- package/scripts/lib/component-states.mjs +56 -0
- package/scripts/lib/contrast.mjs +117 -0
- package/scripts/lib/dtcg.mjs +123 -4
- package/scripts/lib/native-literal.mjs +22 -2
- package/scripts/lib/proof.mjs +186 -0
- package/scripts/lib/sd-native.mjs +112 -25
- package/scripts/lib/source-scan.mjs +45 -0
- package/scripts/validate-adherence.mjs +895 -0
- package/scripts/validate-token-output.mjs +611 -46
- 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
|
-
|
|
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,
|
|
87
|
-
// membership along with the rest of the identity, so
|
|
88
|
-
//
|
|
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
|
-
|
|
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:
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
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
|
-
//
|
|
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
|
|
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
|
-
//
|
|
361
|
-
//
|
|
362
|
-
//
|
|
363
|
-
//
|
|
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
|
|
366
|
-
//
|
|
367
|
-
|
|
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
|
-
|
|
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(
|
|
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(
|
|
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.
|
|
640
|
-
//
|
|
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, '');
|