stylelint-plugin-rhythmguard 3.10.2 → 3.12.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 (44) hide show
  1. package/CHANGELOG.md +77 -0
  2. package/README.md +5 -1
  3. package/package.json +1 -1
  4. package/src/audit/args.js +22 -2
  5. package/src/audit/baseline.js +184 -27
  6. package/src/audit/codemod.js +39 -14
  7. package/src/audit/config.js +25 -31
  8. package/src/audit/contract.js +96 -10
  9. package/src/audit/render-github.js +4 -0
  10. package/src/audit/render-html.js +7 -0
  11. package/src/audit/render-markdown.js +27 -0
  12. package/src/audit/render-text.js +23 -1
  13. package/src/audit/report.js +60 -17
  14. package/src/audit/scan/files.js +154 -27
  15. package/src/audit/scan/stylesheets.js +87 -34
  16. package/src/audit/scan/templates.js +22 -7
  17. package/src/audit/token-chains.js +18 -7
  18. package/src/cli/audit.js +7 -3
  19. package/src/cli/doctor.js +48 -7
  20. package/src/cli/fix.js +56 -15
  21. package/src/cli/init.js +136 -23
  22. package/src/cli/quickstart.js +34 -8
  23. package/src/core/fs-cache.js +6 -0
  24. package/src/core/length.js +15 -0
  25. package/src/core/options.js +8 -1
  26. package/src/core/scale-inference.js +99 -68
  27. package/src/core/tailwind-class-analysis.js +26 -16
  28. package/src/core/tailwind-motion-analysis.js +12 -3
  29. package/src/core/token-index.js +76 -24
  30. package/src/core/token-map.js +23 -10
  31. package/src/core/token-source-entries.js +64 -0
  32. package/src/core/token-sources.js +361 -69
  33. package/src/core/value-nodes.js +280 -54
  34. package/src/eslint/literal-spans.js +319 -0
  35. package/src/eslint/rules/tailwind-class-use-motion-scale.js +9 -32
  36. package/src/eslint/rules/tailwind-class-use-scale.js +45 -40
  37. package/src/rules/no-offscale-transform/index.js +35 -14
  38. package/src/rules/prefer-token/index.js +72 -48
  39. package/src/rules/report.js +3 -1
  40. package/src/rules/use-motion-scale/index.js +68 -5
  41. package/src/rules/use-scale/index.js +54 -39
  42. package/types/__checks__/eslint-config.ts +37 -0
  43. package/types/audit.d.ts +10 -2
  44. package/types/shared.d.ts +21 -3
@@ -7,6 +7,7 @@ const {
7
7
  isHairlineLength,
8
8
  numbersEqual,
9
9
  parseLengthToken,
10
+ pxOnScale,
10
11
  toPx,
11
12
  } = require('../../core/length');
