pi-lens 3.8.72 → 3.8.74

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 (210) hide show
  1. package/CHANGELOG.md +1162 -0
  2. package/README.md +45 -28
  3. package/dist/clients/atomic-write.js +3 -3
  4. package/dist/clients/bus-events-logger.js +5 -1
  5. package/dist/clients/cache/rule-cache.js +43 -2
  6. package/dist/clients/cache-observability.js +211 -0
  7. package/dist/clients/call-graph.js +20 -0
  8. package/dist/clients/cascade-logger.js +5 -1
  9. package/dist/clients/complexity-client.js +4 -7
  10. package/dist/clients/dependency-checker.js +151 -20
  11. package/dist/clients/deps/js-yaml.js +1 -1
  12. package/dist/clients/diagnostic-dispositions.js +22 -2
  13. package/dist/clients/dispatch/auxiliary-lsp.js +34 -0
  14. package/dist/clients/dispatch/dispatcher.js +16 -1
  15. package/dist/clients/dispatch/fact-store.js +11 -0
  16. package/dist/clients/dispatch/facts/function-facts.js +14 -0
  17. package/dist/clients/dispatch/facts/import-facts.js +100 -101
  18. package/dist/clients/dispatch/facts/tree-sitter-facts.js +5 -8
  19. package/dist/clients/dispatch/facts/try-catch-facts.js +13 -0
  20. package/dist/clients/dispatch/integration.js +94 -15
  21. package/dist/clients/dispatch/rules/async-noise.js +1 -0
  22. package/dist/clients/dispatch/rules/unsafe-boundary.js +7 -0
  23. package/dist/clients/dispatch/runners/ast-grep-napi.js +84 -8
  24. package/dist/clients/dispatch/runners/psscriptanalyzer.js +10 -1
  25. package/dist/clients/dispatch/runners/tree-sitter.js +153 -166
  26. package/dist/clients/dispatch/runners/utils/runner-helpers.js +17 -18
  27. package/dist/clients/feature-hints.js +53 -7
  28. package/dist/clients/file-kinds.js +86 -31
  29. package/dist/clients/fix-worklog.js +14 -2
  30. package/dist/clients/generated-artifacts.js +21 -0
  31. package/dist/clients/gzip-stage-write.js +84 -0
  32. package/dist/clients/installer/index.js +358 -129
  33. package/dist/clients/instance-registry.js +12 -1
  34. package/dist/clients/knip-client.js +64 -1
  35. package/dist/clients/language-profile.js +61 -72
  36. package/dist/clients/latency-logger.js +6 -16
  37. package/dist/clients/lens-config.js +128 -165
  38. package/dist/clients/lens-engine.js +39 -5
  39. package/dist/clients/lens-flag-registry.js +285 -0
  40. package/dist/clients/lens-map.js +2 -7
  41. package/dist/clients/log-cleanup.js +6 -17
  42. package/dist/clients/lsp/cascade-tier.js +22 -2
  43. package/dist/clients/lsp/client.js +74 -10
  44. package/dist/clients/lsp/config.js +62 -9
  45. package/dist/clients/lsp/index.js +113 -31
  46. package/dist/clients/lsp/language.js +4 -1
  47. package/dist/clients/lsp/launch.js +8 -6
  48. package/dist/clients/lsp/server.js +84 -29
  49. package/dist/clients/lsp-document-symbols.js +112 -0
  50. package/dist/clients/mcp/analyze.js +5 -4
  51. package/dist/clients/mcp/review.js +36 -3
  52. package/dist/clients/module-report.js +100 -80
  53. package/dist/clients/ndjson-logger.js +51 -21
  54. package/dist/clients/path-keyed-map.js +76 -0
  55. package/dist/clients/path-utils.js +35 -0
  56. package/dist/clients/performance-report.js +327 -0
  57. package/dist/clients/pipeline.js +7 -0
  58. package/dist/clients/project-changes.js +77 -4
  59. package/dist/clients/project-diagnostics/extractors.js +26 -115
  60. package/dist/clients/project-diagnostics/fresh-fetch.js +149 -19
  61. package/dist/clients/project-diagnostics/runner-adapters/call-graph-impact.js +109 -0
  62. package/dist/clients/project-diagnostics/runner-adapters/runner-findings.js +37 -5
  63. package/dist/clients/project-diagnostics/scanner.js +247 -127
  64. package/dist/clients/project-lens-config.js +53 -26
  65. package/dist/clients/project-report.js +39 -14
  66. package/dist/clients/project-scale.js +8 -10
  67. package/dist/clients/project-snapshot-persist-worker.js +6 -0
  68. package/dist/clients/project-snapshot.js +611 -7
  69. package/dist/clients/quiet-window.js +9 -1
  70. package/dist/clients/read-expansion.js +22 -12
  71. package/dist/clients/read-guard.js +78 -0
  72. package/dist/clients/redact/secrets.js +449 -0
  73. package/dist/clients/resource-sampler.js +133 -14
  74. package/dist/clients/reverse-deps.js +48 -0
  75. package/dist/clients/review-graph/builder.js +1522 -179
  76. package/dist/clients/review-graph/persist-worker.js +10 -0
  77. package/dist/clients/review-graph/query.js +5 -0
  78. package/dist/clients/review-graph/shared-extraction-ir.js +90 -0
  79. package/dist/clients/review-graph-logger.js +24 -0
  80. package/dist/clients/runtime-agent-end.js +4 -2
  81. package/dist/clients/runtime-coordinator.js +2 -2
  82. package/dist/clients/runtime-session.js +433 -59
  83. package/dist/clients/runtime-tool-call.js +46 -14
  84. package/dist/clients/runtime-turn.js +97 -26
  85. package/dist/clients/safe-spawn.js +102 -15
  86. package/dist/clients/session-start-observability.js +15 -0
  87. package/dist/clients/session-state-store.js +2 -1
  88. package/dist/clients/sessionstart-logger.js +18 -0
  89. package/dist/clients/sg-runner.js +48 -33
  90. package/dist/clients/source-filter.js +49 -25
  91. package/dist/clients/startup-marker.js +6 -0
  92. package/dist/clients/startup-timing.js +9 -0
  93. package/dist/clients/todo-scanner.js +30 -3
  94. package/dist/clients/tree-sitter-cache.js +147 -139
  95. package/dist/clients/tree-sitter-client.js +1340 -67
  96. package/dist/clients/tree-sitter-logger.js +43 -1
  97. package/dist/clients/tree-sitter-query-loader.js +110 -11
  98. package/dist/clients/tree-sitter-shared.js +43 -19
  99. package/dist/clients/tree-sitter-symbol-extractor.js +71 -4
  100. package/dist/clients/widget-state.js +50 -14
  101. package/dist/clients/word-index-logger.js +44 -0
  102. package/dist/clients/word-index.js +252 -35
  103. package/dist/clients/workspace-topology.js +11 -1
  104. package/dist/index.js +45976 -39387
  105. package/dist/mcp/analyze-cli.js +3 -0
  106. package/dist/mcp/cli.js +110 -0
  107. package/dist/mcp/server.js +63 -8
  108. package/dist/tools/lens-diagnostics.js +47 -5
  109. package/dist/tools/lsp-diagnostics.js +126 -9
  110. package/dist/tools/lsp-navigation.js +5 -28
  111. package/dist/tools/symbol-search.js +21 -2
  112. package/docs/agent-guide.md +247 -0
  113. package/docs/astplayground.md +3 -4
  114. package/docs/audit1.md +5 -5
  115. package/docs/environment-variables.md +153 -11
  116. package/docs/features.md +4 -3
  117. package/docs/globalconfig.md +56 -4
  118. package/docs/language-coverage.md +2 -2
  119. package/docs/mcp.md +17 -16
  120. package/docs/servercapabilities.md +5 -2
  121. package/docs/settings.md +181 -0
  122. package/docs/tools.md +14 -0
  123. package/docs/tree-sitter_rules_catalog.md +11 -31
  124. package/docs/usage.md +13 -0
  125. package/package.json +139 -139
  126. package/rules/ast-grep-rules/rule-tests/hardcoded-url-js-test.yml +1 -0
  127. package/rules/ast-grep-rules/rule-tests/hardcoded-url-test.yml +2 -0
  128. package/rules/ast-grep-rules/rule-tests/no-case-declarations-js-test.yml +10 -0
  129. package/rules/ast-grep-rules/rule-tests/no-case-declarations-test.yml +10 -0
  130. package/rules/ast-grep-rules/rule-tests/no-javascript-url-js-test.yml +10 -0
  131. package/rules/ast-grep-rules/rule-tests/no-javascript-url-test.yml +10 -0
  132. package/rules/ast-grep-rules/rule-tests/strict-equality-js-test.yml +3 -0
  133. package/rules/ast-grep-rules/rule-tests/strict-equality-test.yml +3 -0
  134. package/rules/ast-grep-rules/rule-tests/strict-inequality-js-test.yml +3 -0
  135. package/rules/ast-grep-rules/rule-tests/strict-inequality-test.yml +4 -0
  136. package/rules/ast-grep-rules/rules/hardcoded-url-js.yml +22 -4
  137. package/rules/ast-grep-rules/rules/hardcoded-url.yml +22 -4
  138. package/rules/ast-grep-rules/rules/no-case-declarations-js.yml +4 -3
  139. package/rules/ast-grep-rules/rules/no-case-declarations.yml +4 -3
  140. package/rules/ast-grep-rules/rules/no-console-except-error-js.yml +10 -0
  141. package/rules/ast-grep-rules/rules/no-console-except-error.yml +10 -0
  142. package/rules/ast-grep-rules/rules/no-javascript-url-js.yml +42 -6
  143. package/rules/ast-grep-rules/rules/no-javascript-url.yml +42 -6
  144. package/rules/ast-grep-rules/rules/strict-equality-js.yml +14 -1
  145. package/rules/ast-grep-rules/rules/strict-equality.yml +14 -1
  146. package/rules/ast-grep-rules/rules/strict-inequality-js.yml +14 -1
  147. package/rules/ast-grep-rules/rules/strict-inequality.yml +14 -1
  148. package/rules/tree-sitter-queries/cpp/no-memset-sensitive-data.yml +9 -3
  149. package/rules/tree-sitter-queries/cpp/no-scoped-lock-without-args.yml +6 -6
  150. package/rules/tree-sitter-queries/css/calc-spacing.yml +8 -6
  151. package/rules/tree-sitter-queries/go/go-mutex-copy.yml +21 -18
  152. package/rules/tree-sitter-queries/go/go-shared-map-write-goroutine.yml +3 -3
  153. package/rules/tree-sitter-queries/java/infinite-loop-java.yml +3 -3
  154. package/rules/tree-sitter-queries/java/junit-call-super.yml +6 -4
  155. package/rules/tree-sitter-queries/java/no-double-checked-locking.yml +10 -8
  156. package/rules/tree-sitter-queries/java/no-field-shadowing.yml +1 -1
  157. package/rules/tree-sitter-queries/java/prepared-statement-valid-indices.yml +2 -3
  158. package/rules/tree-sitter-queries/java/resources-closed.yml +2 -2
  159. package/rules/tree-sitter-queries/java/springboot-default-package.yml +6 -3
  160. package/rules/tree-sitter-queries/java/switch-fall-through.yml +6 -4
  161. package/rules/tree-sitter-queries/java/switch-non-case-labels.yml +4 -3
  162. package/rules/tree-sitter-queries/java/tests-include-assertions.yml +6 -5
  163. package/rules/tree-sitter-queries/javascript/switch-case-termination-js.yml +5 -8
  164. package/rules/tree-sitter-queries/javascript/switch-non-case-labels.yml +3 -2
  165. package/rules/tree-sitter-queries/kotlin/prepared-statement-indices.yml +9 -5
  166. package/rules/tree-sitter-queries/php/this-in-static-context.yml +5 -5
  167. package/rules/tree-sitter-queries/python/exit-signature-check.yml +2 -9
  168. package/rules/tree-sitter-queries/python/in-operator-unsupported.yml +3 -4
  169. package/rules/tree-sitter-queries/python/no-super-torchscript.yml +4 -8
  170. package/rules/tree-sitter-queries/python/notimplemented-boolean-context.yml +1 -5
  171. package/rules/tree-sitter-queries/python/python-empty-except.yml +1 -1
  172. package/rules/tree-sitter-queries/python/return-in-init.yml +0 -2
  173. package/rules/tree-sitter-queries/python/yield-return-outside-function.yml +0 -3
  174. package/rules/tree-sitter-queries/ruby/ruby-command-injection.yml +1 -12
  175. package/rules/tree-sitter-queries/ruby/ruby-eval.yml +1 -1
  176. package/rules/tree-sitter-queries/ruby/ruby-insecure-deserialization.yml +4 -4
  177. package/rules/tree-sitter-queries/ruby/ruby-insecure-random.yml +4 -11
  178. package/rules/tree-sitter-queries/ruby/ruby-open-struct.yml +1 -1
  179. package/rules/tree-sitter-queries/ruby/ruby-string-eval.yml +2 -3
  180. package/rules/tree-sitter-queries/ruby/ruby-weak-hash.yml +4 -4
  181. package/rules/tree-sitter-queries/rust/rust-lock-held-across-await.yml +4 -3
  182. package/rules/tree-sitter-queries/typescript/console-statement.yml +11 -0
  183. package/rules/tree-sitter-queries/typescript/duplicate-function-arg.yml +4 -9
  184. package/rules/tree-sitter-queries/typescript/empty-switch-case.yml +13 -9
  185. package/rules/tree-sitter-queries/typescript/infinite-loop.yml +2 -2
  186. package/rules/tree-sitter-queries/typescript/mixed-async-styles.yml +10 -1
  187. package/rules/tree-sitter-queries/typescript/no-equality-in-for-condition.yml +1 -0
  188. package/rules/tree-sitter-queries/typescript/no-jump-in-finally.yml +1 -0
  189. package/rules/tree-sitter-queries/typescript/switch-case-termination.yml +5 -8
  190. package/rules/tree-sitter-queries/typescript/ts-insecure-random.yml +7 -10
  191. package/rules/tree-sitter-queries/typescript/unsafe-regex.yml +4 -2
  192. package/rules/typos/_typos.toml +17 -0
  193. package/skills/pi-lens-write-tree-sitter-rule/SKILL.md +5 -0
  194. package/dist/clients/config-enabled-shape.js +0 -39
  195. package/docs/env_variables.md +0 -10
  196. package/rules/tree-sitter-queries/abap-disabled/delete-where.yml +0 -46
  197. package/rules/tree-sitter-queries/cpp/no-confused-move-forward.yml +0 -59
  198. package/rules/tree-sitter-queries/cpp/noexcept-functions.yml +0 -58
  199. package/rules/tree-sitter-queries/java/mockito-initialized.yml +0 -66
  200. package/rules/tree-sitter-queries/java/no-wait-notify-on-thread.yml +0 -58
  201. package/rules/tree-sitter-queries/java/short-circuit-logic.yml +0 -57
  202. package/rules/tree-sitter-queries/java/spring-session-attributes-setcomplete.yml +0 -75
  203. package/rules/tree-sitter-queries/plsql-disabled/delete-update-where.yml +0 -56
  204. package/rules/tree-sitter-queries/plsql-disabled/end-loop-semicolon.yml +0 -51
  205. package/rules/tree-sitter-queries/plsql-disabled/fetch-bulk-collect-limit.yml +0 -47
  206. package/rules/tree-sitter-queries/plsql-disabled/forallsave-exceptions.yml +0 -51
  207. package/rules/tree-sitter-queries/plsql-disabled/nchar-nvarchar2-bytes.yml +0 -54
  208. package/rules/tree-sitter-queries/plsql-disabled/not-null-initialization.yml +0 -59
  209. package/rules/tree-sitter-queries/plsql-disabled/raise-application-error-codes.yml +0 -50
  210. package/rules/tree-sitter-queries/typescript-disabled/ts-path-traversal.yml +0 -71
