stylelint-plugin-rhythmguard 1.7.0 → 1.8.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/CHANGELOG.md CHANGED
@@ -6,6 +6,15 @@ The format follows Keep a Changelog principles and semantic versioning.
6
6
 
7
7
  ## [Unreleased]
8
8
 
9
+ ## [1.8.0] - 2026-05-23
10
+
11
+ ### Added
12
+
13
+ - Added `.rhythmguardrc.json` audit config loading with `--config` and `--no-config`.
14
+ - Added external audit token sources with `--token-source`, `--token-source-format`, and `--token-kind`.
15
+ - Added token source parsing for CSS custom properties, Tailwind v4 `@theme`, flat JSON, Style Dictionary JSON, and DTCG JSON.
16
+ - Expanded audit token-contract reporting with loaded source metadata, raw values that match known tokens, and conflicting token values.
17
+
9
18
  ## [1.7.0] - 2026-05-23
10
19
 
11
20
  ### Added
package/README.md CHANGED
@@ -93,9 +93,11 @@ npx rhythmguard audit . --ignore "apps/legacy/**" --ignore "vendor/**"
93
93
  npx rhythmguard audit ./src --write-baseline
94
94
  npx rhythmguard audit ./src --since-baseline --fail-on-new-drift
95
95
  npx rhythmguard audit ./src --staged --max-findings 0
96
+ npx rhythmguard audit ./src --token-source ./tokens.json
97
+ npx rhythmguard audit ./src --token-source ./theme.css --token-source-format css
96
98
  ```
97
99
 
98
- The report covers authored CSS declarations, Tailwind arbitrary spacing values in common template/source files, and token-contract drift such as missing spacing tokens, unused spacing tokens, and repeated raw values that deserve token review. Scan paths are scoped to the directory argument. Use `--ignore`, `.rhythmguardignore`, or `--ignore-path` for generated or legacy subtrees, then add baselines and CI thresholds when you are ready to gate new drift. Markdown output is PR-ready for UX developers, UX designers, and design-system owners:
100
+ The report covers authored CSS declarations, Tailwind arbitrary spacing values in common template/source files, and token-contract drift such as missing spacing tokens, unused spacing tokens, repeated raw values that deserve token review, raw values that match known tokens, and conflicting token values. Scan paths are scoped to the directory argument. Use `--ignore`, `.rhythmguardignore`, or `--ignore-path` for generated or legacy subtrees, then add baselines and CI thresholds when you are ready to gate new drift. Markdown output is PR-ready for UX developers, UX designers, and design-system owners:
99
101
 
100
102
  ```md
101
103
  # Rhythmguard Design-System Audit
@@ -110,6 +112,27 @@ The report covers authored CSS declarations, Tailwind arbitrary spacing values i
110
112
  | New findings | 3 |
111
113
  ```
112
114
 
115
+ ### Audit config and external token sources
116
+
117
+ For large codebases, put shared audit settings in `.rhythmguardrc.json`:
118
+
119
+ ```json
120
+ {
121
+ "audit": {
122
+ "ignore": ["legacy/**", "generated/**"],
123
+ "tokenSources": [
124
+ "./tokens.json",
125
+ { "path": "./src/theme.css", "format": "css" }
126
+ ],
127
+ "tokenKind": "spacing",
128
+ "tokenCandidateMinCount": 2,
129
+ "minCleanliness": 90
130
+ }
131
+ }
132
+ ```
133
+
134
+ `rhythmguard audit` loads `.rhythmguardrc.json` automatically when present. Use `--config <file>` for another config, `--no-config` to skip config discovery, and `--token-source <file>` for extra canonical token files. Token source paths in config files resolve from the config file directory; CLI token source paths resolve from the current working directory. Supported source formats are CSS custom properties and Tailwind v4 `@theme`, flat JSON maps, Style Dictionary JSON, and DTCG JSON.
135
+
113
136
  ## Installation
114
137
 
115
138
  ```bash
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "stylelint-plugin-rhythmguard",
3
- "version": "1.7.0",
3
+ "version": "1.8.0",
4
4
  "description": "Token governance for CSS and Tailwind — enforce spacing scales, require design tokens, catch arbitrary values",
