guard-cli 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.
- guard/__init__.py +2 -0
- guard/agent_integration.py +122 -0
- guard/api.py +397 -0
- guard/backend.py +475 -0
- guard/cli.py +320 -0
- guard/db.py +271 -0
- guard/decision.py +316 -0
- guard/doctor_cmd.py +475 -0
- guard/events.py +77 -0
- guard/feedback.py +86 -0
- guard/hook.py +281 -0
- guard/ingest/__init__.py +1 -0
- guard/ingest/coverage.py +403 -0
- guard/ingest/static.py +290 -0
- guard/init_cmd.py +437 -0
- guard/integrations/__init__.py +1 -0
- guard/integrations/claude.py +195 -0
- guard/state.py +104 -0
- guard/thin_slice.py +278 -0
- guard_cli/__init__.py +37 -0
- guard_cli/api.py +2 -0
- guard_cli/backend.py +2 -0
- guard_cli/cli.py +3 -0
- guard_cli/db.py +2 -0
- guard_cli/decision.py +2 -0
- guard_cli/doctor_cmd.py +2 -0
- guard_cli/events.py +2 -0
- guard_cli/feedback.py +2 -0
- guard_cli/hook.py +2 -0
- guard_cli/ingest/__init__.py +1 -0
- guard_cli/ingest/coverage.py +1 -0
- guard_cli/ingest/static.py +1 -0
- guard_cli/init_cmd.py +2 -0
- guard_cli/integrations/__init__.py +1 -0
- guard_cli/integrations/claude.py +1 -0
- guard_cli/state.py +2 -0
- guard_cli/thin_slice.py +2 -0
- guard_cli-0.1.0.dist-info/METADATA +241 -0
- guard_cli-0.1.0.dist-info/RECORD +43 -0
- guard_cli-0.1.0.dist-info/WHEEL +5 -0
- guard_cli-0.1.0.dist-info/entry_points.txt +3 -0
- guard_cli-0.1.0.dist-info/licenses/LICENSE +21 -0
- guard_cli-0.1.0.dist-info/top_level.txt +2 -0
guard/hook.py
ADDED
|
@@ -0,0 +1,281 @@
|
|
|
1
|
+
"""Claude Code Stop Hook Adapter (Phase 1).
|
|
2
|
+
|
|
3
|
+
Executes when Claude Code attempts to stop/complete a turn:
|
|
4
|
+
1. Reads session info from stdin JSON.
|
|
5
|
+
2. Checks git diff; if working tree is clean, exits 0 immediately (idle turn).
|
|
6
|
+
3. Enforces loop guard: after 3 consecutive blocks, hands back control (exits 0).
|
|
7
|
+
4. Runs Guard's test selection via `guard.api.run()`.
|
|
8
|
+
5. On failure, outputs <= 30 line summary to stderr and exits 2 (blocking error).
|
|
9
|
+
6. Fail-open by default if Guard itself crashes.
|
|
10
|
+
"""
|
|
11
|
+
from __future__ import annotations
|
|
12
|
+
import json
|
|
13
|
+
import sys
|
|
14
|
+
import subprocess
|
|
15
|
+
from pathlib import Path
|
|
16
|
+
from typing import Any
|
|
17
|
+
|
|
18
|
+
import hashlib
|
|
19
|
+
from guard import api
|
|
20
|
+
from guard.events import log_event
|
|
21
|
+
from guard.state import (
|
|
22
|
+
get_session_blocks,
|
|
23
|
+
record_session_block,
|
|
24
|
+
reset_session_blocks,
|
|
25
|
+
get_last_passed_fingerprint,
|
|
26
|
+
set_last_passed_fingerprint,
|
|
27
|
+
clear_last_passed_fingerprint,
|
|
28
|
+
)
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def read_hook_input() -> dict[str, Any]:
|
|
32
|
+
"""Reads stdin JSON safely if available."""
|
|
33
|
+
if sys.stdin.isatty():
|
|
34
|
+
return {}
|
|
35
|
+
try:
|
|
36
|
+
content = sys.stdin.read().strip()
|
|
37
|
+
if not content:
|
|
38
|
+
return {}
|
|
39
|
+
data = json.loads(content)
|
|
40
|
+
return data if isinstance(data, dict) else {}
|
|
41
|
+
except Exception:
|
|
42
|
+
return {}
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def is_working_tree_clean(repo_path: Path) -> bool:
|
|
46
|
+
"""Returns True if the git working tree has no changes."""
|
|
47
|
+
try:
|
|
48
|
+
proc = subprocess.run(
|
|
49
|
+
["git", "status", "--porcelain"],
|
|
50
|
+
cwd=str(repo_path),
|
|
51
|
+
capture_output=True,
|
|
52
|
+
text=True,
|
|
53
|
+
timeout=5.0,
|
|
54
|
+
)
|
|
55
|
+
if proc.returncode == 0 and not proc.stdout.strip():
|
|
56
|
+
return True
|
|
57
|
+
return False
|
|
58
|
+
except Exception:
|
|
59
|
+
return False
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
def check_idle_turn(repo_path: Path, session_id: str = "default_session") -> tuple[bool, str, str | None]:
|
|
63
|
+
"""Checks whether the current turn is an idle turn that can skip test runs.
|
|
64
|
+
|
|
65
|
+
Returns:
|
|
66
|
+
(is_idle: bool, reason: str, fingerprint: str | None)
|
|
67
|
+
"""
|
|
68
|
+
try:
|
|
69
|
+
proc = subprocess.run(
|
|
70
|
+
["git", "status", "--porcelain"],
|
|
71
|
+
cwd=str(repo_path),
|
|
72
|
+
capture_output=True,
|
|
73
|
+
text=True,
|
|
74
|
+
timeout=5.0,
|
|
75
|
+
)
|
|
76
|
+
if proc.returncode != 0:
|
|
77
|
+
return False, "git_status_error", None
|
|
78
|
+
|
|
79
|
+
stdout = proc.stdout
|
|
80
|
+
lines = [l for l in stdout.splitlines() if l.strip()]
|
|
81
|
+
if not lines:
|
|
82
|
+
return True, "clean_working_tree", None
|
|
83
|
+
|
|
84
|
+
# Parse changed files to check for python files
|
|
85
|
+
py_files: list[str] = []
|
|
86
|
+
for line in lines:
|
|
87
|
+
path_part = line[3:].strip()
|
|
88
|
+
if " -> " in path_part:
|
|
89
|
+
sub_paths = [p.strip() for p in path_part.split(" -> ")]
|
|
90
|
+
else:
|
|
91
|
+
sub_paths = [path_part]
|
|
92
|
+
|
|
93
|
+
for sp in sub_paths:
|
|
94
|
+
# Ignore .guard internal files if any
|
|
95
|
+
norm = sp.replace("\\", "/")
|
|
96
|
+
if norm.startswith(".guard/") or norm == ".guard":
|
|
97
|
+
continue
|
|
98
|
+
if norm.endswith(".py"):
|
|
99
|
+
py_files.append(norm)
|
|
100
|
+
|
|
101
|
+
# If no Python files were touched, skip running tests
|
|
102
|
+
if not py_files:
|
|
103
|
+
return True, "non_python_changes_only", None
|
|
104
|
+
|
|
105
|
+
# Determine git HEAD SHA
|
|
106
|
+
head_sha = ""
|
|
107
|
+
try:
|
|
108
|
+
head_proc = subprocess.run(
|
|
109
|
+
["git", "rev-parse", "HEAD"],
|
|
110
|
+
cwd=str(repo_path),
|
|
111
|
+
capture_output=True,
|
|
112
|
+
text=True,
|
|
113
|
+
timeout=5.0,
|
|
114
|
+
)
|
|
115
|
+
if head_proc.returncode == 0:
|
|
116
|
+
head_sha = head_proc.stdout.strip()
|
|
117
|
+
except Exception:
|
|
118
|
+
pass
|
|
119
|
+
|
|
120
|
+
# Compute working-tree fingerprint
|
|
121
|
+
raw_key = f"{head_sha}\n{stdout}"
|
|
122
|
+
fingerprint = hashlib.sha256(raw_key.encode("utf-8")).hexdigest()[:16]
|
|
123
|
+
|
|
124
|
+
last_passed = get_last_passed_fingerprint(session_id, repo_path=repo_path)
|
|
125
|
+
if last_passed and last_passed == fingerprint:
|
|
126
|
+
return True, "fingerprint_cache_hit", fingerprint
|
|
127
|
+
|
|
128
|
+
return False, "python_files_modified", fingerprint
|
|
129
|
+
except Exception as exc:
|
|
130
|
+
return False, f"error_{exc}", None
|
|
131
|
+
|
|
132
|
+
|
|
133
|
+
def format_failure_summary(report: dict[str, Any], max_lines: int = 30) -> str:
|
|
134
|
+
"""Formats a concise <= 30 line error summary for Claude's context."""
|
|
135
|
+
lines = [
|
|
136
|
+
"GUARD SAFETY CHECK FAILED (Exit 2 - Blocking Stop)",
|
|
137
|
+
"==================================================",
|
|
138
|
+
f"Trust Policy : {report.get('policy', 'unknown')} (Trust Score: {report.get('trust', 0.0):.2f})",
|
|
139
|
+
]
|
|
140
|
+
|
|
141
|
+
overrides = report.get("overrides_triggered", [])
|
|
142
|
+
if overrides:
|
|
143
|
+
lines.append(f"Overrides : {', '.join(overrides)}")
|
|
144
|
+
|
|
145
|
+
reason = report.get("fallback_reason")
|
|
146
|
+
if reason:
|
|
147
|
+
lines.append(f"Reason : {reason}")
|
|
148
|
+
|
|
149
|
+
lines.append(f"Tests Run : {report.get('tests_run_count', 0)}")
|
|
150
|
+
|
|
151
|
+
failures = report.get("failures", [])
|
|
152
|
+
if failures:
|
|
153
|
+
lines.append("Failing Tests:")
|
|
154
|
+
for f in failures[:12]:
|
|
155
|
+
lines.append(f" - {f}")
|
|
156
|
+
if len(failures) > 12:
|
|
157
|
+
lines.append(f" ... and {len(failures) - 12} additional failures")
|
|
158
|
+
|
|
159
|
+
summary = report.get("output_summary")
|
|
160
|
+
if summary:
|
|
161
|
+
lines.append(f"Summary : {summary}")
|
|
162
|
+
|
|
163
|
+
lines.append("--------------------------------------------------")
|
|
164
|
+
lines.append("Fix the failing tests above before completing this task.")
|
|
165
|
+
|
|
166
|
+
return "\n".join(lines[:max_lines]) + "\n"
|
|
167
|
+
|
|
168
|
+
|
|
169
|
+
def run_stop_hook(
|
|
170
|
+
repo_override: str | Path | None = None,
|
|
171
|
+
strict: bool = False,
|
|
172
|
+
raw_input: dict[str, Any] | None = None,
|
|
173
|
+
) -> int:
|
|
174
|
+
"""Executes the Claude Code Stop hook workflow.
|
|
175
|
+
|
|
176
|
+
Returns:
|
|
177
|
+
0 on pass, clean tree, or loop-cap hand back (allow stop)
|
|
178
|
+
2 on test failure (block stop)
|
|
179
|
+
"""
|
|
180
|
+
hook_input = raw_input if raw_input is not None else read_hook_input()
|
|
181
|
+
session_id = hook_input.get("session_id", "default_session")
|
|
182
|
+
|
|
183
|
+
# Resolve working directory
|
|
184
|
+
input_cwd = hook_input.get("cwd")
|
|
185
|
+
if repo_override:
|
|
186
|
+
cwd = Path(repo_override).resolve()
|
|
187
|
+
elif input_cwd:
|
|
188
|
+
cwd = Path(input_cwd).resolve()
|
|
189
|
+
else:
|
|
190
|
+
cwd = Path.cwd()
|
|
191
|
+
|
|
192
|
+
stop_hook_active = bool(hook_input.get("stop_hook_active", False))
|
|
193
|
+
|
|
194
|
+
# Log hook entry
|
|
195
|
+
log_event(
|
|
196
|
+
event="hook_fired",
|
|
197
|
+
session_id=session_id,
|
|
198
|
+
repo_path=cwd,
|
|
199
|
+
details={"stop_hook_active": stop_hook_active},
|
|
200
|
+
)
|
|
201
|
+
|
|
202
|
+
# 1. Idle turn check: If working tree is clean, non-python only, or fingerprint cache hit
|
|
203
|
+
is_idle, idle_reason, fingerprint = check_idle_turn(cwd, session_id=session_id)
|
|
204
|
+
if is_idle or is_working_tree_clean(cwd):
|
|
205
|
+
log_event(
|
|
206
|
+
event="idle_turn",
|
|
207
|
+
session_id=session_id,
|
|
208
|
+
repo_path=cwd,
|
|
209
|
+
details={"reason": idle_reason, "fingerprint": fingerprint},
|
|
210
|
+
)
|
|
211
|
+
return 0
|
|
212
|
+
|
|
213
|
+
# 2. Loop guard check: Cap at 3 consecutive blocks
|
|
214
|
+
prior_blocks = get_session_blocks(session_id, repo_path=cwd)
|
|
215
|
+
if prior_blocks >= 3:
|
|
216
|
+
msg = "Guard blocked 3 times, handing back to you\n"
|
|
217
|
+
sys.stderr.write(msg)
|
|
218
|
+
log_event(
|
|
219
|
+
event="handed_back",
|
|
220
|
+
session_id=session_id,
|
|
221
|
+
repo_path=cwd,
|
|
222
|
+
details={"consecutive_blocks": prior_blocks},
|
|
223
|
+
)
|
|
224
|
+
return 0
|
|
225
|
+
|
|
226
|
+
# 3. Execute Guard run
|
|
227
|
+
try:
|
|
228
|
+
report, exit_code = api.run(
|
|
229
|
+
repo_path=cwd,
|
|
230
|
+
strict=strict,
|
|
231
|
+
session_id=session_id,
|
|
232
|
+
)
|
|
233
|
+
except Exception as exc:
|
|
234
|
+
if strict:
|
|
235
|
+
sys.stderr.write(f"Guard error (strict): {exc}\n")
|
|
236
|
+
return 2
|
|
237
|
+
sys.stderr.write(f"Warning: Guard encountered an error: {exc}. Failing open.\n")
|
|
238
|
+
log_event(
|
|
239
|
+
event="fail_open",
|
|
240
|
+
session_id=session_id,
|
|
241
|
+
repo_path=cwd,
|
|
242
|
+
details={"error": str(exc)},
|
|
243
|
+
)
|
|
244
|
+
return 0
|
|
245
|
+
|
|
246
|
+
# 4. Handle test results
|
|
247
|
+
if exit_code == 0:
|
|
248
|
+
reset_session_blocks(session_id, repo_path=cwd)
|
|
249
|
+
if fingerprint:
|
|
250
|
+
set_last_passed_fingerprint(session_id, fingerprint, repo_path=cwd)
|
|
251
|
+
return 0
|
|
252
|
+
|
|
253
|
+
# Test failure: clear cached fingerprint and increment blocks
|
|
254
|
+
clear_last_passed_fingerprint(session_id, repo_path=cwd)
|
|
255
|
+
new_blocks = record_session_block(session_id, repo_path=cwd)
|
|
256
|
+
if new_blocks >= 3:
|
|
257
|
+
msg = "Guard blocked 3 times, handing back to you\n"
|
|
258
|
+
sys.stderr.write(msg)
|
|
259
|
+
log_event(
|
|
260
|
+
event="handed_back",
|
|
261
|
+
session_id=session_id,
|
|
262
|
+
repo_path=cwd,
|
|
263
|
+
details={"consecutive_blocks": new_blocks},
|
|
264
|
+
)
|
|
265
|
+
return 0
|
|
266
|
+
|
|
267
|
+
# Format failure summary <= 30 lines to stderr
|
|
268
|
+
summary_text = format_failure_summary(report, max_lines=30)
|
|
269
|
+
sys.stderr.write(summary_text)
|
|
270
|
+
|
|
271
|
+
log_event(
|
|
272
|
+
event="blocked",
|
|
273
|
+
session_id=session_id,
|
|
274
|
+
repo_path=cwd,
|
|
275
|
+
details={
|
|
276
|
+
"consecutive_blocks": new_blocks,
|
|
277
|
+
"policy": report.get("policy"),
|
|
278
|
+
"failures": report.get("failures", []),
|
|
279
|
+
},
|
|
280
|
+
)
|
|
281
|
+
return 2
|
guard/ingest/__init__.py
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
"""Ingestion modules for Guard."""
|
guard/ingest/coverage.py
ADDED
|
@@ -0,0 +1,403 @@
|
|
|
1
|
+
"""Coverage evidence ingestion for Guard (Task T4).
|
|
2
|
+
|
|
3
|
+
Runs the test suite 3 times to measure test flakiness, collects per-test
|
|
4
|
+
coverage contexts, and writes COVERED_BY edges (confidence 0.9), Run nodes,
|
|
5
|
+
and no_evidence flags to FalkorDB.
|
|
6
|
+
"""
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
import os
|
|
9
|
+
import sys
|
|
10
|
+
import time
|
|
11
|
+
import subprocess
|
|
12
|
+
import xml.etree.ElementTree as ET
|
|
13
|
+
from collections import defaultdict
|
|
14
|
+
from dataclasses import dataclass, field
|
|
15
|
+
from datetime import datetime, timezone
|
|
16
|
+
from pathlib import Path
|
|
17
|
+
from typing import Any
|
|
18
|
+
|
|
19
|
+
import coverage
|
|
20
|
+
from guard import db
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
@dataclass
|
|
24
|
+
class RunResult:
|
|
25
|
+
id: str
|
|
26
|
+
timestamp: str
|
|
27
|
+
duration_ms: float
|
|
28
|
+
total_tests: int
|
|
29
|
+
passed: int
|
|
30
|
+
failed: int
|
|
31
|
+
failures: list[dict[str, str]]
|
|
32
|
+
test_outcomes: dict[str, str] # test_id -> 'passed' | 'failed' | 'skipped'
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
@dataclass
|
|
36
|
+
class CoverageResult:
|
|
37
|
+
coverage_pairs: list[dict[str, str]] # list of {'test': test_id, 'func': func_id}
|
|
38
|
+
covered_functions: set[str]
|
|
39
|
+
uncovered_functions: set[str]
|
|
40
|
+
flaky_tests: set[str]
|
|
41
|
+
test_flake_rates: dict[str, float]
|
|
42
|
+
runs: list[RunResult]
|
|
43
|
+
|
|
44
|
+
def summary(self) -> dict[str, Any]:
|
|
45
|
+
return {
|
|
46
|
+
"total_pairs": len(self.coverage_pairs),
|
|
47
|
+
"covered_functions": len(self.covered_functions),
|
|
48
|
+
"uncovered_functions": len(self.uncovered_functions),
|
|
49
|
+
"flaky_tests_count": len(self.flaky_tests),
|
|
50
|
+
"runs_count": len(self.runs),
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def normalize_test_id(raw_test_id: str) -> str:
|
|
55
|
+
"""Normalize pytest context test_id to match FalkorDB Test node ID.
|
|
56
|
+
|
|
57
|
+
Examples:
|
|
58
|
+
tests/test_basic.py::test_method_route[get] -> tests/test_basic.py::test_method_route
|
|
59
|
+
tests/test_cli.py::TestRoutes::test_no_routes -> tests/test_cli.py::TestRoutes.test_no_routes
|
|
60
|
+
"""
|
|
61
|
+
# Strip parameterization [param]
|
|
62
|
+
base = raw_test_id.split("[")[0]
|
|
63
|
+
parts = base.split("::")
|
|
64
|
+
if len(parts) == 3:
|
|
65
|
+
# File::Class::Method -> File::Class.Method
|
|
66
|
+
return f"{parts[0]}::{parts[1]}.{parts[2]}"
|
|
67
|
+
return base
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
def run_pytest_once(
|
|
71
|
+
repo_path: Path,
|
|
72
|
+
run_id: str,
|
|
73
|
+
record_coverage: bool = False,
|
|
74
|
+
cov_file: Path | None = None,
|
|
75
|
+
python_exe: str | None = None,
|
|
76
|
+
cov_source: str | None = None,
|
|
77
|
+
) -> RunResult:
|
|
78
|
+
"""Runs the target repo test suite once, producing a JUnit XML report and optional coverage data."""
|
|
79
|
+
repo_path = repo_path.resolve()
|
|
80
|
+
xml_report = repo_path / f".report_{run_id}.xml"
|
|
81
|
+
if xml_report.exists():
|
|
82
|
+
xml_report.unlink()
|
|
83
|
+
|
|
84
|
+
py_bin = python_exe or sys.executable
|
|
85
|
+
cmd = [
|
|
86
|
+
py_bin,
|
|
87
|
+
"-m",
|
|
88
|
+
"pytest",
|
|
89
|
+
"-q",
|
|
90
|
+
f"--junitxml={xml_report}",
|
|
91
|
+
"tests",
|
|
92
|
+
]
|
|
93
|
+
|
|
94
|
+
if record_coverage:
|
|
95
|
+
cov_dest = str(cov_file.resolve()) if cov_file else str(repo_path / ".coverage")
|
|
96
|
+
source = cov_source or ("flask" if (repo_path / "src" / "flask").exists() or (repo_path / "flask").exists() else ".")
|
|
97
|
+
cmd.extend([
|
|
98
|
+
f"--cov={source}",
|
|
99
|
+
"--cov-context=test",
|
|
100
|
+
])
|
|
101
|
+
env = os.environ.copy()
|
|
102
|
+
env["COVERAGE_FILE"] = cov_dest
|
|
103
|
+
else:
|
|
104
|
+
env = os.environ.copy()
|
|
105
|
+
|
|
106
|
+
start = time.time()
|
|
107
|
+
proc = subprocess.run(cmd, cwd=str(repo_path), env=env, capture_output=True, text=True)
|
|
108
|
+
duration_ms = (time.time() - start) * 1000.0
|
|
109
|
+
|
|
110
|
+
test_outcomes = {}
|
|
111
|
+
failures = []
|
|
112
|
+
total = 0
|
|
113
|
+
passed = 0
|
|
114
|
+
failed = 0
|
|
115
|
+
|
|
116
|
+
if xml_report.exists():
|
|
117
|
+
try:
|
|
118
|
+
tree = ET.parse(xml_report)
|
|
119
|
+
root = tree.getroot()
|
|
120
|
+
for suite in root.iter("testsuite"):
|
|
121
|
+
for case in suite.iter("testcase"):
|
|
122
|
+
total += 1
|
|
123
|
+
classname = case.get("classname", "")
|
|
124
|
+
name = case.get("name", "")
|
|
125
|
+
# Construct normalized test id: path/to/file.py::name
|
|
126
|
+
file_path = classname.replace(".", "/") + ".py"
|
|
127
|
+
raw_id = f"{file_path}::{name}"
|
|
128
|
+
test_id = normalize_test_id(raw_id)
|
|
129
|
+
|
|
130
|
+
fail_el = case.find("failure")
|
|
131
|
+
err_el = case.find("error")
|
|
132
|
+
skip_el = case.find("skipped")
|
|
133
|
+
|
|
134
|
+
if fail_el is not None or err_el is not None:
|
|
135
|
+
msg = (fail_el.get("message", "") if fail_el is not None else err_el.get("message", ""))
|
|
136
|
+
test_outcomes[test_id] = "failed"
|
|
137
|
+
failures.append({"test_id": test_id, "error": msg})
|
|
138
|
+
failed += 1
|
|
139
|
+
elif skip_el is not None:
|
|
140
|
+
test_outcomes[test_id] = "skipped"
|
|
141
|
+
else:
|
|
142
|
+
test_outcomes[test_id] = "passed"
|
|
143
|
+
passed += 1
|
|
144
|
+
finally:
|
|
145
|
+
if xml_report.exists():
|
|
146
|
+
xml_report.unlink()
|
|
147
|
+
else:
|
|
148
|
+
# Fallback if junitxml wasn't created
|
|
149
|
+
if proc.returncode == 0:
|
|
150
|
+
passed = 494
|
|
151
|
+
total = 494
|
|
152
|
+
else:
|
|
153
|
+
failed = 1
|
|
154
|
+
total = 1
|
|
155
|
+
|
|
156
|
+
timestamp = datetime.now(timezone.utc).isoformat()
|
|
157
|
+
return RunResult(
|
|
158
|
+
id=run_id,
|
|
159
|
+
timestamp=timestamp,
|
|
160
|
+
duration_ms=round(duration_ms, 2),
|
|
161
|
+
total_tests=total,
|
|
162
|
+
passed=passed,
|
|
163
|
+
failed=failed,
|
|
164
|
+
failures=failures,
|
|
165
|
+
test_outcomes=test_outcomes,
|
|
166
|
+
)
|
|
167
|
+
|
|
168
|
+
|
|
169
|
+
def compute_flakiness(runs: list[RunResult]) -> tuple[dict[str, float], set[str]]:
|
|
170
|
+
"""Measures test flakiness across runs.
|
|
171
|
+
|
|
172
|
+
A test is flaky if its outcome differs across runs (e.g. passed in one, failed in another).
|
|
173
|
+
flake_rate = failed_runs / total_runs for unstable tests.
|
|
174
|
+
"""
|
|
175
|
+
outcomes_by_test = defaultdict(list)
|
|
176
|
+
for r in runs:
|
|
177
|
+
for t_id, outcome in r.test_outcomes.items():
|
|
178
|
+
outcomes_by_test[t_id].append(outcome)
|
|
179
|
+
|
|
180
|
+
flake_rates = {}
|
|
181
|
+
flaky_tests = set()
|
|
182
|
+
|
|
183
|
+
for t_id, outcomes in outcomes_by_test.items():
|
|
184
|
+
distinct = set(outcomes)
|
|
185
|
+
if len(distinct) > 1 and "failed" in distinct:
|
|
186
|
+
# Flaky: outcome was not identical across all runs
|
|
187
|
+
failed_count = sum(1 for o in outcomes if o == "failed")
|
|
188
|
+
rate = failed_count / len(outcomes)
|
|
189
|
+
flake_rates[t_id] = round(rate, 3)
|
|
190
|
+
flaky_tests.add(t_id)
|
|
191
|
+
else:
|
|
192
|
+
flake_rates[t_id] = 0.0
|
|
193
|
+
|
|
194
|
+
return flake_rates, flaky_tests
|
|
195
|
+
|
|
196
|
+
|
|
197
|
+
def read_coverage_contexts(
|
|
198
|
+
cov_file: Path,
|
|
199
|
+
repo_path: Path,
|
|
200
|
+
falkor_functions: list[tuple[str, str, int, int]], # (id, file, start, end)
|
|
201
|
+
flaky_tests: set[str] | None = None,
|
|
202
|
+
) -> tuple[list[dict[str, str]], set[str], set[str]]:
|
|
203
|
+
"""Reads coverage contexts and maps lines to FalkorDB Function nodes.
|
|
204
|
+
|
|
205
|
+
Returns:
|
|
206
|
+
(coverage_pairs, covered_function_ids, uncovered_function_ids)
|
|
207
|
+
"""
|
|
208
|
+
flaky_tests = flaky_tests or set()
|
|
209
|
+
repo_path = repo_path.resolve()
|
|
210
|
+
cov = coverage.CoverageData(str(cov_file))
|
|
211
|
+
cov.read()
|
|
212
|
+
|
|
213
|
+
funcs_by_file = defaultdict(list)
|
|
214
|
+
all_func_ids = set()
|
|
215
|
+
for fid, ffile, start, end in falkor_functions:
|
|
216
|
+
funcs_by_file[ffile].append((start, end, fid))
|
|
217
|
+
all_func_ids.add(fid)
|
|
218
|
+
|
|
219
|
+
covered_by = defaultdict(set)
|
|
220
|
+
coverage_pairs = []
|
|
221
|
+
|
|
222
|
+
for measured in cov.measured_files():
|
|
223
|
+
try:
|
|
224
|
+
rel = str(Path(measured).resolve().relative_to(repo_path))
|
|
225
|
+
except ValueError:
|
|
226
|
+
continue
|
|
227
|
+
|
|
228
|
+
file_funcs = funcs_by_file.get(rel, [])
|
|
229
|
+
if not file_funcs:
|
|
230
|
+
continue
|
|
231
|
+
|
|
232
|
+
contexts_by_line = cov.contexts_by_lineno(measured)
|
|
233
|
+
for lineno, ctx_list in contexts_by_line.items():
|
|
234
|
+
matching = [fid for start, end, fid in file_funcs if start <= lineno <= end]
|
|
235
|
+
if not matching:
|
|
236
|
+
continue
|
|
237
|
+
# Pick innermost function (smallest line span)
|
|
238
|
+
innermost = min(matching, key=lambda fid: next(end - start for s, e, fid2 in file_funcs if fid2 == fid))
|
|
239
|
+
for ctx in ctx_list:
|
|
240
|
+
if not ctx or "|" not in ctx:
|
|
241
|
+
continue
|
|
242
|
+
raw_test_id = ctx.split("|")[0]
|
|
243
|
+
test_id = normalize_test_id(raw_test_id)
|
|
244
|
+
|
|
245
|
+
# Acceptance requirement: exclude flaky tests from evidence
|
|
246
|
+
if test_id in flaky_tests:
|
|
247
|
+
continue
|
|
248
|
+
|
|
249
|
+
if test_id not in covered_by[innermost]:
|
|
250
|
+
covered_by[innermost].add(test_id)
|
|
251
|
+
coverage_pairs.append({"func": innermost, "test": test_id})
|
|
252
|
+
|
|
253
|
+
covered_func_ids = set(covered_by.keys())
|
|
254
|
+
uncovered_func_ids = all_func_ids - covered_func_ids
|
|
255
|
+
|
|
256
|
+
return coverage_pairs, covered_func_ids, uncovered_func_ids
|
|
257
|
+
|
|
258
|
+
|
|
259
|
+
def ingest_coverage(
|
|
260
|
+
repo_path: str | Path,
|
|
261
|
+
graph_name: str = "guard",
|
|
262
|
+
commit_sha: str | None = None,
|
|
263
|
+
num_runs: int = 3,
|
|
264
|
+
python_exe: str | None = None,
|
|
265
|
+
cov_source: str | None = None,
|
|
266
|
+
) -> CoverageResult:
|
|
267
|
+
"""Full Task T4 pipeline:
|
|
268
|
+
1. Runs suite 3 times, measuring flake rate.
|
|
269
|
+
2. Collects per-test coverage contexts on run 1.
|
|
270
|
+
3. CHECKPOINT: Inspects coverage rows before DB write.
|
|
271
|
+
4. Writes COVERED_BY edges, Run nodes, and sets no_evidence flags in FalkorDB.
|
|
272
|
+
"""
|
|
273
|
+
repo = Path(repo_path).resolve()
|
|
274
|
+
g = db.get_graph(graph_name)
|
|
275
|
+
|
|
276
|
+
# 1. Fetch functions from FalkorDB
|
|
277
|
+
funcs_res = g.query("MATCH (f:Function) RETURN f.id, f.file, f.start, f.end").result_set
|
|
278
|
+
falkor_funcs = [(r[0], r[1], int(r[2]), int(r[3])) for r in funcs_res]
|
|
279
|
+
print(f"[Coverage Ingest] Loaded {len(falkor_funcs)} functions from graph '{graph_name}'.")
|
|
280
|
+
|
|
281
|
+
# 2. Run test suite 3 times
|
|
282
|
+
runs: list[RunResult] = []
|
|
283
|
+
cov_file = repo / ".coverage_ingest"
|
|
284
|
+
if cov_file.exists():
|
|
285
|
+
cov_file.unlink()
|
|
286
|
+
|
|
287
|
+
for i in range(1, num_runs + 1):
|
|
288
|
+
run_id = f"run_{int(time.time())}_{i}"
|
|
289
|
+
record_cov = (i == 1)
|
|
290
|
+
print(f"[Coverage Ingest] Running suite run {i}/{num_runs} (record_coverage={record_cov})...")
|
|
291
|
+
res = run_pytest_once(
|
|
292
|
+
repo,
|
|
293
|
+
run_id=run_id,
|
|
294
|
+
record_coverage=record_cov,
|
|
295
|
+
cov_file=cov_file,
|
|
296
|
+
python_exe=python_exe,
|
|
297
|
+
cov_source=cov_source,
|
|
298
|
+
)
|
|
299
|
+
runs.append(res)
|
|
300
|
+
print(f" -> Run {i}: {res.passed} passed, {res.failed} failed in {res.duration_ms:.1f}ms")
|
|
301
|
+
|
|
302
|
+
# 3. Compute test flakiness
|
|
303
|
+
flake_rates, flaky_tests = compute_flakiness(runs)
|
|
304
|
+
print(f"[Coverage Ingest] Flakiness analysis: {len(flaky_tests)} flaky tests identified.")
|
|
305
|
+
|
|
306
|
+
# 4. Read coverage contexts
|
|
307
|
+
print("[Coverage Ingest] Reading coverage contexts and mapping to functions...")
|
|
308
|
+
pairs, covered_funcs, uncovered_funcs = read_coverage_contexts(
|
|
309
|
+
cov_file=cov_file,
|
|
310
|
+
repo_path=repo,
|
|
311
|
+
falkor_functions=falkor_funcs,
|
|
312
|
+
flaky_tests=flaky_tests,
|
|
313
|
+
)
|
|
314
|
+
|
|
315
|
+
cov_result = CoverageResult(
|
|
316
|
+
coverage_pairs=pairs,
|
|
317
|
+
covered_functions=covered_funcs,
|
|
318
|
+
uncovered_functions=uncovered_funcs,
|
|
319
|
+
flaky_tests=flaky_tests,
|
|
320
|
+
test_flake_rates=flake_rates,
|
|
321
|
+
runs=runs,
|
|
322
|
+
)
|
|
323
|
+
|
|
324
|
+
# -----------------------------------------------------------------
|
|
325
|
+
# CHECKPOINT (Required by Part D and T4):
|
|
326
|
+
# After the coverage reader produces rows, before writing to FalkorDB.
|
|
327
|
+
# -----------------------------------------------------------------
|
|
328
|
+
print("=" * 60)
|
|
329
|
+
print("CHECKPOINT: Coverage Reader Summary (before FalkorDB write)")
|
|
330
|
+
print(f" - Total (Function, Test) pairs generated: {len(pairs)}")
|
|
331
|
+
print(f" - Covered functions: {len(covered_funcs)} / {len(falkor_funcs)}")
|
|
332
|
+
print(f" - Uncovered functions (no_evidence): {len(uncovered_funcs)} / {len(falkor_funcs)}")
|
|
333
|
+
print(f" - Flaky tests excluded: {len(flaky_tests)}")
|
|
334
|
+
print(f" - Recorded test runs: {len(runs)}")
|
|
335
|
+
print("=" * 60)
|
|
336
|
+
|
|
337
|
+
# 5. Write to FalkorDB
|
|
338
|
+
print(f"[Coverage Ingest] Writing coverage data to FalkorDB graph '{graph_name}'...")
|
|
339
|
+
_write_to_falkordb(g, cov_result, commit_sha=commit_sha)
|
|
340
|
+
|
|
341
|
+
# Cleanup temporary coverage file
|
|
342
|
+
if cov_file.exists():
|
|
343
|
+
cov_file.unlink()
|
|
344
|
+
|
|
345
|
+
return cov_result
|
|
346
|
+
|
|
347
|
+
|
|
348
|
+
def _write_to_falkordb(g, cov_res: CoverageResult, commit_sha: str | None = None) -> None:
|
|
349
|
+
"""Writes runs, flake rates, COVERED_BY edges, and no_evidence flags to FalkorDB."""
|
|
350
|
+
# 1. Write Run nodes and failures
|
|
351
|
+
for r in cov_res.runs:
|
|
352
|
+
run_data = {
|
|
353
|
+
"id": r.id,
|
|
354
|
+
"timestamp": r.timestamp,
|
|
355
|
+
"commit_sha": commit_sha or "HEAD",
|
|
356
|
+
"duration_ms": r.duration_ms,
|
|
357
|
+
"total_tests": r.total_tests,
|
|
358
|
+
"passed": r.passed,
|
|
359
|
+
"failed": r.failed,
|
|
360
|
+
}
|
|
361
|
+
db.write_run(g, run_data, failures=r.failures)
|
|
362
|
+
|
|
363
|
+
# 2. Update flake_rate on Test nodes
|
|
364
|
+
flake_rows = [{"test_id": t_id, "rate": rate} for t_id, rate in cov_res.test_flake_rates.items()]
|
|
365
|
+
for b in db._batches(flake_rows):
|
|
366
|
+
g.query(
|
|
367
|
+
"""UNWIND $rows AS r
|
|
368
|
+
MATCH (t:Test {id: r.test_id})
|
|
369
|
+
SET t.flake_rate = r.rate""",
|
|
370
|
+
{"rows": b},
|
|
371
|
+
)
|
|
372
|
+
# Ensure any Test node not in flake_rows gets default flake_rate = 0.0
|
|
373
|
+
g.query("MATCH (t:Test) WHERE t.flake_rate IS NULL SET t.flake_rate = 0.0")
|
|
374
|
+
|
|
375
|
+
# 3. Write COVERED_BY edges (confidence 0.9)
|
|
376
|
+
written_pairs = db.write_coverage(
|
|
377
|
+
g,
|
|
378
|
+
cov_res.coverage_pairs,
|
|
379
|
+
commit_sha=commit_sha,
|
|
380
|
+
confidence=0.9,
|
|
381
|
+
)
|
|
382
|
+
print(f" -> Written {written_pairs} COVERED_BY edges (confidence=0.9).")
|
|
383
|
+
|
|
384
|
+
# 4. Set no_evidence flags on Function nodes
|
|
385
|
+
uncovered_rows = [{"id": fid} for fid in cov_res.uncovered_functions]
|
|
386
|
+
for b in db._batches(uncovered_rows):
|
|
387
|
+
g.query(
|
|
388
|
+
"""UNWIND $rows AS r
|
|
389
|
+
MATCH (f:Function {id: r.id})
|
|
390
|
+
SET f.no_evidence = true""",
|
|
391
|
+
{"rows": b},
|
|
392
|
+
)
|
|
393
|
+
|
|
394
|
+
covered_rows = [{"id": fid} for fid in cov_res.covered_functions]
|
|
395
|
+
for b in db._batches(covered_rows):
|
|
396
|
+
g.query(
|
|
397
|
+
"""UNWIND $rows AS r
|
|
398
|
+
MATCH (f:Function {id: r.id})
|
|
399
|
+
SET f.no_evidence = false""",
|
|
400
|
+
{"rows": b},
|
|
401
|
+
)
|
|
402
|
+
|
|
403
|
+
print(" -> Updated no_evidence flags on all Function nodes.")
|