diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 6353ef5..67524d9 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -58,6 +58,31 @@ jobs: - name: TS-AST scan run: node packages/compat-eslint/test/ts-ast-scan.test.js - # Core: type-aware rule cache classification (probe + sticky). - - name: Core cache type-aware classification - run: node packages/core/test/cache-typeaware.test.js + # Core: type-aware rule probe (foundation for layer 1 cache). + - name: Core probe + run: node packages/core/test/probe.test.js + + # Core: BuilderProgram POC for layer 2 cache invalidation. + - name: Core BuilderProgram POC + run: node packages/core/test/builder-program-poc.test.js + + # Core: skip-rules option + hasFixForDiagnostic API (the surface + # the CLI cache layer uses). + - name: Core skip-rules + run: node packages/core/test/skip-rules.test.js + + # CLI: cache file load / save / atomic write / version key. + - name: CLI cache module + run: node packages/cli/test/cache.test.js + + # CLI: cache-flow — layer 1 cache integration with the linter. + - name: CLI cache-flow + run: node packages/cli/test/cache-flow.test.js + + # CLI: end-to-end integration via subprocess on a temp fixture. + - name: CLI integration + run: node packages/cli/test/integration.test.js + + # CLI: layer 2 cross-session affected-file diff. + - name: CLI incremental state + run: node packages/cli/test/incremental-state.test.js diff --git a/README.md b/README.md index 07bb9dc..b0792b8 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/index.ts b/packages/cli/index.ts index 90c18ba..a1222dc 100644 --- a/packages/cli/index.ts +++ b/packages/cli/index.ts @@ -4,8 +4,8 @@ require('./lib/fs-cache.js'); import ts = require('typescript'); import path = require('path'); -import cache = require('./lib/cache.js'); import worker = require('./lib/worker.js'); +import cache = require('./lib/cache.js'); import fs = require('fs'); import minimatch = require('minimatch'); import languagePlugins = require('./lib/languagePlugins.js'); @@ -26,8 +26,9 @@ Options: --ts-macro-project Lint TS Macro projects --filter Filter files to lint --fix Apply automatic fixes - --force Ignore cache + --force Ignore cache (re-lint every file) --failures-only Only print errors and messages (skip warnings and suggestions) + --list-rules After linting, print each rule's classification (syntactic / type-aware) -h, --help Show this help message Examples: @@ -82,7 +83,7 @@ class Project { options: ts.CompilerOptions = {}; configFile: string | undefined; currentFileIndex = 0; - cache: cache.CacheData = {}; + cacheData: cache.CacheData = cache.emptyCache(); pendingHeader: string | undefined; constructor( @@ -149,8 +150,18 @@ class Project { colors.gray(`(${this.fileNames.length}${filteredLengthDiff ? `, skipped ${filteredLengthDiff}` : ''})`) }`; + // Load layer-1 cache unless --force was passed. The cache file path + // key includes tsslint version, TS version, tsconfig, languages, + // and configFile mtime+size — anything that changes the rule set or + // the toolchain mints a fresh file. See packages/cli/lib/cache.ts. if (!process.argv.includes('--force')) { - this.cache = cache.loadCache(this.tsconfig, this.configFile, this.languages, ts.sys.createHash); + this.cacheData = cache.loadCache( + this.tsconfig, + this.configFile, + this.languages, + ts.version, + ts.sys.createHash, + ); } return this; @@ -177,12 +188,12 @@ const formatHost: ts.FormatDiagnosticsHost = { let hasFix = false; let allFilesNum = 0; let processed = 0; + let cached = 0; let passed = 0; let errors = 0; let warnings = 0; let messages = 0; let suggestions = 0; - let cached = 0; let configErrors = 0; const failuresOnly = process.argv.includes('--failures-only'); @@ -316,6 +327,41 @@ const formatHost: ts.FormatDiagnosticsHost = { } renderer.summary(summaryLines); + + if (process.argv.includes('--list-rules')) { + // Derive from the per-project cacheData: any rule with an entry + // in `ruleModes` is type-aware; the rest came up as cached + // entries on per-file maps without being classified, so they + // ran as syntactic. Output is grouped + alphabetised so it's + // stable across runs and easy to diff. + const typeAware = new Set(); + 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); + + // Always emit both headers — even with count 0 — so the format + // stays consistent across runs (otherwise a project with only + // type-aware rules looks visually different from one with both). + const lines: string[] = []; + lines.push(colors.cyan('type-aware') + colors.gray(` (${typeAware.size})`)); + for (const id of [...typeAware].sort()) lines.push(' ' + id); + lines.push(colors.cyan('syntactic') + colors.gray(` (${syntactic.size})`)); + for (const id of [...syntactic].sort()) lines.push(' ' + id); + for (const l of lines) renderer.info(l); + } + renderer.dispose(); process.exit((errors || messages || configErrors) ? 1 : 0); @@ -342,6 +388,8 @@ const formatHost: ts.FormatDiagnosticsHost = { project.configFile!, project.rawFileNames, project.options, + Object.keys(project.cacheData.ruleModes), + project.cacheData.incrementalState, ); if (setupResult !== true) { renderer.diagnostic(formatConfigError(project.configFile!, setupResult)); @@ -359,32 +407,37 @@ const formatHost: ts.FormatDiagnosticsHost = { continue; } - let fileCache = project.cache[fileName]; - if (fileCache) { - if (fileCache[0] !== fileStat.mtimeMs) { - fileCache[0] = fileStat.mtimeMs; - fileCache[1] = {}; - fileCache[2] = {}; - } - else { - cached++; - } + let fileCache = project.cacheData.files[fileName]; + if (!fileCache) { + fileCache = { mtime: fileStat.mtimeMs, rules: {} }; + project.cacheData.files[fileName] = fileCache; } - else { - project.cache[fileName] = fileCache = [fileStat.mtimeMs, {}, {}]; + else if (fileCache.mtime === fileStat.mtimeMs && Object.keys(fileCache.rules).length) { + // File text untouched since the prev session AND we have at + // least one rule's diagnostics cached for it — treat as a + // warm hit for the `--force` summary hint. Layer 2's BP + // might still re-run type-aware rules if their deps moved, + // but the user-visible signal here is just "cache had + // something for this file." + cached++; } const diagnostics = await linterWorker.lint( fileName, process.argv.includes('--fix'), fileCache, + fileStat.mtimeMs, ); if (diagnostics.length) { hasFix ||= await linterWorker.hasCodeFixes(fileName); for (const diagnostic of diagnostics) { - hasFix ||= !!fileCache[1][diagnostic.code]?.[0]; + // Cache-hit diagnostics come back without their rule + // having registered a fix this session — `hasCodeFixes` + // only sees fresh runs. Fall back to the cached + // per-rule `hasFix` flag for the diagnostic's rule. + hasFix ||= !!fileCache.rules[String(diagnostic.code)]?.hasFix; let output: string; @@ -428,13 +481,35 @@ const formatHost: ts.FormatDiagnosticsHost = { } } } - else if (await linterWorker.hasRules(fileName, fileCache[2])) { + else if (await linterWorker.hasRules(fileName)) { passed++; } processed++; } - cache.saveCache(project.tsconfig, project.configFile!, project.languages, project.cache, ts.sys.createHash); + // Snapshot the linter's runtime classification back into the cache + // file's `ruleModes`. Next session reads this and seeds the linter + // via `initialTypeAwareRules` so rules are classified correctly + // from the first invocation, before the runtime probe re-runs. + const typeAware = await linterWorker.getTypeAwareRules(); + project.cacheData.ruleModes = {}; + for (const ruleId of typeAware) { + project.cacheData.ruleModes[ruleId] = 'type-aware'; + } + // Layer 2: harvest content hashes + transitive deps from a fresh + // BuilderProgram pass over the LS program. Persists alongside the + // per-rule cache so the next `--incremental` session can compute + // affected files. Falls through to `undefined` on layer-1-only + // runs, matching the schema contract. + project.cacheData.incrementalState = await linterWorker.buildIncrementalState(); + cache.saveCache( + project.tsconfig, + project.configFile!, + project.languages, + ts.version, + project.cacheData, + ts.sys.createHash, + ); await startWorker(linterWorker); } diff --git a/packages/cli/lib/cache-flow.ts b/packages/cli/lib/cache-flow.ts new file mode 100644 index 0000000..467c4e6 --- /dev/null +++ b/packages/cli/lib/cache-flow.ts @@ -0,0 +1,153 @@ +// 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 { NO_CACHE, type Linter } from '@tsslint/core'; +import type { FileCache, SerializedDiagnostic } from './cache.js'; + +export function lintWithCache( + linter: Linter, + fileName: string, + fileCache: FileCache, + fileMtime: number, + program: ts.Program, + options?: { + // 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[] { + // 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 = {}; + } + + 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 + // 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 (trustTypeAwareCache) { + 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); + } + } + + 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 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) && !writeTypeAware) { + 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; + } + } + // 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 + .filter(d => !(d as any)[NO_CACHE]) + .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, + })), + }; +} + +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/lib/cache.ts b/packages/cli/lib/cache.ts index 266c965..804a8df 100644 --- a/packages/cli/lib/cache.ts +++ b/packages/cli/lib/cache.ts @@ -1,45 +1,162 @@ -import core = require('@tsslint/core'); +// 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'); -export type CacheData = Record; +import type * as ts from 'typescript'; +import type { IncrementalState } from './incremental-state.js'; 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; + // 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 { + 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[], - createHash: (path: string) => string = btoa, + tsVersion: string, + createHash: (s: string) => string = defaultHash, ): CacheData { - const cacheFilePath = getCacheFilePath(tsconfig, configFilePath, languages, createHash); - if (fs.statSync(cacheFilePath, { throwIfNoEntry: false })?.isFile()) { - try { - return JSON.parse(fs.readFileSync(cacheFilePath, 'utf8')); - } - catch {} + 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(); } - return {}; + 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: (path: string) => string = btoa, + createHash: (s: string) => string = defaultHash, ): void { - const cacheFilePath = getCacheFilePath(tsconfig, configFilePath, languages, createHash); - fs.mkdirSync(path.dirname(cacheFilePath), { recursive: true }); - fs.writeFileSync(cacheFilePath, JSON.stringify(cache)); + 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: {} }; +} + +// 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; + 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( tsconfig: string, configFilePath: string, languages: string[], - createHash: (path: string) => string, + tsVersion: string, + createHash: (s: string) => string, ): string { const configStat = fs.statSync(configFilePath, { throwIfNoEntry: false }); const cacheKey = [ @@ -49,9 +166,17 @@ function getCacheFilePath( configStat?.mtimeMs ?? 0, configStat?.size ?? 0, ].join('\0'); - return path.join(getTsslintCachePath(), createHash(cacheKey) + '.cache.json'); + 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/lib/incremental-state.ts b/packages/cli/lib/incremental-state.ts new file mode 100644 index 0000000..01556cf --- /dev/null +++ b/packages/cli/lib/incremental-state.ts @@ -0,0 +1,183 @@ +// 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. +// +// 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 +// +// 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'; + +// 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 + // us — fed straight back to `ts.getBuildInfo` on the next session. + tsBuildInfoText: string; +} + +// 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'; + +interface IncrementalAccess { + getBuildInfo(file: string, text: string): unknown | undefined; + createBuilderProgramUsingIncrementalBuildInfo( + buildInfo: unknown, + buildInfoPath: string, + host: { useCaseSensitiveFileNames(): boolean; getCurrentDirectory(): string }, + ): ts.BuilderProgram; +} + +// 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; +} + +// 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 +// failure (cold-start fallback). +export function reconstructOldBuilder( + ts: typeof import('typescript'), + 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 buildInfo = api.getBuildInfo(SYNTHETIC_BUILD_INFO_PATH, prev.tsBuildInfoText); + if (!buildInfo) return undefined; + return api.createBuilderProgramUsingIncrementalBuildInfo( + buildInfo, + SYNTHETIC_BUILD_INFO_PATH, + host, + ); + } + 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; + } +} + +// 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 across TS 5.x → 6.x. +// +// 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') { + 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 (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; + 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/lib/render.ts b/packages/cli/lib/render.ts index 36c40e6..1bc32e8 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() {}, }; diff --git a/packages/cli/lib/worker.ts b/packages/cli/lib/worker.ts index b13aa1e..537244d 100644 --- a/packages/cli/lib/worker.ts +++ b/packages/cli/lib/worker.ts @@ -2,9 +2,18 @@ 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 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'; @@ -17,6 +26,16 @@ let fileNames: string[] = []; let language: Language | undefined; let linter: core.Linter; let linterLanguageService!: ts.LanguageService; +// 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. +let currentBuilder: ts.SemanticDiagnosticsBuilderProgram | undefined; const snapshots = new Map(); const versions = new Map(); @@ -38,7 +57,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)) { @@ -83,13 +111,19 @@ 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); + }, + getTypeAwareRules() { + return [...linter.getTypeAwareRules()]; + }, + buildIncrementalState() { + return buildIncrementalState(); }, }; } @@ -100,6 +134,8 @@ async function setup( configFile: string, _fileNames: string[], _options: ts.CompilerOptions, + initialTypeAwareRules: readonly string[], + prevIncrementalState: IncrementalState | undefined, ): Promise { let config: config.Config | config.Config[]; try { @@ -125,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; @@ -151,12 +197,18 @@ async function setup( projectVersion++; typeRootsVersion++; fileNames = _fileNames; - options = plugins.some(plugin => plugin.typescript?.extraFileExtensions.length) - ? { - ..._options, - allowNonTsExtensions: true, - } - : _options; + // 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, @@ -166,27 +218,99 @@ async function setup( path.dirname(configFile), config, () => [], + initialTypeAwareRules, ); + { + const program = linterLanguageService.getProgram()!; + // 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, + { createHash: ts.sys.createHash ?? defaultHash }, + oldBuilder as ts.SemanticDiagnosticsBuilderProgram | undefined, + ); + affectedFiles = new Set(); + // 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); + } + else { + 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. + } + } + return true; } -function lint(fileName: string, fix: boolean, fileCache: core.FileLintCache) { +// 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 (!currentBuilder) return undefined; + return incrementalState.captureIncrementalState(ts.version, currentBuilder); +} + +function lint(fileName: string, fix: boolean, fileCache: FileCache, fileMtime: number) { let newSnapshot: ts.IScriptSnapshot | undefined; let diagnostics!: ts.DiagnosticWithLocation[]; let shouldCheck = true; + // 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 typeAwareUnaffected = !fix && !affectedFiles!.has(fileName); + if (fix) { - if (Object.values(fileCache[1]).some(([hasFix]) => hasFix)) { - // Reset the cache if there are any fixes applied. - fileCache[1] = {}; - fileCache[2] = {}; + // 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]; + } } - diagnostics = linter.lint(fileName, fileCache); + const program = linterLanguageService.getProgram()!; + diagnostics = cacheFlow.lintWithCache(linter, fileName, fileCache, fileMtime, program, { + incremental: true, + typeAwareUnaffected, + }); 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 +335,20 @@ 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] = {}; + // 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, fileCache); + const program = linterLanguageService.getProgram()!; + diagnostics = cacheFlow.lintWithCache(linter, fileName, fileCache, fileMtime, program, { + incremental: true, + typeAwareUnaffected, + }); } // Language-transform path (Vue/MDX/etc.): diagnostics map back from @@ -254,7 +383,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 +394,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/cli/package.json b/packages/cli/package.json index d28b04f..fc5dae9 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", @@ -20,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 0000000..54e942f --- /dev/null +++ b/packages/cli/test/cache-flow.test.ts @@ -0,0 +1,612 @@ +// 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); + }), + }, + }; + 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); + }), + }, + }; + 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); + }), + }, + }; + 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); + }), + }, + }; + 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 + }), + }, + }; + 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); + }), + }, + }; + 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); + }), + }, + }; + // 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', () => []); + }), + }, + }; + 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); + }), + b: ((rctx: RuleContext) => { + bRuns++; + rctx.report('b', 0, 1); + }), + }, + }; + 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); +} + +// ── 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, { incremental: true, 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, { 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( + '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, { 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. + 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): mode A (no incremental) never caches type-aware ── +{ + 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 → 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 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', + ); +} + +// ── 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 (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. +{ + 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) { + 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/test/cache.test.ts b/packages/cli/test/cache.test.ts new file mode 100644 index 0000000..ebeac4f --- /dev/null +++ b/packages/cli/test/cache.test.ts @@ -0,0 +1,377 @@ +// 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); +} + +// ── 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 }); + +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/test/incremental-state.test.ts b/packages/cli/test/incremental-state.test.ts new file mode 100644 index 0000000..8ed35b1 --- /dev/null +++ b/packages/cli/test/incremental-state.test.ts @@ -0,0 +1,326 @@ +// 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'; + +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'); + } +} + +const realLib = ts.getDefaultLibFilePath({ target: ts.ScriptTarget.Latest }); +const libContent = ts.sys.readFile(realLib) ?? ''; +const libName = realLib.split(/[\\/]/).pop()!; + +function buildProgram(files: Record): ts.Program { + const host: ts.CompilerHost = { + 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 => n in files || n === realLib, + readFile: n => files[n] ?? (n === realLib ? libContent : undefined), + getCanonicalFileName: n => n, + useCaseSensitiveFileNames: () => true, + getNewLine: () => '\n', + }; + return ts.createProgram({ + rootNames: Object.keys(files), + options: { + target: ts.ScriptTarget.Latest, + noEmit: true, + incremental: true, + tsBuildInfoFile: inc.SYNTHETIC_BUILD_INFO_PATH, + lib: [libName], + }, + host, + }); +} + +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; +} + +const hostShim = { + useCaseSensitiveFileNames: () => true, + 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;' }); + const builder = ts.createSemanticDiagnosticsBuilderProgram( + program, + { createHash: ts.sys.createHash }, + ); + affectedFileNames(builder); // drain + 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); +} + +// ── Test 2: reconstructOldBuilder + diff round-trip — identical program ─ +{ + const program1 = buildProgram({ + '/a.ts': 'export const x: number = 1;', + '/b.ts': "import { x } from './a'; export const y = x + 1;", + }); + const builder1 = ts.createSemanticDiagnosticsBuilderProgram( + program1, + { createHash: ts.sys.createHash }, + ); + affectedFileNames(builder1); + const captured = inc.captureIncrementalState(ts.version, builder1)!; + + const program2 = buildProgram({ + '/a.ts': 'export const x: number = 1;', + '/b.ts': "import { x } from './a'; export const y = x + 1;", + }); + 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( + 'identical program → no user files affected', + !affected.has('/a.ts') && !affected.has('/b.ts'), + `got affected: ${[...affected].join(', ')}`, + ); +} + +// ── Test 3: round-trip catches edits to imported file ─────────────────── +{ + const program1 = buildProgram({ + '/a.ts': 'export const x: number = 1;', + '/b.ts': "import { x } from './a'; export const y = x + 1;", + }); + const builder1 = ts.createSemanticDiagnosticsBuilderProgram( + program1, + { createHash: ts.sys.createHash }, + ); + affectedFileNames(builder1); + const captured = inc.captureIncrementalState(ts.version, builder1)!; + + // /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 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 4: undefined prev → cold start (oldBP undefined) ─────────────── +{ + 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, 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); +} + +// ── Test 7: TS missing internal load APIs → cold start + warn ────────── +// Future TS could rename `getBuildInfo` / +// `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) { + if (prop === 'getBuildInfo' || prop === 'createBuilderProgramUsingIncrementalBuildInfo') { + return undefined; + } + return (target as any)[prop]; + }, + }); + const valid = { version: inc.INCREMENTAL_STATE_VERSION, tsBuildInfoText: 'irrelevant' }; + 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 + 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. Must also warn the user. +{ + const fakeBuilder = {} as ts.BuilderProgram; + 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 + warn, no throw out ────── +{ + const throwingBuilder = { + emitBuildInfo() { throw new Error('simulated TS internal failure'); }, + } as unknown as ts.BuilderProgram; + 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]; + }, + }); + 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 === ''); +} + +// ── 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) { + 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/test/integration.test.ts b/packages/cli/test/integration.test.ts new file mode 100644 index 0000000..600812c --- /dev/null +++ b/packages/cli/test/integration.test.ts @@ -0,0 +1,355 @@ +// 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: incrementalState always persisted (layer 2 default-on) ───── +{ + const dir = makeFixture(); + try { + const r = runCli(dir); + check('default run produced diagnostic', r.stdout.includes('no-console')); + const data = readCacheForFixture(dir) as any; + check('cache written', !!data); + check( + 'incrementalState persisted to cache file', + !!data?.incrementalState && typeof data.incrementalState.tsBuildInfoText === 'string' + && data.incrementalState.tsBuildInfoText.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): warm run skips type-aware rule (cache hit) ──────── +{ + const { dir, markerPath } = makeTypeAwareFixture(); + try { + runCli(dir); + const afterCold = markerLineCount(markerPath); + check('cold ran rule once', afterCold === 1); + + runCli(dir); + const afterWarm = markerLineCount(markerPath); + check( + 'warm 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); + 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); + 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); + check( + 'warm after ambient edit cache-hits again', + markerLineCount(markerPath) === 2, + ); + } + finally { + fs.rmSync(dir, { recursive: true, force: true }); + } +} + +// ── 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, '--force'); + check('first --force ran rule once', markerLineCount(markerPath) === 1); + + runCli(dir, '--force'); + check( + 'second --force re-ran type-aware rule', + markerLineCount(markerPath) === 2, + `expected 2 marker lines, got ${markerLineCount(markerPath)} — --force should bypass layer 2`, + ); + } + 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'); diff --git a/packages/cli/test/render.test.ts b/packages/cli/test/render.test.ts new file mode 100644 index 0000000..4d0449b --- /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'); diff --git a/packages/cli/tsconfig.json b/packages/cli/tsconfig.json index 807a221..9ec3a11 100644 --- a/packages/cli/tsconfig.json +++ b/packages/cli/tsconfig.json @@ -1,8 +1,9 @@ { "extends": "../../tsconfig.base.json", - "include": ["*", "lib/**/*"], + "include": ["*", "lib/**/*", "test/**/*"], "references": [ { "path": "../core/tsconfig.json" }, - { "path": "../config/tsconfig.json" } + { "path": "../config/tsconfig.json" }, + { "path": "../types/tsconfig.json" } ] } diff --git a/packages/core/index.ts b/packages/core/index.ts index cb4f1a2..e2a2991 100644 --- a/packages/core/index.ts +++ b/packages/core/index.ts @@ -4,22 +4,26 @@ 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; +// 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, 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>(); @@ -47,21 +51,29 @@ 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(); + // 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, cache?: 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, 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)!; @@ -87,22 +99,7 @@ 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, - })), - }, []); - } + if (skipRules?.has(currentRuleId)) { continue; } @@ -121,26 +118,6 @@ export function createLinter( 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 +159,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) { @@ -244,15 +207,7 @@ 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); - } - } - } + (error as any)[NO_CACHE] = true; return this; }, }; @@ -270,12 +225,20 @@ 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, end: number, diagnostics?: ts.Diagnostic[], - minimatchCache?: FileLintCache[2], ) { const lintResult = lintResults.get(fileName); if (!lintResult) { @@ -283,7 +246,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]) { @@ -364,13 +327,20 @@ 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, 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 +356,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 +373,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/builder-program-poc.test.ts b/packages/core/test/builder-program-poc.test.ts new file mode 100644 index 0000000..3437f72 --- /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'); diff --git a/packages/core/test/cache-typeaware.test.ts b/packages/core/test/cache-typeaware.test.ts deleted file mode 100644 index beaab48..0000000 --- a/packages/core/test/cache-typeaware.test.ts +++ /dev/null @@ -1,242 +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 type { Config, RuleContext } from '@tsslint/types'; -import * as ts from 'typescript'; -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); - }), - 'type-aware': ((rctx: RuleContext) => { - typeAwareRan++; - void rctx.program; // probe trigger - rctx.report('typed', 0, 1); - }), - }, - }; - 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 - }), - }, - }; - 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); - }), - }, - }; - 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); - }), - }, - }; - 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', - }]], - }, - {}, - ]; - - // 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', - }]], - }, - {}, - ]; - let ran2 = 0; - const config2: Config = { - rules: { - 'type-aware': ((rctx: RuleContext) => { - ran2++; - void rctx.program; - rctx.report('typed', 0, 1); - }), - }, - }; - 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/test/probe.test.ts b/packages/core/test/probe.test.ts new file mode 100644 index 0000000..db68a9c --- /dev/null +++ b/packages/core/test/probe.test.ts @@ -0,0 +1,215 @@ +// 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); + }), + }, + }; + 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); + }), + }, + }; + 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); + }), + }, + }; + 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); + }), + }, + }; + 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 (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 = { + rules: { + r: ((rctx: RuleContext) => { + void rctx.program; + rctx.report('x', 0, 1); + }), + }, + }; + 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/test/skip-rules.test.ts b/packages/core/test/skip-rules.test.ts new file mode 100644 index 0000000..ed45bbd --- /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); + }), + }, + }; + 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); + }), + b: ((rctx: RuleContext) => { + bRuns++; + rctx.report('b', 0, 1); + }), + }, + }; + 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); + }), + }, + }; + 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); + }), + }, + }; + // 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', () => []); + }), + plain: ((rctx: RuleContext) => { + rctx.report('plain', 1, 2); + }), + }, + }; + 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'); diff --git a/packages/core/tsconfig.json b/packages/core/tsconfig.json index b2dc74b..6b2f9aa 100644 --- a/packages/core/tsconfig.json +++ b/packages/core/tsconfig.json @@ -1,6 +1,6 @@ { "extends": "../../tsconfig.base.json", - "include": ["*", "lib/**/*", "test/**/*"], + "include": ["*", "test/**/*"], "references": [ { "path": "../config/tsconfig.json" } ] diff --git a/packages/types/index.ts b/packages/types/index.ts index 4471c36..57ed8c9 100644 --- a/packages/types/index.ts +++ b/packages/types/index.ts @@ -56,5 +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; } diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index f10fd69..9ce2f00 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