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,368 @@
1
+ import { readFileSync, realpathSync } from "node:fs";
2
+ import { relative, resolve } from "node:path";
3
+ import { pathToFileURL } from "node:url";
4
+
5
+ import { effectiveExtension } from "./effective-extension.js";
6
+ import { getLanguageId } from "./language-mappings.js";
7
+ import type { Diagnostic } from "./types.js";
8
+ import type { PlannedWorkspaceOperation, WorkspaceMutation, WorkspaceMutationDelta } from "./workspace-edit-types.js";
9
+
10
+ const WATCHED_FILE_BATCH_SIZE = 128;
11
+ const DEFAULT_VERSIONLESS_PUBLISH_QUIESCENCE_MS = 250;
12
+
13
+ type SendNotification = (method: string, params: unknown) => Promise<void>;
14
+
15
+ interface OpenDocumentState {
16
+ readonly path: string;
17
+ readonly uri: string;
18
+ readonly languageId: string;
19
+ text: string;
20
+ version: number;
21
+ generation: number;
22
+ publishGeneration: number;
23
+ lastPublish?: PublishedDiagnostics;
24
+ pullCache?: PullDiagnosticsCache;
25
+ waiters: Set<() => void>;
26
+ }
27
+
28
+ interface WatchedFileEvent {
29
+ readonly uri: string;
30
+ readonly type: 1 | 2 | 3;
31
+ }
32
+
33
+ interface PublishedDiagnostics {
34
+ readonly diagnostics: readonly Diagnostic[];
35
+ readonly publishGeneration: number;
36
+ readonly documentGenerationAtArrival: number;
37
+ readonly arrivedAt: number;
38
+ readonly version?: number;
39
+ }
40
+
41
+ interface PullDiagnosticsCache {
42
+ readonly documentVersion: number;
43
+ readonly diagnostics: readonly Diagnostic[];
44
+ readonly resultId?: string;
45
+ }
46
+
47
+ export interface DocumentVersionFailure {
48
+ readonly changeIndex: number;
49
+ readonly message: string;
50
+ }
51
+
52
+ export interface DiagnosticSnapshot {
53
+ readonly path: string;
54
+ readonly uri: string;
55
+ readonly version: number;
56
+ readonly documentGeneration: number;
57
+ readonly publishGeneration: number;
58
+ }
59
+
60
+ export interface PullDiagnosticsReport {
61
+ readonly diagnostics: readonly Diagnostic[];
62
+ readonly kind: "full";
63
+ readonly resultId?: string;
64
+ }
65
+
66
+ export interface PullDiagnosticsCacheHit {
67
+ readonly documentVersion: number;
68
+ readonly diagnostics: readonly Diagnostic[];
69
+ readonly resultId?: string;
70
+ }
71
+
72
+ export type PushDiagnosticsResolution =
73
+ | { readonly status: "ready"; readonly diagnostics: readonly Diagnostic[] }
74
+ | { readonly status: "wait"; readonly waitMs: number }
75
+ | { readonly status: "missing" };
76
+
77
+ export interface WorkspaceDocumentStateOptions {
78
+ readonly now?: () => number;
79
+ readonly versionlessPublishQuiescenceMs?: number;
80
+ }
81
+
82
+ function canonicalPath(filePath: string): string {
83
+ const absolute = resolve(filePath);
84
+ try {
85
+ return realpathSync(absolute);
86
+ } catch {
87
+ return absolute;
88
+ }
89
+ }
90
+
91
+ function isSameOrDescendant(candidate: string, parent: string): boolean {
92
+ const suffix = relative(parent, candidate);
93
+ return suffix === "" || (!suffix.startsWith("..") && suffix !== "..");
94
+ }
95
+
96
+ function movedPath(candidate: string, oldPath: string, newPath: string): string {
97
+ const suffix = relative(oldPath, candidate);
98
+ return suffix === "" ? newPath : resolve(newPath, suffix);
99
+ }
100
+
101
+ export class WorkspaceDocumentState {
102
+ private readonly openDocuments = new Map<string, OpenDocumentState>();
103
+ private readonly openByUri = new Map<string, OpenDocumentState>();
104
+ private readonly openPromises = new Map<string, Promise<void>>();
105
+ private readonly now: () => number;
106
+ private readonly versionlessPublishQuiescenceMs: number;
107
+
108
+ constructor(
109
+ private readonly sendNotification: SendNotification,
110
+ private readonly clearDiagnostics: (uri: string) => void,
111
+ options: WorkspaceDocumentStateOptions = {},
112
+ ) {
113
+ this.now = options.now ?? (() => Date.now());
114
+ this.versionlessPublishQuiescenceMs =
115
+ options.versionlessPublishQuiescenceMs ?? DEFAULT_VERSIONLESS_PUBLISH_QUIESCENCE_MS;
116
+ }
117
+
118
+ async openFile(filePath: string): Promise<void> {
119
+ const path = canonicalPath(filePath);
120
+ const existingOpen = this.openPromises.get(path);
121
+ if (existingOpen) {
122
+ await existingOpen;
123
+ return this.openFile(path);
124
+ }
125
+
126
+ const text = readFileSync(path, "utf-8");
127
+ const existing = this.openDocuments.get(path);
128
+ if (!existing) return this.openDocumentSingleFlight(path, text);
129
+ if (existing.text === text) return;
130
+ await this.changeDocument(existing, text);
131
+ }
132
+
133
+ getVersion(filePath: string): number | undefined {
134
+ return this.openDocuments.get(canonicalPath(filePath))?.version;
135
+ }
136
+
137
+ getStoredDiagnostics(uri: string): readonly Diagnostic[] {
138
+ const state = this.openByUri.get(uri);
139
+ if (!state) return [];
140
+ return state.lastPublish?.diagnostics ?? state.pullCache?.diagnostics ?? [];
141
+ }
142
+
143
+ captureDiagnosticSnapshot(filePath: string): DiagnosticSnapshot | null {
144
+ const state = this.openDocuments.get(canonicalPath(filePath));
145
+ if (!state) return null;
146
+ return {
147
+ path: state.path,
148
+ uri: state.uri,
149
+ version: state.version,
150
+ documentGeneration: state.generation,
151
+ publishGeneration: state.publishGeneration,
152
+ };
153
+ }
154
+
155
+ isCurrentSnapshot(snapshot: DiagnosticSnapshot): boolean {
156
+ const state = this.openDocuments.get(snapshot.path);
157
+ return (
158
+ state !== undefined &&
159
+ state.uri === snapshot.uri &&
160
+ state.version === snapshot.version &&
161
+ state.generation === snapshot.documentGeneration
162
+ );
163
+ }
164
+
165
+ getPullCache(snapshot: DiagnosticSnapshot): PullDiagnosticsCacheHit | null {
166
+ const state = this.openByUri.get(snapshot.uri);
167
+ if (!state?.pullCache || state.pullCache.documentVersion !== snapshot.version) return null;
168
+ return state.pullCache;
169
+ }
170
+
171
+ recordPullDiagnostics(snapshot: DiagnosticSnapshot, report: PullDiagnosticsReport): void {
172
+ const state = this.openByUri.get(snapshot.uri);
173
+ if (!state) return;
174
+ state.pullCache = {
175
+ documentVersion: snapshot.version,
176
+ diagnostics: [...report.diagnostics],
177
+ ...(report.resultId === undefined ? {} : { resultId: report.resultId }),
178
+ };
179
+ }
180
+
181
+ recordPublishedDiagnostics(params: {
182
+ readonly uri: string;
183
+ readonly diagnostics: readonly Diagnostic[];
184
+ readonly version?: number;
185
+ }): void {
186
+ const state = this.openByUri.get(params.uri);
187
+ if (!state) return;
188
+ state.publishGeneration += 1;
189
+ state.lastPublish = {
190
+ diagnostics: [...params.diagnostics],
191
+ publishGeneration: state.publishGeneration,
192
+ documentGenerationAtArrival: state.generation,
193
+ arrivedAt: this.now(),
194
+ ...(params.version === undefined ? {} : { version: params.version }),
195
+ };
196
+ this.notifyWaiters(state);
197
+ }
198
+
199
+ resolvePushDiagnostics(snapshot: DiagnosticSnapshot): PushDiagnosticsResolution {
200
+ const state = this.openByUri.get(snapshot.uri);
201
+ if (!state?.lastPublish) return { status: "missing" };
202
+ const publish = state.lastPublish;
203
+ if (publish.version !== undefined) {
204
+ return publish.version === snapshot.version
205
+ ? { status: "ready", diagnostics: publish.diagnostics }
206
+ : { status: "missing" };
207
+ }
208
+ if (publish.documentGenerationAtArrival < snapshot.documentGeneration) return { status: "missing" };
209
+ const readyAt = publish.arrivedAt + this.versionlessPublishQuiescenceMs;
210
+ const waitMs = Math.max(0, readyAt - this.now());
211
+ return waitMs === 0
212
+ ? { status: "ready", diagnostics: publish.diagnostics }
213
+ : { status: "wait", waitMs };
214
+ }
215
+
216
+ waitForDiagnosticsActivity(snapshot: DiagnosticSnapshot, timeoutMs: number): Promise<void> {
217
+ const state = this.openByUri.get(snapshot.uri);
218
+ if (!state || timeoutMs <= 0) return Promise.resolve();
219
+ return new Promise((resolveActivity) => {
220
+ let settled = false;
221
+ const finish = () => {
222
+ if (settled) return;
223
+ settled = true;
224
+ clearTimeout(timer);
225
+ state.waiters.delete(finish);
226
+ resolveActivity();
227
+ };
228
+ const timer = setTimeout(finish, timeoutMs);
229
+ if (typeof timer.unref === "function") timer.unref();
230
+ state.waiters.add(finish);
231
+ });
232
+ }
233
+
234
+ validateVersions(operations: readonly PlannedWorkspaceOperation[]): DocumentVersionFailure | null {
235
+ const versions = new Map([...this.openDocuments].map(([path, state]) => [path, state.version]));
236
+ for (const operation of operations) {
237
+ if (operation.kind === "text") {
238
+ const current = versions.get(operation.path);
239
+ if (operation.documentVersion !== null && current !== operation.documentVersion) {
240
+ const observed = current === undefined ? "closed document" : `open document version ${current}`;
241
+ return {
242
+ changeIndex: operation.changeIndex,
243
+ message: `document version ${operation.documentVersion} does not match ${observed} for ${operation.path}`,
244
+ };
245
+ }
246
+ if (current !== undefined) versions.set(operation.path, current + 1);
247
+ continue;
248
+ }
249
+ if (operation.kind === "rename") {
250
+ const moved = [...versions].filter(([path]) => isSameOrDescendant(path, operation.oldPath));
251
+ for (const [path] of moved) versions.delete(path);
252
+ for (const [path] of moved) versions.set(movedPath(path, operation.oldPath, operation.newPath), 1);
253
+ continue;
254
+ }
255
+ if (operation.kind === "delete") {
256
+ for (const path of [...versions.keys()]) {
257
+ if (isSameOrDescendant(path, operation.path)) versions.delete(path);
258
+ }
259
+ continue;
260
+ }
261
+ if (operation.kind === "create" && operation.replaced && versions.has(operation.path)) {
262
+ versions.set(operation.path, 1);
263
+ }
264
+ }
265
+ return null;
266
+ }
267
+
268
+ async synchronize(delta: WorkspaceMutationDelta): Promise<void> {
269
+ const watched: WatchedFileEvent[] = [];
270
+ for (const mutation of delta.operations) await this.synchronizeMutation(mutation, watched);
271
+ for (let index = 0; index < watched.length; index += WATCHED_FILE_BATCH_SIZE) {
272
+ await this.sendNotification("workspace/didChangeWatchedFiles", {
273
+ changes: watched.slice(index, index + WATCHED_FILE_BATCH_SIZE),
274
+ });
275
+ }
276
+ }
277
+
278
+ private async synchronizeMutation(mutation: WorkspaceMutation, watched: WatchedFileEvent[]): Promise<void> {
279
+ if (mutation.kind === "text") {
280
+ const state = this.openDocuments.get(mutation.path);
281
+ if (state) await this.changeDocument(state, mutation.afterText);
282
+ else watched.push({ uri: pathToFileURL(mutation.path).href, type: 2 });
283
+ return;
284
+ }
285
+ if (mutation.kind === "create") {
286
+ const state = this.openDocuments.get(mutation.path);
287
+ if (state) {
288
+ await this.closeDocument(state);
289
+ await this.openDocumentSingleFlight(mutation.path, readFileSync(mutation.path, "utf-8"));
290
+ } else {
291
+ watched.push({ uri: pathToFileURL(mutation.path).href, type: mutation.replaced ? 2 : 1 });
292
+ }
293
+ return;
294
+ }
295
+ if (mutation.kind === "rename") {
296
+ const moved = [...this.openDocuments.values()].filter((state) =>
297
+ isSameOrDescendant(state.path, mutation.oldPath),
298
+ );
299
+ for (const state of moved) await this.closeDocument(state);
300
+ for (const state of moved) {
301
+ const path = movedPath(state.path, mutation.oldPath, mutation.newPath);
302
+ await this.openDocumentSingleFlight(path, readFileSync(path, "utf-8"));
303
+ }
304
+ if (moved.length === 0) {
305
+ watched.push({ uri: pathToFileURL(mutation.oldPath).href, type: 3 });
306
+ watched.push({ uri: pathToFileURL(mutation.newPath).href, type: 1 });
307
+ }
308
+ return;
309
+ }
310
+ const removed = [...this.openDocuments.values()].filter((state) =>
311
+ isSameOrDescendant(state.path, mutation.path),
312
+ );
313
+ for (const state of removed) await this.closeDocument(state);
314
+ if (removed.length === 0) watched.push({ uri: pathToFileURL(mutation.path).href, type: 3 });
315
+ }
316
+
317
+ private async openDocumentSingleFlight(path: string, text: string): Promise<void> {
318
+ const existing = this.openPromises.get(path);
319
+ if (existing) return existing;
320
+ const open = (async () => {
321
+ const state: OpenDocumentState = {
322
+ path,
323
+ uri: pathToFileURL(path).href,
324
+ languageId: getLanguageId(effectiveExtension(path)),
325
+ text,
326
+ version: 1,
327
+ generation: 1,
328
+ publishGeneration: 0,
329
+ waiters: new Set(),
330
+ };
331
+ this.openDocuments.set(path, state);
332
+ this.openByUri.set(state.uri, state);
333
+ this.notifyWaiters(state);
334
+ await this.sendNotification("textDocument/didOpen", {
335
+ textDocument: { uri: state.uri, languageId: state.languageId, version: state.version, text },
336
+ });
337
+ })().finally(() => {
338
+ this.openPromises.delete(path);
339
+ });
340
+ this.openPromises.set(path, open);
341
+ return open;
342
+ }
343
+
344
+ private async changeDocument(state: OpenDocumentState, text: string): Promise<void> {
345
+ state.text = text;
346
+ state.version += 1;
347
+ state.generation += 1;
348
+ this.clearDiagnostics(state.uri);
349
+ this.notifyWaiters(state);
350
+ await this.sendNotification("textDocument/didChange", {
351
+ textDocument: { uri: state.uri, version: state.version },
352
+ contentChanges: [{ text }],
353
+ });
354
+ await this.sendNotification("textDocument/didSave", { textDocument: { uri: state.uri }, text });
355
+ }
356
+
357
+ private async closeDocument(state: OpenDocumentState): Promise<void> {
358
+ this.openDocuments.delete(state.path);
359
+ this.openByUri.delete(state.uri);
360
+ this.clearDiagnostics(state.uri);
361
+ this.notifyWaiters(state);
362
+ await this.sendNotification("textDocument/didClose", { textDocument: { uri: state.uri } });
363
+ }
364
+
365
+ private notifyWaiters(state: OpenDocumentState): void {
366
+ for (const waiter of [...state.waiters]) waiter();
367
+ }
368
+ }
@@ -0,0 +1,113 @@
1
+ import { 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, planWorkspaceEdit } from "./workspace-edit.js";
9
+
10
+ const tempDirectories: string[] = [];
11
+
12
+ afterEach(() => {
13
+ for (const directory of tempDirectories.splice(0)) rmSync(directory, { recursive: true, force: true });
14
+ });
15
+
16
+ describe("workspace edit adversarial inputs", () => {
17
+ it("#given malformed and decorated URIs #when planned #then every variant is rejected before mutation", () => {
18
+ const workspace = makeTempDirectory("lsp-workspace-");
19
+ const source = join(workspace, "source.ts");
20
+ writeFileSync(source, "const before = 1;\n", "utf-8");
21
+ const decorated = `${pathToFileURL(source).href}?query=forbidden`;
22
+
23
+ for (const uri of ["untitled:source.ts", decorated, "file:///%ZZ"] as const) {
24
+ const result = planWorkspaceEdit({ changes: { [uri]: [insertion()] } }, workspace);
25
+ expect(result.success).toBe(false);
26
+ }
27
+ expect(readFileSync(source, "utf-8")).toBe("const before = 1;\n");
28
+ });
29
+
30
+ it("#given invalid ranges and resource options #when planned #then each failure retains its operation index", () => {
31
+ const workspace = makeTempDirectory("lsp-workspace-");
32
+ const source = join(workspace, "source.ts");
33
+ const created = join(workspace, "created.ts");
34
+ writeFileSync(source, "const before = 1;\n", "utf-8");
35
+ const invalidRange = planWorkspaceEdit(
36
+ {
37
+ documentChanges: [
38
+ {
39
+ textDocument: { uri: pathToFileURL(source).href, version: null },
40
+ edits: [
41
+ {
42
+ range: { start: { line: 0, character: -1 }, end: { line: 0, character: 2 } },
43
+ newText: "bad",
44
+ },
45
+ ],
46
+ },
47
+ ],
48
+ },
49
+ workspace,
50
+ );
51
+ const invalidOption = planWorkspaceEdit(
52
+ {
53
+ documentChanges: [
54
+ {
55
+ textDocument: { uri: pathToFileURL(source).href, version: null },
56
+ edits: [],
57
+ },
58
+ { kind: "create", uri: pathToFileURL(created).href, options: { overwrite: "yes" } },
59
+ ],
60
+ },
61
+ workspace,
62
+ );
63
+
64
+ expect(invalidRange.success).toBe(false);
65
+ if (!invalidRange.success) expect(invalidRange.result.errors[0]).toContain("change 0");
66
+ expect(invalidOption.success).toBe(false);
67
+ if (!invalidOption.success) expect(invalidOption.result.errors[0]).toContain("change 1");
68
+ expect(readFileSync(source, "utf-8")).toBe("const before = 1;\n");
69
+ });
70
+
71
+ it("#given an outside target and a dirty unrelated file #when prevalidation fails #then neither is touched", () => {
72
+ const workspace = makeTempDirectory("lsp-workspace-");
73
+ const outside = makeTempDirectory("lsp-outside-");
74
+ const dirty = join(workspace, "dirty.ts");
75
+ const outsideFile = join(outside, "outside.ts");
76
+ writeFileSync(dirty, "user-owned dirty bytes\n", "utf-8");
77
+ writeFileSync(outsideFile, "outside bytes\n", "utf-8");
78
+
79
+ const result = applyWorkspaceEdit(
80
+ { changes: { [pathToFileURL(outsideFile).href]: [insertion()] } },
81
+ { workspaceRoot: workspace },
82
+ );
83
+
84
+ expect(result.success).toBe(false);
85
+ expect(result.errors.join("\n")).toContain("outside workspace");
86
+ expect(readFileSync(dirty, "utf-8")).toBe("user-owned dirty bytes\n");
87
+ expect(readFileSync(outsideFile, "utf-8")).toBe("outside bytes\n");
88
+ });
89
+
90
+ it("#given annotations or mixed edit representations #when planned #then unsupported claims are rejected", () => {
91
+ const workspace = makeTempDirectory("lsp-workspace-");
92
+ const unsupported = [
93
+ { changeAnnotations: { change: { label: "unsupported" } } },
94
+ { changes: {}, documentChanges: [] },
95
+ { documentChanges: [{ kind: "create", uri: "file:///missing", annotationId: "change" }] },
96
+ ];
97
+
98
+ for (const edit of unsupported) expect(planWorkspaceEdit(edit, workspace).success).toBe(false);
99
+ });
100
+ });
101
+
102
+ function insertion() {
103
+ return {
104
+ range: { start: { line: 0, character: 0 }, end: { line: 0, character: 0 } },
105
+ newText: "x",
106
+ };
107
+ }
108
+
109
+ function makeTempDirectory(prefix: string): string {
110
+ const directory = mkdtempSync(join(tmpdir(), prefix));
111
+ tempDirectories.push(directory);
112
+ return directory;
113
+ }
@@ -0,0 +1,140 @@
1
+ import { 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 { applyWorkspaceEditDetailed, commitWorkspaceEditPlan, planWorkspaceEdit } from "./workspace-edit.js";
9
+
10
+ const tempDirectories: string[] = [];
11
+
12
+ afterEach(() => {
13
+ for (const directory of tempDirectories.splice(0)) rmSync(directory, { recursive: true, force: true });
14
+ });
15
+
16
+ describe("workspace edit commit barrier", () => {
17
+ it("#given cancellation before commit #when a valid plan is applied #then no mutation begins", () => {
18
+ const fixture = makeFixture();
19
+ const controller = new AbortController();
20
+ controller.abort();
21
+
22
+ const commit = applyWorkspaceEditDetailed(textEdit(fixture.source, "before", "after"), {
23
+ workspaceRoot: fixture.workspace,
24
+ signal: controller.signal,
25
+ });
26
+
27
+ expect(commit.result.success).toBe(false);
28
+ expect(commit.result.errors.join("\n")).toContain("cancelled before commit");
29
+ expect(commit.delta.operations).toEqual([]);
30
+ expect(readFileSync(fixture.source, "utf-8")).toBe("const before = 1;\n");
31
+ });
32
+
33
+ it("#given repeated pre-gate interruptions #when retried #then every attempt leaves the snapshot untouched", () => {
34
+ const fixture = makeFixture();
35
+ for (let attempt = 0; attempt < 3; attempt += 1) {
36
+ const controller = new AbortController();
37
+ controller.abort();
38
+ const commit = applyWorkspaceEditDetailed(textEdit(fixture.source, "before", "after"), {
39
+ workspaceRoot: fixture.workspace,
40
+ signal: controller.signal,
41
+ });
42
+ expect(commit.result.success).toBe(false);
43
+ expect(commit.delta.operations).toEqual([]);
44
+ }
45
+ expect(readFileSync(fixture.source, "utf-8")).toBe("const before = 1;\n");
46
+ });
47
+
48
+ it("#given cancellation after the first write #when commit has crossed the barrier #then all operations finish once", () => {
49
+ const fixture = makeFixture();
50
+ const second = join(fixture.workspace, "second.ts");
51
+ writeFileSync(second, "const second = 2;\n", "utf-8");
52
+ const controller = new AbortController();
53
+ let writes = 0;
54
+
55
+ const commit = applyWorkspaceEditDetailed(
56
+ {
57
+ changes: {
58
+ [pathToFileURL(fixture.source).href]: [replacement("before", "after")],
59
+ [pathToFileURL(second).href]: [replacement("second", "later_")],
60
+ },
61
+ },
62
+ {
63
+ workspaceRoot: fixture.workspace,
64
+ signal: controller.signal,
65
+ io: {
66
+ writeFile(path, content) {
67
+ writes += 1;
68
+ writeFileSync(path, content, "utf-8");
69
+ if (writes === 1) controller.abort();
70
+ },
71
+ },
72
+ },
73
+ );
74
+
75
+ expect(commit.result).toMatchObject({ success: true, lateAbort: true, totalEdits: 2 });
76
+ expect(writes).toBe(2);
77
+ expect(readFileSync(fixture.source, "utf-8")).toBe("const after = 1;\n");
78
+ expect(readFileSync(second, "utf-8")).toBe("const later_ = 2;\n");
79
+ });
80
+
81
+ it("#given an injected write failure #when commit begins #then the result reports real I/O failure", () => {
82
+ const fixture = makeFixture();
83
+
84
+ const commit = applyWorkspaceEditDetailed(textEdit(fixture.source, "before", "after"), {
85
+ workspaceRoot: fixture.workspace,
86
+ io: {
87
+ writeFile() {
88
+ throw new InjectedWriteError();
89
+ },
90
+ },
91
+ });
92
+
93
+ expect(commit.result).toMatchObject({ success: false, failedChange: 0 });
94
+ expect(commit.result.errors.join("\n")).toContain("I/O failure during text: injected write failure");
95
+ expect(commit.delta.operations).toEqual([]);
96
+ expect(readFileSync(fixture.source, "utf-8")).toBe("const before = 1;\n");
97
+ });
98
+
99
+ it("#given a stale file snapshot #when a prepared plan commits #then the newer bytes are preserved", () => {
100
+ const fixture = makeFixture();
101
+ const planned = planWorkspaceEdit(textEdit(fixture.source, "before", "after"), fixture.workspace);
102
+ expect(planned.success).toBe(true);
103
+ if (!planned.success) return;
104
+ writeFileSync(fixture.source, "const external = 2;\n", "utf-8");
105
+
106
+ const commit = commitWorkspaceEditPlan(planned.plan);
107
+
108
+ expect(commit.result.success).toBe(false);
109
+ expect(commit.result.errors.join("\n")).toContain("workspace state changed before commit");
110
+ expect(commit.delta.operations).toEqual([]);
111
+ expect(readFileSync(fixture.source, "utf-8")).toBe("const external = 2;\n");
112
+ });
113
+ });
114
+
115
+ class InjectedWriteError extends Error {
116
+ override readonly name = "InjectedWriteError";
117
+
118
+ constructor() {
119
+ super("injected write failure");
120
+ }
121
+ }
122
+
123
+ function makeFixture(): { readonly workspace: string; readonly source: string } {
124
+ const workspace = mkdtempSync(join(tmpdir(), "lsp-workspace-commit-"));
125
+ tempDirectories.push(workspace);
126
+ const source = join(workspace, "source.ts");
127
+ writeFileSync(source, "const before = 1;\n", "utf-8");
128
+ return { workspace, source };
129
+ }
130
+
131
+ function textEdit(path: string, before: string, after: string) {
132
+ return { changes: { [pathToFileURL(path).href]: [replacement(before, after)] } };
133
+ }
134
+
135
+ function replacement(before: string, after: string) {
136
+ return {
137
+ range: { start: { line: 0, character: 6 }, end: { line: 0, character: 6 + before.length } },
138
+ newText: after,
139
+ };
140
+ }