stylelint-plugin-rhythmguard 3.2.0 → 3.4.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.
Files changed (38) hide show
  1. package/CHANGELOG.md +31 -0
  2. package/CONTRIBUTING.md +19 -3
  3. package/README.md +2 -1
  4. package/package.json +9 -8
  5. package/src/audit/args.js +103 -307
  6. package/src/audit/config.js +1 -1
  7. package/src/audit/contract.js +6 -28
  8. package/src/audit/index.js +4 -7
  9. package/src/audit/render-markdown.js +3 -0
  10. package/src/audit/render-text.js +2 -1
  11. package/src/audit/report.js +26 -19
  12. package/src/audit/scan/files.js +262 -0
  13. package/src/audit/scan/stylesheets.js +275 -0
  14. package/src/audit/scan/templates.js +175 -0
  15. package/src/cli/doctor.js +1 -1
  16. package/src/cli/quickstart.js +5 -2
  17. package/src/{utils → core}/length.js +24 -1
  18. package/src/{utils → core}/options.js +32 -108
  19. package/src/core/scale-inference.js +560 -0
  20. package/src/{utils → core}/token-packages.json +7 -0
  21. package/src/{utils → core}/token-sources.js +155 -17
  22. package/src/{utils/value-utils.js → core/value-nodes.js} +1 -17
  23. package/src/eslint/rules/tailwind-class-use-motion-scale.js +2 -2
  24. package/src/eslint/rules/tailwind-class-use-scale.js +2 -2
  25. package/src/rules/no-offscale-transform/index.js +22 -91
  26. package/src/rules/prefer-token/index.js +15 -73
  27. package/src/rules/report.js +75 -0
  28. package/src/rules/use-motion-scale/index.js +25 -42
  29. package/src/rules/use-scale/index.js +21 -94
  30. package/src/rules/validate.js +132 -0
  31. package/types/audit.d.ts +11 -0
  32. package/src/audit/scan.js +0 -676
  33. package/src/utils/scale-inference.js +0 -351
  34. /package/src/{utils/constants.js → core/css-vocabulary.js} +0 -0
  35. /package/src/{utils → core}/tailwind-class-analysis.js +0 -0
  36. /package/src/{utils → core}/tailwind-motion-analysis.js +0 -0
  37. /package/src/{utils → core}/time.js +0 -0
  38. /package/src/{utils → core}/token-map.js +0 -0
