Skip to content

Commit da9a7b6

Browse files
perf(lint-cli): spawn the typed pass immediately after a config edit (#671)
* perf(lint-cli): skip the builder when a hash bust cleared the caches A config-drift or package-resolution bust deletes the variant caches before the mtime sweep runs, so the sweep can no longer see them and they never entered `clearedCaches`. Sizing therefore missed its short circuit and ran the TypeScript builder to invalidate a cache that was already gone. Report the deleted paths from `applyHashBust` and union every deletion into `clearedCaches`, so an `eslint.config.*` edit spawns the typed pass at once. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * fix(lint-cli): keep seeding builder state when the cache was cleared The skip added for a cleared cache also swallowed the one thing only a builder pass does: establish the incremental state a *later* run invalidates against. With none on disk (a lint right after an install, which discards `node_modules`), the next run reports `firstRun` and throws its affected set away — so a file edited in between re-lints on its own mtime while its importers keep stale type-aware entries no later run revisits. Skip the builder only when prior state for the pass's mode and variant exists, which is every case the drift bust actually meets. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * refactor(lint-cli): tighten the cleared-cache plumbing Build `clearedCaches` in one guarded step instead of a mutable set fed by a helper, fold `canSkipBuilder` back into the one condition that used it, and derive the buildinfo scan prefix from `statePath` so the state-file naming rule is stated once. Tests share one buildinfo prefix constant and read absolute paths from `builderStateFiles`. No behaviour change. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * fix(lint-cli): ignore stray temp files in the builder-state scan Both writers in the state directory stage through a sibling `<name>.<pid>.tmp` that shares the prefix `hasBuilderState` matches, so a run killed mid-write left one behind for good — and it read as real state, re-enabling the builder skip with nothing on disk for the next run to invalidate against. Also names the `tsgate` base name alongside `tsbuildinfo`, marks `BustOutcome.cleared` readonly to match its consumer, and disambiguates the two same-named `clearedPaths` test helpers. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
1 parent c3d0dba commit da9a7b6

6 files changed

Lines changed: 281 additions & 57 deletions

File tree

src/lint-cli/lib/cache/bust.ts

Lines changed: 16 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -32,8 +32,15 @@ export interface HashBust {
3232

3333
/** Outcome of a hash-drift check. */
3434
export interface BustOutcome {
35-
/** True when the hash changed and this variant's caches were deleted. */
36-
busted: boolean;
35+
/**
36+
* Absolute paths of the cache files this bust deleted, empty when the hash
37+
* did not change. Named rather than counted because the planner folds them
38+
* into the set each pass is sized against (see `mutatingDirtyCount`).
39+
*
40+
* Listed whether or not the file was there to delete — an absent cache
41+
* means "everything is dirty" just as a deleted one does.
42+
*/
43+
cleared: ReadonlyArray<string>;
3744
/** True when no prior hash existed (state stored, no bust). */
3845
firstRun: boolean;
3946
}
@@ -90,17 +97,20 @@ export function applyHashBust(
9097
hash: string | undefined,
9198
): BustOutcome {
9299
if (hash === undefined) {
93-
return { busted: false, firstRun: false };
100+
return { cleared: [], firstRun: false };
94101
}
95102

96103
const swap = swapState(statePath(run.cwd, bust.name, run.key), hash);
97104
if (swap !== "changed") {
98-
return { busted: false, firstRun: swap === "first" };
105+
return { cleared: [], firstRun: swap === "first" };
99106
}
100107

108+
const cleared: Array<string> = [];
101109
for (const base of bust.caches) {
102-
fs.rmSync(path.resolve(run.cwd, cacheFileFor(base, run.key)), { force: true });
110+
const cacheFilePath = path.resolve(run.cwd, cacheFileFor(base, run.key));
111+
fs.rmSync(cacheFilePath, { force: true });
112+
cleared.push(cacheFilePath);
103113
}
104114

105-
return { busted: true, firstRun: false };
115+
return { cleared, firstRun: false };
106116
}

src/lint-cli/lib/plan/plan.ts

Lines changed: 17 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -159,11 +159,12 @@ export function plan(options: LintCliOptions, run: RunContext): StagedPlan {
159159
// `--no-cache` never touches cache state, and `--print` (mutate=false) never
160160
// mutates.
161161
//
162-
// The package.json bust runs first and deletes only this variant's type-aware
163-
// caches (a syntactic lint is immune to resolution changes), so it does NOT
164-
// feed `clearedCaches` — the fast cache survives it. The mtime sweep below is
165-
// the wholesale one, and it covers every variant on disk, not just the ones
166-
// this run selected.
162+
// Every deletion feeds `clearedCaches`, whichever step made it, so that
163+
// sizing can tell a pass that re-lints everything from one that does not (see
164+
// `mutatingDirtyCount`). The package.json bust spares the fast cache, so it
165+
// contributes only the two type-aware ones. The mtime sweep below is the
166+
// wholesale one, and it covers every variant on disk, not just the ones this
167+
// run selected.
167168
const canMutateCaches = mutate && options.cache;
168169
const hasTypeAwarePass = descriptors.some((descriptor) => descriptor.invalidation !== "none");
169170

@@ -173,6 +174,7 @@ export function plan(options: LintCliOptions, run: RunContext): StagedPlan {
173174
// `--no-cache` counts every file dirty regardless.
174175
const configHash = options.cache ? computeConfigHash(cwd, files.configFiles) : undefined;
175176

177+
const cleared: Array<string> = [];
176178
if (canMutateCaches) {
177179
// Config drift through a module `eslint.config.*` imports shifts ESLint's
178180
// per-entry `hashOfConfig` (a full re-lint) but touches no bust file, so
@@ -181,14 +183,19 @@ export function plan(options: LintCliOptions, run: RunContext): StagedPlan {
181183
// three of this variant's caches when it changed. Applies to every pass
182184
// (a config change can alter a syntactic lint), so it runs before the
183185
// type-aware-only package.json bust.
184-
applyHashBust(run, CONFIG_DRIFT, configHash);
185-
}
186+
cleared.push(...applyHashBust(run, CONFIG_DRIFT, configHash).cleared);
187+
if (hasTypeAwarePass) {
188+
cleared.push(
189+
...applyHashBust(run, PACKAGE_RESOLUTION, computePackageJsonHash(cwd)).cleared,
190+
);
191+
}
186192

187-
if (canMutateCaches && hasTypeAwarePass) {
188-
applyHashBust(run, PACKAGE_RESOLUTION, computePackageJsonHash(cwd));
193+
// The sweep runs last, so it lists only the caches the busts left on
194+
// disk.
195+
cleared.push(...sweepStaleCaches(cwd, newestBustMtime));
189196
}
190197

191-
const clearedCaches = new Set(canMutateCaches ? sweepStaleCaches(cwd, newestBustMtime) : []);
198+
const clearedCaches = new Set(cleared);
192199

193200
// Drop the files ESLint declines to lint before anything sizes from them:
194201
// they never enter a cache, so they would otherwise read as dirty forever

src/lint-cli/lib/plan/sizing.ts

Lines changed: 19 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ import { applyTypeAwareInvalidation } from "../cache/invalidation.ts";
88
import type { LintCliOptions } from "../cli/types.ts";
99
import type { RunContext } from "../context.ts";
1010
import type { RepoFiles } from "../files/collect.ts";
11+
import { hasBuilderState } from "../typescript/affected.ts";
1112
import type { WorkerLimits } from "./concurrency.ts";
1213
import { computeWorkerCount } from "./concurrency.ts";
1314
import type { PassDescriptor } from "./passes.ts";
@@ -41,9 +42,9 @@ export interface PassPlan {
4142
*/
4243
export interface SizingInputs {
4344
/**
44-
* The cache files the up-front stale sweep deleted (absolute paths). A pass
45-
* whose cache is in here counts every file as dirty and skips the builder —
46-
* everything re-lints regardless.
45+
* The cache files this run deleted up front (absolute paths), from the hash
46+
* busts and the stale sweep alike. A pass whose cache is in here counts
47+
* every file as dirty and skips the builder — everything re-lints anyway.
4748
*/
4849
clearedCaches: ReadonlySet<string>;
4950
/** The lint-target lists, already filtered of ESLint-ignored files. */
@@ -115,10 +116,19 @@ function mutatingDirtyCount(
115116
targetFiles: Array<string>,
116117
{ clearedCaches, run }: SizePassContext,
117118
): number {
118-
if (clearedCaches.has(cacheLocation)) {
119-
// The up-front sweep already deleted this pass's cache (see `plan`), so
120-
// every file is dirty and the builder would be pure waste — everything
121-
// re-lints.
119+
// Both facts this pass needs about its builder, read off the one field that
120+
// records them: whether it builds a TypeScript program at all, and the mode
121+
// that program runs under.
122+
const buildsProgram = descriptor.invalidation !== "none";
123+
const mode = descriptor.invalidation === "only" ? "only" : undefined;
124+
125+
// A bust or the up-front sweep already deleted this pass's cache (see
126+
// `plan`), so every file is dirty and the affected set the builder computes
127+
// would have nothing to remove from. Skipping it is only safe once prior
128+
// builder state exists: with none, the next run reports `firstRun` and throws
129+
// its affected set away, so an importer of a file edited in between would
130+
// keep a stale entry (see `hasBuilderState`).
131+
if (clearedCaches.has(cacheLocation) && (!buildsProgram || hasBuilderState(run, mode))) {
122132
return targetFiles.length;
123133
}
124134

@@ -127,12 +137,12 @@ function mutatingDirtyCount(
127137
(cache?.getUpdatedFiles(targetFiles) ?? targetFiles).map((file) => normalizePath(file)),
128138
);
129139

130-
if (descriptor.invalidation !== "none") {
140+
if (buildsProgram) {
131141
const outcome = applyTypeAwareInvalidation(run, {
132142
alreadyDirty: dirty,
133143
cache,
134144
cacheLocation,
135-
mode: descriptor.invalidation === "only" ? "only" : undefined,
145+
mode,
136146
targetFiles,
137147
});
138148
if (outcome.busted) {

src/lint-cli/lib/typescript/affected.ts

Lines changed: 70 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -67,6 +67,19 @@ interface ProjectWalkResult {
6767

6868
const warned = new Set<string>();
6969

70+
/**
71+
* State-file base name for the builder's incremental state, shared by the
72+
* per-project path and the prefix {@link hasBuilderState} scans for.
73+
*/
74+
const BUILD_INFO_STATE = "tsbuildinfo";
75+
76+
/**
77+
* State-file base name for the gate paired with each buildinfo. Deliberately
78+
* not a `tsbuildinfo-` suffix, so anything enumerating a variant's buildinfo
79+
* files does not pick a gate up as one (see {@link gateStatePath}).
80+
*/
81+
const GATE_STATE = "tsgate";
82+
7083
/**
7184
* Compute the set of files whose type-aware lint results may have changed since
7285
* the previous run, using TypeScript's builder API. The builder does a native
@@ -158,6 +171,61 @@ export function computeAffectedFiles(
158171
}
159172
}
160173

174+
/**
175+
* Whether a previous run left builder state for this mode and config variant.
176+
*
177+
* The one thing a builder pass does that nothing else can is establish this
178+
* state. Its affected set is disposable — a caller with an empty cache re-lints
179+
* regardless — but a run that skips the builder while no state exists only
180+
* defers the cost onto the next run, which then reports `firstRun` and throws
181+
* its affected set away. A file edited in between would be re-linted on its own
182+
* mtime while its importers kept stale type-aware cache entries, which no later
183+
* run revisits. Callers that skip the builder as an optimisation ask this
184+
* first.
185+
*
186+
* Any one project's state is enough: {@link computeAffectedFiles} reports
187+
* `firstRun` only when no project at all had state, and a newly added project
188+
* contributes its whole file set rather than an empty one.
189+
*
190+
* @param run - The run context.
191+
* @param mode - The active ESLint type-aware mode (never `"off"` here).
192+
* @returns True when at least one buildinfo for this mode and variant exists.
193+
*/
194+
export function hasBuilderState(
195+
{ key, cwd }: RunContext,
196+
mode: TypeAwareMode | undefined,
197+
): boolean {
198+
// Named through the same `statePath` the buildinfo files themselves are,
199+
// minus the per-project digest, so the base name and the hyphen-join rule are
200+
// stated once. Spelling the prefix out here would let a rename slip past
201+
// silently: the scan would stop matching and the guard would degrade to
202+
// "always build", with no error and nothing to fail on.
203+
const prefix = `${path.basename(statePath(cwd, BUILD_INFO_STATE, modeSuffix(mode), key))}-`;
204+
try {
205+
// Both writers in this directory stage through a sibling
206+
// `<name>.<pid>.tmp` (see `persistBuilderState` and `writeState`), which
207+
// shares the prefix. A run killed mid-write leaves one behind for good,
208+
// and counting it as state would re-enable the skip with nothing on disk
209+
// to invalidate against.
210+
return fs
211+
.readdirSync(stateDirectory(cwd))
212+
.some((name) => name.startsWith(prefix) && !name.endsWith(".tmp"));
213+
} catch {
214+
return false;
215+
}
216+
}
217+
218+
/**
219+
* The state-file segment discriminating the type-aware mode a builder ran
220+
* under. Two modes resolve different programs, so their state cannot be shared.
221+
*
222+
* @param mode - The active ESLint type-aware mode (never `"off"` here).
223+
* @returns The file-name segment for that mode.
224+
*/
225+
function modeSuffix(mode: TypeAwareMode | undefined): string {
226+
return mode === "only" ? "typeaware" : "full";
227+
}
228+
161229
/**
162230
* Resolve the builder incremental-state (`.tsbuildinfo`) file for a mode and
163231
* config variant.
@@ -187,8 +255,7 @@ function builderStatePath(
187255
key: string,
188256
projectId: string,
189257
): string {
190-
const suffix = mode === "only" ? "typeaware" : "full";
191-
return statePath(cwd, "tsbuildinfo", suffix, key, projectId);
258+
return statePath(cwd, BUILD_INFO_STATE, modeSuffix(mode), key, projectId);
192259
}
193260

194261
/**
@@ -219,8 +286,7 @@ function gateStatePath(
219286
key: string,
220287
projectId: string,
221288
): string {
222-
const suffix = mode === "only" ? "typeaware" : "full";
223-
return statePath(cwd, "tsgate", suffix, key, projectId);
289+
return statePath(cwd, GATE_STATE, modeSuffix(mode), key, projectId);
224290
}
225291

226292
/**

0 commit comments

Comments
 (0)