@radicool/throughline 0.18.0 → 0.20.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +2 -2
- package/adapters/codex/prompts/component-builder.md +24 -1
- package/adapters/codex/prompts/document-component.md +10 -1
- package/adapters/codex/prompts/icon-system-builder.md +4 -0
- package/adapters/codex/prompts/storybook-chromatic-builder.md +114 -7
- package/adapters/codex/prompts/token-builder.md +110 -3
- package/adapters/codex/prompts/token-crosswalk-builder.md +2 -0
- package/adapters/codex/prompts/token-sheet-builder.md +4 -0
- package/adapters/codex/prompts/token-sync-layer.md +140 -20
- package/adapters/cursor/.cursor/commands/document-component.md +10 -1
- package/adapters/cursor/.cursor/rules/component-builder.mdc +24 -1
- package/adapters/cursor/.cursor/rules/icon-system-builder.mdc +4 -0
- package/adapters/cursor/.cursor/rules/storybook-chromatic-builder.mdc +114 -7
- package/adapters/cursor/.cursor/rules/token-builder.mdc +110 -3
- package/adapters/cursor/.cursor/rules/token-crosswalk-builder.mdc +2 -0
- package/adapters/cursor/.cursor/rules/token-sheet-builder.mdc +4 -0
- package/adapters/cursor/.cursor/rules/token-sync-layer.mdc +140 -20
- package/adapters/generic/commands/document-component.md +10 -1
- package/adapters/generic/skills/component-builder/SKILL.md +24 -1
- package/adapters/generic/skills/icon-system-builder/SKILL.md +4 -0
- package/adapters/generic/skills/storybook-chromatic-builder/SKILL.md +114 -7
- package/adapters/generic/skills/token-builder/SKILL.md +110 -3
- package/adapters/generic/skills/token-crosswalk-builder/SKILL.md +2 -0
- package/adapters/generic/skills/token-sheet-builder/SKILL.md +4 -0
- package/adapters/generic/skills/token-sync-layer/SKILL.md +140 -20
- package/package.json +1 -1
- package/references/component-doc-schema.md +11 -1
- package/references/figma-component-standards.md +14 -2
- package/references/figma-scripting.md +12 -2
- package/references/manifest-schema.md +42 -2
- package/references/native-adapter-config.md +112 -25
- package/references/proof-bundle.md +305 -0
- package/references/sync-adapters.md +38 -53
- package/scripts/README.md +140 -6
- package/scripts/build-doc-card-builder.mjs +1 -1
- package/scripts/grep-color-usage.mjs +10 -30
- package/scripts/guard-token-removal.mjs +10 -26
- package/scripts/lib/component-states.mjs +56 -0
- package/scripts/lib/contrast.mjs +117 -0
- package/scripts/lib/dtcg.mjs +123 -4
- package/scripts/lib/native-literal.mjs +22 -2
- package/scripts/lib/proof.mjs +186 -0
- package/scripts/lib/sd-native.mjs +112 -25
- package/scripts/lib/source-scan.mjs +45 -0
- package/scripts/validate-adherence.mjs +895 -0
- package/scripts/validate-token-output.mjs +611 -46
- package/scripts/verify-check.mjs +710 -0
|
@@ -1,22 +1,27 @@
|
|
|
1
|
-
//
|
|
2
|
-
// Catches output that compiles but is
|
|
1
|
+
// Token output validator: assert generated Swift/Kotlin, and web CSS custom
|
|
2
|
+
// properties, match their DTCG source. Catches output that compiles but is
|
|
3
|
+
// wrong. Zero dependencies.
|
|
3
4
|
//
|
|
4
5
|
// Usage:
|
|
5
6
|
// node validate-token-output.mjs --source a.json --source b.json \
|
|
6
7
|
// --output Tokens.swift --platform ios-swift [--min-match 0.5]
|
|
8
|
+
// node validate-token-output.mjs --source a.json --output tokens.css \
|
|
9
|
+
// --platform shadcn --block .dark
|
|
7
10
|
import { readFileSync } from 'node:fs';
|
|
8
11
|
import { parseArgs } from 'node:util';
|
|
9
12
|
import { pathToFileURL } from 'node:url';
|
|
10
13
|
import {
|
|
11
14
|
flattenDtcg,
|
|
12
15
|
flattenDtcgTypes,
|
|
16
|
+
flattenPipelineTypes,
|
|
17
|
+
findDualNodes,
|
|
13
18
|
resolveValue,
|
|
14
19
|
findModeCollisions,
|
|
15
20
|
textRoleGraph,
|
|
16
21
|
mergeDtcg,
|
|
17
22
|
EXT_NS,
|
|
18
23
|
} from './lib/dtcg.mjs';
|
|
19
|
-
import { parseLiteral, isValidLiteral, GRAMMAR } from './lib/native-literal.mjs';
|
|
24
|
+
import { parseLiteral, isValidLiteral, GRAMMAR, CSS_CONSTRUCT_ANYWHERE } from './lib/native-literal.mjs';
|
|
20
25
|
|
|
21
26
|
// Re-exported so consumers (and the test file) keep one import surface.
|
|
22
27
|
export { flattenDtcg, flattenDtcgTypes, resolveValue, findModeCollisions };
|
|
@@ -29,12 +34,123 @@ const DECL = {
|
|
|
29
34
|
'android-kotlin': /^\s*val\s+([A-Za-z_]\w*)\s*=\s*(.+?)\s*$/,
|
|
30
35
|
};
|
|
31
36
|
|
|
37
|
+
// The web platforms this gate reads, and whether each one has an alias
|
|
38
|
+
// layer — a hand-authored custom property (e.g. shadcn/tailwind's
|
|
39
|
+
// `--background: var(--color-bg)`) that has no source token to match.
|
|
40
|
+
export const WEB_PLATFORMS = {
|
|
41
|
+
shadcn: { aliasLayer: true },
|
|
42
|
+
tailwind: { aliasLayer: true },
|
|
43
|
+
'vanilla-css': { aliasLayer: false },
|
|
44
|
+
};
|
|
45
|
+
|
|
32
46
|
// Style Dictionary's ios-swift/enum.swift format emits an inline trailing
|
|
33
47
|
// comment for any token carrying a $description ("... /** Small body text */").
|
|
34
48
|
// Strip a TRAILING comment only — a value that legitimately contains "//"
|
|
35
49
|
// inside a string literal must survive untouched.
|
|
36
50
|
const TRAILING_COMMENT = /\s+(\/\*\*?[\s\S]*\*\/|\/\/.*)$/;
|
|
37
51
|
|
|
52
|
+
// Adapters name blocks differently ("@media (min-width: 768px) :root" vs
|
|
53
|
+
// ":root") but the same block reached two different ways must compare equal —
|
|
54
|
+
// collapse whitespace and normalize the comma spacing a multi-selector block
|
|
55
|
+
// (":root,\n.dark") is written with.
|
|
56
|
+
export function normalizeBlock(s) {
|
|
57
|
+
return String(s).replace(/\s+/g, ' ').replace(/\s*,\s*/g, ', ').trim();
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
// Web output for tokens:validate-output (docs/specs/2026-09-11-web-token-output-validation.md),
|
|
61
|
+
// Step 2. A scanner, not a CSS parser: the only structure this needs is which
|
|
62
|
+
// declarations sit inside which selector/at-rule nesting, and a hand-rolled
|
|
63
|
+
// state machine over `stack` models that directly without pulling in a real
|
|
64
|
+
// CSS grammar. A `{` found while `seg` already looks like a custom property's
|
|
65
|
+
// name and colon is kept IN the value rather than opened as a new block — an
|
|
66
|
+
// unresolved reference such as `{text.xs.lineHeight}` (left raw by a broken
|
|
67
|
+
// Style Dictionary build) must stay readable as the value it is, not be
|
|
68
|
+
// misread as a nested block.
|
|
69
|
+
export function extractCustomProperties(text) {
|
|
70
|
+
const declarations = [];
|
|
71
|
+
let unparsed = 0;
|
|
72
|
+
const stack = [];
|
|
73
|
+
let seg = '';
|
|
74
|
+
|
|
75
|
+
function flush() {
|
|
76
|
+
const s = seg.trim();
|
|
77
|
+
seg = '';
|
|
78
|
+
if (!s || stack.length === 0) return;
|
|
79
|
+
const m = s.match(/^(--[A-Za-z0-9_-]+)\s*:([\s\S]*)$/);
|
|
80
|
+
if (m) {
|
|
81
|
+
declarations.push({ block: stack.join(' '), name: m[1], value: m[2].trim() });
|
|
82
|
+
} else {
|
|
83
|
+
unparsed += 1;
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
let i = 0;
|
|
88
|
+
while (i < text.length) {
|
|
89
|
+
const c = text[i];
|
|
90
|
+
if (c === '/' && text[i + 1] === '*') {
|
|
91
|
+
const end = text.indexOf('*/', i + 2);
|
|
92
|
+
i = end === -1 ? text.length : end + 2;
|
|
93
|
+
seg += ' ';
|
|
94
|
+
continue;
|
|
95
|
+
}
|
|
96
|
+
if (c === '"' || c === "'") {
|
|
97
|
+
const quote = c;
|
|
98
|
+
seg += c;
|
|
99
|
+
i += 1;
|
|
100
|
+
while (i < text.length) {
|
|
101
|
+
const ch = text[i];
|
|
102
|
+
seg += ch;
|
|
103
|
+
if (ch === '\\') {
|
|
104
|
+
i += 1;
|
|
105
|
+
if (i < text.length) {
|
|
106
|
+
seg += text[i];
|
|
107
|
+
i += 1;
|
|
108
|
+
}
|
|
109
|
+
continue;
|
|
110
|
+
}
|
|
111
|
+
i += 1;
|
|
112
|
+
if (ch === quote || ch === '\n') break;
|
|
113
|
+
}
|
|
114
|
+
continue;
|
|
115
|
+
}
|
|
116
|
+
if (c === '{') {
|
|
117
|
+
if (/^\s*--[A-Za-z0-9_-]+\s*:/.test(seg)) {
|
|
118
|
+
let depth = 1;
|
|
119
|
+
seg += c;
|
|
120
|
+
i += 1;
|
|
121
|
+
while (i < text.length && depth > 0) {
|
|
122
|
+
const ch = text[i];
|
|
123
|
+
seg += ch;
|
|
124
|
+
if (ch === '{') depth += 1;
|
|
125
|
+
else if (ch === '}') depth -= 1;
|
|
126
|
+
i += 1;
|
|
127
|
+
}
|
|
128
|
+
continue;
|
|
129
|
+
}
|
|
130
|
+
stack.push(normalizeBlock(seg));
|
|
131
|
+
seg = '';
|
|
132
|
+
i += 1;
|
|
133
|
+
continue;
|
|
134
|
+
}
|
|
135
|
+
if (c === '}') {
|
|
136
|
+
flush();
|
|
137
|
+
stack.pop();
|
|
138
|
+
i += 1;
|
|
139
|
+
continue;
|
|
140
|
+
}
|
|
141
|
+
if (c === ';') {
|
|
142
|
+
flush();
|
|
143
|
+
i += 1;
|
|
144
|
+
continue;
|
|
145
|
+
}
|
|
146
|
+
seg += c;
|
|
147
|
+
i += 1;
|
|
148
|
+
}
|
|
149
|
+
flush();
|
|
150
|
+
|
|
151
|
+
return { declarations, unparsed };
|
|
152
|
+
}
|
|
153
|
+
|
|
38
154
|
export function extractDeclarations(text, platform) {
|
|
39
155
|
const re = DECL[platform];
|
|
40
156
|
if (!re) throw new Error(`unknown platform "${platform}"`);
|
|
@@ -68,6 +184,34 @@ export function normalizeKey(s) {
|
|
|
68
184
|
return String(s).toLowerCase().replace(/[^a-z0-9]/g, '');
|
|
69
185
|
}
|
|
70
186
|
|
|
187
|
+
// Two source paths that normalize to one key, e.g. color.bg.canvas and
|
|
188
|
+
// colorBg.canvas -> colorbgcanvas. Same map-and-compare shape as
|
|
189
|
+
// findModeCollisions, keyed on the normalized form instead of the raw path.
|
|
190
|
+
//
|
|
191
|
+
// This is not only a matching problem, which is why it gates rather than
|
|
192
|
+
// advises. Measured through Style Dictionary on that exact pair: the build
|
|
193
|
+
// emits `val colorBgCanvas` TWICE and kotlinc rejects the file with
|
|
194
|
+
// "conflicting declarations". The source is ambiguous for native output, and
|
|
195
|
+
// the emitted file does not compile.
|
|
196
|
+
//
|
|
197
|
+
// Before this, the second path silently overwrote the first in byKey, with a
|
|
198
|
+
// consequence in each direction: the loser was never checked at all, and every
|
|
199
|
+
// emitted symbol sharing the key was compared against whichever path happened
|
|
200
|
+
// to sort last — which reported a unit-fidelity failure naming a token that was
|
|
201
|
+
// correct. A wrong diagnosis is worse than none, because it sends the author to
|
|
202
|
+
// the wrong file.
|
|
203
|
+
export function findNormalizationCollisions(paths) {
|
|
204
|
+
const byKey = new Map();
|
|
205
|
+
for (const path of paths) {
|
|
206
|
+
const key = normalizeKey(path);
|
|
207
|
+
if (!byKey.has(key)) byKey.set(key, []);
|
|
208
|
+
byKey.get(key).push(path);
|
|
209
|
+
}
|
|
210
|
+
return [...byKey]
|
|
211
|
+
.filter(([, ps]) => ps.length > 1)
|
|
212
|
+
.map(([key, ps]) => ({ key, paths: ps }));
|
|
213
|
+
}
|
|
214
|
+
|
|
71
215
|
const UNIT = /^(-?(?:\d+(?:\.\d+)?|\.\d+))([a-z%]*)$/;
|
|
72
216
|
|
|
73
217
|
// Expected native magnitude for an authored source value. iOS points and Android
|
|
@@ -101,7 +245,21 @@ export function expectedMagnitude(sourceValue) {
|
|
|
101
245
|
// "var(" (e.g. a $type: string value describing CSS) would also match. The
|
|
102
246
|
// isValidLiteral gate below is what tells those apart: a value the grammar
|
|
103
247
|
// accepts as a literal is not foreign syntax, whatever text it contains.
|
|
104
|
-
|
|
248
|
+
//
|
|
249
|
+
// That holds for Swift. It does NOT hold unconditionally for Kotlin (#57):
|
|
250
|
+
// `${...}` inside a Kotlin string is executable code, and the grammar accepts an
|
|
251
|
+
// unescaped `$`, so `"${calc(1)}"` parses as a valid literal and is exempted
|
|
252
|
+
// here. The exemption also covers a small set of values that are well-formed
|
|
253
|
+
// literals AND named-foreign — calc(2), var(1), and on Kotlin calc(2.dp) — which
|
|
254
|
+
// are kept, pass every rule, and do not compile. None is producible CSS, and
|
|
255
|
+
// this was shipped knowingly when the gate was added; it is written down so the
|
|
256
|
+
// next person meets it as a decision rather than a surprise.
|
|
257
|
+
// Imported, not redeclared (#57). This was an independent copy of the same
|
|
258
|
+
// alternation, while native-literal.mjs's comment promised the build and the
|
|
259
|
+
// gate could not drift apart. Adding a fourth construct name there would have
|
|
260
|
+
// taught the output filter to keep something this rule had never been taught to
|
|
261
|
+
// name — recreating exactly the unreachable-rule defect #56 fixed.
|
|
262
|
+
const FOREIGN = CSS_CONSTRUCT_ANYWHERE;
|
|
105
263
|
const BARE_UNIT = /^-?(?:\d+(?:\.\d+)?|\.\d+)(?:px|rem|em|%)$/;
|
|
106
264
|
|
|
107
265
|
// #52. A unitless value is a ratio, not a measurement: DTCG 8.2.1 requires a
|
|
@@ -117,6 +275,18 @@ const DIMENSIONAL = new Set(['dimension', 'fontSize']);
|
|
|
117
275
|
// alone would flag both the alias and its referent for the same problem.
|
|
118
276
|
const WHOLE_REF = /^\{[^}]+\}$/;
|
|
119
277
|
|
|
278
|
+
// Follow whole-value references to the token an advisory's fix belongs on.
|
|
279
|
+
// Stops at the first path that is not a whole-value reference, at an
|
|
280
|
+
// unresolvable one, and on a cycle — this reports, so it must never throw.
|
|
281
|
+
function referentOf(path, flat, seen = new Set()) {
|
|
282
|
+
const raw = String(flat[path] ?? '').trim();
|
|
283
|
+
if (!WHOLE_REF.test(raw)) return path;
|
|
284
|
+
const next = raw.slice(1, -1);
|
|
285
|
+
if (seen.has(path) || !(next in flat)) return path;
|
|
286
|
+
seen.add(path);
|
|
287
|
+
return referentOf(next, flat, seen);
|
|
288
|
+
}
|
|
289
|
+
|
|
120
290
|
// Lines that are obviously not a would-be token declaration: braces-only,
|
|
121
291
|
// comments, imports/package/annotations, or the container declarations
|
|
122
292
|
// (enum/object/class) themselves. Anything else that DECL failed to match is
|
|
@@ -142,17 +312,279 @@ function countUnparsedLines(text, declRe) {
|
|
|
142
312
|
return count;
|
|
143
313
|
}
|
|
144
314
|
|
|
145
|
-
|
|
315
|
+
// The source side every platform reads the same way: mode collisions, the merged
|
|
316
|
+
// flat map, name collisions, and the key-to-path index a declaration matches
|
|
317
|
+
// through.
|
|
318
|
+
function indexSources(sources) {
|
|
146
319
|
const collisions = findModeCollisions(sources);
|
|
147
320
|
|
|
148
321
|
const flat = {};
|
|
149
322
|
for (const { dtcg } of sources) Object.assign(flat, flattenDtcg(dtcg));
|
|
150
323
|
|
|
324
|
+
// Collided keys are deliberately LEFT OUT of byKey. A symbol whose key is
|
|
325
|
+
// ambiguous then matches nothing, so it falls through to `continue` before any
|
|
326
|
+
// source comparison and is not counted as matched — which is the truth: it did
|
|
327
|
+
// not match a determinate token. That removes the false unit-fidelity failure
|
|
328
|
+
// without a special case in the loop below. The literal, foreign-syntax and
|
|
329
|
+
// bare-unit rules still run on it, because none of them reads the source.
|
|
330
|
+
const normalizationCollisions = findNormalizationCollisions(Object.keys(flat));
|
|
331
|
+
const collided = new Set(normalizationCollisions.map((c) => c.key));
|
|
332
|
+
|
|
333
|
+
const byKey = new Map();
|
|
334
|
+
for (const path of Object.keys(flat)) {
|
|
335
|
+
const key = normalizeKey(path);
|
|
336
|
+
if (!collided.has(key)) byKey.set(key, path);
|
|
337
|
+
}
|
|
338
|
+
|
|
339
|
+
return { collisions, flat, normalizationCollisions, byKey };
|
|
340
|
+
}
|
|
341
|
+
|
|
342
|
+
// Advisory, not a failure: the emitted value is correct under the ratio
|
|
343
|
+
// reading this build applies, so it compiles and its magnitude matches.
|
|
344
|
+
// What is wrong is the SOURCE's $type, which only the author can settle.
|
|
345
|
+
//
|
|
346
|
+
// An alias is skipped only when its REFERENT is itself dimension-typed, so
|
|
347
|
+
// the referent's own symbol reports it — that is #69's de-duplication, kept.
|
|
348
|
+
// A blanket skip on any whole-value reference (#72) meant an untyped base
|
|
349
|
+
// behind a typed alias was reported nowhere: the base is not dimension-typed
|
|
350
|
+
// so it never fires, and the alias was skipped for being a reference. The
|
|
351
|
+
// advisory is attributed to the referent either way, because that is the
|
|
352
|
+
// token whose $type or unit the author has to change.
|
|
353
|
+
function unitlessDimensionAdvisory({ path, flat, types, symbol, source, emitted }) {
|
|
354
|
+
const aliased = WHOLE_REF.test(String(flat[path]).trim());
|
|
355
|
+
const target = aliased ? referentOf(path, flat) : path;
|
|
356
|
+
if (
|
|
357
|
+
UNITLESS.test(String(source).trim()) &&
|
|
358
|
+
DIMENSIONAL.has(types[path]) &&
|
|
359
|
+
!(aliased && DIMENSIONAL.has(types[target]))
|
|
360
|
+
) {
|
|
361
|
+
return { rule: 'unitless-dimension', symbol, token: target, source, emitted };
|
|
362
|
+
}
|
|
363
|
+
return null;
|
|
364
|
+
}
|
|
365
|
+
|
|
366
|
+
// A CSS magnitude: a bare number, or a number with a px/rem/em/% unit. `null`
|
|
367
|
+
// for anything else (a colour, a var(), an unresolved reference) — those are
|
|
368
|
+
// not dimensions and unit-fidelity has nothing to compare.
|
|
369
|
+
export function cssMagnitude(value) {
|
|
370
|
+
if (typeof value === 'number') return { n: value, unit: '' };
|
|
371
|
+
if (typeof value !== 'string') return null;
|
|
372
|
+
const m = value.trim().match(/^(-?(?:\d+(?:\.\d+)?|\.\d+))(px|rem|em|%)?$/);
|
|
373
|
+
if (!m) return null;
|
|
374
|
+
return { n: Number(m[1]), unit: m[2] ?? '' };
|
|
375
|
+
}
|
|
376
|
+
|
|
377
|
+
// Web's analogue of expectedMagnitude/magnitudeOf, but two-sided: CSS, unlike
|
|
378
|
+
// Swift/Kotlin, can emit px OR rem for the same source value, and em/% have no
|
|
379
|
+
// unit-less native equivalent to fall back to, so the comparison has to know
|
|
380
|
+
// which unit the SOURCE was authored in, not just read a bare number back out.
|
|
381
|
+
export function webUnitFidelity(source, emitted, type) {
|
|
382
|
+
const px = (x) => (x.unit === 'rem' ? x.n * 16 : x.n);
|
|
383
|
+
const close = (a, b) => Math.abs(a - b) <= 0.001;
|
|
384
|
+
if (type === 'number' || type === 'fontWeight') {
|
|
385
|
+
return emitted.unit === '' && close(emitted.n, px(source));
|
|
386
|
+
}
|
|
387
|
+
if (source.unit === 'em' || source.unit === '%') {
|
|
388
|
+
return emitted.unit === source.unit && close(emitted.n, source.n);
|
|
389
|
+
}
|
|
390
|
+
if (source.unit === 'px' || source.unit === 'rem') {
|
|
391
|
+
return (
|
|
392
|
+
(emitted.unit === 'px' || emitted.unit === 'rem' || (emitted.unit === '' && emitted.n === 0)) &&
|
|
393
|
+
close(px(emitted), px(source))
|
|
394
|
+
);
|
|
395
|
+
}
|
|
396
|
+
// A unitless source is a ratio (unitless-dimension advisory catches the
|
|
397
|
+
// authoring problem separately); accept it read back as px, rem, or bare.
|
|
398
|
+
return ['', 'px', 'rem'].includes(emitted.unit) && close(px(emitted), source.n);
|
|
399
|
+
}
|
|
400
|
+
|
|
401
|
+
// Web output for tokens:validate-output (docs/specs/2026-09-11-web-token-output-validation.md),
|
|
402
|
+
// Step 4. `no-foreign-syntax` and `no-bare-units` do not run here: both exist
|
|
403
|
+
// to catch CSS syntax LEAKING into a Swift/Kotlin literal, where it cannot
|
|
404
|
+
// compile — but CSS is the target language on this path, so `calc(...)`,
|
|
405
|
+
// `var(...)` and a bare `16px` are exactly the forms a correct declaration is
|
|
406
|
+
// expected to contain. There is no foreign syntax to leak (#37).
|
|
407
|
+
// A block key is printed for pasting back as --block, so it is quoted the way a
|
|
408
|
+
// POSIX shell reads it. JSON.stringify escaped the quotes in `[data-theme="light"]`
|
|
409
|
+
// and a copied key matched no block (#133).
|
|
410
|
+
function shellQuote(s) {
|
|
411
|
+
return `'${s.replaceAll("'", `'\\''`)}'`;
|
|
412
|
+
}
|
|
413
|
+
|
|
414
|
+
function validateWeb({ sources, output, platform, minMatch, block }) {
|
|
415
|
+
const { declarations, unparsed } = extractCustomProperties(output);
|
|
416
|
+
const counts = new Map();
|
|
417
|
+
for (const d of declarations) counts.set(d.block, (counts.get(d.block) ?? 0) + 1);
|
|
418
|
+
const list = [...counts].map(([k, c]) => `${shellQuote(k)} (${c})`).join(', ');
|
|
419
|
+
|
|
420
|
+
let key;
|
|
421
|
+
if (block === undefined) {
|
|
422
|
+
if (counts.size === 1) {
|
|
423
|
+
key = [...counts.keys()][0];
|
|
424
|
+
} else if (counts.size > 1) {
|
|
425
|
+
throw new Error(`the output declares custom properties in ${counts.size} blocks — pass --block with one of: ${list}`);
|
|
426
|
+
}
|
|
427
|
+
} else {
|
|
428
|
+
key = normalizeBlock(block);
|
|
429
|
+
if (counts.size > 0 && !counts.has(key)) {
|
|
430
|
+
throw new Error(`no block ${shellQuote(key)} in the output — its blocks are: ${list}`);
|
|
431
|
+
}
|
|
432
|
+
}
|
|
433
|
+
|
|
434
|
+
const declared = new Set(declarations.map((d) => d.name));
|
|
435
|
+
const selected = new Map();
|
|
436
|
+
for (const d of declarations) {
|
|
437
|
+
if (d.block === key) selected.set(d.name, d.value);
|
|
438
|
+
}
|
|
439
|
+
|
|
440
|
+
const { collisions, flat, normalizationCollisions, byKey } = indexSources(sources);
|
|
151
441
|
const types = {};
|
|
152
442
|
for (const { dtcg } of sources) Object.assign(types, flattenDtcgTypes(dtcg));
|
|
153
443
|
|
|
154
|
-
|
|
155
|
-
|
|
444
|
+
function strings(v) {
|
|
445
|
+
if (typeof v === 'string') return [v];
|
|
446
|
+
if (v && typeof v === 'object') return Object.values(v).flatMap(strings);
|
|
447
|
+
return [];
|
|
448
|
+
}
|
|
449
|
+
|
|
450
|
+
const failures = [];
|
|
451
|
+
const advisories = [];
|
|
452
|
+
let matched = 0;
|
|
453
|
+
let aliases = 0;
|
|
454
|
+
|
|
455
|
+
for (const [name, value] of selected) {
|
|
456
|
+
const unquoted = value.replace(/"(?:[^"\\]|\\.)*"|'(?:[^'\\]|\\.)*'/g, '""');
|
|
457
|
+
const emittedVars = [...unquoted.matchAll(/var\(\s*(--[A-Za-z0-9_-]+)/g)].map((m) => m[1]);
|
|
458
|
+
|
|
459
|
+
const path = byKey.get(normalizeKey(name));
|
|
460
|
+
const raw = path === undefined ? '' : strings(flat[path]).join(' ');
|
|
461
|
+
const authored = new Set([...raw.matchAll(/var\(\s*(--[A-Za-z0-9_-]+)/g)].map((m) => m[1]));
|
|
462
|
+
|
|
463
|
+
for (const v of new Set(emittedVars)) {
|
|
464
|
+
if (!declared.has(v) && !authored.has(v)) {
|
|
465
|
+
failures.push({ rule: 'dangling-reference', symbol: name, reference: v });
|
|
466
|
+
}
|
|
467
|
+
}
|
|
468
|
+
|
|
469
|
+
const unresolved = /\{[^{}]*\}/.test(unquoted);
|
|
470
|
+
if (unresolved) failures.push({ rule: 'no-unresolved-reference', symbol: name, emitted: value });
|
|
471
|
+
|
|
472
|
+
const invalid = /\[object Object\]|\bNaN\b|\bundefined\b/.test(unquoted);
|
|
473
|
+
if (invalid) failures.push({ rule: 'invalid-value', symbol: name, emitted: value });
|
|
474
|
+
|
|
475
|
+
if (path === undefined) {
|
|
476
|
+
if (
|
|
477
|
+
WEB_PLATFORMS[platform].aliasLayer &&
|
|
478
|
+
/^var\(\s*--[A-Za-z0-9_-]+\s*\)$/.test(value.trim()) &&
|
|
479
|
+
declared.has(emittedVars[0])
|
|
480
|
+
) {
|
|
481
|
+
aliases += 1;
|
|
482
|
+
}
|
|
483
|
+
continue;
|
|
484
|
+
}
|
|
485
|
+
|
|
486
|
+
let source;
|
|
487
|
+
try {
|
|
488
|
+
source = resolveValue(path, flat);
|
|
489
|
+
} catch {
|
|
490
|
+
continue;
|
|
491
|
+
}
|
|
492
|
+
matched += 1;
|
|
493
|
+
|
|
494
|
+
const advisory = unitlessDimensionAdvisory({ path, flat, types, symbol: name, source, emitted: value });
|
|
495
|
+
if (advisory) advisories.push(advisory);
|
|
496
|
+
|
|
497
|
+
if (emittedVars.length) {
|
|
498
|
+
const wantMatches = [...raw.matchAll(/\{([^{}]+)\}|var\(\s*(--[A-Za-z0-9_-]+)/g)];
|
|
499
|
+
const want = wantMatches.map((m) => normalizeKey(m[1] ?? m[2])).sort();
|
|
500
|
+
const got = emittedVars.map(normalizeKey).sort();
|
|
501
|
+
if (JSON.stringify(want) !== JSON.stringify(got)) {
|
|
502
|
+
failures.push({
|
|
503
|
+
rule: 'reference-fidelity',
|
|
504
|
+
symbol: name,
|
|
505
|
+
token: path,
|
|
506
|
+
source: typeof flat[path] === 'string' ? flat[path] : JSON.stringify(flat[path]),
|
|
507
|
+
emitted: value,
|
|
508
|
+
});
|
|
509
|
+
}
|
|
510
|
+
continue;
|
|
511
|
+
}
|
|
512
|
+
|
|
513
|
+
const s = cssMagnitude(source);
|
|
514
|
+
if (s === null) continue;
|
|
515
|
+
const e = cssMagnitude(value);
|
|
516
|
+
if (e === null) {
|
|
517
|
+
if (!unresolved && !invalid) {
|
|
518
|
+
failures.push({ rule: 'unverifiable-dimension', symbol: name, token: path, source, emitted: value });
|
|
519
|
+
}
|
|
520
|
+
continue;
|
|
521
|
+
}
|
|
522
|
+
if (!webUnitFidelity(s, e, types[path])) {
|
|
523
|
+
failures.push({ rule: 'unit-fidelity', symbol: name, token: path, source, emitted: value });
|
|
524
|
+
}
|
|
525
|
+
}
|
|
526
|
+
|
|
527
|
+
const dualNodes = [...new Set(sources.flatMap((s) => findDualNodes(s.dtcg)))];
|
|
528
|
+
if (dualNodes.length) advisories.push({ rule: 'dual-node', paths: dualNodes });
|
|
529
|
+
|
|
530
|
+
const total = selected.size;
|
|
531
|
+
const denominator = total - aliases;
|
|
532
|
+
const matchRate = denominator ? matched / denominator : 0;
|
|
533
|
+
const ok =
|
|
534
|
+
failures.length === 0 &&
|
|
535
|
+
collisions.length === 0 &&
|
|
536
|
+
normalizationCollisions.length === 0 &&
|
|
537
|
+
matched > 0 &&
|
|
538
|
+
matchRate >= minMatch;
|
|
539
|
+
|
|
540
|
+
const declaredKeys = new Set([...declared].map(normalizeKey));
|
|
541
|
+
const unemittedPaths = [];
|
|
542
|
+
for (const [k, path] of byKey) if (!declaredKeys.has(k)) unemittedPaths.push(path);
|
|
543
|
+
|
|
544
|
+
return {
|
|
545
|
+
platform,
|
|
546
|
+
block: key ?? null,
|
|
547
|
+
total,
|
|
548
|
+
aliases,
|
|
549
|
+
matched,
|
|
550
|
+
matchRate,
|
|
551
|
+
failures,
|
|
552
|
+
advisories,
|
|
553
|
+
collisions,
|
|
554
|
+
normalizationCollisions,
|
|
555
|
+
minMatch,
|
|
556
|
+
ok,
|
|
557
|
+
unparsedLines: unparsed,
|
|
558
|
+
unemittedTokens: unemittedPaths.length,
|
|
559
|
+
unemittedPaths,
|
|
560
|
+
};
|
|
561
|
+
}
|
|
562
|
+
|
|
563
|
+
export function validate({ sources, output, platform, minMatch = 0.5, block }) {
|
|
564
|
+
if (platform === 'mui') {
|
|
565
|
+
throw new Error(
|
|
566
|
+
'--platform mui is not supported: a MUI theme is a JavaScript object, and this gate reads CSS custom properties. See #127.',
|
|
567
|
+
);
|
|
568
|
+
}
|
|
569
|
+
if (platform in WEB_PLATFORMS) {
|
|
570
|
+
return validateWeb({ sources, output, platform, minMatch, block });
|
|
571
|
+
}
|
|
572
|
+
|
|
573
|
+
const { collisions, flat, normalizationCollisions, byKey } = indexSources(sources);
|
|
574
|
+
|
|
575
|
+
const types = {};
|
|
576
|
+
// The PIPELINE's types, not the spec's alone (#71). flattenDtcgTypes reads the
|
|
577
|
+
// raw source, where hoistDualNodes' $type carry has not run — so a unitless,
|
|
578
|
+
// untyped child of a dimension-typed dual node was a dimension to the build
|
|
579
|
+
// and a nothing to this gate, which is the silent case this rule most exists
|
|
580
|
+
// to catch.
|
|
581
|
+
//
|
|
582
|
+
// The issue framed the only fix as running this gate against the PREPROCESSED
|
|
583
|
+
// tree, and rejected it, because the gate would stop checking emitted output
|
|
584
|
+
// against what the author actually wrote. flattenPipelineTypes is a third
|
|
585
|
+
// option that keeps that property: it reads the raw source and MODELS the
|
|
586
|
+
// carry rather than applying it. The gate still reads what the author wrote.
|
|
587
|
+
for (const { dtcg } of sources) Object.assign(types, flattenPipelineTypes(dtcg));
|
|
156
588
|
|
|
157
589
|
const decls = extractDeclarations(output, platform);
|
|
158
590
|
const failures = [];
|
|
@@ -194,22 +626,20 @@ export function validate({ sources, output, platform, minMatch = 0.5 }) {
|
|
|
194
626
|
}
|
|
195
627
|
matched += 1;
|
|
196
628
|
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
// What is wrong is the SOURCE's $type, which only the author can settle.
|
|
200
|
-
if (
|
|
201
|
-
UNITLESS.test(String(source).trim()) &&
|
|
202
|
-
DIMENSIONAL.has(types[path]) &&
|
|
203
|
-
!WHOLE_REF.test(String(flat[path]).trim())
|
|
204
|
-
) {
|
|
205
|
-
advisories.push({ rule: 'unitless-dimension', symbol, token: path, source, emitted: value });
|
|
206
|
-
}
|
|
629
|
+
const advisory = unitlessDimensionAdvisory({ path, flat, types, symbol, source, emitted: value });
|
|
630
|
+
if (advisory) advisories.push(advisory);
|
|
207
631
|
|
|
208
632
|
const expected = expectedMagnitude(source);
|
|
209
633
|
if (expected.skip) continue;
|
|
210
634
|
const actual = magnitudeOf(value);
|
|
211
635
|
if (actual === null) {
|
|
212
|
-
|
|
636
|
+
// no-foreign-syntax already explains why the magnitude could not be read,
|
|
637
|
+
// and names the actual cause. "The token was never actually compared" beside
|
|
638
|
+
// it is a red herring pointing at the symptom (#57). Same suppression the
|
|
639
|
+
// three literal rules already apply to each other.
|
|
640
|
+
if (!foreign) {
|
|
641
|
+
failures.push({ rule: 'unverifiable-dimension', symbol, token: path, source, emitted: value });
|
|
642
|
+
}
|
|
213
643
|
continue;
|
|
214
644
|
}
|
|
215
645
|
if (Math.abs(actual - expected.magnitude) > 0.001) {
|
|
@@ -227,6 +657,24 @@ export function validate({ sources, output, platform, minMatch = 0.5 }) {
|
|
|
227
657
|
// build that actually ran, and a build merges with the later source winning.
|
|
228
658
|
// A union would call a token referenced when this build did not reach it,
|
|
229
659
|
// under-reporting the gap in the one direction that matters.
|
|
660
|
+
// #58. A node carrying both a $value and children is invalid DTCG (§6.1, and
|
|
661
|
+
// §6.2 defines $root as the sanctioned spelling), and nothing told the author
|
|
662
|
+
// so. ADVISORY, not a failure, and deliberately: every Figma-derived source
|
|
663
|
+
// has dozens — the real one this is validated against has 13 — so failing on
|
|
664
|
+
// it would make the gate useless on day one for exactly the people this tool
|
|
665
|
+
// targets. The build keeps handling them; the author now learns the shape is
|
|
666
|
+
// non-conforming and what to write instead.
|
|
667
|
+
//
|
|
668
|
+
// One advisory for the whole finding rather than one per node. It is a single
|
|
669
|
+
// structural fact about the source, and thirteen near-identical lines would
|
|
670
|
+
// bury the rest of the report.
|
|
671
|
+
//
|
|
672
|
+
// Read per source file, not from the merged dict: a merge can conceal a dual
|
|
673
|
+
// node whose children come from one file and whose $value comes from another,
|
|
674
|
+
// and the author fixes this file by file.
|
|
675
|
+
const dualNodes = [...new Set(sources.flatMap((s) => findDualNodes(s.dtcg)))];
|
|
676
|
+
if (dualNodes.length) advisories.push({ rule: 'dual-node', paths: dualNodes });
|
|
677
|
+
|
|
230
678
|
const graph = textRoleGraph(mergeDtcg(sources.map((s) => s.dtcg)));
|
|
231
679
|
for (const { path, group } of graph.unreferencedSiblings) {
|
|
232
680
|
advisories.push({ rule: 'unreferenced-text-sibling', token: path, group });
|
|
@@ -236,52 +684,135 @@ export function validate({ sources, output, platform, minMatch = 0.5 }) {
|
|
|
236
684
|
}
|
|
237
685
|
|
|
238
686
|
const matchRate = decls.length ? matched / decls.length : 0;
|
|
239
|
-
const ok =
|
|
687
|
+
const ok =
|
|
688
|
+
failures.length === 0 &&
|
|
689
|
+
collisions.length === 0 &&
|
|
690
|
+
normalizationCollisions.length === 0 &&
|
|
691
|
+
matched > 0 &&
|
|
692
|
+
matchRate >= minMatch;
|
|
240
693
|
|
|
241
694
|
const unparsedLines = countUnparsedLines(output, DECL[platform]);
|
|
695
|
+
// NAMED, not just counted (#57). A token with no native form is filtered out
|
|
696
|
+
// of native output, and a nested construct like rgba(var(--brand), 0.5) is one
|
|
697
|
+
// of them — the filter's exemption is anchored, deliberately, because the
|
|
698
|
+
// module cannot tell a rescuable outer function from linear-gradient(). Before
|
|
699
|
+
// this the only trace such a token left was a number, which is the same
|
|
700
|
+
// silence this release exists to remove everywhere else.
|
|
242
701
|
const emittedKeys = new Set(decls.map((d) => normalizeKey(d.symbol)));
|
|
243
|
-
|
|
244
|
-
for (const key of byKey
|
|
702
|
+
const unemittedPaths = [];
|
|
703
|
+
for (const [key, path] of byKey) if (!emittedKeys.has(key)) unemittedPaths.push(path);
|
|
704
|
+
const unemittedTokens = unemittedPaths.length;
|
|
245
705
|
|
|
246
|
-
return { total: decls.length, matched, matchRate, failures, advisories, collisions, minMatch, ok, unparsedLines, unemittedTokens };
|
|
706
|
+
return { total: decls.length, matched, matchRate, failures, advisories, collisions, normalizationCollisions, minMatch, ok, unparsedLines, unemittedTokens, unemittedPaths };
|
|
247
707
|
}
|
|
248
708
|
|
|
249
709
|
export function formatReport(r) {
|
|
250
710
|
const lines = [];
|
|
711
|
+
// Step 5 (docs/specs/2026-09-11-web-token-output-validation.md). Native
|
|
712
|
+
// results carry no `platform` field at all, so `web` is false for every
|
|
713
|
+
// pre-existing caller and every line below stays on its original,
|
|
714
|
+
// byte-identical branch.
|
|
715
|
+
const web = r.platform in WEB_PLATFORMS;
|
|
251
716
|
const pct = (r.matchRate * 100).toFixed(0);
|
|
252
|
-
|
|
717
|
+
if (web) {
|
|
718
|
+
lines.push(
|
|
719
|
+
`tokens:validate-output — ${r.matched}/${r.total - r.aliases} declarations in ${r.block ?? 'the output'} matched a source token (${pct}%)`,
|
|
720
|
+
);
|
|
721
|
+
if (r.aliases) {
|
|
722
|
+
lines.push(
|
|
723
|
+
`\n${r.aliases} alias declaration(s) with no source token — each is a var() to a variable the output declares, so they are left out of the match rate.`,
|
|
724
|
+
);
|
|
725
|
+
}
|
|
726
|
+
} else {
|
|
727
|
+
lines.push(`tokens:validate-output — ${r.matched}/${r.total} emitted symbols matched a source token (${pct}%)`);
|
|
728
|
+
}
|
|
253
729
|
if (r.collisions.length) {
|
|
254
730
|
lines.push(`\n${r.collisions.length} mode collision(s) — the source list spans modes:`);
|
|
255
731
|
for (const c of r.collisions) {
|
|
256
732
|
lines.push(` - ${c.path}: ${c.defs.map((d) => `${d.file}=${JSON.stringify(d.value)}`).join(', ')}`);
|
|
257
733
|
}
|
|
258
734
|
}
|
|
735
|
+
if (r.normalizationCollisions?.length) {
|
|
736
|
+
lines.push(
|
|
737
|
+
`\n${r.normalizationCollisions.length} name collision(s) — distinct source paths that reduce to one symbol name:`,
|
|
738
|
+
);
|
|
739
|
+
for (const c of r.normalizationCollisions) {
|
|
740
|
+
lines.push(` - ${c.key}: ${c.paths.join(' vs ')}`);
|
|
741
|
+
}
|
|
742
|
+
lines.push(
|
|
743
|
+
web
|
|
744
|
+
? `\nThese reduce to the same variable name, so the output declares it twice and the later one silently wins. They are also excluded from matching above, because there is no way to tell which source token a declaration came from. Rename one side in source.`
|
|
745
|
+
: `\nThese emit the same symbol name, so the generated file declares it more than once and will not compile. They are also excluded from matching above, because there is no way to tell which source token an emitted symbol came from. Rename one side in source.`,
|
|
746
|
+
);
|
|
747
|
+
}
|
|
259
748
|
if (r.failures.length) {
|
|
260
749
|
lines.push(`\n${r.failures.length} rule failure(s):`);
|
|
261
750
|
for (const f of r.failures) {
|
|
262
751
|
lines.push(
|
|
263
|
-
|
|
264
|
-
?
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
752
|
+
web
|
|
753
|
+
? f.rule === 'unit-fidelity'
|
|
754
|
+
? ` - [unit-fidelity] ${f.symbol}: source ${f.source} for ${f.token}, emitted ${f.emitted}`
|
|
755
|
+
: f.rule === 'reference-fidelity'
|
|
756
|
+
? ` - [reference-fidelity] ${f.symbol}: source ${f.source} for ${f.token}, emitted ${f.emitted} — its var() references don't name the tokens the source references`
|
|
757
|
+
: f.rule === 'dangling-reference'
|
|
758
|
+
? ` - [dangling-reference] ${f.symbol}: references ${f.reference}, which no --output declares`
|
|
759
|
+
: f.rule === 'no-unresolved-reference'
|
|
760
|
+
? ` - [no-unresolved-reference] ${f.symbol}: ${f.emitted} — a {reference} reached the output unresolved`
|
|
761
|
+
: f.rule === 'invalid-value'
|
|
762
|
+
? ` - [invalid-value] ${f.symbol}: ${f.emitted} — a JavaScript value was written into the CSS`
|
|
763
|
+
: f.rule === 'unverifiable-dimension'
|
|
764
|
+
? ` - [${f.rule}] ${f.symbol}: source ${f.source} has a dimension magnitude but emitted ${f.emitted} could not be read — the token was never actually compared`
|
|
765
|
+
: ` - [${f.rule}] ${f.symbol}: ${f.emitted}`
|
|
766
|
+
: f.rule === 'invalid-literal'
|
|
767
|
+
? ` - [${f.rule}] ${f.symbol}: emitted \`${f.emitted}\` is not a valid ${f.platform} literal — parsing stopped at offset ${f.offset} (${JSON.stringify(f.rest.slice(0, 30))})`
|
|
768
|
+
: f.rule === 'unit-fidelity'
|
|
769
|
+
? ` - [${f.rule}] ${f.symbol}: source ${f.source} expects ${f.expected}, emitted ${f.emitted} (${f.actual})`
|
|
770
|
+
: f.rule === 'unverifiable-dimension'
|
|
771
|
+
? ` - [${f.rule}] ${f.symbol}: source ${f.source} has a dimension magnitude but emitted ${f.emitted} could not be read — the token was never actually compared`
|
|
772
|
+
: ` - [${f.rule}] ${f.symbol}: ${f.emitted}`,
|
|
270
773
|
);
|
|
271
774
|
}
|
|
272
|
-
if (r.failures.some((f) => f.rule === 'invalid-literal')) {
|
|
775
|
+
if (!web && r.failures.some((f) => f.rule === 'invalid-literal')) {
|
|
273
776
|
lines.push(
|
|
274
777
|
`\nAn invalid-literal value will not compile. A string value must be quoted — add its $type to the quoting transform in lib/sd-native.mjs. A CSS construct such as linear-gradient() has no native form and should be filtered out of native builds instead.`,
|
|
275
778
|
);
|
|
276
779
|
}
|
|
780
|
+
if (web) {
|
|
781
|
+
if (r.failures.some((f) => f.rule === 'no-unresolved-reference' || f.rule === 'dangling-reference')) {
|
|
782
|
+
lines.push(
|
|
783
|
+
`\nA {reference} left in the output, or a var() to a variable nothing declares, usually means the build did not reach a token that is a child of a node carrying its own $value. A build split across files must pass each file it ships as another --output.`,
|
|
784
|
+
);
|
|
785
|
+
}
|
|
786
|
+
if (r.failures.some((f) => f.rule === 'invalid-value')) {
|
|
787
|
+
lines.push(
|
|
788
|
+
`\nAn [object Object] value is a composite token, such as typography or a shadow, with no shorthand transform registered for this platform.`,
|
|
789
|
+
);
|
|
790
|
+
}
|
|
791
|
+
}
|
|
277
792
|
}
|
|
793
|
+
// The naming-convention diagnosis is wrong when collisions are what removed
|
|
794
|
+
// the tokens, and a confident wrong cause sends the author to the wrong file.
|
|
795
|
+
// Name the collisions instead, and only then fall back to the convention.
|
|
796
|
+
const collisionNote = r.normalizationCollisions?.length
|
|
797
|
+
? ` The ${r.normalizationCollisions.length} name collision(s) above were excluded from matching, which may be the whole of it — resolve those first.`
|
|
798
|
+
: '';
|
|
278
799
|
if (r.matched === 0) {
|
|
279
|
-
lines.push(
|
|
800
|
+
lines.push(
|
|
801
|
+
r.normalizationCollisions?.length
|
|
802
|
+
? `\nNo emitted symbol matched any source token, so nothing was actually verified.${collisionNote}`
|
|
803
|
+
: web
|
|
804
|
+
? `\nNo declaration matched any source token, so nothing was verified. The variable names don't line up with the source token paths — a likely cause is a name transform that doesn't build the name from the whole path, as Style Dictionary's name/kebab does. A JavaScript theme file has no custom properties to read at all.`
|
|
805
|
+
: `\nNo emitted symbol matched any source token — the adapter's naming convention does not line up, so nothing was actually verified. A likely cause is a declaration form the DECL pattern does not match (e.g. a different accessControl such as "internal static let ...").`,
|
|
806
|
+
);
|
|
280
807
|
} else if (r.matchRate < r.minMatch) {
|
|
281
|
-
lines.push(`\nMatch rate ${pct}% is below the ${(r.minMatch * 100).toFixed(0)}% floor — most output went unchecked
|
|
808
|
+
lines.push(`\nMatch rate ${pct}% is below the ${(r.minMatch * 100).toFixed(0)}% floor — most output went unchecked.${collisionNote}`);
|
|
282
809
|
}
|
|
283
810
|
if (r.unparsedLines) {
|
|
284
|
-
lines.push(
|
|
811
|
+
lines.push(
|
|
812
|
+
web
|
|
813
|
+
? `\n${r.unparsedLines} declaration(s) that are not custom properties — not checked, and not counted above.`
|
|
814
|
+
: `\n${r.unparsedLines} unparsed line(s) — declaration-shaped lines the extractor could not read; they count in neither the numerator nor the denominator above.`,
|
|
815
|
+
);
|
|
285
816
|
}
|
|
286
817
|
if (r.advisories?.length) {
|
|
287
818
|
lines.push(`\n${r.advisories.length} advisory note(s) — reported, not gating:`);
|
|
@@ -292,6 +823,16 @@ export function formatReport(r) {
|
|
|
292
823
|
);
|
|
293
824
|
continue;
|
|
294
825
|
}
|
|
826
|
+
if (a.rule === 'dual-node') {
|
|
827
|
+
const shown = a.paths.slice(0, 5).join(', ');
|
|
828
|
+
const more = a.paths.length > 5 ? `, ...and ${a.paths.length - 5} more` : '';
|
|
829
|
+
lines.push(
|
|
830
|
+
web
|
|
831
|
+
? ` - [${a.rule}] ${a.paths.length} node(s) carry both a $value and child tokens: ${shown}${more}. DTCG §6.1 makes that invalid, and §6.2 defines $root as the way a group carries a base value alongside children. Stock Style Dictionary does not descend into these nodes, so their children can be missing from web output. If a no-unresolved-reference or dangling-reference failure appears above, that is why.`
|
|
832
|
+
: ` - [${a.rule}] ${a.paths.length} node(s) carry both a $value and child tokens: ${shown}${more}. DTCG §6.1 makes that invalid — an object cannot be both a token and a group — and §6.2 defines $root as the way a group carries a base value alongside children. The build handles this shape and will keep handling it; nothing here is broken. Rewrite them as $root only if you want the source to conform.`,
|
|
833
|
+
);
|
|
834
|
+
continue;
|
|
835
|
+
}
|
|
295
836
|
if (a.rule === 'ambiguous-text-role') {
|
|
296
837
|
lines.push(
|
|
297
838
|
` - [${a.rule}] ${a.token}: referenced both by typographic member(s) [${a.textLeaves.join(', ')}] and by [${a.otherLeaves.join(', ')}], so no role was inferred rather than a role being guessed. Stamp $extensions["${EXT_NS}"].nativeUnit in source to settle it.`,
|
|
@@ -299,12 +840,22 @@ export function formatReport(r) {
|
|
|
299
840
|
continue;
|
|
300
841
|
}
|
|
301
842
|
lines.push(
|
|
302
|
-
|
|
843
|
+
web
|
|
844
|
+
? ` - [${a.rule}] ${a.symbol}: source ${JSON.stringify(a.source)} for ${a.token} is a dimension with no unit, which DTCG §8.2.1 does not permit. It emitted ${a.emitted}. If it is a ratio, type it "number" (§8.7); if it is a measurement, add the unit you meant.`
|
|
845
|
+
: ` - [${a.rule}] ${a.symbol}: source ${JSON.stringify(a.source)} for ${a.token} is a dimension with no unit, which DTCG §8.2.1 does not permit. It emitted ${a.emitted}, read as a ratio. If it is a ratio, type it "number" (§8.7); if it is a measurement, add the unit you meant.`,
|
|
303
846
|
);
|
|
304
847
|
}
|
|
305
848
|
}
|
|
306
849
|
if (r.unemittedTokens) {
|
|
307
|
-
|
|
850
|
+
const paths = r.unemittedPaths ?? [];
|
|
851
|
+
const shown = paths.slice(0, 10).join(', ');
|
|
852
|
+
const more = paths.length > 10 ? `, ...and ${paths.length - 10} more` : '';
|
|
853
|
+
lines.push(
|
|
854
|
+
web
|
|
855
|
+
? `\n${r.unemittedTokens} source token(s) are declared nowhere in the output${paths.length ? `: ${shown}${more}` : ''}. A token this build filtered out, or the child of a node carrying its own $value that the build did not reach, shows up here.`
|
|
856
|
+
: `\n${r.unemittedTokens} source token(s) had no matching emitted symbol${paths.length ? `: ${shown}${more}` : ''}.` +
|
|
857
|
+
' A value with no native form is filtered out of native output rather than emitted broken — a CSS construct nested inside another function is the common case.',
|
|
858
|
+
);
|
|
308
859
|
}
|
|
309
860
|
return lines;
|
|
310
861
|
}
|
|
@@ -315,8 +866,9 @@ function main() {
|
|
|
315
866
|
const parsed = parseArgs({
|
|
316
867
|
options: {
|
|
317
868
|
source: { type: 'string', multiple: true },
|
|
318
|
-
output: { type: 'string' },
|
|
869
|
+
output: { type: 'string', multiple: true },
|
|
319
870
|
platform: { type: 'string' },
|
|
871
|
+
block: { type: 'string' },
|
|
320
872
|
'min-match': { type: 'string' },
|
|
321
873
|
},
|
|
322
874
|
});
|
|
@@ -326,8 +878,18 @@ function main() {
|
|
|
326
878
|
process.exit(2);
|
|
327
879
|
}
|
|
328
880
|
|
|
329
|
-
if (!values.source?.length || !values.output || !values.platform) {
|
|
330
|
-
console.error('usage: validate-token-output.mjs --source <a.json> [--source <b.json>...] --output <
|
|
881
|
+
if (!values.source?.length || !values.output?.length || !values.platform) {
|
|
882
|
+
console.error('usage: validate-token-output.mjs --source <a.json> [--source <b.json>...] --output <file> [--output <file>...] --platform <ios-swift|android-kotlin|shadcn|tailwind|vanilla-css> [--block <selector>] [--min-match <ratio>]');
|
|
883
|
+
process.exit(2);
|
|
884
|
+
}
|
|
885
|
+
|
|
886
|
+
const web = values.platform in WEB_PLATFORMS;
|
|
887
|
+
if (!web && values.output.length > 1) {
|
|
888
|
+
console.error('--output may be given more than once only for a web platform (shadcn, tailwind, vanilla-css)');
|
|
889
|
+
process.exit(2);
|
|
890
|
+
}
|
|
891
|
+
if (!web && values.block !== undefined) {
|
|
892
|
+
console.error('--block applies only to a web platform (shadcn, tailwind, vanilla-css)');
|
|
331
893
|
process.exit(2);
|
|
332
894
|
}
|
|
333
895
|
|
|
@@ -341,13 +903,16 @@ function main() {
|
|
|
341
903
|
}
|
|
342
904
|
}
|
|
343
905
|
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
906
|
+
const outputs = [];
|
|
907
|
+
for (const file of values.output) {
|
|
908
|
+
try {
|
|
909
|
+
outputs.push(readFileSync(file, 'utf8'));
|
|
910
|
+
} catch (e) {
|
|
911
|
+
console.error(`error reading output file ${file}: ${e.message}`);
|
|
912
|
+
process.exit(2);
|
|
913
|
+
}
|
|
350
914
|
}
|
|
915
|
+
const output = outputs.join('\n');
|
|
351
916
|
|
|
352
917
|
let minMatch;
|
|
353
918
|
try {
|
|
@@ -362,7 +927,7 @@ function main() {
|
|
|
362
927
|
|
|
363
928
|
let r;
|
|
364
929
|
try {
|
|
365
|
-
r = validate({ sources, output, platform: values.platform, minMatch });
|
|
930
|
+
r = validate({ sources, output, platform: values.platform, minMatch, block: values.block });
|
|
366
931
|
} catch (e) {
|
|
367
932
|
console.error(e.message);
|
|
368
933
|
process.exit(2);
|