oh-my-opencode 4.18.0 → 4.18.1

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 (192) hide show
  1. package/.agents/skills/codex-qa/scripts/lsp-e2e.sh +3654 -0
  2. package/.agents/skills/opencode-qa/scripts/lsp-e2e.sh +3071 -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/dist/cli/index.js +457 -154
  8. package/dist/cli-node/index.js +457 -154
  9. package/dist/index.js +415 -388
  10. package/package.json +16 -16
  11. package/packages/lsp-core/package.json +4 -0
  12. package/packages/lsp-core/src/index.ts +1 -0
  13. package/packages/lsp-core/src/lsp/cleanup-errors.test.ts +18 -0
  14. package/packages/lsp-core/src/lsp/cleanup-errors.ts +12 -3
  15. package/packages/lsp-core/src/lsp/client-diagnostics-freshness.integration.test.ts +261 -0
  16. package/packages/lsp-core/src/lsp/client-wrapper.test.ts +63 -0
  17. package/packages/lsp-core/src/lsp/client-wrapper.ts +35 -5
  18. package/packages/lsp-core/src/lsp/client.ts +262 -80
  19. package/packages/lsp-core/src/lsp/config-loader.ts +5 -17
  20. package/packages/lsp-core/src/lsp/connection.ts +12 -6
  21. package/packages/lsp-core/src/lsp/directory-diagnostics.test.ts +104 -0
  22. package/packages/lsp-core/src/lsp/directory-diagnostics.ts +60 -27
  23. package/packages/lsp-core/src/lsp/errors.ts +11 -0
  24. package/packages/lsp-core/src/lsp/fixtures/diagnostics-freshness-contract-probe.ts +283 -0
  25. package/packages/lsp-core/src/lsp/fixtures/workspace-edit-contract-probe.ts +196 -0
  26. package/packages/lsp-core/src/lsp/fixtures/workspace-edit-server.mjs +215 -0
  27. package/packages/lsp-core/src/lsp/formatters.ts +3 -0
  28. package/packages/lsp-core/src/lsp/json-rpc-connection-cancellation.test.ts +97 -0
  29. package/packages/lsp-core/src/lsp/json-rpc-connection.ts +73 -5
  30. package/packages/lsp-core/src/lsp/server-install-state.ts +3 -6
  31. package/packages/lsp-core/src/lsp/transport-protocol.ts +52 -0
  32. package/packages/lsp-core/src/lsp/transport.ts +96 -70
  33. package/packages/lsp-core/src/lsp/workspace-apply-edit-failure.ts +19 -0
  34. package/packages/lsp-core/src/lsp/workspace-apply-edit-lease.integration.test.ts +214 -0
  35. package/packages/lsp-core/src/lsp/workspace-apply-edit-sync.integration.test.ts +113 -0
  36. package/packages/lsp-core/src/lsp/workspace-apply-edit-test-support.ts +163 -0
  37. package/packages/lsp-core/src/lsp/workspace-apply-edit.integration.test.ts +163 -0
  38. package/packages/lsp-core/src/lsp/workspace-document-state.test.ts +67 -0
  39. package/packages/lsp-core/src/lsp/workspace-document-state.ts +368 -0
  40. package/packages/lsp-core/src/lsp/workspace-edit-adversarial.test.ts +113 -0
  41. package/packages/lsp-core/src/lsp/workspace-edit-commit.test.ts +140 -0
  42. package/packages/lsp-core/src/lsp/workspace-edit-commit.ts +220 -0
  43. package/packages/lsp-core/src/lsp/workspace-edit-contract-evidence.test.ts +56 -0
  44. package/packages/lsp-core/src/lsp/workspace-edit-contract-evidence.ts +30 -0
  45. package/packages/lsp-core/src/lsp/workspace-edit-fingerprint.ts +44 -0
  46. package/packages/lsp-core/src/lsp/workspace-edit-options.test.ts +147 -0
  47. package/packages/lsp-core/src/lsp/workspace-edit-parse-helpers.ts +59 -0
  48. package/packages/lsp-core/src/lsp/workspace-edit-parser.ts +130 -0
  49. package/packages/lsp-core/src/lsp/workspace-edit-path.ts +98 -0
  50. package/packages/lsp-core/src/lsp/workspace-edit-plan-types.ts +60 -0
  51. package/packages/lsp-core/src/lsp/workspace-edit-plan.ts +73 -0
  52. package/packages/lsp-core/src/lsp/workspace-edit-prevalidation.test.ts +174 -0
  53. package/packages/lsp-core/src/lsp/workspace-edit-resource-parser.ts +89 -0
  54. package/packages/lsp-core/src/lsp/workspace-edit-simulation.ts +183 -0
  55. package/packages/lsp-core/src/lsp/workspace-edit-snapshot.ts +53 -0
  56. package/packages/lsp-core/src/lsp/workspace-edit-text.ts +125 -0
  57. package/packages/lsp-core/src/lsp/workspace-edit-types.ts +121 -0
  58. package/packages/lsp-core/src/lsp/workspace-edit.characterization.test.ts +95 -0
  59. package/packages/lsp-core/src/lsp/workspace-edit.ts +49 -200
  60. package/packages/lsp-core/src/lsp/workspace-mutation-controller.ts +182 -0
  61. package/packages/lsp-core/src/mcp.ts +18 -7
  62. package/packages/lsp-core/src/missing-dependency-result.test.ts +105 -0
  63. package/packages/lsp-core/src/missing-dependency-result.ts +57 -0
  64. package/packages/lsp-core/src/post-edit/index.ts +1 -0
  65. package/packages/lsp-core/src/post-edit/orchestration.test.ts +157 -0
  66. package/packages/lsp-core/src/post-edit/orchestration.ts +178 -0
  67. package/packages/lsp-core/src/request-context.test.ts +171 -0
  68. package/packages/lsp-core/src/request-context.ts +222 -9
  69. package/packages/lsp-core/src/tool-surface.test.ts +4 -1
  70. package/packages/lsp-core/src/tools/diagnostics.ts +32 -13
  71. package/packages/lsp-core/src/tools/navigation.ts +12 -12
  72. package/packages/lsp-core/src/tools/rename.ts +10 -15
  73. package/packages/lsp-core/src/tools/symbols.ts +11 -11
  74. package/packages/lsp-core/src/tools/types.ts +2 -1
  75. package/packages/lsp-daemon/dist/cli.js +3114 -747
  76. package/packages/lsp-daemon/dist/client.d.ts +105 -0
  77. package/packages/lsp-daemon/dist/client.js +5851 -0
  78. package/packages/lsp-daemon/dist/daemon-client.d.ts +11 -6
  79. package/packages/lsp-daemon/dist/daemon-client.js +113 -30
  80. package/packages/lsp-daemon/dist/daemon-server.d.ts +1 -0
  81. package/packages/lsp-daemon/dist/daemon-server.js +40 -15
  82. package/packages/lsp-daemon/dist/ensure-daemon.d.ts +8 -7
  83. package/packages/lsp-daemon/dist/ensure-daemon.js +67 -44
  84. package/packages/lsp-daemon/dist/index.d.ts +2 -2
  85. package/packages/lsp-daemon/dist/index.js +2862 -754
  86. package/packages/lsp-daemon/dist/ipc-protocol.d.ts +46 -0
  87. package/packages/lsp-daemon/dist/ipc-protocol.js +187 -0
  88. package/packages/lsp-daemon/dist/lock.js +14 -4
  89. package/packages/lsp-daemon/dist/ownership.d.ts +49 -0
  90. package/packages/lsp-daemon/dist/ownership.js +168 -0
  91. package/packages/lsp-daemon/dist/paths.d.ts +33 -9
  92. package/packages/lsp-daemon/dist/paths.js +72 -33
  93. package/packages/lsp-daemon/dist/proxy.d.ts +3 -0
  94. package/packages/lsp-daemon/dist/proxy.js +54 -3
  95. package/packages/lsp-daemon/dist/request-routing.d.ts +7 -2
  96. package/packages/lsp-daemon/dist/request-routing.js +71 -22
  97. package/packages/lsp-daemon/dist/run-daemon.js +9 -2
  98. package/packages/lsp-daemon/dist/runtime-contract.d.ts +21 -0
  99. package/packages/lsp-daemon/dist/runtime-contract.js +58 -0
  100. package/packages/lsp-daemon/dist/socket-jsonrpc.js +6 -1
  101. package/packages/lsp-daemon/package.json +12 -3
  102. package/packages/lsp-tools-mcp/dist/cli.js +2115 -442
  103. package/packages/lsp-tools-mcp/dist/lsp/manager.js +1741 -148
  104. package/packages/lsp-tools-mcp/dist/mcp.js +2127 -454
  105. package/packages/lsp-tools-mcp/dist/request-context.js +176 -6
  106. package/packages/lsp-tools-mcp/dist/tools.js +2118 -446
  107. package/packages/omo-codex/plugin/.codex-plugin/plugin.json +1 -1
  108. package/packages/omo-codex/plugin/components/bootstrap/hooks/hooks.json +1 -1
  109. package/packages/omo-codex/plugin/components/bootstrap/package.json +1 -1
  110. package/packages/omo-codex/plugin/components/codegraph/package.json +1 -1
  111. package/packages/omo-codex/plugin/components/comment-checker/hooks/hooks.json +1 -1
  112. package/packages/omo-codex/plugin/components/comment-checker/package.json +1 -1
  113. package/packages/omo-codex/plugin/components/git-bash/hooks/hooks.json +2 -2
  114. package/packages/omo-codex/plugin/components/git-bash/package.json +1 -1
  115. package/packages/omo-codex/plugin/components/lazycodex-executor-verify/hooks/hooks.json +1 -1
  116. package/packages/omo-codex/plugin/components/lazycodex-executor-verify/package.json +1 -1
  117. package/packages/omo-codex/plugin/components/lsp/dist/.omo-runtime-manifest.json +55 -0
  118. package/packages/omo-codex/plugin/components/lsp/dist/cli.js +2959 -944
  119. package/packages/omo-codex/plugin/components/lsp/dist/codex-hook-cli.js +0 -4
  120. package/packages/omo-codex/plugin/components/lsp/dist/codex-hook.d.ts +5 -2
  121. package/packages/omo-codex/plugin/components/lsp/dist/codex-hook.js +41 -62
  122. package/packages/omo-codex/plugin/components/lsp/dist/daemon-cli-path.d.ts +1 -1
  123. package/packages/omo-codex/plugin/components/lsp/dist/daemon-cli-path.js +24 -15
  124. package/packages/omo-codex/plugin/components/lsp/dist/lsp-session-state.d.ts +3 -7
  125. package/packages/omo-codex/plugin/components/lsp/dist/lsp-session-state.js +23 -49
  126. package/packages/omo-codex/plugin/components/lsp/hooks/hooks.json +2 -2
  127. package/packages/omo-codex/plugin/components/lsp/package.json +3 -2
  128. package/packages/omo-codex/plugin/components/lsp/scripts/build-lsp-daemon.mjs +31 -1
  129. package/packages/omo-codex/plugin/components/lsp/scripts/build-lsp-daemon.test.mjs +76 -0
  130. package/packages/omo-codex/plugin/components/lsp/scripts/build-runtime.mjs +201 -0
  131. package/packages/omo-codex/plugin/components/lsp/scripts/build-runtime.test.mjs +55 -0
  132. package/packages/omo-codex/plugin/components/lsp/src/codex-hook-cli.ts +0 -4
  133. package/packages/omo-codex/plugin/components/lsp/src/codex-hook.ts +49 -71
  134. package/packages/omo-codex/plugin/components/lsp/src/daemon-cli-path.ts +26 -15
  135. package/packages/omo-codex/plugin/components/lsp/src/lsp-session-state.ts +26 -64
  136. package/packages/omo-codex/plugin/components/lsp/test/codex-hook-unavailable.test.ts +16 -17
  137. package/packages/omo-codex/plugin/components/lsp/test/codex-hook.test.ts +30 -4
  138. package/packages/omo-codex/plugin/components/lsp/test/package-smoke.test.ts +19 -5
  139. package/packages/omo-codex/plugin/components/rules/bundled-rules/hephaestus/gpt-5.5.md +1 -1
  140. package/packages/omo-codex/plugin/components/rules/bundled-rules/hephaestus/gpt-5.6.md +1 -1
  141. package/packages/omo-codex/plugin/components/rules/hooks/hooks.json +4 -4
  142. package/packages/omo-codex/plugin/components/rules/package.json +1 -1
  143. package/packages/omo-codex/plugin/components/start-work-continuation/directive.md +1 -1
  144. package/packages/omo-codex/plugin/components/start-work-continuation/hooks/hooks.json +2 -2
  145. package/packages/omo-codex/plugin/components/start-work-continuation/package.json +1 -1
  146. package/packages/omo-codex/plugin/components/teammode/hooks/hooks.json +1 -1
  147. package/packages/omo-codex/plugin/components/teammode/package.json +1 -1
  148. package/packages/omo-codex/plugin/components/telemetry/hooks/hooks.json +1 -1
  149. package/packages/omo-codex/plugin/components/telemetry/package.json +1 -1
  150. package/packages/omo-codex/plugin/components/ultrawork/hooks/hooks.json +1 -1
  151. package/packages/omo-codex/plugin/components/ultrawork/package.json +1 -1
  152. package/packages/omo-codex/plugin/components/ulw-loop/hooks/hooks.json +4 -4
  153. package/packages/omo-codex/plugin/components/ulw-loop/package.json +1 -1
  154. package/packages/omo-codex/plugin/components/ulw-loop/skills/ulw-loop/SKILL.md +1 -1
  155. package/packages/omo-codex/plugin/components/ulw-loop/skills/ulw-loop/references/full-workflow.md +6 -5
  156. package/packages/omo-codex/plugin/hooks/post-compact-resetting-git-bash-mcp-reminder.json +1 -1
  157. package/packages/omo-codex/plugin/hooks/post-compact-resetting-lsp-diagnostics-cache.json +1 -1
  158. package/packages/omo-codex/plugin/hooks/post-compact-resetting-project-rule-cache.json +1 -1
  159. package/packages/omo-codex/plugin/hooks/post-tool-use-checking-codegraph-init-guidance.json +1 -1
  160. package/packages/omo-codex/plugin/hooks/post-tool-use-checking-comments.json +1 -1
  161. package/packages/omo-codex/plugin/hooks/post-tool-use-checking-lsp-diagnostics.json +1 -1
  162. package/packages/omo-codex/plugin/hooks/post-tool-use-checking-thread-title-hygiene.json +1 -1
  163. package/packages/omo-codex/plugin/hooks/post-tool-use-matching-project-rules.json +1 -1
  164. package/packages/omo-codex/plugin/hooks/pre-tool-use-enforcing-unlimited-goal-budget.json +1 -1
  165. package/packages/omo-codex/plugin/hooks/pre-tool-use-guarding-ulw-loop-spawns.json +1 -1
  166. package/packages/omo-codex/plugin/hooks/pre-tool-use-recommending-git-bash-mcp.json +1 -1
  167. package/packages/omo-codex/plugin/hooks/session-start-checking-auto-update.json +1 -1
  168. package/packages/omo-codex/plugin/hooks/session-start-checking-bootstrap-provisioning.json +1 -1
  169. package/packages/omo-codex/plugin/hooks/session-start-checking-codegraph-bootstrap.json +1 -1
  170. package/packages/omo-codex/plugin/hooks/session-start-loading-project-rules.json +1 -1
  171. package/packages/omo-codex/plugin/hooks/session-start-recording-session-telemetry.json +1 -1
  172. package/packages/omo-codex/plugin/hooks/stop-checking-start-work-continuation.json +1 -1
  173. package/packages/omo-codex/plugin/hooks/stop-checking-ulw-loop-resume.json +1 -1
  174. package/packages/omo-codex/plugin/hooks/subagent-stop-checking-start-work-continuation.json +1 -1
  175. package/packages/omo-codex/plugin/hooks/subagent-stop-verifying-lazycodex-executor-evidence.json +1 -1
  176. package/packages/omo-codex/plugin/hooks/user-prompt-submit-checking-ultrawork-trigger.json +1 -1
  177. package/packages/omo-codex/plugin/hooks/user-prompt-submit-checking-ulw-loop-steering.json +1 -1
  178. package/packages/omo-codex/plugin/hooks/user-prompt-submit-loading-project-rules.json +1 -1
  179. package/packages/omo-codex/plugin/package-lock.json +26 -14
  180. package/packages/omo-codex/plugin/package.json +1 -1
  181. package/packages/omo-codex/plugin/scripts/build-bundled-mcp-runtimes.mjs +2 -3
  182. package/packages/omo-codex/plugin/scripts/build-components.mjs +13 -1
  183. package/packages/omo-codex/plugin/scripts/sync-skills.mjs +1 -1
  184. package/packages/omo-codex/plugin/skills/start-work/SKILL.md +1 -1
  185. package/packages/omo-codex/plugin/skills/ulw-loop/SKILL.md +1 -1
  186. package/packages/omo-codex/plugin/skills/ulw-loop/references/full-workflow.md +6 -5
  187. package/packages/omo-codex/plugin/test/aggregate-build.test.mjs +8 -0
  188. package/packages/omo-codex/plugin/test/component-bundled-cli.test.mjs +128 -15
  189. package/packages/omo-codex/plugin/test/install-time-build-runtime.test.mjs +10 -0
  190. package/packages/omo-codex/plugin/test/lsp-prebuild-layouts.test.mjs +2 -0
  191. package/packages/omo-codex/plugin/test/sync-skills-test-support.mjs +1 -1
  192. package/packages/omo-codex/scripts/install-dist/install-local.mjs +328 -63
