stylelint-plugin-rhythmguard 3.5.0 → 3.7.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 (43) hide show
  1. package/CHANGELOG.md +31 -0
  2. package/CONTRIBUTING.md +8 -5
  3. package/README.md +8 -10
  4. package/SECURITY.md +3 -2
  5. package/agents/claude-code/SKILL.md +1 -1
  6. package/agents/copilot/copilot-instructions.md +1 -1
  7. package/agents/cursor/rhythmguard.mdc +1 -1
  8. package/package.json +2 -1
  9. package/src/audit/args.js +1 -0
  10. package/src/audit/baseline.js +55 -11
  11. package/src/audit/codemod.js +129 -0
  12. package/src/audit/config.js +5 -0
  13. package/src/audit/contract.js +1 -0
  14. package/src/audit/decisions.js +105 -0
  15. package/src/audit/render-github.js +5 -2
  16. package/src/audit/render-markdown.js +31 -0
  17. package/src/audit/render-text.js +11 -0
  18. package/src/audit/report.js +30 -2
  19. package/src/audit/scan/stylesheets.js +1 -0
  20. package/src/audit/scan/templates.js +2 -4
  21. package/src/audit/shared.js +2 -0
  22. package/src/audit/token-chains.js +157 -0
  23. package/src/cli/audit.js +5 -0
  24. package/src/cli/fix.js +151 -0
  25. package/src/cli/index.js +4 -0
  26. package/src/core/decisions.js +121 -0
  27. package/src/core/fs-cache.js +36 -0
  28. package/src/core/length.js +1 -0
  29. package/src/core/options.js +40 -0
  30. package/src/core/scale-inference.js +34 -37
  31. package/src/core/tailwind-class-analysis.js +59 -3
  32. package/src/core/token-index.js +82 -0
  33. package/src/core/token-map.js +50 -13
  34. package/src/core/token-sources.js +3 -2
  35. package/src/eslint/rules/tailwind-class-use-motion-scale.js +6 -2
  36. package/src/eslint/rules/tailwind-class-use-scale.js +13 -13
  37. package/src/rules/no-offscale-transform/index.js +27 -6
  38. package/src/rules/prefer-token/index.js +20 -29
  39. package/src/rules/report.js +6 -0
  40. package/src/rules/use-motion-scale/index.js +12 -8
  41. package/src/rules/use-scale/index.js +35 -6
  42. package/types/audit.d.ts +31 -0
  43. package/types/shared.d.ts +6 -0
