The layered lint setup most teams run was designed around a constraint that has largely dissolved. Editor plugins ran a subset of rules because a full pass was too slow to run on every keystroke. Pre-commit hooks ran on staged files only because running the repo took minutes. CI ran everything because CI was the only place that could afford to.
Then linters got fast. Not incrementally — a generation of tooling written in compiled languages, sharing one parse across every rule and caching aggressively, moved a full-repository lint from something you waited on to something that finishes before you notice. The formatting and lint passes merged into single binaries. Editors started running the real check rather than an approximation of it.
The takeaway up front: speed didn't make any layer redundant, but it changed what three of the four layers are for. The editor became the place findings get resolved. Autofix-on-save absorbed an entire category of rules. The pre-commit hook lost its original justification and picked up a narrower, better one. And CI's job did not change at all — which is the part most people get wrong, because it looks like the layer speed should have made optional.
What actually changed
Three things, and they compound.
Full passes became interactive. When a whole-project lint completes in the time it takes to save a file, the distinction between "the quick check" and "the real check" stops existing. Your editor can run the same command CI runs, with the same config, and show you the same findings. That was not practical when a full pass cost minutes, and every layered setup built before it assumes otherwise.
Autofix coverage went up, and fixing got safe enough to automate. A large share of the rules teams actually enable are mechanically fixable — import ordering, quote style, unused imports, modernisation rewrites. When those fixes apply on save, the findings never become findings. The linter stops being a thing that reports and starts being a thing that edits.
Lint and format converged into one tool. Running one binary that formats and lints removes the class of problem where two tools disagree about the same line and fight each other on every commit. If you're still reasoning about those as separate concerns, the formatter-versus-linter split is worth reading first, because the boundary has moved.
None of that changes which rules are worth enabling — that question is entirely independent of runtime, and the test a rule has to pass is the same as it ever was. What changed is where the work happens.
The four positions, and what each one is for now
1. The editor: where findings get resolved
This is the layer that gained the most, and the one worth investing in first. Configure the editor to run your actual linter with your actual project config — not a bundled default, not a subset.
The practical consequence is that a finding surfaced while you're still holding the context costs almost nothing to fix, while the same finding surfaced twenty minutes later in a CI log costs a context switch. That gap was always true; what's new is that you can now close nearly all of it, because the editor can afford to run everything.
If your team's lint findings mostly appear in pull requests rather than in editors, the fix is not a stricter gate. It's that somebody's editor isn't running the project's config.
2. Save-time autofix: the category that disappeared
Turning on fix-on-save is the single highest-leverage change available to most teams, and it is the one that genuinely retires work rather than moving it. Formatting arguments, import order, unused imports — none of these reach review, a hook, or CI, because they were repaired the moment the file was written.
The trade-off nobody mentions: autofix-on-save rewrites code you have not read. Most of the time that's exactly what you want. Occasionally a fix is a behaviour change wearing a style-fix costume — a rewrite that alters evaluation order, or a "simplification" that isn't equivalent in an edge case. Keep automatic fixing to rules whose fixes are mechanical, and leave the ones your linter itself marks as unsafe or suggestion-only to run explicitly, so you see the diff before it lands.
3. Pre-commit: a narrower job than it used to have
The classic argument for a pre-commit hook was economic: catch it locally so you don't burn a CI run and ten minutes discovering a missing semicolon. Fast linters and fast CI both erode that argument from opposite directions, and a hook that duplicates the editor's work is friction with no yield — which is why hooks get bypassed with --no-verify and then quietly stop being maintained.
There is still a good reason to keep one, but it's a different reason: the hook exists to guarantee the committed diff is already clean, not to find problems. Run it in fix mode, over staged files only, and let it be near-invisible:
{
"lint-staged": {
"*.{js,jsx,ts,tsx,json,css}": "biome check --write"
}
}
That framing also tells you what does not belong in a hook: anything slow, anything that needs whole-project context, and anything whose failure mode is a confusing block on git commit. A hook that takes more than a second or two will be removed by someone, eventually, and they will be right to.
Secrets scanning is the honourable exception. It belongs in the hook precisely because its value is preventative — once a credential is in a commit, catching it later in CI means rotating the secret rather than editing a file.
4. CI: unchanged, and that's the point
Every argument above makes CI look increasingly like a formality. It isn't, and the reason has nothing to do with speed.
CI is the only layer that runs on a machine nobody configured for themselves. Editors get customised, extensions get disabled, hooks get skipped, and a new contributor's machine is a fresh set of assumptions. CI is the layer of record because it is the layer that cannot be opted out of — the same reason it mattered when it was slow. The full three-layer setup walks through wiring it up; the point here is only that the layer's justification never depended on it being the fastest place to find things.
What should change in CI is its scope. When a full pass is cheap, stop linting only changed files:
ruff check --fix .
# CI: report and fail, never modify the tree
ruff check .
Changed-files-only linting was a performance compromise, and it has a real cost — a rule enabled today only ever gets applied to files people happen to touch, so the config drifts away from the codebase and nobody can tell you whether the repo is actually clean. Linting everything, every time, makes the gate mean something specific: this repository passes, not this diff passes.
What did not get faster, and where the real bottleneck moved
The intuition that "checks are instant now" breaks in a predictable place: it holds for analysis that can look at one file at a time, and fails for analysis that needs the whole program.
- Type checking with full type information has to build and hold a model of the entire project. Some of it parallelises and caches well, but it is a fundamentally larger job than lexical rule matching.
- Taint-tracking SAST follows data across function and file boundaries by design. The depth is the cost, and there's no version of it that is free.
- Type-aware lint rules — the ones that need to know what a value actually is, not just what it looks like — sit on the type checker's work and inherit its cost.
So the staging problem didn't disappear; it relocated. The layer decisions that used to be about linting are now about these. Which means the sensible modern shape is: everything lexical runs continuously and everywhere, and the expensive whole-program analysis runs at merge time, on a schedule, or on demand — with its own budget and its own noise policy. How the tools divide the work is the map for deciding which of your checks falls on which side of that line.
The constraint speed removed was the wrong one
Worth saying plainly, because fast tooling invites a specific mistake: enabling far more rules, because you can.
Runtime was never what made a large config painful. The cost of a rule is the human attention its findings consume and the disable comments it provokes — and that cost is completely unchanged by how fast the rule evaluates. A config that produces findings nobody acts on is just as broken at one second as it was at one minute; it's now merely broken faster.
The useful thing to do with the reclaimed time is not more rules. It's tighter loops: the same rules, running everywhere, resolved before the commit exists.
What to change, in order
- Point your editor at the project's real config, so every developer sees the same findings the gate will see.
- Turn on fix-on-save for mechanical rules; leave fixes your linter marks as unsafe to explicit runs.
- Cut the pre-commit hook back to fix-mode on staged files, plus secrets scanning. Delete anything slow.
- Expand the CI pass to the whole repository rather than changed files, and keep it in report-only mode — CI should never rewrite the tree.
- Give type checking and SAST their own stage, with their own budget, rather than assuming they'll ride along with the fast checks.
If you're setting this up for the first time rather than reshaping something existing, the fundamentals come first — the layer questions only make sense once the tool boundaries do.
FAQ
Do I still need a pre-commit hook if my editor lints on save?
For finding problems, mostly no — the editor already did that. For guaranteeing the committed diff is clean regardless of anyone's editor setup, yes. Keep the hook in fix mode over staged files only, add secrets scanning, and drop everything that takes more than a second or two.
Should CI lint the whole repository or only changed files?
The whole repository, if it finishes fast enough — which it usually now does. Changed-files linting is a performance compromise that leaves you unable to say whether the repo is actually clean, because newly enabled rules only ever reach files someone happened to edit.
Is it safe to autofix code automatically on save?
For mechanical rules, yes. Linters generally distinguish fixes that preserve behaviour from ones that are suggestions or that the project marks as unsafe. Automate the first group and run the second explicitly, so you can read the diff before accepting a rewrite that might not be equivalent.
Why is my type checker still slow when my linter got fast?
Because they're doing different jobs. Lexical lint rules can decide most findings from one file's syntax tree; type checking has to build a model of the whole program, and type-aware lint rules inherit that cost. Give it its own stage rather than expecting it to keep up with the fast pass.
Does running the same checks in three places waste effort?
No — each layer has a different purpose. The editor resolves findings while the context is fresh, the hook guarantees a clean commit, and CI is the authority that no one can skip. Duplication is only waste when a layer is slow enough to be worth bypassing.
Speed changed the placement of the work, not the standard. Push everything lexical as close to the keystroke as it will go, keep the hook thin and fix-only, leave CI as the authority it always was, and give the whole-program analysis its own stage. The differences that decide which linter suits that shape are autofix coverage, cache behaviour, and how the tool reports in a pipeline — compare linters on those criteria on Lintense, where every comparison states its criteria up front.