devloop 0.2.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 (55) hide show
  1. devloop/__init__.py +3 -0
  2. devloop/agents/__init__.py +33 -0
  3. devloop/agents/agent_health_monitor.py +105 -0
  4. devloop/agents/ci_monitor.py +237 -0
  5. devloop/agents/code_rabbit.py +248 -0
  6. devloop/agents/doc_lifecycle.py +374 -0
  7. devloop/agents/echo.py +24 -0
  8. devloop/agents/file_logger.py +46 -0
  9. devloop/agents/formatter.py +511 -0
  10. devloop/agents/git_commit_assistant.py +421 -0
  11. devloop/agents/linter.py +399 -0
  12. devloop/agents/performance_profiler.py +284 -0
  13. devloop/agents/security_scanner.py +322 -0
  14. devloop/agents/snyk.py +292 -0
  15. devloop/agents/test_runner.py +484 -0
  16. devloop/agents/type_checker.py +242 -0
  17. devloop/cli/__init__.py +1 -0
  18. devloop/cli/commands/__init__.py +1 -0
  19. devloop/cli/commands/custom_agents.py +144 -0
  20. devloop/cli/commands/feedback.py +161 -0
  21. devloop/cli/commands/summary.py +50 -0
  22. devloop/cli/main.py +430 -0
  23. devloop/cli/main_v1.py +144 -0
  24. devloop/collectors/__init__.py +17 -0
  25. devloop/collectors/base.py +55 -0
  26. devloop/collectors/filesystem.py +126 -0
  27. devloop/collectors/git.py +171 -0
  28. devloop/collectors/manager.py +159 -0
  29. devloop/collectors/process.py +221 -0
  30. devloop/collectors/system.py +195 -0
  31. devloop/core/__init__.py +21 -0
  32. devloop/core/agent.py +206 -0
  33. devloop/core/agent_template.py +498 -0
  34. devloop/core/amp_integration.py +166 -0
  35. devloop/core/auto_fix.py +224 -0
  36. devloop/core/config.py +272 -0
  37. devloop/core/context.py +0 -0
  38. devloop/core/context_store.py +530 -0
  39. devloop/core/contextual_feedback.py +311 -0
  40. devloop/core/custom_agent.py +439 -0
  41. devloop/core/debug_trace.py +289 -0
  42. devloop/core/event.py +105 -0
  43. devloop/core/event_store.py +316 -0
  44. devloop/core/feedback.py +311 -0
  45. devloop/core/learning.py +351 -0
  46. devloop/core/manager.py +219 -0
  47. devloop/core/performance.py +433 -0
  48. devloop/core/proactive_feedback.py +302 -0
  49. devloop/core/summary_formatter.py +159 -0
  50. devloop/core/summary_generator.py +275 -0
  51. devloop-0.2.0.dist-info/METADATA +705 -0
  52. devloop-0.2.0.dist-info/RECORD +55 -0
  53. devloop-0.2.0.dist-info/WHEEL +4 -0
  54. devloop-0.2.0.dist-info/entry_points.txt +3 -0
  55. devloop-0.2.0.dist-info/licenses/LICENSE +21 -0
