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 +18 -0
- package/README.md +6 -1
- package/package.json +1 -1
- package/src/cli/audit.js +854 -12
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
|
|
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
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
|
|
164
|
-
|
|
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
|
-
|
|
170
|
-
walk(path.join(currentDir, entry.name));
|
|
171
|
-
}
|
|
416
|
+
walk(fullPath);
|
|
172
417
|
continue;
|
|
173
418
|
}
|
|
174
419
|
|
|
175
420
|
if (entry.isFile()) {
|
|
176
|
-
|
|
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
|
|
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:
|
|
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
|
-
|
|
609
|
-
|
|
610
|
-
|
|
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();
|