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,484 @@
1
+ """Test runner agent - runs tests on file changes."""
2
+
3
+ import asyncio
4
+ import json
5
+ import re
6
+ from datetime import datetime
7
+ from pathlib import Path
8
+ from typing import Any, Dict, List, Optional
9
+
10
+ from devloop.core.agent import Agent, AgentResult
11
+ from devloop.core.context_store import Finding, Severity
12
+ from devloop.core.event import Event
13
+
14
+
15
+ class TestRunnerConfig:
16
+ """Configuration for TestRunnerAgent."""
17
+
18
+ def __init__(self, config: Dict[str, Any]):
19
+ self.enabled = config.get("enabled", True)
20
+ self.run_on_save = config.get("runOnSave", True)
21
+ self.related_tests_only = config.get("relatedTestsOnly", True)
22
+ self.auto_detect_frameworks = config.get("autoDetectFrameworks", True)
23
+
24
+ # Default frameworks (will be overridden by auto-detection if enabled)
25
+ self.test_frameworks = config.get(
26
+ "testFrameworks",
27
+ {"python": "pytest", "javascript": "jest", "typescript": "jest"},
28
+ )
29
+ self.test_patterns = config.get(
30
+ "testPatterns",
31
+ {
32
+ "python": ["**/test_*.py", "**/*_test.py"],
33
+ "javascript": ["**/*.test.js", "**/*.spec.js"],
34
+ "typescript": ["**/*.test.ts", "**/*.spec.ts"],
35
+ },
36
+ )
37
+
38
+ # Auto-detect available frameworks if enabled
39
+ if self.auto_detect_frameworks:
40
+ detected = self._auto_detect_frameworks()
41
+ self.test_frameworks.update(detected)
42
+
43
+ def _auto_detect_frameworks(self) -> Dict[str, str]:
44
+ """Auto-detect available test frameworks in the project."""
45
+ detected = {}
46
+ project_root = Path.cwd()
47
+
48
+ # Python frameworks
49
+ if (project_root / "pytest.ini").exists() or (
50
+ project_root / "pyproject.toml"
51
+ ).exists():
52
+ if self._check_command("python -m pytest --version"):
53
+ detected["python"] = "pytest"
54
+
55
+ if (project_root / "requirements.txt").exists():
56
+ with open(project_root / "requirements.txt") as f:
57
+ if "unittest" in f.read().lower() or "unittest" in str(project_root):
58
+ detected["python"] = "unittest"
59
+
60
+ # JavaScript/TypeScript frameworks
61
+ if (project_root / "package.json").exists():
62
+ try:
63
+ with open(project_root / "package.json") as f:
64
+ package_data = json.load(f)
65
+
66
+ scripts = package_data.get("scripts", {})
67
+ dependencies = package_data.get("devDependencies", {})
68
+
69
+ if "jest" in dependencies or any(
70
+ "jest" in str(v) for v in scripts.values()
71
+ ):
72
+ detected["javascript"] = "jest"
73
+ detected["typescript"] = "jest"
74
+
75
+ if "mocha" in dependencies:
76
+ detected["javascript"] = "mocha"
77
+
78
+ if "vitest" in dependencies:
79
+ detected["javascript"] = "vitest"
80
+ detected["typescript"] = "vitest"
81
+
82
+ except (json.JSONDecodeError, FileNotFoundError):
83
+ pass
84
+
85
+ # Fallback to basic detection
86
+ if not detected.get("python"):
87
+ if self._check_command("python -m pytest --version"):
88
+ detected["python"] = "pytest"
89
+ elif self._check_command("python -m unittest --help > /dev/null 2>&1"):
90
+ detected["python"] = "unittest"
91
+
92
+ return detected
93
+
94
+ def _check_command(self, command: str) -> bool:
95
+ """Check if a command is available."""
96
+ import shutil
97
+
98
+ return shutil.which(command) is not None
99
+
100
+
101
+ class TestResult:
102
+ """Result from running tests."""
103
+
104
+ def __init__(
105
+ self,
106
+ success: bool,
107
+ passed: int = 0,
108
+ failed: int = 0,
109
+ skipped: int = 0,
110
+ duration: float = 0.0,
111
+ failures: List[Dict[str, Any]] | None = None,
112
+ error: str | None = None,
113
+ ):
114
+ self.success = success
115
+ self.passed = passed
116
+ self.failed = failed
117
+ self.skipped = skipped
118
+ self.duration = duration
119
+ self.failures = failures or []
120
+ self.error = error
121
+
122
+ @property
123
+ def total(self) -> int:
124
+ """Total tests run."""
125
+ return self.passed + self.failed + self.skipped
126
+
127
+ @property
128
+ def all_passed(self) -> bool:
129
+ """Check if all tests passed."""
130
+ return self.failed == 0 and self.passed > 0
131
+
132
+
133
+ class TestRunnerAgent(Agent):
134
+ """Agent that runs tests when files change."""
135
+
136
+ def __init__(
137
+ self,
138
+ name: str,
139
+ triggers: List[str],
140
+ event_bus,
141
+ config: Dict[str, Any] | None = None,
142
+ ):
143
+ super().__init__(name, triggers, event_bus)
144
+ self.config = TestRunnerConfig(config or {})
145
+
146
+ async def handle(self, event: Event) -> AgentResult:
147
+ """Handle file change event by running tests."""
148
+ if not self.config.run_on_save:
149
+ return AgentResult(
150
+ agent_name=self.name,
151
+ success=True,
152
+ duration=0,
153
+ message="Run on save disabled",
154
+ )
155
+
156
+ # Extract file path
157
+ file_path = event.payload.get("path")
158
+ if not file_path:
159
+ return AgentResult(
160
+ agent_name=self.name,
161
+ success=True,
162
+ duration=0,
163
+ message="No file path in event",
164
+ )
165
+
166
+ path = Path(file_path)
167
+
168
+ # Determine if this is a test file or source file
169
+ is_test_file = self._is_test_file(path)
170
+
171
+ # Get test framework
172
+ framework = self._get_test_framework(path)
173
+ if not framework:
174
+ return AgentResult(
175
+ agent_name=self.name,
176
+ success=True,
177
+ duration=0,
178
+ message=f"No test framework configured for {path.suffix}",
179
+ )
180
+
181
+ # Determine which tests to run
182
+ if is_test_file:
183
+ # Run this specific test file
184
+ test_files = [path]
185
+ elif self.config.related_tests_only:
186
+ # Find related test files
187
+ test_files = self._find_related_tests(path)
188
+ if not test_files:
189
+ return AgentResult(
190
+ agent_name=self.name,
191
+ success=True,
192
+ duration=0,
193
+ message=f"No tests found for {path.name}",
194
+ )
195
+ else:
196
+ # Run all tests
197
+ test_files = []
198
+
199
+ # Run tests
200
+ result = await self._run_tests(framework, test_files, path)
201
+
202
+ # Build result message
203
+ if result.error:
204
+ message = f"Test error: {result.error}"
205
+ success = False
206
+ elif result.all_passed:
207
+ message = f"✓ {result.passed} test(s) passed"
208
+ success = True
209
+ elif result.failed > 0:
210
+ message = f"✗ {result.failed} test(s) failed, {result.passed} passed"
211
+ success = False
212
+ else:
213
+ message = "No tests run"
214
+ success = True
215
+
216
+ agent_result = AgentResult(
217
+ agent_name=self.name,
218
+ success=success,
219
+ duration=result.duration,
220
+ message=message,
221
+ data={
222
+ "file": str(path),
223
+ "framework": framework,
224
+ "passed": result.passed,
225
+ "failed": result.failed,
226
+ "skipped": result.skipped,
227
+ "total": result.total,
228
+ "failures": result.failures,
229
+ },
230
+ error=result.error,
231
+ )
232
+
233
+ # Write to context store for Claude Code integration
234
+ await self._write_findings_to_context(path, result, framework)
235
+
236
+ return agent_result
237
+
238
+ async def _write_findings_to_context(
239
+ self, path: Path, test_result: TestResult, framework: str
240
+ ) -> None:
241
+ """Write test failures to the context store."""
242
+ from devloop.core.context_store import context_store
243
+
244
+ if test_result.failed > 0:
245
+ # Create a finding for test failures
246
+ finding = Finding(
247
+ id=f"{self.name}-{path}-failed",
248
+ agent=self.name,
249
+ timestamp=str(datetime.now()),
250
+ file=str(path),
251
+ severity=Severity.ERROR,
252
+ message=f"{test_result.failed} test(s) failed in {framework}",
253
+ context={
254
+ "framework": framework,
255
+ "failed": test_result.failed,
256
+ "passed": test_result.passed,
257
+ "blocking": True,
258
+ },
259
+ )
260
+ await context_store.add_finding(finding)
261
+ elif test_result.error:
262
+ # Create a finding for test errors
263
+ finding = Finding(
264
+ id=f"{self.name}-{path}-error",
265
+ agent=self.name,
266
+ timestamp=str(datetime.now()),
267
+ file=str(path),
268
+ severity=Severity.ERROR,
269
+ message=f"Test error: {test_result.error}",
270
+ context={
271
+ "framework": framework,
272
+ "error": test_result.error,
273
+ "blocking": True,
274
+ },
275
+ )
276
+ await context_store.add_finding(finding)
277
+
278
+ def _is_test_file(self, path: Path) -> bool:
279
+ """Check if file is a test file."""
280
+ name = path.name
281
+ return (
282
+ name.startswith("test_")
283
+ or name.endswith("_test.py")
284
+ or ".test." in name
285
+ or ".spec." in name
286
+ )
287
+
288
+ def _get_test_framework(self, path: Path) -> Optional[str]:
289
+ """Get test framework for file type."""
290
+ suffix = path.suffix.lstrip(".")
291
+
292
+ extension_map = {
293
+ "py": "python",
294
+ "js": "javascript",
295
+ "jsx": "javascript",
296
+ "ts": "typescript",
297
+ "tsx": "typescript",
298
+ }
299
+
300
+ language = extension_map.get(suffix)
301
+ if language:
302
+ return self.config.test_frameworks.get(language)
303
+
304
+ return None
305
+
306
+ def _find_related_tests(self, path: Path) -> List[Path]:
307
+ """Find test files related to a source file."""
308
+ test_files = []
309
+
310
+ # For Python: test_<name>.py or <name>_test.py
311
+ if path.suffix == ".py":
312
+ stem = path.stem
313
+ test_dir = path.parent / "tests"
314
+ possible_tests = [
315
+ path.parent / f"test_{stem}.py",
316
+ path.parent / f"{stem}_test.py",
317
+ test_dir / f"test_{stem}.py",
318
+ test_dir / f"{stem}_test.py",
319
+ ]
320
+ test_files.extend([t for t in possible_tests if t.exists()])
321
+
322
+ # For JS/TS: <name>.test.js/ts or <name>.spec.js/ts
323
+ elif path.suffix in [".js", ".jsx", ".ts", ".tsx"]:
324
+ stem = path.stem
325
+ ext = path.suffix
326
+ possible_tests = [
327
+ path.parent / f"{stem}.test{ext}",
328
+ path.parent / f"{stem}.spec{ext}",
329
+ path.parent / "__tests__" / f"{stem}.test{ext}",
330
+ ]
331
+ test_files.extend([t for t in possible_tests if t.exists()])
332
+
333
+ return test_files
334
+
335
+ async def _run_tests(
336
+ self, framework: str, test_files: List[Path], source_path: Path
337
+ ) -> TestResult:
338
+ """Run tests using the specified framework."""
339
+ try:
340
+ if framework == "pytest":
341
+ return await self._run_pytest(test_files, source_path)
342
+ elif framework == "jest":
343
+ return await self._run_jest(test_files, source_path)
344
+ else:
345
+ return TestResult(
346
+ success=False, error=f"Unknown framework: {framework}"
347
+ )
348
+
349
+ except Exception as e:
350
+ self.logger.error(f"Error running {framework}: {e}")
351
+ return TestResult(success=False, error=str(e))
352
+
353
+ async def _run_pytest(
354
+ self, test_files: List[Path], source_path: Path
355
+ ) -> TestResult:
356
+ """Run pytest."""
357
+ try:
358
+ # Get updated environment with venv bin in PATH
359
+ import os
360
+
361
+ env = os.environ.copy()
362
+ venv_bin = Path(__file__).parent.parent.parent.parent / ".venv" / "bin"
363
+ if venv_bin.exists():
364
+ env["PATH"] = f"{venv_bin}:{env.get('PATH', '')}"
365
+
366
+ # Check if pytest is installed
367
+ check = await asyncio.create_subprocess_exec(
368
+ "pytest",
369
+ "--version",
370
+ stdout=asyncio.subprocess.PIPE,
371
+ stderr=asyncio.subprocess.PIPE,
372
+ env=env,
373
+ )
374
+ await check.communicate()
375
+
376
+ if check.returncode != 0:
377
+ return TestResult(success=False, error="pytest not installed")
378
+
379
+ # Build command
380
+ cmd = ["pytest", "-v", "--tb=short"]
381
+
382
+ if test_files:
383
+ # Run specific test files
384
+ cmd.extend([str(f) for f in test_files])
385
+ else:
386
+ # Run all tests in project
387
+ cmd.append(str(source_path.parent))
388
+
389
+ # Run pytest
390
+ proc = await asyncio.create_subprocess_exec(
391
+ *cmd,
392
+ stdout=asyncio.subprocess.PIPE,
393
+ stderr=asyncio.subprocess.PIPE,
394
+ env=env,
395
+ )
396
+
397
+ stdout, stderr = await proc.communicate()
398
+ output = stdout.decode() if stdout else ""
399
+
400
+ # Parse output
401
+ passed = self._count_pattern(output, r"(\d+) passed")
402
+ failed = self._count_pattern(output, r"(\d+) failed")
403
+ skipped = self._count_pattern(output, r"(\d+) skipped")
404
+
405
+ # Extract duration
406
+ duration_match = re.search(r"in ([\d.]+)s", output)
407
+ duration = float(duration_match.group(1)) if duration_match else 0.0
408
+
409
+ success = proc.returncode == 0
410
+
411
+ return TestResult(
412
+ success=success,
413
+ passed=passed,
414
+ failed=failed,
415
+ skipped=skipped,
416
+ duration=duration,
417
+ )
418
+
419
+ except FileNotFoundError:
420
+ return TestResult(success=False, error="pytest command not found")
421
+
422
+ async def _run_jest(self, test_files: List[Path], source_path: Path) -> TestResult:
423
+ """Run jest."""
424
+ try:
425
+ # Get updated environment with venv bin in PATH
426
+ import os
427
+
428
+ env = os.environ.copy()
429
+ venv_bin = Path(__file__).parent.parent.parent.parent / ".venv" / "bin"
430
+ if venv_bin.exists():
431
+ env["PATH"] = f"{venv_bin}:{env.get('PATH', '')}"
432
+
433
+ # Check if jest is installed
434
+ check = await asyncio.create_subprocess_exec(
435
+ "jest",
436
+ "--version",
437
+ stdout=asyncio.subprocess.PIPE,
438
+ stderr=asyncio.subprocess.PIPE,
439
+ env=env,
440
+ )
441
+ await check.communicate()
442
+
443
+ if check.returncode != 0:
444
+ return TestResult(success=False, error="jest not installed")
445
+
446
+ # Build command
447
+ cmd = ["jest", "--json"]
448
+
449
+ if test_files:
450
+ cmd.extend([str(f) for f in test_files])
451
+
452
+ # Run jest
453
+ proc = await asyncio.create_subprocess_exec(
454
+ *cmd,
455
+ stdout=asyncio.subprocess.PIPE,
456
+ stderr=asyncio.subprocess.PIPE,
457
+ env=env,
458
+ )
459
+
460
+ stdout, stderr = await proc.communicate()
461
+
462
+ # Parse JSON output
463
+ if stdout:
464
+ try:
465
+ results = json.loads(stdout.decode())
466
+ return TestResult(
467
+ success=results.get("success", False),
468
+ passed=results.get("numPassedTests", 0),
469
+ failed=results.get("numFailedTests", 0),
470
+ skipped=results.get("numPendingTests", 0),
471
+ duration=results.get("startTime", 0),
472
+ )
473
+ except json.JSONDecodeError:
474
+ pass
475
+
476
+ return TestResult(success=proc.returncode == 0)
477
+
478
+ except FileNotFoundError:
479
+ return TestResult(success=False, error="jest command not found")
480
+
481
+ def _count_pattern(self, text: str, pattern: str) -> int:
482
+ """Count occurrences of a pattern in text."""
483
+ match = re.search(pattern, text)
484
+ return int(match.group(1)) if match else 0