pi-lens 4.2.1 → 4.4.0

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 (671) hide show
  1. package/CHANGELOG.md +1562 -0
  2. package/README.md +157 -24
  3. package/config/dependency-cruiser-eager-allowlist.json +6 -2
  4. package/dist/chunk-22AVIGXS.js +560 -0
  5. package/dist/chunk-27BBHSZC.js +106 -0
  6. package/dist/chunk-2BEMIWEW.js +51 -0
  7. package/dist/chunk-2EECBNC5.js +89 -0
  8. package/dist/chunk-34U7PLGE.js +23 -0
  9. package/dist/chunk-3I5QFT3N.js +181 -0
  10. package/dist/chunk-3MED75HT.js +37 -0
  11. package/dist/chunk-47D2BO4S.js +87 -0
  12. package/dist/chunk-4RERDEXR.js +348 -0
  13. package/dist/chunk-4W2PLC4J.js +27 -0
  14. package/dist/chunk-52H5B5TC.js +202 -0
  15. package/dist/chunk-54B42YRX.js +27 -0
  16. package/dist/chunk-5RXBNSW2.js +266 -0
  17. package/dist/chunk-5THXD6X5.js +201 -0
  18. package/dist/chunk-5W3R6LFT.js +24 -0
  19. package/dist/chunk-5ZEJHV35.js +341 -0
  20. package/dist/chunk-6P62QVEA.js +118 -0
  21. package/dist/chunk-6QIUX4EP.js +459 -0
  22. package/dist/chunk-6VCD4VKZ.js +78 -0
  23. package/dist/chunk-7F72PXY2.js +326 -0
  24. package/dist/chunk-7FBF7NPR.js +998 -0
  25. package/dist/chunk-7U2IDNXN.js +37 -0
  26. package/dist/chunk-ABBOA7UD.js +157 -0
  27. package/dist/chunk-AHDW3GPE.js +50 -0
  28. package/dist/chunk-AJ3QXTRQ.js +756 -0
  29. package/dist/chunk-AJVLWNFN.js +1610 -0
  30. package/dist/chunk-AJXODZY6.js +112 -0
  31. package/dist/chunk-ARFCXG7S.js +84 -0
  32. package/dist/chunk-AS44CFQ4.js +708 -0
  33. package/dist/chunk-AWE5TMPO.js +38 -0
  34. package/dist/chunk-B3I2UUT4.js +4692 -0
  35. package/dist/chunk-BRJKOBZP.js +331 -0
  36. package/dist/chunk-BW5KFIQC.js +59 -0
  37. package/dist/chunk-C2AUYXQH.js +149 -0
  38. package/dist/chunk-CGGAIP7J.js +29 -0
  39. package/dist/chunk-D6QS3U6M.js +30 -0
  40. package/dist/chunk-DFVMIKMQ.js +349 -0
  41. package/dist/chunk-DIULIWKU.js +169 -0
  42. package/dist/chunk-DQ2NZ7C4.js +2317 -0
  43. package/dist/chunk-E2WFBRHM.js +213 -0
  44. package/dist/chunk-E4MAEDPM.js +50 -0
  45. package/dist/chunk-EOKRQ3SA.js +30 -0
  46. package/dist/chunk-F3AU2TMM.js +2626 -0
  47. package/dist/chunk-F3P7VQ7H.js +170 -0
  48. package/dist/chunk-FC4RP4I2.js +12 -0
  49. package/dist/chunk-FJNKANOS.js +32 -0
  50. package/dist/chunk-GUOGVTCQ.js +146 -0
  51. package/dist/chunk-GVXA6CCE.js +18 -0
  52. package/dist/chunk-HHYAELLJ.js +2334 -0
  53. package/dist/chunk-HMFXILPV.js +83 -0
  54. package/dist/chunk-HVUZXSWK.js +421 -0
  55. package/dist/chunk-HWBZIGMJ.js +62 -0
  56. package/dist/chunk-I3R7GKDR.js +17 -0
  57. package/dist/chunk-I7SPGOWI.js +55 -0
  58. package/dist/chunk-IIXOAGHR.js +38 -0
  59. package/dist/chunk-IVP5KAB7.js +2250 -0
  60. package/dist/chunk-IXEBJSUE.js +22 -0
  61. package/dist/chunk-JOLITGCC.js +27 -0
  62. package/dist/chunk-K6VYLB2U.js +145 -0
  63. package/dist/chunk-LJDODBBZ.js +78 -0
  64. package/dist/chunk-LNAKEO63.js +78 -0
  65. package/dist/chunk-LSMJ6KBE.js +4139 -0
  66. package/dist/chunk-LW6XKDZQ.js +5565 -0
  67. package/dist/chunk-N3YQJI6O.js +899 -0
  68. package/dist/chunk-NABLC7W3.js +148 -0
  69. package/dist/chunk-NKOUFPQF.js +659 -0
  70. package/dist/chunk-NL5GLMFI.js +211 -0
  71. package/dist/chunk-NMABWBZN.js +327 -0
  72. package/dist/chunk-NTHH2J74.js +27 -0
  73. package/dist/chunk-NTUJEGDZ.js +59 -0
  74. package/dist/chunk-NXL4FFQQ.js +47 -0
  75. package/dist/chunk-NXPS2L6U.js +466 -0
  76. package/dist/chunk-O5WILGMI.js +42 -0
  77. package/dist/chunk-O6TQT6RI.js +48 -0
  78. package/dist/chunk-OAXNDZJ7.js +323 -0
  79. package/dist/chunk-ODEEOXPH.js +44 -0
  80. package/dist/chunk-OQGFCZJ4.js +4533 -0
  81. package/dist/chunk-ORTMY4Q4.js +282 -0
  82. package/dist/chunk-OS32RNR2.js +442 -0
  83. package/dist/chunk-PAK6IDME.js +189 -0
  84. package/dist/chunk-PWFHBTSF.js +406 -0
  85. package/dist/chunk-Q5U6FMDI.js +2352 -0
  86. package/dist/chunk-QOZXJ4NA.js +3364 -0
  87. package/dist/chunk-QYF5S6P6.js +45 -0
  88. package/dist/chunk-RIISMHKN.js +36 -0
  89. package/dist/chunk-RPD4ZSB7.js +25 -0
  90. package/dist/chunk-RPD5K5NC.js +79 -0
  91. package/dist/chunk-RUOG5D2C.js +262 -0
  92. package/dist/chunk-T4ERT73Y.js +599 -0
  93. package/dist/chunk-T6D4Q4AQ.js +54 -0
  94. package/dist/chunk-TGPXLY2J.js +391 -0
  95. package/dist/chunk-TLGFBM3F.js +12116 -0
  96. package/dist/chunk-TO6WL3GM.js +88 -0
  97. package/dist/chunk-TT22N74J.js +517 -0
  98. package/dist/chunk-TVN5N6GY.js +21216 -0
  99. package/dist/chunk-U3VGR4VU.js +308 -0
  100. package/dist/chunk-UK3CMEAL.js +1250 -0
  101. package/dist/chunk-UOPVJQ4N.js +103 -0
  102. package/dist/chunk-UXVNL3D7.js +23 -0
  103. package/dist/chunk-UY752QSN.js +45 -0
  104. package/dist/chunk-V5PXFSZK.js +188 -0
  105. package/dist/chunk-V7R425XG.js +398 -0
  106. package/dist/chunk-VCSRSYLF.js +81 -0
  107. package/dist/chunk-VD4JQCQC.js +162 -0
  108. package/dist/chunk-VDBPVIKD.js +62 -0
  109. package/dist/chunk-VHQLPIRJ.js +114 -0
  110. package/dist/chunk-VN2AXLEX.js +5414 -0
  111. package/dist/chunk-VNMT7C64.js +478 -0
  112. package/dist/chunk-WONERJTP.js +309 -0
  113. package/dist/chunk-XECLWQZN.js +85 -0
  114. package/dist/chunk-XIMYZ3JA.js +27 -0
  115. package/dist/chunk-XK4CIILI.js +144 -0
  116. package/dist/chunk-Y4T27COV.js +562 -0
  117. package/dist/chunk-YGVYKNSN.js +1860 -0
  118. package/dist/chunk-YLF3PPT5.js +308 -0
  119. package/dist/chunk-YSNFMJKN.js +14 -0
  120. package/dist/chunk-Z3CCVDUB.js +76 -0
  121. package/dist/chunk-Z54ZSOBZ.js +61 -0
  122. package/dist/chunk-ZAWW3AF6.js +97 -0
  123. package/dist/chunk-ZMOL6RQC.js +167 -0
  124. package/dist/index.js +19039 -8956
  125. package/dist/mcp/analyze-cli.js +186 -228
  126. package/dist/mcp/cli.js +137 -98
  127. package/dist/mcp/server.js +22592 -1564
  128. package/dist/mcp/worker.js +97 -38
  129. package/dist/probes/installer.js +135 -0
  130. package/dist/workers/project-snapshot-persist-worker.js +22 -0
  131. package/dist/workers/review-graph-persist-worker.js +15 -0
  132. package/docs/adr/0001-stale-advisory-live-arm.md +29 -0
  133. package/docs/adr/0002-workspace-hash-rendezvous.md +30 -0
  134. package/docs/adr/0003-git-guard-latch-writer.md +203 -0
  135. package/docs/adr/0004-disposition-policy-seam.md +38 -0
  136. package/docs/adr/0005-tool-availability-enforcement-seam.md +29 -0
  137. package/docs/adr/0006-derived-state-benchmark-first.md +31 -0
  138. package/docs/adr/0007-end-to-end-witness-per-seam-slice.md +38 -0
  139. package/docs/adr/0008-turn-end-lane-interface.md +165 -0
  140. package/docs/adr/0009-reported-path-attribution.md +114 -0
  141. package/docs/adr/README.md +26 -0
  142. package/docs/agent-guide.md +13 -4
  143. package/docs/agent-tools.md +60 -1
  144. package/docs/ast-grep_rules_catalog.md +6 -4
  145. package/docs/configuration.md +75 -2
  146. package/docs/engineering-principles.md +202 -0
  147. package/docs/environment-variables.md +113 -6
  148. package/docs/features.md +10 -6
  149. package/docs/globalconfig.md +7 -1
  150. package/docs/io-bridge-v2-migration.md +94 -0
  151. package/docs/language-coverage.md +17 -0
  152. package/docs/lsp-capability-matrix.md +131 -32
  153. package/docs/lsp-idle-eviction.md +124 -0
  154. package/docs/mcp.md +21 -5
  155. package/docs/merge-queue-rollout.md +77 -0
  156. package/docs/pi-lens-fixer.md +161 -39
  157. package/docs/pi-lens-investigator.md +77 -20
  158. package/docs/pi-lens-merge-policy.md +694 -0
  159. package/docs/pi-lens-monitor.md +73 -7
  160. package/docs/pi-lens-retro.md +6 -4
  161. package/docs/pi-lens-reviewer.md +190 -34
  162. package/docs/pi-lens-subagent.md +247 -37
  163. package/docs/pi-lens-warden.md +18 -7
  164. package/docs/public-api-stability.md +56 -0
  165. package/docs/release-qa-baseline.md +86 -17
  166. package/docs/rfcs/3654-unified-io-bridge-v2.md +316 -0
  167. package/docs/servercapabilities.md +66 -52
  168. package/docs/settings.md +13 -3
  169. package/docs/subagent-compat.md +1 -1
  170. package/docs/usage.md +5 -1
  171. package/grammars/tree-sitter-bash.wasm +0 -0
  172. package/grammars/tree-sitter-bash.wasm.json +3 -3
  173. package/package.json +23 -9
  174. package/rules/ast-grep-rules/rule-tests/advisory-registry-subset-test.yml +41 -0
  175. package/rules/ast-grep-rules/rule-tests/no-assert-tuple-test.yml +13 -0
  176. package/rules/ast-grep-rules/rule-tests/no-boolean-in-except-test.yml +26 -0
  177. package/rules/ast-grep-rules/rule-tests/no-http-headers-bracket-access-test.yml +10 -0
  178. package/rules/ast-grep-rules/rule-tests/unchecked-numeric-parse-python-test.yml +18 -0
  179. package/rules/ast-grep-rules/rule-tests/unchecked-throwing-call-python-test.yml +4 -3
  180. package/rules/ast-grep-rules/rules/advisory-registry-subset.yml +50 -0
  181. package/rules/ast-grep-rules/rules/go-no-fmt-println.yml +2 -2
  182. package/rules/ast-grep-rules/rules/no-assert-tuple.yml +23 -13
  183. package/rules/ast-grep-rules/rules/no-boolean-in-except.yml +7 -3
  184. package/rules/ast-grep-rules/rules/no-console-except-error-js.yml +2 -2
  185. package/rules/ast-grep-rules/rules/no-console-except-error.yml +2 -2
  186. package/rules/ast-grep-rules/rules/no-http-headers-bracket-access.yml +12 -7
  187. package/rules/ast-grep-rules/rules/unchecked-numeric-parse-python.yml +44 -0
  188. package/rules/ast-grep-rules/rules/unchecked-throwing-call-python.yml +5 -3
  189. package/rules/tree-sitter-queries/typescript/console-statement.yml +2 -2
  190. package/scripts/analyze-pi-lens-logs.mjs +1275 -62
  191. package/scripts/download-grammars.js +5 -0
  192. package/scripts/grammars.lock.json +6 -1
  193. package/scripts/install-selftest.mjs +38 -35
  194. package/scripts/lib/host-provided-deps.mjs +13 -4
  195. package/scripts/lib/web-tree-sitter-dir.mjs +141 -0
  196. package/dist/clients/actionable-warnings-logger.js +0 -35
  197. package/dist/clients/actionable-warnings.js +0 -1594
  198. package/dist/clients/advisory-provenance.js +0 -457
  199. package/dist/clients/agent-behavior-client.js +0 -147
  200. package/dist/clients/agent-nudge.js +0 -325
  201. package/dist/clients/analysed-root.js +0 -1
  202. package/dist/clients/ast-grep-client.js +0 -731
  203. package/dist/clients/ast-grep-rule-manager.js +0 -146
  204. package/dist/clients/ast-grep-tool-logger.js +0 -118
  205. package/dist/clients/ast-grep-types.js +0 -10
  206. package/dist/clients/ast-grep-yaml-synth.js +0 -154
  207. package/dist/clients/atomic-write-staging.js +0 -75
  208. package/dist/clients/atomic-write.js +0 -189
  209. package/dist/clients/availability-probe-flight.js +0 -12
  210. package/dist/clients/bash-file-access.js +0 -1056
  211. package/dist/clients/biome-client.js +0 -357
  212. package/dist/clients/blocker-freshness.js +0 -837
  213. package/dist/clients/blocker-past-eof.js +0 -129
  214. package/dist/clients/bootstrap.js +0 -569
  215. package/dist/clients/bounded-cache.js +0 -190
  216. package/dist/clients/bounded-pid-file-lock.js +0 -202
  217. package/dist/clients/bounded-telemetry.js +0 -295
  218. package/dist/clients/build-identity.js +0 -95
  219. package/dist/clients/bundled-resource-health.js +0 -113
  220. package/dist/clients/bus-events-logger.js +0 -105
  221. package/dist/clients/bus-publish.js +0 -171
  222. package/dist/clients/cache/rule-cache.js +0 -190
  223. package/dist/clients/cache-manager.js +0 -465
  224. package/dist/clients/cache-observability.js +0 -1313
  225. package/dist/clients/call-graph.js +0 -852
  226. package/dist/clients/cargo-manifest.js +0 -422
  227. package/dist/clients/cascade-budget.js +0 -196
  228. package/dist/clients/cascade-format.js +0 -315
  229. package/dist/clients/cascade-logger.js +0 -31
  230. package/dist/clients/cascade-types.js +0 -9
  231. package/dist/clients/child-unref.js +0 -115
  232. package/dist/clients/code-quality-warnings.js +0 -286
  233. package/dist/clients/codebase-model.js +0 -173
  234. package/dist/clients/collateral-test-role.js +0 -40
  235. package/dist/clients/complexity-client.js +0 -462
  236. package/dist/clients/config-core/deny.js +0 -221
  237. package/dist/clients/config-core/index.js +0 -50
  238. package/dist/clients/config-core/merge.js +0 -357
  239. package/dist/clients/config-core/normalize.js +0 -340
  240. package/dist/clients/config-core/process-spec.js +0 -248
  241. package/dist/clients/config-core/provenance.js +0 -164
  242. package/dist/clients/config-core/records.js +0 -218
  243. package/dist/clients/config-core/resolve.js +0 -125
  244. package/dist/clients/config-core/safe-object.js +0 -78
  245. package/dist/clients/config-core/schema.js +0 -165
  246. package/dist/clients/config-diagnostic-codes.js +0 -283
  247. package/dist/clients/config-locations.js +0 -160
  248. package/dist/clients/config-resolve.js +0 -792
  249. package/dist/clients/config-schema.js +0 -251
  250. package/dist/clients/config-warn.js +0 -407
  251. package/dist/clients/console-guard-install.js +0 -26
  252. package/dist/clients/cooperative-budget.js +0 -42
  253. package/dist/clients/dead-code-client.js +0 -510
  254. package/dist/clients/dead-code-logger.js +0 -30
  255. package/dist/clients/deadline-utils.js +0 -280
  256. package/dist/clients/debug-handles.js +0 -211
  257. package/dist/clients/debug-heap.js +0 -174
  258. package/dist/clients/deferred-lsp-work.js +0 -106
  259. package/dist/clients/degradation-ledger.js +0 -396
  260. package/dist/clients/demoted-finding-render.js +0 -169
  261. package/dist/clients/dependency-checker.js +0 -862
  262. package/dist/clients/deps/ast-grep-napi.js +0 -22
  263. package/dist/clients/deps/js-yaml.js +0 -9
  264. package/dist/clients/deps/minimatch.js +0 -4
  265. package/dist/clients/deps/pi-tui.js +0 -14
  266. package/dist/clients/deps/typebox.js +0 -14
  267. package/dist/clients/deps/vscode-jsonrpc.js +0 -1
  268. package/dist/clients/deps/web-tree-sitter.js +0 -24
  269. package/dist/clients/diagnostic-dispositions.js +0 -594
  270. package/dist/clients/diagnostic-line-freshness.js +0 -305
  271. package/dist/clients/diagnostic-logger.js +0 -73
  272. package/dist/clients/diagnostic-tracker.js +0 -105
  273. package/dist/clients/diagnostics-publish.js +0 -230
  274. package/dist/clients/dispatch/ast-grep-catalog.js +0 -65
  275. package/dist/clients/dispatch/auxiliary-lsp.js +0 -343
  276. package/dist/clients/dispatch/collect-later-tier.js +0 -35
  277. package/dist/clients/dispatch/diagnostic-taxonomy.js +0 -66
  278. package/dist/clients/dispatch/dispatcher.js +0 -1269
  279. package/dist/clients/dispatch/fact-provider-types.js +0 -1
  280. package/dist/clients/dispatch/fact-rule-runner.js +0 -17
  281. package/dist/clients/dispatch/fact-runner.js +0 -30
  282. package/dist/clients/dispatch/fact-scheduler.js +0 -68
  283. package/dist/clients/dispatch/fact-store.js +0 -377
  284. package/dist/clients/dispatch/facts/comment-facts.js +0 -22
  285. package/dist/clients/dispatch/facts/file-content.js +0 -19
  286. package/dist/clients/dispatch/facts/function-facts.js +0 -348
  287. package/dist/clients/dispatch/facts/import-facts.js +0 -204
  288. package/dist/clients/dispatch/facts/tree-sitter-facts.js +0 -34
  289. package/dist/clients/dispatch/facts/try-catch-facts.js +0 -119
  290. package/dist/clients/dispatch/finding-policy.js +0 -222
  291. package/dist/clients/dispatch/indent-detect.js +0 -411
  292. package/dist/clients/dispatch/inline-suppressions.js +0 -70
  293. package/dist/clients/dispatch/integration.js +0 -2377
  294. package/dist/clients/dispatch/lazy.js +0 -11
  295. package/dist/clients/dispatch/pending-runner-findings.js +0 -87
  296. package/dist/clients/dispatch/plan.js +0 -278
  297. package/dist/clients/dispatch/priorities.js +0 -20
  298. package/dist/clients/dispatch/rule-id-normalize.js +0 -50
  299. package/dist/clients/dispatch/rule-ignores.js +0 -62
  300. package/dist/clients/dispatch/rule-policy.js +0 -135
  301. package/dist/clients/dispatch/rules/async-noise.js +0 -40
  302. package/dist/clients/dispatch/rules/async-unnecessary-wrapper.js +0 -27
  303. package/dist/clients/dispatch/rules/cors-wildcard.js +0 -54
  304. package/dist/clients/dispatch/rules/error-obscuring.js +0 -32
  305. package/dist/clients/dispatch/rules/error-swallowing.js +0 -31
  306. package/dist/clients/dispatch/rules/framework-call-noise.js +0 -67
  307. package/dist/clients/dispatch/rules/high-complexity.js +0 -72
  308. package/dist/clients/dispatch/rules/high-fan-out.js +0 -74
  309. package/dist/clients/dispatch/rules/high-import-coupling.js +0 -37
  310. package/dist/clients/dispatch/rules/missing-error-propagation.js +0 -62
  311. package/dist/clients/dispatch/rules/no-commented-credentials.js +0 -52
  312. package/dist/clients/dispatch/rules/pass-through-wrappers.js +0 -42
  313. package/dist/clients/dispatch/rules/placeholder-comments.js +0 -40
  314. package/dist/clients/dispatch/rules/unsafe-boundary.js +0 -126
  315. package/dist/clients/dispatch/runner-context.js +0 -52
  316. package/dist/clients/dispatch/runners/actionlint.js +0 -113
  317. package/dist/clients/dispatch/runners/ast-grep-napi.js +0 -1196
  318. package/dist/clients/dispatch/runners/biome-check.js +0 -283
  319. package/dist/clients/dispatch/runners/cpp-check.js +0 -236
  320. package/dist/clients/dispatch/runners/credo.js +0 -80
  321. package/dist/clients/dispatch/runners/cue-vet.js +0 -398
  322. package/dist/clients/dispatch/runners/dart-analyze.js +0 -202
  323. package/dist/clients/dispatch/runners/detekt.js +0 -169
  324. package/dist/clients/dispatch/runners/dotnet-build.js +0 -159
  325. package/dist/clients/dispatch/runners/elixir-check.js +0 -210
  326. package/dist/clients/dispatch/runners/eslint.js +0 -154
  327. package/dist/clients/dispatch/runners/fact-rules.js +0 -29
  328. package/dist/clients/dispatch/runners/fish-indent.js +0 -69
  329. package/dist/clients/dispatch/runners/gleam-check.js +0 -87
  330. package/dist/clients/dispatch/runners/go-vet.js +0 -90
  331. package/dist/clients/dispatch/runners/golangci-lint.js +0 -120
  332. package/dist/clients/dispatch/runners/hadolint.js +0 -79
  333. package/dist/clients/dispatch/runners/helm-lint.js +0 -171
  334. package/dist/clients/dispatch/runners/helm-render.js +0 -940
  335. package/dist/clients/dispatch/runners/htmlhint.js +0 -108
  336. package/dist/clients/dispatch/runners/index.js +0 -108
  337. package/dist/clients/dispatch/runners/javac.js +0 -89
  338. package/dist/clients/dispatch/runners/ktlint.js +0 -136
  339. package/dist/clients/dispatch/runners/lsp.js +0 -378
  340. package/dist/clients/dispatch/runners/markdownlint.js +0 -160
  341. package/dist/clients/dispatch/runners/mypy.js +0 -101
  342. package/dist/clients/dispatch/runners/oxlint.js +0 -398
  343. package/dist/clients/dispatch/runners/php-lint.js +0 -62
  344. package/dist/clients/dispatch/runners/phpstan.js +0 -120
  345. package/dist/clients/dispatch/runners/prisma-validate.js +0 -62
  346. package/dist/clients/dispatch/runners/psscriptanalyzer.js +0 -445
  347. package/dist/clients/dispatch/runners/pyright.js +0 -169
  348. package/dist/clients/dispatch/runners/rubocop.js +0 -90
  349. package/dist/clients/dispatch/runners/ruff.js +0 -89
  350. package/dist/clients/dispatch/runners/rust-clippy.js +0 -251
  351. package/dist/clients/dispatch/runners/shellcheck.js +0 -169
  352. package/dist/clients/dispatch/runners/shfmt.js +0 -102
  353. package/dist/clients/dispatch/runners/spellcheck.js +0 -121
  354. package/dist/clients/dispatch/runners/spotbugs.js +0 -259
  355. package/dist/clients/dispatch/runners/sqlfluff.js +0 -174
  356. package/dist/clients/dispatch/runners/stylelint.js +0 -158
  357. package/dist/clients/dispatch/runners/swiftlint.js +0 -154
  358. package/dist/clients/dispatch/runners/taplo.js +0 -168
  359. package/dist/clients/dispatch/runners/terragrunt.js +0 -168
  360. package/dist/clients/dispatch/runners/tflint.js +0 -110
  361. package/dist/clients/dispatch/runners/tree-sitter.js +0 -620
  362. package/dist/clients/dispatch/runners/trivy-config.js +0 -247
  363. package/dist/clients/dispatch/runners/utils/availability-policy.js +0 -496
  364. package/dist/clients/dispatch/runners/utils/candidate-probe.js +0 -90
  365. package/dist/clients/dispatch/runners/utils/diagnostic-parsers.js +0 -85
  366. package/dist/clients/dispatch/runners/utils/lazy-installer.js +0 -274
  367. package/dist/clients/dispatch/runners/utils/runner-helpers.js +0 -1701
  368. package/dist/clients/dispatch/runners/utils/spawn-outcome.js +0 -101
  369. package/dist/clients/dispatch/runners/utils/tool-failure.js +0 -235
  370. package/dist/clients/dispatch/runners/utils/toolchain-availability.js +0 -169
  371. package/dist/clients/dispatch/runners/utils.js +0 -15
  372. package/dist/clients/dispatch/runners/vale.js +0 -116
  373. package/dist/clients/dispatch/runners/yaml-rule-parser.js +0 -242
  374. package/dist/clients/dispatch/runners/yamllint.js +0 -85
  375. package/dist/clients/dispatch/runners/zig-check.js +0 -91
  376. package/dist/clients/dispatch/suppress-writer.js +0 -69
  377. package/dist/clients/dispatch/tool-profile.js +0 -30
  378. package/dist/clients/dispatch/types.js +0 -21
  379. package/dist/clients/dispatch/utils/format-utils.js +0 -45
  380. package/dist/clients/dispatch/utils/lsp-diagnostics.js +0 -35
  381. package/dist/clients/disposition-logger.js +0 -32
  382. package/dist/clients/disposition-publish.js +0 -124
  383. package/dist/clients/durable-store.js +0 -97
  384. package/dist/clients/effective-config.js +0 -413
  385. package/dist/clients/env-utils.js +0 -83
  386. package/dist/clients/error-class.js +0 -23
  387. package/dist/clients/eval-timestamp.js +0 -17
  388. package/dist/clients/event-loop-hold.js +0 -274
  389. package/dist/clients/event-loop-monitor.js +0 -250
  390. package/dist/clients/extension-log.js +0 -504
  391. package/dist/clients/extension-mode.js +0 -73
  392. package/dist/clients/feature-hints.js +0 -103
  393. package/dist/clients/file-kinds.js +0 -412
  394. package/dist/clients/file-role.js +0 -134
  395. package/dist/clients/file-time.js +0 -160
  396. package/dist/clients/file-utils.js +0 -1032
  397. package/dist/clients/finding-delivery-gate.js +0 -463
  398. package/dist/clients/finding-identity.js +0 -88
  399. package/dist/clients/fix-worklog.js +0 -103
  400. package/dist/clients/format-events-publish.js +0 -343
  401. package/dist/clients/format-service.js +0 -222
  402. package/dist/clients/formatters-lazy.js +0 -9
  403. package/dist/clients/formatters.js +0 -1994
  404. package/dist/clients/freshness-cadence.js +0 -17
  405. package/dist/clients/freshness.js +0 -38
  406. package/dist/clients/generated-artifacts.js +0 -394
  407. package/dist/clients/generation-guard.js +0 -215
  408. package/dist/clients/git-guard.js +0 -923
  409. package/dist/clients/git-tracked-ignore.js +0 -243
  410. package/dist/clients/gitleaks-client.js +0 -509
  411. package/dist/clients/go-client.js +0 -104
  412. package/dist/clients/govulncheck-client.js +0 -605
  413. package/dist/clients/gradle-ktfmt-style.js +0 -252
  414. package/dist/clients/grammar-source.js +0 -465
  415. package/dist/clients/gzip-stage-write.js +0 -135
  416. package/dist/clients/hashline-anchor.js +0 -424
  417. package/dist/clients/hook-budgets.js +0 -76
  418. package/dist/clients/host-edit-normalize.js +0 -140
  419. package/dist/clients/host-ports.js +0 -39
  420. package/dist/clients/indent-retarget.js +0 -154
  421. package/dist/clients/install-diagnostics.js +0 -143
  422. package/dist/clients/installer/index.js +0 -5167
  423. package/dist/clients/installer/managed-tool-refresh-session.js +0 -58
  424. package/dist/clients/installer/managed-tool-refresh.js +0 -783
  425. package/dist/clients/instance-reaper-state.js +0 -7
  426. package/dist/clients/instance-reaper.js +0 -1137
  427. package/dist/clients/instance-registry-lock.js +0 -145
  428. package/dist/clients/instance-registry.js +0 -773
  429. package/dist/clients/jscpd-client.js +0 -370
  430. package/dist/clients/json-cache-read.js +0 -65
  431. package/dist/clients/knip-client.js +0 -788
  432. package/dist/clients/language-policy.js +0 -252
  433. package/dist/clients/language-profile.js +0 -361
  434. package/dist/clients/language-registry.js +0 -603
  435. package/dist/clients/latency-logger.js +0 -719
  436. package/dist/clients/lazy-import.js +0 -21
  437. package/dist/clients/ledger-bounds.js +0 -54
  438. package/dist/clients/lens-config.js +0 -346
  439. package/dist/clients/lens-engine.js +0 -358
  440. package/dist/clients/lens-events.js +0 -125
  441. package/dist/clients/lens-flag-registry.js +0 -467
  442. package/dist/clients/lens-map.js +0 -1156
  443. package/dist/clients/live-bus-emitter.js +0 -74
  444. package/dist/clients/log-cleanup.js +0 -222
  445. package/dist/clients/lsp/aggregation.js +0 -178
  446. package/dist/clients/lsp/cascade-tier.js +0 -421
  447. package/dist/clients/lsp/client.js +0 -4465
  448. package/dist/clients/lsp/config.js +0 -689
  449. package/dist/clients/lsp/diagnostic-binding.js +0 -225
  450. package/dist/clients/lsp/document-drift.js +0 -467
  451. package/dist/clients/lsp/edits.js +0 -1219
  452. package/dist/clients/lsp/index.js +0 -8305
  453. package/dist/clients/lsp/inferred-project.js +0 -216
  454. package/dist/clients/lsp/jvm-runtime.js +0 -298
  455. package/dist/clients/lsp/language.js +0 -63
  456. package/dist/clients/lsp/launch.js +0 -725
  457. package/dist/clients/lsp/lombok.js +0 -158
  458. package/dist/clients/lsp/path-utils.js +0 -5
  459. package/dist/clients/lsp/pending-aux-coverage.js +0 -251
  460. package/dist/clients/lsp/position-encoding.js +0 -61
  461. package/dist/clients/lsp/ruby-drive-dirs.js +0 -83
  462. package/dist/clients/lsp/server.js +0 -3209
  463. package/dist/clients/lsp/session-roots.js +0 -192
  464. package/dist/clients/lsp/spawn-history.js +0 -17
  465. package/dist/clients/lsp/sync-kind.js +0 -42
  466. package/dist/clients/lsp/tsserver-sync.js +0 -332
  467. package/dist/clients/lsp/wait-policy/capability-snapshot.js +0 -1
  468. package/dist/clients/lsp/wait-policy/classification.js +0 -85
  469. package/dist/clients/lsp/wait-policy/index.js +0 -10
  470. package/dist/clients/lsp/wait-policy/strategies.js +0 -303
  471. package/dist/clients/lsp/watch-queue.js +0 -61
  472. package/dist/clients/lsp/workspace-diagnostics-cache.js +0 -639
  473. package/dist/clients/lsp/workspace-diagnostics-session.js +0 -30
  474. package/dist/clients/lsp/workspace-sweep-hold.js +0 -184
  475. package/dist/clients/lsp-budget.js +0 -186
  476. package/dist/clients/lsp-document-symbols.js +0 -110
  477. package/dist/clients/lsp-lazy.js +0 -9
  478. package/dist/clients/lsp-mutation.js +0 -396
  479. package/dist/clients/lsp-status.js +0 -59
  480. package/dist/clients/map-with-concurrency.js +0 -40
  481. package/dist/clients/mcp/analyze.js +0 -420
  482. package/dist/clients/mcp/host-shim.js +0 -31
  483. package/dist/clients/mcp/ipc.js +0 -335
  484. package/dist/clients/mcp/review.js +0 -151
  485. package/dist/clients/mcp/session.js +0 -412
  486. package/dist/clients/memory-sampler.js +0 -208
  487. package/dist/clients/message-end-attribution.js +0 -30
  488. package/dist/clients/metrics-client.js +0 -107
  489. package/dist/clients/metrics-history.js +0 -327
  490. package/dist/clients/middle-man-analysis.js +0 -263
  491. package/dist/clients/model-provider.js +0 -53
  492. package/dist/clients/module-report-lsp.js +0 -189
  493. package/dist/clients/module-report.js +0 -2150
  494. package/dist/clients/mutating-tool.js +0 -651
  495. package/dist/clients/mutation-attribution.js +0 -368
  496. package/dist/clients/mutation-bridge.js +0 -240
  497. package/dist/clients/ndjson-logger.js +0 -734
  498. package/dist/clients/observed-mutation-sources.js +0 -101
  499. package/dist/clients/observed-mutation.js +0 -1253
  500. package/dist/clients/oldtext-autopatch.js +0 -128
  501. package/dist/clients/opaque-mutation-scan.js +0 -496
  502. package/dist/clients/opengrep-client.js +0 -328
  503. package/dist/clients/opengrep-config.js +0 -76
  504. package/dist/clients/package-manager.js +0 -607
  505. package/dist/clients/package-root.js +0 -38
  506. package/dist/clients/partial-edit-apply.js +0 -439
  507. package/dist/clients/path-attribution-telemetry.js +0 -43
  508. package/dist/clients/path-keyed-map.js +0 -108
  509. package/dist/clients/path-utils.js +0 -1244
  510. package/dist/clients/performance-report.js +0 -331
  511. package/dist/clients/persist-debounce.js +0 -70
  512. package/dist/clients/persistent-reverify.js +0 -271
  513. package/dist/clients/php-cs-fixer-config.js +0 -119
  514. package/dist/clients/pipeline.js +0 -1425
  515. package/dist/clients/post-autofix-notice.js +0 -37
  516. package/dist/clients/print-mode.js +0 -21
  517. package/dist/clients/probe-home-state.js +0 -228
  518. package/dist/clients/process-bridge.js +0 -66
  519. package/dist/clients/process-singletons.js +0 -210
  520. package/dist/clients/process-snapshot.js +0 -68
  521. package/dist/clients/project-changes.js +0 -181
  522. package/dist/clients/project-conventions.js +0 -171
  523. package/dist/clients/project-diagnostics/cache.js +0 -137
  524. package/dist/clients/project-diagnostics/extractors.js +0 -109
  525. package/dist/clients/project-diagnostics/fresh-fetch.js +0 -650
  526. package/dist/clients/project-diagnostics/runner-adapters/call-graph-impact.js +0 -118
  527. package/dist/clients/project-diagnostics/runner-adapters/dead-code.js +0 -47
  528. package/dist/clients/project-diagnostics/runner-adapters/gitleaks.js +0 -40
  529. package/dist/clients/project-diagnostics/runner-adapters/govulncheck.js +0 -34
  530. package/dist/clients/project-diagnostics/runner-adapters/jscpd.js +0 -38
  531. package/dist/clients/project-diagnostics/runner-adapters/knip.js +0 -47
  532. package/dist/clients/project-diagnostics/runner-adapters/madge.js +0 -48
  533. package/dist/clients/project-diagnostics/runner-adapters/opengrep.js +0 -40
  534. package/dist/clients/project-diagnostics/runner-adapters/runner-findings.js +0 -127
  535. package/dist/clients/project-diagnostics/runner-adapters/trivy.js +0 -81
  536. package/dist/clients/project-diagnostics/scanner.js +0 -520
  537. package/dist/clients/project-diagnostics/types.js +0 -1
  538. package/dist/clients/project-lens-config.js +0 -799
  539. package/dist/clients/project-report.js +0 -701
  540. package/dist/clients/project-scale.js +0 -237
  541. package/dist/clients/project-scan-policy.js +0 -11
  542. package/dist/clients/project-snapshot-fingerprint.js +0 -48
  543. package/dist/clients/project-snapshot-persist-worker.js +0 -20
  544. package/dist/clients/project-snapshot.js +0 -1653
  545. package/dist/clients/project-trust.js +0 -181
  546. package/dist/clients/python-environment.js +0 -217
  547. package/dist/clients/python-provenance.js +0 -639
  548. package/dist/clients/quiet-window-config.js +0 -47
  549. package/dist/clients/quiet-window.js +0 -236
  550. package/dist/clients/read-bridge.js +0 -139
  551. package/dist/clients/read-expansion.js +0 -266
  552. package/dist/clients/read-guard-logger.js +0 -251
  553. package/dist/clients/read-guard-tool-lines.js +0 -1306
  554. package/dist/clients/read-guard.js +0 -1561
  555. package/dist/clients/recent-touches.js +0 -240
  556. package/dist/clients/redact/secrets.js +0 -449
  557. package/dist/clients/resource-sampler.js +0 -628
  558. package/dist/clients/reverse-deps.js +0 -270
  559. package/dist/clients/review-graph/builder.js +0 -5118
  560. package/dist/clients/review-graph/format.js +0 -33
  561. package/dist/clients/review-graph/git-identity.js +0 -157
  562. package/dist/clients/review-graph/import-resolvers.js +0 -481
  563. package/dist/clients/review-graph/persist-worker.js +0 -10
  564. package/dist/clients/review-graph/query.js +0 -244
  565. package/dist/clients/review-graph/revision-drift.js +0 -21
  566. package/dist/clients/review-graph/service.js +0 -96
  567. package/dist/clients/review-graph/shared-extraction-ir.js +0 -82
  568. package/dist/clients/review-graph/symbol-id.js +0 -103
  569. package/dist/clients/review-graph/tsconfig-paths.js +0 -299
  570. package/dist/clients/review-graph/types.js +0 -1
  571. package/dist/clients/review-graph/workspace-modules.js +0 -449
  572. package/dist/clients/review-graph-logger.js +0 -81
  573. package/dist/clients/ruff-client.js +0 -152
  574. package/dist/clients/rules-scanner.js +0 -131
  575. package/dist/clients/run-duration.js +0 -55
  576. package/dist/clients/runtime-agent-end.js +0 -746
  577. package/dist/clients/runtime-config.js +0 -77
  578. package/dist/clients/runtime-context.js +0 -211
  579. package/dist/clients/runtime-coordinator.js +0 -1290
  580. package/dist/clients/runtime-session.js +0 -2485
  581. package/dist/clients/runtime-tool-call.js +0 -1284
  582. package/dist/clients/runtime-tool-result.js +0 -2034
  583. package/dist/clients/runtime-turn.js +0 -3600
  584. package/dist/clients/rust-client.js +0 -97
  585. package/dist/clients/safe-spawn.js +0 -2166
  586. package/dist/clients/sanitize.js +0 -286
  587. package/dist/clients/scan-utils.js +0 -25
  588. package/dist/clients/scratch-tree-policy.js +0 -165
  589. package/dist/clients/search-read-registration.js +0 -94
  590. package/dist/clients/secret-findings.js +0 -114
  591. package/dist/clients/security-scan-client.js +0 -352
  592. package/dist/clients/session-event-guard.js +0 -297
  593. package/dist/clients/session-lifecycle.js +0 -484
  594. package/dist/clients/session-start-observability.js +0 -94
  595. package/dist/clients/session-state-store.js +0 -101
  596. package/dist/clients/session-summary.js +0 -15
  597. package/dist/clients/sessionstart-logger.js +0 -27
  598. package/dist/clients/sg-runner.js +0 -876
  599. package/dist/clients/sgconfig.js +0 -347
  600. package/dist/clients/shared-checkout-guard.js +0 -338
  601. package/dist/clients/single-flight.js +0 -158
  602. package/dist/clients/situational-tool-telemetry.js +0 -108
  603. package/dist/clients/skills-resolver.js +0 -141
  604. package/dist/clients/slow-fs.js +0 -138
  605. package/dist/clients/smells-rollup.js +0 -231
  606. package/dist/clients/source-filter.js +0 -596
  607. package/dist/clients/source-walker.js +0 -211
  608. package/dist/clients/spawn-output-cap.js +0 -37
  609. package/dist/clients/spawn-timeout-cooldown.js +0 -110
  610. package/dist/clients/stale-marker.js +0 -17
  611. package/dist/clients/startup-scan.js +0 -400
  612. package/dist/clients/startup-timing.js +0 -130
  613. package/dist/clients/string-utils.js +0 -33
  614. package/dist/clients/subagent-mode.js +0 -111
  615. package/dist/clients/symbol-containment.js +0 -24
  616. package/dist/clients/symbol-types.js +0 -5
  617. package/dist/clients/test-runner-client.js +0 -2507
  618. package/dist/clients/test-runner-delivery.js +0 -287
  619. package/dist/clients/todo-scanner.js +0 -229
  620. package/dist/clients/tool-agreement.js +0 -423
  621. package/dist/clients/tool-config.js +0 -249
  622. package/dist/clients/tool-cwd.js +0 -270
  623. package/dist/clients/tool-definition.js +0 -47
  624. package/dist/clients/tool-event.js +0 -105
  625. package/dist/clients/tool-policy.js +0 -2755
  626. package/dist/clients/tool-probe.js +0 -53
  627. package/dist/clients/tool-render.js +0 -156
  628. package/dist/clients/tool-set-policy.js +0 -118
  629. package/dist/clients/tree-sitter-cache.js +0 -364
  630. package/dist/clients/tree-sitter-client.js +0 -4134
  631. package/dist/clients/tree-sitter-logger.js +0 -100
  632. package/dist/clients/tree-sitter-navigator.js +0 -269
  633. package/dist/clients/tree-sitter-query-loader.js +0 -569
  634. package/dist/clients/tree-sitter-shared.js +0 -158
  635. package/dist/clients/tree-sitter-symbol-extractor.js +0 -1265
  636. package/dist/clients/trivy-client.js +0 -467
  637. package/dist/clients/tui-fit.js +0 -50
  638. package/dist/clients/turn-context.js +0 -52
  639. package/dist/clients/turn-summary-render.js +0 -72
  640. package/dist/clients/turn-summary.js +0 -132
  641. package/dist/clients/typos-config.js +0 -29
  642. package/dist/clients/user-notify.js +0 -51
  643. package/dist/clients/vanished-instance-marker.js +0 -57
  644. package/dist/clients/warm-attach.js +0 -246
  645. package/dist/clients/widget-state.js +0 -1677
  646. package/dist/clients/word-index-logger.js +0 -49
  647. package/dist/clients/word-index-store.js +0 -459
  648. package/dist/clients/word-index.js +0 -2080
  649. package/dist/clients/workspace-topology.js +0 -389
  650. package/dist/clients/write-ordering-guard.js +0 -65
  651. package/dist/clients/zizmor-config.js +0 -327
  652. package/dist/i18n.js +0 -69
  653. package/dist/mcp/build-staleness.js +0 -124
  654. package/dist/scripts/lib/process-scan.mjs +0 -583
  655. package/dist/scripts/lib/skills-predicate.mjs +0 -129
  656. package/dist/tools/activate-tools.js +0 -87
  657. package/dist/tools/ast-grep-outline.js +0 -137
  658. package/dist/tools/ast-grep-replace.js +0 -244
  659. package/dist/tools/ast-grep-search.js +0 -762
  660. package/dist/tools/effective-config.js +0 -90
  661. package/dist/tools/lens-diagnostic-mark.js +0 -375
  662. package/dist/tools/lens-diagnostics.js +0 -2544
  663. package/dist/tools/lsp-diagnostics.js +0 -1634
  664. package/dist/tools/lsp-navigation.js +0 -1367
  665. package/dist/tools/lsp-structured-output.js +0 -294
  666. package/dist/tools/module-report.js +0 -382
  667. package/dist/tools/project-report.js +0 -86
  668. package/dist/tools/render-compact.js +0 -374
  669. package/dist/tools/scan-progress.js +0 -53
  670. package/dist/tools/shared.js +0 -29
  671. package/dist/tools/symbol-search.js +0 -155