@@ -0,0 +1,399 @@
1
+ """Linter agent - runs linters on file changes."""
2
+
3
+ import asyncio
4
+ import json
5
+ from datetime import datetime
6
+ from pathlib import Path
7
+ from typing import Any, Dict, List, Optional
8
+
9
+ from devloop.core.agent import Agent, AgentResult
10
+ from devloop.core.context_store import Finding, Severity
11
+ from devloop.core.event import Event
12
+
13
+
14
+ class LinterConfig:
15
+ """Configuration for LinterAgent."""
16
+
17
+ def __init__(self, config: Dict[str, Any]):
18
+ self.enabled = config.get("enabled", True)
19
+ self.auto_fix = config.get("autoFix", False)
20
+ self.file_patterns = config.get("filePatterns", ["**/*.py"])
21
+ self.linters = config.get(
22
+ "linters",
23
+ {"python": "ruff", "javascript": "eslint", "typescript": "eslint"},
24
+ )
25
+ self.debounce = config.get("debounce", 500) # ms
26
+
27
+
28
+ class LinterResult:
29
+ """Result from running a linter."""
30
+
31
+ def __init__(
32
+ self,
33
+ success: bool,
34
+ issues: List[Dict[str, Any]] | None = None,
35
+ error: str | None = None,
36
+ ):
37
+ self.success = success
38
+ self.issues = issues or []
39
+ self.error = error
40
+
41
+ @property
42
+ def has_issues(self) -> bool:
43
+ """Check if there are any issues."""
44
+ return len(self.issues) > 0
45
+
46
+ @property
47
+ def issue_count(self) -> int:
48
+ """Get number of issues."""
49
+ return len(self.issues)
50
+
51
+
52
+ class LinterAgent(Agent):
53
+ """Agent that runs linters on file changes."""
54
+
55
+ def __init__(
56
+ self,
57
+ name: str,
58
+ triggers: List[str],
59
+ event_bus,
60
+ config: Dict[str, Any] | None = None,
61
+ feedback_api=None,
62
+ performance_monitor=None,
63
+ ):
64
+ super().__init__(
65
+ name,
66
+ triggers,
67
+ event_bus,
68
+ feedback_api=feedback_api,
69
+ performance_monitor=performance_monitor,
70
+ )
71
+ self.config = LinterConfig(config or {})
72
+ self._last_run: Dict[str, float] = {} # path -> timestamp for debouncing
73
+
74
+ async def handle(self, event: Event) -> AgentResult:
75
+ """Handle file change event by running linter."""
76
+ # Extract file path
77
+ file_path = event.payload.get("path")
78
+ if not file_path:
79
+ return AgentResult(
80
+ agent_name=self.name,
81
+ success=True,
82
+ duration=0,
83
+ message="No file path in event",
84
+ )
85
+
86
+ path = Path(file_path)
87
+
88
+ # Check if file should be linted
89
+ if not self._should_lint(path):
90
+ return AgentResult(
91
+ agent_name=self.name,
92
+ success=True,
93
+ duration=0,
94
+ message=f"Skipped {path.name} (not in patterns)",
95
+ )
96
+
97
+ # Get appropriate linter for file type
98
+ linter = self._get_linter_for_file(path)
99
+ if not linter:
100
+ return AgentResult(
101
+ agent_name=self.name,
102
+ success=True,
103
+ duration=0,
104
+ message=f"No linter configured for {path.suffix}",
105
+ )
106
+
107
+ # Run linter
108
+ result = await self._run_linter(linter, path)
109
+
110
+ # Auto-fix if configured and issues found
111
+ if self.config.auto_fix and result.has_issues:
112
+ fix_result = await self._auto_fix(linter, path)
113
+ if fix_result.success:
114
+ # Re-run linter to get updated results
115
+ result = await self._run_linter(linter, path)
116
+
117
+ # Build result message
118
+ if result.error:
119
+ message = f"Linter error on {path.name}: {result.error}"
120
+ success = False
121
+ elif result.has_issues:
122
+ message = f"Found {result.issue_count} issue(s) in {path.name}"
123
+ success = True
124
+ else:
125
+ message = f"No issues in {path.name}"
126
+ success = True
127
+
128
+ agent_result = AgentResult(
129
+ agent_name=self.name,
130
+ success=success,
131
+ duration=0,
132
+ message=message,
133
+ data={
134
+ "file": str(path),
135
+ "linter": linter,
136
+ "issues": result.issues,
137
+ "issue_count": result.issue_count,
138
+ },
139
+ )
140
+
141
+ # Write findings to context store for Claude Code integration
142
+ await self._write_findings_to_context(path, result, linter)
143
+
144
+ return agent_result
145
+
146
+ def _should_lint(self, path: Path) -> bool:
147
+ """Check if file should be linted based on patterns."""
148
+ # Skip if file doesn't exist
149
+ if not path.exists():
150
+ return False
151
+
152
+ # Simple pattern matching (could be improved with fnmatch)
153
+ suffix = path.suffix
154
+ for pattern in self.config.file_patterns:
155
+ if pattern.endswith(suffix):
156
+ return True
157
+ if "*" in pattern and suffix in pattern:
158
+ return True
159
+
160
+ return False
161
+
162
+ def _get_linter_for_file(self, path: Path) -> Optional[str]:
163
+ """Get the appropriate linter for a file."""
164
+ suffix = path.suffix.lstrip(".")
165
+
166
+ # Map file extensions to language
167
+ extension_map = {
168
+ "py": "python",
169
+ "js": "javascript",
170
+ "jsx": "javascript",
171
+ "ts": "typescript",
172
+ "tsx": "typescript",
173
+ }
174
+
175
+ language = extension_map.get(suffix)
176
+ if language:
177
+ return self.config.linters.get(language)
178
+
179
+ return None
180
+
181
+ async def _run_linter(self, linter: str, path: Path) -> LinterResult:
182
+ """Run linter on a file."""
183
+ try:
184
+ # Build command based on linter
185
+ if linter == "ruff":
186
+ result = await self._run_ruff(path)
187
+ elif linter == "eslint":
188
+ result = await self._run_eslint(path)
189
+ else:
190
+ result = LinterResult(success=False, error=f"Unknown linter: {linter}")
191
+
192
+ return result
193
+
194
+ except Exception as e:
195
+ self.logger.error(f"Error running {linter}: {e}")
196
+ return LinterResult(success=False, error=str(e))
197
+
198
+ async def _run_ruff(self, path: Path) -> LinterResult:
199
+ """Run ruff on a Python file."""
200
+ try:
201
+ # Get updated environment with venv bin in PATH
202
+ import os
203
+
204
+ env = os.environ.copy()
205
+ venv_bin = Path(__file__).parent.parent.parent.parent / ".venv" / "bin"
206
+ if venv_bin.exists():
207
+ env["PATH"] = f"{venv_bin}:{env.get('PATH', '')}"
208
+
209
+ # Check if ruff is installed
210
+ check = await asyncio.create_subprocess_exec(
211
+ "ruff",
212
+ "--version",
213
+ stdout=asyncio.subprocess.PIPE,
214
+ stderr=asyncio.subprocess.PIPE,
215
+ env=env,
216
+ )
217
+ await check.communicate()
218
+
219
+ if check.returncode != 0:
220
+ return LinterResult(success=False, error="ruff not installed")
221
+
222
+ # Run ruff with JSON output
223
+ proc = await asyncio.create_subprocess_exec(
224
+ "ruff",
225
+ "check",
226
+ "--output-format",
227
+ "json",
228
+ str(path),
229
+ stdout=asyncio.subprocess.PIPE,
230
+ stderr=asyncio.subprocess.PIPE,
231
+ env=env,
232
+ )
233
+
234
+ stdout, stderr = await proc.communicate()
235
+
236
+ # ruff returns non-zero if issues found, but that's expected
237
+ if stdout:
238
+ try:
239
+ issues = json.loads(stdout.decode())
240
+ return LinterResult(success=True, issues=issues)
241
+ except json.JSONDecodeError:
242
+ # No issues found or invalid JSON
243
+ return LinterResult(success=True, issues=[])
244
+ else:
245
+ # No output = no issues
246
+ return LinterResult(success=True, issues=[])
247
+
248
+ except FileNotFoundError:
249
+ return LinterResult(success=False, error="ruff command not found")
250
+
251
+ async def _run_eslint(self, path: Path) -> LinterResult:
252
+ """Run eslint on a JavaScript/TypeScript file."""
253
+ try:
254
+ # Check if eslint is installed
255
+ check = await asyncio.create_subprocess_exec(
256
+ "eslint",
257
+ "--version",
258
+ stdout=asyncio.subprocess.PIPE,
259
+ stderr=asyncio.subprocess.PIPE,
260
+ )
261
+ await check.communicate()
262
+
263
+ if check.returncode != 0:
264
+ return LinterResult(success=False, error="eslint not installed")
265
+
266
+ # Run eslint with JSON output
267
+ proc = await asyncio.create_subprocess_exec(
268
+ "eslint",
269
+ "--format",
270
+ "json",
271
+ str(path),
272
+ stdout=asyncio.subprocess.PIPE,
273
+ stderr=asyncio.subprocess.PIPE,
274
+ )
275
+
276
+ stdout, stderr = await proc.communicate()
277
+
278
+ if stdout:
279
+ try:
280
+ results = json.loads(stdout.decode())
281
+ # ESLint returns array of file results
282
+ if results and len(results) > 0:
283
+ issues = results[0].get("messages", [])
284
+ return LinterResult(success=True, issues=issues)
285
+ except json.JSONDecodeError:
286
+ pass
287
+
288
+ return LinterResult(success=True, issues=[])
289
+
290
+ except FileNotFoundError:
291
+ return LinterResult(success=False, error="eslint command not found")
292
+
293
+ async def _auto_fix(self, linter: str, path: Path) -> LinterResult:
294
+ """Attempt to auto-fix issues."""
295
+ try:
296
+ # Get updated environment with venv bin in PATH
297
+ import os
298
+
299
+ env = os.environ.copy()
300
+ venv_bin = Path(__file__).parent.parent.parent.parent / ".venv" / "bin"
301
+ if venv_bin.exists():
302
+ env["PATH"] = f"{venv_bin}:{env.get('PATH', '')}"
303
+
304
+ if linter == "ruff":
305
+ proc = await asyncio.create_subprocess_exec(
306
+ "ruff",
307
+ "check",
308
+ "--fix",
309
+ str(path),
310
+ stdout=asyncio.subprocess.PIPE,
311
+ stderr=asyncio.subprocess.PIPE,
312
+ env=env,
313
+ )
314
+ await proc.communicate()
315
+ return LinterResult(success=True)
316
+
317
+ elif linter == "eslint":
318
+ proc = await asyncio.create_subprocess_exec(
319
+ "eslint",
320
+ "--fix",
321
+ str(path),
322
+ stdout=asyncio.subprocess.PIPE,
323
+ stderr=asyncio.subprocess.PIPE,
324
+ env=env,
325
+ )
326
+ await proc.communicate()
327
+ return LinterResult(success=True)
328
+
329
+ return LinterResult(success=False, error="Auto-fix not supported")
330
+
331
+ except Exception as e:
332
+ return LinterResult(success=False, error=str(e))
333
+
334
+ async def _write_findings_to_context(
335
+ self, path: Path, result: LinterResult, linter: str
336
+ ) -> None:
337
+ """Write linter findings to context store."""
338
+ if not result.success or not result.has_issues:
339
+ return
340
+
341
+ from devloop.core.context_store import context_store
342
+
343
+ # Convert each linter issue to a Finding
344
+ for idx, issue in enumerate(result.issues):
345
+ # Extract issue details (format varies by linter)
346
+ if linter == "ruff":
347
+ location = issue.get("location", {})
348
+ line = location.get("row") if isinstance(location, dict) else None
349
+ column = location.get("column") if isinstance(location, dict) else None
350
+ code = issue.get("code", "unknown")
351
+ message_text = issue.get("message", "")
352
+ fixable = issue.get("fix", None) is not None
353
+ # Get severity from code prefix (E, W, F, etc.)
354
+ severity = "error" if code.startswith(("E", "F")) else "warning"
355
+ elif linter == "eslint":
356
+ line = issue.get("line")
357
+ column = issue.get("column")
358
+ code = issue.get("ruleId", "unknown")
359
+ message_text = issue.get("message", "")
360
+ fixable = issue.get("fix", None) is not None
361
+ # eslint severity: 1 = warning, 2 = error
362
+ eslint_severity = issue.get("severity", 1)
363
+ severity = "error" if eslint_severity == 2 else "warning"
364
+ else:
365
+ # Generic format
366
+ line = None
367
+ column = None
368
+ code = "unknown"
369
+ message_text = str(issue)
370
+ fixable = False
371
+ severity = "warning"
372
+
373
+ # Create Finding
374
+ finding = Finding(
375
+ id=f"{self.name}-{path}-{line}-{code}",
376
+ agent=self.name,
377
+ timestamp=str(datetime.now()),
378
+ file=str(path),
379
+ line=line,
380
+ column=column,
381
+ severity=Severity(severity),
382
+ message=message_text,
383
+ category=code,
384
+ suggestion=(
385
+ f"Run {linter} --fix {path}"
386
+ if fixable and self.config.auto_fix
387
+ else ""
388
+ ),
389
+ context={
390
+ "linter": linter,
391
+ "fixable": fixable,
392
+ "auto_fixable": fixable and self.config.auto_fix,
393
+ },
394
+ )
395
+
396
+ try:
397
+ await context_store.add_finding(finding)
398
+ except Exception as e:
399
+ self.logger.error(f"Failed to write finding to context: {e}")
@@ -0,0 +1,284 @@
1
+ #!/usr/bin/env python3
2
+ """Performance Profiler Agent - Analyzes code complexity and performance."""
3
+
4
+ import asyncio
5
+ import json
6
+ import subprocess # nosec B404 - Required for running performance analysis tools
7
+ import sys
8
+ from pathlib import Path
9
+ from typing import Dict, Any, List, Optional
10
+ from dataclasses import dataclass
11
+
12
+ from ..core.agent import Agent, AgentResult
13
+ from ..core.context_store import context_store, Finding
14
+ from ..core.event import Event
15
+
16
+
17
+ @dataclass
18
+ class PerformanceConfig:
19
+ """Configuration for performance profiling."""
20
+
21
+ complexity_threshold: int = 10 # McCabe complexity threshold
22
+ min_lines_threshold: int = 50 # Minimum lines to analyze
23
+ enabled_tools: Optional[List[str]] = None # ["radon", "flake8-complexity"]
24
+ exclude_patterns: Optional[List[str]] = None
25
+ max_issues: int = 50
26
+
27
+ def __post_init__(self):
28
+ if self.enabled_tools is None:
29
+ self.enabled_tools = ["radon"]
30
+ if self.exclude_patterns is None:
31
+ self.exclude_patterns = ["test*", "*_test.py", "*/tests/*", "__init__.py"]
32
+
33
+
34
+ class PerformanceResult:
35
+ """Performance analysis result."""
36
+
37
+ def __init__(
38
+ self, tool: str, metrics: List[Dict[str, Any]], errors: List[str] = None
39
+ ):
40
+ self.tool = tool
41
+ self.metrics = metrics
42
+ self.errors = errors or []
43
+ self.timestamp = None
44
+
45
+ def to_dict(self) -> Dict[str, Any]:
46
+ return {
47
+ "tool": self.tool,
48
+ "functions_analyzed": len(self.metrics),
49
+ "metrics": self.metrics,
50
+ "errors": self.errors,
51
+ "complexity_summary": self._get_complexity_summary(),
52
+ "high_complexity_functions": self._get_high_complexity_functions(),
53
+ }
54
+
55
+ def _get_complexity_summary(self) -> Dict[str, Any]:
56
+ if not self.metrics:
57
+ return {
58
+ "average_complexity": 0,
59
+ "max_complexity": 0,
60
+ "high_complexity_count": 0,
61
+ }
62
+
63
+ complexities = [m.get("complexity", 0) for m in self.metrics]
64
+ high_complexity = [c for c in complexities if c >= 10]
65
+
66
+ return {
67
+ "average_complexity": round(sum(complexities) / len(complexities), 1),
68
+ "max_complexity": max(complexities),
69
+ "high_complexity_count": len(high_complexity),
70
+ "total_functions": len(self.metrics),
71
+ }
72
+
73
+ def _get_high_complexity_functions(self) -> List[Dict[str, Any]]:
74
+ """Get functions with high complexity."""
75
+ return [m for m in self.metrics if m.get("complexity", 0) >= 10]
76
+
77
+
78
+ class PerformanceProfilerAgent(Agent):
79
+ """Agent for analyzing code performance and complexity."""
80
+
81
+ def __init__(self, config: Dict[str, Any], event_bus):
82
+ super().__init__(
83
+ "performance-profiler", ["file:modified", "file:created"], event_bus
84
+ )
85
+ self.config = PerformanceConfig(**config)
86
+
87
+ async def handle(self, event: Event) -> AgentResult:
88
+ """Handle file change events by analyzing performance."""
89
+
90
+ file_path = event.payload.get("path")
91
+ if not file_path:
92
+ return AgentResult(
93
+ agent_name=self.name,
94
+ success=False,
95
+ duration=0.0,
96
+ message="No file path in event",
97
+ )
98
+
99
+ path = Path(file_path)
100
+ if not path.exists():
101
+ return AgentResult(
102
+ agent_name=self.name,
103
+ success=False,
104
+ duration=0.0,
105
+ message=f"File does not exist: {file_path}",
106
+ )
107
+
108
+ # Only analyze Python files
109
+ if path.suffix != ".py":
110
+ return AgentResult(
111
+ agent_name=self.name,
112
+ success=True,
113
+ duration=0.0,
114
+ message=f"Skipped non-Python file: {file_path}",
115
+ )
116
+
117
+ # Check if file is large enough to analyze
118
+ if not self._should_analyze_file(path):
119
+ return AgentResult(
120
+ agent_name=self.name,
121
+ success=True,
122
+ duration=0.0,
123
+ message=f"File too small to analyze: {file_path}",
124
+ )
125
+
126
+ # Check if file matches exclude patterns
127
+ if self._should_exclude_file(str(path)):
128
+ return AgentResult(
129
+ agent_name=self.name,
130
+ success=True,
131
+ duration=0.0,
132
+ message=f"Excluded file: {file_path}",
133
+ )
134
+
135
+ # Run performance analysis
136
+ results = await self._run_performance_analysis(path)
137
+
138
+ summary = results._get_complexity_summary()
139
+ high_complexity = results._get_high_complexity_functions()
140
+
141
+ agent_result = AgentResult(
142
+ agent_name=self.name,
143
+ success=True,
144
+ duration=0.0, # Would be calculated in real implementation
145
+ message=f"Analyzed {path} with {results.tool}",
146
+ data={
147
+ "file": str(path),
148
+ "tool": results.tool,
149
+ "functions_analyzed": len(results.metrics),
150
+ "metrics": results.metrics,
151
+ "complexity_summary": summary,
152
+ "high_complexity_functions": high_complexity,
153
+ "errors": results.errors,
154
+ },
155
+ )
156
+
157
+ # Write to context store for Claude Code integration
158
+ for func in high_complexity:
159
+ await context_store.add_finding(
160
+ Finding(
161
+ id=f"{self.name}-{file_path}-{func['name']}",
162
+ agent=self.name,
163
+ timestamp=str(event.timestamp),
164
+ file=str(file_path),
165
+ line=func["line_number"],
166
+ message=f"High complexity function: {func['name']} (complexity: {func['complexity']})",
167
+ context=func,
168
+ )
169
+ )
170
+
171
+ return agent_result
172
+
173
+ def _should_analyze_file(self, file_path: Path) -> bool:
174
+ """Check if file is large enough to analyze."""
175
+ try:
176
+ with open(file_path, "r", encoding="utf-8") as f:
177
+ lines = f.readlines()
178
+ return len(lines) >= self.config.min_lines_threshold
179
+ except Exception:
180
+ return False
181
+
182
+ def _should_exclude_file(self, file_path: str) -> bool:
183
+ """Check if file should be excluded from analysis."""
184
+ if not self.config.exclude_patterns:
185
+ return False
186
+ for pattern in self.config.exclude_patterns:
187
+ if pattern.startswith("*") and pattern.endswith("*"):
188
+ if pattern[1:-1] in file_path:
189
+ return True
190
+ elif pattern.startswith("*"):
191
+ if file_path.endswith(pattern[1:]):
192
+ return True
193
+ elif pattern.endswith("*"):
194
+ if file_path.startswith(pattern[:-1]):
195
+ return True
196
+ elif pattern == file_path:
197
+ return True
198
+ return False
199
+
200
+ async def _run_performance_analysis(self, file_path: Path) -> PerformanceResult:
201
+ """Run performance analysis tools."""
202
+ results = []
203
+
204
+ # Try radon first (good for complexity analysis)
205
+ if self.config.enabled_tools and "radon" in self.config.enabled_tools:
206
+ radon_result = await self._run_radon(file_path)
207
+ if radon_result:
208
+ results.append(radon_result)
209
+
210
+ # If no results from primary tools, return empty
211
+ if results:
212
+ return results[0] # Return first successful result
213
+
214
+ return PerformanceResult(
215
+ "none", [], ["No performance analysis tools available"]
216
+ )
217
+
218
+ async def _run_radon(self, file_path: Path) -> Optional[PerformanceResult]:
219
+ """Run Radon complexity analysis."""
220
+ try:
221
+ # Check if radon is available
222
+ result = subprocess.run(
223
+ [sys.executable, "-c", "import radon"], capture_output=True, text=True
224
+ ) # nosec B603 - Running trusted system Python with safe arguments
225
+ if result.returncode != 0:
226
+ return PerformanceResult(
227
+ "radon", [], ["Radon not installed - run: pip install radon"]
228
+ )
229
+
230
+ # Run radon cc (complexity) command
231
+ cmd = [
232
+ sys.executable,
233
+ "-m",
234
+ "radon",
235
+ "cc",
236
+ "-j", # JSON output
237
+ str(file_path),
238
+ ]
239
+
240
+ process = await asyncio.create_subprocess_exec(
241
+ *cmd,
242
+ stdout=asyncio.subprocess.PIPE,
243
+ stderr=asyncio.subprocess.PIPE,
244
+ cwd=file_path.parent,
245
+ )
246
+
247
+ stdout, stderr = await process.communicate()
248
+
249
+ metrics = []
250
+
251
+ if process.returncode == 0:
252
+ try:
253
+ data = json.loads(stdout.decode())
254
+
255
+ # Parse radon output
256
+ for file_name, file_data in data.items():
257
+ for function_data in file_data:
258
+ metrics.append(
259
+ {
260
+ "name": function_data.get("name", ""),
261
+ "type": function_data.get("type", ""),
262
+ "complexity": function_data.get("complexity", 0),
263
+ "line_number": function_data.get("lineno", 0),
264
+ "end_line": function_data.get("endline", 0),
265
+ "rank": function_data.get("rank", ""),
266
+ "file": file_name,
267
+ }
268
+ )
269
+
270
+ return PerformanceResult("radon", metrics[: self.config.max_issues])
271
+
272
+ except json.JSONDecodeError:
273
+ return PerformanceResult(
274
+ "radon",
275
+ [],
276
+ [f"Failed to parse radon output: {stdout.decode()[:200]}"],
277
+ )
278
+
279
+ else:
280
+ error_msg = stderr.decode().strip()
281
+ return PerformanceResult("radon", [], [f"Radon failed: {error_msg}"])
282
+
283
+ except Exception as e:
284
+ return PerformanceResult("radon", [], [f"Radon execution error: {str(e)}"])