stylelint-plugin-rhythmguard 2.1.0 → 3.0.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 (39) hide show
  1. package/CHANGELOG.md +49 -0
  2. package/CONTRIBUTING.md +66 -50
  3. package/README.md +17 -6
  4. package/SECURITY.md +2 -2
  5. package/package.json +27 -27
  6. package/scales/community/product-decimal-10.json +27 -4
  7. package/src/audit/args.js +6 -1
  8. package/src/audit/config.js +15 -5
  9. package/src/audit/contract.js +20 -0
  10. package/src/audit/render-markdown.js +13 -0
  11. package/src/audit/render-text.js +9 -1
  12. package/src/audit/report.js +105 -8
  13. package/src/audit/scan.js +67 -10
  14. package/src/cli/index.js +11 -5
  15. package/src/cli/init.js +7 -4
  16. package/src/cli/quickstart.js +207 -0
  17. package/src/configs/embed.js +24 -0
  18. package/src/configs/{logical.mjs → embed.mjs} +1 -1
  19. package/src/index.js +1 -4
  20. package/src/rules/no-offscale-transform/index.js +22 -0
  21. package/src/rules/prefer-token/index.js +28 -0
  22. package/src/rules/use-scale/index.js +34 -4
  23. package/src/utils/length.js +16 -0
  24. package/src/utils/options.js +53 -3
  25. package/src/utils/scale-inference.js +351 -0
  26. package/src/utils/token-map.js +4 -2
  27. package/src/utils/token-packages.json +48 -0
  28. package/src/utils/token-sources.js +313 -4
  29. package/types/__checks__/consumer.ts +10 -6
  30. package/types/audit.d.ts +21 -1
  31. package/types/index.d.ts +4 -4
  32. package/types/shared.d.ts +24 -1
  33. package/src/configs/expanded.js +0 -27
  34. package/src/configs/expanded.mjs +0 -4
  35. package/src/configs/logical.js +0 -16
  36. package/src/configs/migration.js +0 -31
  37. package/src/configs/migration.mjs +0 -4
  38. package/src/configs/react-tailwind.js +0 -25
  39. package/src/configs/react-tailwind.mjs +0 -4
@@ -1,6 +1,9 @@
1
1
  'use strict';
2
2
 
3
+ const fs = require('node:fs');
4
+
3
5
  const {
6
+ createTokenKindMatcher,
4
7
  parseTokenSources,
5
8
  } = require('../utils/token-sources');
