Skip to content

Implement workspace/diagnostics - #64130

Open
Ellen Agarwal (eagarwal-notion) wants to merge 10 commits into
microsoft:mainfrom
makenotion:eagarwal-workspace-diagnostics
Open

Ellen Agarwal (eagarwal-notion) wants to merge 10 commits into
microsoft:mainfrom
makenotion:eagarwal-workspace-diagnostics

Conversation

@eagarwal-notion

@eagarwal-notion Ellen Agarwal (eagarwal-notion) commented Sep 1, 2026 •

Copy link
Copy Markdown

Goal

Implement the workspace/diagnostics feature - see #63784.

This lets users typecheck their entire repository quickly while also getting the remaining feature set of the LSP (unlike watch mode). It also uses less memory than running the LSP alongside watch mode or the CLI. Finally, it provides errors to the user more directly - in their IDE.

Interface

This PR implements the workspace/diagnostic method in the LSP spec.

However, vscode will poll this method every 2 seconds by default. This is likely to cause issues for a large repository so we want to gate this behind a feature flag.

To do this, we don't advertise this feature as available during server startup. We dynamically enable this when the user enables/disables the relevant setting. This way it won't be enabled by default in Vscode.

We can see this working here:

Demo.mov

(TODO: fix the bug where two diagnostics appear simultaneously (this is due to the dynamic registration for workspace/diagnostic conflicting with the static registration for textDocument/diagnostic))

Another approach we could take is to enable this feature by default (and remove the option to disable it via config). That way we can use the initial capabilities to enable/disable this feature. To disable this in vscode, we could make this an option the extension parses. The extension can then modify the initialize message to disable support for this feature based on user configuration.

A third way would be to just report no errors when the feature is disabled. I'm not sure if that would cause any issues for editors.

Implementation

I've borrowed a couple things from other parts of the codebase:

  • We use watch mode's incremental logic. That way the check is incremental after the first request
  • I've expanded the checker pool in the LSP to have 4 checkers (by default, it matches the compiler's setting)

I'd love suggestions on how to implement this better - happy to do this in this PR or in follow ups.

To control memory/cpu usage, this has 4 modes:

  • off (the default)
  • openProjects - Check only projects you have a file open in
  • openProjectsAndDependents - Check open projects and any projects which reference them
  • allProjects - Check all projects

Todo

Future/Potential improvements:

  • Fix the duplicate diagnostics issue
  • Start re-checking files when they're changed, rather than when the client asks for it
  • Make this available by default so we can remove the dynamic registration hack?

Please verify that:

  • There is an associated issue in the Backlog milestone (required)
  • Code is up-to-date with the main branch
  • You've successfully run npx hereby test
  • You've successfully run npx hereby lint
  • You've successfully run npx hereby check:format
  • There are new or updated tests validating the change

@typescript-automation typescript-automation Bot added the For Uncommitted Bug PR for untriaged, rejected, closed or missing bug label Sep 1, 2026
@eagarwal-notion

Copy link
Copy Markdown
Author

@microsoft-github-policy-service agree company="Notion"

@eagarwal-notion
Ellen Agarwal (eagarwal-notion) force-pushed the eagarwal-workspace-diagnostics branch 13 times, most recently from 5558e6e to 1ab15af Compare September 3, 2026 23:14
@eagarwal-notion
Ellen Agarwal (eagarwal-notion) force-pushed the eagarwal-workspace-diagnostics branch 5 times, most recently from d062f15 to 2a36707 Compare September 18, 2026 20:46
@eagarwal-notion
Ellen Agarwal (eagarwal-notion) force-pushed the eagarwal-workspace-diagnostics branch 3 times, most recently from 2e67a96 to 7891cac Compare September 25, 2026 22:52
@eagarwal-notion
Ellen Agarwal (eagarwal-notion) marked this pull request as ready for review September 25, 2026 23:36
Copilot AI balanced review requested due to automatic review settings September 25, 2026 23:36
@typescript-automation

Copy link
Copy Markdown

This PR doesn't have any linked issues. Please open an issue that references this PR. From there we can discuss and prioritise.

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Copilot review overview

🟡 Changes recommended

Project discovery, cancellation, caching, and progress-ordering issues can produce incomplete results or leave expensive obsolete checks running.

Get a fresh assessment by requesting another Copilot review.

