From 8c468905c6fdeb6a0bde0c31bc389d7d47013f0b Mon Sep 17 00:00:00 2001 From: Johnson Chu Date: Mon, 4 May 2026 10:26:53 +0800 Subject: [PATCH 1/6] core: shrink LinterContext to a Program thunk MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Pre-3.2 the linter took `{ languageService, languageServiceHost, typescript }`. Of that surface, core actually used: - `ctx.languageService.getProgram()` — to get the Program - `ctx.languageServiceHost.getCancellationToken?.()` — never set in CLI Everything else (completions, refactors, navigation, watchers, file-version tracking) was paying for capabilities the linter never touched. The indirection also coupled core to the LanguageService model — awkward for hosts that don't run a full LS (raw `ts.createProgram`, native compilers that expose only Program-level APIs, etc.). New shape: interface LinterContext { typescript: typeof import('typescript'); program: () => Program; } A thunk, not a stable instance — callers that mutate the project mid- session (e.g. CLI `--fix` rewriting a file) rebuild the Program and the next `lint()` picks it up. Each `lint()` reads it once at the top so the program identity stays stable for that file's pass. The cancellation hook goes — CLI never plumbed one in, and the only place it could come from (the LS host) is what we're removing. Future cancel support, if needed, can take an explicit AbortSignal at the call site. ## IDE-side completions: new `resolveCompletions` hook The one corner that genuinely needed `LanguageService` was `@tsslint/config`'s ignore plugin: it wraps `getCompletionsAtPosition` to suggest ignore-comment commands and rule IDs. Pulling LS out of LinterContext would have killed that feature. Replaced with a clean hook on `PluginInstance`: resolveCompletions?(file, position, entries: CompletionEntry[]): CompletionEntry[] `Linter.getCompletions(fileName, position)` aggregates across plugins. CLI never calls it; `typescript-plugin` wraps the host LS's `getCompletionsAtPosition` and merges in `linter.getCompletions(...)`. Same user-visible behaviour, no LS reach-through inside `@tsslint/config`. ## Dropped from the public surface - `LinterContext.languageService` — gone - `LinterContext.languageServiceHost` — gone - `RuleContext` already had `program`, no change there ## What still uses LanguageService internally - CLI `worker.ts` — keeps `ts.createLanguageService(host)` as its program factory + incremental snapshot tracker for `--fix`. It now passes `program: () => linterLanguageService.getProgram()!` to `createLinter`. A follow-up will replace this with `ts.createProgram(opts, oldProgram)` directly. - `typescript-plugin` — wraps the host-provided LS by definition (it's the IDE plugin). The linter inside it now sees only Program. ## Verification - `predicate-coverage` 152/152, `lazy-estree.test`, `scope-compat` 24/24, `selector-analysis`, `ts-ast-scan`, `compat-pipeline`, `jsx-react-x` PARITY ✓ — all pass - `probe.test`, `skip-rules.test`, `cache-flow.test` — all pass after context-shape update - Dify CLI cold lint: 2961 passed / 1867 errors / 9 messages — bit- identical to master - tsslint-dify-bench 8 alternating pairs vs v3.1.1: 5 wins / 3 losses, median diff −103 ms (refactor side), high run-to-run noise — within variance, no regression --- packages/cli/lib/worker.ts | 9 +- packages/cli/test/cache-flow.test.ts | 43 +++--- packages/config/lib/plugins/diagnostics.ts | 4 +- packages/config/lib/plugins/ignore.ts | 152 +++++++++------------ packages/core/index.ts | 30 +++- 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 +++- 9 files changed, 170 insertions(+), 129 deletions(-) diff --git a/packages/cli/lib/worker.ts b/packages/cli/lib/worker.ts index 537244d2..7799982b 100644 --- a/packages/cli/lib/worker.ts +++ b/packages/cli/lib/worker.ts @@ -211,9 +211,14 @@ async function setup( }; linter = core.createLinter( { - languageService: linterLanguageService, - languageServiceHost: linterHost, typescript: ts, + // Thunk: each `lint()` call observes the LS's CURRENT program. + // `--fix` rewrites a file mid-session, bumps `projectVersion`, + // and the next `lint()` picks up the rebuilt program here. The + // LS itself is still TSSLint CLI's internal handle to TS — the + // public Linter API only sees Program. Pre-3.2 the linter took + // `{ languageService, languageServiceHost }`; both are gone. + program: () => linterLanguageService.getProgram()!, }, path.dirname(configFile), config, diff --git a/packages/cli/test/cache-flow.test.ts b/packages/cli/test/cache-flow.test.ts index 2eb7cad0..60841bb6 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/config/lib/plugins/diagnostics.ts b/packages/config/lib/plugins/diagnostics.ts index e8ee9dce..a5f5edc3 100644 --- a/packages/config/lib/plugins/diagnostics.ts +++ b/packages/config/lib/plugins/diagnostics.ts @@ -4,9 +4,9 @@ type CheckMode = 'syntactic' | 'semantic' | 'declaration'; export function create(mode: CheckMode | CheckMode[] = 'semantic'): Plugin { const modes = Array.isArray(mode) ? mode : [mode]; - return ({ languageService }) => ({ + return ({ program: getProgram }) => ({ resolveDiagnostics(file, diagnostics) { - const program = languageService.getProgram()!; + const program = getProgram(); for (const mode of modes) { const diags = mode === 'syntactic' ? program.getSyntacticDiagnostics(file) diff --git a/packages/config/lib/plugins/ignore.ts b/packages/config/lib/plugins/ignore.ts index 9efced93..7b7f90ec 100644 --- a/packages/config/lib/plugins/ignore.ts +++ b/packages/config/lib/plugins/ignore.ts @@ -57,107 +57,77 @@ export function create( const completeReg1 = /^\s*\/\/(\s*)([\S]*)?$/; const completeReg2 = new RegExp(`//\\s*${cmd}(\\S*)?$`); - return ({ typescript: ts, languageService }) => { + return ({ typescript: ts }) => { const reportedRulesOfFile = new Map(); - const { getCompletionsAtPosition } = languageService; - languageService.getCompletionsAtPosition = (fileName, position, ...rest) => { - let result = getCompletionsAtPosition(fileName, position, ...rest); - - const sourceFile = languageService.getProgram()?.getSourceFile(fileName); - if (!sourceFile) { - return result; - } - - const reportedRules = reportedRulesOfFile.get(fileName); - const line = sourceFile.getLineAndCharacterOfPosition(position).line; - const lineStart = sourceFile.getPositionOfLineAndCharacter(line, 0); - const prefix = sourceFile.text.slice(lineStart, position); - const matchCmd = completeReg1 - ? prefix.match(completeReg1) - : undefined; + return { + // IDE-side suggestions: when typing `// @ts-` on a fresh line, + // suggest the configured ignore-comment command; when the + // command is already typed, suggest rule IDs that fired on the + // next line so users can scope the ignore precisely. Only runs + // under typescript-plugin (CLI doesn't aggregate completions). + resolveCompletions(file, position, entries) { + const reportedRules = reportedRulesOfFile.get(file.fileName); + const line = file.getLineAndCharacterOfPosition(position).line; + const lineStart = file.getPositionOfLineAndCharacter(line, 0); + const prefix = file.text.slice(lineStart, position); + const matchCmd = prefix.match(completeReg1); - if (matchCmd) { - const nextLineRules = reportedRules?.filter(([, reportedLine]) => reportedLine === line + 1) ?? []; - const item: ts.CompletionEntry = { - name: cmdText, - insertText: matchCmd[1].length ? cmdText : ` ${cmdText}`, - kind: ts.ScriptElementKind.keyword, - sortText: 'a', - replacementSpan: matchCmd[2] - ? { - start: position - matchCmd[2].length, - length: matchCmd[2].length, - } - : undefined, - labelDetails: { - description: nextLineRules.length >= 2 - ? `Ignore ${nextLineRules.length} issues in next line` - : nextLineRules.length - ? 'Ignore 1 issue in next line' + if (matchCmd) { + const nextLineRules = reportedRules?.filter(([, reportedLine]) => reportedLine === line + 1) ?? []; + entries.push({ + name: cmdText, + insertText: matchCmd[1].length ? cmdText : ` ${cmdText}`, + kind: ts.ScriptElementKind.keyword, + sortText: 'a', + replacementSpan: matchCmd[2] + ? { + start: position - matchCmd[2].length, + length: matchCmd[2].length, + } : undefined, - }, - }; - if (result) { - result.entries.push(item); - } - else { - result = { - isGlobalCompletion: false, - isMemberCompletion: false, - isNewIdentifierLocation: false, - entries: [item], - }; + labelDetails: { + description: nextLineRules.length >= 2 + ? `Ignore ${nextLineRules.length} issues in next line` + : nextLineRules.length + ? 'Ignore 1 issue in next line' + : undefined, + }, + }); } - } - else if (reportedRules?.length) { - const matchRule = completeReg2 - ? prefix.match(completeReg2) - : undefined; - if (matchRule) { - const visited = new Set(); - for (const [ruleId] of reportedRules) { - if (visited.has(ruleId)) { - continue; - } - visited.add(ruleId); + else if (reportedRules?.length) { + const matchRule = prefix.match(completeReg2); + if (matchRule) { + const visited = new Set(); + for (const [ruleId] of reportedRules) { + if (visited.has(ruleId)) { + continue; + } + visited.add(ruleId); - const reportedLines = reportedRules - .filter(([r]) => r === ruleId) - .map(([, l]) => l + 1); - const item: ts.CompletionEntry = { - name: ruleId, - kind: ts.ScriptElementKind.keyword, - sortText: ruleId, - replacementSpan: matchRule[1] - ? { - start: position - matchRule[1].length, - length: matchRule[1].length, - } - : undefined, - labelDetails: { - description: `Reported in line${reportedLines.length >= 2 ? 's' : ''} ${reportedLines.join(', ')}`, - }, - }; - if (result) { - result.entries.push(item); - } - else { - result = { - isGlobalCompletion: false, - isMemberCompletion: false, - isNewIdentifierLocation: false, - entries: [item], - }; + const reportedLines = reportedRules + .filter(([r]) => r === ruleId) + .map(([, l]) => l + 1); + entries.push({ + name: ruleId, + kind: ts.ScriptElementKind.keyword, + sortText: ruleId, + replacementSpan: matchRule[1] + ? { + start: position - matchRule[1].length, + length: matchRule[1].length, + } + : undefined, + labelDetails: { + description: `Reported in line${reportedLines.length >= 2 ? 's' : ''} ${reportedLines.join(', ')}`, + }, + }); } } } - } - - return result; - }; - return { + return entries; + }, resolveDiagnostics(file, results) { if ( !reportsUnusedComments diff --git a/packages/core/index.ts b/packages/core/index.ts index e2a29913..7aa986e5 100644 --- a/packages/core/index.ts +++ b/packages/core/index.ts @@ -72,10 +72,12 @@ export function createLinter( const skipRules = options?.skipRules; const rules = getRulesForFile(fileName); - const token = ctx.languageServiceHost.getCancellationToken?.(); const configs = getConfigsForFile(fileName); - const program = ctx.languageService.getProgram()!; + // Single read at top of `lint()`: rule callbacks all observe + // the same Program identity for this file's pass, even if a + // caller swaps the underlying program between `lint()` calls. + const program = ctx.program(); const file = program.getSourceFile(fileName)!; let touchedProgram = false; const rulesContext: RuleContext = { @@ -93,10 +95,6 @@ export function createLinter( const lintResult = lintResults.get(fileName)!; for (const [ruleId, rule] of Object.entries(rules)) { - if (token?.isCancellationRequested()) { - break; - } - currentRuleId = ruleId; if (skipRules?.has(currentRuleId)) { @@ -325,6 +323,26 @@ export function createLinter( } } }, + // IDE-side completion entries. Aggregates `resolveCompletions` + // across plugins; CLI never calls this. typescript-plugin merges + // the result into the host LanguageService's getCompletionsAtPosition. + getCompletions(fileName: string, position: number): ts.CompletionEntry[] { + const program = ctx.program(); + const file = program.getSourceFile(fileName); + if (!file) { + return []; + } + const configs = getConfigsForFile(fileName); + let entries: ts.CompletionEntry[] = []; + for (const { plugins } of configs) { + for (const { resolveCompletions } of plugins) { + if (resolveCompletions) { + entries = resolveCompletions(file, position, entries); + } + } + } + return entries; + }, getRules: getRulesForFile, getConfigs: getConfigsForFile, // Snapshot of rules classified type-aware so far. The CLI reads diff --git a/packages/core/test/probe.test.ts b/packages/core/test/probe.test.ts index 9a734d30..82a42754 100644 --- a/packages/core/test/probe.test.ts +++ b/packages/core/test/probe.test.ts @@ -47,7 +47,7 @@ function makeContext(files: Record) { readFile: n => (n in files ? files[n] : (n === realLibPath ? realLibContent : undefined)), }; const languageService = ts.createLanguageService(host); - return { typescript: ts, languageServiceHost: host, languageService }; + return { typescript: ts, program: () => languageService.getProgram()! }; } // ── Test 1: rule that doesn't read program → not classified ────────────── diff --git a/packages/core/test/skip-rules.test.ts b/packages/core/test/skip-rules.test.ts index fceab6c5..51c760ac 100644 --- a/packages/core/test/skip-rules.test.ts +++ b/packages/core/test/skip-rules.test.ts @@ -42,7 +42,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()! }; } // ── Test 1: rule in skipRules doesn't run, no diagnostic in result ─────── diff --git a/packages/types/index.ts b/packages/types/index.ts index 57ed8c97..f3d1bc05 100644 --- a/packages/types/index.ts +++ b/packages/types/index.ts @@ -1,18 +1,30 @@ import type { CodeFixAction, + CompletionEntry, Diagnostic, DiagnosticWithLocation, FileTextChanges, - LanguageService, - LanguageServiceHost, Program, SourceFile, } from 'typescript'; export interface LinterContext { typescript: typeof import('typescript'); - languageServiceHost: LanguageServiceHost; - languageService: LanguageService; + // Program thunk (not a stable instance). Callers that mutate the + // project mid-session (e.g. CLI `--fix` rewriting a file) rebuild the + // Program and the next `lint()` call sees the new one. Each `lint()` + // reads this once at the top, so within a single file's pass the + // Program identity stays stable. + // + // Pre-3.2: this was `{ languageService, languageServiceHost }`. + // LinterContext consumed only `getProgram()` and `getCancellationToken()` + // from those — the rest of the LS / Host surface was unused, so the + // indirection was paying for capabilities (completions, refactors, + // navigation) that the linter never touches. Direct Program access + // also makes the surface trivially compatible with hosts that don't + // run a full LanguageService (raw `ts.createProgram`, tsgo's + // `Project.program`, etc.). + program: () => Program; } export interface Config { @@ -30,6 +42,12 @@ export interface PluginInstance { resolveRules?(fileName: string, rules: Record): Record; resolveDiagnostics?(file: SourceFile, diagnostics: DiagnosticWithLocation[]): DiagnosticWithLocation[]; resolveCodeFixes?(file: SourceFile, diagnostic: Diagnostic, codeFixes: CodeFixAction[]): CodeFixAction[]; + // IDE-side completion entries. Hosted environments (typescript-plugin) + // merge the result into `LanguageService.getCompletionsAtPosition`. + // CLI ignores it. Pre-3.2 the ignore plugin reached for `LanguageService` + // directly via `LinterContext`; that path is gone, so plugins that want + // IDE completions hook in here instead. + resolveCompletions?(file: SourceFile, position: number, entries: CompletionEntry[]): CompletionEntry[]; } export interface Rules { diff --git a/packages/typescript-plugin/index.ts b/packages/typescript-plugin/index.ts index 8671b13e..1e411a3c 100644 --- a/packages/typescript-plugin/index.ts +++ b/packages/typescript-plugin/index.ts @@ -40,6 +40,7 @@ function decorateLanguageService( getSemanticDiagnostics, getCodeFixesAtPosition, getCombinedCodeFix, + getCompletionsAtPosition, getApplicableRefactors, getEditsForRefactor, } = info.languageService; @@ -91,6 +92,33 @@ function decorateLanguageService( } return getCombinedCodeFix(scope, fixId, formatOptions, preferences); }; + // Merge plugin-supplied completion entries (e.g. ignore-comment + // suggestions from `@tsslint/config`'s ignore plugin) into the host's + // own completion result. Pre-3.2 plugins reached for + // `LanguageService.getCompletionsAtPosition` directly via + // `LinterContext`; the cleaner Program-based context takes that path + // away, so the IDE side reaches in through `linter.getCompletions` + // instead. + info.languageService.getCompletionsAtPosition = (fileName, position, ...rest) => { + let result = getCompletionsAtPosition(fileName, position, ...rest); + if (linter && isProjectFileName(fileName)) { + const extra = linter.getCompletions(fileName, position); + if (extra.length) { + if (result) { + result.entries.push(...extra); + } + else { + result = { + isGlobalCompletion: false, + isMemberCompletion: false, + isNewIdentifierLocation: false, + entries: extra, + }; + } + } + } + return result; + }; info.languageService.getApplicableRefactors = ( fileName, positionOrRange, @@ -167,8 +195,8 @@ function decorateLanguageService( } const projectContext: LinterContext = { - ...info, typescript: ts, + program: () => info.languageService.getProgram()!, }; try { From d2221303ccb18cfc2257e750bcef73f18d50c728 Mon Sep 17 00:00:00 2001 From: Johnson Chu Date: Mon, 4 May 2026 10:53:12 +0800 Subject: [PATCH 2/6] cli: drop createLanguageService, lint via direct ts.createProgram MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Continues the LinterContext cleanup from the previous commit. The CLI worker still constructed an LS internally (and only ever called `getProgram()` on it) — the LS pulled in completion / refactor / navigation / tsserver protocol machinery the CLI never used, plus a LanguageServiceHost that mostly proxied `ts.sys` with hand-rolled project-version tracking. Replaced with a `ts.CompilerHost` (one third of the LS-host surface) + a cached `ts.Program` rebuilt incrementally via `oldProgram`. `--fix` mid-session updates work the same: file overrides go into a `fileTextOverrides` map that the host's `readFile` consults; the next `ensureProgram()` call rebuilds with `oldProgram`, TS reuses unchanged SourceFiles' bound state by text equality, only re-binds the modified file. Same incremental story, smaller surface. ## Specific drops - `ts.createLanguageService` → `ts.createProgram` (or `proxyCreateProgram` when language plugins are configured) - `ts.LanguageServiceHost` and the project-version / type-roots-version / script-version / script-snapshot / script-kind / get-default-lib- file machinery → `ts.CompilerHost` from `ts.createCompilerHost`, overriding only `readFile` / `getSourceFile` for `--fix` overrides - `@volar/typescript`'s `createProxyLanguageService` + `decorateLanguageServiceHost` → `proxyCreateProgram` (program-side equivalent in the same package) - `(originalService as any).getCurrentProgram()` (Volar private API) → just pass our cached `currentProgram` - Snapshot map + version map → single `fileTextOverrides: Map` consulted by the compiler host - `core.applyTextChanges(IScriptSnapshot, …) → IScriptSnapshot` → `applyTextChanges(string, …) → string`. The snapshot wrapper served only the LS path; a plain string is what `--fix` actually needs to write to disk and stash for the next program rebuild. ## Detail: SourceFile.version `createSemanticDiagnosticsBuilderProgram` requires `SourceFile.version` to be set. The LS-host path got this via `host.getScriptVersion`; raw `createCompilerHost` doesn't populate it. The cleanest fix was to override `getSourceFile` in the host, stamp a content hash via `ts.sys.createHash` (sha256 fallback) onto each SourceFile after parse. Same value across runs as long as content matches → BuilderProgram's reference-graph diff correctly identifies unchanged files. ## What remains LanguageService-bound - `typescript-plugin/index.ts` — by definition (it's the IDE plugin wrapping the host-provided `ts.server.LanguageService`). Untouched. ## Verification - `predicate-coverage` 152/152, `lazy-estree.test`, `scope-compat` 24/24, `selector-analysis`, `ts-ast-scan`, `compat-pipeline`, `jsx-react-x` PARITY ✓ — all pass - `probe.test`, `skip-rules.test`, `cache-flow.test` — all pass - Dify CLI cold lint: 2961 passed / 1867 errors / 9 messages — bit-identical to the previous commit (LinterContext-shrunk but LS-still-in-worker) - tsslint-dify-bench compat-eslint level: 5w/2L/1T (median diff −7 ms, within noise — expected, the bench tests compat-eslint not the worker) - CLI cold lint level: 4 alternating pairs, median diff −0.09 s, range −1.58 to +0.41 s — essentially neutral (the LS path was already pretty good at incremental program reuse via `oldProgram`- passing under the hood; making the same machinery explicit doesn't buy or cost much wall time) ## Why this matters for tsgo Pre-3.2 the CLI worker's project-graph layer was LS-shaped: a host that provided versioned snapshots, an LS that owned the project version, a `getProgram()` thunk that rebuilt-on-version-bump. tsgo (`@typescript/native-preview`) has no LanguageService — it exposes Snapshot / Project / Program / Checker. The architectural translation from LS-host-flow to Snapshot-flow would have been the largest piece of the tsgo integration. With this commit the worker's project-graph layer is already program-flow shaped: ┌─────────────────────────────┐ │ ensureProgram() thunk │ │ ↓ │ │ ts.createProgram(opts, │ │ oldProgram = previous) │ └─────────────────────────────┘ The tsgo integration becomes a swap: replace `ts.createProgram` + `compilerHost` with tsgo's `API.updateSnapshot()` flow, keep the thunk shape, keep `oldProgram`-style incremental. --- packages/cli/lib/worker.ts | 254 ++++++++++++++++++------------------- packages/core/index.ts | 22 +--- 2 files changed, 130 insertions(+), 146 deletions(-) diff --git a/packages/cli/lib/worker.ts b/packages/cli/lib/worker.ts index 7799982b..e67329fe 100644 --- a/packages/cli/lib/worker.ts +++ b/packages/cli/lib/worker.ts @@ -15,18 +15,37 @@ import type { IncrementalState } from './incremental-state.js'; // 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'; +import { isCodeActionsEnabled, type Language } from '@volar/language-core'; +import { proxyCreateProgram } 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[] = []; 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(); +// Volar Language instance for Vue / MDX / Astro projects. Set by +// `proxyCreateProgram`'s `setup` callback when a language plugin is +// active; undefined for plain-TS projects. +// `proxyCreateProgram` returns the wrapped `ts.createProgram`. For +// plain-TS this stays as `ts.createProgram` itself. +let createProgram: typeof ts.createProgram = ts.createProgram; +// 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 +56,59 @@ 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)!)); - } - 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; +// 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`. +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) => { + const sf = originalGetSourceFile(fileName, languageVersion, onError, shouldCreate); + // 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 (sf && (sf as unknown as { version?: string }).version === undefined) { + (sf as unknown as { version: string }).version = hash(sf.text); } - return ts.ScriptKind.Unknown; - }, - getDefaultLibFileName(options) { - return ts.getDefaultLibFilePath(options); - }, -}; -const linterHost: ts.LanguageServiceHost = { ...originalHost }; -const originalService = ts.createLanguageService(linterHost); + return sf; + }; + 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 = 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 +161,20 @@ 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(); + currentProgram = undefined; + programDirty = true; + language = undefined; + createProgram = ts.createProgram; affectedFiles = undefined; currentBuilder = undefined; 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,16 +188,30 @@ async function setup( incremental: true, tsBuildInfoFile: incrementalState.SYNTHETIC_BUILD_INFO_PATH, }; + // Compile a fresh CompilerHost each setup — `options` may have + // changed, and createCompilerHost bakes them in (default lib paths, + // case sensitivity). + compilerHost = createCompilerHost(); + if (plugins.length) { + // Volar wraps `ts.createProgram` so Vue / MDX / Astro virtual + // scripts get spliced into the program. The `setup` callback + // gives us the Volar Language handle for diagnostic / fix + // transforms downstream. + createProgram = proxyCreateProgram(ts, ts.createProgram, () => ({ + languagePlugins: plugins, + setup(lang) { + language = lang; + }, + })); + } linter = core.createLinter( { typescript: ts, - // Thunk: each `lint()` call observes the LS's CURRENT program. - // `--fix` rewrites a file mid-session, bumps `projectVersion`, - // and the next `lint()` picks up the rebuilt program here. The - // LS itself is still TSSLint CLI's internal handle to TS — the - // public Linter API only sees Program. Pre-3.2 the linter took - // `{ languageService, languageServiceHost }`; both are gone. - program: () => linterLanguageService.getProgram()!, + // 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, @@ -227,7 +220,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, { @@ -284,7 +277,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; @@ -307,8 +300,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, }); @@ -327,19 +319,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. @@ -349,8 +342,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, }); @@ -358,12 +350,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) => { @@ -392,7 +384,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/core/index.ts b/packages/core/index.ts index 7aa986e5..dc17636c 100644 --- a/packages/core/index.ts +++ b/packages/core/index.ts @@ -434,21 +434,13 @@ export function combineCodeFixes(fileName: string, fixes: ts.CodeFixAction[]) { return finalTextChanges; } -export function applyTextChanges(baseSnapshot: ts.IScriptSnapshot, textChanges: ts.TextChange[]): ts.IScriptSnapshot { - textChanges = [...textChanges].sort((a, b) => b.span.start - a.span.start); - let text = baseSnapshot.getText(0, baseSnapshot.getLength()); - for (const change of textChanges) { +export function applyTextChanges(baseText: string, textChanges: ts.TextChange[]): string { + // Apply edits back-to-front so each change's offsets remain valid as + // earlier ones shift the suffix. + const sorted = [...textChanges].sort((a, b) => b.span.start - a.span.start); + let text = baseText; + for (const change of sorted) { text = text.slice(0, change.span.start) + change.newText + text.slice(change.span.start + change.span.length); } - return { - getText(start, end) { - return text.substring(start, end); - }, - getLength() { - return text.length; - }, - getChangeRange() { - return undefined; - }, - }; + return text; } From 0dc556778d339a49c36eaf3144c19a7c7c071060 Mon Sep 17 00:00:00 2001 From: Johnson Chu Date: Mon, 4 May 2026 11:46:27 +0800 Subject: [PATCH 3/6] cli: hand-roll Volar host wrap (skip decorateProgram), tests + smoke MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Review found one real bug + filled three test gaps. ## Bug: meta-framework lint regressed (.vue, .mdx, .astro) The previous commit used `proxyCreateProgram` from `@volar/typescript` to wire up Vue / MDX / Astro virtualisation. Functionally correct on the parse side — the host's `getSourceFile` returned a SourceFile whose text is `(spaces matching .vue text length) + (Volar-emitted TS)`, parsed into the AST that the linter walks. The wrong piece: `proxyCreateProgram` ALSO calls `decorateProgram`, which wraps `program.getSyntacticDiagnostics` / `getSemanticDiagnostics` / `emit` to call `fillSourceFileText` (in @volar/typescript's transform.js). `fillSourceFileText` MUTATES `SourceFile.text` in place, splicing the original .vue text back over the leading-offset spaces. The mutation fires when our layer-2 BuilderProgram drains via `getSemanticDiagnosticsOfNextAffectedFile` — silent. After that, the linter's `program.getSourceFile(fileName)` returns an SF whose `.text` is "", but whose AST nodes still hold offsets in the original "spaces + TS" layout. Net effect: `forEachChild` still finds the `console.log` CallExpression in the AST, but `getStart(sourceFile)` reads from the mutated text and the diagnostic span lands at wrong characters; `transformDiagnostic` can't map back; diagnostic dropped. Master got away with this by going through `decorateLanguageServiceHost` (LS-side), which doesn't decorate the Program. We need the same shape. ### Fix Drop `proxyCreateProgram`. Build the language manually with `createLanguage` + `resolveFileLanguageId` (the same setup `proxyCreateProgram` does internally), then decorate ONLY `compilerHost.getSourceFile` to virtualise via `language.scripts.get(fileName).generated.languagePlugin .typescript.getServiceScript(...)`. Skip the `decorateProgram` step entirely — the linter doesn't go through the program's own diagnostic methods, so the wrap that would call `fillSourceFileText` isn't needed. `transformDiagnostic` (which we still call explicitly per-diagnostic in `lint()`) DOES call `fillSourceFileText` on the diagnostic's source file, but by then the AST positions have already been captured in the diagnostic span. Idempotent via Volar's `transformedSourceFile` WeakSet. ## Tests Three new test files. Each pins one of the subtle pieces this PR touches. ### `packages/core/test/completions.test.ts` (new) 7 cases for `linter.getCompletions` aggregator + `resolveCompletions` plugin hook: - empty when no plugin contributes - single plugin entry returned - multi-plugin composition: later plugins see earlier entries (proves the aggregator passes the same array through) - plugins without `resolveCompletions` don't break the aggregator - missing source file short-circuits to empty (the program guard) - plugin receives the correct `SourceFile` for the requested fileName - position is passed through unchanged ### `packages/cli/test/program-host.test.ts` (new) 6 cases for the worker's CompilerHost. Pins the three non-obvious invariants of the LS → Program migration: - `BuilderProgram` doesn't crash on raw `createProgram` output (the SourceFile.version-stamping override is the contract) - every SourceFile has `.version` set, distinct contents → distinct versions - `setParentNodes: true` actually populates `Node.parent` chains - `fileTextOverrides` flows through `host.readFile` to the program - `oldProgram`-based rebuild honours overrides: changed text → new parse, unchanged file → text + version stable - BuilderProgram drain finds affected user files end-to-end ### `packages/cli/test/meta-frameworks.test.ts` (new) Smoke test for the language-plugin path. Spawns the actual `tsslint` binary against the in-tree `fixtures/meta-frameworks` for each `--*-project` flag (Vue, MDX, Astro, Vue Vine), asserts the no-console rule fires both on `fixture.tsx` (plain-TS sanity) and on the framework's own script file (proves virtualisation reached the AST walker). This is the test that would have caught the regression above. Skips gracefully when the fixture's `node_modules` isn't installed (e.g. in a fresh checkout that hasn't run `pnpm install`). For `--vue-vine-project`, pins to `fixture.vue` rather than `fixture.vine.ts`: the upstream `@vue-vine/language-service` shipped on the workspace has a known crash inside `createVirtualCode` (`vueCompilerOptions.globalTypesPath is not a function`) that prevents .vine.ts script content from reaching the AST. Master has the same gap. We assert the .vue path through vue-vine's plugin still works. ## Verification - All three new test files pass on first run - `predicate-coverage`, `lazy-estree.test`, `scope-compat` 24/24, `selector-analysis`, `ts-ast-scan`, `compat-pipeline`, `jsx-react-x` PARITY ✓ — unchanged - `cache-flow.test`, `probe.test`, `skip-rules.test` — unchanged, pass - Dify CLI cold lint: 2961 passed / 1867 errors / 9 messages — bit-identical to master - Meta-frameworks fixture per pipeline (vs master baseline): - vue-project: tsx + vue ✓ (was tsx only after the regression) - mdx-project: tsx + mdx ✓ - astro-project: tsx + astro ✓ - vue-vine-project: tsx + vue ✓ (vine.ts gap is upstream, both) ## Comment cleanup Fixed the merged comment block at `worker.ts:39-44` that was attributed to the wrong variable (was describing `language` but sat above `createProgram`). --- packages/cli/lib/worker.ts | 104 ++++++--- packages/cli/test/meta-frameworks.test.ts | 102 +++++++++ packages/cli/test/program-host.test.ts | 250 ++++++++++++++++++++++ packages/core/test/completions.test.ts | 206 ++++++++++++++++++ 4 files changed, 634 insertions(+), 28 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 e67329fe..f48812cd 100644 --- a/packages/cli/lib/worker.ts +++ b/packages/cli/lib/worker.ts @@ -15,12 +15,15 @@ import type { IncrementalState } from './incremental-state.js'; // always provides it via crypto, but the type is optional). sha256 hex. const defaultHash = (s: string) => crypto.createHash('sha256').update(s).digest('hex'); -import { isCodeActionsEnabled, type Language } from '@volar/language-core'; -import { proxyCreateProgram } from '@volar/typescript'; +import { createLanguage, FileMap, isCodeActionsEnabled, type Language } from '@volar/language-core'; +import { resolveFileLanguageId } from '@volar/typescript'; import { transformDiagnostic, transformFileTextChanges } from '@volar/typescript/lib/node/transform'; 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; // Cached Program instance + dirty flag. Pre-3.2 the worker wrapped a @@ -39,12 +42,6 @@ let programDirty = true; // 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(); -// Volar Language instance for Vue / MDX / Astro projects. Set by -// `proxyCreateProgram`'s `setup` callback when a language plugin is -// active; undefined for plain-TS projects. -// `proxyCreateProgram` returns the wrapped `ts.createProgram`. For -// plain-TS this stays as `ts.createProgram` itself. -let createProgram: typeof ts.createProgram = ts.createProgram; // 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- @@ -59,7 +56,8 @@ let currentBuilder: ts.SemanticDiagnosticsBuilderProgram | undefined; // 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`. +// 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 { @@ -77,17 +75,56 @@ function createCompilerHost(): ts.CompilerHost { return originalReadFile(fileName); }; host.getSourceFile = (fileName, languageVersion, onError, shouldCreate) => { - const sf = originalGetSourceFile(fileName, languageVersion, onError, shouldCreate); + const orig = originalGetSourceFile(fileName, languageVersion, onError, shouldCreate); + if (!orig) return orig; + // 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. + if (language) { + const sourceScript = language.scripts.get(fileName); + const tsAdapter = sourceScript?.generated?.languagePlugin.typescript; + if (sourceScript && tsAdapter) { + 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; + } + } + } // 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 (sf && (sf as unknown as { version?: string }).version === undefined) { - (sf as unknown as { version: string }).version = hash(sf.text); + if ((orig as unknown as { version?: string }).version === undefined) { + (orig as unknown as { version: string }).version = hash(orig.text); } - return sf; + return orig; }; return host; } @@ -99,7 +136,7 @@ function ensureProgram(): ts.Program { // re-parsing + re-binding for those. Modified files (via // `fileTextOverrides`) get re-parsed; unchanged files are zero- // cost. - currentProgram = createProgram({ + currentProgram = ts.createProgram({ rootNames: fileNames, options, host: compilerHost, @@ -170,7 +207,6 @@ async function setup( currentProgram = undefined; programDirty = true; language = undefined; - createProgram = ts.createProgram; affectedFiles = undefined; currentBuilder = undefined; @@ -188,22 +224,34 @@ async function setup( incremental: true, tsBuildInfoFile: incrementalState.SYNTHETIC_BUILD_INFO_PATH, }; - // Compile a fresh CompilerHost each setup — `options` may have - // changed, and createCompilerHost bakes them in (default lib paths, - // case sensitivity). - compilerHost = createCompilerHost(); if (plugins.length) { - // Volar wraps `ts.createProgram` so Vue / MDX / Astro virtual - // scripts get spliced into the program. The `setup` callback - // gives us the Volar Language handle for diagnostic / fix - // transforms downstream. - createProgram = proxyCreateProgram(ts, ts.createProgram, () => ({ - languagePlugins: plugins, - setup(lang) { - language = lang; + // 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( { typescript: ts, diff --git a/packages/cli/test/meta-frameworks.test.ts b/packages/cli/test/meta-frameworks.test.ts new file mode 100644 index 00000000..7d57f8bd --- /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 `