agentpool 2.1.9__py3-none-any.whl → 2.5.0__py3-none-any.whl

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 (311) hide show
  1. acp/__init__.py +13 -4
  2. acp/acp_requests.py +20 -77
  3. acp/agent/connection.py +8 -0
  4. acp/agent/implementations/debug_server/debug_server.py +6 -2
  5. acp/agent/protocol.py +6 -0
  6. acp/bridge/README.md +15 -2
  7. acp/bridge/__init__.py +3 -2
  8. acp/bridge/__main__.py +60 -19
  9. acp/bridge/ws_server.py +173 -0
  10. acp/bridge/ws_server_cli.py +89 -0
  11. acp/client/connection.py +38 -29
  12. acp/client/implementations/default_client.py +3 -2
  13. acp/client/implementations/headless_client.py +2 -2
  14. acp/connection.py +2 -2
  15. acp/notifications.py +20 -50
  16. acp/schema/__init__.py +2 -0
  17. acp/schema/agent_responses.py +21 -0
  18. acp/schema/client_requests.py +3 -3
  19. acp/schema/session_state.py +63 -29
  20. acp/stdio.py +39 -9
  21. acp/task/supervisor.py +2 -2
  22. acp/transports.py +362 -2
  23. acp/utils.py +17 -4
  24. agentpool/__init__.py +6 -1
  25. agentpool/agents/__init__.py +2 -0
  26. agentpool/agents/acp_agent/acp_agent.py +407 -277
  27. agentpool/agents/acp_agent/acp_converters.py +196 -38
  28. agentpool/agents/acp_agent/client_handler.py +191 -26
  29. agentpool/agents/acp_agent/session_state.py +17 -6
  30. agentpool/agents/agent.py +607 -572
  31. agentpool/agents/agui_agent/__init__.py +0 -2
  32. agentpool/agents/agui_agent/agui_agent.py +176 -110
  33. agentpool/agents/agui_agent/agui_converters.py +0 -131
  34. agentpool/agents/agui_agent/helpers.py +3 -4
  35. agentpool/agents/base_agent.py +632 -17
  36. agentpool/agents/claude_code_agent/FORKING.md +191 -0
  37. agentpool/agents/claude_code_agent/__init__.py +13 -1
  38. agentpool/agents/claude_code_agent/claude_code_agent.py +1058 -291
  39. agentpool/agents/claude_code_agent/converters.py +74 -143
  40. agentpool/agents/claude_code_agent/history.py +474 -0
  41. agentpool/agents/claude_code_agent/models.py +77 -0
  42. agentpool/agents/claude_code_agent/static_info.py +100 -0
  43. agentpool/agents/claude_code_agent/usage.py +242 -0
  44. agentpool/agents/context.py +40 -0
  45. agentpool/agents/events/__init__.py +24 -0
  46. agentpool/agents/events/builtin_handlers.py +67 -1
  47. agentpool/agents/events/event_emitter.py +32 -2
  48. agentpool/agents/events/events.py +104 -3
  49. agentpool/agents/events/infer_info.py +145 -0
  50. agentpool/agents/events/processors.py +254 -0
  51. agentpool/agents/interactions.py +41 -6
  52. agentpool/agents/modes.py +67 -0
  53. agentpool/agents/slashed_agent.py +5 -4
  54. agentpool/agents/tool_call_accumulator.py +213 -0
  55. agentpool/agents/tool_wrapping.py +18 -6
  56. agentpool/common_types.py +56 -21
  57. agentpool/config_resources/__init__.py +38 -1
  58. agentpool/config_resources/acp_assistant.yml +2 -2
  59. agentpool/config_resources/agents.yml +3 -0
  60. agentpool/config_resources/agents_template.yml +1 -0
  61. agentpool/config_resources/claude_code_agent.yml +10 -6
  62. agentpool/config_resources/external_acp_agents.yml +2 -1
  63. agentpool/delegation/base_team.py +4 -30
  64. agentpool/delegation/pool.py +136 -289
  65. agentpool/delegation/team.py +58 -57
  66. agentpool/delegation/teamrun.py +51 -55
  67. agentpool/diagnostics/__init__.py +53 -0
  68. agentpool/diagnostics/lsp_manager.py +1593 -0
  69. agentpool/diagnostics/lsp_proxy.py +41 -0
  70. agentpool/diagnostics/lsp_proxy_script.py +229 -0
  71. agentpool/diagnostics/models.py +398 -0
  72. agentpool/functional/run.py +10 -4
  73. agentpool/mcp_server/__init__.py +0 -2
  74. agentpool/mcp_server/client.py +76 -32
  75. agentpool/mcp_server/conversions.py +54 -13
  76. agentpool/mcp_server/manager.py +34 -54
  77. agentpool/mcp_server/registries/official_registry_client.py +35 -1
  78. agentpool/mcp_server/tool_bridge.py +186 -139
  79. agentpool/messaging/__init__.py +0 -2
  80. agentpool/messaging/compaction.py +72 -197
  81. agentpool/messaging/connection_manager.py +11 -10
  82. agentpool/messaging/event_manager.py +5 -5
  83. agentpool/messaging/message_container.py +6 -30
  84. agentpool/messaging/message_history.py +99 -8
  85. agentpool/messaging/messagenode.py +52 -14
  86. agentpool/messaging/messages.py +54 -35
  87. agentpool/messaging/processing.py +12 -22
  88. agentpool/models/__init__.py +1 -1
  89. agentpool/models/acp_agents/base.py +6 -24
  90. agentpool/models/acp_agents/mcp_capable.py +126 -157
  91. agentpool/models/acp_agents/non_mcp.py +129 -95
  92. agentpool/models/agents.py +98 -76
  93. agentpool/models/agui_agents.py +1 -1
  94. agentpool/models/claude_code_agents.py +144 -19
  95. agentpool/models/file_parsing.py +0 -1
  96. agentpool/models/manifest.py +113 -50
  97. agentpool/prompts/conversion_manager.py +1 -1
  98. agentpool/prompts/prompts.py +5 -2
  99. agentpool/repomap.py +1 -1
  100. agentpool/resource_providers/__init__.py +11 -1
  101. agentpool/resource_providers/aggregating.py +56 -5
  102. agentpool/resource_providers/base.py +70 -4
  103. agentpool/resource_providers/codemode/code_executor.py +72 -5
  104. agentpool/resource_providers/codemode/helpers.py +2 -2
  105. agentpool/resource_providers/codemode/provider.py +64 -12
  106. agentpool/resource_providers/codemode/remote_mcp_execution.py +2 -2
  107. agentpool/resource_providers/codemode/remote_provider.py +9 -12
  108. agentpool/resource_providers/filtering.py +3 -1
  109. agentpool/resource_providers/mcp_provider.py +89 -12
  110. agentpool/resource_providers/plan_provider.py +228 -46
  111. agentpool/resource_providers/pool.py +7 -3
  112. agentpool/resource_providers/resource_info.py +111 -0
  113. agentpool/resource_providers/static.py +4 -2
  114. agentpool/sessions/__init__.py +4 -1
  115. agentpool/sessions/manager.py +33 -5
  116. agentpool/sessions/models.py +59 -6
  117. agentpool/sessions/protocol.py +28 -0
  118. agentpool/sessions/session.py +11 -55
  119. agentpool/skills/registry.py +13 -8
  120. agentpool/storage/manager.py +572 -49
  121. agentpool/talk/registry.py +4 -4
  122. agentpool/talk/talk.py +9 -10
  123. agentpool/testing.py +538 -20
  124. agentpool/tool_impls/__init__.py +6 -0
  125. agentpool/tool_impls/agent_cli/__init__.py +42 -0
  126. agentpool/tool_impls/agent_cli/tool.py +95 -0
  127. agentpool/tool_impls/bash/__init__.py +64 -0
  128. agentpool/tool_impls/bash/helpers.py +35 -0
  129. agentpool/tool_impls/bash/tool.py +171 -0
  130. agentpool/tool_impls/delete_path/__init__.py +70 -0
  131. agentpool/tool_impls/delete_path/tool.py +142 -0
  132. agentpool/tool_impls/download_file/__init__.py +80 -0
  133. agentpool/tool_impls/download_file/tool.py +183 -0
  134. agentpool/tool_impls/execute_code/__init__.py +55 -0
  135. agentpool/tool_impls/execute_code/tool.py +163 -0
  136. agentpool/tool_impls/grep/__init__.py +80 -0
  137. agentpool/tool_impls/grep/tool.py +200 -0
  138. agentpool/tool_impls/list_directory/__init__.py +73 -0
  139. agentpool/tool_impls/list_directory/tool.py +197 -0
  140. agentpool/tool_impls/question/__init__.py +42 -0
  141. agentpool/tool_impls/question/tool.py +127 -0
  142. agentpool/tool_impls/read/__init__.py +104 -0
  143. agentpool/tool_impls/read/tool.py +305 -0
  144. agentpool/tools/__init__.py +2 -1
  145. agentpool/tools/base.py +114 -34
  146. agentpool/tools/manager.py +57 -1
  147. agentpool/ui/base.py +2 -2
  148. agentpool/ui/mock_provider.py +2 -2
  149. agentpool/ui/stdlib_provider.py +2 -2
  150. agentpool/utils/file_watcher.py +269 -0
  151. agentpool/utils/identifiers.py +121 -0
  152. agentpool/utils/pydantic_ai_helpers.py +46 -0
  153. agentpool/utils/streams.py +616 -2
  154. agentpool/utils/subprocess_utils.py +155 -0
  155. agentpool/utils/token_breakdown.py +461 -0
  156. agentpool/vfs_registry.py +7 -2
  157. {agentpool-2.1.9.dist-info → agentpool-2.5.0.dist-info}/METADATA +41 -27
  158. agentpool-2.5.0.dist-info/RECORD +579 -0
  159. {agentpool-2.1.9.dist-info → agentpool-2.5.0.dist-info}/WHEEL +1 -1
  160. agentpool_cli/__main__.py +24 -0
  161. agentpool_cli/create.py +1 -1
  162. agentpool_cli/serve_acp.py +100 -21
  163. agentpool_cli/serve_agui.py +87 -0
  164. agentpool_cli/serve_opencode.py +119 -0
  165. agentpool_cli/ui.py +557 -0
  166. agentpool_commands/__init__.py +42 -5
  167. agentpool_commands/agents.py +75 -2
  168. agentpool_commands/history.py +62 -0
  169. agentpool_commands/mcp.py +176 -0
  170. agentpool_commands/models.py +56 -3
  171. agentpool_commands/pool.py +260 -0
  172. agentpool_commands/session.py +1 -1
  173. agentpool_commands/text_sharing/__init__.py +119 -0
  174. agentpool_commands/text_sharing/base.py +123 -0
  175. agentpool_commands/text_sharing/github_gist.py +80 -0
  176. agentpool_commands/text_sharing/opencode.py +462 -0
  177. agentpool_commands/text_sharing/paste_rs.py +59 -0
  178. agentpool_commands/text_sharing/pastebin.py +116 -0
  179. agentpool_commands/text_sharing/shittycodingagent.py +112 -0
  180. agentpool_commands/tools.py +57 -0
  181. agentpool_commands/utils.py +80 -30
  182. agentpool_config/__init__.py +30 -2
  183. agentpool_config/agentpool_tools.py +498 -0
  184. agentpool_config/builtin_tools.py +77 -22
  185. agentpool_config/commands.py +24 -1
  186. agentpool_config/compaction.py +258 -0
  187. agentpool_config/converters.py +1 -1
  188. agentpool_config/event_handlers.py +42 -0
  189. agentpool_config/events.py +1 -1
  190. agentpool_config/forward_targets.py +1 -4
  191. agentpool_config/jinja.py +3 -3
  192. agentpool_config/mcp_server.py +132 -6
  193. agentpool_config/nodes.py +1 -1
  194. agentpool_config/observability.py +44 -0
  195. agentpool_config/session.py +0 -3
  196. agentpool_config/storage.py +82 -38
  197. agentpool_config/task.py +3 -3
  198. agentpool_config/tools.py +11 -22
  199. agentpool_config/toolsets.py +109 -233
  200. agentpool_server/a2a_server/agent_worker.py +307 -0
  201. agentpool_server/a2a_server/server.py +23 -18
  202. agentpool_server/acp_server/acp_agent.py +234 -181
  203. agentpool_server/acp_server/commands/acp_commands.py +151 -156
  204. agentpool_server/acp_server/commands/docs_commands/fetch_repo.py +18 -17
  205. agentpool_server/acp_server/event_converter.py +651 -0
  206. agentpool_server/acp_server/input_provider.py +53 -10
  207. agentpool_server/acp_server/server.py +24 -90
  208. agentpool_server/acp_server/session.py +173 -331
  209. agentpool_server/acp_server/session_manager.py +8 -34
  210. agentpool_server/agui_server/server.py +3 -1
  211. agentpool_server/mcp_server/server.py +5 -2
  212. agentpool_server/opencode_server/.rules +95 -0
  213. agentpool_server/opencode_server/ENDPOINTS.md +401 -0
  214. agentpool_server/opencode_server/OPENCODE_UI_TOOLS_COMPLETE.md +202 -0
  215. agentpool_server/opencode_server/__init__.py +19 -0
  216. agentpool_server/opencode_server/command_validation.py +172 -0
  217. agentpool_server/opencode_server/converters.py +975 -0
  218. agentpool_server/opencode_server/dependencies.py +24 -0
  219. agentpool_server/opencode_server/input_provider.py +421 -0
  220. agentpool_server/opencode_server/models/__init__.py +250 -0
  221. agentpool_server/opencode_server/models/agent.py +53 -0
  222. agentpool_server/opencode_server/models/app.py +72 -0
  223. agentpool_server/opencode_server/models/base.py +26 -0
  224. agentpool_server/opencode_server/models/common.py +23 -0
  225. agentpool_server/opencode_server/models/config.py +37 -0
  226. agentpool_server/opencode_server/models/events.py +821 -0
  227. agentpool_server/opencode_server/models/file.py +88 -0
  228. agentpool_server/opencode_server/models/mcp.py +44 -0
  229. agentpool_server/opencode_server/models/message.py +179 -0
  230. agentpool_server/opencode_server/models/parts.py +323 -0
  231. agentpool_server/opencode_server/models/provider.py +81 -0
  232. agentpool_server/opencode_server/models/pty.py +43 -0
  233. agentpool_server/opencode_server/models/question.py +56 -0
  234. agentpool_server/opencode_server/models/session.py +111 -0
  235. agentpool_server/opencode_server/routes/__init__.py +29 -0
  236. agentpool_server/opencode_server/routes/agent_routes.py +473 -0
  237. agentpool_server/opencode_server/routes/app_routes.py +202 -0
  238. agentpool_server/opencode_server/routes/config_routes.py +302 -0
  239. agentpool_server/opencode_server/routes/file_routes.py +571 -0
  240. agentpool_server/opencode_server/routes/global_routes.py +94 -0
  241. agentpool_server/opencode_server/routes/lsp_routes.py +319 -0
  242. agentpool_server/opencode_server/routes/message_routes.py +761 -0
  243. agentpool_server/opencode_server/routes/permission_routes.py +63 -0
  244. agentpool_server/opencode_server/routes/pty_routes.py +300 -0
  245. agentpool_server/opencode_server/routes/question_routes.py +128 -0
  246. agentpool_server/opencode_server/routes/session_routes.py +1276 -0
  247. agentpool_server/opencode_server/routes/tui_routes.py +139 -0
  248. agentpool_server/opencode_server/server.py +475 -0
  249. agentpool_server/opencode_server/state.py +151 -0
  250. agentpool_server/opencode_server/time_utils.py +8 -0
  251. agentpool_storage/__init__.py +12 -0
  252. agentpool_storage/base.py +184 -2
  253. agentpool_storage/claude_provider/ARCHITECTURE.md +433 -0
  254. agentpool_storage/claude_provider/__init__.py +42 -0
  255. agentpool_storage/claude_provider/provider.py +1089 -0
  256. agentpool_storage/file_provider.py +278 -15
  257. agentpool_storage/memory_provider.py +193 -12
  258. agentpool_storage/models.py +3 -0
  259. agentpool_storage/opencode_provider/ARCHITECTURE.md +386 -0
  260. agentpool_storage/opencode_provider/__init__.py +16 -0
  261. agentpool_storage/opencode_provider/helpers.py +414 -0
  262. agentpool_storage/opencode_provider/provider.py +895 -0
  263. agentpool_storage/project_store.py +325 -0
  264. agentpool_storage/session_store.py +26 -6
  265. agentpool_storage/sql_provider/__init__.py +4 -2
  266. agentpool_storage/sql_provider/models.py +48 -0
  267. agentpool_storage/sql_provider/sql_provider.py +269 -3
  268. agentpool_storage/sql_provider/utils.py +12 -13
  269. agentpool_storage/zed_provider/__init__.py +16 -0
  270. agentpool_storage/zed_provider/helpers.py +281 -0
  271. agentpool_storage/zed_provider/models.py +130 -0
  272. agentpool_storage/zed_provider/provider.py +442 -0
  273. agentpool_storage/zed_provider.py +803 -0
  274. agentpool_toolsets/__init__.py +0 -2
  275. agentpool_toolsets/builtin/__init__.py +2 -12
  276. agentpool_toolsets/builtin/code.py +96 -57
  277. agentpool_toolsets/builtin/debug.py +118 -48
  278. agentpool_toolsets/builtin/execution_environment.py +115 -230
  279. agentpool_toolsets/builtin/file_edit/file_edit.py +115 -7
  280. agentpool_toolsets/builtin/skills.py +9 -4
  281. agentpool_toolsets/builtin/subagent_tools.py +64 -51
  282. agentpool_toolsets/builtin/workers.py +4 -2
  283. agentpool_toolsets/composio_toolset.py +2 -2
  284. agentpool_toolsets/entry_points.py +3 -1
  285. agentpool_toolsets/fsspec_toolset/__init__.py +13 -1
  286. agentpool_toolsets/fsspec_toolset/diagnostics.py +860 -73
  287. agentpool_toolsets/fsspec_toolset/grep.py +99 -7
  288. agentpool_toolsets/fsspec_toolset/helpers.py +3 -2
  289. agentpool_toolsets/fsspec_toolset/image_utils.py +161 -0
  290. agentpool_toolsets/fsspec_toolset/toolset.py +500 -95
  291. agentpool_toolsets/mcp_discovery/__init__.py +5 -0
  292. agentpool_toolsets/mcp_discovery/data/mcp_servers.parquet +0 -0
  293. agentpool_toolsets/mcp_discovery/toolset.py +511 -0
  294. agentpool_toolsets/mcp_run_toolset.py +87 -12
  295. agentpool_toolsets/notifications.py +33 -33
  296. agentpool_toolsets/openapi.py +3 -1
  297. agentpool_toolsets/search_toolset.py +3 -1
  298. agentpool-2.1.9.dist-info/RECORD +0 -474
  299. agentpool_config/resources.py +0 -33
  300. agentpool_server/acp_server/acp_tools.py +0 -43
  301. agentpool_server/acp_server/commands/spawn.py +0 -210
  302. agentpool_storage/text_log_provider.py +0 -275
  303. agentpool_toolsets/builtin/agent_management.py +0 -239
  304. agentpool_toolsets/builtin/chain.py +0 -288
  305. agentpool_toolsets/builtin/history.py +0 -36
  306. agentpool_toolsets/builtin/integration.py +0 -85
  307. agentpool_toolsets/builtin/tool_management.py +0 -90
  308. agentpool_toolsets/builtin/user_interaction.py +0 -52
  309. agentpool_toolsets/semantic_memory_toolset.py +0 -536
  310. {agentpool-2.1.9.dist-info → agentpool-2.5.0.dist-info}/entry_points.txt +0 -0
  311. {agentpool-2.1.9.dist-info → agentpool-2.5.0.dist-info}/licenses/LICENSE +0 -0
