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