@@ -1,3209 +0,0 @@
1
- /**
2
- * LSP Server Definitions for pi-lens
3
- *
4
- * Defines 40+ language servers with:
5
- * - Root detection (monorepo support)
6
- * - Auto-installation strategies
7
- * - Platform-specific handling
8
- */
9
- import { existsSync, mkdirSync, statSync } from "node:fs";
10
- import { access, readFile, readdir, stat } from "node:fs/promises";
11
- import os from "node:os";
12
- import path from "node:path";
13
- import { getGlobalPiLensDir, getProjectIgnoreGlobs, isPathIgnoredByProject, } from "../file-utils.js";
14
- import { augmentPythonEnvironment, detectPythonEnvironment, detectPythonVenv, pythonEnvironmentToolCandidates, } from "../python-environment.js";
15
- export { detectPythonVenv };
16
- import { STAGE_TMP_PATTERN } from "../atomic-write-staging.js";
17
- import { DOTNET_CSHARP_ROOT_MARKERS, DOTNET_FSHARP_ROOT_MARKERS, KIND_EXTENSIONS, } from "../file-kinds.js";
18
- import { extensionsForLanguage } from "../language-registry.js";
19
- import { CARGO_WORKSPACE_MEMBER_DIALECT, direntsHaveMarkerGlobMatch, isAtOrAboveHomeDir, isFullyQualified, isWindowsPath, matchesWorkspaceMemberPattern, pathsEqual, } from "../path-utils.js";
20
- import { ensureTool, findManagedToolBinary, getToolEnvironment, getToolPath, } from "../installer/index.js";
21
- import * as installer from "../installer/index.js";
22
- import { classifyProbeFailure, describeInstallAttempt, logAvailabilityDecision, } from "../dispatch/runners/utils/availability-policy.js";
23
- import { resolveOpengrepConfig } from "../opengrep-config.js";
24
- import { isZizmorAuditTarget, resolveZizmorGitHubToken, } from "../zizmor-config.js";
25
- import { logLatency } from "../latency-logger.js";
26
- import { logSessionStart } from "../sessionstart-logger.js";
27
- import { findLocalSgconfig, resolveBaselineSgconfig } from "../sgconfig.js";
28
- import { findLocalTyposConfig } from "../typos-config.js";
29
- import { resolvePackagePath } from "../package-root.js";
30
- import { resolveToolCwd } from "../tool-cwd.js";
31
- import { recordDegradationOnce } from "../degradation-ledger.js";
32
- import { hasCargoWorkspaceTable, readCargoWorkspaceExclude, readCargoWorkspaceMembers, readTextFileOrUndefined, } from "../cargo-manifest.js";
33
- import { resolveAstGrepNativeExe } from "./wait-policy/index.js";
34
- import { hasSpawnFailureKind, isCommandAvailableAsync, safeSpawnAsync, } from "../safe-spawn.js";
35
- import { launchLSP } from "./launch.js";
36
- import { createLombokJdtlsArgs } from "./lombok.js";
37
- import { resolveJavaRuntimeEnv } from "./jvm-runtime.js";
38
- import { normalizeMapKey } from "./path-utils.js";
39
- import { getRubyVersionDirNamesSync } from "./ruby-drive-dirs.js";
40
- import { getProcessSingleton } from "../process-singletons.js";
41
- import { createGenerationSource, } from "../generation-guard.js";
42
- function withRootMarkers(root, markers) {
43
- root.rootMarkers = markers;
44
- return root;
45
- }
46
- /** Resolve a server identity cwd through the shared tool-cwd seam. */
47
- export async function resolveLspServerCwd(server, filePath, sessionCwd, onRootFailure) {
48
- const rootMarkers = server.rootMarkers ?? server.root.rootMarkers;
49
- let serverRoot;
50
- let rootFailed = false;
51
- try {
52
- serverRoot = await server.root(filePath);
53
- }
54
- catch (error) {
55
- const reason = error instanceof Error ? error.message : String(error);
56
- rootFailed = true;
57
- onRootFailure?.(reason);
58
- }
59
- const isFileDirFallback = serverRoot !== undefined &&
60
- rootMarkers?.length &&
61
- path.resolve(serverRoot) === path.resolve(path.dirname(filePath));
62
- if (!serverRoot || isFileDirFallback) {
63
- if (!serverRoot) {
64
- recordDegradationOnce({
65
- kind: "tool-cwd-resolution",
66
- subject: server.id,
67
- reason: `lsp:server-root-${rootFailed ? "failed" : "fallback"}:${filePath}`,
68
- });
69
- }
70
- if (!rootMarkers?.length)
71
- return undefined;
72
- return resolveToolCwd("lsp", server.id, filePath, {
73
- cwd: path.dirname(path.resolve(filePath)),
74
- rootMarkers,
75
- }).cwd;
76
- }
77
- const boundedServerRoot = enforceLspRootCeiling(serverRoot, sessionCwd, filePath);
78
- return resolveToolCwd("lsp", server.id, filePath, {
79
- cwd: sessionCwd,
80
- rootMarkers,
81
- serverRoot: boundedServerRoot,
82
- }).cwd;
83
- }
84
- const FIXTURE_ROOT_SEGMENTS = new Set(["__fixtures__", "testdata"]);
85
- const FALLBACK_PROJECT_MARKERS = [
86
- ".git",
87
- "package.json",
88
- "go.work",
89
- "go.mod",
90
- "Cargo.toml",
91
- "pyproject.toml",
92
- "pom.xml",
93
- "build.gradle",
94
- "build.gradle.kts",
95
- "mix.exs",
96
- "pubspec.yaml",
97
- "Package.swift",
98
- ];
99
- /**
100
- * Markers that make a nested root an independently hosted project rather than
101
- * a config-only directory that should share an ancestor's LSP client.
102
- */
103
- const PROJECT_BOUNDARY_MARKERS = [
104
- "package.json",
105
- "package-lock.json",
106
- "pnpm-lock.yaml",
107
- "yarn.lock",
108
- "bun.lock",
109
- "bun.lockb",
110
- "go.work",
111
- "go.mod",
112
- "Cargo.toml",
113
- "Cargo.lock",
114
- "pyproject.toml",
115
- "uv.lock",
116
- "poetry.lock",
117
- "Pipfile",
118
- "Pipfile.lock",
119
- "pom.xml",
120
- "build.gradle",
121
- "build.gradle.kts",
122
- "settings.gradle",
123
- "settings.gradle.kts",
124
- "mix.exs",
125
- "pubspec.yaml",
126
- "Package.swift",
127
- ];
128
- // This is process-global state, so each candidate pair is logged once per
129
- // process (not once per session). Keep the root-boundary marker below aligned
130
- // with FALLBACK_PROJECT_MARKERS, the shared fallback root-policy marker set.
131
- const loggedRootCeilingClamps = new Set();
132
- const posixCaseInsensitiveByPath = new Map();
133
- function posixFilesystemIsCaseInsensitive(root) {
134
- const resolved = path.resolve(root);
135
- const cached = posixCaseInsensitiveByPath.get(resolved);
136
- if (cached !== undefined)
137
- return cached;
138
- if (!existsSync(resolved)) {
139
- return false;
140
- }
141
- const name = path.basename(resolved);
142
- const alternate = name.toLowerCase() === name ? name.toUpperCase() : name.toLowerCase();
143
- let insensitive = false;
144
- const alternatePath = path.join(path.dirname(resolved), alternate);
145
- if (alternate !== name && existsSync(alternatePath)) {
146
- try {
147
- const actual = statSync(resolved);
148
- const alternateStat = statSync(alternatePath);
149
- insensitive =
150
- actual.dev === alternateStat.dev && actual.ino === alternateStat.ino;
151
- }
152
- catch {
153
- insensitive = false;
154
- }
155
- }
156
- posixCaseInsensitiveByPath.set(resolved, insensitive);
157
- return insensitive;
158
- }
159
- export function resetLSPCaseSensitivityState() {
160
- posixCaseInsensitiveByPath.clear();
161
- }
162
- export function _getPosixCaseSensitivityCacheSizeForTests() {
163
- return posixCaseInsensitiveByPath.size;
164
- }
165
- /**
166
- * Path-shape-aware containment test: is `candidate` the same path as
167
- * `ancestor`, or inside it?
168
- *
169
- * Exported so the session-cwd registry (`clients/lsp/config.ts`) and the
170
- * foreign-root decline gate (`clients/lsp/index.ts`) test containment with the
171
- * SAME comparator the root ceiling uses. A second hand-rolled `path.relative`
172
- * helper alongside this one is how the two drift apart on Windows-shaped
173
- * paths (shape 2 / #1150): `path.isAbsolute("C:\\repo")` is false on POSIX, so
174
- * a host-default comparator silently mis-answers a win32 path on Linux CI.
175
- */
176
- export function isSameOrWithin(ancestor, candidate, deps = {}) {
177
- const windowsShaped = isWindowsPath(ancestor) || isWindowsPath(candidate);
178
- const pathApi = windowsShaped ? path.win32 : path;
179
- const resolvedAncestor = pathApi.resolve(ancestor);
180
- const resolvedCandidate = pathApi.resolve(candidate);
181
- let caseInsensitive = false;
182
- if (!windowsShaped) {
183
- if (deps.caseInsensitiveProbe) {
184
- const cached = posixCaseInsensitiveByPath.get(resolvedAncestor);
185
- caseInsensitive = cached ?? deps.caseInsensitiveProbe(resolvedAncestor);
186
- if (cached === undefined) {
187
- posixCaseInsensitiveByPath.set(resolvedAncestor, caseInsensitive);
188
- }
189
- }
190
- else {
191
- caseInsensitive = posixFilesystemIsCaseInsensitive(resolvedAncestor);
192
- }
193
- }
194
- const relative = pathApi.relative(caseInsensitive ? resolvedAncestor.toLowerCase() : resolvedAncestor, caseInsensitive ? resolvedCandidate.toLowerCase() : resolvedCandidate);
195
- return (relative === "" ||
196
- (!relative.startsWith("..") && !pathApi.isAbsolute(relative)));
197
- }
198
- /** Enforce the session cwd as the hard boundary for LSP client root selection. */
199
- export function enforceLspRootCeiling(root, sessionCwd, filePath) {
200
- const windowsShaped = isWindowsPath(root) || isWindowsPath(sessionCwd);
201
- const pathApi = windowsShaped ? path.win32 : path;
202
- const resolvedRoot = pathApi.resolve(root);
203
- const resolvedCwd = pathApi.resolve(sessionCwd);
204
- // Callers may explicitly inspect an out-of-session file (notably isolated
205
- // tests and API consumers). The cwd ceiling governs roots for files that are
206
- // actually inside the declared session project.
207
- if (filePath && !isSameOrWithin(resolvedCwd, pathApi.resolve(filePath))) {
208
- return resolvedRoot;
209
- }
210
- if (isSameOrWithin(resolvedCwd, resolvedRoot))
211
- return resolvedRoot;
212
- const logKey = `${normalizeMapKey(resolvedCwd)}:${normalizeMapKey(resolvedRoot)}`;
213
- if (!loggedRootCeilingClamps.has(logKey)) {
214
- loggedRootCeilingClamps.add(logKey);
215
- logSessionStart(`lsp root clamped to session cwd: candidate=${resolvedRoot} cwd=${resolvedCwd}`);
216
- }
217
- return resolvedCwd;
218
- }
219
- export async function hasProjectBoundaryMarker(dir) {
220
- // A nested Git checkout is an independently hosted project even when it has
221
- // no language manifest. Keep this directory boundary aligned with the shared
222
- // FALLBACK_PROJECT_MARKERS policy used by nearestNonExcludedFallbackRoot.
223
- if (await markerExists(dir, ".git"))
224
- return true;
225
- for (const marker of PROJECT_BOUNDARY_MARKERS) {
226
- if (await markerExists(dir, marker))
227
- return true;
228
- }
229
- return false;
230
- }
231
- function pathSegments(dir) {
232
- const parsed = path.parse(path.resolve(dir));
233
- return path
234
- .relative(parsed.root, path.resolve(dir))
235
- .split(path.sep)
236
- .filter(Boolean)
237
- .map((segment) => segment.toLowerCase());
238
- }
239
- function hasFixtureConvention(dir) {
240
- const segments = pathSegments(dir);
241
- // Go treats any directory named testdata as fixture data by convention. The
242
- // exclusion is intentionally ancestor-wide so nested fixture projects cannot
243
- // become independent LSP roots, but the segment match itself stays exact.
244
- if (segments.some((segment) => FIXTURE_ROOT_SEGMENTS.has(segment)))
245
- return true;
246
- return segments.some((segment, index) => segment === "tests" && segments[index + 1] === "fixtures");
247
- }
248
- function hasAtomicStageSegment(dir) {
249
- return pathSegments(dir).some((segment) => STAGE_TMP_PATTERN.test(segment));
250
- }
251
- async function findGitBoundary(dir) {
252
- let current = path.resolve(dir);
253
- const fsRoot = path.parse(current).root;
254
- while (true) {
255
- if (await markerExists(current, ".git"))
256
- return current;
257
- if (current === fsRoot)
258
- return undefined;
259
- current = path.dirname(current);
260
- }
261
- }
262
- async function isExcludedLspRoot(dir) {
263
- const candidate = path.resolve(dir);
264
- if (hasFixtureConvention(candidate) || hasAtomicStageSegment(candidate))
265
- return true;
266
- const gitRoot = await findGitBoundary(candidate);
267
- if (!gitRoot || gitRoot === candidate)
268
- return false;
269
- // Avoid constructing the matcher when the project has no positive ignore rules.
270
- // The matcher remains authoritative (including anchored rules and directory form).
271
- if (getProjectIgnoreGlobs(gitRoot).length === 0)
272
- return false;
273
- return isPathIgnoredByProject(candidate, gitRoot, true);
274
- }
275
- async function nearestNonExcludedFallbackRoot(candidate) {
276
- if (!(await isExcludedLspRoot(candidate)))
277
- return path.resolve(candidate);
278
- let current = path.dirname(path.resolve(candidate));
279
- const fsRoot = path.parse(current).root;
280
- let nearestAllowed;
281
- while (true) {
282
- if (!(await isExcludedLspRoot(current))) {
283
- nearestAllowed ??= current;
284
- for (const marker of FALLBACK_PROJECT_MARKERS) {
285
- if (await markerExists(current, marker))
286
- return current;
287
- }
288
- }
289
- if (current === fsRoot)
290
- break;
291
- current = path.dirname(current);
292
- }
293
- // No project marker was available. Keep the file attached to a stable,
294
- // non-excluded ancestor rather than minting a client inside the fixture/stage.
295
- return nearestAllowed ?? fsRoot;
296
- }
297
- function isLspInstallDisabled() {
298
- return process.env.PI_LENS_DISABLE_LSP_INSTALL === "1";
299
- }
300
- function canInstall(allowInstall) {
301
- return allowInstall !== false && !isLspInstallDisabled();
302
- }
303
- const DIRECT_LSP_NEGATIVE_TTL_MS = Math.max(30_000, Number.parseInt(process.env.PI_LENS_DIRECT_LSP_NEGATIVE_TTL_MS ?? "600000", 10) || 600_000);
304
- const directLspCommandUnavailableUntil = new Map();
305
- const directLspCommandSkipLoggedUntil = new Map();
306
- // Availability rows are emitted from the same async launch path that owns the
307
- // live LSP generation. A session reset can retire that generation while a
308
- // managed lookup, install, or launch is still awaiting; stale work must not
309
- // publish into the replacement session (#2351, shape 22).
310
- const lspLaunchAvailabilityGeneration = createGenerationSource("lsp-launch-availability");
311
- export function resetLspLaunchAvailabilityGeneration() {
312
- lspLaunchAvailabilityGeneration.bump();
313
- }
314
- function staleLaunch(generation, subject, proc) {
315
- if (generation.isCurrent())
316
- return false;
317
- try {
318
- proc?.process?.kill();
319
- }
320
- catch {
321
- // Best-effort cleanup for a process returned after service retirement.
322
- }
323
- generation.guardedWrite(subject, () => undefined);
324
- return true;
325
- }
326
- async function installEvidenceForLaunch(toolId, installed, attempt) {
327
- const evidence = describeInstallAttempt(attempt);
328
- const confirmedManagedPath = await findManagedToolBinary(toolId);
329
- return {
330
- ...evidence,
331
- binary: path.basename(installed),
332
- ...(confirmedManagedPath !== undefined &&
333
- pathsEqual(confirmedManagedPath, installed) && {
334
- source: "managed-dir",
335
- }),
336
- };
337
- }
338
- function captureInstallAttempt(toolId) {
339
- try {
340
- // Capture synchronously after ensureTool resolves. Reading this later, after
341
- // launch/evidence awaits, can observe another concurrent ensure's outcome.
342
- return installer.getInstallAttempt?.(toolId);
343
- }
344
- catch {
345
- // Older test doubles do not expose this production export.
346
- }
347
- return undefined;
348
- }
349
- /** Re-arm direct-command availability for the next session. */
350
- export function resetDirectLspCommandAvailability() {
351
- directLspCommandUnavailableUntil.clear();
352
- directLspCommandSkipLoggedUntil.clear();
353
- }
354
- /** Test seam for seeding the real negative-cache path. */
355
- export function _markDirectLspCommandUnavailableForTests(command) {
356
- markDirectLspCommandUnavailable(command);
357
- }
358
- function pruneExpiredDirectLspNegativeEntries(now = Date.now()) {
359
- for (const [command, until] of directLspCommandUnavailableUntil) {
360
- if (until <= now) {
361
- directLspCommandUnavailableUntil.delete(command);
362
- directLspCommandSkipLoggedUntil.delete(command);
363
- }
364
- }
365
- for (const [command, until] of directLspCommandSkipLoggedUntil) {
366
- if (until <= now)
367
- directLspCommandSkipLoggedUntil.delete(command);
368
- }
369
- }
370
- function isSimpleCommand(command) {
371
- return (!isFullyQualified(command) &&
372
- !command.includes("/") &&
373
- !command.includes("\\"));
374
- }
375
- export function isDirectLspCommandTemporarilyUnavailable(command) {
376
- const now = Date.now();
377
- pruneExpiredDirectLspNegativeEntries(now);
378
- const until = directLspCommandUnavailableUntil.get(command);
379
- if (!until || until <= now) {
380
- directLspCommandUnavailableUntil.delete(command);
381
- return false;
382
- }
383
- const loggedUntil = directLspCommandSkipLoggedUntil.get(command) ?? 0;
384
- if (loggedUntil <= now) {
385
- logSessionStart(`lsp direct command ${command}: skipped by negative availability cache (${Math.max(0, until - now)}ms remaining)`);
386
- directLspCommandSkipLoggedUntil.set(command, until);
387
- }
388
- return true;
389
- }
390
- function markDirectLspCommandUnavailable(command) {
391
- if (!isSimpleCommand(command))
392
- return;
393
- directLspCommandUnavailableUntil.set(command, Date.now() + DIRECT_LSP_NEGATIVE_TTL_MS);
394
- directLspCommandSkipLoggedUntil.delete(command);
395
- }
396
- const PI_LENS_BIN_DIR = path.join(getGlobalPiLensDir(), "bin");
397
- export async function resolveAndLaunch(spec, allowInstall) {
398
- const generation = lspLaunchAvailabilityGeneration.capture();
399
- const toolLabel = spec.managedToolId ??
400
- spec.candidates[spec.candidates.length - 1] ??
401
- "unknown";
402
- let lastRuntimeFailure;
403
- const trackRuntimeFailure = (err) => {
404
- const message = err instanceof Error ? err.message : String(err);
405
- if (!hasSpawnFailureKind(err, "tool-not-found")) {
406
- lastRuntimeFailure = err instanceof Error ? err : new Error(message);
407
- }
408
- };
409
- // #2140: consult the release-managed binary directory (~/.pi-lens/bin)
410
- // BEFORE the bare-PATH candidates below. A bare `candidates: ["opengrep"]`
411
- // entry only ever resolves through the OS's own PATH lookup — never that
412
- // directory — so a github/maven/archive-strategy tool installed there with
413
- // no PATH entry ENOENTs every direct candidate first, only to have the
414
- // managed-install step further down find the very same binary a few
415
- // hundred ms later. `findManagedToolBinary` does a bare `fs.access`, no
416
- // spawn, so this costs at most a few stat calls per server launch attempt
417
- // (not per file/dispatch — launches are session-scoped). Mirrors
418
- // `findManagedNodeToolBinary`'s npm-managed fast path (runner-helpers.ts)
419
- // and `SecurityScanClient.probeVersion`'s equivalent fix for the CLI-scan
420
- // half of this same issue (#2140, landed in PR #2148/#2137).
421
- const managedProbeStartedAt = Date.now();
422
- const managedCandidate = spec.managedToolId
423
- ? await findManagedToolBinary(spec.managedToolId)
424
- : undefined;
425
- if (staleLaunch(generation, `${toolLabel}:findManagedToolBinary`)) {
426
- return undefined;
427
- }
428
- const candidates = managedCandidate && !spec.candidates.includes(managedCandidate)
429
- ? [managedCandidate, ...spec.candidates]
430
- : spec.candidates;
431
- // A candidate that fails while a LATER candidate (or managed install)
432
- // succeeds is just fallback, not a failure — logging each immediately floods
433
- // the logs with scary "candidate failed / npm shim failed / Run npm install"
434
- // lines that read as smells even though the launch succeeded. Collect them and
435
- // surface only if ALL direct candidates fail.
436
- const candidateFailures = [];
437
- // Step 1 & 2 — try all explicit candidates (includes bare command = PATH lookup)
438
- for (const [index, command] of candidates.entries()) {
439
- logLatency({
440
- type: "phase",
441
- phase: "lsp_launch_candidate_attempt",
442
- filePath: spec.cwd,
443
- durationMs: 0,
444
- metadata: {
445
- tool: toolLabel,
446
- command,
447
- index,
448
- totalCandidates: candidates.length,
449
- allowInstall: canInstall(allowInstall),
450
- },
451
- });
452
- logSessionStart(`lsp launch candidate attempt tool=${toolLabel} idx=${index}/${candidates.length - 1} command=${command} cwd=${spec.cwd}`);
453
- try {
454
- const proc = await launchLSP(command, spec.args, {
455
- cwd: spec.cwd,
456
- env: spec.env,
457
- });
458
- if (staleLaunch(generation, `${toolLabel}:launchLSP:${command}`, proc)) {
459
- return undefined;
460
- }
461
- logLatency({
462
- type: "phase",
463
- phase: "lsp_launch_candidate_success",
464
- filePath: spec.cwd,
465
- durationMs: 0,
466
- metadata: {
467
- tool: toolLabel,
468
- command,
469
- index,
470
- source: "direct",
471
- },
472
- });
473
- logSessionStart(`lsp launch candidate success tool=${toolLabel} idx=${index} command=${command} source=direct`);
474
- // The managed-dir fast path (#2140) IS the availability probe for a
475
- // release-managed tool: a real spawn just confirmed the binary
476
- // findManagedToolBinary resolved actually launches. Gated on the exact
477
- // managed candidate (never a later bare-PATH fallback), so a session
478
- // start with the binary present emits exactly one decision and a
479
- // developer-PATH-resolved copy (no managed binary at all) emits none —
480
- // unchanged from before this fix.
481
- if (managedCandidate !== undefined && command === managedCandidate) {
482
- logAvailabilityDecision({
483
- tool: toolLabel,
484
- verdict: "available",
485
- outcome: "success",
486
- cause: "ok",
487
- elapsedMs: Date.now() - managedProbeStartedAt,
488
- latched: false,
489
- producer: "lsp-launch",
490
- classifiedBy: "probe",
491
- evidence: {
492
- binary: path.basename(managedCandidate),
493
- source: "managed-dir",
494
- correctsLatchedRow: false,
495
- },
496
- });
497
- }
498
- return { process: proc, source: "direct" };
499
- }
500
- catch (err) {
501
- if (staleLaunch(generation, `${toolLabel}:launchLSP:${command}`)) {
502
- return undefined;
503
- }
504
- const message = err instanceof Error ? err.message : String(err);
505
- // Defer logging: only a failure if no later candidate/install succeeds.
506
- candidateFailures.push({ index, command, message, err });
507
- // try next
508
- }
509
- }
510
- // All direct candidates failed (a successful one returns above). Surface the
511
- // deferred failures now so the all-failed case stays fully diagnosable.
512
- for (const failure of candidateFailures) {
513
- logLatency({
514
- type: "phase",
515
- phase: "lsp_launch_candidate_failed",
516
- filePath: spec.cwd,
517
- durationMs: 0,
518
- metadata: {
519
- tool: toolLabel,
520
- command: failure.command,
521
- index: failure.index,
522
- error: failure.message,
523
- },
524
- });
525
- logSessionStart(`lsp launch candidate failed tool=${toolLabel} idx=${failure.index} command=${failure.command} error=${failure.message}`);
526
- trackRuntimeFailure(failure.err);
527
- }
528
- const hasOnlyRepairableCandidateFailures = candidateFailures.every((failure) => hasSpawnFailureKind(failure.err, "tool-not-found"));
529
- if (!hasOnlyRepairableCandidateFailures) {
530
- if (lastRuntimeFailure)
531
- throw lastRuntimeFailure;
532
- return undefined;
533
- }
534
- if (!canInstall(allowInstall)) {
535
- logSessionStart(`lsp launch install blocked tool=${toolLabel} cwd=${spec.cwd} allowInstall=${allowInstall !== false} globalDisabled=${isLspInstallDisabled()}`);
536
- logLatency({
537
- type: "phase",
538
- phase: "lsp_launch_install_blocked",
539
- filePath: spec.cwd,
540
- durationMs: 0,
541
- metadata: {
542
- tool: toolLabel,
543
- allowInstall,
544
- globalInstallDisabled: isLspInstallDisabled(),
545
- },
546
- });
547
- return undefined;
548
- }
549
- // Step 3 — managed install via installer registry
550
- if (spec.managedToolId) {
551
- // Neither the managed-dir stat (#2140) nor any bare-PATH candidate
552
- // resolved this tool — the negative counterpart to the fast-path
553
- // "available" decision above, mirroring `SecurityScanClient.probeVersion`'s
554
- // failure record so the negative case is visible in latency.log rather
555
- // than swallowed by going straight to the install attempt.
556
- const failedCandidate = candidateFailures.at(-1);
557
- const classifiedFailure = classifyProbeFailure({
558
- error: failedCandidate?.err instanceof Error
559
- ? failedCandidate.err
560
- : undefined,
561
- spawnFailure: {
562
- kind: hasSpawnFailureKind(failedCandidate?.err, "tool-not-found")
563
- ? "tool-not-found"
564
- : undefined,
565
- },
566
- }, { command: failedCandidate?.command });
567
- logAvailabilityDecision({
568
- tool: spec.managedToolId,
569
- verdict: "unavailable",
570
- outcome: classifiedFailure.outcome,
571
- cause: classifiedFailure.cause,
572
- elapsedMs: Date.now() - managedProbeStartedAt,
573
- latched: false,
574
- classifiedBy: "probe",
575
- producer: "lsp-launch",
576
- evidence: classifiedFailure.evidence,
577
- });
578
- logSessionStart(`lsp launch ensure-tool start tool=${spec.managedToolId} cwd=${spec.cwd}`);
579
- const installStartedAt = Date.now();
580
- const installed = await ensureTool(spec.managedToolId);
581
- const installAttempt = captureInstallAttempt(spec.managedToolId);
582
- if (staleLaunch(generation, `${toolLabel}:ensureTool`)) {
583
- return undefined;
584
- }
585
- logSessionStart(`lsp launch ensure-tool result tool=${spec.managedToolId} installed=${installed ? "yes" : "no"} path=${installed ?? ""}`);
586
- logLatency({
587
- type: "phase",
588
- phase: "lsp_launch_ensure_tool_result",
589
- filePath: spec.cwd,
590
- durationMs: 0,
591
- metadata: {
592
- tool: spec.managedToolId,
593
- installed: Boolean(installed),
594
- path: installed,
595
- },
596
- });
597
- if (installed) {
598
- try {
599
- const proc = await launchLSP(installed, spec.args, {
600
- cwd: spec.cwd,
601
- env: spec.env,
602
- });
603
- if (staleLaunch(generation, `${toolLabel}:launchLSP:managed`, proc)) {
604
- return undefined;
605
- }
606
- logSessionStart(`lsp launch managed success tool=${spec.managedToolId} command=${installed} source=managed`);
607
- logLatency({
608
- type: "phase",
609
- phase: "lsp_launch_managed_success",
610
- filePath: spec.cwd,
611
- durationMs: 0,
612
- metadata: {
613
- tool: spec.managedToolId,
614
- command: installed,
615
- },
616
- });
617
- // Compensating row for the "unavailable" decision logged above (#1606
618
- // shape): the install fixed exactly what the fast path found missing,
619
- // so the durable record must not be left saying the tool is off.
620
- // `source` is only asserted when a fresh managed-dir lookup confirms
621
- // the install actually landed in ~/.pi-lens/bin (github/maven/archive
622
- // strategies) rather than an npm/pip/gem install elsewhere, so the
623
- // evidence never claims a resolution this call didn't derive.
624
- const evidence = await installEvidenceForLaunch(spec.managedToolId, installed, installAttempt);
625
- if (staleLaunch(generation, `${toolLabel}:managed-evidence`, proc)) {
626
- return undefined;
627
- }
628
- logAvailabilityDecision({
629
- tool: spec.managedToolId,
630
- verdict: "available",
631
- outcome: "success",
632
- cause: "ok",
633
- elapsedMs: Date.now() - installStartedAt,
634
- latched: false,
635
- classifiedBy: "caller",
636
- producer: "lsp-launch",
637
- evidence: {
638
- ...evidence,
639
- correctsLatchedRow: false,
640
- },
641
- });
642
- return { process: proc, source: "managed" };
643
- }
644
- catch (err) {
645
- if (staleLaunch(generation, `${toolLabel}:launchLSP:managed-rejection`)) {
646
- return undefined;
647
- }
648
- const message = err instanceof Error ? err.message : String(err);
649
- logSessionStart(`lsp launch managed failed tool=${spec.managedToolId} command=${installed} error=${message}`);
650
- logLatency({
651
- type: "phase",
652
- phase: "lsp_launch_managed_failed",
653
- filePath: spec.cwd,
654
- durationMs: 0,
655
- metadata: {
656
- tool: spec.managedToolId,
657
- command: installed,
658
- error: message,
659
- },
660
- });
661
- trackRuntimeFailure(err);
662
- // force-reinstall: when a PATH-resolved tool (bare command name)
663
- // fails to launch (e.g. broken symlink, missing .dll), nuke the
664
- // caches and download a managed copy from the registry.
665
- const looksPathResolved = !installed.includes("/") && !installed.includes("\\");
666
- if (looksPathResolved && hasSpawnFailureKind(err, "tool-not-found")) {
667
- logSessionStart(`lsp launch managed retry force-reinstall tool=${spec.managedToolId}`);
668
- const reinstalled = await ensureTool(spec.managedToolId, {
669
- forceReinstall: true,
670
- });
671
- const reinstallAttempt = captureInstallAttempt(spec.managedToolId);
672
- if (staleLaunch(generation, `${toolLabel}:forceReinstall`)) {
673
- return undefined;
674
- }
675
- if (reinstalled) {
676
- try {
677
- const proc = await launchLSP(reinstalled, spec.args, {
678
- cwd: spec.cwd,
679
- env: spec.env,
680
- });
681
- if (staleLaunch(generation, `${toolLabel}:launchLSP:forceReinstall`, proc)) {
682
- return undefined;
683
- }
684
- logSessionStart(`lsp launch managed force-reinstall success tool=${spec.managedToolId} command=${reinstalled}`);
685
- logLatency({
686
- type: "phase",
687
- phase: "lsp_launch_managed_force_reinstall_success",
688
- filePath: spec.cwd,
689
- durationMs: 0,
690
- metadata: {
691
- tool: spec.managedToolId,
692
- command: reinstalled,
693
- },
694
- });
695
- const evidence = await installEvidenceForLaunch(spec.managedToolId, reinstalled, reinstallAttempt);
696
- if (staleLaunch(generation, `${toolLabel}:forceReinstall-evidence`, proc)) {
697
- return undefined;
698
- }
699
- logAvailabilityDecision({
700
- tool: spec.managedToolId,
701
- verdict: "available",
702
- outcome: "success",
703
- cause: "ok",
704
- elapsedMs: Date.now() - installStartedAt,
705
- latched: false,
706
- classifiedBy: "caller",
707
- producer: "lsp-launch",
708
- evidence: {
709
- ...evidence,
710
- correctsLatchedRow: false,
711
- },
712
- });
713
- return { process: proc, source: "managed" };
714
- }
715
- catch (retryErr) {
716
- logSessionStart(`lsp launch managed force-reinstall failed tool=${spec.managedToolId} error=${retryErr instanceof Error ? retryErr.message : String(retryErr)}`);
717
- }
718
- }
719
- }
720
- // fall through
721
- }
722
- }
723
- }
724
- // Step 4 — language-native runtime install (go install, gem install, …)
725
- const runtimeInstall = spec.runtimeInstall;
726
- const runtimeAvailable = runtimeInstall
727
- ? await isOnPath(runtimeInstall.runtimeCommand)
728
- : false;
729
- if (staleLaunch(generation, `${toolLabel}:runtimeAvailability`)) {
730
- return undefined;
731
- }
732
- if (runtimeInstall && runtimeAvailable) {
733
- const ok = await runtimeInstall.install();
734
- if (staleLaunch(generation, `${toolLabel}:runtimeInstall`)) {
735
- return undefined;
736
- }
737
- if (ok) {
738
- const retry = runtimeInstall.retryCandidates ?? spec.candidates;
739
- for (const command of retry) {
740
- try {
741
- const proc = await launchLSP(command, spec.args, {
742
- cwd: spec.cwd,
743
- env: spec.env,
744
- });
745
- if (staleLaunch(generation, `${toolLabel}:launchLSP:runtime`, proc)) {
746
- return undefined;
747
- }
748
- return { process: proc, source: "managed" };
749
- }
750
- catch (err) {
751
- if (staleLaunch(generation, `${toolLabel}:launchLSP:runtime`)) {
752
- return undefined;
753
- }
754
- trackRuntimeFailure(err);
755
- // try next
756
- }
757
- }
758
- }
759
- }
760
- if (lastRuntimeFailure) {
761
- throw lastRuntimeFailure;
762
- }
763
- return undefined;
764
- }
765
- /**
766
- * Launch a language server that ships as a multi-folder MODULE BUNDLE driven by a
767
- * separate runtime (e.g. PowerShell Editor Services via `pwsh ...
768
- * Start-EditorServices.ps1 -Stdio`), rather than a single executable on PATH.
769
- *
770
- * Resolution order: (1) a runtime interpreter must be on PATH — else GRACEFUL
771
- * SKIP (returns undefined → the runner's coverage notice, never a hard fail);
772
- * (2) the bundle must be installed (already-extracted, or installed now when
773
- * `allowInstall`) — else graceful skip; (3) launch the runtime against the
774
- * bundle over stdio. A launch failure is logged and also degrades to a skip.
775
- */
776
- async function resolveAndLaunchBundle(spec, allowInstall) {
777
- // 1. Resolve the runtime interpreter on PATH (don't spawn it bare — that would
778
- // hang; just probe). No runtime → graceful skip (coverage notice).
779
- let runtime;
780
- for (const candidate of spec.runtimeCandidates) {
781
- if (await isOnPath(candidate)) {
782
- runtime = candidate;
783
- break;
784
- }
785
- }
786
- if (!runtime) {
787
- logSessionStart(`lsp launch bundle skip tool=${spec.bundleToolId}: no runtime on PATH (tried ${spec.runtimeCandidates.join(", ")})`);
788
- return undefined;
789
- }
790
- // 2. Resolve the bundle directory: already installed, else install when allowed.
791
- let bundleDir = await getToolPath(spec.bundleToolId);
792
- if (!bundleDir && canInstall(allowInstall)) {
793
- bundleDir = await ensureTool(spec.bundleToolId);
794
- }
795
- if (!bundleDir) {
796
- logSessionStart(`lsp launch bundle skip tool=${spec.bundleToolId}: bundle not installed (allowInstall=${allowInstall !== false})`);
797
- return undefined;
798
- }
799
- // 3. Launch the runtime against the bundle over stdio.
800
- try {
801
- const proc = await launchLSP(runtime, spec.args(bundleDir), {
802
- cwd: spec.cwd,
803
- env: spec.env,
804
- });
805
- logSessionStart(`lsp launch bundle success tool=${spec.bundleToolId} runtime=${runtime} bundle=${bundleDir}`);
806
- return { process: proc, source: "managed" };
807
- }
808
- catch (err) {
809
- const message = err instanceof Error ? err.message : String(err);
810
- logSessionStart(`lsp launch bundle failed tool=${spec.bundleToolId} runtime=${runtime} error=${message}`);
811
- return undefined;
812
- }
813
- }
814
- /**
815
- * Launch a language server that ships as a self-contained native TREE BUNDLE with
816
- * its executable INSIDE the extracted tree (e.g. clangd: `<bundle>/bin/clangd`
817
- * plus the bundled libclang headers under `lib/`), as opposed to a single binary
818
- * on PATH or a runtime-driven module bundle (see {@link resolveAndLaunchBundle}).
819
- *
820
- * Resolution order: (1) PATH candidates first — a system install wins; (2) the
821
- * managed bundle (already-extracted, or installed now when `allowInstall`), then
822
- * launch the bin within it. No external runtime. Anything missing → GRACEFUL SKIP
823
- * (returns undefined → the runner's coverage notice, never a hard fail).
824
- */
825
- async function resolveAndLaunchTreeBinary(spec, allowInstall) {
826
- // 1. PATH-first — a system install wins (user-managed, no 150MB download).
827
- for (const command of spec.candidates) {
828
- try {
829
- const proc = await launchLSP(command, spec.args, {
830
- cwd: spec.cwd,
831
- env: spec.env,
832
- });
833
- return { process: proc, source: "direct" };
834
- }
835
- catch {
836
- // not on PATH (or broken) — fall through to the managed bundle
837
- }
838
- }
839
- // 2. Managed tree bundle: already-extracted, else install when allowed.
840
- let bundleDir = await getToolPath(spec.bundleToolId);
841
- if (!bundleDir && canInstall(allowInstall)) {
842
- bundleDir = await ensureTool(spec.bundleToolId);
843
- }
844
- if (!bundleDir) {
845
- logSessionStart(`lsp launch tree-bin skip tool=${spec.bundleToolId}: not on PATH and bundle not installed (allowInstall=${allowInstall !== false})`);
846
- return undefined;
847
- }
848
- const suffix = process.platform === "win32" ? ".exe" : "";
849
- const binPath = path.join(bundleDir, ...spec.binRelPath.split("/")) + suffix;
850
- try {
851
- const proc = await launchLSP(binPath, spec.args, {
852
- cwd: spec.cwd,
853
- env: spec.env,
854
- });
855
- logSessionStart(`lsp launch tree-bin success tool=${spec.bundleToolId} bin=${binPath}`);
856
- return { process: proc, source: "managed" };
857
- }
858
- catch (err) {
859
- const message = err instanceof Error ? err.message : String(err);
860
- logSessionStart(`lsp launch tree-bin failed tool=${spec.bundleToolId} bin=${binPath} error=${message}`);
861
- return undefined;
862
- }
863
- }
864
- function nodeBinLocalCandidates(root, baseName) {
865
- const localBase = path.join(root, "node_modules", ".bin", baseName);
866
- if (process.platform === "win32") {
867
- return [`${localBase}.cmd`, `${localBase}.exe`];
868
- }
869
- return [localBase];
870
- }
871
- function nodeBinCandidates(root, baseName) {
872
- return [...nodeBinLocalCandidates(root, baseName), baseName];
873
- }
874
- function normalizeSlashKey(value) {
875
- const normalized = path.resolve(value).replace(/\\/g, "/");
876
- return process.platform === "win32" ? normalized.toLowerCase() : normalized;
877
- }
878
- function piAgentExtensionsRootKey(file) {
879
- const dirKey = normalizeSlashKey(path.dirname(path.resolve(file)));
880
- const marker = "/.pi/agent/extensions";
881
- const index = dirKey.indexOf(marker);
882
- if (index === -1)
883
- return undefined;
884
- return dirKey.slice(0, index + marker.length);
885
- }
886
- function normalizeRootKey(root) {
887
- return process.platform === "win32"
888
- ? path.resolve(root).toLowerCase()
889
- : path.resolve(root);
890
- }
891
- function IgnoreHomeRoot(primary) {
892
- const homeKey = normalizeRootKey(os.homedir());
893
- return withRootMarkers(async (file) => {
894
- const root = await primary(file);
895
- if (!root)
896
- return undefined;
897
- return normalizeRootKey(root) === homeKey ? undefined : root;
898
- }, primary.rootMarkers ?? []);
899
- }
900
- function rubyBinCandidates(baseName) {
901
- const candidates = [];
902
- const home = os.homedir();
903
- const isWin = process.platform === "win32";
904
- const ext = isWin ? ".bat" : "";
905
- // mise and asdf version managers — same layout on all platforms
906
- candidates.push(path.join(home, ".local", "share", "mise", "installs", "ruby", "bin", `${baseName}${ext}`));
907
- candidates.push(path.join(home, ".asdf", "installs", "ruby", "bin", `${baseName}${ext}`));
908
- if (isWin) {
909
- // Ruby installer drops versioned dirs on C: by convention, but the drive
910
- // and version suffix vary — scan what's actually present instead of
911
- // hardcoding. Memoized once per process (#1137): a synchronous drive-root
912
- // enumeration per LSP spawn was an event-loop offender.
913
- const driveRoot = path.parse(home).root; // e.g. "C:\"
914
- for (const entry of getRubyVersionDirNamesSync(driveRoot)) {
915
- candidates.push(path.join(driveRoot, entry, "bin", `${baseName}.bat`));
916
- candidates.push(path.join(driveRoot, entry, "bin", baseName));
917
- }
918
- }
919
- return candidates;
920
- }
921
- function createInteractiveServer(spec) {
922
- return {
923
- id: spec.id,
924
- name: spec.name,
925
- extensions: spec.extensions,
926
- root: spec.root,
927
- rootMarkers: spec.root.rootMarkers,
928
- fallbackFor: spec.fallbackFor,
929
- availabilityKey: typeof spec.command === "string" && isSimpleCommand(spec.command)
930
- ? spec.command
931
- : undefined,
932
- async spawn(root) {
933
- const command = typeof spec.command === "function" ? spec.command(root) : spec.command;
934
- const args = typeof spec.args === "function" ? spec.args(root) : spec.args || [];
935
- // Try to launch directly — no auto-install for language-runtime tools
936
- // (C#, Java, Swift, etc. require their SDK; cannot npm/pip install them)
937
- if (isSimpleCommand(command) &&
938
- isDirectLspCommandTemporarilyUnavailable(command)) {
939
- return undefined;
940
- }
941
- // #241: the server binary is run by a language runtime (jdtls → java).
942
- // When that runtime isn't on PATH, inject a discovered install's env so
943
- // it launches instead of silently failing with no_clients.
944
- const runtimeEnv = spec.runtime === "java" ? await resolveJavaRuntimeEnv() : undefined;
945
- try {
946
- const proc = await launchLSP(command, args, {
947
- cwd: root,
948
- ...(runtimeEnv ? { env: runtimeEnv } : {}),
949
- });
950
- const initialization = typeof spec.initialization === "function"
951
- ? spec.initialization(root)
952
- : spec.initialization;
953
- return { process: proc, source: "direct", initialization };
954
- }
955
- catch (err) {
956
- if (hasSpawnFailureKind(err, "tool-not-found")) {
957
- markDirectLspCommandUnavailable(command);
958
- }
959
- return undefined;
960
- }
961
- },
962
- };
963
- }
964
- export function PriorityRoot(markerGroups, excludePatterns, stopDir) {
965
- const resolvers = markerGroups.map((markers) => NearestRoot(markers, excludePatterns, stopDir));
966
- return withRootMarkers(async (file) => {
967
- for (const resolve of resolvers) {
968
- const root = await resolve(file);
969
- if (root)
970
- return root;
971
- }
972
- return undefined;
973
- }, markerGroups.flat());
974
- }
975
- export const FileDirRoot = async (file) => {
976
- const candidate = path.resolve(path.dirname(file));
977
- return nearestNonExcludedFallbackRoot(candidate);
978
- };
979
- export function RootWithFallback(primary, fallback = FileDirRoot) {
980
- return withRootMarkers(async (file) => {
981
- const primaryRoot = await primary(file);
982
- if (primaryRoot)
983
- return primaryRoot;
984
- return fallback(file);
985
- }, primary.rootMarkers ?? []);
986
- }
987
- export function WorkspacePriorityRoot(markerGroups, excludePatterns) {
988
- return withRootMarkers(async (file) => PriorityRoot(markerGroups, excludePatterns, process.cwd())(file), markerGroups.flat());
989
- }
990
- function isPermissionFsError(err) {
991
- const code = err?.code;
992
- return code === "EACCES" || code === "EPERM";
993
- }
994
- async function markerExists(dir, pattern) {
995
- if (!pattern.includes("*")) {
996
- try {
997
- await stat(path.join(dir, pattern));
998
- return true;
999
- }
1000
- catch (err) {
1001
- if (isPermissionFsError(err)) {
1002
- logSessionStart(`lsp root marker skipped: permission error stat ${path.join(dir, pattern)}`);
1003
- }
1004
- return false;
1005
- }
1006
- }
1007
- const normalized = pattern.replace(/\\/g, "/");
1008
- const slash = normalized.lastIndexOf("/");
1009
- const parentPattern = slash >= 0 ? normalized.slice(0, slash) : "";
1010
- const basenamePattern = slash >= 0 ? normalized.slice(slash + 1) : normalized;
1011
- if (!basenamePattern)
1012
- return false;
1013
- const targetDir = parentPattern
1014
- ? path.join(dir, ...parentPattern.split("/").filter(Boolean))
1015
- : dir;
1016
- try {
1017
- const entries = await readdir(targetDir, { withFileTypes: true });
1018
- // Match files/symlinks only — a directory named like the marker (e.g. a
1019
- // `Foo.csproj/` dir) is not a project file. Case-insensitive on win32 to
1020
- // match the filesystem (and the project ignore matcher), via the shared
1021
- // marker-glob helper.
1022
- return direntsHaveMarkerGlobMatch(entries, basenamePattern);
1023
- }
1024
- catch (err) {
1025
- if (isPermissionFsError(err)) {
1026
- logSessionStart(`lsp root marker skipped: permission error read ${targetDir}`);
1027
- }
1028
- return false;
1029
- }
1030
- }
1031
- // --- Root Detection Helpers ---
1032
- // --- Interactive Install Helper ---
1033
- /**
1034
- * Walk up the directory tree looking for project root markers.
1035
- *
1036
- * NearestRoot(includePatterns, excludePatterns?) → RootFunction
1037
- *
1038
- * - includePatterns: file/dir names that signal the project root (e.g. ["package.json"])
1039
- * - excludePatterns: if any of these exist in a directory, SKIP that directory and
1040
- * keep walking up toward stopDir — it does not abort resolution. A directory that
1041
- * matches an exclude pattern is simply never returned as a root; the walk still
1042
- * continues past it looking for an include-pattern hit higher up (#1671).
1043
- * - stopDir: walk stops here (defaults to filesystem root; set to project cwd for safety)
1044
- *
1045
- * Equivalent to createRootDetector; exported under both names for clarity.
1046
- */
1047
- export function NearestRoot(includePatterns, excludePatterns, stopDir) {
1048
- // Per-instance caches — each NearestRoot(markers) call gets its own Map so
1049
- // different servers (e.g. TypeScript vs Go) with different marker sets never
1050
- // share entries. vi.resetModules() in tests resets module state between cases.
1051
- const cache = new Map();
1052
- // Only cache successful hits. Undefined results are NOT cached so that a
1053
- // newly-created root marker (e.g. package.json or tsconfig.json scaffolded
1054
- // mid-session by the agent) is detected on the next call — the absent →
1055
- // present transition must work without a process restart. The uncached
1056
- // re-walk cost for configless repos is a known trade-off; bounding the
1057
- // walk with stopDir for in-cwd files is the tracked optimization (#1412).
1058
- const inFlight = new Map();
1059
- return withRootMarkers(async (file) => {
1060
- // Cache key is the resolved directory — all files in the same dir share a root.
1061
- const startDir = path.resolve(path.dirname(file));
1062
- const dirKey = normalizeMapKey(startDir);
1063
- // Fast path: already resolved for this directory.
1064
- const cached = cache.get(dirKey);
1065
- if (cached !== undefined)
1066
- return cached;
1067
- // In-flight deduplication: if N parallel pipelines edit files in the same
1068
- // directory simultaneously, only one stat-walk runs; the rest await the same
1069
- // promise. This is the main fix for parallel-turn LSP timeout spikes.
1070
- const flying = inFlight.get(dirKey);
1071
- if (flying)
1072
- return flying;
1073
- const promise = (async () => {
1074
- let currentDir = startDir;
1075
- const fsRoot = path.parse(currentDir).root;
1076
- const stop = stopDir ? path.resolve(stopDir) : fsRoot;
1077
- while (true) {
1078
- if (stop !== fsRoot &&
1079
- currentDir.startsWith(stop + path.sep) === false &&
1080
- currentDir !== stop) {
1081
- break;
1082
- }
1083
- // Check exclude patterns — skip this dir (but keep walking up)
1084
- if (excludePatterns) {
1085
- let excluded = false;
1086
- for (const pattern of excludePatterns) {
1087
- if (await markerExists(currentDir, pattern)) {
1088
- excluded = true;
1089
- break;
1090
- }
1091
- }
1092
- if (excluded) {
1093
- currentDir = path.dirname(currentDir);
1094
- continue;
1095
- }
1096
- }
1097
- // Check include patterns. Exact marker names stay cheap (`stat`), while
1098
- // glob markers like `*.csproj` match real project filenames (#201).
1099
- for (const pattern of includePatterns) {
1100
- if ((await markerExists(currentDir, pattern)) &&
1101
- (pattern !== ".git" || currentDir !== os.tmpdir()) &&
1102
- !(await isExcludedLspRoot(currentDir))) {
1103
- return enforceLspRootCeiling(currentDir, process.cwd(), file);
1104
- }
1105
- }
1106
- if (currentDir === stop || currentDir === fsRoot) {
1107
- break;
1108
- }
1109
- currentDir = path.dirname(currentDir);
1110
- }
1111
- return undefined;
1112
- })();
1113
- inFlight.set(dirKey, promise);
1114
- try {
1115
- const result = await promise;
1116
- if (result !== undefined)
1117
- cache.set(dirKey, result);
1118
- return result;
1119
- }
1120
- finally {
1121
- inFlight.delete(dirKey);
1122
- }
1123
- }, includePatterns);
1124
- }
1125
- /** Alias kept for backward compatibility */
1126
- export const createRootDetector = NearestRoot;
1127
- // --- Runtime Tool Helpers ---
1128
- /**
1129
- * Check if a command is available on system PATH.
1130
- *
1131
- * Async (was a blocking `spawnSync("where"/"which")`): runs on the spawn
1132
- * fall-through path (Step 4, runtime-install gate). The shared
1133
- * `isCommandAvailableAsync` spawns the same finder via `safeSpawnAsync` with a
1134
- * 5s timeout, so a stalled finder can no longer freeze the loop. Semantics are
1135
- * preserved: true iff the finder exits 0.
1136
- */
1137
- function isOnPath(command) {
1138
- return isCommandAvailableAsync(command);
1139
- }
1140
- /**
1141
- * Try to install gopls via `go install`. Resolves true if the install succeeded.
1142
- *
1143
- * Async (was a blocking `spawnSync`): runs on the LSP runtime-install gate, off
1144
- * the event loop. `ignoreAmbientSignal` keeps the install running to completion
1145
- * even if the agent turn is interrupted, matching the old uncancellable sync
1146
- * behaviour. Success semantics preserved: true iff the process exits 0.
1147
- */
1148
- export async function tryGoInstallGopls() {
1149
- const isWindows = process.platform === "win32";
1150
- const result = await safeSpawnAsync(isWindows ? "go.exe" : "go", ["install", "golang.org/x/tools/gopls@latest"], { timeout: 180000, ignoreAmbientSignal: true });
1151
- return !result.error && result.status === 0;
1152
- }
1153
- export async function tryDotnetToolInstall(tool) {
1154
- mkdirSync(PI_LENS_BIN_DIR, { recursive: true });
1155
- const result = await safeSpawnAsync("dotnet", ["tool", "install", "--tool-path", PI_LENS_BIN_DIR, tool], { timeout: 180000, ignoreAmbientSignal: true });
1156
- if (!result.error && result.status === 0)
1157
- return true;
1158
- const stderr = result.stderr ?? "";
1159
- if (stderr.includes("No NuGet sources are defined or enabled")) {
1160
- logSessionStart(`lsp dotnet-install: NuGet sources missing — cannot install ${tool}. ` +
1161
- `Run: dotnet nuget add source https://api.nuget.org/v3/index.json -n nuget.org`);
1162
- return false;
1163
- }
1164
- const updateResult = await safeSpawnAsync("dotnet", ["tool", "update", "--tool-path", PI_LENS_BIN_DIR, tool], { timeout: 180000, ignoreAmbientSignal: true });
1165
- return !updateResult.error && updateResult.status === 0;
1166
- }
1167
- /**
1168
- * #1412 M1: walk up from `startDir` (inclusive) looking for a file at
1169
- * `startDir/<relativeSegments>`, `dirname(startDir)/<relativeSegments>`, and so
1170
- * on — the same ancestor walk `findNativeTypeScriptLsp` uses, bounded the same
1171
- * way (`isAtOrAboveHomeDir`). A nested config root (e.g. a `cypress/tsconfig.json`
1172
- * LSP root inside a repo whose `node_modules` only exists at the repo root) must
1173
- * still resolve tooling installed at an ancestor, not just directly under the
1174
- * LSP root — mirrors how node module resolution itself walks up.
1175
- */
1176
- async function findAncestorFile(startDir, ...relativeSegments) {
1177
- return findAncestorFileAmong(startDir, [relativeSegments]);
1178
- }
1179
- /**
1180
- * Same ancestor walk as `findAncestorFile`, but checks every candidate
1181
- * relative-path in `candidateSegmentLists` AT EACH LEVEL before moving up —
1182
- * so the nearest ancestor wins regardless of which candidate name matched
1183
- * there, matching normal node_modules resolution priority (nearest install
1184
- * shadows a further one, never the reverse).
1185
- */
1186
- async function findAncestorFileAmong(startDir, candidateSegmentLists) {
1187
- const fs = await import("node:fs/promises");
1188
- let currentDir = path.resolve(startDir);
1189
- while (!isAtOrAboveHomeDir(currentDir)) {
1190
- for (const segments of candidateSegmentLists) {
1191
- const candidate = path.join(currentDir, ...segments);
1192
- try {
1193
- await fs.access(candidate);
1194
- return candidate;
1195
- }
1196
- catch {
1197
- /* not found at this level */
1198
- }
1199
- }
1200
- const parent = path.dirname(currentDir);
1201
- if (parent === currentDir)
1202
- break;
1203
- currentDir = parent;
1204
- }
1205
- return undefined;
1206
- }
1207
- /**
1208
- * A failed classic-compiler repair must not repeat within a session.
1209
- * `ensureTool` caches successful installs, so a repair that works
1210
- * short-circuits later calls on its own. A repair that fails leaves nothing
1211
- * behind, and `findTsserverPath` has three call sites (TypeScript, Vue,
1212
- * Svelte). Without this guard an offline or partial install re-runs a 120 s
1213
- * forced reinstall on every spawn.
1214
- *
1215
- * The guard is process-singleton state, so without an explicit re-arm it
1216
- * would latch for the whole extension-host process — a repair that failed
1217
- * once (transient registry hiccup) would stay unrepairable for every later
1218
- * session in that process. `resetClassicTsRepairGuard` re-arms it; callers
1219
- * wire that into `session_start` alongside the other per-session resets
1220
- * (#1570).
1221
- */
1222
- const CLASSIC_TS_REPAIR_FAMILY = "lsp.classic-ts-repair-guard";
1223
- const CLASSIC_TS_REPAIR_VERSION = 1;
1224
- function classicTsRepairState() {
1225
- return getProcessSingleton(CLASSIC_TS_REPAIR_FAMILY, CLASSIC_TS_REPAIR_VERSION, () => ({ attempted: false }));
1226
- }
1227
- /** Re-arm the classic-repair guard so a new session gets its own attempt. */
1228
- export function resetClassicTsRepairGuard() {
1229
- classicTsRepairState().attempted = false;
1230
- }
1231
- /** Test hook — clears the process-singleton classic-repair guard. */
1232
- export function _resetClassicTsRepairForTests() {
1233
- resetClassicTsRepairGuard();
1234
- }
1235
- /**
1236
- * Directories that may hold the TypeScript package next to a resolved `tsc`
1237
- * binary: `<bin>/../typescript` (npm-global layout) and
1238
- * `<bin>/../../typescript` (managed `node_modules/.bin` layout).
1239
- */
1240
- function typescriptDirsForTsc(tscPath) {
1241
- const binDir = path.dirname(tscPath);
1242
- return [
1243
- path.join(binDir, "..", "typescript"),
1244
- path.join(binDir, "..", "..", "typescript"),
1245
- ];
1246
- }
1247
- /**
1248
- * Read the major version of the TypeScript package that backs a resolved
1249
- * `tsc` binary. Returns undefined when the version is unknowable: `ensureTool`
1250
- * returns the bare string `"tsc"` for a PATH hit, and `path.dirname("tsc")` is
1251
- * `"."`, so the candidates would go cwd-relative. Callers must not repair on
1252
- * an unknown version — a healthy global TypeScript 5.x would be reinstalled
1253
- * for nothing.
1254
- */
1255
- async function typescriptVersionForTsc(tscPath) {
1256
- if (!path.isAbsolute(tscPath))
1257
- return undefined;
1258
- for (const dir of typescriptDirsForTsc(tscPath)) {
1259
- let manifest;
1260
- try {
1261
- manifest = await readFile(path.join(dir, "package.json"), "utf8");
1262
- }
1263
- catch {
1264
- continue;
1265
- }
1266
- let version;
1267
- try {
1268
- const parsed = JSON.parse(manifest);
1269
- if (typeof parsed !== "object" ||
1270
- parsed === null ||
1271
- !("version" in parsed) ||
1272
- typeof parsed.version !== "string") {
1273
- return undefined;
1274
- }
1275
- version = parsed.version;
1276
- }
1277
- catch {
1278
- return undefined;
1279
- }
1280
- const majorText = version.split(".", 1)[0] ?? "";
1281
- const major = /^\d+$/.test(majorText) ? Number(majorText) : Number.NaN;
1282
- if (!Number.isFinite(major))
1283
- return undefined;
1284
- return { version, major };
1285
- }
1286
- return undefined;
1287
- }
1288
- /**
1289
- * Locate tsserver.js — tries local project (walking up from root, #1412 M1),
1290
- * then process.cwd() as a last-resort fallback, then pi-lens managed
1291
- * TypeScript. Returns the path to tsserver.js, or undefined if not found.
1292
- */
1293
- async function findTsserverPath(root, allowInstall) {
1294
- const fs = await import("node:fs/promises");
1295
- const ancestorHit = await findAncestorFile(root, "node_modules", "typescript", "lib", "tsserver.js");
1296
- if (ancestorHit)
1297
- return ancestorHit;
1298
- const cwdCandidate = path.join(process.cwd(), "node_modules", "typescript", "lib", "tsserver.js");
1299
- try {
1300
- await fs.access(cwdCandidate);
1301
- return cwdCandidate;
1302
- }
1303
- catch {
1304
- /* not found */
1305
- }
1306
- const tsserverForTsc = async (tscPath) => {
1307
- if (!tscPath)
1308
- return undefined;
1309
- for (const dir of typescriptDirsForTsc(tscPath)) {
1310
- const candidate = path.join(dir, "lib", "tsserver.js");
1311
- try {
1312
- await fs.access(candidate);
1313
- return candidate;
1314
- }
1315
- catch {
1316
- /* not found */
1317
- }
1318
- }
1319
- return undefined;
1320
- };
1321
- // Discover the TypeScript install (PATH / npm-global) even when installation
1322
- // is disabled; only the download is gated by allowInstall.
1323
- const installAllowed = canInstall(allowInstall);
1324
- const discoveredTsc = await ensureTool("typescript", {
1325
- allowInstall: installAllowed,
1326
- });
1327
- const discoveredTsserver = await tsserverForTsc(discoveredTsc);
1328
- if (discoveredTsserver ||
1329
- !discoveredTsc ||
1330
- !installAllowed ||
1331
- classicTsRepairState().attempted) {
1332
- return discoveredTsserver;
1333
- }
1334
- // Repair only a compiler we can prove is TypeScript 7+. TypeScript 7 dropped
1335
- // lib/tsserver.js, so the classic wrapper cannot start against it. Any other
1336
- // version — or a version we cannot read, such as a bare PATH `tsc` — is left
1337
- // alone rather than force-reinstalled.
1338
- const discoveredVersion = await typescriptVersionForTsc(discoveredTsc);
1339
- if (!discoveredVersion || discoveredVersion.major < 7)
1340
- return undefined;
1341
- // An older managed tree took `latest` before the registry pinned the classic
1342
- // compiler. Reinstall the pinned version once so that tree self-heals,
1343
- // without deleting user or project-local TypeScript installations.
1344
- classicTsRepairState().attempted = true;
1345
- logSessionStart(`lsp typescript: managed compiler resolved to TypeScript ${discoveredVersion.version}, which ships no tsserver.js; reinstalling pinned classic fallback`);
1346
- const repairedTsc = await ensureTool("typescript", {
1347
- allowInstall: true,
1348
- forceReinstall: true,
1349
- });
1350
- return tsserverForTsc(repairedTsc);
1351
- }
1352
- /**
1353
- * TypeScript 7+ ships the native typescript-go language server through the
1354
- * workspace-local `tsc --lsp --stdio` entrypoint and no longer includes
1355
- * `lib/tsserver.js`. Resolve the nearest TypeScript package using normal
1356
- * node_modules ancestor semantics so a monorepo package can use its hoisted
1357
- * compiler, while never falling through to a PATH/global `tsc`.
1358
- */
1359
- async function findNativeTypeScriptLsp(root) {
1360
- let currentDir = path.resolve(root);
1361
- while (!isAtOrAboveHomeDir(currentDir)) {
1362
- const typescriptDir = path.join(currentDir, "node_modules", "typescript");
1363
- const packageJsonPath = path.join(typescriptDir, "package.json");
1364
- let packageJsonText;
1365
- try {
1366
- packageJsonText = await readFile(packageJsonPath, "utf8");
1367
- }
1368
- catch (error) {
1369
- if (error.code !== "ENOENT") {
1370
- return undefined;
1371
- }
1372
- // A `node_modules/typescript/` directory that exists but has no
1373
- // `package.json` is a malformed/partial install at THIS level, not an
1374
- // absent one — stop here (fall back to classic) rather than walking up
1375
- // to an ancestor, or a broken nearest install would let an unrelated
1376
- // ancestor TS 7 binary silently shadow it (Copilot review, PR #526).
1377
- try {
1378
- const dirStat = await stat(typescriptDir);
1379
- if (dirStat.isDirectory())
1380
- return undefined;
1381
- }
1382
- catch {
1383
- /* typescript dir itself doesn't exist here — keep walking up */
1384
- }
1385
- const parent = path.dirname(currentDir);
1386
- if (parent === currentDir)
1387
- return undefined;
1388
- currentDir = parent;
1389
- continue;
1390
- }
1391
- let version;
1392
- try {
1393
- const parsed = JSON.parse(packageJsonText);
1394
- if (typeof parsed !== "object" ||
1395
- parsed === null ||
1396
- !("version" in parsed) ||
1397
- typeof parsed.version !== "string") {
1398
- return undefined;
1399
- }
1400
- version = parsed.version;
1401
- }
1402
- catch {
1403
- return undefined;
1404
- }
1405
- // The nearest installed package shadows any ancestor TypeScript package,
1406
- // matching Node/package-manager resolution. Never skip a local TS <=6 or
1407
- // malformed install just to select an unrelated ancestor TS 7 binary.
1408
- const majorText = version.split(".", 1)[0] ?? "";
1409
- const major = /^\d+$/.test(majorText) ? Number(majorText) : Number.NaN;
1410
- if (!Number.isFinite(major) || major < 7)
1411
- return undefined;
1412
- const localTsc = path.join(currentDir, "node_modules", ".bin", "tsc");
1413
- const candidates = process.platform === "win32"
1414
- ? [`${localTsc}.cmd`, `${localTsc}.exe`, localTsc]
1415
- : [localTsc];
1416
- for (const command of candidates) {
1417
- try {
1418
- await access(command);
1419
- return { command, version };
1420
- }
1421
- catch {
1422
- /* not found */
1423
- }
1424
- }
1425
- return undefined;
1426
- }
1427
- return undefined;
1428
- }
1429
- function dotnetToolCandidates(tool) {
1430
- const home = os.homedir();
1431
- return [
1432
- path.join(PI_LENS_BIN_DIR, `${tool}.exe`),
1433
- path.join(PI_LENS_BIN_DIR, tool),
1434
- path.join(home, ".dotnet", "tools", `${tool}.exe`),
1435
- path.join(home, ".dotnet", "tools", tool),
1436
- tool,
1437
- ].filter(Boolean);
1438
- }
1439
- /**
1440
- * Both filename forms for a tool in a directory (`.exe` first on Windows). A
1441
- * managed binary may carry the extension or not depending on how the toolchain
1442
- * dropped it, so we try both.
1443
- */
1444
- function binExeVariants(dir, tool) {
1445
- return process.platform === "win32"
1446
- ? [path.join(dir, `${tool}.exe`), path.join(dir, tool)]
1447
- : [path.join(dir, tool)];
1448
- }
1449
- /**
1450
- * Canonical-bin discovery (#241): a runtime-managed server can be installed yet
1451
- * absent from the shell PATH — the toolchain drops it in a well-known dir the
1452
- * user's PATH often omits (fresh installs, Windows, non-login shells). Returning
1453
- * the bare command FIRST keeps PATH authoritative when it resolves; the explicit
1454
- * dir paths are the fallback (and the post-`go install` retry target).
1455
- *
1456
- * Go: `$GOPATH/bin` (first GOPATH entry) or `~/go/bin` — where `go install` lands.
1457
- */
1458
- export function goBinCandidates(tool) {
1459
- const gopath = process.env.GOPATH?.split(path.delimiter)[0] ||
1460
- path.join(os.homedir(), "go");
1461
- return [tool, ...binExeVariants(path.join(gopath, "bin"), tool)];
1462
- }
1463
- /** Rust: `$CARGO_HOME/bin` or `~/.cargo/bin` — cargo/rustup binaries + proxies. */
1464
- export function cargoBinCandidates(tool) {
1465
- const cargoHome = process.env.CARGO_HOME || path.join(os.homedir(), ".cargo");
1466
- return [tool, ...binExeVariants(path.join(cargoHome, "bin"), tool)];
1467
- }
1468
- /**
1469
- * Try to install a gem to the pi-lens bin dir. Resolves true if the install succeeded.
1470
- */
1471
- export async function tryGemInstall(gem) {
1472
- const { join } = await import("node:path");
1473
- const binDir = join(getGlobalPiLensDir(), "bin");
1474
- const { mkdir } = await import("node:fs/promises");
1475
- await mkdir(binDir, { recursive: true });
1476
- const result = await safeSpawnAsync("gem", ["install", gem, "--bindir", binDir, "--no-document"], { timeout: 180000, ignoreAmbientSignal: true });
1477
- const ok = !result.error && result.status === 0;
1478
- // Add binDir to PATH so subsequent lookups find the installed gem
1479
- if (ok) {
1480
- const sep = process.platform === "win32" ? ";" : ":";
1481
- if (!process.env.PATH?.includes(binDir)) {
1482
- process.env.PATH = `${binDir}${sep}${process.env.PATH ?? ""}`;
1483
- }
1484
- }
1485
- return ok;
1486
- }
1487
- /**
1488
- * Wraps a root function so it returns undefined for files inside a Deno project.
1489
- * Prevents TypeScript LSP from being spawned alongside Deno LSP for the same file,
1490
- * which would produce false diagnostics for Deno-specific APIs.
1491
- */
1492
- export function DenoExcludeRoot(primary) {
1493
- const denoDetector = createRootDetector(["deno.json", "deno.jsonc"]);
1494
- return async (file) => {
1495
- const denoRoot = await denoDetector(file);
1496
- if (denoRoot)
1497
- return undefined;
1498
- return primary(file);
1499
- };
1500
- }
1501
- // --- Server Definitions ---
1502
- const JS_TS_LSP_EXTENSIONS = KIND_EXTENSIONS["jsts"].filter((ext) => ext !== ".svelte" && ext !== ".vue");
1503
- // TypeScript identity and tooling discovery deliberately use separate marker
1504
- // families. A governing config wins even when a package directory supplies
1505
- // hoisted binaries. Keep configs out of PROJECT_BOUNDARY_MARKERS: #1373 still
1506
- // coalesces a config-only nested root when an ancestor client was hosted first
1507
- // (nested-config-first remains intentionally open-order-sensitive). #1412
1508
- // accepted risk (M2, not fixed here): honoring nested config roots at all
1509
- // enlarges the population of directories that can independently coalesce or
1510
- // diverge under #1373's open-order sensitivity — the same pre-existing
1511
- // blast-radius, just triggered by more roots than before.
1512
- const TS_CONFIG_MARKERS = ["tsconfig.json", "jsconfig.json"];
1513
- const TS_TOOLING_MARKERS = [
1514
- "package-lock.json",
1515
- "bun.lockb",
1516
- "bun.lock",
1517
- "pnpm-lock.yaml",
1518
- "yarn.lock",
1519
- "package.json",
1520
- ];
1521
- // #1412 M3: tsserver associates jsconfig.json with JS files only (its identity
1522
- // probe reports a jsconfig-governed .ts file as unassociated) — so a TS-family
1523
- // file under a jsconfig-only directory must NOT root there; keep walking up
1524
- // for a real tsconfig.json. A JS-family file accepts either: tsconfig also
1525
- // governs plain JS via `allowJs`, so accepting tsconfig for a .js file is
1526
- // correct, and jsconfig obviously is too.
1527
- const TS_FAMILY_EXTENSIONS = new Set([".ts", ".tsx", ".mts", ".cts"]);
1528
- function isTsFamilyFile(file) {
1529
- return TS_FAMILY_EXTENSIONS.has(path.extname(file).toLowerCase());
1530
- }
1531
- function tsConfigMarkersForFile(file) {
1532
- return isTsFamilyFile(file)
1533
- ? ["tsconfig.json"]
1534
- : TS_CONFIG_MARKERS;
1535
- }
1536
- // Two detector instances (not one parameterized by file) so each keeps its own
1537
- // per-directory NearestRoot cache valid for its fixed marker set — a shared
1538
- // cache keyed only by directory would conflate the TS-only and either-config
1539
- // answers for the same directory.
1540
- const TypeScriptConfigRootTsOnly = IgnoreHomeRoot(createRootDetector(["tsconfig.json"]));
1541
- const TypeScriptConfigRootEither = IgnoreHomeRoot(createRootDetector([...TS_CONFIG_MARKERS]));
1542
- const TypeScriptToolingRoot = IgnoreHomeRoot(createRootDetector([...TS_TOOLING_MARKERS]));
1543
- async function findTypeScriptProjectRoot(file) {
1544
- const configDetector = isTsFamilyFile(file)
1545
- ? TypeScriptConfigRootTsOnly
1546
- : TypeScriptConfigRootEither;
1547
- const [configRoot, toolingRoot] = await Promise.all([
1548
- configDetector(file),
1549
- TypeScriptToolingRoot(file),
1550
- ]);
1551
- if (!configRoot)
1552
- return toolingRoot;
1553
- if (!toolingRoot)
1554
- return configRoot;
1555
- // A config inside (or beside) the nearest package governs its files. A
1556
- // config above a nearer package must not erase that topology boundary.
1557
- return isSameOrWithin(toolingRoot, configRoot) ? configRoot : toolingRoot;
1558
- }
1559
- /**
1560
- * Walk up from the file's directory looking for a TypeScript project marker
1561
- * (a governing config first, per-directory, per #1412 M3's extension-family
1562
- * filter; else a tooling/lockfile marker), but stop at `extensionRootKey` so
1563
- * we never escape the .pi/agent/extensions boundary into a higher-up project
1564
- * (e.g. ~/.pi/agent/package.json which would pull every extension in the
1565
- * directory into one LSP workspace).
1566
- *
1567
- * #1412 L4: each directory returns immediately on its first match (config or
1568
- * tooling) — there is no cross-level "nearest tooling root" to carry forward,
1569
- * since a match always wins on the spot. Returns the nearest directory
1570
- * containing a marker, or undefined if none is found between the file and the
1571
- * extensions root inclusive.
1572
- */
1573
- async function findExtensionBoundedRoot(file, extensionRootKey) {
1574
- const startDir = path.resolve(path.dirname(file));
1575
- let currentDir = startDir;
1576
- const configMarkers = tsConfigMarkersForFile(file);
1577
- while (true) {
1578
- for (const pattern of configMarkers) {
1579
- try {
1580
- await stat(path.join(currentDir, pattern));
1581
- return currentDir;
1582
- }
1583
- catch {
1584
- /* not found, try next marker */
1585
- }
1586
- }
1587
- for (const pattern of TS_TOOLING_MARKERS) {
1588
- try {
1589
- await stat(path.join(currentDir, pattern));
1590
- return currentDir;
1591
- }
1592
- catch {
1593
- /* not found, try next marker */
1594
- }
1595
- }
1596
- // Stop at or beyond the extensions root — never walk into the
1597
- // pi-agent-wide scope.
1598
- const currentKey = normalizeSlashKey(currentDir);
1599
- if (currentKey === extensionRootKey)
1600
- return undefined;
1601
- const parent = path.dirname(currentDir);
1602
- if (parent === currentDir)
1603
- return undefined;
1604
- currentDir = parent;
1605
- }
1606
- }
1607
- /**
1608
- * Check whether the directory immediately containing the extensions folder
1609
- * (i.e. `.pi/agent/`) holds any TypeScript project marker. This narrowly
1610
- * detects the #123 scenario — pi itself installs a package.json at
1611
- * `~/.pi/agent/` and the user's extension has none of its own — without
1612
- * picking up accidental markers further up the filesystem.
1613
- */
1614
- async function hasAgentLevelProjectMarker(extensionRootKey) {
1615
- const agentDir = path.dirname(extensionRootKey);
1616
- if (!agentDir || agentDir === extensionRootKey)
1617
- return false;
1618
- for (const pattern of [...TS_CONFIG_MARKERS, ...TS_TOOLING_MARKERS]) {
1619
- try {
1620
- await stat(path.join(agentDir, pattern));
1621
- return true;
1622
- }
1623
- catch {
1624
- /* not found, try next */
1625
- }
1626
- }
1627
- return false;
1628
- }
1629
- const TypeScriptRoot = withRootMarkers(DenoExcludeRoot(async (file) => {
1630
- const extensionRootKey = piAgentExtensionsRootKey(file);
1631
- if (extensionRootKey) {
1632
- // Bounded walk so we never adopt a parent (e.g. ~/.pi/agent/) as the
1633
- // LSP root.
1634
- const bounded = await findExtensionBoundedRoot(file, extensionRootKey);
1635
- if (bounded)
1636
- return bounded;
1637
- // No marker inside the extension boundary. If pi itself has a
1638
- // package.json at ~/.pi/agent/ (the #123 setup), the previous code
1639
- // returned undefined and the LSP silently failed to start. Fall
1640
- // back to a per-file scope so the LSP at least runs.
1641
- if (await hasAgentLevelProjectMarker(extensionRootKey)) {
1642
- return FileDirRoot(file);
1643
- }
1644
- // Truly loose extension file with no project context anywhere
1645
- // relevant — preserve the existing skip behavior (LSP shouldn't
1646
- // analyze a lone .ts file with no package.json above or below).
1647
- return undefined;
1648
- }
1649
- const projectRoot = await findTypeScriptProjectRoot(file);
1650
- if (projectRoot)
1651
- return projectRoot;
1652
- return FileDirRoot(file);
1653
- }), [...TS_CONFIG_MARKERS, ...TS_TOOLING_MARKERS]);
1654
- export const TypeScriptServer = {
1655
- id: "typescript",
1656
- name: "TypeScript Language Server",
1657
- extensions: JS_TS_LSP_EXTENSIONS,
1658
- autoPropagateDiagnostics: true,
1659
- root: TypeScriptRoot,
1660
- rootMarkers: TypeScriptRoot.rootMarkers,
1661
- async spawn(root, options) {
1662
- const fs = await import("node:fs/promises");
1663
- const nativeLsp = await findNativeTypeScriptLsp(root);
1664
- if (nativeLsp) {
1665
- const env = await getToolEnvironment();
1666
- logSessionStart(`lsp typescript-native: version=${nativeLsp.version} command=${nativeLsp.command}`);
1667
- const proc = await launchLSP(nativeLsp.command, ["--lsp", "--stdio"], {
1668
- cwd: root,
1669
- env,
1670
- });
1671
- return { process: proc, source: "direct", launchVariant: "native-ts7" };
1672
- }
1673
- let source = "direct";
1674
- // TypeScript <=6 uses typescript-language-server + tsserver.js. Prefer a
1675
- // project-local wrapper, then fall back to discovered/managed tooling.
1676
- // #1412 M1: walk up from root (Windows .cmd first, then Unix at each
1677
- // level) — a nested config root's node_modules/.bin lives at an ancestor,
1678
- // not necessarily directly under the LSP root.
1679
- let lspPath = await findAncestorFileAmong(root, [
1680
- ["node_modules", ".bin", "typescript-language-server.cmd"],
1681
- ["node_modules", ".bin", "typescript-language-server"],
1682
- ]);
1683
- // Fall back to a discovered or managed install. ensureTool() runs PATH /
1684
- // npm-global discovery even when install is disabled (only the download is
1685
- // gated by canInstall), so a globally-installed typescript-language-server
1686
- // resolves even without a per-project node_modules/.bin entry.
1687
- if (!lspPath) {
1688
- lspPath = await ensureTool("typescript-language-server", {
1689
- allowInstall: canInstall(options?.allowInstall),
1690
- });
1691
- if (lspPath)
1692
- source = "managed";
1693
- if (!lspPath) {
1694
- return undefined;
1695
- }
1696
- }
1697
- // Find tsserver.js — also try relative to the LSP binary for local installs
1698
- let tsserverPath = await findTsserverPath(root, options?.allowInstall);
1699
- if (!tsserverPath) {
1700
- const localCandidate = path.join(path.dirname(lspPath), "..", "typescript", "lib", "tsserver.js");
1701
- try {
1702
- await fs.access(localCandidate);
1703
- tsserverPath = localCandidate;
1704
- }
1705
- catch {
1706
- /* not found */
1707
- }
1708
- }
1709
- if (tsserverPath)
1710
- source = "managed";
1711
- // Use absolute path and proper environment
1712
- const env = await getToolEnvironment();
1713
- const proc = await launchLSP(lspPath, ["--stdio"], {
1714
- cwd: root,
1715
- env: {
1716
- ...env,
1717
- TSSERVER_PATH: tsserverPath,
1718
- },
1719
- });
1720
- return {
1721
- process: proc,
1722
- source,
1723
- initialization: tsserverPath
1724
- ? { tsserver: { path: tsserverPath } }
1725
- : undefined,
1726
- launchVariant: "classic",
1727
- };
1728
- },
1729
- };
1730
- export const DenoServer = {
1731
- id: "deno",
1732
- name: "Deno Language Server",
1733
- fallbackFor: "typescript",
1734
- extensions: JS_TS_LSP_EXTENSIONS,
1735
- autoPropagateDiagnostics: true,
1736
- root: createRootDetector(["deno.json", "deno.jsonc"]),
1737
- async spawn(root, options) {
1738
- return resolveAndLaunch({
1739
- candidates: ["deno"],
1740
- args: ["lsp"],
1741
- cwd: root,
1742
- managedToolId: "deno",
1743
- }, options?.allowInstall);
1744
- },
1745
- };
1746
- export const PythonServer = {
1747
- id: "python",
1748
- name: "Pyright Language Server",
1749
- extensions: KIND_EXTENSIONS["python"],
1750
- root: RootWithFallback(createRootDetector([
1751
- ".git",
1752
- "pyproject.toml",
1753
- "setup.py",
1754
- "setup.cfg",
1755
- "requirements.txt",
1756
- "Pipfile",
1757
- "poetry.lock",
1758
- ])),
1759
- async spawn(root, options) {
1760
- const pythonEnvironment = await detectPythonEnvironment(root);
1761
- const env = augmentPythonEnvironment(await getToolEnvironment(), pythonEnvironment);
1762
- let source = "direct";
1763
- // openFilesOnly: true — analyse only open files rather than the full workspace.
1764
- // Avoids the 5–14 s cold-start on large projects caused by workspace-wide
1765
- // analysis on startup. Deep type checking is still available via the standalone
1766
- // pyright CLI runner that runs in parallel.
1767
- const pyrightInit = (pythonPath) => ({
1768
- ...(pythonPath ? { pythonPath } : {}),
1769
- openFilesOnly: true,
1770
- });
1771
- // Project ownership outranks checker preference: exhaust explicit project
1772
- // candidates before a bare command can resolve to pi-lens's managed bin or
1773
- // the host PATH. Within the project tier, preserve the established
1774
- // pyright → basedpyright → ty preference.
1775
- const projectPyright = await resolveAndLaunch({
1776
- candidates: [
1777
- ...pythonEnvironmentToolCandidates(pythonEnvironment, "pyright-langserver"),
1778
- ...nodeBinLocalCandidates(root, "pyright-langserver"),
1779
- ...pythonEnvironmentToolCandidates(pythonEnvironment, "basedpyright-langserver"),
1780
- ...nodeBinLocalCandidates(root, "basedpyright-langserver"),
1781
- ],
1782
- args: ["--stdio"],
1783
- cwd: root,
1784
- env,
1785
- }, false);
1786
- if (projectPyright) {
1787
- return {
1788
- process: projectPyright.process,
1789
- source: projectPyright.source,
1790
- initialization: pyrightInit(pythonEnvironment?.pythonPath),
1791
- };
1792
- }
1793
- // ty uses `ty server`, so it needs a separate launch phase from the
1794
- // Pyright-compatible servers. It has no stable initializationOptions
1795
- // equivalent to pyright's `pythonPath` (astral-sh/ty#2032); the child
1796
- // environment and cwd provide interpreter discovery instead.
1797
- const projectTy = await resolveAndLaunch({
1798
- candidates: pythonEnvironmentToolCandidates(pythonEnvironment, "ty"),
1799
- args: ["server"],
1800
- cwd: root,
1801
- env,
1802
- }, false);
1803
- if (projectTy) {
1804
- return { process: projectTy.process, source: projectTy.source };
1805
- }
1806
- // With no project-owned checker, retain the existing PATH preference and
1807
- // keep ty opt-in: pyright, then basedpyright, then ty. The augmented child
1808
- // PATH includes pi-lens-managed bins before the inherited global PATH.
1809
- const pathPyright = await resolveAndLaunch({
1810
- candidates: ["pyright-langserver", "basedpyright-langserver"],
1811
- args: ["--stdio"],
1812
- cwd: root,
1813
- env,
1814
- }, false);
1815
- if (pathPyright) {
1816
- return {
1817
- process: pathPyright.process,
1818
- source: pathPyright.source,
1819
- initialization: pyrightInit(pythonEnvironment?.pythonPath),
1820
- };
1821
- }
1822
- const pathTy = await resolveAndLaunch({
1823
- candidates: ["ty"],
1824
- args: ["server"],
1825
- cwd: root,
1826
- env,
1827
- }, false);
1828
- if (pathTy) {
1829
- return { process: pathTy.process, source: pathTy.source };
1830
- }
1831
- // Discover a globally-installed pyright even when install is disabled;
1832
- // only the download is gated by canInstall.
1833
- const pyrightPath = await ensureTool("pyright", {
1834
- allowInstall: canInstall(options?.allowInstall),
1835
- });
1836
- if (!pyrightPath)
1837
- return undefined;
1838
- source = "managed";
1839
- const binDir = path.dirname(pyrightPath);
1840
- const isWindows = process.platform === "win32";
1841
- const managedCandidates = isWindows
1842
- ? [
1843
- path.join(binDir, "pyright-langserver.cmd"),
1844
- path.join(binDir, "pyright-langserver"),
1845
- "pyright-langserver",
1846
- ]
1847
- : [path.join(binDir, "pyright-langserver"), "pyright-langserver"];
1848
- const resolved = await resolveAndLaunch({ candidates: managedCandidates, args: ["--stdio"], cwd: root, env }, false);
1849
- if (!resolved)
1850
- return undefined;
1851
- return {
1852
- process: resolved.process,
1853
- source,
1854
- initialization: pyrightInit(pythonEnvironment?.pythonPath),
1855
- };
1856
- },
1857
- };
1858
- export const PythonJediServer = {
1859
- id: "python-jedi",
1860
- name: "Jedi Language Server",
1861
- fallbackFor: "python",
1862
- extensions: KIND_EXTENSIONS["python"],
1863
- root: RootWithFallback(createRootDetector([
1864
- ".git",
1865
- "pyproject.toml",
1866
- "setup.py",
1867
- "setup.cfg",
1868
- "requirements.txt",
1869
- "Pipfile",
1870
- "poetry.lock",
1871
- ])),
1872
- async spawn(root, options) {
1873
- const launched = await resolveAndLaunch({
1874
- candidates: ["jedi-language-server"],
1875
- args: [],
1876
- cwd: root,
1877
- managedToolId: "jedi-language-server",
1878
- }, options?.allowInstall);
1879
- if (!launched)
1880
- return undefined;
1881
- const pythonPath = await detectPythonVenv(root);
1882
- return {
1883
- ...launched,
1884
- initialization: pythonPath
1885
- ? { workspace: { environmentPath: pythonPath } }
1886
- : {},
1887
- };
1888
- },
1889
- };
1890
- export const GoServer = {
1891
- id: "go",
1892
- name: "gopls",
1893
- extensions: KIND_EXTENSIONS["go"],
1894
- root: RootWithFallback(WorkspacePriorityRoot([["go.work"], ["go.mod", "go.sum"], [".git"]])),
1895
- async spawn(root, options) {
1896
- const result = await resolveAndLaunch({
1897
- // Canonical-bin discovery (#241): include $GOPATH/bin so a gopls that
1898
- // `go install` dropped there resolves even when it isn't on PATH —
1899
- // which is also the retry target after the runtimeInstall below.
1900
- candidates: goBinCandidates("gopls"),
1901
- args: [],
1902
- cwd: root,
1903
- runtimeInstall: {
1904
- runtimeCommand: "go",
1905
- install: tryGoInstallGopls,
1906
- },
1907
- }, options?.allowInstall);
1908
- if (!result)
1909
- return undefined;
1910
- return { ...result, initialization: { ui: { semanticTokens: true } } };
1911
- },
1912
- };
1913
- /**
1914
- * Given an ancestor Cargo.toml's raw contents that already contains a
1915
- * `[workspace]` table, decide whether it actually claims `childDir` as a
1916
- * member. Cargo's own default is that a bare `[workspace]` with no `members`
1917
- * key claims only the manifest's own package — a nested crate is NOT
1918
- * implicitly swept in just because it sits underneath the workspace root on
1919
- * disk. Without this check any Cargo.toml with a `[workspace]` table would
1920
- * hoist every crate below it, including ones the workspace never declared
1921
- * (#1671) — the same defect shape as an undeclared Maven sibling module.
1922
- *
1923
- * KNOWN LIMITATION (#1671 F7, documented rather than implemented): cargo also
1924
- * treats a crate as an implicit workspace member when the workspace root's
1925
- * OWN `[package]` has a `path = "..."` dependency on it and no explicit
1926
- * `members` key is present at all. That dependency-graph inference is not
1927
- * evaluated here — a workspace relying on it alone (no `members` key) reads
1928
- * as claiming nothing, so its crates stay independently rooted rather than
1929
- * being (correctly, per cargo) swept in. An explicit `members` list — the
1930
- * common case and the one #1671's fixtures exercise — is unaffected.
1931
- */
1932
- function cargoWorkspaceDeclaresMember(workspaceContent, parentDir, childDir) {
1933
- const relativePath = path
1934
- .relative(parentDir, childDir)
1935
- .split(path.sep)
1936
- .join("/");
1937
- if (relativePath === "" || relativePath.startsWith(".."))
1938
- return false;
1939
- // Reuse the shared, `[workspace]`-scoped readers (review round 2, F4)
1940
- // instead of hand-composing `extractTomlTableSection` +
1941
- // `parseTomlStringArray` here — that hand-composition duplicated exactly
1942
- // what `readCargoWorkspaceMembers`/`readCargoWorkspaceExclude` do, the
1943
- // single-source-of-truth violation #2473 was filed to close for the OTHER
1944
- // two Cargo.toml readers. Pattern matching goes through the ONE
1945
- // workspace-member matcher for the same reason (#2591); cargo's dialect —
1946
- // `**` never matches (#1671 F6), wildcards confined to one component, so
1947
- // pattern and path must have the same component count — is
1948
- // `CARGO_WORKSPACE_MEMBER_DIALECT`, not a private compiler here.
1949
- const matches = (pattern) => matchesWorkspaceMemberPattern(pattern, relativePath, CARGO_WORKSPACE_MEMBER_DIALECT);
1950
- const excluded = readCargoWorkspaceExclude(workspaceContent);
1951
- if (excluded.some(matches))
1952
- return false;
1953
- return readCargoWorkspaceMembers(workspaceContent).some(matches);
1954
- }
1955
- /**
1956
- * Bound a monorepo-hoist walk-up by the session ceiling: when `file` is inside
1957
- * the session cwd, the walk must stop AT the cwd rather than climb past it —
1958
- * matching enforceLspRootCeiling's clamp semantics but avoiding the wasted stat
1959
- * calls of walking past a boundary we would clamp back down anyway. When
1960
- * `file` is outside the session (isolated tests, out-of-session API callers),
1961
- * enforceLspRootCeiling is a no-op, so the walk is left unbounded (`undefined`).
1962
- */
1963
- function sessionHoistCeiling(sessionCwd, file) {
1964
- return isSameOrWithin(path.resolve(sessionCwd), path.resolve(file))
1965
- ? path.resolve(sessionCwd)
1966
- : undefined;
1967
- }
1968
- function RustWorkspaceRoot() {
1969
- const crateRoot = createRootDetector(["Cargo.toml", "Cargo.lock"]);
1970
- return withRootMarkers(async (file) => {
1971
- const root = await crateRoot(file);
1972
- if (!root)
1973
- return undefined;
1974
- const sessionCwd = process.cwd();
1975
- // The walk-up for an ancestor Cargo.toml with a [workspace] table stays
1976
- // bounded by the same session ceiling the crate-root lookup above already
1977
- // enforces (enforceLspRootCeiling) — a monorepo hoist must never cross the
1978
- // session boundary just because a workspace manifest happens to sit above
1979
- // it (#1671).
1980
- const stop = sessionHoistCeiling(sessionCwd, file);
1981
- let current = root;
1982
- const fsRoot = path.parse(current).root;
1983
- while (true) {
1984
- if (stop !== undefined && current === stop)
1985
- break;
1986
- const parent = path.dirname(current);
1987
- if (parent === current || parent === fsRoot)
1988
- break;
1989
- const parentCargoPath = path.join(parent, "Cargo.toml");
1990
- const parentCargoContent = await readTextFileOrUndefined(parentCargoPath);
1991
- if (parentCargoContent !== undefined &&
1992
- hasCargoWorkspaceTable(parentCargoContent)) {
1993
- // Test membership against the ORIGINAL crate dir (`root`), never the
1994
- // walk cursor (`current`, which may already have climbed through
1995
- // gap directories that have no Cargo.toml of their own) — a
1996
- // `members = ["crates/foo"]` or `["crates/*"]` entry is relative to
1997
- // the workspace root, spanning the whole gap in one hop (#1671 F1).
1998
- if (cargoWorkspaceDeclaresMember(parentCargoContent, parent, root)) {
1999
- return enforceLspRootCeiling(parent, sessionCwd, file);
2000
- }
2001
- // A workspace root exists here but its `members`/`exclude` tables do
2002
- // not claim this crate — stop climbing; the crate stays independently
2003
- // rooted rather than being swept into a workspace it opted out of.
2004
- break;
2005
- }
2006
- current = parent;
2007
- }
2008
- return root;
2009
- }, crateRoot.rootMarkers ?? []);
2010
- }
2011
- /**
2012
- * Resolve whether `parentPomPath`'s <modules> block declares `childDir` as one
2013
- * of its member modules. Maven multi-module hoisting must only chain through
2014
- * poms that actually declare the child (an undeclared sibling directory that
2015
- * merely happens to sit next to a parent pom stays independently rooted) —
2016
- * this is the "Maven module-chain verification" #1671 asks for, as opposed to
2017
- * rust-analyzer's simpler "any ancestor [workspace] wins" hoist.
2018
- */
2019
- /**
2020
- * Strip XML comments to a fixed point rather than in one pass: a single
2021
- * `.replace()` can leave a residual `<!--` behind on adversarially-nested
2022
- * input (CodeQL flags this class as "incomplete multi-character
2023
- * sanitization" — the removal of one comment can expose a delimiter that
2024
- * was itself inside another). Looping until nothing changes closes that gap.
2025
- */
2026
- function stripXmlComments(content) {
2027
- let result = content;
2028
- let previous;
2029
- do {
2030
- previous = result;
2031
- result = result.replace(/<!--[\s\S]*?-->/g, "");
2032
- } while (result !== previous);
2033
- return result;
2034
- }
2035
- async function declaresMavenModule(parentDir, parentPomPath, childDir) {
2036
- try {
2037
- const content = stripXmlComments(await readFile(parentPomPath, "utf-8"));
2038
- const modulesBlock = content.match(/<modules>([\s\S]*?)<\/modules>/);
2039
- if (!modulesBlock)
2040
- return false;
2041
- const resolvedChild = path.resolve(childDir);
2042
- for (const match of modulesBlock[1].matchAll(/<module>\s*([^<]+?)\s*<\/module>/g)) {
2043
- // Case-insensitive / realpath-aware compare (#1671 F8): a declared
2044
- // `<module>Foo</module>` must still match a directory actually named
2045
- // `foo` on a case-insensitive filesystem, the same class of check the
2046
- // rest of this codebase does via `pathsEqual` (#1139/#1150).
2047
- if (pathsEqual(path.resolve(parentDir, match[1]), resolvedChild))
2048
- return true;
2049
- }
2050
- return false;
2051
- }
2052
- catch {
2053
- return false;
2054
- }
2055
- }
2056
- function JavaWorkspaceRoot() {
2057
- const moduleRoot = createRootDetector([
2058
- "pom.xml",
2059
- "build.gradle",
2060
- ".classpath",
2061
- ]);
2062
- return withRootMarkers(async (file) => {
2063
- const root = await moduleRoot(file);
2064
- if (!root)
2065
- return undefined;
2066
- // Only a Maven (pom.xml) module chain-hoists here — Gradle/.classpath module
2067
- // roots are returned as found; multi-module Gradle wiring (settings.gradle)
2068
- // is a different shape and out of scope for #1671.
2069
- if (!(await markerExists(root, "pom.xml")))
2070
- return root;
2071
- const sessionCwd = process.cwd();
2072
- const stop = sessionHoistCeiling(sessionCwd, file);
2073
- // `current` is the walk CURSOR — it climbs over gap directories (ones
2074
- // with no pom.xml of their own, e.g. a `<module>sub/dir</module>` entry
2075
- // spanning more than one filesystem level). `lastHop` is the last
2076
- // CONFIRMED module boundary; it is what the next ancestor pom's
2077
- // <modules> must declare, and it is what actually gets returned — never
2078
- // the cursor, which can land on a bare gap directory that is not itself
2079
- // a valid module root (#1671 F2).
2080
- let current = root;
2081
- let lastHop = root;
2082
- const fsRoot = path.parse(current).root;
2083
- while (true) {
2084
- if (stop !== undefined && current === stop)
2085
- break;
2086
- const parent = path.dirname(current);
2087
- if (parent === current || parent === fsRoot)
2088
- break;
2089
- if (await markerExists(parent, "pom.xml")) {
2090
- const parentPom = path.join(parent, "pom.xml");
2091
- if (!(await declaresMavenModule(parent, parentPom, lastHop)))
2092
- break;
2093
- current = parent;
2094
- lastHop = parent;
2095
- continue;
2096
- }
2097
- // No pom.xml here — a gap directory, not a module boundary. Keep
2098
- // climbing without advancing `lastHop`.
2099
- current = parent;
2100
- }
2101
- return enforceLspRootCeiling(lastHop, sessionCwd, file);
2102
- }, moduleRoot.rootMarkers ?? []);
2103
- }
2104
- export const RustServer = {
2105
- id: "rust",
2106
- name: "rust-analyzer",
2107
- extensions: KIND_EXTENSIONS["rust"],
2108
- // No FileDirRoot fallback (#201): rust-analyzer is a heavy workspace server
2109
- // that is useless without a Cargo manifest. With the fallback, every .rs file
2110
- // written before a Cargo.toml exists resolved to its OWN directory as the
2111
- // root, and since clients dedup by `${serverId}:${root}`, each directory
2112
- // spawned a separate rust-analyzer (one per file/dir during scaffolding).
2113
- // Returning undefined here skips the spawn until a Cargo.toml gives a stable,
2114
- // shared crate root — then all files share one server.
2115
- root: RustWorkspaceRoot(),
2116
- async spawn(root, options) {
2117
- // Prefer rustup-installed rust-analyzer; fall back to GitHub-downloaded
2118
- // managed copy. Canonical-bin discovery (#241): include ~/.cargo/bin so a
2119
- // cargo/rustup-managed rust-analyzer resolves before paying for a download
2120
- // even when ~/.cargo/bin isn't on PATH.
2121
- const result = await resolveAndLaunch({
2122
- candidates: cargoBinCandidates("rust-analyzer"),
2123
- args: [],
2124
- cwd: root,
2125
- managedToolId: "rust-analyzer",
2126
- }, options?.allowInstall);
2127
- if (!result)
2128
- return undefined;
2129
- return {
2130
- ...result,
2131
- initialization: {
2132
- cargo: { buildScripts: { enable: true } },
2133
- procMacro: { enable: true },
2134
- diagnostics: { enable: true },
2135
- },
2136
- };
2137
- },
2138
- };
2139
- export const RubyServer = {
2140
- id: "ruby",
2141
- name: "Ruby LSP",
2142
- extensions: KIND_EXTENSIONS["ruby"],
2143
- root: RootWithFallback(PriorityRoot([["Gemfile", ".ruby-version"], [".git"]])),
2144
- // Ruby LSP may need extra time to finish composed-bundle setup before it can
2145
- // answer initialize/documentSymbol on cold start.
2146
- initializeTimeoutMs: 30_000,
2147
- clientWaitTimeoutMs: 30_000,
2148
- async spawn(root, options) {
2149
- // Try ruby-lsp first, then solargraph, then rubocop --lsp
2150
- // Each has different args so we can't use a single resolveAndLaunch call
2151
- const rubylsp = await resolveAndLaunch({
2152
- candidates: ["ruby-lsp", ...rubyBinCandidates("ruby-lsp")],
2153
- args: [],
2154
- cwd: root,
2155
- runtimeInstall: {
2156
- runtimeCommand: "gem",
2157
- install: () => tryGemInstall("ruby-lsp"),
2158
- retryCandidates: ["ruby-lsp", ...rubyBinCandidates("ruby-lsp")],
2159
- },
2160
- }, options?.allowInstall);
2161
- if (rubylsp)
2162
- return rubylsp;
2163
- // Solargraph fallback
2164
- const solargraph = await resolveAndLaunch({
2165
- candidates: ["solargraph", ...rubyBinCandidates("solargraph")],
2166
- args: ["stdio"],
2167
- cwd: root,
2168
- }, false);
2169
- if (solargraph)
2170
- return solargraph;
2171
- // rubocop --lsp fallback
2172
- return resolveAndLaunch({
2173
- candidates: ["rubocop", ...rubyBinCandidates("rubocop")],
2174
- args: ["--lsp"],
2175
- cwd: root,
2176
- }, false);
2177
- },
2178
- };
2179
- // NOTE: Ruby's Solargraph + RuboCop fallbacks live INSIDE RubyServer.spawn
2180
- // (ruby-lsp → solargraph → rubocop --lsp). Primary selection is first-success-
2181
- // wins (one server per file, see LSPService.getClientForFile), so a separate
2182
- // solargraph sibling server could never be reached — RubyServer only returns
2183
- // undefined when solargraph is also absent. A standalone RubySolargraphServer
2184
- // would therefore be dead code; it intentionally does not exist. If a future
2185
- // user-selectable preferred-server config lands, refactor RubyServer to a
2186
- // single binary and register the alternatives as siblings (cf. python/jedi).
2187
- export const PHPServer = {
2188
- id: "php",
2189
- name: "Intelephense",
2190
- extensions: KIND_EXTENSIONS["php"],
2191
- root: RootWithFallback(createRootDetector(["composer.json", "composer.lock"])),
2192
- async spawn(root, options) {
2193
- const result = await resolveAndLaunch({
2194
- candidates: nodeBinCandidates(root, "intelephense"),
2195
- args: ["--stdio"],
2196
- cwd: root,
2197
- managedToolId: "intelephense",
2198
- }, options?.allowInstall);
2199
- if (!result)
2200
- return undefined;
2201
- return {
2202
- ...result,
2203
- initialization: {
2204
- storagePath: path.join(getGlobalPiLensDir(), "intelephense"),
2205
- },
2206
- };
2207
- },
2208
- };
2209
- // PowerShell Editor Services bootstrap (#278). Builds the `pwsh`/`powershell`
2210
- // args that launch the bundled Start-EditorServices.ps1 over stdio. Param set
2211
- // verified against the PSES v4.6.0 bundle. Each spawn gets a private session dir
2212
- // for the required Log/SessionDetails paths.
2213
- function buildPsesArgs(bundleDir) {
2214
- const script = path.join(bundleDir, "PowerShellEditorServices", "Start-EditorServices.ps1");
2215
- const sessionDir = path.join(getGlobalPiLensDir(), "pses", `${process.pid}-${Date.now()}`);
2216
- mkdirSync(sessionDir, { recursive: true });
2217
- const logPath = path.join(sessionDir, "pses.log");
2218
- const sessionDetailsPath = path.join(sessionDir, "session.json");
2219
- // Use -File with each PSES parameter as a SEPARATE argv element (the canonical
2220
- // editor launch form). This deliberately avoids `-Command "& '...'"`: pwsh.exe
2221
- // commonly lives under "C:\Program Files\…" (a space), which forces launchLSP's
2222
- // Windows shell path, and an embedded `&`/quotes in a single -Command string
2223
- // gets mangled by cmd.exe. Plain argv tokens survive shell escaping (our paths
2224
- // are under ~/.pi-lens, no spaces). -Stdio makes PSES speak LSP over this
2225
- // process's stdin/stdout; -LanguageServiceOnly skips the debug adapter.
2226
- return [
2227
- "-NoLogo",
2228
- "-NoProfile",
2229
- "-NonInteractive",
2230
- // Unsigned bundled script + mark-of-the-web on Windows — Bypass so it runs;
2231
- // ignored by non-Windows pwsh.
2232
- "-ExecutionPolicy",
2233
- "Bypass",
2234
- "-File",
2235
- script,
2236
- "-HostName",
2237
- "pi-lens",
2238
- "-HostProfileId",
2239
- "pi-lens",
2240
- "-HostVersion",
2241
- "1.0.0",
2242
- "-BundledModulesPath",
2243
- bundleDir,
2244
- "-LogPath",
2245
- logPath,
2246
- "-LogLevel",
2247
- "Warning",
2248
- "-SessionDetailsPath",
2249
- sessionDetailsPath,
2250
- "-Stdio",
2251
- "-LanguageServiceOnly",
2252
- ];
2253
- }
2254
- export const PowerShellServer = {
2255
- id: "powershell",
2256
- name: "PowerShell Editor Services",
2257
- extensions: KIND_EXTENSIONS["powershell"],
2258
- // Index at the workspace (script modules reference siblings); fall back to the
2259
- // file dir.
2260
- root: RootWithFallback(createRootDetector([".git"])),
2261
- spawn(root, options) {
2262
- // PSES is a module bundle launched via pwsh, not a binary on PATH. Resolve
2263
- // pwsh/powershell + the managed bundle, then launch the bootstrap over
2264
- // stdio. Graceful skip (→ coverage notice) when pwsh or the bundle is
2265
- // unavailable; psscriptanalyzer remains the fallback in the dispatch group.
2266
- return resolveAndLaunchBundle({
2267
- runtimeCandidates: ["pwsh", "powershell"],
2268
- bundleToolId: "powershell-editor-services",
2269
- cwd: root,
2270
- args: buildPsesArgs,
2271
- }, options?.allowInstall);
2272
- },
2273
- };
2274
- export const CSharpServer = {
2275
- id: "csharp",
2276
- name: "csharp-ls",
2277
- extensions: KIND_EXTENSIONS["csharp"],
2278
- // No FileDirRoot fallback (#201): csharp-ls is a workspace server and should
2279
- // not spawn once per source directory before a .sln/.csproj exists. Glob root
2280
- // markers match real project filenames such as `App.csproj` / `App.sln`
2281
- // (shared marker list — see file-kinds.ts, refs #895).
2282
- root: createRootDetector([...DOTNET_CSHARP_ROOT_MARKERS]),
2283
- async spawn(root, options) {
2284
- const candidates = dotnetToolCandidates("csharp-ls");
2285
- return resolveAndLaunch({
2286
- candidates,
2287
- args: [],
2288
- cwd: root,
2289
- runtimeInstall: {
2290
- runtimeCommand: "dotnet",
2291
- install: () => tryDotnetToolInstall("csharp-ls"),
2292
- retryCandidates: candidates,
2293
- },
2294
- }, options?.allowInstall);
2295
- },
2296
- };
2297
- export const OmniSharpServer = createInteractiveServer({
2298
- id: "omnisharp",
2299
- name: "OmniSharp",
2300
- fallbackFor: "csharp",
2301
- extensions: KIND_EXTENSIONS["csharp"],
2302
- root: createRootDetector([...DOTNET_CSHARP_ROOT_MARKERS]),
2303
- language: "csharp",
2304
- command: "OmniSharp",
2305
- args: ["--languageserver"],
2306
- });
2307
- export const FSharpServer = {
2308
- id: "fsharp",
2309
- name: "FSAutocomplete",
2310
- extensions: KIND_EXTENSIONS["fsharp"],
2311
- root: createRootDetector([...DOTNET_FSHARP_ROOT_MARKERS]),
2312
- async spawn(root, options) {
2313
- // fsautocomplete is a `dotnet tool` (#241), exactly like csharp-ls: prefer a
2314
- // managed/.dotnet-tools copy, else `dotnet tool install` when the .NET SDK
2315
- // is on PATH. dotnetToolCandidates covers the install target so the retry
2316
- // resolves it.
2317
- const candidates = dotnetToolCandidates("fsautocomplete");
2318
- return resolveAndLaunch({
2319
- candidates,
2320
- args: [],
2321
- cwd: root,
2322
- runtimeInstall: {
2323
- runtimeCommand: "dotnet",
2324
- install: () => tryDotnetToolInstall("fsautocomplete"),
2325
- retryCandidates: candidates,
2326
- },
2327
- }, options?.allowInstall);
2328
- },
2329
- };
2330
- export const JavaServer = createInteractiveServer({
2331
- id: "java",
2332
- name: "JDT Language Server",
2333
- extensions: KIND_EXTENSIONS["java"],
2334
- root: RootWithFallback(JavaWorkspaceRoot()),
2335
- language: "java",
2336
- command: () => process.env.JDTLS_PATH || "jdtls",
2337
- args: (root) => createLombokJdtlsArgs(root),
2338
- runtime: "java",
2339
- });
2340
- export const KotlinServer = {
2341
- id: "kotlin",
2342
- name: "Kotlin Language Server",
2343
- extensions: KIND_EXTENSIONS["kotlin"],
2344
- root: RootWithFallback(createRootDetector(["build.gradle.kts", "build.gradle", "pom.xml"])),
2345
- async spawn(root, options) {
2346
- // Prefer the newer official Kotlin LSP CLI when available, but keep
2347
- // compatibility with the older fwcd kotlin-language-server command.
2348
- return resolveAndLaunch({
2349
- candidates: ["kotlin-lsp", "kotlin-language-server"],
2350
- args: [],
2351
- cwd: root,
2352
- }, options?.allowInstall);
2353
- },
2354
- };
2355
- export const SwiftServer = createInteractiveServer({
2356
- id: "swift",
2357
- name: "SourceKit-LSP",
2358
- extensions: KIND_EXTENSIONS["swift"],
2359
- root: createRootDetector(["Package.swift"]),
2360
- language: "swift",
2361
- command: "sourcekit-lsp",
2362
- });
2363
- export const DartServer = createInteractiveServer({
2364
- id: "dart",
2365
- name: "Dart Analysis Server",
2366
- extensions: KIND_EXTENSIONS["dart"],
2367
- root: RootWithFallback(createRootDetector(["pubspec.yaml"])),
2368
- language: "dart",
2369
- command: "dart",
2370
- args: ["language-server", "--protocol=lsp"],
2371
- });
2372
- /**
2373
- * Build an {@link LSPServerInfo} for a language server that ships as a
2374
- * self-contained native TREE BUNDLE (single archive, `bin/<binary>` inside,
2375
- * no external runtime — clangd #241, lua-language-server #564, and the
2376
- * kotlin-language-server/elixir-ls follow-on #565). Extracted once both
2377
- * `CppServer` and `LuaServer` turned out to be a near-verbatim structural
2378
- * copy of each other (same `resolveAndLaunchTreeBinary` call shape) —
2379
- * flagged by SonarCloud's new-code duplication gate on PR #567 — so a third
2380
- * and fourth server of this shape (#565) can call this instead of
2381
- * copy-pasting a `spawn` again. Each server's own "why archive-tree, why
2382
- * stripComponents differs, etc." explanation stays as a comment at its own
2383
- * call site below, since that reasoning is genuinely per-server.
2384
- */
2385
- function createTreeBinaryServer(spec) {
2386
- return {
2387
- id: spec.id,
2388
- name: spec.name,
2389
- extensions: spec.extensions,
2390
- root: spec.root,
2391
- spawn(root, options) {
2392
- return resolveAndLaunchTreeBinary({
2393
- candidates: [spec.binaryName],
2394
- bundleToolId: spec.binaryName,
2395
- binRelPath: spec.binRelPath,
2396
- cwd: root,
2397
- args: spec.args ?? [],
2398
- }, options?.allowInstall);
2399
- },
2400
- };
2401
- }
2402
- // lua-language-server ships the same self-contained native TREE BUNDLE shape
2403
- // as clangd (#241/#564): bin/lua-language-server + bundled locale/meta files,
2404
- // no external runtime. Prefer a system install on PATH; else auto-install the
2405
- // managed bundle and launch bin/lua-language-server within it. Graceful skip
2406
- // when neither is available (→ coverage notice).
2407
- export const LuaServer = createTreeBinaryServer({
2408
- id: "lua",
2409
- name: "Lua Language Server",
2410
- extensions: KIND_EXTENSIONS["lua"],
2411
- root: createRootDetector([".luarc.json", ".luacheckrc"]),
2412
- binaryName: "lua-language-server",
2413
- binRelPath: "bin/lua-language-server",
2414
- });
2415
- // clangd ships a self-contained native tree bundle (bin/clangd + bundled
2416
- // libclang headers). Prefer a system clangd on PATH; else auto-install the
2417
- // managed bundle (#241) and launch bin/clangd within it. Graceful skip when
2418
- // neither is available (→ coverage notice); cpp-check stays the fallback.
2419
- export const CppServer = createTreeBinaryServer({
2420
- id: "cpp",
2421
- name: "clangd",
2422
- extensions: KIND_EXTENSIONS["cxx"],
2423
- root: RootWithFallback(createRootDetector([
2424
- "compile_commands.json",
2425
- ".clangd",
2426
- "CMakeLists.txt",
2427
- "Makefile",
2428
- ])),
2429
- binaryName: "clangd",
2430
- binRelPath: "bin/clangd",
2431
- args: ["--background-index"],
2432
- });
2433
- export const ZigServer = {
2434
- id: "zig",
2435
- name: "ZLS",
2436
- extensions: KIND_EXTENSIONS["zig"],
2437
- root: RootWithFallback(createRootDetector(["build.zig"])),
2438
- spawn(root, options) {
2439
- return resolveAndLaunch({
2440
- candidates: ["zls"],
2441
- args: [],
2442
- cwd: root,
2443
- managedToolId: "zls",
2444
- }, options?.allowInstall);
2445
- },
2446
- };
2447
- export const HaskellServer = createInteractiveServer({
2448
- id: "haskell",
2449
- name: "Haskell Language Server",
2450
- extensions: KIND_EXTENSIONS["haskell"],
2451
- root: createRootDetector(["stack.yaml", "cabal.project", "*.cabal"]),
2452
- language: "haskell",
2453
- command: "haskell-language-server-wrapper",
2454
- args: ["--lsp"],
2455
- });
2456
- export const ElixirServer = createInteractiveServer({
2457
- id: "elixir",
2458
- name: "ElixirLS",
2459
- extensions: KIND_EXTENSIONS["elixir"],
2460
- root: RootWithFallback(createRootDetector(["mix.exs"])),
2461
- language: "elixir",
2462
- command: "elixir-ls",
2463
- });
2464
- export const ElixirExpertServer = {
2465
- id: "expert",
2466
- name: "Expert",
2467
- fallbackFor: "elixir",
2468
- extensions: KIND_EXTENSIONS["elixir"],
2469
- root: RootWithFallback(createRootDetector(["mix.exs"])),
2470
- availabilityKey: "expert",
2471
- async spawn(root, options) {
2472
- return resolveAndLaunch({
2473
- candidates: ["expert"],
2474
- args: ["--stdio"],
2475
- cwd: root,
2476
- managedToolId: "expert",
2477
- }, options?.allowInstall);
2478
- },
2479
- autoInstall: async () => Boolean(await ensureTool("expert")),
2480
- };
2481
- export const GleamServer = {
2482
- id: "gleam",
2483
- name: "Gleam LSP",
2484
- extensions: KIND_EXTENSIONS["gleam"],
2485
- root: RootWithFallback(createRootDetector(["gleam.toml"])),
2486
- async spawn(root, options) {
2487
- // Prefer a PATH `gleam` (full toolchain); fall back to the managed
2488
- // GitHub-release binary. `gleam lsp` is the server entrypoint either way.
2489
- return resolveAndLaunch({
2490
- candidates: ["gleam"],
2491
- args: ["lsp"],
2492
- cwd: root,
2493
- managedToolId: "gleam",
2494
- }, options?.allowInstall);
2495
- },
2496
- };
2497
- export const TinymistServer = {
2498
- id: "tinymist",
2499
- name: "Tinymist",
2500
- extensions: extensionsForLanguage("typst"),
2501
- root: RootWithFallback(createRootDetector(["typst.toml", ".git"])),
2502
- availabilityKey: "tinymist",
2503
- async spawn(root, options) {
2504
- return resolveAndLaunch({
2505
- candidates: ["tinymist"],
2506
- args: ["lsp"],
2507
- cwd: root,
2508
- managedToolId: "tinymist",
2509
- }, options?.allowInstall);
2510
- },
2511
- };
2512
- export const MarksmanServer = {
2513
- id: "marksman",
2514
- name: "Marksman",
2515
- extensions: KIND_EXTENSIONS["markdown"],
2516
- // Index at the workspace root so cross-file checks (broken intra-repo links,
2517
- // missing/renamed anchors, heading refs) see the whole tree; fall back to the
2518
- // file's directory when there's no project marker.
2519
- root: RootWithFallback(createRootDetector([".marksman.toml", ".git"])),
2520
- spawn(root, options) {
2521
- // Prefer a PATH `marksman`; fall back to the managed GitHub-release binary.
2522
- // `marksman server` is the stdio LSP entrypoint either way.
2523
- return resolveAndLaunch({
2524
- candidates: ["marksman"],
2525
- args: ["server"],
2526
- cwd: root,
2527
- managedToolId: "marksman",
2528
- }, options?.allowInstall);
2529
- },
2530
- };
2531
- export const OCamlServer = createInteractiveServer({
2532
- id: "ocaml",
2533
- name: "ocamllsp",
2534
- extensions: KIND_EXTENSIONS["ocaml"],
2535
- root: createRootDetector(["dune-project", "opam"]),
2536
- language: "ocaml",
2537
- command: "ocamllsp",
2538
- });
2539
- export const ClojureServer = {
2540
- id: "clojure",
2541
- name: "Clojure LSP",
2542
- extensions: KIND_EXTENSIONS["clojure"],
2543
- root: createRootDetector(["deps.edn", "project.clj"]),
2544
- async spawn(root, options) {
2545
- // Prefer a PATH `clojure-lsp`; fall back to the managed self-contained
2546
- // native (GraalVM) GitHub-release binary — no JVM needed either way.
2547
- return resolveAndLaunch({
2548
- candidates: ["clojure-lsp"],
2549
- args: [],
2550
- cwd: root,
2551
- managedToolId: "clojure-lsp",
2552
- }, options?.allowInstall);
2553
- },
2554
- };
2555
- export const CueServer = {
2556
- id: "cue",
2557
- name: "CUE Language Server",
2558
- extensions: KIND_EXTENSIONS["cue"],
2559
- root: RootWithFallback(createRootDetector(["cue.mod", ".git"])),
2560
- async spawn(root, options) {
2561
- return resolveAndLaunch({
2562
- candidates: ["cue"],
2563
- args: ["lsp", "serve"],
2564
- cwd: root,
2565
- managedToolId: "cue",
2566
- }, options?.allowInstall);
2567
- },
2568
- };
2569
- export const TerraformServer = {
2570
- id: "terraform",
2571
- name: "Terraform LSP",
2572
- extensions: KIND_EXTENSIONS["terraform"],
2573
- root: RootWithFallback(createRootDetector([".terraform.lock.hcl", ".terraform"])),
2574
- spawn(root, options) {
2575
- return resolveAndLaunch({
2576
- candidates: ["terraform-ls"],
2577
- args: ["serve"],
2578
- cwd: root,
2579
- managedToolId: "terraform-ls",
2580
- }, options?.allowInstall);
2581
- },
2582
- };
2583
- export const NixServer = createInteractiveServer({
2584
- id: "nix",
2585
- name: "nixd",
2586
- extensions: KIND_EXTENSIONS["nix"],
2587
- root: createRootDetector(["flake.nix"]),
2588
- language: "nix",
2589
- command: "nixd",
2590
- });
2591
- export const BashServer = {
2592
- id: "bash",
2593
- name: "Bash Language Server",
2594
- extensions: [".bash", ".sh", ".zsh"],
2595
- root: FileDirRoot,
2596
- // #2194 bounded the installer's own verification at 20s; the dispatch
2597
- // touch's client-wait floor stayed at the shared 5s default, so a cold
2598
- // spawn could still race that budget and read as unavailable even after
2599
- // installer verification cleared it. Match the installer bound (#2169).
2600
- // `initializeTimeoutMs` matches it too: the measured cold start
2601
- // (9,667ms, #2194) has headroom under the unraised 15s default, but
2602
- // leaving the two fields mismatched is the same latent shape that made
2603
- // the Prisma/Vue raise a no-op (fix-round F1, #2233) — a slower host
2604
- // than the one measured would still hit the 15s inner kill despite the
2605
- // caller being willing to wait 20s. Equalizing costs nothing on the
2606
- // success path and removes the mismatch outright.
2607
- clientWaitTimeoutMs: 20_000,
2608
- initializeTimeoutMs: 20_000,
2609
- spawn(root, options) {
2610
- return resolveAndLaunch({
2611
- candidates: nodeBinCandidates(root, "bash-language-server"),
2612
- args: ["start"],
2613
- cwd: root,
2614
- managedToolId: "bash-language-server",
2615
- }, options?.allowInstall);
2616
- },
2617
- };
2618
- export const FishServer = {
2619
- id: "fish",
2620
- name: "Fish Language Server",
2621
- extensions: KIND_EXTENSIONS["fish"],
2622
- root: RootWithFallback(createRootDetector([".git"])),
2623
- spawn(root, options) {
2624
- return resolveAndLaunch({
2625
- candidates: nodeBinCandidates(root, "fish-lsp"),
2626
- args: ["start"],
2627
- cwd: root,
2628
- managedToolId: "fish-lsp",
2629
- }, options?.allowInstall);
2630
- },
2631
- };
2632
- export const CMakeServer = {
2633
- id: "cmake",
2634
- name: "CMake Language Server",
2635
- // CMake's canonical project file has no .cmake suffix. The configured-server
2636
- // matcher supports exact basenames as well as extensions.
2637
- extensions: [...KIND_EXTENSIONS["cmake"], "CMakeLists.txt"],
2638
- root: RootWithFallback(createRootDetector(["CMakeLists.txt", ".git"])),
2639
- spawn(root, options) {
2640
- return resolveAndLaunch({
2641
- candidates: ["cmake-language-server"],
2642
- args: [],
2643
- cwd: root,
2644
- managedToolId: "cmake-language-server",
2645
- }, options?.allowInstall);
2646
- },
2647
- };
2648
- export const DockerServer = {
2649
- id: "docker",
2650
- name: "Dockerfile Language Server",
2651
- extensions: [".dockerfile", "Dockerfile"],
2652
- root: RootWithFallback(PriorityRoot([
2653
- [
2654
- "docker-compose.yml",
2655
- "docker-compose.yaml",
2656
- "compose.yml",
2657
- "compose.yaml",
2658
- ],
2659
- [".git"],
2660
- ])),
2661
- spawn(root, options) {
2662
- return resolveAndLaunch({
2663
- candidates: nodeBinCandidates(root, "docker-langserver"),
2664
- args: ["--stdio"],
2665
- cwd: root,
2666
- managedToolId: "dockerfile-language-server-nodejs",
2667
- }, options?.allowInstall);
2668
- },
2669
- };
2670
- export const YamlServer = {
2671
- id: "yaml",
2672
- name: "YAML Language Server",
2673
- extensions: KIND_EXTENSIONS["yaml"],
2674
- root: RootWithFallback(PriorityRoot([
2675
- [".yamllint", "yamllint.yml", "yamllint.yaml", "pyproject.toml"],
2676
- [".git"],
2677
- ])),
2678
- spawn(root, options) {
2679
- return resolveAndLaunch({
2680
- candidates: nodeBinCandidates(root, "yaml-language-server"),
2681
- args: ["--stdio"],
2682
- cwd: root,
2683
- managedToolId: "yaml-language-server",
2684
- }, options?.allowInstall);
2685
- },
2686
- };
2687
- export const JsonServer = {
2688
- id: "json",
2689
- name: "VSCode JSON Language Server",
2690
- extensions: KIND_EXTENSIONS["json"],
2691
- root: RootWithFallback(WorkspacePriorityRoot([
2692
- ["package.json", "tsconfig.json", "jsconfig.json"],
2693
- [".git"],
2694
- ])),
2695
- // See BashServer above: the installer's 20s verification bound (#2194)
2696
- // does not, on its own, raise the dispatch touch's cold-spawn wait floor.
2697
- // Mirror it here so a cold spawn cannot lose that race either (#2169).
2698
- // `initializeTimeoutMs` matches it for the same reason as BashServer
2699
- // above (fix-round F1, #2233).
2700
- clientWaitTimeoutMs: 20_000,
2701
- initializeTimeoutMs: 20_000,
2702
- spawn(root, options) {
2703
- return resolveAndLaunch({
2704
- candidates: ["vscode-json-language-server"],
2705
- args: ["--stdio"],
2706
- cwd: root,
2707
- managedToolId: "vscode-json-language-server",
2708
- }, options?.allowInstall);
2709
- },
2710
- };
2711
- export const HtmlServer = {
2712
- id: "html",
2713
- name: "VSCode HTML Language Server",
2714
- extensions: KIND_EXTENSIONS["html"],
2715
- root: RootWithFallback(IgnoreHomeRoot(PriorityRoot([["package.json", "index.html", "vite.config.ts"]]))),
2716
- spawn(root, options) {
2717
- return resolveAndLaunch({
2718
- candidates: nodeBinCandidates(root, "vscode-html-language-server"),
2719
- args: ["--stdio"],
2720
- cwd: root,
2721
- managedToolId: "vscode-html-languageserver-bin",
2722
- }, options?.allowInstall);
2723
- },
2724
- };
2725
- export const TomlServer = {
2726
- id: "toml",
2727
- name: "Taplo",
2728
- extensions: KIND_EXTENSIONS["toml"],
2729
- root: RootWithFallback(PriorityRoot([["pyproject.toml", "Cargo.toml", "taplo.toml"], [".git"]])),
2730
- spawn(root, options) {
2731
- return resolveAndLaunch({
2732
- candidates: ["taplo"],
2733
- args: ["lsp", "stdio"],
2734
- cwd: root,
2735
- managedToolId: "taplo",
2736
- }, options?.allowInstall);
2737
- },
2738
- };
2739
- export const PrismaServer = {
2740
- id: "prisma",
2741
- name: "Prisma Language Server",
2742
- extensions: KIND_EXTENSIONS["prisma"],
2743
- root: RootWithFallback(createRootDetector(["prisma/schema.prisma", "schema.prisma"])),
2744
- // Matches the installer's 40s verification bound above (#2169): the
2745
- // dispatch touch's cold-spawn wait floor defaults to 5s and would
2746
- // otherwise time the client out well before the binary answers.
2747
- // `initializeTimeoutMs` MUST be set alongside it (RubyServer's own
2748
- // precedent below): unset, it falls back to the 15s
2749
- // `INITIALIZE_TIMEOUT_MS` default in `clients/lsp/client.ts`, which
2750
- // hard-kills the child mid-handshake — a real cold Prisma run measured
2751
- // up to 27.3s, so the spawn would die there before this wait ever gets
2752
- // a chance to matter (fix-round F1, #2233).
2753
- clientWaitTimeoutMs: 40_000,
2754
- initializeTimeoutMs: 40_000,
2755
- spawn(root, options) {
2756
- return resolveAndLaunch({
2757
- candidates: nodeBinCandidates(root, "prisma-language-server"),
2758
- args: ["--stdio"],
2759
- cwd: root,
2760
- managedToolId: "@prisma/language-server",
2761
- }, options?.allowInstall);
2762
- },
2763
- };
2764
- // --- Web Framework & Styling Servers ---
2765
- export const VueServer = {
2766
- id: "vue",
2767
- name: "Vue Language Server",
2768
- extensions: [".vue"],
2769
- root: RootWithFallback(IgnoreHomeRoot(createRootDetector([
2770
- "package.json",
2771
- "package-lock.json",
2772
- "bun.lockb",
2773
- "bun.lock",
2774
- "pnpm-lock.yaml",
2775
- "yarn.lock",
2776
- ]))),
2777
- // Vue's launcher loads the full language-service bundle before answering,
2778
- // matching the installer's 30s verification bound (#2176). The dispatch
2779
- // touch's cold-spawn wait floor defaults to 5s and needs the same raise so
2780
- // a cold spawn cannot time out there instead. `initializeTimeoutMs` MUST
2781
- // match: unset, it falls back to the 15s default in `clients/lsp/client.ts`
2782
- // and hard-kills a cold spawn (measured up to 22.6s in #2188, 16.2s here)
2783
- // mid-handshake before this wait can matter (fix-round F1, #2233).
2784
- clientWaitTimeoutMs: 30_000,
2785
- initializeTimeoutMs: 30_000,
2786
- async spawn(root, options) {
2787
- const tsserverPath = await findTsserverPath(root, options?.allowInstall);
2788
- // Vue Language Server needs Vue dependencies installed to resolve types.
2789
- // Without node_modules, navigation requests will timeout or return empty.
2790
- const hasPackageJson = existsSync(path.join(root, "package.json"));
2791
- const hasNodeModules = existsSync(path.join(root, "node_modules"));
2792
- if (hasPackageJson && !hasNodeModules) {
2793
- logSessionStart(`lsp vue: node_modules missing in ${root} — Vue navigation may be limited. ` +
2794
- `Run: npm install (or pnpm/yarn install) in this project.`);
2795
- }
2796
- const proc = await resolveAndLaunch({
2797
- candidates: nodeBinCandidates(root, "vue-language-server"),
2798
- args: ["--stdio"],
2799
- cwd: root,
2800
- managedToolId: "@vue/language-server",
2801
- }, options?.allowInstall);
2802
- if (!proc)
2803
- return undefined;
2804
- return {
2805
- process: proc.process,
2806
- source: proc.source,
2807
- initialization: tsserverPath
2808
- ? { typescript: { tsdk: path.dirname(tsserverPath) } }
2809
- : undefined,
2810
- };
2811
- },
2812
- };
2813
- export const SvelteServer = {
2814
- id: "svelte",
2815
- name: "Svelte Language Server",
2816
- extensions: [".svelte"],
2817
- root: RootWithFallback(IgnoreHomeRoot(createRootDetector([
2818
- "package.json",
2819
- "package-lock.json",
2820
- "bun.lockb",
2821
- "bun.lock",
2822
- "pnpm-lock.yaml",
2823
- "yarn.lock",
2824
- ]))),
2825
- // Matches the installer's 20s verification bound above (#2169): the
2826
- // dispatch touch's cold-spawn wait floor defaults to 5s and would
2827
- // otherwise time the client out well before the binary answers.
2828
- // `initializeTimeoutMs` matches it for the same reason as BashServer
2829
- // above (fix-round F1, #2233).
2830
- clientWaitTimeoutMs: 20_000,
2831
- initializeTimeoutMs: 20_000,
2832
- async spawn(root, options) {
2833
- const tsserverPath = await findTsserverPath(root, options?.allowInstall);
2834
- const proc = await resolveAndLaunch({
2835
- candidates: [
2836
- ...nodeBinCandidates(root, "svelteserver"),
2837
- ...nodeBinCandidates(root, "svelte-language-server"),
2838
- ],
2839
- args: ["--stdio"],
2840
- cwd: root,
2841
- managedToolId: "svelte-language-server",
2842
- }, options?.allowInstall);
2843
- if (!proc)
2844
- return undefined;
2845
- return {
2846
- process: proc.process,
2847
- source: proc.source,
2848
- initialization: tsserverPath
2849
- ? { typescript: { tsdk: path.dirname(tsserverPath) } }
2850
- : undefined,
2851
- };
2852
- },
2853
- };
2854
- export const CssServer = {
2855
- id: "css",
2856
- name: "CSS Language Server",
2857
- extensions: KIND_EXTENSIONS["css"],
2858
- root: RootWithFallback(IgnoreHomeRoot(PriorityRoot([
2859
- [
2860
- "package.json",
2861
- "postcss.config.js",
2862
- "tailwind.config.js",
2863
- "vite.config.ts",
2864
- ],
2865
- ]))),
2866
- spawn(root, options) {
2867
- return resolveAndLaunch({
2868
- candidates: nodeBinCandidates(root, "vscode-css-language-server"),
2869
- args: ["--stdio"],
2870
- cwd: root,
2871
- managedToolId: "vscode-css-languageserver",
2872
- }, options?.allowInstall);
2873
- },
2874
- };
2875
- // --- Registry ---
2876
- // Opengrep — a cross-language security scanner that speaks LSP. Unlike the
2877
- // per-language servers it attaches to MANY file kinds (the aggregation layer
2878
- // merges its diagnostics with the file's real language server). Running it as a
2879
- // warm LSP server compiles the ruleset once per session instead of paying it on
2880
- // every file (the ~8s CLI-per-file cost #111), bringing per-file scans to ~1.3s.
2881
- // Rules load via `initializationOptions.scan.configuration` (a local rule file
2882
- // if the repo has one, else Opengrep's login-free `auto` set).
2883
- const OPENGREP_KINDS = [
2884
- "csharp",
2885
- "css",
2886
- "cxx",
2887
- "dart",
2888
- "docker",
2889
- "go",
2890
- "html",
2891
- "java",
2892
- "json",
2893
- "jsts",
2894
- "kotlin",
2895
- "lua",
2896
- "php",
2897
- "python",
2898
- "ruby",
2899
- "rust",
2900
- "shell",
2901
- "swift",
2902
- "terraform",
2903
- "yaml",
2904
- ];
2905
- const OPENGREP_EXTENSIONS = Array.from(new Set(OPENGREP_KINDS.flatMap((k) => KIND_EXTENSIONS[k] ?? [])));
2906
- function opengrepInitialization(root) {
2907
- // As an always-on LSP server, enablement is structural (the server is
2908
- // registered); resolveOpengrepConfig here only chooses WHICH rules — a local
2909
- // rule file if present, otherwise `auto`.
2910
- const resolved = resolveOpengrepConfig(root, { enabled: true });
2911
- return {
2912
- scan: {
2913
- configuration: [resolved.configArg ?? "auto"],
2914
- onlyGitDirty: false,
2915
- jobs: 16,
2916
- },
2917
- metrics: { enabled: false },
2918
- doHover: false,
2919
- };
2920
- }
2921
- export const OpengrepServer = {
2922
- id: "opengrep",
2923
- name: "Opengrep Security Scanner",
2924
- role: "auxiliary",
2925
- extensions: OPENGREP_EXTENSIONS,
2926
- // Stable per-repo root so ONE warm server serves the whole project (a
2927
- // per-directory root would spawn a fresh server — and re-pay rule load —
2928
- // for every folder).
2929
- root: RootWithFallback(NearestRoot([".git"]), async () => process.cwd()),
2930
- availabilityKey: "opengrep",
2931
- // Rule compilation can take a few seconds on the first scan of a session.
2932
- initializeTimeoutMs: 15000,
2933
- async spawn(root, options) {
2934
- const launched = await resolveAndLaunch({
2935
- candidates: ["opengrep"],
2936
- args: ["lsp", "--experimental"],
2937
- cwd: root,
2938
- managedToolId: "opengrep",
2939
- }, options?.allowInstall);
2940
- if (!launched)
2941
- return undefined;
2942
- return { ...launched, initialization: opengrepInitialization(root) };
2943
- },
2944
- autoInstall: async () => Boolean(await ensureTool("opengrep")),
2945
- };
2946
- // ast-grep — a polyglot structural linter that speaks LSP. Like Opengrep it is a
2947
- // cross-cutting, diagnostic-only auxiliary (never a file's primary language
2948
- // server). It attaches EVERYWHERE (#239 Phase 2): a project `sgconfig.y[a]ml`
2949
- // surfaces the team's OWN curated rules (auto-discovered), and absent one it
2950
- // launches with `--config <shipped baseline>` so pi-lens's bundled ruleset runs
2951
- // anyway — superseding the in-process napi runner, which steps aside when this
2952
- // server's binary is available (and resumes as the fallback when it isn't —
2953
- // Gate B). NOTE: the napi runner is NOT a subset — it delegates to napi's native
2954
- // engine via root.findAll({rule}) (#206), the SAME Rust core as this LSP and the
2955
- // ast-grep CLI, so rule semantics are identical across all three. The LSP's edge
2956
- // is engine-driven codeAction fixes, not faithfulness of matching. #2347 closed
2957
- // the one known divergence under Gate B: the LSP/CLI resolve embedded `<script>`
2958
- // bodies in HTML and run `language: JavaScript` rules inside them, and the napi
2959
- // fallback now mirrors that (each script body is reparsed as JS and findings are
2960
- // translated back to file coordinates). A future ast-grep embedded-content
2961
- // surface (for example `<style>` bodies, which 0.45.1 does NOT inject) must land
2962
- // on both routes together or be recorded here as an accepted divergence.
2963
- const AST_GREP_KINDS = [
2964
- "csharp",
2965
- "cxx",
2966
- "css",
2967
- "elixir",
2968
- "go",
2969
- "haskell",
2970
- "html",
2971
- "java",
2972
- "json",
2973
- "jsts",
2974
- "kotlin",
2975
- "lua",
2976
- "nix",
2977
- "php",
2978
- "python",
2979
- "ruby",
2980
- "rust",
2981
- "scala",
2982
- "shell",
2983
- "swift",
2984
- "yaml",
2985
- ];
2986
- const AST_GREP_EXTENSIONS = Array.from(new Set(AST_GREP_KINDS.flatMap((k) => KIND_EXTENSIONS[k] ?? [])));
2987
- export const AstGrepServer = {
2988
- id: "ast-grep",
2989
- name: "ast-grep structural linter",
2990
- role: "auxiliary",
2991
- extensions: AST_GREP_EXTENSIONS,
2992
- // Attaches everywhere (#239 Phase 2): prefer a project `sgconfig.y[a]ml` root,
2993
- // else the repo root (.git) or cwd — like Opengrep. When there's no project
2994
- // sgconfig the spawn launches with `--config <shipped baseline>` so the team's
2995
- // rules still run; the napi runner steps aside when this server is available
2996
- // (it falls back to napi when the ast-grep binary is absent — Gate B).
2997
- root: RootWithFallback(createRootDetector(["sgconfig.yml", "sgconfig.yaml"]), RootWithFallback(NearestRoot([".git"]), async () => process.cwd())),
2998
- availabilityKey: "ast-grep",
2999
- // #1714: the one auxiliary with a measured wedge ceiling. A `lens_diagnostics
3000
- // mode=full` sweep of 225 files drove this server into an unrecoverable stall
3001
- // twice in two exposures (writes outstanding 5.9 s and 9.4 s, then a forced
3002
- // shutdown that timed out both the request and the exit notify). It re-parses
3003
- // the whole file on every didOpen, so it absorbs a sweep more slowly than the
3004
- // other scanners — hold it to half the shared default.
3005
- notifyInflightLimit: 4,
3006
- // First scan of a session compiles the rules.
3007
- initializeTimeoutMs: 15000,
3008
- async spawn(root, options) {
3009
- // A project sgconfig wins (the team's curated ruleset, auto-discovered from
3010
- // cwd). Otherwise point `--config` at pi-lens's shipped baseline ruleset.
3011
- const projectSgconfig = findLocalSgconfig(root);
3012
- let args = ["lsp"];
3013
- if (!projectSgconfig) {
3014
- const baseline = resolveBaselineSgconfig(root);
3015
- if (baseline)
3016
- args = ["lsp", "--config", baseline];
3017
- }
3018
- // #472: prefer the platform-native exe directly (one less orphanable
3019
- // node-bin-wrapper layer). Prepended as the first candidate; falls back
3020
- // to the existing "ast-grep" PATH/global-bin resolution when the
3021
- // optional native package isn't installed for this platform/arch.
3022
- const nativeExe = resolveAstGrepNativeExe();
3023
- const candidates = nativeExe ? [nativeExe, "ast-grep"] : ["ast-grep"];
3024
- return resolveAndLaunch({
3025
- candidates,
3026
- args,
3027
- cwd: root,
3028
- managedToolId: "ast-grep",
3029
- }, options?.allowInstall);
3030
- },
3031
- autoInstall: async () => Boolean(await ensureTool("ast-grep")),
3032
- };
3033
- // zizmor — a GitHub Actions workflow-security scanner that speaks LSP (#272).
3034
- // Like Opengrep/ast-grep it is a cross-cutting, diagnostic-only auxiliary. Its
3035
- // extension match (any YAML) is intentionally broad — actual candidacy is
3036
- // narrowed by `pathFilter` (`isZizmorAuditTarget`, #636) to the exact paths
3037
- // zizmor's own input collection audits (`.github/workflows/*`, `action.yml`,
3038
- // `.github/dependabot.yaml`); every other YAML file is a guaranteed no-op —
3039
- // measured directly against a real `zizmor --lsp` process, a non-matching
3040
- // file gets NO `publishDiagnostics` at all, so without the path gate every
3041
- // edit of e.g. a `docker-compose.yml` would burn zizmor's full
3042
- // diagnostics-wait budget for zero signal. Its audit set ("regular" persona)
3043
- // is compiled-in and runs with NO config; a repo `zizmor.yml` only
3044
- // tunes/ignores rules (the blocking opt-in, see the auxiliary profile).
3045
- // Online audits (known-vulnerable-actions, unpinned-uses, …) need a GitHub
3046
- // token — resolveZizmorGitHubToken forwards one (env, else `gh auth token`);
3047
- // without it zizmor runs its offline audit subset.
3048
- const ZIZMOR_EXTENSIONS = KIND_EXTENSIONS["yaml"];
3049
- export const ZizmorServer = {
3050
- id: "zizmor",
3051
- name: "zizmor Actions Security Scanner",
3052
- role: "auxiliary",
3053
- extensions: ZIZMOR_EXTENSIONS,
3054
- pathFilter: isZizmorAuditTarget,
3055
- // Stable per-repo root so ONE warm server serves the whole project (like
3056
- // Opengrep) — config + workflow discovery is repo-relative.
3057
- root: RootWithFallback(NearestRoot([".git"]), async () => process.cwd()),
3058
- availabilityKey: "zizmor",
3059
- async spawn(root, options) {
3060
- // Forward a token so the online audits run; absent one, zizmor self-selects
3061
- // offline mode (the env vars + `gh auth token` are resolved once and merged
3062
- // over process.env by launchLSP).
3063
- const ghToken = await resolveZizmorGitHubToken();
3064
- return resolveAndLaunch({
3065
- candidates: ["zizmor"],
3066
- args: ["--lsp"],
3067
- cwd: root,
3068
- managedToolId: "zizmor",
3069
- ...(ghToken ? { env: { GH_TOKEN: ghToken } } : {}),
3070
- }, options?.allowInstall);
3071
- },
3072
- autoInstall: async () => Boolean(await ensureTool("zizmor")),
3073
- };
3074
- // typos — a source-code spell checker that speaks LSP (#283). Cross-cutting,
3075
- // diagnostic-only auxiliary like Opengrep/ast-grep/zizmor: it attaches to many
3076
- // code kinds AND markdown/docs (option B — a spell checker that skips prose
3077
- // misses its highest-value target; typos is ALLOW-LIST based, so it only flags
3078
- // known misspellings with a known correction, keeping the false-positive rate on
3079
- // technical vocab low). Its built-in dictionary is compiled in — NO config needed
3080
- // to run; a repo `typos.toml`/`_typos.toml`/`.typos.toml` only tunes the
3081
- // dictionary/severity (and is the blocking opt-in, see the auxiliary profile).
3082
- // `typos-lsp` takes NO subcommand/flag — it wires stdin/stdout straight into the
3083
- // LSP server. Default severity is WARNING, so findings are advisory by default.
3084
- const TYPOS_EXTENSIONS = Array.from(new Set([...OPENGREP_EXTENSIONS, ...KIND_EXTENSIONS["markdown"]]));
3085
- // #967: typos-lsp's `initializationOptions.config` is a filesystem PATH to a
3086
- // config file (confirmed against upstream source — crates/typos-lsp/src/lsp.rs
3087
- // reads `config` as a string and tilde-expands it into a PathBuf; it is never
3088
- // an inline TOML string nor a parsed table). typos-lsp then MERGES that config
3089
- // with any repo-local one it discovers itself, with the injected config
3090
- // taking precedence on key collisions — so a project's own config must never
3091
- // be injected alongside ours (see findLocalTyposConfig below): honoring an
3092
- // existing project config means injecting NOTHING, letting typos-lsp read the
3093
- // project's file untouched.
3094
- function typosInitialization(root) {
3095
- const localConfig = findLocalTyposConfig(root);
3096
- if (localConfig) {
3097
- logLatency({
3098
- type: "phase",
3099
- phase: "typos_config_resolved",
3100
- filePath: root,
3101
- durationMs: 0,
3102
- metadata: { mode: "project_config", configPath: localConfig },
3103
- });
3104
- logSessionStart(`typos config resolved mode=project_config configPath=${localConfig}`);
3105
- return undefined;
3106
- }
3107
- const configPath = resolvePackagePath(import.meta.url, "rules", "typos", "_typos.toml");
3108
- logLatency({
3109
- type: "phase",
3110
- phase: "typos_config_resolved",
3111
- filePath: root,
3112
- durationMs: 0,
3113
- metadata: { mode: "injected_default", configPath },
3114
- });
3115
- logSessionStart(`typos config resolved mode=injected_default configPath=${configPath}`);
3116
- return { config: configPath };
3117
- }
3118
- export const TyposServer = {
3119
- id: "typos",
3120
- name: "typos Spell Checker",
3121
- role: "auxiliary",
3122
- extensions: TYPOS_EXTENSIONS,
3123
- // Stable per-repo root so ONE warm server serves the whole project (like the
3124
- // other auxiliaries) — typos.toml discovery is repo-relative.
3125
- root: RootWithFallback(NearestRoot([".git"]), async () => process.cwd()),
3126
- availabilityKey: "typos-lsp",
3127
- async spawn(root, options) {
3128
- const launched = await resolveAndLaunch({
3129
- candidates: ["typos-lsp"],
3130
- args: [],
3131
- cwd: root,
3132
- managedToolId: "typos-lsp",
3133
- }, options?.allowInstall);
3134
- if (!launched)
3135
- return undefined;
3136
- const initialization = typosInitialization(root);
3137
- return initialization ? { ...launched, initialization } : launched;
3138
- },
3139
- autoInstall: async () => Boolean(await ensureTool("typos-lsp")),
3140
- };
3141
- export const LSP_SERVERS = [
3142
- TypeScriptServer,
3143
- DenoServer,
3144
- PythonServer, // pyright / basedpyright — preferred; openFilesOnly avoids cold-start; ty (#717) is a local-only opt-in fallback
3145
- PythonJediServer, // fallback when neither pyright nor basedpyright is available
3146
- GoServer,
3147
- RustServer,
3148
- RubyServer,
3149
- PHPServer,
3150
- PowerShellServer, // PowerShell Editor Services — pwsh-bootstrapped module bundle (#278)
3151
- CSharpServer,
3152
- OmniSharpServer,
3153
- FSharpServer,
3154
- JavaServer,
3155
- KotlinServer,
3156
- SwiftServer,
3157
- DartServer,
3158
- LuaServer,
3159
- CppServer,
3160
- ZigServer,
3161
- HaskellServer,
3162
- ElixirServer,
3163
- ElixirExpertServer,
3164
- GleamServer,
3165
- TinymistServer,
3166
- MarksmanServer,
3167
- OCamlServer,
3168
- ClojureServer,
3169
- CueServer,
3170
- TerraformServer,
3171
- NixServer,
3172
- BashServer,
3173
- FishServer,
3174
- CMakeServer,
3175
- DockerServer,
3176
- YamlServer,
3177
- JsonServer,
3178
- HtmlServer,
3179
- TomlServer,
3180
- PrismaServer,
3181
- // Web frameworks & styling
3182
- VueServer,
3183
- SvelteServer,
3184
- CssServer,
3185
- // Auxiliary (cross-cutting, diagnostic-only) servers go last — never primary.
3186
- OpengrepServer,
3187
- AstGrepServer,
3188
- ZizmorServer,
3189
- TyposServer,
3190
- ];
3191
- /**
3192
- * Get server for a file extension
3193
- */
3194
- export function getServerForExtension(ext) {
3195
- return LSP_SERVERS.find((server) => server.extensions.includes(ext));
3196
- }
3197
- /**
3198
- * Get server by ID
3199
- */
3200
- export function getServerById(id) {
3201
- return LSP_SERVERS.find((server) => server.id === id);
3202
- }
3203
- /**
3204
- * Get all servers for a file (may have multiple matches)
3205
- */
3206
- export function getServersForFile(filePath) {
3207
- const ext = path.extname(filePath).toLowerCase();
3208
- return LSP_SERVERS.filter((server) => server.extensions.includes(ext));
3209
- }