@@ -1,115 +1,902 @@
1
- """LSP diagnostics manager for file operations."""
1
+ """Diagnostics orchestration for code analysis tools.
2
+
3
+ This module provides configurable orchestration for running diagnostic tools
4
+ (type checkers, linters) with support for:
5
+ - Server selection and filtering (rust-only, preferred servers, exclusions)
6
+ - Parallel execution
7
+ - Rich progress notifications with command details
8
+ - Extensible server definitions
9
+ """
2
10
 
3
11
  from __future__ import annotations
4
12
 
5
- from typing import TYPE_CHECKING
13
+ import asyncio
14
+ from dataclasses import dataclass
15
+ import re
16
+ import time
17
+ from typing import TYPE_CHECKING, Literal, Protocol
6
18
 
7
- from anyenv.lsp_servers import DiagnosticRunner
8
- from anyenv.os_commands import get_os_command_provider
19
+ from agentpool.log import get_logger
9
20
 
10
21
 
11
22
  if TYPE_CHECKING:
12
- from anyenv.lsp_servers import Diagnostic, LSPServerInfo
13
23
  from exxec import ExecutionEnvironment
14
24
 
25
+ logger = get_logger(__name__)
26
+
27
+
28
+ # =============================================================================
29
+ # Core Data Structures
30
+ # =============================================================================
31
+
32
+
33
+ @dataclass
34
+ class Diagnostic:
35
+ """A single diagnostic message from a tool."""
36
+
37
+ file: str
38
+ line: int
39
+ column: int
40
+ severity: Literal["error", "warning", "info", "hint"]
41
+ message: str
42
+ source: str
43
+ code: str | None = None
44
+ end_line: int | None = None
45
+ end_column: int | None = None
46
+
47
+
48
+ @dataclass
49
+ class DiagnosticRunResult:
50
+ """Result from running a single diagnostic server."""
51
+
52
+ server_id: str
53
+ command: str
54
+ diagnostics: list[Diagnostic]
55
+ duration: float
56
+ success: bool
57
+ error: str | None = None
58
+
59
+
60
+ @dataclass
61
+ class DiagnosticsResult:
62
+ """Combined result from running diagnostics."""
63
+
64
+ diagnostics: list[Diagnostic]
65
+ runs: list[DiagnosticRunResult]
66
+ total_duration: float
67
+
68
+ @property
69
+ def success(self) -> bool:
70
+ """Check if all runs succeeded."""
71
+ return all(r.success for r in self.runs)
72
+
73
+ @property
74
+ def error_count(self) -> int:
75
+ """Count of error-level diagnostics."""
76
+ return sum(1 for d in self.diagnostics if d.severity == "error")
77
+
78
+ @property
79
+ def warning_count(self) -> int:
80
+ """Count of warning-level diagnostics."""
81
+ return sum(1 for d in self.diagnostics if d.severity == "warning")
82
+
83
+
84
+ @dataclass
85
+ class DiagnosticsConfig:
86
+ """Configuration for diagnostic runs.
87
+
88
+ Attributes:
89
+ preferred_servers: Only run these servers (by ID). If None, run all available.
90
+ excluded_servers: Never run these servers (by ID).
91
+ rust_only: Shorthand to only run Rust-based tools (ty, oxlint, biome).
92
+ max_servers_per_language: Limit servers per file extension (0 = unlimited).
93
+ parallel: Run multiple servers in parallel.
94
+ timeout: Timeout per server in seconds (0 = no timeout).
95
+ """
96
+
97
+ preferred_servers: list[str] | None = None
98
+ excluded_servers: list[str] | None = None
99
+ rust_only: bool = False
100
+ max_servers_per_language: int = 0
101
+ parallel: bool = True
102
+ timeout: float = 30.0
103
+
104
+
105
+ # =============================================================================
106
+ # Server Definitions
107
+ # =============================================================================
108
+
109
+
110
+ @dataclass
111
+ class CLIDiagnosticConfig:
112
+ """CLI configuration for running diagnostics."""
113
+
114
+ command: str
115
+ args: list[str]
116
+ output_format: Literal["json", "text"] = "json"
117
+
118
+
119
+ @dataclass
120
+ class DiagnosticServer:
121
+ """Base class for diagnostic server definitions.
122
+
123
+ Attributes:
124
+ id: Unique identifier for this server.
125
+ extensions: File extensions this server handles (e.g., [".py", ".pyi"]).
126
+ cli: CLI configuration for running diagnostics.
127
+ rust_based: Whether this tool is implemented in Rust (fast).
128
+ priority: Lower values run first when limiting servers per language.
129
+ """
130
+
131
+ id: str
132
+ extensions: list[str]
133
+ cli: CLIDiagnosticConfig
134
+ rust_based: bool = False
135
+ priority: int = 50
136
+
137
+ def can_handle(self, extension: str) -> bool:
138
+ """Check if this server handles the given file extension."""
139
+ ext = extension if extension.startswith(".") else f".{extension}"
140
+ return ext.lower() in [e.lower() for e in self.extensions]
141
+
142
+ def build_command(self, files: list[str]) -> str:
143
+ """Build the CLI command for running diagnostics."""
144
+ file_str = " ".join(files)
145
+ args = [arg.replace("{files}", file_str) for arg in self.cli.args]
146
+ return " ".join([self.cli.command, *args])
147
+
148
+ def parse_output(self, stdout: str, stderr: str) -> list[Diagnostic]:
149
+ """Parse CLI output into diagnostics. Override in subclasses."""
150
+ return []
151
+
152
+
153
+ def _severity_from_string(severity: str) -> Literal["error", "warning", "info", "hint"]:
154
+ """Convert severity string to Diagnostic severity."""
155
+ severity = severity.lower()
156
+ match severity:
157
+ case "error" | "err" | "blocker" | "critical" | "major":
158
+ return "error"
159
+ case "warning" | "warn" | "minor":
160
+ return "warning"
161
+ case "info" | "information":
162
+ return "info"
163
+ case "hint" | "note":
164
+ return "hint"
165
+ case _:
166
+ return "warning"
167
+
168
+
169
+ # -----------------------------------------------------------------------------
170
+ # Python Servers
171
+ # -----------------------------------------------------------------------------
172
+
173
+
174
+ @dataclass
175
+ class TyServer(DiagnosticServer):
176
+ """Ty (Astral) type checker - Rust-based, very fast."""
177
+
178
+ def parse_output(self, stdout: str, stderr: str) -> list[Diagnostic]:
179
+ """Parse ty GitLab JSON output."""
180
+ import json
181
+
182
+ diagnostics: list[Diagnostic] = []
183
+ try:
184
+ data = json.loads(stdout)
185
+ for item in data:
186
+ location = item.get("location", {})
187
+ positions = location.get("positions", {})
188
+ begin = positions.get("begin", {})
189
+ end = positions.get("end", {})
190
+
191
+ diagnostics.append(
192
+ Diagnostic(
193
+ file=location.get("path", ""),
194
+ line=begin.get("line", 1),
195
+ column=begin.get("column", 1),
196
+ end_line=end.get("line", begin.get("line", 1)),
197
+ end_column=end.get("column", begin.get("column", 1)),
198
+ severity=_severity_from_string(item.get("severity", "major")),
199
+ message=item.get("description", ""),
200
+ code=item.get("check_name"),
201
+ source=self.id,
202
+ )
203
+ )
204
+ except json.JSONDecodeError:
205
+ pass
206
+ return diagnostics
207
+
208
+
209
+ @dataclass
210
+ class PyrightServer(DiagnosticServer):
211
+ """Pyright type checker - Node.js based."""
212
+
213
+ def parse_output(self, stdout: str, stderr: str) -> list[Diagnostic]:
214
+ """Parse pyright JSON output."""
215
+ import json
216
+
217
+ diagnostics: list[Diagnostic] = []
218
+ try:
219
+ # Find JSON object in output (may have warnings before it)
220
+ json_start = stdout.find("{")
221
+ if json_start == -1:
222
+ return diagnostics
223
+ data = json.loads(stdout[json_start:])
224
+
225
+ for diag in data.get("generalDiagnostics", []):
226
+ range_info = diag.get("range", {})
227
+ start = range_info.get("start", {})
228
+ end = range_info.get("end", {})
229
+
230
+ diagnostics.append(
231
+ Diagnostic(
232
+ file=diag.get("file", ""),
233
+ line=start.get("line", 0) + 1, # pyright uses 0-indexed
234
+ column=start.get("character", 0) + 1,
235
+ end_line=end.get("line", start.get("line", 0)) + 1,
236
+ end_column=end.get("character", start.get("character", 0)) + 1,
237
+ severity=_severity_from_string(diag.get("severity", "error")),
238
+ message=diag.get("message", ""),
239
+ code=diag.get("rule"),
240
+ source=self.id,
241
+ )
242
+ )
243
+ except json.JSONDecodeError:
244
+ pass
245
+ return diagnostics
246
+
247
+
248
+ @dataclass
249
+ class MypyServer(DiagnosticServer):
250
+ """Mypy type checker - Python based."""
251
+
252
+ def parse_output(self, stdout: str, stderr: str) -> list[Diagnostic]:
253
+ """Parse mypy JSON output (one JSON object per line)."""
254
+ import json
255
+
256
+ diagnostics: list[Diagnostic] = []
257
+ for raw_line in stdout.strip().splitlines():
258
+ line = raw_line.strip()
259
+ if not line or not line.startswith("{"):
260
+ continue
261
+ try:
262
+ data = json.loads(line)
263
+ diagnostics.append(
264
+ Diagnostic(
265
+ file=data.get("file", ""),
266
+ line=data.get("line", 1),
267
+ column=data.get("column", 1),
268
+ severity=_severity_from_string(data.get("severity", "error")),
269
+ message=data.get("message", ""),
270
+ code=data.get("code"),
271
+ source=self.id,
272
+ )
273
+ )
274
+ except json.JSONDecodeError:
275
+ continue
276
+ return diagnostics
277
+
278
+
279
+ @dataclass
280
+ class ZubanServer(DiagnosticServer):
281
+ """Zuban type checker - mypy-compatible output."""
282
+
283
+ def parse_output(self, stdout: str, stderr: str) -> list[Diagnostic]:
284
+ """Parse zuban mypy-compatible text output."""
285
+ diagnostics: list[Diagnostic] = []
286
+ # Pattern: path:line:col: severity: message [code]
287
+ pattern = re.compile(
288
+ r"^(.+?):(\d+):(\d+): (error|warning|note): (.+?)(?:\s+\[([^\]]+)\])?$"
289
+ )
290
+
291
+ for raw_line in (stdout or stderr).strip().splitlines():
292
+ line = raw_line.strip()
293
+ if match := pattern.match(line):
294
+ file_path, line_no, col, severity, message, code = match.groups()
295
+ diagnostics.append(
296
+ Diagnostic(
297
+ file=file_path,
298
+ line=int(line_no),
299
+ column=int(col),
300
+ severity=_severity_from_string(severity),
301
+ message=message.strip(),
302
+ code=code,
303
+ source=self.id,
304
+ )
305
+ )
306
+ return diagnostics
307
+
308
+
309
+ @dataclass
310
+ class PyreflyServer(DiagnosticServer):
311
+ """Pyrefly (Meta) type checker."""
312
+
313
+ def parse_output(self, stdout: str, stderr: str) -> list[Diagnostic]:
314
+ """Parse pyrefly JSON output."""
315
+ import json
316
+
317
+ diagnostics: list[Diagnostic] = []
318
+ try:
319
+ json_start = stdout.find("{")
320
+ json_end = stdout.rfind("}") + 1
321
+ if json_start == -1 or json_end == 0:
322
+ return diagnostics
323
+
324
+ data = json.loads(stdout[json_start:json_end])
325
+ diagnostics.extend(
326
+ Diagnostic(
327
+ file=error.get("path", ""),
328
+ line=error.get("line", 1),
329
+ column=error.get("column", 1),
330
+ end_line=error.get("stop_line", error.get("line", 1)),
331
+ end_column=error.get("stop_column", error.get("column", 1)),
332
+ severity=_severity_from_string(error.get("severity", "error")),
333
+ message=error.get("description", ""),
334
+ code=error.get("name"),
335
+ source=self.id,
336
+ )
337
+ for error in data.get("errors", [])
338
+ )
339
+ except json.JSONDecodeError:
340
+ pass
341
+ return diagnostics
342
+
343
+
344
+ # -----------------------------------------------------------------------------
345
+ # JavaScript/TypeScript Servers
346
+ # -----------------------------------------------------------------------------
347
+
348
+
349
+ @dataclass
350
+ class OxlintServer(DiagnosticServer):
351
+ """Oxlint linter - Rust-based, very fast."""
352
+
353
+ def parse_output(self, stdout: str, stderr: str) -> list[Diagnostic]:
354
+ """Parse oxlint JSON output."""
355
+ import json
356
+
357
+ diagnostics: list[Diagnostic] = []
358
+ try:
359
+ data = json.loads(stdout)
360
+ for diag in data.get("diagnostics", []):
361
+ labels = diag.get("labels", [])
362
+ if labels:
363
+ span = labels[0].get("span", {})
364
+ line = span.get("line", 1)
365
+ column = span.get("column", 1)
366
+ else:
367
+ line, column = 1, 1
368
+
369
+ diagnostics.append(
370
+ Diagnostic(
371
+ file=diag.get("filename", ""),
372
+ line=line,
373
+ column=column,
374
+ severity=_severity_from_string(diag.get("severity", "warning")),
375
+ message=diag.get("message", ""),
376
+ code=diag.get("code"),
377
+ source=self.id,
378
+ )
379
+ )
380
+ except json.JSONDecodeError:
381
+ pass
382
+ return diagnostics
383
+
384
+
385
+ @dataclass
386
+ class BiomeServer(DiagnosticServer):
387
+ """Biome linter/formatter - Rust-based."""
388
+
389
+ def parse_output(self, stdout: str, stderr: str) -> list[Diagnostic]:
390
+ """Parse biome JSON output."""
391
+ import json
392
+
393
+ diagnostics: list[Diagnostic] = []
394
+ try:
395
+ json_start = stdout.find("{")
396
+ if json_start == -1:
397
+ return diagnostics
398
+
399
+ data = json.loads(stdout[json_start:])
400
+ for diag in data.get("diagnostics", []):
401
+ location = diag.get("location", {})
402
+ span = location.get("span", [0, 0])
403
+ path_info = location.get("path", {})
404
+ file_path = path_info.get("file", "") if isinstance(path_info, dict) else ""
405
+
406
+ diagnostics.append(
407
+ Diagnostic(
408
+ file=file_path,
409
+ line=1, # Biome uses byte offsets
410
+ column=span[0] if span else 1,
411
+ severity=_severity_from_string(diag.get("severity", "error")),
412
+ message=diag.get("description", ""),
413
+ code=diag.get("category"),
414
+ source=self.id,
415
+ )
416
+ )
417
+ except json.JSONDecodeError:
418
+ pass
419
+ return diagnostics
420
+
421
+
422
+ # =============================================================================
423
+ # Server Registry
424
+ # =============================================================================
425
+
426
+ # Python servers
427
+ TY = TyServer(
428
+ id="ty",
429
+ extensions=[".py", ".pyi"],
430
+ cli=CLIDiagnosticConfig(
431
+ command="ty",
432
+ args=["check", "--output-format", "gitlab", "{files}"],
433
+ output_format="json",
434
+ ),
435
+ rust_based=True,
436
+ priority=10,
437
+ )
438
+
439
+ PYRIGHT = PyrightServer(
440
+ id="pyright",
441
+ extensions=[".py", ".pyi"],
442
+ cli=CLIDiagnosticConfig(
443
+ command="pyright",
444
+ args=["--outputjson", "{files}"],
445
+ output_format="json",
446
+ ),
447
+ rust_based=False,
448
+ priority=20,
449
+ )
450
+
451
+ BASEDPYRIGHT = PyrightServer(
452
+ id="basedpyright",
453
+ extensions=[".py", ".pyi"],
454
+ cli=CLIDiagnosticConfig(
455
+ command="basedpyright",
456
+ args=["--outputjson", "{files}"],
457
+ output_format="json",
458
+ ),
459
+ rust_based=False,
460
+ priority=25,
461
+ )
462
+
463
+ MYPY = MypyServer(
464
+ id="mypy",
465
+ extensions=[".py", ".pyi"],
466
+ cli=CLIDiagnosticConfig(
467
+ command="mypy",
468
+ args=["--output", "json", "{files}"],
469
+ output_format="json",
470
+ ),
471
+ rust_based=False,
472
+ priority=30,
473
+ )
474
+
475
+ ZUBAN = ZubanServer(
476
+ id="zuban",
477
+ extensions=[".py", ".pyi"],
478
+ cli=CLIDiagnosticConfig(
479
+ command="zuban",
480
+ args=["check", "--show-column-numbers", "--show-error-codes", "{files}"],
481
+ output_format="text",
482
+ ),
483
+ rust_based=False,
484
+ priority=35,
485
+ )
486
+
487
+ PYREFLY = PyreflyServer(
488
+ id="pyrefly",
489
+ extensions=[".py", ".pyi"],
490
+ cli=CLIDiagnosticConfig(
491
+ command="pyrefly",
492
+ args=["check", "--output-format", "json", "{files}"],
493
+ output_format="json",
494
+ ),
495
+ rust_based=False,
496
+ priority=40,
497
+ )
498
+
499
+ # JavaScript/TypeScript servers
500
+ OXLINT = OxlintServer(
501
+ id="oxlint",
502
+ extensions=[".ts", ".tsx", ".js", ".jsx", ".mjs", ".cjs", ".mts", ".cts", ".vue"],
503
+ cli=CLIDiagnosticConfig(
504
+ command="oxlint",
505
+ args=["--format", "json", "{files}"],
506
+ output_format="json",
507
+ ),
508
+ rust_based=True,
509
+ priority=10,
510
+ )
511
+
512
+ BIOME = BiomeServer(
513
+ id="biome",
514
+ extensions=[".ts", ".tsx", ".js", ".jsx", ".mjs", ".cjs", ".mts", ".cts", ".json", ".jsonc"],
515
+ cli=CLIDiagnosticConfig(
516
+ command="biome",
517
+ args=["lint", "--reporter=json", "{files}"],
518
+ output_format="json",
519
+ ),
520
+ rust_based=True,
521
+ priority=15,
522
+ )
523
+
524
+ # All available servers, ordered by priority within each language
525
+ ALL_SERVERS: list[DiagnosticServer] = [
526
+ # Python (Rust-based first)
527
+ TY,
528
+ # PYRIGHT,
529
+ # BASEDPYRIGHT,
530
+ # MYPY,
531
+ # ZUBAN, # Disabled: times out on large files (>1000 lines)
532
+ PYREFLY,
533
+ # JavaScript/TypeScript (Rust-based)
534
+ OXLINT,
535
+ BIOME,
536
+ ]
537
+
538
+ # Quick lookup by ID
539
+ SERVERS_BY_ID: dict[str, DiagnosticServer] = {s.id: s for s in ALL_SERVERS}
540
+
541
+ # Rust-based server IDs for quick filtering
542
+ RUST_BASED_IDS: set[str] = {s.id for s in ALL_SERVERS if s.rust_based}
543
+
544
+
545
+ # =============================================================================
546
+ # Progress Callback Protocol
547
+ # =============================================================================
548
+
549
+
550
+ class ProgressCallback(Protocol):
551
+ """Protocol for progress notifications during diagnostic runs."""
552
+
553
+ async def __call__(
554
+ self,
555
+ message: str,
556
+ *,
557
+ server_id: str | None = None,
558
+ command: str | None = None,
559
+ status: Literal["starting", "running", "completed", "failed"] = "running",
560
+ ) -> None:
561
+ """Report progress during diagnostic execution."""
562
+ ...
563
+
564
+
565
+ # =============================================================================
566
+ # Diagnostics Manager
567
+ # =============================================================================
568
+
15
569
 
