pi-lens 4.1.2 → 4.1.4

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 (359) hide show
  1. package/CHANGELOG.md +1308 -0
  2. package/README.md +3 -0
  3. package/config/biome/core.jsonc +11 -2
  4. package/config/dependency-cruiser-eager-allowlist.json +91 -0
  5. package/dist/clients/actionable-warnings-logger.js +18 -3
  6. package/dist/clients/actionable-warnings.js +927 -80
  7. package/dist/clients/agent-behavior-client.js +13 -4
  8. package/dist/clients/ast-grep-client.js +136 -9
  9. package/dist/clients/ast-grep-rule-manager.js +60 -5
  10. package/dist/clients/ast-grep-tool-logger.js +2 -2
  11. package/dist/clients/availability-probe-flight.js +12 -0
  12. package/dist/clients/bash-file-access.js +1 -3
  13. package/dist/clients/biome-client.js +16 -2
  14. package/dist/clients/blocker-freshness.js +34 -0
  15. package/dist/clients/bootstrap.js +509 -73
  16. package/dist/clients/bounded-cache.js +152 -12
  17. package/dist/clients/bounded-telemetry.js +115 -23
  18. package/dist/clients/build-identity.js +95 -0
  19. package/dist/clients/bundled-resource-health.js +113 -0
  20. package/dist/clients/bus-events-logger.js +2 -2
  21. package/dist/clients/bus-publish.js +3 -0
  22. package/dist/clients/cache/rule-cache.js +33 -5
  23. package/dist/clients/cache-manager.js +105 -6
  24. package/dist/clients/cache-observability.js +46 -19
  25. package/dist/clients/cargo-manifest.js +422 -0
  26. package/dist/clients/cascade-logger.js +17 -3
  27. package/dist/clients/code-quality-warnings.js +13 -3
  28. package/dist/clients/complexity-client.js +16 -5
  29. package/dist/clients/config-core/deny.js +221 -0
  30. package/dist/clients/config-core/index.js +47 -0
  31. package/dist/clients/config-core/merge.js +357 -0
  32. package/dist/clients/config-core/normalize.js +340 -0
  33. package/dist/clients/config-core/process-spec.js +248 -0
  34. package/dist/clients/config-core/provenance.js +171 -0
  35. package/dist/clients/config-core/records.js +218 -0
  36. package/dist/clients/config-core/resolve.js +125 -0
  37. package/dist/clients/config-core/safe-object.js +78 -0
  38. package/dist/clients/config-core/schema.js +167 -0
  39. package/dist/clients/config-diagnostic-codes.js +281 -0
  40. package/dist/clients/config-locations.js +160 -0
  41. package/dist/clients/config-resolve.js +789 -0
  42. package/dist/clients/config-schema.js +197 -0
  43. package/dist/clients/config-warn.js +407 -0
  44. package/dist/clients/dead-code-client.js +33 -25
  45. package/dist/clients/dead-code-logger.js +2 -2
  46. package/dist/clients/deadline-utils.js +178 -0
  47. package/dist/clients/debug-handles.js +2 -2
  48. package/dist/clients/debug-heap.js +3 -3
  49. package/dist/clients/deferred-lsp-work.js +106 -0
  50. package/dist/clients/degradation-ledger.js +166 -8
  51. package/dist/clients/demoted-finding-render.js +14 -0
  52. package/dist/clients/dependency-checker.js +28 -22
  53. package/dist/clients/deps/minimatch.js +1 -1
  54. package/dist/clients/diagnostic-line-freshness.js +12 -9
  55. package/dist/clients/diagnostic-logger.js +14 -4
  56. package/dist/clients/dispatch/collect-later-tier.js +39 -0
  57. package/dist/clients/dispatch/dispatcher.js +304 -19
  58. package/dist/clients/dispatch/fact-store.js +328 -9
  59. package/dist/clients/dispatch/integration.js +214 -107
  60. package/dist/clients/dispatch/pending-runner-findings.js +87 -0
  61. package/dist/clients/dispatch/plan.js +21 -5
  62. package/dist/clients/dispatch/runners/actionlint.js +0 -1
  63. package/dist/clients/dispatch/runners/ast-grep-napi.js +550 -77
  64. package/dist/clients/dispatch/runners/biome-check.js +0 -1
  65. package/dist/clients/dispatch/runners/cpp-check.js +0 -1
  66. package/dist/clients/dispatch/runners/credo.js +0 -1
  67. package/dist/clients/dispatch/runners/cue-vet.js +0 -1
  68. package/dist/clients/dispatch/runners/dart-analyze.js +0 -1
  69. package/dist/clients/dispatch/runners/detekt.js +0 -1
  70. package/dist/clients/dispatch/runners/dotnet-build.js +0 -1
  71. package/dist/clients/dispatch/runners/elixir-check.js +0 -1
  72. package/dist/clients/dispatch/runners/eslint.js +5 -2
  73. package/dist/clients/dispatch/runners/fact-rules.js +0 -1
  74. package/dist/clients/dispatch/runners/fish-indent.js +0 -1
  75. package/dist/clients/dispatch/runners/gleam-check.js +0 -1
  76. package/dist/clients/dispatch/runners/go-vet.js +1 -3
  77. package/dist/clients/dispatch/runners/golangci-lint.js +0 -1
  78. package/dist/clients/dispatch/runners/hadolint.js +0 -1
  79. package/dist/clients/dispatch/runners/helm-lint.js +16 -4
  80. package/dist/clients/dispatch/runners/helm-render.js +54 -10
  81. package/dist/clients/dispatch/runners/htmlhint.js +0 -1
  82. package/dist/clients/dispatch/runners/index.js +1 -3
  83. package/dist/clients/dispatch/runners/javac.js +0 -1
  84. package/dist/clients/dispatch/runners/ktlint.js +0 -1
  85. package/dist/clients/dispatch/runners/lsp.js +37 -2
  86. package/dist/clients/dispatch/runners/markdownlint.js +4 -2
  87. package/dist/clients/dispatch/runners/mypy.js +0 -1
  88. package/dist/clients/dispatch/runners/oxlint.js +24 -22
  89. package/dist/clients/dispatch/runners/php-lint.js +0 -1
  90. package/dist/clients/dispatch/runners/phpstan.js +0 -1
  91. package/dist/clients/dispatch/runners/prisma-validate.js +0 -1
  92. package/dist/clients/dispatch/runners/psscriptanalyzer.js +4 -1
  93. package/dist/clients/dispatch/runners/pyright.js +7 -4
  94. package/dist/clients/dispatch/runners/rubocop.js +0 -1
  95. package/dist/clients/dispatch/runners/ruff.js +0 -1
  96. package/dist/clients/dispatch/runners/rust-clippy.js +9 -6
  97. package/dist/clients/dispatch/runners/shellcheck.js +0 -1
  98. package/dist/clients/dispatch/runners/shfmt.js +0 -1
  99. package/dist/clients/dispatch/runners/spellcheck.js +0 -1
  100. package/dist/clients/dispatch/runners/spotbugs.js +0 -1
  101. package/dist/clients/dispatch/runners/sqlfluff.js +0 -1
  102. package/dist/clients/dispatch/runners/stylelint.js +0 -1
  103. package/dist/clients/dispatch/runners/swiftlint.js +0 -1
  104. package/dist/clients/dispatch/runners/taplo.js +0 -1
  105. package/dist/clients/dispatch/runners/terragrunt.js +0 -1
  106. package/dist/clients/dispatch/runners/tflint.js +0 -1
  107. package/dist/clients/dispatch/runners/tree-sitter.js +0 -1
  108. package/dist/clients/dispatch/runners/trivy-config.js +0 -1
  109. package/dist/clients/dispatch/runners/utils/availability-policy.js +3 -0
  110. package/dist/clients/dispatch/runners/utils/diagnostic-parsers.js +3 -4
  111. package/dist/clients/dispatch/runners/utils/runner-helpers.js +270 -105
  112. package/dist/clients/dispatch/runners/utils/spawn-outcome.js +4 -0
  113. package/dist/clients/dispatch/runners/utils/tool-failure.js +19 -7
  114. package/dist/clients/dispatch/runners/utils/toolchain-availability.js +89 -26
  115. package/dist/clients/dispatch/runners/vale.js +0 -1
  116. package/dist/clients/dispatch/runners/yaml-rule-parser.js +51 -17
  117. package/dist/clients/dispatch/runners/yamllint.js +0 -1
  118. package/dist/clients/dispatch/runners/zig-check.js +0 -1
  119. package/dist/clients/disposition-logger.js +3 -3
  120. package/dist/clients/effective-config.js +403 -0
  121. package/dist/clients/error-class.js +23 -0
  122. package/dist/clients/event-loop-hold.js +274 -0
  123. package/dist/clients/event-loop-monitor.js +66 -1
  124. package/dist/clients/extension-log.js +2 -2
  125. package/dist/clients/feature-hints.js +2 -1
  126. package/dist/clients/file-time.js +6 -1
  127. package/dist/clients/file-utils.js +266 -13
  128. package/dist/clients/finding-delivery-gate.js +55 -13
  129. package/dist/clients/format-service.js +6 -1
  130. package/dist/clients/formatters.js +303 -104
  131. package/dist/clients/freshness-cadence.js +17 -0
  132. package/dist/clients/generated-artifacts.js +114 -19
  133. package/dist/clients/generation-guard.js +4 -9
  134. package/dist/clients/git-guard.js +198 -48
  135. package/dist/clients/git-tracked-ignore.js +32 -1
  136. package/dist/clients/go-client.js +39 -0
  137. package/dist/clients/govulncheck-client.js +5 -1
  138. package/dist/clients/gradle-ktfmt-style.js +252 -0
  139. package/dist/clients/gzip-stage-write.js +7 -0
  140. package/dist/clients/hashline-anchor.js +424 -0
  141. package/dist/clients/hook-budgets.js +76 -0
  142. package/dist/clients/host-edit-normalize.js +5 -2
  143. package/dist/clients/host-ports.js +1 -1
  144. package/dist/clients/installer/index.js +200 -84
  145. package/dist/clients/installer/managed-tool-refresh.js +3 -1
  146. package/dist/clients/instance-reaper.js +108 -243
  147. package/dist/clients/instance-registry-lock.js +145 -0
  148. package/dist/clients/instance-registry.js +435 -122
  149. package/dist/clients/jscpd-client.js +6 -1
  150. package/dist/clients/language-profile.js +4 -2
  151. package/dist/clients/language-registry.js +595 -0
  152. package/dist/clients/latency-logger.js +179 -3
  153. package/dist/clients/ledger-bounds.js +13 -0
  154. package/dist/clients/lens-config.js +141 -30
  155. package/dist/clients/lens-engine.js +30 -37
  156. package/dist/clients/lens-flag-registry.js +67 -6
  157. package/dist/clients/lens-map.js +3 -1
  158. package/dist/clients/log-cleanup.js +2 -2
  159. package/dist/clients/lsp/aggregation.js +3 -1
  160. package/dist/clients/lsp/cascade-tier.js +4 -0
  161. package/dist/clients/lsp/client.js +672 -49
  162. package/dist/clients/lsp/config.js +504 -156
  163. package/dist/clients/lsp/diagnostic-binding.js +10 -5
  164. package/dist/clients/lsp/edits.js +15 -2
  165. package/dist/clients/lsp/index.js +1179 -247
  166. package/dist/clients/lsp/jvm-runtime.js +5 -0
  167. package/dist/clients/lsp/language.js +18 -169
  168. package/dist/clients/lsp/launch.js +38 -9
  169. package/dist/clients/lsp/pending-aux-coverage.js +113 -12
  170. package/dist/clients/lsp/server.js +426 -160
  171. package/dist/clients/lsp/session-roots.js +192 -0
  172. package/dist/clients/lsp/spawn-history.js +2 -7
  173. package/dist/clients/lsp/tsserver-sync.js +9 -2
  174. package/dist/clients/lsp/workspace-diagnostics-cache.js +17 -7
  175. package/dist/clients/lsp/workspace-sweep-hold.js +32 -23
  176. package/dist/clients/lsp-mutation.js +179 -23
  177. package/dist/clients/map-with-concurrency.js +36 -0
  178. package/dist/clients/mcp/analyze.js +45 -10
  179. package/dist/clients/mcp/session.js +19 -17
  180. package/dist/clients/memory-sampler.js +28 -1
  181. package/dist/clients/message-end-attribution.js +30 -0
  182. package/dist/clients/metrics-history.js +21 -4
  183. package/dist/clients/middle-man-analysis.js +2 -4
  184. package/dist/clients/module-report.js +20 -45
  185. package/dist/clients/mutating-tool.js +651 -0
  186. package/dist/clients/mutation-attribution.js +368 -0
  187. package/dist/clients/mutation-bridge.js +240 -0
  188. package/dist/clients/ndjson-logger.js +247 -20
  189. package/dist/clients/observed-mutation-sources.js +101 -0
  190. package/dist/clients/observed-mutation.js +1215 -0
  191. package/dist/clients/opaque-mutation-scan.js +300 -40
  192. package/dist/clients/opengrep-config.js +4 -0
  193. package/dist/clients/package-manager.js +201 -44
  194. package/dist/clients/partial-edit-apply.js +359 -80
  195. package/dist/clients/path-attribution-telemetry.js +23 -7
  196. package/dist/clients/path-keyed-map.js +35 -3
  197. package/dist/clients/path-utils.js +460 -13
  198. package/dist/clients/persist-debounce.js +8 -1
  199. package/dist/clients/php-cs-fixer-config.js +114 -0
  200. package/dist/clients/pipeline.js +77 -24
  201. package/dist/clients/probe-home-state.js +228 -0
  202. package/dist/clients/process-bridge.js +66 -0
  203. package/dist/clients/process-singletons.js +210 -0
  204. package/dist/clients/process-snapshot.js +68 -0
  205. package/dist/clients/project-diagnostics/fresh-fetch.js +4 -3
  206. package/dist/clients/project-diagnostics/runner-adapters/madge.js +2 -1
  207. package/dist/clients/project-diagnostics/runner-adapters/runner-findings.js +27 -3
  208. package/dist/clients/project-diagnostics/scanner.js +61 -24
  209. package/dist/clients/project-lens-config.js +393 -122
  210. package/dist/clients/project-snapshot.js +85 -35
  211. package/dist/clients/python-environment.js +217 -0
  212. package/dist/clients/python-provenance.js +639 -0
  213. package/dist/clients/quiet-window.js +6 -1
  214. package/dist/clients/read-bridge.js +13 -21
  215. package/dist/clients/read-expansion.js +9 -46
  216. package/dist/clients/read-guard-logger.js +39 -6
  217. package/dist/clients/read-guard-tool-lines.js +364 -212
  218. package/dist/clients/read-guard.js +95 -7
  219. package/dist/clients/recent-touches.js +17 -5
  220. package/dist/clients/resource-sampler.js +195 -96
  221. package/dist/clients/review-graph/builder.js +752 -132
  222. package/dist/clients/review-graph/service.js +55 -3
  223. package/dist/clients/review-graph/shared-extraction-ir.js +7 -15
  224. package/dist/clients/review-graph/tsconfig-paths.js +5 -1
  225. package/dist/clients/review-graph/workspace-modules.js +10 -74
  226. package/dist/clients/review-graph-logger.js +30 -4
  227. package/dist/clients/runtime-agent-end.js +18 -2
  228. package/dist/clients/runtime-context.js +49 -16
  229. package/dist/clients/runtime-coordinator.js +93 -6
  230. package/dist/clients/runtime-session.js +169 -34
  231. package/dist/clients/runtime-tool-call.js +183 -35
  232. package/dist/clients/runtime-tool-result.js +894 -241
  233. package/dist/clients/runtime-turn.js +1264 -74
  234. package/dist/clients/rust-client.js +31 -0
  235. package/dist/clients/safe-spawn.js +191 -28
  236. package/dist/clients/sanitize.js +7 -1
  237. package/dist/clients/scratch-tree-policy.js +1 -3
  238. package/dist/clients/security-scan-client.js +51 -17
  239. package/dist/clients/session-lifecycle.js +238 -42
  240. package/dist/clients/session-start-observability.js +79 -0
  241. package/dist/clients/sessionstart-logger.js +12 -3
  242. package/dist/clients/sg-runner.js +27 -15
  243. package/dist/clients/sgconfig.js +6 -1
  244. package/dist/clients/shared-checkout-guard.js +338 -0
  245. package/dist/clients/skills-resolver.js +105 -0
  246. package/dist/clients/smells-rollup.js +2 -2
  247. package/dist/clients/spawn-output-cap.js +37 -0
  248. package/dist/clients/spawn-timeout-cooldown.js +15 -1
  249. package/dist/clients/startup-scan.js +6 -7
  250. package/dist/clients/startup-timing.js +69 -4
  251. package/dist/clients/string-utils.js +33 -0
  252. package/dist/clients/subagent-mode.js +17 -4
  253. package/dist/clients/test-runner-client.js +586 -118
  254. package/dist/clients/test-runner-delivery.js +239 -0
  255. package/dist/clients/tool-definition.js +41 -1
  256. package/dist/clients/tool-policy.js +92 -22
  257. package/dist/clients/tree-sitter-cache.js +40 -40
  258. package/dist/clients/tree-sitter-client.js +97 -53
  259. package/dist/clients/tree-sitter-logger.js +19 -3
  260. package/dist/clients/tree-sitter-query-loader.js +81 -1
  261. package/dist/clients/tree-sitter-shared.js +51 -46
  262. package/dist/clients/tree-sitter-symbol-extractor.js +28 -0
  263. package/dist/clients/typos-config.js +5 -0
  264. package/dist/clients/user-notify.js +6 -2
  265. package/dist/clients/warm-attach.js +8 -10
  266. package/dist/clients/widget-state.js +191 -26
  267. package/dist/clients/word-index-logger.js +15 -3
  268. package/dist/clients/word-index-store.js +463 -0
  269. package/dist/clients/word-index.js +815 -125
  270. package/dist/clients/workspace-topology.js +25 -1
  271. package/dist/clients/zizmor-config.js +29 -6
  272. package/dist/index.js +68577 -53950
  273. package/dist/mcp/analyze-cli.js +3 -1
  274. package/dist/mcp/build-staleness.js +8 -7
  275. package/dist/mcp/cli.js +3 -1
  276. package/dist/mcp/server.js +133 -6
  277. package/dist/scripts/lib/process-scan.mjs +583 -0
  278. package/dist/scripts/lib/skills-predicate.mjs +129 -0
  279. package/dist/tools/effective-config.js +89 -0
  280. package/dist/tools/lens-diagnostics.js +99 -24
  281. package/dist/tools/lsp-diagnostics.js +81 -85
  282. package/dist/tools/lsp-navigation.js +40 -17
  283. package/dist/tools/shared.js +0 -1
  284. package/docs/agent-guide.md +68 -3
  285. package/docs/ast-grep_rules_catalog.md +41 -10
  286. package/docs/astplayground.md +26 -17
  287. package/docs/configuration.md +222 -0
  288. package/docs/dependencies.md +3 -3
  289. package/docs/features.md +44 -5
  290. package/docs/language-coverage.md +2 -2
  291. package/docs/pi-lens-fixer.md +25 -0
  292. package/docs/pi-lens-investigator.md +25 -0
  293. package/docs/pi-lens-reviewer.md +27 -0
  294. package/docs/pi-lens-subagent.md +38 -0
  295. package/docs/pi-lens-warden.md +55 -0
  296. package/docs/public-api-stability.md +359 -0
  297. package/docs/release-qa-baseline.md +182 -0
  298. package/docs/subagent-compat.md +110 -30
  299. package/docs/tree-sitter_rules_catalog.md +2 -2
  300. package/package.json +17 -21
  301. package/rules/ast-grep-rules/rule-tests/c-no-malloc-free-test.yml +20 -0
  302. package/rules/ast-grep-rules/rule-tests/cpp-no-malloc-free-test.yml +25 -0
  303. package/rules/ast-grep-rules/rule-tests/cpp-no-printf-test.yml +19 -0
  304. package/rules/ast-grep-rules/rule-tests/go-no-fmt-println-test.yml +19 -0
  305. package/rules/ast-grep-rules/rule-tests/go-no-panic-in-lib-test.yml +17 -0
  306. package/rules/ast-grep-rules/rule-tests/go-no-underscore-func-name-test.yml +18 -0
  307. package/rules/ast-grep-rules/rule-tests/go-prefer-errors-is-test.yml +15 -0
  308. package/rules/ast-grep-rules/rule-tests/go-prefer-string-builder-test.yml +21 -0
  309. package/rules/ast-grep-rules/rule-tests/hyphenated-svg-attribute-test.yml +11 -0
  310. package/rules/ast-grep-rules/rule-tests/kotlin-no-lateinit-test.yml +17 -0
  311. package/rules/ast-grep-rules/rule-tests/kotlin-no-nullable-boolean-test.yml +15 -0
  312. package/rules/ast-grep-rules/rule-tests/kotlin-no-println-test.yml +19 -0
  313. package/rules/ast-grep-rules/rule-tests/kotlin-no-unnecessary-let-test.yml +7 -0
  314. package/rules/ast-grep-rules/rule-tests/kotlin-no-unsafe-cast-test.yml +7 -0
  315. package/rules/ast-grep-rules/rule-tests/kotlin-no-var-test.yml +7 -0
  316. package/rules/ast-grep-rules/rule-tests/kotlin-prefer-data-class-test.yml +25 -0
  317. package/rules/ast-grep-rules/rule-tests/kotlin-prefer-is-empty-test.yml +11 -0
  318. package/rules/ast-grep-rules/rule-tests/no-important-test.yml +21 -0
  319. package/rules/ast-grep-rules/rule-tests/no-raw-types-test.yml +30 -0
  320. package/rules/ast-grep-rules/rule-tests/no-string-concat-in-loop-test.yml +100 -0
  321. package/rules/ast-grep-rules/rule-tests/no-system-out-println-test.yml +7 -0
  322. package/rules/ast-grep-rules/rule-tests/prefer-string-is-empty-test.yml +7 -0
  323. package/rules/ast-grep-rules/rule-tests/python-optional-type-test.yml +9 -0
  324. package/rules/ast-grep-rules/rule-tests/reducible-list-comprehension-test.yml +11 -0
  325. package/rules/ast-grep-rules/rule-tests/ruby-symbol-to-proc-candidate-test.yml +10 -0
  326. package/rules/ast-grep-rules/rule-tests/rust-no-chars-enumerate-test.yml +7 -0
  327. package/rules/ast-grep-rules/rules/c-no-malloc-free.yml +39 -0
  328. package/rules/ast-grep-rules/rules/cpp-no-malloc-free.yml +45 -0
  329. package/rules/ast-grep-rules/rules/cpp-no-printf.yml +41 -0
  330. package/rules/ast-grep-rules/rules/go-no-fmt-println.yml +43 -0
  331. package/rules/ast-grep-rules/rules/go-no-panic-in-lib.yml +41 -0
  332. package/rules/ast-grep-rules/rules/go-no-underscore-func-name.yml +35 -0
  333. package/rules/ast-grep-rules/rules/go-prefer-errors-is.yml +40 -0
  334. package/rules/ast-grep-rules/rules/go-prefer-string-builder.yml +43 -0
  335. package/rules/ast-grep-rules/rules/hyphenated-svg-attribute.yml +32 -0
  336. package/rules/ast-grep-rules/rules/kotlin-no-lateinit.yml +37 -0
  337. package/rules/ast-grep-rules/rules/kotlin-no-nullable-boolean.yml +33 -0
  338. package/rules/ast-grep-rules/rules/kotlin-no-println.yml +42 -0
  339. package/rules/ast-grep-rules/rules/kotlin-no-unnecessary-let.yml +28 -0
  340. package/rules/ast-grep-rules/rules/kotlin-no-unsafe-cast.yml +28 -0
  341. package/rules/ast-grep-rules/rules/kotlin-no-var.yml +35 -0
  342. package/rules/ast-grep-rules/rules/kotlin-prefer-data-class.yml +66 -0
  343. package/rules/ast-grep-rules/rules/kotlin-prefer-is-empty.yml +35 -0
  344. package/rules/ast-grep-rules/rules/no-important.yml +21 -0
  345. package/rules/ast-grep-rules/rules/no-raw-types.yml +33 -0
  346. package/rules/ast-grep-rules/rules/no-string-concat-in-loop.yml +90 -0
  347. package/rules/ast-grep-rules/rules/no-system-out-println.yml +16 -0
  348. package/rules/ast-grep-rules/rules/prefer-string-is-empty.yml +11 -0
  349. package/rules/ast-grep-rules/rules/python-optional-type.yml +22 -0
  350. package/rules/ast-grep-rules/rules/reducible-list-comprehension.yml +29 -0
  351. package/rules/ast-grep-rules/rules/ruby-symbol-to-proc-candidate.yml +25 -0
  352. package/rules/ast-grep-rules/rules/rust-no-chars-enumerate.yml +19 -0
  353. package/rules/tree-sitter-queries/python/python-cross-language-method.yml +3 -3
  354. package/rules/tree-sitter-queries/python/python-hallucinated-import.yml +2 -2
  355. package/rules/tree-sitter-queries/python/python-sql-injection.yml +12 -2
  356. package/scripts/analyze-pi-lens-logs.mjs +192 -1
  357. package/scripts/install-selftest.mjs +58 -2
  358. package/scripts/lib/skills-predicate.mjs +129 -0
  359. package/scripts/rpc-load-check.mjs +3 -1
