Skip to content

fix: derive conflict detection from the generated CSS (closes #93) - #94

Merged
sergioazoc merged 10 commits into
mainfrom
fix/derive-conflicts-from-generated-css
Jul 25, 2026
Merged

fix: derive conflict detection from the generated CSS (closes #93)#94
sergioazoc merged 10 commits into
mainfrom
fix/derive-conflicts-from-generated-css

Conversation

@sergioazoc

Copy link
Copy Markdown
Owner

Why

no-conflicting-classes decided whether two Tailwind classes clash by comparing the precomputed names of the CSS properties each one declares. Names alone cannot tell a conflict from a composition, so the rule reported combinations Tailwind's own documentation shows as correct usage.

#93 is the visible tip — it needs tailwind-scrollbar to reproduce — but a sweep over the ~23 000 utilities of a real design system found the defect fires on plain Tailwind, no plugins:

Combination Why it was wrong
text-gray-900 placeholder-gray-400 placeholder-* styles ::placeholder, not the element
ms-2 space-x-4 space-x-* styles the children
mask-b-from-50% mask-b-from-black four shared declarations, byte-identical
drop-shadow-xl drop-shadow-indigo-500 the colour reads the size variable
scale-3d scale-x-110, translate-3d translate-x-4 same
transform-gpu rotate-x-45 reads the variable the other writes
rounded-t-lg rounded-l-lg shared corner, same radius

And the mirror problem: p-4 p-[5px], w-[10px] w-[20px], border-1 border-2, bg-red-500/50 bg-blue-500, rounded rounded-lg were silently accepted while p-4 p-6 reported — silence that looks like coverage.

What changed

The precompute emits declarations instead of property names: property + value + which custom properties the value reads (direct reads kept apart from var() fallbacks) + which box it applies to. Everything is interned, so the disk artifact shrank from 3.11 MiB to 2.66 MiB while carrying strictly more, and the extraction phase got faster (94 ms → 75 ms).

A shared property is a conflict only when the declaration that loses the cascade carries something the winner does not reproduce: equal values never clash, a var() forwarder does not clash with the class supplying the variable (matched by the real variable name, which is what closes #93 without knowing anything about scrollbars), a custom property cleared to initial carries no information, and declarations on different boxes never clash. Which class wins is asked of the design system, so the message names it — and that settled an inconsistency where size-4 h-6 was accepted while h-6 size-4 reported, though both compile to identical CSS.

Derivation replaced the tables of composing families. spec.ts went from 5 groups + 7 pairs to 1 + 3, and what survives is plugin intent no CSS comparison can see, each entry saying why. Users who need more have an allow option instead of an issue and a release.

Also in scope: user-written values are resolved from the design system at lint time; prefer-theme-tokens no longer autofixes bg-(--primary)bg-primary when the two mean different colours; and consistent-variant-order / no-contradicting-variants derive pseudo-element and barrier behaviour from real selectors, so a project's own @custom-variant is classified correctly.

Behaviour change to be aware of

Identical declarations are now reported as redundant, a new messageId that is on by default. shadow shadow-sm, ring ring-1, grayscale grayscale-100, h-4 size-4, container w-full and friends will surface in projects that pass CI today — set reportRedundant: false to opt out. It is on by default because nothing else reports them: the design system canonicalizes those spellings to themselves.

Version goes to 1.4.0, which also ships the previously unreleased #76/#77/#78/#79/#81 work.

Verification

  • 61 test files, 1304 tests, green from a cold cache in 55 s.
  • A new integration test locks the premise everything rests on: cache.getOrder must agree with the physical order of the compiled stylesheet, including the counter-intuitive pairs (shadow-sm beats shadow-lg, text-red-500 beats text-blue-500, flex-row beats flex-col). A drift there would make every message name the wrong winner with nothing failing.
  • False positive in no-conflicting-classes for tailwind-scrollbar utilities (scrollbar-thin + scrollbar-thumb-*/scrollbar-track-*) #93's exact shape is covered by a fixture that reproduces it with @utility, so CI needs no third-party dependency.
  • The branch was reviewed adversarially before opening (four lenses over the diff, each finding re-verified against the real rule). It found 13 real defects — among them the lint-time resolution being completely inert under a project prefix, blur-lg blur-none regressing to silence, space-x-4 space-x-reverse reported as a conflict, and slide-in-from-start called redundant. All are fixed here, with tests.

Two things worth flagging for the reviewer's attention: redundant being on by default is a judgement call, and the space-*/divide-* + *-reverse pair had to go into spec.ts because nothing in the emitted CSS says the 0 was only the variable's registered @property default.

🤖 Generated with Claude Code

https://claude.ai/code/session_014nYoQzdWv8TRfUUK7ZCnQg

…ames

`no-conflicting-classes` decided whether two classes clash by comparing the
precomputed *names* of the CSS properties each one declares. That is blind to
two things the generated CSS makes obvious, and both produce false positives on
combinations Tailwind's own documentation shows as correct usage:

- the VALUE: `mask-b-from-50% mask-b-from-black` share four declarations that
  are byte-identical, and `outline-2` merely READS the `--tw-outline-style`
  that `outline-solid` WRITES;
- the BOX: `.placeholder-gray-400::placeholder` styles the placeholder, and
  `:where(.space-x-4 > :not(:last-child))` styles the children — yet both were
  attributed to the element, so `text-gray-900 placeholder-gray-400` read as a
  `color` conflict.

So the precompute now emits declarations: property + value + which custom
properties the value reads (direct reads kept apart from `var()` fallbacks,
which are unreachable once the variable is supplied) + which box it applies to.
Everything is interned — 55k declarations collapse to ~22k distinct triples over
~2.7k values — so the artifact SHRANK from 3.27 MB to 2.66 MB while carrying
strictly more, and declarations decode lazily per class instead of building a
23k-entry map on every load. The extraction phase also got faster (94ms → 75ms).

Also closes a data gap: classes recovered outside `getClassList()` (bare
utilities like `rounded`, negatives like `-col-1`, legacy v3 spellings) were
pushed into `validClasses` with their CSS discarded, leaving ~300 valid classes
invisible to the rule — `rounded rounded-lg` was silently never compared. Only
`group` and `peer`, which emit no CSS, now lack declarations.

Descendant declarations are kept only for classes that style nothing else
(`space-*`, `divide-*`): a class that styles both itself and its descendants
(`prose`) keeps advertising just its own box, or `prose prose-sm` would read as
~40 conflicts. Those classes, and any with `@media`/`@supports` declarations, are
flagged `partial` so they can never be called redundant — `container` looks like
a plain `width: 100%` because its breakpoint `max-width`es live in `@media`.

The rule itself still compares element-scope property names, so this commit
already removes the pseudo-element and descendant false positives; comparing
values and stylesheet order comes next, and restores detection for pairs within
the same non-element scope (`space-x-4 space-x-2`).

Incidental: `pnpm bench` has been reporting "No test files found" instead of
measuring anything since benchmarks were excluded from the default vitest run —
a CLI path filter does not override `exclude`, and `--exclude` appends to it. It
now has its own config. That is also what let the benchmark's hand-copied
duplicate of the extractor drift unnoticed; it now interpolates the same
`DECL_EXTRACTOR_SOURCE` the worker uses.
The rule compared the NAMES of the CSS properties two classes declare. Names
alone cannot tell a conflict from a composition, so it reported combinations
Tailwind's own documentation shows as correct usage — `mask-b-from-50%
mask-b-from-black`, `drop-shadow-xl drop-shadow-indigo-500`, `scale-3d
scale-x-110`, `transform-gpu rotate-x-45`, `text-gray-900
placeholder-gray-400`, `ms-2 space-x-4` — and, with tailwind-scrollbar
installed, `scrollbar-thin scrollbar-thumb-* scrollbar-track-*` (#93).

It now decides from the declarations themselves. A property is a conflict only
when the declaration that LOSES the cascade carries something the winner does
not reproduce:

- equal values never clash, whoever wins;
- a `var()` forwarder does not clash with the class supplying the variable —
  matched by the actual variable name, so it works for a plugin's `--scrollbar-*`
  exactly as for Tailwind's `--tw-*`, which is what closes #93 without the plugin
  being known to us;
- the winner absorbing the loser is followed transitively through the group's
  surviving writer, which is what covers the gradient chain (`from-*` →
  `--tw-gradient-via-stops` → `via-*`);
- a custom property reset to `initial` carries no information, which is the
  `animate-in` + `fade-in` shape;
- declarations on different boxes never clash.

Which class wins is asked of the design system (`getOrder`) rather than guessed
from the attribute, so the message names it. That also settles a long-standing
inconsistency: `size-4 h-6` was valid while `h-6 size-4` was not, though both
compile to identical CSS. The directional `isNarrowingOverride` heuristic is
gone; its legitimate half (equal values) is subsumed, and it was hiding a real
conflict — `sr-only not-sr-only` clobbers seven declarations and was silently
accepted because one property set is a subset of the other.

Identical declarations are now reported as `redundant` instead of as a conflict:
"remove one" was the right advice with the wrong reason. Nothing else reports
these — `shadow`, `ring`, `grayscale` and `transform` canonicalize to themselves
— so silencing them would have lost coverage; `reportRedundant: false` opts out.
`container w-full` is reported against `w-full` and never against `container`,
whose breakpoint max-widths live in `@media` and are not modelled.

Derived detection replaces the tables of composing families: gradients,
transitions, transform axes, masks, `text-*`/`leading-*`, `text-*`/`tracking-*`,
`divide-*`/`border-*` and both animate families are gone from `spec.ts`, which
now holds only what no CSS comparison can infer — `prose` variants, `prose` +
`max-w-*`, and `mask-composite` modes — each with a comment saying why. Deleting
the mask table also un-hid a false negative: `mask-linear-from-20%
mask-b-from-50%` write `--tw-mask-linear` with different values and one silently
killed the other.

Newly detected, previously silent: `rounded rounded-lg` and `blur blur-sm` (the
bare utilities had no precomputed data at all until the previous commit), and
`!` now beats stylesheet order when resolving the winner.
The rule derives compositions from the generated CSS, and the two exceptions
left in `spec.ts` are plugin intent that no CSS comparison can see. That list
should not grow: a user whose stack produces a combination we cannot derive
needs a way to silence it today, not a release of this plugin.

`allow` takes patterns. A bare one silences every pair involving a matching
class; a two-element one silences that specific combination, in either
orientation. Invalid regex sources are skipped rather than thrown, matching
`enforce-logical`'s allowlist behaviour, so one typo cannot take down the lint.
The precompute enumerates the design system's class list, so it never sees the
values a user writes. Those classes had no declarations at all, and the rule
compared nothing: `p-4 p-6` reported while `p-4 p-[5px]`, `w-[10px] w-[20px]`,
`border-1 border-2`, `gap-13 gap-15` and `bg-red-500/50 bg-blue-500` were
silently accepted. Silence that looks like coverage is worse than a false
positive.

They are now resolved from the design system when a class attribute mentions
them, over the same worker machinery `sort-service` and `canonicalize-service`
use, batched and memoised per entry point (a class that produces no CSS is
remembered as such, so it is asked about once, not once per AST node). The worker
interpolates the precompute's own `DECL_EXTRACTOR_SOURCE`, so lint-time
declarations are parsed by exactly the same code as precomputed ones, and it
returns the per-value variable analysis so the host has no second copy of that
logic. Values are interned against the table the precompute filled — `p-4`'s
value id has to be comparable with `p-[1rem]`'s or the two could never be told
apart from a real conflict.

A service failure degrades to "no information", which is the pre-existing
silence, rather than to wrong diagnostics.

These classes also expose a limit worth being explicit about: their stylesheet
position is not knowable, because `getOrder` borrows a prefix sibling's. Naming a
winner from that approximation would be a guess, so the order counts as unknown
and the pair is reported without one. That in turn made it clear that whether two
declarations compose does not depend on knowing who wins — only on whether the
losing one carries anything of its own — so the composition rules are now tried
in both directions when the order is unknown.
The rule rewrites `bg-(--primary)` to `bg-primary`, and it autofixes. The only
check was `cache.isValid(candidate)` — whether a class by that NAME exists. That
holds for shadcn/ui, where `@theme inline { --color-primary: var(--primary) }`
makes the two spellings the same declaration. It does not hold for a project with
a literal `--color-primary: oklch(…)` in `@theme` and an unrelated `--primary` in
`:root`: there the two are different colours, and `--fix` silently changed the
design.

The theme table is what tells those apart, so the precompute now stores the
`@theme` indirections (variable → the custom properties its value references) and
the custom properties the project defines. A rewrite is proposed when the token's
declaration reads a variable that resolves back to the one written — the `inline`
pattern — or when that variable is defined nowhere, in which case the current
declaration is dead CSS and the token can only be an improvement. When it is
defined and means something else, the rule now stays quiet.

Same principle as `enforce-canonical`'s `safe` guard (#78): don't rewrite unless
the emitted CSS says the two forms agree.

Also fixes a lookup bug the guard exposed: the modifier in `bg-(--primary)/80`
was being carried into the token lookup, and `bg-primary/80` is a user-written
value the precompute never saw.
The rule page, README and CLAUDE.md all described the mechanism that is now
gone: two regex tables as the extension point, the "second class wins" reading of
the attribute order, and narrowing-override as a structural rule. They now
describe what the rule actually does — compare the emitted CSS — and, more
usefully, spell out the five shapes that are therefore NOT conflicts, so it is
clear none of them is special-cased.

The two exceptions that survive in `spec.ts` are documented as exceptions, with
the reason they cannot be derived, and `allow` is presented as the answer to "my
plugin composes in a way you cannot see" instead of "open an issue and wait".

The CHANGELOG leads with the honest headline: `redundant` is a new diagnostic
that can fail a CI run which passes today, and how to opt out. Version goes to
1.4.0 — behaviour changes, no API break.
`consistent-variant-order` and `no-contradicting-variants` decided what a variant
does to the selector by matching its name against a hardcoded list. That list
cannot know a project's own variants, so `@custom-variant thumb
(&::-webkit-slider-thumb)` read as a plain condition: `size-4 thumb:size-4` was
reported as redundant though the two style different boxes, and `thumb:` was not
pinned innermost the way Tailwind's own `before:` is.

Both rules now ask the design system. The facts come from compiling a probe
utility per variant and classifying the emitted selector with the same walker the
declaration extractor uses — NOT from `variant.selectors()`, which returns
`&`-relative strings, comes back empty for `before`/`after`, and reports
`group`/`peer` as arbitrary with no selectors at all. A variant that styles a
generated box wins over one that also reaches descendants (`marker` emits both
`& ::marker` and `&::marker`), so it stays innermost instead of becoming a
barrier.

Both rules remain DS-OPTIONAL: without an entry point they fall back to the same
static lists and behave exactly as before. `no-contradicting-variants` gains an
`entryPoint` option but never reports a missing design system — it had no such
diagnostic and must not start now.

Recording a claim this investigation DISPROVED, because it was the original
motivation for the change: `group-*` and `peer-*` are NOT reordering barriers.
Tailwind 4.3.3 compiles them to `&:is(:where(.group):hover *)` — an `:is()` on the
element itself — so `peer-checked:group-hover:x` and `group-hover:peer-checked:x`
differ only in the order of two `:is()` compounds and match the same elements.
Reordering them is safe, and `flex group-hover:flex` really is a redundant
variant. Both are now locked down by tests, since neither rule had a single
`group-*`/`peer-*` case before.

Also in this commit, the two remaining gaps from the conflict work:

- Two spellings of the same number no longer read as a conflict:
  `slide-in-from-left` declares `--tw-enter-translate-x: -100%` and
  `slide-in-from-left-full` declares `calc(1 * -100%)`. The pair is `redundant`
  now. Only a single numeric term is unwrapped, so `calc(1 * 2px + 3px)` is left
  alone.
- A test that locks the premise the conflict diagnostics rest on: `cache.getOrder`
  must agree with the physical order of the compiled stylesheet. A drift there
  would make every message name the wrong winner with nothing failing, so the test
  compiles the stylesheet and compares byte offsets — including the
  counter-intuitive pairs (`shadow-sm` beats `shadow-lg`, `text-red-500` beats
  `text-blue-500`, `flex-row` beats `flex-col`).

And one robustness fix that this work paid for the hard way: a syntax error in
PRECOMPUTE_SCRIPT was unreportable. `signalError` lives inside the script, so it
is never installed, and the worker's 'error' event cannot be read because the main
thread parks in `Atomics.wait` and the event loop never turns. The failure
surfaced as a full-timeout blaming the user's machine ("raise the timeout"). The
script is now parsed before being handed to a worker, which turns that into the
actual SyntaxError — it is what found the escaping bug in the normalizer above.
The comparison key joined the box and the property with a NUL byte. It worked,
but it made git classify `decide.ts` as a binary file: the PR's whole
conflict-decision engine rendered as `Bin 0 -> 10828 bytes`, with no diff in
`git diff`, in `git log -p`, or in GitHub's review UI. The most delicate logic in
the change was the one part nobody could read.

`|` cannot occur in a CSS property or pseudo-element name, so the split is just as
unambiguous, and the docstring now says why the separator has to stay printable.
An adversarial review of the branch (four independent lenses over the diff, each
finding then re-verified against the real rule) found real defects. Fixing them
here rather than shipping them:

**The lint-time declaration service resolved nothing under a project prefix.** The
rule asks with prefix-free names — `extractUtility` strips `tw:` along with the
variants — and a prefixed design system only resolves the prefixed form. So every
user-written value went silently uncompared in `prefix(...)` projects, which is
the whole feature the previous commit added. The handler now applies the prefix
when talking to the design system and keys the answer prefix-free, the same
invariant the precompute follows.

**`blur-lg blur-none` stopped reporting.** Treating a custom property cleared to
`initial`/empty as carrying no information is right for `animate-in`, which clears
five `--tw-enter-*` vars so a modifier can set one — but for `blur-none`,
`via-none` or `drop-shadow-none` the reset IS the utility, and dropping it drops
the only thing the user asked for. The exemption now requires the resetting class
to declare something of its own; this was a regression against main.

**`space-x-4 space-x-reverse` was reported as a conflict.** That is verbatim
Tailwind documentation: the base writes the reverse flag as its registered default
and `*-reverse` flips it. Nothing in the emitted CSS says `0` was only the
`@property` default, so this is not derivable — it joins `spec.ts` with that
reason spelled out. `prose` + `text-*`/`leading-*` joins it too, for the same
reason the `prose` + `max-w-*` entry already existed: the plugin sets defaults it
means you to override.

**tw-animate-css's `slide-in-from-start` was called redundant.** It emits
`&:dir(ltr)` and `&:dir(rtl)` blocks that both classify as the element's own box,
so keeping the last declaration per (box, property) silently compared half the
class and told the user to remove it — reversing the LTR animation. A key a class
declares twice with different values is now excluded from comparison instead of
guessed at.

**The declaration service's memo outlived the cache holding its answers.** Keyed
by path, a rebuilt cache (an mtime bump in an editor process, or
`resetDesignSystem()`) inherited a full "already asked" set and could never
re-learn, going permanently blind to user-written values with no diagnostic. It is
now a WeakMap keyed by the cache instance, so there is nothing left to reset.
Lint-time classes also join `partialSet` now, so the "never call a partially
modelled class redundant" invariant covers them too.

Docs and CHANGELOG caught up with the code: the rule page no longer offers
`size-4 h-6` as a CORRECT example while the suite asserts it is a conflict, the
tables are no longer advertised as the extension point (`allow` is), the
`enforce-sort-order` interaction no longer claims attribute order decides the
winner, both `index.md` default tables match `meta`, and the pages for
`no-contradicting-variants`, `consistent-variant-order` and `prefer-theme-tokens`
describe what those rules now do. CLAUDE.md/AGENTS.md list the third worker
service and both DS-optional rules. The CHANGELOG heading is `## 1.4.0`, which is
what the release job looks for — left as `## Unreleased` it would have published
with empty release notes.
The fix for #93 was only ever verified against a `tailwind-scrollbar` fixture
living in a scratch directory, so nothing in the repo exercised the shape the PR
is named after.

A fixture reproduces it with `@utility`: a reader that forwards
`--gutter-thumb`/`--gutter-track` and writers that supply one each. The variables
are deliberately NOT named `--tw-*`, which is the whole point — the escape hatch
this PR replaced keyed on that convention and could not see the plugin's own
variables. Named `gutter-*` because Tailwind 4.3 ships real `scrollbar-*`
utilities that would collide.

Six cases, including the two that guard the subtle conditions: the reader with only
one of its two variables supplied still reports (the outcome genuinely depends on
which declaration wins), and a pure writer plus a concrete declarer that supplies
none of the reader's variables also reports — without the "winner must supply one
of them" condition, an unrelated utility outranking a reader/writer pair would
silence a real clobber.
@sergioazoc
sergioazoc merged commit 6346060 into main Jul 25, 2026
5 checks passed
@sergioazoc
sergioazoc deleted the fix/derive-conflicts-from-generated-css branch July 25, 2026 15:39
sergioazoc added a commit that referenced this pull request Jul 26, 2026
* fix(enforce-shorthand): decide every merge from the emitted CSS

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

* fix(no-deprecated-classes): derive the rename map, and stop double-reporting

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.

* fix(no-unknown-classes): validate the variant chain and the value, not 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.

* fix(no-dark-without-light): match the base by declared property, not 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.

* fix: close the remaining name-based gaps in four rules

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.

* docs: document the derived behaviour, bump to 1.5.0

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.

* perf: keep the new lookups off the hot path

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

* test(no-unknown-classes): guard the new strictness with real shadcn class 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.

* fix(no-unknown-classes): restore the fast path, and rebuild the fixed 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 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.

False positive in no-conflicting-classes for tailwind-scrollbar utilities (scrollbar-thin + scrollbar-thumb-*/scrollbar-track-*)

1 participant