@herbertgao/pi-extensions 2026.8.3 → 2026.8.5

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 (408) hide show
  1. package/README.md +45 -4
  2. package/examples/pi-footer.json +275 -0
  3. package/node_modules/@herbertgao/pi-cc-extensions/README.en.md +10 -2
  4. package/node_modules/@herbertgao/pi-cc-extensions/README.md +10 -2
  5. package/node_modules/@herbertgao/pi-cc-extensions/extensions/config/config.ts +24 -0
  6. package/node_modules/@herbertgao/pi-cc-extensions/extensions/config/panel.ts +91 -1
  7. package/node_modules/@herbertgao/pi-cc-extensions/extensions/feature/compact-thinking.ts +7 -3
  8. package/node_modules/@herbertgao/pi-cc-extensions/extensions/index.ts +7 -6
  9. package/node_modules/@herbertgao/pi-cc-extensions/extensions/renderer/compact-mode.ts +21 -12
  10. package/node_modules/@herbertgao/pi-cc-extensions/extensions/renderer/default-mode.ts +4 -5
  11. package/node_modules/@herbertgao/pi-cc-extensions/extensions/renderer/markdown-enhance.ts +5 -8
  12. package/node_modules/@herbertgao/pi-cc-extensions/extensions/renderer/tool/grouping.ts +22 -12
  13. package/node_modules/@herbertgao/pi-cc-extensions/extensions/renderer/tool/result.ts +2 -4
  14. package/node_modules/@herbertgao/pi-cc-extensions/package.json +3 -3
  15. package/node_modules/@herbertgao/pi-subagents/CHANGELOG.md +8 -0
  16. package/node_modules/@herbertgao/pi-subagents/package.json +8 -4
  17. package/node_modules/@herbertgao/pi-subagents/src/agent-color.ts +72 -67
  18. package/node_modules/@herbertgao/pi-subagents/src/agent-file-toggle.ts +255 -0
  19. package/node_modules/@herbertgao/pi-subagents/src/agent-manager.ts +191 -3
  20. package/node_modules/@herbertgao/pi-subagents/src/agent-runner.ts +15 -2
  21. package/node_modules/@herbertgao/pi-subagents/src/index.ts +289 -113
  22. package/node_modules/@herbertgao/pi-subagents/src/output-file.ts +24 -1
  23. package/node_modules/@herbertgao/pi-subagents/src/ui/agent-widget.ts +24 -2
  24. package/node_modules/@herbertgao/pi-subagents/src/ui/schedule-menu.ts +9 -8
  25. package/node_modules/@herbertgao/pi-subagents/src/ui/select-item.ts +48 -0
  26. package/node_modules/@juicesharp/rpiv-ask-user-question/docs/keyboard.md +7 -0
  27. package/node_modules/@juicesharp/rpiv-ask-user-question/package.json +2 -2
  28. package/node_modules/@juicesharp/rpiv-ask-user-question/state/build-questionnaire.ts +7 -0
  29. package/node_modules/@juicesharp/rpiv-ask-user-question/state/key-router.ts +16 -5
  30. package/node_modules/@juicesharp/rpiv-ask-user-question/state/questionnaire-session.ts +4 -2
  31. package/node_modules/@narumitw/pi-btw/README.md +8 -7
  32. package/node_modules/@narumitw/pi-btw/package.json +2 -2
  33. package/node_modules/@narumitw/pi-btw/src/menu.ts +1 -0
  34. package/node_modules/pi-footer/CHANGELOG.md +145 -0
  35. package/node_modules/pi-footer/LICENSE +21 -0
  36. package/node_modules/pi-footer/README.md +354 -0
  37. package/node_modules/pi-footer/package.json +68 -0
  38. package/node_modules/pi-footer/src/cache.ts +106 -0
  39. package/node_modules/pi-footer/src/colors.ts +237 -0
  40. package/node_modules/pi-footer/src/config.ts +202 -0
  41. package/node_modules/pi-footer/src/event-widgets.ts +41 -0
  42. package/node_modules/pi-footer/src/extension-statuses.ts +102 -0
  43. package/node_modules/pi-footer/src/git.ts +135 -0
  44. package/node_modules/pi-footer/src/index.ts +268 -0
  45. package/node_modules/pi-footer/src/metrics.ts +141 -0
  46. package/node_modules/pi-footer/src/presets.ts +383 -0
  47. package/node_modules/pi-footer/src/render.ts +114 -0
  48. package/node_modules/pi-footer/src/separators.ts +52 -0
  49. package/node_modules/pi-footer/src/types.ts +122 -0
  50. package/node_modules/pi-footer/src/ui/color-level-confirm.ts +22 -0
  51. package/node_modules/pi-footer/src/ui/color-options.ts +195 -0
  52. package/node_modules/pi-footer/src/ui/config-lifecycle.ts +61 -0
  53. package/node_modules/pi-footer/src/ui/edit-colors.ts +13 -0
  54. package/node_modules/pi-footer/src/ui/events.ts +35 -0
  55. package/node_modules/pi-footer/src/ui/extension-status-picker.ts +60 -0
  56. package/node_modules/pi-footer/src/ui/extension-statuses.ts +73 -0
  57. package/node_modules/pi-footer/src/ui/fields.ts +252 -0
  58. package/node_modules/pi-footer/src/ui/global-menu.ts +126 -0
  59. package/node_modules/pi-footer/src/ui/helpers.ts +44 -0
  60. package/node_modules/pi-footer/src/ui/layout.ts +17 -0
  61. package/node_modules/pi-footer/src/ui/line-list.ts +19 -0
  62. package/node_modules/pi-footer/src/ui/model.ts +64 -0
  63. package/node_modules/pi-footer/src/ui/navigation.ts +29 -0
  64. package/node_modules/pi-footer/src/ui/option-edit.ts +93 -0
  65. package/node_modules/pi-footer/src/ui/overlay-render.ts +103 -0
  66. package/node_modules/pi-footer/src/ui/screen-context.ts +18 -0
  67. package/node_modules/pi-footer/src/ui/screen-controller.ts +27 -0
  68. package/node_modules/pi-footer/src/ui/screen-render.ts +32 -0
  69. package/node_modules/pi-footer/src/ui/screen-state.ts +20 -0
  70. package/node_modules/pi-footer/src/ui/screen.ts +243 -0
  71. package/node_modules/pi-footer/src/ui/screens/add-widget.ts +92 -0
  72. package/node_modules/pi-footer/src/ui/screens/confirm-exit.ts +52 -0
  73. package/node_modules/pi-footer/src/ui/screens/controller.ts +12 -0
  74. package/node_modules/pi-footer/src/ui/screens/edit-colors.ts +68 -0
  75. package/node_modules/pi-footer/src/ui/screens/edit-widget.ts +115 -0
  76. package/node_modules/pi-footer/src/ui/screens/extension-status-row.ts +61 -0
  77. package/node_modules/pi-footer/src/ui/screens/global.ts +75 -0
  78. package/node_modules/pi-footer/src/ui/screens/line-list.ts +108 -0
  79. package/node_modules/pi-footer/src/ui/screens/main.ts +99 -0
  80. package/node_modules/pi-footer/src/ui/screens/terminal.ts +121 -0
  81. package/node_modules/pi-footer/src/ui/screens/widget-list.ts +154 -0
  82. package/node_modules/pi-footer/src/ui/terminal-menu.ts +32 -0
  83. package/node_modules/pi-footer/src/ui/theme.ts +48 -0
  84. package/node_modules/pi-footer/src/ui/title-bar.ts +100 -0
  85. package/node_modules/pi-footer/src/ui/widget-actions.ts +76 -0
  86. package/node_modules/pi-footer/src/ui.ts +66 -0
  87. package/node_modules/pi-footer/src/widgets/context.ts +28 -0
  88. package/node_modules/pi-footer/src/widgets/core/active-tools.ts +17 -0
  89. package/node_modules/pi-footer/src/widgets/core/context-window.ts +24 -0
  90. package/node_modules/pi-footer/src/widgets/core/cwd-basename.ts +19 -0
  91. package/node_modules/pi-footer/src/widgets/core/cwd.ts +108 -0
  92. package/node_modules/pi-footer/src/widgets/core/event.ts +34 -0
  93. package/node_modules/pi-footer/src/widgets/core/external-status.ts +102 -0
  94. package/node_modules/pi-footer/src/widgets/core/model-provider.ts +20 -0
  95. package/node_modules/pi-footer/src/widgets/core/model.ts +35 -0
  96. package/node_modules/pi-footer/src/widgets/core/provider.ts +17 -0
  97. package/node_modules/pi-footer/src/widgets/core/session-name.ts +17 -0
  98. package/node_modules/pi-footer/src/widgets/core/text-verbosity.ts +17 -0
  99. package/node_modules/pi-footer/src/widgets/core/thinking-level.ts +17 -0
  100. package/node_modules/pi-footer/src/widgets/git/ahead-behind.ts +17 -0
  101. package/node_modules/pi-footer/src/widgets/git/branch.ts +81 -0
  102. package/node_modules/pi-footer/src/widgets/git/clean.ts +19 -0
  103. package/node_modules/pi-footer/src/widgets/git/deletions.ts +17 -0
  104. package/node_modules/pi-footer/src/widgets/git/diff.ts +38 -0
  105. package/node_modules/pi-footer/src/widgets/git/insertions.ts +17 -0
  106. package/node_modules/pi-footer/src/widgets/git/remote.ts +17 -0
  107. package/node_modules/pi-footer/src/widgets/git/root.ts +17 -0
  108. package/node_modules/pi-footer/src/widgets/git/sha.ts +17 -0
  109. package/node_modules/pi-footer/src/widgets/git/staged.ts +17 -0
  110. package/node_modules/pi-footer/src/widgets/git/status.ts +19 -0
  111. package/node_modules/pi-footer/src/widgets/git/unstaged.ts +17 -0
  112. package/node_modules/pi-footer/src/widgets/git/untracked.ts +17 -0
  113. package/node_modules/pi-footer/src/widgets/instance.ts +118 -0
  114. package/node_modules/pi-footer/src/widgets/layout/custom-text.ts +18 -0
  115. package/node_modules/pi-footer/src/widgets/layout/flex-separator.ts +32 -0
  116. package/node_modules/pi-footer/src/widgets/layout/separator.ts +57 -0
  117. package/node_modules/pi-footer/src/widgets/layout/spacer.ts +31 -0
  118. package/node_modules/pi-footer/src/widgets/options.ts +122 -0
  119. package/node_modules/pi-footer/src/widgets/project/runtime.ts +361 -0
  120. package/node_modules/pi-footer/src/widgets/registry.ts +223 -0
  121. package/node_modules/pi-footer/src/widgets/session/assistant-messages.ts +17 -0
  122. package/node_modules/pi-footer/src/widgets/session/compactions.ts +17 -0
  123. package/node_modules/pi-footer/src/widgets/session/elapsed.ts +18 -0
  124. package/node_modules/pi-footer/src/widgets/session/last-activity.ts +18 -0
  125. package/node_modules/pi-footer/src/widgets/session/messages.ts +19 -0
  126. package/node_modules/pi-footer/src/widgets/session/session-id.ts +17 -0
  127. package/node_modules/pi-footer/src/widgets/session/session-start.ts +18 -0
  128. package/node_modules/pi-footer/src/widgets/session/tool-results.ts +17 -0
  129. package/node_modules/pi-footer/src/widgets/session/total-messages.ts +19 -0
  130. package/node_modules/pi-footer/src/widgets/session/total-time.ts +18 -0
  131. package/node_modules/pi-footer/src/widgets/session/user-messages.ts +17 -0
  132. package/node_modules/pi-footer/src/widgets/store.ts +26 -0
  133. package/node_modules/pi-footer/src/widgets/tokens/cache-hit-rate.ts +55 -0
  134. package/node_modules/pi-footer/src/widgets/tokens/cache-read.ts +18 -0
  135. package/node_modules/pi-footer/src/widgets/tokens/cache-write.ts +18 -0
  136. package/node_modules/pi-footer/src/widgets/tokens/context-bar.ts +108 -0
  137. package/node_modules/pi-footer/src/widgets/tokens/context-length.ts +26 -0
  138. package/node_modules/pi-footer/src/widgets/tokens/context-remaining.ts +29 -0
  139. package/node_modules/pi-footer/src/widgets/tokens/context.ts +29 -0
  140. package/node_modules/pi-footer/src/widgets/tokens/cost.ts +51 -0
  141. package/node_modules/pi-footer/src/widgets/tokens/input-speed.ts +25 -0
  142. package/node_modules/pi-footer/src/widgets/tokens/input-tokens.ts +18 -0
  143. package/node_modules/pi-footer/src/widgets/tokens/output-speed.ts +25 -0
  144. package/node_modules/pi-footer/src/widgets/tokens/output-tokens.ts +18 -0
  145. package/node_modules/pi-footer/src/widgets/tokens/tokens.ts +20 -0
  146. package/node_modules/pi-footer/src/widgets/tokens/total-speed.ts +25 -0
  147. package/node_modules/pi-footer/src/widgets/tokens/total-tokens.ts +18 -0
  148. package/node_modules/pi-footer/src/widgets/types.ts +181 -0
  149. package/node_modules/pi-footer/src/widgets/utils/colors.ts +11 -0
  150. package/node_modules/pi-footer/src/widgets/utils/context.ts +112 -0
  151. package/node_modules/pi-footer/src/widgets/utils/session.ts +19 -0
  152. package/node_modules/pi-footer/src/widgets/utils/token-format.ts +78 -0
  153. package/node_modules/pi-lens/CHANGELOG.md +2440 -954
  154. package/node_modules/pi-lens/README.md +13 -0
  155. package/node_modules/pi-lens/dist/clients/actionable-warnings.js +73 -18
  156. package/node_modules/pi-lens/dist/clients/agent-behavior-client.js +21 -4
  157. package/node_modules/pi-lens/dist/clients/ast-grep-client.js +68 -16
  158. package/node_modules/pi-lens/dist/clients/ast-grep-types.js +0 -1
  159. package/node_modules/pi-lens/dist/clients/ast-grep-yaml-synth.js +37 -10
  160. package/node_modules/pi-lens/dist/clients/atomic-write-staging.js +63 -0
  161. package/node_modules/pi-lens/dist/clients/atomic-write.js +114 -14
  162. package/node_modules/pi-lens/dist/clients/bash-file-access.js +193 -23
  163. package/node_modules/pi-lens/dist/clients/biome-client.js +29 -25
  164. package/node_modules/pi-lens/dist/clients/bootstrap.js +11 -2
  165. package/node_modules/pi-lens/dist/clients/bounded-cache.js +34 -0
  166. package/node_modules/pi-lens/dist/clients/bounded-pid-file-lock.js +192 -0
  167. package/node_modules/pi-lens/dist/clients/bus-publish.js +10 -3
  168. package/node_modules/pi-lens/dist/clients/cache/rule-cache.js +47 -5
  169. package/node_modules/pi-lens/dist/clients/cache-manager.js +97 -9
  170. package/node_modules/pi-lens/dist/clients/cache-observability.js +301 -14
  171. package/node_modules/pi-lens/dist/clients/call-graph.js +449 -117
  172. package/node_modules/pi-lens/dist/clients/child-unref.js +85 -0
  173. package/node_modules/pi-lens/dist/clients/codebase-model.js +52 -23
  174. package/node_modules/pi-lens/dist/clients/collateral-test-role.js +40 -0
  175. package/node_modules/pi-lens/dist/clients/complexity-client.js +2 -1
  176. package/node_modules/pi-lens/dist/clients/console-guard-install.js +14 -0
  177. package/node_modules/pi-lens/dist/clients/cooperative-budget.js +42 -0
  178. package/node_modules/pi-lens/dist/clients/dead-code-client.js +2 -1
  179. package/node_modules/pi-lens/dist/clients/debug-handles.js +206 -0
  180. package/node_modules/pi-lens/dist/clients/debug-heap.js +167 -0
  181. package/node_modules/pi-lens/dist/clients/degradation-ledger.js +153 -0
  182. package/node_modules/pi-lens/dist/clients/dependency-checker.js +347 -75
  183. package/node_modules/pi-lens/dist/clients/diagnostic-dispositions.js +61 -11
  184. package/node_modules/pi-lens/dist/clients/diagnostics-publish.js +10 -3
  185. package/node_modules/pi-lens/dist/clients/dispatch/dispatcher.js +63 -20
  186. package/node_modules/pi-lens/dist/clients/dispatch/facts/function-facts.js +23 -7
  187. package/node_modules/pi-lens/dist/clients/dispatch/facts/import-facts.js +8 -18
  188. package/node_modules/pi-lens/dist/clients/dispatch/facts/tree-sitter-facts.js +8 -5
  189. package/node_modules/pi-lens/dist/clients/dispatch/indent-detect.js +35 -0
  190. package/node_modules/pi-lens/dist/clients/dispatch/inline-suppressions.js +17 -8
  191. package/node_modules/pi-lens/dist/clients/dispatch/integration.js +1085 -681
  192. package/node_modules/pi-lens/dist/clients/dispatch/lazy.js +14 -0
  193. package/node_modules/pi-lens/dist/clients/dispatch/plan.js +11 -1
  194. package/node_modules/pi-lens/dist/clients/dispatch/rule-id-normalize.js +50 -0
  195. package/node_modules/pi-lens/dist/clients/dispatch/rule-policy.js +135 -0
  196. package/node_modules/pi-lens/dist/clients/dispatch/runners/ast-grep-napi.js +23 -5
  197. package/node_modules/pi-lens/dist/clients/dispatch/runners/biome-check.js +5 -9
  198. package/node_modules/pi-lens/dist/clients/dispatch/runners/detekt.js +3 -2
  199. package/node_modules/pi-lens/dist/clients/dispatch/runners/hadolint.js +2 -1
  200. package/node_modules/pi-lens/dist/clients/dispatch/runners/helm-lint.js +154 -0
  201. package/node_modules/pi-lens/dist/clients/dispatch/runners/htmlhint.js +3 -2
  202. package/node_modules/pi-lens/dist/clients/dispatch/runners/index.js +5 -1
  203. package/node_modules/pi-lens/dist/clients/dispatch/runners/lsp.js +14 -6
  204. package/node_modules/pi-lens/dist/clients/dispatch/runners/markdownlint.js +4 -9
  205. package/node_modules/pi-lens/dist/clients/dispatch/runners/pyright.js +9 -5
  206. package/node_modules/pi-lens/dist/clients/dispatch/runners/ruff.js +4 -8
  207. package/node_modules/pi-lens/dist/clients/dispatch/runners/shellcheck.js +2 -3
  208. package/node_modules/pi-lens/dist/clients/dispatch/runners/shfmt.js +2 -3
  209. package/node_modules/pi-lens/dist/clients/dispatch/runners/spotbugs.js +2 -1
  210. package/node_modules/pi-lens/dist/clients/dispatch/runners/taplo.js +2 -1
  211. package/node_modules/pi-lens/dist/clients/dispatch/runners/terragrunt.js +163 -0
  212. package/node_modules/pi-lens/dist/clients/dispatch/runners/tflint.js +37 -5
  213. package/node_modules/pi-lens/dist/clients/dispatch/runners/tree-sitter.js +3 -1
  214. package/node_modules/pi-lens/dist/clients/dispatch/runners/trivy-config.js +13 -7
  215. package/node_modules/pi-lens/dist/clients/dispatch/runners/utils/lazy-installer.js +3 -0
  216. package/node_modules/pi-lens/dist/clients/dispatch/runners/utils/runner-helpers.js +277 -26
  217. package/node_modules/pi-lens/dist/clients/dispatch/runners/utils/spawn-outcome.js +22 -0
  218. package/node_modules/pi-lens/dist/clients/dispatch/runners/yaml-rule-parser.js +5 -4
  219. package/node_modules/pi-lens/dist/clients/disposition-publish.js +10 -3
  220. package/node_modules/pi-lens/dist/clients/durable-store.js +97 -0
  221. package/node_modules/pi-lens/dist/clients/event-loop-monitor.js +94 -3
  222. package/node_modules/pi-lens/dist/clients/extension-log.js +139 -0
  223. package/node_modules/pi-lens/dist/clients/extension-mode.js +73 -0
  224. package/node_modules/pi-lens/dist/clients/file-kinds.js +57 -0
  225. package/node_modules/pi-lens/dist/clients/file-role.js +18 -3
  226. package/node_modules/pi-lens/dist/clients/file-utils.js +56 -34
  227. package/node_modules/pi-lens/dist/clients/format-events-publish.js +16 -3
  228. package/node_modules/pi-lens/dist/clients/format-service.js +24 -8
  229. package/node_modules/pi-lens/dist/clients/formatters-lazy.js +8 -0
  230. package/node_modules/pi-lens/dist/clients/formatters.js +358 -61
  231. package/node_modules/pi-lens/dist/clients/generated-artifacts.js +148 -15
  232. package/node_modules/pi-lens/dist/clients/git-guard.js +681 -11
  233. package/node_modules/pi-lens/dist/clients/git-tracked-ignore.js +4 -0
  234. package/node_modules/pi-lens/dist/clients/go-client.js +2 -1
  235. package/node_modules/pi-lens/dist/clients/govulncheck-client.js +8 -0
  236. package/node_modules/pi-lens/dist/clients/grammar-source.js +12 -4
  237. package/node_modules/pi-lens/dist/clients/gzip-stage-write.js +21 -3
  238. package/node_modules/pi-lens/dist/clients/host-ports.js +31 -0
  239. package/node_modules/pi-lens/dist/clients/installer/index.js +390 -99
  240. package/node_modules/pi-lens/dist/clients/instance-reaper.js +142 -172
  241. package/node_modules/pi-lens/dist/clients/jscpd-client.js +31 -54
  242. package/node_modules/pi-lens/dist/clients/knip-client.js +15 -36
  243. package/node_modules/pi-lens/dist/clients/language-policy.js +14 -2
  244. package/node_modules/pi-lens/dist/clients/language-profile.js +13 -7
  245. package/node_modules/pi-lens/dist/clients/latency-logger.js +23 -1
  246. package/node_modules/pi-lens/dist/clients/lens-config.js +12 -1
  247. package/node_modules/pi-lens/dist/clients/lens-engine.js +86 -17
  248. package/node_modules/pi-lens/dist/clients/lens-events.js +27 -4
  249. package/node_modules/pi-lens/dist/clients/lens-flag-registry.js +8 -0
  250. package/node_modules/pi-lens/dist/clients/lens-map.js +94 -19
  251. package/node_modules/pi-lens/dist/clients/live-bus-emitter.js +30 -0
  252. package/node_modules/pi-lens/dist/clients/log-cleanup.js +2 -1
  253. package/node_modules/pi-lens/dist/clients/lsp/cascade-tier.js +1 -1
  254. package/node_modules/pi-lens/dist/clients/lsp/client.js +493 -48
  255. package/node_modules/pi-lens/dist/clients/lsp/config.js +13 -2
  256. package/node_modules/pi-lens/dist/clients/lsp/diagnostic-binding.js +86 -0
  257. package/node_modules/pi-lens/dist/clients/lsp/edits.js +1012 -121
  258. package/node_modules/pi-lens/dist/clients/lsp/index.js +1807 -780
  259. package/node_modules/pi-lens/dist/clients/lsp/launch.js +32 -26
  260. package/node_modules/pi-lens/dist/clients/lsp/path-utils.js +1 -1
  261. package/node_modules/pi-lens/dist/clients/lsp/ruby-drive-dirs.js +83 -0
  262. package/node_modules/pi-lens/dist/clients/lsp/server.js +196 -32
  263. package/node_modules/pi-lens/dist/clients/lsp/workspace-diagnostics-cache.js +28 -4
  264. package/node_modules/pi-lens/dist/clients/lsp-lazy.js +8 -0
  265. package/node_modules/pi-lens/dist/clients/lsp-mutation.js +248 -0
  266. package/node_modules/pi-lens/dist/clients/mcp/analyze.js +123 -32
  267. package/node_modules/pi-lens/dist/clients/mcp/ipc.js +157 -24
  268. package/node_modules/pi-lens/dist/clients/mcp/session.js +216 -8
  269. package/node_modules/pi-lens/dist/clients/memory-sampler.js +115 -0
  270. package/node_modules/pi-lens/dist/clients/metrics-client.js +2 -1
  271. package/node_modules/pi-lens/dist/clients/metrics-history.js +2 -1
  272. package/node_modules/pi-lens/dist/clients/module-report-lsp.js +5 -0
  273. package/node_modules/pi-lens/dist/clients/module-report.js +168 -3
  274. package/node_modules/pi-lens/dist/clients/ndjson-logger.js +319 -118
  275. package/node_modules/pi-lens/dist/clients/opengrep-client.js +1 -1
  276. package/node_modules/pi-lens/dist/clients/partial-edit-apply.js +120 -5
  277. package/node_modules/pi-lens/dist/clients/path-utils.js +110 -9
  278. package/node_modules/pi-lens/dist/clients/pipeline.js +47 -13
  279. package/node_modules/pi-lens/dist/clients/print-mode.js +21 -0
  280. package/node_modules/pi-lens/dist/clients/project-changes.js +40 -0
  281. package/node_modules/pi-lens/dist/clients/project-diagnostics/cache.js +3 -2
  282. package/node_modules/pi-lens/dist/clients/project-diagnostics/runner-adapters/call-graph-impact.js +8 -0
  283. package/node_modules/pi-lens/dist/clients/project-diagnostics/scanner.js +21 -1
  284. package/node_modules/pi-lens/dist/clients/project-lens-config.js +86 -9
  285. package/node_modules/pi-lens/dist/clients/project-report.js +27 -13
  286. package/node_modules/pi-lens/dist/clients/project-snapshot.js +175 -20
  287. package/node_modules/pi-lens/dist/clients/project-trust.js +181 -0
  288. package/node_modules/pi-lens/dist/clients/quiet-window.js +18 -4
  289. package/node_modules/pi-lens/dist/clients/read-bridge.js +147 -0
  290. package/node_modules/pi-lens/dist/clients/read-guard-logger.js +159 -1
  291. package/node_modules/pi-lens/dist/clients/read-guard-tool-lines.js +150 -29
  292. package/node_modules/pi-lens/dist/clients/read-guard.js +185 -4
  293. package/node_modules/pi-lens/dist/clients/resource-sampler.js +70 -84
  294. package/node_modules/pi-lens/dist/clients/review-graph/builder.js +1122 -221
  295. package/node_modules/pi-lens/dist/clients/review-graph/git-identity.js +2 -1
  296. package/node_modules/pi-lens/dist/clients/review-graph/import-resolvers.js +21 -10
  297. package/node_modules/pi-lens/dist/clients/review-graph/symbol-id.js +46 -0
  298. package/node_modules/pi-lens/dist/clients/review-graph/tsconfig-paths.js +57 -6
  299. package/node_modules/pi-lens/dist/clients/review-graph/workspace-modules.js +51 -3
  300. package/node_modules/pi-lens/dist/clients/review-graph-logger.js +30 -1
  301. package/node_modules/pi-lens/dist/clients/ruff-client.js +27 -44
  302. package/node_modules/pi-lens/dist/clients/runtime-agent-end.js +107 -73
  303. package/node_modules/pi-lens/dist/clients/runtime-context.js +36 -12
  304. package/node_modules/pi-lens/dist/clients/runtime-coordinator.js +74 -6
  305. package/node_modules/pi-lens/dist/clients/runtime-session.js +405 -117
  306. package/node_modules/pi-lens/dist/clients/runtime-tool-call.js +71 -48
  307. package/node_modules/pi-lens/dist/clients/runtime-tool-result.js +218 -23
  308. package/node_modules/pi-lens/dist/clients/runtime-turn.js +237 -64
  309. package/node_modules/pi-lens/dist/clients/rust-client.js +2 -1
  310. package/node_modules/pi-lens/dist/clients/safe-spawn.js +777 -62
  311. package/node_modules/pi-lens/dist/clients/security-scan-client.js +2 -1
  312. package/node_modules/pi-lens/dist/clients/session-state-store.js +3 -2
  313. package/node_modules/pi-lens/dist/clients/sg-runner.js +284 -174
  314. package/node_modules/pi-lens/dist/clients/slow-fs.js +2 -1
  315. package/node_modules/pi-lens/dist/clients/smells-rollup.js +224 -0
  316. package/node_modules/pi-lens/dist/clients/source-filter.js +184 -20
  317. package/node_modules/pi-lens/dist/clients/source-walker.js +66 -10
  318. package/node_modules/pi-lens/dist/clients/startup-scan.js +15 -16
  319. package/node_modules/pi-lens/dist/clients/startup-timing.js +1 -1
  320. package/node_modules/pi-lens/dist/clients/test-runner-client.js +38 -8
  321. package/node_modules/pi-lens/dist/clients/tool-event.js +35 -0
  322. package/node_modules/pi-lens/dist/clients/tool-policy.js +388 -39
  323. package/node_modules/pi-lens/dist/clients/tool-render.js +155 -0
  324. package/node_modules/pi-lens/dist/clients/tree-sitter-cache.js +6 -1
  325. package/node_modules/pi-lens/dist/clients/tree-sitter-client.js +419 -50
  326. package/node_modules/pi-lens/dist/clients/tree-sitter-logger.js +21 -0
  327. package/node_modules/pi-lens/dist/clients/tree-sitter-query-loader.js +7 -1
  328. package/node_modules/pi-lens/dist/clients/tree-sitter-shared.js +7 -2
  329. package/node_modules/pi-lens/dist/clients/tree-sitter-symbol-extractor.js +44 -7
  330. package/node_modules/pi-lens/dist/clients/user-notify.js +51 -0
  331. package/node_modules/pi-lens/dist/clients/vanished-instance-marker.js +57 -0
  332. package/node_modules/pi-lens/dist/clients/warm-attach.js +33 -13
  333. package/node_modules/pi-lens/dist/clients/widget-state.js +300 -43
  334. package/node_modules/pi-lens/dist/clients/word-index.js +391 -64
  335. package/node_modules/pi-lens/dist/clients/workspace-topology.js +89 -6
  336. package/node_modules/pi-lens/dist/index.js +49361 -36888
  337. package/node_modules/pi-lens/dist/mcp/analyze-cli.js +182 -29
  338. package/node_modules/pi-lens/dist/mcp/server.js +211 -44
  339. package/node_modules/pi-lens/dist/tools/ast-grep-search.js +93 -27
  340. package/node_modules/pi-lens/dist/tools/lens-diagnostics.js +200 -36
  341. package/node_modules/pi-lens/dist/tools/lsp-diagnostics.js +153 -40
  342. package/node_modules/pi-lens/dist/tools/lsp-navigation.js +46 -3
  343. package/node_modules/pi-lens/dist/tools/module-report.js +9 -0
  344. package/node_modules/pi-lens/docs/agent-guide.md +2 -2
  345. package/node_modules/pi-lens/docs/agent-tools.md +21 -10
  346. package/node_modules/pi-lens/docs/analysisall.md +192 -0
  347. package/node_modules/pi-lens/docs/api-ports-inventory.md +165 -0
  348. package/node_modules/pi-lens/docs/ast-grep_rules_catalog.md +5 -2
  349. package/node_modules/pi-lens/docs/durable-store-audit-1202.md +32 -0
  350. package/node_modules/pi-lens/docs/environment-variables.md +44 -0
  351. package/node_modules/pi-lens/docs/fable.md +27 -0
  352. package/node_modules/pi-lens/docs/features.md +31 -2
  353. package/node_modules/pi-lens/docs/globalconfig.md +45 -1
  354. package/node_modules/pi-lens/docs/language-coverage.md +4 -3
  355. package/node_modules/pi-lens/docs/lsp-capability-matrix.md +2 -2
  356. package/node_modules/pi-lens/docs/mcp.md +37 -4
  357. package/node_modules/pi-lens/docs/servercapabilities.md +3 -3
  358. package/node_modules/pi-lens/docs/settings.md +12 -1
  359. package/node_modules/pi-lens/docs/tools.md +4 -1
  360. package/node_modules/pi-lens/docs/usage.md +18 -0
  361. package/node_modules/pi-lens/package.json +10 -10
  362. package/node_modules/pi-lens/rules/ast-grep-rules/rule-tests/no-bare-except-test.yml +49 -0
  363. package/node_modules/pi-lens/rules/ast-grep-rules/rule-tests/no-bare-host-path-in-win32-branch-test.yml +87 -0
  364. package/node_modules/pi-lens/rules/ast-grep-rules/rule-tests/no-nested-links-test.yml +11 -1
  365. package/node_modules/pi-lens/rules/ast-grep-rules/rule-tests/no-raw-json-store-write-test.yml +17 -0
  366. package/node_modules/pi-lens/rules/ast-grep-rules/rule-tests/no-win32-isabsolute-for-qualification-test.yml +16 -0
  367. package/node_modules/pi-lens/rules/ast-grep-rules/rules/no-bare-except.yml +3 -15
  368. package/node_modules/pi-lens/rules/ast-grep-rules/rules/no-bare-host-path-in-win32-branch.yml +25 -0
  369. package/node_modules/pi-lens/rules/ast-grep-rules/rules/no-nested-links.yml +7 -0
  370. package/node_modules/pi-lens/rules/ast-grep-rules/rules/no-raw-json-store-write.yml +30 -0
  371. package/node_modules/pi-lens/rules/ast-grep-rules/rules/no-win32-isabsolute-for-qualification.yml +23 -0
  372. package/node_modules/pi-lens/rules/tree-sitter-queries/tsx/no-nested-links.yml +4 -6
  373. package/node_modules/pi-lens/rules/tree-sitter-queries/typescript/duplicate-function-arg.yml +2 -2
  374. package/node_modules/pi-lens/scripts/analyze-pi-lens-logs.mjs +55 -2
  375. package/node_modules/pi-mcp-adapter/CHANGELOG.md +25 -0
  376. package/node_modules/pi-mcp-adapter/README.md +34 -3
  377. package/node_modules/pi-mcp-adapter/agent-plugin-loader.ts +1 -1
  378. package/node_modules/pi-mcp-adapter/commands.ts +15 -7
  379. package/node_modules/pi-mcp-adapter/direct-tools.ts +54 -7
  380. package/node_modules/pi-mcp-adapter/host-html-template.ts +0 -8
  381. package/node_modules/pi-mcp-adapter/index.ts +8 -3
  382. package/node_modules/pi-mcp-adapter/init.ts +29 -5
  383. package/node_modules/pi-mcp-adapter/lifecycle.ts +0 -4
  384. package/node_modules/pi-mcp-adapter/mcp-auth-flow.ts +1 -2
  385. package/node_modules/pi-mcp-adapter/mcp-auth.ts +60 -2
  386. package/node_modules/pi-mcp-adapter/mcp-panel.ts +45 -9
  387. package/node_modules/pi-mcp-adapter/mcp-script-worker.mjs +22 -20
  388. package/node_modules/pi-mcp-adapter/metadata-cache.ts +27 -4
  389. package/node_modules/pi-mcp-adapter/npx-resolver.ts +81 -26
  390. package/node_modules/pi-mcp-adapter/package.json +3 -3
  391. package/node_modules/pi-mcp-adapter/proxy-modes.ts +108 -26
  392. package/node_modules/pi-mcp-adapter/search-ranking.ts +86 -7
  393. package/node_modules/pi-mcp-adapter/server-manager.ts +36 -7
  394. package/node_modules/pi-mcp-adapter/session-recovery.ts +8 -2
  395. package/node_modules/pi-mcp-adapter/tool-approval.ts +48 -7
  396. package/node_modules/pi-mcp-adapter/tool-metadata.ts +42 -4
  397. package/node_modules/pi-mcp-adapter/tool-result-renderer.ts +138 -12
  398. package/node_modules/pi-mcp-adapter/types.ts +93 -27
  399. package/node_modules/pi-mcp-adapter/ui-server.ts +20 -1
  400. package/package.json +17 -13
  401. package/node_modules/pi-lens/dist/clients/ast-grep-parser.js +0 -86
  402. package/node_modules/pi-lens/dist/clients/lsp/interactive-install.js +0 -367
  403. package/node_modules/pi-lens/dist/clients/lsp/lsp-index.js +0 -10
  404. package/node_modules/pi-lens/dist/clients/lsp/server-strategies.js +0 -5
  405. package/node_modules/pi-lens/dist/clients/project-metadata.js +0 -690
  406. package/node_modules/pi-lens/dist/clients/source-groups.js +0 -96
  407. package/node_modules/pi-lens/dist/clients/startup-marker.js +0 -6
  408. package/node_modules/pi-lens/dist/clients/types.js +0 -11
