stylelint-plugin-rhythmguard 2.1.0 → 2.2.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/CHANGELOG.md CHANGED
@@ -6,6 +6,27 @@ The format follows Keep a Changelog principles and semantic versioning.
6
6
 
7
7
  ## [Unreleased]
8
8
 
9
+ ## [2.2.0] - 2026-09-05
10
+
11
+ ### Added
12
+
13
+ - Added `scale: "auto"` to `rhythmguard/use-scale`, `rhythmguard/no-offscale-transform` and `rhythmguard/prefer-token`. The scale is inferred from spacing tokens: `scaleSources` files, then `.rhythmguardrc.json` audit token sources, then the linted stylesheet's custom properties, then `tailwindConfigPath`, with a `rhythmic-4` fallback that is announced in the first report of the file. First matching source wins.
14
+ - Added the `scaleSources` rule option and made `tailwindConfigPath` available to every scale rule.
15
+ - Added `rhythmguard audit --scale auto`, which infers one project-level scale from token sources, then spacing custom properties across the scanned CSS, then the default. The JSON contract now carries `contracts.scale.values`, `source` and `files`; text and Markdown output print the scale and its source.
16
+ - Added the quiet benchmark (`npm run bench:quiet`): audits public design systems with `--scale auto`, classifies findings as drift, noise or allowance, and writes `docs/QUIET_BENCHMARK.md` with a per-repo false-positive rate. Manifest and classification rules live in `benchmarks/quiet/`.
17
+ - Scale inference now reads token values written as `calc(<length> * var(--factor))` (Radix Themes), expands a bare Tailwind v4 `--spacing` base into the default multiplier scale, and matches prefixed spacing tokens such as `--lb-spacing-md` while excluding `letter-spacing` and `word-spacing`.
18
+
19
+ - Added `docs/FOR_AGENTS.md` with a paste-ready `AGENTS.md` block and the commands a coding agent should run, and a "what it is not" paragraph at the top of the README (no color checking, class strings need the ESLint companion, no SCSS in the audit).
20
+ - The quiet benchmark now pins each repository to a recorded commit and keeps snapshots of its findings under `benchmarks/quiet/snapshots/`. `npm run bench:quiet -- --check` fails when the finding set or the inferred scale changes, `--update-snapshots` accepts a reviewed change, `--latest` audits upstream heads. A farm workflow runs the check on every change and the upstream run weekly.
21
+ - `npx rhythmguard` with no command now runs a zero-config quickstart: detects Tailwind (and its major version), Next.js, an existing Stylelint config and token files; infers the spacing scale from the project's own tokens; audits the current directory; and prints the exact `.stylelintrc.json` (plus an ESLint snippet for Tailwind) to paste. `--help` is unchanged; `rhythmguard quickstart` is an explicit alias.
22
+ - Added `stylelint-plugin-rhythmguard/configs/embed`: `use-scale` at warning level with `scale: "auto"`, no `extends`, shape frozen for 2.x. The entry point for shared-config authors who want to enable spacing governance for their consumers without knowing each consumer's scale. Guide in `docs/FOR_CONFIG_AUTHORS.md`.
23
+ - Added `allowHairlines` (default `true`) to `use-scale`, `no-offscale-transform` and `prefer-token`. Non-zero lengths that resolve to one CSS pixel or less (`1px`, `-1px`, `0.5px`, `0.0625rem`) are exempt: they compensate for borders and rendering, not spacing. The quiet benchmark showed them to be the only systematic false positive left across Radix Themes, Mantine, Primer React and Liveblocks. Set `allowHairlines: false` to restore the previous reports.
24
+
25
+ ### Fixed
26
+
27
+ - `rhythmguard/prefer-token` no longer reports percentages such as `translate(-50%, -50%)` or `inset: 100%` as raw scale values. Percentages are never token candidates. This was the largest noise source in the quiet benchmark.
28
+ - `rhythmguard/use-scale` with `allowPercentages: false` now reports percentage lengths instead of silently skipping them.
29
+
9
30
  ## [2.1.0] - 2026-09-05