6
9
  const {
@@ -13,7 +16,9 @@ const {
13
16
  loadAuditConfig,
14
17
  loadIgnorePatterns,
15
18
  } = require('./config');
16
- const { buildReport } = require('./contract');
19
+ const { buildReport, collectTokenDefinitions } = require('./contract');
20
+ const { DEFAULT_SCALE, formatPath } = require('./shared');
21
+ const { discoverTokenPackages, scaleFromDefinitions } = require('../utils/scale-inference');
17
22
  const {
18
23
  assertDirectory,
19
24
  collectCssFindings,
@@ -35,19 +40,27 @@ async function createAuditReport(options) {
35
40
  const scanFiles = getScanFiles(resolvedDir, ignorePatterns, parsed);
36
41
 
37
42
  const { cssFiles, scanScope, templateFiles } = scanFiles;
38
- const lintOptions = {
39
- baseFontSize: parsed.baseFontSize,
40
- includeMotion: parsed.includeMotion,
41
- scale: parsed.scale,
42
- };
43
-
44
- const cssResults = await runStylelintAudit(cssFiles, lintOptions);
45
43
 
46
44
  const tokenSourceResult = parseTokenSources({
47
45
  baseFontSize: parsed.baseFontSize,
48
46
  sources: parsed.tokenSources,
49
47
  tokenKind: parsed.tokenKind,
50
48
  });
49
+
50
+ const scale = resolveAuditScale({
51
+ baseFontSize: parsed.baseFontSize,
52
+ cssFiles,
53
+ requested: parsed.scale,
54
+ tokenSourceResult,
55
+ });
56
+
57
+ const lintOptions = {
58
+ baseFontSize: parsed.baseFontSize,
59
+ includeMotion: parsed.includeMotion,
60
+ scale: scale.values,
61
+ };
62
+
63
+ const cssResults = await runStylelintAudit(cssFiles, lintOptions);
51
64
  const stylelintFindings = collectCssFindings(cssResults);
52
65
  const cssFindings = stylelintFindings.filter((finding) => !finding.type.startsWith('motion-'));
53
66
  const motionFindings = [
@@ -64,7 +77,10 @@ async function createAuditReport(options) {
64
77
  externalTokenDefinitions: tokenSourceResult.definitions,
65
78
  includeMotion: parsed.includeMotion,
66
79
  motionFindings,
80
+ scale,
67
81
  scanScope,
82
+ scssFiles: cssResults.scssFiles || 0,
83
+ scssSkipped: cssResults.scssSkipped || 0,
68
84
  tailwindFindings: collectTailwindFindings(templateFiles, lintOptions),
69
85
  templateFiles,
70
86
  tokenCandidateMinCount: parsed.tokenCandidateMinCount,
@@ -84,6 +100,87 @@ async function createAuditReport(options) {
84
100
  return report;
85
101
  }
86
102
 
103
+ const NON_AUTHORED_SEGMENT = /(^|\/)(test|tests|__tests__|spec|specs|fixtures?|__fixtures__|__snapshots__|vendor|third[-_]?party|storybook-static)(\/|$)/i;
104
+
105
+ /**
106
+ * One project-level scale for the whole audit, with provenance.
107
+ * "auto": external token sources, then spacing custom properties across the scanned
108
+ * CSS, then the default scale. First source that yields values wins.
109
+ */
110
+ function resolveAuditScale({ baseFontSize, cssFiles, requested, tokenSourceResult }) {
111
+ if (Array.isArray(requested)) {
112
+ return {
113
+ files: [],
114
+ source: requested === DEFAULT_SCALE ? 'default' : 'explicit',
115
+ tokenCount: 0,
116
+ values: requested,
117
+ };
118
+ }
119
+
120
+ if (tokenSourceResult.definitions.size > 0) {
121
+ const values = scaleFromDefinitions(tokenSourceResult.definitions, baseFontSize);
122
+ if (values) {
123
+ return {
124
+ files: tokenSourceResult.sources.map((source) => source.file),
125
+ source: 'token-sources',
126
+ tokenCount: tokenSourceResult.definitions.size,
127
+ values,
128
+ };
129
+ }
130
+ }
131
+
132
+ const definitions = new Map();
133
+ const matchesKind = createTokenKindMatcher('spacing');
134
+ for (const filePath of cssFiles) {
135
+ // Test, fixture and vendored stylesheets often redefine tokens to exercise
136
+ // overrides; they are findings noise and must not shape the inferred scale.
137
+ if (NON_AUTHORED_SEGMENT.test(formatPath(filePath))) {
138
+ continue;
139
+ }
140
+ let text;
141
+ try {
142
+ text = fs.readFileSync(filePath, 'utf8');
143
+ } catch {
144
+ continue;
145
+ }
146
+ collectTokenDefinitions(text, formatPath(filePath), definitions, matchesKind, baseFontSize);
147
+ }
148
+
149
+ if (definitions.size > 0) {
150
+ const values = scaleFromDefinitions(definitions, baseFontSize);
151
+ if (values) {
152
+ const files = new Set();
153
+ for (const definition of definitions.values()) {
154
+ for (const file of definition.files) {
155
+ files.add(file);
156
+ }
157
+ }
158
+ return {
159
+ files: Array.from(files).sort(),
160
+ source: 'scanned-css',
161
+ tokenCount: definitions.size,
162
+ values,
163
+ };
164
+ }
165
+ }
166
+
167
+ const packageSources = discoverTokenPackages(process.cwd());
168
+ if (packageSources.length > 0) {
169
+ const parsedPackages = parseTokenSources({ baseFontSize, sources: packageSources, tokenKind: 'spacing' });
170
+ const values = scaleFromDefinitions(parsedPackages.definitions, baseFontSize);
171
+ if (values) {
172
+ return {
173
+ files: parsedPackages.sources.map((source) => source.file),
174
+ source: 'token-package',
175
+ tokenCount: parsedPackages.definitions.size,
176
+ values,
177
+ };
178
+ }
179
+ }
180
+
181
+ return { files: [], source: 'fallback', tokenCount: 0, values: DEFAULT_SCALE };
182
+ }
183
+
87
184
  function normalizeCreateAuditOptions(options = {}) {
88
185
  const parsed = {
89
186
  ...createDefaultAuditOptions(),
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,15 +302,42 @@ 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 = [];
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
+ }
284
337
 
285
- return result.results || [];
338
+ results.scssFiles = scssFiles.length;
339
+ results.scssSkipped = scssSkipped;
340
+ return results;
286
341
  }
287
342
 
288
343
  function collectCssFindings(fileResults) {
@@ -535,6 +590,8 @@ module.exports = {
535
590
  globToRegExp,
536
591
  hasGlob,
537
592
  isCssFile,
593
+ isScssFile,
594
+ resolveScssSyntax,
538
595
  isPathInside,
539
596
  isTemplateFile,
540
597
  offsetToLineColumn,
package/src/cli/index.js CHANGED
@@ -3,9 +3,13 @@
3
3
 
4
4
  const command = process.argv[2];
5
5
 
6
- const HELP = `Usage: rhythmguard <command>
6
+ const HELP = `Usage: rhythmguard [command]
7
+
8
+ With no command: zero-config quickstart. Detects your stack and tokens, infers
9
+ the spacing scale, audits the current directory and prints a config to paste.
7
10
 
8
11
  Commands:
12
+ quickstart Same as running with no command
9
13
  audit <dir> Report design-system drift across CSS and Tailwind class strings
10
14
  init Scaffold a Rhythmguard config for your project
11
15
  doctor Validate your Rhythmguard setup
@@ -14,22 +18,24 @@ Options:
14
18
  --help Show this help message
15
19
 
16
20
  Examples:
21
+ npx rhythmguard
17
22
  npx rhythmguard audit ./src
18
- npx rhythmguard audit ./src --json
19
23
  npx rhythmguard audit ./src --format markdown
20
24
  npx rhythmguard init
21
25
  npx rhythmguard doctor
22
26
  `;
23
27
 
24
- if (!command || command === '--help' || command === '-h') {
28
+ if (command === '--help' || command === '-h') {
25
29
  process.stdout.write(HELP);
26
30
  process.exit(0);
27
31
  }
28
32
 
29
- if (command === 'audit') {
33
+ if (!command || command === 'quickstart') {
34
+ require('./quickstart').run();
35
+ } else if (command === 'audit') {
30
36
  require('./audit').run();
31
37
  } else if (command === 'init') {
32
- require('./init');
38
+ require('./init').run();
33
39
  } else if (command === 'doctor') {
34
40
  require('./doctor');
35
41
  } else {
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');
@@ -153,4 +152,8 @@ async function run() {
153
152
  }
154
153
  }
155
154
 
156
- run();
155
+ module.exports = { detect, run, selectProfile };
156
+
157
+ if (require.main === module) {
158
+ run();
159
+ }
@@ -0,0 +1,207 @@
1
+ 'use strict';
2
+
3
+ /**
4
+ * `npx rhythmguard` with no arguments.
5
+ *
6
+ * The first minute, with nothing to configure: detect the stack and any token
7
+ * files, infer the spacing scale from the project's own tokens, audit the
8
+ * current directory, and end with the exact config to paste. Never fails the
9
+ * process on findings; this is a report, not a gate.
10
+ */
11
+
12
+ const fs = require('node:fs');
13
+ const path = require('node:path');
14
+
15
+ const { createAuditReport } = require('../audit/report');
16
+ const { detect } = require('./init');
17
+ const { discoverTokenPackages } = require('../utils/scale-inference');
18
+
19
+ const TOKEN_FILE_PATTERN = /(^|[.-])tokens?\.json$/i;
20
+ const TOKEN_DIRS = ['tokens', 'design-tokens', path.join('src', 'tokens'), path.join('dist', 'tokens')];
21
+ const MAX_LISTED = 5;
22
+
23
+ function readPackage(cwd) {
24
+ try {
25
+ return JSON.parse(fs.readFileSync(path.join(cwd, 'package.json'), 'utf8'));
26
+ } catch {
27
+ return {};
28
+ }
29
+ }
30
+
31
+ function tailwindMajor(pkg) {
32
+ const deps = { ...pkg.dependencies, ...pkg.devDependencies };
33
+ if (deps['@tailwindcss/postcss'] || deps['@tailwindcss/vite']) {
34
+ return 4;
35
+ }
36
+ const spec = deps.tailwindcss;
37
+ if (!spec) {
38
+ return null;
39
+ }
40
+ const match = String(spec).match(/(\d+)/);
41
+ return match ? Number(match[1]) : null;
42
+ }
43
+
44
+ function findTokenFiles(cwd) {
45
+ const found = new Set();
46
+ const consider = (dir) => {
47
+ let entries;
48
+ try {
49
+ entries = fs.readdirSync(dir, { withFileTypes: true });
50
+ } catch {
51
+ return;
52
+ }
53
+ for (const entry of entries) {
54
+ if (entry.isFile() && TOKEN_FILE_PATTERN.test(entry.name)) {
55
+ found.add(path.relative(cwd, path.join(dir, entry.name)));
56
+ }
57
+ }
58
+ };
59
+ consider(cwd);
60
+ for (const sub of TOKEN_DIRS) {
61
+ consider(path.join(cwd, sub));
62
+ }
63
+ return Array.from(found).sort();
64
+ }
65
+
66
+ function hasRhythmguardConfig(cwd) {
67
+ return fs.existsSync(path.join(cwd, '.rhythmguardrc.json'));
68
+ }
69
+
70
+ function toPosix(relativePath) {
71
+ return `./${relativePath.split(path.sep).join('/')}`;
72
+ }
73
+
74
+ function topEntries(counts, limit = MAX_LISTED) {
75
+ return Object.entries(counts || {})
76
+ .sort((a, b) => b[1] - a[1])
77
+ .slice(0, limit);
78
+ }
79
+
80
+ function suggestedStylelintConfig({ nextjs = false, profile, tokenFiles }) {
81
+ const ruleOptions = { scale: 'auto' };
82
+ if (tokenFiles.length > 0) {
83
+ ruleOptions.scaleSources = tokenFiles.map(toPosix);
84
+ }
85
+ return {
86
+ extends: [`stylelint-plugin-rhythmguard/configs/${profile}`],
87
+ ...(nextjs ? { ignoreFiles: ['.next/**', 'out/**', 'node_modules/**'] } : {}),
88
+ rules: {
89
+ 'rhythmguard/use-scale': [true, ruleOptions],
90
+ },
91
+ };
92
+ }
93
+
94
+ function eslintSnippet(scaleValues) {
95
+ const scale = scaleValues.filter((value) => typeof value === 'number').join(', ');
96
+ return [
97
+ "// eslint.config.js",
98
+ "import rhythmguard from 'stylelint-plugin-rhythmguard/eslint';",
99
+ '',
100
+ 'export default [',
101
+ ' {',
102
+ " plugins: { 'rhythmguard-tailwind': rhythmguard },",
103
+ ` rules: { 'rhythmguard-tailwind/tailwind-class-use-scale': ['warn', { scale: [${scale}] }] },`,
104
+ ' },',
105
+ '];',
106
+ ].join('\n');
107
+ }
108
+
109
+ function describeScale(scale) {
110
+ if (!scale) {
111
+ return 'default';
112
+ }
113
+ const files = scale.files && scale.files.length > 0 ? ` (${scale.files.slice(0, 3).join(', ')}${scale.files.length > 3 ? ', …' : ''})` : '';
114
+ return `${scale.source}${files}`;
115
+ }
116
+
117
+ async function run() {
118
+ const cwd = process.cwd();
119
+ const pkg = readPackage(cwd);
120
+ const stack = detect();
121
+ const twMajor = stack.tailwind ? tailwindMajor(pkg) : null;
122
+ const tokenFiles = findTokenFiles(cwd);
123
+ const rcPresent = hasRhythmguardConfig(cwd);
124
+ const out = [];
125
+
126
+ out.push('', ' Rhythmguard quickstart', ` ${cwd}`, '');
127
+ out.push(' Detected');
128
+ out.push(` Tailwind ${stack.tailwind ? `v${twMajor || '?'}` : 'no'}`);
129
+ out.push(` Next.js ${stack.nextjs ? 'yes' : 'no'}`);
130
+ out.push(` Stylelint config ${stack.hasExistingConfig ? 'present' : 'none'}`);
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'}`);
134
+ out.push(` .rhythmguardrc ${rcPresent ? 'present (its token sources are used)' : 'none'}`);
135
+ out.push('');
136
+
137
+ let report;
138
+ try {
139
+ report = await createAuditReport({
140
+ dir: '.',
141
+ scale: 'auto',
142
+ ...(tokenFiles.length > 0 && !rcPresent
143
+ ? { tokenSources: tokenFiles }
144
+ : {}),
145
+ });
146
+ } catch (error) {
147
+ process.stderr.write(`Quickstart could not audit ${cwd}: ${error.message}\n`);
148
+ process.exitCode = 1;
149
+ return;
150
+ }
151
+
152
+ if (report.cssFilesScanned === 0 && report.templateFilesScanned === 0) {
153
+ out.push(' No CSS files found here. Run from your project root, or audit a directory directly:');
154
+ out.push(' npx rhythmguard audit ./src', '');
155
+ process.stdout.write(`${out.join('\n')}\n`);
156
+ return;
157
+ }
158
+
159
+ const scale = report.scale;
160
+ out.push(' Scale');
161
+ out.push(` Scale ${scale.values.join(', ')}`);
162
+ out.push(` Source ${describeScale(scale)}`);
163
+ if (scale.source === 'fallback') {
164
+ out.push(' Note No spacing tokens found; the rhythmic-4 preset is being used.');
165
+ out.push(' Add scaleSources or define --space-*/--spacing-* custom properties to lint against your own scale.');
166
+ }
167
+ out.push('');
168
+
169
+ const offScale = report.findings.css.filter((finding) => finding.type === 'off-scale');
170
+ out.push(' Findings');
171
+ out.push(` CSS files ${report.cssFilesScanned}`);
172
+ out.push(` Template files ${report.templateFilesScanned}`);
173
+ out.push(` Off-scale ${offScale.length} in CSS, ${report.findings.tailwind.length} in class strings`);
174
+ out.push(` Token opps ${report.findings.css.length - offScale.length} raw values that match or deserve a token`);
175
+ out.push(` Cleanliness ${report.scaleCleanliness}%`);
176
+ const topValues = topEntries(report.offScaleValues);
177
+ if (topValues.length > 0) {
178
+ out.push(` Top values ${topValues.map(([value, count]) => `${value} ×${count}`).join(', ')}`);
179
+ }
180
+ const topFiles = (report.topAffectedFiles || []).slice(0, 3);
181
+ if (topFiles.length > 0) {
182
+ out.push(` Top files ${topFiles.map((entry) => `${entry.file} (${entry.count})`).join(', ')}`);
183
+ }
184
+ out.push('');
185
+
186
+ const profile = stack.tailwind ? 'tailwind' : 'recommended';
187
+ out.push(` Paste this into .stylelintrc.json${stack.hasExistingConfig ? ' (merge with your existing config)' : ''}:`, '');
188
+ out.push(JSON.stringify(suggestedStylelintConfig({ nextjs: stack.nextjs, profile, tokenFiles }), null, 2).replace(/^/gm, ' '));
189
+ out.push('');
190
+
191
+ if (stack.tailwind) {
192
+ out.push(' Tailwind class strings need the ESLint companion. Paste this into eslint.config.js:', '');
193
+ out.push(eslintSnippet(scale.values).replace(/^/gm, ' '));
194
+ out.push('');
195
+ }
196
+
197
+ out.push(' Next');
198
+ out.push(' npx stylelint "**/*.css" lint with the config above');
199
+ out.push(' npx rhythmguard audit . --format markdown PR-ready report');
200
+ out.push(' npx rhythmguard audit . --write-baseline then gate only new drift in CI');
201
+ out.push(' npx rhythmguard doctor check the setup');
202
+ out.push('');
203
+
204
+ process.stdout.write(`${out.join('\n')}\n`);
205
+ }
206
+
207
+ module.exports = { findTokenFiles, run, suggestedStylelintConfig, tailwindMajor };
@@ -0,0 +1,24 @@
1
+ 'use strict';
2
+
3
+ /**
4
+ * The one-liner for shared-config authors.
5
+ *
6
+ * One rule, warning level, scale inferred from the consuming project's own
7
+ * spacing tokens (scaleSources, .rhythmguardrc.json token sources, the
8
+ * stylesheet's custom properties, a Tailwind config, then rhythmic-4).
9
+ * No `extends`, no dependency beyond this plugin, and the shape is frozen for
10
+ * the life of the 2.x line so a config that embeds it never changes behaviour
11
+ * on a minor bump.
12
+ */
13
+ module.exports = {
14
+ plugins: ['stylelint-plugin-rhythmguard'],
15
+ rules: {
16
+ 'rhythmguard/use-scale': [
17
+ true,
18
+ {
19
+ scale: 'auto',
20
+ severity: 'warning',
21
+ },
22
+ ],
23
+ },
24
+ };
@@ -1,4 +1,4 @@
1
1
  import { createRequire } from 'node:module';
2
2
  const require = createRequire(import.meta.url);
3
- const config = require('./logical.js');
3
+ const config = require('./embed.js');
4
4
  export default config;
package/src/index.js CHANGED
@@ -15,14 +15,11 @@ module.exports.rules = {
15
15
  [useMotionScale.ruleName]: useMotionScale,
16
16
  };
17
17
  module.exports.configs = {
18
+ embed: require('./configs/embed'),
18
19
  recommended: require('./configs/recommended'),
19
20
  strict: require('./configs/strict'),
20
21
  tailwind: require('./configs/tailwind'),
21
- expanded: require('./configs/expanded'),
22
- logical: require('./configs/logical'),
23
- migration: require('./configs/migration'),
24
22
  motion: require('./configs/motion'),
25
- 'react-tailwind': require('./configs/react-tailwind'),
26
23
  };
27
24
  module.exports.eslint = require('./eslint');
28
25
  module.exports.presets = require('./presets');
@@ -5,6 +5,7 @@ const valueParser = require('postcss-value-parser');
5
5
  const {
6
6
  formatLength,
7
7
  fromPx,
8
+ isHairlineLength,
8
9
  nearestScaleValues,
9
10
  normalizeScale,
10
11
  normalizeScaleByUnit,
@@ -25,6 +26,11 @@ const {
25
26
  walkTransformTranslateNodes,
26
27
  } = require('../../utils/value-utils');
27
28
 
29
+ const {
30
+ DEFAULT_AUTO_TOKEN_PATTERN,
31
+ resolveAutoScale,
32
+ } = require('../../utils/scale-inference');
33
+
28
34
  const ruleName = 'rhythmguard/no-offscale-transform';
29
35
  const messages = stylelint.utils.ruleMessages(ruleName, {
30
36
  invalidPreset: (presetName, presetNames) =>
@@ -84,6 +90,18 @@ const ruleFunction = (primary, secondaryOptions) => {
84
90
  });
85
91
  }
86
92
 
93
+ if (options.scaleAuto) {
94
+ const inference = resolveAutoScale({
95
+ baseFontSize: options.baseFontSize,
96
+ root,
97
+ scaleSources: options.scaleSources,
98
+ tailwindConfigPath: options.tailwindConfigPath,
99
+ tokenPattern: options.tokenPatternExplicit ? options.tokenPattern : DEFAULT_AUTO_TOKEN_PATTERN,
100
+ });
101
+ options.scale = inference.scale;
102
+ options.scaleInference = inference;
103
+ }
104
+
87
105
  const scaleCache = new Map();
88
106
  const getScaleStateForProperty = (prop) => {
89
107
  const cached = scaleCache.get(prop);
@@ -152,6 +170,10 @@ const ruleFunction = (primary, secondaryOptions) => {
152
170
  return;
153
171
  }
154
172
 
173
+ if (options.allowHairlines && isHairlineLength(parsedLength, options.baseFontSize)) {
174
+ return;
175
+ }
176
+
155
177
  if (!options.allowNegative && parsedLength.number < 0) {
156
178
  return;
157
179
  }
@@ -4,6 +4,7 @@ const stylelint = require('stylelint');
4
4
  const valueParser = require('postcss-value-parser');
5
5
  const {
6
6
  formatLength,
7
+ isHairlineLength,
7
8
  normalizeScale,
8
9
  normalizeScaleByUnit,
9
10
  numbersEqual,
@@ -28,6 +29,11 @@ const {
28
29
  } = require('../../utils/value-utils');
29
30
  const { buildEffectiveTokenMap } = require('../../utils/token-map');
30
31
 
32
+ const {
33
+ DEFAULT_AUTO_TOKEN_PATTERN,
34
+ resolveAutoScale,
35
+ } = require('../../utils/scale-inference');
36
+
31
37
  const ruleName = 'rhythmguard/prefer-token';
32
38
 
33
39
  const messages = stylelint.utils.ruleMessages(ruleName, {
@@ -112,6 +118,18 @@ const ruleFunction = (primary, secondaryOptions) => {
112
118
  });
113
119
  }
114
120
 
121
+ if (options.scaleAuto) {
122
+ const inference = resolveAutoScale({
123
+ baseFontSize: options.baseFontSize,
124
+ root,
125
+ scaleSources: options.scaleSources,
126
+ tailwindConfigPath: options.tailwindConfigPath,
127
+ tokenPattern: options.tokenPatternExplicit ? options.tokenPattern : DEFAULT_AUTO_TOKEN_PATTERN,
128
+ });
129
+ options.scale = inference.scale;
130
+ options.scaleInference = inference;
131
+ }
132
+
115
133
  const tokenRegex = createTokenRegex(options.tokenPattern, result, ruleName);
116
134
  const tokenMap = buildEffectiveTokenMap({
117
135
  options,
@@ -196,6 +214,16 @@ const ruleFunction = (primary, secondaryOptions) => {
196
214
  return false;
197
215
  }
198
216
 
217
+ // Percentages are relative to the container or the element itself; they are
218
+ // never spacing-token candidates (translate(-50%, -50%) centering, inset: 100%).
219
+ if (parsedLength.unit === '%') {
220
+ return false;
221
+ }
222
+
223
+ if (options.allowHairlines && isHairlineLength(parsedLength, options.baseFontSize)) {
224
+ return false;
225
+ }
226
+
199
227
  if (
200
228
  parsedLength.unit &&
201
229
  parsedLength.unit !== '%' &&