stylelint-plugin-rhythmguard 3.10.2 → 3.12.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 (44) hide show
  1. package/CHANGELOG.md +77 -0
  2. package/README.md +5 -1
  3. package/package.json +1 -1
  4. package/src/audit/args.js +22 -2
  5. package/src/audit/baseline.js +184 -27
  6. package/src/audit/codemod.js +39 -14
  7. package/src/audit/config.js +25 -31
  8. package/src/audit/contract.js +96 -10
  9. package/src/audit/render-github.js +4 -0
  10. package/src/audit/render-html.js +7 -0
  11. package/src/audit/render-markdown.js +27 -0
  12. package/src/audit/render-text.js +23 -1
  13. package/src/audit/report.js +60 -17
  14. package/src/audit/scan/files.js +154 -27
  15. package/src/audit/scan/stylesheets.js +87 -34
  16. package/src/audit/scan/templates.js +22 -7
  17. package/src/audit/token-chains.js +18 -7
  18. package/src/cli/audit.js +7 -3
  19. package/src/cli/doctor.js +48 -7
  20. package/src/cli/fix.js +56 -15
  21. package/src/cli/init.js +136 -23
  22. package/src/cli/quickstart.js +34 -8
  23. package/src/core/fs-cache.js +6 -0
  24. package/src/core/length.js +15 -0
  25. package/src/core/options.js +8 -1
  26. package/src/core/scale-inference.js +99 -68
  27. package/src/core/tailwind-class-analysis.js +26 -16
  28. package/src/core/tailwind-motion-analysis.js +12 -3
  29. package/src/core/token-index.js +76 -24
  30. package/src/core/token-map.js +23 -10
  31. package/src/core/token-source-entries.js +64 -0
  32. package/src/core/token-sources.js +361 -69
  33. package/src/core/value-nodes.js +280 -54
  34. package/src/eslint/literal-spans.js +319 -0
  35. package/src/eslint/rules/tailwind-class-use-motion-scale.js +9 -32
  36. package/src/eslint/rules/tailwind-class-use-scale.js +45 -40
  37. package/src/rules/no-offscale-transform/index.js +35 -14
  38. package/src/rules/prefer-token/index.js +72 -48
  39. package/src/rules/report.js +3 -1
  40. package/src/rules/use-motion-scale/index.js +68 -5
  41. package/src/rules/use-scale/index.js +54 -39
  42. package/types/__checks__/eslint-config.ts +37 -0
  43. package/types/audit.d.ts +10 -2
  44. package/types/shared.d.ts +21 -3
@@ -2,7 +2,7 @@
2
2
 
3
3
  const fs = require('node:fs');
4
4
  const { formatLength, numbersEqual, parseLengthToken, toPx } = require('../core/length');
5
- const { customPropertyDeclarations, parseTokenValueLength } = require('../core/token-sources');
5
+ const { customPropertyDeclarations, hasLineComments, parseTokenValueLength } = require('../core/token-sources');
6
6
  const { formatPath } = require('./shared');
7
7
 
8
8
  /**
@@ -33,7 +33,7 @@ function isSpacingTokenName(token) {
33
33
  }
34
34
 
35
35
  /** Every custom property declaration the audit can see: scanned stylesheets plus external token definitions. */
