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.
Files changed (197) hide show
  1. codeframe/__init__.py +11 -0
  2. codeframe/__main__.py +20 -0
  3. codeframe/adapters/__init__.py +5 -0
  4. codeframe/adapters/e2b/__init__.py +13 -0
  5. codeframe/adapters/e2b/adapter.py +342 -0
  6. codeframe/adapters/e2b/budget.py +71 -0
  7. codeframe/adapters/e2b/credential_scanner.py +134 -0
  8. codeframe/adapters/llm/__init__.py +92 -0
  9. codeframe/adapters/llm/anthropic.py +414 -0
  10. codeframe/adapters/llm/base.py +444 -0
  11. codeframe/adapters/llm/mock.py +281 -0
  12. codeframe/adapters/llm/openai.py +483 -0
  13. codeframe/agents/__init__.py +8 -0
  14. codeframe/agents/dependency_resolver.py +714 -0
  15. codeframe/auth/__init__.py +16 -0
  16. codeframe/auth/api_key_router.py +238 -0
  17. codeframe/auth/api_keys.py +156 -0
  18. codeframe/auth/dependencies.py +358 -0
  19. codeframe/auth/manager.py +178 -0
  20. codeframe/auth/models.py +30 -0
  21. codeframe/auth/router.py +93 -0
  22. codeframe/auth/schemas.py +15 -0
  23. codeframe/auth/scopes.py +53 -0
  24. codeframe/cli/__init__.py +12 -0
  25. codeframe/cli/__main__.py +20 -0
  26. codeframe/cli/api_client.py +275 -0
  27. codeframe/cli/app.py +5688 -0
  28. codeframe/cli/auth.py +122 -0
  29. codeframe/cli/auth_commands.py +958 -0
  30. codeframe/cli/commands/__init__.py +5 -0
  31. codeframe/cli/config_commands.py +79 -0
  32. codeframe/cli/dashboard_commands.py +67 -0
  33. codeframe/cli/engines_commands.py +205 -0
  34. codeframe/cli/env_commands.py +409 -0
  35. codeframe/cli/helpers.py +56 -0
  36. codeframe/cli/hooks_commands.py +208 -0
  37. codeframe/cli/import_commands.py +129 -0
  38. codeframe/cli/pr_commands.py +549 -0
  39. codeframe/cli/proof_commands.py +415 -0
  40. codeframe/cli/stats_commands.py +311 -0
  41. codeframe/cli/telemetry_runtime.py +153 -0
  42. codeframe/cli/validators.py +123 -0
  43. codeframe/config/rate_limits.py +165 -0
  44. codeframe/core/__init__.py +15 -0
  45. codeframe/core/adapters/__init__.py +43 -0
  46. codeframe/core/adapters/agent_adapter.py +114 -0
  47. codeframe/core/adapters/builtin.py +326 -0
  48. codeframe/core/adapters/claude_code.py +62 -0
  49. codeframe/core/adapters/codex.py +393 -0
  50. codeframe/core/adapters/git_utils.py +40 -0
  51. codeframe/core/adapters/kilocode.py +126 -0
  52. codeframe/core/adapters/opencode.py +48 -0
  53. codeframe/core/adapters/streaming_chat.py +483 -0
  54. codeframe/core/adapters/subprocess_adapter.py +213 -0
  55. codeframe/core/adapters/verification_wrapper.py +269 -0
  56. codeframe/core/agent.py +2183 -0
  57. codeframe/core/agents_config.py +569 -0
  58. codeframe/core/api_key_service.py +211 -0
  59. codeframe/core/artifacts.py +428 -0
  60. codeframe/core/blocker_detection.py +218 -0
  61. codeframe/core/blockers.py +433 -0
  62. codeframe/core/checkpoints.py +481 -0
  63. codeframe/core/conductor.py +2255 -0
  64. codeframe/core/config.py +827 -0
  65. codeframe/core/config_watcher.py +268 -0
  66. codeframe/core/context.py +542 -0
  67. codeframe/core/context_packager.py +234 -0
  68. codeframe/core/credentials.py +735 -0
  69. codeframe/core/dependency_analyzer.py +229 -0
  70. codeframe/core/dependency_graph.py +290 -0
  71. codeframe/core/diagnostic_agent.py +712 -0
  72. codeframe/core/diagnostics.py +616 -0
  73. codeframe/core/editor.py +556 -0
  74. codeframe/core/engine_registry.py +256 -0
  75. codeframe/core/engine_stats.py +231 -0
  76. codeframe/core/environment.py +697 -0
  77. codeframe/core/events.py +375 -0
  78. codeframe/core/executor.py +1005 -0
  79. codeframe/core/fix_tracker.py +480 -0
  80. codeframe/core/gates.py +1322 -0
  81. codeframe/core/git.py +477 -0
  82. codeframe/core/github_connect_service.py +178 -0
  83. codeframe/core/github_integration_config.py +118 -0
  84. codeframe/core/github_issues_service.py +449 -0
  85. codeframe/core/hooks.py +184 -0
  86. codeframe/core/importers/__init__.py +1 -0
  87. codeframe/core/importers/ralph.py +540 -0
  88. codeframe/core/installer.py +650 -0
  89. codeframe/core/models.py +1026 -0
  90. codeframe/core/notifications_config.py +183 -0
  91. codeframe/core/planner.py +437 -0
  92. codeframe/core/prd.py +670 -0
  93. codeframe/core/prd_discovery.py +1118 -0
  94. codeframe/core/prd_stress_test.py +499 -0
  95. codeframe/core/progress.py +126 -0
  96. codeframe/core/proof/__init__.py +34 -0
  97. codeframe/core/proof/capture.py +79 -0
  98. codeframe/core/proof/evidence.py +56 -0
  99. codeframe/core/proof/ledger.py +574 -0
  100. codeframe/core/proof/models.py +162 -0
  101. codeframe/core/proof/obligations.py +103 -0
  102. codeframe/core/proof/runner.py +233 -0
  103. codeframe/core/proof/scope.py +81 -0
  104. codeframe/core/proof/stubs.py +156 -0
  105. codeframe/core/quick_fixes.py +558 -0
  106. codeframe/core/react_agent.py +1650 -0
  107. codeframe/core/reconciliation.py +183 -0
  108. codeframe/core/replay.py +788 -0
  109. codeframe/core/review.py +285 -0
  110. codeframe/core/runtime.py +1134 -0
  111. codeframe/core/sandbox/__init__.py +27 -0
  112. codeframe/core/sandbox/context.py +98 -0
  113. codeframe/core/sandbox/worktree.py +20 -0
  114. codeframe/core/schedule.py +396 -0
  115. codeframe/core/stall_detector.py +71 -0
  116. codeframe/core/stall_monitor.py +134 -0
  117. codeframe/core/state_machine.py +121 -0
  118. codeframe/core/streaming.py +502 -0
  119. codeframe/core/task_tree.py +400 -0
  120. codeframe/core/tasks.py +1022 -0
  121. codeframe/core/telemetry.py +232 -0
  122. codeframe/core/templates.py +221 -0
  123. codeframe/core/tools.py +942 -0
  124. codeframe/core/workspace.py +887 -0
  125. codeframe/core/worktrees.py +276 -0
  126. codeframe/git/__init__.py +5 -0
  127. codeframe/git/github_integration.py +505 -0
  128. codeframe/lib/__init__.py +0 -0
  129. codeframe/lib/audit_logger.py +248 -0
  130. codeframe/lib/metrics_tracker.py +800 -0
  131. codeframe/lib/quality/__init__.py +7 -0
  132. codeframe/lib/quality/complexity_analyzer.py +316 -0
  133. codeframe/lib/quality/owasp_patterns.py +284 -0
  134. codeframe/lib/quality/security_scanner.py +250 -0
  135. codeframe/lib/rate_limiter.py +312 -0
  136. codeframe/notifications/__init__.py +0 -0
  137. codeframe/notifications/webhook.py +380 -0
  138. codeframe/planning/__init__.py +30 -0
  139. codeframe/planning/issue_generator.py +219 -0
  140. codeframe/planning/prd_template_functions.py +137 -0
  141. codeframe/planning/prd_templates.py +975 -0
  142. codeframe/planning/task_scheduler.py +511 -0
  143. codeframe/planning/task_templates.py +533 -0
  144. codeframe/platform_store/__init__.py +5 -0
  145. codeframe/platform_store/database.py +277 -0
  146. codeframe/platform_store/repositories/__init__.py +24 -0
  147. codeframe/platform_store/repositories/api_key_repository.py +245 -0
  148. codeframe/platform_store/repositories/audit_repository.py +67 -0
  149. codeframe/platform_store/repositories/base.py +295 -0
  150. codeframe/platform_store/repositories/interactive_sessions.py +165 -0
  151. codeframe/platform_store/repositories/token_repository.py +598 -0
  152. codeframe/platform_store/repositories/workspace_registry_repository.py +175 -0
  153. codeframe/platform_store/schema_manager.py +321 -0
  154. codeframe/templates/AGENTS.md.default +94 -0
  155. codeframe/tui/__init__.py +5 -0
  156. codeframe/tui/app.py +256 -0
  157. codeframe/tui/data_service.py +103 -0
  158. codeframe/ui/__init__.py +0 -0
  159. codeframe/ui/dependencies.py +103 -0
  160. codeframe/ui/models.py +999 -0
  161. codeframe/ui/response_models.py +201 -0
  162. codeframe/ui/routers/__init__.py +5 -0
  163. codeframe/ui/routers/_helpers.py +29 -0
  164. codeframe/ui/routers/batches_v2.py +315 -0
  165. codeframe/ui/routers/blockers_v2.py +320 -0
  166. codeframe/ui/routers/checkpoints_v2.py +310 -0
  167. codeframe/ui/routers/costs_v2.py +322 -0
  168. codeframe/ui/routers/diagnose_v2.py +225 -0
  169. codeframe/ui/routers/discovery_v2.py +417 -0
  170. codeframe/ui/routers/environment_v2.py +284 -0
  171. codeframe/ui/routers/events_v2.py +75 -0
  172. codeframe/ui/routers/gates_v2.py +166 -0
  173. codeframe/ui/routers/git_v2.py +284 -0
  174. codeframe/ui/routers/github_integrations_v2.py +532 -0
  175. codeframe/ui/routers/interactive_sessions_v2.py +238 -0
  176. codeframe/ui/routers/pr_v2.py +709 -0
  177. codeframe/ui/routers/prd_v2.py +695 -0
  178. codeframe/ui/routers/proof_v2.py +755 -0
  179. codeframe/ui/routers/review_v2.py +360 -0
  180. codeframe/ui/routers/schedule_v2.py +214 -0
  181. codeframe/ui/routers/session_chat_ws.py +354 -0
  182. codeframe/ui/routers/settings_v2.py +562 -0
  183. codeframe/ui/routers/streaming_v2.py +155 -0
  184. codeframe/ui/routers/tasks_v2.py +1098 -0
  185. codeframe/ui/routers/templates_v2.py +232 -0
  186. codeframe/ui/routers/terminal_ws.py +267 -0
  187. codeframe/ui/routers/workspace_v2.py +527 -0
  188. codeframe/ui/server.py +568 -0
  189. codeframe/ui/shared.py +241 -0
  190. codeframe/workspace/__init__.py +5 -0
  191. codeframe/workspace/manager.py +249 -0
  192. codeframe_ai-0.9.0.dist-info/METADATA +517 -0
  193. codeframe_ai-0.9.0.dist-info/RECORD +197 -0
  194. codeframe_ai-0.9.0.dist-info/WHEEL +5 -0
  195. codeframe_ai-0.9.0.dist-info/entry_points.txt +3 -0
  196. codeframe_ai-0.9.0.dist-info/licenses/LICENSE +661 -0
  197. codeframe_ai-0.9.0.dist-info/top_level.txt +1 -0
@@ -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]