pi-lens
Version:
Real-time code feedback for pi — LSP, linters, formatters, type-checking, structural analysis & booboo
227 lines (226 loc) • 10.5 kB
JavaScript
/**
* Per-Server Diagnostic Strategies for pi-lens LSP
*
* Codifies known server behavior so timing decisions (debounce, retry budget,
* first-push seeding) are automatic rather than one-size-fits-all.
*
* Env var overrides (PI_LENS_LSP_*) always take precedence over strategy values.
*/
import { createRequire } from "node:module";
const _require = createRequire(import.meta.url);
/**
* Platform/arch → `@ast-grep/cli-<platform>-<arch>[-msvc|-gnu]` native
* package name, mirroring @ast-grep/cli's own `optionalDependencies` matrix
* (checked directly against node_modules/@ast-grep/cli/package.json). Each
* package ships the native `ast-grep`/`ast-grep.exe` binary at its package
* root (no `bin/` subdir) — see node_modules/@ast-grep/cli-win32-x64-msvc/.
*/
function astGrepNativePackageName(platform, arch) {
switch (platform) {
case "win32":
if (arch === "x64")
return "@ast-grep/cli-win32-x64-msvc";
if (arch === "arm64")
return "@ast-grep/cli-win32-arm64-msvc";
if (arch === "ia32")
return "@ast-grep/cli-win32-ia32-msvc";
return undefined;
case "darwin":
if (arch === "arm64")
return "@ast-grep/cli-darwin-arm64";
if (arch === "x64")
return "@ast-grep/cli-darwin-x64";
return undefined;
case "linux":
if (arch === "x64")
return "@ast-grep/cli-linux-x64-gnu";
if (arch === "arm64")
return "@ast-grep/cli-linux-arm64-gnu";
return undefined;
default:
return undefined;
}
}
/**
* Resolve ast-grep's platform-native exe DIRECTLY, skipping the node-bin
* wrapper (`ast-grep.cmd`/shim → node → cli.js → spawn native exe). One less
* orphanable process layer (#472): a wrapper's direct child is the node/cmd
* process, so on abnormal exit the actual ast-grep binary is a grandchild the
* #234 teardown path never reaches — resolving straight to the native exe
* means the LSP's direct child IS the real server.
*
* `require.resolve` is wrapped in try/catch (ESM-safe via createRequire, same
* pattern as clients/deps/ast-grep-napi.ts) — returns undefined so the caller
* falls back to the existing wrapper-based resolution when the platform
* package isn't installed (it's an optionalDependency; native builds can be
* absent on unsupported platforms/arches or a partial install).
*/
export function resolveAstGrepNativeExe(platform = process.platform, arch = process.arch) {
const pkgName = astGrepNativePackageName(platform, arch);
if (!pkgName)
return undefined;
const binaryName = platform === "win32" ? "ast-grep.exe" : "ast-grep";
try {
return _require.resolve(`${pkgName}/${binaryName}`);
}
catch {
return undefined;
}
}
export const SERVER_DIAGNOSTIC_STRATEGIES = {
typescript: {
seedFirstPush: true,
pullRetryBudgetMs: 0,
debounceMs: 50,
aggregateWaitMs: 1000,
expectSemanticSecondPush: false,
// Tier 3 (#458): typescript-language-server publishes nothing on a
// clean→clean edit (docs/lsp-capability-matrix.md, re-confirmed
// 2026-07-12). It's the lone core-set tier-3 server, which is exactly
// why the cascade lane's in-lane wait is worth skipping for it
// specifically. Applies to the CLASSIC server only (#524/#529/#558)
// — TS7's native `tsc --lsp --stdio` variant shares this
// "typescript" server id but the 2026-07-12 dual-environment
// re-measurement found it now publishes on clean (a drift from the
// #541 measurement); cascade-tier.ts's classifier checks the live
// snapshot's launchVariant and never applies this flag to a
// native-ts7 instance.
silentOnClean: true,
},
"rust-analyzer": {
seedFirstPush: false,
pullRetryBudgetMs: 500,
debounceMs: 150,
aggregateWaitMs: 3000,
expectSemanticSecondPush: true,
},
// PythonServer (pyright / basedpyright) — openFilesOnly mode: lazy per-file
// analysis, startup similar to jedi. seedFirstPush: true because pyright's
// first publishDiagnostics after didOpen is the complete result for that file.
python: {
seedFirstPush: true,
pullRetryBudgetMs: 0,
debounceMs: 100,
aggregateWaitMs: 1500,
expectSemanticSecondPush: false,
},
// jedi-language-server is push-only (no pull diagnostics) and its FIRST
// publishDiagnostics is the complete result (seedFirstPush). But that first
// push lands just after didOpen+~1s on cold start (Python/parso import) —
// measured ~1011ms — so a 1000ms aggregate budget misses it by a hair and
// returns zero. 3000ms gives cold-start headroom without stalling the warm path.
"python-jedi": {
seedFirstPush: true,
pullRetryBudgetMs: 0,
debounceMs: 100,
aggregateWaitMs: 3000,
expectSemanticSecondPush: false,
},
eslint: {
seedFirstPush: true,
pullRetryBudgetMs: 0,
debounceMs: 200,
aggregateWaitMs: 2000,
expectSemanticSecondPush: false,
},
// Opengrep security scanner (cross-language LSP). It pushes an EMPTY result
// during the one-time rule-load window at startup, then the real scan after
// `semgrep/rulesRefreshed` — so never seed the first push. Push-only (no pull
// diagnostics). Warm per-file scan ~1.3s; the first touch in a session may
// also pay rule-load (~3.5s cold). aggregateWaitMs is 3500: it's this
// server's OWN deadline, bounded by the per-edit caller cap as a ceiling
// (#242), so on a 2500ms-capped edit opengrep waits min(2500, 3500)=2500
// and on uncapped paths it gets its full 3500. 3500 covers warm and most
// cold; a cold scan that overruns isn't lost — late diagnostics are cached
// and surface on the next edit.
opengrep: {
seedFirstPush: false,
pullRetryBudgetMs: 0,
debounceMs: 250,
aggregateWaitMs: 3500,
expectSemanticSecondPush: false,
// Opengrep re-scans only on a fresh didOpen — didChange is a no-op for it.
reopenOnResync: true,
},
// ast-grep structural linter (sgconfig-gated auxiliary LSP). Push-only,
// compiles the project rules on the first scan of a session, and — like
// Opengrep — is re-synced via didClose+didOpen so edits trigger a re-scan.
// Conservative budget until measured against real projects (#239).
// ast-grep re-scans on didChange (verified: toggling the violation count
// 3→1→4→2 returns the correct fresh count each touch, with matching doc
// versions), so reopen isn't needed and didChange is the lighter path.
// aggregateWaitMs is 1800: its warm scan is ~1.3s, so 1000 was under-budgeted
// (only masked before because the old with-auxiliary deadline was a global
// max() floor; now each server has its own caller-cap-bounded deadline, #242,
// so the budget must actually cover the scan).
"ast-grep": {
seedFirstPush: false,
pullRetryBudgetMs: 0,
debounceMs: 150,
aggregateWaitMs: 1800,
expectSemanticSecondPush: false,
reopenOnResync: false,
},
// zizmor (GitHub Actions security scanner, auxiliary LSP #272). Push-only
// (no pull diagnostics). Its audit set is compiled-in — there's no rule-load
// window like Opengrep — so the FIRST publishDiagnostics after didOpen IS the
// complete result: seedFirstPush. It re-scans on didChange (FULL sync), so no
// reopen-on-resync. A native single-workflow audit is sub-second offline;
// online mode may add a GitHub-API round-trip, so 2000ms gives headroom
// (bounded by the per-edit caller cap as a ceiling, #242), and any late online
// finding is cached and surfaces on the next edit.
zizmor: {
seedFirstPush: true,
pullRetryBudgetMs: 0,
debounceMs: 150,
aggregateWaitMs: 2000,
expectSemanticSecondPush: false,
reopenOnResync: false,
},
// typos (source-code spell checker, auxiliary LSP #283). Push-only (no pull
// diagnostics). Its dictionary is compiled in — there's NO rule-load window
// like Opengrep — so the FIRST publishDiagnostics after didOpen IS the
// complete result: seedFirstPush. It re-scans on didChange (FULL sync), so no
// reopen-on-resync. A native single-file spell scan is sub-100ms, so the seed
// arrives near-instantly; aggregateWaitMs is a generous ceiling (bounded by
// the per-edit caller cap, #242) that the early seed resolves well under.
typos: {
seedFirstPush: true,
pullRetryBudgetMs: 0,
debounceMs: 150,
aggregateWaitMs: 1500,
expectSemanticSecondPush: false,
reopenOnResync: false,
},
// marksman (Markdown LSP, #274). Push-based; native binary so the per-file
// parse is fast, but its value is CROSS-file (broken intra-repo links,
// missing anchors/heading refs) which needs the workspace index — so the
// first push after didOpen can be empty before indexing completes. Don't
// seed it (like opengrep/rust-analyzer); a modest 1500ms aggregate covers
// warm edits, and any late cross-file finding surfaces on the next touch.
marksman: {
seedFirstPush: false,
pullRetryBudgetMs: 0,
debounceMs: 150,
aggregateWaitMs: 1500,
expectSemanticSecondPush: false,
// #645: a full-tree sweep opens every markdown file, all racing the
// SAME one-time workspace index build — pay the 1500ms budget once
// per sweep (the first markdown file touched), not once per file.
// 250ms covers a warm per-file publish (native binary, fast parse)
// once the index has already had a full aggregateWaitMs window.
workspaceIndexing: true,
workspaceIndexingWarmWaitMs: 250,
},
};
/** Fallback for unknown servers. Conservative defaults. */
export const DEFAULT_STRATEGY = {
seedFirstPush: false,
pullRetryBudgetMs: 250,
debounceMs: 150,
aggregateWaitMs: 1500,
expectSemanticSecondPush: false,
};
export function getStrategy(serverId) {
return SERVER_DIAGNOSTIC_STRATEGIES[serverId] ?? DEFAULT_STRATEGY;
}