Skip to content

Implement workspace/diagnostics - #64130

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

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 and use the CLI's routing of files to checkers. Without this, we run into errors in our large monorepo.

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
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) <noreply@anthropic.com>
A project's checkers were built for individual requests: one for diagnostics, a
handful for queries. A pull on a file and a build of the same program therefore
checked it with different checkers, out of a different number of them.

Which checker sees a file decides what its caches hold and what it reports, so
the compiler now exposes both halves of how it splits a program - how many
checkers a build uses, and which of them owns each file - and a project's
diagnostics run on the same split. A file is checked in the editor by the
checker the command line would check it with, and a pull reuses the types
whatever last touched that file left behind. 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.

Each checker is taken for one file at a time rather than for its whole group.
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 split is taken 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) <noreply@anthropic.com>
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) <noreply@anthropic.com>
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) <noreply@anthropic.com>
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) <noreply@anthropic.com>
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) <noreply@anthropic.com>
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) <noreply@anthropic.com>
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
program split 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) <noreply@anthropic.com>
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 program splits 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) <noreply@anthropic.com>
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) <noreply@anthropic.com>

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.

1 participant