stylelint-plugin-rhythmguard 3.5.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.
@@ -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
+ };
@@ -62,6 +62,10 @@ function isSupportedUnit(value) {
62
62
  return SUPPORTED_SCALE_UNITS.has(value.trim().toLowerCase());
63
63
  }
64
64
 
65
+ function isFixWith(value) {
66
+ return value === 'value' || value === 'token';
67
+ }
68
+
65
69
  function isUnitStrategy(value) {
66
70
  return value === 'convert' || value === 'exact';
67
71
  }
@@ -359,6 +363,9 @@ function resolveUnits(options) {
359
363
  }
360
364
 
361
365
  const SCALE_VALIDATION_SCHEMA = Object.freeze({
366
+ decisions: Object.freeze({
367
+ entryValidator: isBoolean,
368
+ }),
362
369
  allowHairlines: Object.freeze({
363
370
  entryValidator: isBoolean,
364
371
  }),
@@ -378,6 +385,9 @@ const SCALE_VALIDATION_SCHEMA = Object.freeze({
378
385
  enforceInsideMathFunctions: Object.freeze({
379
386
  entryValidator: isBoolean,
380
387
  }),
388
+ fixWith: Object.freeze({
389
+ entryValidator: isFixWith,
390
+ }),
381
391
  fixToScale: Object.freeze({
382
392
  entryValidator: isBoolean,
383
393
  }),
@@ -545,6 +555,7 @@ function buildScaleOptions(rawOptions) {
545
555
 
546
556
  return {
547
557
  allowHairlines: options.allowHairlines !== false,
558
+ readDecisions: options.decisions !== false,
548
559
  allowNegative: options.allowNegative !== false,
549
560
  allowPercentages: options.allowPercentages !== false,
550
561
  baseFontSize:
@@ -555,6 +566,7 @@ function buildScaleOptions(rawOptions) {
555
566
  : 16,
556
567
  enforceInsideMathFunctions: options.enforceInsideMathFunctions === true,
557
568
  fixToScale: options.fixToScale !== false,
569
+ fixWith: options.fixWith === 'token' ? 'token' : 'value',
558
570
  ignoreMathFunctionArguments: normalizeMathFunctionArgumentMap(options.ignoreMathFunctionArguments),
559
571
  ignoreValues: Array.isArray(options.ignoreValues)
560
572
  ? options.ignoreValues.map((value) => String(value).toLowerCase())