10
31
 
11
32
  ### Added
package/CONTRIBUTING.md CHANGED
@@ -72,6 +72,7 @@ When changing rule logic:
72
72
  2. validate deterministic fix behavior
73
73
  3. update the rule's page in `docs/rules/` if options or behavior changed
74
74
  4. update CHANGELOG
75
+ 5. run the quiet benchmark: `npm run bench:quiet -- --check`. It audits real public design systems at pinned commits and fails when the finding set or the inferred scale changes. If the change is intended, review the printed diff, run `npm run bench:quiet -- --update-snapshots`, and commit `benchmarks/quiet/snapshots/` with a sentence in the PR on why the new findings are right. CI runs the same check.
75
76
 
76
77
  ## Release Workflow
77
78
 
package/README.md CHANGED
@@ -13,7 +13,15 @@ Spacing scale and design-token governance for CSS and Tailwind. `padding: 13px`
13
13
 
14
14
  Rhythmguard is scale-aware rather than a blanket ban: values on your scale pass, values off it are reported with the two nearest steps, and tokens are only ever suggested from a map you control. It works on CSS declarations through Stylelint and on Tailwind class strings through an ESLint companion, and it ships an audit CLI so you can measure drift and ratchet it down before enforcing anything.
15
15
 
16
- ## Install
16
+ What it is not: it does not check colors or hex values, the Stylelint rules do not see Tailwind class strings (that is the separate ESLint companion below), and it does not parse SCSS in the audit. Pair it with a color linter if you need one; do not expect one tool to do both.
17
+
18
+ ## Start here
19
+
20
+ ```bash
21
+ npx rhythmguard
22
+ ```
23
+
24
+ No install, no config. It detects your stack and token files, infers your spacing scale from your own tokens, audits the current directory, and prints the exact `.stylelintrc.json` (and ESLint snippet for Tailwind) to paste. Then:
17
25
 