@@ -9,19 +9,22 @@
9
9
  */
10
10
  import { spawn as nodeSpawn } from "node:child_process";
11
11
  import { EventEmitter } from "node:events";
12
- import { access, readFile } from "node:fs/promises";
12
+ import { access, lstat, readFile } from "node:fs/promises";
13
13
  import * as os from "node:os";
14
14
  import { pathToFileURL } from "node:url";
15
15
  import { emitBounded } from "../bounded-telemetry.js";
16
16
  import { withTimeout } from "../deadline-utils.js";
17
- import { incrementDegradationCount } from "../degradation-ledger.js";
17
+ import { minimatch } from "../deps/minimatch.js";
18
+ import { incrementDegradationCount, recordDegradationOnce, } from "../degradation-ledger.js";
18
19
  // vscode-jsonrpc v9 ships an `exports` map exposing the Node entry as the
19
20
  // `./node` subpath (no `.js`); the old `/node.js` file path no longer resolves.
20
21
  import { CancellationTokenSource, createMessageConnection, StreamMessageReader, StreamMessageWriter, } from "../deps/vscode-jsonrpc.js";
21
22
  import { logExtension } from "../extension-log.js";
22
23
  import { recordLspChild, removeLspChild } from "../instance-registry.js";
24
+ import { workspaceDiagnosticsCacheSessionStart } from "./workspace-diagnostics-session.js";
23
25
  import { logLatency } from "../latency-logger.js";
