pi-lens 3.8.39 → 3.8.41

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (65) hide show
  1. package/CHANGELOG.md +84 -5
  2. package/README.md +37 -1
  3. package/clients/biome-client.ts +5 -4
  4. package/clients/cache/rule-cache.ts +1 -1
  5. package/clients/complexity-client.ts +1 -1
  6. package/clients/dependency-checker.ts +1 -1
  7. package/clients/dispatch/diagnostic-taxonomy.ts +13 -1
  8. package/clients/dispatch/dispatcher.ts +9 -0
  9. package/clients/dispatch/fact-scheduler.ts +1 -1
  10. package/clients/dispatch/integration.ts +58 -3
  11. package/clients/dispatch/runners/index.ts +2 -0
  12. package/clients/dispatch/runners/semgrep.ts +269 -0
  13. package/clients/dispatch/runners/shellcheck.ts +2 -8
  14. package/clients/dispatch/runners/tree-sitter.ts +32 -11
  15. package/clients/dispatch/tool-profile.ts +1 -0
  16. package/clients/format-service.ts +10 -0
  17. package/clients/formatters.ts +22 -8
  18. package/clients/installer/index.ts +3 -3
  19. package/clients/knip-client.ts +360 -362
  20. package/clients/lsp/aggregation.ts +91 -0
  21. package/clients/lsp/client.ts +91 -38
  22. package/clients/lsp/index.ts +88 -72
  23. package/clients/lsp/launch.ts +107 -34
  24. package/clients/lsp/server-strategies.ts +71 -0
  25. package/clients/lsp/server.ts +76 -57
  26. package/clients/path-utils.ts +17 -0
  27. package/clients/pipeline.ts +23 -5
  28. package/clients/production-readiness.ts +2 -2
  29. package/clients/read-guard-logger.ts +41 -1
  30. package/clients/read-guard-tool-lines.ts +17 -4
  31. package/clients/read-guard.ts +95 -46
  32. package/clients/runtime-agent-end.ts +3 -0
  33. package/clients/runtime-session.ts +5 -0
  34. package/clients/runtime-tool-result.ts +48 -1
  35. package/clients/runtime-turn.ts +48 -4
  36. package/clients/sanitize.ts +1 -1
  37. package/clients/semgrep-config.ts +213 -0
  38. package/clients/tool-policy.ts +1982 -1936
  39. package/clients/tree-sitter-client.ts +1 -1
  40. package/clients/widget-state.ts +283 -0
  41. package/commands/booboo.ts +34 -2
  42. package/index.ts +231 -17
  43. package/package.json +3 -2
  44. package/rules/rule-catalog.json +25 -1
  45. package/rules/tree-sitter-queries/cobol/lock-table-cobol.yml +35 -0
  46. package/rules/tree-sitter-queries/cpp/unnecessary-bit-ops.yml +58 -0
  47. package/rules/tree-sitter-queries/java/infinite-loop.yml +58 -0
  48. package/rules/tree-sitter-queries/java/infinite-recursion.yml +58 -0
  49. package/rules/tree-sitter-queries/java/mockito-initialized.yml +66 -0
  50. package/rules/tree-sitter-queries/java/name-capitalization-conflict.yml +54 -0
  51. package/rules/tree-sitter-queries/java/no-octal-values.yml +48 -0
  52. package/rules/tree-sitter-queries/java/resources-closed.yml +57 -0
  53. package/rules/tree-sitter-queries/java/short-circuit-logic.yml +57 -0
  54. package/rules/tree-sitter-queries/java/tests-include-assertions.yml +60 -0
  55. package/rules/tree-sitter-queries/java/unnecessary-bit-ops-java.yml +57 -0
  56. package/rules/tree-sitter-queries/javascript/switch-case-termination-js.yml +64 -0
  57. package/rules/tree-sitter-queries/plsql/lock-table.yml +42 -0
  58. package/rules/tree-sitter-queries/plsql/nchar-nvarchar2-bytes.yml +54 -0
  59. package/rules/tree-sitter-queries/python/no-super-torchscript.yml +52 -0
  60. package/rules/tree-sitter-queries/typescript/default-not-last.yml +54 -0
  61. package/rules/tree-sitter-queries/typescript/duplicate-function-arg.yml +51 -0
  62. package/rules/tree-sitter-queries/typescript/empty-switch-case.yml +54 -0
  63. package/rules/tree-sitter-queries/typescript/infinite-loop.yml +55 -0
  64. package/rules/tree-sitter-queries/typescript/self-assignment.yml +46 -0
  65. package/rules/tree-sitter-queries/typescript/switch-case-termination.yml +64 -0