@@ -46,7 +46,7 @@
46
46
  * - GitHub releases (platform-specific binaries → ~/.pi-lens/bin/)
47
47
  */
48
48
  import { spawn } from "node:child_process";
49
- import { existsSync, statSync } from "node:fs";
49
+ import { existsSync, statSync, unlinkSync } from "node:fs";
50
50
  import fs from "node:fs/promises";
51
51
  import https from "node:https";
52
52
  import { createRequire } from "node:module";
@@ -54,17 +54,116 @@ import os from "node:os";
54
54
  import path from "node:path";
55
55
  const _installerRequire = createRequire(import.meta.url);
56
56
  import { createGunzip } from "node:zlib";
57
- import { isTestMode } from "../env-utils.js";
57
+ import { logSessionStart } from "../sessionstart-logger.js";
58
58
  import { getGlobalPiLensDir } from "../file-utils.js";
59
59
  import { allAvailableGlobalBinDirs, installArgs, pmBinary, resolveNodePackageManager, } from "../package-manager.js";
60
+ import { safeSpawnAsync } from "../safe-spawn.js";
60
61
  // Global installation directory for pi-lens tools
61
62
  const TOOLS_DIR = path.join(getGlobalPiLensDir(), "tools");
63
+ const INSTALL_LOCK_PATH = path.join(TOOLS_DIR, ".install.lock");
64
+ const activeInstallLocks = new Set();
65
+ let installLockExitCleanupRegistered = false;
66
+ function isProcessAlive(pid) {
67
+ try {
68
+ process.kill(pid, 0);
69
+ return true;
70
+ }
71
+ catch (error) {
72
+ return error.code === "EPERM";
73
+ }
74
+ }
75
+ async function acquireInstallLock() {
76
+ await fs.mkdir(TOOLS_DIR, { recursive: true });
77
+ // #946 review F2: the waiter's bound must exceed the owner's install bound
78
+ // (PI_LENS_INSTALL_TIMEOUT_MS, default 120s) — a 30s waiter gave up on a
79
+ // legitimate slow install and reported the tool unavailable for the whole
80
+ // session even though it arrived seconds later.
81
+ const timeoutMs = Number(process.env.PI_LENS_INSTALL_LOCK_TIMEOUT_MS) || 150_000;
82
+ const deadline = Date.now() + timeoutMs;
83
+ let lastOwner = "unknown owner";
84
+ while (Date.now() < deadline) {
85
+ try {
86
+ const handle = await fs.open(INSTALL_LOCK_PATH, "wx");
87
+ await handle.writeFile(JSON.stringify({ pid: process.pid, createdAt: Date.now() }));
88
+ await handle.close();
89
+ activeInstallLocks.add(INSTALL_LOCK_PATH);
90
+ if (!installLockExitCleanupRegistered) {
91
+ installLockExitCleanupRegistered = true;
92
+ process.once("exit", () => {
93
+ for (const lockPath of activeInstallLocks) {
94
+ try {
95
+ unlinkSync(lockPath);
96
+ }
97
+ catch {
98
+ // Best effort; the next owner verifies this PID is dead.
99
+ }
100
+ }
101
+ });
102
+ }
103
+ let released = false;
104
+ return {
105
+ release: async () => {
106
+ if (released)
107
+ return;
108
+ released = true;
109
+ activeInstallLocks.delete(INSTALL_LOCK_PATH);
110
+ await fs.rm(INSTALL_LOCK_PATH, { force: true });
111
+ },
112
+ };
113
+ }
114
+ catch (error) {
115
+ if (error.code !== "EEXIST")
116
+ throw error;
117
+ try {
118
+ const owner = JSON.parse(await fs.readFile(INSTALL_LOCK_PATH, "utf8"));
119
+ lastOwner = `pid=${owner.pid} createdAt=${owner.createdAt}`;
120
+ // #946 review F1: PID liveness alone cannot detect a hard-killed
121
+ // owner whose PID Windows has recycled — that lock would poison
122
+ // every future install with a full-timeout wait. A lock older
123
+ // than any legitimate install (owner install bound + slack) is
124
+ // stale regardless of what the PID now points at.
125
+ const maxAgeMs = (Number(process.env.PI_LENS_INSTALL_TIMEOUT_MS) || 120_000) +
126
+ 60_000;
127
+ const expired = Number.isFinite(owner.createdAt) &&
128
+ Date.now() - owner.createdAt > maxAgeMs;
129
+ if (expired ||
130
+ (Number.isInteger(owner.pid) &&
131
+ owner.pid > 0 &&
132
+ !isProcessAlive(owner.pid))) {
133
+ await fs.rm(INSTALL_LOCK_PATH, { force: true });
134
+ continue;
135
+ }
136
+ }
137
+ catch (readError) {
138
+ lastOwner = "unreadable owner";
139
+ // An unreadable/empty lock has no createdAt to age out — fall
140
+ // back to the file's own mtime for the max-age check so it is
141
+ // eventually recoverable (#946 review F1/F6).
142
+ try {
143
+ const stat = await fs.stat(INSTALL_LOCK_PATH);
144
+ const maxAgeMs = (Number(process.env.PI_LENS_INSTALL_TIMEOUT_MS) || 120_000) +
145
+ 60_000;
146
+ if (Date.now() - stat.mtimeMs > maxAgeMs) {
147
+ await fs.rm(INSTALL_LOCK_PATH, { force: true });
148
+ continue;
149
+ }
150
+ }
151
+ catch {
152
+ // stat raced a release — loop and retry acquisition.
153
+ }
154
+ void readError;
155
+ }
156
+ await new Promise((resolve) => setTimeout(resolve, 100));
157
+ }
158
+ }
159
+ return {
160
+ reason: `timed out after ${timeoutMs}ms waiting for shared tools install lock (${lastOwner})`,
161
+ };
162
+ }
62
163
  // Directory for GitHub-downloaded binaries
