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.
- package/CHANGELOG.md +29 -0
- package/CONTRIBUTING.md +7 -4
- package/README.md +8 -10
- package/SECURITY.md +3 -2
- package/package.json +2 -1
- package/src/audit/args.js +1 -0
- package/src/audit/baseline.js +55 -11
- package/src/audit/codemod.js +129 -0
- package/src/audit/config.js +11 -0
- package/src/audit/contract.js +1 -0
- package/src/audit/decisions.js +105 -0
- package/src/audit/render-github.js +5 -2
- package/src/audit/render-markdown.js +31 -0
- package/src/audit/render-text.js +11 -0
- package/src/audit/report.js +30 -2
- package/src/audit/scan/stylesheets.js +1 -0
- package/src/audit/shared.js +2 -0
- package/src/audit/token-chains.js +157 -0
- package/src/cli/audit.js +5 -0
- package/src/cli/fix.js +151 -0
- package/src/cli/index.js +4 -0
- package/src/core/decisions.js +121 -0
- package/src/core/fs-cache.js +36 -0
- package/src/core/options.js +12 -0
- package/src/core/scale-inference.js +34 -37
- package/src/core/token-index.js +72 -0
- package/src/core/token-packages.json +36 -0
- package/src/core/token-sources.js +3 -2
- package/src/rules/no-offscale-transform/index.js +24 -4
- package/src/rules/prefer-token/index.js +3 -19
- package/src/rules/report.js +6 -0
- package/src/rules/use-scale/index.js +33 -5
- package/types/audit.d.ts +31 -0
- package/types/shared.d.ts +4 -0
|
@@ -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,
|
package/src/audit/render-text.js
CHANGED
|
@@ -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) {
|
package/src/audit/report.js
CHANGED
|
@@ -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
|
|
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:
|
|
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
|
}
|
package/src/audit/shared.js
CHANGED
|
@@ -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
|
+
};
|