@@ -0,0 +1,91 @@
1
+ /**
2
+ * Diagnostic Aggregation Utilities for pi-lens LSP
3
+ *
4
+ * Provides result-aware racing for multi-client diagnostic collection.
5
+ * Replaces the simple Promise.race + grace window pattern with one that
6
+ * only fires the grace window when a client actually returned diagnostics.
7
+ */
8
+
9
+ /**
10
+ * Race a set of promises to completion, resolving as soon as the
11
+ * `shouldComplete` predicate is satisfied by the accumulated results.
12
+ *
13
+ * Key difference from Promise.race: Promise.race resolves when ANY promise
14
+ * settles (even with an empty/useless result). raceToCompletion only resolves
15
+ * early when results meet a quality threshold, optionally with a grace window
16
+ * to let more results accumulate.
17
+ *
18
+ * @param promises - Array of promises producing results
19
+ * @param shouldComplete - Called after each settled promise with all results
20
+ * accumulated so far. Return true to trigger early completion.
21
+ * @param options.timeoutMs - Hard deadline; after this, resolve with whatever is ready
22
+ * @param options.graceMs - After shouldComplete returns true, wait this many ms
23
+ * for additional results before finalizing. 0 = finalize immediately.
24
+ */
25
+ export async function raceToCompletion<T>(
26
+ promises: Promise<T>[],
27
+ shouldComplete: (results: T[]) => boolean,
28
+ options: { timeoutMs: number; graceMs?: number } = { timeoutMs: 1500 },
29
+ ): Promise<T[]> {
30
+ const results: (T | undefined)[] = new Array(promises.length).fill(undefined);
31
+ let graceTimer: ReturnType<typeof setTimeout> | undefined;
32
+ let completed = false;
33
+ let remaining = promises.length;
34
+
35
+ return new Promise((resolve) => {
36
+ const timeout = setTimeout(() => {
37
+ completed = true;
38
+ if (graceTimer) clearTimeout(graceTimer);
39
+ resolve(results.filter((r): r is T => r !== undefined));
40
+ }, options.timeoutMs);
41
+
42
+ const finalize = () => {
43
+ if (completed) return;
44
+ completed = true;
45
+ clearTimeout(timeout);
46
+ if (graceTimer) clearTimeout(graceTimer);
47
+ resolve(results.filter((r): r is T => r !== undefined));
48
+ };
49
+
50
+ const check = () => {
51
+ if (completed) return;
52
+
53
+ if (remaining === 0) {
54
+ finalize();
55
+ return;
56
+ }
57
+
58
+ const collected = results.filter((r): r is T => r !== undefined);
59
+ if (shouldComplete(collected)) {
60
+ if (
61
+ options.graceMs !== undefined &&
62
+ options.graceMs > 0 &&
63
+ !graceTimer
64
+ ) {
65
+ // Start grace window — more results may arrive
66
+ graceTimer = setTimeout(() => finalize(), options.graceMs);
67
+ } else {
68
+ finalize();
69
+ }
70
+ }
71
+ };
72
+
73
+ for (let i = 0; i < promises.length; i++) {
74
+ const index = i;
75
+ promises[i]
76
+ .then((result) => {
77
+ if (!completed) {
78
+ results[index] = result;
79
+ remaining--;
80
+ check();
81
+ }
82
+ })
83
+ .catch(() => {
84
+ if (!completed) {
85
+ remaining--;
86
+ check();
87
+ }
88
+ });
89
+ }
90
+ });
91
+ }
@@ -8,9 +8,9 @@
8
8
  * - Request/response handling
9
9
  */
10
10
 
11
- import { existsSync } from "node:fs";
12
11
  import { spawn as nodeSpawn } from "node:child_process";
13
12
  import { EventEmitter } from "node:events";
13
+ import { existsSync } from "node:fs";
14
14
  import { pathToFileURL } from "node:url";
15
15
  import type { MessageConnection } from "vscode-jsonrpc";