Review effort: Balanced
Findings: 6 Medium severity

Open (6)
What changed in this PR

Implements opt-in LSP workspace/diagnostic support with incremental checking, caching, progress reporting, and configurable project scope.

Changes:

  • Adds workspace diagnostic handling, registration, caching, and configuration.
  • Extends checker pools and incremental state for parallel, interruptible workspace checks.
  • Adds comprehensive project, LSP, and integration tests.
File Description
tsc/​testdata/​baselines/​reference/​fourslash/​state/​codeLensAcrossProjects.baseline Updates default preferences baseline.
tsc/​internal/​project/​snapshothost.go Initializes shared interactive-work tracking.
tsc/​internal/​project/​snapshot.go Exposes workspace-checking project APIs.
tsc/​internal/​project/​session.go Tracks configuration and interactive updates.
tsc/​internal/​project/​projectcollectionbuilder.go Tracks loaded trees and incremental state.
tsc/​internal/​project/​projectcollection.go Tracks loaded trees and open projects.
tsc/​internal/​project/​project.go Adds incremental state and checker sizing.
tsc/​internal/​project/​interactivework.go Implements interactive-work coordination.
tsc/​internal/​project/​interactivework_test.go Tests interactive-work coordination.
tsc/​internal/​project/​incrementalstate.go Carries incremental state across programs.
tsc/​internal/​project/​incrementalstate_test.go Tests incremental-state concurrency.
tsc/​internal/​project/​incrementalreferences_test.go Tests reference-map reuse.
tsc/​internal/​project/​incrementalpartial_test.go Tests partial results after cancellation.
tsc/​internal/​project/​idlecacheclean_test.go Tests update prioritization.
tsc/​internal/​project/​checkprogress.go Adds checker progress callbacks.
tsc/​internal/​project/​checkerpool.go Adds parallel diagnostic checkers.
tsc/​internal/​project/​checkerpool_test.go Expands checker-pool coverage.
tsc/​internal/​lsp/​workspacediagnosticsscope.go Selects projects and files in scope.
tsc/​internal/​lsp/​workspacediagnosticsregistration.go Dynamically registers the capability.
tsc/​internal/​lsp/​workspacediagnosticscache.go Caches workspace diagnostic results.
tsc/​internal/​lsp/​workspacediagnostics.go Implements workspace diagnostic requests.
tsc/​internal/​lsp/​workspacediagnostics_internal_test.go Tests settings and supersession internals.
tsc/​internal/​lsp/​server.go Integrates handlers, registration, and errors.
tsc/​internal/​lsp/​server_workspacediagnosticsregistration_test.go Tests dynamic registration.
tsc/​internal/​lsp/​server_workspacediagnostics_test.go Tests workspace diagnostic behavior.
tsc/​internal/​lsp/​lsproto/​lsp.go Adds partial-result progress typing.
tsc/​internal/​ls/​lsutil/​userpreferences.go Adds workspace diagnostic preferences.
tsc/​internal/​ls/​diagnostics.go Adds whole-project diagnostic collection.
tsc/​internal/​execute/​incremental/​programtosnapshot.go Reuses prior snapshots and references.
tsc/​internal/​execute/​incremental/​program.go Preserves partial incremental results.
tsc/​internal/​diagnostics/​extraDiagnosticMessages.json Adds progress text.
tsc/​internal/​diagnostics/​diagnostics_generated.go Regenerates diagnostic definitions.
tsc/​internal/​core/​context.go Marks interactive requests.
tsc/​internal/​compiler/​program.go Supports pool-driven whole-program checks.
packages/​vscode-typescript/​package.nls.json Adds localized setting descriptions.
packages/​vscode-typescript/​package.json Exposes experimental VS Code settings.
Files not reviewed (1)
  • tsc/internal/diagnostics/diagnostics_generated.go: Generated file

💡 Add a code-review agent skill for context-aware, tailored reviews. Learn more in the docs.

