stylelint-plugin-rhythmguard 3.12.0 → 3.13.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.
Files changed (48) hide show
  1. package/CHANGELOG.md +40 -0
  2. package/README.md +1 -1
  3. package/package.json +23 -1
  4. package/src/audit/args.js +23 -4
  5. package/src/audit/baseline.js +15 -2
  6. package/src/audit/codemod.js +56 -5
  7. package/src/audit/config.js +2 -1
  8. package/src/audit/contract.js +3 -0
  9. package/src/audit/decisions.js +45 -16
  10. package/src/audit/render-markdown.js +22 -21
  11. package/src/audit/render-text.js +1 -0
  12. package/src/audit/render-utils.js +28 -1
  13. package/src/audit/report.js +14 -2
  14. package/src/audit/scan/files.js +139 -30
  15. package/src/audit/token-chains.js +4 -1
  16. package/src/audit/token-ladders.js +23 -2
  17. package/src/cli/doctor.js +28 -3
  18. package/src/cli/fix.js +7 -6
  19. package/src/cli/index.js +2 -2
  20. package/src/cli/init.js +41 -5
  21. package/src/cli/quickstart.js +46 -8
  22. package/src/configs/embed.js +2 -0
  23. package/src/configs/motion.js +1 -0
  24. package/src/configs/recommended.js +1 -0
  25. package/src/configs/strict.js +1 -0
  26. package/src/configs/tailwind.js +1 -0
  27. package/src/core/decisions.js +2 -1
  28. package/src/core/json.js +25 -0
  29. package/src/core/length.js +11 -0
  30. package/src/core/options.js +39 -20
  31. package/src/core/scale-inference.js +78 -23
  32. package/src/core/tailwind-class-analysis.js +3 -2
  33. package/src/core/token-map.js +2 -1
  34. package/src/core/token-sources.js +2 -1
  35. package/src/eslint/index.js +14 -8
  36. package/src/eslint/rules/tailwind-class-use-motion-scale.js +1 -0
  37. package/src/eslint/rules/tailwind-class-use-scale.js +1 -0
  38. package/src/rules/no-offscale-transform/index.js +58 -53
  39. package/src/rules/prefer-token/index.js +31 -46
  40. package/src/rules/use-motion-scale/index.js +14 -1
  41. package/src/rules/use-scale/index.js +75 -102
  42. package/src/rules/validate.js +13 -4
  43. package/types/__checks__/consumer.ts +20 -1
  44. package/types/__checks__/eslint-config.ts +8 -0
  45. package/types/__checks__/shipped-configs.ts +96 -0
  46. package/types/audit.d.ts +101 -16
  47. package/types/index.d.ts +15 -0
  48. package/types/shared.d.ts +126 -20