18
26
  ```bash
19
27
  npm install --save-dev stylelint stylelint-plugin-rhythmguard
@@ -62,7 +70,7 @@ Every rule validates its options up front. Unknown option names and wrong shapes
62
70
 
63
71
  ## Configs
64
72
 
65
- `recommended`, `strict`, `tailwind`, `react-tailwind`, `expanded`, `logical`, `migration`, `motion`. All are `stylelint-plugin-rhythmguard/configs/<name>`. What each enables, the full custom setup, and the scale-selection precedence are in [docs/CONFIGS.md](docs/CONFIGS.md). Built-in and community scale presets are in [docs/SCALE_PRESETS.md](docs/SCALE_PRESETS.md).
73
+ `recommended`, `strict`, `tailwind`, `react-tailwind`, `expanded`, `logical`, `migration`, `motion`, and `embed` for authors of shared configs (see [docs/FOR_CONFIG_AUTHORS.md](docs/FOR_CONFIG_AUTHORS.md)). All are `stylelint-plugin-rhythmguard/configs/<name>`. What each enables, the full custom setup, and the scale-selection precedence are in [docs/CONFIGS.md](docs/CONFIGS.md). Built-in and community scale presets are in [docs/SCALE_PRESETS.md](docs/SCALE_PRESETS.md).
66
74
 
67
75
  ## Audit before you enforce
68
76
 
@@ -83,6 +91,9 @@ The audit scans CSS declarations, Tailwind class strings and your token contract
83
91
  - [Framework setup](docs/FRAMEWORKS.md) for Vue, Lit, Astro and SvelteKit
84
92
  - [Comparison with adjacent plugins](docs/COMPARISON.md) and migration recipes
85
93
  - [Real before/after excerpts](docs/ADOPTION_DIFFS.md) from public codebases
94
+ - [For shared-config authors](docs/FOR_CONFIG_AUTHORS.md): the `embed` entry point and how inference works per consumer
95
+ - [For coding agents](docs/FOR_AGENTS.md): a paste-ready `AGENTS.md` block and the commands to run
96
+ - [Quiet benchmark](docs/QUIET_BENCHMARK.md): findings on public design systems, checked on every change
86
97
  - [Product direction](docs/STRATEGY_2026-09.md)
87
98
  - Browser playground: [petrilahdelma.github.io/stylelint-plugin-rhythmguard](https://petrilahdelma.github.io/stylelint-plugin-rhythmguard/)
88
99
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "stylelint-plugin-rhythmguard",
3
- "version": "2.1.0",
3
+ "version": "2.2.0",
4
4
  "description": "Token governance for CSS and Tailwind — enforce spacing scales, require design tokens, catch arbitrary values",
5
5
  "bin": {
6
6
  "rhythmguard": "src/cli/index.js"
@@ -32,6 +32,11 @@
32
32
  "require": "./src/configs/recommended.js",
33
33
  "import": "./src/configs/recommended.mjs"
34
34
  },
35
+ "./configs/embed": {
36
+ "types": "./types/config.d.ts",
37
+ "require": "./src/configs/embed.js",
38
+ "import": "./src/configs/embed.mjs"
39
+ },
35
40
  "./configs/strict": {
36
41
  "types": "./types/config.d.ts",
37
42
  "require": "./src/configs/strict.js",
@@ -118,6 +123,7 @@
118
123
  "scripts": {
119
124
  "bench:perf": "node scripts/bench/compare.mjs",
120
125
  "bench:perf:fix": "node scripts/bench/compare.mjs --fix",
126
+ "bench:quiet": "node scripts/bench/quiet.mjs",
121
127
  "lint": "eslint .",
122
128
  "scales:add": "node scripts/scales/add-scale.mjs",
123
129
  "scales:validate": "node scripts/scales/validate-community-scales.mjs",
package/src/audit/args.js CHANGED
@@ -36,7 +36,8 @@ Options:
36
36
  --token-source-format <format> Token source format: auto, css, flat-json, style-dictionary, dtcg (default: auto)
37
37
  --token-kind <kind> Token kind: spacing, radius, typography, size, motion, all (default: spacing)
38
38
  --token-candidate-min-count <n> Minimum repeated raw value count for token candidates (default: 2)
39
- --scale <values> Comma-separated scale values (default: 0,4,8,12,16,24,32)
39
+ --scale <values|auto> Comma-separated scale values (default: 0,4,8,12,16,24,32);
40
+ auto infers the scale from token sources, then scanned CSS
40
41
  --base-font-size <number> px base for rem/em conversion (default: 16)
41
42
  `;
42
43
 
@@ -406,6 +407,10 @@ function parseScale(raw) {
406
407
  throw new Error('Missing value for --scale.');
407
408
  }
408
409
 
410
+ if (raw.trim().toLowerCase() === 'auto') {
411
+ return 'auto';
412
+ }
413
+
409
414
  const scale = raw.split(',')
410
415
  .map((part) => part.trim())
411
416
  .filter(Boolean)
@@ -134,11 +134,21 @@ function parseBooleanOption(value, optionName) {
134
134
  }
135
135
 
136
136
  function normalizeCliTokenSources(sources, format) {
137
- return sources.map((sourcePath) => ({
138
- baseDir: process.cwd(),
139
- format,
140
- path: sourcePath,
141
- }));
137
+ return sources.map((source) => {
138
+ // The programmatic API may pass typed { path | file, format?, baseDir? } entries.
139
+ if (source && typeof source === 'object') {
140
+ return {
141
+ baseDir: source.baseDir || process.cwd(),
142
+ format: source.format || format,
143
+ path: source.path || source.file,
144
+ };
145
+ }
146
+ return {
147
+ baseDir: process.cwd(),
148
+ format,
149
+ path: source,
150
+ };
151
+ });
142
152
  }
