pi-lens 3.8.62 → 3.8.64

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 (57) hide show
  1. package/CHANGELOG.md +37 -0
  2. package/README.md +12 -9
  3. package/dist/clients/deadline-utils.js +93 -0
  4. package/dist/clients/installer/index.js +50 -35
  5. package/dist/clients/log-cleanup.js +36 -13
  6. package/dist/clients/lsp/client.js +156 -61
  7. package/dist/clients/lsp/index.js +218 -33
  8. package/dist/clients/lsp/interactive-install.js +14 -4
  9. package/dist/clients/lsp/launch.js +31 -102
  10. package/dist/clients/lsp/server.js +29 -22
  11. package/dist/clients/mcp/review.js +7 -4
  12. package/dist/clients/module-report-lsp.js +1 -16
  13. package/dist/clients/package-manager.js +223 -0
  14. package/dist/clients/pipeline.js +113 -39
  15. package/dist/clients/project-metadata.js +3 -23
  16. package/dist/clients/read-expansion.js +1 -14
  17. package/dist/clients/safe-spawn.js +9 -0
  18. package/dist/clients/tree-sitter-client.js +72 -14
  19. package/dist/index.js +16 -0
  20. package/dist/mcp/server.js +4 -2
  21. package/dist/tools/ast-grep-search.js +10 -6
  22. package/dist/tools/lens-diagnostics.js +43 -10
  23. package/dist/tools/lsp-diagnostics.js +23 -4
  24. package/dist/tools/scan-progress.js +38 -0
  25. package/grammars/tree-sitter-bash.wasm +0 -0
  26. package/grammars/tree-sitter-bash.wasm.json +5 -0
  27. package/grammars/tree-sitter-css.wasm +0 -0
  28. package/grammars/tree-sitter-css.wasm.json +5 -0
  29. package/grammars/tree-sitter-go.wasm +0 -0
  30. package/grammars/tree-sitter-go.wasm.json +5 -0
  31. package/grammars/tree-sitter-html.wasm +0 -0
  32. package/grammars/tree-sitter-html.wasm.json +5 -0
  33. package/grammars/tree-sitter-java.wasm +0 -0
  34. package/grammars/tree-sitter-java.wasm.json +5 -0
  35. package/grammars/tree-sitter-javascript.wasm +0 -0
  36. package/grammars/tree-sitter-javascript.wasm.json +5 -0
  37. package/grammars/tree-sitter-json.wasm +0 -0
  38. package/grammars/tree-sitter-json.wasm.json +5 -0
  39. package/grammars/tree-sitter-python.wasm +0 -0
  40. package/grammars/tree-sitter-python.wasm.json +5 -0
  41. package/grammars/tree-sitter-rust.wasm +0 -0
  42. package/grammars/tree-sitter-rust.wasm.json +5 -0
  43. package/grammars/tree-sitter-tsx.wasm +0 -0
  44. package/grammars/tree-sitter-tsx.wasm.json +5 -0
  45. package/grammars/tree-sitter-typescript.wasm +0 -0
  46. package/grammars/tree-sitter-typescript.wasm.json +5 -0
  47. package/grammars/tree-sitter-yaml.wasm +0 -0
  48. package/grammars/tree-sitter-yaml.wasm.json +5 -0
  49. package/package.json +7 -5
  50. package/rules/ast-grep-rules/rule-tests/incomplete-string-escaping-js-test.yml +21 -0
  51. package/rules/ast-grep-rules/rule-tests/incomplete-string-escaping-test.yml +21 -0
  52. package/rules/ast-grep-rules/rules/incomplete-string-escaping-js.yml +19 -0
  53. package/rules/ast-grep-rules/rules/incomplete-string-escaping.yml +19 -0
  54. package/scripts/analyze-pi-lens-logs.mjs +101 -3
  55. package/scripts/download-grammars.js +171 -30
  56. package/scripts/grammars.lock.json +32 -0
  57. package/skills/write-ast-grep-rule/SKILL.md +36 -26
@@ -11,6 +11,7 @@ import { spawn as nodeSpawn } from "node:child_process";
11
11
  import { EventEmitter } from "node:events";