16
570
  class DiagnosticsManager:
17
- """Manages LSP diagnostics for file operations.
571
+ """Orchestrates diagnostic tool execution with rich configurability.
18
572
 
19
- Lazily checks server availability and caches results.
573
+ Features:
574
+ - Server selection via config (preferred, excluded, rust-only)
575
+ - Availability caching (checks `which` once per server)
576
+ - Parallel execution option
577
+ - Progress callbacks with command details
578
+ - Rich result metadata
20
579
  """
21
580
 
22
- def __init__(self, env: ExecutionEnvironment | None = None) -> None:
581
+ def __init__(
582
+ self,
583
+ env: ExecutionEnvironment,
584
+ config: DiagnosticsConfig | None = None,
585
+ ) -> None:
23
586
  """Initialize diagnostics manager.
24
587
 
25
588
  Args:
26
589
  env: Execution environment for running diagnostic commands.
27
- If None, diagnostics will be disabled.
590
+ config: Configuration for server selection and execution.
28
591
  """
29
592
  self._env = env
30
- self._runner: DiagnosticRunner | None = None
31
- self._server_availability: dict[str, bool] = {}
593
+ self._config = config or DiagnosticsConfig()
594
+ self._availability_cache: dict[str, bool] = {}
32
595
 
33
596
  @property
34
- def enabled(self) -> bool:
35
- """Check if diagnostics are enabled (have an execution environment)."""
36
- return self._env is not None
597
+ def config(self) -> DiagnosticsConfig:
598
+ """Get current configuration."""
599
+ return self._config
600
+
601
+ @config.setter
602
+ def config(self, value: DiagnosticsConfig) -> None:
603
+ """Set configuration."""
604
+ self._config = value
605
+
606
+ def get_servers_for_extension(self, extension: str) -> list[DiagnosticServer]:
607
+ """Get all servers that can handle a file extension, filtered by config."""
608
+ ext = extension if extension.startswith(".") else f".{extension}"
609
+
610
+ # Start with all servers that handle this extension
611
+ servers = [s for s in ALL_SERVERS if s.can_handle(ext)]
612
+
613
+ # Apply rust_only filter
614
+ if self._config.rust_only:
615
+ servers = [s for s in servers if s.rust_based]
616
+
617
+ # Apply preferred_servers filter (if set, only these servers)
618
+ if self._config.preferred_servers:
619
+ preferred = set(self._config.preferred_servers)
620
+ servers = [s for s in servers if s.id in preferred]
621
+
622
+ # Apply excluded_servers filter
623
+ if self._config.excluded_servers:
624
+ excluded = set(self._config.excluded_servers)
625
+ servers = [s for s in servers if s.id not in excluded]
626
+
627
+ # Sort by priority
628
+ servers.sort(key=lambda s: s.priority)
629
+
630
+ # Apply max_servers_per_language limit
631
+ if self._config.max_servers_per_language > 0:
632
+ servers = servers[: self._config.max_servers_per_language]
633
+
634
+ return servers
635
+
636
+ def get_servers_for_file(self, path: str) -> list[DiagnosticServer]:
637
+ """Get servers for a file path."""
638
+ import posixpath
639
+
640
+ ext = posixpath.splitext(path)[1]
641
+ return self.get_servers_for_extension(ext)
642
+
643
+ async def check_availability(self, server: DiagnosticServer) -> bool:
644
+ """Check if a server's command is available (cached)."""
645
+ if server.id in self._availability_cache:
646
+ return self._availability_cache[server.id]
647
+
648
+ # Use 'which' or 'where' depending on OS
649
+ if self._env.os_type == "Windows":
650
+ cmd = f"where {server.cli.command}"
651
+ else:
652
+ cmd = f"which {server.cli.command}"
653
+
654
+ result = await self._env.execute_command(cmd)
655
+ available = result.exit_code == 0 and bool(result.stdout and result.stdout.strip())
656
+
657
+ self._availability_cache[server.id] = available
658
+ logger.debug("Server %s availability: %s", server.id, available)
659
+ return available
660
+
661
+ async def _run_server(
662
+ self,
663
+ server: DiagnosticServer,
664
+ files: list[str],
665
+ progress: ProgressCallback | None = None,
666
+ ) -> DiagnosticRunResult:
667
+ """Run a single diagnostic server."""
668
+ command = server.build_command(files)
669
+
670
+ if progress:
671
+ await progress(
672
+ f"Running {server.id}...",
673
+ server_id=server.id,
674
+ command=command,
675
+ status="starting",
676
+ )
677
+
678
+ start = time.perf_counter()
679
+ try:
680
+ result = await asyncio.wait_for(
681
+ self._env.execute_command(command),
682
+ timeout=self._config.timeout if self._config.timeout > 0 else None,
683
+ )
684
+ duration = time.perf_counter() - start
685
+
686
+ diagnostics = server.parse_output(result.stdout or "", result.stderr or "")
37
687
 
