oh-my-opencode 4.17.1 → 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 (213) hide show
  1. package/.agents/skills/codex-qa/SKILL.md +2 -0
  2. package/.agents/skills/codex-qa/scripts/lsp-e2e.sh +3654 -0
  3. package/.agents/skills/opencode-qa/scripts/lsp-e2e.sh +3071 -0
  4. package/.agents/skills/work-with-pr/SKILL.md +16 -37
  5. package/.agents/skills/work-with-pr-workspace/evals/evals.json +3 -3
  6. package/.opencode/skills/work-with-pr/SKILL.md +16 -37
  7. package/.opencode/skills/work-with-pr-workspace/evals/evals.json +3 -3
  8. package/dist/cli/get-local-version/types.d.ts +1 -1
  9. package/dist/cli/index.js +498 -165
  10. package/dist/cli-node/index.js +498 -165
  11. package/dist/index.js +425 -392
  12. package/dist/skills/frontend/SKILL.md +1 -1
  13. package/dist/skills/frontend/references/design/README.md +9 -0
  14. package/dist/skills/frontend/references/design/design-system-architecture.md +4 -2
  15. package/dist/skills/frontend/references/design/layout-skill.md +107 -0
  16. package/dist/skills/programming/SKILL.md +12 -2
  17. package/package.json +17 -16
  18. package/packages/lsp-core/package.json +4 -0
  19. package/packages/lsp-core/src/index.ts +1 -0
  20. package/packages/lsp-core/src/lsp/cleanup-errors.test.ts +18 -0
  21. package/packages/lsp-core/src/lsp/cleanup-errors.ts +12 -3
  22. package/packages/lsp-core/src/lsp/client-diagnostics-freshness.integration.test.ts +261 -0
  23. package/packages/lsp-core/src/lsp/client-wrapper.test.ts +63 -0
  24. package/packages/lsp-core/src/lsp/client-wrapper.ts +35 -5
  25. package/packages/lsp-core/src/lsp/client.ts +262 -80
  26. package/packages/lsp-core/src/lsp/config-loader.ts +5 -17
  27. package/packages/lsp-core/src/lsp/connection.ts +12 -6
  28. package/packages/lsp-core/src/lsp/directory-diagnostics.test.ts +104 -0
  29. package/packages/lsp-core/src/lsp/directory-diagnostics.ts +60 -27
  30. package/packages/lsp-core/src/lsp/errors.ts +11 -0
  31. package/packages/lsp-core/src/lsp/fixtures/diagnostics-freshness-contract-probe.ts +283 -0
  32. package/packages/lsp-core/src/lsp/fixtures/workspace-edit-contract-probe.ts +196 -0
  33. package/packages/lsp-core/src/lsp/fixtures/workspace-edit-server.mjs +215 -0
  34. package/packages/lsp-core/src/lsp/formatters.ts +3 -0
  35. package/packages/lsp-core/src/lsp/json-rpc-connection-cancellation.test.ts +97 -0
  36. package/packages/lsp-core/src/lsp/json-rpc-connection.ts +73 -5
  37. package/packages/lsp-core/src/lsp/server-install-state.ts +3 -6
  38. package/packages/lsp-core/src/lsp/transport-protocol.ts +52 -0
  39. package/packages/lsp-core/src/lsp/transport.ts +96 -70
  40. package/packages/lsp-core/src/lsp/workspace-apply-edit-failure.ts +19 -0
  41. package/packages/lsp-core/src/lsp/workspace-apply-edit-lease.integration.test.ts +214 -0
  42. package/packages/lsp-core/src/lsp/workspace-apply-edit-sync.integration.test.ts +113 -0
  43. package/packages/lsp-core/src/lsp/workspace-apply-edit-test-support.ts +163 -0
  44. package/packages/lsp-core/src/lsp/workspace-apply-edit.integration.test.ts +163 -0
  45. package/packages/lsp-core/src/lsp/workspace-document-state.test.ts +67 -0
  46. package/packages/lsp-core/src/lsp/workspace-document-state.ts +368 -0
  47. package/packages/lsp-core/src/lsp/workspace-edit-adversarial.test.ts +113 -0
  48. package/packages/lsp-core/src/lsp/workspace-edit-commit.test.ts +140 -0
  49. package/packages/lsp-core/src/lsp/workspace-edit-commit.ts +220 -0
  50. package/packages/lsp-core/src/lsp/workspace-edit-contract-evidence.test.ts +56 -0
  51. package/packages/lsp-core/src/lsp/workspace-edit-contract-evidence.ts +30 -0
  52. package/packages/lsp-core/src/lsp/workspace-edit-fingerprint.ts +44 -0
  53. package/packages/lsp-core/src/lsp/workspace-edit-options.test.ts +147 -0
  54. package/packages/lsp-core/src/lsp/workspace-edit-parse-helpers.ts +59 -0
  55. package/packages/lsp-core/src/lsp/workspace-edit-parser.ts +130 -0
  56. package/packages/lsp-core/src/lsp/workspace-edit-path.ts +98 -0
  57. package/packages/lsp-core/src/lsp/workspace-edit-plan-types.ts +60 -0
  58. package/packages/lsp-core/src/lsp/workspace-edit-plan.ts +73 -0
  59. package/packages/lsp-core/src/lsp/workspace-edit-prevalidation.test.ts +174 -0
  60. package/packages/lsp-core/src/lsp/workspace-edit-resource-parser.ts +89 -0
  61. package/packages/lsp-core/src/lsp/workspace-edit-simulation.ts +183 -0
  62. package/packages/lsp-core/src/lsp/workspace-edit-snapshot.ts +53 -0
  63. package/packages/lsp-core/src/lsp/workspace-edit-text.ts +125 -0
  64. package/packages/lsp-core/src/lsp/workspace-edit-types.ts +121 -0
  65. package/packages/lsp-core/src/lsp/workspace-edit.characterization.test.ts +95 -0
  66. package/packages/lsp-core/src/lsp/workspace-edit.ts +49 -200
  67. package/packages/lsp-core/src/lsp/workspace-mutation-controller.ts +182 -0
  68. package/packages/lsp-core/src/mcp.ts +18 -7
  69. package/packages/lsp-core/src/missing-dependency-result.test.ts +105 -0
  70. package/packages/lsp-core/src/missing-dependency-result.ts +57 -0
  71. package/packages/lsp-core/src/post-edit/index.ts +1 -0
  72. package/packages/lsp-core/src/post-edit/orchestration.test.ts +157 -0
  73. package/packages/lsp-core/src/post-edit/orchestration.ts +178 -0
  74. package/packages/lsp-core/src/request-context.test.ts +171 -0
  75. package/packages/lsp-core/src/request-context.ts +222 -9
  76. package/packages/lsp-core/src/tool-surface.test.ts +4 -1
  77. package/packages/lsp-core/src/tools/diagnostics.ts +32 -13
  78. package/packages/lsp-core/src/tools/navigation.ts +12 -12
  79. package/packages/lsp-core/src/tools/rename.ts +10 -15
  80. package/packages/lsp-core/src/tools/symbols.ts +11 -11
  81. package/packages/lsp-core/src/tools/types.ts +2 -1
  82. package/packages/lsp-daemon/dist/cli.js +3114 -747
  83. package/packages/lsp-daemon/dist/client.d.ts +105 -0
  84. package/packages/lsp-daemon/dist/client.js +5851 -0
  85. package/packages/lsp-daemon/dist/daemon-client.d.ts +11 -6
  86. package/packages/lsp-daemon/dist/daemon-client.js +113 -30
  87. package/packages/lsp-daemon/dist/daemon-server.d.ts +1 -0
  88. package/packages/lsp-daemon/dist/daemon-server.js +40 -15
  89. package/packages/lsp-daemon/dist/ensure-daemon.d.ts +8 -7
  90. package/packages/lsp-daemon/dist/ensure-daemon.js +67 -44
  91. package/packages/lsp-daemon/dist/index.d.ts +2 -2
  92. package/packages/lsp-daemon/dist/index.js +2862 -754
  93. package/packages/lsp-daemon/dist/ipc-protocol.d.ts +46 -0
  94. package/packages/lsp-daemon/dist/ipc-protocol.js +187 -0
  95. package/packages/lsp-daemon/dist/lock.js +14 -4
  96. package/packages/lsp-daemon/dist/ownership.d.ts +49 -0
  97. package/packages/lsp-daemon/dist/ownership.js +168 -0
  98. package/packages/lsp-daemon/dist/paths.d.ts +33 -9
  99. package/packages/lsp-daemon/dist/paths.js +72 -33
  100. package/packages/lsp-daemon/dist/proxy.d.ts +3 -0
  101. package/packages/lsp-daemon/dist/proxy.js +54 -3
  102. package/packages/lsp-daemon/dist/request-routing.d.ts +7 -2
  103. package/packages/lsp-daemon/dist/request-routing.js +71 -22
  104. package/packages/lsp-daemon/dist/run-daemon.js +9 -2
  105. package/packages/lsp-daemon/dist/runtime-contract.d.ts +21 -0
  106. package/packages/lsp-daemon/dist/runtime-contract.js +58 -0
  107. package/packages/lsp-daemon/dist/socket-jsonrpc.js +6 -1
  108. package/packages/lsp-daemon/package.json +12 -3
  109. package/packages/lsp-tools-mcp/dist/cli.js +2115 -442
  110. package/packages/lsp-tools-mcp/dist/lsp/manager.js +1741 -148
  111. package/packages/lsp-tools-mcp/dist/mcp.js +2127 -454
  112. package/packages/lsp-tools-mcp/dist/request-context.js +176 -6
  113. package/packages/lsp-tools-mcp/dist/tools.js +2118 -446
  114. package/packages/omo-codex/plugin/.codex-plugin/plugin.json +1 -1
  115. package/packages/omo-codex/plugin/components/bootstrap/hooks/hooks.json +1 -1
  116. package/packages/omo-codex/plugin/components/bootstrap/package.json +1 -1
  117. package/packages/omo-codex/plugin/components/codegraph/package.json +1 -1
  118. package/packages/omo-codex/plugin/components/comment-checker/hooks/hooks.json +1 -1
  119. package/packages/omo-codex/plugin/components/comment-checker/package.json +1 -1
  120. package/packages/omo-codex/plugin/components/git-bash/hooks/hooks.json +2 -2
  121. package/packages/omo-codex/plugin/components/git-bash/package.json +1 -1
  122. package/packages/omo-codex/plugin/components/lazycodex-executor-verify/hooks/hooks.json +1 -1
  123. package/packages/omo-codex/plugin/components/lazycodex-executor-verify/package.json +1 -1
  124. package/packages/omo-codex/plugin/components/lsp/dist/.omo-runtime-manifest.json +55 -0
  125. package/packages/omo-codex/plugin/components/lsp/dist/cli.js +2959 -944
  126. package/packages/omo-codex/plugin/components/lsp/dist/codex-hook-cli.js +0 -4
  127. package/packages/omo-codex/plugin/components/lsp/dist/codex-hook.d.ts +5 -2
  128. package/packages/omo-codex/plugin/components/lsp/dist/codex-hook.js +41 -62
  129. package/packages/omo-codex/plugin/components/lsp/dist/daemon-cli-path.d.ts +1 -1
  130. package/packages/omo-codex/plugin/components/lsp/dist/daemon-cli-path.js +24 -15
  131. package/packages/omo-codex/plugin/components/lsp/dist/lsp-session-state.d.ts +3 -7
  132. package/packages/omo-codex/plugin/components/lsp/dist/lsp-session-state.js +23 -49
  133. package/packages/omo-codex/plugin/components/lsp/hooks/hooks.json +2 -2
  134. package/packages/omo-codex/plugin/components/lsp/package.json +3 -2
  135. package/packages/omo-codex/plugin/components/lsp/scripts/build-lsp-daemon.mjs +31 -1
  136. package/packages/omo-codex/plugin/components/lsp/scripts/build-lsp-daemon.test.mjs +76 -0
  137. package/packages/omo-codex/plugin/components/lsp/scripts/build-runtime.mjs +201 -0
  138. package/packages/omo-codex/plugin/components/lsp/scripts/build-runtime.test.mjs +55 -0
  139. package/packages/omo-codex/plugin/components/lsp/src/codex-hook-cli.ts +0 -4
  140. package/packages/omo-codex/plugin/components/lsp/src/codex-hook.ts +49 -71
  141. package/packages/omo-codex/plugin/components/lsp/src/daemon-cli-path.ts +26 -15
  142. package/packages/omo-codex/plugin/components/lsp/src/lsp-session-state.ts +26 -64
  143. package/packages/omo-codex/plugin/components/lsp/test/codex-hook-unavailable.test.ts +16 -17
  144. package/packages/omo-codex/plugin/components/lsp/test/codex-hook.test.ts +30 -4
  145. package/packages/omo-codex/plugin/components/lsp/test/package-smoke.test.ts +19 -5
  146. package/packages/omo-codex/plugin/components/rules/bundled-rules/hephaestus/gpt-5.5.md +1 -1
  147. package/packages/omo-codex/plugin/components/rules/bundled-rules/hephaestus/gpt-5.6.md +8 -6
  148. package/packages/omo-codex/plugin/components/rules/hooks/hooks.json +4 -4
  149. package/packages/omo-codex/plugin/components/rules/package.json +1 -1
  150. package/packages/omo-codex/plugin/components/start-work-continuation/directive.md +1 -1
  151. package/packages/omo-codex/plugin/components/start-work-continuation/hooks/hooks.json +2 -2
  152. package/packages/omo-codex/plugin/components/start-work-continuation/package.json +1 -1
  153. package/packages/omo-codex/plugin/components/teammode/hooks/hooks.json +1 -1
  154. package/packages/omo-codex/plugin/components/teammode/package.json +1 -1
  155. package/packages/omo-codex/plugin/components/telemetry/hooks/hooks.json +1 -1
  156. package/packages/omo-codex/plugin/components/telemetry/package.json +1 -1
  157. package/packages/omo-codex/plugin/components/ultrawork/directive.md +37 -10
  158. package/packages/omo-codex/plugin/components/ultrawork/hooks/hooks.json +1 -1
  159. package/packages/omo-codex/plugin/components/ultrawork/package.json +1 -1
  160. package/packages/omo-codex/plugin/components/ultrawork/skills/ultrawork/SKILL.md +37 -10
  161. package/packages/omo-codex/plugin/components/ulw-loop/directive.md +37 -10
  162. package/packages/omo-codex/plugin/components/ulw-loop/hooks/hooks.json +4 -4
  163. package/packages/omo-codex/plugin/components/ulw-loop/package.json +1 -1
  164. package/packages/omo-codex/plugin/components/ulw-loop/skills/ulw-loop/SKILL.md +2 -2
  165. package/packages/omo-codex/plugin/components/ulw-loop/skills/ulw-loop/references/full-workflow.md +10 -9
  166. package/packages/omo-codex/plugin/hooks/post-compact-resetting-git-bash-mcp-reminder.json +1 -1
  167. package/packages/omo-codex/plugin/hooks/post-compact-resetting-lsp-diagnostics-cache.json +1 -1
  168. package/packages/omo-codex/plugin/hooks/post-compact-resetting-project-rule-cache.json +1 -1
  169. package/packages/omo-codex/plugin/hooks/post-tool-use-checking-codegraph-init-guidance.json +1 -1
  170. package/packages/omo-codex/plugin/hooks/post-tool-use-checking-comments.json +1 -1
  171. package/packages/omo-codex/plugin/hooks/post-tool-use-checking-lsp-diagnostics.json +1 -1
  172. package/packages/omo-codex/plugin/hooks/post-tool-use-checking-thread-title-hygiene.json +1 -1
  173. package/packages/omo-codex/plugin/hooks/post-tool-use-matching-project-rules.json +1 -1
  174. package/packages/omo-codex/plugin/hooks/pre-tool-use-enforcing-unlimited-goal-budget.json +1 -1
  175. package/packages/omo-codex/plugin/hooks/pre-tool-use-guarding-ulw-loop-spawns.json +1 -1
  176. package/packages/omo-codex/plugin/hooks/pre-tool-use-recommending-git-bash-mcp.json +1 -1
  177. package/packages/omo-codex/plugin/hooks/session-start-checking-auto-update.json +1 -1
  178. package/packages/omo-codex/plugin/hooks/session-start-checking-bootstrap-provisioning.json +1 -1
  179. package/packages/omo-codex/plugin/hooks/session-start-checking-codegraph-bootstrap.json +1 -1
  180. package/packages/omo-codex/plugin/hooks/session-start-loading-project-rules.json +1 -1
  181. package/packages/omo-codex/plugin/hooks/session-start-recording-session-telemetry.json +1 -1
  182. package/packages/omo-codex/plugin/hooks/stop-checking-start-work-continuation.json +1 -1
  183. package/packages/omo-codex/plugin/hooks/stop-checking-ulw-loop-resume.json +1 -1
  184. package/packages/omo-codex/plugin/hooks/subagent-stop-checking-start-work-continuation.json +1 -1
  185. package/packages/omo-codex/plugin/hooks/subagent-stop-verifying-lazycodex-executor-evidence.json +1 -1
  186. package/packages/omo-codex/plugin/hooks/user-prompt-submit-checking-ultrawork-trigger.json +1 -1
  187. package/packages/omo-codex/plugin/hooks/user-prompt-submit-checking-ulw-loop-steering.json +1 -1
  188. package/packages/omo-codex/plugin/hooks/user-prompt-submit-loading-project-rules.json +1 -1
  189. package/packages/omo-codex/plugin/package-lock.json +26 -14
  190. package/packages/omo-codex/plugin/package.json +1 -1
  191. package/packages/omo-codex/plugin/scripts/build-bundled-mcp-runtimes.mjs +2 -3
  192. package/packages/omo-codex/plugin/scripts/build-components.mjs +13 -1
  193. package/packages/omo-codex/plugin/scripts/sync-skills.mjs +1 -1
  194. package/packages/omo-codex/plugin/skills/frontend/SKILL.md +1 -1
  195. package/packages/omo-codex/plugin/skills/frontend/references/design/README.md +9 -0
  196. package/packages/omo-codex/plugin/skills/frontend/references/design/design-system-architecture.md +4 -2
  197. package/packages/omo-codex/plugin/skills/frontend/references/design/layout-skill.md +107 -0
  198. package/packages/omo-codex/plugin/skills/programming/SKILL.md +12 -2
  199. package/packages/omo-codex/plugin/skills/start-work/SKILL.md +1 -1
  200. package/packages/omo-codex/plugin/skills/ultrawork/SKILL.md +37 -10
  201. package/packages/omo-codex/plugin/skills/ulw-loop/SKILL.md +2 -2
  202. package/packages/omo-codex/plugin/skills/ulw-loop/references/full-workflow.md +10 -9
  203. package/packages/omo-codex/plugin/test/aggregate-build.test.mjs +8 -0
  204. package/packages/omo-codex/plugin/test/component-bundled-cli.test.mjs +128 -15
  205. package/packages/omo-codex/plugin/test/install-time-build-runtime.test.mjs +10 -0
  206. package/packages/omo-codex/plugin/test/lsp-prebuild-layouts.test.mjs +2 -0
  207. package/packages/omo-codex/plugin/test/sync-skills-test-support.mjs +1 -1
  208. package/packages/omo-codex/scripts/install-dist/install-local.mjs +351 -74
  209. package/packages/shared-skills/skills/frontend/SKILL.md +1 -1
  210. package/packages/shared-skills/skills/frontend/references/design/README.md +9 -0
  211. package/packages/shared-skills/skills/frontend/references/design/design-system-architecture.md +4 -2
  212. package/packages/shared-skills/skills/frontend/references/design/layout-skill.md +107 -0
  213. package/packages/shared-skills/skills/programming/SKILL.md +12 -2
