pi-lens 3.8.66 → 3.8.68

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 (50) hide show
  1. package/CHANGELOG.md +44 -0
  2. package/README.md +2 -1
  3. package/dist/clients/actionable-warnings-logger.js +11 -36
  4. package/dist/clients/ast-grep-tool-logger.js +11 -36
  5. package/dist/clients/cascade-logger.js +7 -12
  6. package/dist/clients/dead-code-logger.js +11 -22
  7. package/dist/clients/deps/ast-grep-napi.js +20 -1
  8. package/dist/clients/deps/pi-tui.js +4 -1
  9. package/dist/clients/deps/typebox.js +5 -2
  10. package/dist/clients/deps/web-tree-sitter.js +22 -1
  11. package/dist/clients/diagnostic-logger.js +7 -31
  12. package/dist/clients/dispatch/auxiliary-lsp.js +38 -0
  13. package/dist/clients/dispatch/dispatcher.js +1 -36
  14. package/dist/clients/dispatch/inline-suppressions.js +61 -0
  15. package/dist/clients/dispatch/integration.js +75 -23
  16. package/dist/clients/dispatch/runners/lsp.js +17 -5
  17. package/dist/clients/dispatch/runners/tree-sitter.js +7 -2
  18. package/dist/clients/instance-reaper.js +418 -0
  19. package/dist/clients/instance-registry.js +238 -0
  20. package/dist/clients/latency-logger.js +10 -15
  21. package/dist/clients/lsp/client.js +76 -2
  22. package/dist/clients/lsp/config.js +61 -3
  23. package/dist/clients/lsp/index.js +43 -3
  24. package/dist/clients/lsp/launch.js +2 -0
  25. package/dist/clients/lsp/server-strategies.js +61 -0
  26. package/dist/clients/lsp/server.js +8 -1
  27. package/dist/clients/ndjson-logger.js +150 -0
  28. package/dist/clients/pipeline.js +25 -41
  29. package/dist/clients/project-diagnostics/scanner.js +8 -2
  30. package/dist/clients/read-guard-logger.js +11 -36
  31. package/dist/clients/review-graph/builder.js +336 -17
  32. package/dist/clients/review-graph/git-identity.js +150 -0
  33. package/dist/clients/review-graph/service.js +3 -3
  34. package/dist/clients/runtime-coordinator.js +83 -0
  35. package/dist/clients/runtime-session.js +89 -8
  36. package/dist/clients/runtime-tool-result.js +10 -2
  37. package/dist/clients/runtime-turn.js +30 -0
  38. package/dist/clients/session-lifecycle.js +252 -0
  39. package/dist/clients/sgconfig.js +31 -1
  40. package/dist/clients/slow-fs.js +137 -0
  41. package/dist/clients/source-filter.js +18 -5
  42. package/dist/clients/subagent-mode.js +53 -0
  43. package/dist/clients/tree-sitter-logger.js +7 -12
  44. package/dist/clients/tree-sitter-query-loader.js +1 -0
  45. package/dist/index.js +60587 -1636
  46. package/dist/tools/lens-diagnostics.js +203 -12
  47. package/package.json +3 -2
  48. package/rules/ast-grep-rules/rule-tests/no-init-return-test.yml +15 -0
  49. package/rules/ast-grep-rules/rules/no-init-return.yml +11 -5
  50. package/rules/tree-sitter-queries/python/python-assert-production.yml +4 -0