38
- def _get_runner(self) -> DiagnosticRunner:
39
- """Get or create the diagnostic runner."""
40
- if self._runner is None:
41
- executor = self._env.execute_command if self._env else None
42
- self._runner = DiagnosticRunner(executor=executor)
43
- self._runner.register_defaults()
44
- return self._runner
688
+ if progress:
689
+ await progress(
690
+ f"{server.id}: {len(diagnostics)} issues",
691
+ server_id=server.id,
692
+ command=command,
693
+ status="completed",
694
+ )
45
695
 
46
- async def _is_server_available(self, server: LSPServerInfo) -> bool:
47
- """Check if a server's CLI command is available (cached).
696
+ return DiagnosticRunResult(
697
+ server_id=server.id,
698
+ command=command,
699
+ diagnostics=diagnostics,
700
+ duration=duration,
701
+ success=True,
702
+ )
703
+
704
+ except TimeoutError:
705
+ duration = time.perf_counter() - start
706
+ error_msg = f"Timeout after {self._config.timeout}s"
707
+ if progress:
708
+ await progress(
709
+ f"{server.id}: {error_msg}",
710
+ server_id=server.id,
711
+ command=command,
712
+ status="failed",
713
+ )
714
+ return DiagnosticRunResult(
715
+ server_id=server.id,
716
+ command=command,
717
+ diagnostics=[],
718
+ duration=duration,
719
+ success=False,
720
+ error=error_msg,
721
+ )
722
+
723
+ except Exception as e: # noqa: BLE001
724
+ duration = time.perf_counter() - start
725
+ error_msg = f"{type(e).__name__}: {e}"
726
+ if progress:
727
+ await progress(
728
+ f"{server.id}: {error_msg}",
729
+ server_id=server.id,
730
+ command=command,
731
+ status="failed",
732
+ )
733
+ return DiagnosticRunResult(
734
+ server_id=server.id,
735
+ command=command,
736
+ diagnostics=[],
737
+ duration=duration,
738
+ success=False,
739
+ error=error_msg,
740
+ )
741
+
742
+ async def run_for_file(
743
+ self,
744
+ path: str,
745
+ progress: ProgressCallback | None = None,
746
+ ) -> DiagnosticsResult:
747
+ """Run all applicable diagnostics on a single file.
48
748
 
