stylelint-plugin-rhythmguard 1.6.0 → 1.7.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,24 @@ The format follows Keep a Changelog principles and semantic versioning.
6
6
 
7
7
  ## [Unreleased]
8
8
 
9
+ ## [1.7.0] - 2026-05-23
10
+
11
+ ### Added
12
+
13
+ - Added audit baseline workflows with `--write-baseline`, `--since-baseline`, and `--fail-on-new-drift` for legacy-safe CI adoption.
14
+ - Added changed-only audit scopes with `--staged` and `--since <git-ref>`.
15
+ - Added CI threshold gates with `--max-findings` and `--min-cleanliness`.
16
+ - Added `.rhythmguardignore` / `--ignore-path` support for reusable root-relative scan pruning.
17
+ - Added token contract reporting for spacing tokens used but missing, tokens defined but unused, and repeated raw value candidates.
18
+
19
+ ## [1.6.1] - 2026-05-23
20
+
21
+ ### Fixed
22
+
23
+ - Added `rhythmguard audit --ignore` for pruning root-relative paths before scanning large repositories.
24
+ - Scoped audit traversal to scan-relevant CSS and template files instead of collecting every file under the audit root first.
25
+ - Added default audit skips for common generated directories such as `.svelte-kit`, `.turbo`, and `.vercel`.
26
+
9
27
  ## [1.6.0] - 2026-05-19
10
28
 
11
29
  ### Added
package/README.md CHANGED
@@ -89,9 +89,13 @@ Use the audit CLI to create a design-system drift report before turning rules in
89
89
  npx rhythmguard audit ./src
90
90
  npx rhythmguard audit ./src --format markdown
91
91
  npx rhythmguard audit ./src --json
92
+ npx rhythmguard audit . --ignore "apps/legacy/**" --ignore "vendor/**"
93
+ npx rhythmguard audit ./src --write-baseline
94
+ npx rhythmguard audit ./src --since-baseline --fail-on-new-drift
95
+ npx rhythmguard audit ./src --staged --max-findings 0
92
96
  ```
93
97
 
94
- The report covers authored CSS declarations and Tailwind arbitrary spacing values in common template/source files. Markdown output is PR-ready for UX developers, UX designers, and design-system owners:
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:
95
99
 
96
100
  ```md
97
101
  # Rhythmguard Design-System Audit
@@ -103,6 +107,7 @@ The report covers authored CSS declarations and Tailwind arbitrary spacing value
103
107
  | Files with issues | 12 |
104
108
  | Total findings | 52 |
105
109
  | Scale cleanliness | 91% |
110
+ | New findings | 3 |
106
111
  ```
107
112
 
108
113
  ## Installation
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "stylelint-plugin-rhythmguard",
3
- "version": "1.6.0",
3
+ "version": "1.7.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
@@ -1,5 +1,6 @@
1
1
  'use strict';
2
2
 
3
+ const { execFileSync } = require('node:child_process');
3
4
  const fs = require('node:fs');
4
5
  const path = require('node:path');
5
6
 
@@ -11,12 +12,19 @@ const pluginPath = path.resolve(__dirname, '..', 'index.js');
11
12
 
12
13
  const DEFAULT_SCALE = [0, 4, 8, 12, 16, 24, 32];
13
14
  const DEFAULT_BASE_FONT_SIZE = 16;
15
+ const DEFAULT_BASELINE_PATH = '.rhythmguard-baseline.json';
16
+ const DEFAULT_IGNORE_PATH = '.rhythmguardignore';
17
+ const DEFAULT_TOKEN_PATTERN = '^--spac(e|ing)-';
18
+ const DEFAULT_TOKEN_CANDIDATE_MIN_COUNT = 2;
14
19
  const VALID_FORMATS = new Set(['text', 'json', 'markdown']);
15
20
  const SKIP_DIRS = new Set([
16
21
  '.git',
17
22
  '.next',
18
23
  '.nuxt',
19
24
  '.omx',
25
+ '.svelte-kit',
26
+ '.turbo',
27
+ '.vercel',
20
28
  'build',
21
29
  'coverage',
22
30
  'dist',
@@ -41,16 +49,38 @@ Options:
41
49
  --format <text|json|markdown> Output format (default: text)
42
50
  --json Alias for --format json
43
51
  --markdown Alias for --format markdown
52
+ --ignore <pattern> Exclude root-relative path/glob (repeatable, comma-separated)
53
+ --ignore-path <file> Load ignore patterns from file (default: .rhythmguardignore when present)
54
+ --baseline <file> Baseline file path (default: .rhythmguard-baseline.json)
55
+ --write-baseline [file] Write current findings as a baseline
56
+ --since-baseline [file] Compare current findings against a baseline
57
+ --fail-on-new-drift Exit 1 when --since-baseline finds new drift
58
+ --max-findings <number> Exit 1 when total findings exceed this count
59
+ --min-cleanliness <percent> Exit 1 when scale cleanliness is lower than this percent
60
+ --since <git-ref> Scan only changed files since a git ref
61
+ --staged Scan only staged files
62
+ --token-candidate-min-count <n> Minimum repeated raw value count for token candidates (default: 2)
44
63
  --scale <values> Comma-separated scale values (default: 0,4,8,12,16,24,32)
45
64
  --base-font-size <number> px base for rem/em conversion (default: 16)
46
65
  `;
47
66
 
