stylelint-plugin-rhythmguard 1.4.2 → 1.6.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.
@@ -0,0 +1,645 @@
1
+ 'use strict';
2
+
3
+ const fs = require('node:fs');
4
+ const path = require('node:path');
5
+
6
+ const { formatLength } = require('../utils/length');
7
+ const { createTailwindClassAnalyzer } = require('../utils/tailwind-class-analysis');
8
+
9
+ const args = process.argv.slice(3);
10
+ const pluginPath = path.resolve(__dirname, '..', 'index.js');
11
+
12
+ const DEFAULT_SCALE = [0, 4, 8, 12, 16, 24, 32];
13
+ const DEFAULT_BASE_FONT_SIZE = 16;
14
+ const VALID_FORMATS = new Set(['text', 'json', 'markdown']);
15
+ const SKIP_DIRS = new Set([
16
+ '.git',
17
+ '.next',
18
+ '.nuxt',
19
+ '.omx',
20
+ 'build',
21
+ 'coverage',
22
+ 'dist',
23
+ 'node_modules',
24
+ 'out',
25
+ ]);
26
+ const TEMPLATE_EXTENSIONS = new Set([
27
+ '.astro',
28
+ '.cjs',
29
+ '.js',
30
+ '.jsx',
31
+ '.mjs',
32
+ '.svelte',
33
+ '.ts',
34
+ '.tsx',
35
+ '.vue',
36
+ ]);
37
+
38
+ const HELP = `Usage: rhythmguard audit <dir> [options]
39
+
40
+ Options:
41
+ --format <text|json|markdown> Output format (default: text)
42
+ --json Alias for --format json
43
+ --markdown Alias for --format markdown
44
+ --scale <values> Comma-separated scale values (default: 0,4,8,12,16,24,32)
45
+ --base-font-size <number> px base for rem/em conversion (default: 16)
46
+ `;
47
+
48
+ function parseArgs(argv) {
49
+ const parsed = {
50
+ baseFontSize: DEFAULT_BASE_FONT_SIZE,
51
+ dir: null,
52
+ format: 'text',
53
+ scale: DEFAULT_SCALE,
54
+ };
55
+
56
+ for (let index = 0; index < argv.length; index++) {
57
+ const arg = argv[index];
58
+
59
+ if (arg === '--help' || arg === '-h') {
60
+ parsed.help = true;
61
+ continue;
62
+ }
63
+
64
+ if (arg === '--json') {
65
+ parsed.format = 'json';
66
+ continue;
67
+ }
68
+
69
+ if (arg === '--markdown') {
70
+ parsed.format = 'markdown';
71
+ continue;
72
+ }
73
+
74
+ if (arg === '--format') {
75
+ parsed.format = String(argv[++index] || '').toLowerCase();
76
+ continue;
77
+ }
78
+
79
+ if (arg.startsWith('--format=')) {
80
+ parsed.format = arg.slice('--format='.length).toLowerCase();
81
+ continue;
82
+ }
83
+
84
+ if (arg === '--scale') {
85
+ parsed.scale = parseScale(argv[++index]);
86
+ continue;
87
+ }
88
+
89
+ if (arg.startsWith('--scale=')) {
90
+ parsed.scale = parseScale(arg.slice('--scale='.length));
91
+ continue;
92
+ }
93
+
94
+ if (arg === '--base-font-size') {
95
+ parsed.baseFontSize = parseBaseFontSize(argv[++index]);
96
+ continue;
97
+ }
98
+
99
+ if (arg.startsWith('--base-font-size=')) {
100
+ parsed.baseFontSize = parseBaseFontSize(arg.slice('--base-font-size='.length));
101
+ continue;
102
+ }
103
+
104
+ if (!arg.startsWith('-') && !parsed.dir) {
105
+ parsed.dir = arg;
106
+ continue;
107
+ }
108
+
109
+ throw new Error(`Unknown option: ${arg}`);
110
+ }
111
+
112
+ if (!VALID_FORMATS.has(parsed.format)) {
113
+ throw new Error(`Invalid format "${parsed.format}". Expected text, json, or markdown.`);
114
+ }
115
+
116
+ return parsed;
117
+ }
118
+
119
+ function parseScale(raw) {
120
+ if (!raw) {
121
+ throw new Error('Missing value for --scale.');
122
+ }
123
+
124
+ const scale = raw.split(',')
125
+ .map((part) => part.trim())
126
+ .filter(Boolean)
127
+ .map((part) => {
128
+ const numeric = Number(part);
129
+ return Number.isFinite(numeric) && !/[a-z%]/i.test(part) ? numeric : part;
130
+ });
131
+
132
+ if (scale.length === 0) {
133
+ throw new Error('Scale must include at least one value.');
134
+ }
135
+
136
+ return scale;
137
+ }
138
+
139
+ function parseBaseFontSize(raw) {
140
+ const value = Number(raw);
141
+ if (!Number.isFinite(value) || value <= 0) {
142
+ throw new Error('--base-font-size must be a positive number.');
143
+ }
144
+
145
+ return value;
146
+ }
147
+
148
+ function assertDirectory(dir) {
149
+ if (!dir) {
150
+ process.stderr.write(HELP);
151
+ process.exit(1);
152
+ }
153
+
154
+ const resolvedDir = path.resolve(dir);
155
+ if (!fs.existsSync(resolvedDir)) {
156
+ process.stderr.write(`Directory not found: ${dir}\n`);
157
+ process.exit(1);
158
+ }
159
+
160
+ return resolvedDir;
161
+ }
162
+
163
+ function walkFiles(rootDir) {
164
+ const files = [];
165
+
166
+ function walk(currentDir) {
167
+ for (const entry of fs.readdirSync(currentDir, { withFileTypes: true })) {
168
+ if (entry.isDirectory()) {
169
+ if (!SKIP_DIRS.has(entry.name)) {
170
+ walk(path.join(currentDir, entry.name));
171
+ }
172
+ continue;
173
+ }
174
+
175
+ if (entry.isFile()) {
176
+ files.push(path.join(currentDir, entry.name));
177
+ }
178
+ }
179
+ }
180
+
181
+ walk(rootDir);
182
+ return files;
183
+ }
184
+
185
+ function isCssFile(filePath) {
186
+ return filePath.endsWith('.css');
187
+ }
188
+
189
+ function isTemplateFile(filePath) {
190
+ return TEMPLATE_EXTENSIONS.has(path.extname(filePath));
191
+ }
192
+
193
+ async function runStylelintAudit(cssFiles, options) {
194
+ if (cssFiles.length === 0) {
195
+ return [];
196
+ }
197
+
198
+ const { default: stylelint } = await import('stylelint');
199
+
200
+ const result = await stylelint.lint({
201
+ files: cssFiles,
202
+ config: {
203
+ plugins: [pluginPath],
204
+ rules: {
205
+ 'rhythmguard/use-scale': [
206
+ true,
207
+ {
208
+ baseFontSize: options.baseFontSize,
209
+ scale: options.scale,
210
+ severity: 'warning',
211
+ },
212
+ ],
213
+ 'rhythmguard/prefer-token': [
214
+ true,
215
+ {
216
+ baseFontSize: options.baseFontSize,
217
+ scale: options.scale,
218
+ severity: 'warning',
219
+ tokenMapFromCssCustomProperties: true,
220
+ tokenPattern: '^--spac(e|ing)-',
221
+ },
222
+ ],
223
+ },
224
+ },
225
+ });
226
+
227
+ return result.results || [];
228
+ }
229
+
230
+ function collectCssFindings(fileResults) {
231
+ const findings = [];
232
+
233
+ for (const fileResult of fileResults) {
234
+ for (const warning of fileResult.warnings || []) {
235
+ const text = warning.text || '';
236
+ const offScaleMatch = text.match(
237
+ /Unexpected (?:off-scale value|transform translation value) "([^"]+)"/,
238
+ );
239
+ const tokenMatch = text.match(
240
+ /Unexpected raw scale value "([^"]+)"/,
241
+ );
242
+
243
+ findings.push({
244
+ column: warning.column || 1,
245
+ file: formatPath(fileResult.source),
246
+ line: warning.line || 1,
247
+ rule: warning.rule || 'rhythmguard',
248
+ text,
249
+ type: tokenMatch ? 'token-opportunity' : 'off-scale',
250
+ value: tokenMatch ? tokenMatch[1] : offScaleMatch ? offScaleMatch[1] : null,
251
+ });
252
+ }
253
+ }
254
+
255
+ return findings;
256
+ }
257
+
258
+ function collectTailwindFindings(templateFiles, options) {
259
+ const analyzer = createTailwindClassAnalyzer(options);
260
+ const findings = [];
261
+
262
+ for (const filePath of templateFiles) {
263
+ let source = '';
264
+ try {
265
+ source = fs.readFileSync(filePath, 'utf8');
266
+ } catch {
267
+ continue;
268
+ }
269
+
270
+ const lineStarts = getLineStarts(source);
271
+
272
+ for (const literal of findStringLiterals(source)) {
273
+ for (const { analysis, segment } of analyzer.analyzeClassString(literal.value)) {
274
+ const position = offsetToLineColumn(lineStarts, literal.valueStart + segment.start);
275
+ findings.push({
276
+ column: position.column,
277
+ file: formatPath(filePath),
278
+ fixedToken: analysis.fixedToken,
279
+ line: position.line,
280
+ nearest: analysis.nearest
281
+ ? {
282
+ lower: formatLength(analysis.nearest.lower, 'px'),
283
+ upper: formatLength(analysis.nearest.upper, 'px'),
284
+ }
285
+ : null,
286
+ rawValue: analysis.rawValue,
287
+ rule: 'rhythmguard-tailwind/tailwind-class-use-scale',
288
+ text: analysis.reason === 'negative'
289
+ ? `Unexpected Tailwind arbitrary spacing value "${segment.token}". Negative values are disabled for this rule.`
290
+ : `Unexpected Tailwind arbitrary spacing value "${segment.token}". Use scale values.`,
291
+ token: segment.token,
292
+ type: 'tailwind-arbitrary-spacing',
293
+ utility: analysis.utility,
294
+ });
295
+ }
296
+ }
297
+ }
298
+
299
+ return findings;
300
+ }
301
+
302
+ function findStringLiterals(source) {
303
+ const literals = [];
304
+ const literalPattern = /(["'`])((?:\\[\s\S]|(?!\1)[\s\S])*?)\1/g;
305
+ let match;
306
+
307
+ while ((match = literalPattern.exec(source)) !== null) {
308
+ literals.push({
309
+ quote: match[1],
310
+ value: match[2],
311
+ valueStart: match.index + 1,
312
+ });
313
+ }
314
+
315
+ return literals;
316
+ }
317
+
318
+ function getLineStarts(source) {
319
+ const starts = [0];
320
+
321
+ for (let index = 0; index < source.length; index++) {
322
+ if (source[index] === '\n') {
323
+ starts.push(index + 1);
324
+ }
325
+ }
326
+
327
+ return starts;
328
+ }
329
+
330
+ function offsetToLineColumn(lineStarts, offset) {
331
+ let low = 0;
332
+ let high = lineStarts.length - 1;
333
+
334
+ while (low <= high) {
335
+ const mid = Math.floor((low + high) / 2);
336
+ if (lineStarts[mid] <= offset) {
337
+ low = mid + 1;
338
+ } else {
339
+ high = mid - 1;
340
+ }
341
+ }
342
+
343
+ const lineIndex = Math.max(0, high);
344
+ return {
345
+ column: offset - lineStarts[lineIndex] + 1,
346
+ line: lineIndex + 1,
347
+ };
348
+ }
349
+
350
+ function buildReport({
351
+ cssFiles,
352
+ cssFindings,
353
+ dir,
354
+ templateFiles,
355
+ tailwindFindings,
356
+ }) {
357
+ const offScaleValues = countByValue(cssFindings
358
+ .filter((finding) => finding.type === 'off-scale' && finding.value)
359
+ .map((finding) => finding.value));
360
+ const tokenOpportunities = countByValue(cssFindings
361
+ .filter((finding) => finding.type === 'token-opportunity' && finding.value)
362
+ .map((finding) => finding.value));
363
+ const tailwindArbitraryValues = countByValue(tailwindFindings
364
+ .map((finding) => finding.rawValue));
365
+ const issueFiles = new Set([
366
+ ...cssFindings.map((finding) => finding.file),
367
+ ...tailwindFindings.map((finding) => finding.file),
368
+ ]);
369
+ const topAffectedFiles = sortCountMap(countByValue([
370
+ ...cssFindings.map((finding) => finding.file),
371
+ ...tailwindFindings.map((finding) => finding.file),
372
+ ])).slice(0, 10);
373
+
374
+ const totalFiles = cssFiles.length + templateFiles.length;
375
+ const totalWarnings = cssFindings.length + tailwindFindings.length;
376
+ const filesWithIssues = issueFiles.size;
377
+ const scaleCleanliness = totalFiles > 0
378
+ ? Math.max(0, Math.round(((totalFiles - filesWithIssues) / totalFiles) * 100))
379
+ : 100;
380
+
381
+ return {
382
+ cssFilesScanned: cssFiles.length,
383
+ directory: dir,
384
+ filesWithIssues,
385
+ findings: {
386
+ css: cssFindings,
387
+ tailwind: tailwindFindings,
388
+ },
389
+ formatVersion: 2,
390
+ offScaleValues: Object.fromEntries(sortCountMap(offScaleValues).slice(0, 10)),
391
+ scaleCleanliness,
392
+ scanned: {
393
+ cssFiles: cssFiles.length,
394
+ templateFiles: templateFiles.length,
395
+ totalFiles,
396
+ },
397
+ summary: {
398
+ cssWarnings: cssFindings.length,
399
+ filesWithIssues,
400
+ scaleCleanliness,
401
+ tailwindArbitrarySpacing: tailwindFindings.length,
402
+ tokenOpportunities: sumCounts(tokenOpportunities),
403
+ totalFindings: totalWarnings,
404
+ },
405
+ tailwindArbitraryValues: Object.fromEntries(sortCountMap(tailwindArbitraryValues).slice(0, 10)),
406
+ templateFilesScanned: templateFiles.length,
407
+ tokenOpportunities: Object.fromEntries(sortCountMap(tokenOpportunities).slice(0, 10)),
408
+ topAffectedFiles: topAffectedFiles.map(([file, count]) => ({ count, file })),
409
+ totalFiles,
410
+ totalWarnings,
411
+ };
412
+ }
413
+
414
+ function countByValue(values) {
415
+ const counts = {};
416
+
417
+ for (const value of values) {
418
+ if (value) {
419
+ counts[value] = (counts[value] || 0) + 1;
420
+ }
421
+ }
422
+
423
+ return counts;
424
+ }
425
+
426
+ function sortCountMap(counts) {
427
+ return Object.entries(counts).sort((a, b) => b[1] - a[1] || a[0].localeCompare(b[0]));
428
+ }
429
+
430
+ function sumCounts(counts) {
431
+ return Object.values(counts).reduce((total, count) => total + count, 0);
432
+ }
433
+
434
+ function renderText(report) {
435
+ const lines = [
436
+ '',
437
+ ' ┌─ RHYTHMGUARD DESIGN-SYSTEM AUDIT ─────────────────────────┐',
438
+ ` │ ${report.directory.padEnd(58)}│`,
439
+ ' └─────────────────────────────────────────────────────────────┘',
440
+ '',
441
+ ` CSS files scanned ${String(report.cssFilesScanned).padStart(4)}`,
442
+ ` Template files scanned ${String(report.templateFilesScanned).padStart(4)}`,
443
+ ` Files with issues ${String(report.filesWithIssues).padStart(4)}`,
444
+ ` Scale cleanliness ${scoreBar(report.scaleCleanliness)} ${report.scaleCleanliness}%`,
445
+ '',
446
+ ];
447
+
448
+ appendHistogram(lines, 'CSS OFF-SCALE VALUES', report.offScaleValues);
449
+ appendHistogram(lines, 'CSS TOKEN OPPORTUNITIES', report.tokenOpportunities);
450
+ appendHistogram(lines, 'TAILWIND CLASS-STRING DRIFT', report.tailwindArbitraryValues);
451
+
452
+ if (report.topAffectedFiles.length > 0) {
453
+ lines.push(' ── TOP AFFECTED FILES ──');
454
+ lines.push('');
455
+ const maxCount = Math.max(...report.topAffectedFiles.map(({ count }) => count));
456
+ for (const { file, count } of report.topAffectedFiles) {
457
+ lines.push(` ${truncate(file, 34).padEnd(36)} ${histBar(count, maxCount)} ${count}`);
458
+ }
459
+ lines.push('');
460
+ }
461
+
462
+ if (report.totalWarnings === 0) {
463
+ lines.push(' ✓ No issues found. Your design-system rhythm is clean.');
464
+ } else {
465
+ lines.push(' → Run "npx stylelint --fix" for CSS declaration fixes.');
466
+ lines.push(' → Use "npx rhythmguard audit ./src --format markdown" for PR/design review.');
467
+ }
468
+ lines.push('');
469
+
470
+ return `${lines.join('\n')}\n`;
471
+ }
472
+
473
+ function appendHistogram(lines, title, counts) {
474
+ const entries = sortCountMap(counts);
475
+ const total = sumCounts(counts);
476
+
477
+ if (entries.length === 0) {
478
+ return;
479
+ }
480
+
481
+ lines.push(` ── ${title} ── ${total} total`);
482
+ lines.push('');
483
+ const maxCount = Math.max(...entries.map(([, count]) => count));
484
+ for (const [value, count] of entries) {
485
+ lines.push(` ${value.padEnd(14)} ${histBar(count, maxCount)} ${count}`);
486
+ }
487
+ lines.push('');
488
+ }
489
+
490
+ function renderMarkdown(report) {
491
+ const lines = [
492
+ '# Rhythmguard Design-System Audit',
493
+ '',
494
+ `Directory: \`${report.directory}\``,
495
+ '',
496
+ '## Summary',
497
+ '',
498
+ '| Metric | Value |',
499
+ '| --- | ---: |',
500
+ `| CSS files scanned | ${report.cssFilesScanned} |`,
501
+ `| Template files scanned | ${report.templateFilesScanned} |`,
502
+ `| Files with issues | ${report.filesWithIssues} |`,
503
+ `| Total findings | ${report.totalWarnings} |`,
504
+ `| Scale cleanliness | ${report.scaleCleanliness}% |`,
505
+ '',
506
+ ];
507
+
508
+ appendMarkdownCounts(lines, 'CSS Off-Scale Values', report.offScaleValues);
509
+ appendMarkdownCounts(lines, 'CSS Token Opportunities', report.tokenOpportunities);
510
+ appendMarkdownCounts(lines, 'Tailwind Class-String Drift', report.tailwindArbitraryValues);
511
+
512
+ if (report.topAffectedFiles.length > 0) {
513
+ lines.push('## Top Affected Files');
514
+ lines.push('');
515
+ lines.push('| File | Findings |');
516
+ lines.push('| --- | ---: |');
517
+ for (const { file, count } of report.topAffectedFiles) {
518
+ lines.push(`| \`${escapeMarkdown(file)}\` | ${count} |`);
519
+ }
520
+ lines.push('');
521
+ }
522
+
523
+ if (report.findings.tailwind.length > 0) {
524
+ lines.push('## Tailwind Examples');
525
+ lines.push('');
526
+ lines.push('| File | Class | Suggested class |');
527
+ lines.push('| --- | --- | --- |');
528
+ for (const finding of report.findings.tailwind.slice(0, 10)) {
529
+ lines.push(`| \`${escapeMarkdown(`${finding.file}:${finding.line}`)}\` | \`${escapeMarkdown(finding.token)}\` | \`${escapeMarkdown(finding.fixedToken || 'n/a')}\` |`);
530
+ }
531
+ lines.push('');
532
+ }
533
+
534
+ lines.push('## Recommended Next Actions');
535
+ lines.push('');
536
+ if (report.totalWarnings === 0) {
537
+ lines.push('- Keep Rhythmguard in CI to prevent new drift.');
538
+ } else {
539
+ lines.push('- Run deterministic autofix for CSS declarations where appropriate.');
540
+ lines.push('- Review Tailwind arbitrary spacing values with UX/design-system owners.');
541
+ lines.push('- Convert repeated raw on-scale values into existing design tokens.');
542
+ }
543
+ lines.push('');
544
+
545
+ return `${lines.join('\n')}\n`;
546
+ }
547
+
548
+ function appendMarkdownCounts(lines, title, counts) {
549
+ const entries = sortCountMap(counts);
550
+
551
+ if (entries.length === 0) {
552
+ return;
553
+ }
554
+
555
+ lines.push(`## ${title}`);
556
+ lines.push('');
557
+ lines.push('| Value | Count |');
558
+ lines.push('| --- | ---: |');
559
+ for (const [value, count] of entries) {
560
+ lines.push(`| \`${escapeMarkdown(value)}\` | ${count} |`);
561
+ }
562
+ lines.push('');
563
+ }
564
+
565
+ function histBar(count, maxCount) {
566
+ const width = 30;
567
+ const filled = Math.round((count / maxCount) * width);
568
+ return '█'.repeat(filled) + '░'.repeat(width - filled);
569
+ }
570
+
571
+ function scoreBar(pct) {
572
+ const width = 20;
573
+ const filled = Math.round((pct / 100) * width);
574
+ return '█'.repeat(filled) + '░'.repeat(width - filled);
575
+ }
576
+
577
+ function truncate(value, maxLength) {
578
+ if (value.length <= maxLength) {
579
+ return value;
580
+ }
581
+
582
+ return `…${value.slice(value.length - maxLength + 1)}`;
583
+ }
584
+
585
+ function escapeMarkdown(value) {
586
+ return String(value).replace(/\|/g, '\\|').replace(/`/g, '\\`');
587
+ }
588
+
589
+ function formatPath(filePath) {
590
+ return path.relative(process.cwd(), filePath).replace(/\\/g, '/');
591
+ }
592
+
593
+ async function run() {
594
+ let parsed;
595
+ try {
596
+ parsed = parseArgs(args);
597
+ } catch (err) {
598
+ process.stderr.write(`${err.message}\n\n${HELP}`);
599
+ process.exit(1);
600
+ }
601
+
602
+ if (parsed.help) {
603
+ process.stdout.write(HELP);
604
+ return;
605
+ }
606
+
607
+ const resolvedDir = assertDirectory(parsed.dir);
608
+ const allFiles = walkFiles(resolvedDir);
609
+ const cssFiles = allFiles.filter(isCssFile);
610
+ const templateFiles = allFiles.filter(isTemplateFile);
611
+ const options = {
612
+ baseFontSize: parsed.baseFontSize,
613
+ scale: parsed.scale,
614
+ };
615
+
616
+ let cssResults;
617
+ try {
618
+ cssResults = await runStylelintAudit(cssFiles, options);
619
+ } catch (err) {
620
+ process.stderr.write(`Lint error: ${err.message}\n`);
621
+ process.exit(1);
622
+ }
623
+
624
+ const report = buildReport({
625
+ cssFiles,
626
+ cssFindings: collectCssFindings(cssResults),
627
+ dir: parsed.dir,
628
+ tailwindFindings: collectTailwindFindings(templateFiles, options),
629
+ templateFiles,
630
+ });
631
+
632
+ if (parsed.format === 'json') {
633
+ process.stdout.write(`${JSON.stringify(report, null, 2)}\n`);
634
+ return;
635
+ }
636
+
637
+ if (parsed.format === 'markdown') {
638
+ process.stdout.write(renderMarkdown(report));
639
+ return;
640
+ }
641
+
642
+ process.stdout.write(renderText(report));
643
+ }
644
+
645
+ run();