5
5
  "bin": {
6
6
  "rhythmguard": "src/cli/index.js"
package/src/cli/audit.js CHANGED
@@ -6,6 +6,14 @@ const path = require('node:path');
6
6
 
7
7
  const { formatLength } = require('../utils/length');
8
8
  const { createTailwindClassAnalyzer } = require('../utils/tailwind-class-analysis');
9
+ const {
10
+ addDefinition,
11
+ createTokenKindMatcher,
12
+ getNormalizedValueKeys,
13
+ normalizeTokenKind,
14
+ normalizeTokenSourceFormat,
15
+ parseTokenSources,
16
+ } = require('../utils/token-sources');
9
17
 
10
18
  const args = process.argv.slice(3);
11
19
  const pluginPath = path.resolve(__dirname, '..', 'index.js');
@@ -13,8 +21,8 @@ const pluginPath = path.resolve(__dirname, '..', 'index.js');
13
21
  const DEFAULT_SCALE = [0, 4, 8, 12, 16, 24, 32];
14
22
  const DEFAULT_BASE_FONT_SIZE = 16;
15
23
  const DEFAULT_BASELINE_PATH = '.rhythmguard-baseline.json';
24
+ const DEFAULT_CONFIG_PATH = '.rhythmguardrc.json';
16
25
  const DEFAULT_IGNORE_PATH = '.rhythmguardignore';
17
- const DEFAULT_TOKEN_PATTERN = '^--spac(e|ing)-';
18
26
  const DEFAULT_TOKEN_CANDIDATE_MIN_COUNT = 2;
19
27
  const VALID_FORMATS = new Set(['text', 'json', 'markdown']);
20
28
  const SKIP_DIRS = new Set([
@@ -49,6 +57,8 @@ Options:
49
57
  --format <text|json|markdown> Output format (default: text)
50
58
  --json Alias for --format json
51
59
  --markdown Alias for --format markdown
60
+ --config <file> Load audit config (default: .rhythmguardrc.json when present)
61
+ --no-config Ignore .rhythmguardrc.json discovery
52
62
  --ignore <pattern> Exclude root-relative path/glob (repeatable, comma-separated)
53
63
  --ignore-path <file> Load ignore patterns from file (default: .rhythmguardignore when present)
54
64
  --baseline <file> Baseline file path (default: .rhythmguard-baseline.json)
@@ -59,6 +69,9 @@ Options:
59
69
  --min-cleanliness <percent> Exit 1 when scale cleanliness is lower than this percent
60
70
  --since <git-ref> Scan only changed files since a git ref
61
71
  --staged Scan only staged files
72
+ --token-source <file> External token source (repeatable, comma-separated)
73
+ --token-source-format <format> Token source format: auto, css, flat-json, style-dictionary, dtcg (default: auto)
74
+ --token-kind <kind> Token kind: spacing, radius, typography, size, all (default: spacing)
62
75
  --token-candidate-min-count <n> Minimum repeated raw value count for token candidates (default: 2)
63
76
  --scale <values> Comma-separated scale values (default: 0,4,8,12,16,24,32)
64
77
  --base-font-size <number> px base for rem/em conversion (default: 16)
@@ -68,6 +81,9 @@ function parseArgs(argv) {
68
81
  const parsed = {
69
82
  baselinePath: DEFAULT_BASELINE_PATH,
70
83
  baseFontSize: DEFAULT_BASE_FONT_SIZE,
84
+ cliOptions: new Set(),
85
+ configExplicit: false,
86
+ configPath: DEFAULT_CONFIG_PATH,
71
87
  dir: null,
72
88
  failOnNewDrift: false,
73
89
  format: 'text',
@@ -75,11 +91,15 @@ function parseArgs(argv) {
75
91
  ignorePatterns: [],
76
92
  maxFindings: null,
77
93
  minCleanliness: null,
94
+ noConfig: false,
78
95
  scale: DEFAULT_SCALE,
79
96
  since: null,
80
97
  sinceBaseline: false,
81
98
  staged: false,
82
99
  tokenCandidateMinCount: DEFAULT_TOKEN_CANDIDATE_MIN_COUNT,
100
+ tokenKind: 'spacing',
101
+ tokenSourceFormat: 'auto',
102
+ tokenSources: [],
83
103
  writeBaseline: false,
84
104
  };
85
105
 
@@ -103,38 +123,63 @@ function parseArgs(argv) {
103
123
 
104
124
  if (arg === '--ignore') {
105
125
  parsed.ignorePatterns.push(...parseIgnorePatterns(argv[++index]));
126
+ parsed.cliOptions.add('ignore');
106
127
  continue;
107
128
  }
108
129
 
109
130
  if (arg.startsWith('--ignore=')) {
110
131
  parsed.ignorePatterns.push(...parseIgnorePatterns(arg.slice('--ignore='.length)));
132
+ parsed.cliOptions.add('ignore');
133
+ continue;
134
+ }
135
+
136
+ if (arg === '--config') {
137
+ parsed.configPath = parsePathOption(argv[++index], '--config');
138
+ parsed.configExplicit = true;
139
+ continue;
140
+ }
141
+
142
+ if (arg.startsWith('--config=')) {
143
+ parsed.configPath = parsePathOption(arg.slice('--config='.length), '--config');
144
+ parsed.configExplicit = true;
145
+ continue;
146
+ }
147
+
148
+ if (arg === '--no-config') {
149
+ parsed.noConfig = true;
111
150
  continue;
112
151
  }
113
152
 
114
153
  if (arg === '--ignore-path') {
115
154
  parsed.ignorePath = parsePathOption(argv[++index], '--ignore-path');
155
+ parsed.cliOptions.add('ignorePath');
116
156
  continue;
117
157
  }
118
158
 
119
159
  if (arg.startsWith('--ignore-path=')) {
120
160
  parsed.ignorePath = parsePathOption(arg.slice('--ignore-path='.length), '--ignore-path');
161
+ parsed.cliOptions.add('ignorePath');
121
162
  continue;
122
163
  }
123
164
 
124
165
  if (arg === '--baseline') {
125
166
  parsed.baselinePath = parsePathOption(argv[++index], '--baseline');
167
+ parsed.cliOptions.add('baselinePath');
126
168
  continue;
127
169
  }
128
170
 
129
171
  if (arg.startsWith('--baseline=')) {
130
172
  parsed.baselinePath = parsePathOption(arg.slice('--baseline='.length), '--baseline');
173
+ parsed.cliOptions.add('baselinePath');
131
174
  continue;
132
175
  }
133
176
 
134
177
  if (arg === '--write-baseline') {
135
178
  parsed.writeBaseline = true;
179
+ parsed.cliOptions.add('writeBaseline');
136
180
  if (argv[index + 1] && !argv[index + 1].startsWith('-')) {
137
181
  parsed.baselinePath = parsePathOption(argv[++index], '--write-baseline');
182
+ parsed.cliOptions.add('baselinePath');
138
183
  }
139
184
  continue;
140
185
  }
@@ -142,13 +187,17 @@ function parseArgs(argv) {
142
187
  if (arg.startsWith('--write-baseline=')) {
143
188
  parsed.writeBaseline = true;
144
189
  parsed.baselinePath = parsePathOption(arg.slice('--write-baseline='.length), '--write-baseline');
190
+ parsed.cliOptions.add('writeBaseline');
191
+ parsed.cliOptions.add('baselinePath');
145
192
  continue;
146
193
  }
147
194
 
148
195
  if (arg === '--since-baseline') {
149
196
  parsed.sinceBaseline = true;
197
+ parsed.cliOptions.add('sinceBaseline');
150
198
  if (argv[index + 1] && !argv[index + 1].startsWith('-')) {
151
199
  parsed.baselinePath = parsePathOption(argv[++index], '--since-baseline');
200
+ parsed.cliOptions.add('baselinePath');
152
201
  }
153
202
  continue;
154
203
  }
@@ -156,26 +205,32 @@ function parseArgs(argv) {
156
205
  if (arg.startsWith('--since-baseline=')) {
157
206
  parsed.sinceBaseline = true;
158
207
  parsed.baselinePath = parsePathOption(arg.slice('--since-baseline='.length), '--since-baseline');
208
+ parsed.cliOptions.add('sinceBaseline');
209
+ parsed.cliOptions.add('baselinePath');
159
210
  continue;
160
211
  }
161
212
 
162
213
  if (arg === '--fail-on-new-drift') {
163
214
  parsed.failOnNewDrift = true;
215
+ parsed.cliOptions.add('failOnNewDrift');
164
216
  continue;
165
217
  }
166
218
 
167
219
  if (arg === '--max-findings') {
168
220
  parsed.maxFindings = parseNonNegativeInteger(argv[++index], '--max-findings');
221
+ parsed.cliOptions.add('maxFindings');
169
222
  continue;
170
223
  }
171
224
 
172
225
  if (arg.startsWith('--max-findings=')) {
173
226
  parsed.maxFindings = parseNonNegativeInteger(arg.slice('--max-findings='.length), '--max-findings');
227
+ parsed.cliOptions.add('maxFindings');
174
228
  continue;
175
229
  }
176
230
 
177
231
  if (arg === '--min-cleanliness') {
178
232
  parsed.minCleanliness = parsePercentage(argv[++index], '--min-cleanliness');
233
+ parsed.cliOptions.add('minCleanliness');
179
234
  continue;
180
235
  }
181
236
 
@@ -184,26 +239,67 @@ function parseArgs(argv) {
184
239
  arg.slice('--min-cleanliness='.length),
185
240
  '--min-cleanliness',
186
241
  );
242
+ parsed.cliOptions.add('minCleanliness');
187
243
  continue;
188
244
  }
189
245
 
190
246
  if (arg === '--since') {
191
247
  parsed.since = parsePathOption(argv[++index], '--since');
248
+ parsed.cliOptions.add('since');
192
249
  continue;
193
250
  }
194
251
 
195
252
  if (arg.startsWith('--since=')) {
196
253
  parsed.since = parsePathOption(arg.slice('--since='.length), '--since');
254
+ parsed.cliOptions.add('since');
197
255
  continue;
198
256
  }
199
257
 
200
258
  if (arg === '--staged') {
201
259
  parsed.staged = true;
260
+ parsed.cliOptions.add('staged');
261
+ continue;
262
+ }
263
+
264
+ if (arg === '--token-source') {
265
+ parsed.tokenSources.push(...parseTokenSourcePaths(argv[++index]));
266
+ parsed.cliOptions.add('tokenSources');
267
+ continue;
268
+ }
269
+
270
+ if (arg.startsWith('--token-source=')) {
271
+ parsed.tokenSources.push(...parseTokenSourcePaths(arg.slice('--token-source='.length)));
272
+ parsed.cliOptions.add('tokenSources');
273
+ continue;
274
+ }
275
+
276
+ if (arg === '--token-source-format') {
277
+ parsed.tokenSourceFormat = normalizeTokenSourceFormat(argv[++index]);
278
+ parsed.cliOptions.add('tokenSourceFormat');
279
+ continue;
280
+ }
281
+
282
+ if (arg.startsWith('--token-source-format=')) {
283
+ parsed.tokenSourceFormat = normalizeTokenSourceFormat(arg.slice('--token-source-format='.length));
284
+ parsed.cliOptions.add('tokenSourceFormat');
285
+ continue;
286
+ }
287
+
288
+ if (arg === '--token-kind') {
289
+ parsed.tokenKind = normalizeTokenKind(argv[++index]);
290
+ parsed.cliOptions.add('tokenKind');
291
+ continue;
292
+ }
293
+
294
+ if (arg.startsWith('--token-kind=')) {
295
+ parsed.tokenKind = normalizeTokenKind(arg.slice('--token-kind='.length));
296
+ parsed.cliOptions.add('tokenKind');
202
297
  continue;
203
298
  }
204
299
 
205
300
  if (arg === '--token-candidate-min-count') {
206
301
  parsed.tokenCandidateMinCount = parsePositiveInteger(argv[++index], '--token-candidate-min-count');
302
+ parsed.cliOptions.add('tokenCandidateMinCount');
207
303
  continue;
208
304
  }
209
305
 
@@ -212,6 +308,7 @@ function parseArgs(argv) {
212
308
  arg.slice('--token-candidate-min-count='.length),
213
309
  '--token-candidate-min-count',
214
310
  );
311
+ parsed.cliOptions.add('tokenCandidateMinCount');
215
312
  continue;
216
313
  }
217
314
 
@@ -227,21 +324,25 @@ function parseArgs(argv) {
227
324
 
228
325
  if (arg === '--scale') {
229
326
  parsed.scale = parseScale(argv[++index]);
327
+ parsed.cliOptions.add('scale');
230
328
  continue;
231
329
  }
232
330
 
233
331
  if (arg.startsWith('--scale=')) {
234
332
  parsed.scale = parseScale(arg.slice('--scale='.length));
333
+ parsed.cliOptions.add('scale');
235
334
  continue;
236
335
  }
237
336
 
238
337
  if (arg === '--base-font-size') {
239
338
  parsed.baseFontSize = parseBaseFontSize(argv[++index]);
339
+ parsed.cliOptions.add('baseFontSize');
240
340
  continue;
241
341
  }
242
342
 
243
343
  if (arg.startsWith('--base-font-size=')) {
244
344
  parsed.baseFontSize = parseBaseFontSize(arg.slice('--base-font-size='.length));
345
+ parsed.cliOptions.add('baseFontSize');
245
346
  continue;
246
347
  }
247
348
 
@@ -319,6 +420,22 @@ function parseIgnorePatterns(raw) {
319
420
  return patterns;
320
421
  }
321
422
 
423
+ function parseTokenSourcePaths(raw) {
424
+ if (!raw) {
425
+ throw new Error('Missing value for --token-source.');
426
+ }
427
+
428
+ const sources = String(raw).split(',')
429
+ .map((sourcePath) => sourcePath.trim())
430
+ .filter(Boolean);
431
+
432
+ if (sources.length === 0) {
433
+ throw new Error('--token-source must include at least one file.');
434
+ }
435
+
436
+ return sources;
437
+ }
438
+
322
439
  function normalizeIgnorePattern(pattern) {
323
440
  return String(pattern)
324
441
  .trim()
@@ -377,6 +494,145 @@ function assertDirectory(dir) {
377
494
  return resolvedDir;
378
495
  }
379
496
 
497
+ function loadAuditConfig(parsed) {
498
+ if (parsed.noConfig) {
499
+ return null;
500
+ }
501
+
502
+ const resolvedPath = path.resolve(process.cwd(), parsed.configPath);
503
+ if (!fs.existsSync(resolvedPath)) {
504
+ if (parsed.configExplicit) {
505
+ throw new Error(`Config file not found: ${parsed.configPath}`);
506
+ }
507
+ return null;
508
+ }
509
+
510
+ let config;
511
+ try {
512
+ config = JSON.parse(fs.readFileSync(resolvedPath, 'utf8'));
513
+ } catch (err) {
514
+ throw new Error(`Invalid Rhythmguard config ${parsed.configPath}: ${err.message}`);
515
+ }
516
+
517
+ if (!config || typeof config !== 'object' || Array.isArray(config)) {
518
+ throw new Error(`Invalid Rhythmguard config ${parsed.configPath}: expected an object.`);
519
+ }
520
+
521
+ const audit = config.audit || {};
522
+ if (!audit || typeof audit !== 'object' || Array.isArray(audit)) {
523
+ throw new Error(`Invalid Rhythmguard config ${parsed.configPath}: "audit" must be an object.`);
524
+ }
525
+
526
+ return {
527
+ audit,
528
+ file: formatPath(resolvedPath),
529
+ rootDir: path.dirname(resolvedPath),
530
+ };
531
+ }
532
+
533
+ function applyAuditConfig(parsed, configResult) {
534
+ const cliOptions = parsed.cliOptions;
535
+ const next = {
536
+ ...parsed,
537
+ config: configResult
538
+ ? {
539
+ file: configResult.file,
540
+ }
541
+ : null,
542
+ };
543
+
544
+ if (!configResult) {
545
+ next.tokenSources = normalizeCliTokenSources(parsed.tokenSources, parsed.tokenSourceFormat);
546
+ delete next.cliOptions;
547
+ return next;
548
+ }
549
+
550
+ const audit = configResult.audit;
551
+
552
+ if (Array.isArray(audit.ignore)) {
553
+ next.ignorePatterns = [
554
+ ...audit.ignore.map((pattern) => normalizeIgnorePattern(pattern)).filter(Boolean),
555
+ ...parsed.ignorePatterns,
556
+ ];
557
+ }
558
+
559
+ const configuredTokenSources = normalizeConfigTokenSources(
560
+ audit.tokenSources,
561
+ configResult.rootDir,
562
+ );
563
+ next.tokenSources = [
564
+ ...configuredTokenSources,
565
+ ...normalizeCliTokenSources(parsed.tokenSources, parsed.tokenSourceFormat),
566
+ ];
567
+
568
+ applyConfigScalar(next, audit, cliOptions, 'ignorePath', 'ignorePath', parsePathOption);
569
+ applyConfigScalar(next, audit, cliOptions, 'baselinePath', 'baseline', parsePathOption);
570
+ applyConfigScalar(next, audit, cliOptions, 'maxFindings', 'maxFindings', parseNonNegativeInteger);
571
+ applyConfigScalar(next, audit, cliOptions, 'minCleanliness', 'minCleanliness', parsePercentage);
572
+ applyConfigScalar(next, audit, cliOptions, 'tokenCandidateMinCount', 'tokenCandidateMinCount', parsePositiveInteger);
573
+ applyConfigScalar(next, audit, cliOptions, 'tokenKind', 'tokenKind', normalizeTokenKind);
574
+ applyConfigScalar(next, audit, cliOptions, 'baseFontSize', 'baseFontSize', parseBaseFontSize);
575
+ applyConfigScalar(next, audit, cliOptions, 'scale', 'scale', (value) => {
576
+ if (Array.isArray(value)) {
577
+ return value;
578
+ }
579
+ return parseScale(String(value));
580
+ });
581
+
582
+ delete next.cliOptions;
583
+ return next;
584
+ }
585
+
586
+ function applyConfigScalar(target, audit, cliOptions, targetKey, configKey, parser) {
587
+ if (cliOptions.has(targetKey) || audit[configKey] === undefined) {
588
+ return;
589
+ }
590
+
591
+ target[targetKey] = parser(audit[configKey], configKey);
592
+ }
593
+
594
+ function normalizeCliTokenSources(sources, format) {
595
+ return sources.map((sourcePath) => ({
596
+ baseDir: process.cwd(),
597
+ format,
598
+ path: sourcePath,
599
+ }));
600
+ }
601
+
602
+ function normalizeConfigTokenSources(sources, baseDir) {
603
+ if (sources === undefined) {
604
+ return [];
605
+ }
606
+
607
+ if (!Array.isArray(sources)) {
608
+ throw new Error('Invalid Rhythmguard config: audit.tokenSources must be an array.');
609
+ }
610
+
611
+ return sources.map((source) => {
612
+ if (typeof source === 'string') {
613
+ return {
614
+ baseDir,
615
+ format: 'auto',
616
+ path: source,
617
+ };
618
+ }
619
+
620
+ if (!source || typeof source !== 'object' || Array.isArray(source)) {
621
+ throw new Error('Invalid Rhythmguard config: audit.tokenSources entries must be strings or objects.');
622
+ }
623
+
624
+ if (typeof source.path !== 'string' || source.path.trim().length === 0) {
625
+ throw new Error('Invalid Rhythmguard config: token source objects must include a path.');
626
+ }
627
+
628
+ return {
629
+ baseDir,
630
+ format: normalizeTokenSourceFormat(source.format || 'auto'),
631
+ path: source.path,
632
+ };
633
+ });
634
+ }
635
+
380
636
  function loadIgnorePatterns(ignorePath) {
381
637
  if (!ignorePath) {
382
638
  return [];
@@ -761,11 +1017,21 @@ function offsetToLineColumn(lineStarts, offset) {
761
1017
  };
762
1018
  }
763
1019
 
764
- function collectTokenContract(cssFiles, cssFindings, tailwindFindings, minCandidateCount) {
1020
+ function collectTokenContract({
1021
+ baseFontSize,
1022
+ cssFiles,
1023
+ cssFindings,
1024
+ externalTokenDefinitions,
1025
+ minCandidateCount,
1026
+ tailwindFindings,
1027
+ tokenKind,
1028
+ tokenSourceReports,
1029
+ }) {
765
1030
  const definitions = new Map();
766
1031
  const uses = new Map();
767
1032
  const rawValues = new Map();
768
1033
  const rawValueLocations = new Set();
1034
+ const matchesKind = createTokenKindMatcher(tokenKind);
769
1035
 
770
1036
  for (const filePath of cssFiles) {
771
1037
  let source = '';
@@ -776,8 +1042,20 @@ function collectTokenContract(cssFiles, cssFindings, tailwindFindings, minCandid
776
1042
  }
777
1043
 
778
1044
  const file = formatPath(filePath);
779
- collectTokenDefinitions(source, file, definitions);
780
- collectTokenUses(source, file, uses);
1045
+ collectTokenDefinitions(source, file, definitions, matchesKind, baseFontSize);
1046
+ collectTokenUses(source, file, uses, matchesKind);
1047
+ }
1048
+
1049
+ for (const entry of externalTokenDefinitions.values()) {
1050
+ for (const value of entry.values || []) {
1051
+ addDefinition(definitions, {
1052
+ baseFontSize,
1053
+ file: Array.from(entry.files)[0] || 'external-token-source',
1054
+ source: Array.from(entry.sources)[0] || Array.from(entry.files)[0] || 'external-token-source',
1055
+ token: entry.token,
1056
+ value,
1057
+ });
1058
+ }
781
1059
  }
782
1060
 
783
1061
  for (const finding of cssFindings) {
@@ -792,6 +1070,8 @@ function collectTokenContract(cssFiles, cssFindings, tailwindFindings, minCandid
792
1070
  const usedTokens = mapTokenEntries(uses);
793
1071
  const missingTokens = usedTokens.filter(({ token }) => !definitions.has(token));
794
1072
  const unusedTokens = definedTokens.filter(({ token }) => !uses.has(token));
1073
+ const rawValueMatches = collectRawValueMatches(rawValues, definitions, baseFontSize);
1074
+ const conflictingTokens = collectConflictingTokens(definitions);
795
1075
  const rawValueCandidates = Array.from(rawValues.entries())
796
1076
  .map(([value, entry]) => ({
797
1077
  count: entry.count,
@@ -803,12 +1083,18 @@ function collectTokenContract(cssFiles, cssFindings, tailwindFindings, minCandid
803
1083
 
804
1084
  return {
805
1085
  definedTokens,
1086
+ conflictingTokens,
806
1087
  missingTokens,
1088
+ rawValueMatches,
807
1089
  rawValueCandidates,
1090
+ sources: tokenSourceReports,
808
1091
  summary: {
1092
+ conflictingTokens: conflictingTokens.length,
809
1093
  definedTokens: definedTokens.length,
810
1094
  missingTokens: missingTokens.length,
1095
+ rawValueMatches: rawValueMatches.length,
811
1096
  rawValueCandidates: rawValueCandidates.length,
1097
+ tokenSources: tokenSourceReports.length,
812
1098
  unusedTokens: unusedTokens.length,
813
1099
  usedTokens: usedTokens.length,
814
1100
  },
@@ -817,34 +1103,33 @@ function collectTokenContract(cssFiles, cssFindings, tailwindFindings, minCandid
817
1103
  };
818
1104
  }
819
1105
 
820
- function collectTokenDefinitions(source, file, definitions) {
1106
+ function collectTokenDefinitions(source, file, definitions, matchesKind, baseFontSize) {
821
1107
  const declarationPattern = /(--[\w-]+)\s*:\s*([^;{}]+)/g;
822
1108
  let match;
823
1109
 
824
1110
  while ((match = declarationPattern.exec(source)) !== null) {
825
1111
  const token = match[1];
826
- if (!isSpacingToken(token)) {
1112
+ if (!matchesKind(token)) {
827
1113
  continue;
828
1114
  }
829
1115
 
830
- const entry = definitions.get(token) || {
831
- files: new Set(),
1116
+ addDefinition(definitions, {
1117
+ baseFontSize,
1118
+ file,
1119
+ source: file,
832
1120
  token,
833
- values: new Set(),
834
- };
835
- entry.files.add(file);
836
- entry.values.add(match[2].trim());
837
- definitions.set(token, entry);
1121
+ value: match[2].trim(),
1122
+ });
838
1123
  }
839
1124
  }
840
1125
 
841
- function collectTokenUses(source, file, uses) {
1126
+ function collectTokenUses(source, file, uses, matchesKind) {
842
1127
  const varPattern = /var\(\s*(--[\w-]+)/g;
843
1128
  let match;
844
1129
 
845
1130
  while ((match = varPattern.exec(source)) !== null) {
846
1131
  const token = match[1];
847
- if (!isSpacingToken(token)) {
1132
+ if (!matchesKind(token)) {
848
1133
  continue;
849
1134
  }
850
1135
 
@@ -857,10 +1142,6 @@ function collectTokenUses(source, file, uses) {
857
1142
  }
858
1143
  }
859
1144
 
860
- function isSpacingToken(token) {
861
- return new RegExp(DEFAULT_TOKEN_PATTERN).test(token);
862
- }
863
-
864
1145
  function addRawValue(rawValues, rawValueLocations, value, finding) {
865
1146
  if (!value) {
866
1147
  return;
@@ -892,20 +1173,90 @@ function mapTokenEntries(entries) {
892
1173
  return Array.from(entries.values())
893
1174
  .map((entry) => ({
894
1175
  files: Array.from(entry.files).sort(),
1176
+ normalizedValues: entry.normalizedValues
1177
+ ? Array.from(entry.normalizedValues).sort()
1178
+ : undefined,
1179
+ sources: entry.sources ? Array.from(entry.sources).sort() : undefined,
895
1180
  token: entry.token,
896
1181
  values: entry.values ? Array.from(entry.values).sort() : undefined,
897
1182
  }))
898
1183
  .sort((a, b) => a.token.localeCompare(b.token));
899
1184
  }
900
1185
 
1186
+ function collectRawValueMatches(rawValues, definitions, baseFontSize) {
1187
+ const valueToTokens = createValueToTokensMap(definitions);
1188
+ const matches = [];
1189
+
1190
+ for (const [value, entry] of rawValues.entries()) {
1191
+ const tokens = new Set();
1192
+ for (const key of getNormalizedValueKeys(value, baseFontSize)) {
1193
+ for (const token of valueToTokens.get(key) || []) {
1194
+ tokens.add(token);
1195
+ }
1196
+ }
1197
+
1198
+ if (tokens.size === 0) {
1199
+ continue;
1200
+ }
1201
+
1202
+ matches.push({
1203
+ count: entry.count,
1204
+ files: Array.from(entry.files).sort(),
1205
+ tokens: Array.from(tokens).sort(),
1206
+ value,
1207
+ });
1208
+ }
1209
+
1210
+ return matches.sort((a, b) => b.count - a.count || a.value.localeCompare(b.value));
1211
+ }
1212
+
1213
+ function collectConflictingTokens(definitions) {
1214
+ const valueToTokens = createValueToTokensMap(definitions);
1215
+ const conflicts = [];
1216
+
1217
+ for (const [value, tokens] of valueToTokens.entries()) {
1218
+ const uniqueTokens = Array.from(tokens).sort();
1219
+ if (uniqueTokens.length < 2) {
1220
+ continue;
1221
+ }
1222
+
1223
+ conflicts.push({
1224
+ tokens: uniqueTokens,
1225
+ value,
1226
+ });
1227
+ }
1228
+
1229
+ return conflicts.sort((a, b) => a.value.localeCompare(b.value));
1230
+ }
1231
+
1232
+ function createValueToTokensMap(definitions) {
1233
+ const valueToTokens = new Map();
1234
+
1235
+ for (const entry of definitions.values()) {
1236
+ for (const value of entry.normalizedValues || []) {
1237
+ const tokens = valueToTokens.get(value) || new Set();
1238
+ tokens.add(entry.token);
1239
+ valueToTokens.set(value, tokens);
1240
+ }
1241
+ }
1242
+
1243
+ return valueToTokens;
1244
+ }
1245
+
901
1246
  function buildReport({
1247
+ baseFontSize,
1248
+ config,
902
1249
  cssFiles,
903
1250
  cssFindings,
904
1251
  dir,
1252
+ externalTokenDefinitions,
905
1253
  scanScope,
906
1254
  templateFiles,
907
1255
  tailwindFindings,
908
1256
  tokenCandidateMinCount,
1257
+ tokenKind,
1258
+ tokenSourceReports,
1259
+ tokenSourceWarnings,
909
1260
  }) {
910
1261
  const offScaleValues = countByValue(cssFindings
911
1262
  .filter((finding) => finding.type === 'off-scale' && finding.value)
@@ -930,14 +1281,19 @@ function buildReport({
930
1281
  const scaleCleanliness = totalFiles > 0
931
1282
  ? Math.max(0, Math.round(((totalFiles - filesWithIssues) / totalFiles) * 100))
932
1283
  : 100;
933
- const tokenContract = collectTokenContract(
1284
+ const tokenContract = collectTokenContract({
1285
+ baseFontSize,
934
1286
  cssFiles,
935
1287
  cssFindings,
1288
+ externalTokenDefinitions,
1289
+ minCandidateCount: tokenCandidateMinCount,
936
1290
  tailwindFindings,
937
- tokenCandidateMinCount,
938
- );
1291
+ tokenKind,
1292
+ tokenSourceReports,
1293
+ });
939
1294
 
940
1295
  return {
1296
+ config,
941
1297
  cssFilesScanned: cssFiles.length,
942
1298
  directory: dir,
943
1299
  filesWithIssues,
@@ -945,7 +1301,7 @@ function buildReport({
945
1301
  css: cssFindings,
946
1302
  tailwind: tailwindFindings,
947
1303
  },
948
- formatVersion: 3,
1304
+ formatVersion: 4,
949
1305
  offScaleValues: Object.fromEntries(sortCountMap(offScaleValues).slice(0, 10)),
950
1306
  scaleCleanliness,
951
1307
  scanScope,
@@ -957,17 +1313,20 @@ function buildReport({
957
1313
  summary: {
958
1314
  cssWarnings: cssFindings.length,
959
1315
  filesWithIssues,
1316
+ rawValueMatches: tokenContract.summary.rawValueMatches,
960
1317
  missingTokens: tokenContract.summary.missingTokens,
961
1318
  rawValueCandidates: tokenContract.summary.rawValueCandidates,
962
1319
  scaleCleanliness,
963
1320
  tailwindArbitrarySpacing: tailwindFindings.length,
964
1321
  tokenOpportunities: sumCounts(tokenOpportunities),
1322
+ tokenSources: tokenContract.summary.tokenSources,
965
1323
  totalFindings: totalWarnings,
966
1324
  unusedTokens: tokenContract.summary.unusedTokens,
967
1325
  },
968
1326
  tailwindArbitraryValues: Object.fromEntries(sortCountMap(tailwindArbitraryValues).slice(0, 10)),
969
1327
  templateFilesScanned: templateFiles.length,
970
1328
  tokenContract,
1329
+ tokenSourceWarnings,
971
1330
  tokenOpportunities: Object.fromEntries(sortCountMap(tokenOpportunities).slice(0, 10)),
972
1331
  topAffectedFiles: topAffectedFiles.map(([file, count]) => ({ count, file })),
973
1332
  totalFiles,
@@ -1136,14 +1495,36 @@ function renderText(report) {
1136
1495
  }
1137
1496
 
1138
1497
  function appendTokenContractText(lines, tokenContract) {
1139
- const { missingTokens, rawValueCandidates, unusedTokens } = tokenContract;
1140
- if (missingTokens.length === 0 && rawValueCandidates.length === 0 && unusedTokens.length === 0) {
1498
+ const {
1499
+ conflictingTokens,
1500
+ missingTokens,
1501
+ rawValueCandidates,
1502
+ rawValueMatches,
1503
+ sources,
1504
+ unusedTokens,
1505
+ } = tokenContract;
1506
+ if (
1507
+ missingTokens.length === 0 &&
1508
+ rawValueCandidates.length === 0 &&
1509
+ rawValueMatches.length === 0 &&
1510
+ unusedTokens.length === 0 &&
1511
+ conflictingTokens.length === 0 &&
1512
+ sources.length === 0
1513
+ ) {
1141
1514
  return;
1142
1515
  }
1143
1516
 
1144
1517
  lines.push(' ── TOKEN CONTRACT ──');
1145
1518
  lines.push('');
1146
1519
 
1520
+ if (sources.length > 0) {
1521
+ lines.push(` Token sources ${sources.length}`);
1522
+ for (const source of sources.slice(0, 5)) {
1523
+ const warningSuffix = source.warnings.length > 0 ? ' (warning)' : '';
1524
+ lines.push(` ${truncate(source.file, 42)} ${source.tokenCount}${warningSuffix}`);
1525
+ }
1526
+ }
1527
+
1147
1528
  if (missingTokens.length > 0) {
1148
1529
  lines.push(` Missing tokens ${missingTokens.length}`);
1149
1530
  for (const entry of missingTokens.slice(0, 5)) {
@@ -1165,6 +1546,20 @@ function appendTokenContractText(lines, tokenContract) {
1165
1546
  }
1166
1547
  }
1167
1548
 
1549
+ if (rawValueMatches.length > 0) {
1550
+ lines.push(` Raw values matching tokens ${rawValueMatches.length}`);
1551
+ for (const entry of rawValueMatches.slice(0, 5)) {
1552
+ lines.push(` ${entry.value.padEnd(14)} ${truncate(entry.tokens.join(', '), 34)}`);
1553
+ }
1554
+ }
1555
+
1556
+ if (conflictingTokens.length > 0) {
1557
+ lines.push(` Conflicting tokens ${conflictingTokens.length}`);
1558
+ for (const entry of conflictingTokens.slice(0, 5)) {
1559
+ lines.push(` ${entry.value.padEnd(14)} ${truncate(entry.tokens.join(', '), 34)}`);
1560
+ }
1561
+ }
1562
+
1168
1563
  lines.push('');
1169
1564
  }
1170
1565
 
@@ -1267,14 +1662,39 @@ function renderMarkdown(report) {
1267
1662
  }
1268
1663
 
1269
1664
  function appendTokenContractMarkdown(lines, tokenContract) {
1270
- const { missingTokens, rawValueCandidates, unusedTokens } = tokenContract;
1271
- if (missingTokens.length === 0 && rawValueCandidates.length === 0 && unusedTokens.length === 0) {
1665
+ const {
1666
+ conflictingTokens,
1667
+ missingTokens,
1668
+ rawValueCandidates,
1669
+ rawValueMatches,
1670
+ sources,
1671
+ unusedTokens,
1672
+ } = tokenContract;
1673
+ if (
1674
+ missingTokens.length === 0 &&
1675
+ rawValueCandidates.length === 0 &&
1676
+ rawValueMatches.length === 0 &&
1677
+ unusedTokens.length === 0 &&
1678
+ conflictingTokens.length === 0 &&
1679
+ sources.length === 0
1680
+ ) {
1272
1681
  return;
1273
1682
  }
1274
1683
 
1275
1684
  lines.push('## Token Contract');
1276
1685
  lines.push('');
1277
1686
 
1687
+ if (sources.length > 0) {
1688
+ lines.push('### Token Sources');
1689
+ lines.push('');
1690
+ lines.push('| File | Format | Tokens | Warnings |');
1691
+ lines.push('| --- | --- | ---: | --- |');
1692
+ for (const source of sources.slice(0, 10)) {
1693
+ lines.push(`| \`${escapeMarkdown(source.file)}\` | \`${source.format}\` | ${source.tokenCount} | \`${escapeMarkdown(source.warnings.join('; ') || 'none')}\` |`);
1694
+ }
1695
+ lines.push('');
1696
+ }
1697
+
1278
1698
  if (missingTokens.length > 0) {
1279
1699
  lines.push('### Tokens Used But Missing');
1280
1700
  lines.push('');
@@ -1307,6 +1727,28 @@ function appendTokenContractMarkdown(lines, tokenContract) {
1307
1727
  }
1308
1728
  lines.push('');
1309
1729
  }
1730
+
1731
+ if (rawValueMatches.length > 0) {
1732
+ lines.push('### Raw Values Matching Known Tokens');
1733
+ lines.push('');
1734
+ lines.push('| Value | Count | Tokens |');
1735
+ lines.push('| --- | ---: | --- |');
1736
+ for (const entry of rawValueMatches.slice(0, 10)) {
1737
+ lines.push(`| \`${escapeMarkdown(entry.value)}\` | ${entry.count} | \`${escapeMarkdown(entry.tokens.join(', '))}\` |`);
1738
+ }
1739
+ lines.push('');
1740
+ }
1741
+
1742
+ if (conflictingTokens.length > 0) {
1743
+ lines.push('### Conflicting Token Values');
1744
+ lines.push('');
1745
+ lines.push('| Value | Tokens |');
1746
+ lines.push('| --- | --- |');
1747
+ for (const entry of conflictingTokens.slice(0, 10)) {
1748
+ lines.push(`| \`${escapeMarkdown(entry.value)}\` | \`${escapeMarkdown(entry.tokens.join(', '))}\` |`);
1749
+ }
1750
+ lines.push('');
1751
+ }
1310
1752
  }
1311
1753
 
1312
1754
  function appendBaselineMarkdown(lines, report) {
@@ -1400,6 +1842,13 @@ async function run() {
1400
1842
  return;
1401
1843
  }
1402
1844
 
1845
+ try {
1846
+ parsed = applyAuditConfig(parsed, loadAuditConfig(parsed));
1847
+ } catch (err) {
1848
+ process.stderr.write(`${err.message}\n`);
1849
+ process.exit(1);
1850
+ }
1851
+
1403
1852
  const resolvedDir = assertDirectory(parsed.dir);
1404
1853
  let ignorePatterns;
1405
1854
  try {
@@ -1434,14 +1883,26 @@ async function run() {
1434
1883
  process.exit(1);
1435
1884
  }
1436
1885
 
1886
+ const tokenSourceResult = parseTokenSources({
1887
+ baseFontSize: parsed.baseFontSize,
1888
+ sources: parsed.tokenSources,
1889
+ tokenKind: parsed.tokenKind,
1890
+ });
1891
+
1437
1892
  const report = buildReport({
1893
+ baseFontSize: parsed.baseFontSize,
1894
+ config: parsed.config,
1438
1895
  cssFiles,
1439
1896
  cssFindings: collectCssFindings(cssResults),
1440
1897
  dir: parsed.dir,
1898
+ externalTokenDefinitions: tokenSourceResult.definitions,
1441
1899
  scanScope,
1442
1900
  tailwindFindings: collectTailwindFindings(templateFiles, options),
1443
1901
  templateFiles,
1444
1902
  tokenCandidateMinCount: parsed.tokenCandidateMinCount,
1903
+ tokenKind: parsed.tokenKind,
1904
+ tokenSourceReports: tokenSourceResult.sources,
1905
+ tokenSourceWarnings: tokenSourceResult.warnings,
1445
1906
  });
1446
1907
 
1447
1908
  try {
@@ -0,0 +1,418 @@
1
+ 'use strict';
2
+
3
+ const fs = require('node:fs');
4
+ const path = require('node:path');
5
+
6
+ const {
7
+ formatLength,
8
+ parseLengthToken,
9
+ toPx,
10
+ } = require('./length');
11
+
12
+ const VALID_TOKEN_SOURCE_FORMATS = new Set([
13
+ 'auto',
14
+ 'css',
15
+ 'flat-json',
16
+ 'style-dictionary',
17
+ 'dtcg',
18
+ ]);
19
+
20
+ const VALID_TOKEN_KINDS = new Set([
21
+ 'spacing',
22
+ 'radius',
23
+ 'typography',
24
+ 'size',
25
+ 'all',
26
+ ]);
27
+
28
+ const TOKEN_KIND_PATTERNS = Object.freeze({
29
+ all: /^--/,
30
+ radius: /^--radius-/,
31
+ size: /^--(?:size|width|height|container)-/,
32
+ spacing: /^--(?:space|spacing)-/,
33
+ typography: /^--(?:font|font-size|line-height|leading|tracking|typography)-/,
34
+ });
35
+
36
+ function isPlainObject(value) {
37
+ return (
38
+ value !== null &&
39
+ typeof value === 'object' &&
40
+ !Array.isArray(value)
41
+ );
42
+ }
43
+
44
+ function normalizeTokenSourceFormat(format) {
45
+ const normalized = String(format || 'auto').trim().toLowerCase();
46
+ if (!VALID_TOKEN_SOURCE_FORMATS.has(normalized)) {
47
+ throw new Error(`Invalid token source format "${format}". Expected auto, css, flat-json, style-dictionary, or dtcg.`);
48
+ }
49
+
50
+ return normalized;
51
+ }
52
+
53
+ function normalizeTokenKind(kind) {
54
+ const normalized = String(kind || 'spacing').trim().toLowerCase();
55
+ if (!VALID_TOKEN_KINDS.has(normalized)) {
56
+ throw new Error(`Invalid token kind "${kind}". Expected spacing, radius, typography, size, or all.`);
57
+ }
58
+
59
+ return normalized;
60
+ }
61
+
62
+ function createTokenKindMatcher(kind) {
63
+ const normalizedKind = normalizeTokenKind(kind);
64
+ const pattern = TOKEN_KIND_PATTERNS[normalizedKind] || TOKEN_KIND_PATTERNS.spacing;
65
+
66
+ return (token) => pattern.test(token);
67
+ }
68
+
69
+ function parseTokenSources({
70
+ baseFontSize = 16,
71
+ sources = [],
72
+ tokenKind = 'spacing',
73
+ } = {}) {
74
+ const definitions = new Map();
75
+ const sourceReports = [];
76
+ const warnings = [];
77
+ const matchesKind = createTokenKindMatcher(tokenKind);
78
+
79
+ for (const source of sources) {
80
+ const normalizedSource = normalizeTokenSource(source);
81
+ const sourceReport = {
82
+ file: formatPath(normalizedSource.resolvedPath),
83
+ format: normalizedSource.format,
84
+ requestedFormat: normalizedSource.requestedFormat,
85
+ tokenCount: 0,
86
+ warnings: [],
87
+ };
88
+
89
+ if (!fs.existsSync(normalizedSource.resolvedPath)) {
90
+ const warning = `Token source not found: ${normalizedSource.displayPath}`;
91
+ sourceReport.warnings.push(warning);
92
+ warnings.push(warning);
93
+ sourceReports.push(sourceReport);
94
+ continue;
95
+ }
96
+
97
+ let text;
98
+ try {
99
+ text = fs.readFileSync(normalizedSource.resolvedPath, 'utf8');
100
+ } catch (err) {
101
+ const warning = `Unable to read token source ${normalizedSource.displayPath}: ${err.message}`;
102
+ sourceReport.warnings.push(warning);
103
+ warnings.push(warning);
104
+ sourceReports.push(sourceReport);
105
+ continue;
106
+ }
107
+
108
+ let tokens = [];
109
+ try {
110
+ if (normalizedSource.format === 'css') {
111
+ tokens = collectCssTokens(text, matchesKind);
112
+ } else {
113
+ const parsed = JSON.parse(text);
114
+ const detectedFormat = normalizedSource.requestedFormat === 'auto'
115
+ ? detectJsonTokenFormat(parsed)
116
+ : normalizedSource.format;
117
+ sourceReport.format = detectedFormat;
118
+ tokens = collectJsonTokens(parsed, matchesKind);
119
+ }
120
+ } catch (err) {
121
+ const warning = `Unable to parse token source ${normalizedSource.displayPath}: ${err.message}`;
122
+ sourceReport.warnings.push(warning);
123
+ warnings.push(warning);
124
+ sourceReports.push(sourceReport);
125
+ continue;
126
+ }
127
+
128
+ for (const token of tokens) {
129
+ addDefinition(definitions, {
130
+ baseFontSize,
131
+ file: sourceReport.file,
132
+ source: sourceReport.file,
133
+ token: token.token,
134
+ value: token.value,
135
+ });
136
+ }
137
+
138
+ sourceReport.tokenCount = tokens.length;
139
+ sourceReports.push(sourceReport);
140
+ }
141
+
142
+ return {
143
+ definitions,
144
+ sources: sourceReports,
145
+ warnings,
146
+ };
147
+ }
148
+
149
+ function normalizeTokenSource(source) {
150
+ if (!isPlainObject(source)) {
151
+ throw new Error('Token source entries must be strings or objects with a path.');
152
+ }
153
+
154
+ const sourcePath = source.path || source.file;
155
+ if (typeof sourcePath !== 'string' || sourcePath.trim().length === 0) {
156
+ throw new Error('Token source entries must include a non-empty path.');
157
+ }
158
+
159
+ const baseDir = source.baseDir || process.cwd();
160
+ const requestedFormat = normalizeTokenSourceFormat(source.format || 'auto');
161
+ const resolvedPath = path.resolve(baseDir, sourcePath);
162
+
163
+ return {
164
+ displayPath: sourcePath,
165
+ format: requestedFormat === 'auto' ? detectSourceFormat(resolvedPath) : requestedFormat,
166
+ requestedFormat,
167
+ resolvedPath,
168
+ };
169
+ }
170
+
171
+ function detectSourceFormat(filePath) {
172
+ if (path.extname(filePath).toLowerCase() === '.css') {
173
+ return 'css';
174
+ }
175
+
176
+ return 'flat-json';
177
+ }
178
+
179
+ function detectJsonTokenFormat(parsed) {
180
+ let hasDtcg = false;
181
+ let hasStyleDictionary = false;
182
+
183
+ function walk(value) {
184
+ if (!isPlainObject(value)) {
185
+ return;
186
+ }
187
+
188
+ if (Object.prototype.hasOwnProperty.call(value, '$value')) {
189
+ hasDtcg = true;
190
+ }
191
+
192
+ if (Object.prototype.hasOwnProperty.call(value, 'value')) {
193
+ hasStyleDictionary = true;
194
+ }
195
+
196
+ for (const child of Object.values(value)) {
197
+ walk(child);
198
+ }
199
+ }
200
+
201
+ walk(parsed);
202
+
203
+ if (hasDtcg) {
204
+ return 'dtcg';
205
+ }
206
+
207
+ if (hasStyleDictionary) {
208
+ return 'style-dictionary';
209
+ }
210
+
211
+ return 'flat-json';
212
+ }
213
+
214
+ function collectCssTokens(source, matchesKind) {
215
+ const tokens = [];
216
+ const declarationPattern = /(--[\w-]+)\s*:\s*([^;{}]+)/g;
217
+ let match;
218
+
219
+ while ((match = declarationPattern.exec(source)) !== null) {
220
+ const token = match[1];
221
+ if (!matchesKind(token)) {
222
+ continue;
223
+ }
224
+
225
+ tokens.push({
226
+ token,
227
+ value: match[2].trim(),
228
+ });
229
+ }
230
+
231
+ return tokens;
232
+ }
233
+
234
+ function collectJsonTokens(parsed, matchesKind) {
235
+ if (!isPlainObject(parsed)) {
236
+ return [];
237
+ }
238
+
239
+ const tokens = [];
240
+ walkJsonTokenGroup(parsed, [], tokens, matchesKind);
241
+ return tokens;
242
+ }
243
+
244
+ function walkJsonTokenGroup(group, segments, tokens, matchesKind) {
245
+ for (const [key, value] of Object.entries(group)) {
246
+ if (isMetadataKey(key)) {
247
+ continue;
248
+ }
249
+
250
+ if (typeof value === 'string' || typeof value === 'number') {
251
+ const tokenEntry = tokenFromPrimitive(key, value, segments);
252
+ if (tokenEntry && matchesKind(tokenEntry.token)) {
253
+ tokens.push(tokenEntry);
254
+ }
255
+ continue;
256
+ }
257
+
258
+ if (!isPlainObject(value)) {
259
+ continue;
260
+ }
261
+
262
+ const leafValue = leafTokenValue(value);
263
+ if (leafValue !== null) {
264
+ const token = key.startsWith('--')
265
+ ? key
266
+ : toCustomProperty([...segments, key]);
267
+ if (matchesKind(token)) {
268
+ tokens.push({
269
+ token,
270
+ value: leafValue,
271
+ });
272
+ }
273
+ continue;
274
+ }
275
+
276
+ walkJsonTokenGroup(value, [...segments, key], tokens, matchesKind);
277
+ }
278
+ }
279
+
280
+ function tokenFromPrimitive(key, value, segments) {
281
+ if (key.startsWith('--')) {
282
+ return {
283
+ token: key,
284
+ value: String(value),
285
+ };
286
+ }
287
+
288
+ const keyAsLength = parseLengthToken(key);
289
+ const tokenName = typeof value === 'string' ? extractTokenName(value) : null;
290
+ if (keyAsLength && tokenName) {
291
+ return {
292
+ token: tokenName,
293
+ value: key,
294
+ };
295
+ }
296
+
297
+ const valueAsLength = typeof value === 'string'
298
+ ? parseLengthToken(value)
299
+ : typeof value === 'number'
300
+ ? parseLengthToken(`${value}px`)
301
+ : null;
302
+ if (valueAsLength) {
303
+ return {
304
+ token: toCustomProperty([...segments, key]),
305
+ value: typeof value === 'number' ? `${value}px` : value,
306
+ };
307
+ }
308
+
309
+ return null;
310
+ }
311
+
312
+ function leafTokenValue(value) {
313
+ if (typeof value.$value === 'string' || typeof value.$value === 'number') {
314
+ return String(value.$value);
315
+ }
316
+
317
+ if (typeof value.value === 'string' || typeof value.value === 'number') {
318
+ return String(value.value);
319
+ }
320
+
321
+ return null;
322
+ }
323
+
324
+ function isMetadataKey(key) {
325
+ return key.startsWith('$')
326
+ || key === 'type'
327
+ || key === 'description'
328
+ || key === 'comment'
329
+ || key === 'attributes';
330
+ }
331
+
332
+ function toCustomProperty(segments) {
333
+ return `--${segments
334
+ .map((segment) => String(segment).trim())
335
+ .filter(Boolean)
336
+ .join('-')}`;
337
+ }
338
+
339
+ function extractTokenName(value) {
340
+ const varMatch = value.match(/var\(\s*(--[\w-]+)/);
341
+ if (varMatch) {
342
+ return varMatch[1];
343
+ }
344
+
345
+ if (value.startsWith('--')) {
346
+ return value;
347
+ }
348
+
349
+ return null;
350
+ }
351
+
352
+ function addDefinition(definitions, {
353
+ baseFontSize,
354
+ file,
355
+ source,
356
+ token,
357
+ value,
358
+ }) {
359
+ const entry = definitions.get(token) || {
360
+ files: new Set(),
361
+ normalizedValues: new Set(),
362
+ sources: new Set(),
363
+ token,
364
+ values: new Set(),
365
+ };
366
+
367
+ entry.files.add(file);
368
+ entry.sources.add(source);
369
+ entry.values.add(String(value).trim());
370
+
371
+ for (const normalizedValue of getNormalizedValueKeys(value, baseFontSize)) {
372
+ entry.normalizedValues.add(normalizedValue);
373
+ }
374
+
375
+ definitions.set(token, entry);
376
+ }
377
+
378
+ function getNormalizedValueKeys(value, baseFontSize = 16) {
379
+ if (value === null || value === undefined) {
380
+ return [];
381
+ }
382
+
383
+ const raw = String(value).trim();
384
+ if (!raw) {
385
+ return [];
386
+ }
387
+
388
+ const keys = new Set([raw]);
389
+ const parsed = parseLengthToken(raw);
390
+ if (!parsed) {
391
+ return Array.from(keys);
392
+ }
393
+
394
+ const absolute = Math.abs(parsed.number);
395
+ keys.add(formatLength(absolute, parsed.unit || 'px'));
396
+
397
+ const px = toPx(absolute, parsed.unit, baseFontSize);
398
+ if (px !== null) {
399
+ keys.add(`${px}px`);
400
+ }
401
+
402
+ return Array.from(keys);
403
+ }
404
+
405
+ function formatPath(filePath) {
406
+ return path.relative(process.cwd(), filePath).replace(/\\/g, '/');
407
+ }
408
+
409
+ module.exports = {
410
+ VALID_TOKEN_KINDS,
411
+ VALID_TOKEN_SOURCE_FORMATS,
412
+ addDefinition,
413
+ createTokenKindMatcher,
414
+ getNormalizedValueKeys,
415
+ normalizeTokenKind,
416
+ normalizeTokenSourceFormat,
417
+ parseTokenSources,
418
+ };