Lintmax
Lintmax lint/format orchestrator conventions
lintmax = biome + oxlint + eslint + prettier + sort-package-json in one command; we own it.
Every lintmax version runs configless by default. lintmax (TypeScript) is the sole exception — its max-strict default is dense enough that a project needs lintmax.config.ts to opt a rule out, so it supports one; every other version (lintmax-go, lintmax-rs, …) stays config-free. A project config opts a rule out only with documented false-positive evidence, never to dodge a fix.
MUST
- Run only
bun run fixfor code maintenance. Why: it fixes then verifies internally (all 5 linters twice); a clean run printsokon a single line + exit 0 —okIS the success signal, not silence. - Read failure output directly. Why: already grouped file→linter→rule, compressed line numbers, deduped across 5 linters.
- Make ALL edits first, then run
fixforeground to completion. Why: editing during a backgroundedfixraces it — the formatter writes its pre-edit buffer back and silently reverts your change. - WHEN a
fixis running, wait untilpgrep -f 'lintmax|bun.*fix'is clear before editing. Why: same revert race. - Commit a checkpoint before any multi-file mutator (
fixafter stripping directives, audit/codemod, sed-all/rename-all). Why:fixmixes autofixes with your edits;git reset --hardthen restores in one command. - Batch many edits, run
fixonce at the end. Why:fixis slow per run. - File code-lint gaps upstream against lintmax. Why: it is the only lint tool — domain-specific hand-rolled
tools/*.tschecks (banned vocab, spec-vs-code diff) are fine; code-lint is not.
NEVER
- Never run
bun run check/lintmax checkfor maintenance —checkis CI-only;fixis the agent-side maintenance command. Cost: redundant afterfix, wastes 2+ min re-running 5 linters. - Never use
| tail/| headon any lintmax command. Cost: empty output IS success; failure output is already agent-formatted — truncation hides violations. - Never run
lintmax check --humanto “see violations”. Cost: runbun run fixand read its failure output. - Never add a second code-lint tool — extra eslint plugins, stylelint, knip, depcheck, dependency-cruiser, size-limit. Cost: fragments lintmax’s curated surface, drifts.
- Never use the
voidoperator. Cost:fixauto-deletes it (no-void) —void promise()→ bare expr →noUnusedExpressions;() => { void mutate() }→() => { undefined }, dropping the call.
void replacements
- Unused promise:
promise.catch(() => {})ortry { await ... } catch {}. - Async in a
() => voidslot (onClick):() => { mutate().catch(console.error) }, or widen the prop type to() => void | Promise<void>. - Async inside a
useEffectbody (slot type can’t be widened): wrap in an IIFE;(async () => { ... })()or.catch(noop). - Unused var: rename
_xor remove it.
Ignore syntax
| Linter | File-level | Per-line |
|---|---|---|
| oxlint | /* oxlint-disable rule */ | // oxlint-disable-next-line rule |
| eslint | /* eslint-disable rule */ | // eslint-disable-next-line rule |
| biome | /** biome-ignore-all lint/cat/rule: reason */ | /** biome-ignore lint/cat/rule: reason */ |
Ignore strategy
- Fix every legit, fixable finding by fixing the code, never the rule; ignore is last resort. Why: a found-and-fixable finding disabled is the severity/effort loophole that lets a real defect rot — the rule stays, the code changes. The only legitimate disable is a documented false-positive-rate, logged as an exception.
- File-level disable WHEN a file has many unavoidable same-rule violations (sequential DB mutations, standard React patterns, external images); per-line for an isolated one. Why: scale-appropriate.
- File-level directive at absolute file top, above imports/code (incl
'use client'/'use node'); per-line on the line ABOVE the code. Why: per-line inline tripsno-inline-comments. - WHEN 2+ linters flag one line, file-level for one + per-line for the other. Why: stacking multiple per-line above one line is banned.
- One top
eslint-disableper file, multiple rules comma-joined; keep one canonical block, remove duplicates. Why: dedupe. - WHEN a file-level
biome-ignore-allexists, drop the redundant per-linebiome-ignorefor that same rule. Why: file-level already covers every line. - NEVER 5+ per-line ignores for one rule. Cost: use file-level instead.
- Don’t hand-remove dead directives or add one “just in case”. Why:
fixauto-removes UNUSED file-leveloxlint-disable/biome-ignore-all(both/**and//forms) by strip-relint-in-place; if a rule doesn’t fire,fixdrops it andcheckfails on it.
Cross-linter
- Same rule in 2 linters (biome
noAwaitInLoops+ oxlintno-await-in-loop) = double enforcement, not conflict — never disable one. Why: both must pass. - Suppress a shared eslint/oxlint rule on eslint’s side. Why: oxlint auto-picks up eslint rules and is faster.
- oxlint
eslint/sort-keysis disabled in lintmax. Why: conflicts with perfectionist (ASCII vs natural sort).
Never-ignore rules
lintmax check FAILS on these suppressions, used or unused — no suppress-for-now path reaches CI. Fix the code:
@typescript-eslint/no-unsafe-*(assignment, call, member-access, return, argument) — use proper types.@typescript-eslint/no-explicit-any— define the actual type.@ts-ignore/@ts-expect-error/@ts-nocheck— fix the type error.@typescript-eslint/no-non-null-assertion— handle the null case.
Fixes, not suppressions:
- Test-file exception:
@ts-expect-error+no-explicit-anyallowed in test files only (asserting a wrong type is rejected); the rest forbidden everywhere. - Untyped third-party dep (types resolve to
any: brokenexports.types, unresolvedtypeof import(...)): cast through a typed facade at one boundary —const get = rawGet as <T>(k: string) => Promise<T | undefined>, orconst x: unknown = await loader.init(); return x as MonacoApiwith a minimal interface. Neveras any. - Non-null (
x[i]!): null-check (const v = x[i]; if (v) ...) orconst-tuple ([...] as const) so fixed indices type as defined. no-unsafe-*on a visible-shape stub:(() => undefined) as never(bottom type, no visible ops);((..._: unknown[]) => ({})) as neverstill trips. Tighten withnever/ branded / generic.
Safe-to-ignore
- oxlint:
promise/prefer-await-to-then(Promise.race, ky chaining). - eslint:
no-await-in-loop,max-statements,max-depth,complexity(sequential ops) ·no-unnecessary-condition(narrowing) ·promise-function-async(thenable returns) ·max-params·@next/next/no-img-element(external images) ·react-hooks/refs. - biome:
style/noProcessEnv(env files) ·performance/noAwaitInLoops(sequential ops) ·nursery/noForIn·performance/noImgElement·suspicious/noExplicitAny(generic boundaries).
Playbook maintenance
- Merge each new lesson into the most relevant existing section immediately; correct rules in place, remove superseded guidance. Why: single source of truth, no append-only “recent lessons” buckets.
Gotchas
- A rule oxlint REPORTS may not exist in oxlint's own config namespace, because oxlint auto-adopts eslint plugin rules that
--print-confignever lists — naming such a rule inoxlintrc.jsonis a hard parse error (Rule 'function-component-definition' not found in plugin 'react'), and setting it'off'in the eslint config does not silence oxlint's copy. The only suppression path is the CLI allow-list (OXLINT_CLI_ALLOW), which is exactly why that list mirrors rules eslint deliberately turns off. Grep the resolved--print-configoutput for the real id first: searching it for the reportedreact/function-component-definitionandnode/no-top-level-awaitreturnsreact/prefer-function-componentandunicorn/prefer-top-level-await— different rules entirely, so editing what looks like the match changes nothing. - A green
fixproves nothing about a tree whosenode_modulespredates the current oxlint release:up.shdeletesbun.lockand reinstalls, so CI resolves a newer oxlint whose newly-enabled rules fire on files nobody touched, while a warm local tree reads green. Reproduce CI's own lane (sh up.sh, thenLINTMAX_NO_CACHE=1 bun run check) before blaming a red on the latest commit — and never conclude afix-versus-checkdivergence from two runs taken against different installs, which is the false trail this exact skew lays. - A plugin's own
all/recommendedconfig is NOT automatically max-strict, in two separate ways, and both read as adopted while enforcing less than they look.recommendedis the author's SUBSET — extending it alone leaves the rest of the plugin's surface unenforced (sonarjs ships 279 rules; its recommended enables a fraction), so enable every rule the plugin ships and drop only the ones whosemeta.deprecatedis set, since an upstream major deletes those and eslint hard-errors on an unknown id. And anallconfig can still ship rules at WARN — regexp'sflat/allsets six that way, and a warn enforces nothing; the gate's ownwarnToErrorcovers consumer overrides, never a config reached throughextends, so route the config's rules through it explicitly. Prove both by asking eslint for the EFFECTIVE config of a real file and asserting zero rules resolved to warn. - The gate turns a failed lookup into a benign value in more places than any one bug shows, and each one reads as a healthy answer:
catch { return null }on a registry fetch made "unknown" indistinguishable from "fresh";catch { process.exitCode ??= 0 }swallowed an error AND pinned success; a.nothrow()whose output is never checked rendered a whole linter as contributing nothing; a day-long cache returned[]so a stale dep found yesterday read as clean today. Grep the gate's own source for.nothrow()with an unreadexitCode, and for acatchreturningnull/[]/{}/0— a check that cannot say "I could not tell" is a check that always says yes. An unanswerable lookup raises; it never shares a value with a healthy one. - Ask a tool for its rule set through a call you have PROVEN prints something, and assert the result is non-empty — a flag can die upstream while staying in
--helpand still exiting 0.oxlint --rulesemits zero bytes on 1.74 (documented, exit 0, both streams empty) while--print-configreturns the resolved JSON; parsing the dead flag's table yielded an empty oxlint section, sorulesreported 1012 rules (biome 525 + eslint 487) and hid oxlint's 726 — understating the enforced surface by 42% while looking complete. oxlint severities aredeny/allow, noterror/warn, and its plugin prefixes use underscores (jsx_a11y,react_perf) — a check written for the eslint spelling silently finds nothing. - Read the tracked-dep set from the manifest that declares it, never a hand-kept list: a list of eight watched 8 of 24 shipped linters, and all three stale deps sat outside it. Staleness is measured on the PUBLISH TIME of
dist-tags.latest, which the abbreviated packument (application/vnd.npm.install-v1+json) does not carry — request the full document or every package resolves to "unknown". - A stale npm wrapper is not a dead tool, and "no alternative exists" is a claim to prove, not assume:
@taplo/clisat 896 days at 0.7.0 while taplo upstream shipped 0.10.0 and its author stepped back as maintainer — the wrapper was stale, the tool was not, and the replacement (tombi: formatter + linter + language server, weekly releases) was one search away. Swapping to a formatter-only tool would have traded the lint half away; check the capability set, not just the freshness. @types/react-domat 247 days is a stable package, not an abandoned one — DefinitelyTyped publishes actively (@types/reactships within weeks) andreact-dombundles no types of its own, so this is the only source and the age just means its type surface has not moved. Exception with a revisit trigger: react-dom shipping bundled types, or a new publish.- Pin
typescriptto6.0.x(neverlatest) in lintmax's own repo and in every consuming project that runs type-aware linting. Why:typescript-eslint(peer>=4.8.4 <6.1.0), Next's build-timeverifyTypeScriptSetup, andtsdown's dts generation all need the classic sync type-checker API, which the TS-7-nativetypescriptpackage does not expose at its main entry (it ships a version stub there, the compiler API living undertypescript/unstable/*);6.0.xsits inside typescript-eslint's range and holds all 61 type-aware rules at full fidelity. The TS-7-native type-aware linteroxlint-tsgolint(oxlintoptions.typeAware:true) is alpha at 59/61 rules — missingnaming-convention+prefer-destructuring— so it lowers strictness (monotonic-up violation); the deferral trigger to adopt it is 61/61 stable OR typescript-eslint native tsgo support at TS 7.1 (typescript-eslint#10940). A version-consistency checker (sherif) that rejects an intentional app-6 vs synced-lib-5 mismatch is scoped (sherif -i typescript), never fought. - A range pin on a lintmax dependency is a silent staleness freeze the never-stale cadence does NOT catch —
latestself-updates, a caret does not, and on a0.xpackage a caret locks the MINOR (^0.139.0froze oxc-parser while 0.140.0 shipped). Audit the gate's OWN manifest for any non-latestspec; each one is either a documented exception with a revisit trigger or a bug. Real case:eslint: ^9is legitimate (eslint-plugin-react peers^3||…||^9.7with no eslint-10 support, while every other plugin already accepts^10) and needs the reason recorded;@eslint-react/eslint-plugin: ^2was NOT blocked (peereslint:*) and sat three majors stale for nothing. A duplicate in the lock is not automatically a bug —@eslint/jsresolves to both 10.x (lintmax's ownlatest) and 9.x (eslint 9's transitive), and theirconfigs.allrule sets are identical, so pinning it would cost latest-only for zero gain. - Adopting an eslint plugin across a major is a RULE-ID REMAP plus a CONFIG-UNION problem, never a version bump — and the rule COUNT is the only honest measure of whether strictness survived. eslint-react v5 flattened the namespace separator (
dom/no-render→dom-no-render,naming-convention/ref-name→naming-convention-ref-name) and folded its relocatedreact-jsxrules back into the unified package, so every explicitly-set id must be remapped — eslint hard-errors on an unknown id in the CONFIG, which is the one mercy, but an inlineeslint-disable-next-linenaming a renamed-or-deleted rule fails SILENTLY: it suppresses nothing, the real rule fires on the line below, and a rule the major deleted leaves the directive as dead weight forever. Grep the consuming tree for every inline disable carrying the plugin's prefix and retarget it in the same pass as the bump. Itsconfigs.allis NOT a superset ofstrict-type-checked(each carries rules the other lacks), so extending one config silently drops the rest; compose the union. Before believing any rule is gone, check whether a plugin ALREADY loaded covers it: eslint-plugin-react'sflat.all(spread wholesale) already ownsjsx-boolean-value,jsx-fragments,jsx-pascal-case,jsx-no-undef,no-string-refs,no-children-propandjsx-no-useless-fragmentat error, and react-hooks ownsvoid-use-memo, while six more react-hooks rules (memo-dependencies,memoized-effect-dependencies,exhaustive-effect-dependencies,no-deriving-state-in-effects,capitalized-calls,component-hook-factories) ship but stay dormant until enabled — so upstream's deletions cost nothing once each is rehomed. Diff rule ids only after normalizing the rename, or every rule reads as both lost and gained. A RULE COUNT IS NOT A STRICTNESS MEASURE — it counts names, and a name proves nothing on its own: a rule loaded with no configuration (no-restricted-syntaxships inconfigs.allcarrying zero patterns) counts as active while enforcing nothing, and the count is blind to a severity downgrade. Verify BOTH: ask eslint for the EFFECTIVE config of a real file (new ESLint({overrideConfig}).calculateConfigForFile(f)) and assert no rule resolved to warn — a raw scan of the flat blocks reports the pre-warnToErrorseverity and lies. Then, for every rule upstream deleted, exercise a planted violation:no-restricted-syntaxwithJSXExpressionContainer CallExpression[callee.type=/^(Arrow)?FunctionExpression$/]replaces a dropped IIFE-in-JSX rule only once a fixture proves it fires and a named-call fixture proves it does not. - TWO PLUGINS CAN SHIP THE SAME RULE, so it fires under TWO ids and a disable naming one silences only that copy —
eslint-plugin-react-hooksand@eslint-reactboth providerefs,immutability,static-components,exhaustive-deps,set-state-in-effect. This is why a line that already looks suppressed reports again after a bump: the file disabledreact-hooks/Xand the twin@eslint-react/Xis untouched. Fixing the CODE kills both ids at once and is preferred; when a disable is genuinely earned, add only the id the gate actually reports — a directive for a rule that does not fire is an UNUSED-DIRECTIVE ERROR under max-strict, so a speculative twin turns the gate red. Never guess which id fires: run the gate and read it. - The false positives cluster in EXTERNAL IMPERATIVE STORES, where mutation IS the library's designed api and no pure alternative exists — a three.js object in a
useFrameloop (reallocating uniforms per frame is the anti-pattern the renderer warns against), amotion/reactMotionValue whoseset()deliberately bypasses render, a canvas 2D context, a portal host node, a test spy whose captured-local mutation IS how it observes a callback. Those earn a disable with the evidence stated. A ref MIRRORING ordinary react state, or a global written during render, is the opposite — a real bug the rule caught; fix the code. - Spreading a plugin's config INTO a later flat-config block silently re-enables a rule an earlier block deliberately turned off — later wins. Adding
configs.allto the second block re-armedno-missing-context-display-nameagainst lintmax's own'off', and only a real consumer's tree surfaced it. Re-assert every deliberate'off'AFTER the spreads, and read the rule count back to confirm. - Parse lintmax's own comment-stripper, JSX-extension detector, and className-rule checker with
oxc-parser, neverimport ts from 'typescript'. Why: they need real parsing (a regex mistakes//inside a string/regex/JSX for a comment), andoxc-parsergives comment ranges + an ESTree AST independent of the TypeScript version, where the classic syncts.createSourceFile/ts.ScriptTarget/ts.getLeadingCommentRangesAPI is absent from the TS-7-native package. Against oxc's flat comment list, NEVER strip a comment that is the sole content of a block (catch { /* intentional */ },noop() { /* empty */ }): a comment bounded only by{ … }whitespace attaches to no AST node, so removing it leaves an empty{}that tripsnoEmptyBlockStatementsand deletes the intentional-empty documentation — keep any comment whose enclosing block content is only comments plus whitespace. - A
test.skip/test.todotrips biomelint/suspicious/noSkippedTests, so a skip that deliberately documents a known-deferred failure (a confirmed-but-parked security hole, an un-built feature) needs an inline// biome-ignore lint/suspicious/noSkippedTests: <reason>carrying why it is parked and when it un-skips — the skip is legit, the silent skip is not. - A TS project that runs the gate via
bunx lintmax@latest(not installed in its ownnode_modules) can fail eslint withexit 2+ an emptyResolveMessage {}on a FRESH checkout while passing locally on a stale green-cache (bust withLINTMAX_NO_CACHE=1): the lintmax-generatednode_modules/.cache/lintmax/eslint.generated.mjsdoesexport { default } from 'lintmax/eslint', which eslint cannot resolve because lintmax is bunx-ephemeral, not a project dep. Fix in the consumer: addlintmax(and any type pkg the project's owntsconfigtypesarray declares, e.g.@types/bun) todevDependenciesso the specifier resolves. This bit a real release — CI's clean checkout self-cancelled the whole pipeline while local cached-green hid it. - A per-line disable cannot survive a local-vs-CI plugin-version skew: a rule that fires in CI but not in the locally-cached plugin gets its disable stripped as unused by
fix, so CI fails again — fix-forward with a code change the rule accepts, never a disable. - The gate self-installs EVERY child binary it shells out to (deny, machete, nextest, typos, AND dprint) via binstall-or-install on a fresh machine — a missing child binary is the gate's job to resolve, never a manual operator step. A formatter/linter the gate invokes but omits from the self-install list fails SILENTLY on a fresh CI runner where it is absent:
Command::new(tool).output()returnsErr, the stage returns failure with no stdout/stderr, and the whole gate reads as a generic stage error — the local machine passes only because the operator already has the tool on PATH. Audit the self-install list against EVERY tool the gate spawns, never a remembered subset. - A file-level
/** biome-ignore-all <rule>: … */placed directly above a statement binds to it as JSDoc, so biome reads it as a statement-range suppression and reportssuppressions/incorrectwhile the rule still fires; and a plain/* … */buffer inserted to detach it is itself flaggedcomments/deletableand stripped by the comment survivor-set, re-binding the block. Fix: suppress at the offending line with an inline// biome-ignore <rule>: …(a survivor), or buffer the file-level block from code with a survivor directive the file genuinely needs (/* eslint-disable … */,/* oxlint-disable … */) — never a bare comment. - The full
lintmaxwrapper can abort locally on a consumer's eslint typed-linting (@eslint-react/no-implicit-key…parserOptions … type information) from a muddiednode_modules, blinding local validation while CI's clean install passes — so a child linter's findings reach you only as a red CI. Reproduce a single child linter directly against its shipped config to validate before pushing:oxlint -c node_modules/lintmax/oxlintrc.json --allow <each OXLINT_CLI_ALLOW rule> <paths>(the--allowflags matter — without them lintmax-allowed rules likeunicorn/prefer-export-fromshow as false errors), and read biome's own findings from the wrapper's pre-eslint output. - The TS gate's
promise-function-async+useAwait+return-awaitjointly constrain a thin promise-passthrough:return pfails promise-function-async (a Promise-returning fn must beasync),async … { return p }fails useAwait (async with no await), andasync … { return await p }fails return-await (redundant await outside try) — onlyasync … { const x = await p; return x }satisfies all three. - The
voidoperator is BANNED and its autofix is DESTRUCTIVE:void someCall()is rewritten toundefined, DROPPING the call — a fire-and-forgetvoid navigator.clipboard.writeText(x)silently becomesundefined, the feature breaks, and the gate goes GREEN. Never usevoidfor fire-and-forget; the autofix loses the side effect with no error. Always re-grep the call afterfixto confirm it survived. - Fire-and-forget a promise from a
() => voidhandler (a context-menuonSelect, a DOM event handler) hits a four-way conflict: anasynchandler tripsno-misused-promises+strict-void-return,.then/.catchtrips oxlintpromise/prefer-await-to-then, a bare call tripsno-floating-promises, andvoidis banned + mangled. The only working form is a SYNC handler with.then/.catch+ a file-level/* oxlint-disable promise/prefer-await-to-then */(idecn ships exactly this disable for its clipboard writes). - Read the REAL failure set from
bun run check/fix, NEVER from a rawoxlint -c node_modules/lintmax/oxlintrc.jsonrun: the raw run omits lintmax'sOXLINT_CLI_ALLOWallow-list AND its path excludes, so it floods with false errors (jsx-no-literals,no-underscore-dangle,func-styleare allow-listed;readonly/ui+generated/are excluded) and over-reports a rule by 10×. The gate is the only truth; a raw-oxlint count sends you chasing phantom rules. - The gate groups all of a file's hits for one rule onto a single output line (
8,46 unicorn(max-nested-calls)), so auniq -cover the rule name counts FILES, not cases — "21 max-nested" can be ~80 actual lines. Parse<file>header + the<comma-lines> <rule>body to get real per-line targets. - Run
fixto a formatting fixed-point BEFORE inserting any disable, then take line numbers fromcheckand insert descending per file. Why:fixreflows code, so a directive placed on unstable formatting lands off its target —suppressions/unusedon the stale line while the rule re-fires below; fix-first makes the gate's nextfixa no-op so the directive holds. - An inline
// oxlint-disable-next-line node/no-syncsurvivesfix; a file-level/* oxlint-disable node/no-sync */gets STRIPPED for that rule (kept for others likeunicorn/max-nested-callson whole-idiomatic files) — so a multi-sync-call script needs a per-line disable on EACH call, never one file-level. A borderlessfor … if (syncCall())makesfixmove the inline directive above thefor(mis-targeting theif), and braces alone do NOT hold it — the form that survives is HOISTING the sync call to its own plainconst x = existsSync(...)statement with the disable above the const, then testing the const (the formatter never moves a disable off a plain assignment). The DISABLE is the expedient fix; the PROPER one is converting to Bun-native async —Bun.file(p).text()/.json()/.exists(),Bun.write(p, data),Bun.$\cmd`for spawn (all async, noSyncname) plusnode:fs/promises(mkdir/rm/readdir) for what Bun lacks — which eliminatesnode/no-syncentirely (no disable, no name-match fragility) and matches the Bun-native preference. Cost is async propagation through the call tree (top-levelawaitcovers module-init); reach for disables only where a context is genuinely synchronous. NBBun.spawnSyncstill trips the*Sync(NAME match — useBun.$/Bun.spawn` instead. node/no-syncmatches the*Sync(call-NAME, so a hand-named helper ending inSync(setupAndSync(),loadConfigSync()) is a FALSE POSITIVE even though it touches no fs — RENAME it (setupProject), never disable, since a spurious disable on a non-sync call reads as real. Tell: the flagged line has no*Syncfs/child_process call on it.- A dogfood/integration test that writes a code FIXTURE and runs the gate on it breaks when a newer lintmax adds a rule the fixture trips UNFIXABLY (
readFileSync/existsSyncin the fixture now failsnode/no-sync, whichfixcannot auto-resolve →fixexits 1 → the "fix should exit 0" assert fails). The fixture must only hold dirt the gate fully auto-fixes (comments,function→arrow, formatting) — strip constructs needing a manual disable, especially since such tests often assert NO disable comments survive in the fixed output. Also delete any stale fixture a prior failed run left insrc/(not gitignored → the next gate lints it). - A no-unsafe wall confined to ONE test/file that persists across a clean reinstall is NOT a missing build artifact — it is either (a) the package has NO
tsconfig.json, so eslint's typed-linting projectService cannot type it and every import resolves toany(fix: add atsconfig.jsonextending the shared base, mirroring a sibling package that lints clean), or (b) a STALE test importing names absent from the module under test's current export surface — the missing imports areundefined/any, manufacturing the wall (fix: rewrite the test against the module's CURRENT exports; the wall is a real dead-test bug wearing a lint disguise, not a suppression target). Also: a dot-directory (e.g..well-known/) is skipped by TypeScript's default include glob, so eslint hits aparse-error: file not in projecton it — add an explicitincludeentry for the dot-dir path in the projecttsconfig.json. - A wall of
@typescript-eslint/no-unsafe-*(assignment/member-access/argument/call/return) clustered in one app or file is usually NOT real debt — it is a MISSING BUILD ARTIFACT erasing a typed import toany: a workspace package with nodist/(itsexportspoint at absent.d.ts), an unbuilt fumadocs.source/, or a removed.next/types(soPageProps/route types resolve toany). Abun clean(which nukesdist/.next/.source/node_modules) manufactures the whole wall. Fix by REBUILDING (bun run buildto regenerate the artifacts) BEFORE trusting any no-unsafe finding — never paper over it with local type-redeclarations oras unknown ascasts; the cast is a band-aid that hides the missing build. Order the gatebuildbeforefix(or pre-build) so the linter sees real types, sincefixalone does not regenerate them. - A rule whose autofix an ALREADY-ENABLED rule reverts cannot be adopted — the two oscillate and
fixleaves whichever ran last, so the finding never clears however many times you rewrite the source. The tell: you fix a finding,fixruns, and the SOURCE has reverted to the flagged form (read the file, not the log). Real case:eslint-plugin-de-morganwants!a && !b, but an already-enabled simplification rule autofixes it back to!(a || b); every hand-edit and subagent fix reverted on the nextfix, across 8 sites. De Morgan vs collapsed-negation is pure style, so the resolution is to DROP the newcomer (de-morgan), not fight the existing autofix — dropping a rule that oscillates with an installed one is not a strictness cut, it is removing an unwinnable conflict. Distinct from the useAwait↔async oscillation below (same file, one rule pair) — this is TWO rules with OPPOSITE fixes. - The release
verifygate runsbuild && fix && check && smoke && test— running onlyfix+testlocally is NOT the release bar, and a green local check still fails the release.smokescaffolds a throwaway package (bun init -y) and runslintmax fixon it expecting zero; after adding a package.json rule set, that minimal generated manifest trippedrequire-description+specify-peers-locally(a workspace-only opinion), failing the publish. Run the FULL verify chain before a version-bump push, and treat the scaffold/smoke fixture as a consumer the new rules must not break. - A regex with the
/v(unicodeSets) flag reserves/inside a character class — write[\w\/], not[\w/], or the pattern throwsInvalid character classat parse time (silently, until the file loads). Switching a regex from/uto/v(e.g. to satisfyrequire-unicode-sets-regexp) is a semantic change, not cosmetic: re-run the tests. And a literal*/inside a/** ... */JSDoc closes the comment early — never put one in comment prose. - Asserting on captured CLI output, match a CONTIGUOUS substring — colored output interleaves ANSI escape codes, so
Score: 100/100prints asScore:\x1b[22m \x1b[32m100/100andtoContain('Score: 100/100')fails whiletoContain('100/100')passes. Assert the shortest ansi-free run that proves the behaviour, or strip ANSI before matching. - A plugin that exports BOTH a
defaultand a namedconfigstrips a different rule on each import style:import * as p→ biomenoNamespaceImport+ oxlintno-namespace;import p fromthenp.configs→ oxlintimport/no-named-as-default-member. The escape that satisfies both is the named import:import { configs as pConfigs } from '...'. Check the plugin actually names-exportsconfigsfirst (most flat-config plugins do). - Turning a swallow honest can trip a DIFFERENT linter, and the pipeline's own autofixers do the tripping: rewriting
catch { return '' }tocatch { return undefined }in astring | undefinedfunction makes one pass strip the redundantreturn undefined, leavingcatch {}— which biome then fails asnoEmptyBlockStatements, 150+ of them. The empty catch is CORRECT (an unreadable input yields the declaredundefined, and the caller reports it and exits non-zero) so the fix is abiome-ignorecarrying that reasoning, never a revert to''— which would restore the lie that the file was read and empty. Expect this shape whenever a fix removes a value the linter then considers redundant. - A large diff across vendored read-only paths the run never touched is the SYNC, not the linter —
pm4ai fixrefreshesreadonly/uifrom cnsync as part of its job, and a current lintmax leaves those paths alone (verified: reverting the churn and re-running a latest lintmax touches zero of them). Read the version before blaming it (node_modules/lintmax/package.jsonagainst the registry); attributing the churn to staleness sends the next reader upgrading a toolchain that is already latest. Refresh (bun clean && bun i) is still the right move for a wrapper that aborts on muddiednode_modules— never fall back to a rawoxlint -c node_modules/lintmax/oxlintrc.jsonrun, which bypasses the allow-list and path excludes and over-reports by 10×, sending you after phantom rules. - The orchestrated
bun run fixre-addsasyncto a synchronous method that NO single linter (biome/oxlint/eslint) re-adds when run alone — eslint's@typescript-eslint/promise-function-asyncautofix fires only in the multi-pass pipeline, then biomeuseAwaitfails the now-async-but-awaitless body, an oscillation a naive remove-async loses every gate. For a method that MUST stay sync (a React classrender()— an async render returns a Promise and crashes at runtime), stop the adder with// eslint-disable-next-line @typescript-eslint/promise-function-asyncabove it; suppressinguseAwaitinstead would leave the runtime-breakingasyncin place. Isolate which child linter mutates a file by running each--fixalone before assuming it's the gate's pipeline. node/no-sync,noProcessEnv,noAwaitInLoops,noUndeclaredClassesare good rules in the wrong CONTEXT when they hit CLI scripts / codegen / e2e / env-modules / tailwind-v4 classes: there sync/sequential/env/unresolvable-class are idiomatic, so a documented per-line disable is the sanctioned false-positive exception — but it stays per-CASE with a reason, never a blanket lintmax scope, andmax-nested-callson a ConvexdefineTable/zod schema gets one file-level disable (the whole file is declarative), while on real logic it gets the var extracted.- The TS gate runs TWO class-member-order rules that conflict on accessors: oxlint
perfectionist/sort-classesputsget-methodbefore private fields,@typescript-eslint/member-orderingwants fields first — no arrangement satisfies both. Expose accessors as plain methods (foo(): Tnotget foo(): T) so they fall in the method group both rules sort alphabetically; update call sites from.footo.foo(). @eslint-react/refsNAME-MATCHES any identifier CONTAINING "ref" (case-insensitive), so a non-React value that merely reads like one — a file reference, a document id, a git ref — trips "Passing a ref to a function may read its value during render" wherever a hook passes it on. Fix by RENAMING to a word with norefsubstring (locator,key,handle), never a disable — a disable on a non-ref value reads as a real ref finding forever. The trap: a HALF rename does not escape it.ref→fileRefSTILL matches (it ends inRef), and renaming only the caller's variable while the CALLEE's param staysrefalso still fires — the match follows the callee's parameter name. Rename every binding in the chain and prove it with a fixture: same file,fileRef→ 2 errors,locator→ 0. Same shape as thenode/no-sync*Sync(call-NAME match.react-hooks/memo-dependenciesis UNSATISFIABLE besidereact-hooks/exhaustive-deps, so it staysoff(earned exception, evidence below) whileexhaustive-depsgates. The two encode opposite eras:memo-dependenciesassumes the React Compiler memoizes everything and reads EVERY hand-writtenuseCallback/useMemodep as redundant;exhaustive-depsrequires those same deps be listed. Proven on a real hook: baseline deps → "Unnecessary dependencyerrorHandler"; remove it → BOTHexhaustive-depsids fire "missing dependencyerrorHandler"; hoist the derivation and re-dep → the rule then callsmutateANDpackageNameunnecessary, and both are demonstrably used inside the callback — a false positive, not advice. Its siblingreact-hooks/preserve-manual-memoizationisofffor the same reason and is the tell that the pair is compiler-native. Decisive for a general-purpose gate: a PUBLISHED library cannot assume its consumer enables the compiler, so obeying the rule (droppinguseCallback) returns a fresh function per render and breaks every memoized consumer. Re-enable only if lintmax ever gates compiler-only codebases, and thenexhaustive-depsgoes off in the same change — never both on.- An index/barrel file must use explicit named re-exports (
export { A, B } from './x.ts'), neverexport *(oxlintno-barrel-file+ biomenoReExportAll). - Name every integration/e2e test
*.test.ts(or*.spec.ts,__tests__/**) so the gate's test-file glob relaxations apply (off there:noAwaitInLoops,noProcessEnv,useTopLevelRegex,useAwait,no-await-in-loop); a non-.test.tsintegration file is held to full source strictness and floods on poll-loop awaits + direct env reads. - TS narrows a closure-mutated
let/object-property back to its reset literal across anawait(it cannot see the notification-callback mutation), so a post-awaitstate.failed === nullreads as an always-trueno-unnecessary-condition; return the outcome from the awaited helper with an explicitPromise<{…}>annotation and read THAT, never the mutable closure var after the await. - The TS consumer config is
defineConfig({ ignores, eslint: { append: [{ files, rules }] }, oxlint: { overrides: [{ files, off: [] }] } })imported fromlintmax— a bareexport default { overrides: {…} }silently fails validation (overrides[…].eslint must be an array/not supported) and applies nothing; reserve it for genuine ecosystem-fit opt-outs, never to dodge a real finding. bun test's per-test timeout defaults to 5000ms, so a multi-turn integration test that lints clean is SILENTLY KILLED at 5s (timed out after 5000ms) unless it passes an explicitit(name, fn, timeoutMs)(or{ timeout }) — lint never catches the missing timeout, only a live run does. Every long-running*.test.tscarries an explicit timeout.- Gate a live/integration test on the DETERMINISTIC signal, never a best-effort one: a hard
expecton a non-deterministic model behaviour (the model actually USING an MCP tool, obeying a steer, grounding a specific fact) is flaky-by-construction — it passes on a capable tier and fails on the cheap one. Assert the deterministic plumbing (tool surfaced + direct call returns, turn completed) and treat model-USE as a logged best-effort, per the product's own documented limitations. - A test FIXTURE that writes source containing a template literal cannot sit in a plain string: eslint
no-template-curly-in-stringfires on any${…}inside a quoted string, reading deliberate fixture text as a mistaken template. Build it by concatenation ('const n = "x-" + Date.now()') or interpolate a variable into a real template — never a quoted string carrying a bare${. - biome
noMisplacedAssertiondoes not recognisetest.skipIf(cond)(name, fn)as a test context, so everyexpectinside a skipIf-gated test reads as an assertion outside a test and fails the gate — while the identical body under a plaintest(name, fn)passes. Gate such a test with a plaintest()plus an early return (if (!process.env.FLAG) return), nevertest.skipIf. - biome
noUndeclaredEnvVarsvalidates everyprocess.env.Xread against turbo.json's taskenvarray, so a NEW env seam fails the gate until its name is listed there — and abiome-ignore lint/style/noProcessEnvdoes NOT satisfy it, because the two are separate rules: a seam needs BOTH the turbo.json entry and the noProcessEnv suppression. The tell: an env read that lints clean beside an existing seam (already declared) fails the moment you add a differently-named one. - The green-tree-hash cache keys on the tree, so an edit to the BIGGEST package re-lints that whole package — a warm no-change run still pays the type-aware floor (measured: 60s warm vs 148s cold on a large Go tree). A session that edits one big package repeatedly never sees the warm path; that is the cache working as designed, not a regression, and the lever is fewer gate invocations rather than a faster gate.
- Restoring
process.exitCodeto a capturedprevthat wasundefineddoes NOT clear a value the code set to1in between — bun keeps the1, so a test that sets/asserts exitCode (a codegencheckSchema, a CLIfix) leaks0 fail but exit 1, failing the suite while every test passes. Reset with?? 0on the restore AND a globalafterEach(() => { process.exitCode = 0 })via abunfig [test] preload; a packagebunfig.tomlREPLACES the root one (not merged), so carry the root's[install]/[run]blocks into it. The tell:bun testexits 1 with0 failand no(fail)line — a leaked global, not a failing assertion. - Bun's shell-tag has no
.timeout()method — a.quiet().nothrow().timeout(ms)chain throwsnot a function. Bound an external scan/spawn withBun.spawn(args, { timeout: ms, stdout: 'pipe' })and readawait new Response(proc.stdout).text(), never a shell-tag.timeout. An unbounded home scan (a barerg --filesover$HOME) hangs for MINUTES (models, caches, deep node_modules); bound it with a spawn timeout AND--max-depth, but give the depth to the HOME scans only — a per-project cwd scan must keep full depth or it silently misses nested files. - A
process.env.NEXT_PUBLIC_*read must stay the literalprocess.env.NEXT_PUBLIC_X— Next replaces it by STRING SUBSTITUTION at build time, so routing it through an env-boundary function/getter leaves the browser bundle withundefined. When consolidating scattered env reads into one typed boundary, route only SERVER-side reads and leave everyNEXT_PUBLIC_*, framework config (next.config/playwright/instrumentation), and self-contained zod-schema runtime script alone. The boundary module must read LIVE (() => raw.Xgetters), not captureprocess.envinto consts at import — tests set the seams AFTER the module loads, and import-time capture freezes the pre-test values. - A consumer
lintmax.config.tsignoresentry, or a packagetsconfig'sexclude/strict:false/noUncheckedIndexedAccess:false, may cover ONLY auto-generated or vendored (read-only synced) output — excluding or loosening HAND-WRITTEN source is a strictness hole that hides real findings (found: repos ignoring hand-written scripts/test files from lint, an app runningstrict:falseon its own source that typechecked clean once restored). Audit every config exclusion against "is this generated/vendored?"; the resolved config (tsc --showConfig), not aheadof the override file, is the authority on the effective strict flags — a packageextendsthe sharedlintmax/tsconfigbase whose strictness a narrow override silently drops. - A spec-of-code doc-diff check that reads a SIBLING doc repo silently SKIPS (passes vacuously) when that repo is absent — CI has only the code checkout, so the sibling is missing and the gate enforces nothing. Clone it in the CI job (
git clone --depth 1 <doc-repo> ../<sibling>) before the check runs, or the doc-vs-code drift the check exists to catch ships green. - The class-sweep is a FIX-TIME obligation, not a challenge-time one: the moment a finding is fixed, grep the whole fleet for the SHAPE (an unbounded scan, a refusal-returning-0, an exitCode leak, an env read outside the boundary) and fix every sibling in the same pass — waiting to be asked "did you sweep the class?" means the sweep was skipped and the siblings rotted. Verify a repo green by remote HEAD == CI
headSha==success, never by push-time optimism: pushed is not CI-green. - An MDX inline code span (single backticks) CANNOT contain a backtick — a nested or backslash-escaped backtick desyncs the span pairing, so text meant as code falls OUTSIDE it and any
{…}there is parsed as a JSX expression, failing the Turbopack/fumadocs build withCould not parse expression with acorn/Unexpected content after expressionat a column deep in the line. A brace group inside ONE valid span is fine; a broken span exposes it. This is a docs-only edit that reds CI, so build the docs app locally (bun run buildinapps/docs) before pushing any.mdxedit — the gate runs the build, and lint/present-tense checks never see the MDX parse.