gdmcode 0.1.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.
- gdmcode-0.1.0.dist-info/METADATA +240 -0
- gdmcode-0.1.0.dist-info/RECORD +131 -0
- gdmcode-0.1.0.dist-info/WHEEL +4 -0
- gdmcode-0.1.0.dist-info/entry_points.txt +2 -0
- src/__init__.py +1 -0
- src/_internal/__init__.py +0 -0
- src/_internal/constants.py +244 -0
- src/_internal/domain_skills.py +339 -0
- src/agent/__init__.py +0 -0
- src/agent/commit_classifier.py +91 -0
- src/agent/context_budget.py +391 -0
- src/agent/daemon.py +681 -0
- src/agent/dag_validator.py +153 -0
- src/agent/debug_loop.py +473 -0
- src/agent/impact_analyzer.py +149 -0
- src/agent/impact_graph.py +117 -0
- src/agent/loop.py +1410 -0
- src/agent/orchestrator.py +141 -0
- src/agent/regression_guard.py +251 -0
- src/agent/review_gate.py +648 -0
- src/agent/risk_scorer.py +169 -0
- src/agent/self_healing.py +145 -0
- src/agent/smart_test_selector.py +89 -0
- src/agent/system_prompt.py +226 -0
- src/agent/task_tracker.py +320 -0
- src/agent/test_validator.py +210 -0
- src/agent/tool_orchestrator.py +402 -0
- src/agent/transcript.py +230 -0
- src/agent/verification_loop.py +133 -0
- src/agent/work_director.py +136 -0
- src/agent/worktree_manager.py +53 -0
- src/artifacts/__init__.py +16 -0
- src/artifacts/artifact_store.py +456 -0
- src/artifacts/verification_graph.py +75 -0
- src/auth.py +411 -0
- src/cli.py +1290 -0
- src/commands.py +1398 -0
- src/config.py +762 -0
- src/cost_tracker.py +348 -0
- src/db/__init__.py +4 -0
- src/db/migrations.py +337 -0
- src/enterprise/__init__.py +3 -0
- src/enterprise/audit_log.py +182 -0
- src/enterprise/identity.py +90 -0
- src/enterprise/rbac.py +100 -0
- src/enterprise/team_config.py +125 -0
- src/enterprise/usage_analytics.py +261 -0
- src/exceptions.py +207 -0
- src/git_workflow.py +651 -0
- src/integrations/__init__.py +6 -0
- src/integrations/github_actions.py +106 -0
- src/integrations/mcp_server.py +333 -0
- src/integrations/sentry_integration.py +100 -0
- src/integrations/sentry_server.py +82 -0
- src/integrations/webhook_security.py +19 -0
- src/main.py +27 -0
- src/memory/__init__.py +0 -0
- src/memory/code_index.py +376 -0
- src/memory/compressor.py +378 -0
- src/memory/context_memory.py +135 -0
- src/memory/continuous_memory.py +234 -0
- src/memory/conventions.py +495 -0
- src/memory/db.py +1119 -0
- src/memory/document_index.py +205 -0
- src/memory/file_cache.py +128 -0
- src/memory/project_scanner.py +178 -0
- src/memory/session_store.py +201 -0
- src/models/__init__.py +0 -0
- src/models/client.py +715 -0
- src/models/definitions.py +459 -0
- src/models/router.py +418 -0
- src/models/schemas.py +389 -0
- src/permissions.py +294 -0
- src/remote/__init__.py +5 -0
- src/remote/command_filter.py +33 -0
- src/remote/models.py +31 -0
- src/remote/permission_handler.py +79 -0
- src/remote/phone_ui.py +48 -0
- src/remote/protocol.py +59 -0
- src/remote/qr.py +65 -0
- src/remote/server.py +586 -0
- src/remote/token_manager.py +61 -0
- src/remote/tunnel.py +212 -0
- src/repl.py +475 -0
- src/runtime/__init__.py +1 -0
- src/runtime/branch_farm.py +372 -0
- src/runtime/replay.py +351 -0
- src/sandbox/__init__.py +2 -0
- src/sandbox/hermetic.py +214 -0
- src/sandbox/policy.py +44 -0
- src/sdk/__init__.py +3 -0
- src/sdk/plugin_base.py +39 -0
- src/sdk/plugin_host.py +100 -0
- src/sdk/plugin_loader.py +101 -0
- src/security.py +409 -0
- src/server/__init__.py +7 -0
- src/server/bridge.py +427 -0
- src/server/bridge_cli.py +103 -0
- src/server/bridge_client.py +170 -0
- src/server/protocol_version.py +103 -0
- src/session/__init__.py +10 -0
- src/session/event_fanout.py +46 -0
- src/session/input_broker.py +38 -0
- src/session/permission_bridge.py +100 -0
- src/tools/__init__.py +160 -0
- src/tools/_atomic.py +72 -0
- src/tools/agent_tools.py +423 -0
- src/tools/ask_user_tool.py +83 -0
- src/tools/bash_tool.py +384 -0
- src/tools/browser_tool.py +352 -0
- src/tools/browser_tools.py +179 -0
- src/tools/dep_tools.py +210 -0
- src/tools/document_reader.py +167 -0
- src/tools/document_tool.py +240 -0
- src/tools/document_writer.py +171 -0
- src/tools/impact_tools.py +240 -0
- src/tools/playwright_tool.py +172 -0
- src/tools/quality_tools.py +366 -0
- src/tools/read_tools.py +318 -0
- src/tools/result_cache.py +157 -0
- src/tools/search_tools.py +310 -0
- src/tools/shell_tools.py +311 -0
- src/tools/write_tools.py +337 -0
- src/voice/__init__.py +25 -0
- src/voice/audio_capture.py +92 -0
- src/voice/audio_playback.py +68 -0
- src/voice/errors.py +14 -0
- src/voice/models.py +35 -0
- src/voice/providers.py +143 -0
- src/voice/vad.py +55 -0
- src/voice/voice_loop.py +156 -0
|
@@ -0,0 +1,141 @@
|
|
|
1
|
+
"""Multi-file edit orchestrator with atomic rollback."""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
import os
|
|
5
|
+
import tempfile
|
|
6
|
+
from dataclasses import dataclass, field
|
|
7
|
+
from pathlib import Path
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
@dataclass
|
|
11
|
+
class FileEdit:
|
|
12
|
+
path: str # relative to project root
|
|
13
|
+
content: str # new file content (full replacement)
|
|
14
|
+
create: bool = False # True if creating new file
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
@dataclass
|
|
18
|
+
class OrchestratorPlan:
|
|
19
|
+
edits: list[FileEdit]
|
|
20
|
+
description: str = ""
|
|
21
|
+
dependencies: dict[str, list[str]] = field(default_factory=dict)
|
|
22
|
+
# dependencies: {file_path: [files_that_must_be_edited_first]}
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
@dataclass
|
|
26
|
+
class OrchestratorResult:
|
|
27
|
+
success: bool
|
|
28
|
+
applied: list[str] = field(default_factory=list) # paths successfully written
|
|
29
|
+
rolled_back: list[str] = field(default_factory=list) # paths restored on failure
|
|
30
|
+
error: str | None = None
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
class MultiFileOrchestrator:
|
|
34
|
+
"""Coordinates multi-file edits with dependency ordering and atomic rollback.
|
|
35
|
+
|
|
36
|
+
On any failure, all already-applied edits are reverted to original content.
|
|
37
|
+
"""
|
|
38
|
+
|
|
39
|
+
def __init__(self, project_root: str | Path = "."):
|
|
40
|
+
self._root = Path(project_root)
|
|
41
|
+
|
|
42
|
+
def apply(self, plan: OrchestratorPlan) -> OrchestratorResult:
|
|
43
|
+
"""Apply all edits in dependency order. Roll back all on any failure."""
|
|
44
|
+
try:
|
|
45
|
+
ordered = self._topological_sort(plan.edits, plan.dependencies)
|
|
46
|
+
except ValueError as exc:
|
|
47
|
+
return OrchestratorResult(success=False, error=str(exc))
|
|
48
|
+
|
|
49
|
+
paths = [e.path for e in ordered]
|
|
50
|
+
backups = self._backup(paths)
|
|
51
|
+
applied: list[str] = []
|
|
52
|
+
|
|
53
|
+
try:
|
|
54
|
+
for edit in ordered:
|
|
55
|
+
self._write(edit)
|
|
56
|
+
applied.append(edit.path)
|
|
57
|
+
except Exception as exc: # noqa: BLE001
|
|
58
|
+
self._restore(backups)
|
|
59
|
+
return OrchestratorResult(
|
|
60
|
+
success=False,
|
|
61
|
+
rolled_back=list(backups.keys()),
|
|
62
|
+
error=str(exc),
|
|
63
|
+
)
|
|
64
|
+
|
|
65
|
+
return OrchestratorResult(success=True, applied=applied)
|
|
66
|
+
|
|
67
|
+
def _topological_sort(
|
|
68
|
+
self, edits: list[FileEdit], deps: dict[str, list[str]]
|
|
69
|
+
) -> list[FileEdit]:
|
|
70
|
+
"""Return edits sorted by dependency order (Kahn''s algorithm).
|
|
71
|
+
|
|
72
|
+
Raises ValueError on circular dependency.
|
|
73
|
+
"""
|
|
74
|
+
edit_by_path: dict[str, FileEdit] = {e.path: e for e in edits}
|
|
75
|
+
|
|
76
|
+
# Build in-degree and adjacency: prereq -> [dependents]
|
|
77
|
+
in_degree: dict[str, int] = {e.path: 0 for e in edits}
|
|
78
|
+
dependents: dict[str, list[str]] = {e.path: [] for e in edits}
|
|
79
|
+
|
|
80
|
+
for path, prereqs in deps.items():
|
|
81
|
+
if path not in in_degree:
|
|
82
|
+
continue
|
|
83
|
+
for prereq in prereqs:
|
|
84
|
+
if prereq not in in_degree:
|
|
85
|
+
continue
|
|
86
|
+
in_degree[path] += 1
|
|
87
|
+
dependents[prereq].append(path)
|
|
88
|
+
|
|
89
|
+
# Kahn''s: start with zero-in-degree nodes (sorted for determinism)
|
|
90
|
+
queue: list[str] = sorted(p for p, d in in_degree.items() if d == 0)
|
|
91
|
+
result: list[FileEdit] = []
|
|
92
|
+
|
|
93
|
+
while queue:
|
|
94
|
+
path = queue.pop(0)
|
|
95
|
+
result.append(edit_by_path[path])
|
|
96
|
+
for dep in sorted(dependents[path]):
|
|
97
|
+
in_degree[dep] -= 1
|
|
98
|
+
if in_degree[dep] == 0:
|
|
99
|
+
queue.append(dep)
|
|
100
|
+
|
|
101
|
+
if len(result) != len(edits):
|
|
102
|
+
raise ValueError("Circular dependency detected in edit plan")
|
|
103
|
+
|
|
104
|
+
return result
|
|
105
|
+
|
|
106
|
+
def _backup(self, paths: list[str]) -> dict[str, str | None]:
|
|
107
|
+
"""Snapshot current file contents. None if file does not exist."""
|
|
108
|
+
backups: dict[str, str | None] = {}
|
|
109
|
+
for path in paths:
|
|
110
|
+
abs_path = self._root / path
|
|
111
|
+
backups[path] = abs_path.read_text(encoding="utf-8") if abs_path.exists() else None
|
|
112
|
+
return backups
|
|
113
|
+
|
|
114
|
+
def _restore(self, backups: dict[str, str | None]) -> None:
|
|
115
|
+
"""Restore files from backup snapshot."""
|
|
116
|
+
for path, content in backups.items():
|
|
117
|
+
abs_path = self._root / path
|
|
118
|
+
if content is None:
|
|
119
|
+
# File did not exist before: remove it if it was created
|
|
120
|
+
if abs_path.exists():
|
|
121
|
+
abs_path.unlink()
|
|
122
|
+
else:
|
|
123
|
+
abs_path.parent.mkdir(parents=True, exist_ok=True)
|
|
124
|
+
abs_path.write_text(content, encoding="utf-8")
|
|
125
|
+
|
|
126
|
+
def _write(self, edit: FileEdit) -> None:
|
|
127
|
+
"""Write a single FileEdit atomically (write to temp, then rename)."""
|
|
128
|
+
abs_path = self._root / edit.path
|
|
129
|
+
abs_path.parent.mkdir(parents=True, exist_ok=True)
|
|
130
|
+
|
|
131
|
+
fd, tmp_str = tempfile.mkstemp(dir=abs_path.parent, prefix=".gdm_tmp_")
|
|
132
|
+
try:
|
|
133
|
+
with os.fdopen(fd, "w", encoding="utf-8") as fh:
|
|
134
|
+
fh.write(edit.content)
|
|
135
|
+
os.replace(tmp_str, str(abs_path))
|
|
136
|
+
except Exception:
|
|
137
|
+
try:
|
|
138
|
+
os.unlink(tmp_str)
|
|
139
|
+
except OSError:
|
|
140
|
+
pass
|
|
141
|
+
raise
|
|
@@ -0,0 +1,251 @@
|
|
|
1
|
+
"""Regression guard — capture test baselines before edits, verify after.
|
|
2
|
+
|
|
3
|
+
Protocol:
|
|
4
|
+
BEFORE editing file X:
|
|
5
|
+
1. Locate test files that import X
|
|
6
|
+
2. Run those tests and record pass/fail baseline
|
|
7
|
+
AFTER editing file X:
|
|
8
|
+
1. Re-run the same tests
|
|
9
|
+
2. Compare against baseline — report any newly failing tests
|
|
10
|
+
On 2 consecutive failures: recommend git reset to last checkpoint.
|
|
11
|
+
"""
|
|
12
|
+
from __future__ import annotations
|
|
13
|
+
|
|
14
|
+
import logging
|
|
15
|
+
import random
|
|
16
|
+
import re
|
|
17
|
+
import subprocess
|
|
18
|
+
from dataclasses import dataclass, field
|
|
19
|
+
from pathlib import Path
|
|
20
|
+
from typing import Any
|
|
21
|
+
|
|
22
|
+
__all__ = ["RegressionGuard", "RegressionResult"]
|
|
23
|
+
|
|
24
|
+
log = logging.getLogger(__name__)
|
|
25
|
+
|
|
26
|
+
_TEST_RUN_TIMEOUT: int = 120
|
|
27
|
+
_MAX_SCOPED_TESTS: int = 50 # cap for performance scoping
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
# ---------------------------------------------------------------------------
|
|
31
|
+
# Data classes
|
|
32
|
+
# ---------------------------------------------------------------------------
|
|
33
|
+
|
|
34
|
+
@dataclass
|
|
35
|
+
class TestRun:
|
|
36
|
+
"""Result of a single test run."""
|
|
37
|
+
|
|
38
|
+
passed: bool
|
|
39
|
+
failed_tests: list[str] = field(default_factory=list)
|
|
40
|
+
output: str = ""
|
|
41
|
+
exit_code: int = 0
|
|
42
|
+
coverage_pct: float | None = None
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
@dataclass
|
|
46
|
+
class RegressionResult:
|
|
47
|
+
"""Outcome of a before/after regression comparison."""
|
|
48
|
+
|
|
49
|
+
file_edited: Path
|
|
50
|
+
baseline: TestRun
|
|
51
|
+
after_edit: TestRun
|
|
52
|
+
regressions: list[str] # test names that were passing and now fail
|
|
53
|
+
new_passes: list[str] # test names that were failing and now pass
|
|
54
|
+
rollback_recommended: bool
|
|
55
|
+
coverage_drop: float | None = None # pp drop in coverage, if measurable
|
|
56
|
+
|
|
57
|
+
@property
|
|
58
|
+
def new_failures(self) -> list[str]:
|
|
59
|
+
"""Alias for regressions — tests that newly fail after the edit."""
|
|
60
|
+
return self.regressions
|
|
61
|
+
|
|
62
|
+
@property
|
|
63
|
+
def has_regression(self) -> bool:
|
|
64
|
+
return bool(self.regressions)
|
|
65
|
+
|
|
66
|
+
def summary(self) -> str:
|
|
67
|
+
"""Human-readable summary for the agent."""
|
|
68
|
+
if not self.has_regression:
|
|
69
|
+
return f"✓ No regressions detected in {self.file_edited.name}"
|
|
70
|
+
lines = [f"⚠ Regressions in {self.file_edited.name}:"]
|
|
71
|
+
for t in self.regressions:
|
|
72
|
+
lines.append(f" ✗ {t}")
|
|
73
|
+
if self.rollback_recommended:
|
|
74
|
+
lines.append(" → Rollback recommended (2+ consecutive failures)")
|
|
75
|
+
return "\n".join(lines)
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
# ---------------------------------------------------------------------------
|
|
79
|
+
# RegressionGuard
|
|
80
|
+
# ---------------------------------------------------------------------------
|
|
81
|
+
|
|
82
|
+
class RegressionGuard:
|
|
83
|
+
"""Captures test baselines and detects regressions caused by file edits.
|
|
84
|
+
|
|
85
|
+
Usage::
|
|
86
|
+
|
|
87
|
+
guard = RegressionGuard(project_root)
|
|
88
|
+
guard.capture_baseline(Path("src/auth.py"))
|
|
89
|
+
# ... make edits ...
|
|
90
|
+
result = guard.verify_after_edit(Path("src/auth.py"))
|
|
91
|
+
if result.has_regression:
|
|
92
|
+
console.print(result.summary())
|
|
93
|
+
"""
|
|
94
|
+
|
|
95
|
+
def __init__(self, project_root: Path) -> None:
|
|
96
|
+
self._root = project_root
|
|
97
|
+
self._baselines: dict[Path, TestRun] = {}
|
|
98
|
+
self._fail_streak: dict[Path, int] = {}
|
|
99
|
+
|
|
100
|
+
# ------------------------------------------------------------------
|
|
101
|
+
# Public API
|
|
102
|
+
# ------------------------------------------------------------------
|
|
103
|
+
|
|
104
|
+
def capture_baseline(self, file_path: Path) -> TestRun:
|
|
105
|
+
"""Run tests for *file_path* and store baseline. Returns the run result."""
|
|
106
|
+
test_files = self._find_test_files(file_path)
|
|
107
|
+
if not test_files:
|
|
108
|
+
log.debug("No test files found for %s — skipping baseline", file_path)
|
|
109
|
+
baseline = TestRun(passed=True, output="(no tests found)")
|
|
110
|
+
else:
|
|
111
|
+
baseline = self._run_tests(test_files)
|
|
112
|
+
self._baselines[file_path] = baseline
|
|
113
|
+
return baseline
|
|
114
|
+
|
|
115
|
+
def verify_after_edit(self, file_path: Path) -> RegressionResult:
|
|
116
|
+
"""Re-run tests and compare against stored baseline."""
|
|
117
|
+
baseline = self._baselines.get(file_path, TestRun(passed=True))
|
|
118
|
+
test_files = self._find_test_files(file_path)
|
|
119
|
+
|
|
120
|
+
if not test_files:
|
|
121
|
+
return RegressionResult(
|
|
122
|
+
file_edited=file_path,
|
|
123
|
+
baseline=baseline,
|
|
124
|
+
after_edit=TestRun(passed=True, output="(no tests found)"),
|
|
125
|
+
regressions=[],
|
|
126
|
+
new_passes=[],
|
|
127
|
+
rollback_recommended=False,
|
|
128
|
+
)
|
|
129
|
+
|
|
130
|
+
after = self._run_tests(test_files)
|
|
131
|
+
regressions = _compute_regressions(baseline, after)
|
|
132
|
+
new_passes = _compute_new_passes(baseline, after)
|
|
133
|
+
|
|
134
|
+
if regressions:
|
|
135
|
+
self._fail_streak[file_path] = self._fail_streak.get(file_path, 0) + 1
|
|
136
|
+
else:
|
|
137
|
+
self._fail_streak[file_path] = 0
|
|
138
|
+
|
|
139
|
+
rollback = self._fail_streak.get(file_path, 0) >= 2
|
|
140
|
+
|
|
141
|
+
coverage_drop: float | None = None
|
|
142
|
+
if baseline.coverage_pct is not None and after.coverage_pct is not None:
|
|
143
|
+
drop = baseline.coverage_pct - after.coverage_pct
|
|
144
|
+
if drop > 5.0:
|
|
145
|
+
coverage_drop = drop
|
|
146
|
+
log.warning(
|
|
147
|
+
"Coverage drop of %.1fpp detected for %s (%.1f%% → %.1f%%)",
|
|
148
|
+
drop, file_path, baseline.coverage_pct, after.coverage_pct,
|
|
149
|
+
)
|
|
150
|
+
|
|
151
|
+
return RegressionResult(
|
|
152
|
+
file_edited=file_path,
|
|
153
|
+
baseline=baseline,
|
|
154
|
+
after_edit=after,
|
|
155
|
+
regressions=regressions,
|
|
156
|
+
new_passes=new_passes,
|
|
157
|
+
rollback_recommended=rollback,
|
|
158
|
+
coverage_drop=coverage_drop,
|
|
159
|
+
)
|
|
160
|
+
|
|
161
|
+
def reset_streak(self, file_path: Path) -> None:
|
|
162
|
+
"""Reset consecutive failure count for a file (e.g., after rollback)."""
|
|
163
|
+
self._fail_streak.pop(file_path, None)
|
|
164
|
+
|
|
165
|
+
# ------------------------------------------------------------------
|
|
166
|
+
# Private helpers
|
|
167
|
+
# ------------------------------------------------------------------
|
|
168
|
+
|
|
169
|
+
def _find_test_files(self, file_path: Path) -> list[Path]:
|
|
170
|
+
"""Find test files that likely test the given source file.
|
|
171
|
+
|
|
172
|
+
Performance scoping: skip baseline if 0 tests found; sample randomly
|
|
173
|
+
(max _MAX_SCOPED_TESTS) if too many are found.
|
|
174
|
+
"""
|
|
175
|
+
stem = file_path.stem
|
|
176
|
+
candidates: list[Path] = []
|
|
177
|
+
|
|
178
|
+
for pattern in (f"test_{stem}.py", f"{stem}_test.py", f"test_{stem}.ts"):
|
|
179
|
+
found = list(self._root.rglob(pattern))
|
|
180
|
+
candidates.extend(found)
|
|
181
|
+
|
|
182
|
+
if not candidates:
|
|
183
|
+
candidates = [
|
|
184
|
+
Path(p) for p in self._grep_import_refs(stem, self._root)
|
|
185
|
+
]
|
|
186
|
+
|
|
187
|
+
if not candidates:
|
|
188
|
+
return []
|
|
189
|
+
|
|
190
|
+
if len(candidates) > _MAX_SCOPED_TESTS:
|
|
191
|
+
candidates = random.sample(candidates, _MAX_SCOPED_TESTS)
|
|
192
|
+
|
|
193
|
+
return candidates[:_MAX_SCOPED_TESTS]
|
|
194
|
+
|
|
195
|
+
def _grep_import_refs(self, module_name: str, root: Path) -> list[str]:
|
|
196
|
+
"""Find test files that reference the given module name (Windows-safe)."""
|
|
197
|
+
results: list[str] = []
|
|
198
|
+
for py_file in root.rglob("test_*.py"):
|
|
199
|
+
try:
|
|
200
|
+
content = py_file.read_text(encoding="utf-8", errors="ignore")
|
|
201
|
+
if module_name in content:
|
|
202
|
+
results.append(str(py_file))
|
|
203
|
+
except OSError:
|
|
204
|
+
pass
|
|
205
|
+
return results
|
|
206
|
+
|
|
207
|
+
def _run_tests(self, test_files: list[Path]) -> TestRun:
|
|
208
|
+
"""Run pytest on specific test files. Returns TestRun."""
|
|
209
|
+
args = ["python", "-m", "pytest", "--tb=line", "-q",
|
|
210
|
+
*[str(f) for f in test_files]]
|
|
211
|
+
try:
|
|
212
|
+
proc = subprocess.run(
|
|
213
|
+
args, capture_output=True, text=True,
|
|
214
|
+
cwd=self._root, timeout=_TEST_RUN_TIMEOUT,
|
|
215
|
+
)
|
|
216
|
+
combined = proc.stdout + proc.stderr
|
|
217
|
+
failed = _extract_failed_tests(combined)
|
|
218
|
+
return TestRun(
|
|
219
|
+
passed=proc.returncode == 0,
|
|
220
|
+
failed_tests=failed,
|
|
221
|
+
output=combined[:3_000],
|
|
222
|
+
exit_code=proc.returncode,
|
|
223
|
+
)
|
|
224
|
+
except subprocess.TimeoutExpired:
|
|
225
|
+
return TestRun(passed=False, output="Tests timed out", exit_code=1)
|
|
226
|
+
except OSError as exc:
|
|
227
|
+
return TestRun(passed=False, output=str(exc), exit_code=1)
|
|
228
|
+
|
|
229
|
+
|
|
230
|
+
# ---------------------------------------------------------------------------
|
|
231
|
+
# Pure helpers
|
|
232
|
+
# ---------------------------------------------------------------------------
|
|
233
|
+
|
|
234
|
+
def _extract_failed_tests(output: str) -> list[str]:
|
|
235
|
+
"""Parse pytest --tb=line output to extract failed test names."""
|
|
236
|
+
pattern = re.compile(r"FAILED (.+?) -")
|
|
237
|
+
return [m.group(1).strip() for m in pattern.finditer(output)]
|
|
238
|
+
|
|
239
|
+
|
|
240
|
+
def _compute_regressions(before: TestRun, after: TestRun) -> list[str]:
|
|
241
|
+
"""Tests that were passing before and are failing after."""
|
|
242
|
+
before_failed = set(before.failed_tests)
|
|
243
|
+
after_failed = set(after.failed_tests)
|
|
244
|
+
return sorted(after_failed - before_failed)
|
|
245
|
+
|
|
246
|
+
|
|
247
|
+
def _compute_new_passes(before: TestRun, after: TestRun) -> list[str]:
|
|
248
|
+
"""Tests that were failing before and are passing after."""
|
|
249
|
+
before_failed = set(before.failed_tests)
|
|
250
|
+
after_failed = set(after.failed_tests)
|
|
251
|
+
return sorted(before_failed - after_failed)
|