63
164
  const GITHUB_BIN_DIR = path.join(getGlobalPiLensDir(), "bin");
64
165
  // Debug flag - set via PI_LENS_DEBUG=1 or --debug
65
166
  const DEBUG = process.env.PI_LENS_DEBUG === "1" || process.argv.includes("--debug");
66
- const SESSIONSTART_LOG_DIR = getGlobalPiLensDir();
67
- const SESSIONSTART_LOG = path.join(SESSIONSTART_LOG_DIR, "sessionstart.log");
68
167
  /**
69
168
  * Log debug messages only when DEBUG is enabled
70
169
  */
@@ -73,18 +172,6 @@ function debugLog(...args) {
73
172
  console.error("[auto-install:debug]", ...args);
74
173
  }
75
174
  }
76
- function logSessionStart(msg) {
77
- if (isTestMode()) {
78
- return;
79
- }
80
- const line = `[${new Date().toISOString()}] ${msg}\n`;
81
- void fs
82
- .mkdir(SESSIONSTART_LOG_DIR, { recursive: true })
83
- .then(() => fs.appendFile(SESSIONSTART_LOG, line))
84
- .catch(() => {
85
- // best-effort logging
86
- });
87
- }
88
175
  /**
89
176
  * Build a GitHub-release `assetMatch` from a small per-platform table, replacing
90
177
  * the copy-pasted `if (platform === "linux") return arch === "arm64" ? … : …`
@@ -257,6 +344,24 @@ export const TOOLS = [
257
344
  packageName: "bash-language-server",
258
345
  binaryName: "bash-language-server",
259
346
  },
347
+ {
348
+ id: "fish-lsp",
349
+ name: "Fish Language Server",
350
+ checkCommand: "fish-lsp",
351
+ checkArgs: ["--version"],
352
+ installStrategy: "npm",
353
+ packageName: "fish-lsp",
354
+ binaryName: "fish-lsp",
355
+ },
356
+ {
357
+ id: "cmake-language-server",
358
+ name: "CMake Language Server",
359
+ checkCommand: "cmake-language-server",
360
+ checkArgs: ["--version"],
361
+ installStrategy: "pip",
362
+ packageName: "cmake-language-server",
363
+ binaryName: "cmake-language-server",
364
+ },
260
365
  {
261
366
  id: "yaml-language-server",
262
367
  name: "YAML Language Server",
@@ -1110,6 +1215,11 @@ export const TOOLS = [
1110
1215
  },
1111
1216
  ];
1112
1217
  const ensureInFlight = new Map();
1218
+ const installFailureReasons = new Map();
1219
+ /** Last honest install refusal/failure reason for callers that need diagnostics. */
1220
+ export function getInstallFailureReason(toolId) {
1221
+ return installFailureReasons.get(toolId);
1222
+ }
1113
1223
  // Session-lifetime cache: once a tool path is resolved, skip the process-spawn check on subsequent calls.