12
13
  const {
@@ -15,14 +16,17 @@ const {
15
16
  } = require('../../core/options');
16
17
  const {
17
18
  collectMathBounds,
19
+ declarationValueSource,
18
20
  isBoundFunction,
19
21
  isKeyword,
20
22
  isMathFunction,
21
- isTokenFunction,
22
23
  propertyMatches,
24
+ sassOperandNodes,
23
25
  shouldLintMathArgument,
26
+ skipTokenFunction,
24
27
  walkRootValueNodes,
25
28
  walkTransformTranslateNodes,
29
+ writeDeclarationValue,
26
30
  } = require('../../core/value-nodes');
27
31
  const { buildEffectiveTokenMap } = require('../../core/token-map');
28
32
 
@@ -36,9 +40,9 @@ const { validatePrimary, validatePreferTokenSecondaryOptions } = require('../val
36
40
 
37
41
  const {
38
42
  negateReplacement,
43
+ signedTokensForLength,
39
44
  tokenFitsProperty,
40
45
  tokenIndexFromDefinitions,
41
- tokensForLength,
42
46
  } = require('../../core/token-index');
43
47
 
44
48
  const ruleName = 'rhythmguard/prefer-token';
@@ -61,30 +65,31 @@ function listTokens(tokens) {
61
65
  : references.join('');
62
66
  }
63
67
 
64
- function applyNegativeToken(replacement, parsedLength) {
65
- return !replacement || parsedLength.number >= 0 ? replacement : negateReplacement(replacement);
66
- }
67
-
68
- /** The token for a raw length, and the text the fix writes (negated when the literal is). */
68
+ /**
69
+ * The token for a raw length, and the text the fix writes. A key with the
70
+ * literal's sign holds exactly that value, so its token is written as is; a
71
+ * negative literal that only matches the positive key gets the token negated.
72
+ * Keys are signed, so a positive literal never finds a negative token (issue
73
+ * #221).
74
+ */
69
75
  function resolveTokenReplacement(tokenMap, raw, parsedLength, options) {
70
- const found = (token) => ({ replacement: applyNegativeToken(token, parsedLength), token });
71
-
72
- if (Object.prototype.hasOwnProperty.call(tokenMap, raw)) {
73
- return found(tokenMap[raw]);
74
- }
75
-
76
- const absoluteRaw = formatLength(Math.abs(parsedLength.number), parsedLength.unit || 'px');
77
- if (Object.prototype.hasOwnProperty.call(tokenMap, absoluteRaw)) {
78
- return found(tokenMap[absoluteRaw]);
76
+ const unit = parsedLength.unit || 'px';
77
+ const negative = parsedLength.number < 0;
78
+ // [key, negate]: the literal as written, then normalized, then in px.
79
+ const lookups = [[raw, false], [formatLength(parsedLength.number, unit), false]];
80
+ if (negative) lookups.push([formatLength(Math.abs(parsedLength.number), unit), true]);
81
+ if (options.unitStrategy === 'convert') {
82
+ const px = toPx(parsedLength.number, parsedLength.unit, options.baseFontSize);
83
+ if (px !== null) {
84
+ lookups.push([`${px}px`, false]);
85
+ if (negative) lookups.push([`${Math.abs(px)}px`, true]);
86
+ }
79
87
  }
80
88
 
81
- if (options.unitStrategy === 'convert') {
82
- const absPx = toPx(Math.abs(parsedLength.number), parsedLength.unit, options.baseFontSize);
83
- if (absPx !== null) {
84
- const pxKey = `${absPx}px`;
85
- if (Object.prototype.hasOwnProperty.call(tokenMap, pxKey)) {
86
- return found(tokenMap[pxKey]);
87
- }
89
+ for (const [key, negate] of lookups) {
90
+ if (Object.prototype.hasOwnProperty.call(tokenMap, key)) {
91
+ const token = tokenMap[key];
92
+ return { replacement: negate ? negateReplacement(token) : token, token };
88
93
  }
89
94
  }
90
95
 
@@ -98,15 +103,16 @@ function resolveTokenReplacement(tokenMap, raw, parsedLength, options) {
98
103
  * token in another unit, or several tokens, are named but never written.
99
104
  */
100
105
  function resolveFromIndex(tokenIndex, parsedLength, prop, options) {
101
- const px = toPx(Math.abs(parsedLength.number), parsedLength.unit || 'px', options.baseFontSize);
106
+ const px = toPx(parsedLength.number, parsedLength.unit || 'px', options.baseFontSize);
102
107
  if (px === null || tokenIndex.length === 0) {
103
108
  return { candidates: [] };
104
109
  }
105
110
  const unit = parsedLength.unit || 'px';
106
- const sameUnit = tokensForLength(tokenIndex, px, unit, { prop });
107
- const candidates = sameUnit.length > 0 || options.unitStrategy !== 'convert'
111
+ const sameUnit = signedTokensForLength(tokenIndex, px, unit, { prop });
112
+ const found = sameUnit.tokens.length > 0 || options.unitStrategy !== 'convert'
108
113
  ? sameUnit
109
- : tokensForLength(tokenIndex, px, unit, { anyUnit: true, prop });
114
+ : signedTokensForLength(tokenIndex, px, unit, { anyUnit: true, prop });
115
+ const candidates = found.tokens;
110
116
  if (candidates.length !== 1) {
111
117
  return { candidates };
112
118
  }
@@ -114,13 +120,19 @@ function resolveFromIndex(tokenIndex, parsedLength, prop, options) {
114
120
  const entry = tokenIndex.find((item) => item.token === candidates[0]);
115
121
  return {
116
122
  candidates,
117
- fixable: sameUnit.length === 1,
123
+ fixable: sameUnit.tokens.length === 1,
118
124
  origin: entry ? entry.file : null,
119
- replacement: applyNegativeToken(token, parsedLength),
125
+ replacement: found.negate ? negateReplacement(token) : token,
120
126
  token,
121
127
  };
122
128
  }
123
129
 
130
+ /** The custom property a replacement such as `var(--space-2)` or `calc(-1 * var(--space-2))` writes. */
131
+ function replacementToken(replacement) {
132
+ const match = typeof replacement === 'string' ? replacement.match(/var\(\s*(--[^\s,)]+)/) : null;
133
+ return match ? match[1] : null;
134
+ }
135
+
124
136
  const ruleFunction = (primary, secondaryOptions) => {
125
137
  return (root, result) => {
126
138
  const valid = validatePrimary(result, ruleName, primary);
@@ -143,7 +155,7 @@ const ruleFunction = (primary, secondaryOptions) => {
143
155
 
144
156
  withResolvedScale(options, root);
145
157
 
146
- const tokenRegex = createTokenRegex(options.tokenPattern, result, ruleName);
158
+ const tokenRegex = createTokenRegex(options.tokenPattern, { result, root, ruleName });
147
159
  const origins = {};
148
160
  const tokenMap = buildEffectiveTokenMap({
149
161
  options,
@@ -175,24 +187,36 @@ const ruleFunction = (primary, secondaryOptions) => {
175
187
  return;
176
188
  }
177
189
 
178
- const parsed = valueParser(decl.value);
190
+ const source = declarationValueSource(decl);
191
+ const parsed = valueParser(source.parseText);
179
192
  const { scaleByUnit, scalePx } = getScaleStateForProperty(prop);
180
- let changed = false;
181
-
182
- const reportNode = (node, parsedLength, position = null) => {
193
+ // Operands of a Sass expression are reported, never rewritten (issue #234).
194
+ const sassOperands = sassOperandNodes(parsed, decl.value);
195
+
196
+ // `avoidTokens`: the var() names a fallback literal falls back from. A
197
+ // fallback held by one of them restates that token and passes; it is
198
+ // never replaced with the token it is the fallback of (issue #240).
199
+ const reportNode = (node, parsedLength, position = null, avoidTokens = []) => {
200
+ const fixable = !sassOperands.has(node);
183
201
  const mapped = resolveTokenReplacement(tokenMap, node.value, parsedLength, options);
184
202
  if (mapped && tokenFitsProperty(mapped.token, prop)) {
203
+ if (avoidTokens.includes(replacementToken(mapped.token))) {
204
+ return;
205
+ }
185
206
  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 });
207
+ reportValueNode({ decl, message: messages.rejected(node.value, mapped.replacement, origin, options.note, [], position), node, replacement: fixable ? mapped.replacement : null, result, ruleName });
187
208
  return;
188
209
  }
189
210
  const indexed = resolveFromIndex(tokenIndex, parsedLength, prop, options);
211
+ if (indexed.candidates.some((candidate) => avoidTokens.includes(candidate))) {
212
+ return;
213
+ }
190
214
  const named = indexed.replacement || null;
191
215
  reportValueNode({
192
216
  decl,
193
217
  message: messages.rejected(node.value, named, indexed.origin || null, options.note, indexed.candidates, position),
194
218
  node,
195
- replacement: indexed.fixable ? named : null,
219
+ replacement: indexed.fixable && fixable ? named : null,
196
220
  result,
197
221
  ruleName,
198
222
  });
@@ -253,7 +277,7 @@ const ruleFunction = (primary, secondaryOptions) => {
253
277
  } else {
254
278
  const absPx = toPx(Math.abs(parsedLength.number), parsedLength.unit, options.baseFontSize);
255
279
  if (absPx !== null) {
256
- const onScale = scalePx.some((entry) => numbersEqual(entry, absPx));
280
+ const onScale = pxOnScale(scalePx, absPx, parsedLength.unit, options.baseFontSize);
257
281
  if (onScale) {
258
282
  return false;
259
283
  }
@@ -261,15 +285,16 @@ const ruleFunction = (primary, secondaryOptions) => {
261
285
  }
262
286
  }
263
287
 
264
- reportNode(node, parsedLength, position);
288
+ reportNode(node, parsedLength, position, (context && context.tokenFallbackOf) || []);
265
289
  return true;
266
290
  };
267
291
 
268
292
  if (prop === 'transform') {
269
293
  walkTransformTranslateNodes(parsed, (node, context) => {
270
294
  if (node.type === 'function') {
271
- if (isTokenFunction(node, options.tokenFunctions, tokenRegex)) {
272
- return true;
295
+ const skipToken = skipTokenFunction(node, options.tokenFunctions, tokenRegex);
296
+ if (skipToken) {
297
+ return skipToken;
273
298
  }
274
299
 
275
300
  if (isMathFunction(node.value) && !options.enforceInsideMathFunctions) {
@@ -283,22 +308,23 @@ const ruleFunction = (primary, secondaryOptions) => {
283
308
  return false;
284
309
  }
285
310
 
286
- changed = checkWordNode(node, context) || changed;
311
+ checkWordNode(node, context);
287
312
  return false;
288
313
  });
289
314
  } else {
290
315
  const checkedBounds = new Set();
291
316
  walkRootValueNodes(parsed, (node, context) => {
292
317
  if (node.type === 'function') {
293
- if (isTokenFunction(node, options.tokenFunctions, tokenRegex)) {
294
- return true;
318
+ const skipToken = skipTokenFunction(node, options.tokenFunctions, tokenRegex);
319
+ if (skipToken) {
320
+ return skipToken;
295
321
  }
296
322
 
297
323
  if (options.checkMathBounds && isBoundFunction(node)) {
298
324
  for (const bound of collectMathBounds(node)) {
299
325
  if (!checkedBounds.has(bound.node)) {
300
326
  checkedBounds.add(bound.node);
301
- changed = checkWordNode(bound.node, null, bound.position) || changed;
327
+ checkWordNode(bound.node, context, bound.position);
302
328
  }
303
329
  }
304
330
  }
@@ -314,14 +340,12 @@ const ruleFunction = (primary, secondaryOptions) => {
314
340
  return false;
315
341
  }
316
342
 
317
- changed = checkWordNode(node, context) || changed;
343
+ checkWordNode(node, context);
318
344
  return false;
319
345
  });
320
346
  }
321
347
 
322
- if (changed) {
323
- decl.value = parsed.toString();
324
- }
348
+ writeDeclarationValue(decl, source, parsed);
325
349
  });
326
350
  };
327
351
  };
@@ -13,12 +13,14 @@ const { declarationValueIndex } = require('../core/value-nodes');
13
13
  * against the root and replaced with the historical default so the file still
14
14
  * lints instead of throwing inside Stylelint.
15
15
  */
16
- function createTokenRegex(tokenPattern, result, ruleName) {
16
+ function createTokenRegex(tokenPattern, { result, root, ruleName }) {
17
17
  try {
18
18
  return new RegExp(tokenPattern);
19
19
  } catch {
20
+ // Stylelint throws on a report without a node, so the root carries it (issue #222).
20
21
  stylelint.utils.report({
21
22
  message: `Invalid tokenPattern regex: ${tokenPattern}`,
23
+ node: root,
22
24
  result,
23
25
  ruleName,
24
26
  });
@@ -8,7 +8,10 @@ const {
8
8
  numbersEqual,
9
9
  } = require('../../core/length');
10
10
  const {
11
+ declarationValueSource,
12
+ isMathFunction,
11
13
  walkRootValueNodes,
14
+ writeDeclarationValue,
12
15
  } = require('../../core/value-nodes');
13
16
  const {
14
17
  formatTime,
@@ -39,6 +42,7 @@ const EASING_PROPERTIES = new Set([
39
42
  'animation',
40
43
  ]);
41
44
  const EASING_FUNCTIONS = new Set(['cubic-bezier', 'linear', 'steps']);
45
+ const SHORTHAND_PROPERTIES = new Set(['transition', 'animation']);
42
46
 
43
47
  const messages = stylelint.utils.ruleMessages(ruleName, {
44
48
  invalidDuration: (value, note = '') =>
@@ -96,13 +100,62 @@ function getFixedNodeValue(parsedTime, nearestMs, options) {
96
100
  return null;
97
101
  }
98
102
 
99
- return formatTime(converted, parsedTime.unit);
103
+ return formatTime(parsedTime.number < 0 ? -converted : converted, parsedTime.unit);
100
104
  }
101
105
 
102
106
  function functionToString(node) {
103
107
  return valueParser.stringify(node);
104
108
  }
105
109
 
110
+ /**
111
+ * The time nodes of a transition or animation shorthand that are its
112
+ * durations: the first time of each comma-separated layer (#236). The second
113
+ * is the delay, which may be negative. A function before the first time, such
114
+ * as var() or calc(), may itself be the duration, so that layer has no time
115
+ * known to be a duration and none is returned for it.
116
+ */
117
+ function shorthandDurationNodes(parsed) {
118
+ const durations = new Set();
119
+ let settled = false;
120
+
121
+ for (const node of parsed.nodes) {
122
+ if (node.type === 'div' && node.value === ',') {
123
+ settled = false;
124
+ continue;
125
+ }
126
+
127
+ if (settled) {
128
+ continue;
129
+ }
130
+
131
+ if (node.type === 'function') {
132
+ settled = !EASING_FUNCTIONS.has(node.value.toLowerCase());
133
+ continue;
134
+ }
135
+
136
+ if (node.type === 'word' && parseTimeToken(node.value)) {
137
+ durations.add(node);
138
+ settled = true;
139
+ }
140
+ }
141
+
142
+ return durations;
143
+ }
144
+
145
+ /** Whether a negative time in this place is an error: true for durations, false for delays. */
146
+ function createDurationRole(prop, parsed) {
147
+ if (prop.endsWith('-duration')) {
148
+ return () => true;
149
+ }
150
+
151
+ if (!SHORTHAND_PROPERTIES.has(prop)) {
152
+ return () => false;
153
+ }
154
+
155
+ const durations = shorthandDurationNodes(parsed);
156
+ return (node) => durations.has(node);
157
+ }
158
+
106
159
  const ruleFunction = (primary, secondaryOptions) => {
107
160
  return (root, result) => {
108
161
  if (!validatePrimary(result, ruleName, primary) || !validateSecondaryOptions(result, secondaryOptions)) {
@@ -117,7 +170,9 @@ const ruleFunction = (primary, secondaryOptions) => {
117
170
  return;
118
171
  }
119
172
 
120
- const parsed = valueParser(decl.value);
173
+ const source = declarationValueSource(decl);
174
+ const parsed = valueParser(source.parseText);
175
+ const isDuration = createDurationRole(prop, parsed);
121
176
  let changed = false;
122
177
 
123
178
  const reportDuration = (node, nearest, fixedValue = null) => {
@@ -161,6 +216,12 @@ const ruleFunction = (primary, secondaryOptions) => {
161
216
  walkRootValueNodes(parsed, (node) => {
162
217
  if (node.type === 'function') {
163
218
  const functionName = node.value.toLowerCase();
219
+ // A time inside calc(), clamp(), min() or max() is a term of the
220
+ // expression, not a duration on its own (#232).
221
+ if (isMathFunction(functionName)) {
222
+ return true;
223
+ }
224
+
164
225
  if (!EASING_PROPERTIES.has(prop) || !EASING_FUNCTIONS.has(functionName)) {
165
226
  return false;
166
227
  }
@@ -180,7 +241,9 @@ const ruleFunction = (primary, secondaryOptions) => {
180
241
  return false;
181
242
  }
182
243
 
183
- if (parsedTime.number < 0) {
244
+ // A negative duration is invalid; a negative delay starts the motion
245
+ // partway through, so its size is checked like any other (#236).
246
+ if (parsedTime.number < 0 && isDuration(node)) {
184
247
  reportDuration(node, null);
185
248
  return false;
186
249
  }
@@ -189,7 +252,7 @@ const ruleFunction = (primary, secondaryOptions) => {
189
252
  return false;
190
253
  }
191
254
 
192
- const msValue = toMs(parsedTime.number, parsedTime.unit);
255
+ const msValue = toMs(Math.abs(parsedTime.number), parsedTime.unit);
193
256
  if (msValue === null) {
194
257
  return false;
195
258
  }
@@ -214,7 +277,7 @@ const ruleFunction = (primary, secondaryOptions) => {
214
277
  });
215
278
 
216
279
  if (changed) {
217
- decl.value = parsed.toString();
280
+ writeDeclarationValue(decl, source, parsed);
218
281
  }
219
282
  });
220
283
  };
@@ -9,6 +9,7 @@ const {
9
9
  nearestText,
10
10
  numbersEqual,
11
11
  parseLengthToken,
12
+ pxOnScale,
12
13
  toPx,
13
14
  } = require('../../core/length');
14
15
  const {
@@ -16,15 +17,20 @@ const {
16
17
  createPropertyScaleResolver,
17
18
  } = require('../../core/options');
18
19
  const {
20
+ blankBlockComments,
19
21
  collectMathBounds,
22
+ declarationValueSource,
20
23
  isBoundFunction,
21
24
  isKeyword,
22
25
  isMathFunction,
23
- isTokenFunction,
24
26
  propertyMatches,
27
+ sassOperandNodes,
25
28
  shouldLintMathArgument,
29
+ skipTokenFunction,
26
30
  walkRootValueNodes,
27
31
  walkTransformTranslateNodes,
32
+ writeDeclarationValue,
33
+ writeValueEdits,
28
34
  } = require('../../core/value-nodes');
29
35
 
30
36
  const {
@@ -72,6 +78,7 @@ function readDecisions(options, { result, root }) {
72
78
  }
73
79
 
74
80
  function checkLengthValue({
81
+ avoidTokens = [],
75
82
  decl,
76
83
  evaluated = null,
77
84
  fixable = true,
@@ -149,7 +156,7 @@ function checkLengthValue({
149
156
  }
150
157
 
151
158
  const fixedValue = options.fixToScale && fixable
152
- ? replacementFor(parsedLength, nearest.nearest, options, decl.prop)
159
+ ? replacementFor(parsedLength, nearest.nearest, options, decl.prop, avoidTokens)
153
160
  : null;
154
161
 
155
162
  report(node.value, decl, node, nearest, fixedValue, unit, evaluated ? evaluatedNote(evaluated, options) : '');
@@ -162,7 +169,7 @@ function checkLengthValue({
162
169
  return false;
163
170
  }
164
171
 
165
- const isOnScale = scalePx.some((scaleValue) => numbersEqual(scaleValue, pxValue));
172
+ const isOnScale = pxOnScale(scalePx, pxValue, parsedLength.unit, options.baseFontSize);
166
173
  if (isOnScale) {
167
174
  return false;
168
175
  }
@@ -173,7 +180,7 @@ function checkLengthValue({
173
180
  }
174
181
 
175
182
  const fixedValue = options.fixToScale && fixable
176
- ? replacementFor(parsedLength, nearest.nearest, options, decl.prop)
183
+ ? replacementFor(parsedLength, nearest.nearest, options, decl.prop, avoidTokens)
177
184
  : null;
178
185
 
179
186
  report(node.value, decl, node, nearest, fixedValue, 'px', evaluated ? evaluatedNote(evaluated, options) : '');
@@ -193,14 +200,17 @@ function evaluatedNote(evaluated, options) {
193
200
  * alone, as they always were.
194
201
  */
195
202
  function checkSassValue({ decl, options, report, resolveVariable, scaleByUnit, scalePx }) {
196
- const terms = evaluateScssValueList(decl.value, resolveVariable, new Set());
203
+ // Offsets are offsets into the value as written, comments included (issue #233).
204
+ const source = declarationValueSource(decl);
205
+ const terms = evaluateScssValueList(blankBlockComments(source.parseText), resolveVariable, new Set());
197
206
  if (!terms) {
198
207
  // Not fully evaluable (an unknown variable, interpolation, a keyword): the
199
208
  // literal walk below still checks the plain lengths in the value.
200
209
  return false;
201
210
  }
202
- // Right to left, so a fix spliced into an earlier term never shifts the spans
203
- // of the terms still to be checked. Stylelint orders the warnings by position.
211
+ // Fixes are collected as spans of the written value and written once at the
212
+ // end. Stylelint orders the warnings by position.
213
+ const edits = [];
204
214
  for (const term of [...terms].reverse()) {
205
215
  if (!term.unit || term.number === 0) {
206
216
  continue;
@@ -208,9 +218,9 @@ function checkSassValue({ decl, options, report, resolveVariable, scaleByUnit, s
208
218
  const node = {
209
219
  sourceIndex: term.start,
210
220
  type: 'word',
211
- get value() { return term.text; },
221
+ get value() { return source.text.slice(term.start, term.end); },
212
222
  // The fix writes into the declaration at the term's span.
213
- set value(replacement) { decl.value = `${decl.value.slice(0, term.start)}${replacement}${decl.value.slice(term.end)}`; },
223
+ set value(replacement) { edits.push({ end: term.end, start: term.start, text: replacement }); },
214
224
  };
215
225
  checkLengthValue({
216
226
  decl,
@@ -224,6 +234,7 @@ function checkSassValue({ decl, options, report, resolveVariable, scaleByUnit, s
224
234
  scalePx,
225
235
  });
226
236
  }
237
+ writeValueEdits(decl, source, edits);
227
238
  return true;
228
239
  }
229
240
 
@@ -249,7 +260,7 @@ const ruleFunction = (primary, secondaryOptions) => {
249
260
  options.decisions = readDecisions(options, { result, root });
250
261
  withResolvedScale(options, root);
251
262
 
252
- const tokenRegex = createTokenRegex(options.tokenPattern, result, ruleName);
263
+ const tokenRegex = createTokenRegex(options.tokenPattern, { result, root, ruleName });
253
264
  if (options.fixWith === 'token') {
254
265
  options.tokenIndex = tokenIndexFromDefinitions(
255
266
  collectTokenDefinitions({
@@ -269,7 +280,7 @@ const ruleFunction = (primary, secondaryOptions) => {
269
280
  const report = (value, decl, node, nearest, fixedValue = null, nearestUnit = 'px', extraNote = '', position = null) => {
270
281
  const lower = nearest ? formatLength(nearest.lower, nearestUnit) : 'n/a';
271
282
  const upper = nearest ? formatLength(nearest.upper, nearestUnit) : 'n/a';
272
- const tokenNote = nearest ? tokenHoldsNote(fixedValue, formatLength(nearest.nearest, nearestUnit)) : '';
283
+ const tokenNote = nearest ? tokenHoldsNote(fixedValue, formatLength(nearest.nearest, nearestUnit), String(value).trim().startsWith('-')) : '';
273
284
  reportValueNode({
274
285
  decl,
275
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),
@@ -312,15 +323,18 @@ const ruleFunction = (primary, secondaryOptions) => {
312
323
  return;
313
324
  }
314
325
 
315
- const parsed = valueParser(decl.value);
316
- let changed = false;
326
+ const source = declarationValueSource(decl);
327
+ const parsed = valueParser(source.parseText);
328
+ // Operands of a Sass expression that did not resolve are reported, never rewritten (issue #234).
329
+ const sassOperands = sassOperandNodes(parsed, decl.value);
317
330
 
318
331
  if (prop === 'transform') {
319
332
  walkTransformTranslateNodes(parsed, (node, context) => {
320
333
 
321
334
  if (node.type === 'function') {
322
- if (isTokenFunction(node, options.tokenFunctions, tokenRegex)) {
323
- return true;
335
+ const skipToken = skipTokenFunction(node, options.tokenFunctions, tokenRegex);
336
+ if (skipToken) {
337
+ return skipToken;
324
338
  }
325
339
 
326
340
  if (isMathFunction(node.value) && !options.enforceInsideMathFunctions) {
@@ -342,15 +356,16 @@ const ruleFunction = (primary, secondaryOptions) => {
342
356
  return false;
343
357
  }
344
358
 
345
- changed =
346
- checkLengthValue({
347
- decl,
348
- node,
349
- options,
350
- report,
351
- scaleByUnit,
352
- scalePx,
353
- }) || changed;
359
+ checkLengthValue({
360
+ avoidTokens: context.tokenFallbackOf,
361
+ decl,
362
+ fixable: !sassOperands.has(node),
363
+ node,
364
+ options,
365
+ report,
366
+ scaleByUnit,
367
+ scalePx,
368
+ });
354
369
 
355
370
  return false;
356
371
  });
@@ -359,8 +374,9 @@ const ruleFunction = (primary, secondaryOptions) => {
359
374
  walkRootValueNodes(parsed, (node, context) => {
360
375
 
361
376
  if (node.type === 'function') {
362
- if (isTokenFunction(node, options.tokenFunctions, tokenRegex)) {
363
- return true;
377
+ const skipToken = skipTokenFunction(node, options.tokenFunctions, tokenRegex);
378
+ if (skipToken) {
379
+ return skipToken;
364
380
  }
365
381
 
366
382
  if (options.checkMathBounds && isBoundFunction(node)) {
@@ -373,7 +389,7 @@ const ruleFunction = (primary, secondaryOptions) => {
373
389
  continue;
374
390
  }
375
391
  const boundReport = (value, d, n, nearest, fixed, unit, extra) => report(value, d, n, nearest, fixed, unit, extra, bound.position);
376
- changed = checkLengthValue({ decl, node: bound.node, options, report: boundReport, scaleByUnit, scalePx }) || changed;
392
+ checkLengthValue({ avoidTokens: context.tokenFallbackOf, decl, fixable: !sassOperands.has(bound.node), node: bound.node, options, report: boundReport, scaleByUnit, scalePx });
377
393
  }
378
394
  }
379
395
 
@@ -399,23 +415,22 @@ const ruleFunction = (primary, secondaryOptions) => {
399
415
  return false;
400
416
  }
401
417
 
402
- changed =
403
- checkLengthValue({
404
- decl,
405
- node,
406
- options,
407
- report,
408
- scaleByUnit,
409
- scalePx,
410
- }) || changed;
418
+ checkLengthValue({
419
+ avoidTokens: context.tokenFallbackOf,
420
+ decl,
421
+ fixable: !sassOperands.has(node),
422
+ node,
423
+ options,
424
+ report,
425
+ scaleByUnit,
426
+ scalePx,
427
+ });
411
428
 
412
429
  return false;
413
430
  });
414
431
  }
415
432
 
416
- if (changed) {
417
- decl.value = parsed.toString();
418
- }
433
+ writeDeclarationValue(decl, source, parsed);
419
434
  });
420
435
  };
421
436
  };
@@ -0,0 +1,37 @@
1
+ /**
2
+ * Compile-time check that the ESLint plugin types fit ESLint's own config
3
+ * types (#242): a typed `eslint.config.ts` that registers the plugin with
4
+ * `defineConfig` must compile. Nothing here runs; it only has to type-check.
5
+ */
6
+ import type { ESLint, Linter } from 'eslint';
7
+ import { defineConfig } from 'eslint/config';
8
+ import rhythmguard from 'eslint-plugin-rhythmguard';
9
+ import companion from 'stylelint-plugin-rhythmguard/eslint';
10
+
11
+ const config = defineConfig([
12
+ {
13
+ files: ['**/*.{js,jsx,mjs,cjs}'],
14
+ languageOptions: { parserOptions: { ecmaFeatures: { jsx: true } } },
15
+ plugins: { 'rhythmguard-tailwind': rhythmguard },
16
+ rules: {
17
+ 'rhythmguard-tailwind/tailwind-class-use-scale': ['error', { scale: [0, 4, 8, 12, 16, 24, 32] }],
18
+ },
19
+ },
20
+ {
21
+ files: ['**/*.jsx'],
22
+ plugins: { 'rhythmguard-tailwind': companion },
23
+ rules: companion.configs.recommended.rules,
24
+ },
25
+ ]);
26
+
27
+ const asPlugin: ESLint.Plugin = rhythmguard;
28
+ const companionAsPlugin: ESLint.Plugin = companion;
29
+ const flatConfig: Linter.Config = {
30
+ plugins: { 'rhythmguard-tailwind': rhythmguard },
31
+ rules: rhythmguard.configs.recommended.rules,
32
+ };
33
+
34
+ void config;
35
+ void asPlugin;
36
+ void companionAsPlugin;
37
+ void flatConfig;