Implement workspace/diagnostics - #64130
Draft
Ellen Agarwal (eagarwal-notion) wants to merge 10 commits into
Draft
Ellen Agarwal (eagarwal-notion) wants to merge 10 commits into
Ellen Agarwal (eagarwal-notion) wants to merge 10 commits into
Conversation
Author
|
@microsoft-github-policy-service agree company="Notion" |
Ellen Agarwal (eagarwal-notion)
force-pushed
the
eagarwal-workspace-diagnostics
branch
13 times, most recently
from
September 3, 2026 23:14
5558e6e to
1ab15af
Compare
Ellen Agarwal (eagarwal-notion)
force-pushed
the
eagarwal-workspace-diagnostics
branch
5 times, most recently
from
September 18, 2026 20:46
d062f15 to
2a36707
Compare
Ellen Agarwal (eagarwal-notion)
force-pushed
the
eagarwal-workspace-diagnostics
branch
from
September 24, 2026 21:47
2a36707 to
ade232f
Compare
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>
Ellen Agarwal (eagarwal-notion)
force-pushed
the
eagarwal-workspace-diagnostics
branch
from
September 24, 2026 22:13
ade232f to
2e67a96
Compare
This branch has not been deployed
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.
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:
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:
Todo
Future/Potential improvements:
Please verify that:
Backlogmilestone (required)mainbranchnpx hereby testnpx hereby lintnpx hereby check:format