UNPKG

pi-lens

Version:

Real-time code feedback for pi — LSP, linters, formatters, type-checking, structural analysis & booboo

158 lines (140 loc) • 11 kB
# LSP capability matrix — affirmative-clean-signal strategy How pi-lens knows a just-edited file is **clean** (no diagnostics) vs the server simply **hasn't answered yet** (cold/crashed/silent). pi-lens waits *synchronously* for a verdict, so — unlike an editor, which renders asynchronously and never needs to decide — it must have a positive signal. There is no signal for "silence", so we classify each server and pick a per-server strategy. (Background: #240; mechanism confirmed against Neovim's LSP client, which sidesteps this entirely by being async.) Generate/refresh this matrix with `node scripts/characterize-lsp.mjs [--install]` (the `mode` column) and `node scripts/probe-clean-signal.mjs [--install]` (the `clean-behavior` column — 4-way: 2 / 2* / 3 / unknown). Both **merge** in place: a server the running host couldn't spawn keeps its prior row, so an ubuntu-poor run can't regress a richer one (#390). The nightly **tool-smoke** workflow runs both (plus `server-capabilities.mjs`) and opens/updates a single auto-PR (`bot/lsp-docs-refresh`, "docs(nightly): refresh LSP capability docs") with the regenerated docs — so this file self-populates from CI without manual copy-paste. `probe-clean-signal.mjs` also runs a **drift check** (#529): it compares each probed server's observed `clean-behavior` against the hand-set `silentOnClean` marker in `clients/lsp/server-strategies.ts` and writes any mismatch to the `## silentOnClean drift` section below. This is telemetry only — never a CI gate — because the probe is a timing-based negative observation; a mismatch just tells a human the marker may need updating. `unknown` observations are never compared in either direction (a slow/absent server isn't evidence of anything). The native TS7 launch variant (`typescript7`/`typescript7-clean`, #524/#526) is deliberately excluded from comparison against classic's marker — they share a server id but not a verified clean-signal behavior. ## The strategies | Tier | Signal | Affirmative clean? | Example | |---|---|---|---| | **1 — pull** | `textDocument/diagnostic` returns an authoritative report (empty = clean) | YES, deterministic | rust-analyzer | | **2 — push, publishes-versioned** | `publishDiagnostics([])` **with version** on every scan, incl. clean→clean | YES, currency-proven via version | ast-grep | | **2\* — push, publishes-unversioned** | re-publishes on a clean scan but **version-less** — the wait still early-returns (the client accepts a version-less publish as fresh: it can't be proven stale), but currency is only *temporally correlated*, not proven | YES at runtime, with a staleness-risk caveat (not a latency cost) | opengrep | | **3 — push, silent on clean** | server publishes nothing when nothing changed | **NO** — budget-wait floor (safe; a timeout is *not* a false clean). **This tier is #458's learned-deadline target set.** | typescript-language-server | Detection is **cached** at `initialize` (`detectWorkspaceDiagnosticsSupport` → `state.workspaceDiagnosticsSupport.mode`, upgraded on `client/registerCapability`), so the tier is free at collection time — no per-edit probe. ## Matrix (dev box + CI nightly; mode last refreshed 2026-06-17 from run 27713958681, clean-behavior probed on the dev box 2026-07-08 — #460) `mode` from cached capabilities; `clean-behavior` from the phase-aware publish-trace probe. The probe attributes publishes to two phases — the **dirty touch** (proves the server is live) and the **clean transitions** (the discriminator) — and classifies 4-way: `publishes-versioned` (tier 2: publish WITH version on a clean transition — affirmative + currency-proven), `publishes-unversioned` (tier 2\*: version-less publish on a clean transition — the wait still early-returns at runtime, currency only temporally correlated), `silent` (tier 3: alive on dirty, silent on clean — budget-wait, the #458 target), `unknown` (no publish at all — slow/absent, conservatively not classified). `src` = where a row was measured: **ci** = the nightly steps, **dev** = the dev box (a row measured on both reads `dev+ci`). Merges never blank a prior good value, so a CI non-result leaves the dev classification standing. | lang | server | mode | clean-behavior | tier | src | |---|---|---|---|---|---| | json | vscode-json-language-server | pull | — | 1 | dev+ci | | css | vscode-css-language-server | pull | — | 1 | dev+ci | | html | vscode-html-language-server | pull | — | 1 | dev+ci | | rust | rust-analyzer | pull | — | 1 | dev | | svelte | svelte-language-server | pull | — | 1 | dev+ci | | deno | deno (alt of typescript) | pull | — | 1 | dev+ci | | ruby | ruby-lsp | pull | — | 1 | ci | | csharp | csharp-ls | pull | — | 1 | ci | | typescript | typescript-language-server | push-only | silent | 3 | dev+ci | | python | pyright | push-only | publishes-versioned | 2 | dev+ci | | jedi | jedi-language-server (alt of python) | push-only | publishes-versioned | 2 | ci | | yaml | yaml-language-server | push-only | publishes-unversioned | 2* | dev+ci | | shell | bash-language-server | push-only | publishes-versioned | 2 | dev+ci | | dockerfile | docker-langserver | push-only | publishes-unversioned | 2* | dev+ci | | toml | taplo | push-only | publishes-unversioned | 2* | dev+ci | | terraform | terraform-ls | push-only | TBD | 2/3? | dev+ci | | prisma | @prisma/language-server | push-only | publishes-unversioned | 2* | dev+ci | | php | intelephense | push-only | TBD | 2/3? | dev | | zig | zls | push-only | publishes-unversioned | 2* | dev+ci | | vue | @vue/language-server | push-only | TBD | 2/3? | dev+ci | | dart | dart language-server | push-only | publishes-unversioned | 2* | ci | | gleam | gleam lsp | push-only | publishes-unversioned | 2* | ci | | clojure | clojure-lsp | push-only | publishes-unversioned | 2* | ci | | opengrep | opengrep (aux) | push-only | publishes-unversioned | 2* | dev+ci | | ast-grep | ast-grep (aux) | push-only | publishes-versioned | 2 | dev+ci | **Unknown — fixture exists, mode not yet captured.** The toolchain-gated family (no auto-install today; tracked in #241) — `go` (gopls), `java` (jdtls), `kotlin`, `swift` (sourcekit-lsp), `lua`, `cpp` (clangd), `haskell`, `elixir`, `ocaml`, `nix` (nixd), `fsharp`. Their servers don't install in the nightly, so characterize reports `unknown` (a non-failure ⚠). Once #241 lands they'll fill in the same way clojure-lsp/gleam now do (both auto-install via the github strategy and were characterized `push-only` in the run above). ## Key findings - **Mode ≠ tier, and the split needs BOTH axes.** Push-only further splits along latency (does anything publish on a clean transition? — silence is the only budget-wait case, because pi-lens's publish handler emits and early-returns the wait on EVERY publish, versioned or not) and currency-proof (is the publish versioned, i.e. provably about the live edit?). The 4-way `probe-clean-signal.mjs` measurement drives this: ast-grep → 2, yaml/opengrep → 2\*, typescript (clean file) → 3. - **opengrep is 2\*, not 3 and not plain 2.** It *does* re-publish on a clean scan (the wait early-returns at runtime — fast), but every push carries `pubVersion=undefined`, so currency is only temporally correlated, not proven — a staleness-risk note, not a latency cost. An earlier hand-note called it Tier 2 on the "re-publishes" observation alone; the phase-aware probe refines it to 2\*. - **typescript's clean behavior is diagnostic-set-dependent (major probe finding).** On a DIRTY file it re-publishes (version-lessly) after every change — the dirty fixture measures 2\*. On a genuinely CLEAN file (the `typescript-clean` fixture) it publishes nothing on a clean→clean edit — silent, Tier 3. The clean-file behavior is the production case (the observed budget-wait timeouts), so the matrix row records the clean fixture's verdict; the probe prefers `clean: true` fixtures for exactly this reason. Corollary: a 2\* measured only on a dirty fixture may overstate a server whose publishes stop when its set goes empty — langs without a clean fixture carry that caveat. - **#458's learned-deadline target set = the tier-3 rows only.** 2\* rows resolve the wait at runtime and must NOT be given learned deadlines. - **Tier 3 is budget-bound by necessity**, not laziness: a silent server's silence is ambiguous (clean-unchanged vs still-analyzing), so shortening the wait or reusing `lastKnownDiagnostics` would risk a false clean. The wait *is* the safety mechanism. - **ast-grep (Phase 2 / #239) is Tier 2** — it self-signals clean on every scan, so it is not the bottleneck. The cost on a clean with-auxiliary touch is the *silent primary* (typescript), a pre-existing Tier-3 cost independent of ast-grep. ## Completing the matrix The fixtures (`tests/fixtures/tool-smoke/<lang>/`) are durable and cover every registered server. `mode` is read from the server's advertised capabilities at `initialize`, so it is **content-independent** — for languages that already had a tool-layer fixture we point `characterize-lsp.mjs` at the existing (deliberately dirty) `bad.*` source rather than a colliding clean duplicate; new languages get a minimal clean source. Either way the mode reported is the same. The nightly **tool-smoke** workflow runs `characterize-lsp.mjs --install` **and** `probe-clean-signal.mjs --install` (after the LSP handshake layer) on `ubuntu-latest`, then opens/updates an auto-PR with the regenerated docs (#390) — so both the `mode` and `clean-behavior` columns self-populate in CI without manual copy-paste. They fill for servers that either auto-install (npm/pip/github — including clojure-lsp and gleam, both github-strategy as of f263cf3) or whose toolchain the workflow provisions (Ruby/Dart/Zig + .NET→csharp). The remaining `unknown` rows are the toolchain-gated family (#241): until `runtimeInstall` + canonical-bin discovery land, their servers don't install in CI and stay ⚠. The **merge guard** means a CI run that can't reach a server never blanks its dev-measured row. The `clean-behavior` split (4-way: 2 publishes-versioned / 2\* publishes-unversioned / 3 silent / unknown) is now measured per-server by `probe-clean-signal.mjs`, no longer a manual one-off. Locally confirmed: ast-grep + ast-grep-baseline → 2, yaml + opengrep → 2\*, typescript → 3 on its clean fixture (2\* on the dirty fixture — see Key findings); the rest fill in as the nightly reaches them. ## silentOnClean drift (nightly-generated) Telemetry only — never a CI gate. Compares each probed server's observed `clean-behavior` against `clients/lsp/server-strategies.ts`'s `silentOnClean` marker; a mismatch means the marker may need a human update (#529). `unknown` observations are never compared (a slow/absent server is not evidence either way). _None observed as of the last probe run._