@gamesheet-inc/oxlint-plugin
v0.2.0
Published
GameSheet's first-party Oxlint rules.
Maintainers
Readme
@gamesheet-inc/oxlint-plugin
GameSheet's first-party Oxlint rules.
Install
pnpm add -D @gamesheet-inc/oxlint-plugin @oxlint/plugins oxlint@oxlint/plugins is a peer dependency and must match your oxlint version.
Configure
In .oxlintrc.json:
{
"jsPlugins": [{ "name": "gamesheet", "specifier": "@gamesheet-inc/oxlint-plugin" }],
"rules": {
"gamesheet/no-comments": "error",
"gamesheet/no-tailwind-v3-classes": "error",
"gamesheet/no-tailwind-important-prefix": "error"
}
}The two Tailwind rules are for repos on Tailwind v4. A repo still on v3 should leave them off: everything they flag is correct v3.
Rules
gamesheet/no-comments
Bans // line comments and /* */ block comments. Autofixable.
Exempt unconditionally, regardless of allow: JSDoc (/** */) of any
length, a /*! preserve/license banner, shebangs, and — only in block-comment
form (/* global foo */, never // global foo) — a bare ESLint config
comment (eslint <rule>: ...), eslint-env, global, globals, exported.
ESLint itself never recognizes these five as a // line comment, and gating
them on the comment's actual type is what keeps this rule from also exempting
ordinary prose that happens to contain the same words — "exported for
testing", "global state is a problem here" — which a text-only pattern can't
tell apart from the real directive.
Exempt by default (overridable via the allow option below) — machine-read
directives:
eslint-disable*/eslint-enable*,oxlint-disable*/oxlint-enable*@ts-*@jsx*(@jsxImportSource,@jsxFrag,@jsxRuntime, and friends)/// <reference>prettier-ignore,oxfmt-ignorev8/c8/istanbul ignore#__PURE__/@__PURE__,#__NO_SIDE_EFFECTS__- the webpack magic-comment family:
webpackChunkName,webpackIgnore,webpackPreload,webpackPrefetch,webpackMode,webpackExports,webpackInclude @vite-ignore//# sourceMappingURL=,//# sourceURL=falls through/fallthrough/Fall through.— but only when that phrase is the entire comment, optionally with a trailing period. Unlike ESLint's ownno-fallthrough, which only ever looks at the last comment before acaselabel, this rule scans every comment in the file, so an unanchored version of this pattern would also exempt any sentence that merely mentions falling through something ("the request falls through the cache and hits origin").@vitest-environment,@jest-environment- any comment containing
SAFETY:
SAFETY: is exempt because anti-slop/require-safety-comment-for-type-assertion
requires such a comment for every non-const type assertion. Banning them makes
that rule unsatisfiable.
Option allow replaces the default list entirely, so include the directive
patterns you still need. Every pattern must tolerate leading whitespace —
Oxlint's Comment.value retains the space after //, so a pattern anchored
with a bare ^ will match nothing. Patterns compile with the u flag, so an
unnecessary escape — prettier\-ignore, say — throws Invalid regular
expression: /prettier\-ignore/u: Invalid escape and fails every file, not
just the pattern that has the typo.
{
"rules": {
"gamesheet/no-comments": ["error", { "allow": ["^\\s*@ts-", "\\bSAFETY\\s*:"] }]
}
}Durable knowledge does not belong in a deleted comment. Move it to a JSDoc
block or a docs/constraints.md in the consuming repo before running --fix.
gamesheet/no-tailwind-v3-classes
Flags Tailwind v3 class names that Tailwind v4 removed, renamed, or compiles to
invalid CSS. What v4 actually does with each class was checked against the real
v4.3 compiler, not the upgrade guide, and __tests__/tailwind-oracle.test.ts
keeps checking it.
| What v4 does with it | Classes | Write instead | Fix |
| --- | --- | --- | --- |
| Produces no CSS at all | bg-opacity-*, text-opacity-*, border-opacity-*, divide-opacity-*, ring-opacity-*, placeholder-opacity-*, with any suffix: a number, a theme key, an arbitrary value | the opacity modifier on the colour utility: bg-black/50 | report only; the rewrite needs the colour class it pairs with |
| Compiles to invalid CSS (background-color: --brand) | bg-[--brand], any <utility>-[--var], with or without a /50 modifier | bg-(--brand) | autofix |
| Still compiles through a legacy table, but is not the v4 name | flex-shrink, flex-shrink-*, flex-grow, flex-grow-*, overflow-ellipsis, decoration-slice, decoration-clone, bg-gradient-to-*, bg-left-top and the other three bg- corners, object-left-top and the other three object- corners | shrink, shrink-*, grow, grow-*, text-ellipsis, box-decoration-slice, box-decoration-clone, bg-linear-to-*, bg-top-left, object-top-left | autofix |
The rename list is the one @tailwindcss/upgrade applies. Variants, the !
modifier, a negative sign and /opacity modifiers all survive the rewrite:
md:!flex-shrink-0 becomes md:!shrink-0, -translate-x-[--shift] becomes
-translate-x-(--shift).
Not flagged, on purpose. shadow-sm, shadow, rounded-sm, rounded,
blur-sm, blur, drop-shadow*, backdrop-blur*, ring and outline-none
all changed meaning between v3 and v4: the size scales shifted one step, ring
went from 3px to 1px, and outline-none now really sets outline-style: none
(v3's behaviour is outline-hidden). Every one is a valid v4 class, so a linter
cannot tell a v3 leftover from a deliberate v4 choice. Across the GameSheet v4
repos there are 126 outline-none, 103 rounded-sm and 55 shadow-sm in
legitimate use. Also left alone: v3's variant order (first:*:pt-0 versus
*:first:pt-0), placeholder-<colour> (v4 still compiles it), and the later
v4.x modernisations such as break-words to wrap-break-word or order-none
to order-0, which are v4-to-v4 changes, not v3-to-v4.
A class cut by a ${} boundary, such as flex-shrink-${n}, is skipped: half a
class cannot be classified.
Option ignore lists class names never to flag, matched against the whole
token. Tailwind v4 compiles flex-grow-1 through its legacy table, and so does
Bootstrap. A repo that mixes Bootstrap with Tailwind, as admin-dashboard-v3
does, should list Bootstrap's flex utilities here.
{
"rules": {
"gamesheet/no-tailwind-v3-classes": ["error", { "ignore": ["flex-grow-1", "flex-shrink-1"] }]
}
}gamesheet/no-tailwind-important-prefix
Tailwind v4 moved the important modifier to the end of the class. !bg-white is
the deprecated v3 spelling of bg-white!; both compile to the same CSS today.
Autofixable.
- Variants and negatives stay where they are:
hover:!text-red-500becomeshover:text-red-500!,!-mt-2becomes-mt-2!, and[&_svg]:!size-4becomes[&_svg]:size-4!. A colon inside[...]or(...)is part of an arbitrary value, not a variant separator. !importantinside a string is CSS, never a class, and is ignored.- A class cut by
${}, such as!bg-${tone}-500, is reported without a fix.
Where the Tailwind rules look for classes
Both rules share one finder. Every string literal and template-literal quasi is examined when it sits under
- a JSX attribute named
classNameorclass(optionattributes), - an argument of
cn,clsx,classnames,classNames,cva,tv,twMerge,twJoinorcx(optioncallees), including the keys of a clsx-style object and the variant mapscvaandtvtake, - a
twtagged template (optiontags),
reached through the branches of a conditional (never its test, so
state === "flex-grow" ? a : b keeps its comparison), && and ||, +
concatenation, arrays, objects, spreads and as casts. Inside a cva or tv
config the values of variants and slots and the class/className of a
compound entry are classes; variant names, defaultVariants and compound
selectors are not, so a variant option that happens to be called flex-grow
keeps its name. Anywhere else, a string is scanned only when it reads as a class list:
two or more whitespace-separated tokens, every one a plausible utility, a
template literal being judged as a whole. A token is a plausible utility when it
parses as a class and its utility part has a hyphen, a digit or an arbitrary
value, or is one of the 42 hyphen-less names the v4 compiler enumerates
(flex, hidden, rounded, ...; the oracle test re-derives that list). That
is what finds toastOptions={{ classNames: { success: "!bg-green-50 ..." } }}
and const styles = { primary: "..." }, while a one-token string such as
setProperty("flex-grow", "1") or import "!raw-loader!./x" is never touched,
and prose that merely mentions a class, "supports flex-grow utilities" or a
test title ending in "returns !ok", is never scanned, let alone rewritten.
Each option replaces its default list rather than extending it:
{
"rules": {
"gamesheet/no-tailwind-important-prefix": [
"error",
{ "attributes": ["className", "class", "iconClassName"], "callees": ["cn", "cva"], "tags": ["tw"] }
]
}
}Running --fix with both rules on. Oxlint applies one fix per overlapping
range per run and does not loop, so a class both rules want to rewrite,
hover:!flex-shrink, becomes hover:shrink! on the second oxlint --fix. Run
it until clean, which is already how gamesheet/no-comments is applied in this
org's repos. Splitting a fix into disjoint spans would not help: like ESLint,
Oxlint merges an array of fixes into one range, so the two rules' edits overlap
however they are expressed. __tests__/tailwind-both-rules.test.ts pins the
two-pass contract.
Measured on 2026-09-03 against the main checkouts of the GameSheet repos on Tailwind v4, build output excluded, before enabling the rules anywhere:
| repo | no-tailwind-v3-classes | no-tailwind-important-prefix |
| --- | --- | --- |
| app-stats-widget-next | 23 (flex-shrink-0) | 1 |
| mono/apps/teams | 47 (flex-shrink-0) | 0 |
| mono/packages/ui | 5 (ring-opacity-5) | 0 |
| project-wingman | 15 (9 flex-shrink-0, 6 *-opacity-*) | 4 |
| project-forge | 2 | 16 |
| tournament-board-builder | 0 | 16 |
| standup-picker | 3 (bg-gradient-to-*) | 0 |
| admin-dashboard-v3 (not on oxlint) | 5, of which 4 are Bootstrap's flex-grow-1 | 15 |
Every finding was read at its source line; none is a false positive, and the
four Bootstrap hits are the case the ignore option exists for. The 32
!-prefix findings in project-forge and tournament-board-builder sit in
sonner's classNames object, which only the class-list heuristic reaches.
Adding a rule
Each new rule lands with a measured finding count against a real GameSheet repo
and a fixture pair under __fixtures__/. That gate is what keeps the
false-positive rate at zero.
Development
pnpm install
pnpm test # builds dist via prepack, then runs the suite
pnpm build # tsc -p tsconfig.build.json
pnpm lint
pnpm type-checkAuthored in TypeScript, published as JavaScript. dist/ is gitignored and built
by prepack (before any pnpm pack) and by prepublishOnly — not by an
install-time prepare, because pnpm blocks lifecycle scripts by default and that
would force an onlyBuiltDependencies entry in every consuming repo.
The build step is not optional. Node's ESM loader refuses to type-strip any .ts
file whose realpath is under a node_modules path, so a package with a .ts
entry point cannot be consumed as a dependency — only via a symlink or a
workspace link, which is what made the original design look viable.
Rules are tested by shelling the real oxlint binary at fixtures and asserting
on its JSON diagnostics, because @oxlint/plugins ships no RuleTester.
tailwindcss is a devDependency only so that __tests__/tailwind-oracle.test.ts
can compile every class the Tailwind rules name and prove the table is right;
consumers never install it through this package.
__tests__/packaging.test.ts is the one test that exercises how consumers
actually load the plugin — a real pnpm pack, a real install, a real lint.
Releasing
Nothing to click. Every push to main whose test job passes runs the release job in
.github/workflows/ci.yml: it derives the next version from the conventional
commits since the last v* tag, publishes to npm through Trusted Publishing,
and creates the GitHub Release, which creates the tag. scripts/next-version.ts
is the computation; __tests__/next-version.test.ts pins it, including the real
v0.1.1..6eb80f4 history.
| Commits since the last tag | Result |
|---|---|
| any feat | minor |
| fix, perf, refactor, revert | patch |
| ! before the colon, or a BREAKING CHANGE: footer | major — minor while the package is 0.x |
| only docs, ci, test, chore, style, build, or non-conventional subjects | no release |
package.json is a floor, not the source of the number: the job publishes
max(bump, package.json). Bump it in a PR only to force a release that has no
releasable commit, or to jump (to 1.0.0, which commits cannot express from
0.x). Otherwise leave it alone — the commit a tag points at may carry a lower
package.json than the tag; the published tarball is what is truthful.
Squash-merged PRs need a conventional title, because the squash commit's subject is what gets parsed. Merge commits themselves are skipped; the commits they bring in are read.
A release is idempotent. Re-running a successful job publishes nothing (the range
since the new tag is empty). A run that fails after npm publish skips the
publish on re-run and only creates the Release. The run's summary links the npm
version and the Release, or says why nothing was released.
Publishing is authorised by an npm trusted publisher keyed to this repository and
the workflow file ci.yml. Moving the job to another file needs a new trusted
publisher on npmjs.com first, with "allow npm publish" ticked — configurations
created after 2026-09-03 default to staged publishing only.
