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.
@@ -26,6 +26,8 @@ const FALLBACK_PRESET = 'rhythmic-4';
26
26
  const MIN_INFERRED_SCALE_LENGTH = 4;
27
27
  const RC_FILE = '.rhythmguardrc.json';
28
28
 
29
+ const { cachedByFiles } = require('./fs-cache');
30
+
29
31
  const sourceCache = new Map();
30
32
  const TOKEN_PACKAGES = require('./token-packages.json').packages;
31
33
 
@@ -53,35 +55,6 @@ function readDirectDependencies(dir) {
53
55
  * installs are found by walking up; a stray global node_modules is never
54
56
  * consulted because the walk stops at the repository.
55
57
  */
56
- /**
57
- * Editors and pre-commit hooks lint one file at a time, and each lint asked
58
- * the filesystem the same questions: which package.json files sit between
59
- * cwd and the repository, what they declare, and whether a token package is
60
- * installed. The answers change only when one of those files changes, so
61
- * the result is cached per cwd and revalidated by mtime, which costs a stat
62
- * per file instead of a read, a JSON parse and a directory walk.
63
- */
64
- const discoveryCache = new Map();
65
-
66
- function fileStamp(file) {
67
- try {
68
- return fs.statSync(file).mtimeMs;
69
- } catch {
70
- return null;
71
- }
72
- }
73
-
74
- function cachedByFiles(cacheKey, compute) {
75
- const cached = discoveryCache.get(cacheKey);
76
- if (cached && cached.stamps.every(([file, stamp]) => fileStamp(file) === stamp)) {
77
- return cached.value;
78
- }
79
- const consulted = [];
80
- const value = compute((file) => consulted.push([file, fileStamp(file)]));
81
- discoveryCache.set(cacheKey, { stamps: consulted, value });
82
- return value;
83
- }
84
-
85
58
  function projectRoots(cwd, consult = () => {}) {
86
59
  const roots = [];
87
60
  let current = path.resolve(cwd);
@@ -192,21 +165,27 @@ function cacheKey(sources) {
192
165
  .join('\n');
193
166
  }
194
167
 
195
- function scaleFromSources(sources, baseFontSize) {
168
+ /** Parse token sources once per (sources, base font size, file mtimes). */
169
+ function parseSourcesCached(sources, baseFontSize) {
196
170
  const normalized = sources.map((source) => normalizeSource(source, process.cwd())).filter(Boolean);
197
171
  if (normalized.length === 0) {
198
172
  return null;
199
173
  }
200
-
201
174
  const key = `${baseFontSize}\n${cacheKey(normalized)}`;
202
- if (sourceCache.has(key)) {
203
- return sourceCache.get(key);
175
+ if (!sourceCache.has(key)) {
176
+ sourceCache.set(key, parseTokenSources({ baseFontSize, sources: normalized, tokenKind: 'spacing' }));
204
177
  }
178
+ return sourceCache.get(key);
179
+ }
205
180
 
206
- const parsed = parseTokenSources({ baseFontSize, sources: normalized, tokenKind: 'spacing' });
181
+ function scaleFromSources(sources, baseFontSize) {
182
+ const parsed = parseSourcesCached(sources, baseFontSize);
183
+ if (!parsed) {
184
+ return null;
185
+ }
207
186
  // scaleFromDefinitions also expands a bare Tailwind --spacing base into its multiples.
208
187
  const scale = scaleFromDefinitions(parsed.definitions, baseFontSize);
209
- const outcome = scale
188
+ return scale
210
189
  ? {
211
190
  files: parsed.sources.map((source) => source.file),
212
191
  scale,
@@ -214,9 +193,26 @@ function scaleFromSources(sources, baseFontSize) {
214
193
  warnings: parsed.warnings,
215
194
  }
216
195
  : null;
196
+ }
217
197
 
218
- sourceCache.set(key, outcome);
219
- return outcome;
198
+ /**
199
+ * Every spacing token definition visible from a stylesheet, for fixes that
200
+ * write tokens: the stylesheet's own declarations, then `scaleSources`,
201
+ * `.rhythmguardrc.json` token sources and installed token packages. The same
202
+ * places scale inference reads, in the same order.
203
+ */
204
+ function collectTokenDefinitions({ baseFontSize = 16, root, scaleSources = [], tokenRegex }) {
205
+ const definitions = root ? stylesheetDefinitions(root, tokenRegex, baseFontSize) : new Map();
206
+ const sources = [...scaleSources, ...rcTokenSources(process.cwd()), ...discoverTokenPackages(process.cwd())];
207
+ const parsed = parseSourcesCached(sources, baseFontSize);
208
+ if (parsed) {
209
+ for (const [token, definition] of parsed.definitions) {
210
+ if (!definitions.has(token)) {
211
+ definitions.set(token, definition);
212
+ }
213
+ }
214
+ }
215
+ return definitions;
220
216
  }
221
217
 
222
218
  /**
@@ -552,6 +548,7 @@ module.exports = {
552
548
  DEFAULT_AUTO_TOKEN_PATTERN,
553
549
  assessScale,
554
550
  autoScaleFallbackNote,
551
+ collectTokenDefinitions,
555
552
  discoverTokenPackages,
556
553
  inferScaleFromDefinitions,
557
554
  resolveAutoScale,
@@ -0,0 +1,72 @@
1
+ 'use strict';
2
+
3
+ const { fixedLengthValue, numbersEqual, toPx } = require('./length');
4
+ const { parseTokenValueLength } = require('./token-sources');
5
+
6
+ /**
7
+ * Which token a fix may write for a length. Strict on purpose: the token must
8
+ * hold the same px value in the same unit as the literal being fixed, it must
9
+ * be a custom property (a Sass variable is not valid in CSS output), and it
10
+ * must be the only candidate. A fix that guesses a token is worse than a fix
11
+ * that writes a number, so anything ambiguous falls back to the literal.
12
+ */
13
+ function tokenIndexFromDefinitions(definitions, baseFontSize = 16) {
14
+ const index = [];
15
+ for (const definition of definitions.values()) {
16
+ for (const raw of definition.values) {
17
+ const parsed = parseTokenValueLength(String(raw).trim());
18
+ if (!parsed) continue;
19
+ const unit = parsed.unit || 'px';
20
+ const px = toPx(Math.abs(parsed.number), unit, baseFontSize);
21
+ if (px === null || !Number.isFinite(px) || px === 0) continue;
22
+ index.push({ px, raw: String(raw).trim(), token: definition.token, unit });
23
+ }
24
+ }
25
+ return index;
26
+ }
27
+
28
+ function tokenForLength(index, px, unit) {
29
+ const wanted = unit || 'px';
30
+ const candidates = new Set();
31
+ for (const entry of index) {
32
+ if (entry.token.startsWith('--') && entry.unit === wanted && numbersEqual(entry.px, Math.abs(px))) {
33
+ candidates.add(entry.token);
34
+ }
35
+ }
36
+ if (candidates.size !== 1) {
37
+ return null;
38
+ }
39
+ return `var(${[...candidates][0]})`;
40
+ }
41
+
42
+ /** A negative token reference that is still valid where it is written. */
43
+ function negateReplacement(replacement) {
44
+ if (!replacement || replacement.startsWith('-')) {
45
+ return replacement;
46
+ }
47
+ if (replacement.startsWith('$') || replacement.startsWith('@')) {
48
+ return `-${replacement}`;
49
+ }
50
+ return `calc(-1 * ${replacement})`;
51
+ }
52
+
53
+ /**
54
+ * The replacement text a length rule writes: the matching token when the
55
+ * options ask for one and exactly one qualifies, otherwise the literal.
56
+ */
57
+ function replacementFor(parsedLength, nearestPx, options) {
58
+ if (options.fixWith === 'token' && options.tokenIndex) {
59
+ const token = tokenForLength(options.tokenIndex, nearestPx, parsedLength.unit || 'px');
60
+ if (token) {
61
+ return parsedLength.number < 0 ? negateReplacement(token) : token;
62
+ }
63
+ }
64
+ return fixedLengthValue(parsedLength, nearestPx, options);
65
+ }
66
+
67
+ module.exports = {
68
+ negateReplacement,
69
+ replacementFor,
70
+ tokenForLength,
71
+ tokenIndexFromDefinitions,
72
+ };
@@ -792,8 +792,9 @@ function addDefinition(definitions, {
792
792
  definitions.set(token, entry);
793
793
  }
794
794
 
795
- const CALC_LENGTH_TIMES_VAR = /^calc\(\s*(-?[\d.]+(?:px|rem|em)?)\s*\*\s*var\([^()]*\)\s*\)$/i;
796
- const CALC_VAR_TIMES_LENGTH = /^calc\(\s*var\([^()]*\)\s*\*\s*(-?[\d.]+(?:px|rem|em)?)\s*\)$/i;
795
+ // The factor must carry a unit: `calc(4px * var(--scaling))` is a length, `calc(var(--x) * 2)` is a multiplier.
796
+ const CALC_LENGTH_TIMES_VAR = /^calc\(\s*(-?[\d.]+(?:px|rem|em))\s*\*\s*var\([^()]*\)\s*\)$/i;
797
+ const CALC_VAR_TIMES_LENGTH = /^calc\(\s*var\([^()]*\)\s*\*\s*(-?[\d.]+(?:px|rem|em))\s*\)$/i;
797
798
 
798
799
  /**
799
800
  * Parse the length a token value carries. Accepts plain lengths and the
@@ -3,7 +3,6 @@
3
3
  const stylelint = require('stylelint');
4
4
  const valueParser = require('postcss-value-parser');
5
5
  const {
6
- fixedLengthValue,
7
6
  formatLength,
8
7
  isHairlineLength,
9
8
  nearestScaleValues,
@@ -23,12 +22,15 @@ const {
23
22
  } = require('../../core/value-nodes');
24
23
 
25
24
  const {
25
+ collectTokenDefinitions,
26
26
  withResolvedScale,
27
27
  } = require('../../core/scale-inference');
28
28
 
29
29
  const { validatePrimary, validateNoOffscaleTransformSecondaryOptions } = require('../validate');
30
30
 
31
- const { reportInvalidPreset, reportValueNode } = require('../report');
31
+ const { reportInvalidPreset, reportProblem, reportValueNode } = require('../report');
32
+ const { decisionFor, loadRcDecisions } = require('../../core/decisions');
33
+ const { replacementFor, tokenIndexFromDefinitions } = require('../../core/token-index');
32
34
 
33
35
  const ruleName = 'rhythmguard/no-offscale-transform';
34
36
  const messages = stylelint.utils.ruleMessages(ruleName, {
@@ -57,8 +59,20 @@ const ruleFunction = (primary, secondaryOptions) => {
57
59
 
58
60
  const options = buildScaleOptions(secondaryOptions);
59
61
  reportInvalidPreset(options, { message: messages.invalidPreset, result, root, ruleName });
62
+ try {
63
+ options.decisions = options.readDecisions ? loadRcDecisions(process.cwd(), { baseFontSize: options.baseFontSize }) : [];
64
+ } catch (error) {
65
+ reportProblem(error.message, { result, root, ruleName });
66
+ options.decisions = [];
67
+ }
60
68
 
61
69
  withResolvedScale(options, root);
70
+ if (options.fixWith === 'token') {
71
+ options.tokenIndex = tokenIndexFromDefinitions(
72
+ collectTokenDefinitions({ baseFontSize: options.baseFontSize, root, scaleSources: options.scaleSources, tokenRegex: new RegExp(options.tokenPattern) }),
73
+ options.baseFontSize,
74
+ );
75
+ }
62
76
 
63
77
  const getScaleStateForProperty = createPropertyScaleResolver(options);
64
78
 
@@ -117,6 +131,12 @@ const ruleFunction = (primary, secondaryOptions) => {
117
131
  return;
118
132
  }
119
133
 
134
+ const decidedPx = toPx(Math.abs(parsedLength.number), parsedLength.unit, options.baseFontSize);
135
+ const decision = decidedPx === null ? null : decisionFor(decidedPx, prop, options.decisions);
136
+ if (decision && (decision.decision === 'adopt' || decision.decision === 'allow')) {
137
+ return;
138
+ }
139
+
120
140
  if (options.unitStrategy === 'exact') {
121
141
  const unit = parsedLength.unit || 'px';
122
142
  const unitScale = scaleByUnit.get(unit);
@@ -136,7 +156,7 @@ const ruleFunction = (primary, secondaryOptions) => {
136
156
  }
137
157
 
138
158
  const fixedValue = options.fixToScale
139
- ? fixedLengthValue(parsedLength, nearest.nearest, options)
159
+ ? replacementFor(parsedLength, nearest.nearest, options)
140
160
  : null;
141
161
 
142
162
  report(node, nearest, unit, fixedValue);
@@ -160,7 +180,7 @@ const ruleFunction = (primary, secondaryOptions) => {
160
180
  }
161
181
 
162
182
  const fixedValue = options.fixToScale
163
- ? fixedLengthValue(parsedLength, nearest.nearest, options)
183
+ ? replacementFor(parsedLength, nearest.nearest, options)
164
184
  : null;
165
185
 
166
186
  report(node, nearest, 'px', fixedValue);
@@ -31,6 +31,8 @@ const {
31
31
  const { createTokenRegex, reportInvalidPreset, reportValueNode } = require('../report');
32
32
  const { validatePrimary, validatePreferTokenSecondaryOptions } = require('../validate');
33
33
 
34
+ const { negateReplacement } = require('../../core/token-index');
35
+
34
36
  const ruleName = 'rhythmguard/prefer-token';
35
37
 
36
38
  const messages = stylelint.utils.ruleMessages(ruleName, {
@@ -41,25 +43,7 @@ const messages = stylelint.utils.ruleMessages(ruleName, {
41
43
  });
42
44
 
43
45
  function applyNegativeToken(replacement, parsedLength) {
44
- if (!replacement || parsedLength.number >= 0) {
45
- return replacement;
46
- }
47
-
48
- if (replacement.startsWith('-')) {
49
- return replacement;
50
- }
51
-
52
- if (
53
- replacement.startsWith('var(') ||
54
- replacement.startsWith('theme(') ||
55
- replacement.startsWith('token(') ||
56
- replacement.startsWith('$') ||
57
- replacement.startsWith('@')
58
- ) {
59
- return `-${replacement}`;
60
- }
61
-
62
- return `calc(${replacement} * -1)`;
46
+ return !replacement || parsedLength.number >= 0 ? replacement : negateReplacement(replacement);
63
47
  }
64
48
 
65
49
  function resolveTokenReplacement(tokenMap, raw, parsedLength, options) {
@@ -68,8 +68,14 @@ function reportInvalidPreset(options, { message, result, root, ruleName }) {
68
68
  });
69
69
  }
70
70
 
71
+ /** A configuration problem that is not about one node: reported once, on the root. */
72
+ function reportProblem(message, { result, root, ruleName }) {
73
+ stylelint.utils.report({ message, node: root, result, ruleName });
74
+ }
75
+
71
76
  module.exports = {
72
77
  createTokenRegex,
73
78
  reportInvalidPreset,
79
+ reportProblem,
74
80
  reportValueNode,
75
81
  };
@@ -3,7 +3,6 @@
3
3
  const stylelint = require('stylelint');
4
4
  const valueParser = require('postcss-value-parser');
5
5
  const {
6
- fixedLengthValue,
7
6
  formatLength,
8
7
  isHairlineLength,
9
8
  nearestScaleValues,
@@ -27,10 +26,13 @@ const {
27
26
 
28
27
  const {
29
28
  autoScaleFallbackNote,
29
+ collectTokenDefinitions,
30
30
  withResolvedScale,
31
31
  } = require('../../core/scale-inference');
32
32
 
33
- const { createTokenRegex, reportInvalidPreset, reportValueNode } = require('../report');
33
+ const { createTokenRegex, reportInvalidPreset, reportProblem, reportValueNode } = require('../report');
34
+ const { decisionFor, loadRcDecisions } = require('../../core/decisions');
35
+ const { replacementFor, tokenIndexFromDefinitions } = require('../../core/token-index');
34
36
  const { validatePrimary, validateUseScaleSecondaryOptions } = require('../validate');
35
37
 
36
38
  const ruleName = 'rhythmguard/use-scale';
@@ -42,6 +44,19 @@ const messages = stylelint.utils.ruleMessages(ruleName, {
42
44
  `Unexpected off-scale value "${value}". Use scale values (nearest: ${lower} or ${upper}).${note ? ` ${note}` : ''}`,
43
45
  });
44
46
 
47
+ /** Decisions from .rhythmguardrc.json; an invalid section is reported once and ignored. */
48
+ function readDecisions(options, { result, root }) {
49
+ if (!options.readDecisions) {
50
+ return [];
51
+ }
52
+ try {
53
+ return loadRcDecisions(process.cwd(), { baseFontSize: options.baseFontSize });
54
+ } catch (error) {
55
+ reportProblem(error.message, { result, root, ruleName });
56
+ return [];
57
+ }
58
+ }
59
+
45
60
  function checkLengthValue({
46
61
  decl,
47
62
  node,
@@ -90,6 +105,12 @@ function checkLengthValue({
90
105
  return false;
91
106
  }
92
107
 
108
+ const decidedPx = toPx(Math.abs(parsedLength.number), parsedLength.unit, options.baseFontSize);
109
+ const decision = decidedPx === null ? null : decisionFor(decidedPx, decl.prop, options.decisions);
110
+ if (decision && (decision.decision === 'adopt' || decision.decision === 'allow')) {
111
+ return false;
112
+ }
113
+
93
114
  if (options.unitStrategy === 'exact') {
94
115
  const unit = parsedLength.unit || 'px';
95
116
  const unitScale = scaleByUnit.get(unit);
@@ -110,7 +131,7 @@ function checkLengthValue({
110
131
  }
111
132
 
112
133
  const fixedValue = options.fixToScale
113
- ? fixedLengthValue(parsedLength, nearest.nearest, options)
134
+ ? replacementFor(parsedLength, nearest.nearest, options)
114
135
  : null;
115
136
 
116
137
  report(node.value, decl, node, nearest, fixedValue, unit);
@@ -134,7 +155,7 @@ function checkLengthValue({
134
155
  }
135
156
 
136
157
  const fixedValue = options.fixToScale
137
- ? fixedLengthValue(parsedLength, nearest.nearest, options)
158
+ ? replacementFor(parsedLength, nearest.nearest, options)
138
159
  : null;
139
160
 
140
161
  report(node.value, decl, node, nearest, fixedValue, 'px');
@@ -160,10 +181,17 @@ const ruleFunction = (primary, secondaryOptions) => {
160
181
 
161
182
  const options = buildScaleOptions(secondaryOptions);
162
183
  reportInvalidPreset(options, { message: messages.invalidPreset, result, root, ruleName });
163
-
184
+ options.decisions = readDecisions(options, { result, root });
164
185
  withResolvedScale(options, root);
165
186
 
166
187
  const tokenRegex = createTokenRegex(options.tokenPattern, result, ruleName);
188
+ if (options.fixWith === 'token') {
189
+ options.tokenIndex = tokenIndexFromDefinitions(
190
+ collectTokenDefinitions({ baseFontSize: options.baseFontSize, root, scaleSources: options.scaleSources, tokenRegex }),
191
+ options.baseFontSize,
192
+ );
193
+ }
194
+
167
195
  let fallbackNote = autoScaleFallbackNote(options.scaleInference);
168
196
  const getScaleStateForProperty = createPropertyScaleResolver(options);
169
197
 
package/types/audit.d.ts CHANGED
@@ -98,6 +98,33 @@ export interface AuditBaselineComparison {
98
98
 
99
99
  export type AuditScaleSource = "default" | "explicit" | "fallback" | "scanned-css" | "token-package" | "token-sources";
100
100
 
101
+ export type AuditDecisionKind = "adopt" | "allow" | "snap" | "undecided";
102
+
103
+ /** One entry of the `decisions` section of `.rhythmguardrc.json`. */
104
+ export interface AuditDecision {
105
+ value: string;
106
+ decision: AuditDecisionKind;
107
+ /** For `adopt`: the token the value should become. */
108
+ as?: string;
109
+ /** For `allow`: property names or `prefix-*` patterns the allowance is limited to. */
110
+ properties?: string[];
111
+ reason?: string;
112
+ }
113
+
114
+ export interface AuditDecisionSummary {
115
+ adopt: number;
116
+ allow: number;
117
+ snap: number;
118
+ undecided: number;
119
+ suppressed: number;
120
+ }
121
+
122
+ /** One line of `rhythmguard audit --plan`: a decision entry with what the audit saw. */
123
+ export interface AuditDecisionPlanEntry extends AuditDecision {
124
+ count: number;
125
+ nearest: string[];
126
+ }
127
+
101
128
  export interface AuditScaleRejected {
102
129
  files?: string[];
103
130
  /** Why the inferred set was not accepted as a scale, for example "no common step". */
@@ -117,6 +144,8 @@ export interface AuditScale {
117
144
  }
118
145
 
119
146
  export interface AuditReport {
147
+ decisions?: AuditDecisionSummary | null;
148
+ decisionPlan?: AuditDecisionPlanEntry[];
120
149
  baseline?: AuditBaselineComparison | null;
121
150
  scale?: AuditScale | null;
122
151
  config?: string | null;
@@ -143,6 +172,8 @@ export interface AuditContractReport {
143
172
  scanScope: string;
144
173
  };
145
174
  contracts: {
175
+ /** Counts per decision kind and how many findings the decisions suppressed; null when the config has no decisions. */
176
+ decisions: AuditDecisionSummary | null;
146
177
  motion?: unknown;
147
178
  scale: {
148
179
  cleanliness?: unknown;
package/types/shared.d.ts CHANGED
@@ -17,6 +17,10 @@ export interface ScaleSource {
17
17
  export interface RhythmguardRuleOptions {
18
18
  /** Exempt non-zero lengths of one CSS pixel or less (1px, -1px, 0.5px, 0.0625rem). Default true. */
19
19
  allowHairlines?: boolean;
20
+ /** What autofix writes: the nearest literal (default) or, with "token", `var(--name)` when exactly one custom property holds that value in the same unit. */
21
+ fixWith?: "value" | "token";
22
+ /** Read the `decisions` section of `.rhythmguardrc.json` (default true). The audit sets false and applies decisions itself. */
23
+ decisions?: boolean;
20
24
  baseFontSize?: number;
21
25
  customScale?: ScaleValue[];
22
26
  includeMathFunctions?: boolean;