Skip to content

fix(enforce-canonical): find the class wherever its selector puts it - #228

Merged
sergioazoc merged 3 commits into
mainfrom
fix/canonical-own-class-anywhere
Oct 9, 2026
Merged

sergioazoc merged 3 commits into
mainfrom
fix/canonical-own-class-anywhere

Conversation

@sergioazoc

Copy link
Copy Markdown
Owner

Part of #213.

Summary

enforce-canonical rewrites a class only when its canonical form prints the same CSS (#78). It compares the two with each one's own class name neutralized, but only a name that opened a statement was (#156), and plenty of selectors don't start with it:

Variant / utility What Tailwind prints
in-* :where(:focus) .in-focus\:x
*: / **: :is(.\*\:x > *)
divide-* :where(.divide-x-2 > :not(:last-child))
group-* / peer-*, after 4.3.3 (tailwindlabs/tailwindcss#20513) :is(:where(.group):hover .group-hover\:x)

Each of those rewrites read as a selector change and was never reported. On 4.3.3 today that means in-focus:z-[10], *:z-[10], divide-x-[2px] and the like. On Tailwind's next release it would have been every group-* and peer-* rewrite too, and consumers float on ^4.x, so they would get that before this repo bumps.

  • The fix: the token neutralized is the one Tailwind prints, . + the class serialized like CSS.escape (Tailwind's escape() is that algorithm). It is matched whole (not the start of a longer name, not after a \) and never inside a string. Every other class in the selector is compared as written, so a canonicalization that changes the variant's selector ([&>*]: → *:, has-[:checked]: → has-checked:) still differs and stays unreported. CANON_LOGIC_HASH renames the canonical cache; the precompute is untouched.
  • Canary: a new suite job runs the package's tests with tailwindcss@latest and @insiders in place of the plugin's own engine. The smoke passed on 2026-09-28 and 2026-10-05 with the insiders build that carries #20513; the suite caught it on its first run.
    • It installs the dist-tag's exact version. Given a tag, pnpm 12 saves ^0.0.0-insiders.<sha> even with --save-exact, and that range resolves to a 2023 0.0.0-oxide-insiders.* build (345 failures).
    • Two engine-guard tests now expect what the guard does with an insiders engine when one is installed: one notice, no drift check.

Test plan

  • Rule: in-focus:, in-data-[open]:, *:, **:, divide-x-[2px], peer-checked:, -z- and @md: rewrite with their autofix; has-[:checked]: and [&>*]: still don't.
  • Service on the real engine (4.3.3), and the shipped handler string on the CSS insiders prints for group-* / peer-* (verbatim from 0.0.0-insiders.fa81d69).
  • Without the fix, 8 of the new tests fail. Dropping the string skip, the whole-name check, the backslash check, or the escape of a leading digit or a symbol each fails a test.
  • The handler's token equals Tailwind's own escape() on 26 names (leading digit, -, -1, control characters, non-ASCII, emoji, !, @, /, brackets…), checked against the function extracted from tailwindcss/dist.
  • A consumer project on insiders, linted with this branch: 13 of 13 non-canonical classes reported (4 before), and the same 13 on 4.3.3.
  • The whole suite with tailwindcss@0.0.0-insiders.fa81d69 installed the way the canary does it: 3782/3782 (before: 3 failures, one of them this bug).
  • Lint, lint:types, format:check, typecheck, build and pnpm test (3782 + 312); bench npm test. The corpus and synthetic runs are identical to the baseline, so results/latest.json doesn't move.

🤖 Generated with Claude Code

https://claude.ai/code/session_019vTh6S5A41M1HLkmk3FaM1

The safe gate compares a class's CSS with its canonical form's, each with its
own name neutralized. Only a name that OPENED a statement was (#156), and
plenty of selectors don't start with it: in-* (`:where(:focus) .x`), *: and
**: (`:is(.x > *)`), divide-* (`:where(.x > :not(:last-child))`) and, after
Tailwind 4.3.3 (tailwindlabs/tailwindcss#20513), every group-* and peer-*
(`:is(:where(.group):hover .x)`). Each of those rewrites read as a selector
change and was never reported: in-focus:z-[10], *:z-[10] or divide-x-[2px]
today, and every group-hover:z-[10] on Tailwind's next release.

The token is now the one Tailwind prints, a `.` and the class serialized the
way CSS.escape does it (Tailwind's escape() is that algorithm), matched whole:
not as the start of a longer name, not after a `\`, never inside a string.
Every other class in the selector is compared as written, so a
canonicalization that changes the variant's selector still differs. The
handler changed, so CANON_LOGIC_HASH renames the canonical cache; the
precompute is untouched.

Tests: the rule on in-*, *:, **:, divide-*, peer-*, -z- and @md:; the service
on the real engine, and the handler on the CSS insiders prints for group-* and
peer-*. Without the change 8 of them fail, and dropping the string skip, the
whole-name check, the backslash check or the escape of a leading digit or a
symbol each fails one.

Claude-Session: https://claude.ai/code/session_019vTh6S5A41M1HLkmk3FaM1
The smoke installs the engine in a fresh consumer project and checks that
every rule runs, coarse on purpose, so it can't see a change in the CSS
Tailwind prints. It passed on 2026-09-28 and 2026-10-05 with the insiders build
that moves group-*/peer-* classes inside their :is(), while enforce-canonical
had stopped reporting every rewrite under those variants. The whole suite
caught it on its first run.

A `suite` job swaps tailwindcss@latest / @insiders in for the plugin's own
engine and runs the package's tests. Two engine-guard tests now expect what the
guard does with an insiders engine when one is installed: a single notice, and
no drift check against it.

Claude-Session: https://claude.ai/code/session_019vTh6S5A41M1HLkmk3FaM1
…AUDE.md

Given a dist-tag, pnpm 12 saves `^0.0.0-insiders.<sha>` even with
--save-exact, and that range resolves to whichever 0.0.0 prerelease sorts
highest: tailwindcss@0.0.0-oxide-insiders.ff2c25f (2023) and
@tailwindcss/node@0.0.0-insiders.ffb1d72 (2025), on which 345 tests failed. The
suite job resolves the tag with `npm view` and adds that exact version; run
that way on 0.0.0-insiders.fa81d69, the suite passes, 3782 of 3782.

CHANGELOG (Unreleased): the enforce-canonical fix, with the reports it can add.
CLAUDE.md: the safe gate as it is now (own(), escapeClass, the shapes that
don't open the selector), and the canary's suite job with what it asks of a
test that asserts on the installed engine.

Claude-Session: https://claude.ai/code/session_019vTh6S5A41M1HLkmk3FaM1
@sergioazoc sergioazoc mentioned this pull request Oct 9, 2026
@sergioazoc
sergioazoc merged commit 93ca78c into main Oct 9, 2026
16 checks passed
@sergioazoc
sergioazoc deleted the fix/canonical-own-class-anywhere branch October 9, 2026 20:27
@sergioazoc sergioazoc mentioned this pull request Oct 9, 2026
3 tasks done
sergioazoc added a commit that referenced this pull request Oct 9, 2026
- packages/oxlint-tailwindcss/package.json and the Claude Code plugin's
  plugin.json at 1.15.1.
- CHANGELOG: Unreleased → 1.15.1 — Before you upgrade (enforce-canonical's
  reports under in-*, *: and **:, and on divide-*; none on shadcn/ui apps/v4)
  and Bug fixes (#228).
- bench/results/latest.json regenerated after the bump (only the version and
  timings move), /benchmark and /comparison regenerated from it.

Claude-Session: https://claude.ai/code/session_019vTh6S5A41M1HLkmk3FaM1
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