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 ADDED
@@ -0,0 +1,2 @@
1
+ """Guard: Graph-powered safety layer for AI coding agents."""
2
+ __version__ = "0.1.0rc1"
@@ -0,0 +1,122 @@
1
+ """Agent integration and test execution runner (Task T7).
2
+
3
+ Provides `guard run`: an agent calls this command before declaring work complete.
4
+ - Calls `guard check` on proposed changes.
5
+ - If trust is low or changes are uncovered (fallback), forces a full test suite run.
6
+ - If trust is high, runs only the selected test subset.
7
+ - Blocks task completion if any test fails.
8
+ """
9
+ from __future__ import annotations
10
+ import os
11
+ import sys
12
+ import time
13
+ import subprocess
14
+ from pathlib import Path
15
+ from typing import Any
16
+
17
+ from guard.thin_slice import guard_check
18
+
19
+ REPO_ROOT = Path("target_repos/flask").resolve()
20
+
21
+
22
+ def apply_patch(diff_file: Path, repo_path: Path | None = None) -> bool:
23
+ """Applies patch to target repo."""
24
+ target = repo_path or REPO_ROOT
25
+ proc = subprocess.run(["git", "apply", str(diff_file)], cwd=str(target), capture_output=True, text=True)
26
+ if proc.returncode != 0:
27
+ patch_proc = subprocess.run(["patch", "-p1", "-i", str(diff_file)], cwd=str(target), capture_output=True)
28
+ return patch_proc.returncode == 0
29
+ return True
30
+
31
+
32
+ def reset_repo(repo_path: Path | None = None):
33
+ """Restores target repo to clean state."""
34
+ target = repo_path or REPO_ROOT
35
+ subprocess.run(["git", "checkout", "--", "."], cwd=str(target), capture_output=True)
36
+ subprocess.run(["git", "clean", "-fdx"], cwd=str(target), capture_output=True)
37
+
38
+
39
+ def count_collected_tests(test_targets: list[str], repo_path: Path | None = None) -> int:
40
+ """Counts how many test items are collected for the targets."""
41
+ target = repo_path or REPO_ROOT
42
+ cmd = [sys.executable, "-m", "pytest", "--collect-only", "-q"] + test_targets
43
+ proc = subprocess.run(cmd, cwd=str(target), capture_output=True, text=True)
44
+ lines = proc.stdout.strip().splitlines()
45
+ for line in reversed(lines):
46
+ if "test" in line:
47
+ parts = line.split()
48
+ for p in parts:
49
+ if p.isdigit():
50
+ return int(p)
51
+ return len(test_targets)
52
+
53
+
54
+ def run_agent_guard(
55
+ diff_file: str | Path | None = None,
56
+ repo_path: str | Path | None = None,
57
+ graph_name: str = "guard",
58
+ ) -> tuple[dict[str, Any], int]:
59
+ """Runs Guard check and executes tests according to trust level.
60
+
61
+ Returns:
62
+ (report_dict, exit_code)
63
+ """
64
+ repo = Path(repo_path).resolve() if repo_path else REPO_ROOT
65
+ diff_path = Path(diff_file).resolve() if diff_file else None
66
+
67
+ # 1. Run guard check
68
+ check_res = guard_check(
69
+ diff_file=diff_path,
70
+ repo_path=repo,
71
+ graph_name=graph_name,
72
+ )
73
+
74
+ forced_full = check_res.is_fallback or (check_res.status == "run-all")
75
+ tests_to_run = ["tests"] if forced_full else check_res.selected_tests
76
+
77
+ # 2. If a patch file was passed, apply it temporarily for test execution
78
+ applied = False
79
+ if diff_path and diff_path.exists():
80
+ reset_repo()
81
+ applied = apply_patch(diff_path)
82
+ if not applied:
83
+ return {
84
+ "status": "error",
85
+ "forced_full": True,
86
+ "fallback_reason": "Failed to apply patch diff",
87
+ "changed_functions": [],
88
+ "tests_run": [],
89
+ "tests_run_count": 0,
90
+ "duration_s": 0.0,
91
+ "tests_passed": False,
92
+ "exit_code": 1,
93
+ "output_summary": "Error: Failed to apply patch cleanly to target repo.",
94
+ }, 1
95
+
96
+ try:
97
+ # Count collected tests
98
+ test_count = count_collected_tests(tests_to_run)
99
+
100
+ # 3. Execute pytest
101
+ cmd = [sys.executable, "-m", "pytest", "-q", "--tb=short"] + tests_to_run
102
+ start = time.time()
103
+ proc = subprocess.run(cmd, cwd=str(repo), capture_output=True, text=True)
104
+ duration_s = time.time() - start
105
+
106
+ passed = (proc.returncode == 0)
107
+ report = {
108
+ "status": check_res.status,
109
+ "forced_full": forced_full,
110
+ "fallback_reason": check_res.fallback_reason,
111
+ "changed_functions": check_res.changed_functions,
112
+ "tests_run": tests_to_run,
113
+ "tests_run_count": test_count,
114
+ "duration_s": round(duration_s, 3),
115
+ "tests_passed": passed,
116
+ "exit_code": proc.returncode,
117
+ "output_summary": proc.stdout.strip().splitlines()[-1] if proc.stdout.strip() else "",
118
+ }
119
+ return report, proc.returncode
120
+ finally:
121
+ if applied:
122
+ reset_repo()
guard/api.py ADDED
@@ -0,0 +1,397 @@
1
+ """Guard Public API and Stable Contract (Phase 0).
2
+
3
+ Provides the unified contract used across the CLI, Claude Code hook,
4
+ MCP server, and external agent loops:
5
+ - `check()`: Evaluates diff, returns test selection decision + versioned schema.
6
+ - `run()`: Executes the selected test subset according to trust policy.
7
+ - `covering_tests()`: Queries graph for tests covering a function.
8
+ - `callers()`: Queries multi-hop callers of a function.
9
+ - `report_miss()`: Feedback loop repairing graph with a miss_repair edge.
10
+
11
+ Fail-open by default: If database or graph is unavailable and strict=False,
12
+ Guard emits a warning to stderr and fails open instead of blocking the agent.
13
+ """
14
+ from __future__ import annotations
15
+ import re
16
+ import sys
17
+ import time
18
+ import subprocess
19
+ from pathlib import Path
20
+ from typing import Any
21
+
22
+ from guard import db
23
+ from guard import feedback
24
+ from guard.events import log_event
25
+ from guard.thin_slice import (
26
+ guard_check,
27
+ parse_unified_diff,
28
+ resolve_changed_functions,
29
+ thin_slice_decision,
30
+ CheckResult,
31
+ )
32
+ from guard.agent_integration import (
33
+ REPO_ROOT,
34
+ apply_patch,
35
+ reset_repo,
36
+ count_collected_tests,
37
+ )
38
+
39
+ SCHEMA_VERSION = "1.0"
40
+
41
+
42
+ def _extract_failures(stdout: str, stderr: str) -> list[str]:
43
+ """Parses failing pytest test identifiers from output."""
44
+ failures = []
45
+ # pytest output lines typically look like:
46
+ # FAILED tests/test_config.py::test_config_class - AssertionError...
47
+ fail_regex = re.compile(r"^FAILED\s+([^\s]+)", re.MULTILINE)
48
+ for match in fail_regex.finditer(stdout):
49
+ failures.append(match.group(1))
50
+ for match in fail_regex.finditer(stderr):
51
+ failures.append(match.group(1))
52
+ return sorted(list(set(failures)))
53
+
54
+
55
+ def _format_reasons(check_res: CheckResult) -> dict[str, str]:
56
+ """Generates a reasons map for selected tests."""
57
+ reasons: dict[str, str] = {}
58
+ if check_res.is_fallback:
59
+ reason_text = check_res.fallback_reason or "Fallback triggered"
60
+ for t in check_res.selected_tests:
61
+ reasons[t] = f"selected: full suite required ({reason_text})"
62
+ else:
63
+ for t in check_res.selected_tests:
64
+ reasons[t] = "selected: covering edge from changed function"
65
+ return reasons
66
+
67
+
68
+ def normalize_test_target_for_pytest(target: str) -> str:
69
+ """Normalizes graph test identifiers into valid pytest CLI invocation targets."""
70
+ if "::" not in target:
71
+ return target
72
+ file_part, func_part = target.split("::", 1)
73
+ if "." in func_part:
74
+ cls_name, method_name = func_part.split(".", 1)
75
+ if method_name.startswith("test"):
76
+ return f"{file_part}::{cls_name}::{method_name}"
77
+ else:
78
+ return f"{file_part}::{cls_name}"
79
+ else:
80
+ if func_part.startswith("test") or func_part.startswith("Test"):
81
+ return target
82
+ else:
83
+ return file_part
84
+
85
+
86
+ def check(
87
+ diff_text: str | None = None,
88
+ diff_file: str | Path | None = None,
89
+ repo_path: str | Path | None = None,
90
+ graph_name: str = "guard",
91
+ strict: bool = False,
92
+ no_confidence_filter: bool = False,
93
+ no_dynamic_widening: bool = False,
94
+ ) -> dict[str, Any]:
95
+ """Evaluates proposed changes and returns test selection decision.
96
+
97
+ Returns a versioned JSON dictionary conforming to schema v1.0.
98
+ """
99
+ repo = Path(repo_path).resolve() if repo_path else Path.cwd()
100
+ diff_path = Path(diff_file).resolve() if diff_file else None
101
+
102
+ try:
103
+ res = guard_check(
104
+ diff_text=diff_text,
105
+ diff_file=diff_path,
106
+ repo_path=repo,
107
+ graph_name=graph_name,
108
+ no_confidence_filter=no_confidence_filter,
109
+ no_dynamic_widening=no_dynamic_widening,
110
+ )
111
+
112
+ is_fallback = res.is_fallback
113
+ policy = "subset" if not is_fallback else "run_all"
114
+ trust = 0.9 if not is_fallback else 0.0
115
+
116
+ overrides = []
117
+ if is_fallback:
118
+ if res.fallback_reason:
119
+ if "Test files" in res.fallback_reason:
120
+ overrides.append("test_file_touched")
121
+ elif "no_evidence" in res.fallback_reason or "No covering tests" in res.fallback_reason or "No parsed" in res.fallback_reason:
122
+ overrides.append("no_evidence")
123
+ else:
124
+ overrides.append("fallback_override")
125
+
126
+ reasons = _format_reasons(res)
127
+
128
+ return {
129
+ "version": SCHEMA_VERSION,
130
+ "status": res.status,
131
+ "policy": policy,
132
+ "trust": trust,
133
+ "overrides_triggered": overrides,
134
+ "selected_tests": res.selected_tests,
135
+ "selected_tests_count": len(res.selected_tests),
136
+ "failures": [],
137
+ "reasons": reasons,
138
+ "changed_functions": res.changed_functions,
139
+ "is_fallback": res.is_fallback,
140
+ "fallback_reason": res.fallback_reason,
141
+ "execution_time_ms": res.execution_time_ms,
142
+ }
143
+ except Exception as exc:
144
+ if strict:
145
+ raise
146
+ sys.stderr.write(f"Guard warning (fail-open): check failed with {exc}\n")
147
+ log_event(
148
+ event="fail_open",
149
+ repo_path=repo,
150
+ details={"op": "check", "error": str(exc)},
151
+ )
152
+ return {
153
+ "version": SCHEMA_VERSION,
154
+ "status": "run-all",
155
+ "policy": "fail_open",
156
+ "trust": 0.0,
157
+ "overrides_triggered": ["fail_open_error"],
158
+ "selected_tests": ["tests"],
159
+ "selected_tests_count": 1,
160
+ "failures": [],
161
+ "reasons": {"error": f"Guard check error: {exc}"},
162
+ "changed_functions": [],
163
+ "is_fallback": True,
164
+ "fallback_reason": f"Guard database/internal error: {exc}",
165
+ "execution_time_ms": 0.0,
166
+ "fail_open": True,
167
+ "error": str(exc),
168
+ }
169
+
170
+
171
+ def run(
172
+ diff_file: str | Path | None = None,
173
+ diff_text: str | None = None,
174
+ repo_path: str | Path | None = None,
175
+ graph_name: str = "guard",
176
+ strict: bool = False,
177
+ session_id: str | None = None,
178
+ ) -> tuple[dict[str, Any], int]:
179
+ """Runs Guard check and executes the selected tests.
180
+
181
+ Returns:
182
+ (report_dict, exit_code)
183
+ """
184
+ repo = Path(repo_path).resolve() if repo_path else Path.cwd()
185
+ diff_path = Path(diff_file).resolve() if diff_file else None
186
+
187
+ # 1. Run check
188
+ try:
189
+ check_dict = check(
190
+ diff_text=diff_text,
191
+ diff_file=diff_path,
192
+ repo_path=repo,
193
+ graph_name=graph_name,
194
+ strict=strict,
195
+ )
196
+ except Exception as exc:
197
+ if strict:
198
+ raise
199
+ sys.stderr.write(f"Guard warning (fail-open): check failed with {exc}\n")
200
+ log_event(
201
+ event="fail_open",
202
+ session_id=session_id,
203
+ repo_path=repo,
204
+ details={"op": "run", "error": str(exc)},
205
+ )
206
+ return {
207
+ "version": SCHEMA_VERSION,
208
+ "status": "passed",
209
+ "policy": "fail_open",
210
+ "trust": 0.0,
211
+ "overrides_triggered": ["fail_open_error"],
212
+ "selected_tests": [],
213
+ "failures": [],
214
+ "reasons": {},
215
+ "tests_run": [],
216
+ "tests_run_count": 0,
217
+ "tests_passed": True,
218
+ "forced_full": False,
219
+ "fallback_reason": str(exc),
220
+ "duration_s": 0.0,
221
+ "exit_code": 0,
222
+ "output_summary": f"Guard bypassed fail-open: {exc}",
223
+ "fail_open": True,
224
+ }, 0
225
+
226
+ forced_full = check_dict.get("is_fallback", False) or (check_dict.get("status") == "run-all")
227
+ if forced_full:
228
+ tests_to_run = ["tests"]
229
+ else:
230
+ raw_selected = check_dict.get("selected_tests", ["tests"])
231
+ tests_to_run = list(dict.fromkeys(normalize_test_target_for_pytest(t) for t in raw_selected))
232
+
233
+ # 2. Apply patch if explicit diff_file was provided
234
+ applied = False
235
+ if diff_path and diff_path.exists():
236
+ reset_repo(repo)
237
+ applied = apply_patch(diff_path, repo)
238
+ if not applied:
239
+ err_dict = {
240
+ "version": SCHEMA_VERSION,
241
+ "status": "error",
242
+ "policy": check_dict.get("policy", "run_all"),
243
+ "trust": check_dict.get("trust", 0.0),
244
+ "overrides_triggered": check_dict.get("overrides_triggered", []),
245
+ "selected_tests": tests_to_run,
246
+ "failures": ["patch_apply_failure"],
247
+ "reasons": check_dict.get("reasons", {}),
248
+ "forced_full": True,
249
+ "fallback_reason": "Failed to apply patch diff",
250
+ "changed_functions": check_dict.get("changed_functions", []),
251
+ "tests_run": [],
252
+ "tests_run_count": 0,
253
+ "duration_s": 0.0,
254
+ "tests_passed": False,
255
+ "exit_code": 1,
256
+ "output_summary": "Error: Failed to apply patch cleanly to target repo.",
257
+ }
258
+ log_event(
259
+ event="failed",
260
+ session_id=session_id,
261
+ repo_path=repo,
262
+ details=err_dict,
263
+ )
264
+ return err_dict, 1
265
+
266
+ try:
267
+ # Count collected tests
268
+ test_count = count_collected_tests(tests_to_run, repo)
269
+
270
+ # Resolve target repo python interpreter
271
+ py_bin = sys.executable
272
+ if repo:
273
+ try:
274
+ from guard.init_cmd import find_test_environment
275
+ py_bin = str(find_test_environment(repo))
276
+ except Exception:
277
+ pass
278
+
279
+ # 3. Execute pytest (180s timeout for worst-case run-all test suites)
280
+ cmd = [py_bin, "-m", "pytest", "-q", "--tb=short"] + tests_to_run
281
+ start = time.time()
282
+ try:
283
+ proc = subprocess.run(cmd, cwd=str(repo), capture_output=True, text=True, timeout=180)
284
+ duration_s = round(time.time() - start, 3)
285
+ passed = (proc.returncode == 0)
286
+ failures = _extract_failures(proc.stdout, proc.stderr)
287
+ summary_line = proc.stdout.strip().splitlines()[-1] if proc.stdout.strip() else ""
288
+ proc_exit_code = proc.returncode
289
+ except subprocess.TimeoutExpired:
290
+ duration_s = round(time.time() - start, 3)
291
+ passed = False
292
+ failures = ["test_suite_timeout_expired_180s"]
293
+ summary_line = f"Test execution timed out after {duration_s:.1f}s."
294
+ proc_exit_code = 124
295
+
296
+ report = {
297
+ "version": SCHEMA_VERSION,
298
+ "status": "passed" if passed else "failed",
299
+ "policy": check_dict.get("policy", "subset"),
300
+ "trust": check_dict.get("trust", 0.9 if passed else 0.0),
301
+ "overrides_triggered": check_dict.get("overrides_triggered", []),
302
+ "selected_tests": check_dict.get("selected_tests", []),
303
+ "failures": failures,
304
+ "reasons": check_dict.get("reasons", {}),
305
+ "forced_full": forced_full,
306
+ "fallback_reason": check_dict.get("fallback_reason"),
307
+ "changed_functions": check_dict.get("changed_functions", []),
308
+ "tests_run": tests_to_run,
309
+ "tests_run_count": test_count,
310
+ "duration_s": duration_s,
311
+ "tests_passed": passed,
312
+ "exit_code": proc_exit_code,
313
+ "output_summary": summary_line,
314
+ }
315
+
316
+ # Log event
317
+ log_event(
318
+ event="passed" if passed else "blocked",
319
+ session_id=session_id,
320
+ repo_path=repo,
321
+ details={
322
+ "exit_code": proc_exit_code,
323
+ "tests_passed": passed,
324
+ "tests_run_count": test_count,
325
+ "failures": failures,
326
+ "policy": report["policy"],
327
+ },
328
+ )
329
+
330
+ return report, proc_exit_code
331
+ finally:
332
+ if applied:
333
+ reset_repo()
334
+
335
+
336
+ def covering_tests(
337
+ function_id: str,
338
+ min_confidence: float = 0.0,
339
+ graph_name: str = "guard",
340
+ strict: bool = False,
341
+ ) -> list[dict[str, Any]]:
342
+ """Find tests covering a given function, ordered by confidence."""
343
+ try:
344
+ g = db.get_graph(graph_name)
345
+ return db.get_covering_tests(g, function_id, min_confidence=min_confidence)
346
+ except Exception as exc:
347
+ if strict:
348
+ raise
349
+ sys.stderr.write(f"Guard warning (fail-open): covering_tests failed with {exc}\n")
350
+ return []
351
+
352
+
353
+ def callers(
354
+ function_id: str,
355
+ hops: int = 4,
356
+ min_confidence: float = 0.0,
357
+ graph_name: str = "guard",
358
+ strict: bool = False,
359
+ ) -> list[dict[str, Any]]:
360
+ """Traverse inbound CALLS edges up to N hops filtered by min_confidence."""
361
+ try:
362
+ g = db.get_graph(graph_name)
363
+ return db.get_callers(g, function_id, hops=hops, min_confidence=min_confidence)
364
+ except Exception as exc:
365
+ if strict:
366
+ raise
367
+ sys.stderr.write(f"Guard warning (fail-open): callers failed with {exc}\n")
368
+ return []
369
+
370
+
371
+ def report_miss(
372
+ function_id: str,
373
+ test_id: str,
374
+ new_confidence: float = 1.0,
375
+ graph_name: str = "guard",
376
+ strict: bool = False,
377
+ ) -> dict[str, Any]:
378
+ """Records a missed failure by repairing the graph with a miss_repair edge."""
379
+ try:
380
+ g = db.get_graph(graph_name)
381
+ return feedback.record_miss(
382
+ g,
383
+ function_id=function_id,
384
+ test_id=test_id,
385
+ new_confidence=new_confidence,
386
+ )
387
+ except Exception as exc:
388
+ if strict:
389
+ raise
390
+ sys.stderr.write(f"Guard warning (fail-open): report_miss failed with {exc}\n")
391
+ return {
392
+ "status": "error",
393
+ "error": str(exc),
394
+ "function": function_id,
395
+ "test": test_id,
396
+ "fail_open": True,
397
+ }