Skip to content

fix: derive the remaining nine rules from the generated CSS - #95

Merged
sergioazoc merged 9 commits into
mainfrom
fix/derive-remaining-rules-from-css
Jul 26, 2026
Merged

fix: derive the remaining nine rules from the generated CSS#95
sergioazoc merged 9 commits into
mainfrom
fix/derive-remaining-rules-from-css

Conversation

@sergioazoc

Copy link
Copy Markdown
Owner

Companion to #94. That PR made no-conflicting-classes decide from the CSS Tailwind emits; the audit behind it left nine other rules reasoning about class NAMES. This closes them: one destructive autofix, one case of double reporting, six false negatives and one family of false positives.

Every finding below was reproduced against Tailwind 4.3.3 with the real design system before being fixed, and each fix is verified the same way.

The nine

Rule Defect Reproduction
enforce-shorthand destructive autofix Tailwind v4 resolves w-* from --width-*, h-* from --height-*, size-* from --size-*. With @theme { --width-brand: 10rem; --height-brand: 20rem }, w-brand h-brand was rewritten to size-brand, which does not exist — the element lost its width and its height
no-deprecated-classes + enforce-canonical double reporting Tailwind's own canonicalizeCandidates rewrites every v3 spelling, and the precompute fed them into the canonical map, so bg-gradient-to-r produced two diagnostics carrying the same fix
no-unknown-classes false negatives The validity check strips variants before validating, so hoverr:flex, darkk:size-4, peer-cheked:flex, @mdd:flex passed while producing no CSS. And it accepts anything shaped like an off-scale number, so bg-red-5000, bg-red-500/foo, w-[] passed for the same reason w-45 legitimately does
no-hardcoded-colors false negatives The value was matched from its first character only (shadow-[0_1px_2px_#000] passed) and the prefix list had ring but not inset-ring, shadow but not inset-shadow, and no way to spell [color:#f00]
no-arbitrary-value false negative bg-(--x) is sugar for bg-[var(--x)] and only the second reported — so enforce-consistent-variable-syntax's default autofix rewrote the reported form into the unreported one and the violation vanished
enforce-logical / enforce-physical false negatives Three pairs Tailwind ships were missing: float-leftfloat-start, clear-leftclear-start, text-lefttext-start. A codebase could be fully converted and still float things left
no-dark-without-light false positives underline dark:no-underline, italic dark:not-italic, visible dark:invisible, uppercase dark:normal-case, truncate dark:text-clip, sr-only dark:not-sr-only — every pair writes the same CSS property, but the spellings share no prefix
4 rewriting rules unvalidated replacement A project with @utility ml-huge was autofixed to ms-huge, which emits nothing
no-duplicate-classes no defect Audited and left alone — semantic duplicates are redundant since 1.4.0, and that rule already skips the exact-duplicate pair

How each is decided now

Eight of the nine rules are DS-OPTIONAL: with an entryPoint they get more accurate, without one they behave exactly as before. None can emit designSystemUnavailable — every one of them was usable with no configuration and has to stay that way.

  • enforce-shorthand verifies each merge against the emitted declarations: the parts must resolve to the same value and the replacement must reproduce it. The INVALID_SIZE_VALUES blocklist is gone with it — w-screen h-screen is rejected because 100vw is not 100vh (derived, not listed) and w-dvw h-dvw, excluded by that list for no reason, merges.
  • no-deprecated-classes derives the rename list from the design system, which also decides ownership: it reports them and enforce-canonical skips them. The derived list covers renames the hardcoded table never had (break-words, order-none, the reordered position spellings) and prunes itself when a Tailwind release stops compiling one. start-2inset-s-2 stays with enforce-canonical: current Tailwind, just not canonical.
  • no-unknown-classes compiles the variant chain — the only exact way, since variantOrder holds the 71 static variants and has no group, no data, no @md, so a name list would report most real-world variants as unknown. Suggestions are confirmed against the design system before being offered, so group-hoverrgroup-hover keeps the root, and a project's own @custom-variant thumb is spell-checked like a built-in.
  • no-dark-without-light also matches the base by declared property. The check is additive, so configuring an entry point can only make it report less.

Also in here

enforce-shorthand went from 10 collapsible families to 47 (borders, inset, radius edges, gap, overflow, overscroll, border-spacing, scroll-m*/scroll-p*, the logical inline pairs, translate) and now reports one diagnostic per group instead of one per matching family — four sides used to produce three errors.

scale-x+scale-y is deliberately NOT a family: scale-110 also writes --tw-scale-z, which scale-3d reads, so the merge would change how scale-x-110 scale-y-110 scale-3d renders. That is the kind of thing only the CSS can tell you, which is the whole point of the branch.

Testing

  • tests/integration/shorthand-families.test.ts proves every family against the real design system: the properties the replacement writes must be exactly the union of the parts', expanded through the CSS shorthands. It caught rounded-s being ss+es rather than the ss+se the names suggest, and it is what keeps scale out.
  • Each family is exercised through both paths (with and without a design system), because the DS path compares declarations and could silently stop merging.
  • no-unknown-classes gets an anti-false-positive suite of class strings copied from real shadcn/ui components — compound data variants, has-[…], arbitrary selectors, in-*, supports-[…], colour modifiers.
  • Two coexistence tests pin the rule boundaries: deprecated vs canonical, and arbitrary-value vs variable-syntax.
  • A unit test for the colour scanner, including the two exclusions that make it a scanner rather than a regex (quoted strings, url() contents).
  • 1600 tests green, cold cache included (62 s from an empty cache — CACHE_KEY changes with the precompute script). Verified end to end under the real oxlint binary with the built plugin.

Breaking-ish

New diagnostics can fail a CI run that passes today — that is the point, since everything they catch produces no CSS or changes it. Details in the CHANGELOG; version bumped to 1.5.0.

The rule compared the textual token, not the value it resolves to. Tailwind v4
has per-axis theme namespaces — `w-*` reads `--width-*`, `h-*` reads `--height-*`,
`size-*` reads `--size-*` — so with

    @theme { --width-brand: 10rem; --height-brand: 20rem }

`w-brand h-brand` was autofixed to `size-brand`, a class that emits nothing: the
element lost its width and its height. A hardcoded `INVALID_SIZE_VALUES` blocklist
covered `screen` and, over-eagerly, every viewport unit, while the case that
actually breaks was never in it.

Now the merge is verified against the declarations Tailwind emits: the parts have
to resolve to the same value and the replacement has to reproduce it. The blocklist
is gone (`w-screen h-screen` is rejected because 100vw is not 100vh; `w-dvw h-dvw`
merges), and named theme tokens on that family are only merged when the values are
literally identical, for the same reason enforce-canonical refuses a var-backed
rewrite (#78). DS-OPTIONAL: with no entryPoint the merges that are safe whatever
the theme says still fire.

The family table is now declarative data, which made it cheap to add what was
missing: border sides/axes (widths AND colours), inset, the rounded edges and the
logical corners, gap, overflow, overscroll, border-spacing, scroll-m*/scroll-p*,
the logical inline pairs (`ms`+`me` → `mx`) and translate. `scale-x`+`scale-y` is
deliberately absent: `scale-110` also writes `--tw-scale-z`, which `scale-3d`
reads, so that merge changes the render.

Each entry is proved against the real design system in
tests/integration/shorthand-families.test.ts: the properties the replacement writes
must be exactly the union of the parts', expanded through the CSS shorthands. That
matrix caught `rounded-s` being `ss`+`es`, not the `ss`+`se` the names suggest.

Four sides now report ONE diagnostic instead of three (the merge consumes the
classes, so the axis halves are no longer reported next to it).
…porting

Tailwind's own canonicalizeCandidates rewrites every v3 spelling this rule knew
about (`bg-gradient-to-r` → `bg-linear-to-r`, `flex-grow` → `grow`), and the
precompute already fed them into the canonical map. So each of those classes
produced TWO diagnostics carrying the SAME fix: one from no-deprecated-classes'
hardcoded table, one from enforce-canonical.

The precompute now records the renames separately from the rest of the canonical
map, derived by asking the design system, and that map decides ownership:
no-deprecated-classes reports them (it says the class is deprecated, which is the
more actionable message) and enforce-canonical skips them. The derived map also
covers renames the hardcoded table never had — `break-words`, `order-none`, the
reordered `bg-left-top`/`object-left-top` position spellings — and prunes itself
when a Tailwind release stops compiling one, which is what the hardcoded table
could not do.

The split is not "everything the canonical map holds": `start-2` canonicalizes to
`inset-s-2` and is NOT deprecated — it is the spelling Tailwind's docs use — so
enforce-canonical keeps that one. Both directions are pinned down in
tests/integration/deprecated-canonical-coexistence.test.ts.

DS-OPTIONAL: with no entryPoint the hardcoded table stands in and the rule reports
exactly what it reported before. no-unknown-classes now asks the design system
which classes are deprecated instead of importing that table.
…t their shape

Two holes, both of which looked like coverage.

`isValid` strips the variants BEFORE validating, so the chain was never checked at
all: `hoverr:flex`, `darkk:size-4`, `peer-cheked:flex` and `@mdd:flex` produce no
CSS whatsoever and every one of them passed. And it accepts anything shaped like a
dynamic value, because until the declaration service there was no way to ask —
which is why `bg-red-5000`, `bg-red-500/foo` and `w-[]` passed for exactly the
reason `w-45` legitimately does. They are the same shape; only Tailwind knows it
compiles one and not the other.

Both are now answered by the design system, batched per location and memoized per
class, with a fall back to the old heuristic if the worker is unavailable so a
broken service can never invent a diagnostic.

Validating a chain by NAME was not an option: `variantOrder` holds the 71 static
variants and nothing else — no `group`, no `data`, no `@md` — so a list-based check
would report most real-world variants as unknown. Instead a probe utility is
compiled under the chain, which costs one call per DISTINCT chain in the project.
Suggestions are verified the same way before being offered, so `group-hoverr` is
corrected to `group-hover` (root kept, tail fixed, confirmed to compile) and a
project's own `@custom-variant thumb` spell-checks like a built-in.

The declaration service now reports which classes produce no CSS and owns the
interning, which collapses the two memos the rules kept into one answer per class.
…just by prefix

Grouping by the leading token reported the idiomatic "light does X, dark undoes X"
pairs as missing a base, because the two spellings share no prefix:
`underline dark:no-underline`, `italic dark:not-italic`,
`visible dark:invisible`, `uppercase dark:normal-case`, `truncate dark:text-clip`,
`sr-only dark:not-sr-only`. Each pair writes the SAME CSS property, and the rule
had no way to know — a hardcoded equivalence table covered exactly two groups
(display, position) and would have needed one entry per pair forever.

With an entry point the properties come from the design system instead, and
user-written values are resolved through the declaration service so
`bg-[#fff] dark:bg-[#111]` keeps grouping too. The check is ADDITIVE: a class that
matched by prefix still matches, so this can only ever report less than before,
never more.

DS-OPTIONAL: with no entryPoint the prefix heuristic and its two static groups
stand in unchanged. That limit is now asserted in the test file rather than left
implicit.

`isUserValued` moves to utils/class-parser.ts, shared with no-conflicting-classes.
enforce-logical / enforce-physical
  Three physical→logical pairs Tailwind ships were simply missing from the table:
  `float-left`↔`float-start` (`float: inline-start`), `clear-left`↔`clear-start`
  and `text-left`↔`text-start`. A codebase could be fully converted and still
  float things left. They are marked `exact` because the direction is the VALUE
  there, which also stops the prefix scan from rewriting `float-left-0` — a class
  that does not exist — into another one that doesn't either.

  Both also verify the class they propose exists, when a design system is
  available. A project defining `@utility ml-huge` got an autofix to `ms-huge`,
  which emits nothing: the fix applies and the margin disappears.

  enforce-physical additionally converts `inset-s-*`/`inset-e-*`. enforce-logical
  suggests `start-2` (the spelling Tailwind's docs use), enforce-canonical then
  rewrites that to `inset-s-2` (what the design system calls canonical), and this
  rule had no way back — its table only knew `start`.

no-hardcoded-colors
  The value was matched from its first character only, so a colour anywhere else in
  it passed: `shadow-[0_1px_2px_#000]` is a hardcoded black the rule promised to
  catch. Anchoring also forced a companion list of "colour-bearing prefixes" which
  had `ring` but not `inset-ring`, `shadow` but not `inset-shadow`, and no way to
  spell an arbitrary property (`[color:#f00]`, `[--brand:#f00]`). Both are gone:
  the value carries a colour or it doesn't, whatever utility it hangs off. The
  scanner skips quoted strings and `url()` contents, which is what keeps
  `content-['#fff']` and `fill-[url(#gradient)]` out, and treats `_` as the space
  it stands for so a function name inside a shorthand still surfaces.

no-arbitrary-value
  `bg-(--x)` is sugar for `bg-[var(--x)]`, and only the second was reported — so
  `enforce-consistent-variable-syntax` in its default `shorthand` mode rewrote the
  reported form into the unreported one and the violation vanished with the code
  unchanged in substance. Both spellings report now.

The replacement guard is deliberately NOT wired into the two rules whose
replacement is an arbitrary value: Tailwind takes those verbatim, so `x-[-5px]`
compiles whenever `-x-[5px]` does and the guard could not answer anything. They
stay purely syntactic, as their docs say.
Rule pages for the nine rules this branch touches, in both locales (prose in
`_extras`, pages regenerated). Each one says what changed, what an `entryPoint`
buys, and what the rule still does without one — including the limits: the pairs
`no-dark-without-light` cannot group without CSS, and the sizing merges
`enforce-shorthand` skips when it cannot verify them.

CHANGELOG records the three groups of new diagnostics under "Breaking-ish" (they
can fail a CI run that passes today), and a "Not changed, and why" section for
`no-duplicate-classes` — audited, no defect — and for the two rules that
deliberately did NOT get the replacement guard.

Also renames tests/rules/tmp-ncv-entrypoint-option.test.ts, which landed in #94
with a draft filename.
Visitors run on every AST node, so three things from the previous commits were
paying per-location or per-class costs they didn't need to:

- no-unknown-classes built three intermediate arrays per location to collect the
  classes needing verification. Now a single loop that allocates nothing at all
  for the common location where every class is precomputed.
- the variant-chain verdict is memoized in the rule too, keyed by entry point and
  chain: a file with a thousand `hover:` classes was paying a probe array and a
  WeakMap lookup a thousand times over.
- no-dark-without-light did the same map/filter pair per location; same fix.

Also fixes a duplicate the shorthand fix could introduce: `mt-2 mb-2 my-2` already
carries the shorthand, so appending it produced `my-2 my-2`.
…lass strings

The validation is exact now, and the way that goes wrong is by reporting
something real. These are class strings copied from shadcn/ui components —
compound data variants, has-[…], arbitrary selectors, in-*, supports-[…], child
selectors, colour modifiers — each run against the fixture that defines what it
needs.

Written while checking exactly that, and it earned its place twice: the first two
runs "failed" on `bg-sidebar` and `animate-in`, which turned out to be the probe's
fault (a fixture that defines neither), not the rule's.
… chain from segments

Three things found reviewing the diff:

- the early exit for a precomputed class with no variant chain had been dropped,
  so every class in every file was paying a deprecation lookup and a chain check
  it could not need. That is the overwhelming majority of what a file contains.
- the suggested chain was built with `String.replace`, which rewrites the first
  occurrence of the offending segment — `data-[x=md]:mdd:` could have had the
  wrong one replaced. It is rebuilt from the parsed segments now.
- the per-location reporter closure was allocated on every visitor call.

Also: the reference tables in the docs index were out of date (and were already
wrong about `no-deprecated-classes` before this branch), so they now have three
groups — DS-dependent, DS-optional, DS-independent — with what the design system
adds for each optional rule. `settings.md` says the same in one paragraph.

Test coverage: the new shorthand families were only exercised WITHOUT a design
system, and the DS path compares emitted declarations, so a family could have
silently stopped merging with an entryPoint configured. All 16 are now run through
both paths. The CHANGELOG said "~30 families"; it is 47, of which 37 are new.
@sergioazoc
sergioazoc merged commit 3238d86 into main Jul 26, 2026
5 checks passed
@sergioazoc
sergioazoc deleted the fix/derive-remaining-rules-from-css branch July 26, 2026 02:01
sergioazoc added a commit that referenced this pull request Jul 26, 2026
…1.5.0 review (#96)

* fix: three reliability gaps left over from the 1.5.0 review

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.

* feat(prefer-scale-token): report the literal that equals a token, without 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.

* docs: prefer-scale-token, and bump to 1.6.0

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.

* fix(prefer-scale-token): a bare number is not a length

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.
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.

1 participant