Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
33 commits
Select commit Hold shift + click to select a range
47f5410
refactor: remove cache implementation
johnsoncodehk Apr 29, 2026
50abc9e
docs(cli): plan for new cache implementation
johnsoncodehk Apr 29, 2026
64fea3c
docs(cli): add pre-implementation checklist to CACHE.md
johnsoncodehk Apr 30, 2026
76ecde7
feat(core): runtime probe for type-aware rule classification
johnsoncodehk Apr 30, 2026
7a8c243
test(core): BuilderProgram POC for layer 2 cache invalidation
johnsoncodehk Apr 30, 2026
2284d7c
feat: layer 1 cache (CLI module + core wiring)
johnsoncodehk Apr 30, 2026
eaabb4c
refactor(core): decouple cache from lint, expose skipRules + hasFix API
johnsoncodehk Apr 30, 2026
80e3d6a
feat(cli): cache-flow module — all layer 1 logic in one place
johnsoncodehk Apr 30, 2026
b7a86c2
feat(cli): wire layer 1 cache into the lint loop
johnsoncodehk Apr 30, 2026
576641f
feat(cli): layer 2 cache-flow option for type-aware rules
johnsoncodehk May 2, 2026
10f0f5c
feat(cli): --incremental flag plumbs layer 2 typeAwareUnaffected
johnsoncodehk May 2, 2026
f5b1f9b
test(cli): end-to-end integration covers layer 1 wiring
johnsoncodehk May 2, 2026
3c02d30
feat(cli): cross-session layer 2 — content hash + dep graph
johnsoncodehk May 2, 2026
463a2db
fix(cli): split layer 2 cache write/read gates; cover ambient deps
johnsoncodehk May 2, 2026
68140be
fix(cli): incremental state size — path interning + user-file filter
johnsoncodehk May 2, 2026
1c4c888
refactor(cli): swap layer 2 state to TS internal incremental API
johnsoncodehk May 2, 2026
c6cfbac
perf(cli): skip diagnostic compute in BP affected drain
johnsoncodehk May 2, 2026
d303aba
feat(cli): make layer 2 the default, drop --incremental flag
johnsoncodehk May 2, 2026
0a6cbce
fix(cli): restore --force hint in summary on warm runs
johnsoncodehk May 2, 2026
b7cbcbe
feat(cli): --list-rules surfaces rule classification
johnsoncodehk May 2, 2026
29b2218
fix(cli): --list-rules emits both headers even with zero count
johnsoncodehk May 2, 2026
8995ed4
fix(cli): consistent blank line between summary and --list-rules
johnsoncodehk May 2, 2026
3b22952
fix(cli): graceful fallback when TS internal API is missing
johnsoncodehk May 2, 2026
b1adecd
feat(cli): warn user when TS internal API is missing
johnsoncodehk May 2, 2026
91f3566
test: drop redundant type assertions in core/cli tests
johnsoncodehk May 2, 2026
2d72e2f
fix(cli): clear worker module state between projects
johnsoncodehk May 2, 2026
beba753
fix(cli): deep-validate cache file shape on load
johnsoncodehk May 2, 2026
87c8e31
feat(cli): cap incremental-state size with warn
johnsoncodehk May 2, 2026
01a731d
test(cli): pin renderer formatting invariants
johnsoncodehk May 2, 2026
35701b3
docs(cli): drop CACHE.md
johnsoncodehk May 2, 2026
b815314
Merge branch 'master' into refactor/rebuild-cache
johnsoncodehk May 2, 2026
f8e8a78
fix: restore Reporter.withoutCache() — removing it was breaking
johnsoncodehk May 2, 2026
d8a466d
test: pin mixed-mode rule classification + cache-flow soundness
johnsoncodehk May 2, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
31 changes: 28 additions & 3 deletions .github/workflows/test.yml
Original file line number Diff line number Diff line change
Expand Up @@ -58,6 +58,31 @@ jobs:
- name: TS-AST scan
run: node packages/compat-eslint/test/ts-ast-scan.test.js

# Core: type-aware rule cache classification (probe + sticky).
- name: Core cache type-aware classification
run: node packages/core/test/cache-typeaware.test.js
# Core: type-aware rule probe (foundation for layer 1 cache).
- name: Core probe
run: node packages/core/test/probe.test.js

# Core: BuilderProgram POC for layer 2 cache invalidation.
- name: Core BuilderProgram POC
run: node packages/core/test/builder-program-poc.test.js

# Core: skip-rules option + hasFixForDiagnostic API (the surface
# the CLI cache layer uses).
- name: Core skip-rules
run: node packages/core/test/skip-rules.test.js

# CLI: cache file load / save / atomic write / version key.
- name: CLI cache module
run: node packages/cli/test/cache.test.js

# CLI: cache-flow — layer 1 cache integration with the linter.
- name: CLI cache-flow
run: node packages/cli/test/cache-flow.test.js

# CLI: end-to-end integration via subprocess on a temp fixture.
- name: CLI integration
run: node packages/cli/test/integration.test.js

