@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
@@ -6,10 +6,17 @@
6
6
  // Usage:
7
7
  // node grep-color-usage.mjs --root <dir> [--config <patterns.json>] [--out <counts.json>]
8
8
  // patterns.json: { "<category>": { "files": "<regex>", "pattern": "<regex with g flag>" }, ... }
9
- import { readFileSync, readdirSync, statSync, writeFileSync } from 'node:fs';
10
- import { join, relative } from 'node:path';
9
+ import { readFileSync, writeFileSync } from 'node:fs';
10
+ import { relative } from 'node:path';
11
11
  import { parseArgs } from 'node:util';
12
12
  import { pathToFileURL } from 'node:url';
13
+ import { walk, DEFAULT_EXCLUDES, SOURCE_EXT } from './lib/source-scan.mjs';
14
+
15
+ // This script has exported `walk` and DEFAULT_EXCLUDES since it was written;
16
+ // keep that surface intact now that both live one file over. SOURCE_EXT is not
17
+ // re-exported because it was never exported from here — import it from
18
+ // lib/source-scan.mjs. `walk`'s second parameter is now an options object.
19
+ export { walk, DEFAULT_EXCLUDES };
13
20
 
14
21
  // The five categories from the case study. Each: which files it applies to, and the
15
22
  // (global) match pattern. Tuned defaults — design-system-audit may override per repo.
@@ -36,33 +43,6 @@ export const DEFAULT_CATEGORIES = {
36
43
  },
37
44
  };
38
45
 
39
- export const DEFAULT_EXCLUDES = [
40
- /(^|\/)node_modules(\/|$)/,
41
- /(^|\/)generated(\/|$)/,
42
- /\.generated\./,
43
- /\.test\./,
44
- /\.spec\./,
45
- /(^|\/)__tests__(\/|$)/,
46
- /(^|\/)dist(\/|$)/,
47
- /(^|\/)\.next(\/|$)/,
48
- ];
49
-
50
- // Source extensions worth opening at all (union of every category's `files`).
51
- const SOURCE_EXT = /\.(scss|sass|css|tsx?|jsx?|mjs|cjs|vue|svelte|html|svg)$/;
52
-
53
- export function* walk(root, excludes = DEFAULT_EXCLUDES) {
54
- for (const entry of readdirSync(root)) {
55
- const full = join(root, entry);
56
- if (excludes.some((re) => re.test(full))) continue;
57
- const st = statSync(full);
58
- if (st.isDirectory()) {
59
- yield* walk(full, excludes);
60
- } else if (SOURCE_EXT.test(full)) {
61
- yield full;
62
- }
63
- }
64
- }
65
-
66
46
  // Count matches per category in one file. A category contributes only if its `files`
67
47
  // regex matches this path. Returns { <category>: count } for every category key.
