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 +21 -0
- package/README.md +1 -1
- package/package.json +1 -1
- package/src/audit/args.js +22 -2
- package/src/audit/codemod.js +19 -8
- package/src/audit/config.js +17 -2
- package/src/audit/report.js +2 -6
- package/src/audit/scan/files.js +36 -21
- package/src/audit/scan/stylesheets.js +20 -0
- package/src/cli/audit.js +7 -3
- package/src/cli/fix.js +50 -6
- package/src/core/length.js +15 -0
- package/src/core/options.js +1 -0
- package/src/core/tailwind-class-analysis.js +2 -2
- package/src/core/token-index.js +73 -24
- package/src/core/token-map.js +23 -10
- package/src/eslint/literal-spans.js +234 -0
- package/src/eslint/rules/tailwind-class-use-motion-scale.js +9 -32
- package/src/eslint/rules/tailwind-class-use-scale.js +9 -32
- package/src/rules/no-offscale-transform/index.js +3 -2
- package/src/rules/prefer-token/index.js +33 -30
- package/src/rules/report.js +3 -1
- package/src/rules/use-scale/index.js +4 -3
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
|
[](https://github.com/petrilahdelma/stylelint-plugin-rhythmguard/actions/workflows/ci.yml)
|
|
10
|
-
[](https://www.npmjs.com/package/stylelint-plugin-rhythmguard)
|
|
11
11
|
[](https://www.npmjs.com/package/stylelint-plugin-rhythmguard)
|
|
12
12
|
[](./LICENSE)
|
|
13
13
|
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "stylelint-plugin-rhythmguard",
|
|
3
|
-
"version": "3.
|
|
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(
|
|
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
|
};
|
package/src/audit/codemod.js
CHANGED
|
@@ -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
|
-
|
|
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
|
|
113
|
+
let parsed;
|
|
104
114
|
try {
|
|
105
|
-
|
|
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 (!
|
|
111
|
-
skipped.push({ file: formatPath(filePath), reason: '
|
|
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
|
}
|
package/src/audit/config.js
CHANGED
|
@@ -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
|
};
|
package/src/audit/report.js
CHANGED
|
@@ -14,7 +14,7 @@ const { createDefaultAuditOptions } = require('./shared');
|
|
|
14
14
|
const {
|
|
15
15
|
applyAuditConfig,
|
|
16
16
|
loadAuditConfig,
|
|
17
|
-
|
|
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
|
-
|
|
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
|
|
package/src/audit/scan/files.js
CHANGED
|
@@ -77,34 +77,36 @@ function getScanFiles(rootDir, ignorePatterns, parsed) {
|
|
|
77
77
|
}
|
|
78
78
|
|
|
79
79
|
function getGitChangedScanFiles(rootDir, ignorePatterns, parsed) {
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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,
|
|
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 = {
|
|
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
|
-
|
|
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
|
-
|
|
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`);
|
package/src/core/length.js
CHANGED
|
@@ -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
|
};
|
package/src/core/options.js
CHANGED
|
@@ -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
|
|
190
|
+
const isOnScale = pxOnScale(scalePx, pxValue, parsedLength.unit, options.baseFontSize);
|
|
191
191
|
if (isOnScale) {
|
|
192
192
|
return null;
|
|
193
193
|
}
|