# CLI: layer 2 cross-session affected-file diff.
- name: CLI incremental state
run: node packages/cli/test/incremental-state.test.js
11 changes: 8 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -163,11 +163,16 @@ defineConfig({

### Caching

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.
Diagnostics are cached on disk under `os.tmpdir()/tsslint-cache/` in two layers, picked per rule:

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.
- **Layer 1** — invalidated by the linted file's mtime. Used for rules that don't read `ctx.program` (purely syntactic).
- **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.

Pass `--force` to the CLI to ignore the cache.
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).

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.

Pass `--force` to the CLI to ignore the cache. `--list-rules` prints each rule's classification (type-aware vs syntactic) after the run.

### Debugging

Expand Down
115 changes: 95 additions & 20 deletions packages/cli/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,8 +4,8 @@ require('./lib/fs-cache.js');

import ts = require('typescript');
import path = require('path');
import cache = require('./lib/cache.js');
import worker = require('./lib/worker.js');
import cache = require('./lib/cache.js');
import fs = require('fs');
import minimatch = require('minimatch');
import languagePlugins = require('./lib/languagePlugins.js');
Expand All @@ -26,8 +26,9 @@ Options:
--ts-macro-project <glob...> Lint TS Macro projects
--filter <glob...> Filter files to lint
--fix Apply automatic fixes
--force Ignore cache
--force Ignore cache (re-lint every file)
--failures-only Only print errors and messages (skip warnings and suggestions)
--list-rules After linting, print each rule's classification (syntactic / type-aware)
-h, --help Show this help message

Examples:
Expand Down Expand Up @@ -82,7 +83,7 @@ class Project {
options: ts.CompilerOptions = {};
configFile: string | undefined;
currentFileIndex = 0;
cache: cache.CacheData = {};
cacheData: cache.CacheData = cache.emptyCache();
pendingHeader: string | undefined;

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

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

return this;
Expand All @@ -177,12 +188,12 @@ const formatHost: ts.FormatDiagnosticsHost = {
let hasFix = false;
let allFilesNum = 0;
let processed = 0;
let cached = 0;
let passed = 0;
let errors = 0;
let warnings = 0;
let messages = 0;
let suggestions = 0;
let cached = 0;
let configErrors = 0;
const failuresOnly = process.argv.includes('--failures-only');

Expand Down Expand Up @@ -316,6 +327,41 @@ const formatHost: ts.FormatDiagnosticsHost = {
}

renderer.summary(summaryLines);

if (process.argv.includes('--list-rules')) {
// Derive from the per-project cacheData: any rule with an entry
// in `ruleModes` is type-aware; the rest came up as cached
// entries on per-file maps without being classified, so they
// ran as syntactic. Output is grouped + alphabetised so it's
// stable across runs and easy to diff.
const typeAware = new Set<string>();
const syntactic = new Set<string>();
for (const project of projects) {
for (const ruleId of Object.keys(project.cacheData.ruleModes)) {
typeAware.add(ruleId);
}
for (const file of Object.values(project.cacheData.files)) {
for (const ruleId of Object.keys(file.rules)) {
if (!project.cacheData.ruleModes[ruleId]) syntactic.add(ruleId);
}
}
}
// A rule classified type-aware in any project is type-aware
// everywhere — drop it from the syntactic side to avoid
// double-listing the same id across multi-project runs.
for (const id of typeAware) syntactic.delete(id);

// Always emit both headers — even with count 0 — so the format
// stays consistent across runs (otherwise a project with only
// type-aware rules looks visually different from one with both).
const lines: string[] = [];
lines.push(colors.cyan('type-aware') + colors.gray(` (${typeAware.size})`));
for (const id of [...typeAware].sort()) lines.push(' ' + id);
lines.push(colors.cyan('syntactic') + colors.gray(` (${syntactic.size})`));
for (const id of [...syntactic].sort()) lines.push(' ' + id);
for (const l of lines) renderer.info(l);
}

renderer.dispose();

process.exit((errors || messages || configErrors) ? 1 : 0);
Expand All @@ -342,6 +388,8 @@ const formatHost: ts.FormatDiagnosticsHost = {
project.configFile!,
project.rawFileNames,
project.options,
Object.keys(project.cacheData.ruleModes),
project.cacheData.incrementalState,
);
if (setupResult !== true) {
renderer.diagnostic(formatConfigError(project.configFile!, setupResult));
Expand All @@ -359,32 +407,37 @@ const formatHost: ts.FormatDiagnosticsHost = {
continue;
}

let fileCache = project.cache[fileName];
if (fileCache) {
if (fileCache[0] !== fileStat.mtimeMs) {
fileCache[0] = fileStat.mtimeMs;
fileCache[1] = {};
fileCache[2] = {};
}
else {
cached++;
}
let fileCache = project.cacheData.files[fileName];
if (!fileCache) {
fileCache = { mtime: fileStat.mtimeMs, rules: {} };
project.cacheData.files[fileName] = fileCache;
}
else {
project.cache[fileName] = fileCache = [fileStat.mtimeMs, {}, {}];
else if (fileCache.mtime === fileStat.mtimeMs && Object.keys(fileCache.rules).length) {
// File text untouched since the prev session AND we have at
// least one rule's diagnostics cached for it — treat as a
// warm hit for the `--force` summary hint. Layer 2's BP
// might still re-run type-aware rules if their deps moved,
// but the user-visible signal here is just "cache had
// something for this file."
cached++;
}

const diagnostics = await linterWorker.lint(
fileName,
process.argv.includes('--fix'),
fileCache,
fileStat.mtimeMs,
);

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

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

let output: string;

Expand Down Expand Up @@ -428,13 +481,35 @@ const formatHost: ts.FormatDiagnosticsHost = {
}
}
}
else if (await linterWorker.hasRules(fileName, fileCache[2])) {
else if (await linterWorker.hasRules(fileName)) {
passed++;
}
processed++;
}

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

await startWorker(linterWorker);
}
Expand Down
Loading
Loading