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/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
@@ -0,0 +1 @@
1
+ """Ingestion modules for Guard."""
@@ -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.")