fix: derive conflict detection from the generated CSS (closes #93) - #94
Merged
Conversation
…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
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.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Why
no-conflicting-classesdecided 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-scrollbarto reproduce — but a sweep over the ~23 000 utilities of a real design system found the defect fires on plain Tailwind, no plugins:text-gray-900 placeholder-gray-400placeholder-*styles::placeholder, not the elementms-2 space-x-4space-x-*styles the childrenmask-b-from-50% mask-b-from-blackdrop-shadow-xl drop-shadow-indigo-500scale-3d scale-x-110,translate-3d translate-x-4transform-gpu rotate-x-45rounded-t-lg rounded-l-lgAnd the mirror problem:
p-4 p-[5px],w-[10px] w-[20px],border-1 border-2,bg-red-500/50 bg-blue-500,rounded rounded-lgwere silently accepted whilep-4 p-6reported — 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 toinitialcarries 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 wheresize-4 h-6was accepted whileh-6 size-4reported, though both compile to identical CSS.Derivation replaced the tables of composing families.
spec.tswent 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 anallowoption instead of an issue and a release.Also in scope: user-written values are resolved from the design system at lint time;
prefer-theme-tokensno longer autofixesbg-(--primary)→bg-primarywhen the two mean different colours; andconsistent-variant-order/no-contradicting-variantsderive pseudo-element and barrier behaviour from real selectors, so a project's own@custom-variantis 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-fulland friends will surface in projects that pass CI today — setreportRedundant: falseto 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
cache.getOrdermust agree with the physical order of the compiled stylesheet, including the counter-intuitive pairs (shadow-smbeatsshadow-lg,text-red-500beatstext-blue-500,flex-rowbeatsflex-col). A drift there would make every message name the wrong winner with nothing failing.@utility, so CI needs no third-party dependency.blur-lg blur-noneregressing to silence,space-x-4 space-x-reversereported as a conflict, andslide-in-from-startcalled redundant. All are fixed here, with tests.Two things worth flagging for the reviewer's attention:
redundantbeing on by default is a judgement call, and thespace-*/divide-*+*-reversepair had to go intospec.tsbecause nothing in the emitted CSS says the0was only the variable's registered@propertydefault.🤖 Generated with Claude Code
https://claude.ai/code/session_014nYoQzdWv8TRfUUK7ZCnQg