stylelint-plugin-rhythmguard 3.3.0 → 3.4.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 (34) hide show
  1. package/CHANGELOG.md +15 -0
  2. package/CONTRIBUTING.md +19 -3
  3. package/README.md +2 -1
  4. package/package.json +7 -7
  5. package/src/audit/args.js +103 -307
  6. package/src/audit/config.js +1 -1
  7. package/src/audit/contract.js +5 -28
  8. package/src/audit/index.js +4 -7
  9. package/src/audit/report.js +14 -14
  10. package/src/audit/scan/files.js +262 -0
  11. package/src/audit/scan/stylesheets.js +275 -0
  12. package/src/audit/scan/templates.js +175 -0
  13. package/src/cli/doctor.js +1 -1
  14. package/src/cli/quickstart.js +1 -1
  15. package/src/{utils → core}/length.js +21 -0
  16. package/src/{utils → core}/options.js +32 -108
  17. package/src/{utils → core}/scale-inference.js +151 -15
  18. package/src/{utils → core}/token-sources.js +121 -12
  19. package/src/{utils/value-utils.js → core/value-nodes.js} +1 -17
  20. package/src/eslint/rules/tailwind-class-use-motion-scale.js +2 -2
  21. package/src/eslint/rules/tailwind-class-use-scale.js +2 -2
  22. package/src/rules/no-offscale-transform/index.js +22 -91
  23. package/src/rules/prefer-token/index.js +15 -73
  24. package/src/rules/report.js +75 -0
  25. package/src/rules/use-motion-scale/index.js +25 -42
  26. package/src/rules/use-scale/index.js +21 -94
  27. package/src/rules/validate.js +132 -0
  28. package/src/audit/scan.js +0 -676
  29. /package/src/{utils/constants.js → core/css-vocabulary.js} +0 -0
  30. /package/src/{utils → core}/tailwind-class-analysis.js +0 -0
  31. /package/src/{utils → core}/tailwind-motion-analysis.js +0 -0
  32. /package/src/{utils → core}/time.js +0 -0
  33. /package/src/{utils → core}/token-map.js +0 -0
  34. /package/src/{utils → core}/token-packages.json +0 -0
