@radicool/throughline 0.19.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/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 +3 -2
- package/scripts/lib/proof.mjs +186 -0
- package/scripts/lib/source-scan.mjs +45 -0
- package/scripts/validate-adherence.mjs +895 -0
- package/scripts/validate-token-output.mjs +468 -63
- 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();
|