oh-my-opencode 4.18.0 → 4.18.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (226) hide show
  1. package/.agents/skills/codex-qa/scripts/lsp-e2e.sh +3677 -0
  2. package/.agents/skills/opencode-qa/scripts/lsp-e2e.sh +3154 -0
  3. package/.agents/skills/work-with-pr/SKILL.md +16 -37
  4. package/.agents/skills/work-with-pr-workspace/evals/evals.json +3 -3
  5. package/.opencode/skills/work-with-pr/SKILL.md +16 -37
  6. package/.opencode/skills/work-with-pr-workspace/evals/evals.json +3 -3
  7. package/bin/AGENTS.md +33 -0
  8. package/dist/cli/index.js +500 -158
  9. package/dist/cli-node/index.js +500 -158
  10. package/dist/hooks/anthropic-context-window-limit-recovery/empty-content-recovery-sdk.d.ts +6 -0
  11. package/dist/hooks/category-skill-reminder/hook.d.ts +9 -1
  12. package/dist/hooks/comment-checker/hook.d.ts +8 -1
  13. package/dist/hooks/todo-continuation-enforcer/types.d.ts +3 -0
  14. package/dist/index.js +956 -702
  15. package/dist/plugin/messages-transform.d.ts +1 -0
  16. package/dist/plugin-handlers/prometheus-agent-config-builder.d.ts +2 -0
  17. package/dist/tui.js +43 -4
  18. package/package.json +16 -16
  19. package/packages/git-bash-mcp/dist/cli.js +81 -19
  20. package/packages/lsp-core/package.json +4 -0
  21. package/packages/lsp-core/src/index.ts +1 -0
  22. package/packages/lsp-core/src/lsp/cleanup-errors.test.ts +18 -0
  23. package/packages/lsp-core/src/lsp/cleanup-errors.ts +12 -3
  24. package/packages/lsp-core/src/lsp/client-diagnostics-freshness.integration.test.ts +261 -0
  25. package/packages/lsp-core/src/lsp/client-wrapper.test.ts +63 -0
  26. package/packages/lsp-core/src/lsp/client-wrapper.ts +35 -5
  27. package/packages/lsp-core/src/lsp/client.ts +262 -80
  28. package/packages/lsp-core/src/lsp/config-loader.ts +5 -17
  29. package/packages/lsp-core/src/lsp/connection.ts +12 -6
  30. package/packages/lsp-core/src/lsp/directory-diagnostics.test.ts +221 -0
  31. package/packages/lsp-core/src/lsp/directory-diagnostics.ts +61 -28
  32. package/packages/lsp-core/src/lsp/errors.ts +11 -0
  33. package/packages/lsp-core/src/lsp/fixtures/diagnostics-freshness-contract-probe.ts +283 -0
  34. package/packages/lsp-core/src/lsp/fixtures/workspace-edit-contract-probe.ts +196 -0
  35. package/packages/lsp-core/src/lsp/fixtures/workspace-edit-server.mjs +215 -0
  36. package/packages/lsp-core/src/lsp/formatters.ts +3 -0
  37. package/packages/lsp-core/src/lsp/json-rpc-connection-cancellation.test.ts +97 -0
  38. package/packages/lsp-core/src/lsp/json-rpc-connection.ts +73 -5
  39. package/packages/lsp-core/src/lsp/server-install-state.ts +3 -6
  40. package/packages/lsp-core/src/lsp/transport-protocol.ts +52 -0
  41. package/packages/lsp-core/src/lsp/transport.ts +96 -70
  42. package/packages/lsp-core/src/lsp/workspace-apply-edit-failure.ts +19 -0
  43. package/packages/lsp-core/src/lsp/workspace-apply-edit-lease.integration.test.ts +214 -0
  44. package/packages/lsp-core/src/lsp/workspace-apply-edit-sync.integration.test.ts +113 -0
  45. package/packages/lsp-core/src/lsp/workspace-apply-edit-test-support.ts +163 -0
  46. package/packages/lsp-core/src/lsp/workspace-apply-edit.integration.test.ts +163 -0
  47. package/packages/lsp-core/src/lsp/workspace-document-state.test.ts +67 -0
  48. package/packages/lsp-core/src/lsp/workspace-document-state.ts +368 -0
  49. package/packages/lsp-core/src/lsp/workspace-edit-adversarial.test.ts +113 -0
  50. package/packages/lsp-core/src/lsp/workspace-edit-commit.test.ts +140 -0
  51. package/packages/lsp-core/src/lsp/workspace-edit-commit.ts +220 -0
  52. package/packages/lsp-core/src/lsp/workspace-edit-contract-evidence.test.ts +56 -0
  53. package/packages/lsp-core/src/lsp/workspace-edit-contract-evidence.ts +30 -0
  54. package/packages/lsp-core/src/lsp/workspace-edit-fingerprint.ts +44 -0
  55. package/packages/lsp-core/src/lsp/workspace-edit-options.test.ts +147 -0
  56. package/packages/lsp-core/src/lsp/workspace-edit-parse-helpers.ts +59 -0
  57. package/packages/lsp-core/src/lsp/workspace-edit-parser.ts +130 -0
  58. package/packages/lsp-core/src/lsp/workspace-edit-path.ts +98 -0
  59. package/packages/lsp-core/src/lsp/workspace-edit-plan-types.ts +60 -0
  60. package/packages/lsp-core/src/lsp/workspace-edit-plan.ts +73 -0
  61. package/packages/lsp-core/src/lsp/workspace-edit-prevalidation.test.ts +174 -0
  62. package/packages/lsp-core/src/lsp/workspace-edit-resource-parser.ts +89 -0
  63. package/packages/lsp-core/src/lsp/workspace-edit-simulation.ts +183 -0
  64. package/packages/lsp-core/src/lsp/workspace-edit-snapshot.ts +53 -0
  65. package/packages/lsp-core/src/lsp/workspace-edit-text.ts +125 -0
  66. package/packages/lsp-core/src/lsp/workspace-edit-types.ts +121 -0
  67. package/packages/lsp-core/src/lsp/workspace-edit.characterization.test.ts +95 -0
  68. package/packages/lsp-core/src/lsp/workspace-edit.ts +49 -200
  69. package/packages/lsp-core/src/lsp/workspace-mutation-controller.ts +182 -0
  70. package/packages/lsp-core/src/mcp.ts +18 -7
  71. package/packages/lsp-core/src/missing-dependency-result.test.ts +105 -0
  72. package/packages/lsp-core/src/missing-dependency-result.ts +57 -0
  73. package/packages/lsp-core/src/post-edit/index.ts +1 -0
  74. package/packages/lsp-core/src/post-edit/orchestration.test.ts +157 -0
  75. package/packages/lsp-core/src/post-edit/orchestration.ts +178 -0
  76. package/packages/lsp-core/src/request-context.test.ts +171 -0
  77. package/packages/lsp-core/src/request-context.ts +222 -9
  78. package/packages/lsp-core/src/tool-surface.test.ts +4 -1
  79. package/packages/lsp-core/src/tools/diagnostics.ts +32 -13
  80. package/packages/lsp-core/src/tools/navigation.ts +12 -12
  81. package/packages/lsp-core/src/tools/rename.ts +10 -15
  82. package/packages/lsp-core/src/tools/symbols.ts +11 -11
  83. package/packages/lsp-core/src/tools/types.ts +2 -1
  84. package/packages/lsp-daemon/dist/cli.js +3330 -764
  85. package/packages/lsp-daemon/dist/client.d.ts +105 -0
  86. package/packages/lsp-daemon/dist/client.js +5995 -0
  87. package/packages/lsp-daemon/dist/daemon-client.d.ts +12 -7
  88. package/packages/lsp-daemon/dist/daemon-client.js +139 -32
  89. package/packages/lsp-daemon/dist/daemon-server.d.ts +1 -0
  90. package/packages/lsp-daemon/dist/daemon-server.js +40 -15
  91. package/packages/lsp-daemon/dist/ensure-daemon.d.ts +10 -8
  92. package/packages/lsp-daemon/dist/ensure-daemon.js +135 -51
  93. package/packages/lsp-daemon/dist/index.d.ts +2 -2
  94. package/packages/lsp-daemon/dist/index.js +3093 -786
  95. package/packages/lsp-daemon/dist/ipc-protocol.d.ts +46 -0
  96. package/packages/lsp-daemon/dist/ipc-protocol.js +187 -0
  97. package/packages/lsp-daemon/dist/lock.js +14 -4
  98. package/packages/lsp-daemon/dist/ownership.d.ts +49 -0
  99. package/packages/lsp-daemon/dist/ownership.js +168 -0
  100. package/packages/lsp-daemon/dist/paths.d.ts +33 -9
  101. package/packages/lsp-daemon/dist/paths.js +72 -33
  102. package/packages/lsp-daemon/dist/proxy.d.ts +5 -0
  103. package/packages/lsp-daemon/dist/proxy.js +123 -16
  104. package/packages/lsp-daemon/dist/request-routing.d.ts +7 -2
  105. package/packages/lsp-daemon/dist/request-routing.js +71 -22
  106. package/packages/lsp-daemon/dist/run-daemon.js +9 -2
  107. package/packages/lsp-daemon/dist/runtime-contract.d.ts +21 -0
  108. package/packages/lsp-daemon/dist/runtime-contract.js +58 -0
  109. package/packages/lsp-daemon/dist/socket-jsonrpc.js +6 -1
  110. package/packages/lsp-daemon/package.json +12 -3
  111. package/packages/lsp-tools-mcp/dist/cli.js +2189 -454
  112. package/packages/lsp-tools-mcp/dist/lsp/manager.js +1741 -148
  113. package/packages/lsp-tools-mcp/dist/mcp.js +2206 -471
  114. package/packages/lsp-tools-mcp/dist/request-context.js +176 -6
  115. package/packages/lsp-tools-mcp/dist/tools.js +2119 -447
  116. package/packages/omo-codex/plugin/.codex-plugin/plugin.json +1 -1
  117. package/packages/omo-codex/plugin/.mcp.json +2 -1
  118. package/packages/omo-codex/plugin/components/bootstrap/hooks/hooks.json +1 -1
  119. package/packages/omo-codex/plugin/components/bootstrap/package.json +1 -1
  120. package/packages/omo-codex/plugin/components/codegraph/dist/cli.js +100 -28
  121. package/packages/omo-codex/plugin/components/codegraph/dist/serve.js +100 -28
  122. package/packages/omo-codex/plugin/components/codegraph/package.json +1 -1
  123. package/packages/omo-codex/plugin/components/codegraph/src/mcp-bridge.ts +21 -9
  124. package/packages/omo-codex/plugin/components/codegraph/test/mcp-bridge-fixtures.ts +35 -0
  125. package/packages/omo-codex/plugin/components/codegraph/test/serve-mcp-bridge-lifecycle.test.ts +69 -0
  126. package/packages/omo-codex/plugin/components/codegraph/test/serve-mcp-bridge.test.ts +57 -1
  127. package/packages/omo-codex/plugin/components/comment-checker/hooks/hooks.json +1 -1
  128. package/packages/omo-codex/plugin/components/comment-checker/package.json +1 -1
  129. package/packages/omo-codex/plugin/components/git-bash/hooks/hooks.json +2 -2
  130. package/packages/omo-codex/plugin/components/git-bash/package.json +1 -1
  131. package/packages/omo-codex/plugin/components/lazycodex-executor-verify/hooks/hooks.json +1 -1
  132. package/packages/omo-codex/plugin/components/lazycodex-executor-verify/package.json +1 -1
  133. package/packages/omo-codex/plugin/components/lsp/.mcp.json +2 -1
  134. package/packages/omo-codex/plugin/components/lsp/dist/.omo-runtime-manifest.json +55 -0
  135. package/packages/omo-codex/plugin/components/lsp/dist/cli.js +3033 -936
  136. package/packages/omo-codex/plugin/components/lsp/dist/codex-hook-cli.js +0 -4
  137. package/packages/omo-codex/plugin/components/lsp/dist/codex-hook.d.ts +5 -2
  138. package/packages/omo-codex/plugin/components/lsp/dist/codex-hook.js +41 -62
  139. package/packages/omo-codex/plugin/components/lsp/dist/daemon-cli-path.d.ts +1 -1
  140. package/packages/omo-codex/plugin/components/lsp/dist/daemon-cli-path.js +24 -15
  141. package/packages/omo-codex/plugin/components/lsp/dist/lsp-session-state.d.ts +3 -7
  142. package/packages/omo-codex/plugin/components/lsp/dist/lsp-session-state.js +23 -49
  143. package/packages/omo-codex/plugin/components/lsp/hooks/hooks.json +2 -2
  144. package/packages/omo-codex/plugin/components/lsp/package.json +3 -2
  145. package/packages/omo-codex/plugin/components/lsp/scripts/build-lsp-daemon.mjs +31 -1
  146. package/packages/omo-codex/plugin/components/lsp/scripts/build-lsp-daemon.test.mjs +76 -0
  147. package/packages/omo-codex/plugin/components/lsp/scripts/build-runtime.mjs +201 -0
  148. package/packages/omo-codex/plugin/components/lsp/scripts/build-runtime.test.mjs +55 -0
  149. package/packages/omo-codex/plugin/components/lsp/src/codex-hook-cli.ts +0 -4
  150. package/packages/omo-codex/plugin/components/lsp/src/codex-hook.ts +49 -71
  151. package/packages/omo-codex/plugin/components/lsp/src/daemon-cli-path.ts +26 -15
  152. package/packages/omo-codex/plugin/components/lsp/src/lsp-session-state.ts +26 -64
  153. package/packages/omo-codex/plugin/components/lsp/test/codex-hook-unavailable.test.ts +16 -17
  154. package/packages/omo-codex/plugin/components/lsp/test/codex-hook.test.ts +30 -4
  155. package/packages/omo-codex/plugin/components/lsp/test/package-smoke.test.ts +20 -5
  156. package/packages/omo-codex/plugin/components/rules/bundled-rules/hephaestus/gpt-5.5.md +1 -1
  157. package/packages/omo-codex/plugin/components/rules/bundled-rules/hephaestus/gpt-5.6.md +5 -3
  158. package/packages/omo-codex/plugin/components/rules/hooks/hooks.json +4 -4
  159. package/packages/omo-codex/plugin/components/rules/package.json +1 -1
  160. package/packages/omo-codex/plugin/components/start-work-continuation/README.md +2 -2
  161. package/packages/omo-codex/plugin/components/start-work-continuation/directive.md +3 -3
  162. package/packages/omo-codex/plugin/components/start-work-continuation/dist/cli.js +2 -2
  163. package/packages/omo-codex/plugin/components/start-work-continuation/hooks/hooks.json +2 -2
  164. package/packages/omo-codex/plugin/components/start-work-continuation/package.json +1 -1
  165. package/packages/omo-codex/plugin/components/start-work-continuation/src/boulder-reader.ts +1 -1
  166. package/packages/omo-codex/plugin/components/start-work-continuation/src/codex-hook.ts +1 -1
  167. package/packages/omo-codex/plugin/components/start-work-continuation/test/boulder-reader.test.ts +15 -1
  168. package/packages/omo-codex/plugin/components/start-work-continuation/test/codex-hook.test.ts +20 -0
  169. package/packages/omo-codex/plugin/components/teammode/hooks/hooks.json +1 -1
  170. package/packages/omo-codex/plugin/components/teammode/package.json +1 -1
  171. package/packages/omo-codex/plugin/components/telemetry/hooks/hooks.json +1 -1
  172. package/packages/omo-codex/plugin/components/telemetry/package.json +1 -1
  173. package/packages/omo-codex/plugin/components/ultrawork/directive.md +50 -33
  174. package/packages/omo-codex/plugin/components/ultrawork/hooks/hooks.json +1 -1
  175. package/packages/omo-codex/plugin/components/ultrawork/package.json +1 -1
  176. package/packages/omo-codex/plugin/components/ultrawork/skills/ultrawork/SKILL.md +50 -33
  177. package/packages/omo-codex/plugin/components/ulw-loop/directive.md +50 -33
  178. package/packages/omo-codex/plugin/components/ulw-loop/dist/cli.js +3 -3
  179. package/packages/omo-codex/plugin/components/ulw-loop/dist/stop-resume-hook.js +5 -6
  180. package/packages/omo-codex/plugin/components/ulw-loop/hooks/hooks.json +4 -4
  181. package/packages/omo-codex/plugin/components/ulw-loop/package.json +1 -1
  182. package/packages/omo-codex/plugin/components/ulw-loop/skills/ulw-loop/SKILL.md +1 -1
  183. package/packages/omo-codex/plugin/components/ulw-loop/skills/ulw-loop/references/full-workflow.md +6 -5
  184. package/packages/omo-codex/plugin/components/ulw-loop/src/stop-resume-hook.ts +5 -6
  185. package/packages/omo-codex/plugin/components/ulw-loop/test/stop-resume-hook.test.ts +2 -2
  186. package/packages/omo-codex/plugin/hooks/post-compact-resetting-git-bash-mcp-reminder.json +1 -1
  187. package/packages/omo-codex/plugin/hooks/post-compact-resetting-lsp-diagnostics-cache.json +1 -1
  188. package/packages/omo-codex/plugin/hooks/post-compact-resetting-project-rule-cache.json +1 -1
  189. package/packages/omo-codex/plugin/hooks/post-tool-use-checking-codegraph-init-guidance.json +1 -1
  190. package/packages/omo-codex/plugin/hooks/post-tool-use-checking-comments.json +1 -1
  191. package/packages/omo-codex/plugin/hooks/post-tool-use-checking-lsp-diagnostics.json +1 -1
  192. package/packages/omo-codex/plugin/hooks/post-tool-use-checking-thread-title-hygiene.json +1 -1
  193. package/packages/omo-codex/plugin/hooks/post-tool-use-matching-project-rules.json +1 -1
  194. package/packages/omo-codex/plugin/hooks/pre-tool-use-enforcing-unlimited-goal-budget.json +1 -1
  195. package/packages/omo-codex/plugin/hooks/pre-tool-use-guarding-ulw-loop-spawns.json +1 -1
  196. package/packages/omo-codex/plugin/hooks/pre-tool-use-recommending-git-bash-mcp.json +1 -1
  197. package/packages/omo-codex/plugin/hooks/session-start-checking-auto-update.json +1 -1
  198. package/packages/omo-codex/plugin/hooks/session-start-checking-bootstrap-provisioning.json +1 -1
  199. package/packages/omo-codex/plugin/hooks/session-start-checking-codegraph-bootstrap.json +1 -1
  200. package/packages/omo-codex/plugin/hooks/session-start-loading-project-rules.json +1 -1
  201. package/packages/omo-codex/plugin/hooks/session-start-recording-session-telemetry.json +1 -1
  202. package/packages/omo-codex/plugin/hooks/stop-checking-start-work-continuation.json +1 -1
  203. package/packages/omo-codex/plugin/hooks/stop-checking-ulw-loop-resume.json +1 -1
  204. package/packages/omo-codex/plugin/hooks/subagent-stop-checking-start-work-continuation.json +1 -1
  205. package/packages/omo-codex/plugin/hooks/subagent-stop-verifying-lazycodex-executor-evidence.json +1 -1
  206. package/packages/omo-codex/plugin/hooks/user-prompt-submit-checking-ultrawork-trigger.json +1 -1
  207. package/packages/omo-codex/plugin/hooks/user-prompt-submit-checking-ulw-loop-steering.json +1 -1
  208. package/packages/omo-codex/plugin/hooks/user-prompt-submit-loading-project-rules.json +1 -1
  209. package/packages/omo-codex/plugin/package-lock.json +26 -14
  210. package/packages/omo-codex/plugin/package.json +1 -1
  211. package/packages/omo-codex/plugin/scripts/build-bundled-mcp-runtimes.mjs +2 -3
  212. package/packages/omo-codex/plugin/scripts/build-components.mjs +13 -1
  213. package/packages/omo-codex/plugin/scripts/sync-skills.mjs +9 -1
  214. package/packages/omo-codex/plugin/skills/review-work/SKILL.md +7 -0
  215. package/packages/omo-codex/plugin/skills/start-work/SKILL.md +2 -1
  216. package/packages/omo-codex/plugin/skills/ultrawork/SKILL.md +50 -33
  217. package/packages/omo-codex/plugin/skills/ulw-loop/SKILL.md +1 -1
  218. package/packages/omo-codex/plugin/skills/ulw-loop/references/full-workflow.md +6 -5
  219. package/packages/omo-codex/plugin/test/aggregate-build.test.mjs +8 -0
  220. package/packages/omo-codex/plugin/test/component-bundled-cli.test.mjs +128 -15
  221. package/packages/omo-codex/plugin/test/install-time-build-runtime.test.mjs +10 -0
  222. package/packages/omo-codex/plugin/test/lsp-prebuild-layouts.test.mjs +2 -0
  223. package/packages/omo-codex/plugin/test/mcp-research-servers.test.mjs +1 -0
  224. package/packages/omo-codex/plugin/test/sync-skills-orchestration.test.mjs +7 -0
  225. package/packages/omo-codex/plugin/test/sync-skills-test-support.mjs +34 -3
  226. package/packages/omo-codex/scripts/install-dist/install-local.mjs +328 -63
