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.
Files changed (48) hide show
  1. package/CHANGELOG.md +40 -0
  2. package/README.md +1 -1
  3. package/package.json +23 -1
  4. package/src/audit/args.js +23 -4
  5. package/src/audit/baseline.js +15 -2
  6. package/src/audit/codemod.js +56 -5
  7. package/src/audit/config.js +2 -1
  8. package/src/audit/contract.js +3 -0
  9. package/src/audit/decisions.js +45 -16
  10. package/src/audit/render-markdown.js +22 -21
  11. package/src/audit/render-text.js +1 -0
  12. package/src/audit/render-utils.js +28 -1
  13. package/src/audit/report.js +14 -2
  14. package/src/audit/scan/files.js +139 -30
  15. package/src/audit/token-chains.js +4 -1
  16. package/src/audit/token-ladders.js +23 -2
  17. package/src/cli/doctor.js +28 -3
  18. package/src/cli/fix.js +7 -6
  19. package/src/cli/index.js +2 -2
  20. package/src/cli/init.js +41 -5
  21. package/src/cli/quickstart.js +46 -8
  22. package/src/configs/embed.js +2 -0
  23. package/src/configs/motion.js +1 -0
  24. package/src/configs/recommended.js +1 -0
  25. package/src/configs/strict.js +1 -0
  26. package/src/configs/tailwind.js +1 -0
  27. package/src/core/decisions.js +2 -1
  28. package/src/core/json.js +25 -0
  29. package/src/core/length.js +11 -0
  30. package/src/core/options.js +39 -20
  31. package/src/core/scale-inference.js +78 -23
  32. package/src/core/tailwind-class-analysis.js +3 -2
  33. package/src/core/token-map.js +2 -1
  34. package/src/core/token-sources.js +2 -1
  35. package/src/eslint/index.js +14 -8
  36. package/src/eslint/rules/tailwind-class-use-motion-scale.js +1 -0
  37. package/src/eslint/rules/tailwind-class-use-scale.js +1 -0
  38. package/src/rules/no-offscale-transform/index.js +58 -53
  39. package/src/rules/prefer-token/index.js +31 -46
  40. package/src/rules/use-motion-scale/index.js +14 -1
  41. package/src/rules/use-scale/index.js +75 -102
  42. package/src/rules/validate.js +13 -4
  43. package/types/__checks__/consumer.ts +20 -1
  44. package/types/__checks__/eslint-config.ts +8 -0
  45. package/types/__checks__/shipped-configs.ts +96 -0
  46. package/types/audit.d.ts +101 -16
  47. package/types/index.d.ts +15 -0
  48. package/types/shared.d.ts +126 -20
@@ -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
  };
@@ -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(options = {}) {
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,
@@ -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
- function walk(currentDir) {
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
- if (entry.isDirectory()) {
48
- walk(fullPath);
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 (entry.isFile()) {
53
- if (isCssFile(fullPath)) {
54
- cssFiles.push(fullPath);
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
- return { cssFiles, templateFiles };
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 stagedBlobs = new Map();
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
- if (parsed.staged) {
107
- // The index entry is what gets committed: a regular file blob, whatever
108
- // the working tree holds now, or whether the file is still there at all.
109
- if (!REGULAR_FILE_MODES.has(entry.mode)) {
110
- continue;
111
- }
112
- } else if (!fs.existsSync(fullPath) || !fs.statSync(fullPath).isFile()) {
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
- if (isCssFile(fullPath)) {
123
- cssFiles.push(fullPath);
124
- } else if (isTemplateFile(fullPath)) {
125
- templateFiles.push(fullPath);
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
- if (parsed.staged) {
130
- stagedBlobs.set(fullPath, entry.blob);
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(steps[index], steps[index + 1], 'min', minStepPx);
198
- const atMax = compareEnds(steps[index], steps[index + 1], 'max', minStepPx);
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 = JSON.parse(fs.readFileSync(configPath, 'utf8'));
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 = JSON.parse(fs.readFileSync(baselinePath, 'utf8'));
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 = Number(next());
66
- else if (arg.startsWith('--base-font-size=')) parsed.baseFontSize = Number(arg.slice('--base-font-size='.length));
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 === '--agents') {
208
- parsed.agents = String(argv[++index] || 'all').toLowerCase();
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
- const args = parseInitArgs(process.argv.slice(3));
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();