@@ -1,108 +1,378 @@
1
1
  import fs from "node:fs/promises";
2
2
  import path from "node:path";
3
- import { uriToPath } from "./path-utils.js";
3
+ import { URL } from "node:url";
4
+ import { isUnderDir, normalizeEphemeralMapKey, normalizeMapKey, pathsEqual, uriToDiskPath, uriToPath, } from "./path-utils.js";
5
+ import { convertCharacterOffset, lineTextAt, } from "./position-encoding.js";
6
+ import { recordLspMutation, } from "../lsp-mutation.js";
7
+ import { detectLineEnding, normalizeToLF, restoreLineEndings, } from "../host-edit-normalize.js";
8
+ function isRecord(value) {
9
+ return typeof value === "object" && value !== null && !Array.isArray(value);
10
+ }
4
11
  function isPosition(value) {
5
- return (typeof value === "object" &&
6
- value !== null &&
7
- typeof value.line === "number" &&
8
- typeof value.character === "number");
12
+ return (isRecord(value) &&
13
+ Number.isFinite(value.line) &&
14
+ Number.isInteger(value.line) &&
15
+ value.line >= 0 &&
16
+ Number.isFinite(value.character) &&
17
+ Number.isInteger(value.character) &&
18
+ value.character >= 0);
19
+ }
20
+ function comparePosition(a, b) {
21
+ return a.line === b.line ? a.character - b.character : a.line - b.line;
9
22
  }
