From 8a11b35f92e783837a7c06896dc22ab493af441d Mon Sep 17 00:00:00 2001 From: Johnson Chu Date: Mon, 4 May 2026 04:40:24 +0000 Subject: [PATCH 01/19] Drop LanguageService dependency from core and CLI (#98) --- packages/cli/lib/worker.ts | 333 +++++++++++++-------- packages/cli/test/cache-flow.test.ts | 43 +-- packages/cli/test/meta-frameworks.test.ts | 102 +++++++ packages/cli/test/program-host.test.ts | 254 ++++++++++++++++ packages/config/lib/plugins/diagnostics.ts | 4 +- packages/config/lib/plugins/ignore.ts | 152 ++++------ packages/core/index.ts | 52 ++-- packages/core/test/completions.test.ts | 206 +++++++++++++ packages/core/test/probe.test.ts | 2 +- packages/core/test/skip-rules.test.ts | 3 +- packages/types/index.ts | 26 +- packages/typescript-plugin/index.ts | 30 +- 12 files changed, 941 insertions(+), 266 deletions(-) create mode 100644 packages/cli/test/meta-frameworks.test.ts create mode 100644 packages/cli/test/program-host.test.ts create mode 100644 packages/core/test/completions.test.ts diff --git a/packages/cli/lib/worker.ts b/packages/cli/lib/worker.ts index 537244d..767dc55 100644 --- a/packages/cli/lib/worker.ts +++ b/packages/cli/lib/worker.ts @@ -16,17 +16,43 @@ import type { IncrementalState } from './incremental-state.js'; 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'; +import { resolveFileLanguageId } from '@volar/typescript'; import { transformDiagnostic, transformFileTextChanges } from '@volar/typescript/lib/node/transform'; -let projectVersion = 0; -let typeRootsVersion = 0; let options: ts.CompilerOptions = {}; let fileNames: string[] = []; +// Volar Language handle — populated by `proxyCreateProgram`'s `setup` +// callback when a language plugin is active (Vue / MDX / Astro); +// undefined for plain-TS projects. let language: Language | undefined; let linter: core.Linter; -let linterLanguageService!: ts.LanguageService; -// Layer 2 state. We wrap the LS program in a SemanticDiagnostics- +// Cached Program instance + dirty flag. Pre-3.2 the worker wrapped a +// LanguageService over a LanguageServiceHost; getProgram() implicitly +// rebuilt when the host's projectVersion bumped. We've collapsed that +// down to direct `ts.createProgram` calls — the LS provided no +// linter-relevant capability beyond program-rebuild-on-version-bump, +// and it pulled in completion / refactor / navigation machinery we +// never used. `--fix` rewrites a file → bumps `programDirty` → next +// `ensureProgram()` rebuilds with `oldProgram` for incremental binder +// reuse (TS reuses unchanged SourceFiles' bound state, only re-binds +// the modified file). +let currentProgram: ts.Program | undefined; +let programDirty = true; +// In-session content overrides for `--fix`-modified files. The +// CompilerHost's readFile / getSourceFile consult this map first so +// the next program rebuild sees the post-fix text without disk I/O. +const fileTextOverrides = new Map(); +// Process-level SourceFile cache, shared across projects in the same +// CLI invocation (`tsslint --project a/tsconfig.json --project b/tsconfig.json` +// runs in one worker). Pre-3.2 the LS instance survived setup() calls +// and its internal SourceFile cache reused lib.es5.d.ts / shared +// node_modules types across projects; with the LS gone, `oldProgram`- +// based reuse only works within a project (TS bails when +// compilerOptions differ across tsconfigs). This keeps lib + shared +// types from re-parsing per project. Invalidated by content change +// (text-equality check on lookup) so `--fix` rewrites are seen. +const sourceFileCache = new Map(); +// Layer 2 state. We wrap the 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 @@ -37,65 +63,121 @@ let affectedFiles: Set | undefined; // capture its updated buildinfo text for next session's persistence. let currentBuilder: ts.SemanticDiagnosticsBuilderProgram | undefined; -const snapshots = new Map(); -const versions = new Map(); -const originalHost: ts.LanguageServiceHost = { - ...ts.sys, - useCaseSensitiveFileNames() { - return ts.sys.useCaseSensitiveFileNames; - }, - getProjectVersion() { - return projectVersion.toString(); - }, - getTypeRootsVersion() { - return typeRootsVersion; - }, - getCompilationSettings() { - return options; - }, - getScriptFileNames() { - return fileNames; - }, - getScriptVersion(fileName) { - // 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)) { - snapshots.set(fileName, ts.ScriptSnapshot.fromString(ts.sys.readFile(fileName)!)); +// CompilerHost: lower-level than LanguageServiceHost. Just +// readFile / writeFile / fileExists / lib-file resolution / case +// sensitivity. We override `readFile` (and `getSourceFile`, which +// internally reads via readFile) to consult `fileTextOverrides` AND +// to virtualise Vue / MDX / Astro files via the active language plugin. +let compilerHost: ts.CompilerHost = createCompilerHost(); + +function createCompilerHost(): ts.CompilerHost { + // `setParentNodes: true` — compat-eslint's bottom-up materialise + // walks ts.Node.parent chains; without parent pointers it crashes. + // `ts.createLanguageService` set this implicitly; `ts.createProgram` + // via `createCompilerHost` defaults false, so we set it explicitly. + const host = ts.createCompilerHost(options, true); + const originalReadFile = host.readFile.bind(host); + const originalGetSourceFile = host.getSourceFile.bind(host); + const hash = ts.sys.createHash ?? defaultHash; + host.readFile = (fileName: string) => { + const override = fileTextOverrides.get(fileName); + if (override !== undefined) return override; + return originalReadFile(fileName); + }; + host.getSourceFile = (fileName, languageVersion, onError, shouldCreate) => { + // Vue / MDX / Astro virtualisation. We replicate `proxyCreateProgram` + // but DO NOT apply its `decorateProgram` step — that wraps + // `program.getSemanticDiagnostics` to call `fillSourceFileText`, + // which mutates `SourceFile.text` in place to splice the original + // .vue text back over the leading-offset spaces. The mutation + // happens AFTER the AST has been parsed, so the rule walks an AST + // whose positions now point at characters in the post-mutation + // text — the rule reports diagnostics at offsets that no longer + // match the AST it was given. Master got away with this by going + // through `decorateLanguageServiceHost` (LS-side, doesn't touch + // the program) instead. + // + // Virtualised SFs are NOT cached cross-project: the plugin's + // `getServiceScript` output depends on the per-project `language` + // instance, so two projects with different language plugins can + // produce different virtual TS for the same fileName. + if (language) { + const sourceScript = language.scripts.get(fileName); + const tsAdapter = sourceScript?.generated?.languagePlugin.typescript; + if (sourceScript && tsAdapter) { + const orig = originalGetSourceFile(fileName, languageVersion, onError, shouldCreate); + if (!orig) return orig; + const serviceScript = tsAdapter.getServiceScript(sourceScript.generated!.root); + if (serviceScript) { + // Two layouts depending on the plugin: + // - !preventLeadingOffset: replace original-text positions + // with whitespace (preserves source-map offsets), then + // append the plugin's emitted TS. + // - preventLeadingOffset: just emit the plugin's TS. + const virtualText = !serviceScript.preventLeadingOffset + ? orig.text.split('\n').map(l => ' '.repeat(l.length)).join('\n') + + serviceScript.code.snapshot.getText(0, serviceScript.code.snapshot.getLength()) + : serviceScript.code.snapshot.getText(0, serviceScript.code.snapshot.getLength()); + const virtual = ts.createSourceFile( + fileName, + virtualText, + languageVersion, + /*setParentNodes*/ true, + serviceScript.scriptKind, + ); + (virtual as unknown as { version: string }).version = hash(virtualText); + return virtual; + } + } + } + // Plain-TS path. Cross-project SF cache: same path + same content + // → return the cached SF unchanged. Skips re-parse for lib.es5.d.ts + // and any node_modules types both projects pull in. The text- + // equality check is what invalidates after `--fix` (override + // changes the text → cache miss → fresh parse). + const text = host.readFile(fileName); + if (text === undefined) { + sourceFileCache.delete(fileName); + return undefined; } - return snapshots.get(fileName); - }, - getScriptKind(fileName) { - const languageId = resolveFileLanguageId(fileName); - switch (languageId) { - case 'javascript': - return ts.ScriptKind.JS; - case 'javascriptreact': - return ts.ScriptKind.JSX; - case 'typescript': - return ts.ScriptKind.TS; - case 'typescriptreact': - return ts.ScriptKind.TSX; - case 'json': - return ts.ScriptKind.JSON; + const cached = sourceFileCache.get(fileName); + if (cached && cached.text === text) { + return cached; } - return ts.ScriptKind.Unknown; - }, - getDefaultLibFileName(options) { - return ts.getDefaultLibFilePath(options); - }, -}; -const linterHost: ts.LanguageServiceHost = { ...originalHost }; -const originalService = ts.createLanguageService(linterHost); + const orig = originalGetSourceFile(fileName, languageVersion, onError, shouldCreate); + if (!orig) return orig; + // BuilderProgram requires `SourceFile.version` (Debug.checkDefined + // throws otherwise). The LS-host path got this via the host's + // `getScriptVersion`; raw CompilerHost has no equivalent, so we + // stamp a content hash here. Same value across runs as long as + // content matches → BuilderProgram's reference-graph diff + // correctly identifies unchanged files. + if ((orig as unknown as { version?: string }).version === undefined) { + (orig as unknown as { version: string }).version = hash(orig.text); + } + sourceFileCache.set(fileName, orig); + return orig; + }; + return host; +} + +function ensureProgram(): ts.Program { + if (programDirty || !currentProgram) { + // `oldProgram` lets `ts.createProgram` reuse SourceFiles whose + // text hasn't changed (text-equality check vs old SF) and skip + // re-parsing + re-binding for those. Modified files (via + // `fileTextOverrides`) get re-parsed; unchanged files are zero- + // cost. + currentProgram = ts.createProgram({ + rootNames: fileNames, + options, + host: compilerHost, + oldProgram: currentProgram, + }); + programDirty = false; + } + return currentProgram; +} // Linter is single-threaded by design. The previous version split into a // worker_threads worker for TTY mode (so the spinner could update during a @@ -148,54 +230,27 @@ async function setup( return String(err); } - for (let key in linterHost) { - if (!(key in originalHost)) { - // @ts-ignore - delete linterHost[key]; - } - else { - // @ts-ignore - linterHost[key] = originalHost[key]; - } - } - 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 + // `fileTextOverrides` (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(); + fileTextOverrides.clear(); + language = undefined; affectedFiles = undefined; currentBuilder = undefined; + // `currentProgram` is intentionally NOT cleared — `ensureProgram()` + // will pass it as `oldProgram` to the next `ts.createProgram` call. + // TS reuses SourceFiles whose path + text match across the two + // programs, so lib files (lib.es5.d.ts etc., shared between every + // project) and any node_modules types both projects pull in skip + // the parse + bind cost on the second project. Pre-3.2 the LS + // instance survived across projects with the same effect; this + // preserves that. `programDirty = true` forces the rebuild so the + // new project's rootNames + options take effect. + programDirty = true; const plugins = await languagePlugins.load(tsconfig, languages); - if (plugins.length) { - const { getScriptSnapshot } = originalHost; - language = createLanguage( - [ - ...plugins, - { getLanguageId: fileName => resolveFileLanguageId(fileName) }, - ], - new FileMap(ts.sys.useCaseSensitiveFileNames), - fileName => { - const snapshot = getScriptSnapshot(fileName); - if (snapshot) { - language!.scripts.set(fileName, snapshot); - } - }, - ); - decorateLanguageServiceHost(ts, language, linterHost); - - const proxy = createProxyLanguageService(linterLanguageService); - proxy.initialize(language); - linterLanguageService = proxy.proxy; - } - - projectVersion++; - typeRootsVersion++; fileNames = _fileNames; // Internal API path: BuilderProgram.emitBuildInfo only produces // content when these options are set. Override the user's values @@ -209,11 +264,42 @@ async function setup( incremental: true, tsBuildInfoFile: incrementalState.SYNTHETIC_BUILD_INFO_PATH, }; + if (plugins.length) { + // Manual replication of `proxyCreateProgram`'s language setup — + // without its `decorateProgram` step, which mutates the program's + // `getSemanticDiagnostics` to call `fillSourceFileText` and + // breaks AST-position lookups (see `createCompilerHost`). The + // host's `getSourceFile` consults `language.scripts` to splice + // virtual TS into Vue / MDX / Astro files. + language = createLanguage( + [ + ...plugins, + { getLanguageId: fileName => resolveFileLanguageId(fileName) }, + ], + new FileMap(ts.sys.useCaseSensitiveFileNames), + (fileName, includeFsFiles) => { + if (!includeFsFiles) return; + const text = fileTextOverrides.get(fileName) ?? ts.sys.readFile(fileName); + if (text === undefined) { + language!.scripts.delete(fileName); + return; + } + language!.scripts.set(fileName, ts.ScriptSnapshot.fromString(text)); + }, + ); + } + // Compile a fresh CompilerHost AFTER `language` is wired so the + // host's getSourceFile virtualisation can read from it. `options` + // may have changed too — createCompilerHost bakes those in. + compilerHost = createCompilerHost(); linter = core.createLinter( { - languageService: linterLanguageService, - languageServiceHost: linterHost, typescript: ts, + // Thunk: each `lint()` call gets the latest Program. `--fix` + // rewrites a file mid-session → flips `programDirty` → next + // `ensureProgram()` rebuilds with `oldProgram` for incremental + // binder reuse. + program: ensureProgram, }, path.dirname(configFile), config, @@ -222,7 +308,7 @@ async function setup( ); { - const program = linterLanguageService.getProgram()!; + const program = ensureProgram(); // 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, { @@ -279,7 +365,7 @@ function buildIncrementalState(): IncrementalState | undefined { } function lint(fileName: string, fix: boolean, fileCache: FileCache, fileMtime: number) { - let newSnapshot: ts.IScriptSnapshot | undefined; + let newText: string | undefined; let diagnostics!: ts.DiagnosticWithLocation[]; let shouldCheck = true; @@ -302,8 +388,7 @@ function lint(fileName: string, fix: boolean, fileCache: FileCache, fileMtime: n delete fileCache.rules[ruleId]; } } - const program = linterLanguageService.getProgram()!; - diagnostics = cacheFlow.lintWithCache(linter, fileName, fileCache, fileMtime, program, { + diagnostics = cacheFlow.lintWithCache(linter, fileName, fileCache, fileMtime, ensureProgram(), { incremental: true, typeAwareUnaffected, }); @@ -322,19 +407,20 @@ function lint(fileName: string, fix: boolean, fileCache: FileCache, fileMtime: n const textChanges = core.combineCodeFixes(fileName, fixes); if (textChanges.length) { - const oldSnapshot = snapshots.get(fileName)!; - newSnapshot = core.applyTextChanges(oldSnapshot, textChanges); - snapshots.set(fileName, newSnapshot); - versions.set(fileName, (versions.get(fileName) ?? 0) + 1); - projectVersion++; + // Apply edits to the current text (override map first, fall + // through to disk). Stash result in `fileTextOverrides` so the + // next `ensureProgram()` rebuild sees the post-fix content. + const baseText = fileTextOverrides.get(fileName) ?? ts.sys.readFile(fileName) ?? ''; + newText = core.applyTextChanges(baseText, textChanges); + fileTextOverrides.set(fileName, newText); + programDirty = true; } } - if (newSnapshot) { - const newText = newSnapshot.getText(0, newSnapshot.getLength()); + if (newText !== undefined) { const oldText = ts.sys.readFile(fileName); if (newText !== oldText) { - ts.sys.writeFile(fileName, newSnapshot.getText(0, newSnapshot.getLength())); + ts.sys.writeFile(fileName, newText); // 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. @@ -344,8 +430,7 @@ function lint(fileName: string, fix: boolean, fileCache: FileCache, fileMtime: n } if (shouldCheck) { - const program = linterLanguageService.getProgram()!; - diagnostics = cacheFlow.lintWithCache(linter, fileName, fileCache, fileMtime, program, { + diagnostics = cacheFlow.lintWithCache(linter, fileName, fileCache, fileMtime, ensureProgram(), { incremental: true, typeAwareUnaffected, }); @@ -353,12 +438,12 @@ function lint(fileName: string, fix: boolean, fileCache: FileCache, fileMtime: n // Language-transform path (Vue/MDX/etc.): diagnostics map back from // the transformed file to the original source. The original file - // might not be in the language service's program, so we substitute a - // SourceFile-shaped POJO with the real source text — `formatDiagnostics- - // WithColorAndContext` reads `.file.text` to render code snippets. + // might not be in the program, so we substitute a SourceFile-shaped + // POJO with the real source text — `formatDiagnosticsWithColorAndContext` + // reads `.file.text` to render code snippets. if (language) { diagnostics = diagnostics - .map(d => transformDiagnostic(language!, d, (originalService as any).getCurrentProgram(), false)) + .map(d => transformDiagnostic(language!, d, ensureProgram(), false)) .filter(d => !!d); const fileShim = new Map(); const getShim = (fn: string) => { @@ -387,7 +472,7 @@ function lint(fileName: string, fix: boolean, fileCache: FileCache, fileMtime: n } function getFileText(fileName: string) { - return originalHost.getScriptSnapshot(fileName)!.getText(0, Number.MAX_VALUE); + return fileTextOverrides.get(fileName) ?? ts.sys.readFile(fileName) ?? ''; } function hasCodeFixes(fileName: string) { diff --git a/packages/cli/test/cache-flow.test.ts b/packages/cli/test/cache-flow.test.ts index 2eb7cad..60841bb 100644 --- a/packages/cli/test/cache-flow.test.ts +++ b/packages/cli/test/cache-flow.test.ts @@ -43,7 +43,8 @@ function makeContext(files: Record) { 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) }; + const languageService = ts.createLanguageService(host); + return { typescript: ts, program: () => languageService.getProgram()! }; } function emptyFileCache(mtime = 0): FileCache { @@ -62,7 +63,7 @@ function emptyFileCache(mtime = 0): FileCache { }; const linter = core.createLinter(ctx, '/', config, () => []); const cache = emptyFileCache(1); - cacheFlow.lintWithCache(linter, '/a.ts', cache, 1, ctx.languageService.getProgram()!); + cacheFlow.lintWithCache(linter, '/a.ts', cache, 1, ctx.program()); 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); @@ -81,7 +82,7 @@ function emptyFileCache(mtime = 0): FileCache { }; const linter = core.createLinter(ctx, '/', config, () => []); const cache = emptyFileCache(1); - const result = cacheFlow.lintWithCache(linter, '/a.ts', cache, 1, ctx.languageService.getProgram()!); + const result = cacheFlow.lintWithCache(linter, '/a.ts', cache, 1, ctx.program()); check('type-aware rule still produces diagnostics', result.length === 1); check('type-aware rule NOT cached', !cache.rules['typed']); } @@ -100,7 +101,7 @@ function emptyFileCache(mtime = 0): FileCache { }; const linter = core.createLinter(ctx, '/', config, () => []); const cache = emptyFileCache(1); - const program = ctx.languageService.getProgram()!; + const program = ctx.program(); cacheFlow.lintWithCache(linter, '/a.ts', cache, 1, program); check('first call ran rule', runs === 1); @@ -128,7 +129,7 @@ function emptyFileCache(mtime = 0): FileCache { }; const linter = core.createLinter(ctx, '/', config, () => []); const cache = emptyFileCache(1); - const program = ctx.languageService.getProgram()!; + const program = ctx.program(); cacheFlow.lintWithCache(linter, '/a.ts', cache, 1, program); check('first call ran', runs === 1); @@ -156,7 +157,7 @@ function emptyFileCache(mtime = 0): FileCache { }; const linter = core.createLinter(ctx, '/', config, () => []); const cache = emptyFileCache(1); - cacheFlow.lintWithCache(linter, '/a.ts', cache, 1, ctx.languageService.getProgram()!); + cacheFlow.lintWithCache(linter, '/a.ts', cache, 1, ctx.program()); check('post-rule cleanup deleted entry', !cache.rules['report-then-touch']); } @@ -179,7 +180,7 @@ function emptyFileCache(mtime = 0): FileCache { const linter = core.createLinter(ctx, '/', config, () => []); const cacheA = emptyFileCache(1); const cacheB = emptyFileCache(1); - const program = ctx.languageService.getProgram()!; + const program = ctx.program(); 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']); @@ -221,7 +222,7 @@ function emptyFileCache(mtime = 0): FileCache { }, }, }; - const result = cacheFlow.lintWithCache(linter, '/a.ts', cache, 1, ctx.languageService.getProgram()!); + const result = cacheFlow.lintWithCache(linter, '/a.ts', cache, 1, ctx.program()); check('rule re-ran (stale cache ignored)', runs === 1); check('result reflects fresh run', result.length === 1); check('stale entry deleted', !cache.rules['typed']); @@ -239,7 +240,7 @@ function emptyFileCache(mtime = 0): FileCache { }; const linter = core.createLinter(ctx, '/', config, () => []); const cache = emptyFileCache(1); - cacheFlow.lintWithCache(linter, '/a.ts', cache, 1, ctx.languageService.getProgram()!); + cacheFlow.lintWithCache(linter, '/a.ts', cache, 1, ctx.program()); check('hasFix true after rule registered fix', cache.rules['fixable']?.hasFix === true); } @@ -261,7 +262,7 @@ function emptyFileCache(mtime = 0): FileCache { }; const linter = core.createLinter(ctx, '/', config, () => []); const cache = emptyFileCache(1); - const program = ctx.languageService.getProgram()!; + const program = ctx.program(); // First call: both run cacheFlow.lintWithCache(linter, '/a.ts', cache, 1, program); @@ -294,7 +295,7 @@ function emptyFileCache(mtime = 0): FileCache { }; const linter = core.createLinter(ctx, '/', config, () => []); const cache = emptyFileCache(1); - const program = ctx.languageService.getProgram()!; + const program = ctx.program(); cacheFlow.lintWithCache(linter, '/a.ts', cache, 1, program, { incremental: true, typeAwareUnaffected: true }); check('first call ran (no cache yet)', runs === 1); @@ -324,7 +325,7 @@ function emptyFileCache(mtime = 0): FileCache { }; const linter = core.createLinter(ctx, '/', config, () => []); const cache = emptyFileCache(1); - const program = ctx.languageService.getProgram()!; + const program = ctx.program(); cacheFlow.lintWithCache(linter, '/a.ts', cache, 1, program, { incremental: true, typeAwareUnaffected: true }); const second = cacheFlow.lintWithCache(linter, '/a.ts', cache, 1, program, { @@ -358,7 +359,7 @@ function emptyFileCache(mtime = 0): FileCache { }; const linter = core.createLinter(ctx, '/', config, () => []); const cache = emptyFileCache(1); - const program = ctx.languageService.getProgram()!; + const program = ctx.program(); // Mode B: write the entry. cacheFlow.lintWithCache(linter, '/a.ts', cache, 1, program, { incremental: true, typeAwareUnaffected: true }); @@ -383,7 +384,7 @@ function emptyFileCache(mtime = 0): FileCache { }; const linter = core.createLinter(ctx, '/', config, () => []); const cache = emptyFileCache(1); - const program = ctx.languageService.getProgram()!; + const program = ctx.program(); // No options arg → mode A. Type-aware rules never cached. cacheFlow.lintWithCache(linter, '/a.ts', cache, 1, program); @@ -414,7 +415,7 @@ function emptyFileCache(mtime = 0): FileCache { }; const linter = core.createLinter(ctx, '/', config, () => []); const cache = emptyFileCache(1); - const program = ctx.languageService.getProgram()!; + const program = ctx.program(); // Cold session under --incremental: no prev state, file is "affected" // (unaffected=false). Must run AND write entry. @@ -454,7 +455,7 @@ function emptyFileCache(mtime = 0): FileCache { '/a.ts', cache, 1, - ctx.languageService.getProgram()!, + ctx.program(), ); check('current run returns both diagnostics', diags.length === 2); check( @@ -485,7 +486,7 @@ function emptyFileCache(mtime = 0): FileCache { }; const linter = core.createLinter(ctx, '/', config, () => []); const cache = emptyFileCache(1); - cacheFlow.lintWithCache(linter, '/a.ts', cache, 1, ctx.languageService.getProgram()!); + cacheFlow.lintWithCache(linter, '/a.ts', cache, 1, ctx.program()); // Second run with the same mtime — rule cache-hits, only the // persisted diagnostic comes back. @@ -495,7 +496,7 @@ function emptyFileCache(mtime = 0): FileCache { '/a.ts', cache, 1, - ctx.languageService.getProgram()!, + ctx.program(), ); check('warm cache hit returns 1 diagnostic', diags.length === 1); check('warm replay drops the marked one', diags[0]?.messageText === 'plain'); @@ -528,7 +529,7 @@ function emptyFileCache(mtime = 0): FileCache { // ── 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 program1 = ctx.program(); const cacheSkip: FileCache = emptyFileCache(1); const cacheCheck: FileCache = emptyFileCache(1); cacheFlow.lintWithCache(linter1, '/skip.ts', cacheSkip, 1, program1); @@ -551,7 +552,7 @@ function emptyFileCache(mtime = 0): FileCache { // 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()!; + const program2 = ctx.program(); let earlyReturnRanInSession2 = false; const config2: Config = { rules: { @@ -599,7 +600,7 @@ function emptyFileCache(mtime = 0): FileCache { }; const linter = core.createLinter(ctx, '/', config, () => []); const cache = emptyFileCache(1); - cacheFlow.lintWithCache(linter, '/a.ts', cache, 1, ctx.languageService.getProgram()!); + cacheFlow.lintWithCache(linter, '/a.ts', cache, 1, ctx.program()); // 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')); diff --git a/packages/cli/test/meta-frameworks.test.ts b/packages/cli/test/meta-frameworks.test.ts new file mode 100644 index 0000000..7d57f8b --- /dev/null +++ b/packages/cli/test/meta-frameworks.test.ts @@ -0,0 +1,102 @@ +// Smoke test for the meta-framework language-plugin path. Spawns the +// real tsslint CLI against the in-tree `fixtures/meta-frameworks` fixture +// (.tsx + .vue + .vine.ts + .astro + .mdx) for each `--*-project` flag, +// asserts the no-console rule fires on EACH framework's script content. +// +// This catches the regression class where the language plugin loads but +// the script content never makes it into the linter's program — e.g. a +// virtual-script transformation that gets undone before AST walk. Pre- +// 3.2 (LS-side `decorateLanguageServiceHost`) and post-3.2 (program-side +// host wrap) both have the same user-visible contract: `console.log` +// inside `