24
26
  import { newLspMutationCorrelationId, } from "../lsp-mutation.js";
27
+ import { getProcessSingleton } from "../process-singletons.js";
25
28
  import { getAmbientAbortSignal } from "../safe-spawn.js";
26
29
  import { raceToCompletion } from "./aggregation.js";
27
30
  import { hashDiagnosticContent, } from "./diagnostic-binding.js";
@@ -67,7 +70,7 @@ function extractSpawnMarker(args) {
67
70
  return undefined;
68
71
  }
69
72
  // --- Constants ---
70
- const INITIALIZE_TIMEOUT_MS = positiveIntFromEnv("PI_LENS_LSP_INIT_TIMEOUT_MS", 15_000); // 15s — npx downloads are handled by ensureTool, not here
73
+ export const INITIALIZE_TIMEOUT_MS = positiveIntFromEnv("PI_LENS_LSP_INIT_TIMEOUT_MS", 15_000); // 15s — npx downloads are handled by ensureTool, not here
71
74
  /**
72
75
  * The client capabilities advertised in every `initialize`. The textDocument set
73
76
  * is intentionally COMPLETE and spec-compliant: servers built on
@@ -96,6 +99,11 @@ export const CLIENT_CAPABILITIES = {
96
99
  workspaceFolders: true,
97
100
  configuration: true,
98
101
  didChangeWatchedFiles: { dynamicRegistration: true },
102
+ fileOperations: {
103
+ dynamicRegistration: false,
104
+ willRename: true,
105
+ didRename: true,
106
+ },
99
107
  },
100
108
  textDocument: {
101
109
  synchronization: {
@@ -115,7 +123,27 @@ export const CLIENT_CAPABILITIES = {
115
123
  implementation: { dynamicRegistration: false },
116
124
  references: { dynamicRegistration: false },
117
125
  documentSymbol: { dynamicRegistration: false },
118
- codeAction: { dynamicRegistration: false },
126
+ codeAction: {
127
+ dynamicRegistration: false,
128
+ dataSupport: true,
129
+ resolveSupport: { properties: ["edit", "command"] },
130
+ // #1971 review: without literal support, LSP 3.17 constrains
131
+ // textDocument/codeAction responses to Command[] — resolve of an edit
132
+ // would then be protocol-invalid. We parse CodeAction objects.
133
+ codeActionLiteralSupport: {
134
+ codeActionKind: {
135
+ valueSet: [
136
+ "quickfix",
137
+ "refactor",
138
+ "refactor.extract",
139
+ "refactor.inline",
140
+ "refactor.rewrite",
141
+ "source",
142
+ "source.organizeImports",
143
+ ],
144
+ },
145
+ },
146
+ },
119
147
  rename: { dynamicRegistration: false },
120
148
  publishDiagnostics: {
121
149
  relatedInformation: true,
@@ -253,6 +281,78 @@ function installCrashGuard() {
253
281
  throw reason instanceof Error ? reason : new Error(String(reason));
254
282
  });
255
283
  }
284
+ /** #2065: bound both the path count and UTF-16 heap represented by retained
285
+ * Incremental-sync text. The map keeps the newest sent paths when evicting. */
286
+ export const MAX_INCREMENTAL_TEXT_RETAINED_ENTRIES = 128;
287
+ export const MAX_INCREMENTAL_TEXT_RETAINED_BYTES = 64 * 1024 * 1024;
288
+ /**
289
+ * #2065 fix round 1: a PROJECTION registry, not a second source of truth for
290
+ * "which clients are live" — its only job is letting `memory-sampler.ts`
291
+ * total retained-text bytes across clients without importing `LSPService`.
292
+ * Considered projecting straight off `LSPService.state.clients` (the
293
+ * manager's own client map, `clients/lsp/index.ts`) instead of keeping this
294
+ * Set at all, and rejected it for two reasons:
295
+ * 1. Import direction: `clients/lsp/index.ts` imports THIS file, not the
296
+ * reverse, so reading `LSPService.state.clients` from here (or from
297
+ * `memory-sampler.ts`, which every other snapshot in that module reaches
298
+ * by importing the owning subsystem directly, e.g.
299
+ * `getReviewGraphWorkspaceCacheSnapshot`, `getDispatchCascadeCacheStats`)
300
+ * means importing the higher-level manager from the lower-level client
301
+ * module — a layering inversion, not a cycle fix.
302
+ * 2. `LSPService.state.clients` is ITSELF only lazily reconciled against
303
+ * real client death — a crashed client's slot is noticed and evicted on
304
+ * the next `getClientForFile` attach, not at crash time (see
305
+ * `markExitedIfUnset`'s docstring above). Projecting off it would not
306
+ * give memory-sampler any more real-time accuracy than this Set; it
307
+ * would just move the same "who deregisters, and when" question onto a
308
+ * larger, higher-risk surface (the manager's routing map) for no gain.
309
+ *
310
+ * What makes this safe to keep as a second collection: it has exactly ONE
311
+ * lifecycle seam. `spawnClient` is the only writer that adds (below), and
312
+ * `disposeClientConnection` is the only writer that removes — and that
313
+ * function is itself the single convergence point every permanent-death path
314
+ * (`connection.onError`, `connection.onClose`, the process `exit` handler,
315
+ * and `clientShutdownOnce`'s `finally`) already funnels through, guarded by
316
+ * the same `connectionDisposed` idempotency flag that gates the connection
317
+ * teardown itself. Membership can therefore never drift from "has this
318
+ * client's connection been torn down" — there is nowhere else in the file
319
+ * that can flip a client from alive to permanently dead without going
320
+ * through `disposeClientConnection`.
321
+ *
322
+ * PROCESS-SCOPED, not module-scoped (#2130 criterion 2). This Set is what
323
+ * `memory_sample.subsystems.lsp.clients` counts, and a module-scope Set counted
324
+ * only the clients this MODULE EVALUATION spawned. pi evaluates the pi-lens
325
+ * graph up to nine times per process (#2146, measured `host_boot` = 9) and each
326
+ * evaluation builds its own `LSPService` and fleet (#2157 item 1), so a
327
+ * secondary root's servers — spawned by a later evaluation — were invisible to
328
+ * the sampler running in the first. That is the reported symptom exactly:
329
+ * `clients: 1` in `memory_sample` beside `lspChildCount: 2` in `instances.json`,
330
+ * with two tsservers alive. Sharing the Set through `getProcessSingleton` makes
331
+ * both the count and `roots` span every root the process serves.
332
+ *
333
+ * Both seams share it, which is the whole requirement: sharing only the add
334
+ * site would build a Set that never shrinks, a worse leak than the undercount.
335
+ */
336
+ const ACTIVE_CLIENTS_FAMILY = "lsp-client.active-clients";
337
+ /** Bump when the cell's shape changes. */
338
+ const ACTIVE_CLIENTS_VERSION = 1;
339
+ function activeLspClientSet() {
340
+ return getProcessSingleton(ACTIVE_CLIENTS_FAMILY, ACTIVE_CLIENTS_VERSION, () => new Set());
341
+ }
342
+ export function getLspDocumentTextRetentionSnapshot() {
343
+ let entries = 0;
344
+ let bytes = 0;
345
+ const roots = new Set();
346
+ const active = activeLspClientSet();
347
+ for (const state of active) {
348
+ entries += state.incrementalTextRetainedEntries ?? 0;
349
+ bytes += state.incrementalTextRetainedBytes ?? 0;
350
+ if (typeof state.root === "string" && state.root.length > 0) {
351
+ roots.add(state.root);
352
+ }
353
+ }
354
+ return { clients: active.size, entries, bytes, roots: roots.size };
355
+ }
256
356
  function isClientAlive(state) {
257
357
  return (state.isConnected && !state.isDestroyed && !state.lspProcess.process.killed);
258
358
  }
