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
@@ -1,5 +1,6 @@
1
1
  'use strict';
2
2
 
3
+ const { MAX_NOTE_LENGTH, normalizeNote, withNote } = require('../../core/options');
3
4
  const { formatTime } = require('../../core/time');
4
5
  const { createTailwindMotionAnalyzer } = require('../../core/tailwind-motion-analysis');
5
6
 
@@ -51,7 +52,7 @@ function maybeCheckNodeText(node, sourceCode, context, analyzer, allowFix) {
51
52
  ? formatTime(analysis.nearest.upper, 'ms')
52
53
  : 'n/a';
53
54
  context.report({
54
- message: buildMessage(analysis, segment, lower, upper),
55
+ message: withNote(buildMessage(analysis, segment, lower, upper), analyzer.note),
55
56
  node,
56
57
  fix:
57
58
  fixedText && analysis.reason === 'duration'
@@ -95,13 +96,16 @@ module.exports = {
95
96
  },
96
97
  type: 'array',
97
98
  },
99
+ note: { maxLength: MAX_NOTE_LENGTH, minLength: 1, type: 'string' },
98
100
  },
99
101
  type: 'object',
100
102
  },
101
103
  ],
102
104
  },
103
105
  create(context) {
104
- const analyzer = createTailwindMotionAnalyzer(context.options && context.options[0]);
106
+ const option = (context.options && context.options[0]) || {};
107
+ const analyzer = createTailwindMotionAnalyzer(option);
108
+ analyzer.note = normalizeNote(option.note);
105
109
  const sourceCode = context.sourceCode || context.getSourceCode();
106
110
 
107
111
  return {
@@ -1,7 +1,7 @@
1
1
  'use strict';
2
2
 
3
- const { formatLength } = require('../../core/length');
4
- const { createTailwindClassAnalyzer } = require('../../core/tailwind-class-analysis');
3
+ const { MAX_NOTE_LENGTH, normalizeNote, withNote } = require('../../core/options');
4
+ const { createTailwindClassAnalyzer, offScaleClassMessage } = require('../../core/tailwind-class-analysis');
5
5
 
6
6
  const RULE_NAME = 'tailwind-class-use-scale';
7
7
 
@@ -44,17 +44,8 @@ function maybeCheckNodeText(node, sourceCode, context, analyzer, allowFix) {
44
44
  : null;
45
45
 
46
46
  for (const { analysis, segment } of findings) {
47
- const lower = analysis.nearest
48
- ? formatLength(analysis.nearest.lower, 'px')
49
- : 'n/a';
50
- const upper = analysis.nearest
51
- ? formatLength(analysis.nearest.upper, 'px')
52
- : 'n/a';
53
47
  context.report({
54
- message:
55
- analysis.reason === 'negative'
56
- ? `Unexpected Tailwind arbitrary spacing value "${segment.token}". Negative values are disabled for this rule.`
57
- : `Unexpected Tailwind arbitrary spacing value "${segment.token}". Use scale values (nearest: ${lower} or ${upper}).`,
48
+ message: withNote(offScaleClassMessage(segment.token, analysis), analyzer.note),
58
49
  node,
59
50
  fix:
60
51
  fixedText && analysis.reason !== 'negative'
@@ -77,6 +68,7 @@ module.exports = {
77
68
  properties: {
78
69
  allowNegative: { type: 'boolean' },
79
70
  baseFontSize: { type: 'number' },
71
+ note: { maxLength: MAX_NOTE_LENGTH, minLength: 1, type: 'string' },
80
72
  scale: {
81
73
  items: {
82
74
  anyOf: [
@@ -86,6 +78,12 @@ module.exports = {
86
78
  },
87
79
  type: 'array',
88
80
  },
81
+ spacingUnit: {
82
+ anyOf: [
83
+ { exclusiveMinimum: true, minimum: 0, type: 'number' },
84
+ { enum: [false] },
85
+ ],
86
+ },
89
87
  units: {
90
88
  items: { type: 'string' },
91
89
  type: 'array',
@@ -96,7 +94,9 @@ module.exports = {
96
94
  ],
97
95
  },
98
96
  create(context) {
99
- const analyzer = createTailwindClassAnalyzer(context.options && context.options[0]);
97
+ const option = (context.options && context.options[0]) || {};
98
+ const analyzer = createTailwindClassAnalyzer(option);
99
+ analyzer.note = normalizeNote(option.note);
100
100
  const sourceCode = context.sourceCode || context.getSourceCode();
101
101
 
102
102
  return {
@@ -3,7 +3,6 @@
3
3
  const stylelint = require('stylelint');
4
4
  const valueParser = require('postcss-value-parser');
5
5
  const {
6
- fixedLengthValue,
7
6
  formatLength,
8
7
  isHairlineLength,
9
8
  nearestScaleValues,
@@ -23,19 +22,22 @@ const {
23
22
  } = require('../../core/value-nodes');
24
23
 
25
24
  const {
25
+ collectTokenDefinitions,
26
26
  withResolvedScale,
27
27
  } = require('../../core/scale-inference');
28
28
 
29
29
  const { validatePrimary, validateNoOffscaleTransformSecondaryOptions } = require('../validate');
30
30
 
31
- const { reportInvalidPreset, reportValueNode } = require('../report');
31
+ const { reportInvalidPreset, reportProblem, reportValueNode } = require('../report');
32
+ const { decisionFor, loadRcDecisions } = require('../../core/decisions');
33
+ const { replacementFor, tokenHoldsNote, tokenIndexFromDefinitions } = require('../../core/token-index');
32
34
 
33
35
  const ruleName = 'rhythmguard/no-offscale-transform';
34
36
  const messages = stylelint.utils.ruleMessages(ruleName, {
35
37
  invalidPreset: (presetName, presetNames) =>
36
38
  `Unknown scale preset "${presetName}". Available presets: ${presetNames.join(', ')}.`,
37
- rejected: (value, lower, upper) =>
38
- `Unexpected transform translation value "${value}". Use scale values (nearest: ${lower} or ${upper}).`,
39
+ rejected: (value, lower, upper, note = '') =>
40
+ `Unexpected transform translation value "${value}". Use scale values (nearest: ${lower} or ${upper}).${note ? ` ${note}` : ''}`,
39
41
  });
40
42
 
41
43
  const ruleFunction = (primary, secondaryOptions) => {
@@ -57,8 +59,20 @@ const ruleFunction = (primary, secondaryOptions) => {
57
59
 
58
60
  const options = buildScaleOptions(secondaryOptions);
59
61
  reportInvalidPreset(options, { message: messages.invalidPreset, result, root, ruleName });
62
+ try {
63
+ options.decisions = options.readDecisions ? loadRcDecisions(process.cwd(), { baseFontSize: options.baseFontSize }) : [];
64
+ } catch (error) {
65
+ reportProblem(error.message, { result, root, ruleName });
66
+ options.decisions = [];
67
+ }
60
68
 
61
69
  withResolvedScale(options, root);
70
+ if (options.fixWith === 'token') {
71
+ options.tokenIndex = tokenIndexFromDefinitions(
72
+ collectTokenDefinitions({ baseFontSize: options.baseFontSize, root, scaleSources: options.scaleSources, tokenRegex: new RegExp(options.tokenPattern) }),
73
+ options.baseFontSize,
74
+ );
75
+ }
62
76
 
63
77
  const getScaleStateForProperty = createPropertyScaleResolver(options);
64
78
 
@@ -79,6 +93,7 @@ const ruleFunction = (primary, secondaryOptions) => {
79
93
  node.value,
80
94
  formatLength(nearest.lower, nearestUnit),
81
95
  formatLength(nearest.upper, nearestUnit),
96
+ [tokenHoldsNote(fixedValue, formatLength(nearest.nearest, nearestUnit)), options.note].filter(Boolean).join(' '),
82
97
  ),
83
98
  node,
84
99
  replacement: fixedValue,
@@ -117,6 +132,12 @@ const ruleFunction = (primary, secondaryOptions) => {
117
132
  return;
118
133
  }
119
134
 
135
+ const decidedPx = toPx(Math.abs(parsedLength.number), parsedLength.unit, options.baseFontSize);
136
+ const decision = decidedPx === null ? null : decisionFor(decidedPx, prop, options.decisions);
137
+ if (decision && (decision.decision === 'adopt' || decision.decision === 'allow')) {
138
+ return;
139
+ }
140
+
120
141
  if (options.unitStrategy === 'exact') {
121
142
  const unit = parsedLength.unit || 'px';
122
143
  const unitScale = scaleByUnit.get(unit);
@@ -136,7 +157,7 @@ const ruleFunction = (primary, secondaryOptions) => {
136
157
  }
137
158
 
138
159
  const fixedValue = options.fixToScale
139
- ? fixedLengthValue(parsedLength, nearest.nearest, options)
160
+ ? replacementFor(parsedLength, nearest.nearest, options)
140
161
  : null;
141
162
 
142
163
  report(node, nearest, unit, fixedValue);
@@ -160,7 +181,7 @@ const ruleFunction = (primary, secondaryOptions) => {
160
181
  }
161
182
 
162
183
  const fixedValue = options.fixToScale
163
- ? fixedLengthValue(parsedLength, nearest.nearest, options)
184
+ ? replacementFor(parsedLength, nearest.nearest, options)
164
185
  : null;
165
186
 
166
187
  report(node, nearest, 'px', fixedValue);
@@ -31,45 +31,34 @@ const {
31
31
  const { createTokenRegex, reportInvalidPreset, reportValueNode } = require('../report');
32
32
  const { validatePrimary, validatePreferTokenSecondaryOptions } = require('../validate');
33
33
 
34
+ const { negateReplacement } = require('../../core/token-index');
35
+
34
36
  const ruleName = 'rhythmguard/prefer-token';
35
37
 
36
38
  const messages = stylelint.utils.ruleMessages(ruleName, {
37
39
  invalidPreset: (presetName, presetNames) =>
38
40
  `Unknown scale preset "${presetName}". Available presets: ${presetNames.join(', ')}.`,
39
- rejected: (value) =>
40
- `Unexpected raw scale value "${value}". Use design tokens for scale decisions.`,
41
+ rejected: (value, replacement = null, origin = null, note = '') =>
42
+ `Unexpected raw scale value "${value}". ${replacement
43
+ ? `Use ${replacement}${origin ? ` (${origin})` : ''}.`
44
+ : 'No known token holds this value; use the nearest token or add one.'}${note ? ` ${note}` : ''}`,
41
45
  });
42
46
 
43
47
  function applyNegativeToken(replacement, parsedLength) {
44
- if (!replacement || parsedLength.number >= 0) {
45
- return replacement;
46
- }
47
-
48
- if (replacement.startsWith('-')) {
49
- return replacement;
50
- }
51
-
52
- if (
53
- replacement.startsWith('var(') ||
54
- replacement.startsWith('theme(') ||
55
- replacement.startsWith('token(') ||
56
- replacement.startsWith('$') ||
57
- replacement.startsWith('@')
58
- ) {
59
- return `-${replacement}`;
60
- }
61
-
62
- return `calc(${replacement} * -1)`;
48
+ return !replacement || parsedLength.number >= 0 ? replacement : negateReplacement(replacement);
63
49
  }
64
50
 
51
+ /** The token for a raw length, and the text the fix writes (negated when the literal is). */
65
52
  function resolveTokenReplacement(tokenMap, raw, parsedLength, options) {
53
+ const found = (token) => ({ replacement: applyNegativeToken(token, parsedLength), token });
54
+
66
55
  if (Object.prototype.hasOwnProperty.call(tokenMap, raw)) {
67
- return applyNegativeToken(tokenMap[raw], parsedLength);
56
+ return found(tokenMap[raw]);
68
57
  }
69
58
 
70
59
  const absoluteRaw = formatLength(Math.abs(parsedLength.number), parsedLength.unit || 'px');
71
60
  if (Object.prototype.hasOwnProperty.call(tokenMap, absoluteRaw)) {
72
- return applyNegativeToken(tokenMap[absoluteRaw], parsedLength);
61
+ return found(tokenMap[absoluteRaw]);
73
62
  }
74
63
 
75
64
  if (options.unitStrategy === 'convert') {
@@ -77,7 +66,7 @@ function resolveTokenReplacement(tokenMap, raw, parsedLength, options) {
77
66
  if (absPx !== null) {
78
67
  const pxKey = `${absPx}px`;
79
68
  if (Object.prototype.hasOwnProperty.call(tokenMap, pxKey)) {
80
- return applyNegativeToken(tokenMap[pxKey], parsedLength);
69
+ return found(tokenMap[pxKey]);
81
70
  }
82
71
  }
83
72
  }
@@ -108,8 +97,10 @@ const ruleFunction = (primary, secondaryOptions) => {
108
97
  withResolvedScale(options, root);
109
98
 
110
99
  const tokenRegex = createTokenRegex(options.tokenPattern, result, ruleName);
100
+ const origins = {};
111
101
  const tokenMap = buildEffectiveTokenMap({
112
102
  options,
103
+ origins,
113
104
  root,
114
105
  tokenRegex,
115
106
  });
@@ -130,8 +121,10 @@ const ruleFunction = (primary, secondaryOptions) => {
130
121
  const { scaleByUnit, scalePx } = getScaleStateForProperty(prop);
131
122
  let changed = false;
132
123
 
133
- const reportNode = (node, replacement = null) => {
134
- reportValueNode({ decl, message: messages.rejected(node.value), node, replacement, result, ruleName });
124
+ const reportNode = (node, resolved = null) => {
125
+ const replacement = resolved ? resolved.replacement : null;
126
+ const origin = resolved ? origins[resolved.token] || null : null;
127
+ reportValueNode({ decl, message: messages.rejected(node.value, replacement, origin, options.note), node, replacement, result, ruleName });
135
128
  };
136
129
 
137
130
  const checkWordNode = (node, context) => {
@@ -197,9 +190,7 @@ const ruleFunction = (primary, secondaryOptions) => {
197
190
  }
198
191
  }
199
192
 
200
- const replacement = resolveTokenReplacement(tokenMap, node.value, parsedLength, options);
201
-
202
- reportNode(node, replacement);
193
+ reportNode(node, resolveTokenReplacement(tokenMap, node.value, parsedLength, options));
203
194
  return true;
204
195
  };
205
196
 
@@ -68,8 +68,14 @@ function reportInvalidPreset(options, { message, result, root, ruleName }) {
68
68
  });
69
69
  }
70
70
 
71
+ /** A configuration problem that is not about one node: reported once, on the root. */
72
+ function reportProblem(message, { result, root, ruleName }) {
73
+ stylelint.utils.report({ message, node: root, result, ruleName });
74
+ }
75
+
71
76
  module.exports = {
72
77
  createTokenRegex,
73
78
  reportInvalidPreset,
79
+ reportProblem,
74
80
  reportValueNode,
75
81
  };
@@ -20,6 +20,7 @@ const {
20
20
 
21
21
  const { reportValueNode } = require('../report');
22
22
  const { validatePrimary } = require('../validate');
23
+ const { isNote, normalizeNote, withNote } = require('../../core/options');
23
24
 
24
25
  const ruleName = 'rhythmguard/use-motion-scale';
25
26
  const DURATION_PROPERTIES = new Set([
@@ -39,12 +40,12 @@ const EASING_PROPERTIES = new Set([
39
40
  const EASING_FUNCTIONS = new Set(['cubic-bezier', 'linear', 'steps']);
40
41
 
41
42
  const messages = stylelint.utils.ruleMessages(ruleName, {
42
- invalidDuration: (value) =>
43
- `Unexpected negative motion duration "${value}". Use non-negative duration values.`,
44
- rejectedDuration: (value, lower, upper) =>
45
- `Unexpected motion duration "${value}". Use duration scale values (nearest: ${lower} or ${upper}).`,
46
- rejectedEasing: (value) =>
47
- `Unexpected raw motion easing "${value}". Use motion tokens for easing decisions.`,
43
+ invalidDuration: (value, note = '') =>
44
+ withNote(`Unexpected negative motion duration "${value}". Use non-negative duration values.`, note),
45
+ rejectedDuration: (value, lower, upper, note = '') =>
46
+ withNote(`Unexpected motion duration "${value}". Use duration scale values (nearest: ${lower} or ${upper}).`, note),
47
+ rejectedEasing: (value, note = '') =>
48
+ withNote(`Unexpected raw motion easing "${value}". Use motion tokens for easing decisions.`, note),
48
49
  });
49
50
 
50
51
  function isPlainObject(value) {
@@ -59,6 +60,7 @@ function buildOptions(rawOptions) {
59
60
  durationUnits: normalizeDurationUnits(options.durationUnits),
60
61
  easingTokenMap: isPlainObject(options.easingTokenMap) ? options.easingTokenMap : {},
61
62
  fixToScale: options.fixToScale !== false,
63
+ note: normalizeNote(options.note),
62
64
  };
63
65
  }
64
66
 
@@ -78,6 +80,7 @@ function validateSecondaryOptions(result, secondaryOptions) {
78
80
  typeof entry === 'string' && entry.trim().length > 0,
79
81
  )],
80
82
  fixToScale: [true, false],
83
+ note: [isNote],
81
84
  },
82
85
  });
83
86
  }
@@ -124,8 +127,9 @@ const ruleFunction = (primary, secondaryOptions) => {
124
127
  node.value,
125
128
  formatTime(nearest.lower, 'ms'),
126
129
  formatTime(nearest.upper, 'ms'),
130
+ options.note,
127
131
  )
128
- : messages.invalidDuration(node.value),
132
+ : messages.invalidDuration(node.value, options.note),
129
133
  node,
130
134
  replacement: fixedValue,
131
135
  result,
@@ -146,7 +150,7 @@ const ruleFunction = (primary, secondaryOptions) => {
146
150
  }
147
151
  : null,
148
152
  length: source.length,
149
- message: messages.rejectedEasing(source),
153
+ message: messages.rejectedEasing(source, options.note),
150
154
  node,
151
155
  result,
152
156
  ruleName,
@@ -3,7 +3,6 @@
3
3
  const stylelint = require('stylelint');
4
4
  const valueParser = require('postcss-value-parser');
5
5
  const {
6
- fixedLengthValue,
7
6
  formatLength,
8
7
  isHairlineLength,
9
8
  nearestScaleValues,
@@ -27,10 +26,13 @@ const {
27
26
 
28
27
  const {
29
28
  autoScaleFallbackNote,
29
+ collectTokenDefinitions,
30
30
  withResolvedScale,
31
31
  } = require('../../core/scale-inference');
32
32
 
33
- const { createTokenRegex, reportInvalidPreset, reportValueNode } = require('../report');
33
+ const { createTokenRegex, reportInvalidPreset, reportProblem, reportValueNode } = require('../report');
34
+ const { decisionFor, loadRcDecisions } = require('../../core/decisions');
35
+ const { replacementFor, tokenHoldsNote, tokenIndexFromDefinitions } = require('../../core/token-index');
34
36
  const { validatePrimary, validateUseScaleSecondaryOptions } = require('../validate');
35
37
 
36
38
  const ruleName = 'rhythmguard/use-scale';
@@ -42,6 +44,19 @@ const messages = stylelint.utils.ruleMessages(ruleName, {
42
44
  `Unexpected off-scale value "${value}". Use scale values (nearest: ${lower} or ${upper}).${note ? ` ${note}` : ''}`,
43
45
  });
44
46
 
47
+ /** Decisions from .rhythmguardrc.json; an invalid section is reported once and ignored. */
48
+ function readDecisions(options, { result, root }) {
49
+ if (!options.readDecisions) {
50
+ return [];
51
+ }
52
+ try {
53
+ return loadRcDecisions(process.cwd(), { baseFontSize: options.baseFontSize });
54
+ } catch (error) {
55
+ reportProblem(error.message, { result, root, ruleName });
56
+ return [];
57
+ }
58
+ }
59
+
45
60
  function checkLengthValue({
46
61
  decl,
47
62
  node,
@@ -90,6 +105,12 @@ function checkLengthValue({
90
105
  return false;
91
106
  }
92
107
 
108
+ const decidedPx = toPx(Math.abs(parsedLength.number), parsedLength.unit, options.baseFontSize);
109
+ const decision = decidedPx === null ? null : decisionFor(decidedPx, decl.prop, options.decisions);
110
+ if (decision && (decision.decision === 'adopt' || decision.decision === 'allow')) {
111
+ return false;
112
+ }
113
+
93
114
  if (options.unitStrategy === 'exact') {
94
115
  const unit = parsedLength.unit || 'px';
95
116
  const unitScale = scaleByUnit.get(unit);
@@ -110,7 +131,7 @@ function checkLengthValue({
110
131
  }
111
132
 
112
133
  const fixedValue = options.fixToScale
113
- ? fixedLengthValue(parsedLength, nearest.nearest, options)
134
+ ? replacementFor(parsedLength, nearest.nearest, options)
114
135
  : null;
115
136
 
116
137
  report(node.value, decl, node, nearest, fixedValue, unit);
@@ -134,7 +155,7 @@ function checkLengthValue({
134
155
  }
135
156
 
136
157
  const fixedValue = options.fixToScale
137
- ? fixedLengthValue(parsedLength, nearest.nearest, options)
158
+ ? replacementFor(parsedLength, nearest.nearest, options)
138
159
  : null;
139
160
 
140
161
  report(node.value, decl, node, nearest, fixedValue, 'px');
@@ -160,19 +181,27 @@ const ruleFunction = (primary, secondaryOptions) => {
160
181
 
161
182
  const options = buildScaleOptions(secondaryOptions);
162
183
  reportInvalidPreset(options, { message: messages.invalidPreset, result, root, ruleName });
163
-
184
+ options.decisions = readDecisions(options, { result, root });
164
185
  withResolvedScale(options, root);
165
186
 
166
187
  const tokenRegex = createTokenRegex(options.tokenPattern, result, ruleName);
188
+ if (options.fixWith === 'token') {
189
+ options.tokenIndex = tokenIndexFromDefinitions(
190
+ collectTokenDefinitions({ baseFontSize: options.baseFontSize, root, scaleSources: options.scaleSources, tokenRegex }),
191
+ options.baseFontSize,
192
+ );
193
+ }
194
+
167
195
  let fallbackNote = autoScaleFallbackNote(options.scaleInference);
168
196
  const getScaleStateForProperty = createPropertyScaleResolver(options);
169
197
 
170
198
  const report = (value, decl, node, nearest, fixedValue = null, nearestUnit = 'px') => {
171
199
  const lower = nearest ? formatLength(nearest.lower, nearestUnit) : 'n/a';
172
200
  const upper = nearest ? formatLength(nearest.upper, nearestUnit) : 'n/a';
201
+ const tokenNote = nearest ? tokenHoldsNote(fixedValue, formatLength(nearest.nearest, nearestUnit)) : '';
173
202
  reportValueNode({
174
203
  decl,
175
- message: messages.rejected(value, lower, upper, fallbackNote),
204
+ message: messages.rejected(value, lower, upper, [fallbackNote, tokenNote, options.note].filter(Boolean).join(' ')),
176
205
  node,
177
206
  replacement: fixedValue,
178
207
  result,
package/types/audit.d.ts CHANGED
@@ -98,6 +98,33 @@ export interface AuditBaselineComparison {
98
98
 
99
99
  export type AuditScaleSource = "default" | "explicit" | "fallback" | "scanned-css" | "token-package" | "token-sources";
100
100
 
101
+ export type AuditDecisionKind = "adopt" | "allow" | "snap" | "undecided";
102
+
103
+ /** One entry of the `decisions` section of `.rhythmguardrc.json`. */
104
+ export interface AuditDecision {
105
+ value: string;
106
+ decision: AuditDecisionKind;
107
+ /** For `adopt`: the token the value should become. */
108
+ as?: string;
109
+ /** For `allow`: property names or `prefix-*` patterns the allowance is limited to. */
110
+ properties?: string[];
111
+ reason?: string;
112
+ }
113
+
114
+ export interface AuditDecisionSummary {
115
+ adopt: number;
116
+ allow: number;
117
+ snap: number;
118
+ undecided: number;
119
+ suppressed: number;
120
+ }
121
+
122
+ /** One line of `rhythmguard audit --plan`: a decision entry with what the audit saw. */
123
+ export interface AuditDecisionPlanEntry extends AuditDecision {
124
+ count: number;
125
+ nearest: string[];
126
+ }
127
+
101
128
  export interface AuditScaleRejected {
102
129
  files?: string[];
103
130
  /** Why the inferred set was not accepted as a scale, for example "no common step". */
@@ -117,6 +144,8 @@ export interface AuditScale {
117
144
  }
118
145
 
119
146
  export interface AuditReport {
147
+ decisions?: AuditDecisionSummary | null;
148
+ decisionPlan?: AuditDecisionPlanEntry[];
120
149
  baseline?: AuditBaselineComparison | null;
121
150
  scale?: AuditScale | null;
122
151
  config?: string | null;
@@ -143,6 +172,8 @@ export interface AuditContractReport {
143
172
  scanScope: string;
144
173
  };
145
174
  contracts: {
175
+ /** Counts per decision kind and how many findings the decisions suppressed; null when the config has no decisions. */
176
+ decisions: AuditDecisionSummary | null;
146
177
  motion?: unknown;
147
178
  scale: {
148
179
  cleanliness?: unknown;
package/types/shared.d.ts CHANGED
@@ -17,6 +17,12 @@ export interface ScaleSource {
17
17
  export interface RhythmguardRuleOptions {
18
18
  /** Exempt non-zero lengths of one CSS pixel or less (1px, -1px, 0.5px, 0.0625rem). Default true. */
19
19
  allowHairlines?: boolean;
20
+ /** What autofix writes: the nearest literal (default) or, with "token", `var(--name)` when exactly one custom property holds that value in the same unit. */
21
+ fixWith?: "value" | "token";
22
+ /** Read the `decisions` section of `.rhythmguardrc.json` (default true). The audit sets false and applies decisions itself. */
23
+ decisions?: boolean;
24
+ /** A sentence of up to 200 characters appended to every finding, after the built-in text. */
25
+ note?: string;
20
26
  baseFontSize?: number;
21
27
  customScale?: ScaleValue[];
22
28
  includeMathFunctions?: boolean;