48
67
  function parseArgs(argv) {
49
68
  const parsed = {
69
+ baselinePath: DEFAULT_BASELINE_PATH,
50
70
  baseFontSize: DEFAULT_BASE_FONT_SIZE,
51
71
  dir: null,
72
+ failOnNewDrift: false,
52
73
  format: 'text',
74
+ ignorePath: DEFAULT_IGNORE_PATH,
75
+ ignorePatterns: [],
76
+ maxFindings: null,
77
+ minCleanliness: null,
53
78
  scale: DEFAULT_SCALE,
79
+ since: null,
80
+ sinceBaseline: false,
81
+ staged: false,
82
+ tokenCandidateMinCount: DEFAULT_TOKEN_CANDIDATE_MIN_COUNT,
83
+ writeBaseline: false,
54
84
  };
55
85
 
56
86
  for (let index = 0; index < argv.length; index++) {
@@ -71,6 +101,120 @@ function parseArgs(argv) {
71
101
  continue;
72
102
  }
73
103
 
104
+ if (arg === '--ignore') {
105
+ parsed.ignorePatterns.push(...parseIgnorePatterns(argv[++index]));
106
+ continue;
107
+ }
108
+
109
+ if (arg.startsWith('--ignore=')) {
110
+ parsed.ignorePatterns.push(...parseIgnorePatterns(arg.slice('--ignore='.length)));
111
+ continue;
112
+ }
113
+
114
+ if (arg === '--ignore-path') {
115
+ parsed.ignorePath = parsePathOption(argv[++index], '--ignore-path');
116
+ continue;
117
+ }
118
+
119
+ if (arg.startsWith('--ignore-path=')) {
120
+ parsed.ignorePath = parsePathOption(arg.slice('--ignore-path='.length), '--ignore-path');
121
+ continue;
122
+ }
123
+
124
+ if (arg === '--baseline') {
125
+ parsed.baselinePath = parsePathOption(argv[++index], '--baseline');
126
+ continue;
127
+ }
128
+
129
+ if (arg.startsWith('--baseline=')) {
130
+ parsed.baselinePath = parsePathOption(arg.slice('--baseline='.length), '--baseline');
131
+ continue;
132
+ }
133
+
134
+ if (arg === '--write-baseline') {
135
+ parsed.writeBaseline = true;
136
+ if (argv[index + 1] && !argv[index + 1].startsWith('-')) {
137
+ parsed.baselinePath = parsePathOption(argv[++index], '--write-baseline');
138
+ }
139
+ continue;
140
+ }
141
+
142
+ if (arg.startsWith('--write-baseline=')) {
143
+ parsed.writeBaseline = true;
144
+ parsed.baselinePath = parsePathOption(arg.slice('--write-baseline='.length), '--write-baseline');
145
+ continue;
146
+ }
147
+
148
+ if (arg === '--since-baseline') {
149
+ parsed.sinceBaseline = true;
150
+ if (argv[index + 1] && !argv[index + 1].startsWith('-')) {
151
+ parsed.baselinePath = parsePathOption(argv[++index], '--since-baseline');
152
+ }
153
+ continue;
154
+ }
155
+
156
+ if (arg.startsWith('--since-baseline=')) {
157
+ parsed.sinceBaseline = true;
158
+ parsed.baselinePath = parsePathOption(arg.slice('--since-baseline='.length), '--since-baseline');
159
+ continue;
160
+ }
161
+
162
+ if (arg === '--fail-on-new-drift') {
163
+ parsed.failOnNewDrift = true;
164
+ continue;
165
+ }
166
+
167
+ if (arg === '--max-findings') {
168
+ parsed.maxFindings = parseNonNegativeInteger(argv[++index], '--max-findings');
169
+ continue;
170
+ }
171
+
172
+ if (arg.startsWith('--max-findings=')) {
173
+ parsed.maxFindings = parseNonNegativeInteger(arg.slice('--max-findings='.length), '--max-findings');
174
+ continue;
175
+ }
176
+
177
+ if (arg === '--min-cleanliness') {
178
+ parsed.minCleanliness = parsePercentage(argv[++index], '--min-cleanliness');
179
+ continue;
180
+ }
181
+
182
+ if (arg.startsWith('--min-cleanliness=')) {
183
+ parsed.minCleanliness = parsePercentage(
184
+ arg.slice('--min-cleanliness='.length),
185
+ '--min-cleanliness',
186
+ );
187
+ continue;
188
+ }
189
+
190
+ if (arg === '--since') {
191
+ parsed.since = parsePathOption(argv[++index], '--since');
192
+ continue;
193
+ }
194
+
195
+ if (arg.startsWith('--since=')) {
196
+ parsed.since = parsePathOption(arg.slice('--since='.length), '--since');
197
+ continue;
198
+ }
199
+
200
+ if (arg === '--staged') {
201
+ parsed.staged = true;
202
+ continue;
203
+ }
204
+
205
+ if (arg === '--token-candidate-min-count') {
206
+ parsed.tokenCandidateMinCount = parsePositiveInteger(argv[++index], '--token-candidate-min-count');
207
+ continue;
208
+ }
209
+
210
+ if (arg.startsWith('--token-candidate-min-count=')) {
211
+ parsed.tokenCandidateMinCount = parsePositiveInteger(
212
+ arg.slice('--token-candidate-min-count='.length),
213
+ '--token-candidate-min-count',
214
+ );
215
+ continue;
216
+ }
217
+
74
218
  if (arg === '--format') {
75
219
  parsed.format = String(argv[++index] || '').toLowerCase();
76
220
  continue;
@@ -113,9 +257,77 @@ function parseArgs(argv) {
113
257
  throw new Error(`Invalid format "${parsed.format}". Expected text, json, or markdown.`);
114
258
  }
115
259
 
260
+ if (parsed.since && parsed.staged) {
261
+ throw new Error('Use either --since or --staged, not both.');
262
+ }
263
+
264
+ if (parsed.failOnNewDrift && !parsed.sinceBaseline) {
265
+ throw new Error('--fail-on-new-drift requires --since-baseline.');
266
+ }
267
+
116
268
  return parsed;
117
269
  }
118
270
 
271
+ function parsePathOption(raw, optionName) {
272
+ if (!raw) {
273
+ throw new Error(`Missing value for ${optionName}.`);
274
+ }
275
+
276
+ return String(raw);
277
+ }
278
+
279
+ function parseNonNegativeInteger(raw, optionName) {
280
+ const value = Number(raw);
281
+ if (!Number.isInteger(value) || value < 0) {
282
+ throw new Error(`${optionName} must be a non-negative integer.`);
283
+ }
284
+
285
+ return value;
286
+ }
287
+
288
+ function parsePositiveInteger(raw, optionName) {
289
+ const value = Number(raw);
290
+ if (!Number.isInteger(value) || value <= 0) {
291
+ throw new Error(`${optionName} must be a positive integer.`);
292
+ }
293
+
294
+ return value;
295
+ }
296
+
297
+ function parsePercentage(raw, optionName) {
298
+ const value = Number(raw);
299
+ if (!Number.isFinite(value) || value < 0 || value > 100) {
300
+ throw new Error(`${optionName} must be a number between 0 and 100.`);
301
+ }
302
+
303
+ return value;
304
+ }
305
+
306
+ function parseIgnorePatterns(raw) {
307
+ if (!raw) {
308
+ throw new Error('Missing value for --ignore.');
309
+ }
310
+
311
+ const patterns = String(raw).split(',')
312
+ .map((pattern) => normalizeIgnorePattern(pattern))
313
+ .filter(Boolean);
314
+
315
+ if (patterns.length === 0) {
316
+ throw new Error('--ignore must include at least one pattern.');
317
+ }
318
+
319
+ return patterns;
320
+ }
321
+
322
+ function normalizeIgnorePattern(pattern) {
323
+ return String(pattern)
324
+ .trim()
325
+ .replace(/\\/g, '/')
326
+ .replace(/^\/+/, '')
327
+ .replace(/^\.\//, '')
328
+ .replace(/\/+$/, '');
329
+ }
330
+
119
331
  function parseScale(raw) {
120
332
  if (!raw) {
121
333
  throw new Error('Missing value for --scale.');
@@ -157,29 +369,231 @@ function assertDirectory(dir) {
157
369
  process.exit(1);
158
370
  }
159
371
 
372
+ if (!fs.statSync(resolvedDir).isDirectory()) {
373
+ process.stderr.write(`Not a directory: ${dir}\n`);
374
+ process.exit(1);
375
+ }
376
+
160
377
  return resolvedDir;
161
378
  }
162
379
 
163
- function walkFiles(rootDir) {
164
- const files = [];
380
+ function loadIgnorePatterns(ignorePath) {
381
+ if (!ignorePath) {
382
+ return [];
383
+ }
384
+
385
+ const resolvedPath = path.resolve(process.cwd(), ignorePath);
386
+ if (!fs.existsSync(resolvedPath)) {
387
+ if (ignorePath === DEFAULT_IGNORE_PATH) {
388
+ return [];
389
+ }
390
+ throw new Error(`Ignore file not found: ${ignorePath}`);
391
+ }
392
+
393
+ return fs.readFileSync(resolvedPath, 'utf8')
394
+ .split(/\r?\n/)
395
+ .map((line) => line.trim())
396
+ .filter((line) => line && !line.startsWith('#'))
397
+ .map((line) => normalizeIgnorePattern(line))
398
+ .filter(Boolean);
399
+ }
400
+
401
+ function walkFiles(rootDir, ignorePatterns = []) {
402
+ const cssFiles = [];
403
+ const templateFiles = [];
404
+ const ignoreMatchers = createIgnoreMatchers(ignorePatterns);
165
405
 
166
406
  function walk(currentDir) {
167
407
  for (const entry of fs.readdirSync(currentDir, { withFileTypes: true })) {
408
+ const fullPath = path.join(currentDir, entry.name);
409
+ const relativePath = toPosixRelativePath(rootDir, fullPath);
410
+
411
+ if (shouldIgnorePath(relativePath, entry, ignoreMatchers)) {
412
+ continue;
413
+ }
414
+
168
415
  if (entry.isDirectory()) {
169
- if (!SKIP_DIRS.has(entry.name)) {
170
- walk(path.join(currentDir, entry.name));
171
- }
416
+ walk(fullPath);
172
417
  continue;
173
418
  }
174
419
 
175
420
  if (entry.isFile()) {
176
- files.push(path.join(currentDir, entry.name));
421
+ if (isCssFile(fullPath)) {
422
+ cssFiles.push(fullPath);
423
+ } else if (isTemplateFile(fullPath)) {
424
+ templateFiles.push(fullPath);
425
+ }
177
426
  }
178
427
  }
179
428
  }
180
429
 
181
430
  walk(rootDir);
182
- return files;
431
+ return { cssFiles, templateFiles };
432
+ }
433
+
434
+ function getScanFiles(rootDir, ignorePatterns, parsed) {
435
+ if (parsed.staged || parsed.since) {
436
+ return getGitChangedScanFiles(rootDir, ignorePatterns, parsed);
437
+ }
438
+
439
+ return {
440
+ ...walkFiles(rootDir, ignorePatterns),
441
+ scanScope: {
442
+ mode: 'full',
443
+ },
444
+ };
445
+ }
446
+
447
+ function getGitChangedScanFiles(rootDir, ignorePatterns, parsed) {
448
+ const args = parsed.staged
449
+ ? ['diff', '--name-only', '--cached', '--diff-filter=ACMR', '--']
450
+ : ['diff', '--name-only', '--diff-filter=ACMR', parsed.since, '--'];
451
+ let output = '';
452
+
453
+ try {
454
+ output = execFileSync('git', args, {
455
+ cwd: process.cwd(),
456
+ encoding: 'utf8',
457
+ stdio: ['ignore', 'pipe', 'pipe'],
458
+ });
459
+ } catch (err) {
460
+ const stderr = err.stderr ? String(err.stderr).trim() : err.message;
461
+ throw new Error(`Unable to read changed files from git: ${stderr}`);
462
+ }
463
+
464
+ const cssFiles = [];
465
+ const templateFiles = [];
466
+ const ignoreMatchers = createIgnoreMatchers(ignorePatterns);
467
+ const seen = new Set();
468
+ const changedFiles = output.split(/\r?\n/)
469
+ .map((filePath) => filePath.trim())
470
+ .filter(Boolean);
471
+
472
+ for (const filePath of changedFiles) {
473
+ const fullPath = path.resolve(process.cwd(), filePath);
474
+
475
+ if (!isPathInside(rootDir, fullPath) || seen.has(fullPath) || !fs.existsSync(fullPath)) {
476
+ continue;
477
+ }
478
+
479
+ const stat = fs.statSync(fullPath);
480
+ if (!stat.isFile()) {
481
+ continue;
482
+ }
483
+
484
+ const relativePath = toPosixRelativePath(rootDir, fullPath);
485
+ if (shouldIgnoreRelativeFile(relativePath, ignoreMatchers)) {
486
+ continue;
487
+ }
488
+
489
+ seen.add(fullPath);
490
+ if (isCssFile(fullPath)) {
491
+ cssFiles.push(fullPath);
492
+ } else if (isTemplateFile(fullPath)) {
493
+ templateFiles.push(fullPath);
494
+ }
495
+ }
496
+
497
+ return {
498
+ cssFiles,
499
+ scanScope: {
500
+ changedFiles: changedFiles.length,
501
+ mode: parsed.staged ? 'staged' : 'since',
502
+ since: parsed.since,
503
+ },
504
+ templateFiles,
505
+ };
506
+ }
507
+
508
+ function isPathInside(rootDir, filePath) {
509
+ const relativePath = path.relative(rootDir, filePath);
510
+ return relativePath === '' || (!relativePath.startsWith('..') && !path.isAbsolute(relativePath));
511
+ }
512
+
513
+ function shouldIgnorePath(relativePath, entry, ignoreMatchers) {
514
+ return ignoreMatchers.some((matcher) => matcher.test(relativePath))
515
+ || (entry.isDirectory() && SKIP_DIRS.has(entry.name));
516
+ }
517
+
518
+ function shouldIgnoreRelativeFile(relativePath, ignoreMatchers) {
519
+ const segments = relativePath.split('/');
520
+ return segments.some((segment) => SKIP_DIRS.has(segment))
521
+ || ignoreMatchers.some((matcher) => matcher.test(relativePath));
522
+ }
523
+
524
+ function createIgnoreMatchers(patterns) {
525
+ const variants = new Set();
526
+
527
+ for (const pattern of patterns) {
528
+ addIgnorePatternVariants(variants, pattern);
529
+ }
530
+
531
+ return Array.from(variants, (pattern) => globToRegExp(pattern));
532
+ }
533
+
534
+ function addIgnorePatternVariants(variants, pattern) {
535
+ if (!pattern) {
536
+ return;
537
+ }
538
+
539
+ variants.add(pattern);
540
+
541
+ if (!pattern.includes('/')) {
542
+ variants.add(`${pattern}/**`);
543
+ variants.add(`**/${pattern}`);
544
+ variants.add(`**/${pattern}/**`);
545
+ return;
546
+ }
547
+
548
+ if (pattern.endsWith('/**')) {
549
+ variants.add(pattern.slice(0, -3));
550
+ return;
551
+ }
552
+
553
+ if (!hasGlob(pattern)) {
554
+ variants.add(`${pattern}/**`);
555
+ }
556
+ }
557
+
558
+ function hasGlob(pattern) {
559
+ return /[*?]/.test(pattern);
560
+ }
561
+
562
+ function globToRegExp(pattern) {
563
+ let source = '^';
564
+
565
+ for (let index = 0; index < pattern.length; index++) {
566
+ const char = pattern[index];
567
+ const nextChar = pattern[index + 1];
568
+
569
+ if (char === '*' && nextChar === '*') {
570
+ source += '.*';
571
+ index++;
572
+ continue;
573
+ }
574
+
575
+ if (char === '*') {
576
+ source += '[^/]*';
577
+ continue;
578
+ }
579
+
580
+ if (char === '?') {
581
+ source += '[^/]';
582
+ continue;
583
+ }
584
+
585
+ source += escapeRegExp(char);
586
+ }
587
+
588
+ return new RegExp(`${source}$`);
589
+ }
590
+
591
+ function escapeRegExp(value) {
592
+ return value.replace(/[|\\{}()[\]^$+*?.]/g, '\\$&');
593
+ }
594
+
595
+ function toPosixRelativePath(rootDir, filePath) {
596
+ return path.relative(rootDir, filePath).split(path.sep).join('/');
183
597
  }
184
598
 
185
599
  function isCssFile(filePath) {
@@ -347,12 +761,151 @@ function offsetToLineColumn(lineStarts, offset) {
347
761
  };
348
762
  }
349
763
 
764
+ function collectTokenContract(cssFiles, cssFindings, tailwindFindings, minCandidateCount) {
765
+ const definitions = new Map();
766
+ const uses = new Map();
767
+ const rawValues = new Map();
768
+ const rawValueLocations = new Set();
769
+
770
+ for (const filePath of cssFiles) {
771
+ let source = '';
772
+ try {
773
+ source = fs.readFileSync(filePath, 'utf8');
774
+ } catch {
775
+ continue;
776
+ }
777
+
778
+ const file = formatPath(filePath);
779
+ collectTokenDefinitions(source, file, definitions);
780
+ collectTokenUses(source, file, uses);
781
+ }
782
+
783
+ for (const finding of cssFindings) {
784
+ addRawValue(rawValues, rawValueLocations, finding.value, finding);
785
+ }
786
+
787
+ for (const finding of tailwindFindings) {
788
+ addRawValue(rawValues, rawValueLocations, finding.rawValue, finding);
789
+ }
790
+
791
+ const definedTokens = mapTokenEntries(definitions);
792
+ const usedTokens = mapTokenEntries(uses);
793
+ const missingTokens = usedTokens.filter(({ token }) => !definitions.has(token));
794
+ const unusedTokens = definedTokens.filter(({ token }) => !uses.has(token));
795
+ const rawValueCandidates = Array.from(rawValues.entries())
796
+ .map(([value, entry]) => ({
797
+ count: entry.count,
798
+ files: Array.from(entry.files).sort(),
799
+ value,
800
+ }))
801
+ .filter(({ count }) => count >= minCandidateCount)
802
+ .sort((a, b) => b.count - a.count || a.value.localeCompare(b.value));
803
+
804
+ return {
805
+ definedTokens,
806
+ missingTokens,
807
+ rawValueCandidates,
808
+ summary: {
809
+ definedTokens: definedTokens.length,
810
+ missingTokens: missingTokens.length,
811
+ rawValueCandidates: rawValueCandidates.length,
812
+ unusedTokens: unusedTokens.length,
813
+ usedTokens: usedTokens.length,
814
+ },
815
+ unusedTokens,
816
+ usedTokens,
817
+ };
818
+ }
819
+
820
+ function collectTokenDefinitions(source, file, definitions) {
821
+ const declarationPattern = /(--[\w-]+)\s*:\s*([^;{}]+)/g;
822
+ let match;
823
+
824
+ while ((match = declarationPattern.exec(source)) !== null) {
825
+ const token = match[1];
826
+ if (!isSpacingToken(token)) {
827
+ continue;
828
+ }
829
+
830
+ const entry = definitions.get(token) || {
831
+ files: new Set(),
832
+ token,
833
+ values: new Set(),
834
+ };
835
+ entry.files.add(file);
836
+ entry.values.add(match[2].trim());
837
+ definitions.set(token, entry);
838
+ }
839
+ }
840
+
841
+ function collectTokenUses(source, file, uses) {
842
+ const varPattern = /var\(\s*(--[\w-]+)/g;
843
+ let match;
844
+
845
+ while ((match = varPattern.exec(source)) !== null) {
846
+ const token = match[1];
847
+ if (!isSpacingToken(token)) {
848
+ continue;
849
+ }
850
+
851
+ const entry = uses.get(token) || {
852
+ files: new Set(),
853
+ token,
854
+ };
855
+ entry.files.add(file);
856
+ uses.set(token, entry);
857
+ }
858
+ }
859
+
860
+ function isSpacingToken(token) {
861
+ return new RegExp(DEFAULT_TOKEN_PATTERN).test(token);
862
+ }
863
+
864
+ function addRawValue(rawValues, rawValueLocations, value, finding) {
865
+ if (!value) {
866
+ return;
867
+ }
868
+
869
+ const locationKey = [
870
+ finding.file || '',
871
+ finding.line || '',
872
+ finding.column || '',
873
+ value,
874
+ ].join('\u001f');
875
+ if (rawValueLocations.has(locationKey)) {
876
+ return;
877
+ }
878
+ rawValueLocations.add(locationKey);
879
+
880
+ const entry = rawValues.get(value) || {
881
+ count: 0,
882
+ files: new Set(),
883
+ };
884
+ entry.count += 1;
885
+ if (finding.file) {
886
+ entry.files.add(finding.file);
887
+ }
888
+ rawValues.set(value, entry);
889
+ }
890
+
891
+ function mapTokenEntries(entries) {
892
+ return Array.from(entries.values())
893
+ .map((entry) => ({
894
+ files: Array.from(entry.files).sort(),
895
+ token: entry.token,
896
+ values: entry.values ? Array.from(entry.values).sort() : undefined,
897
+ }))
898
+ .sort((a, b) => a.token.localeCompare(b.token));
899
+ }
900
+
350
901
  function buildReport({
351
902
  cssFiles,
352
903
  cssFindings,
353
904
  dir,
905
+ scanScope,
354
906
  templateFiles,
355
907
  tailwindFindings,
908
+ tokenCandidateMinCount,
356
909
  }) {
357
910
  const offScaleValues = countByValue(cssFindings
358
911
  .filter((finding) => finding.type === 'off-scale' && finding.value)
@@ -377,6 +930,12 @@ function buildReport({
377
930
  const scaleCleanliness = totalFiles > 0
378
931
  ? Math.max(0, Math.round(((totalFiles - filesWithIssues) / totalFiles) * 100))
379
932
  : 100;
933
+ const tokenContract = collectTokenContract(
934
+ cssFiles,
935
+ cssFindings,
936
+ tailwindFindings,
937
+ tokenCandidateMinCount,
938
+ );
380
939
 
381
940
  return {
382
941
  cssFilesScanned: cssFiles.length,
@@ -386,9 +945,10 @@ function buildReport({
386
945
  css: cssFindings,
387
946
  tailwind: tailwindFindings,
388
947
  },
389
- formatVersion: 2,
948
+ formatVersion: 3,
390
949
  offScaleValues: Object.fromEntries(sortCountMap(offScaleValues).slice(0, 10)),
391
950
  scaleCleanliness,
951
+ scanScope,
392
952
  scanned: {
393
953
  cssFiles: cssFiles.length,
394
954
  templateFiles: templateFiles.length,
@@ -397,13 +957,17 @@ function buildReport({
397
957
  summary: {
398
958
  cssWarnings: cssFindings.length,
399
959
  filesWithIssues,
960
+ missingTokens: tokenContract.summary.missingTokens,
961
+ rawValueCandidates: tokenContract.summary.rawValueCandidates,
400
962
  scaleCleanliness,
401
963
  tailwindArbitrarySpacing: tailwindFindings.length,
402
964
  tokenOpportunities: sumCounts(tokenOpportunities),
403
965
  totalFindings: totalWarnings,
966
+ unusedTokens: tokenContract.summary.unusedTokens,
404
967
  },
405
968
  tailwindArbitraryValues: Object.fromEntries(sortCountMap(tailwindArbitraryValues).slice(0, 10)),
406
969
  templateFilesScanned: templateFiles.length,
970
+ tokenContract,
407
971
  tokenOpportunities: Object.fromEntries(sortCountMap(tokenOpportunities).slice(0, 10)),
408
972
  topAffectedFiles: topAffectedFiles.map(([file, count]) => ({ count, file })),
409
973
  totalFiles,
@@ -431,6 +995,105 @@ function sumCounts(counts) {
431
995
  return Object.values(counts).reduce((total, count) => total + count, 0);
432
996
  }
433
997
 
998
+ function applyBaselineComparison(report, baselinePath) {
999
+ const resolvedPath = path.resolve(process.cwd(), baselinePath);
1000
+ if (!fs.existsSync(resolvedPath)) {
1001
+ throw new Error(`Baseline file not found: ${baselinePath}`);
1002
+ }
1003
+
1004
+ const baseline = JSON.parse(fs.readFileSync(resolvedPath, 'utf8'));
1005
+ const baselineFindings = Array.isArray(baseline.findings) ? baseline.findings : [];
1006
+ const baselineKeys = new Set(baselineFindings.map((finding) => finding.key || createFindingKey(finding)));
1007
+ const currentFindings = getAllFindings(report);
1008
+ const currentKeys = new Set(currentFindings.map(createFindingKey));
1009
+ const newFindings = currentFindings.filter((finding) => !baselineKeys.has(createFindingKey(finding)));
1010
+ const resolvedFindings = baselineFindings.filter((finding) => !currentKeys.has(finding.key || createFindingKey(finding)));
1011
+
1012
+ report.baseline = {
1013
+ baselineFindings: baselineFindings.length,
1014
+ file: formatPath(resolvedPath),
1015
+ newFindings,
1016
+ newFindingsCount: newFindings.length,
1017
+ resolvedFindingsCount: resolvedFindings.length,
1018
+ };
1019
+ report.summary.newFindings = newFindings.length;
1020
+ report.summary.resolvedFindings = resolvedFindings.length;
1021
+ }
1022
+
1023
+ function writeBaseline(report, baselinePath) {
1024
+ const resolvedPath = path.resolve(process.cwd(), baselinePath);
1025
+ fs.mkdirSync(path.dirname(resolvedPath), { recursive: true });
1026
+ fs.writeFileSync(
1027
+ resolvedPath,
1028
+ `${JSON.stringify({
1029
+ createdAt: new Date().toISOString(),
1030
+ directory: report.directory,
1031
+ findings: getAllFindings(report).map(toBaselineFinding),
1032
+ formatVersion: 1,
1033
+ summary: {
1034
+ scaleCleanliness: report.scaleCleanliness,
1035
+ totalFindings: report.totalWarnings,
1036
+ },
1037
+ }, null, 2)}\n`,
1038
+ );
1039
+
1040
+ report.baselineWritten = {
1041
+ file: formatPath(resolvedPath),
1042
+ findings: report.totalWarnings,
1043
+ };
1044
+ }
1045
+
1046
+ function getAllFindings(report) {
1047
+ return [
1048
+ ...report.findings.css,
1049
+ ...report.findings.tailwind,
1050
+ ];
1051
+ }
1052
+
1053
+ function toBaselineFinding(finding) {
1054
+ return {
1055
+ column: finding.column,
1056
+ file: finding.file,
1057
+ key: createFindingKey(finding),
1058
+ line: finding.line,
1059
+ rule: finding.rule,
1060
+ text: finding.text,
1061
+ token: finding.token,
1062
+ type: finding.type,
1063
+ value: finding.value || finding.rawValue,
1064
+ };
1065
+ }
1066
+
1067
+ function createFindingKey(finding) {
1068
+ return [
1069
+ finding.rule || '',
1070
+ finding.type || '',
1071
+ finding.file || '',
1072
+ finding.line || '',
1073
+ finding.column || '',
1074
+ finding.value || finding.rawValue || finding.token || '',
1075
+ finding.text || '',
1076
+ ].join('\u001f');
1077
+ }
1078
+
1079
+ function getAuditFailures(report, parsed) {
1080
+ const failures = [];
1081
+
1082
+ if (parsed.maxFindings !== null && report.totalWarnings > parsed.maxFindings) {
1083
+ failures.push(`total findings ${report.totalWarnings} exceeds --max-findings ${parsed.maxFindings}`);
1084
+ }
1085
+
1086
+ if (parsed.minCleanliness !== null && report.scaleCleanliness < parsed.minCleanliness) {
1087
+ failures.push(`scale cleanliness ${report.scaleCleanliness}% is below --min-cleanliness ${parsed.minCleanliness}%`);
1088
+ }
1089
+
1090
+ if (parsed.failOnNewDrift && report.baseline && report.baseline.newFindingsCount > 0) {
1091
+ failures.push(`new drift found: ${report.baseline.newFindingsCount} finding(s) not present in baseline`);
1092
+ }
1093
+
1094
+ return failures;
1095
+ }
1096
+
434
1097
  function renderText(report) {
435
1098
  const lines = [
436
1099
  '',
@@ -448,6 +1111,8 @@ function renderText(report) {
448
1111
  appendHistogram(lines, 'CSS OFF-SCALE VALUES', report.offScaleValues);
449
1112
  appendHistogram(lines, 'CSS TOKEN OPPORTUNITIES', report.tokenOpportunities);
450
1113
  appendHistogram(lines, 'TAILWIND CLASS-STRING DRIFT', report.tailwindArbitraryValues);
1114
+ appendTokenContractText(lines, report.tokenContract);
1115
+ appendBaselineText(lines, report);
451
1116
 
452
1117
  if (report.topAffectedFiles.length > 0) {
453
1118
  lines.push(' ── TOP AFFECTED FILES ──');
@@ -470,6 +1135,55 @@ function renderText(report) {
470
1135
  return `${lines.join('\n')}\n`;
471
1136
  }
472
1137
 
1138
+ function appendTokenContractText(lines, tokenContract) {
1139
+ const { missingTokens, rawValueCandidates, unusedTokens } = tokenContract;
1140
+ if (missingTokens.length === 0 && rawValueCandidates.length === 0 && unusedTokens.length === 0) {
1141
+ return;
1142
+ }
1143
+
1144
+ lines.push(' ── TOKEN CONTRACT ──');
1145
+ lines.push('');
1146
+
1147
+ if (missingTokens.length > 0) {
1148
+ lines.push(` Missing tokens ${missingTokens.length}`);
1149
+ for (const entry of missingTokens.slice(0, 5)) {
1150
+ lines.push(` ${entry.token} (${truncate(entry.files.join(', '), 42)})`);
1151
+ }
1152
+ }
1153
+
1154
+ if (unusedTokens.length > 0) {
1155
+ lines.push(` Defined but unused ${unusedTokens.length}`);
1156
+ for (const entry of unusedTokens.slice(0, 5)) {
1157
+ lines.push(` ${entry.token}`);
1158
+ }
1159
+ }
1160
+
1161
+ if (rawValueCandidates.length > 0) {
1162
+ lines.push(` Repeated raw values ${rawValueCandidates.length}`);
1163
+ for (const entry of rawValueCandidates.slice(0, 5)) {
1164
+ lines.push(` ${entry.value.padEnd(14)} ${entry.count}`);
1165
+ }
1166
+ }
1167
+
1168
+ lines.push('');
1169
+ }
1170
+
1171
+ function appendBaselineText(lines, report) {
1172
+ if (report.baseline) {
1173
+ lines.push(' ── BASELINE COMPARISON ──');
1174
+ lines.push('');
1175
+ lines.push(` Baseline findings ${String(report.baseline.baselineFindings).padStart(4)}`);
1176
+ lines.push(` New findings ${String(report.baseline.newFindingsCount).padStart(4)}`);
1177
+ lines.push(` Resolved findings ${String(report.baseline.resolvedFindingsCount).padStart(4)}`);
1178
+ lines.push('');
1179
+ }
1180
+
1181
+ if (report.baselineWritten) {
1182
+ lines.push(` Baseline written ${report.baselineWritten.file}`);
1183
+ lines.push('');
1184
+ }
1185
+ }
1186
+
473
1187
  function appendHistogram(lines, title, counts) {
474
1188
  const entries = sortCountMap(counts);
475
1189
  const total = sumCounts(counts);
@@ -502,12 +1216,19 @@ function renderMarkdown(report) {
502
1216
  `| Files with issues | ${report.filesWithIssues} |`,
503
1217
  `| Total findings | ${report.totalWarnings} |`,
504
1218
  `| Scale cleanliness | ${report.scaleCleanliness}% |`,
505
- '',
506
1219
  ];
507
1220
 
1221
+ if (report.baseline) {
1222
+ lines.push(`| New findings | ${report.baseline.newFindingsCount} |`);
1223
+ lines.push(`| Resolved findings | ${report.baseline.resolvedFindingsCount} |`);
1224
+ }
1225
+ lines.push('');
1226
+
508
1227
  appendMarkdownCounts(lines, 'CSS Off-Scale Values', report.offScaleValues);
509
1228
  appendMarkdownCounts(lines, 'CSS Token Opportunities', report.tokenOpportunities);
510
1229
  appendMarkdownCounts(lines, 'Tailwind Class-String Drift', report.tailwindArbitraryValues);
1230
+ appendTokenContractMarkdown(lines, report.tokenContract);
1231
+ appendBaselineMarkdown(lines, report);
511
1232
 
512
1233
  if (report.topAffectedFiles.length > 0) {
513
1234
  lines.push('## Top Affected Files');
@@ -545,6 +1266,81 @@ function renderMarkdown(report) {
545
1266
  return `${lines.join('\n')}\n`;
546
1267
  }
547
1268
 
1269
+ function appendTokenContractMarkdown(lines, tokenContract) {
1270
+ const { missingTokens, rawValueCandidates, unusedTokens } = tokenContract;
1271
+ if (missingTokens.length === 0 && rawValueCandidates.length === 0 && unusedTokens.length === 0) {
1272
+ return;
1273
+ }
1274
+
1275
+ lines.push('## Token Contract');
1276
+ lines.push('');
1277
+
1278
+ if (missingTokens.length > 0) {
1279
+ lines.push('### Tokens Used But Missing');
1280
+ lines.push('');
1281
+ lines.push('| Token | Files |');
1282
+ lines.push('| --- | --- |');
1283
+ for (const entry of missingTokens.slice(0, 10)) {
1284
+ lines.push(`| \`${escapeMarkdown(entry.token)}\` | \`${escapeMarkdown(entry.files.join(', '))}\` |`);
1285
+ }
1286
+ lines.push('');
1287
+ }
1288
+
1289
+ if (unusedTokens.length > 0) {
1290
+ lines.push('### Tokens Defined But Unused');
1291
+ lines.push('');
1292
+ lines.push('| Token | Value |');
1293
+ lines.push('| --- | --- |');
1294
+ for (const entry of unusedTokens.slice(0, 10)) {
1295
+ lines.push(`| \`${escapeMarkdown(entry.token)}\` | \`${escapeMarkdown((entry.values || []).join(', ') || 'n/a')}\` |`);
1296
+ }
1297
+ lines.push('');
1298
+ }
1299
+
1300
+ if (rawValueCandidates.length > 0) {
1301
+ lines.push('### Repeated Raw Value Candidates');
1302
+ lines.push('');
1303
+ lines.push('| Value | Count | Files |');
1304
+ lines.push('| --- | ---: | --- |');
1305
+ for (const entry of rawValueCandidates.slice(0, 10)) {
1306
+ lines.push(`| \`${escapeMarkdown(entry.value)}\` | ${entry.count} | \`${escapeMarkdown(entry.files.join(', '))}\` |`);
1307
+ }
1308
+ lines.push('');
1309
+ }
1310
+ }
1311
+
1312
+ function appendBaselineMarkdown(lines, report) {
1313
+ if (!report.baseline && !report.baselineWritten) {
1314
+ return;
1315
+ }
1316
+
1317
+ lines.push('## Baseline');
1318
+ lines.push('');
1319
+
1320
+ if (report.baseline) {
1321
+ lines.push('| Metric | Value |');
1322
+ lines.push('| --- | ---: |');
1323
+ lines.push(`| Baseline findings | ${report.baseline.baselineFindings} |`);
1324
+ lines.push(`| New findings | ${report.baseline.newFindingsCount} |`);
1325
+ lines.push(`| Resolved findings | ${report.baseline.resolvedFindingsCount} |`);
1326
+ lines.push('');
1327
+ }
1328
+
1329
+ if (report.baseline && report.baseline.newFindings.length > 0) {
1330
+ lines.push('| New finding | Location |');
1331
+ lines.push('| --- | --- |');
1332
+ for (const finding of report.baseline.newFindings.slice(0, 10)) {
1333
+ lines.push(`| \`${escapeMarkdown(finding.text)}\` | \`${escapeMarkdown(`${finding.file}:${finding.line}`)}\` |`);
1334
+ }
1335
+ lines.push('');
1336
+ }
1337
+
1338
+ if (report.baselineWritten) {
1339
+ lines.push(`Baseline written: \`${escapeMarkdown(report.baselineWritten.file)}\``);
1340
+ lines.push('');
1341
+ }
1342
+ }
1343
+
548
1344
  function appendMarkdownCounts(lines, title, counts) {
549
1345
  const entries = sortCountMap(counts);
550
1346
 
@@ -605,9 +1401,26 @@ async function run() {
605
1401
  }
606
1402
 
607
1403
  const resolvedDir = assertDirectory(parsed.dir);
608
- const allFiles = walkFiles(resolvedDir);
609
- const cssFiles = allFiles.filter(isCssFile);
610
- const templateFiles = allFiles.filter(isTemplateFile);
1404
+ let ignorePatterns;
1405
+ try {
1406
+ ignorePatterns = [
1407
+ ...loadIgnorePatterns(parsed.ignorePath),
1408
+ ...parsed.ignorePatterns,
1409
+ ];
1410
+ } catch (err) {
1411
+ process.stderr.write(`${err.message}\n`);
1412
+ process.exit(1);
1413
+ }
1414
+
1415
+ let scanFiles;
1416
+ try {
1417
+ scanFiles = getScanFiles(resolvedDir, ignorePatterns, parsed);
1418
+ } catch (err) {
1419
+ process.stderr.write(`${err.message}\n`);
1420
+ process.exit(1);
1421
+ }
1422
+
1423
+ const { cssFiles, scanScope, templateFiles } = scanFiles;
611
1424
  const options = {
612
1425
  baseFontSize: parsed.baseFontSize,
613
1426
  scale: parsed.scale,
@@ -625,21 +1438,50 @@ async function run() {
625
1438
  cssFiles,
626
1439
  cssFindings: collectCssFindings(cssResults),
627
1440
  dir: parsed.dir,
1441
+ scanScope,
628
1442
  tailwindFindings: collectTailwindFindings(templateFiles, options),
629
1443
  templateFiles,
1444
+ tokenCandidateMinCount: parsed.tokenCandidateMinCount,
630
1445
  });
631
1446
 
1447
+ try {
1448
+ if (parsed.sinceBaseline) {
1449
+ applyBaselineComparison(report, parsed.baselinePath);
1450
+ }
1451
+
1452
+ if (parsed.writeBaseline) {
1453
+ writeBaseline(report, parsed.baselinePath);
1454
+ }
1455
+ } catch (err) {
1456
+ process.stderr.write(`${err.message}\n`);
1457
+ process.exit(1);
1458
+ }
1459
+
1460
+ const auditFailures = getAuditFailures(report, parsed);
1461
+
632
1462
  if (parsed.format === 'json') {
633
1463
  process.stdout.write(`${JSON.stringify(report, null, 2)}\n`);
1464
+ finish(auditFailures);
634
1465
  return;
635
1466
  }
636
1467
 
637
1468
  if (parsed.format === 'markdown') {
638
1469
  process.stdout.write(renderMarkdown(report));
1470
+ finish(auditFailures);
639
1471
  return;
640
1472
  }
641
1473
 
642
1474
  process.stdout.write(renderText(report));
1475
+ finish(auditFailures);
1476
+ }
1477
+
1478
+ function finish(auditFailures) {
1479
+ if (auditFailures.length === 0) {
1480
+ return;
1481
+ }
1482
+
1483
+ process.stderr.write(`Audit failed: ${auditFailures.join('; ')}\n`);
1484
+ process.exitCode = 1;
643
1485
  }
644
1486
 
645
1487
  run();