49
749
  Args:
50
- server: The LSP server info to check
750
+ path: File path to check.
751
+ progress: Optional callback for progress notifications.
51
752
 
52
753
  Returns:
53
- True if the server's command is available
754
+ DiagnosticsResult with all diagnostics and run metadata.
54
755
  """
55
- if not self._env or not server.cli_diagnostics:
56
- return False
57
-
58
- if server.id not in self._server_availability:
59
- provider = get_os_command_provider(self._env.os_type)
60
- which_cmd = provider.get_command("which")
61
- cmd = which_cmd.create_command(server.cli_diagnostics.command)
62
- result = await self._env.execute_command(cmd)
63
- is_available = (
64
- which_cmd.parse_command(result.stdout or "", result.exit_code or 0) is not None
65
- )
66
- self._server_availability[server.id] = is_available
67
-
68
- return self._server_availability[server.id]
756
+ return await self.run_for_files([path], progress=progress)
69
757
 
70
- async def run_for_file(self, path: str) -> list[Diagnostic]:
71
- """Run all applicable diagnostics on a file.
758
+ async def run_for_files(
759
+ self,
760
+ files: list[str],
761
+ progress: ProgressCallback | None = None,
762
+ ) -> DiagnosticsResult:
763
+ """Run diagnostics on multiple files.
72
764
 
73
765
  Args:
74
- path: File path to check
766
+ files: File paths to check.
767
+ progress: Optional callback for progress notifications.
75
768
 
76
769
  Returns:
77
- List of diagnostics from all available servers
770
+ DiagnosticsResult with all diagnostics and run metadata.
78
771
  """