36
- function collectDeclarations({ cssFiles, externalDefinitions, skipFile = () => false }) {
36
+ function collectDeclarations({ cssFiles, externalDefinitions, readSource = (file) => fs.readFileSync(file, 'utf8'), skipFile = () => false }) {
37
37
  const declarations = new Map();
38
38
  const add = (token, value) => {
39
39
  if (!declarations.has(token)) declarations.set(token, new Set());
@@ -43,11 +43,12 @@ function collectDeclarations({ cssFiles, externalDefinitions, skipFile = () => f
43
43
  if (skipFile(formatPath(filePath))) continue;
44
44
  let source;
45
45
  try {
46
- source = fs.readFileSync(filePath, 'utf8');
46
+ source = readSource(filePath);
47
47
  } catch {
48
48
  continue;
49
49
  }
50
- for (const declaration of customPropertyDeclarations(source)) add(declaration.token, declaration.value);
50
+ const lineComments = hasLineComments(filePath);
51
+ for (const declaration of customPropertyDeclarations(source, { lineComments })) add(declaration.token, declaration.value);
51
52
  }
52
53
  for (const definition of (externalDefinitions || new Map()).values()) {
53
54
  if (!definition.token.startsWith('--')) continue;
@@ -56,21 +57,31 @@ function collectDeclarations({ cssFiles, externalDefinitions, skipFile = () => f
56
57
  return declarations;
57
58
  }
58
59
 
59
- function resolveTokenChains({ baseFontSize = 16, declarations, scale }) {
60
+ /**
61
+ * `declarations` are the tokens reported on; `references` are looked up only,
62
+ * so a chain can end in a token package or a token of another kind without
63
+ * that token becoming an entry of its own (issue #259).
64
+ */
65
+ function resolveTokenChains({ baseFontSize = 16, declarations, references = null, scale }) {
60
66
  const scalePx = (scale || []).map(Number).filter(Number.isFinite);
61
67
  const memo = new Map();
68
+ const declared = (token) => declarations.get(token) || (references ? references.get(token) : undefined);
62
69
 
63
70
  const classifyValue = (raw, stack, via) => {
64
71
  const value = String(raw).trim();
65
72
  const length = parseTokenValueLength(value) || parseLengthToken(value);
66
73
  if (length && !/^var\(/i.test(value)) {
74
+ // A non-zero number without a unit (`--space-ratio: 1.5`) is a ratio, not 1.5px (issue #268).
75
+ if (length.unit === '' && length.number !== 0) {
76
+ return { outcomes: ['non-length'], terminals: [] };
77
+ }
67
78
  const px = toPx(Math.abs(length.number), length.unit || 'px', baseFontSize);
68
79
  return px === null ? { outcomes: ['non-length'], terminals: [] } : { outcomes: [], terminals: [formatLength(px, 'px')] };
69
80
  }
70
81
  const reference = value.match(VAR_REFERENCE);
71
82
  if (reference) {
72
83
  const [, target, fallback] = reference;
73
- if (declarations.has(target)) {
84
+ if (declared(target)) {
74
85
  via.push(target);
75
86
  return resolve(target, stack);
76
87
  }
@@ -90,7 +101,7 @@ function resolveTokenChains({ baseFontSize = 16, declarations, scale }) {
90
101
  if (stack.includes(token)) {
91
102
  return { outcomes: ['unresolved'], reason: `cycle through \`${stack[stack.length - 1]}\``, terminals: [], via: [] };
92
103
  }
93
- const values = declarations.get(token);
104
+ const values = declared(token);
94
105
  if (!values) return { outcomes: ['unresolved'], reason: `\`${token}\` is not declared`, terminals: [], via: [] };
95
106
  const nextStack = [...stack, token];
96
107
  const outcomes = new Set();
package/src/cli/audit.js CHANGED
@@ -17,7 +17,7 @@ const { renderBadge } = require('../audit/render-badge');
17
17
  const { renderHtml } = require('../audit/render-html');
18
18
  const { renderMarkdown } = require('../audit/render-markdown');
19
19
  const { renderText } = require('../audit/render-text');
20
- const { createAuditReport } = require('../audit/report');
20
+ const { createAuditReport, normalizeCreateAuditOptions } = require('../audit/report');
21
21
 
22
22
  const args = process.argv.slice(3);
23
23
 
@@ -41,8 +41,12 @@ async function run() {
41
41
  }
42
42
 
43
43
  let report;
44
+ let options;
44
45
  try {
45
- report = await createAuditReport(parsed);
46
+ // Apply .rhythmguardrc.json once, here, so the exit gates (maxFindings,
47
+ // minCleanliness) read the same merged options the report was built from.
48
+ options = { ...normalizeCreateAuditOptions(parsed), configApplied: true };
49
+ report = await createAuditReport(options);
46
50
  } catch (err) {
47
51
  process.stderr.write(`${err.message}\n`);
48
52
  process.exit(1);
@@ -53,7 +57,7 @@ async function run() {
53
57
  return;
54
58
  }
55
59
 
56
- const auditFailures = getAuditFailures(report, parsed);
60
+ const auditFailures = getAuditFailures(report, options);
57
61
 
58
62
  if (parsed.format === 'json') {
59
63
  writeOutput(`${JSON.stringify(toAuditContractReport(report), null, 2)}\n`, parsed.outputPath);
package/src/cli/doctor.js CHANGED
@@ -2,6 +2,9 @@
2
2
 
3
3
  const fs = require('node:fs');
4
4
  const path = require('node:path');
5
+ const { applyAuditConfig, loadAuditConfig, resolveIgnorePatterns } = require('../audit/config');
6
+ const { createDefaultAuditOptions } = require('../audit/shared');
7
+ const { normalizeDecisions } = require('../core/decisions');
5
8
  const { normalizeTokenSourceFormat } = require('../core/token-sources');
6
9
 
7
10
  const cwd = process.cwd();
@@ -221,18 +224,56 @@ function checkRhythmguardConfig() {
221
224
  return;
222
225
  }
223
226
 
224
- const audit = parsed.audit;
225
- if (!audit || typeof audit !== 'object' || Array.isArray(audit)) {
226
- fail('.rhythmguardrc.json audit config missing', 'Add an "audit" object or remove the config file');
227
- return;
227
+ // Validate with the audit's own loader, so doctor passes exactly the files
228
+ // `rhythmguard audit` accepts. The "audit" section is optional there too.
229
+ // Decisions are checked on their own, so an error in one section does not
230
+ // hide an error in the other.
231
+ let configResult = null;
232
+ let options = null;
233
+ try {
234
+ configResult = loadAuditConfig({});
235
+ const applied = applyAuditConfig(
236
+ { ...createDefaultAuditOptions(), cliOptions: new Set() },
237
+ { ...configResult, decisions: undefined },
238
+ );
239
+ resolveIgnorePatterns(applied);
240
+ options = applied;
241
+ pass('.rhythmguardrc.json audit config valid');
242
+ } catch (err) {
243
+ fail(
244
+ `.rhythmguardrc.json audit config invalid: ${err.message}`,
245
+ 'Fix the "audit" section; rhythmguard audit stops on the same error',
246
+ );
228
247
  }
229
248
 
230
- pass('.rhythmguardrc.json audit config valid');
231
- checkRhythmguardTokenSources(audit, path.dirname(configPath));
232
- checkRhythmguardMotionConfig(audit);
249
+ checkRhythmguardDecisions(parsed, options ? options.baseFontSize : undefined);
250
+
251
+ const audit = configResult ? configResult.audit : {};
252
+ if (options) {
253
+ checkRhythmguardTokenSources(audit, path.dirname(configPath));
254
+ checkRhythmguardMotionConfig(audit);
255
+ }
233
256
  checkRhythmguardBaseline(audit);
234
257
  }
235
258
 
259
+ function checkRhythmguardDecisions(config, baseFontSize) {
260
+ const raw = config && typeof config === 'object' && !Array.isArray(config) ? config.decisions : undefined;
261
+ if (raw === undefined || raw === null) {
262
+ skip('decisions check skipped (not configured)');
263
+ return;
264
+ }
265
+
266
+ try {
267
+ const decisions = normalizeDecisions(raw, baseFontSize === undefined ? {} : { baseFontSize });
268
+ pass(`.rhythmguardrc.json decisions valid (${decisions.length} decision${decisions.length === 1 ? '' : 's'})`);
269
+ } catch (err) {
270
+ fail(
271
+ `.rhythmguardrc.json decisions invalid: ${err.message}`,
272
+ 'Fix the "decisions" section; rhythmguard audit and the Stylelint rules read the same entries',
273
+ );
274
+ }
275
+ }
276
+
236
277
  function checkRhythmguardTokenSources(audit, baseDir) {
237
278
  if (audit.tokenSources === undefined) {
238
279
  skip('token source config check skipped (not configured)');
package/src/cli/fix.js CHANGED
@@ -2,7 +2,14 @@
2
2
 
3
3
  const { loadRcDecisions } = require('../core/decisions');
4
4
  const { parseLengthToken, toPx } = require('../core/length');
5
+ const { OPTIONS: AUDIT_OPTIONS } = require('../audit/args');
5
6
  const { runCodemod } = require('../audit/codemod');
7
+ const { resolveIgnorePatterns } = require('../audit/config');
8
+ const { normalizeCreateAuditOptions } = require('../audit/report');
9
+ const { DEFAULT_IGNORE_PATH } = require('../audit/shared');
10
+
11
+ // --ignore and --ignore-path are the audit's own flags, parsed by the audit's table.
12
+ const IGNORE_OPTIONS = AUDIT_OPTIONS.filter((option) => option.flag === '--ignore' || option.flag === '--ignore-path');
6
13
 
7
14
  const HELP = `Usage: rhythmguard fix <dir> --value <length> --to <replacement> [options]
8
15
  rhythmguard fix <dir> --decided [options]
@@ -11,7 +18,9 @@ Replace one spacing literal everywhere it appears on a spacing property, or
11
18
  execute the snap decisions in .rhythmguardrc.json that name a "to". Matching is
12
19
  by px, so --value 10px also rewrites 0.625rem. Token definitions, values inside
13
20
  var()/theme()/token(), and non-spacing properties are never touched. Dry run by
14
- default.
21
+ default. Files the audit skips are skipped too: .rhythmguardignore, audit.ignore
22
+ in .rhythmguardrc.json, --ignore, and generated or vendored directories such as
23
+ node_modules and dist.
15
24
 
16
25
  Options:
17
26
  --value <length> The literal to replace, for example 10px or 0.625rem
@@ -19,16 +28,32 @@ Options:
19
28
  --decided Apply every snap decision that has a "to"
20
29
  --properties <list> Comma-separated property names or prefix-* patterns (default: spacing properties)
21
30
  --base-font-size <px> Base for rem/em conversion (default: 16)
31
+ --ignore <pattern> Exclude a root-relative path or glob (repeatable, comma-separated)
32
+ --ignore-path <file> Load ignore patterns from file (default: .rhythmguardignore when present)
22
33
  --write Apply the changes; without it, only list them
23
34
  --help Show this help message
24
35
  `;
25
36
 
26
37
  function parseArgs(argv) {
27
- const parsed = { baseFontSize: 16, decided: false, dir: null, help: false, properties: null, replacement: null, value: null, write: false };
38
+ const parsed = {
39
+ baseFontSize: 16,
40
+ cliOptions: new Set(),
41
+ decided: false,
42
+ dir: null,
43
+ help: false,
44
+ ignorePath: DEFAULT_IGNORE_PATH,
45
+ ignorePatterns: [],
46
+ properties: null,
47
+ replacement: null,
48
+ value: null,
49
+ write: false,
50
+ };
28
51
  for (let index = 0; index < argv.length; index += 1) {
29
52
  const arg = argv[index];
30
53
  const next = () => argv[++index];
31
- if (arg === '--help' || arg === '-h') parsed.help = true;
54
+ const ignoreOption = IGNORE_OPTIONS.find((option) => arg === option.flag || arg.startsWith(`${option.flag}=`));
55
+ if (ignoreOption) ignoreOption.apply(parsed, arg === ignoreOption.flag ? next() : arg.slice(ignoreOption.flag.length + 1));
56
+ else if (arg === '--help' || arg === '-h') parsed.help = true;
32
57
  else if (arg === '--write') parsed.write = true;
33
58
  else if (arg === '--decided') parsed.decided = true;
34
59
  else if (arg === '--value') parsed.value = next();
@@ -61,6 +86,19 @@ function lengthToPx(value, baseFontSize) {
61
86
  return px;
62
87
  }
63
88
 
89
+ /**
90
+ * The ignore list, loaded exactly as the audit loads it: .rhythmguardrc.json's
91
+ * audit.ignore and audit.ignorePath, the ignore file, then --ignore.
92
+ */
93
+ function auditIgnorePatterns(parsed) {
94
+ return resolveIgnorePatterns(normalizeCreateAuditOptions({
95
+ cliOptions: parsed.cliOptions,
96
+ dir: parsed.dir,
97
+ ignorePath: parsed.ignorePath,
98
+ ignorePatterns: parsed.ignorePatterns,
99
+ }));
100
+ }
101
+
64
102
  /** The replacements to run: one from --value/--to, or every snap decision with a "to". */
65
103
  function plannedReplacements(parsed) {
66
104
  if (parsed.decided) {
@@ -98,8 +136,10 @@ function run() {
98
136
  }
99
137
 
100
138
  let plan;
139
+ let ignorePatterns;
101
140
  try {
102
141
  plan = plannedReplacements(parsed);
142
+ ignorePatterns = auditIgnorePatterns(parsed);
103
143
  } catch (error) {
104
144
  process.stderr.write(`${error.message}\n`);
105
145
  process.exit(1);
@@ -108,25 +148,26 @@ function run() {
108
148
  const lines = [];
109
149
  let total = 0;
110
150
  const touched = new Set();
111
- const skipped = [];
112
- for (const replacement of plan.replacements) {
113
- const outcome = runCodemod({
151
+ // Every replacement in one pass over the original values: a value one snap
152
+ // writes is never snapped again, and the dry run lists exactly what --write writes.
153
+ const outcome = plan.replacements.length === 0
154
+ ? { results: [], skipped: [] }
155
+ : runCodemod({
114
156
  baseFontSize: parsed.baseFontSize,
115
157
  dir: parsed.dir,
158
+ ignorePatterns,
116
159
  properties: parsed.properties,
117
- px: replacement.px,
118
- replacement: replacement.replacement,
160
+ replacements: plan.replacements,
119
161
  write: parsed.write,
120
162
  });
121
- for (const result of outcome.results) {
122
- touched.add(result.file);
123
- for (const change of result.changes) {
124
- total += 1;
125
- lines.push(` ${result.file}:${change.line}:${change.column} ${change.property}: ${change.from} -> ${change.to}`);
126
- }
163
+ for (const result of outcome.results) {
164
+ touched.add(result.file);
165
+ for (const change of result.changes) {
166
+ total += 1;
167
+ lines.push(` ${result.file}:${change.line}:${change.column} ${change.property}: ${change.from} -> ${change.to}`);
127
168
  }
128
- skipped.push(...outcome.skipped);
129
169
  }
170
+ const skipped = outcome.skipped;
130
171
 
131
172
  if (lines.length > 0) {
132
173
  lines.push('');
package/src/cli/init.js CHANGED
@@ -72,23 +72,117 @@ function detect() {
72
72
  fs.existsSync(path.join(cwd, 'next.config.mjs')) ||
73
73
  fs.existsSync(path.join(cwd, 'next.config.ts'));
74
74
 
75
- const hasExistingConfig =
76
- fs.existsSync(path.join(cwd, '.stylelintrc')) ||
77
- fs.existsSync(path.join(cwd, '.stylelintrc.json')) ||
78
- fs.existsSync(path.join(cwd, '.stylelintrc.js')) ||
79
- fs.existsSync(path.join(cwd, '.stylelintrc.cjs')) ||
80
- fs.existsSync(path.join(cwd, '.stylelintrc.mjs')) ||
81
- fs.existsSync(path.join(cwd, '.stylelintrc.yml')) ||
82
- fs.existsSync(path.join(cwd, '.stylelintrc.yaml')) ||
83
- fs.existsSync(path.join(cwd, 'stylelint.config.js')) ||
84
- fs.existsSync(path.join(cwd, 'stylelint.config.cjs')) ||
85
- fs.existsSync(path.join(cwd, 'stylelint.config.mjs')) ||
86
- Boolean(pkg.stylelint);
75
+ const activeConfig = findActiveStylelintConfig(cwd);
76
+ const hasExistingConfig = activeConfig !== null;
87
77
 
88
78
  const tailwind = hasTailwindConfig || hasTailwindDep;
89
79
  const nextjs = hasNextConfig;
90
80
 
91
- return { tailwind, nextjs, hasExistingConfig };
81
+ return { activeConfig, tailwind, nextjs, hasExistingConfig };
82
+ }
83
+
84
+ /**
85
+ * Where Stylelint looks for a config in one directory, in its order. Stylelint
86
+ * searches with cosmiconfig and does not expose the file it picks, so this is
87
+ * cosmiconfig 9's default list for the module name "stylelint". The first hit
88
+ * wins: package.json counts only with a "stylelint" key, and an empty file is
89
+ * skipped, as cosmiconfig skips it.
90
+ */
91
+ const STYLELINT_SEARCH_PLACES = [
92
+ 'package.json',
93
+ '.stylelintrc',
94
+ '.stylelintrc.json',
95
+ '.stylelintrc.yaml',
96
+ '.stylelintrc.yml',
97
+ '.stylelintrc.js',
98
+ '.stylelintrc.ts',
99
+ '.stylelintrc.cjs',
100
+ '.stylelintrc.mjs',
101
+ '.config/stylelintrc',
102
+ '.config/stylelintrc.json',
103
+ '.config/stylelintrc.yaml',
104
+ '.config/stylelintrc.yml',
105
+ '.config/stylelintrc.js',
106
+ '.config/stylelintrc.ts',
107
+ '.config/stylelintrc.cjs',
108
+ '.config/stylelintrc.mjs',
109
+ 'stylelint.config.js',
110
+ 'stylelint.config.ts',
111
+ 'stylelint.config.cjs',
112
+ 'stylelint.config.mjs',
113
+ ];
114
+
115
+ /**
116
+ * The config file Stylelint loads in `cwd`, or null. `kind` says what init can
117
+ * do with it: 'json' is rewritten in place; 'package' (the package.json key),
118
+ * 'yaml' and 'code' (JS or TS) get a snippet to add by hand, because writing a
119
+ * .stylelintrc.json next to them changes nothing: Stylelint never reads it.
120
+ */
121
+ function findActiveStylelintConfig(cwd = process.cwd()) {
122
+ for (const file of STYLELINT_SEARCH_PLACES) {
123
+ let content;
124
+ try {
125
+ content = fs.readFileSync(path.join(cwd, file), 'utf8');
126
+ } catch {
127
+ continue;
128
+ }
129
+
130
+ if (file === 'package.json') {
131
+ let pkg = null;
132
+ try {
133
+ pkg = JSON.parse(content);
134
+ } catch {
135
+ // cosmiconfig would throw here; a broken package.json is no Stylelint config
136
+ }
137
+ if (pkg && typeof pkg === 'object' && pkg.stylelint !== undefined && pkg.stylelint !== null) {
138
+ return { file, kind: 'package' };
139
+ }
140
+ continue;
141
+ }
142
+
143
+ if (content.trim() === '') {
144
+ continue;
145
+ }
146
+
147
+ return { file, kind: configKind(file, content) };
148
+ }
149
+
150
+ return null;
151
+ }
152
+
153
+ function configKind(file, content) {
154
+ const extension = path.extname(file);
155
+ if (extension === '.json') {
156
+ return 'json';
157
+ }
158
+ if (extension === '.yaml' || extension === '.yml') {
159
+ return 'yaml';
160
+ }
161
+ if (extension === '') {
162
+ // .stylelintrc holds JSON or YAML; only JSON is rewritten.
163
+ try {
164
+ JSON.parse(content);
165
+ return 'json';
166
+ } catch {
167
+ return 'yaml';
168
+ }
169
+ }
170
+ return 'code';
171
+ }
172
+
173
+ /** What to add to a config init cannot rewrite, in that file's own syntax. */
174
+ function configSnippet(kind, config) {
175
+ if (kind === 'package') {
176
+ return JSON.stringify({ stylelint: config }, null, 2).split('\n').slice(1, -1).map((line) => line.slice(2)).join('\n');
177
+ }
178
+ if (kind === 'yaml') {
179
+ return Object.entries(config)
180
+ .map(([key, values]) => `${key}:\n${values.map((value) => ` - ${JSON.stringify(value)}`).join('\n')}`)
181
+ .join('\n');
182
+ }
183
+ return Object.entries(config)
184
+ .map(([key, values]) => `${key}: ${JSON.stringify(values)},`)
185
+ .join('\n');
92
186
  }
93
187
 
94
188
  function selectProfile(stack) {
@@ -193,6 +287,31 @@ async function run() {
193
287
  process.stdout.write('Detected: plain CSS project\n');
194
288
  }
195
289
 
290
+ const profile = selectProfile(stack);
291
+ const config = {
292
+ extends: [`stylelint-plugin-rhythmguard/configs/${profile}`],
293
+ // Next.js build output is generated CSS; never lint it.
294
+ ...(stack.nextjs ? { ignoreFiles: ['.next/**', 'out/**', 'node_modules/**'] } : {}),
295
+ };
296
+
297
+ // A .stylelintrc.json next to the config Stylelint actually loads is either
298
+ // never read (package.json, a YAML .stylelintrc) or silently takes over
299
+ // from it (YAML, JS and TS files later in the search). When that config is
300
+ // not JSON, say what to add to it and write nothing.
301
+ const active = stack.activeConfig;
302
+ if (active && active.kind !== 'json') {
303
+ const where = active.kind === 'package' ? 'the "stylelint" key in package.json' : active.file;
304
+ const shadowsJson = STYLELINT_SEARCH_PLACES.indexOf(active.file) < STYLELINT_SEARCH_PLACES.indexOf('.stylelintrc.json');
305
+ process.stdout.write(`\nProfile: ${profile}\n`);
306
+ process.stdout.write(shadowsJson
307
+ ? `\n⚠ Stylelint loads ${where} before .stylelintrc.json, so it would never read a .stylelintrc.json.\n`
308
+ : `\n⚠ Stylelint loads ${where}; a new .stylelintrc.json would replace it and leave it unused.\n`);
309
+ process.stdout.write(`Add this to ${active.kind === 'package' ? 'package.json' : active.file}, merging with what is there:\n\n`);
310
+ process.stdout.write(`${configSnippet(active.kind, config)}\n\n`);
311
+ process.stdout.write('Nothing was written.\n\n');
312
+ return;
313
+ }
314
+
196
315
  // Warn about existing config
197
316
  if (stack.hasExistingConfig) {
198
317
  process.stdout.write('\n⚠ Existing Stylelint config found.\n');
@@ -203,22 +322,16 @@ async function run() {
203
322
  }
204
323
  }
205
324
 
206
- const profile = selectProfile(stack);
207
325
  process.stdout.write(`\nProfile: ${profile}\n`);
208
326
 
209
- const answer = await prompter.ask('Write .stylelintrc.json? (y/n) ');
327
+ const targetFile = active ? active.file : '.stylelintrc.json';
328
+ const answer = await prompter.ask(`Write ${targetFile}? (y/n) `);
210
329
  if (answer !== 'y' && answer !== 'yes') {
211
330
  process.stdout.write('Aborted.\n');
212
331
  process.exit(0);
213
332
  }
214
333
 
215
- const config = {
216
- extends: [`stylelint-plugin-rhythmguard/configs/${profile}`],
217
- // Next.js build output is generated CSS; never lint it.
218
- ...(stack.nextjs ? { ignoreFiles: ['.next/**', 'out/**', 'node_modules/**'] } : {}),
219
- };
220
-
221
- const configPath = path.join(process.cwd(), '.stylelintrc.json');
334
+ const configPath = path.join(process.cwd(), targetFile);
222
335
  fs.writeFileSync(configPath, JSON.stringify(config, null, 2) + '\n');
223
336
 
224
337
  process.stdout.write(`\n✓ Wrote ${configPath}\n`);
@@ -229,7 +342,7 @@ async function run() {
229
342
  }
230
343
  }
231
344
 
232
- module.exports = { AGENT_TARGETS, detect, installAgents, run, selectProfile };
345
+ module.exports = { AGENT_TARGETS, detect, findActiveStylelintConfig, installAgents, run, selectProfile };
233
346
 
234
347
  if (require.main === module) {
235
348
  run();
@@ -77,9 +77,16 @@ function topEntries(counts, limit = MAX_LISTED) {
77
77
  .slice(0, limit);
78
78
  }
79
79
 
80
- function suggestedStylelintConfig({ nextjs = false, profile, tokenFiles }) {
81
- const ruleOptions = { scale: 'auto' };
82
- if (tokenFiles.length > 0) {
80
+ /**
81
+ * The config to paste. With a scale inferred from tokens it is `scale: "auto"`
82
+ * pointed at the same token files, so Stylelint infers what the audit did.
83
+ * When the audit fell back to its default scale, that scale is written out:
84
+ * `scale: "auto"` would fall back to the `rhythmic-4` preset instead, which is
85
+ * a longer scale, and the pasted config would not reproduce this report (#246).
86
+ */
87
+ function suggestedStylelintConfig({ fallbackScale = null, nextjs = false, profile, tokenFiles }) {
88
+ const ruleOptions = Array.isArray(fallbackScale) ? { scale: [...fallbackScale] } : { scale: 'auto' };
89
+ if (!fallbackScale && tokenFiles.length > 0) {
83
90
  ruleOptions.scaleSources = tokenFiles.map(toPosix);
84
91
  }
85
92
  return {
@@ -99,6 +106,9 @@ function eslintSnippet(scaleValues) {
99
106
  '',
100
107
  'export default [',
101
108
  ' {',
109
+ // Without `files` and JSX parsing, a flat config lints no .jsx at all (#243).
110
+ " files: ['**/*.{js,jsx,mjs,cjs}'],",
111
+ ' languageOptions: { parserOptions: { ecmaFeatures: { jsx: true } } },',
102
112
  " plugins: { 'rhythmguard-tailwind': rhythmguard },",
103
113
  ` rules: { 'rhythmguard-tailwind/tailwind-class-use-scale': ['warn', { scale: [${scale}] }] },`,
104
114
  ' },',
@@ -149,7 +159,9 @@ async function run() {
149
159
  return;
150
160
  }
151
161
 
152
- if (report.cssFilesScanned === 0 && report.templateFilesScanned === 0) {
162
+ const scanned = report.scanned || {};
163
+ const skippedStylesheets = (scanned.scssSkipped || 0) + (scanned.stylelintIgnored || 0);
164
+ if (report.cssFilesScanned === 0 && report.templateFilesScanned === 0 && skippedStylesheets === 0) {
153
165
  out.push(' No CSS files found here. Run from your project root, or audit a directory directly:');
154
166
  out.push(' npx rhythmguard audit ./src', '');
155
167
  process.stdout.write(`${out.join('\n')}\n`);
@@ -161,10 +173,10 @@ async function run() {
161
173
  out.push(` Scale ${scale.values.join(', ')}`);
162
174
  out.push(` Source ${describeScale(scale)}`);
163
175
  if (scale.source === 'fallback' && scale.rejected) {
164
- out.push(` Note Spacing tokens were found but do not form a scale (${scale.rejected.reasons.join(', ')}); the rhythmic-4 preset is being used.`);
176
+ out.push(` Note Spacing tokens were found but do not form a scale (${scale.rejected.reasons.join(', ')}); the audit's default scale is being used.`);
165
177
  out.push(' Point scaleSources at your real token file, or tighten tokenPattern.');
166
178
  } else if (scale.source === 'fallback') {
167
- out.push(' Note No spacing tokens found; the rhythmic-4 preset is being used.');
179
+ out.push(" Note No spacing tokens found; the audit's default scale is being used.");
168
180
  out.push(' Add scaleSources or define --space-*/--spacing-* custom properties to lint against your own scale.');
169
181
  }
170
182
  out.push('');
@@ -173,6 +185,17 @@ async function run() {
173
185
  out.push(' Findings');
174
186
  out.push(` CSS files ${report.cssFilesScanned}`);
175
187
  out.push(` Template files ${report.templateFilesScanned}`);
188
+ if (skippedStylesheets > 0) {
189
+ const reasons = [
190
+ ...(scanned.scssSkipped > 0 ? [`${scanned.scssSkipped} SCSS without postcss-scss`] : []),
191
+ ...(scanned.stylelintIgnored > 0 ? [`${scanned.stylelintIgnored} in .stylelintignore`] : []),
192
+ ];
193
+ out.push(` Skipped ${skippedStylesheets} not audited (${reasons.join(', ')})`);
194
+ }
195
+ const parseErrors = (report.findings.parseErrors || []).length;
196
+ if (parseErrors > 0) {
197
+ out.push(` Parse errors ${parseErrors} not audited (the stylesheet could not be parsed)`);
198
+ }
176
199
  out.push(` Off-scale ${offScale.length} in CSS, ${report.findings.tailwind.length} in class strings`);
177
200
  out.push(` Token opps ${report.findings.css.length - offScale.length} raw values that match or deserve a token`);
178
201
  out.push(` Cleanliness ${report.scaleCleanliness}%`);
@@ -192,12 +215,15 @@ async function run() {
192
215
 
193
216
  const profile = stack.tailwind ? 'tailwind' : 'recommended';
194
217
  out.push(` Paste this into .stylelintrc.json${stack.hasExistingConfig ? ' (merge with your existing config)' : ''}:`, '');
195
- out.push(JSON.stringify(suggestedStylelintConfig({ nextjs: stack.nextjs, profile, tokenFiles }), null, 2).replace(/^/gm, ' '));
218
+ const fallbackScale = scale.source === 'fallback' ? scale.values : null;
219
+ out.push(JSON.stringify(suggestedStylelintConfig({ fallbackScale, nextjs: stack.nextjs, profile, tokenFiles }), null, 2).replace(/^/gm, ' '));
196
220
  out.push('');
197
221
 
198
222
  if (stack.tailwind) {
199
223
  out.push(' Tailwind class strings need the ESLint companion. Paste this into eslint.config.js:', '');
200
224
  out.push(eslintSnippet(scale.values).replace(/^/gm, ' '));
225
+ out.push('', ' TypeScript, Vue, Svelte and Astro files need their parser in a block of their own; see the');
226
+ out.push(' eslint-plugin-rhythmguard README, "Other file types".');
201
227
  out.push('');
202
228
  }
203
229
 
@@ -211,4 +237,4 @@ async function run() {
211
237
  process.stdout.write(`${out.join('\n')}\n`);
212
238
  }
213
239
 
214
- module.exports = { findTokenFiles, run, suggestedStylelintConfig, tailwindMajor };
240
+ module.exports = { eslintSnippet, findTokenFiles, run, suggestedStylelintConfig, tailwindMajor };
@@ -31,6 +31,12 @@ function cachedByFiles(cacheKey, compute) {
31
31
  return value;
32
32
  }
33
33
 
34
+ /** Number of cached entries, for tests that check the cache stays bounded. */
35
+ function cacheSize() {
36
+ return cache.size;
37
+ }
38
+
34
39
  module.exports = {
40
+ cacheSize,
35
41
  cachedByFiles,
36
42
  };
@@ -93,6 +93,20 @@ function numbersEqual(a, b) {
93
93
  return Math.abs(a - b) < EPSILON;
94
94
  }
95
95
 
96
+ /**
97
+ * Whether a px value is one of the scale steps. A fix writes rem and em with
98
+ * four decimals, so at a base font size other than 16 the written value can
99
+ * sit up to half a unit in the fourth decimal off the step: 0.6667rem at an
100
+ * 18px base is 12.0006px. For those units the comparison allows exactly that
101
+ * much, so a value a fix wrote is on the scale on the next run (issue #224).
102
+ */
103
+ function pxOnScale(scalePx, px, unit, baseFontSize) {
104
+ const tolerance = unit === 'rem' || unit === 'em'
105
+ ? EPSILON + 0.00005 * Math.abs(baseFontSize)
106
+ : EPSILON;
107
+ return scalePx.some((entry) => Math.abs(entry - px) < tolerance);
108
+ }
109
+
96
110
  function normalizeScale(scale, baseFontSize) {
97
111
  const normalized = [];
98
112
 
@@ -222,5 +236,6 @@ module.exports = {
222
236
  normalizeScaleByUnit,
223
237
  numbersEqual,
224
238
  parseLengthToken,
239
+ pxOnScale,
225
240
  toPx,
226
241
  };