stylelint-plugin-rhythmguard 3.2.0 → 3.3.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.
package/CHANGELOG.md CHANGED
@@ -6,6 +6,22 @@ The format follows Keep a Changelog principles and semantic versioning.
6
6
 
7
7
  ## [Unreleased]
8
8
 
9
+ ## [3.3.0] - 2026-09-06
10
+
11
+ ### Fixed
12
+
13
+ - Whole numbers ending in zero were printed without their zeros: a scale containing `30px` and `60px` produced messages such as `nearest: 3px or 6px`, autofix wrote `3px` for a value snapped to `30px`, and scale inference added phantom small steps (`3px`, `6px`) to every scale that contained `30px` or `60px`. This affected all three Stylelint rules, the ESLint rule's suggested classes, the audit and `scale: "auto"`. Found while reading why GOV.UK's scale came out as `1, 2, 3, 4, 5, 6, 10, 15, ...`. If you use a scale with round-ten values, rerun `--fix` on files that were fixed with an earlier version.
14
+ - `scale: "auto"` accepts tokens named `spacer`, so PatternFly's `--pf-t--global--spacer--*` ladder is read instead of falling back. ([#85](https://github.com/PetriLahdelma/stylelint-plugin-rhythmguard/issues/85))
15
+ - Token-package discovery reads `@carbon/layout`, where Carbon defines `$spacing-01` to `$spacing-13`. `.scss` files are accepted as token sources, in `scaleSources` and `--token-source` too. ([#87](https://github.com/PetriLahdelma/stylelint-plugin-rhythmguard/issues/87))
16
+ - `scale: "auto"` and `--scale auto` reject an inferred scale that does not look like one: fewer than three steps, fractional values, no common step (2, 3, 4, 5 or 8), or, in the audit, tokens that come mostly from component files without a near-perfect ladder. The rule then uses the preset and says why in its first message; the audit reports `contracts.scale.rejected` with the source, values and reasons, and the Markdown and text output show it next to the scale source. Component-local variables such as `--chip-spacing: 3px` can no longer become the project scale. ([#88](https://github.com/PetriLahdelma/stylelint-plugin-rhythmguard/issues/88))
17
+ - Sass spacing maps with one namespace segment before the anchor, such as GOV.UK's `$govuk-spacing-points` and NHS.UK's `$nhsuk-spacing-points`, are read when they hold at least four distinct lengths. Namespaced scalars such as `$dropdown-spacer` and small maps stay excluded, and unitless Sass numbers (mixin parameter defaults such as `$spacing-responsive: 6`, multipliers such as `$spacing-unit: 8`) are no longer taken as lengths. ([#86](https://github.com/PetriLahdelma/stylelint-plugin-rhythmguard/issues/86))
18
+ - When several Tailwind v4 `--spacing` bases are found, only the first is expanded. A union of ladders from several theme files produced fractional steps that matched nothing on shadcn/ui. ([#89](https://github.com/PetriLahdelma/stylelint-plugin-rhythmguard/issues/89))
19
+
20
+ ### Changed
21
+
22
+ - The quiet benchmark manifest grew from 20 to 57 repositories: design systems (GOV.UK, NHS.UK, PatternFly, Cloudscape, Angular Material, Ionic, Element Plus, HashiCorp, SAP Fundamental Styles and UI5 Web Components, daisyUI, Skeleton, Semi, n8n, Utrecht, Mozilla Protocol, Foundation, CoreUI, Tabler, AdminLTE, Materialize, Pure, Tachyons) and CSS-heavy applications (Mastodon already; now Forem, Discourse, Zulip, Mattermost, Grafana, Metabase, Directus, Excalidraw, Ghost, Cal.com, VS Code base UI, Docusaurus, VitePress and Starlight themes). Every repository has a pinned snapshot and the State of Spacing edition is regenerated over the full set.
23
+ - `npm run bench:outreach` drafts one audit issue per benchmark repository under `docs/outreach/audits/`; a separate paced posting script runs a per-repository preflight and records every outcome in `docs/outreach/embed-log.md`.
24
+
9
25
  ## [3.2.0] - 2026-09-06
10
26
 
11
27
  ### Added
package/README.md CHANGED
@@ -1,5 +1,5 @@
1
1
  <p align="center">
2
- <img src="https://raw.githubusercontent.com/petrilahdelma/stylelint-plugin-rhythmguard/main/assets/rhythmguard-banner.svg?v=5" width="100%" alt="Rhythmguard banner showing spacing scale ruler and lint output" />
2
+ <img src="https://raw.githubusercontent.com/petrilahdelma/stylelint-plugin-rhythmguard/main/assets/rhythmguard-banner.svg?v=6" width="100%" alt="Rhythmguard banner showing spacing scale ruler and lint output" />
3
3
  </p>
4
4
 
5
5
  # stylelint-plugin-rhythmguard
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "stylelint-plugin-rhythmguard",
3
- "version": "3.2.0",
3
+ "version": "3.3.0",
4
4
  "description": "Nobody chose 13px. Catches off-scale spacing in CSS and Tailwind class strings and snaps it to your scale or tokens. Stylelint rules, an ESLint companion, and an audit CLI.",
5
5
  "bin": {
6
6
  "rhythmguard": "src/cli/index.js"
@@ -116,7 +116,8 @@
116
116
  "test:watch": "node --test --watch test/*.test.js",
117
117
  "typecheck": "tsc -p tsconfig.typecheck.json",
118
118
  "build:agents": "node scripts/build-agents.mjs",
119
- "bench:state-of-spacing": "node scripts/bench/state-of-spacing.mjs"
119
+ "bench:state-of-spacing": "node scripts/bench/state-of-spacing.mjs",
120
+ "bench:outreach": "node scripts/bench/outreach.mjs"
120
121
  },
121
122
  "repository": {
122
123
  "type": "git",
@@ -394,6 +394,7 @@ function toAuditContractReport(report) {
394
394
  files: report.scale ? report.scale.files : [],
395
395
  offScaleProperties: report.offScaleProperties || {},
396
396
  offScaleValues: report.offScaleValues,
397
+ rejected: report.scale && report.scale.rejected ? report.scale.rejected : null,
397
398
  source: report.scale ? report.scale.source : 'default',
398
399
  tokenOpportunities: report.tokenOpportunities,
399
400
  values: report.scale ? report.scale.values : null,
@@ -4,6 +4,9 @@ const { sortCountMap } = require('./contract');
4
4
  const { escapeMarkdown } = require('./render-utils');
5
5
 
6
6
  function describeScaleSource(scale) {
7
+ if (scale.rejected) {
8
+ return `${scale.source} (${scale.rejected.source} rejected: ${scale.rejected.reasons.join(', ')})`;
9
+ }
7
10
  return scale.files.length > 0
8
11
  ? `${scale.source} (${scale.files.join(', ')})`
9
12
  : scale.source;
@@ -28,7 +28,8 @@ function renderText(report) {
28
28
  if (report.scale) {
29
29
  lines.push(` Scale ${report.scale.values.join(', ')}`);
30
30
  const files = report.scale.files.length > 0 ? ` (${report.scale.files.join(', ')})` : '';
31
- lines.push(` Scale source ${report.scale.source}${files}`);
31
+ const rejected = report.scale.rejected ? ` (${report.scale.rejected.source} rejected: ${report.scale.rejected.reasons.join(', ')})` : '';
32
+ lines.push(` Scale source ${report.scale.source}${files}${rejected}`);
32
33
  }
33
34
  lines.push('');
34
35
 
@@ -18,7 +18,8 @@ const {
18
18
  } = require('./config');
19
19
  const { buildReport, collectTokenDefinitions } = require('./contract');
20
20
  const { DEFAULT_SCALE, formatPath } = require('./shared');
21
- const { discoverTokenPackages, scaleFromDefinitions } = require('../utils/scale-inference');
21
+ const {
22
+ assessScale, discoverTokenPackages, scaleFromDefinitions } = require('../utils/scale-inference');
22
23
  const {
23
24
  assertDirectory,
24
25
  collectCssFindings,
@@ -129,6 +130,7 @@ function resolveAuditScale({ baseFontSize, cssFiles, requested, tokenSourceResul
129
130
  }
130
131
  }
131
132
 
133
+ let rejected = null;
132
134
  const definitions = new Map();
133
135
  const matchesKind = createTokenKindMatcher('spacing');
134
136
  for (const filePath of cssFiles) {
@@ -155,12 +157,17 @@ function resolveAuditScale({ baseFontSize, cssFiles, requested, tokenSourceResul
155
157
  files.add(file);
156
158
  }
157
159
  }
158
- return {
159
- files: Array.from(files).sort(),
160
- source: 'scanned-css',
161
- tokenCount: definitions.size,
162
- values,
163
- };
160
+ const sortedFiles = Array.from(files).sort();
161
+ const assessment = assessScale({ files: sortedFiles, source: 'scanned-css', values });
162
+ if (assessment.plausible) {
163
+ return {
164
+ files: sortedFiles,
165
+ source: 'scanned-css',
166
+ tokenCount: definitions.size,
167
+ values,
168
+ };
169
+ }
170
+ rejected = { files: sortedFiles, reasons: assessment.reasons, source: 'scanned-css', values };
164
171
  }
165
172
  }
166
173
 
@@ -178,7 +185,7 @@ function resolveAuditScale({ baseFontSize, cssFiles, requested, tokenSourceResul
178
185
  }
179
186
  }
180
187
 
181
- return { files: [], source: 'fallback', tokenCount: 0, values: DEFAULT_SCALE };
188
+ return { files: [], ...(rejected ? { rejected } : {}), source: 'fallback', tokenCount: 0, values: DEFAULT_SCALE };
182
189
  }
183
190
 
184
191
  function normalizeCreateAuditOptions(options = {}) {
@@ -160,7 +160,10 @@ async function run() {
160
160
  out.push(' Scale');
161
161
  out.push(` Scale ${scale.values.join(', ')}`);
162
162
  out.push(` Source ${describeScale(scale)}`);
163
- if (scale.source === 'fallback') {
163
+ if (scale.source === 'fallback' && scale.rejected) {
164
+ out.push(` Note Spacing tokens were found but do not form a scale (${scale.rejected.reasons.join(', ')}); the rhythmic-4 preset is being used.`);
165
+ out.push(' Point scaleSources at your real token file, or tighten tokenPattern.');
166
+ } else if (scale.source === 'fallback') {
164
167
  out.push(' Note No spacing tokens found; the rhythmic-4 preset is being used.');
165
168
  out.push(' Add scaleSources or define --space-*/--spacing-* custom properties to lint against your own scale.');
166
169
  }
@@ -69,8 +69,10 @@ function formatNumber(value) {
69
69
  return '0';
70
70
  }
71
71
 
72
+ // String() already prints the shortest form (1.5, not 1.50). The old
73
+ // trailing-zero strip also turned 30 into 3 and 100 into 1.
72
74
  const rounded = Math.round(value * 10000) / 10000;
73
- return String(rounded).replace(/\.?0+$/, '');
75
+ return String(rounded);
74
76
  }
75
77
 
76
78
  function formatLength(number, unit) {
@@ -9,7 +9,7 @@ const { collectScssTokens, createTokenKindMatcher, parseTokenSources } = require
9
9
  const { getScalePreset } = require('../presets/scales');
10
10
 
11
11
  // Matches the audit default so lint and audit agree on what a spacing token is.
12
- const DEFAULT_AUTO_TOKEN_PATTERN = '(^--|-)(?<!letter-)(?<!word-)(space|spacing)(-|$)';
12
+ const DEFAULT_AUTO_TOKEN_PATTERN = '(^--|-)(?<!letter-)(?<!word-)(space|spacing|spacer)(-|$)';
13
13
  // Tailwind v4 defines one base (`--spacing: 0.25rem`) and derives utilities by multiplying it.
14
14
  const TAILWIND_BASE_TOKENS = new Set(['--spacing', '--space']);
15
15
  const TAILWIND_SPACING_MULTIPLIERS = [
@@ -191,21 +191,38 @@ function scaleFromDefinitions(definitions, baseFontSize = 16) {
191
191
  return scale.length >= MIN_INFERRED_SCALE_LENGTH ? scale : null;
192
192
  }
193
193
 
194
- /** Merge Tailwind-style base multiples into a scale when a bare --spacing/--space base is defined. */
194
+ /**
195
+ * Merge Tailwind-style base multiples into a scale when a bare --spacing/--space
196
+ * base is defined. Only the first base found is expanded: a project that ships
197
+ * several theme files with different bases (shadcn/ui) has one active base at a
198
+ * time, and a union of ladders is a scale nobody designed (issue #89).
199
+ */
195
200
  function expandTailwindBase(scale, baseKeys, baseFontSize) {
196
- const bases = pxValuesFromKeys(baseKeys, baseFontSize).filter((value) => value > 0);
197
- if (bases.length === 0) {
201
+ const base = firstPositivePx(baseKeys, baseFontSize);
202
+ if (base === null) {
198
203
  return scale;
199
204
  }
200
205
  const values = new Set(scale);
201
- for (const base of bases) {
202
- for (const multiplier of TAILWIND_SPACING_MULTIPLIERS) {
203
- values.add(Math.round(base * multiplier * 1000) / 1000);
204
- }
206
+ for (const multiplier of TAILWIND_SPACING_MULTIPLIERS) {
207
+ values.add(Math.round(base * multiplier * 1000) / 1000);
205
208
  }
206
209
  return Array.from(values).sort((a, b) => a - b);
207
210
  }
208
211
 
212
+ function firstPositivePx(keys, baseFontSize) {
213
+ for (const key of keys) {
214
+ const parsed = parseLengthToken(String(key));
215
+ if (!parsed) {
216
+ continue;
217
+ }
218
+ const px = toPx(Math.abs(parsed.number), parsed.unit || 'px', baseFontSize);
219
+ if (px !== null && Number.isFinite(px) && px > 0) {
220
+ return px;
221
+ }
222
+ }
223
+ return null;
224
+ }
225
+
209
226
  function rcTokenSources(cwd) {
210
227
  const rcPath = path.join(cwd, RC_FILE);
211
228
  if (!fs.existsSync(rcPath)) {
@@ -290,6 +307,7 @@ function resolveAutoScale({
290
307
  tokenRegex = new RegExp(DEFAULT_AUTO_TOKEN_PATTERN);
291
308
  }
292
309
 
310
+ let rejected = null;
293
311
  if (root) {
294
312
  const stylesheetMap = buildEffectiveTokenMap({
295
313
  options: { baseFontSize, tokenMap: {}, tokenMapFromCssCustomProperties: true },
@@ -298,7 +316,11 @@ function resolveAutoScale({
298
316
  });
299
317
  const scale = scaleFromTokenMap(stylesheetMap, baseFontSize, sassValuesFromRoot(root));
300
318
  if (scale) {
301
- return { files: [], scale, source: 'stylesheet', tokenCount: scale.length - 1, warnings: [] };
319
+ const assessment = assessScale({ source: 'stylesheet', values: scale });
320
+ if (assessment.plausible) {
321
+ return { files: [], scale, source: 'stylesheet', tokenCount: scale.length - 1, warnings: [] };
322
+ }
323
+ rejected = { reasons: assessment.reasons, source: 'stylesheet', values: scale };
302
324
  }
303
325
  }
304
326
 
@@ -324,9 +346,56 @@ function resolveAutoScale({
324
346
  return { source: 'token-package', ...fromPackages };
325
347
  }
326
348
 
349
+ return fallbackInference(rejected);
350
+ }
351
+
352
+ const TRUSTED_SCALE_SOURCES = new Set(['scaleSources', 'rhythmguardrc', 'token-sources', 'tailwind', 'token-package', 'explicit', 'default']);
353
+ const TOKEN_FILE_PATTERN = /(token|variable|spacing|space|theme|primitive|global|scale|layout)/i;
354
+ const LADDER_STEPS = [2, 3, 4, 5, 8];
355
+ const MIN_ASSESSED_STEPS = 3;
356
+ // A scale with more steps than this must be a near-perfect ladder; otherwise it
357
+ // is a list of every value a codebase happens to use (Semi Design: 42 steps).
358
+ const MAX_LOOSE_STEPS = 24;
359
+
360
+ /**
361
+ * Inference can pick up component-local variables (`--chip-spacing: 3px`) and
362
+ * assemble a "scale" nobody designed. Explicit sources are trusted. An inferred
363
+ * scale is plausible when it has at least three positive steps, is mostly whole
364
+ * pixels, mostly shares a common step (2, 3, 4, 5 or 8), and, past two dozen
365
+ * steps, is a near-perfect ladder. When the source
366
+ * files are known, a set that comes mostly from component files must be a
367
+ * near-perfect ladder to pass. Fallback is never the project's scale.
368
+ */
369
+ function assessScale({ files = null, source, values = [] } = {}) {
370
+ if (source === 'fallback') {
371
+ return { plausible: false, reasons: ['fallback'] };
372
+ }
373
+ if (TRUSTED_SCALE_SOURCES.has(source)) {
374
+ return { plausible: true, reasons: [] };
375
+ }
376
+ const positives = values.map(Number).filter((value) => Number.isFinite(value) && value > 0);
377
+ const integers = positives.filter(Number.isInteger);
378
+ const integerShare = positives.length ? integers.length / positives.length : 0;
379
+ const coherence = positives.length
380
+ ? Math.max(...LADDER_STEPS.map((step) => integers.filter((value) => value % step === 0).length / positives.length))
381
+ : 0;
382
+ const reasons = [];
383
+ if (positives.length < MIN_ASSESSED_STEPS) reasons.push('fewer than three steps');
384
+ if (integerShare < 0.8) reasons.push('fractional values');
385
+ if (coherence < 0.7) reasons.push('no common step');
386
+ if (positives.length > MAX_LOOSE_STEPS && coherence < 0.9) reasons.push('too many steps');
387
+ if (Array.isArray(files) && files.length > 0) {
388
+ const tokenFileShare = files.filter((file) => TOKEN_FILE_PATTERN.test(file)).length / files.length;
389
+ if (tokenFileShare < 0.5 && coherence < 0.9) reasons.push('sources are component files');
390
+ }
391
+ return { plausible: reasons.length === 0, reasons };
392
+ }
393
+
394
+ function fallbackInference(rejected = null) {
327
395
  return {
328
396
  files: [],
329
397
  preset: FALLBACK_PRESET,
398
+ ...(rejected ? { rejected } : {}),
330
399
  scale: getScalePreset(FALLBACK_PRESET),
331
400
  source: 'fallback',
332
401
  tokenCount: 0,
@@ -338,12 +407,16 @@ function autoScaleFallbackNote(inference) {
338
407
  if (!inference || inference.source !== 'fallback') {
339
408
  return '';
340
409
  }
410
+ if (inference.rejected) {
411
+ return `The spacing tokens found do not form a spacing scale (${inference.rejected.reasons.join(', ')}); using preset "${inference.preset}".`;
412
+ }
341
413
 
342
414
  return `No spacing tokens were found for scale "auto"; using preset "${inference.preset}".`;
343
415
  }
344
416
 
345
417
  module.exports = {
346
418
  DEFAULT_AUTO_TOKEN_PATTERN,
419
+ assessScale,
347
420
  autoScaleFallbackNote,
348
421
  discoverTokenPackages,
349
422
  resolveAutoScale,
@@ -43,6 +43,13 @@
43
43
  "dist/css/global-vars.css"
44
44
  ],
45
45
  "note": "--spectrum-spacing-* in px"
46
+ },
47
+ {
48
+ "name": "@carbon/layout",
49
+ "files": [
50
+ "scss/generated/_spacing.scss"
51
+ ],
52
+ "note": "$spacing-01..13 in rem, Sass variables; Carbon's styles package forwards them from here"
46
53
  }
47
54
  ]
48
55
  }
@@ -38,7 +38,7 @@ const TOKEN_KIND_PATTERNS = Object.freeze({
38
38
  // --spacing) but never letter-/word-spacing. Sass names must start with the scale word
39
39
  // (an optional `system-` prefix allowed): $spacer, $spacers.3, $spacing-01, $system-spacing.
40
40
  // Component variables such as $dropdown-spacer or $card-spacer-y are not scale tokens.
41
- spacing: /^(?:\$(?:system-)?(?:space|spacing|spacer)s?(?:-|$|\.)|--(?:[\w-]*-)?(?<!letter-)(?<!word-)(?:space|spacing)(?:-|$))/,
41
+ spacing: /^(?:\$(?:system-)?(?:space|spacing|spacer)s?(?:-|$|\.)|--(?:[\w-]*-)?(?<!letter-)(?<!word-)(?:space|spacing|spacer)(?:-|$))/,
42
42
  typography: /^--(?:font|font-size|line-height|leading|tracking|typography)-/,
43
43
  });
44
44
 
@@ -193,7 +193,8 @@ function normalizeTokenSource(source) {
193
193
  }
194
194
 
195
195
  function detectSourceFormat(filePath) {
196
- if (path.extname(filePath).toLowerCase() === '.css') {
196
+ // .scss token files are read by the CSS collector too: custom properties plus Sass variables and maps.
197
+ if (['.css', '.scss'].includes(path.extname(filePath).toLowerCase())) {
197
198
  return 'css';
198
199
  }
199
200
 
@@ -283,16 +284,40 @@ function collectScssTokens(source, matchesKind) {
283
284
  };
284
285
 
285
286
  const tokens = [];
286
- const push = (tokenName, value) => {
287
- if (!value || !matchesKind(tokenName)) {
287
+ const push = (tokenName, value, force = false) => {
288
+ if (!value || (!force && !matchesKind(tokenName))) {
288
289
  return;
289
290
  }
290
- tokens.push({ token: tokenName, value: formatScssValue(value) });
291
+ const formatted = formatScssValue(value);
292
+ // A unitless number is a multiplier or a map index, not a length. Zero is fine.
293
+ if (/^-?\d*\.?\d+$/.test(formatted) && parseFloat(formatted) !== 0) {
294
+ return;
295
+ }
296
+ tokens.push({ token: tokenName, value: formatted });
291
297
  };
298
+ // Namespaced maps such as GOV.UK's $govuk-spacing-points are only accepted
299
+ // for the spacing kind, and only when they hold a real ladder (issue #86).
300
+ const spacingKind = matchesKind('$spacing-probe');
292
301
 
293
302
  for (const [name, raw] of declarations) {
294
303
  const tokenName = `$${name}`;
295
304
  if (isScssMap(raw)) {
305
+ if (!matchesKind(tokenName) && spacingKind && NAMESPACED_SPACING_MAP.test(tokenName)) {
306
+ const entries = [];
307
+ walkScssMap(raw, [tokenName], (pathName, expression) => {
308
+ const value = evaluateScssExpression(expression, resolveVariable, new Set());
309
+ if (value) {
310
+ entries.push([pathName, value]);
311
+ }
312
+ });
313
+ const distinct = new Set(entries.map(([, value]) => formatScssValue(value)).filter((value) => /^-?\d*\.?\d+(?:px|rem|em)$/.test(value) && parseFloat(value) !== 0));
314
+ if (distinct.size >= MIN_NAMESPACED_MAP_LENGTHS) {
315
+ for (const [pathName, value] of entries) {
316
+ push(pathName, value, true);
317
+ }
318
+ }
319
+ continue;
320
+ }
296
321
  walkScssMap(raw, [tokenName], (pathName, expression) => {
297
322
  push(pathName, evaluateScssExpression(expression, resolveVariable, new Set()));
298
323
  });
@@ -304,6 +329,10 @@ function collectScssTokens(source, matchesKind) {
304
329
  return tokens;
305
330
  }
306
331
 
332
+ /** `$<namespace>-spacing-points`, `$<ns>-space-scale`: one namespace segment before the anchor. */
333
+ const NAMESPACED_SPACING_MAP = /^\$[a-z0-9]+-(?:space|spacing|spacer)s?(?:-|$)/i;
334
+ const MIN_NAMESPACED_MAP_LENGTHS = 4;
335
+
307
336
  function stripScssComments(source) {
308
337
  return source
309
338
  .replace(/\/\*[\s\S]*?\*\//g, '')
package/types/audit.d.ts CHANGED
@@ -98,9 +98,19 @@ export interface AuditBaselineComparison {
98
98
 
99
99
  export type AuditScaleSource = "default" | "explicit" | "fallback" | "scanned-css" | "token-package" | "token-sources";
100
100
 
101
+ export interface AuditScaleRejected {
102
+ files?: string[];
103
+ /** Why the inferred set was not accepted as a scale, for example "no common step". */
104
+ reasons: string[];
105
+ source: AuditScaleSource;
106
+ values: Array<number | string>;
107
+ }
108
+
101
109
  export interface AuditScale {
102
110
  /** Files the scale was derived from (token sources or scanned stylesheets). Empty for explicit, default and fallback. */
103
111
  files: string[];
112
+ /** Present when `source` is "fallback" because an inferred scale failed the plausibility check. */
113
+ rejected?: AuditScaleRejected | null;
104
114
  source: AuditScaleSource;
105
115
  tokenCount: number;
106
116
  values: Array<number | string>;
@@ -139,6 +149,7 @@ export interface AuditContractReport {
139
149
  files: string[];
140
150
  offScaleProperties?: Record<string, number>;
141
151
  offScaleValues?: unknown;
152
+ rejected: AuditScaleRejected | null;
142
153
  source: AuditScaleSource;
143
154
  tokenOpportunities?: unknown;
144
155
  values: Array<number | string> | null;