1114
1224
  const resolvedPathCache = new Map();
1115
1225
  const PROBE_CACHE_PATH = path.join(getGlobalPiLensDir(), "probe-cache.json");
@@ -1133,26 +1243,61 @@ function scheduleProbeFlush() {
1133
1243
  if (_probeCacheFlushTimer !== null)
1134
1244
  return;
1135
1245
  _probeCacheFlushTimer = setTimeout(() => {
1136
- _probeCacheFlushTimer = null;
1137
- if (!_probeCacheDirty || _probeCache === null)
1138
- return;
1139
- _probeCacheDirty = false;
1140
- void fs
1141
- .writeFile(PROBE_CACHE_PATH, JSON.stringify(_probeCache, null, 2))
1142
- .catch(() => { });
1246
+ void flushProbeCache();
1143
1247
  }, 300);
1144
1248
  _probeCacheFlushTimer.unref?.();
1145
1249
  }
1250
+ let _probeCacheWriteInFlight = null;
1251
+ /** Await pending probe-cache persistence before a one-shot process exits. */
1252
+ export async function flushProbeCache() {
1253
+ if (_probeCacheFlushTimer !== null) {
1254
+ clearTimeout(_probeCacheFlushTimer);
1255
+ _probeCacheFlushTimer = null;
1256
+ }
1257
+ // #946 review F4: if the 300ms timer's write already started, the dirty
1258
+ // flag is false but the write may still be in flight — an immediate
1259
+ // process.exit would truncate it. Always await the in-flight write.
1260
+ if (!_probeCacheDirty || _probeCache === null) {
1261
+ await _probeCacheWriteInFlight;
1262
+ return;
1263
+ }
1264
+ _probeCacheDirty = false;
1265
+ const write = (async () => {
1266
+ try {
1267
+ await fs.writeFile(PROBE_CACHE_PATH, JSON.stringify(_probeCache, null, 2));
1268
+ }
1269
+ catch {
1270
+ // Cache persistence is best effort; discovery remains authoritative.
1271
+ }
1272
+ })();
1273
+ _probeCacheWriteInFlight = write;
1274
+ try {
1275
+ await write;
1276
+ }
1277
+ finally {
1278
+ if (_probeCacheWriteInFlight === write)
1279
+ _probeCacheWriteInFlight = null;
1280
+ }
1281
+ }
1146
1282
  function isAstGrepVersionOutput(output) {
1147
1283
  return /\bast[- ]grep\b/i.test(output);
1148
1284
  }