16
16
  import {
@@ -21,6 +21,7 @@ import {
21
21
 
22
22
  import type { LSPProcess } from "./launch.js";
23
23
  import { normalizeMapKey, uriToPath } from "./path-utils.js";
24
+ import { getStrategy } from "./server-strategies.js";
24
25
 
25
26
  // --- Types ---
26
27
 
@@ -228,10 +229,6 @@ export interface LSPClientInfo {
228
229
 
229
230
  // --- Constants ---
230
231
 
231
- const DIAGNOSTICS_DEBOUNCE_MS = positiveIntFromEnv(
232
- "PI_LENS_LSP_DIAGNOSTICS_DEBOUNCE_MS",
233
- 150,
234
- ); // ms — waits for follow-up semantic diagnostics
235
232
  const INITIALIZE_TIMEOUT_MS = positiveIntFromEnv(
236
233
  "PI_LENS_LSP_INIT_TIMEOUT_MS",
237
234
  15_000,
@@ -244,14 +241,14 @@ const DIAGNOSTICS_WAIT_TIMEOUT_MS = positiveIntFromEnv(
244
241
  "PI_LENS_LSP_DIAGNOSTICS_WAIT_MS",
245
242
  10_000,
246
243
  );
247
- const PULL_DIAGNOSTICS_RETRY_BUDGET_MS = positiveIntFromEnv(
248
- "PI_LENS_LSP_PULL_RETRY_BUDGET_MS",
249
- 1200,
250
- );
251
244
  const PULL_DIAGNOSTICS_RETRY_INTERVAL_MS = positiveIntFromEnv(
252
245
  "PI_LENS_LSP_PULL_RETRY_INTERVAL_MS",
253
246
  250,
254
247
  );
248
+ const SHUTDOWN_REQUEST_TIMEOUT_MS = positiveIntFromEnv(
249
+ "PI_LENS_LSP_SHUTDOWN_TIMEOUT_MS",
250
+ 1000,
251
+ );
255
252
 
256
253
  const LSP_CRASH_CODES = new Set([
257
254
  "ERR_STREAM_DESTROYED",
@@ -340,6 +337,33 @@ function disposeClientConnection(state: LSPClientState): void {
340
337
  }
341
338
  }
342
339
 
340
+ async function killProcessTree(
341
+ proc: { kill(signal?: NodeJS.Signals | number): boolean },
342
+ pid: number,
343
+ ): Promise<void> {
344
+ if (process.platform === "win32" && pid > 0) {
345
+ await new Promise<void>((resolve) => {
346
+ try {
347
+ // Absolute path avoids PATH-resolution: SystemRoot is set by Windows itself.
348
+ const taskkill = `${process.env.SystemRoot ?? "C:\\Windows"}\\System32\\taskkill.exe`;
349
+ const killer = nodeSpawn(taskkill, ["/F", "/T", "/PID", String(pid)], {
350
+ shell: false,
351
+ windowsHide: true,
352
+ });
353
+ killer.once("close", () => resolve());
354
+ killer.once("error", () => resolve());
355
+ } catch {
356
+ resolve();
357
+ }
358
+ });
359
+ return;
360
+ }
361
+
362
+ try {
363
+ proc.kill("SIGTERM");
364
+ } catch {}
365
+ }
366
+
343
367
  function mergeDiagnosticLists(
344
368
  push: LSPDiagnostic[] | undefined,
345
369
  pull: LSPDiagnostic[] | undefined,
@@ -450,6 +474,19 @@ function setupIncomingHandlers(
450
474
  const filePath = uriToPath(params.uri);
451
475
  const normalizedPath = normalizeMapKey(filePath);
452
476
  const newDiags: LSPDiagnostic[] = params.diagnostics || [];
477
+ const strategy = getStrategy(state.serverId);
478
+
479
+ // Seed on first push for servers whose first push is known complete.
480
+ // Bypasses the debounce timer entirely — resolves waiting promises immediately.
481
+ if (
482
+ strategy.seedFirstPush &&
483
+ !state.pushDiagnostics.has(normalizedPath)
484
+ ) {
485
+ state.pushDiagnostics.set(normalizedPath, newDiags);
486
+ state.pushDiagnosticTimestamps.set(normalizedPath, Date.now());
487
+ state.diagnosticEmitter.emit("diagnostics", normalizedPath);
488
+ return;
489
+ }
453
490
 
454
491
  const existingTimer = state.pendingDiagnostics.get(normalizedPath);
455
492
  if (existingTimer) clearTimeout(existingTimer);
@@ -459,7 +496,7 @@ function setupIncomingHandlers(
459
496
  state.pushDiagnosticTimestamps.set(normalizedPath, Date.now());
460
497
  state.pendingDiagnostics.delete(normalizedPath);
461
498
  state.diagnosticEmitter.emit("diagnostics", normalizedPath);
462
- }, DIAGNOSTICS_DEBOUNCE_MS);
499
+ }, strategy.debounceMs);
463
500
 
464
501
  state.pendingDiagnostics.set(normalizedPath, timer);
465
502
  },
@@ -483,9 +520,7 @@ function setupIncomingHandlers(
483
520
  );
484
521
  state.connection.onRequest(
485
522
  "client/unregisterCapability",
486
- async (params: {
487
- unregisterations?: Array<{ id: string }>;
488
- }) => {
523
+ async (params: { unregisterations?: Array<{ id: string }> }) => {
489
524
  for (const unreg of params?.unregisterations ?? []) {
490
525
  if (unreg.id) {
491
526
  state.dynamicRegistrations.delete(unreg.id);
@@ -579,7 +614,11 @@ export async function clientWaitForDiagnostics(
579
614
  const firstPullCount = await clientRequestPullDiagnostics(state, filePath);
580
615
  if (firstPullCount > 0) return;
581
616
 
582
- const retryBudgetMs = Math.min(timeoutMs, PULL_DIAGNOSTICS_RETRY_BUDGET_MS);
617
+ const strategy = getStrategy(state.serverId);
618
+ const retryBudgetMs =
619
+ strategy.pullRetryBudgetMs > 0
620
+ ? Math.min(timeoutMs, strategy.pullRetryBudgetMs)
621
+ : 0;
583
622
  const startedAt = Date.now();
584
623
  let latestCount = firstPullCount;
585
624
 
@@ -600,11 +639,19 @@ export async function clientWaitForDiagnostics(
600
639
  const onDiagnostics = (fp: string) => {
601
640
  if (normalizeMapKey(fp) !== normalizedPath) return;
602
641
  if (debounceTimer) clearTimeout(debounceTimer);
642
+
643
+ // Adaptive debounce: use time since last push to compute remaining
644
+ // wait instead of always waiting the full debounce window.
645
+ const strategy = getStrategy(state.serverId);
646
+ const hit = state.pushDiagnosticTimestamps.get(normalizedPath);
647
+ const timeSincePush = hit ? Date.now() - hit : Infinity;
648
+ const remaining = Math.max(0, strategy.debounceMs - timeSincePush);
649
+
603
650
  debounceTimer = setTimeout(() => {
604
651
  state.diagnosticEmitter.off("diagnostics", onDiagnostics);
605
652
  clearTimeout(timeout);
606
653
  resolve();
607
- }, DIAGNOSTICS_DEBOUNCE_MS);
654
+ }, remaining);
608
655
  };
609
656
 
610
657
  state.diagnosticEmitter.on("diagnostics", onDiagnostics);
@@ -715,9 +762,12 @@ async function clientShutdown(state: LSPClientState): Promise<void> {
715
762
  state.openDocuments.clear();
716
763
  state.diagnosticEmitter.removeAllListeners();
717
764
  try {
718
- await safeSendRequest(state.connection, "shutdown", {});
765
+ await withTimeout(
766
+ safeSendRequest(state.connection, "shutdown", {}),
767
+ SHUTDOWN_REQUEST_TIMEOUT_MS,
768
+ );
719
769
  } catch {
720
- /* ignore */
770
+ /* ignore — proceed to exit/kill so shutdown cannot hang the session */
721
771
  }
722
772
  try {
723
773
  await safeSendNotification(state.connection, "exit", {});
@@ -725,7 +775,10 @@ async function clientShutdown(state: LSPClientState): Promise<void> {
725
775
  /* ignore */
726
776
  }
727
777
  disposeClientConnection(state);
728
- state.lspProcess.process.kill();
778
+ const pid = state.lspProcess.pid;
779
+ // On Windows, killing the direct child first can orphan grandchildren before
780
+ // taskkill can traverse the tree. Kill the full tree first and wait briefly.
781
+ await killProcessTree(state.lspProcess.process, pid);
729
782
  }
730
783
 
731
784
  async function navRequest<T>(
@@ -902,17 +955,11 @@ export async function createLSPClient(options: {
902
955
  // Hard-kill the hung process so it doesn't become a zombie.
903
956
  // SIGTERM alone is unreliable on Windows for cmd.exe/PowerShell trees.
904
957
  const pid = lspProcess.pid;
905
- lspProcess.process.kill("SIGTERM");
906
- if (process.platform === "win32" && pid > 0) {
907
- try {
908
- nodeSpawn("taskkill", ["/F", "/T", "/PID", String(pid)], {
909
- shell: false,
910
- windowsHide: true,
911
- });
912
- } catch {}
913
- }
958
+ void killProcessTree(lspProcess.process, pid);
914
959
  setTimeout(() => {
915
- if (!lspProcess.process.killed) lspProcess.process.kill("SIGKILL");
960
+ if (!lspProcess.process.killed && process.platform !== "win32") {
961
+ lspProcess.process.kill("SIGKILL");
962
+ }
916
963
  }, 2000);
917
964
  throw err;
918
965
  } finally {
@@ -940,7 +987,8 @@ export async function createLSPClient(options: {
940
987
  );
941
988
  }
942
989
 
943
- state.workspaceDiagnosticsSupport = detectWorkspaceDiagnosticsSupport(initResult);
990
+ state.workspaceDiagnosticsSupport =
991
+ detectWorkspaceDiagnosticsSupport(initResult);
944
992
  state.operationSupport = detectOperationSupport(initResult);
945
993
  state.staticDiagnosticsMode = state.workspaceDiagnosticsSupport.mode;
946
994
 
@@ -1247,19 +1295,24 @@ async function withTimeout<T>(
1247
1295
  promise: Promise<T>,
1248
1296
  timeoutMs: number,
1249
1297
  ): Promise<T> {
1298
+ let timeout: ReturnType<typeof setTimeout> | undefined;
1250
1299
  // Suppress unhandled rejection if `promise` rejects AFTER the timeout
1251
1300
  // wins the race — Promise.race settles on the first result but the
1252
1301
  // losing promises still run, and any later rejection would be uncaught.
1253
1302
  promise.catch(() => {});
1254
- return Promise.race([
1255
- promise,
1256
- new Promise<T>((_, reject) =>
1257
- setTimeout(
1258
- () => reject(new Error(`Timeout after ${timeoutMs}ms`)),
1259
- timeoutMs,
1260
- ),
1261
- ),
1262
- ]);
1303
+ try {
1304
+ return await Promise.race([
1305
+ promise,
1306
+ new Promise<T>((_, reject) => {
1307
+ timeout = setTimeout(
1308
+ () => reject(new Error(`Timeout after ${timeoutMs}ms`)),
1309
+ timeoutMs,
1310
+ );
1311
+ }),
1312
+ ]);
1313
+ } finally {
1314
+ if (timeout) clearTimeout(timeout);
1315
+ }
1263
1316
  }
1264
1317
 
1265
1318
  function positiveIntFromEnv(name: string, fallback: number): number {
@@ -12,6 +12,7 @@ import * as nodeFs from "node:fs";
12
12
  import fs from "node:fs/promises";
13
13
  import os from "node:os";
14
14
  import path from "node:path";
15
+ import { recordLsp } from "../widget-state.js";
15
16
  import { logLatency } from "../latency-logger.js";
16
17
  import { normalizeMapKey, uriToPath } from "../path-utils.js";
17
18
  import type { LSPClientInfo } from "./client.js";
@@ -19,6 +20,8 @@ import { createLSPClient } from "./client.js";
19
20
  import { getServersForFileWithConfig } from "./config.js";
20
21
  import { getLanguageId } from "./language.js";
21
22
  import type { LSPServerInfo } from "./server.js";
23
+ import { getStrategy } from "./server-strategies.js";
24
+ import { raceToCompletion } from "./aggregation.js";
22
25
 
23
26
  // --- Types ---
24
27
 
@@ -44,13 +47,6 @@ const TOUCH_DEBOUNCE_MS = Math.max(
44
47
  Number.parseInt(process.env.PI_LENS_LSP_TOUCH_DEBOUNCE_MS ?? "1500", 10) ||
45
48
  1500,
46
49
  );
47
- const DIAGNOSTICS_AGGREGATE_WAIT_MS = Math.max(
48
- 0,
49
- Number.parseInt(
50
- process.env.PI_LENS_LSP_DIAGNOSTICS_AGGREGATE_WAIT_MS ?? "1500",
51
- 10,
52
- ) || 1500,
53
- );
54
50
  const DIAGNOSTICS_SEMANTIC_SETTLE_THRESHOLD_MS = Math.max(
55
51
  0,
56
52
  Number.parseInt(
@@ -197,11 +193,22 @@ export class LSPService {
197
193
  clientScope: "primary" | "all",
198
194
  ): void {
199
195
  const key = `${normalizeMapKey(filePath)}:${clientScope}`;
196
+ const now = Date.now();
200
197
  this.recentTouches.set(key, {
201
198
  fingerprint: this.fingerprintContent(content),
202
- touchedAt: Date.now(),
199
+ touchedAt: now,
203
200
  clientScope,
204
201
  });
202
+ // Trim entries that are already past the debounce window — shouldSkipTouch
203
+ // ignores them anyway, so they serve no purpose. Only sweep when the map
204
+ // exceeds the threshold to avoid iterating on every call.
205
+ if (this.recentTouches.size > 200) {
206
+ for (const [k, v] of this.recentTouches) {
207
+ if (now - v.touchedAt > TOUCH_DEBOUNCE_MS) {
208
+ this.recentTouches.delete(k);
209
+ }
210
+ }
211
+ }
205
212
  }
206
213
 
207
214
  /**
@@ -326,7 +333,7 @@ export class LSPService {
326
333
 
327
334
  const normalizedRoot = normalizeMapKey(root);
328
335
  const key = `${server.id}:${normalizedRoot}`;
329
- const isOptionalServer = OPTIONAL_LSP_SERVER_IDS.has(server.id);
336
+ const isOptionalServer = OPTIONAL_LSP_SERVER_IDS.has(server.id); // NOSONAR: set intentionally empty — no optional servers configured yet
330
337
 
331
338
  if (isOptionalServer && this.optionalDisabled.has(key)) {
332
339
  return undefined;
@@ -406,17 +413,19 @@ export class LSPService {
406
413
  filePath: string,
407
414
  allowInstall: boolean,
408
415
  ): Promise<SpawnedServer | undefined> {
409
- const isOptionalServer = OPTIONAL_LSP_SERVER_IDS.has(server.id);
416
+ const isOptionalServer = OPTIONAL_LSP_SERVER_IDS.has(server.id); // NOSONAR: set intentionally empty — no optional servers configured yet
410
417
  const startedAt = Date.now();
411
418
  logSessionStart(
412
419
  `lsp spawn ${server.id}: start root=${root} install=${allowInstall ? "enabled" : "disabled"} file=${filePath}`,
413
420
  );
421
+ recordLsp(server.id, root, "spawn_start");
414
422
  try {
415
423
  const spawned = await server.spawn(root, { allowInstall });
416
424
  if (!spawned) {
417
425
  logSessionStart(
418
426
  `lsp spawn ${server.id}: unavailable (${Date.now() - startedAt}ms)`,
419
427
  );
428
+ recordLsp(server.id, root, "spawn_failed", Date.now() - startedAt);
420
429
  const uCount = (this.failureCounts.get(key) ?? 0) + 1;
421
430
  this.failureCounts.set(key, uCount);
422
431
  const uCooldown = Math.min(
@@ -457,6 +466,7 @@ export class LSPService {
457
466
  logSessionStart(
458
467
  `lsp spawn ${server.id}: success source=${spawned.source ?? "unknown"} (${Date.now() - startedAt}ms)`,
459
468
  );
469
+ recordLsp(server.id, root, "spawn_success", Date.now() - startedAt);
460
470
  if (!this.workspaceProbeLogged.has(key)) {
461
471
  logSessionStart(
462
472
  `lsp workspace-diag probe ${server.id}: advertised=${wsDiag.advertised} mode=${wsDiag.mode} provider=${wsDiag.diagnosticProviderKind}`,
@@ -465,6 +475,7 @@ export class LSPService {
465
475
  }
466
476
  return { client, info: server };
467
477
  } catch (err) {
478
+ recordLsp(server.id, root, "spawn_failed", Date.now() - startedAt);
468
479
  if (!isOptionalServer || !this.optionalFailureLogged.has(key)) {
469
480
  logSessionStart(
470
481
  `lsp spawn ${server.id}: failed (${Date.now() - startedAt}ms) error=${err instanceof Error ? err.message : String(err)}`,
@@ -651,6 +662,7 @@ export class LSPService {
651
662
  */
652
663
  async getDiagnostics(
653
664
  filePath: string,
665
+ diagnosticsMode: LSPDiagnosticsMode = "full",
654
666
  ): Promise<import("./client.js").LSPDiagnostic[]> {
655
667
  if (this.checkDestroyed()) return [];
656
668
  const startedAt = Date.now();
@@ -675,82 +687,85 @@ export class LSPService {
675
687
  return [];
676
688
  }
677
689
 
678
- // TypeScript LSP often pushes syntactic diagnostics first (sometimes empty)
679
- // and semantic diagnostics shortly after. Keep the aggregate wait short for
680
- // interactive edits, then do one brief settle pass only when the first
681
- // response was empty and arrived quickly.
682
- //
683
- // Early-unblock: each client writes into pendingResults as it finishes. The
684
- // outer race exits as soon as all clients are done OR the first client finishes
685
- // and the grace window elapses, whichever is sooner. Remaining slots are left
686
- // undefined and filled with zero-diagnostic defaults before merging.
690
+ // Per-server entries produced by client waits. Each promise resolves
691
+ // with a PerServerEntry; raceToCompletion collects them as they finish.
687
692
  type PerServerEntry = {
688
693
  serverId: string;
689
694
  waitMs: number;
690
695
  diagnosticCount: number;
691
696
  diagnostics: import("./client.js").LSPDiagnostic[];
692
697
  };
693
- const pendingResults: (PerServerEntry | undefined)[] = new Array(
694
- spawned.length,
695
- ).fill(undefined);
696
698
 
697
- const clientWaits = spawned.map(async (entry, index) => {
698
- const waitStart = Date.now();
699
- await entry.client.waitForDiagnostics(
700
- filePath,
701
- DIAGNOSTICS_AGGREGATE_WAIT_MS,
702
- );
703
- let diagnostics = entry.client.getDiagnostics(filePath);
704
- const firstWaitMs = Date.now() - waitStart;
705
- if (
706
- diagnostics.length === 0 &&
707
- firstWaitMs < DIAGNOSTICS_SEMANTIC_SETTLE_THRESHOLD_MS
708
- ) {
699
+ const clientWaits: Promise<PerServerEntry>[] = spawned.map(
700
+ async (entry) => {
701
+ const waitStart = Date.now();
702
+ const strategy = getStrategy(entry.info.id);
709
703
  await entry.client.waitForDiagnostics(
710
704
  filePath,
711
- DIAGNOSTICS_SEMANTIC_SETTLE_WAIT_MS,
705
+ strategy.aggregateWaitMs,
712
706
  );
713
- diagnostics = entry.client.getDiagnostics(filePath);
714
- }
715
- pendingResults[index] = {
716
- serverId: entry.info.id,
717
- waitMs: Date.now() - waitStart,
718
- diagnosticCount: diagnostics.length,
719
- diagnostics,
720
- };
721
- return pendingResults[index]!;
722
- });
707
+ let diagnostics = entry.client.getDiagnostics(filePath);
708
+ const firstWaitMs = Date.now() - waitStart;
709
+ if (
710
+ strategy.expectSemanticSecondPush &&
711
+ diagnostics.length === 0 &&
712
+ firstWaitMs < DIAGNOSTICS_SEMANTIC_SETTLE_THRESHOLD_MS
713
+ ) {
714
+ await entry.client.waitForDiagnostics(
715
+ filePath,
716
+ DIAGNOSTICS_SEMANTIC_SETTLE_WAIT_MS,
717
+ );
718
+ diagnostics = entry.client.getDiagnostics(filePath);
719
+ }
720
+ return {
721
+ serverId: entry.info.id,
722
+ waitMs: Date.now() - waitStart,
723
+ diagnosticCount: diagnostics.length,
724
+ diagnostics,
725
+ };
726
+ },
727
+ );
723
728
 
724
- if (EARLY_UNBLOCK_GRACE_MS > 0 && spawned.length > 1) {
725
- await Promise.race([
726
- Promise.all(clientWaits),
727
- Promise.race(clientWaits).then(
728
- () =>
729
- new Promise<void>((resolve) =>
730
- setTimeout(resolve, EARLY_UNBLOCK_GRACE_MS),
731
- ),
729
+ // Document mode: 0ms grace return as soon as any client has results.
730
+ // Full mode: 400ms grace — wait a bit for other clients to catch up.
731
+ const graceMs = diagnosticsMode === "document" ? 0 : EARLY_UNBLOCK_GRACE_MS;
732
+
733
+ // Result-aware racing: trigger early-unblock when any client has results,
734
+ // OR when a seedFirstPush server returns (its first push is authoritative
735
+ // even when empty — waiting longer yields nothing more).
736
+ const perServer = await raceToCompletion(
737
+ clientWaits,
738
+ (results) =>
739
+ results.some(
740
+ (r) => r.diagnosticCount > 0 || getStrategy(r.serverId).seedFirstPush,
732
741
  ),
733
- ]);
734
- } else {
735
- await Promise.all(clientWaits);
736
- }
742
+ {
743
+ timeoutMs: Math.max(
744
+ ...spawned.map((entry) => getStrategy(entry.info.id).aggregateWaitMs),
745
+ ),
746
+ graceMs,
747
+ },
748
+ );
737
749
 
738
- const earlyUnblockedCount = pendingResults.filter(
739
- (r) => r === undefined,
740
- ).length;
741
- const perServer: PerServerEntry[] = pendingResults.map(
742
- (r, i) =>
743
- r ?? {
744
- serverId: spawned[i].info.id,
745
- waitMs: DIAGNOSTICS_AGGREGATE_WAIT_MS,
750
+ // Fill in any slots that timed out before producing results.
751
+ const earlyUnblockedCount = spawned.length - perServer.length;
752
+ const perServerFull: PerServerEntry[] = spawned.map((entry) => {
753
+ const found = perServer.find((r) => r.serverId === entry.info.id);
754
+ return (
755
+ found ?? {
756
+ serverId: entry.info.id,
757
+ waitMs: getStrategy(entry.info.id).aggregateWaitMs,
746
758
  diagnosticCount: 0,
747
759
  diagnostics: [],
748
- },
749
- );
760
+ }
761
+ );
762
+ });
763
+
764
+ // Deduplicate across servers (same diagnostic reported by multiple tools).
750
765
 
751
766
  const merged: import("./client.js").LSPDiagnostic[] = [];
752
767
  const seen = new Set<string>();
753
- for (const entry of perServer) {
768
+ for (const entry of perServerFull) {
754
769
  for (const diagnostic of entry.diagnostics) {
755
770
  const key = [
756
771
  diagnostic.range.start.line,
@@ -763,11 +778,11 @@ export class LSPService {
763
778
  }
764
779
  }
765
780
 
766
- const rawCount = perServer.reduce(
781
+ const rawCount = perServerFull.reduce(
767
782
  (sum, entry) => sum + entry.diagnosticCount,
768
783
  0,
769
784
  );
770
- const serversWithDiagnostics = perServer.filter(
785
+ const serversWithDiagnostics = perServerFull.filter(
771
786
  (entry) => entry.diagnosticCount > 0,
772
787
  ).length;
773
788
  const failureKind = merged.length === 0 ? "ok_empty" : "success";
@@ -779,14 +794,15 @@ export class LSPService {
779
794
  durationMs: Date.now() - startedAt,
780
795
  metadata: {
781
796
  serverCountAttempted: getServersForFileWithConfig(filePath).length,
782
- serverCountReady: perServer.length,
797
+ serverCountReady: perServerFull.length,
783
798
  serverCountWithDiagnostics: serversWithDiagnostics,
784
799
  mergedCount: merged.length,
785
800
  dedupDroppedCount: rawCount - merged.length,
786
801
  earlyUnblockedCount,
802
+ diagnosticsMode,
787
803
  failureKind,
788
804
  health: failureKind === "success" ? "ok" : "ok_empty",
789
- servers: perServer.map((entry) => ({
805
+ servers: perServerFull.map((entry) => ({
790
806
  id: entry.serverId,
791
807
  waitMs: entry.waitMs,
792
808
  diagnosticCount: entry.diagnosticCount,