041 — The utilities that were never removed: the CSS auditor, and the pack that told readers their working markup was broken

2026-09-03. Backlog item 2f, the seventh and last pack. JOURNAL/038 parked tailwindcss with an honest limit rather than a green tick: "only 1 TS fact of 29; the pack is CSS/HTML and this tool cannot read it. A real check needs a tailwind build, not a type-checker. Low yield, note the limit rather than pretend coverage."

The limit was right. The yield estimate was wrong. The pack carried the most consequential defect the audit programme has found so far — a fact filed S1 breaks-build about five class names that have never stopped working in any 4.x release, and that compile to CSS byte-identical to the replacements the Index told readers to switch to.


The instrument: tools/audit/css-audit.mjs

The other two auditors check a claim of the form this name exists and this call type-checks. The tailwindcss pack makes almost none of those. Its 29 facts are 15 class names in markup, 11 pieces of stylesheet syntax, 2 JS configs and one line of prose about browser baselines. Nothing in the repo could read them.

Tailwind v4 ships its compiler as a library — compile(css, opts) returning build(candidates) — so the oracle was available the whole time. Two passes:

  1. Stylesheet. The snippet's CSS is compiled by the installed tailwindcss. A removed at-rule, a theme() argument that no longer resolves, a directive that arrived in a later release than the fact is filed under: all of them throw here.
  2. Candidates. Every class name in the snippet's markup is built one at a time against that same stylesheet, and the output is diffed against the same stylesheet built with no candidates at all. A class that adds nothing generates nothing: it does not exist at that version.

The two halves compose the way a reader actually uses the pack — a fact's own CSS is the entry file its classes are built against. @import "tailwindcss" prefix(tw) really does decide whether tw:flex resolves (LF21: it does, and all three v3-style tw- classes generate nothing); @theme { --color-brand: … } really does decide whether bg-brand does.

One mechanical detail worth carrying forward: a compiled sheet builds incrementally. Hand it five candidates at once and only the first shows up as a diff. Every candidate needs its own compile.


LF5: five utilities the release note removed and the compiler kept

The fact, as published since 2026-08-31:

change_kind: removed · severity: S1 breaks-build These long-deprecated aliases were removed: use shrink-*, grow-*, text-ellipsis, box-decoration-slice and box-decoration-clone.

Its citation is the upgrade guide, quoted exactly:

We've removed any utilities that were deprecated in v3 and have been undocumented for several years.

Built against four installed releases — 4.0.0, 4.1.0, 4.2.0 and 4.3.3:

4.0.0 flex-shrink-0 .flex-shrink-0 { flex-shrink: 0; } 4.0.0 shrink-0 .shrink-0 { flex-shrink: 0; } 4.0.0 flex-grow .flex-grow { flex-grow: 1; } 4.0.0 grow .grow { flex-grow: 1; } 4.0.0 overflow-ellipsis .overflow-ellipsis { text-overflow: ellipsis; } 4.0.0 text-ellipsis .text-ellipsis { text-overflow: ellipsis; } 4.0.0 decoration-slice .decoration-slice { -webkit-box-decoration-break: slice; box-decoration-break: slice; } 4.0.0 box-decoration-slice { … the same two declarations … }

Identical at 4.3.3. Not "still parses" — the same declarations, five for five, at the earliest release the fact is filed under and at the newest release that exists.

The mechanism is visible in the shipped bundle. Each old name is registered:

e.utilities.functional("flex-shrink", …) e.utilities.static("overflow-ellipsis", () => [a("text-overflow","ellipsis")]) e.utilities.static("decoration-slice", …)

and each is immediately preceded by an empty suggestion list:

e.utilities.suggest("flex-shrink", () => [])

Registered, and hidden from tooling. That is what "removed" meant: removed from the documentation and from IntelliSense, not from the compiler.

This is the third instance of a release note that is accurate about the intent and wrong about the artifact — valibot's coerce archaeology (JOURNAL/027) and valibot's isbn (JOURNAL/040) are the others. It is the first where the error ran in the harmful direction. isbn cost a model a finding it did not deserve. LF5 cost readers: a correction pack, filed at the highest severity the scale has, telling anyone with flex-shrink-0 in their markup that their build was broken. It was not. It compiled to exactly the CSS the Index recommended they switch to.

Corrected: removeddeprecated, S1 breaks-build → S3 deprecated, the statement rewritten to say the aliases were dropped from the docs and not from the compiler, the upgrade-guide citation retitled to say what it is accurate about, and two shipped-package citations added.

The same sentence, one fact over, is correct

LF4 — bg-opacity-*, text-opacity-* and their four siblings — cites that identical upgrade guide sentence, at the same severity, filed at the same release. bg-opacity-50 built against 4.3.3 generates nothing. LF4 is right.

Two facts, one citation, one true and one false. Nothing in the prose could separate them and the 2026-09-02 hand sweep of all 168 facts (JOURNAL/037) read straight past both. Only the compiler told them apart. A citation shared between two facts verifies neither — that is now a rule.


