@radicool/throughline 0.15.0 → 0.16.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.
@@ -7,36 +7,10 @@ import { readFileSync } from 'node:fs';
7
7
  import { parseArgs } from 'node:util';
8
8
  import { pathToFileURL } from 'node:url';
9
9
  import { loadCrosswalk, statusCounts } from './lib/crosswalk.mjs';
10
+ import { flattenDtcg, resolveValue } from './lib/dtcg.mjs';
10
11
 
11
- const REF = /^\{([^}]+)\}$/;
12
-
13
- // Flatten nested DTCG groups into { "dot.path": rawValue }. Skips $-prefixed meta keys.
14
- export function flattenDtcg(obj, prefix = [], out = {}) {
15
- for (const [key, val] of Object.entries(obj)) {
16
- if (key.startsWith('$')) continue;
17
- if (val && typeof val === 'object' && '$value' in val) {
18
- out[[...prefix, key].join('.')] = val.$value;
19
- } else if (val && typeof val === 'object') {
20
- flattenDtcg(val, [...prefix, key], out);
21
- }
22
- }
23
- return out;
24
- }
25
-
26
- // Follow {alias} chains to a leaf literal. Throws on missing or circular refs.
27
- export function resolveValue(name, flat, seen = new Set()) {
28
- if (!(name in flat)) throw new Error(`token "${name}" not found in DTCG source`);
29
- const val = flat[name];
30
- if (typeof val === 'string') {
31
- const m = val.match(REF);
32
- if (m) {
33
- if (seen.has(name)) throw new Error(`circular reference at "${name}"`);
34
- seen.add(name);
35
- return resolveValue(m[1], flat, seen);
36
- }
37
- }
38
- return val;
39
- }
12
+ // Re-exported so consumers (and the test file) keep one import surface.
13
+ export { flattenDtcg, resolveValue };
40
14
 