79
- if not self.enabled:
80
- return []
772
+ if not files:
773
+ return DiagnosticsResult(diagnostics=[], runs=[], total_duration=0.0)
774
+
775
+ start = time.perf_counter()
776
+
777
+ # Collect all applicable servers across all files
778
+ import posixpath
81
779
 
82
- runner = self._get_runner()
780
+ extensions = {posixpath.splitext(f)[1] for f in files}
781
+ servers_to_run: list[DiagnosticServer] = []
782
+ seen_ids: set[str] = set()
783
+
784
+ for ext in extensions:
785
+ for server in self.get_servers_for_extension(ext):
786
+ if server.id not in seen_ids:
787
+ seen_ids.add(server.id)
788
+ servers_to_run.append(server)
789
+
790
+ # Check availability (can't use list comprehension due to await)
791
+ available_servers: list[DiagnosticServer] = []
792
+ for server in servers_to_run:
793
+ if await self.check_availability(server):
794
+ available_servers.append(server) # noqa: PERF401
795
+
796
+ if not available_servers:
797
+ return DiagnosticsResult(
798
+ diagnostics=[],
799
+ runs=[],
800
+ total_duration=time.perf_counter() - start,
801
+ )
802
+
803
+ # Run servers (parallel or sequential)
804
+ if self._config.parallel and len(available_servers) > 1:
805
+ tasks = [self._run_server(s, files, progress) for s in available_servers]
806
+ runs = await asyncio.gather(*tasks)
807
+ else:
808
+ runs = []
809
+ for server in available_servers:
810
+ run_result = await self._run_server(server, files, progress)
811
+ runs.append(run_result)
812
+
813
+ # Combine diagnostics
83
814
  all_diagnostics: list[Diagnostic] = []