@@ -292,6 +292,7 @@ export function resetDispatchBaselines(cwd) {
292
292
  resetSessionSlopScore();
293
293
  clearCoverageNoticeState();
294
294
  clearReviewGraphWorkspaceCache();
295
+ clearReverseDepsIndexCache();
295
296
  clearModuleGraphCache();
296
297
  neighborTouchCache.clear();
297
298
  recentlyCleanNeighborCache.clear();
@@ -337,6 +338,15 @@ function ensureCascadeTurnScope(turnSeq) {
337
338
  const CASCADE_TTL_MS = 240_000;
338
339
  const MAX_PER_FILE = RUNTIME_CONFIG.pipeline.cascadeMaxDiagnosticsPerFile;
339
340
  const MAX_FILES = RUNTIME_CONFIG.pipeline.cascadeMaxFiles;
341
+ const reverseDepsIndexCache = new Map();
342
+ function reverseDepsReuseEnabled() {
343
+ const raw = process.env.PI_LENS_REVERSE_DEPS_REUSE;
344
+ return raw !== "0" && raw !== "false";
345
+ }
346
+ /** Test-reset hook — mirrors clearReviewGraphWorkspaceCache's scope. */
347
+ export function clearReverseDepsIndexCache() {
348
+ reverseDepsIndexCache.clear();
349
+ }
340
350
  // Bounded transitive cascade (#162): expand neighbour derivation beyond the
341
351
  // one-hop importers/callers to depth-2 dependents, so an edit's blast radius
342
352
  // reaches indirect dependents — capped so the per-edit cost stays bounded. The
@@ -383,7 +393,7 @@ function isIgnoredCascadeNeighbor(filePath, cwd) {
383
393
  }
384
394
  }
385
395
  export async function computeCascadeForFile(filePath, cwd, options = {}) {
386
- const { hasBlockers = false, dbg, turnSeq = 0, writeSeq } = options;
396
+ const { hasBlockers = false, dbg, turnSeq = 0, writeSeq, seqState } = options;
387
397
  ensureCascadeTurnScope(turnSeq);
388
398
  if (hasBlockers) {
389
399
  logCascade({
@@ -429,32 +439,71 @@ export async function computeCascadeForFile(filePath, cwd, options = {}) {
429
439
  let referenceCount = 0;
430
440
  if (CASCADE_GRAPH_KINDS.has(fileKind)) {
431
441
  const graphStart = Date.now();
432
- const graph = await buildOrUpdateGraph(cwd, [normalizedFile], sessionFacts);
442
+ const graph = await buildOrUpdateGraph(cwd, [normalizedFile], sessionFacts, seqState);
433
443
  const graphMs = Date.now() - graphStart;
434
- const reverseDepsIndex = buildReverseDependencyIndexFromGraph({
435
- cwd,
436
- graph,
437
- });
438
- const reverseDepsSaved = writeReverseDependencyIndexToSnapshot({
439
- cwd,
440
- index: reverseDepsIndex,
441
- dbg,
442
- });
443
- logCascade({
444
- phase: "reverse_deps_cache",
445
- filePath,
446
- durationMs: Date.now() - graphStart,
447
- metadata: {
448
- action: "refresh_from_review_graph",
444
+ // #459: the reuse decision keys on graph.buildGeneration — a stamp that
445
+ // travels WITH the graph instance this cascade holds. Deliberately NOT the
446
+ // global last-build-info slot: post-#450 cascades overlap, and another
447
+ // cascade's cache-hit build can overwrite that slot (graphChanged:false)
448
+ // between this build mutating the graph and this read — which would turn a
449
+ // changed graph into a spurious reuse of a stale index that steady-state
450
+ // cache hits then never heal. Generation equality can't be clobbered into
451
+ // a false positive: a graph-mutating build always mints a new generation.
452
+ // An unstamped graph (mode "skipped") always rebuilds.
453
+ const graphBuildInfo = getLastGraphBuildInfo();
454
+ const workspaceKey = normalizeMapKey(cwd);
455
+ const cachedReverseDeps = reverseDepsIndexCache.get(workspaceKey);
456
+ const canReuse = reverseDepsReuseEnabled() &&
457
+ cachedReverseDeps !== undefined &&
458
+ graph.buildGeneration !== undefined &&
459
+ cachedReverseDeps.generation === graph.buildGeneration;
460
+ let reverseDepsIndex;
461
+ let reverseDepsSaved;
462
+ if (canReuse && cachedReverseDeps) {
463
+ reverseDepsIndex = cachedReverseDeps.index;
464
+ reverseDepsSaved = cachedReverseDeps.savedToSnapshot;
465
+ logCascade({
466
+ phase: "reverse_deps_cache",
467
+ filePath,
468
+ durationMs: Date.now() - graphStart,
469
+ metadata: {
470
+ action: "reused_unchanged",
471
+ savedToSnapshot: reverseDepsSaved,
472
+ importsFileCount: Object.keys(reverseDepsIndex.imports).length,
473
+ importedByFileCount: Object.keys(reverseDepsIndex.importedBy).length,
474
+ },
475
+ });
476
+ }
477
+ else {
478
+ reverseDepsIndex = buildReverseDependencyIndexFromGraph({
479
+ cwd,
480
+ graph,
481
+ });
482
+ reverseDepsSaved = writeReverseDependencyIndexToSnapshot({
483
+ cwd,
484
+ index: reverseDepsIndex,
485
+ dbg,
486
+ });
487
+ reverseDepsIndexCache.set(workspaceKey, {
488
+ index: reverseDepsIndex,
449
489
  savedToSnapshot: reverseDepsSaved,
450
- importsFileCount: Object.keys(reverseDepsIndex.imports).length,
451
- importedByFileCount: Object.keys(reverseDepsIndex.importedBy).length,
452
- importEdgeCount: Object.values(reverseDepsIndex.imports).reduce((total, imports) => total + imports.length, 0),
453
- },
454
- });
490
+ generation: graph.buildGeneration,
491
+ });
492
+ logCascade({
493
+ phase: "reverse_deps_cache",
494
+ filePath,
495
+ durationMs: Date.now() - graphStart,
496
+ metadata: {
497
+ action: "refresh_from_review_graph",
498
+ savedToSnapshot: reverseDepsSaved,
499
+ importsFileCount: Object.keys(reverseDepsIndex.imports).length,
500
+ importedByFileCount: Object.keys(reverseDepsIndex.importedBy).length,
501
+ importEdgeCount: Object.values(reverseDepsIndex.imports).reduce((total, imports) => total + imports.length, 0),
502
+ },
503
+ });
504
+ }
455
505
  // Count files represented in the graph (nodes with a filePath).
456
506
  const graphFileCount = new Set([...graph.nodes.values()].flatMap((n) => n.filePath ? [n.filePath] : [])).size;
457
- const graphBuildInfo = getLastGraphBuildInfo();
458
507
  logCascade({
459
508
  phase: "graph_build",
460
509
  filePath,
@@ -468,6 +517,9 @@ export async function computeCascadeForFile(filePath, cwd, options = {}) {
468
517
  skipReason: graphBuildInfo.skipReason,
469
518
  sourceFileCount: graphBuildInfo.sourceFileCount,
470
519
  maxFileCount: graphBuildInfo.maxFileCount,
520
+ // #451: when the seq fast path fell back (or was skipped), why — so
521
+ // cascade.log surfaces the fast-path hit/miss rate.
522
+ seqFastpathFallback: graphBuildInfo.seqFastpathFallback,
471
523
  },
472
524
  });
473
525
  impact = computeImpactCascade(graph, normalizedFile, cwd);
@@ -189,7 +189,7 @@ const lspRunner = {
189
189
  .map((d, idx) => ({ d, idx }))
190
190
  .filter(({ d }) => d.severity === 1)
191
191
  .slice(0, MAX_CODE_ACTION_LOOKUPS);
192
- for (const { d, idx } of blockingDiagIndexes) {
192
+ await Promise.all(blockingDiagIndexes.map(async ({ d, idx }) => {
193
193
  try {
194
194
  const start = d.range.start;
195
195
  const end = d.range.end ?? d.range.start;
@@ -202,17 +202,26 @@ const lspRunner = {
202
202
  catch {
203
203
  // Best-effort enrichment only; base diagnostics remain authoritative.
204
204
  }
205
- }
205
+ }));
206
206
  const diagnostics = convertLspDiagnostics(validLspDiags, diagnosticPath, { fixSuggestionByIndex });
207
207
  // convertLspDiagnostics maps validLspDiags 1:1, so re-tag any
208
208
  // auxiliary-sourced diagnostics (opengrep emits source "Semgrep", …) with
209
209
  // their tool id + semantic policy — language-server diagnostics keep "lsp".
210
210
  // blockingAllowed is per-workspace (e.g. curated repo rules), computed once.
211
211
  const blockingAllowedByProfile = new Map();
212
+ // Diagnostics dropped by the tool's NATIVE inline suppression (e.g. opengrep
213
+ // `# nosemgrep`, #441). Reuses `content` from the sync read above.
214
+ const suppressedIndices = new Set();
212
215
  for (let i = 0; i < diagnostics.length; i++) {
213
216
  const profile = findAuxiliaryProfileForSource(validLspDiags[i]?.source);
214
217
  if (!profile)
215
218
  continue;
219
+ if (profile.isSuppressed) {
220
+ if (profile.isSuppressed(validLspDiags[i], content)) {
221
+ suppressedIndices.add(i);
222
+ continue;
223
+ }
224
+ }
216
225
  let blockingAllowed = blockingAllowedByProfile.get(profile);
217
226
  if (blockingAllowed === undefined) {
218
227
  blockingAllowed = profile.allowBlocking?.(ctx.cwd) ?? false;
@@ -228,10 +237,13 @@ const lspRunner = {
228
237
  if (defectClass)
229
238
  d.defectClass = defectClass;
230
239
  }
231
- const hasErrors = diagnostics.some((d) => d.semantic === "blocking");
240
+ const keptDiagnostics = suppressedIndices.size
241
+ ? diagnostics.filter((_, i) => !suppressedIndices.has(i))
242
+ : diagnostics;
243
+ const hasErrors = keptDiagnostics.some((d) => d.semantic === "blocking");
232
244
  const resultSemantic = hasErrors
233
245
  ? "blocking"
234
- : diagnostics.length > 0
246
+ : keptDiagnostics.length > 0
235
247
  ? "warning"
236
248
  : "none";
237
249
  return {
@@ -239,7 +251,7 @@ const lspRunner = {
239
251
  // "failed" here means the file has blocking type errors — the check ran
240
252
  // fine. Tag it so the smell analyzer doesn't read it as a runner crash.
241
253
  failureKind: hasErrors ? "blocking_diagnostics" : undefined,
242
- diagnostics,
254
+ diagnostics: keptDiagnostics,
243
255
  semantic: resultSemantic,
244
256
  };
245
257
  },
@@ -7,6 +7,7 @@
7
7
  import * as fs from "node:fs";
8
8
  import * as path from "node:path";
9
9
  import { RuleCache } from "../../cache/rule-cache.js";
10
+ import { isTestFile } from "../../file-utils.js";
10
11
  import { resolvePackagePath } from "../../package-root.js";
11
12
  import { buildOrUpdateGraph, computeImpactCascade, recordEntitySnapshotDiff, } from "../../review-graph/service.js";
12
13
  import { getSharedTreeSitterClient, isTreeSitterWasmAborted, markTreeSitterWasmAborted, resolveTreeSitterLanguage, } from "../../tree-sitter-shared.js";
@@ -403,9 +404,13 @@ const treeSitterRunner = {
403
404
  // Run all queries regardless of blockingOnly — warning-tier results are logged
404
405
  // for diagnostic history but filtered from agent output by the dispatcher.
405
406
  // Only skip "review" tier queries on write (too noisy / expensive).
406
- const effectiveQueries = ctx.blockingOnly
407
+ // Per-rule test-file carve-out (#440): rules that are noise in tests (e.g.
408
+ // python-assert-production — `assert` is the idiomatic test assertion) opt
409
+ // out via `skip_test_files` while the runner otherwise runs on test files.
410
+ const fileIsTest = isTestFile(filePath);
411
+ const effectiveQueries = (ctx.blockingOnly
407
412
  ? languageQueries.filter((q) => q.inline_tier !== "review")
408
- : languageQueries;
413
+ : languageQueries).filter((q) => !(fileIsTest && q.skip_test_files));
409
414
  logTreeSitter({
410
415
  phase: "queries_loaded",
411
416
  filePath,
@@ -0,0 +1,418 @@
1
+ /**
2
+ * Orphaned LSP process reaper (#472), built on the instance registry (#449
3
+ * slice 1).
4
+ *
5
+ * Split into a PURE decision function (`decideOrphanReaping`) and an IMPURE
6
+ * sweep (`sweepOrphans`) so the decision logic is unit-testable with fake
7
+ * pid tables — no real process spawns/kills in tests.
8
+ *
9
+ * Why the registry reaper, not EOF/processId alone (see issue #472): both are
10
+ * best-effort hints a well-behaved server may honor (typescript-language-server
11
+ * does; ast-grep's native exe does not — an upstream LSP-spec violation). The
12
+ * registry reaper works regardless of why a stdin pipe write-end stayed open
13
+ * after the parent died (Windows handle-inheritance capture) — it identifies
14
+ * dead-parent instances directly and kills the full recorded child tree, with
15
+ * a command-line marker fallback for the case where the pid itself was
16
+ * recycled or the mid-tree pid link is broken (e.g. a dead node-wrapper whose
17
+ * native-exe grandchild is still alive under a different, unrecorded pid).
18
+ */
19
+ import { spawn as nodeSpawn } from "node:child_process";
20
+ import * as fs from "node:fs";
21
+ import * as path from "node:path";
22
+ import { getGlobalPiLensDir } from "./file-utils.js";
23
+ import { isInstanceRegistryEnabled, readInstanceRegistry, } from "./instance-registry.js";
24
+ import { logLatency } from "./latency-logger.js";
25
+ const isWindows = process.platform === "win32";
26
+ /**
27
+ * Markers claimed by any LIVE instance's children. A marker search kills by
28
+ * command-line match, so a marker that a live session also uses must never
29
+ * be searched — killing it would take down the live session's server.
30
+ * Markers are per-process-unique by construction (sgconfig.ts embeds the
31
+ * pid), so this is defense in depth against non-unique markers ever
32
+ * reappearing (#472: the original shared baseline.sgconfig.yml would have
33
+ * made the fallback kill every live ast-grep on the machine).
34
+ */
35
+ function collectLiveMarkers(registry, isPidAlive) {
36
+ const liveMarkers = new Set();
37
+ for (const instance of registry) {
38
+ if (!isPidAlive(instance.pid))
39
+ continue;
40
+ for (const child of instance.lspChildren) {
41
+ if (child.marker)
42
+ liveMarkers.add(child.marker);
43
+ }
44
+ }
45
+ return liveMarkers;
46
+ }
47
+ /**
48
+ * Classify one dead-parent instance's children into kills / marker-searches,
49
+ * appending onto the shared `out` accumulator. Extracted from
50
+ * `decideOrphanReaping` to keep cognitive complexity in check — no behavior
51
+ * change, just the per-instance inner loop pulled out.
52
+ */
53
+ function classifyDeadInstanceChildren(instance, isPidAlive, matchProcess, liveMarkers, out) {
54
+ for (const child of instance.lspChildren) {
55
+ const childAlive = isPidAlive(child.pid);
56
+ if (childAlive) {
57
+ const identityOk = matchProcess
58
+ ? matchProcess(child.pid, {
59
+ command: child.command,
60
+ marker: child.marker,
61
+ })
62
+ : true;
63
+ if (identityOk) {
64
+ out.childrenToKill.push({
65
+ pid: child.pid,
66
+ serverId: child.serverId,
67
+ command: child.command,
68
+ });
69
+ continue;
70
+ }
71
+ }
72
+ // Child pid is dead, or alive-but-identity-mismatched (recycled pid) —
73
+ // if we have a marker, surface it so the caller can find a live
74
+ // process (e.g. the native exe grandchild) by command-line match.
75
+ // Never surface a marker a live instance also claims (see above).
76
+ if (child.marker && !liveMarkers.has(child.marker)) {
77
+ out.markerSearches.push({ marker: child.marker, serverId: child.serverId });
78
+ }
79
+ }
80
+ }
81
+ /**
82
+ * Pure decision function: given the registry state and injectable liveness /
83
+ * identity predicates, decide what to kill. Performs zero I/O.
84
+ *
85
+ * @param isPidAlive - `process.kill(pid, 0)`-style liveness check. Must be
86
+ * CONSERVATIVE: only pid-confirmed-dead (ESRCH) counts as dead. Any
87
+ * ambiguous result (EPERM, or the caller's fake table saying "unknown")
88
+ * must be treated as alive — never kill on an ambiguous signal-check.
89
+ * @param matchProcess - optional identity verification (e.g. confirm the
90
+ * live pid's command line still matches what we recorded) to guard against
91
+ * a recycled pid coincidentally matching. If omitted, liveness alone is used.
92
+ */
93
+ export function decideOrphanReaping(registry, isPidAlive, matchProcess) {
94
+ const deadInstances = [];
95
+ const childrenToKill = [];
96
+ const markerSearches = [];
97
+ const liveMarkers = collectLiveMarkers(registry, isPidAlive);
98
+ for (const instance of registry) {
99
+ if (isPidAlive(instance.pid)) {
100
+ continue; // parent still alive — leave its children alone entirely
101
+ }
102
+ deadInstances.push(instance);
103
+ classifyDeadInstanceChildren(instance, isPidAlive, matchProcess, liveMarkers, {
104
+ childrenToKill,
105
+ markerSearches,
106
+ });
107
+ }
108
+ return { deadInstances, childrenToKill, markerSearches };
109
+ }
110
+ // --- Impure liveness / identity / kill helpers ---
111
+ /** `process.kill(pid, 0)` liveness check: ESRCH ⇒ dead, anything else
112
+ * (EPERM, or no error thrown at all) ⇒ conservatively alive. */
113
+ function realIsPidAlive(pid) {
114
+ if (!Number.isFinite(pid) || pid <= 0)
115
+ return false;
116
+ try {
117
+ process.kill(pid, 0);
118
+ return true; // no throw — process exists and we can signal it
119
+ }
120
+ catch (err) {
121
+ const code = err?.code;
122
+ if (code === "ESRCH")
123
+ return false; // definitively dead
124
+ // EPERM (exists, no permission) or any other/unknown errno: ambiguous —
125
+ // never treat as dead.
126
+ return true;
127
+ }
128
+ }
129
+ function windowsExe(name) {
130
+ return path.join(process.env.SystemRoot ?? String.raw `C:\Windows`, "System32", name);
131
+ }
132
+ /** Resolve an absolute path to `ps` (S4036: never spawn via bare PATH lookup).
133
+ * Prefers `/bin/ps` (present on virtually every POSIX system), falls back to
134
+ * `/usr/bin/ps`, and defaults back to `/bin/ps` if neither probe succeeds
135
+ * (spawn will then fail closed rather than silently resolving via PATH). */
136
+ function posixPsPath() {
137
+ if (fs.existsSync("/bin/ps"))
138
+ return "/bin/ps";
139
+ if (fs.existsSync("/usr/bin/ps"))
140
+ return "/usr/bin/ps";
141
+ return "/bin/ps";
142
+ }
143
+ /** Escape a value for embedding in a WQL LIKE clause: WQL uses `'` as the
144
+ * string delimiter (doubled to escape) and `%`/`_` as wildcards — the marker
145
+ * is an opaque path string, so escape all three before interpolating. */
146
+ function escapeWqlLikeValue(value) {
147
+ return value.replaceAll("'", "''").replaceAll(/[%_]/g, (ch) => `[${ch}]`);
148
+ }
149
+ /** Search running processes whose command line contains `marker` (Windows,
150
+ * via CIM/WQL). Returns matching pids. Best-effort: any failure ⇒ []. */
151
+ async function findPidsByMarkerWindows(marker) {
152
+ if (!isWindows || !marker)
153
+ return [];
154
+ const escaped = escapeWqlLikeValue(marker);
155
+ // $PID exclusion: the query's own powershell.exe command line embeds the
156
+ // marker string, so it would match itself.
157
+ const psScript = `Get-CimInstance Win32_Process -Filter "CommandLine LIKE '%${escaped}%'" ` +
158
+ `| Where-Object { $_.ProcessId -ne $PID } ` +
159
+ `| Select-Object -ExpandProperty ProcessId`;
160
+ return new Promise((resolve) => {
161
+ try {
162
+ const powershell = windowsExe("WindowsPowerShell\\v1.0\\powershell.exe");
163
+ const child = nodeSpawn(powershell, ["-NoProfile", "-NonInteractive", "-Command", psScript], { shell: false, windowsHide: true, stdio: ["ignore", "pipe", "ignore"] });
164
+ let out = "";
165
+ child.stdout?.on("data", (chunk) => {
166
+ out += chunk.toString();
167
+ });
168
+ child.once("error", () => resolve([]));
169
+ child.once("close", () => {
170
+ const pids = out
171
+ .split(/\r?\n/)
172
+ .map((line) => Number(line.trim()))
173
+ .filter((n) => Number.isFinite(n) && n > 0);
174
+ resolve(pids);
175
+ });
176
+ }
177
+ catch {
178
+ resolve([]);
179
+ }
180
+ });
181
+ }
182
+ /** Fetch command lines for a set of pids in one query (Windows: CIM; POSIX:
183
+ * `ps`). Returns a pid → command-line map; pids that can't be resolved are
184
+ * simply absent (the caller treats absent as "identity unverifiable — do not
185
+ * kill by pid"). Best-effort: any failure ⇒ empty map. */
186
+ async function queryCommandLines(pids) {
187
+ const valid = [...new Set(pids.filter((p) => Number.isFinite(p) && p > 0))];
188
+ const map = new Map();
189
+ if (valid.length === 0)
190
+ return map;
191
+ if (isWindows) {
192
+ const filter = valid.map((p) => `ProcessId=${p}`).join(" OR ");
193
+ const psScript = `Get-CimInstance Win32_Process -Filter "${filter}" ` +
194
+ `| ForEach-Object { "$($_.ProcessId)\t$($_.CommandLine)" }`;
195
+ return new Promise((resolve) => {
196
+ try {
197
+ const powershell = windowsExe("WindowsPowerShell\\v1.0\\powershell.exe");
198
+ const child = nodeSpawn(powershell, ["-NoProfile", "-NonInteractive", "-Command", psScript], { shell: false, windowsHide: true, stdio: ["ignore", "pipe", "ignore"] });
199
+ let out = "";
200
+ child.stdout?.on("data", (chunk) => {
201
+ out += chunk.toString();
202
+ });
203
+ child.once("error", () => resolve(map));
204
+ child.once("close", () => {
205
+ for (const line of out.split(/\r?\n/)) {
206
+ const tab = line.indexOf("\t");
207
+ if (tab <= 0)
208
+ continue;
209
+ const pid = Number(line.slice(0, tab).trim());
210
+ if (Number.isFinite(pid) && pid > 0)
211
+ map.set(pid, line.slice(tab + 1));
212
+ }
213
+ resolve(map);
214
+ });
215
+ }
216
+ catch {
217
+ resolve(map);
218
+ }
219
+ });
220
+ }
221
+ return new Promise((resolve) => {
222
+ try {
223
+ const child = nodeSpawn(posixPsPath(), ["-p", valid.join(","), "-o", "pid=,args="], { shell: false, stdio: ["ignore", "pipe", "ignore"] });
224
+ let out = "";
225
+ child.stdout?.on("data", (chunk) => {
226
+ out += chunk.toString();
227
+ });
228
+ child.once("error", () => resolve(map));
229
+ child.once("close", () => {
230
+ for (const line of out.split(/\r?\n/)) {
231
+ // Linear parse (S8786/S6594: avoid regex backtracking on
232
+ // attacker-lengthenable ps output) — trim leading whitespace,
233
+ // then split on the first whitespace run: " 1234 args here".
234
+ const trimmed = line.trimStart();
235
+ if (!trimmed)
236
+ continue;
237
+ let i = 0;
238
+ while (i < trimmed.length && trimmed[i] >= "0" && trimmed[i] <= "9")
239
+ i++;
240
+ if (i === 0)
241
+ continue;
242
+ const pidStr = trimmed.slice(0, i);
243
+ let j = i;
244
+ while (j < trimmed.length && (trimmed[j] === " " || trimmed[j] === "\t"))
245
+ j++;
246
+ const pid = Number(pidStr);
247
+ if (Number.isFinite(pid) && pid > 0)
248
+ map.set(pid, trimmed.slice(j));
249
+ }
250
+ resolve(map);
251
+ });
252
+ }
253
+ catch {
254
+ resolve(map);
255
+ }
256
+ });
257
+ }
258
+ /**
259
+ * Build a `matchProcess` identity predicate from a pid → command-line map
260
+ * (as produced by `queryCommandLines`). PURE — exported for unit testing.
261
+ *
262
+ * Semantics (guarding pid kills against pid recycling):
263
+ * - pid absent from the map ⇒ false: identity is UNVERIFIABLE, so never kill
264
+ * by pid (the marker-search fallback may still catch a real orphan).
265
+ * - marker recorded and present in the command line ⇒ match (strongest
266
+ * signal — markers are per-spawn-unique).
267
+ * - else: the recorded command's basename appears (case-insensitive) in the
268
+ * command line ⇒ match. Empty basename never matches (guard against a
269
+ * recorded empty/odd command matching everything via `includes("")`).
270
+ */
271
+ export function buildIdentityMatcher(cmdlines) {
272
+ return (pid, expected) => {
273
+ const cmdline = cmdlines.get(pid);
274
+ if (cmdline === undefined)
275
+ return false; // unverifiable ⇒ never kill by pid
276
+ if (expected.marker && cmdline.includes(expected.marker))
277
+ return true;
278
+ const basename = path.basename(expected.command ?? "").toLowerCase();
279
+ if (!basename)
280
+ return false;
281
+ return cmdline.toLowerCase().includes(basename);
282
+ };
283
+ }
284
+ /** Force-kill a pid's full process tree. Windows: `taskkill /F /T`. POSIX:
285
+ * mirror killProcessTree's process-group kill, falling back to a direct
286
+ * signal. Best-effort: swallow all errors. */
287
+ async function killPidTree(pid) {
288
+ if (!Number.isFinite(pid) || pid <= 0)
289
+ return;
290
+ if (isWindows) {
291
+ try {
292
+ const taskkill = windowsExe("taskkill.exe");
293
+ const killer = nodeSpawn(taskkill, ["/F", "/T", "/PID", String(pid)], {
294
+ shell: false,
295
+ windowsHide: true,
296
+ stdio: "ignore",
297
+ });
298
+ await new Promise((resolve) => {
299
+ killer.once("close", () => resolve());
300
+ killer.once("error", () => resolve());
301
+ });
302
+ }
303
+ catch {
304
+ // best-effort
305
+ }
306
+ return;
307
+ }
308
+ try {
309
+ process.kill(-pid, "SIGKILL");
310
+ }
311
+ catch {
312
+ try {
313
+ process.kill(pid, "SIGKILL");
314
+ }
315
+ catch {
316
+ // best-effort — process may already be gone
317
+ }
318
+ }
319
+ }
320
+ /**
321
+ * Fire-and-forget orphan sweep: reads the registry, decides what's dead via
322
+ * `decideOrphanReaping`, kills orphaned LSP children (by pid, with a
323
+ * marker-based command-line search fallback), then drops fully-dead
324
+ * instances from the registry. Never throws — every step is wrapped so a
325
+ * reap failure cannot block or crash the caller (session_start).
326
+ */
327
+ export async function sweepOrphans() {
328
+ if (!isInstanceRegistryEnabled())
329
+ return;
330
+ const startedAt = Date.now();
331
+ try {
332
+ const registry = await readInstanceRegistry();
333
+ if (registry.length === 0)
334
+ return;
335
+ // Identity verification before any pid kill (recycled-pid guard): fetch
336
+ // the command lines of every recorded child pid in ONE batched query,
337
+ // then let the pure decision function verify each live child's identity
338
+ // against what was recorded at spawn. A pid whose command line can't be
339
+ // fetched is treated as unverifiable and never killed by pid — the
340
+ // marker-search fallback may still catch it.
341
+ const candidatePids = registry.flatMap((instance) => instance.lspChildren.map((child) => child.pid));
342
+ const cmdlines = await queryCommandLines(candidatePids);
343
+ const matchProcess = buildIdentityMatcher(cmdlines);
344
+ const decision = decideOrphanReaping(registry, realIsPidAlive, matchProcess);
345
+ let killedCount = 0;
346
+ const killedServerIds = [];
347
+ for (const child of decision.childrenToKill) {
348
+ await killPidTree(child.pid);
349
+ killedCount++;
350
+ killedServerIds.push(child.serverId);
351
+ }
352
+ for (const search of decision.markerSearches) {
353
+ try {
354
+ const pids = await findPidsByMarkerWindows(search.marker);
355
+ for (const pid of pids) {
356
+ await killPidTree(pid);
357
+ killedCount++;
358
+ killedServerIds.push(search.serverId);
359
+ }
360
+ }
361
+ catch {
362
+ // best-effort — a failed marker search just misses that orphan this sweep
363
+ }
364
+ }
365
+ if (decision.deadInstances.length > 0) {
366
+ try {
367
+ const deadPids = new Set(decision.deadInstances.map((i) => i.pid));
368
+ await pruneDeadInstances(deadPids);
369
+ }
370
+ catch {
371
+ // best-effort — a stale registry entry is re-evaluated next sweep
372
+ }
373
+ }
374
+ try {
375
+ logLatency({
376
+ type: "phase",
377
+ phase: "orphan_lsp_reaped",
378
+ filePath: "",
379
+ durationMs: Date.now() - startedAt,
380
+ metadata: {
381
+ deadInstances: decision.deadInstances.length,
382
+ killed: killedCount,
383
+ serverIds: killedServerIds,
384
+ markerSearches: decision.markerSearches.length,
385
+ },
386
+ });
387
+ }
388
+ catch {
389
+ // best-effort logging only
390
+ }
391
+ }
392
+ catch {
393
+ // The sweep must never throw out of session_start.
394
+ }
395
+ }
396
+ /** Drop dead-parent instances from the registry. Re-reads immediately before
397
+ * writing (rather than reusing the earlier `readInstanceRegistry()` snapshot)
398
+ * to narrow — not eliminate — the last-writer-wins race already accepted for
399
+ * slice 1's read-modify-write model. */
400
+ async function pruneDeadInstances(deadPids) {
401
+ const target = path.join(getGlobalPiLensDir(), "instances.json");
402
+ try {
403
+ const raw = await fs.promises.readFile(target, "utf-8");
404
+ const parsed = JSON.parse(raw);
405
+ if (!parsed || !Array.isArray(parsed.instances))
406
+ return;
407
+ const remaining = parsed.instances.filter((entry) => !deadPids.has(entry.pid));
408
+ if (remaining.length === parsed.instances.length)
409
+ return;
410
+ const tmpPath = `${target}.tmp-${process.pid}`;
411
+ await fs.promises.mkdir(getGlobalPiLensDir(), { recursive: true });
412
+ await fs.promises.writeFile(tmpPath, JSON.stringify({ instances: remaining }), "utf-8");
413
+ await fs.promises.rename(tmpPath, target);
414
+ }
415
+ catch {
416
+ // best-effort
417
+ }
418
+ }