41
15
  function norm(v) {
42
16
  return String(v).trim().toLowerCase();
@@ -0,0 +1,338 @@
1
+ // Native token output validator: assert generated Swift/Kotlin matches its DTCG source.
2
+ // Catches output that compiles but is wrong. Zero dependencies.
3
+ //
4
+ // Usage:
5
+ // node validate-token-output.mjs --source a.json --source b.json \
6
+ // --output Tokens.swift --platform ios-swift [--min-match 0.5]
7
+ import { readFileSync } from 'node:fs';
8
+ import { parseArgs } from 'node:util';
9
+ import { pathToFileURL } from 'node:url';
10
+ import { flattenDtcg, flattenDtcgTypes, resolveValue, findModeCollisions } from './lib/dtcg.mjs';
11
+ import { parseLiteral, isValidLiteral, GRAMMAR } from './lib/native-literal.mjs';
12
+
13
+ // Re-exported so consumers (and the test file) keep one import surface.
14
+ export { flattenDtcg, flattenDtcgTypes, resolveValue, findModeCollisions };
15
+
16
+ // Declaration patterns per platform. Coupled to the ios-swift/enum.swift and
17
+ // compose/object output formats; a different format needs a different pattern,
18
+ // which surfaces as a zero-match failure rather than a silent pass.
19
+ const DECL = {
20
+ 'ios-swift': /^\s*(?:public\s+)?static\s+let\s+([A-Za-z_]\w*)\s*=\s*(.+?)\s*$/,
21
+ 'android-kotlin': /^\s*val\s+([A-Za-z_]\w*)\s*=\s*(.+?)\s*$/,
22
+ };
23
+
24
+ // Style Dictionary's ios-swift/enum.swift format emits an inline trailing
25
+ // comment for any token carrying a $description ("... /** Small body text */").
26
+ // Strip a TRAILING comment only — a value that legitimately contains "//"
27
+ // inside a string literal must survive untouched.
28
+ const TRAILING_COMMENT = /\s+(\/\*\*?[\s\S]*\*\/|\/\/.*)$/;
29
+
30
+ export function extractDeclarations(text, platform) {
31
+ const re = DECL[platform];
32
+ if (!re) throw new Error(`unknown platform "${platform}"`);
33
+ const out = [];
34
+ for (const line of text.split('\n')) {
35
+ const m = line.match(re);
36
+ if (m) out.push({ symbol: m[1], value: m[2].replace(TRAILING_COMMENT, '').trim() });
37
+ }
38
+ return out;
39
+ }
40
+
41
+ // Known dimension wrappers. Multi-argument constructors (colors) never match,
42
+ // so they are exempt from unit-fidelity by construction.
43
+ const MAGNITUDE = [
44
+ /^CGFloat\(\s*(-?(?:\d+(?:\.\d+)?|\.\d+))\s*\)$/,
45
+ /^(-?(?:\d+(?:\.\d+)?|\.\d+))\.(?:dp|sp)$/,
46
+ /^(-?(?:\d+(?:\.\d+)?|\.\d+))$/,
47
+ ];
48
+
49
+ export function magnitudeOf(value) {
50
+ for (const re of MAGNITUDE) {
51
+ const m = value.match(re);
52
+ if (m) return Number(m[1]);
53
+ }
54
+ return null;
55
+ }
56
+
57
+ // Adapters name tokens differently (color.bg.canvas -> colorBgCanvas -> color_bg_canvas).
58
+ // Lowercase and strip every non-alphanumeric so all conventions compare equal.
59
+ export function normalizeKey(s) {
60
+ return String(s).toLowerCase().replace(/[^a-z0-9]/g, '');
61
+ }
62
+
63
+ const UNIT = /^(-?(?:\d+(?:\.\d+)?|\.\d+))([a-z%]*)$/;
64
+
65
+ // Expected native magnitude for an authored source value. iOS points and Android
66
+ // dp both map 1:1 to CSS px by convention; rem is root-relative at a 16px root;
67
+ // a unitless dimension is a ratio and is never scaled. % and em have no native
68
+ // equivalent, so they are skipped here and caught by no-bare-units if emitted raw.
69
+ export function expectedMagnitude(sourceValue) {
70
+ if (typeof sourceValue === 'number') return { magnitude: sourceValue };
71
+ if (typeof sourceValue !== 'string') return { skip: 'non-scalar' };
72
+ const m = sourceValue.trim().match(UNIT);
73
+ if (!m) return { skip: 'not-a-dimension' };
74
+ const n = Number(m[1]);
75
+ switch (m[2]) {
76
+ case 'px':
77
+ case '':
78
+ return { magnitude: n };
79
+ case 'rem':
80
+ return { magnitude: n * 16 };
81
+ case '%':
82
+ case 'em':
83
+ return { skip: 'not-expressible' };
84
+ default:
85
+ return { skip: 'not-a-dimension' };
86
+ }
87
+ }
88
+
89
+ // Unanchored: matches this text anywhere in the value, including inside a
90
+ // quoted string. That is deliberate for the bare case — an unrescued
91
+ // calc(...)/var(...)/color-mix(...) leaks CSS syntax wherever it sits — but it
92
+ // means a well-formed quoted literal whose TEXT happens to contain "calc(" or
93
+ // "var(" (e.g. a $type: string value describing CSS) would also match. The
94
+ // isValidLiteral gate below is what tells those apart: a value the grammar
95
+ // accepts as a literal is not foreign syntax, whatever text it contains.
96
+ const FOREIGN = /(?:color-mix|calc|var)\s*\(/;
97
+ const BARE_UNIT = /^-?(?:\d+(?:\.\d+)?|\.\d+)(?:px|rem|em|%)$/;
98
+
99
+ // #52. A unitless value is a ratio, not a measurement: DTCG 8.2.1 requires a
100
+ // dimension to carry a unit, and 8.7's `number` is the type for a multiplier.
101
+ // Distinct from BARE_UNIT, which requires a unit SUFFIX and so never matches
102
+ // this — which is exactly why the shape passed the gate silently before.
103
+ const UNITLESS = /^-?(?:\d+(?:\.\d+)?|\.\d+)$/;
104
+ const DIMENSIONAL = new Set(['dimension', 'fontSize']);
105
+
106
+ // A token whose RAW authored $value is itself a whole-value reference is not
107
+ // flagged: its referent is (per DTCG 5.2.2, an alias takes the referent's
108
+ // type), and `source` above is already the RESOLVED value, so testing it
109
+ // alone would flag both the alias and its referent for the same problem.
110
+ const WHOLE_REF = /^\{[^}]+\}$/;
111
+
112
+ // Lines that are obviously not a would-be token declaration: braces-only,
113
+ // comments, imports/package/annotations, or the container declarations
114
+ // (enum/object/class) themselves. Anything else that DECL failed to match is
115
+ // a genuine unparsed line, not noise — conservatively under-count rather than
116
+ // over-count (a false "unparsed" is noise the brief warns against).
117
+ const STRUCTURAL_PREFIX = /^(\/\/|\/\*|\*|import\b|package\b|@)/;
118
+ const STRUCTURAL_CONTAINS = /\b(enum|object|class)\s/;
119
+
120
+ // Non-blank output lines DECL could not parse and that aren't structural —
121
+ // the denominator's blind spot. Made visible, not enforced (Decision 6 only
122
+ // covers zero matches).
123
+ function countUnparsedLines(text, declRe) {
124
+ let count = 0;
125
+ for (const line of text.split('\n')) {
126
+ const trimmed = line.trim();
127
+ if (!trimmed) continue;
128
+ if (declRe.test(line)) continue;
129
+ if (/^[{}]+$/.test(trimmed)) continue;
130
+ if (STRUCTURAL_PREFIX.test(trimmed)) continue;
131
+ if (STRUCTURAL_CONTAINS.test(trimmed)) continue;
132
+ count += 1;
133
+ }
134
+ return count;
135
+ }
136
+
137
+ export function validate({ sources, output, platform, minMatch = 0.5 }) {
138
+ const collisions = findModeCollisions(sources);
139
+
140
+ const flat = {};
141
+ for (const { dtcg } of sources) Object.assign(flat, flattenDtcg(dtcg));
142
+
143
+ const types = {};
144
+ for (const { dtcg } of sources) Object.assign(types, flattenDtcgTypes(dtcg));
145
+
146
+ const byKey = new Map();
147
+ for (const path of Object.keys(flat)) byKey.set(normalizeKey(path), path);
148
+
149
+ const decls = extractDeclarations(output, platform);
150
+ const failures = [];
151
+ const advisories = [];
152
+ let matched = 0;
153
+
154
+ for (const { symbol, value } of decls) {
155
+ // The two specific rules diagnose better than "not a valid literal", so
156
+ // they win; invalid-literal is the general net underneath them. Reporting
157
+ // one symbol under all three would be noise.
158
+ const foreign = FOREIGN.test(value) && !isValidLiteral(value, GRAMMAR[platform]);
159
+ const bare = BARE_UNIT.test(value);
160
+ if (foreign) failures.push({ rule: 'no-foreign-syntax', symbol, emitted: value });
161
+ if (bare) failures.push({ rule: 'no-bare-units', symbol, emitted: value });
162
+ if (!foreign && !bare) {
163
+ // Before the name-match `continue` below: a symbol that resolves to no
164
+ // source token must not escape a validity check by being unnamed.
165
+ const parsed = parseLiteral(value, GRAMMAR[platform]);
166
+ if (!parsed.ok) {
167
+ failures.push({
168
+ rule: 'invalid-literal',
169
+ symbol,
170
+ emitted: value,
171
+ platform,
172
+ offset: parsed.offset,
173
+ rest: parsed.rest,
174
+ });
175
+ }
176
+ }
177
+
178
+ const path = byKey.get(normalizeKey(symbol));
179
+ if (!path) continue;
180
+
181
+ let source;
182
+ try {
183
+ source = resolveValue(path, flat);
184
+ } catch {
185
+ continue;
186
+ }
187
+ matched += 1;
188
+
189
+ // Advisory, not a failure: the emitted value is correct under the ratio
190
+ // reading this build applies, so it compiles and its magnitude matches.
191
+ // What is wrong is the SOURCE's $type, which only the author can settle.
192
+ if (
193
+ UNITLESS.test(String(source).trim()) &&
194
+ DIMENSIONAL.has(types[path]) &&
195
+ !WHOLE_REF.test(String(flat[path]).trim())
196
+ ) {
197
+ advisories.push({ rule: 'unitless-dimension', symbol, token: path, source, emitted: value });
198
+ }
199
+
200
+ const expected = expectedMagnitude(source);
201
+ if (expected.skip) continue;
202
+ const actual = magnitudeOf(value);
203
+ if (actual === null) {
204
+ failures.push({ rule: 'unverifiable-dimension', symbol, token: path, source, emitted: value });
205
+ continue;
206
+ }
207
+ if (Math.abs(actual - expected.magnitude) > 0.001) {
208
+ failures.push({ rule: 'unit-fidelity', symbol, token: path, source, emitted: value, expected: expected.magnitude, actual });
209
+ }
210
+ }
211
+
212
+ const matchRate = decls.length ? matched / decls.length : 0;
213
+ const ok = failures.length === 0 && collisions.length === 0 && matched > 0 && matchRate >= minMatch;
214
+
215
+ const unparsedLines = countUnparsedLines(output, DECL[platform]);
216
+ const emittedKeys = new Set(decls.map((d) => normalizeKey(d.symbol)));
217
+ let unemittedTokens = 0;
218
+ for (const key of byKey.keys()) if (!emittedKeys.has(key)) unemittedTokens += 1;
219
+
220
+ return { total: decls.length, matched, matchRate, failures, advisories, collisions, minMatch, ok, unparsedLines, unemittedTokens };
221
+ }
222
+
223
+ export function formatReport(r) {
224
+ const lines = [];
225
+ const pct = (r.matchRate * 100).toFixed(0);
226
+ lines.push(`tokens:validate-output — ${r.matched}/${r.total} emitted symbols matched a source token (${pct}%)`);
227
+ if (r.collisions.length) {
228
+ lines.push(`\n${r.collisions.length} mode collision(s) — the source list spans modes:`);
229
+ for (const c of r.collisions) {
230
+ lines.push(` - ${c.path}: ${c.defs.map((d) => `${d.file}=${JSON.stringify(d.value)}`).join(', ')}`);
231
+ }
232
+ }
233
+ if (r.failures.length) {
234
+ lines.push(`\n${r.failures.length} rule failure(s):`);
235
+ for (const f of r.failures) {
236
+ lines.push(
237
+ f.rule === 'invalid-literal'
238
+ ? ` - [${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))})`
239
+ : f.rule === 'unit-fidelity'
240
+ ? ` - [${f.rule}] ${f.symbol}: source ${f.source} expects ${f.expected}, emitted ${f.emitted} (${f.actual})`
241
+ : f.rule === 'unverifiable-dimension'
242
+ ? ` - [${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`
243
+ : ` - [${f.rule}] ${f.symbol}: ${f.emitted}`,
244
+ );
245
+ }
246
+ if (r.failures.some((f) => f.rule === 'invalid-literal')) {
247
+ lines.push(
248
+ `\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.`,
249
+ );
250
+ }
251
+ }
252
+ if (r.matched === 0) {
253
+ lines.push(`\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 ...").`);
254
+ } else if (r.matchRate < r.minMatch) {
255
+ lines.push(`\nMatch rate ${pct}% is below the ${(r.minMatch * 100).toFixed(0)}% floor — most output went unchecked.`);
256
+ }
257
+ if (r.unparsedLines) {
258
+ lines.push(`\n${r.unparsedLines} unparsed line(s) — declaration-shaped lines the extractor could not read; they count in neither the numerator nor the denominator above.`);
259
+ }
260
+ if (r.advisories?.length) {
261
+ lines.push(`\n${r.advisories.length} advisory note(s) — reported, not gating:`);
262
+ for (const a of r.advisories) {
263
+ lines.push(
264
+ ` - [${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.`,
265
+ );
266
+ }
267
+ }
268
+ if (r.unemittedTokens) {
269
+ lines.push(`\n${r.unemittedTokens} source token(s) had no matching emitted symbol.`);
270
+ }
271
+ return lines;
272
+ }
273
+
274
+ function main() {
275
+ let values;
276
+ try {
277
+ const parsed = parseArgs({
278
+ options: {
279
+ source: { type: 'string', multiple: true },
280
+ output: { type: 'string' },
281
+ platform: { type: 'string' },
282
+ 'min-match': { type: 'string' },
283
+ },
284
+ });
285
+ values = parsed.values;
286
+ } catch (e) {
287
+ console.error(e.message);
288
+ process.exit(2);
289
+ }
290
+
291
+ if (!values.source?.length || !values.output || !values.platform) {
292
+ console.error('usage: validate-token-output.mjs --source <a.json> [--source <b.json>...] --output <Tokens.swift|Tokens.kt> --platform <ios-swift|android-kotlin> [--min-match <ratio>]');
293
+ process.exit(2);
294
+ }
295
+
296
+ const sources = [];
297
+ for (const file of values.source) {
298
+ try {
299
+ sources.push({ file, dtcg: JSON.parse(readFileSync(file, 'utf8')) });
300
+ } catch (e) {
301
+ console.error(`error reading or parsing ${file}: ${e.message}`);
302
+ process.exit(2);
303
+ }
304
+ }
305
+
306
+ let output;
307
+ try {
308
+ output = readFileSync(values.output, 'utf8');
309
+ } catch (e) {
310
+ console.error(`error reading output file ${values.output}: ${e.message}`);
311
+ process.exit(2);
312
+ }
313
+
314
+ let minMatch;
315
+ try {
316
+ minMatch = values['min-match'] === undefined ? 0.5 : Number(values['min-match']);
317
+ if (!Number.isFinite(minMatch)) {
318
+ throw new Error(`--min-match must be a finite number, got "${values['min-match']}"`);
319
+ }
320
+ } catch (e) {
321
+ console.error(e.message);
322
+ process.exit(2);
323
+ }
324
+
325
+ let r;
326
+ try {
327
+ r = validate({ sources, output, platform: values.platform, minMatch });
328
+ } catch (e) {
329
+ console.error(e.message);
330
+ process.exit(2);
331
+ }
332
+ for (const line of formatReport(r)) console.log(line);
333
+ if (!r.ok) process.exit(1);
334
+ }
335
+
336
+ if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) {
337
+ main();
338
+ }