@@ -2,12 +2,19 @@ import { pathToFileURL } from "node:url";
2
2
 
3
3
  import { LspClientTransport } from "./transport.js";
4
4
 
5
- const INITIALIZE_SETTLE_MS = 300;
5
+ interface InitializeCapabilities {
6
+ readonly diagnosticProvider?: unknown;
7
+ }
8
+
9
+ function supportsDiagnosticPull(capabilities: InitializeCapabilities | undefined): boolean {
10
+ if (capabilities === undefined) return false;
11
+ return Object.hasOwn(capabilities, "diagnosticProvider");
12
+ }
6
13
 
7
14
  export class LspClientConnection extends LspClientTransport {
8
15
  async initialize(): Promise<void> {
9
16
  const rootUri = pathToFileURL(this.root).href;
10
- await this.sendRequest(
17
+ const result = await this.sendRequest<{ readonly capabilities?: InitializeCapabilities }>(
11
18
  "initialize",
12
19
  {
13
20
  processId: process.pid,
@@ -24,7 +31,6 @@ export class LspClientConnection extends LspClientTransport {
24
31
  rename: {
25
32
  prepareSupport: true,
26
33
  prepareSupportDefaultBehavior: 1,
27
- honorsChangeAnnotations: true,
28
34
  },
29
35
  codeAction: {
30
36
  codeActionLiteralSupport: {
@@ -53,9 +59,10 @@ export class LspClientConnection extends LspClientTransport {
53
59
  symbol: {},
54
60
  workspaceFolders: true,
55
61
  configuration: true,
56
- applyEdit: true,
62
+ ...(this.hasWorkspaceApplyEditHandler() ? { applyEdit: true } : {}),
57
63
  workspaceEdit: {
58
64
  documentChanges: true,
65
+ resourceOperations: ["create", "rename", "delete"],
59
66
  },
60
67
  },
61
68
  },
@@ -63,11 +70,10 @@ export class LspClientConnection extends LspClientTransport {
63
70
  },
64
71
  { timeoutMs: this.initializeTimeoutMs },
65
72
  );
73
+ this.setDiagnosticPullSupported(supportsDiagnosticPull(result?.capabilities));
66
74
  await this.sendNotification("initialized");
67
75
  await this.sendNotification("workspace/didChangeConfiguration", {
68
76
  settings: { json: { validate: { enable: true } } },
69
77
  });
70
- // Some servers accept initialized before their diagnostics/indexing handlers are ready.
71
- await new Promise((r) => setTimeout(r, INITIALIZE_SETTLE_MS));
72
78
  }
73
79
  }
@@ -0,0 +1,104 @@
1
+ import { mkdtempSync, rmSync } from "node:fs";
2
+ import { tmpdir } from "node:os";
3
+ import { join } from "node:path";
4
+
5
+ import { describe, expect, it } from "bun:test";
6
+
7
+ import { aggregateDiagnosticsForDirectory } from "./directory-diagnostics.js";
8
+ import type { LspClient } from "./client.js";
9
+ import type { Diagnostic, ResolvedServer } from "./types.js";
10
+
11
+ describe("directory diagnostics", () => {
12
+ it("#given multiple files and one per-file failure #when directory diagnostics run #then work stays bounded and file failures stay structured", async () => {
13
+ const workspace = mkdtempSync(join(tmpdir(), "lsp-directory-diag-"));
14
+ const active: string[] = [];
15
+ let maxActive = 0;
16
+ const diagnosticsByFile = new Map<string, Diagnostic[] | Error>([
17
+ [join(workspace, "a.ts"), [diagnostic("a")]],
18
+ [join(workspace, "b.ts"), new Error("b failed")],
19
+ [join(workspace, "c.ts"), [diagnostic("c")]],
20
+ [join(workspace, "d.ts"), [diagnostic("d")]],
21
+ [join(workspace, "e.ts"), [diagnostic("e")]],
22
+ ]);
23
+ const client = {
24
+ async diagnostics(filePath: string) {
25
+ active.push(filePath);
26
+ maxActive = Math.max(maxActive, active.length);
27
+ await Promise.resolve();
28
+ active.splice(active.indexOf(filePath), 1);
29
+ const entry = diagnosticsByFile.get(filePath);
30
+ if (entry instanceof Error) throw entry;
31
+ return { items: entry ?? [] };
32
+ },
33
+ } as Pick<LspClient, "diagnostics"> as LspClient;
34
+ const server: ResolvedServer = { id: "typescript", command: ["typescript-language-server"], extensions: [".ts"], priority: 1 };
35
+ const manager = {
36
+ async getClient() {
37
+ return client;
38
+ },
39
+ releaseClient() {},
40
+ };
41
+
42
+ try {
43
+ const result = await aggregateDiagnosticsForDirectory(workspace, ".ts", "all", 4, {
44
+ manager: manager as never,
45
+ listFiles: () => [...diagnosticsByFile.keys()],
46
+ workspaceRoot: workspace,
47
+ server,
48
+ maxConcurrency: 2,
49
+ });
50
+
51
+ expect(maxActive).toBeLessThanOrEqual(2);
52
+ expect(result.fileFailures).toEqual([{ file: join(workspace, "b.ts"), error: "b failed" }]);
53
+ expect(result.totalDiagnostics).toBe(3);
54
+ expect(result.output).toContain("File processing errors:");
55
+ } finally {
56
+ rmSync(workspace, { recursive: true, force: true });
57
+ }
58
+ });
59
+
60
+ it("#given directory diagnostics are aborted after one file #when the worker loops #then no later file is scheduled", async () => {
61
+ const workspace = mkdtempSync(join(tmpdir(), "lsp-directory-diag-cancel-"));
62
+ const controller = new AbortController();
63
+ const visited: string[] = [];
64
+ const firstFile = join(workspace, "a.ts");
65
+ const files = [firstFile, join(workspace, "b.ts"), join(workspace, "c.ts")];
66
+ const client = {
67
+ async diagnostics(filePath: string) {
68
+ visited.push(filePath);
69
+ controller.abort();
70
+ return { items: [] };
71
+ },
72
+ } as Pick<LspClient, "diagnostics"> as LspClient;
73
+ const server: ResolvedServer = { id: "typescript", command: ["typescript-language-server"], extensions: [".ts"], priority: 1 };
74
+ const manager = {
75
+ async getClient() {
76
+ return client;
77
+ },
78
+ releaseClient() {},
79
+ };
80
+
81
+ try {
82
+ const result = await aggregateDiagnosticsForDirectory(workspace, ".ts", "all", 3, {
83
+ manager: manager as never,
84
+ listFiles: () => files,
85
+ workspaceRoot: workspace,
86
+ server,
87
+ maxConcurrency: 1,
88
+ signal: controller.signal,
89
+ });
90
+
91
+ expect(result.totalDiagnostics).toBe(0);
92
+ expect(visited).toEqual([firstFile]);
93
+ } finally {
94
+ rmSync(workspace, { recursive: true, force: true });
95
+ }
96
+ });
97
+ });
98
+
99
+ function diagnostic(message: string): Diagnostic {
100
+ return {
101
+ range: { start: { line: 0, character: 0 }, end: { line: 0, character: 1 } },
102
+ message,
103
+ };
104
+ }
@@ -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
+ }