stylelint-plugin-rhythmguard 3.12.0 → 3.13.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 +40 -0
- package/README.md +1 -1
- package/package.json +23 -1
- package/src/audit/args.js +23 -4
- package/src/audit/baseline.js +15 -2
- package/src/audit/codemod.js +56 -5
- package/src/audit/config.js +2 -1
- package/src/audit/contract.js +3 -0
- package/src/audit/decisions.js +45 -16
- package/src/audit/render-markdown.js +22 -21
- package/src/audit/render-text.js +1 -0
- package/src/audit/render-utils.js +28 -1
- package/src/audit/report.js +14 -2
- package/src/audit/scan/files.js +139 -30
- package/src/audit/token-chains.js +4 -1
- package/src/audit/token-ladders.js +23 -2
- package/src/cli/doctor.js +28 -3
- package/src/cli/fix.js +7 -6
- package/src/cli/index.js +2 -2
- package/src/cli/init.js +41 -5
- package/src/cli/quickstart.js +46 -8
- package/src/configs/embed.js +2 -0
- package/src/configs/motion.js +1 -0
- package/src/configs/recommended.js +1 -0
- package/src/configs/strict.js +1 -0
- package/src/configs/tailwind.js +1 -0
- package/src/core/decisions.js +2 -1
- package/src/core/json.js +25 -0
- package/src/core/length.js +11 -0
- package/src/core/options.js +39 -20
- package/src/core/scale-inference.js +78 -23
- package/src/core/tailwind-class-analysis.js +3 -2
- package/src/core/token-map.js +2 -1
- package/src/core/token-sources.js +2 -1
- package/src/eslint/index.js +14 -8
- package/src/eslint/rules/tailwind-class-use-motion-scale.js +1 -0
- package/src/eslint/rules/tailwind-class-use-scale.js +1 -0
- package/src/rules/no-offscale-transform/index.js +58 -53
- package/src/rules/prefer-token/index.js +31 -46
- package/src/rules/use-motion-scale/index.js +14 -1
- package/src/rules/use-scale/index.js +75 -102
- package/src/rules/validate.js +13 -4
- package/types/__checks__/consumer.ts +20 -1
- package/types/__checks__/eslint-config.ts +8 -0
- package/types/__checks__/shipped-configs.ts +96 -0
- package/types/audit.d.ts +101 -16
- package/types/index.d.ts +15 -0
- package/types/shared.d.ts +126 -20
package/src/audit/render-text.js
CHANGED
|
@@ -22,6 +22,7 @@ function renderText(report) {
|
|
|
22
22
|
...(report.scanned && report.scanned.scssFiles > 0 ? [` SCSS files scanned ${String(report.scanned.scssFiles - report.scanned.scssSkipped).padStart(4)}`] : []),
|
|
23
23
|
...(report.scanned && report.scanned.scssSkipped > 0 ? [` SCSS files skipped ${String(report.scanned.scssSkipped).padStart(4)} (install postcss-scss to audit them)`] : []),
|
|
24
24
|
...(report.scanned && report.scanned.stylelintIgnored > 0 ? [` Ignored by .stylelintignore ${String(report.scanned.stylelintIgnored).padStart(4)} (not audited)`] : []),
|
|
25
|
+
...(report.scanned && report.scanned.linksOutsideRoot > 0 ? [` Links not followed ${String(report.scanned.linksOutsideRoot).padStart(4)} (they point outside the scanned directory)`] : []),
|
|
25
26
|
...(parseErrors(report).length > 0 ? [` Parse errors ${String(parseErrors(report).length).padStart(4)} (not audited)`] : []),
|
|
26
27
|
` Files with issues ${String(report.filesWithIssues).padStart(4)}`,
|
|
27
28
|
` Scale cleanliness ${scoreBar(report.scaleCleanliness)} ${report.scaleCleanliness}%`,
|
|
@@ -20,8 +20,33 @@ function truncate(value, maxLength) {
|
|
|
20
20
|
return `…${value.slice(value.length - maxLength + 1)}`;
|
|
21
21
|
}
|
|
22
22
|
|
|
23
|
+
/** Plain Markdown text, safe in a table cell: pipes and backticks escaped, one line. */
|
|
23
24
|
function escapeMarkdown(value) {
|
|
24
|
-
return String(value).replace(/\|/g, '\\|').replace(/`/g, '\\`');
|
|
25
|
+
return String(value).replace(/\r?\n/g, ' ').replace(/\|/g, '\\|').replace(/`/g, '\\`');
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* A Markdown code span holding `value` verbatim. A backslash does not escape
|
|
30
|
+
* anything inside a code span, so a backtick in the value is kept by a fence
|
|
31
|
+
* one backtick longer than the longest run in it, padded with a space when the
|
|
32
|
+
* value starts or ends with a backtick (CommonMark strips one space from each
|
|
33
|
+
* side). In a table cell a pipe is still escaped, as GFM reads `\|` before it
|
|
34
|
+
* finds code spans (#285).
|
|
35
|
+
*/
|
|
36
|
+
function markdownCode(value, { table = false } = {}) {
|
|
37
|
+
let text = String(value).replace(/\r?\n/g, ' ');
|
|
38
|
+
if (table) {
|
|
39
|
+
text = text.replace(/\|/g, '\\|');
|
|
40
|
+
}
|
|
41
|
+
const longestRun = Math.max(0, ...(text.match(/`+/g) || []).map((run) => run.length));
|
|
42
|
+
const fence = '`'.repeat(longestRun + 1);
|
|
43
|
+
const pad = /^`|`$/.test(text) || (/^ /.test(text) && / $/.test(text) && text.trim() !== '') ? ' ' : '';
|
|
44
|
+
return `${fence}${pad}${text}${pad}${fence}`;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/** markdownCode for a table cell. */
|
|
48
|
+
function tableCode(value) {
|
|
49
|
+
return markdownCode(value, { table: true });
|
|
25
50
|
}
|
|
26
51
|
|
|
27
52
|
function escapeHtml(value) {
|
|
@@ -36,6 +61,8 @@ module.exports = {
|
|
|
36
61
|
escapeHtml,
|
|
37
62
|
escapeMarkdown,
|
|
38
63
|
histBar,
|
|
64
|
+
markdownCode,
|
|
65
|
+
tableCode,
|
|
39
66
|
scoreBar,
|
|
40
67
|
truncate,
|
|
41
68
|
};
|
package/src/audit/report.js
CHANGED
|
@@ -24,6 +24,7 @@ const {
|
|
|
24
24
|
inferScaleFromDefinitions,
|
|
25
25
|
scaleFromDefinitions,
|
|
26
26
|
} = require('../core/scale-inference');
|
|
27
|
+
const { buildGroupScales } = require('../core/options');
|
|
27
28
|
const { applyDecisions, buildDecisionPlan } = require('./decisions');
|
|
28
29
|
const { collectDeclarations, resolveTokenChains, toChainsContract } = require('./token-chains');
|
|
29
30
|
const { resolveTokenLadders, toLaddersContract } = require('./token-ladders');
|
|
@@ -53,11 +54,15 @@ async function createAuditReport(options) {
|
|
|
53
54
|
? tokenSourceResult
|
|
54
55
|
: parseTokenSources({ baseFontSize: parsed.baseFontSize, sources: parsed.tokenSources, tokenKind: 'spacing' });
|
|
55
56
|
|
|
57
|
+
// A spacing scale in audit.scales is the one use-scale checks spacing
|
|
58
|
+
// against, so it is the scale the report names and prefer-token reads (#283).
|
|
59
|
+
const groupScales = buildGroupScales(parsed.scales);
|
|
60
|
+
const spacingOverride = groupScales.spacing && Array.isArray(groupScales.spacing.scale) ? groupScales.spacing.scale : null;
|
|
56
61
|
const scale = resolveAuditScale({
|
|
57
62
|
baseFontSize: parsed.baseFontSize,
|
|
58
63
|
cssFiles,
|
|
59
64
|
readSource,
|
|
60
|
-
requested: parsed.scale,
|
|
65
|
+
requested: spacingOverride || parsed.scale,
|
|
61
66
|
tokenSourceResult: spacingSourceResult,
|
|
62
67
|
});
|
|
63
68
|
|
|
@@ -96,6 +101,7 @@ async function createAuditReport(options) {
|
|
|
96
101
|
dir: parsed.dir,
|
|
97
102
|
externalTokenDefinitions: tokenSourceResult.definitions,
|
|
98
103
|
includeMotion: parsed.includeMotion,
|
|
104
|
+
linksOutsideRoot: scanFiles.linksOutsideRoot || [],
|
|
99
105
|
motionFindings,
|
|
100
106
|
parseErrorFindings,
|
|
101
107
|
readSource,
|
|
@@ -133,6 +139,7 @@ async function createAuditReport(options) {
|
|
|
133
139
|
report.decisionPlan = buildDecisionPlan({
|
|
134
140
|
baseFontSize: parsed.baseFontSize,
|
|
135
141
|
decisions: parsed.decisions || [],
|
|
142
|
+
groupScales,
|
|
136
143
|
offScaleFindings: [
|
|
137
144
|
...stylelintFindings.filter((finding) => finding.type === 'off-scale'),
|
|
138
145
|
...collectTailwindFindings(templateFiles, lintOptions),
|
|
@@ -269,7 +276,12 @@ function resolveAuditScale({ baseFontSize, cssFiles, readSource = readSourceFrom
|
|
|
269
276
|
return { files: [], ...(rejected ? { rejected } : {}), source: 'fallback', tokenCount: 0, values: DEFAULT_SCALE };
|
|
270
277
|
}
|
|
271
278
|
|
|
272
|
-
function normalizeCreateAuditOptions(
|
|
279
|
+
function normalizeCreateAuditOptions(rawOptions) {
|
|
280
|
+
// A key set to undefined is an omitted key: it neither replaces a default nor
|
|
281
|
+
// counts as given, so the config file still applies to it (#284).
|
|
282
|
+
const options = Object.fromEntries(
|
|
283
|
+
Object.entries(rawOptions || {}).filter(([, value]) => value !== undefined),
|
|
284
|
+
);
|
|
273
285
|
const parsed = {
|
|
274
286
|
...createDefaultAuditOptions(),
|
|
275
287
|
...options,
|
package/src/audit/scan/files.js
CHANGED
|
@@ -30,37 +30,101 @@ function assertDirectory(dir) {
|
|
|
30
30
|
return resolvedDir;
|
|
31
31
|
}
|
|
32
32
|
|
|
33
|
+
/**
|
|
34
|
+
* Every stylesheet and template under `rootDir`. Symbolic links are followed
|
|
35
|
+
* when they resolve inside `rootDir`: a linked directory is walked unless it
|
|
36
|
+
* is one of the directories being walked already (a cycle), and a file
|
|
37
|
+
* reached by more than one path is scanned once, under its own path when the
|
|
38
|
+
* walk reaches it directly, else under the first linked path in sorted order
|
|
39
|
+
* (#289). A link that resolves outside `rootDir` is never followed, so the
|
|
40
|
+
* audit cannot read, and `rhythmguard fix` cannot rewrite, files outside the
|
|
41
|
+
* directory it was given; such links are returned in `linksOutsideRoot`
|
|
42
|
+
* (root-relative). A broken link is skipped.
|
|
43
|
+
*/
|
|
33
44
|
function walkFiles(rootDir, ignorePatterns = []) {
|
|
34
|
-
const cssFiles = [];
|
|
35
|
-
const templateFiles = [];
|
|
36
45
|
const ignoreMatchers = createIgnoreMatchers(ignorePatterns);
|
|
46
|
+
const realRoot = realPathOf(rootDir) || rootDir;
|
|
47
|
+
const found = [];
|
|
48
|
+
const linksOutsideRoot = [];
|
|
37
49
|
|
|
38
|
-
|
|
50
|
+
// `realDir` is where `currentDir` really is. Below a plain directory that is
|
|
51
|
+
// its real path plus the entry name, so only a link costs a realpath call.
|
|
52
|
+
function walk(currentDir, realDir, ancestors, viaLink) {
|
|
39
53
|
for (const entry of fs.readdirSync(currentDir, { withFileTypes: true })) {
|
|
40
54
|
const fullPath = path.join(currentDir, entry.name);
|
|
41
55
|
const relativePath = toPosixRelativePath(rootDir, fullPath);
|
|
56
|
+
const link = entry.isSymbolicLink();
|
|
57
|
+
let kind = entry;
|
|
58
|
+
if (link) {
|
|
59
|
+
try {
|
|
60
|
+
kind = fs.statSync(fullPath);
|
|
61
|
+
} catch {
|
|
62
|
+
continue;
|
|
63
|
+
}
|
|
64
|
+
}
|
|
42
65
|
|
|
43
|
-
if (shouldIgnorePath(relativePath, entry, ignoreMatchers)) {
|
|
66
|
+
if (shouldIgnorePath(relativePath, { isDirectory: () => kind.isDirectory(), name: entry.name }, ignoreMatchers)) {
|
|
44
67
|
continue;
|
|
45
68
|
}
|
|
46
69
|
|
|
47
|
-
|
|
48
|
-
|
|
70
|
+
const realPath = link ? realPathOf(fullPath) : path.join(realDir, entry.name);
|
|
71
|
+
if (!realPath) {
|
|
72
|
+
continue;
|
|
73
|
+
}
|
|
74
|
+
if (link && !isPathInside(realRoot, realPath)) {
|
|
75
|
+
if (kind.isDirectory() || isCssFile(fullPath) || isTemplateFile(fullPath)) {
|
|
76
|
+
linksOutsideRoot.push(relativePath);
|
|
77
|
+
}
|
|
49
78
|
continue;
|
|
50
79
|
}
|
|
51
80
|
|
|
52
|
-
if (
|
|
53
|
-
if (
|
|
54
|
-
|
|
55
|
-
} else if (isTemplateFile(fullPath)) {
|
|
56
|
-
templateFiles.push(fullPath);
|
|
81
|
+
if (kind.isDirectory()) {
|
|
82
|
+
if (!ancestors.has(realPath)) {
|
|
83
|
+
walk(fullPath, realPath, new Set([...ancestors, realPath]), viaLink || link);
|
|
57
84
|
}
|
|
85
|
+
continue;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
if (kind.isFile() && (isCssFile(fullPath) || isTemplateFile(fullPath))) {
|
|
89
|
+
found.push({ fullPath, realPath, viaLink: viaLink || link });
|
|
58
90
|
}
|
|
59
91
|
}
|
|
60
92
|
}
|
|
61
93
|
|
|
62
|
-
walk(rootDir);
|
|
63
|
-
|
|
94
|
+
walk(rootDir, realRoot, new Set([realRoot]), false);
|
|
95
|
+
|
|
96
|
+
const chosen = new Map();
|
|
97
|
+
for (const file of found) {
|
|
98
|
+
const current = chosen.get(file.realPath);
|
|
99
|
+
if (!current || preferPath(file, current)) {
|
|
100
|
+
chosen.set(file.realPath, file);
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
const cssFiles = [];
|
|
104
|
+
const templateFiles = [];
|
|
105
|
+
for (const file of found) {
|
|
106
|
+
if (chosen.get(file.realPath) !== file) {
|
|
107
|
+
continue;
|
|
108
|
+
}
|
|
109
|
+
(isCssFile(file.fullPath) ? cssFiles : templateFiles).push(file.fullPath);
|
|
110
|
+
}
|
|
111
|
+
return { cssFiles, linksOutsideRoot: linksOutsideRoot.sort(), templateFiles };
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
// A direct path beats a linked one; between two of a kind, the first in sorted order.
|
|
115
|
+
function preferPath(candidate, current) {
|
|
116
|
+
if (candidate.viaLink !== current.viaLink) {
|
|
117
|
+
return !candidate.viaLink;
|
|
118
|
+
}
|
|
119
|
+
return candidate.fullPath < current.fullPath;
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
function realPathOf(filePath) {
|
|
123
|
+
try {
|
|
124
|
+
return fs.realpathSync(filePath);
|
|
125
|
+
} catch {
|
|
126
|
+
return null;
|
|
127
|
+
}
|
|
64
128
|
}
|
|
65
129
|
|
|
66
130
|
function getScanFiles(rootDir, ignorePatterns, parsed) {
|
|
@@ -87,11 +151,10 @@ function getGitChangedScanFiles(rootDir, ignorePatterns, parsed) {
|
|
|
87
151
|
// git reports the top level with symlinks resolved (/private/var on macOS);
|
|
88
152
|
// compare in that form, then name files under the directory as it was given.
|
|
89
153
|
const realRootDir = fs.realpathSync(rootDir);
|
|
90
|
-
const cssFiles = [];
|
|
91
|
-
const templateFiles = [];
|
|
92
154
|
const ignoreMatchers = createIgnoreMatchers(ignorePatterns);
|
|
93
155
|
const seen = new Set();
|
|
94
|
-
const
|
|
156
|
+
const found = [];
|
|
157
|
+
const linksOutsideRoot = [];
|
|
95
158
|
|
|
96
159
|
for (const entry of changed) {
|
|
97
160
|
const realPath = path.resolve(topLevel, entry.path);
|
|
@@ -103,13 +166,18 @@ function getGitChangedScanFiles(rootDir, ignorePatterns, parsed) {
|
|
|
103
166
|
continue;
|
|
104
167
|
}
|
|
105
168
|
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
169
|
+
// A staged regular file is read from its index blob: that is what gets
|
|
170
|
+
// committed, whatever the working tree holds now, or whether the file is
|
|
171
|
+
// still there at all. A staged symbolic link is followed on disk, as a
|
|
172
|
+
// changed one is in a --since run (#289).
|
|
173
|
+
const link = entry.mode === SYMLINK_MODE;
|
|
174
|
+
if (parsed.staged && !REGULAR_FILE_MODES.has(entry.mode) && !link) {
|
|
175
|
+
continue;
|
|
176
|
+
}
|
|
177
|
+
if ((!parsed.staged || link) && !isFileOnDisk(fullPath)) {
|
|
178
|
+
continue;
|
|
179
|
+
}
|
|
180
|
+
if (!isCssFile(fullPath) && !isTemplateFile(fullPath)) {
|
|
113
181
|
continue;
|
|
114
182
|
}
|
|
115
183
|
|
|
@@ -119,20 +187,44 @@ function getGitChangedScanFiles(rootDir, ignorePatterns, parsed) {
|
|
|
119
187
|
}
|
|
120
188
|
|
|
121
189
|
seen.add(fullPath);
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
} else {
|
|
190
|
+
const fileRealPath = realPathOf(fullPath) || fullPath;
|
|
191
|
+
// A link that resolves outside the scanned directory is not followed (#289).
|
|
192
|
+
if (!isPathInside(realRootDir, fileRealPath)) {
|
|
193
|
+
linksOutsideRoot.push(relativePath);
|
|
127
194
|
continue;
|
|
128
195
|
}
|
|
129
|
-
|
|
130
|
-
|
|
196
|
+
found.push({
|
|
197
|
+
blob: parsed.staged && !link ? entry.blob : null,
|
|
198
|
+
fullPath,
|
|
199
|
+
realPath: fileRealPath,
|
|
200
|
+
viaLink: link || isSymbolicLink(fullPath),
|
|
201
|
+
});
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
// A file changed under two paths (itself and a link to it) is scanned once.
|
|
205
|
+
const chosen = new Map();
|
|
206
|
+
for (const file of found) {
|
|
207
|
+
const current = chosen.get(file.realPath);
|
|
208
|
+
if (!current || preferPath(file, current)) {
|
|
209
|
+
chosen.set(file.realPath, file);
|
|
210
|
+
}
|
|
211
|
+
}
|
|
212
|
+
const cssFiles = [];
|
|
213
|
+
const templateFiles = [];
|
|
214
|
+
const stagedBlobs = new Map();
|
|
215
|
+
for (const file of found) {
|
|
216
|
+
if (chosen.get(file.realPath) !== file) {
|
|
217
|
+
continue;
|
|
218
|
+
}
|
|
219
|
+
(isCssFile(file.fullPath) ? cssFiles : templateFiles).push(file.fullPath);
|
|
220
|
+
if (file.blob) {
|
|
221
|
+
stagedBlobs.set(file.fullPath, file.blob);
|
|
131
222
|
}
|
|
132
223
|
}
|
|
133
224
|
|
|
134
225
|
const result = {
|
|
135
226
|
cssFiles,
|
|
227
|
+
linksOutsideRoot: linksOutsideRoot.sort(),
|
|
136
228
|
scanScope: {
|
|
137
229
|
changedFiles: changed.length,
|
|
138
230
|
mode: parsed.staged ? 'staged' : 'since',
|
|
@@ -149,6 +241,23 @@ function getGitChangedScanFiles(rootDir, ignorePatterns, parsed) {
|
|
|
149
241
|
}
|
|
150
242
|
|
|
151
243
|
const REGULAR_FILE_MODES = new Set(['100644', '100755']);
|
|
244
|
+
const SYMLINK_MODE = '120000';
|
|
245
|
+
|
|
246
|
+
function isFileOnDisk(filePath) {
|
|
247
|
+
try {
|
|
248
|
+
return fs.statSync(filePath).isFile();
|
|
249
|
+
} catch {
|
|
250
|
+
return false;
|
|
251
|
+
}
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
function isSymbolicLink(filePath) {
|
|
255
|
+
try {
|
|
256
|
+
return fs.lstatSync(filePath).isSymbolicLink();
|
|
257
|
+
} catch {
|
|
258
|
+
return false;
|
|
259
|
+
}
|
|
260
|
+
}
|
|
152
261
|
|
|
153
262
|
// -z prints names raw and NUL-separated: no quoting of spaces or non-ASCII
|
|
154
263
|
// bytes. diff.relative is pinned off so every name is relative to the
|
|
@@ -28,8 +28,11 @@ const SPACING_TOKEN_NAME = /^--size(?:-|$)|(?:^--|-)(?<!letter-)(?<!word-)(?:spa
|
|
|
28
28
|
const VAR_REFERENCE = /^var\(\s*(--[\w-]+)\s*(?:,\s*([\s\S]+))?\)$/;
|
|
29
29
|
const OUTCOMES = ['on-scale', 'off-scale', 'unresolved', 'ambiguous', 'computed', 'non-length'];
|
|
30
30
|
|
|
31
|
+
// Shadows are never spacing, even when `inset` is in the name (Tailwind v4 `--inset-shadow-sm`, issue #295).
|
|
32
|
+
const SHADOW_TOKEN_NAME = /shadow/i;
|
|
33
|
+
|
|
31
34
|
function isSpacingTokenName(token) {
|
|
32
|
-
return token.startsWith('--') && SPACING_TOKEN_NAME.test(token);
|
|
35
|
+
return token.startsWith('--') && SPACING_TOKEN_NAME.test(token) && !SHADOW_TOKEN_NAME.test(token);
|
|
33
36
|
}
|
|
34
37
|
|
|
35
38
|
/** Every custom property declaration the audit can see: scanned stylesheets plus external token definitions. */
|
|
@@ -129,6 +129,26 @@ function sameEnd(a, b) {
|
|
|
129
129
|
return (a === null && b === null) || (a !== null && b !== null && numbersEqual(a, b));
|
|
130
130
|
}
|
|
131
131
|
|
|
132
|
+
/**
|
|
133
|
+
* The ranges adjacent steps are compared on. A ladder whose every step is
|
|
134
|
+
* negative (`--space-negative-1: -4px` to `-3: -16px`) grows in magnitude, so
|
|
135
|
+
* it is compared by magnitude, its `min` end being the smaller magnitude
|
|
136
|
+
* (issue #294). A ladder that crosses zero keeps its signed order.
|
|
137
|
+
*/
|
|
138
|
+
function comparableSteps(steps) {
|
|
139
|
+
const negative = steps.every((step) => {
|
|
140
|
+
const ends = [step.min, step.max].filter((end) => end !== null);
|
|
141
|
+
return ends.length > 0 && ends.every((end) => end < 0);
|
|
142
|
+
});
|
|
143
|
+
if (!negative) {
|
|
144
|
+
return steps;
|
|
145
|
+
}
|
|
146
|
+
return steps.map((step) => ({
|
|
147
|
+
max: step.min === null ? null : -step.min,
|
|
148
|
+
min: step.max === null ? null : -step.max,
|
|
149
|
+
}));
|
|
150
|
+
}
|
|
151
|
+
|
|
132
152
|
function compareEnds(lower, upper, end, minStepPx) {
|
|
133
153
|
const a = lower[end];
|
|
134
154
|
const b = upper[end];
|
|
@@ -191,11 +211,12 @@ function resolveTokenLadders({ baseFontSize = 16, declarations, minStepPx = DEFA
|
|
|
191
211
|
for (const ladder of eligible) {
|
|
192
212
|
if (aliases.has(ladder)) continue;
|
|
193
213
|
const steps = ladder.steps.sort((a, b) => a.rank - b.rank);
|
|
214
|
+
const compared = comparableSteps(steps);
|
|
194
215
|
const problems = [];
|
|
195
216
|
for (let index = 0; index < steps.length - 1; index += 1) {
|
|
196
217
|
const tokens = [steps[index].token, steps[index + 1].token];
|
|
197
|
-
const atMin = compareEnds(
|
|
198
|
-
const atMax = compareEnds(
|
|
218
|
+
const atMin = compareEnds(compared[index], compared[index + 1], 'min', minStepPx);
|
|
219
|
+
const atMax = compareEnds(compared[index], compared[index + 1], 'max', minStepPx);
|
|
199
220
|
// The same problem at both ends (always, for static steps) is one problem.
|
|
200
221
|
if (atMin && atMax && atMin.type === atMax.type && atMin.difference === atMax.difference) {
|
|
201
222
|
problems.push({ end: 'both', tokens, ...atMin });
|
package/src/cli/doctor.js
CHANGED
|
@@ -5,8 +5,19 @@ const path = require('node:path');
|
|
|
5
5
|
const { applyAuditConfig, loadAuditConfig, resolveIgnorePatterns } = require('../audit/config');
|
|
6
6
|
const { createDefaultAuditOptions } = require('../audit/shared');
|
|
7
7
|
const { normalizeDecisions } = require('../core/decisions');
|
|
8
|
+
const { readJsonFile } = require('../core/json');
|
|
8
9
|
const { normalizeTokenSourceFormat } = require('../core/token-sources');
|
|
9
10
|
|
|
11
|
+
const HELP = `Usage: rhythmguard doctor
|
|
12
|
+
|
|
13
|
+
Validate the Rhythmguard setup in the current directory: Stylelint, its config,
|
|
14
|
+
the rules it references, .rhythmguardrc.json, token sources and the baseline.
|
|
15
|
+
Exits 1 when a check fails.
|
|
16
|
+
|
|
17
|
+
Options:
|
|
18
|
+
--help Show this help message
|
|
19
|
+
`;
|
|
20
|
+
|
|
10
21
|
const cwd = process.cwd();
|
|
11
22
|
let issues = 0;
|
|
12
23
|
|
|
@@ -218,7 +229,7 @@ function checkRhythmguardConfig() {
|
|
|
218
229
|
|
|
219
230
|
let parsed;
|
|
220
231
|
try {
|
|
221
|
-
parsed =
|
|
232
|
+
parsed = readJsonFile(configPath);
|
|
222
233
|
} catch {
|
|
223
234
|
fail('.rhythmguardrc.json is not valid JSON', 'Fix or remove .rhythmguardrc.json');
|
|
224
235
|
return;
|
|
@@ -329,7 +340,7 @@ function checkRhythmguardBaseline(audit) {
|
|
|
329
340
|
}
|
|
330
341
|
|
|
331
342
|
try {
|
|
332
|
-
const baseline =
|
|
343
|
+
const baseline = readJsonFile(baselinePath);
|
|
333
344
|
if (!Array.isArray(baseline.findings)) {
|
|
334
345
|
fail('baseline file does not include findings array', 'Regenerate it with rhythmguard audit --write-baseline');
|
|
335
346
|
return;
|
|
@@ -341,6 +352,16 @@ function checkRhythmguardBaseline(audit) {
|
|
|
341
352
|
}
|
|
342
353
|
|
|
343
354
|
function run() {
|
|
355
|
+
// Doctor takes no arguments; --help prints the usage, anything else is refused (#286).
|
|
356
|
+
for (const arg of process.argv.slice(3)) {
|
|
357
|
+
if (arg === '--help' || arg === '-h') {
|
|
358
|
+
process.stdout.write(HELP);
|
|
359
|
+
return;
|
|
360
|
+
}
|
|
361
|
+
process.stderr.write(`Unknown option: ${arg}\n\n${HELP}`);
|
|
362
|
+
process.exit(1);
|
|
363
|
+
}
|
|
364
|
+
|
|
344
365
|
process.stdout.write('\nRhythmguard Doctor\n\n');
|
|
345
366
|
|
|
346
367
|
const stylelintOk = checkStylelint();
|
|
@@ -364,4 +385,8 @@ function run() {
|
|
|
364
385
|
process.exit(stylelintOk && issues === 0 ? 0 : 1);
|
|
365
386
|
}
|
|
366
387
|
|
|
367
|
-
run
|
|
388
|
+
module.exports = { HELP, run };
|
|
389
|
+
|
|
390
|
+
if (require.main === module) {
|
|
391
|
+
run();
|
|
392
|
+
}
|
package/src/cli/fix.js
CHANGED
|
@@ -2,10 +2,11 @@
|
|
|
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
|
+
const { OPTIONS: AUDIT_OPTIONS, parseBaseFontSize } = require('../audit/args');
|
|
6
6
|
const { runCodemod } = require('../audit/codemod');
|
|
7
7
|
const { resolveIgnorePatterns } = require('../audit/config');
|
|
8
8
|
const { normalizeCreateAuditOptions } = require('../audit/report');
|
|
9
|
+
const { assertDirectory } = require('../audit/scan/files');
|
|
9
10
|
const { DEFAULT_IGNORE_PATH } = require('../audit/shared');
|
|
10
11
|
|
|
11
12
|
// --ignore and --ignore-path are the audit's own flags, parsed by the audit's table.
|
|
@@ -62,14 +63,11 @@ function parseArgs(argv) {
|
|
|
62
63
|
else if (arg.startsWith('--to=')) parsed.replacement = arg.slice('--to='.length);
|
|
63
64
|
else if (arg === '--properties') parsed.properties = splitList(next());
|
|
64
65
|
else if (arg.startsWith('--properties=')) parsed.properties = splitList(arg.slice('--properties='.length));
|
|
65
|
-
else if (arg === '--base-font-size') parsed.baseFontSize =
|
|
66
|
-
else if (arg.startsWith('--base-font-size=')) parsed.baseFontSize =
|
|
66
|
+
else if (arg === '--base-font-size') parsed.baseFontSize = parseBaseFontSize(next());
|
|
67
|
+
else if (arg.startsWith('--base-font-size=')) parsed.baseFontSize = parseBaseFontSize(arg.slice('--base-font-size='.length));
|
|
67
68
|
else if (!arg.startsWith('-') && !parsed.dir) parsed.dir = arg;
|
|
68
69
|
else throw new Error(`Unknown option: ${arg}`);
|
|
69
70
|
}
|
|
70
|
-
if (!Number.isFinite(parsed.baseFontSize) || parsed.baseFontSize <= 0) {
|
|
71
|
-
throw new Error('--base-font-size must be a positive number.');
|
|
72
|
-
}
|
|
73
71
|
return parsed;
|
|
74
72
|
}
|
|
75
73
|
|
|
@@ -138,6 +136,9 @@ function run() {
|
|
|
138
136
|
let plan;
|
|
139
137
|
let ignorePatterns;
|
|
140
138
|
try {
|
|
139
|
+
// The same check, and the same message, as the audit: a missing path or a
|
|
140
|
+
// file reads "Directory not found" or "Not a directory", not a stack trace (#288).
|
|
141
|
+
assertDirectory(parsed.dir);
|
|
141
142
|
plan = plannedReplacements(parsed);
|
|
142
143
|
ignorePatterns = auditIgnorePatterns(parsed);
|
|
143
144
|
} catch (error) {
|
package/src/cli/index.js
CHANGED
|
@@ -15,7 +15,7 @@ Commands:
|
|
|
15
15
|
doctor Validate your Rhythmguard setup
|
|
16
16
|
|
|
17
17
|
Options:
|
|
18
|
-
--help Show this help message
|
|
18
|
+
--help Show this help message; after a command, that command's help
|
|
19
19
|
|
|
20
20
|
Examples:
|
|
21
21
|
npx rhythmguard
|
|
@@ -45,7 +45,7 @@ function main() {
|
|
|
45
45
|
} else if (command === 'init') {
|
|
46
46
|
require('./init').run();
|
|
47
47
|
} else if (command === 'doctor') {
|
|
48
|
-
require('./doctor');
|
|
48
|
+
require('./doctor').run();
|
|
49
49
|
} else {
|
|
50
50
|
process.stderr.write(`Unknown command: ${command}\n\n`);
|
|
51
51
|
process.stdout.write(HELP);
|
package/src/cli/init.js
CHANGED
|
@@ -200,14 +200,39 @@ const AGENT_TARGETS = {
|
|
|
200
200
|
const AGENT_BLOCK_START = '<!-- rhythmguard:agents:start -->';
|
|
201
201
|
const AGENT_BLOCK_END = '<!-- rhythmguard:agents:end -->';
|
|
202
202
|
|
|
203
|
+
const HELP = `Usage: rhythmguard init [options]
|
|
204
|
+
|
|
205
|
+
Scaffold a Rhythmguard Stylelint config for the project in the current
|
|
206
|
+
directory. Detects Tailwind and Next.js, picks a profile and asks before it
|
|
207
|
+
writes. When Stylelint loads a config init cannot rewrite (package.json, YAML,
|
|
208
|
+
JS or TS), it prints what to add and writes nothing.
|
|
209
|
+
|
|
210
|
+
Options:
|
|
211
|
+
--agents [claude|cursor|copilot|all] Install the agent instruction packs instead (default: all)
|
|
212
|
+
--help Show this help message
|
|
213
|
+
`;
|
|
214
|
+
|
|
215
|
+
/**
|
|
216
|
+
* Init writes files, so anything it does not know stops it: `init --help`
|
|
217
|
+
* and a typo such as `--agnets` used to fall through to the interactive
|
|
218
|
+
* init and write a config (#286).
|
|
219
|
+
*/
|
|
203
220
|
function parseInitArgs(argv) {
|
|
204
|
-
const parsed = { agents: null };
|
|
221
|
+
const parsed = { agents: null, help: false };
|
|
205
222
|
for (let index = 0; index < argv.length; index += 1) {
|
|
206
223
|
const arg = argv[index];
|
|
207
|
-
if (arg === '--
|
|
208
|
-
parsed.
|
|
224
|
+
if (arg === '--help' || arg === '-h') {
|
|
225
|
+
parsed.help = true;
|
|
226
|
+
} else if (arg === '--agents') {
|
|
227
|
+
const next = argv[index + 1];
|
|
228
|
+
parsed.agents = next !== undefined && !next.startsWith('-') ? argv[++index].toLowerCase() : 'all';
|
|
209
229
|
} else if (arg.startsWith('--agents=')) {
|
|
210
230
|
parsed.agents = arg.slice('--agents='.length).toLowerCase();
|
|
231
|
+
if (!parsed.agents) {
|
|
232
|
+
throw new Error('Missing value for --agents.');
|
|
233
|
+
}
|
|
234
|
+
} else {
|
|
235
|
+
throw new Error(`Unknown option: ${arg}`);
|
|
211
236
|
}
|
|
212
237
|
}
|
|
213
238
|
return parsed;
|
|
@@ -252,7 +277,18 @@ function installAgents(selection, cwd = process.cwd()) {
|
|
|
252
277
|
}
|
|
253
278
|
|
|
254
279
|
async function run() {
|
|
255
|
-
|
|
280
|
+
let args;
|
|
281
|
+
try {
|
|
282
|
+
args = parseInitArgs(process.argv.slice(3));
|
|
283
|
+
} catch (error) {
|
|
284
|
+
process.stderr.write(`${error.message}\n\n${HELP}`);
|
|
285
|
+
process.exit(1);
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
if (args.help) {
|
|
289
|
+
process.stdout.write(HELP);
|
|
290
|
+
return;
|
|
291
|
+
}
|
|
256
292
|
|
|
257
293
|
if (args.agents) {
|
|
258
294
|
let written;
|
|
@@ -342,7 +378,7 @@ async function run() {
|
|
|
342
378
|
}
|
|
343
379
|
}
|
|
344
380
|
|
|
345
|
-
module.exports = { AGENT_TARGETS, detect, findActiveStylelintConfig, installAgents, run, selectProfile };
|
|
381
|
+
module.exports = { AGENT_TARGETS, HELP, detect, findActiveStylelintConfig, installAgents, parseInitArgs, run, selectProfile };
|
|
346
382
|
|
|
347
383
|
if (require.main === module) {
|
|
348
384
|
run();
|