stylelint-plugin-rhythmguard 3.8.0 → 3.10.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.
@@ -123,7 +123,7 @@ function parseTokenSources({
123
123
  continue;
124
124
  }
125
125
 
126
- let tokens = [];
126
+ let tokens;
127
127
  const matchesSource = normalizedSource.tokenPattern
128
128
  ? createPatternMatcher(normalizedSource.tokenPattern, matchesKind)
129
129
  : matchesKind;
@@ -428,7 +428,7 @@ function createScssVariableResolver(source, fallback = () => null) {
428
428
  if (stack.has(name)) {
429
429
  return null;
430
430
  }
431
- let value = null;
431
+ let value;
432
432
  if (declarations.has(name)) {
433
433
  stack.add(name);
434
434
  const raw = declarations.get(name);
@@ -191,6 +191,55 @@ function shouldLintMathArgument(context, options) {
191
191
  return true;
192
192
  }
193
193
 
194
+ const BOUND_FUNCTIONS = new Set(['clamp', 'min', 'max']);
195
+
196
+ function isBoundFunction(node) {
197
+ return node.type === 'function' && BOUND_FUNCTIONS.has(node.value.toLowerCase());
198
+ }
199
+
200
+ function splitArguments(nodes) {
201
+ const args = [[]];
202
+ for (const node of nodes) {
203
+ if (node.type === 'div' && node.value === ',') {
204
+ args.push([]);
205
+ continue;
206
+ }
207
+ if (node.type !== 'space' && node.type !== 'comment') {
208
+ args[args.length - 1].push(node);
209
+ }
210
+ }
211
+ return args;
212
+ }
213
+
214
+ /**
215
+ * The static bounds of a fluid value (issue #203): the minimum and maximum of
216
+ * `clamp(MIN, VAL, MAX)` and every argument of `min()` and `max()`. A bound
217
+ * counts only when it is one word, a length literal; the preferred value of
218
+ * clamp(), an expression, or a var() is fluid by design and left alone. A
219
+ * bound that is itself min(), max() or clamp() is followed.
220
+ */
221
+ function collectMathBounds(fnNode) {
222
+ const name = fnNode.value.toLowerCase();
223
+ const args = splitArguments(fnNode.nodes);
224
+ const positioned = name === 'clamp'
225
+ ? (args.length === 3 ? [[args[0], 'clamp() minimum'], [args[2], 'clamp() maximum']] : [])
226
+ : args.map((arg) => [arg, `${name}() argument`]);
227
+
228
+ const bounds = [];
229
+ for (const [arg, position] of positioned) {
230
+ if (arg.length !== 1) {
231
+ continue;
232
+ }
233
+ const [node] = arg;
234
+ if (node.type === 'word') {
235
+ bounds.push({ node, position });
236
+ } else if (isBoundFunction(node)) {
237
+ bounds.push(...collectMathBounds(node));
238
+ }
239
+ }
240
+ return bounds;
241
+ }
242
+
194
243
  function declarationValueIndex(decl) {
195
244
  const declarationText = decl.toString();
196
245
  const idx = declarationText.indexOf(decl.value);
@@ -198,7 +247,9 @@ function declarationValueIndex(decl) {
198
247
  }
199
248
 
200
249
  module.exports = {
250
+ collectMathBounds,
201
251
  declarationValueIndex,
252
+ isBoundFunction,
202
253
  isKeyword,
203
254
  isMathFunction,
204
255
  isTokenFunction,
@@ -69,7 +69,13 @@ const ruleFunction = (primary, secondaryOptions) => {
69
69
  withResolvedScale(options, root);
70
70
  if (options.fixWith === 'token') {
71
71
  options.tokenIndex = tokenIndexFromDefinitions(
72
- collectTokenDefinitions({ baseFontSize: options.baseFontSize, root, scaleSources: options.scaleSources, tokenRegex: new RegExp(options.tokenPattern) }),
72
+ collectTokenDefinitions({
73
+ baseFontSize: options.baseFontSize,
74
+ root,
75
+ scaleSources: options.scaleSources,
76
+ tokenPattern: options.tokenPatternExplicit ? options.tokenPattern : null,
77
+ tokenRegex: new RegExp(options.tokenPattern),
78
+ }),
73
79
  options.baseFontSize,
74
80
  );
75
81
  }
@@ -157,7 +163,7 @@ const ruleFunction = (primary, secondaryOptions) => {
157
163
  }
158
164
 
159
165
  const fixedValue = options.fixToScale
160
- ? replacementFor(parsedLength, nearest.nearest, options)
166
+ ? replacementFor(parsedLength, nearest.nearest, options, decl.prop)
161
167
  : null;
162
168
 
163
169
  report(node, nearest, unit, fixedValue);
@@ -181,7 +187,7 @@ const ruleFunction = (primary, secondaryOptions) => {
181
187
  }
182
188
 
183
189
  const fixedValue = options.fixToScale
184
- ? replacementFor(parsedLength, nearest.nearest, options)
190
+ ? replacementFor(parsedLength, nearest.nearest, options, decl.prop)
185
191
  : null;
186
192
 
187
193
  report(node, nearest, 'px', fixedValue);
@@ -14,6 +14,8 @@ const {
14
14
  createPropertyScaleResolver,
15
15
  } = require('../../core/options');
16
16
  const {
17
+ collectMathBounds,
18
+ isBoundFunction,
17
19
  isKeyword,
18
20
  isMathFunction,
19
21
  isTokenFunction,
@@ -25,25 +27,40 @@ const {
25
27
  const { buildEffectiveTokenMap } = require('../../core/token-map');
26
28
 
27
29
  const {
30
+ collectTokenDefinitions,
28
31
  withResolvedScale,
29
32
  } = require('../../core/scale-inference');
30
33
 
31
34
  const { createTokenRegex, reportInvalidPreset, reportValueNode } = require('../report');
32
35
  const { validatePrimary, validatePreferTokenSecondaryOptions } = require('../validate');
33
36
 
34
- const { negateReplacement } = require('../../core/token-index');
37
+ const {
38
+ negateReplacement,
39
+ tokenFitsProperty,
40
+ tokenIndexFromDefinitions,
41
+ tokensForLength,
42
+ } = require('../../core/token-index');
35
43
 
36
44
  const ruleName = 'rhythmguard/prefer-token';
37
45
 
38
46
  const messages = stylelint.utils.ruleMessages(ruleName, {
39
47
  invalidPreset: (presetName, presetNames) =>
40
48
  `Unknown scale preset "${presetName}". Available presets: ${presetNames.join(', ')}.`,
41
- rejected: (value, replacement = null, origin = null, note = '') =>
42
- `Unexpected raw scale value "${value}". ${replacement
49
+ rejected: (value, replacement = null, origin = null, note = '', candidates = [], position = null) =>
50
+ `Unexpected raw ${position || 'scale value'} "${value}". ${replacement
43
51
  ? `Use ${replacement}${origin ? ` (${origin})` : ''}.`
44
- : 'No known token holds this value; use the nearest token or add one.'}${note ? ` ${note}` : ''}`,
52
+ : candidates.length > 1
53
+ ? `Use one of the tokens that hold it: ${listTokens(candidates)}.`
54
+ : 'No known token holds this value; use the nearest token or add one.'}${note ? ` ${note}` : ''}`,
45
55
  });
46
56
 
57
+ function listTokens(tokens) {
58
+ const references = tokens.map((token) => `var(${token})`);
59
+ return references.length > 1
60
+ ? `${references.slice(0, -1).join(', ')} or ${references[references.length - 1]}`
61
+ : references.join('');
62
+ }
63
+
47
64
  function applyNegativeToken(replacement, parsedLength) {
48
65
  return !replacement || parsedLength.number >= 0 ? replacement : negateReplacement(replacement);
49
66
  }
@@ -74,6 +91,36 @@ function resolveTokenReplacement(tokenMap, raw, parsedLength, options) {
74
91
  return null;
75
92
  }
76
93
 
94
+ /**
95
+ * The tokens use-scale's `fixWith: "token"` would see: the stylesheet,
96
+ * `scaleSources`, `.rhythmguardrc.json` token sources and installed token
97
+ * packages, filtered to the property's kind. One same-unit token is a fix; a
98
+ * token in another unit, or several tokens, are named but never written.
99
+ */
100
+ function resolveFromIndex(tokenIndex, parsedLength, prop, options) {
101
+ const px = toPx(Math.abs(parsedLength.number), parsedLength.unit || 'px', options.baseFontSize);
102
+ if (px === null || tokenIndex.length === 0) {
103
+ return { candidates: [] };
104
+ }
105
+ const unit = parsedLength.unit || 'px';
106
+ const sameUnit = tokensForLength(tokenIndex, px, unit, { prop });
107
+ const candidates = sameUnit.length > 0 || options.unitStrategy !== 'convert'
108
+ ? sameUnit
109
+ : tokensForLength(tokenIndex, px, unit, { anyUnit: true, prop });
110
+ if (candidates.length !== 1) {
111
+ return { candidates };
112
+ }
113
+ const token = `var(${candidates[0]})`;
114
+ const entry = tokenIndex.find((item) => item.token === candidates[0]);
115
+ return {
116
+ candidates,
117
+ fixable: sameUnit.length === 1,
118
+ origin: entry ? entry.file : null,
119
+ replacement: applyNegativeToken(token, parsedLength),
120
+ token,
121
+ };
122
+ }
123
+
77
124
  const ruleFunction = (primary, secondaryOptions) => {
78
125
  return (root, result) => {
79
126
  const valid = validatePrimary(result, ruleName, primary);
@@ -105,6 +152,17 @@ const ruleFunction = (primary, secondaryOptions) => {
105
152
  tokenRegex,
106
153
  });
107
154
 
155
+ const tokenIndex = tokenIndexFromDefinitions(
156
+ collectTokenDefinitions({
157
+ baseFontSize: options.baseFontSize,
158
+ root,
159
+ scaleSources: options.scaleSources,
160
+ tokenPattern: options.tokenPatternExplicit ? options.tokenPattern : null,
161
+ tokenRegex,
162
+ }),
163
+ options.baseFontSize,
164
+ );
165
+
108
166
  const getScaleStateForProperty = createPropertyScaleResolver(options);
109
167
 
110
168
  root.walkDecls((decl) => {
@@ -121,18 +179,31 @@ const ruleFunction = (primary, secondaryOptions) => {
121
179
  const { scaleByUnit, scalePx } = getScaleStateForProperty(prop);
122
180
  let changed = false;
123
181
 
124
- const reportNode = (node, resolved = null) => {
125
- const replacement = resolved ? resolved.replacement : null;
126
- const origin = resolved ? origins[resolved.token] || null : null;
127
- reportValueNode({ decl, message: messages.rejected(node.value, replacement, origin, options.note), node, replacement, result, ruleName });
182
+ const reportNode = (node, parsedLength, position = null) => {
183
+ const mapped = resolveTokenReplacement(tokenMap, node.value, parsedLength, options);
184
+ if (mapped && tokenFitsProperty(mapped.token, prop)) {
185
+ const origin = origins[mapped.token] || null;
186
+ reportValueNode({ decl, message: messages.rejected(node.value, mapped.replacement, origin, options.note, [], position), node, replacement: mapped.replacement, result, ruleName });
187
+ return;
188
+ }
189
+ const indexed = resolveFromIndex(tokenIndex, parsedLength, prop, options);
190
+ const named = indexed.replacement || null;
191
+ reportValueNode({
192
+ decl,
193
+ message: messages.rejected(node.value, named, indexed.origin || null, options.note, indexed.candidates, position),
194
+ node,
195
+ replacement: indexed.fixable ? named : null,
196
+ result,
197
+ ruleName,
198
+ });
128
199
  };
129
200
 
130
- const checkWordNode = (node, context) => {
201
+ const checkWordNode = (node, context, position = null) => {
131
202
  if (isKeyword(node.value, options.ignoreValues)) {
132
203
  return false;
133
204
  }
134
205
 
135
- if (!shouldLintMathArgument(context, options)) {
206
+ if (!position && !shouldLintMathArgument(context, options)) {
136
207
  return false;
137
208
  }
138
209
 
@@ -190,7 +261,7 @@ const ruleFunction = (primary, secondaryOptions) => {
190
261
  }
191
262
  }
192
263
 
193
- reportNode(node, resolveTokenReplacement(tokenMap, node.value, parsedLength, options));
264
+ reportNode(node, parsedLength, position);
194
265
  return true;
195
266
  };
196
267
 
@@ -216,12 +287,22 @@ const ruleFunction = (primary, secondaryOptions) => {
216
287
  return false;
217
288
  });
218
289
  } else {
290
+ const checkedBounds = new Set();
219
291
  walkRootValueNodes(parsed, (node, context) => {
220
292
  if (node.type === 'function') {
221
293
  if (isTokenFunction(node, options.tokenFunctions, tokenRegex)) {
222
294
  return true;
223
295
  }
224
296
 
297
+ if (options.checkMathBounds && isBoundFunction(node)) {
298
+ for (const bound of collectMathBounds(node)) {
299
+ if (!checkedBounds.has(bound.node)) {
300
+ checkedBounds.add(bound.node);
301
+ changed = checkWordNode(bound.node, null, bound.position) || changed;
302
+ }
303
+ }
304
+ }
305
+
225
306
  if (isMathFunction(node.value) && !options.enforceInsideMathFunctions) {
226
307
  return true;
227
308
  }
@@ -229,7 +310,7 @@ const ruleFunction = (primary, secondaryOptions) => {
229
310
  return false;
230
311
  }
231
312
 
232
- if (node.type !== 'word') {
313
+ if (node.type !== 'word' || checkedBounds.has(node)) {
233
314
  return false;
234
315
  }
235
316
 
@@ -15,6 +15,8 @@ const {
15
15
  createPropertyScaleResolver,
16
16
  } = require('../../core/options');
17
17
  const {
18
+ collectMathBounds,
19
+ isBoundFunction,
18
20
  isKeyword,
19
21
  isMathFunction,
20
22
  isTokenFunction,
@@ -41,10 +43,20 @@ const ruleName = 'rhythmguard/use-scale';
41
43
  const messages = stylelint.utils.ruleMessages(ruleName, {
42
44
  invalidPreset: (presetName, presetNames) =>
43
45
  `Unknown scale preset "${presetName}". Available presets: ${presetNames.join(', ')}.`,
44
- rejected: (value, lower, upper, note = '') =>
45
- `Unexpected off-scale value "${value}". Use scale values (nearest: ${lower} or ${upper}).${note ? ` ${note}` : ''}`,
46
+ rejected: (value, lower, upper, note = '', position = null, aside = '', group = null) =>
47
+ `Unexpected off-scale ${position ? `${position} "${value}"${aside}` : `value "${value}"`}. Use ${group ? `${group} ` : ''}scale values (nearest: ${lower} or ${upper}).${note ? ` ${note}` : ''}`,
46
48
  });
47
49
 
50
+ /** " (16.8px)" after a bound written in rem or em, so the reader sees what was compared. */
51
+ function pxAside(value, baseFontSize) {
52
+ const parsed = parseLengthToken(value);
53
+ if (!parsed || !parsed.unit || parsed.unit === 'px' || parsed.unit === '%') {
54
+ return '';
55
+ }
56
+ const px = toPx(Math.abs(parsed.number), parsed.unit, baseFontSize);
57
+ return px === null ? '' : ` (${formatLength(parsed.number < 0 ? -px : px, 'px')})`;
58
+ }
59
+
48
60
  /** Decisions from .rhythmguardrc.json; an invalid section is reported once and ignored. */
49
61
  function readDecisions(options, { result, root }) {
50
62
  if (!options.readDecisions) {
@@ -136,7 +148,7 @@ function checkLengthValue({
136
148
  }
137
149
 
138
150
  const fixedValue = options.fixToScale && fixable
139
- ? replacementFor(parsedLength, nearest.nearest, options)
151
+ ? replacementFor(parsedLength, nearest.nearest, options, decl.prop)
140
152
  : null;
141
153
 
142
154
  report(node.value, decl, node, nearest, fixedValue, unit, evaluated ? evaluatedNote(evaluated, options) : '');
@@ -160,7 +172,7 @@ function checkLengthValue({
160
172
  }
161
173
 
162
174
  const fixedValue = options.fixToScale && fixable
163
- ? replacementFor(parsedLength, nearest.nearest, options)
175
+ ? replacementFor(parsedLength, nearest.nearest, options, decl.prop)
164
176
  : null;
165
177
 
166
178
  report(node.value, decl, node, nearest, fixedValue, 'px', evaluated ? evaluatedNote(evaluated, options) : '');
@@ -239,7 +251,13 @@ const ruleFunction = (primary, secondaryOptions) => {
239
251
  const tokenRegex = createTokenRegex(options.tokenPattern, result, ruleName);
240
252
  if (options.fixWith === 'token') {
241
253
  options.tokenIndex = tokenIndexFromDefinitions(
242
- collectTokenDefinitions({ baseFontSize: options.baseFontSize, root, scaleSources: options.scaleSources, tokenRegex }),
254
+ collectTokenDefinitions({
255
+ baseFontSize: options.baseFontSize,
256
+ root,
257
+ scaleSources: options.scaleSources,
258
+ tokenPattern: options.tokenPatternExplicit ? options.tokenPattern : null,
259
+ tokenRegex,
260
+ }),
243
261
  options.baseFontSize,
244
262
  );
245
263
  }
@@ -247,13 +265,13 @@ const ruleFunction = (primary, secondaryOptions) => {
247
265
  let fallbackNote = autoScaleFallbackNote(options.scaleInference);
248
266
  const getScaleStateForProperty = createPropertyScaleResolver(options);
249
267
 
250
- const report = (value, decl, node, nearest, fixedValue = null, nearestUnit = 'px', extraNote = '') => {
268
+ const report = (value, decl, node, nearest, fixedValue = null, nearestUnit = 'px', extraNote = '', position = null) => {
251
269
  const lower = nearest ? formatLength(nearest.lower, nearestUnit) : 'n/a';
252
270
  const upper = nearest ? formatLength(nearest.upper, nearestUnit) : 'n/a';
253
271
  const tokenNote = nearest ? tokenHoldsNote(fixedValue, formatLength(nearest.nearest, nearestUnit)) : '';
254
272
  reportValueNode({
255
273
  decl,
256
- message: messages.rejected(value, lower, upper, [fallbackNote, tokenNote, extraNote, options.note].filter(Boolean).join(' ')),
274
+ message: messages.rejected(value, lower, upper, [fallbackNote, tokenNote, extraNote, options.note].filter(Boolean).join(' '), position, position ? pxAside(value, options.baseFontSize) : '', getScaleStateForProperty(decl.prop.toLowerCase()).group),
257
275
  node,
258
276
  replacement: fixedValue,
259
277
  result,
@@ -336,6 +354,7 @@ const ruleFunction = (primary, secondaryOptions) => {
336
354
  return false;
337
355
  });
338
356
  } else {
357
+ const checkedBounds = new Set();
339
358
  walkRootValueNodes(parsed, (node, context) => {
340
359
 
341
360
  if (node.type === 'function') {
@@ -343,6 +362,20 @@ const ruleFunction = (primary, secondaryOptions) => {
343
362
  return true;
344
363
  }
345
364
 
365
+ if (options.checkMathBounds && isBoundFunction(node)) {
366
+ for (const bound of collectMathBounds(node)) {
367
+ if (checkedBounds.has(bound.node)) {
368
+ continue;
369
+ }
370
+ checkedBounds.add(bound.node);
371
+ if (isKeyword(bound.node.value, options.ignoreValues)) {
372
+ continue;
373
+ }
374
+ const boundReport = (value, d, n, nearest, fixed, unit, extra) => report(value, d, n, nearest, fixed, unit, extra, bound.position);
375
+ changed = checkLengthValue({ decl, node: bound.node, options, report: boundReport, scaleByUnit, scalePx }) || changed;
376
+ }
377
+ }
378
+
346
379
  if (
347
380
  isMathFunction(node.value) &&
348
381
  !options.enforceInsideMathFunctions
@@ -353,7 +386,7 @@ const ruleFunction = (primary, secondaryOptions) => {
353
386
  return false;
354
387
  }
355
388
 
356
- if (node.type !== 'word') {
389
+ if (node.type !== 'word' || checkedBounds.has(node)) {
357
390
  return false;
358
391
  }
359
392
 
package/types/audit.d.ts CHANGED
@@ -32,7 +32,11 @@ export interface AuditOptions {
32
32
  format?: "json" | "json-v1" | "markdown" | "text" | "html" | "github" | "badge";
33
33
  ignorePath?: string;
34
34
  ignorePatterns?: string[];
35
+ /** Check clamp() bounds and min()/max() arguments against the scale (equivalent to `--check-math-bounds`). */
36
+ checkMathBounds?: boolean;
35
37
  includeMotion?: boolean;
38
+ /** A scale per property group (`spacing`, `radius`, `size`, `typography`): values, a preset name or "auto". Each group named is also checked. */
39
+ scales?: Partial<Record<"spacing" | "radius" | "size" | "typography", Array<number | string> | string | { preset?: string; scale?: Array<number | string>; customScale?: Array<number | string> }>>;
36
40
  maxFindings?: number;
37
41
  minCleanliness?: number;
38
42
  /** Skip config discovery entirely (equivalent to `--no-config`). */
@@ -68,6 +72,8 @@ export interface AuditScanned {
68
72
  scssFiles?: number;
69
73
  /** .scss files found but not audited because postcss-scss is not installed. */
70
74
  scssSkipped?: number;
75
+ /** Stylesheets Stylelint skipped because the project's own .stylelintignore excludes them. */
76
+ stylelintIgnored?: number;
71
77
  templateFiles: number;
72
78
  totalFiles?: number;
73
79
  [key: string]: unknown;
package/types/shared.d.ts CHANGED
@@ -17,6 +17,8 @@ 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
+ /** Check the static bounds of fluid values: the clamp() minimum and maximum and each length argument of min() and max(). Default false; the preferred value of clamp() is never checked. */
21
+ checkMathBounds?: boolean;
20
22
  /** 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
23
  fixWith?: "value" | "token";
22
24
  /** Read the `decisions` section of `.rhythmguardrc.json` (default true). The audit sets false and applies decisions itself. */
@@ -34,6 +36,12 @@ export interface RhythmguardRuleOptions {
34
36
  * properties, then `tailwindConfigPath`, falling back to the `rhythmic-4` preset.
35
37
  */
36
38
  scale?: ScaleValue[] | "auto";
39
+ /**
40
+ * A scale per property group, used before `scale` and after `propertyScales`: values, a
41
+ * preset name, or `"auto"` to infer the group's scale from its tokens (`--font-size-*` and
42
+ * `--text-*` for typography, `--radius-*` for radius). Findings then name the group's scale.
43
+ */
44
+ scales?: Partial<Record<"spacing" | "radius" | "size" | "typography", ScaleValue[] | string | { preset?: string; scale?: ScaleValue[]; customScale?: ScaleValue[] }>>;
37
45
  /** Token files consulted first when `scale` is `"auto"`. */
38
46
  scaleSources?: Array<string | ScaleSource>;
39
47
  /** Tailwind v3 config whose `theme.spacing` feeds `scale: "auto"` and `tokenMapFromTailwindSpacing`. */