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
@@ -3,10 +3,10 @@
3
3
  const fs = require('node:fs');
4
4
  const {
5
5
  addDefinition,
6
- collectScssTokens,
6
+ collectCssTokens,
7
7
  createTokenKindMatcher,
8
8
  getNormalizedValueKeys,
9
- } = require('../utils/token-sources');
9
+ } = require('../core/token-sources');
10
10
  const { formatPath } = require('./shared');
11
11
 
12
12
  function collectTokenContract({
@@ -95,33 +95,10 @@ function collectTokenContract({
95
95
  };
96
96
  }
97
97
 
98
+ /** Custom properties and Sass tokens declared in one stylesheet, added to the definitions map. */
98
99
  function collectTokenDefinitions(source, file, definitions, matchesKind, baseFontSize) {
99
- const declarationPattern = /(--[\w-]+)\s*:\s*([^;{}]+)/g;
100
- let match;
101
-
102
- while ((match = declarationPattern.exec(source)) !== null) {
103
- const token = match[1];
104
- if (!matchesKind(token)) {
105
- continue;
106
- }
107
-
108
- addDefinition(definitions, {
109
- baseFontSize,
110
- file,
111
- source: file,
112
- token,
113
- value: match[2].trim(),
114
- });
115
- }
116
-
117
- for (const sassToken of collectScssTokens(source, matchesKind)) {
118
- addDefinition(definitions, {
119
- baseFontSize,
120
- file,
121
- source: file,
122
- token: sassToken.token,
123
- value: sassToken.value,
124
- });
100
+ for (const { scope, token, value } of collectCssTokens(source, matchesKind)) {
101
+ addDefinition(definitions, { baseFontSize, file, scope, source: file, token, value });
125
102
  }
126
103
  }
127
104
 
@@ -394,6 +371,7 @@ function toAuditContractReport(report) {
394
371
  files: report.scale ? report.scale.files : [],
395
372
  offScaleProperties: report.offScaleProperties || {},
396
373
  offScaleValues: report.offScaleValues,
374
+ rejected: report.scale && report.scale.rejected ? report.scale.rejected : null,
397
375
  source: report.scale ? report.scale.source : 'default',
398
376
  tokenOpportunities: report.tokenOpportunities,
399
377
  values: report.scale ? report.scale.values : null,
@@ -1,12 +1,9 @@
1
1
  'use strict';
2
2
 
3
- const {
4
- AUDIT_JSON_SCHEMA,
5
- createAuditReport,
6
- loadAuditConfig,
7
- toAuditContractReport,
8
- } = require('../cli/audit');
9
- const { parseTokenSources } = require('../utils/token-sources');
3
+ const { loadAuditConfig } = require('./config');
4
+ const { AUDIT_JSON_SCHEMA, toAuditContractReport } = require('./contract');
5
+ const { createAuditReport } = require('./report');
6
+ const { parseTokenSources } = require('../core/token-sources');
10
7
 
11
8
  module.exports = {
12
9
  AUDIT_JSON_SCHEMA,
@@ -4,6 +4,9 @@ const { sortCountMap } = require('./contract');
4
4
  const { escapeMarkdown } = require('./render-utils');
5
5
 
6
6
  function describeScaleSource(scale) {
7
+ if (scale.rejected) {
8
+ return `${scale.source} (${scale.rejected.source} rejected: ${scale.rejected.reasons.join(', ')})`;
9
+ }
7
10
  return scale.files.length > 0
8
11
  ? `${scale.source} (${scale.files.join(', ')})`
9
12
  : scale.source;
@@ -28,7 +28,8 @@ function renderText(report) {
28
28
  if (report.scale) {
29
29
  lines.push(` Scale ${report.scale.values.join(', ')}`);
30
30
  const files = report.scale.files.length > 0 ? ` (${report.scale.files.join(', ')})` : '';
31
- lines.push(` Scale source ${report.scale.source}${files}`);
31
+ const rejected = report.scale.rejected ? ` (${report.scale.rejected.source} rejected: ${report.scale.rejected.reasons.join(', ')})` : '';
32
+ lines.push(` Scale source ${report.scale.source}${files}${rejected}`);
32
33
  }
33
34
  lines.push('');
34
35
 
@@ -5,7 +5,7 @@ const fs = require('node:fs');
5
5
  const {
6
6
  createTokenKindMatcher,
7
7
  parseTokenSources,
8
- } = require('../utils/token-sources');
8
+ } = require('../core/token-sources');
9
9
  const {
10
10
  applyBaselineComparison,
11
11
  writeBaseline,
@@ -18,15 +18,15 @@ const {
18
18
  } = require('./config');
19
19
  const { buildReport, collectTokenDefinitions } = require('./contract');
20
20
  const { DEFAULT_SCALE, formatPath } = require('./shared');
21
- const { discoverTokenPackages, scaleFromDefinitions } = require('../utils/scale-inference');
22
21
  const {
23
- assertDirectory,
24
- collectCssFindings,
25
- collectTailwindFindings,
26
- collectTailwindMotionFindings,
27
- getScanFiles,
28
- runStylelintAudit,
29
- } = require('./scan');
22
+ assessScale,
23
+ discoverTokenPackages,
24
+ inferScaleFromDefinitions,
25
+ scaleFromDefinitions,
26
+ } = require('../core/scale-inference');
27
+ const { assertDirectory, getScanFiles } = require('./scan/files');
28
+ const { collectCssFindings, runStylelintAudit } = require('./scan/stylesheets');
29
+ const { collectTailwindFindings, collectTailwindMotionFindings } = require('./scan/templates');
30
30
 
31
31
  async function createAuditReport(options) {
32
32
  const parsed = normalizeCreateAuditOptions(options);
@@ -129,6 +129,7 @@ function resolveAuditScale({ baseFontSize, cssFiles, requested, tokenSourceResul
129
129
  }
130
130
  }
131
131
 
132
+ let rejected = null;
132
133
  const definitions = new Map();
133
134
  const matchesKind = createTokenKindMatcher('spacing');
134
135
  for (const filePath of cssFiles) {
@@ -147,20 +148,26 @@ function resolveAuditScale({ baseFontSize, cssFiles, requested, tokenSourceResul
147
148
  }
148
149
 
149
150
  if (definitions.size > 0) {
150
- const values = scaleFromDefinitions(definitions, baseFontSize);
151
- if (values) {
151
+ const inferred = inferScaleFromDefinitions(definitions, baseFontSize);
152
+ if (inferred) {
153
+ const { values } = inferred;
152
154
  const files = new Set();
153
- for (const definition of definitions.values()) {
155
+ for (const definition of inferred.definitions.values()) {
154
156
  for (const file of definition.files) {
155
157
  files.add(file);
156
158
  }
157
159
  }
158
- return {
159
- files: Array.from(files).sort(),
160
- source: 'scanned-css',
161
- tokenCount: definitions.size,
162
- values,
163
- };
160
+ const sortedFiles = Array.from(files).sort();
161
+ const assessment = assessScale({ files: sortedFiles, source: 'scanned-css', values });
162
+ if (assessment.plausible) {
163
+ return {
164
+ files: sortedFiles,
165
+ source: 'scanned-css',
166
+ tokenCount: inferred.definitions.size,
167
+ values,
168
+ };
169
+ }
170
+ rejected = { files: sortedFiles, reasons: assessment.reasons, source: 'scanned-css', values };
164
171
  }
165
172
  }
166
173
 
@@ -178,7 +185,7 @@ function resolveAuditScale({ baseFontSize, cssFiles, requested, tokenSourceResul
178
185
  }
179
186
  }
180
187
 
181
- return { files: [], source: 'fallback', tokenCount: 0, values: DEFAULT_SCALE };
188
+ return { files: [], ...(rejected ? { rejected } : {}), source: 'fallback', tokenCount: 0, values: DEFAULT_SCALE };
182
189
  }
183
190
 
184
191
  function normalizeCreateAuditOptions(options = {}) {
@@ -0,0 +1,262 @@
1
+ 'use strict';
2
+
3
+ /**
4
+ * Which files the audit looks at: directory walking, ignore patterns (root-relative
5
+ * paths and globs), git-scoped selection (--since, --staged), and the file-type
6
+ * predicates. Nothing here parses CSS.
7
+ */
8
+ const { execFileSync } = require('node:child_process');
9
+ const fs = require('node:fs');
10
+ const path = require('node:path');
11
+ const {
12
+ SKIP_DIRS,
13
+ TEMPLATE_EXTENSIONS,
14
+ } = require('../shared');
15
+
16
+ function assertDirectory(dir) {
17
+ if (!dir) {
18
+ throw new Error('Missing audit directory.');
19
+ }
20
+
21
+ const resolvedDir = path.resolve(dir);
22
+ if (!fs.existsSync(resolvedDir)) {
23
+ throw new Error(`Directory not found: ${dir}`);
24
+ }
25
+
26
+ if (!fs.statSync(resolvedDir).isDirectory()) {
27
+ throw new Error(`Not a directory: ${dir}`);
28
+ }
29
+
30
+ return resolvedDir;
31
+ }
32
+
33
+ function walkFiles(rootDir, ignorePatterns = []) {
34
+ const cssFiles = [];
35
+ const templateFiles = [];
36
+ const ignoreMatchers = createIgnoreMatchers(ignorePatterns);
37
+
38
+ function walk(currentDir) {
39
+ for (const entry of fs.readdirSync(currentDir, { withFileTypes: true })) {
40
+ const fullPath = path.join(currentDir, entry.name);
41
+ const relativePath = toPosixRelativePath(rootDir, fullPath);
42
+
43
+ if (shouldIgnorePath(relativePath, entry, ignoreMatchers)) {
44
+ continue;
45
+ }
46
+
47
+ if (entry.isDirectory()) {
48
+ walk(fullPath);
49
+ continue;
50
+ }
51
+
52
+ if (entry.isFile()) {
53
+ if (isCssFile(fullPath)) {
54
+ cssFiles.push(fullPath);
55
+ } else if (isTemplateFile(fullPath)) {
56
+ templateFiles.push(fullPath);
57
+ }
58
+ }
59
+ }
60
+ }
61
+
62
+ walk(rootDir);
63
+ return { cssFiles, templateFiles };
64
+ }
65
+
66
+ function getScanFiles(rootDir, ignorePatterns, parsed) {
67
+ if (parsed.staged || parsed.since) {
68
+ return getGitChangedScanFiles(rootDir, ignorePatterns, parsed);
69
+ }
70
+
71
+ return {
72
+ ...walkFiles(rootDir, ignorePatterns),
73
+ scanScope: {
74
+ mode: 'full',
75
+ },
76
+ };
77
+ }
78
+
79
+ function getGitChangedScanFiles(rootDir, ignorePatterns, parsed) {
80
+ const args = parsed.staged
81
+ ? ['diff', '--name-only', '--cached', '--diff-filter=ACMR', '--']
82
+ : ['diff', '--name-only', '--diff-filter=ACMR', parsed.since, '--'];
83
+ let output = '';
84
+
85
+ try {
86
+ output = execFileSync('git', args, {
87
+ cwd: process.cwd(),
88
+ encoding: 'utf8',
89
+ stdio: ['ignore', 'pipe', 'pipe'],
90
+ });
91
+ } catch (err) {
92
+ const stderr = err.stderr ? String(err.stderr).trim() : err.message;
93
+ throw new Error(`Unable to read changed files from git: ${stderr}`);
94
+ }
95
+
96
+ const cssFiles = [];
97
+ const templateFiles = [];
98
+ const ignoreMatchers = createIgnoreMatchers(ignorePatterns);
99
+ const seen = new Set();
100
+ const changedFiles = output.split(/\r?\n/)
101
+ .map((filePath) => filePath.trim())
102
+ .filter(Boolean);
103
+
104
+ for (const filePath of changedFiles) {
105
+ const fullPath = path.resolve(process.cwd(), filePath);
106
+
107
+ if (!isPathInside(rootDir, fullPath) || seen.has(fullPath) || !fs.existsSync(fullPath)) {
108
+ continue;
109
+ }
110
+
111
+ const stat = fs.statSync(fullPath);
112
+ if (!stat.isFile()) {
113
+ continue;
114
+ }
115
+
116
+ const relativePath = toPosixRelativePath(rootDir, fullPath);
117
+ if (shouldIgnoreRelativeFile(relativePath, ignoreMatchers)) {
118
+ continue;
119
+ }
120
+
121
+ seen.add(fullPath);
122
+ if (isCssFile(fullPath)) {
123
+ cssFiles.push(fullPath);
124
+ } else if (isTemplateFile(fullPath)) {
125
+ templateFiles.push(fullPath);
126
+ }
127
+ }
128
+
129
+ return {
130
+ cssFiles,
131
+ scanScope: {
132
+ changedFiles: changedFiles.length,
133
+ mode: parsed.staged ? 'staged' : 'since',
134
+ since: parsed.since,
135
+ },
136
+ templateFiles,
137
+ };
138
+ }
139
+
140
+ function isPathInside(rootDir, filePath) {
141
+ const relativePath = path.relative(rootDir, filePath);
142
+ return relativePath === '' || (!relativePath.startsWith('..') && !path.isAbsolute(relativePath));
143
+ }
144
+
145
+ function shouldIgnorePath(relativePath, entry, ignoreMatchers) {
146
+ return ignoreMatchers.some((matcher) => matcher.test(relativePath))
147
+ || (entry.isDirectory() && SKIP_DIRS.has(entry.name));
148
+ }
149
+
150
+ function shouldIgnoreRelativeFile(relativePath, ignoreMatchers) {
151
+ const segments = relativePath.split('/');
152
+ return segments.some((segment) => SKIP_DIRS.has(segment))
153
+ || ignoreMatchers.some((matcher) => matcher.test(relativePath));
154
+ }
155
+
156
+ function createIgnoreMatchers(patterns) {
157
+ const variants = new Set();
158
+
159
+ for (const pattern of patterns) {
160
+ addIgnorePatternVariants(variants, pattern);
161
+ }
162
+
163
+ return Array.from(variants, (pattern) => globToRegExp(pattern));
164
+ }
165
+
166
+ function addIgnorePatternVariants(variants, pattern) {
167
+ if (!pattern) {
168
+ return;
169
+ }
170
+
171
+ variants.add(pattern);
172
+
173
+ if (!pattern.includes('/')) {
174
+ variants.add(`${pattern}/**`);
175
+ variants.add(`**/${pattern}`);
176
+ variants.add(`**/${pattern}/**`);
177
+ return;
178
+ }
179
+
180
+ if (pattern.endsWith('/**')) {
181
+ variants.add(pattern.slice(0, -3));
182
+ return;
183
+ }
184
+
185
+ if (!hasGlob(pattern)) {
186
+ variants.add(`${pattern}/**`);
187
+ }
188
+ }
189
+
190
+ function hasGlob(pattern) {
191
+ return /[*?]/.test(pattern);
192
+ }
193
+
194
+ function globToRegExp(pattern) {
195
+ let source = '^';
196
+
197
+ for (let index = 0; index < pattern.length; index++) {
198
+ const char = pattern[index];
199
+ const nextChar = pattern[index + 1];
200
+
201
+ if (char === '*' && nextChar === '*') {
202
+ source += '.*';
203
+ index++;
204
+ continue;
205
+ }
206
+
207
+ if (char === '*') {
208
+ source += '[^/]*';
209
+ continue;
210
+ }
211
+
212
+ if (char === '?') {
213
+ source += '[^/]';
214
+ continue;
215
+ }
216
+
217
+ source += escapeRegExp(char);
218
+ }
219
+
220
+ return new RegExp(`${source}$`);
221
+ }
222
+
223
+ function escapeRegExp(value) {
224
+ return value.replace(/[|\\{}()[\]^$+*?.]/g, '\\$&');
225
+ }
226
+
227
+ function toPosixRelativePath(rootDir, filePath) {
228
+ return path.relative(rootDir, filePath).split(path.sep).join('/');
229
+ }
230
+
231
+ function isScssFile(filePath) {
232
+ return filePath.endsWith('.scss');
233
+ }
234
+
235
+ // "CSS files" in the audit means authored stylesheets: .css always, .scss too;
236
+ // whether .scss can be parsed is decided when the run starts (stylesheets.js).
237
+ function isCssFile(filePath) {
238
+ return filePath.endsWith('.css') || isScssFile(filePath);
239
+ }
240
+
241
+ function isTemplateFile(filePath) {
242
+ return TEMPLATE_EXTENSIONS.has(path.extname(filePath));
243
+ }
244
+
245
+ module.exports = {
246
+ addIgnorePatternVariants,
247
+ assertDirectory,
248
+ createIgnoreMatchers,
249
+ escapeRegExp,
250
+ getGitChangedScanFiles,
251
+ getScanFiles,
252
+ globToRegExp,
253
+ hasGlob,
254
+ isCssFile,
255
+ isPathInside,
256
+ isScssFile,
257
+ isTemplateFile,
258
+ shouldIgnorePath,
259
+ shouldIgnoreRelativeFile,
260
+ toPosixRelativePath,
261
+ walkFiles,
262
+ };