codeframe-ai 0.9.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.
- codeframe/__init__.py +11 -0
- codeframe/__main__.py +20 -0
- codeframe/adapters/__init__.py +5 -0
- codeframe/adapters/e2b/__init__.py +13 -0
- codeframe/adapters/e2b/adapter.py +342 -0
- codeframe/adapters/e2b/budget.py +71 -0
- codeframe/adapters/e2b/credential_scanner.py +134 -0
- codeframe/adapters/llm/__init__.py +92 -0
- codeframe/adapters/llm/anthropic.py +414 -0
- codeframe/adapters/llm/base.py +444 -0
- codeframe/adapters/llm/mock.py +281 -0
- codeframe/adapters/llm/openai.py +483 -0
- codeframe/agents/__init__.py +8 -0
- codeframe/agents/dependency_resolver.py +714 -0
- codeframe/auth/__init__.py +16 -0
- codeframe/auth/api_key_router.py +238 -0
- codeframe/auth/api_keys.py +156 -0
- codeframe/auth/dependencies.py +358 -0
- codeframe/auth/manager.py +178 -0
- codeframe/auth/models.py +30 -0
- codeframe/auth/router.py +93 -0
- codeframe/auth/schemas.py +15 -0
- codeframe/auth/scopes.py +53 -0
- codeframe/cli/__init__.py +12 -0
- codeframe/cli/__main__.py +20 -0
- codeframe/cli/api_client.py +275 -0
- codeframe/cli/app.py +5688 -0
- codeframe/cli/auth.py +122 -0
- codeframe/cli/auth_commands.py +958 -0
- codeframe/cli/commands/__init__.py +5 -0
- codeframe/cli/config_commands.py +79 -0
- codeframe/cli/dashboard_commands.py +67 -0
- codeframe/cli/engines_commands.py +205 -0
- codeframe/cli/env_commands.py +409 -0
- codeframe/cli/helpers.py +56 -0
- codeframe/cli/hooks_commands.py +208 -0
- codeframe/cli/import_commands.py +129 -0
- codeframe/cli/pr_commands.py +549 -0
- codeframe/cli/proof_commands.py +415 -0
- codeframe/cli/stats_commands.py +311 -0
- codeframe/cli/telemetry_runtime.py +153 -0
- codeframe/cli/validators.py +123 -0
- codeframe/config/rate_limits.py +165 -0
- codeframe/core/__init__.py +15 -0
- codeframe/core/adapters/__init__.py +43 -0
- codeframe/core/adapters/agent_adapter.py +114 -0
- codeframe/core/adapters/builtin.py +326 -0
- codeframe/core/adapters/claude_code.py +62 -0
- codeframe/core/adapters/codex.py +393 -0
- codeframe/core/adapters/git_utils.py +40 -0
- codeframe/core/adapters/kilocode.py +126 -0
- codeframe/core/adapters/opencode.py +48 -0
- codeframe/core/adapters/streaming_chat.py +483 -0
- codeframe/core/adapters/subprocess_adapter.py +213 -0
- codeframe/core/adapters/verification_wrapper.py +269 -0
- codeframe/core/agent.py +2183 -0
- codeframe/core/agents_config.py +569 -0
- codeframe/core/api_key_service.py +211 -0
- codeframe/core/artifacts.py +428 -0
- codeframe/core/blocker_detection.py +218 -0
- codeframe/core/blockers.py +433 -0
- codeframe/core/checkpoints.py +481 -0
- codeframe/core/conductor.py +2255 -0
- codeframe/core/config.py +827 -0
- codeframe/core/config_watcher.py +268 -0
- codeframe/core/context.py +542 -0
- codeframe/core/context_packager.py +234 -0
- codeframe/core/credentials.py +735 -0
- codeframe/core/dependency_analyzer.py +229 -0
- codeframe/core/dependency_graph.py +290 -0
- codeframe/core/diagnostic_agent.py +712 -0
- codeframe/core/diagnostics.py +616 -0
- codeframe/core/editor.py +556 -0
- codeframe/core/engine_registry.py +256 -0
- codeframe/core/engine_stats.py +231 -0
- codeframe/core/environment.py +697 -0
- codeframe/core/events.py +375 -0
- codeframe/core/executor.py +1005 -0
- codeframe/core/fix_tracker.py +480 -0
- codeframe/core/gates.py +1322 -0
- codeframe/core/git.py +477 -0
- codeframe/core/github_connect_service.py +178 -0
- codeframe/core/github_integration_config.py +118 -0
- codeframe/core/github_issues_service.py +449 -0
- codeframe/core/hooks.py +184 -0
- codeframe/core/importers/__init__.py +1 -0
- codeframe/core/importers/ralph.py +540 -0
- codeframe/core/installer.py +650 -0
- codeframe/core/models.py +1026 -0
- codeframe/core/notifications_config.py +183 -0
- codeframe/core/planner.py +437 -0
- codeframe/core/prd.py +670 -0
- codeframe/core/prd_discovery.py +1118 -0
- codeframe/core/prd_stress_test.py +499 -0
- codeframe/core/progress.py +126 -0
- codeframe/core/proof/__init__.py +34 -0
- codeframe/core/proof/capture.py +79 -0
- codeframe/core/proof/evidence.py +56 -0
- codeframe/core/proof/ledger.py +574 -0
- codeframe/core/proof/models.py +162 -0
- codeframe/core/proof/obligations.py +103 -0
- codeframe/core/proof/runner.py +233 -0
- codeframe/core/proof/scope.py +81 -0
- codeframe/core/proof/stubs.py +156 -0
- codeframe/core/quick_fixes.py +558 -0
- codeframe/core/react_agent.py +1650 -0
- codeframe/core/reconciliation.py +183 -0
- codeframe/core/replay.py +788 -0
- codeframe/core/review.py +285 -0
- codeframe/core/runtime.py +1134 -0
- codeframe/core/sandbox/__init__.py +27 -0
- codeframe/core/sandbox/context.py +98 -0
- codeframe/core/sandbox/worktree.py +20 -0
- codeframe/core/schedule.py +396 -0
- codeframe/core/stall_detector.py +71 -0
- codeframe/core/stall_monitor.py +134 -0
- codeframe/core/state_machine.py +121 -0
- codeframe/core/streaming.py +502 -0
- codeframe/core/task_tree.py +400 -0
- codeframe/core/tasks.py +1022 -0
- codeframe/core/telemetry.py +232 -0
- codeframe/core/templates.py +221 -0
- codeframe/core/tools.py +942 -0
- codeframe/core/workspace.py +887 -0
- codeframe/core/worktrees.py +276 -0
- codeframe/git/__init__.py +5 -0
- codeframe/git/github_integration.py +505 -0
- codeframe/lib/__init__.py +0 -0
- codeframe/lib/audit_logger.py +248 -0
- codeframe/lib/metrics_tracker.py +800 -0
- codeframe/lib/quality/__init__.py +7 -0
- codeframe/lib/quality/complexity_analyzer.py +316 -0
- codeframe/lib/quality/owasp_patterns.py +284 -0
- codeframe/lib/quality/security_scanner.py +250 -0
- codeframe/lib/rate_limiter.py +312 -0
- codeframe/notifications/__init__.py +0 -0
- codeframe/notifications/webhook.py +380 -0
- codeframe/planning/__init__.py +30 -0
- codeframe/planning/issue_generator.py +219 -0
- codeframe/planning/prd_template_functions.py +137 -0
- codeframe/planning/prd_templates.py +975 -0
- codeframe/planning/task_scheduler.py +511 -0
- codeframe/planning/task_templates.py +533 -0
- codeframe/platform_store/__init__.py +5 -0
- codeframe/platform_store/database.py +277 -0
- codeframe/platform_store/repositories/__init__.py +24 -0
- codeframe/platform_store/repositories/api_key_repository.py +245 -0
- codeframe/platform_store/repositories/audit_repository.py +67 -0
- codeframe/platform_store/repositories/base.py +295 -0
- codeframe/platform_store/repositories/interactive_sessions.py +165 -0
- codeframe/platform_store/repositories/token_repository.py +598 -0
- codeframe/platform_store/repositories/workspace_registry_repository.py +175 -0
- codeframe/platform_store/schema_manager.py +321 -0
- codeframe/templates/AGENTS.md.default +94 -0
- codeframe/tui/__init__.py +5 -0
- codeframe/tui/app.py +256 -0
- codeframe/tui/data_service.py +103 -0
- codeframe/ui/__init__.py +0 -0
- codeframe/ui/dependencies.py +103 -0
- codeframe/ui/models.py +999 -0
- codeframe/ui/response_models.py +201 -0
- codeframe/ui/routers/__init__.py +5 -0
- codeframe/ui/routers/_helpers.py +29 -0
- codeframe/ui/routers/batches_v2.py +315 -0
- codeframe/ui/routers/blockers_v2.py +320 -0
- codeframe/ui/routers/checkpoints_v2.py +310 -0
- codeframe/ui/routers/costs_v2.py +322 -0
- codeframe/ui/routers/diagnose_v2.py +225 -0
- codeframe/ui/routers/discovery_v2.py +417 -0
- codeframe/ui/routers/environment_v2.py +284 -0
- codeframe/ui/routers/events_v2.py +75 -0
- codeframe/ui/routers/gates_v2.py +166 -0
- codeframe/ui/routers/git_v2.py +284 -0
- codeframe/ui/routers/github_integrations_v2.py +532 -0
- codeframe/ui/routers/interactive_sessions_v2.py +238 -0
- codeframe/ui/routers/pr_v2.py +709 -0
- codeframe/ui/routers/prd_v2.py +695 -0
- codeframe/ui/routers/proof_v2.py +755 -0
- codeframe/ui/routers/review_v2.py +360 -0
- codeframe/ui/routers/schedule_v2.py +214 -0
- codeframe/ui/routers/session_chat_ws.py +354 -0
- codeframe/ui/routers/settings_v2.py +562 -0
- codeframe/ui/routers/streaming_v2.py +155 -0
- codeframe/ui/routers/tasks_v2.py +1098 -0
- codeframe/ui/routers/templates_v2.py +232 -0
- codeframe/ui/routers/terminal_ws.py +267 -0
- codeframe/ui/routers/workspace_v2.py +527 -0
- codeframe/ui/server.py +568 -0
- codeframe/ui/shared.py +241 -0
- codeframe/workspace/__init__.py +5 -0
- codeframe/workspace/manager.py +249 -0
- codeframe_ai-0.9.0.dist-info/METADATA +517 -0
- codeframe_ai-0.9.0.dist-info/RECORD +197 -0
- codeframe_ai-0.9.0.dist-info/WHEEL +5 -0
- codeframe_ai-0.9.0.dist-info/entry_points.txt +3 -0
- codeframe_ai-0.9.0.dist-info/licenses/LICENSE +661 -0
- codeframe_ai-0.9.0.dist-info/top_level.txt +1 -0
codeframe/core/gates.py
ADDED
|
@@ -0,0 +1,1322 @@
|
|
|
1
|
+
"""Verification gates for CodeFRAME v2.
|
|
2
|
+
|
|
3
|
+
Gates are automated checks that run before code is considered complete.
|
|
4
|
+
MVP gates: pytest, ruff/lint.
|
|
5
|
+
|
|
6
|
+
This module is headless - no FastAPI or HTTP dependencies.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
import re
|
|
10
|
+
import subprocess
|
|
11
|
+
import shutil
|
|
12
|
+
from dataclasses import dataclass, field
|
|
13
|
+
from datetime import datetime, timezone
|
|
14
|
+
from enum import Enum
|
|
15
|
+
from pathlib import Path
|
|
16
|
+
from typing import Any, Callable, Optional
|
|
17
|
+
|
|
18
|
+
from codeframe.core.workspace import Workspace
|
|
19
|
+
from codeframe.core import events
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def _utc_now() -> datetime:
|
|
23
|
+
"""Get current UTC time as timezone-aware datetime."""
|
|
24
|
+
return datetime.now(timezone.utc)
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
_RUFF_ERROR_PATTERN = re.compile(r'^(.+?):(\d+):(\d+): ([A-Z]+\d+) (.+)$')
|
|
28
|
+
_TSC_ERROR_PATTERN = re.compile(r'^(.+?)\((\d+),(\d+)\): error (TS\d+): (.+)$')
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def _parse_ruff_errors(output: str) -> list[dict[str, Any]]:
|
|
32
|
+
"""Parse ruff output into structured error dicts.
|
|
33
|
+
|
|
34
|
+
Parses lines matching the pattern: path/file.py:10:5: E501 Line too long
|
|
35
|
+
|
|
36
|
+
Args:
|
|
37
|
+
output: Raw ruff stdout/stderr output.
|
|
38
|
+
|
|
39
|
+
Returns:
|
|
40
|
+
List of dicts with keys: file, line, col, code, message.
|
|
41
|
+
"""
|
|
42
|
+
errors = []
|
|
43
|
+
for line in output.splitlines():
|
|
44
|
+
match = _RUFF_ERROR_PATTERN.match(line.strip())
|
|
45
|
+
if match:
|
|
46
|
+
errors.append({
|
|
47
|
+
"file": match.group(1),
|
|
48
|
+
"line": int(match.group(2)),
|
|
49
|
+
"col": int(match.group(3)),
|
|
50
|
+
"code": match.group(4),
|
|
51
|
+
"message": match.group(5),
|
|
52
|
+
})
|
|
53
|
+
return errors
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def _parse_tsc_errors(output: str) -> list[dict[str, Any]]:
|
|
57
|
+
"""Parse TypeScript compiler output into structured error dicts.
|
|
58
|
+
|
|
59
|
+
Parses lines matching the pattern: src/file.ts(10,5): error TS2339: Property does not exist
|
|
60
|
+
|
|
61
|
+
Args:
|
|
62
|
+
output: Raw tsc stdout/stderr output.
|
|
63
|
+
|
|
64
|
+
Returns:
|
|
65
|
+
List of dicts with keys: file, line, col, code, message.
|
|
66
|
+
"""
|
|
67
|
+
errors = []
|
|
68
|
+
for line in output.splitlines():
|
|
69
|
+
match = _TSC_ERROR_PATTERN.match(line.strip())
|
|
70
|
+
if match:
|
|
71
|
+
errors.append({
|
|
72
|
+
"file": match.group(1),
|
|
73
|
+
"line": int(match.group(2)),
|
|
74
|
+
"col": int(match.group(3)),
|
|
75
|
+
"code": match.group(4),
|
|
76
|
+
"message": match.group(5),
|
|
77
|
+
})
|
|
78
|
+
return errors
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
class GateStatus(str, Enum):
|
|
82
|
+
"""Status of a gate check."""
|
|
83
|
+
|
|
84
|
+
PASSED = "PASSED"
|
|
85
|
+
FAILED = "FAILED"
|
|
86
|
+
SKIPPED = "SKIPPED"
|
|
87
|
+
ERROR = "ERROR"
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
@dataclass
|
|
91
|
+
class GateCheck:
|
|
92
|
+
"""Result of a single gate check.
|
|
93
|
+
|
|
94
|
+
Attributes:
|
|
95
|
+
name: Gate name (e.g., 'pytest', 'ruff')
|
|
96
|
+
status: Pass/fail/skip status
|
|
97
|
+
exit_code: Process exit code (if run)
|
|
98
|
+
output: Captured stdout/stderr
|
|
99
|
+
duration_ms: How long the check took
|
|
100
|
+
detailed_errors: Structured error list parsed from tool output
|
|
101
|
+
"""
|
|
102
|
+
|
|
103
|
+
name: str
|
|
104
|
+
status: GateStatus
|
|
105
|
+
exit_code: Optional[int] = None
|
|
106
|
+
output: str = ""
|
|
107
|
+
duration_ms: int = 0
|
|
108
|
+
detailed_errors: Optional[list[dict[str, Any]]] = None
|
|
109
|
+
|
|
110
|
+
|
|
111
|
+
@dataclass
|
|
112
|
+
class GateResult:
|
|
113
|
+
"""Result of running all gates.
|
|
114
|
+
|
|
115
|
+
Attributes:
|
|
116
|
+
passed: Whether all gates passed
|
|
117
|
+
checks: List of individual gate checks
|
|
118
|
+
started_at: When gates started
|
|
119
|
+
completed_at: When gates completed
|
|
120
|
+
"""
|
|
121
|
+
|
|
122
|
+
passed: bool
|
|
123
|
+
checks: list[GateCheck] = field(default_factory=list)
|
|
124
|
+
started_at: Optional[datetime] = None
|
|
125
|
+
completed_at: Optional[datetime] = None
|
|
126
|
+
|
|
127
|
+
@property
|
|
128
|
+
def summary(self) -> str:
|
|
129
|
+
"""Human-readable summary."""
|
|
130
|
+
passed_count = sum(1 for c in self.checks if c.status == GateStatus.PASSED)
|
|
131
|
+
failed_count = sum(1 for c in self.checks if c.status == GateStatus.FAILED)
|
|
132
|
+
skipped_count = sum(1 for c in self.checks if c.status == GateStatus.SKIPPED)
|
|
133
|
+
error_count = sum(1 for c in self.checks if c.status == GateStatus.ERROR)
|
|
134
|
+
|
|
135
|
+
parts = []
|
|
136
|
+
if passed_count:
|
|
137
|
+
parts.append(f"{passed_count} passed")
|
|
138
|
+
if failed_count:
|
|
139
|
+
parts.append(f"{failed_count} failed")
|
|
140
|
+
if error_count:
|
|
141
|
+
parts.append(f"{error_count} errors")
|
|
142
|
+
if skipped_count:
|
|
143
|
+
parts.append(f"{skipped_count} skipped")
|
|
144
|
+
|
|
145
|
+
return ", ".join(parts) if parts else "no checks run"
|
|
146
|
+
|
|
147
|
+
def get_error_summary(self) -> str:
|
|
148
|
+
"""Format all errors into a readable multi-line string.
|
|
149
|
+
|
|
150
|
+
Returns:
|
|
151
|
+
Newline-separated string of all structured errors from failed checks,
|
|
152
|
+
or empty string if no errors.
|
|
153
|
+
"""
|
|
154
|
+
lines = []
|
|
155
|
+
for check in self.checks:
|
|
156
|
+
if check.detailed_errors:
|
|
157
|
+
for err in check.detailed_errors:
|
|
158
|
+
lines.append(
|
|
159
|
+
f"{err['file']}:{err['line']}:{err['col']}: "
|
|
160
|
+
f"{err['code']} {err['message']}"
|
|
161
|
+
)
|
|
162
|
+
return "\n".join(lines)
|
|
163
|
+
|
|
164
|
+
def get_errors_by_file(self) -> dict[str, list[str]]:
|
|
165
|
+
"""Group error messages by file path.
|
|
166
|
+
|
|
167
|
+
Returns:
|
|
168
|
+
Dict mapping file paths to lists of formatted error strings.
|
|
169
|
+
"""
|
|
170
|
+
by_file: dict[str, list[str]] = {}
|
|
171
|
+
for check in self.checks:
|
|
172
|
+
if check.detailed_errors:
|
|
173
|
+
for err in check.detailed_errors:
|
|
174
|
+
file_path = err["file"]
|
|
175
|
+
msg = f"{err['code']} {err['message']} (line {err['line']})"
|
|
176
|
+
by_file.setdefault(file_path, []).append(msg)
|
|
177
|
+
return by_file
|
|
178
|
+
|
|
179
|
+
|
|
180
|
+
def _ensure_dependencies_installed(
|
|
181
|
+
repo_path: Path,
|
|
182
|
+
auto_install: bool = True,
|
|
183
|
+
) -> tuple[bool, str]:
|
|
184
|
+
"""Ensure project dependencies are installed before running test gates.
|
|
185
|
+
|
|
186
|
+
Checks for missing dependencies and optionally installs them:
|
|
187
|
+
- Python: If requirements.txt exists but no .venv/ or venv/ directory
|
|
188
|
+
- Node.js: If package.json exists but no node_modules/ directory
|
|
189
|
+
|
|
190
|
+
Args:
|
|
191
|
+
repo_path: Path to the repository root
|
|
192
|
+
auto_install: Whether to auto-install missing dependencies (default: True)
|
|
193
|
+
|
|
194
|
+
Returns:
|
|
195
|
+
Tuple of (success: bool, message: str)
|
|
196
|
+
- success=True means deps installed or already present
|
|
197
|
+
- success=False means installation failed
|
|
198
|
+
"""
|
|
199
|
+
messages = []
|
|
200
|
+
|
|
201
|
+
# Check Python dependencies
|
|
202
|
+
requirements_txt = repo_path / "requirements.txt"
|
|
203
|
+
venv_dirs = [repo_path / ".venv", repo_path / "venv"]
|
|
204
|
+
has_venv = any(d.exists() and d.is_dir() for d in venv_dirs)
|
|
205
|
+
|
|
206
|
+
if requirements_txt.exists() and not has_venv:
|
|
207
|
+
if not auto_install:
|
|
208
|
+
messages.append("Python dependencies not installed (auto-install disabled)")
|
|
209
|
+
else:
|
|
210
|
+
# Try to install using uv first, fallback to pip
|
|
211
|
+
uv_bin = shutil.which("uv")
|
|
212
|
+
pip_bin = shutil.which("pip")
|
|
213
|
+
|
|
214
|
+
if uv_bin:
|
|
215
|
+
try:
|
|
216
|
+
result = subprocess.run(
|
|
217
|
+
["uv", "pip", "install", "-r", str(requirements_txt)],
|
|
218
|
+
cwd=repo_path,
|
|
219
|
+
capture_output=True,
|
|
220
|
+
text=True,
|
|
221
|
+
timeout=300, # 5 minutes
|
|
222
|
+
)
|
|
223
|
+
if result.returncode == 0:
|
|
224
|
+
messages.append(f"Installed Python dependencies via uv: {requirements_txt.name}")
|
|
225
|
+
else:
|
|
226
|
+
return False, f"Failed to install Python dependencies: {result.stderr}"
|
|
227
|
+
except Exception as e:
|
|
228
|
+
return False, f"Error installing Python dependencies: {e}"
|
|
229
|
+
elif pip_bin:
|
|
230
|
+
try:
|
|
231
|
+
result = subprocess.run(
|
|
232
|
+
["pip", "install", "-r", str(requirements_txt)],
|
|
233
|
+
cwd=repo_path,
|
|
234
|
+
capture_output=True,
|
|
235
|
+
text=True,
|
|
236
|
+
timeout=300,
|
|
237
|
+
)
|
|
238
|
+
if result.returncode == 0:
|
|
239
|
+
messages.append(f"Installed Python dependencies via pip: {requirements_txt.name}")
|
|
240
|
+
else:
|
|
241
|
+
return False, f"Failed to install Python dependencies: {result.stderr}"
|
|
242
|
+
except Exception as e:
|
|
243
|
+
return False, f"Error installing Python dependencies: {e}"
|
|
244
|
+
else:
|
|
245
|
+
messages.append("Python dependencies needed but no package manager found (uv/pip)")
|
|
246
|
+
elif requirements_txt.exists() and has_venv:
|
|
247
|
+
messages.append("Python dependencies already installed (venv exists)")
|
|
248
|
+
|
|
249
|
+
# Check Node.js dependencies
|
|
250
|
+
package_json = repo_path / "package.json"
|
|
251
|
+
node_modules = repo_path / "node_modules"
|
|
252
|
+
|
|
253
|
+
if package_json.exists() and not node_modules.exists():
|
|
254
|
+
if not auto_install:
|
|
255
|
+
messages.append("Node dependencies not installed (auto-install disabled)")
|
|
256
|
+
else:
|
|
257
|
+
npm_bin = shutil.which("npm")
|
|
258
|
+
|
|
259
|
+
if npm_bin:
|
|
260
|
+
try:
|
|
261
|
+
result = subprocess.run(
|
|
262
|
+
["npm", "install"],
|
|
263
|
+
cwd=repo_path,
|
|
264
|
+
capture_output=True,
|
|
265
|
+
text=True,
|
|
266
|
+
timeout=300, # 5 minutes
|
|
267
|
+
)
|
|
268
|
+
if result.returncode == 0:
|
|
269
|
+
messages.append("Installed Node dependencies via npm")
|
|
270
|
+
else:
|
|
271
|
+
return False, f"Failed to install Node dependencies: {result.stderr}"
|
|
272
|
+
except Exception as e:
|
|
273
|
+
return False, f"Error installing Node dependencies: {e}"
|
|
274
|
+
else:
|
|
275
|
+
messages.append("Node dependencies needed but npm not found")
|
|
276
|
+
elif package_json.exists() and node_modules.exists():
|
|
277
|
+
messages.append("Node dependencies already installed (node_modules exists)")
|
|
278
|
+
|
|
279
|
+
# Success if we get here
|
|
280
|
+
if not messages:
|
|
281
|
+
return True, "No dependency checks needed"
|
|
282
|
+
return True, " | ".join(messages)
|
|
283
|
+
|
|
284
|
+
|
|
285
|
+
def run(
|
|
286
|
+
workspace: Workspace,
|
|
287
|
+
gates: Optional[list[str]] = None,
|
|
288
|
+
verbose: bool = False,
|
|
289
|
+
auto_install_deps: bool = True,
|
|
290
|
+
) -> GateResult:
|
|
291
|
+
"""Run verification gates.
|
|
292
|
+
|
|
293
|
+
Args:
|
|
294
|
+
workspace: Target workspace
|
|
295
|
+
gates: Specific gates to run (None = all available)
|
|
296
|
+
verbose: Whether to capture full output
|
|
297
|
+
auto_install_deps: Whether to auto-install missing dependencies before test gates (default: True)
|
|
298
|
+
|
|
299
|
+
Returns:
|
|
300
|
+
GateResult with all check results
|
|
301
|
+
"""
|
|
302
|
+
started_at = _utc_now()
|
|
303
|
+
|
|
304
|
+
# Track whether gates were explicitly provided (vs auto-detected)
|
|
305
|
+
gates_explicitly_provided = gates is not None
|
|
306
|
+
|
|
307
|
+
# Emit gates started event - report actual provided list or ["auto"]
|
|
308
|
+
events.emit_for_workspace(
|
|
309
|
+
workspace,
|
|
310
|
+
events.EventType.GATES_STARTED,
|
|
311
|
+
{"gates": gates if gates is not None else ["auto"]},
|
|
312
|
+
print_event=True,
|
|
313
|
+
)
|
|
314
|
+
|
|
315
|
+
# PRE-FLIGHT: Ensure dependencies are installed before running test gates
|
|
316
|
+
dep_success, dep_message = _ensure_dependencies_installed(
|
|
317
|
+
workspace.repo_path,
|
|
318
|
+
auto_install=auto_install_deps,
|
|
319
|
+
)
|
|
320
|
+
if verbose:
|
|
321
|
+
print(f"[gates] Dependency check: {dep_message}")
|
|
322
|
+
|
|
323
|
+
# If dependency installation fails, create an ERROR check and return early
|
|
324
|
+
if not dep_success:
|
|
325
|
+
check = GateCheck(
|
|
326
|
+
name="dependency-check",
|
|
327
|
+
status=GateStatus.ERROR,
|
|
328
|
+
output=f"Dependency installation failed: {dep_message}",
|
|
329
|
+
)
|
|
330
|
+
result = GateResult(
|
|
331
|
+
passed=False,
|
|
332
|
+
checks=[check],
|
|
333
|
+
started_at=started_at,
|
|
334
|
+
completed_at=_utc_now(),
|
|
335
|
+
)
|
|
336
|
+
events.emit_for_workspace(
|
|
337
|
+
workspace,
|
|
338
|
+
events.EventType.GATES_COMPLETED,
|
|
339
|
+
{
|
|
340
|
+
"passed": False,
|
|
341
|
+
"summary": "Dependency installation failed",
|
|
342
|
+
"checks": [{"name": check.name, "status": check.status.value}],
|
|
343
|
+
},
|
|
344
|
+
print_event=True,
|
|
345
|
+
)
|
|
346
|
+
return result
|
|
347
|
+
|
|
348
|
+
checks: list[GateCheck] = []
|
|
349
|
+
repo_path = workspace.repo_path
|
|
350
|
+
|
|
351
|
+
# Determine which gates to run
|
|
352
|
+
if gates is None:
|
|
353
|
+
gates = _detect_available_gates(repo_path)
|
|
354
|
+
|
|
355
|
+
# Known gate names
|
|
356
|
+
known_gates = {"pytest", "ruff", "mypy", "npm-test", "npm-lint", "tsc", "python-build", "npm-build"}
|
|
357
|
+
|
|
358
|
+
# Run each gate
|
|
359
|
+
for gate_name in gates:
|
|
360
|
+
if gate_name == "pytest":
|
|
361
|
+
check = _run_pytest(repo_path, verbose)
|
|
362
|
+
elif gate_name == "ruff":
|
|
363
|
+
check = _run_ruff(repo_path, verbose)
|
|
364
|
+
elif gate_name == "mypy":
|
|
365
|
+
check = _run_mypy(repo_path, verbose)
|
|
366
|
+
elif gate_name == "npm-test":
|
|
367
|
+
check = _run_npm_test(repo_path, verbose)
|
|
368
|
+
elif gate_name == "npm-lint":
|
|
369
|
+
check = _run_npm_lint(repo_path, verbose)
|
|
370
|
+
elif gate_name == "tsc":
|
|
371
|
+
check = _run_tsc(repo_path, verbose)
|
|
372
|
+
elif gate_name == "python-build":
|
|
373
|
+
check = _run_python_build(repo_path, verbose)
|
|
374
|
+
elif gate_name == "npm-build":
|
|
375
|
+
check = _run_npm_build(repo_path, verbose)
|
|
376
|
+
else:
|
|
377
|
+
# Unknown gate: FAILED if explicitly requested, SKIPPED if auto-detected
|
|
378
|
+
if gates_explicitly_provided:
|
|
379
|
+
check = GateCheck(
|
|
380
|
+
name=gate_name,
|
|
381
|
+
status=GateStatus.FAILED,
|
|
382
|
+
output=f"Unknown gate: {gate_name}. Valid gates: {', '.join(sorted(known_gates))}",
|
|
383
|
+
)
|
|
384
|
+
else:
|
|
385
|
+
check = GateCheck(
|
|
386
|
+
name=gate_name,
|
|
387
|
+
status=GateStatus.SKIPPED,
|
|
388
|
+
output=f"Unknown gate: {gate_name}",
|
|
389
|
+
)
|
|
390
|
+
|
|
391
|
+
checks.append(check)
|
|
392
|
+
|
|
393
|
+
completed_at = _utc_now()
|
|
394
|
+
|
|
395
|
+
# Determine overall pass/fail
|
|
396
|
+
passed = all(
|
|
397
|
+
c.status in (GateStatus.PASSED, GateStatus.SKIPPED) for c in checks
|
|
398
|
+
)
|
|
399
|
+
|
|
400
|
+
result = GateResult(
|
|
401
|
+
passed=passed,
|
|
402
|
+
checks=checks,
|
|
403
|
+
started_at=started_at,
|
|
404
|
+
completed_at=completed_at,
|
|
405
|
+
)
|
|
406
|
+
|
|
407
|
+
# Emit gates completed event
|
|
408
|
+
events.emit_for_workspace(
|
|
409
|
+
workspace,
|
|
410
|
+
events.EventType.GATES_COMPLETED,
|
|
411
|
+
{
|
|
412
|
+
"passed": passed,
|
|
413
|
+
"summary": result.summary,
|
|
414
|
+
"checks": [{"name": c.name, "status": c.status.value} for c in checks],
|
|
415
|
+
},
|
|
416
|
+
print_event=True,
|
|
417
|
+
)
|
|
418
|
+
|
|
419
|
+
return result
|
|
420
|
+
|
|
421
|
+
|
|
422
|
+
def _detect_available_gates(repo_path: Path) -> list[str]:
|
|
423
|
+
"""Detect which gates are available in the repo."""
|
|
424
|
+
gates = []
|
|
425
|
+
|
|
426
|
+
# Python: pytest
|
|
427
|
+
if (repo_path / "pytest.ini").exists() or \
|
|
428
|
+
(repo_path / "pyproject.toml").exists() or \
|
|
429
|
+
(repo_path / "setup.py").exists() or \
|
|
430
|
+
(repo_path / "tests").is_dir():
|
|
431
|
+
gates.append("pytest")
|
|
432
|
+
|
|
433
|
+
# Python: ruff (available via direct command or via uv)
|
|
434
|
+
if (shutil.which("ruff") or shutil.which("uv")) and (
|
|
435
|
+
(repo_path / "pyproject.toml").exists() or
|
|
436
|
+
(repo_path / "ruff.toml").exists() or
|
|
437
|
+
any(repo_path.glob("*.py"))
|
|
438
|
+
):
|
|
439
|
+
gates.append("ruff")
|
|
440
|
+
|
|
441
|
+
# Node.js: npm test
|
|
442
|
+
package_json = repo_path / "package.json"
|
|
443
|
+
if package_json.exists():
|
|
444
|
+
try:
|
|
445
|
+
import json
|
|
446
|
+
pkg = json.loads(package_json.read_text())
|
|
447
|
+
scripts = pkg.get("scripts", {})
|
|
448
|
+
if "test" in scripts:
|
|
449
|
+
gates.append("npm-test")
|
|
450
|
+
if "lint" in scripts:
|
|
451
|
+
gates.append("npm-lint")
|
|
452
|
+
except Exception:
|
|
453
|
+
pass
|
|
454
|
+
|
|
455
|
+
# TypeScript: tsc type checking
|
|
456
|
+
if (repo_path / "tsconfig.json").exists():
|
|
457
|
+
gates.append("tsc")
|
|
458
|
+
|
|
459
|
+
# Python build verification (smoke test import)
|
|
460
|
+
for entry_point in ["main.py", "app.py", "api.py", "__main__.py"]:
|
|
461
|
+
if (repo_path / entry_point).exists():
|
|
462
|
+
gates.append("python-build")
|
|
463
|
+
break
|
|
464
|
+
|
|
465
|
+
# Node.js build verification
|
|
466
|
+
if package_json.exists():
|
|
467
|
+
try:
|
|
468
|
+
import json
|
|
469
|
+
pkg = json.loads(package_json.read_text())
|
|
470
|
+
scripts = pkg.get("scripts", {})
|
|
471
|
+
if "build" in scripts:
|
|
472
|
+
gates.append("npm-build")
|
|
473
|
+
except Exception:
|
|
474
|
+
pass
|
|
475
|
+
|
|
476
|
+
return gates
|
|
477
|
+
|
|
478
|
+
|
|
479
|
+
def _run_pytest(repo_path: Path, verbose: bool = False) -> GateCheck:
|
|
480
|
+
"""Run pytest."""
|
|
481
|
+
import time
|
|
482
|
+
|
|
483
|
+
start = time.time()
|
|
484
|
+
|
|
485
|
+
# Check if pytest is available
|
|
486
|
+
if not shutil.which("pytest") and not shutil.which("uv"):
|
|
487
|
+
return GateCheck(
|
|
488
|
+
name="pytest",
|
|
489
|
+
status=GateStatus.SKIPPED,
|
|
490
|
+
output="pytest not found",
|
|
491
|
+
)
|
|
492
|
+
|
|
493
|
+
try:
|
|
494
|
+
# Try uv run pytest first, fall back to pytest
|
|
495
|
+
if shutil.which("uv"):
|
|
496
|
+
cmd = ["uv", "run", "pytest", "-v", "--tb=short"]
|
|
497
|
+
else:
|
|
498
|
+
cmd = ["pytest", "-v", "--tb=short"]
|
|
499
|
+
|
|
500
|
+
result = subprocess.run(
|
|
501
|
+
cmd,
|
|
502
|
+
cwd=repo_path,
|
|
503
|
+
capture_output=True,
|
|
504
|
+
text=True,
|
|
505
|
+
timeout=300, # 5 minute timeout
|
|
506
|
+
)
|
|
507
|
+
|
|
508
|
+
duration_ms = int((time.time() - start) * 1000)
|
|
509
|
+
|
|
510
|
+
output = result.stdout
|
|
511
|
+
if result.stderr:
|
|
512
|
+
output += "\n" + result.stderr
|
|
513
|
+
|
|
514
|
+
# Truncate output if too long
|
|
515
|
+
if len(output) > 10000:
|
|
516
|
+
output = output[:5000] + "\n...[truncated]...\n" + output[-5000:]
|
|
517
|
+
|
|
518
|
+
# Determine status based on exit code and output patterns
|
|
519
|
+
# pytest exit codes:
|
|
520
|
+
# 0 = all tests passed
|
|
521
|
+
# 1 = tests were collected and run but some failed
|
|
522
|
+
# 2 = test execution was interrupted by the user
|
|
523
|
+
# 3 = internal error happened while executing tests
|
|
524
|
+
# 4 = pytest command line usage error (often import errors)
|
|
525
|
+
# 5 = no tests were collected
|
|
526
|
+
status = GateStatus.PASSED
|
|
527
|
+
|
|
528
|
+
if result.returncode == 0:
|
|
529
|
+
status = GateStatus.PASSED
|
|
530
|
+
elif result.returncode == 1:
|
|
531
|
+
# Tests ran but failed - this is a legitimate FAILED
|
|
532
|
+
status = GateStatus.FAILED
|
|
533
|
+
elif result.returncode in [2, 3, 4]:
|
|
534
|
+
# Collection errors, internal errors, usage errors - all FAILED
|
|
535
|
+
status = GateStatus.FAILED
|
|
536
|
+
elif result.returncode == 5:
|
|
537
|
+
# Exit code 5: no tests collected
|
|
538
|
+
# Check if this is a clean "no tests" or an error during collection
|
|
539
|
+
output_lower = output.lower()
|
|
540
|
+
if "error" in output_lower or "importerror" in output_lower or "modulenotfounderror" in output_lower:
|
|
541
|
+
# Collection error disguised as "no tests collected"
|
|
542
|
+
status = GateStatus.FAILED
|
|
543
|
+
elif "no tests ran" in output_lower or "collected 0 items" in output_lower:
|
|
544
|
+
# Legitimately empty test suite - acceptable
|
|
545
|
+
status = GateStatus.PASSED
|
|
546
|
+
else:
|
|
547
|
+
# Unclear, default to FAILED to be safe
|
|
548
|
+
status = GateStatus.FAILED
|
|
549
|
+
else:
|
|
550
|
+
# Unknown exit code - treat as FAILED
|
|
551
|
+
status = GateStatus.FAILED
|
|
552
|
+
|
|
553
|
+
return GateCheck(
|
|
554
|
+
name="pytest",
|
|
555
|
+
status=status,
|
|
556
|
+
exit_code=result.returncode,
|
|
557
|
+
output=output if verbose else _summarize_pytest_output(output),
|
|
558
|
+
duration_ms=duration_ms,
|
|
559
|
+
)
|
|
560
|
+
|
|
561
|
+
except subprocess.TimeoutExpired:
|
|
562
|
+
return GateCheck(
|
|
563
|
+
name="pytest",
|
|
564
|
+
status=GateStatus.ERROR,
|
|
565
|
+
output="Timeout after 5 minutes",
|
|
566
|
+
)
|
|
567
|
+
except Exception as e:
|
|
568
|
+
return GateCheck(
|
|
569
|
+
name="pytest",
|
|
570
|
+
status=GateStatus.ERROR,
|
|
571
|
+
output=str(e),
|
|
572
|
+
)
|
|
573
|
+
|
|
574
|
+
|
|
575
|
+
def _run_ruff(repo_path: Path, verbose: bool = False) -> GateCheck:
|
|
576
|
+
"""Run ruff linter."""
|
|
577
|
+
import time
|
|
578
|
+
|
|
579
|
+
start = time.time()
|
|
580
|
+
|
|
581
|
+
# Check if ruff is available (either directly or via uv)
|
|
582
|
+
if not shutil.which("ruff") and not shutil.which("uv"):
|
|
583
|
+
return GateCheck(
|
|
584
|
+
name="ruff",
|
|
585
|
+
status=GateStatus.SKIPPED,
|
|
586
|
+
output="ruff not found",
|
|
587
|
+
)
|
|
588
|
+
|
|
589
|
+
try:
|
|
590
|
+
# Use uv run ruff if uv is available (runs in target project's environment)
|
|
591
|
+
# This ensures ruff runs with the target project's dependencies
|
|
592
|
+
if shutil.which("uv"):
|
|
593
|
+
cmd = ["uv", "run", "ruff", "check", "."]
|
|
594
|
+
else:
|
|
595
|
+
cmd = ["ruff", "check", "."]
|
|
596
|
+
|
|
597
|
+
result = subprocess.run(
|
|
598
|
+
cmd,
|
|
599
|
+
cwd=repo_path,
|
|
600
|
+
capture_output=True,
|
|
601
|
+
text=True,
|
|
602
|
+
timeout=60,
|
|
603
|
+
)
|
|
604
|
+
|
|
605
|
+
duration_ms = int((time.time() - start) * 1000)
|
|
606
|
+
|
|
607
|
+
output = result.stdout
|
|
608
|
+
if result.stderr:
|
|
609
|
+
output += "\n" + result.stderr
|
|
610
|
+
|
|
611
|
+
check = GateCheck(
|
|
612
|
+
name="ruff",
|
|
613
|
+
status=GateStatus.PASSED if result.returncode == 0 else GateStatus.FAILED,
|
|
614
|
+
exit_code=result.returncode,
|
|
615
|
+
output=output if verbose else _summarize_ruff_output(output),
|
|
616
|
+
duration_ms=duration_ms,
|
|
617
|
+
)
|
|
618
|
+
|
|
619
|
+
# Parse detailed errors for failed checks
|
|
620
|
+
if check.status == GateStatus.FAILED:
|
|
621
|
+
check.detailed_errors = _parse_ruff_errors(output)
|
|
622
|
+
|
|
623
|
+
return check
|
|
624
|
+
|
|
625
|
+
except subprocess.TimeoutExpired:
|
|
626
|
+
return GateCheck(
|
|
627
|
+
name="ruff",
|
|
628
|
+
status=GateStatus.ERROR,
|
|
629
|
+
output="Timeout after 60 seconds",
|
|
630
|
+
)
|
|
631
|
+
except Exception as e:
|
|
632
|
+
return GateCheck(
|
|
633
|
+
name="ruff",
|
|
634
|
+
status=GateStatus.ERROR,
|
|
635
|
+
output=str(e),
|
|
636
|
+
)
|
|
637
|
+
|
|
638
|
+
|
|
639
|
+
def _run_mypy(repo_path: Path, verbose: bool = False) -> GateCheck:
|
|
640
|
+
"""Run mypy type checker."""
|
|
641
|
+
import time
|
|
642
|
+
|
|
643
|
+
start = time.time()
|
|
644
|
+
|
|
645
|
+
if not shutil.which("mypy"):
|
|
646
|
+
return GateCheck(
|
|
647
|
+
name="mypy",
|
|
648
|
+
status=GateStatus.SKIPPED,
|
|
649
|
+
output="mypy not found",
|
|
650
|
+
)
|
|
651
|
+
|
|
652
|
+
try:
|
|
653
|
+
result = subprocess.run(
|
|
654
|
+
["mypy", "."],
|
|
655
|
+
cwd=repo_path,
|
|
656
|
+
capture_output=True,
|
|
657
|
+
text=True,
|
|
658
|
+
timeout=120,
|
|
659
|
+
)
|
|
660
|
+
|
|
661
|
+
duration_ms = int((time.time() - start) * 1000)
|
|
662
|
+
|
|
663
|
+
output = result.stdout
|
|
664
|
+
if result.stderr:
|
|
665
|
+
output += "\n" + result.stderr
|
|
666
|
+
|
|
667
|
+
return GateCheck(
|
|
668
|
+
name="mypy",
|
|
669
|
+
status=GateStatus.PASSED if result.returncode == 0 else GateStatus.FAILED,
|
|
670
|
+
exit_code=result.returncode,
|
|
671
|
+
output=output[:2000] if not verbose else output,
|
|
672
|
+
duration_ms=duration_ms,
|
|
673
|
+
)
|
|
674
|
+
|
|
675
|
+
except subprocess.TimeoutExpired:
|
|
676
|
+
return GateCheck(
|
|
677
|
+
name="mypy",
|
|
678
|
+
status=GateStatus.ERROR,
|
|
679
|
+
output="Timeout after 120 seconds",
|
|
680
|
+
)
|
|
681
|
+
except Exception as e:
|
|
682
|
+
return GateCheck(
|
|
683
|
+
name="mypy",
|
|
684
|
+
status=GateStatus.ERROR,
|
|
685
|
+
output=str(e),
|
|
686
|
+
)
|
|
687
|
+
|
|
688
|
+
|
|
689
|
+
def _run_npm_test(repo_path: Path, verbose: bool = False) -> GateCheck:
|
|
690
|
+
"""Run npm test."""
|
|
691
|
+
import time
|
|
692
|
+
|
|
693
|
+
start = time.time()
|
|
694
|
+
|
|
695
|
+
if not shutil.which("npm"):
|
|
696
|
+
return GateCheck(
|
|
697
|
+
name="npm-test",
|
|
698
|
+
status=GateStatus.SKIPPED,
|
|
699
|
+
output="npm not found",
|
|
700
|
+
)
|
|
701
|
+
|
|
702
|
+
try:
|
|
703
|
+
result = subprocess.run(
|
|
704
|
+
["npm", "test"],
|
|
705
|
+
cwd=repo_path,
|
|
706
|
+
capture_output=True,
|
|
707
|
+
text=True,
|
|
708
|
+
timeout=300,
|
|
709
|
+
)
|
|
710
|
+
|
|
711
|
+
duration_ms = int((time.time() - start) * 1000)
|
|
712
|
+
|
|
713
|
+
output = result.stdout
|
|
714
|
+
if result.stderr:
|
|
715
|
+
output += "\n" + result.stderr
|
|
716
|
+
|
|
717
|
+
return GateCheck(
|
|
718
|
+
name="npm-test",
|
|
719
|
+
status=GateStatus.PASSED if result.returncode == 0 else GateStatus.FAILED,
|
|
720
|
+
exit_code=result.returncode,
|
|
721
|
+
output=output[:5000] if not verbose else output,
|
|
722
|
+
duration_ms=duration_ms,
|
|
723
|
+
)
|
|
724
|
+
|
|
725
|
+
except subprocess.TimeoutExpired:
|
|
726
|
+
return GateCheck(
|
|
727
|
+
name="npm-test",
|
|
728
|
+
status=GateStatus.ERROR,
|
|
729
|
+
output="Timeout after 5 minutes",
|
|
730
|
+
)
|
|
731
|
+
except Exception as e:
|
|
732
|
+
return GateCheck(
|
|
733
|
+
name="npm-test",
|
|
734
|
+
status=GateStatus.ERROR,
|
|
735
|
+
output=str(e),
|
|
736
|
+
)
|
|
737
|
+
|
|
738
|
+
|
|
739
|
+
def _run_npm_lint(repo_path: Path, verbose: bool = False) -> GateCheck:
|
|
740
|
+
"""Run npm run lint."""
|
|
741
|
+
import time
|
|
742
|
+
|
|
743
|
+
start = time.time()
|
|
744
|
+
|
|
745
|
+
if not shutil.which("npm"):
|
|
746
|
+
return GateCheck(
|
|
747
|
+
name="npm-lint",
|
|
748
|
+
status=GateStatus.SKIPPED,
|
|
749
|
+
output="npm not found",
|
|
750
|
+
)
|
|
751
|
+
|
|
752
|
+
try:
|
|
753
|
+
result = subprocess.run(
|
|
754
|
+
["npm", "run", "lint"],
|
|
755
|
+
cwd=repo_path,
|
|
756
|
+
capture_output=True,
|
|
757
|
+
text=True,
|
|
758
|
+
timeout=120,
|
|
759
|
+
)
|
|
760
|
+
|
|
761
|
+
duration_ms = int((time.time() - start) * 1000)
|
|
762
|
+
|
|
763
|
+
output = result.stdout
|
|
764
|
+
if result.stderr:
|
|
765
|
+
output += "\n" + result.stderr
|
|
766
|
+
|
|
767
|
+
return GateCheck(
|
|
768
|
+
name="npm-lint",
|
|
769
|
+
status=GateStatus.PASSED if result.returncode == 0 else GateStatus.FAILED,
|
|
770
|
+
exit_code=result.returncode,
|
|
771
|
+
output=output[:2000] if not verbose else output,
|
|
772
|
+
duration_ms=duration_ms,
|
|
773
|
+
)
|
|
774
|
+
|
|
775
|
+
except subprocess.TimeoutExpired:
|
|
776
|
+
return GateCheck(
|
|
777
|
+
name="npm-lint",
|
|
778
|
+
status=GateStatus.ERROR,
|
|
779
|
+
output="Timeout after 120 seconds",
|
|
780
|
+
)
|
|
781
|
+
except Exception as e:
|
|
782
|
+
return GateCheck(
|
|
783
|
+
name="npm-lint",
|
|
784
|
+
status=GateStatus.ERROR,
|
|
785
|
+
output=str(e),
|
|
786
|
+
)
|
|
787
|
+
|
|
788
|
+
|
|
789
|
+
def _run_python_build(repo_path: Path, verbose: bool = False) -> GateCheck:
|
|
790
|
+
"""Run Python build verification via smoke test import.
|
|
791
|
+
|
|
792
|
+
Attempts to import common entry points to verify the project builds/imports correctly.
|
|
793
|
+
Checks for: main.py, app.py, api.py, __main__.py
|
|
794
|
+
"""
|
|
795
|
+
import time
|
|
796
|
+
|
|
797
|
+
start = time.time()
|
|
798
|
+
|
|
799
|
+
# Detect entry points
|
|
800
|
+
entry_points = []
|
|
801
|
+
for candidate in ["main", "app", "api", "__main__"]:
|
|
802
|
+
if (repo_path / f"{candidate}.py").exists():
|
|
803
|
+
entry_points.append(candidate)
|
|
804
|
+
|
|
805
|
+
if not entry_points:
|
|
806
|
+
return GateCheck(
|
|
807
|
+
name="python-build",
|
|
808
|
+
status=GateStatus.SKIPPED,
|
|
809
|
+
output="No entry point files found (main.py, app.py, api.py, __main__.py)",
|
|
810
|
+
)
|
|
811
|
+
|
|
812
|
+
# Check if python is available
|
|
813
|
+
python_cmd = "python"
|
|
814
|
+
if shutil.which("uv"):
|
|
815
|
+
python_cmd = "uv run python"
|
|
816
|
+
elif not shutil.which("python"):
|
|
817
|
+
return GateCheck(
|
|
818
|
+
name="python-build",
|
|
819
|
+
status=GateStatus.SKIPPED,
|
|
820
|
+
output="python not found",
|
|
821
|
+
)
|
|
822
|
+
|
|
823
|
+
# Try importing the first entry point
|
|
824
|
+
entry_point = entry_points[0]
|
|
825
|
+
|
|
826
|
+
try:
|
|
827
|
+
# Try to import the module (smoke test)
|
|
828
|
+
cmd = python_cmd.split() + ["-c", f"import {entry_point}"]
|
|
829
|
+
|
|
830
|
+
result = subprocess.run(
|
|
831
|
+
cmd,
|
|
832
|
+
cwd=repo_path,
|
|
833
|
+
capture_output=True,
|
|
834
|
+
text=True,
|
|
835
|
+
timeout=60, # 1 minute for import check
|
|
836
|
+
)
|
|
837
|
+
|
|
838
|
+
duration_ms = int((time.time() - start) * 1000)
|
|
839
|
+
|
|
840
|
+
output = result.stdout
|
|
841
|
+
if result.stderr:
|
|
842
|
+
output += "\n" + result.stderr
|
|
843
|
+
|
|
844
|
+
return GateCheck(
|
|
845
|
+
name="python-build",
|
|
846
|
+
status=GateStatus.PASSED if result.returncode == 0 else GateStatus.FAILED,
|
|
847
|
+
exit_code=result.returncode,
|
|
848
|
+
output=output[:2000] if not verbose else output,
|
|
849
|
+
duration_ms=duration_ms,
|
|
850
|
+
)
|
|
851
|
+
|
|
852
|
+
except subprocess.TimeoutExpired:
|
|
853
|
+
return GateCheck(
|
|
854
|
+
name="python-build",
|
|
855
|
+
status=GateStatus.ERROR,
|
|
856
|
+
output="Timeout after 60 seconds",
|
|
857
|
+
)
|
|
858
|
+
except Exception as e:
|
|
859
|
+
return GateCheck(
|
|
860
|
+
name="python-build",
|
|
861
|
+
status=GateStatus.ERROR,
|
|
862
|
+
output=str(e),
|
|
863
|
+
)
|
|
864
|
+
|
|
865
|
+
|
|
866
|
+
def _run_npm_build(repo_path: Path, verbose: bool = False) -> GateCheck:
|
|
867
|
+
"""Run npm build if build script exists in package.json."""
|
|
868
|
+
import json
|
|
869
|
+
import time
|
|
870
|
+
|
|
871
|
+
start = time.time()
|
|
872
|
+
|
|
873
|
+
# Check if package.json exists
|
|
874
|
+
package_json = repo_path / "package.json"
|
|
875
|
+
if not package_json.exists():
|
|
876
|
+
return GateCheck(
|
|
877
|
+
name="npm-build",
|
|
878
|
+
status=GateStatus.SKIPPED,
|
|
879
|
+
output="package.json not found",
|
|
880
|
+
)
|
|
881
|
+
|
|
882
|
+
# Check if build script exists
|
|
883
|
+
try:
|
|
884
|
+
pkg = json.loads(package_json.read_text())
|
|
885
|
+
scripts = pkg.get("scripts", {})
|
|
886
|
+
if "build" not in scripts:
|
|
887
|
+
return GateCheck(
|
|
888
|
+
name="npm-build",
|
|
889
|
+
status=GateStatus.SKIPPED,
|
|
890
|
+
output="No build script in package.json",
|
|
891
|
+
)
|
|
892
|
+
except Exception as e:
|
|
893
|
+
return GateCheck(
|
|
894
|
+
name="npm-build",
|
|
895
|
+
status=GateStatus.ERROR,
|
|
896
|
+
output=f"Failed to parse package.json: {e}",
|
|
897
|
+
)
|
|
898
|
+
|
|
899
|
+
# Check if npm is available
|
|
900
|
+
if not shutil.which("npm"):
|
|
901
|
+
return GateCheck(
|
|
902
|
+
name="npm-build",
|
|
903
|
+
status=GateStatus.SKIPPED,
|
|
904
|
+
output="npm not found",
|
|
905
|
+
)
|
|
906
|
+
|
|
907
|
+
try:
|
|
908
|
+
result = subprocess.run(
|
|
909
|
+
["npm", "run", "build"],
|
|
910
|
+
cwd=repo_path,
|
|
911
|
+
capture_output=True,
|
|
912
|
+
text=True,
|
|
913
|
+
timeout=300, # 5 minutes for builds
|
|
914
|
+
)
|
|
915
|
+
|
|
916
|
+
duration_ms = int((time.time() - start) * 1000)
|
|
917
|
+
|
|
918
|
+
output = result.stdout
|
|
919
|
+
if result.stderr:
|
|
920
|
+
output += "\n" + result.stderr
|
|
921
|
+
|
|
922
|
+
# Truncate long build output
|
|
923
|
+
if len(output) > 5000 and not verbose:
|
|
924
|
+
output = output[:2500] + "\n...[truncated]...\n" + output[-2500:]
|
|
925
|
+
|
|
926
|
+
return GateCheck(
|
|
927
|
+
name="npm-build",
|
|
928
|
+
status=GateStatus.PASSED if result.returncode == 0 else GateStatus.FAILED,
|
|
929
|
+
exit_code=result.returncode,
|
|
930
|
+
output=output,
|
|
931
|
+
duration_ms=duration_ms,
|
|
932
|
+
)
|
|
933
|
+
|
|
934
|
+
except subprocess.TimeoutExpired:
|
|
935
|
+
return GateCheck(
|
|
936
|
+
name="npm-build",
|
|
937
|
+
status=GateStatus.ERROR,
|
|
938
|
+
output="Timeout after 5 minutes",
|
|
939
|
+
)
|
|
940
|
+
except Exception as e:
|
|
941
|
+
return GateCheck(
|
|
942
|
+
name="npm-build",
|
|
943
|
+
status=GateStatus.ERROR,
|
|
944
|
+
output=str(e),
|
|
945
|
+
)
|
|
946
|
+
|
|
947
|
+
|
|
948
|
+
def _run_tsc(repo_path: Path, verbose: bool = False) -> GateCheck:
|
|
949
|
+
"""Run TypeScript type checking (tsc --noEmit).
|
|
950
|
+
|
|
951
|
+
Checks for type-check script in package.json first, otherwise uses npx tsc --noEmit.
|
|
952
|
+
"""
|
|
953
|
+
import json
|
|
954
|
+
import time
|
|
955
|
+
|
|
956
|
+
start = time.time()
|
|
957
|
+
|
|
958
|
+
# Check if tsconfig.json exists
|
|
959
|
+
tsconfig_path = repo_path / "tsconfig.json"
|
|
960
|
+
if not tsconfig_path.exists():
|
|
961
|
+
return GateCheck(
|
|
962
|
+
name="tsc",
|
|
963
|
+
status=GateStatus.SKIPPED,
|
|
964
|
+
output="tsconfig.json not found",
|
|
965
|
+
)
|
|
966
|
+
|
|
967
|
+
# Check if npx is available
|
|
968
|
+
if not shutil.which("npx"):
|
|
969
|
+
return GateCheck(
|
|
970
|
+
name="tsc",
|
|
971
|
+
status=GateStatus.SKIPPED,
|
|
972
|
+
output="npx not found",
|
|
973
|
+
)
|
|
974
|
+
|
|
975
|
+
# Determine command: prefer "type-check" script in package.json
|
|
976
|
+
package_json_path = repo_path / "package.json"
|
|
977
|
+
cmd = ["npx", "tsc", "--noEmit"] # Default fallback
|
|
978
|
+
|
|
979
|
+
if package_json_path.exists():
|
|
980
|
+
try:
|
|
981
|
+
package_data = json.loads(package_json_path.read_text())
|
|
982
|
+
scripts = package_data.get("scripts", {})
|
|
983
|
+
if "type-check" in scripts:
|
|
984
|
+
cmd = ["npm", "run", "type-check"]
|
|
985
|
+
except Exception:
|
|
986
|
+
pass # Fallback to npx tsc --noEmit
|
|
987
|
+
|
|
988
|
+
try:
|
|
989
|
+
result = subprocess.run(
|
|
990
|
+
cmd,
|
|
991
|
+
cwd=repo_path,
|
|
992
|
+
capture_output=True,
|
|
993
|
+
text=True,
|
|
994
|
+
timeout=120, # 2 minutes, same as mypy
|
|
995
|
+
)
|
|
996
|
+
|
|
997
|
+
duration_ms = int((time.time() - start) * 1000)
|
|
998
|
+
|
|
999
|
+
output = result.stdout
|
|
1000
|
+
if result.stderr:
|
|
1001
|
+
output += "\n" + result.stderr
|
|
1002
|
+
|
|
1003
|
+
# Parse TypeScript errors into structured format
|
|
1004
|
+
detailed_errors = _parse_tsc_errors(output) if result.returncode != 0 else None
|
|
1005
|
+
|
|
1006
|
+
return GateCheck(
|
|
1007
|
+
name="tsc",
|
|
1008
|
+
status=GateStatus.PASSED if result.returncode == 0 else GateStatus.FAILED,
|
|
1009
|
+
exit_code=result.returncode,
|
|
1010
|
+
output=output[:2000] if not verbose else output,
|
|
1011
|
+
duration_ms=duration_ms,
|
|
1012
|
+
detailed_errors=detailed_errors,
|
|
1013
|
+
)
|
|
1014
|
+
|
|
1015
|
+
except subprocess.TimeoutExpired:
|
|
1016
|
+
return GateCheck(
|
|
1017
|
+
name="tsc",
|
|
1018
|
+
status=GateStatus.ERROR,
|
|
1019
|
+
output="Timeout after 120 seconds",
|
|
1020
|
+
)
|
|
1021
|
+
except Exception as e:
|
|
1022
|
+
return GateCheck(
|
|
1023
|
+
name="tsc",
|
|
1024
|
+
status=GateStatus.ERROR,
|
|
1025
|
+
output=str(e),
|
|
1026
|
+
)
|
|
1027
|
+
|
|
1028
|
+
|
|
1029
|
+
# ---------------------------------------------------------------------------
|
|
1030
|
+
# Per-file lint gate (language-aware)
|
|
1031
|
+
# ---------------------------------------------------------------------------
|
|
1032
|
+
|
|
1033
|
+
|
|
1034
|
+
@dataclass
|
|
1035
|
+
class LinterConfig:
|
|
1036
|
+
"""Configuration for a file-level linter.
|
|
1037
|
+
|
|
1038
|
+
Attributes:
|
|
1039
|
+
name: Human-readable linter name (used in GateCheck.name).
|
|
1040
|
+
extensions: File extensions this linter handles (e.g., {".py", ".pyi"}).
|
|
1041
|
+
cmd: Command list to invoke. The placeholder ``{file}`` is replaced
|
|
1042
|
+
with the absolute file path at runtime.
|
|
1043
|
+
check_available: Binary name checked via ``shutil.which`` to see if the
|
|
1044
|
+
linter is installed. ``None`` means always available.
|
|
1045
|
+
use_uv: If True *and* ``uv`` is on PATH, prepend ``["uv", "run"]``.
|
|
1046
|
+
parse_errors: Optional callable to parse raw output into structured
|
|
1047
|
+
error dicts. Receives the raw stdout string.
|
|
1048
|
+
"""
|
|
1049
|
+
|
|
1050
|
+
name: str
|
|
1051
|
+
extensions: set[str]
|
|
1052
|
+
cmd: list[str]
|
|
1053
|
+
check_available: str | None = None
|
|
1054
|
+
use_uv: bool = False
|
|
1055
|
+
parse_errors: Optional[Callable[[str], list[dict[str, Any]]]] = None
|
|
1056
|
+
autofix_cmd: list[str] | None = None
|
|
1057
|
+
|
|
1058
|
+
|
|
1059
|
+
# ---- Registry ---------------------------------------------------------------
|
|
1060
|
+
# Add new linters here. Order does not matter – the first config whose
|
|
1061
|
+
# ``extensions`` set contains the file suffix wins.
|
|
1062
|
+
|
|
1063
|
+
LINTER_REGISTRY: list[LinterConfig] = [
|
|
1064
|
+
LinterConfig(
|
|
1065
|
+
name="ruff",
|
|
1066
|
+
extensions={".py", ".pyi"},
|
|
1067
|
+
cmd=["ruff", "check", "--output-format=concise", "{file}"],
|
|
1068
|
+
check_available="ruff",
|
|
1069
|
+
use_uv=True,
|
|
1070
|
+
parse_errors=_parse_ruff_errors,
|
|
1071
|
+
autofix_cmd=["ruff", "check", "--fix", "{file}"],
|
|
1072
|
+
),
|
|
1073
|
+
LinterConfig(
|
|
1074
|
+
name="eslint",
|
|
1075
|
+
extensions={".ts", ".tsx", ".js", ".jsx"},
|
|
1076
|
+
cmd=["npx", "eslint", "{file}"],
|
|
1077
|
+
check_available="npx",
|
|
1078
|
+
autofix_cmd=["npx", "eslint", "--fix", "{file}"],
|
|
1079
|
+
),
|
|
1080
|
+
# Clippy lints the whole crate, not a single file. Omitted from the
|
|
1081
|
+
# per-file registry to avoid slow full-project checks on every edit.
|
|
1082
|
+
# TODO: re-add when a per-file Rust lint solution is available
|
|
1083
|
+
# (e.g., rustfmt --check {file} for formatting).
|
|
1084
|
+
# LinterConfig(
|
|
1085
|
+
# name="clippy",
|
|
1086
|
+
# extensions={".rs"},
|
|
1087
|
+
# cmd=["cargo", "clippy", "--", "-D", "warnings"],
|
|
1088
|
+
# check_available="cargo",
|
|
1089
|
+
# ),
|
|
1090
|
+
]
|
|
1091
|
+
|
|
1092
|
+
|
|
1093
|
+
def _find_linter_for_file(file_path: Path) -> Optional[LinterConfig]:
|
|
1094
|
+
"""Return the first matching ``LinterConfig`` for *file_path*, or ``None``."""
|
|
1095
|
+
suffix = file_path.suffix.lower()
|
|
1096
|
+
for cfg in LINTER_REGISTRY:
|
|
1097
|
+
if suffix in cfg.extensions:
|
|
1098
|
+
return cfg
|
|
1099
|
+
return None
|
|
1100
|
+
|
|
1101
|
+
|
|
1102
|
+
def run_lint_on_file(
|
|
1103
|
+
file_path: Path,
|
|
1104
|
+
repo_path: Path,
|
|
1105
|
+
*,
|
|
1106
|
+
timeout: int = 30,
|
|
1107
|
+
) -> GateCheck:
|
|
1108
|
+
"""Run the appropriate linter on a single file.
|
|
1109
|
+
|
|
1110
|
+
Returns a ``GateCheck`` with status PASSED / FAILED / SKIPPED / ERROR.
|
|
1111
|
+
SKIPPED is returned when no linter is registered for the file extension,
|
|
1112
|
+
the required binary is not installed, or the tool is not found in the
|
|
1113
|
+
project's dependencies (e.g. ``uv run ruff`` fails to spawn).
|
|
1114
|
+
"""
|
|
1115
|
+
import time
|
|
1116
|
+
|
|
1117
|
+
cfg = _find_linter_for_file(file_path)
|
|
1118
|
+
if cfg is None:
|
|
1119
|
+
return GateCheck(name="lint", status=GateStatus.SKIPPED,
|
|
1120
|
+
output=f"No linter configured for {file_path.suffix}")
|
|
1121
|
+
|
|
1122
|
+
# Check binary availability — when use_uv is set, uv can provide the tool
|
|
1123
|
+
if cfg.check_available and not shutil.which(cfg.check_available):
|
|
1124
|
+
if not (cfg.use_uv and shutil.which("uv")):
|
|
1125
|
+
return GateCheck(name=cfg.name, status=GateStatus.SKIPPED,
|
|
1126
|
+
output=f"{cfg.check_available} not found")
|
|
1127
|
+
|
|
1128
|
+
# Build command – replace {file} placeholder
|
|
1129
|
+
cmd = [part.replace("{file}", str(file_path)) for part in cfg.cmd]
|
|
1130
|
+
if cfg.use_uv and shutil.which("uv"):
|
|
1131
|
+
cmd = ["uv", "run"] + cmd
|
|
1132
|
+
|
|
1133
|
+
start = time.time()
|
|
1134
|
+
try:
|
|
1135
|
+
result = subprocess.run(
|
|
1136
|
+
cmd,
|
|
1137
|
+
cwd=repo_path,
|
|
1138
|
+
capture_output=True,
|
|
1139
|
+
text=True,
|
|
1140
|
+
timeout=timeout,
|
|
1141
|
+
)
|
|
1142
|
+
|
|
1143
|
+
duration_ms = int((time.time() - start) * 1000)
|
|
1144
|
+
output = result.stdout
|
|
1145
|
+
if result.stderr:
|
|
1146
|
+
output += "\n" + result.stderr
|
|
1147
|
+
output = output.strip()
|
|
1148
|
+
|
|
1149
|
+
# Detect tool-not-found: uv/shell report "Failed to spawn",
|
|
1150
|
+
# "command not found", or "No such file or directory" when the
|
|
1151
|
+
# linter binary isn't installed in the target project.
|
|
1152
|
+
# Note: "no such file or directory" is only matched when the tool
|
|
1153
|
+
# name also appears in stderr, to avoid false positives from
|
|
1154
|
+
# missing *target* files.
|
|
1155
|
+
if result.returncode != 0 and result.stderr:
|
|
1156
|
+
stderr_lower = result.stderr.lower()
|
|
1157
|
+
tool_names = {cfg.cmd[0].lower(), cfg.name.lower()}
|
|
1158
|
+
if (
|
|
1159
|
+
"failed to spawn" in stderr_lower
|
|
1160
|
+
or "command not found" in stderr_lower
|
|
1161
|
+
or (
|
|
1162
|
+
"no such file or directory" in stderr_lower
|
|
1163
|
+
and any(name in stderr_lower for name in tool_names)
|
|
1164
|
+
)
|
|
1165
|
+
):
|
|
1166
|
+
return GateCheck(
|
|
1167
|
+
name=cfg.name,
|
|
1168
|
+
status=GateStatus.SKIPPED,
|
|
1169
|
+
output=f"{cfg.name} not found in project dependencies",
|
|
1170
|
+
duration_ms=duration_ms,
|
|
1171
|
+
)
|
|
1172
|
+
|
|
1173
|
+
passed = result.returncode == 0
|
|
1174
|
+
check = GateCheck(
|
|
1175
|
+
name=cfg.name,
|
|
1176
|
+
status=GateStatus.PASSED if passed else GateStatus.FAILED,
|
|
1177
|
+
exit_code=result.returncode,
|
|
1178
|
+
output=output,
|
|
1179
|
+
duration_ms=duration_ms,
|
|
1180
|
+
)
|
|
1181
|
+
|
|
1182
|
+
if not passed and cfg.parse_errors:
|
|
1183
|
+
check.detailed_errors = cfg.parse_errors(output)
|
|
1184
|
+
|
|
1185
|
+
return check
|
|
1186
|
+
|
|
1187
|
+
except subprocess.TimeoutExpired:
|
|
1188
|
+
return GateCheck(name=cfg.name, status=GateStatus.ERROR,
|
|
1189
|
+
output=f"Timeout after {timeout}s")
|
|
1190
|
+
except FileNotFoundError:
|
|
1191
|
+
return GateCheck(name=cfg.name, status=GateStatus.SKIPPED,
|
|
1192
|
+
output=f"{cfg.check_available or cfg.cmd[0]} not found")
|
|
1193
|
+
except Exception as e:
|
|
1194
|
+
return GateCheck(name=cfg.name, status=GateStatus.ERROR,
|
|
1195
|
+
output=str(e))
|
|
1196
|
+
|
|
1197
|
+
|
|
1198
|
+
def run_autofix_on_file(
|
|
1199
|
+
file_path: Path,
|
|
1200
|
+
repo_path: Path,
|
|
1201
|
+
*,
|
|
1202
|
+
timeout: int = 30,
|
|
1203
|
+
) -> GateCheck:
|
|
1204
|
+
"""Run the appropriate linter autofix on a single file.
|
|
1205
|
+
|
|
1206
|
+
Returns a ``GateCheck`` with status PASSED / SKIPPED / ERROR.
|
|
1207
|
+
SKIPPED is returned when no linter is registered for the file extension,
|
|
1208
|
+
the linter has no ``autofix_cmd``, or the required binary is not installed.
|
|
1209
|
+
"""
|
|
1210
|
+
import time
|
|
1211
|
+
|
|
1212
|
+
cfg = _find_linter_for_file(file_path)
|
|
1213
|
+
if cfg is None:
|
|
1214
|
+
return GateCheck(name="autofix", status=GateStatus.SKIPPED,
|
|
1215
|
+
output=f"No linter configured for {file_path.suffix}")
|
|
1216
|
+
|
|
1217
|
+
if cfg.autofix_cmd is None:
|
|
1218
|
+
return GateCheck(name="autofix", status=GateStatus.SKIPPED,
|
|
1219
|
+
output=f"{cfg.name} has no autofix command")
|
|
1220
|
+
|
|
1221
|
+
# Check binary availability — when use_uv is set, uv can provide the tool
|
|
1222
|
+
if cfg.check_available and not shutil.which(cfg.check_available):
|
|
1223
|
+
if not (cfg.use_uv and shutil.which("uv")):
|
|
1224
|
+
return GateCheck(name=f"autofix-{cfg.name}", status=GateStatus.SKIPPED,
|
|
1225
|
+
output=f"{cfg.check_available} not found")
|
|
1226
|
+
|
|
1227
|
+
# Build command – replace {file} placeholder
|
|
1228
|
+
cmd = [part.replace("{file}", str(file_path)) for part in cfg.autofix_cmd]
|
|
1229
|
+
if cfg.use_uv and shutil.which("uv"):
|
|
1230
|
+
cmd = ["uv", "run"] + cmd
|
|
1231
|
+
|
|
1232
|
+
start = time.time()
|
|
1233
|
+
try:
|
|
1234
|
+
result = subprocess.run(
|
|
1235
|
+
cmd,
|
|
1236
|
+
cwd=repo_path,
|
|
1237
|
+
capture_output=True,
|
|
1238
|
+
text=True,
|
|
1239
|
+
timeout=timeout,
|
|
1240
|
+
)
|
|
1241
|
+
|
|
1242
|
+
duration_ms = int((time.time() - start) * 1000)
|
|
1243
|
+
output = result.stdout
|
|
1244
|
+
if result.stderr:
|
|
1245
|
+
output += "\n" + result.stderr
|
|
1246
|
+
output = output.strip()
|
|
1247
|
+
|
|
1248
|
+
# Detect tool-not-found (same pattern as run_lint_on_file)
|
|
1249
|
+
if result.returncode != 0 and result.stderr:
|
|
1250
|
+
stderr_lower = result.stderr.lower()
|
|
1251
|
+
tool_names = {cfg.autofix_cmd[0].lower(), cfg.name.lower()}
|
|
1252
|
+
if (
|
|
1253
|
+
"failed to spawn" in stderr_lower
|
|
1254
|
+
or "command not found" in stderr_lower
|
|
1255
|
+
or (
|
|
1256
|
+
"no such file or directory" in stderr_lower
|
|
1257
|
+
and any(name in stderr_lower for name in tool_names)
|
|
1258
|
+
)
|
|
1259
|
+
):
|
|
1260
|
+
return GateCheck(
|
|
1261
|
+
name=f"autofix-{cfg.name}",
|
|
1262
|
+
status=GateStatus.SKIPPED,
|
|
1263
|
+
output=f"{cfg.name} not found in project dependencies",
|
|
1264
|
+
duration_ms=duration_ms,
|
|
1265
|
+
)
|
|
1266
|
+
|
|
1267
|
+
return GateCheck(
|
|
1268
|
+
name=f"autofix-{cfg.name}",
|
|
1269
|
+
status=GateStatus.PASSED if result.returncode == 0 else GateStatus.ERROR,
|
|
1270
|
+
exit_code=result.returncode,
|
|
1271
|
+
output=output,
|
|
1272
|
+
duration_ms=duration_ms,
|
|
1273
|
+
)
|
|
1274
|
+
|
|
1275
|
+
except subprocess.TimeoutExpired:
|
|
1276
|
+
return GateCheck(name=f"autofix-{cfg.name}", status=GateStatus.ERROR,
|
|
1277
|
+
output=f"Timeout after {timeout}s")
|
|
1278
|
+
except FileNotFoundError:
|
|
1279
|
+
return GateCheck(name=f"autofix-{cfg.name}", status=GateStatus.SKIPPED,
|
|
1280
|
+
output=f"{cfg.check_available or cfg.autofix_cmd[0]} not found")
|
|
1281
|
+
except Exception as e:
|
|
1282
|
+
return GateCheck(name=f"autofix-{cfg.name}", status=GateStatus.ERROR,
|
|
1283
|
+
output=str(e))
|
|
1284
|
+
|
|
1285
|
+
|
|
1286
|
+
def _summarize_pytest_output(output: str) -> str:
|
|
1287
|
+
"""Extract key summary from pytest output."""
|
|
1288
|
+
lines = output.split("\n")
|
|
1289
|
+
|
|
1290
|
+
# Look for the summary line (e.g., "5 passed in 1.23s")
|
|
1291
|
+
for line in reversed(lines):
|
|
1292
|
+
if "passed" in line or "failed" in line or "error" in line:
|
|
1293
|
+
if "=" in line or "passed" in line.lower():
|
|
1294
|
+
return line.strip()
|
|
1295
|
+
|
|
1296
|
+
# Fall back to last non-empty lines
|
|
1297
|
+
non_empty = [line.strip() for line in lines if line.strip()]
|
|
1298
|
+
if non_empty:
|
|
1299
|
+
return "\n".join(non_empty[-3:])
|
|
1300
|
+
|
|
1301
|
+
return output[:500]
|
|
1302
|
+
|
|
1303
|
+
|
|
1304
|
+
def _summarize_ruff_output(output: str) -> str:
|
|
1305
|
+
"""Extract key summary from ruff output."""
|
|
1306
|
+
if not output.strip():
|
|
1307
|
+
return "No issues found"
|
|
1308
|
+
|
|
1309
|
+
lines = output.strip().split("\n")
|
|
1310
|
+
|
|
1311
|
+
# Count issues
|
|
1312
|
+
issue_count = len([line for line in lines if line.strip() and not line.startswith("Found")])
|
|
1313
|
+
|
|
1314
|
+
# Look for "Found X errors" line
|
|
1315
|
+
for line in lines:
|
|
1316
|
+
if "Found" in line and ("error" in line or "warning" in line):
|
|
1317
|
+
return line.strip()
|
|
1318
|
+
|
|
1319
|
+
if issue_count > 0:
|
|
1320
|
+
return f"{issue_count} issues found"
|
|
1321
|
+
|
|
1322
|
+
return output[:500]
|