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.
- devloop/__init__.py +3 -0
- devloop/agents/__init__.py +33 -0
- devloop/agents/agent_health_monitor.py +105 -0
- devloop/agents/ci_monitor.py +237 -0
- devloop/agents/code_rabbit.py +248 -0
- devloop/agents/doc_lifecycle.py +374 -0
- devloop/agents/echo.py +24 -0
- devloop/agents/file_logger.py +46 -0
- devloop/agents/formatter.py +511 -0
- devloop/agents/git_commit_assistant.py +421 -0
- devloop/agents/linter.py +399 -0
- devloop/agents/performance_profiler.py +284 -0
- devloop/agents/security_scanner.py +322 -0
- devloop/agents/snyk.py +292 -0
- devloop/agents/test_runner.py +484 -0
- devloop/agents/type_checker.py +242 -0
- devloop/cli/__init__.py +1 -0
- devloop/cli/commands/__init__.py +1 -0
- devloop/cli/commands/custom_agents.py +144 -0
- devloop/cli/commands/feedback.py +161 -0
- devloop/cli/commands/summary.py +50 -0
- devloop/cli/main.py +430 -0
- devloop/cli/main_v1.py +144 -0
- devloop/collectors/__init__.py +17 -0
- devloop/collectors/base.py +55 -0
- devloop/collectors/filesystem.py +126 -0
- devloop/collectors/git.py +171 -0
- devloop/collectors/manager.py +159 -0
- devloop/collectors/process.py +221 -0
- devloop/collectors/system.py +195 -0
- devloop/core/__init__.py +21 -0
- devloop/core/agent.py +206 -0
- devloop/core/agent_template.py +498 -0
- devloop/core/amp_integration.py +166 -0
- devloop/core/auto_fix.py +224 -0
- devloop/core/config.py +272 -0
- devloop/core/context.py +0 -0
- devloop/core/context_store.py +530 -0
- devloop/core/contextual_feedback.py +311 -0
- devloop/core/custom_agent.py +439 -0
- devloop/core/debug_trace.py +289 -0
- devloop/core/event.py +105 -0
- devloop/core/event_store.py +316 -0
- devloop/core/feedback.py +311 -0
- devloop/core/learning.py +351 -0
- devloop/core/manager.py +219 -0
- devloop/core/performance.py +433 -0
- devloop/core/proactive_feedback.py +302 -0
- devloop/core/summary_formatter.py +159 -0
- devloop/core/summary_generator.py +275 -0
- devloop-0.2.0.dist-info/METADATA +705 -0
- devloop-0.2.0.dist-info/RECORD +55 -0
- devloop-0.2.0.dist-info/WHEEL +4 -0
- devloop-0.2.0.dist-info/entry_points.txt +3 -0
- devloop-0.2.0.dist-info/licenses/LICENSE +21 -0
devloop/agents/linter.py
ADDED
|
@@ -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)}"])
|