package/src/audit/scan.js DELETED
@@ -1,676 +0,0 @@
1
- 'use strict';
2
-
3
- const { execFileSync } = require('node:child_process');
4
- const fs = require('node:fs');
5
- const path = require('node:path');
6
- const { formatLength } = require('../utils/length');
7
- const { createTailwindClassAnalyzer } = require('../utils/tailwind-class-analysis');
8
- const { createTailwindMotionAnalyzer } = require('../utils/tailwind-motion-analysis');
9
- const { formatTime } = require('../utils/time');
10
- const {
11
- DEFAULT_AUDIT_TOKEN_PATTERN,
12
- SKIP_DIRS,
13
- TEMPLATE_EXTENSIONS,
14
- formatPath,
15
- pluginPath,
16
- } = require('./shared');
17
-
18
- function assertDirectory(dir) {
19
- if (!dir) {
20
- throw new Error('Missing audit directory.');
21
- }
22
-
23
- const resolvedDir = path.resolve(dir);
24
- if (!fs.existsSync(resolvedDir)) {
25
- throw new Error(`Directory not found: ${dir}`);
26
- }
27
-
28
- if (!fs.statSync(resolvedDir).isDirectory()) {
29
- throw new Error(`Not a directory: ${dir}`);
30
- }
31
-
32
- return resolvedDir;
33
- }
34
-
35
- function walkFiles(rootDir, ignorePatterns = []) {
36
- const cssFiles = [];
37
- const templateFiles = [];
38
- const ignoreMatchers = createIgnoreMatchers(ignorePatterns);
39
-
40
- function walk(currentDir) {
41
- for (const entry of fs.readdirSync(currentDir, { withFileTypes: true })) {
42
- const fullPath = path.join(currentDir, entry.name);
43
- const relativePath = toPosixRelativePath(rootDir, fullPath);
44
-
45
- if (shouldIgnorePath(relativePath, entry, ignoreMatchers)) {
46
- continue;
47
- }
48
-
49
- if (entry.isDirectory()) {
50
- walk(fullPath);
51
- continue;
52
- }
53
-
54
- if (entry.isFile()) {
55
- if (isCssFile(fullPath)) {
56
- cssFiles.push(fullPath);
57
- } else if (isTemplateFile(fullPath)) {
58
- templateFiles.push(fullPath);
59
- }
60
- }
61
- }
62
- }
63
-
64
- walk(rootDir);
65
- return { cssFiles, templateFiles };
66
- }
67
-
68
- function getScanFiles(rootDir, ignorePatterns, parsed) {
69
- if (parsed.staged || parsed.since) {
70
- return getGitChangedScanFiles(rootDir, ignorePatterns, parsed);
71
- }
72
-
73
- return {
74
- ...walkFiles(rootDir, ignorePatterns),
75
- scanScope: {
76
- mode: 'full',
77
- },
78
- };
79
- }
80
-
81
- function getGitChangedScanFiles(rootDir, ignorePatterns, parsed) {
82
- const args = parsed.staged
83
- ? ['diff', '--name-only', '--cached', '--diff-filter=ACMR', '--']
84
- : ['diff', '--name-only', '--diff-filter=ACMR', parsed.since, '--'];
85
- let output = '';
86
-
87
- try {
88
- output = execFileSync('git', args, {
89
- cwd: process.cwd(),
90
- encoding: 'utf8',
91
- stdio: ['ignore', 'pipe', 'pipe'],
92
- });
93
- } catch (err) {
94
- const stderr = err.stderr ? String(err.stderr).trim() : err.message;
95
- throw new Error(`Unable to read changed files from git: ${stderr}`);
96
- }
97
-
98
- const cssFiles = [];
99
- const templateFiles = [];
100
- const ignoreMatchers = createIgnoreMatchers(ignorePatterns);
101
- const seen = new Set();
102
- const changedFiles = output.split(/\r?\n/)
103
- .map((filePath) => filePath.trim())
104
- .filter(Boolean);
105
-
106
- for (const filePath of changedFiles) {
107
- const fullPath = path.resolve(process.cwd(), filePath);
108
-
109
- if (!isPathInside(rootDir, fullPath) || seen.has(fullPath) || !fs.existsSync(fullPath)) {
110
- continue;
111
- }
112
-
113
- const stat = fs.statSync(fullPath);
114
- if (!stat.isFile()) {
115
- continue;
116
- }
117
-
118
- const relativePath = toPosixRelativePath(rootDir, fullPath);
119
- if (shouldIgnoreRelativeFile(relativePath, ignoreMatchers)) {
120
- continue;
121
- }
122
-
123
- seen.add(fullPath);
124
- if (isCssFile(fullPath)) {
125
- cssFiles.push(fullPath);
126
- } else if (isTemplateFile(fullPath)) {
127
- templateFiles.push(fullPath);
128
- }
129
- }
130
-
131
- return {
132
- cssFiles,
133
- scanScope: {
134
- changedFiles: changedFiles.length,
135
- mode: parsed.staged ? 'staged' : 'since',
136
- since: parsed.since,
137
- },
138
- templateFiles,
139
- };
140
- }
141
-
142
- function isPathInside(rootDir, filePath) {
143
- const relativePath = path.relative(rootDir, filePath);
144
- return relativePath === '' || (!relativePath.startsWith('..') && !path.isAbsolute(relativePath));
145
- }
146
-
147
- function shouldIgnorePath(relativePath, entry, ignoreMatchers) {
148
- return ignoreMatchers.some((matcher) => matcher.test(relativePath))
149
- || (entry.isDirectory() && SKIP_DIRS.has(entry.name));
150
- }
151
-
152
- function shouldIgnoreRelativeFile(relativePath, ignoreMatchers) {
153
- const segments = relativePath.split('/');
154
- return segments.some((segment) => SKIP_DIRS.has(segment))
155
- || ignoreMatchers.some((matcher) => matcher.test(relativePath));
156
- }
157
-
158
- function createIgnoreMatchers(patterns) {
159
- const variants = new Set();
160
-
161
- for (const pattern of patterns) {
162
- addIgnorePatternVariants(variants, pattern);
163
- }
164
-
165
- return Array.from(variants, (pattern) => globToRegExp(pattern));
166
- }
167
-
168
- function addIgnorePatternVariants(variants, pattern) {
169
- if (!pattern) {
170
- return;
171
- }
172
-
173
- variants.add(pattern);
174
-
175
- if (!pattern.includes('/')) {
176
- variants.add(`${pattern}/**`);
177
- variants.add(`**/${pattern}`);
178
- variants.add(`**/${pattern}/**`);
179
- return;
180
- }
181
-
182
- if (pattern.endsWith('/**')) {
183
- variants.add(pattern.slice(0, -3));
184
- return;
185
- }
186
-
187
- if (!hasGlob(pattern)) {
188
- variants.add(`${pattern}/**`);
189
- }
190
- }
191
-
192
- function hasGlob(pattern) {
193
- return /[*?]/.test(pattern);
194
- }
195
-
196
- function globToRegExp(pattern) {
197
- let source = '^';
198
-
199
- for (let index = 0; index < pattern.length; index++) {
200
- const char = pattern[index];
201
- const nextChar = pattern[index + 1];
202
-
203
- if (char === '*' && nextChar === '*') {
204
- source += '.*';
205
- index++;
206
- continue;
207
- }
208
-
209
- if (char === '*') {
210
- source += '[^/]*';
211
- continue;
212
- }
213
-
214
- if (char === '?') {
215
- source += '[^/]';
216
- continue;
217
- }
218
-
219
- source += escapeRegExp(char);
220
- }
221
-
222
- return new RegExp(`${source}$`);
223
- }
224
-
225
- function escapeRegExp(value) {
226
- return value.replace(/[|\\{}()[\]^$+*?.]/g, '\\$&');
227
- }
228
-
229
- function toPosixRelativePath(rootDir, filePath) {
230
- return path.relative(rootDir, filePath).split(path.sep).join('/');
231
- }
232
-
233
- function isScssFile(filePath) {
234
- return filePath.endsWith('.scss');
235
- }
236
-
237
- // "CSS files" in the audit means authored stylesheets: .css always, .scss when
238
- // postcss-scss can be resolved (see resolveScssSyntax).
239
- function isCssFile(filePath) {
240
- return filePath.endsWith('.css') || isScssFile(filePath);
241
- }
242
-
243
- let scssSyntaxCache;
244
-
245
- /**
246
- * postcss-scss is an optional peer. Resolve it from the audited project first,
247
- * then from this package. Returns null when unavailable; SCSS files are then
248
- * counted as skipped instead of failing the audit.
249
- */
250
- function resolveScssSyntax() {
251
- if (scssSyntaxCache !== undefined) {
252
- return scssSyntaxCache;
253
- }
254
- try {
255
- scssSyntaxCache = require.resolve('postcss-scss', { paths: [process.cwd(), __dirname] });
256
- } catch {
257
- scssSyntaxCache = null;
258
- }
259
- return scssSyntaxCache;
260
- }
261
-
262
- function isTemplateFile(filePath) {
263
- return TEMPLATE_EXTENSIONS.has(path.extname(filePath));
264
- }
265
-
266
- async function runStylelintAudit(cssFiles, options) {
267
- if (cssFiles.length === 0) {
268
- const empty = [];
269
- empty.scssFiles = 0;
270
- empty.scssSkipped = 0;
271
- return empty;
272
- }
273
-
274
- const { default: stylelint } = await import('stylelint');
275
- const rules = {
276
- 'rhythmguard/use-scale': [
277
- true,
278
- {
279
- baseFontSize: options.baseFontSize,
280
- scale: options.scale,
281
- severity: 'warning',
282
- },
283
- ],
284
- 'rhythmguard/prefer-token': [
285
- true,
286
- {
287
- baseFontSize: options.baseFontSize,
288
- scale: options.scale,
289
- severity: 'warning',
290
- tokenMapFromCssCustomProperties: true,
291
- tokenPattern: DEFAULT_AUDIT_TOKEN_PATTERN,
292
- },
293
- ],
294
- };
295
-
296
- if (options.includeMotion) {
297
- rules['rhythmguard/use-motion-scale'] = [
298
- true,
299
- {
300
- severity: 'warning',
301
- },
302
- ];
303
- }
304
-
305
- const plainFiles = cssFiles.filter((file) => !isScssFile(file));
306
- const scssFiles = cssFiles.filter(isScssFile);
307
- const results = [];
308
-
309
- if (plainFiles.length > 0) {
310
- const result = await stylelint.lint({
311
- files: plainFiles,
312
- config: {
313
- plugins: [pluginPath],
314
- rules,
315
- },
316
- });
317
- results.push(...(result.results || []));
318
- }
319
-
320
- let scssSkipped = 0;
321
- if (scssFiles.length > 0) {
322
- const scssSyntax = resolveScssSyntax();
323
- if (scssSyntax) {
324
- const result = await stylelint.lint({
325
- files: scssFiles,
326
- config: {
327
- customSyntax: scssSyntax,
328
- plugins: [pluginPath],
329
- rules,
330
- },
331
- });
332
- results.push(...(result.results || []));
333
- } else {
334
- scssSkipped = scssFiles.length;
335
- }
336
- }
337
-
338
- results.scssFiles = scssFiles.length;
339
- results.scssSkipped = scssSkipped;
340
- return results;
341
- }
342
-
343
- function collectCssFindings(fileResults) {
344
- const findings = [];
345
- const sources = new Map();
346
-
347
- for (const fileResult of fileResults) {
348
- for (const warning of fileResult.warnings || []) {
349
- const text = warning.text || '';
350
- const source = readSourceOnce(sources, fileResult.source);
351
- const offScaleMatch = text.match(
352
- /Unexpected (?:off-scale value|transform translation value) "([^"]+)"/,
353
- );
354
- const tokenMatch = text.match(
355
- /Unexpected raw scale value "([^"]+)"/,
356
- );
357
- const motionDurationMatch = text.match(
358
- /Unexpected (?:motion duration|negative motion duration) "([^"]+)"/,
359
- );
360
- const motionEasingMatch = text.match(
361
- /Unexpected raw motion easing "([^"]+)"/,
362
- );
363
-
364
- findings.push({
365
- column: warning.column || 1,
366
- file: formatPath(fileResult.source),
367
- line: warning.line || 1,
368
- property: source === null
369
- ? null
370
- : findDeclarationProperty(source, warning.line || 1, warning.column || 1),
371
- rule: warning.rule || 'rhythmguard',
372
- text,
373
- type: getCssFindingType({ motionDurationMatch, motionEasingMatch, tokenMatch }),
374
- value: getCssFindingValue({
375
- motionDurationMatch,
376
- motionEasingMatch,
377
- offScaleMatch,
378
- tokenMatch,
379
- }),
380
- });
381
- }
382
- }
383
-
384
- return findings;
385
- }
386
-
387
- function readSourceOnce(cache, filePath) {
388
- if (!filePath) {
389
- return null;
390
- }
391
- if (!cache.has(filePath)) {
392
- try {
393
- cache.set(filePath, fs.readFileSync(filePath, 'utf8'));
394
- } catch {
395
- cache.set(filePath, null);
396
- }
397
- }
398
- return cache.get(filePath);
399
- }
400
-
401
- const DECLARATION_BOUNDARY = new Set([';', '{', '}']);
402
- const DECLARATION_HEAD_PATTERN = /^\s*(?:(?:\/\*[\s\S]*?\*\/|\/\/[^\n]*)\s*)*(--[\w-]+|[a-zA-Z][\w-]*)\s*:/;
403
-
404
- /**
405
- * Stylelint warnings carry a position but not the declaration node. Recover the
406
- * property by walking from the warning position back to the previous declaration
407
- * boundary and reading the `property:` head, skipping block and Sass line comments.
408
- * Sass interpolation is blanked first so `#{...}` braces do not act as boundaries. Returns null when the position is not
409
- * inside a declaration, for example inside an at-rule.
410
- */
411
- function findDeclarationProperty(source, line, column) {
412
- const offset = positionToOffset(source, line, column);
413
- if (offset === null) {
414
- return null;
415
- }
416
-
417
- const text = source.replace(/#\{[^}]*\}/g, (match) => ' '.repeat(match.length));
418
- let start = offset;
419
- while (start > 0 && !DECLARATION_BOUNDARY.has(text[start - 1])) {
420
- start -= 1;
421
- }
422
- const backward = text.slice(start, offset).match(DECLARATION_HEAD_PATTERN);
423
- if (backward) {
424
- return backward[1];
425
- }
426
-
427
- let end = offset;
428
- while (end < text.length && !DECLARATION_BOUNDARY.has(text[end])) {
429
- end += 1;
430
- }
431
- const forward = text.slice(offset, end).match(DECLARATION_HEAD_PATTERN);
432
- return forward ? forward[1] : null;
433
- }
434
-
435
- function positionToOffset(source, line, column) {
436
- if (!Number.isInteger(line) || line < 1) {
437
- return null;
438
- }
439
- let offset = 0;
440
- let currentLine = 1;
441
- while (currentLine < line) {
442
- const newline = source.indexOf('\n', offset);
443
- if (newline === -1) {
444
- return null;
445
- }
446
- offset = newline + 1;
447
- currentLine += 1;
448
- }
449
- const lineEnd = source.indexOf('\n', offset);
450
- const lineLength = (lineEnd === -1 ? source.length : lineEnd) - offset;
451
- return offset + Math.min(Math.max((column || 1) - 1, 0), lineLength);
452
- }
453
-
454
- function getCssFindingValue({
455
- motionDurationMatch,
456
- motionEasingMatch,
457
- offScaleMatch,
458
- tokenMatch,
459
- }) {
460
- if (tokenMatch) {
461
- return tokenMatch[1];
462
- }
463
-
464
- if (offScaleMatch) {
465
- return offScaleMatch[1];
466
- }
467
-
468
- if (motionDurationMatch) {
469
- return motionDurationMatch[1];
470
- }
471
-
472
- if (motionEasingMatch) {
473
- return motionEasingMatch[1];
474
- }
475
-
476
- return null;
477
- }
478
-
479
- function getCssFindingType({ motionDurationMatch, motionEasingMatch, tokenMatch }) {
480
- if (motionDurationMatch) {
481
- return 'motion-duration';
482
- }
483
-
484
- if (motionEasingMatch) {
485
- return 'motion-easing';
486
- }
487
-
488
- if (tokenMatch) {
489
- return 'token-opportunity';
490
- }
491
-
492
- return 'off-scale';
493
- }
494
-
495
- function collectTailwindFindings(templateFiles, options) {
496
- const analyzer = createTailwindClassAnalyzer(options);
497
- const findings = [];
498
-
499
- for (const filePath of templateFiles) {
500
- let source = '';
501
- try {
502
- source = fs.readFileSync(filePath, 'utf8');
503
- } catch {
504
- continue;
505
- }
506
-
507
- const lineStarts = getLineStarts(source);
508
-
509
- for (const literal of findStringLiterals(source)) {
510
- for (const { analysis, segment } of analyzer.analyzeClassString(literal.value)) {
511
- const position = offsetToLineColumn(lineStarts, literal.valueStart + segment.start);
512
- findings.push({
513
- column: position.column,
514
- file: formatPath(filePath),
515
- fixedToken: analysis.fixedToken,
516
- line: position.line,
517
- nearest: analysis.nearest
518
- ? {
519
- lower: formatLength(analysis.nearest.lower, 'px'),
520
- upper: formatLength(analysis.nearest.upper, 'px'),
521
- }
522
- : null,
523
- rawValue: analysis.rawValue,
524
- rule: 'rhythmguard-tailwind/tailwind-class-use-scale',
525
- text: analysis.reason === 'negative'
526
- ? `Unexpected Tailwind arbitrary spacing value "${segment.token}". Negative values are disabled for this rule.`
527
- : `Unexpected Tailwind arbitrary spacing value "${segment.token}". Use scale values.`,
528
- token: segment.token,
529
- type: 'tailwind-arbitrary-spacing',
530
- utility: analysis.utility,
531
- });
532
- }
533
- }
534
- }
535
-
536
- return findings;
537
- }
538
-
539
- function collectTailwindMotionFindings(templateFiles, options) {
540
- if (!options.includeMotion) {
541
- return [];
542
- }
543
-
544
- const analyzer = createTailwindMotionAnalyzer(options);
545
- const findings = [];
546
-
547
- for (const filePath of templateFiles) {
548
- let source = '';
549
- try {
550
- source = fs.readFileSync(filePath, 'utf8');
551
- } catch {
552
- continue;
553
- }
554
-
555
- const lineStarts = getLineStarts(source);
556
-
557
- for (const literal of findStringLiterals(source)) {
558
- for (const { analysis, segment } of analyzer.analyzeClassString(literal.value)) {
559
- const position = offsetToLineColumn(lineStarts, literal.valueStart + segment.start);
560
- findings.push({
561
- column: position.column,
562
- file: formatPath(filePath),
563
- fixedToken: analysis.fixedToken,
564
- line: position.line,
565
- nearest: analysis.nearest
566
- ? {
567
- lower: formatTime(analysis.nearest.lower, 'ms'),
568
- upper: formatTime(analysis.nearest.upper, 'ms'),
569
- }
570
- : null,
571
- rawValue: analysis.rawValue,
572
- rule: 'rhythmguard-tailwind/tailwind-class-use-motion-scale',
573
- text: buildTailwindMotionFindingText(segment.token, analysis),
574
- token: segment.token,
575
- type: analysis.reason === 'easing'
576
- ? 'tailwind-motion-easing'
577
- : 'tailwind-motion-duration',
578
- utility: analysis.utility,
579
- });
580
- }
581
- }
582
- }
583
-
584
- return findings;
585
- }
586
-
587
- function buildTailwindMotionFindingText(token, analysis) {
588
- if (analysis.reason === 'easing') {
589
- return `Unexpected Tailwind arbitrary motion easing "${token}". Use motion tokens for easing decisions.`;
590
- }
591
-
592
- if (analysis.reason === 'negative') {
593
- return `Unexpected Tailwind arbitrary motion duration "${token}". Use non-negative duration values.`;
594
- }
595
-
596
- return `Unexpected Tailwind arbitrary motion duration "${token}". Use duration scale values.`;
597
- }
598
-
599
- function findStringLiterals(source) {
600
- const literals = [];
601
- const literalPattern = /(["'`])((?:\\[\s\S]|(?!\1)[\s\S])*?)\1/g;
602
- let match;
603
-
604
- while ((match = literalPattern.exec(source)) !== null) {
605
- literals.push({
606
- quote: match[1],
607
- value: match[2],
608
- valueStart: match.index + 1,
609
- });
610
- }
611
-
612
- return literals;
613
- }
614
-
615
- function getLineStarts(source) {
616
- const starts = [0];
617
-
618
- for (let index = 0; index < source.length; index++) {
619
- if (source[index] === '\n') {
620
- starts.push(index + 1);
621
- }
622
- }
623
-
624
- return starts;
625
- }
626
-
627
- function offsetToLineColumn(lineStarts, offset) {
628
- let low = 0;
629
- let high = lineStarts.length - 1;
630
-
631
- while (low <= high) {
632
- const mid = Math.floor((low + high) / 2);
633
- if (lineStarts[mid] <= offset) {
634
- low = mid + 1;
635
- } else {
636
- high = mid - 1;
637
- }
638
- }
639
-
640
- const lineIndex = Math.max(0, high);
641
- return {
642
- column: offset - lineStarts[lineIndex] + 1,
643
- line: lineIndex + 1,
644
- };
645
- }
646
-
647
- module.exports = {
648
- addIgnorePatternVariants,
649
- assertDirectory,
650
- buildTailwindMotionFindingText,
651
- collectCssFindings,
652
- collectTailwindFindings,
653
- collectTailwindMotionFindings,
654
- createIgnoreMatchers,
655
- escapeRegExp,
656
- findDeclarationProperty,
657
- findStringLiterals,
658
- getCssFindingType,
659
- getCssFindingValue,
660
- getGitChangedScanFiles,
661
- getLineStarts,
662
- getScanFiles,
663
- globToRegExp,
664
- hasGlob,
665
- isCssFile,
666
- isScssFile,
667
- resolveScssSyntax,
668
- isPathInside,
669
- isTemplateFile,
670
- offsetToLineColumn,
671
- runStylelintAudit,
672
- shouldIgnorePath,
673
- shouldIgnoreRelativeFile,
674
- toPosixRelativePath,
675
- walkFiles,
676
- };