@@ -260,6 +360,13 @@ function disposeClientConnection(state) {
260
360
  if (state.connectionDisposed)
261
361
  return;
262
362
  state.connectionDisposed = true;
363
+ // #2065 fix round 1: this is the ONE place every permanent-death path
364
+ // converges (onError, onClose, the process 'exit' handler, and
365
+ // clientShutdownOnce's finally) — see setupConnectionLifecycle below and
366
+ // clientShutdownOnce. Deregistering here, guarded by the idempotency flag
367
+ // above, means a crash that never re-attaches still frees its retained
368
+ // text instead of pinning it for the rest of the process lifetime.
369
+ activeLspClientSet().delete(state);
263
370
  try {
264
371
  state.connection.dispose();
265
372
  }
@@ -623,7 +730,7 @@ function retirePullSource(state, identifier) {
623
730
  for (const source of [state.pullResultIds, state.pullRequestSequences]) {
624
731
  if (!source)
625
732
  continue;
626
- for (const key of [...source.keys()]) {
733
+ for (const key of source.keys()) {
627
734
  if (key.endsWith(suffix))
628
735
  source.delete(key);
629
736
  }
@@ -788,6 +895,50 @@ function logTypeScriptPullSettle(state, normalizedPath, durationMs, pullSettleSo
788
895
  },
789
896
  });
790
897
  }
898
+ /**
899
+ * #2066: the ONE full-document walk a send needs, replacing the two that used
900
+ * to run independently — a `charCodeAt` loop counting `\n` for the
901
+ * `lsp_document_send` line count, and a `.split(/\r\n|\r|\n/)` in
902
+ * `buildContentChanges` that materialized one substring per line to read the
903
+ * last one. The #1095 sha256 is a third walk and stays; it is native, and it
904
+ * is load-bearing.
905
+ *
906
+ * The two counts here are deliberately NOT the same number and must not be
907
+ * collapsed into one. `lfNewlineCount` counts `\n` only, because its consumer
908
+ * pairs against `diagnostic_past_eof`, whose gate counts LF BYTES
909
+ * (`clients/diagnostic-line-freshness.ts`); merging the conventions would put
910
+ * the two records one apart on exactly the mixed-ending documents the pairing
911
+ * exists to debug. `lastLine` uses the full LSP terminator set (`\r\n`, lone
912
+ * `\r`, `\n` — #1669 review F6), because a CRLF or classic-Mac document whose
913
+ * terminators went uncounted computes a range ending mid-document.
914
+ */
915
+ function scanSentContent(content) {
916
+ let lfNewlineCount = 0;
917
+ let index = 0;
918
+ let start = 0;
919
+ // `charCodeAt`, not `codePointAt` (typescript:S7758, accepted): both are
920
+ // compared against 10/13, which no surrogate unit or astral code point can
921
+ // equal, so they are interchangeable here — but `codePointAt` measured 8%
922
+ // slower on a 400 KiB LF document (0.716 vs 0.663 ms/scan), because every
923
+ // ordinary character pays a surrogate-pair check this loop never uses.
924
+ for (let i = 0; i < content.length; i++) {
925
+ const code = content.charCodeAt(i);
926
+ if (code !== 10 && code !== 13)
927
+ continue;
928
+ if (code === 10) {
929
+ lfNewlineCount++;
930
+ }
931
+ else if (content.charCodeAt(i + 1) === 10) {
932
+ // `\r\n` is ONE line terminator, and still carries the `\n` the
933
+ // LF-only count wants.
934
+ lfNewlineCount++;
935
+ i++;
936
+ }
937
+ index++;
938
+ start = i + 1;
939
+ }
940
+ return { lfNewlineCount, lastLine: { index, start } };
941
+ }
791
942
  /**
792
943
  * #1095: fingerprint the EXACT didOpen/didChange payload text at SEND time and
793
944
  * tag it with the document version it was sent as, so a later
@@ -796,17 +947,85 @@ function logTypeScriptPullSettle(state, normalizedPath, durationMs, pullSettleSo
796
947
  * read on the notification path (I1). Bounded by the same file-size gates the
797
948
  * caller already applies to the content it hands us.
798
949
  */
799
- function recordSentContent(state, normalizedPath, version, content) {
950
+ function recordSentContent(state, normalizedPath, version, content, coalescedCount = 0) {
951
+ const scan = scanSentContent(content);
952
+ const previous = state.documentContentHashes.get(normalizedPath);
953
+ if (previous && previous.version > version) {
954
+ incrementDegradationCount({
955
+ kind: "lsp-document-send-order",
956
+ subject: `${state.serverId}:${normalizedPath}`,
957
+ reason: `recorded version ${version} after newer version ${previous.version}`,
958
+ });
959
+ return;
960
+ }
961
+ if (previous?.text !== undefined) {
962
+ state.incrementalTextRetainedEntries = Math.max(0, (state.incrementalTextRetainedEntries ?? 1) - 1);
963
+ state.incrementalTextRetainedBytes = Math.max(0, (state.incrementalTextRetainedBytes ?? previous.text.length * 2) -
964
+ previous.text.length * 2);
965
+ // #2065 fix round 1 F5: the path is about to be rewritten below (and
966
+ // possibly re-marked text-bearing) — drop the stale membership first so
967
+ // the reinsert further down lands at the END of the recency order
968
+ // instead of leaving a duplicate-free but stale position.
969
+ state.incrementalTextBearingPaths?.delete(normalizedPath);
970
+ }
971
+ // #2065 fix round 1 F5: eviction recency now lives in
972
+ // `incrementalTextBearingPaths` (above/below), not this map's own
973
+ // insertion order — no remaining reader iterates `documentContentHashes`
974
+ // for order (every other site below is a keyed `.get`/`.delete`). The
975
+ // reinsert is kept anyway so a log or future debug dump that DOES walk
976
+ // this map in insertion order still reads "most recently sent last",
977
+ // matching the aux set it mirrors, rather than a stale mixed order.
978
+ state.documentContentHashes.delete(normalizedPath);
800
979
  state.documentContentHashes.set(normalizedPath, {
801
980
  version,
802
981
  hash: hashDiagnosticContent(content),
803
982
  // #1669: retain the full text only for Incremental — the sole reader
804
983
  // (`buildContentChanges`) needs it to compute the NEXT change against
805
984
  // what the server last saw; Full/None never read this field.
985
+ // #2066: `lastLine` is that reader's answer, already computed by the
986
+ // scan above — it rides along with the text it describes.
806
987
  ...(state.syncKind === TEXT_DOCUMENT_SYNC_KIND_INCREMENTAL && {
807
988
  text: content,
989
+ lastLine: scan.lastLine,
808
990
  }),
809
991
  });
992
+ if (state.syncKind === TEXT_DOCUMENT_SYNC_KIND_INCREMENTAL) {
993
+ state.incrementalTextRetainedEntries =
994
+ (state.incrementalTextRetainedEntries ?? 0) + 1;
995
+ state.incrementalTextRetainedBytes =
996
+ (state.incrementalTextRetainedBytes ?? 0) + content.length * 2;
997
+ // #2065 fix round 1 F5: track membership in the same insertion (=
998
+ // recency) order as `documentContentHashes` itself, so eviction below
999
+ // never needs to spread and scan the full content-binding map —
1000
+ // including paths whose text was already stripped — just to find the
1001
+ // oldest text-bearing one.
1002
+ if (!state.incrementalTextBearingPaths) {
1003
+ state.incrementalTextBearingPaths = new Set();
1004
+ }
1005
+ state.incrementalTextBearingPaths.add(normalizedPath);
1006
+ while ((state.incrementalTextRetainedEntries ?? 0) >
1007
+ MAX_INCREMENTAL_TEXT_RETAINED_ENTRIES ||
1008
+ (state.incrementalTextRetainedBytes ?? 0) >
1009
+ MAX_INCREMENTAL_TEXT_RETAINED_BYTES) {
1010
+ const oldestPath = state.incrementalTextBearingPaths
1011
+ ?.values()
1012
+ .next().value;
1013
+ if (oldestPath === undefined)
1014
+ break;
1015
+ const binding = state.documentContentHashes.get(oldestPath);
1016
+ state.incrementalTextBearingPaths.delete(oldestPath);
1017
+ if (!binding || binding.text === undefined)
1018
+ continue;
1019
+ // #2066: `lastLine` is dropped here with the text it describes. The
1020
+ // #1095 version/hash binding is what must survive the strip.
1021
+ state.documentContentHashes.set(oldestPath, {
1022
+ version: binding.version,
1023
+ hash: binding.hash,
1024
+ });
1025
+ state.incrementalTextRetainedEntries = Math.max(0, (state.incrementalTextRetainedEntries ?? 1) - 1);
1026
+ state.incrementalTextRetainedBytes = Math.max(0, (state.incrementalTextRetainedBytes ?? 0) - binding.text.length * 2);
1027
+ }
1028
+ }
810
1029
  // #1641 criterion 3: the in-memory document's version + content length AT
811
1030
  // SEND TIME, so a later "diagnostic cited a line past current disk EOF"
812
1031
  // record (`diagnostic_past_eof`, clients/diagnostic-line-freshness.ts) can be
@@ -818,14 +1037,9 @@ function recordSentContent(state, normalizedPath, version, content) {
818
1037
  // convention as the gate itself (newline count + 1 — a trailing `\n` adds
819
1038
  // one more, empty, addressable line; an empty document is still 1 line),
820
1039
  // or the two records disagree by one at exactly the boundary this counter
821
- // exists to help debug. Counted directly (no `.split("\n")`, which
822
- // allocates one substring per line — measured at 13.3ms/200k allocations
823
- // on a 7MB send) since only the COUNT is needed here, not the lines.
824
- let newlineCount = 0;
825
- for (let i = 0; i < content.length; i++) {
826
- if (content.charCodeAt(i) === 10)
827
- newlineCount++;
828
- }
1040
+ // exists to help debug. #2066: the count comes from `scanSentContent`
1041
+ // above, which folded this loop into the walk `buildContentChanges` was
1042
+ // already paying for; the LF-only convention is unchanged.
829
1043
  logLatency({
830
1044
  type: "phase",
831
1045
  phase: "lsp_document_send",
@@ -834,7 +1048,8 @@ function recordSentContent(state, normalizedPath, version, content) {
834
1048
  metadata: {
835
1049
  version,
836
1050
  contentLength: content.length,
837
- contentLineCount: newlineCount + 1,
1051
+ contentLineCount: scan.lfNewlineCount + 1,
1052
+ ...(coalescedCount > 0 && { coalescedCount }),
838
1053
  },
839
1054
  });
840
1055
  }
@@ -864,23 +1079,24 @@ function buildContentChanges(state, normalizedPath, content) {
864
1079
  if (state.syncKind !== TEXT_DOCUMENT_SYNC_KIND_INCREMENTAL) {
865
1080
  return [{ text: content }];
866
1081
  }
867
- const previousText = state.documentContentHashes.get(normalizedPath)?.text;
868
- if (previousText === undefined) {
1082
+ const previous = state.documentContentHashes.get(normalizedPath);
1083
+ if (previous?.text === undefined) {
869
1084
  return [{ text: content }];
870
1085
  }
871
- // #1669 review F6: split on every LSP line terminator (\r\n, lone \r, or
872
- // \n), not just \n — a document using CRLF or lone-CR line endings would
873
- // otherwise be undercounted, computing a range that ends mid-document
874
- // instead of at the real last line.
875
- const previousLines = previousText.split(/\r\n|\r|\n/);
876
- const lastLine = previousLines.length - 1;
877
- const lastLineText = previousLines[lastLine] ?? "";
1086
+ const previousText = previous.text;
1087
+ // #2066: `recordSentContent` scanned this exact text when it sent it, so
1088
+ // read where it left the last line instead of walking (and splitting) the
1089
+ // document a second time. An entry carrying text without that position was
1090
+ // written by something else — resolve it through the SAME scanner, never a
1091
+ // second convention that can drift from it.
1092
+ const lastLine = previous.lastLine ?? scanSentContent(previousText).lastLine;
1093
+ const lastLineText = previousText.slice(lastLine.start);
878
1094
  return [
879
1095
  {
880
1096
  range: {
881
1097
  start: { line: 0, character: 0 },
882
1098
  end: {
883
- line: lastLine,
1099
+ line: lastLine.index,
884
1100
  character: convertCharacterOffset(state.positionEncoding, lastLineText, lastLineText.length),
885
1101
  },
886
1102
  },
@@ -1208,14 +1424,33 @@ export function setupIncomingHandlers(state, initialization) {
1208
1424
  if (state.serverEditsAllowed <= 0 || !params?.edit) {
1209
1425
  return { applied: false, failureReason: "edit not solicited" };
1210
1426
  }
1211
- const context = (state.activeMutationDepth ?? 0) === 1
1212
- ? state.activeMutationContext
1213
- : undefined;
1427
+ // #2450 fix round 3 (F3) removed a `depth === 1` re-check here as
1428
+ // dead code. It is not dead — it moved to the WRITE side, where it
1429
+ // is the live invariant: `state.activeMutationContext` is assigned
1430
+ // only by `syncMutationContextSlot` (below in this file), which
1431
+ // populates it exactly when `activeMutationDepth === 1` and
1432
+ // empties it on every other frame transition. The depth test
1433
+ // therefore still runs, on each entry and each unwind; repeating
1434
+ // it here would be a second copy of the same predicate, not an
1435
+ // independent gate. What this handler reads is consequently either
1436
+ // the context of the single frame currently in flight or
1437
+ // `undefined` — never a settled frame's context, and never a
1438
+ // context belonging to some other window that is still open
1439
+ // (#2479 review rounds 2/3).
1440
+ const context = state.activeMutationContext;
1441
+ // `workspace/applyEdit` is only ever honored inside the
1442
+ // `serverEditsAllowed` window this handler just checked, which is
1443
+ // opened exclusively by an executeCommand call — so a fallback here is
1444
+ // always executeCommand-solicited, never a bare edit (#2450). This
1445
+ // fallback context carries no runtime/cacheManager (there is no live
1446
+ // reference to those singletons at this call site); lsp-mutation.ts's
1447
+ // bookkeepLspMutation falls back to the mutation bridge for exactly
1448
+ // that reason rather than silently dropping the write's bookkeeping.
1214
1449
  const telemetryContext = context ?? {
1215
1450
  cwd: state.root,
1216
1451
  correlationId: newLspMutationCorrelationId(),
1217
1452
  tool: "lsp-workspace-applyEdit",
1218
- source: "lsp-edit",
1453
+ source: "lsp-execute-command",
1219
1454
  };
1220
1455
  try {
1221
1456
  await applyWorkspaceEdit(params.edit, state.root, {
@@ -2215,7 +2450,7 @@ export function handleNotifyExternalChange(state, filePath, type) {
2215
2450
  const uri = state.openDocumentUris?.get(normalizedPath) ?? pathToFileURL(filePath).href;
2216
2451
  state.watchQueue.enqueue(uri, type);
2217
2452
  }
2218
- export async function handleNotifyOpen(state, filePath, content, languageId, preserveDiagnostics = false, silent = false) {
2453
+ async function handleNotifyOpenOnce(state, filePath, content, languageId, preserveDiagnostics = false, silent = false, coalescedCount = 0) {
2219
2454
  if (!isClientAlive(state))
2220
2455
  return;
2221
2456
  const normalizedPath = normalizeMapKey(filePath);
@@ -2263,7 +2498,7 @@ export async function handleNotifyOpen(state, filePath, content, languageId, pre
2263
2498
  // #1669 review F7: only mirror the send locally once it actually left
2264
2499
  // the process — see safeSendNotification's doc comment.
2265
2500
  if (reopenSent)
2266
- recordSentContent(state, normalizedPath, version, content);
2501
+ recordSentContent(state, normalizedPath, version, content, coalescedCount);
2267
2502
  state.openDocuments.add(normalizedPath);
2268
2503
  state.openDocumentUris?.set(normalizedPath, uri);
2269
2504
  return;
@@ -2273,7 +2508,7 @@ export async function handleNotifyOpen(state, filePath, content, languageId, pre
2273
2508
  contentChanges: buildContentChanges(state, normalizedPath, content),
2274
2509
  });
2275
2510
  if (changeSent)
2276
- recordSentContent(state, normalizedPath, version, content);
2511
+ recordSentContent(state, normalizedPath, version, content, coalescedCount);
2277
2512
  return;
2278
2513
  }
2279
2514
  state.pendingOpens.add(normalizedPath);
@@ -2308,7 +2543,7 @@ export async function handleNotifyOpen(state, filePath, content, languageId, pre
2308
2543
  return;
2309
2544
  const openSent = await safeSendNotification(state.connection, "textDocument/didOpen", { textDocument: { uri, languageId, version: 0, text: content } });
2310
2545
  if (openSent)
2311
- recordSentContent(state, normalizedPath, 0, content);
2546
+ recordSentContent(state, normalizedPath, 0, content, coalescedCount);
2312
2547
  state.pendingOpens.delete(normalizedPath);
2313
2548
  state.openDocuments.add(normalizedPath);
2314
2549
  state.closedDocuments?.delete(normalizedPath);
@@ -2332,10 +2567,83 @@ export async function handleNotifyOpen(state, filePath, content, languageId, pre
2332
2567
  },
2333
2568
  });
2334
2569
  }
2335
- export async function handleNotifyChange(state, filePath, content) {
2570
+ /**
2571
+ * Replace only notifications that have not started a transport write. A
2572
+ * microtask starts the first entry so a synchronous burst of callers installs
2573
+ * one latest entry before any bytes are handed to vscode-jsonrpc. Once an
2574
+ * entry starts, it runs to completion; LSP has no cancellation/acknowledgement
2575
+ * for notifications, so dropping a started write could violate didOpen before
2576
+ * didChange ordering or leave a partial message in the pipe.
2577
+ */
2578
+ function enqueueDocumentNotify(state, normalizedPath, run) {
2579
+ let queue = state.notifyChangeQueues.get(normalizedPath);
2580
+ if (!queue) {
2581
+ queue = { running: false };
2582
+ state.notifyChangeQueues.set(normalizedPath, queue);
2583
+ }
2584
+ return new Promise((resolve, reject) => {
2585
+ const previous = queue?.pending;
2586
+ // Keep superseded callers attached to the replacement's completion. The
2587
+ // notification is dropped, but callers such as the auxiliary backlog
2588
+ // ledger must not observe completion before the newest content is sent.
2589
+ const waiters = previous?.waiters ?? [];
2590
+ waiters.push({ resolve, reject });
2591
+ queue.pending = {
2592
+ run,
2593
+ waiters,
2594
+ coalescedCount: (previous?.coalescedCount ?? 0) + (previous ? 1 : 0),
2595
+ };
2596
+ if (queue.running)
2597
+ return;
2598
+ queue.running = true;
2599
+ // Let same-turn callers replace the unwritten entry before the runner
2600
+ // invokes the transport. Different path queues still start independently.
2601
+ void Promise.resolve().then(async () => {
2602
+ try {
2603
+ for (;;) {
2604
+ const next = queue.pending;
2605
+ if (!next)
2606
+ break;
2607
+ queue.pending = undefined;
2608
+ try {
2609
+ await next.run(next.coalescedCount);
2610
+ for (const waiter of next.waiters)
2611
+ waiter.resolve();
2612
+ }
2613
+ catch (error) {
2614
+ for (const waiter of next.waiters)
2615
+ waiter.reject(error);
2616
+ }
2617
+ }
2618
+ }
2619
+ finally {
2620
+ queue.running = false;
2621
+ if (!queue.pending &&
2622
+ state.notifyChangeQueues.get(normalizedPath) === queue) {
2623
+ state.notifyChangeQueues.delete(normalizedPath);
2624
+ }
2625
+ }
2626
+ });
2627
+ });
2628
+ }
2629
+ /** Drop unwritten document notifications when a client is torn down. */
2630
+ function cancelDocumentNotifyQueues(state) {
2631
+ for (const queue of state.notifyChangeQueues.values()) {
2632
+ for (const waiter of queue.pending?.waiters ?? [])
2633
+ waiter.resolve();
2634
+ queue.pending = undefined;
2635
+ }
2636
+ state.notifyChangeQueues.clear();
2637
+ }
2638
+ export function handleNotifyOpen(state, filePath, content, languageId, preserveDiagnostics = false, silent = false) {
2336
2639
  if (!isClientAlive(state))
2337
- return;
2640
+ return Promise.resolve();
2338
2641
  const normalizedPath = normalizeMapKey(filePath);
2642
+ return enqueueDocumentNotify(state, normalizedPath, (coalescedCount) => handleNotifyOpenOnce(state, filePath, content, languageId, preserveDiagnostics, silent, coalescedCount));
2643
+ }
2644
+ async function handleNotifyChangeOnce(state, filePath, content, normalizedPath, coalescedCount = 0) {
2645
+ if (!isClientAlive(state))
2646
+ return;
2339
2647
  const uri = state.openDocumentUris?.get(normalizedPath) ?? pathToFileURL(filePath).href;
2340
2648
  if (!state.openDocuments.has(normalizedPath)) {
2341
2649
  // Safety fallback: keep protocol ordering valid even if caller sends
@@ -2352,7 +2660,7 @@ export async function handleNotifyChange(state, filePath, content) {
2352
2660
  state.documentOpenedAt.set(normalizedPath, Date.now());
2353
2661
  state.diagnosticPublicationCounts.set(normalizedPath, 0);
2354
2662
  if (fallbackOpenSent)
2355
- recordSentContent(state, normalizedPath, 0, content);
2663
+ recordSentContent(state, normalizedPath, 0, content, coalescedCount);
2356
2664
  state.openDocuments.add(normalizedPath);
2357
2665
  state.openDocumentUris?.set(normalizedPath, uri);
2358
2666
  return;
@@ -2367,7 +2675,18 @@ export async function handleNotifyChange(state, filePath, content) {
2367
2675
  contentChanges: buildContentChanges(state, normalizedPath, content),
2368
2676
  });
2369
2677
  if (changeSent)
2370
- recordSentContent(state, normalizedPath, version, content);
2678
+ recordSentContent(state, normalizedPath, version, content, coalescedCount);
2679
+ }
2680
+ /**
2681
+ * #2113: serialize the read/build/send/record transaction per document. The
2682
+ * content-change payload must be built against the content recorded by the
2683
+ * preceding send for this path; unrelated paths retain parallel sends.
2684
+ */
2685
+ export function handleNotifyChange(state, filePath, content) {
2686
+ if (!isClientAlive(state))
2687
+ return Promise.resolve();
2688
+ const normalizedPath = normalizeMapKey(filePath);
2689
+ return enqueueDocumentNotify(state, normalizedPath, (coalescedCount) => handleNotifyChangeOnce(state, filePath, content, normalizedPath, coalescedCount));
2371
2690
  }
2372
2691
  /** Close a document through the same lifecycle path exposed by the client. */
2373
2692
  export async function closeDocument(state, filePath) {
@@ -2393,7 +2712,44 @@ export async function closeDocument(state, filePath) {
2393
2712
  // harmless and cheap) — mirror openDocuments' own per-close cleanup so it
2394
2713
  // doesn't grow unbounded across a long session's worth of open/close churn.
2395
2714
  state.projectIdentityProbedFiles?.delete(normalizedPath);
2715
+ const retained = state.documentContentHashes.get(normalizedPath);
2716
+ if (retained?.text !== undefined) {
2717
+ state.incrementalTextRetainedEntries = Math.max(0, (state.incrementalTextRetainedEntries ?? 1) - 1);
2718
+ state.incrementalTextRetainedBytes = Math.max(0, (state.incrementalTextRetainedBytes ?? retained.text.length * 2) -
2719
+ retained.text.length * 2);
2720
+ // #2065 fix round 2 N1: `recordSentContent` and the eviction loop both
2721
+ // delete from `incrementalTextBearingPaths` when they strip a path's
2722
+ // text, but neither runs on close — this was the missing third writer.
2723
+ // Without it, a closed path's membership in the aux set survives
2724
+ // forever: below the 128-entry/64 MiB cap the eviction loop that would
2725
+ // otherwise clean it up never runs at all, so the set grows by one
2726
+ // stale entry per open/close cycle with nothing ever draining it — the
2727
+ // exact unbounded-per-path-store class #2065 exists to close, just
2728
+ // moved into the aux index instead of the byte-retention count itself.
2729
+ state.incrementalTextBearingPaths?.delete(normalizedPath);
2730
+ }
2731
+ // Keep the hash/version binding only while the document is open; the full
2732
+ // text is needed for the next edit, never after didClose (#2065).
2733
+ state.documentContentHashes.delete(normalizedPath);
2396
2734
  clearDiagnosticsForPath(state, normalizedPath);
2735
+ // #2065 fix round 2 (reverts fix round 1 F4): `pullGenerations` is the
2736
+ // eighth per-path map in this family, but it is deliberately NOT cleared
2737
+ // here — the same reasoning `clearDiagnosticsForPath` already documents
2738
+ // above for `pullRequestSequences` applies to this counter too, and fix
2739
+ // round 1's F4 missed it. `pullGenerationFor` defaults a missing entry to
2740
+ // `0`, so deleting the entry on close resets the counter. A pull already
2741
+ // in flight before the close captured its generation BEFORE this close's
2742
+ // `clearDiagnosticsForPath` bump above ran. If the document is closed and
2743
+ // reopened before that in-flight pull's write lands, deleting the entry
2744
+ // here would let the counter read back as `0` again — the exact value the
2745
+ // stale write's stale captured generation equals — so the write-time
2746
+ // `!== generation` check at its call site would wrongly accept a stale
2747
+ // answer computed against the pre-close content. Leaving the counter
2748
+ // monotonic and un-reset keeps every pre-close capture permanently
2749
+ // distinguishable from whatever the counter reads afterward, no matter how
2750
+ // many closes or reopens happen in between. The cost is one integer per
2751
+ // path that outlives the document's open lifetime — negligible next to
2752
+ // correctness here.
2397
2753
  }
2398
2754
  /**
2399
2755
  * #1620 residual review F1: `fast` and `processExiting` only ever escalate —
@@ -2467,6 +2823,9 @@ async function clientShutdownOnce(state, options) {
2467
2823
  state.pendingOpens.clear();
2468
2824
  state.openDocuments.clear();
2469
2825
  state.openDocumentUris?.clear();
2826
+ // #2357: superseded notifications that have not started writing are moot
2827
+ // once this client is dead; resolve their callers and release the queue.
2828
+ cancelDocumentNotifyQueues(state);
2470
2829
  // #1412 L1: mirror openDocuments' clear — a shut-down/evicted client's
2471
2830
  // probe memo is moot along with everything else document-scoped.
2472
2831
  state.projectIdentityProbedFiles?.clear();
@@ -2532,6 +2891,9 @@ async function clientShutdownOnce(state, options) {
2532
2891
  }
2533
2892
  }
2534
2893
  finally {
2894
+ // #2065 fix round 1: deregistration now lives in disposeClientConnection
2895
+ // itself (the convergence point for every crash/dispose path, not just
2896
+ // this graceful one) — see its comment.
2535
2897
  disposeClientConnection(state);
2536
2898
  const pid = state.lspProcess.pid;
2537
2899
  logLatency({
@@ -2908,6 +3270,35 @@ async function clientPingLiveness(state, timeoutMs = LIVENESS_PING_TIMEOUT_MS) {
2908
3270
  }
2909
3271
  return isClientAlive(state);
2910
3272
  }
3273
+ // #2479: `activeMutationContext` is DERIVED state, not a stack. The frame that
3274
+ // takes `activeMutationDepth` 0 -> 1 owns the context for its whole lifetime
3275
+ // (`mutationContextOwner`); the slot merely exposes that owner while it is the
3276
+ // ONLY frame in flight. Deeper or overlapping frames therefore empty the slot
3277
+ // for as long as they are open — concurrent and nested commands deliberately
3278
+ // do not cross-correlate — and it refills by itself when they unwind, with no
3279
+ // frame having to remember anything.
3280
+ //
3281
+ // Recomputing beats saving and restoring because these frames do not nest:
3282
+ // `LSPClient.executeCommand` and `LSPService.executeCommand` fan out with no
3283
+ // mutex, parallel `lsp_navigation` calls with `apply: true` overlap, and #449
3284
+ // light mode shares one client across agents. With A (the owner), B and C
3285
+ // overlapping and the MIDDLE one settling first, a per-frame restore hands A's
3286
+ // context back while C's window is the open one — and `LspMutationContext`
3287
+ // carries a cwd plus a directly-threaded runtime/cacheManager, with
3288
+ // `readTurnState` / `appendProjectChange` cwd-scoped, so that routes a live
3289
+ // edit's bookkeeping into another project's change log (strictly worse than
3290
+ // the honest fallback the pre-#2479 code gave). An owner-liveness flag does
3291
+ // not fix it either: it answers "is an owner live", not "does the slot belong
3292
+ // to the window this edit is in" — and C's own unwind then puts back the
3293
+ // `undefined` it saved while the owner is still running, resurrecting #2479
3294
+ // (review round 3). Deriving answers the second question by construction, and
3295
+ // "no command in flight == no context" — the invariant the
3296
+ // `serverEditsAllowed` gate on `workspace/applyEdit` is paired with — falls
3297
+ // out of it with no depth-0 special case.
3298
+ function syncMutationContextSlot(state) {
3299
+ state.activeMutationContext =
3300
+ state.activeMutationDepth === 1 ? state.mutationContextOwner : undefined;
3301
+ }
2911
3302
  // Run an advertised server command via workspace/executeCommand, with the
2912
3303
  // generous EXECUTE_COMMAND_TIMEOUT_MS anti-deadlock backstop. Preserves the
2913
3304
  // hardening invariants: allowlist-by-advertisement (only commands the server
@@ -2926,10 +3317,20 @@ export async function runServerCommand(state, command, args, timeoutMs = EXECUTE
2926
3317
  }
2927
3318
  state.serverEditsAllowed += 1;
2928
3319
  state.activeMutationDepth = (state.activeMutationDepth ?? 0) + 1;
2929
- if (state.activeMutationDepth === 1)
2930
- state.activeMutationContext = mutationContext;
2931
- else
2932
- state.activeMutationContext = undefined;
3320
+ // #2479: the frame that takes the depth to 1 owns the context; the slot is
3321
+ // derived from that owner (see `syncMutationContextSlot` above). The
3322
+ // pre-#2479 shape only ever CLEARED the slot, and only once the depth
3323
+ // returned to 0, so a nested `executeCommand` unwinding from depth 2 back
3324
+ // to 1 left the OUTER call without its own context for the rest of its
3325
+ // window: every server-initiated `applyEdit` it still solicited read
3326
+ // `undefined`, fell to the mutation bridge, and carried the generic
3327
+ // `agent-tool:lsp-workspace-applyEdit` receipt instead of the outer
3328
+ // operation's own (`lsp-rename` / `lsp-execute-command`).
3329
+ const isMutationContextOwner = state.activeMutationDepth === 1;
3330
+ if (isMutationContextOwner) {
3331
+ state.mutationContextOwner = mutationContext;
3332
+ }
3333
+ syncMutationContextSlot(state);
2933
3334
  try {
2934
3335
  let result;
2935
3336
  try {
@@ -2956,8 +3357,15 @@ export async function runServerCommand(state, command, args, timeoutMs = EXECUTE
2956
3357
  finally {
2957
3358
  state.serverEditsAllowed -= 1;
2958
3359
  state.activeMutationDepth = Math.max(0, (state.activeMutationDepth ?? 0) - 1);
2959
- if (state.activeMutationDepth === 0)
2960
- state.activeMutationContext = undefined;
3360
+ // The owner drops its context; every frame then resyncs. Both the
3361
+ // hand-back to a still-live owner once the deeper frames unwind and
3362
+ // the empty slot while more than one frame is open are consequences of
3363
+ // the derivation, not separate rules each frame has to get right
3364
+ // (#2479 review rounds 2/3).
3365
+ if (isMutationContextOwner) {
3366
+ state.mutationContextOwner = undefined;
3367
+ }
3368
+ syncMutationContextSlot(state);
2961
3369
  }
2962
3370
  }
2963
3371
  // #1412 H1/H2: read-only sibling of runServerCommand for telemetry/identity
@@ -3060,6 +3468,14 @@ async function resolveCodeActionBestEffort(state, action) {
3060
3468
  edit: await normalizeClientWorkspaceEdit(state, action.edit),
3061
3469
  };
3062
3470
  }
3471
+ if (!state.operationSupport.codeActionResolve) {
3472
+ recordDegradationOnce({
3473
+ kind: "lsp-capability-skip",
3474
+ subject: `${state.serverId}:codeAction/resolve`,
3475
+ reason: "server did not advertise codeActionProvider.resolveProvider",
3476
+ });
3477
+ return action;
3478
+ }
3063
3479
  let resolved;
3064
3480
  try {
3065
3481
  resolved = await withTimeout(safeSendRequest(state.connection, "codeAction/resolve", action), NAV_REQUEST_TIMEOUT_MS);
@@ -3082,7 +3498,7 @@ async function resolveCodeActionBestEffort(state, action) {
3082
3498
  // --- Client Factory ---
3083
3499
  export async function createLSPClient(options) {
3084
3500
  installCrashGuard();
3085
- const { serverId, process: lspProcess, root, initialization, initializeTimeoutMs = INITIALIZE_TIMEOUT_MS, launchVariant, } = options;
3501
+ const { serverId, process: lspProcess, root, sessionCwd, initialization, initializeTimeoutMs = INITIALIZE_TIMEOUT_MS, launchVariant, } = options;
3086
3502
  // #449/#472: register this LSP child in the cross-process instance registry
3087
3503
  // as soon as we have a live pid — BEFORE `initialize` completes, not after.
3088
3504
  // Registering early means a child that dies/hangs during initialize (the
@@ -3095,6 +3511,11 @@ export async function createLSPClient(options) {
3095
3511
  serverId,
3096
3512
  command: lspProcess.command,
3097
3513
  marker: extractSpawnMarker(lspProcess.args),
3514
+ sessionIdentity: {
3515
+ projectRoot: sessionCwd ?? process.cwd(),
3516
+ rootSource: sessionCwd ? "service-cwd" : "lsp-fallback",
3517
+ startedAt: new Date(workspaceDiagnosticsCacheSessionStart()).toISOString(),
3518
+ },
3098
3519
  }).catch((err) => {
3099
3520
  // best-effort observability — never fail LSP startup over this
3100
3521
  logLatency({
@@ -3204,8 +3625,12 @@ export async function createLSPClient(options) {
3204
3625
  diagnosticsVersion: 0,
3205
3626
  diagnosticsVersionsByPath: new Map(),
3206
3627
  documentVersions: new Map(),
3628
+ notifyChangeQueues: new Map(),
3207
3629
  diagnosticDocVersions: new Map(),
3208
3630
  documentContentHashes: new Map(),
3631
+ incrementalTextRetainedEntries: 0,
3632
+ incrementalTextBearingPaths: new Set(),
3633
+ incrementalTextRetainedBytes: 0,
3209
3634
  diagnosticBindings: new Map(),
3210
3635
  pullResultIds: new Map(),
3211
3636
  documentPullDiagnosticsBySource: new Map(),
@@ -3238,6 +3663,7 @@ export async function createLSPClient(options) {
3238
3663
  // SAFETY: state construction completes before the flush closure can run.
3239
3664
  watchQueue: undefined,
3240
3665
  };
3666
+ activeLspClientSet().add(state);
3241
3667
  // #271: batch per-file workspace/didChangeWatchedFiles into one notification
3242
3668
  // per debounce window, so an N-file turn re-indexes the server once, not N×.
3243
3669
  state.watchQueue = new WatchedFilesQueue((changes) => {
@@ -3340,6 +3766,31 @@ export async function createLSPClient(options) {
3340
3766
  state.workspaceDiagnosticsSupport =
3341
3767
  detectWorkspaceDiagnosticsSupport(initResult);
3342
3768
  state.operationSupport = detectOperationSupport(initResult);
3769
+ const capabilities = (initResult
3770
+ ?.capabilities ?? {});
3771
+ const workspace = isCapabilityRecord(capabilities.workspace)
3772
+ ? capabilities.workspace
3773
+ : undefined;
3774
+ const fileOperations = isCapabilityRecord(workspace)
3775
+ ? workspace.fileOperations
3776
+ : undefined;
3777
+ const malformedFileOperationRegistrations = new Set();
3778
+ if (isCapabilityRecord(fileOperations)) {
3779
+ for (const operation of ["willRename", "didRename"]) {
3780
+ if (fileOperations[operation] !== undefined &&
3781
+ parseFileOperationFilters(fileOperations[operation]) === undefined) {
3782
+ malformedFileOperationRegistrations.add(operation);
3783
+ }
3784
+ }
3785
+ }
3786
+ state.fileOperationMalformedRegistrations =
3787
+ malformedFileOperationRegistrations;
3788
+ if (isCapabilityRecord(fileOperations)) {
3789
+ state.fileOperationFilters = {
3790
+ willRename: parseFileOperationFilters(fileOperations.willRename),
3791
+ didRename: parseFileOperationFilters(fileOperations.didRename),
3792
+ };
3793
+ }
3343
3794
  state.positionEncoding = negotiatePositionEncoding(initResult?.capabilities);
3344
3795
  state.syncKind = negotiateSyncKind(initResult?.capabilities);
3345
3796
  state.rawCapabilityKeys = Object.keys(initResult?.capabilities ??
@@ -3376,6 +3827,8 @@ export async function createLSPClient(options) {
3376
3827
  checkAlive: () => checkProcessAlive(),
3377
3828
  /** #1277: cheap request round-trip proving the server still responds. */
3378
3829
  pingLiveness: (timeoutMs) => clientPingLiveness(state, timeoutMs),
3830
+ /** #2358: the OS pid of the live server process (see interface doc). */
3831
+ getProcessPid: () => lspProcess.pid,
3379
3832
  notify: {
3380
3833
  async open(filePath, content, languageId, preserveDiagnostics, silent) {
3381
3834
  return handleNotifyOpen(state, filePath, content, languageId, preserveDiagnostics, silent);
@@ -3444,6 +3897,9 @@ export async function createLSPClient(options) {
3444
3897
  getOperationSupport() {
3445
3898
  return state.operationSupport;
3446
3899
  },
3900
+ getMalformedFileOperationRegistrations() {
3901
+ return state.fileOperationMalformedRegistrations ?? new Set();
3902
+ },
3447
3903
  getAdvertisedCommands() {
3448
3904
  return [...state.advertisedCommands];
3449
3905
  },
@@ -3521,6 +3977,9 @@ export async function createLSPClient(options) {
3521
3977
  isDocumentOpen(filePath) {
3522
3978
  return state.openDocuments.has(normalizeMapKey(filePath));
3523
3979
  },
3980
+ openDocumentPaths() {
3981
+ return [...state.openDocuments];
3982
+ },
3524
3983
  isBusy() {
3525
3984
  return (activeRequestsByConnection.get(connection) ?? 0) > 0;
3526
3985
  },
@@ -3570,11 +4029,26 @@ export async function createLSPClient(options) {
3570
4029
  },
3571
4030
  closeDocument: (filePath) => closeDocument(state, filePath),
3572
4031
  async willRenameFiles(oldFilePath, newFilePath) {
4032
+ if (!isClientAlive(state))
4033
+ return null;
4034
+ // #1971 review: the server registered WHICH paths it cares about.
4035
+ // Sending outside those filters asks a question the server never
4036
+ // signed up to answer.
4037
+ const oldUri = pathToFileURL(oldFilePath).href;
4038
+ const newUri = pathToFileURL(newFilePath).href;
4039
+ if (!(await fileOperationFiltersMatch(state.fileOperationFilters?.willRename, oldUri, newUri, oldFilePath, newFilePath))) {
4040
+ recordDegradationOnce({
4041
+ kind: "lsp-capability-skip",
4042
+ subject: `${state.serverId}:workspace/willRenameFiles:filter`,
4043
+ reason: "filter-mismatch",
4044
+ });
4045
+ return null;
4046
+ }
3573
4047
  const result = await navRequest(state, "workspace/willRenameFiles", {
3574
4048
  files: [
3575
4049
  {
3576
- oldUri: pathToFileURL(oldFilePath).href,
3577
- newUri: pathToFileURL(newFilePath).href,
4050
+ oldUri,
4051
+ newUri,
3578
4052
  },
3579
4053
  ],
3580
4054
  });
@@ -3583,11 +4057,28 @@ export async function createLSPClient(options) {
3583
4057
  async didRenameFiles(oldFilePath, newFilePath, oldUri, newUri) {
3584
4058
  if (!isClientAlive(state))
3585
4059
  return;
4060
+ // `fileOperationFilters.didRename` is parsed by the same predicate that
4061
+ // derives operationSupport.didRenameFiles (see
4062
+ // isFileOperationRegistrationOptions). Thus filters undefined iff the
4063
+ // capability is absent; the service-level gate remains the enforcement
4064
+ // point for aggregated dispatch.
4065
+ // #1971 review: same registration-filter discipline as willRename —
4066
+ // the server only signed up to hear about matching paths.
4067
+ const wireOldUri = oldUri ?? pathToFileURL(oldFilePath).href;
4068
+ const wireNewUri = newUri ?? pathToFileURL(newFilePath).href;
4069
+ if (!(await fileOperationFiltersMatch(state.fileOperationFilters?.didRename, wireOldUri, wireNewUri, oldFilePath, newFilePath))) {
4070
+ recordDegradationOnce({
4071
+ kind: "lsp-capability-skip",
4072
+ subject: `${state.serverId}:workspace/didRenameFiles:filter`,
4073
+ reason: "filter-mismatch",
4074
+ });
4075
+ return;
4076
+ }
3586
4077
  await safeSendNotification(state.connection, "workspace/didRenameFiles", {
3587
4078
  files: [
3588
4079
  {
3589
- oldUri: oldUri ?? pathToFileURL(oldFilePath).href,
3590
- newUri: newUri ?? pathToFileURL(newFilePath).href,
4080
+ oldUri: wireOldUri,
4081
+ newUri: wireNewUri,
3591
4082
  },
3592
4083
  ],
3593
4084
  });
@@ -3818,8 +4309,13 @@ function detectOperationSupport(initResult) {
3818
4309
  return false;
3819
4310
  if (typeof value === "boolean")
3820
4311
  return value;
3821
- return true;
4312
+ return isCapabilityRecord(value);
3822
4313
  };
4314
+ const codeActionProvider = capabilities?.codeActionProvider;
4315
+ const workspace = capabilities?.workspace;
4316
+ const fileOperations = isCapabilityRecord(workspace)
4317
+ ? workspace.fileOperations
4318
+ : undefined;
3823
4319
  return {
3824
4320
  definition: hasProvider("definitionProvider"),
3825
4321
  typeDefinition: hasProvider("typeDefinitionProvider"),
@@ -3830,8 +4326,135 @@ function detectOperationSupport(initResult) {
3830
4326
  documentSymbol: hasProvider("documentSymbolProvider"),
3831
4327
  workspaceSymbol: hasProvider("workspaceSymbolProvider"),
3832
4328
  codeAction: hasProvider("codeActionProvider"),
4329
+ codeActionResolve: isCapabilityRecord(codeActionProvider) &&
4330
+ codeActionProvider.resolveProvider === true,
3833
4331
  rename: hasProvider("renameProvider"),
4332
+ willRenameFiles: isCapabilityRecord(fileOperations) &&
4333
+ isFileOperationRegistrationOptions(fileOperations.willRename),
4334
+ didRenameFiles: isCapabilityRecord(fileOperations) &&
4335
+ isFileOperationRegistrationOptions(fileOperations.didRename),
3834
4336
  implementation: hasProvider("implementationProvider"),
3835
4337
  callHierarchy: hasProvider("callHierarchyProvider"),
3836
4338
  };
3837
4339
  }
4340
+ function isCapabilityRecord(value) {
4341
+ return typeof value === "object" && value !== null && !Array.isArray(value);
4342
+ }
4343
+ function isFileOperationRegistrationOptions(value) {
4344
+ return parseFileOperationFilters(value) !== undefined;
4345
+ }
4346
+ /**
4347
+ * Parse the filters of a `FileOperationRegistrationOptions` object. Returns
4348
+ * `undefined` when the options are absent or malformed — callers treat that as
4349
+ * "no interest declared" and fail closed, never sending the operation.
4350
+ */
4351
+ function parseFileOperationFilters(value) {
4352
+ if (!isCapabilityRecord(value) || !Array.isArray(value.filters)) {
4353
+ return undefined;
4354
+ }
4355
+ const filters = [];
4356
+ for (const entry of value.filters) {
4357
+ if (!isCapabilityRecord(entry))
4358
+ return undefined;
4359
+ const pattern = entry.pattern;
4360
+ if (!isCapabilityRecord(pattern) ||
4361
+ typeof pattern.glob !== "string" ||
4362
+ pattern.glob.trim() === "") {
4363
+ return undefined;
4364
+ }
4365
+ if (entry.scheme !== undefined &&
4366
+ (typeof entry.scheme !== "string" ||
4367
+ entry.scheme.trim() === "" ||
4368
+ !/^[A-Za-z][A-Za-z0-9+.-]*$/.test(entry.scheme))) {
4369
+ return undefined;
4370
+ }
4371
+ const matches = pattern.matches;
4372
+ if (matches !== undefined && matches !== "file" && matches !== "folder") {
4373
+ return undefined;
4374
+ }
4375
+ if (pattern.options !== undefined && !isCapabilityRecord(pattern.options)) {
4376
+ return undefined;
4377
+ }
4378
+ const options = isCapabilityRecord(pattern.options)
4379
+ ? pattern.options
4380
+ : undefined;
4381
+ if (options?.ignoreCase !== undefined &&
4382
+ typeof options.ignoreCase !== "boolean") {
4383
+ return undefined;
4384
+ }
4385
+ filters.push({
4386
+ scheme: entry.scheme?.toLowerCase(),
4387
+ glob: pattern.glob,
4388
+ matches: matches === "file" || matches === "folder" ? matches : undefined,
4389
+ ignoreCase: options?.ignoreCase === true,
4390
+ });
4391
+ }
4392
+ return filters;
4393
+ }
4394
+ /**
4395
+ * Whether ANY parsed filter expresses interest in either path of a rename.
4396
+ * Conservative by design: unsupported URI schemes, an unresolved explicit
4397
+ * file/folder kind, malformed URIs, and zero filters all match nothing.
4398
+ */
4399
+ async function fileOperationFiltersMatch(filters, oldUri, newUri, oldFilePath, newFilePath) {
4400
+ if (!filters || filters.length === 0)
4401
+ return false;
4402
+ const candidates = [oldUri, newUri].flatMap(fileOperationUriCandidates);
4403
+ const entityKind = filters.some((filter) => filter.matches !== undefined)
4404
+ ? await probeRenameEntityKind(oldFilePath, newFilePath)
4405
+ : undefined;
4406
+ for (const filter of filters) {
4407
+ if (filter.scheme !== undefined && filter.scheme !== "file")
4408
+ continue;
4409
+ if (filter.matches !== undefined && filter.matches !== entityKind)
4410
+ continue;
4411
+ for (const candidate of candidates) {
4412
+ try {
4413
+ if (minimatch(candidate, filter.glob, {
4414
+ nocase: filter.ignoreCase,
4415
+ dot: true,
4416
+ })) {
4417
+ return true;
4418
+ }
4419
+ }
4420
+ catch {
4421
+ // Malformed glob implementations must never widen server fan-out.
4422
+ }
4423
+ }
4424
+ }
4425
+ return false;
4426
+ }
4427
+ function fileOperationUriCandidates(uri) {
4428
+ let parsed;
4429
+ try {
4430
+ parsed = new URL(uri);
4431
+ }
4432
+ catch {
4433
+ return [];
4434
+ }
4435
+ // pi-lens performs local filesystem renames only. Even when a caller supplies
4436
+ // preserved URI spelling, another scheme cannot describe that operation.
4437
+ if (parsed.protocol.toLowerCase() !== "file:")
4438
+ return [];
4439
+ let uriPath;
4440
+ try {
4441
+ uriPath = decodeURIComponent(parsed.pathname).replace(/\\/g, "/");
4442
+ }
4443
+ catch {
4444
+ return [];
4445
+ }
4446
+ return [uriPath];
4447
+ }
4448
+ async function probeRenameEntityKind(oldFilePath, newFilePath) {
4449
+ for (const filePath of [oldFilePath, newFilePath]) {
4450
+ try {
4451
+ const info = await lstat(filePath);
4452
+ return info.isDirectory() ? "folder" : "file";
4453
+ }
4454
+ catch {
4455
+ // Before the rename the old path normally exists; after it, the new path
4456
+ // does. Probe both instead of deriving behavior from the host OS.
4457
+ }
4458
+ }
4459
+ return undefined;
4460
+ }