143
153
 
144
154
  function normalizeConfigTokenSources(sources, baseDir) {
@@ -243,6 +243,7 @@ function buildReport({
243
243
  externalTokenDefinitions,
244
244
  includeMotion,
245
245
  motionFindings,
246
+ scale,
246
247
  scanScope,
247
248
  templateFiles,
248
249
  tailwindFindings,
@@ -306,6 +307,7 @@ function buildReport({
306
307
  values: Object.fromEntries(sortCountMap(motionValues).slice(0, 10)),
307
308
  },
308
309
  offScaleValues: Object.fromEntries(sortCountMap(offScaleValues).slice(0, 10)),
310
+ scale: scale || null,
309
311
  scaleCleanliness,
310
312
  scanScope,
311
313
  scanned: {
@@ -370,8 +372,11 @@ function toAuditContractReport(report) {
370
372
  motion: report.motion,
371
373
  scale: {
372
374
  cleanliness: report.scaleCleanliness,
375
+ files: report.scale ? report.scale.files : [],
373
376
  offScaleValues: report.offScaleValues,
377
+ source: report.scale ? report.scale.source : 'default',
374
378
  tokenOpportunities: report.tokenOpportunities,
379
+ values: report.scale ? report.scale.values : null,
375
380
  },
376
381
  tokens: report.tokenContract,
377
382
  },
@@ -3,6 +3,12 @@
3
3
  const { sortCountMap } = require('./contract');
4
4
  const { escapeMarkdown } = require('./render-utils');
5
5
 
6
+ function describeScaleSource(scale) {
7
+ return scale.files.length > 0
8
+ ? `${scale.source} (${scale.files.join(', ')})`
9
+ : scale.source;
10
+ }
11
+
6
12
  function renderMarkdown(report) {
7
13
  const lines = [
8
14
  '# Rhythmguard Design-System Audit',
@@ -20,6 +26,11 @@ function renderMarkdown(report) {
20
26
  `| Scale cleanliness | ${report.scaleCleanliness}% |`,
21
27
  ];
22
28
 
29
+ if (report.scale) {
30
+ lines.push(`| Scale | ${report.scale.values.join(', ')} |`);
31
+ lines.push(`| Scale source | ${describeScaleSource(report.scale)} |`);
32
+ }
33
+
23
34
  if (report.baseline) {
24
35
  lines.push(`| New findings | ${report.baseline.newFindingsCount} |`);
25
36
  lines.push(`| Resolved findings | ${report.baseline.resolvedFindingsCount} |`);
@@ -21,9 +21,15 @@ function renderText(report) {
21
21
  ` Template files scanned ${String(report.templateFilesScanned).padStart(4)}`,
22
22
  ` Files with issues ${String(report.filesWithIssues).padStart(4)}`,
23
23
  ` Scale cleanliness ${scoreBar(report.scaleCleanliness)} ${report.scaleCleanliness}%`,
24
- '',
25
24
  ];
26
25
 
26
+ if (report.scale) {
27
+ lines.push(` Scale ${report.scale.values.join(', ')}`);
28
+ const files = report.scale.files.length > 0 ? ` (${report.scale.files.join(', ')})` : '';
29
+ lines.push(` Scale source ${report.scale.source}${files}`);
30
+ }
31
+ lines.push('');
32
+
27
33
  appendHistogram(lines, 'CSS OFF-SCALE VALUES', report.offScaleValues);
28
34
  appendHistogram(lines, 'CSS TOKEN OPPORTUNITIES', report.tokenOpportunities);
29
35
  appendHistogram(lines, 'TAILWIND CLASS-STRING DRIFT', report.tailwindArbitraryValues);
@@ -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 { 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,6 +77,7 @@ async function createAuditReport(options) {
64
77
  externalTokenDefinitions: tokenSourceResult.definitions,
65
78
  includeMotion: parsed.includeMotion,
66
79
  motionFindings,
80
+ scale,
67
81
  scanScope,
68
82
  tailwindFindings: collectTailwindFindings(templateFiles, lintOptions),
69
83
  templateFiles,
@@ -84,6 +98,66 @@ async function createAuditReport(options) {
84
98
  return report;
85
99
  }
86
100
 
101
+ /**
102
+ * One project-level scale for the whole audit, with provenance.
103
+ * "auto": external token sources, then spacing custom properties across the scanned
104
+ * CSS, then the default scale. First source that yields values wins.
105
+ */
106
+ function resolveAuditScale({ baseFontSize, cssFiles, requested, tokenSourceResult }) {
107
+ if (Array.isArray(requested)) {
108
+ return {
109
+ files: [],
110
+ source: requested === DEFAULT_SCALE ? 'default' : 'explicit',
111
+ tokenCount: 0,
112
+ values: requested,
113
+ };
114
+ }
115
+
116
+ if (tokenSourceResult.definitions.size > 0) {
117
+ const values = scaleFromDefinitions(tokenSourceResult.definitions, baseFontSize);
118
+ if (values) {
119
+ return {
120
+ files: tokenSourceResult.sources.map((source) => source.file),
121
+ source: 'token-sources',
122
+ tokenCount: tokenSourceResult.definitions.size,
123
+ values,
124
+ };
125
+ }
126
+ }
127
+
128
+ const definitions = new Map();
129
+ const matchesKind = createTokenKindMatcher('spacing');
130
+ for (const filePath of cssFiles) {
131
+ let text;
132
+ try {
133
+ text = fs.readFileSync(filePath, 'utf8');
134
+ } catch {
135
+ continue;
136
+ }
137
+ collectTokenDefinitions(text, formatPath(filePath), definitions, matchesKind, baseFontSize);
138
+ }
139
+
140
+ if (definitions.size > 0) {
141
+ const values = scaleFromDefinitions(definitions, baseFontSize);
142
+ if (values) {
143
+ const files = new Set();
144
+ for (const definition of definitions.values()) {
145
+ for (const file of definition.files) {
146
+ files.add(file);
147
+ }
148
+ }
149
+ return {
150
+ files: Array.from(files).sort(),
151
+ source: 'scanned-css',
152
+ tokenCount: definitions.size,
153
+ values,
154
+ };
155
+ }
156
+ }
157
+
158
+ return { files: [], source: 'fallback', tokenCount: 0, values: DEFAULT_SCALE };
159
+ }
160
+
87
161
  function normalizeCreateAuditOptions(options = {}) {
88
162
  const parsed = {
89
163
  ...createDefaultAuditOptions(),
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
@@ -153,4 +153,8 @@ async function run() {
153
153
  }
154
154
  }
155
155
 
156
- run();
156
+ module.exports = { detect, run, selectProfile };
157
+
158
+ if (require.main === module) {
159
+ run();
160
+ }
@@ -0,0 +1,203 @@
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
+
18
+ const TOKEN_FILE_PATTERN = /(^|[.-])tokens?\.json$/i;
19
+ const TOKEN_DIRS = ['tokens', 'design-tokens', path.join('src', 'tokens'), path.join('dist', 'tokens')];
20
+ const MAX_LISTED = 5;
21
+
22
+ function readPackage(cwd) {
23
+ try {
24
+ return JSON.parse(fs.readFileSync(path.join(cwd, 'package.json'), 'utf8'));
25
+ } catch {
26
+ return {};
27
+ }
28
+ }
29
+
30
+ function tailwindMajor(pkg) {
31
+ const deps = { ...pkg.dependencies, ...pkg.devDependencies };
32
+ if (deps['@tailwindcss/postcss'] || deps['@tailwindcss/vite']) {
33
+ return 4;
34
+ }
35
+ const spec = deps.tailwindcss;
36
+ if (!spec) {
37
+ return null;
38
+ }
39
+ const match = String(spec).match(/(\d+)/);
40
+ return match ? Number(match[1]) : null;
41
+ }
42
+
43
+ function findTokenFiles(cwd) {
44
+ const found = new Set();
45
+ const consider = (dir) => {
46
+ let entries;
47
+ try {
48
+ entries = fs.readdirSync(dir, { withFileTypes: true });
49
+ } catch {
50
+ return;
51
+ }
52
+ for (const entry of entries) {
53
+ if (entry.isFile() && TOKEN_FILE_PATTERN.test(entry.name)) {
54
+ found.add(path.relative(cwd, path.join(dir, entry.name)));
55
+ }
56
+ }
57
+ };
58
+ consider(cwd);
59
+ for (const sub of TOKEN_DIRS) {
60
+ consider(path.join(cwd, sub));
61
+ }
62
+ return Array.from(found).sort();
63
+ }
64
+
65
+ function hasRhythmguardConfig(cwd) {
66
+ return fs.existsSync(path.join(cwd, '.rhythmguardrc.json'));
67
+ }
68
+
69
+ function toPosix(relativePath) {
70
+ return `./${relativePath.split(path.sep).join('/')}`;
71
+ }
72
+
73
+ function topEntries(counts, limit = MAX_LISTED) {
74
+ return Object.entries(counts || {})
75
+ .sort((a, b) => b[1] - a[1])
76
+ .slice(0, limit);
77
+ }
78
+
79
+ function suggestedStylelintConfig({ profile, tokenFiles }) {
80
+ const ruleOptions = { scale: 'auto' };
81
+ if (tokenFiles.length > 0) {
82
+ ruleOptions.scaleSources = tokenFiles.map(toPosix);
83
+ }
84
+ return {
85
+ extends: [`stylelint-plugin-rhythmguard/configs/${profile}`],
86
+ rules: {
87
+ 'rhythmguard/use-scale': [true, ruleOptions],
88
+ },
89
+ };
90
+ }
91
+
92
+ function eslintSnippet(scaleValues) {
93
+ const scale = scaleValues.filter((value) => typeof value === 'number').join(', ');
94
+ return [
95
+ "// eslint.config.js",
96
+ "import rhythmguard from 'stylelint-plugin-rhythmguard/eslint';",
97
+ '',
98
+ 'export default [',
99
+ ' {',
100
+ " plugins: { 'rhythmguard-tailwind': rhythmguard },",
101
+ ` rules: { 'rhythmguard-tailwind/tailwind-class-use-scale': ['warn', { scale: [${scale}] }] },`,
102
+ ' },',
103
+ '];',
104
+ ].join('\n');
105
+ }
106
+
107
+ function describeScale(scale) {
108
+ if (!scale) {
109
+ return 'default';
110
+ }
111
+ const files = scale.files && scale.files.length > 0 ? ` (${scale.files.slice(0, 3).join(', ')}${scale.files.length > 3 ? ', …' : ''})` : '';
112
+ return `${scale.source}${files}`;
113
+ }
114
+
115
+ async function run() {
116
+ const cwd = process.cwd();
117
+ const pkg = readPackage(cwd);
118
+ const stack = detect();
119
+ const twMajor = stack.tailwind ? tailwindMajor(pkg) : null;
120
+ const tokenFiles = findTokenFiles(cwd);
121
+ const rcPresent = hasRhythmguardConfig(cwd);
122
+ const out = [];
123
+
124
+ out.push('', ' Rhythmguard quickstart', ` ${cwd}`, '');
125
+ out.push(' Detected');
126
+ out.push(` Tailwind ${stack.tailwind ? `v${twMajor || '?'}` : 'no'}`);
127
+ out.push(` Next.js ${stack.nextjs ? 'yes' : 'no'}`);
128
+ out.push(` Stylelint config ${stack.hasExistingConfig ? 'present' : 'none'}`);
129
+ out.push(` Token files ${tokenFiles.length > 0 ? tokenFiles.join(', ') : 'none found'}`);
130
+ out.push(` .rhythmguardrc ${rcPresent ? 'present (its token sources are used)' : 'none'}`);
131
+ out.push('');
132
+
133
+ let report;
134
+ try {
135
+ report = await createAuditReport({
136
+ dir: '.',
137
+ scale: 'auto',
138
+ ...(tokenFiles.length > 0 && !rcPresent
139
+ ? { tokenSources: tokenFiles }
140
+ : {}),
141
+ });
142
+ } catch (error) {
143
+ process.stderr.write(`Quickstart could not audit ${cwd}: ${error.message}\n`);
144
+ process.exitCode = 1;
145
+ return;
146
+ }
147
+
148
+ if (report.cssFilesScanned === 0 && report.templateFilesScanned === 0) {
149
+ out.push(' No CSS files found here. Run from your project root, or audit a directory directly:');
150
+ out.push(' npx rhythmguard audit ./src', '');
151
+ process.stdout.write(`${out.join('\n')}\n`);
152
+ return;
153
+ }
154
+
155
+ const scale = report.scale;
156
+ out.push(' Scale');
157
+ out.push(` Scale ${scale.values.join(', ')}`);
158
+ out.push(` Source ${describeScale(scale)}`);
159
+ if (scale.source === 'fallback') {
160
+ out.push(' Note No spacing tokens found; the rhythmic-4 preset is being used.');
161
+ out.push(' Add scaleSources or define --space-*/--spacing-* custom properties to lint against your own scale.');
162
+ }
163
+ out.push('');
164
+
165
+ const offScale = report.findings.css.filter((finding) => finding.type === 'off-scale');
166
+ out.push(' Findings');
167
+ out.push(` CSS files ${report.cssFilesScanned}`);
168
+ out.push(` Template files ${report.templateFilesScanned}`);
169
+ out.push(` Off-scale ${offScale.length} in CSS, ${report.findings.tailwind.length} in class strings`);
170
+ out.push(` Token opps ${report.findings.css.length - offScale.length} raw values that match or deserve a token`);
171
+ out.push(` Cleanliness ${report.scaleCleanliness}%`);
172
+ const topValues = topEntries(report.offScaleValues);
173
+ if (topValues.length > 0) {
174
+ out.push(` Top values ${topValues.map(([value, count]) => `${value} ×${count}`).join(', ')}`);
175
+ }
176
+ const topFiles = (report.topAffectedFiles || []).slice(0, 3);
177
+ if (topFiles.length > 0) {
178
+ out.push(` Top files ${topFiles.map((entry) => `${entry.file} (${entry.count})`).join(', ')}`);
179
+ }
180
+ out.push('');
181
+
182
+ const profile = stack.tailwind ? (stack.nextjs ? 'react-tailwind' : 'tailwind') : 'recommended';
183
+ 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, ' '));
185
+ out.push('');
186
+
187
+ if (stack.tailwind) {
188
+ out.push(' Tailwind class strings need the ESLint companion. Paste this into eslint.config.js:', '');
189
+ out.push(eslintSnippet(scale.values).replace(/^/gm, ' '));
190
+ out.push('');
191
+ }
192
+
193
+ out.push(' Next');
194
+ out.push(' npx stylelint "**/*.css" lint with the config above');
195
+ out.push(' npx rhythmguard audit . --format markdown PR-ready report');
196
+ out.push(' npx rhythmguard audit . --write-baseline then gate only new drift in CI');
197
+ out.push(' npx rhythmguard doctor check the setup');
198
+ out.push('');
199
+
200
+ process.stdout.write(`${out.join('\n')}\n`);
201
+ }
202
+
203
+ 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
+ };
@@ -0,0 +1,4 @@
1
+ import { createRequire } from 'node:module';
2
+ const require = createRequire(import.meta.url);
3
+ const config = require('./embed.js');
4
+ export default config;
package/src/index.js CHANGED
@@ -15,6 +15,7 @@ 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'),