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.
- package/CHANGELOG.md +84 -5
- package/README.md +37 -1
- package/clients/biome-client.ts +5 -4
- package/clients/cache/rule-cache.ts +1 -1
- package/clients/complexity-client.ts +1 -1
- package/clients/dependency-checker.ts +1 -1
- package/clients/dispatch/diagnostic-taxonomy.ts +13 -1
- package/clients/dispatch/dispatcher.ts +9 -0
- package/clients/dispatch/fact-scheduler.ts +1 -1
- package/clients/dispatch/integration.ts +58 -3
- package/clients/dispatch/runners/index.ts +2 -0
- package/clients/dispatch/runners/semgrep.ts +269 -0
- package/clients/dispatch/runners/shellcheck.ts +2 -8
- package/clients/dispatch/runners/tree-sitter.ts +32 -11
- package/clients/dispatch/tool-profile.ts +1 -0
- package/clients/format-service.ts +10 -0
- package/clients/formatters.ts +22 -8
- package/clients/installer/index.ts +3 -3
- package/clients/knip-client.ts +360 -362
- package/clients/lsp/aggregation.ts +91 -0
- package/clients/lsp/client.ts +91 -38
- package/clients/lsp/index.ts +88 -72
- package/clients/lsp/launch.ts +107 -34
- package/clients/lsp/server-strategies.ts +71 -0
- package/clients/lsp/server.ts +76 -57
- package/clients/path-utils.ts +17 -0
- package/clients/pipeline.ts +23 -5
- package/clients/production-readiness.ts +2 -2
- package/clients/read-guard-logger.ts +41 -1
- package/clients/read-guard-tool-lines.ts +17 -4
- package/clients/read-guard.ts +95 -46
- package/clients/runtime-agent-end.ts +3 -0
- package/clients/runtime-session.ts +5 -0
- package/clients/runtime-tool-result.ts +48 -1
- package/clients/runtime-turn.ts +48 -4
- package/clients/sanitize.ts +1 -1
- package/clients/semgrep-config.ts +213 -0
- package/clients/tool-policy.ts +1982 -1936
- package/clients/tree-sitter-client.ts +1 -1
- package/clients/widget-state.ts +283 -0
- package/commands/booboo.ts +34 -2
- package/index.ts +231 -17
- package/package.json +3 -2
- package/rules/rule-catalog.json +25 -1
- package/rules/tree-sitter-queries/cobol/lock-table-cobol.yml +35 -0
- package/rules/tree-sitter-queries/cpp/unnecessary-bit-ops.yml +58 -0
- package/rules/tree-sitter-queries/java/infinite-loop.yml +58 -0
- package/rules/tree-sitter-queries/java/infinite-recursion.yml +58 -0
- package/rules/tree-sitter-queries/java/mockito-initialized.yml +66 -0
- package/rules/tree-sitter-queries/java/name-capitalization-conflict.yml +54 -0
- package/rules/tree-sitter-queries/java/no-octal-values.yml +48 -0
- package/rules/tree-sitter-queries/java/resources-closed.yml +57 -0
- package/rules/tree-sitter-queries/java/short-circuit-logic.yml +57 -0
- package/rules/tree-sitter-queries/java/tests-include-assertions.yml +60 -0
- package/rules/tree-sitter-queries/java/unnecessary-bit-ops-java.yml +57 -0
- package/rules/tree-sitter-queries/javascript/switch-case-termination-js.yml +64 -0
- package/rules/tree-sitter-queries/plsql/lock-table.yml +42 -0
- package/rules/tree-sitter-queries/plsql/nchar-nvarchar2-bytes.yml +54 -0
- package/rules/tree-sitter-queries/python/no-super-torchscript.yml +52 -0
- package/rules/tree-sitter-queries/typescript/default-not-last.yml +54 -0
- package/rules/tree-sitter-queries/typescript/duplicate-function-arg.yml +51 -0
- package/rules/tree-sitter-queries/typescript/empty-switch-case.yml +54 -0
- package/rules/tree-sitter-queries/typescript/infinite-loop.yml +55 -0
- package/rules/tree-sitter-queries/typescript/self-assignment.yml +46 -0
- 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
|
+
}
|
package/clients/lsp/client.ts
CHANGED
|
@@ -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
|
-
},
|
|
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
|
|
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
|
-
},
|
|
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
|
|
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.
|
|
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
|
|
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
|
|
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 =
|
|
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
|
-
|
|
1255
|
-
|
|
1256
|
-
|
|
1257
|
-
|
|
1258
|
-
|
|
1259
|
-
|
|
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 {
|
package/clients/lsp/index.ts
CHANGED
|
@@ -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:
|
|
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
|
-
//
|
|
679
|
-
//
|
|
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(
|
|
698
|
-
|
|
699
|
-
|
|
700
|
-
|
|
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
|
-
|
|
705
|
+
strategy.aggregateWaitMs,
|
|
712
706
|
);
|
|
713
|
-
diagnostics = entry.client.getDiagnostics(filePath);
|
|
714
|
-
|
|
715
|
-
|
|
716
|
-
|
|
717
|
-
|
|
718
|
-
|
|
719
|
-
|
|
720
|
-
|
|
721
|
-
|
|
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
|
-
|
|
725
|
-
|
|
726
|
-
|
|
727
|
-
|
|
728
|
-
|
|
729
|
-
|
|
730
|
-
|
|
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
|
-
|
|
735
|
-
|
|
736
|
-
|
|
742
|
+
{
|
|
743
|
+
timeoutMs: Math.max(
|
|
744
|
+
...spawned.map((entry) => getStrategy(entry.info.id).aggregateWaitMs),
|
|
745
|
+
),
|
|
746
|
+
graceMs,
|
|
747
|
+
},
|
|
748
|
+
);
|
|
737
749
|
|
|
738
|
-
|
|
739
|
-
|
|
740
|
-
)
|
|
741
|
-
|
|
742
|
-
(
|
|
743
|
-
|
|
744
|
-
serverId:
|
|
745
|
-
waitMs:
|
|
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
|
|
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 =
|
|
781
|
+
const rawCount = perServerFull.reduce(
|
|
767
782
|
(sum, entry) => sum + entry.diagnosticCount,
|
|
768
783
|
0,
|
|
769
784
|
);
|
|
770
|
-
const serversWithDiagnostics =
|
|
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:
|
|
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:
|
|
805
|
+
servers: perServerFull.map((entry) => ({
|
|
790
806
|
id: entry.serverId,
|
|
791
807
|
waitMs: entry.waitMs,
|
|
792
808
|
diagnosticCount: entry.diagnosticCount,
|