@herbertgao/pi-extensions 2026.10.1 → 2026.10.2

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 (707) hide show
  1. package/README.md +13 -9
  2. package/THIRD_PARTY_NOTICES.md +2 -2
  3. package/node_modules/@dietrichgebert/ponytail/.opencode/command/ponytail-audit.md +2 -2
  4. package/node_modules/@dietrichgebert/ponytail/.opencode/command/ponytail-debt.md +2 -2
  5. package/node_modules/@dietrichgebert/ponytail/.opencode/command/ponytail-gain.md +1 -1
  6. package/node_modules/@dietrichgebert/ponytail/.opencode/command/ponytail-help.md +1 -1
  7. package/node_modules/@dietrichgebert/ponytail/.opencode/command/ponytail-review.md +2 -2
  8. package/node_modules/@dietrichgebert/ponytail/.opencode/command/ponytail.md +1 -1
  9. package/node_modules/@dietrichgebert/ponytail/.qoder/rules/ponytail.md +21 -21
  10. package/node_modules/@dietrichgebert/ponytail/.qoder-plugin/plugin.json +1 -1
  11. package/node_modules/@dietrichgebert/ponytail/AGENTS.md +21 -21
  12. package/node_modules/@dietrichgebert/ponytail/README.md +68 -72
  13. package/node_modules/@dietrichgebert/ponytail/hooks/ponytail-activate.js +7 -0
  14. package/node_modules/@dietrichgebert/ponytail/hooks/ponytail-instructions.js +30 -30
  15. package/node_modules/@dietrichgebert/ponytail/hooks/ponytail-map.js +103 -0
  16. package/node_modules/@dietrichgebert/ponytail/package.json +2 -2
  17. package/node_modules/@dietrichgebert/ponytail/skills/ponytail/SKILL.md +33 -101
  18. package/node_modules/@dietrichgebert/ponytail/skills/ponytail-audit/SKILL.md +89 -32
  19. package/node_modules/@dietrichgebert/ponytail/skills/ponytail-debt/SKILL.md +13 -12
  20. package/node_modules/@dietrichgebert/ponytail/skills/ponytail-gain/SKILL.md +17 -16
  21. package/node_modules/@dietrichgebert/ponytail/skills/ponytail-help/SKILL.md +9 -10
  22. package/node_modules/@dietrichgebert/ponytail/skills/ponytail-review/SKILL.md +96 -57
  23. package/node_modules/@herbertgao/pi-agency/LICENSE +21 -0
  24. package/node_modules/@herbertgao/pi-agency/README.md +40 -0
  25. package/node_modules/@herbertgao/pi-agency/package.json +53 -0
  26. package/node_modules/@herbertgao/pi-agency/src/index.ts +441 -0
  27. package/node_modules/@herbertgao/pi-cc-extensions/extensions/feature/compact-thinking.ts +12 -12
  28. package/node_modules/@herbertgao/pi-cc-extensions/extensions/renderer/compact-mode.ts +30 -6
  29. package/node_modules/@herbertgao/pi-cc-extensions/extensions/renderer/default-mode.ts +15 -9
  30. package/node_modules/@herbertgao/pi-cc-extensions/extensions/renderer/mouse/interaction.ts +27 -9
  31. package/node_modules/@herbertgao/pi-cc-extensions/extensions/renderer/tool/codemode.ts +4 -1
  32. package/node_modules/@herbertgao/pi-cc-extensions/package.json +2 -2
  33. package/node_modules/@narumitw/pi-caffeinate/package.json +3 -3
  34. package/node_modules/@tifan/pi-mermaid-open/package.json +5 -1
  35. package/node_modules/@tifan/pi-mermaid-open/src/index.ts +26 -9
  36. package/node_modules/@tifan/pi-recap/package.json +1 -1
  37. package/node_modules/@tifan/pi-recap/src/index.ts +104 -20
  38. package/node_modules/@tifan/pi-recap/src/models.ts +46 -4
  39. package/node_modules/pi-antigravity/README.md +23 -2
  40. package/node_modules/pi-antigravity/package.json +5 -3
  41. package/node_modules/pi-antigravity/src/diagnostics/diagnostics.ts +3 -2
  42. package/node_modules/pi-antigravity/src/features/generate-image.ts +72 -0
  43. package/node_modules/pi-antigravity/src/features/google-search.ts +71 -0
  44. package/node_modules/pi-antigravity/src/image/image.ts +86 -47
  45. package/node_modules/pi-antigravity/src/image/native.ts +69 -0
  46. package/node_modules/pi-antigravity/src/index.ts +52 -137
  47. package/node_modules/pi-antigravity/src/search/search.ts +130 -31
  48. package/node_modules/pi-antigravity/src/stream/stream.ts +162 -113
  49. package/node_modules/pi-antigravity/src/types/enums.ts +1 -0
  50. package/node_modules/pi-antigravity/src/types/types.ts +1 -1
  51. package/node_modules/pi-antigravity/src/utils/security.ts +47 -1
  52. package/node_modules/pi-antigravity/src/utils/util.ts +38 -6
  53. package/node_modules/pi-lens/CHANGELOG.md +1064 -0
  54. package/node_modules/pi-lens/README.md +115 -25
  55. package/node_modules/pi-lens/config/dependency-cruiser-eager-allowlist.json +6 -2
  56. package/node_modules/pi-lens/dist/chunk-22AVIGXS.js +560 -0
  57. package/node_modules/pi-lens/dist/chunk-27BBHSZC.js +106 -0
  58. package/node_modules/pi-lens/dist/chunk-2BEMIWEW.js +51 -0
  59. package/node_modules/pi-lens/dist/chunk-2EECBNC5.js +89 -0
  60. package/node_modules/pi-lens/dist/chunk-34U7PLGE.js +23 -0
  61. package/node_modules/pi-lens/dist/chunk-3I5QFT3N.js +181 -0
  62. package/node_modules/pi-lens/dist/chunk-3MED75HT.js +37 -0
  63. package/node_modules/pi-lens/dist/chunk-47D2BO4S.js +87 -0
  64. package/node_modules/pi-lens/dist/chunk-4RERDEXR.js +348 -0
  65. package/node_modules/pi-lens/dist/chunk-4W2PLC4J.js +27 -0
  66. package/node_modules/pi-lens/dist/chunk-52H5B5TC.js +202 -0
  67. package/node_modules/pi-lens/dist/chunk-54B42YRX.js +27 -0
  68. package/node_modules/pi-lens/dist/chunk-5RXBNSW2.js +266 -0
  69. package/node_modules/pi-lens/dist/chunk-5THXD6X5.js +201 -0
  70. package/node_modules/pi-lens/dist/chunk-5W3R6LFT.js +24 -0
  71. package/node_modules/pi-lens/dist/chunk-5ZEJHV35.js +341 -0
  72. package/node_modules/pi-lens/dist/chunk-6P62QVEA.js +118 -0
  73. package/node_modules/pi-lens/dist/chunk-6QIUX4EP.js +459 -0
  74. package/node_modules/pi-lens/dist/chunk-6VCD4VKZ.js +78 -0
  75. package/node_modules/pi-lens/dist/chunk-7F72PXY2.js +326 -0
  76. package/node_modules/pi-lens/dist/chunk-7FBF7NPR.js +998 -0
  77. package/node_modules/pi-lens/dist/chunk-7U2IDNXN.js +37 -0
  78. package/node_modules/pi-lens/dist/chunk-ABBOA7UD.js +157 -0
  79. package/node_modules/pi-lens/dist/chunk-AHDW3GPE.js +50 -0
  80. package/node_modules/pi-lens/dist/chunk-AJ3QXTRQ.js +756 -0
  81. package/node_modules/pi-lens/dist/chunk-AJVLWNFN.js +1610 -0
  82. package/node_modules/pi-lens/dist/chunk-AJXODZY6.js +112 -0
  83. package/node_modules/pi-lens/dist/chunk-ARFCXG7S.js +84 -0
  84. package/node_modules/pi-lens/dist/chunk-AS44CFQ4.js +708 -0
  85. package/node_modules/pi-lens/dist/chunk-AWE5TMPO.js +38 -0
  86. package/node_modules/pi-lens/dist/chunk-B3I2UUT4.js +4692 -0
  87. package/node_modules/pi-lens/dist/chunk-BRJKOBZP.js +331 -0
  88. package/node_modules/pi-lens/dist/chunk-BW5KFIQC.js +59 -0
  89. package/node_modules/pi-lens/dist/chunk-C2AUYXQH.js +149 -0
  90. package/node_modules/pi-lens/dist/chunk-CGGAIP7J.js +29 -0
  91. package/node_modules/pi-lens/dist/chunk-D6QS3U6M.js +30 -0
  92. package/node_modules/pi-lens/dist/chunk-DFVMIKMQ.js +349 -0
  93. package/node_modules/pi-lens/dist/chunk-DIULIWKU.js +169 -0
  94. package/node_modules/pi-lens/dist/chunk-DQ2NZ7C4.js +2317 -0
  95. package/node_modules/pi-lens/dist/chunk-E2WFBRHM.js +213 -0
  96. package/node_modules/pi-lens/dist/chunk-E4MAEDPM.js +50 -0
  97. package/node_modules/pi-lens/dist/chunk-EOKRQ3SA.js +30 -0
  98. package/node_modules/pi-lens/dist/chunk-F3AU2TMM.js +2626 -0
  99. package/node_modules/pi-lens/dist/chunk-F3P7VQ7H.js +170 -0
  100. package/node_modules/pi-lens/dist/chunk-FC4RP4I2.js +12 -0
  101. package/node_modules/pi-lens/dist/chunk-FJNKANOS.js +32 -0
  102. package/node_modules/pi-lens/dist/chunk-GUOGVTCQ.js +146 -0
  103. package/node_modules/pi-lens/dist/chunk-GVXA6CCE.js +18 -0
  104. package/node_modules/pi-lens/dist/chunk-HHYAELLJ.js +2334 -0
  105. package/node_modules/pi-lens/dist/chunk-HMFXILPV.js +83 -0
  106. package/node_modules/pi-lens/dist/chunk-HVUZXSWK.js +421 -0
  107. package/node_modules/pi-lens/dist/chunk-HWBZIGMJ.js +62 -0
  108. package/node_modules/pi-lens/dist/chunk-I3R7GKDR.js +17 -0
  109. package/node_modules/pi-lens/dist/chunk-I7SPGOWI.js +55 -0
  110. package/node_modules/pi-lens/dist/chunk-IIXOAGHR.js +38 -0
  111. package/node_modules/pi-lens/dist/chunk-IVP5KAB7.js +2250 -0
  112. package/node_modules/pi-lens/dist/chunk-IXEBJSUE.js +22 -0
  113. package/node_modules/pi-lens/dist/chunk-JOLITGCC.js +27 -0
  114. package/node_modules/pi-lens/dist/chunk-K6VYLB2U.js +145 -0
  115. package/node_modules/pi-lens/dist/chunk-LJDODBBZ.js +78 -0
  116. package/node_modules/pi-lens/dist/chunk-LNAKEO63.js +78 -0
  117. package/node_modules/pi-lens/dist/chunk-LSMJ6KBE.js +4139 -0
  118. package/node_modules/pi-lens/dist/chunk-LW6XKDZQ.js +5565 -0
  119. package/node_modules/pi-lens/dist/chunk-N3YQJI6O.js +899 -0
  120. package/node_modules/pi-lens/dist/chunk-NABLC7W3.js +148 -0
  121. package/node_modules/pi-lens/dist/chunk-NKOUFPQF.js +659 -0
  122. package/node_modules/pi-lens/dist/chunk-NL5GLMFI.js +211 -0
  123. package/node_modules/pi-lens/dist/chunk-NMABWBZN.js +327 -0
  124. package/node_modules/pi-lens/dist/chunk-NTHH2J74.js +27 -0
  125. package/node_modules/pi-lens/dist/chunk-NTUJEGDZ.js +59 -0
  126. package/node_modules/pi-lens/dist/chunk-NXL4FFQQ.js +47 -0
  127. package/node_modules/pi-lens/dist/chunk-NXPS2L6U.js +466 -0
  128. package/node_modules/pi-lens/dist/chunk-O5WILGMI.js +42 -0
  129. package/node_modules/pi-lens/dist/chunk-O6TQT6RI.js +48 -0
  130. package/node_modules/pi-lens/dist/chunk-OAXNDZJ7.js +323 -0
  131. package/node_modules/pi-lens/dist/chunk-ODEEOXPH.js +44 -0
  132. package/node_modules/pi-lens/dist/chunk-OQGFCZJ4.js +4533 -0
  133. package/node_modules/pi-lens/dist/chunk-ORTMY4Q4.js +282 -0
  134. package/node_modules/pi-lens/dist/chunk-OS32RNR2.js +442 -0
  135. package/node_modules/pi-lens/dist/chunk-PAK6IDME.js +189 -0
  136. package/node_modules/pi-lens/dist/chunk-PWFHBTSF.js +406 -0
  137. package/node_modules/pi-lens/dist/chunk-Q5U6FMDI.js +2352 -0
  138. package/node_modules/pi-lens/dist/chunk-QOZXJ4NA.js +3364 -0
  139. package/node_modules/pi-lens/dist/chunk-QYF5S6P6.js +45 -0
  140. package/node_modules/pi-lens/dist/chunk-RIISMHKN.js +36 -0
  141. package/node_modules/pi-lens/dist/chunk-RPD4ZSB7.js +25 -0
  142. package/node_modules/pi-lens/dist/chunk-RPD5K5NC.js +79 -0
  143. package/node_modules/pi-lens/dist/chunk-RUOG5D2C.js +262 -0
  144. package/node_modules/pi-lens/dist/chunk-T4ERT73Y.js +599 -0
  145. package/node_modules/pi-lens/dist/chunk-T6D4Q4AQ.js +54 -0
  146. package/node_modules/pi-lens/dist/chunk-TGPXLY2J.js +391 -0
  147. package/node_modules/pi-lens/dist/chunk-TLGFBM3F.js +12116 -0
  148. package/node_modules/pi-lens/dist/chunk-TO6WL3GM.js +88 -0
  149. package/node_modules/pi-lens/dist/chunk-TT22N74J.js +517 -0
  150. package/node_modules/pi-lens/dist/chunk-TVN5N6GY.js +21216 -0
  151. package/node_modules/pi-lens/dist/chunk-U3VGR4VU.js +308 -0
  152. package/node_modules/pi-lens/dist/chunk-UK3CMEAL.js +1250 -0
  153. package/node_modules/pi-lens/dist/chunk-UOPVJQ4N.js +103 -0
  154. package/node_modules/pi-lens/dist/chunk-UXVNL3D7.js +23 -0
  155. package/node_modules/pi-lens/dist/chunk-UY752QSN.js +45 -0
  156. package/node_modules/pi-lens/dist/chunk-V5PXFSZK.js +188 -0
  157. package/node_modules/pi-lens/dist/chunk-V7R425XG.js +398 -0
  158. package/node_modules/pi-lens/dist/chunk-VCSRSYLF.js +81 -0
  159. package/node_modules/pi-lens/dist/chunk-VD4JQCQC.js +162 -0
  160. package/node_modules/pi-lens/dist/chunk-VDBPVIKD.js +62 -0
  161. package/node_modules/pi-lens/dist/chunk-VHQLPIRJ.js +114 -0
  162. package/node_modules/pi-lens/dist/chunk-VN2AXLEX.js +5414 -0
  163. package/node_modules/pi-lens/dist/chunk-VNMT7C64.js +478 -0
  164. package/node_modules/pi-lens/dist/chunk-WONERJTP.js +309 -0
  165. package/node_modules/pi-lens/dist/chunk-XECLWQZN.js +85 -0
  166. package/node_modules/pi-lens/dist/chunk-XIMYZ3JA.js +27 -0
  167. package/node_modules/pi-lens/dist/chunk-XK4CIILI.js +144 -0
  168. package/node_modules/pi-lens/dist/chunk-Y4T27COV.js +562 -0
  169. package/node_modules/pi-lens/dist/chunk-YGVYKNSN.js +1860 -0
  170. package/node_modules/pi-lens/dist/chunk-YLF3PPT5.js +308 -0
  171. package/node_modules/pi-lens/dist/chunk-YSNFMJKN.js +14 -0
  172. package/node_modules/pi-lens/dist/chunk-Z3CCVDUB.js +76 -0
  173. package/node_modules/pi-lens/dist/chunk-Z54ZSOBZ.js +61 -0
  174. package/node_modules/pi-lens/dist/chunk-ZAWW3AF6.js +97 -0
  175. package/node_modules/pi-lens/dist/chunk-ZMOL6RQC.js +167 -0
  176. package/node_modules/pi-lens/dist/index.js +16456 -8335
  177. package/node_modules/pi-lens/dist/mcp/analyze-cli.js +186 -232
  178. package/node_modules/pi-lens/dist/mcp/cli.js +137 -98
  179. package/node_modules/pi-lens/dist/mcp/server.js +22589 -1573
  180. package/node_modules/pi-lens/dist/mcp/worker.js +97 -38
  181. package/node_modules/pi-lens/dist/probes/installer.js +135 -0
  182. package/node_modules/pi-lens/dist/workers/project-snapshot-persist-worker.js +22 -0
  183. package/node_modules/pi-lens/dist/workers/review-graph-persist-worker.js +15 -0
  184. package/node_modules/pi-lens/docs/agent-guide.md +13 -4
  185. package/node_modules/pi-lens/docs/agent-tools.md +42 -0
  186. package/node_modules/pi-lens/docs/ast-grep_rules_catalog.md +3 -2
  187. package/node_modules/pi-lens/docs/configuration.md +20 -1
  188. package/node_modules/pi-lens/docs/environment-variables.md +52 -2
  189. package/node_modules/pi-lens/docs/features.md +8 -4
  190. package/node_modules/pi-lens/docs/globalconfig.md +1 -0
  191. package/node_modules/pi-lens/docs/io-bridge-v2-migration.md +94 -0
  192. package/node_modules/pi-lens/docs/lsp-capability-matrix.md +36 -4
  193. package/node_modules/pi-lens/docs/lsp-idle-eviction.md +124 -0
  194. package/node_modules/pi-lens/docs/mcp.md +21 -5
  195. package/node_modules/pi-lens/docs/merge-queue-rollout.md +77 -0
  196. package/node_modules/pi-lens/docs/pi-lens-fixer.md +156 -88
  197. package/node_modules/pi-lens/docs/pi-lens-investigator.md +74 -26
  198. package/node_modules/pi-lens/docs/pi-lens-merge-policy.md +694 -0
  199. package/node_modules/pi-lens/docs/pi-lens-monitor.md +73 -7
  200. package/node_modules/pi-lens/docs/pi-lens-retro.md +6 -4
  201. package/node_modules/pi-lens/docs/pi-lens-reviewer.md +188 -48
  202. package/node_modules/pi-lens/docs/pi-lens-subagent.md +246 -45
  203. package/node_modules/pi-lens/docs/pi-lens-warden.md +18 -7
  204. package/node_modules/pi-lens/docs/public-api-stability.md +55 -0
  205. package/node_modules/pi-lens/docs/release-qa-baseline.md +84 -17
  206. package/node_modules/pi-lens/docs/rfcs/3654-unified-io-bridge-v2.md +316 -0
  207. package/node_modules/pi-lens/docs/servercapabilities.md +53 -49
  208. package/node_modules/pi-lens/docs/settings.md +1 -0
  209. package/node_modules/pi-lens/docs/usage.md +5 -1
  210. package/node_modules/pi-lens/grammars/tree-sitter-bash.wasm +0 -0
  211. package/node_modules/pi-lens/grammars/tree-sitter-bash.wasm.json +3 -3
  212. package/node_modules/pi-lens/package.json +18 -6
  213. package/node_modules/pi-lens/rules/ast-grep-rules/rule-tests/advisory-registry-subset-test.yml +41 -0
  214. package/node_modules/pi-lens/rules/ast-grep-rules/rules/advisory-registry-subset.yml +50 -0
  215. package/node_modules/pi-lens/scripts/analyze-pi-lens-logs.mjs +1275 -62
  216. package/node_modules/pi-lens/scripts/download-grammars.js +5 -0
  217. package/node_modules/pi-lens/scripts/grammars.lock.json +6 -1
  218. package/node_modules/pi-lens/scripts/install-selftest.mjs +33 -34
  219. package/node_modules/pi-lens/scripts/lib/host-provided-deps.mjs +13 -4
  220. package/node_modules/pi-lens/scripts/lib/web-tree-sitter-dir.mjs +18 -0
  221. package/package.json +15 -10
  222. package/node_modules/@dietrichgebert/ponytail/README.es.md +0 -212
  223. package/node_modules/@dietrichgebert/ponytail/README.ja.md +0 -212
  224. package/node_modules/@dietrichgebert/ponytail/README.ko.md +0 -212
  225. package/node_modules/@dietrichgebert/ponytail/README.zh-CN.md +0 -212
  226. package/node_modules/pi-lens/dist/clients/actionable-warnings-logger.js +0 -35
  227. package/node_modules/pi-lens/dist/clients/actionable-warnings.js +0 -1610
  228. package/node_modules/pi-lens/dist/clients/advisory-provenance.js +0 -593
  229. package/node_modules/pi-lens/dist/clients/agent-behavior-client.js +0 -147
  230. package/node_modules/pi-lens/dist/clients/agent-nudge.js +0 -325
  231. package/node_modules/pi-lens/dist/clients/analysed-root.js +0 -1
  232. package/node_modules/pi-lens/dist/clients/ast-grep-client.js +0 -731
  233. package/node_modules/pi-lens/dist/clients/ast-grep-rule-manager.js +0 -146
  234. package/node_modules/pi-lens/dist/clients/ast-grep-tool-logger.js +0 -118
  235. package/node_modules/pi-lens/dist/clients/ast-grep-types.js +0 -10
  236. package/node_modules/pi-lens/dist/clients/ast-grep-yaml-synth.js +0 -154
  237. package/node_modules/pi-lens/dist/clients/atomic-write-staging.js +0 -75
  238. package/node_modules/pi-lens/dist/clients/atomic-write.js +0 -189
  239. package/node_modules/pi-lens/dist/clients/availability-probe-flight.js +0 -12
  240. package/node_modules/pi-lens/dist/clients/bash-file-access.js +0 -1056
  241. package/node_modules/pi-lens/dist/clients/biome-client.js +0 -357
  242. package/node_modules/pi-lens/dist/clients/blocker-freshness.js +0 -837
  243. package/node_modules/pi-lens/dist/clients/blocker-past-eof.js +0 -129
  244. package/node_modules/pi-lens/dist/clients/bootstrap.js +0 -569
  245. package/node_modules/pi-lens/dist/clients/bounded-cache.js +0 -190
  246. package/node_modules/pi-lens/dist/clients/bounded-pid-file-lock.js +0 -202
  247. package/node_modules/pi-lens/dist/clients/bounded-telemetry.js +0 -295
  248. package/node_modules/pi-lens/dist/clients/build-identity.js +0 -95
  249. package/node_modules/pi-lens/dist/clients/bundled-resource-health.js +0 -113
  250. package/node_modules/pi-lens/dist/clients/bus-events-logger.js +0 -105
  251. package/node_modules/pi-lens/dist/clients/bus-publish.js +0 -171
  252. package/node_modules/pi-lens/dist/clients/cache/rule-cache.js +0 -190
  253. package/node_modules/pi-lens/dist/clients/cache-manager.js +0 -524
  254. package/node_modules/pi-lens/dist/clients/cache-observability.js +0 -1313
  255. package/node_modules/pi-lens/dist/clients/call-graph.js +0 -852
  256. package/node_modules/pi-lens/dist/clients/cargo-manifest.js +0 -422
  257. package/node_modules/pi-lens/dist/clients/cascade-budget.js +0 -196
  258. package/node_modules/pi-lens/dist/clients/cascade-format.js +0 -315
  259. package/node_modules/pi-lens/dist/clients/cascade-logger.js +0 -31
  260. package/node_modules/pi-lens/dist/clients/cascade-types.js +0 -9
  261. package/node_modules/pi-lens/dist/clients/child-unref.js +0 -124
  262. package/node_modules/pi-lens/dist/clients/code-quality-warnings.js +0 -286
  263. package/node_modules/pi-lens/dist/clients/codebase-model.js +0 -173
  264. package/node_modules/pi-lens/dist/clients/collateral-test-role.js +0 -40
  265. package/node_modules/pi-lens/dist/clients/complexity-client.js +0 -462
  266. package/node_modules/pi-lens/dist/clients/config-core/deny.js +0 -221
  267. package/node_modules/pi-lens/dist/clients/config-core/index.js +0 -50
  268. package/node_modules/pi-lens/dist/clients/config-core/merge.js +0 -357
  269. package/node_modules/pi-lens/dist/clients/config-core/normalize.js +0 -340
  270. package/node_modules/pi-lens/dist/clients/config-core/process-spec.js +0 -248
  271. package/node_modules/pi-lens/dist/clients/config-core/provenance.js +0 -164
  272. package/node_modules/pi-lens/dist/clients/config-core/records.js +0 -218
  273. package/node_modules/pi-lens/dist/clients/config-core/resolve.js +0 -125
  274. package/node_modules/pi-lens/dist/clients/config-core/safe-object.js +0 -78
  275. package/node_modules/pi-lens/dist/clients/config-core/schema.js +0 -165
  276. package/node_modules/pi-lens/dist/clients/config-diagnostic-codes.js +0 -285
  277. package/node_modules/pi-lens/dist/clients/config-locations.js +0 -388
  278. package/node_modules/pi-lens/dist/clients/config-resolve.js +0 -798
  279. package/node_modules/pi-lens/dist/clients/config-schema.js +0 -251
  280. package/node_modules/pi-lens/dist/clients/config-warn.js +0 -407
  281. package/node_modules/pi-lens/dist/clients/console-guard-install.js +0 -26
  282. package/node_modules/pi-lens/dist/clients/cooperative-budget.js +0 -42
  283. package/node_modules/pi-lens/dist/clients/dead-code-client.js +0 -510
  284. package/node_modules/pi-lens/dist/clients/dead-code-logger.js +0 -30
  285. package/node_modules/pi-lens/dist/clients/deadline-utils.js +0 -280
  286. package/node_modules/pi-lens/dist/clients/debug-handles.js +0 -211
  287. package/node_modules/pi-lens/dist/clients/debug-heap.js +0 -174
  288. package/node_modules/pi-lens/dist/clients/deferred-lsp-work.js +0 -106
  289. package/node_modules/pi-lens/dist/clients/degradation-ledger.js +0 -426
  290. package/node_modules/pi-lens/dist/clients/demoted-finding-render.js +0 -169
  291. package/node_modules/pi-lens/dist/clients/dependency-checker.js +0 -885
  292. package/node_modules/pi-lens/dist/clients/deps/ast-grep-napi.js +0 -22
  293. package/node_modules/pi-lens/dist/clients/deps/js-yaml.js +0 -9
  294. package/node_modules/pi-lens/dist/clients/deps/minimatch.js +0 -4
  295. package/node_modules/pi-lens/dist/clients/deps/pi-tui.js +0 -14
  296. package/node_modules/pi-lens/dist/clients/deps/typebox.js +0 -14
  297. package/node_modules/pi-lens/dist/clients/deps/vscode-jsonrpc.js +0 -1
  298. package/node_modules/pi-lens/dist/clients/deps/web-tree-sitter.js +0 -24
  299. package/node_modules/pi-lens/dist/clients/diagnostic-dispositions.js +0 -630
  300. package/node_modules/pi-lens/dist/clients/diagnostic-line-freshness.js +0 -305
  301. package/node_modules/pi-lens/dist/clients/diagnostic-logger.js +0 -73
  302. package/node_modules/pi-lens/dist/clients/diagnostic-tracker.js +0 -105
  303. package/node_modules/pi-lens/dist/clients/diagnostics-publish.js +0 -230
  304. package/node_modules/pi-lens/dist/clients/dispatch/ast-grep-catalog.js +0 -65
  305. package/node_modules/pi-lens/dist/clients/dispatch/auxiliary-lsp.js +0 -343
  306. package/node_modules/pi-lens/dist/clients/dispatch/collect-later-tier.js +0 -35
  307. package/node_modules/pi-lens/dist/clients/dispatch/diagnostic-taxonomy.js +0 -66
  308. package/node_modules/pi-lens/dist/clients/dispatch/dispatcher.js +0 -1269
  309. package/node_modules/pi-lens/dist/clients/dispatch/fact-provider-types.js +0 -1
  310. package/node_modules/pi-lens/dist/clients/dispatch/fact-rule-runner.js +0 -17
  311. package/node_modules/pi-lens/dist/clients/dispatch/fact-runner.js +0 -30
  312. package/node_modules/pi-lens/dist/clients/dispatch/fact-scheduler.js +0 -68
  313. package/node_modules/pi-lens/dist/clients/dispatch/fact-store.js +0 -377
  314. package/node_modules/pi-lens/dist/clients/dispatch/facts/comment-facts.js +0 -22
  315. package/node_modules/pi-lens/dist/clients/dispatch/facts/file-content.js +0 -19
  316. package/node_modules/pi-lens/dist/clients/dispatch/facts/function-facts.js +0 -348
  317. package/node_modules/pi-lens/dist/clients/dispatch/facts/import-facts.js +0 -204
  318. package/node_modules/pi-lens/dist/clients/dispatch/facts/tree-sitter-facts.js +0 -34
  319. package/node_modules/pi-lens/dist/clients/dispatch/facts/try-catch-facts.js +0 -119
  320. package/node_modules/pi-lens/dist/clients/dispatch/finding-policy.js +0 -339
  321. package/node_modules/pi-lens/dist/clients/dispatch/indent-detect.js +0 -417
  322. package/node_modules/pi-lens/dist/clients/dispatch/inline-suppressions.js +0 -89
  323. package/node_modules/pi-lens/dist/clients/dispatch/integration.js +0 -2377
  324. package/node_modules/pi-lens/dist/clients/dispatch/lazy.js +0 -11
  325. package/node_modules/pi-lens/dist/clients/dispatch/pending-runner-findings.js +0 -87
  326. package/node_modules/pi-lens/dist/clients/dispatch/plan.js +0 -278
  327. package/node_modules/pi-lens/dist/clients/dispatch/priorities.js +0 -20
  328. package/node_modules/pi-lens/dist/clients/dispatch/rule-id-normalize.js +0 -50
  329. package/node_modules/pi-lens/dist/clients/dispatch/rule-ignores.js +0 -69
  330. package/node_modules/pi-lens/dist/clients/dispatch/rule-policy.js +0 -135
  331. package/node_modules/pi-lens/dist/clients/dispatch/rules/async-noise.js +0 -40
  332. package/node_modules/pi-lens/dist/clients/dispatch/rules/async-unnecessary-wrapper.js +0 -27
  333. package/node_modules/pi-lens/dist/clients/dispatch/rules/cors-wildcard.js +0 -54
  334. package/node_modules/pi-lens/dist/clients/dispatch/rules/error-obscuring.js +0 -32
  335. package/node_modules/pi-lens/dist/clients/dispatch/rules/error-swallowing.js +0 -31
  336. package/node_modules/pi-lens/dist/clients/dispatch/rules/framework-call-noise.js +0 -67
  337. package/node_modules/pi-lens/dist/clients/dispatch/rules/high-complexity.js +0 -72
  338. package/node_modules/pi-lens/dist/clients/dispatch/rules/high-fan-out.js +0 -74
  339. package/node_modules/pi-lens/dist/clients/dispatch/rules/high-import-coupling.js +0 -37
  340. package/node_modules/pi-lens/dist/clients/dispatch/rules/missing-error-propagation.js +0 -62
  341. package/node_modules/pi-lens/dist/clients/dispatch/rules/no-commented-credentials.js +0 -52
  342. package/node_modules/pi-lens/dist/clients/dispatch/rules/pass-through-wrappers.js +0 -42
  343. package/node_modules/pi-lens/dist/clients/dispatch/rules/placeholder-comments.js +0 -40
  344. package/node_modules/pi-lens/dist/clients/dispatch/rules/unsafe-boundary.js +0 -126
  345. package/node_modules/pi-lens/dist/clients/dispatch/runner-context.js +0 -52
  346. package/node_modules/pi-lens/dist/clients/dispatch/runners/actionlint.js +0 -111
  347. package/node_modules/pi-lens/dist/clients/dispatch/runners/ast-grep-napi.js +0 -1196
  348. package/node_modules/pi-lens/dist/clients/dispatch/runners/biome-check.js +0 -296
  349. package/node_modules/pi-lens/dist/clients/dispatch/runners/cpp-check.js +0 -219
  350. package/node_modules/pi-lens/dist/clients/dispatch/runners/credo.js +0 -82
  351. package/node_modules/pi-lens/dist/clients/dispatch/runners/cue-vet.js +0 -416
  352. package/node_modules/pi-lens/dist/clients/dispatch/runners/dart-analyze.js +0 -183
  353. package/node_modules/pi-lens/dist/clients/dispatch/runners/detekt.js +0 -176
  354. package/node_modules/pi-lens/dist/clients/dispatch/runners/dotnet-build.js +0 -164
  355. package/node_modules/pi-lens/dist/clients/dispatch/runners/elixir-check.js +0 -195
  356. package/node_modules/pi-lens/dist/clients/dispatch/runners/eslint.js +0 -137
  357. package/node_modules/pi-lens/dist/clients/dispatch/runners/fact-rules.js +0 -29
  358. package/node_modules/pi-lens/dist/clients/dispatch/runners/fish-indent.js +0 -69
  359. package/node_modules/pi-lens/dist/clients/dispatch/runners/gleam-check.js +0 -167
  360. package/node_modules/pi-lens/dist/clients/dispatch/runners/go-vet.js +0 -104
  361. package/node_modules/pi-lens/dist/clients/dispatch/runners/golangci-lint.js +0 -135
  362. package/node_modules/pi-lens/dist/clients/dispatch/runners/hadolint.js +0 -91
  363. package/node_modules/pi-lens/dist/clients/dispatch/runners/helm-lint.js +0 -171
  364. package/node_modules/pi-lens/dist/clients/dispatch/runners/helm-render.js +0 -940
  365. package/node_modules/pi-lens/dist/clients/dispatch/runners/htmlhint.js +0 -117
  366. package/node_modules/pi-lens/dist/clients/dispatch/runners/index.js +0 -108
  367. package/node_modules/pi-lens/dist/clients/dispatch/runners/javac.js +0 -99
  368. package/node_modules/pi-lens/dist/clients/dispatch/runners/ktlint.js +0 -110
  369. package/node_modules/pi-lens/dist/clients/dispatch/runners/lsp.js +0 -379
  370. package/node_modules/pi-lens/dist/clients/dispatch/runners/markdownlint.js +0 -167
  371. package/node_modules/pi-lens/dist/clients/dispatch/runners/mypy.js +0 -102
  372. package/node_modules/pi-lens/dist/clients/dispatch/runners/oxlint.js +0 -414
  373. package/node_modules/pi-lens/dist/clients/dispatch/runners/php-lint.js +0 -85
  374. package/node_modules/pi-lens/dist/clients/dispatch/runners/phpstan.js +0 -120
  375. package/node_modules/pi-lens/dist/clients/dispatch/runners/prisma-validate.js +0 -62
  376. package/node_modules/pi-lens/dist/clients/dispatch/runners/psscriptanalyzer.js +0 -445
  377. package/node_modules/pi-lens/dist/clients/dispatch/runners/pyright.js +0 -169
  378. package/node_modules/pi-lens/dist/clients/dispatch/runners/rubocop.js +0 -99
  379. package/node_modules/pi-lens/dist/clients/dispatch/runners/ruff.js +0 -88
  380. package/node_modules/pi-lens/dist/clients/dispatch/runners/rust-clippy.js +0 -256
  381. package/node_modules/pi-lens/dist/clients/dispatch/runners/shellcheck.js +0 -173
  382. package/node_modules/pi-lens/dist/clients/dispatch/runners/shfmt.js +0 -102
  383. package/node_modules/pi-lens/dist/clients/dispatch/runners/spellcheck.js +0 -128
  384. package/node_modules/pi-lens/dist/clients/dispatch/runners/spotbugs.js +0 -259
  385. package/node_modules/pi-lens/dist/clients/dispatch/runners/sqlfluff.js +0 -183
  386. package/node_modules/pi-lens/dist/clients/dispatch/runners/stylelint.js +0 -166
  387. package/node_modules/pi-lens/dist/clients/dispatch/runners/swiftlint.js +0 -163
  388. package/node_modules/pi-lens/dist/clients/dispatch/runners/taplo.js +0 -174
  389. package/node_modules/pi-lens/dist/clients/dispatch/runners/terragrunt.js +0 -168
  390. package/node_modules/pi-lens/dist/clients/dispatch/runners/tflint.js +0 -125
  391. package/node_modules/pi-lens/dist/clients/dispatch/runners/tree-sitter.js +0 -643
  392. package/node_modules/pi-lens/dist/clients/dispatch/runners/trivy-config.js +0 -253
  393. package/node_modules/pi-lens/dist/clients/dispatch/runners/utils/availability-policy.js +0 -496
  394. package/node_modules/pi-lens/dist/clients/dispatch/runners/utils/candidate-probe.js +0 -90
  395. package/node_modules/pi-lens/dist/clients/dispatch/runners/utils/diagnostic-parsers.js +0 -95
  396. package/node_modules/pi-lens/dist/clients/dispatch/runners/utils/lazy-installer.js +0 -274
  397. package/node_modules/pi-lens/dist/clients/dispatch/runners/utils/runner-helpers.js +0 -1701
  398. package/node_modules/pi-lens/dist/clients/dispatch/runners/utils/spawn-outcome.js +0 -101
  399. package/node_modules/pi-lens/dist/clients/dispatch/runners/utils/tool-failure.js +0 -235
  400. package/node_modules/pi-lens/dist/clients/dispatch/runners/utils/toolchain-availability.js +0 -169
  401. package/node_modules/pi-lens/dist/clients/dispatch/runners/utils.js +0 -15
  402. package/node_modules/pi-lens/dist/clients/dispatch/runners/vale.js +0 -128
  403. package/node_modules/pi-lens/dist/clients/dispatch/runners/yaml-rule-parser.js +0 -242
  404. package/node_modules/pi-lens/dist/clients/dispatch/runners/yamllint.js +0 -95
  405. package/node_modules/pi-lens/dist/clients/dispatch/runners/zig-check.js +0 -79
  406. package/node_modules/pi-lens/dist/clients/dispatch/suppress-writer.js +0 -79
  407. package/node_modules/pi-lens/dist/clients/dispatch/tool-profile.js +0 -30
  408. package/node_modules/pi-lens/dist/clients/dispatch/types.js +0 -21
  409. package/node_modules/pi-lens/dist/clients/dispatch/utils/format-utils.js +0 -45
  410. package/node_modules/pi-lens/dist/clients/dispatch/utils/lsp-diagnostics.js +0 -35
  411. package/node_modules/pi-lens/dist/clients/disposition-logger.js +0 -32
  412. package/node_modules/pi-lens/dist/clients/disposition-publish.js +0 -124
  413. package/node_modules/pi-lens/dist/clients/durable-store.js +0 -97
  414. package/node_modules/pi-lens/dist/clients/effective-config.js +0 -415
  415. package/node_modules/pi-lens/dist/clients/env-utils.js +0 -83
  416. package/node_modules/pi-lens/dist/clients/error-class.js +0 -23
  417. package/node_modules/pi-lens/dist/clients/eval-timestamp.js +0 -17
  418. package/node_modules/pi-lens/dist/clients/event-loop-hold.js +0 -274
  419. package/node_modules/pi-lens/dist/clients/event-loop-monitor.js +0 -250
  420. package/node_modules/pi-lens/dist/clients/extension-log.js +0 -504
  421. package/node_modules/pi-lens/dist/clients/extension-mode.js +0 -73
  422. package/node_modules/pi-lens/dist/clients/feature-hints.js +0 -103
  423. package/node_modules/pi-lens/dist/clients/file-kinds.js +0 -412
  424. package/node_modules/pi-lens/dist/clients/file-role.js +0 -134
  425. package/node_modules/pi-lens/dist/clients/file-time.js +0 -160
  426. package/node_modules/pi-lens/dist/clients/file-utils.js +0 -1032
  427. package/node_modules/pi-lens/dist/clients/finding-delivery-gate.js +0 -494
  428. package/node_modules/pi-lens/dist/clients/finding-identity.js +0 -88
  429. package/node_modules/pi-lens/dist/clients/fix-worklog.js +0 -103
  430. package/node_modules/pi-lens/dist/clients/format-events-publish.js +0 -343
  431. package/node_modules/pi-lens/dist/clients/format-service.js +0 -222
  432. package/node_modules/pi-lens/dist/clients/formatters-lazy.js +0 -9
  433. package/node_modules/pi-lens/dist/clients/formatters.js +0 -2032
  434. package/node_modules/pi-lens/dist/clients/freshness-cadence.js +0 -17
  435. package/node_modules/pi-lens/dist/clients/freshness.js +0 -38
  436. package/node_modules/pi-lens/dist/clients/generated-artifacts.js +0 -394
  437. package/node_modules/pi-lens/dist/clients/generation-guard.js +0 -215
  438. package/node_modules/pi-lens/dist/clients/git-guard.js +0 -1136
  439. package/node_modules/pi-lens/dist/clients/git-tracked-ignore.js +0 -243
  440. package/node_modules/pi-lens/dist/clients/gitleaks-client.js +0 -509
  441. package/node_modules/pi-lens/dist/clients/go-client.js +0 -104
  442. package/node_modules/pi-lens/dist/clients/govulncheck-client.js +0 -605
  443. package/node_modules/pi-lens/dist/clients/gradle-ktfmt-style.js +0 -348
  444. package/node_modules/pi-lens/dist/clients/grammar-source.js +0 -465
  445. package/node_modules/pi-lens/dist/clients/gzip-stage-write.js +0 -135
  446. package/node_modules/pi-lens/dist/clients/hashline-anchor.js +0 -424
  447. package/node_modules/pi-lens/dist/clients/hook-budgets.js +0 -76
  448. package/node_modules/pi-lens/dist/clients/host-edit-normalize.js +0 -140
  449. package/node_modules/pi-lens/dist/clients/host-ports.js +0 -39
  450. package/node_modules/pi-lens/dist/clients/indent-retarget.js +0 -154
  451. package/node_modules/pi-lens/dist/clients/inline-blocker-dispositions.js +0 -186
  452. package/node_modules/pi-lens/dist/clients/install-diagnostics.js +0 -156
  453. package/node_modules/pi-lens/dist/clients/installer/index.js +0 -5416
  454. package/node_modules/pi-lens/dist/clients/installer/managed-tool-refresh-session.js +0 -58
  455. package/node_modules/pi-lens/dist/clients/installer/managed-tool-refresh.js +0 -783
  456. package/node_modules/pi-lens/dist/clients/instance-reaper-state.js +0 -7
  457. package/node_modules/pi-lens/dist/clients/instance-reaper.js +0 -1137
  458. package/node_modules/pi-lens/dist/clients/instance-registry-lock.js +0 -145
  459. package/node_modules/pi-lens/dist/clients/instance-registry.js +0 -773
  460. package/node_modules/pi-lens/dist/clients/jscpd-client.js +0 -378
  461. package/node_modules/pi-lens/dist/clients/json-cache-read.js +0 -65
  462. package/node_modules/pi-lens/dist/clients/knip-client.js +0 -788
  463. package/node_modules/pi-lens/dist/clients/language-policy.js +0 -252
  464. package/node_modules/pi-lens/dist/clients/language-profile.js +0 -361
  465. package/node_modules/pi-lens/dist/clients/language-registry.js +0 -603
  466. package/node_modules/pi-lens/dist/clients/latency-logger.js +0 -727
  467. package/node_modules/pi-lens/dist/clients/lazy-import.js +0 -21
  468. package/node_modules/pi-lens/dist/clients/ledger-bounds.js +0 -54
  469. package/node_modules/pi-lens/dist/clients/lens-config.js +0 -404
  470. package/node_modules/pi-lens/dist/clients/lens-engine.js +0 -358
  471. package/node_modules/pi-lens/dist/clients/lens-events.js +0 -125
  472. package/node_modules/pi-lens/dist/clients/lens-flag-registry.js +0 -467
  473. package/node_modules/pi-lens/dist/clients/lens-map.js +0 -1156
  474. package/node_modules/pi-lens/dist/clients/live-bus-emitter.js +0 -74
  475. package/node_modules/pi-lens/dist/clients/log-cleanup.js +0 -222
  476. package/node_modules/pi-lens/dist/clients/lsp/aggregation.js +0 -178
  477. package/node_modules/pi-lens/dist/clients/lsp/cascade-tier.js +0 -421
  478. package/node_modules/pi-lens/dist/clients/lsp/client.js +0 -4593
  479. package/node_modules/pi-lens/dist/clients/lsp/config.js +0 -691
  480. package/node_modules/pi-lens/dist/clients/lsp/content-limits.js +0 -41
  481. package/node_modules/pi-lens/dist/clients/lsp/diagnostic-binding.js +0 -225
  482. package/node_modules/pi-lens/dist/clients/lsp/document-drift.js +0 -467
  483. package/node_modules/pi-lens/dist/clients/lsp/edits.js +0 -1219
  484. package/node_modules/pi-lens/dist/clients/lsp/index.js +0 -8305
  485. package/node_modules/pi-lens/dist/clients/lsp/inferred-project.js +0 -216
  486. package/node_modules/pi-lens/dist/clients/lsp/jvm-runtime.js +0 -298
  487. package/node_modules/pi-lens/dist/clients/lsp/language.js +0 -63
  488. package/node_modules/pi-lens/dist/clients/lsp/launch.js +0 -746
  489. package/node_modules/pi-lens/dist/clients/lsp/lombok.js +0 -158
  490. package/node_modules/pi-lens/dist/clients/lsp/path-utils.js +0 -5
  491. package/node_modules/pi-lens/dist/clients/lsp/pending-aux-coverage.js +0 -251
  492. package/node_modules/pi-lens/dist/clients/lsp/position-encoding.js +0 -61
  493. package/node_modules/pi-lens/dist/clients/lsp/ruby-drive-dirs.js +0 -83
  494. package/node_modules/pi-lens/dist/clients/lsp/server.js +0 -3290
  495. package/node_modules/pi-lens/dist/clients/lsp/session-roots.js +0 -192
  496. package/node_modules/pi-lens/dist/clients/lsp/spawn-history.js +0 -17
  497. package/node_modules/pi-lens/dist/clients/lsp/sync-kind.js +0 -56
  498. package/node_modules/pi-lens/dist/clients/lsp/tsserver-sync.js +0 -332
  499. package/node_modules/pi-lens/dist/clients/lsp/wait-policy/capability-snapshot.js +0 -1
  500. package/node_modules/pi-lens/dist/clients/lsp/wait-policy/classification.js +0 -85
  501. package/node_modules/pi-lens/dist/clients/lsp/wait-policy/index.js +0 -10
  502. package/node_modules/pi-lens/dist/clients/lsp/wait-policy/strategies.js +0 -392
  503. package/node_modules/pi-lens/dist/clients/lsp/watch-queue.js +0 -61
  504. package/node_modules/pi-lens/dist/clients/lsp/workspace-diagnostics-cache.js +0 -639
  505. package/node_modules/pi-lens/dist/clients/lsp/workspace-diagnostics-session.js +0 -30
  506. package/node_modules/pi-lens/dist/clients/lsp/workspace-sweep-hold.js +0 -184
  507. package/node_modules/pi-lens/dist/clients/lsp-budget.js +0 -186
  508. package/node_modules/pi-lens/dist/clients/lsp-document-symbols.js +0 -110
  509. package/node_modules/pi-lens/dist/clients/lsp-lazy.js +0 -9
  510. package/node_modules/pi-lens/dist/clients/lsp-mutation.js +0 -396
  511. package/node_modules/pi-lens/dist/clients/lsp-status.js +0 -59
  512. package/node_modules/pi-lens/dist/clients/map-with-concurrency.js +0 -40
  513. package/node_modules/pi-lens/dist/clients/mcp/analyze.js +0 -420
  514. package/node_modules/pi-lens/dist/clients/mcp/host-shim.js +0 -31
  515. package/node_modules/pi-lens/dist/clients/mcp/ipc.js +0 -523
  516. package/node_modules/pi-lens/dist/clients/mcp/review.js +0 -197
  517. package/node_modules/pi-lens/dist/clients/mcp/session.js +0 -412
  518. package/node_modules/pi-lens/dist/clients/memory-sampler.js +0 -208
  519. package/node_modules/pi-lens/dist/clients/message-end-attribution.js +0 -30
  520. package/node_modules/pi-lens/dist/clients/metrics-client.js +0 -107
  521. package/node_modules/pi-lens/dist/clients/metrics-history.js +0 -327
  522. package/node_modules/pi-lens/dist/clients/middle-man-analysis.js +0 -263
  523. package/node_modules/pi-lens/dist/clients/model-provider.js +0 -53
  524. package/node_modules/pi-lens/dist/clients/module-report-lsp.js +0 -189
  525. package/node_modules/pi-lens/dist/clients/module-report.js +0 -2150
  526. package/node_modules/pi-lens/dist/clients/mutating-tool.js +0 -651
  527. package/node_modules/pi-lens/dist/clients/mutation-attribution.js +0 -368
  528. package/node_modules/pi-lens/dist/clients/mutation-bridge.js +0 -240
  529. package/node_modules/pi-lens/dist/clients/ndjson-logger.js +0 -734
  530. package/node_modules/pi-lens/dist/clients/observed-mutation-sources.js +0 -101
  531. package/node_modules/pi-lens/dist/clients/observed-mutation.js +0 -1253
  532. package/node_modules/pi-lens/dist/clients/oldtext-autopatch.js +0 -128
  533. package/node_modules/pi-lens/dist/clients/opaque-mutation-scan.js +0 -496
  534. package/node_modules/pi-lens/dist/clients/opengrep-client.js +0 -333
  535. package/node_modules/pi-lens/dist/clients/opengrep-config.js +0 -76
  536. package/node_modules/pi-lens/dist/clients/package-manager.js +0 -607
  537. package/node_modules/pi-lens/dist/clients/package-root.js +0 -38
  538. package/node_modules/pi-lens/dist/clients/partial-edit-apply.js +0 -439
  539. package/node_modules/pi-lens/dist/clients/path-attribution-telemetry.js +0 -43
  540. package/node_modules/pi-lens/dist/clients/path-keyed-map.js +0 -108
  541. package/node_modules/pi-lens/dist/clients/path-utils.js +0 -1244
  542. package/node_modules/pi-lens/dist/clients/performance-report.js +0 -331
  543. package/node_modules/pi-lens/dist/clients/persist-debounce.js +0 -70
  544. package/node_modules/pi-lens/dist/clients/persistent-reverify.js +0 -271
  545. package/node_modules/pi-lens/dist/clients/php-cs-fixer-config.js +0 -119
  546. package/node_modules/pi-lens/dist/clients/pipeline.js +0 -1480
  547. package/node_modules/pi-lens/dist/clients/post-autofix-notice.js +0 -37
  548. package/node_modules/pi-lens/dist/clients/print-mode.js +0 -21
  549. package/node_modules/pi-lens/dist/clients/probe-home-state.js +0 -228
  550. package/node_modules/pi-lens/dist/clients/process-bridge.js +0 -66
  551. package/node_modules/pi-lens/dist/clients/process-singletons.js +0 -210
  552. package/node_modules/pi-lens/dist/clients/process-snapshot.js +0 -68
  553. package/node_modules/pi-lens/dist/clients/project-changes.js +0 -181
  554. package/node_modules/pi-lens/dist/clients/project-conventions.js +0 -171
  555. package/node_modules/pi-lens/dist/clients/project-diagnostics/cache.js +0 -137
  556. package/node_modules/pi-lens/dist/clients/project-diagnostics/extractors.js +0 -109
  557. package/node_modules/pi-lens/dist/clients/project-diagnostics/fresh-fetch.js +0 -668
  558. package/node_modules/pi-lens/dist/clients/project-diagnostics/runner-adapters/call-graph-impact.js +0 -118
  559. package/node_modules/pi-lens/dist/clients/project-diagnostics/runner-adapters/dead-code.js +0 -47
  560. package/node_modules/pi-lens/dist/clients/project-diagnostics/runner-adapters/gitleaks.js +0 -40
  561. package/node_modules/pi-lens/dist/clients/project-diagnostics/runner-adapters/govulncheck.js +0 -34
  562. package/node_modules/pi-lens/dist/clients/project-diagnostics/runner-adapters/jscpd.js +0 -38
  563. package/node_modules/pi-lens/dist/clients/project-diagnostics/runner-adapters/knip.js +0 -47
  564. package/node_modules/pi-lens/dist/clients/project-diagnostics/runner-adapters/madge.js +0 -48
  565. package/node_modules/pi-lens/dist/clients/project-diagnostics/runner-adapters/opengrep.js +0 -40
  566. package/node_modules/pi-lens/dist/clients/project-diagnostics/runner-adapters/runner-findings.js +0 -127
  567. package/node_modules/pi-lens/dist/clients/project-diagnostics/runner-adapters/trivy.js +0 -81
  568. package/node_modules/pi-lens/dist/clients/project-diagnostics/scanner.js +0 -520
  569. package/node_modules/pi-lens/dist/clients/project-diagnostics/types.js +0 -1
  570. package/node_modules/pi-lens/dist/clients/project-lens-config.js +0 -807
  571. package/node_modules/pi-lens/dist/clients/project-report.js +0 -701
  572. package/node_modules/pi-lens/dist/clients/project-scale.js +0 -237
  573. package/node_modules/pi-lens/dist/clients/project-scan-policy.js +0 -11
  574. package/node_modules/pi-lens/dist/clients/project-snapshot-fingerprint.js +0 -48
  575. package/node_modules/pi-lens/dist/clients/project-snapshot-persist-worker.js +0 -20
  576. package/node_modules/pi-lens/dist/clients/project-snapshot.js +0 -1653
  577. package/node_modules/pi-lens/dist/clients/project-trust.js +0 -181
  578. package/node_modules/pi-lens/dist/clients/python-environment.js +0 -217
  579. package/node_modules/pi-lens/dist/clients/python-provenance.js +0 -639
  580. package/node_modules/pi-lens/dist/clients/quiet-window-config.js +0 -47
  581. package/node_modules/pi-lens/dist/clients/quiet-window.js +0 -236
  582. package/node_modules/pi-lens/dist/clients/read-bridge.js +0 -139
  583. package/node_modules/pi-lens/dist/clients/read-expansion.js +0 -266
  584. package/node_modules/pi-lens/dist/clients/read-guard-logger.js +0 -251
  585. package/node_modules/pi-lens/dist/clients/read-guard-tool-lines.js +0 -1306
  586. package/node_modules/pi-lens/dist/clients/read-guard.js +0 -1561
  587. package/node_modules/pi-lens/dist/clients/recent-touches.js +0 -240
  588. package/node_modules/pi-lens/dist/clients/redact/secrets.js +0 -449
  589. package/node_modules/pi-lens/dist/clients/resource-sampler.js +0 -628
  590. package/node_modules/pi-lens/dist/clients/reverse-deps.js +0 -270
  591. package/node_modules/pi-lens/dist/clients/review-graph/builder.js +0 -5126
  592. package/node_modules/pi-lens/dist/clients/review-graph/format.js +0 -33
  593. package/node_modules/pi-lens/dist/clients/review-graph/git-identity.js +0 -163
  594. package/node_modules/pi-lens/dist/clients/review-graph/import-resolvers.js +0 -481
  595. package/node_modules/pi-lens/dist/clients/review-graph/persist-worker.js +0 -10
  596. package/node_modules/pi-lens/dist/clients/review-graph/query.js +0 -244
  597. package/node_modules/pi-lens/dist/clients/review-graph/revision-drift.js +0 -21
  598. package/node_modules/pi-lens/dist/clients/review-graph/service.js +0 -96
  599. package/node_modules/pi-lens/dist/clients/review-graph/shared-extraction-ir.js +0 -82
  600. package/node_modules/pi-lens/dist/clients/review-graph/symbol-id.js +0 -103
  601. package/node_modules/pi-lens/dist/clients/review-graph/tsconfig-paths.js +0 -299
  602. package/node_modules/pi-lens/dist/clients/review-graph/types.js +0 -1
  603. package/node_modules/pi-lens/dist/clients/review-graph/workspace-modules.js +0 -449
  604. package/node_modules/pi-lens/dist/clients/review-graph-logger.js +0 -81
  605. package/node_modules/pi-lens/dist/clients/ruff-client.js +0 -179
  606. package/node_modules/pi-lens/dist/clients/rules-scanner.js +0 -131
  607. package/node_modules/pi-lens/dist/clients/run-duration.js +0 -55
  608. package/node_modules/pi-lens/dist/clients/runtime-agent-end.js +0 -746
  609. package/node_modules/pi-lens/dist/clients/runtime-config.js +0 -77
  610. package/node_modules/pi-lens/dist/clients/runtime-context.js +0 -211
  611. package/node_modules/pi-lens/dist/clients/runtime-coordinator.js +0 -1324
  612. package/node_modules/pi-lens/dist/clients/runtime-session.js +0 -2485
  613. package/node_modules/pi-lens/dist/clients/runtime-tool-call.js +0 -1295
  614. package/node_modules/pi-lens/dist/clients/runtime-tool-result.js +0 -2049
  615. package/node_modules/pi-lens/dist/clients/runtime-turn.js +0 -3767
  616. package/node_modules/pi-lens/dist/clients/rust-client.js +0 -97
  617. package/node_modules/pi-lens/dist/clients/safe-spawn.js +0 -2357
  618. package/node_modules/pi-lens/dist/clients/sanitize.js +0 -286
  619. package/node_modules/pi-lens/dist/clients/scan-utils.js +0 -25
  620. package/node_modules/pi-lens/dist/clients/scratch-tree-policy.js +0 -165
  621. package/node_modules/pi-lens/dist/clients/search-read-registration.js +0 -94
  622. package/node_modules/pi-lens/dist/clients/secret-findings.js +0 -114
  623. package/node_modules/pi-lens/dist/clients/security-scan-client.js +0 -352
  624. package/node_modules/pi-lens/dist/clients/session-event-guard.js +0 -297
  625. package/node_modules/pi-lens/dist/clients/session-lifecycle.js +0 -484
  626. package/node_modules/pi-lens/dist/clients/session-start-observability.js +0 -94
  627. package/node_modules/pi-lens/dist/clients/session-state-store.js +0 -101
  628. package/node_modules/pi-lens/dist/clients/session-summary.js +0 -15
  629. package/node_modules/pi-lens/dist/clients/sessionstart-logger.js +0 -27
  630. package/node_modules/pi-lens/dist/clients/sg-runner.js +0 -876
  631. package/node_modules/pi-lens/dist/clients/sgconfig.js +0 -347
  632. package/node_modules/pi-lens/dist/clients/shared-checkout-guard.js +0 -338
  633. package/node_modules/pi-lens/dist/clients/single-flight.js +0 -158
  634. package/node_modules/pi-lens/dist/clients/situational-tool-telemetry.js +0 -108
  635. package/node_modules/pi-lens/dist/clients/skills-resolver.js +0 -141
  636. package/node_modules/pi-lens/dist/clients/slow-fs.js +0 -138
  637. package/node_modules/pi-lens/dist/clients/smells-rollup.js +0 -231
  638. package/node_modules/pi-lens/dist/clients/source-filter.js +0 -596
  639. package/node_modules/pi-lens/dist/clients/source-walker.js +0 -211
  640. package/node_modules/pi-lens/dist/clients/spawn-output-cap.js +0 -120
  641. package/node_modules/pi-lens/dist/clients/spawn-timeout-cooldown.js +0 -110
  642. package/node_modules/pi-lens/dist/clients/stale-marker.js +0 -17
  643. package/node_modules/pi-lens/dist/clients/startup-scan.js +0 -400
  644. package/node_modules/pi-lens/dist/clients/startup-timing.js +0 -130
  645. package/node_modules/pi-lens/dist/clients/string-utils.js +0 -33
  646. package/node_modules/pi-lens/dist/clients/subagent-mode.js +0 -111
  647. package/node_modules/pi-lens/dist/clients/symbol-containment.js +0 -24
  648. package/node_modules/pi-lens/dist/clients/symbol-types.js +0 -5
  649. package/node_modules/pi-lens/dist/clients/test-runner-client.js +0 -2507
  650. package/node_modules/pi-lens/dist/clients/test-runner-delivery.js +0 -287
  651. package/node_modules/pi-lens/dist/clients/todo-scanner.js +0 -229
  652. package/node_modules/pi-lens/dist/clients/tool-agreement.js +0 -423
  653. package/node_modules/pi-lens/dist/clients/tool-config.js +0 -249
  654. package/node_modules/pi-lens/dist/clients/tool-cwd.js +0 -270
  655. package/node_modules/pi-lens/dist/clients/tool-definition.js +0 -47
  656. package/node_modules/pi-lens/dist/clients/tool-event.js +0 -105
  657. package/node_modules/pi-lens/dist/clients/tool-policy.js +0 -2755
  658. package/node_modules/pi-lens/dist/clients/tool-probe.js +0 -53
  659. package/node_modules/pi-lens/dist/clients/tool-render.js +0 -156
  660. package/node_modules/pi-lens/dist/clients/tool-set-policy.js +0 -118
  661. package/node_modules/pi-lens/dist/clients/tree-sitter-cache.js +0 -364
  662. package/node_modules/pi-lens/dist/clients/tree-sitter-client.js +0 -4195
  663. package/node_modules/pi-lens/dist/clients/tree-sitter-logger.js +0 -100
  664. package/node_modules/pi-lens/dist/clients/tree-sitter-navigator.js +0 -269
  665. package/node_modules/pi-lens/dist/clients/tree-sitter-query-loader.js +0 -569
  666. package/node_modules/pi-lens/dist/clients/tree-sitter-shared.js +0 -158
  667. package/node_modules/pi-lens/dist/clients/tree-sitter-symbol-extractor.js +0 -1265
  668. package/node_modules/pi-lens/dist/clients/trivy-client.js +0 -467
  669. package/node_modules/pi-lens/dist/clients/tui-fit.js +0 -50
  670. package/node_modules/pi-lens/dist/clients/turn-context.js +0 -52
  671. package/node_modules/pi-lens/dist/clients/turn-end/lane.js +0 -51
  672. package/node_modules/pi-lens/dist/clients/turn-end/lanes/govulncheck.js +0 -112
  673. package/node_modules/pi-lens/dist/clients/turn-end/lanes/secrets.js +0 -204
  674. package/node_modules/pi-lens/dist/clients/turn-summary-render.js +0 -72
  675. package/node_modules/pi-lens/dist/clients/turn-summary.js +0 -132
  676. package/node_modules/pi-lens/dist/clients/typos-config.js +0 -29
  677. package/node_modules/pi-lens/dist/clients/user-notify.js +0 -51
  678. package/node_modules/pi-lens/dist/clients/vanished-instance-marker.js +0 -57
  679. package/node_modules/pi-lens/dist/clients/warm-attach.js +0 -305
  680. package/node_modules/pi-lens/dist/clients/widget-state.js +0 -1808
  681. package/node_modules/pi-lens/dist/clients/word-index-logger.js +0 -49
  682. package/node_modules/pi-lens/dist/clients/word-index-store.js +0 -459
  683. package/node_modules/pi-lens/dist/clients/word-index.js +0 -2080
  684. package/node_modules/pi-lens/dist/clients/workspace-topology.js +0 -443
  685. package/node_modules/pi-lens/dist/clients/write-ordering-guard.js +0 -65
  686. package/node_modules/pi-lens/dist/clients/zizmor-config.js +0 -340
  687. package/node_modules/pi-lens/dist/i18n.js +0 -69
  688. package/node_modules/pi-lens/dist/mcp/build-staleness.js +0 -124
  689. package/node_modules/pi-lens/dist/scripts/lib/process-scan.mjs +0 -583
  690. package/node_modules/pi-lens/dist/scripts/lib/skills-predicate.mjs +0 -129
  691. package/node_modules/pi-lens/dist/scripts/lib/web-tree-sitter-dir.mjs +0 -123
  692. package/node_modules/pi-lens/dist/tools/activate-tools.js +0 -87
  693. package/node_modules/pi-lens/dist/tools/ast-grep-outline.js +0 -137
  694. package/node_modules/pi-lens/dist/tools/ast-grep-replace.js +0 -244
  695. package/node_modules/pi-lens/dist/tools/ast-grep-search.js +0 -762
  696. package/node_modules/pi-lens/dist/tools/effective-config.js +0 -90
  697. package/node_modules/pi-lens/dist/tools/lens-diagnostic-mark.js +0 -375
  698. package/node_modules/pi-lens/dist/tools/lens-diagnostics.js +0 -2571
  699. package/node_modules/pi-lens/dist/tools/lsp-diagnostics.js +0 -1745
  700. package/node_modules/pi-lens/dist/tools/lsp-navigation.js +0 -1367
  701. package/node_modules/pi-lens/dist/tools/lsp-structured-output.js +0 -294
  702. package/node_modules/pi-lens/dist/tools/module-report.js +0 -382
  703. package/node_modules/pi-lens/dist/tools/project-report.js +0 -86
  704. package/node_modules/pi-lens/dist/tools/render-compact.js +0 -374
  705. package/node_modules/pi-lens/dist/tools/scan-progress.js +0 -53
  706. package/node_modules/pi-lens/dist/tools/shared.js +0 -29
  707. package/node_modules/pi-lens/dist/tools/symbol-search.js +0 -155
