@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
@@ -0,0 +1,710 @@
1
+ // verify:check — the verification proof bundle gate. Reads the per-stage proof
2
+ // entries at design-system/proof/<stage>.json, re-derives off disk every check
3
+ // recorded as `derived`, and fails when a stored result and a fresh run
4
+ // disagree. Zero dependencies.
5
+ //
6
+ // Failing: orphan-token | state-incomplete | name-drift | color-contrast
7
+ // | proof-missing | proof-stale | proof-contradicted
8
+ // | nothing-verified | orphan-rule-inert | contrast-rule-inert
9
+ // Informational: archetype-unknown | proof-unadopted
10
+ // (archetype-unknown = a doc record whose archetype neither its own `archetype`
11
+ // field nor its name resolves, so no baseline state set applies to it.
12
+ // proof-unadopted = a manifest with no `verification` key at all: the system
13
+ // has not opted into the bundle, so the proof-integrity checks are skipped and
14
+ // the four derived rules still run.)
15
+ //
16
+ // Usage: node verify-check.mjs [--root <dir>] [--tokens <file>]... [--source <dir>]... [--skip <rule>]...
17
+ // node verify-check.mjs --record --stage <name> [--subject <Name>] --entry <file.json>
18
+ //
19
+ // Design: docs/specs/2026-09-12-verification-proof-bundle.md
20
+ import { existsSync, mkdirSync, readFileSync, readdirSync, realpathSync, writeFileSync } from 'node:fs';
21
+ import { basename, dirname, join, relative, sep } from 'node:path';
22
+ import { parseArgs } from 'node:util';
23
+ import { pathToFileURL } from 'node:url';
24
+ import { loadRecord } from './lib/doc-record.mjs';
25
+ import { flattenDtcg } from './lib/dtcg.mjs';
26
+ import { walk, normalizeName } from './lib/source-scan.mjs';
27
+ import { missingStates, resolveArchetype } from './lib/component-states.mjs';
28
+ import { AA_NORMAL_TEXT, CONTRAST_PAIRS, contrastRatio, parseHex, composite } from './lib/contrast.mjs';
29
+ import { tokenPackageDirs, normalizeHex, rgbToHex, resolvedTokens } from './validate-adherence.mjs';
30
+ import {
31
+ DERIVED_RULE_SCOPE,
32
+ PER_COMPONENT_STAGES,
33
+ PROOF_DIR,
34
+ STAGES,
35
+ entryProblems,
36
+ loadStage,
37
+ mergeEntry,
38
+ recordedChecks,
39
+ stageExempt,
40
+ stageFingerprint,
41
+ stagePath,
42
+ } from './lib/proof.mjs';
43
+
44
+ // The manifest version this gate's `verification` pointer ships in.
45
+ const SCHEMA_VERSION = 7;
46
+
47
+ // The fold validate-token-output.mjs calls `normalizeKey`: it collapses every
48
+ // adapter naming convention (color.bg.primary, --color-bg-primary, colorBgPrimary)
49
+ // onto one spelling. Re-declared here rather than imported because that script
50
+ // is not in this gate's install set, and importing it would drag it in.
51
+ export const normalizeText = (s) => String(s).toLowerCase().replace(/[^a-z0-9]/g, '');
52
+
53
+ // A DTCG reference anywhere in a string value — `{color.bg.primary}` on its own,
54
+ // or embedded in an expression.
55
+ const REF_ANYWHERE = /\{([^{}]+)\}/g;
56
+ const REF_IN_STRING = /\{[^{}]+\}/;
57
+
58
+ // Every token path referenced by any token's `$value`. An object value's string
59
+ // members count too: a composite token (a shadow, a typography role) holds its
60
+ // references one level down.
61
+ export function aliasTargets(flat) {
62
+ const out = new Set();
63
+ const collect = (value) => {
64
+ if (typeof value === 'string') {
65
+ for (const match of value.matchAll(REF_ANYWHERE)) out.add(match[1]);
66
+ } else if (value && typeof value === 'object') {
67
+ for (const member of Object.values(value)) collect(member);
68
+ }
69
+ };
70
+ for (const value of Object.values(flat)) collect(value);
71
+ return out;
72
+ }
73
+
74
+ // A semantic token nothing names: no other token aliases it, no scanned source
75
+ // file mentions it, and no doc record lists it in `tokensUsed`.
76
+ //
77
+ // Only tokens whose own `$value` is an alias are candidates. In a two-tier
78
+ // system that is the semantic tier by construction, and the semantic tier is
79
+ // what code is meant to consume — a primitive is legitimately reached only
80
+ // through a semantic, so gating one would fail a correct system.
81
+ //
82
+ // A token is also bound by its path without the first segment (#137). Code
83
+ // consumes what the build emitted, and a build renames the category: a
84
+ // Tailwind preset turns `spacing.inset.lg` into `p-inset-lg`, and a hand-written
85
+ // build into `--space-inset-lg`. Neither contains `spacing`. Measured on
86
+ // throughline-ds, matching the full path alone failed 49 used tokens out of 61
87
+ // flagged; with the tail, none, and every real orphan still fails
88
+ // (docs/notes/2026-09-29-proof-bundle-rules-measurement.md).
89
+ //
90
+ // LIMIT, stated rather than hidden: binding evidence is a permissive substring
91
+ // match over the normalized text, so `color.bg.primary` counts as bound by a
92
+ // mention of `color.bg.primary.hover`, `radius.card` by any `card`, and either
93
+ // by prose that happens to contain the words. Every miss is a false negative —
94
+ // an orphan reported as bound — never a false failure, which is the direction a
95
+ // failing rule has to err in. `fileTexts` entries are pre-normalized by the
96
+ // caller.
97
+ export function checkOrphanTokens({ flat, fileTexts = [], records = new Map() }) {
98
+ const targets = aliasTargets(flat);
99
+ const recordTokens = new Set();
100
+ for (const record of records.values()) {
101
+ for (const token of record.tokensUsed ?? []) {
102
+ if (typeof token === 'string') recordTokens.add(normalizeText(token));
103
+ }
104
+ }
105
+
106
+ const failures = [];
107
+ let candidates = 0;
108
+ for (const [path, value] of Object.entries(flat)) {
109
+ if (typeof value !== 'string' || !REF_IN_STRING.test(value)) continue;
110
+ candidates += 1;
111
+ const key = normalizeText(path);
112
+ const segments = path.split('.');
113
+ const tail = segments.length > 1 ? normalizeText(segments.slice(1).join('.')) : null;
114
+ const bound =
115
+ targets.has(path) ||
116
+ fileTexts.some((text) => text.includes(key) || (tail !== null && text.includes(tail))) ||
117
+ recordTokens.has(key);
118
+ if (!bound) failures.push({ rule: 'orphan-token', token: path });
119
+ }
120
+ return { failures, inert: candidates === 0, candidates };
121
+ }
122
+
123
+ // Every doc record owes the unconditional baseline interaction states of its
124
+ // archetype. A record whose archetype neither resolves is reported
125
+ // informationally and asserted against nothing — a conservative resolver
126
+ // produces no false failures.
127
+ export function checkStates({ records = new Map() }) {
128
+ const failures = [];
129
+ const informational = [];
130
+ for (const [name, record] of records) {
131
+ if (resolveArchetype(record) === null) {
132
+ informational.push({ rule: 'archetype-unknown', name });
133
+ continue;
134
+ }
135
+ const missing = missingStates(record);
136
+ if (missing.length > 0) failures.push({ rule: 'state-incomplete', name, missing });
137
+ }
138
+ return { failures, informational };
139
+ }
140
+
141
+ // One component, three spellings. The manifest name, the record's own `name`,
142
+ // and the code surface's path (`packages/ui/src/Button/Button.mdx` → `Button`
143
+ // twice) are the spellings a CLI can read; the Figma spelling is the executor's
144
+ // attested check. Folded with `normalizeName`, they must all agree.
145
+ export function checkNames({ built = [], meta = {}, records = new Map() }) {
146
+ const failures = [];
147
+ for (const name of built) {
148
+ const spellings = { manifest: name };
149
+ const record = records.get(name);
150
+ if (record && typeof record.name === 'string') spellings.record = record.name;
151
+
152
+ const file = meta[name]?.doc?.surfaces?.storybookMdx?.file;
153
+ if (typeof file === 'string' && file !== '') {
154
+ spellings.file = basename(file).replace(/\.[^.]+$/, '');
155
+ // A surface recorded as a bare filename has no parent directory to
156
+ // compare, and reading `.` as a spelling would fail every one of them.
157
+ const parent = dirname(file);
158
+ if (parent !== '.' && parent !== '' && parent !== '/') spellings.dir = basename(parent);
159
+ }
160
+
161
+ const folded = new Set(Object.values(spellings).map(normalizeName));
162
+ if (folded.size > 1) failures.push({ rule: 'name-drift', name, spellings });
163
+ }
164
+ return failures;
165
+ }
166
+
167
+ // A pair is evaluated once per mode, and has to clear in every mode it is
168
+ // evaluated in — a system that passes in Light and fails in Dark fails, full
169
+ // stop, because the failure list carries the mode and nothing averages across
170
+ // them. A pair whose roles are absent from a mode abstains rather than
171
+ // passing: that mode contributes nothing for that pair, which is why
172
+ // `contrast-rule-inert` exists — a rule that recognised no role anywhere must
173
+ // not read as a clean run.
174
+ //
175
+ // Alpha: a translucent foreground has one correct answer over a known
176
+ // background, so it is composited and compared. A translucent background does
177
+ // not — what is behind it is a layout fact the token tier does not hold — so
178
+ // that pair is skipped and counted rather than guessed at.
179
+ //
180
+ // The returned `modes` is the number of modes in which at least one pair was
181
+ // actually compared, which is NOT the number of modes passed in: a primitives
182
+ // file is a mode that holds no semantic role and contributes nothing. It is the
183
+ // number the report's `contrast:` line carries, because "compared across N
184
+ // modes" is the reading that catches a multi-mode system registered with one
185
+ // mode file.
186
+ export function checkContrast({ modes }) {
187
+ const failures = [];
188
+ const skipped = { unresolvable: 0, nonHex: 0, alphaBackground: 0 };
189
+ let pairs = 0;
190
+ let modesCompared = 0;
191
+
192
+ for (const { mode, values, unresolved } of modes) {
193
+ let comparedHere = 0;
194
+ const resolved = new Map();
195
+ for (const [path, value] of values) resolved.set(normalizeText(path), value);
196
+ const unresolvedFolded = new Set([...unresolved].map(normalizeText));
197
+
198
+ for (const pair of CONTRAST_PAIRS) {
199
+ const fgKey = normalizeText(pair.fg);
200
+ const bgKey = normalizeText(pair.bg);
201
+ const fgDefined = resolved.has(fgKey) || unresolvedFolded.has(fgKey);
202
+ const bgDefined = resolved.has(bgKey) || unresolvedFolded.has(bgKey);
203
+ // A side absent from both this mode's resolved values and its unresolved
204
+ // set means the mode does not define it at all — not evaluated, not
205
+ // counted anywhere.
206
+ if (!fgDefined || !bgDefined) continue;
207
+
208
+ if (unresolvedFolded.has(fgKey) || unresolvedFolded.has(bgKey)) {
209
+ skipped.unresolvable += 1;
210
+ continue;
211
+ }
212
+
213
+ const fgValue = resolved.get(fgKey);
214
+ const bgValue = resolved.get(bgKey);
215
+ const fgHex = normalizeHex(fgValue) ?? rgbToHex(fgValue);
216
+ const bgHex = normalizeHex(bgValue) ?? rgbToHex(bgValue);
217
+ if (!fgHex || !bgHex) {
218
+ skipped.nonHex += 1;
219
+ continue;
220
+ }
221
+
222
+ const bgParsed = parseHex(bgHex);
223
+ if (bgParsed.a < 1) {
224
+ skipped.alphaBackground += 1;
225
+ continue;
226
+ }
227
+
228
+ const fgParsed = parseHex(fgHex);
229
+ const fgComposited = fgParsed.a < 1 ? composite(fgHex, bgHex) : fgHex;
230
+
231
+ const ratio = contrastRatio(fgComposited, bgHex);
232
+ pairs += 1;
233
+ comparedHere += 1;
234
+ if (ratio < pair.threshold) {
235
+ failures.push({
236
+ rule: 'color-contrast',
237
+ mode,
238
+ fg: pair.fg,
239
+ bg: pair.bg,
240
+ fgValue,
241
+ bgValue,
242
+ ratio,
243
+ threshold: pair.threshold,
244
+ });
245
+ }
246
+ }
247
+ if (comparedHere > 0) modesCompared += 1;
248
+ }
249
+
250
+ return { failures, skipped, pairs, modes: modesCompared };
251
+ }
252
+
253
+ // Does this stage owe this component an entry yet?
254
+ //
255
+ // The manifest states the lifecycle outright (references/manifest-schema.md):
256
+ // a component is created at `draft` by `component-builder` and promoted to
257
+ // `stable` by `storybook-chromatic-builder` once its code and stories are built
258
+ // and approved. A `draft` component is one that has not reached the storying
259
+ // stage — the normal state between the two skills — and flagging it would fail
260
+ // every component built in Figma and not yet storied.
261
+ export function stageOwes(stage, name, meta = {}) {
262
+ if (stage === 'component-builder') return true;
263
+ if (stage === 'storybook-chromatic-builder') return meta[name]?.status === 'stable';
264
+ return false;
265
+ }
266
+
267
+ // The proof-integrity layer: is the store there, does it match its fingerprint,
268
+ // does every component the stage owes an entry have one, and does every result
269
+ // recorded as `derived` still agree with this run?
270
+ export function checkProof({ manifest, root, derived = [], built = [], meta = {}, skipped = new Set() }) {
271
+ const failures = [];
272
+ const informational = [];
273
+
274
+ // A system that never adopted the bundle is not a system that stopped
275
+ // proving things: report it and skip the integrity checks entirely. The
276
+ // four derived rules still run — they do not depend on the store.
277
+ if (!manifest.verification) {
278
+ informational.push({ rule: 'proof-unadopted' });
279
+ return { failures, informational, stagesRead: 0 };
280
+ }
281
+
282
+ let stagesRead = 0;
283
+ for (const [stage, pointer] of Object.entries(manifest.verification.stages ?? {})) {
284
+ const stageFile = loadStage(root, stage);
285
+ // This class owns "the file is gone"; proof-stale is about a file that
286
+ // exists and disagrees with its hash, so the two never fire together here.
287
+ if (stageFile === null) {
288
+ failures.push({ rule: 'proof-missing', stage });
289
+ continue;
290
+ }
291
+ stagesRead += 1;
292
+ if (stageFingerprint(stageFile) !== pointer.fingerprint) {
293
+ failures.push({ rule: 'proof-stale', stage });
294
+ }
295
+
296
+ if (PER_COMPONENT_STAGES.has(stage)) {
297
+ const exempt = stageExempt(manifest, stage);
298
+ for (const name of built) {
299
+ if (stageFile.subjects?.[name]) continue;
300
+ // Two independent gates, each ruling out a different false failure:
301
+ // the stage must owe it an entry by lifecycle, and it must not be
302
+ // grandfathered. Do NOT compare meta[name].updatedAt against
303
+ // adoptedAt, however natural it looks: `updatedAt` is refreshed by
304
+ // storybook-chromatic-builder on promotion, so a single storying run
305
+ // drags every pre-existing component past component-builder's
306
+ // adoption moment and the gate fails a system where nothing went
307
+ // wrong. The stated false negative is that a grandfathered component
308
+ // is never proven.
309
+ if (!stageOwes(stage, name, meta)) continue;
310
+ if (exempt.has(name)) continue;
311
+ failures.push({ rule: 'proof-missing', stage, name });
312
+ }
313
+ }
314
+
315
+ for (const check of recordedChecks(stageFile)) {
316
+ if (check.method !== 'derived') continue;
317
+ const scope = DERIVED_RULE_SCOPE[check.name];
318
+ // A rule this run did not compute cannot contradict anything, and
319
+ // pretending otherwise would fail a repo for using --skip.
320
+ if (!scope || skipped.has(check.name)) continue;
321
+ const ruleFailures = derived.filter((f) => f.rule === check.name);
322
+ const rerunFailed =
323
+ scope === 'component'
324
+ ? ruleFailures.some((f) => f.name === check.subject)
325
+ : ruleFailures.length > 0;
326
+ if (rerunFailed !== (check.result === 'fail')) {
327
+ failures.push({
328
+ rule: 'proof-contradicted',
329
+ stage,
330
+ subject: check.subject,
331
+ check: check.name,
332
+ recorded: check.result,
333
+ rerun: rerunFailed ? 'fail' : 'pass',
334
+ });
335
+ }
336
+ }
337
+ }
338
+
339
+ return { failures, informational, stagesRead };
340
+ }
341
+
342
+ function failureDetail(f) {
343
+ switch (f.rule) {
344
+ case 'orphan-token':
345
+ return `${f.token} — an alias no other token references, no scanned file mentions and no doc record lists in tokensUsed. Bind it, delete it, or --skip orphan-token if this repo's consumers are outside --source.`;
346
+ case 'state-incomplete':
347
+ return `${f.name} documents no ${f.missing.join(', ')} state — its archetype's baseline requires ${f.missing.length === 1 ? 'it' : 'them'}. Add ${f.missing.length === 1 ? 'it' : 'them'} to the record's "states" with /document-component.`;
348
+ case 'name-drift':
349
+ return `${f.name} is spelled ${Object.entries(f.spellings).map(([k, v]) => `${v} (${k})`).join(', ')} — one component, more than one name. Rename so the manifest, the record and the code surface agree.`;
350
+ case 'proof-missing':
351
+ return f.name
352
+ ? `${f.stage} owes ${f.name} an entry and has none — record one with verify-check.mjs --record --stage ${f.stage} --subject ${f.name}.`
353
+ : `${f.stage} is listed in design-system.json verification.stages, but ${PROOF_DIR}/${f.stage}.json is gone. Restore it, or re-record the stage's entries.`;
354
+ case 'proof-stale':
355
+ return `${PROOF_DIR}/${f.stage}.json does not match the fingerprint in design-system.json — it was edited outside the recorder. Re-record the stage's entries rather than editing the file.`;
356
+ case 'proof-contradicted':
357
+ return `${f.stage} recorded ${f.check} as "${f.recorded}" for ${f.subject}, but this run derives "${f.rerun}". A derived result is a cache, not a claim — fix what it found, or re-record it.`;
358
+ case 'color-contrast':
359
+ return `${f.fg} on ${f.bg} is ${f.ratio.toFixed(2)}:1 in ${f.mode} (${f.fgValue} on ${f.bgValue}) — WCAG AA needs ${f.threshold}:1 for normal text. Re-point one side of the pair at a primitive with more separation in that mode; the pair has to clear in every mode, not on average.`;
360
+ case 'nothing-verified':
361
+ return 'the enabled rules examined nothing: no alias token candidate, no doc record, no component in components.built, no colour pair compared. Check --root points at the design system, and that --tokens names a real token source.';
362
+ case 'orphan-rule-inert':
363
+ return 'no token source yielded an alias, so orphan-token checked nothing. Pass the --tokens file that holds the semantic tier, or --skip orphan-token if this system has none.';
364
+ case 'contrast-rule-inert':
365
+ return 'no token source held both sides of any checked colour pair, so color-contrast compared nothing. Pass the --tokens file that holds the semantic colour tier, or --skip color-contrast if this system names its roles differently.';
366
+ default:
367
+ return JSON.stringify(f);
368
+ }
369
+ }
370
+
371
+ function informationalDetail(i) {
372
+ switch (i.rule) {
373
+ case 'archetype-unknown':
374
+ return `${i.name} — neither an "archetype" field nor its name resolves to a known archetype, so no baseline states are asserted for it.`;
375
+ case 'proof-unadopted':
376
+ return 'design-system.json has no "verification" key, so this system has not adopted the proof bundle and the proof-integrity checks are skipped. The first verify-check.mjs --record adopts it.';
377
+ default:
378
+ return JSON.stringify(i);
379
+ }
380
+ }
381
+
382
+ // Counts SUBJECTS, not rules. A rule that ran over nothing has verified
383
+ // nothing, and a headline counting rules switched on would read as busy over an
384
+ // empty system.
385
+ export function formatReport(r) {
386
+ const s = r.stats;
387
+ const lines = [
388
+ `verify:check — ${s.components} component(s), ${s.records} doc record(s), ${s.candidates} token candidate(s), ${s.modes} token mode(s), ${s.files} file(s) scanned, ${s.stages} stage file(s) read`,
389
+ ];
390
+ for (const e of r.excluded) {
391
+ lines.push(` excluded: ${e.files} file(s) in ${e.dir}, the package that owns --tokens`);
392
+ }
393
+ // Every number on this line is a PAIR, in the mode it occurred in — which is
394
+ // why it says "pair(s)" once and means it across the whole line. Its mode
395
+ // count is the modes a pair was actually compared in, not the headline's
396
+ // count of --tokens files: a primitives file is a mode that holds no semantic
397
+ // role, and a multi-mode system registered with one --tokens flag shows up
398
+ // here as one.
399
+ if (r.contrast) {
400
+ lines.push(
401
+ ` contrast: ${r.contrast.pairs} pair(s) compared across ${r.contrast.modes} mode(s), ${r.contrast.skipped.unresolvable} skipped as unresolvable, ${r.contrast.skipped.nonHex} skipped as non-hex, ${r.contrast.skipped.alphaBackground} skipped for a translucent background`,
402
+ );
403
+ }
404
+ if (r.skipped.length) lines.push(` skipped: ${r.skipped.join(', ')}`);
405
+
406
+ if (r.failures.length) {
407
+ lines.push(`\n${r.failures.length} failure(s):`);
408
+ for (const f of r.failures) lines.push(` - [${f.rule}] ${failureDetail(f)}`);
409
+ }
410
+ if (r.informational.length) {
411
+ lines.push(`\n${r.informational.length} informational note(s) — reported, not gating:`);
412
+ for (const i of r.informational) lines.push(` ~ [${i.rule}] ${informationalDetail(i)}`);
413
+ }
414
+ return lines;
415
+ }
416
+
417
+ function readJson(path, what) {
418
+ try {
419
+ return JSON.parse(readFileSync(path, 'utf8'));
420
+ } catch (e) {
421
+ console.error(`verify:check — cannot read ${what} at ${path}: ${e.message}`);
422
+ process.exit(2);
423
+ }
424
+ }
425
+
426
+ function manifestPathOf(root) {
427
+ const path = join(root, 'design-system.json');
428
+ if (!existsSync(path)) {
429
+ console.error(`verify:check — no design-system.json at ${root}`);
430
+ process.exit(2);
431
+ }
432
+ return path;
433
+ }
434
+
435
+ function loadRecords(root, meta) {
436
+ const records = new Map();
437
+ const dir = join(root, 'design-system', 'docs', 'components');
438
+ if (existsSync(dir)) {
439
+ for (const file of readdirSync(dir)) {
440
+ if (!file.endsWith('.doc.json')) continue;
441
+ records.set(file.slice(0, -'.doc.json'.length), loadRecord(join(dir, file)));
442
+ }
443
+ }
444
+ // A manifest that points a component's record somewhere else wins for that
445
+ // name. The key comes from where the file was found, never from inside it —
446
+ // which is what makes manifest `Button` plus a record named `Buttons` a
447
+ // name-drift failure rather than a record that silently cannot be found.
448
+ for (const [name, m] of Object.entries(meta)) {
449
+ const path = m?.doc?.path;
450
+ if (typeof path === 'string' && existsSync(join(root, path))) {
451
+ records.set(name, loadRecord(join(root, path)));
452
+ }
453
+ }
454
+ return records;
455
+ }
456
+
457
+ function gateMode(values) {
458
+ const manifest = readJson(manifestPathOf(values.root), 'the manifest');
459
+ const built = manifest.components?.built ?? [];
460
+ const meta = manifest.components?.meta ?? {};
461
+ const skipped = new Set(values.skip);
462
+
463
+ // The parsed dicts are kept, not only the merged `flat`: one --tokens file is
464
+ // one mode, and color-contrast compares each mode on its own values. Merging
465
+ // first would check only whichever file was passed last — the light-only
466
+ // build findModeCollisions was written to stop, reintroduced inside the gate.
467
+ // Each file is still read exactly once.
468
+ const dicts = values.tokens.map((file) => readJson(file, 'a token source'));
469
+ const flat = {};
470
+ for (const dict of dicts) Object.assign(flat, flattenDtcg(dict));
471
+
472
+ // One mode per --tokens file, labelled by that file's basename. A token that
473
+ // resolved carries its value; one this mode defines but whose alias chain did
474
+ // not resolve goes into `unresolved` instead, so checkContrast can count the
475
+ // PAIR it broke rather than the gate counting loose tokens.
476
+ const modes = values.tokens.map((file) => ({
477
+ mode: basename(file).replace(/\.[^.]+$/, ''),
478
+ values: new Map(),
479
+ unresolved: new Set(),
480
+ }));
481
+ for (const t of resolvedTokens(dicts)) {
482
+ if (t.resolves) modes[t.source].values.set(t.path, t.value);
483
+ else modes[t.source].unresolved.add(t.path);
484
+ }
485
+
486
+ // With no --tokens there is no token source to read, so both rules that read
487
+ // one are ABSENT rather than passed: a run that checked no tokens must not
488
+ // report a clean orphan-token, and has nothing to compute a ratio from.
489
+ if (values.tokens.length === 0) {
490
+ skipped.add('orphan-token');
491
+ skipped.add('color-contrast');
492
+ }
493
+
494
+ // Partitioned after the walk rather than excluded during it, so the report
495
+ // can count what it set aside. WITHOUT THIS THE ORPHAN RULE CANNOT FIRE AT
496
+ // ALL: Style Dictionary writes every token by name into
497
+ // packages/tokens/<platform>/, SOURCE_EXT yields .css, .mjs and .js, and
498
+ // nothing in DEFAULT_EXCLUDES covers that directory — so under the
499
+ // registered `--root ../..` the generated output binds every semantic token
500
+ // to itself. Ported from validate-adherence.mjs, realpath comparison
501
+ // included: a tmpdir fixture or a symlinked checkout would otherwise never
502
+ // match the realpath'd package dirs.
503
+ const realRoot = realpathSync(values.root);
504
+ const ownerDirs = tokenPackageDirs(values.tokens, values.root);
505
+ const excludedCounts = new Map(ownerDirs.map((d) => [d, 0]));
506
+ const scanned = [];
507
+ for (const dir of values.source.length > 0 ? values.source : [values.root]) {
508
+ let walked;
509
+ try {
510
+ walked = [...walk(dir)];
511
+ } catch (e) {
512
+ console.error(`verify:check — cannot scan ${dir}: ${e.message}`);
513
+ process.exit(2);
514
+ }
515
+ const realDir = realpathSync(dir);
516
+ for (const path of walked) {
517
+ const real = join(realDir, relative(dir, path));
518
+ const owner = ownerDirs.find((d) => real.startsWith(d + sep));
519
+ if (owner) excludedCounts.set(owner, excludedCounts.get(owner) + 1);
520
+ else scanned.push(path);
521
+ }
522
+ }
523
+ const excluded = [...excludedCounts]
524
+ .filter(([, n]) => n > 0)
525
+ .map(([dir, n]) => ({ dir: join(values.root, relative(realRoot, dir)), files: n }));
526
+
527
+ const records = loadRecords(values.root, meta);
528
+ const failures = [];
529
+ const informational = [];
530
+ const derived = [];
531
+
532
+ let candidates = 0;
533
+ let orphanInert = false;
534
+ if (!skipped.has('orphan-token')) {
535
+ const fileTexts = scanned.map((path) => normalizeText(readFileSync(path, 'utf8')));
536
+ const orphans = checkOrphanTokens({ flat, fileTexts, records });
537
+ derived.push(...orphans.failures);
538
+ candidates = orphans.candidates;
539
+ orphanInert = orphans.inert;
540
+ }
541
+ if (!skipped.has('state-incomplete')) {
542
+ const states = checkStates({ records });
543
+ derived.push(...states.failures);
544
+ informational.push(...states.informational);
545
+ }
546
+ if (!skipped.has('name-drift')) {
547
+ derived.push(...checkNames({ built, meta, records }));
548
+ }
549
+ let contrast = null;
550
+ if (!skipped.has('color-contrast')) {
551
+ contrast = checkContrast({ modes });
552
+ derived.push(...contrast.failures);
553
+ }
554
+ failures.push(...derived);
555
+
556
+ const proof = checkProof({ manifest, root: values.root, derived, built, meta, skipped });
557
+ failures.push(...proof.failures);
558
+ informational.push(...proof.informational);
559
+
560
+ // Subjects examined, not rules enabled. Counting enabled rules would pass a
561
+ // system with no doc records at all — exactly the green-having-read-nothing
562
+ // run this class exists to stop.
563
+ const examined =
564
+ (skipped.has('orphan-token') ? 0 : candidates) +
565
+ (skipped.has('state-incomplete') ? 0 : records.size) +
566
+ (skipped.has('name-drift') ? 0 : built.length) +
567
+ // A compared contrast pair is a subject the same way a token candidate is.
568
+ // Without this, a run that skips the other three rules — which is exactly
569
+ // how token-sync-layer invokes this gate — would fail nothing-verified on a
570
+ // perfectly healthy system.
571
+ (contrast ? contrast.pairs : 0);
572
+ if (examined === 0) failures.push({ rule: 'nothing-verified' });
573
+ if (!skipped.has('orphan-token') && orphanInert) failures.push({ rule: 'orphan-rule-inert' });
574
+ // This and nothing-verified can fire on the same run, as orphan-rule-inert
575
+ // already can.
576
+ if (contrast && contrast.pairs === 0) failures.push({ rule: 'contrast-rule-inert' });
577
+
578
+ const report = formatReport({
579
+ stats: {
580
+ components: built.length,
581
+ records: records.size,
582
+ candidates,
583
+ modes: modes.length,
584
+ files: scanned.length,
585
+ stages: proof.stagesRead,
586
+ },
587
+ excluded,
588
+ contrast,
589
+ skipped: [...skipped],
590
+ failures,
591
+ informational,
592
+ });
593
+ for (const line of report) console.log(line);
594
+ process.exit(failures.length > 0 ? 1 : 0);
595
+ }
596
+
597
+ function recordMode(values) {
598
+ if (!values.stage || !values.entry) {
599
+ console.error(
600
+ 'usage: verify-check.mjs --record --stage <name> [--subject <Name>] --entry <file.json>',
601
+ );
602
+ process.exit(2);
603
+ }
604
+ if (!STAGES.includes(values.stage)) {
605
+ console.error(
606
+ `verify:check — unknown --stage "${values.stage}"; the stages that record entries are ${STAGES.join(', ')}`,
607
+ );
608
+ process.exit(2);
609
+ }
610
+ // A per-component record that forgets --subject would be filed under
611
+ // "system", and if it were the stage's first record it would stamp adoptedAt
612
+ // and capture the whole exempt list off a misinvocation — which no later run
613
+ // undoes. Refuse before anything is written.
614
+ if (PER_COMPONENT_STAGES.has(values.stage) && !values.subject) {
615
+ console.error(
616
+ `verify:check — --stage ${values.stage} is keyed by component, so --subject is required`,
617
+ );
618
+ process.exit(2);
619
+ }
620
+ const subject = values.subject ?? 'system';
621
+
622
+ // The manifest must already exist. Manifest rule 1 has each skill create it
623
+ // with defaults if absent, but that is the owning skill's job on its own
624
+ // first action — this recorder is called after a skill has already read and
625
+ // written it, so a missing manifest here means the caller is pointed at the
626
+ // wrong root, and creating one would bury that in a stray file.
627
+ const manifestPath = manifestPathOf(values.root);
628
+ const entry = readJson(values.entry, 'the entry');
629
+ const problems = entryProblems(entry);
630
+ if (problems.length > 0) {
631
+ console.error(`verify:check — the entry at ${values.entry} is malformed:`);
632
+ for (const problem of problems) console.error(` ${problem}`);
633
+ process.exit(2);
634
+ }
635
+
636
+ const manifest = readJson(manifestPath, 'the manifest');
637
+ const next = mergeEntry(loadStage(values.root, values.stage), values.stage, subject, entry);
638
+ const path = stagePath(values.root, values.stage);
639
+ mkdirSync(dirname(path), { recursive: true });
640
+ writeFileSync(path, `${JSON.stringify(next, null, 2)}\n`);
641
+
642
+ if (!manifest.verification) manifest.verification = { path: PROOF_DIR, stages: {} };
643
+ manifest.verification.path = PROOF_DIR;
644
+ if (!manifest.verification.stages) manifest.verification.stages = {};
645
+ const existing = manifest.verification.stages[values.stage];
646
+ // "First record" is the stage entry being absent BEFORE this write — never
647
+ // the `exempt` key being absent, which would let a hand-deleted list be
648
+ // silently recaptured from the current components.built.
649
+ const first = existing === undefined;
650
+ const pointer = { ...(existing ?? {}) };
651
+ pointer.at = entry.at;
652
+ if (first) {
653
+ pointer.adoptedAt = entry.at;
654
+ // Capture reads the WHOLE of components.built, not "everything but this
655
+ // subject": a run's entire batch is already in `built` before the record
656
+ // loop reaches the first component, so a subject-excluding capture would
657
+ // permanently grandfather its batch-mates.
658
+ if (PER_COMPONENT_STAGES.has(values.stage)) {
659
+ pointer.exempt = [...(manifest.components?.built ?? [])].sort();
660
+ }
661
+ }
662
+ // On EVERY record, the first included, the subject leaves the list. Removal
663
+ // is the only mutation allowed, so the list can only narrow and no write can
664
+ // widen an exemption.
665
+ if (Array.isArray(pointer.exempt)) pointer.exempt = pointer.exempt.filter((n) => n !== subject);
666
+ pointer.fingerprint = stageFingerprint(next);
667
+ manifest.verification.stages[values.stage] = pointer;
668
+
669
+ if (!(manifest.schemaVersion >= SCHEMA_VERSION)) manifest.schemaVersion = SCHEMA_VERSION;
670
+ writeFileSync(manifestPath, `${JSON.stringify(manifest, null, 2)}\n`);
671
+
672
+ console.log(`✓ verify:check — recorded ${values.stage} · ${subject} (${pointer.fingerprint})`);
673
+ process.exit(0);
674
+ }
675
+
676
+ export const SKIPPABLE = ['orphan-token', 'state-incomplete', 'name-drift', 'color-contrast'];
677
+
678
+ function main() {
679
+ let values;
680
+ try {
681
+ ({ values } = parseArgs({
682
+ options: {
683
+ root: { type: 'string', default: '.' },
684
+ tokens: { type: 'string', multiple: true, default: [] },
685
+ source: { type: 'string', multiple: true, default: [] },
686
+ skip: { type: 'string', multiple: true, default: [] },
687
+ record: { type: 'boolean', default: false },
688
+ stage: { type: 'string' },
689
+ subject: { type: 'string' },
690
+ entry: { type: 'string' },
691
+ },
692
+ }));
693
+ } catch (e) {
694
+ console.error(e.message);
695
+ process.exit(2);
696
+ }
697
+
698
+ // A misspelled rule would print on the skipped: line and still run, and the
699
+ // prose spells it "colour" as often as the rule says "color".
700
+ const unknown = values.skip.filter((rule) => !SKIPPABLE.includes(rule));
701
+ if (unknown.length) {
702
+ console.error(`--skip names no rule: ${unknown.join(', ')}. Skippable: ${SKIPPABLE.join(', ')}.`);
703
+ process.exit(2);
704
+ }
705
+
706
+ if (values.record) recordMode(values);
707
+ else gateMode(values);
708
+ }
709
+
710
+ if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) main();