815
+ for run in runs:
816
+ all_diagnostics.extend(run.diagnostics)
84
817
 
85
- for server in runner.get_servers_for_file(path):
86
- if await self._is_server_available(server):
87
- result = await runner.run(server, [path])
88
- all_diagnostics.extend(result.diagnostics)
818
+ total_duration = time.perf_counter() - start
89
819
 
90
- return all_diagnostics
820
+ return DiagnosticsResult(
821
+ diagnostics=all_diagnostics,
822
+ runs=list(runs),
823
+ total_duration=total_duration,
824
+ )
91
825
 
92
- def format_diagnostics(self, diagnostics: list[Diagnostic]) -> str:
93
- """Format diagnostics as a Markdown table.
94
826
 
95
- Args:
96
- diagnostics: List of diagnostics to format
827
+ # =============================================================================
828
+ # Formatting Helpers
829
+ # =============================================================================
97
830
 
98
- Returns:
99
- Markdown table with Severity, Location, Code, Description columns
100
- """
101
- if not diagnostics:
102
- return ""
103
-
104
- lines: list[str] = [
105
- "| Severity | Location | Code | Description |",
106
- "|----------|----------|------|-------------|",
107
- ]
108
- for d in diagnostics:
109
- loc = f"{d.file}:{d.line}:{d.column}"
110
- code = d.code or ""
111
- # Escape pipe characters in message
112
- msg = d.message.replace("|", "\\|")
113
- lines.append(f"| {d.severity.upper()} | {loc} | {code} | {msg} |")
114
-
115
- return "\n".join(lines)
831
+
832
+ def format_diagnostics_table(diagnostics: list[Diagnostic]) -> str:
833
+ """Format diagnostics as a Markdown table.
834
+
835
+ Args:
836
+ diagnostics: List of diagnostics to format.
837
+
838
+ Returns:
839
+ Markdown table string.
840
+ """
841
+ if not diagnostics:
842
+ return "No issues found."
843
+
844
+ lines: list[str] = [
845
+ "| Severity | Location | Code | Source | Description |",
846
+ "|----------|----------|------|--------|-------------|",
847
+ ]
848
+ for d in diagnostics:
849
+ loc = f"{d.file}:{d.line}:{d.column}"
850
+ code = d.code or ""
851
+ # Escape pipe characters and newlines in message
852
+ msg = d.message.replace("|", "\\|").replace("\n", " ")
853
+ lines.append(f"| {d.severity.upper()} | {loc} | {code} | {d.source} | {msg} |")
854
+
855
+ return "\n".join(lines)
856
+
857
+
858
+ def format_diagnostics_compact(diagnostics: list[Diagnostic]) -> str:
859
+ """Format diagnostics in a compact single-line-per-issue format.
860
+
861
+ Args:
862
+ diagnostics: List of diagnostics to format.
863
+
864
+ Returns:
865
+ Compact formatted string.
866
+ """
867
+ if not diagnostics:
868
+ return "No issues found."
869
+
870
+ lines: list[str] = []
871
+ for d in diagnostics:
872
+ code_part = f"[{d.code}] " if d.code else ""
873
+ msg = d.message.replace("\n", " ")
874
+ lines.append(f"{d.file}:{d.line}:{d.column}: {d.severity}: {code_part}{msg} ({d.source})")
875
+
876
+ return "\n".join(lines)
877
+
878
+
879
+ def format_run_summary(result: DiagnosticsResult) -> str:
880
+ """Format a summary of the diagnostic run.
881
+
882
+ Args:
883
+ result: The diagnostics result to summarize.
884
+
885
+ Returns:
886
+ Summary string.
887
+ """
888
+ parts = [f"Ran {len(result.runs)} tool(s) in {result.total_duration:.2f}s"]
889
+
890
+ if result.diagnostics:
891
+ parts.append(f"Found {len(result.diagnostics)} issues")
892
+ if result.error_count:
893
+ parts.append(f"({result.error_count} errors, {result.warning_count} warnings)")
894
+ else:
895
+ parts.append("No issues found")
896
+
897
+ # Add per-server timing
898
+ if result.runs:
899
+ timings = ", ".join(f"{r.server_id}: {r.duration:.2f}s" for r in result.runs)
900
+ parts.append(f"[{timings}]")
901
+
902
+ return " | ".join(parts)