perf: lint what webpack rebuilt, while it is still building - #319
Merged
Conversation
A rebuild re-linted every file in the graph. Touching one module of three hundred sent all three hundred back to ESLint, which is what #137 has been reporting since 2022 and what `lintDirtyModulesOnly` traded the first lint away to avoid. The plugin now keeps what each check found, per file, for the life of the compiler. A module webpack rebuilt is linted; one it reports as still valid is reported from there; one that leaves the graph is dropped, as is one webpack lists in `removedFiles` for a check that walks the file system rather than the module graph. Over the same three hundred modules a rebuild goes from 1078 ms to 89 ms. Three things the store has to get right, each of which cost a test: - A file a check finds nothing in is remembered as nothing found. ESLint reports no result for a clean file, so without this a rebuild cannot tell a clean file from one it has never seen, and lints everything again. - What was linted is forgotten before the new results are put back, or a file that has just been fixed keeps reporting the problem it no longer has. - A file the store does not hold is linted rather than skipped: webpack restores a module from its own filesystem cache without building it, and a compiler that starts that way has nothing to report it from. Linting also starts during the module phase rather than after it, once enough files have arrived to be worth sending — a check that parallelises its own work, as ESLint does under `concurrency`, has nothing to spread across workers before then. This is measured neutral on a single-threaded lint, where it interleaves with webpack rather than overlapping it; the rebuild is where the time is. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01GzZci4NQeiqwdrVfd7dGXy
webpack reports a module's resource with the separators the platform uses and a check answers with whatever its own tool wrote, so on Windows the files a compilation covered never matched the results stored for them and the store evicted every one of them.
The two shapes the store is fed meet on any platform: a glob check walks the file system for forward slashes while the watcher answers in the separators the platform uses, so both cases fail on the unfixed store without needing Windows to run them.
alexander-akait
force-pushed
the
perf/incremental-linting
branch
from
September 8, 2026 13:42
06d6f6c to
80b1291
Compare
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Summary
A rebuild re-linted every file in the graph. Measured by counting what reaches
lintFilesover three hundred modules:That is what #137 has been reporting since 2022, and what
lintDirtyModulesOnlytrades the first lint away to avoid. On the same fixture a rebuild goes from 1078 ms to 89 ms, best of three.The plugin now keeps what each check found, per file, for the life of the compiler. A module webpack rebuilt is linted; one it reports as still valid is reported from the store; one that leaves the graph is dropped, as is one webpack lists in
removedFilesfor a check that walks the file system rather than the module graph — themodifiedFiles/removedFilespair the issue asked for.Three things the store has to get right, each of which cost me a failing test before I understood it:
stillValidModulefor everything and has nothing to report it from.cached.test.jscaught this.Linting also starts during the module phase rather than after it, once enough files have arrived to be worth sending. I am not claiming a win for that half: measured on a single-threaded lint it is neutral, because overlapping CPU-bound linting with CPU-bound bundling on one thread interleaves rather than overlaps. It is in because it costs nothing, it is what lets a check that parallelises its own work start earlier, and a batch floor keeps small builds on the single call they had (below it, ESLint's
concurrencywould have nothing to spread across workers).Also here: a check that fails to lint now reports once rather than once per batch, and a result a check cannot attribute to a file is still reported rather than dropped.
What I looked at and did not ship. Reading
fork-ts-checker-webpack-pluginalongside this, two more ideas came up. Aborting a superseded compilation's work (it holds anAbortControllerper iteration) is real but I could not measure a benefit here. And Stylelint's worker pool looks like it is torn down after the first compilation, leaving watch rebuilds single-threaded — I wrote the fix, then found the test I wrote for it passes onmainas well, so it demonstrates nothing and is not in this PR. Both are worth their own look.What kind of change does this PR introduce?
perf.
Did you add tests for your changes?
Yes,
test/incremental.test.js: that a rebuild lints only what webpack rebuilt, and that a file leaving the graph stops being reported. The three defects above are each covered by an existing suite that failed while I had them wrong —cached,watchandstylelint/watch. 120 passing.Does this PR introduce a breaking change?
No. What is reported is unchanged; only how much is re-linted to report it.
lintDirtyModulesOnlystill does what it did.If relevant, what needs to be documented once your changes are merged or what have you already documented?
Documented here: that a rebuild lints only what changed, so
lintDirtyModulesOnlyis now only worth setting to skip the first lint; and ESLint'sconcurrency, with the caveat that ESLint itself warns on the runs where threading costs more than it saves. The changeset is a patch.Use of AI
Written with Claude Code, driven interactively. I asked it to read #137, look at
fork-ts-checker-webpack-pluginfor ideas, and then to fix what it found. It measured before and after rather than asserting: it counted the files reachinglintFiles, timed the phases against webpack's own, and caught that its own first benchmark was measuring ESLint ignoring every file because the fixture'scwdwas wrong. It also corrected two of its own earlier claims — that streaming in small batches was faster (cold-start bias) and thatconcurrencywas unsupported here (it is supported and tested) — and dropped a fix it could not demonstrate. I reviewed the result.🤖 Generated with Claude Code
https://claude.ai/code/session_01GzZci4NQeiqwdrVfd7dGXy
Generated by Claude Code