10
23
  function isRange(value) {
11
- return (typeof value === "object" &&
12
- value !== null &&
13
- isPosition(value.start) &&
14
- isPosition(value.end));
24
+ if (!isRecord(value) || !isPosition(value.start) || !isPosition(value.end)) {
25
+ return false;
26
+ }
27
+ return comparePosition(value.start, value.end) <= 0;
15
28
  }
16
29
  function isTextEdit(value) {
17
- return (typeof value === "object" &&
18
- value !== null &&
19
- isRange(value.range) &&
20
- typeof value.newText === "string");
21
- }
22
- function isTextDocumentEdit(value) {
23
- return (typeof value === "object" &&
24
- value !== null &&
25
- typeof value.textDocument?.uri ===
26
- "string" &&
27
- Array.isArray(value.edits));
30
+ return isRecord(value) && isRange(value.range) && typeof value.newText === "string";
28
31
  }
29
- function comparePosition(a, b) {
30
- return a.line === b.line ? a.character - b.character : a.line - b.line;
32
+ function parseTextEdits(value, context) {
33
+ if (!Array.isArray(value))
34
+ throw new Error(`${context}.edits must be an array`);
35
+ if (!value.every(isTextEdit))
36
+ throw new Error(`malformed text edit in ${context}`);
37
+ return value;
38
+ }
39
+ function parseVersion(value, context) {
40
+ if (value === undefined || value === null)
41
+ return value;
42
+ if (!Number.isFinite(value) || !Number.isInteger(value) || value < 0) {
43
+ throw new Error(`${context}.version must be a nonnegative integer or null`);
44
+ }
45
+ return value;
46
+ }
47
+ function parseResourceOptions(value, kind) {
48
+ if (value === undefined)
49
+ return undefined;
50
+ if (!isRecord(value))
51
+ throw new Error(`${kind}.options must be an object`);
52
+ const allowed = new Set(kind === "delete"
53
+ ? ["ignoreIfNotExists", "recursive"]
54
+ : ["overwrite", "ignoreIfExists"]);
55
+ for (const key of Object.keys(value)) {
56
+ if (!allowed.has(key) || typeof value[key] !== "boolean") {
57
+ throw new Error(`invalid ${kind}.options.${key}`);
58
+ }
59
+ }
60
+ if (value.overwrite === true && value.ignoreIfExists === true) {
61
+ throw new Error(`${kind} cannot set both overwrite and ignoreIfExists`);
62
+ }
63
+ if (kind !== "delete" && value.ignoreIfNotExists !== undefined) {
64
+ throw new Error(`${kind} does not support ignoreIfNotExists`);
65
+ }
66
+ if (kind === "delete" && value.overwrite !== undefined) {
67
+ throw new Error(`delete does not support overwrite`);
68
+ }
69
+ return value;
70
+ }
71
+ function parseTextDocumentEdit(value, context) {
72
+ if (!isRecord(value) || !isRecord(value.textDocument))
73
+ return null;
74
+ if (typeof value.textDocument.uri !== "string") {
75
+ throw new Error(`${context}.textDocument.uri must be a string`);
76
+ }
77
+ const version = parseVersion(value.textDocument.version, `${context}.textDocument`);
78
+ return {
79
+ textDocument: { uri: value.textDocument.uri, version },
80
+ edits: parseTextEdits(value.edits, context),
81
+ };
82
+ }
83
+ function parseWorkspaceEdit(edit) {
84
+ if (!isRecord(edit))
85
+ throw new Error("workspace edit must be an object");
86
+ if (edit.changes !== undefined) {
87
+ if (!isRecord(edit.changes))
88
+ throw new Error("workspace edit changes must be an object");
89
+ for (const [uri, edits] of Object.entries(edit.changes)) {
90
+ if (typeof uri !== "string")
91
+ throw new Error("workspace edit URI must be a string");
92
+ parseTextEdits(edits, `changes[${uri}]`);
93
+ }
94
+ }
95
+ if (edit.documentChanges !== undefined && !Array.isArray(edit.documentChanges)) {
96
+ throw new Error("workspace edit documentChanges must be an array");
97
+ }
98
+ for (const [index, change] of (edit.documentChanges ?? []).entries()) {
99
+ const text = parseTextDocumentEdit(change, `documentChanges[${index}]`);
100
+ if (text)
101
+ continue;
102
+ if (!isRecord(change) || typeof change.kind !== "string") {
103
+ throw new Error(`malformed documentChanges[${index}]`);
104
+ }
105
+ switch (change.kind) {
106
+ case "create":
107
+ if (typeof change.uri !== "string")
108
+ throw new Error("create.uri must be a string");
109
+ parseResourceOptions(change.options, "create");
110
+ break;
111
+ case "rename":
112
+ if (typeof change.oldUri !== "string" || typeof change.newUri !== "string") {
113
+ throw new Error("rename requires oldUri and newUri strings");
114
+ }
115
+ parseResourceOptions(change.options, "rename");
116
+ break;
117
+ case "delete":
118
+ if (typeof change.uri !== "string")
119
+ throw new Error("delete.uri must be a string");
120
+ parseResourceOptions(change.options, "delete");
121
+ break;
122
+ default:
123
+ throw new Error(`unsupported workspace resource operation: ${change.kind}`);
124
+ }
125
+ }
31
126
  }
32
127
  function formatRange(range) {
33
128
  return `${range.start.line + 1}:${range.start.character + 1}-${range.end.line + 1}:${range.end.character + 1}`;
34
129
  }
35
130
  export function rangesOverlap(a, b) {
36
- return (comparePosition(a.start, b.end) < 0 && comparePosition(b.start, a.end) < 0);
37
- }
38
- export function applyTextEditsToString(content, edits) {
39
- const sortedEdits = [...edits].sort((a, b) => {
40
- const lineDelta = b.range.start.line - a.range.start.line;
41
- return lineDelta !== 0
42
- ? lineDelta
43
- : b.range.start.character - a.range.start.character;
44
- });
45
- for (let index = 0; index < sortedEdits.length - 1; index++) {
46
- const later = sortedEdits[index]?.range;
47
- const earlier = sortedEdits[index + 1]?.range;
131
+ return comparePosition(a.start, b.end) < 0 && comparePosition(b.start, a.end) < 0;
132
+ }
133
+ function positionsEqual(a, b) {
134
+ return a.line === b.line && a.character === b.character;
135
+ }
136
+ function isEmptyRange(range) {
137
+ return positionsEqual(range.start, range.end);
138
+ }
139
+ /**
140
+ * Order edits for reverse-application to a string: latest position first, so an
141
+ * earlier splice never shifts a not-yet-applied edit's offsets. Ties break to
142
+ * match LSP insertion semantics:
143
+ * - same start, larger END first → a replace anchored at position P applies
144
+ * before a zero-width insert at P, so the insert lands at the (post-replace)
145
+ * boundary instead of being clobbered — and the result no longer depends on
146
+ * listing order (P3-1, insert-at-replace-boundary);
147
+ * - same start AND end (e.g. several zero-width inserts at one point) → later
148
+ * ARRAY index first, so applying in this reverse order reproduces the edits'
149
+ * original array order (the #1066 same-position-insert invariant).
150
+ *
151
+ * This ordering is NOT idempotent for the same-range tie (re-sorting flips the
152
+ * array index), so it MUST be applied exactly once, at the string-write site,
153
+ * over array-order input. `normalizeTextEditsForContent` deliberately returns
154
+ * edits in array order (never pre-sorted) so this single sort is the only pass.
155
+ */
156
+ function sortEditsForApplication(edits) {
157
+ return edits
158
+ .map((edit, index) => ({ edit, index }))
159
+ .sort((a, b) => {
160
+ const startDelta = comparePosition(b.edit.range.start, a.edit.range.start);
161
+ if (startDelta !== 0)
162
+ return startDelta;
163
+ const endDelta = comparePosition(b.edit.range.end, a.edit.range.end);
164
+ if (endDelta !== 0)
165
+ return endDelta;
166
+ return b.index - a.index;
167
+ })
168
+ .map(({ edit }) => edit);
169
+ }
170
+ /**
171
+ * Deduplicate exact non-empty duplicate edits and reject genuinely overlapping
172
+ * ranges, returning the survivors in their ORIGINAL ARRAY ORDER. Zero-width
173
+ * inserts are never deduplicated (their multiplicity is meaningful). Overlap is
174
+ * checked on a position-sorted copy so the check is order-independent, but the
175
+ * returned order is preserved for the single downstream application sort.
176
+ */
177
+ function validateTextEdits(edits) {
178
+ const unique = [];
179
+ const seen = new Set();
180
+ for (const edit of edits) {
181
+ if (!isEmptyRange(edit.range)) {
182
+ const key = textEditKey("", edit);
183
+ if (seen.has(key))
184
+ continue;
185
+ seen.add(key);
186
+ }
187
+ unique.push(edit);
188
+ }
189
+ const ordered = sortEditsForApplication(unique);
190
+ for (let index = 0; index < ordered.length - 1; index++) {
191
+ const later = ordered[index]?.range;
192
+ const earlier = ordered[index + 1]?.range;
48
193
  if (later && earlier && comparePosition(earlier.end, later.start) > 0) {
49
194
  throw new Error(`overlapping LSP edits: ${formatRange(earlier)} conflicts with ${formatRange(later)}`);
50
195
  }
51
196
  }
197
+ return unique;
198
+ }
199
+ function utf16Position(content, position, encoding) {
200
+ const line = lineTextAt(content, position.line);
201
+ const wireLength = convertCharacterOffset(encoding, line, line.length);
202
+ // `lineTextAt` splits on `\n` only and keeps a trailing `\r`, so the "real"
203
+ // (spec) end of line content is BEFORE that `\r`, not after it. Clamp to
204
+ // that boundary rather than `line.length`/`wireLength`, which include the
205
+ // `\r`. `\r` is exactly one code unit in every encoding, so the clamped
206
+ // wire length is always `wireLength - 1` when a `\r` is present.
207
+ const hasTrailingCR = line.endsWith("\r");
208
+ const clampedLength = hasTrailingCR ? line.length - 1 : line.length;
209
+ const clampedWireLength = hasTrailingCR ? wireLength - 1 : wireLength;
210
+ if (position.character > clampedWireLength) {
211
+ // LSP 3.17: a character past the end of the line defaults to the line
212
+ // length. Clamp to the UTF-16 line length rather than throwing (whole-line
213
+ // and whole-document sentinel ranges rely on this) — landing the position
214
+ // at the CRLF boundary, not between `\r` and `\n`. Otherwise the whole-line
215
+ // sentinel replace `(0,0)-(0,999)` would eat the `\r` and a char-past-EOL
216
+ // insert would land mid-CRLF (P2-1 corruption on Windows repos).
217
+ //
218
+ // This also covers a caller-supplied position that is NOT past the line's
219
+ // full (with-`\r`) length but still lands strictly between `\r` and `\n`
220
+ // (i.e. `position.character === wireLength` when a `\r` is present): that
221
+ // position is `> clampedWireLength` too, since `clampedWireLength ===
222
+ // wireLength - 1`, so it clamps to the same CRLF-safe boundary instead of
223
+ // slipping through as an "in-bounds" position (#1147 P3-5, general class
224
+ // beyond #1120's strict past-EOL clamp).
225
+ return { line: position.line, character: clampedLength };
226
+ }
227
+ if (encoding === "utf-16")
228
+ return position;
229
+ // Find the UTF-16 offset whose encoded prefix has exactly this length. This
230
+ // rejects offsets in the middle of a UTF-8 sequence or UTF-16 surrogate pair.
231
+ for (let offset = 0; offset <= line.length; offset++) {
232
+ // A UTF-32 position cannot split a UTF-16 surrogate pair.
233
+ if (encoding === "utf-32" && offset > 0 && offset < line.length) {
234
+ const previous = line.charCodeAt(offset - 1);
235
+ const current = line.charCodeAt(offset);
236
+ if (previous >= 0xd800 && previous <= 0xdbff && current >= 0xdc00 && current <= 0xdfff)
237
+ continue;
238
+ }
239
+ if (convertCharacterOffset(encoding, line, offset) === position.character) {
240
+ return { line: position.line, character: offset };
241
+ }
242
+ }
243
+ throw new Error(`text edit character ${position.character} is not a ${encoding} boundary`);
244
+ }
245
+ function normalizeTextEditsForContent(content, edits, encoding) {
52
246
  const lines = content.split("\n");
53
- for (const edit of sortedEdits) {
247
+ const lastLine = Math.max(0, lines.length - 1);
248
+ // The target file's dominant EOL style, detected the same way the host edit
249
+ // tool does (first-occurrence-wins). Every `newText` is normalized to this
250
+ // style below so a server-supplied bare `\n` can never be spliced verbatim
251
+ // into a CRLF file and produce mixed line endings — the LSP workspace-edit
252
+ // apply path now shares the exact contract the host-edit path already
253
+ // enforces via `host-edit-normalize.ts` (#1147 P3-5, general class). A no-op
254
+ // for LF files: `restoreLineEndings` is the identity when `ending === "\n"`.
255
+ const ending = detectLineEnding(content);
256
+ const clamp = (position) => {
257
+ // LSP 3.17: a line past the end of the document defaults to the end of the
258
+ // document (last line, its length). Clamp rather than throw so a large
259
+ // sentinel end position — e.g. the (0,0)-(9999,0) whole-document replace
260
+ // idiom — resolves to a real range instead of failing.
261
+ if (position.line >= lines.length) {
262
+ return { line: lastLine, character: (lines[lastLine] ?? "").length };
263
+ }
264
+ return utf16Position(content, position, encoding);
265
+ };
266
+ const converted = edits.map((edit) => {
267
+ const start = clamp(edit.range.start);
268
+ const end = clamp(edit.range.end);
269
+ if (comparePosition(start, end) > 0)
270
+ throw new Error("text edit range is out of order");
271
+ return {
272
+ ...edit,
273
+ range: { start, end },
274
+ newText: restoreLineEndings(normalizeToLF(edit.newText), ending),
275
+ };
276
+ });
277
+ // Return array order (validated, deduped); the single application-ordering
278
+ // sort happens once at the string-write site in applyTextEditsToString.
279
+ return validateTextEdits(converted);
280
+ }
281
+ export function applyTextEditsToString(content, edits, positionEncoding = "utf-16") {
282
+ const lines = content.split("\n");
283
+ // normalizeTextEditsForContent returns array order; sort for reverse
284
+ // application exactly once, here, at the single string-write site.
285
+ const normalized = sortEditsForApplication(normalizeTextEditsForContent(content, edits, positionEncoding));
286
+ for (const edit of normalized) {
54
287
  const { start, end } = edit.range;
55
288
  if (start.line === end.line) {
56
289
  const line = lines[start.line] ?? "";
57
- lines[start.line] =
58
- line.slice(0, start.character) +
59
- edit.newText +
60
- line.slice(end.character);
290
+ lines[start.line] = line.slice(0, start.character) + edit.newText + line.slice(end.character);
61
291
  continue;
62
292
  }
63
293
  const startLine = lines[start.line] ?? "";
64
294
  const endLine = lines[end.line] ?? "";
65
- const replacement = startLine.slice(0, start.character) +
66
- edit.newText +
67
- endLine.slice(end.character);
295
+ const replacement = startLine.slice(0, start.character) + edit.newText + endLine.slice(end.character);
68
296
  lines.splice(start.line, end.line - start.line + 1, ...replacement.split("\n"));
69
297
  }
70
298
  return lines.join("\n");
71
299
  }
300
+ export async function normalizeWorkspaceEditToUtf16(edit, positionEncoding, cwd) {
301
+ parseWorkspaceEdit(edit);
302
+ if (positionEncoding === "utf-16")
303
+ return edit;
304
+ // Use the same ordered planner and virtual resource/content model as the
305
+ // eventual apply. This is important for edits such as rename(oldDir, newDir)
306
+ // followed by a text edit at newDir/file.ts: the destination is not on disk
307
+ // yet, but its content is available through the virtual move.
308
+ const planned = planWorkspaceEdit(edit, true);
309
+ const prepared = await preflightWorkspaceEdit(planned, cwd, { positionEncoding });
310
+ const normalizedByOrigin = new Map();
311
+ for (const op of planned) {
312
+ if (op.kind !== "text")
313
+ continue;
314
+ for (const origin of op.origins ?? []) {
315
+ normalizedByOrigin.set(textEditOriginKey(origin), prepared.textByOrigin.get(textEditOriginKey(origin)) ?? []);
316
+ }
317
+ }
318
+ const changes = {};
319
+ for (const uri of Object.keys(edit.changes ?? {})) {
320
+ changes[uri] = normalizedByOrigin.get(textEditOriginKey({ kind: "changes", uri, edits: [] })) ?? [];
321
+ }
322
+ const documentChanges = [];
323
+ for (const [index, change] of (edit.documentChanges ?? []).entries()) {
324
+ const text = parseTextDocumentEdit(change, `documentChanges[${index}]`);
325
+ if (!text) {
326
+ documentChanges.push(change);
327
+ continue;
328
+ }
329
+ documentChanges.push({
330
+ ...change,
331
+ edits: normalizedByOrigin.get(textEditOriginKey({ kind: "documentChanges", index, edits: [] })) ?? [],
332
+ });
333
+ }
334
+ return {
335
+ ...(edit.changes !== undefined ? { changes } : {}),
336
+ ...(edit.documentChanges !== undefined ? { documentChanges } : {}),
337
+ };
338
+ }
72
339
  export function flattenWorkspaceTextEdits(edit) {
73
- const out = new Map();
340
+ parseWorkspaceEdit(edit);
341
+ const buckets = new Map();
74
342
  const push = (uri, edits) => {
75
- const textEdits = edits.filter(isTextEdit);
343
+ const textEdits = parseTextEdits(edits, uri);
76
344
  if (textEdits.length === 0)
77
345
  return;
78
- const existing = out.get(uri);
346
+ const key = pathIndexKey(uri);
347
+ const existing = buckets.get(key);
79
348
  if (existing)
80
- existing.push(...textEdits);
349
+ existing.edits.push(...textEdits);
81
350
  else
82
- out.set(uri, [...textEdits]);
351
+ buckets.set(key, { uri, edits: [...textEdits] });
83
352
  };
84
- for (const [uri, edits] of Object.entries(edit.changes ?? {})) {
353
+ for (const [uri, edits] of Object.entries(edit.changes ?? {}))
85
354
  push(uri, edits);
86
- }
87
355
  for (const change of edit.documentChanges ?? []) {
88
- if (isTextDocumentEdit(change)) {
89
- push(change.textDocument.uri, change.edits);
90
- }
356
+ const text = parseTextDocumentEdit(change, "documentChanges");
357
+ if (text)
358
+ push(text.textDocument.uri, text.edits);
91
359
  }
92
- return out;
360
+ return new Map([...buckets.values()].map(({ uri, edits }) => [uri, edits]));
93
361
  }
94
362
  function textEditKey(uri, edit) {
95
- return [
96
- uri,
97
- edit.range.start.line,
98
- edit.range.start.character,
99
- edit.range.end.line,
100
- edit.range.end.character,
101
- edit.newText,
102
- ].join(":");
363
+ return [pathIndexKey(uri), edit.range.start.line, edit.range.start.character, edit.range.end.line, edit.range.end.character, edit.newText].join(":");
103
364
  }
104
365
  export function mergeWorkspaceTextEditsByPriority(entries) {
105
366
  const merged = new Map();
367
+ // Exact-duplicate dedup is a CROSS-SERVER concern only: two servers proposing
368
+ // the identical non-empty replace should collapse to one. Zero-width inserts
369
+ // are never deduplicated here — same as `validateTextEdits` on the normal
370
+ // apply path — because their multiplicity is meaningful: a single server can
371
+ // legitimately emit several identical zero-width inserts at one position
372
+ // (e.g. `willRenameFiles` producing "QQ" twice for an "aQQbc" edit), and
373
+ // deduping on exact key alone would silently drop the duplicate within that
374
+ // one server's own edit, contradicting the invariant documented above
375
+ // `validateTextEdits`.
106
376
  const seenExact = new Set();
107
377
  let droppedConflicts = 0;
108
378
  let inputEditCount = 0;
@@ -112,117 +382,738 @@ export function mergeWorkspaceTextEditsByPriority(entries) {
112
382
  if (!entry.edit)
113
383
  continue;
114
384
  for (const [uri, edits] of flattenWorkspaceTextEdits(entry.edit)) {
115
- const kept = merged.get(uri) ?? [];
385
+ const key = pathIndexKey(uri);
386
+ const bucket = merged.get(key) ?? { uri, edits: [] };
387
+ const kept = bucket.edits;
116
388
  for (const edit of edits) {
117
389
  inputEditCount += 1;
118
- const exactKey = textEditKey(uri, edit);
119
- if (seenExact.has(exactKey))
390
+ const exactKey = isEmptyRange(edit.range) ? undefined : textEditKey(uri, edit);
391
+ if (exactKey !== undefined && seenExact.has(exactKey))
120
392
  continue;
121
393
  if (kept.some((existing) => rangesOverlap(existing.range, edit.range))) {
122
394
  droppedConflicts += 1;
123
395
  continue;
124
396
  }
125
- seenExact.add(exactKey);
397
+ if (exactKey !== undefined)
398
+ seenExact.add(exactKey);
126
399
  kept.push(edit);
127
400
  }
128
401
  if (kept.length > 0)
129
- merged.set(uri, kept);
402
+ merged.set(key, bucket);
130
403
  }
131
404
  }
132
405
  const changes = {};
133
- for (const [uri, edits] of merged) {
406
+ for (const { uri, edits } of merged.values())
134
407
  changes[uri] = edits;
135
- }
136
408
  return { edit: { changes }, droppedConflicts, inputEditCount, serverIds };
137
409
  }
410
+ function pathIndexKey(uri) {
411
+ // uriToPath already resolves the path through normalizeFilePath, including
412
+ // realpath canonicalization for existing files. Re-canonicalizing that
413
+ // result with normalizeMapKey made every planner lookup perform a second
414
+ // synchronous filesystem walk; this index is call-scoped, so the cheap fold
415
+ // is sufficient after the first canonicalization.
416
+ return normalizeEphemeralMapKey(uriToPath(uri));
417
+ }
418
+ function textEditOriginKey(origin) {
419
+ // This is an input-container identity, not a filesystem/resource key. Keep
420
+ // the original URI spelling so two equivalent `changes` keys cannot overwrite
421
+ // each other's normalized output while the planner still coalesces them.
422
+ return origin.kind === "changes"
423
+ ? `changes:${origin.uri}`
424
+ : `documentChanges:${origin.index}`;
425
+ }
426
+ function parseResource(change) {
427
+ const kind = change.kind;
428
+ if (kind === "create")
429
+ return { kind, uri: change.uri, options: parseResourceOptions(change.options, kind) };
430
+ if (kind === "rename")
431
+ return { kind, oldUri: change.oldUri, newUri: change.newUri, options: parseResourceOptions(change.options, kind) };
432
+ if (kind === "delete")
433
+ return { kind, uri: change.uri, options: parseResourceOptions(change.options, kind) };
434
+ throw new Error(`unsupported workspace resource operation: ${String(kind)}`);
435
+ }
436
+ function planWorkspaceEdit(edit, trackOrigins = false) {
437
+ parseWorkspaceEdit(edit);
438
+ const ops = [];
439
+ const pending = new Map();
440
+ const descendants = new Map();
441
+ const indexedAncestors = new Map();
442
+ const seenResources = new Set();
443
+ const pathKeys = new Map();
444
+ const indexKey = (uri) => {
445
+ const cached = pathKeys.get(uri);
446
+ if (cached !== undefined)
447
+ return cached;
448
+ const key = pathIndexKey(uri);
449
+ pathKeys.set(uri, key);
450
+ return key;
451
+ };
452
+ const addIndex = (key) => {
453
+ const ancestors = [];
454
+ let current = key;
455
+ // `key` is a normalized URI index key, not an on-disk directory. The
456
+ // index intentionally uses the key's own separator/root semantics, so
457
+ // walkUpDirs (which resolves through the host filesystem) is not suitable.
458
+ while (true) {
459
+ const set = descendants.get(current) ?? new Set();
460
+ set.add(key);
461
+ descendants.set(current, set);
462
+ ancestors.push(current);
463
+ const parent = path.dirname(current);
464
+ if (parent === current)
465
+ break;
466
+ current = parent;
467
+ }
468
+ indexedAncestors.set(key, ancestors);
469
+ };
470
+ const removeIndex = (key) => {
471
+ for (const ancestor of indexedAncestors.get(key) ?? [])
472
+ descendants.get(ancestor)?.delete(key);
473
+ indexedAncestors.delete(key);
474
+ };
475
+ const queue = (uri, edits, version, origin) => {
476
+ const key = indexKey(uri);
477
+ const existing = pending.get(key);
478
+ if (existing) {
479
+ // Per LSP 3.17, `version: null` means "don't check" — it is not itself a
480
+ // version to conflict against. Only two DIFFERENT numeric versions for the
481
+ // same URI are a genuine conflict; a numeric version is authoritative and
482
+ // adopted over a `null`/`undefined` counterpart from another edit container
483
+ // for the same document, so the later preflight version check (which only
484
+ // fires for numeric `op.version`) still validates it.
485
+ const existingNumeric = typeof existing.version === "number" ? existing.version : undefined;
486
+ const incomingNumeric = typeof version === "number" ? version : undefined;
487
+ if (existingNumeric !== undefined && incomingNumeric !== undefined && existingNumeric !== incomingNumeric) {
488
+ throw new Error(`conflicting text document versions for ${uri}`);
489
+ }
490
+ existing.edits.push(...edits);
491
+ if (origin)
492
+ (existing.origins ??= []).push(origin);
493
+ if (existingNumeric === undefined && incomingNumeric !== undefined)
494
+ existing.version = version;
495
+ return;
496
+ }
497
+ if (origin) {
498
+ pending.set(key, { uri, edits: [...edits], version, origins: [origin] });
499
+ }
500
+ else {
501
+ pending.set(key, { uri, edits: [...edits], version });
502
+ }
503
+ addIndex(key);
504
+ };
505
+ const flushUri = (uri) => {
506
+ const key = indexKey(uri);
507
+ const item = pending.get(key);
508
+ if (!item)
509
+ return;
510
+ pending.delete(key);
511
+ removeIndex(key);
512
+ if (item.origins) {
513
+ ops.push({
514
+ kind: "text",
515
+ uri: item.uri,
516
+ edits: item.edits,
517
+ version: item.version,
518
+ origins: item.origins,
519
+ });
520
+ }
521
+ else {
522
+ ops.push({ kind: "text", uri: item.uri, edits: item.edits, version: item.version });
523
+ }
524
+ };
525
+ const flushSubtree = (uri) => {
526
+ const key = indexKey(uri);
527
+ for (const candidate of [...(descendants.get(key) ?? [])]) {
528
+ const item = pending.get(candidate);
529
+ if (item)
530
+ flushUri(item.uri);
531
+ }
532
+ };
533
+ for (const [uri, edits] of Object.entries(edit.changes ?? {})) {
534
+ const parsed = parseTextEdits(edits, `changes[${uri}]`);
535
+ queue(uri, parsed, undefined, trackOrigins ? { kind: "changes", uri, edits: parsed } : undefined);
536
+ }
537
+ for (const [index, change] of (edit.documentChanges ?? []).entries()) {
538
+ const text = parseTextDocumentEdit(change, `documentChanges[${index}]`);
539
+ if (text) {
540
+ queue(text.textDocument.uri, text.edits, text.textDocument.version, trackOrigins
541
+ ? {
542
+ kind: "documentChanges",
543
+ index,
544
+ edits: text.edits,
545
+ }
546
+ : undefined);
547
+ continue;
548
+ }
549
+ const resource = parseResource(change);
550
+ const resourceKey = resource.kind === "rename"
551
+ ? `rename:${indexKey(resource.oldUri)}:${indexKey(resource.newUri)}`
552
+ : `${resource.kind}:${indexKey(resource.uri)}`;
553
+ if (seenResources.has(resourceKey))
554
+ throw new Error(`duplicate workspace resource operation: ${resourceKey}`);
555
+ seenResources.add(resourceKey);
556
+ if (resource.kind === "create")
557
+ flushUri(resource.uri);
558
+ else if (resource.kind === "rename") {
559
+ flushSubtree(resource.oldUri);
560
+ flushSubtree(resource.newUri);
561
+ }
562
+ else
563
+ flushSubtree(resource.uri);
564
+ ops.push(resource);
565
+ }
566
+ for (const item of [...pending.values()])
567
+ flushUri(item.uri);
568
+ return ops;
569
+ }
570
+ /** Test-only planning probe used by the scaled occupancy regression. */
571
+ export function __planWorkspaceEditForTest(edit) {
572
+ return planWorkspaceEdit(edit).length;
573
+ }
138
574
  function relativeToCwd(filePath, cwd) {
139
575
  const rel = path.relative(cwd, filePath) || path.basename(filePath);
140
576
  return rel.replace(/\\/g, "/");
141
577
  }
578
+ // Matches an import declaration or a re-export-from declaration — the two
579
+ // statement shapes that actually change a module's dependency edges. Also
580
+ // matches a bare `from "..."` continuation line WITHOUT a leading `import`/
581
+ // `export`, so a formatter-wrapped multiline import
582
+ // import {
583
+ // foo,
584
+ // } from "./old";
585
+ // still flags its specifier line as import-relevant even though line 1
586
+ // ("import {") doesn't itself contain the module path. Fail-safe direction:
587
+ // this can over-match a non-import line that happens to contain `from "..."`
588
+ // (e.g. a string literal), but that only risks over-reporting `importsChanged`
589
+ // (the safe direction — master's prior /^import\s/m heuristic over-invalidated
590
+ // too), never under-reporting a real specifier change.
591
+ const IMPORT_RELEVANT_LINE = /^\s*import\b|^\s*export\s[^;]*\bfrom\s|\bfrom\s+['"]/;
592
+ /**
593
+ * A stable signature of the import/re-export-from lines in `text`, order-
594
+ * preserved. Used to detect whether a text edit actually changed the file's
595
+ * import graph (P3-6) rather than merely landing in a file that HAS imports —
596
+ * `fileDetails[].importsChanged` gates expensive downstream dependency-graph
597
+ * re-checks (see `cache-manager.ts`'s `importsChanged` filter and
598
+ * `lsp-mutation.ts`'s `addModifiedRange`), so over-reporting "changed" on
599
+ * every edit to an already-import-bearing file defeats that gate. This is a
600
+ * LINE-signature heuristic, not a parse: a multiline import whose specifier
601
+ * ("from" line) is untouched but whose bound-name list changes on an
602
+ * interior line (e.g. renaming one of several named imports without
603
+ * touching the `import {`/`} from "..."` lines) is not detected — narrower
604
+ * than a full import-statement diff, but still strictly safer than the prior
605
+ * "file merely contains any import" heuristic. Known pre-existing gaps
606
+ * (unaddressed here, same as before): dynamic `import(...)` calls and
607
+ * `require(...)` are not import-relevant lines by this heuristic.
608
+ */
609
+ function importsSignature(text) {
610
+ return text
611
+ .split("\n")
612
+ .filter((line) => IMPORT_RELEVANT_LINE.test(line))
613
+ .join("\n");
614
+ }
142
615
  export function summarizeWorkspaceEdit(edit, cwd) {
143
616
  const lines = [];
144
- const textEditsByUri = flattenWorkspaceTextEdits(edit);
145
- for (const [uri, edits] of textEditsByUri) {
617
+ for (const [uri, edits] of flattenWorkspaceTextEdits(edit))
146
618
  lines.push(`Apply ${edits.length} edit(s) to ${relativeToCwd(uriToPath(uri), cwd)}`);
147
- }
148
619
  for (const change of edit.documentChanges ?? []) {
149
- if (typeof change !== "object" || change === null || !("kind" in change))
620
+ if (!isRecord(change) || typeof change.kind !== "string")
150
621
  continue;
151
- const kind = change.kind;
152
- if (kind === "create" && typeof change.uri === "string") {
622
+ if (change.kind === "create" && typeof change.uri === "string")
153
623
  lines.push(`Create ${relativeToCwd(uriToPath(change.uri), cwd)}`);
154
- }
155
- else if (kind === "rename" &&
156
- typeof change.oldUri === "string" &&
157
- typeof change.newUri === "string") {
624
+ else if (change.kind === "rename" && typeof change.oldUri === "string" && typeof change.newUri === "string")
158
625
  lines.push(`Rename ${relativeToCwd(uriToPath(change.oldUri), cwd)} → ${relativeToCwd(uriToPath(change.newUri), cwd)}`);
159
- }
160
- else if (kind === "delete" &&
161
- typeof change.uri === "string") {
626
+ else if (change.kind === "delete" && typeof change.uri === "string")
162
627
  lines.push(`Delete ${relativeToCwd(uriToPath(change.uri), cwd)}`);
163
- }
164
628
  }
165
629
  return lines;
166
630
  }
167
- export async function applyWorkspaceEdit(edit, cwd) {
631
+ async function lstatOrMissing(filePath) {
632
+ try {
633
+ return await fs.lstat(filePath);
634
+ }
635
+ catch (err) {
636
+ if (err.code === "ENOENT")
637
+ return undefined;
638
+ throw err;
639
+ }
640
+ }
641
+ /**
642
+ * True when two `lstat` results denote the SAME on-disk entry. Requires a
643
+ * matching `dev` AND a matching, NONZERO `ino`. The nonzero guard is
644
+ * load-bearing: ino-less filesystems (FAT32/exFAT, some SMB redirectors,
645
+ * VirtualBox shared folders) report `ino: 0` for every entry via libuv, so
646
+ * without it two DISTINCT files would compare `(dev, 0) === (dev, 0)` and a
647
+ * rename would be misclassified as a case-only alias and silently clobber the
648
+ * destination. Comparing the BigInt fields (from `lstat({ bigint: true })`)
649
+ * also avoids the double-precision loss on NTFS 64-bit file IDs >= 2^53 that a
650
+ * `number` `ino` would suffer (which could false-equal distinct files).
651
+ * Anything that is not a confident same-entry match returns false → the caller
652
+ * falls back to the destination-exists check (fail-closed). Exported for direct
653
+ * unit coverage of the ino-0 guard.
654
+ */
655
+ export function isSameFsIdentity(a, b) {
656
+ return a.dev === b.dev && a.ino !== 0n && b.ino !== 0n && a.ino === b.ino;
657
+ }
658
+ async function bigintLstatOrMissing(filePath) {
659
+ try {
660
+ return await fs.lstat(filePath, { bigint: true });
661
+ }
662
+ catch (err) {
663
+ if (err.code === "ENOENT")
664
+ return undefined;
665
+ throw err;
666
+ }
667
+ }
668
+ /**
669
+ * True when two paths name the SAME on-disk entry — how a case-only rename on a
670
+ * case-insensitive FS is told apart from a genuine destination conflict without
671
+ * branching on `process.platform`. `lstat` (not `stat`) so a symlink is compared
672
+ * as itself, consistent with the rest of the preflight's symlink policy. See
673
+ * `isSameFsIdentity` for the dev/ino identity rules (and the ino-0 fail-closed
674
+ * guard for ino-less filesystems).
675
+ */
676
+ async function isSameFsEntry(a, b) {
677
+ const [statA, statB] = await Promise.all([
678
+ bigintLstatOrMissing(a),
679
+ bigintLstatOrMissing(b),
680
+ ]);
681
+ return Boolean(statA && statB && isSameFsIdentity(statA, statB));
682
+ }
683
+ async function assertParentIsUsable(filePath, stateFor) {
684
+ let current = path.dirname(filePath);
685
+ while (true) {
686
+ const known = await stateFor(current);
687
+ if (known.exists) {
688
+ if (!known.directory)
689
+ throw new Error(`resource parent is not a directory: ${current}`);
690
+ return;
691
+ }
692
+ const parent = path.dirname(current);
693
+ if (parent === current)
694
+ throw new Error(`resource parent does not exist: ${filePath}`);
695
+ current = parent;
696
+ }
697
+ }
698
+ async function resolveExistingAncestor(filePath) {
699
+ let current = path.resolve(filePath);
700
+ const tail = [];
701
+ while (true) {
702
+ try {
703
+ const resolved = await fs.realpath(current);
704
+ return path.join(resolved, ...tail.reverse());
705
+ }
706
+ catch (err) {
707
+ const code = err.code;
708
+ if (code !== "ENOENT" && code !== "ENOTDIR")
709
+ throw err;
710
+ const parent = path.dirname(current);
711
+ if (parent === current)
712
+ throw err;
713
+ tail.push(path.basename(current));
714
+ current = parent;
715
+ }
716
+ }
717
+ }
718
+ async function createWorkspaceUriConfiner(cwd) {
719
+ const root = await resolveExistingAncestor(cwd);
720
+ return async (uri) => {
721
+ let filePath = "";
722
+ try {
723
+ const parsed = new URL(uri);
724
+ if (parsed.protocol !== "file:" || (parsed.host !== "" && parsed.hostname !== "localhost"))
725
+ throw new Error("URI must be a local file URI");
726
+ filePath = uriToPath(uri);
727
+ }
728
+ catch (err) {
729
+ throw new Error(`invalid workspace edit URI ${uri}: ${err instanceof Error ? err.message : String(err)}`);
730
+ }
731
+ if (!path.isAbsolute(filePath))
732
+ throw new Error(`workspace edit path escapes workspace: ${uri}`);
733
+ const resolved = await resolveExistingAncestor(filePath);
734
+ if (!isUnderDir(resolved, root))
735
+ throw new Error(`workspace edit path escapes workspace: ${uri}`);
736
+ return filePath;
737
+ };
738
+ }
739
+ /** Validate a resource-only workspace edit without applying any mutation. */
740
+ export async function validateWorkspaceEdit(edit, cwd) {
741
+ const planned = planWorkspaceEdit(edit);
742
+ await preflightWorkspaceEdit(planned, cwd, {});
743
+ }
744
+ async function preflightWorkspaceEdit(planned, cwd, options) {
745
+ const confine = await createWorkspaceUriConfiner(cwd);
746
+ const virtual = new Map();
747
+ // State for a path that `resolveVirtualPath` cannot resolve to a physical
748
+ // address — i.e. a path CURRENTLY vacated by an earlier rename in this same
749
+ // edit (its "from" address, still shadowed by an active virtual move) — but
750
+ // which a later `create` op in the same ordered edit re-establishes at that
751
+ // exact address. Keyed on the raw (unresolved) query path because there is
752
+ // no physical path to key `virtual` on. Consulted only when `resolveVirtualPath`
753
+ // returns undefined, so it never shadows a real physical/virtual-move address.
754
+ const virtualOverrides = new Map();
755
+ const virtualMoves = [];
756
+ const virtualTombstones = [];
757
+ const text = new Map();
758
+ const textByOrigin = new Map();
759
+ const ignored = new Set();
760
+ const resolveVirtualPath = (filePath) => {
761
+ let resolved = filePath;
762
+ for (let index = virtualMoves.length - 1; index >= 0; index--) {
763
+ const move = virtualMoves[index];
764
+ if (isUnderDir(resolved, move.to) &&
765
+ (move.directory || pathsEqual(resolved, move.to))) {
766
+ const suffix = path.relative(move.to, resolved);
767
+ resolved = suffix ? path.join(move.from, suffix) : move.from;
768
+ continue;
769
+ }
770
+ if (isUnderDir(resolved, move.from) &&
771
+ (move.directory || pathsEqual(resolved, move.from))) {
772
+ return undefined;
773
+ }
774
+ }
775
+ return resolved;
776
+ };
777
+ const isTombstoned = (filePath) => virtualTombstones.some((tombstone) => pathsEqual(filePath, tombstone.path) ||
778
+ (tombstone.directory && isUnderDir(filePath, tombstone.path)));
779
+ const clearTombstonesUnder = (filePath) => {
780
+ for (let index = virtualTombstones.length - 1; index >= 0; index--) {
781
+ const tombstone = virtualTombstones[index];
782
+ if (pathsEqual(tombstone.path, filePath) || isUnderDir(tombstone.path, filePath)) {
783
+ virtualTombstones.splice(index, 1);
784
+ }
785
+ }
786
+ };
787
+ const stateFor = async (filePath) => {
788
+ if (isTombstoned(filePath))
789
+ return { exists: false, directory: false };
790
+ const physicalPath = resolveVirtualPath(filePath);
791
+ if (!physicalPath) {
792
+ return virtualOverrides.get(normalizeMapKey(filePath)) ?? { exists: false, directory: false };
793
+ }
794
+ const key = normalizeMapKey(physicalPath);
795
+ const known = virtual.get(key);
796
+ if (known)
797
+ return known;
798
+ const stat = await lstatOrMissing(physicalPath);
799
+ const value = { exists: Boolean(stat), directory: stat?.isDirectory() ?? false };
800
+ virtual.set(key, value);
801
+ return value;
802
+ };
803
+ const contentFor = async (filePath, state) => {
804
+ if (!state.exists || state.directory)
805
+ throw new Error(`text edit target is not a file: ${filePath}`);
806
+ if (state.content !== undefined)
807
+ return state.content;
808
+ const physicalPath = resolveVirtualPath(filePath);
809
+ if (!physicalPath)
810
+ throw new Error(`text edit target does not exist: ${filePath}`);
811
+ const content = await fs.readFile(physicalPath, "utf-8");
812
+ state.content = content;
813
+ return content;
814
+ };
815
+ for (const op of planned) {
816
+ if (op.kind === "text") {
817
+ const filePath = await confine(op.uri);
818
+ const state = await stateFor(filePath);
819
+ if (op.version !== undefined && op.version !== null) {
820
+ const current = options.documentVersions?.get(normalizeMapKey(filePath));
821
+ if (current === undefined || current !== op.version)
822
+ throw new Error(`stale text document version for ${filePath}: expected ${op.version}, current ${current ?? "unknown"}`);
823
+ }
824
+ const content = await contentFor(filePath, state);
825
+ const encoding = options.positionEncoding ?? "utf-16";
826
+ const normalized = normalizeTextEditsForContent(content, op.edits, encoding);
827
+ text.set(op, normalized);
828
+ // Keep the normalized edits attached to their original LSP containers.
829
+ // A planner operation can combine `changes` with a documentChanges text
830
+ // edit for the same URI, so the combined validation above remains the
831
+ // authority while this assignment preserves version/resource ordering.
832
+ for (const origin of op.origins ?? []) {
833
+ // Origins are already validated together above, but must retain
834
+ // multiplicity: identical zero-width insertions are meaningful and
835
+ // must not be deduplicated while restoring the original containers.
836
+ textByOrigin.set(textEditOriginKey(origin), normalizeTextEditsForContent(content, origin.edits, encoding));
837
+ }
838
+ state.content = applyTextEditsToString(content, normalized);
839
+ continue;
840
+ }
841
+ if (op.kind === "create") {
842
+ const filePath = await confine(op.uri);
843
+ const state = await stateFor(filePath);
844
+ await assertParentIsUsable(filePath, stateFor);
845
+ if (state.exists) {
846
+ if (op.options?.ignoreIfExists) {
847
+ ignored.add(op);
848
+ continue;
849
+ }
850
+ if (!op.options?.overwrite || state.directory)
851
+ throw new Error(`create target already exists: ${filePath}`);
852
+ state.content = "";
853
+ }
854
+ else {
855
+ clearTombstonesUnder(filePath);
856
+ state.exists = true;
857
+ state.directory = false;
858
+ state.content = "";
859
+ const physicalPath = resolveVirtualPath(filePath);
860
+ if (physicalPath)
861
+ virtual.set(normalizeMapKey(physicalPath), state);
862
+ // `resolveVirtualPath` returns undefined for a path currently shadowed
863
+ // by an earlier rename's vacated "from" address in this same ordered
864
+ // edit (P3-3): the created state has nowhere physical to live, so it
865
+ // must be recorded in the override overlay keyed on the raw query path
866
+ // instead — otherwise a later op addressing this exact path (e.g. a
867
+ // text edit) would see it as still-vacated/nonexistent.
868
+ else
869
+ virtualOverrides.set(normalizeMapKey(filePath), state);
870
+ }
871
+ continue;
872
+ }
873
+ if (op.kind === "rename") {
874
+ const oldPath = await confine(op.oldUri);
875
+ const newPath = await confine(op.newUri);
876
+ // "Must differ" is a property of the on-disk target, not the case-folded
877
+ // map key. A case-only rename (foo.txt → Foo.txt) is a legitimate refactor
878
+ // whose decoded paths differ even though they collapse to a single key on a
879
+ // case-insensitive filesystem — so compare the decoded disk paths here, and
880
+ // treat a same-key-different-case pair as a real rename below.
881
+ const oldDisk = uriToDiskPath(op.oldUri);
882
+ const newDisk = uriToDiskPath(op.newUri);
883
+ if (path.resolve(oldDisk).replace(/\\/g, "/") === path.resolve(newDisk).replace(/\\/g, "/")) {
884
+ throw new Error("rename source and destination must differ");
885
+ }
886
+ const source = await stateFor(oldPath);
887
+ if (!source.exists)
888
+ throw new Error(`rename source does not exist: ${oldPath}`);
889
+ // Captured BEFORE this rename's own `virtualMoves`/override mutations
890
+ // below, so it reflects how `source` was actually resolved: `true` when
891
+ // `oldPath` is itself shadowed by an earlier rename in this edit and the
892
+ // state came from the `virtualOverrides` overlay (P3-3) rather than a
893
+ // physically-addressable entry.
894
+ const sourceFromOverride = resolveVirtualPath(oldPath) === undefined;
895
+ await assertParentIsUsable(newPath, stateFor);
896
+ const destination = await stateFor(newPath);
897
+ if (destination.exists) {
898
+ // Decide "already exists" by on-disk IDENTITY, not a platform-keyed path
899
+ // fold. A case-only (or otherwise-aliased) rename whose destination
900
+ // resolves to the SAME FS entry as the source is a legitimate refactor,
901
+ // not a conflict — detected here on ANY case-insensitive FS (win32, macOS
902
+ // APFS/HFS+), per the #1024 "probe the FS, don't branch on platform"
903
+ // lesson. Conversely, on a case-SENSITIVE FS where both spellings are
904
+ // distinct real files, this stays a genuine conflict (closing the inverse
905
+ // silent-clobber edge that a win32-only fold left open).
906
+ //
907
+ // P3-8: when the source exists only VIRTUALLY (e.g. created earlier in
908
+ // this same ordered edit and never yet written to disk), `isSameFsEntry`
909
+ // lstats disk and finds nothing there, so it can never recognize a
910
+ // case-only alias for a not-yet-physical file. `stateFor` keys the
911
+ // `virtual`/override maps on the SAME case-folded identity used for
912
+ // physical aliasing, so if `destination` and `source` resolved to the
913
+ // identical cached VirtualFile object, they are — by construction of
914
+ // that keying — the same virtual entry (a case-insensitive-FS alias);
915
+ // checked as a fast, disk-free first branch before falling back to the
916
+ // physical probe (which remains untouched for genuinely-physical paths).
917
+ const aliasesSource = destination === source || (await isSameFsEntry(oldDisk, newDisk));
918
+ if (!aliasesSource) {
919
+ if (op.options?.ignoreIfExists) {
920
+ ignored.add(op);
921
+ continue;
922
+ }
923
+ if (!op.options?.overwrite)
924
+ throw new Error(`rename destination already exists: ${newPath}`);
925
+ }
926
+ }
927
+ clearTombstonesUnder(newPath);
928
+ if (!source.directory)
929
+ await contentFor(oldPath, source);
930
+ if (sourceFromOverride) {
931
+ // P3 (round-2 review): a purely virtual entry (created earlier in this
932
+ // same edit at a path vacated by an even earlier rename) has no
933
+ // physical address for a `virtualMoves` shadow to resolve against —
934
+ // `resolveVirtualPath` walks that list by PHYSICAL path chasing, which
935
+ // cannot represent "this virtual entry moved again." Pushing a move
936
+ // here would leave the stale `virtualOverrides[oldPath]` entry
937
+ // claiming `exists: true` forever (a later `create(oldPath)` would be
938
+ // falsely rejected, and reads of the new address wouldn't reliably
939
+ // find it either). Instead, migrate the state object directly: drop
940
+ // the stale key and re-key it under the destination, placing it back
941
+ // in `virtual` (physically addressable) or `virtualOverrides` (still
942
+ // shadowed) depending on whether the destination itself currently
943
+ // resolves to a physical path.
944
+ virtualOverrides.delete(normalizeMapKey(oldPath));
945
+ const destinationPhysicalPath = resolveVirtualPath(newPath);
946
+ if (destinationPhysicalPath)
947
+ virtual.set(normalizeMapKey(destinationPhysicalPath), source);
948
+ else
949
+ virtualOverrides.set(normalizeMapKey(newPath), source);
950
+ }
951
+ else {
952
+ // Keep descendants lazy: a later text edit under a renamed directory
953
+ // resolves through this virtual move to the original physical path.
954
+ // This preserves ordered workspace-edit semantics without walking the
955
+ // entire subtree during preflight.
956
+ virtualMoves.push({ from: oldPath, to: newPath, directory: source.directory });
957
+ }
958
+ continue;
959
+ }
960
+ const filePath = await confine(op.uri);
961
+ const state = await stateFor(filePath);
962
+ if (!state.exists) {
963
+ if (op.options?.ignoreIfNotExists)
964
+ ignored.add(op);
965
+ else
966
+ throw new Error(`delete target does not exist: ${filePath}`);
967
+ }
968
+ else {
969
+ if (state.directory && !op.options?.recursive)
970
+ throw new Error(`delete directory requires recursive: ${filePath}`);
971
+ state.exists = false;
972
+ state.content = undefined;
973
+ virtualTombstones.push({ path: filePath, directory: state.directory });
974
+ }
975
+ }
976
+ return { text, textByOrigin, ignored };
977
+ }
978
+ /**
979
+ * Applies a workspace edit. All URI, shape, version, resource-precondition and
980
+ * text-bound checks happen in preflight before the first write. Once preflight
981
+ * succeeds, an unexpected filesystem failure may still leave earlier mutations
982
+ * applied; this is intentionally the existing no-rollback boundary.
983
+ */
984
+ export async function applyWorkspaceEdit(edit, cwd, options = {}) {
168
985
  const descriptions = [];
169
986
  const touchedFiles = new Set();
170
- const textEditsByUri = flattenWorkspaceTextEdits(edit);
987
+ const fileDetails = [];
988
+ const appliedOperationIndexes = [];
989
+ let appliedOperationTotal = 0;
990
+ let operationIndex = 0;
991
+ const operationCounts = { textEdits: 0, create: 0, rename: 0, delete: 0 };
992
+ const planned = planWorkspaceEdit(edit);
993
+ let operationTotal = 0;
994
+ let prepared;
995
+ const operationSize = (op) => op.kind === "text" ? (prepared.text.get(op)?.length ?? 0) : 1;
996
+ const markApplied = (op) => {
997
+ const count = operationSize(op);
998
+ for (let index = 0; index < count; index++) {
999
+ if (appliedOperationIndexes.length < 100) {
1000
+ appliedOperationIndexes.push(operationIndex + index);
1001
+ }
1002
+ }
1003
+ appliedOperationTotal += count;
1004
+ operationIndex += count;
1005
+ };
1006
+ const skipOperation = (op) => {
1007
+ operationIndex += operationSize(op);
1008
+ };
1009
+ const makeResult = () => ({
1010
+ descriptions,
1011
+ files: [...touchedFiles],
1012
+ operationTotal,
1013
+ appliedOperationTotal,
1014
+ appliedOperationIndexes,
1015
+ operationCounts,
1016
+ fileDetails,
1017
+ });
171
1018
  try {
172
- for (const [uri, edits] of textEditsByUri) {
173
- const filePath = uriToPath(uri);
174
- const content = await fs.readFile(filePath, "utf-8");
175
- const updated = applyTextEditsToString(content, edits);
176
- await fs.writeFile(filePath, updated, "utf-8");
177
- touchedFiles.add(filePath);
178
- descriptions.push(`Applied ${edits.length} edit(s) to ${relativeToCwd(filePath, cwd)}`);
179
- }
180
- for (const change of edit.documentChanges ?? []) {
181
- if (typeof change !== "object" || change === null || !("kind" in change))
1019
+ prepared = await preflightWorkspaceEdit(planned, cwd, options);
1020
+ for (const op of planned) {
1021
+ const size = operationSize(op);
1022
+ if (op.kind === "text")
1023
+ operationCounts.textEdits += size;
1024
+ else
1025
+ operationCounts[op.kind]++;
1026
+ operationTotal += size;
1027
+ }
1028
+ for (const op of planned) {
1029
+ if (prepared.ignored.has(op)) {
1030
+ skipOperation(op);
182
1031
  continue;
183
- const kind = change.kind;
184
- if (kind === "create" &&
185
- typeof change.uri === "string") {
186
- const filePath = uriToPath(change.uri);
187
- await fs.mkdir(path.dirname(filePath), { recursive: true });
188
- await fs
189
- .writeFile(filePath, "", { flag: "wx" })
190
- .catch(async (err) => {
191
- if (err.code !== "EEXIST")
192
- throw err;
193
- });
1032
+ }
1033
+ if (op.kind === "text") {
1034
+ // Report/key on the normalized path (forward-slash, realpath-canonical),
1035
+ // but read/write the decoded on-disk path so the URI's casing is honored
1036
+ // on win32 (see uriToDiskPath).
1037
+ const filePath = uriToPath(op.uri);
1038
+ const diskPath = uriToDiskPath(op.uri);
1039
+ const edits = prepared.text.get(op) ?? [];
1040
+ if (edits.length === 0) {
1041
+ skipOperation(op);
1042
+ continue;
1043
+ }
1044
+ const content = await fs.readFile(diskPath, "utf-8");
1045
+ const updated = applyTextEditsToString(content, edits, "utf-16");
1046
+ await fs.writeFile(diskPath, updated, "utf-8");
1047
+ const start = Math.min(...edits.map((item) => item.range.start.line + 1));
1048
+ const end = Math.max(...edits.map((item) => item.range.end.line + 1));
1049
+ touchedFiles.add(filePath);
1050
+ fileDetails.push({ filePath, range: { start, end }, importsChanged: importsSignature(content) !== importsSignature(updated) });
1051
+ markApplied(op);
1052
+ descriptions.push(`Applied ${edits.length} edit(s) to ${relativeToCwd(filePath, cwd)}`);
1053
+ }
1054
+ else if (op.kind === "create") {
1055
+ // Create on the decoded path so `NewFile.txt` is not lowercased on win32;
1056
+ // report/key on the normalized path.
1057
+ const filePath = uriToPath(op.uri);
1058
+ const diskPath = uriToDiskPath(op.uri);
1059
+ await fs.mkdir(path.dirname(diskPath), { recursive: true });
1060
+ if (op.options?.overwrite)
1061
+ await fs.writeFile(diskPath, "", "utf-8");
1062
+ else
1063
+ await fs.writeFile(diskPath, "", { flag: "wx" });
194
1064
  touchedFiles.add(filePath);
1065
+ fileDetails.push({ filePath, range: { start: 1, end: 1 }, importsChanged: false });
1066
+ markApplied(op);
195
1067
  descriptions.push(`Created ${relativeToCwd(filePath, cwd)}`);
196
1068
  }
197
- else if (kind === "rename" &&
198
- typeof change.oldUri === "string" &&
199
- typeof change.newUri === "string") {
200
- const oldPath = uriToPath(change.oldUri);
201
- const newPath = uriToPath(change.newUri);
202
- await fs.mkdir(path.dirname(newPath), { recursive: true });
203
- await fs.rename(oldPath, newPath);
1069
+ else if (op.kind === "rename") {
1070
+ // Rename on the decoded paths so the destination casing is honored (and a
1071
+ // case-only rename actually changes the name); report on normalized paths.
1072
+ const oldPath = uriToPath(op.oldUri);
1073
+ const newPath = uriToPath(op.newUri);
1074
+ const oldDisk = uriToDiskPath(op.oldUri);
1075
+ const newDisk = uriToDiskPath(op.newUri);
1076
+ await fs.mkdir(path.dirname(newDisk), { recursive: true });
1077
+ if (op.options?.overwrite)
1078
+ await fs.rm(newDisk, { recursive: true, force: true });
1079
+ await fs.rename(oldDisk, newDisk);
204
1080
  touchedFiles.add(oldPath);
205
1081
  touchedFiles.add(newPath);
1082
+ fileDetails.push({ filePath: oldPath, range: { start: 1, end: 1 }, importsChanged: true }, { filePath: newPath, range: { start: 1, end: 1 }, importsChanged: true });
1083
+ markApplied(op);
206
1084
  descriptions.push(`Renamed ${relativeToCwd(oldPath, cwd)} → ${relativeToCwd(newPath, cwd)}`);
207
1085
  }
208
- else if (kind === "delete" &&
209
- typeof change.uri === "string") {
210
- const filePath = uriToPath(change.uri);
211
- await fs.rm(filePath, { recursive: true, force: true });
1086
+ else {
1087
+ const filePath = uriToPath(op.uri);
1088
+ const diskPath = uriToDiskPath(op.uri);
1089
+ await fs.rm(diskPath, { recursive: op.options?.recursive === true, force: false });
212
1090
  touchedFiles.add(filePath);
1091
+ fileDetails.push({ filePath, range: { start: 1, end: 1 }, importsChanged: true });
1092
+ markApplied(op);
213
1093
  descriptions.push(`Deleted ${relativeToCwd(filePath, cwd)}`);
214
1094
  }
215
1095
  }
216
1096
  }
217
1097
  catch (err) {
1098
+ const partial = makeResult();
1099
+ if (options.mutationContext && options.observe !== false) {
1100
+ recordLspMutation(options.mutationContext, { results: [partial], status: "failed" });
1101
+ }
218
1102
  const already = [...touchedFiles];
219
1103
  if (already.length > 0) {
220
- const alreadyList = already
221
- .map((f) => ` • ${relativeToCwd(f, cwd)}`)
222
- .join("\n");
223
- throw new Error(`Workspace edit failed mid-application — ${already.length} file(s) already written, no rollback performed:\n${alreadyList}\nCause: ${err instanceof Error ? err.message : String(err)}`);
1104
+ const alreadyList = already.map((f) => ` • ${relativeToCwd(f, cwd)}`).join("\n");
1105
+ const failure = new Error(`Workspace edit failed mid-application — ${already.length} file(s) already written, no rollback performed:\n${alreadyList}\nCause: ${err instanceof Error ? err.message : String(err)}`);
1106
+ failure.appliedWorkspaceEdit = partial;
1107
+ throw failure;
224
1108
  }
225
1109
  throw err;
226
1110
  }
227
- return { descriptions, files: [...touchedFiles] };
1111
+ const applied = makeResult();
1112
+ if (options.mutationContext && options.observe !== false) {
1113
+ recordLspMutation(options.mutationContext, {
1114
+ results: [applied],
1115
+ status: appliedOperationTotal > 0 ? "success" : "skipped",
1116
+ });
1117
+ }
1118
+ return applied;
228
1119
  }