Skip to content

Commit f6026c3

Browse files
Rebuild cache: sound type-aware invalidation via two-layer scheme (#87)
1 parent 1caf61b commit f6026c3

23 files changed

Lines changed: 3354 additions & 406 deletions

.github/workflows/test.yml

Lines changed: 28 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -58,6 +58,31 @@ jobs:
5858
- name: TS-AST scan
5959
run: node packages/compat-eslint/test/ts-ast-scan.test.js
6060

61-
# Core: type-aware rule cache classification (probe + sticky).
62-
- name: Core cache type-aware classification
63-
run: node packages/core/test/cache-typeaware.test.js
61+
# Core: type-aware rule probe (foundation for layer 1 cache).
62+
- name: Core probe
63+
run: node packages/core/test/probe.test.js
64+
65+
# Core: BuilderProgram POC for layer 2 cache invalidation.
66+
- name: Core BuilderProgram POC
67+
run: node packages/core/test/builder-program-poc.test.js
68+
69+
# Core: skip-rules option + hasFixForDiagnostic API (the surface
70+
# the CLI cache layer uses).
71+
- name: Core skip-rules
72+
run: node packages/core/test/skip-rules.test.js
73+
74+
# CLI: cache file load / save / atomic write / version key.
75+
- name: CLI cache module
76+
run: node packages/cli/test/cache.test.js
77+
78+
# CLI: cache-flow — layer 1 cache integration with the linter.
79+
- name: CLI cache-flow
80+
run: node packages/cli/test/cache-flow.test.js
81+
82+
# CLI: end-to-end integration via subprocess on a temp fixture.
83+
- name: CLI integration
84+
run: node packages/cli/test/integration.test.js
85+
86+
# CLI: layer 2 cross-session affected-file diff.
87+
- name: CLI incremental state
88+
run: node packages/cli/test/incremental-state.test.js

README.md

Lines changed: 8 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -163,11 +163,16 @@ defineConfig({
163163

164164
### Caching
165165

166-
Diagnostics are cached on disk under `os.tmpdir()/tsslint-cache/`, keyed by file mtime. The cache is shared across rules and survives between editor sessions.
166+
Diagnostics are cached on disk under `os.tmpdir()/tsslint-cache/` in two layers, picked per rule:
167167

168-
A diagnostic whose correctness depends on more than one file's mtime (e.g. anything that reads `ctx.program` for cross-file resolution and reports on the cached side) should opt out per-diagnostic via `.withoutCache()` on the reporter — the cached entry would otherwise go stale when an unrelated dependency file changes without invalidating its consumers' mtime.
168+
- **Layer 1** — invalidated by the linted file's mtime. Used for rules that don't read `ctx.program` (purely syntactic).
169+
- **Layer 2** — invalidated by TypeScript's `BuilderProgram` affected-file diff (transitive, includes ambient `.d.ts`). Used for rules that touch `ctx.program`. The first time a rule reads `ctx.program` it's classified type-aware and stays type-aware across sessions.
169170

170-
Pass `--force` to the CLI to ignore the cache.
171+
A diagnostic whose correctness depends on inputs neither layer tracks — external resources, env vars, sibling files the rule reads directly via `fs` — should opt out per-diagnostic via `.withoutCache()` on the reporter. The diagnostic still surfaces on the current run; it just isn't written to disk, so the next warm hit on this file won't replay it (the rule has to re-run to surface it again).
172+
173+
For diagnostics that depend on cross-file types, prefer reading `ctx.program` once instead — that re-classifies the rule type-aware and layer 2 handles invalidation properly.
174+
175+
Pass `--force` to the CLI to ignore the cache. `--list-rules` prints each rule's classification (type-aware vs syntactic) after the run.
171176

172177
### Debugging
173178

packages/cli/index.ts

Lines changed: 95 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -4,8 +4,8 @@ require('./lib/fs-cache.js');
44

55
import ts = require('typescript');
66
import path = require('path');
7-
import cache = require('./lib/cache.js');
87
import worker = require('./lib/worker.js');
8+
import cache = require('./lib/cache.js');
99
import fs = require('fs');
1010
import minimatch = require('minimatch');
1111
import languagePlugins = require('./lib/languagePlugins.js');
@@ -26,8 +26,9 @@ Options:
2626
--ts-macro-project <glob...> Lint TS Macro projects
2727
--filter <glob...> Filter files to lint
2828
--fix Apply automatic fixes
29-
--force Ignore cache
29+
--force Ignore cache (re-lint every file)
3030
--failures-only Only print errors and messages (skip warnings and suggestions)
31+
--list-rules After linting, print each rule's classification (syntactic / type-aware)
3132
-h, --help Show this help message
3233
3334
Examples:
@@ -82,7 +83,7 @@ class Project {
8283
options: ts.CompilerOptions = {};
8384
configFile: string | undefined;
8485
currentFileIndex = 0;
85-
cache: cache.CacheData = {};
86+
cacheData: cache.CacheData = cache.emptyCache();
8687
pendingHeader: string | undefined;
8788

8889
constructor(
@@ -149,8 +150,18 @@ class Project {
149150
colors.gray(`(${this.fileNames.length}${filteredLengthDiff ? `, skipped ${filteredLengthDiff}` : ''})`)
150151
}`;
151152

153+
// Load layer-1 cache unless --force was passed. The cache file path
154+
// key includes tsslint version, TS version, tsconfig, languages,
155+
// and configFile mtime+size — anything that changes the rule set or
156+
// the toolchain mints a fresh file. See packages/cli/lib/cache.ts.
152157
if (!process.argv.includes('--force')) {
153-
this.cache = cache.loadCache(this.tsconfig, this.configFile, this.languages, ts.sys.createHash);
158+
this.cacheData = cache.loadCache(
159+
this.tsconfig,
160+
this.configFile,
161+
this.languages,
162+
ts.version,
163+
ts.sys.createHash,
164+
);
154165
}
155166

156167
return this;
@@ -177,12 +188,12 @@ const formatHost: ts.FormatDiagnosticsHost = {
177188
let hasFix = false;
178189
let allFilesNum = 0;
179190
let processed = 0;
191+
let cached = 0;
180192
let passed = 0;
181193
let errors = 0;
182194
let warnings = 0;
183195
let messages = 0;
184196
let suggestions = 0;
185-
let cached = 0;
186197
let configErrors = 0;
187198
const failuresOnly = process.argv.includes('--failures-only');
188199

@@ -316,6 +327,41 @@ const formatHost: ts.FormatDiagnosticsHost = {
316327
}
317328

318329
renderer.summary(summaryLines);
330+
331+
if (process.argv.includes('--list-rules')) {
332+
// Derive from the per-project cacheData: any rule with an entry
333+
// in `ruleModes` is type-aware; the rest came up as cached
334+
// entries on per-file maps without being classified, so they
335+
// ran as syntactic. Output is grouped + alphabetised so it's
336+
// stable across runs and easy to diff.
337+
const typeAware = new Set<string>();
338+
const syntactic = new Set<string>();
339+
for (const project of projects) {
340+
for (const ruleId of Object.keys(project.cacheData.ruleModes)) {
341+
typeAware.add(ruleId);
342+
}
343+
for (const file of Object.values(project.cacheData.files)) {
344+
for (const ruleId of Object.keys(file.rules)) {
345+
if (!project.cacheData.ruleModes[ruleId]) syntactic.add(ruleId);
346+
}
347+
}
348+
}
349+
// A rule classified type-aware in any project is type-aware
350+
// everywhere — drop it from the syntactic side to avoid
351+
// double-listing the same id across multi-project runs.
352+
for (const id of typeAware) syntactic.delete(id);
353+
354+
// Always emit both headers — even with count 0 — so the format
355+
// stays consistent across runs (otherwise a project with only
356+
// type-aware rules looks visually different from one with both).
357+
const lines: string[] = [];
358+
lines.push(colors.cyan('type-aware') + colors.gray(` (${typeAware.size})`));
359+
for (const id of [...typeAware].sort()) lines.push(' ' + id);
360+
lines.push(colors.cyan('syntactic') + colors.gray(` (${syntactic.size})`));
361+
for (const id of [...syntactic].sort()) lines.push(' ' + id);
362+
for (const l of lines) renderer.info(l);
363+
}
364+
319365
renderer.dispose();
320366

321367
process.exit((errors || messages || configErrors) ? 1 : 0);
@@ -342,6 +388,8 @@ const formatHost: ts.FormatDiagnosticsHost = {
342388
project.configFile!,
343389
project.rawFileNames,
344390
project.options,
391+
Object.keys(project.cacheData.ruleModes),
392+
project.cacheData.incrementalState,
345393
);
346394
if (setupResult !== true) {
347395
renderer.diagnostic(formatConfigError(project.configFile!, setupResult));
@@ -359,32 +407,37 @@ const formatHost: ts.FormatDiagnosticsHost = {
359407
continue;
360408
}
361409

362-
let fileCache = project.cache[fileName];
363-
if (fileCache) {
364-
if (fileCache[0] !== fileStat.mtimeMs) {
365-
fileCache[0] = fileStat.mtimeMs;
366-
fileCache[1] = {};
367-
fileCache[2] = {};
368-
}
369-
else {
370-
cached++;
371-
}
410+
let fileCache = project.cacheData.files[fileName];
411+
if (!fileCache) {
412+
fileCache = { mtime: fileStat.mtimeMs, rules: {} };
413+
project.cacheData.files[fileName] = fileCache;
372414
}
373-
else {
374-
project.cache[fileName] = fileCache = [fileStat.mtimeMs, {}, {}];
415+
else if (fileCache.mtime === fileStat.mtimeMs && Object.keys(fileCache.rules).length) {
416+
// File text untouched since the prev session AND we have at
417+
// least one rule's diagnostics cached for it — treat as a
418+
// warm hit for the `--force` summary hint. Layer 2's BP
419+
// might still re-run type-aware rules if their deps moved,
420+
// but the user-visible signal here is just "cache had
421+
// something for this file."
422+
cached++;
375423
}
376424

377425
const diagnostics = await linterWorker.lint(
378426
fileName,
379427
process.argv.includes('--fix'),
380428
fileCache,
429+
fileStat.mtimeMs,
381430
);
382431

383432
if (diagnostics.length) {
384433
hasFix ||= await linterWorker.hasCodeFixes(fileName);
385434

386435
for (const diagnostic of diagnostics) {
387-
hasFix ||= !!fileCache[1][diagnostic.code]?.[0];
436+
// Cache-hit diagnostics come back without their rule
437+
// having registered a fix this session — `hasCodeFixes`
438+
// only sees fresh runs. Fall back to the cached
439+
// per-rule `hasFix` flag for the diagnostic's rule.
440+
hasFix ||= !!fileCache.rules[String(diagnostic.code)]?.hasFix;
388441

389442
let output: string;
390443

@@ -428,13 +481,35 @@ const formatHost: ts.FormatDiagnosticsHost = {
428481
}
429482
}
430483
}
431-
else if (await linterWorker.hasRules(fileName, fileCache[2])) {
484+
else if (await linterWorker.hasRules(fileName)) {
432485
passed++;
433486
}
434487
processed++;
435488
}
436489

437-
cache.saveCache(project.tsconfig, project.configFile!, project.languages, project.cache, ts.sys.createHash);
490+
// Snapshot the linter's runtime classification back into the cache
491+
// file's `ruleModes`. Next session reads this and seeds the linter
492+
// via `initialTypeAwareRules` so rules are classified correctly
493+
// from the first invocation, before the runtime probe re-runs.
494+
const typeAware = await linterWorker.getTypeAwareRules();
495+
project.cacheData.ruleModes = {};
496+
for (const ruleId of typeAware) {
497+
project.cacheData.ruleModes[ruleId] = 'type-aware';
498+
}
499+
// Layer 2: harvest content hashes + transitive deps from a fresh
500+
// BuilderProgram pass over the LS program. Persists alongside the
501+
// per-rule cache so the next `--incremental` session can compute
502+
// affected files. Falls through to `undefined` on layer-1-only
503+
// runs, matching the schema contract.
504+
project.cacheData.incrementalState = await linterWorker.buildIncrementalState();
505+
cache.saveCache(
506+
project.tsconfig,
507+
project.configFile!,
508+
project.languages,
509+
ts.version,
510+
project.cacheData,
511+
ts.sys.createHash,
512+
);
438513

439514
await startWorker(linterWorker);
440515
}

0 commit comments

Comments
 (0)