stylelint-plugin-rhythmguard 3.4.0 → 3.6.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.
@@ -16,6 +16,7 @@ function renderMarkdown(report) {
16
16
  const lines = [
17
17
  '# Rhythmguard Design-System Audit',
18
18
  '',
19
+ ...(report.baseline ? [`**Since baseline:** ${report.baseline.resolvedFindingsCount} resolved, ${report.baseline.newFindingsCount} new.`, ''] : []),
19
20
  `Directory: \`${report.directory}\``,
20
21
  '',
21
22
  '## Summary',
@@ -36,6 +37,10 @@ function renderMarkdown(report) {
36
37
  lines.push(`| Scale source | ${describeScaleSource(report.scale)} |`);
37
38
  }
38
39
 
40
+ if (report.decisions) {
41
+ lines.push(`| Decisions | ${report.decisions.adopt + report.decisions.allow + report.decisions.snap + report.decisions.undecided} (${report.decisions.adopt} adopt, ${report.decisions.allow} allow, ${report.decisions.snap} snap, ${report.decisions.undecided} undecided); ${report.decisions.suppressed} findings suppressed |`);
42
+ }
43
+
39
44
  if (report.baseline) {
40
45
  lines.push(`| New findings | ${report.baseline.newFindingsCount} |`);
41
46
  lines.push(`| Resolved findings | ${report.baseline.resolvedFindingsCount} |`);
@@ -48,6 +53,7 @@ function renderMarkdown(report) {
48
53
  appendMarkdownCounts(lines, 'Tailwind Class-String Drift', report.tailwindArbitraryValues);
49
54
  appendMarkdownCounts(lines, 'Motion Rhythm Drift', report.motion.values);
50
55
  appendTokenContractMarkdown(lines, report.tokenContract);
56
+ appendTokenChainsMarkdown(lines, report.tokenContract.chains);
51
57
  appendBaselineMarkdown(lines, report);
52
58
 
53
59
  if (report.topAffectedFiles.length > 0) {
@@ -86,6 +92,31 @@ function renderMarkdown(report) {
86
92
  return `${lines.join('\n')}\n`;
87
93
  }
88
94
 
95
+ function appendTokenChainsMarkdown(lines, chains) {
96
+ if (!chains || chains.summary.total === 0) {
97
+ return;
98
+ }
99
+ const { summary } = chains;
100
+ lines.push('## Token Chains');
101
+ lines.push('');
102
+ const attention = ['off-scale', 'unresolved', 'ambiguous', 'computed', 'non-length']
103
+ .filter((outcome) => summary[outcome] > 0)
104
+ .map((outcome) => `${summary[outcome]} ${outcome}`);
105
+ lines.push(`${summary['on-scale']} of ${summary.total} spacing tokens resolve to the scale${attention.length > 0 ? `; ${attention.join(', ')}` : ''}. A token is followed through \`var()\` to its terminal value; the token layer, not the literal, is where a system like this keeps its discipline.`);
106
+ lines.push('');
107
+ if (chains.entries.length > 0) {
108
+ lines.push('| Token | Outcome | Detail |');
109
+ lines.push('| --- | --- | --- |');
110
+ for (const entry of chains.entries.slice(0, 50)) {
111
+ const detail = entry.reason
112
+ ? entry.reason
113
+ : entry.terminals.map((terminal) => `\`${terminal}\``).join(', ');
114
+ lines.push(`| \`${escapeMarkdown(entry.token)}\` | ${entry.outcome} | ${detail}${entry.via.length > 0 ? ` via ${entry.via.map((name) => `\`${name}\``).join(' → ')}` : ''} |`);
115
+ }
116
+ lines.push('');
117
+ }
118
+ }
119
+
89
120
  function appendTokenContractMarkdown(lines, tokenContract) {
90
121
  const {
91
122
  conflictingTokens,
@@ -31,6 +31,12 @@ function renderText(report) {
31
31
  const rejected = report.scale.rejected ? ` (${report.scale.rejected.source} rejected: ${report.scale.rejected.reasons.join(', ')})` : '';
32
32
  lines.push(` Scale source ${report.scale.source}${files}${rejected}`);
33
33
  }
34
+ if (report.decisions) {
35
+ lines.push(` Decisions ${report.decisions.adopt + report.decisions.allow + report.decisions.snap + report.decisions.undecided} (${report.decisions.adopt} adopt, ${report.decisions.allow} allow, ${report.decisions.snap} snap, ${report.decisions.undecided} undecided); ${report.decisions.suppressed} findings suppressed`);
36
+ }
37
+ if (report.baseline) {
38
+ lines.push(` Since baseline ${report.baseline.resolvedFindingsCount} resolved, ${report.baseline.newFindingsCount} new`);
39
+ }
34
40
  lines.push('');
35
41
 
36
42
  appendHistogram(lines, 'CSS OFF-SCALE VALUES', report.offScaleValues);
@@ -39,6 +45,11 @@ function renderText(report) {
39
45
  appendHistogram(lines, 'TAILWIND CLASS-STRING DRIFT', report.tailwindArbitraryValues);
40
46
  appendHistogram(lines, 'MOTION RHYTHM DRIFT', report.motion.values);
41
47
  appendTokenContractText(lines, report.tokenContract);
48
+ if (report.tokenContract.chains && report.tokenContract.chains.summary.total > 0) {
49
+ const { summary } = report.tokenContract.chains;
50
+ lines.push(` Token chains ${summary['on-scale']} of ${summary.total} resolve to the scale (${summary['off-scale']} off-scale, ${summary.unresolved} unresolved, ${summary.ambiguous} ambiguous)`);
51
+ lines.push('');
52
+ }
42
53
  appendBaselineText(lines, report);
43
54
 
44
55
  if (report.topAffectedFiles.length > 0) {
@@ -24,6 +24,8 @@ const {
24
24
  inferScaleFromDefinitions,
25
25
  scaleFromDefinitions,
26
26
  } = require('../core/scale-inference');
27
+ const { applyDecisions, buildDecisionPlan } = require('./decisions');
28
+ const { collectDeclarations, resolveTokenChains, toChainsContract } = require('./token-chains');
27
29
  const { assertDirectory, getScanFiles } = require('./scan/files');
28
30
  const { collectCssFindings, runStylelintAudit } = require('./scan/stylesheets');
29
31
  const { collectTailwindFindings, collectTailwindMotionFindings } = require('./scan/templates');
@@ -62,7 +64,13 @@ async function createAuditReport(options) {
62
64
 
63
65
  const cssResults = await runStylelintAudit(cssFiles, lintOptions);
64
66
  const stylelintFindings = collectCssFindings(cssResults);
65
- const cssFindings = stylelintFindings.filter((finding) => !finding.type.startsWith('motion-'));
67
+ const decided = applyDecisions({
68
+ baseFontSize: parsed.baseFontSize,
69
+ cssFindings: stylelintFindings.filter((finding) => !finding.type.startsWith('motion-')),
70
+ decisions: parsed.decisions || [],
71
+ tailwindFindings: collectTailwindFindings(templateFiles, lintOptions),
72
+ });
73
+ const cssFindings = decided.cssFindings;
66
74
  const motionFindings = [
67
75
  ...stylelintFindings.filter((finding) => finding.type.startsWith('motion-')),
68
76
  ...collectTailwindMotionFindings(templateFiles, lintOptions),
@@ -81,7 +89,7 @@ async function createAuditReport(options) {
81
89
  scanScope,
82
90
  scssFiles: cssResults.scssFiles || 0,
83
91
  scssSkipped: cssResults.scssSkipped || 0,
84
- tailwindFindings: collectTailwindFindings(templateFiles, lintOptions),
92
+ tailwindFindings: decided.tailwindFindings,
85
93
  templateFiles,
86
94
  tokenCandidateMinCount: parsed.tokenCandidateMinCount,
87
95
  tokenKind: parsed.tokenKind,
@@ -89,6 +97,26 @@ async function createAuditReport(options) {
89
97
  tokenSourceWarnings: tokenSourceResult.warnings,
90
98
  });
91
99
 
100
+ report.tokenContract.chains = toChainsContract(resolveTokenChains({
101
+ baseFontSize: parsed.baseFontSize,
102
+ declarations: collectDeclarations({
103
+ cssFiles,
104
+ externalDefinitions: tokenSourceResult.definitions,
105
+ skipFile: (file) => NON_AUTHORED_SEGMENT.test(file),
106
+ }),
107
+ scale: scale.values,
108
+ }));
109
+ report.decisions = decided.summary;
110
+ report.decisionPlan = buildDecisionPlan({
111
+ baseFontSize: parsed.baseFontSize,
112
+ decisions: parsed.decisions || [],
113
+ offScaleFindings: [
114
+ ...stylelintFindings.filter((finding) => finding.type === 'off-scale'),
115
+ ...collectTailwindFindings(templateFiles, lintOptions),
116
+ ],
117
+ scale: scale.values,
118
+ });
119
+
92
120
  if (parsed.sinceBaseline) {
93
121
  applyBaselineComparison(report, parsed.baselinePath);
94
122
  }
@@ -45,6 +45,7 @@ async function runStylelintAudit(cssFiles, options) {
45
45
  'rhythmguard/use-scale': [
46
46
  true,
47
47
  {
48
+ decisions: false,
48
49
  baseFontSize: options.baseFontSize,
49
50
  scale: options.scale,
50
51
  severity: 'warning',
@@ -70,6 +70,8 @@ function createDefaultAuditOptions() {
70
70
  dir: null,
71
71
  failOnNewDrift: false,
72
72
  badgeMetric: 'drift',
73
+ decisions: [],
74
+ plan: false,
73
75
  format: 'text',
74
76
  ignorePath: DEFAULT_IGNORE_PATH,
75
77
  ignorePatterns: [],
@@ -0,0 +1,157 @@
1
+ 'use strict';
2
+
3
+ const fs = require('node:fs');
4
+ const { formatLength, numbersEqual, parseLengthToken, toPx } = require('../core/length');
5
+ const { customPropertyDeclarations, parseTokenValueLength } = require('../core/token-sources');
6
+ const { formatPath } = require('./shared');
7
+
8
+ /**
9
+ * Token chains (issue #110). In a token-layered system the SCSS says
10
+ * `padding: var(--button--padding-x)` and the discipline lives one level up:
11
+ * does every spacing token resolve to the scale? This follows var() references
12
+ * through every custom property declaration the audit can see and classifies
13
+ * each spacing-named token by where its chain ends:
14
+ *
15
+ * on-scale every terminal is a length on the scale
16
+ * off-scale a terminal is a length off the scale: drift at the token layer
17
+ * unresolved the chain reaches a token nobody declares, or a cycle
18
+ * ambiguous definitions disagree (themes, media queries) on the length
19
+ * computed the value is calc() or otherwise not a plain length or var()
20
+ * non-length the value is not a single length (a shorthand, a keyword)
21
+ *
22
+ * A token that is defined several times keeps every definition; the chain
23
+ * reports the set, never a guess.
24
+ */
25
+ // `size` counts only as a name prefix (`--size-px--m`, mittwald Flow); anywhere else it is a
26
+ // font size or a dimension (`--account-bio-size`, `--circle-size`), not spacing.
27
+ const SPACING_TOKEN_NAME = /^--size(?:-|$)|(?:^--|-)(?<!letter-)(?<!word-)(?:space|spacing|spacer|padding|margin|gap|inset)(?:-|$)/i;
28
+ const VAR_REFERENCE = /^var\(\s*(--[\w-]+)\s*(?:,\s*([\s\S]+))?\)$/;
29
+ const OUTCOMES = ['on-scale', 'off-scale', 'unresolved', 'ambiguous', 'computed', 'non-length'];
30
+
31
+ function isSpacingTokenName(token) {
32
+ return token.startsWith('--') && SPACING_TOKEN_NAME.test(token);
33
+ }
34
+
35
+ /** Every custom property declaration the audit can see: scanned stylesheets plus external token definitions. */
36
+ function collectDeclarations({ cssFiles, externalDefinitions, skipFile = () => false }) {
37
+ const declarations = new Map();
38
+ const add = (token, value) => {
39
+ if (!declarations.has(token)) declarations.set(token, new Set());
40
+ declarations.get(token).add(String(value).trim());
41
+ };
42
+ for (const filePath of cssFiles) {
43
+ if (skipFile(formatPath(filePath))) continue;
44
+ let source;
45
+ try {
46
+ source = fs.readFileSync(filePath, 'utf8');
47
+ } catch {
48
+ continue;
49
+ }
50
+ for (const declaration of customPropertyDeclarations(source)) add(declaration.token, declaration.value);
51
+ }
52
+ for (const definition of (externalDefinitions || new Map()).values()) {
53
+ if (!definition.token.startsWith('--')) continue;
54
+ for (const value of definition.values) add(definition.token, value);
55
+ }
56
+ return declarations;
57
+ }
58
+
59
+ function resolveTokenChains({ baseFontSize = 16, declarations, scale }) {
60
+ const scalePx = (scale || []).map(Number).filter(Number.isFinite);
61
+ const memo = new Map();
62
+
63
+ const classifyValue = (raw, stack, via) => {
64
+ const value = String(raw).trim();
65
+ const length = parseTokenValueLength(value) || parseLengthToken(value);
66
+ if (length && !/^var\(/i.test(value)) {
67
+ const px = toPx(Math.abs(length.number), length.unit || 'px', baseFontSize);
68
+ return px === null ? { outcomes: ['non-length'], terminals: [] } : { outcomes: [], terminals: [formatLength(px, 'px')] };
69
+ }
70
+ const reference = value.match(VAR_REFERENCE);
71
+ if (reference) {
72
+ const [, target, fallback] = reference;
73
+ if (declarations.has(target)) {
74
+ via.push(target);
75
+ return resolve(target, stack);
76
+ }
77
+ if (fallback !== undefined) {
78
+ return classifyValue(fallback, stack, via);
79
+ }
80
+ return { outcomes: ['unresolved'], reason: `\`${target}\` is not declared`, terminals: [] };
81
+ }
82
+ if (/var\(|calc\(|clamp\(|min\(|max\(/i.test(value)) {
83
+ return { outcomes: ['computed'], terminals: [] };
84
+ }
85
+ return { outcomes: ['non-length'], terminals: [] };
86
+ };
87
+
88
+ const resolve = (token, stack) => {
89
+ if (memo.has(token)) return memo.get(token);
90
+ if (stack.includes(token)) {
91
+ return { outcomes: ['unresolved'], reason: `cycle through \`${stack[stack.length - 1]}\``, terminals: [], via: [] };
92
+ }
93
+ const values = declarations.get(token);
94
+ if (!values) return { outcomes: ['unresolved'], reason: `\`${token}\` is not declared`, terminals: [], via: [] };
95
+ const nextStack = [...stack, token];
96
+ const outcomes = new Set();
97
+ const terminals = new Set();
98
+ const via = [];
99
+ let reason = null;
100
+ for (const raw of values) {
101
+ const result = classifyValue(raw, nextStack, via);
102
+ for (const outcome of result.outcomes) outcomes.add(outcome);
103
+ for (const terminal of result.terminals) terminals.add(terminal);
104
+ if (result.reason && !reason) reason = result.reason;
105
+ if (result.via) via.push(...result.via.filter((name) => !via.includes(name)));
106
+ }
107
+ const result = { outcomes: [...outcomes], reason, terminals: [...terminals], via };
108
+ memo.set(token, result);
109
+ return result;
110
+ };
111
+
112
+ const onScale = (terminal) => {
113
+ const parsed = parseLengthToken(terminal);
114
+ return parsed && scalePx.some((step) => numbersEqual(step, parsed.number));
115
+ };
116
+
117
+ const entries = [];
118
+ for (const token of [...declarations.keys()].sort()) {
119
+ if (!isSpacingTokenName(token)) continue;
120
+ const result = resolve(token, []);
121
+ let outcome;
122
+ if (result.outcomes.includes('unresolved')) outcome = 'unresolved';
123
+ else if (result.outcomes.includes('computed')) outcome = 'computed';
124
+ else if (result.outcomes.includes('non-length')) outcome = 'non-length';
125
+ else if (result.terminals.length > 1) outcome = 'ambiguous';
126
+ else if (result.terminals.length === 1) outcome = onScale(result.terminals[0]) ? 'on-scale' : 'off-scale';
127
+ else outcome = 'unresolved';
128
+ entries.push({ outcome, reason: result.reason || null, terminals: result.terminals, token, via: result.via });
129
+ }
130
+
131
+ const summary = Object.fromEntries([['total', entries.length], ...OUTCOMES.map((outcome) => [outcome, 0])]);
132
+ for (const entry of entries) summary[entry.outcome] += 1;
133
+ return { entries, summary };
134
+ }
135
+
136
+ /** The contract shape: counts for everything, entries only for what needs attention. */
137
+ function toChainsContract(chains) {
138
+ const attention = new Set(['off-scale', 'unresolved', 'ambiguous']);
139
+ return {
140
+ entries: chains.entries.filter((entry) => attention.has(entry.outcome)).map((entry) => ({
141
+ outcome: entry.outcome,
142
+ ...(entry.reason ? { reason: entry.reason } : {}),
143
+ terminals: entry.terminals,
144
+ token: entry.token,
145
+ via: entry.via,
146
+ })),
147
+ summary: chains.summary,
148
+ };
149
+ }
150
+
151
+ module.exports = {
152
+ OUTCOMES,
153
+ collectDeclarations,
154
+ isSpacingTokenName,
155
+ resolveTokenChains,
156
+ toChainsContract,
157
+ };
package/src/cli/audit.js CHANGED
@@ -48,6 +48,11 @@ async function run() {
48
48
  process.exit(1);
49
49
  }
50
50
 
51
+ if (parsed.plan) {
52
+ writeOutput(`${JSON.stringify({ decisions: report.decisionPlan }, null, 2)}\n`, parsed.outputPath);
53
+ return;
54
+ }
55
+
51
56
  const auditFailures = getAuditFailures(report, parsed);
52
57
 
53
58
  if (parsed.format === 'json') {
package/src/cli/fix.js ADDED
@@ -0,0 +1,151 @@
1
+ 'use strict';
2
+
3
+ const { loadRcDecisions } = require('../core/decisions');
4
+ const { parseLengthToken, toPx } = require('../core/length');
5
+ const { runCodemod } = require('../audit/codemod');
6
+
7
+ const HELP = `Usage: rhythmguard fix <dir> --value <length> --to <replacement> [options]
8
+ rhythmguard fix <dir> --decided [options]
9
+
10
+ Replace one spacing literal everywhere it appears on a spacing property, or
11
+ execute the snap decisions in .rhythmguardrc.json that name a "to". Matching is
12
+ by px, so --value 10px also rewrites 0.625rem. Token definitions, values inside
13
+ var()/theme()/token(), and non-spacing properties are never touched. Dry run by
14
+ default.
15
+
16
+ Options:
17
+ --value <length> The literal to replace, for example 10px or 0.625rem
18
+ --to <replacement> What to write, for example var(--space-sm) or 8px
19
+ --decided Apply every snap decision that has a "to"
20
+ --properties <list> Comma-separated property names or prefix-* patterns (default: spacing properties)
21
+ --base-font-size <px> Base for rem/em conversion (default: 16)
22
+ --write Apply the changes; without it, only list them
23
+ --help Show this help message
24
+ `;
25
+
26
+ function parseArgs(argv) {
27
+ const parsed = { baseFontSize: 16, decided: false, dir: null, help: false, properties: null, replacement: null, value: null, write: false };
28
+ for (let index = 0; index < argv.length; index += 1) {
29
+ const arg = argv[index];
30
+ const next = () => argv[++index];
31
+ if (arg === '--help' || arg === '-h') parsed.help = true;
32
+ else if (arg === '--write') parsed.write = true;
33
+ else if (arg === '--decided') parsed.decided = true;
34
+ else if (arg === '--value') parsed.value = next();
35
+ else if (arg.startsWith('--value=')) parsed.value = arg.slice('--value='.length);
36
+ else if (arg === '--to') parsed.replacement = next();
37
+ else if (arg.startsWith('--to=')) parsed.replacement = arg.slice('--to='.length);
38
+ else if (arg === '--properties') parsed.properties = splitList(next());
39
+ else if (arg.startsWith('--properties=')) parsed.properties = splitList(arg.slice('--properties='.length));
40
+ else if (arg === '--base-font-size') parsed.baseFontSize = Number(next());
41
+ else if (arg.startsWith('--base-font-size=')) parsed.baseFontSize = Number(arg.slice('--base-font-size='.length));
42
+ else if (!arg.startsWith('-') && !parsed.dir) parsed.dir = arg;
43
+ else throw new Error(`Unknown option: ${arg}`);
44
+ }
45
+ if (!Number.isFinite(parsed.baseFontSize) || parsed.baseFontSize <= 0) {
46
+ throw new Error('--base-font-size must be a positive number.');
47
+ }
48
+ return parsed;
49
+ }
50
+
51
+ function splitList(raw) {
52
+ return String(raw || '').split(',').map((entry) => entry.trim()).filter(Boolean);
53
+ }
54
+
55
+ function lengthToPx(value, baseFontSize) {
56
+ const parsed = typeof value === 'string' ? parseLengthToken(value.trim()) : null;
57
+ const px = parsed ? toPx(Math.abs(parsed.number), parsed.unit || 'px', baseFontSize) : null;
58
+ if (px === null || !Number.isFinite(px) || px === 0) {
59
+ throw new Error(`--value must be a CSS length such as 10px or 0.625rem, got ${JSON.stringify(value)}.`);
60
+ }
61
+ return px;
62
+ }
63
+
64
+ /** The replacements to run: one from --value/--to, or every snap decision with a "to". */
65
+ function plannedReplacements(parsed) {
66
+ if (parsed.decided) {
67
+ const decisions = loadRcDecisions(process.cwd(), { baseFontSize: parsed.baseFontSize });
68
+ const snaps = decisions.filter((decision) => decision.decision === 'snap');
69
+ return {
70
+ replacements: snaps.filter((decision) => decision.to).map((decision) => ({ label: decision.value, px: decision.px, replacement: decision.to })),
71
+ skippedSnaps: snaps.filter((decision) => !decision.to).map((decision) => decision.value),
72
+ };
73
+ }
74
+ if (!parsed.value) {
75
+ throw new Error('Pass --value <length> and --to <replacement>, or --decided.');
76
+ }
77
+ if (!parsed.replacement) {
78
+ throw new Error('--to <replacement> is required with --value.');
79
+ }
80
+ return { replacements: [{ label: parsed.value, px: lengthToPx(parsed.value, parsed.baseFontSize), replacement: parsed.replacement }], skippedSnaps: [] };
81
+ }
82
+
83
+ function run() {
84
+ let parsed;
85
+ try {
86
+ parsed = parseArgs(process.argv.slice(3));
87
+ } catch (error) {
88
+ process.stderr.write(`${error.message}\n\n${HELP}`);
89
+ process.exit(1);
90
+ }
91
+ if (parsed.help) {
92
+ process.stdout.write(HELP);
93
+ return;
94
+ }
95
+ if (!parsed.dir) {
96
+ process.stderr.write(`Pass the directory to rewrite.\n\n${HELP}`);
97
+ process.exit(1);
98
+ }
99
+
100
+ let plan;
101
+ try {
102
+ plan = plannedReplacements(parsed);
103
+ } catch (error) {
104
+ process.stderr.write(`${error.message}\n`);
105
+ process.exit(1);
106
+ }
107
+
108
+ const lines = [];
109
+ let total = 0;
110
+ const touched = new Set();
111
+ const skipped = [];
112
+ for (const replacement of plan.replacements) {
113
+ const outcome = runCodemod({
114
+ baseFontSize: parsed.baseFontSize,
115
+ dir: parsed.dir,
116
+ properties: parsed.properties,
117
+ px: replacement.px,
118
+ replacement: replacement.replacement,
119
+ write: parsed.write,
120
+ });
121
+ for (const result of outcome.results) {
122
+ touched.add(result.file);
123
+ for (const change of result.changes) {
124
+ total += 1;
125
+ lines.push(` ${result.file}:${change.line}:${change.column} ${change.property}: ${change.from} -> ${change.to}`);
126
+ }
127
+ }
128
+ skipped.push(...outcome.skipped);
129
+ }
130
+
131
+ if (lines.length > 0) {
132
+ lines.push('');
133
+ }
134
+ const noun = total === 1 ? 'replacement' : 'replacements';
135
+ const files = touched.size === 1 ? 'file' : 'files';
136
+ lines.push(`${total} ${noun} in ${touched.size} ${files}${parsed.write ? '' : ' (dry run; pass --write to apply)'}`);
137
+ if (plan.skippedSnaps.length > 0) {
138
+ const noun2 = plan.skippedSnaps.length === 1 ? 'snap decision has' : 'snap decisions have';
139
+ lines.push(`${plan.skippedSnaps.length} ${noun2} no "to" and ${plan.skippedSnaps.length === 1 ? 'was' : 'were'} skipped: ${plan.skippedSnaps.join(', ')}`);
140
+ }
141
+ for (const entry of skipped) {
142
+ lines.push(`skipped ${entry.file}: ${entry.reason}`);
143
+ }
144
+ process.stdout.write(`${lines.join('\n')}\n`);
145
+ }
146
+
147
+ module.exports = { HELP, parseArgs, run };
148
+
149
+ if (require.main === module) {
150
+ run();
151
+ }
package/src/cli/index.js CHANGED
@@ -11,6 +11,7 @@ the spacing scale, audits the current directory and prints a config to paste.
11
11
  Commands:
12
12
  quickstart Same as running with no command
13
13
  audit <dir> Report design-system drift across CSS and Tailwind class strings
14
+ fix <dir> Replace one off-scale value everywhere, or execute snap decisions
14
15
  init Scaffold a Rhythmguard config for your project
15
16
  --agents <claude|cursor|copilot|all> installs the agent instruction packs instead
16
17
  doctor Validate your Rhythmguard setup
@@ -22,6 +23,7 @@ Examples:
22
23
  npx rhythmguard
23
24
  npx rhythmguard audit ./src
24
25
  npx rhythmguard audit ./src --format markdown
26
+ npx rhythmguard fix ./src --value 10px --to "var(--space-sm)" --write
25
27
  npx rhythmguard init
26
28
  npx rhythmguard init --agents all
27
29
  npx rhythmguard doctor
@@ -36,6 +38,8 @@ if (!command || command === 'quickstart') {
36
38
  require('./quickstart').run();
37
39
  } else if (command === 'audit') {
38
40
  require('./audit').run();
41
+ } else if (command === 'fix') {
42
+ require('./fix').run();
39
43
  } else if (command === 'init') {
40
44
  require('./init').run();
41
45
  } else if (command === 'doctor') {
@@ -0,0 +1,121 @@
1
+ 'use strict';
2
+
3
+ const fs = require('node:fs');
4
+ const path = require('node:path');
5
+ const { cachedByFiles } = require('./fs-cache');
6
+ const { numbersEqual, parseLengthToken, toPx } = require('./length');
7
+
8
+ /**
9
+ * The `decisions` section of .rhythmguardrc.json records what a team decided
10
+ * about each off-scale value the audit found:
11
+ *
12
+ * adopt the value is part of the scale (optionally naming the token it
13
+ * should become); it stops being a finding everywhere
14
+ * allow the value is intentional and not rhythm (borders, focus rings);
15
+ * not a finding, optionally only on some properties
16
+ * snap the value is a slip; still a finding, and `rhythmguard fix
17
+ * --decided` executes it when `to` names the replacement
18
+ * undecided recorded, still a finding, counted so the team sees it
19
+ *
20
+ * Decisions are matched by px value, so `10px` and `0.625rem` are one
21
+ * decision. The Stylelint rules and the audit read the same file, so a
22
+ * decision made once holds in the editor, in CI and in the report.
23
+ */
24
+ const RC_FILE = '.rhythmguardrc.json';
25
+ const DECISIONS = new Set(['adopt', 'allow', 'snap', 'undecided']);
26
+
27
+ function normalizeDecisions(raw, { baseFontSize = 16 } = {}) {
28
+ if (raw === undefined || raw === null) {
29
+ return [];
30
+ }
31
+ if (!Array.isArray(raw)) {
32
+ throw new Error('Invalid Rhythmguard config: decisions must be an array.');
33
+ }
34
+ return raw.map((entry, index) => normalizeDecision(entry, index, baseFontSize));
35
+ }
36
+
37
+ function normalizeDecision(entry, index, baseFontSize) {
38
+ const where = `decisions[${index}]`;
39
+ if (!entry || typeof entry !== 'object' || Array.isArray(entry)) {
40
+ throw new Error(`Invalid Rhythmguard config: ${where} must be an object.`);
41
+ }
42
+ const parsed = typeof entry.value === 'string' ? parseLengthToken(entry.value.trim()) : null;
43
+ const px = parsed ? toPx(Math.abs(parsed.number), parsed.unit || 'px', baseFontSize) : null;
44
+ if (px === null || !Number.isFinite(px)) {
45
+ throw new Error(`Invalid Rhythmguard config: ${where}.value must be a CSS length such as "10px" or "0.75rem".`);
46
+ }
47
+ if (!DECISIONS.has(entry.decision)) {
48
+ throw new Error(`Invalid Rhythmguard config: ${where}.decision must be one of ${Array.from(DECISIONS).join(', ')}.`);
49
+ }
50
+ if (entry.properties !== undefined && (!Array.isArray(entry.properties) || !entry.properties.every((p) => typeof p === 'string' && p.trim()))) {
51
+ throw new Error(`Invalid Rhythmguard config: ${where}.properties must be an array of property names or patterns such as "border-*".`);
52
+ }
53
+ if (entry.as !== undefined && (typeof entry.as !== 'string' || !/^(?:--|\$)[\w-]+$/.test(entry.as))) {
54
+ throw new Error(`Invalid Rhythmguard config: ${where}.as must be a custom property or Sass variable name such as "--space-2".`);
55
+ }
56
+ if (entry.to !== undefined && (typeof entry.to !== 'string' || !entry.to.trim())) {
57
+ throw new Error(`Invalid Rhythmguard config: ${where}.to must be the replacement text a snap writes, such as "8px" or "var(--space-2)".`);
58
+ }
59
+ if (entry.reason !== undefined && typeof entry.reason !== 'string') {
60
+ throw new Error(`Invalid Rhythmguard config: ${where}.reason must be a string.`);
61
+ }
62
+ return {
63
+ as: entry.as || null,
64
+ decision: entry.decision,
65
+ properties: entry.properties ? entry.properties.map((p) => p.trim().toLowerCase()) : null,
66
+ px,
67
+ reason: entry.reason || null,
68
+ to: entry.to ? entry.to.trim() : null,
69
+ value: entry.value.trim(),
70
+ };
71
+ }
72
+
73
+ function propertyMatchesPattern(prop, pattern) {
74
+ if (pattern.endsWith('*')) {
75
+ return prop.startsWith(pattern.slice(0, -1));
76
+ }
77
+ return prop === pattern;
78
+ }
79
+
80
+ /** The decision that applies to a value (in px) on a property, or null. */
81
+ function decisionFor(px, prop, decisions) {
82
+ if (!decisions || decisions.length === 0) {
83
+ return null;
84
+ }
85
+ const normalizedProp = String(prop || '').toLowerCase();
86
+ for (const decision of decisions) {
87
+ if (!numbersEqual(decision.px, Math.abs(px))) {
88
+ continue;
89
+ }
90
+ if (decision.properties && !decision.properties.some((pattern) => propertyMatchesPattern(normalizedProp, pattern))) {
91
+ continue;
92
+ }
93
+ return decision;
94
+ }
95
+ return null;
96
+ }
97
+
98
+ /** Decisions from the .rhythmguardrc.json in cwd, cached and revalidated by mtime. Invalid entries throw. */
99
+ function loadRcDecisions(cwd = process.cwd(), { baseFontSize = 16 } = {}) {
100
+ const rcPath = path.join(cwd, RC_FILE);
101
+ return cachedByFiles(`decisions:${rcPath}:${baseFontSize}`, (consult) => {
102
+ consult(rcPath);
103
+ if (!fs.existsSync(rcPath)) {
104
+ return [];
105
+ }
106
+ let config;
107
+ try {
108
+ config = JSON.parse(fs.readFileSync(rcPath, 'utf8'));
109
+ } catch {
110
+ return [];
111
+ }
112
+ return normalizeDecisions(config && typeof config === 'object' ? config.decisions : undefined, { baseFontSize });
113
+ });
114
+ }
115
+
116
+ module.exports = {
117
+ DECISIONS,
118
+ decisionFor,
119
+ loadRcDecisions,
120
+ normalizeDecisions,
121
+ };
@@ -0,0 +1,36 @@
1
+ 'use strict';
2
+
3
+ const fs = require('node:fs');
4
+
5
+ /**
6
+ * Editors and pre-commit hooks lint one file at a time, and each lint asks the
7
+ * filesystem the same questions: which package.json files sit between cwd and
8
+ * the repository, what .rhythmguardrc.json says, whether a token package is
9
+ * installed. The answers change only when one of those files changes, so a
10
+ * result is cached per key and revalidated by the mtime of every file the
11
+ * computation consulted: a stat per file instead of a read, a parse and a walk.
12
+ */
13
+ const cache = new Map();
14
+
15
+ function fileStamp(file) {
16
+ try {
17
+ return fs.statSync(file).mtimeMs;
18
+ } catch {
19
+ return null;
20
+ }
21
+ }
22
+
23
+ function cachedByFiles(cacheKey, compute) {
24
+ const cached = cache.get(cacheKey);
25
+ if (cached && cached.stamps.every(([file, stamp]) => fileStamp(file) === stamp)) {
26
+ return cached.value;
27
+ }
28
+ const consulted = [];
29
+ const value = compute((file) => consulted.push([file, fileStamp(file)]));
30
+ cache.set(cacheKey, { stamps: consulted, value });
31
+ return value;
32
+ }
33
+
34
+ module.exports = {
35
+ cachedByFiles,
36
+ };