Skip to content

feat: prefer-scale-token (#91), and three reliability fixes from the 1.5.0 review - #96

Merged
sergioazoc merged 4 commits into
mainfrom
feat/prefer-scale-token
Jul 26, 2026
Merged

feat: prefer-scale-token (#91), and three reliability fixes from the 1.5.0 review#96
sergioazoc merged 4 commits into
mainfrom
feat/prefer-scale-token

Conversation

@sergioazoc

Copy link
Copy Markdown
Owner

Closes #91, plus the three reliability findings the 1.5.0 review left documented and unfixed — one of which 1.5.0 itself made worse.

prefer-scale-token (#91)

A new rule that reports a hardcoded value numerically equal to something the design system already names, and suggests the name. It fills a gap the other three arbitrary→named rules structurally cannot, because each keys on something this case does not satisfy:

Rule Fires on Keyed on
no-unnecessary-arbitrary-value w-[100%]w-full byte-identical CSS
enforce-canonical what canonicalizeCandidates proposes the same, since #78
prefer-theme-tokens bg-(--primary)bg-primary the NAME of a variable
prefer-scale-token p-[10px]p-2.5 numeric equality

p-2.5 compiles to calc(var(--spacing) * 2.5) and p-[10px] to 10px: same length, different text.

Report-only, and it will stay that way. No fixable — the token reaches its value through var(), so a :root override or a different rootFontSize makes the equivalence false. Autofixing that is the bug #78 fixed. The message says so.

Correcting the issue's premise

w-[140px]w-35 was never reported, before or after #78: canonicalizeCandidates doesn't propose it, because 35 isn't one of the steps Tailwind enumerates. What #78 silenced were the enumerated ones (p-[10px]p-2.5, gap-[4px]gap-1, h-[2rem]h-8) plus the theme tokens (rounded-[0.5rem]rounded-lg). Both are covered now, including the reporter's example — Tailwind compiles w-35, so it counts.

Two families, both derived

--spacing comes from the theme; the prefixes that read it are found by asking what <prefix>-1 compiles to; named tokens come from the emitted CSS plus the theme values. No namespace table exists to drift, and two things fall out of the derivation rather than being special-cased:

  • text-[14px]text-sm is NOT reported. text-sm also sets line-height, so it isn't the same declaration. Requiring one declaration drops the whole family on its own.
  • Colour tokens aren't candidates — a colour can't match a literal numerically. ~7 000 of the ~7 200 pure-var() classes gone, 253 comparable ones left, +8.5 KB on the cache artifact.

The granularity is derived, not chosen

Tailwind compiles any number (w-8.425 is valid), so every length is N spacing units for some N — reporting all of them would be no-arbitrary-value with extra steps. Every step Tailwind's own scale enumerates is a multiple of 0.5, and the precompute computes that from the steps themselves. w-[140px]w-35 passes; w-[33.7px]w-8.425 doesn't. step only ever makes it finer.

Three reliability fixes

  • The declaration service degraded in silence. It swallowed its own failures, which was defensible when no-conflicting-classes was the only caller. Since fix: derive the remaining nine rules from the generated CSS #95 no-unknown-classes asks it about VALIDITY, and there silence sends the rule back to the tolerant heuristic: a dead worker quietly reinstated the false negatives the service exists to remove, on a run that stayed green. It throws now, and each caller takes a posture that already existed — the two DS-dependent rules surface designSystemUnavailable, no-dark-without-light (which may never emit it) degrades and logs under debug. A transient worker failure now fails those two rules instead of weakening them quietly.
  • prefer-theme-tokens proposed a colour-changing rewrite when the theme lived in an @import. definesVar is the guard that stops it, and it scanned only the entry CSS — so the normal shadcn/ui layout made :root { --primary: … } read as undefined. Reproduced with a fixture, then fixed.
  • Two caches over one artifact could produce a false conflict. Lint-time interning appended to the artifact's own array while each cache kept its own text→id map, so one value could get two ids — and conflicts are decided by comparing ids. Ids are local now.

Testing

  • Every fix has a test that fails on the old code — verified by reverting each one, not assumed.
  • prefer-scale-token: 33 cases, and the valid half is the important one (the boundary, byte-identical hand-offs, text-[14px], variable references, bare numbers).
  • The coexistence matrix goes from three rules to four: for each input, exactly one rule fires.
  • The derived data is pinned in precomputed-snapshot.test.ts, so a Tailwind release that changes the granularity or the token set shows up as a failure rather than silently moving the rule.
  • 1661 tests green, plus lint, format, typecheck, and both builds. Precompute cost of the new probe measured at 15 ms (+0.7%).

Found while reviewing my own diff

p-[10] compiles to padding: 10 — no unit, not a length — and the rule treated the bare number as px, so it suggested p-2.5 (padding: 10px). A change of meaning dressed as a change of spelling, which matters even without an autofix because editors apply suggestions on a click. Values now carry whether they had a unit.

Also fixes a doc error the same misconception produced: the rule index claimed no-unnecessary-arbitrary-value handles w-[200px]w-50. It can't — getNamedEquivalent('w-[200px]') is null — and that example now points at the rule that does.

Version bumped to 1.6.0: new rule (off by default, no presets exist), plus the behaviour change above.

Documented in the #94 review, and one of them got worse in #95.

The declaration service degraded in silence. It swallowed its own failures and
returned "no information", which was defensible while `no-conflicting-classes`
was the only caller — no declarations, no comparison, no diagnostic. Since #95
`no-unknown-classes` asks it about VALIDITY, and there silence sends the rule back
to the tolerant heuristic: a dead worker quietly reinstates the exact false
negatives the service exists to remove, on a run that stays green. It throws now,
like the sort and canonicalize services, and each caller takes the posture that
already existed in the plugin — the two DS-dependent rules surface
`designSystemUnavailable`, and `no-dark-without-light` (DS-OPTIONAL, which may
never emit it) degrades to prefix-only grouping and says so via debugLog.

`internDeclarations` appended lint-time values to `index.values`, which belongs to
the cache artifact and is held by reference. Two caches built from one artifact
would each push while holding their own text→id map, so the same value would get
two ids — and `decidePair` compares ids, so two identical values would read as a
conflict. Ids are now local to the cache; nothing reads them back out of the array
(the decoder only resolves ids that came from `index.table`). Unreachable through
the loader today, one shared object away from being a false positive nobody could
explain, and the test interleaves the two caches so it fails on the old code.

`definedVars` scanned only the entry CSS. Splitting the theme across files is the
normal shadcn/ui layout, so `:root { --primary: … }` in an `@import` read as
undefined — and that is precisely what tells `prefer-theme-tokens` a rewrite is
safe, so it proposed `bg-primary` for `bg-(--primary)` and silently changed the
colour. It now scans the entry and its resolved imports, reusing the collection
`extractComponentClasses` already did.
…hout fixing it

Closes the gap #91 reported. Since #78, `enforce-canonical` only rewrites when the
emitted CSS is byte-identical — right for an autofixer, and it took the consistency
signal with it for every value whose token resolves through `var()`. Nothing else
covered it: the three related rules each key on something this case does not
satisfy (byte-identical CSS, what canonicalizeCandidates proposes, the NAME of a
variable the user wrote). Numeric equality is a fourth premise.

Two families, both derived from the design system by the precompute:

- the spacing scale — `p-[10px]` → `p-2.5`, `gap-[4px]` → `gap-1`, `h-[2rem]` →
  `h-8`, and `w-[140px]` → `w-35`, the issue's own example;
- named theme tokens by value — `rounded-[0.5rem]` → `rounded-lg`, read off the
  emitted CSS plus the theme, so no namespace table exists to drift.

Report-only, with a suggestion and NO autofix: the equivalence is numeric, so a
`:root` override or a different root font size makes the two diverge. Fixing that
automatically is the mistake #78 fixed, and the message says as much.

The boundary is where the work was. Tailwind compiles ANY number — `w-8.425` is
valid — so "has a scale equivalent" is true for practically every length, and
reporting all of them would be `no-arbitrary-value` with extra steps. The cut is
the granularity Tailwind's own enumerated steps use, computed by the precompute as
the smallest gap between them (0.5 in the default theme) rather than chosen here.
`step` only ever makes it finer.

Two things the derivation gets right on its own, which is why it is a derivation:
`text-[14px]` → `text-sm` is NOT reported, because `text-sm` also sets
`line-height` and the single-declaration requirement drops the whole family; and
the ~7 000 colour tokens are excluded because a colour can never match a literal
numerically, leaving 253 comparable tokens and +8.5 KB on the cache artifact.

Corrects the issue's premise, for the record: `w-[140px]` → `w-35` was never
reported, before or after #78 — Tailwind does not propose it, since 35 is not one
of the steps it enumerates.
Rule page in both locales, with the two things worth understanding up front: why
there is no autofix (the equivalence is numeric, so a `:root` override or a
different root font size makes it false — autofixing that is the bug #78 fixed),
and why there is a granularity at all (Tailwind compiles any number, so without one
the rule fires on essentially every length).

Also fixes a doc error the same misconception produced: the rule index claimed
`no-unnecessary-arbitrary-value` handles `w-[200px]` → `w-50`. It does not and
cannot — those emit different CSS text — and `getNamedEquivalent('w-[200px]')`
returns null. That example now points at the rule that does handle it.

`enforce-canonical`'s page gains the pointer to its report-only half, since that is
where the signal was lost.

Unrelated flake found on the way: the precompute worker test scanned the WHOLE
shared cache dir for `.tmp.` stragglers, so it failed whenever a peer isolate was
mid-write — which this branch made likely by changing CACHE_KEY, since every
fixture then recomputes at once. Scoped to its own artifact's temp names.
Found reviewing the diff. `p-[10]` compiles to `padding: 10` — no unit, which is
not a length at all — and the rule treated the bare number as px, so it suggested
`p-2.5` (`padding: 10px`). That is a change of meaning dressed up as a change of
spelling, which is the one thing a rule with no autofix still has to get right,
because an editor applies its suggestion on a click.

Values now carry whether they had a unit, and a unitless number is never the same
value as a length whatever the digits say. The scale path additionally requires
both sides to be the same kind of quantity, so a hypothetical unitless `--spacing`
would still compare correctly.

Also tightened the prefix split to require the dash that joins a utility to its
value, instead of assuming the character before `[` is one.

Verified the fix discriminates: without the guard, `p-[10]` and `w-[140]` are
reported.
@sergioazoc
sergioazoc merged commit 73bf113 into main Jul 26, 2026
5 checks passed
@sergioazoc
sergioazoc deleted the feat/prefer-scale-token branch July 26, 2026 20:19
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Feature Request: Add a report-only "prefer scale token" signal?

1 participant