@@ -0,0 +1,36 @@
1
+ 'use strict';
2
+
3
+ const fs = require('node:fs');
4
+
5
+ /**
6
+ * Editors and pre-commit hooks lint one file at a time, and each lint asks the
7
+ * filesystem the same questions: which package.json files sit between cwd and
8
+ * the repository, what .rhythmguardrc.json says, whether a token package is
9
+ * installed. The answers change only when one of those files changes, so a
10
+ * result is cached per key and revalidated by the mtime of every file the
11
+ * computation consulted: a stat per file instead of a read, a parse and a walk.
12
+ */
13
+ const cache = new Map();
14
+
15
+ function fileStamp(file) {
16
+ try {
17
+ return fs.statSync(file).mtimeMs;
18
+ } catch {
19
+ return null;
20
+ }
21
+ }
22
+
23
+ function cachedByFiles(cacheKey, compute) {
24
+ const cached = cache.get(cacheKey);
25
+ if (cached && cached.stamps.every(([file, stamp]) => fileStamp(file) === stamp)) {
26
+ return cached.value;
27
+ }
28
+ const consulted = [];
29
+ const value = compute((file) => consulted.push([file, fileStamp(file)]));
30
+ cache.set(cacheKey, { stamps: consulted, value });
31
+ return value;
32
+ }
33
+
34
+ module.exports = {
35
+ cachedByFiles,
36
+ };
@@ -205,6 +205,7 @@ function fixedLengthValue(parsedLength, nearestPx, { baseFontSize, unitStrategy,
205
205
  module.exports = {
206
206
  fixedLengthValue,
207
207
  formatLength,
208
+ formatNumber,
208
209
  fromPx,
209
210
  isHairlineLength,
210
211
  nearestScaleValues,
@@ -54,6 +54,22 @@ function isNonEmptyString(value) {
54
54
  return typeof value === 'string' && value.trim().length > 0;
55
55
  }
56
56
 
57
+ const MAX_NOTE_LENGTH = 200;
58
+
59
+ /** A `note` is appended to every finding, so it stays a sentence: non-empty, at most 200 characters. */
60
+ function isNote(value) {
61
+ return isNonEmptyString(value) && value.trim().length <= MAX_NOTE_LENGTH;
62
+ }
63
+
64
+ function normalizeNote(value) {
65
+ return isNote(value) ? value.trim() : '';
66
+ }
67
+
68
+ /** The message with the configured note after it, or the message alone. */
69
+ function withNote(message, note) {
70
+ return note ? `${message} ${note}` : message;
71
+ }
72
+
57
73
  function isSupportedUnit(value) {
58
74
  if (!isNonEmptyString(value)) {
59
75
  return false;
@@ -62,6 +78,10 @@ function isSupportedUnit(value) {
62
78
  return SUPPORTED_SCALE_UNITS.has(value.trim().toLowerCase());
63
79
  }
64
80
 
81
+ function isFixWith(value) {
82
+ return value === 'value' || value === 'token';
83
+ }
84
+
65
85
  function isUnitStrategy(value) {
66
86
  return value === 'convert' || value === 'exact';
67
87
  }
@@ -359,6 +379,9 @@ function resolveUnits(options) {
359
379
  }
360
380
 
361
381
  const SCALE_VALIDATION_SCHEMA = Object.freeze({
382
+ decisions: Object.freeze({
383
+ entryValidator: isBoolean,
384
+ }),
362
385
  allowHairlines: Object.freeze({
363
386
  entryValidator: isBoolean,
364
387
  }),
@@ -378,6 +401,9 @@ const SCALE_VALIDATION_SCHEMA = Object.freeze({
378
401
  enforceInsideMathFunctions: Object.freeze({
379
402
  entryValidator: isBoolean,
380
403
  }),
404
+ fixWith: Object.freeze({
405
+ entryValidator: isFixWith,
406
+ }),
381
407
  fixToScale: Object.freeze({
382
408
  entryValidator: isBoolean,
383
409
  }),
@@ -389,6 +415,9 @@ const SCALE_VALIDATION_SCHEMA = Object.freeze({
389
415
  entryValidator: isMathFunctionArgumentMap,
390
416
  expectsObject: true,
391
417
  }),
418
+ note: Object.freeze({
419
+ entryValidator: isNote,
420
+ }),
392
421
  preset: Object.freeze({
393
422
  entryValidator: isNonEmptyString,
394
423
  }),
@@ -473,6 +502,9 @@ const PREFER_TOKEN_VALIDATION_SCHEMA = Object.freeze({
473
502
  entryValidator: isMathFunctionArgumentMap,
474
503
  expectsObject: true,
475
504
  }),
505
+ note: Object.freeze({
506
+ entryValidator: isNote,
507
+ }),
476
508
  preset: Object.freeze({
477
509
  entryValidator: isNonEmptyString,
478
510
  }),
@@ -545,6 +577,7 @@ function buildScaleOptions(rawOptions) {
545
577
 
546
578
  return {
547
579
  allowHairlines: options.allowHairlines !== false,
580
+ readDecisions: options.decisions !== false,
548
581
  allowNegative: options.allowNegative !== false,
549
582
  allowPercentages: options.allowPercentages !== false,
550
583
  baseFontSize:
@@ -555,12 +588,14 @@ function buildScaleOptions(rawOptions) {
555
588
  : 16,
556
589
  enforceInsideMathFunctions: options.enforceInsideMathFunctions === true,
557
590
  fixToScale: options.fixToScale !== false,
591
+ fixWith: options.fixWith === 'token' ? 'token' : 'value',
558
592
  ignoreMathFunctionArguments: normalizeMathFunctionArgumentMap(options.ignoreMathFunctionArguments),
559
593
  ignoreValues: Array.isArray(options.ignoreValues)
560
594
  ? options.ignoreValues.map((value) => String(value).toLowerCase())
561
595
  : DEFAULT_IGNORE_KEYWORDS,
562
596
  invalidPreset: scaleSelection.invalidPreset,
563
597
  mathFunctionArguments: normalizeMathFunctionArgumentMap(options.mathFunctionArguments),
598
+ note: normalizeNote(options.note),
564
599
  preset: scaleSelection.selectedPreset,
565
600
  presetNames: listScalePresetNames(),
566
601
  properties: resolvePropertyPatterns(options),
@@ -606,6 +641,7 @@ function buildTokenOptions(rawOptions) {
606
641
  : DEFAULT_IGNORE_KEYWORDS,
607
642
  invalidPreset: scaleSelection.invalidPreset,
608
643
  mathFunctionArguments: normalizeMathFunctionArgumentMap(options.mathFunctionArguments),
644
+ note: normalizeNote(options.note),
609
645
  preset: scaleSelection.selectedPreset,
610
646
  presetNames: listScalePresetNames(),
611
647
  properties: resolvePropertyPatterns(options),
@@ -689,6 +725,10 @@ function resolvePropertyScale(prop, options) {
689
725
  }
690
726
 
691
727
  module.exports = {
728
+ MAX_NOTE_LENGTH,
729
+ isNote,
730
+ normalizeNote,
731
+ withNote,
692
732
  NO_OFFSCALE_TRANSFORM_POSSIBLE_OPTIONS,
693
733
  NO_OFFSCALE_TRANSFORM_VALIDATION_SCHEMA,
694
734
  PREFER_TOKEN_POSSIBLE_OPTIONS,
@@ -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,
@@ -2,6 +2,7 @@
2
2
 
3
3
  const {
4
4
  formatLength,
5
+ formatNumber,
5
6
  nearestScaleValues,
6
7
  normalizeScale,
7
8
  numbersEqual,
@@ -11,6 +12,9 @@ const {
11
12
 
12
13
  const DEFAULT_SCALE = [0, 4, 8, 12, 16, 24, 32];
13
14
  const DEFAULT_UNITS = ['px', 'rem', 'em'];
15
+ // Tailwind's numeric spacing utilities are multiples of one unit: `--spacing`
16
+ // (0.25rem, 4px) in v4, and the same 4px step in the v3 default scale.
17
+ const DEFAULT_SPACING_UNIT_PX = 4;
14
18
 
15
19
  const ARBITRARY_SPACING_CLASS = /^(?<utility>-?(?:m(?:[trblxy])?|p(?:[trblxy])?|gap(?:-[xy])?|space-[xy]|inset(?:-[xy])?|top|right|bottom|left|translate-[xy]|scroll-(?:m|p)(?:[trblxy])?))-\[(?<rawValue>[^\]]+)\]$/;
16
20
 
@@ -24,6 +28,11 @@ function normalizeTailwindClassOptions(option = {}) {
24
28
  ? option.baseFontSize
25
29
  : 16,
26
30
  scale: Array.isArray(option.scale) ? option.scale : DEFAULT_SCALE,
31
+ spacingUnit: option.spacingUnit === false
32
+ ? null
33
+ : typeof option.spacingUnit === 'number' && Number.isFinite(option.spacingUnit) && option.spacingUnit > 0
34
+ ? option.spacingUnit
35
+ : DEFAULT_SPACING_UNIT_PX,
27
36
  units: Array.isArray(option.units)
28
37
  ? option.units.map((unit) => String(unit).toLowerCase())
29
38
  : DEFAULT_UNITS,
@@ -121,6 +130,26 @@ function hasInvalidVariantPrefix(prefix) {
121
130
  return false;
122
131
  }
123
132
 
133
+ /**
134
+ * The utility class Tailwind generates for a px value, or null when the value
135
+ * is not a quarter step of the spacing unit (v4 generates any quarter step;
136
+ * v3 generates the quarter steps of its default scale). `p-3` for 12px on a
137
+ * 4px unit; `-m-3` when the class or the value is negative.
138
+ */
139
+ function utilityClassFor(utility, px, negative, spacingUnit) {
140
+ if (!spacingUnit) {
141
+ return null;
142
+ }
143
+ const steps = Math.abs(px) / spacingUnit;
144
+ const quarters = steps * 4;
145
+ if (Math.abs(quarters - Math.round(quarters)) > 1e-9) {
146
+ return null;
147
+ }
148
+ const name = utility.startsWith('-') ? utility.slice(1) : utility;
149
+ const sign = negative && steps !== 0 ? '-' : '';
150
+ return `${sign}${name}-${formatNumber(Math.round(quarters) / 4)}`;
151
+ }
152
+
124
153
  function analyzeClassToken(token, options, scalePx) {
125
154
  const parsedToken = parseClassToken(token);
126
155
  if (parsedToken.prefix && hasInvalidVariantPrefix(parsedToken.prefix)) {
@@ -175,13 +204,22 @@ function analyzeClassToken(token, options, scalePx) {
175
204
  ? signedNearest
176
205
  : signedNearest / options.baseFontSize;
177
206
 
207
+ const negative = match.groups.utility.startsWith('-') || parsedLength.number < 0;
208
+ const wrap = (candidate) =>
209
+ `${parsedToken.prefix}${parsedToken.leadingImportant}${candidate}${parsedToken.trailingImportant}`;
210
+ const utilityFix = utilityClassFor(match.groups.utility, nearest.nearest, negative, options.spacingUnit);
211
+ const lowerUtility = utilityClassFor(match.groups.utility, nearest.lower, negative, options.spacingUnit);
212
+ const upperUtility = utilityClassFor(match.groups.utility, nearest.upper, negative, options.spacingUnit);
213
+
178
214
  const replacementValue = formatLength(replacementNumber, parsedLength.unit);
179
- const fixedCandidate = parsedToken.candidate.replace(match.groups.rawValue, replacementValue);
180
- const fixedToken = `${parsedToken.prefix}${parsedToken.leadingImportant}${fixedCandidate}${parsedToken.trailingImportant}`;
215
+ const fixedCandidate = utilityFix || parsedToken.candidate.replace(match.groups.rawValue, replacementValue);
181
216
 
182
217
  return {
183
- fixedToken,
218
+ fixedToken: wrap(fixedCandidate),
184
219
  nearest,
220
+ // The utility classes for the two nearest steps, when both are quarter
221
+ // steps of the spacing unit; messages name these instead of px values.
222
+ nearestUtilities: lowerUtility && upperUtility ? { lower: lowerUtility, upper: upperUtility } : null,
185
223
  parsedLength,
186
224
  reason: 'off-scale',
187
225
  rawValue: match.groups.rawValue,
@@ -214,8 +252,26 @@ function createTailwindClassAnalyzer(option = {}) {
214
252
  };
215
253
  }
216
254
 
255
+ /**
256
+ * The message text for an off-scale class: the two nearest utility classes
257
+ * with their px values when the unit makes them, the px steps otherwise.
258
+ */
259
+ function offScaleClassMessage(token, analysis) {
260
+ if (analysis.reason === 'negative') {
261
+ return `Unexpected Tailwind arbitrary spacing value "${token}". Negative values are disabled for this rule.`;
262
+ }
263
+ const lowerPx = analysis.nearest ? formatLength(analysis.nearest.lower, 'px') : 'n/a';
264
+ const upperPx = analysis.nearest ? formatLength(analysis.nearest.upper, 'px') : 'n/a';
265
+ if (analysis.nearestUtilities) {
266
+ const { lower, upper } = analysis.nearestUtilities;
267
+ return `Unexpected Tailwind arbitrary spacing value "${token}". Use "${lower}" (${lowerPx}) or "${upper}" (${upperPx}).`;
268
+ }
269
+ return `Unexpected Tailwind arbitrary spacing value "${token}". Use scale values (nearest: ${lowerPx} or ${upperPx}).`;
270
+ }
271
+
217
272
  module.exports = {
218
273
  createTailwindClassAnalyzer,
219
274
  findClassSegments,
220
275
  normalizeTailwindClassOptions,
276
+ offScaleClassMessage,
221
277
  };
@@ -0,0 +1,82 @@
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
+ /**
68
+ * The sentence a length rule appends when its fix writes a token: which token
69
+ * holds the snapped value. Empty when the replacement is a literal.
70
+ */
71
+ function tokenHoldsNote(replacement, formattedNearest) {
72
+ const match = typeof replacement === 'string' ? replacement.match(/var\(--[^)]+\)/) : null;
73
+ return match ? `${match[0]} holds ${formattedNearest}.` : '';
74
+ }
75
+
76
+ module.exports = {
77
+ negateReplacement,
78
+ replacementFor,
79
+ tokenHoldsNote,
80
+ tokenForLength,
81
+ tokenIndexFromDefinitions,
82
+ };
@@ -40,7 +40,19 @@ function normalizeTokenReference(tokenReference) {
40
40
  return `var(--${trimmed})`;
41
41
  }
42
42
 
43
- function addLengthValueMapping(map, rawLength, tokenReference, baseFontSize) {
43
+ /**
44
+ * Where a token came from, for messages: the path relative to the working
45
+ * directory when it is inside it, the absolute path otherwise.
46
+ */
47
+ function displayOrigin(file) {
48
+ if (!file) {
49
+ return null;
50
+ }
51
+ const relative = path.relative(process.cwd(), file);
52
+ return relative && !relative.startsWith('..') && !path.isAbsolute(relative) ? relative : file;
53
+ }
54
+
55
+ function addLengthValueMapping(map, rawLength, tokenReference, baseFontSize, origins = null, origin = null) {
44
56
  if (typeof rawLength !== 'string') {
45
57
  return;
46
58
  }
@@ -55,6 +67,10 @@ function addLengthValueMapping(map, rawLength, tokenReference, baseFontSize) {
55
67
  return;
56
68
  }
57
69
 
70
+ if (origins && origin && !origins[token]) {
71
+ origins[token] = origin;
72
+ }
73
+
58
74
  const absolute = Math.abs(parsed.number);
59
75
  const normalizedRaw = formatLength(absolute, parsed.unit || 'px');
60
76
  map[normalizedRaw] = token;
@@ -81,7 +97,7 @@ function mergeExplicitTokenMap(target, source) {
81
97
  return target;
82
98
  }
83
99
 
84
- function walkTokenGroup(map, group, prefix, baseFontSize) {
100
+ function walkTokenGroup(map, group, prefix, baseFontSize, origins, origin) {
85
101
  for (const [key, value] of Object.entries(group)) {
86
102
  const tokenName = `${prefix}-${key}`;
87
103
 
@@ -91,24 +107,25 @@ function walkTokenGroup(map, group, prefix, baseFontSize) {
91
107
 
92
108
  // Leaf node with $value (DTCG)
93
109
  if (typeof value.$value === 'string') {
94
- addLengthValueMapping(map, value.$value, tokenName, baseFontSize);
110
+ addLengthValueMapping(map, value.$value, tokenName, baseFontSize, origins, origin);
95
111
  continue;
96
112
  }
97
113
 
98
114
  // Leaf node with value (Style Dictionary)
99
115
  if (typeof value.value === 'string') {
100
- addLengthValueMapping(map, value.value, tokenName, baseFontSize);
116
+ addLengthValueMapping(map, value.value, tokenName, baseFontSize, origins, origin);
101
117
  continue;
102
118
  }
103
119
 
104
- // Nested group — recurse deeper
105
- walkTokenGroup(map, value, tokenName, baseFontSize);
120
+ // Nested group: recurse deeper
121
+ walkTokenGroup(map, value, tokenName, baseFontSize, origins, origin);
106
122
  }
107
123
  }
108
124
 
109
125
  function mergeTokenMapFromFile({
110
126
  baseFontSize,
111
127
  currentMap,
128
+ origins = null,
112
129
  tokenMapFile,
113
130
  }) {
114
131
  if (!tokenMapFile) {
@@ -134,6 +151,7 @@ function mergeTokenMapFromFile({
134
151
  const nextMap = {
135
152
  ...currentMap,
136
153
  };
154
+ const origin = displayOrigin(resolvedPath);
137
155
 
138
156
  for (const [entryKey, entryValue] of Object.entries(parsed)) {
139
157
  if (typeof entryValue === 'string') {
@@ -142,36 +160,39 @@ function mergeTokenMapFromFile({
142
160
 
143
161
  if (keyAsLength) {
144
162
  nextMap[entryKey] = entryValue;
163
+ if (origins && !origins[entryValue]) {
164
+ origins[entryValue] = origin;
165
+ }
145
166
  continue;
146
167
  }
147
168
 
148
169
  if (valueAsLength) {
149
- addLengthValueMapping(nextMap, entryValue, entryKey, baseFontSize);
170
+ addLengthValueMapping(nextMap, entryValue, entryKey, baseFontSize, origins, origin);
150
171
  }
151
172
 
152
173
  continue;
153
174
  }
154
175
 
155
176
  if (typeof entryValue === 'number') {
156
- addLengthValueMapping(nextMap, `${entryValue}px`, entryKey, baseFontSize);
177
+ addLengthValueMapping(nextMap, `${entryValue}px`, entryKey, baseFontSize, origins, origin);
157
178
  continue;
158
179
  }
159
180
 
160
181
  if (isPlainObject(entryValue)) {
161
182
  // Style Dictionary format: { value: "16px" }
162
183
  if (typeof entryValue.value === 'string') {
163
- addLengthValueMapping(nextMap, entryValue.value, entryKey, baseFontSize);
184
+ addLengthValueMapping(nextMap, entryValue.value, entryKey, baseFontSize, origins, origin);
164
185
  continue;
165
186
  }
166
187
 
167
188
  // W3C DTCG format: { $value: "16px", $type: "dimension" }
168
189
  if (typeof entryValue.$value === 'string') {
169
- addLengthValueMapping(nextMap, entryValue.$value, entryKey, baseFontSize);
190
+ addLengthValueMapping(nextMap, entryValue.$value, entryKey, baseFontSize, origins, origin);
170
191
  continue;
171
192
  }
172
193
 
173
- // Nested group — recurse (e.g. { spacing: { 4: { $value: "16px" } } })
174
- walkTokenGroup(nextMap, entryValue, entryKey, baseFontSize);
194
+ // Nested group: recurse (e.g. { spacing: { 4: { $value: "16px" } } })
195
+ walkTokenGroup(nextMap, entryValue, entryKey, baseFontSize, origins, origin);
175
196
  }
176
197
  }
177
198
 
@@ -181,12 +202,14 @@ function mergeTokenMapFromFile({
181
202
  function mergeTokenMapFromCssCustomProperties({
182
203
  baseFontSize,
183
204
  currentMap,
205
+ origins = null,
184
206
  root,
185
207
  tokenRegex,
186
208
  }) {
187
209
  const nextMap = {
188
210
  ...currentMap,
189
211
  };
212
+ const origin = displayOrigin(root && root.source && root.source.input ? root.source.input.file : null);
190
213
 
191
214
  root.walkDecls((decl) => {
192
215
  const prop = decl.prop.toLowerCase();
@@ -203,7 +226,7 @@ function mergeTokenMapFromCssCustomProperties({
203
226
  return;
204
227
  }
205
228
 
206
- addLengthValueMapping(nextMap, decl.value, `var(${decl.prop})`, baseFontSize);
229
+ addLengthValueMapping(nextMap, decl.value, `var(${decl.prop})`, baseFontSize, origins, origin);
207
230
  });
208
231
 
209
232
  return nextMap;
@@ -321,6 +344,7 @@ function loadTailwindSpacing(resolvedPath) {
321
344
 
322
345
  function mergeTokenMapFromTailwindSpacing({
323
346
  currentMap,
347
+ origins = null,
324
348
  tailwindConfigPath,
325
349
  }) {
326
350
  if (!tailwindConfigPath) {
@@ -354,13 +378,23 @@ function mergeTokenMapFromTailwindSpacing({
354
378
  const absolute = Math.abs(parsed.number);
355
379
  const normalizedRaw = formatLength(absolute, parsed.unit || 'px');
356
380
  nextMap[normalizedRaw] = `theme(spacing.${key})`;
381
+ if (origins && !origins[nextMap[normalizedRaw]]) {
382
+ origins[nextMap[normalizedRaw]] = displayOrigin(resolvedPath);
383
+ }
357
384
  }
358
385
 
359
386
  return nextMap;
360
387
  }
361
388
 
389
+ /**
390
+ * The raw-length to token-reference map a rule fixes with. Pass `origins` to
391
+ * also learn where each token reference came from (the token map file, the
392
+ * stylesheet, the Tailwind config), keyed by the reference; an explicit
393
+ * `tokenMap` entry has no origin.
394
+ */
362
395
  function buildEffectiveTokenMap({
363
396
  options,
397
+ origins = null,
364
398
  root,
365
399
  tokenRegex,
366
400
  }) {
@@ -370,6 +404,7 @@ function buildEffectiveTokenMap({
370
404
  tokenMap = mergeTokenMapFromFile({
371
405
  baseFontSize: options.baseFontSize,
372
406
  currentMap: tokenMap,
407
+ origins,
373
408
  tokenMapFile: options.tokenMapFile,
374
409
  });
375
410
  }
@@ -378,6 +413,7 @@ function buildEffectiveTokenMap({
378
413
  tokenMap = mergeTokenMapFromCssCustomProperties({
379
414
  baseFontSize: options.baseFontSize,
380
415
  currentMap: tokenMap,
416
+ origins,
381
417
  root,
382
418
  tokenRegex,
383
419
  });
@@ -386,6 +422,7 @@ function buildEffectiveTokenMap({
386
422
  if (options.tokenMapFromTailwindSpacing && options.tailwindConfigPath) {
387
423
  tokenMap = mergeTokenMapFromTailwindSpacing({
388
424
  currentMap: tokenMap,
425
+ origins,
389
426
  tailwindConfigPath: options.tailwindConfigPath,
390
427
  });
391
428
  }
@@ -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