From 47f54102268b59ad329a9b310ca4303facef5bf2 Mon Sep 17 00:00:00 2001 From: Johnson Chu Date: Thu, 30 Apr 2026 07:04:36 +0800 Subject: [PATCH 01/32] refactor: remove cache implementation MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The per-file mtime cache shipped in 3.1.0 is unsound for type-aware rules — see commit 4f4f923 for context. The minimal patch in 4f4f923 restored the 3.0.4 invariant (don't cache type-aware rule results) but type-aware rules then pay full cost on every run, which is the workload TSSLint primarily serves. Rather than evolve the existing tuple-shaped cache, remove it entirely and rebuild from a sound foundation. Plan in `packages/cli/CACHE.md`. This commit deletes: - `packages/cli/lib/cache.ts` — load/save + path key derivation - `Reporter.withoutCache()` from `@tsslint/types` — only meaningful when there's a cache to opt out of - `core.FileLintCache` type and the `cache?` parameter throughout `core.lint`, `core.getCodeFixes`, `core.getRules`, `core.getConfigs` - The getter probe + `typeAwareRules` set added in 4f4f923 — needed only because there was a cache to gate; bring it back in step 1 of the new design - The `cache-typeaware.test.ts` suite — testing removed code - The `--force` CLI flag and its summary hint - `minimatchResult` per-pattern cache — was useful only as a third tuple slot; cost is microseconds per file, can be reintroduced later as a flat top-level key if profiling justifies it CLI smoke: `pnpm -r exec tsc --build` clean across all packages. Compat-eslint pipeline + lazy-estree tests pass unchanged (the `withoutCache()` mocks in compat-pipeline.test.ts are excess properties on object literals; TS allows, runtime no-ops since no caller invokes them after this commit). Net: -442 LOC. --- .github/workflows/test.yml | 4 - packages/cli/index.ts | 36 +--- packages/cli/lib/cache.ts | 57 ----- packages/cli/lib/worker.ts | 27 +-- packages/core/index.ts | 117 +--------- packages/core/test/cache-typeaware.test.ts | 240 --------------------- packages/core/tsconfig.json | 2 +- packages/types/index.ts | 1 - 8 files changed, 21 insertions(+), 463 deletions(-) delete mode 100644 packages/cli/lib/cache.ts delete mode 100644 packages/core/test/cache-typeaware.test.ts diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 6353ef52..703fba83 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -57,7 +57,3 @@ jobs: # TS-AST scan walker unit tests. - 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 diff --git a/packages/cli/index.ts b/packages/cli/index.ts index 90c18ba8..6477091c 100644 --- a/packages/cli/index.ts +++ b/packages/cli/index.ts @@ -4,7 +4,6 @@ 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 fs = require('fs'); import minimatch = require('minimatch'); @@ -26,7 +25,6 @@ Options: --ts-macro-project Lint TS Macro projects --filter Filter files to lint --fix Apply automatic fixes - --force Ignore cache --failures-only Only print errors and messages (skip warnings and suggestions) -h, --help Show this help message @@ -82,7 +80,6 @@ class Project { options: ts.CompilerOptions = {}; configFile: string | undefined; currentFileIndex = 0; - cache: cache.CacheData = {}; pendingHeader: string | undefined; constructor( @@ -149,10 +146,6 @@ class Project { colors.gray(`(${this.fileNames.length}${filteredLengthDiff ? `, skipped ${filteredLengthDiff}` : ''})`) }`; - if (!process.argv.includes('--force')) { - this.cache = cache.loadCache(this.tsconfig, this.configFile, this.languages, ts.sys.createHash); - } - return this; } } @@ -182,7 +175,6 @@ const formatHost: ts.FormatDiagnosticsHost = { let warnings = 0; let messages = 0; let suggestions = 0; - let cached = 0; let configErrors = 0; const failuresOnly = process.argv.includes('--failures-only'); @@ -289,9 +281,6 @@ const formatHost: ts.FormatDiagnosticsHost = { } const hints: string[] = []; - if (cached) { - hints.push(colors.cyan('--force') + colors.gray(' to ignore cache')); - } if (hasFix) { hints.push(colors.cyan('--fix') + colors.gray(' to apply fixes')); } @@ -354,38 +343,19 @@ const formatHost: ts.FormatDiagnosticsHost = { while (project.currentFileIndex < project.fileNames.length) { const fileName = project.fileNames[project.currentFileIndex++]; - const fileStat = fs.statSync(fileName, { throwIfNoEntry: false }); - if (!fileStat) { + if (!fs.statSync(fileName, { throwIfNoEntry: false })) { continue; } - let fileCache = project.cache[fileName]; - if (fileCache) { - if (fileCache[0] !== fileStat.mtimeMs) { - fileCache[0] = fileStat.mtimeMs; - fileCache[1] = {}; - fileCache[2] = {}; - } - else { - cached++; - } - } - else { - project.cache[fileName] = fileCache = [fileStat.mtimeMs, {}, {}]; - } - const diagnostics = await linterWorker.lint( fileName, process.argv.includes('--fix'), - fileCache, ); if (diagnostics.length) { hasFix ||= await linterWorker.hasCodeFixes(fileName); for (const diagnostic of diagnostics) { - hasFix ||= !!fileCache[1][diagnostic.code]?.[0]; - let output: string; if (diagnostic.category === ts.DiagnosticCategory.Suggestion) { @@ -428,14 +398,12 @@ 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); - await startWorker(linterWorker); } diff --git a/packages/cli/lib/cache.ts b/packages/cli/lib/cache.ts deleted file mode 100644 index 266c9654..00000000 --- a/packages/cli/lib/cache.ts +++ /dev/null @@ -1,57 +0,0 @@ -import core = require('@tsslint/core'); -import path = require('path'); -import fs = require('fs'); -import os = require('os'); - -export type CacheData = Record; - -const pkg = require('../package.json'); - -export function loadCache( - tsconfig: string, - configFilePath: string, - languages: string[], - createHash: (path: string) => string = btoa, -): CacheData { - const cacheFilePath = getCacheFilePath(tsconfig, configFilePath, languages, createHash); - if (fs.statSync(cacheFilePath, { throwIfNoEntry: false })?.isFile()) { - try { - return JSON.parse(fs.readFileSync(cacheFilePath, 'utf8')); - } - catch {} - } - return {}; -} - -export function saveCache( - tsconfig: string, - configFilePath: string, - languages: string[], - cache: CacheData, - createHash: (path: string) => string = btoa, -): void { - const cacheFilePath = getCacheFilePath(tsconfig, configFilePath, languages, createHash); - fs.mkdirSync(path.dirname(cacheFilePath), { recursive: true }); - fs.writeFileSync(cacheFilePath, JSON.stringify(cache)); -} - -function getCacheFilePath( - tsconfig: string, - configFilePath: string, - languages: string[], - createHash: (path: string) => string, -): string { - const configStat = fs.statSync(configFilePath, { throwIfNoEntry: false }); - const cacheKey = [ - configFilePath, - tsconfig, - languages.sort().join(','), - configStat?.mtimeMs ?? 0, - configStat?.size ?? 0, - ].join('\0'); - return path.join(getTsslintCachePath(), createHash(cacheKey) + '.cache.json'); -} - -function getTsslintCachePath(): string { - return path.join(os.tmpdir(), 'tsslint-cache', pkg.version); -} diff --git a/packages/cli/lib/worker.ts b/packages/cli/lib/worker.ts index b13aa1e6..ee27fbaa 100644 --- a/packages/cli/lib/worker.ts +++ b/packages/cli/lib/worker.ts @@ -2,7 +2,6 @@ import ts = require('typescript'); import type config = require('@tsslint/config'); import core = require('@tsslint/core'); import url = require('url'); -import fs = require('fs'); import path = require('path'); import languagePlugins = require('./languagePlugins.js'); @@ -83,13 +82,13 @@ export function create() { return setup(...args); }, lint(...args: Parameters) { - return lint(...args)[0]; + return lint(...args); }, hasCodeFixes(...args: Parameters) { return hasCodeFixes(...args); }, hasRules(...args: Parameters) { - return hasRules(...args)[0]; + return hasRules(...args); }, }; } @@ -171,22 +170,17 @@ async function setup( return true; } -function lint(fileName: string, fix: boolean, fileCache: core.FileLintCache) { +function lint(fileName: string, fix: boolean) { let newSnapshot: ts.IScriptSnapshot | undefined; let diagnostics!: ts.DiagnosticWithLocation[]; let shouldCheck = true; if (fix) { - if (Object.values(fileCache[1]).some(([hasFix]) => hasFix)) { - // Reset the cache if there are any fixes applied. - fileCache[1] = {}; - fileCache[2] = {}; - } - diagnostics = linter.lint(fileName, fileCache); + diagnostics = linter.lint(fileName); shouldCheck = false; let fixes = linter - .getCodeFixes(fileName, 0, Number.MAX_VALUE, diagnostics, fileCache[2]) + .getCodeFixes(fileName, 0, Number.MAX_VALUE, diagnostics) .filter(fix => fix.fixId === 'tsslint'); if (language) { @@ -211,15 +205,12 @@ function lint(fileName: string, fix: boolean, fileCache: core.FileLintCache) { const oldText = ts.sys.readFile(fileName); if (newText !== oldText) { ts.sys.writeFile(fileName, newSnapshot.getText(0, newSnapshot.getLength())); - fileCache[0] = fs.statSync(fileName).mtimeMs; - fileCache[1] = {}; - fileCache[2] = {}; shouldCheck = true; } } if (shouldCheck) { - diagnostics = linter.lint(fileName, fileCache); + diagnostics = linter.lint(fileName); } // Language-transform path (Vue/MDX/etc.): diagnostics map back from @@ -254,7 +245,7 @@ function lint(fileName: string, fix: boolean, fileCache: core.FileLintCache) { // diagnostics on the same file (so `formatDiagnosticsWithColorAndContext` // only computes line starts once per file). - return [diagnostics, fileCache] as const; + return diagnostics; } function getFileText(fileName: string) { @@ -265,6 +256,6 @@ function hasCodeFixes(fileName: string) { return linter.hasCodeFixes(fileName); } -function hasRules(fileName: string, minimatchCache: core.FileLintCache[2]) { - return [Object.keys(linter.getRules(fileName, minimatchCache)).length > 0, minimatchCache] as const; +function hasRules(fileName: string) { + return Object.keys(linter.getRules(fileName)).length > 0; } diff --git a/packages/core/index.ts b/packages/core/index.ts index cb4f1a20..a644a098 100644 --- a/packages/core/index.ts +++ b/packages/core/index.ts @@ -4,15 +4,6 @@ import type * as ts from 'typescript'; import path = require('path'); import minimatch = require('minimatch'); -export type FileLintCache = [ - mtime: number, - lintResult: Record< - /* ruleId */ string, - [hasFix: boolean, diagnostics: ts.DiagnosticWithLocation[]] - >, - minimatchResult: Record, -]; - export type Linter = ReturnType; export function createLinter( @@ -47,32 +38,21 @@ export function createLinter( plugins: (config.plugins ?? []).map(plugin => plugin(ctx)), })); const normalizedPath = new Map(); - // Rules that touched `rulesContext.program` are type-aware: their - // diagnostics depend on cross-file type information that the per-file - // mtime cache can't track. We never write their results to cache, and - // we ignore any pre-existing cached entry for them so a session that - // classifies a rule as type-aware doesn't keep serving stale data - // from a previous session that didn't. - const typeAwareRules = new Set(); return { - lint(fileName: string, cache?: FileLintCache): ts.DiagnosticWithLocation[] { + lint(fileName: string): ts.DiagnosticWithLocation[] { let currentRuleId: string; - const rules = getRulesForFile(fileName, cache?.[2]); + const rules = getRulesForFile(fileName); const token = ctx.languageServiceHost.getCancellationToken?.(); - const configs = getConfigsForFile(fileName, cache?.[2]); + const configs = getConfigsForFile(fileName); const program = ctx.languageService.getProgram()!; const file = program.getSourceFile(fileName)!; - let touchedProgram = false; const rulesContext: RuleContext = { typescript: ctx.typescript, file, - get program() { - touchedProgram = true; - return program; - }, + program, report, }; @@ -87,26 +67,6 @@ export function createLinter( currentRuleId = ruleId; - const ruleCache = cache?.[1][currentRuleId]; - if (ruleCache && !typeAwareRules.has(currentRuleId)) { - let lintResult = lintResults.get(fileName); - if (!lintResult) { - lintResults.set(fileName, lintResult = [rulesContext.file, new Map(), []]); - } - for (const cacheDiagnostic of ruleCache[1]) { - lintResult[1].set({ - ...cacheDiagnostic, - file: rulesContext.file, - relatedInformation: cacheDiagnostic.relatedInformation?.map(info => ({ - ...info, - file: info.file ? program.getSourceFile(info.file.fileName) : undefined, - })), - }, []); - } - continue; - } - - touchedProgram = false; try { rule(rulesContext); } @@ -118,29 +78,6 @@ export function createLinter( report(String(err), 0, 0).at(new Error(), Number.MAX_VALUE); } } - if (touchedProgram) { - typeAwareRules.add(currentRuleId); - } - - if (cache) { - if (typeAwareRules.has(currentRuleId)) { - // Rule is type-aware: discard any cache entry (this - // session may have written one through `report()` - // before the program access; a previous session may - // have left a stale one too). - delete cache[1][currentRuleId]; - } - else { - cache[1][currentRuleId] ??= [false, []]; - - for (const [_, fixes] of lintResult[1]) { - if (fixes.length) { - cache[1][currentRuleId][0] = true; - break; - } - } - } - } } let diagnostics = [...lintResult[1].keys()]; @@ -182,20 +119,6 @@ export function createLinter( }; let location: [Error, number] = [new Error(), 1]; let relatedInformation: ts.DiagnosticRelatedInformation[] | undefined; - let cachedObj: ts.DiagnosticWithLocation | undefined; - - if (cache) { - cachedObj = { - ...error, - file: undefined as any, - relatedInformation: error.relatedInformation?.map(info => ({ - ...info, - file: info.file ? { fileName: info.file.fileName } as any : undefined, - })), - }; - cache[1][currentRuleId] ??= [false, []]; - cache[1][currentRuleId][1].push(cachedObj); - } let lintResult = lintResults.get(fileName); if (!lintResult) { @@ -243,18 +166,6 @@ export function createLinter( }); return this; }, - withoutCache() { - if (cachedObj) { - const ruleCache = cache?.[1][currentRuleId]; - if (ruleCache) { - const index = ruleCache[1].indexOf(cachedObj); - if (index >= 0) { - ruleCache[1].splice(index, 1); - } - } - } - return this; - }, }; } }, @@ -275,7 +186,6 @@ export function createLinter( start: number, end: number, diagnostics?: ts.Diagnostic[], - minimatchCache?: FileLintCache[2], ) { const lintResult = lintResults.get(fileName); if (!lintResult) { @@ -283,7 +193,7 @@ export function createLinter( } const file = lintResult[0]; - const configs = getConfigsForFile(fileName, minimatchCache); + const configs = getConfigsForFile(fileName); const result: ts.CodeFixAction[] = []; for (const [diagnostic, actions] of lintResult[1]) { @@ -366,11 +276,11 @@ export function createLinter( getConfigs: getConfigsForFile, }; - function getRulesForFile(fileName: string, minimatchCache: undefined | FileLintCache[2]) { + function getRulesForFile(fileName: string) { let rules = fileRules.get(fileName); if (!rules) { rules = {}; - const configs = getConfigsForFile(fileName, minimatchCache); + const configs = getConfigsForFile(fileName); for (const config of configs) { collectRules(rules, config.rules, []); } @@ -386,7 +296,7 @@ export function createLinter( return rules; } - function getConfigsForFile(fileName: string, minimatchCache: undefined | FileLintCache[2]) { + function getConfigsForFile(fileName: string) { let result = fileConfigs.get(fileName); if (!result) { result = configs.filter(({ include, exclude }) => { @@ -403,21 +313,12 @@ export function createLinter( return result; function _minimatch(pattern: string) { - if (minimatchCache) { - if (pattern in minimatchCache) { - return minimatchCache[pattern]; - } - } let normalized = normalizedPath.get(pattern); if (!normalized) { normalized = ts.server.toNormalizedPath(path.resolve(rootDir, pattern)); normalizedPath.set(pattern, normalized); } - const res = minimatch.minimatch(fileName, normalized, { dot: true }); - if (minimatchCache) { - minimatchCache[pattern] = res; - } - return res; + return minimatch.minimatch(fileName, normalized, { dot: true }); } } diff --git a/packages/core/test/cache-typeaware.test.ts b/packages/core/test/cache-typeaware.test.ts deleted file mode 100644 index 7eb254a3..00000000 --- a/packages/core/test/cache-typeaware.test.ts +++ /dev/null @@ -1,240 +0,0 @@ -// Tests for the runtime probe that detects type-aware rules and skips -// caching their diagnostics. The per-file mtime cache can't track -// cross-file type dependencies; rules that read `rulesContext.program` -// must therefore be excluded from cache writes — and any pre-existing -// cache entry for them must be ignored once they've been classified. -// -// Run via: -// node --experimental-strip-types --no-warnings packages/core/test/cache-typeaware.test.ts - -import * as ts from 'typescript'; -import type { Config, RuleContext } from '@tsslint/types'; -import type { FileLintCache } from '../index.js'; - -const core = require('../index.js') as typeof import('../index.js'); - -const failures: string[] = []; -function check(name: string, cond: boolean, detail?: string) { - if (cond) { - process.stdout.write('.'); - } - else { - failures.push(name + (detail ? ' — ' + detail : '')); - process.stdout.write('F'); - } -} - -function makeContext(files: Record) { - const realLibPath = ts.getDefaultLibFilePath({ target: ts.ScriptTarget.Latest }); - const realLibContent = ts.sys.readFile(realLibPath) ?? ''; - const host: ts.LanguageServiceHost = { - getCompilationSettings: () => ({ - target: ts.ScriptTarget.Latest, - noEmit: true, - lib: [realLibPath.split(/[\\/]/).pop()!], - }), - getScriptFileNames: () => Object.keys(files), - getScriptVersion: () => '1', - getScriptSnapshot: n => { - if (n in files) return ts.ScriptSnapshot.fromString(files[n]); - if (n === realLibPath) return ts.ScriptSnapshot.fromString(realLibContent); - return undefined; - }, - getCurrentDirectory: () => '/', - getDefaultLibFileName: () => realLibPath, - fileExists: n => n in files || n === realLibPath, - readFile: n => (n in files ? files[n] : (n === realLibPath ? realLibContent : undefined)), - }; - const languageService = ts.createLanguageService(host); - return { typescript: ts, languageServiceHost: host, languageService }; -} - -function makeCache(): FileLintCache { - return [Date.now(), {}, {}]; -} - -// ── Test 1: syntactic rule cached; type-aware rule not cached ───────── -{ - const ctx = makeContext({ '/a.ts': 'const x = 1;' }); - let syntacticRan = 0; - let typeAwareRan = 0; - const config: Config = { - rules: { - syntactic: ((rctx: RuleContext) => { - syntacticRan++; - rctx.report('plain', 0, 1); - }) as any, - 'type-aware': ((rctx: RuleContext) => { - typeAwareRan++; - void rctx.program; // probe trigger - rctx.report('typed', 0, 1); - }) as any, - }, - }; - const linter = core.createLinter(ctx, '/', config, () => []); - const cache = makeCache(); - linter.lint('/a.ts', cache); - - check('syntactic ran', syntacticRan === 1); - check('type-aware ran', typeAwareRan === 1); - check( - 'syntactic cache entry written', - !!cache[1]['syntactic'], - 'expected cache[1][syntactic] to be set', - ); - check( - 'type-aware cache entry NOT written', - !cache[1]['type-aware'], - `expected cache[1][type-aware] to be undefined, got ${JSON.stringify(cache[1]['type-aware'])}`, - ); -} - -// ── Test 2: rule reports BEFORE touching program — cache entry deleted ─ -{ - const ctx = makeContext({ '/a.ts': 'const x = 1;' }); - const config: Config = { - rules: { - 'report-then-touch': ((rctx: RuleContext) => { - rctx.report('first', 0, 1); // populates cache[1] via report() - void rctx.program; // sets touchedProgram - }) as any, - }, - }; - const linter = core.createLinter(ctx, '/', config, () => []); - const cache = makeCache(); - linter.lint('/a.ts', cache); - - check( - 'report-then-touch cache entry deleted', - !cache[1]['report-then-touch'], - 'expected cache to be empty after delete', - ); -} - -// ── Test 3: classification sticks across files in same session ───────── -{ - const ctx = makeContext({ - '/a.ts': 'const x = 1;', - '/b.ts': 'const y = 2;', - }); - let touchCount = 0; - const config: Config = { - rules: { - 'sometimes-typed': ((rctx: RuleContext) => { - if (rctx.file.fileName === '/a.ts') { - touchCount++; - void rctx.program; - } - rctx.report('hi', 0, 1); - }) as any, - }, - }; - const linter = core.createLinter(ctx, '/', config, () => []); - const cacheA = makeCache(); - const cacheB = makeCache(); - linter.lint('/a.ts', cacheA); // touches program - linter.lint('/b.ts', cacheB); // does NOT touch on this file - - check('rule touched program once', touchCount === 1); - check( - '/a.ts cache entry deleted (touched)', - !cacheA[1]['sometimes-typed'], - 'expected cacheA empty', - ); - check( - '/b.ts cache entry NOT written (sticky)', - !cacheB[1]['sometimes-typed'], - `expected cacheB empty due to sticky type-aware classification, got ${JSON.stringify(cacheB[1]['sometimes-typed'])}`, - ); -} - -// ── Test 4: pre-existing cache entry ignored after classification ────── -{ - const ctx = makeContext({ '/a.ts': 'const x = 1;' }); - let ran = 0; - const config: Config = { - rules: { - 'type-aware': ((rctx: RuleContext) => { - ran++; - void rctx.program; - rctx.report('typed', 0, 1); - }) as any, - }, - }; - const linter = core.createLinter(ctx, '/', config, () => []); - - // Simulate a cache that already has an entry for this rule (e.g. from - // pre-fix 3.1.0 where type-aware results were cached). - const cache: FileLintCache = [ - Date.now(), - { - 'type-aware': [false, [{ - category: ts.DiagnosticCategory.Message, - code: 'type-aware' as any, - messageText: 'stale', - file: undefined as any, - start: 0, - length: 1, - source: 'tsslint', - } as ts.DiagnosticWithLocation]], - }, - {}, - ]; - - // First file: rule runs (no classification yet) — current code uses - // the cached entry. This is the existing soundness gap on cold sessions. - linter.lint('/a.ts', cache); - check('first invocation reused stale cache (cold session limit)', ran === 0); - - // Second file (same linter session): now classified type-aware, - // pre-existing cache entry must be ignored. - const ctx2 = makeContext({ '/b.ts': 'const x = 1;' }); - const cache2: FileLintCache = [ - Date.now(), - { - 'type-aware': [false, [{ - category: ts.DiagnosticCategory.Message, - code: 'type-aware' as any, - messageText: 'stale', - file: undefined as any, - start: 0, - length: 1, - source: 'tsslint', - } as ts.DiagnosticWithLocation]], - }, - {}, - ]; - let ran2 = 0; - const config2: Config = { - rules: { - 'type-aware': ((rctx: RuleContext) => { - ran2++; - void rctx.program; - rctx.report('typed', 0, 1); - }) as any, - }, - }; - const linter3 = core.createLinter(ctx2, '/', config2, () => []); - // First lint touches program → marks rule type-aware in this linter - linter3.lint('/b.ts', makeCache()); - // Second lint with stale cache → should not be served, should re-run - linter3.lint('/b.ts', cache2); - check( - 'cached entry ignored after classification', - ran2 === 2, - `expected rule to run twice (once to classify, once because stale cache ignored), got ${ran2}`, - ); - check( - 'stale cache entry deleted by re-run', - !cache2[1]['type-aware'], - ); -} - -// ── Done ─────────────────────────────────────────────────────────────── -process.stdout.write('\n'); -if (failures.length) { - console.error(`\n${failures.length} failure(s):`); - for (const f of failures) console.error(' - ' + f); - process.exit(1); -} -console.log('OK'); diff --git a/packages/core/tsconfig.json b/packages/core/tsconfig.json index b2dc74b8..ac254271 100644 --- a/packages/core/tsconfig.json +++ b/packages/core/tsconfig.json @@ -1,6 +1,6 @@ { "extends": "../../tsconfig.base.json", - "include": ["*", "lib/**/*", "test/**/*"], + "include": ["*"], "references": [ { "path": "../config/tsconfig.json" } ] diff --git a/packages/types/index.ts b/packages/types/index.ts index 4471c36c..41b7a960 100644 --- a/packages/types/index.ts +++ b/packages/types/index.ts @@ -56,5 +56,4 @@ export interface Reporter { withUnnecessary(): Reporter; withFix(title: string, getChanges: () => FileTextChanges[]): Reporter; withRefactor(title: string, getChanges: () => FileTextChanges[]): Reporter; - withoutCache(): Reporter; } From 50abc9eaf43f8d8cf16ea951fcbf1da321b7e48e Mon Sep 17 00:00:00 2001 From: Johnson Chu Date: Thu, 30 Apr 2026 07:04:49 +0800 Subject: [PATCH 02/32] docs(cli): plan for new cache implementation MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Captures the design for the cache rewrite in `packages/cli/CACHE.md`: - Two layers gated by an `--incremental` flag - Layer 1: per-file mtime cache for syntactic rules, runtime getter probe to classify type-aware rules (sticky), persist classification across sessions. Always on. - Layer 2: `BuilderProgram`-based affected-file tracking for type-aware rule diagnostics, cross-session state via TSSLint- managed `.tsbuildinfo`. Opt-in until perf is measured. - New cache file shape: object form with `version`, `ruleModes`, `files.{mtime, rules}`. Drops the 3.0.x tuple format and the `minimatchResult` slot. - Implementation order, edge cases (incremental + noEmit + tsconfig collision, .tsbuildinfo format stability across TS majors), backwards compat (none — path key includes pkg.version), and open questions (default-on vs opt-in for layer 2, ruleModes expiry policy). This is a working doc; expected to evolve as implementation lands. --- packages/cli/CACHE.md | 201 ++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 201 insertions(+) create mode 100644 packages/cli/CACHE.md diff --git a/packages/cli/CACHE.md b/packages/cli/CACHE.md new file mode 100644 index 00000000..cc9a2e61 --- /dev/null +++ b/packages/cli/CACHE.md @@ -0,0 +1,201 @@ +# CLI cache: design notes + +This package previously shipped a per-file mtime cache. It was removed +because it was unsound for type-aware rules — see commit removing +`packages/cli/lib/cache.ts` for context. This file lays out how the +replacement should be built. + +## What broke in 3.1.0 + +The 3.1.0 cache invalidated cached diagnostics only on the linted +file's own mtime. Type-aware rules (rules that read +`rulesContext.program` to look up cross-file types) depend on +declarations in *other* files; mtime on the linted file doesn't move +when those dependencies change. Cached results went silently stale. + +3.0.4 had a partial guard: it ran rules first against a syntax-only +`LanguageService` whose `program` getter threw on access, caught the +throw, marked the rule type-aware, and excluded type-aware rules from +cache writes. The syntax-only path was removed in c5a8c25; the +cache-skip side effect was lost with it. + +## What replaces it + +Two layers, gated by a flag. + +### Layer 1: per-file mtime cache for syntactic rules + +When a rule's diagnostic is a pure function of the linted file's +text, its cached result is valid as long as the file's mtime hasn't +moved. The cache file format records, per `(file, rule)`: +- the source file's mtime at last lint +- the diagnostic list (and whether any had a fix) + +On read: if `mtime(file) === cached.mtime` and the rule is classified +as syntactic, reuse the cached diagnostics; otherwise re-run the rule. + +Rule classification uses a getter probe on `rulesContext.program` — +the same mechanism implemented in commit 4f4f923. Once a rule +touches `program` in any session, it's marked type-aware (sticky) +and never cached. + +This layer always runs, regardless of any user flag. + +### Layer 2: BuilderProgram-based invalidation for type-aware rules + +ESLint-style per-file mtime can't see global changes (`declare global`, +ambient `.d.ts`, lib files, module augmentation, `@types/*`) that +change a file's effective types without changing its text. TypeScript +already tracks this internally via `BuilderProgram` — the same +machinery that powers `tsc --incremental`. + +When the `--incremental` flag is passed (or the equivalent config +option): + +1. CLI creates a `ts.createSemanticDiagnosticsBuilderProgram` wrapping + the `ts.LanguageService`'s program. The first run has no + `oldProgram`; everything is "affected". +2. Builder serializes its state to a `.tsbuildinfo` file in TSSLint's + own cache directory (not the user's tsconfig path — that would + collide with their own `tsc` runs). State persists across sessions. +3. Next session: load the previous `.tsbuildinfo`, pass as `oldProgram` + to `createSemanticDiagnosticsBuilderProgram`. Walk + `getSemanticDiagnosticsOfNextAffectedFile()` — those are the files + whose type-relevant inputs (own content, transitive imports, + ambient declarations, lib changes) have moved. Type-aware rule + diagnostics for unaffected files reuse the previous cache. + +Type-aware rule diagnostics get written to the cache only when this +layer is active. Without `--incremental`, type-aware rules always +re-run (matches 3.0.4 behavior). + +## Cache file format + +Single JSON file at `os.tmpdir()/tsslint-cache//.cache.json`. +Path key already invalidates on tsslint version change, tsslint.config.ts +mtime/size, tsconfig path, and language plugin set. + +Shape (proposed): + +```jsonc +{ + "version": "v2", + // Sticky type-aware classification across sessions. Once a rule has + // been observed reading `program` in any past session, it stays + // type-aware until it disappears from the config. + "ruleModes": { + "no-leaked-conditional-rendering": "type-aware", + "semi": "syntactic" + }, + // Per-file diagnostic cache. Type-aware rule entries only present + // when layer 2 (BuilderProgram) is active. + "files": { + "/abs/path/foo.ts": { + "mtime": 1234567890, + "rules": { + "semi": { "hasFix": false, "diagnostics": [] }, + "no-leaked-conditional-rendering": { + "hasFix": false, + "diagnostics": [], + // Only present in layer 2 mode. Tracks which BuilderProgram + // version produced this — invalidate if mismatch on load. + "buildSignature": "" + } + } + } + } +} +``` + +The 3.0.4 / 3.1.0 cache used a tuple `[mtime, lintResult, minimatchResult]`. +Drop the tuple in favor of a self-documenting object — it cost no +measurable space but made the format opaque and migration painful. + +`minimatchResult` (per-file pattern→bool cache) is dropped. It was +useful to skip re-running glob matches across runs but the actual +cost is microseconds per file and it added a third tuple slot of +serialization overhead per file. If profiling later shows it back +on the hot path, add it as a separate top-level key, not woven in. + +## Implementation outline + +Order matters — each step compiles and ships independently: + +1. **Restore the getter probe in `core/index.ts`** (already landed in + 4f4f923, removed alongside cache here). Doesn't write or read any + cache yet — just classifies rules. + +2. **Add layer 1**: cache file load/save in CLI; per-file mtime + invalidation; per-rule lint short-circuit. Type-aware rules never + cache. Persist `ruleModes` in the cache file so a session starting + cold knows which rules to skip without re-probing. + +3. **Add `--incremental` flag**: create `BuilderProgram` alongside + the existing `LanguageService`. Use `getSemanticDiagnosticsOfNext- + AffectedFile()` to enumerate which files BuilderProgram considers + changed since last run. Cross-reference with `ruleModes`: + - syntactic rules: layer 1 cache (mtime-only) is authoritative + - type-aware rules: cache hit only if file is unaffected per + BuilderProgram AND ruleModes hasn't changed + +4. **Manage `.tsbuildinfo`**: enable `incremental: true` in TSSLint's + internal program options (NOT the user's tsconfig). Set + `tsBuildInfoFile` to a path under TSSLint's cache directory. + +5. **Tests** in `packages/core/test/` and `packages/cli/test/`: + - syntactic rule cached, type-aware rule not (without `--incremental`) + - sticky classification across files + - persistent classification across sessions (load `ruleModes` on init) + - layer 2: edit `globals.d.ts`, type-aware rule re-runs for + affected files but not others + - layer 2: `.tsbuildinfo` corrupted → graceful cache miss + +## Edge cases + +- **`incremental: true` in user tsconfig**: TSSLint's internal program + must use its own `tsBuildInfoFile` path. Don't let the user's tsc + build state collide with TSSLint's. +- **`noEmit: true`**: confirmed compatible with `incremental` — TS + still writes the buildinfo, just doesn't emit JS. Set both. +- **`.tsbuildinfo` format across TS major versions**: TS doesn't + guarantee format stability. On parse error, treat as cache miss + and rebuild. Already the pattern in old `cache.ts:21`. +- **First run cold**: no `oldProgram`, everything affected, full lint. + Same cost as `--force` today. +- **`--fix` runs**: if any fix wrote to the file, mtime moves and + layer 1 invalidates that file's entries; layer 2's BuilderProgram + picks up the snapshot change via project version bump. +- **Rule that conditionally reads `program`**: classified as syntactic + on a session that doesn't hit the type-aware branch. Later session + hits the branch → upgrade to type-aware, persist in `ruleModes`. + The cached results from before the upgrade are stale but the upgrade + is a one-time event per cache lifetime; users running `--force` + once after upgrading TSSLint clears any residue. + +## Backwards compat + +None needed for cache files. The cache file path key includes +`pkg.version`; version bumps create fresh cache directories. +Old cache files just sit unused until OS tmpdir cleanup. + +The TSSLint *config API* (`@tsslint/types`) loses `Reporter.withoutCache()`. +That method only made sense when the linter had a cache to suppress +writes to; without one, calling it was a no-op. The new cache impl +may reintroduce a per-report cache opt-out if needed — design TBD, +likely a different shape (e.g. `withDependencies(...filePaths)` or +similar declarative form) once layer 2 lands. + +## Open questions + +- Should layer 2 be opt-in (`--incremental`) or default-on once it's + proven? Default-on is the friendlier UX but `BuilderProgram` adds + some startup overhead and writes a cache file the user has to know + about. Default-off until the perf cost is measured. +- Per-rule classification persistence: should we expire `ruleModes` + entries when a rule disappears from config? Or keep forever? Keep + forever is simpler; the file grows by one string per rule ever + used, which is bounded. +- Should the cache work for `--fix` runs? Currently `--fix` runs + rewrite files; cache stays correct as long as we update mtime + after write. Layer 2's BuilderProgram automatically tracks the + snapshot change. No special handling needed. From 64fea3c77b5c064dc4d1c67b6307aceb9bf99ac3 Mon Sep 17 00:00:00 2001 From: Johnson Chu Date: Thu, 30 Apr 2026 10:52:04 +0800 Subject: [PATCH 03/32] docs(cli): add pre-implementation checklist to CACHE.md Ten items to run through before touching cache code, grouped by soundness / cache key / implementation / operational. Each item corresponds to a real failure mode the old design hit or could have hit (silent stale on global type changes, half-written JSON on SIGINT, missing TS-version in cache key, etc.). Also captures the realistic upper bound from profile data: cache caps at ~40% wall-time reduction on warm runs because TS Program build is unavoidable. --- packages/cli/CACHE.md | 97 +++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 97 insertions(+) diff --git a/packages/cli/CACHE.md b/packages/cli/CACHE.md index cc9a2e61..aaeb26e4 100644 --- a/packages/cli/CACHE.md +++ b/packages/cli/CACHE.md @@ -185,6 +185,103 @@ may reintroduce a per-report cache opt-out if needed — design TBD, likely a different shape (e.g. `withDependencies(...filePaths)` or similar declarative form) once layer 2 lands. +## Pre-implementation checklist + +Run through these before writing any cache code. Each one corresponds +to a real failure mode the old design either hit or could have hit. + +### Soundness + +1. **Write the regression tests first**, before any cache code: + - Edit `globals.d.ts` (`declare global { ... }`) → linted file must + re-check + - Edit imported file's exported type → linted file must re-check + - Switch `compilerOptions.lib` → all files re-check + - Edit `compilerOptions` other than lib → whole cache invalidates + - Add/remove rule in tsslint.config.ts → that rule's entries clear + - Rule that conditionally reads `program` → after sticky upgrade, + no stale serve + + Each test corresponds to one invalidation path. Tests failing on + un-implemented spec is the goal — finishing them all defines done. + +2. **Cache miss is always safe**. Treat any uncertainty as a miss: + - `.tsbuildinfo` parse failure → miss + - `ruleModes` shape mismatch → miss + - `getProgram()` returns null → miss + - File stat throws → miss + + Wrong cache hit corrupts a code-review tool. Wrong miss costs a + re-run. Bias hard. + +3. **`--fix` writes a file**. After a fix, that file's mtime moves + and its layer-1 entry must invalidate. If the fixed file is an + import dep of another, layer-2's BuilderProgram must see the + snapshot change and mark that other file affected. Both paths + need a regression test. + +### Cache key + +4. **Include TypeScript's version in the cache path key**. The current + key is `os.tmpdir()/tsslint-cache//` — extend + to `//`. `.tsbuildinfo` format + is TS-major-coupled; without this, a TS upgrade silently corrupts + layer 2. + +5. **Cache key already covers**: tsslint.config.ts mtime+size, tsconfig + path, language plugins. Don't break this on the rewrite. + +### Implementation + +6. **Atomic write**: `writeFileSync(file.tmp); rename(file.tmp, file)`. + Old `cache.ts` wrote in-place — SIGINT during write left half-JSON. + Cheap to fix. + +7. **BuilderProgram + LanguageService coexistence**: CLI uses + `ts.createLanguageService(host)`, which owns its program internally. + Wrapping in BuilderProgram requires `builder.getProgram() === + languageService.getProgram()` to avoid double-building. Spike a + 10-line POC standalone before touching the real CLI flow. + +8. **Multi-project / virtual files (Vue / MDX / Astro)**: language + plugins synthesize virtual `.ts` files with magic suffixes. Cache + key uses absolute path; verify virtual paths don't collide with + real ones in a fixture test before relying on it. + +### Operational + +9. **Add `--force` flag back**. Removed in the cache deletion. Users + need an escape hatch when something breaks. + +10. **Bench a warm-cache scenario**. `tsslint-dify-bench` only tests + cold runs; can't measure cache value or catch stale-result bugs. + Add: cold run → identical-input warm run (cache hit expected) → + edit `globals.d.ts` and warm run again (selective miss expected). + This is both perf metric and soundness regression gate. + +### Realistic upper bound + +Profile attached in CPU.20260430.062953.cpuprofile shows the cold-run +breakdown for Dify web/ (5860 files): + +``` +~5.0 s TS Program build (parse + bind + resolve) — cache CANNOT save +~4.4 s lint pass (rule execution + walker) — cache CAN save +~1.8 s process startup + render output — cache CANNOT save +───── +11.3 s total +``` + +`BuilderProgram` does not speed up cold-run program build — `.tsbuildinfo` +stores file signatures and shape hashes, not parsed AST, so parse+bind +must happen every cold start. The cache saves the lint pass on warm +runs. **Upper bound: ~40% wall-time reduction on warm runs**, capped +by the unavoidable Program build. + +Set this as the warm-run target before starting; if implementation +doesn't get close, something's wrong with the design. + + ## Open questions - Should layer 2 be opt-in (`--incremental`) or default-on once it's From 76ecde7a770279eb015fb15e4b795e8dbe4ada11 Mon Sep 17 00:00:00 2001 From: Johnson Chu Date: Thu, 30 Apr 2026 10:55:03 +0800 Subject: [PATCH 04/32] feat(core): runtime probe for type-aware rule classification MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit First step of the cache rebuild plan. Adds the foundation but no cache wiring yet — see packages/cli/CACHE.md for the full design. createLinter now: - Accepts an optional `initialTypeAwareRules` iterable. The CLI will load this from the cache file's `ruleModes` map on session start so a rule classified type-aware in any prior session stays classified — closes the cold-session-with-stale-cache hole 3.0.4 had where the first invocation of a session re-probed and could silently mismatch a stale cache entry. - Wraps `rulesContext.program` in a getter that flips a per-rule `touchedProgram` flag. After each rule call, if the flag is set, add the rule to the linter's `typeAwareRules` set (sticky). - Exposes `getTypeAwareRules(): ReadonlySet` so the CLI can snapshot the classification at session end and persist into the cache file. No behavior change to cache-less operation: probe runs but its output isn't consulted yet. Layer 1 (next commit) is what gates cache reads/writes on the classification. Tests in packages/core/test/probe.test.ts (5 scenarios): - syntactic rule not classified - rule that reads program is classified - classification sticks past the touching file (cross-file in same session) - seeded `initialTypeAwareRules` preserved across sessions - `getTypeAwareRules()` returns the live set --- .github/workflows/test.yml | 4 + packages/core/index.ts | 28 +++++- packages/core/test/probe.test.ts | 165 +++++++++++++++++++++++++++++++ packages/core/tsconfig.json | 2 +- 4 files changed, 197 insertions(+), 2 deletions(-) create mode 100644 packages/core/test/probe.test.ts diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 703fba83..48b29616 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -57,3 +57,7 @@ jobs: # TS-AST scan walker unit tests. - name: TS-AST scan run: node packages/compat-eslint/test/ts-ast-scan.test.js + + # Core: type-aware rule probe (foundation for layer 1 cache). + - name: Core probe + run: node packages/core/test/probe.test.js diff --git a/packages/core/index.ts b/packages/core/index.ts index a644a098..5490d76a 100644 --- a/packages/core/index.ts +++ b/packages/core/index.ts @@ -11,6 +11,12 @@ export function createLinter( rootDir: string, config: Config | Config[], getRelatedInformations: (err: Error, stackIndex: number) => ts.DiagnosticRelatedInformation[], + // Rule IDs already known to be type-aware from a prior session (via + // the cache file's `ruleModes` map). Pre-seeding lets us treat those + // rules as type-aware on their first invocation in this session, + // before the runtime probe has a chance to classify them — closing + // the cold-session-with-stale-cache hole 3.0.4 had. + initialTypeAwareRules?: Iterable, ) { const ts = ctx.typescript; const fileRules = new Map>(); @@ -38,6 +44,11 @@ export function createLinter( plugins: (config.plugins ?? []).map(plugin => plugin(ctx)), })); const normalizedPath = new Map(); + // Sticky type-aware classification. Once a rule has read + // `rulesContext.program` in any past or current session, it stays + // type-aware for the lifetime of this linter. Reads are gated by a + // getter on `program` that flips a per-rule flag during execution. + const typeAwareRules = new Set(initialTypeAwareRules ?? []); return { lint(fileName: string): ts.DiagnosticWithLocation[] { @@ -49,10 +60,14 @@ export function createLinter( const program = ctx.languageService.getProgram()!; const file = program.getSourceFile(fileName)!; + let touchedProgram = false; const rulesContext: RuleContext = { typescript: ctx.typescript, file, - program, + get program() { + touchedProgram = true; + return program; + }, report, }; @@ -67,6 +82,7 @@ export function createLinter( currentRuleId = ruleId; + touchedProgram = false; try { rule(rulesContext); } @@ -78,6 +94,9 @@ export function createLinter( report(String(err), 0, 0).at(new Error(), Number.MAX_VALUE); } } + if (touchedProgram) { + typeAwareRules.add(currentRuleId); + } } let diagnostics = [...lintResult[1].keys()]; @@ -274,6 +293,13 @@ export function createLinter( }, getRules: getRulesForFile, getConfigs: getConfigsForFile, + // Snapshot of rules classified type-aware so far. The CLI reads + // this after a lint pass to persist into the cache file's + // `ruleModes` map, then feeds it back via `initialTypeAwareRules` + // on the next session. + getTypeAwareRules(): ReadonlySet { + return typeAwareRules; + }, }; function getRulesForFile(fileName: string) { diff --git a/packages/core/test/probe.test.ts b/packages/core/test/probe.test.ts new file mode 100644 index 00000000..31d48496 --- /dev/null +++ b/packages/core/test/probe.test.ts @@ -0,0 +1,165 @@ +// Tests for the runtime probe that detects type-aware rules. +// +// A rule is "type-aware" if it reads `rulesContext.program` during +// execution. The probe is a getter on `program` that flips a flag. +// Once a rule has been observed reading `program`, it stays +// classified type-aware for the lifetime of the linter; pre-existing +// classification can be seeded via `initialTypeAwareRules`. +// +// Run via: +// node packages/core/test/probe.test.js + +import * as ts from 'typescript'; +import type { Config, RuleContext } from '@tsslint/types'; + +const core = require('../index.js') as typeof import('../index.js'); + +const failures: string[] = []; +function check(name: string, cond: boolean, detail?: string) { + if (cond) { + process.stdout.write('.'); + } + else { + failures.push(name + (detail ? ' — ' + detail : '')); + process.stdout.write('F'); + } +} + +function makeContext(files: Record) { + const realLibPath = ts.getDefaultLibFilePath({ target: ts.ScriptTarget.Latest }); + const realLibContent = ts.sys.readFile(realLibPath) ?? ''; + const host: ts.LanguageServiceHost = { + getCompilationSettings: () => ({ + target: ts.ScriptTarget.Latest, + noEmit: true, + lib: [realLibPath.split(/[\\/]/).pop()!], + }), + getScriptFileNames: () => Object.keys(files), + getScriptVersion: () => '1', + getScriptSnapshot: n => { + if (n in files) return ts.ScriptSnapshot.fromString(files[n]); + if (n === realLibPath) return ts.ScriptSnapshot.fromString(realLibContent); + return undefined; + }, + getCurrentDirectory: () => '/', + getDefaultLibFileName: () => realLibPath, + fileExists: n => n in files || n === realLibPath, + readFile: n => (n in files ? files[n] : (n === realLibPath ? realLibContent : undefined)), + }; + const languageService = ts.createLanguageService(host); + return { typescript: ts, languageServiceHost: host, languageService }; +} + +// ── Test 1: rule that doesn't read program → not classified ────────────── +{ + const ctx = makeContext({ '/a.ts': 'const x = 1;' }); + const config: Config = { + rules: { + plain: ((rctx: RuleContext) => { + rctx.report('plain', 0, 1); + }) as any, + }, + }; + const linter = core.createLinter(ctx, '/', config, () => []); + linter.lint('/a.ts'); + check( + 'plain rule not classified type-aware', + !linter.getTypeAwareRules().has('plain'), + ); +} + +// ── Test 2: rule reads program → classified type-aware ──────────────────── +{ + const ctx = makeContext({ '/a.ts': 'const x = 1;' }); + const config: Config = { + rules: { + 'type-aware': ((rctx: RuleContext) => { + void rctx.program; + rctx.report('typed', 0, 1); + }) as any, + }, + }; + const linter = core.createLinter(ctx, '/', config, () => []); + linter.lint('/a.ts'); + check( + 'rule that read program classified type-aware', + linter.getTypeAwareRules().has('type-aware'), + ); +} + +// ── Test 3: classification sticks across files in same session ─────────── +{ + const ctx = makeContext({ + '/a.ts': 'const x = 1;', + '/b.ts': 'const y = 2;', + }); + let touched = 0; + const config: Config = { + rules: { + 'sometimes-typed': ((rctx: RuleContext) => { + if (rctx.file.fileName === '/a.ts') { + touched++; + void rctx.program; + } + rctx.report('hi', 0, 1); + }) as any, + }, + }; + const linter = core.createLinter(ctx, '/', config, () => []); + linter.lint('/a.ts'); + linter.lint('/b.ts'); + check('rule touched program once', touched === 1); + check( + 'classification sticks past the touching file', + linter.getTypeAwareRules().has('sometimes-typed'), + ); +} + +// ── Test 4: initialTypeAwareRules seeded from prior session is preserved ─ +{ + const ctx = makeContext({ '/a.ts': 'const x = 1;' }); + let ran = 0; + const config: Config = { + rules: { + 'syntactic-now': ((rctx: RuleContext) => { + ran++; + rctx.report('hi', 0, 1); + }) as any, + }, + }; + const linter = core.createLinter(ctx, '/', config, () => [], ['syntactic-now']); + linter.lint('/a.ts'); + check('rule still ran', ran === 1); + check( + 'seeded classification preserved', + linter.getTypeAwareRules().has('syntactic-now'), + 'rule was syntactic this session but type-aware in prior session — must remain classified', + ); +} + +// ── Test 5: getTypeAwareRules returns live set; mutations not allowed ──── +{ + const ctx = makeContext({ '/a.ts': 'const x = 1;' }); + const config: Config = { + rules: { + r: ((rctx: RuleContext) => { + void rctx.program; + rctx.report('x', 0, 1); + }) as any, + }, + }; + const linter = core.createLinter(ctx, '/', config, () => []); + const before = linter.getTypeAwareRules().size; + check('initially empty', before === 0); + linter.lint('/a.ts'); + const after = linter.getTypeAwareRules().size; + check('grows after probe', after === 1); +} + +process.stdout.write('\n'); +if (failures.length) { + console.error(`\n${failures.length} failure(s):`); + for (const f of failures) console.error(' - ' + f); + process.exit(1); +} +console.log('OK'); diff --git a/packages/core/tsconfig.json b/packages/core/tsconfig.json index ac254271..6b2f9aaf 100644 --- a/packages/core/tsconfig.json +++ b/packages/core/tsconfig.json @@ -1,6 +1,6 @@ { "extends": "../../tsconfig.base.json", - "include": ["*"], + "include": ["*", "test/**/*"], "references": [ { "path": "../config/tsconfig.json" } ] From 7a8c2430a39a3c40ce7f1e794452a547477d31b1 Mon Sep 17 00:00:00 2001 From: Johnson Chu Date: Thu, 30 Apr 2026 10:57:22 +0800 Subject: [PATCH 05/32] test(core): BuilderProgram POC for layer 2 cache invalidation MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit CACHE.md item 7 — spike the LS / BP integration before wiring it through the CLI. Six scenarios: 1. `builder.getProgram() === languageService.getProgram()` — no double-build when wrapping LS's program in a builder 2. Cold first run flags every file as affected 3. Edit a.ts → both a.ts and b.ts (importer) in affected set 4. Identical re-run → no files affected (cache hit territory) 5. Edit unrelated c.ts → only c.ts affected, NOT a.ts/b.ts 6. Edit globals.d.ts → all consumers in affected set ↑ the motivating soundness case: ambient declarations don't appear in any file's `imports` but BuilderProgram's reference graph tracks them transitively. This is the gap layer 1 alone cannot close. 13/13 checks pass. The integration model works. Layer 2 wiring into the CLI lint flow is the next step. Test runs in-process (oldProgram passed directly). Cross-session state transfer via `.tsbuildinfo` is a separate concern handled when wiring `incremental: true` into the LS's compilerOptions. --- .github/workflows/test.yml | 4 + .../core/test/builder-program-poc.test.ts | 289 ++++++++++++++++++ 2 files changed, 293 insertions(+) create mode 100644 packages/core/test/builder-program-poc.test.ts diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 48b29616..6390df2f 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -61,3 +61,7 @@ jobs: # 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 diff --git a/packages/core/test/builder-program-poc.test.ts b/packages/core/test/builder-program-poc.test.ts new file mode 100644 index 00000000..3437f722 --- /dev/null +++ b/packages/core/test/builder-program-poc.test.ts @@ -0,0 +1,289 @@ +// POC for layer 2 of the cache rebuild — verify that +// `ts.createSemanticDiagnosticsBuilderProgram` can wrap the program +// owned by `ts.createLanguageService` without double-building, and +// that affected-file iteration correctly identifies which files' +// type-relevant inputs have changed since the previous pass. +// +// This is the integration risk flagged in CACHE.md item 7. Validate +// in isolation before wiring through the CLI. +// +// Run via: +// node packages/core/test/builder-program-poc.test.js + +import * as ts from 'typescript'; + +const failures: string[] = []; +function check(name: string, cond: boolean, detail?: string) { + if (cond) { + process.stdout.write('.'); + } + else { + failures.push(name + (detail ? ' — ' + detail : '')); + process.stdout.write('F'); + } +} + +// In-memory file system the LanguageServiceHost reads from. Tests +// mutate `files`/`versions` to simulate file edits. +const files: Record = {}; +const versions: Record = {}; +function write(name: string, text: string) { + files[name] = text; + versions[name] = (versions[name] ?? 0) + 1; +} + +const realLibPath = ts.getDefaultLibFilePath({ target: ts.ScriptTarget.Latest }); +const realLibContent = ts.sys.readFile(realLibPath) ?? ''; +let projectVersion = 0; + +const host: ts.LanguageServiceHost = { + getCompilationSettings: () => ({ + target: ts.ScriptTarget.Latest, + noEmit: true, + lib: [realLibPath.split(/[\\/]/).pop()!], + }), + getScriptFileNames: () => Object.keys(files), + getScriptVersion: n => String(versions[n] ?? 0), + getScriptSnapshot: n => { + if (n in files) return ts.ScriptSnapshot.fromString(files[n]); + if (n === realLibPath) return ts.ScriptSnapshot.fromString(realLibContent); + return undefined; + }, + getCurrentDirectory: () => '/', + getDefaultLibFileName: () => realLibPath, + fileExists: n => n in files || n === realLibPath, + readFile: n => (n in files ? files[n] : (n === realLibPath ? realLibContent : undefined)), + getProjectVersion: () => String(projectVersion), +}; + +const languageService = ts.createLanguageService(host); + +// Iterate `getSemanticDiagnosticsOfNextAffectedFile` until exhausted, +// returning the set of file names BuilderProgram considered affected. +function affectedFileNames(builder: ts.SemanticDiagnosticsBuilderProgram): Set { + const seen = new Set(); + while (true) { + const result = builder.getSemanticDiagnosticsOfNextAffectedFile(); + if (!result) break; + const a = result.affected; + if ('fileName' in a) seen.add(a.fileName); + else { + // `affected` was the whole Program (rare — happens when no + // file-granular diff is possible, e.g. compilerOptions changed). + seen.add(''); + } + } + return seen; +} + +// ── Test 1: builder.getProgram() === languageService.getProgram() ──────── +// +// The whole point of layer 2 is to share one Program across LS and +// BP — if BP rebuilt from scratch we'd pay the parse/bind cost twice. +{ + write('/a.ts', 'export const x: number = 1;'); + write('/b.ts', "import { x } from './a'; export const y = x + 1;"); + projectVersion++; + + const program = languageService.getProgram()!; + const builder = ts.createSemanticDiagnosticsBuilderProgram( + program, + { createHash: ts.sys.createHash }, + ); + check( + 'builder shares program with LS', + builder.getProgram() === program, + 'expected identity, got divergent Programs', + ); +} + +// ── Test 2: cold first run flags every file as affected ────────────────── +{ + write('/a.ts', 'export const x: number = 1;'); + write('/b.ts', "import { x } from './a'; export const y = x + 1;"); + projectVersion++; + + const program = languageService.getProgram()!; + const builder = ts.createSemanticDiagnosticsBuilderProgram( + program, + { createHash: ts.sys.createHash }, + ); + const affected = affectedFileNames(builder); + check( + 'cold run: a.ts is affected', + affected.has('/a.ts'), + `got: ${[...affected].join(', ')}`, + ); + check( + 'cold run: b.ts is affected', + affected.has('/b.ts'), + `got: ${[...affected].join(', ')}`, + ); +} + +// ── Test 3: edit a.ts → both a.ts and b.ts are affected ────────────────── +// +// The whole reason layer 2 exists: a change in a.ts should invalidate +// b.ts's type-aware rule cache because b.ts imports a's types. +{ + write('/a.ts', 'export const x: number = 1;'); + write('/b.ts', "import { x } from './a'; export const y = x + 1;"); + projectVersion++; + const oldProgram = languageService.getProgram()!; + const oldBuilder = ts.createSemanticDiagnosticsBuilderProgram( + oldProgram, + { createHash: ts.sys.createHash }, + ); + // Walk to drain — establishes the baseline state. + affectedFileNames(oldBuilder); + + // Edit a.ts: change exported type. + write('/a.ts', 'export const x: string = "1";'); + projectVersion++; + const newProgram = languageService.getProgram()!; + check( + 'edit produced a new Program', + newProgram !== oldProgram, + 'LS did not bump program after projectVersion change', + ); + + const newBuilder = ts.createSemanticDiagnosticsBuilderProgram( + newProgram, + { createHash: ts.sys.createHash }, + oldBuilder, + ); + const affected = affectedFileNames(newBuilder); + check( + 'edited a.ts is in affected set', + affected.has('/a.ts'), + `got: ${[...affected].join(', ')}`, + ); + check( + 'b.ts (importer of a.ts) is in affected set', + affected.has('/b.ts'), + `got: ${[...affected].join(', ')}`, + ); +} + +// ── Test 4: identical re-run after baseline → nothing affected ─────────── +{ + write('/a.ts', 'export const x: number = 1;'); + write('/b.ts', "import { x } from './a'; export const y = x + 1;"); + projectVersion++; + const oldProgram = languageService.getProgram()!; + const oldBuilder = ts.createSemanticDiagnosticsBuilderProgram( + oldProgram, + { createHash: ts.sys.createHash }, + ); + affectedFileNames(oldBuilder); + + // No edits. + const newProgram = languageService.getProgram()!; + const newBuilder = ts.createSemanticDiagnosticsBuilderProgram( + newProgram, + { createHash: ts.sys.createHash }, + oldBuilder, + ); + const affected = affectedFileNames(newBuilder); + check( + 'unchanged second pass: no files affected', + affected.size === 0, + `got ${affected.size} affected: ${[...affected].join(', ')}`, + ); +} + +// ── Test 5: edit unrelated file does NOT affect imports ────────────────── +// +// b.ts imports a.ts, c.ts is independent. Changing c.ts must not put +// b.ts in the affected set. +{ + write('/a.ts', 'export const x: number = 1;'); + write('/b.ts', "import { x } from './a'; export const y = x + 1;"); + write('/c.ts', 'export const z = 42;'); + projectVersion++; + const oldBuilder = ts.createSemanticDiagnosticsBuilderProgram( + languageService.getProgram()!, + { createHash: ts.sys.createHash }, + ); + affectedFileNames(oldBuilder); + + // Edit c.ts only. + write('/c.ts', 'export const z = 100;'); + projectVersion++; + const newBuilder = ts.createSemanticDiagnosticsBuilderProgram( + languageService.getProgram()!, + { createHash: ts.sys.createHash }, + oldBuilder, + ); + const affected = affectedFileNames(newBuilder); + check( + 'c.ts edit affects c.ts', + affected.has('/c.ts'), + `got: ${[...affected].join(', ')}`, + ); + check( + 'c.ts edit does NOT affect b.ts (no import dep)', + !affected.has('/b.ts'), + `got: ${[...affected].join(', ')}`, + ); + check( + 'c.ts edit does NOT affect a.ts', + !affected.has('/a.ts'), + `got: ${[...affected].join(', ')}`, + ); +} + +// ── Test 6: edit globals.d.ts → all files using globals are affected ───── +// +// The motivating soundness case: ambient declarations don't show up +// in any file's `imports`, but BuilderProgram's reference graph +// tracks them transitively. Editing the global declaration must +// propagate to every consumer. +{ + files['/globals.d.ts'] = 'declare const FOO: number;'; + versions['/globals.d.ts'] = (versions['/globals.d.ts'] ?? 0) + 1; + write('/use1.ts', 'export const a = FOO + 1;'); + write('/use2.ts', 'export const b = FOO * 2;'); + write('/no_globals.ts', 'export const c = 42;'); + projectVersion++; + const oldBuilder = ts.createSemanticDiagnosticsBuilderProgram( + languageService.getProgram()!, + { createHash: ts.sys.createHash }, + ); + affectedFileNames(oldBuilder); + + // Change globals.d.ts: type changes from number to string. + files['/globals.d.ts'] = 'declare const FOO: string;'; + versions['/globals.d.ts']++; + projectVersion++; + const newBuilder = ts.createSemanticDiagnosticsBuilderProgram( + languageService.getProgram()!, + { createHash: ts.sys.createHash }, + oldBuilder, + ); + const affected = affectedFileNames(newBuilder); + check( + 'globals.d.ts edit: globals.d.ts is affected', + affected.has('/globals.d.ts'), + `got: ${[...affected].join(', ')}`, + ); + check( + 'globals.d.ts edit: use1.ts is affected (consumer of FOO)', + affected.has('/use1.ts'), + `got: ${[...affected].join(', ')} — this is the soundness case the per-file mtime cache misses`, + ); + check( + 'globals.d.ts edit: use2.ts is affected (consumer of FOO)', + affected.has('/use2.ts'), + `got: ${[...affected].join(', ')}`, + ); +} + +// ── Done ──────────────────────────────────────────────────────────────── +process.stdout.write('\n'); +if (failures.length) { + console.error(`\n${failures.length} failure(s):`); + for (const f of failures) console.error(' - ' + f); + process.exit(1); +} +console.log('OK'); From 2284d7cac7e5d72e36064471d02739c8e9c7156d Mon Sep 17 00:00:00 2001 From: Johnson Chu Date: Thu, 30 Apr 2026 11:08:09 +0800 Subject: [PATCH 06/32] feat: layer 1 cache (CLI module + core wiring) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit CACHE.md steps 2 + 3 of the rebuild plan: the cache file format and its wiring into core.lint(). CLI integration (file mtime check, load/save in the lint loop) is the next commit. CLI side — packages/cli/lib/cache.ts: - Cache file at os.tmpdir()/tsslint-cache///.cache.json. TS version segment closes the .tsbuildinfo-format-cross-major hole flagged in CACHE.md item 4. - Object-form CacheData { version, ruleModes, files{mtime, rules} } replaces the 3.0.x tuple form. `version: "v2"` lets us evolve the schema; mismatch returns empty cache. - Atomic write via tmp + rename (CACHE.md item 6). Half-written JSON on SIGINT no longer corrupts the canonical file. - Default hash is sha256 hex (was btoa, which produced filesystem- ENAMETOOLONG paths on real configs). - Loader treats any uncertainty (parse error, shape mismatch, version drift) as a cache miss — CACHE.md item 2's "miss is always safe" invariant. - Also: scope `files` in cli's package.json to explicit paths so the newly-introduced test/ doesn't leak to npm. Mirrors fix in e40bfbb for core, 0585586 for compat-eslint. Core side — packages/core/index.ts: - New types `RuleCache { hasFix, diagnostics }` and `FileLintCache = Record`. Per-file shape; CLI manages the file- level mtime/version/ruleModes envelope. - lint() takes optional fileCache. Read path: hit only when entry exists AND rule is not in typeAwareRules — type-aware rules ignore any cache entry, matching CACHE.md's layer 1 invariant. - Write path: report() pushes a serialization-friendly twin of the diagnostic (file: undefined, relatedInformation files reduced to { fileName }). After the rule call, if it touched program, the whole entry is deleted; otherwise hasFix is set if any fix was registered. - Restored diagnostics on cache hit get rehydrated with the live ts.SourceFile from the current Program. Tests: - packages/cli/test/cache.test.ts (14 checks): roundtrip, missing file → empty, corrupted JSON → empty, version mismatch → empty, shape mismatch → empty, ts-version segregation, config edit invalidates path key, no .tmp leak after successful save. - packages/core/test/cache-layer1.test.ts (18 checks): syntactic rule cached, type-aware rule not cached, cache hit skips rule re-execution, restored diagnostic has live file ref, report-then- touch deletes entry, sticky cross-file, initialTypeAwareRules ignores stale cache entry, hasFix flag, lint without fileCache still works (back-compat). Existing tests pass unchanged (probe, builder-program-poc, compat- pipeline, lazy-estree). --- .github/workflows/test.yml | 8 + packages/cli/lib/cache.ts | 158 ++++++++++++++ packages/cli/package.json | 7 +- packages/cli/test/cache.test.ts | 271 ++++++++++++++++++++++++ packages/cli/tsconfig.json | 2 +- packages/core/index.ts | 76 ++++++- packages/core/test/cache-layer1.test.ts | 242 +++++++++++++++++++++ 7 files changed, 760 insertions(+), 4 deletions(-) create mode 100644 packages/cli/lib/cache.ts create mode 100644 packages/cli/test/cache.test.ts create mode 100644 packages/core/test/cache-layer1.test.ts diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 6390df2f..c2b4bb9b 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -65,3 +65,11 @@ jobs: # Core: BuilderProgram POC for layer 2 cache invalidation. - name: Core BuilderProgram POC run: node packages/core/test/builder-program-poc.test.js + + # Core: layer 1 cache (per-file, per-rule, syntactic-only). + - name: Core layer 1 cache + run: node packages/core/test/cache-layer1.test.js + + # CLI: cache file load / save / atomic write / version key. + - name: CLI cache module + run: node packages/cli/test/cache.test.js diff --git a/packages/cli/lib/cache.ts b/packages/cli/lib/cache.ts new file mode 100644 index 00000000..860f54ea --- /dev/null +++ b/packages/cli/lib/cache.ts @@ -0,0 +1,158 @@ +// CLI cache module. +// +// Persistent JSON cache file at: +// os.tmpdir()/tsslint-cache///.cache.json +// +// Path key includes: +// - tsslint version (segment) → bumping the package invalidates everything +// - typescript version (segment) → .tsbuildinfo format / TS internals can +// change across TS majors, so segregate per TS version +// - hash of (configFilePath, tsconfig, languages, configFile mtime+size) +// → editing tsslint.config.ts mints a fresh cache file +// +// Cache file shape — see packages/cli/CACHE.md "Cache file format" section. +// +// Soundness invariants the loader enforces: +// - any parse / shape / version mismatch returns an empty cache (treated +// as cold start). Wrong cache hit corrupts a code-review tool; wrong +// miss costs a re-run. Bias hard. +// - atomic write via temp file + rename. SIGINT during write can leave +// a stray .tmp but the canonical file stays intact. + +import path = require('path'); +import fs = require('fs'); +import os = require('os'); +import crypto = require('crypto'); + +import type * as ts from 'typescript'; + +const pkg = require('../package.json'); + +// Bump when the on-disk shape changes incompatibly. Mismatching version +// returns an empty cache; we don't migrate (cold start is the safe default). +const CACHE_FORMAT_VERSION = 'v2'; + +export interface CacheData { + version: string; + // Sticky per-rule classification. Only "type-aware" entries are + // stored — anything missing is treated as syntactic (or unclassified, + // which currently means the same thing: cache write happens). + ruleModes: Record; + files: Record; +} + +export interface FileCache { + mtime: number; + rules: Record; +} + +export interface RuleCache { + hasFix: boolean; + diagnostics: SerializedDiagnostic[]; +} + +// `ts.DiagnosticWithLocation` minus the live `file` reference — that gets +// re-attached on load by looking up the SourceFile from the current Program. +// `relatedInformation` similarly stores `{ fileName }` placeholders. +export type SerializedDiagnostic = Omit & { + relatedInformation?: SerializedRelatedInfo[]; +}; + +export type SerializedRelatedInfo = Omit & { + file?: { fileName: string }; +}; + +export function loadCache( + tsconfig: string, + configFilePath: string, + languages: string[], + tsVersion: string, + createHash: (s: string) => string = defaultHash, +): CacheData { + const filePath = getCacheFilePath(tsconfig, configFilePath, languages, tsVersion, createHash); + if (!fs.statSync(filePath, { throwIfNoEntry: false })?.isFile()) { + return emptyCache(); + } + let raw: string; + try { + raw = fs.readFileSync(filePath, 'utf8'); + } + catch { + return emptyCache(); + } + let parsed: unknown; + try { + parsed = JSON.parse(raw); + } + catch { + return emptyCache(); + } + if (!isCacheData(parsed)) { + return emptyCache(); + } + if (parsed.version !== CACHE_FORMAT_VERSION) { + return emptyCache(); + } + return parsed; +} + +export function saveCache( + tsconfig: string, + configFilePath: string, + languages: string[], + tsVersion: string, + cache: CacheData, + createHash: (s: string) => string = defaultHash, +): void { + const filePath = getCacheFilePath(tsconfig, configFilePath, languages, tsVersion, createHash); + fs.mkdirSync(path.dirname(filePath), { recursive: true }); + const tmpPath = filePath + '.tmp'; + fs.writeFileSync(tmpPath, JSON.stringify(cache)); + // `fs.renameSync` calls `MoveFileEx` on Windows (replaces existing) and + // `rename(2)` on POSIX (atomic same-fs replacement). If a process is + // killed between writeFileSync and renameSync, the canonical cache file + // is untouched and only the .tmp leaks. + fs.renameSync(tmpPath, filePath); +} + +export function emptyCache(): CacheData { + return { version: CACHE_FORMAT_VERSION, ruleModes: {}, files: {} }; +} + +function isCacheData(x: unknown): x is CacheData { + if (typeof x !== 'object' || x === null) return false; + const o = x as Record; + return typeof o.version === 'string' + && typeof o.ruleModes === 'object' && o.ruleModes !== null + && typeof o.files === 'object' && o.files !== null; +} + +function getCacheFilePath( + tsconfig: string, + configFilePath: string, + languages: string[], + tsVersion: string, + createHash: (s: string) => string, +): string { + const configStat = fs.statSync(configFilePath, { throwIfNoEntry: false }); + const cacheKey = [ + configFilePath, + tsconfig, + languages.sort().join(','), + configStat?.mtimeMs ?? 0, + configStat?.size ?? 0, + ].join('\0'); + return path.join( + getTsslintCachePath(), + tsVersion, + createHash(cacheKey) + '.cache.json', + ); +} + +function getTsslintCachePath(): string { + return path.join(os.tmpdir(), 'tsslint-cache', pkg.version); +} + +function defaultHash(s: string): string { + return crypto.createHash('sha256').update(s).digest('hex'); +} diff --git a/packages/cli/package.json b/packages/cli/package.json index d28b04f4..457f7b61 100644 --- a/packages/cli/package.json +++ b/packages/cli/package.json @@ -9,8 +9,11 @@ "tsslint": "./bin/tsslint.js" }, "files": [ - "**/*.js", - "**/*.d.ts" + "index.js", + "index.d.ts", + "bin/**/*", + "lib/**/*.js", + "lib/**/*.d.ts" ], "repository": { "type": "git", diff --git a/packages/cli/test/cache.test.ts b/packages/cli/test/cache.test.ts new file mode 100644 index 00000000..82d20bdd --- /dev/null +++ b/packages/cli/test/cache.test.ts @@ -0,0 +1,271 @@ +// Tests for the cache load/save module. +// +// Pure data-layer tests — no linter, no language service. Verifies: +// - round-trip save → load preserves shape +// - missing / corrupted / shape-mismatched files return empty cache +// - cache file path key segregates by tsslint+ts version + config +// +// Run via: +// node packages/cli/test/cache.test.js + +import path = require('path'); +import fs = require('fs'); +import os = require('os'); + +const cache = require('../lib/cache.js') as typeof import('../lib/cache.js'); + +const failures: string[] = []; +function check(name: string, cond: boolean, detail?: string) { + if (cond) { + process.stdout.write('.'); + } + else { + failures.push(name + (detail ? ' — ' + detail : '')); + process.stdout.write('F'); + } +} + +// Make a unique temp dir per test invocation so concurrent runs don't +// collide (and so we don't pollute prior tsslint-cache dirs). +const tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'tsslint-cache-test-')); +function tmpFile(name: string, content = ''): string { + const p = path.join(tmp, name); + fs.writeFileSync(p, content); + return p; +} + +// Override the cache root path. The module hardcodes os.tmpdir() — +// can't redirect cleanly, so just accept that test artefacts land +// alongside real cache files. Use a unique tsslint version segment via +// monkey-patching require.cache to make tests isolated. +// +// Simpler: just use unique configFile contents to force unique hashes. + +function configWithMarker(marker: string): string { + return tmpFile(`config-${marker}.ts`, `// ${marker}`); +} + +// ── Test 1: empty load when file doesn't exist ─────────────────────────── +{ + const tsconfig = tmpFile('tsconfig1.json', '{}'); + const config = configWithMarker('t1-' + Date.now()); + const data = cache.loadCache(tsconfig, config, [], '6.0.0'); + check('empty cache when file missing', data.files !== undefined && Object.keys(data.files).length === 0); + check('ruleModes empty', Object.keys(data.ruleModes).length === 0); + check('version present', typeof data.version === 'string'); +} + +// ── Test 2: round-trip save → load ─────────────────────────────────────── +{ + const tsconfig = tmpFile('tsconfig2.json', '{}'); + const config = configWithMarker('t2-' + Date.now()); + const original: import('../lib/cache.js').CacheData = { + version: 'v2', + ruleModes: { 'no-undef': 'type-aware' }, + files: { + '/abs/foo.ts': { + mtime: 1234567890, + rules: { + 'semi': { hasFix: false, diagnostics: [] }, + }, + }, + }, + }; + cache.saveCache(tsconfig, config, [], '6.0.0', original); + const loaded = cache.loadCache(tsconfig, config, [], '6.0.0'); + check('round-trip: ruleModes preserved', loaded.ruleModes['no-undef'] === 'type-aware'); + check('round-trip: file mtime preserved', loaded.files['/abs/foo.ts']?.mtime === 1234567890); + check('round-trip: rule entry preserved', !!loaded.files['/abs/foo.ts']?.rules['semi']); +} + +// ── Test 3: corrupted JSON returns empty cache ─────────────────────────── +{ + const tsconfig = tmpFile('tsconfig3.json', '{}'); + const config = configWithMarker('t3-' + Date.now()); + cache.saveCache(tsconfig, config, [], '6.0.0', cache.emptyCache()); + + // Find the cache file and corrupt it. + const cacheRoot = path.join(os.tmpdir(), 'tsslint-cache'); + const findCacheFile = (): string | null => { + const stack = [cacheRoot]; + while (stack.length) { + const dir = stack.pop()!; + let entries: fs.Dirent[]; + try { entries = fs.readdirSync(dir, { withFileTypes: true }); } + catch { continue; } + for (const e of entries) { + const full = path.join(dir, e.name); + if (e.isDirectory()) stack.push(full); + else if (e.isFile() && full.endsWith('.cache.json')) { + try { + const content = fs.readFileSync(full, 'utf8'); + const parsed = JSON.parse(content); + // Find the one we just wrote (heuristic: empty data). + if (parsed.version === 'v2' + && Object.keys(parsed.ruleModes).length === 0 + && Object.keys(parsed.files).length === 0) { + return full; + } + } + catch { /* ignore */ } + } + } + } + return null; + }; + const cacheFile = findCacheFile(); + check('found newly-written cache file', !!cacheFile); + if (cacheFile) { + fs.writeFileSync(cacheFile, '{not valid json'); + const loaded = cache.loadCache(tsconfig, config, [], '6.0.0'); + check('corrupted JSON → empty cache', Object.keys(loaded.files).length === 0); + } +} + +// ── Test 4: version mismatch returns empty cache ───────────────────────── +{ + const tsconfig = tmpFile('tsconfig4.json', '{}'); + const config = configWithMarker('t4-' + Date.now()); + const stale = { + version: 'v1', // wrong + ruleModes: { 'old-rule': 'type-aware' }, + files: { '/x': { mtime: 1, rules: {} } }, + }; + // Manually write a stale-version file + cache.saveCache(tsconfig, config, [], '6.0.0', stale as any); + const loaded = cache.loadCache(tsconfig, config, [], '6.0.0'); + check('version mismatch → empty', Object.keys(loaded.ruleModes).length === 0); +} + +// ── Test 5: shape mismatch returns empty cache ─────────────────────────── +{ + const tsconfig = tmpFile('tsconfig5.json', '{}'); + const config = configWithMarker('t5-' + Date.now()); + cache.saveCache(tsconfig, config, [], '6.0.0', cache.emptyCache()); + // Corrupt to a valid-JSON-but-wrong-shape value. Find the file we + // just wrote (heuristic: empty cache contents). + const cacheRoot = path.join(os.tmpdir(), 'tsslint-cache'); + const all: string[] = []; + const stack = [cacheRoot]; + while (stack.length) { + const dir = stack.pop()!; + let entries: fs.Dirent[]; + try { entries = fs.readdirSync(dir, { withFileTypes: true }); } + catch { continue; } + for (const e of entries) { + const full = path.join(dir, e.name); + if (e.isDirectory()) stack.push(full); + else if (e.isFile() && full.endsWith('.cache.json')) all.push(full); + } + } + // Look for one whose content is empty cache (the one we just wrote). + let target: string | null = null; + for (const f of all) { + try { + const o = JSON.parse(fs.readFileSync(f, 'utf8')); + if (o.version === 'v2' && Object.keys(o.files).length === 0 + && Object.keys(o.ruleModes).length === 0) { + // Could be from a previous test; pick first. + target = f; + break; + } + } + catch { /* ignore */ } + } + if (target) { + fs.writeFileSync(target, '"just a string"'); + const loaded = cache.loadCache(tsconfig, config, [], '6.0.0'); + check('shape mismatch → empty', Object.keys(loaded.files).length === 0); + } +} + +// ── Test 6: different ts version → different cache file ────────────────── +{ + const tsconfig = tmpFile('tsconfig6.json', '{}'); + const config = configWithMarker('t6-' + Date.now()); + const data: import('../lib/cache.js').CacheData = { + version: 'v2', + ruleModes: { 'rule6a': 'type-aware' }, + files: {}, + }; + cache.saveCache(tsconfig, config, [], '6.0.0', data); + const loadedSameVersion = cache.loadCache(tsconfig, config, [], '6.0.0'); + const loadedDifferentVersion = cache.loadCache(tsconfig, config, [], '7.0.0'); + + check( + 'same ts version finds the cache', + loadedSameVersion.ruleModes['rule6a'] === 'type-aware', + ); + check( + 'different ts version → different file → empty', + !loadedDifferentVersion.ruleModes['rule6a'], + ); +} + +// ── Test 7: editing config file changes cache file path ────────────────── +// +// configStat.mtimeMs / size is in the cache key. Mutating the config +// content + bumping mtime should land at a different cache file. +{ + const tsconfig = tmpFile('tsconfig7.json', '{}'); + const config = configWithMarker('t7-' + Date.now()); + + const data: import('../lib/cache.js').CacheData = { + version: 'v2', + ruleModes: { 'rule7': 'type-aware' }, + files: {}, + }; + cache.saveCache(tsconfig, config, [], '6.0.0', data); + + // Bump config size + mtime. + fs.writeFileSync(config, '// changed content'); + // Bump mtime explicitly to make sure the test isn't flaky on + // filesystems with low mtime resolution. + const newTime = new Date(Date.now() + 60_000); + fs.utimesSync(config, newTime, newTime); + + const loaded = cache.loadCache(tsconfig, config, [], '6.0.0'); + check( + 'config edit invalidates cache (different path key)', + !loaded.ruleModes['rule7'], + 'expected fresh cache after config mtime+size change', + ); +} + +// ── Test 8: atomic write uses .tmp file ────────────────────────────────── +// +// Sanity: saveCache writes to .tmp then renames. We can't easily +// observe the intermediate state, but we can verify no .tmp leaks +// after a successful save. +{ + const tsconfig = tmpFile('tsconfig8.json', '{}'); + const config = configWithMarker('t8-' + Date.now()); + cache.saveCache(tsconfig, config, [], '6.0.0', cache.emptyCache()); + + const cacheRoot = path.join(os.tmpdir(), 'tsslint-cache'); + let tmpLeaked = false; + const stack = [cacheRoot]; + while (stack.length) { + const dir = stack.pop()!; + let entries: fs.Dirent[]; + try { entries = fs.readdirSync(dir, { withFileTypes: true }); } + catch { continue; } + for (const e of entries) { + const full = path.join(dir, e.name); + if (e.isDirectory()) stack.push(full); + else if (e.isFile() && full.endsWith('.tmp')) tmpLeaked = true; + } + } + check('no .tmp leaked after successful save', !tmpLeaked); +} + +// ── Cleanup ───────────────────────────────────────────────────────────── +fs.rmSync(tmp, { recursive: true, force: true }); + +process.stdout.write('\n'); +if (failures.length) { + console.error(`\n${failures.length} failure(s):`); + for (const f of failures) console.error(' - ' + f); + process.exit(1); +} +console.log('OK'); diff --git a/packages/cli/tsconfig.json b/packages/cli/tsconfig.json index 807a2210..1140f37c 100644 --- a/packages/cli/tsconfig.json +++ b/packages/cli/tsconfig.json @@ -1,6 +1,6 @@ { "extends": "../../tsconfig.base.json", - "include": ["*", "lib/**/*"], + "include": ["*", "lib/**/*", "test/**/*"], "references": [ { "path": "../core/tsconfig.json" }, { "path": "../config/tsconfig.json" } diff --git a/packages/core/index.ts b/packages/core/index.ts index 5490d76a..afe5ef62 100644 --- a/packages/core/index.ts +++ b/packages/core/index.ts @@ -4,6 +4,23 @@ import type * as ts from 'typescript'; import path = require('path'); import minimatch = require('minimatch'); +// Per-file diagnostic cache. Keyed by ruleId — each entry holds the +// rule's last-known result (with `hasFix` so the CLI can decide whether +// to surface `--fix` even on a cache hit). The CLI handles file-level +// invalidation (mtime check) and cross-session persistence; this type +// is just the in-memory shape core reads from and writes to. +// +// Diagnostics stored here have `file` cleared (`undefined as any`) — +// they're rehydrated with the current `ts.SourceFile` on cache hit. +// The CLI's serialization layer drops / restores them across the +// JSON boundary. +export interface RuleCache { + hasFix: boolean; + diagnostics: ts.DiagnosticWithLocation[]; +} + +export type FileLintCache = Record; + export type Linter = ReturnType; export function createLinter( @@ -51,7 +68,7 @@ export function createLinter( const typeAwareRules = new Set(initialTypeAwareRules ?? []); return { - lint(fileName: string): ts.DiagnosticWithLocation[] { + lint(fileName: string, fileCache?: FileLintCache): ts.DiagnosticWithLocation[] { let currentRuleId: string; const rules = getRulesForFile(fileName); @@ -82,6 +99,27 @@ export function createLinter( currentRuleId = ruleId; + const ruleCache = fileCache?.[currentRuleId]; + // Cache hit only when: + // - cache has an entry for this rule + // - rule has not been classified type-aware + // For type-aware rules we never trust the cache, regardless + // of whether the entry was written this session or carried + // over from before classification. + if (ruleCache && !typeAwareRules.has(currentRuleId)) { + for (const cached of ruleCache.diagnostics) { + lintResult[1].set({ + ...cached, + file: rulesContext.file, + relatedInformation: cached.relatedInformation?.map(info => ({ + ...info, + file: info.file ? program.getSourceFile(info.file.fileName) : undefined, + })), + }, []); + } + continue; + } + touchedProgram = false; try { rule(rulesContext); @@ -97,6 +135,25 @@ export function createLinter( if (touchedProgram) { typeAwareRules.add(currentRuleId); } + + if (fileCache) { + if (typeAwareRules.has(currentRuleId)) { + // Discard any entry — could have been written by + // `report()` during the run before the program access, + // or stale from a prior session that didn't classify + // this rule. + delete fileCache[currentRuleId]; + } + else { + fileCache[currentRuleId] ??= { hasFix: false, diagnostics: [] }; + for (const [_, fixes] of lintResult[1]) { + if (fixes.length) { + fileCache[currentRuleId].hasFix = true; + break; + } + } + } + } } let diagnostics = [...lintResult[1].keys()]; @@ -139,6 +196,23 @@ export function createLinter( let location: [Error, number] = [new Error(), 1]; let relatedInformation: ts.DiagnosticRelatedInformation[] | undefined; + // Push a serialization-friendly twin of the diagnostic into + // the cache as the rule reports. If the rule turns out to be + // type-aware (touchedProgram flips later in the same call), + // the post-rule cleanup deletes this entry — the cache only + // retains entries for rules confirmed syntactic. + if (fileCache) { + fileCache[currentRuleId] ??= { hasFix: false, diagnostics: [] }; + fileCache[currentRuleId].diagnostics.push({ + ...error, + file: undefined as any, + relatedInformation: error.relatedInformation?.map(info => ({ + ...info, + file: info.file ? { fileName: info.file.fileName } as any : undefined, + })), + }); + } + let lintResult = lintResults.get(fileName); if (!lintResult) { lintResults.set(fileName, lintResult = [rulesContext.file, new Map(), []]); diff --git a/packages/core/test/cache-layer1.test.ts b/packages/core/test/cache-layer1.test.ts new file mode 100644 index 00000000..450ff77a --- /dev/null +++ b/packages/core/test/cache-layer1.test.ts @@ -0,0 +1,242 @@ +// Layer 1 cache: per-file mtime-driven cache for syntactic rules. +// Covers core's slice — read fileCache, skip rule on hit, write on miss. +// Type-aware rules never get cached regardless of fileCache state. +// +// Run via: +// node packages/core/test/cache-layer1.test.js + +import * as ts from 'typescript'; +import type { Config, RuleContext } from '@tsslint/types'; +import type { FileLintCache } from '../index.js'; + +const core = require('../index.js') as typeof import('../index.js'); + +const failures: string[] = []; +function check(name: string, cond: boolean, detail?: string) { + if (cond) { + process.stdout.write('.'); + } + else { + failures.push(name + (detail ? ' — ' + detail : '')); + process.stdout.write('F'); + } +} + +function makeContext(files: Record) { + const realLibPath = ts.getDefaultLibFilePath({ target: ts.ScriptTarget.Latest }); + const realLibContent = ts.sys.readFile(realLibPath) ?? ''; + const host: ts.LanguageServiceHost = { + getCompilationSettings: () => ({ + target: ts.ScriptTarget.Latest, + noEmit: true, + lib: [realLibPath.split(/[\\/]/).pop()!], + }), + getScriptFileNames: () => Object.keys(files), + getScriptVersion: () => '1', + getScriptSnapshot: n => { + if (n in files) return ts.ScriptSnapshot.fromString(files[n]); + if (n === realLibPath) return ts.ScriptSnapshot.fromString(realLibContent); + return undefined; + }, + getCurrentDirectory: () => '/', + getDefaultLibFileName: () => realLibPath, + fileExists: n => n in files || n === realLibPath, + readFile: n => (n in files ? files[n] : (n === realLibPath ? realLibContent : undefined)), + }; + return { typescript: ts, languageServiceHost: host, languageService: ts.createLanguageService(host) }; +} + +// ── Test 1: syntactic rule writes cache entry ──────────────────────────── +{ + const ctx = makeContext({ '/a.ts': 'const x = 1;' }); + const config: Config = { + rules: { + syntactic: ((rctx: RuleContext) => { + rctx.report('hi', 0, 1); + }) as any, + }, + }; + const linter = core.createLinter(ctx, '/', config, () => []); + const cache: FileLintCache = {}; + linter.lint('/a.ts', cache); + check('syntactic rule cached', !!cache['syntactic']); + check('cache has 1 diagnostic', cache['syntactic']?.diagnostics.length === 1); + check('hasFix is false (no fix reported)', cache['syntactic']?.hasFix === false); +} + +// ── Test 2: type-aware rule does not write cache entry ─────────────────── +{ + const ctx = makeContext({ '/a.ts': 'const x = 1;' }); + const config: Config = { + rules: { + typed: ((rctx: RuleContext) => { + void rctx.program; + rctx.report('typed', 0, 1); + }) as any, + }, + }; + const linter = core.createLinter(ctx, '/', config, () => []); + const cache: FileLintCache = {}; + const diagnostics = linter.lint('/a.ts', cache); + check('type-aware rule still produces diagnostics', diagnostics.length === 1); + check('type-aware rule NOT cached', !cache['typed']); +} + +// ── Test 3: cache hit → rule not re-run, diagnostics restored ──────────── +{ + const ctx = makeContext({ '/a.ts': 'const x = 1;' }); + let runs = 0; + const config: Config = { + rules: { + syntactic: ((rctx: RuleContext) => { + runs++; + rctx.report('hi', 0, 1); + }) as any, + }, + }; + const linter = core.createLinter(ctx, '/', config, () => []); + + // First run populates cache. + const cache: FileLintCache = {}; + const first = linter.lint('/a.ts', cache); + check('first run actually ran', runs === 1); + check('first run produced 1 diagnostic', first.length === 1); + + // Second run should hit cache, rule should NOT execute. + const second = linter.lint('/a.ts', cache); + check('second run did NOT re-execute rule (cache hit)', runs === 1); + check('second run still produced 1 diagnostic', second.length === 1); + + // Restored diagnostic must have a live file pointer (not undefined). + check( + 'restored diagnostic has live file ref', + second[0]?.file !== undefined && typeof second[0]?.file?.fileName === 'string', + `got file: ${second[0]?.file}`, + ); +} + +// ── Test 4: report-then-touch-program deletes cache entry ──────────────── +{ + const ctx = makeContext({ '/a.ts': 'const x = 1;' }); + const config: Config = { + rules: { + 'report-then-touch': ((rctx: RuleContext) => { + rctx.report('first', 0, 1); // populates cache via report() + void rctx.program; // flips touchedProgram → mark type-aware + }) as any, + }, + }; + const linter = core.createLinter(ctx, '/', config, () => []); + const cache: FileLintCache = {}; + linter.lint('/a.ts', cache); + check('post-rule cleanup deleted entry', !cache['report-then-touch']); +} + +// ── Test 5: sticky type-aware across files ─────────────────────────────── +{ + const ctx = makeContext({ + '/a.ts': 'const x = 1;', + '/b.ts': 'const y = 2;', + }); + const config: Config = { + rules: { + 'sometimes-typed': ((rctx: RuleContext) => { + if (rctx.file.fileName === '/a.ts') { + void rctx.program; + } + rctx.report('hi', 0, 1); + }) as any, + }, + }; + const linter = core.createLinter(ctx, '/', config, () => []); + const cacheA: FileLintCache = {}; + const cacheB: FileLintCache = {}; + linter.lint('/a.ts', cacheA); // touches program for a + linter.lint('/b.ts', cacheB); // doesn't touch for b + check('a.ts no cache entry', !cacheA['sometimes-typed']); + check( + 'b.ts no cache entry (sticky)', + !cacheB['sometimes-typed'], + 'classification persists past the file that triggered it', + ); +} + +// ── Test 6: initialTypeAwareRules → pre-existing cache entry ignored ───── +// +// Cold session with stale cache: ruleModes from a prior session marks +// `typed` as type-aware. cache file still has an entry for `typed` +// (e.g. written before this fix shipped). The linter must ignore it +// and re-run the rule. +{ + const ctx = makeContext({ '/a.ts': 'const x = 1;' }); + let runs = 0; + const config: Config = { + rules: { + typed: ((rctx: RuleContext) => { + runs++; + void rctx.program; + rctx.report('typed', 0, 1); + }) as any, + }, + }; + const linter = core.createLinter(ctx, '/', config, () => [], ['typed']); + + const cache: FileLintCache = { + typed: { + hasFix: false, + diagnostics: [{ + category: ts.DiagnosticCategory.Message, + code: 'typed' as any, + messageText: 'stale', + file: undefined as any, + start: 0, + length: 1, + source: 'tsslint', + } as ts.DiagnosticWithLocation], + }, + }; + const result = linter.lint('/a.ts', cache); + check('rule re-ran (stale cache ignored)', runs === 1); + check('result reflects fresh run, not stale cache', result.length === 1); + check('stale entry deleted after re-run', !cache['typed']); +} + +// ── Test 7: hasFix flag set when rule registers a fix ──────────────────── +{ + const ctx = makeContext({ '/a.ts': 'const x = 1;' }); + const config: Config = { + rules: { + fixable: ((rctx: RuleContext) => { + rctx.report('fix me', 0, 1).withFix('apply', () => []); + }) as any, + }, + }; + const linter = core.createLinter(ctx, '/', config, () => []); + const cache: FileLintCache = {}; + linter.lint('/a.ts', cache); + check('hasFix true after rule registered a fix', cache['fixable']?.hasFix === true); +} + +// ── Test 8: lint without fileCache works (back-compat) ─────────────────── +{ + const ctx = makeContext({ '/a.ts': 'const x = 1;' }); + const config: Config = { + rules: { + r: ((rctx: RuleContext) => { + rctx.report('hi', 0, 1); + }) as any, + }, + }; + const linter = core.createLinter(ctx, '/', config, () => []); + const result = linter.lint('/a.ts'); // no fileCache passed + check('lint without fileCache still works', result.length === 1); +} + +// ── Done ──────────────────────────────────────────────────────────────── +process.stdout.write('\n'); +if (failures.length) { + console.error(`\n${failures.length} failure(s):`); + for (const f of failures) console.error(' - ' + f); + process.exit(1); +} +console.log('OK'); From eaabb4c3f1bc75bcc43c702e6d4c884ffb4d516e Mon Sep 17 00:00:00 2001 From: Johnson Chu Date: Thu, 30 Apr 2026 11:18:20 +0800 Subject: [PATCH 07/32] refactor(core): decouple cache from lint, expose skipRules + hasFix API MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Walks back the cache-aware lint() introduced in 2284d7c. The cache file format and lifecycle are CLI concerns; core's job is to run rules. Future cache changes (BuilderProgram-based invalidation, schema bumps) shouldn't churn this layer. API changes on createLinter(): - `lint(fileName, options?: { skipRules?: ReadonlySet })` Replaces the cache-mutation overload. Caller passes rule IDs to skip; core just doesn't run them. Caller is responsible for merging cached output back into the diagnostic list. - `hasFixForDiagnostic(fileName, diagnostic): boolean` New per-diagnostic fix accessor. The CLI cache layer uses this to snapshot the `hasFix` flag for a rule's cache entry without reaching into core's internal `diagnostic2Fixes` map. - Removed types: `RuleCache`, `FileLintCache`. Both move to the CLI cache module in the next commit. - Removed the cache-write side of `report()`. Reports now only update the in-memory lint result; serialization for persistence is handled outside core. What stays in core: - The runtime probe + sticky `typeAwareRules` set + `getTypeAwareRules()` exposed for the CLI to persist into `ruleModes`. - `initialTypeAwareRules` constructor param so a session starting cold can preseed classification from the cache file. Tests: - packages/core/test/cache-layer1.test.ts removed — that suite tested cache mutation through lint(), which is no longer core's job. The same scenarios are reformulated against the CLI cache layer in the next commit. - packages/core/test/skip-rules.test.ts added (9 checks): rule in skipRules doesn't run, only specified rule is skipped, lint without options runs everything (back-compat), seeded classification persists through skipping, hasFixForDiagnostic returns true only for diags with registered fixes. Existing probe.test.ts and builder-program-poc.test.ts pass unchanged. --- .github/workflows/test.yml | 7 +- packages/core/index.ts | 93 ++------- packages/core/test/cache-layer1.test.ts | 242 ------------------------ packages/core/test/skip-rules.test.ts | 156 +++++++++++++++ 4 files changed, 181 insertions(+), 317 deletions(-) delete mode 100644 packages/core/test/cache-layer1.test.ts create mode 100644 packages/core/test/skip-rules.test.ts diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index c2b4bb9b..a57eeef7 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -66,9 +66,10 @@ jobs: - name: Core BuilderProgram POC run: node packages/core/test/builder-program-poc.test.js - # Core: layer 1 cache (per-file, per-rule, syntactic-only). - - name: Core layer 1 cache - run: node packages/core/test/cache-layer1.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 diff --git a/packages/core/index.ts b/packages/core/index.ts index afe5ef62..65fa91a4 100644 --- a/packages/core/index.ts +++ b/packages/core/index.ts @@ -4,23 +4,6 @@ import type * as ts from 'typescript'; import path = require('path'); import minimatch = require('minimatch'); -// Per-file diagnostic cache. Keyed by ruleId — each entry holds the -// rule's last-known result (with `hasFix` so the CLI can decide whether -// to surface `--fix` even on a cache hit). The CLI handles file-level -// invalidation (mtime check) and cross-session persistence; this type -// is just the in-memory shape core reads from and writes to. -// -// Diagnostics stored here have `file` cleared (`undefined as any`) — -// they're rehydrated with the current `ts.SourceFile` on cache hit. -// The CLI's serialization layer drops / restores them across the -// JSON boundary. -export interface RuleCache { - hasFix: boolean; - diagnostics: ts.DiagnosticWithLocation[]; -} - -export type FileLintCache = Record; - export type Linter = ReturnType; export function createLinter( @@ -68,8 +51,18 @@ export function createLinter( const typeAwareRules = new Set(initialTypeAwareRules ?? []); return { - lint(fileName: string, fileCache?: FileLintCache): ts.DiagnosticWithLocation[] { + // `options.skipRules`: rule IDs the caller already has cached + // results for. Core just doesn't run them — the caller is + // responsible for merging cached output into the final diagnostic + // list. Decoupling cache lifecycle from core means future cache + // changes (BuilderProgram-based invalidation, schema bumps) live + // in the CLI without churning this layer. + lint( + fileName: string, + options?: { skipRules?: ReadonlySet }, + ): ts.DiagnosticWithLocation[] { let currentRuleId: string; + const skipRules = options?.skipRules; const rules = getRulesForFile(fileName); const token = ctx.languageServiceHost.getCancellationToken?.(); @@ -99,24 +92,7 @@ export function createLinter( currentRuleId = ruleId; - const ruleCache = fileCache?.[currentRuleId]; - // Cache hit only when: - // - cache has an entry for this rule - // - rule has not been classified type-aware - // For type-aware rules we never trust the cache, regardless - // of whether the entry was written this session or carried - // over from before classification. - if (ruleCache && !typeAwareRules.has(currentRuleId)) { - for (const cached of ruleCache.diagnostics) { - lintResult[1].set({ - ...cached, - file: rulesContext.file, - relatedInformation: cached.relatedInformation?.map(info => ({ - ...info, - file: info.file ? program.getSourceFile(info.file.fileName) : undefined, - })), - }, []); - } + if (skipRules?.has(currentRuleId)) { continue; } @@ -135,25 +111,6 @@ export function createLinter( if (touchedProgram) { typeAwareRules.add(currentRuleId); } - - if (fileCache) { - if (typeAwareRules.has(currentRuleId)) { - // Discard any entry — could have been written by - // `report()` during the run before the program access, - // or stale from a prior session that didn't classify - // this rule. - delete fileCache[currentRuleId]; - } - else { - fileCache[currentRuleId] ??= { hasFix: false, diagnostics: [] }; - for (const [_, fixes] of lintResult[1]) { - if (fixes.length) { - fileCache[currentRuleId].hasFix = true; - break; - } - } - } - } } let diagnostics = [...lintResult[1].keys()]; @@ -196,23 +153,6 @@ export function createLinter( let location: [Error, number] = [new Error(), 1]; let relatedInformation: ts.DiagnosticRelatedInformation[] | undefined; - // Push a serialization-friendly twin of the diagnostic into - // the cache as the rule reports. If the rule turns out to be - // type-aware (touchedProgram flips later in the same call), - // the post-rule cleanup deletes this entry — the cache only - // retains entries for rules confirmed syntactic. - if (fileCache) { - fileCache[currentRuleId] ??= { hasFix: false, diagnostics: [] }; - fileCache[currentRuleId].diagnostics.push({ - ...error, - file: undefined as any, - relatedInformation: error.relatedInformation?.map(info => ({ - ...info, - file: info.file ? { fileName: info.file.fileName } as any : undefined, - })), - }); - } - let lintResult = lintResults.get(fileName); if (!lintResult) { lintResults.set(fileName, lintResult = [rulesContext.file, new Map(), []]); @@ -274,6 +214,15 @@ export function createLinter( } return false; }, + // Per-diagnostic fix presence — used by the CLI cache layer to + // snapshot `hasFix` for a rule's cache entry without reaching + // into core's internal `diagnostic2Fixes` map. + hasFixForDiagnostic(fileName: string, diagnostic: ts.DiagnosticWithLocation): boolean { + const lintResult = lintResults.get(fileName); + if (!lintResult) return false; + const fixes = lintResult[1].get(diagnostic); + return !!fixes && fixes.length > 0; + }, getCodeFixes( fileName: string, start: number, diff --git a/packages/core/test/cache-layer1.test.ts b/packages/core/test/cache-layer1.test.ts deleted file mode 100644 index 450ff77a..00000000 --- a/packages/core/test/cache-layer1.test.ts +++ /dev/null @@ -1,242 +0,0 @@ -// Layer 1 cache: per-file mtime-driven cache for syntactic rules. -// Covers core's slice — read fileCache, skip rule on hit, write on miss. -// Type-aware rules never get cached regardless of fileCache state. -// -// Run via: -// node packages/core/test/cache-layer1.test.js - -import * as ts from 'typescript'; -import type { Config, RuleContext } from '@tsslint/types'; -import type { FileLintCache } from '../index.js'; - -const core = require('../index.js') as typeof import('../index.js'); - -const failures: string[] = []; -function check(name: string, cond: boolean, detail?: string) { - if (cond) { - process.stdout.write('.'); - } - else { - failures.push(name + (detail ? ' — ' + detail : '')); - process.stdout.write('F'); - } -} - -function makeContext(files: Record) { - const realLibPath = ts.getDefaultLibFilePath({ target: ts.ScriptTarget.Latest }); - const realLibContent = ts.sys.readFile(realLibPath) ?? ''; - const host: ts.LanguageServiceHost = { - getCompilationSettings: () => ({ - target: ts.ScriptTarget.Latest, - noEmit: true, - lib: [realLibPath.split(/[\\/]/).pop()!], - }), - getScriptFileNames: () => Object.keys(files), - getScriptVersion: () => '1', - getScriptSnapshot: n => { - if (n in files) return ts.ScriptSnapshot.fromString(files[n]); - if (n === realLibPath) return ts.ScriptSnapshot.fromString(realLibContent); - return undefined; - }, - getCurrentDirectory: () => '/', - getDefaultLibFileName: () => realLibPath, - fileExists: n => n in files || n === realLibPath, - readFile: n => (n in files ? files[n] : (n === realLibPath ? realLibContent : undefined)), - }; - return { typescript: ts, languageServiceHost: host, languageService: ts.createLanguageService(host) }; -} - -// ── Test 1: syntactic rule writes cache entry ──────────────────────────── -{ - const ctx = makeContext({ '/a.ts': 'const x = 1;' }); - const config: Config = { - rules: { - syntactic: ((rctx: RuleContext) => { - rctx.report('hi', 0, 1); - }) as any, - }, - }; - const linter = core.createLinter(ctx, '/', config, () => []); - const cache: FileLintCache = {}; - linter.lint('/a.ts', cache); - check('syntactic rule cached', !!cache['syntactic']); - check('cache has 1 diagnostic', cache['syntactic']?.diagnostics.length === 1); - check('hasFix is false (no fix reported)', cache['syntactic']?.hasFix === false); -} - -// ── Test 2: type-aware rule does not write cache entry ─────────────────── -{ - const ctx = makeContext({ '/a.ts': 'const x = 1;' }); - const config: Config = { - rules: { - typed: ((rctx: RuleContext) => { - void rctx.program; - rctx.report('typed', 0, 1); - }) as any, - }, - }; - const linter = core.createLinter(ctx, '/', config, () => []); - const cache: FileLintCache = {}; - const diagnostics = linter.lint('/a.ts', cache); - check('type-aware rule still produces diagnostics', diagnostics.length === 1); - check('type-aware rule NOT cached', !cache['typed']); -} - -// ── Test 3: cache hit → rule not re-run, diagnostics restored ──────────── -{ - const ctx = makeContext({ '/a.ts': 'const x = 1;' }); - let runs = 0; - const config: Config = { - rules: { - syntactic: ((rctx: RuleContext) => { - runs++; - rctx.report('hi', 0, 1); - }) as any, - }, - }; - const linter = core.createLinter(ctx, '/', config, () => []); - - // First run populates cache. - const cache: FileLintCache = {}; - const first = linter.lint('/a.ts', cache); - check('first run actually ran', runs === 1); - check('first run produced 1 diagnostic', first.length === 1); - - // Second run should hit cache, rule should NOT execute. - const second = linter.lint('/a.ts', cache); - check('second run did NOT re-execute rule (cache hit)', runs === 1); - check('second run still produced 1 diagnostic', second.length === 1); - - // Restored diagnostic must have a live file pointer (not undefined). - check( - 'restored diagnostic has live file ref', - second[0]?.file !== undefined && typeof second[0]?.file?.fileName === 'string', - `got file: ${second[0]?.file}`, - ); -} - -// ── Test 4: report-then-touch-program deletes cache entry ──────────────── -{ - const ctx = makeContext({ '/a.ts': 'const x = 1;' }); - const config: Config = { - rules: { - 'report-then-touch': ((rctx: RuleContext) => { - rctx.report('first', 0, 1); // populates cache via report() - void rctx.program; // flips touchedProgram → mark type-aware - }) as any, - }, - }; - const linter = core.createLinter(ctx, '/', config, () => []); - const cache: FileLintCache = {}; - linter.lint('/a.ts', cache); - check('post-rule cleanup deleted entry', !cache['report-then-touch']); -} - -// ── Test 5: sticky type-aware across files ─────────────────────────────── -{ - const ctx = makeContext({ - '/a.ts': 'const x = 1;', - '/b.ts': 'const y = 2;', - }); - const config: Config = { - rules: { - 'sometimes-typed': ((rctx: RuleContext) => { - if (rctx.file.fileName === '/a.ts') { - void rctx.program; - } - rctx.report('hi', 0, 1); - }) as any, - }, - }; - const linter = core.createLinter(ctx, '/', config, () => []); - const cacheA: FileLintCache = {}; - const cacheB: FileLintCache = {}; - linter.lint('/a.ts', cacheA); // touches program for a - linter.lint('/b.ts', cacheB); // doesn't touch for b - check('a.ts no cache entry', !cacheA['sometimes-typed']); - check( - 'b.ts no cache entry (sticky)', - !cacheB['sometimes-typed'], - 'classification persists past the file that triggered it', - ); -} - -// ── Test 6: initialTypeAwareRules → pre-existing cache entry ignored ───── -// -// Cold session with stale cache: ruleModes from a prior session marks -// `typed` as type-aware. cache file still has an entry for `typed` -// (e.g. written before this fix shipped). The linter must ignore it -// and re-run the rule. -{ - const ctx = makeContext({ '/a.ts': 'const x = 1;' }); - let runs = 0; - const config: Config = { - rules: { - typed: ((rctx: RuleContext) => { - runs++; - void rctx.program; - rctx.report('typed', 0, 1); - }) as any, - }, - }; - const linter = core.createLinter(ctx, '/', config, () => [], ['typed']); - - const cache: FileLintCache = { - typed: { - hasFix: false, - diagnostics: [{ - category: ts.DiagnosticCategory.Message, - code: 'typed' as any, - messageText: 'stale', - file: undefined as any, - start: 0, - length: 1, - source: 'tsslint', - } as ts.DiagnosticWithLocation], - }, - }; - const result = linter.lint('/a.ts', cache); - check('rule re-ran (stale cache ignored)', runs === 1); - check('result reflects fresh run, not stale cache', result.length === 1); - check('stale entry deleted after re-run', !cache['typed']); -} - -// ── Test 7: hasFix flag set when rule registers a fix ──────────────────── -{ - const ctx = makeContext({ '/a.ts': 'const x = 1;' }); - const config: Config = { - rules: { - fixable: ((rctx: RuleContext) => { - rctx.report('fix me', 0, 1).withFix('apply', () => []); - }) as any, - }, - }; - const linter = core.createLinter(ctx, '/', config, () => []); - const cache: FileLintCache = {}; - linter.lint('/a.ts', cache); - check('hasFix true after rule registered a fix', cache['fixable']?.hasFix === true); -} - -// ── Test 8: lint without fileCache works (back-compat) ─────────────────── -{ - const ctx = makeContext({ '/a.ts': 'const x = 1;' }); - const config: Config = { - rules: { - r: ((rctx: RuleContext) => { - rctx.report('hi', 0, 1); - }) as any, - }, - }; - const linter = core.createLinter(ctx, '/', config, () => []); - const result = linter.lint('/a.ts'); // no fileCache passed - check('lint without fileCache still works', result.length === 1); -} - -// ── Done ──────────────────────────────────────────────────────────────── -process.stdout.write('\n'); -if (failures.length) { - console.error(`\n${failures.length} failure(s):`); - for (const f of failures) console.error(' - ' + f); - process.exit(1); -} -console.log('OK'); diff --git a/packages/core/test/skip-rules.test.ts b/packages/core/test/skip-rules.test.ts new file mode 100644 index 00000000..11f9c6fd --- /dev/null +++ b/packages/core/test/skip-rules.test.ts @@ -0,0 +1,156 @@ +// Tests for the `skipRules` option on `linter.lint`. The CLI cache +// layer uses this to bypass rules whose cached results it'll merge in +// itself — core just doesn't run them. +// +// Run via: +// node packages/core/test/skip-rules.test.js + +import * as ts from 'typescript'; +import type { Config, RuleContext } from '@tsslint/types'; + +const core = require('../index.js') as typeof import('../index.js'); + +const failures: string[] = []; +function check(name: string, cond: boolean, detail?: string) { + if (cond) { + process.stdout.write('.'); + } + else { + failures.push(name + (detail ? ' — ' + detail : '')); + process.stdout.write('F'); + } +} + +function makeContext(files: Record) { + const realLibPath = ts.getDefaultLibFilePath({ target: ts.ScriptTarget.Latest }); + const realLibContent = ts.sys.readFile(realLibPath) ?? ''; + const host: ts.LanguageServiceHost = { + getCompilationSettings: () => ({ + target: ts.ScriptTarget.Latest, + noEmit: true, + lib: [realLibPath.split(/[\\/]/).pop()!], + }), + getScriptFileNames: () => Object.keys(files), + getScriptVersion: () => '1', + getScriptSnapshot: n => { + if (n in files) return ts.ScriptSnapshot.fromString(files[n]); + if (n === realLibPath) return ts.ScriptSnapshot.fromString(realLibContent); + return undefined; + }, + getCurrentDirectory: () => '/', + getDefaultLibFileName: () => realLibPath, + fileExists: n => n in files || n === realLibPath, + readFile: n => (n in files ? files[n] : (n === realLibPath ? realLibContent : undefined)), + }; + return { typescript: ts, languageServiceHost: host, languageService: ts.createLanguageService(host) }; +} + +// ── Test 1: rule in skipRules doesn't run, no diagnostic in result ─────── +{ + const ctx = makeContext({ '/a.ts': 'const x = 1;' }); + let runs = 0; + const config: Config = { + rules: { + r: ((rctx: RuleContext) => { + runs++; + rctx.report('hi', 0, 1); + }) as any, + }, + }; + const linter = core.createLinter(ctx, '/', config, () => []); + const out = linter.lint('/a.ts', { skipRules: new Set(['r']) }); + check('skipped rule did not run', runs === 0); + check('skipped rule produced no diagnostic', out.length === 0); +} + +// ── Test 2: only the specified rule is skipped; others run normally ────── +{ + const ctx = makeContext({ '/a.ts': 'const x = 1;' }); + let aRuns = 0; + let bRuns = 0; + const config: Config = { + rules: { + a: ((rctx: RuleContext) => { + aRuns++; + rctx.report('a', 0, 1); + }) as any, + b: ((rctx: RuleContext) => { + bRuns++; + rctx.report('b', 0, 1); + }) as any, + }, + }; + const linter = core.createLinter(ctx, '/', config, () => []); + const out = linter.lint('/a.ts', { skipRules: new Set(['a']) }); + check('a skipped', aRuns === 0); + check('b ran', bRuns === 1); + check('only b in result', out.length === 1 && String(out[0]?.code) === 'b'); +} + +// ── Test 3: lint without options runs everything (back-compat) ─────────── +{ + const ctx = makeContext({ '/a.ts': 'const x = 1;' }); + let runs = 0; + const config: Config = { + rules: { + r: ((rctx: RuleContext) => { + runs++; + rctx.report('x', 0, 1); + }) as any, + }, + }; + const linter = core.createLinter(ctx, '/', config, () => []); + linter.lint('/a.ts'); + check('lint without options runs rule', runs === 1); +} + +// ── Test 4: skipped rule's classification is unchanged ────────────────── +// +// Probe runs only when a rule actually executes. A skipped rule's +// type-aware status comes from `initialTypeAwareRules` (or stays +// uncalculated). Skipping doesn't undo a prior classification. +{ + const ctx = makeContext({ '/a.ts': 'const x = 1;' }); + const config: Config = { + rules: { + r: ((rctx: RuleContext) => { + void rctx.program; + rctx.report('x', 0, 1); + }) as any, + }, + }; + // Seed as type-aware. Then skip the rule. Classification persists. + const linter = core.createLinter(ctx, '/', config, () => [], ['r']); + linter.lint('/a.ts', { skipRules: new Set(['r']) }); + check('seeded classification persists when skipped', linter.getTypeAwareRules().has('r')); +} + +// ── Test 5: hasFixForDiagnostic returns true only for diags with fixes ─── +{ + const ctx = makeContext({ '/a.ts': 'const x = 1;' }); + const config: Config = { + rules: { + fixable: ((rctx: RuleContext) => { + rctx.report('fix me', 0, 1).withFix('apply', () => []); + }) as any, + plain: ((rctx: RuleContext) => { + rctx.report('plain', 1, 2); + }) as any, + }, + }; + const linter = core.createLinter(ctx, '/', config, () => []); + const out = linter.lint('/a.ts'); + const fixable = out.find(d => String(d.code) === 'fixable')!; + const plain = out.find(d => String(d.code) === 'plain')!; + check('hasFixForDiagnostic true for fixable diag', linter.hasFixForDiagnostic('/a.ts', fixable) === true); + check('hasFixForDiagnostic false for plain diag', linter.hasFixForDiagnostic('/a.ts', plain) === false); +} + +// ── Done ──────────────────────────────────────────────────────────────── +process.stdout.write('\n'); +if (failures.length) { + console.error(`\n${failures.length} failure(s):`); + for (const f of failures) console.error(' - ' + f); + process.exit(1); +} +console.log('OK'); From 80e3d6ae44b58a555bf993868b9d8fb1037e590c Mon Sep 17 00:00:00 2001 From: Johnson Chu Date: Thu, 30 Apr 2026 11:22:36 +0800 Subject: [PATCH 08/32] =?UTF-8?q?feat(cli):=20cache-flow=20module=20?= =?UTF-8?q?=E2=80=94=20all=20layer=201=20logic=20in=20one=20place?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Counterpart to the prior commit (eaabb4c) that pulled cache concerns out of core. Lands the layer 1 logic in packages/cli/lib/cache-flow.ts: lintWithCache(linter, fileName, fileCache, fileMtime, program) → DiagnosticWithLocation[] Responsibilities, all owned by this module: - mtime check: bumping the file mtime resets every rule entry - skip-set computation: cached entries minus type-aware rules - call into core's `linter.lint(fileName, { skipRules })` - group fresh diagnostics by `diagnostic.code` (the rule ID core sets in `report()`) — that's the attribution channel - update cache for rules that ran: - type-aware rules: delete the entry (sticky cleanup, covers report-then-touch and stale prior-session entries) - syntactic rules: write fresh entry, derive `hasFix` via `linter.hasFixForDiagnostic` for each emitted diagnostic - rehydrate cached diagnostics for skipped rules — replace the serialized `file: undefined` with the live `ts.SourceFile` from the current Program; same for `relatedInformation[].file` Layer 2 (BuilderProgram-based affected-file invalidation) plugs in here later: it'll narrow the skip set further by removing entries the BuilderProgram says are affected. Core stays untouched. Tests in packages/cli/test/cache-flow.test.ts (25 checks): syntactic cached / type-aware not, cache hit skips rule, mtime change re-runs, report-then-touch deletes entry, sticky across files, stale entry ignored + cleaned, hasFix derivation, multi-rule partial cache. Setup notes: - Added `@tsslint/types` to cli's dependencies — needed for tests to type-resolve `Config` / `RuleContext` (cli only used them transitively via core before). - Added types/tsconfig project reference to cli's tsconfig. Total cache test surface across the rebuild: 8 + 9 + 13 + 14 + 25 = 69 checks. 144 across all suites. --- .github/workflows/test.yml | 4 + packages/cli/lib/cache-flow.ts | 120 +++++++++++ packages/cli/package.json | 1 + packages/cli/test/cache-flow.test.ts | 285 +++++++++++++++++++++++++++ packages/cli/tsconfig.json | 3 +- pnpm-lock.yaml | 3 + 6 files changed, 415 insertions(+), 1 deletion(-) create mode 100644 packages/cli/lib/cache-flow.ts create mode 100644 packages/cli/test/cache-flow.test.ts diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index a57eeef7..e147198c 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -74,3 +74,7 @@ jobs: # 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 diff --git a/packages/cli/lib/cache-flow.ts b/packages/cli/lib/cache-flow.ts new file mode 100644 index 00000000..934e540c --- /dev/null +++ b/packages/cli/lib/cache-flow.ts @@ -0,0 +1,120 @@ +// Cache-aware wrapper around `linter.lint`. Decides which rules to skip +// based on the file's cached entries + the linter's type-aware +// classification, calls into core with `skipRules`, and merges the +// freshly-computed diagnostics with the rehydrated cached ones. +// +// Core knows nothing about the cache shape or lifecycle. It just runs +// rules and returns diagnostics. Everything in this module is the +// CLI's responsibility — serialization, mtime invalidation, sticky +// type-aware cleanup. + +import type * as ts from 'typescript'; +import type { Linter } from '@tsslint/core'; +import type { FileCache, SerializedDiagnostic, SerializedRelatedInfo } from './cache.js'; + +export function lintWithCache( + linter: Linter, + fileName: string, + fileCache: FileCache, + fileMtime: number, + program: ts.Program, +): ts.DiagnosticWithLocation[] { + // File mtime is the layer-1 invalidation key. Anything that touched + // the file's text drops every cached rule entry — those rules will + // re-run because they vanish from the skip set. + if (fileCache.mtime !== fileMtime) { + fileCache.mtime = fileMtime; + fileCache.rules = {}; + } + + // Cache hit only when: + // - this file has a cache entry for the rule + // - the rule has not been classified type-aware (in any past or + // current session) + // Type-aware rules' entries get cleaned up below regardless of + // whether they're stale from before this fix shipped. + const typeAware = linter.getTypeAwareRules(); + const skipRules = new Set(); + for (const ruleId of Object.keys(fileCache.rules)) { + if (!typeAware.has(ruleId)) { + skipRules.add(ruleId); + } + } + + const fresh = linter.lint(fileName, { skipRules }); + + // Group fresh diagnostics by rule. `report()` in core sets + // `diagnostic.code = ruleId`, so the attribution is intrinsic. + const byRule = new Map(); + for (const diag of fresh) { + const ruleId = String(diag.code); + let bucket = byRule.get(ruleId); + if (!bucket) byRule.set(ruleId, bucket = []); + bucket.push(diag); + } + + // For every rule that actually ran (i.e. not skipped), update the + // cache. Type-aware rules: delete any entry, never write. Syntactic: + // write the freshly-computed entry. + const allRuleIds = Object.keys(linter.getRules(fileName)); + for (const ruleId of allRuleIds) { + if (skipRules.has(ruleId)) continue; + if (typeAware.has(ruleId)) { + delete fileCache.rules[ruleId]; + continue; + } + const diags = byRule.get(ruleId) ?? []; + let hasFix = false; + for (const diag of diags) { + if (linter.hasFixForDiagnostic(fileName, diag)) { + hasFix = true; + break; + } + } + fileCache.rules[ruleId] = { + hasFix, + diagnostics: diags.map(serializeDiagnostic), + }; + } + + // Restore cached diagnostics for the rules we skipped. Rehydrate + // `file` from the current Program — the cached form drops live + // SourceFile refs at write time. + const restored: ts.DiagnosticWithLocation[] = []; + for (const ruleId of skipRules) { + const entry = fileCache.rules[ruleId]; + if (!entry) continue; + for (const sd of entry.diagnostics) { + restored.push(deserializeDiagnostic(sd, fileName, program)); + } + } + + return [...restored, ...fresh]; +} + +function serializeDiagnostic(d: ts.DiagnosticWithLocation): SerializedDiagnostic { + const { file, relatedInformation, ...rest } = d; + void file; + return { + ...rest, + relatedInformation: relatedInformation?.map(info => ({ + ...info, + file: info.file ? { fileName: info.file.fileName } : undefined, + })) as SerializedRelatedInfo[] | undefined, + }; +} + +function deserializeDiagnostic( + sd: SerializedDiagnostic, + fileName: string, + program: ts.Program, +): ts.DiagnosticWithLocation { + return { + ...sd, + file: program.getSourceFile(fileName)!, + relatedInformation: sd.relatedInformation?.map(info => ({ + ...info, + file: info.file ? program.getSourceFile(info.file.fileName) : undefined, + })), + }; +} diff --git a/packages/cli/package.json b/packages/cli/package.json index 457f7b61..fc5dae9a 100644 --- a/packages/cli/package.json +++ b/packages/cli/package.json @@ -23,6 +23,7 @@ "dependencies": { "@tsslint/config": "3.1.0", "@tsslint/core": "3.1.0", + "@tsslint/types": "3.1.0", "@volar/language-core": "~2.4.0", "@volar/language-hub": "0.0.1", "@volar/typescript": "~2.4.0", diff --git a/packages/cli/test/cache-flow.test.ts b/packages/cli/test/cache-flow.test.ts new file mode 100644 index 00000000..2d3be55c --- /dev/null +++ b/packages/cli/test/cache-flow.test.ts @@ -0,0 +1,285 @@ +// Layer 1 cache-flow tests. These cover the cache-aware lint pass — +// every invariant of the CLI's per-file mtime / per-rule cache. +// +// Run via: +// node packages/cli/test/cache-flow.test.js + +import * as ts from 'typescript'; +import type { Config, RuleContext } from '@tsslint/types'; +import type { FileCache } from '../lib/cache.js'; + +const core = require('@tsslint/core') as typeof import('@tsslint/core'); +const cacheFlow = require('../lib/cache-flow.js') as typeof import('../lib/cache-flow.js'); + +const failures: string[] = []; +function check(name: string, cond: boolean, detail?: string) { + if (cond) { + process.stdout.write('.'); + } + else { + failures.push(name + (detail ? ' — ' + detail : '')); + process.stdout.write('F'); + } +} + +function makeContext(files: Record) { + const realLibPath = ts.getDefaultLibFilePath({ target: ts.ScriptTarget.Latest }); + const realLibContent = ts.sys.readFile(realLibPath) ?? ''; + const host: ts.LanguageServiceHost = { + getCompilationSettings: () => ({ + target: ts.ScriptTarget.Latest, + noEmit: true, + lib: [realLibPath.split(/[\\/]/).pop()!], + }), + getScriptFileNames: () => Object.keys(files), + getScriptVersion: () => '1', + getScriptSnapshot: n => { + if (n in files) return ts.ScriptSnapshot.fromString(files[n]); + if (n === realLibPath) return ts.ScriptSnapshot.fromString(realLibContent); + return undefined; + }, + getCurrentDirectory: () => '/', + getDefaultLibFileName: () => realLibPath, + fileExists: n => n in files || n === realLibPath, + readFile: n => (n in files ? files[n] : (n === realLibPath ? realLibContent : undefined)), + }; + return { typescript: ts, languageServiceHost: host, languageService: ts.createLanguageService(host) }; +} + +function emptyFileCache(mtime = 0): FileCache { + return { mtime, rules: {} }; +} + +// ── Test 1: syntactic rule writes cache entry ──────────────────────────── +{ + const ctx = makeContext({ '/a.ts': 'const x = 1;' }); + const config: Config = { + rules: { + syntactic: ((rctx: RuleContext) => { + rctx.report('hi', 0, 1); + }) as any, + }, + }; + const linter = core.createLinter(ctx, '/', config, () => []); + const cache = emptyFileCache(1); + cacheFlow.lintWithCache(linter, '/a.ts', cache, 1, ctx.languageService.getProgram()!); + check('syntactic rule cache entry written', !!cache.rules['syntactic']); + check('cache has 1 diagnostic', cache.rules['syntactic']?.diagnostics.length === 1); + check('hasFix false (no fix reported)', cache.rules['syntactic']?.hasFix === false); +} + +// ── Test 2: type-aware rule does not write cache entry ─────────────────── +{ + const ctx = makeContext({ '/a.ts': 'const x = 1;' }); + const config: Config = { + rules: { + typed: ((rctx: RuleContext) => { + void rctx.program; + rctx.report('typed', 0, 1); + }) as any, + }, + }; + const linter = core.createLinter(ctx, '/', config, () => []); + const cache = emptyFileCache(1); + const result = cacheFlow.lintWithCache(linter, '/a.ts', cache, 1, ctx.languageService.getProgram()!); + check('type-aware rule still produces diagnostics', result.length === 1); + check('type-aware rule NOT cached', !cache.rules['typed']); +} + +// ── Test 3: cache hit skips rule, restores diagnostic ──────────────────── +{ + const ctx = makeContext({ '/a.ts': 'const x = 1;' }); + let runs = 0; + const config: Config = { + rules: { + syntactic: ((rctx: RuleContext) => { + runs++; + rctx.report('hi', 0, 1); + }) as any, + }, + }; + const linter = core.createLinter(ctx, '/', config, () => []); + const cache = emptyFileCache(1); + const program = ctx.languageService.getProgram()!; + + cacheFlow.lintWithCache(linter, '/a.ts', cache, 1, program); + check('first call ran rule', runs === 1); + + const second = cacheFlow.lintWithCache(linter, '/a.ts', cache, 1, program); + check('second call did NOT re-run rule', runs === 1); + check('second call still produced 1 diagnostic', second.length === 1); + check( + 'restored diagnostic has live file ref', + !!second[0]?.file && typeof second[0]?.file.fileName === 'string', + ); +} + +// ── Test 4: mtime mismatch clears all rule entries ─────────────────────── +{ + const ctx = makeContext({ '/a.ts': 'const x = 1;' }); + let runs = 0; + const config: Config = { + rules: { + r: ((rctx: RuleContext) => { + runs++; + rctx.report('x', 0, 1); + }) as any, + }, + }; + const linter = core.createLinter(ctx, '/', config, () => []); + const cache = emptyFileCache(1); + const program = ctx.languageService.getProgram()!; + + cacheFlow.lintWithCache(linter, '/a.ts', cache, 1, program); + check('first call ran', runs === 1); + + // Same mtime → cache hit + cacheFlow.lintWithCache(linter, '/a.ts', cache, 1, program); + check('same mtime → still 1 run', runs === 1); + + // Different mtime → cache invalidated + cacheFlow.lintWithCache(linter, '/a.ts', cache, 2, program); + check('mtime change → re-run', runs === 2); + check('cache mtime updated', cache.mtime === 2); +} + +// ── Test 5: report-then-touch deletes any cache entry ──────────────────── +{ + const ctx = makeContext({ '/a.ts': 'const x = 1;' }); + const config: Config = { + rules: { + 'report-then-touch': ((rctx: RuleContext) => { + rctx.report('first', 0, 1); + void rctx.program; // flips touchedProgram → mark type-aware + }) as any, + }, + }; + const linter = core.createLinter(ctx, '/', config, () => []); + const cache = emptyFileCache(1); + cacheFlow.lintWithCache(linter, '/a.ts', cache, 1, ctx.languageService.getProgram()!); + check('post-rule cleanup deleted entry', !cache.rules['report-then-touch']); +} + +// ── Test 6: sticky type-aware across files ─────────────────────────────── +{ + const ctx = makeContext({ + '/a.ts': 'const x = 1;', + '/b.ts': 'const y = 2;', + }); + const config: Config = { + rules: { + 'sometimes-typed': ((rctx: RuleContext) => { + if (rctx.file.fileName === '/a.ts') { + void rctx.program; + } + rctx.report('hi', 0, 1); + }) as any, + }, + }; + const linter = core.createLinter(ctx, '/', config, () => []); + const cacheA = emptyFileCache(1); + const cacheB = emptyFileCache(1); + const program = ctx.languageService.getProgram()!; + cacheFlow.lintWithCache(linter, '/a.ts', cacheA, 1, program); + cacheFlow.lintWithCache(linter, '/b.ts', cacheB, 1, program); + check('a.ts no cache entry (touched)', !cacheA.rules['sometimes-typed']); + check( + 'b.ts no cache entry (sticky classification)', + !cacheB.rules['sometimes-typed'], + ); +} + +// ── Test 7: stale cache entry for type-aware rule is ignored & cleaned ─── +{ + const ctx = makeContext({ '/a.ts': 'const x = 1;' }); + let runs = 0; + const config: Config = { + rules: { + typed: ((rctx: RuleContext) => { + runs++; + void rctx.program; + rctx.report('typed', 0, 1); + }) as any, + }, + }; + // Linter is seeded with `typed` as type-aware (e.g. from prior session + // via cache file's ruleModes). The fileCache also has a stale entry. + const linter = core.createLinter(ctx, '/', config, () => [], ['typed']); + const cache: FileCache = { + mtime: 1, + rules: { + typed: { + hasFix: false, + diagnostics: [{ + category: ts.DiagnosticCategory.Message, + code: 'typed' as any, + messageText: 'stale', + start: 0, + length: 1, + source: 'tsslint', + }], + }, + }, + }; + const result = cacheFlow.lintWithCache(linter, '/a.ts', cache, 1, ctx.languageService.getProgram()!); + check('rule re-ran (stale cache ignored)', runs === 1); + check('result reflects fresh run', result.length === 1); + check('stale entry deleted', !cache.rules['typed']); +} + +// ── Test 8: hasFix flag derives from rule fix registration ─────────────── +{ + const ctx = makeContext({ '/a.ts': 'const x = 1;' }); + const config: Config = { + rules: { + fixable: ((rctx: RuleContext) => { + rctx.report('fix me', 0, 1).withFix('apply', () => []); + }) as any, + }, + }; + const linter = core.createLinter(ctx, '/', config, () => []); + const cache = emptyFileCache(1); + cacheFlow.lintWithCache(linter, '/a.ts', cache, 1, ctx.languageService.getProgram()!); + check('hasFix true after rule registered fix', cache.rules['fixable']?.hasFix === true); +} + +// ── Test 9: multiple rules — only cached ones skipped, others run ──────── +{ + const ctx = makeContext({ '/a.ts': 'const x = 1;' }); + let aRuns = 0, bRuns = 0; + const config: Config = { + rules: { + a: ((rctx: RuleContext) => { + aRuns++; + rctx.report('a', 0, 1); + }) as any, + b: ((rctx: RuleContext) => { + bRuns++; + rctx.report('b', 0, 1); + }) as any, + }, + }; + const linter = core.createLinter(ctx, '/', config, () => []); + const cache = emptyFileCache(1); + const program = ctx.languageService.getProgram()!; + + // First call: both run + cacheFlow.lintWithCache(linter, '/a.ts', cache, 1, program); + check('first call ran both', aRuns === 1 && bRuns === 1); + check('a cached', !!cache.rules['a']); + check('b cached', !!cache.rules['b']); + + // Second call: both cached + const result = cacheFlow.lintWithCache(linter, '/a.ts', cache, 1, program); + check('second call ran neither', aRuns === 1 && bRuns === 1); + check('result has both restored diagnostics', result.length === 2); +} + +// ── Done ──────────────────────────────────────────────────────────────── +process.stdout.write('\n'); +if (failures.length) { + console.error(`\n${failures.length} failure(s):`); + for (const f of failures) console.error(' - ' + f); + process.exit(1); +} +console.log('OK'); diff --git a/packages/cli/tsconfig.json b/packages/cli/tsconfig.json index 1140f37c..9ec3a11a 100644 --- a/packages/cli/tsconfig.json +++ b/packages/cli/tsconfig.json @@ -3,6 +3,7 @@ "include": ["*", "lib/**/*", "test/**/*"], "references": [ { "path": "../core/tsconfig.json" }, - { "path": "../config/tsconfig.json" } + { "path": "../config/tsconfig.json" }, + { "path": "../types/tsconfig.json" } ] } diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index f10fd69c..9ce2f004 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -95,6 +95,9 @@ importers: '@tsslint/core': specifier: 3.1.0 version: link:../core + '@tsslint/types': + specifier: 3.1.0 + version: link:../types '@volar/language-core': specifier: ~2.4.0 version: 2.4.28 From b7a86c23dff686c8b375ce5758569890023ee8cf Mon Sep 17 00:00:00 2001 From: Johnson Chu Date: Thu, 30 Apr 2026 11:32:30 +0800 Subject: [PATCH 09/32] feat(cli): wire layer 1 cache into the lint loop MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit End of CACHE.md step 4: cli/index.ts and worker.ts now go through the cache-flow module. Per-project cache file is loaded at startup, each file's lint pass uses cached entries when available, and the file is written back at end of project. Project class: - `cacheData: cache.CacheData` (defaults to empty cache) - `init()` calls `cache.loadCache(...)` unless `--force` is passed. Cache key includes ts.version now (the segment was added when the cache module landed; this is the first caller honouring it). Lint loop: - Per file: stat for mtime, get-or-create `project.cacheData.files[fileName]`, pass both to `worker.lint`. - After all files in a project: snapshot `worker.getTypeAwareRules()` into `project.cacheData.ruleModes`, then `cache.saveCache(...)`. - Diagnostic loop now also OR's in `fileCache.rules[diagnostic.code]?.hasFix` because cache-hit diagnostics don't re-register fixes through `linter.hasCodeFixes`. Worker (lib/worker.ts): - `setup` takes `initialTypeAwareRules: readonly string[]` and forwards to `core.createLinter`. - `lint(fileName, fix, fileCache, fileMtime)` — both cache args now required. Replaces direct `linter.lint` calls with `cacheFlow.lintWithCache`. After a fix writes the file, mtime is refreshed via `fs.statSync` so the post-fix re-lint sees the new mtime and invalidates layer-1 entries. - New `getTypeAwareRules()` wrapper for the CLI to snapshot. - `--fix` interaction: before calling lintWithCache in the fix branch, drop cached entries for rules with `hasFix === true`. Their `getEdits` callbacks don't survive the JSON cache round- trip, so we have to actually run those rules to rebuild them. Rules with no fixes can stay cached even under `--fix`. `--force` flag is back, behind a HELP-only documentation entry. With `--force`, `loadCache` is skipped — the project starts with an empty `cacheData` and all rules run fresh. The cache is still written at end of run. Smoke-tested on fixtures/define-rule/: - Cold run: lints, writes cache file with the no-console diagnostic + hasFix=true. - Warm run: cache hit on no-console; same diagnostic shown; `--fix` hint correctly surfaces (via the cached hasFix flag, since the rule didn't run this session). - `--fix` cold + warm: both apply the fix correctly. The pre-call drop of hasFix entries forces those rules to re-run and rebuild their getEdits callbacks. All 144 existing tests still pass (probe / skip-rules / builder- program-poc / cache module / cache-flow / compat-pipeline). --- packages/cli/index.ts | 53 +++++++++++++++++++++++++++++++++++++- packages/cli/lib/worker.ts | 29 ++++++++++++++++++--- 2 files changed, 78 insertions(+), 4 deletions(-) diff --git a/packages/cli/index.ts b/packages/cli/index.ts index 6477091c..004a49ba 100644 --- a/packages/cli/index.ts +++ b/packages/cli/index.ts @@ -5,6 +5,7 @@ require('./lib/fs-cache.js'); import ts = require('typescript'); import path = require('path'); 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'); @@ -25,6 +26,7 @@ Options: --ts-macro-project Lint TS Macro projects --filter Filter files to lint --fix Apply automatic fixes + --force Ignore cache (re-lint every file) --failures-only Only print errors and messages (skip warnings and suggestions) -h, --help Show this help message @@ -80,6 +82,7 @@ class Project { options: ts.CompilerOptions = {}; configFile: string | undefined; currentFileIndex = 0; + cacheData: cache.CacheData = cache.emptyCache(); pendingHeader: string | undefined; constructor( @@ -146,6 +149,20 @@ 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.cacheData = cache.loadCache( + this.tsconfig, + this.configFile, + this.languages, + ts.version, + ts.sys.createHash, + ); + } + return this; } } @@ -331,6 +348,7 @@ const formatHost: ts.FormatDiagnosticsHost = { project.configFile!, project.rawFileNames, project.options, + Object.keys(project.cacheData.ruleModes), ); if (setupResult !== true) { renderer.diagnostic(formatConfigError(project.configFile!, setupResult)); @@ -343,19 +361,34 @@ const formatHost: ts.FormatDiagnosticsHost = { while (project.currentFileIndex < project.fileNames.length) { const fileName = project.fileNames[project.currentFileIndex++]; - if (!fs.statSync(fileName, { throwIfNoEntry: false })) { + const fileStat = fs.statSync(fileName, { throwIfNoEntry: false }); + if (!fileStat) { continue; } + let fileCache = project.cacheData.files[fileName]; + if (!fileCache) { + fileCache = { mtime: fileStat.mtimeMs, rules: {} }; + project.cacheData.files[fileName] = fileCache; + } + 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) { + // 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; if (diagnostic.category === ts.DiagnosticCategory.Suggestion) { @@ -404,6 +437,24 @@ const formatHost: ts.FormatDiagnosticsHost = { processed++; } + // 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'; + } + cache.saveCache( + project.tsconfig, + project.configFile!, + project.languages, + ts.version, + project.cacheData, + ts.sys.createHash, + ); + await startWorker(linterWorker); } diff --git a/packages/cli/lib/worker.ts b/packages/cli/lib/worker.ts index ee27fbaa..d6a12160 100644 --- a/packages/cli/lib/worker.ts +++ b/packages/cli/lib/worker.ts @@ -3,7 +3,10 @@ import type config = require('@tsslint/config'); import core = require('@tsslint/core'); import url = require('url'); import path = require('path'); +import fs = require('fs'); import languagePlugins = require('./languagePlugins.js'); +import cacheFlow = require('./cache-flow.js'); +import type { FileCache } from './cache.js'; import { createLanguage, FileMap, isCodeActionsEnabled, type Language } from '@volar/language-core'; import { createProxyLanguageService, decorateLanguageServiceHost, resolveFileLanguageId } from '@volar/typescript'; @@ -90,6 +93,9 @@ export function create() { hasRules(...args: Parameters) { return hasRules(...args); }, + getTypeAwareRules() { + return [...linter.getTypeAwareRules()]; + }, }; } @@ -99,6 +105,7 @@ async function setup( configFile: string, _fileNames: string[], _options: ts.CompilerOptions, + initialTypeAwareRules: readonly string[], ): Promise { let config: config.Config | config.Config[]; try { @@ -165,18 +172,29 @@ async function setup( path.dirname(configFile), config, () => [], + initialTypeAwareRules, ); return true; } -function lint(fileName: string, fix: boolean) { +function lint(fileName: string, fix: boolean, fileCache: FileCache, fileMtime: number) { let newSnapshot: ts.IScriptSnapshot | undefined; let diagnostics!: ts.DiagnosticWithLocation[]; let shouldCheck = true; if (fix) { - diagnostics = linter.lint(fileName); + // Drop cache entries for rules that registered a fix in any prior + // session — we need to actually run those rules now to rebuild the + // `getEdits` callbacks (closures don't survive the JSON cache). + // Rules with no fixes can stay cached. + for (const ruleId of Object.keys(fileCache.rules)) { + if (fileCache.rules[ruleId].hasFix) { + delete fileCache.rules[ruleId]; + } + } + const program = linterLanguageService.getProgram()!; + diagnostics = cacheFlow.lintWithCache(linter, fileName, fileCache, fileMtime, program); shouldCheck = false; let fixes = linter @@ -205,12 +223,17 @@ function lint(fileName: string, fix: boolean) { const oldText = ts.sys.readFile(fileName); if (newText !== oldText) { ts.sys.writeFile(fileName, newSnapshot.getText(0, newSnapshot.getLength())); + // File content moved — refresh mtime so the next lint pass + // invalidates layer-1 cache entries for this file. lintWithCache + // compares fileCache.mtime against the fileMtime we pass in. + fileMtime = fs.statSync(fileName).mtimeMs; shouldCheck = true; } } if (shouldCheck) { - diagnostics = linter.lint(fileName); + const program = linterLanguageService.getProgram()!; + diagnostics = cacheFlow.lintWithCache(linter, fileName, fileCache, fileMtime, program); } // Language-transform path (Vue/MDX/etc.): diagnostics map back from From 576641ffafbb62e19714c06e04a6803b52cc25b1 Mon Sep 17 00:00:00 2001 From: Johnson Chu Date: Sat, 2 May 2026 17:10:33 +0800 Subject: [PATCH 10/32] feat(cli): layer 2 cache-flow option for type-aware rules MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds the affected-file gate that BuilderProgram (or any equivalent tracker) feeds layer 2 with. New option on lintWithCache: options.typeAwareUnaffected: boolean true → type-aware rules behave like syntactic ones for caching (cache hit allowed, cache write on run) false / undefined → mode A: type-aware rules always re-run, their entries deleted (current default behavior — safe when mtime alone can't catch ambient-declaration edits) Behavior change is opt-in. Existing call sites pass no option and keep the layer-1-only semantics. The CLI side that drives this (creating BuilderProgram, walking getSemanticDiagnosticsOfNextAffected- File, computing per-file affected sets, plumbing through worker.lint) is the next commit. Skip-set computation now considers the option: - syntactic + cached → skip - type-aware + cached + typeAwareUnaffected=true → skip - type-aware + cached + typeAwareUnaffected!=true → run (current) Cache-write logic mirrors: - rule ran AND (syntactic OR typeAwareUnaffected=true) → write entry - rule ran AND type-aware AND not typeAwareUnaffected → delete entry Tests in cache-flow.test.ts cover four new scenarios (10–13): - typeAwareUnaffected=true → first run caches the entry - typeAwareUnaffected=true → second run cache hit, rule skipped - mode B → mode A: entry replaced by re-run, cleanup applied - default / explicit false → mode A semantics preserved 36 cache-flow checks total. Drive-by: drop unused `SerializedRelatedInfo` import in cache-flow.ts (the user's earlier serialize-helper rewrite no longer references it). --- packages/cli/lib/cache-flow.ts | 48 +++++++--- packages/cli/test/cache-flow.test.ts | 136 +++++++++++++++++++++++++-- 2 files changed, 162 insertions(+), 22 deletions(-) diff --git a/packages/cli/lib/cache-flow.ts b/packages/cli/lib/cache-flow.ts index 934e540c..469f0271 100644 --- a/packages/cli/lib/cache-flow.ts +++ b/packages/cli/lib/cache-flow.ts @@ -10,7 +10,7 @@ import type * as ts from 'typescript'; import type { Linter } from '@tsslint/core'; -import type { FileCache, SerializedDiagnostic, SerializedRelatedInfo } from './cache.js'; +import type { FileCache, SerializedDiagnostic } from './cache.js'; export function lintWithCache( linter: Linter, @@ -18,6 +18,21 @@ export function lintWithCache( fileCache: FileCache, fileMtime: number, program: ts.Program, + options?: { + // Layer 2 signal from the caller: BuilderProgram (or equivalent + // affected-file tracker) says this file's type-relevant inputs — + // own text, transitive imports, ambient declarations, lib — + // haven't moved since the cached entries were written. + // + // When true: type-aware rules behave like syntactic ones for + // caching purposes — cached entries can be reused, and fresh runs + // write back. When false / undefined: type-aware rules are always + // re-run and their cache entries deleted. The default is the + // safe one because mtime alone can't catch ambient-declaration + // edits that change a file's effective types without touching + // its text. + typeAwareUnaffected?: boolean; + }, ): ts.DiagnosticWithLocation[] { // File mtime is the layer-1 invalidation key. Anything that touched // the file's text drops every cached rule entry — those rules will @@ -27,16 +42,24 @@ export function lintWithCache( fileCache.rules = {}; } - // Cache hit only when: - // - this file has a cache entry for the rule - // - the rule has not been classified type-aware (in any past or - // current session) - // Type-aware rules' entries get cleaned up below regardless of - // whether they're stale from before this fix shipped. + const typeAwareCanCache = options?.typeAwareUnaffected === true; const typeAware = linter.getTypeAwareRules(); + + // Cache hit only when this file has a cache entry for the rule AND + // either: + // - the rule is not classified type-aware, or + // - the rule is type-aware but the caller signalled the file is + // unaffected this session (layer 2) const skipRules = new Set(); for (const ruleId of Object.keys(fileCache.rules)) { - if (!typeAware.has(ruleId)) { + if (typeAware.has(ruleId)) { + if (typeAwareCanCache) { + skipRules.add(ruleId); + } + // else: re-run; the post-rule write path will overwrite the + // stale entry or delete it depending on classification. + } + else { skipRules.add(ruleId); } } @@ -54,12 +77,13 @@ export function lintWithCache( } // For every rule that actually ran (i.e. not skipped), update the - // cache. Type-aware rules: delete any entry, never write. Syntactic: - // write the freshly-computed entry. + // cache. Type-aware rules without the unaffected signal: delete any + // entry, never write. Otherwise (syntactic, or type-aware with + // signal): write the freshly-computed entry. const allRuleIds = Object.keys(linter.getRules(fileName)); for (const ruleId of allRuleIds) { if (skipRules.has(ruleId)) continue; - if (typeAware.has(ruleId)) { + if (typeAware.has(ruleId) && !typeAwareCanCache) { delete fileCache.rules[ruleId]; continue; } @@ -100,7 +124,7 @@ function serializeDiagnostic(d: ts.DiagnosticWithLocation): SerializedDiagnostic relatedInformation: relatedInformation?.map(info => ({ ...info, file: info.file ? { fileName: info.file.fileName } : undefined, - })) as SerializedRelatedInfo[] | undefined, + })), }; } diff --git a/packages/cli/test/cache-flow.test.ts b/packages/cli/test/cache-flow.test.ts index 2d3be55c..e1aac666 100644 --- a/packages/cli/test/cache-flow.test.ts +++ b/packages/cli/test/cache-flow.test.ts @@ -57,7 +57,7 @@ function emptyFileCache(mtime = 0): FileCache { rules: { syntactic: ((rctx: RuleContext) => { rctx.report('hi', 0, 1); - }) as any, + }), }, }; const linter = core.createLinter(ctx, '/', config, () => []); @@ -76,7 +76,7 @@ function emptyFileCache(mtime = 0): FileCache { typed: ((rctx: RuleContext) => { void rctx.program; rctx.report('typed', 0, 1); - }) as any, + }), }, }; const linter = core.createLinter(ctx, '/', config, () => []); @@ -95,7 +95,7 @@ function emptyFileCache(mtime = 0): FileCache { syntactic: ((rctx: RuleContext) => { runs++; rctx.report('hi', 0, 1); - }) as any, + }), }, }; const linter = core.createLinter(ctx, '/', config, () => []); @@ -123,7 +123,7 @@ function emptyFileCache(mtime = 0): FileCache { r: ((rctx: RuleContext) => { runs++; rctx.report('x', 0, 1); - }) as any, + }), }, }; const linter = core.createLinter(ctx, '/', config, () => []); @@ -151,7 +151,7 @@ function emptyFileCache(mtime = 0): FileCache { 'report-then-touch': ((rctx: RuleContext) => { rctx.report('first', 0, 1); void rctx.program; // flips touchedProgram → mark type-aware - }) as any, + }), }, }; const linter = core.createLinter(ctx, '/', config, () => []); @@ -173,7 +173,7 @@ function emptyFileCache(mtime = 0): FileCache { void rctx.program; } rctx.report('hi', 0, 1); - }) as any, + }), }, }; const linter = core.createLinter(ctx, '/', config, () => []); @@ -199,7 +199,7 @@ function emptyFileCache(mtime = 0): FileCache { runs++; void rctx.program; rctx.report('typed', 0, 1); - }) as any, + }), }, }; // Linter is seeded with `typed` as type-aware (e.g. from prior session @@ -234,7 +234,7 @@ function emptyFileCache(mtime = 0): FileCache { rules: { fixable: ((rctx: RuleContext) => { rctx.report('fix me', 0, 1).withFix('apply', () => []); - }) as any, + }), }, }; const linter = core.createLinter(ctx, '/', config, () => []); @@ -252,11 +252,11 @@ function emptyFileCache(mtime = 0): FileCache { a: ((rctx: RuleContext) => { aRuns++; rctx.report('a', 0, 1); - }) as any, + }), b: ((rctx: RuleContext) => { bRuns++; rctx.report('b', 0, 1); - }) as any, + }), }, }; const linter = core.createLinter(ctx, '/', config, () => []); @@ -275,6 +275,122 @@ function emptyFileCache(mtime = 0): FileCache { check('result has both restored diagnostics', result.length === 2); } +// ── Test 10 (layer 2): typeAwareUnaffected=true caches type-aware rule ─── +// +// When the caller signals that the file's type-relevant inputs haven't +// moved (BuilderProgram check in production), type-aware rules become +// eligible for cache hits and writes — same path as syntactic rules. +{ + const ctx = makeContext({ '/a.ts': 'const x = 1;' }); + let runs = 0; + const config: Config = { + rules: { + typed: ((rctx: RuleContext) => { + runs++; + void rctx.program; + rctx.report('typed', 0, 1); + }), + }, + }; + const linter = core.createLinter(ctx, '/', config, () => []); + const cache = emptyFileCache(1); + const program = ctx.languageService.getProgram()!; + + cacheFlow.lintWithCache(linter, '/a.ts', cache, 1, program, { typeAwareUnaffected: true }); + check('first call ran (no cache yet)', runs === 1); + check( + 'type-aware entry written under unaffected signal', + !!cache.rules['typed'], + 'expected entry — typeAwareUnaffected=true makes type-aware caching legal', + ); + check( + 'cached diagnostic count', + cache.rules['typed']?.diagnostics.length === 1, + ); +} + +// ── Test 11 (layer 2): cache hit on second call with same signal ───────── +{ + const ctx = makeContext({ '/a.ts': 'const x = 1;' }); + let runs = 0; + const config: Config = { + rules: { + typed: ((rctx: RuleContext) => { + runs++; + void rctx.program; + rctx.report('typed', 0, 1); + }), + }, + }; + const linter = core.createLinter(ctx, '/', config, () => []); + const cache = emptyFileCache(1); + const program = ctx.languageService.getProgram()!; + + cacheFlow.lintWithCache(linter, '/a.ts', cache, 1, program, { typeAwareUnaffected: true }); + const second = cacheFlow.lintWithCache(linter, '/a.ts', cache, 1, program, { typeAwareUnaffected: true }); + check('second call did NOT re-run type-aware rule', runs === 1); + check('second call still produced 1 diagnostic', second.length === 1); + check( + 'restored diagnostic has live file ref (layer 2)', + !!second[0]?.file && typeof second[0]?.file.fileName === 'string', + ); +} + +// ── Test 12 (layer 2): mode B → mode A re-runs and clears entry ────────── +// +// Cache entry was written under typeAwareUnaffected=true. Next call +// drops the signal (BuilderProgram now considers the file affected). +// The type-aware rule must re-run AND its old entry deleted. +{ + const ctx = makeContext({ '/a.ts': 'const x = 1;' }); + let runs = 0; + const config: Config = { + rules: { + typed: ((rctx: RuleContext) => { + runs++; + void rctx.program; + rctx.report('typed', 0, 1); + }), + }, + }; + const linter = core.createLinter(ctx, '/', config, () => []); + const cache = emptyFileCache(1); + const program = ctx.languageService.getProgram()!; + + // Mode B: write the entry. + cacheFlow.lintWithCache(linter, '/a.ts', cache, 1, program, { typeAwareUnaffected: true }); + check('layer-2 first run cached the entry', !!cache.rules['typed']); + + // Mode A (default): file is affected. Re-run, drop entry. + cacheFlow.lintWithCache(linter, '/a.ts', cache, 1, program); + check('mode-A re-run executed rule', runs === 2); + check('mode-A re-run dropped the entry', !cache.rules['typed']); +} + +// ── Test 13 (layer 2): default (no signal) preserves mode-A semantics ──── +{ + const ctx = makeContext({ '/a.ts': 'const x = 1;' }); + const config: Config = { + rules: { + typed: ((rctx: RuleContext) => { + void rctx.program; + rctx.report('typed', 0, 1); + }), + }, + }; + const linter = core.createLinter(ctx, '/', config, () => []); + const cache = emptyFileCache(1); + const program = ctx.languageService.getProgram()!; + + // No options arg passed at all — should behave like mode A. + cacheFlow.lintWithCache(linter, '/a.ts', cache, 1, program); + check('default (no options) does NOT cache type-aware', !cache.rules['typed']); + + // Explicit false also defaults to mode A. + cacheFlow.lintWithCache(linter, '/a.ts', cache, 1, program, { typeAwareUnaffected: false }); + check('explicit false does NOT cache type-aware', !cache.rules['typed']); +} + // ── Done ──────────────────────────────────────────────────────────────── process.stdout.write('\n'); if (failures.length) { From 10f0f5cbe8d3ff6123229c319aa023433828d7d2 Mon Sep 17 00:00:00 2001 From: Johnson Chu Date: Sat, 2 May 2026 17:15:08 +0800 Subject: [PATCH 11/32] feat(cli): --incremental flag plumbs layer 2 typeAwareUnaffected MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit CLI surface for layer 2. Adds the `--incremental` flag (in HELP between --force and --failures-only) and threads it through to worker.setup as a new boolean param. Worker creates a Semantic- DiagnosticsBuilderProgram once at setup and walks `getSemantic- DiagnosticsOfNextAffectedFile` to collect the affected file set. worker.lint computes `typeAwareUnaffected` per file: !!affectedFiles && !fix && !affectedFiles.has(fileName) - falsey when --incremental wasn't passed (no BP, layer 1 only) - false in --fix mode (fix mutates files mid-session, the setup- time affected snapshot becomes stale for downstream files — conservatively treat all as affected) - true when BP says the file is unaffected this session The flag is forwarded to cache-flow's lintWithCache option, already implemented in 576641f. Limitation, documented inline: without cross-session BP state (.tsbuildinfo persistence), the first session's BP has no oldProgram and considers every file affected on cold start. So within a one-shot CLI invocation, `--incremental` is effectively a no-op today. The wiring lands here so the cross-session work can plug in without touching cache-flow or core. Smoke tests on fixtures/define-rule (cold without flag, cold with --incremental, warm with --incremental) all produce the expected diagnostic. Existing unit tests untouched (155 checks across six suites). --- packages/cli/index.ts | 2 ++ packages/cli/lib/worker.ts | 55 ++++++++++++++++++++++++++++++++++++-- 2 files changed, 55 insertions(+), 2 deletions(-) diff --git a/packages/cli/index.ts b/packages/cli/index.ts index 004a49ba..75ec43fb 100644 --- a/packages/cli/index.ts +++ b/packages/cli/index.ts @@ -27,6 +27,7 @@ Options: --filter Filter files to lint --fix Apply automatic fixes --force Ignore cache (re-lint every file) + --incremental Cache type-aware rule results across sessions (layer 2) --failures-only Only print errors and messages (skip warnings and suggestions) -h, --help Show this help message @@ -349,6 +350,7 @@ const formatHost: ts.FormatDiagnosticsHost = { project.rawFileNames, project.options, Object.keys(project.cacheData.ruleModes), + process.argv.includes('--incremental'), ); if (setupResult !== true) { renderer.diagnostic(formatConfigError(project.configFile!, setupResult)); diff --git a/packages/cli/lib/worker.ts b/packages/cli/lib/worker.ts index d6a12160..73953cd6 100644 --- a/packages/cli/lib/worker.ts +++ b/packages/cli/lib/worker.ts @@ -19,6 +19,16 @@ let fileNames: string[] = []; let language: Language | undefined; let linter: core.Linter; let linterLanguageService!: ts.LanguageService; +// Layer 2 state. When `--incremental` is on, we wrap the LS's program in +// a SemanticDiagnosticsBuilderProgram and walk affected files once at +// setup. cache-flow consults this set to decide whether type-aware rules +// can be cache-hit. `undefined` here = layer 1 only (cache-flow's default +// safe behavior — type-aware rules always re-run). +// +// Without cross-session BP state (tsbuildinfo), the first session sees +// every file as affected on cold start; the wiring lands here so the +// state-persistence work can plug in without touching cache-flow. +let affectedFiles: Set | undefined; const snapshots = new Map(); const versions = new Map(); @@ -106,6 +116,7 @@ async function setup( _fileNames: string[], _options: ts.CompilerOptions, initialTypeAwareRules: readonly string[], + incremental: boolean, ): Promise { let config: config.Config | config.Config[]; try { @@ -175,14 +186,50 @@ async function setup( initialTypeAwareRules, ); + affectedFiles = incremental ? computeAffectedFiles() : undefined; + return true; } +// Wrap LS's program in a SemanticDiagnosticsBuilderProgram and drain the +// affected-file iterator. Without an `oldProgram` (cross-session state +// not yet persisted), every file counts as affected on cold runs — so +// the returned set is large but correctness is preserved. cache-flow +// consults `!affectedFiles.has(fileName)` for `typeAwareUnaffected`. +function computeAffectedFiles(): Set { + const program = linterLanguageService.getProgram()!; + const builder = ts.createSemanticDiagnosticsBuilderProgram( + program, + { createHash: ts.sys.createHash }, + ); + const set = new Set(); + while (true) { + const result = builder.getSemanticDiagnosticsOfNextAffectedFile(); + if (!result) break; + const a = result.affected; + if ('fileName' in a) { + set.add(a.fileName); + } + else { + // Whole-program affected — config option flip, lib change, etc. + // Conservatively mark every source file affected. + for (const sf of a.getSourceFiles()) set.add(sf.fileName); + } + } + return set; +} + function lint(fileName: string, fix: boolean, fileCache: FileCache, fileMtime: number) { let newSnapshot: ts.IScriptSnapshot | undefined; let diagnostics!: ts.DiagnosticWithLocation[]; let shouldCheck = true; + // Layer 2 signal: file is unaffected if --incremental is on AND the + // BuilderProgram pass at setup didn't list this file. In `--fix` mode + // we conservatively force `false` — fixes mutate files mid-session, + // invalidating the setup-time affected snapshot for downstream files. + const typeAwareUnaffected = !!affectedFiles && !fix && !affectedFiles.has(fileName); + if (fix) { // Drop cache entries for rules that registered a fix in any prior // session — we need to actually run those rules now to rebuild the @@ -194,7 +241,9 @@ function lint(fileName: string, fix: boolean, fileCache: FileCache, fileMtime: n } } const program = linterLanguageService.getProgram()!; - diagnostics = cacheFlow.lintWithCache(linter, fileName, fileCache, fileMtime, program); + diagnostics = cacheFlow.lintWithCache(linter, fileName, fileCache, fileMtime, program, { + typeAwareUnaffected, + }); shouldCheck = false; let fixes = linter @@ -233,7 +282,9 @@ function lint(fileName: string, fix: boolean, fileCache: FileCache, fileMtime: n if (shouldCheck) { const program = linterLanguageService.getProgram()!; - diagnostics = cacheFlow.lintWithCache(linter, fileName, fileCache, fileMtime, program); + diagnostics = cacheFlow.lintWithCache(linter, fileName, fileCache, fileMtime, program, { + typeAwareUnaffected, + }); } // Language-transform path (Vue/MDX/etc.): diagnostics map back from From f5b1f9bdb4c9baa91a607703364ec9ee5dacbea2 Mon Sep 17 00:00:00 2001 From: Johnson Chu Date: Sat, 2 May 2026 17:20:44 +0800 Subject: [PATCH 12/32] test(cli): end-to-end integration covers layer 1 wiring MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Spawns the local tsslint binary against a throwaway fixture in os.tmpdir() and inspects diagnostics + cache state on disk. The in-process cache-flow tests cover the module's invariants — these exercise everything around it: argv parsing, mtime detection from the real filesystem, --force / --incremental gates, the cache file load/save round-trip, and Project's wiring of fileCache through worker.lint. Six scenarios: 1. Cold run produces the no-console diagnostic and writes a cache file containing a rule entry for the fixture. 2. Warm run produces the same diagnostic (cache hit doesn't lose data). 3. Editing the linted file moves the cached `mtime` past the edit on the next run (mtime invalidation works through the CLI). 4. Editing tsslint.config.ts mints a fresh cache file under a different path key (config mtime+size in the hash). 5. --force still produces the diagnostic and exits non-zero. 6. --incremental is accepted and writes a cache (the layer-2 plumbing in 10f0f5c doesn't break anything). Setup gotcha worth noting: macOS' /var symlinks to /private/var, and the CLI canonicalises paths through realpath when keying the cache. The fixture helper realpaths the temp dir up front so the test's cache-file lookups line up with the keys the CLI writes. Total cache test surface: 8 + 9 + 13 + 14 + 36 + 14 = 94 across six suites, plus 75 in compat-pipeline. --- .github/workflows/test.yml | 4 + packages/cli/test/integration.test.ts | 224 ++++++++++++++++++++++++++ 2 files changed, 228 insertions(+) create mode 100644 packages/cli/test/integration.test.ts diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index e147198c..3c72a8a5 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -78,3 +78,7 @@ jobs: # 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 diff --git a/packages/cli/test/integration.test.ts b/packages/cli/test/integration.test.ts new file mode 100644 index 00000000..3fdeadb7 --- /dev/null +++ b/packages/cli/test/integration.test.ts @@ -0,0 +1,224 @@ +// CLI integration tests. Spawns the local tsslint binary against a +// throwaway fixture and inspects diagnostics + cache state on disk. +// These complement the in-process cache-flow unit tests by exercising +// the actual lint loop, mtime check, --force gate, and cache file +// load/save round-trip. +// +// Run via: +// node packages/cli/test/integration.test.js + +import path = require('path'); +import fs = require('fs'); +import os = require('os'); +import { spawnSync } from 'child_process'; + +const failures: string[] = []; +function check(name: string, cond: boolean, detail?: string) { + if (cond) { + process.stdout.write('.'); + } + else { + failures.push(name + (detail ? ' — ' + detail : '')); + process.stdout.write('F'); + } +} + +const repoRoot = path.resolve(__dirname, '../../..'); +const tsslintBin = path.join(repoRoot, 'packages/cli/bin/tsslint.js'); +const noConsoleRule = path.join(repoRoot, 'fixtures/noConsoleRule.ts'); + +function makeFixture(): string { + // Resolve symlinks (macOS' /var → /private/var) so paths line up with + // the realpath-canonicalised keys the CLI stores in the cache file. + const dir = fs.realpathSync(fs.mkdtempSync(path.join(os.tmpdir(), 'tsslint-int-'))); + fs.writeFileSync( + path.join(dir, 'tsconfig.json'), + JSON.stringify({ + compilerOptions: { + target: 'es2020', + module: 'esnext', + moduleResolution: 'bundler', + strict: true, + skipLibCheck: true, + }, + include: ['*.ts'], + }), + ); + fs.writeFileSync( + path.join(dir, 'tsslint.config.ts'), + `import { defineConfig } from '${repoRoot}/packages/config/index.js';\n` + + `export default defineConfig({\n` + + ` rules: {\n` + + ` 'no-console': (await import('${noConsoleRule}')),\n` + + ` },\n` + + `});\n`, + ); + fs.writeFileSync(path.join(dir, 'fixture.ts'), `console.log('hi');\n`); + return dir; +} + +function runCli(dir: string, ...extraArgs: string[]): { stdout: string; status: number } { + const result = spawnSync( + process.execPath, + [tsslintBin, '--project', path.join(dir, 'tsconfig.json'), ...extraArgs], + { stdio: ['ignore', 'pipe', 'pipe'], encoding: 'utf8' }, + ); + return { stdout: result.stdout || '', status: result.status ?? -1 }; +} + +function findCacheFiles(): string[] { + const root = path.join(os.tmpdir(), 'tsslint-cache'); + const out: string[] = []; + const stack = [root]; + while (stack.length) { + const cur = stack.pop()!; + let entries: fs.Dirent[]; + try { entries = fs.readdirSync(cur, { withFileTypes: true }); } + catch { continue; } + for (const e of entries) { + const full = path.join(cur, e.name); + if (e.isDirectory()) stack.push(full); + else if (e.isFile() && full.endsWith('.cache.json')) out.push(full); + } + } + return out; +} + +function readCacheForFixture(fixtureDir: string): unknown { + // Find the cache file that mentions our fixture.ts in its files map. + const target = path.join(fixtureDir, 'fixture.ts'); + for (const f of findCacheFiles()) { + try { + const data = JSON.parse(fs.readFileSync(f, 'utf8')); + if (data?.files?.[target]) return data; + } + catch { /* skip */ } + } + return null; +} + +// ── Test 1: cold run produces diagnostic + writes cache ───────────────── +{ + const dir = makeFixture(); + try { + const r = runCli(dir); + check('cold run included no-console diagnostic', r.stdout.includes('no-console')); + const data = readCacheForFixture(dir) as { files: any }; + check('cache file written for the fixture', !!data); + check( + 'cache has rule entry for fixture', + !!data?.files?.[path.join(dir, 'fixture.ts')]?.rules?.['no-console/default'], + ); + } + finally { + fs.rmSync(dir, { recursive: true, force: true }); + } +} + +// ── Test 2: warm run produces the same diagnostic ─────────────────────── +{ + const dir = makeFixture(); + try { + runCli(dir); + const r2 = runCli(dir); + check('warm run included no-console diagnostic', r2.stdout.includes('no-console')); + } + finally { + fs.rmSync(dir, { recursive: true, force: true }); + } +} + +// ── Test 3: editing the linted file invalidates its entry on next run ─── +{ + const dir = makeFixture(); + const fixturePath = path.join(dir, 'fixture.ts'); + try { + runCli(dir); + const before = readCacheForFixture(dir) as any; + const beforeMtime = before?.files?.[fixturePath]?.mtime; + check('cold run wrote a mtime', typeof beforeMtime === 'number'); + + // Edit the file. Bump mtime explicitly so coarse filesystem + // timestamps don't cause a flaky "same mtime" hit. + fs.writeFileSync(fixturePath, `console.warn('changed');\n`); + const t = new Date(Date.now() + 60_000); + fs.utimesSync(fixturePath, t, t); + + const r2 = runCli(dir); + check('still produced diagnostic after edit', r2.stdout.includes('no-console')); + const after = readCacheForFixture(dir) as any; + const afterMtime = after?.files?.[fixturePath]?.mtime; + check( + 'cache mtime moved past the edit', + typeof afterMtime === 'number' && afterMtime > beforeMtime, + `before=${beforeMtime}, after=${afterMtime}`, + ); + } + finally { + fs.rmSync(dir, { recursive: true, force: true }); + } +} + +// ── Test 4: editing tsslint.config.ts mints a fresh cache file ────────── +{ + const dir = makeFixture(); + const configPath = path.join(dir, 'tsslint.config.ts'); + try { + runCli(dir); + const before = readCacheForFixture(dir); + check('cache exists after cold run', !!before); + + // Touch the config so its mtime+size change. Adding a comment is + // enough — the cache key includes config mtime+size. + const original = fs.readFileSync(configPath, 'utf8'); + fs.writeFileSync(configPath, `// touched\n` + original); + + const r2 = runCli(dir); + check('still produced diagnostic with new config mtime', r2.stdout.includes('no-console')); + // The current cache file (under new key) should have entries; the + // old one is orphaned. Both might exist; verify the new run + // produced a valid cache by re-reading. + const after = readCacheForFixture(dir); + check('new cache file written under new key', !!after); + } + finally { + fs.rmSync(dir, { recursive: true, force: true }); + } +} + +// ── Test 5: --force bypasses cache (still produces diagnostic) ────────── +{ + const dir = makeFixture(); + try { + runCli(dir); + const r2 = runCli(dir, '--force'); + check('--force run produced diagnostic', r2.stdout.includes('no-console')); + check('--force run exits non-zero (errors/messages)', r2.status !== 0); + } + finally { + fs.rmSync(dir, { recursive: true, force: true }); + } +} + +// ── Test 6: --incremental accepted, doesn't break the lint pass ───────── +{ + const dir = makeFixture(); + try { + const r = runCli(dir, '--incremental'); + check('--incremental run produced diagnostic', r.stdout.includes('no-console')); + const data = readCacheForFixture(dir); + check('cache written under --incremental', !!data); + } + finally { + fs.rmSync(dir, { recursive: true, force: true }); + } +} + +// ── Done ──────────────────────────────────────────────────────────────── +process.stdout.write('\n'); +if (failures.length) { + console.error(`\n${failures.length} failure(s):`); + for (const f of failures) console.error(' - ' + f); + process.exit(1); +} +console.log('OK'); From 3c02d304666a58d86cac86d0f7b7c82ac07cbb8e Mon Sep 17 00:00:00 2001 From: Johnson Chu Date: Sat, 2 May 2026 17:31:33 +0800 Subject: [PATCH 13/32] =?UTF-8?q?feat(cli):=20cross-session=20layer=202=20?= =?UTF-8?q?=E2=80=94=20content=20hash=20+=20dep=20graph?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Persists per-file state between `tsslint --incremental` runs so the next session can compute which files' type-relevant inputs (own text, transitive deps, ambient `.d.ts`, lib) have actually moved. Without this, the prior wiring (10f0f5c) treated every file as affected on cold start — wiring done but value was zero. Why not `.tsbuildinfo`? TS reads/writes it via `getProgramBuildInfo` / `createBuilderProgramUsingProgramBuildInfo`, both internal and TS- major-coupled. Going through public `BuilderProgram.getAllDependencies` + a content-hash digest trades some hit rate (we invalidate on body- only edits where TS's shape signatures wouldn't) for a contract that survives TS upgrades. New module — packages/cli/lib/incremental-state.ts: - `IncrementalState` shape: `{ version: 'v1', files: { [name]: { contentHash, deps: string[] } } }`. Saved as a new optional `incrementalState` field on the cache file. - `buildIncrementalState(builder, hash)` — harvest from a fresh BuilderProgram pass over the LS program (called once at end of project, after the lint loop). - `computeAffectedFiles(prev, program, hash)` — diff: a file is affected if its own content moved, any dep moved, it's newly added, or anyone listed a removed file in deps. Stale-deps risk is bounded because dep-graph changes always move the file's own content (import statement edit), so they propagate via the own-hash path. Worker: - `setup` now takes `prevIncrementalState` and (when `--incremental`) computes the affected set via the new module instead of the cold- start "everything affected" placeholder. - New `buildIncrementalState()` accessor, called from CLI at end of project to harvest fresh state for persistence. - `defaultHash` fallback (sha256 hex via `node:crypto`) if `ts.sys. createHash` is unavailable on the host. CLI: - Reads `project.cacheData.incrementalState` and passes to `worker.setup`. - After the lint loop, calls `worker.buildIncrementalState()` and writes back to `project.cacheData.incrementalState` before `cache.saveCache`. Cache schema (lib/cache.ts): - Optional `incrementalState?: IncrementalState` on `CacheData`. Layer-1-only sessions stay clean (field absent). Field's own `version` lets us bump the diff format without touching the cache schema. Tests in packages/cli/test/incremental-state.test.ts (18 checks): - No prior state → all affected - Schema version bump → all affected - Identical state → no user file affected (lib files mirrored in prev) - Own content changed → just that file - Dep changed (the `globals.d.ts` killer case) → all consumers affected - Newly added file → affected - Removed file → consumers affected (transitive impact propagates) - Independent file change doesn't affect unrelated files Smoke-tested on fixtures/define-rule with `--incremental`: cold run writes 84 files × deps into the cache file; warm run reads the state and produces the same diagnostic. Total cache test surface now: 8 + 9 + 13 + 14 + 36 + 18 + 14 = 112 across seven cache-related suites, plus 75 in compat-pipeline. --- .github/workflows/test.yml | 4 + packages/cli/index.ts | 7 + packages/cli/lib/cache.ts | 7 + packages/cli/lib/incremental-state.ts | 107 +++++++++ packages/cli/lib/worker.ts | 66 +++--- packages/cli/test/incremental-state.test.ts | 227 ++++++++++++++++++++ 6 files changed, 387 insertions(+), 31 deletions(-) create mode 100644 packages/cli/lib/incremental-state.ts create mode 100644 packages/cli/test/incremental-state.test.ts diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 3c72a8a5..67524d9e 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -82,3 +82,7 @@ jobs: # 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 diff --git a/packages/cli/index.ts b/packages/cli/index.ts index 75ec43fb..37b6e861 100644 --- a/packages/cli/index.ts +++ b/packages/cli/index.ts @@ -351,6 +351,7 @@ const formatHost: ts.FormatDiagnosticsHost = { project.options, Object.keys(project.cacheData.ruleModes), process.argv.includes('--incremental'), + project.cacheData.incrementalState, ); if (setupResult !== true) { renderer.diagnostic(formatConfigError(project.configFile!, setupResult)); @@ -448,6 +449,12 @@ const formatHost: ts.FormatDiagnosticsHost = { 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!, diff --git a/packages/cli/lib/cache.ts b/packages/cli/lib/cache.ts index 860f54ea..0096bffa 100644 --- a/packages/cli/lib/cache.ts +++ b/packages/cli/lib/cache.ts @@ -25,6 +25,7 @@ import os = require('os'); import crypto = require('crypto'); import type * as ts from 'typescript'; +import type { IncrementalState } from './incremental-state.js'; const pkg = require('../package.json'); @@ -39,6 +40,12 @@ export interface CacheData { // which currently means the same thing: cache write happens). ruleModes: Record; files: Record; + // Layer 2 cross-session state. Present iff the previous session ran + // with `--incremental`. Lets the next session compute which files' + // type-relevant inputs (incl. ambient `.d.ts`) have changed since. + // See `lib/incremental-state.ts`. Optional so layer-1-only sessions + // stay schema-clean. + incrementalState?: IncrementalState; } export interface FileCache { diff --git a/packages/cli/lib/incremental-state.ts b/packages/cli/lib/incremental-state.ts new file mode 100644 index 00000000..6dcdc82a --- /dev/null +++ b/packages/cli/lib/incremental-state.ts @@ -0,0 +1,107 @@ +// Layer 2 cross-session state. Persists per-file content hashes and +// transitive dependency lists between `tsslint --incremental` runs so +// the next session can compute which files' type-relevant inputs have +// moved. +// +// Why not `.tsbuildinfo`? TS's `BuilderProgram` reads/writes that file +// via internal APIs (`getProgramBuildInfo` / `createBuilderProgramUsing- +// ProgramBuildInfo`), and the format is explicitly TS-major-coupled. +// Going through the public `getAllDependencies` + a content-hash digest +// trades some cache hit rate (we invalidate on body-only edits where +// TS's shape signatures wouldn't) for a sustainable contract that +// doesn't break on TS upgrades. + +import type * as ts from 'typescript'; + +export interface IncrementalState { + version: string; + files: Record; +} + +export const INCREMENTAL_STATE_VERSION = 'v1'; + +// Build a fresh state snapshot from a wrapped BuilderProgram, to save +// alongside the cache file. Called after the lint pass. +export function buildIncrementalState( + builder: ts.BuilderProgram, + hash: (s: string) => string, +): IncrementalState { + const files: IncrementalState['files'] = {}; + for (const sf of builder.getProgram().getSourceFiles()) { + files[sf.fileName] = { + contentHash: hash(sf.text), + deps: [...builder.getAllDependencies(sf)], + }; + } + return { version: INCREMENTAL_STATE_VERSION, files }; +} + +// Diff a previous state against the current program to figure out which +// files' type-relevant inputs are affected since the last session. The +// result feeds `cacheFlow.lintWithCache(..., { typeAwareUnaffected })`: +// a file NOT in the affected set has unchanged dep hashes, so its +// type-aware rule cache entries are still valid. +export function computeAffectedFiles( + prev: IncrementalState | undefined, + program: ts.Program, + hash: (s: string) => string, +): Set { + const affected = new Set(); + const sourceFiles = program.getSourceFiles(); + if (!prev || prev.version !== INCREMENTAL_STATE_VERSION) { + // No prior state (or schema bump): every file is affected. + for (const sf of sourceFiles) affected.add(sf.fileName); + return affected; + } + + // Step 1: hash every current file. Files whose own content moved go + // straight into `changed`. Files newly added (no prev entry) go into + // `affected` directly — we have nothing cached for them anyway. + const currentHashes = new Map(); + const changed = new Set(); + for (const sf of sourceFiles) { + const h = hash(sf.text); + currentHashes.set(sf.fileName, h); + const prevEntry = prev.files[sf.fileName]; + if (!prevEntry) { + affected.add(sf.fileName); + } + else if (prevEntry.contentHash !== h) { + changed.add(sf.fileName); + affected.add(sf.fileName); + } + } + // Files removed from the program also count as a change — anyone who + // listed them in `deps` is now affected. + for (const prevName of Object.keys(prev.files)) { + if (!currentHashes.has(prevName)) { + changed.add(prevName); + } + } + + // Step 2: propagate. A file is affected if any of its prior deps + // landed in `changed`. We use the prev session's dep list — if the + // dep graph itself changed (file F gained or lost an import), F's + // own content moved, so it's already in `changed` → propagating from + // stale deps stays sound. + for (const sf of sourceFiles) { + if (affected.has(sf.fileName)) continue; + const prevEntry = prev.files[sf.fileName]; + if (!prevEntry) continue; // already added above + for (const dep of prevEntry.deps) { + if (changed.has(dep)) { + affected.add(sf.fileName); + break; + } + } + } + + return affected; +} diff --git a/packages/cli/lib/worker.ts b/packages/cli/lib/worker.ts index 73953cd6..0d6c0823 100644 --- a/packages/cli/lib/worker.ts +++ b/packages/cli/lib/worker.ts @@ -4,9 +4,16 @@ import core = require('@tsslint/core'); import url = require('url'); import path = require('path'); import fs = require('fs'); +import crypto = require('crypto'); import languagePlugins = require('./languagePlugins.js'); import cacheFlow = require('./cache-flow.js'); +import incrementalState = require('./incremental-state.js'); import type { FileCache } from './cache.js'; +import type { IncrementalState } from './incremental-state.js'; + +// Fallback if `ts.sys.createHash` is undefined on this host (Node ≥ 22.6 +// always provides it via crypto, but the type is optional). sha256 hex. +const defaultHash = (s: string) => crypto.createHash('sha256').update(s).digest('hex'); import { createLanguage, FileMap, isCodeActionsEnabled, type Language } from '@volar/language-core'; import { createProxyLanguageService, decorateLanguageServiceHost, resolveFileLanguageId } from '@volar/typescript'; @@ -19,15 +26,12 @@ let fileNames: string[] = []; let language: Language | undefined; let linter: core.Linter; let linterLanguageService!: ts.LanguageService; -// Layer 2 state. When `--incremental` is on, we wrap the LS's program in -// a SemanticDiagnosticsBuilderProgram and walk affected files once at -// setup. cache-flow consults this set to decide whether type-aware rules -// can be cache-hit. `undefined` here = layer 1 only (cache-flow's default -// safe behavior — type-aware rules always re-run). -// -// Without cross-session BP state (tsbuildinfo), the first session sees -// every file as affected on cold start; the wiring lands here so the -// state-persistence work can plug in without touching cache-flow. +// Layer 2 state. When `--incremental` is on, we diff the prior session's +// stored content hashes + transitive dep lists against the current +// program to decide which files' type-relevant inputs have moved. cache- +// flow consults this set to decide whether type-aware rules can be +// cache-hit. `undefined` = layer 1 only (cache-flow's default safe +// behavior). let affectedFiles: Set | undefined; const snapshots = new Map(); @@ -106,6 +110,9 @@ export function create() { getTypeAwareRules() { return [...linter.getTypeAwareRules()]; }, + buildIncrementalState() { + return buildIncrementalState(); + }, }; } @@ -117,6 +124,7 @@ async function setup( _options: ts.CompilerOptions, initialTypeAwareRules: readonly string[], incremental: boolean, + prevIncrementalState: IncrementalState | undefined, ): Promise { let config: config.Config | config.Config[]; try { @@ -186,37 +194,33 @@ async function setup( initialTypeAwareRules, ); - affectedFiles = incremental ? computeAffectedFiles() : undefined; + if (incremental) { + const program = linterLanguageService.getProgram()!; + affectedFiles = incrementalState.computeAffectedFiles( + prevIncrementalState, + program, + ts.sys.createHash ?? defaultHash, + ); + } + else { + affectedFiles = undefined; + } return true; } -// Wrap LS's program in a SemanticDiagnosticsBuilderProgram and drain the -// affected-file iterator. Without an `oldProgram` (cross-session state -// not yet persisted), every file counts as affected on cold runs — so -// the returned set is large but correctness is preserved. cache-flow -// consults `!affectedFiles.has(fileName)` for `typeAwareUnaffected`. -function computeAffectedFiles(): Set { +// Build a fresh state snapshot to persist alongside the cache file. +// Called by the CLI at end of project, after the lint loop. Wraps the +// current LS program in a BuilderProgram once to harvest `getAll- +// Dependencies`, then hashes file texts. +function buildIncrementalState(): IncrementalState | undefined { + if (!affectedFiles) return undefined; const program = linterLanguageService.getProgram()!; const builder = ts.createSemanticDiagnosticsBuilderProgram( program, { createHash: ts.sys.createHash }, ); - const set = new Set(); - while (true) { - const result = builder.getSemanticDiagnosticsOfNextAffectedFile(); - if (!result) break; - const a = result.affected; - if ('fileName' in a) { - set.add(a.fileName); - } - else { - // Whole-program affected — config option flip, lib change, etc. - // Conservatively mark every source file affected. - for (const sf of a.getSourceFiles()) set.add(sf.fileName); - } - } - return set; + return incrementalState.buildIncrementalState(builder, ts.sys.createHash ?? defaultHash); } function lint(fileName: string, fix: boolean, fileCache: FileCache, fileMtime: number) { diff --git a/packages/cli/test/incremental-state.test.ts b/packages/cli/test/incremental-state.test.ts new file mode 100644 index 00000000..9e5878cb --- /dev/null +++ b/packages/cli/test/incremental-state.test.ts @@ -0,0 +1,227 @@ +// Tests for the layer 2 cross-session diff. Given a stored +// `IncrementalState` from a prior session and a current Program, the +// `computeAffectedFiles` function should return the set of files whose +// type-relevant inputs (own content, transitive deps incl. ambient +// `.d.ts`) have changed. +// +// Run via: +// node packages/cli/test/incremental-state.test.js + +import * as ts from 'typescript'; +import type { IncrementalState } from '../lib/incremental-state.js'; + +const inc = require('../lib/incremental-state.js') as typeof import('../lib/incremental-state.js'); + +const failures: string[] = []; +function check(name: string, cond: boolean, detail?: string) { + if (cond) { + process.stdout.write('.'); + } + else { + failures.push(name + (detail ? ' — ' + detail : '')); + process.stdout.write('F'); + } +} + +// Trivial deterministic hash for tests — we just need stable+collision-free +// for the strings we throw at it. +function fakeHash(s: string): string { + let h = 0; + for (let i = 0; i < s.length; i++) h = ((h << 5) - h + s.charCodeAt(i)) | 0; + return String(h); +} + +// Build a minimal Program from in-memory file map. Lib not needed for +// these tests — we only exercise getSourceFiles / sf.fileName / sf.text. +function buildProgram(files: Record): ts.Program { + const realLibPath = ts.getDefaultLibFilePath({ target: ts.ScriptTarget.Latest }); + const realLibContent = ts.sys.readFile(realLibPath) ?? ''; + const realLib = ts.createSourceFile(realLibPath, realLibContent, ts.ScriptTarget.Latest, true); + const sourceFiles = new Map(); + for (const [name, text] of Object.entries(files)) { + sourceFiles.set(name, ts.createSourceFile(name, text, ts.ScriptTarget.Latest, true)); + } + const host: ts.CompilerHost = { + getSourceFile: n => sourceFiles.get(n) ?? (n === realLibPath ? realLib : undefined), + getDefaultLibFileName: () => realLibPath, + writeFile: () => {}, + getCurrentDirectory: () => '/', + getDirectories: () => [], + fileExists: n => sourceFiles.has(n) || n === realLibPath, + readFile: n => files[n] ?? (n === realLibPath ? realLibContent : undefined), + getCanonicalFileName: n => n, + useCaseSensitiveFileNames: () => true, + getNewLine: () => '\n', + }; + return ts.createProgram({ + rootNames: [...sourceFiles.keys()], + options: { target: ts.ScriptTarget.Latest, noEmit: true, lib: [realLibPath.split(/[\\/]/).pop()!] }, + host, + }); +} + +// ── Test 1: no prior state → all source files are affected ────────────── +{ + const program = buildProgram({ '/a.ts': 'const x = 1;', '/b.ts': 'const y = 2;' }); + const affected = inc.computeAffectedFiles(undefined, program, fakeHash); + check('a.ts affected (no prev state)', affected.has('/a.ts')); + check('b.ts affected (no prev state)', affected.has('/b.ts')); +} + +// ── Test 2: state version mismatch → all affected ─────────────────────── +{ + const program = buildProgram({ '/a.ts': 'const x = 1;' }); + const stale: IncrementalState = { + version: 'v0', + files: { '/a.ts': { contentHash: fakeHash('const x = 1;'), deps: ['/a.ts'] } }, + } as any; + const affected = inc.computeAffectedFiles(stale, program, fakeHash); + check('schema bump → all affected', affected.has('/a.ts')); +} + +// ── Test 3: identical state → no user files affected ──────────────────── +// +// lib.*.d.ts files would also be in `program.getSourceFiles()`. For the +// "everything matches" check we mirror that into prev too. +{ + const program = buildProgram({ '/a.ts': 'const x = 1;', '/b.ts': 'const y = 2;' }); + const prev: IncrementalState = { + version: inc.INCREMENTAL_STATE_VERSION, + files: Object.fromEntries( + program.getSourceFiles().map(sf => [sf.fileName, { + contentHash: fakeHash(sf.text), + deps: [sf.fileName], + }]), + ), + }; + const affected = inc.computeAffectedFiles(prev, program, fakeHash); + check('a.ts NOT affected (hash matches)', !affected.has('/a.ts')); + check('b.ts NOT affected (hash matches)', !affected.has('/b.ts')); + check('no user file affected', !affected.has('/a.ts') && !affected.has('/b.ts')); +} + +// ── Test 4: file content changed → only that file in affected ─────────── +// +// b.ts has /a.ts in its deps. a.ts unchanged. b.ts content changed. +// Only b.ts is affected. +{ + const program = buildProgram({ + '/a.ts': 'const x = 1;', + '/b.ts': 'const y = 99;', + }); + const prev: IncrementalState = { + version: inc.INCREMENTAL_STATE_VERSION, + files: { + '/a.ts': { contentHash: fakeHash('const x = 1;'), deps: ['/a.ts'] }, + '/b.ts': { contentHash: fakeHash('const y = 2;'), deps: ['/a.ts', '/b.ts'] }, + }, + }; + const affected = inc.computeAffectedFiles(prev, program, fakeHash); + check('b.ts affected (own content changed)', affected.has('/b.ts')); + check('a.ts NOT affected (unchanged)', !affected.has('/a.ts')); +} + +// ── Test 5: dep file changed → all consumers affected ─────────────────── +// +// The killer case for layer 2: editing globals.d.ts (or any ambient file) +// must propagate to every file that listed it as a dep. That's what +// per-file mtime caching can't catch. +{ + const program = buildProgram({ + '/globals.d.ts': 'declare const FOO: string;', // changed from `: number;` + '/use1.ts': 'const a = FOO;', + '/use2.ts': 'const b = FOO;', + '/standalone.ts': 'const c = 42;', + }); + const prev: IncrementalState = { + version: inc.INCREMENTAL_STATE_VERSION, + files: { + '/globals.d.ts': { contentHash: fakeHash('declare const FOO: number;'), deps: ['/globals.d.ts'] }, + '/use1.ts': { contentHash: fakeHash('const a = FOO;'), deps: ['/globals.d.ts', '/use1.ts'] }, + '/use2.ts': { contentHash: fakeHash('const b = FOO;'), deps: ['/globals.d.ts', '/use2.ts'] }, + '/standalone.ts': { contentHash: fakeHash('const c = 42;'), deps: ['/standalone.ts'] }, + }, + }; + const affected = inc.computeAffectedFiles(prev, program, fakeHash); + check('globals.d.ts affected (own change)', affected.has('/globals.d.ts')); + check('use1.ts affected (dep changed)', affected.has('/use1.ts')); + check('use2.ts affected (dep changed)', affected.has('/use2.ts')); + check( + 'standalone.ts NOT affected (no globals.d.ts in deps)', + !affected.has('/standalone.ts'), + ); +} + +// ── Test 6: new file (in current, not prev) → affected ────────────────── +{ + const program = buildProgram({ + '/old.ts': 'const o = 1;', + '/new.ts': 'const n = 2;', + }); + const prev: IncrementalState = { + version: inc.INCREMENTAL_STATE_VERSION, + files: { + '/old.ts': { contentHash: fakeHash('const o = 1;'), deps: ['/old.ts'] }, + }, + }; + const affected = inc.computeAffectedFiles(prev, program, fakeHash); + check('new.ts affected (newly added)', affected.has('/new.ts')); + check('old.ts NOT affected (unchanged)', !affected.has('/old.ts')); +} + +// ── Test 7: removed file → its consumers are affected ─────────────────── +// +// /removed.ts is gone. /still-here.ts had it in deps. The consumer +// must re-check because its type info may have changed (import error, +// missing export, etc.). +{ + const program = buildProgram({ + '/still-here.ts': 'const z = 3;', + }); + const prev: IncrementalState = { + version: inc.INCREMENTAL_STATE_VERSION, + files: { + '/removed.ts': { contentHash: fakeHash('export const r = 1;'), deps: ['/removed.ts'] }, + '/still-here.ts': { contentHash: fakeHash('const z = 3;'), deps: ['/removed.ts', '/still-here.ts'] }, + }, + }; + const affected = inc.computeAffectedFiles(prev, program, fakeHash); + check( + 'still-here.ts affected (dep removed)', + affected.has('/still-here.ts'), + 'consumer must re-check when a transitive dep disappears', + ); +} + +// ── Test 8: hash collision in deps doesn't false-positive ─────────────── +// +// File A has dep file C. C is unchanged. A's content unchanged. Even +// though something else (D) changed, A is unaffected. +{ + const program = buildProgram({ + '/a.ts': 'const x = 1;', + '/c.ts': 'const c = 1;', + '/d.ts': 'const d = 99;', // changed + }); + const prev: IncrementalState = { + version: inc.INCREMENTAL_STATE_VERSION, + files: { + '/a.ts': { contentHash: fakeHash('const x = 1;'), deps: ['/c.ts', '/a.ts'] }, + '/c.ts': { contentHash: fakeHash('const c = 1;'), deps: ['/c.ts'] }, + '/d.ts': { contentHash: fakeHash('const d = 1;'), deps: ['/d.ts'] }, + }, + }; + const affected = inc.computeAffectedFiles(prev, program, fakeHash); + check('a.ts NOT affected (deps unchanged, own unchanged)', !affected.has('/a.ts')); + check('d.ts affected (own content changed)', affected.has('/d.ts')); + check('c.ts NOT affected', !affected.has('/c.ts')); +} + +// ── Done ──────────────────────────────────────────────────────────────── +process.stdout.write('\n'); +if (failures.length) { + console.error(`\n${failures.length} failure(s):`); + for (const f of failures) console.error(' - ' + f); + process.exit(1); +} +console.log('OK'); From 463a2dbda4f77523da80faecb711d2836754fc07 Mon Sep 17 00:00:00 2001 From: Johnson Chu Date: Sat, 2 May 2026 17:43:23 +0800 Subject: [PATCH 14/32] fix(cli): split layer 2 cache write/read gates; cover ambient deps MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two bugs found via the layer-2 soundness integration tests: 1. Cold session under --incremental wasn't writing type-aware cache entries because the affected-file diff (correctly) lists every file as affected on first encounter (no prev state). The single-flag `typeAwareUnaffected` conflated "trust cache" with "write cache" — so layer 2 had nothing to read on the warm session. Split the option into two flags on cacheFlow.lintWithCache: - `incremental: boolean` — write type-aware entries (next-session bait). True ⇔ CLI passed --incremental. - `typeAwareUnaffected: boolean` — trust this file's cached type-aware entries (run-skip via skipRules). Only meaningful when `incremental` is true. cold session: incremental=true, unaffected=false → run + WRITE warm session unaffected: incremental=true, unaffected=true → SKIP warm session affected: incremental=true, unaffected=false → run + WRITE no flag: any combination → never write type-aware (mode A) 2. `BuilderProgram.getAllDependencies` walks the explicit reference graph (imports + /// references). Ambient `.d.ts` files in script mode (e.g. user's `globals.d.ts` declaring globals) connect only via global scope — no file imports them — so they're absent from every consumer's dep list. Editing one wouldn't propagate to the affected-file set; the killer layer-2 case fails silently. Fix in incremental-state.ts: enumerate non-lib script-mode `.d.ts` files at state-build time and treat them as universal deps. Lib files are excluded since `compilerOptions.lib` flips already invalidate the whole cache via the path key. Detect script mode via `externalModuleIndicator` (cast since field is technically internal in TS's public types, but stable at runtime — used by typescript-eslint and ts-morph for the same purpose). Worker and cache-flow tests updated for the new option shape. Two new cache-flow checks (38 total) cover the cold-session-writes-fresh-entry case. Three integration scenarios now passing end-to-end via the marker- file fixture (rule writes its own execution count to disk, test reads back to verify): - Test 7: warm `--incremental` skips type-aware rule (cache hit) - Test 8: editing `ambient.d.ts` forces dependent re-lint - Test 9: without `--incremental`, type-aware rule re-runs every session (mode A semantics preserved) 22 integration checks total (was 14). All other suites unchanged. --- packages/cli/lib/cache-flow.ts | 38 ++++---- packages/cli/lib/incremental-state.ts | 31 ++++++- packages/cli/lib/worker.ts | 17 +++- packages/cli/test/cache-flow.test.ts | 54 +++++++++-- packages/cli/test/integration.test.ts | 128 +++++++++++++++++++++++++- 5 files changed, 233 insertions(+), 35 deletions(-) diff --git a/packages/cli/lib/cache-flow.ts b/packages/cli/lib/cache-flow.ts index 469f0271..3f4d6afd 100644 --- a/packages/cli/lib/cache-flow.ts +++ b/packages/cli/lib/cache-flow.ts @@ -19,18 +19,18 @@ export function lintWithCache( fileMtime: number, program: ts.Program, options?: { - // Layer 2 signal from the caller: BuilderProgram (or equivalent - // affected-file tracker) says this file's type-relevant inputs — - // own text, transitive imports, ambient declarations, lib — - // haven't moved since the cached entries were written. - // - // When true: type-aware rules behave like syntactic ones for - // caching purposes — cached entries can be reused, and fresh runs - // write back. When false / undefined: type-aware rules are always - // re-run and their cache entries deleted. The default is the - // safe one because mtime alone can't catch ambient-declaration - // edits that change a file's effective types without touching - // its text. + // Layer 2 master switch. Driven by the CLI's `--incremental` flag. + // When false / undefined (mode A), type-aware rules are never + // cached: their entries are deleted after each run. When true + // (mode B), type-aware rules' fresh results get persisted so the + // next session can reuse them. + incremental?: boolean; + // File-level signal: the caller's affected-file tracker says this + // file's type-relevant inputs (own text, transitive deps incl. + // ambient `.d.ts` and lib) haven't moved since the prior session. + // Only meaningful in mode B. When true, type-aware rule cache + // entries are also cache-hit-eligible (skipped via the run's + // skipRules set). typeAwareUnaffected?: boolean; }, ): ts.DiagnosticWithLocation[] { @@ -42,7 +42,8 @@ export function lintWithCache( fileCache.rules = {}; } - const typeAwareCanCache = options?.typeAwareUnaffected === true; + const writeTypeAware = options?.incremental === true; + const trustTypeAwareCache = writeTypeAware && options?.typeAwareUnaffected === true; const typeAware = linter.getTypeAwareRules(); // Cache hit only when this file has a cache entry for the rule AND @@ -53,7 +54,7 @@ export function lintWithCache( const skipRules = new Set(); for (const ruleId of Object.keys(fileCache.rules)) { if (typeAware.has(ruleId)) { - if (typeAwareCanCache) { + if (trustTypeAwareCache) { skipRules.add(ruleId); } // else: re-run; the post-rule write path will overwrite the @@ -77,13 +78,14 @@ export function lintWithCache( } // For every rule that actually ran (i.e. not skipped), update the - // cache. Type-aware rules without the unaffected signal: delete any - // entry, never write. Otherwise (syntactic, or type-aware with - // signal): write the freshly-computed entry. + // cache. Type-aware rules in mode A (no `incremental`): delete any + // entry, never write. Otherwise (syntactic, or type-aware in mode + // B): write the freshly-computed entry — even if the file was + // considered affected this run, so the next session can cache-hit. const allRuleIds = Object.keys(linter.getRules(fileName)); for (const ruleId of allRuleIds) { if (skipRules.has(ruleId)) continue; - if (typeAware.has(ruleId) && !typeAwareCanCache) { + if (typeAware.has(ruleId) && !writeTypeAware) { delete fileCache.rules[ruleId]; continue; } diff --git a/packages/cli/lib/incremental-state.ts b/packages/cli/lib/incremental-state.ts index 6dcdc82a..ba7dd508 100644 --- a/packages/cli/lib/incremental-state.ts +++ b/packages/cli/lib/incremental-state.ts @@ -29,15 +29,42 @@ export const INCREMENTAL_STATE_VERSION = 'v1'; // Build a fresh state snapshot from a wrapped BuilderProgram, to save // alongside the cache file. Called after the lint pass. +// +// Ambient `.d.ts` files (`declare global`, top-level declarations in +// script-mode `.d.ts`) don't show up in any specific file's +// `getAllDependencies` because no file explicitly imports them — they +// connect via global scope. To catch their edits, we treat every +// non-lib script-mode `.d.ts` as a universal dep. Lib files are +// excluded because they only change when `compilerOptions.lib` flips, +// which already invalidates the entire cache file via the path key. export function buildIncrementalState( builder: ts.BuilderProgram, hash: (s: string) => string, ): IncrementalState { + const program = builder.getProgram(); + // Detect script-mode .d.ts via `externalModuleIndicator`. Field is + // internal in TS's public types but stable at runtime — used by tools + // across the ecosystem (typescript-eslint, ts-morph) for the same + // reason. The public `ts.isExternalModule` check would work too but + // is itself runtime-only at the API level. + const ambients: string[] = []; + for (const sf of program.getSourceFiles()) { + if ( + sf.isDeclarationFile + && !(sf as { externalModuleIndicator?: unknown }).externalModuleIndicator + && !program.isSourceFileDefaultLibrary(sf) + ) { + ambients.push(sf.fileName); + } + } + const files: IncrementalState['files'] = {}; - for (const sf of builder.getProgram().getSourceFiles()) { + for (const sf of program.getSourceFiles()) { + const deps = new Set(builder.getAllDependencies(sf)); + for (const a of ambients) deps.add(a); files[sf.fileName] = { contentHash: hash(sf.text), - deps: [...builder.getAllDependencies(sf)], + deps: [...deps], }; } return { version: INCREMENTAL_STATE_VERSION, files }; diff --git a/packages/cli/lib/worker.ts b/packages/cli/lib/worker.ts index 0d6c0823..aa95bb5d 100644 --- a/packages/cli/lib/worker.ts +++ b/packages/cli/lib/worker.ts @@ -228,11 +228,16 @@ function lint(fileName: string, fix: boolean, fileCache: FileCache, fileMtime: n let diagnostics!: ts.DiagnosticWithLocation[]; let shouldCheck = true; - // Layer 2 signal: file is unaffected if --incremental is on AND the - // BuilderProgram pass at setup didn't list this file. In `--fix` mode - // we conservatively force `false` — fixes mutate files mid-session, - // invalidating the setup-time affected snapshot for downstream files. - const typeAwareUnaffected = !!affectedFiles && !fix && !affectedFiles.has(fileName); + // Layer 2 signals. + // incremental: master switch. drives whether type-aware entries + // are written this session (so the NEXT one can read). + // typeAwareUnaffected: file's deps haven't moved since prev session, + // so cached type-aware entries can be reused this run. + // False in --fix mode — fixes mutate files mid-session + // and invalidate the setup-time affected snapshot for + // downstream files; we'd rather re-run than serve stale. + const incremental = !!affectedFiles; + const typeAwareUnaffected = incremental && !fix && !affectedFiles!.has(fileName); if (fix) { // Drop cache entries for rules that registered a fix in any prior @@ -246,6 +251,7 @@ function lint(fileName: string, fix: boolean, fileCache: FileCache, fileMtime: n } const program = linterLanguageService.getProgram()!; diagnostics = cacheFlow.lintWithCache(linter, fileName, fileCache, fileMtime, program, { + incremental, typeAwareUnaffected, }); shouldCheck = false; @@ -287,6 +293,7 @@ function lint(fileName: string, fix: boolean, fileCache: FileCache, fileMtime: n if (shouldCheck) { const program = linterLanguageService.getProgram()!; diagnostics = cacheFlow.lintWithCache(linter, fileName, fileCache, fileMtime, program, { + incremental, typeAwareUnaffected, }); } diff --git a/packages/cli/test/cache-flow.test.ts b/packages/cli/test/cache-flow.test.ts index e1aac666..121fca54 100644 --- a/packages/cli/test/cache-flow.test.ts +++ b/packages/cli/test/cache-flow.test.ts @@ -296,7 +296,7 @@ function emptyFileCache(mtime = 0): FileCache { const cache = emptyFileCache(1); const program = ctx.languageService.getProgram()!; - cacheFlow.lintWithCache(linter, '/a.ts', cache, 1, program, { typeAwareUnaffected: true }); + cacheFlow.lintWithCache(linter, '/a.ts', cache, 1, program, { incremental: true, typeAwareUnaffected: true }); check('first call ran (no cache yet)', runs === 1); check( 'type-aware entry written under unaffected signal', @@ -326,8 +326,8 @@ function emptyFileCache(mtime = 0): FileCache { const cache = emptyFileCache(1); const program = ctx.languageService.getProgram()!; - cacheFlow.lintWithCache(linter, '/a.ts', cache, 1, program, { typeAwareUnaffected: true }); - const second = cacheFlow.lintWithCache(linter, '/a.ts', cache, 1, program, { typeAwareUnaffected: true }); + cacheFlow.lintWithCache(linter, '/a.ts', cache, 1, program, { incremental: true, typeAwareUnaffected: true }); + const second = cacheFlow.lintWithCache(linter, '/a.ts', cache, 1, program, { incremental: true, typeAwareUnaffected: true }); check('second call did NOT re-run type-aware rule', runs === 1); check('second call still produced 1 diagnostic', second.length === 1); check( @@ -358,7 +358,7 @@ function emptyFileCache(mtime = 0): FileCache { const program = ctx.languageService.getProgram()!; // Mode B: write the entry. - cacheFlow.lintWithCache(linter, '/a.ts', cache, 1, program, { typeAwareUnaffected: true }); + cacheFlow.lintWithCache(linter, '/a.ts', cache, 1, program, { incremental: true, typeAwareUnaffected: true }); check('layer-2 first run cached the entry', !!cache.rules['typed']); // Mode A (default): file is affected. Re-run, drop entry. @@ -367,7 +367,7 @@ function emptyFileCache(mtime = 0): FileCache { check('mode-A re-run dropped the entry', !cache.rules['typed']); } -// ── Test 13 (layer 2): default (no signal) preserves mode-A semantics ──── +// ── Test 13 (layer 2): mode A (no incremental) never caches type-aware ── { const ctx = makeContext({ '/a.ts': 'const x = 1;' }); const config: Config = { @@ -382,13 +382,49 @@ function emptyFileCache(mtime = 0): FileCache { const cache = emptyFileCache(1); const program = ctx.languageService.getProgram()!; - // No options arg passed at all — should behave like mode A. + // No options arg → mode A. Type-aware rules never cached. cacheFlow.lintWithCache(linter, '/a.ts', cache, 1, program); check('default (no options) does NOT cache type-aware', !cache.rules['typed']); - // Explicit false also defaults to mode A. - cacheFlow.lintWithCache(linter, '/a.ts', cache, 1, program, { typeAwareUnaffected: false }); - check('explicit false does NOT cache type-aware', !cache.rules['typed']); + // Explicit incremental=false also mode A. + cacheFlow.lintWithCache(linter, '/a.ts', cache, 1, program, { incremental: false }); + check('explicit incremental=false does NOT cache type-aware', !cache.rules['typed']); +} + +// ── Test 14 (layer 2): incremental writes type-aware even when affected ─ +// +// First-time-ever (cold session) under --incremental: there's no prior +// entry to hit, but we must still WRITE one so the next session can +// hit. The split between "trust cache" (typeAwareUnaffected) and "write +// cache" (incremental) is the gate. +{ + const ctx = makeContext({ '/a.ts': 'const x = 1;' }); + let runs = 0; + const config: Config = { + rules: { + typed: ((rctx: RuleContext) => { + runs++; + void rctx.program; + rctx.report('typed', 0, 1); + }), + }, + }; + const linter = core.createLinter(ctx, '/', config, () => []); + const cache = emptyFileCache(1); + const program = ctx.languageService.getProgram()!; + + // Cold session under --incremental: no prev state, file is "affected" + // (unaffected=false). Must run AND write entry. + cacheFlow.lintWithCache(linter, '/a.ts', cache, 1, program, { + incremental: true, + typeAwareUnaffected: false, + }); + check('cold session ran the rule', runs === 1); + check( + 'cold session under --incremental wrote type-aware entry', + !!cache.rules['typed'], + 'entry needed for next session to cache-hit', + ); } // ── Done ──────────────────────────────────────────────────────────────── diff --git a/packages/cli/test/integration.test.ts b/packages/cli/test/integration.test.ts index 3fdeadb7..6316d85e 100644 --- a/packages/cli/test/integration.test.ts +++ b/packages/cli/test/integration.test.ts @@ -206,8 +206,134 @@ function readCacheForFixture(fixtureDir: string): unknown { try { const r = runCli(dir, '--incremental'); check('--incremental run produced diagnostic', r.stdout.includes('no-console')); - const data = readCacheForFixture(dir); + const data = readCacheForFixture(dir) as any; check('cache written under --incremental', !!data); + check( + 'incrementalState persisted to cache file', + !!data?.incrementalState && Object.keys(data.incrementalState.files).length > 0, + ); + } + finally { + fs.rmSync(dir, { recursive: true, force: true }); + } +} + +// Build a fixture that exercises layer 2: a type-aware rule that touches +// `program` (classifies it type-aware via the runtime probe), writes the +// linted file's name to a marker file each time it runs, and reports a +// fixed diagnostic. The marker file's line count tells us how many times +// the rule actually executed across runs. +function makeTypeAwareFixture(): { dir: string; markerPath: string; ambient: string; fixture: string } { + const dir = fs.realpathSync(fs.mkdtempSync(path.join(os.tmpdir(), 'tsslint-int-l2-'))); + const markerPath = path.join(dir, 'marker.log'); + const rulePath = path.join(dir, 'type-aware-rule.ts'); + fs.writeFileSync(rulePath, + `import { defineRule } from '${repoRoot}/packages/config/index.js';\n` + + `import * as fs from 'fs';\n` + + `export default defineRule(({ file, program, report }) => {\n` + + ` void program.getTypeChecker();\n` + + ` fs.appendFileSync(${JSON.stringify(markerPath)}, file.fileName + '\\n');\n` + + ` report('type-aware ran', 0, 1);\n` + + `});\n`, + ); + fs.writeFileSync(path.join(dir, 'tsconfig.json'), + JSON.stringify({ + compilerOptions: { + target: 'es2020', module: 'esnext', moduleResolution: 'bundler', + strict: true, skipLibCheck: true, + }, + include: ['*.ts', '*.d.ts'], + }), + ); + fs.writeFileSync(path.join(dir, 'tsslint.config.ts'), + `import { defineConfig } from '${repoRoot}/packages/config/index.js';\n` + + `export default defineConfig({\n` + + ` include: ['fixture.ts'],\n` + + ` rules: { 'type-aware': (await import('${rulePath}')) },\n` + + `});\n`, + ); + const ambient = path.join(dir, 'ambient.d.ts'); + const fixture = path.join(dir, 'fixture.ts'); + fs.writeFileSync(ambient, `declare const FOO: number;\n`); + fs.writeFileSync(fixture, `const z = FOO;\nexport {};\n`); + return { dir, markerPath, ambient, fixture }; +} + +function markerLineCount(markerPath: string): number { + if (!fs.existsSync(markerPath)) return 0; + return fs.readFileSync(markerPath, 'utf8').split('\n').filter(Boolean).length; +} + +// ── Test 7 (layer 2): --incremental skips type-aware rule on warm run ─── +{ + const { dir, markerPath } = makeTypeAwareFixture(); + try { + runCli(dir, '--incremental'); + const afterCold = markerLineCount(markerPath); + check('cold --incremental ran rule once', afterCold === 1); + + runCli(dir, '--incremental'); + const afterWarm = markerLineCount(markerPath); + check( + 'warm --incremental did NOT re-run rule (layer 2 cache hit)', + afterWarm === 1, + `expected 1 marker line, got ${afterWarm}`, + ); + } + finally { + fs.rmSync(dir, { recursive: true, force: true }); + } +} + +// ── Test 8 (layer 2): editing ambient.d.ts forces re-run on dependent ─── +// +// The killer case for layer 2: ambient declaration edits don't move the +// dependent file's mtime, so layer 1 alone would silently serve stale +// type-aware results. The cross-session affected-file diff (content hash +// + transitive deps) must catch this. +{ + const { dir, markerPath, ambient } = makeTypeAwareFixture(); + try { + runCli(dir, '--incremental'); + check('cold ran rule once', markerLineCount(markerPath) === 1); + + // Mutate the ambient declaration. fixture.ts's text doesn't change. + fs.writeFileSync(ambient, `declare const FOO: string;\n`); + const t = new Date(Date.now() + 60_000); + fs.utimesSync(ambient, t, t); + + runCli(dir, '--incremental'); + check( + 'ambient edit forced fixture.ts re-lint (layer 2 invalidation)', + markerLineCount(markerPath) === 2, + `expected 2 marker lines after ambient edit, got ${markerLineCount(markerPath)}`, + ); + + // And the cache should re-hit again on the next warm run. + runCli(dir, '--incremental'); + check( + 'warm after ambient edit cache-hits again', + markerLineCount(markerPath) === 2, + ); + } + finally { + fs.rmSync(dir, { recursive: true, force: true }); + } +} + +// ── Test 9 (layer 2): without --incremental, type-aware rule always runs ─ +{ + const { dir, markerPath } = makeTypeAwareFixture(); + try { + runCli(dir); + check('cold ran rule once (no --incremental)', markerLineCount(markerPath) === 1); + + runCli(dir); + check( + 'warm without --incremental re-ran type-aware rule (no layer 2)', + markerLineCount(markerPath) === 2, + `expected 2 marker lines, got ${markerLineCount(markerPath)} — type-aware rules without layer 2 are not cached`, + ); } finally { fs.rmSync(dir, { recursive: true, force: true }); From 68140be06fd1a08d15457e0b92f8fa374b6cfefa Mon Sep 17 00:00:00 2001 From: Johnson Chu Date: Sat, 2 May 2026 17:55:47 +0800 Subject: [PATCH 15/32] =?UTF-8?q?fix(cli):=20incremental=20state=20size=20?= =?UTF-8?q?=E2=80=94=20path=20interning=20+=20user-file=20filter?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two layered fixes after the layer 2 wiring crashed `JSON.stringify` on Dify (5867 source files, 16M+ transitive dep references → 80MB+ serialized payload, past V8's max string length). 1. Path interning. Every file path stored once in a `paths` table; the per-file entry uses integer indices in its `deps` list. On the same Dify program: ~5× compression vs the verbose form, and numbers serialize faster than strings. 2. Track only user files. node_modules and lib files aren't tracked; their changes don't propagate via this state. Trade-off: a `pnpm install` between `tsslint --incremental` runs serves stale type-aware results until `--force`. Acceptable for CLI use; lib changes are already covered by the cache path key (`compilerOptions.lib` participates in tsconfig+config mtime). Schema bump to `v2` (cache file's `incrementalState.version`). Old "v1" state files are treated as cache-misses on load — by-design, since the path-list/dep-list shape is incompatible. Smoke-tested on Dify web/ (5867 files, react-x/no-leaked-conditional- rendering rule, --incremental): - cold: 19.6 s, cache file 17 MB (vs 80MB+ pre-fix, would crash) - warm (no edits): 11.9 s — ~1.6× speedup from layer 2 cache hits Tests rewritten for the interned shape — 18 checks unchanged in spirit (test 8 renamed from "hash collision in deps" to "independent file change doesn't false-positive" — its actual intent). --- packages/cli/lib/incremental-state.ts | 158 +++++++++++++------- packages/cli/test/incremental-state.test.ts | 97 ++++++------ 2 files changed, 153 insertions(+), 102 deletions(-) diff --git a/packages/cli/lib/incremental-state.ts b/packages/cli/lib/incremental-state.ts index ba7dd508..19ef5274 100644 --- a/packages/cli/lib/incremental-state.ts +++ b/packages/cli/lib/incremental-state.ts @@ -3,29 +3,55 @@ // the next session can compute which files' type-relevant inputs have // moved. // -// Why not `.tsbuildinfo`? TS's `BuilderProgram` reads/writes that file -// via internal APIs (`getProgramBuildInfo` / `createBuilderProgramUsing- -// ProgramBuildInfo`), and the format is explicitly TS-major-coupled. -// Going through the public `getAllDependencies` + a content-hash digest -// trades some cache hit rate (we invalidate on body-only edits where -// TS's shape signatures wouldn't) for a sustainable contract that -// doesn't break on TS upgrades. +// Path interning: every unique file path is stored once in a `paths` +// table and referenced by integer index in deps lists. A naive +// representation balloons past V8's max string length on Dify-scale +// projects (5867 source files × hundreds of transitive deps each → +// multi-hundred-MB JSON). With interning, paths are written once and +// deps become number arrays — fits comfortably under any practical +// limit and serialises faster. +// +// Why not `.tsbuildinfo`? TS reads/writes it via `getProgramBuildInfo` / +// `createBuilderProgramUsingProgramBuildInfo`, both internal and TS- +// major-coupled. Going through public `BuilderProgram.getAllDependencies` +// + a content-hash digest trades some hit rate (we invalidate on body- +// only edits where TS's shape signatures wouldn't) for a contract that +// survives TS upgrades. import type * as ts from 'typescript'; export interface IncrementalState { version: string; - files: Record; } -export const INCREMENTAL_STATE_VERSION = 'v1'; +export const INCREMENTAL_STATE_VERSION = 'v2'; + +// Files we don't track. node_modules content changes after `pnpm install` +// / `npm install`, which users typically pair with `--force` or a fresh +// CI run. Tracking node_modules deps blows the JSON past V8's max string +// length even with path interning (Dify-scale: 16M dep refs → 80MB+). +// Lib files only change when `compilerOptions.lib` flips, already +// covered by the cache path key. +// +// The trade-off: a `pnpm install` that bumps `@types/*` between two +// `tsslint --incremental` runs serves stale type-aware results until +// the next `--force`. Acceptable for CLI use; documented in CACHE.md. +function isUserFile(sf: ts.SourceFile, program: ts.Program): boolean { + if (program.isSourceFileDefaultLibrary(sf)) return false; + if (sf.fileName.includes('/node_modules/')) return false; + return true; +} // Build a fresh state snapshot from a wrapped BuilderProgram, to save // alongside the cache file. Called after the lint pass. @@ -34,40 +60,57 @@ export const INCREMENTAL_STATE_VERSION = 'v1'; // script-mode `.d.ts`) don't show up in any specific file's // `getAllDependencies` because no file explicitly imports them — they // connect via global scope. To catch their edits, we treat every -// non-lib script-mode `.d.ts` as a universal dep. Lib files are -// excluded because they only change when `compilerOptions.lib` flips, -// which already invalidates the entire cache file via the path key. +// user-controlled script-mode `.d.ts` as a universal dep. export function buildIncrementalState( builder: ts.BuilderProgram, hash: (s: string) => string, ): IncrementalState { const program = builder.getProgram(); + const sourceFiles = program.getSourceFiles().filter(sf => isUserFile(sf, program)); + + // Build the path table — one entry per tracked user file. The index + // of each file's path becomes its key in `files` and its identifier + // in every dep list. node_modules / lib are excluded — see + // `isUserFile` for rationale. + const paths: string[] = []; + const pathIndex = new Map(); + for (const sf of sourceFiles) { + const i = paths.length; + paths.push(sf.fileName); + pathIndex.set(sf.fileName, i); + } + // Detect script-mode .d.ts via `externalModuleIndicator`. Field is - // internal in TS's public types but stable at runtime — used by tools - // across the ecosystem (typescript-eslint, ts-morph) for the same - // reason. The public `ts.isExternalModule` check would work too but - // is itself runtime-only at the API level. - const ambients: string[] = []; - for (const sf of program.getSourceFiles()) { + // internal in TS's public types but stable at runtime — used by + // typescript-eslint and ts-morph for the same purpose. + const ambientIndices: number[] = []; + for (const sf of sourceFiles) { if ( sf.isDeclarationFile && !(sf as { externalModuleIndicator?: unknown }).externalModuleIndicator - && !program.isSourceFileDefaultLibrary(sf) ) { - ambients.push(sf.fileName); + ambientIndices.push(pathIndex.get(sf.fileName)!); } } const files: IncrementalState['files'] = {}; - for (const sf of program.getSourceFiles()) { - const deps = new Set(builder.getAllDependencies(sf)); - for (const a of ambients) deps.add(a); - files[sf.fileName] = { + for (const sf of sourceFiles) { + const idx = pathIndex.get(sf.fileName)!; + const depSet = new Set(); + // Filter transitive deps to user files only — node_modules deps + // would explode the cache size (transitive @types/* alone runs + // into millions of references on monorepo-scale projects). + for (const d of builder.getAllDependencies(sf)) { + const di = pathIndex.get(d); + if (di !== undefined) depSet.add(di); + } + for (const a of ambientIndices) depSet.add(a); + files[String(idx)] = { contentHash: hash(sf.text), - deps: [...deps], + deps: [...depSet], }; } - return { version: INCREMENTAL_STATE_VERSION, files }; + return { version: INCREMENTAL_STATE_VERSION, paths, files }; } // Diff a previous state against the current program to figure out which @@ -88,42 +131,55 @@ export function computeAffectedFiles( return affected; } - // Step 1: hash every current file. Files whose own content moved go - // straight into `changed`. Files newly added (no prev entry) go into - // `affected` directly — we have nothing cached for them anyway. + // Resolve `prev.paths[idx]` lazily. Building a Map for + // reverse lookup pays off: we hit it once per current source file + // (to find prev entry) and once per dep index (to read current hash). + const prevPathToIdx = new Map(); + for (let i = 0; i < prev.paths.length; i++) { + prevPathToIdx.set(prev.paths[i], i); + } + + // Step 1: hash every current file. Track which prev indices have + // content that moved — the propagation step uses this set. const currentHashes = new Map(); - const changed = new Set(); + const changedPrevIdx = new Set(); for (const sf of sourceFiles) { const h = hash(sf.text); currentHashes.set(sf.fileName, h); - const prevEntry = prev.files[sf.fileName]; - if (!prevEntry) { + const pi = prevPathToIdx.get(sf.fileName); + if (pi === undefined) { + // New file — no prior entry. Affected; nothing to record in + // `changedPrevIdx` because no prev consumer could list it. affected.add(sf.fileName); + continue; } - else if (prevEntry.contentHash !== h) { - changed.add(sf.fileName); + const prevEntry = prev.files[String(pi)]; + if (!prevEntry || prevEntry.contentHash !== h) { + changedPrevIdx.add(pi); affected.add(sf.fileName); } } - // Files removed from the program also count as a change — anyone who - // listed them in `deps` is now affected. - for (const prevName of Object.keys(prev.files)) { - if (!currentHashes.has(prevName)) { - changed.add(prevName); + // Files removed from the program also count as changed — anyone who + // listed them in deps is now affected. + for (let i = 0; i < prev.paths.length; i++) { + if (!currentHashes.has(prev.paths[i])) { + changedPrevIdx.add(i); } } // Step 2: propagate. A file is affected if any of its prior deps - // landed in `changed`. We use the prev session's dep list — if the - // dep graph itself changed (file F gained or lost an import), F's - // own content moved, so it's already in `changed` → propagating from - // stale deps stays sound. + // (by index) landed in `changedPrevIdx`. We use the prev session's + // dep list — if the dep graph itself changed (file F gained or lost + // an import), F's own content moved, so F is already in `affected` + // → propagating from stale deps stays sound. for (const sf of sourceFiles) { if (affected.has(sf.fileName)) continue; - const prevEntry = prev.files[sf.fileName]; - if (!prevEntry) continue; // already added above + const pi = prevPathToIdx.get(sf.fileName); + if (pi === undefined) continue; // already added + const prevEntry = prev.files[String(pi)]; + if (!prevEntry) continue; for (const dep of prevEntry.deps) { - if (changed.has(dep)) { + if (changedPrevIdx.has(dep)) { affected.add(sf.fileName); break; } diff --git a/packages/cli/test/incremental-state.test.ts b/packages/cli/test/incremental-state.test.ts index 9e5878cb..51d86f01 100644 --- a/packages/cli/test/incremental-state.test.ts +++ b/packages/cli/test/incremental-state.test.ts @@ -31,6 +31,23 @@ function fakeHash(s: string): string { return String(h); } +// Build an interned IncrementalState from a friendlier path-keyed +// representation. Keeps tests readable. +function makeState( + entries: Array<{ name: string; hash: string; deps: string[] }>, +): IncrementalState { + const paths = entries.map(e => e.name); + const idx = new Map(paths.map((p, i) => [p, i])); + const files: IncrementalState['files'] = {}; + for (let i = 0; i < entries.length; i++) { + files[String(i)] = { + contentHash: entries[i].hash, + deps: entries[i].deps.map(d => idx.get(d)!), + }; + } + return { version: inc.INCREMENTAL_STATE_VERSION, paths, files }; +} + // Build a minimal Program from in-memory file map. Lib not needed for // these tests — we only exercise getSourceFiles / sf.fileName / sf.text. function buildProgram(files: Record): ts.Program { @@ -71,10 +88,7 @@ function buildProgram(files: Record): ts.Program { // ── Test 2: state version mismatch → all affected ─────────────────────── { const program = buildProgram({ '/a.ts': 'const x = 1;' }); - const stale: IncrementalState = { - version: 'v0', - files: { '/a.ts': { contentHash: fakeHash('const x = 1;'), deps: ['/a.ts'] } }, - } as any; + const stale = { ...makeState([{ name: '/a.ts', hash: fakeHash('const x = 1;'), deps: ['/a.ts'] }]), version: 'v0' }; const affected = inc.computeAffectedFiles(stale, program, fakeHash); check('schema bump → all affected', affected.has('/a.ts')); } @@ -85,15 +99,11 @@ function buildProgram(files: Record): ts.Program { // "everything matches" check we mirror that into prev too. { const program = buildProgram({ '/a.ts': 'const x = 1;', '/b.ts': 'const y = 2;' }); - const prev: IncrementalState = { - version: inc.INCREMENTAL_STATE_VERSION, - files: Object.fromEntries( - program.getSourceFiles().map(sf => [sf.fileName, { - contentHash: fakeHash(sf.text), - deps: [sf.fileName], - }]), - ), - }; + const prev = makeState(program.getSourceFiles().map(sf => ({ + name: sf.fileName, + hash: fakeHash(sf.text), + deps: [sf.fileName], + }))); const affected = inc.computeAffectedFiles(prev, program, fakeHash); check('a.ts NOT affected (hash matches)', !affected.has('/a.ts')); check('b.ts NOT affected (hash matches)', !affected.has('/b.ts')); @@ -109,13 +119,10 @@ function buildProgram(files: Record): ts.Program { '/a.ts': 'const x = 1;', '/b.ts': 'const y = 99;', }); - const prev: IncrementalState = { - version: inc.INCREMENTAL_STATE_VERSION, - files: { - '/a.ts': { contentHash: fakeHash('const x = 1;'), deps: ['/a.ts'] }, - '/b.ts': { contentHash: fakeHash('const y = 2;'), deps: ['/a.ts', '/b.ts'] }, - }, - }; + const prev = makeState([ + { name: '/a.ts', hash: fakeHash('const x = 1;'), deps: ['/a.ts'] }, + { name: '/b.ts', hash: fakeHash('const y = 2;'), deps: ['/a.ts', '/b.ts'] }, + ]); const affected = inc.computeAffectedFiles(prev, program, fakeHash); check('b.ts affected (own content changed)', affected.has('/b.ts')); check('a.ts NOT affected (unchanged)', !affected.has('/a.ts')); @@ -133,15 +140,12 @@ function buildProgram(files: Record): ts.Program { '/use2.ts': 'const b = FOO;', '/standalone.ts': 'const c = 42;', }); - const prev: IncrementalState = { - version: inc.INCREMENTAL_STATE_VERSION, - files: { - '/globals.d.ts': { contentHash: fakeHash('declare const FOO: number;'), deps: ['/globals.d.ts'] }, - '/use1.ts': { contentHash: fakeHash('const a = FOO;'), deps: ['/globals.d.ts', '/use1.ts'] }, - '/use2.ts': { contentHash: fakeHash('const b = FOO;'), deps: ['/globals.d.ts', '/use2.ts'] }, - '/standalone.ts': { contentHash: fakeHash('const c = 42;'), deps: ['/standalone.ts'] }, - }, - }; + const prev = makeState([ + { name: '/globals.d.ts', hash: fakeHash('declare const FOO: number;'), deps: ['/globals.d.ts'] }, + { name: '/use1.ts', hash: fakeHash('const a = FOO;'), deps: ['/globals.d.ts', '/use1.ts'] }, + { name: '/use2.ts', hash: fakeHash('const b = FOO;'), deps: ['/globals.d.ts', '/use2.ts'] }, + { name: '/standalone.ts', hash: fakeHash('const c = 42;'), deps: ['/standalone.ts'] }, + ]); const affected = inc.computeAffectedFiles(prev, program, fakeHash); check('globals.d.ts affected (own change)', affected.has('/globals.d.ts')); check('use1.ts affected (dep changed)', affected.has('/use1.ts')); @@ -158,12 +162,9 @@ function buildProgram(files: Record): ts.Program { '/old.ts': 'const o = 1;', '/new.ts': 'const n = 2;', }); - const prev: IncrementalState = { - version: inc.INCREMENTAL_STATE_VERSION, - files: { - '/old.ts': { contentHash: fakeHash('const o = 1;'), deps: ['/old.ts'] }, - }, - }; + const prev = makeState([ + { name: '/old.ts', hash: fakeHash('const o = 1;'), deps: ['/old.ts'] }, + ]); const affected = inc.computeAffectedFiles(prev, program, fakeHash); check('new.ts affected (newly added)', affected.has('/new.ts')); check('old.ts NOT affected (unchanged)', !affected.has('/old.ts')); @@ -178,13 +179,10 @@ function buildProgram(files: Record): ts.Program { const program = buildProgram({ '/still-here.ts': 'const z = 3;', }); - const prev: IncrementalState = { - version: inc.INCREMENTAL_STATE_VERSION, - files: { - '/removed.ts': { contentHash: fakeHash('export const r = 1;'), deps: ['/removed.ts'] }, - '/still-here.ts': { contentHash: fakeHash('const z = 3;'), deps: ['/removed.ts', '/still-here.ts'] }, - }, - }; + const prev = makeState([ + { name: '/removed.ts', hash: fakeHash('export const r = 1;'), deps: ['/removed.ts'] }, + { name: '/still-here.ts', hash: fakeHash('const z = 3;'), deps: ['/removed.ts', '/still-here.ts'] }, + ]); const affected = inc.computeAffectedFiles(prev, program, fakeHash); check( 'still-here.ts affected (dep removed)', @@ -193,7 +191,7 @@ function buildProgram(files: Record): ts.Program { ); } -// ── Test 8: hash collision in deps doesn't false-positive ─────────────── +// ── Test 8: independent file change doesn't false-positive ────────────── // // File A has dep file C. C is unchanged. A's content unchanged. Even // though something else (D) changed, A is unaffected. @@ -203,14 +201,11 @@ function buildProgram(files: Record): ts.Program { '/c.ts': 'const c = 1;', '/d.ts': 'const d = 99;', // changed }); - const prev: IncrementalState = { - version: inc.INCREMENTAL_STATE_VERSION, - files: { - '/a.ts': { contentHash: fakeHash('const x = 1;'), deps: ['/c.ts', '/a.ts'] }, - '/c.ts': { contentHash: fakeHash('const c = 1;'), deps: ['/c.ts'] }, - '/d.ts': { contentHash: fakeHash('const d = 1;'), deps: ['/d.ts'] }, - }, - }; + const prev = makeState([ + { name: '/a.ts', hash: fakeHash('const x = 1;'), deps: ['/c.ts', '/a.ts'] }, + { name: '/c.ts', hash: fakeHash('const c = 1;'), deps: ['/c.ts'] }, + { name: '/d.ts', hash: fakeHash('const d = 1;'), deps: ['/d.ts'] }, + ]); const affected = inc.computeAffectedFiles(prev, program, fakeHash); check('a.ts NOT affected (deps unchanged, own unchanged)', !affected.has('/a.ts')); check('d.ts affected (own content changed)', affected.has('/d.ts')); From 1c4c888f7496a62c4b0113c8ed9ab51465c82f28 Mon Sep 17 00:00:00 2001 From: Johnson Chu Date: Sat, 2 May 2026 18:20:15 +0800 Subject: [PATCH 16/32] refactor(cli): swap layer 2 state to TS internal incremental API MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The previous approach (path-interned content-hash + dep graph in 68140be) shipped working but with two limitations: - 17MB cache file on Dify (vs 3.6MB now) - drops node_modules dep tracking to fit; ambient declarations needed an explicit special case in code Both stem from rebuilding what TS already does internally for `tsc --incremental`. Switching to the underlying internal API (`getBuildInfo`, `createBuilderProgramUsingIncrementalBuildInfo`) gets all of TS's machinery for free: shape signatures, ambient detection, module augmentation, project references. Mechanism: BuilderProgram emits its state via `emitBuildInfo` to a synthetic `tsBuildInfoFile` path; we capture the text via the host's `writeFile` callback and stash it in `IncrementalState.tsBuildInfoText`. Next session reads it back through `getBuildInfo` and feeds the result to `createBuilderProgramUsingIncrementalBuildInfo`, producing an `oldBuilder` we pass to the new BP. Risk surface (documented in incremental-state.ts): - These two functions are not in `typescript.d.ts`. Stable across TS 5.x → 6.x but no public contract. The cache file path key already includes `ts.version`, so version drift gives a clean miss instead of corruption. - On any deserialization failure (parse error, version mismatch, schema bump), `reconstructOldBuilder` returns `undefined` — caller treats as cold start. The "miss is always safe" invariant. Schema bump to `v3` (the path-keyed `paths`/`files` structure is gone — now just `{ version, tsBuildInfoText }`). Old `v2` state files are treated as misses on load. Worker side: - `getScriptVersion` now falls back to file mtime (was always '0' for unmutated files). Without this, BP couldn't tell that a file's content changed across sessions. In-session bumps from `--fix` still win — they reflect content changes the BP needs to see immediately. - Setup: when `--incremental`, override `compilerOptions.incremental` + `tsBuildInfoFile` (synthetic path). User's own `tsc --incremental` builds use a different path; no collision. - One BP (cached as `currentBuilder`) covers both the affected-file diff at setup and the buildinfo capture at end of session. Dify-scale numbers (5867 files, react-x/no-leaked-conditional-rendering): cold (--incremental): ~58s ← +38s drain cost vs no-incremental warm (--incremental): ~6s ← BP says 0 affected, type-aware rules cache-hit no-incremental: ~10s ← layer 1 only Cold-time penalty is real: BP's `getSemanticDiagnosticsOfNextAffectedFile` forces full semantic-check work that TSSLint's regular lint pass doesn't trigger. CI workloads that always cold-start should NOT use `--incremental`. Repeat dev workflows pay it once and amortize over N warm runs. Tests: - incremental-state.test.ts rewritten for new shape (10 checks): state captured, version stamp, round-trip identical-program → 0 affected, round-trip with edited dep → consumer in affected, undefined / version-mismatch / corrupted-text → cold-start fallback. - integration.test.ts: cache-shape assertion updated (look for `tsBuildInfoText`, not `files`/`paths`). - All other layer 2 integration scenarios (test 7-9: cache hit on warm, ambient-edit invalidation, mode-A re-run) pass unchanged. Out: custom path table, content hashes, ambient-as-universal-dep workaround, 200 LOC of dep-graph plumbing. --- packages/cli/lib/incremental-state.ts | 263 +++++++----------- packages/cli/lib/worker.ts | 80 ++++-- packages/cli/test/incremental-state.test.ts | 280 +++++++++----------- packages/cli/test/integration.test.ts | 3 +- 4 files changed, 277 insertions(+), 349 deletions(-) diff --git a/packages/cli/lib/incremental-state.ts b/packages/cli/lib/incremental-state.ts index 19ef5274..9d92f6f9 100644 --- a/packages/cli/lib/incremental-state.ts +++ b/packages/cli/lib/incremental-state.ts @@ -1,190 +1,111 @@ -// Layer 2 cross-session state. Persists per-file content hashes and -// transitive dependency lists between `tsslint --incremental` runs so -// the next session can compute which files' type-relevant inputs have -// moved. +// Layer 2 cross-session state. Wraps TypeScript's internal +// `getBuildInfo` / `createBuilderProgramUsingIncrementalBuildInfo` +// so two `tsslint --incremental` runs can share BuilderProgram state +// across processes via TS's own `tsBuildInfoFile` format. // -// Path interning: every unique file path is stored once in a `paths` -// table and referenced by integer index in deps lists. A naive -// representation balloons past V8's max string length on Dify-scale -// projects (5867 source files × hundreds of transitive deps each → -// multi-hundred-MB JSON). With interning, paths are written once and -// deps become number arrays — fits comfortably under any practical -// limit and serialises faster. +// Why internal API: the public `BuilderProgram` interface only takes +// an `oldProgram` of type `SemanticDiagnosticsBuilderProgram` — there's +// no public path from disk back to BP. Manually serialising via +// `getAllDependencies` + content hash works (we shipped that in +// 68140be) but produces 17MB+ JSON on Dify-scale projects, drops +// node_modules tracking to fit, and uses content hashes where TS +// would use shape signatures. The internal route gives: +// - shape signatures (smarter than content hash — body-only edits +// don't propagate) +// - bit-packed compact format (380 bytes for a 3-file program in +// our spike, vs kilobytes manual) +// - node_modules tracking included +// - all the edge cases TS already debugged: ambient declarations, +// module augmentations, lib changes, project references // -// Why not `.tsbuildinfo`? TS reads/writes it via `getProgramBuildInfo` / -// `createBuilderProgramUsingProgramBuildInfo`, both internal and TS- -// major-coupled. Going through public `BuilderProgram.getAllDependencies` -// + a content-hash digest trades some hit rate (we invalidate on body- -// only edits where TS's shape signatures wouldn't) for a contract that -// survives TS upgrades. +// Risk surface: +// - `getBuildInfo`, `createBuilderProgramUsingIncrementalBuildInfo` +// are not in `typescript.d.ts`. They've been stable across TS +// 5.x → 6.x but no contract. Cache key already includes +// `ts.version`, so version skew gives a clean miss instead of +// corrupted state. +// - On parse / shape mismatch, treat as cold start (the standard +// "miss is always safe" invariant). import type * as ts from 'typescript'; +// Synthetic path used as the `tsBuildInfoFile` value passed to TS. +// TS uses this only to resolve relative paths inside the buildinfo +// payload — we never actually write here on disk; the buildinfo text +// is captured via the BuilderProgramHost's `writeFile` callback and +// stored in `IncrementalState.tsBuildInfoText`. +export const SYNTHETIC_BUILD_INFO_PATH = '/__tsslint__.tsbuildinfo'; + export interface IncrementalState { version: string; - // Interned path table. Indices into this array are used in `files` - // keys and `deps` values throughout the rest of the structure. - paths: string[]; - // `files[i]` is the entry for the file at `paths[i]`. Sparse: only - // indices for source files actually present in the program at save - // time appear. Deps are indices into `paths` — typically a much - // smaller integer payload than the full path strings repeated. - files: Record; + // Raw text TS wrote via `BuilderProgram.emitBuildInfo`. Opaque to + // us — fed straight back to `ts.getBuildInfo` on the next session. + tsBuildInfoText: string; } -export const INCREMENTAL_STATE_VERSION = 'v2'; +// Format version. Bump if we ever change what we store alongside +// `tsBuildInfoText`. The internal TS format itself is keyed on +// `ts.version` via the cache file's path component. +export const INCREMENTAL_STATE_VERSION = 'v3'; -// Files we don't track. node_modules content changes after `pnpm install` -// / `npm install`, which users typically pair with `--force` or a fresh -// CI run. Tracking node_modules deps blows the JSON past V8's max string -// length even with path interning (Dify-scale: 16M dep refs → 80MB+). -// Lib files only change when `compilerOptions.lib` flips, already -// covered by the cache path key. -// -// The trade-off: a `pnpm install` that bumps `@types/*` between two -// `tsslint --incremental` runs serves stale type-aware results until -// the next `--force`. Acceptable for CLI use; documented in CACHE.md. -function isUserFile(sf: ts.SourceFile, program: ts.Program): boolean { - if (program.isSourceFileDefaultLibrary(sf)) return false; - if (sf.fileName.includes('/node_modules/')) return false; - return true; +interface IncrementalAccess { + getBuildInfo(file: string, text: string): unknown | undefined; + createBuilderProgramUsingIncrementalBuildInfo( + buildInfo: unknown, + buildInfoPath: string, + host: { useCaseSensitiveFileNames(): boolean; getCurrentDirectory(): string }, + ): ts.BuilderProgram; } -// Build a fresh state snapshot from a wrapped BuilderProgram, to save -// alongside the cache file. Called after the lint pass. -// -// Ambient `.d.ts` files (`declare global`, top-level declarations in -// script-mode `.d.ts`) don't show up in any specific file's -// `getAllDependencies` because no file explicitly imports them — they -// connect via global scope. To catch their edits, we treat every -// user-controlled script-mode `.d.ts` as a universal dep. -export function buildIncrementalState( - builder: ts.BuilderProgram, - hash: (s: string) => string, -): IncrementalState { - const program = builder.getProgram(); - const sourceFiles = program.getSourceFiles().filter(sf => isUserFile(sf, program)); - - // Build the path table — one entry per tracked user file. The index - // of each file's path becomes its key in `files` and its identifier - // in every dep list. node_modules / lib are excluded — see - // `isUserFile` for rationale. - const paths: string[] = []; - const pathIndex = new Map(); - for (const sf of sourceFiles) { - const i = paths.length; - paths.push(sf.fileName); - pathIndex.set(sf.fileName, i); - } - - // Detect script-mode .d.ts via `externalModuleIndicator`. Field is - // internal in TS's public types but stable at runtime — used by - // typescript-eslint and ts-morph for the same purpose. - const ambientIndices: number[] = []; - for (const sf of sourceFiles) { - if ( - sf.isDeclarationFile - && !(sf as { externalModuleIndicator?: unknown }).externalModuleIndicator - ) { - ambientIndices.push(pathIndex.get(sf.fileName)!); - } - } - - const files: IncrementalState['files'] = {}; - for (const sf of sourceFiles) { - const idx = pathIndex.get(sf.fileName)!; - const depSet = new Set(); - // Filter transitive deps to user files only — node_modules deps - // would explode the cache size (transitive @types/* alone runs - // into millions of references on monorepo-scale projects). - for (const d of builder.getAllDependencies(sf)) { - const di = pathIndex.get(d); - if (di !== undefined) depSet.add(di); - } - for (const a of ambientIndices) depSet.add(a); - files[String(idx)] = { - contentHash: hash(sf.text), - deps: [...depSet], - }; - } - return { version: INCREMENTAL_STATE_VERSION, paths, files }; +// Cast helper. Keeps the unsafe access concentrated and easy to find +// when TS upgrades. +export function asIncremental(ts: typeof import('typescript')): IncrementalAccess { + return ts as unknown as IncrementalAccess; } -// Diff a previous state against the current program to figure out which -// files' type-relevant inputs are affected since the last session. The -// result feeds `cacheFlow.lintWithCache(..., { typeAwareUnaffected })`: -// a file NOT in the affected set has unchanged dep hashes, so its -// type-aware rule cache entries are still valid. -export function computeAffectedFiles( +// Reconstruct an old BuilderProgram from a previously captured +// `tsBuildInfoText`. Used as the `oldProgram` argument when creating +// the current session's BP. Returns undefined on any deserialization +// failure (cold-start fallback). +export function reconstructOldBuilder( + ts: typeof import('typescript'), prev: IncrementalState | undefined, - program: ts.Program, - hash: (s: string) => string, -): Set { - const affected = new Set(); - const sourceFiles = program.getSourceFiles(); - if (!prev || prev.version !== INCREMENTAL_STATE_VERSION) { - // No prior state (or schema bump): every file is affected. - for (const sf of sourceFiles) affected.add(sf.fileName); - return affected; + host: { useCaseSensitiveFileNames(): boolean; getCurrentDirectory(): string }, +): ts.BuilderProgram | undefined { + if (!prev || prev.version !== INCREMENTAL_STATE_VERSION) return undefined; + try { + const api = asIncremental(ts); + const buildInfo = api.getBuildInfo(SYNTHETIC_BUILD_INFO_PATH, prev.tsBuildInfoText); + if (!buildInfo) return undefined; + return api.createBuilderProgramUsingIncrementalBuildInfo( + buildInfo, + SYNTHETIC_BUILD_INFO_PATH, + host, + ); } - - // Resolve `prev.paths[idx]` lazily. Building a Map for - // reverse lookup pays off: we hit it once per current source file - // (to find prev entry) and once per dep index (to read current hash). - const prevPathToIdx = new Map(); - for (let i = 0; i < prev.paths.length; i++) { - prevPathToIdx.set(prev.paths[i], i); - } - - // Step 1: hash every current file. Track which prev indices have - // content that moved — the propagation step uses this set. - const currentHashes = new Map(); - const changedPrevIdx = new Set(); - for (const sf of sourceFiles) { - const h = hash(sf.text); - currentHashes.set(sf.fileName, h); - const pi = prevPathToIdx.get(sf.fileName); - if (pi === undefined) { - // New file — no prior entry. Affected; nothing to record in - // `changedPrevIdx` because no prev consumer could list it. - affected.add(sf.fileName); - continue; - } - const prevEntry = prev.files[String(pi)]; - if (!prevEntry || prevEntry.contentHash !== h) { - changedPrevIdx.add(pi); - affected.add(sf.fileName); - } - } - // Files removed from the program also count as changed — anyone who - // listed them in deps is now affected. - for (let i = 0; i < prev.paths.length; i++) { - if (!currentHashes.has(prev.paths[i])) { - changedPrevIdx.add(i); - } - } - - // Step 2: propagate. A file is affected if any of its prior deps - // (by index) landed in `changedPrevIdx`. We use the prev session's - // dep list — if the dep graph itself changed (file F gained or lost - // an import), F's own content moved, so F is already in `affected` - // → propagating from stale deps stays sound. - for (const sf of sourceFiles) { - if (affected.has(sf.fileName)) continue; - const pi = prevPathToIdx.get(sf.fileName); - if (pi === undefined) continue; // already added - const prevEntry = prev.files[String(pi)]; - if (!prevEntry) continue; - for (const dep of prevEntry.deps) { - if (changedPrevIdx.has(dep)) { - affected.add(sf.fileName); - break; - } - } + catch { + return undefined; } +} - return affected; +// Capture a `tsBuildInfoText` from a live BuilderProgram. Triggers a +// `BuilderProgram.emitBuildInfo` with a writeFile callback that +// intercepts what TS would otherwise write to disk. +// +// `emitBuildInfo` is on the runtime `BuilderProgram` shape but not in +// the public d.ts (the public surface only exposes the `emit()` family +// that wraps it). Cast through `unknown` to silence the type error; +// runtime is stable. +export function captureIncrementalState( + builder: ts.BuilderProgram, +): IncrementalState | undefined { + let captured: string | undefined; + const builderAny = builder as unknown as { + emitBuildInfo(writeFile: (path: string, content: string) => void): void; + }; + builderAny.emitBuildInfo((_path, content) => { + captured = content; + }); + if (!captured) return undefined; + return { version: INCREMENTAL_STATE_VERSION, tsBuildInfoText: captured }; } diff --git a/packages/cli/lib/worker.ts b/packages/cli/lib/worker.ts index aa95bb5d..985ad0e5 100644 --- a/packages/cli/lib/worker.ts +++ b/packages/cli/lib/worker.ts @@ -26,13 +26,15 @@ let fileNames: string[] = []; let language: Language | undefined; let linter: core.Linter; let linterLanguageService!: ts.LanguageService; -// Layer 2 state. When `--incremental` is on, we diff the prior session's -// stored content hashes + transitive dep lists against the current -// program to decide which files' type-relevant inputs have moved. cache- -// flow consults this set to decide whether type-aware rules can be -// cache-hit. `undefined` = layer 1 only (cache-flow's default safe -// behavior). +// Layer 2 state. When `--incremental` is on, we wrap the LS program in +// a SemanticDiagnosticsBuilderProgram (with the prev session's BP fed +// back via TS's internal `tsBuildInfoText` round-trip) and walk +// affected files once. cache-flow consults this set to decide whether +// type-aware rules can be cache-hit. `undefined` = layer 1 only. let affectedFiles: Set | undefined; +// The current session's BP — held until end-of-project so we can +// capture its updated buildinfo text for next session's persistence. +let currentBuilder: ts.SemanticDiagnosticsBuilderProgram | undefined; const snapshots = new Map(); const versions = new Map(); @@ -54,7 +56,16 @@ const originalHost: ts.LanguageServiceHost = { return fileNames; }, getScriptVersion(fileName) { - return versions.get(fileName)?.toString() ?? '0'; + // In-session bumps win — `--fix` updates this map after writing + // the file. Otherwise fall back to the on-disk mtime so the + // version reflects content across CLI invocations. Layer 2's + // BuilderProgram diff relies on this — without it, every cross- + // session file looks unchanged (always '0') even when the + // content moved on disk. + const inSession = versions.get(fileName); + if (inSession !== undefined) return inSession.toString(); + const stat = fs.statSync(fileName, { throwIfNoEntry: false }); + return stat ? stat.mtimeMs.toString() : '0'; }, getScriptSnapshot(fileName) { if (!snapshots.has(fileName)) { @@ -182,6 +193,18 @@ async function setup( allowNonTsExtensions: true, } : _options; + if (incremental) { + // Internal API path: BuilderProgram.emitBuildInfo only produces + // content when these options are set. Override the user's values + // (their own tsc --incremental builds shouldn't share this file). + // The synthetic path is never written to disk — captured via + // writeFile callback at end of session. + options = { + ...options, + incremental: true, + tsBuildInfoFile: incrementalState.SYNTHETIC_BUILD_INFO_PATH, + }; + } linter = core.createLinter( { languageService: linterLanguageService, @@ -196,31 +219,46 @@ async function setup( if (incremental) { const program = linterLanguageService.getProgram()!; - affectedFiles = incrementalState.computeAffectedFiles( - prevIncrementalState, + // Reconstruct the prev session's BP from cached buildinfo text, + // fall through to undefined on any failure (cold-start path). + const oldBuilder = incrementalState.reconstructOldBuilder(ts, prevIncrementalState, { + useCaseSensitiveFileNames: () => ts.sys.useCaseSensitiveFileNames, + getCurrentDirectory: () => ts.sys.getCurrentDirectory(), + }); + currentBuilder = ts.createSemanticDiagnosticsBuilderProgram( program, - ts.sys.createHash ?? defaultHash, + { createHash: ts.sys.createHash ?? defaultHash }, + oldBuilder as ts.SemanticDiagnosticsBuilderProgram | undefined, ); + affectedFiles = new Set(); + while (true) { + const result = currentBuilder.getSemanticDiagnosticsOfNextAffectedFile(); + if (!result) break; + const a = result.affected; + if ('fileName' in a) { + affectedFiles.add(a.fileName); + } + else { + // Whole-program affected — config option flip, lib change. + // Conservatively mark every source file affected. + for (const sf of a.getSourceFiles()) affectedFiles.add(sf.fileName); + } + } } else { affectedFiles = undefined; + currentBuilder = undefined; } return true; } -// Build a fresh state snapshot to persist alongside the cache file. -// Called by the CLI at end of project, after the lint loop. Wraps the -// current LS program in a BuilderProgram once to harvest `getAll- -// Dependencies`, then hashes file texts. +// Capture the current session's BP state for persistence. Called by +// the CLI at end of project. Returns undefined when not in incremental +// mode or when capture fails. function buildIncrementalState(): IncrementalState | undefined { - if (!affectedFiles) return undefined; - const program = linterLanguageService.getProgram()!; - const builder = ts.createSemanticDiagnosticsBuilderProgram( - program, - { createHash: ts.sys.createHash }, - ); - return incrementalState.buildIncrementalState(builder, ts.sys.createHash ?? defaultHash); + if (!currentBuilder) return undefined; + return incrementalState.captureIncrementalState(currentBuilder); } function lint(fileName: string, fix: boolean, fileCache: FileCache, fileMtime: number) { diff --git a/packages/cli/test/incremental-state.test.ts b/packages/cli/test/incremental-state.test.ts index 51d86f01..04c4d05e 100644 --- a/packages/cli/test/incremental-state.test.ts +++ b/packages/cli/test/incremental-state.test.ts @@ -1,14 +1,13 @@ -// Tests for the layer 2 cross-session diff. Given a stored -// `IncrementalState` from a prior session and a current Program, the -// `computeAffectedFiles` function should return the set of files whose -// type-relevant inputs (own content, transitive deps incl. ambient -// `.d.ts`) have changed. +// Round-trip tests for the layer 2 cross-session state. Verifies that +// capturing a BP's state via TS internal `emitBuildInfo` produces a +// text the next session can feed back through +// `createBuilderProgramUsingIncrementalBuildInfo` to get an oldBP that +// correctly diffs the current program. // // Run via: // node packages/cli/test/incremental-state.test.js import * as ts from 'typescript'; -import type { IncrementalState } from '../lib/incremental-state.js'; const inc = require('../lib/incremental-state.js') as typeof import('../lib/incremental-state.js'); @@ -23,193 +22,162 @@ function check(name: string, cond: boolean, detail?: string) { } } -// Trivial deterministic hash for tests — we just need stable+collision-free -// for the strings we throw at it. -function fakeHash(s: string): string { - let h = 0; - for (let i = 0; i < s.length; i++) h = ((h << 5) - h + s.charCodeAt(i)) | 0; - return String(h); -} - -// Build an interned IncrementalState from a friendlier path-keyed -// representation. Keeps tests readable. -function makeState( - entries: Array<{ name: string; hash: string; deps: string[] }>, -): IncrementalState { - const paths = entries.map(e => e.name); - const idx = new Map(paths.map((p, i) => [p, i])); - const files: IncrementalState['files'] = {}; - for (let i = 0; i < entries.length; i++) { - files[String(i)] = { - contentHash: entries[i].hash, - deps: entries[i].deps.map(d => idx.get(d)!), - }; - } - return { version: inc.INCREMENTAL_STATE_VERSION, paths, files }; -} +const realLib = ts.getDefaultLibFilePath({ target: ts.ScriptTarget.Latest }); +const libContent = ts.sys.readFile(realLib) ?? ''; +const libName = realLib.split(/[\\/]/).pop()!; -// Build a minimal Program from in-memory file map. Lib not needed for -// these tests — we only exercise getSourceFiles / sf.fileName / sf.text. function buildProgram(files: Record): ts.Program { - const realLibPath = ts.getDefaultLibFilePath({ target: ts.ScriptTarget.Latest }); - const realLibContent = ts.sys.readFile(realLibPath) ?? ''; - const realLib = ts.createSourceFile(realLibPath, realLibContent, ts.ScriptTarget.Latest, true); - const sourceFiles = new Map(); - for (const [name, text] of Object.entries(files)) { - sourceFiles.set(name, ts.createSourceFile(name, text, ts.ScriptTarget.Latest, true)); - } const host: ts.CompilerHost = { - getSourceFile: n => sourceFiles.get(n) ?? (n === realLibPath ? realLib : undefined), - getDefaultLibFileName: () => realLibPath, + getSourceFile(name) { + if (name in files) { + const text = files[name]; + const sf = ts.createSourceFile(name, text, ts.ScriptTarget.Latest, true); + // BP requires a `version` field on each SourceFile. Production + // LS sets this from `host.getScriptVersion`; here we derive + // from content length so edited files get a different version + // (otherwise BP's diff sees no change and skips propagation). + (sf as unknown as { version: string }).version = String(text.length) + ':' + text.charCodeAt(0); + return sf; + } + if (name === realLib) { + const sf = ts.createSourceFile(realLib, libContent, ts.ScriptTarget.Latest, true); + (sf as unknown as { version: string }).version = String(libContent.length); + return sf; + } + return undefined; + }, + getDefaultLibFileName: () => realLib, writeFile: () => {}, getCurrentDirectory: () => '/', getDirectories: () => [], - fileExists: n => sourceFiles.has(n) || n === realLibPath, - readFile: n => files[n] ?? (n === realLibPath ? realLibContent : undefined), + fileExists: n => n in files || n === realLib, + readFile: n => files[n] ?? (n === realLib ? libContent : undefined), getCanonicalFileName: n => n, useCaseSensitiveFileNames: () => true, getNewLine: () => '\n', }; return ts.createProgram({ - rootNames: [...sourceFiles.keys()], - options: { target: ts.ScriptTarget.Latest, noEmit: true, lib: [realLibPath.split(/[\\/]/).pop()!] }, + rootNames: Object.keys(files), + options: { + target: ts.ScriptTarget.Latest, + noEmit: true, + incremental: true, + tsBuildInfoFile: inc.SYNTHETIC_BUILD_INFO_PATH, + lib: [libName], + }, host, }); } -// ── Test 1: no prior state → all source files are affected ────────────── -{ - const program = buildProgram({ '/a.ts': 'const x = 1;', '/b.ts': 'const y = 2;' }); - const affected = inc.computeAffectedFiles(undefined, program, fakeHash); - check('a.ts affected (no prev state)', affected.has('/a.ts')); - check('b.ts affected (no prev state)', affected.has('/b.ts')); +function affectedFileNames(builder: ts.SemanticDiagnosticsBuilderProgram): Set { + const set = new Set(); + while (true) { + const r = builder.getSemanticDiagnosticsOfNextAffectedFile(); + if (!r) break; + if ('fileName' in r.affected) set.add(r.affected.fileName); + else for (const sf of r.affected.getSourceFiles()) set.add(sf.fileName); + } + return set; } -// ── Test 2: state version mismatch → all affected ─────────────────────── -{ - const program = buildProgram({ '/a.ts': 'const x = 1;' }); - const stale = { ...makeState([{ name: '/a.ts', hash: fakeHash('const x = 1;'), deps: ['/a.ts'] }]), version: 'v0' }; - const affected = inc.computeAffectedFiles(stale, program, fakeHash); - check('schema bump → all affected', affected.has('/a.ts')); -} +const hostShim = { + useCaseSensitiveFileNames: () => true, + getCurrentDirectory: () => '/', +}; -// ── Test 3: identical state → no user files affected ──────────────────── -// -// lib.*.d.ts files would also be in `program.getSourceFiles()`. For the -// "everything matches" check we mirror that into prev too. +// ── Test 1: captureIncrementalState produces text on a fresh BP ───────── { - const program = buildProgram({ '/a.ts': 'const x = 1;', '/b.ts': 'const y = 2;' }); - const prev = makeState(program.getSourceFiles().map(sf => ({ - name: sf.fileName, - hash: fakeHash(sf.text), - deps: [sf.fileName], - }))); - const affected = inc.computeAffectedFiles(prev, program, fakeHash); - check('a.ts NOT affected (hash matches)', !affected.has('/a.ts')); - check('b.ts NOT affected (hash matches)', !affected.has('/b.ts')); - check('no user file affected', !affected.has('/a.ts') && !affected.has('/b.ts')); + const program = buildProgram({ '/a.ts': 'export const x: number = 1;' }); + const builder = ts.createSemanticDiagnosticsBuilderProgram( + program, + { createHash: ts.sys.createHash }, + ); + affectedFileNames(builder); // drain + const state = inc.captureIncrementalState(builder); + check('state captured', !!state); + check('state version is v3', state?.version === inc.INCREMENTAL_STATE_VERSION); + check('tsBuildInfoText is non-empty', !!state && state.tsBuildInfoText.length > 0); } -// ── Test 4: file content changed → only that file in affected ─────────── -// -// b.ts has /a.ts in its deps. a.ts unchanged. b.ts content changed. -// Only b.ts is affected. +// ── Test 2: reconstructOldBuilder + diff round-trip — identical program ─ { - const program = buildProgram({ - '/a.ts': 'const x = 1;', - '/b.ts': 'const y = 99;', + const program1 = buildProgram({ + '/a.ts': 'export const x: number = 1;', + '/b.ts': "import { x } from './a'; export const y = x + 1;", }); - const prev = makeState([ - { name: '/a.ts', hash: fakeHash('const x = 1;'), deps: ['/a.ts'] }, - { name: '/b.ts', hash: fakeHash('const y = 2;'), deps: ['/a.ts', '/b.ts'] }, - ]); - const affected = inc.computeAffectedFiles(prev, program, fakeHash); - check('b.ts affected (own content changed)', affected.has('/b.ts')); - check('a.ts NOT affected (unchanged)', !affected.has('/a.ts')); -} + const builder1 = ts.createSemanticDiagnosticsBuilderProgram( + program1, + { createHash: ts.sys.createHash }, + ); + affectedFileNames(builder1); + const captured = inc.captureIncrementalState(builder1)!; -// ── Test 5: dep file changed → all consumers affected ─────────────────── -// -// The killer case for layer 2: editing globals.d.ts (or any ambient file) -// must propagate to every file that listed it as a dep. That's what -// per-file mtime caching can't catch. -{ - const program = buildProgram({ - '/globals.d.ts': 'declare const FOO: string;', // changed from `: number;` - '/use1.ts': 'const a = FOO;', - '/use2.ts': 'const b = FOO;', - '/standalone.ts': 'const c = 42;', + const program2 = buildProgram({ + '/a.ts': 'export const x: number = 1;', + '/b.ts': "import { x } from './a'; export const y = x + 1;", }); - const prev = makeState([ - { name: '/globals.d.ts', hash: fakeHash('declare const FOO: number;'), deps: ['/globals.d.ts'] }, - { name: '/use1.ts', hash: fakeHash('const a = FOO;'), deps: ['/globals.d.ts', '/use1.ts'] }, - { name: '/use2.ts', hash: fakeHash('const b = FOO;'), deps: ['/globals.d.ts', '/use2.ts'] }, - { name: '/standalone.ts', hash: fakeHash('const c = 42;'), deps: ['/standalone.ts'] }, - ]); - const affected = inc.computeAffectedFiles(prev, program, fakeHash); - check('globals.d.ts affected (own change)', affected.has('/globals.d.ts')); - check('use1.ts affected (dep changed)', affected.has('/use1.ts')); - check('use2.ts affected (dep changed)', affected.has('/use2.ts')); + const oldBP = inc.reconstructOldBuilder(ts, captured, hostShim); + check('reconstruct produced an oldBP', !!oldBP); + const builder2 = ts.createSemanticDiagnosticsBuilderProgram( + program2, + { createHash: ts.sys.createHash }, + oldBP as ts.SemanticDiagnosticsBuilderProgram, + ); + const affected = affectedFileNames(builder2); check( - 'standalone.ts NOT affected (no globals.d.ts in deps)', - !affected.has('/standalone.ts'), + 'identical program → no user files affected', + !affected.has('/a.ts') && !affected.has('/b.ts'), + `got affected: ${[...affected].join(', ')}`, ); } -// ── Test 6: new file (in current, not prev) → affected ────────────────── +// ── Test 3: round-trip catches edits to imported file ─────────────────── { - const program = buildProgram({ - '/old.ts': 'const o = 1;', - '/new.ts': 'const n = 2;', + const program1 = buildProgram({ + '/a.ts': 'export const x: number = 1;', + '/b.ts': "import { x } from './a'; export const y = x + 1;", }); - const prev = makeState([ - { name: '/old.ts', hash: fakeHash('const o = 1;'), deps: ['/old.ts'] }, - ]); - const affected = inc.computeAffectedFiles(prev, program, fakeHash); - check('new.ts affected (newly added)', affected.has('/new.ts')); - check('old.ts NOT affected (unchanged)', !affected.has('/old.ts')); -} + const builder1 = ts.createSemanticDiagnosticsBuilderProgram( + program1, + { createHash: ts.sys.createHash }, + ); + affectedFileNames(builder1); + const captured = inc.captureIncrementalState(builder1)!; -// ── Test 7: removed file → its consumers are affected ─────────────────── -// -// /removed.ts is gone. /still-here.ts had it in deps. The consumer -// must re-check because its type info may have changed (import error, -// missing export, etc.). -{ - const program = buildProgram({ - '/still-here.ts': 'const z = 3;', + // /a.ts changes its public type — should propagate to /b.ts. + const program2 = buildProgram({ + '/a.ts': 'export const x: string = "1";', + '/b.ts': "import { x } from './a'; export const y = x + 1;", }); - const prev = makeState([ - { name: '/removed.ts', hash: fakeHash('export const r = 1;'), deps: ['/removed.ts'] }, - { name: '/still-here.ts', hash: fakeHash('const z = 3;'), deps: ['/removed.ts', '/still-here.ts'] }, - ]); - const affected = inc.computeAffectedFiles(prev, program, fakeHash); - check( - 'still-here.ts affected (dep removed)', - affected.has('/still-here.ts'), - 'consumer must re-check when a transitive dep disappears', + const oldBP = inc.reconstructOldBuilder(ts, captured, hostShim); + const builder2 = ts.createSemanticDiagnosticsBuilderProgram( + program2, + { createHash: ts.sys.createHash }, + oldBP as ts.SemanticDiagnosticsBuilderProgram, ); + const affected = affectedFileNames(builder2); + check('a.ts affected', affected.has('/a.ts')); + check('b.ts affected (importer of a.ts)', affected.has('/b.ts')); } -// ── Test 8: independent file change doesn't false-positive ────────────── -// -// File A has dep file C. C is unchanged. A's content unchanged. Even -// though something else (D) changed, A is unaffected. +// ── Test 4: undefined prev → cold start (oldBP undefined) ─────────────── { - const program = buildProgram({ - '/a.ts': 'const x = 1;', - '/c.ts': 'const c = 1;', - '/d.ts': 'const d = 99;', // changed - }); - const prev = makeState([ - { name: '/a.ts', hash: fakeHash('const x = 1;'), deps: ['/c.ts', '/a.ts'] }, - { name: '/c.ts', hash: fakeHash('const c = 1;'), deps: ['/c.ts'] }, - { name: '/d.ts', hash: fakeHash('const d = 1;'), deps: ['/d.ts'] }, - ]); - const affected = inc.computeAffectedFiles(prev, program, fakeHash); - check('a.ts NOT affected (deps unchanged, own unchanged)', !affected.has('/a.ts')); - check('d.ts affected (own content changed)', affected.has('/d.ts')); - check('c.ts NOT affected', !affected.has('/c.ts')); + const oldBP = inc.reconstructOldBuilder(ts, undefined, hostShim); + check('undefined prev → undefined oldBP', oldBP === undefined); +} + +// ── Test 5: schema version mismatch → cold start ──────────────────────── +{ + const stale = { version: 'v0', tsBuildInfoText: '' }; + const oldBP = inc.reconstructOldBuilder(ts, stale as any, hostShim); + check('version mismatch → undefined oldBP', oldBP === undefined); +} + +// ── Test 6: corrupted tsBuildInfoText → cold start ────────────────────── +{ + const corrupted = { version: inc.INCREMENTAL_STATE_VERSION, tsBuildInfoText: '{garbage' }; + const oldBP = inc.reconstructOldBuilder(ts, corrupted, hostShim); + check('corrupted text → undefined oldBP', oldBP === undefined); } // ── Done ──────────────────────────────────────────────────────────────── diff --git a/packages/cli/test/integration.test.ts b/packages/cli/test/integration.test.ts index 6316d85e..4327ff88 100644 --- a/packages/cli/test/integration.test.ts +++ b/packages/cli/test/integration.test.ts @@ -210,7 +210,8 @@ function readCacheForFixture(fixtureDir: string): unknown { check('cache written under --incremental', !!data); check( 'incrementalState persisted to cache file', - !!data?.incrementalState && Object.keys(data.incrementalState.files).length > 0, + !!data?.incrementalState && typeof data.incrementalState.tsBuildInfoText === 'string' + && data.incrementalState.tsBuildInfoText.length > 0, ); } finally { From c6cfbac6838c45dd7518dc82ed191c2be7b90ac4 Mon Sep 17 00:00:00 2001 From: Johnson Chu Date: Sat, 2 May 2026 18:26:51 +0800 Subject: [PATCH 17/32] perf(cli): skip diagnostic compute in BP affected drain MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The drain pattern from 1c4c888 paid for full semantic diagnostics on every affected file — TSSLint's lint pass already triggers semantic checks lazily for the symbols type-aware rules query, so doing it twice cost +38s on Dify cold (58s vs 10s baseline). `getSemanticDiagnosticsOfNextAffectedFile` accepts an `ignoreSource- File` callback. Returning true from it skips the diagnostic compute for that file but the internal `getNextAffectedFile` walk continues — graph propagation through the reference map still happens, signatures still get computed during emitBuildInfo, the next session can still diff. We just don't pay for diagnostics we never use. Threading the affected-file recorder through the callback keeps the shape we want (affected set in `affectedFiles`) and discards the unused diagnostics. Type wrinkle: the public type for `ignoreSourceFile` declares `(sourceFile: SourceFile) => boolean`, but TS internally calls it with the iterator's `affected` value — which can also be a Program (whole-program affected path, e.g. lib flip). Discriminate at runtime via the `fileName` field; cast through `SourceFile | Program`. Dify-scale numbers (5867 files, react-x/no-leaked-conditional-rendering): before this commit: --incremental cold: 58.3 s --incremental warm: 5.8 s no --incremental: 10.3 s after: --incremental cold: 10.9 s (+0.6 s vs baseline — noise) --incremental warm: 5.9 s (1.7× speedup over baseline) no --incremental: 10.3 s `--incremental` now strictly dominates layer 1 for warm and matches it for cold. CI workloads no longer need to avoid the flag. Existing tests pass unchanged (192 checks across 8 cache suites). --- packages/cli/lib/worker.ts | 33 +++++++++++++++++++++++++-------- 1 file changed, 25 insertions(+), 8 deletions(-) diff --git a/packages/cli/lib/worker.ts b/packages/cli/lib/worker.ts index 985ad0e5..7a98a292 100644 --- a/packages/cli/lib/worker.ts +++ b/packages/cli/lib/worker.ts @@ -231,18 +231,35 @@ async function setup( oldBuilder as ts.SemanticDiagnosticsBuilderProgram | undefined, ); affectedFiles = new Set(); - while (true) { - const result = currentBuilder.getSemanticDiagnosticsOfNextAffectedFile(); - if (!result) break; - const a = result.affected; + // Drain via `ignoreSourceFile` to record affected files without + // computing their semantic diagnostics. The diagnostic compute is + // the expensive part of the drain (~38s on Dify cold) — TSSLint's + // own lint pass triggers semantic checks lazily for the symbols + // type-aware rules query, not the full program. Doing it twice + // wasted time. The graph-propagation work (which determines + // affected via reference graph) still runs internally. + // `ignoreSourceFile`'s typed param is SourceFile only, but TS + // internally calls it with the same `affected` value the iterator + // returns — which can also be a Program (whole-program affected + // path, e.g. lib flip). Handle both shapes at runtime via the + // `fileName` discriminator. + const recordAffected = (sf: ts.SourceFile) => { + const a = sf as ts.SourceFile | ts.Program; if ('fileName' in a) { - affectedFiles.add(a.fileName); + affectedFiles!.add(a.fileName); } else { - // Whole-program affected — config option flip, lib change. - // Conservatively mark every source file affected. - for (const sf of a.getSourceFiles()) affectedFiles.add(sf.fileName); + for (const f of a.getSourceFiles()) affectedFiles!.add(f.fileName); } + return true; + }; + while (true) { + const result = currentBuilder.getSemanticDiagnosticsOfNextAffectedFile( + undefined, + recordAffected, + ); + if (!result) break; + // Should not reach here — `ignoreSourceFile` always returns true. } } else { From d303abab1d72ebde3a6075dc58f74d7385791c15 Mon Sep 17 00:00:00 2001 From: Johnson Chu Date: Sat, 2 May 2026 18:35:10 +0800 Subject: [PATCH 18/32] feat(cli): make layer 2 the default, drop --incremental flag MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Cold-time penalty was the only reason `--incremental` had to be opt-in. After c6cfbac brought it to +0.6s vs baseline (noise), there's no longer a reason to keep two paths. `--force` already serves as the opt-out — it skips the cache load, so the next invocation starts cold (no prev state for layer 2 to diff against, type-aware rules re-run, fresh state written for the session after that). Changes: - HELP: drop the `--incremental` line. - CLI: drop the `process.argv.includes('--incremental')` check; always pass through to worker. - Worker `setup`: drop the `incremental: boolean` parameter. `compilerOptions.incremental` + `tsBuildInfoFile` always set (overriding the user's tsc-side values, which use a different path). - Worker `lint`: simplify — `affectedFiles` is always populated; drop the conditional that derived `incremental` from its presence. - `cache-flow.lintWithCache`'s `incremental` option stays — the module is library-level and still supports mode A for tests / future callers. Tests: - integration.test.ts test 6: rephrase ("incrementalState always persisted" instead of "--incremental accepted"). - Tests 7-8: drop the redundant `--incremental` arg. - Test 9: repurposed from "without --incremental, type-aware rule re-runs" (no longer reachable from CLI) to "with --force, type-aware rule re-runs every time" — same invariant, exposed via the supported opt-out. Mode A semantics still tested at the cache-flow unit level (38 checks unchanged in cache-flow.test.ts). 192 checks across 8 cache-related suites all pass. --- packages/cli/index.ts | 2 - packages/cli/lib/worker.ts | 60 +++++++++++---------------- packages/cli/test/integration.test.ts | 40 ++++++++++-------- 3 files changed, 46 insertions(+), 56 deletions(-) diff --git a/packages/cli/index.ts b/packages/cli/index.ts index 37b6e861..06b47eea 100644 --- a/packages/cli/index.ts +++ b/packages/cli/index.ts @@ -27,7 +27,6 @@ Options: --filter Filter files to lint --fix Apply automatic fixes --force Ignore cache (re-lint every file) - --incremental Cache type-aware rule results across sessions (layer 2) --failures-only Only print errors and messages (skip warnings and suggestions) -h, --help Show this help message @@ -350,7 +349,6 @@ const formatHost: ts.FormatDiagnosticsHost = { project.rawFileNames, project.options, Object.keys(project.cacheData.ruleModes), - process.argv.includes('--incremental'), project.cacheData.incrementalState, ); if (setupResult !== true) { diff --git a/packages/cli/lib/worker.ts b/packages/cli/lib/worker.ts index 7a98a292..fcec4795 100644 --- a/packages/cli/lib/worker.ts +++ b/packages/cli/lib/worker.ts @@ -26,11 +26,12 @@ let fileNames: string[] = []; let language: Language | undefined; let linter: core.Linter; let linterLanguageService!: ts.LanguageService; -// Layer 2 state. When `--incremental` is on, we wrap the LS program in -// a SemanticDiagnosticsBuilderProgram (with the prev session's BP fed -// back via TS's internal `tsBuildInfoText` round-trip) and walk -// affected files once. cache-flow consults this set to decide whether -// type-aware rules can be cache-hit. `undefined` = layer 1 only. +// Layer 2 state. We wrap the LS program in a SemanticDiagnostics- +// BuilderProgram (with the prev session's BP fed back via TS's internal +// `tsBuildInfoText` round-trip) and walk affected files once. cache- +// flow consults this set to decide whether type-aware rules can be +// cache-hit. Always populated under the CLI; `--force` opts out by +// clearing the loaded cache, not by disabling layer 2. let affectedFiles: Set | undefined; // The current session's BP — held until end-of-project so we can // capture its updated buildinfo text for next session's persistence. @@ -134,7 +135,6 @@ async function setup( _fileNames: string[], _options: ts.CompilerOptions, initialTypeAwareRules: readonly string[], - incremental: boolean, prevIncrementalState: IncrementalState | undefined, ): Promise { let config: config.Config | config.Config[]; @@ -187,24 +187,18 @@ async function setup( projectVersion++; typeRootsVersion++; fileNames = _fileNames; - options = plugins.some(plugin => plugin.typescript?.extraFileExtensions.length) - ? { - ..._options, - allowNonTsExtensions: true, - } - : _options; - if (incremental) { - // Internal API path: BuilderProgram.emitBuildInfo only produces - // content when these options are set. Override the user's values - // (their own tsc --incremental builds shouldn't share this file). - // The synthetic path is never written to disk — captured via - // writeFile callback at end of session. - options = { - ...options, - incremental: true, - tsBuildInfoFile: incrementalState.SYNTHETIC_BUILD_INFO_PATH, - }; - } + // Internal API path: BuilderProgram.emitBuildInfo only produces + // content when these options are set. Override the user's values + // (their own tsc --incremental builds shouldn't share this file). + // The synthetic path is never written to disk — captured via + // writeFile callback at end of session. + options = { + ...(plugins.some(plugin => plugin.typescript?.extraFileExtensions.length) + ? { ..._options, allowNonTsExtensions: true } + : _options), + incremental: true, + tsBuildInfoFile: incrementalState.SYNTHETIC_BUILD_INFO_PATH, + }; linter = core.createLinter( { languageService: linterLanguageService, @@ -217,7 +211,7 @@ async function setup( initialTypeAwareRules, ); - if (incremental) { + { const program = linterLanguageService.getProgram()!; // Reconstruct the prev session's BP from cached buildinfo text, // fall through to undefined on any failure (cold-start path). @@ -262,10 +256,6 @@ async function setup( // Should not reach here — `ignoreSourceFile` always returns true. } } - else { - affectedFiles = undefined; - currentBuilder = undefined; - } return true; } @@ -283,16 +273,14 @@ function lint(fileName: string, fix: boolean, fileCache: FileCache, fileMtime: n let diagnostics!: ts.DiagnosticWithLocation[]; let shouldCheck = true; - // Layer 2 signals. - // incremental: master switch. drives whether type-aware entries - // are written this session (so the NEXT one can read). + // Layer 2 signals. `incremental` is always true under the CLI now — + // `--force` opts out by clearing the loaded cache instead. // typeAwareUnaffected: file's deps haven't moved since prev session, // so cached type-aware entries can be reused this run. // False in --fix mode — fixes mutate files mid-session // and invalidate the setup-time affected snapshot for // downstream files; we'd rather re-run than serve stale. - const incremental = !!affectedFiles; - const typeAwareUnaffected = incremental && !fix && !affectedFiles!.has(fileName); + const typeAwareUnaffected = !fix && !affectedFiles!.has(fileName); if (fix) { // Drop cache entries for rules that registered a fix in any prior @@ -306,7 +294,7 @@ function lint(fileName: string, fix: boolean, fileCache: FileCache, fileMtime: n } const program = linterLanguageService.getProgram()!; diagnostics = cacheFlow.lintWithCache(linter, fileName, fileCache, fileMtime, program, { - incremental, + incremental: true, typeAwareUnaffected, }); shouldCheck = false; @@ -348,7 +336,7 @@ function lint(fileName: string, fix: boolean, fileCache: FileCache, fileMtime: n if (shouldCheck) { const program = linterLanguageService.getProgram()!; diagnostics = cacheFlow.lintWithCache(linter, fileName, fileCache, fileMtime, program, { - incremental, + incremental: true, typeAwareUnaffected, }); } diff --git a/packages/cli/test/integration.test.ts b/packages/cli/test/integration.test.ts index 4327ff88..600812c9 100644 --- a/packages/cli/test/integration.test.ts +++ b/packages/cli/test/integration.test.ts @@ -200,14 +200,14 @@ function readCacheForFixture(fixtureDir: string): unknown { } } -// ── Test 6: --incremental accepted, doesn't break the lint pass ───────── +// ── Test 6: incrementalState always persisted (layer 2 default-on) ───── { const dir = makeFixture(); try { - const r = runCli(dir, '--incremental'); - check('--incremental run produced diagnostic', r.stdout.includes('no-console')); + const r = runCli(dir); + check('default run produced diagnostic', r.stdout.includes('no-console')); const data = readCacheForFixture(dir) as any; - check('cache written under --incremental', !!data); + check('cache written', !!data); check( 'incrementalState persisted to cache file', !!data?.incrementalState && typeof data.incrementalState.tsBuildInfoText === 'string' @@ -265,18 +265,18 @@ function markerLineCount(markerPath: string): number { return fs.readFileSync(markerPath, 'utf8').split('\n').filter(Boolean).length; } -// ── Test 7 (layer 2): --incremental skips type-aware rule on warm run ─── +// ── Test 7 (layer 2): warm run skips type-aware rule (cache hit) ──────── { const { dir, markerPath } = makeTypeAwareFixture(); try { - runCli(dir, '--incremental'); + runCli(dir); const afterCold = markerLineCount(markerPath); - check('cold --incremental ran rule once', afterCold === 1); + check('cold ran rule once', afterCold === 1); - runCli(dir, '--incremental'); + runCli(dir); const afterWarm = markerLineCount(markerPath); check( - 'warm --incremental did NOT re-run rule (layer 2 cache hit)', + 'warm did NOT re-run rule (layer 2 cache hit)', afterWarm === 1, `expected 1 marker line, got ${afterWarm}`, ); @@ -295,7 +295,7 @@ function markerLineCount(markerPath: string): number { { const { dir, markerPath, ambient } = makeTypeAwareFixture(); try { - runCli(dir, '--incremental'); + runCli(dir); check('cold ran rule once', markerLineCount(markerPath) === 1); // Mutate the ambient declaration. fixture.ts's text doesn't change. @@ -303,7 +303,7 @@ function markerLineCount(markerPath: string): number { const t = new Date(Date.now() + 60_000); fs.utimesSync(ambient, t, t); - runCli(dir, '--incremental'); + runCli(dir); check( 'ambient edit forced fixture.ts re-lint (layer 2 invalidation)', markerLineCount(markerPath) === 2, @@ -311,7 +311,7 @@ function markerLineCount(markerPath: string): number { ); // And the cache should re-hit again on the next warm run. - runCli(dir, '--incremental'); + runCli(dir); check( 'warm after ambient edit cache-hits again', markerLineCount(markerPath) === 2, @@ -322,18 +322,22 @@ function markerLineCount(markerPath: string): number { } } -// ── Test 9 (layer 2): without --incremental, type-aware rule always runs ─ +// ── Test 9 (--force): type-aware rule re-runs every time ──────────────── +// +// `--force` is the opt-out from layer 2. It skips the cache load, so +// the linter starts cold every invocation — the type-aware rule has +// no prev state to cache-hit against and runs fresh. { const { dir, markerPath } = makeTypeAwareFixture(); try { - runCli(dir); - check('cold ran rule once (no --incremental)', markerLineCount(markerPath) === 1); + runCli(dir, '--force'); + check('first --force ran rule once', markerLineCount(markerPath) === 1); - runCli(dir); + runCli(dir, '--force'); check( - 'warm without --incremental re-ran type-aware rule (no layer 2)', + 'second --force re-ran type-aware rule', markerLineCount(markerPath) === 2, - `expected 2 marker lines, got ${markerLineCount(markerPath)} — type-aware rules without layer 2 are not cached`, + `expected 2 marker lines, got ${markerLineCount(markerPath)} — --force should bypass layer 2`, ); } finally { From 0a6cbcef489de0e0663a74ef7e1f9ee4a7b6c5a9 Mon Sep 17 00:00:00 2001 From: Johnson Chu Date: Sat, 2 May 2026 18:51:52 +0800 Subject: [PATCH 19/32] fix(cli): restore --force hint in summary on warm runs MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Lost in the cache-removal refactor (47f5410) and never put back when the cache returned. Now that `--force` is the documented opt-out from layer 2 (d303aba), the hint is genuinely useful — without it, users hitting stale cache results have no surfaced way to clear it. Tracking: a file counts as cache-hit if its prev `mtime` matches the current stat AND there's at least one rule entry recorded for it. Approximation, not soundness — layer 2's BP may still re-run type-aware rules if their deps moved, but the user-visible signal just answers "did the cache have something for this file." Output now reads: cold: "1 message (use --fix to apply fixes)" warm: "1 message (use --force to ignore cache, --fix to apply fixes)" with --force: "1 message (use --fix to apply fixes)" --- packages/cli/index.ts | 13 +++++++++++++ 1 file changed, 13 insertions(+) diff --git a/packages/cli/index.ts b/packages/cli/index.ts index 06b47eea..b216af03 100644 --- a/packages/cli/index.ts +++ b/packages/cli/index.ts @@ -187,6 +187,7 @@ 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; @@ -298,6 +299,9 @@ const formatHost: ts.FormatDiagnosticsHost = { } const hints: string[] = []; + if (cached) { + hints.push(colors.cyan('--force') + colors.gray(' to ignore cache')); + } if (hasFix) { hints.push(colors.cyan('--fix') + colors.gray(' to apply fixes')); } @@ -372,6 +376,15 @@ const formatHost: ts.FormatDiagnosticsHost = { fileCache = { mtime: fileStat.mtimeMs, rules: {} }; project.cacheData.files[fileName] = fileCache; } + 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, From b7cbcbe8ab800498199c56427bc270d3272c13d9 Mon Sep 17 00:00:00 2001 From: Johnson Chu Date: Sat, 2 May 2026 18:57:28 +0800 Subject: [PATCH 20/32] feat(cli): --list-rules surfaces rule classification Debug aid for understanding which configured rules went down the type-aware path vs ran as plain syntactic. Reads from the cache data we already accumulate (`ruleModes` for type-aware, per-file `rules` keys for the rest), groups + sorts for stable output. Sample on Dify (5867 files, react-x rule): type-aware (1) react-x/no-leaked-conditional-rendering Sample on fixtures/define-rule (no-console, no program access): syntactic (1) no-console/default Multi-project runs union the type-aware set across projects so a rule classified type-aware in one project doesn't show up as syntactic from another. --- packages/cli/index.ts | 40 ++++++++++++++++++++++++++++++++++++++++ 1 file changed, 40 insertions(+) diff --git a/packages/cli/index.ts b/packages/cli/index.ts index b216af03..bf9673a6 100644 --- a/packages/cli/index.ts +++ b/packages/cli/index.ts @@ -28,6 +28,7 @@ Options: --fix Apply automatic fixes --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: @@ -326,6 +327,45 @@ 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(); + const syntactic = new Set(); + 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); + + const lines: string[] = []; + if (typeAware.size) { + lines.push(colors.cyan('type-aware') + colors.gray(` (${typeAware.size})`)); + for (const id of [...typeAware].sort()) lines.push(' ' + id); + } + if (syntactic.size) { + lines.push(colors.cyan('syntactic') + colors.gray(` (${syntactic.size})`)); + for (const id of [...syntactic].sort()) lines.push(' ' + id); + } + if (!lines.length) { + lines.push(colors.gray('(no rules ran)')); + } + for (const l of lines) renderer.info(l); + } + renderer.dispose(); process.exit((errors || messages || configErrors) ? 1 : 0); From 29b2218a647b05d4942d31a38275f1a11634eb2e Mon Sep 17 00:00:00 2001 From: Johnson Chu Date: Sat, 2 May 2026 18:59:53 +0800 Subject: [PATCH 21/32] fix(cli): --list-rules emits both headers even with zero count MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Previous behaviour skipped a section header when its count was 0, so a project with only type-aware rules formatted differently from one with both kinds. Always print both headers; visually consistent across runs. Output now reads, regardless of which side is empty: type-aware (1) react-x/no-leaked-conditional-rendering syntactic (0) Drops the special-case "(no rules ran)" line — if both counts are zero the user sees `type-aware (0)` / `syntactic (0)`, which is already self-explanatory. --- packages/cli/index.ts | 18 +++++++----------- 1 file changed, 7 insertions(+), 11 deletions(-) diff --git a/packages/cli/index.ts b/packages/cli/index.ts index bf9673a6..a1222dcf 100644 --- a/packages/cli/index.ts +++ b/packages/cli/index.ts @@ -351,18 +351,14 @@ const formatHost: ts.FormatDiagnosticsHost = { // 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[] = []; - if (typeAware.size) { - lines.push(colors.cyan('type-aware') + colors.gray(` (${typeAware.size})`)); - for (const id of [...typeAware].sort()) lines.push(' ' + id); - } - if (syntactic.size) { - lines.push(colors.cyan('syntactic') + colors.gray(` (${syntactic.size})`)); - for (const id of [...syntactic].sort()) lines.push(' ' + id); - } - if (!lines.length) { - lines.push(colors.gray('(no rules ran)')); - } + 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); } From 8995ed47c2ebc989888d112e1c9233401149dc31 Mon Sep 17 00:00:00 2001 From: Johnson Chu Date: Sat, 2 May 2026 19:08:40 +0800 Subject: [PATCH 22/32] fix(cli): consistent blank line between summary and --list-rules MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `renderer.summary` didn't update lastWasContent, so the blank-line separator before --list-rules output only appeared when diagnostics happened to print earlier in the run. On a clean project (e.g. Dify, where the only rule is type-aware and there are no findings) the separator vanished, making the section visually blur into the summary line. Mark summary as content so any follow-up info() — currently just the --list-rules block — gets the same separator that follows a diagnostic block. Non-TTY output is unchanged (info still skips the blank line outside a TTY). --- packages/cli/lib/render.ts | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/packages/cli/lib/render.ts b/packages/cli/lib/render.ts index 36c40e66..1bc32e8b 100644 --- a/packages/cli/lib/render.ts +++ b/packages/cli/lib/render.ts @@ -46,6 +46,11 @@ export function createRenderer(): Renderer { for (const line of lines) { process.stdout.write(line + '\n'); } + // Mark as content so any follow-up `info()` (e.g. --list-rules) + // gets a blank-line separator in TTY mode. Without this, the + // separator only appears when diagnostics happened to print + // before summary, making the gap inconsistent across runs. + lastWasContent = true; }, dispose() {}, }; From 3b22952765ca418422520a640c91843b2fcc127c Mon Sep 17 00:00:00 2001 From: Johnson Chu Date: Sat, 2 May 2026 19:19:32 +0800 Subject: [PATCH 23/32] fix(cli): graceful fallback when TS internal API is missing MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Layer 2 leans on three TS internal APIs: - ts.getBuildInfo (load path) - ts.createBuilderProgramUsingIncrementalBuildInfo (load path) - BuilderProgram.emitBuildInfo (save path) Load was already wrapped in try/catch — if a future TS renames or removes the loader pair, reconstructOldBuilder returns undefined and the run starts cold. Save was unguarded: missing emitBuildInfo would throw a TypeError after lint already finished, surfacing as an unhandled rejection that takes down the CLI and discards diagnostics for the run. captureIncrementalState now feature-detects emitBuildInfo, wraps the call in try/catch, and returns undefined on either path. The caller (worker.buildIncrementalState → cli persistence) already treats undefined as "layer-1-only this session", so the next run just rebuilds layer 2 from a cold start. Wrong miss > wrong hit. Tests cover both directions — Proxy-stub TS without the loader APIs; fake BuilderProgram without emitBuildInfo and one whose emitBuildInfo throws. --- packages/cli/lib/incremental-state.ts | 23 +++++--- packages/cli/test/incremental-state.test.ts | 60 +++++++++++++++++++++ 2 files changed, 77 insertions(+), 6 deletions(-) diff --git a/packages/cli/lib/incremental-state.ts b/packages/cli/lib/incremental-state.ts index 9d92f6f9..297d6630 100644 --- a/packages/cli/lib/incremental-state.ts +++ b/packages/cli/lib/incremental-state.ts @@ -95,17 +95,28 @@ export function reconstructOldBuilder( // `emitBuildInfo` is on the runtime `BuilderProgram` shape but not in // the public d.ts (the public surface only exposes the `emit()` family // that wraps it). Cast through `unknown` to silence the type error; -// runtime is stable. +// runtime is stable across TS 5.x → 6.x. +// +// On any failure (method missing on a future TS, or it threw), return +// undefined. Caller persists no `incrementalState` for this session, +// which means next session starts cold for layer 2 — the layer-1 +// mtime cache still works. Wrong miss > wrong hit. export function captureIncrementalState( builder: ts.BuilderProgram, ): IncrementalState | undefined { - let captured: string | undefined; const builderAny = builder as unknown as { - emitBuildInfo(writeFile: (path: string, content: string) => void): void; + emitBuildInfo?(writeFile: (path: string, content: string) => void): void; }; - builderAny.emitBuildInfo((_path, content) => { - captured = content; - }); + if (typeof builderAny.emitBuildInfo !== 'function') return undefined; + let captured: string | undefined; + try { + builderAny.emitBuildInfo((_path, content) => { + captured = content; + }); + } + catch { + return undefined; + } if (!captured) return undefined; return { version: INCREMENTAL_STATE_VERSION, tsBuildInfoText: captured }; } diff --git a/packages/cli/test/incremental-state.test.ts b/packages/cli/test/incremental-state.test.ts index 04c4d05e..90752746 100644 --- a/packages/cli/test/incremental-state.test.ts +++ b/packages/cli/test/incremental-state.test.ts @@ -180,6 +180,66 @@ const hostShim = { check('corrupted text → undefined oldBP', oldBP === undefined); } +// ── Test 7: TS missing internal load APIs → cold start ────────────────── +// Future TS could rename `getBuildInfo` / +// `createBuilderProgramUsingIncrementalBuildInfo`. We must not throw. +{ + const tsStub = new Proxy(ts, { + get(target, prop) { + if (prop === 'getBuildInfo' || prop === 'createBuilderProgramUsingIncrementalBuildInfo') { + return undefined; + } + return (target as any)[prop]; + }, + }) as typeof ts; + const valid = { version: inc.INCREMENTAL_STATE_VERSION, tsBuildInfoText: 'irrelevant' }; + let threw = false; + let result: ts.BuilderProgram | undefined; + try { + result = inc.reconstructOldBuilder(tsStub, valid, hostShim); + } + catch { + threw = true; + } + check('missing load APIs → no throw', !threw); + check('missing load APIs → undefined result', result === undefined); +} + +// ── Test 8: BuilderProgram missing emitBuildInfo → undefined, no throw ── +// The save path must degrade gracefully if a future TS removes or +// renames `BuilderProgram.emitBuildInfo`. Otherwise the CLI throws +// after lint completes, losing all results. +{ + const fakeBuilder = {} as ts.BuilderProgram; + let threw = false; + let result: ReturnType; + try { + result = inc.captureIncrementalState(fakeBuilder); + } + catch { + threw = true; + } + check('missing emitBuildInfo → no throw', !threw); + check('missing emitBuildInfo → undefined state', result === undefined); +} + +// ── Test 9: emitBuildInfo throws → undefined, no throw out ────────────── +{ + const throwingBuilder = { + emitBuildInfo() { throw new Error('simulated TS internal failure'); }, + } as unknown as ts.BuilderProgram; + let threw = false; + let result: ReturnType; + try { + result = inc.captureIncrementalState(throwingBuilder); + } + catch { + threw = true; + } + check('throwing emitBuildInfo → no throw', !threw); + check('throwing emitBuildInfo → undefined state', result === undefined); +} + // ── Done ──────────────────────────────────────────────────────────────── process.stdout.write('\n'); if (failures.length) { From b1adecd5a07ef8fbf27f833ef076676e4b2c6f6e Mon Sep 17 00:00:00 2001 From: Johnson Chu Date: Sat, 2 May 2026 19:23:53 +0800 Subject: [PATCH 24/32] feat(cli): warn user when TS internal API is missing MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Silent fallback meant a TS upgrade that drops getBuildInfo / createBuilderProgramUsingIncrementalBuildInfo / emitBuildInfo would degrade tsslint to layer-1-only without telling anyone — the run would just get slower across sessions, with no obvious cause. Now all four failure paths print a yellow `warn` line on stderr naming the TS version, the missing or failing API, and the consequence (cache disabled this session / not persisted for next): warn TypeScript 6.0.3 is missing internal APIs (getBuildInfo / createBuilderProgramUsingIncrementalBuildInfo). Type-aware cache disabled — every type-aware rule will re-run from cold start. warn TypeScript 6.0.3 BuilderProgram is missing emitBuildInfo. Type-aware cache cannot be persisted — next run will start cold. warn Could not persist incremental state on TypeScript 6.0.3: . Type-aware cache cannot be persisted — next run will start cold. warn Could not load previous incremental state on TypeScript 6.0.3: . Type-aware cache will rebuild from cold start. Normal cold-start paths (no prev cache; cache schema bumped) stay silent — only actual incompatibility / failure warns. stderr keeps it out of the renderer's stdout flow but visible to anyone who hasn't redirected stderr. `captureIncrementalState` takes ts.version as an explicit param now so the helper stays free of `import 'typescript'` (it works with a typeof-only import). Tests verify each warn fires with the right substring and that silent paths produce no stderr. --- packages/cli/lib/incremental-state.ts | 59 ++++++++-- packages/cli/lib/worker.ts | 2 +- packages/cli/test/incremental-state.test.ts | 121 ++++++++++++++------ 3 files changed, 138 insertions(+), 44 deletions(-) diff --git a/packages/cli/lib/incremental-state.ts b/packages/cli/lib/incremental-state.ts index 297d6630..083ff96e 100644 --- a/packages/cli/lib/incremental-state.ts +++ b/packages/cli/lib/incremental-state.ts @@ -63,6 +63,20 @@ export function asIncremental(ts: typeof import('typescript')): IncrementalAcces return ts as unknown as IncrementalAccess; } +// Warn the user that layer 2 cache is unavailable this session. +// Goes to stderr (yellow) so it doesn't interleave with the +// renderer's stdout output but stays visible by default. CI logs +// stderr by default; users who pipe stderr to /dev/null know what +// they signed up for. +// +// Each call site phrases its own reason — these are rare enough +// that we don't need rate-limiting. A single CLI invocation can +// emit at most one of each (load fails once at setup, save fails +// once at end-of-project). +function warn(msg: string): void { + process.stderr.write('\x1b[93mwarn\x1b[0m ' + msg + '\n'); +} + // Reconstruct an old BuilderProgram from a previously captured // `tsBuildInfoText`. Used as the `oldProgram` argument when creating // the current session's BP. Returns undefined on any deserialization @@ -72,9 +86,23 @@ export function reconstructOldBuilder( prev: IncrementalState | undefined, host: { useCaseSensitiveFileNames(): boolean; getCurrentDirectory(): string }, ): ts.BuilderProgram | undefined { + // Schema-version mismatch and "no prev cache" are normal cold-start + // paths (e.g. cache invalidated by config change, or first run); + // don't warn about either. if (!prev || prev.version !== INCREMENTAL_STATE_VERSION) return undefined; + const api = asIncremental(ts); + if ( + typeof api.getBuildInfo !== 'function' + || typeof api.createBuilderProgramUsingIncrementalBuildInfo !== 'function' + ) { + warn( + `TypeScript ${ts.version} is missing internal APIs ` + + `(getBuildInfo / createBuilderProgramUsingIncrementalBuildInfo). ` + + `Type-aware cache disabled — every type-aware rule will re-run from cold start.`, + ); + return undefined; + } try { - const api = asIncremental(ts); const buildInfo = api.getBuildInfo(SYNTHETIC_BUILD_INFO_PATH, prev.tsBuildInfoText); if (!buildInfo) return undefined; return api.createBuilderProgramUsingIncrementalBuildInfo( @@ -83,7 +111,11 @@ export function reconstructOldBuilder( host, ); } - catch { + catch (e) { + warn( + `Could not load previous incremental state on TypeScript ${ts.version}: ` + + `${(e as Error).message}. Type-aware cache will rebuild from cold start.`, + ); return undefined; } } @@ -97,24 +129,35 @@ export function reconstructOldBuilder( // that wraps it). Cast through `unknown` to silence the type error; // runtime is stable across TS 5.x → 6.x. // -// On any failure (method missing on a future TS, or it threw), return -// undefined. Caller persists no `incrementalState` for this session, -// which means next session starts cold for layer 2 — the layer-1 -// mtime cache still works. Wrong miss > wrong hit. +// On any failure (method missing on a future TS, or it threw), warn + +// return undefined. Caller persists no `incrementalState` for this +// session, which means next session starts cold for layer 2 — the +// layer-1 mtime cache still works. Wrong miss > wrong hit. export function captureIncrementalState( + tsVersion: string, builder: ts.BuilderProgram, ): IncrementalState | undefined { const builderAny = builder as unknown as { emitBuildInfo?(writeFile: (path: string, content: string) => void): void; }; - if (typeof builderAny.emitBuildInfo !== 'function') return undefined; + if (typeof builderAny.emitBuildInfo !== 'function') { + warn( + `TypeScript ${tsVersion} BuilderProgram is missing emitBuildInfo. ` + + `Type-aware cache cannot be persisted — next run will start cold.`, + ); + return undefined; + } let captured: string | undefined; try { builderAny.emitBuildInfo((_path, content) => { captured = content; }); } - catch { + catch (e) { + warn( + `Could not persist incremental state on TypeScript ${tsVersion}: ` + + `${(e as Error).message}. Type-aware cache cannot be persisted — next run will start cold.`, + ); return undefined; } if (!captured) return undefined; diff --git a/packages/cli/lib/worker.ts b/packages/cli/lib/worker.ts index fcec4795..23430d1a 100644 --- a/packages/cli/lib/worker.ts +++ b/packages/cli/lib/worker.ts @@ -265,7 +265,7 @@ async function setup( // mode or when capture fails. function buildIncrementalState(): IncrementalState | undefined { if (!currentBuilder) return undefined; - return incrementalState.captureIncrementalState(currentBuilder); + return incrementalState.captureIncrementalState(ts.version, currentBuilder); } function lint(fileName: string, fix: boolean, fileCache: FileCache, fileMtime: number) { diff --git a/packages/cli/test/incremental-state.test.ts b/packages/cli/test/incremental-state.test.ts index 90752746..8cf28366 100644 --- a/packages/cli/test/incremental-state.test.ts +++ b/packages/cli/test/incremental-state.test.ts @@ -85,6 +85,20 @@ const hostShim = { getCurrentDirectory: () => '/', }; +// stderr capture helper for warning-path tests below. +function captureStderr(fn: () => T): { result: T; stderr: string } { + const orig = process.stderr.write.bind(process.stderr); + let buf = ''; + (process.stderr.write as any) = (chunk: any) => { buf += String(chunk); return true; }; + try { + const result = fn(); + return { result, stderr: buf }; + } + finally { + (process.stderr.write as any) = orig; + } +} + // ── Test 1: captureIncrementalState produces text on a fresh BP ───────── { const program = buildProgram({ '/a.ts': 'export const x: number = 1;' }); @@ -93,7 +107,7 @@ const hostShim = { { createHash: ts.sys.createHash }, ); affectedFileNames(builder); // drain - const state = inc.captureIncrementalState(builder); + const state = inc.captureIncrementalState(ts.version, builder); check('state captured', !!state); check('state version is v3', state?.version === inc.INCREMENTAL_STATE_VERSION); check('tsBuildInfoText is non-empty', !!state && state.tsBuildInfoText.length > 0); @@ -110,7 +124,7 @@ const hostShim = { { createHash: ts.sys.createHash }, ); affectedFileNames(builder1); - const captured = inc.captureIncrementalState(builder1)!; + const captured = inc.captureIncrementalState(ts.version, builder1)!; const program2 = buildProgram({ '/a.ts': 'export const x: number = 1;', @@ -142,7 +156,7 @@ const hostShim = { { createHash: ts.sys.createHash }, ); affectedFileNames(builder1); - const captured = inc.captureIncrementalState(builder1)!; + const captured = inc.captureIncrementalState(ts.version, builder1)!; // /a.ts changes its public type — should propagate to /b.ts. const program2 = buildProgram({ @@ -180,9 +194,11 @@ const hostShim = { check('corrupted text → undefined oldBP', oldBP === undefined); } -// ── Test 7: TS missing internal load APIs → cold start ────────────────── +// ── Test 7: TS missing internal load APIs → cold start + warn ────────── // Future TS could rename `getBuildInfo` / -// `createBuilderProgramUsingIncrementalBuildInfo`. We must not throw. +// `createBuilderProgramUsingIncrementalBuildInfo`. We must not throw, +// AND we must surface a stderr warning so users know type-aware cache +// is silently disabled. { const tsStub = new Proxy(ts, { get(target, prop) { @@ -193,51 +209,86 @@ const hostShim = { }, }) as typeof ts; const valid = { version: inc.INCREMENTAL_STATE_VERSION, tsBuildInfoText: 'irrelevant' }; - let threw = false; - let result: ts.BuilderProgram | undefined; - try { - result = inc.reconstructOldBuilder(tsStub, valid, hostShim); - } - catch { - threw = true; - } - check('missing load APIs → no throw', !threw); + const { result, stderr } = captureStderr(() => { + try { return inc.reconstructOldBuilder(tsStub, valid, hostShim); } + catch { return 'THREW' as const; } + }); + check('missing load APIs → no throw', result !== 'THREW'); check('missing load APIs → undefined result', result === undefined); + check('missing load APIs → warning printed', /warn/.test(stderr)); + check('missing load APIs → warning names the missing API', /getBuildInfo/.test(stderr)); + check('missing load APIs → warning names TS version', stderr.includes(ts.version)); } -// ── Test 8: BuilderProgram missing emitBuildInfo → undefined, no throw ── +// ── Test 8: BuilderProgram missing emitBuildInfo → undefined + warn ──── // The save path must degrade gracefully if a future TS removes or // renames `BuilderProgram.emitBuildInfo`. Otherwise the CLI throws -// after lint completes, losing all results. +// after lint completes, losing all results. Must also warn the user. { const fakeBuilder = {} as ts.BuilderProgram; - let threw = false; - let result: ReturnType; - try { - result = inc.captureIncrementalState(fakeBuilder); - } - catch { - threw = true; - } - check('missing emitBuildInfo → no throw', !threw); + const { result, stderr } = captureStderr(() => { + try { return inc.captureIncrementalState(ts.version, fakeBuilder); } + catch { return 'THREW' as const; } + }); + check('missing emitBuildInfo → no throw', result !== 'THREW'); check('missing emitBuildInfo → undefined state', result === undefined); + check('missing emitBuildInfo → warning printed', /warn/.test(stderr)); + check('missing emitBuildInfo → warning names emitBuildInfo', /emitBuildInfo/.test(stderr)); } -// ── Test 9: emitBuildInfo throws → undefined, no throw out ────────────── +// ── Test 9: emitBuildInfo throws → undefined + warn, no throw out ────── { const throwingBuilder = { emitBuildInfo() { throw new Error('simulated TS internal failure'); }, } as unknown as ts.BuilderProgram; - let threw = false; - let result: ReturnType; - try { - result = inc.captureIncrementalState(throwingBuilder); - } - catch { - threw = true; - } - check('throwing emitBuildInfo → no throw', !threw); + const { result, stderr } = captureStderr(() => { + try { return inc.captureIncrementalState(ts.version, throwingBuilder); } + catch { return 'THREW' as const; } + }); + check('throwing emitBuildInfo → no throw', result !== 'THREW'); check('throwing emitBuildInfo → undefined state', result === undefined); + check('throwing emitBuildInfo → warning printed', /warn/.test(stderr)); + check( + 'throwing emitBuildInfo → warning includes underlying error', + /simulated TS internal failure/.test(stderr), + ); +} + +// ── Test 10: corrupted buildInfo on load throws inside getBuildInfo → +// warn, return undefined. Distinct from Test 6 — TS itself may throw on +// some corrupt inputs (vs returning undefined for others). +{ + const throwingTs = new Proxy(ts, { + get(target, prop) { + if (prop === 'getBuildInfo') { + return () => { throw new Error('synthetic parse failure'); }; + } + return (target as any)[prop]; + }, + }) as typeof ts; + const valid = { version: inc.INCREMENTAL_STATE_VERSION, tsBuildInfoText: 'whatever' }; + const { result, stderr } = captureStderr(() => { + try { return inc.reconstructOldBuilder(throwingTs, valid, hostShim); } + catch { return 'THREW' as const; } + }); + check('throwing getBuildInfo → no throw', result !== 'THREW'); + check('throwing getBuildInfo → undefined result', result === undefined); + check('throwing getBuildInfo → warning printed', /warn/.test(stderr)); + check( + 'throwing getBuildInfo → warning includes underlying error', + /synthetic parse failure/.test(stderr), + ); +} + +// ── Test 11: silent paths stay silent ────────────────────────────────── +// Cold start (no prev) and version mismatch are normal, not an error +// — must NOT print a warning. +{ + const { stderr: s1 } = captureStderr(() => inc.reconstructOldBuilder(ts, undefined, hostShim)); + check('undefined prev → no warning', s1 === ''); + const stale = { version: 'v0', tsBuildInfoText: '' }; + const { stderr: s2 } = captureStderr(() => inc.reconstructOldBuilder(ts, stale as any, hostShim)); + check('version mismatch → no warning', s2 === ''); } // ── Done ──────────────────────────────────────────────────────────────── From 91f35664cd7e60ae08993a943874c30aec8fc676 Mon Sep 17 00:00:00 2001 From: Johnson Chu Date: Sat, 2 May 2026 19:31:25 +0800 Subject: [PATCH 25/32] test: drop redundant type assertions in core/cli tests tsslint's own dogfood lint flagged these as @typescript-eslint/no-unnecessary-type-assertion: - probe.test, skip-rules.test: `((rctx: RuleContext) => { ... }) as any` on rule values typed against `Config`. The arrow function already matches `Rule = (ctx: RuleContext) => void`; the `as any` was leftover scaffolding from earlier iterations. - incremental-state.test: `new Proxy(ts, ...) as typeof ts`. Proxy's constructor preserves the target type; the cast is a no-op. Same file: `stale as any` on a `{ version, tsBuildInfoText }` object that already structurally satisfies `IncrementalState`. Applied via `tsslint --fix`. All 133 cross-package checks still pass. --- packages/cli/test/incremental-state.test.ts | 6 +++--- packages/core/test/probe.test.ts | 10 +++++----- packages/core/test/skip-rules.test.ts | 14 +++++++------- 3 files changed, 15 insertions(+), 15 deletions(-) diff --git a/packages/cli/test/incremental-state.test.ts b/packages/cli/test/incremental-state.test.ts index 8cf28366..90b22e75 100644 --- a/packages/cli/test/incremental-state.test.ts +++ b/packages/cli/test/incremental-state.test.ts @@ -183,7 +183,7 @@ function captureStderr(fn: () => T): { result: T; stderr: string } { // ── Test 5: schema version mismatch → cold start ──────────────────────── { const stale = { version: 'v0', tsBuildInfoText: '' }; - const oldBP = inc.reconstructOldBuilder(ts, stale as any, hostShim); + const oldBP = inc.reconstructOldBuilder(ts, stale, hostShim); check('version mismatch → undefined oldBP', oldBP === undefined); } @@ -207,7 +207,7 @@ function captureStderr(fn: () => T): { result: T; stderr: string } { } return (target as any)[prop]; }, - }) as typeof ts; + }); const valid = { version: inc.INCREMENTAL_STATE_VERSION, tsBuildInfoText: 'irrelevant' }; const { result, stderr } = captureStderr(() => { try { return inc.reconstructOldBuilder(tsStub, valid, hostShim); } @@ -265,7 +265,7 @@ function captureStderr(fn: () => T): { result: T; stderr: string } { } return (target as any)[prop]; }, - }) as typeof ts; + }); const valid = { version: inc.INCREMENTAL_STATE_VERSION, tsBuildInfoText: 'whatever' }; const { result, stderr } = captureStderr(() => { try { return inc.reconstructOldBuilder(throwingTs, valid, hostShim); } diff --git a/packages/core/test/probe.test.ts b/packages/core/test/probe.test.ts index 31d48496..9e2e8cac 100644 --- a/packages/core/test/probe.test.ts +++ b/packages/core/test/probe.test.ts @@ -57,7 +57,7 @@ function makeContext(files: Record) { rules: { plain: ((rctx: RuleContext) => { rctx.report('plain', 0, 1); - }) as any, + }), }, }; const linter = core.createLinter(ctx, '/', config, () => []); @@ -76,7 +76,7 @@ function makeContext(files: Record) { 'type-aware': ((rctx: RuleContext) => { void rctx.program; rctx.report('typed', 0, 1); - }) as any, + }), }, }; const linter = core.createLinter(ctx, '/', config, () => []); @@ -102,7 +102,7 @@ function makeContext(files: Record) { void rctx.program; } rctx.report('hi', 0, 1); - }) as any, + }), }, }; const linter = core.createLinter(ctx, '/', config, () => []); @@ -124,7 +124,7 @@ function makeContext(files: Record) { 'syntactic-now': ((rctx: RuleContext) => { ran++; rctx.report('hi', 0, 1); - }) as any, + }), }, }; const linter = core.createLinter(ctx, '/', config, () => [], ['syntactic-now']); @@ -145,7 +145,7 @@ function makeContext(files: Record) { r: ((rctx: RuleContext) => { void rctx.program; rctx.report('x', 0, 1); - }) as any, + }), }, }; const linter = core.createLinter(ctx, '/', config, () => []); diff --git a/packages/core/test/skip-rules.test.ts b/packages/core/test/skip-rules.test.ts index 11f9c6fd..ed45bbdb 100644 --- a/packages/core/test/skip-rules.test.ts +++ b/packages/core/test/skip-rules.test.ts @@ -54,7 +54,7 @@ function makeContext(files: Record) { r: ((rctx: RuleContext) => { runs++; rctx.report('hi', 0, 1); - }) as any, + }), }, }; const linter = core.createLinter(ctx, '/', config, () => []); @@ -73,11 +73,11 @@ function makeContext(files: Record) { a: ((rctx: RuleContext) => { aRuns++; rctx.report('a', 0, 1); - }) as any, + }), b: ((rctx: RuleContext) => { bRuns++; rctx.report('b', 0, 1); - }) as any, + }), }, }; const linter = core.createLinter(ctx, '/', config, () => []); @@ -96,7 +96,7 @@ function makeContext(files: Record) { r: ((rctx: RuleContext) => { runs++; rctx.report('x', 0, 1); - }) as any, + }), }, }; const linter = core.createLinter(ctx, '/', config, () => []); @@ -116,7 +116,7 @@ function makeContext(files: Record) { r: ((rctx: RuleContext) => { void rctx.program; rctx.report('x', 0, 1); - }) as any, + }), }, }; // Seed as type-aware. Then skip the rule. Classification persists. @@ -132,10 +132,10 @@ function makeContext(files: Record) { rules: { fixable: ((rctx: RuleContext) => { rctx.report('fix me', 0, 1).withFix('apply', () => []); - }) as any, + }), plain: ((rctx: RuleContext) => { rctx.report('plain', 1, 2); - }) as any, + }), }, }; const linter = core.createLinter(ctx, '/', config, () => []); From 2d72e2fb7b531b82bce082d4f6d7a3d06affbe60 Mon Sep 17 00:00:00 2001 From: Johnson Chu Date: Sat, 2 May 2026 19:50:08 +0800 Subject: [PATCH 26/32] fix(cli): clear worker module state between projects Multi-project runs reuse the same in-process worker. setup() reset linterHost back to originalHost but left snapshots / versions / affectedFiles / currentBuilder populated from the previous project. Two consequences: - memory leak: cross-project file paths accumulate in snapshots and versions for the lifetime of the process. Short-lived CLI today, but a future daemon mode would grow unbounded. - latent correctness bug: if two projects share an absolute file path (uncommon but possible with monorepo workspace links), affectedFiles from project A could mis-classify the same path in project B as cache-hit-eligible. Clear all four at the top of setup(). projectVersion/typeRootsVersion keep bumping for LS-cache invalidation as before. --- packages/cli/lib/worker.ts | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/packages/cli/lib/worker.ts b/packages/cli/lib/worker.ts index 23430d1a..537244d2 100644 --- a/packages/cli/lib/worker.ts +++ b/packages/cli/lib/worker.ts @@ -161,6 +161,16 @@ async function setup( linterLanguageService = originalService; language = undefined; + // Reset per-project state. Multi-project runs reuse the same worker + // (in-process) — without this, cross-project file paths accumulate in + // `snapshots` / `versions` (memory leak) and `affectedFiles` from a + // prior project would mis-classify this project's files as cache-hit + // candidates if their absolute paths happened to overlap. + snapshots.clear(); + versions.clear(); + affectedFiles = undefined; + currentBuilder = undefined; + const plugins = await languagePlugins.load(tsconfig, languages); if (plugins.length) { const { getScriptSnapshot } = originalHost; From beba753b0fedf1522bf42b59ec6dd6233f5266b6 Mon Sep 17 00:00:00 2001 From: Johnson Chu Date: Sat, 2 May 2026 19:50:18 +0800 Subject: [PATCH 27/32] fix(cli): deep-validate cache file shape on load MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit isCacheData was a shallow check — top-level keys only. A corrupt files entry (mtime as string, rules as null, missing keys) would pass the gate and surface later as a TypeError mid-lint, with no actionable signal to the user. Walk Object.values(files) and verify each entry has a numeric mtime + non-null rules object. Same for incrementalState if present (must be {version: string, tsBuildInfoText: string}). Cost is one full traversal at load time — negligible against the cost of a confusing crash. Tests cover both inner-shape mismatches (mtime wrong type, rules null) and incrementalState corruption (wrong type, missing field). --- packages/cli/lib/cache.ts | 23 ++++++- packages/cli/test/cache.test.ts | 106 ++++++++++++++++++++++++++++++++ 2 files changed, 126 insertions(+), 3 deletions(-) diff --git a/packages/cli/lib/cache.ts b/packages/cli/lib/cache.ts index 0096bffa..804a8df4 100644 --- a/packages/cli/lib/cache.ts +++ b/packages/cli/lib/cache.ts @@ -126,12 +126,29 @@ export function emptyCache(): CacheData { return { version: CACHE_FORMAT_VERSION, ruleModes: {}, files: {} }; } +// Deep shape check. A corrupt `files` entry (wrong `mtime` type, missing +// `rules` map) would otherwise pass the gate and crash the lint loop +// later with a confusing TypeError. Cost of a single full traversal at +// load time is negligible against the cost of a mid-lint crash. function isCacheData(x: unknown): x is CacheData { if (typeof x !== 'object' || x === null) return false; const o = x as Record; - return typeof o.version === 'string' - && typeof o.ruleModes === 'object' && o.ruleModes !== null - && typeof o.files === 'object' && o.files !== null; + if (typeof o.version !== 'string') return false; + if (typeof o.ruleModes !== 'object' || o.ruleModes === null) return false; + if (typeof o.files !== 'object' || o.files === null) return false; + for (const v of Object.values(o.files as Record)) { + if (typeof v !== 'object' || v === null) return false; + const f = v as Record; + if (typeof f.mtime !== 'number') return false; + if (typeof f.rules !== 'object' || f.rules === null) return false; + } + if (o.incrementalState !== undefined) { + if (typeof o.incrementalState !== 'object' || o.incrementalState === null) return false; + const inc = o.incrementalState as Record; + if (typeof inc.version !== 'string') return false; + if (typeof inc.tsBuildInfoText !== 'string') return false; + } + return true; } function getCacheFilePath( diff --git a/packages/cli/test/cache.test.ts b/packages/cli/test/cache.test.ts index 82d20bdd..ebeac4f3 100644 --- a/packages/cli/test/cache.test.ts +++ b/packages/cli/test/cache.test.ts @@ -259,6 +259,112 @@ function configWithMarker(marker: string): string { check('no .tmp leaked after successful save', !tmpLeaked); } +// ── Test 9: inner shape mismatch (corrupt file entry) → empty cache ───── +// +// isCacheData now validates `files[*]` entries deeply: a bad inner shape +// (mtime is a string, rules is null) must reject the whole cache instead +// of letting the load succeed and crashing later in the lint loop. +{ + const tsconfig = tmpFile('tsconfig9.json', '{}'); + const config = configWithMarker('t9-' + Date.now()); + cache.saveCache(tsconfig, config, [], '6.0.0', cache.emptyCache()); + const cacheRoot = path.join(os.tmpdir(), 'tsslint-cache'); + let target: string | null = null; + const stack = [cacheRoot]; + while (stack.length) { + const dir = stack.pop()!; + let entries: fs.Dirent[]; + try { entries = fs.readdirSync(dir, { withFileTypes: true }); } + catch { continue; } + for (const e of entries) { + const full = path.join(dir, e.name); + if (e.isDirectory()) stack.push(full); + else if (e.isFile() && full.endsWith('.cache.json')) { + try { + const o = JSON.parse(fs.readFileSync(full, 'utf8')); + if (o.version === 'v2' && Object.keys(o.files).length === 0) { + target = full; + break; + } + } + catch { /* ignore */ } + } + } + if (target) break; + } + if (target) { + // mtime is a string, not a number — must reject. + const corrupt = { + version: 'v2', + ruleModes: {}, + files: { '/x': { mtime: 'not-a-number', rules: {} } }, + }; + fs.writeFileSync(target, JSON.stringify(corrupt)); + const loaded = cache.loadCache(tsconfig, config, [], '6.0.0'); + check('inner mtime mismatch → empty cache', Object.keys(loaded.files).length === 0); + + // rules is null — must also reject. + const corrupt2 = { + version: 'v2', + ruleModes: {}, + files: { '/x': { mtime: 1, rules: null } }, + }; + fs.writeFileSync(target, JSON.stringify(corrupt2)); + const loaded2 = cache.loadCache(tsconfig, config, [], '6.0.0'); + check('inner rules null → empty cache', Object.keys(loaded2.files).length === 0); + } +} + +// ── Test 10: incrementalState shape gate ──────────────────────────────── +// +// `incrementalState` is optional, but if present must be a +// `{version, tsBuildInfoText}` object. A stray `incrementalState: 42` +// would otherwise sneak past and crash later when reconstructOldBuilder +// reads `.tsBuildInfoText`. +{ + const tsconfig = tmpFile('tsconfig10.json', '{}'); + const config = configWithMarker('t10-' + Date.now()); + cache.saveCache(tsconfig, config, [], '6.0.0', cache.emptyCache()); + const cacheRoot = path.join(os.tmpdir(), 'tsslint-cache'); + let target: string | null = null; + const stack = [cacheRoot]; + while (stack.length) { + const dir = stack.pop()!; + let entries: fs.Dirent[]; + try { entries = fs.readdirSync(dir, { withFileTypes: true }); } + catch { continue; } + for (const e of entries) { + const full = path.join(dir, e.name); + if (e.isDirectory()) stack.push(full); + else if (e.isFile() && full.endsWith('.cache.json')) { + try { + const o = JSON.parse(fs.readFileSync(full, 'utf8')); + if (o.version === 'v2' && Object.keys(o.files).length === 0) { + target = full; + break; + } + } + catch { /* ignore */ } + } + } + if (target) break; + } + if (target) { + const corrupt = { version: 'v2', ruleModes: {}, files: {}, incrementalState: 42 }; + fs.writeFileSync(target, JSON.stringify(corrupt)); + const loaded = cache.loadCache(tsconfig, config, [], '6.0.0'); + check('incrementalState wrong type → empty cache', loaded.incrementalState === undefined); + + const corrupt2 = { + version: 'v2', ruleModes: {}, files: {}, + incrementalState: { version: 'v3' /* tsBuildInfoText missing */ }, + }; + fs.writeFileSync(target, JSON.stringify(corrupt2)); + const loaded2 = cache.loadCache(tsconfig, config, [], '6.0.0'); + check('incrementalState missing field → empty cache', loaded2.incrementalState === undefined); + } +} + // ── Cleanup ───────────────────────────────────────────────────────────── fs.rmSync(tmp, { recursive: true, force: true }); From 87c8e31152c6285d6dc9fe679ceebfb1b9ad16a3 Mon Sep 17 00:00:00 2001 From: Johnson Chu Date: Sat, 2 May 2026 19:50:28 +0800 Subject: [PATCH 28/32] feat(cli): cap incremental-state size with warn MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The TS internal buildinfo format is compact (~3.6MB on Dify's 5867 files), but pathologically large monorepos could in theory grow past V8's max-string limit, or just make the surrounding JSON.stringify of cacheData feel sticky on every save. Hard cap at 64MB (~10–15× headroom over Dify scale, comfortably below V8's max). If captureIncrementalState gets a payload over that, warn + return undefined — caller persists no incrementalState this session, next run starts cold for layer 2 (layer 1 mtime cache still works). Wrong miss > wrong hit. --- packages/cli/lib/incremental-state.ts | 18 +++++++++++++++ packages/cli/test/incremental-state.test.ts | 25 +++++++++++++++++++++ 2 files changed, 43 insertions(+) diff --git a/packages/cli/lib/incremental-state.ts b/packages/cli/lib/incremental-state.ts index 083ff96e..01556cfa 100644 --- a/packages/cli/lib/incremental-state.ts +++ b/packages/cli/lib/incremental-state.ts @@ -36,6 +36,15 @@ import type * as ts from 'typescript'; // stored in `IncrementalState.tsBuildInfoText`. export const SYNTHETIC_BUILD_INFO_PATH = '/__tsslint__.tsbuildinfo'; +// Hard cap on the captured buildinfo text length. The TS internal +// format is compact (~3.6MB on Dify's 5867 files), so 64MB leaves +// ~10–15× headroom on that scale — comfortably below V8's max string +// length and below the size where JSON.stringify of the surrounding +// cache object starts to feel sticky. If the cap fires we warn + skip +// persistence (cold start next session is preferable to a multi-second +// JSON serialise hit on every run). +const MAX_BUILD_INFO_BYTES = 64 * 1024 * 1024; + export interface IncrementalState { version: string; // Raw text TS wrote via `BuilderProgram.emitBuildInfo`. Opaque to @@ -161,5 +170,14 @@ export function captureIncrementalState( return undefined; } if (!captured) return undefined; + if (captured.length > MAX_BUILD_INFO_BYTES) { + const mb = (captured.length / 1024 / 1024).toFixed(1); + const capMb = MAX_BUILD_INFO_BYTES / 1024 / 1024; + warn( + `Incremental state too large (${mb}MB > ${capMb}MB cap). ` + + `Type-aware cache cannot be persisted — next run will start cold.`, + ); + return undefined; + } return { version: INCREMENTAL_STATE_VERSION, tsBuildInfoText: captured }; } diff --git a/packages/cli/test/incremental-state.test.ts b/packages/cli/test/incremental-state.test.ts index 90b22e75..8ed35b1f 100644 --- a/packages/cli/test/incremental-state.test.ts +++ b/packages/cli/test/incremental-state.test.ts @@ -291,6 +291,31 @@ function captureStderr(fn: () => T): { result: T; stderr: string } { check('version mismatch → no warning', s2 === ''); } +// ── Test 12: oversized buildinfo → undefined + warn ──────────────────── +// Hard cap on `tsBuildInfoText` size protects against pathological +// monorepos where the captured state grows past V8's max-string limit +// or makes JSON.stringify of the surrounding cache feel sticky. Cap +// fires → warn + skip persistence (next run starts cold for layer 2). +{ + // Fabricate a builder that emits a 65MB buildinfo blob. We only + // exercise the size-guard path; the actual TS-emitted text is + // always small in practice (Dify 5867 files = ~3.6MB). + const huge = 'x'.repeat(65 * 1024 * 1024); + const oversizedBuilder = { + emitBuildInfo(write: (path: string, content: string) => void) { + write('whatever', huge); + }, + } as unknown as ts.BuilderProgram; + const { result, stderr } = captureStderr(() => { + try { return inc.captureIncrementalState(ts.version, oversizedBuilder); } + catch { return 'THREW' as const; } + }); + check('oversized buildinfo → no throw', result !== 'THREW'); + check('oversized buildinfo → undefined state', result === undefined); + check('oversized buildinfo → warning printed', /warn/.test(stderr)); + check('oversized buildinfo → warning mentions cap', /cap/.test(stderr)); +} + // ── Done ──────────────────────────────────────────────────────────────── process.stdout.write('\n'); if (failures.length) { From 01a731d7a47a8b48bdf6b09ba71aa8529439c7ca Mon Sep 17 00:00:00 2001 From: Johnson Chu Date: Sat, 2 May 2026 19:50:37 +0800 Subject: [PATCH 29/32] test(cli): pin renderer formatting invariants MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The summary→info blank-line invariant (recently fixed in 8995ed4) was only verified by integration tests eyeballing subprocess stdout. Cover it directly so the formatting rules can't drift silently. Patches process.stdout.write + process.stdout.isTTY around each case to exercise both the TTY-mode separator path and the non-TTY clean-log path. Re-requires the render module per case so the closure-captured isTTY reflects the current value. Verifies all four state transitions (info→info, diagnostic→info, summary→info, summary→info→info), TTY vs non-TTY for each, and edge cases (empty summary is a no-op, diagnostic indents only in TTY). --- packages/cli/test/render.test.ts | 182 +++++++++++++++++++++++++++++++ 1 file changed, 182 insertions(+) create mode 100644 packages/cli/test/render.test.ts diff --git a/packages/cli/test/render.test.ts b/packages/cli/test/render.test.ts new file mode 100644 index 00000000..4d0449be --- /dev/null +++ b/packages/cli/test/render.test.ts @@ -0,0 +1,182 @@ +// Renderer-level invariants that integration tests can only verify by +// eyeballing subprocess stdout. Pin them here so the formatting rules +// don't drift silently: +// +// - `info()` only prepends a separator newline in TTY mode AND when +// the previous emission was "content" (diagnostic or summary). +// - `summary()` always prepends a leading newline (even non-TTY) and +// marks itself as content so any follow-up `info()` (e.g. the +// `--list-rules` block) gets the same separator a diagnostic block +// would have produced. +// - `diagnostic()` indents only in TTY mode, and prepends a newline. +// +// Run via: +// node packages/cli/test/render.test.js + +const failures: string[] = []; +function check(name: string, cond: boolean, detail?: string) { + if (cond) { + process.stdout.write('.'); + } + else { + failures.push(name + (detail ? ' — ' + detail : '')); + process.stdout.write('F'); + } +} + +// Capture stdout while a renderer block runs. Toggles `process.stdout.isTTY` +// for the duration so we can exercise both branches deterministically. +function withCapture(isTTY: boolean, fn: (renderer: import('../lib/render').Renderer) => void): string { + const stdout: any = process.stdout; + const prevWrite = stdout.write.bind(stdout); + const prevIsTTY = stdout.isTTY; + stdout.isTTY = isTTY; + let buf = ''; + stdout.write = (chunk: any) => { + buf += String(chunk); + return true; + }; + try { + // Re-require so isTTY is captured by the new closure. + delete require.cache[require.resolve('../lib/render.js')]; + const render = require('../lib/render.js') as typeof import('../lib/render.js'); + fn(render.createRenderer()); + } + finally { + stdout.write = prevWrite; + stdout.isTTY = prevIsTTY; + delete require.cache[require.resolve('../lib/render.js')]; + } + return buf; +} + +// ── Test 1: info() in TTY adds no blank line on first call ───────────── +{ + const out = withCapture(true, r => { + r.info('hello'); + }); + check('first info has no leading blank line', out === 'hello\n', `got: ${JSON.stringify(out)}`); +} + +// ── Test 2: info() after info() — no separator ───────────────────────── +{ + const out = withCapture(true, r => { + r.info('a'); + r.info('b'); + }); + check('info→info: no blank between', out === 'a\nb\n', `got: ${JSON.stringify(out)}`); +} + +// ── Test 3: diagnostic→info in TTY: blank-line separator ─────────────── +{ + const out = withCapture(true, r => { + r.diagnostic('foo.ts:1:1 - error'); + r.info('summary line'); + }); + // diagnostic prepends \n, indents in TTY; info prepends \n because + // lastWasContent is true. + check( + 'diagnostic→info: separator in TTY', + out === '\n foo.ts:1:1 - error\n\nsummary line\n', + `got: ${JSON.stringify(out)}`, + ); +} + +// ── Test 4: diagnostic→info in non-TTY: NO separator (info gates on TTY) ─ +{ + const out = withCapture(false, r => { + r.diagnostic('foo.ts:1:1 - error'); + r.info('summary line'); + }); + check( + 'diagnostic→info: no separator in non-TTY', + out === '\nfoo.ts:1:1 - error\nsummary line\n', + `got: ${JSON.stringify(out)}`, + ); +} + +// ── Test 5: summary→info in TTY: blank-line separator ────────────────── +// This is the recently fixed invariant. summary() must mark itself as +// content so --list-rules block reads visually as its own section. +{ + const out = withCapture(true, r => { + r.summary(['5 passed']); + r.info('type-aware (1)'); + }); + check( + 'summary→info: separator in TTY', + out === '\n5 passed\n\ntype-aware (1)\n', + `got: ${JSON.stringify(out)}`, + ); +} + +// ── Test 6: summary→info in non-TTY: still no separator ──────────────── +{ + const out = withCapture(false, r => { + r.summary(['5 passed']); + r.info('type-aware (1)'); + }); + check( + 'summary→info: no separator in non-TTY', + out === '\n5 passed\ntype-aware (1)\n', + `got: ${JSON.stringify(out)}`, + ); +} + +// ── Test 7: summary→info→info: only first info gets the separator ────── +// Follow-up `info()`s reset lastWasContent to false, so they're flush +// against each other. +{ + const out = withCapture(true, r => { + r.summary(['done']); + r.info('a'); + r.info('b'); + }); + check( + 'summary→info→info: only first info gets separator', + out === '\ndone\n\na\nb\n', + `got: ${JSON.stringify(out)}`, + ); +} + +// ── Test 8: empty summary is a no-op (doesn't trip the content flag) ─── +{ + const out = withCapture(true, r => { + r.summary([]); + r.info('after empty'); + }); + check( + 'empty summary doesn\'t set content flag', + out === 'after empty\n', + `got: ${JSON.stringify(out)}`, + ); +} + +// ── Test 9: diagnostic indents only in TTY ───────────────────────────── +{ + const ttyOut = withCapture(true, r => { + r.diagnostic('line1\nline2'); + }); + const nonTtyOut = withCapture(false, r => { + r.diagnostic('line1\nline2'); + }); + check( + 'diagnostic indents in TTY', + ttyOut === '\n line1\n line2\n', + `got: ${JSON.stringify(ttyOut)}`, + ); + check( + 'diagnostic doesn\'t indent in non-TTY', + nonTtyOut === '\nline1\nline2\n', + `got: ${JSON.stringify(nonTtyOut)}`, + ); +} + +// ── Done ────────────────────────────────────────────────────────────── +process.stdout.write('\n'); +if (failures.length) { + console.error(`\n${failures.length} failure(s):`); + for (const f of failures) console.error(' - ' + f); + process.exit(1); +} +console.log('OK'); From 35701b367c99bf2f0919a3d1d0e2bf60c40f5b34 Mon Sep 17 00:00:00 2001 From: Johnson Chu Date: Sat, 2 May 2026 19:50:41 +0800 Subject: [PATCH 30/32] docs(cli): drop CACHE.md --- packages/cli/CACHE.md | 298 ------------------------------------------ 1 file changed, 298 deletions(-) delete mode 100644 packages/cli/CACHE.md diff --git a/packages/cli/CACHE.md b/packages/cli/CACHE.md deleted file mode 100644 index aaeb26e4..00000000 --- a/packages/cli/CACHE.md +++ /dev/null @@ -1,298 +0,0 @@ -# CLI cache: design notes - -This package previously shipped a per-file mtime cache. It was removed -because it was unsound for type-aware rules — see commit removing -`packages/cli/lib/cache.ts` for context. This file lays out how the -replacement should be built. - -## What broke in 3.1.0 - -The 3.1.0 cache invalidated cached diagnostics only on the linted -file's own mtime. Type-aware rules (rules that read -`rulesContext.program` to look up cross-file types) depend on -declarations in *other* files; mtime on the linted file doesn't move -when those dependencies change. Cached results went silently stale. - -3.0.4 had a partial guard: it ran rules first against a syntax-only -`LanguageService` whose `program` getter threw on access, caught the -throw, marked the rule type-aware, and excluded type-aware rules from -cache writes. The syntax-only path was removed in c5a8c25; the -cache-skip side effect was lost with it. - -## What replaces it - -Two layers, gated by a flag. - -### Layer 1: per-file mtime cache for syntactic rules - -When a rule's diagnostic is a pure function of the linted file's -text, its cached result is valid as long as the file's mtime hasn't -moved. The cache file format records, per `(file, rule)`: -- the source file's mtime at last lint -- the diagnostic list (and whether any had a fix) - -On read: if `mtime(file) === cached.mtime` and the rule is classified -as syntactic, reuse the cached diagnostics; otherwise re-run the rule. - -Rule classification uses a getter probe on `rulesContext.program` — -the same mechanism implemented in commit 4f4f923. Once a rule -touches `program` in any session, it's marked type-aware (sticky) -and never cached. - -This layer always runs, regardless of any user flag. - -### Layer 2: BuilderProgram-based invalidation for type-aware rules - -ESLint-style per-file mtime can't see global changes (`declare global`, -ambient `.d.ts`, lib files, module augmentation, `@types/*`) that -change a file's effective types without changing its text. TypeScript -already tracks this internally via `BuilderProgram` — the same -machinery that powers `tsc --incremental`. - -When the `--incremental` flag is passed (or the equivalent config -option): - -1. CLI creates a `ts.createSemanticDiagnosticsBuilderProgram` wrapping - the `ts.LanguageService`'s program. The first run has no - `oldProgram`; everything is "affected". -2. Builder serializes its state to a `.tsbuildinfo` file in TSSLint's - own cache directory (not the user's tsconfig path — that would - collide with their own `tsc` runs). State persists across sessions. -3. Next session: load the previous `.tsbuildinfo`, pass as `oldProgram` - to `createSemanticDiagnosticsBuilderProgram`. Walk - `getSemanticDiagnosticsOfNextAffectedFile()` — those are the files - whose type-relevant inputs (own content, transitive imports, - ambient declarations, lib changes) have moved. Type-aware rule - diagnostics for unaffected files reuse the previous cache. - -Type-aware rule diagnostics get written to the cache only when this -layer is active. Without `--incremental`, type-aware rules always -re-run (matches 3.0.4 behavior). - -## Cache file format - -Single JSON file at `os.tmpdir()/tsslint-cache//.cache.json`. -Path key already invalidates on tsslint version change, tsslint.config.ts -mtime/size, tsconfig path, and language plugin set. - -Shape (proposed): - -```jsonc -{ - "version": "v2", - // Sticky type-aware classification across sessions. Once a rule has - // been observed reading `program` in any past session, it stays - // type-aware until it disappears from the config. - "ruleModes": { - "no-leaked-conditional-rendering": "type-aware", - "semi": "syntactic" - }, - // Per-file diagnostic cache. Type-aware rule entries only present - // when layer 2 (BuilderProgram) is active. - "files": { - "/abs/path/foo.ts": { - "mtime": 1234567890, - "rules": { - "semi": { "hasFix": false, "diagnostics": [] }, - "no-leaked-conditional-rendering": { - "hasFix": false, - "diagnostics": [], - // Only present in layer 2 mode. Tracks which BuilderProgram - // version produced this — invalidate if mismatch on load. - "buildSignature": "" - } - } - } - } -} -``` - -The 3.0.4 / 3.1.0 cache used a tuple `[mtime, lintResult, minimatchResult]`. -Drop the tuple in favor of a self-documenting object — it cost no -measurable space but made the format opaque and migration painful. - -`minimatchResult` (per-file pattern→bool cache) is dropped. It was -useful to skip re-running glob matches across runs but the actual -cost is microseconds per file and it added a third tuple slot of -serialization overhead per file. If profiling later shows it back -on the hot path, add it as a separate top-level key, not woven in. - -## Implementation outline - -Order matters — each step compiles and ships independently: - -1. **Restore the getter probe in `core/index.ts`** (already landed in - 4f4f923, removed alongside cache here). Doesn't write or read any - cache yet — just classifies rules. - -2. **Add layer 1**: cache file load/save in CLI; per-file mtime - invalidation; per-rule lint short-circuit. Type-aware rules never - cache. Persist `ruleModes` in the cache file so a session starting - cold knows which rules to skip without re-probing. - -3. **Add `--incremental` flag**: create `BuilderProgram` alongside - the existing `LanguageService`. Use `getSemanticDiagnosticsOfNext- - AffectedFile()` to enumerate which files BuilderProgram considers - changed since last run. Cross-reference with `ruleModes`: - - syntactic rules: layer 1 cache (mtime-only) is authoritative - - type-aware rules: cache hit only if file is unaffected per - BuilderProgram AND ruleModes hasn't changed - -4. **Manage `.tsbuildinfo`**: enable `incremental: true` in TSSLint's - internal program options (NOT the user's tsconfig). Set - `tsBuildInfoFile` to a path under TSSLint's cache directory. - -5. **Tests** in `packages/core/test/` and `packages/cli/test/`: - - syntactic rule cached, type-aware rule not (without `--incremental`) - - sticky classification across files - - persistent classification across sessions (load `ruleModes` on init) - - layer 2: edit `globals.d.ts`, type-aware rule re-runs for - affected files but not others - - layer 2: `.tsbuildinfo` corrupted → graceful cache miss - -## Edge cases - -- **`incremental: true` in user tsconfig**: TSSLint's internal program - must use its own `tsBuildInfoFile` path. Don't let the user's tsc - build state collide with TSSLint's. -- **`noEmit: true`**: confirmed compatible with `incremental` — TS - still writes the buildinfo, just doesn't emit JS. Set both. -- **`.tsbuildinfo` format across TS major versions**: TS doesn't - guarantee format stability. On parse error, treat as cache miss - and rebuild. Already the pattern in old `cache.ts:21`. -- **First run cold**: no `oldProgram`, everything affected, full lint. - Same cost as `--force` today. -- **`--fix` runs**: if any fix wrote to the file, mtime moves and - layer 1 invalidates that file's entries; layer 2's BuilderProgram - picks up the snapshot change via project version bump. -- **Rule that conditionally reads `program`**: classified as syntactic - on a session that doesn't hit the type-aware branch. Later session - hits the branch → upgrade to type-aware, persist in `ruleModes`. - The cached results from before the upgrade are stale but the upgrade - is a one-time event per cache lifetime; users running `--force` - once after upgrading TSSLint clears any residue. - -## Backwards compat - -None needed for cache files. The cache file path key includes -`pkg.version`; version bumps create fresh cache directories. -Old cache files just sit unused until OS tmpdir cleanup. - -The TSSLint *config API* (`@tsslint/types`) loses `Reporter.withoutCache()`. -That method only made sense when the linter had a cache to suppress -writes to; without one, calling it was a no-op. The new cache impl -may reintroduce a per-report cache opt-out if needed — design TBD, -likely a different shape (e.g. `withDependencies(...filePaths)` or -similar declarative form) once layer 2 lands. - -## Pre-implementation checklist - -Run through these before writing any cache code. Each one corresponds -to a real failure mode the old design either hit or could have hit. - -### Soundness - -1. **Write the regression tests first**, before any cache code: - - Edit `globals.d.ts` (`declare global { ... }`) → linted file must - re-check - - Edit imported file's exported type → linted file must re-check - - Switch `compilerOptions.lib` → all files re-check - - Edit `compilerOptions` other than lib → whole cache invalidates - - Add/remove rule in tsslint.config.ts → that rule's entries clear - - Rule that conditionally reads `program` → after sticky upgrade, - no stale serve - - Each test corresponds to one invalidation path. Tests failing on - un-implemented spec is the goal — finishing them all defines done. - -2. **Cache miss is always safe**. Treat any uncertainty as a miss: - - `.tsbuildinfo` parse failure → miss - - `ruleModes` shape mismatch → miss - - `getProgram()` returns null → miss - - File stat throws → miss - - Wrong cache hit corrupts a code-review tool. Wrong miss costs a - re-run. Bias hard. - -3. **`--fix` writes a file**. After a fix, that file's mtime moves - and its layer-1 entry must invalidate. If the fixed file is an - import dep of another, layer-2's BuilderProgram must see the - snapshot change and mark that other file affected. Both paths - need a regression test. - -### Cache key - -4. **Include TypeScript's version in the cache path key**. The current - key is `os.tmpdir()/tsslint-cache//` — extend - to `//`. `.tsbuildinfo` format - is TS-major-coupled; without this, a TS upgrade silently corrupts - layer 2. - -5. **Cache key already covers**: tsslint.config.ts mtime+size, tsconfig - path, language plugins. Don't break this on the rewrite. - -### Implementation - -6. **Atomic write**: `writeFileSync(file.tmp); rename(file.tmp, file)`. - Old `cache.ts` wrote in-place — SIGINT during write left half-JSON. - Cheap to fix. - -7. **BuilderProgram + LanguageService coexistence**: CLI uses - `ts.createLanguageService(host)`, which owns its program internally. - Wrapping in BuilderProgram requires `builder.getProgram() === - languageService.getProgram()` to avoid double-building. Spike a - 10-line POC standalone before touching the real CLI flow. - -8. **Multi-project / virtual files (Vue / MDX / Astro)**: language - plugins synthesize virtual `.ts` files with magic suffixes. Cache - key uses absolute path; verify virtual paths don't collide with - real ones in a fixture test before relying on it. - -### Operational - -9. **Add `--force` flag back**. Removed in the cache deletion. Users - need an escape hatch when something breaks. - -10. **Bench a warm-cache scenario**. `tsslint-dify-bench` only tests - cold runs; can't measure cache value or catch stale-result bugs. - Add: cold run → identical-input warm run (cache hit expected) → - edit `globals.d.ts` and warm run again (selective miss expected). - This is both perf metric and soundness regression gate. - -### Realistic upper bound - -Profile attached in CPU.20260430.062953.cpuprofile shows the cold-run -breakdown for Dify web/ (5860 files): - -``` -~5.0 s TS Program build (parse + bind + resolve) — cache CANNOT save -~4.4 s lint pass (rule execution + walker) — cache CAN save -~1.8 s process startup + render output — cache CANNOT save -───── -11.3 s total -``` - -`BuilderProgram` does not speed up cold-run program build — `.tsbuildinfo` -stores file signatures and shape hashes, not parsed AST, so parse+bind -must happen every cold start. The cache saves the lint pass on warm -runs. **Upper bound: ~40% wall-time reduction on warm runs**, capped -by the unavoidable Program build. - -Set this as the warm-run target before starting; if implementation -doesn't get close, something's wrong with the design. - - -## Open questions - -- Should layer 2 be opt-in (`--incremental`) or default-on once it's - proven? Default-on is the friendlier UX but `BuilderProgram` adds - some startup overhead and writes a cache file the user has to know - about. Default-off until the perf cost is measured. -- Per-rule classification persistence: should we expire `ruleModes` - entries when a rule disappears from config? Or keep forever? Keep - forever is simpler; the file grows by one string per rule ever - used, which is bounded. -- Should the cache work for `--fix` runs? Currently `--fix` runs - rewrite files; cache stays correct as long as we update mtime - after write. Layer 2's BuilderProgram automatically tracks the - snapshot change. No special handling needed. From f8e8a78182a19753e0b756eac737f91a5c504469 Mon Sep 17 00:00:00 2001 From: Johnson Chu Date: Sat, 2 May 2026 20:14:26 +0800 Subject: [PATCH 31/32] =?UTF-8?q?fix:=20restore=20Reporter.withoutCache()?= =?UTF-8?q?=20=E2=80=94=20removing=20it=20was=20breaking?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 3.1.0 (e05416f) shipped Reporter.withoutCache() as the per-diagnostic opt-out for findings whose correctness depends on inputs the cache doesn't track. Dropping it in 47f5410 silently broke any rule that relied on it — the call site would hit a TypeError at runtime, or fail to type-check if written in TS. Restore the API with semantics that match the original: - Diagnostic is still returned for the current run. - cache-flow filters it out before serialising the rule entry to disk, so the next warm hit on this file won't replay it (rule has to re-run to surface it again). Marker is a Symbol.for('@tsslint/no-cache') stamped on the diagnostic in core. Symbol-keyed → invisible to JSON.stringify and to {...spread}, so it doesn't leak into the on-disk cache. Test 17 covers that explicitly. README's "Caching" section is rewritten to describe the new two-layer model and explain when withoutCache() still makes sense (external inputs / fs-driven side reads); for cross-file types, the recommended path is to read ctx.program once so layer 2 handles invalidation. Tests: - cache-flow.test #15: marked diagnostic returned but not persisted - cache-flow.test #16: warm replay drops the marked one - cache-flow.test #17: marker doesn't leak through serialisation --- README.md | 11 +++- packages/cli/lib/cache-flow.ts | 11 +++- packages/cli/test/cache-flow.test.ts | 92 ++++++++++++++++++++++++++++ packages/core/index.ts | 11 ++++ packages/types/index.ts | 10 +++ 5 files changed, 130 insertions(+), 5 deletions(-) diff --git a/README.md b/README.md index 07bb9dc7..b0792b88 100644 --- a/README.md +++ b/README.md @@ -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 diff --git a/packages/cli/lib/cache-flow.ts b/packages/cli/lib/cache-flow.ts index 3f4d6afd..467c4e6e 100644 --- a/packages/cli/lib/cache-flow.ts +++ b/packages/cli/lib/cache-flow.ts @@ -9,7 +9,7 @@ // type-aware cleanup. import type * as ts from 'typescript'; -import type { Linter } from '@tsslint/core'; +import { NO_CACHE, type Linter } from '@tsslint/core'; import type { FileCache, SerializedDiagnostic } from './cache.js'; export function lintWithCache( @@ -97,9 +97,16 @@ export function lintWithCache( break; } } + // Drop diagnostics the rule marked via `Reporter.withoutCache()` — + // the rule is asserting they depend on inputs we don't track in + // the cache key, so a warm replay would be unsound. They're still + // included in the live `fresh` array returned from this function; + // they just don't survive to disk. fileCache.rules[ruleId] = { hasFix, - diagnostics: diags.map(serializeDiagnostic), + diagnostics: diags + .filter(d => !(d as any)[NO_CACHE]) + .map(serializeDiagnostic), }; } diff --git a/packages/cli/test/cache-flow.test.ts b/packages/cli/test/cache-flow.test.ts index 121fca54..ed912e50 100644 --- a/packages/cli/test/cache-flow.test.ts +++ b/packages/cli/test/cache-flow.test.ts @@ -427,6 +427,98 @@ function emptyFileCache(mtime = 0): FileCache { ); } +// ── Test 15: withoutCache() — diagnostic returned but not persisted ───── +// +// `Reporter.withoutCache()` is the rule's contract: "this finding's +// correctness depends on inputs neither layer tracks; please don't +// replay it from the cache." The current run still surfaces the +// diagnostic; it just isn't written to disk, so the next warm hit on +// the same file won't see it. +{ + const ctx = makeContext({ '/a.ts': 'const x = 1;' }); + const config: Config = { + rules: { + env_dependent: ((rctx: RuleContext) => { + rctx.report('depends on env', 0, 1).withoutCache(); + rctx.report('plain', 0, 1); + }), + }, + }; + const linter = core.createLinter(ctx, '/', config, () => []); + const cache = emptyFileCache(1); + const diags = cacheFlow.lintWithCache( + linter, + '/a.ts', + cache, + 1, + ctx.languageService.getProgram()!, + ); + check('current run returns both diagnostics', diags.length === 2); + check( + 'cache entry exists for the rule', + !!cache.rules['env_dependent'], + ); + check( + 'only the non-marked diagnostic is persisted', + cache.rules['env_dependent']?.diagnostics.length === 1, + `expected 1 cached diagnostic, got ${cache.rules['env_dependent']?.diagnostics.length}`, + ); + check( + 'persisted diagnostic is the plain one', + cache.rules['env_dependent']?.diagnostics[0]?.messageText === 'plain', + ); +} + +// ── Test 16: withoutCache() — second run cache-hits with the survivor ─── +{ + const ctx = makeContext({ '/a.ts': 'const x = 1;' }); + const config: Config = { + rules: { + env_dependent: ((rctx: RuleContext) => { + rctx.report('depends on env', 0, 1).withoutCache(); + rctx.report('plain', 0, 1); + }), + }, + }; + const linter = core.createLinter(ctx, '/', config, () => []); + const cache = emptyFileCache(1); + cacheFlow.lintWithCache(linter, '/a.ts', cache, 1, ctx.languageService.getProgram()!); + + // Second run with the same mtime — rule cache-hits, only the + // persisted diagnostic comes back. + const linter2 = core.createLinter(ctx, '/', config, () => []); + const diags = cacheFlow.lintWithCache( + linter2, + '/a.ts', + cache, + 1, + ctx.languageService.getProgram()!, + ); + check('warm cache hit returns 1 diagnostic', diags.length === 1); + check('warm replay drops the marked one', diags[0]?.messageText === 'plain'); +} + +// ── Test 17: NO_CACHE marker doesn't leak through serialisation ───────── +// +// Symbol-keyed property must stay invisible to JSON.stringify and to +// `{...spread}` so the on-disk cache stays clean. +{ + const ctx = makeContext({ '/a.ts': 'const x = 1;' }); + const config: Config = { + rules: { + r: ((rctx: RuleContext) => { + rctx.report('marked', 0, 1).withoutCache(); + }), + }, + }; + const linter = core.createLinter(ctx, '/', config, () => []); + const cache = emptyFileCache(1); + cacheFlow.lintWithCache(linter, '/a.ts', cache, 1, ctx.languageService.getProgram()!); + // rule entry exists but with 0 diagnostics — no smuggled keys. + const json = JSON.stringify(cache); + check('NO_CACHE marker not visible in serialised cache', !json.includes('no-cache')); +} + // ── Done ──────────────────────────────────────────────────────────────── process.stdout.write('\n'); if (failures.length) { diff --git a/packages/core/index.ts b/packages/core/index.ts index 65fa91a4..e2a29913 100644 --- a/packages/core/index.ts +++ b/packages/core/index.ts @@ -6,6 +6,13 @@ import minimatch = require('minimatch'); export type Linter = ReturnType; +// Marker stamped onto diagnostics by `Reporter.withoutCache()`. The CLI +// cache-flow filters these out before serialising to disk so they're +// never replayed from a warm cache hit. Symbol-keyed (via Symbol.for so +// it's stable across module instances) — invisible to JSON.stringify and +// to `{...spread}` so it doesn't leak into the serialised cache. +export const NO_CACHE = Symbol.for('@tsslint/no-cache'); + export function createLinter( ctx: LinterContext, rootDir: string, @@ -199,6 +206,10 @@ export function createLinter( }); return this; }, + withoutCache() { + (error as any)[NO_CACHE] = true; + return this; + }, }; } }, diff --git a/packages/types/index.ts b/packages/types/index.ts index 41b7a960..57ed8c97 100644 --- a/packages/types/index.ts +++ b/packages/types/index.ts @@ -56,4 +56,14 @@ export interface Reporter { withUnnecessary(): Reporter; withFix(title: string, getChanges: () => FileTextChanges[]): Reporter; withRefactor(title: string, getChanges: () => FileTextChanges[]): Reporter; + // Mark this diagnostic as ineligible for the CLI's per-file cache. + // The diagnostic is still returned for the current run, but won't be + // written to disk — so the next warm run (cache hit on this file) + // won't replay it, and the rule must re-run to surface it again. + // Use when a diagnostic's correctness depends on inputs the layer-1 + // mtime check doesn't track (external resources, env, sibling files + // the rule reads directly via fs). For type-checker-derived findings + // just read `ctx.program` once — that re-classifies the rule + // type-aware, and layer 2 handles cross-file invalidation properly. + withoutCache(): Reporter; } From d8a466d7d89e257ba0aa7c5f4ce59d8d149afff1 Mon Sep 17 00:00:00 2001 From: Johnson Chu Date: Sat, 2 May 2026 20:20:38 +0800 Subject: [PATCH 32/32] test: pin mixed-mode rule classification + cache-flow soundness MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Document the runtime probe's behavior on rules that file-shape-filter before reading ctx.program. Two scenarios that came up during review but weren't directly covered: - probe.test #5 (regression): early-return file processed FIRST in a session leaves the rule unclassified for that invocation, but the next file that does touch program flips classification mid-session, and it sticks even on subsequent early-return calls. - cache-flow.test #17 (regression): in the same scenario, the early-return file's cache entry replays cleanly on a later warm hit — even after the rule is globally classified type-aware. Sound because the early-return path's output is a deterministic function of file text alone (mtime catches changes), and the type-aware path simply never executes for that file. Both pin the user-facing invariant: "a rule that does file-type filtering before accessing program won't be silently mis-classified in a way that produces stale cached diagnostics." --- packages/cli/test/cache-flow.test.ts | 85 +++++++++++++++++++++++++++- packages/core/test/probe.test.ts | 52 ++++++++++++++++- 2 files changed, 135 insertions(+), 2 deletions(-) diff --git a/packages/cli/test/cache-flow.test.ts b/packages/cli/test/cache-flow.test.ts index ed912e50..54e942f0 100644 --- a/packages/cli/test/cache-flow.test.ts +++ b/packages/cli/test/cache-flow.test.ts @@ -498,7 +498,90 @@ function emptyFileCache(mtime = 0): FileCache { check('warm replay drops the marked one', diags[0]?.messageText === 'plain'); } -// ── Test 17: NO_CACHE marker doesn't leak through serialisation ───────── +// ── Test 17 (regression): mixed-mode rule, early-return file replays sound +// +// A rule that file-shape-filters before reading `program` will, for the +// early-returning file, finish without touching the probe. Verify the +// resulting cache entry replays correctly across sessions even after the +// rule is globally classified type-aware (because some OTHER file did +// touch program). The early-return path's output is a deterministic +// function of file text alone — replaying it on a warm hit should match +// what re-running the rule would produce. +{ + const ctx = makeContext({ + '/skip.ts': 'const x = 1;', + '/check.ts': 'const y = 2;', + }); + const config: Config = { + rules: { + 'mixed-mode': ((rctx: RuleContext) => { + if (rctx.file.fileName === '/skip.ts') return; + void rctx.program; + rctx.report('typed', 0, 1); + }), + }, + }; + + // ── Session 1: cold, both files lint — process the early-return file + // FIRST so the rule isn't yet type-aware when its entry gets written. + const linter1 = core.createLinter(ctx, '/', config, () => []); + const program1 = ctx.languageService.getProgram()!; + const cacheSkip: FileCache = emptyFileCache(1); + const cacheCheck: FileCache = emptyFileCache(1); + cacheFlow.lintWithCache(linter1, '/skip.ts', cacheSkip, 1, program1); + cacheFlow.lintWithCache(linter1, '/check.ts', cacheCheck, 1, program1); + + check( + 'session 1: rule classified type-aware after both files', + linter1.getTypeAwareRules().has('mixed-mode'), + ); + check( + 'session 1: early-return file got an entry (rule wasn\'t yet type-aware at write time)', + !!cacheSkip.rules['mixed-mode'], + ); + check( + 'session 1: early-return file\'s entry has 0 diagnostics (rule reported nothing)', + cacheSkip.rules['mixed-mode']?.diagnostics.length === 0, + ); + + // ── Session 2: rule pre-classified type-aware (from session 1). + // Both files unchanged. typeAwareUnaffected=true → both should + // cache-hit and replay cleanly. + const linter2 = core.createLinter(ctx, '/', config, () => [], ['mixed-mode']); + const program2 = ctx.languageService.getProgram()!; + let earlyReturnRanInSession2 = false; + const config2: Config = { + rules: { + 'mixed-mode': ((rctx: RuleContext) => { + earlyReturnRanInSession2 = true; + if (rctx.file.fileName === '/skip.ts') return; + void rctx.program; + rctx.report('typed', 0, 1); + }), + }, + }; + const linterMonitored = core.createLinter(ctx, '/', config2, () => [], ['mixed-mode']); + const session2Skip = cacheFlow.lintWithCache( + linterMonitored, + '/skip.ts', + cacheSkip, + 1, + program2, + { incremental: true, typeAwareUnaffected: true }, + ); + check( + 'session 2: warm hit on early-return file replays empty diagnostics', + session2Skip.length === 0, + ); + check( + 'session 2: rule body did NOT execute on early-return file (cache skipped it)', + !earlyReturnRanInSession2, + 'cache-hit means we skip the rule entirely — body shouldn\'t run', + ); + void linter2; // type-only ref; linterMonitored is the one we observe +} + +// ── Test 18: NO_CACHE marker doesn't leak through serialisation ───────── // // Symbol-keyed property must stay invisible to JSON.stringify and to // `{...spread}` so the on-disk cache stays clean. diff --git a/packages/core/test/probe.test.ts b/packages/core/test/probe.test.ts index 9e2e8cac..db68a9cc 100644 --- a/packages/core/test/probe.test.ts +++ b/packages/core/test/probe.test.ts @@ -137,7 +137,57 @@ function makeContext(files: Record) { ); } -// ── Test 5: getTypeAwareRules returns live set; mutations not allowed ──── +// ── Test 5 (regression): early-return-then-type-aware, reverse order ──── +// +// User concern: a rule that file-shape-filters before reading +// `program` will be classified syntactic for the early-returning +// invocation. Verify that running the EARLY-RETURN file FIRST doesn't +// permanently mis-classify the rule — the next file that does touch +// program flips classification to type-aware mid-session, and it +// stays sticky. +{ + const ctx = makeContext({ + '/skip.ts': 'const x = 1;', + '/check.ts': 'const y = 2;', + }); + const config: Config = { + rules: { + 'mixed-mode': ((rctx: RuleContext) => { + // Cheap pre-filter — only files matching the predicate + // take the type-aware branch. /skip.ts early-returns. + if (rctx.file.fileName === '/skip.ts') return; + void rctx.program; + rctx.report('typed', 0, 1); + }), + }, + }; + const linter = core.createLinter(ctx, '/', config, () => []); + + // Process the early-return file FIRST. + linter.lint('/skip.ts'); + check( + 'after early-return file: not yet classified', + !linter.getTypeAwareRules().has('mixed-mode'), + 'sanity — probe correctly observed no program access on /skip.ts', + ); + + // Then the type-aware file. Classification flips mid-session. + linter.lint('/check.ts'); + check( + 'after type-aware file: classified type-aware', + linter.getTypeAwareRules().has('mixed-mode'), + ); + + // And it stays — re-linting the early-return file doesn't unclassify. + linter.lint('/skip.ts'); + check( + 'classification sticks even after a subsequent early-return invocation', + linter.getTypeAwareRules().has('mixed-mode'), + 'sticky semantics: a rule that has ever been observed touching program stays type-aware', + ); +} + +// ── Test 6: getTypeAwareRules returns live set; mutations not allowed ──── { const ctx = makeContext({ '/a.ts': 'const x = 1;' }); const config: Config = {