@@ -0,0 +1,175 @@
1
+ 'use strict';
2
+
3
+ /**
4
+ * Findings from template files: Tailwind class strings inside string literals,
5
+ * for spacing and (opt-in) motion, with line and column recovered from offsets.
6
+ */
7
+ const fs = require('node:fs');
8
+ const { formatLength } = require('../../core/length');
9
+ const { createTailwindClassAnalyzer } = require('../../core/tailwind-class-analysis');
10
+ const { createTailwindMotionAnalyzer } = require('../../core/tailwind-motion-analysis');
11
+ const { formatTime } = require('../../core/time');
12
+ const {
13
+ formatPath,
14
+ } = require('../shared');
15
+
16
+ function collectTailwindFindings(templateFiles, options) {
17
+ const analyzer = createTailwindClassAnalyzer(options);
18
+ const findings = [];
19
+
20
+ for (const filePath of templateFiles) {
21
+ let source = '';
22
+ try {
23
+ source = fs.readFileSync(filePath, 'utf8');
24
+ } catch {
25
+ continue;
26
+ }
27
+
28
+ const lineStarts = getLineStarts(source);
29
+
30
+ for (const literal of findStringLiterals(source)) {
31
+ for (const { analysis, segment } of analyzer.analyzeClassString(literal.value)) {
32
+ const position = offsetToLineColumn(lineStarts, literal.valueStart + segment.start);
33
+ findings.push({
34
+ column: position.column,
35
+ file: formatPath(filePath),
36
+ fixedToken: analysis.fixedToken,
37
+ line: position.line,
38
+ nearest: analysis.nearest
39
+ ? {
40
+ lower: formatLength(analysis.nearest.lower, 'px'),
41
+ upper: formatLength(analysis.nearest.upper, 'px'),
42
+ }
43
+ : null,
44
+ rawValue: analysis.rawValue,
45
+ rule: 'rhythmguard-tailwind/tailwind-class-use-scale',
46
+ text: analysis.reason === 'negative'
47
+ ? `Unexpected Tailwind arbitrary spacing value "${segment.token}". Negative values are disabled for this rule.`
48
+ : `Unexpected Tailwind arbitrary spacing value "${segment.token}". Use scale values.`,
49
+ token: segment.token,
50
+ type: 'tailwind-arbitrary-spacing',
51
+ utility: analysis.utility,
52
+ });
53
+ }
54
+ }
55
+ }
56
+
57
+ return findings;
58
+ }
59
+
60
+ function collectTailwindMotionFindings(templateFiles, options) {
61
+ if (!options.includeMotion) {
62
+ return [];
63
+ }
64
+
65
+ const analyzer = createTailwindMotionAnalyzer(options);
66
+ const findings = [];
67
+
68
+ for (const filePath of templateFiles) {
69
+ let source = '';
70
+ try {
71
+ source = fs.readFileSync(filePath, 'utf8');
72
+ } catch {
73
+ continue;
74
+ }
75
+
76
+ const lineStarts = getLineStarts(source);
77
+
78
+ for (const literal of findStringLiterals(source)) {
79
+ for (const { analysis, segment } of analyzer.analyzeClassString(literal.value)) {
80
+ const position = offsetToLineColumn(lineStarts, literal.valueStart + segment.start);
81
+ findings.push({
82
+ column: position.column,
83
+ file: formatPath(filePath),
84
+ fixedToken: analysis.fixedToken,
85
+ line: position.line,
86
+ nearest: analysis.nearest
87
+ ? {
88
+ lower: formatTime(analysis.nearest.lower, 'ms'),
89
+ upper: formatTime(analysis.nearest.upper, 'ms'),
90
+ }
91
+ : null,
92
+ rawValue: analysis.rawValue,
93
+ rule: 'rhythmguard-tailwind/tailwind-class-use-motion-scale',
94
+ text: buildTailwindMotionFindingText(segment.token, analysis),
95
+ token: segment.token,
96
+ type: analysis.reason === 'easing'
97
+ ? 'tailwind-motion-easing'
98
+ : 'tailwind-motion-duration',
99
+ utility: analysis.utility,
100
+ });
101
+ }
102
+ }
103
+ }
104
+
105
+ return findings;
106
+ }
107
+
108
+ function buildTailwindMotionFindingText(token, analysis) {
109
+ if (analysis.reason === 'easing') {
110
+ return `Unexpected Tailwind arbitrary motion easing "${token}". Use motion tokens for easing decisions.`;
111
+ }
112
+
113
+ if (analysis.reason === 'negative') {
114
+ return `Unexpected Tailwind arbitrary motion duration "${token}". Use non-negative duration values.`;
115
+ }
116
+
117
+ return `Unexpected Tailwind arbitrary motion duration "${token}". Use duration scale values.`;
118
+ }
119
+
120
+ function findStringLiterals(source) {
121
+ const literals = [];
122
+ const literalPattern = /(["'`])((?:\\[\s\S]|(?!\1)[\s\S])*?)\1/g;
123
+ let match;
124
+
125
+ while ((match = literalPattern.exec(source)) !== null) {
126
+ literals.push({
127
+ quote: match[1],
128
+ value: match[2],
129
+ valueStart: match.index + 1,
130
+ });
131
+ }
132
+
133
+ return literals;
134
+ }
135
+
136
+ function getLineStarts(source) {
137
+ const starts = [0];
138
+
139
+ for (let index = 0; index < source.length; index++) {
140
+ if (source[index] === '\n') {
141
+ starts.push(index + 1);
142
+ }
143
+ }
144
+
145
+ return starts;
146
+ }
147
+
148
+ function offsetToLineColumn(lineStarts, offset) {
149
+ let low = 0;
150
+ let high = lineStarts.length - 1;
151
+
152
+ while (low <= high) {
153
+ const mid = Math.floor((low + high) / 2);
154
+ if (lineStarts[mid] <= offset) {
155
+ low = mid + 1;
156
+ } else {
157
+ high = mid - 1;
158
+ }
159
+ }
160
+
161
+ const lineIndex = Math.max(0, high);
162
+ return {
163
+ column: offset - lineStarts[lineIndex] + 1,
164
+ line: lineIndex + 1,
165
+ };
166
+ }
167
+
168
+ module.exports = {
169
+ buildTailwindMotionFindingText,
170
+ collectTailwindFindings,
171
+ collectTailwindMotionFindings,
172
+ findStringLiterals,
173
+ getLineStarts,
174
+ offsetToLineColumn,
175
+ };
package/src/cli/doctor.js CHANGED
@@ -2,7 +2,7 @@
2
2
 
3
3
  const fs = require('node:fs');
4
4
  const path = require('node:path');
5
- const { normalizeTokenSourceFormat } = require('../utils/token-sources');
5
+ const { normalizeTokenSourceFormat } = require('../core/token-sources');
6
6
 
7
7
  const cwd = process.cwd();
8
8
  let issues = 0;
@@ -14,7 +14,7 @@ const path = require('node:path');
14
14
 
15
15
  const { createAuditReport } = require('../audit/report');
16
16
  const { detect } = require('./init');
17
- const { discoverTokenPackages } = require('../utils/scale-inference');
17
+ const { discoverTokenPackages } = require('../core/scale-inference');
18
18
 
19
19
  const TOKEN_FILE_PATTERN = /(^|[.-])tokens?\.json$/i;
20
20
  const TOKEN_DIRS = ['tokens', 'design-tokens', path.join('src', 'tokens'), path.join('dist', 'tokens')];
@@ -182,7 +182,28 @@ function nearestScaleValues(target, scale) {
182
182
  };
183
183
  }
184
184
 
185
+ /**
186
+ * The replacement text for an off-scale length, or null when the value's unit
187
+ * is not one the rule may rewrite. Keeps the sign, and with the `exact` unit
188
+ * strategy keeps the unit as written instead of converting through px.
189
+ */
190
+ function fixedLengthValue(parsedLength, nearestPx, { baseFontSize, unitStrategy, units }) {
191
+ const unit = parsedLength.unit || 'px';
192
+ if (unit === '%' || !units.includes(unit)) {
193
+ return null;
194
+ }
195
+
196
+ const signedNearest = parsedLength.number < 0 ? -Math.abs(nearestPx) : nearestPx;
197
+ if (unitStrategy === 'exact') {
198
+ return formatLength(signedNearest, unit);
199
+ }
200
+
201
+ const converted = fromPx(signedNearest, unit, baseFontSize);
202
+ return converted === null ? null : formatLength(converted, unit);
203
+ }
204
+
185
205
  module.exports = {
206
+ fixedLengthValue,
186
207
  formatLength,
187
208
  fromPx,
188
209
  isHairlineLength,
@@ -1,6 +1,7 @@
1
1
  'use strict';
2
2
 
3
- const stylelint = require('stylelint');
3
+ const { normalizeScale, normalizeScaleByUnit } = require('./length');
4
+
4
5
  const { all: knownCssProperties = [] } = require('known-css-properties');
5
6
  const {
6
7
  DEFAULT_IGNORE_KEYWORDS,
@@ -10,7 +11,7 @@ const {
10
11
  PROPERTY_GROUP_PATTERNS,
11
12
  SPACING_PROPERTY_PATTERNS,
12
13
  SUPPORTED_SCALE_UNITS,
13
- } = require('./constants');
14
+ } = require('./css-vocabulary');
14
15
  const { parseLengthToken } = require('./length');
15
16
  const {
16
17
  getScalePreset,
@@ -236,57 +237,6 @@ function isPropertyScaleMap(value) {
236
237
  });
237
238
  }
238
239
 
239
- function validateSecondaryOptionShapes(result, ruleName, secondaryOptions, schema) {
240
- if (secondaryOptions === undefined || secondaryOptions === null) {
241
- return true;
242
- }
243
-
244
- if (!isPlainObject(secondaryOptions)) {
245
- return true;
246
- }
247
-
248
- let valid = true;
249
-
250
- for (const [optionName, descriptor] of Object.entries(schema)) {
251
- const optionValue = secondaryOptions[optionName];
252
- if (optionValue === undefined) {
253
- continue;
254
- }
255
-
256
- const literalAllowed = Array.isArray(descriptor.allowLiterals)
257
- && descriptor.allowLiterals.includes(optionValue);
258
-
259
- if (descriptor.expectsArray && !Array.isArray(optionValue) && !literalAllowed) {
260
- valid = false;
261
- result.warn(
262
- `Invalid value ${stringifyOptionValue(optionValue)} for option "${optionName}" of rule "${ruleName}"`,
263
- { stylelintType: 'invalidOption' },
264
- );
265
- result.stylelint.stylelintError = true;
266
- continue;
267
- }
268
-
269
- if (descriptor.expectsObject && !isPlainObject(optionValue)) {
270
- valid = false;
271
- result.warn(
272
- `Invalid value ${stringifyOptionValue(optionValue)} for option "${optionName}" of rule "${ruleName}"`,
273
- { stylelintType: 'invalidOption' },
274
- );
275
- result.stylelint.stylelintError = true;
276
- }
277
- }
278
-
279
- return valid;
280
- }
281
-
282
- function stringifyOptionValue(value) {
283
- if (typeof value === 'string') {
284
- return `"${value}"`;
285
- }
286
-
287
- return `"${JSON.stringify(value)}"`;
288
- }
289
-
290
240
  function buildPossibleOptionMap(schema) {
291
241
  return Object.fromEntries(
292
242
  Object.entries(schema).map(([optionName, descriptor]) => [
@@ -296,28 +246,6 @@ function buildPossibleOptionMap(schema) {
296
246
  );
297
247
  }
298
248
 
299
- function validateSecondaryOptions({
300
- result,
301
- ruleName,
302
- secondaryOptions,
303
- schema,
304
- possibleOptionMap,
305
- }) {
306
- const validOptions = stylelint.utils.validateOptions(result, ruleName, {
307
- actual: secondaryOptions,
308
- optional: true,
309
- possible: possibleOptionMap,
310
- });
311
- const validShapes = validateSecondaryOptionShapes(
312
- result,
313
- ruleName,
314
- secondaryOptions,
315
- schema,
316
- );
317
-
318
- return validOptions && validShapes;
319
- }
320
-
321
249
  function normalizePropertyGroups(rawGroups) {
322
250
  const source = Array.isArray(rawGroups) && rawGroups.length > 0
323
251
  ? rawGroups
@@ -713,6 +641,27 @@ function buildTokenOptions(rawOptions) {
713
641
  };
714
642
  }
715
643
 
644
+ /**
645
+ * Per-property scale lookup with a cache, because a stylesheet repeats the
646
+ * same few properties thousands of times and normalising a scale is not free.
647
+ */
648
+ function createPropertyScaleResolver(options) {
649
+ const cache = new Map();
650
+ return (prop) => {
651
+ const cached = cache.get(prop);
652
+ if (cached) {
653
+ return cached;
654
+ }
655
+ const selectedScale = resolvePropertyScale(prop, options);
656
+ const state = {
657
+ scaleByUnit: normalizeScaleByUnit(selectedScale),
658
+ scalePx: normalizeScale(selectedScale, options.baseFontSize),
659
+ };
660
+ cache.set(prop, state);
661
+ return state;
662
+ };
663
+ }
664
+
716
665
  function resolvePropertyScale(prop, options) {
717
666
  if (!Array.isArray(options.propertyScaleOverrides)) {
718
667
  return options.scale;
@@ -739,41 +688,16 @@ function resolvePropertyScale(prop, options) {
739
688
  return options.scale;
740
689
  }
741
690
 
742
- function validateUseScaleSecondaryOptions(result, ruleName, secondaryOptions) {
743
- return validateSecondaryOptions({
744
- result,
745
- ruleName,
746
- secondaryOptions,
747
- schema: USE_SCALE_VALIDATION_SCHEMA,
748
- possibleOptionMap: USE_SCALE_POSSIBLE_OPTIONS,
749
- });
750
- }
751
-
752
- function validateNoOffscaleTransformSecondaryOptions(result, ruleName, secondaryOptions) {
753
- return validateSecondaryOptions({
754
- result,
755
- ruleName,
756
- secondaryOptions,
757
- schema: NO_OFFSCALE_TRANSFORM_VALIDATION_SCHEMA,
758
- possibleOptionMap: NO_OFFSCALE_TRANSFORM_POSSIBLE_OPTIONS,
759
- });
760
- }
761
-
762
- function validatePreferTokenSecondaryOptions(result, ruleName, secondaryOptions) {
763
- return validateSecondaryOptions({
764
- result,
765
- ruleName,
766
- secondaryOptions,
767
- schema: PREFER_TOKEN_VALIDATION_SCHEMA,
768
- possibleOptionMap: PREFER_TOKEN_POSSIBLE_OPTIONS,
769
- });
770
- }
771
-
772
691
  module.exports = {
692
+ NO_OFFSCALE_TRANSFORM_POSSIBLE_OPTIONS,
693
+ NO_OFFSCALE_TRANSFORM_VALIDATION_SCHEMA,
694
+ PREFER_TOKEN_POSSIBLE_OPTIONS,
695
+ PREFER_TOKEN_VALIDATION_SCHEMA,
696
+ USE_SCALE_POSSIBLE_OPTIONS,
697
+ USE_SCALE_VALIDATION_SCHEMA,
773
698
  buildScaleOptions,
774
699
  buildTokenOptions,
700
+ createPropertyScaleResolver,
701
+ isPlainObject,
775
702
  resolvePropertyScale,
776
- validateNoOffscaleTransformSecondaryOptions,
777
- validatePreferTokenSecondaryOptions,
778
- validateUseScaleSecondaryOptions,
779
703
  };
@@ -5,7 +5,13 @@ const path = require('node:path');
5
5
 
6
6
  const { parseLengthToken, toPx } = require('./length');
7
7
  const { buildEffectiveTokenMap } = require('./token-map');
8
- const { collectScssTokens, createTokenKindMatcher, parseTokenSources } = require('./token-sources');
8
+ const {
9
+ addDefinition,
10
+ collectScssTokens,
11
+ createTokenKindMatcher,
12
+ parseTokenSources,
13
+ parseTokenValueLength,
14
+ } = require('./token-sources');
9
15
  const { getScalePreset } = require('../presets/scales');
10
16
 
11
17
  // Matches the audit default so lint and audit agree on what a spacing token is.
@@ -47,10 +53,40 @@ function readDirectDependencies(dir) {
47
53
  * installs are found by walking up; a stray global node_modules is never
48
54
  * consulted because the walk stops at the repository.
49
55
  */
50
- function projectRoots(cwd) {
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
+ function projectRoots(cwd, consult = () => {}) {
51
86
  const roots = [];
52
87
  let current = path.resolve(cwd);
53
88
  for (;;) {
89
+ consult(path.join(current, 'package.json'));
54
90
  const direct = readDirectDependencies(current);
55
91
  if (direct) {
56
92
  roots.push({ dir: current, direct });
@@ -65,8 +101,12 @@ function projectRoots(cwd) {
65
101
  }
66
102
 
67
103
  function discoverTokenPackages(cwd = process.cwd()) {
104
+ return cachedByFiles(`packages:${path.resolve(cwd)}`, (consult) => discoverTokenPackagesUncached(cwd, consult));
105
+ }
106
+
107
+ function discoverTokenPackagesUncached(cwd, consult) {
68
108
  const sources = [];
69
- const roots = projectRoots(cwd);
109
+ const roots = projectRoots(cwd, consult);
70
110
  for (const entry of TOKEN_PACKAGES) {
71
111
  // Only packages the project depends on directly count. A transitive
72
112
  // tailwindcss (for example via stylelint-config-tailwindcss) must not hand
@@ -75,14 +115,17 @@ function discoverTokenPackages(cwd = process.cwd()) {
75
115
  if (!owner) {
76
116
  continue;
77
117
  }
78
- const root = roots
79
- .map((candidate) => path.join(candidate.dir, 'node_modules', entry.name))
80
- .find((dir) => fs.existsSync(path.join(dir, 'package.json')));
118
+ const candidates = roots.map((candidate) => path.join(candidate.dir, 'node_modules', entry.name));
119
+ for (const dir of candidates) {
120
+ consult(path.join(dir, 'package.json'));
121
+ }
122
+ const root = candidates.find((dir) => fs.existsSync(path.join(dir, 'package.json')));
81
123
  if (!root) {
82
124
  continue;
83
125
  }
84
126
  for (const file of entry.files) {
85
127
  const resolved = path.join(root, file);
128
+ consult(resolved);
86
129
  if (fs.existsSync(resolved)) {
87
130
  sources.push({
88
131
  format: 'auto',
@@ -176,8 +219,38 @@ function scaleFromSources(sources, baseFontSize) {
176
219
  return outcome;
177
220
  }
178
221
 
179
- /** Build a px scale from token definitions as produced by token-sources.js / contract.js. */
222
+ /**
223
+ * Build a px scale from token definitions. Root-level declarations win: when
224
+ * the definitions declared in `:root`, `html` or `@theme` form a scale on
225
+ * their own, component-local variables (`--chip-spacing: 3px` inside `.chip`)
226
+ * are left out, because they are a component's parameters, not the project's
227
+ * scale (issue #54). When the root does not carry a scale, everything counts
228
+ * and the plausibility check is the backstop. Returns the values and the
229
+ * definitions that produced them, so provenance can name only those files.
230
+ */
231
+ function inferScaleFromDefinitions(definitions, baseFontSize = 16) {
232
+ const rootOnly = new Map();
233
+ for (const [token, definition] of definitions) {
234
+ if (!definition.scopes || definition.scopes.has('root')) {
235
+ rootOnly.set(token, definition);
236
+ }
237
+ }
238
+ if (rootOnly.size > 0 && rootOnly.size < definitions.size) {
239
+ const fromRoot = scaleFromAllDefinitions(rootOnly, baseFontSize);
240
+ if (fromRoot) {
241
+ return { definitions: rootOnly, values: fromRoot };
242
+ }
243
+ }
244
+ const values = scaleFromAllDefinitions(definitions, baseFontSize);
245
+ return values ? { definitions, values } : null;
246
+ }
247
+
180
248
  function scaleFromDefinitions(definitions, baseFontSize = 16) {
249
+ const inferred = inferScaleFromDefinitions(definitions, baseFontSize);
250
+ return inferred ? inferred.values : null;
251
+ }
252
+
253
+ function scaleFromAllDefinitions(definitions, baseFontSize) {
181
254
  const keys = [];
182
255
  const baseKeys = [];
183
256
  for (const definition of definitions.values()) {
@@ -191,6 +264,44 @@ function scaleFromDefinitions(definitions, baseFontSize = 16) {
191
264
  return scale.length >= MIN_INFERRED_SCALE_LENGTH ? scale : null;
192
265
  }
193
266
 
267
+ /** The scope of a declaration node: the AST-side twin of customPropertyDeclarations. */
268
+ function scopeOfNode(decl) {
269
+ for (let node = decl.parent; node; node = node.parent) {
270
+ if (node.type === 'rule') {
271
+ return String(node.selector).split(',').every((selector) => ROOT_SELECTOR.test(selector.trim())) ? 'root' : 'component';
272
+ }
273
+ if (node.type === 'atrule') {
274
+ const name = String(node.name).toLowerCase();
275
+ if (name === 'theme') return 'root';
276
+ if (!TRANSPARENT_AT_RULES.has(name)) return 'component';
277
+ }
278
+ }
279
+ return 'root';
280
+ }
281
+
282
+ const ROOT_SELECTOR = /^(?::root|html|:host|:(?:where|is)\(\s*(?::root|html)\s*\))$/i;
283
+ const TRANSPARENT_AT_RULES = new Set(['media', 'supports', 'layer', 'container', 'scope', 'document']);
284
+
285
+ /** Token definitions declared in the linted stylesheet itself: custom properties with their scope, plus Sass variables and maps. */
286
+ function stylesheetDefinitions(root, tokenRegex, baseFontSize) {
287
+ const definitions = new Map();
288
+ root.walkDecls((decl) => {
289
+ const prop = decl.prop.toLowerCase();
290
+ if (!prop.startsWith('--') || !tokenRegex.test(prop)) {
291
+ return;
292
+ }
293
+ const parsed = parseTokenValueLength(decl.value);
294
+ if (!parsed || parsed.number === 0) {
295
+ return;
296
+ }
297
+ addDefinition(definitions, { baseFontSize, file: 'stylesheet', scope: scopeOfNode(decl), source: 'stylesheet', token: decl.prop, value: decl.value });
298
+ });
299
+ for (const sassToken of sassTokensFromRoot(root)) {
300
+ addDefinition(definitions, { baseFontSize, file: 'stylesheet', scope: 'root', source: 'stylesheet', token: sassToken.token, value: sassToken.value });
301
+ }
302
+ return definitions;
303
+ }
304
+
194
305
  /**
195
306
  * Merge Tailwind-style base multiples into a scale when a bare --spacing/--space
196
307
  * base is defined. Only the first base found is expanded: a project that ships
@@ -225,6 +336,13 @@ function firstPositivePx(keys, baseFontSize) {
225
336
 
226
337
  function rcTokenSources(cwd) {
227
338
  const rcPath = path.join(cwd, RC_FILE);
339
+ return cachedByFiles(`rc:${rcPath}`, (consult) => {
340
+ consult(rcPath);
341
+ return rcTokenSourcesUncached(rcPath);
342
+ });
343
+ }
344
+
345
+ function rcTokenSourcesUncached(rcPath) {
228
346
  if (!fs.existsSync(rcPath)) {
229
347
  return [];
230
348
  }
@@ -251,7 +369,7 @@ function rcTokenSources(cwd) {
251
369
  * same collector the audit uses; component variables such as $dropdown-spacer
252
370
  * are excluded by the anchored name rule.
253
371
  */
254
- function sassValuesFromRoot(root) {
372
+ function sassTokensFromRoot(root) {
255
373
  const lines = [];
256
374
  root.walkDecls((decl) => {
257
375
  if (typeof decl.prop === 'string' && decl.prop.startsWith('$')) {
@@ -261,7 +379,7 @@ function sassValuesFromRoot(root) {
261
379
  if (lines.length === 0) {
262
380
  return [];
263
381
  }
264
- return collectScssTokens(lines.join('\n'), createTokenKindMatcher('spacing')).map((token) => token.value);
382
+ return collectScssTokens(lines.join('\n'), createTokenKindMatcher('spacing'));
265
383
  }
266
384
 
267
385
  function scaleFromTokenMap(map, baseFontSize, extraKeys = []) {
@@ -309,12 +427,7 @@ function resolveAutoScale({
309
427
 
310
428
  let rejected = null;
311
429
  if (root) {
312
- const stylesheetMap = buildEffectiveTokenMap({
313
- options: { baseFontSize, tokenMap: {}, tokenMapFromCssCustomProperties: true },
314
- root,
315
- tokenRegex,
316
- });
317
- const scale = scaleFromTokenMap(stylesheetMap, baseFontSize, sassValuesFromRoot(root));
430
+ const scale = scaleFromDefinitions(stylesheetDefinitions(root, tokenRegex, baseFontSize), baseFontSize);
318
431
  if (scale) {
319
432
  const assessment = assessScale({ source: 'stylesheet', values: scale });
320
433
  if (assessment.plausible) {
@@ -403,6 +516,27 @@ function fallbackInference(rejected = null) {
403
516
  };
404
517
  }
405
518
 
519
+ /**
520
+ * Apply `scale: "auto"` to built rule options: the inferred scale replaces the
521
+ * placeholder and the inference is kept for the fallback note. Options with
522
+ * an explicit scale pass through untouched.
523
+ */
524
+ function withResolvedScale(options, root) {
525
+ if (!options.scaleAuto) {
526
+ return options;
527
+ }
528
+ const inference = resolveAutoScale({
529
+ baseFontSize: options.baseFontSize,
530
+ root,
531
+ scaleSources: options.scaleSources,
532
+ tailwindConfigPath: options.tailwindConfigPath,
533
+ tokenPattern: options.tokenPatternExplicit ? options.tokenPattern : DEFAULT_AUTO_TOKEN_PATTERN,
534
+ });
535
+ options.scale = inference.scale;
536
+ options.scaleInference = inference;
537
+ return options;
538
+ }
539
+
406
540
  function autoScaleFallbackNote(inference) {
407
541
  if (!inference || inference.source !== 'fallback') {
408
542
  return '';
@@ -419,6 +553,8 @@ module.exports = {
419
553
  assessScale,
420
554
  autoScaleFallbackNote,
421
555
  discoverTokenPackages,
556
+ inferScaleFromDefinitions,
422
557
  resolveAutoScale,
423
558
  scaleFromDefinitions,
559
+ withResolvedScale,
424
560
  };