stylelint-plugin-rhythmguard 2.0.1 → 2.2.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/src/cli/audit.js CHANGED
@@ -1,2221 +1,24 @@
1
1
  'use strict';
2
2
 
3
- const { execFileSync } = require('node:child_process');
4
3
  const fs = require('node:fs');
5
4
  const path = require('node:path');
5
+ const {
6
+ HELP,
7
+ parseArgs,
8
+ } = require('../audit/args');
9
+ const { getAuditFailures } = require('../audit/baseline');
10
+ const { loadAuditConfig } = require('../audit/config');
11
+ const {
12
+ AUDIT_JSON_SCHEMA,
13
+ toAuditContractReport,
14
+ } = require('../audit/contract');
15
+ const { renderGithub } = require('../audit/render-github');
16
+ const { renderHtml } = require('../audit/render-html');
17
+ const { renderMarkdown } = require('../audit/render-markdown');
18
+ const { renderText } = require('../audit/render-text');
19
+ const { createAuditReport } = require('../audit/report');
6
20
 
7
- const { formatLength } = require('../utils/length');
8
- const { createTailwindClassAnalyzer } = require('../utils/tailwind-class-analysis');
9
- const { createTailwindMotionAnalyzer } = require('../utils/tailwind-motion-analysis');
10
- const { formatTime } = require('../utils/time');
11
- const {
12
- addDefinition,
13
- createTokenKindMatcher,
14
- getNormalizedValueKeys,
15
- normalizeTokenKind,
16
- normalizeTokenSourceFormat,
17
- parseTokenSources,
18
- } = require('../utils/token-sources');
19
-
20
- const args = process.argv.slice(3);
21
- const pluginPath = path.resolve(__dirname, '..', 'index.js');
22
-
23
- const DEFAULT_SCALE = [0, 4, 8, 12, 16, 24, 32];
24
- const DEFAULT_BASE_FONT_SIZE = 16;
25
- const DEFAULT_BASELINE_PATH = '.rhythmguard-baseline.json';
26
- const DEFAULT_CONFIG_PATH = '.rhythmguardrc.json';
27
- const DEFAULT_IGNORE_PATH = '.rhythmguardignore';
28
- const DEFAULT_AUDIT_TOKEN_PATTERN = '^--(space|spacing)-';
29
- const DEFAULT_TOKEN_CANDIDATE_MIN_COUNT = 2;
30
- const VALID_FORMATS = new Set(['text', 'json', 'json-v1', 'markdown', 'html']);
31
- const SKIP_DIRS = new Set([
32
- '.git',
33
- '.next',
34
- '.nuxt',
35
- '.omx',
36
- '.svelte-kit',
37
- '.turbo',
38
- '.vercel',
39
- 'build',
40
- 'coverage',
41
- 'dist',
42
- 'node_modules',
43
- 'out',
44
- ]);
45
- const TEMPLATE_EXTENSIONS = new Set([
46
- '.astro',
47
- '.cjs',
48
- '.js',
49
- '.jsx',
50
- '.mjs',
51
- '.svelte',
52
- '.ts',
53
- '.tsx',
54
- '.vue',
55
- ]);
56
-
57
- const HELP = `Usage: rhythmguard audit <dir> [options]
58
-
59
- Options:
60
- --format <text|json|json-v1|markdown|html> Output format (default: text)
61
- --json Alias for --format json
62
- --markdown Alias for --format markdown
63
- --schema Print the audit JSON schema and exit
64
- --output <file> Write json, markdown, or html output to a file
65
- --config <file> Load audit config (default: .rhythmguardrc.json when present)
66
- --no-config Ignore .rhythmguardrc.json discovery
67
- --ignore <pattern> Exclude root-relative path/glob (repeatable, comma-separated)
68
- --ignore-path <file> Load ignore patterns from file (default: .rhythmguardignore when present)
69
- --baseline <file> Baseline file path (default: .rhythmguard-baseline.json)
70
- --write-baseline [file] Write current findings as a baseline
71
- --since-baseline [file] Compare current findings against a baseline
72
- --fail-on-new-drift Exit 1 when --since-baseline finds new drift
73
- --max-findings <number> Exit 1 when total findings exceed this count
74
- --min-cleanliness <percent> Exit 1 when scale cleanliness is lower than this percent
75
- --since <git-ref> Scan only changed files since a git ref
76
- --staged Scan only staged files
77
- --include-motion Include opt-in motion duration/easing drift
78
- --token-source <file> External token source (repeatable, comma-separated)
79
- --token-source-format <format> Token source format: auto, css, flat-json, style-dictionary, dtcg (default: auto)
80
- --token-kind <kind> Token kind: spacing, radius, typography, size, motion, all (default: spacing)
81
- --token-candidate-min-count <n> Minimum repeated raw value count for token candidates (default: 2)
82
- --scale <values> Comma-separated scale values (default: 0,4,8,12,16,24,32)
83
- --base-font-size <number> px base for rem/em conversion (default: 16)
84
- `;
85
-
86
- function parseArgs(argv) {
87
- const parsed = createDefaultAuditOptions();
88
-
89
- for (let index = 0; index < argv.length; index++) {
90
- const arg = argv[index];
91
-
92
- if (arg === '--help' || arg === '-h') {
93
- parsed.help = true;
94
- continue;
95
- }
96
-
97
- if (arg === '--schema') {
98
- parsed.schema = true;
99
- parsed.format = 'json';
100
- continue;
101
- }
102
-
103
- if (arg === '--json') {
104
- parsed.format = 'json';
105
- continue;
106
- }
107
-
108
- if (arg === '--markdown') {
109
- parsed.format = 'markdown';
110
- continue;
111
- }
112
-
113
- if (arg === '--output') {
114
- parsed.outputPath = parsePathOption(argv[++index], '--output');
115
- parsed.cliOptions.add('outputPath');
116
- continue;
117
- }
118
-
119
- if (arg.startsWith('--output=')) {
120
- parsed.outputPath = parsePathOption(arg.slice('--output='.length), '--output');
121
- parsed.cliOptions.add('outputPath');
122
- continue;
123
- }
124
-
125
- if (arg === '--ignore') {
126
- parsed.ignorePatterns.push(...parseIgnorePatterns(argv[++index]));
127
- parsed.cliOptions.add('ignore');
128
- continue;
129
- }
130
-
131
- if (arg.startsWith('--ignore=')) {
132
- parsed.ignorePatterns.push(...parseIgnorePatterns(arg.slice('--ignore='.length)));
133
- parsed.cliOptions.add('ignore');
134
- continue;
135
- }
136
-
137
- if (arg === '--config') {
138
- parsed.configPath = parsePathOption(argv[++index], '--config');
139
- parsed.configExplicit = true;
140
- continue;
141
- }
142
-
143
- if (arg.startsWith('--config=')) {
144
- parsed.configPath = parsePathOption(arg.slice('--config='.length), '--config');
145
- parsed.configExplicit = true;
146
- continue;
147
- }
148
-
149
- if (arg === '--no-config') {
150
- parsed.noConfig = true;
151
- continue;
152
- }
153
-
154
- if (arg === '--ignore-path') {
155
- parsed.ignorePath = parsePathOption(argv[++index], '--ignore-path');
156
- parsed.cliOptions.add('ignorePath');
157
- continue;
158
- }
159
-
160
- if (arg.startsWith('--ignore-path=')) {
161
- parsed.ignorePath = parsePathOption(arg.slice('--ignore-path='.length), '--ignore-path');
162
- parsed.cliOptions.add('ignorePath');
163
- continue;
164
- }
165
-
166
- if (arg === '--baseline') {
167
- parsed.baselinePath = parsePathOption(argv[++index], '--baseline');
168
- parsed.cliOptions.add('baselinePath');
169
- continue;
170
- }
171
-
172
- if (arg.startsWith('--baseline=')) {
173
- parsed.baselinePath = parsePathOption(arg.slice('--baseline='.length), '--baseline');
174
- parsed.cliOptions.add('baselinePath');
175
- continue;
176
- }
177
-
178
- if (arg === '--write-baseline') {
179
- parsed.writeBaseline = true;
180
- parsed.cliOptions.add('writeBaseline');
181
- if (argv[index + 1] && !argv[index + 1].startsWith('-')) {
182
- parsed.baselinePath = parsePathOption(argv[++index], '--write-baseline');
183
- parsed.cliOptions.add('baselinePath');
184
- }
185
- continue;
186
- }
187
-
188
- if (arg.startsWith('--write-baseline=')) {
189
- parsed.writeBaseline = true;
190
- parsed.baselinePath = parsePathOption(arg.slice('--write-baseline='.length), '--write-baseline');
191
- parsed.cliOptions.add('writeBaseline');
192
- parsed.cliOptions.add('baselinePath');
193
- continue;
194
- }
195
-
196
- if (arg === '--since-baseline') {
197
- parsed.sinceBaseline = true;
198
- parsed.cliOptions.add('sinceBaseline');
199
- if (argv[index + 1] && !argv[index + 1].startsWith('-')) {
200
- parsed.baselinePath = parsePathOption(argv[++index], '--since-baseline');
201
- parsed.cliOptions.add('baselinePath');
202
- }
203
- continue;
204
- }
205
-
206
- if (arg.startsWith('--since-baseline=')) {
207
- parsed.sinceBaseline = true;
208
- parsed.baselinePath = parsePathOption(arg.slice('--since-baseline='.length), '--since-baseline');
209
- parsed.cliOptions.add('sinceBaseline');
210
- parsed.cliOptions.add('baselinePath');
211
- continue;
212
- }
213
-
214
- if (arg === '--fail-on-new-drift') {
215
- parsed.failOnNewDrift = true;
216
- parsed.cliOptions.add('failOnNewDrift');
217
- continue;
218
- }
219
-
220
- if (arg === '--max-findings') {
221
- parsed.maxFindings = parseNonNegativeInteger(argv[++index], '--max-findings');
222
- parsed.cliOptions.add('maxFindings');
223
- continue;
224
- }
225
-
226
- if (arg.startsWith('--max-findings=')) {
227
- parsed.maxFindings = parseNonNegativeInteger(arg.slice('--max-findings='.length), '--max-findings');
228
- parsed.cliOptions.add('maxFindings');
229
- continue;
230
- }
231
-
232
- if (arg === '--min-cleanliness') {
233
- parsed.minCleanliness = parsePercentage(argv[++index], '--min-cleanliness');
234
- parsed.cliOptions.add('minCleanliness');
235
- continue;
236
- }
237
-
238
- if (arg.startsWith('--min-cleanliness=')) {
239
- parsed.minCleanliness = parsePercentage(
240
- arg.slice('--min-cleanliness='.length),
241
- '--min-cleanliness',
242
- );
243
- parsed.cliOptions.add('minCleanliness');
244
- continue;
245
- }
246
-
247
- if (arg === '--since') {
248
- parsed.since = parsePathOption(argv[++index], '--since');
249
- parsed.cliOptions.add('since');
250
- continue;
251
- }
252
-
253
- if (arg.startsWith('--since=')) {
254
- parsed.since = parsePathOption(arg.slice('--since='.length), '--since');
255
- parsed.cliOptions.add('since');
256
- continue;
257
- }
258
-
259
- if (arg === '--staged') {
260
- parsed.staged = true;
261
- parsed.cliOptions.add('staged');
262
- continue;
263
- }
264
-
265
- if (arg === '--include-motion') {
266
- parsed.includeMotion = true;
267
- parsed.cliOptions.add('includeMotion');
268
- continue;
269
- }
270
-
271
- if (arg === '--token-source') {
272
- parsed.tokenSources.push(...parseTokenSourcePaths(argv[++index]));
273
- parsed.cliOptions.add('tokenSources');
274
- continue;
275
- }
276
-
277
- if (arg.startsWith('--token-source=')) {
278
- parsed.tokenSources.push(...parseTokenSourcePaths(arg.slice('--token-source='.length)));
279
- parsed.cliOptions.add('tokenSources');
280
- continue;
281
- }
282
-
283
- if (arg === '--token-source-format') {
284
- parsed.tokenSourceFormat = normalizeTokenSourceFormat(argv[++index]);
285
- parsed.cliOptions.add('tokenSourceFormat');
286
- continue;
287
- }
288
-
289
- if (arg.startsWith('--token-source-format=')) {
290
- parsed.tokenSourceFormat = normalizeTokenSourceFormat(arg.slice('--token-source-format='.length));
291
- parsed.cliOptions.add('tokenSourceFormat');
292
- continue;
293
- }
294
-
295
- if (arg === '--token-kind') {
296
- parsed.tokenKind = normalizeTokenKind(argv[++index]);
297
- parsed.cliOptions.add('tokenKind');
298
- continue;
299
- }
300
-
301
- if (arg.startsWith('--token-kind=')) {
302
- parsed.tokenKind = normalizeTokenKind(arg.slice('--token-kind='.length));
303
- parsed.cliOptions.add('tokenKind');
304
- continue;
305
- }
306
-
307
- if (arg === '--token-candidate-min-count') {
308
- parsed.tokenCandidateMinCount = parsePositiveInteger(argv[++index], '--token-candidate-min-count');
309
- parsed.cliOptions.add('tokenCandidateMinCount');
310
- continue;
311
- }
312
-
313
- if (arg.startsWith('--token-candidate-min-count=')) {
314
- parsed.tokenCandidateMinCount = parsePositiveInteger(
315
- arg.slice('--token-candidate-min-count='.length),
316
- '--token-candidate-min-count',
317
- );
318
- parsed.cliOptions.add('tokenCandidateMinCount');
319
- continue;
320
- }
321
-
322
- if (arg === '--format') {
323
- parsed.format = String(argv[++index] || '').toLowerCase();
324
- continue;
325
- }
326
-
327
- if (arg.startsWith('--format=')) {
328
- parsed.format = arg.slice('--format='.length).toLowerCase();
329
- continue;
330
- }
331
-
332
- if (arg === '--scale') {
333
- parsed.scale = parseScale(argv[++index]);
334
- parsed.cliOptions.add('scale');
335
- continue;
336
- }
337
-
338
- if (arg.startsWith('--scale=')) {
339
- parsed.scale = parseScale(arg.slice('--scale='.length));
340
- parsed.cliOptions.add('scale');
341
- continue;
342
- }
343
-
344
- if (arg === '--base-font-size') {
345
- parsed.baseFontSize = parseBaseFontSize(argv[++index]);
346
- parsed.cliOptions.add('baseFontSize');
347
- continue;
348
- }
349
-
350
- if (arg.startsWith('--base-font-size=')) {
351
- parsed.baseFontSize = parseBaseFontSize(arg.slice('--base-font-size='.length));
352
- parsed.cliOptions.add('baseFontSize');
353
- continue;
354
- }
355
-
356
- if (!arg.startsWith('-') && !parsed.dir) {
357
- parsed.dir = arg;
358
- continue;
359
- }
360
-
361
- throw new Error(`Unknown option: ${arg}`);
362
- }
363
-
364
- if (!VALID_FORMATS.has(parsed.format)) {
365
- throw new Error(`Invalid format "${parsed.format}". Expected text, json, json-v1, markdown, or html.`);
366
- }
367
-
368
- if (parsed.since && parsed.staged) {
369
- throw new Error('Use either --since or --staged, not both.');
370
- }
371
-
372
- if (parsed.failOnNewDrift && !parsed.sinceBaseline) {
373
- throw new Error('--fail-on-new-drift requires --since-baseline.');
374
- }
375
-
376
- return parsed;
377
- }
378
-
379
- function createDefaultAuditOptions() {
380
- return {
381
- baselinePath: DEFAULT_BASELINE_PATH,
382
- baseFontSize: DEFAULT_BASE_FONT_SIZE,
383
- cliOptions: new Set(),
384
- configExplicit: false,
385
- configPath: DEFAULT_CONFIG_PATH,
386
- dir: null,
387
- failOnNewDrift: false,
388
- format: 'text',
389
- ignorePath: DEFAULT_IGNORE_PATH,
390
- ignorePatterns: [],
391
- includeMotion: false,
392
- maxFindings: null,
393
- minCleanliness: null,
394
- noConfig: false,
395
- outputPath: null,
396
- scale: DEFAULT_SCALE,
397
- schema: false,
398
- since: null,
399
- sinceBaseline: false,
400
- staged: false,
401
- tokenCandidateMinCount: DEFAULT_TOKEN_CANDIDATE_MIN_COUNT,
402
- tokenKind: 'spacing',
403
- tokenSourceFormat: 'auto',
404
- tokenSources: [],
405
- writeBaseline: false,
406
- };
407
- }
408
-
409
- function parsePathOption(raw, optionName) {
410
- if (!raw) {
411
- throw new Error(`Missing value for ${optionName}.`);
412
- }
413
-
414
- return String(raw);
415
- }
416
-
417
- function parseNonNegativeInteger(raw, optionName) {
418
- const value = Number(raw);
419
- if (!Number.isInteger(value) || value < 0) {
420
- throw new Error(`${optionName} must be a non-negative integer.`);
421
- }
422
-
423
- return value;
424
- }
425
-
426
- function parsePositiveInteger(raw, optionName) {
427
- const value = Number(raw);
428
- if (!Number.isInteger(value) || value <= 0) {
429
- throw new Error(`${optionName} must be a positive integer.`);
430
- }
431
-
432
- return value;
433
- }
434
-
435
- function parsePercentage(raw, optionName) {
436
- const value = Number(raw);
437
- if (!Number.isFinite(value) || value < 0 || value > 100) {
438
- throw new Error(`${optionName} must be a number between 0 and 100.`);
439
- }
440
-
441
- return value;
442
- }
443
-
444
- function parseIgnorePatterns(raw) {
445
- if (!raw) {
446
- throw new Error('Missing value for --ignore.');
447
- }
448
-
449
- const patterns = String(raw).split(',')
450
- .map((pattern) => normalizeIgnorePattern(pattern))
451
- .filter(Boolean);
452
-
453
- if (patterns.length === 0) {
454
- throw new Error('--ignore must include at least one pattern.');
455
- }
456
-
457
- return patterns;
458
- }
459
-
460
- function parseTokenSourcePaths(raw) {
461
- if (!raw) {
462
- throw new Error('Missing value for --token-source.');
463
- }
464
-
465
- const sources = String(raw).split(',')
466
- .map((sourcePath) => sourcePath.trim())
467
- .filter(Boolean);
468
-
469
- if (sources.length === 0) {
470
- throw new Error('--token-source must include at least one file.');
471
- }
472
-
473
- return sources;
474
- }
475
-
476
- function normalizeIgnorePattern(pattern) {
477
- return String(pattern)
478
- .trim()
479
- .replace(/\\/g, '/')
480
- .replace(/^\/+/, '')
481
- .replace(/^\.\//, '')
482
- .replace(/\/+$/, '');
483
- }
484
-
485
- function parseScale(raw) {
486
- if (!raw) {
487
- throw new Error('Missing value for --scale.');
488
- }
489
-
490
- const scale = raw.split(',')
491
- .map((part) => part.trim())
492
- .filter(Boolean)
493
- .map((part) => {
494
- const numeric = Number(part);
495
- return Number.isFinite(numeric) && !/[a-z%]/i.test(part) ? numeric : part;
496
- });
497
-
498
- if (scale.length === 0) {
499
- throw new Error('Scale must include at least one value.');
500
- }
501
-
502
- return scale;
503
- }
504
-
505
- function parseBaseFontSize(raw) {
506
- const value = Number(raw);
507
- if (!Number.isFinite(value) || value <= 0) {
508
- throw new Error('--base-font-size must be a positive number.');
509
- }
510
-
511
- return value;
512
- }
513
-
514
- function assertDirectory(dir) {
515
- if (!dir) {
516
- throw new Error('Missing audit directory.');
517
- }
518
-
519
- const resolvedDir = path.resolve(dir);
520
- if (!fs.existsSync(resolvedDir)) {
521
- throw new Error(`Directory not found: ${dir}`);
522
- }
523
-
524
- if (!fs.statSync(resolvedDir).isDirectory()) {
525
- throw new Error(`Not a directory: ${dir}`);
526
- }
527
-
528
- return resolvedDir;
529
- }
530
-
531
- function loadAuditConfig(parsed) {
532
- parsed = {
533
- ...createDefaultAuditOptions(),
534
- ...parsed,
535
- };
536
-
537
- if (parsed.noConfig) {
538
- return null;
539
- }
540
-
541
- const resolvedPath = path.resolve(process.cwd(), parsed.configPath);
542
- if (!fs.existsSync(resolvedPath)) {
543
- if (parsed.configExplicit) {
544
- throw new Error(`Config file not found: ${parsed.configPath}`);
545
- }
546
- return null;
547
- }
548
-
549
- let config;
550
- try {
551
- config = JSON.parse(fs.readFileSync(resolvedPath, 'utf8'));
552
- } catch (err) {
553
- throw new Error(`Invalid Rhythmguard config ${parsed.configPath}: ${err.message}`);
554
- }
555
-
556
- if (!config || typeof config !== 'object' || Array.isArray(config)) {
557
- throw new Error(`Invalid Rhythmguard config ${parsed.configPath}: expected an object.`);
558
- }
559
-
560
- const audit = config.audit || {};
561
- if (!audit || typeof audit !== 'object' || Array.isArray(audit)) {
562
- throw new Error(`Invalid Rhythmguard config ${parsed.configPath}: "audit" must be an object.`);
563
- }
564
-
565
- return {
566
- audit,
567
- file: formatPath(resolvedPath),
568
- rootDir: path.dirname(resolvedPath),
569
- };
570
- }
571
-
572
- function applyAuditConfig(parsed, configResult) {
573
- const cliOptions = parsed.cliOptions;
574
- const next = {
575
- ...parsed,
576
- config: configResult
577
- ? {
578
- file: configResult.file,
579
- }
580
- : null,
581
- };
582
-
583
- if (!configResult) {
584
- next.tokenSources = normalizeCliTokenSources(parsed.tokenSources, parsed.tokenSourceFormat);
585
- delete next.cliOptions;
586
- return next;
587
- }
588
-
589
- const audit = configResult.audit;
590
-
591
- if (Array.isArray(audit.ignore)) {
592
- next.ignorePatterns = [
593
- ...audit.ignore.map((pattern) => normalizeIgnorePattern(pattern)).filter(Boolean),
594
- ...parsed.ignorePatterns,
595
- ];
596
- }
597
-
598
- const configuredTokenSources = normalizeConfigTokenSources(
599
- audit.tokenSources,
600
- configResult.rootDir,
601
- );
602
- next.tokenSources = [
603
- ...configuredTokenSources,
604
- ...normalizeCliTokenSources(parsed.tokenSources, parsed.tokenSourceFormat),
605
- ];
606
-
607
- applyConfigScalar(next, audit, cliOptions, 'ignorePath', 'ignorePath', parsePathOption);
608
- applyConfigScalar(next, audit, cliOptions, 'baselinePath', 'baseline', parsePathOption);
609
- applyConfigScalar(next, audit, cliOptions, 'maxFindings', 'maxFindings', parseNonNegativeInteger);
610
- applyConfigScalar(next, audit, cliOptions, 'minCleanliness', 'minCleanliness', parsePercentage);
611
- applyConfigScalar(next, audit, cliOptions, 'tokenCandidateMinCount', 'tokenCandidateMinCount', parsePositiveInteger);
612
- applyConfigScalar(next, audit, cliOptions, 'tokenKind', 'tokenKind', normalizeTokenKind);
613
- applyConfigScalar(next, audit, cliOptions, 'baseFontSize', 'baseFontSize', parseBaseFontSize);
614
- applyConfigScalar(next, audit, cliOptions, 'includeMotion', 'includeMotion', parseBooleanOption);
615
- applyConfigScalar(next, audit, cliOptions, 'scale', 'scale', (value) => {
616
- if (Array.isArray(value)) {
617
- return value;
618
- }
619
- return parseScale(String(value));
620
- });
621
-
622
- delete next.cliOptions;
623
- return next;
624
- }
625
-
626
- function applyConfigScalar(target, audit, cliOptions, targetKey, configKey, parser) {
627
- if (cliOptions.has(targetKey) || audit[configKey] === undefined) {
628
- return;
629
- }
630
-
631
- target[targetKey] = parser(audit[configKey], configKey);
632
- }
633
-
634
- function parseBooleanOption(value, optionName) {
635
- if (typeof value !== 'boolean') {
636
- throw new Error(`${optionName} must be a boolean.`);
637
- }
638
-
639
- return value;
640
- }
641
-
642
- function normalizeCliTokenSources(sources, format) {
643
- return sources.map((sourcePath) => ({
644
- baseDir: process.cwd(),
645
- format,
646
- path: sourcePath,
647
- }));
648
- }
649
-
650
- function normalizeConfigTokenSources(sources, baseDir) {
651
- if (sources === undefined) {
652
- return [];
653
- }
654
-
655
- if (!Array.isArray(sources)) {
656
- throw new Error('Invalid Rhythmguard config: audit.tokenSources must be an array.');
657
- }
658
-
659
- return sources.map((source) => {
660
- if (typeof source === 'string') {
661
- return {
662
- baseDir,
663
- format: 'auto',
664
- path: source,
665
- };
666
- }
667
-
668
- if (!source || typeof source !== 'object' || Array.isArray(source)) {
669
- throw new Error('Invalid Rhythmguard config: audit.tokenSources entries must be strings or objects.');
670
- }
671
-
672
- if (typeof source.path !== 'string' || source.path.trim().length === 0) {
673
- throw new Error('Invalid Rhythmguard config: token source objects must include a path.');
674
- }
675
-
676
- return {
677
- baseDir,
678
- format: normalizeTokenSourceFormat(source.format || 'auto'),
679
- path: source.path,
680
- };
681
- });
682
- }
683
-
684
- function loadIgnorePatterns(ignorePath) {
685
- if (!ignorePath) {
686
- return [];
687
- }
688
-
689
- const resolvedPath = path.resolve(process.cwd(), ignorePath);
690
- if (!fs.existsSync(resolvedPath)) {
691
- if (ignorePath === DEFAULT_IGNORE_PATH) {
692
- return [];
693
- }
694
- throw new Error(`Ignore file not found: ${ignorePath}`);
695
- }
696
-
697
- return fs.readFileSync(resolvedPath, 'utf8')
698
- .split(/\r?\n/)
699
- .map((line) => line.trim())
700
- .filter((line) => line && !line.startsWith('#'))
701
- .map((line) => normalizeIgnorePattern(line))
702
- .filter(Boolean);
703
- }
704
-
705
- function walkFiles(rootDir, ignorePatterns = []) {
706
- const cssFiles = [];
707
- const templateFiles = [];
708
- const ignoreMatchers = createIgnoreMatchers(ignorePatterns);
709
-
710
- function walk(currentDir) {
711
- for (const entry of fs.readdirSync(currentDir, { withFileTypes: true })) {
712
- const fullPath = path.join(currentDir, entry.name);
713
- const relativePath = toPosixRelativePath(rootDir, fullPath);
714
-
715
- if (shouldIgnorePath(relativePath, entry, ignoreMatchers)) {
716
- continue;
717
- }
718
-
719
- if (entry.isDirectory()) {
720
- walk(fullPath);
721
- continue;
722
- }
723
-
724
- if (entry.isFile()) {
725
- if (isCssFile(fullPath)) {
726
- cssFiles.push(fullPath);
727
- } else if (isTemplateFile(fullPath)) {
728
- templateFiles.push(fullPath);
729
- }
730
- }
731
- }
732
- }
733
-
734
- walk(rootDir);
735
- return { cssFiles, templateFiles };
736
- }
737
-
738
- function getScanFiles(rootDir, ignorePatterns, parsed) {
739
- if (parsed.staged || parsed.since) {
740
- return getGitChangedScanFiles(rootDir, ignorePatterns, parsed);
741
- }
742
-
743
- return {
744
- ...walkFiles(rootDir, ignorePatterns),
745
- scanScope: {
746
- mode: 'full',
747
- },
748
- };
749
- }
750
-
751
- function getGitChangedScanFiles(rootDir, ignorePatterns, parsed) {
752
- const args = parsed.staged
753
- ? ['diff', '--name-only', '--cached', '--diff-filter=ACMR', '--']
754
- : ['diff', '--name-only', '--diff-filter=ACMR', parsed.since, '--'];
755
- let output = '';
756
-
757
- try {
758
- output = execFileSync('git', args, {
759
- cwd: process.cwd(),
760
- encoding: 'utf8',
761
- stdio: ['ignore', 'pipe', 'pipe'],
762
- });
763
- } catch (err) {
764
- const stderr = err.stderr ? String(err.stderr).trim() : err.message;
765
- throw new Error(`Unable to read changed files from git: ${stderr}`);
766
- }
767
-
768
- const cssFiles = [];
769
- const templateFiles = [];
770
- const ignoreMatchers = createIgnoreMatchers(ignorePatterns);
771
- const seen = new Set();
772
- const changedFiles = output.split(/\r?\n/)
773
- .map((filePath) => filePath.trim())
774
- .filter(Boolean);
775
-
776
- for (const filePath of changedFiles) {
777
- const fullPath = path.resolve(process.cwd(), filePath);
778
-
779
- if (!isPathInside(rootDir, fullPath) || seen.has(fullPath) || !fs.existsSync(fullPath)) {
780
- continue;
781
- }
782
-
783
- const stat = fs.statSync(fullPath);
784
- if (!stat.isFile()) {
785
- continue;
786
- }
787
-
788
- const relativePath = toPosixRelativePath(rootDir, fullPath);
789
- if (shouldIgnoreRelativeFile(relativePath, ignoreMatchers)) {
790
- continue;
791
- }
792
-
793
- seen.add(fullPath);
794
- if (isCssFile(fullPath)) {
795
- cssFiles.push(fullPath);
796
- } else if (isTemplateFile(fullPath)) {
797
- templateFiles.push(fullPath);
798
- }
799
- }
800
-
801
- return {
802
- cssFiles,
803
- scanScope: {
804
- changedFiles: changedFiles.length,
805
- mode: parsed.staged ? 'staged' : 'since',
806
- since: parsed.since,
807
- },
808
- templateFiles,
809
- };
810
- }
811
-
812
- function isPathInside(rootDir, filePath) {
813
- const relativePath = path.relative(rootDir, filePath);
814
- return relativePath === '' || (!relativePath.startsWith('..') && !path.isAbsolute(relativePath));
815
- }
816
-
817
- function shouldIgnorePath(relativePath, entry, ignoreMatchers) {
818
- return ignoreMatchers.some((matcher) => matcher.test(relativePath))
819
- || (entry.isDirectory() && SKIP_DIRS.has(entry.name));
820
- }
821
-
822
- function shouldIgnoreRelativeFile(relativePath, ignoreMatchers) {
823
- const segments = relativePath.split('/');
824
- return segments.some((segment) => SKIP_DIRS.has(segment))
825
- || ignoreMatchers.some((matcher) => matcher.test(relativePath));
826
- }
827
-
828
- function createIgnoreMatchers(patterns) {
829
- const variants = new Set();
830
-
831
- for (const pattern of patterns) {
832
- addIgnorePatternVariants(variants, pattern);
833
- }
834
-
835
- return Array.from(variants, (pattern) => globToRegExp(pattern));
836
- }
837
-
838
- function addIgnorePatternVariants(variants, pattern) {
839
- if (!pattern) {
840
- return;
841
- }
842
-
843
- variants.add(pattern);
844
-
845
- if (!pattern.includes('/')) {
846
- variants.add(`${pattern}/**`);
847
- variants.add(`**/${pattern}`);
848
- variants.add(`**/${pattern}/**`);
849
- return;
850
- }
851
-
852
- if (pattern.endsWith('/**')) {
853
- variants.add(pattern.slice(0, -3));
854
- return;
855
- }
856
-
857
- if (!hasGlob(pattern)) {
858
- variants.add(`${pattern}/**`);
859
- }
860
- }
861
-
862
- function hasGlob(pattern) {
863
- return /[*?]/.test(pattern);
864
- }
865
-
866
- function globToRegExp(pattern) {
867
- let source = '^';
868
-
869
- for (let index = 0; index < pattern.length; index++) {
870
- const char = pattern[index];
871
- const nextChar = pattern[index + 1];
872
-
873
- if (char === '*' && nextChar === '*') {
874
- source += '.*';
875
- index++;
876
- continue;
877
- }
878
-
879
- if (char === '*') {
880
- source += '[^/]*';
881
- continue;
882
- }
883
-
884
- if (char === '?') {
885
- source += '[^/]';
886
- continue;
887
- }
888
-
889
- source += escapeRegExp(char);
890
- }
891
-
892
- return new RegExp(`${source}$`);
893
- }
894
-
895
- function escapeRegExp(value) {
896
- return value.replace(/[|\\{}()[\]^$+*?.]/g, '\\$&');
897
- }
898
-
899
- function toPosixRelativePath(rootDir, filePath) {
900
- return path.relative(rootDir, filePath).split(path.sep).join('/');
901
- }
902
-
903
- function isCssFile(filePath) {
904
- return filePath.endsWith('.css');
905
- }
906
-
907
- function isTemplateFile(filePath) {
908
- return TEMPLATE_EXTENSIONS.has(path.extname(filePath));
909
- }
910
-
911
- async function runStylelintAudit(cssFiles, options) {
912
- if (cssFiles.length === 0) {
913
- return [];
914
- }
915
-
916
- const { default: stylelint } = await import('stylelint');
917
- const rules = {
918
- 'rhythmguard/use-scale': [
919
- true,
920
- {
921
- baseFontSize: options.baseFontSize,
922
- scale: options.scale,
923
- severity: 'warning',
924
- },
925
- ],
926
- 'rhythmguard/prefer-token': [
927
- true,
928
- {
929
- baseFontSize: options.baseFontSize,
930
- scale: options.scale,
931
- severity: 'warning',
932
- tokenMapFromCssCustomProperties: true,
933
- tokenPattern: DEFAULT_AUDIT_TOKEN_PATTERN,
934
- },
935
- ],
936
- };
937
-
938
- if (options.includeMotion) {
939
- rules['rhythmguard/use-motion-scale'] = [
940
- true,
941
- {
942
- severity: 'warning',
943
- },
944
- ];
945
- }
946
-
947
- const result = await stylelint.lint({
948
- files: cssFiles,
949
- config: {
950
- plugins: [pluginPath],
951
- rules,
952
- },
953
- });
954
-
955
- return result.results || [];
956
- }
957
-
958
- function collectCssFindings(fileResults) {
959
- const findings = [];
960
-
961
- for (const fileResult of fileResults) {
962
- for (const warning of fileResult.warnings || []) {
963
- const text = warning.text || '';
964
- const offScaleMatch = text.match(
965
- /Unexpected (?:off-scale value|transform translation value) "([^"]+)"/,
966
- );
967
- const tokenMatch = text.match(
968
- /Unexpected raw scale value "([^"]+)"/,
969
- );
970
- const motionDurationMatch = text.match(
971
- /Unexpected (?:motion duration|negative motion duration) "([^"]+)"/,
972
- );
973
- const motionEasingMatch = text.match(
974
- /Unexpected raw motion easing "([^"]+)"/,
975
- );
976
-
977
- findings.push({
978
- column: warning.column || 1,
979
- file: formatPath(fileResult.source),
980
- line: warning.line || 1,
981
- rule: warning.rule || 'rhythmguard',
982
- text,
983
- type: getCssFindingType({ motionDurationMatch, motionEasingMatch, tokenMatch }),
984
- value: getCssFindingValue({
985
- motionDurationMatch,
986
- motionEasingMatch,
987
- offScaleMatch,
988
- tokenMatch,
989
- }),
990
- });
991
- }
992
- }
993
-
994
- return findings;
995
- }
996
-
997
- function getCssFindingValue({
998
- motionDurationMatch,
999
- motionEasingMatch,
1000
- offScaleMatch,
1001
- tokenMatch,
1002
- }) {
1003
- if (tokenMatch) {
1004
- return tokenMatch[1];
1005
- }
1006
-
1007
- if (offScaleMatch) {
1008
- return offScaleMatch[1];
1009
- }
1010
-
1011
- if (motionDurationMatch) {
1012
- return motionDurationMatch[1];
1013
- }
1014
-
1015
- if (motionEasingMatch) {
1016
- return motionEasingMatch[1];
1017
- }
1018
-
1019
- return null;
1020
- }
1021
-
1022
- function getCssFindingType({ motionDurationMatch, motionEasingMatch, tokenMatch }) {
1023
- if (motionDurationMatch) {
1024
- return 'motion-duration';
1025
- }
1026
-
1027
- if (motionEasingMatch) {
1028
- return 'motion-easing';
1029
- }
1030
-
1031
- if (tokenMatch) {
1032
- return 'token-opportunity';
1033
- }
1034
-
1035
- return 'off-scale';
1036
- }
1037
-
1038
- function collectTailwindFindings(templateFiles, options) {
1039
- const analyzer = createTailwindClassAnalyzer(options);
1040
- const findings = [];
1041
-
1042
- for (const filePath of templateFiles) {
1043
- let source = '';
1044
- try {
1045
- source = fs.readFileSync(filePath, 'utf8');
1046
- } catch {
1047
- continue;
1048
- }
1049
-
1050
- const lineStarts = getLineStarts(source);
1051
-
1052
- for (const literal of findStringLiterals(source)) {
1053
- for (const { analysis, segment } of analyzer.analyzeClassString(literal.value)) {
1054
- const position = offsetToLineColumn(lineStarts, literal.valueStart + segment.start);
1055
- findings.push({
1056
- column: position.column,
1057
- file: formatPath(filePath),
1058
- fixedToken: analysis.fixedToken,
1059
- line: position.line,
1060
- nearest: analysis.nearest
1061
- ? {
1062
- lower: formatLength(analysis.nearest.lower, 'px'),
1063
- upper: formatLength(analysis.nearest.upper, 'px'),
1064
- }
1065
- : null,
1066
- rawValue: analysis.rawValue,
1067
- rule: 'rhythmguard-tailwind/tailwind-class-use-scale',
1068
- text: analysis.reason === 'negative'
1069
- ? `Unexpected Tailwind arbitrary spacing value "${segment.token}". Negative values are disabled for this rule.`
1070
- : `Unexpected Tailwind arbitrary spacing value "${segment.token}". Use scale values.`,
1071
- token: segment.token,
1072
- type: 'tailwind-arbitrary-spacing',
1073
- utility: analysis.utility,
1074
- });
1075
- }
1076
- }
1077
- }
1078
-
1079
- return findings;
1080
- }
1081
-
1082
- function collectTailwindMotionFindings(templateFiles, options) {
1083
- if (!options.includeMotion) {
1084
- return [];
1085
- }
1086
-
1087
- const analyzer = createTailwindMotionAnalyzer(options);
1088
- const findings = [];
1089
-
1090
- for (const filePath of templateFiles) {
1091
- let source = '';
1092
- try {
1093
- source = fs.readFileSync(filePath, 'utf8');
1094
- } catch {
1095
- continue;
1096
- }
1097
-
1098
- const lineStarts = getLineStarts(source);
1099
-
1100
- for (const literal of findStringLiterals(source)) {
1101
- for (const { analysis, segment } of analyzer.analyzeClassString(literal.value)) {
1102
- const position = offsetToLineColumn(lineStarts, literal.valueStart + segment.start);
1103
- findings.push({
1104
- column: position.column,
1105
- file: formatPath(filePath),
1106
- fixedToken: analysis.fixedToken,
1107
- line: position.line,
1108
- nearest: analysis.nearest
1109
- ? {
1110
- lower: formatTime(analysis.nearest.lower, 'ms'),
1111
- upper: formatTime(analysis.nearest.upper, 'ms'),
1112
- }
1113
- : null,
1114
- rawValue: analysis.rawValue,
1115
- rule: 'rhythmguard-tailwind/tailwind-class-use-motion-scale',
1116
- text: buildTailwindMotionFindingText(segment.token, analysis),
1117
- token: segment.token,
1118
- type: analysis.reason === 'easing'
1119
- ? 'tailwind-motion-easing'
1120
- : 'tailwind-motion-duration',
1121
- utility: analysis.utility,
1122
- });
1123
- }
1124
- }
1125
- }
1126
-
1127
- return findings;
1128
- }
1129
-
1130
- function buildTailwindMotionFindingText(token, analysis) {
1131
- if (analysis.reason === 'easing') {
1132
- return `Unexpected Tailwind arbitrary motion easing "${token}". Use motion tokens for easing decisions.`;
1133
- }
1134
-
1135
- if (analysis.reason === 'negative') {
1136
- return `Unexpected Tailwind arbitrary motion duration "${token}". Use non-negative duration values.`;
1137
- }
1138
-
1139
- return `Unexpected Tailwind arbitrary motion duration "${token}". Use duration scale values.`;
1140
- }
1141
-
1142
- function findStringLiterals(source) {
1143
- const literals = [];
1144
- const literalPattern = /(["'`])((?:\\[\s\S]|(?!\1)[\s\S])*?)\1/g;
1145
- let match;
1146
-
1147
- while ((match = literalPattern.exec(source)) !== null) {
1148
- literals.push({
1149
- quote: match[1],
1150
- value: match[2],
1151
- valueStart: match.index + 1,
1152
- });
1153
- }
1154
-
1155
- return literals;
1156
- }
1157
-
1158
- function getLineStarts(source) {
1159
- const starts = [0];
1160
-
1161
- for (let index = 0; index < source.length; index++) {
1162
- if (source[index] === '\n') {
1163
- starts.push(index + 1);
1164
- }
1165
- }
1166
-
1167
- return starts;
1168
- }
1169
-
1170
- function offsetToLineColumn(lineStarts, offset) {
1171
- let low = 0;
1172
- let high = lineStarts.length - 1;
1173
-
1174
- while (low <= high) {
1175
- const mid = Math.floor((low + high) / 2);
1176
- if (lineStarts[mid] <= offset) {
1177
- low = mid + 1;
1178
- } else {
1179
- high = mid - 1;
1180
- }
1181
- }
1182
-
1183
- const lineIndex = Math.max(0, high);
1184
- return {
1185
- column: offset - lineStarts[lineIndex] + 1,
1186
- line: lineIndex + 1,
1187
- };
1188
- }
1189
-
1190
- function collectTokenContract({
1191
- baseFontSize,
1192
- cssFiles,
1193
- cssFindings,
1194
- externalTokenDefinitions,
1195
- minCandidateCount,
1196
- tailwindFindings,
1197
- tokenKind,
1198
- tokenSourceReports,
1199
- }) {
1200
- const definitions = new Map();
1201
- const uses = new Map();
1202
- const rawValues = new Map();
1203
- const rawValueLocations = new Set();
1204
- const matchesKind = createTokenKindMatcher(tokenKind);
1205
-
1206
- for (const filePath of cssFiles) {
1207
- let source = '';
1208
- try {
1209
- source = fs.readFileSync(filePath, 'utf8');
1210
- } catch {
1211
- continue;
1212
- }
1213
-
1214
- const file = formatPath(filePath);
1215
- collectTokenDefinitions(source, file, definitions, matchesKind, baseFontSize);
1216
- collectTokenUses(source, file, uses, matchesKind);
1217
- }
1218
-
1219
- for (const entry of externalTokenDefinitions.values()) {
1220
- for (const value of entry.values || []) {
1221
- addDefinition(definitions, {
1222
- baseFontSize,
1223
- file: Array.from(entry.files)[0] || 'external-token-source',
1224
- source: Array.from(entry.sources)[0] || Array.from(entry.files)[0] || 'external-token-source',
1225
- token: entry.token,
1226
- value,
1227
- });
1228
- }
1229
- }
1230
-
1231
- for (const finding of cssFindings) {
1232
- addRawValue(rawValues, rawValueLocations, finding.value, finding);
1233
- }
1234
-
1235
- for (const finding of tailwindFindings) {
1236
- addRawValue(rawValues, rawValueLocations, finding.rawValue, finding);
1237
- }
1238
-
1239
- const definedTokens = mapTokenEntries(definitions);
1240
- const usedTokens = mapTokenEntries(uses);
1241
- const missingTokens = usedTokens.filter(({ token }) => !definitions.has(token));
1242
- const unusedTokens = definedTokens.filter(({ token }) => !uses.has(token));
1243
- const rawValueMatches = collectRawValueMatches(rawValues, definitions, baseFontSize);
1244
- const conflictingTokens = collectConflictingTokens(definitions);
1245
- const rawValueCandidates = Array.from(rawValues.entries())
1246
- .map(([value, entry]) => ({
1247
- count: entry.count,
1248
- files: Array.from(entry.files).sort(),
1249
- value,
1250
- }))
1251
- .filter(({ count }) => count >= minCandidateCount)
1252
- .sort((a, b) => b.count - a.count || a.value.localeCompare(b.value));
1253
-
1254
- return {
1255
- definedTokens,
1256
- conflictingTokens,
1257
- missingTokens,
1258
- rawValueMatches,
1259
- rawValueCandidates,
1260
- sources: tokenSourceReports,
1261
- summary: {
1262
- conflictingTokens: conflictingTokens.length,
1263
- definedTokens: definedTokens.length,
1264
- missingTokens: missingTokens.length,
1265
- rawValueMatches: rawValueMatches.length,
1266
- rawValueCandidates: rawValueCandidates.length,
1267
- tokenSources: tokenSourceReports.length,
1268
- unusedTokens: unusedTokens.length,
1269
- usedTokens: usedTokens.length,
1270
- },
1271
- unusedTokens,
1272
- usedTokens,
1273
- };
1274
- }
1275
-
1276
- function collectTokenDefinitions(source, file, definitions, matchesKind, baseFontSize) {
1277
- const declarationPattern = /(--[\w-]+)\s*:\s*([^;{}]+)/g;
1278
- let match;
1279
-
1280
- while ((match = declarationPattern.exec(source)) !== null) {
1281
- const token = match[1];
1282
- if (!matchesKind(token)) {
1283
- continue;
1284
- }
1285
-
1286
- addDefinition(definitions, {
1287
- baseFontSize,
1288
- file,
1289
- source: file,
1290
- token,
1291
- value: match[2].trim(),
1292
- });
1293
- }
1294
- }
1295
-
1296
- function collectTokenUses(source, file, uses, matchesKind) {
1297
- const varPattern = /var\(\s*(--[\w-]+)/g;
1298
- let match;
1299
-
1300
- while ((match = varPattern.exec(source)) !== null) {
1301
- const token = match[1];
1302
- if (!matchesKind(token)) {
1303
- continue;
1304
- }
1305
-
1306
- const entry = uses.get(token) || {
1307
- files: new Set(),
1308
- token,
1309
- };
1310
- entry.files.add(file);
1311
- uses.set(token, entry);
1312
- }
1313
- }
1314
-
1315
- function addRawValue(rawValues, rawValueLocations, value, finding) {
1316
- if (!value) {
1317
- return;
1318
- }
1319
-
1320
- const locationKey = [
1321
- finding.file || '',
1322
- finding.line || '',
1323
- finding.column || '',
1324
- value,
1325
- ].join('\u001f');
1326
- if (rawValueLocations.has(locationKey)) {
1327
- return;
1328
- }
1329
- rawValueLocations.add(locationKey);
1330
-
1331
- const entry = rawValues.get(value) || {
1332
- count: 0,
1333
- files: new Set(),
1334
- };
1335
- entry.count += 1;
1336
- if (finding.file) {
1337
- entry.files.add(finding.file);
1338
- }
1339
- rawValues.set(value, entry);
1340
- }
1341
-
1342
- function mapTokenEntries(entries) {
1343
- return Array.from(entries.values())
1344
- .map((entry) => ({
1345
- files: Array.from(entry.files).sort(),
1346
- normalizedValues: entry.normalizedValues
1347
- ? Array.from(entry.normalizedValues).sort()
1348
- : undefined,
1349
- sources: entry.sources ? Array.from(entry.sources).sort() : undefined,
1350
- token: entry.token,
1351
- values: entry.values ? Array.from(entry.values).sort() : undefined,
1352
- }))
1353
- .sort((a, b) => a.token.localeCompare(b.token));
1354
- }
1355
-
1356
- function collectRawValueMatches(rawValues, definitions, baseFontSize) {
1357
- const valueToTokens = createValueToTokensMap(definitions);
1358
- const matches = [];
1359
-
1360
- for (const [value, entry] of rawValues.entries()) {
1361
- const tokens = new Set();
1362
- for (const key of getNormalizedValueKeys(value, baseFontSize)) {
1363
- for (const token of valueToTokens.get(key) || []) {
1364
- tokens.add(token);
1365
- }
1366
- }
1367
-
1368
- if (tokens.size === 0) {
1369
- continue;
1370
- }
1371
-
1372
- matches.push({
1373
- count: entry.count,
1374
- files: Array.from(entry.files).sort(),
1375
- tokens: Array.from(tokens).sort(),
1376
- value,
1377
- });
1378
- }
1379
-
1380
- return matches.sort((a, b) => b.count - a.count || a.value.localeCompare(b.value));
1381
- }
1382
-
1383
- function collectConflictingTokens(definitions) {
1384
- const valueToTokens = createValueToTokensMap(definitions);
1385
- const conflicts = [];
1386
-
1387
- for (const [value, tokens] of valueToTokens.entries()) {
1388
- const uniqueTokens = Array.from(tokens).sort();
1389
- if (uniqueTokens.length < 2) {
1390
- continue;
1391
- }
1392
-
1393
- conflicts.push({
1394
- tokens: uniqueTokens,
1395
- value,
1396
- });
1397
- }
1398
-
1399
- return conflicts.sort((a, b) => a.value.localeCompare(b.value));
1400
- }
1401
-
1402
- function createValueToTokensMap(definitions) {
1403
- const valueToTokens = new Map();
1404
-
1405
- for (const entry of definitions.values()) {
1406
- for (const value of entry.normalizedValues || []) {
1407
- const tokens = valueToTokens.get(value) || new Set();
1408
- tokens.add(entry.token);
1409
- valueToTokens.set(value, tokens);
1410
- }
1411
- }
1412
-
1413
- return valueToTokens;
1414
- }
1415
-
1416
- function buildReport({
1417
- baseFontSize,
1418
- config,
1419
- cssFiles,
1420
- cssFindings,
1421
- dir,
1422
- externalTokenDefinitions,
1423
- includeMotion,
1424
- motionFindings,
1425
- scanScope,
1426
- templateFiles,
1427
- tailwindFindings,
1428
- tokenCandidateMinCount,
1429
- tokenKind,
1430
- tokenSourceReports,
1431
- tokenSourceWarnings,
1432
- }) {
1433
- const offScaleValues = countByValue(cssFindings
1434
- .filter((finding) => finding.type === 'off-scale' && finding.value)
1435
- .map((finding) => finding.value));
1436
- const tokenOpportunities = countByValue(cssFindings
1437
- .filter((finding) => finding.type === 'token-opportunity' && finding.value)
1438
- .map((finding) => finding.value));
1439
- const tailwindArbitraryValues = countByValue(tailwindFindings
1440
- .map((finding) => finding.rawValue));
1441
- const motionValues = countByValue(motionFindings
1442
- .map((finding) => finding.value || finding.rawValue));
1443
- const issueFiles = new Set([
1444
- ...cssFindings.map((finding) => finding.file),
1445
- ...motionFindings.map((finding) => finding.file),
1446
- ...tailwindFindings.map((finding) => finding.file),
1447
- ]);
1448
- const topAffectedFiles = sortCountMap(countByValue([
1449
- ...cssFindings.map((finding) => finding.file),
1450
- ...motionFindings.map((finding) => finding.file),
1451
- ...tailwindFindings.map((finding) => finding.file),
1452
- ])).slice(0, 10);
1453
-
1454
- const totalFiles = cssFiles.length + templateFiles.length;
1455
- const totalWarnings = cssFindings.length + motionFindings.length + tailwindFindings.length;
1456
- const filesWithIssues = issueFiles.size;
1457
- const scaleCleanliness = totalFiles > 0
1458
- ? Math.max(0, Math.round(((totalFiles - filesWithIssues) / totalFiles) * 100))
1459
- : 100;
1460
- const tokenContract = collectTokenContract({
1461
- baseFontSize,
1462
- cssFiles,
1463
- cssFindings,
1464
- externalTokenDefinitions,
1465
- minCandidateCount: tokenCandidateMinCount,
1466
- tailwindFindings,
1467
- tokenKind,
1468
- tokenSourceReports,
1469
- });
1470
-
1471
- return {
1472
- config,
1473
- cssFilesScanned: cssFiles.length,
1474
- directory: dir,
1475
- filesWithIssues,
1476
- findings: {
1477
- css: cssFindings,
1478
- motion: motionFindings,
1479
- tailwind: tailwindFindings,
1480
- },
1481
- formatVersion: 5,
1482
- motion: {
1483
- enabled: includeMotion,
1484
- findings: motionFindings.length,
1485
- values: Object.fromEntries(sortCountMap(motionValues).slice(0, 10)),
1486
- },
1487
- offScaleValues: Object.fromEntries(sortCountMap(offScaleValues).slice(0, 10)),
1488
- scaleCleanliness,
1489
- scanScope,
1490
- scanned: {
1491
- cssFiles: cssFiles.length,
1492
- templateFiles: templateFiles.length,
1493
- totalFiles,
1494
- },
1495
- summary: {
1496
- cssWarnings: cssFindings.length,
1497
- filesWithIssues,
1498
- motionFindings: motionFindings.length,
1499
- rawValueMatches: tokenContract.summary.rawValueMatches,
1500
- missingTokens: tokenContract.summary.missingTokens,
1501
- rawValueCandidates: tokenContract.summary.rawValueCandidates,
1502
- scaleCleanliness,
1503
- tailwindArbitrarySpacing: tailwindFindings.length,
1504
- tokenOpportunities: sumCounts(tokenOpportunities),
1505
- tokenSources: tokenContract.summary.tokenSources,
1506
- totalFindings: totalWarnings,
1507
- unusedTokens: tokenContract.summary.unusedTokens,
1508
- },
1509
- tailwindArbitraryValues: Object.fromEntries(sortCountMap(tailwindArbitraryValues).slice(0, 10)),
1510
- templateFilesScanned: templateFiles.length,
1511
- tokenContract,
1512
- tokenSourceWarnings,
1513
- tokenOpportunities: Object.fromEntries(sortCountMap(tokenOpportunities).slice(0, 10)),
1514
- topAffectedFiles: topAffectedFiles.map(([file, count]) => ({ count, file })),
1515
- totalFiles,
1516
- totalWarnings,
1517
- };
1518
- }
1519
-
1520
- function countByValue(values) {
1521
- const counts = {};
1522
-
1523
- for (const value of values) {
1524
- if (value) {
1525
- counts[value] = (counts[value] || 0) + 1;
1526
- }
1527
- }
1528
-
1529
- return counts;
1530
- }
1531
-
1532
- function sortCountMap(counts) {
1533
- return Object.entries(counts).sort((a, b) => b[1] - a[1] || a[0].localeCompare(b[0]));
1534
- }
1535
-
1536
- function sumCounts(counts) {
1537
- return Object.values(counts).reduce((total, count) => total + count, 0);
1538
- }
1539
-
1540
- function applyBaselineComparison(report, baselinePath) {
1541
- const resolvedPath = path.resolve(process.cwd(), baselinePath);
1542
- if (!fs.existsSync(resolvedPath)) {
1543
- throw new Error(`Baseline file not found: ${baselinePath}`);
1544
- }
1545
-
1546
- const baseline = JSON.parse(fs.readFileSync(resolvedPath, 'utf8'));
1547
- const baselineFindings = Array.isArray(baseline.findings) ? baseline.findings : [];
1548
- const baselineKeys = new Set(baselineFindings.map((finding) => finding.key || createFindingKey(finding)));
1549
- const currentFindings = getAllFindings(report);
1550
- const currentKeys = new Set(currentFindings.map(createFindingKey));
1551
- const newFindings = currentFindings.filter((finding) => !baselineKeys.has(createFindingKey(finding)));
1552
- const resolvedFindings = baselineFindings.filter((finding) => !currentKeys.has(finding.key || createFindingKey(finding)));
1553
-
1554
- report.baseline = {
1555
- baselineFindings: baselineFindings.length,
1556
- file: formatPath(resolvedPath),
1557
- newFindings,
1558
- newFindingsCount: newFindings.length,
1559
- resolvedFindingsCount: resolvedFindings.length,
1560
- };
1561
- report.summary.newFindings = newFindings.length;
1562
- report.summary.resolvedFindings = resolvedFindings.length;
1563
- }
1564
-
1565
- function writeBaseline(report, baselinePath) {
1566
- const resolvedPath = path.resolve(process.cwd(), baselinePath);
1567
- fs.mkdirSync(path.dirname(resolvedPath), { recursive: true });
1568
- fs.writeFileSync(
1569
- resolvedPath,
1570
- `${JSON.stringify({
1571
- createdAt: new Date().toISOString(),
1572
- directory: report.directory,
1573
- findings: getAllFindings(report).map(toBaselineFinding),
1574
- formatVersion: 1,
1575
- summary: {
1576
- scaleCleanliness: report.scaleCleanliness,
1577
- totalFindings: report.totalWarnings,
1578
- },
1579
- }, null, 2)}\n`,
1580
- );
1581
-
1582
- report.baselineWritten = {
1583
- file: formatPath(resolvedPath),
1584
- findings: report.totalWarnings,
1585
- };
1586
- }
1587
-
1588
- function getAllFindings(report) {
1589
- return [
1590
- ...report.findings.css,
1591
- ...report.findings.motion,
1592
- ...report.findings.tailwind,
1593
- ];
1594
- }
1595
-
1596
- function toBaselineFinding(finding) {
1597
- return {
1598
- column: finding.column,
1599
- file: finding.file,
1600
- key: createFindingKey(finding),
1601
- line: finding.line,
1602
- rule: finding.rule,
1603
- text: finding.text,
1604
- token: finding.token,
1605
- type: finding.type,
1606
- value: finding.value || finding.rawValue,
1607
- };
1608
- }
1609
-
1610
- function createFindingKey(finding) {
1611
- return [
1612
- finding.rule || '',
1613
- finding.type || '',
1614
- finding.file || '',
1615
- finding.line || '',
1616
- finding.column || '',
1617
- finding.value || finding.rawValue || finding.token || '',
1618
- finding.text || '',
1619
- ].join('\u001f');
1620
- }
1621
-
1622
- function getAuditFailures(report, parsed) {
1623
- const failures = [];
1624
-
1625
- if (parsed.maxFindings !== null && report.totalWarnings > parsed.maxFindings) {
1626
- failures.push(`total findings ${report.totalWarnings} exceeds --max-findings ${parsed.maxFindings}`);
1627
- }
1628
-
1629
- if (parsed.minCleanliness !== null && report.scaleCleanliness < parsed.minCleanliness) {
1630
- failures.push(`scale cleanliness ${report.scaleCleanliness}% is below --min-cleanliness ${parsed.minCleanliness}%`);
1631
- }
1632
-
1633
- if (parsed.failOnNewDrift && report.baseline && report.baseline.newFindingsCount > 0) {
1634
- failures.push(`new drift found: ${report.baseline.newFindingsCount} finding(s) not present in baseline`);
1635
- }
1636
-
1637
- return failures;
1638
- }
1639
-
1640
- function renderText(report) {
1641
- const lines = [
1642
- '',
1643
- ' ┌─ RHYTHMGUARD DESIGN-SYSTEM AUDIT ─────────────────────────┐',
1644
- ` │ ${report.directory.padEnd(58)}│`,
1645
- ' └─────────────────────────────────────────────────────────────┘',
1646
- '',
1647
- ` CSS files scanned ${String(report.cssFilesScanned).padStart(4)}`,
1648
- ` Template files scanned ${String(report.templateFilesScanned).padStart(4)}`,
1649
- ` Files with issues ${String(report.filesWithIssues).padStart(4)}`,
1650
- ` Scale cleanliness ${scoreBar(report.scaleCleanliness)} ${report.scaleCleanliness}%`,
1651
- '',
1652
- ];
1653
-
1654
- appendHistogram(lines, 'CSS OFF-SCALE VALUES', report.offScaleValues);
1655
- appendHistogram(lines, 'CSS TOKEN OPPORTUNITIES', report.tokenOpportunities);
1656
- appendHistogram(lines, 'TAILWIND CLASS-STRING DRIFT', report.tailwindArbitraryValues);
1657
- appendHistogram(lines, 'MOTION RHYTHM DRIFT', report.motion.values);
1658
- appendTokenContractText(lines, report.tokenContract);
1659
- appendBaselineText(lines, report);
1660
-
1661
- if (report.topAffectedFiles.length > 0) {
1662
- lines.push(' ── TOP AFFECTED FILES ──');
1663
- lines.push('');
1664
- const maxCount = Math.max(...report.topAffectedFiles.map(({ count }) => count));
1665
- for (const { file, count } of report.topAffectedFiles) {
1666
- lines.push(` ${truncate(file, 34).padEnd(36)} ${histBar(count, maxCount)} ${count}`);
1667
- }
1668
- lines.push('');
1669
- }
1670
-
1671
- if (report.totalWarnings === 0) {
1672
- lines.push(' ✓ No issues found. Your design-system rhythm is clean.');
1673
- } else {
1674
- lines.push(' → Run "npx stylelint --fix" for CSS declaration fixes.');
1675
- lines.push(' → Use "npx rhythmguard audit ./src --format markdown" for PR/design review.');
1676
- }
1677
- lines.push('');
1678
-
1679
- return `${lines.join('\n')}\n`;
1680
- }
1681
-
1682
- function appendTokenContractText(lines, tokenContract) {
1683
- const {
1684
- conflictingTokens,
1685
- missingTokens,
1686
- rawValueCandidates,
1687
- rawValueMatches,
1688
- sources,
1689
- unusedTokens,
1690
- } = tokenContract;
1691
- if (
1692
- missingTokens.length === 0 &&
1693
- rawValueCandidates.length === 0 &&
1694
- rawValueMatches.length === 0 &&
1695
- unusedTokens.length === 0 &&
1696
- conflictingTokens.length === 0 &&
1697
- sources.length === 0
1698
- ) {
1699
- return;
1700
- }
1701
-
1702
- lines.push(' ── TOKEN CONTRACT ──');
1703
- lines.push('');
1704
-
1705
- if (sources.length > 0) {
1706
- lines.push(` Token sources ${sources.length}`);
1707
- for (const source of sources.slice(0, 5)) {
1708
- const warningSuffix = source.warnings.length > 0 ? ' (warning)' : '';
1709
- lines.push(` ${truncate(source.file, 42)} ${source.tokenCount}${warningSuffix}`);
1710
- }
1711
- }
1712
-
1713
- if (missingTokens.length > 0) {
1714
- lines.push(` Missing tokens ${missingTokens.length}`);
1715
- for (const entry of missingTokens.slice(0, 5)) {
1716
- lines.push(` ${entry.token} (${truncate(entry.files.join(', '), 42)})`);
1717
- }
1718
- }
1719
-
1720
- if (unusedTokens.length > 0) {
1721
- lines.push(` Defined but unused ${unusedTokens.length}`);
1722
- for (const entry of unusedTokens.slice(0, 5)) {
1723
- lines.push(` ${entry.token}`);
1724
- }
1725
- }
1726
-
1727
- if (rawValueCandidates.length > 0) {
1728
- lines.push(` Repeated raw values ${rawValueCandidates.length}`);
1729
- for (const entry of rawValueCandidates.slice(0, 5)) {
1730
- lines.push(` ${entry.value.padEnd(14)} ${entry.count}`);
1731
- }
1732
- }
1733
-
1734
- if (rawValueMatches.length > 0) {
1735
- lines.push(` Raw values matching tokens ${rawValueMatches.length}`);
1736
- for (const entry of rawValueMatches.slice(0, 5)) {
1737
- lines.push(` ${entry.value.padEnd(14)} ${truncate(entry.tokens.join(', '), 34)}`);
1738
- }
1739
- }
1740
-
1741
- if (conflictingTokens.length > 0) {
1742
- lines.push(` Conflicting tokens ${conflictingTokens.length}`);
1743
- for (const entry of conflictingTokens.slice(0, 5)) {
1744
- lines.push(` ${entry.value.padEnd(14)} ${truncate(entry.tokens.join(', '), 34)}`);
1745
- }
1746
- }
1747
-
1748
- lines.push('');
1749
- }
1750
-
1751
- function appendBaselineText(lines, report) {
1752
- if (report.baseline) {
1753
- lines.push(' ── BASELINE COMPARISON ──');
1754
- lines.push('');
1755
- lines.push(` Baseline findings ${String(report.baseline.baselineFindings).padStart(4)}`);
1756
- lines.push(` New findings ${String(report.baseline.newFindingsCount).padStart(4)}`);
1757
- lines.push(` Resolved findings ${String(report.baseline.resolvedFindingsCount).padStart(4)}`);
1758
- lines.push('');
1759
- }
1760
-
1761
- if (report.baselineWritten) {
1762
- lines.push(` Baseline written ${report.baselineWritten.file}`);
1763
- lines.push('');
1764
- }
1765
- }
1766
-
1767
- function appendHistogram(lines, title, counts) {
1768
- const entries = sortCountMap(counts);
1769
- const total = sumCounts(counts);
1770
-
1771
- if (entries.length === 0) {
1772
- return;
1773
- }
1774
-
1775
- lines.push(` ── ${title} ── ${total} total`);
1776
- lines.push('');
1777
- const maxCount = Math.max(...entries.map(([, count]) => count));
1778
- for (const [value, count] of entries) {
1779
- lines.push(` ${value.padEnd(14)} ${histBar(count, maxCount)} ${count}`);
1780
- }
1781
- lines.push('');
1782
- }
1783
-
1784
- function renderMarkdown(report) {
1785
- const lines = [
1786
- '# Rhythmguard Design-System Audit',
1787
- '',
1788
- `Directory: \`${report.directory}\``,
1789
- '',
1790
- '## Summary',
1791
- '',
1792
- '| Metric | Value |',
1793
- '| --- | ---: |',
1794
- `| CSS files scanned | ${report.cssFilesScanned} |`,
1795
- `| Template files scanned | ${report.templateFilesScanned} |`,
1796
- `| Files with issues | ${report.filesWithIssues} |`,
1797
- `| Total findings | ${report.totalWarnings} |`,
1798
- `| Scale cleanliness | ${report.scaleCleanliness}% |`,
1799
- ];
1800
-
1801
- if (report.baseline) {
1802
- lines.push(`| New findings | ${report.baseline.newFindingsCount} |`);
1803
- lines.push(`| Resolved findings | ${report.baseline.resolvedFindingsCount} |`);
1804
- }
1805
- lines.push('');
1806
-
1807
- appendMarkdownCounts(lines, 'CSS Off-Scale Values', report.offScaleValues);
1808
- appendMarkdownCounts(lines, 'CSS Token Opportunities', report.tokenOpportunities);
1809
- appendMarkdownCounts(lines, 'Tailwind Class-String Drift', report.tailwindArbitraryValues);
1810
- appendMarkdownCounts(lines, 'Motion Rhythm Drift', report.motion.values);
1811
- appendTokenContractMarkdown(lines, report.tokenContract);
1812
- appendBaselineMarkdown(lines, report);
1813
-
1814
- if (report.topAffectedFiles.length > 0) {
1815
- lines.push('## Top Affected Files');
1816
- lines.push('');
1817
- lines.push('| File | Findings |');
1818
- lines.push('| --- | ---: |');
1819
- for (const { file, count } of report.topAffectedFiles) {
1820
- lines.push(`| \`${escapeMarkdown(file)}\` | ${count} |`);
1821
- }
1822
- lines.push('');
1823
- }
1824
-
1825
- if (report.findings.tailwind.length > 0) {
1826
- lines.push('## Tailwind Examples');
1827
- lines.push('');
1828
- lines.push('| File | Class | Suggested class |');
1829
- lines.push('| --- | --- | --- |');
1830
- for (const finding of report.findings.tailwind.slice(0, 10)) {
1831
- lines.push(`| \`${escapeMarkdown(`${finding.file}:${finding.line}`)}\` | \`${escapeMarkdown(finding.token)}\` | \`${escapeMarkdown(finding.fixedToken || 'n/a')}\` |`);
1832
- }
1833
- lines.push('');
1834
- }
1835
-
1836
- lines.push('## Recommended Next Actions');
1837
- lines.push('');
1838
- if (report.totalWarnings === 0) {
1839
- lines.push('- Keep Rhythmguard in CI to prevent new drift.');
1840
- } else {
1841
- lines.push('- Run deterministic autofix for CSS declarations where appropriate.');
1842
- lines.push('- Review Tailwind arbitrary spacing values with UX/design-system owners.');
1843
- lines.push('- Convert repeated raw on-scale values into existing design tokens.');
1844
- }
1845
- lines.push('');
1846
-
1847
- return `${lines.join('\n')}\n`;
1848
- }
1849
-
1850
- function appendTokenContractMarkdown(lines, tokenContract) {
1851
- const {
1852
- conflictingTokens,
1853
- missingTokens,
1854
- rawValueCandidates,
1855
- rawValueMatches,
1856
- sources,
1857
- unusedTokens,
1858
- } = tokenContract;
1859
- if (
1860
- missingTokens.length === 0 &&
1861
- rawValueCandidates.length === 0 &&
1862
- rawValueMatches.length === 0 &&
1863
- unusedTokens.length === 0 &&
1864
- conflictingTokens.length === 0 &&
1865
- sources.length === 0
1866
- ) {
1867
- return;
1868
- }
1869
-
1870
- lines.push('## Token Contract');
1871
- lines.push('');
1872
-
1873
- if (sources.length > 0) {
1874
- lines.push('### Token Sources');
1875
- lines.push('');
1876
- lines.push('| File | Format | Tokens | Warnings |');
1877
- lines.push('| --- | --- | ---: | --- |');
1878
- for (const source of sources.slice(0, 10)) {
1879
- lines.push(`| \`${escapeMarkdown(source.file)}\` | \`${source.format}\` | ${source.tokenCount} | \`${escapeMarkdown(source.warnings.join('; ') || 'none')}\` |`);
1880
- }
1881
- lines.push('');
1882
- }
1883
-
1884
- if (missingTokens.length > 0) {
1885
- lines.push('### Tokens Used But Missing');
1886
- lines.push('');
1887
- lines.push('| Token | Files |');
1888
- lines.push('| --- | --- |');
1889
- for (const entry of missingTokens.slice(0, 10)) {
1890
- lines.push(`| \`${escapeMarkdown(entry.token)}\` | \`${escapeMarkdown(entry.files.join(', '))}\` |`);
1891
- }
1892
- lines.push('');
1893
- }
1894
-
1895
- if (unusedTokens.length > 0) {
1896
- lines.push('### Tokens Defined But Unused');
1897
- lines.push('');
1898
- lines.push('| Token | Value |');
1899
- lines.push('| --- | --- |');
1900
- for (const entry of unusedTokens.slice(0, 10)) {
1901
- lines.push(`| \`${escapeMarkdown(entry.token)}\` | \`${escapeMarkdown((entry.values || []).join(', ') || 'n/a')}\` |`);
1902
- }
1903
- lines.push('');
1904
- }
1905
-
1906
- if (rawValueCandidates.length > 0) {
1907
- lines.push('### Repeated Raw Value Candidates');
1908
- lines.push('');
1909
- lines.push('| Value | Count | Files |');
1910
- lines.push('| --- | ---: | --- |');
1911
- for (const entry of rawValueCandidates.slice(0, 10)) {
1912
- lines.push(`| \`${escapeMarkdown(entry.value)}\` | ${entry.count} | \`${escapeMarkdown(entry.files.join(', '))}\` |`);
1913
- }
1914
- lines.push('');
1915
- }
1916
-
1917
- if (rawValueMatches.length > 0) {
1918
- lines.push('### Raw Values Matching Known Tokens');
1919
- lines.push('');
1920
- lines.push('| Value | Count | Tokens |');
1921
- lines.push('| --- | ---: | --- |');
1922
- for (const entry of rawValueMatches.slice(0, 10)) {
1923
- lines.push(`| \`${escapeMarkdown(entry.value)}\` | ${entry.count} | \`${escapeMarkdown(entry.tokens.join(', '))}\` |`);
1924
- }
1925
- lines.push('');
1926
- }
1927
-
1928
- if (conflictingTokens.length > 0) {
1929
- lines.push('### Conflicting Token Values');
1930
- lines.push('');
1931
- lines.push('| Value | Tokens |');
1932
- lines.push('| --- | --- |');
1933
- for (const entry of conflictingTokens.slice(0, 10)) {
1934
- lines.push(`| \`${escapeMarkdown(entry.value)}\` | \`${escapeMarkdown(entry.tokens.join(', '))}\` |`);
1935
- }
1936
- lines.push('');
1937
- }
1938
- }
1939
-
1940
- function appendBaselineMarkdown(lines, report) {
1941
- if (!report.baseline && !report.baselineWritten) {
1942
- return;
1943
- }
1944
-
1945
- lines.push('## Baseline');
1946
- lines.push('');
1947
-
1948
- if (report.baseline) {
1949
- lines.push('| Metric | Value |');
1950
- lines.push('| --- | ---: |');
1951
- lines.push(`| Baseline findings | ${report.baseline.baselineFindings} |`);
1952
- lines.push(`| New findings | ${report.baseline.newFindingsCount} |`);
1953
- lines.push(`| Resolved findings | ${report.baseline.resolvedFindingsCount} |`);
1954
- lines.push('');
1955
- }
1956
-
1957
- if (report.baseline && report.baseline.newFindings.length > 0) {
1958
- lines.push('| New finding | Location |');
1959
- lines.push('| --- | --- |');
1960
- for (const finding of report.baseline.newFindings.slice(0, 10)) {
1961
- lines.push(`| \`${escapeMarkdown(finding.text)}\` | \`${escapeMarkdown(`${finding.file}:${finding.line}`)}\` |`);
1962
- }
1963
- lines.push('');
1964
- }
1965
-
1966
- if (report.baselineWritten) {
1967
- lines.push(`Baseline written: \`${escapeMarkdown(report.baselineWritten.file)}\``);
1968
- lines.push('');
1969
- }
1970
- }
1971
-
1972
- function appendMarkdownCounts(lines, title, counts) {
1973
- const entries = sortCountMap(counts);
1974
-
1975
- if (entries.length === 0) {
1976
- return;
1977
- }
1978
-
1979
- lines.push(`## ${title}`);
1980
- lines.push('');
1981
- lines.push('| Value | Count |');
1982
- lines.push('| --- | ---: |');
1983
- for (const [value, count] of entries) {
1984
- lines.push(`| \`${escapeMarkdown(value)}\` | ${count} |`);
1985
- }
1986
- lines.push('');
1987
- }
1988
-
1989
- function histBar(count, maxCount) {
1990
- const width = 30;
1991
- const filled = Math.round((count / maxCount) * width);
1992
- return '█'.repeat(filled) + '░'.repeat(width - filled);
1993
- }
1994
-
1995
- function scoreBar(pct) {
1996
- const width = 20;
1997
- const filled = Math.round((pct / 100) * width);
1998
- return '█'.repeat(filled) + '░'.repeat(width - filled);
1999
- }
2000
-
2001
- function truncate(value, maxLength) {
2002
- if (value.length <= maxLength) {
2003
- return value;
2004
- }
2005
-
2006
- return `…${value.slice(value.length - maxLength + 1)}`;
2007
- }
2008
-
2009
- function escapeMarkdown(value) {
2010
- return String(value).replace(/\|/g, '\\|').replace(/`/g, '\\`');
2011
- }
2012
-
2013
- function formatPath(filePath) {
2014
- return path.relative(process.cwd(), filePath).replace(/\\/g, '/');
2015
- }
2016
-
2017
- async function createAuditReport(options) {
2018
- const parsed = normalizeCreateAuditOptions(options);
2019
- const resolvedDir = assertDirectory(parsed.dir);
2020
- let ignorePatterns;
2021
- ignorePatterns = [
2022
- ...loadIgnorePatterns(parsed.ignorePath),
2023
- ...parsed.ignorePatterns,
2024
- ];
2025
-
2026
- const scanFiles = getScanFiles(resolvedDir, ignorePatterns, parsed);
2027
-
2028
- const { cssFiles, scanScope, templateFiles } = scanFiles;
2029
- const lintOptions = {
2030
- baseFontSize: parsed.baseFontSize,
2031
- includeMotion: parsed.includeMotion,
2032
- scale: parsed.scale,
2033
- };
2034
-
2035
- const cssResults = await runStylelintAudit(cssFiles, lintOptions);
2036
-
2037
- const tokenSourceResult = parseTokenSources({
2038
- baseFontSize: parsed.baseFontSize,
2039
- sources: parsed.tokenSources,
2040
- tokenKind: parsed.tokenKind,
2041
- });
2042
- const stylelintFindings = collectCssFindings(cssResults);
2043
- const cssFindings = stylelintFindings.filter((finding) => !finding.type.startsWith('motion-'));
2044
- const motionFindings = [
2045
- ...stylelintFindings.filter((finding) => finding.type.startsWith('motion-')),
2046
- ...collectTailwindMotionFindings(templateFiles, lintOptions),
2047
- ];
2048
-
2049
- const report = buildReport({
2050
- baseFontSize: parsed.baseFontSize,
2051
- config: parsed.config,
2052
- cssFiles,
2053
- cssFindings,
2054
- dir: parsed.dir,
2055
- externalTokenDefinitions: tokenSourceResult.definitions,
2056
- includeMotion: parsed.includeMotion,
2057
- motionFindings,
2058
- scanScope,
2059
- tailwindFindings: collectTailwindFindings(templateFiles, lintOptions),
2060
- templateFiles,
2061
- tokenCandidateMinCount: parsed.tokenCandidateMinCount,
2062
- tokenKind: parsed.tokenKind,
2063
- tokenSourceReports: tokenSourceResult.sources,
2064
- tokenSourceWarnings: tokenSourceResult.warnings,
2065
- });
2066
-
2067
- if (parsed.sinceBaseline) {
2068
- applyBaselineComparison(report, parsed.baselinePath);
2069
- }
2070
-
2071
- if (parsed.writeBaseline) {
2072
- writeBaseline(report, parsed.baselinePath);
2073
- }
2074
-
2075
- return report;
2076
- }
2077
-
2078
- function normalizeCreateAuditOptions(options = {}) {
2079
- const parsed = {
2080
- ...createDefaultAuditOptions(),
2081
- ...options,
2082
- cliOptions: options.cliOptions instanceof Set
2083
- ? options.cliOptions
2084
- : new Set(Object.keys(options)),
2085
- };
2086
-
2087
- if (!Array.isArray(parsed.ignorePatterns)) {
2088
- parsed.ignorePatterns = [];
2089
- }
2090
-
2091
- if (!Array.isArray(parsed.tokenSources)) {
2092
- parsed.tokenSources = [];
2093
- }
2094
-
2095
- if (parsed.configApplied) {
2096
- delete parsed.cliOptions;
2097
- return parsed;
2098
- }
2099
-
2100
- return applyAuditConfig(parsed, loadAuditConfig(parsed));
2101
- }
2102
-
2103
- function toAuditContractReport(report) {
2104
- return {
2105
- baseline: report.baseline || null,
2106
- command: {
2107
- config: report.config,
2108
- directory: report.directory,
2109
- scanScope: report.scanScope.mode,
2110
- },
2111
- contracts: {
2112
- motion: report.motion,
2113
- scale: {
2114
- cleanliness: report.scaleCleanliness,
2115
- offScaleValues: report.offScaleValues,
2116
- tokenOpportunities: report.tokenOpportunities,
2117
- },
2118
- tokens: report.tokenContract,
2119
- },
2120
- findings: report.findings,
2121
- scanned: report.scanned,
2122
- schemaVersion: '2.0',
2123
- summary: report.summary,
2124
- };
2125
- }
2126
-
2127
- const AUDIT_JSON_SCHEMA = Object.freeze({
2128
- $schema: 'https://json-schema.org/draft/2020-12/schema',
2129
- additionalProperties: true,
2130
- properties: {
2131
- baseline: { type: ['object', 'null'] },
2132
- command: { type: 'object' },
2133
- contracts: { type: 'object' },
2134
- findings: { type: 'object' },
2135
- scanned: { type: 'object' },
2136
- schemaVersion: { const: '2.0' },
2137
- summary: { type: 'object' },
2138
- },
2139
- required: ['schemaVersion', 'command', 'summary', 'scanned', 'contracts', 'findings'],
2140
- title: 'Rhythmguard Audit Report',
2141
- type: 'object',
2142
- });
2143
-
2144
- function renderHtml(report) {
2145
- const contractReport = toAuditContractReport(report);
2146
- const lines = [
2147
- '<!doctype html>',
2148
- '<html lang="en">',
2149
- '<head>',
2150
- '<meta charset="utf-8">',
2151
- '<meta name="viewport" content="width=device-width, initial-scale=1">',
2152
- '<title>Rhythmguard Design-System Audit</title>',
2153
- '<style>',
2154
- 'body{font-family:system-ui,-apple-system,BlinkMacSystemFont,"Segoe UI",sans-serif;margin:0;color:#171717;background:#fafafa;}',
2155
- 'main{max-width:1040px;margin:0 auto;padding:32px 20px;}',
2156
- 'h1,h2{line-height:1.2;}',
2157
- '.grid{display:grid;grid-template-columns:repeat(auto-fit,minmax(180px,1fr));gap:12px;}',
2158
- '.metric{border:1px solid #ddd;background:#fff;padding:14px;border-radius:6px;}',
2159
- '.metric strong{display:block;font-size:28px;}',
2160
- 'table{width:100%;border-collapse:collapse;background:#fff;border:1px solid #ddd;}',
2161
- 'th,td{padding:10px;border-bottom:1px solid #eee;text-align:left;}',
2162
- 'code{font-family:ui-monospace,SFMono-Regular,Menlo,monospace;}',
2163
- '</style>',
2164
- '</head>',
2165
- '<body>',
2166
- '<main>',
2167
- '<h1>Rhythmguard Design-System Audit</h1>',
2168
- `<p>Directory: <code>${escapeHtml(contractReport.command.directory)}</code></p>`,
2169
- '<section class="grid">',
2170
- metricHtml('Total findings', report.totalWarnings),
2171
- metricHtml('Scale cleanliness', `${report.scaleCleanliness}%`),
2172
- metricHtml('CSS files', report.cssFilesScanned),
2173
- metricHtml('Template files', report.templateFilesScanned),
2174
- '</section>',
2175
- renderHtmlTable('Top Affected Files', ['File', 'Findings'], report.topAffectedFiles.map(({ file, count }) => [file, count])),
2176
- renderHtmlTable('Token Contract Sources', ['File', 'Format', 'Tokens'], report.tokenContract.sources.map((source) => [
2177
- source.file,
2178
- source.format,
2179
- source.tokenCount,
2180
- ])),
2181
- renderHtmlTable('Motion Rhythm Drift', ['Value', 'Count'], Object.entries(report.motion.values)),
2182
- '<h2>Machine JSON</h2>',
2183
- `<pre><code>${escapeHtml(JSON.stringify(contractReport, null, 2))}</code></pre>`,
2184
- '</main>',
2185
- '</body>',
2186
- '</html>',
2187
- ];
2188
-
2189
- return `${lines.join('\n')}\n`;
2190
- }
2191
-
2192
- function metricHtml(label, value) {
2193
- return `<div class="metric"><span>${escapeHtml(label)}</span><strong>${escapeHtml(value)}</strong></div>`;
2194
- }
2195
-
2196
- function renderHtmlTable(title, headers, rows) {
2197
- if (rows.length === 0) {
2198
- return '';
2199
- }
2200
-
2201
- return [
2202
- `<h2>${escapeHtml(title)}</h2>`,
2203
- '<table>',
2204
- `<thead><tr>${headers.map((header) => `<th>${escapeHtml(header)}</th>`).join('')}</tr></thead>`,
2205
- '<tbody>',
2206
- ...rows.map((row) => `<tr>${row.map((cell) => `<td><code>${escapeHtml(cell)}</code></td>`).join('')}</tr>`),
2207
- '</tbody>',
2208
- '</table>',
2209
- ].join('\n');
2210
- }
2211
-
2212
- function escapeHtml(value) {
2213
- return String(value)
2214
- .replace(/&/g, '&amp;')
2215
- .replace(/</g, '&lt;')
2216
- .replace(/>/g, '&gt;')
2217
- .replace(/"/g, '&quot;');
2218
- }
21
+ const args = process.argv.slice(3);
2219
22
 
2220
23
  async function run() {
2221
24
  let parsed;
@@ -2270,6 +73,12 @@ async function run() {
2270
73
  return;
2271
74
  }
2272
75
 
76
+ if (parsed.format === 'github') {
77
+ writeOutput(renderGithub(report), parsed.outputPath);
78
+ finish(auditFailures);
79
+ return;
80
+ }
81
+
2273
82
  writeOutput(renderText(report), parsed.outputPath);
2274
83
  finish(auditFailures);
2275
84
  }