stylelint-plugin-rhythmguard 3.10.2 → 3.11.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
+ ## [3.11.0] - 2026-10-18
10
+
11
+ ### Fixed
12
+
13
+ - ESLint `--fix` on `tailwind-class-use-scale` and `tailwind-class-use-motion-scale` no longer rewrites the rest of the string. The fix was built from the decoded value with only the quote re-escaped, so `'p-[13px]\n'` became a string with a real newline that no longer parsed, `\\201C` became an octal escape, `\\` lost its escape and a JSX `"` became `\"`. A fix now replaces only the class token in the source text, and everything else in the literal stays as written. A class with an escape inside it (`p-[13\x70x]`) is still reported but not fixed. Template literals are checked on their decoded text, so a class next to an escape such as `` `p-[13px]\n` `` is now reported; they are still not fixed. ([#225](https://github.com/PetriLahdelma/stylelint-plugin-rhythmguard/issues/225))
14
+ - `rhythmguard fix` parsed SCSS with the plain CSS parser (`postcss.parse` ignores a `syntax` option), so it rewrote values inside `//` comments (`// old: .card { padding: 10px; }` became `padding: 8px`) and skipped valid SCSS such as a rule with `// note` inside it as `could not parse`. SCSS is now read and written with postcss-scss's own `parse` and `stringify`: comments stay byte-identical and the declarations are fixed. Without postcss-scss an `.scss` file is skipped with `postcss-scss is not installed; install it to rewrite SCSS`. ([#229](https://github.com/PetriLahdelma/stylelint-plugin-rhythmguard/issues/229))
15
+ - `rhythmguard fix` ignored `.rhythmguardignore` and `audit.ignore`, so `--write` rewrote files the audit never scans: with `legacy` and `vendor/**` ignored, the audit scanned 1 file and `fix` reported `3 replacements in 3 files`. It now loads ignores through the same code as the audit (the ignore file, `audit.ignore`, `audit.ignorePath`) and takes the audit's `--ignore` and `--ignore-path`; generated and vendored directories such as `node_modules` and `dist` stay skipped. Dry run and `--write` list the same files. A file `--decided` cannot read is named once instead of once per decision. ([#230](https://github.com/PetriLahdelma/stylelint-plugin-rhythmguard/issues/230))
16
+ - `maxFindings` and `minCleanliness` in `.rhythmguardrc.json` now fail the audit. The config was merged into the report but the exit gates read only the command line, so `{"audit":{"maxFindings":0}}` printed findings and exited 0. The gates now read the merged options; a `--max-findings` or `--min-cleanliness` flag still overrides the config, and `--no-config` still ignores it. ([#226](https://github.com/PetriLahdelma/stylelint-plugin-rhythmguard/issues/226))
17
+ - An invalid `--scale` such as `0;4;8`, `4,abc`, `NaN` or `-4,4,8` is refused with `Invalid --scale value "0;4;8": "0;4;8" is not a non-negative number or length.` and exit 1. Before, Stylelint rejected the option, ran no rule, and the audit reported `No issues found` with every gate passing. `audit.scale` in the config is checked the same way. The audit also stops with Stylelint's own text whenever it rejects any rule option, so no other bad option can switch the audit off. ([#227](https://github.com/PetriLahdelma/stylelint-plugin-rhythmguard/issues/227))
18
+ - `--staged` and `--since` scan the changed files from any directory. Git printed paths relative to the repository root, which were resolved against the current directory, so a run from a package directory found no files and exited 0; and names with non-ASCII characters came back quoted and were dropped. Git now runs in the scanned directory with `-z`, and paths resolve against the repository top level, so `src/ä b.css` and `src/sp ace.css` are scanned. ([#228](https://github.com/PetriLahdelma/stylelint-plugin-rhythmguard/issues/228))
19
+ - Negative tokens no longer flip the sign of a fix. Tokens were indexed by their absolute value, so `--space-neg-2: -8px` was offered for `padding: 9px` (writing `var(--space-neg-2)`, which is -8px) and negated for `margin: -9px` (writing `calc(-1 * var(--space-neg-2))`, which is +8px). Token values are now indexed with their sign: a negative token serves only a negative value and is written as is, a positive token is negated for a negative value as before, and a positive value never gets a negative token. Applies to `use-scale` and `no-offscale-transform` with `fixWith: "token"` and to `prefer-token`, including `tokenMapFromCssCustomProperties`, where the last of `--space-2: 8px` and `--space-neg-2: -8px` used to win for both signs. ([#221](https://github.com/PetriLahdelma/stylelint-plugin-rhythmguard/issues/221))
20
+ - A token with more than one value is no longer written by a fix. `--space-3: 12px` at the root and 20px under `@media (min-width: 768px)`, or `--space-2` redefined under `[data-density=compact]`, was indexed once per value, so `padding: 20px` became `var(--space-3)`, which is 12px on mobile. A token whose definitions resolve to more than one px value (or to a value that is not a plain length) is now left out of the token index and of `tokenMapFromCssCustomProperties`, and the fix writes the literal. The same value in two units (`12px` and `0.75rem`) is still one value. ([#231](https://github.com/PetriLahdelma/stylelint-plugin-rhythmguard/issues/231))
21
+ - `unitStrategy: "exact"` with `fixWith: "token"` finds the token in the literal's unit. The snapped value, in rem for a rem literal, was compared with the tokens' px values, so `padding: 1.1rem` against a `1rem` step wrote `var(--space-a)` (`0.0625rem`, 1px) instead of `var(--space-1)` (`1rem`). The lookup now converts the snapped value to px first. Applies to `use-scale` and `no-offscale-transform`. ([#223](https://github.com/PetriLahdelma/stylelint-plugin-rhythmguard/issues/223))
22
+ - An invalid `tokenPattern` (`"["`) is reported again instead of aborting the Stylelint run. `use-scale` and `prefer-token` reported it without a node, which Stylelint rejects with an error about position arguments; the report is now made on the root, once per file, and the file is linted with the default pattern as the option docs describe. ([#222](https://github.com/PetriLahdelma/stylelint-plugin-rhythmguard/issues/222))
23
+ - Autofix converges when `baseFontSize` is not 16. A fix writes rem and em with four decimals, so `0.7rem` at base 18 became `0.6667rem` (12.0006px), and the next run reported that value as off scale because the on-scale check allowed only 0.0001px. A rem or em value is now on the scale when it is within the rounding of four decimals at the base font size (0.00005 of the base, plus the old tolerance); px values are compared as before. Fix then re-lint is clean at base 10, 12, 14, 18 and 20 in `use-scale`, `no-offscale-transform`, `prefer-token`'s numeric scale check and the ESLint Tailwind rule. ([#224](https://github.com/PetriLahdelma/stylelint-plugin-rhythmguard/issues/224))
24
+
25
+ ### Messages
26
+
27
+ - `use-scale` and `no-offscale-transform` with `fixWith: "token"`: when a negative token is written as is for a negative literal, the note gives the signed value, `var(--space-neg-2) holds -8px.` A positive token negated for a negative literal still reads `var(--space-3) holds 12px.` ([#221](https://github.com/PetriLahdelma/stylelint-plugin-rhythmguard/issues/221))
28
+ - `prefer-token`: a negative literal that a negative token holds now reads `Use var(--space-neg-2).`, where it read `Use calc(-1 * var(--space-neg-2)).`; a positive literal is no longer offered a negative token. A token that holds more than one value is no longer named, so such a value reads `No known token holds this value; use the nearest token or add one.` ([#221](https://github.com/PetriLahdelma/stylelint-plugin-rhythmguard/issues/221), [#231](https://github.com/PetriLahdelma/stylelint-plugin-rhythmguard/issues/231))
29
+
9
30
  ## [3.10.2] - 2026-10-15
10
31
 
11
32
  ### Messages
package/README.md CHANGED
@@ -7,7 +7,7 @@
7
7
  Nobody chose 13px. Rhythmguard catches off-scale spacing in CSS and Tailwind class strings, tells you the nearest steps on your scale, and snaps to them or to your tokens when you ask.
8
8
 
9
9
  [![CI](https://img.shields.io/github/actions/workflow/status/petrilahdelma/stylelint-plugin-rhythmguard/ci.yml?branch=main&label=ci)](https://github.com/petrilahdelma/stylelint-plugin-rhythmguard/actions/workflows/ci.yml)
10
- [![npm version](https://img.shields.io/npm/v/stylelint-plugin-rhythmguard?label=npm&color=1f6feb&r=3102)](https://www.npmjs.com/package/stylelint-plugin-rhythmguard)
10
+ [![npm version](https://img.shields.io/npm/v/stylelint-plugin-rhythmguard?label=npm&color=1f6feb&r=3110)](https://www.npmjs.com/package/stylelint-plugin-rhythmguard)
11
11
  [![npm downloads](https://img.shields.io/npm/dm/stylelint-plugin-rhythmguard.svg)](https://www.npmjs.com/package/stylelint-plugin-rhythmguard)
12
12
  [![License: MIT](https://img.shields.io/badge/license-MIT-white.svg)](./LICENSE)
13
13
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "stylelint-plugin-rhythmguard",
3
- "version": "3.10.2",
3
+ "version": "3.11.0",
4
4
  "description": "Nobody chose 13px. Catches off-scale spacing in CSS and Tailwind class strings and snaps it to your scale or tokens. Stylelint rules, an ESLint companion, and an audit CLI.",
5
5
  "bin": {
6
6
  "rhythmguard": "src/cli/index.js"
package/src/audit/args.js CHANGED
@@ -4,6 +4,7 @@ const {
4
4
  normalizeTokenKind,
5
5
  normalizeTokenSourceFormat,
6
6
  } = require('../core/token-sources');
7
+ const { isScaleEntry } = require('../core/options');
7
8
  const { BADGE_METRICS } = require('./render-badge');
8
9
  const {
9
10
  VALID_FORMATS,
@@ -223,9 +224,9 @@ function parseTokenSourcePaths(raw) {
223
224
  }
224
225
 
225
226
 
226
- function parseScale(raw) {
227
+ function parseScale(raw, optionName = '--scale') {
227
228
  if (!raw) {
228
- throw new Error('Missing value for --scale.');
229
+ throw new Error(`Missing value for ${optionName}.`);
229
230
  }
230
231
 
231
232
  if (raw.trim().toLowerCase() === 'auto') {
@@ -240,10 +241,28 @@ function parseScale(raw) {
240
241
  return Number.isFinite(numeric) && !/[a-z%]/i.test(part) ? numeric : part;
241
242
  });
242
243
 
244
+ return validateScale(scale, optionName, raw);
245
+ }
246
+
247
+ /**
248
+ * A scale reaches Stylelint as the use-scale `scale` option, and Stylelint
249
+ * answers an invalid entry with an option warning and no findings at all. Check
250
+ * every entry the way the rule does, so a typo fails here, by name.
251
+ */
252
+ function validateScale(scale, optionName = '--scale', raw = scale) {
243
253
  if (scale.length === 0) {
244
254
  throw new Error('Scale must include at least one value.');
245
255
  }
246
256
 
257
+ const invalid = scale.find((entry) => !isScaleEntry(entry));
258
+ if (invalid !== undefined) {
259
+ const shown = Array.isArray(raw) ? JSON.stringify(raw) : `"${raw}"`;
260
+ throw new Error(
261
+ `Invalid ${optionName} value ${shown}: "${invalid}" is not a non-negative number or length. `
262
+ + 'Use comma-separated values such as 0,4,8,12,16, or auto.',
263
+ );
264
+ }
265
+
247
266
  return scale;
248
267
  }
249
268
 
@@ -268,4 +287,5 @@ module.exports = {
268
287
  parsePositiveInteger,
269
288
  parseScale,
270
289
  parseTokenSourcePaths,
290
+ validateScale,
271
291
  };
@@ -77,21 +77,31 @@ function declarationValueOffset(decl) {
77
77
  return decl.prop.length + between.length;
78
78
  }
79
79
 
80
+ /**
81
+ * Parse with the syntax that will write the file back. SCSS goes through
82
+ * postcss-scss's own parse and stringify: `postcss.parse` ignores a `syntax`
83
+ * option, so it would read `// a: 10px` as a declaration and rewrite it.
84
+ * Returns null for SCSS when postcss-scss cannot be resolved; such a file is
85
+ * skipped, never parsed as CSS.
86
+ */
80
87
  function parseStylesheet(filePath, source) {
81
88
  if (isScssFile(filePath)) {
82
89
  const syntaxPath = resolveScssSyntax();
83
90
  if (!syntaxPath) {
84
91
  return null;
85
92
  }
86
- return postcss.parse(source, { from: filePath, syntax: require(syntaxPath) });
93
+ const syntax = require(syntaxPath);
94
+ return { root: syntax.parse(source, { from: filePath }), syntax };
87
95
  }
88
- return postcss.parse(source, { from: filePath });
96
+ return { root: postcss.parse(source, { from: filePath }), syntax: null };
89
97
  }
90
98
 
91
99
  /**
92
100
  * Apply one replacement across a directory. Dry run unless `write`; returns
93
101
  * the per-file changes and which files were skipped (unparseable, or SCSS
94
- * without postcss-scss).
102
+ * without postcss-scss). `ignorePatterns` are root-relative paths and globs,
103
+ * as the audit takes them; the audit's generated and vendored directories
104
+ * (node_modules, dist, build and the rest of SKIP_DIRS) are always skipped.
95
105
  */
96
106
  function runCodemod({ baseFontSize = 16, dir, ignorePatterns = [], properties = null, px, replacement, write = false }) {
97
107
  const rootDir = path.resolve(process.cwd(), dir);
@@ -100,23 +110,24 @@ function runCodemod({ baseFontSize = 16, dir, ignorePatterns = [], properties =
100
110
  const skipped = [];
101
111
  for (const filePath of files) {
102
112
  const source = fs.readFileSync(filePath, 'utf8');
103
- let root;
113
+ let parsed;
104
114
  try {
105
- root = parseStylesheet(filePath, source);
115
+ parsed = parseStylesheet(filePath, source);
106
116
  } catch {
107
117
  skipped.push({ file: formatPath(filePath), reason: 'could not parse' });
108
118
  continue;
109
119
  }
110
- if (!root) {
111
- skipped.push({ file: formatPath(filePath), reason: 'install postcss-scss to rewrite SCSS' });
120
+ if (!parsed) {
121
+ skipped.push({ file: formatPath(filePath), reason: 'postcss-scss is not installed; install it to rewrite SCSS' });
112
122
  continue;
113
123
  }
124
+ const { root, syntax } = parsed;
114
125
  const changes = replaceLength(root, { baseFontSize, properties, px, replacement });
115
126
  if (changes.length === 0) {
116
127
  continue;
117
128
  }
118
129
  if (write) {
119
- fs.writeFileSync(filePath, root.toString());
130
+ fs.writeFileSync(filePath, syntax ? root.toString(syntax) : root.toString());
120
131
  }
121
132
  results.push({ changes, file: formatPath(filePath) });
122
133
  }
@@ -15,6 +15,7 @@ const {
15
15
  parsePercentage,
16
16
  parsePositiveInteger,
17
17
  parseScale,
18
+ validateScale,
18
19
  } = require('./args');
19
20
  const {
20
21
  DEFAULT_IGNORE_PATH,
@@ -117,9 +118,9 @@ function applyAuditConfig(parsed, configResult) {
117
118
  }
118
119
  applyConfigScalar(next, audit, cliOptions, 'scale', 'scale', (value) => {
119
120
  if (Array.isArray(value)) {
120
- return value;
121
+ return validateScale(value, 'audit.scale');
121
122
  }
122
- return parseScale(String(value));
123
+ return parseScale(String(value), 'audit.scale');
123
124
  });
124
125
 
125
126
  next.decisions = normalizeDecisions(configResult.decisions, { baseFontSize: next.baseFontSize });
@@ -240,6 +241,19 @@ function loadIgnorePatterns(ignorePath) {
240
241
  .filter(Boolean);
241
242
  }
242
243
 
244
+ /**
245
+ * The ignore list a run uses: the ignore file (.rhythmguardignore unless
246
+ * --ignore-path or audit.ignorePath names another), then audit.ignore and
247
+ * --ignore. `parsed` is the options after applyAuditConfig. The audit and
248
+ * `rhythmguard fix` both call this, so they never disagree on what is skipped.
249
+ */
250
+ function resolveIgnorePatterns(parsed) {
251
+ return [
252
+ ...loadIgnorePatterns(parsed.ignorePath),
253
+ ...parsed.ignorePatterns,
254
+ ];
255
+ }
256
+
243
257
  module.exports = {
244
258
  applyAuditConfig,
245
259
  applyConfigScalar,
@@ -248,4 +262,5 @@ module.exports = {
248
262
  normalizeCliTokenSources,
249
263
  normalizeConfigTokenSources,
250
264
  parseBooleanOption,
265
+ resolveIgnorePatterns,
251
266
  };
@@ -14,7 +14,7 @@ const { createDefaultAuditOptions } = require('./shared');
14
14
  const {
15
15
  applyAuditConfig,
16
16
  loadAuditConfig,
17
- loadIgnorePatterns,
17
+ resolveIgnorePatterns,
18
18
  } = require('./config');
19
19
  const { buildReport, collectTokenDefinitions } = require('./contract');
20
20
  const { DEFAULT_SCALE, formatPath } = require('./shared');
@@ -34,11 +34,7 @@ const { collectTailwindFindings, collectTailwindMotionFindings } = require('./sc
34
34
  async function createAuditReport(options) {
35
35
  const parsed = normalizeCreateAuditOptions(options);
36
36
  const resolvedDir = assertDirectory(parsed.dir);
37
- let ignorePatterns;
38
- ignorePatterns = [
39
- ...loadIgnorePatterns(parsed.ignorePath),
40
- ...parsed.ignorePatterns,
41
- ];
37
+ const ignorePatterns = resolveIgnorePatterns(parsed);
42
38
 
43
39
  const scanFiles = getScanFiles(resolvedDir, ignorePatterns, parsed);
44
40
 
@@ -77,34 +77,36 @@ function getScanFiles(rootDir, ignorePatterns, parsed) {
77
77
  }
78
78
 
79
79
  function getGitChangedScanFiles(rootDir, ignorePatterns, parsed) {
80
- const args = parsed.staged
81
- ? ['diff', '--name-only', '--cached', '--diff-filter=ACMR', '--']
82
- : ['diff', '--name-only', '--diff-filter=ACMR', parsed.since, '--'];
83
- let output;
84
-
85
- try {
86
- output = execFileSync('git', args, {
87
- cwd: process.cwd(),
88
- encoding: 'utf8',
89
- stdio: ['ignore', 'pipe', 'pipe'],
90
- });
91
- } catch (err) {
92
- const stderr = err.stderr ? String(err.stderr).trim() : err.message;
93
- throw new Error(`Unable to read changed files from git: ${stderr}`, { cause: err });
80
+ // -z prints names raw and NUL-separated: no quoting of spaces or non-ASCII
81
+ // bytes. diff.relative is pinned off so every name is relative to the
82
+ // repository top level, whatever the user's git config says.
83
+ const diffArgs = parsed.staged
84
+ ? ['diff', '--name-only', '-z', '--cached', '--diff-filter=ACMR', '--']
85
+ : ['diff', '--name-only', '-z', '--diff-filter=ACMR', parsed.since, '--'];
86
+ // Git runs in the scanned directory, so the repository is the one that holds
87
+ // the files, whichever directory the command was started from.
88
+ const topLevel = runGit(rootDir, ['rev-parse', '--show-toplevel']).replace(/\r?\n$/, '');
89
+ const output = runGit(rootDir, ['-c', 'diff.relative=false', ...diffArgs]);
90
+
91
+ // git reports the top level with symlinks resolved (/private/var on macOS);
92
+ // compare in that form, then name files under the directory as it was given.
93
+ const realRootDir = fs.realpathSync(rootDir);
94
+ const changedFiles = output.split('\0').filter(Boolean);
95
+ const changedPaths = [];
96
+ for (const filePath of changedFiles) {
97
+ const realPath = path.resolve(topLevel, filePath);
98
+ if (isPathInside(realRootDir, realPath)) {
99
+ changedPaths.push(path.join(rootDir, path.relative(realRootDir, realPath)));
100
+ }
94
101
  }
95
102
 
96
103
  const cssFiles = [];
97
104
  const templateFiles = [];
98
105
  const ignoreMatchers = createIgnoreMatchers(ignorePatterns);
99
106
  const seen = new Set();
100
- const changedFiles = output.split(/\r?\n/)
101
- .map((filePath) => filePath.trim())
102
- .filter(Boolean);
103
-
104
- for (const filePath of changedFiles) {
105
- const fullPath = path.resolve(process.cwd(), filePath);
106
107
 
107
- if (!isPathInside(rootDir, fullPath) || seen.has(fullPath) || !fs.existsSync(fullPath)) {
108
+ for (const fullPath of changedPaths) {
109
+ if (seen.has(fullPath) || !fs.existsSync(fullPath)) {
108
110
  continue;
109
111
  }
110
112
 
@@ -137,6 +139,19 @@ function getGitChangedScanFiles(rootDir, ignorePatterns, parsed) {
137
139
  };
138
140
  }
139
141
 
142
+ function runGit(cwd, args) {
143
+ try {
144
+ return execFileSync('git', args, {
145
+ cwd,
146
+ encoding: 'utf8',
147
+ stdio: ['ignore', 'pipe', 'pipe'],
148
+ });
149
+ } catch (err) {
150
+ const stderr = err.stderr ? String(err.stderr).trim() : err.message;
151
+ throw new Error(`Unable to read changed files from git: ${stderr}`, { cause: err });
152
+ }
153
+ }
154
+
140
155
  function isPathInside(rootDir, filePath) {
141
156
  const relativePath = path.relative(rootDir, filePath);
142
157
  return relativePath === '' || (!relativePath.startsWith('..') && !path.isAbsolute(relativePath));
@@ -113,6 +113,8 @@ async function runStylelintAudit(cssFiles, options) {
113
113
  }
114
114
  }
115
115
 
116
+ assertNoInvalidOptions(results);
117
+
116
118
  results.scssFiles = scssFiles.length;
117
119
  results.scssSkipped = scssSkipped;
118
120
  // Stylelint returns no result at all for a file its ignore file excludes, so
@@ -122,6 +124,24 @@ async function runStylelintAudit(cssFiles, options) {
122
124
  return results;
123
125
  }
124
126
 
127
+ /**
128
+ * Stylelint does not run a rule whose options it rejects: it reports an
129
+ * invalidOptionWarning and no findings for that rule. Read as findings, that is
130
+ * a clean audit and a passing gate, so any rejected option stops the run.
131
+ */
132
+ function assertNoInvalidOptions(results) {
133
+ const texts = new Set();
134
+ for (const result of results) {
135
+ for (const warning of result.invalidOptionWarnings || []) {
136
+ texts.add(warning.text);
137
+ }
138
+ }
139
+
140
+ if (texts.size > 0) {
141
+ throw new Error(`Stylelint rejected the audit's rule options, so no findings could be collected: ${Array.from(texts).join('; ')}`);
142
+ }
143
+ }
144
+
125
145
  function collectCssFindings(fileResults) {
126
146
  const findings = [];
127
147
  const sources = new Map();
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/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,11 +148,12 @@ function run() {
108
148
  const lines = [];
109
149
  let total = 0;
110
150
  const touched = new Set();
111
- const skipped = [];
151
+ const skipped = new Map();
112
152
  for (const replacement of plan.replacements) {
113
153
  const outcome = runCodemod({
114
154
  baseFontSize: parsed.baseFontSize,
115
155
  dir: parsed.dir,
156
+ ignorePatterns,
116
157
  properties: parsed.properties,
117
158
  px: replacement.px,
118
159
  replacement: replacement.replacement,
@@ -125,7 +166,10 @@ function run() {
125
166
  lines.push(` ${result.file}:${change.line}:${change.column} ${change.property}: ${change.from} -> ${change.to}`);
126
167
  }
127
168
  }
128
- skipped.push(...outcome.skipped);
169
+ // --decided runs once per decision; a file that cannot be read is named once
170
+ for (const entry of outcome.skipped) {
171
+ skipped.set(entry.file, entry);
172
+ }
129
173
  }
130
174
 
131
175
  if (lines.length > 0) {
@@ -138,7 +182,7 @@ function run() {
138
182
  const noun2 = plan.skippedSnaps.length === 1 ? 'snap decision has' : 'snap decisions have';
139
183
  lines.push(`${plan.skippedSnaps.length} ${noun2} no "to" and ${plan.skippedSnaps.length === 1 ? 'was' : 'were'} skipped: ${plan.skippedSnaps.join(', ')}`);
140
184
  }
141
- for (const entry of skipped) {
185
+ for (const entry of skipped.values()) {
142
186
  lines.push(`skipped ${entry.file}: ${entry.reason}`);
143
187
  }
144
188
  process.stdout.write(`${lines.join('\n')}\n`);
@@ -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
  };
@@ -809,5 +809,6 @@ module.exports = {
809
809
  createPropertyScaleResolver,
810
810
  isGroupScalesMap,
811
811
  isPlainObject,
812
+ isScaleEntry,
812
813
  resolvePropertyScale,
813
814
  };
@@ -6,8 +6,8 @@ const {
6
6
  nearestScaleValues,
7
7
  nearestText,
8
8
  normalizeScale,
9
- numbersEqual,
10
9
  parseLengthToken,
10
+ pxOnScale,
11
11
  toPx,
12
12
  } = require('./length');
13
13
 
@@ -187,7 +187,7 @@ function analyzeClassToken(token, options, scalePx) {
187
187
  return null;
188
188
  }
189
189
 
190
- const isOnScale = scalePx.some((entry) => numbersEqual(entry, pxValue));
190
+ const isOnScale = pxOnScale(scalePx, pxValue, parsedLength.unit, options.baseFontSize);
191
191
  if (isOnScale) {
192
192
  return null;
193
193
  }