@@ -1,4593 +0,0 @@
1
- /**
2
- * LSP Client for pi-lens
3
- *
4
- * Handles JSON-RPC communication with language servers:
5
- * - Initialize/shutdown lifecycle
6
- * - Document synchronization (didOpen, didChange)
7
- * - Diagnostics with debouncing
8
- * - Request/response handling
9
- */
10
- import { spawn as nodeSpawn } from "node:child_process";
11
- import { EventEmitter } from "node:events";
12
- import { access, lstat, readFile } from "node:fs/promises";
13
- import * as os from "node:os";
14
- import { pathToFileURL } from "node:url";
15
- import { emitBounded } from "../bounded-telemetry.js";
16
- import { withTimeout } from "../deadline-utils.js";
17
- import { minimatch } from "../deps/minimatch.js";
18
- import { mapWithConcurrency } from "../map-with-concurrency.js";
19
- import { incrementDegradationCount, recordDegradationOnce, } from "../degradation-ledger.js";
20
- // vscode-jsonrpc v9 ships an `exports` map exposing the Node entry as the
21
- // `./node` subpath (no `.js`); the old `/node.js` file path no longer resolves.
22
- import { CancellationTokenSource, createMessageConnection, StreamMessageReader, StreamMessageWriter, } from "../deps/vscode-jsonrpc.js";
23
- import { logExtension } from "../extension-log.js";
24
- import { recordLspChild, removeLspChild } from "../instance-registry.js";
25
- import { workspaceDiagnosticsCacheSessionStart } from "./workspace-diagnostics-session.js";
26
- import { logLatency } from "../latency-logger.js";
27
- import { newLspMutationCorrelationId, } from "../lsp-mutation.js";
28
- import { getProcessSingleton } from "../process-singletons.js";
29
- import { getAmbientAbortSignal, isOwnLiveChild, releaseOwnChildPid, } from "../safe-spawn.js";
30
- import { raceToCompletion } from "./aggregation.js";
31
- import { hashDiagnosticContent, } from "./diagnostic-binding.js";
32
- import { applyWorkspaceEdit, normalizeWorkspaceEditToUtf16 } from "./edits.js";
33
- import { normalizeMapKey, uriToPath } from "./path-utils.js";
34
- import { ADVERTISED_POSITION_ENCODINGS, convertCharacterOffset, lineTextAt, negotiatePositionEncoding, } from "./position-encoding.js";
35
- import { negotiateSaveOptions, negotiateSyncKind, TEXT_DOCUMENT_SYNC_KIND_FULL, TEXT_DOCUMENT_SYNC_KIND_INCREMENTAL, } from "./sync-kind.js";
36
- import { exceedsLspSyncLimits } from "./content-limits.js";
37
- import { probeTsserverProjectIdentity } from "./tsserver-sync.js";
38
- import { getStrategy } from "./wait-policy/index.js";
39
- import { WatchedFilesQueue } from "./watch-queue.js";
40
- import { clearAllWorkspaceDiagnosticsCaches, clearWorkspaceDiagnosticsCacheAtAndAbove, } from "./workspace-diagnostics-cache.js";
41
- // Opt-in publishDiagnostics trace (PILENS_PUB_DEBUG=1) — read once, negligible
42
- // hot-path cost. Surfaces each server's publish behavior (version + count) to
43
- // diagnose the clean-file affirmative-signal question (#240): which servers
44
- // publish an empty-with-version set on a clean scan vs go silent.
45
- const PUB_DEBUG = Boolean(process.env.PILENS_PUB_DEBUG);
46
- /**
47
- * #472/#449: extract a per-spawn-unique "marker" from an LSP server's resolved
48
- * args, for the instance registry's command-line re-identification fallback
49
- * (used when a recorded child's pid is dead/recycled but its process tree
50
- * grandchild — e.g. ast-grep's native exe behind a dead node wrapper — is
51
- * still alive under a different pid).
52
- *
53
- * Generalized, NOT ast-grep-specific (uniformity requirement — no per-server
54
- * special casing): the value immediately following a `--config`/`-c` flag, if
55
- * that value looks like a path under a temp directory (`os.tmpdir()`). This
56
- * covers ast-grep's `lsp --config <tmp sgconfig path>` (clients/sgconfig.ts)
57
- * today, and any other server later launched with a temp-file `--config`/`-c`
58
- * argument, without new server-specific code.
59
- */
60
- function extractSpawnMarker(args) {
61
- if (!args)
62
- return undefined;
63
- const tmpDir = os.tmpdir();
64
- for (let i = 0; i < args.length - 1; i++) {
65
- const flag = args[i];
66
- if (flag === "--config" || flag === "-c") {
67
- const value = args[i + 1];
68
- if (value?.startsWith(tmpDir))
69
- return value;
70
- }
71
- }
72
- return undefined;
73
- }
74
- // --- Constants ---
75
- export const INITIALIZE_TIMEOUT_MS = positiveIntFromEnv("PI_LENS_LSP_INIT_TIMEOUT_MS", 15_000); // 15s — npx downloads are handled by ensureTool, not here
76
- /**
77
- * The client capabilities advertised in every `initialize`. The textDocument set
78
- * is intentionally COMPLETE and spec-compliant: servers built on
79
- * OmniSharp.Extensions.LanguageServer (PowerShell Editor Services, #278)
80
- * dereference these sub-capabilities while handling `initialize` and throw a
81
- * NullReferenceException when an expected one is absent, hanging the handshake. A
82
- * partial textDocument object (the old `synchronization: {didOpen, didChange}` —
83
- * not even valid TextDocumentSyncClientCapabilities fields) triggered exactly
84
- * that. Declaring the full set is harmless to other servers (they act only on the
85
- * requests we actually send), so this is the single, server-agnostic shape.
86
- * Exported for the regression guard in client-internals tests.
87
- */
88
- export const CLIENT_CAPABILITIES = {
89
- general: { positionEncodings: ADVERTISED_POSITION_ENCODINGS },
90
- // #974: workDoneProgress is intentionally NOT advertised. pi-lens never
91
- // consumes `$/progress` notifications (grepped: zero listeners anywhere in
92
- // clients/), so declaring the capability only invites servers to open
93
- // progress tokens pi-lens will silently ignore — and opengrep's
94
- // `--experimental` LSP mode crash-loops when it can't parse our
95
- // spec-correct `{"result": null}` reply to its
96
- // `window/workDoneProgress/create` request. "Only advertise what you
97
- // implement" — the `window/workDoneProgress/create` handler below stays as
98
- // a defensive no-op in case a server ignores capabilities and asks anyway.
99
- window: {},
100
- workspace: {
101
- workspaceFolders: true,
102
- configuration: true,
103
- didChangeWatchedFiles: { dynamicRegistration: true },
104
- fileOperations: {
105
- dynamicRegistration: false,
106
- willRename: true,
107
- didRename: true,
108
- },
109
- },
110
- textDocument: {
111
- synchronization: {
112
- dynamicRegistration: false,
113
- willSave: false,
114
- willSaveWaitUntil: false,
115
- didSave: true,
116
- },
117
- completion: {
118
- dynamicRegistration: false,
119
- completionItem: { snippetSupport: false },
120
- },
121
- hover: { dynamicRegistration: false },
122
- signatureHelp: { dynamicRegistration: false },
123
- definition: { dynamicRegistration: false },
124
- typeDefinition: { dynamicRegistration: false },
125
- implementation: { dynamicRegistration: false },
126
- references: { dynamicRegistration: false },
127
- documentSymbol: { dynamicRegistration: false },
128
- codeAction: {
129
- dynamicRegistration: false,
130
- dataSupport: true,
131
- resolveSupport: { properties: ["edit", "command"] },
132
- // #1971 review: without literal support, LSP 3.17 constrains
133
- // textDocument/codeAction responses to Command[] — resolve of an edit
134
- // would then be protocol-invalid. We parse CodeAction objects.
135
- codeActionLiteralSupport: {
136
- codeActionKind: {
137
- valueSet: [
138
- "quickfix",
139
- "refactor",
140
- "refactor.extract",
141
- "refactor.inline",
142
- "refactor.rewrite",
143
- "source",
144
- "source.organizeImports",
145
- ],
146
- },
147
- },
148
- },
149
- rename: { dynamicRegistration: false },
150
- publishDiagnostics: {
151
- relatedInformation: true,
152
- versionSupport: true,
153
- },
154
- },
155
- };
156
- const NAV_REQUEST_TIMEOUT_MS = positiveIntFromEnv("PI_LENS_LSP_NAV_REQUEST_TIMEOUT_MS", 10_000); // 10s — per-request ceiling; prevents heavy servers (vue, svelte) from hanging
157
- const DIAGNOSTICS_WAIT_TIMEOUT_MS = positiveIntFromEnv("PI_LENS_LSP_DIAGNOSTICS_WAIT_MS", 10_000);
158
- const PULL_DIAGNOSTICS_RETRY_INTERVAL_MS = positiveIntFromEnv("PI_LENS_LSP_PULL_RETRY_INTERVAL_MS", 250);
159
- // Per-request ceiling for pull diagnostics (textDocument/diagnostic), mirroring
160
- // NAV_REQUEST_TIMEOUT_MS. safeSendRequest only settles on a reply or a *destroyed*
161
- // stream, so a pull-mode server that is alive but hung (accepts the request, never
162
- // replies) would await forever — hanging clientWaitForDiagnostics and, upstream,
163
- // the diagnostics flush. On timeout the request is treated as `unavailable`, which
164
- // (per #240) is NOT read as clean and falls through to the bounded push backstop.
165
- const PULL_REQUEST_TIMEOUT_MS = positiveIntFromEnv("PI_LENS_LSP_PULL_REQUEST_TIMEOUT_MS", 10_000);
166
- // #1773: the smallest budget this codebase treats as usable — dispatching
167
- // BELOW it is not a real attempt. The old clamp (`Math.max(1, ...)`) let an
168
- // exhausted `budgetMs` (0 or negative) through as a 1ms pull — sent, timed
169
- // out by construction, and recorded as a genuine `lsp_pull_diagnostic_timeout`
170
- // with a fabricated `effectiveBudgetMs: 1` (observed live: both pull-timeout
171
- // records in the 2026-08-20 plegma dogfood session carried exactly that).
172
- // 5ms sits below the smallest budget this codebase's own regression fixtures
173
- // already treat as a real dispatch (`tests/clients/lsp/pull-diagnostic-
174
- // timeout-telemetry.test.ts` exercises 20ms and 30ms as genuinely-attempted-
175
- // then-timed-out) and above the 1-4ms band that is indistinguishable from
176
- // "already exhausted" — a local stdio server round trip (write + compute +
177
- // parse) needs more than a handful of milliseconds even on the fast path.
178
- // `budgetMs === PULL_MIN_USABLE_BUDGET_MS` dispatches (the comparison below
179
- // is strict `<`), so the name reads literally: this is the floor value that
180
- // still gets a real attempt.
181
- const PULL_MIN_USABLE_BUDGET_MS = 5;
182
- const SHUTDOWN_REQUEST_TIMEOUT_MS = positiveIntFromEnv("PI_LENS_LSP_SHUTDOWN_TIMEOUT_MS", 1000);
183
- // #1620: the `exit` NOTIFICATION needs its own ceiling, for the same reason the
184
- // `shutdown` REQUEST has one. A notification write on a pipe that is not
185
- // draining neither resolves nor rejects, so `safeSendNotification`'s catch never
186
- // runs and the await is unbounded. The #1459 wedged-write breaker calls this
187
- // teardown precisely when that stdin has already been PROVEN wedged, so the
188
- // unbounded await was the whole mechanism of the leak. Kept equal to the request
189
- // budget: a healthy server still gets a brief graceful window before the kill.
190
- const EXIT_NOTIFY_TIMEOUT_MS = positiveIntFromEnv("PI_LENS_LSP_EXIT_NOTIFY_TIMEOUT_MS", 1000);
191
- // #1277: cheap liveness round-trip for the silent-clean gates (`index.ts`).
192
- // Those gates convert a diagnostics-wait timeout into a confirmed-clean
193
- // result from a STATIC capability classification (`silentOnClean`) alone —
194
- // but a wedged server (accepted the notify write, then hung) satisfies that
195
- // classification identically to a genuinely clean one. This is deliberately
196
- // short relative to NAV_REQUEST_TIMEOUT_MS: it only needs to prove the
197
- // connection round-trips SOMETHING before the touch reports clean, not
198
- // complete a real navigation request.
199
- const LIVENESS_PING_TIMEOUT_MS = positiveIntFromEnv("PI_LENS_LSP_LIVENESS_PING_TIMEOUT_MS", 300);
200
- // Distinctive, unlikely-to-collide query string — the response content is
201
- // never inspected, only whether one arrived before the timeout.
202
- const LIVENESS_PING_QUERY = "__pi_lens_liveness_ping__";
203
- // #1104: bound on `state.workspacePullResultCache` — one entry per distinct
204
- // file the server has ever returned a `resultId` for across this client's
205
- // lifetime. A full clear on overflow (rather than an LRU) is fine, same
206
- // reasoning as `DISK_BINDING_MEMO_MAX` in diagnostic-binding.ts: each entry is
207
- // cheaply rebuilt by the next full pull, the worst case is just one extra full
208
- // (non-`unchanged`) report per affected file.
209
- const WORKSPACE_PULL_RESULT_CACHE_MAX = 4096;
210
- // #1713: `workspace/diagnostic` has no per-file/per-identifier request to key
211
- // telemetry identity on — it is one project-wide pull — so timeout/late-answer
212
- // records use this fixed subject/scope instead of a real path.
213
- const WORKSPACE_PULL_SCOPE = "*workspace*";
214
- // #1669 review N2: cap on simultaneous re-pulls the `workspace/diagnostic/
215
- // refresh` handler fires for open documents. A workspace with hundreds of
216
- // open documents fanning out one `textDocument/diagnostic` request each,
217
- // with no cap, floods the server the refresh itself just told us is under
218
- // load. Small and fixed — this is a background improvement after the
219
- // protocol reply, never something worth tuning per project.
220
- const REFRESH_REPULL_CONCURRENCY = 4;
221
- // Anti-deadlock backstop for workspace/executeCommand. Deliberately generous
222
- // (30s): the command is mutating and legitimately long-running (a real server
223
- // refactor / organize-imports), so this must not truncate valid work — it only
224
- // stops a hung server from blocking the caller forever. On timeout the command
225
- // may still be applying server-side; we surface that rather than pretend it ran.
226
- const EXECUTE_COMMAND_TIMEOUT_MS = positiveIntFromEnv("PI_LENS_LSP_EXECUTE_COMMAND_TIMEOUT_MS", 30_000);
227
- // #1412 H1: short ceiling for the read-only tsserver project-identity probe.
228
- // This is a telemetry sample, not a mutation — it must never hold the door
229
- // open for anything close to EXECUTE_COMMAND_TIMEOUT_MS.
230
- const PROBE_COMMAND_TIMEOUT_MS = positiveIntFromEnv("PI_LENS_LSP_PROJECT_IDENTITY_PROBE_TIMEOUT_MS", 2_500);
231
- const LSP_CRASH_CODES = new Set([
232
- "ERR_STREAM_DESTROYED",
233
- "ERR_STREAM_WRITE_AFTER_END",
234
- "EPIPE",
235
- "ECONNRESET",
236
- ]);
237
- let crashGuardInstalled = false;
238
- function isIgnorableLspRuntimeCrash(err) {
239
- if (!(err instanceof Error))
240
- return false;
241
- const code = err.code;
242
- if (code && LSP_CRASH_CODES.has(code))
243
- return true;
244
- const msg = err.message.toLowerCase();
245
- const stack = (err.stack ?? "").toLowerCase();
246
- return (msg.includes("stream") ||
247
- msg.includes("write after end") ||
248
- stack.includes("vscode-jsonrpc/lib/node/ril.js"));
249
- }
250
- function installCrashGuard() {
251
- if (crashGuardInstalled)
252
- return;
253
- crashGuardInstalled = true;
254
- process.on("uncaughtException", (err) => {
255
- if (isIgnorableLspRuntimeCrash(err)) {
256
- return;
257
- }
258
- throw err;
259
- });
260
- process.on("unhandledRejection", (reason) => {
261
- if (isIgnorableLspRuntimeCrash(reason)) {
262
- return;
263
- }
264
- throw reason instanceof Error ? reason : new Error(String(reason));
265
- });
266
- }
267
- /** #2065: bound both the path count and UTF-16 heap represented by retained
268
- * Incremental-sync text. The map keeps the newest sent paths when evicting. */
269
- export const MAX_INCREMENTAL_TEXT_RETAINED_ENTRIES = 128;
270
- export const MAX_INCREMENTAL_TEXT_RETAINED_BYTES = 64 * 1024 * 1024;
271
- /**
272
- * #2065 fix round 1: a PROJECTION registry, not a second source of truth for
273
- * "which clients are live" — its only job is letting `memory-sampler.ts`
274
- * total retained-text bytes across clients without importing `LSPService`.
275
- * Considered projecting straight off `LSPService.state.clients` (the
276
- * manager's own client map, `clients/lsp/index.ts`) instead of keeping this
277
- * Set at all, and rejected it for two reasons:
278
- * 1. Import direction: `clients/lsp/index.ts` imports THIS file, not the
279
- * reverse, so reading `LSPService.state.clients` from here (or from
280
- * `memory-sampler.ts`, which every other snapshot in that module reaches
281
- * by importing the owning subsystem directly, e.g.
282
- * `getReviewGraphWorkspaceCacheSnapshot`, `getDispatchCascadeCacheStats`)
283
- * means importing the higher-level manager from the lower-level client
284
- * module — a layering inversion, not a cycle fix.
285
- * 2. `LSPService.state.clients` is ITSELF only lazily reconciled against
286
- * real client death — a crashed client's slot is noticed and evicted on
287
- * the next `getClientForFile` attach, not at crash time (see
288
- * `markExitedIfUnset`'s docstring above). Projecting off it would not
289
- * give memory-sampler any more real-time accuracy than this Set; it
290
- * would just move the same "who deregisters, and when" question onto a
291
- * larger, higher-risk surface (the manager's routing map) for no gain.
292
- *
293
- * What makes this safe to keep as a second collection: it has exactly ONE
294
- * lifecycle seam. `spawnClient` is the only writer that adds (below), and
295
- * `disposeClientConnection` is the only writer that removes — and that
296
- * function is itself the single convergence point every permanent-death path
297
- * (`connection.onError`, `connection.onClose`, the process `exit` handler,
298
- * and `clientShutdownOnce`'s `finally`) already funnels through, guarded by
299
- * the same `connectionDisposed` idempotency flag that gates the connection
300
- * teardown itself. Membership can therefore never drift from "has this
301
- * client's connection been torn down" — there is nowhere else in the file
302
- * that can flip a client from alive to permanently dead without going
303
- * through `disposeClientConnection`.
304
- *
305
- * PROCESS-SCOPED, not module-scoped (#2130 criterion 2). This Set is what
306
- * `memory_sample.subsystems.lsp.clients` counts, and a module-scope Set counted
307
- * only the clients this MODULE EVALUATION spawned. pi evaluates the pi-lens
308
- * graph up to nine times per process (#2146, measured `host_boot` = 9) and each
309
- * evaluation builds its own `LSPService` and fleet (#2157 item 1), so a
310
- * secondary root's servers — spawned by a later evaluation — were invisible to
311
- * the sampler running in the first. That is the reported symptom exactly:
312
- * `clients: 1` in `memory_sample` beside `lspChildCount: 2` in `instances.json`,
313
- * with two tsservers alive. Sharing the Set through `getProcessSingleton` makes
314
- * both the count and `roots` span every root the process serves.
315
- *
316
- * Both seams share it, which is the whole requirement: sharing only the add
317
- * site would build a Set that never shrinks, a worse leak than the undercount.
318
- */
319
- const ACTIVE_CLIENTS_FAMILY = "lsp-client.active-clients";
320
- /** Bump when the cell's shape changes. */
321
- const ACTIVE_CLIENTS_VERSION = 1;
322
- function activeLspClientSet() {
323
- return getProcessSingleton(ACTIVE_CLIENTS_FAMILY, ACTIVE_CLIENTS_VERSION, () => new Set());
324
- }
325
- export function getLspDocumentTextRetentionSnapshot() {
326
- let entries = 0;
327
- let bytes = 0;
328
- const roots = new Set();
329
- const active = activeLspClientSet();
330
- for (const state of active) {
331
- entries += state.incrementalTextRetainedEntries ?? 0;
332
- bytes += state.incrementalTextRetainedBytes ?? 0;
333
- if (typeof state.root === "string" && state.root.length > 0) {
334
- roots.add(state.root);
335
- }
336
- }
337
- return { clients: active.size, entries, bytes, roots: roots.size };
338
- }
339
- function isClientAlive(state) {
340
- return (state.isConnected && !state.isDestroyed && !state.lspProcess.process.killed);
341
- }
342
- function disposeClientConnection(state) {
343
- if (state.connectionDisposed)
344
- return;
345
- state.connectionDisposed = true;
346
- // #2065 fix round 1: this is the ONE place every permanent-death path
347
- // converges (onError, onClose, the process 'exit' handler, and
348
- // clientShutdownOnce's finally) — see setupConnectionLifecycle below and
349
- // clientShutdownOnce. Deregistering here, guarded by the idempotency flag
350
- // above, means a crash that never re-attaches still frees its retained
351
- // text instead of pinning it for the rest of the process lifetime.
352
- activeLspClientSet().delete(state);
353
- try {
354
- state.connection.dispose();
355
- }
356
- catch {
357
- // ignore
358
- }
359
- }
360
- export async function killProcessTree(proc, pid, options = {}) {
361
- // If our child has already exited, its PID is dead and the OS may have
362
- // RECYCLED it. The Windows `taskkill /F /T` below force-kills the PID's whole
363
- // tree, so on a recycled PID it would kill an unrelated process (in the test
364
- // suite this occasionally nuked a vitest worker fork → "Worker exited
365
- // unexpectedly" with no fatal dump). There is nothing left for us to kill, and
366
- // the handle-based proc.kill() below is moot, so return early.
367
- if ((proc.exitCode != null || proc.signalCode != null) &&
368
- !options.processExiting) {
369
- // #3091 F1-r2b: this pid will never be signalled again, so retire the
370
- // ownership hold taken at spawn rather than leaving it to age out.
371
- releaseOwnChildPid(pid);
372
- proc.unref?.();
373
- return;
374
- }
375
- if (process.platform === "win32" && pid > 0) {
376
- // Host process is exiting (loop already closing): never spawn a child here —
377
- // the spawn's uv_async_send on the closing loop-wakeup handle hard-aborts
378
- // (src\win\async.c). Kill the direct child via the handle we already hold
379
- // (TerminateProcess; synchronous, no async handle).
380
- //
381
- // #472 CORRECTION of a prior false claim here ("orphaned grandchildren are
382
- // reaped by the OS as the host exits"): Windows does NOT kill children when
383
- // a parent dies. For shell/.cmd-wrapped servers the direct child is
384
- // cmd.exe, so this path only ever kills the wrapper — the actual server
385
- // (its grandchild) survives by design whenever it doesn't independently
386
- // exit. It relies entirely on best-effort backstops instead: (1) the
387
- // server observing stdin EOF once the wrapper's pipes close, (2) LSP
388
- // `initialize.processId: process.pid` (some servers self-watchdog on that
389
- // pid dying — typescript-language-server does, ast-grep's native binary
390
- // does not, an upstream spec violation), and (3) the #449/#472
391
- // cross-process instance registry's orphan reaper, which is the only
392
- // mechanism that works regardless of why a pipe write-end stayed open
393
- // (e.g. Windows handle-inheritance capture by a long-lived process). This
394
- // is why registering every LSP child at spawn matters uniformly — do NOT
395
- // weaken this direct-child-only kill to try to chase grandchildren here;
396
- // spawning taskkill in this branch is exactly the libuv hazard above.
397
- if (options.processExiting) {
398
- try {
399
- proc.kill();
400
- }
401
- catch {
402
- // best-effort
403
- }
404
- proc.unref?.();
405
- return;
406
- }
407
- try {
408
- // Absolute path avoids PATH-resolution: SystemRoot is set by Windows itself.
409
- const taskkill = `${process.env.SystemRoot ?? "C:\\Windows"}\\System32\\taskkill.exe`;
410
- const killer = nodeSpawn(taskkill, ["/F", "/T", "/PID", String(pid)], {
411
- shell: false,
412
- windowsHide: true,
413
- stdio: "ignore",
414
- detached: !!options.fast,
415
- });
416
- if (options.fast) {
417
- killer.unref();
418
- proc.unref?.();
419
- return;
420
- }
421
- await new Promise((resolve) => {
422
- killer.once("close", () => resolve());
423
- killer.once("error", (err) => {
424
- logLatency({
425
- type: "phase",
426
- phase: "lsp_kill_escalation",
427
- filePath: "",
428
- durationMs: 0,
429
- metadata: { pid, platform: "win32", taskkillError: String(err) },
430
- });
431
- resolve();
432
- });
433
- });
434
- }
435
- catch (err) {
436
- logLatency({
437
- type: "phase",
438
- phase: "lsp_kill_escalation",
439
- filePath: "",
440
- durationMs: 0,
441
- metadata: { pid, platform: "win32", taskkillSpawnError: String(err) },
442
- });
443
- }
444
- return;
445
- }
446
- const killPosixProcessGroup = (signal) => {
447
- // #2042: one ownership predicate for every kill-by-raw-pid, replacing
448
- // the `pid <= 0` sign check that let a test double's invented pid
449
- // through. A pid we do not own falls back to `killDirectChild` below,
450
- // which signals through the retained handle and can only ever reach
451
- // our own child.
452
- //
453
- // #3091 F1: `proc` is deliberately NOT passed. The handle arm means
454
- // "already exited ⇒ refuse", and this group kill must still fire when
455
- // the direct child is dead — the early return at :1269 is skipped under
456
- // `options.processExiting`, and a POSIX group outlives its leader, so
457
- // the group signal is the only thing that reaps surviving grandchildren
458
- // at host exit (#2026). Ownership here comes from the kernel and, once
459
- // the leader is gone, from the verdict recorded while it was alive.
460
- if (!isOwnLiveChild(pid, "lsp-stop-posix-group"))
461
- return false;
462
- try {
463
- process.kill(-pid, signal);
464
- return true;
465
- }
466
- catch {
467
- return false;
468
- }
469
- };
470
- const killDirectChild = (signal) => {
471
- try {
472
- proc.kill(signal);
473
- }
474
- catch {
475
- // best-effort
476
- }
477
- };
478
- try {
479
- // #1114: gate the escalation on OBSERVED exit, not `proc.killed`. Node
480
- // only sets `proc.killed = true` when `proc.kill()` (the ChildProcess
481
- // method) successfully SENDS a signal — never when the process actually
482
- // dies, and the primary SIGTERM path above goes through the raw
483
- // `process.kill(-pid, …)` process-group call, which never touches
484
- // `proc.killed` at all. Checking `!proc.killed` here was therefore
485
- // either always-true (unconditional SIGKILL after the window,
486
- // regardless of whether the group already died — group-kill path) or
487
- // always-false/dead (direct-child fallback path, same shape as the
488
- // safe-spawn escalation bug). An `exit` listener set once, up front,
489
- // gives a real observed-death signal for both. Seeded from the same
490
- // `exitCode`/`signalCode` pre-check the top-of-function early return
491
- // uses (:689) — that early return is skipped when
492
- // `options.processExiting` is set, so a process that was ALREADY dead
493
- // on entry can still reach here; without seeding, `exited` would stay
494
- // false (the "exit" event already fired before this listener was
495
- // attached) and the fast/non-fast branches below would still fire a
496
- // redundant group SIGKILL at the escalation window.
497
- let exited = proc.exitCode != null || proc.signalCode != null;
498
- proc.once?.("exit", () => {
499
- exited = true;
500
- });
501
- if (!killPosixProcessGroup("SIGTERM")) {
502
- killDirectChild("SIGTERM");
503
- }
504
- if (options.fast) {
505
- const timer = setTimeout(() => {
506
- if (!exited) {
507
- logLatency({
508
- type: "phase",
509
- phase: "lsp_kill_escalation",
510
- filePath: "",
511
- durationMs: 1500,
512
- metadata: { pid, platform: "posix", method: "SIGKILL", fast: true },
513
- });
514
- if (!killPosixProcessGroup("SIGKILL")) {
515
- killDirectChild("SIGKILL");
516
- }
517
- }
518
- // AFTER the escalation, never before it: this tick issues the
519
- // last signal this pid can receive, and retiring the ownership
520
- // hold first would make that very SIGKILL the thing that gets
521
- // refused (#3091 F1-r2b).
522
- releaseOwnChildPid(pid);
523
- }, 1500);
524
- timer.unref?.();
525
- proc.unref?.();
526
- return;
527
- }
528
- // SIGTERM → exit-or-1.5s → SIGKILL escalation. SIGTERM alone can leave
529
- // zombie processes if the server hangs — but a server that dies promptly
530
- // must resolve on its exit event, not sleep the full escalation window
531
- // (that unconditional 1500ms was the whole cost of every graceful LSP
532
- // teardown, ×N clients per session and per test).
533
- const exitedInTime = await new Promise((resolve) => {
534
- if (proc.exitCode != null || proc.signalCode != null) {
535
- resolve(true);
536
- return;
537
- }
538
- const onExit = () => {
539
- clearTimeout(timer);
540
- resolve(true);
541
- };
542
- const timer = setTimeout(() => {
543
- proc.off?.("exit", onExit);
544
- resolve(false);
545
- }, 1500);
546
- proc.once?.("exit", onExit);
547
- });
548
- if (!exitedInTime && !exited) {
549
- logLatency({
550
- type: "phase",
551
- phase: "lsp_kill_escalation",
552
- filePath: "",
553
- durationMs: 1500,
554
- metadata: { pid, platform: "posix", method: "SIGKILL", fast: false },
555
- });
556
- if (!killPosixProcessGroup("SIGKILL")) {
557
- killDirectChild("SIGKILL");
558
- }
559
- }
560
- releaseOwnChildPid(pid);
561
- }
562
- catch {
563
- // ignore
564
- }
565
- }
566
- export function stripDiagnosticNoiseLines(message) {
567
- const cleaned = message
568
- .split(/\r?\n/)
569
- .filter((line) => {
570
- const trimmed = line.trim();
571
- if (/^for further information visit\b/i.test(trimmed))
572
- return false;
573
- if (/^https?:\/\/\S+$/i.test(trimmed))
574
- return false;
575
- return true;
576
- })
577
- .join("\n")
578
- .trim();
579
- return cleaned || message.trim() || message;
580
- }
581
- function normalizeLspDiagnostic(diagnostic) {
582
- const message = stripDiagnosticNoiseLines(diagnostic.message);
583
- return message === diagnostic.message
584
- ? diagnostic
585
- : { ...diagnostic, message };
586
- }
587
- function normalizeLspDiagnostics(diagnostics) {
588
- return diagnostics.map(normalizeLspDiagnostic);
589
- }
590
- /**
591
- * Union of diagnostic lists, first occurrence wins, exact duplicates dropped.
592
- * Variadic since #1667 so the per-identifier pull sources merge through the
593
- * SAME key as the push/pull merge rather than growing a second dedupe rule.
594
- */
595
- function mergeDiagnosticLists(...lists) {
596
- const merged = [];
597
- const seen = new Set();
598
- for (const diagnostic of lists.flatMap((list) => list ?? [])) {
599
- const key = [
600
- diagnostic.range.start.line,
601
- diagnostic.range.start.character,
602
- diagnostic.range.end.line,
603
- diagnostic.range.end.character,
604
- diagnostic.code ?? "",
605
- diagnostic.source ?? "",
606
- diagnostic.message,
607
- ].join(":");
608
- if (seen.has(key))
609
- continue;
610
- seen.add(key);
611
- merged.push(diagnostic);
612
- }
613
- return merged;
614
- }
615
- function getMergedDiagnosticsForPath(state, normalizedPath) {
616
- // SAFETY: `diagnostics` is the pre-split field name that older states (and
617
- // older embedders holding a state built before `pushDiagnostics` existed)
618
- // still carry. It is absent from `LSPClientState` on purpose — new code
619
- // must not write it. The cast declares it OPTIONAL, so the read below is
620
- // still `undefined`-guarded and a state without it falls through to the
621
- // current field.
622
- const legacy = state;
623
- return mergeDiagnosticLists(state.pushDiagnostics?.get(normalizedPath) ??
624
- legacy.diagnostics?.get(normalizedPath), state.documentPullDiagnostics?.get(normalizedPath));
625
- }
626
- /**
627
- * #1667: per-source pull-diagnostic bookkeeping.
628
- *
629
- * A pull-mode server can expose several diagnostic SOURCES, each named by its
630
- * registration's `registerOptions.identifier` (Roslyn: syntax, semantic,
631
- * analyzers). Each source answers independently, with its own `resultId` and
632
- * its own findings, so per-path state that used to be a single slot has to
633
- * become one slot per (path, source):
634
- *
635
- * - `pullResultIds` keys on `pullSourceKey(path, identifier)`, so an
636
- * `unchanged` report is only ever answered against the id THAT source
637
- * issued (#240/#1104 machinery, now per source).
638
- * - `documentPullDiagnosticsBySource` holds each source's findings, and
639
- * `documentPullDiagnostics` is their deduped union - recomputed whenever a
640
- * source reports, so a fresh answer from one source never wipes another's.
641
- *
642
- * The bare (identifier-less) source keys on the plain path, so a single-source
643
- * server stores exactly what it stored before this change.
644
- */
645
- // NUL is the one byte a filesystem path can never contain, so
646
- // `path + SEP + identifier` is unambiguous: no path/identifier pair can
647
- // produce the same key as a different pair, and the bare source's key (the
648
- // plain path) can never collide with a named source's.
649
- const PULL_SOURCE_KEY_SEPARATOR = "\u0000";
650
- const BARE_PULL_SOURCE = "";
651
- function pullSourceKey(normalizedPath, identifier) {
652
- return identifier === undefined
653
- ? normalizedPath
654
- : `${normalizedPath}${PULL_SOURCE_KEY_SEPARATOR}${identifier}`;
655
- }
656
- /** Every `pullResultIds` key belonging to a path - the bare key plus one per
657
- * identifier. Used by the clear path so a resync drops EVERY source's basis. */
658
- function pullSourceKeysForPath(state, normalizedPath) {
659
- const prefix = `${normalizedPath}${PULL_SOURCE_KEY_SEPARATOR}`;
660
- return [
661
- normalizedPath,
662
- ...[...(state.pullResultIds?.keys() ?? [])].filter((key) => key.startsWith(prefix)),
663
- ];
664
- }
665
- function pullSourcesFor(state, normalizedPath) {
666
- if (!state.documentPullDiagnosticsBySource) {
667
- state.documentPullDiagnosticsBySource = new Map();
668
- }
669
- let sources = state.documentPullDiagnosticsBySource.get(normalizedPath);
670
- if (!sources) {
671
- sources = new Map();
672
- state.documentPullDiagnosticsBySource.set(normalizedPath, sources);
673
- }
674
- return sources;
675
- }
676
- /** This source's currently stored findings for a path - what an `unchanged`
677
- * report inherits. Falls back to the whole per-path list when the source map
678
- * has no entry yet, so a state that pulled before this change (or a caller
679
- * that seeded `documentPullDiagnostics` directly) still inherits correctly. */
680
- function pullSourceDiagnostics(state, normalizedPath, identifier) {
681
- const sources = state.documentPullDiagnosticsBySource?.get(normalizedPath);
682
- const stored = sources?.get(identifier ?? BARE_PULL_SOURCE);
683
- if (stored)
684
- return stored;
685
- return sources && sources.size > 0
686
- ? []
687
- : (state.documentPullDiagnostics.get(normalizedPath) ?? []);
688
- }
689
- /** Store one source's findings and republish the union to
690
- * `documentPullDiagnostics`, which every reader outside this file uses. */
691
- function storePullSourceDiagnostics(state, normalizedPath, identifier, diagnostics) {
692
- const sources = pullSourcesFor(state, normalizedPath);
693
- sources.set(identifier ?? BARE_PULL_SOURCE, diagnostics);
694
- state.documentPullDiagnostics.set(normalizedPath, mergeDiagnosticLists(...sources.values()));
695
- }
696
- /** #1667: the pull generation for a path. The fan-out deliberately lets losing
697
- * pulls run to completion so their findings still reach the cache; a resync
698
- * that lands first must win, so every late write re-checks this. */
699
- function pullGenerationFor(state, normalizedPath) {
700
- return state.pullGenerations?.get(normalizedPath) ?? 0;
701
- }
702
- function bumpPullGeneration(state, normalizedPath) {
703
- if (!state.pullGenerations)
704
- state.pullGenerations = new Map();
705
- state.pullGenerations.set(normalizedPath, (state.pullGenerations.get(normalizedPath) ?? 0) + 1);
706
- }
707
- /** #1667: claim the newest-request slot for a source and return the claim.
708
- * Called at REQUEST time; `isNewestPullRequest` re-checks it at WRITE time so
709
- * a slow answer from an earlier fan-out cannot overwrite a newer one's. */
710
- function claimPullRequestSequence(state, sourceKey) {
711
- if (!state.pullRequestSequences)
712
- state.pullRequestSequences = new Map();
713
- const next = (state.pullRequestSequences.get(sourceKey) ?? 0) + 1;
714
- state.pullRequestSequences.set(sourceKey, next);
715
- return next;
716
- }
717
- function isNewestPullRequest(state, sourceKey, sequence) {
718
- return (state.pullRequestSequences?.get(sourceKey) ?? 0) === sequence;
719
- }
720
- /**
721
- * #1667: drop every trace of a diagnostic source the server just unregistered.
722
- *
723
- * `client/unregisterCapability` retires a source, but its findings sit in
724
- * `documentPullDiagnosticsBySource` and its resultId in `pullResultIds`, so
725
- * without this the union keeps serving a retired source's diagnostics until
726
- * something resyncs the file. Roslyn and vtsls re-register their sources on
727
- * solution reload, which is exactly when the old source's findings are stale.
728
- *
729
- * Purges the source's slice from every path and republishes each affected
730
- * path's union, so the removal is visible on the next read rather than only
731
- * after the next pull.
732
- */
733
- function retirePullSource(state, identifier) {
734
- const suffix = `${PULL_SOURCE_KEY_SEPARATOR}${identifier}`;
735
- for (const source of [state.pullResultIds, state.pullRequestSequences]) {
736
- if (!source)
737
- continue;
738
- for (const key of source.keys()) {
739
- if (key.endsWith(suffix))
740
- source.delete(key);
741
- }
742
- }
743
- for (const [normalizedPath, sources,] of state.documentPullDiagnosticsBySource ?? []) {
744
- if (!sources.delete(identifier))
745
- continue;
746
- state.documentPullDiagnostics.set(normalizedPath, mergeDiagnosticLists(...sources.values()));
747
- bumpDiagnosticsVersion(state, normalizedPath);
748
- state.diagnosticEmitter.emit("diagnostics", normalizedPath);
749
- }
750
- }
751
- /**
752
- * #1667: the diagnostic sources to pull for a document - always the bare
753
- * request (a server may answer it even while registering named sources, and it
754
- * is the ONLY request a single-source server understands), plus one per
755
- * distinct `identifier` on a live `textDocument/diagnostic` registration.
756
- *
757
- * Derived from `dynamicRegistrations` on every call rather than cached in a
758
- * parallel list: the registration map is the single source of truth, and
759
- * `client/unregisterCapability` must be able to retire a source immediately.
760
- */
761
- export function pullDiagnosticSources(state) {
762
- const identifiers = new Set();
763
- for (const registration of state.dynamicRegistrations?.values() ?? []) {
764
- if (registration.method !== "textDocument/diagnostic")
765
- continue;
766
- if (registration.identifier)
767
- identifiers.add(registration.identifier);
768
- }
769
- // Sorted for a stable, reproducible request order in logs and tests; the
770
- // fan-out is parallel, so order carries no priority.
771
- return [undefined, ...[...identifiers].sort((a, b) => a.localeCompare(b))];
772
- }
773
- /** #1531: bump the client-global diagnostics counter and stamp the path it was
774
- * bumped FOR. The single seam every "fresh diagnostics stored" site goes
775
- * through, so the per-path stamp can never drift from the global counter. */
776
- export function bumpDiagnosticsVersion(state, normalizedPath) {
777
- state.diagnosticsVersion += 1;
778
- state.diagnosticsVersionsByPath?.set(normalizedPath, state.diagnosticsVersion);
779
- }
780
- /** #1531: the global counter's value when diagnostics were last stored for
781
- * `normalizedPath`, or 0 when nothing is stored. 0 is a safe floor because the
782
- * counter starts at 0 and only ever increases, so any later publication for the
783
- * path compares greater than a baseline read while it was absent. */
784
- export function diagnosticsVersionForPath(state, normalizedPath) {
785
- return state.diagnosticsVersionsByPath?.get(normalizedPath) ?? 0;
786
- }
787
- /** Exported for tests: the quiet-window timer cancel on clear/resync is the
788
- * headline #1412 safety property (a stale versionless publication must never
789
- * land after the document content changed). */
790
- export function clearDiagnosticsForPath(state, normalizedPath) {
791
- // SAFETY: same pre-split field names as `getMergedDiagnosticsForPath`.
792
- // Clearing must cover the legacy maps too, or a state that still carries
793
- // them replays a stale diagnostic after the clear. Both are declared
794
- // OPTIONAL, so every use below is `?.`-guarded on a state that lacks them.
795
- const legacy = state;
796
- state.pushDiagnostics?.delete(normalizedPath);
797
- const pending = state.pendingDiagnostics?.get(normalizedPath);
798
- if (pending)
799
- clearTimeout(pending);
800
- state.pendingDiagnostics?.delete(normalizedPath);
801
- state.pushDiagnosticTimestamps?.delete(normalizedPath);
802
- state.documentPullDiagnostics?.delete(normalizedPath);
803
- state.documentPullDiagnosticTimestamps?.delete(normalizedPath);
804
- state.diagnosticDocVersions?.delete(normalizedPath);
805
- // #1095: a cleared path must never serve a stale content binding alongside a
806
- // later publish — drop it with the diagnostics it described. (The last-sent
807
- // `documentContentHashes` record is intentionally retained: it describes what
808
- // we sent, which the NEXT publish for that version still needs to bind to.)
809
- state.diagnosticBindings?.delete(normalizedPath);
810
- // #1531: the per-path publication stamp describes diagnostics that no longer
811
- // exist — drop it with them. Safe because the stamps hold the GLOBAL counter's
812
- // value: a baseline captured before this clear still compares less than any
813
- // later publication for the path, so dropping the entry cannot manufacture a
814
- // missed answer.
815
- state.diagnosticsVersionsByPath?.delete(normalizedPath);
816
- // #1104: a resync invalidates any `unchanged`-report basis too — the next
817
- // pull must not inherit a resultId/contentHash computed against the
818
- // content this resync just replaced.
819
- // #1667: every SOURCE's basis, not just the bare one - a multi-source server
820
- // holds one resultId per identifier, and leaving even one behind lets the
821
- // next pull inherit against content this resync replaced.
822
- for (const key of pullSourceKeysForPath(state, normalizedPath)) {
823
- state.pullResultIds?.delete(key);
824
- }
825
- // `pullRequestSequences` is deliberately NOT cleared here. It is a
826
- // monotonic per-source counter, and resetting it would let a request issued
827
- // BEFORE this resync claim the same number as one issued after, so the stale
828
- // answer would pass the newest-request check. The two guards cover different
829
- // cases and neither subsumes the other: the generation bump below invalidates
830
- // in-flight work when nothing newer was requested, and the sequence stamp
831
- // invalidates it when something newer was.
832
- state.documentPullDiagnosticsBySource?.delete(normalizedPath);
833
- // #1667: invalidate any pull still in flight for this path. The fan-out lets
834
- // losing pulls run to completion so their findings still reach the cache;
835
- // without this bump one of them could resurrect what this resync cleared.
836
- bumpPullGeneration(state, normalizedPath);
837
- state.workspacePullResultCache?.delete(normalizedPath);
838
- legacy.diagnostics?.delete(normalizedPath);
839
- legacy.diagnosticTimestamps?.delete(normalizedPath);
840
- }
841
- /**
842
- * #1639: `durationMs` measures the PULL SETTLE ITSELF — the caller passes how
843
- * long `clientRequestPullDiagnostics` (plus any retries) took to resolve
844
- * `found`/`clean` — never time-since-didOpen. Document age is a genuinely
845
- * useful signal but a DIFFERENT one; it stays under its own honest name in
846
- * `metadata.elapsedSinceDidOpenMs`, exactly like the push-path `logSequence`
847
- * above already does. Before this fix the top-level field carried the same
848
- * age value pull requests use for retry-budget bookkeeping, so a session's
849
- * latency percentiles for this phase read multi-second document lifetimes as
850
- * millisecond operation costs (147/239 records read >60s "durations" for
851
- * settles that took milliseconds).
852
- *
853
- * `version` reads the real tracked version from `diagnosticsVersionsByPath`
854
- * (bumped by `bumpDiagnosticsVersion` right after this pull's diagnostics
855
- * were stored — see the `documentPullDiagnostics.set` call above it) instead
856
- * of a hardcoded `null`, which made every pull settle look "unbound" to
857
- * staleness forensics even though this codebase already tracks a real
858
- * version for the path. Falls back to the explicit `"pull-unversioned"`
859
- * marker only when nothing has been stored yet, so "no data" is never
860
- * confused with "confirmed no version" (`null`).
861
- *
862
- * `pullSettleSource` distinguishes the two legitimate call paths that can
863
- * both settle a pull for the same file close together: a real
864
- * content-collecting touch ("pull") and `ensureWarmForSweep`'s warm-up-only
865
- * touch ("pull-warmup"), which runs a real round trip purely to prove the
866
- * server answers before the sweep's real touch immediately follows it.
867
- * Before this fix both logged identically as settleSource "pull", so a
868
- * session with warm-up sweeps double-counted: 239 records against 145
869
- * touches, with same-file pairs ~60ms apart and near-identical document
870
- * ages — the warm-up settle and the real touch's settle for the same file.
871
- */
872
- function logTypeScriptPullSettle(state, normalizedPath, durationMs, pullSettleSource) {
873
- if (state.serverId !== "typescript")
874
- return;
875
- const diagnostics = state.documentPullDiagnostics.get(normalizedPath) ?? [];
876
- const elapsedSinceDidOpenMs = Math.max(0, Date.now() - (state.documentOpenedAt.get(normalizedPath) ?? Date.now()));
877
- const diagnosticCodes = [
878
- ...new Set(diagnostics
879
- .map((diagnostic) => diagnostic.code)
880
- .filter((code) => code !== undefined)
881
- .map(String)),
882
- ].slice(0, 8);
883
- const trackedVersion = state.diagnosticsVersionsByPath?.get(normalizedPath);
884
- logLatency({
885
- type: "phase",
886
- phase: "lsp_typescript_diagnostic_sequence",
887
- filePath: normalizedPath,
888
- durationMs,
889
- metadata: {
890
- serverId: state.serverId,
891
- outcome: "settled",
892
- launchVariant: state.launchVariant ?? "unknown",
893
- publicationIndex: state.diagnosticPublicationCounts.get(normalizedPath) ?? 0,
894
- version: trackedVersion ?? "pull-unversioned",
895
- diagnosticCount: diagnostics.length,
896
- diagnosticCodes,
897
- elapsedSinceDidOpenMs,
898
- settledReturn: true,
899
- settleSource: pullSettleSource,
900
- },
901
- });
902
- }
903
- /**
904
- * #2066: the ONE full-document walk a send needs, replacing the two that used
905
- * to run independently — a `charCodeAt` loop counting `\n` for the
906
- * `lsp_document_send` line count, and a `.split(/\r\n|\r|\n/)` in
907
- * `buildContentChanges` that materialized one substring per line to read the
908
- * last one. The #1095 sha256 is a third walk and stays; it is native, and it
909
- * is load-bearing.
910
- *
911
- * The two counts here are deliberately NOT the same number and must not be
912
- * collapsed into one. `lfNewlineCount` counts `\n` only, because its consumer
913
- * pairs against `diagnostic_past_eof`, whose gate counts LF BYTES
914
- * (`clients/diagnostic-line-freshness.ts`); merging the conventions would put
915
- * the two records one apart on exactly the mixed-ending documents the pairing
916
- * exists to debug. `lastLine` uses the full LSP terminator set (`\r\n`, lone
917
- * `\r`, `\n` — #1669 review F6), because a CRLF or classic-Mac document whose
918
- * terminators went uncounted computes a range ending mid-document.
919
- */
920
- function scanSentContent(content) {
921
- let lfNewlineCount = 0;
922
- let index = 0;
923
- let start = 0;
924
- // `charCodeAt`, not `codePointAt` (typescript:S7758, accepted): both are
925
- // compared against 10/13, which no surrogate unit or astral code point can
926
- // equal, so they are interchangeable here — but `codePointAt` measured 8%
927
- // slower on a 400 KiB LF document (0.716 vs 0.663 ms/scan), because every
928
- // ordinary character pays a surrogate-pair check this loop never uses.
929
- for (let i = 0; i < content.length; i++) {
930
- const code = content.charCodeAt(i);
931
- if (code !== 10 && code !== 13)
932
- continue;
933
- if (code === 10) {
934
- lfNewlineCount++;
935
- }
936
- else if (content.charCodeAt(i + 1) === 10) {
937
- // `\r\n` is ONE line terminator, and still carries the `\n` the
938
- // LF-only count wants.
939
- lfNewlineCount++;
940
- i++;
941
- }
942
- index++;
943
- start = i + 1;
944
- }
945
- return { lfNewlineCount, lastLine: { index, start } };
946
- }
947
- /**
948
- * #1095: fingerprint the EXACT didOpen/didChange payload text at SEND time and
949
- * tag it with the document version it was sent as, so a later
950
- * `publishDiagnostics` echoing that version can bind its diagnostics to the
951
- * content they were computed against. Runs on in-memory content — never a disk
952
- * read on the notification path (I1). Bounded by the same file-size gates the
953
- * caller already applies to the content it hands us.
954
- */
955
- function recordSentContent(state, normalizedPath, version, content, coalescedCount = 0) {
956
- const scan = scanSentContent(content);
957
- const previous = state.documentContentHashes.get(normalizedPath);
958
- if (previous && previous.version > version) {
959
- incrementDegradationCount({
960
- kind: "lsp-document-send-order",
961
- subject: `${state.serverId}:${normalizedPath}`,
962
- reason: `recorded version ${version} after newer version ${previous.version}`,
963
- });
964
- return;
965
- }
966
- if (previous?.text !== undefined) {
967
- state.incrementalTextRetainedEntries = Math.max(0, (state.incrementalTextRetainedEntries ?? 1) - 1);
968
- state.incrementalTextRetainedBytes = Math.max(0, (state.incrementalTextRetainedBytes ?? previous.text.length * 2) -
969
- previous.text.length * 2);
970
- // #2065 fix round 1 F5: the path is about to be rewritten below (and
971
- // possibly re-marked text-bearing) — drop the stale membership first so
972
- // the reinsert further down lands at the END of the recency order
973
- // instead of leaving a duplicate-free but stale position.
974
- state.incrementalTextBearingPaths?.delete(normalizedPath);
975
- }
976
- // #2065 fix round 1 F5: eviction recency now lives in
977
- // `incrementalTextBearingPaths` (above/below), not this map's own
978
- // insertion order — no remaining reader iterates `documentContentHashes`
979
- // for order (every other site below is a keyed `.get`/`.delete`). The
980
- // reinsert is kept anyway so a log or future debug dump that DOES walk
981
- // this map in insertion order still reads "most recently sent last",
982
- // matching the aux set it mirrors, rather than a stale mixed order.
983
- state.documentContentHashes.delete(normalizedPath);
984
- state.documentContentHashes.set(normalizedPath, {
985
- version,
986
- hash: hashDiagnosticContent(content),
987
- // #1669: retain the full text only for Incremental — the sole reader
988
- // (`buildContentChanges`) needs it to compute the NEXT change against
989
- // what the server last saw; Full/None never read this field.
990
- // #2066: `lastLine` is that reader's answer, already computed by the
991
- // scan above — it rides along with the text it describes.
992
- ...(state.syncKind === TEXT_DOCUMENT_SYNC_KIND_INCREMENTAL && {
993
- text: content,
994
- lastLine: scan.lastLine,
995
- }),
996
- });
997
- if (state.syncKind === TEXT_DOCUMENT_SYNC_KIND_INCREMENTAL) {
998
- state.incrementalTextRetainedEntries =
999
- (state.incrementalTextRetainedEntries ?? 0) + 1;
1000
- state.incrementalTextRetainedBytes =
1001
- (state.incrementalTextRetainedBytes ?? 0) + content.length * 2;
1002
- // #2065 fix round 1 F5: track membership in the same insertion (=
1003
- // recency) order as `documentContentHashes` itself, so eviction below
1004
- // never needs to spread and scan the full content-binding map —
1005
- // including paths whose text was already stripped — just to find the
1006
- // oldest text-bearing one.
1007
- if (!state.incrementalTextBearingPaths) {
1008
- state.incrementalTextBearingPaths = new Set();
1009
- }
1010
- state.incrementalTextBearingPaths.add(normalizedPath);
1011
- while ((state.incrementalTextRetainedEntries ?? 0) >
1012
- MAX_INCREMENTAL_TEXT_RETAINED_ENTRIES ||
1013
- (state.incrementalTextRetainedBytes ?? 0) >
1014
- MAX_INCREMENTAL_TEXT_RETAINED_BYTES) {
1015
- const oldestPath = state.incrementalTextBearingPaths
1016
- ?.values()
1017
- .next().value;
1018
- if (oldestPath === undefined)
1019
- break;
1020
- const binding = state.documentContentHashes.get(oldestPath);
1021
- state.incrementalTextBearingPaths.delete(oldestPath);
1022
- if (!binding || binding.text === undefined)
1023
- continue;
1024
- // #2066: `lastLine` is dropped here with the text it describes. The
1025
- // #1095 version/hash binding is what must survive the strip.
1026
- state.documentContentHashes.set(oldestPath, {
1027
- version: binding.version,
1028
- hash: binding.hash,
1029
- });
1030
- state.incrementalTextRetainedEntries = Math.max(0, (state.incrementalTextRetainedEntries ?? 1) - 1);
1031
- state.incrementalTextRetainedBytes = Math.max(0, (state.incrementalTextRetainedBytes ?? 0) - binding.text.length * 2);
1032
- }
1033
- }
1034
- // #1641 criterion 3: the in-memory document's version + content length AT
1035
- // SEND TIME, so a later "diagnostic cited a line past current disk EOF"
1036
- // record (`diagnostic_past_eof`, clients/diagnostic-line-freshness.ts) can be
1037
- // paired with the send that produced the divergent in-memory document —
1038
- // today only the symptom (the stale citation) is observable; this is the
1039
- // cause side of the same timeline.
1040
- //
1041
- // Review round F4/F1: `contentLineCount` MUST use the same LSP-addressable
1042
- // convention as the gate itself (newline count + 1 — a trailing `\n` adds
1043
- // one more, empty, addressable line; an empty document is still 1 line),
1044
- // or the two records disagree by one at exactly the boundary this counter
1045
- // exists to help debug. #2066: the count comes from `scanSentContent`
1046
- // above, which folded this loop into the walk `buildContentChanges` was
1047
- // already paying for; the LF-only convention is unchanged.
1048
- logLatency({
1049
- type: "phase",
1050
- phase: "lsp_document_send",
1051
- filePath: normalizedPath,
1052
- durationMs: 0,
1053
- metadata: {
1054
- version,
1055
- contentLength: content.length,
1056
- contentLineCount: scan.lfNewlineCount + 1,
1057
- ...(coalescedCount > 0 && { coalescedCount }),
1058
- },
1059
- });
1060
- }
1061
- /**
1062
- * #1669: the `contentChanges` array for a `textDocument/didChange` notification.
1063
- *
1064
- * Full/None (or unrecognized/absent) sync kind: unchanged — a single
1065
- * whole-document `{ text }` event, the shape pi-lens has always sent.
1066
- *
1067
- * Incremental: a server registering Incremental-only expects every change
1068
- * event to carry a `range`; a shapeless whole-document event is out of spec
1069
- * for it. Rather than hand-roll a real diff, send the SAFEST incremental
1070
- * representation of a full update — one ranged edit spanning the entire
1071
- * PREVIOUS document (start of file to its last character), replacing it with
1072
- * the entire new content. That is spec-valid for any Incremental server and
1073
- * semantically identical to the Full-sync event it replaces.
1074
- *
1075
- * The previous document's end position is computed from the text
1076
- * `recordSentContent` retained for THIS path the last time content was sent
1077
- * (reusing that seam rather than a second parallel content store — see its
1078
- * doc comment). No prior text on record (first change since Incremental was
1079
- * negotiated, or the path was never sent before) falls back to the
1080
- * whole-document shape — still spec-valid, and self-heals once the next
1081
- * change has a retained previous text to diff against.
1082
- */
1083
- function buildContentChanges(state, normalizedPath, content) {
1084
- if (state.syncKind !== TEXT_DOCUMENT_SYNC_KIND_INCREMENTAL) {
1085
- return [{ text: content }];
1086
- }
1087
- const previous = state.documentContentHashes.get(normalizedPath);
1088
- if (previous?.text === undefined) {
1089
- return [{ text: content }];
1090
- }
1091
- const previousText = previous.text;
1092
- // #2066: `recordSentContent` scanned this exact text when it sent it, so
1093
- // read where it left the last line instead of walking (and splitting) the
1094
- // document a second time. An entry carrying text without that position was
1095
- // written by something else — resolve it through the SAME scanner, never a
1096
- // second convention that can drift from it.
1097
- const lastLine = previous.lastLine ?? scanSentContent(previousText).lastLine;
1098
- const lastLineText = previousText.slice(lastLine.start);
1099
- return [
1100
- {
1101
- range: {
1102
- start: { line: 0, character: 0 },
1103
- end: {
1104
- line: lastLine.index,
1105
- character: convertCharacterOffset(state.positionEncoding, lastLineText, lastLineText.length),
1106
- },
1107
- },
1108
- text: content,
1109
- },
1110
- ];
1111
- }
1112
- // Methods that can be registered dynamically and map to operationSupport keys
1113
- const DYNAMIC_OPERATION_METHOD_MAP = {
1114
- "textDocument/definition": "definition",
1115
- "textDocument/typeDefinition": "typeDefinition",
1116
- "textDocument/declaration": "declaration",
1117
- "textDocument/references": "references",
1118
- "textDocument/hover": "hover",
1119
- "textDocument/signatureHelp": "signatureHelp",
1120
- "textDocument/documentSymbol": "documentSymbol",
1121
- "workspace/symbol": "workspaceSymbol",
1122
- "textDocument/codeAction": "codeAction",
1123
- "textDocument/rename": "rename",
1124
- "textDocument/implementation": "implementation",
1125
- "textDocument/prepareCallHierarchy": "callHierarchy",
1126
- };
1127
- export function applyDynamicCapabilities(state) {
1128
- const registrations = [...state.dynamicRegistrations.values()];
1129
- const registeredMethods = new Set(registrations.map((r) => r.method));
1130
- const hasDynamicPull = registeredMethods.has("textDocument/diagnostic") ||
1131
- registeredMethods.has("workspace/diagnostic");
1132
- if (hasDynamicPull) {
1133
- state.workspaceDiagnosticsSupport = {
1134
- advertised: true,
1135
- mode: "pull",
1136
- // #1667: workspace-pull support is what the REGISTRATION declares. The
1137
- // spec registers workspace pull as `registerOptions.workspaceDiagnostics`
1138
- // on a `textDocument/diagnostic` registration - `workspace/diagnostic` is
1139
- // not itself a registrable method - so reading the method name alone
1140
- // missed every conforming server and this client never issued a
1141
- // workspace pull for one. The method-name check stays as a fallback for
1142
- // servers that register it as a method anyway.
1143
- workspaceDiagnostics: registrations.some((r) => r.workspaceDiagnostics === true) ||
1144
- registeredMethods.has("workspace/diagnostic"),
1145
- diagnosticProviderKind: "dynamic",
1146
- };
1147
- }
1148
- else if (state.staticDiagnosticsMode === "push-only" &&
1149
- state.workspaceDiagnosticsSupport.diagnosticProviderKind === "dynamic") {
1150
- // Was only dynamically registered, now unregistered — revert to push-only
1151
- state.workspaceDiagnosticsSupport = {
1152
- advertised: false,
1153
- mode: "push-only",
1154
- workspaceDiagnostics: false,
1155
- diagnosticProviderKind: "none",
1156
- };
1157
- }
1158
- for (const [method, key] of Object.entries(DYNAMIC_OPERATION_METHOD_MAP)) {
1159
- if (registeredMethods.has(method)) {
1160
- state.operationSupport[key] = true;
1161
- }
1162
- }
1163
- }
1164
- export function resolveConfigurationSection(initialization, section) {
1165
- if (!initialization)
1166
- return {};
1167
- if (!section)
1168
- return initialization;
1169
- let cur = initialization;
1170
- for (const part of section.split(".")) {
1171
- if (typeof cur !== "object" || cur === null || !Object.hasOwn(cur, part)) {
1172
- return {};
1173
- }
1174
- cur = cur[part];
1175
- }
1176
- return cur;
1177
- }
1178
- // Exported (only) so tests can invoke the publishDiagnostics notification
1179
- // handler directly against a mock LSPClientState/connection without spawning
1180
- // a real language server. Not part of the public client API surface.
1181
- export function setupIncomingHandlers(state, initialization, onDiagnosticsPublished) {
1182
- state.connection.onNotification("textDocument/publishDiagnostics", (params) => {
1183
- // #1639: the settle operation's own clock for THIS publish. A
1184
- // "quiet-window" settle's durationMs below measures from here (when
1185
- // this publish arrived and — armed or re-armed — the debounce timer)
1186
- // to when that timer actually fires, i.e. the real debounce wait, not
1187
- // time-since-didOpen.
1188
- const publishReceivedAt = Date.now();
1189
- const filePath = uriToPath(params.uri);
1190
- const normalizedPath = normalizeMapKey(filePath);
1191
- // A server can flush a queued publish after didClose during teardown.
1192
- // Do not resurrect diagnostics or their content binding for a document
1193
- // that is no longer open on this client.
1194
- if (state.closedDocuments?.has(normalizedPath))
1195
- return;
1196
- onDiagnosticsPublished?.(state.serverId);
1197
- const newDiags = normalizeLspDiagnostics(params.diagnostics || []);
1198
- const docVersion = params.version;
1199
- if (PUB_DEBUG) {
1200
- // #1333: PUB_DEBUG gate preserved; sink is extension.log.
1201
- logExtension({
1202
- subsystem: "lsp-pub",
1203
- level: "debug",
1204
- message: `server=${state.serverId} pubVersion=${docVersion} docVersion=${state.documentVersions?.get(normalizedPath)} diags=${newDiags.length}`,
1205
- });
1206
- }
1207
- const strategy = getStrategy(state.serverId, state.launchVariant);
1208
- // Publication counting and code extraction exist only for the
1209
- // TypeScript diagnostic-sequence telemetry; skip the bookkeeping for
1210
- // every other push server on this hot receive path.
1211
- const isTypeScriptTelemetry = state.serverId === "typescript";
1212
- const publicationIndex = isTypeScriptTelemetry
1213
- ? (state.diagnosticPublicationCounts.get(normalizedPath) ?? 0) + 1
1214
- : 0;
1215
- if (isTypeScriptTelemetry) {
1216
- state.diagnosticPublicationCounts.set(normalizedPath, publicationIndex);
1217
- }
1218
- const diagnosticCodes = isTypeScriptTelemetry
1219
- ? [
1220
- ...new Set(newDiags
1221
- .map((diagnostic) => diagnostic.code)
1222
- .filter((code) => code !== undefined)
1223
- .map(String)),
1224
- ].slice(0, 8)
1225
- : [];
1226
- // #1639: `durationMs` measures the settle operation, never
1227
- // time-since-didOpen — document age keeps its own honest name in
1228
- // `metadata.elapsedSinceDidOpenMs`, exactly like the pull-path
1229
- // producer above. Before this fix EVERY call (settled or not) logged
1230
- // the doc-age value as durationMs, which is what actually produced
1231
- // the issue's cited evidence: this push-path producer, not the pull
1232
- // path, emitted all 239 records (147 of them >60s "durations" for
1233
- // settles that took milliseconds; a 61ms same-file pair is this
1234
- // function's OWN unsettled-then-settled shape — the
1235
- // `logSequence(false)` raw-receipt record immediately followed by
1236
- // the `logSequence(true, ...)` settle record for the same publish).
1237
- // - "first-push": settles immediately — the seed-first-push strategy
1238
- // bypasses the debounce timer entirely, so there is no wait to
1239
- // measure; durationMs is 0.
1240
- // - "quiet-window": the real debounce wait, timed from THIS publish's
1241
- // own receipt (a later publish that re-arms the timer captures its
1242
- // OWN `publishReceivedAt`, so a re-armed wait is timed from the
1243
- // push that actually won the debounce, not the first one that lost
1244
- // it).
1245
- // - "publication": a raw per-publication receipt, never itself a
1246
- // settle — durationMs is 0 and `settledReturn` stays false. This
1247
- // is the record that used to log with NO settleSource at all;
1248
- // giving it one closes AC3 (distinguish, don't silently overload,
1249
- // the phase's two shapes) for the pairs that actually occur.
1250
- const logSequence = (settledReturn, settleSource, durationMs) => {
1251
- if (state.serverId !== "typescript")
1252
- return;
1253
- const elapsedSinceDidOpenMs = Math.max(0, Date.now() -
1254
- (state.documentOpenedAt.get(normalizedPath) ?? Date.now()));
1255
- logLatency({
1256
- type: "phase",
1257
- phase: "lsp_typescript_diagnostic_sequence",
1258
- filePath: normalizedPath,
1259
- durationMs,
1260
- metadata: {
1261
- serverId: state.serverId,
1262
- outcome: settledReturn ? "settled" : "published",
1263
- launchVariant: state.launchVariant ?? "unknown",
1264
- publicationIndex,
1265
- version: docVersion ?? "push-unversioned",
1266
- diagnosticCount: newDiags.length,
1267
- diagnosticCodes,
1268
- elapsedSinceDidOpenMs,
1269
- settledReturn,
1270
- settleSource,
1271
- },
1272
- });
1273
- };
1274
- // Record the document version these diagnostics were computed against
1275
- // (when the server reports it) so waitForDiagnostics can reject results
1276
- // that lag behind the latest didChange instead of serving them as fresh.
1277
- const recordDocVersion = () => {
1278
- if (docVersion !== undefined) {
1279
- state.diagnosticDocVersions.set(normalizedPath, docVersion);
1280
- }
1281
- recordBinding();
1282
- };
1283
- // #1095: bind the just-stored diagnostics to the content they were
1284
- // computed against. Only when the server reported a version AND we still
1285
- // hold the sent-content fingerprint for exactly that version — otherwise
1286
- // no contentHash is recorded, so the binding reads "unknown" and a
1287
- // version-less server behaves exactly as before. Runs at the same
1288
- // write-time moment as `pushDiagnostics.set` (superseded pushes are
1289
- // dropped before this via `isSupersededPush`, so a binding never lags the
1290
- // latest sent version).
1291
- const recordBinding = () => {
1292
- if (docVersion === undefined) {
1293
- state.diagnosticBindings.delete(normalizedPath);
1294
- return;
1295
- }
1296
- const sent = state.documentContentHashes.get(normalizedPath);
1297
- state.diagnosticBindings.set(normalizedPath, {
1298
- version: docVersion,
1299
- contentHash: sent && sent.version === docVersion ? sent.hash : undefined,
1300
- });
1301
- };
1302
- // Late/superseded-push guard: if the server stamped this push with a
1303
- // version and that version already lags the latest didChange we sent,
1304
- // this is analysis for an edit that's since been overtaken — caching it
1305
- // would let getDiagnostics()/getAllDiagnostics()/pruneDiagnostics() (none
1306
- // of which consult isVersionStale — that check only gates the *wait*
1307
- // helper below) serve stale results as current until the next genuinely
1308
- // fresh push overwrites them. Drop it before it reaches the cache instead.
1309
- // Checked at write time (not at notification-receipt time) so a push that
1310
- // arrives fresh but whose debounce timer fires after a later didChange is
1311
- // still caught. Version-less servers (docVersion undefined) are
1312
- // unaffected — that's an intentional, separate tradeoff (see
1313
- // isVersionStale below), not something this guard touches.
1314
- //
1315
- // Known, deliberately out-of-scope gaps: the pull-diagnostics path
1316
- // (clientRequestPullDiagnostics/clientRequestWorkspaceDiagnostics) has no
1317
- // version stamp to compare against in this codebase's current handling,
1318
- // so nothing analogous is applied there. The other gap this note used to
1319
- // record — `diagnosticsVersion` being a single global counter, so an
1320
- // unrelated path's fresh push satisfied a wait for THIS path — is closed by
1321
- // #1531: every bump also stamps `diagnosticsVersionsByPath`, and both the
1322
- // `minVersion` freshness gate and the auxiliary answered/silent evidence
1323
- // check read that per-path stamp.
1324
- const isSupersededPush = () => {
1325
- if (docVersion === undefined)
1326
- return false;
1327
- const currentVersion = state.documentVersions.get(normalizedPath);
1328
- return currentVersion !== undefined && docVersion < currentVersion;
1329
- };
1330
- // #3310: HOLD the empty first publish of an asynchronously-indexing
1331
- // server. Measured (docs/lsp-capability-matrix.md's `first-publish`
1332
- // column): intelephense answers `didOpen` with `[]` while its
1333
- // whole-workspace index builds and publishes the real set once
1334
- // indexing ends, so letting that first publish through cached an empty
1335
- // set, bumped the publication stamp and emitted — which resolved the
1336
- // push wait AND satisfied the "answered" evidence check in
1337
- // `clients/lsp/index.ts`, rendering a file with an error as "confirmed
1338
- // clean". A timeout is not a false clean; an empty pre-index publish
1339
- // must not become one either.
1340
- //
1341
- // Held means: not cached, no version bump, no emit — so it can neither
1342
- // resolve a wait nor count as evidence. Nothing is scheduled and
1343
- // nothing is awaited: the server's OWN next publish releases the hold
1344
- // (it is no longer a first publish), and the existing per-server budget
1345
- // stays the only bound, so there is no new timer, listener or
1346
- // cancellation path. A genuinely clean file still gets an affirmative
1347
- // clean, because the measured server re-publishes `[]` at the end of
1348
- // indexing.
1349
- //
1350
- // Three conditions, each load-bearing:
1351
- // - the MEASURED class (never every push server): a Tier 2/2* server
1352
- // that publishes `[]` once for a clean file keeps resolving the wait
1353
- // on it, with no added latency (#3310 AC2);
1354
- // - one-shot per client session: the cold index builds once, so a warm
1355
- // touch — or a class server that never re-publishes — pays nothing;
1356
- // - FIRST publication for this document: no cached push AND no pending
1357
- // debounce timer. An empty publish that CLEARS an earlier non-empty
1358
- // one (a fix landing) is never held, in either arrival order.
1359
- if (strategy.emptyFirstPublish === "indexing" &&
1360
- !state.emptyFirstPublishHoldSpent &&
1361
- newDiags.length === 0 &&
1362
- !state.pushDiagnostics.has(normalizedPath) &&
1363
- !state.pendingDiagnostics.has(normalizedPath)) {
1364
- state.emptyFirstPublishHoldSpent = true;
1365
- // Bounded by construction: one record per client session, because
1366
- // the hold itself is one-shot.
1367
- logLatency({
1368
- type: "phase",
1369
- phase: "lsp_empty_first_publish_held",
1370
- filePath: normalizedPath,
1371
- durationMs: Math.max(0, publishReceivedAt -
1372
- (state.documentOpenedAt.get(normalizedPath) ?? publishReceivedAt)),
1373
- metadata: {
1374
- serverId: state.serverId,
1375
- emptyFirstPublish: strategy.emptyFirstPublish,
1376
- // `pubVersion`, the `[lsp-pub]` trace's own field name for the
1377
- // publish's LSP document version — not a lifecycle identity, and
1378
- // not the retired glossary spelling (AGENTS.md "generation").
1379
- pubVersion: docVersion ?? "push-unversioned",
1380
- },
1381
- });
1382
- return;
1383
- }
1384
- // Seed on first push for servers whose first push is known complete.
1385
- // Bypasses the debounce timer entirely — resolves waiting promises immediately.
1386
- if (strategy.seedFirstPush &&
1387
- !state.pushDiagnostics.has(normalizedPath)) {
1388
- if (isSupersededPush())
1389
- return;
1390
- state.pushDiagnostics.set(normalizedPath, newDiags);
1391
- state.pushDiagnosticTimestamps.set(normalizedPath, Date.now());
1392
- recordDocVersion();
1393
- bumpDiagnosticsVersion(state, normalizedPath);
1394
- state.diagnosticEmitter.emit("diagnostics", normalizedPath);
1395
- // Immediate settle — no debounce wait to measure.
1396
- logSequence(true, "first-push", 0);
1397
- return;
1398
- }
1399
- // A raw per-publication receipt, not itself a settle.
1400
- logSequence(false, "publication", 0);
1401
- const existingTimer = state.pendingDiagnostics.get(normalizedPath);
1402
- if (existingTimer)
1403
- clearTimeout(existingTimer);
1404
- const timer = setTimeout(() => {
1405
- state.pendingDiagnostics.delete(normalizedPath);
1406
- if (isSupersededPush())
1407
- return;
1408
- state.pushDiagnostics.set(normalizedPath, newDiags);
1409
- state.pushDiagnosticTimestamps.set(normalizedPath, Date.now());
1410
- recordDocVersion();
1411
- bumpDiagnosticsVersion(state, normalizedPath);
1412
- state.diagnosticEmitter.emit("diagnostics", normalizedPath);
1413
- // The real debounce wait, timed from THIS publish's own receipt.
1414
- logSequence(true, "quiet-window", Date.now() - publishReceivedAt);
1415
- }, strategy.debounceMs);
1416
- state.pendingDiagnostics.set(normalizedPath, timer);
1417
- });
1418
- state.connection.onRequest("workspace/workspaceFolders", () => [
1419
- { name: "workspace", uri: pathToFileURL(state.root).href },
1420
- ]);
1421
- state.connection.onRequest("client/registerCapability", async (params) => {
1422
- for (const reg of params?.registrations ?? []) {
1423
- const options = reg.registerOptions;
1424
- const commands = Array.isArray(options?.commands)
1425
- ? options.commands.filter((cmd) => typeof cmd === "string")
1426
- : undefined;
1427
- if (reg.id && reg.method) {
1428
- // #1667: keep the whole registration, not just its method. An empty
1429
- // `identifier` is treated as absent - it names no source, and
1430
- // sending `identifier: ""` would be a second, indistinguishable
1431
- // bare pull.
1432
- state.dynamicRegistrations.set(reg.id, {
1433
- method: reg.method,
1434
- ...(typeof options?.identifier === "string" && options.identifier
1435
- ? { identifier: options.identifier }
1436
- : {}),
1437
- ...(typeof options?.workspaceDiagnostics === "boolean"
1438
- ? { workspaceDiagnostics: options.workspaceDiagnostics }
1439
- : {}),
1440
- ...(commands ? { commands } : {}),
1441
- });
1442
- }
1443
- // executeCommand commands can arrive dynamically too — merge them
1444
- // into the allowlist so dynamically-registered commands are runnable.
1445
- if (reg.method === "workspace/executeCommand" && commands) {
1446
- for (const cmd of commands)
1447
- state.advertisedCommands.add(cmd);
1448
- }
1449
- }
1450
- applyDynamicCapabilities(state);
1451
- });
1452
- state.connection.onRequest("client/unregisterCapability", async (params) => {
1453
- const retiredIdentifiers = new Set();
1454
- for (const unreg of params?.unregisterations ?? []) {
1455
- if (!unreg.id)
1456
- continue;
1457
- const registration = state.dynamicRegistrations.get(unreg.id);
1458
- state.dynamicRegistrations.delete(unreg.id);
1459
- if (registration?.method === "textDocument/diagnostic" &&
1460
- registration.identifier) {
1461
- retiredIdentifiers.add(registration.identifier);
1462
- }
1463
- }
1464
- // #1667: a retired source's cached findings and resultId must go with
1465
- // it, or the union keeps serving them. Checked after the deletes above,
1466
- // and only when NO surviving registration still speaks for that
1467
- // identifier.
1468
- const stillRegistered = new Set([...state.dynamicRegistrations.values()]
1469
- .filter((r) => r.method === "textDocument/diagnostic")
1470
- .map((r) => r.identifier));
1471
- for (const identifier of retiredIdentifiers) {
1472
- if (!stillRegistered.has(identifier)) {
1473
- retirePullSource(state, identifier);
1474
- }
1475
- }
1476
- applyDynamicCapabilities(state);
1477
- });
1478
- // Server-initiated edits (the mutation vector for executeCommand). Honored
1479
- // ONLY while an explicit executeCommand is in flight (serverEditsAllowed > 0);
1480
- // an unsolicited applyEdit outside that window is refused so a server can't
1481
- // push edits to disk at will. Applied through the same applyWorkspaceEdit path
1482
- // as every other edit.
1483
- state.connection.onRequest("workspace/applyEdit", async (params) => {
1484
- if (state.serverEditsAllowed <= 0 || !params?.edit) {
1485
- return { applied: false, failureReason: "edit not solicited" };
1486
- }
1487
- // #2450 fix round 3 (F3) removed a `depth === 1` re-check here as
1488
- // dead code. It is not dead — it moved to the WRITE side, where it
1489
- // is the live invariant: `state.activeMutationContext` is assigned
1490
- // only by `syncMutationContextSlot` (below in this file), which
1491
- // populates it exactly when `activeMutationDepth === 1` and
1492
- // empties it on every other frame transition. The depth test
1493
- // therefore still runs, on each entry and each unwind; repeating
1494
- // it here would be a second copy of the same predicate, not an
1495
- // independent gate. What this handler reads is consequently either
1496
- // the context of the single frame currently in flight or
1497
- // `undefined` — never a settled frame's context, and never a
1498
- // context belonging to some other window that is still open
1499
- // (#2479 review rounds 2/3).
1500
- const context = state.activeMutationContext;
1501
- // `workspace/applyEdit` is only ever honored inside the
1502
- // `serverEditsAllowed` window this handler just checked, which is
1503
- // opened exclusively by an executeCommand call — so a fallback here is
1504
- // always executeCommand-solicited, never a bare edit (#2450). This
1505
- // fallback context carries no runtime/cacheManager (there is no live
1506
- // reference to those singletons at this call site); lsp-mutation.ts's
1507
- // bookkeepLspMutation falls back to the mutation bridge for exactly
1508
- // that reason rather than silently dropping the write's bookkeeping.
1509
- const telemetryContext = context ?? {
1510
- cwd: state.root,
1511
- correlationId: newLspMutationCorrelationId(),
1512
- tool: "lsp-workspace-applyEdit",
1513
- source: "lsp-execute-command",
1514
- };
1515
- try {
1516
- await applyWorkspaceEdit(params.edit, state.root, {
1517
- positionEncoding: state.positionEncoding,
1518
- documentVersions: state.documentVersions,
1519
- mutationContext: telemetryContext,
1520
- });
1521
- return { applied: true };
1522
- }
1523
- catch (err) {
1524
- return {
1525
- applied: false,
1526
- failureReason: err instanceof Error ? err.message : String(err),
1527
- };
1528
- }
1529
- });
1530
- // #983: the LSP spec requires the response array to have exactly one entry
1531
- // per requested item, each resolved against that item's `section` (a
1532
- // dot-path into the server's config, e.g. "scan.jobs") — not a fixed
1533
- // single-element array duplicating the whole blob for every item. An item
1534
- // with no `section` gets the whole blob (that's what "no section" means
1535
- // per spec); an unresolvable section gets `{}` (never `null`: strict consumers such as
1536
- // vscode-css-language-server and @prisma/language-server throw or exit on
1537
- // null), never the whole blob.
1538
- state.connection.onRequest("workspace/configuration", async (params) => {
1539
- const items = params?.items ?? [];
1540
- return items.map((item) => resolveConfigurationSection(initialization, item?.section));
1541
- });
1542
- state.connection.onRequest("window/workDoneProgress/create", async () => { });
1543
- // #1669: a server can send `workspace/diagnostic/refresh` (typically after a
1544
- // project-wide config change) to say every pull result it has already
1545
- // reported may be stale. Left unhandled, vscode-jsonrpc replies
1546
- // MethodNotFound to a server explicitly telling us its results are stale —
1547
- // this is exactly the signal `workspacePullResultCache` and the persisted
1548
- // workspace-diagnostics cache want (the #1461 family, server-initiated for
1549
- // once). Reply null per spec and drop both so the NEXT pull recomputes
1550
- // instead of replaying — or inheriting an `unchanged` basis from — results
1551
- // the server just told us to distrust.
1552
- // #1669 review R1: single-flight coalescing latch for the re-pull pool
1553
- // below, scoped to THIS client (one `setupIncomingHandlers` call per
1554
- // connection). A refresh FLOOD — a watch-mode rebuild or a `git checkout`
1555
- // can legitimately fire many `workspace/diagnostic/refresh` requests in a
1556
- // tight burst — used to queue one independent capped pool PER refresh:
1557
- // N refreshes meant N pools running concurrently (peak concurrency N x
1558
- // the per-pool cap) and N full passes over every open document (N x
1559
- // redundant pulls for the SAME documents). `refreshRepullRunning` gates a
1560
- // SECOND pool from ever starting while one is already in flight;
1561
- // `refreshRepullRerunRequested` remembers that at least one more refresh
1562
- // arrived meanwhile and coalesces it into exactly ONE trailing rerun
1563
- // after the current pool finishes, using a FRESH snapshot of open
1564
- // documents at rerun time (not the stale list from whenever the burst
1565
- // started) — so an arbitrarily large burst still costs at most 2 passes
1566
- // and never more than `REFRESH_REPULL_CONCURRENCY` pulls at once.
1567
- let refreshRepullRunning = false;
1568
- let refreshRepullRerunRequested = false;
1569
- const runRefreshRepullPool = async () => {
1570
- do {
1571
- refreshRepullRerunRequested = false;
1572
- const toRepull = state.workspaceDiagnosticsSupport.mode === "pull"
1573
- ? [...state.openDocuments]
1574
- : [];
1575
- if (toRepull.length > 0) {
1576
- try {
1577
- await mapWithConcurrency(toRepull, REFRESH_REPULL_CONCURRENCY, async (normalizedPath) => {
1578
- const uri = state.openDocumentUris?.get(normalizedPath);
1579
- const filePath = uri ? uriToPath(uri) : normalizedPath;
1580
- await clientRequestPullDiagnostics(state, filePath);
1581
- });
1582
- }
1583
- catch (err) {
1584
- // #1669 review N2: rejections are observed, never
1585
- // `void`-swallowed — `clientRequestPullDiagnostics` itself never
1586
- // throws (it returns an `unavailable` outcome), so this only
1587
- // fires on a genuine programming error, but a silent throw
1588
- // inside `mapWithConcurrency`'s `Promise.all` must not vanish.
1589
- incrementDegradationCount({
1590
- kind: "lsp-pull-unconfirmed",
1591
- subject: state.serverId,
1592
- reason: `refresh re-pull threw: ${err instanceof Error ? err.message : String(err)}`,
1593
- });
1594
- }
1595
- }
1596
- // A refresh that arrived WHILE this pool was running gets exactly
1597
- // one more pass, never a pass per refresh.
1598
- } while (refreshRepullRerunRequested);
1599
- refreshRepullRunning = false;
1600
- };
1601
- state.connection.onRequest("workspace/diagnostic/refresh", async () => {
1602
- state.workspacePullResultCache.clear();
1603
- // #1669 review F1: clear every on-disk sweep cache this process has
1604
- // recorded a cwd for — `state.root` alone is a per-SERVER identity
1605
- // marker that a monorepo/multi-root project routinely nests BELOW the
1606
- // real sweep cwd, so clearing ONLY there would miss the cache file the
1607
- // sweep actually reads/writes. See the doc comment on
1608
- // `clearAllWorkspaceDiagnosticsCaches`.
1609
- clearAllWorkspaceDiagnosticsCaches();
1610
- // #1669 review N3: ALSO clear at `state.root` directly. The registry
1611
- // above only knows cwds a sweep has already run under IN THIS PROCESS —
1612
- // a refresh arriving before this project's first sweep this process
1613
- // (e.g. right after a respawn) would otherwise clear nothing at all,
1614
- // and a later sweep would load the untouched, now-genuinely-stale
1615
- // on-disk cache a PRIOR process/session left behind. `state.root` is
1616
- // the one cwd this handler can always reach regardless of registry
1617
- // state — a cheap, durable backstop for the common single-root case
1618
- // where root and sweep cwd coincide. It does NOT cover a monorepo
1619
- // subproject a sweep hasn't reached yet under a DIFFERENT cwd than
1620
- // `state.root` — that residual gap is tracked in #1707, not silently
1621
- // claimed as solved (see `clearAllWorkspaceDiagnosticsCaches`'s doc
1622
- // comment for the full shape).
1623
- clearWorkspaceDiagnosticsCacheAtAndAbove(state.root);
1624
- // #1669 review F3: a server-initiated "everything may be stale" signal
1625
- // must drop the SAME per-document state a normal resync already drops
1626
- // via `clearDiagnosticsForPath` (pullResultIds, pushDiagnostics,
1627
- // documentPullDiagnostics, diagnosticBindings,
1628
- // diagnosticsVersionsByPath, ...) — dropping only
1629
- // `workspacePullResultCache` above left every open document's OTHER
1630
- // pull state intact, so the next pull would echo a disowned
1631
- // `previousResultId`, get back `kind: "unchanged"`, and re-confirm
1632
- // stale diagnostics under a fresh timestamp; nothing would ever
1633
- // re-pull on its own. This clear is cheap and always runs — every
1634
- // refresh in a burst gets its own, unlike the re-pull pool below.
1635
- for (const normalizedPath of state.openDocuments) {
1636
- clearDiagnosticsForPath(state, normalizedPath);
1637
- }
1638
- // #1669 review N2: reply to the PROTOCOL request now — every clear
1639
- // above is synchronous and already complete, so there is nothing left
1640
- // this reply should wait on. The re-pull below is a background
1641
- // improvement (so an open document reflects the refresh without
1642
- // waiting on its next edit), never something worth delaying — or
1643
- // risking — the required `null` reply over.
1644
- if (state.workspaceDiagnosticsSupport.mode === "pull" &&
1645
- state.openDocuments.size > 0) {
1646
- if (refreshRepullRunning) {
1647
- // #1669 review R1: a pool is already in flight from an earlier
1648
- // refresh in this burst — coalesce instead of starting a second
1649
- // one.
1650
- refreshRepullRerunRequested = true;
1651
- }
1652
- else {
1653
- refreshRepullRunning = true;
1654
- // `setImmediate` defers even the SYNCHRONOUS prefix of the pool
1655
- // (URI lookups, map bookkeeping) to the next tick, so it can
1656
- // never compete with this reply for the current one regardless
1657
- // of how many documents are open.
1658
- setImmediate(() => {
1659
- void runRefreshRepullPool();
1660
- });
1661
- }
1662
- }
1663
- return null;
1664
- });
1665
- }
1666
- /**
1667
- * #1127: record the FIRST moment this client observed its own death. Detection
1668
- * of a dead client (the next `getClientForFile` attach in index.ts) can happen
1669
- * long after the process actually died — this timestamp is the only reliable
1670
- * "when did it die" signal, so it must be set here, at the earliest death
1671
- * signal, not derived later from detection time.
1672
- */
1673
- function markExitedIfUnset(state) {
1674
- if (state.exitedAt === undefined) {
1675
- state.exitedAt = Date.now();
1676
- }
1677
- }
1678
- function setupConnectionLifecycle(state, recentStderr) {
1679
- state.connection.onError(([error]) => {
1680
- state.lastError = error instanceof Error ? error : new Error(String(error));
1681
- state.isConnected = false;
1682
- state.isDestroyed = true;
1683
- markExitedIfUnset(state);
1684
- disposeClientConnection(state);
1685
- });
1686
- state.connection.onClose(() => {
1687
- state.isConnected = false;
1688
- state.isDestroyed = true;
1689
- markExitedIfUnset(state);
1690
- disposeClientConnection(state);
1691
- });
1692
- state.lspProcess.process.on("exit", (code, signal) => {
1693
- // Gate on shutdownRequested (our own clientShutdown() call), not
1694
- // isConnected: a genuine crash's connection.onClose/onError handler above
1695
- // can fire and flip isConnected false BEFORE this 'exit' event arrives,
1696
- // which used to make the old `wasConnected` check silently swallow every
1697
- // crash whose transport died before the process itself reported exiting
1698
- // (previously: 5 ast-grep deaths during a dogfooding sweep logged only
1699
- // "respawn, uptime=Xms" — no exit code, no signal, no stderr — because
1700
- // none of them tripped this log).
1701
- const wasIntentional = state.shutdownRequested;
1702
- state.isConnected = false;
1703
- state.isDestroyed = true;
1704
- markExitedIfUnset(state);
1705
- disposeClientConnection(state);
1706
- if (!wasIntentional) {
1707
- logLatency({
1708
- type: "phase",
1709
- phase: "lsp_server_unexpected_exit",
1710
- filePath: state.root,
1711
- durationMs: 0,
1712
- metadata: {
1713
- serverId: state.serverId,
1714
- pid: state.lspProcess.pid,
1715
- exitCode: code ?? null,
1716
- exitSignal: signal ?? null,
1717
- stderrTail: recentStderr(20),
1718
- },
1719
- });
1720
- }
1721
- });
1722
- // #1969: the exit CAUSE, in the degradation ledger.
1723
- //
1724
- // The `exit` log above is one latency.log line per death. That line is
1725
- // necessary but not sufficient: an ast-grep child died 14 times in one day
1726
- // with `code=1` and empty stderr, and the only thing visible downstream was
1727
- // 19 `lsp_client_skipped_broken` cooldowns and 32 coverage gaps — fallout
1728
- // with no cause attached. The ledger is what a session-end health read
1729
- // looks at, so the cause belongs there too, counted per server.
1730
- //
1731
- // `close` rather than `exit`: `close` fires only after the child's stdio
1732
- // streams have drained, so "stderr was empty" is a statement about the
1733
- // server rather than a race against the pipe. `exit` can arrive first with
1734
- // stderr still buffered.
1735
- //
1736
- // Gated on `shutdownRequested` exactly as the exit log is — an eviction or
1737
- // an ordinary teardown is not a degradation and must not be recorded.
1738
- // `incrementDegradationCount` counts every occurrence and retains the
1739
- // latest reason per server, so a server dying in a loop shows as one entry
1740
- // with an honest count rather than N entries.
1741
- state.lspProcess.process.on("close", (code, signal) => {
1742
- if (state.shutdownRequested)
1743
- return;
1744
- try {
1745
- const stderrTail = recentStderr(20);
1746
- incrementDegradationCount({
1747
- kind: "lsp-server-unexpected-close",
1748
- subject: state.serverId,
1749
- reason: `closed unprompted: code=${code ?? "none"} signal=${signal ?? "none"} ` +
1750
- `stderr=${stderrTail.length > 0 ? `present(${stderrTail.length}b)` : "empty"} root=${state.root}`,
1751
- });
1752
- }
1753
- catch {
1754
- // Telemetry must never break the observed path.
1755
- }
1756
- });
1757
- }
1758
- /** Margin between each source's own request timeout and the race's backstop
1759
- * deadline, so the natural "all sources settled" path wins over the backstop
1760
- * and the race never reports `unavailable` for sources that did answer. */
1761
- const PULL_RACE_BACKSTOP_MARGIN_MS = 25;
1762
- /**
1763
- * #1667: pull EVERY registered diagnostic source for a file, in parallel, and
1764
- * answer as soon as the first source returns findings for that file.
1765
- *
1766
- * Roslyn, vtsls and some Java setups register `textDocument/diagnostic` once
1767
- * per source (syntax, semantic, analyzers), each named by
1768
- * `registerOptions.identifier`. One bare request asks only the server's default
1769
- * source, so whole categories of findings were never requested. The fan-out
1770
- * asks all of them plus the bare request.
1771
- *
1772
- * Timing is deliberate and load-bearing:
1773
- * - PARALLEL, never one source after another. Sequencing would add every
1774
- * slow source's latency to every touch.
1775
- * - The race resolves on the FIRST source with findings for the file, with no
1776
- * settle or debounce window after the match. A post-match wait is the exact
1777
- * latency shape #1112/#1407 already cost this codebase once.
1778
- * - Losing sources are NOT cancelled. Each source writes its own findings into
1779
- * the pull cache before it resolves, so a late answer merges in the
1780
- * background and the NEXT read of the file sees it. Late answers are not
1781
- * dropped, they are just not waited for.
1782
- *
1783
- * The race runs inside the existing `raceToCompletion` primitive rather than a
1784
- * second first-wins helper.
1785
- */
1786
- async function clientRequestPullDiagnostics(state, filePath, budgetMs = PULL_REQUEST_TIMEOUT_MS) {
1787
- if (!isClientAlive(state))
1788
- return { status: "unavailable" };
1789
- const sources = pullDiagnosticSources(state);
1790
- if (sources.length === 1) {
1791
- // The overwhelmingly common case: no named sources registered. Skip the
1792
- // race entirely so a single-source server pays nothing for this feature.
1793
- return aggregatePullOutcomes(state, filePath, [
1794
- await pullDiagnosticSource(state, filePath, budgetMs, undefined),
1795
- ]);
1796
- }
1797
- const attempts = sources.map((identifier) => pullDiagnosticSource(state, filePath, budgetMs, identifier));
1798
- const settled = await raceToCompletion(attempts, (results) => results.some((r) => r.status === "found" && r.primaryCount > 0), {
1799
- // No graceMs: finalize the instant a source answers for this file.
1800
- // The per-request `withTimeout` inside each attempt is the real bound;
1801
- // this deadline is only a backstop, so it sits just past it.
1802
- timeoutMs: Math.max(1, Math.min(PULL_REQUEST_TIMEOUT_MS, budgetMs)) +
1803
- PULL_RACE_BACKSTOP_MARGIN_MS,
1804
- });
1805
- return aggregatePullOutcomes(state, filePath, settled);
1806
- }
1807
- /**
1808
- * Collapse the sources that answered into one outcome for the caller.
1809
- * `found` beats `clean` beats `unavailable`: a source with findings is an
1810
- * answer, an authoritative empty report is an answer, and only "nobody
1811
- * answered" is unavailable (#240 — an empty result must distinguish clean
1812
- * from errored).
1813
- */
1814
- function aggregatePullOutcomes(state, filePath, outcomes) {
1815
- const found = outcomes.filter((o) => o.status === "found");
1816
- if (found.length > 0) {
1817
- const normalizedPath = normalizeMapKey(filePath);
1818
- // The union for the requested file, which is what a caller reading the
1819
- // cache will see. A source that only reported `relatedDocuments` findings
1820
- // contributes nothing there, so fall back to its own count.
1821
- const unionCount = state.documentPullDiagnostics.get(normalizedPath)?.length ?? 0;
1822
- return {
1823
- status: "found",
1824
- count: Math.max(unionCount, ...found.map((o) => o.count)),
1825
- };
1826
- }
1827
- // #240, applied across sources: `clean` is an authoritative "this file has no
1828
- // findings", and one source saying so proves nothing about a source that
1829
- // FAILED to answer. A fan-out where any source errored is therefore
1830
- // unavailable, not clean - the caller falls through to the push-wait/timeout
1831
- // backstop instead of recording a confirmed-clean touch it cannot support.
1832
- // (`raceToCompletion` drops still-pending promises from its result array, so
1833
- // "every source settled, none errored" is the only shape that reads clean.)
1834
- if (outcomes.some((o) => o.status === "unavailable")) {
1835
- return { status: "unavailable" };
1836
- }
1837
- return outcomes.some((o) => o.status === "clean")
1838
- ? { status: "clean" }
1839
- : { status: "unavailable" };
1840
- }
1841
- async function pullDiagnosticSource(state, filePath, budgetMs, identifier) {
1842
- if (!isClientAlive(state))
1843
- return { status: "unavailable" };
1844
- const normalizedPath = normalizeMapKey(filePath);
1845
- // #1773: a budget at or below the usable floor cannot complete a real
1846
- // round trip. Skip the dispatch entirely — no `safeSendRequest` call, no
1847
- // generation/sequence claim — and say so with a distinct record, so
1848
- // `lsp_pull_diagnostic_timeout` only ever means a pull was genuinely
1849
- // attempted. The caller sees the same `unavailable` outcome a timeout
1850
- // would have produced (#240: unavailable, never a false clean), so
1851
- // nothing downstream of this call changes shape.
1852
- if (budgetMs < PULL_MIN_USABLE_BUDGET_MS) {
1853
- emitBounded("lsp_pull_skipped_budget_exhausted", `${state.serverId}::${pullSourceKey(normalizedPath, identifier)}`, {
1854
- type: "phase",
1855
- filePath: normalizedPath,
1856
- durationMs: 0,
1857
- metadata: {
1858
- identifier: identifier ?? "bare",
1859
- remainingBudgetMs: budgetMs,
1860
- server: state.serverId,
1861
- },
1862
- }, {
1863
- ledgerKind: "lsp-pull-skipped-budget-exhausted",
1864
- reason: `pull skipped on ${state.serverId}: budget already exhausted (${budgetMs}ms remaining)`,
1865
- });
1866
- return { status: "unavailable" };
1867
- }
1868
- const uri = pathToFileURL(filePath).href;
1869
- // #1104: echo the last resultId we hold for this document so a server that
1870
- // hasn't changed its view can answer `kind: "unchanged"` instead of
1871
- // recomputing — see the `kind === "unchanged"` branch below for how that's
1872
- // honored (inherit, never treat an omitted `items` as clean).
1873
- // #1667: read THIS source's id, never another source's. Result ids are
1874
- // issued per source, so echoing a syntax id on a semantic request asks the
1875
- // server to compare against a basis it never handed out.
1876
- const sourceKey = pullSourceKey(normalizedPath, identifier);
1877
- if (state.abandonedPullRequests?.has(sourceKey)) {
1878
- return { status: "unavailable" };
1879
- }
1880
- const previousResultId = state.pullResultIds.get(sourceKey);
1881
- // #1667: the generation this pull was computed against. A resync landing
1882
- // while the request is in flight bumps it, and every write below is dropped
1883
- // rather than resurrecting state the resync cleared.
1884
- const generation = pullGenerationFor(state, normalizedPath);
1885
- // #1667: claim the newest-request slot for THIS source. Two fan-outs can
1886
- // overlap for the same file with no resync between them (`ensureWarmForSweep`
1887
- // touches, then the real touch follows ~60ms later), so the generation check
1888
- // alone would let a slow answer from the earlier round land on top of a newer
1889
- // one. Re-checked at write time below.
1890
- const requestSequence = claimPullRequestSequence(state, sourceKey);
1891
- // #1889: give the request a cancellation token. `withTimeout` only abandons
1892
- // its await; aborting in the timeout branch below also sends `$/cancelRequest`
1893
- // so repeated touches cannot leave an aging backlog inside the server.
1894
- const requestStartedAt = Date.now();
1895
- const effectiveTimeoutMs = Math.max(1, Math.min(PULL_REQUEST_TIMEOUT_MS, budgetMs));
1896
- const pullAbort = new AbortController();
1897
- const pullSettlement = { cancelled: false };
1898
- const requestPromise = safeSendRequest(state.connection, "textDocument/diagnostic", {
1899
- textDocument: { uri },
1900
- // #1667: name the source this request is for, so the server answers
1901
- // from that source instead of its default one.
1902
- ...(identifier !== undefined && { identifier }),
1903
- ...(previousResultId !== undefined && { previousResultId }),
1904
- }, pullAbort.signal, pullSettlement);
1905
- try {
1906
- // withTimeout is the backstop against a hung pull-mode server: without it
1907
- // this await never settles unless the stream is destroyed. Bounded by the
1908
- // smaller of the absolute ceiling and the caller's remaining wait budget.
1909
- // On timeout the caught error yields `unavailable` below (never a false
1910
- // `clean`), so it falls through to the push-wait/timeout backstop.
1911
- const report = await withTimeout(requestPromise, effectiveTimeoutMs);
1912
- if (!report) {
1913
- recordPullFailure(state, "textDocument/diagnostic", new Error("empty response"));
1914
- return { status: "unavailable" };
1915
- }
1916
- const now = Date.now();
1917
- // #1104: the fingerprint of the content we last sent for this document —
1918
- // the SAME `documentContentHashes` entry the push binding path uses
1919
- // (`recordSentContent` runs unconditionally on every didOpen/didChange,
1920
- // regardless of push/pull mode), so this costs no extra read. A pull
1921
- // response describes whatever the server had when it answered, which for
1922
- // a pi-lens-opened document is exactly that last-sent payload.
1923
- const sentHash = state.documentContentHashes.get(normalizedPath)?.hash;
1924
- // #1667: two ways this answer can already be obsolete, and neither may
1925
- // write. Report the round trip as unavailable - a superseded answer is not
1926
- // evidence the file is clean (#240).
1927
- // - A resync landed while the request was in flight, so these findings
1928
- // describe content that no longer exists.
1929
- // - A LATER request for this same source has already been issued (an
1930
- // overlapping fan-out), so this answer is the stale loser and would
1931
- // clobber the newer result.
1932
- if (pullGenerationFor(state, normalizedPath) !== generation ||
1933
- !isNewestPullRequest(state, sourceKey, requestSequence)) {
1934
- return { status: "unavailable" };
1935
- }
1936
- let primaryCount;
1937
- if (report.kind === "unchanged") {
1938
- // #1104: same resultId basis as last time — an omitted `items` here
1939
- // means "no change", NOT "clean". Overwriting with `[]` would be the
1940
- // exact false-clean shape #570/#571 already fixed for the touch path;
1941
- // keep the previously stored diagnostics and binding as-is.
1942
- // #1667: "the previously stored diagnostics" means THIS SOURCE's, so
1943
- // one source reporting unchanged neither inherits nor disturbs another
1944
- // source's findings for the same file.
1945
- primaryCount = pullSourceDiagnostics(state, normalizedPath, identifier).length;
1946
- // Still a fresh confirmation as of `now` even though the content is
1947
- // unchanged — bump the timestamp so `getAllDiagnostics()` doesn't read
1948
- // this entry as aging purely because the server had nothing new to say.
1949
- state.documentPullDiagnosticTimestamps.set(normalizedPath, now);
1950
- }
1951
- else {
1952
- const primaryItems = normalizeLspDiagnostics(report.items ?? []);
1953
- // #1667: replace only this source's slice; `documentPullDiagnostics`
1954
- // becomes the deduped union of every source that has reported.
1955
- storePullSourceDiagnostics(state, normalizedPath, identifier, primaryItems);
1956
- state.documentPullDiagnosticTimestamps.set(normalizedPath, now);
1957
- bumpDiagnosticsVersion(state, normalizedPath);
1958
- state.diagnosticBindings.set(normalizedPath, { contentHash: sentHash });
1959
- primaryCount = primaryItems.length;
1960
- }
1961
- let totalCount = primaryCount;
1962
- if (report.resultId !== undefined) {
1963
- state.pullResultIds.set(sourceKey, report.resultId);
1964
- }
1965
- else {
1966
- state.pullResultIds.delete(sourceKey);
1967
- }
1968
- // #1531 note (pre-existing, unchanged): a related document's diagnostics are
1969
- // STORED below but earn no version bump and therefore no per-path stamp —
1970
- // exactly as before, since this loop never bumped the global counter either.
1971
- // A wait on a path that only ever hears about itself through some other
1972
- // document's `relatedDocuments` sees no freshness evidence and rides its
1973
- // timeout. Direction is under-detection, and correcting it means deciding
1974
- // what a related-document publication is evidence OF (its binding is
1975
- // honestly "unknown" per #1104), so it stays out of scope here.
1976
- if (report.relatedDocuments) {
1977
- for (const [relatedUri, related] of Object.entries(report.relatedDocuments)) {
1978
- const relatedPath = uriToPath(relatedUri);
1979
- const relatedNormalized = normalizeMapKey(relatedPath);
1980
- // #1667: a related document's report belongs to the SAME source as
1981
- // the request that carried it, so it stores and inherits under that
1982
- // source's key too.
1983
- if (related?.kind === "unchanged") {
1984
- totalCount += pullSourceDiagnostics(state, relatedNormalized, identifier).length;
1985
- state.documentPullDiagnosticTimestamps.set(relatedNormalized, now);
1986
- }
1987
- else {
1988
- const relatedItems = normalizeLspDiagnostics(related?.items ?? []);
1989
- storePullSourceDiagnostics(state, relatedNormalized, identifier, relatedItems);
1990
- state.documentPullDiagnosticTimestamps.set(relatedNormalized, now);
1991
- // #1104: a related document's diagnostics were NOT computed against
1992
- // content we independently sent/fingerprinted (we never requested
1993
- // it directly) — its binding stays honestly "unknown" rather than
1994
- // borrowing the primary document's hash.
1995
- state.diagnosticBindings.delete(relatedNormalized);
1996
- totalCount += relatedItems.length;
1997
- }
1998
- if (related?.resultId !== undefined) {
1999
- state.pullResultIds.set(pullSourceKey(relatedNormalized, identifier), related.resultId);
2000
- }
2001
- }
2002
- }
2003
- state.diagnosticEmitter.emit("diagnostics", normalizedPath);
2004
- return totalCount > 0
2005
- ? { status: "found", count: totalCount, primaryCount }
2006
- : { status: "clean" };
2007
- }
2008
- catch (err) {
2009
- if (isPullTimeoutError(err, effectiveTimeoutMs)) {
2010
- trackAbandonedPullRequest(state, sourceKey, requestPromise);
2011
- pullAbort.abort();
2012
- recordPullTimeoutTelemetry({
2013
- scope: normalizedPath,
2014
- identifier,
2015
- effectiveBudgetMs: effectiveTimeoutMs,
2016
- hadPreviousResultId: previousResultId !== undefined,
2017
- elapsedMs: Date.now() - requestStartedAt,
2018
- server: state.serverId,
2019
- });
2020
- armLateAnswerTelemetry({
2021
- requestPromise,
2022
- scope: normalizedPath,
2023
- identifier,
2024
- subject: sourceKey,
2025
- requestStartedAt,
2026
- server: state.serverId,
2027
- settlement: pullSettlement,
2028
- });
2029
- }
2030
- recordPullFailure(state, "textDocument/diagnostic", err);
2031
- return { status: "unavailable" };
2032
- }
2033
- }
2034
- function trackAbandonedPullRequest(state, sourceKey, requestPromise) {
2035
- if (!state.abandonedPullRequests)
2036
- state.abandonedPullRequests = new Map();
2037
- state.abandonedPullRequests.set(sourceKey, requestPromise);
2038
- const release = () => {
2039
- if (state.abandonedPullRequests?.get(sourceKey) === requestPromise) {
2040
- state.abandonedPullRequests.delete(sourceKey);
2041
- }
2042
- };
2043
- requestPromise.then(release, release);
2044
- }
2045
- /**
2046
- * #1713: `withTimeout` rejects with this EXACT message (`clients/deadline-utils.ts`)
2047
- * only when its own timer wins the race — every other rejection is the request
2048
- * itself failing (bad params, dead connection, thrown by the server). Only the
2049
- * timer-won case is a "the answer might still be coming" situation worth a
2050
- * late-answer watch; a request that already failed fast has nothing further to
2051
- * observe.
2052
- */
2053
- function isPullTimeoutError(err, timeoutMs) {
2054
- return err instanceof Error && err.message === `Timeout after ${timeoutMs}ms`;
2055
- }
2056
- /**
2057
- * #1713: every pull timeout emits exactly one bounded latency.log record — the
2058
- * observability gap the issue names (`withTimeout`'s throw previously skipped
2059
- * ALL telemetry for this site). Never throws: telemetry must not perturb the
2060
- * timeout path, which has already decided to return `unavailable` regardless.
2061
- *
2062
- * #1743: routed through `emitBounded`. This phase takes NO rising-edge gate
2063
- * and NO per-turn cap, which is the pre-existing behavior kept exactly: a
2064
- * pull timeout costs at least the full budget (seconds), so the call cadence
2065
- * already bounds the volume, unlike `navRequest` below. The helper still
2066
- * supplies the identity discipline and the registry membership the sweep
2067
- * checks.
2068
- */
2069
- function recordPullTimeoutTelemetry(args) {
2070
- // #1771: this record is written on a genuine failure path (a dispatched
2071
- // pull that never answered), so per the bounded-telemetry rule
2072
- // (`clients/bounded-telemetry.ts`) it needs a `ledgerKind`, not just the
2073
- // detailed latency.log record. Before this it counted nothing in the
2074
- // degradation ledger — every occurrence wrote a detail line, but nothing
2075
- // tallied. No `risingEdgePer`: timeouts repeat and every one should count,
2076
- // matching the pre-existing "no rising-edge gate" behavior for the
2077
- // detailed record itself.
2078
- emitBounded("lsp_pull_diagnostic_timeout", `${args.server}::${args.scope}::${args.identifier ?? "bare"}`, {
2079
- type: "phase",
2080
- filePath: args.scope,
2081
- durationMs: args.elapsedMs,
2082
- metadata: {
2083
- identifier: args.identifier ?? "bare",
2084
- effectiveBudgetMs: args.effectiveBudgetMs,
2085
- hadPreviousResultId: args.hadPreviousResultId,
2086
- server: args.server,
2087
- },
2088
- }, {
2089
- ledgerKind: "lsp-pull-diagnostic-timeout",
2090
- reason: `pull timeout on ${args.server} after ${args.elapsedMs}ms (budget ${args.effectiveBudgetMs}ms)`,
2091
- });
2092
- }
2093
- /**
2094
- * #1713: a telemetry-only continuation on the ALREADY-abandoned request
2095
- * promise. `withTimeout` only raced it against a timer — the request is still
2096
- * live server-side — so if it eventually resolves, this is the only place that
2097
- * ever finds out. Purely observational: it runs after the caller has already
2098
- * moved on with `unavailable`, and it never writes into `state`.
2099
- *
2100
- * Bounded via the degradation-ledger convention (`incrementDegradationCount`,
2101
- * chosen over a hand-rolled per-identity rising-edge Set): the ledger already
2102
- * caps entries per kind and re-arms at `session_start`
2103
- * (`resetDegradationLedger`, wired into `handleSessionStart`), so reusing it
2104
- * avoids a second latch this fix would otherwise have to register and reset
2105
- * itself. `subject` is the same `pullSourceKey` (path + identifier) the pull
2106
- * path already uses, so the discriminating identity survives aggregation.
2107
- *
2108
- * Attaching `.then` here creates no timer, socket, or other handle — it only
2109
- * observes a promise the request already created — so it cannot keep the
2110
- * process alive a moment longer than that pending request already does.
2111
- */
2112
- function armLateAnswerTelemetry(args) {
2113
- args.requestPromise.then(() => {
2114
- if (args.settlement.cancelled)
2115
- return;
2116
- try {
2117
- const elapsedMs = Date.now() - args.requestStartedAt;
2118
- // #1743: `emitBounded` counts every late answer in the ledger
2119
- // under the same `pullSourceKey` subject as before, then writes
2120
- // the record. No rising-edge gate, matching #1713's original
2121
- // behavior: a late answer arrives at most once per abandoned
2122
- // request, so the record count cannot exceed the timeout count.
2123
- emitBounded("lsp_pull_late_answer_discarded", args.subject, {
2124
- type: "phase",
2125
- filePath: args.scope,
2126
- durationMs: elapsedMs,
2127
- metadata: { identifier: args.identifier ?? "bare" },
2128
- }, {
2129
- ledgerKind: "lsp-pull-late-answer",
2130
- reason: `late pull answer discarded after ${elapsedMs}ms`,
2131
- });
2132
- }
2133
- catch {
2134
- // Telemetry must never break the observed path.
2135
- }
2136
- }, (err) => {
2137
- // #1774: the abandoned request eventually REJECTED rather than
2138
- // answering. Behavior is unchanged — the rejection is still
2139
- // swallowed here, exactly as before — but "timeout then silence"
2140
- // and "timeout then server rejection (e.g. ContentModified)" were
2141
- // previously indistinguishable: `recordPullFailure` at request
2142
- // time reports the FIRST failure only, and this rejection happens
2143
- // strictly after that request already timed out, so it needs its
2144
- // own record. Bounded via the degradation ledger, same shape as
2145
- // the late-answer branch above, so this rejection cannot outpace
2146
- // the timeout count that gates it.
2147
- try {
2148
- const elapsedMs = Date.now() - args.requestStartedAt;
2149
- const candidate = err;
2150
- const code = typeof candidate?.code === "number" ||
2151
- typeof candidate?.code === "string"
2152
- ? candidate.code
2153
- : undefined;
2154
- emitBounded("lsp_pull_late_rejection",
2155
- // #1774 review: prefix the server, same reason the timeout kind
2156
- // does (#1771). The workspace call site's `subject` is the bare
2157
- // `WORKSPACE_PULL_SCOPE` constant — without the server prefix,
2158
- // two servers' abandoned-workspace-pull rejections would collapse
2159
- // into one ledger subject and hide which server is storming.
2160
- `${args.server}::${args.subject}`, {
2161
- type: "phase",
2162
- filePath: args.scope,
2163
- durationMs: elapsedMs,
2164
- metadata: {
2165
- identifier: args.identifier ?? "bare",
2166
- server: args.server,
2167
- ...(code !== undefined && { code }),
2168
- },
2169
- }, {
2170
- ledgerKind: "lsp-pull-late-rejection",
2171
- reason: `late pull rejection ${elapsedMs}ms after timeout${code !== undefined ? ` (code ${code})` : ""}`,
2172
- });
2173
- }
2174
- catch {
2175
- // Telemetry must never break the observed path.
2176
- }
2177
- });
2178
- }
2179
- const PULL_FAILURE_HISTORY_LIMIT = 10;
2180
- function recordPullFailure(state, method, error) {
2181
- const candidate = error;
2182
- const message = typeof candidate.message === "string" ? candidate.message : "";
2183
- const unsupportedMessage = /^(?:method not found|unknown method|unsupported method)(?::|$)/i;
2184
- if (candidate.code === -32601 ||
2185
- candidate.code === "-32601" ||
2186
- unsupportedMessage.test(message.trim()))
2187
- return;
2188
- state.pullFailureHistory.push({
2189
- timestamp: Date.now(),
2190
- method,
2191
- ...(typeof candidate.code === "number" || typeof candidate.code === "string"
2192
- ? { code: candidate.code }
2193
- : {}),
2194
- message: typeof candidate.message === "string" ? candidate.message : String(error),
2195
- });
2196
- if (state.pullFailureHistory.length > PULL_FAILURE_HISTORY_LIMIT) {
2197
- state.pullFailureHistory.splice(0, state.pullFailureHistory.length - PULL_FAILURE_HISTORY_LIMIT);
2198
- }
2199
- }
2200
- /**
2201
- * One project-wide `workspace/diagnostic` pull — a single request that returns
2202
- * diagnostics for every document the server knows, instead of opening N files.
2203
- * Returns per-file reports, or `undefined` on unsupported/dead/timeout/malformed
2204
- * (caller falls back to the per-file path). `unchanged`-kind items carry no
2205
- * diagnostics and are skipped, so a file absent from the result is "clean".
2206
- */
2207
- export async function clientRequestWorkspaceDiagnostics(state, budgetMs) {
2208
- if (!isClientAlive(state))
2209
- return undefined;
2210
- if (!state.workspaceDiagnosticsSupport.workspaceDiagnostics)
2211
- return undefined;
2212
- if (state.abandonedPullRequests?.has(WORKSPACE_PULL_SCOPE))
2213
- return undefined;
2214
- // #1104: echo every resultId we hold from a PRIOR pull so the server can
2215
- // answer `kind: "unchanged"` for files it hasn't recomputed, instead of
2216
- // resending (and us re-hashing) every file on every sweep.
2217
- const previousResultIds = Array.from(state.workspacePullResultCache.values()).map((entry) => ({ uri: entry.uri, value: entry.resultId }));
2218
- // #1773: same floor as `pullDiagnosticSource` — an exhausted budget here is
2219
- // the SAME clamp-then-dispatch defect via a second entry point. Observed
2220
- // live in the 2026-08-20 plegma dogfood session via
2221
- // `lsp_workspace_diagnostics_start` (not just the `lens_diagnostics_full`
2222
- // fan-out `pullDiagnosticSource` covers), so this call site needs its own
2223
- // skip, not just a shared helper the caller happens to hit.
2224
- if (budgetMs < PULL_MIN_USABLE_BUDGET_MS) {
2225
- emitBounded("lsp_pull_skipped_budget_exhausted", `${state.serverId}::${WORKSPACE_PULL_SCOPE}`, {
2226
- type: "phase",
2227
- filePath: WORKSPACE_PULL_SCOPE,
2228
- durationMs: 0,
2229
- metadata: {
2230
- identifier: "bare",
2231
- remainingBudgetMs: budgetMs,
2232
- server: state.serverId,
2233
- },
2234
- }, {
2235
- ledgerKind: "lsp-pull-skipped-budget-exhausted",
2236
- reason: `workspace pull skipped on ${state.serverId}: budget already exhausted (${budgetMs}ms remaining)`,
2237
- });
2238
- return undefined;
2239
- }
2240
- // #1713: declared OUTSIDE the try so the catch below can still reach them —
2241
- // same shape as `pullDiagnosticSource` above. Captured outside `withTimeout`
2242
- // so a timeout still holds a live handle on the request for the late-answer
2243
- // watch.
2244
- const workspacePullStartedAt = Date.now();
2245
- const workspacePullTimeoutMs = Math.max(1, budgetMs);
2246
- const workspacePullAbort = new AbortController();
2247
- const workspacePullSettlement = { cancelled: false };
2248
- const workspaceRequestPromise = safeSendRequest(state.connection, "workspace/diagnostic", { previousResultIds }, workspacePullAbort.signal, workspacePullSettlement);
2249
- try {
2250
- const report = await withTimeout(workspaceRequestPromise, workspacePullTimeoutMs);
2251
- if (!report || !Array.isArray(report.items))
2252
- return undefined;
2253
- const out = [];
2254
- // #1669 review N1: ONE deadline, shared across every "unchanged, no
2255
- // cached basis" fallback pull this call makes — not a fresh `budgetMs`
2256
- // grant handed to EACH one. A refresh clears `workspacePullResultCache`,
2257
- // so the very next sweep after a refresh can route every item down this
2258
- // path; granting each one the full budget serially turned a bounded call
2259
- // unbounded (measured: 6 files x 300ms budget = 1844ms; worst case is
2260
- // the normal case — 2000 files x 30s). Mirrors the shared-budget shape
2261
- // `runWorkspaceDiagnosticsSwept`'s `perFileMs` bounds each file to,
2262
- // scoped to fallback pulls made from within THIS one call.
2263
- const fallbackPullDeadline = Date.now() + Math.max(1, budgetMs);
2264
- for (const item of report.items) {
2265
- if (!item?.uri)
2266
- continue;
2267
- const filePath = uriToPath(item.uri);
2268
- const normalizedPath = normalizeMapKey(filePath);
2269
- if (item.kind === "unchanged") {
2270
- // #1104: inherit the prior pull's diagnostics + content binding for
2271
- // the SAME resultId basis — an "unchanged" report never carries
2272
- // `items`, so without this a file the server confirmed unchanged
2273
- // would silently drop out of the sweep result entirely.
2274
- const prior = state.workspacePullResultCache.get(normalizedPath);
2275
- if (!prior) {
2276
- // #1669 review F4: no earlier basis to inherit (e.g. right after a
2277
- // `workspace/diagnostic/refresh` cleared `workspacePullResultCache`)
2278
- // — the server is answering "unchanged" against a resultId basis we
2279
- // no longer hold, so there is nothing honest to report for this
2280
- // file from THIS response. `continue`-ing here would make the file
2281
- // silently absent from `out`, which the caller reads as "clean" —
2282
- // a false clean for a file the server just told us it hasn't even
2283
- // looked at freshly. Treat it as needs-full-pull: request the
2284
- // per-file diagnostic directly instead.
2285
- const remainingMs = fallbackPullDeadline - Date.now();
2286
- if (remainingMs <= 0) {
2287
- // #1669 review N1: the shared fallback-pull deadline is already
2288
- // exhausted by earlier files in this same loop — bail WITHOUT
2289
- // attempting a request (never fabricate clean; this file was
2290
- // never actually asked about) and surface it as unavailable via
2291
- // the same degradation ledger path as a real unavailable pull.
2292
- incrementDegradationCount({
2293
- kind: "lsp-pull-unconfirmed",
2294
- subject: state.serverId,
2295
- reason: "unchanged report with no workspacePullResultCache basis; the shared fallback-pull deadline was already exhausted by earlier files this sweep",
2296
- });
2297
- continue;
2298
- }
2299
- const outcome = await clientRequestPullDiagnostics(state, filePath, remainingMs);
2300
- if (outcome.status === "found") {
2301
- out.push({
2302
- filePath,
2303
- diagnostics: state.documentPullDiagnostics.get(normalizedPath) ?? [],
2304
- contentHash: state.diagnosticBindings.get(normalizedPath)?.contentHash,
2305
- });
2306
- }
2307
- else if (outcome.status === "unavailable") {
2308
- // Genuinely unconfirmed — never fabricate a clean result. Recorded
2309
- // via the shared degradation ledger (AGENTS.md: repeated
2310
- // degradations use incrementDegradationCount, not a hand-rolled
2311
- // counter) so a server that keeps racing refresh against sweeps
2312
- // is visible in aggregate instead of silently absorbed per-file.
2313
- incrementDegradationCount({
2314
- kind: "lsp-pull-unconfirmed",
2315
- subject: state.serverId,
2316
- reason: "unchanged report with no workspacePullResultCache basis, and the per-file fallback pull was unavailable",
2317
- });
2318
- }
2319
- // "clean": genuinely clean, correctly absent from `out`.
2320
- continue;
2321
- }
2322
- if (item.resultId !== undefined) {
2323
- state.workspacePullResultCache.set(normalizedPath, {
2324
- ...prior,
2325
- resultId: item.resultId,
2326
- });
2327
- }
2328
- out.push({
2329
- filePath,
2330
- diagnostics: prior.diagnostics,
2331
- contentHash: prior.contentHash,
2332
- });
2333
- continue;
2334
- }
2335
- // "full" (or a non-conforming server omitting `kind`, per the LSP
2336
- // default) — recompute and re-fingerprint.
2337
- const diagnostics = normalizeLspDiagnostics(item.items ?? []);
2338
- // #1104: fingerprint the file bytes active AT REQUEST TIME. Best-effort —
2339
- // a read failure (deleted/unreadable mid-sweep) just leaves contentHash
2340
- // undefined, so the binding reads honestly "unknown", never fabricated.
2341
- let contentHash;
2342
- try {
2343
- contentHash = hashDiagnosticContent(await readFile(filePath, "utf-8"));
2344
- }
2345
- catch {
2346
- contentHash = undefined;
2347
- }
2348
- if (item.resultId !== undefined) {
2349
- if (state.workspacePullResultCache.size >= WORKSPACE_PULL_RESULT_CACHE_MAX) {
2350
- state.workspacePullResultCache.clear();
2351
- }
2352
- state.workspacePullResultCache.set(normalizedPath, {
2353
- uri: item.uri,
2354
- resultId: item.resultId,
2355
- diagnostics,
2356
- contentHash,
2357
- });
2358
- }
2359
- else {
2360
- state.workspacePullResultCache.delete(normalizedPath);
2361
- }
2362
- out.push({ filePath, diagnostics, contentHash });
2363
- }
2364
- return out;
2365
- }
2366
- catch (err) {
2367
- if (isPullTimeoutError(err, workspacePullTimeoutMs)) {
2368
- trackAbandonedPullRequest(state, WORKSPACE_PULL_SCOPE, workspaceRequestPromise);
2369
- workspacePullAbort.abort();
2370
- recordPullTimeoutTelemetry({
2371
- scope: WORKSPACE_PULL_SCOPE,
2372
- identifier: undefined,
2373
- effectiveBudgetMs: workspacePullTimeoutMs,
2374
- hadPreviousResultId: previousResultIds.length > 0,
2375
- elapsedMs: Date.now() - workspacePullStartedAt,
2376
- server: state.serverId,
2377
- });
2378
- armLateAnswerTelemetry({
2379
- requestPromise: workspaceRequestPromise,
2380
- scope: WORKSPACE_PULL_SCOPE,
2381
- identifier: undefined,
2382
- subject: WORKSPACE_PULL_SCOPE,
2383
- requestStartedAt: workspacePullStartedAt,
2384
- server: state.serverId,
2385
- settlement: workspacePullSettlement,
2386
- });
2387
- }
2388
- recordPullFailure(state, "workspace/diagnostic", err);
2389
- return undefined;
2390
- }
2391
- }
2392
- export async function clientWaitForDiagnostics(state, filePath, timeoutMs, options = {}) {
2393
- const normalizedPath = normalizeMapKey(filePath);
2394
- const minVersion = options.minVersion;
2395
- const pullSettleSource = options.pullSettleSource ?? "pull";
2396
- // #1531: the freshness gate is PER PATH. `minVersion` is a reading of the
2397
- // client-global counter, and `diagnosticsVersionsByPath` stores that same
2398
- // counter's value at each store — the two are on one axis, so comparing them
2399
- // asks "has a publication landed for THIS file since the baseline?" instead of
2400
- // "has anything at all landed on this client?". Reading the global counter here
2401
- // let a sibling file's publication end this file's wait before its own budget
2402
- // lapsed, which then labelled the outcome row `silent` (reserved for "own
2403
- // budget lapsed with nothing published") instead of `cut_off`.
2404
- const hasFreshDiagnostics = () => minVersion === undefined ||
2405
- diagnosticsVersionForPath(state, normalizedPath) > minVersion;
2406
- // Version coherence: a cached push is "stale" only when the server reported
2407
- // the document version it computed against AND that version lags the latest
2408
- // didChange we sent. This prevents serving diagnostics from a superseded
2409
- // version as fresh (e.g. once the redundant double-push is collapsed and the
2410
- // dispatch wait runs without a push-counter baseline — #203). Unknown version
2411
- // (server omits it) is treated as current so version-less servers are
2412
- // unaffected, and the timeout remains the backstop.
2413
- const isVersionStale = () => {
2414
- const cachedVersion = state.diagnosticDocVersions?.get(normalizedPath);
2415
- if (cachedVersion === undefined)
2416
- return false;
2417
- const currentVersion = state.documentVersions?.get(normalizedPath);
2418
- return currentVersion !== undefined && cachedVersion < currentVersion;
2419
- };
2420
- if (state.workspaceDiagnosticsSupport.mode === "pull") {
2421
- // Pull is authoritative. An AFFIRMATIVE outcome — diagnostics `found`, or
2422
- // an authoritative empty `clean` report — ends the wait. An `unavailable`
2423
- // pull (dead client / no reply / thrown) is NOT clean and must not
2424
- // short-circuit: fall through to the push-wait/timeout backstop. This is
2425
- // the #240 fix — previously the early-return also fired on
2426
- // `hasFreshDiagnostics()`, which is unconditionally true when there is no
2427
- // version baseline (`minVersion === undefined`), so a failed pull returned
2428
- // 0 and was read as a fresh clean.
2429
- // #1639: the settle operation's OWN clock — durationMs on the eventual
2430
- // log record measures from here, never from didOpen (that stays in
2431
- // metadata.elapsedSinceDidOpenMs, computed separately below).
2432
- const pullSettleStartedAt = Date.now();
2433
- let outcome = await clientRequestPullDiagnostics(state, filePath, timeoutMs);
2434
- if (outcome.status === "found") {
2435
- logTypeScriptPullSettle(state, normalizedPath, Date.now() - pullSettleStartedAt, pullSettleSource);
2436
- return;
2437
- }
2438
- let sawClean = outcome.status === "clean";
2439
- const strategy = getStrategy(state.serverId, state.launchVariant);
2440
- const retryBudgetMs = strategy.pullRetryBudgetMs > 0
2441
- ? Math.min(timeoutMs, strategy.pullRetryBudgetMs)
2442
- : 0;
2443
- const startedAt = Date.now();
2444
- // Retry within budget to catch incremental servers whose first pull is
2445
- // empty while analysis is still running (rust-analyzer). A `clean` seen at
2446
- // any point is a valid affirmative answer for this touch.
2447
- while (outcome.status !== "found" &&
2448
- Date.now() - startedAt < retryBudgetMs) {
2449
- await new Promise((resolve) => setTimeout(resolve, PULL_DIAGNOSTICS_RETRY_INTERVAL_MS));
2450
- outcome = await clientRequestPullDiagnostics(state, filePath, Math.max(0, retryBudgetMs - (Date.now() - startedAt)));
2451
- if (outcome.status === "clean")
2452
- sawClean = true;
2453
- }
2454
- if (options.pullOnly) {
2455
- if (outcome.status === "found" || sawClean) {
2456
- logTypeScriptPullSettle(state, normalizedPath, Date.now() - pullSettleStartedAt, pullSettleSource);
2457
- }
2458
- return;
2459
- }
2460
- if (outcome.status === "found" || sawClean) {
2461
- logTypeScriptPullSettle(state, normalizedPath, Date.now() - pullSettleStartedAt, pullSettleSource);
2462
- return;
2463
- }
2464
- }
2465
- if (hasFreshDiagnostics() &&
2466
- !isVersionStale() &&
2467
- getMergedDiagnosticsForPath(state, normalizedPath).length > 0) {
2468
- return;
2469
- }
2470
- return new Promise((resolve) => {
2471
- let debounceTimer;
2472
- const onDiagnostics = (fp) => {
2473
- if (normalizeMapKey(fp) !== normalizedPath)
2474
- return;
2475
- if (!hasFreshDiagnostics() || isVersionStale())
2476
- return;
2477
- if (debounceTimer)
2478
- clearTimeout(debounceTimer);
2479
- // Adaptive debounce: use time since last push to compute remaining
2480
- // wait instead of always waiting the full debounce window.
2481
- const strategy = getStrategy(state.serverId, state.launchVariant);
2482
- const hit = state.pushDiagnosticTimestamps.get(normalizedPath);
2483
- const timeSincePush = hit ? Date.now() - hit : Infinity;
2484
- const remaining = Math.max(0, strategy.debounceMs - timeSincePush);
2485
- debounceTimer = setTimeout(() => {
2486
- state.diagnosticEmitter.off("diagnostics", onDiagnostics);
2487
- clearTimeout(timeout);
2488
- resolve();
2489
- }, remaining);
2490
- };
2491
- state.diagnosticEmitter.on("diagnostics", onDiagnostics);
2492
- const timeout = setTimeout(() => {
2493
- if (debounceTimer)
2494
- clearTimeout(debounceTimer);
2495
- state.diagnosticEmitter.off("diagnostics", onDiagnostics);
2496
- resolve();
2497
- }, timeoutMs);
2498
- });
2499
- }
2500
- /**
2501
- * Queue a watched-files change for a file this client did not learn about
2502
- * through textDocument/didOpen or didChange — an external bash write/delete,
2503
- * or any other disk change outside the open-document sync path (#1668).
2504
- * Shares `handleNotifyOpen`'s per-client debounced queue (#271), so a burst
2505
- * of external changes coalesces into one notification per debounce window
2506
- * instead of flooding the server with one per file.
2507
- */
2508
- export function handleNotifyExternalChange(state, filePath, type) {
2509
- if (!isClientAlive(state))
2510
- return;
2511
- const normalizedPath = normalizeMapKey(filePath);
2512
- const uri = state.openDocumentUris?.get(normalizedPath) ?? pathToFileURL(filePath).href;
2513
- state.watchQueue.enqueue(uri, type);
2514
- }
2515
- /**
2516
- * #3405: tell the server the document it just received is the file's saved
2517
- * on-disk state.
2518
- *
2519
- * pi-lens has no unsaved-buffer concept — every notification it sends carries
2520
- * bytes read from disk — so a save-triggered server is the one class of server
2521
- * this client could never reach: `CLIENT_CAPABILITIES` has advertised
2522
- * `synchronization.didSave` since #278 and no caller emitted one, so Expert
2523
- * (whose ONLY whole-project recompile trigger is didSave —
2524
- * `expert-lsp/expert@6bbad8c` `apps/expert/lib/expert/state.ex:243-257`)
2525
- * published nothing for an edit made through pi-lens.
2526
- *
2527
- * Sent ONLY when the caller declared this touch a save (`saved`) AND the server
2528
- * declared `textDocumentSync.save`; `includeText` decides whether the text
2529
- * rides along. Both gates fail closed: an undeclared notification is the #278
2530
- * class of hazard, and a save claimed for content whose didOpen/didChange never
2531
- * left the process would tell the server to diagnose bytes it does not have —
2532
- * so every call site below sends this only after its own content notification
2533
- * returned `true` from `safeSendNotification`, and never for a document this
2534
- * client has not opened.
2535
- */
2536
- async function sendDidSave(state, uri, content) {
2537
- const save = state.saveOptions;
2538
- if (!save)
2539
- return;
2540
- if (!isClientAlive(state))
2541
- return;
2542
- // #3405 r2 (M3406-1): `text` is a SECOND full copy of bytes the server was
2543
- // just handed, so it is the one payload here that can be file-sized. The
2544
- // explicit `lsp_diagnostics` writer reads whole files with no bound of its
2545
- // own, so without this the same 32MB string would be framed twice. Bound it
2546
- // at the shared seam both writers pass through rather than at either caller.
2547
- //
2548
- // Omitting `text` is what the contract tolerates, and it is not a lost save:
2549
- // `DidSaveTextDocumentParams.text` is OPTIONAL ("Optional the content when
2550
- // saved") in the spec, FsAutoComplete — the one `includeText: true` server in
2551
- // the registry — types it `string option` and, given `None`, keeps the source
2552
- // it already holds and still runs its re-check
2553
- // (ionide/FsAutoComplete@85886b1, AdaptiveServerState.fs lines 2649-2673),
2554
- // and pi-lens only ever sends this after a landed didOpen/didChange for the
2555
- // same document, so the server has these bytes either way. Skipping the save
2556
- // outright would instead drop the diagnose trigger this PR exists to restore.
2557
- const limit = save.includeText
2558
- ? exceedsLspSyncLimits(content)
2559
- : { tooLarge: false, reason: "" };
2560
- if (limit.tooLarge) {
2561
- recordDegradationOnce({
2562
- kind: "lsp-did-save-text-omitted",
2563
- subject: state.serverId,
2564
- reason: limit.reason,
2565
- });
2566
- }
2567
- await safeSendNotification(state.connection, "textDocument/didSave", {
2568
- textDocument: { uri },
2569
- ...(save.includeText && !limit.tooLarge ? { text: content } : {}),
2570
- });
2571
- }
2572
- async function handleNotifyOpenOnce(state, filePath, content, languageId, preserveDiagnostics = false, silent = false, coalescedCount = 0, saved = false) {
2573
- if (!isClientAlive(state))
2574
- return;
2575
- const normalizedPath = normalizeMapKey(filePath);
2576
- const uri = state.openDocumentUris?.get(normalizedPath) ?? pathToFileURL(filePath).href;
2577
- if (state.openDocuments.has(normalizedPath) ||
2578
- state.pendingOpens.has(normalizedPath)) {
2579
- const version = (state.documentVersions.get(normalizedPath) ?? 0) + 1;
2580
- state.documentVersions.set(normalizedPath, version);
2581
- // preserveDiagnostics: skip cache clear for format-only resyncs so
2582
- // waitForDiagnostics fast-paths instead of waiting up to 5s for TypeScript
2583
- // to re-publish what it already knows (formatting doesn't change semantics).
2584
- // #1095 note: this also retains the prior content `binding`. Until the
2585
- // server republishes for the new version, a binding read compares the OLD
2586
- // content hash against the NEW disk bytes → boundToCurrentDisk `false` →
2587
- // the consumer demotes to inconclusive. That's the SAFE (#533) direction (a
2588
- // transient "unconfirmed", never a false-clean), self-healing on the next
2589
- // publish — not a correctness hazard, unlike the reopen false-TRUE above.
2590
- if (!preserveDiagnostics) {
2591
- clearDiagnosticsForPath(state, normalizedPath);
2592
- }
2593
- // Scanners that only re-scan on a fresh open (opengrep ignores didChange):
2594
- // close + reopen so the re-edit actually triggers a re-scan instead of
2595
- // silently publishing nothing.
2596
- if (getStrategy(state.serverId, state.launchVariant).reopenOnResync) {
2597
- await safeSendNotification(state.connection, "textDocument/didClose", {
2598
- textDocument: { uri },
2599
- });
2600
- state.openDocuments.delete(normalizedPath);
2601
- state.openDocumentUris?.delete(normalizedPath);
2602
- // #1095 (P2-3): carry the version counter FORWARD across the
2603
- // close+reopen instead of resetting to 0. LSP lets a didOpen use any
2604
- // version, and reusing 0 for successive resyncs made the version
2605
- // ambiguous — a late publish for an earlier resync's content echoed the
2606
- // SAME 0 as the current send, so the superseded-push guard (0 < 0 is
2607
- // false) accepted it and `recordBinding` bound STALE diagnostics to the
2608
- // CURRENT content's fingerprint → an affirmative boundToCurrentDisk TRUE
2609
- // for a stale view (worse than "unknown"). Monotonic versions make that
2610
- // late echo strictly older → dropped by isSupersededPush → never bound.
2611
- state.documentVersions.set(normalizedPath, version);
2612
- state.documentOpenedAt.set(normalizedPath, Date.now());
2613
- state.diagnosticPublicationCounts.set(normalizedPath, 0);
2614
- if (!isClientAlive(state))
2615
- return;
2616
- const reopenSent = await safeSendNotification(state.connection, "textDocument/didOpen", { textDocument: { uri, languageId, version, text: content } });
2617
- // #1669 review F7: only mirror the send locally once it actually left
2618
- // the process — see safeSendNotification's doc comment.
2619
- if (reopenSent)
2620
- recordSentContent(state, normalizedPath, version, content, coalescedCount);
2621
- state.openDocuments.add(normalizedPath);
2622
- state.openDocumentUris?.set(normalizedPath, uri);
2623
- if (saved && reopenSent)
2624
- await sendDidSave(state, uri, content);
2625
- return;
2626
- }
2627
- const changeSent = await safeSendNotification(state.connection, "textDocument/didChange", {
2628
- textDocument: { uri, version },
2629
- contentChanges: buildContentChanges(state, normalizedPath, content),
2630
- });
2631
- if (changeSent)
2632
- recordSentContent(state, normalizedPath, version, content, coalescedCount);
2633
- if (saved && changeSent)
2634
- await sendDidSave(state, uri, content);
2635
- return;
2636
- }
2637
- state.pendingOpens.add(normalizedPath);
2638
- state.documentVersions.set(normalizedPath, 0);
2639
- state.documentOpenedAt.set(normalizedPath, Date.now());
2640
- state.diagnosticPublicationCounts.set(normalizedPath, 0);
2641
- clearDiagnosticsForPath(state, normalizedPath); // always clear for initial open
2642
- // Send workspace notification first (like opencode does).
2643
- // Skipped in silent mode — cascade reads a file for diagnostics,
2644
- // not reporting a real filesystem change. Avoids N project-wide
2645
- // rechecks on push-diagnostics LSPs (TypeScript, Python) per CR-1.
2646
- if (!silent) {
2647
- // Async existence probe (was a synchronous existsSync on the document-open
2648
- // path — a stat that blocks the loop during first-read/warm). The notify
2649
- // type is unchanged: 2 (Changed) when the file exists on disk, else 1
2650
- // (Created). access() rejects when absent.
2651
- let fileExists = true;
2652
- try {
2653
- await access(filePath);
2654
- }
2655
- catch {
2656
- fileExists = false;
2657
- }
2658
- // #271: enqueue instead of sending now — the per-client queue coalesces a
2659
- // turn's file opens into a single notification, so push-diagnostics servers
2660
- // re-analyze the project once per burst rather than once per file. didOpen
2661
- // (below) still carries this file's content immediately, so the open
2662
- // document is analyzed without waiting on the batched watcher notify.
2663
- state.watchQueue.enqueue(uri, fileExists ? 2 : 1);
2664
- }
2665
- if (!isClientAlive(state))
2666
- return;
2667
- const openSent = await safeSendNotification(state.connection, "textDocument/didOpen", { textDocument: { uri, languageId, version: 0, text: content } });
2668
- if (openSent)
2669
- recordSentContent(state, normalizedPath, 0, content, coalescedCount);
2670
- state.pendingOpens.delete(normalizedPath);
2671
- state.openDocuments.add(normalizedPath);
2672
- state.closedDocuments?.delete(normalizedPath);
2673
- state.openDocumentUris?.set(normalizedPath, uri);
2674
- if (saved && openSent)
2675
- await sendDidSave(state, uri, content);
2676
- // Telemetry is deliberately detached after didOpen succeeds.
2677
- // #1412 H1: routed through runReadOnlyServerCommand, NOT runServerCommand —
2678
- // the probe must never open the serverEditsAllowed/activeMutationContext
2679
- // mutation-acceptance window; it is a diagnostic sample, not a mutation, and
2680
- // carries its own short PROBE_COMMAND_TIMEOUT_MS backstop. The probe itself
2681
- // is classic-only and swallows every failure.
2682
- void probeTsserverProjectIdentity({
2683
- serverId: state.serverId,
2684
- launchVariant: state.launchVariant,
2685
- clientRoot: state.root,
2686
- file: filePath,
2687
- normalizedFile: normalizedPath,
2688
- probedFiles: state.projectIdentityProbedFiles ??
2689
- (state.projectIdentityProbedFiles = new Set()),
2690
- commandChannel: {
2691
- executeCommand: (command, args) => runReadOnlyServerCommand(state, command, args),
2692
- },
2693
- });
2694
- }
2695
- /**
2696
- * Replace only notifications that have not started a transport write. A
2697
- * microtask starts the first entry so a synchronous burst of callers installs
2698
- * one latest entry before any bytes are handed to vscode-jsonrpc. Once an
2699
- * entry starts, it runs to completion; LSP has no cancellation/acknowledgement
2700
- * for notifications, so dropping a started write could violate didOpen before
2701
- * didChange ordering or leave a partial message in the pipe.
2702
- */
2703
- function enqueueDocumentNotify(state, normalizedPath, run, saved = false) {
2704
- let queue = state.notifyChangeQueues.get(normalizedPath);
2705
- if (!queue) {
2706
- queue = { running: false };
2707
- state.notifyChangeQueues.set(normalizedPath, queue);
2708
- }
2709
- return new Promise((resolve, reject) => {
2710
- const previous = queue?.pending;
2711
- // Keep superseded callers attached to the replacement's completion. The
2712
- // notification is dropped, but callers such as the auxiliary backlog
2713
- // ledger must not observe completion before the newest content is sent.
2714
- const waiters = previous?.waiters ?? [];
2715
- waiters.push({ resolve, reject });
2716
- queue.pending = {
2717
- run,
2718
- waiters,
2719
- coalescedCount: (previous?.coalescedCount ?? 0) + (previous ? 1 : 0),
2720
- saved: saved || previous?.saved === true,
2721
- };
2722
- if (queue.running)
2723
- return;
2724
- queue.running = true;
2725
- // Let same-turn callers replace the unwritten entry before the runner
2726
- // invokes the transport. Different path queues still start independently.
2727
- void Promise.resolve().then(async () => {
2728
- try {
2729
- for (;;) {
2730
- const next = queue.pending;
2731
- if (!next)
2732
- break;
2733
- queue.pending = undefined;
2734
- try {
2735
- await next.run(next.coalescedCount, next.saved);
2736
- for (const waiter of next.waiters)
2737
- waiter.resolve();
2738
- }
2739
- catch (error) {
2740
- for (const waiter of next.waiters)
2741
- waiter.reject(error);
2742
- }
2743
- }
2744
- }
2745
- finally {
2746
- queue.running = false;
2747
- if (!queue.pending &&
2748
- state.notifyChangeQueues.get(normalizedPath) === queue) {
2749
- state.notifyChangeQueues.delete(normalizedPath);
2750
- }
2751
- }
2752
- });
2753
- });
2754
- }
2755
- /** Drop unwritten document notifications when a client is torn down. */
2756
- function cancelDocumentNotifyQueues(state) {
2757
- for (const queue of state.notifyChangeQueues.values()) {
2758
- for (const waiter of queue.pending?.waiters ?? [])
2759
- waiter.resolve();
2760
- queue.pending = undefined;
2761
- }
2762
- state.notifyChangeQueues.clear();
2763
- }
2764
- export function handleNotifyOpen(state, filePath, content, languageId, preserveDiagnostics = false, silent = false, saved = false) {
2765
- if (!isClientAlive(state))
2766
- return Promise.resolve();
2767
- const normalizedPath = normalizeMapKey(filePath);
2768
- return enqueueDocumentNotify(state, normalizedPath, (coalescedCount, queuedSaved) => handleNotifyOpenOnce(state, filePath, content, languageId, preserveDiagnostics, silent, coalescedCount, queuedSaved), saved);
2769
- }
2770
- async function handleNotifyChangeOnce(state, filePath, content, normalizedPath, coalescedCount = 0) {
2771
- if (!isClientAlive(state))
2772
- return;
2773
- const uri = state.openDocumentUris?.get(normalizedPath) ?? pathToFileURL(filePath).href;
2774
- if (!state.openDocuments.has(normalizedPath)) {
2775
- // Safety fallback: keep protocol ordering valid even if caller sends
2776
- // didChange before first didOpen for this document.
2777
- const fallbackOpenSent = await safeSendNotification(state.connection, "textDocument/didOpen", {
2778
- textDocument: {
2779
- uri,
2780
- languageId: "plaintext",
2781
- version: 0,
2782
- text: content,
2783
- },
2784
- });
2785
- state.documentVersions.set(normalizedPath, 0);
2786
- state.documentOpenedAt.set(normalizedPath, Date.now());
2787
- state.diagnosticPublicationCounts.set(normalizedPath, 0);
2788
- if (fallbackOpenSent)
2789
- recordSentContent(state, normalizedPath, 0, content, coalescedCount);
2790
- state.openDocuments.add(normalizedPath);
2791
- state.openDocumentUris?.set(normalizedPath, uri);
2792
- return;
2793
- }
2794
- const version = (state.documentVersions.get(normalizedPath) ?? 0) + 1;
2795
- state.documentVersions.set(normalizedPath, version);
2796
- // Clear stale diagnostics before sending new content so waitForDiagnostics
2797
- // doesn't return immediately with the previous edit's results.
2798
- clearDiagnosticsForPath(state, normalizedPath);
2799
- const changeSent = await safeSendNotification(state.connection, "textDocument/didChange", {
2800
- textDocument: { uri, version },
2801
- contentChanges: buildContentChanges(state, normalizedPath, content),
2802
- });
2803
- if (changeSent)
2804
- recordSentContent(state, normalizedPath, version, content, coalescedCount);
2805
- }
2806
- /**
2807
- * #2113: serialize the read/build/send/record transaction per document. The
2808
- * content-change payload must be built against the content recorded by the
2809
- * preceding send for this path; unrelated paths retain parallel sends.
2810
- */
2811
- export function handleNotifyChange(state, filePath, content) {
2812
- if (!isClientAlive(state))
2813
- return Promise.resolve();
2814
- const normalizedPath = normalizeMapKey(filePath);
2815
- // #3405: no `saved` argument — `LSPService.updateFile` is this path's only
2816
- // entry point and no caller declares a save through it, so a change never
2817
- // originates one. A save intent inherited from a superseded open entry is
2818
- // dropped here by construction rather than sent after a bare didChange.
2819
- return enqueueDocumentNotify(state, normalizedPath, (coalescedCount) => handleNotifyChangeOnce(state, filePath, content, normalizedPath, coalescedCount));
2820
- }
2821
- /** Close a document through the same lifecycle path exposed by the client. */
2822
- export async function closeDocument(state, filePath) {
2823
- if (!isClientAlive(state))
2824
- return;
2825
- const normalizedPath = normalizeMapKey(filePath);
2826
- if (!state.openDocuments.has(normalizedPath))
2827
- return;
2828
- await safeSendNotification(state.connection, "textDocument/didClose", {
2829
- textDocument: {
2830
- uri: state.openDocumentUris?.get(normalizedPath) ??
2831
- pathToFileURL(filePath).href,
2832
- },
2833
- });
2834
- state.openDocuments.delete(normalizedPath);
2835
- state.closedDocuments?.add(normalizedPath);
2836
- state.openDocumentUris?.delete(normalizedPath);
2837
- state.documentVersions.delete(normalizedPath);
2838
- state.documentOpenedAt.delete(normalizedPath);
2839
- state.diagnosticPublicationCounts.delete(normalizedPath);
2840
- // #1412 L1: projectIdentityProbedFiles is a claim-once memo scoped to the
2841
- // document's open lifetime (re-probing a closed-then-reopened file is
2842
- // harmless and cheap) — mirror openDocuments' own per-close cleanup so it
2843
- // doesn't grow unbounded across a long session's worth of open/close churn.
2844
- state.projectIdentityProbedFiles?.delete(normalizedPath);
2845
- const retained = state.documentContentHashes.get(normalizedPath);
2846
- if (retained?.text !== undefined) {
2847
- state.incrementalTextRetainedEntries = Math.max(0, (state.incrementalTextRetainedEntries ?? 1) - 1);
2848
- state.incrementalTextRetainedBytes = Math.max(0, (state.incrementalTextRetainedBytes ?? retained.text.length * 2) -
2849
- retained.text.length * 2);
2850
- // #2065 fix round 2 N1: `recordSentContent` and the eviction loop both
2851
- // delete from `incrementalTextBearingPaths` when they strip a path's
2852
- // text, but neither runs on close — this was the missing third writer.
2853
- // Without it, a closed path's membership in the aux set survives
2854
- // forever: below the 128-entry/64 MiB cap the eviction loop that would
2855
- // otherwise clean it up never runs at all, so the set grows by one
2856
- // stale entry per open/close cycle with nothing ever draining it — the
2857
- // exact unbounded-per-path-store class #2065 exists to close, just
2858
- // moved into the aux index instead of the byte-retention count itself.
2859
- state.incrementalTextBearingPaths?.delete(normalizedPath);
2860
- }
2861
- // Keep the hash/version binding only while the document is open; the full
2862
- // text is needed for the next edit, never after didClose (#2065).
2863
- state.documentContentHashes.delete(normalizedPath);
2864
- clearDiagnosticsForPath(state, normalizedPath);
2865
- // #2065 fix round 2 (reverts fix round 1 F4): `pullGenerations` is the
2866
- // eighth per-path map in this family, but it is deliberately NOT cleared
2867
- // here — the same reasoning `clearDiagnosticsForPath` already documents
2868
- // above for `pullRequestSequences` applies to this counter too, and fix
2869
- // round 1's F4 missed it. `pullGenerationFor` defaults a missing entry to
2870
- // `0`, so deleting the entry on close resets the counter. A pull already
2871
- // in flight before the close captured its generation BEFORE this close's
2872
- // `clearDiagnosticsForPath` bump above ran. If the document is closed and
2873
- // reopened before that in-flight pull's write lands, deleting the entry
2874
- // here would let the counter read back as `0` again — the exact value the
2875
- // stale write's stale captured generation equals — so the write-time
2876
- // `!== generation` check at its call site would wrongly accept a stale
2877
- // answer computed against the pre-close content. Leaving the counter
2878
- // monotonic and un-reset keeps every pre-close capture permanently
2879
- // distinguishable from whatever the counter reads afterward, no matter how
2880
- // many closes or reopens happen in between. The cost is one integer per
2881
- // path that outlives the document's open lifetime — negligible next to
2882
- // correctness here.
2883
- }
2884
- /**
2885
- * #1620 residual review F1: `fast` and `processExiting` only ever escalate —
2886
- * a `true` demands strictly less work (or none) than a `false`, never more.
2887
- * `existing` covers `requested` when it is at least as demanding on BOTH
2888
- * axes, so deduping onto it can't silently downgrade what the new caller
2889
- * needs (e.g. a `processExiting` caller must never inherit a teardown that
2890
- * might still spawn `taskkill`).
2891
- */
2892
- function isAtLeastAsAggressiveShutdown(existing, requested) {
2893
- return (!!existing.fast >= !!requested.fast &&
2894
- !!existing.processExiting >= !!requested.processExiting);
2895
- }
2896
- export function clientShutdown(state, options = {}) {
2897
- // #1620 residual 3: idempotent across racing callers — see the doc comment
2898
- // on `LSPClientState.shutdownPromise`. The FIRST call starts the real
2899
- // teardown and stores its promise; every subsequent call (any of the 8
2900
- // call sites, on the same state) whose options are no MORE aggressive
2901
- // awaits that same promise instead of running the RPC handshake and
2902
- // emitting `lsp_client_shutdown` again.
2903
- //
2904
- // #1620 residual review F1: a call whose options ARE more aggressive does
2905
- // NOT dedupe — it starts (and takes over as) its own teardown instead.
2906
- // The concrete failure this closes: `resetLSPService`'s session-exit path
2907
- // (`index.ts`) calls `shutdown({fast:true, processExiting:true})` — the
2908
- // event loop is already closing, and `processExiting` exists precisely so
2909
- // this teardown never spawns `taskkill` (a closing-loop `uv_async_send`
2910
- // hard-aborts, `src\win\async.c`). If a GRACEFUL teardown (default
2911
- // options — e.g. `client_ceiling_lru` eviction) is already in flight on
2912
- // this same client when the exit path arrives, blindly deduping would
2913
- // make the exit path inherit that graceful run's own `taskkill` spawn and
2914
- // its close-event wait — the exact hazard `processExiting` forbids, on
2915
- // top of blocking the closing loop for however long that takes. The
2916
- // weaker in-flight teardown is left to finish on its own (every step
2917
- // downstream — dispose, kill, deregister — is already idempotent).
2918
- if (state.shutdownPromise &&
2919
- state.shutdownOptions &&
2920
- isAtLeastAsAggressiveShutdown(state.shutdownOptions, options)) {
2921
- return state.shutdownPromise;
2922
- }
2923
- state.shutdownOptions = options;
2924
- const attempt = clientShutdownOnce(state, options);
2925
- state.shutdownPromise = attempt;
2926
- // #1620 residual review F3: a REJECTED teardown (e.g. `killProcessTree`
2927
- // throwing something its own internal catches don't cover) must not
2928
- // latch a permanently-rejected promise here — every later call, even one
2929
- // with equal or weaker options, would dedupe onto it forever and never
2930
- // retry, so the child leaks silently with no further kill/dispose/
2931
- // deregister attempt. Clear the latch on rejection (only if nothing more
2932
- // aggressive has already superseded it) so the NEXT call starts a fresh
2933
- // attempt instead of re-handing back the same dead promise. The rejection
2934
- // itself still propagates to whoever is awaiting `attempt` right now —
2935
- // this only affects callers that haven't asked yet.
2936
- attempt.catch(() => {
2937
- if (state.shutdownPromise === attempt) {
2938
- state.shutdownPromise = undefined;
2939
- state.shutdownOptions = undefined;
2940
- }
2941
- });
2942
- return attempt;
2943
- }
2944
- async function clientShutdownOnce(state, options) {
2945
- const shutdownStart = Date.now();
2946
- state.shutdownRequested = true;
2947
- state.isConnected = false;
2948
- state.isDestroyed = true;
2949
- for (const timer of state.pendingDiagnostics.values()) {
2950
- clearTimeout(timer);
2951
- }
2952
- state.pendingDiagnostics.clear();
2953
- state.pendingOpens.clear();
2954
- state.openDocuments.clear();
2955
- state.openDocumentUris?.clear();
2956
- // #2357: superseded notifications that have not started writing are moot
2957
- // once this client is dead; resolve their callers and release the queue.
2958
- cancelDocumentNotifyQueues(state);
2959
- // #1412 L1: mirror openDocuments' clear — a shut-down/evicted client's
2960
- // probe memo is moot along with everything else document-scoped.
2961
- state.projectIdentityProbedFiles?.clear();
2962
- // #271: drop any pending watched-files batch + its timer (a dying client's
2963
- // queued FS changes are moot, and the timer must not outlive the connection).
2964
- state.watchQueue?.cancel();
2965
- state.diagnosticEmitter.removeAllListeners();
2966
- let shutdownRequestTimedOut = false;
2967
- let exitNotifyTimedOut = false;
2968
- // #1620 residual 1 / residual-review F2: a failure that ISN'T the timer
2969
- // winning was previously folded into the same "*TimedOut" flag as a real
2970
- // timeout — honest for the rolled-up `shutdownOutcome` (still "forced"
2971
- // either way), imprecise about WHICH failure happened. Two shapes land
2972
- // here, both meaning "no confirmation this reached the server":
2973
- // (a) a genuine promise rejection that isn't the timer (an immediate
2974
- // protocol error) — reaches the `catch` below.
2975
- // (b) `safeSendRequest`/`safeSendNotification` SWALLOW a stream error
2976
- // (EPIPE, disposed connection — `isStreamError`) and RESOLVE
2977
- // instead of rejecting, so the `catch` never runs at all. A real
2978
- // `shutdown` reply is `null`, never `undefined`, and a real `exit`
2979
- // notify send returns `true`; `undefined`/`false` is the swallow
2980
- // case. Without this, a coded EPIPE reported `shutdownOutcome:
2981
- // "graceful"` — nothing was delivered, but the log called it clean.
2982
- let shutdownRequestUndelivered = false;
2983
- let exitNotifyUndelivered = false;
2984
- // #1620: the graceful handshake is BEST-EFFORT and the teardown is not. A
2985
- // teardown path must not depend on the health of the thing it is tearing
2986
- // down — the breaker fires exactly when the server is unresponsive. Keep the
2987
- // handshake inside the `try` and every irreversible teardown step in the
2988
- // `finally`, so disposal, the record, the deregistration, and the kill run no
2989
- // matter how the two writes behave, and so a future added await here cannot
2990
- // reintroduce the class.
2991
- try {
2992
- if (!options.fast) {
2993
- try {
2994
- const shutdownAck = await withTimeout(safeSendRequest(state.connection, "shutdown", {}), SHUTDOWN_REQUEST_TIMEOUT_MS);
2995
- if (shutdownAck === undefined)
2996
- shutdownRequestUndelivered = true;
2997
- }
2998
- catch (err) {
2999
- /* ignore — proceed to exit/kill so shutdown cannot hang the session */
3000
- if (isShutdownTimeoutError(err, SHUTDOWN_REQUEST_TIMEOUT_MS)) {
3001
- shutdownRequestTimedOut = true;
3002
- }
3003
- else {
3004
- shutdownRequestUndelivered = true;
3005
- }
3006
- }
3007
- try {
3008
- const exitSent = await withTimeout(safeSendNotification(state.connection, "exit", {}), EXIT_NOTIFY_TIMEOUT_MS);
3009
- if (exitSent === false)
3010
- exitNotifyUndelivered = true;
3011
- }
3012
- catch (err) {
3013
- /* ignore — same reason as the request above */
3014
- if (isShutdownTimeoutError(err, EXIT_NOTIFY_TIMEOUT_MS)) {
3015
- exitNotifyTimedOut = true;
3016
- }
3017
- else {
3018
- exitNotifyUndelivered = true;
3019
- }
3020
- }
3021
- }
3022
- }
3023
- finally {
3024
- // #2065 fix round 1: deregistration now lives in disposeClientConnection
3025
- // itself (the convergence point for every crash/dispose path, not just
3026
- // this graceful one) — see its comment.
3027
- disposeClientConnection(state);
3028
- const pid = state.lspProcess.pid;
3029
- logLatency({
3030
- type: "phase",
3031
- phase: "lsp_client_shutdown",
3032
- filePath: state.root,
3033
- durationMs: Date.now() - shutdownStart,
3034
- metadata: {
3035
- serverId: state.serverId,
3036
- pid,
3037
- fast: !!options.fast,
3038
- processExiting: !!options.processExiting,
3039
- shutdownRequestTimedOut,
3040
- // #1620: `shutdownRequestTimedOut` covered only the request half, so
3041
- // a hung exit notify was indistinguishable from a clean exit — and
3042
- // pre-fix no record was emitted at all. Report both halves plus one
3043
- // rolled-up verdict, so a forced teardown is countable from the log.
3044
- exitNotifyTimedOut,
3045
- // #1620 residual 1 / residual-review F2: the *Undelivered pair covers
3046
- // the failure the *TimedOut pair used to also claim — a rejection
3047
- // that is NOT the timer winning, OR (the F2 gap) a swallowed stream
3048
- // error that `safeSendRequest`/`safeSendNotification` resolved
3049
- // instead of rejecting, so no exception ever reached the catches
3050
- // above. Both still roll into shutdownOutcome "forced" below — an
3051
- // undelivered write is never reported as "graceful".
3052
- shutdownRequestUndelivered,
3053
- exitNotifyUndelivered,
3054
- shutdownOutcome: options.fast
3055
- ? "fast"
3056
- : shutdownRequestTimedOut ||
3057
- exitNotifyTimedOut ||
3058
- shutdownRequestUndelivered ||
3059
- exitNotifyUndelivered
3060
- ? "forced"
3061
- : "graceful",
3062
- },
3063
- });
3064
- // #449/#472: deregister this LSP child from the instance registry. Fire-
3065
- // and-forget (async fs, no spawn) — must not add latency/risk to shutdown,
3066
- // including the `processExiting` path where the event loop is closing
3067
- // (#234 forbids spawning here, but a plain fs write/rename is fine; even
3068
- // so, we don't await it to keep this teardown path as fast as before).
3069
- // #1724: pass this spawn's marker so removeLspChild can guard against a
3070
- // pid-recycling window (a NEW child claiming this exact pid before this
3071
- // fire-and-forget write lands) rather than removing whichever child
3072
- // currently sits at this pid.
3073
- void removeLspChild(pid, extractSpawnMarker(state.lspProcess.args)).catch((err) => {
3074
- logLatency({
3075
- type: "phase",
3076
- phase: "lsp_registry_write_failed",
3077
- filePath: "",
3078
- durationMs: 0,
3079
- metadata: { op: "remove", pid, error: String(err) },
3080
- });
3081
- });
3082
- // On Windows, killing the direct child first can orphan grandchildren before
3083
- // taskkill can traverse the tree. Kill the full tree first and wait briefly.
3084
- // Safe and idempotent on an already-exited child: killProcessTree returns
3085
- // early on an observed exit rather than signalling a possibly-recycled pid.
3086
- await killProcessTree(state.lspProcess.process, pid, options);
3087
- }
3088
- }
3089
- /**
3090
- * Translate a caller-supplied (UTF-16) `(line, character)` into the position the
3091
- * server expects under its negotiated encoding (#269). UTF-16 is the identity —
3092
- * the common case pays nothing (no I/O). For UTF-8/UTF-32 we read the target
3093
- * line from disk (pi edits files on disk before navigating, so disk == the
3094
- * server's content) and re-measure the character offset; a read failure falls
3095
- * back to the raw offset rather than dropping the request.
3096
- */
3097
- async function toWirePosition(state, filePath, line, character) {
3098
- if (state.positionEncoding === "utf-16")
3099
- return { line, character };
3100
- try {
3101
- const content = await readFile(filePath, "utf8");
3102
- return {
3103
- line,
3104
- character: convertCharacterOffset(state.positionEncoding, lineTextAt(content, line), character),
3105
- };
3106
- }
3107
- catch {
3108
- return { line, character };
3109
- }
3110
- }
3111
- // #276: drop a navigation result whose document was edited while the request was
3112
- // in flight. Mirrors the diagnostics-path staleness check (isVersionStale) which
3113
- // compares the version computed-against to the latest didChange. Default on;
3114
- // PI_LENS_LSP_NAV_STALE_DROP=0 disables it if it ever over-drops.
3115
- function navStaleDropEnabled() {
3116
- return process.env.PI_LENS_LSP_NAV_STALE_DROP !== "0";
3117
- }
3118
- // Exported for the timeout regression tests (#365). `timeoutMs` overrides the
3119
- // per-request ceiling so a test can bound a hung server quickly.
3120
- export async function navRequest(state, method, params,
3121
- // When provided, the request is dropped if the document's version advances
3122
- // (an edit landed) between send and response. Omit for non-single-file
3123
- // requests (workspaceSymbol, call-hierarchy follow-ups) that have no version.
3124
- staleCheckPath, timeoutMs = NAV_REQUEST_TIMEOUT_MS,
3125
- // Cancels the in-flight request (LSP `$/cancelRequest`) when the turn is
3126
- // abandoned. Defaults to the ambient abort signal set around dispatch/tool
3127
- // handling, so callers get cancellation for free without a signature change
3128
- // (#238 Item 1). Pass explicitly in tests.
3129
- signal = getAmbientAbortSignal()) {
3130
- if (!isClientAlive(state))
3131
- return null;
3132
- const normalizedPath = staleCheckPath !== undefined ? normalizeMapKey(staleCheckPath) : undefined;
3133
- const requestVersion = normalizedPath !== undefined
3134
- ? state.documentVersions.get(normalizedPath)
3135
- : undefined;
3136
- // #1716: captured OUTSIDE withTimeout, mirroring #1713's pull-diagnostic
3137
- // fix (`pullDiagnosticSource`), so a timeout's catch below still holds a
3138
- // live handle on the request for the late-answer watch. `withTimeout`
3139
- // only races it against a timer — the request keeps running server-side
3140
- // either way, and without this handle an abandoned answer would settle
3141
- // into the void with no way to observe it.
3142
- const requestStartedAt = Date.now();
3143
- const requestPromise = safeSendRequest(state.connection, method, params, signal);
3144
- const result = (await withTimeout(requestPromise, timeoutMs).catch((err) => {
3145
- if (isNavTimeoutError(err, timeoutMs)) {
3146
- recordNavTimeoutTelemetry({
3147
- method,
3148
- scope: normalizedPath,
3149
- budgetMs: timeoutMs,
3150
- elapsedMs: Date.now() - requestStartedAt,
3151
- });
3152
- armNavLateAnswerTelemetry({
3153
- requestPromise,
3154
- method,
3155
- scope: normalizedPath,
3156
- requestStartedAt,
3157
- });
3158
- return undefined;
3159
- }
3160
- if (err instanceof Error && err.message.startsWith("Timeout after")) {
3161
- return undefined;
3162
- }
3163
- throw err;
3164
- }));
3165
- // requestVersion === undefined (never opened, or version-less) → unaffected,
3166
- // matching the diagnostics path; the request timeout remains the backstop.
3167
- if (normalizedPath !== undefined &&
3168
- requestVersion !== undefined &&
3169
- navStaleDropEnabled()) {
3170
- const currentVersion = state.documentVersions.get(normalizedPath);
3171
- if (currentVersion !== undefined && currentVersion > requestVersion) {
3172
- return undefined;
3173
- }
3174
- }
3175
- return result;
3176
- }
3177
- /**
3178
- * #1716: `withTimeout` rejects with this EXACT message (`clients/deadline-
3179
- * utils.ts`) only when its own timer wins the race — mirrors #1713's
3180
- * `isPullTimeoutError`. Only the timer-won case is a "the answer might still
3181
- * be coming" situation worth a late-answer watch; a request that already
3182
- * failed fast (or a stale-message coincidence) has nothing further to
3183
- * observe. `navRequest`'s own behavior still falls back to the looser
3184
- * `startsWith` check afterward, unchanged, so this stricter check only gates
3185
- * telemetry — never the existing timeout-handling behavior.
3186
- */
3187
- function isNavTimeoutError(err, timeoutMs) {
3188
- return err instanceof Error && err.message === `Timeout after ${timeoutMs}ms`;
3189
- }
3190
- /**
3191
- * #1620 residual 1: `withTimeout` rejects with this EXACT message only when
3192
- * its own timer wins the race — mirrors #1713's `isPullTimeoutError` and
3193
- * #1716's `isNavTimeoutError`. `clientShutdown`'s two catches previously
3194
- * treated ANY rejection (a real timeout OR an immediate protocol error) as
3195
- * "timed out"; honest for the rolled-up `shutdownOutcome` (both are
3196
- * "forced"), imprecise for the per-field flag a log reader keys on.
3197
- */
3198
- function isShutdownTimeoutError(err, timeoutMs) {
3199
- return err instanceof Error && err.message === `Timeout after ${timeoutMs}ms`;
3200
- }
3201
- // #1716: subject/scope used when a nav request has no `staleCheckPath` (e.g.
3202
- // workspaceSymbol, call-hierarchy follow-ups) — mirrors WORKSPACE_PULL_SCOPE
3203
- // above for the same reason: telemetry identity needs a stable placeholder
3204
- // where there is no real path.
3205
- const NAV_REQUEST_NO_PATH_SCOPE = "*no-path*";
3206
- /**
3207
- * #1716: `navRequest` is the highest-volume LSP call site — hover,
3208
- * definition, references, signatureHelp, documentSymbol, workspace/symbol,
3209
- * call hierarchy all route through it, often several times per turn. A stuck
3210
- * server can storm timeouts across many methods and files far faster than
3211
- * the pull-diagnostic path #1713 fixed, so a per-event latency.log write
3212
- * (that fix's shape) would itself become the flood. Every timeout is still
3213
- * counted exactly, via the degradation ledger (bounded, in-memory, no I/O),
3214
- * but only the RISING EDGE — the first occurrence per (method, file) this
3215
- * session — also pays for a detailed log write. `incrementDegradationCount`'s
3216
- * return value (`true` on the first occurrence for a kind/subject pair) is
3217
- * reused as that rising-edge gate instead of a second hand-rolled latch: it
3218
- * already re-arms at session_start via `resetDegradationLedger`, so this
3219
- * adds zero new module state. Never throws: telemetry must not perturb a
3220
- * path that has already decided to return `undefined` regardless.
3221
- */
3222
- function recordNavTimeoutTelemetry(args) {
3223
- // #1743: `risingEdgePer: "identity"` is the same gate this site hand-rolled
3224
- // off `incrementDegradationCount`'s return value, now expressed as an
3225
- // option. The ledger still counts every timeout exactly, per (method, file).
3226
- emitBounded("lsp_nav_request_timeout", `${args.method}:${args.scope ?? NAV_REQUEST_NO_PATH_SCOPE}`, {
3227
- type: "phase",
3228
- filePath: args.scope ?? NAV_REQUEST_NO_PATH_SCOPE,
3229
- durationMs: args.elapsedMs,
3230
- metadata: { method: args.method, effectiveBudgetMs: args.budgetMs },
3231
- }, {
3232
- ledgerKind: "lsp-nav-request-timeout",
3233
- risingEdgePer: "identity",
3234
- reason: `timeout budget=${args.budgetMs}ms elapsed=${args.elapsedMs}ms`,
3235
- });
3236
- }
3237
- /**
3238
- * #1716: nav-request sibling of #1713's `armLateAnswerTelemetry`. A
3239
- * telemetry-only continuation on the ALREADY-abandoned request promise —
3240
- * purely observational, never touches `state`, and attaching `.then` here
3241
- * creates no new timer, socket, or other handle, so it cannot keep the
3242
- * process alive a moment longer than the pending request already does.
3243
- *
3244
- * Bounded the same way as the timeout record above: every late answer is
3245
- * counted exactly via the ledger, but only the rising edge per (method,
3246
- * file) also writes an `lsp_nav_late_answer_discarded` record.
3247
- *
3248
- * Nav answers are read-once — unlike diagnostics' persistent pull cache,
3249
- * there is no stale value this could poison — so this exists purely for
3250
- * observability: telling a dogfood session "truly hung" apart from
3251
- * "answered late", the same distinction #1713 made possible for pulls.
3252
- */
3253
- function armNavLateAnswerTelemetry(args) {
3254
- args.requestPromise.then(() => {
3255
- try {
3256
- const elapsedMs = Date.now() - args.requestStartedAt;
3257
- // #1743: same rising-edge gate as the timeout record above, now
3258
- // carried by the helper instead of hand-rolled here.
3259
- emitBounded("lsp_nav_late_answer_discarded", `${args.method}:${args.scope ?? NAV_REQUEST_NO_PATH_SCOPE}`, {
3260
- type: "phase",
3261
- filePath: args.scope ?? NAV_REQUEST_NO_PATH_SCOPE,
3262
- durationMs: elapsedMs,
3263
- metadata: { method: args.method },
3264
- }, {
3265
- ledgerKind: "lsp-nav-late-answer",
3266
- risingEdgePer: "identity",
3267
- reason: `late nav answer discarded after ${elapsedMs}ms`,
3268
- });
3269
- }
3270
- catch {
3271
- // Telemetry must never break the observed path.
3272
- }
3273
- }, () => {
3274
- // The abandoned request eventually failed rather than answering —
3275
- // not an "answer discarded" event. Nothing more to record here; this
3276
- // handler exists so the request's eventual rejection never surfaces
3277
- // as an unhandled rejection.
3278
- });
3279
- }
3280
- /**
3281
- * #1969: pick the request the liveness probe may send to THIS server.
3282
- *
3283
- * The probe only needs a round-trip, so any request works — but it must be one
3284
- * the server advertised. #1277 originally hardcoded `workspace/symbol` because
3285
- * it needs no open document, and treated `MethodNotFound` as proof of life.
3286
- * That rationale is sound for the round-trip, and wrong as a default: a server
3287
- * that does not implement the method logs an error for every probe. ast-grep's
3288
- * tower_lsp backend wrote "got a 'workspace/symbol' request, but it is not
3289
- * implemented" on each one, and its `code=1` deaths clustered after them
3290
- * (#1969). pi-lens must not keep pushing an unimplemented method at a server
3291
- * just to learn it is awake.
3292
- *
3293
- * The ladder, best first:
3294
- * 1. `workspace/symbol` when `workspaceSymbolProvider` is advertised — the
3295
- * #1277 probe, unchanged, and still needs no open document.
3296
- * 2. `textDocument/documentSymbol` on an already-open document when
3297
- * `documentSymbolProvider` is advertised. Read-only, scoped to a document
3298
- * the server already has, and cheap.
3299
- * 3. `textDocument/hover` at the start of an already-open document when
3300
- * `hoverProvider` is advertised. This step is what keeps ast-grep covered:
3301
- * `docs/servercapabilities.md:53` records its advertised set as
3302
- * codeActionProvider, executeCommandProvider, hoverProvider and
3303
- * textDocumentSync — no symbol provider of either kind. #1714's own
3304
- * measurement (clients/lsp/index.ts, `paceAuxNotify`) timed hover against
3305
- * the real ast-grep binary at 1 ms idle and 2086 ms behind 30 `didOpen`s,
3306
- * the same ordering proof it recorded for `workspace/symbol`. So the
3307
- * notify throttle keeps a real barrier here, not a hollow one.
3308
- * 4. Nothing. The caller then reports liveness from process and connection
3309
- * state alone, and records that it did.
3310
- *
3311
- * `MethodNotFound`-counts-as-alive still holds for every probe: dynamic
3312
- * registration can retract a capability after `initialize`, and an error reply
3313
- * is still a reply. What changes is that pi-lens no longer PROVOKES that error
3314
- * on every probe.
3315
- */
3316
- function chooseLivenessProbe(state) {
3317
- if (state.operationSupport?.workspaceSymbol === true) {
3318
- return {
3319
- method: "workspace/symbol",
3320
- params: { query: LIVENESS_PING_QUERY },
3321
- };
3322
- }
3323
- const openUri = firstOpenDocumentUri(state);
3324
- if (openUri === undefined)
3325
- return undefined;
3326
- if (state.operationSupport?.documentSymbol === true) {
3327
- return {
3328
- method: "textDocument/documentSymbol",
3329
- params: { textDocument: { uri: openUri } },
3330
- };
3331
- }
3332
- if (state.operationSupport?.hover === true) {
3333
- return {
3334
- method: "textDocument/hover",
3335
- params: {
3336
- textDocument: { uri: openUri },
3337
- position: { line: 0, character: 0 },
3338
- },
3339
- };
3340
- }
3341
- return undefined;
3342
- }
3343
- /** URI of any document this client currently holds open, if it holds one. */
3344
- function firstOpenDocumentUri(state) {
3345
- for (const normalizedPath of state.openDocuments) {
3346
- const uri = state.openDocumentUris?.get(normalizedPath);
3347
- if (uri !== undefined)
3348
- return uri;
3349
- }
3350
- return undefined;
3351
- }
3352
- // #1277: cheap liveness round-trip used by the silent-clean gates in
3353
- // `index.ts`. `isAlive()`/`checkAlive()` only look at process/connection
3354
- // state — a server that accepted the notify write and then wedged (still
3355
- // running, connection still open, just never replying) reads as "alive" by
3356
- // those checks even though it will never answer anything again. This sends a
3357
- // real request (see `chooseLivenessProbe` for which, #1969) and reports
3358
- // whether the connection round-tripped it — success, a genuine
3359
- // protocol-level error (e.g. MethodNotFound), and a stream-destroyed/
3360
- // cancelled response (safeSendRequest swallows those to `undefined`, so the
3361
- // final `isClientAlive` re-check is what catches "died mid-flight") ALL count
3362
- // as "alive"; only a real timeout, or the connection having gone down by the
3363
- // time this resolves, reports dead. The response content itself is never
3364
- // inspected — only whether one arrived in time.
3365
- async function clientPingLiveness(state, timeoutMs = LIVENESS_PING_TIMEOUT_MS) {
3366
- if (!isClientAlive(state))
3367
- return false;
3368
- const probe = chooseLivenessProbe(state);
3369
- if (probe === undefined) {
3370
- // #1969: no advertised method to probe with, so this answer comes from
3371
- // process and connection state alone — exactly the checks the probe
3372
- // exists to strengthen. Say so in the ledger rather than let a weaker
3373
- // verdict pass as the strong one. Rising edge per server, via the
3374
- // ledger's own tally; the count carries the repeats.
3375
- try {
3376
- incrementDegradationCount({
3377
- kind: "lsp-liveness-probe-unsupported",
3378
- subject: state.serverId,
3379
- reason: "no advertised request method to probe with (no " +
3380
- "workspaceSymbolProvider, and no open document with " +
3381
- "documentSymbolProvider or hoverProvider); liveness from " +
3382
- "process and connection state only",
3383
- });
3384
- }
3385
- catch {
3386
- // Telemetry must never break the observed path.
3387
- }
3388
- return isClientAlive(state);
3389
- }
3390
- try {
3391
- await withTimeout(safeSendRequest(state.connection, probe.method, probe.params), timeoutMs);
3392
- }
3393
- catch (err) {
3394
- if (err instanceof Error && err.message.startsWith("Timeout after")) {
3395
- return false;
3396
- }
3397
- // A real protocol-level error reply still proves the server round-
3398
- // tripped the request — fall through to the alive re-check below
3399
- // rather than treating an error response as "dead".
3400
- }
3401
- return isClientAlive(state);
3402
- }
3403
- // #2479: `activeMutationContext` is DERIVED state, not a stack. The frame that
3404
- // takes `activeMutationDepth` 0 -> 1 owns the context for its whole lifetime
3405
- // (`mutationContextOwner`); the slot merely exposes that owner while it is the
3406
- // ONLY frame in flight. Deeper or overlapping frames therefore empty the slot
3407
- // for as long as they are open — concurrent and nested commands deliberately
3408
- // do not cross-correlate — and it refills by itself when they unwind, with no
3409
- // frame having to remember anything.
3410
- //
3411
- // Recomputing beats saving and restoring because these frames do not nest:
3412
- // `LSPClient.executeCommand` and `LSPService.executeCommand` fan out with no
3413
- // mutex, parallel `lsp_navigation` calls with `apply: true` overlap, and #449
3414
- // light mode shares one client across agents. With A (the owner), B and C
3415
- // overlapping and the MIDDLE one settling first, a per-frame restore hands A's
3416
- // context back while C's window is the open one — and `LspMutationContext`
3417
- // carries a cwd plus a directly-threaded runtime/cacheManager, with
3418
- // `readTurnState` / `appendProjectChange` cwd-scoped, so that routes a live
3419
- // edit's bookkeeping into another project's change log (strictly worse than
3420
- // the honest fallback the pre-#2479 code gave). An owner-liveness flag does
3421
- // not fix it either: it answers "is an owner live", not "does the slot belong
3422
- // to the window this edit is in" — and C's own unwind then puts back the
3423
- // `undefined` it saved while the owner is still running, resurrecting #2479
3424
- // (review round 3). Deriving answers the second question by construction, and
3425
- // "no command in flight == no context" — the invariant the
3426
- // `serverEditsAllowed` gate on `workspace/applyEdit` is paired with — falls
3427
- // out of it with no depth-0 special case.
3428
- function syncMutationContextSlot(state) {
3429
- state.activeMutationContext =
3430
- state.activeMutationDepth === 1 ? state.mutationContextOwner : undefined;
3431
- }
3432
- // Run an advertised server command via workspace/executeCommand, with the
3433
- // generous EXECUTE_COMMAND_TIMEOUT_MS anti-deadlock backstop. Preserves the
3434
- // hardening invariants: allowlist-by-advertisement (only commands the server
3435
- // declared) and the serverEditsAllowed window that gates server-driven
3436
- // applyEdit to the duration of an explicit call. Exported with an overridable
3437
- // `timeoutMs` for the #365 regression tests.
3438
- export async function runServerCommand(state, command, args, timeoutMs = EXECUTE_COMMAND_TIMEOUT_MS, mutationContext) {
3439
- if (!isClientAlive(state)) {
3440
- return { executed: false, reason: "lsp client not alive" };
3441
- }
3442
- if (!state.advertisedCommands.has(command)) {
3443
- return {
3444
- executed: false,
3445
- reason: `command "${command}" is not advertised by the ${state.serverId} server`,
3446
- };
3447
- }
3448
- state.serverEditsAllowed += 1;
3449
- state.activeMutationDepth = (state.activeMutationDepth ?? 0) + 1;
3450
- // #2479: the frame that takes the depth to 1 owns the context; the slot is
3451
- // derived from that owner (see `syncMutationContextSlot` above). The
3452
- // pre-#2479 shape only ever CLEARED the slot, and only once the depth
3453
- // returned to 0, so a nested `executeCommand` unwinding from depth 2 back
3454
- // to 1 left the OUTER call without its own context for the rest of its
3455
- // window: every server-initiated `applyEdit` it still solicited read
3456
- // `undefined`, fell to the mutation bridge, and carried the generic
3457
- // `agent-tool:lsp-workspace-applyEdit` receipt instead of the outer
3458
- // operation's own (`lsp-rename` / `lsp-execute-command`).
3459
- const isMutationContextOwner = state.activeMutationDepth === 1;
3460
- if (isMutationContextOwner) {
3461
- state.mutationContextOwner = mutationContext;
3462
- }
3463
- syncMutationContextSlot(state);
3464
- try {
3465
- let result;
3466
- try {
3467
- result = await withTimeout(safeSendRequest(state.connection, "workspace/executeCommand", {
3468
- command,
3469
- arguments: args ?? [],
3470
- }), timeoutMs);
3471
- }
3472
- catch (err) {
3473
- // Generous backstop only: a timeout means the server is hung (or the
3474
- // command is running longer than the ceiling). Surface it honestly — the
3475
- // command may still be applying — instead of hanging the caller. Real
3476
- // (non-timeout) errors still propagate.
3477
- if (err instanceof Error && err.message.startsWith("Timeout after")) {
3478
- return {
3479
- executed: false,
3480
- reason: `workspace/executeCommand timed out after ${timeoutMs}ms — the command may still be applying server-side`,
3481
- };
3482
- }
3483
- throw err;
3484
- }
3485
- return { executed: true, result };
3486
- }
3487
- finally {
3488
- state.serverEditsAllowed -= 1;
3489
- state.activeMutationDepth = Math.max(0, (state.activeMutationDepth ?? 0) - 1);
3490
- // The owner drops its context; every frame then resyncs. Both the
3491
- // hand-back to a still-live owner once the deeper frames unwind and
3492
- // the empty slot while more than one frame is open are consequences of
3493
- // the derivation, not separate rules each frame has to get right
3494
- // (#2479 review rounds 2/3).
3495
- if (isMutationContextOwner) {
3496
- state.mutationContextOwner = undefined;
3497
- }
3498
- syncMutationContextSlot(state);
3499
- }
3500
- }
3501
- // #1412 H1/H2: read-only sibling of runServerCommand for telemetry/identity
3502
- // probes that must NOT participate in the mutation-acceptance window. Unlike
3503
- // runServerCommand this never touches serverEditsAllowed, activeMutationDepth,
3504
- // or activeMutationContext — a probe firing mid-flight must leave a concurrent
3505
- // real executeCommand's mutation context untouched, and must not itself open
3506
- // the workspace/applyEdit acceptance window (client.ts's applyEdit handler
3507
- // gates on serverEditsAllowed > 0). Preserves the allowlist-by-advertisement
3508
- // invariant. Short PROBE_COMMAND_TIMEOUT_MS backstop — this is a diagnostic
3509
- // sample, not a mutation, and must never hold anything up for anywhere near
3510
- // EXECUTE_COMMAND_TIMEOUT_MS.
3511
- export async function runReadOnlyServerCommand(state, command, args, timeoutMs = PROBE_COMMAND_TIMEOUT_MS) {
3512
- if (!isClientAlive(state)) {
3513
- return { executed: false, reason: "lsp client not alive" };
3514
- }
3515
- if (!state.advertisedCommands.has(command)) {
3516
- return {
3517
- executed: false,
3518
- reason: `command "${command}" is not advertised by the ${state.serverId} server`,
3519
- };
3520
- }
3521
- try {
3522
- const result = await withTimeout(safeSendRequest(state.connection, "workspace/executeCommand", {
3523
- command,
3524
- arguments: args ?? [],
3525
- }), timeoutMs);
3526
- return { executed: true, result };
3527
- }
3528
- catch (err) {
3529
- if (err instanceof Error && err.message.startsWith("Timeout after")) {
3530
- return {
3531
- executed: false,
3532
- reason: `workspace/executeCommand timed out after ${timeoutMs}ms`,
3533
- };
3534
- }
3535
- throw err;
3536
- }
3537
- }
3538
- function validateWorkspaceEditVersions(state, edit) {
3539
- for (const change of edit.documentChanges ?? []) {
3540
- if (typeof change !== "object" ||
3541
- change === null ||
3542
- !("textDocument" in change))
3543
- continue;
3544
- const textDocument = change.textDocument;
3545
- if (!textDocument ||
3546
- typeof textDocument.uri !== "string" ||
3547
- textDocument.version == null)
3548
- continue;
3549
- const current = state.documentVersions.get(normalizeMapKey(uriToPath(textDocument.uri)));
3550
- if (current === undefined || current !== textDocument.version) {
3551
- throw new Error(`stale workspace edit document version for ${textDocument.uri}`);
3552
- }
3553
- }
3554
- }
3555
- // Neutralize numeric `textDocument.version` stamps AFTER they have been
3556
- // validated against the live document map. The tool apply paths (rename
3557
- // apply:true in tools/lsp-navigation.ts, code-action autofix in
3558
- // actionable-warnings.ts) call applyWorkspaceEdit without a documentVersions
3559
- // map, so a preserved numeric version would fail preflight 100% of the time for
3560
- // servers that stamp real versions (gopls stamps open documents). Setting the
3561
- // version to null is the spec's "do not check" — the freshness guarantee has
3562
- // already been provided here by validateWorkspaceEditVersions at the correct
3563
- // moment. The server-initiated workspace/applyEdit handler does NOT route
3564
- // through here (it applies params.edit directly with state.documentVersions),
3565
- // so its real preflight version check is left fully intact.
3566
- function stripDocumentVersions(edit) {
3567
- if (!Array.isArray(edit.documentChanges))
3568
- return edit;
3569
- const documentChanges = edit.documentChanges.map((change) => {
3570
- if (typeof change === "object" &&
3571
- change !== null &&
3572
- "textDocument" in change &&
3573
- "edits" in change) {
3574
- const textDocument = change
3575
- .textDocument;
3576
- if (textDocument && typeof textDocument.version === "number") {
3577
- return {
3578
- ...change,
3579
- textDocument: { ...textDocument, version: null },
3580
- };
3581
- }
3582
- }
3583
- return change;
3584
- });
3585
- return { ...edit, documentChanges };
3586
- }
3587
- export async function normalizeClientWorkspaceEdit(state, edit) {
3588
- validateWorkspaceEditVersions(state, edit);
3589
- const normalized = (await normalizeWorkspaceEditToUtf16(edit, state.positionEncoding, state.root));
3590
- return stripDocumentVersions(normalized);
3591
- }
3592
- async function resolveCodeActionBestEffort(state, action) {
3593
- if (!isClientAlive(state))
3594
- return action;
3595
- if (action.edit) {
3596
- return {
3597
- ...action,
3598
- edit: await normalizeClientWorkspaceEdit(state, action.edit),
3599
- };
3600
- }
3601
- if (!state.operationSupport.codeActionResolve) {
3602
- recordDegradationOnce({
3603
- kind: "lsp-capability-skip",
3604
- subject: `${state.serverId}:codeAction/resolve`,
3605
- reason: "server did not advertise codeActionProvider.resolveProvider",
3606
- });
3607
- return action;
3608
- }
3609
- let resolved;
3610
- try {
3611
- resolved = await withTimeout(safeSendRequest(state.connection, "codeAction/resolve", action), NAV_REQUEST_TIMEOUT_MS);
3612
- }
3613
- catch {
3614
- // codeAction/resolve is optional. Keep the original lightweight action when
3615
- // the server does not support resolve or fails to populate an edit.
3616
- return action;
3617
- }
3618
- if (!resolved || typeof resolved !== "object")
3619
- return action;
3620
- const merged = { ...action, ...resolved };
3621
- return merged.edit
3622
- ? {
3623
- ...merged,
3624
- edit: await normalizeClientWorkspaceEdit(state, merged.edit),
3625
- }
3626
- : merged;
3627
- }
3628
- // --- Client Factory ---
3629
- export async function createLSPClient(options) {
3630
- installCrashGuard();
3631
- const { serverId, process: lspProcess, root, sessionCwd, initialization, initializeTimeoutMs = INITIALIZE_TIMEOUT_MS, launchVariant, onDiagnosticsPublished, } = options;
3632
- // #449/#472: register this LSP child in the cross-process instance registry
3633
- // as soon as we have a live pid — BEFORE `initialize` completes, not after.
3634
- // Registering early means a child that dies/hangs during initialize (the
3635
- // catch block below kills it) is still deregistered by that same path via
3636
- // removeLspChild, and a process that crashes mid-initialize is still
3637
- // visible to the orphan reaper rather than silently untracked. Fire-and-
3638
- // forget: registry I/O must never block or fail LSP startup.
3639
- void recordLspChild({
3640
- pid: lspProcess.pid,
3641
- serverId,
3642
- command: lspProcess.command,
3643
- marker: extractSpawnMarker(lspProcess.args),
3644
- sessionIdentity: {
3645
- projectRoot: sessionCwd ?? process.cwd(),
3646
- rootSource: sessionCwd ? "service-cwd" : "lsp-fallback",
3647
- startedAt: new Date(workspaceDiagnosticsCacheSessionStart()).toISOString(),
3648
- },
3649
- }).catch((err) => {
3650
- // best-effort observability — never fail LSP startup over this
3651
- logLatency({
3652
- type: "phase",
3653
- phase: "lsp_registry_write_failed",
3654
- filePath: "",
3655
- durationMs: 0,
3656
- metadata: { op: "record", pid: lspProcess.pid, error: String(err) },
3657
- });
3658
- });
3659
- const startupState = {
3660
- exitCode: null,
3661
- exitSignal: null,
3662
- closeCode: null,
3663
- closeSignal: null,
3664
- stderr: "",
3665
- };
3666
- // Persistent stderr ring buffer — captures last ~100 lines for diagnostics.
3667
- // Used in error messages to show what the server said before dying.
3668
- const stderrRing = [];
3669
- const MAX_STDERR_LINES = 100;
3670
- const onStderr = (chunk) => {
3671
- stderrRing.push(chunk.toString());
3672
- if (stderrRing.length > MAX_STDERR_LINES)
3673
- stderrRing.shift();
3674
- // Also capture startup stderr for the initialized-failed error path
3675
- if (startupState.stderr.length < 4096) {
3676
- startupState.stderr += chunk.toString();
3677
- }
3678
- };
3679
- const recentStderr = (lines = 10) => stderrRing.slice(-lines).join("").trim();
3680
- // Pre-request health check — returns error string if process is dead.
3681
- const checkProcessAlive = () => {
3682
- const exited = lspProcess.process.exitCode;
3683
- if (exited !== null) {
3684
- const tail = recentStderr(20);
3685
- return `LSP server ${serverId} exited with code ${exited}${tail ? `. stderr: ${tail}` : ""}`;
3686
- }
3687
- if (lspProcess.process.killed) {
3688
- return `LSP server ${serverId} was killed`;
3689
- }
3690
- return undefined;
3691
- };
3692
- const onProcessExit = (code, signal) => {
3693
- startupState.exitCode = code;
3694
- startupState.exitSignal = signal;
3695
- };
3696
- const onProcessClose = (code, signal) => {
3697
- startupState.closeCode = code;
3698
- startupState.closeSignal = signal;
3699
- };
3700
- lspProcess.stderr.on("data", onStderr);
3701
- lspProcess.process.on("exit", onProcessExit);
3702
- lspProcess.process.on("close", onProcessClose);
3703
- // Attach persistent 'error' listeners to all three stdio streams.
3704
- //
3705
- // Why: when the LSP process exits, Node.js destroys its stdio streams and
3706
- // may emit 'error' (ERR_STREAM_DESTROYED / EPIPE / ECONNRESET) on them.
3707
- // Without a listener that becomes an uncaught exception.
3708
- //
3709
- // vscode-jsonrpc covers stdin/stdout during the connection lifetime but
3710
- // removes its listeners on dispose(). Our permanent listeners cover the gap.
3711
- const streamErrorHandler = (_label) => (err) => {
3712
- if (err.code === "ERR_STREAM_DESTROYED" ||
3713
- err.code === "ERR_STREAM_WRITE_AFTER_END" ||
3714
- err.code === "EPIPE" ||
3715
- err.code === "ECONNRESET")
3716
- return;
3717
- };
3718
- lspProcess.stdin.on("error", streamErrorHandler("stdin"));
3719
- lspProcess.stdout.on("error", streamErrorHandler("stdout"));
3720
- lspProcess.stderr.on("error", streamErrorHandler("stderr"));
3721
- const connection = createMessageConnection(new StreamMessageReader(lspProcess.stdout), new StreamMessageWriter(lspProcess.stdin));
3722
- // Local event emitter — signals waitForDiagnostics when new diagnostics arrive.
3723
- // Scoped to this client instance. setMaxListeners guards against Node.js warning
3724
- // for concurrent waitForDiagnostics calls.
3725
- const diagnosticEmitter = new EventEmitter();
3726
- diagnosticEmitter.setMaxListeners(50);
3727
- // SAFETY: three fields below are seeded `undefined as unknown as T` because
3728
- // they cannot be built here. `workspaceDiagnosticsSupport` and
3729
- // `operationSupport` are derived from the server's `initialize` result, and
3730
- // `watchQueue`'s flush closure needs `state` itself. Each is assigned
3731
- // in this same function before `state` reaches any caller:
3732
- // `state.watchQueue` on the statement immediately after this literal, and
3733
- // the two capability fields right after the `initialize` handshake
3734
- // returns. Reorder that and the fields are genuinely `undefined` at their
3735
- // declared non-optional types.
3736
- const state = {
3737
- isConnected: true,
3738
- isDestroyed: false,
3739
- shutdownRequested: false,
3740
- shutdownPromise: undefined,
3741
- shutdownOptions: undefined,
3742
- exitedAt: undefined,
3743
- connectionDisposed: false,
3744
- lastError: undefined,
3745
- connection,
3746
- pushDiagnostics: new Map(),
3747
- pushDiagnosticTimestamps: new Map(),
3748
- documentPullDiagnostics: new Map(),
3749
- documentPullDiagnosticTimestamps: new Map(),
3750
- pullFailureHistory: [],
3751
- pendingDiagnostics: new Map(),
3752
- diagnosticPublicationCounts: new Map(),
3753
- documentOpenedAt: new Map(),
3754
- diagnosticEmitter,
3755
- diagnosticsVersion: 0,
3756
- diagnosticsVersionsByPath: new Map(),
3757
- documentVersions: new Map(),
3758
- notifyChangeQueues: new Map(),
3759
- diagnosticDocVersions: new Map(),
3760
- documentContentHashes: new Map(),
3761
- incrementalTextRetainedEntries: 0,
3762
- incrementalTextBearingPaths: new Set(),
3763
- incrementalTextRetainedBytes: 0,
3764
- diagnosticBindings: new Map(),
3765
- pullResultIds: new Map(),
3766
- documentPullDiagnosticsBySource: new Map(),
3767
- pullGenerations: new Map(),
3768
- pullRequestSequences: new Map(),
3769
- workspacePullResultCache: new Map(),
3770
- openDocuments: new Set(),
3771
- // #3310: one-shot, per client session.
3772
- emptyFirstPublishHoldSpent: false,
3773
- closedDocuments: new Set(),
3774
- openDocumentUris: new Map(),
3775
- pendingOpens: new Set(),
3776
- projectIdentityProbedFiles: new Set(),
3777
- // these are filled in after initialize — cast to avoid two-phase init
3778
- workspaceDiagnosticsSupport:
3779
- // SAFETY: initialize fills this capability before any workspace diagnostic request.
3780
- undefined,
3781
- // SAFETY: initialize fills this capability before operation dispatch.
3782
- operationSupport: undefined,
3783
- staticDiagnosticsMode: "push-only",
3784
- positionEncoding: "utf-16",
3785
- syncKind: TEXT_DOCUMENT_SYNC_KIND_FULL,
3786
- dynamicRegistrations: new Map(),
3787
- advertisedCommands: new Set(),
3788
- serverEditsAllowed: 0,
3789
- activeMutationDepth: 0,
3790
- serverId,
3791
- launchVariant,
3792
- root,
3793
- lspProcess,
3794
- // two-phase: the flush closure needs `state` (below)
3795
- // SAFETY: state construction completes before the flush closure can run.
3796
- watchQueue: undefined,
3797
- };
3798
- activeLspClientSet().add(state);
3799
- // #271: batch per-file workspace/didChangeWatchedFiles into one notification
3800
- // per debounce window, so an N-file turn re-indexes the server once, not N×.
3801
- state.watchQueue = new WatchedFilesQueue((changes) => {
3802
- if (!isClientAlive(state))
3803
- return;
3804
- void safeSendNotification(state.connection, "workspace/didChangeWatchedFiles", { changes });
3805
- });
3806
- setupIncomingHandlers(state, initialization, onDiagnosticsPublished);
3807
- connection.listen();
3808
- setupConnectionLifecycle(state, recentStderr);
3809
- let initResult;
3810
- try {
3811
- initResult = await withTimeout(safeSendRequest(connection, "initialize", {
3812
- processId: process.pid,
3813
- rootUri: pathToFileURL(root).href,
3814
- workspaceFolders: [
3815
- { name: "workspace", uri: pathToFileURL(root).href },
3816
- ],
3817
- capabilities: CLIENT_CAPABILITIES,
3818
- initializationOptions: initialization,
3819
- }), initializeTimeoutMs);
3820
- }
3821
- catch (err) {
3822
- // #1969: claim this kill BEFORE issuing it. `setupConnectionLifecycle`
3823
- // ran above, so its `exit` and `close` handlers are already armed on
3824
- // this child, and both read `state.shutdownRequested` to tell a crash
3825
- // from a teardown we asked for. The kill below is one we asked for.
3826
- // Without this line it reported as an unprompted death, fabricating a
3827
- // `lsp-server-unexpected-close` entry reading
3828
- // "code=1 signal=none stderr=empty" — character for character the
3829
- // ast-grep signature this issue exists to make trustworthy — every time
3830
- // a server merely failed to complete its handshake. It also silenced a
3831
- // false `lsp_server_unexpected_exit` latency line that this path has
3832
- // been writing since the #615 follow-up.
3833
- state.shutdownRequested = true;
3834
- // Hard-kill the hung process so it doesn't become a zombie.
3835
- // SIGTERM alone is unreliable on Windows for cmd.exe/PowerShell trees.
3836
- const pid = lspProcess.pid;
3837
- void killProcessTree(lspProcess.process, pid);
3838
- // A child registered above (recordLspChild) but never reaching a healthy
3839
- // createLSPClient return must still be deregistered here — otherwise the
3840
- // registry keeps a stale entry for a process we just killed.
3841
- void removeLspChild(pid, extractSpawnMarker(lspProcess.args)).catch((err) => {
3842
- // best-effort — a stale registry entry is harmless (the reaper's
3843
- // liveness check will find it dead on the next sweep regardless)
3844
- logLatency({
3845
- type: "phase",
3846
- phase: "lsp_registry_write_failed",
3847
- filePath: "",
3848
- durationMs: 0,
3849
- metadata: { op: "remove", pid, error: String(err) },
3850
- });
3851
- });
3852
- setTimeout(() => {
3853
- // #1114: gate on the process's own observed `exitCode`/`signalCode`,
3854
- // not `.killed` — `killProcessTree` above signals the POSIX process
3855
- // GROUP via the raw `process.kill(-pid, …)`, which never touches
3856
- // this `ChildProcess` instance's `.killed` flag, so `!…killed` here
3857
- // was always true and this 2s backstop unconditionally re-sent
3858
- // SIGKILL even when the group had already exited. `exitCode` alone
3859
- // is insufficient too: a process that died FROM a signal (the
3860
- // common case here — killProcessTree's own SIGTERM/SIGKILL) has
3861
- // `exitCode === null` forever and only `signalCode` set, so
3862
- // checking `exitCode === null` alone still re-SIGKILLs that corpse
3863
- // on the common path (harmless — `kill()` on an already-exited pid
3864
- // is a swallowed no-op — but not actually "observed still alive").
3865
- // Require both null to mean "no exit observed by either signal".
3866
- if (lspProcess.process.exitCode === null &&
3867
- lspProcess.process.signalCode === null &&
3868
- process.platform !== "win32") {
3869
- lspProcess.process.kill("SIGKILL");
3870
- }
3871
- }, 2000);
3872
- throw err;
3873
- }
3874
- finally {
3875
- lspProcess.stderr.off("data", onStderr);
3876
- }
3877
- if (initResult === undefined) {
3878
- const compactStderr = startupState.stderr
3879
- .replace(/\s+/g, " ")
3880
- .trim()
3881
- .slice(0, 320);
3882
- const reinstallHint = serverId === "cpp"
3883
- ? "Install clangd (LLVM/clang-tools) and ensure clangd.exe is on PATH."
3884
- : `Try reinstalling: npm install -g ${serverId}-language-server.`;
3885
- const telemetry = [
3886
- `pid=${lspProcess.pid}`,
3887
- `exitCode=${startupState.exitCode ?? "none"}`,
3888
- `exitSignal=${startupState.exitSignal ?? "none"}`,
3889
- `closeCode=${startupState.closeCode ?? "none"}`,
3890
- `closeSignal=${startupState.closeSignal ?? "none"}`,
3891
- `root=${root}`,
3892
- compactStderr ? `stderr=${compactStderr}` : "stderr=<empty>",
3893
- ].join(" ");
3894
- throw new Error(`[lsp] ${serverId} failed to initialize - stream may have been destroyed. ` +
3895
- `The server binary may be missing or crashed immediately. ${reinstallHint} ` +
3896
- `telemetry: ${telemetry}`);
3897
- }
3898
- state.workspaceDiagnosticsSupport =
3899
- detectWorkspaceDiagnosticsSupport(initResult);
3900
- state.operationSupport = detectOperationSupport(initResult);
3901
- const capabilities = (initResult
3902
- ?.capabilities ?? {});
3903
- const workspace = isCapabilityRecord(capabilities.workspace)
3904
- ? capabilities.workspace
3905
- : undefined;
3906
- const fileOperations = isCapabilityRecord(workspace)
3907
- ? workspace.fileOperations
3908
- : undefined;
3909
- const malformedFileOperationRegistrations = new Set();
3910
- if (isCapabilityRecord(fileOperations)) {
3911
- for (const operation of ["willRename", "didRename"]) {
3912
- if (fileOperations[operation] !== undefined &&
3913
- parseFileOperationFilters(fileOperations[operation]) === undefined) {
3914
- malformedFileOperationRegistrations.add(operation);
3915
- }
3916
- }
3917
- }
3918
- state.fileOperationMalformedRegistrations =
3919
- malformedFileOperationRegistrations;
3920
- if (isCapabilityRecord(fileOperations)) {
3921
- state.fileOperationFilters = {
3922
- willRename: parseFileOperationFilters(fileOperations.willRename),
3923
- didRename: parseFileOperationFilters(fileOperations.didRename),
3924
- };
3925
- }
3926
- state.positionEncoding = negotiatePositionEncoding(initResult?.capabilities);
3927
- state.syncKind = negotiateSyncKind(initResult?.capabilities);
3928
- state.saveOptions = negotiateSaveOptions(initResult?.capabilities);
3929
- state.rawCapabilityKeys = Object.keys(initResult?.capabilities ??
3930
- {}).sort((a, b) => a.localeCompare(b));
3931
- for (const cmd of detectExecuteCommands(initResult)) {
3932
- state.advertisedCommands.add(cmd);
3933
- }
3934
- state.staticDiagnosticsMode = state.workspaceDiagnosticsSupport.mode;
3935
- await safeSendNotification(connection, "initialized", {});
3936
- if (initialization) {
3937
- await safeSendNotification(connection, "workspace/didChangeConfiguration", {
3938
- settings: initialization,
3939
- });
3940
- }
3941
- return {
3942
- serverId,
3943
- root,
3944
- connection,
3945
- isAlive: () => isClientAlive(state),
3946
- /** True if the server process has exited or been killed. */
3947
- processExited: () => lspProcess.process.exitCode !== null ||
3948
- lspProcess.process.killed === true,
3949
- /** #1127: mirrors `state.shutdownRequested` — see interface doc. */
3950
- wasShutdownIntentional: () => state.shutdownRequested,
3951
- /** #1127: mirrors `state.exitedAt` — see interface doc. */
3952
- getExitedAt: () => state.exitedAt,
3953
- /** Last N lines of server stderr for diagnostics. */
3954
- recentStderr: (lines) => recentStderr(lines),
3955
- getPullFailureHistory: () => state.pullFailureHistory.map((entry) => ({
3956
- ...entry,
3957
- message: entry.message.slice(0, 200),
3958
- })),
3959
- /** Pre-request health check — returns error string if dead. */
3960
- checkAlive: () => checkProcessAlive(),
3961
- /** #1277: cheap request round-trip proving the server still responds. */
3962
- pingLiveness: (timeoutMs) => clientPingLiveness(state, timeoutMs),
3963
- /** #2358: the OS pid of the live server process (see interface doc). */
3964
- getProcessPid: () => lspProcess.pid,
3965
- notify: {
3966
- async open(filePath, content, languageId, preserveDiagnostics, silent, saved) {
3967
- return handleNotifyOpen(state, filePath, content, languageId, preserveDiagnostics, silent, saved);
3968
- },
3969
- async change(filePath, content) {
3970
- return handleNotifyChange(state, filePath, content);
3971
- },
3972
- watchedFileChange(filePath, type) {
3973
- handleNotifyExternalChange(state, filePath, type);
3974
- },
3975
- },
3976
- getDiagnostics(filePath) {
3977
- return getMergedDiagnosticsForPath(state, normalizeMapKey(filePath));
3978
- },
3979
- getDiagnosticBinding(filePath) {
3980
- return state.diagnosticBindings.get(normalizeMapKey(filePath));
3981
- },
3982
- getDiagnosticsVersionForPath(filePath) {
3983
- return diagnosticsVersionForPath(state, normalizeMapKey(filePath));
3984
- },
3985
- getAllDiagnostics() {
3986
- const result = new Map();
3987
- const keys = new Set([
3988
- ...state.pushDiagnostics.keys(),
3989
- ...state.documentPullDiagnostics.keys(),
3990
- ]);
3991
- for (const key of keys) {
3992
- result.set(key, {
3993
- diags: getMergedDiagnosticsForPath(state, key),
3994
- ts: Math.max(state.pushDiagnosticTimestamps.get(key) ?? 0, state.documentPullDiagnosticTimestamps.get(key) ?? 0),
3995
- binding: state.diagnosticBindings.get(key),
3996
- });
3997
- }
3998
- return result;
3999
- },
4000
- getTrackedDiagnosticPaths() {
4001
- return [
4002
- ...new Set([
4003
- ...state.pushDiagnostics.keys(),
4004
- ...state.documentPullDiagnostics.keys(),
4005
- ]),
4006
- ].map((filePath) => process.platform === "win32" ? filePath.replace(/\//g, "\\") : filePath);
4007
- },
4008
- pruneDiagnostics(predicate) {
4009
- let removed = 0;
4010
- const keys = new Set([
4011
- ...state.pushDiagnostics.keys(),
4012
- ...state.documentPullDiagnostics.keys(),
4013
- ]);
4014
- for (const key of keys) {
4015
- const diags = getMergedDiagnosticsForPath(state, key);
4016
- const ts = Math.max(state.pushDiagnosticTimestamps.get(key) ?? 0, state.documentPullDiagnosticTimestamps.get(key) ?? 0);
4017
- if (!predicate(key, ts, diags))
4018
- continue;
4019
- clearDiagnosticsForPath(state, key);
4020
- removed++;
4021
- }
4022
- return removed;
4023
- },
4024
- getWorkspaceDiagnosticsSupport() {
4025
- return state.workspaceDiagnosticsSupport;
4026
- },
4027
- requestWorkspaceDiagnostics(budgetMs) {
4028
- return clientRequestWorkspaceDiagnostics(state, budgetMs);
4029
- },
4030
- getOperationSupport() {
4031
- return state.operationSupport;
4032
- },
4033
- getMalformedFileOperationRegistrations() {
4034
- return state.fileOperationMalformedRegistrations ?? new Set();
4035
- },
4036
- getAdvertisedCommands() {
4037
- return [...state.advertisedCommands];
4038
- },
4039
- getRawCapabilityKeys() {
4040
- return state.rawCapabilityKeys ?? [];
4041
- },
4042
- getLaunchVariant() {
4043
- return state.launchVariant;
4044
- },
4045
- async executeCommand(command, args, mutationContext) {
4046
- return runServerCommand(state, command, args, EXECUTE_COMMAND_TIMEOUT_MS, mutationContext);
4047
- },
4048
- async executeReadOnlyCommand(command, args) {
4049
- return runReadOnlyServerCommand(state, command, args);
4050
- },
4051
- get diagnosticsVersion() {
4052
- return state.diagnosticsVersion;
4053
- },
4054
- async waitForDiagnostics(filePath, timeoutMs = DIAGNOSTICS_WAIT_TIMEOUT_MS, options) {
4055
- return clientWaitForDiagnostics(state, filePath, timeoutMs, options);
4056
- },
4057
- async definition(filePath, line, character) {
4058
- const result = await navRequest(state, "textDocument/definition", {
4059
- textDocument: { uri: pathToFileURL(filePath).href },
4060
- position: await toWirePosition(state, filePath, line, character),
4061
- }, filePath);
4062
- if (!result)
4063
- return [];
4064
- return Array.isArray(result) ? result : [result];
4065
- },
4066
- async typeDefinition(filePath, line, character) {
4067
- const result = await navRequest(state, "textDocument/typeDefinition", {
4068
- textDocument: { uri: pathToFileURL(filePath).href },
4069
- position: await toWirePosition(state, filePath, line, character),
4070
- }, filePath);
4071
- if (!result)
4072
- return [];
4073
- return Array.isArray(result) ? result : [result];
4074
- },
4075
- async declaration(filePath, line, character) {
4076
- const result = await navRequest(state, "textDocument/declaration", {
4077
- textDocument: { uri: pathToFileURL(filePath).href },
4078
- position: await toWirePosition(state, filePath, line, character),
4079
- }, filePath);
4080
- if (!result)
4081
- return [];
4082
- return Array.isArray(result) ? result : [result];
4083
- },
4084
- async references(filePath, line, character, includeDeclaration = true) {
4085
- const result = await navRequest(state, "textDocument/references", {
4086
- textDocument: { uri: pathToFileURL(filePath).href },
4087
- position: await toWirePosition(state, filePath, line, character),
4088
- context: { includeDeclaration },
4089
- }, filePath);
4090
- return result ?? [];
4091
- },
4092
- async hover(filePath, line, character) {
4093
- const result = await navRequest(state, "textDocument/hover", {
4094
- textDocument: { uri: pathToFileURL(filePath).href },
4095
- position: await toWirePosition(state, filePath, line, character),
4096
- }, filePath);
4097
- return result ?? null;
4098
- },
4099
- async signatureHelp(filePath, line, character) {
4100
- const result = await navRequest(state, "textDocument/signatureHelp", {
4101
- textDocument: { uri: pathToFileURL(filePath).href },
4102
- position: await toWirePosition(state, filePath, line, character),
4103
- }, filePath);
4104
- return result ?? null;
4105
- },
4106
- async documentSymbol(filePath) {
4107
- const result = await navRequest(state, "textDocument/documentSymbol", { textDocument: { uri: pathToFileURL(filePath).href } }, filePath);
4108
- return result ?? [];
4109
- },
4110
- isDocumentOpen(filePath) {
4111
- return state.openDocuments.has(normalizeMapKey(filePath));
4112
- },
4113
- openDocumentPaths() {
4114
- return [...state.openDocuments];
4115
- },
4116
- isBusy() {
4117
- return (activeRequestsByConnection.get(connection) ?? 0) > 0;
4118
- },
4119
- getDocumentUri(filePath) {
4120
- return state.openDocumentUris?.get(normalizeMapKey(filePath));
4121
- },
4122
- async workspaceSymbol(query) {
4123
- if (!isClientAlive(state))
4124
- return [];
4125
- // Route through navRequest for the shared withTimeout ceiling — a hung
4126
- // server would otherwise await forever (safeSendRequest only settles on
4127
- // a reply or a destroyed stream). No staleCheckPath: not single-file.
4128
- const result = await navRequest(state, "workspace/symbol", {
4129
- query,
4130
- });
4131
- return result ?? [];
4132
- },
4133
- async codeAction(filePath, line, character, endLine, endCharacter) {
4134
- if (!isClientAlive(state))
4135
- return [];
4136
- const uri = pathToFileURL(filePath).href;
4137
- // navRequest adds the shared withTimeout ceiling + single-file
4138
- // stale-drop (matches documentSymbol); a hung server no longer awaits
4139
- // forever, and code actions computed against superseded content drop.
4140
- const result = await navRequest(state, "textDocument/codeAction", {
4141
- textDocument: { uri },
4142
- range: {
4143
- start: await toWirePosition(state, filePath, line, character),
4144
- end: await toWirePosition(state, filePath, endLine, endCharacter),
4145
- },
4146
- context: {
4147
- diagnostics: getMergedDiagnosticsForPath(state, normalizeMapKey(filePath)),
4148
- },
4149
- }, filePath);
4150
- if (!result || !Array.isArray(result))
4151
- return [];
4152
- const actions = result.filter((item) => typeof item === "object" && item !== null && "title" in item);
4153
- return Promise.all(actions.map((action) => resolveCodeActionBestEffort(state, action)));
4154
- },
4155
- async rename(filePath, line, character, newName) {
4156
- const result = await navRequest(state, "textDocument/rename", {
4157
- textDocument: { uri: pathToFileURL(filePath).href },
4158
- position: await toWirePosition(state, filePath, line, character),
4159
- newName,
4160
- }, filePath);
4161
- return result ? await normalizeClientWorkspaceEdit(state, result) : null;
4162
- },
4163
- closeDocument: (filePath) => closeDocument(state, filePath),
4164
- async willRenameFiles(oldFilePath, newFilePath) {
4165
- if (!isClientAlive(state))
4166
- return null;
4167
- // #1971 review: the server registered WHICH paths it cares about.
4168
- // Sending outside those filters asks a question the server never
4169
- // signed up to answer.
4170
- const oldUri = pathToFileURL(oldFilePath).href;
4171
- const newUri = pathToFileURL(newFilePath).href;
4172
- if (!(await fileOperationFiltersMatch(state.fileOperationFilters?.willRename, oldUri, newUri, oldFilePath, newFilePath))) {
4173
- recordDegradationOnce({
4174
- kind: "lsp-capability-skip",
4175
- subject: `${state.serverId}:workspace/willRenameFiles:filter`,
4176
- reason: "filter-mismatch",
4177
- });
4178
- return null;
4179
- }
4180
- const result = await navRequest(state, "workspace/willRenameFiles", {
4181
- files: [
4182
- {
4183
- oldUri,
4184
- newUri,
4185
- },
4186
- ],
4187
- });
4188
- return result ? await normalizeClientWorkspaceEdit(state, result) : null;
4189
- },
4190
- async didRenameFiles(oldFilePath, newFilePath, oldUri, newUri) {
4191
- if (!isClientAlive(state))
4192
- return;
4193
- // `fileOperationFilters.didRename` is parsed by the same predicate that
4194
- // derives operationSupport.didRenameFiles (see
4195
- // isFileOperationRegistrationOptions). Thus filters undefined iff the
4196
- // capability is absent; the service-level gate remains the enforcement
4197
- // point for aggregated dispatch.
4198
- // #1971 review: same registration-filter discipline as willRename —
4199
- // the server only signed up to hear about matching paths.
4200
- const wireOldUri = oldUri ?? pathToFileURL(oldFilePath).href;
4201
- const wireNewUri = newUri ?? pathToFileURL(newFilePath).href;
4202
- if (!(await fileOperationFiltersMatch(state.fileOperationFilters?.didRename, wireOldUri, wireNewUri, oldFilePath, newFilePath))) {
4203
- recordDegradationOnce({
4204
- kind: "lsp-capability-skip",
4205
- subject: `${state.serverId}:workspace/didRenameFiles:filter`,
4206
- reason: "filter-mismatch",
4207
- });
4208
- return;
4209
- }
4210
- await safeSendNotification(state.connection, "workspace/didRenameFiles", {
4211
- files: [
4212
- {
4213
- oldUri: wireOldUri,
4214
- newUri: wireNewUri,
4215
- },
4216
- ],
4217
- });
4218
- },
4219
- async implementation(filePath, line, character) {
4220
- const result = await navRequest(state, "textDocument/implementation", {
4221
- textDocument: { uri: pathToFileURL(filePath).href },
4222
- position: await toWirePosition(state, filePath, line, character),
4223
- }, filePath);
4224
- if (!result)
4225
- return [];
4226
- return Array.isArray(result) ? result : [result];
4227
- },
4228
- async prepareCallHierarchy(filePath, line, character) {
4229
- const result = await navRequest(state, "textDocument/prepareCallHierarchy", {
4230
- textDocument: { uri: pathToFileURL(filePath).href },
4231
- position: await toWirePosition(state, filePath, line, character),
4232
- }, filePath);
4233
- if (!result)
4234
- return [];
4235
- return Array.isArray(result) ? result : [result];
4236
- },
4237
- async incomingCalls(item) {
4238
- const result = await navRequest(state, "callHierarchy/incomingCalls", { item });
4239
- return result ?? [];
4240
- },
4241
- async outgoingCalls(item) {
4242
- const result = await navRequest(state, "callHierarchy/outgoingCalls", { item });
4243
- return result ?? [];
4244
- },
4245
- async shutdown(options) {
4246
- return clientShutdown(state, options);
4247
- },
4248
- };
4249
- }
4250
- // Helper to safely send notifications - catches stream destruction
4251
- /**
4252
- * Returns `true` once the notification was actually handed to the transport,
4253
- * `false` when a stream error was swallowed (connection error handlers will
4254
- * update state separately). #1669 review F7: callers that mirror what they
4255
- * just told the server locally (`recordSentContent`) MUST gate on this
4256
- * return value — recording a send that never left the process would desync
4257
- * the mirror from what the server actually has, with nothing to self-heal
4258
- * it (the next Incremental range would be computed against content the
4259
- * server never saw).
4260
- */
4261
- async function safeSendNotification(connection, method, params) {
4262
- try {
4263
- await connection.sendNotification(method, params);
4264
- return true;
4265
- }
4266
- catch (err) {
4267
- if (isStreamError(err)) {
4268
- // Silently ignore - stream was destroyed, connection error handlers will update state
4269
- return false;
4270
- }
4271
- throw err;
4272
- }
4273
- }
4274
- const activeRequestsByConnection = new WeakMap();
4275
- // Helper to safely send requests - catches stream destruction
4276
- async function safeSendRequest(connection, method, params,
4277
- // When provided, aborting the signal cancels the in-flight request via
4278
- // vscode-jsonrpc's CancellationToken → an LSP `$/cancelRequest` notification,
4279
- // so a server stops computing a result the agent has already abandoned (#238
4280
- // Item 1). The rejection that follows is swallowed (treated as `undefined`).
4281
- signal, settlement) {
4282
- // Already abandoned before we even sent — don't bother the server.
4283
- if (signal?.aborted)
4284
- return undefined;
4285
- let tokenSource;
4286
- let onAbort;
4287
- if (signal) {
4288
- tokenSource = new CancellationTokenSource();
4289
- onAbort = () => tokenSource?.cancel();
4290
- signal.addEventListener("abort", onAbort, { once: true });
4291
- }
4292
- // Only pass a token when cancellation is wired, so the call shape is unchanged
4293
- // for the (many) requests without a signal.
4294
- const send = () => tokenSource
4295
- ? connection.sendRequest(method, params, tokenSource.token)
4296
- : connection.sendRequest(method, params);
4297
- activeRequestsByConnection.set(connection, (activeRequestsByConnection.get(connection) ?? 0) + 1);
4298
- try {
4299
- // One safe retry on ContentModified (-32801): the document changed under
4300
- // us, so the server discarded the request. A single retry beats returning
4301
- // empty — correctness-under-edit is pi-lens's whole hot path (#238 Item 2).
4302
- const MAX_ATTEMPTS = 2;
4303
- for (let attempt = 1;; attempt++) {
4304
- try {
4305
- return (await send());
4306
- }
4307
- catch (err) {
4308
- if (isCancellationError(err)) {
4309
- if (settlement)
4310
- settlement.cancelled = true;
4311
- return undefined;
4312
- }
4313
- if (isStreamError(err)) {
4314
- // Stream destroyed; connection handlers update client state separately.
4315
- return undefined;
4316
- }
4317
- if (isContentModifiedError(err)) {
4318
- // Retry once (unless we've since been aborted); if it's still
4319
- // ContentModified after that, return empty rather than throwing a
4320
- // code callers don't understand. RequestFailed (-32803) and other
4321
- // codes are permanent and fall through to the rethrow below.
4322
- if (attempt < MAX_ATTEMPTS && !signal?.aborted)
4323
- continue;
4324
- return undefined;
4325
- }
4326
- throw err;
4327
- }
4328
- }
4329
- }
4330
- finally {
4331
- const remaining = (activeRequestsByConnection.get(connection) ?? 1) - 1;
4332
- if (remaining > 0)
4333
- activeRequestsByConnection.set(connection, remaining);
4334
- else
4335
- activeRequestsByConnection.delete(connection);
4336
- if (signal && onAbort)
4337
- signal.removeEventListener("abort", onAbort);
4338
- tokenSource?.dispose();
4339
- }
4340
- }
4341
- // vscode-jsonrpc rejects a token-cancelled request with a `ResponseError` whose
4342
- // code is `RequestCancelled` (-32800) or `ServerCancelled` (-32802). Treat both
4343
- // as "no result" rather than a failure. (isStreamError also matches the
4344
- // "cancelled" message text; this adds the structured error-code path.)
4345
- function isCancellationError(err) {
4346
- const code = err?.code;
4347
- return code === -32800 || code === -32802;
4348
- }
4349
- // `ContentModified` (-32801): the document changed while the request was in
4350
- // flight, so the server couldn't answer against a consistent state. Retryable —
4351
- // the only LSP error code worth a second attempt on the edit hot path (#238).
4352
- function isContentModifiedError(err) {
4353
- return err?.code === -32801;
4354
- }
4355
- // Helper to detect stream destruction / connection disposal errors.
4356
- // vscode-jsonrpc throws these when the LSP server process exits while
4357
- // requests are still in flight:
4358
- // "Connection is disposed."
4359
- // "Pending response rejected since connection got disposed"
4360
- // Neither phrase contains "stream", "destroyed", or "closed", which is
4361
- // why we must also match "disposed" and "cancelled" here.
4362
- function isStreamError(err) {
4363
- if (!(err instanceof Error))
4364
- return false;
4365
- const msg = err.message.toLowerCase();
4366
- return (msg.includes("stream") ||
4367
- msg.includes("destroyed") ||
4368
- msg.includes("closed") ||
4369
- msg.includes("disposed") ||
4370
- msg.includes("cancelled") ||
4371
- err.code === "ERR_STREAM_DESTROYED" ||
4372
- err.code === "ERR_STREAM_WRITE_AFTER_END" ||
4373
- err.code === "EPIPE");
4374
- }
4375
- // Using shared path utilities from path-utils.ts
4376
- function positiveIntFromEnv(name, fallback) {
4377
- const raw = process.env[name];
4378
- if (!raw)
4379
- return fallback;
4380
- const parsed = Number.parseInt(raw, 10);
4381
- if (!Number.isFinite(parsed) || parsed <= 0)
4382
- return fallback;
4383
- return parsed;
4384
- }
4385
- function detectWorkspaceDiagnosticsSupport(initResult) {
4386
- const capabilities = typeof initResult === "object" && initResult !== null
4387
- ? initResult.capabilities
4388
- : undefined;
4389
- const diagnosticProvider = capabilities?.diagnosticProvider;
4390
- if (!diagnosticProvider) {
4391
- return {
4392
- advertised: false,
4393
- mode: "push-only",
4394
- workspaceDiagnostics: false,
4395
- diagnosticProviderKind: "none",
4396
- };
4397
- }
4398
- if (typeof diagnosticProvider === "boolean") {
4399
- return {
4400
- advertised: diagnosticProvider,
4401
- mode: diagnosticProvider ? "pull" : "push-only",
4402
- // The boolean form of diagnosticProvider only signals document pull.
4403
- workspaceDiagnostics: false,
4404
- diagnosticProviderKind: "boolean",
4405
- };
4406
- }
4407
- if (typeof diagnosticProvider === "object") {
4408
- return {
4409
- advertised: true,
4410
- mode: "pull",
4411
- workspaceDiagnostics: diagnosticProvider
4412
- .workspaceDiagnostics === true,
4413
- diagnosticProviderKind: "object",
4414
- };
4415
- }
4416
- return {
4417
- advertised: false,
4418
- mode: "push-only",
4419
- workspaceDiagnostics: false,
4420
- diagnosticProviderKind: typeof diagnosticProvider,
4421
- };
4422
- }
4423
- function detectExecuteCommands(initResult) {
4424
- const capabilities = typeof initResult === "object" && initResult !== null
4425
- ? initResult.capabilities
4426
- : undefined;
4427
- const provider = capabilities?.executeCommandProvider;
4428
- if (typeof provider !== "object" || provider === null)
4429
- return [];
4430
- const commands = provider.commands;
4431
- if (!Array.isArray(commands))
4432
- return [];
4433
- return commands.filter((cmd) => typeof cmd === "string");
4434
- }
4435
- function detectOperationSupport(initResult) {
4436
- const capabilities = typeof initResult === "object" && initResult !== null
4437
- ? initResult.capabilities
4438
- : undefined;
4439
- const hasProvider = (key) => {
4440
- const value = capabilities?.[key];
4441
- if (value === undefined || value === null)
4442
- return false;
4443
- if (typeof value === "boolean")
4444
- return value;
4445
- return isCapabilityRecord(value);
4446
- };
4447
- const codeActionProvider = capabilities?.codeActionProvider;
4448
- const workspace = capabilities?.workspace;
4449
- const fileOperations = isCapabilityRecord(workspace)
4450
- ? workspace.fileOperations
4451
- : undefined;
4452
- return {
4453
- definition: hasProvider("definitionProvider"),
4454
- typeDefinition: hasProvider("typeDefinitionProvider"),
4455
- declaration: hasProvider("declarationProvider"),
4456
- references: hasProvider("referencesProvider"),
4457
- hover: hasProvider("hoverProvider"),
4458
- signatureHelp: hasProvider("signatureHelpProvider"),
4459
- documentSymbol: hasProvider("documentSymbolProvider"),
4460
- workspaceSymbol: hasProvider("workspaceSymbolProvider"),
4461
- codeAction: hasProvider("codeActionProvider"),
4462
- codeActionResolve: isCapabilityRecord(codeActionProvider) &&
4463
- codeActionProvider.resolveProvider === true,
4464
- rename: hasProvider("renameProvider"),
4465
- willRenameFiles: isCapabilityRecord(fileOperations) &&
4466
- isFileOperationRegistrationOptions(fileOperations.willRename),
4467
- didRenameFiles: isCapabilityRecord(fileOperations) &&
4468
- isFileOperationRegistrationOptions(fileOperations.didRename),
4469
- implementation: hasProvider("implementationProvider"),
4470
- callHierarchy: hasProvider("callHierarchyProvider"),
4471
- };
4472
- }
4473
- function isCapabilityRecord(value) {
4474
- return typeof value === "object" && value !== null && !Array.isArray(value);
4475
- }
4476
- function isFileOperationRegistrationOptions(value) {
4477
- return parseFileOperationFilters(value) !== undefined;
4478
- }
4479
- /**
4480
- * Parse the filters of a `FileOperationRegistrationOptions` object. Returns
4481
- * `undefined` when the options are absent or malformed — callers treat that as
4482
- * "no interest declared" and fail closed, never sending the operation.
4483
- */
4484
- function parseFileOperationFilters(value) {
4485
- if (!isCapabilityRecord(value) || !Array.isArray(value.filters)) {
4486
- return undefined;
4487
- }
4488
- const filters = [];
4489
- for (const entry of value.filters) {
4490
- if (!isCapabilityRecord(entry))
4491
- return undefined;
4492
- const pattern = entry.pattern;
4493
- if (!isCapabilityRecord(pattern) ||
4494
- typeof pattern.glob !== "string" ||
4495
- pattern.glob.trim() === "") {
4496
- return undefined;
4497
- }
4498
- if (entry.scheme !== undefined &&
4499
- (typeof entry.scheme !== "string" ||
4500
- entry.scheme.trim() === "" ||
4501
- !/^[A-Za-z][A-Za-z0-9+.-]*$/.test(entry.scheme))) {
4502
- return undefined;
4503
- }
4504
- const matches = pattern.matches;
4505
- if (matches !== undefined && matches !== "file" && matches !== "folder") {
4506
- return undefined;
4507
- }
4508
- if (pattern.options !== undefined && !isCapabilityRecord(pattern.options)) {
4509
- return undefined;
4510
- }
4511
- const options = isCapabilityRecord(pattern.options)
4512
- ? pattern.options
4513
- : undefined;
4514
- if (options?.ignoreCase !== undefined &&
4515
- typeof options.ignoreCase !== "boolean") {
4516
- return undefined;
4517
- }
4518
- filters.push({
4519
- scheme: entry.scheme?.toLowerCase(),
4520
- glob: pattern.glob,
4521
- matches: matches === "file" || matches === "folder" ? matches : undefined,
4522
- ignoreCase: options?.ignoreCase === true,
4523
- });
4524
- }
4525
- return filters;
4526
- }
4527
- /**
4528
- * Whether ANY parsed filter expresses interest in either path of a rename.
4529
- * Conservative by design: unsupported URI schemes, an unresolved explicit
4530
- * file/folder kind, malformed URIs, and zero filters all match nothing.
4531
- */
4532
- async function fileOperationFiltersMatch(filters, oldUri, newUri, oldFilePath, newFilePath) {
4533
- if (!filters || filters.length === 0)
4534
- return false;
4535
- const candidates = [oldUri, newUri].flatMap(fileOperationUriCandidates);
4536
- const entityKind = filters.some((filter) => filter.matches !== undefined)
4537
- ? await probeRenameEntityKind(oldFilePath, newFilePath)
4538
- : undefined;
4539
- for (const filter of filters) {
4540
- if (filter.scheme !== undefined && filter.scheme !== "file")
4541
- continue;
4542
- if (filter.matches !== undefined && filter.matches !== entityKind)
4543
- continue;
4544
- for (const candidate of candidates) {
4545
- try {
4546
- if (minimatch(candidate, filter.glob, {
4547
- nocase: filter.ignoreCase,
4548
- dot: true,
4549
- })) {
4550
- return true;
4551
- }
4552
- }
4553
- catch {
4554
- // Malformed glob implementations must never widen server fan-out.
4555
- }
4556
- }
4557
- }
4558
- return false;
4559
- }
4560
- function fileOperationUriCandidates(uri) {
4561
- let parsed;
4562
- try {
4563
- parsed = new URL(uri);
4564
- }
4565
- catch {
4566
- return [];
4567
- }
4568
- // pi-lens performs local filesystem renames only. Even when a caller supplies
4569
- // preserved URI spelling, another scheme cannot describe that operation.
4570
- if (parsed.protocol.toLowerCase() !== "file:")
4571
- return [];
4572
- let uriPath;
4573
- try {
4574
- uriPath = decodeURIComponent(parsed.pathname).replace(/\\/g, "/");
4575
- }
4576
- catch {
4577
- return [];
4578
- }
4579
- return [uriPath];
4580
- }
4581
- async function probeRenameEntityKind(oldFilePath, newFilePath) {
4582
- for (const filePath of [oldFilePath, newFilePath]) {
4583
- try {
4584
- const info = await lstat(filePath);
4585
- return info.isDirectory() ? "folder" : "file";
4586
- }
4587
- catch {
4588
- // Before the rename the old path normally exists; after it, the new path
4589
- // does. Probe both instead of deriving behavior from the host OS.
4590
- }
4591
- }
4592
- return undefined;
4593
- }