stylelint-plugin-rhythmguard 2.2.0 → 3.1.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/audit/scan.js CHANGED
@@ -230,8 +230,33 @@ function toPosixRelativePath(rootDir, filePath) {
230
230
  return path.relative(rootDir, filePath).split(path.sep).join('/');
231
231
  }
232
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).
233
239
  function isCssFile(filePath) {
234
- return filePath.endsWith('.css');
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;
235
260
  }
236
261
 
237
262
  function isTemplateFile(filePath) {
@@ -240,7 +265,10 @@ function isTemplateFile(filePath) {
240
265
 
241
266
  async function runStylelintAudit(cssFiles, options) {
242
267
  if (cssFiles.length === 0) {
243
- return [];
268
+ const empty = [];
269
+ empty.scssFiles = 0;
270
+ empty.scssSkipped = 0;
271
+ return empty;
244
272
  }
245
273
 
246
274
  const { default: stylelint } = await import('stylelint');
@@ -274,23 +302,52 @@ async function runStylelintAudit(cssFiles, options) {
274
302
  ];
275
303
  }
276
304
 
277
- const result = await stylelint.lint({
278
- files: cssFiles,
279
- config: {
280
- plugins: [pluginPath],
281
- rules,
282
- },
283
- });
305
+ const plainFiles = cssFiles.filter((file) => !isScssFile(file));
306
+ const scssFiles = cssFiles.filter(isScssFile);
307
+ const results = [];
284
308
 
285
- return result.results || [];
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;
286
341
  }
287
342
 
288
343
  function collectCssFindings(fileResults) {
289
344
  const findings = [];
345
+ const sources = new Map();
290
346
 
291
347
  for (const fileResult of fileResults) {
292
348
  for (const warning of fileResult.warnings || []) {
293
349
  const text = warning.text || '';
350
+ const source = readSourceOnce(sources, fileResult.source);
294
351
  const offScaleMatch = text.match(
295
352
  /Unexpected (?:off-scale value|transform translation value) "([^"]+)"/,
296
353
  );
@@ -308,6 +365,9 @@ function collectCssFindings(fileResults) {
308
365
  column: warning.column || 1,
309
366
  file: formatPath(fileResult.source),
310
367
  line: warning.line || 1,
368
+ property: source === null
369
+ ? null
370
+ : findDeclarationProperty(source, warning.line || 1, warning.column || 1),
311
371
  rule: warning.rule || 'rhythmguard',
312
372
  text,
313
373
  type: getCssFindingType({ motionDurationMatch, motionEasingMatch, tokenMatch }),
@@ -324,6 +384,73 @@ function collectCssFindings(fileResults) {
324
384
  return findings;
325
385
  }
326
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
+
327
454
  function getCssFindingValue({
328
455
  motionDurationMatch,
329
456
  motionEasingMatch,
@@ -526,6 +653,7 @@ module.exports = {
526
653
  collectTailwindMotionFindings,
527
654
  createIgnoreMatchers,
528
655
  escapeRegExp,
656
+ findDeclarationProperty,
529
657
  findStringLiterals,
530
658
  getCssFindingType,
531
659
  getCssFindingValue,
@@ -535,6 +663,8 @@ module.exports = {
535
663
  globToRegExp,
536
664
  hasGlob,
537
665
  isCssFile,
666
+ isScssFile,
667
+ resolveScssSyntax,
538
668
  isPathInside,
539
669
  isTemplateFile,
540
670
  offsetToLineColumn,
package/src/cli/init.js CHANGED
@@ -92,9 +92,6 @@ function detect() {
92
92
  }
93
93
 
94
94
  function selectProfile(stack) {
95
- if (stack.nextjs && stack.tailwind) {
96
- return 'react-tailwind';
97
- }
98
95
  if (stack.tailwind) {
99
96
  return 'tailwind';
100
97
  }
@@ -140,6 +137,8 @@ async function run() {
140
137
 
141
138
  const config = {
142
139
  extends: [`stylelint-plugin-rhythmguard/configs/${profile}`],
140
+ // Next.js build output is generated CSS; never lint it.
141
+ ...(stack.nextjs ? { ignoreFiles: ['.next/**', 'out/**', 'node_modules/**'] } : {}),
143
142
  };
144
143
 
145
144
  const configPath = path.join(process.cwd(), '.stylelintrc.json');
@@ -14,6 +14,7 @@ const path = require('node:path');
14
14
 
15
15
  const { createAuditReport } = require('../audit/report');
16
16
  const { detect } = require('./init');
17
+ const { discoverTokenPackages } = require('../utils/scale-inference');
17
18
 
18
19
  const TOKEN_FILE_PATTERN = /(^|[.-])tokens?\.json$/i;
19
20
  const TOKEN_DIRS = ['tokens', 'design-tokens', path.join('src', 'tokens'), path.join('dist', 'tokens')];
@@ -76,13 +77,14 @@ function topEntries(counts, limit = MAX_LISTED) {
76
77
  .slice(0, limit);
77
78
  }
78
79
 
79
- function suggestedStylelintConfig({ profile, tokenFiles }) {
80
+ function suggestedStylelintConfig({ nextjs = false, profile, tokenFiles }) {
80
81
  const ruleOptions = { scale: 'auto' };
81
82
  if (tokenFiles.length > 0) {
82
83
  ruleOptions.scaleSources = tokenFiles.map(toPosix);
83
84
  }
84
85
  return {
85
86
  extends: [`stylelint-plugin-rhythmguard/configs/${profile}`],
87
+ ...(nextjs ? { ignoreFiles: ['.next/**', 'out/**', 'node_modules/**'] } : {}),
86
88
  rules: {
87
89
  'rhythmguard/use-scale': [true, ruleOptions],
88
90
  },
@@ -127,6 +129,8 @@ async function run() {
127
129
  out.push(` Next.js ${stack.nextjs ? 'yes' : 'no'}`);
128
130
  out.push(` Stylelint config ${stack.hasExistingConfig ? 'present' : 'none'}`);
129
131
  out.push(` Token files ${tokenFiles.length > 0 ? tokenFiles.join(', ') : 'none found'}`);
132
+ const tokenPackages = [...new Set(discoverTokenPackages(cwd).map((source) => source.package))];
133
+ out.push(` Token packages ${tokenPackages.length > 0 ? tokenPackages.join(', ') : 'none installed'}`);
130
134
  out.push(` .rhythmguardrc ${rcPresent ? 'present (its token sources are used)' : 'none'}`);
131
135
  out.push('');
132
136
 
@@ -173,15 +177,19 @@ async function run() {
173
177
  if (topValues.length > 0) {
174
178
  out.push(` Top values ${topValues.map(([value, count]) => `${value} ×${count}`).join(', ')}`);
175
179
  }
180
+ const topProperties = topEntries(report.offScaleProperties);
181
+ if (topProperties.length > 0) {
182
+ out.push(` Top properties ${topProperties.map(([property, count]) => `${property} ×${count}`).join(', ')}`);
183
+ }
176
184
  const topFiles = (report.topAffectedFiles || []).slice(0, 3);
177
185
  if (topFiles.length > 0) {
178
186
  out.push(` Top files ${topFiles.map((entry) => `${entry.file} (${entry.count})`).join(', ')}`);
179
187
  }
180
188
  out.push('');
181
189
 
182
- const profile = stack.tailwind ? (stack.nextjs ? 'react-tailwind' : 'tailwind') : 'recommended';
190
+ const profile = stack.tailwind ? 'tailwind' : 'recommended';
183
191
  out.push(` Paste this into .stylelintrc.json${stack.hasExistingConfig ? ' (merge with your existing config)' : ''}:`, '');
184
- out.push(JSON.stringify(suggestedStylelintConfig({ profile, tokenFiles }), null, 2).replace(/^/gm, ' '));
192
+ out.push(JSON.stringify(suggestedStylelintConfig({ nextjs: stack.nextjs, profile, tokenFiles }), null, 2).replace(/^/gm, ' '));
185
193
  out.push('');
186
194
 
187
195
  if (stack.tailwind) {
package/src/index.js CHANGED
@@ -19,11 +19,7 @@ module.exports.configs = {
19
19
  recommended: require('./configs/recommended'),
20
20
  strict: require('./configs/strict'),
21
21
  tailwind: require('./configs/tailwind'),
22
- expanded: require('./configs/expanded'),
23
- logical: require('./configs/logical'),
24
- migration: require('./configs/migration'),
25
22
  motion: require('./configs/motion'),
26
- 'react-tailwind': require('./configs/react-tailwind'),
27
23
  };
28
24
  module.exports.eslint = require('./eslint');
29
25
  module.exports.presets = require('./presets');
@@ -5,7 +5,7 @@ const path = require('node:path');
5
5
 
6
6
  const { parseLengthToken, toPx } = require('./length');
7
7
  const { buildEffectiveTokenMap } = require('./token-map');
8
- const { parseTokenSources } = require('./token-sources');
8
+ const { collectScssTokens, createTokenKindMatcher, parseTokenSources } = require('./token-sources');
9
9
  const { getScalePreset } = require('../presets/scales');
10
10
 
11
11
  // Matches the audit default so lint and audit agree on what a spacing token is.
@@ -21,6 +21,80 @@ const MIN_INFERRED_SCALE_LENGTH = 4;
21
21
  const RC_FILE = '.rhythmguardrc.json';
22
22
 
23
23
  const sourceCache = new Map();
24
+ const TOKEN_PACKAGES = require('./token-packages.json').packages;
25
+
26
+ /**
27
+ * Installed design-token packages that ship a spacing scale (allowlist in
28
+ * token-packages.json). Resolved from the project, so only what the project
29
+ * actually depends on is read. Returns token-source entries.
30
+ */
31
+ function readDirectDependencies(dir) {
32
+ try {
33
+ const pkg = JSON.parse(fs.readFileSync(path.join(dir, 'package.json'), 'utf8'));
34
+ return new Set([
35
+ ...Object.keys(pkg.dependencies || {}),
36
+ ...Object.keys(pkg.devDependencies || {}),
37
+ ...Object.keys(pkg.peerDependencies || {}),
38
+ ]);
39
+ } catch {
40
+ return null;
41
+ }
42
+ }
43
+
44
+ /**
45
+ * Project roots from cwd up to the repository boundary (first directory holding
46
+ * .git), each with the dependencies it declares directly. Hoisted monorepo
47
+ * installs are found by walking up; a stray global node_modules is never
48
+ * consulted because the walk stops at the repository.
49
+ */
50
+ function projectRoots(cwd) {
51
+ const roots = [];
52
+ let current = path.resolve(cwd);
53
+ for (;;) {
54
+ const direct = readDirectDependencies(current);
55
+ if (direct) {
56
+ roots.push({ dir: current, direct });
57
+ }
58
+ const parent = path.dirname(current);
59
+ if (fs.existsSync(path.join(current, '.git')) || parent === current) {
60
+ break;
61
+ }
62
+ current = parent;
63
+ }
64
+ return roots;
65
+ }
66
+
67
+ function discoverTokenPackages(cwd = process.cwd()) {
68
+ const sources = [];
69
+ const roots = projectRoots(cwd);
70
+ for (const entry of TOKEN_PACKAGES) {
71
+ // Only packages the project depends on directly count. A transitive
72
+ // tailwindcss (for example via stylelint-config-tailwindcss) must not hand
73
+ // a non-Tailwind project the Tailwind scale.
74
+ const owner = roots.find((root) => root.direct.has(entry.name));
75
+ if (!owner) {
76
+ continue;
77
+ }
78
+ const root = roots
79
+ .map((candidate) => path.join(candidate.dir, 'node_modules', entry.name))
80
+ .find((dir) => fs.existsSync(path.join(dir, 'package.json')));
81
+ if (!root) {
82
+ continue;
83
+ }
84
+ for (const file of entry.files) {
85
+ const resolved = path.join(root, file);
86
+ if (fs.existsSync(resolved)) {
87
+ sources.push({
88
+ format: 'auto',
89
+ package: entry.name,
90
+ path: resolved,
91
+ ...(entry.tokenPattern ? { tokenPattern: entry.tokenPattern } : {}),
92
+ });
93
+ }
94
+ }
95
+ }
96
+ return sources;
97
+ }
24
98
 
25
99
  function pxValuesFromKeys(keys, baseFontSize) {
26
100
  const values = new Set([0]);
@@ -54,6 +128,7 @@ function normalizeSource(source, baseDir) {
54
128
  return {
55
129
  format: typeof source.format === 'string' ? source.format : 'auto',
56
130
  path: path.resolve(source.baseDir || baseDir, rawPath),
131
+ ...(source.tokenPattern ? { tokenPattern: source.tokenPattern } : {}),
57
132
  };
58
133
  }
59
134
 
@@ -69,7 +144,7 @@ function cacheKey(sources) {
69
144
  } catch {
70
145
  // missing file: key still changes when it appears
71
146
  }
72
- return `${source.path}|${source.format}|${mtime}`;
147
+ return `${source.path}|${source.format}|${source.tokenPattern || ''}|${mtime}`;
73
148
  })
74
149
  .join('\n');
75
150
  }
@@ -86,13 +161,9 @@ function scaleFromSources(sources, baseFontSize) {
86
161
  }
87
162
 
88
163
  const parsed = parseTokenSources({ baseFontSize, sources: normalized, tokenKind: 'spacing' });
89
- const keys = [];
90
- for (const definition of parsed.definitions.values()) {
91
- keys.push(...definition.normalizedValues);
92
- }
93
-
94
- const scale = pxValuesFromKeys(keys, baseFontSize);
95
- const outcome = scale.length >= MIN_INFERRED_SCALE_LENGTH
164
+ // scaleFromDefinitions also expands a bare Tailwind --spacing base into its multiples.
165
+ const scale = scaleFromDefinitions(parsed.definitions, baseFontSize);
166
+ const outcome = scale
96
167
  ? {
97
168
  files: parsed.sources.map((source) => source.file),
98
169
  scale,
@@ -157,8 +228,27 @@ function rcTokenSources(cwd) {
157
228
  .filter(Boolean);
158
229
  }
159
230
 
160
- function scaleFromTokenMap(map, baseFontSize) {
161
- const keys = [];
231
+ /**
232
+ * Sass variables and maps declared in the linted stylesheet itself (postcss-scss
233
+ * exposes them as declarations whose prop starts with `$`). Evaluated with the
234
+ * same collector the audit uses; component variables such as $dropdown-spacer
235
+ * are excluded by the anchored name rule.
236
+ */
237
+ function sassValuesFromRoot(root) {
238
+ const lines = [];
239
+ root.walkDecls((decl) => {
240
+ if (typeof decl.prop === 'string' && decl.prop.startsWith('$')) {
241
+ lines.push(`${decl.prop}: ${decl.value};`);
242
+ }
243
+ });
244
+ if (lines.length === 0) {
245
+ return [];
246
+ }
247
+ return collectScssTokens(lines.join('\n'), createTokenKindMatcher('spacing')).map((token) => token.value);
248
+ }
249
+
250
+ function scaleFromTokenMap(map, baseFontSize, extraKeys = []) {
251
+ const keys = [...extraKeys];
162
252
  const baseKeys = [];
163
253
  for (const [key, reference] of Object.entries(map)) {
164
254
  const name = String(reference).match(/^var\((--[\w-]+)\)$/);
@@ -206,7 +296,7 @@ function resolveAutoScale({
206
296
  root,
207
297
  tokenRegex,
208
298
  });
209
- const scale = scaleFromTokenMap(stylesheetMap, baseFontSize);
299
+ const scale = scaleFromTokenMap(stylesheetMap, baseFontSize, sassValuesFromRoot(root));
210
300
  if (scale) {
211
301
  return { files: [], scale, source: 'stylesheet', tokenCount: scale.length - 1, warnings: [] };
212
302
  }
@@ -229,6 +319,11 @@ function resolveAutoScale({
229
319
  }
230
320
  }
231
321
 
322
+ const fromPackages = scaleFromSources(discoverTokenPackages(process.cwd()), baseFontSize);
323
+ if (fromPackages) {
324
+ return { source: 'token-package', ...fromPackages };
325
+ }
326
+
232
327
  return {
233
328
  files: [],
234
329
  preset: FALLBACK_PRESET,
@@ -250,6 +345,7 @@ function autoScaleFallbackNote(inference) {
250
345
  module.exports = {
251
346
  DEFAULT_AUTO_TOKEN_PATTERN,
252
347
  autoScaleFallbackNote,
348
+ discoverTokenPackages,
253
349
  resolveAutoScale,
254
350
  scaleFromDefinitions,
255
351
  };
@@ -0,0 +1,48 @@
1
+ {
2
+ "$comment": "Design-token packages whose installed files declare a spacing scale. Used by scale: \"auto\" after the project's own stylesheets are checked. Each entry lists the files to read (relative to the package root) and, when the package does not use space/spacing names, the token pattern to match. Additions welcome; keep them to files that ship in the published package.",
3
+ "packages": [
4
+ {
5
+ "name": "tailwindcss",
6
+ "files": [
7
+ "theme.css"
8
+ ],
9
+ "note": "Tailwind v4 base multiplier --spacing"
10
+ },
11
+ {
12
+ "name": "@radix-ui/themes",
13
+ "files": [
14
+ "tokens.css"
15
+ ],
16
+ "note": "--space-1..9 as calc(<px> * var(--scaling))"
17
+ },
18
+ {
19
+ "name": "@mantine/core",
20
+ "files": [
21
+ "styles.css"
22
+ ],
23
+ "note": "--mantine-spacing-* as calc(<rem> * var(--mantine-scale))"
24
+ },
25
+ {
26
+ "name": "@primer/primitives",
27
+ "files": [
28
+ "dist/css/base/size/size.css"
29
+ ],
30
+ "tokenPattern": "^--base-size-\\d+$",
31
+ "note": "Primer names its spacing ladder size"
32
+ },
33
+ {
34
+ "name": "@shopify/polaris-tokens",
35
+ "files": [
36
+ "dist/css/styles.css"
37
+ ],
38
+ "note": "--p-space-* in rem"
39
+ },
40
+ {
41
+ "name": "@spectrum-css/tokens",
42
+ "files": [
43
+ "dist/css/global-vars.css"
44
+ ],
45
+ "note": "--spectrum-spacing-* in px"
46
+ }
47
+ ]
48
+ }