12
12
  import { access, readFile } from "node:fs/promises";
13
13
  import { pathToFileURL } from "node:url";
14
+ import { withTimeout } from "../deadline-utils.js";
14
15
  import { logLatency } from "../latency-logger.js";
15
16
  // vscode-jsonrpc v9 ships an `exports` map exposing the Node entry as the
16
17
  // `./node` subpath (no `.js`); the old `/node.js` file path no longer resolves.
@@ -76,7 +77,20 @@ export const CLIENT_CAPABILITIES = {
76
77
  const NAV_REQUEST_TIMEOUT_MS = positiveIntFromEnv("PI_LENS_LSP_NAV_REQUEST_TIMEOUT_MS", 10_000); // 10s — per-request ceiling; prevents heavy servers (vue, svelte) from hanging
77
78
  const DIAGNOSTICS_WAIT_TIMEOUT_MS = positiveIntFromEnv("PI_LENS_LSP_DIAGNOSTICS_WAIT_MS", 10_000);
78
79
  const PULL_DIAGNOSTICS_RETRY_INTERVAL_MS = positiveIntFromEnv("PI_LENS_LSP_PULL_RETRY_INTERVAL_MS", 250);
80
+ // Per-request ceiling for pull diagnostics (textDocument/diagnostic), mirroring
81
+ // NAV_REQUEST_TIMEOUT_MS. safeSendRequest only settles on a reply or a *destroyed*
82
+ // stream, so a pull-mode server that is alive but hung (accepts the request, never
83
+ // replies) would await forever — hanging clientWaitForDiagnostics and, upstream,
84
+ // the diagnostics flush. On timeout the request is treated as `unavailable`, which
85
+ // (per #240) is NOT read as clean and falls through to the bounded push backstop.
86
+ const PULL_REQUEST_TIMEOUT_MS = positiveIntFromEnv("PI_LENS_LSP_PULL_REQUEST_TIMEOUT_MS", 10_000);
79
87
  const SHUTDOWN_REQUEST_TIMEOUT_MS = positiveIntFromEnv("PI_LENS_LSP_SHUTDOWN_TIMEOUT_MS", 1000);
88
+ // Anti-deadlock backstop for workspace/executeCommand. Deliberately generous
89
+ // (30s): the command is mutating and legitimately long-running (a real server
90
+ // refactor / organize-imports), so this must not truncate valid work — it only
91
+ // stops a hung server from blocking the caller forever. On timeout the command
92
+ // may still be applying server-side; we surface that rather than pretend it ran.
93
+ const EXECUTE_COMMAND_TIMEOUT_MS = positiveIntFromEnv("PI_LENS_LSP_EXECUTE_COMMAND_TIMEOUT_MS", 30_000);
80
94
  const LSP_CRASH_CODES = new Set([
81
95
  "ERR_STREAM_DESTROYED",
82
96
  "ERR_STREAM_WRITE_AFTER_END",
@@ -179,18 +193,36 @@ export async function killProcessTree(proc, pid, options = {}) {
179
193
  }
180
194
  return;
181
195
  }
196
+ const killPosixProcessGroup = (signal) => {
197
+ if (pid <= 0)
198
+ return false;
199
+ try {
200
+ process.kill(-pid, signal);
201
+ return true;
202
+ }
203
+ catch {
204
+ return false;
205
+ }
206
+ };
207
+ const killDirectChild = (signal) => {
208
+ try {
209
+ proc.kill(signal);
210
+ }
211
+ catch {
212
+ // best-effort
213
+ }
214
+ };
182
215
  try {
183
- proc.kill("SIGTERM");
216
+ if (!killPosixProcessGroup("SIGTERM")) {
217
+ killDirectChild("SIGTERM");
218
+ }
184
219
  if (options.fast) {
185
220
  const timer = setTimeout(() => {
186
- try {
187
- if (!proc.killed) {
188
- proc.kill("SIGKILL");
221
+ if (!proc.killed) {
222
+ if (!killPosixProcessGroup("SIGKILL")) {
223
+ killDirectChild("SIGKILL");
189
224
  }
190
225
  }
191
- catch {
192
- // best-effort
193
- }
194
226
  }, 1500);
195
227
  timer.unref?.();
196
228
  proc.unref?.();
@@ -199,14 +231,11 @@ export async function killProcessTree(proc, pid, options = {}) {
199
231
  // SIGTERM → 1.5s → SIGKILL escalation.
200
232
  // SIGTERM alone can leave zombie processes if the server hangs.
201
233
  await new Promise((resolve) => setTimeout(resolve, 1500));
202
- try {
203
- if (!proc.killed) {
204
- proc.kill("SIGKILL");
234
+ if (!proc.killed) {
235
+ if (!killPosixProcessGroup("SIGKILL")) {
236
+ killDirectChild("SIGKILL");
205
237
  }
206
238
  }
207
- catch {
208
- // best-effort
209
- }
210
239
  }
211
240
  catch {
212
241
  // ignore
@@ -294,6 +323,7 @@ export function applyDynamicCapabilities(state) {
294
323
  state.workspaceDiagnosticsSupport = {
295
324
  advertised: true,
296
325
  mode: "pull",
326
+ workspaceDiagnostics: registeredMethods.has("workspace/diagnostic"),
297
327
  diagnosticProviderKind: "dynamic",
298
328
  };
299
329
  }
@@ -303,6 +333,7 @@ export function applyDynamicCapabilities(state) {
303
333
  state.workspaceDiagnosticsSupport = {
304
334
  advertised: false,
305
335
  mode: "push-only",
336
+ workspaceDiagnostics: false,
306
337
  diagnosticProviderKind: "none",
307
338
  };
308
339
  }
@@ -439,12 +470,17 @@ function setupConnectionLifecycle(state) {
439
470
  }
440
471
  });
441
472
  }
442
- async function clientRequestPullDiagnostics(state, filePath) {
473
+ async function clientRequestPullDiagnostics(state, filePath, budgetMs = PULL_REQUEST_TIMEOUT_MS) {
443
474
  if (!isClientAlive(state))
444
475
  return { status: "unavailable" };
445
476
  const uri = pathToFileURL(filePath).href;
446
477
  try {
447
- const report = await safeSendRequest(state.connection, "textDocument/diagnostic", { textDocument: { uri } });
478
+ // withTimeout is the backstop against a hung pull-mode server: without it
479
+ // this await never settles unless the stream is destroyed. Bounded by the
480
+ // smaller of the absolute ceiling and the caller's remaining wait budget.
481
+ // On timeout the caught error yields `unavailable` below (never a false
482
+ // `clean`), so it falls through to the push-wait/timeout backstop.
483
+ const report = await withTimeout(safeSendRequest(state.connection, "textDocument/diagnostic", { textDocument: { uri } }), Math.max(1, Math.min(PULL_REQUEST_TIMEOUT_MS, budgetMs)));
448
484
  if (!report)
449
485
  return { status: "unavailable" };
450
486
  const normalizedPath = normalizeMapKey(filePath);
@@ -472,6 +508,39 @@ async function clientRequestPullDiagnostics(state, filePath) {
472
508
  return { status: "unavailable" };
473
509
  }
474
510
  }
511
+ /**
512
+ * One project-wide `workspace/diagnostic` pull — a single request that returns
513
+ * diagnostics for every document the server knows, instead of opening N files.
514
+ * Returns per-file reports, or `undefined` on unsupported/dead/timeout/malformed
515
+ * (caller falls back to the per-file path). `unchanged`-kind items carry no
516
+ * diagnostics and are skipped, so a file absent from the result is "clean".
517
+ */
518
+ export async function clientRequestWorkspaceDiagnostics(state, budgetMs) {
519
+ if (!isClientAlive(state))
520
+ return undefined;
521
+ if (!state.workspaceDiagnosticsSupport.workspaceDiagnostics)
522
+ return undefined;
523
+ try {
524
+ const report = await withTimeout(safeSendRequest(state.connection, "workspace/diagnostic", { previousResultIds: [] }), Math.max(1, budgetMs));
525
+ if (!report || !Array.isArray(report.items))
526
+ return undefined;
527
+ const out = [];
528
+ for (const item of report.items) {
529
+ // Only "full" reports carry items; "unchanged" means "same as last pull"
530
+ // (none, since previousResultIds is empty on this one-shot request).
531
+ if (!item?.uri || item.kind !== "full")
532
+ continue;
533
+ out.push({
534
+ filePath: uriToPath(item.uri),
535
+ diagnostics: normalizeLspDiagnostics(item.items ?? []),
536
+ });
537
+ }
538
+ return out;
539
+ }
540
+ catch {
541
+ return undefined;
542
+ }
543
+ }
475
544
  export async function clientWaitForDiagnostics(state, filePath, timeoutMs, options = {}) {
476
545
  const normalizedPath = normalizeMapKey(filePath);
477
546
  const minVersion = options.minVersion;
@@ -499,7 +568,7 @@ export async function clientWaitForDiagnostics(state, filePath, timeoutMs, optio
499
568
  // `hasFreshDiagnostics()`, which is unconditionally true when there is no
500
569
  // version baseline (`minVersion === undefined`), so a failed pull returned
501
570
  // 0 and was read as a fresh clean.
502
- let outcome = await clientRequestPullDiagnostics(state, filePath);
571
+ let outcome = await clientRequestPullDiagnostics(state, filePath, timeoutMs);
503
572
  if (outcome.status === "found")
504
573
  return;
505
574
  let sawClean = outcome.status === "clean";
@@ -513,7 +582,7 @@ export async function clientWaitForDiagnostics(state, filePath, timeoutMs, optio
513
582
  // any point is a valid affirmative answer for this touch.
514
583
  while (outcome.status !== "found" && Date.now() - startedAt < retryBudgetMs) {
515
584
  await new Promise((resolve) => setTimeout(resolve, PULL_DIAGNOSTICS_RETRY_INTERVAL_MS));
516
- outcome = await clientRequestPullDiagnostics(state, filePath);
585
+ outcome = await clientRequestPullDiagnostics(state, filePath, Math.max(0, retryBudgetMs - (Date.now() - startedAt)));
517
586
  if (outcome.status === "clean")
518
587
  sawClean = true;
519
588
  }
@@ -714,18 +783,20 @@ async function toWirePosition(state, filePath, line, character) {
714
783
  function navStaleDropEnabled() {
715
784
  return process.env.PI_LENS_LSP_NAV_STALE_DROP !== "0";
716
785
  }
717
- async function navRequest(state, method, params,
786
+ // Exported for the timeout regression tests (#365). `timeoutMs` overrides the
787
+ // per-request ceiling so a test can bound a hung server quickly.
788
+ export async function navRequest(state, method, params,
718
789
  // When provided, the request is dropped if the document's version advances
719
790
  // (an edit landed) between send and response. Omit for non-single-file
720
791
  // requests (workspaceSymbol, call-hierarchy follow-ups) that have no version.
721
- staleCheckPath) {
792
+ staleCheckPath, timeoutMs = NAV_REQUEST_TIMEOUT_MS) {
722
793
  if (!isClientAlive(state))
723
794
  return null;
724
795
  const normalizedPath = staleCheckPath !== undefined ? normalizeMapKey(staleCheckPath) : undefined;
725
796
  const requestVersion = normalizedPath !== undefined
726
797
  ? state.documentVersions.get(normalizedPath)
727
798
  : undefined;
728
- const result = (await withTimeout(safeSendRequest(state.connection, method, params), NAV_REQUEST_TIMEOUT_MS).catch((err) => {
799
+ const result = (await withTimeout(safeSendRequest(state.connection, method, params), timeoutMs).catch((err) => {
729
800
  if (err instanceof Error && err.message.startsWith("Timeout after")) {
730
801
  return undefined;
731
802
  }
@@ -743,6 +814,50 @@ staleCheckPath) {
743
814
  }
744
815
  return result;
745
816
  }
817
+ // Run an advertised server command via workspace/executeCommand, with the
818
+ // generous EXECUTE_COMMAND_TIMEOUT_MS anti-deadlock backstop. Preserves the
819
+ // hardening invariants: allowlist-by-advertisement (only commands the server
820
+ // declared) and the serverEditsAllowed window that gates server-driven
821
+ // applyEdit to the duration of an explicit call. Exported with an overridable
822
+ // `timeoutMs` for the #365 regression tests.
823
+ export async function runServerCommand(state, command, args, timeoutMs = EXECUTE_COMMAND_TIMEOUT_MS) {
824
+ if (!isClientAlive(state)) {
825
+ return { executed: false, reason: "lsp client not alive" };
826
+ }
827
+ if (!state.advertisedCommands.has(command)) {
828
+ return {
829
+ executed: false,
830
+ reason: `command "${command}" is not advertised by the ${state.serverId} server`,
831
+ };
832
+ }
833
+ state.serverEditsAllowed += 1;
834
+ try {
835
+ let result;
836
+ try {
837
+ result = await withTimeout(safeSendRequest(state.connection, "workspace/executeCommand", {
838
+ command,
839
+ arguments: args ?? [],
840
+ }), timeoutMs);
841
+ }
842
+ catch (err) {
843
+ // Generous backstop only: a timeout means the server is hung (or the
844
+ // command is running longer than the ceiling). Surface it honestly — the
845
+ // command may still be applying — instead of hanging the caller. Real
846
+ // (non-timeout) errors still propagate.
847
+ if (err instanceof Error && err.message.startsWith("Timeout after")) {
848
+ return {
849
+ executed: false,
850
+ reason: `workspace/executeCommand timed out after ${timeoutMs}ms — the command may still be applying server-side`,
851
+ };
852
+ }
853
+ throw err;
854
+ }
855
+ return { executed: true, result };
856
+ }
857
+ finally {
858
+ state.serverEditsAllowed -= 1;
859
+ }
860
+ }
746
861
  async function resolveCodeActionBestEffort(state, action) {
747
862
  if (!isClientAlive(state) || action.edit)
748
863
  return action;
@@ -999,6 +1114,9 @@ export async function createLSPClient(options) {
999
1114
  getWorkspaceDiagnosticsSupport() {
1000
1115
  return state.workspaceDiagnosticsSupport;
1001
1116
  },
1117
+ requestWorkspaceDiagnostics(budgetMs) {
1118
+ return clientRequestWorkspaceDiagnostics(state, budgetMs);
1119
+ },
1002
1120
  getOperationSupport() {
1003
1121
  return state.operationSupport;
1004
1122
  },
@@ -1009,25 +1127,7 @@ export async function createLSPClient(options) {
1009
1127
  return state.rawCapabilityKeys ?? [];
1010
1128
  },
1011
1129
  async executeCommand(command, args) {
1012
- if (!isClientAlive(state)) {
1013
- return { executed: false, reason: "lsp client not alive" };
1014
- }
1015
- // Hardening: allowlist-by-advertisement. Only commands the server
1016
- // itself declared (static or dynamic) may run — no arbitrary strings.
1017
- if (!state.advertisedCommands.has(command)) {
1018
- return {
1019
- executed: false,
1020
- reason: `command "${command}" is not advertised by the ${state.serverId} server`,
1021
- };
1022
- }
1023
- state.serverEditsAllowed += 1;
1024
- try {
1025
- const result = await safeSendRequest(state.connection, "workspace/executeCommand", { command, arguments: args ?? [] });
1026
- return { executed: true, result };
1027
- }
1028
- finally {
1029
- state.serverEditsAllowed -= 1;
1030
- }
1130
+ return runServerCommand(state, command, args);
1031
1131
  },
1032
1132
  get diagnosticsVersion() {
1033
1133
  return state.diagnosticsVersion;
@@ -1091,14 +1191,22 @@ export async function createLSPClient(options) {
1091
1191
  async workspaceSymbol(query) {
1092
1192
  if (!isClientAlive(state))
1093
1193
  return [];
1094
- const result = await safeSendRequest(connection, "workspace/symbol", { query });
1194
+ // Route through navRequest for the shared withTimeout ceiling — a hung
1195
+ // server would otherwise await forever (safeSendRequest only settles on
1196
+ // a reply or a destroyed stream). No staleCheckPath: not single-file.
1197
+ const result = await navRequest(state, "workspace/symbol", {
1198
+ query,
1199
+ });
1095
1200
  return result ?? [];
1096
1201
  },
1097
1202
  async codeAction(filePath, line, character, endLine, endCharacter) {
1098
1203
  if (!isClientAlive(state))
1099
1204
  return [];
1100
1205
  const uri = pathToFileURL(filePath).href;
1101
- const result = await safeSendRequest(connection, "textDocument/codeAction", {
1206
+ // navRequest adds the shared withTimeout ceiling + single-file
1207
+ // stale-drop (matches documentSymbol); a hung server no longer awaits
1208
+ // forever, and code actions computed against superseded content drop.
1209
+ const result = await navRequest(state, "textDocument/codeAction", {
1102
1210
  textDocument: { uri },
1103
1211
  range: {
1104
1212
  start: await toWirePosition(state, filePath, line, character),
@@ -1107,7 +1215,7 @@ export async function createLSPClient(options) {
1107
1215
  context: {
1108
1216
  diagnostics: getMergedDiagnosticsForPath(state, normalizeMapKey(filePath)),
1109
1217
  },
1110
- });
1218
+ }, filePath);
1111
1219
  if (!result || !Array.isArray(result))
1112
1220
  return [];
1113
1221
  const actions = result.filter((item) => typeof item === "object" && item !== null && "title" in item);
@@ -1222,25 +1330,6 @@ function isStreamError(err) {
1222
1330
  err.code === "EPIPE");
1223
1331
  }
1224
1332
  // Using shared path utilities from path-utils.ts
1225
- async function withTimeout(promise, timeoutMs) {
1226
- let timeout;
1227
- // Suppress unhandled rejection if `promise` rejects AFTER the timeout
1228
- // wins the race — Promise.race settles on the first result but the
1229
- // losing promises still run, and any later rejection would be uncaught.
1230
- promise.catch(() => { });
1231
- try {
1232
- return await Promise.race([
1233
- promise,
1234
- new Promise((_, reject) => {
1235
- timeout = setTimeout(() => reject(new Error(`Timeout after ${timeoutMs}ms`)), timeoutMs);
1236
- }),
1237
- ]);
1238
- }
1239
- finally {
1240
- if (timeout)
1241
- clearTimeout(timeout);
1242
- }
1243
- }
1244
1333
  function positiveIntFromEnv(name, fallback) {
1245
1334
  const raw = process.env[name];
1246
1335
  if (!raw)
@@ -1259,6 +1348,7 @@ function detectWorkspaceDiagnosticsSupport(initResult) {
1259
1348
  return {
1260
1349
  advertised: false,
1261
1350
  mode: "push-only",
1351
+ workspaceDiagnostics: false,
1262
1352
  diagnosticProviderKind: "none",
1263
1353
  };
1264
1354
  }
@@ -1266,6 +1356,8 @@ function detectWorkspaceDiagnosticsSupport(initResult) {
1266
1356
  return {
1267
1357
  advertised: diagnosticProvider,
1268
1358
  mode: diagnosticProvider ? "pull" : "push-only",
1359
+ // The boolean form of diagnosticProvider only signals document pull.
1360
+ workspaceDiagnostics: false,
1269
1361
  diagnosticProviderKind: "boolean",
1270
1362
  };
1271
1363
  }
@@ -1273,12 +1365,15 @@ function detectWorkspaceDiagnosticsSupport(initResult) {
1273
1365
  return {
1274
1366
  advertised: true,
1275
1367
  mode: "pull",
1368
+ workspaceDiagnostics: diagnosticProvider
1369
+ .workspaceDiagnostics === true,
1276
1370
  diagnosticProviderKind: "object",
1277
1371
  };
1278
1372
  }
1279
1373
  return {
1280
1374
  advertised: false,
1281
1375
  mode: "push-only",
1376
+ workspaceDiagnostics: false,
1282
1377
  diagnosticProviderKind: typeof diagnosticProvider,
1283
1378
  };
1284
1379
  }