1149
1285
  async function verifyAstGrepProbePath(binPath) {
1150
1286
  return new Promise((resolve) => {
1151
- const proc = spawn(binPath, ["--version"], {
1152
- stdio: ["ignore", "pipe", "pipe"],
1153
- shell: process.platform === "win32" && /\.(cmd|bat)$/i.test(binPath),
1154
- timeout: 5000,
1155
- });
1287
+ let proc;
1288
+ try {
1289
+ proc = spawn(binPath, ["--version"], {
1290
+ stdio: ["ignore", "pipe", "pipe"],
1291
+ shell: process.platform === "win32" && /\.(cmd|bat)$/i.test(binPath),
1292
+ timeout: 5000,
1293
+ });
1294
+ }
1295
+ catch {
1296
+ // SYNCHRONOUS spawn throw (Windows `spawn UNKNOWN`/EINVAL, the pidusage
1297
+ // bug class, #533) — best-effort probe, resolve rather than reject.
1298
+ resolve(false);
1299
+ return;
1300
+ }
1156
1301
  let output = "";
1157
1302
  proc.stdout?.on("data", (data) => (output += data));
1158
1303
  proc.stderr?.on("data", (data) => (output += data));
@@ -1225,6 +1370,7 @@ export function resetProbeCacheStateForTesting() {
1225
1370
  _probeCacheDirty = false;
1226
1371
  resolvedPathCache.clear();
1227
1372
  ensureInFlight.clear();
1373
+ installFailureReasons.clear();
1228
1374
  lastManagedInstallVersion.clear();
1229
1375
  if (_probeCacheFlushTimer !== null) {
1230
1376
  clearTimeout(_probeCacheFlushTimer);
@@ -1238,7 +1384,7 @@ export function resetProbeCacheStateForTesting() {
1238
1384
  * Catches broken symlinks (stat throws ENOENT or returns size 0) without
1239
1385
  * spawning a process — ~μs per candidate vs ~50ms for which/where.
1240
1386
  */
1241
- async function isCommandAvailable(command, _args) {
1387
+ export async function isCommandAvailable(command, _args) {
1242
1388
  const isWindows = process.platform === "win32";
1243
1389
  const pathEnv = process.env.PATH || process.env.Path || process.env.path || "";
1244
1390
  const dirs = pathEnv.split(path.delimiter);
@@ -1266,6 +1412,23 @@ async function isCommandAvailable(command, _args) {
1266
1412
  }
1267
1413
  return false;
1268
1414
  }
1415
+ /**
1416
+ * Can `command` even be spawned? Path-bearing commands stat the file directly;
1417
+ * bare names walk PATH. ~μs either way — callers use this to skip a `--version`
1418
+ * probe that is guaranteed to fail with a full spawn round-trip.
1419
+ */
1420
+ export async function isSpawnableCommand(command) {
1421
+ if (/[\\/]/.test(command)) {
1422
+ try {
1423
+ const stat = statSync(command);
1424
+ return stat.isFile() && stat.size > 0;
1425
+ }
1426
+ catch {
1427
+ return false;
1428
+ }
1429
+ }
1430
+ return isCommandAvailable(command);
1431
+ }
1269
1432
  // --- Verification Functions
1270
1433
  /**
1271
1434
  * Stdio LSP servers built on `vscode-languageserver-node` (the entire
@@ -1341,11 +1504,21 @@ async function verifyToolBinary(binPath, onVersionOutput) {
1341
1504
  }
1342
1505
  // When shell:true (Windows .cmd), bake args into the command string to avoid DEP0190.
1343
1506
  const spawnCmd = useShell ? `"${execPath}" --version` : execPath;
1344
- const proc = spawn(spawnCmd, useShell ? [] : ["--version"], {
1345
- timeout: 10000,
1346
- stdio: ["ignore", "pipe", "pipe"],
1347
- shell: useShell,
1348
- });
1507
+ let proc;
1508
+ try {
1509
+ proc = spawn(spawnCmd, useShell ? [] : ["--version"], {
1510
+ timeout: 10000,
1511
+ stdio: ["ignore", "pipe", "pipe"],
1512
+ shell: useShell,
1513
+ });
1514
+ }
1515
+ catch (err) {
1516
+ // SYNCHRONOUS spawn throw (Windows `spawn UNKNOWN`/EINVAL, the pidusage
1517
+ // bug class, #533) — best-effort verify, resolve rather than reject.
1518
+ logSessionStart(`auto-install verify: spawn threw for ${binPath} (${err instanceof Error ? err.message : String(err)})`);
1519
+ resolve(false);
1520
+ return;
1521
+ }
1349
1522
  let stdout = "";
1350
1523
  let stderr = "";
1351
1524
  proc.stdout?.on("data", (data) => (stdout += data));
@@ -1405,11 +1578,20 @@ export async function getAllToolStatuses() {
1405
1578
  status.path = tool.checkCommand;
1406
1579
  // Try to get version
1407
1580
  const versionResult = await new Promise((resolve) => {
1408
- const proc = spawn(tool.checkCommand, ["--version"], {
1409
- stdio: ["ignore", "pipe", "pipe"],
1410
- shell: process.platform === "win32",
1411
- timeout: 5000,
1412
- });
1581
+ let proc;
1582
+ try {
1583
+ proc = spawn(tool.checkCommand, ["--version"], {
1584
+ stdio: ["ignore", "pipe", "pipe"],
1585
+ shell: process.platform === "win32",
1586
+ timeout: 5000,
1587
+ });
1588
+ }
1589
+ catch {
1590
+ // SYNCHRONOUS spawn throw (Windows `spawn UNKNOWN`/EINVAL, the
1591
+ // pidusage bug class, #533) — best-effort, resolve empty version.
1592
+ resolve("");
1593
+ return;
1594
+ }
1413
1595
  let out = "";
1414
1596
  proc.stdout?.on("data", (d) => (out += d));
1415
1597
  proc.stderr?.on("data", (d) => (out += d));
@@ -1849,10 +2031,19 @@ async function getPythonUserBaseCandidates() {
1849
2031
  const spawnCmd = isWin
1850
2032
  ? [probe.command, ...probe.args].join(" ")
1851
2033
  : probe.command;
1852
- const proc = spawn(spawnCmd, isWin ? [] : probe.args, {
1853
- stdio: ["ignore", "pipe", "pipe"],
1854
- shell: isWin,
1855
- });
2034
+ let proc;
2035
+ try {
2036
+ proc = spawn(spawnCmd, isWin ? [] : probe.args, {
2037
+ stdio: ["ignore", "pipe", "pipe"],
2038
+ shell: isWin,
2039
+ });
2040
+ }
2041
+ catch {
2042
+ // SYNCHRONOUS spawn throw (Windows `spawn UNKNOWN`/EINVAL, the
2043
+ // pidusage bug class, #533) — best-effort probe, resolve empty.
2044
+ resolve("");
2045
+ return;
2046
+ }
1856
2047
  let stdout = "";
1857
2048
  proc.stdout?.on("data", (data) => (stdout += data));
1858
2049
  proc.on("exit", (code) => resolve(code === 0 ? stdout.trim() : ""));
@@ -1925,11 +2116,20 @@ function httpsGet(url, maxRedirects = 5, headers = {}) {
1925
2116
  */
1926
2117
  function runCommand(command, args, cwd) {
1927
2118
  return new Promise((resolve) => {
1928
- const proc = spawn(command, args, {
1929
- cwd,
1930
- stdio: "ignore",
1931
- shell: process.platform === "win32",
1932
- });
2119
+ let proc;
2120
+ try {
2121
+ proc = spawn(command, args, {
2122
+ cwd,
2123
+ stdio: "ignore",
2124
+ shell: process.platform === "win32",
2125
+ });
2126
+ }
2127
+ catch {
2128
+ // SYNCHRONOUS spawn throw (Windows `spawn UNKNOWN`/EINVAL, the pidusage
2129
+ // bug class, #533) — resolve false rather than reject/crash.
2130
+ resolve(false);
2131
+ return;
2132
+ }
1933
2133
  proc.on("exit", (code) => resolve(code === 0));
1934
2134
  proc.on("error", () => resolve(false));
1935
2135
  });
@@ -2257,23 +2457,16 @@ async function installArchiveTool(tool) {
2257
2457
  const tarBin = isWindows
2258
2458
  ? `${process.env.SystemRoot ?? "C:\\Windows"}\\System32\\tar.exe`
2259
2459
  : "tar";
2260
- const extracted = await new Promise((resolve) => {
2261
- const proc = spawn(tarBin, tarArgs, {
2262
- cwd: TOOLS_DIR,
2263
- stdio: ["ignore", "ignore", "pipe"],
2264
- });
2265
- let stderr = "";
2266
- proc.stderr?.on("data", (d) => (stderr += d));
2267
- const timer = setTimeout(() => {
2268
- proc.kill();
2269
- resolve({ ok: false, stderr: "extraction timed out" });
2270
- }, 120_000);
2271
- proc.on("exit", (code) => {
2272
- clearTimeout(timer);
2273
- resolve({ ok: code === 0, stderr });
2274
- });
2275
- proc.on("error", (err) => resolve({ ok: false, stderr: err.message }));
2460
+ const extractResult = await safeSpawnAsync(tarBin, tarArgs, {
2461
+ cwd: TOOLS_DIR,
2462
+ timeout: 120_000,
2463
+ ignoreAmbientSignal: true,
2464
+ lifetimeCoupled: true,
2276
2465
  });
2466
+ const extracted = {
2467
+ ok: extractResult.status === 0,
2468
+ stderr: extractResult.error?.message ?? extractResult.stderr,
2469
+ };
2277
2470
  await fs.rm(tmpArchive, { force: true });
2278
2471
  if (!extracted.ok) {
2279
2472
  logSessionStart(`archive-install ${tool.id}: extraction failed: ${extracted.stderr}`);
@@ -2346,39 +2539,33 @@ async function installNpmTool(packageName, binaryName) {
2346
2539
  // Resolve the package manager for the tools dir and build install args.
2347
2540
  const isWindows = process.platform === "win32";
2348
2541
  const pm = await resolveNodePackageManager(TOOLS_DIR);
2349
- const pmCommand = pmBinary(pm);
2542
+ const testNpmScript = process.env.PI_LENS_TEST_MODE === "1"
2543
+ ? process.env.PI_LENS_TEST_NPM_SCRIPT
2544
+ : undefined;
2545
+ const pmCommand = testNpmScript ? process.execPath : pmBinary(pm);
2350
2546
  // Use --ignore-scripts unless the package explicitly needs postinstall
2351
2547
  // (e.g. biome downloads a platform-specific native binary via postinstall).
2352
2548
  const needsScripts = NEEDS_POSTINSTALL.has(packageName);
2353
2549
  const baseInstallArgs = installArgs(pm, packageName, {
2354
2550
  ignoreScripts: !needsScripts,
2355
2551
  });
2356
- const INSTALL_TIMEOUT_MS = 120_000;
2357
- const runInstallAttempt = async (args) => new Promise((resolve) => {
2358
- const proc = spawn(pmCommand, args, {
2552
+ const INSTALL_TIMEOUT_MS = Number(process.env.PI_LENS_INSTALL_TIMEOUT_MS) || 120_000;
2553
+ const runInstallAttempt = async (args) => {
2554
+ const result = await safeSpawnAsync(pmCommand, args, {
2359
2555
  cwd: TOOLS_DIR,
2360
- stdio: ["ignore", "pipe", "pipe"],
2361
- shell: isWindows, // Required for .cmd files on Windows
2556
+ timeout: INSTALL_TIMEOUT_MS,
2557
+ ignoreAmbientSignal: true,
2558
+ lifetimeCoupled: true,
2362
2559
  });
2363
- let stderr = "";
2364
- proc.stderr?.on("data", (data) => (stderr += data));
2365
- const timer = setTimeout(() => {
2366
- proc.kill();
2367
- resolve({
2368
- ok: false,
2369
- stderr: `install timed out after ${INSTALL_TIMEOUT_MS / 1000}s`,
2370
- });
2371
- }, INSTALL_TIMEOUT_MS);
2372
- proc.on("exit", (code) => {
2373
- clearTimeout(timer);
2374
- resolve({ ok: code === 0, stderr });
2375
- });
2376
- proc.on("error", (err) => {
2377
- clearTimeout(timer);
2378
- resolve({ ok: false, stderr: err.message });
2379
- });
2380
- });
2381
- let outcome = await runInstallAttempt(baseInstallArgs);
2560
+ return {
2561
+ ok: result.status === 0,
2562
+ stderr: result.error?.message ?? result.stderr,
2563
+ };
2564
+ };
2565
+ let outcome = await runInstallAttempt([
2566
+ ...(testNpmScript ? [testNpmScript] : []),
2567
+ ...baseInstallArgs,
2568
+ ]);
2382
2569
  // --legacy-peer-deps is npm-only; retry just npm's ERESOLVE failures.
2383
2570
  const erResolve = outcome.ok === false &&
2384
2571
  /npm\s+error\s+ERESOLVE|\bERESOLVE\b|could not resolve/i.test(outcome.stderr);
@@ -2388,7 +2575,10 @@ async function installNpmTool(packageName, binaryName) {
2388
2575
  legacyPeerDeps: true,
2389
2576
  });
2390
2577
  logSessionStart(`auto-install npm ${packageName}: retry with --legacy-peer-deps after ERESOLVE`);
2391
- outcome = await runInstallAttempt(retryArgs);
2578
+ outcome = await runInstallAttempt([
2579
+ ...(testNpmScript ? [testNpmScript] : []),
2580
+ ...retryArgs,
2581
+ ]);
2392
2582
  }
2393
2583
  if (!outcome.ok) {
2394
2584
  throw new Error(`Failed to install ${packageName}: ${outcome.stderr}`);
@@ -2475,33 +2665,32 @@ async function installPipTool(packageName) {
2475
2665
  ];
2476
2666
  let lastError = "";
2477
2667
  for (const candidate of pipCandidates) {
2478
- const outcome = await new Promise((resolve) => {
2479
- const proc = spawn(candidate.command, candidate.args, {
2480
- stdio: ["ignore", "pipe", "pipe"],
2481
- shell: isWindows, // Required for .cmd files on Windows
2482
- });
2483
- let stderr = "";
2484
- proc.stderr?.on("data", (data) => (stderr += data));
2485
- proc.on("exit", (code) => {
2486
- if (code === 0) {
2487
- resolve({ ok: true, error: "" });
2488
- }
2489
- else {
2490
- resolve({ ok: false, error: stderr.trim() });
2491
- }
2492
- });
2493
- proc.on("error", (err) => {
2494
- resolve({ ok: false, error: err.message });
2495
- });
2668
+ const pipResult = await safeSpawnAsync(candidate.command, candidate.args, {
2669
+ timeout: 120_000,
2670
+ ignoreAmbientSignal: true,
2671
+ lifetimeCoupled: true,
2496
2672
  });
2673
+ const outcome = {
2674
+ ok: pipResult.status === 0,
2675
+ error: (pipResult.error?.message ?? pipResult.stderr).trim(),
2676
+ };
2497
2677
  if (outcome.ok) {
2498
2678
  // Ensure user-level scripts directory is available in current process PATH.
2499
2679
  // This helps tools installed via `pip install --user` become immediately callable.
2500
2680
  const userBaseResult = await new Promise((resolve) => {
2501
- const probe = spawn(candidate.command, ["-m", "site", "--user-base"], {
2502
- stdio: ["ignore", "pipe", "pipe"],
2503
- shell: isWindows,
2504
- });
2681
+ let probe;
2682
+ try {
2683
+ probe = spawn(candidate.command, ["-m", "site", "--user-base"], {
2684
+ stdio: ["ignore", "pipe", "pipe"],
2685
+ shell: isWindows,
2686
+ });
2687
+ }
2688
+ catch {
2689
+ // SYNCHRONOUS spawn throw (Windows `spawn UNKNOWN`/EINVAL, the
2690
+ // pidusage bug class, #533) — best-effort probe, resolve empty.
2691
+ resolve("");
2692
+ return;
2693
+ }
2505
2694
  let stdout = "";
2506
2695
  probe.stdout?.on("data", (data) => (stdout += data));
2507
2696
  probe.on("exit", (code) => {
@@ -2576,21 +2765,15 @@ async function installPipTool(packageName) {
2576
2765
  }
2577
2766
  async function installGemTool(packageName) {
2578
2767
  try {
2579
- const isWindows = process.platform === "win32";
2580
- const outcome = await new Promise((resolve) => {
2581
- const proc = spawn("gem", ["install", packageName, "--no-document"], {
2582
- stdio: ["ignore", "pipe", "pipe"],
2583
- shell: isWindows,
2584
- });
2585
- let stderr = "";
2586
- proc.stderr?.on("data", (data) => (stderr += data));
2587
- proc.on("exit", (code) => {
2588
- resolve({ ok: code === 0, error: stderr.trim() });
2589
- });
2590
- proc.on("error", (err) => {
2591
- resolve({ ok: false, error: err.message });
2592
- });
2768
+ const gemResult = await safeSpawnAsync("gem", ["install", packageName, "--no-document"], {
2769
+ timeout: 120_000,
2770
+ ignoreAmbientSignal: true,
2771
+ lifetimeCoupled: true,
2593
2772
  });
2773
+ const outcome = {
2774
+ ok: gemResult.status === 0,
2775
+ error: (gemResult.error?.message ?? gemResult.stderr).trim(),
2776
+ };
2594
2777
  if (!outcome.ok) {
2595
2778
  throw new Error(`Failed to install ${packageName} via gem: ${outcome.error}`);
2596
2779
  }
@@ -2605,6 +2788,11 @@ async function installGemTool(packageName) {
2605
2788
  * Install a tool by ID
2606
2789
  */
2607
2790
  export async function installTool(toolId) {
2791
+ if (process.env.PI_LENS_DISABLE_TOOL_INSTALL === "1") {
2792
+ installFailureReasons.set(toolId, "installation disabled by PI_LENS_DISABLE_TOOL_INSTALL=1");
2793
+ logSessionStart(`auto-install ${toolId}: refused — PI_LENS_DISABLE_TOOL_INSTALL=1`);
2794
+ return false;
2795
+ }
2608
2796
  const tool = TOOLS.find((t) => t.id === toolId);
2609
2797
  if (!tool) {
2610
2798
  logSessionStart(`auto-install ${toolId}: unknown tool id`);
@@ -2676,6 +2864,7 @@ export async function installTool(toolId) {
2676
2864
  * Ensure a tool is installed (check first, install if missing)
2677
2865
  */
2678
2866
  export async function ensureTool(toolId, opts) {
2867
+ installFailureReasons.delete(toolId);
2679
2868
  const cacheResolvedPath = (result) => {
2680
2869
  if (result) {
2681
2870
  resolvedPathCache.set(toolId, result);
@@ -2706,8 +2895,23 @@ export async function ensureTool(toolId, opts) {
2706
2895
  logSessionStart(`auto-install ensure ${toolId}: force reinstall blocked — install disabled, discovery only (${Date.now() - ensureStartMs}ms)`);
2707
2896
  return cacheResolvedPath(await getToolPath(toolId));
2708
2897
  }
2709
- // Force download
2710
- const installed = await installTool(toolId);
2898
+ if (process.env.PI_LENS_DISABLE_TOOL_INSTALL === "1") {
2899
+ installFailureReasons.set(toolId, "installation disabled by PI_LENS_DISABLE_TOOL_INSTALL=1");
2900
+ logSessionStart(`auto-install ensure ${toolId}: refused — PI_LENS_DISABLE_TOOL_INSTALL=1`);
2901
+ return undefined;
2902
+ }
2903
+ const lock = await acquireInstallLock();
2904
+ if (!lock.release) {
2905
+ logSessionStart(`auto-install ensure ${toolId}: ${lock.reason}`);
2906
+ return undefined;
2907
+ }
2908
+ let installed;
2909
+ try {
2910
+ installed = await installTool(toolId);
2911
+ }
2912
+ finally {
2913
+ await lock.release();
2914
+ }
2711
2915
  if (!installed) {
2712
2916
  logSessionStart(`auto-install ensure ${toolId}: force reinstall failed (${Date.now() - ensureStartMs}ms)`);
2713
2917
  return undefined;
@@ -2783,7 +2987,32 @@ export async function ensureTool(toolId, opts) {
2783
2987
  logSessionStart(`auto-install ensure ${toolId}: install disabled — discovery only, not found (${Date.now() - ensureStartMs}ms)`);
2784
2988
  return undefined;
2785
2989
  }
2786
- const installed = await installTool(toolId);
2990
+ if (process.env.PI_LENS_DISABLE_TOOL_INSTALL === "1") {
2991
+ installFailureReasons.set(toolId, "installation disabled by PI_LENS_DISABLE_TOOL_INSTALL=1");
2992
+ logSessionStart(`auto-install ensure ${toolId}: refused — PI_LENS_DISABLE_TOOL_INSTALL=1 (${Date.now() - ensureStartMs}ms)`);
2993
+ return undefined;
2994
+ }
2995
+ const lock = await acquireInstallLock();
2996
+ if (!lock.release) {
2997
+ installFailureReasons.set(toolId, lock.reason ?? "install lock failed");
2998
+ logSessionStart(`auto-install ensure ${toolId}: ${lock.reason}`);
2999
+ return undefined;
3000
+ }
3001
+ let installed;
3002
+ try {
3003
+ // Cross-process double-check: the lock waiter may now observe the tool
3004
+ // installed by its predecessor and must not run a second package manager.
3005
+ const installedByPeer = await getToolPath(toolId);
3006
+ if (installedByPeer) {
3007
+ resolvedPathCache.set(toolId, installedByPeer);
3008
+ void updateProbeCache(toolId, installedByPeer);
3009
+ return installedByPeer;
3010
+ }
3011
+ installed = await installTool(toolId);
3012
+ }
3013
+ finally {
3014
+ await lock.release();
3015
+ }
2787
3016
  if (!installed) {
2788
3017
  logSessionStart(`auto-install ensure ${toolId}: unavailable (${Date.now() - ensureStartMs}ms)`);
2789
3018
  return undefined;