Drop LanguageService dependency from core and CLI#98
Merged
johnsoncodehk merged 6 commits intomasterfrom May 4, 2026
Merged
Conversation
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
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<string, string>`
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.
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 "<original .vue><trailing TS suffix>", 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`).
User caught a regression: `npm run lint` (which runs the CLI on
`{tsconfig.json,packages/*/tsconfig.json}` — root + every package) got
~2x slower vs master.
## Root cause
Pre-3.2 the worker constructed `originalService = ts.createLanguageService(linterHost)`
ONCE at module load, mutated the host's project state across `setup()`
calls, and reused the LS instance for every project. The LS's internal
SourceFile cache survived projectVersion bumps — so lib.es5.d.ts and
any node_modules types both projects pulled in skipped re-parsing on
the second project.
The previous commit's `currentProgram = undefined` reset in `setup()`
threw that away. `oldProgram`-passing on the next `ts.createProgram`
gives within-project incremental reuse (for `--fix` rewrites) but TS's
`tryReuseStructureFromOldProgram` bails when compilerOptions differ
across tsconfigs — and they always do across packages (different
target / lib / paths). So every project re-parsed every file from
scratch, including the multi-MB lib.
## Fix
Process-level `sourceFileCache: Map<path, SourceFile>` consulted by
`compilerHost.getSourceFile`. Same path + same content → return the
cached SF. Invalidated by content change (text-equality check on
lookup), so `--fix` rewrites still get a fresh parse.
Virtualised SFs (Vue / MDX / Astro) are NOT cached — 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. Plain TS files (the
common case, including all of lib + node_modules) cache cross-project.
`currentProgram` itself also stays across `setup()` calls, so the
post-fix incremental rebuild within a project still works via
`oldProgram`.
## Numbers
Multi-project (`{tsconfig.json,packages/*/tsconfig.json}`, --force, 4 runs):
master: wall 1.34–1.91s, user 2.64s
pre-fix: wall 2.58–2.90s, user 4.62–4.99s ← regression
post-fix: wall 1.37–1.40s, user 2.71–2.73s ← parity restored
Single-project Dify cold lint (`--project tsconfig.json --force`):
2961 passed / 1867 errors / 9 messages — bit-identical to master.
All meta-framework smoke tests still report 2 diagnostics each
(rule fires on .tsx + framework script for vue/mdx/astro/vue-vine).
All other tests pass.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
What
Replace
LinterContext = { typescript, languageService, languageServiceHost }with{ typescript, program: () => Program }and dropts.createLanguageServicefrom the CLI worker. Linting now runs end-to-end againstts.Programonly, with no LanguageService anywhere on the path.The thunk (vs a stable instance) lets callers swap programs mid-session —
--fixrewrites a file, flipsprogramDirty, the nextlint()reads a freshly built program. Eachlint()reads the thunk once at the top so program identity stays stable for that file's pass.Why
tsgo (
@typescript/native-preview) exposesSnapshot/Project/Program/Checker— noLanguageServiceequivalent. The pre-3.2 LS-coupled core was the largest blocker for tsgo integration; with the surface shrunk to a Program thunk, tsgo'sProject.programslots straight in.This also pays for itself today:
ts.createLanguageServicepulls in completion / refactor / navigation / watcher machinery that the linter never touched.CLI worker (the bulk of the diff)
packages/cli/lib/worker.tsno longer instantiates a LanguageService. It now:ts.CompilerHostdirectly. OverridesgetSourceFilefor (a)setParentNodes: true(compat-eslint walksNode.parent), (b)SourceFile.versioncontent-hash stamping (BuilderProgram requires it), and (c) Vue / MDX / Astro virtualisation via the active language plugin.createLanguage+resolveFileLanguageId) instead of callingproxyCreateProgram. The latter appliesdecorateProgramwhich wrapsgetSemanticDiagnosticsto mutateSourceFile.textin place — after the AST is parsed — so diagnostic spans land at characters that no longer match the AST. Master got away with this via LS-sidedecorateLanguageServiceHost; we can't.sourceFileCache: Map<string, ts.SourceFile>shared across projects in the same CLI invocation. Pre-3.2 the LS instance survivedsetup()calls and reused lib + sharednode_modulestypes across projects; with the LS gone, this restores parity (warmnpm run lintover 7 tsconfigs: 1.09-1.14s, on par with master).resolveCompletionsplugin hookThe one place that genuinely needed
LanguageServicewas@tsslint/config's ignore plugin — it wrappedgetCompletionsAtPositionto suggest// @tsslint-ignore-style comments and rule IDs. Replaced with:Linter.getCompletions(fileName, position)aggregates across plugins. CLI doesn't call it;typescript-pluginwraps the host LS'sgetCompletionsAtPositionand merges inlinter.getCompletions(...). Same UX, no LS reach-through inside@tsslint/config.Public API breakage
LinterContext.languageServiceremovedLinterContext.languageServiceHostremovedLinterContext.program: () => ProgramaddedPluginInstance.resolveCompletionsadded (optional)Affects custom
LinterContextconsumers and any plugin reaching forctx.languageServicedirectly. The path forward isctx.program()for type info,resolveCompletionsfor completion contributions.Tests
New:
packages/core/test/completions.test.ts—resolveCompletionsaggregator (7 cases)packages/cli/test/program-host.test.ts— version stamping,setParentNodes,fileTextOverridesflow,oldProgramrebuild (6 cases)packages/cli/test/meta-frameworks.test.ts— spawns the real CLI onfixtures/meta-frameworksfor--vue-project/--mdx-project/--astro-project/--vue-vine-project, assertsno-consolefires on each framework's script contentExisting tests adjusted for the new context shape. Dify CLI cold lint: bit-identical to master (2961 passed / 1867 errors / 9 messages).