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.
- acp/__init__.py +13 -4
- acp/acp_requests.py +20 -77
- acp/agent/connection.py +8 -0
- acp/agent/implementations/debug_server/debug_server.py +6 -2
- acp/agent/protocol.py +6 -0
- acp/bridge/README.md +15 -2
- acp/bridge/__init__.py +3 -2
- acp/bridge/__main__.py +60 -19
- acp/bridge/ws_server.py +173 -0
- acp/bridge/ws_server_cli.py +89 -0
- acp/client/connection.py +38 -29
- acp/client/implementations/default_client.py +3 -2
- acp/client/implementations/headless_client.py +2 -2
- acp/connection.py +2 -2
- acp/notifications.py +20 -50
- acp/schema/__init__.py +2 -0
- acp/schema/agent_responses.py +21 -0
- acp/schema/client_requests.py +3 -3
- acp/schema/session_state.py +63 -29
- acp/stdio.py +39 -9
- acp/task/supervisor.py +2 -2
- acp/transports.py +362 -2
- acp/utils.py +17 -4
- agentpool/__init__.py +6 -1
- agentpool/agents/__init__.py +2 -0
- agentpool/agents/acp_agent/acp_agent.py +407 -277
- agentpool/agents/acp_agent/acp_converters.py +196 -38
- agentpool/agents/acp_agent/client_handler.py +191 -26
- agentpool/agents/acp_agent/session_state.py +17 -6
- agentpool/agents/agent.py +607 -572
- agentpool/agents/agui_agent/__init__.py +0 -2
- agentpool/agents/agui_agent/agui_agent.py +176 -110
- agentpool/agents/agui_agent/agui_converters.py +0 -131
- agentpool/agents/agui_agent/helpers.py +3 -4
- agentpool/agents/base_agent.py +632 -17
- agentpool/agents/claude_code_agent/FORKING.md +191 -0
- agentpool/agents/claude_code_agent/__init__.py +13 -1
- agentpool/agents/claude_code_agent/claude_code_agent.py +1058 -291
- agentpool/agents/claude_code_agent/converters.py +74 -143
- agentpool/agents/claude_code_agent/history.py +474 -0
- agentpool/agents/claude_code_agent/models.py +77 -0
- agentpool/agents/claude_code_agent/static_info.py +100 -0
- agentpool/agents/claude_code_agent/usage.py +242 -0
- agentpool/agents/context.py +40 -0
- agentpool/agents/events/__init__.py +24 -0
- agentpool/agents/events/builtin_handlers.py +67 -1
- agentpool/agents/events/event_emitter.py +32 -2
- agentpool/agents/events/events.py +104 -3
- agentpool/agents/events/infer_info.py +145 -0
- agentpool/agents/events/processors.py +254 -0
- agentpool/agents/interactions.py +41 -6
- agentpool/agents/modes.py +67 -0
- agentpool/agents/slashed_agent.py +5 -4
- agentpool/agents/tool_call_accumulator.py +213 -0
- agentpool/agents/tool_wrapping.py +18 -6
- agentpool/common_types.py +56 -21
- agentpool/config_resources/__init__.py +38 -1
- agentpool/config_resources/acp_assistant.yml +2 -2
- agentpool/config_resources/agents.yml +3 -0
- agentpool/config_resources/agents_template.yml +1 -0
- agentpool/config_resources/claude_code_agent.yml +10 -6
- agentpool/config_resources/external_acp_agents.yml +2 -1
- agentpool/delegation/base_team.py +4 -30
- agentpool/delegation/pool.py +136 -289
- agentpool/delegation/team.py +58 -57
- agentpool/delegation/teamrun.py +51 -55
- agentpool/diagnostics/__init__.py +53 -0
- agentpool/diagnostics/lsp_manager.py +1593 -0
- agentpool/diagnostics/lsp_proxy.py +41 -0
- agentpool/diagnostics/lsp_proxy_script.py +229 -0
- agentpool/diagnostics/models.py +398 -0
- agentpool/functional/run.py +10 -4
- agentpool/mcp_server/__init__.py +0 -2
- agentpool/mcp_server/client.py +76 -32
- agentpool/mcp_server/conversions.py +54 -13
- agentpool/mcp_server/manager.py +34 -54
- agentpool/mcp_server/registries/official_registry_client.py +35 -1
- agentpool/mcp_server/tool_bridge.py +186 -139
- agentpool/messaging/__init__.py +0 -2
- agentpool/messaging/compaction.py +72 -197
- agentpool/messaging/connection_manager.py +11 -10
- agentpool/messaging/event_manager.py +5 -5
- agentpool/messaging/message_container.py +6 -30
- agentpool/messaging/message_history.py +99 -8
- agentpool/messaging/messagenode.py +52 -14
- agentpool/messaging/messages.py +54 -35
- agentpool/messaging/processing.py +12 -22
- agentpool/models/__init__.py +1 -1
- agentpool/models/acp_agents/base.py +6 -24
- agentpool/models/acp_agents/mcp_capable.py +126 -157
- agentpool/models/acp_agents/non_mcp.py +129 -95
- agentpool/models/agents.py +98 -76
- agentpool/models/agui_agents.py +1 -1
- agentpool/models/claude_code_agents.py +144 -19
- agentpool/models/file_parsing.py +0 -1
- agentpool/models/manifest.py +113 -50
- agentpool/prompts/conversion_manager.py +1 -1
- agentpool/prompts/prompts.py +5 -2
- agentpool/repomap.py +1 -1
- agentpool/resource_providers/__init__.py +11 -1
- agentpool/resource_providers/aggregating.py +56 -5
- agentpool/resource_providers/base.py +70 -4
- agentpool/resource_providers/codemode/code_executor.py +72 -5
- agentpool/resource_providers/codemode/helpers.py +2 -2
- agentpool/resource_providers/codemode/provider.py +64 -12
- agentpool/resource_providers/codemode/remote_mcp_execution.py +2 -2
- agentpool/resource_providers/codemode/remote_provider.py +9 -12
- agentpool/resource_providers/filtering.py +3 -1
- agentpool/resource_providers/mcp_provider.py +89 -12
- agentpool/resource_providers/plan_provider.py +228 -46
- agentpool/resource_providers/pool.py +7 -3
- agentpool/resource_providers/resource_info.py +111 -0
- agentpool/resource_providers/static.py +4 -2
- agentpool/sessions/__init__.py +4 -1
- agentpool/sessions/manager.py +33 -5
- agentpool/sessions/models.py +59 -6
- agentpool/sessions/protocol.py +28 -0
- agentpool/sessions/session.py +11 -55
- agentpool/skills/registry.py +13 -8
- agentpool/storage/manager.py +572 -49
- agentpool/talk/registry.py +4 -4
- agentpool/talk/talk.py +9 -10
- agentpool/testing.py +538 -20
- agentpool/tool_impls/__init__.py +6 -0
- agentpool/tool_impls/agent_cli/__init__.py +42 -0
- agentpool/tool_impls/agent_cli/tool.py +95 -0
- agentpool/tool_impls/bash/__init__.py +64 -0
- agentpool/tool_impls/bash/helpers.py +35 -0
- agentpool/tool_impls/bash/tool.py +171 -0
- agentpool/tool_impls/delete_path/__init__.py +70 -0
- agentpool/tool_impls/delete_path/tool.py +142 -0
- agentpool/tool_impls/download_file/__init__.py +80 -0
- agentpool/tool_impls/download_file/tool.py +183 -0
- agentpool/tool_impls/execute_code/__init__.py +55 -0
- agentpool/tool_impls/execute_code/tool.py +163 -0
- agentpool/tool_impls/grep/__init__.py +80 -0
- agentpool/tool_impls/grep/tool.py +200 -0
- agentpool/tool_impls/list_directory/__init__.py +73 -0
- agentpool/tool_impls/list_directory/tool.py +197 -0
- agentpool/tool_impls/question/__init__.py +42 -0
- agentpool/tool_impls/question/tool.py +127 -0
- agentpool/tool_impls/read/__init__.py +104 -0
- agentpool/tool_impls/read/tool.py +305 -0
- agentpool/tools/__init__.py +2 -1
- agentpool/tools/base.py +114 -34
- agentpool/tools/manager.py +57 -1
- agentpool/ui/base.py +2 -2
- agentpool/ui/mock_provider.py +2 -2
- agentpool/ui/stdlib_provider.py +2 -2
- agentpool/utils/file_watcher.py +269 -0
- agentpool/utils/identifiers.py +121 -0
- agentpool/utils/pydantic_ai_helpers.py +46 -0
- agentpool/utils/streams.py +616 -2
- agentpool/utils/subprocess_utils.py +155 -0
- agentpool/utils/token_breakdown.py +461 -0
- agentpool/vfs_registry.py +7 -2
- {agentpool-2.1.9.dist-info → agentpool-2.5.0.dist-info}/METADATA +41 -27
- agentpool-2.5.0.dist-info/RECORD +579 -0
- {agentpool-2.1.9.dist-info → agentpool-2.5.0.dist-info}/WHEEL +1 -1
- agentpool_cli/__main__.py +24 -0
- agentpool_cli/create.py +1 -1
- agentpool_cli/serve_acp.py +100 -21
- agentpool_cli/serve_agui.py +87 -0
- agentpool_cli/serve_opencode.py +119 -0
- agentpool_cli/ui.py +557 -0
- agentpool_commands/__init__.py +42 -5
- agentpool_commands/agents.py +75 -2
- agentpool_commands/history.py +62 -0
- agentpool_commands/mcp.py +176 -0
- agentpool_commands/models.py +56 -3
- agentpool_commands/pool.py +260 -0
- agentpool_commands/session.py +1 -1
- agentpool_commands/text_sharing/__init__.py +119 -0
- agentpool_commands/text_sharing/base.py +123 -0
- agentpool_commands/text_sharing/github_gist.py +80 -0
- agentpool_commands/text_sharing/opencode.py +462 -0
- agentpool_commands/text_sharing/paste_rs.py +59 -0
- agentpool_commands/text_sharing/pastebin.py +116 -0
- agentpool_commands/text_sharing/shittycodingagent.py +112 -0
- agentpool_commands/tools.py +57 -0
- agentpool_commands/utils.py +80 -30
- agentpool_config/__init__.py +30 -2
- agentpool_config/agentpool_tools.py +498 -0
- agentpool_config/builtin_tools.py +77 -22
- agentpool_config/commands.py +24 -1
- agentpool_config/compaction.py +258 -0
- agentpool_config/converters.py +1 -1
- agentpool_config/event_handlers.py +42 -0
- agentpool_config/events.py +1 -1
- agentpool_config/forward_targets.py +1 -4
- agentpool_config/jinja.py +3 -3
- agentpool_config/mcp_server.py +132 -6
- agentpool_config/nodes.py +1 -1
- agentpool_config/observability.py +44 -0
- agentpool_config/session.py +0 -3
- agentpool_config/storage.py +82 -38
- agentpool_config/task.py +3 -3
- agentpool_config/tools.py +11 -22
- agentpool_config/toolsets.py +109 -233
- agentpool_server/a2a_server/agent_worker.py +307 -0
- agentpool_server/a2a_server/server.py +23 -18
- agentpool_server/acp_server/acp_agent.py +234 -181
- agentpool_server/acp_server/commands/acp_commands.py +151 -156
- agentpool_server/acp_server/commands/docs_commands/fetch_repo.py +18 -17
- agentpool_server/acp_server/event_converter.py +651 -0
- agentpool_server/acp_server/input_provider.py +53 -10
- agentpool_server/acp_server/server.py +24 -90
- agentpool_server/acp_server/session.py +173 -331
- agentpool_server/acp_server/session_manager.py +8 -34
- agentpool_server/agui_server/server.py +3 -1
- agentpool_server/mcp_server/server.py +5 -2
- agentpool_server/opencode_server/.rules +95 -0
- agentpool_server/opencode_server/ENDPOINTS.md +401 -0
- agentpool_server/opencode_server/OPENCODE_UI_TOOLS_COMPLETE.md +202 -0
- agentpool_server/opencode_server/__init__.py +19 -0
- agentpool_server/opencode_server/command_validation.py +172 -0
- agentpool_server/opencode_server/converters.py +975 -0
- agentpool_server/opencode_server/dependencies.py +24 -0
- agentpool_server/opencode_server/input_provider.py +421 -0
- agentpool_server/opencode_server/models/__init__.py +250 -0
- agentpool_server/opencode_server/models/agent.py +53 -0
- agentpool_server/opencode_server/models/app.py +72 -0
- agentpool_server/opencode_server/models/base.py +26 -0
- agentpool_server/opencode_server/models/common.py +23 -0
- agentpool_server/opencode_server/models/config.py +37 -0
- agentpool_server/opencode_server/models/events.py +821 -0
- agentpool_server/opencode_server/models/file.py +88 -0
- agentpool_server/opencode_server/models/mcp.py +44 -0
- agentpool_server/opencode_server/models/message.py +179 -0
- agentpool_server/opencode_server/models/parts.py +323 -0
- agentpool_server/opencode_server/models/provider.py +81 -0
- agentpool_server/opencode_server/models/pty.py +43 -0
- agentpool_server/opencode_server/models/question.py +56 -0
- agentpool_server/opencode_server/models/session.py +111 -0
- agentpool_server/opencode_server/routes/__init__.py +29 -0
- agentpool_server/opencode_server/routes/agent_routes.py +473 -0
- agentpool_server/opencode_server/routes/app_routes.py +202 -0
- agentpool_server/opencode_server/routes/config_routes.py +302 -0
- agentpool_server/opencode_server/routes/file_routes.py +571 -0
- agentpool_server/opencode_server/routes/global_routes.py +94 -0
- agentpool_server/opencode_server/routes/lsp_routes.py +319 -0
- agentpool_server/opencode_server/routes/message_routes.py +761 -0
- agentpool_server/opencode_server/routes/permission_routes.py +63 -0
- agentpool_server/opencode_server/routes/pty_routes.py +300 -0
- agentpool_server/opencode_server/routes/question_routes.py +128 -0
- agentpool_server/opencode_server/routes/session_routes.py +1276 -0
- agentpool_server/opencode_server/routes/tui_routes.py +139 -0
- agentpool_server/opencode_server/server.py +475 -0
- agentpool_server/opencode_server/state.py +151 -0
- agentpool_server/opencode_server/time_utils.py +8 -0
- agentpool_storage/__init__.py +12 -0
- agentpool_storage/base.py +184 -2
- agentpool_storage/claude_provider/ARCHITECTURE.md +433 -0
- agentpool_storage/claude_provider/__init__.py +42 -0
- agentpool_storage/claude_provider/provider.py +1089 -0
- agentpool_storage/file_provider.py +278 -15
- agentpool_storage/memory_provider.py +193 -12
- agentpool_storage/models.py +3 -0
- agentpool_storage/opencode_provider/ARCHITECTURE.md +386 -0
- agentpool_storage/opencode_provider/__init__.py +16 -0
- agentpool_storage/opencode_provider/helpers.py +414 -0
- agentpool_storage/opencode_provider/provider.py +895 -0
- agentpool_storage/project_store.py +325 -0
- agentpool_storage/session_store.py +26 -6
- agentpool_storage/sql_provider/__init__.py +4 -2
- agentpool_storage/sql_provider/models.py +48 -0
- agentpool_storage/sql_provider/sql_provider.py +269 -3
- agentpool_storage/sql_provider/utils.py +12 -13
- agentpool_storage/zed_provider/__init__.py +16 -0
- agentpool_storage/zed_provider/helpers.py +281 -0
- agentpool_storage/zed_provider/models.py +130 -0
- agentpool_storage/zed_provider/provider.py +442 -0
- agentpool_storage/zed_provider.py +803 -0
- agentpool_toolsets/__init__.py +0 -2
- agentpool_toolsets/builtin/__init__.py +2 -12
- agentpool_toolsets/builtin/code.py +96 -57
- agentpool_toolsets/builtin/debug.py +118 -48
- agentpool_toolsets/builtin/execution_environment.py +115 -230
- agentpool_toolsets/builtin/file_edit/file_edit.py +115 -7
- agentpool_toolsets/builtin/skills.py +9 -4
- agentpool_toolsets/builtin/subagent_tools.py +64 -51
- agentpool_toolsets/builtin/workers.py +4 -2
- agentpool_toolsets/composio_toolset.py +2 -2
- agentpool_toolsets/entry_points.py +3 -1
- agentpool_toolsets/fsspec_toolset/__init__.py +13 -1
- agentpool_toolsets/fsspec_toolset/diagnostics.py +860 -73
- agentpool_toolsets/fsspec_toolset/grep.py +99 -7
- agentpool_toolsets/fsspec_toolset/helpers.py +3 -2
- agentpool_toolsets/fsspec_toolset/image_utils.py +161 -0
- agentpool_toolsets/fsspec_toolset/toolset.py +500 -95
- agentpool_toolsets/mcp_discovery/__init__.py +5 -0
- agentpool_toolsets/mcp_discovery/data/mcp_servers.parquet +0 -0
- agentpool_toolsets/mcp_discovery/toolset.py +511 -0
- agentpool_toolsets/mcp_run_toolset.py +87 -12
- agentpool_toolsets/notifications.py +33 -33
- agentpool_toolsets/openapi.py +3 -1
- agentpool_toolsets/search_toolset.py +3 -1
- agentpool-2.1.9.dist-info/RECORD +0 -474
- agentpool_config/resources.py +0 -33
- agentpool_server/acp_server/acp_tools.py +0 -43
- agentpool_server/acp_server/commands/spawn.py +0 -210
- agentpool_storage/text_log_provider.py +0 -275
- agentpool_toolsets/builtin/agent_management.py +0 -239
- agentpool_toolsets/builtin/chain.py +0 -288
- agentpool_toolsets/builtin/history.py +0 -36
- agentpool_toolsets/builtin/integration.py +0 -85
- agentpool_toolsets/builtin/tool_management.py +0 -90
- agentpool_toolsets/builtin/user_interaction.py +0 -52
- agentpool_toolsets/semantic_memory_toolset.py +0 -536
- {agentpool-2.1.9.dist-info → agentpool-2.5.0.dist-info}/entry_points.txt +0 -0
- {agentpool-2.1.9.dist-info → agentpool-2.5.0.dist-info}/licenses/LICENSE +0 -0
|
@@ -1,115 +1,902 @@
|
|
|
1
|
-
"""
|
|
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
|
-
|
|
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
|
|
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
|
-
"""
|
|
571
|
+
"""Orchestrates diagnostic tool execution with rich configurability.
|
|
18
572
|
|
|
19
|
-
|
|
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__(
|
|
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
|
-
|
|
590
|
+
config: Configuration for server selection and execution.
|
|
28
591
|
"""
|
|
29
592
|
self._env = env
|
|
30
|
-
self.
|
|
31
|
-
self.
|
|
593
|
+
self._config = config or DiagnosticsConfig()
|
|
594
|
+
self._availability_cache: dict[str, bool] = {}
|
|
32
595
|
|
|
33
596
|
@property
|
|
34
|
-
def
|
|
35
|
-
"""
|
|
36
|
-
return self.
|
|
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
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
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
|
-
|
|
47
|
-
|
|
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
|
-
|
|
750
|
+
path: File path to check.
|
|
751
|
+
progress: Optional callback for progress notifications.
|
|
51
752
|
|
|
52
753
|
Returns:
|
|
53
|
-
|
|
754
|
+
DiagnosticsResult with all diagnostics and run metadata.
|
|
54
755
|
"""
|
|
55
|
-
|
|
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
|
|
71
|
-
|
|
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
|
-
|
|
766
|
+
files: File paths to check.
|
|
767
|
+
progress: Optional callback for progress notifications.
|
|
75
768
|
|
|
76
769
|
Returns:
|
|
77
|
-
|
|
770
|
+
DiagnosticsResult with all diagnostics and run metadata.
|
|
78
771
|
"""
|
|
79
|
-
if not
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
96
|
-
|
|
827
|
+
# =============================================================================
|
|
828
|
+
# Formatting Helpers
|
|
829
|
+
# =============================================================================
|
|
97
830
|
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
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)
|