@@ -0,0 +1,98 @@
1
+ import { existsSync, lstatSync, readFileSync, readdirSync, realpathSync } from "node:fs";
2
+ import { dirname, isAbsolute, relative, resolve } from "node:path";
3
+ import { fileURLToPath } from "node:url";
4
+
5
+ import type { WorkspaceSnapshotEntry } from "./workspace-edit-types.js";
6
+
7
+ export type WorkspacePathResult =
8
+ | {
9
+ readonly success: true;
10
+ readonly path: string;
11
+ readonly requestedPath: string;
12
+ readonly followedSymbolicLink: boolean;
13
+ }
14
+ | { readonly success: false; readonly error: string };
15
+
16
+ class WorkspaceEditPathError extends Error {
17
+ override readonly name = "WorkspaceEditPathError";
18
+
19
+ constructor(
20
+ readonly path: string,
21
+ readonly detail: string,
22
+ ) {
23
+ super(`${detail}: ${path}`);
24
+ }
25
+ }
26
+
27
+ export function isPathInsideWorkspace(filePath: string, workspaceRoot: string): boolean {
28
+ const relativePath = relative(workspaceRoot, filePath);
29
+ return relativePath === "" || (!relativePath.startsWith("..") && !isAbsolute(relativePath));
30
+ }
31
+
32
+ function canonicalizeMissingPath(filePath: string): string {
33
+ let ancestor = filePath;
34
+ while (!existsSync(ancestor)) {
35
+ const parent = dirname(ancestor);
36
+ if (parent === ancestor) throw new WorkspaceEditPathError(filePath, "no existing ancestor");
37
+ ancestor = parent;
38
+ }
39
+ return resolve(realpathSync(ancestor), relative(ancestor, filePath));
40
+ }
41
+
42
+ export function canonicalWorkspaceRoot(workspaceRoot: string): WorkspacePathResult {
43
+ try {
44
+ const canonical = realpathSync(resolve(workspaceRoot));
45
+ if (!lstatSync(canonical).isDirectory()) {
46
+ return { success: false, error: `workspace root is not a directory: ${workspaceRoot}` };
47
+ }
48
+ return {
49
+ success: true,
50
+ path: canonical,
51
+ requestedPath: resolve(workspaceRoot),
52
+ followedSymbolicLink: existsSync(resolve(workspaceRoot)) && lstatSync(resolve(workspaceRoot)).isSymbolicLink(),
53
+ };
54
+ } catch (error) {
55
+ const detail = error instanceof Error ? error.message : String(error);
56
+ return { success: false, error: `workspace root ${workspaceRoot}: ${detail}` };
57
+ }
58
+ }
59
+
60
+ export function uriToCanonicalWorkspacePath(uri: string, workspaceRoot: string): WorkspacePathResult {
61
+ let requestedPath: string;
62
+ try {
63
+ const parsed = new URL(uri);
64
+ if (parsed.protocol !== "file:" || parsed.search !== "" || parsed.hash !== "") {
65
+ return { success: false, error: `non-file URI ${uri}` };
66
+ }
67
+ requestedPath = resolve(fileURLToPath(parsed));
68
+ } catch (error) {
69
+ const detail = error instanceof Error ? error.message : String(error);
70
+ return { success: false, error: `non-file URI ${uri}: ${detail}` };
71
+ }
72
+
73
+ try {
74
+ const canonical = existsSync(requestedPath) ? realpathSync(requestedPath) : canonicalizeMissingPath(requestedPath);
75
+ if (!isPathInsideWorkspace(canonical, workspaceRoot)) {
76
+ return { success: false, error: `${requestedPath}: outside workspace ${workspaceRoot}` };
77
+ }
78
+ return {
79
+ success: true,
80
+ path: canonical,
81
+ requestedPath,
82
+ followedSymbolicLink: existsSync(requestedPath) && lstatSync(requestedPath).isSymbolicLink(),
83
+ };
84
+ } catch (error) {
85
+ const detail = error instanceof Error ? error.message : String(error);
86
+ return { success: false, error: `${requestedPath}: ${detail}` };
87
+ }
88
+ }
89
+
90
+ export function snapshotPath(path: string, includeChildren: boolean): WorkspaceSnapshotEntry {
91
+ if (!existsSync(path)) return { kind: "missing" };
92
+ const stats = lstatSync(path);
93
+ if (stats.isFile()) return { kind: "file", content: readFileSync(path, "utf-8") };
94
+ if (stats.isDirectory()) {
95
+ return includeChildren ? { kind: "directory", children: readdirSync(path).sort() } : { kind: "directory" };
96
+ }
97
+ throw new WorkspaceEditPathError(path, "unsupported filesystem entry");
98
+ }
@@ -0,0 +1,60 @@
1
+ import type { TextEdit } from "./types.js";
2
+ import type { ApplyResult, PlannedWorkspaceOperation } from "./workspace-edit-types.js";
3
+
4
+ export type ParsedWorkspaceOperation =
5
+ | {
6
+ readonly kind: "text";
7
+ readonly changeIndex: number;
8
+ readonly path: string;
9
+ readonly reportedPath: string;
10
+ readonly edits: readonly TextEdit[];
11
+ readonly version: number | null;
12
+ }
13
+ | {
14
+ readonly kind: "create";
15
+ readonly changeIndex: number;
16
+ readonly path: string;
17
+ readonly reportedPath: string;
18
+ readonly overwrite: boolean;
19
+ readonly ignoreIfExists: boolean;
20
+ readonly followedSymbolicLink: boolean;
21
+ }
22
+ | {
23
+ readonly kind: "rename";
24
+ readonly changeIndex: number;
25
+ readonly oldPath: string;
26
+ readonly newPath: string;
27
+ readonly reportedOldPath: string;
28
+ readonly reportedNewPath: string;
29
+ readonly overwrite: boolean;
30
+ readonly ignoreIfExists: boolean;
31
+ readonly followedSymbolicLink: boolean;
32
+ }
33
+ | {
34
+ readonly kind: "delete";
35
+ readonly changeIndex: number;
36
+ readonly path: string;
37
+ readonly reportedPath: string;
38
+ readonly recursive: boolean;
39
+ readonly ignoreIfNotExists: boolean;
40
+ readonly followedSymbolicLink: boolean;
41
+ };
42
+
43
+ export interface ParseFailure {
44
+ readonly changeIndex: number;
45
+ readonly message: string;
46
+ }
47
+
48
+ export interface ParsedWorkspaceEdit {
49
+ readonly operations: readonly ParsedWorkspaceOperation[];
50
+ readonly failures: readonly ParseFailure[];
51
+ }
52
+
53
+ export interface SimulatedWorkspaceEdit {
54
+ readonly operations: readonly PlannedWorkspaceOperation[];
55
+ readonly failures: readonly ParseFailure[];
56
+ }
57
+
58
+ export type WorkspaceEditFingerprintResult =
59
+ | { readonly success: true; readonly fingerprint: string }
60
+ | { readonly success: false; readonly result: ApplyResult };
@@ -0,0 +1,73 @@
1
+ import { canonicalFingerprint } from "./workspace-edit-fingerprint.js";
2
+ import { canonicalWorkspaceRoot } from "./workspace-edit-path.js";
3
+ import { failureResult, parseWorkspaceEdit } from "./workspace-edit-parser.js";
4
+ import type { ParsedWorkspaceOperation, WorkspaceEditFingerprintResult } from "./workspace-edit-plan-types.js";
5
+ import { simulateOperations } from "./workspace-edit-simulation.js";
6
+ import { snapshotOperations } from "./workspace-edit-snapshot.js";
7
+ import type { WorkspaceEditPlan, WorkspaceEditPlanResult } from "./workspace-edit-types.js";
8
+
9
+ export type { WorkspaceEditFingerprintResult } from "./workspace-edit-plan-types.js";
10
+
11
+ class PlanPathIndex {
12
+ readonly firstChangeByPath = new Map<string, number>();
13
+ readonly reportedPathByCanonical = new Map<string, string>();
14
+
15
+ build(operations: readonly ParsedWorkspaceOperation[]): void {
16
+ for (const operation of operations) {
17
+ switch (operation.kind) {
18
+ case "rename":
19
+ this.add(operation.oldPath, operation.reportedOldPath, operation.changeIndex);
20
+ this.add(operation.newPath, operation.reportedNewPath, operation.changeIndex);
21
+ break;
22
+ case "text":
23
+ case "create":
24
+ case "delete":
25
+ this.add(operation.path, operation.reportedPath, operation.changeIndex);
26
+ break;
27
+ }
28
+ }
29
+ }
30
+
31
+ private add(path: string, reportedPath: string, changeIndex: number): void {
32
+ if (!this.firstChangeByPath.has(path)) this.firstChangeByPath.set(path, changeIndex);
33
+ if (!this.reportedPathByCanonical.has(path)) this.reportedPathByCanonical.set(path, reportedPath);
34
+ }
35
+ }
36
+
37
+ export function fingerprintWorkspaceEdit(edit: unknown, workspaceRoot: string): WorkspaceEditFingerprintResult {
38
+ const root = canonicalWorkspaceRoot(workspaceRoot);
39
+ if (!root.success) return { success: false, result: failureResult([{ changeIndex: 0, message: root.error }]) };
40
+ const parsed = parseWorkspaceEdit(edit, root.path);
41
+ if (parsed.failures.length > 0) return { success: false, result: failureResult(parsed.failures) };
42
+ return { success: true, fingerprint: canonicalFingerprint(parsed.operations) };
43
+ }
44
+
45
+ export function planWorkspaceEdit(edit: unknown, workspaceRoot: string): WorkspaceEditPlanResult {
46
+ const root = canonicalWorkspaceRoot(workspaceRoot);
47
+ if (!root.success) return { success: false, result: failureResult([{ changeIndex: 0, message: root.error }]) };
48
+ const parsed = parseWorkspaceEdit(edit, root.path);
49
+ if (parsed.failures.length > 0) return { success: false, result: failureResult(parsed.failures) };
50
+
51
+ let snapshots;
52
+ try {
53
+ snapshots = snapshotOperations(parsed.operations, root.path);
54
+ } catch (error) {
55
+ return {
56
+ success: false,
57
+ result: failureResult([{ changeIndex: 0, message: error instanceof Error ? error.message : String(error) }]),
58
+ };
59
+ }
60
+ const simulated = simulateOperations(parsed.operations, snapshots);
61
+ if (simulated.failures.length > 0) return { success: false, result: failureResult(simulated.failures) };
62
+ const paths = new PlanPathIndex();
63
+ paths.build(parsed.operations);
64
+ const plan: WorkspaceEditPlan = {
65
+ workspaceRoot: root.path,
66
+ operations: simulated.operations,
67
+ snapshots,
68
+ firstChangeByPath: paths.firstChangeByPath,
69
+ reportedPathByCanonical: paths.reportedPathByCanonical,
70
+ fingerprint: canonicalFingerprint(parsed.operations),
71
+ };
72
+ return { success: true, plan };
73
+ }
@@ -0,0 +1,174 @@
1
+ import { existsSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs";
2
+ import { tmpdir } from "node:os";
3
+ import { join } from "node:path";
4
+ import { pathToFileURL } from "node:url";
5
+
6
+ import { afterEach, describe, expect, it } from "bun:test";
7
+
8
+ import { applyWorkspaceEdit } from "./workspace-edit.js";
9
+
10
+ const tempDirectories: string[] = [];
11
+
12
+ afterEach(() => {
13
+ for (const directory of tempDirectories.splice(0)) {
14
+ rmSync(directory, { recursive: true, force: true });
15
+ }
16
+ });
17
+
18
+ describe("applyWorkspaceEdit prevalidation", () => {
19
+ it("#given a valid edit before an overlapping edit #when validated #then neither file is mutated", () => {
20
+ // given
21
+ const workspace = makeTempDirectory();
22
+ const validFile = join(workspace, "valid.ts");
23
+ const invalidFile = join(workspace, "invalid.ts");
24
+ writeFileSync(validFile, "const valid = 1;\n", "utf-8");
25
+ writeFileSync(invalidFile, "const invalid = 2;\n", "utf-8");
26
+
27
+ // when
28
+ const result = applyWorkspaceEdit(
29
+ {
30
+ changes: {
31
+ [pathToFileURL(validFile).href]: [edit(0, [6, 11], "changed")],
32
+ [pathToFileURL(invalidFile).href]: [edit(0, [0, 10], "first"), edit(0, [5, 13], "second")],
33
+ },
34
+ },
35
+ { workspaceRoot: workspace },
36
+ );
37
+
38
+ // then
39
+ expect(result.success).toBe(false);
40
+ expect(result.filesModified).toEqual([]);
41
+ expect(readFileSync(validFile, "utf-8")).toBe("const valid = 1;\n");
42
+ expect(readFileSync(invalidFile, "utf-8")).toBe("const invalid = 2;\n");
43
+ });
44
+
45
+ it("#given byte-identical non-empty edits #when applied #then one edit is committed", () => {
46
+ // given
47
+ const workspace = makeTempDirectory();
48
+ const source = join(workspace, "source.ts");
49
+ writeFileSync(source, "const before = 1;\n", "utf-8");
50
+ const replacement = edit(0, [6, 12], "after");
51
+
52
+ // when
53
+ const result = applyWorkspaceEdit(
54
+ { changes: { [pathToFileURL(source).href]: [replacement, replacement] } },
55
+ { workspaceRoot: workspace },
56
+ );
57
+
58
+ // then
59
+ expect(result.success).toBe(true);
60
+ expect(result.totalEdits).toBe(1);
61
+ expect(readFileSync(source, "utf-8")).toBe("const after = 1;\n");
62
+ });
63
+
64
+ it("#given equal-position insertions #when applied #then all insertions retain declared order", () => {
65
+ // given
66
+ const workspace = makeTempDirectory();
67
+ const source = join(workspace, "source.ts");
68
+ writeFileSync(source, "abc", "utf-8");
69
+
70
+ // when
71
+ const result = applyWorkspaceEdit(
72
+ {
73
+ changes: {
74
+ [pathToFileURL(source).href]: [edit(0, [1, 1], "X"), edit(0, [1, 1], "X"), edit(0, [1, 1], "Y")],
75
+ },
76
+ },
77
+ { workspaceRoot: workspace },
78
+ );
79
+
80
+ // then
81
+ expect(result.success).toBe(true);
82
+ expect(result.totalEdits).toBe(3);
83
+ expect(readFileSync(source, "utf-8")).toBe("aXXYbc");
84
+ });
85
+
86
+ it("#given a range beyond the snapshot #when validated #then the edit fails without mutation", () => {
87
+ // given
88
+ const workspace = makeTempDirectory();
89
+ const source = join(workspace, "source.ts");
90
+ writeFileSync(source, "short\n", "utf-8");
91
+
92
+ // when
93
+ const result = applyWorkspaceEdit(
94
+ { changes: { [pathToFileURL(source).href]: [edit(4, [0, 1], "invalid")] } },
95
+ { workspaceRoot: workspace },
96
+ );
97
+
98
+ // then
99
+ expect(result.success).toBe(false);
100
+ expect(result.filesModified).toEqual([]);
101
+ expect(readFileSync(source, "utf-8")).toBe("short\n");
102
+ });
103
+
104
+ it("#given a create followed by an invalid edit #when the sequence is validated #then no file is created", () => {
105
+ // given
106
+ const workspace = makeTempDirectory();
107
+ const source = join(workspace, "created.ts");
108
+ const uri = pathToFileURL(source).href;
109
+
110
+ // when
111
+ const result = applyWorkspaceEdit(
112
+ {
113
+ documentChanges: [
114
+ { kind: "create", uri },
115
+ { textDocument: { uri, version: null }, edits: [edit(2, [0, 1], "invalid")] },
116
+ ],
117
+ },
118
+ { workspaceRoot: workspace },
119
+ );
120
+
121
+ // then
122
+ expect(result.success).toBe(false);
123
+ expect(result.filesModified).toEqual([]);
124
+ expect(result).toMatchObject({ failedChange: 1 });
125
+ expect(result.errors.join("\n")).toContain("change 1");
126
+ expect(existsSync(source)).toBe(false);
127
+ });
128
+
129
+ it("#given rename then text edit of the destination #when applied #then declared order uses virtual state", () => {
130
+ // given
131
+ const workspace = makeTempDirectory();
132
+ const source = join(workspace, "before.ts");
133
+ const destination = join(workspace, "after.ts");
134
+ writeFileSync(source, "const before = 1;\n", "utf-8");
135
+ const sourceUri = pathToFileURL(source).href;
136
+ const destinationUri = pathToFileURL(destination).href;
137
+
138
+ // when
139
+ const result = applyWorkspaceEdit(
140
+ {
141
+ documentChanges: [
142
+ { kind: "rename", oldUri: sourceUri, newUri: destinationUri },
143
+ {
144
+ textDocument: { uri: destinationUri, version: null },
145
+ edits: [edit(0, [6, 12], "after")],
146
+ },
147
+ ],
148
+ },
149
+ { workspaceRoot: workspace },
150
+ );
151
+
152
+ // then
153
+ expect(result.success).toBe(true);
154
+ expect(existsSync(source)).toBe(false);
155
+ expect(readFileSync(destination, "utf-8")).toBe("const after = 1;\n");
156
+ });
157
+ });
158
+
159
+ function edit(line: number, characters: readonly [start: number, end: number], newText: string) {
160
+ const [startCharacter, endCharacter] = characters;
161
+ return {
162
+ range: {
163
+ start: { line, character: startCharacter },
164
+ end: { line, character: endCharacter },
165
+ },
166
+ newText,
167
+ };
168
+ }
169
+
170
+ function makeTempDirectory(): string {
171
+ const directory = mkdtempSync(join(tmpdir(), "lsp-workspace-prevalidation-"));
172
+ tempDirectories.push(directory);
173
+ return directory;
174
+ }
@@ -0,0 +1,89 @@
1
+ import { uriToCanonicalWorkspacePath } from "./workspace-edit-path.js";
2
+ import { parseOptions } from "./workspace-edit-parse-helpers.js";
3
+ import type { ParsedWorkspaceOperation, ParseFailure } from "./workspace-edit-plan-types.js";
4
+ import { WorkspaceEditValidationError } from "./workspace-edit-types.js";
5
+
6
+ interface ResourceParseTarget {
7
+ readonly operations: ParsedWorkspaceOperation[];
8
+ readonly failures: ParseFailure[];
9
+ }
10
+
11
+ export interface ResourceChangeInput {
12
+ readonly change: Record<string, unknown>;
13
+ readonly changeIndex: number;
14
+ readonly workspaceRoot: string;
15
+ readonly target: ResourceParseTarget;
16
+ }
17
+
18
+ export function parseResourceChange(input: ResourceChangeInput): void {
19
+ const kind = input.change["kind"];
20
+ if (kind === "create" || kind === "delete") {
21
+ parseSinglePathResource(input, kind);
22
+ return;
23
+ }
24
+ if (kind !== "rename") {
25
+ throw new WorkspaceEditValidationError(input.changeIndex, `unsupported resource operation ${String(kind)}`);
26
+ }
27
+ parseRename(input);
28
+ }
29
+
30
+ function parseSinglePathResource(input: ResourceChangeInput, kind: "create" | "delete"): void {
31
+ const { change, changeIndex, workspaceRoot, target } = input;
32
+ if (typeof change["uri"] !== "string") throw new WorkspaceEditValidationError(changeIndex, `${kind}.uri is required`);
33
+ const resolvedPath = uriToCanonicalWorkspacePath(change["uri"], workspaceRoot);
34
+ if (!resolvedPath.success) {
35
+ target.failures.push({ changeIndex, message: resolvedPath.error });
36
+ return;
37
+ }
38
+ if (kind === "create") {
39
+ const options = parseOptions(change["options"], ["overwrite", "ignoreIfExists"], changeIndex);
40
+ target.operations.push({
41
+ kind,
42
+ changeIndex,
43
+ path: resolvedPath.path,
44
+ reportedPath: resolvedPath.requestedPath,
45
+ overwrite: options["overwrite"] ?? false,
46
+ ignoreIfExists: options["ignoreIfExists"] ?? false,
47
+ followedSymbolicLink: resolvedPath.followedSymbolicLink,
48
+ });
49
+ return;
50
+ }
51
+ const options = parseOptions(change["options"], ["recursive", "ignoreIfNotExists"], changeIndex);
52
+ target.operations.push({
53
+ kind,
54
+ changeIndex,
55
+ path: resolvedPath.path,
56
+ reportedPath: resolvedPath.requestedPath,
57
+ recursive: options["recursive"] ?? false,
58
+ ignoreIfNotExists: options["ignoreIfNotExists"] ?? false,
59
+ followedSymbolicLink: resolvedPath.followedSymbolicLink,
60
+ });
61
+ }
62
+
63
+ function parseRename(input: ResourceChangeInput): void {
64
+ const { change, changeIndex, workspaceRoot, target } = input;
65
+ if (typeof change["oldUri"] !== "string" || typeof change["newUri"] !== "string") {
66
+ throw new WorkspaceEditValidationError(changeIndex, "rename requires oldUri and newUri");
67
+ }
68
+ const oldPath = uriToCanonicalWorkspacePath(change["oldUri"], workspaceRoot);
69
+ const newPath = uriToCanonicalWorkspacePath(change["newUri"], workspaceRoot);
70
+ if (!oldPath.success || !newPath.success) {
71
+ target.failures.push({
72
+ changeIndex,
73
+ message: !oldPath.success ? oldPath.error : !newPath.success ? newPath.error : "invalid rename path",
74
+ });
75
+ return;
76
+ }
77
+ const options = parseOptions(change["options"], ["overwrite", "ignoreIfExists"], changeIndex);
78
+ target.operations.push({
79
+ kind: "rename",
80
+ changeIndex,
81
+ oldPath: oldPath.path,
82
+ newPath: newPath.path,
83
+ reportedOldPath: oldPath.requestedPath,
84
+ reportedNewPath: newPath.requestedPath,
85
+ overwrite: options["overwrite"] ?? false,
86
+ ignoreIfExists: options["ignoreIfExists"] ?? false,
87
+ followedSymbolicLink: oldPath.followedSymbolicLink || newPath.followedSymbolicLink,
88
+ });
89
+ }
@@ -0,0 +1,183 @@
1
+ import { dirname, relative, resolve } from "node:path";
2
+
3
+ import { normalizeTextEdits } from "./workspace-edit-text.js";
4
+ import type {
5
+ ParsedWorkspaceOperation,
6
+ ParseFailure,
7
+ SimulatedWorkspaceEdit,
8
+ } from "./workspace-edit-plan-types.js";
9
+ import type { PlannedWorkspaceOperation, WorkspaceSnapshotEntry } from "./workspace-edit-types.js";
10
+ import { WorkspaceEditValidationError } from "./workspace-edit-types.js";
11
+
12
+ function isSameOrDescendant(candidate: string, parent: string): boolean {
13
+ const relativePath = relative(parent, candidate);
14
+ return relativePath === "" || (!relativePath.startsWith("..") && relativePath !== "..");
15
+ }
16
+
17
+ function removeVirtualSubtree(virtual: Map<string, WorkspaceSnapshotEntry>, path: string): void {
18
+ for (const candidate of [...virtual.keys()]) {
19
+ if (isSameOrDescendant(candidate, path)) virtual.delete(candidate);
20
+ }
21
+ virtual.set(path, { kind: "missing" });
22
+ }
23
+
24
+ function moveVirtualSubtree(virtual: Map<string, WorkspaceSnapshotEntry>, oldPath: string, newPath: string): void {
25
+ const moved = [...virtual.entries()].filter(([candidate]) => isSameOrDescendant(candidate, oldPath));
26
+ removeVirtualSubtree(virtual, oldPath);
27
+ removeVirtualSubtree(virtual, newPath);
28
+ for (const [candidate, entry] of moved) {
29
+ const suffix = relative(oldPath, candidate);
30
+ virtual.set(suffix === "" ? newPath : resolve(newPath, suffix), entry);
31
+ }
32
+ }
33
+
34
+ function virtualDirectoryHasChildren(virtual: ReadonlyMap<string, WorkspaceSnapshotEntry>, path: string): boolean {
35
+ for (const [candidate, entry] of virtual) {
36
+ if (candidate !== path && entry.kind !== "missing" && isSameOrDescendant(candidate, path)) return true;
37
+ }
38
+ return false;
39
+ }
40
+
41
+ function requireVirtualParent(
42
+ virtual: ReadonlyMap<string, WorkspaceSnapshotEntry>,
43
+ path: string,
44
+ changeIndex: number,
45
+ ): void {
46
+ if (virtual.get(dirname(path))?.kind !== "directory") {
47
+ throw new WorkspaceEditValidationError(changeIndex, `parent directory does not exist for ${path}`);
48
+ }
49
+ }
50
+
51
+ export function simulateOperations(
52
+ parsed: readonly ParsedWorkspaceOperation[],
53
+ snapshots: ReadonlyMap<string, WorkspaceSnapshotEntry>,
54
+ ): SimulatedWorkspaceEdit {
55
+ const virtual = new Map(snapshots);
56
+ const planned: PlannedWorkspaceOperation[] = [];
57
+ const failures: ParseFailure[] = [];
58
+ for (const operation of parsed) {
59
+ try {
60
+ planned.push(simulateOperation(operation, virtual));
61
+ } catch (error) {
62
+ if (error instanceof WorkspaceEditValidationError) {
63
+ failures.push({ changeIndex: operation.changeIndex, message: error.detail });
64
+ continue;
65
+ }
66
+ throw error;
67
+ }
68
+ }
69
+ return { operations: planned, failures };
70
+ }
71
+
72
+ function simulateOperation(
73
+ operation: ParsedWorkspaceOperation,
74
+ virtual: Map<string, WorkspaceSnapshotEntry>,
75
+ ): PlannedWorkspaceOperation {
76
+ switch (operation.kind) {
77
+ case "text":
78
+ return simulateText(operation, virtual);
79
+ case "create":
80
+ return simulateCreate(operation, virtual);
81
+ case "rename":
82
+ return simulateRename(operation, virtual);
83
+ case "delete":
84
+ return simulateDelete(operation, virtual);
85
+ }
86
+ }
87
+
88
+ function rejectSymbolicLink(operation: Exclude<ParsedWorkspaceOperation, { readonly kind: "text" }>): void {
89
+ if (operation.followedSymbolicLink) {
90
+ throw new WorkspaceEditValidationError(operation.changeIndex, "resource operations through symbolic links are unsupported");
91
+ }
92
+ }
93
+
94
+ function simulateText(
95
+ operation: Extract<ParsedWorkspaceOperation, { readonly kind: "text" }>,
96
+ virtual: Map<string, WorkspaceSnapshotEntry>,
97
+ ): PlannedWorkspaceOperation {
98
+ const entry = virtual.get(operation.path);
99
+ if (entry?.kind !== "file") throw new WorkspaceEditValidationError(operation.changeIndex, `${operation.path} is not a file`);
100
+ const normalized = normalizeTextEdits(entry.content, operation.edits, operation.changeIndex);
101
+ virtual.set(operation.path, { kind: "file", content: normalized.text });
102
+ return {
103
+ kind: "text",
104
+ changeIndex: operation.changeIndex,
105
+ path: operation.path,
106
+ beforeText: entry.content,
107
+ afterText: normalized.text,
108
+ editCount: normalized.edits.length,
109
+ documentVersion: operation.version,
110
+ };
111
+ }
112
+
113
+ function simulateCreate(
114
+ operation: Extract<ParsedWorkspaceOperation, { readonly kind: "create" }>,
115
+ virtual: Map<string, WorkspaceSnapshotEntry>,
116
+ ): PlannedWorkspaceOperation {
117
+ rejectSymbolicLink(operation);
118
+ requireVirtualParent(virtual, operation.path, operation.changeIndex);
119
+ const target = virtual.get(operation.path) ?? { kind: "missing" };
120
+ if (target.kind !== "missing") {
121
+ if (operation.overwrite && target.kind === "file") {
122
+ virtual.set(operation.path, { kind: "file", content: "" });
123
+ return { kind: "create", changeIndex: operation.changeIndex, path: operation.path, replaced: true };
124
+ }
125
+ if (operation.ignoreIfExists) return { kind: "noop", changeIndex: operation.changeIndex };
126
+ throw new WorkspaceEditValidationError(operation.changeIndex, `create target already exists: ${operation.path}`);
127
+ }
128
+ virtual.set(operation.path, { kind: "file", content: "" });
129
+ return { kind: "create", changeIndex: operation.changeIndex, path: operation.path, replaced: false };
130
+ }
131
+
132
+ function simulateRename(
133
+ operation: Extract<ParsedWorkspaceOperation, { readonly kind: "rename" }>,
134
+ virtual: Map<string, WorkspaceSnapshotEntry>,
135
+ ): PlannedWorkspaceOperation {
136
+ rejectSymbolicLink(operation);
137
+ const source = virtual.get(operation.oldPath) ?? { kind: "missing" };
138
+ if (source.kind === "missing") {
139
+ throw new WorkspaceEditValidationError(operation.changeIndex, `rename source does not exist: ${operation.oldPath}`);
140
+ }
141
+ if (operation.oldPath === operation.newPath) return { kind: "noop", changeIndex: operation.changeIndex };
142
+ if (isSameOrDescendant(operation.newPath, operation.oldPath)) {
143
+ throw new WorkspaceEditValidationError(operation.changeIndex, "cannot rename a path into its own subtree");
144
+ }
145
+ requireVirtualParent(virtual, operation.newPath, operation.changeIndex);
146
+ const destination = virtual.get(operation.newPath) ?? { kind: "missing" };
147
+ if (destination.kind !== "missing" && !operation.overwrite) {
148
+ if (operation.ignoreIfExists) return { kind: "noop", changeIndex: operation.changeIndex };
149
+ throw new WorkspaceEditValidationError(operation.changeIndex, `rename target already exists: ${operation.newPath}`);
150
+ }
151
+ moveVirtualSubtree(virtual, operation.oldPath, operation.newPath);
152
+ return {
153
+ kind: "rename",
154
+ changeIndex: operation.changeIndex,
155
+ oldPath: operation.oldPath,
156
+ newPath: operation.newPath,
157
+ sourceKind: source.kind,
158
+ replaceDestination: destination.kind !== "missing",
159
+ };
160
+ }
161
+
162
+ function simulateDelete(
163
+ operation: Extract<ParsedWorkspaceOperation, { readonly kind: "delete" }>,
164
+ virtual: Map<string, WorkspaceSnapshotEntry>,
165
+ ): PlannedWorkspaceOperation {
166
+ rejectSymbolicLink(operation);
167
+ const target = virtual.get(operation.path) ?? { kind: "missing" };
168
+ if (target.kind === "missing") {
169
+ if (operation.ignoreIfNotExists) return { kind: "noop", changeIndex: operation.changeIndex };
170
+ throw new WorkspaceEditValidationError(operation.changeIndex, `delete target does not exist: ${operation.path}`);
171
+ }
172
+ if (target.kind === "directory" && !operation.recursive && virtualDirectoryHasChildren(virtual, operation.path)) {
173
+ throw new WorkspaceEditValidationError(operation.changeIndex, `directory is not empty: ${operation.path}`);
174
+ }
175
+ removeVirtualSubtree(virtual, operation.path);
176
+ return {
177
+ kind: "delete",
178
+ changeIndex: operation.changeIndex,
179
+ path: operation.path,
180
+ targetKind: target.kind,
181
+ recursive: operation.recursive,
182
+ };
183
+ }