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/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
|
+
}
|