LF27: bg-gradient-* is an alias the compiler rewrites

Smaller, and it sharpens a hedge the pack was already carrying. LF27 said the gradient utilities were "renamed from bg-gradient-* to bg-linear-*", with a note admitting the old name had not been verified to stop working.

It has now been verified to keep working, and by a stronger mechanism than backwards tolerance. At 4.0.0 bg-gradient-to-r emits declarations identical to bg-linear-to-r. At 4.3.3 the two differ slightly and both still render a gradient. The bundle says why — the candidate parser rewrites the root before compiling it:

if (e.kind === "static" && e.root.startsWith("bg-gradient-to-")) { … e.root = bg-linear-to-${i} }

The two directional families cannot diverge, because one is the other. Only bg-linear-45 and the bg-radial-* / bg-conic-* families are genuinely new surface. renameddeprecated, and the statement now says the old spelling is a retained alias rather than a dead one.


Four correct facts the first version of this tool flagged

The naive rule — a removed/renamed fact whose stale class still compiles is a defect — produced seven flags on its first run, four of them wrong. Fixing that is most of the tool's design, and each gate is a rule:

(a) Scope the claim to the family the fact's own api names. bg-black sits in both halves of LF4 (bg-black bg-opacity-50bg-black/50) and carries no claim; it was being flagged as a surviving removed utility. Only a class matching a family in api can falsify the fact. Where api is prose — "stacked variant order", "prefix" — nothing is flagged and the classes are reported instead. Silence beats a false positive.

(b) Compare declarations, not existence. LF6 (shadow-sm is now one step larger), LF7 (outline-none no longer means what v3 meant by it) and LF10 (bg-[--brand] emits background-color: --brand, an inert value, where bg-(--brand) emits var(--brand)) all say in so many words that the old class survives and means something else. Their stale classes should still generate. Flag only when the survivor emits declarations identical to a class the fact prescribes — that is the case where a reader "fixing" their markup changes nothing. Different output is a note carrying both rules, for a person to read.

(c) deprecated is exempt by definition. Deprecated means it still works. LF15 (!flexflex!) and LF29 (start-*inset-s-*) would otherwise be flagged for being accurate.

(d) A stub cannot fail a fact. LF13's correction is @reference "../assets/main.css" — the reader's own entry file, which the audit does not have. Stubbed empty, @apply text-brand throws for the stub's reason, not the fact's. Every stub is recorded and a stubbed failure is downgraded to a note.

(e) The auditor has to read replacement_available_from. LF12's @source inline(…) throws at 4.0.0 — @source paths must be quoted — which is exactly what its declared 4.1.0 floor says will happen. Below the declared release the fact reports UNCH; at or above it, a failure is a finding again. Without this the field that exists to record the defect class manufactures findings about itself.


Proving it can fail

JOURNAL/038's rule (c). Three deliberate defects and two deliberate non-defects, injected into a copy of the manifest:

injectedexpectedgot
prescribes made-up-42FLAGFLAG — generates NO CSS
stylesheet with @apply not-a-utilityFLAGFLAG — does not compile
removed + stale emits same as prescribedFLAGFLAG — present and equivalent
the same fact filed deprecatedokok
renamed + stale emits something differentokok, with the note

Plus the live demonstration: LF5 and LF27 flagged, the data was corrected, and both went green — the failure and the pass came from the same instrument on the same day.


Results

versionscopeokflaggeduncheckedskipped
4.3.3whole pack25004
4.0.0facts filed at 4.0.022016
4.1.0facts filed at 4.1.010028
4.2.0facts filed at 4.2.010028

Coverage at 4.3.3: 19 stylesheets compiled, 39 prescribed classes built and diffed, 23 stale-only classes built and diffed. The one UNCH is LF12 below its declared floor, which is the correct reading and not a gap.

Four facts are beyond this tool and are counted as skipped rather than clean: LF3 and LF14 are JS/PostCSS config (the Node auditor's shape, not this one's), LF25 and LF26 are prose about preprocessors and browser baselines with no code at all.

All seven packs are now machine-audited. Eight defects found and fixed across the programme: langchain LF24; prisma LF21, LF25, LF5, LF18; zod LF16; valibot LF3 (which also withdrew a published finding); tailwindcss LF5. Plus two imprecisions sharpened — better-auth's two structurally uncheckable facts recorded as such, and tailwindcss LF27.

The pattern across all of them is one sentence: the pack's prose was checked by people and its code was not. Every defect the three auditors found had survived at least one careful human reading of the same fact.


Dataset unchanged: 72 runs, 124 findings (117 chargeable), 7 facts files. No published finding depends on LF5 or LF27 — tailwindcss has exactly one finding across three subjects (Opus 5 F1, an S4 about a boundary), so this correction touches the packs and the site and no run. The site rebuilt at 131 pages, 3130 internal links checked.