Comment on lines +54 to +56
if ctx.Err() != nil {
return ctx.Err()
}
Comment on lines +133 to +138
run.fingerprint = newWorkspaceDiagnosticsFingerprint(snapshot, settings)
if s.workspaceDiagnostics.repeatsLastAnswer(run.fingerprint, run.previous) {
repeat = true
return
}
run.collect(snapshot, projectsInScope(snapshot, scope))
Comment on lines +346 to +349
// Ask through the incremental view, so a change is re-checked where it landed rather than
// across the whole project.
program := snapshot.IncrementalProgram(pf.project)
reports := pf.languageService.WorkspaceDiagnosticsForProject(ctx, program, files)
Comment on lines +503 to +512
for {
last := r.lastPercentage.Load()
if percentage <= last {
return
}
if r.lastPercentage.CompareAndSwap(last, percentage) {
break
}
}
r.sendProgress(lsproto.WorkDoneProgressBeginOrReportOrEnd{
Comment on lines +95 to +100
for uri, entry := range c.entries {
if clientHolds[uri] != entry.resultID {
return false
}
}
return true
Comment on lines +36 to +38
all := snapshot.ProjectCollection.Projects()
if scope == lsutil.WorkspaceDiagnosticsScopeAllProjects {
return all
The settings a workspace pull reads, ahead of anything that reads them.

experimental.workspaceDiagnostics.scope decides which projects a pull reports
on, and is off by default: the pull checks projects the editor would otherwise
never load, so it is opt-in. serverDiagnosticsDeDuplication lets a client that
does not pull documents separately ask for open documents to be included, which
a client that pulls both would otherwise show twice.

Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
A project's checkers were built for individual requests: one for diagnostics, a
handful for queries, so a whole project was checked a file at a time on one
checker.

A project's diagnostics now run on as many checkers as a build of the program
would use. A whole-program check shares its files between them from one queue,
each checker taking the next file as it finishes the last, and a file is
remembered against the checker that checked it, so a later pull on it goes back
to where its types already are. Query checkers keep their own slots after them,
since a query does not depend on which checker answers it and should not queue
behind a check of the whole project.

The program hands a whole-program check to a pool that runs one itself, and
otherwise checks file by file as before. The compiler's own pool is unchanged.

Each checker is taken for one file at a time rather than for the whole check.
That costs an acquisition per file and buys the ability to let something else
in between them, which the next changes need.

The checkers are handed back when a caller is done with them. They hold the
types of every file they reached, and keeping them buys nothing: a later pull
that finds the project unchanged answers from what the client already holds
without checking, and one that finds it changed needs new checkers anyway.

Only a workspace pull checks a project this way, so the extra checkers are held
only when that is switched on. Without it a project keeps the single diagnostics
checker it has always had, and does not pay for the rest.

Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
A pass over the whole workspace keeps every checker of every project it is
checking busy, and the machine with them. A pull on the document in front of
the user arrives into that and waits: for the checker that owns the file, and
then for a core to check it on. A snapshot update is worse, since it rebuilds
the program under the session's snapshot lock, which every other request queues
behind.

The session counts the work a user is waiting on: an interactive document pull,
and a snapshot update. A whole-program check waits for that count to reach zero
before each file. It holds no checker while waiting, so what it stands aside
for can take the checker it was about to use, and it gives up waiting if its
own caller goes away.

The trade is one-sided. A pass runs for as long as the workspace is big and
nothing waits on it finishing sooner, so the time it gives up is time the user
was going to spend waiting anyway. Only the pass waits; a pull never does, and
counts from before it waits for a checker rather than from when it gets one, so
the pass cannot take the checker out from under it.

Requests are marked interactive by the handler rather than inferred from the
checker lifetime, because a pass acquires diagnostics checkers itself, per
file, and would otherwise stand aside for its own work and never finish.

Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
A check of a large project runs for minutes inside a single call, so a caller
that wants to say how far along it is cannot learn it from the call returning.
The pool doing the work has to say.

The pool takes a callback from the context and calls it as each file is
finished. It is called once per file, from each of the checkers, so it has to
be cheap and safe to call concurrently; what a caller does with it, and how
often it acts on it, is the caller's business.

Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
Checking a project checked every file of it, however little had changed. An
edit to one file meant the next check re-did the workspace.

A project now keeps the incremental view of its program: the file hashes,
references and cached diagnostics one program leaves for the next to work out
what a change reached. A caller asking through that gets a file the edit did
not reach from what was cached rather than checking it again. The view is
built on first use, since working it out walks every file and most programs are
never asked, and what it worked out is carried to the next program without
carrying the program itself - a caller that keeps the whole thing keeps every
type reachable from it too.

Building it resolves every file's imports, which needs a checker. It asks for
a diagnostics checker rather than letting the project system read the bare
context as a query: a query checker is never handed back when a check is done,
so a program's worth of types would sit in the slots kept for hovers and
completions until they idled out, and survive into the next program's
generation.

Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
A check that was cancelled threw away every file it had finished, so the next
one started from nothing. On a project where a check outlasts the gap between
two edits, that meant it could never finish at all.

It was thrown away because the results could not be told apart: an entry for a
file the check never reached is nil, which reads the same as a file checked
and found clean, and caching that would hide real errors. The collection now
reports which files it got through, and only those are kept.

Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
A pull has to know which projects to report on and which files are theirs, and
none of that was reachable from outside the project system.

OpenProjects is the projects an editor actually has a file open in, which is
what the narrower scopes report on. ReferencedProjectPaths walks the reference
graph, so a pull can find the projects that consume an open one. isOpen answers
the question for one project, going through the memoized set for configured
projects and scanning the open files for the inferred one, which is not in that
set and of which there is only ever one.

A collection also remembers which project trees it was last built for, so a
request the loaded trees already cover is answered without building a snapshot
to discover there was nothing to load. A pull asking for everything, every few
seconds, would otherwise rebuild the collection each time.

Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
What a pull reports for a project, in terms the language service already
speaks.

WorkspaceDiagnosticFiles is the files of a project worth reporting on. It
leaves out what is not the user's code to fix: default libraries, anything
reached as an external library, and a referenced project's sources or emitted
declarations, which the project that owns them reports itself. A content-mapped
file's projection goes too, since its canonical file reports under the same
URI. A project narrows the rest the way it narrows a build, with its tsconfig.

WorkspaceDiagnosticsForProject checks the project in one call and returns what
each file should report, keyed by file. Checking everything at once lets the
pool share the work across its checkers rather than having it driven a file
at a time from outside. The program is passed in rather than taken from the
language service, because a pull hands over the incremental view of it.

Suggestions are left out: nothing caches them, so asking would re-check every
file and undo the point of the incremental view.

Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
Reports diagnostics for the whole workspace, not just the files the editor has
open.

A pull runs off the dispatch loop, because checking a large workspace takes
minutes and the server has to keep answering everything else meanwhile. Files
within a project are checked in one call so the pool shares the work; the
projects themselves run a few at a time, bounded so the two together do not
take the machine. Reports stream through the partial result token as projects
finish, and always in the same order.

Three things keep the cost down between pulls, because the client pulls every
couple of seconds for as long as it is open:

A result id per file, hashed from its diagnostics and remembered against the
program version that produced it. A project is rebuilt as a unit, so an
unchanged generation answers every file in it without checking anything.

A fingerprint of what the last answer was computed from - the snapshot and the
settings. A pull that matches it, and whose client still holds every result id
handed out, reports nothing at all rather than walking every file to say so.

Superseding: a pull cancels the one before it, with a cause saying why. The
reason decides what the client is told. A pull the user cancelled answers
RequestCancelled; one a newer pull replaced answers ServerCancelled carrying
DiagnosticServerCancellationData, because a client that cannot tell a server
standing down from a failure counts it as one, and a handful is enough for it
to stop pulling the workspace for the session. Pulls are superseded on the
dispatch loop, so they replace one another in the order the client sent them
rather than the order their goroutines start in.

Progress is reported against the client's work done token, or one the server
creates when the client did not send one - and clients that pull the workspace
do not. It advances as files are checked rather than as projects report, since
a workspace can be a single project and would otherwise sit at nothing for the
whole run. A report is only sent when the percentage moves, which bounds it to
a hundred notifications however large the sweep.

Open documents are left to the client's own per-document pull, which it
reconciles poorly with workspace results; a client that only pulls the
workspace can ask for them to be included.

Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
The capability cannot be advertised at initialize. The setting that turns
workspace diagnostics on arrives after it, and a server that claimed the
capability while the setting was off would have clients pulling a workspace
nobody asked it to check.

It is registered and unregistered dynamically instead, as the setting changes,
and only ever by the one provider. The client runs a workspace pull per
provider that asks for it, into that provider's own collection, so a second
provider carrying the capability would report every problem twice; the content
mapper's registration deliberately leaves it off, and the provider that does
carry it covers content-mapped files too.

Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>

This branch has not been deployed

No deployments
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

For Uncommitted Bug PR for untriaged, rejected, closed or missing bug

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants