stylelint-plugin-rhythmguard 1.6.1 → 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,16 @@ 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
+
9
19
  ## [1.6.1] - 2026-05-23
10
20
 
11
21
  ### Fixed
package/README.md CHANGED
@@ -90,9 +90,12 @@ npx rhythmguard audit ./src
90
90
  npx rhythmguard audit ./src --format markdown
91
91
  npx rhythmguard audit ./src --json
92
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
93
96
  ```
94
97
 
95
- The report covers authored CSS declarations and Tailwind arbitrary spacing values in common template/source files. Scan paths are scoped to the directory argument, and `--ignore` accepts repeatable, root-relative glob patterns for large generated or legacy subtrees. 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:
96
99
 
97
100
  ```md
98
101
  # Rhythmguard Design-System Audit
@@ -104,6 +107,7 @@ The report covers authored CSS declarations and Tailwind arbitrary spacing value
104
107
  | Files with issues | 12 |
105
108
  | Total findings | 52 |
106
109
  | Scale cleanliness | 91% |
110
+ | New findings | 3 |
107
111
  ```
108
112
 
109
113
  ## Installation
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "stylelint-plugin-rhythmguard",
3
- "version": "1.6.1",
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,6 +12,10 @@ 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',
@@ -45,17 +50,37 @@ Options:
45
50
  --json Alias for --format json
46
51
  --markdown Alias for --format markdown
47
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)
48
63
  --scale <values> Comma-separated scale values (default: 0,4,8,12,16,24,32)
49
64
  --base-font-size <number> px base for rem/em conversion (default: 16)
50
65
  `;
51
66
 
52
67
  function parseArgs(argv) {
53
68
  const parsed = {
69
+ baselinePath: DEFAULT_BASELINE_PATH,
54
70
  baseFontSize: DEFAULT_BASE_FONT_SIZE,
55
71
  dir: null,
72
+ failOnNewDrift: false,
56
73
  format: 'text',
74
+ ignorePath: DEFAULT_IGNORE_PATH,
57
75
  ignorePatterns: [],
76
+ maxFindings: null,
77
+ minCleanliness: null,
58
78
  scale: DEFAULT_SCALE,
79
+ since: null,
80
+ sinceBaseline: false,
81
+ staged: false,
82
+ tokenCandidateMinCount: DEFAULT_TOKEN_CANDIDATE_MIN_COUNT,
83
+ writeBaseline: false,
59
84
  };
60
85
 
61
86
  for (let index = 0; index < argv.length; index++) {
@@ -86,6 +111,110 @@ function parseArgs(argv) {
86
111
  continue;
87
112
  }
88
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
+
89
218
  if (arg === '--format') {
90
219
  parsed.format = String(argv[++index] || '').toLowerCase();
91
220
  continue;
@@ -128,9 +257,52 @@ function parseArgs(argv) {
128
257
  throw new Error(`Invalid format "${parsed.format}". Expected text, json, or markdown.`);
129
258
  }
130
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
+
131
268
  return parsed;
132
269
  }
133
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
+
134
306
  function parseIgnorePatterns(raw) {
135
307
  if (!raw) {
136
308
  throw new Error('Missing value for --ignore.');
@@ -205,6 +377,27 @@ function assertDirectory(dir) {
205
377
  return resolvedDir;
206
378
  }
207
379
 
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
+
208
401
  function walkFiles(rootDir, ignorePatterns = []) {
209
402
  const cssFiles = [];
210
403
  const templateFiles = [];
@@ -238,11 +431,96 @@ function walkFiles(rootDir, ignorePatterns = []) {
238
431
  return { cssFiles, templateFiles };
239
432
  }
240
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
+
241
513
  function shouldIgnorePath(relativePath, entry, ignoreMatchers) {
242
514
  return ignoreMatchers.some((matcher) => matcher.test(relativePath))
243
515
  || (entry.isDirectory() && SKIP_DIRS.has(entry.name));
244
516
  }
245
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
+
246
524
  function createIgnoreMatchers(patterns) {
247
525
  const variants = new Set();
248
526
 
@@ -483,12 +761,151 @@ function offsetToLineColumn(lineStarts, offset) {
483
761
  };
484
762
  }
485
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
+
486
901
  function buildReport({
487
902
  cssFiles,
488
903
  cssFindings,
489
904
  dir,
905
+ scanScope,
490
906
  templateFiles,
491
907
  tailwindFindings,
908
+ tokenCandidateMinCount,
492
909
  }) {
493
910
  const offScaleValues = countByValue(cssFindings
494
911
  .filter((finding) => finding.type === 'off-scale' && finding.value)
@@ -513,6 +930,12 @@ function buildReport({
513
930
  const scaleCleanliness = totalFiles > 0
514
931
  ? Math.max(0, Math.round(((totalFiles - filesWithIssues) / totalFiles) * 100))
515
932
  : 100;
933
+ const tokenContract = collectTokenContract(
934
+ cssFiles,
935
+ cssFindings,
936
+ tailwindFindings,
937
+ tokenCandidateMinCount,
938
+ );
516
939
 
517
940
  return {
518
941
  cssFilesScanned: cssFiles.length,
@@ -522,9 +945,10 @@ function buildReport({
522
945
  css: cssFindings,
523
946
  tailwind: tailwindFindings,
524
947
  },
525
- formatVersion: 2,
948
+ formatVersion: 3,
526
949
  offScaleValues: Object.fromEntries(sortCountMap(offScaleValues).slice(0, 10)),
527
950
  scaleCleanliness,
951
+ scanScope,
528
952
  scanned: {
529
953
  cssFiles: cssFiles.length,
530
954
  templateFiles: templateFiles.length,
@@ -533,13 +957,17 @@ function buildReport({
533
957
  summary: {
534
958
  cssWarnings: cssFindings.length,
535
959
  filesWithIssues,
960
+ missingTokens: tokenContract.summary.missingTokens,
961
+ rawValueCandidates: tokenContract.summary.rawValueCandidates,
536
962
  scaleCleanliness,
537
963
  tailwindArbitrarySpacing: tailwindFindings.length,
538
964
  tokenOpportunities: sumCounts(tokenOpportunities),
539
965
  totalFindings: totalWarnings,
966
+ unusedTokens: tokenContract.summary.unusedTokens,
540
967
  },
541
968
  tailwindArbitraryValues: Object.fromEntries(sortCountMap(tailwindArbitraryValues).slice(0, 10)),
542
969
  templateFilesScanned: templateFiles.length,
970
+ tokenContract,
543
971
  tokenOpportunities: Object.fromEntries(sortCountMap(tokenOpportunities).slice(0, 10)),
544
972
  topAffectedFiles: topAffectedFiles.map(([file, count]) => ({ count, file })),
545
973
  totalFiles,
@@ -567,6 +995,105 @@ function sumCounts(counts) {
567
995
  return Object.values(counts).reduce((total, count) => total + count, 0);
568
996
  }
569
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
+
570
1097
  function renderText(report) {
571
1098
  const lines = [
572
1099
  '',
@@ -584,6 +1111,8 @@ function renderText(report) {
584
1111
  appendHistogram(lines, 'CSS OFF-SCALE VALUES', report.offScaleValues);
585
1112
  appendHistogram(lines, 'CSS TOKEN OPPORTUNITIES', report.tokenOpportunities);
586
1113
  appendHistogram(lines, 'TAILWIND CLASS-STRING DRIFT', report.tailwindArbitraryValues);
1114
+ appendTokenContractText(lines, report.tokenContract);
1115
+ appendBaselineText(lines, report);
587
1116
 
588
1117
  if (report.topAffectedFiles.length > 0) {
589
1118
  lines.push(' ── TOP AFFECTED FILES ──');
@@ -606,6 +1135,55 @@ function renderText(report) {
606
1135
  return `${lines.join('\n')}\n`;
607
1136
  }
608
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
+
609
1187
  function appendHistogram(lines, title, counts) {
610
1188
  const entries = sortCountMap(counts);
611
1189
  const total = sumCounts(counts);
@@ -638,12 +1216,19 @@ function renderMarkdown(report) {
638
1216
  `| Files with issues | ${report.filesWithIssues} |`,
639
1217
  `| Total findings | ${report.totalWarnings} |`,
640
1218
  `| Scale cleanliness | ${report.scaleCleanliness}% |`,
641
- '',
642
1219
  ];
643
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
+
644
1227
  appendMarkdownCounts(lines, 'CSS Off-Scale Values', report.offScaleValues);
645
1228
  appendMarkdownCounts(lines, 'CSS Token Opportunities', report.tokenOpportunities);
646
1229
  appendMarkdownCounts(lines, 'Tailwind Class-String Drift', report.tailwindArbitraryValues);
1230
+ appendTokenContractMarkdown(lines, report.tokenContract);
1231
+ appendBaselineMarkdown(lines, report);
647
1232
 
648
1233
  if (report.topAffectedFiles.length > 0) {
649
1234
  lines.push('## Top Affected Files');
@@ -681,6 +1266,81 @@ function renderMarkdown(report) {
681
1266
  return `${lines.join('\n')}\n`;
682
1267
  }
683
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
+
684
1344
  function appendMarkdownCounts(lines, title, counts) {
685
1345
  const entries = sortCountMap(counts);
686
1346
 
@@ -741,7 +1401,26 @@ async function run() {
741
1401
  }
742
1402
 
743
1403
  const resolvedDir = assertDirectory(parsed.dir);
744
- const { cssFiles, templateFiles } = walkFiles(resolvedDir, parsed.ignorePatterns);
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;
745
1424
  const options = {
746
1425
  baseFontSize: parsed.baseFontSize,
747
1426
  scale: parsed.scale,
@@ -759,21 +1438,50 @@ async function run() {
759
1438
  cssFiles,
760
1439
  cssFindings: collectCssFindings(cssResults),
761
1440
  dir: parsed.dir,
1441
+ scanScope,
762
1442
  tailwindFindings: collectTailwindFindings(templateFiles, options),
763
1443
  templateFiles,
1444
+ tokenCandidateMinCount: parsed.tokenCandidateMinCount,
764
1445
  });
765
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
+
766
1462
  if (parsed.format === 'json') {
767
1463
  process.stdout.write(`${JSON.stringify(report, null, 2)}\n`);
1464
+ finish(auditFailures);
768
1465
  return;
769
1466
  }
770
1467
 
771
1468
  if (parsed.format === 'markdown') {
772
1469
  process.stdout.write(renderMarkdown(report));
1470
+ finish(auditFailures);
773
1471
  return;
774
1472
  }
775
1473
 
776
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;
777
1485
  }
778
1486
 
779
1487
  run();