68
48
  export function scanFile(path, categories) {
@@ -82,7 +62,7 @@ export function grepColorUsage(root, categories = DEFAULT_CATEGORIES, excludes =
82
62
  const counts = {};
83
63
  for (const key of Object.keys(categories)) counts[key] = 0;
84
64
  const byFile = [];
85
- for (const file of walk(root, excludes)) {
65
+ for (const file of walk(root, { excludes, fileFilter: SOURCE_EXT })) {
86
66
  const fileCounts = scanFile(file, categories);
87
67
  const total = Object.values(fileCounts).reduce((a, b) => a + b, 0);
88
68
  if (total > 0) {
@@ -7,34 +7,16 @@
7
7
  // Usage:
8
8
  // node guard-token-removal.mjs --root <dir> --symbols <symbols.txt>
9
9
  // symbols file: one symbol per line (blank lines and # comments ignored)
10
- import { readFileSync, readdirSync, statSync } from 'node:fs';
11
- import { join, relative } from 'node:path';
10
+ import { readFileSync } from 'node:fs';
11
+ import { relative } from 'node:path';
12
12
  import { parseArgs } from 'node:util';
13
13
  import { pathToFileURL } from 'node:url';
14
+ import { walk, DEFAULT_EXCLUDES } from './lib/source-scan.mjs';
14
15
 
15
- export const DEFAULT_EXCLUDES = [
16
- /(^|\/)node_modules(\/|$)/,
17
- /(^|\/)generated(\/|$)/,
18
- /\.generated\./,
19
- /\.test\./,
20
- /\.spec\./,
21
- /(^|\/)__tests__(\/|$)/,
22
- /(^|\/)dist(\/|$)/,
23
- /(^|\/)\.next(\/|$)/,
24
- ];
25
-
26
- export function* walk(root, excludes = DEFAULT_EXCLUDES) {
27
- for (const entry of readdirSync(root)) {
28
- const full = join(root, entry);
29
- if (excludes.some((re) => re.test(full))) continue;
30
- const st = statSync(full);
31
- if (st.isDirectory()) {
32
- yield* walk(full, excludes);
33
- } else if (/\.tsx?$/.test(full)) {
34
- yield full;
35
- }
36
- }
37
- }
16
+ // This script has exported `walk` and DEFAULT_EXCLUDES since it was written;
17
+ // keep that surface intact now that both live one file over. `walk`'s second
18
+ // parameter is now an options object.
19
+ export { walk, DEFAULT_EXCLUDES };
38
20
 
39
21
  export function scanFile(path, symbols) {
40
22
  const lines = readFileSync(path, 'utf8').split('\n');
@@ -51,7 +33,9 @@ export function scanFile(path, symbols) {
51
33
 
52
34
  export function guard(root, symbols, excludes = DEFAULT_EXCLUDES) {
53
35
  const findings = [];
54
- for (const file of walk(root, excludes)) {
36
+ // .ts/.tsx is this guard's own contract, so the filter stays here rather
37
+ // than moving into the shared walker's default.
38
+ for (const file of walk(root, { excludes, fileFilter: /\.tsx?$/ })) {
55
39
  for (const hit of scanFile(file, symbols)) {
56
40
  findings.push({ file: relative(root, file), ...hit });
57
41
  }
@@ -0,0 +1,56 @@
1
+ // The archetype baseline for `state-incomplete` (`docs/specs/2026-09-12-verification-proof-bundle.md`,
2
+ // step 3). Source of truth for the prose is `references/figma-component-standards.md:126-138`.
3
+ //
4
+ // `default` is deliberately excluded from every baseline below: documenting the
5
+ // resting state is not documentation, and the Figma matrix requirement for it is
6
+ // the executor's, not this table's. Conditional states (`loading`, `error`,
7
+ // `success`, `selected`) are deliberately excluded too — whether one applies to a
8
+ // given component is a design judgment, not something a baseline can assert.
9
+ //
10
+ // MAINTENANCE: a change to the prose baseline must change this table.
11
+
12
+ import { normalizeName } from './source-scan.mjs';
13
+
14
+ export const ARCHETYPES = ['button', 'input', 'choice', 'card', 'modal', 'badge', 'other'];
15
+
16
+ export const BASELINE_STATES = {
17
+ button: ['hover', 'focus', 'active', 'disabled'],
18
+ input: ['hover', 'focus', 'disabled'],
19
+ choice: ['hover', 'focus', 'active', 'disabled'],
20
+ card: [],
21
+ modal: [],
22
+ badge: [],
23
+ other: [],
24
+ };
25
+
26
+ export const NAME_SYNONYMS = {
27
+ button: 'button',
28
+ input: 'input',
29
+ textfield: 'input',
30
+ textinput: 'input',
31
+ checkbox: 'choice',
32
+ radio: 'choice',
33
+ toggle: 'choice',
34
+ switch: 'choice',
35
+ chip: 'choice',
36
+ card: 'card',
37
+ modal: 'modal',
38
+ dialog: 'modal',
39
+ badge: 'badge',
40
+ tag: 'badge',
41
+ };
42
+
43
+ export function resolveArchetype(record) {
44
+ if (ARCHETYPES.includes(record.archetype)) return record.archetype;
45
+ const bySynonym = NAME_SYNONYMS[normalizeName(record.name)];
46
+ return bySynonym ?? null;
47
+ }
48
+
49
+ export function missingStates(record) {
50
+ const archetype = resolveArchetype(record);
51
+ if (archetype === null) return [];
52
+ const baseline = BASELINE_STATES[archetype];
53
+ if (baseline.length === 0) return [];
54
+ const recordedKeys = new Set(Object.keys(record.states ?? {}).map(normalizeName));
55
+ return baseline.filter((state) => !recordedKeys.has(normalizeName(state)));
56
+ }
@@ -0,0 +1,117 @@
1
+ // The WCAG contrast maths and the semantic colour pair table behind the
2
+ // `color-contrast` rule (`docs/specs/2026-09-12-token-contrast-validation.md`,
3
+ // step 1). Source of truth for the prose contract is
4
+ // `skills/token-builder/SKILL.md:250-265`.
5
+ //
6
+ // MAINTENANCE: a change to the semantic role set must change this table.
7
+
8
+ export const AA_NORMAL_TEXT = 4.5;
9
+
10
+ // `text/disabled` is excluded: WCAG 1.4.3 exempts inactive user-interface
11
+ // components from the contrast minimum, and a disabled role is deliberately
12
+ // low-contrast — gating it would fail correct systems.
13
+ //
14
+ // `bg/subtle` and `bg/muted` are deliberately not paired in v1 (Open
15
+ // questions) — a system is free to use those roles for non-text surfaces, so
16
+ // pairing them risks the first false failures in the table.
17
+ //
18
+ // `border/*` needs the 3:1 non-text threshold (WCAG 1.4.11) and is a separate
19
+ // table; this one is text-on-surface only.
20
+ //
21
+ // One threshold is applied to every pair because a colour role does not say
22
+ // what text size it is used at — `text/primary` is body copy as well as
23
+ // headings, so 4.5:1 is the binding requirement for it anyway.
24
+ export const CONTRAST_PAIRS = [
25
+ {
26
+ fg: 'color.text.primary',
27
+ bg: 'color.bg.default',
28
+ threshold: AA_NORMAL_TEXT,
29
+ why: 'the default reading pair; if this fails, body copy fails everywhere at once',
30
+ },
31
+ {
32
+ fg: 'color.text.secondary',
33
+ bg: 'color.bg.default',
34
+ threshold: AA_NORMAL_TEXT,
35
+ why: 'secondary text is still text, and still has to clear AA on the same surface primary does',
36
+ },
37
+ {
38
+ fg: 'color.text.link',
39
+ bg: 'color.bg.default',
40
+ threshold: AA_NORMAL_TEXT,
41
+ why: 'a link that fails contrast is a link nobody can find, regardless of colour meaning',
42
+ },
43
+ {
44
+ fg: 'color.text.onEmphasis',
45
+ bg: 'color.bg.emphasis',
46
+ threshold: AA_NORMAL_TEXT,
47
+ why: 'the role exists because it sits on the emphasis fill; that is the whole reason it is not `text/inverse`',
48
+ },
49
+ {
50
+ fg: 'color.text.inverse',
51
+ bg: 'color.bg.inverse',
52
+ threshold: AA_NORMAL_TEXT,
53
+ why: 'the inverse pair flips with the theme, and both sides have to keep clearing together',
54
+ },
55
+ {
56
+ fg: 'color.status.success.text',
57
+ bg: 'color.status.success.bg',
58
+ threshold: AA_NORMAL_TEXT,
59
+ why: 'a status text role exists to sit on that status\'s own background, not any other surface',
60
+ },
61
+ {
62
+ fg: 'color.status.warning.text',
63
+ bg: 'color.status.warning.bg',
64
+ threshold: AA_NORMAL_TEXT,
65
+ why: 'a status text role exists to sit on that status\'s own background, not any other surface',
66
+ },
67
+ {
68
+ fg: 'color.status.danger.text',
69
+ bg: 'color.status.danger.bg',
70
+ threshold: AA_NORMAL_TEXT,
71
+ why: 'a status text role exists to sit on that status\'s own background, not any other surface',
72
+ },
73
+ ];
74
+
75
+ // WCAG 2.x sRGB relative luminance over 0-255 channels.
76
+ export function relativeLuminance({ r, g, b }) {
77
+ const linearize = (channel) => {
78
+ const c = channel / 255;
79
+ return c <= 0.03928 ? c / 12.92 : ((c + 0.055) / 1.055) ** 2.4;
80
+ };
81
+ return 0.2126 * linearize(r) + 0.7152 * linearize(g) + 0.0722 * linearize(b);
82
+ }
83
+
84
+ // Takes two `#rrggbb` strings (no alpha; the caller composites first) and
85
+ // returns the raw ratio, unrounded.
86
+ export function contrastRatio(hexA, hexB) {
87
+ const lumA = relativeLuminance(parseHex(hexA));
88
+ const lumB = relativeLuminance(parseHex(hexB));
89
+ const lighter = Math.max(lumA, lumB);
90
+ const darker = Math.min(lumA, lumB);
91
+ return (lighter + 0.05) / (darker + 0.05);
92
+ }
93
+
94
+ // `#rrggbb` or `#rrggbbaa` → `{ r, g, b, a }` with `a` in 0-1. `null` for
95
+ // anything else.
96
+ export function parseHex(hex) {
97
+ if (typeof hex !== 'string') return null;
98
+ const match = /^#([0-9a-fA-F]{6}|[0-9a-fA-F]{8})$/.exec(hex);
99
+ if (!match) return null;
100
+ const digits = match[1];
101
+ const r = parseInt(digits.slice(0, 2), 16);
102
+ const g = parseInt(digits.slice(2, 4), 16);
103
+ const b = parseInt(digits.slice(4, 6), 16);
104
+ const a = digits.length === 8 ? parseInt(digits.slice(6, 8), 16) / 255 : 1;
105
+ return { r, g, b, a };
106
+ }
107
+
108
+ // The `#rrggbb` of `fg` drawn over `bg` with src-over (`out = fg*a + bg*(1-a)`).
109
+ export function composite(fgHex, bgHex) {
110
+ const fg = parseHex(fgHex);
111
+ if (fg.a === 0) return bgHex;
112
+ if (fg.a === 1) return fgHex.slice(0, 7);
113
+ const bg = parseHex(bgHex);
114
+ const mix = (fgChannel, bgChannel) => Math.round(fgChannel * fg.a + bgChannel * (1 - fg.a));
115
+ const toHex = (value) => value.toString(16).padStart(2, '0');
116
+ return `#${toHex(mix(fg.r, bg.r))}${toHex(mix(fg.g, bg.g))}${toHex(mix(fg.b, bg.b))}`;
117
+ }
@@ -1,9 +1,12 @@
1
1
  // Shared DTCG reading: flatten a token tree to dot-paths, resolve {alias} chains.
2
- // Zero dependencies. Consumed by validate-crosswalk.mjs and validate-token-output.mjs,
3
- // and copied alongside both when a skill installs either gate.
2
+ // Zero dependencies. Consumed by validate-crosswalk.mjs, validate-token-output.mjs,
3
+ // validate-adherence.mjs, verify-check.mjs and lib/sd-native.mjs, and copied
4
+ // alongside them when a skill installs any of those.
4
5
 
5
6
  const REF = /^\{([^}]+)\}$/;
6
7
 
8
+ const isPlainObject = (v) => v !== null && typeof v === 'object' && !Array.isArray(v);
9
+
7
10
  // The typographic member names DTCG §9.8 fixes at MUST level, the unit gate a
8
11
  // text-role dimension must pass, and this project's $extensions namespace.
9
12
  //
@@ -16,6 +19,42 @@ export const TEXT_UNIT_NAMES = new Set(['fontSize', 'letterSpacing', 'lineHeight
16
19
  export const TEXT_ROLE_UNIT = /^-?(?:\d+(?:\.\d+)?|\.\d+)(?:px|rem|em)$/;
17
20
  export const EXT_NS = 'com.radicool.throughline';
18
21
 
22
+ // Read this project's $extensions namespace off a node, refusing a shape that
23
+ // cannot hold one.
24
+ //
25
+ // A DTCG $extensions namespace key may hold ANY JSON value, so a source that
26
+ // authored ours as a string is CONFORMANT input this module does not handle —
27
+ // not malformed input. That distinction is why it gets a diagnostic rather than
28
+ // a shrug: the module's contract is that it consumes conformant DTCG.
29
+ //
30
+ // Before #62 the three places that read this namespace all hit the `in`
31
+ // operator on a primitive and threw a bare TypeError naming no token, no path
32
+ // and no value — out of step with every other diagnostic here. nativeSources
33
+ // names the colliding path and both files; the hoist-collision throw names both
34
+ // paths and the value it would overwrite; nativePlatform names the unknown
35
+ // platform and the expected set.
36
+ export function extNamespace(node, path) {
37
+ const ext = node.$extensions;
38
+ if (ext === undefined) return undefined;
39
+ if (!isPlainObject(ext)) {
40
+ throw new Error(
41
+ `token "${path}" has a $extensions that is ${JSON.stringify(ext)}, not an object.\n` +
42
+ 'DTCG 5.4 makes $extensions a map of namespace keys. Remove it, or give it the shape ' +
43
+ `{ "${EXT_NS}": { "nativeUnit": "text" } }.`,
44
+ );
45
+ }
46
+ const ns = ext[EXT_NS];
47
+ if (ns === undefined) return undefined;
48
+ if (!isPlainObject(ns)) {
49
+ throw new Error(
50
+ `token "${path}" has $extensions["${EXT_NS}"] set to ${JSON.stringify(ns)}, not an object.\n` +
51
+ `The namespace holds named settings, so a bare value cannot be read. Write ` +
52
+ `{ "nativeUnit": ${JSON.stringify(ns)} } if that is the setting you meant.`,
53
+ );
54
+ }
55
+ return ns;
56
+ }
57
+
19
58
  // Flatten nested DTCG groups into { "dot.path": rawValue }. Skips $-prefixed meta keys.
20
59
  //
21
60
  // A node carrying BOTH a $value and children yields its own value AND is descended
@@ -63,6 +102,87 @@ export function flattenDtcgTypes(obj, prefix = [], out = {}, groupType = undefin
63
102
  return out;
64
103
  }
65
104
 
105
+ // DTCG 5.2.2 PLUS the one repair this pipeline applies on top of it, so that
106
+ // anything reasoning about what the build emits reads the same types the build
107
+ // used. flattenDtcgTypes is the spec; this is the spec as this build resolves it.
108
+ //
109
+ // The repair is hoistDualNodes' $type carry. A dual node is a token, not a group
110
+ // (DTCG 6.1), so it is not its children's inheritance source and 5.2.2 gives an
111
+ // untyped child of one no type at all. The hoist then makes that child a sibling
112
+ // of the dual node, which destroys the last relationship it had — so where NO
113
+ // enclosing group supplies a type, the hoist stamps the dual node's own. That is
114
+ // a repair for what the hoist broke, not a reading of the source, which is why
115
+ // it does not belong in flattenDtcgTypes.
116
+ //
117
+ // It has to be modelled somewhere, though, because two things that reason about
118
+ // types could not see it: classification, which runs before the hoist and so
119
+ // declined a child the pipeline goes on to type (#89), and the unitless-dimension
120
+ // advisory, which reads the raw source (#71). Both were silent on shapes the
121
+ // build handles.
122
+ //
123
+ // The conditions mirror hoistDualNodes exactly — an untyped TOKEN child (#67
124
+ // restricted it to those), a dual node that has a $type, no enclosing group type,
125
+ // and a value that is not a whole-value reference. That last one is why this must
126
+ // run on the RAW tree: resolveInPlace rewrites a reference to its literal, and
127
+ // the WeakSet the hoist consults for it does not survive into a fresh call.
128
+ //
129
+ // Keyed on pre-hoist paths, like flattenDtcgTypes, because every consumer of this
130
+ // map runs before the hoist or reports against source paths.
131
+ export function flattenPipelineTypes(dict) {
132
+ const types = flattenDtcgTypes(dict);
133
+ (function walk(node, prefix, groupType) {
134
+ const inherited = '$value' in node ? groupType : (node.$type ?? groupType);
135
+ for (const [key, val] of Object.entries(node)) {
136
+ if (key.startsWith('$') || !isPlainObject(val)) continue;
137
+ const path = [...prefix, key];
138
+ if ('$value' in val && '$type' in val && inherited === undefined) {
139
+ for (const [childKey, childVal] of Object.entries(val)) {
140
+ if (childKey.startsWith('$') || !isPlainObject(childVal)) continue;
141
+ if ('$value' in childVal && !('$type' in childVal) && !REF.test(String(childVal.$value).trim())) {
142
+ types[[...path, childKey].join('.')] = val.$type;
143
+ }
144
+ }
145
+ }
146
+ walk(val, path, inherited);
147
+ }
148
+ })(dict, [], undefined);
149
+ return types;
150
+ }
151
+
152
+ // Collect nodes carrying BOTH a $value and child tokens or groups.
153
+ //
154
+ // This shape is invalid DTCG. Format Module, Draft Community Group Report of
155
+ // 30 July 2026, §6.1: "The presence of a $value property definitively identifies
156
+ // an object as a token. If an object contains both $value and child
157
+ // tokens/groups, this creates an invalid structure where the object cannot be
158
+ // both a token and a group simultaneously. Tools MUST report this as an error."
159
+ // The prohibition is deliberate rather than an oversight — §6.2 defines $root as
160
+ // the sanctioned way for a group to carry a base value alongside children, which
161
+ // is exactly what a dual node is reaching for.
162
+ //
163
+ // Collecting them is NOT a step towards rejecting them. Figma-derived sources
164
+ // emit dual nodes by the dozen, hoistDualNodes exists precisely to handle them,
165
+ // and refusing them would make this tool useless against the sources it targets.
166
+ // That behaviour is unchanged. What was missing is telling the author their
167
+ // source is non-conforming, which nothing did — so someone hand-authoring
168
+ // text.sm with both a value and a lineHeight child had no way to learn that
169
+ // $root is the blessed spelling.
170
+ //
171
+ // Lives here because flattenDtcg already walks this tree and already descends
172
+ // into dual nodes on purpose, so the knowledge is present and only the reporting
173
+ // was absent.
174
+ export function findDualNodes(obj, prefix = [], out = []) {
175
+ for (const [key, val] of Object.entries(obj)) {
176
+ if (key.startsWith('$') || !isPlainObject(val)) continue;
177
+ const path = [...prefix, key];
178
+ if ('$value' in val && Object.entries(val).some(([k, v]) => !k.startsWith('$') && isPlainObject(v))) {
179
+ out.push(path.join('.'));
180
+ }
181
+ findDualNodes(val, path, out);
182
+ }
183
+ return out;
184
+ }
185
+
66
186
  // Follow {alias} chains to a leaf literal. Throws on missing or circular refs.
67
187
  export function resolveValue(name, flat, seen = new Set()) {
68
188
  if (!(name in flat)) throw new Error(`token "${name}" not found in DTCG source`);
@@ -104,7 +224,6 @@ export function findModeCollisions(sources) {
104
224
  //
105
225
  // Each source is cloned on the way in. Merging the caller's own objects would
106
226
  // 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
227
 
109
228
  function mergeInto(target, src) {
110
229
  for (const [key, val] of Object.entries(src)) {
@@ -196,7 +315,7 @@ export function textRoleGraph(dict) {
196
315
  types[dotted] === 'dimension' &&
197
316
  TEXT_ROLE_UNIT.test(String(val.$value).trim()) &&
198
317
  !referrers.has(dotted) &&
199
- !('nativeUnit' in (val.$extensions?.[EXT_NS] ?? {})) &&
318
+ !('nativeUnit' in (extNamespace(val, dotted) ?? {})) &&
200
319
  inferredGroups.has(group)
201
320
  ) {
202
321
  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);
@@ -0,0 +1,186 @@
1
+ // The proof-bundle store (`docs/specs/2026-09-12-verification-proof-bundle.md`,
2
+ // step 4): load, merge and fingerprint the per-stage files at
3
+ // `design-system/proof/<stage>.json`, and read the manifest's adoption state
4
+ // for each stage. Zero dependencies.
5
+ //
6
+ // See `references/proof-bundle.md` for the file shape and the vocabulary this
7
+ // module defines.
8
+ import { existsSync, readFileSync } from 'node:fs';
9
+ import { join } from 'node:path';
10
+ import { stableStringify, fingerprint } from './doc-record.mjs';
11
+
12
+ export const PROOF_DIR = 'design-system/proof';
13
+
14
+ // The stage vocabulary. A change here must also update the stage table in
15
+ // `references/proof-bundle.md`: the constant and the prose are one vocabulary,
16
+ // and a stage this list accepts but the reference does not name is a stage
17
+ // nobody knows how to record.
18
+ //
19
+ // `token-builder` is system-wide. The vocabulary names the places where the
20
+ // negative conditions' evidence is produced, and token creation is now one of
21
+ // them — it is the cheapest place in the system to catch a mode built with poor
22
+ // contrast, because the mode is being defined right then and nothing is built
23
+ // on it yet. Its subject is the whole colour system, never a component.
24
+ export const STAGES = [
25
+ 'component-builder',
26
+ 'storybook-chromatic-builder',
27
+ 'token-sync-layer',
28
+ 'token-builder',
29
+ ];
30
+
31
+ // Which stages are keyed by component (subject = component name) rather than
32
+ // by the system as a whole (subject = "system"). This is the machine-readable
33
+ // copy of the split `references/proof-bundle.md` documents in prose — it is
34
+ // the constant `checkProof` asks "is this stage keyed by component?".
35
+ export const PER_COMPONENT_STAGES = new Set([
36
+ 'component-builder',
37
+ 'storybook-chromatic-builder',
38
+ ]);
39
+
40
+ // Tells the reader (Step 6's `checkProof`) how to compare a recorded derived
41
+ // check against a rerun: a check scoped to "component" is compared per
42
+ // subject; one scoped to "system" is compared once, system-wide, regardless
43
+ // of which subject it was recorded under.
44
+ export const DERIVED_RULE_SCOPE = {
45
+ 'orphan-token': 'system',
46
+ 'state-incomplete': 'component',
47
+ 'name-drift': 'component',
48
+ // Contrast is a property of the token system as a whole, not of any one
49
+ // component, so a recorded result is compared against the rerun system-wide —
50
+ // the same scope orphan-token has.
51
+ 'color-contrast': 'system',
52
+ };
53
+
54
+ // The names this stage has not yet proven, per the Adoption decision's
55
+ // capture-once-then-shrink rule. A missing `exempt` key is deliberately read
56
+ // as "exempts nothing", not "exempts everything": the only way to reach a
57
+ // stage entry with no `exempt` key is a hand-edited manifest (record mode
58
+ // always writes one for a per-component stage), and the off-switch reading
59
+ // would let a hand-edit silently stop the gate. A missing key must get
60
+ // louder, not quieter.
61
+ export function stageExempt(manifest, stage) {
62
+ return new Set(manifest.verification?.stages?.[stage]?.exempt ?? []);
63
+ }
64
+
65
+ // The human-readable "when did this system opt in", written once by record
66
+ // mode and never rewritten. No gate reads it.
67
+ //
68
+ // It is not compared against `components.meta[name].updatedAt`, and `exempt`
69
+ // is a captured list of names rather than a date comparison, for the same
70
+ // reason: `updatedAt` has a second writer.
71
+ // `storybook-chromatic-builder` refreshes it on promotion
72
+ // (`skills/storybook-chromatic-builder/SKILL.md:276-277`), a different stage
73
+ // from the one that owes the entry, so a date gate would fail a pre-existing
74
+ // component the first time an unrelated storying run promotes it.
75
+ //
76
+ // Neither `adoptedAt` nor `exempt` is derived from the stage file's entries,
77
+ // either: `mergeEntry` is latest-wins and replaces a subject's `at`, so "the
78
+ // earliest `at` in the file" would slide forward every time the earliest
79
+ // subject is rebuilt.
80
+ export function stageAdoptedAt(manifest, stage) {
81
+ return manifest.verification?.stages?.[stage]?.adoptedAt ?? null;
82
+ }
83
+
84
+ export function stagePath(root, stage) {
85
+ return join(root, PROOF_DIR, `${stage}.json`);
86
+ }
87
+
88
+ export function loadStage(root, stage) {
89
+ const path = stagePath(root, stage);
90
+ if (!existsSync(path)) return null;
91
+ return JSON.parse(readFileSync(path, 'utf8'));
92
+ }
93
+
94
+ export function stageFingerprint(stageFile) {
95
+ return fingerprint(stableStringify(stageFile));
96
+ }
97
+
98
+ const CHECK_METHODS = ['derived', 'attested'];
99
+ const CHECK_RESULTS = ['pass', 'fail'];
100
+
101
+ export function entryProblems(entry) {
102
+ const problems = [];
103
+ if (typeof entry.at !== 'string' || entry.at === '') {
104
+ problems.push('"at" must be a non-empty string');
105
+ }
106
+ if (typeof entry.advancedBecause !== 'string' || entry.advancedBecause === '') {
107
+ problems.push('"advancedBecause" must be a non-empty string');
108
+ }
109
+ if (!Array.isArray(entry.changed) || !entry.changed.every((c) => typeof c === 'string')) {
110
+ problems.push('"changed" must be an array of strings');
111
+ }
112
+ if (!Array.isArray(entry.checks) || entry.checks.length === 0) {
113
+ problems.push('"checks" must be a non-empty array');
114
+ } else {
115
+ entry.checks.forEach((check, i) => {
116
+ if (!check || typeof check !== 'object') {
117
+ problems.push(`checks[${i}] must be an object`);
118
+ return;
119
+ }
120
+ if (typeof check.name !== 'string' || check.name === '') {
121
+ problems.push(`checks[${i}].name must be a non-empty string`);
122
+ }
123
+ if (!CHECK_METHODS.includes(check.method)) {
124
+ problems.push(`checks[${i}].method must be one of ${CHECK_METHODS.join(', ')}`);
125
+ }
126
+ if (!CHECK_RESULTS.includes(check.result)) {
127
+ problems.push(`checks[${i}].result must be one of ${CHECK_RESULTS.join(', ')}`);
128
+ }
129
+ if (typeof check.evidence !== 'string') {
130
+ problems.push(`checks[${i}].evidence must be a string`);
131
+ }
132
+ // Optional, and only `contrast-baseline` uses it today — but validated
133
+ // because it is load-bearing: token-sync-layer subtracts these (fg, bg,
134
+ // mode) triples from its own gate run's color-contrast failures, so a
135
+ // malformed one is the difference between a pair the user accepted and a
136
+ // pair nobody has seen. Reject it here rather than letting the sync
137
+ // silently match nothing.
138
+ if (check.accepted !== undefined) {
139
+ if (!Array.isArray(check.accepted)) {
140
+ problems.push(`checks[${i}].accepted must be an array when present`);
141
+ } else {
142
+ check.accepted.forEach((pair, j) => {
143
+ const wellFormed =
144
+ pair &&
145
+ typeof pair === 'object' &&
146
+ ['fg', 'bg', 'mode'].every((k) => typeof pair[k] === 'string' && pair[k] !== '');
147
+ if (!wellFormed) {
148
+ problems.push(
149
+ `checks[${i}].accepted[${j}] must carry non-empty "fg", "bg" and "mode" strings`,
150
+ );
151
+ }
152
+ });
153
+ }
154
+ }
155
+ });
156
+ }
157
+ if (entry.screenshot !== undefined && typeof entry.screenshot !== 'string') {
158
+ problems.push('"screenshot" must be a string when present');
159
+ }
160
+ return problems;
161
+ }
162
+
163
+ export function mergeEntry(stageFile, stage, subject, entry) {
164
+ const subjects = { ...(stageFile?.subjects ?? {}), [subject]: entry };
165
+ const sortedSubjects = {};
166
+ for (const key of Object.keys(subjects).sort()) {
167
+ sortedSubjects[key] = subjects[key];
168
+ }
169
+ return { stage, subjects: sortedSubjects };
170
+ }
171
+
172
+ export function recordedChecks(stageFile) {
173
+ const out = [];
174
+ for (const [subject, entry] of Object.entries(stageFile.subjects ?? {})) {
175
+ for (const check of entry.checks ?? []) {
176
+ out.push({
177
+ subject,
178
+ name: check.name,
179
+ method: check.method,
180
+ result: check.result,
181
+ evidence: check.evidence,
182
+ });
183
+ }
184
+ }
185
+ return out;
186
+ }