@@ -34,6 +34,7 @@ function maybeCheckNodeText(node, sourceCode, context, analyzer, allowFix) {
34
34
 
35
35
  module.exports = {
36
36
  meta: {
37
+ type: 'suggestion',
37
38
  docs: {
38
39
  url: 'https://github.com/PetriLahdelma/stylelint-plugin-rhythmguard/blob/main/docs/rules/tailwind-class-use-scale.md',
39
40
  description: 'Enforce spacing scale for Tailwind arbitrary spacing utilities in class strings',
@@ -9,6 +9,7 @@ const {
9
9
  nearestText,
10
10
  numbersEqual,
11
11
  parseLengthToken,
12
+ pxAside,
12
13
  pxOnScale,
13
14
  toPx,
14
15
  } = require('../../core/length');
@@ -17,7 +18,9 @@ const {
17
18
  createPropertyScaleResolver,
18
19
  } = require('../../core/options');
19
20
  const {
21
+ collectMathBounds,
20
22
  declarationValueSource,
23
+ isBoundFunction,
21
24
  isMathFunction,
22
25
  shouldLintMathArgument,
23
26
  skipTokenFunction,
@@ -41,8 +44,13 @@ const ruleName = 'rhythmguard/no-offscale-transform';
41
44
  const messages = stylelint.utils.ruleMessages(ruleName, {
42
45
  invalidPreset: (presetName, presetNames) =>
43
46
  `Unknown scale preset "${presetName}". Available presets: ${presetNames.join(', ')}.`,
44
- rejected: (value, lower, upper, note = '') =>
45
- `Unexpected transform translation value "${value}". Use scale values (${nearestText(lower, upper)}).${note ? ` ${note}` : ''}`,
47
+ rejected: (value, lower, upper, note = '', position = null, aside = '') =>
48
+ `Unexpected transform translation ${position ? `${position} "${value}"${aside}` : `value "${value}"`}. Use scale values (${nearestText(lower, upper)}).${note ? ` ${note}` : ''}`,
49
+ // A value the options forbid outright has no nearest scale value to name (issue #274).
50
+ rejectedNegative: (value, note = '', position = null) =>
51
+ `Unexpected transform translation ${position || 'value'} "${value}". Negative values are disabled for this rule.${note ? ` ${note}` : ''}`,
52
+ rejectedPercentage: (value, note = '', position = null) =>
53
+ `Unexpected transform translation ${position || 'value'} "${value}". Percentages are disabled for this rule.${note ? ` ${note}` : ''}`,
46
54
  });
47
55
 
48
56
  const ruleFunction = (primary, secondaryOptions) => {
@@ -99,15 +107,17 @@ const ruleFunction = (primary, secondaryOptions) => {
99
107
  const { scaleByUnit, scalePx } = getScaleStateForProperty(prop);
100
108
  let changed = false;
101
109
 
102
- // `nearest` is null for a value the options forbid outright, as in use-scale.
103
- const report = (node, nearest, nearestUnit, fixedValue = null) => {
110
+ // `position` names a clamp(), min() or max() bound (issue #272).
111
+ const report = (node, nearest, nearestUnit, fixedValue = null, position = null) => {
104
112
  reportValueNode({
105
113
  decl,
106
114
  message: messages.rejected(
107
115
  node.value,
108
- nearest ? formatLength(nearest.lower, nearestUnit) : 'n/a',
109
- nearest ? formatLength(nearest.upper, nearestUnit) : 'n/a',
110
- [nearest ? tokenHoldsNote(fixedValue, formatLength(nearest.nearest, nearestUnit), node.value.trim().startsWith('-')) : '', options.note].filter(Boolean).join(' '),
116
+ formatLength(nearest.lower, nearestUnit),
117
+ formatLength(nearest.upper, nearestUnit),
118
+ [tokenHoldsNote(fixedValue, formatLength(nearest.nearest, nearestUnit), node.value.trim().startsWith('-')), options.note].filter(Boolean).join(' '),
119
+ position,
120
+ position ? pxAside(node.value, options.baseFontSize) : '',
111
121
  ),
112
122
  node,
113
123
  replacement: fixedValue,
@@ -116,7 +126,12 @@ const ruleFunction = (primary, secondaryOptions) => {
116
126
  });
117
127
  };
118
128
 
119
- const checkNode = (node, avoidTokens = []) => {
129
+ // A value the options forbid outright says so, with no fix (issue #274).
130
+ const reportForbidden = (node, message, position) => {
131
+ reportValueNode({ decl, message: message(node.value, options.note, position), node, result, ruleName });
132
+ };
133
+
134
+ const checkNode = (node, avoidTokens = [], position = null) => {
120
135
  const parsedLength = parseLengthToken(node.value);
121
136
  if (!parsedLength || parsedLength.number === 0) {
122
137
  return;
@@ -128,7 +143,7 @@ const ruleFunction = (primary, secondaryOptions) => {
128
143
 
129
144
  if (parsedLength.unit === '%') {
130
145
  if (!options.allowPercentages) {
131
- report(node, null, '%');
146
+ reportForbidden(node, messages.rejectedPercentage, position);
132
147
  }
133
148
  return;
134
149
  }
@@ -138,7 +153,7 @@ const ruleFunction = (primary, secondaryOptions) => {
138
153
  }
139
154
 
140
155
  if (!options.allowNegative && parsedLength.number < 0) {
141
- report(node, null, parsedLength.unit);
156
+ reportForbidden(node, messages.rejectedNegative, position);
142
157
  return;
143
158
  }
144
159
 
@@ -178,7 +193,7 @@ const ruleFunction = (primary, secondaryOptions) => {
178
193
  ? replacementFor(parsedLength, nearest.nearest, options, decl.prop, avoidTokens)
179
194
  : null;
180
195
 
181
- report(node, nearest, unit, fixedValue);
196
+ report(node, nearest, unit, fixedValue, position);
182
197
  changed = true;
183
198
  return;
184
199
  }
@@ -202,62 +217,52 @@ const ruleFunction = (primary, secondaryOptions) => {
202
217
  ? replacementFor(parsedLength, nearest.nearest, options, decl.prop, avoidTokens)
203
218
  : null;
204
219
 
205
- report(node, nearest, 'px', fixedValue);
220
+ report(node, nearest, 'px', fixedValue, position);
206
221
  changed = true;
207
222
  };
208
223
 
209
- if (prop === 'transform') {
210
- walkTransformTranslateNodes(parsed, (node, context) => {
211
- if (node.type === 'function') {
212
- const skipToken = skipTokenFunction(node, options.tokenFunctions, tokenRegex);
213
- if (skipToken) {
214
- return skipToken;
215
- }
216
-
217
- if (isMathFunction(node.value) && !options.enforceInsideMathFunctions) {
218
- return true;
219
- }
220
-
221
- return false;
224
+ // The bounds of clamp(), min() and max() are checked as in use-scale
225
+ // (issue #272).
226
+ const checkedBounds = new Set();
227
+ const visit = (node, context) => {
228
+ if (node.type === 'function') {
229
+ const skipToken = skipTokenFunction(node, options.tokenFunctions, tokenRegex);
230
+ if (skipToken) {
231
+ return skipToken;
222
232
  }
223
233
 
224
- if (node.type !== 'word') {
225
- return false;
234
+ if (options.checkMathBounds && isBoundFunction(node)) {
235
+ for (const bound of collectMathBounds(node)) {
236
+ if (!checkedBounds.has(bound.node)) {
237
+ checkedBounds.add(bound.node);
238
+ checkNode(bound.node, context.tokenFallbackOf, bound.position);
239
+ }
240
+ }
226
241
  }
227
242
 
228
- if (!shouldLintMathArgument(context, options)) {
229
- return false;
243
+ if (isMathFunction(node.value) && !options.enforceInsideMathFunctions) {
244
+ return true;
230
245
  }
231
246
 
232
- checkNode(node, context.tokenFallbackOf);
233
247
  return false;
234
- });
235
- } else {
236
- walkRootValueNodes(parsed, (node, context) => {
237
- if (node.type === 'function') {
238
- const skipToken = skipTokenFunction(node, options.tokenFunctions, tokenRegex);
239
- if (skipToken) {
240
- return skipToken;
241
- }
242
-
243
- if (isMathFunction(node.value) && !options.enforceInsideMathFunctions) {
244
- return true;
245
- }
248
+ }
246
249
 
247
- return false;
248
- }
250
+ if (node.type !== 'word' || checkedBounds.has(node)) {
251
+ return false;
252
+ }
249
253
 
250
- if (node.type !== 'word') {
251
- return false;
252
- }
254
+ if (!shouldLintMathArgument(context, options)) {
255
+ return false;
256
+ }
253
257
 
254
- if (!shouldLintMathArgument(context, options)) {
255
- return false;
256
- }
258
+ checkNode(node, context.tokenFallbackOf);
259
+ return false;
260
+ };
257
261
 
258
- checkNode(node, context.tokenFallbackOf);
259
- return false;
260
- });
262
+ if (prop === 'transform') {
263
+ walkTransformTranslateNodes(parsed, visit);
264
+ } else {
265
+ walkRootValueNodes(parsed, visit);
261
266
  }
262
267
 
263
268
  if (changed) {
@@ -81,8 +81,9 @@ function resolveTokenReplacement(tokenMap, raw, parsedLength, options) {
81
81
  if (options.unitStrategy === 'convert') {
82
82
  const px = toPx(parsedLength.number, parsedLength.unit, options.baseFontSize);
83
83
  if (px !== null) {
84
- lookups.push([`${px}px`, false]);
85
- if (negative) lookups.push([`${Math.abs(px)}px`, true]);
84
+ // Formatted, so 1.1rem at a 14px base is "15.4px", not "15.400000000000002px" (issue #275).
85
+ lookups.push([formatLength(px, 'px'), false]);
86
+ if (negative) lookups.push([formatLength(Math.abs(px), 'px'), true]);
86
87
  }
87
88
  }
88
89
 
@@ -289,60 +290,44 @@ const ruleFunction = (primary, secondaryOptions) => {
289
290
  return true;
290
291
  };
291
292
 
292
- if (prop === 'transform') {
293
- walkTransformTranslateNodes(parsed, (node, context) => {
294
- if (node.type === 'function') {
295
- const skipToken = skipTokenFunction(node, options.tokenFunctions, tokenRegex);
296
- if (skipToken) {
297
- return skipToken;
298
- }
293
+ // The bounds of clamp(), min() and max() are checked in translate
294
+ // functions as anywhere else (issue #272).
295
+ const checkedBounds = new Set();
296
+ const visit = (node, context) => {
297
+ if (node.type === 'function') {
298
+ const skipToken = skipTokenFunction(node, options.tokenFunctions, tokenRegex);
299
+ if (skipToken) {
300
+ return skipToken;
301
+ }
299
302
 
300
- if (isMathFunction(node.value) && !options.enforceInsideMathFunctions) {
301
- return true;
303
+ if (options.checkMathBounds && isBoundFunction(node)) {
304
+ for (const bound of collectMathBounds(node)) {
305
+ if (!checkedBounds.has(bound.node)) {
306
+ checkedBounds.add(bound.node);
307
+ checkWordNode(bound.node, context, bound.position);
308
+ }
302
309
  }
303
-
304
- return false;
305
310
  }
306
311
 
307
- if (node.type !== 'word') {
308
- return false;
312
+ if (isMathFunction(node.value) && !options.enforceInsideMathFunctions) {
313
+ return true;
309
314
  }
310
315
 
311
- checkWordNode(node, context);
312
316
  return false;
313
- });
314
- } else {
315
- const checkedBounds = new Set();
316
- walkRootValueNodes(parsed, (node, context) => {
317
- if (node.type === 'function') {
318
- const skipToken = skipTokenFunction(node, options.tokenFunctions, tokenRegex);
319
- if (skipToken) {
320
- return skipToken;
321
- }
322
-
323
- if (options.checkMathBounds && isBoundFunction(node)) {
324
- for (const bound of collectMathBounds(node)) {
325
- if (!checkedBounds.has(bound.node)) {
326
- checkedBounds.add(bound.node);
327
- checkWordNode(bound.node, context, bound.position);
328
- }
329
- }
330
- }
331
-
332
- if (isMathFunction(node.value) && !options.enforceInsideMathFunctions) {
333
- return true;
334
- }
317
+ }
335
318
 
336
- return false;
337
- }
319
+ if (node.type !== 'word' || checkedBounds.has(node)) {
320
+ return false;
321
+ }
338
322
 
339
- if (node.type !== 'word' || checkedBounds.has(node)) {
340
- return false;
341
- }
323
+ checkWordNode(node, context);
324
+ return false;
325
+ };
342
326
 
343
- checkWordNode(node, context);
344
- return false;
345
- });
327
+ if (prop === 'transform') {
328
+ walkTransformTranslateNodes(parsed, visit);
329
+ } else {
330
+ walkRootValueNodes(parsed, visit);
346
331
  }
347
332
 
348
333
  writeDeclarationValue(decl, source, parsed);
@@ -23,7 +23,7 @@ const {
23
23
  } = require('../../core/time');
24
24
 
25
25
  const { reportValueNode } = require('../report');
26
- const { validatePrimary } = require('../validate');
26
+ const { validatePrimary, validateSecondaryOptionShapes } = require('../validate');
27
27
  const { isNote, normalizeNote, withNote } = require('../../core/options');
28
28
 
29
29
  const ruleName = 'rhythmguard/use-motion-scale';
@@ -69,7 +69,20 @@ function buildOptions(rawOptions) {
69
69
  };
70
70
  }
71
71
 
72
+ // Stylelint checks list options per element, so a scalar or an empty list
73
+ // passed and the defaults were used without a word (issue #273).
74
+ const MOTION_SHAPES = Object.freeze({
75
+ durationScale: Object.freeze({ expectsArray: true, nonEmpty: true }),
76
+ durationUnits: Object.freeze({ expectsArray: true, nonEmpty: true }),
77
+ });
78
+
72
79
  function validateSecondaryOptions(result, secondaryOptions) {
80
+ const validEntries = validateEntries(result, secondaryOptions);
81
+ const validShapes = validateSecondaryOptionShapes(result, ruleName, secondaryOptions, MOTION_SHAPES);
82
+ return validEntries && validShapes;
83
+ }
84
+
85
+ function validateEntries(result, secondaryOptions) {
73
86
  return stylelint.utils.validateOptions(result, ruleName, {
74
87
  actual: secondaryOptions,
75
88
  optional: true,
@@ -9,6 +9,7 @@ const {
9
9
  nearestText,
10
10
  numbersEqual,
11
11
  parseLengthToken,
12
+ pxAside,
12
13
  pxOnScale,
13
14
  toPx,
14
15
  } = require('../../core/length');
@@ -52,17 +53,18 @@ const messages = stylelint.utils.ruleMessages(ruleName, {
52
53
  `Unknown scale preset "${presetName}". Available presets: ${presetNames.join(', ')}.`,
53
54
  rejected: (value, lower, upper, note = '', position = null, aside = '', group = null) =>
54
55
  `Unexpected off-scale ${position ? `${position} "${value}"${aside}` : `value "${value}"`}. Use ${group ? `${group} ` : ''}scale values (${nearestText(lower, upper)}).${note ? ` ${note}` : ''}`,
56
+ // A value the options forbid outright has no nearest scale value to name (issue #274).
57
+ rejectedNegative: (value, note = '', position = null) =>
58
+ `Unexpected ${position || 'value'} "${value}". Negative values are disabled for this rule.${note ? ` ${note}` : ''}`,
59
+ rejectedPercentage: (value, note = '', position = null) =>
60
+ `Unexpected ${position || 'value'} "${value}". Percentages are disabled for this rule.${note ? ` ${note}` : ''}`,
55
61
  });
56
62
 
57
- /** " (16.8px)" after a bound written in rem or em, so the reader sees what was compared. */
58
- function pxAside(value, baseFontSize) {
59
- const parsed = parseLengthToken(value);
60
- if (!parsed || !parsed.unit || parsed.unit === 'px' || parsed.unit === '%') {
61
- return '';
62
- }
63
- const px = toPx(Math.abs(parsed.number), parsed.unit, baseFontSize);
64
- return px === null ? '' : ` (${formatLength(parsed.number < 0 ? -px : px, 'px')})`;
65
- }
63
+ // Passed as `nearest` for a value that `allowNegative` or `allowPercentages` forbids.
64
+ const FORBIDDEN_NEGATIVE = Object.freeze({ forbidden: 'negative' });
65
+ const FORBIDDEN_PERCENTAGE = Object.freeze({ forbidden: 'percentage' });
66
+
67
+
66
68
 
67
69
  /** Decisions from .rhythmguardrc.json; an invalid section is reported once and ignored. */
68
70
  function readDecisions(options, { result, root }) {
@@ -109,7 +111,7 @@ function checkLengthValue({
109
111
  return false;
110
112
  }
111
113
 
112
- report(node.value, decl, node, null, null, '%');
114
+ report(node.value, decl, node, FORBIDDEN_PERCENTAGE);
113
115
  return false;
114
116
  }
115
117
 
@@ -118,7 +120,7 @@ function checkLengthValue({
118
120
  }
119
121
 
120
122
  if (!options.allowNegative && parsedLength.number < 0) {
121
- report(node.value, decl, node, null, null, parsedLength.unit || 'px');
123
+ report(node.value, decl, node, FORBIDDEN_NEGATIVE);
122
124
  return false;
123
125
  }
124
126
 
@@ -278,12 +280,19 @@ const ruleFunction = (primary, secondaryOptions) => {
278
280
  const getScaleStateForProperty = createPropertyScaleResolver(options);
279
281
 
280
282
  const report = (value, decl, node, nearest, fixedValue = null, nearestUnit = 'px', extraNote = '', position = null) => {
281
- const lower = nearest ? formatLength(nearest.lower, nearestUnit) : 'n/a';
282
- const upper = nearest ? formatLength(nearest.upper, nearestUnit) : 'n/a';
283
- const tokenNote = nearest ? tokenHoldsNote(fixedValue, formatLength(nearest.nearest, nearestUnit), String(value).trim().startsWith('-')) : '';
283
+ let message;
284
+ if (nearest.forbidden) {
285
+ const note = [fallbackNote, extraNote, options.note].filter(Boolean).join(' ');
286
+ message = nearest.forbidden === 'negative'
287
+ ? messages.rejectedNegative(value, note, position)
288
+ : messages.rejectedPercentage(value, note, position);
289
+ } else {
290
+ const tokenNote = tokenHoldsNote(fixedValue, formatLength(nearest.nearest, nearestUnit), String(value).trim().startsWith('-'));
291
+ message = messages.rejected(value, formatLength(nearest.lower, nearestUnit), formatLength(nearest.upper, nearestUnit), [fallbackNote, tokenNote, extraNote, options.note].filter(Boolean).join(' '), position, position ? pxAside(value, options.baseFontSize) : '', getScaleStateForProperty(decl.prop.toLowerCase()).group);
292
+ }
284
293
  reportValueNode({
285
294
  decl,
286
- 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),
295
+ message,
287
296
  node,
288
297
  replacement: fixedValue,
289
298
  result,
@@ -328,106 +337,70 @@ const ruleFunction = (primary, secondaryOptions) => {
328
337
  // Operands of a Sass expression that did not resolve are reported, never rewritten (issue #234).
329
338
  const sassOperands = sassOperandNodes(parsed, decl.value);
330
339
 
331
- if (prop === 'transform') {
332
- walkTransformTranslateNodes(parsed, (node, context) => {
333
-
334
- if (node.type === 'function') {
335
- const skipToken = skipTokenFunction(node, options.tokenFunctions, tokenRegex);
336
- if (skipToken) {
337
- return skipToken;
338
- }
339
-
340
- if (isMathFunction(node.value) && !options.enforceInsideMathFunctions) {
341
- return true;
342
- }
343
-
344
- return false;
340
+ // The bounds of clamp(), min() and max() are checked in translate
341
+ // functions as anywhere else (issue #272).
342
+ const checkedBounds = new Set();
343
+ const visit = (node, context) => {
344
+ if (node.type === 'function') {
345
+ const skipToken = skipTokenFunction(node, options.tokenFunctions, tokenRegex);
346
+ if (skipToken) {
347
+ return skipToken;
345
348
  }
346
349
 
347
- if (node.type !== 'word') {
348
- return false;
349
- }
350
-
351
- if (isKeyword(node.value, options.ignoreValues)) {
352
- return false;
353
- }
354
-
355
- if (!shouldLintMathArgument(context, options)) {
356
- return false;
357
- }
358
-
359
- checkLengthValue({
360
- avoidTokens: context.tokenFallbackOf,
361
- decl,
362
- fixable: !sassOperands.has(node),
363
- node,
364
- options,
365
- report,
366
- scaleByUnit,
367
- scalePx,
368
- });
369
-
370
- return false;
371
- });
372
- } else {
373
- const checkedBounds = new Set();
374
- walkRootValueNodes(parsed, (node, context) => {
375
-
376
- if (node.type === 'function') {
377
- const skipToken = skipTokenFunction(node, options.tokenFunctions, tokenRegex);
378
- if (skipToken) {
379
- return skipToken;
380
- }
381
-
382
- if (options.checkMathBounds && isBoundFunction(node)) {
383
- for (const bound of collectMathBounds(node)) {
384
- if (checkedBounds.has(bound.node)) {
385
- continue;
386
- }
387
- checkedBounds.add(bound.node);
388
- if (isKeyword(bound.node.value, options.ignoreValues)) {
389
- continue;
390
- }
391
- const boundReport = (value, d, n, nearest, fixed, unit, extra) => report(value, d, n, nearest, fixed, unit, extra, bound.position);
392
- checkLengthValue({ avoidTokens: context.tokenFallbackOf, decl, fixable: !sassOperands.has(bound.node), node: bound.node, options, report: boundReport, scaleByUnit, scalePx });
350
+ if (options.checkMathBounds && isBoundFunction(node)) {
351
+ for (const bound of collectMathBounds(node)) {
352
+ if (checkedBounds.has(bound.node)) {
353
+ continue;
393
354
  }
355
+ checkedBounds.add(bound.node);
356
+ if (isKeyword(bound.node.value, options.ignoreValues)) {
357
+ continue;
358
+ }
359
+ const boundReport = (value, d, n, nearest, fixed, unit, extra) => report(value, d, n, nearest, fixed, unit, extra, bound.position);
360
+ checkLengthValue({ avoidTokens: context.tokenFallbackOf, decl, fixable: !sassOperands.has(bound.node), node: bound.node, options, report: boundReport, scaleByUnit, scalePx });
394
361
  }
395
-
396
- if (
397
- isMathFunction(node.value) &&
398
- !options.enforceInsideMathFunctions
399
- ) {
400
- return true;
401
- }
402
-
403
- return false;
404
362
  }
405
363
 
406
- if (node.type !== 'word' || checkedBounds.has(node)) {
407
- return false;
364
+ if (
365
+ isMathFunction(node.value) &&
366
+ !options.enforceInsideMathFunctions
367
+ ) {
368
+ return true;
408
369
  }
409
370
 
410
- if (isKeyword(node.value, options.ignoreValues)) {
411
- return false;
412
- }
371
+ return false;
372
+ }
413
373
 
414
- if (!shouldLintMathArgument(context, options)) {
415
- return false;
416
- }
374
+ if (node.type !== 'word' || checkedBounds.has(node)) {
375
+ return false;
376
+ }
417
377
 
418
- checkLengthValue({
419
- avoidTokens: context.tokenFallbackOf,
420
- decl,
421
- fixable: !sassOperands.has(node),
422
- node,
423
- options,
424
- report,
425
- scaleByUnit,
426
- scalePx,
427
- });
378
+ if (isKeyword(node.value, options.ignoreValues)) {
379
+ return false;
380
+ }
428
381
 
382
+ if (!shouldLintMathArgument(context, options)) {
429
383
  return false;
384
+ }
385
+
386
+ checkLengthValue({
387
+ avoidTokens: context.tokenFallbackOf,
388
+ decl,
389
+ fixable: !sassOperands.has(node),
390
+ node,
391
+ options,
392
+ report,
393
+ scaleByUnit,
394
+ scalePx,
430
395
  });
396
+
397
+ return false;
398
+ };
399
+
400
+ if (prop === 'transform') {
401
+ walkTransformTranslateNodes(parsed, visit);
402
+ } else {
403
+ walkRootValueNodes(parsed, visit);
431
404
  }
432
405
 
433
406
  writeDeclarationValue(decl, source, parsed);
@@ -33,10 +33,18 @@ function validateSecondaryOptionShapes(result, ruleName, secondaryOptions, schem
33
33
  continue;
34
34
  }
35
35
 
36
- const literalAllowed = Array.isArray(descriptor.allowLiterals)
37
- && descriptor.allowLiterals.includes(optionValue);
38
-
39
- if (descriptor.expectsArray && !Array.isArray(optionValue) && !literalAllowed) {
36
+ const literals = Array.isArray(descriptor.allowLiterals) ? descriptor.allowLiterals : [];
37
+ const literalAllowed = literals.includes(optionValue);
38
+
39
+ // A literal such as "auto" stands for the whole option, so `["auto"]` is
40
+ // invalid, and an empty list where a scale is expected checks nothing
41
+ // (issue #273).
42
+ const invalidArray = Array.isArray(optionValue) && (
43
+ (descriptor.nonEmpty && optionValue.length === 0)
44
+ || optionValue.some((entry) => literals.includes(entry))
45
+ );
46
+
47
+ if ((descriptor.expectsArray && !Array.isArray(optionValue) && !literalAllowed) || invalidArray) {
40
48
  valid = false;
41
49
  result.warn(
42
50
  `Invalid value ${stringifyOptionValue(optionValue)} for option "${optionName}" of rule "${ruleName}"`,
@@ -126,6 +134,7 @@ function validatePrimary(result, ruleName, primary) {
126
134
 
127
135
  module.exports = {
128
136
  validatePrimary,
137
+ validateSecondaryOptionShapes,
129
138
  validateNoOffscaleTransformSecondaryOptions,
130
139
  validatePreferTokenSecondaryOptions,
131
140
  validateUseScaleSecondaryOptions,
@@ -9,6 +9,7 @@ import type {
9
9
  AuditReport,
10
10
  RhythmguardRuleOptions,
11
11
  RhythmguardStylelintConfig,
12
+ ScalePresetMetadata,
12
13
  } from 'stylelint-plugin-rhythmguard';
13
14
  import { createAuditReport, toAuditContractReport } from 'stylelint-plugin-rhythmguard/audit';
14
15
  import eslintPlugin from 'stylelint-plugin-rhythmguard/eslint';
@@ -48,6 +49,9 @@ const name: string = useScaleName;
48
49
 
49
50
  const presetNames: string[] = listScalePresetNames();
50
51
  const preset = getScalePreset('rhythmic-4');
52
+ // Unknown names return null, as the runtime does (#281).
53
+ const missingPreset: readonly number[] | null = getScalePreset('nope');
54
+ const missingMetadata: ScalePresetMetadata | null = presets.getCommunityScaleMetadata('nope');
51
55
  const presetsAgain: string[] = presets.listScalePresetNames();
52
56
 
53
57
  const eslintRule = eslint.rules['tailwind-class-use-scale'];
@@ -55,8 +59,21 @@ const eslintRuleAgain = eslintPlugin.rules['tailwind-class-use-motion-scale'];
55
59
 
56
60
  async function runAudit(): Promise<AuditContractReport> {
57
61
  const report: AuditReport = await createAuditReport({ dir: './src', noConfig: true });
58
- const viaPlugin: AuditReport = await audit.createAuditReport({ dir: './src' });
62
+ const viaPlugin: AuditReport = await audit.createAuditReport({ dir: './src', scale: undefined });
59
63
  void viaPlugin;
64
+ // The fields the runtime report has (#284).
65
+ const cleanliness: number = report.summary.scaleCleanliness + report.scaleCleanliness;
66
+ const counts: number = report.summary.totalFindings + report.summary.filesWithIssues + report.filesWithIssues;
67
+ const configFile: string | undefined = report.config?.file;
68
+ const firstText: string | undefined = report.findings.css[0]?.text;
69
+ const resolved: number | undefined = report.baseline?.resolvedFindingsCount;
70
+ const contractConfig: { file: string } | null = toAuditContractReport(report).command.config;
71
+ void cleanliness;
72
+ void counts;
73
+ void configFile;
74
+ void firstText;
75
+ void resolved;
76
+ void contractConfig;
60
77
  return toAuditContractReport(report);
61
78
  }
62
79
 
@@ -68,6 +85,8 @@ void docUrl;
68
85
  void name;
69
86
  void presetNames;
70
87
  void preset;
88
+ void missingPreset;
89
+ void missingMetadata;
71
90
  void presetsAgain;
72
91
  void eslintRule;
73
92
  void eslintRuleAgain;
@@ -31,7 +31,15 @@ const flatConfig: Linter.Config = {
31
31
  rules: rhythmguard.configs.recommended.rules,
32
32
  };
33
33
 
34
+ // configs.recommended registers the plugin, so it stands alone in a config array (#279).
35
+ const standalone = defineConfig([rhythmguard.configs.recommended]);
36
+ const recommendedAsConfig: Linter.Config = companion.configs.recommended;
37
+ const spreadRecommended: Linter.Config = { ...rhythmguard.configs.recommended, files: ['**/*.jsx'] };
38
+
34
39
  void config;
40
+ void standalone;
41
+ void recommendedAsConfig;
42
+ void spreadRecommended;
35
43
  void asPlugin;
36
44
  void companionAsPlugin;
37
45
  void flatConfig;