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/doctor_cmd.py ADDED
@@ -0,0 +1,475 @@
1
+ """Guard doctor command implementation (Work Package D).
2
+
3
+ Performs diagnostic verification across 6 subsystems:
4
+ 1. Environment (Python version >= 3.10, Guard version, PATH check for hooks)
5
+ 2. Backend (reachability and query round-trip)
6
+ 3. Graph (nodes > 0, edges > 0)
7
+ 4. Freshness (ingest SHA vs git HEAD SHA, changed files count)
8
+ 5. Target repo (git root, target Python, pytest, pytest-cov, baseline status)
9
+ 6. Integrations (.claude/settings.json, AGENTS.md, .gitignore)
10
+
11
+ Exit codes:
12
+ 0: All checks passed
13
+ 1: Warnings present, no failures
14
+ 2: At least one failure present
15
+ """
16
+ from __future__ import annotations
17
+ import json
18
+ import os
19
+ import shutil
20
+ import subprocess
21
+ import sys
22
+ from dataclasses import dataclass, asdict
23
+ from pathlib import Path
24
+ from typing import Any
25
+
26
+ from rich.console import Console
27
+ from rich.table import Table
28
+
29
+ import guard
30
+ from guard import db
31
+ from guard.backend import (
32
+ GraphBackend,
33
+ get_repo_graph_name,
34
+ resolve_backend,
35
+ DockerBackend,
36
+ )
37
+ from guard.init_cmd import (
38
+ find_repo_root,
39
+ find_test_environment,
40
+ write_integrations,
41
+ INTEGRATION_MARKER_START,
42
+ )
43
+
44
+ console = Console()
45
+
46
+
47
+ @dataclass
48
+ class CheckResult:
49
+ name: str
50
+ status: str # "pass", "warn", "fail"
51
+ message: str
52
+ fix: str | None = None
53
+
54
+
55
+ def check_environment() -> CheckResult:
56
+ """Check Python version, Guard version, and executable on PATH."""
57
+ py_ver = f"{sys.version_info[0]}.{sys.version_info[1]}.{sys.version_info[2]}"
58
+ if sys.version_info < (3, 10):
59
+ return CheckResult(
60
+ name="environment",
61
+ status="fail",
62
+ message=f"Python {py_ver} is unsupported. Python 3.10 or newer is required.",
63
+ fix="Upgrade to Python 3.10+ (recommended: Python 3.12).",
64
+ )
65
+
66
+ guard_bin = shutil.which("guard")
67
+ if not guard_bin:
68
+ return CheckResult(
69
+ name="environment",
70
+ status="warn",
71
+ message=f"Python {py_ver}, guard v{guard.__version__}. 'guard' is not found on PATH for subshells and agent hooks.",
72
+ fix="Add your Python/uv tool bin directory to PATH (e.g. run 'uv tool update-shell' or add ~/.local/bin to PATH).",
73
+ )
74
+
75
+ return CheckResult(
76
+ name="environment",
77
+ status="pass",
78
+ message=f"Python {py_ver}, guard v{guard.__version__} (binary: {guard_bin}).",
79
+ )
80
+
81
+
82
+ def check_backend(repo_path: Path) -> tuple[CheckResult, GraphBackend | None]:
83
+ """Check if FalkorDB backend is reachable and round-trips a query."""
84
+ try:
85
+ backend = resolve_backend(repo_path=repo_path, autostart=False)
86
+ desc = backend.describe()
87
+ if not desc.get("healthy", False):
88
+ return (
89
+ CheckResult(
90
+ name="backend",
91
+ status="fail",
92
+ message=f"Backend '{backend.name}' is unreachable or failed query round-trip.",
93
+ fix="Start FalkorDB: 'docker start guard-falkordb' or run 'guard init --backend docker'.",
94
+ ),
95
+ backend,
96
+ )
97
+ return (
98
+ CheckResult(
99
+ name="backend",
100
+ status="pass",
101
+ message=f"Backend '{backend.name}' is healthy ({desc.get('connection_str') or desc.get('data_path') or desc.get('container')}).",
102
+ ),
103
+ backend,
104
+ )
105
+ except Exception as err:
106
+ return (
107
+ CheckResult(
108
+ name="backend",
109
+ status="fail",
110
+ message=f"Backend resolution error: {err}",
111
+ fix="Run 'guard init' to configure a valid backend.",
112
+ ),
113
+ None,
114
+ )
115
+
116
+
117
+ def check_graph(repo_path: Path, backend: GraphBackend | None) -> CheckResult:
118
+ """Verify repository graph exists in FalkorDB with non-zero nodes and edges."""
119
+ if backend is None or not backend.is_healthy():
120
+ return CheckResult(
121
+ name="graph",
122
+ status="fail",
123
+ message="Cannot check graph: backend is offline.",
124
+ fix="Ensure backend is running and re-run doctor.",
125
+ )
126
+
127
+ graph_name = get_repo_graph_name(repo_path)
128
+ try:
129
+ g = backend.get_graph(graph_name)
130
+ counts = db.get_schema_counts(g)
131
+ nodes_total = sum(counts.get(k, 0) for k in ("File", "Function", "Test", "Run"))
132
+ edges_total = sum(counts.get(k, 0) for k in ("CALLS", "IMPORTS", "DEFINED_IN", "COVERED_BY", "FAILED_IN"))
133
+
134
+ if nodes_total == 0:
135
+ return CheckResult(
136
+ name="graph",
137
+ status="fail",
138
+ message=f"Graph '{graph_name}' exists but has 0 nodes and 0 edges.",
139
+ fix="Run 'guard init' to ingest repository structure.",
140
+ )
141
+
142
+ cov_count = counts.get("COVERED_BY", 0)
143
+ cov_info = f", {cov_count} coverage edges" if cov_count else " (no coverage edges yet)"
144
+ return CheckResult(
145
+ name="graph",
146
+ status="pass",
147
+ message=f"Graph '{graph_name}' has {counts.get('Function', 0)} functions, {counts.get('Test', 0)} tests{cov_info}.",
148
+ )
149
+ except Exception as err:
150
+ return CheckResult(
151
+ name="graph",
152
+ status="fail",
153
+ message=f"Error querying graph '{graph_name}': {err}",
154
+ fix="Run 'guard init' to rebuild graph schema.",
155
+ )
156
+
157
+
158
+ def check_freshness(repo_path: Path) -> CheckResult:
159
+ """Compare recorded ingest commit SHA with current git HEAD SHA."""
160
+ state_file = repo_path / ".guard" / "state.json"
161
+ if not state_file.exists():
162
+ return CheckResult(
163
+ name="freshness",
164
+ status="warn",
165
+ message="No recorded ingestion state found in .guard/state.json.",
166
+ fix="Run 'guard init' to index repository and record baseline state.",
167
+ )
168
+
169
+ try:
170
+ data = json.loads(state_file.read_text(encoding="utf-8"))
171
+ ingest_sha = data.get("head_sha")
172
+ except Exception:
173
+ ingest_sha = None
174
+
175
+ if not ingest_sha or ingest_sha == "unknown":
176
+ return CheckResult(
177
+ name="freshness",
178
+ status="warn",
179
+ message="Ingestion state exists but recorded commit SHA is unknown.",
180
+ fix="Run 'guard init' to update state with current commit.",
181
+ )
182
+
183
+ # Get current git HEAD SHA
184
+ try:
185
+ res = subprocess.run(
186
+ ["git", "rev-parse", "HEAD"],
187
+ cwd=str(repo_path),
188
+ capture_output=True,
189
+ text=True,
190
+ timeout=5,
191
+ )
192
+ current_sha = res.stdout.strip() if res.returncode == 0 else ""
193
+ except Exception:
194
+ current_sha = ""
195
+
196
+ if not current_sha:
197
+ return CheckResult(
198
+ name="freshness",
199
+ status="warn",
200
+ message="Could not determine current git HEAD SHA.",
201
+ fix="Ensure git is installed and repository has commits.",
202
+ )
203
+
204
+ if ingest_sha == current_sha:
205
+ return CheckResult(
206
+ name="freshness",
207
+ status="pass",
208
+ message=f"Graph is fresh at commit {current_sha[:7]}.",
209
+ )
210
+
211
+ # Check changed files since ingest SHA
212
+ try:
213
+ diff_res = subprocess.run(
214
+ ["git", "diff", "--name-only", ingest_sha, "HEAD"],
215
+ cwd=str(repo_path),
216
+ capture_output=True,
217
+ text=True,
218
+ timeout=10,
219
+ )
220
+ changed_files = [f for f in diff_res.stdout.splitlines() if f.strip()]
221
+ if changed_files:
222
+ return CheckResult(
223
+ name="freshness",
224
+ status="warn",
225
+ message=f"{len(changed_files)} file(s) changed since ingest at {ingest_sha[:7]} (HEAD is {current_sha[:7]}).",
226
+ fix="Run 'guard ingest-static .' to refresh static call graph.",
227
+ )
228
+ return CheckResult(
229
+ name="freshness",
230
+ status="pass",
231
+ message=f"Graph is up to date (no files changed between {ingest_sha[:7]} and {current_sha[:7]}).",
232
+ )
233
+ except Exception:
234
+ return CheckResult(
235
+ name="freshness",
236
+ status="warn",
237
+ message=f"HEAD has diverged from ingest commit {ingest_sha[:7]}.",
238
+ fix="Run 'guard ingest-static .' to refresh call graph.",
239
+ )
240
+
241
+
242
+ def check_target_repo(repo_path: Path) -> CheckResult:
243
+ """Verify target repo Python environment, pytest, and pytest-cov."""
244
+ try:
245
+ test_py = find_test_environment(repo_path)
246
+ except Exception as err:
247
+ return CheckResult(
248
+ name="target_repo",
249
+ status="fail",
250
+ message=f"Failed detecting test environment: {err}",
251
+ fix="Ensure virtualenv exists or pytest is installed.",
252
+ )
253
+
254
+ # Check pytest
255
+ try:
256
+ res = subprocess.run(
257
+ [str(test_py), "-m", "pytest", "--version"],
258
+ capture_output=True,
259
+ text=True,
260
+ timeout=5,
261
+ )
262
+ if res.returncode != 0:
263
+ return CheckResult(
264
+ name="target_repo",
265
+ status="fail",
266
+ message=f"pytest is not installed in target environment ({test_py}).",
267
+ fix=f"Run: {test_py} -m pip install pytest",
268
+ )
269
+ except Exception:
270
+ return CheckResult(
271
+ name="target_repo",
272
+ status="fail",
273
+ message=f"pytest is not functional in target environment ({test_py}).",
274
+ fix=f"Run: {test_py} -m pip install pytest",
275
+ )
276
+
277
+ # Check pytest-cov
278
+ try:
279
+ res_cov = subprocess.run(
280
+ [str(test_py), "-c", "import pytest_cov"],
281
+ capture_output=True,
282
+ text=True,
283
+ timeout=5,
284
+ )
285
+ has_cov = (res_cov.returncode == 0)
286
+ except Exception:
287
+ has_cov = False
288
+
289
+ if not has_cov:
290
+ return CheckResult(
291
+ name="target_repo",
292
+ status="warn",
293
+ message=f"pytest present ({test_py.name}), but pytest-cov is not installed in target environment.",
294
+ fix=f"Run: {test_py} -m pip install pytest-cov",
295
+ )
296
+
297
+ return CheckResult(
298
+ name="target_repo",
299
+ status="pass",
300
+ message=f"Target environment verified with pytest and pytest-cov ({test_py}).",
301
+ )
302
+
303
+
304
+ def check_integrations(repo_path: Path) -> CheckResult:
305
+ """Verify .claude/settings.json, AGENTS.md, and .gitignore integrations."""
306
+ missing: list[str] = []
307
+
308
+ # 1. Check .gitignore
309
+ gi = repo_path / ".gitignore"
310
+ if not gi.exists() or (".guard/" not in gi.read_text(encoding="utf-8") and ".guard" not in gi.read_text(encoding="utf-8")):
311
+ missing.append(".guard/ in .gitignore")
312
+
313
+ # 2. Check Claude Code Stop hook in settings.local.json or settings.json
314
+ from guard.integrations import claude
315
+ claude_info = claude.check_all_settings(repo_path)
316
+ if not claude_info["installed"]:
317
+ missing.append("hook in .claude/settings.local.json or .claude/settings.json")
318
+
319
+ # 3. Check AGENTS.md or CLAUDE.md marked block
320
+ agents_doc = repo_path / "AGENTS.md"
321
+ claude_doc = repo_path / "CLAUDE.md"
322
+ has_marker = False
323
+ for doc in (agents_doc, claude_doc):
324
+ if doc.exists() and INTEGRATION_MARKER_START in doc.read_text(encoding="utf-8"):
325
+ has_marker = True
326
+ break
327
+ if not has_marker:
328
+ missing.append("marked instruction block in AGENTS.md/CLAUDE.md")
329
+
330
+ if missing:
331
+ return CheckResult(
332
+ name="integrations",
333
+ status="warn",
334
+ message=f"Missing integration settings: {', '.join(missing)}.",
335
+ fix="Run 'guard doctor --fix' or 'guard init' to write integration files.",
336
+ )
337
+
338
+ return CheckResult(
339
+ name="integrations",
340
+ status="pass",
341
+ message="Integrations verified in .claude/settings.json, AGENTS.md, and .gitignore.",
342
+ )
343
+
344
+
345
+ def apply_fixes(repo_path: Path, backend: GraphBackend | None, checks: list[CheckResult]) -> list[str]:
346
+ """Execute safe automated repairs for warnings/failures."""
347
+ applied: list[str] = []
348
+
349
+ for c in checks:
350
+ if c.name == "backend" and isinstance(backend, DockerBackend):
351
+ # Attempt to start stopped docker container
352
+ if backend.start_container():
353
+ applied.append(f"Started docker container '{backend.container_name}'.")
354
+
355
+ elif c.name == "integrations":
356
+ write_integrations(repo_path, agents=["claude", "cursor", "codex"])
357
+ applied.append("Wrote integrations to .gitignore, .claude/settings.json, AGENTS.md, and CLAUDE.md.")
358
+
359
+ return applied
360
+
361
+
362
+ def run_doctor(
363
+ repo_path: str | Path | None = None,
364
+ json_mode: bool = False,
365
+ fix_mode: bool = False,
366
+ ) -> int:
367
+ """Run full Guard doctor diagnostic check."""
368
+ try:
369
+ root = find_repo_root(repo_path)
370
+ except Exception as err:
371
+ if json_mode:
372
+ print(json.dumps({
373
+ "status": "fail",
374
+ "exit_code": 2,
375
+ "error": str(err),
376
+ }, indent=2))
377
+ else:
378
+ console.print(f"[bold red]Error finding repository:[/bold red] {err}")
379
+ return 2
380
+
381
+ # Run checks
382
+ check_env_res = check_environment()
383
+ check_b_res, backend = check_backend(root)
384
+ check_g_res = check_graph(root, backend)
385
+ check_f_res = check_freshness(root)
386
+ check_t_res = check_target_repo(root)
387
+ check_i_res = check_integrations(root)
388
+
389
+ checks = [
390
+ check_env_res,
391
+ check_b_res,
392
+ check_g_res,
393
+ check_f_res,
394
+ check_t_res,
395
+ check_i_res,
396
+ ]
397
+
398
+ applied_fixes: list[str] = []
399
+ if fix_mode:
400
+ applied_fixes = apply_fixes(root, backend, checks)
401
+ # Re-run checks if fixes were applied
402
+ if applied_fixes:
403
+ check_b_res, backend = check_backend(root)
404
+ check_g_res = check_graph(root, backend)
405
+ check_i_res = check_integrations(root)
406
+ checks = [
407
+ check_env_res,
408
+ check_b_res,
409
+ check_g_res,
410
+ check_f_res,
411
+ check_t_res,
412
+ check_i_res,
413
+ ]
414
+
415
+ # Compute status and exit code
416
+ has_fail = any(c.status == "fail" for c in checks)
417
+ has_warn = any(c.status == "warn" for c in checks)
418
+
419
+ if has_fail:
420
+ overall_status = "fail"
421
+ exit_code = 2
422
+ elif has_warn:
423
+ overall_status = "warn"
424
+ exit_code = 1
425
+ else:
426
+ overall_status = "pass"
427
+ exit_code = 0
428
+
429
+ if json_mode:
430
+ payload = {
431
+ "status": overall_status,
432
+ "exit_code": exit_code,
433
+ "applied_fixes": applied_fixes,
434
+ "checks": [asdict(c) for c in checks],
435
+ }
436
+ print(json.dumps(payload, indent=2))
437
+ return exit_code
438
+
439
+ # Rich terminal formatting
440
+ console.rule("[bold cyan]Guard Doctor Diagnostic Report[/bold cyan]")
441
+ if applied_fixes:
442
+ console.print("[bold green]Automated Repairs Applied (--fix):[/bold green]")
443
+ for fix_msg in applied_fixes:
444
+ console.print(f" [green]✔[/green] {fix_msg}")
445
+ console.print()
446
+
447
+ table = Table(title="Subsystem Checks", show_header=True, header_style="bold magenta")
448
+ table.add_column("Subsystem", style="cyan", width=15)
449
+ table.add_column("Status", width=10)
450
+ table.add_column("Details", style="white")
451
+
452
+ for c in checks:
453
+ if c.status == "pass":
454
+ status_badge = "[bold green]PASS[/bold green]"
455
+ elif c.status == "warn":
456
+ status_badge = "[bold yellow]WARN[/bold yellow]"
457
+ else:
458
+ status_badge = "[bold red]FAIL[/bold red]"
459
+
460
+ details = c.message
461
+ if c.fix and c.status in ("warn", "fail"):
462
+ details += f"\n[dim italic]Fix: {c.fix}[/dim italic]"
463
+
464
+ table.add_row(c.name, status_badge, details)
465
+
466
+ console.print(table)
467
+
468
+ pass_count = sum(1 for c in checks if c.status == "pass")
469
+ warn_count = sum(1 for c in checks if c.status == "warn")
470
+ fail_count = sum(1 for c in checks if c.status == "fail")
471
+
472
+ summary_color = "green" if exit_code == 0 else ("yellow" if exit_code == 1 else "red")
473
+ console.rule(f"[bold {summary_color}]Result: {pass_count} passed, {warn_count} warnings, {fail_count} failures[/bold {summary_color}]")
474
+
475
+ return exit_code
guard/events.py ADDED
@@ -0,0 +1,77 @@
1
+ """Append-only local event log for Guard.
2
+
3
+ Records lifecycle events (hook fired, blocked, passed, handed back, fail-open)
4
+ into `.guard/events.jsonl` in the repository root.
5
+ """
6
+ from __future__ import annotations
7
+ import json
8
+ import os
9
+ from datetime import datetime, timezone
10
+ from pathlib import Path
11
+ from typing import Any
12
+
13
+
14
+ def get_guard_dir(repo_path: str | Path | None = None) -> Path:
15
+ """Returns the .guard directory path for a repo, ensuring it exists."""
16
+ base = Path(repo_path).resolve() if repo_path else Path.cwd()
17
+ guard_dir = base / ".guard"
18
+ guard_dir.mkdir(parents=True, exist_ok=True)
19
+ return guard_dir
20
+
21
+
22
+ def log_event(
23
+ event: str,
24
+ session_id: str | None = None,
25
+ repo_path: str | Path | None = None,
26
+ details: dict[str, Any] | None = None,
27
+ ) -> Path | None:
28
+ """Appends an event entry to `.guard/events.jsonl`.
29
+
30
+ Guaranteed fail-safe: never raises an exception.
31
+
32
+ Args:
33
+ event: Event name (e.g. 'hook_fired', 'blocked', 'passed', 'handed_back', 'fail_open')
34
+ session_id: Claude Code or agent session identifier
35
+ repo_path: Working directory / repository root
36
+ details: Additional context (selected tests, failures, policy, exit code, etc.)
37
+
38
+ Returns:
39
+ Path to the events.jsonl file if written, or None on failure.
40
+ """
41
+ try:
42
+ guard_dir = get_guard_dir(repo_path)
43
+ events_file = guard_dir / "events.jsonl"
44
+
45
+ entry = {
46
+ "timestamp": datetime.now(timezone.utc).isoformat(),
47
+ "event": event,
48
+ "session_id": session_id or "unknown",
49
+ "details": details or {},
50
+ }
51
+
52
+ with open(events_file, "a", encoding="utf-8") as f:
53
+ f.write(json.dumps(entry) + "\n")
54
+
55
+ return events_file
56
+ except Exception:
57
+ # Event logging must never crash the tool
58
+ return None
59
+
60
+
61
+ def read_events(repo_path: str | Path | None = None) -> list[dict[str, Any]]:
62
+ """Reads all recorded events from `.guard/events.jsonl`."""
63
+ try:
64
+ base = Path(repo_path).resolve() if repo_path else Path.cwd()
65
+ events_file = base / ".guard" / "events.jsonl"
66
+ if not events_file.exists():
67
+ return []
68
+
69
+ events = []
70
+ with open(events_file, "r", encoding="utf-8") as f:
71
+ for line in f:
72
+ line = line.strip()
73
+ if line:
74
+ events.append(json.loads(line))
75
+ return events
76
+ except Exception:
77
+ return []
guard/feedback.py ADDED
@@ -0,0 +1,86 @@
1
+ """Feedback loop and repair edge mechanism for Guard (Task T6).
2
+
3
+ When a test failure is missed by Guard (an escape), this module repairs
4
+ the graph by inserting a high-confidence COVERED_BY edge with source='miss_repair',
5
+ logging the old and new confidence values, and unsetting no_evidence flags so that
6
+ future decisions widen to include the missed test.
7
+ """
8
+ from __future__ import annotations
9
+ import time
10
+ from typing import Any
11
+ from guard import db
12
+
13
+
14
+ def record_miss(
15
+ g,
16
+ function_id: str,
17
+ test_id: str,
18
+ new_confidence: float = 1.0,
19
+ ) -> dict[str, Any]:
20
+ """Records a missed failure by repairing the graph with a miss_repair edge.
21
+
22
+ Args:
23
+ g: FalkorDB graph instance
24
+ function_id: ID of the function where the fault/change occurred
25
+ test_id: ID of the test that failed and was missed
26
+ new_confidence: Confidence score for the repair edge (default: 1.0)
27
+
28
+ Returns:
29
+ Dict reporting the repair event including old and new confidence.
30
+ """
31
+ # 1. Query existing edge if present
32
+ q_existing = (
33
+ "MATCH (f:Function {id: $fid})-[e:COVERED_BY]->(t:Test {id: $tid}) "
34
+ "RETURN e.confidence, e.source"
35
+ )
36
+ existing_res = g.query(q_existing, {"fid": function_id, "tid": test_id}).result_set
37
+ if existing_res:
38
+ old_confidence = float(existing_res[0][0])
39
+ prev_source = existing_res[0][1]
40
+ else:
41
+ old_confidence = 0.0
42
+ prev_source = None
43
+
44
+ ts = time.time()
45
+
46
+ # 2. Ensure Test node exists and has basic defined properties if new
47
+ file_part = test_id.split("::")[0] if "::" in test_id else "tests/unknown.py"
48
+ g.query(
49
+ "MERGE (t:Test {id: $tid}) "
50
+ "ON CREATE SET t.file = $file, t.flake_rate = 0.0",
51
+ {"tid": test_id, "file": file_part},
52
+ )
53
+
54
+ # 3. Create or update COVERED_BY edge with source='miss_repair'
55
+ q_repair = (
56
+ """MATCH (f:Function {id: $fid}), (t:Test {id: $tid})
57
+ MERGE (f)-[e:COVERED_BY]->(t)
58
+ SET e.source = 'miss_repair',
59
+ e.old_confidence = $old_conf,
60
+ e.confidence = $new_conf,
61
+ e.evidence_count = coalesce(e.evidence_count, 0) + 1,
62
+ e.last_repaired_at = $ts
63
+ WITH f
64
+ SET f.no_evidence = false"""
65
+ )
66
+ g.query(
67
+ q_repair,
68
+ {
69
+ "fid": function_id,
70
+ "tid": test_id,
71
+ "old_conf": old_confidence,
72
+ "new_conf": new_confidence,
73
+ "ts": ts,
74
+ },
75
+ )
76
+
77
+ return {
78
+ "status": "repaired",
79
+ "function": function_id,
80
+ "test": test_id,
81
+ "old_confidence": old_confidence,
82
+ "new_confidence": new_confidence,
83
+ "source": "miss_repair",
84
+ "previous_source": prev_source,
85
+ "timestamp": ts,
86
+ }