@@ -6,17 +6,38 @@ import { DEFAULT_MAX_DIAGNOSTICS, DEFAULT_MAX_DIRECTORY_FILES } from "./constant
6
6
  import { effectiveExtension } from "./effective-extension.js";
7
7
  import { LspInvalidPathError, LspServerLookupError } from "./errors.js";
8
8
  import { filterDiagnosticsBySeverity, formatDiagnostic } from "./formatters.js";
9
- import { getLspManager } from "./manager.js";
9
+ import { getLspManager, type LspManager } from "./manager.js";
10
10
  import { findServerForExtension } from "./server-resolution.js";
11
- import type { Diagnostic, SeverityFilter } from "./types.js";
11
+ import type { Diagnostic, ResolvedServer, SeverityFilter } from "./types.js";
12
12
 
13
13
  const SKIP_DIRECTORIES = new Set(["node_modules", ".git", "dist", "build", ".next", "out"]);
14
+ const DIRECTORY_DIAGNOSTICS_MAX_CONCURRENCY = 4;
14
15
 
15
16
  interface FileDiagnostic {
16
17
  filePath: string;
17
18
  diagnostic: Diagnostic;
18
19
  }
19
20
 
21
+ export interface DirectoryDiagnosticsFileFailure {
22
+ readonly file: string;
23
+ readonly error: string;
24
+ }
25
+
26
+ export interface DirectoryDiagnosticsResult {
27
+ readonly output: string;
28
+ readonly totalDiagnostics: number;
29
+ readonly fileFailures: readonly DirectoryDiagnosticsFileFailure[];
30
+ }
31
+
32
+ export interface DirectoryDiagnosticsOptions {
33
+ readonly listFiles?: (directory: string, extension: string, maxFiles: number) => string[];
34
+ readonly manager?: LspManager;
35
+ readonly maxConcurrency?: number;
36
+ readonly workspaceRoot?: string;
37
+ readonly server?: ResolvedServer;
38
+ readonly signal?: AbortSignal;
39
+ }
40
+
20
41
  export function collectFilesWithExtension(dir: string, extension: string, maxFiles: number): string[] {
21
42
  const files: string[] = [];
22
43
 
@@ -63,61 +84,73 @@ export async function aggregateDiagnosticsForDirectory(
63
84
  extension: string,
64
85
  severity?: SeverityFilter,
65
86
  maxFiles: number = DEFAULT_MAX_DIRECTORY_FILES,
66
- ): Promise<string> {
87
+ options: DirectoryDiagnosticsOptions = {},
88
+ ): Promise<DirectoryDiagnosticsResult> {
67
89
  if (!extension.startsWith(".")) {
68
90
  throw new LspInvalidPathError(
69
91
  `Extension must start with a dot (e.g., ".ts", not "${extension}"). Use ".${extension}" instead.`,
70
92
  );
71
93
  }
72
94
 
73
- const absDir = resolve(contextCwd(), directory);
95
+ const absDir = resolve(options.workspaceRoot ?? contextCwd(), directory);
74
96
  if (!existsSync(absDir)) {
75
97
  throw new LspInvalidPathError(`Directory does not exist: ${absDir}`);
76
98
  }
77
99
 
78
- const serverResult = findServerForExtension(extension);
100
+ const serverResult = options.server === undefined ? findServerForExtension(extension) : { status: "found" as const, server: options.server };
79
101
  if (serverResult.status !== "found") {
80
102
  throw new LspServerLookupError(formatServerLookupError(serverResult));
81
103
  }
82
104
 
83
105
  const server = serverResult.server;
84
- const allFiles = collectFilesWithExtension(absDir, extension, maxFiles + 1);
106
+ const allFiles = (options.listFiles ?? collectFilesWithExtension)(absDir, extension, maxFiles + 1);
85
107
  const wasCapped = allFiles.length > maxFiles;
86
108
  const filesToProcess = allFiles.slice(0, maxFiles);
87
109
 
88
110
  if (filesToProcess.length === 0) {
89
- return [
111
+ const output = [
90
112
  `Directory: ${absDir}`,
91
113
  `Extension: ${extension}`,
92
114
  "Files scanned: 0",
93
115
  `No files found with extension "${extension}".`,
94
116
  ].join("\n");
117
+ return { output, totalDiagnostics: 0, fileFailures: [] };
95
118
  }
96
119
 
97
- const root = findWorkspaceRoot(absDir);
98
- const manager = getLspManager();
120
+ const root = options.workspaceRoot ?? findWorkspaceRoot(absDir);
121
+ const manager = options.manager ?? getLspManager();
99
122
  const allDiagnostics: FileDiagnostic[] = [];
100
- const fileErrors: { file: string; error: string }[] = [];
123
+ const fileErrors: DirectoryDiagnosticsFileFailure[] = [];
124
+ const maxConcurrency = Math.max(1, options.maxConcurrency ?? DIRECTORY_DIAGNOSTICS_MAX_CONCURRENCY);
101
125
 
126
+ options.signal?.throwIfAborted();
102
127
  const client = await manager.getClient(root, server);
103
128
  try {
104
- for (const file of filesToProcess) {
105
- try {
106
- const result = await client.diagnostics(file);
107
- const filtered = filterDiagnosticsBySeverity(result.items, severity);
108
- allDiagnostics.push(
109
- ...filtered.map((diagnostic) => ({
110
- filePath: file,
111
- diagnostic,
112
- })),
113
- );
114
- } catch (e) {
115
- fileErrors.push({
116
- file,
117
- error: e instanceof Error ? e.message : String(e),
118
- });
129
+ let nextIndex = 0;
130
+ const workers = Array.from({ length: Math.min(maxConcurrency, filesToProcess.length) }, async () => {
131
+ for (;;) {
132
+ if (options.signal?.aborted) return;
133
+ const file = filesToProcess[nextIndex];
134
+ nextIndex += 1;
135
+ if (file === undefined) return;
136
+ try {
137
+ const result = await client.diagnostics(file, options.signal);
138
+ const filtered = filterDiagnosticsBySeverity(result.items, severity);
139
+ allDiagnostics.push(
140
+ ...filtered.map((diagnostic) => ({
141
+ filePath: file,
142
+ diagnostic,
143
+ })),
144
+ );
145
+ } catch (e) {
146
+ fileErrors.push({
147
+ file,
148
+ error: e instanceof Error ? e.message : String(e),
149
+ });
150
+ }
119
151
  }
120
- }
152
+ });
153
+ await Promise.all(workers);
121
154
  } finally {
122
155
  manager.releaseClient(root, server.id);
123
156
  }
@@ -150,5 +183,5 @@ export async function aggregateDiagnosticsForDirectory(
150
183
  }
151
184
  }
152
185
 
153
- return lines.join("\n");
186
+ return { output: lines.join("\n"), totalDiagnostics: allDiagnostics.length, fileFailures: fileErrors };
154
187
  }
@@ -1,3 +1,7 @@
1
+ import type { ServerLookupResult } from "./types.js";
2
+
3
+ export type FailedServerLookupResult = Exclude<ServerLookupResult, { status: "found" }>;
4
+
1
5
  export class LspConnectionClosedError extends Error {
2
6
  override readonly name = "LspConnectionClosedError";
3
7
 
@@ -42,6 +46,13 @@ export class LspInvalidPathError extends Error {
42
46
 
43
47
  export class LspServerLookupError extends Error {
44
48
  override readonly name = "LspServerLookupError";
49
+
50
+ constructor(
51
+ message: string,
52
+ readonly lookup?: FailedServerLookupResult,
53
+ ) {
54
+ super(message);
55
+ }
45
56
  }
46
57
 
47
58
  export class LspServerInitializingError extends Error {
@@ -0,0 +1,283 @@
1
+ import { writeFileSync } from "node:fs";
2
+
3
+ import {
4
+ createWorkspaceEditTestHarness,
5
+ diagnostic,
6
+ readEvents,
7
+ waitForEventCount,
8
+ } from "../workspace-apply-edit-test-support.js";
9
+
10
+ class DiagnosticsFreshnessContractProbeError extends Error {
11
+ override readonly name = "DiagnosticsFreshnessContractProbeError";
12
+ }
13
+
14
+ function requireProbe(condition: boolean, message: string): asserts condition {
15
+ if (!condition) throw new DiagnosticsFreshnessContractProbeError(message);
16
+ }
17
+
18
+ const outputPath = process.argv[2];
19
+ if (!outputPath) throw new DiagnosticsFreshnessContractProbeError("output path is required");
20
+
21
+ const harness = createWorkspaceEditTestHarness();
22
+
23
+ try {
24
+ const exactCurrent = await harness.makeClient(
25
+ {
26
+ publishDiagnostics: [
27
+ {
28
+ trigger: "didOpen",
29
+ version: 1,
30
+ diagnostics: [diagnostic("exact-current")],
31
+ },
32
+ ],
33
+ },
34
+ { diagnosticsFreshnessTimeoutMs: 50, versionlessPublishQuiescenceMs: 5 },
35
+ );
36
+ const [exactFirst, exactSecond] = await Promise.all([
37
+ exactCurrent.client.diagnostics(exactCurrent.source),
38
+ exactCurrent.client.diagnostics(exactCurrent.source),
39
+ ]);
40
+ const exactDidOpenCount = readEvents(exactCurrent.events).filter(
41
+ (event) => event.type === "clientNotification" && event.method === "textDocument/didOpen",
42
+ ).length;
43
+ const exactCurrentEvidence = {
44
+ outcome: "exact-current",
45
+ items: exactFirst.items.map((entry) => entry.message),
46
+ concurrentItems: exactSecond.items.map((entry) => entry.message),
47
+ didOpenCount: exactDidOpenCount,
48
+ ok:
49
+ exactDidOpenCount === 1
50
+ && JSON.stringify(exactFirst.items) === JSON.stringify([diagnostic("exact-current")])
51
+ && JSON.stringify(exactSecond.items) === JSON.stringify([diagnostic("exact-current")]),
52
+ };
53
+ requireProbe(exactCurrentEvidence.ok, "exact-current diagnostics did not resolve immediately with a single didOpen");
54
+
55
+ const postGenerationVersionless = await harness.makeClient(
56
+ {
57
+ publishDiagnostics: [
58
+ {
59
+ trigger: "didChange",
60
+ diagnostics: [diagnostic("post-generation-versionless")],
61
+ },
62
+ ],
63
+ },
64
+ { diagnosticsFreshnessTimeoutMs: 80, versionlessPublishQuiescenceMs: 20 },
65
+ );
66
+ await postGenerationVersionless.client.openFile(postGenerationVersionless.source);
67
+ await waitForEventCount(
68
+ postGenerationVersionless.events,
69
+ (event) => event.type === "serverNotification" && event.method === "textDocument/publishDiagnostics",
70
+ 1,
71
+ );
72
+ writeFileSync(postGenerationVersionless.source, "const post_generation = 1;\n", "utf-8");
73
+ await postGenerationVersionless.client.openFile(postGenerationVersionless.source);
74
+ const versionlessStartedAt = Date.now();
75
+ const versionlessResult = await postGenerationVersionless.client.diagnostics(postGenerationVersionless.source);
76
+ const versionlessElapsedMs = Date.now() - versionlessStartedAt;
77
+ const postGenerationVersionlessEvidence = {
78
+ outcome: "post-generation-versionless",
79
+ items: versionlessResult.items.map((entry) => entry.message),
80
+ elapsedMs: versionlessElapsedMs,
81
+ ok:
82
+ JSON.stringify(versionlessResult.items) === JSON.stringify([diagnostic("post-generation-versionless")])
83
+ && versionlessElapsedMs >= 15,
84
+ };
85
+ requireProbe(
86
+ postGenerationVersionlessEvidence.ok,
87
+ "post-generation versionless diagnostics did not wait for quiescence before resolving",
88
+ );
89
+
90
+ const stale = await harness.makeClient(
91
+ {
92
+ publishDiagnostics: [
93
+ {
94
+ trigger: "didChange",
95
+ version: 1,
96
+ diagnostics: [diagnostic("stale")],
97
+ },
98
+ ],
99
+ },
100
+ { diagnosticsFreshnessTimeoutMs: 30, versionlessPublishQuiescenceMs: 5 },
101
+ );
102
+ await stale.client.openFile(stale.source);
103
+ writeFileSync(stale.source, "const stale = 1;\n", "utf-8");
104
+ await stale.client.openFile(stale.source);
105
+ const staleResult = await stale.client.diagnostics(stale.source);
106
+ const staleEvidence = {
107
+ outcome: "stale",
108
+ items: staleResult.items.map((entry) => entry.message),
109
+ transientError: staleResult.transientError ?? null,
110
+ ok: staleResult.items.length === 0 && staleResult.transientError?.kind === "freshness_timeout",
111
+ };
112
+ requireProbe(staleEvidence.ok, "stale diagnostics unexpectedly satisfied the current request");
113
+
114
+ const future = await harness.makeClient(
115
+ {
116
+ publishDiagnostics: [
117
+ {
118
+ trigger: "didChange",
119
+ version: 3,
120
+ diagnostics: [diagnostic("future")],
121
+ },
122
+ ],
123
+ },
124
+ { diagnosticsFreshnessTimeoutMs: 30, versionlessPublishQuiescenceMs: 5 },
125
+ );
126
+ await future.client.openFile(future.source);
127
+ writeFileSync(future.source, "const future = 1;\n", "utf-8");
128
+ await future.client.openFile(future.source);
129
+ const futureResult = await future.client.diagnostics(future.source);
130
+ const futureEvidence = {
131
+ outcome: "future",
132
+ items: futureResult.items.map((entry) => entry.message),
133
+ transientError: futureResult.transientError ?? null,
134
+ ok: futureResult.items.length === 0 && futureResult.transientError?.kind === "freshness_timeout",
135
+ };
136
+ requireProbe(futureEvidence.ok, "future diagnostics unexpectedly satisfied the current request");
137
+
138
+ const pullOvertaken = await harness.makeClient(
139
+ {
140
+ capabilities: { diagnosticProvider: { interFileDependencies: false, workspaceDiagnostics: false } },
141
+ diagnosticResponses: [
142
+ {
143
+ delayMs: 20,
144
+ report: { kind: "full", resultId: "v1", items: [diagnostic("pull-stale")] },
145
+ },
146
+ {
147
+ report: { kind: "full", resultId: "v2", items: [diagnostic("pull-fresh")] },
148
+ },
149
+ ],
150
+ },
151
+ { diagnosticsFreshnessTimeoutMs: 80, versionlessPublishQuiescenceMs: 5 },
152
+ );
153
+ await pullOvertaken.client.openFile(pullOvertaken.source);
154
+ const pendingPull = pullOvertaken.client.diagnostics(pullOvertaken.source);
155
+ await waitForEventCount(
156
+ pullOvertaken.events,
157
+ (event) => event.type === "clientRequest" && event.method === "textDocument/diagnostic",
158
+ 1,
159
+ );
160
+ writeFileSync(pullOvertaken.source, "const pull_fresh = 1;\n", "utf-8");
161
+ await pullOvertaken.client.openFile(pullOvertaken.source);
162
+ const pullOvertakenResult = await pendingPull;
163
+ const pullRequestCount = readEvents(pullOvertaken.events).filter(
164
+ (event) => event.type === "clientRequest" && event.method === "textDocument/diagnostic",
165
+ ).length;
166
+ const pullOvertakenEvidence = {
167
+ outcome: "pull-overtaken",
168
+ items: pullOvertakenResult.items.map((entry) => entry.message),
169
+ requestCount: pullRequestCount,
170
+ ok:
171
+ JSON.stringify(pullOvertakenResult.items) === JSON.stringify([diagnostic("pull-fresh")])
172
+ && pullRequestCount === 2,
173
+ };
174
+ requireProbe(
175
+ pullOvertakenEvidence.ok,
176
+ "pull diagnostics were not invalidated and retried after a local change overtook the in-flight request",
177
+ );
178
+
179
+ const silent = await harness.makeClient({}, { diagnosticsFreshnessTimeoutMs: 35, versionlessPublishQuiescenceMs: 5 });
180
+ const silentResult = await silent.client.diagnostics(silent.source);
181
+ const silentEvidence = {
182
+ outcome: "silent",
183
+ items: silentResult.items.map((entry) => entry.message),
184
+ transientError: silentResult.transientError ?? null,
185
+ ok: silentResult.items.length === 0 && silentResult.transientError?.kind === "freshness_timeout",
186
+ };
187
+ requireProbe(silentEvidence.ok, "silent diagnostics were labeled clean instead of timing out transiently");
188
+
189
+ const closedServer = await harness.makeClient(
190
+ {
191
+ capabilities: { diagnosticProvider: { interFileDependencies: false, workspaceDiagnostics: false } },
192
+ diagnosticResponses: [{ action: "close" }],
193
+ },
194
+ { diagnosticsFreshnessTimeoutMs: 50, versionlessPublishQuiescenceMs: 5 },
195
+ );
196
+ await closedServer.client.openFile(closedServer.source);
197
+ let closedServerError = "";
198
+ try {
199
+ await closedServer.client.diagnostics(closedServer.source);
200
+ } catch (error) {
201
+ closedServerError = error instanceof Error ? error.message : String(error);
202
+ }
203
+ const closedServerEvidence = {
204
+ outcome: "closed-server",
205
+ error: closedServerError,
206
+ ok: /exited|closed/i.test(closedServerError),
207
+ };
208
+ requireProbe(closedServerEvidence.ok, "closed-server diagnostics failure was not surfaced as a transport error");
209
+
210
+ const unsupportedPull = await harness.makeClient(
211
+ {
212
+ capabilities: { diagnosticProvider: { interFileDependencies: false, workspaceDiagnostics: false } },
213
+ publishDiagnostics: [
214
+ {
215
+ trigger: "didOpen",
216
+ version: 1,
217
+ diagnostics: [diagnostic("push-fallback")],
218
+ },
219
+ ],
220
+ diagnosticResponses: [{ error: { code: -32601, message: "Method not found" } }],
221
+ },
222
+ { diagnosticsFreshnessTimeoutMs: 50, versionlessPublishQuiescenceMs: 5 },
223
+ );
224
+ const unsupportedPullResult = await unsupportedPull.client.diagnostics(unsupportedPull.source);
225
+ const unsupportedPullEvidence = {
226
+ outcome: "unsupported-pull",
227
+ items: unsupportedPullResult.items.map((entry) => entry.message),
228
+ ok: JSON.stringify(unsupportedPullResult.items) === JSON.stringify([diagnostic("push-fallback")]),
229
+ };
230
+ requireProbe(unsupportedPullEvidence.ok, "explicitly unsupported pull did not fall back to push diagnostics");
231
+
232
+ const unchanged = await harness.makeClient(
233
+ {
234
+ capabilities: { diagnosticProvider: { interFileDependencies: false, workspaceDiagnostics: false } },
235
+ diagnosticResponses: [
236
+ {
237
+ report: { kind: "full", resultId: "same-version", items: [diagnostic("cached-full")] },
238
+ },
239
+ {
240
+ report: { kind: "unchanged", resultId: "same-version" },
241
+ },
242
+ ],
243
+ },
244
+ { diagnosticsFreshnessTimeoutMs: 50, versionlessPublishQuiescenceMs: 5 },
245
+ );
246
+ await unchanged.client.openFile(unchanged.source);
247
+ const unchangedFirst = await unchanged.client.diagnostics(unchanged.source);
248
+ const unchangedSecond = await unchanged.client.diagnostics(unchanged.source);
249
+ const unchangedEvidence = {
250
+ outcome: "same-version-unchanged",
251
+ firstItems: unchangedFirst.items.map((entry) => entry.message),
252
+ secondItems: unchangedSecond.items.map((entry) => entry.message),
253
+ ok:
254
+ JSON.stringify(unchangedFirst.items) === JSON.stringify([diagnostic("cached-full")])
255
+ && JSON.stringify(unchangedSecond.items) === JSON.stringify([diagnostic("cached-full")]),
256
+ };
257
+ requireProbe(unchangedEvidence.ok, "unchanged pull did not reuse the cached same-version result");
258
+
259
+ writeFileSync(
260
+ outputPath,
261
+ `${JSON.stringify(
262
+ {
263
+ schemaVersion: 1,
264
+ outcomes: {
265
+ exactCurrent: exactCurrentEvidence,
266
+ postGenerationVersionless: postGenerationVersionlessEvidence,
267
+ stale: staleEvidence,
268
+ future: futureEvidence,
269
+ pullOvertaken: pullOvertakenEvidence,
270
+ silent: silentEvidence,
271
+ closedServer: closedServerEvidence,
272
+ unsupportedPull: unsupportedPullEvidence,
273
+ sameVersionUnchanged: unchangedEvidence,
274
+ },
275
+ },
276
+ null,
277
+ 2,
278
+ )}\n`,
279
+ "utf-8",
280
+ );
281
+ } finally {
282
+ await harness.cleanup();
283
+ }
@@ -0,0 +1,196 @@
1
+ import { readFileSync, realpathSync, writeFileSync } from "node:fs";
2
+ import { pathToFileURL } from "node:url";
3
+
4
+ import {
5
+ createWorkspaceEditTestHarness,
6
+ diagnostic,
7
+ readEvents,
8
+ renameTextEdit,
9
+ waitForEventCount,
10
+ } from "../workspace-apply-edit-test-support.js";
11
+ import { workspaceEditContractFailureEvidence } from "../workspace-edit-contract-evidence.js";
12
+
13
+ class WorkspaceEditContractProbeError extends Error {
14
+ override readonly name = "WorkspaceEditContractProbeError";
15
+ }
16
+
17
+ function requireProbe(condition: boolean, message: string): asserts condition {
18
+ if (!condition) throw new WorkspaceEditContractProbeError(message);
19
+ }
20
+
21
+ function isRecord(value: unknown): value is Record<string, unknown> {
22
+ return typeof value === "object" && value !== null && !Array.isArray(value);
23
+ }
24
+
25
+ function applyResponses(eventsPath: string): readonly unknown[] {
26
+ return readEvents(eventsPath)
27
+ .filter((event) => event.type === "clientResponse" && event.method === "workspace/applyEdit")
28
+ .map((event) => event.result);
29
+ }
30
+
31
+ function isRejectedApply(value: unknown): boolean {
32
+ return isRecord(value) && value["applied"] === false && typeof value["failureReason"] === "string";
33
+ }
34
+
35
+ function didChangeVersions(eventsPath: string): readonly number[] {
36
+ const versions: number[] = [];
37
+ for (const event of readEvents(eventsPath)) {
38
+ if (event.type !== "clientNotification" || event.method !== "textDocument/didChange") continue;
39
+ if (!isRecord(event.params)) continue;
40
+ const textDocument = event.params["textDocument"];
41
+ if (!isRecord(textDocument) || typeof textDocument["version"] !== "number") continue;
42
+ versions.push(textDocument["version"]);
43
+ }
44
+ return versions;
45
+ }
46
+
47
+ const outputPath = process.argv[2];
48
+ if (!outputPath) throw new WorkspaceEditContractProbeError("output path is required");
49
+
50
+ const harness = createWorkspaceEditTestHarness();
51
+ try {
52
+ const success = await harness.makeClient({
53
+ renameSteps: [{ applyEdit: renameTextEdit("before", "after", { version: 1 }), renameResult: "same" }],
54
+ diagnostics: [diagnostic("todo3-fresh")],
55
+ });
56
+ let successWrites = 0;
57
+ success.client.setWorkspaceEditIo({
58
+ writeFile(path, content) {
59
+ successWrites += 1;
60
+ writeFileSync(path, content, "utf-8");
61
+ },
62
+ });
63
+ await success.client.openFile(success.source);
64
+ const successResult = await success.client.rename(success.source, 1, 6, "after");
65
+ const successResponses = applyResponses(success.events);
66
+ const successVersions = didChangeVersions(success.events);
67
+ const successUri = pathToFileURL(realpathSync(success.source)).href;
68
+ const successEvidence = {
69
+ serverAppliedRename:
70
+ successResponses.length === 1 && isRecord(successResponses[0]) && successResponses[0]["applied"] === true,
71
+ recordedResultReused: successResult.edit !== null && successResult.apply.success && successWrites === 1,
72
+ writeCount: successWrites,
73
+ secondWritePrevented: successWrites === 1 && successVersions.length === 1,
74
+ synchronizedDocumentVersion: success.client.getOpenDocumentVersion(success.source) ?? null,
75
+ didChangeVersions: successVersions,
76
+ immediateDiagnostics: success.client
77
+ .getStoredDiagnostics(successUri)
78
+ .some((entry) => entry.message === "todo3-fresh"),
79
+ finalContent: readFileSync(success.source, "utf-8"),
80
+ };
81
+ requireProbe(successEvidence.serverAppliedRename, "successful server apply was not reported truthfully");
82
+ requireProbe(successEvidence.recordedResultReused, "rename did not reuse the recorded server result");
83
+ requireProbe(successEvidence.secondWritePrevented, "server edit was written or synchronized more than once");
84
+ requireProbe(successEvidence.synchronizedDocumentVersion === 2, "open document version did not advance to 2");
85
+ requireProbe(successEvidence.immediateDiagnostics, "fresh diagnostics were not visible immediately after applyEdit");
86
+ requireProbe(successEvidence.finalContent === "const after = 1;\n", "successful rename produced unexpected bytes");
87
+
88
+ const unscoped = await harness.makeClient({
89
+ unscopedApplyEdit: renameTextEdit("before", "after", { version: 1 }),
90
+ });
91
+ const unscopedResponses = await waitForEventCount(
92
+ unscoped.events,
93
+ (event) => event.type === "clientResponse" && event.method === "workspace/applyEdit",
94
+ 1,
95
+ );
96
+ const unscopedFailure = unscopedResponses[0]?.result;
97
+ requireProbe(isRejectedApply(unscopedFailure), "unscoped applyEdit was not rejected");
98
+ requireProbe(readFileSync(unscoped.source, "utf-8") === "const before = 1;\n", "unscoped failure mutated bytes");
99
+
100
+ const concurrent = await harness.makeClient({
101
+ renameSteps: [
102
+ {
103
+ applyEdit: renameTextEdit("before", "after", { version: 1 }),
104
+ applyConcurrently: true,
105
+ renameResult: "same",
106
+ },
107
+ ],
108
+ });
109
+ let concurrentWrites = 0;
110
+ concurrent.client.setWorkspaceEditIo({
111
+ writeFile(path, content) {
112
+ concurrentWrites += 1;
113
+ writeFileSync(path, content, "utf-8");
114
+ },
115
+ });
116
+ await concurrent.client.openFile(concurrent.source);
117
+ const concurrentResult = await concurrent.client.rename(concurrent.source, 1, 6, "after");
118
+ const concurrentFailure = applyResponses(concurrent.events).find(isRejectedApply);
119
+ requireProbe(concurrentResult.apply.success, "the winning concurrent applyEdit did not complete");
120
+ requireProbe(concurrentWrites === 1, "concurrent applyEdit wrote more than once");
121
+ requireProbe(isRejectedApply(concurrentFailure), "concurrent applyEdit loser was not rejected");
122
+
123
+ const mismatched = await harness.makeClient({
124
+ renameSteps: [{ applyEdit: renameTextEdit("before", "after", { version: 9 }), renameResult: "same" }],
125
+ });
126
+ await mismatched.client.openFile(mismatched.source);
127
+ const mismatchedResult = await mismatched.client.rename(mismatched.source, 1, 6, "after");
128
+ const mismatchedFailure = applyResponses(mismatched.events).find(isRejectedApply);
129
+ requireProbe(!mismatchedResult.apply.success, "mismatched document version unexpectedly succeeded");
130
+ requireProbe(isRejectedApply(mismatchedFailure), "mismatched version was not rejected over applyEdit");
131
+ requireProbe(readFileSync(mismatched.source, "utf-8") === "const before = 1;\n", "version failure mutated bytes");
132
+
133
+ const preGate = await harness.makeClient({
134
+ renameSteps: [
135
+ {
136
+ applyEdit: renameTextEdit("before", "after", { version: 1 }),
137
+ applyDelayMs: 100,
138
+ renameResult: "same",
139
+ },
140
+ ],
141
+ });
142
+ const controller = new AbortController();
143
+ await preGate.client.openFile(preGate.source);
144
+ const pendingRename = preGate.client.rename(preGate.source, 1, 6, "after", controller.signal);
145
+ await waitForEventCount(
146
+ preGate.events,
147
+ (event) => event.type === "clientRequest" && event.method === "textDocument/rename",
148
+ 1,
149
+ );
150
+ controller.abort();
151
+ const preGateError = await pendingRename.then(
152
+ () => null,
153
+ (error: unknown) => error,
154
+ );
155
+ const preGateMessage = preGateError instanceof Error ? preGateError.message : String(preGateError);
156
+ const preGateFailure = {
157
+ success: false,
158
+ filesModified: [],
159
+ totalEdits: 0,
160
+ errors: [preGateMessage],
161
+ };
162
+ requireProbe(preGateError !== null, "pre-gate cancellation unexpectedly succeeded");
163
+ requireProbe(
164
+ preGateFailure.errors.some((message) => /cancel|abort/i.test(message)),
165
+ "pre-gate cancellation returned the wrong failure",
166
+ );
167
+ requireProbe(readFileSync(preGate.source, "utf-8") === "const before = 1;\n", "pre-gate cancellation mutated bytes");
168
+
169
+ const failureCases = {
170
+ unscoped: workspaceEditContractFailureEvidence(unscopedFailure, unscoped.workspace),
171
+ concurrent: workspaceEditContractFailureEvidence(concurrentFailure, concurrent.workspace),
172
+ mismatched: workspaceEditContractFailureEvidence(mismatchedFailure, mismatched.workspace),
173
+ preGate: workspaceEditContractFailureEvidence(preGateFailure, preGate.workspace),
174
+ };
175
+ writeFileSync(
176
+ outputPath,
177
+ `${JSON.stringify(
178
+ {
179
+ schemaVersion: 1,
180
+ success: successEvidence,
181
+ failureCases,
182
+ failureHashes: {
183
+ unscoped: failureCases.unscoped.sha256,
184
+ concurrent: failureCases.concurrent.sha256,
185
+ mismatched: failureCases.mismatched.sha256,
186
+ preGate: failureCases.preGate.sha256,
187
+ },
188
+ },
189
+ null,
190
+ 2,
191
+ )}\n`,
192
+ "utf-8",
193
+ );
194
+ } finally {
195
+ await harness.cleanup();
196
+ }