Skip to content

Drop LanguageService dependency from core and CLI#98

Merged
johnsoncodehk merged 6 commits intomasterfrom
refactor/core-program-interface
May 4, 2026
Merged

Drop LanguageService dependency from core and CLI#98
johnsoncodehk merged 6 commits intomasterfrom
refactor/core-program-interface

Conversation

@johnsoncodehk
Copy link
Copy Markdown
Owner

@johnsoncodehk johnsoncodehk commented May 4, 2026

What

Replace LinterContext = { typescript, languageService, languageServiceHost } with { typescript, program: () => Program } and drop ts.createLanguageService from the CLI worker. Linting now runs end-to-end against ts.Program only, with no LanguageService anywhere on the path.

// before
interface LinterContext {
  typescript: typeof import('typescript');
  languageService: ts.LanguageService;
  languageServiceHost: ts.LanguageServiceHost;
}

// after
interface LinterContext {
  typescript: typeof import('typescript');
  program: () => ts.Program;
}

The thunk (vs a stable instance) lets callers swap programs mid-session — --fix rewrites a file, flips programDirty, the next lint() reads a freshly built program. Each lint() reads the thunk once at the top so program identity stays stable for that file's pass.

Why

tsgo (@typescript/native-preview) exposes Snapshot / Project / Program / Checker — no LanguageService equivalent. The pre-3.2 LS-coupled core was the largest blocker for tsgo integration; with the surface shrunk to a Program thunk, tsgo's Project.program slots straight in.

This also pays for itself today: ts.createLanguageService pulls in completion / refactor / navigation / watcher machinery that the linter never touched.

CLI worker (the bulk of the diff)

packages/cli/lib/worker.ts no longer instantiates a LanguageService. It now:

  • Builds a ts.CompilerHost directly. Overrides getSourceFile for (a) setParentNodes: true (compat-eslint walks Node.parent), (b) SourceFile.version content-hash stamping (BuilderProgram requires it), and (c) Vue / MDX / Astro virtualisation via the active language plugin.
  • Hand-rolls the Volar setup (createLanguage + resolveFileLanguageId) instead of calling proxyCreateProgram. The latter applies decorateProgram which wraps getSemanticDiagnostics to mutate SourceFile.text in 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-side decorateLanguageServiceHost; we can't.
  • Maintains a process-level sourceFileCache: Map<string, ts.SourceFile> shared across projects in the same CLI invocation. Pre-3.2 the LS instance survived setup() calls and reused lib + shared node_modules types across projects; with the LS gone, this restores parity (warm npm run lint over 7 tsconfigs: 1.09-1.14s, on par with master).

resolveCompletions plugin hook

The one place that genuinely needed LanguageService was @tsslint/config's ignore plugin — it wrapped getCompletionsAtPosition to suggest // @tsslint-ignore-style comments and rule IDs. Replaced with:

interface PluginInstance {
  resolveCompletions?(file, position, entries: CompletionEntry[]): CompletionEntry[];
}

Linter.getCompletions(fileName, position) aggregates across plugins. CLI doesn't call it; typescript-plugin wraps the host LS's getCompletionsAtPosition and merges in linter.getCompletions(...). Same UX, no LS reach-through inside @tsslint/config.

Public API breakage

  • LinterContext.languageService removed
  • LinterContext.languageServiceHost removed
  • LinterContext.program: () => Program added
  • PluginInstance.resolveCompletions added (optional)

Affects custom LinterContext consumers and any plugin reaching for ctx.languageService directly. The path forward is ctx.program() for type info, resolveCompletions for completion contributions.

Tests

New:

  • packages/core/test/completions.test.tsresolveCompletions aggregator (7 cases)
  • packages/cli/test/program-host.test.ts — version stamping, setParentNodes, fileTextOverrides flow, oldProgram rebuild (6 cases)
  • packages/cli/test/meta-frameworks.test.ts — spawns the real CLI on fixtures/meta-frameworks for --vue-project / --mdx-project / --astro-project / --vue-vine-project, asserts no-console fires on each framework's script content

Existing tests adjusted for the new context shape. Dify CLI cold lint: bit-identical to master (2961 passed / 1867 errors / 9 messages).

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.
@johnsoncodehk johnsoncodehk changed the title core: shrink LinterContext to a Program thunk Drop LanguageService dependency from core and CLI May 4, 2026
@johnsoncodehk johnsoncodehk merged commit 8a11b35 into master May 4, 2026
1 check passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant