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/state.py ADDED
@@ -0,0 +1,104 @@
1
+ """Session state tracking for Guard Loop Protection.
2
+
3
+ Maintains block counts per session in `.guard/state.json` to prevent
4
+ infinite agent stop-hook loops (capping consecutive blocks at 3).
5
+ """
6
+ from __future__ import annotations
7
+ import json
8
+ import time
9
+ from pathlib import Path
10
+ from typing import Any
11
+ from guard.events import get_guard_dir
12
+
13
+
14
+ def _state_file(repo_path: str | Path | None = None) -> Path:
15
+ return get_guard_dir(repo_path) / "session_state.json"
16
+
17
+
18
+ def load_state(repo_path: str | Path | None = None) -> dict[str, Any]:
19
+ """Loads current state dictionary from .guard/session_state.json."""
20
+ sf = _state_file(repo_path)
21
+ if not sf.exists():
22
+ return {"sessions": {}}
23
+ try:
24
+ with open(sf, "r", encoding="utf-8") as f:
25
+ data = json.load(f)
26
+ if not isinstance(data, dict):
27
+ return {"sessions": {}}
28
+ data.setdefault("sessions", {})
29
+ return data
30
+ except Exception:
31
+ return {"sessions": {}}
32
+
33
+
34
+ def save_state(state: dict[str, Any], repo_path: str | Path | None = None) -> None:
35
+ """Saves state dictionary to .guard/session_state.json safely."""
36
+ try:
37
+ sf = _state_file(repo_path)
38
+ tmp_file = sf.with_suffix(".tmp")
39
+ with open(tmp_file, "w", encoding="utf-8") as f:
40
+ json.dump(state, f, indent=2)
41
+ tmp_file.replace(sf)
42
+ except Exception:
43
+ pass
44
+
45
+
46
+ def get_session_blocks(session_id: str, repo_path: str | Path | None = None) -> int:
47
+ """Returns the number of consecutive blocks for a session."""
48
+ state = load_state(repo_path)
49
+ session_data = state.get("sessions", {}).get(session_id, {})
50
+ return int(session_data.get("consecutive_blocks", 0))
51
+
52
+
53
+ def record_session_block(session_id: str, repo_path: str | Path | None = None) -> int:
54
+ """Increments and returns the consecutive block count for a session."""
55
+ state = load_state(repo_path)
56
+ sessions = state.setdefault("sessions", {})
57
+ session_data = sessions.setdefault(session_id, {})
58
+
59
+ current = int(session_data.get("consecutive_blocks", 0)) + 1
60
+ session_data["consecutive_blocks"] = current
61
+ session_data["last_blocked_at"] = time.time()
62
+
63
+ save_state(state, repo_path)
64
+ return current
65
+
66
+
67
+ def reset_session_blocks(session_id: str, repo_path: str | Path | None = None) -> None:
68
+ """Resets consecutive blocks for a session back to 0."""
69
+ state = load_state(repo_path)
70
+ sessions = state.setdefault("sessions", {})
71
+ if session_id in sessions:
72
+ sessions[session_id]["consecutive_blocks"] = 0
73
+ sessions[session_id]["last_passed_at"] = time.time()
74
+ save_state(state, repo_path)
75
+
76
+
77
+ def get_last_passed_fingerprint(session_id: str, repo_path: str | Path | None = None) -> str | None:
78
+ """Returns the working-tree fingerprint that last passed for this session."""
79
+ state = load_state(repo_path)
80
+ session_data = state.get("sessions", {}).get(session_id, {})
81
+ return session_data.get("last_passed_fingerprint")
82
+
83
+
84
+ def set_last_passed_fingerprint(
85
+ session_id: str,
86
+ fingerprint: str,
87
+ repo_path: str | Path | None = None,
88
+ ) -> None:
89
+ """Records working-tree fingerprint that passed tests for this session."""
90
+ state = load_state(repo_path)
91
+ sessions = state.setdefault("sessions", {})
92
+ session_data = sessions.setdefault(session_id, {})
93
+ session_data["last_passed_fingerprint"] = fingerprint
94
+ session_data["fingerprint_passed_at"] = time.time()
95
+ save_state(state, repo_path)
96
+
97
+
98
+ def clear_last_passed_fingerprint(session_id: str, repo_path: str | Path | None = None) -> None:
99
+ """Clears cached passed fingerprint when a failure occurs."""
100
+ state = load_state(repo_path)
101
+ sessions = state.setdefault("sessions", {})
102
+ if session_id in sessions and "last_passed_fingerprint" in sessions[session_id]:
103
+ sessions[session_id].pop("last_passed_fingerprint", None)
104
+ save_state(state, repo_path)
guard/thin_slice.py ADDED
@@ -0,0 +1,278 @@
1
+ """Thin slice end-to-end decision engine (Task T5a).
2
+
3
+ Parses a git diff, maps changed line numbers to FalkorDB Function nodes,
4
+ queries covering tests from COVERED_BY edges, and applies crude decision logic:
5
+ - If ANY changed function has no_evidence=true (or 0 covering tests) -> run all.
6
+ - Otherwise -> select the covering tests.
7
+ """
8
+ from __future__ import annotations
9
+ import re
10
+ import subprocess
11
+ from dataclasses import dataclass, field
12
+ from pathlib import Path
13
+ from typing import Any
14
+
15
+ from guard import db
16
+
17
+
18
+ @dataclass
19
+ class ChangedFunction:
20
+ id: str
21
+ file: str
22
+ name: str
23
+ start: int
24
+ end: int
25
+ no_evidence: bool
26
+ has_dynamic_dispatch: bool = False
27
+ covering_tests: list[str] = field(default_factory=list)
28
+
29
+
30
+ @dataclass
31
+ class CheckResult:
32
+ status: str # "select" | "run-all"
33
+ is_fallback: bool
34
+ fallback_reason: str | None
35
+ changed_functions: list[str]
36
+ selected_tests: list[str]
37
+ execution_time_ms: float = 0.0
38
+
39
+ def to_dict(self) -> dict[str, Any]:
40
+ return {
41
+ "status": self.status,
42
+ "is_fallback": self.is_fallback,
43
+ "fallback_reason": self.fallback_reason,
44
+ "changed_functions": self.changed_functions,
45
+ "selected_tests": self.selected_tests,
46
+ "selected_tests_count": len(self.selected_tests),
47
+ "execution_time_ms": self.execution_time_ms,
48
+ }
49
+
50
+
51
+ def parse_unified_diff(diff_text: str) -> dict[str, set[int]]:
52
+ """Parses a unified diff into a map of {relative_file_path: set(modified_lines)}."""
53
+ file_lines: dict[str, set[int]] = {}
54
+ current_file = None
55
+ current_line = 0
56
+
57
+ hunk_regex = re.compile(r"^@@ -\d+(?:,\d+)? \+(\d+)(?:,(\d+))? @@")
58
+
59
+ for line in diff_text.splitlines():
60
+ if line.startswith("+++ b/"):
61
+ raw_path = line[6:].strip()
62
+ # Normalize path: strip repo prefixes if present
63
+ current_file = raw_path
64
+ if current_file not in file_lines:
65
+ file_lines[current_file] = set()
66
+ current_line = 0
67
+ continue
68
+ elif line.startswith("+++ "):
69
+ raw_path = line[4:].strip()
70
+ if raw_path != "/dev/null":
71
+ current_file = raw_path.lstrip("b/")
72
+ if current_file not in file_lines:
73
+ file_lines[current_file] = set()
74
+ current_line = 0
75
+ continue
76
+
77
+ if not current_file:
78
+ continue
79
+
80
+ hunk_match = hunk_regex.match(line)
81
+ if hunk_match:
82
+ current_line = int(hunk_match.group(1))
83
+ continue
84
+
85
+ if line.startswith("+") and not line.startswith("+++"):
86
+ file_lines[current_file].add(current_line)
87
+ current_line += 1
88
+ elif line.startswith("-") and not line.startswith("---"):
89
+ # Deletion occurs at current_line
90
+ file_lines[current_file].add(current_line)
91
+ elif line.startswith(" "):
92
+ current_line += 1
93
+
94
+ return file_lines
95
+
96
+
97
+ def resolve_changed_functions(g, file_lines: dict[str, set[int]]) -> list[ChangedFunction]:
98
+ """Queries FalkorDB to find Function nodes spanning the modified lines."""
99
+ changed_funcs: dict[str, ChangedFunction] = {}
100
+
101
+ for file_path, lines in file_lines.items():
102
+ if not lines:
103
+ continue
104
+
105
+ # In FalkorDB, File nodes use paths like 'src/flask/config.py'
106
+ # Query functions defined in this file
107
+ q = (
108
+ "MATCH (f:Function) "
109
+ "WHERE f.file = $file OR f.file ENDS WITH $file OR $file ENDS WITH f.file "
110
+ "RETURN f.id, f.file, f.name, f.start, f.end, coalesce(f.no_evidence, false), coalesce(f.has_dynamic_dispatch, false)"
111
+ )
112
+ res = g.query(q, {"file": file_path}).result_set
113
+
114
+ for fid, ffile, fname, start, end, no_evidence, dyn in res:
115
+ start_i = int(start)
116
+ end_i = int(end)
117
+ # Check if any modified line falls within [start, end]
118
+ if any(start_i <= lineno <= end_i for lineno in lines):
119
+ if fid not in changed_funcs:
120
+ changed_funcs[fid] = ChangedFunction(
121
+ id=fid,
122
+ file=ffile,
123
+ name=fname,
124
+ start=start_i,
125
+ end=end_i,
126
+ no_evidence=bool(no_evidence),
127
+ has_dynamic_dispatch=bool(dyn),
128
+ )
129
+
130
+ return list(changed_funcs.values())
131
+
132
+
133
+ def thin_slice_decision(
134
+ g,
135
+ changed_functions: list[ChangedFunction],
136
+ test_files_touched: bool = False,
137
+ no_confidence_filter: bool = False,
138
+ no_dynamic_widening: bool = False,
139
+ ) -> CheckResult:
140
+ """Decision logic:
141
+ - If test files were touched or no functions matched -> run all.
142
+ - If ANY changed function has no_evidence=true (and not no_confidence_filter) -> run all.
143
+ - Otherwise -> collect covering tests via COVERED_BY edges (with confidence filter unless ablated).
144
+ - If dynamic dispatch is present and widening enabled -> include module tests.
145
+ """
146
+ if test_files_touched:
147
+ return CheckResult(
148
+ status="run-all",
149
+ is_fallback=True,
150
+ fallback_reason="Test files were modified in diff",
151
+ changed_functions=[f.id for f in changed_functions],
152
+ selected_tests=["tests"],
153
+ )
154
+
155
+ if not changed_functions:
156
+ return CheckResult(
157
+ status="run-all",
158
+ is_fallback=True,
159
+ fallback_reason="No parsed functions matched diff line changes",
160
+ changed_functions=[],
161
+ selected_tests=["tests"],
162
+ )
163
+
164
+ # Hard override: If any function has no evidence -> fallback to run all (unless confidence filter ablated)
165
+ if not no_confidence_filter:
166
+ for fn in changed_functions:
167
+ if fn.no_evidence:
168
+ return CheckResult(
169
+ status="run-all",
170
+ is_fallback=True,
171
+ fallback_reason=f"Uncovered function has no_evidence=true: {fn.id}",
172
+ changed_functions=[f.id for f in changed_functions],
173
+ selected_tests=["tests"],
174
+ )
175
+
176
+ # Collect covering tests from graph
177
+ all_tests = set()
178
+ min_conf = 0.0 if no_confidence_filter else 0.8
179
+ for fn in changed_functions:
180
+ covering = db.get_covering_tests(g, fn.id, min_confidence=min_conf) if g is not None else []
181
+ fn_tests = [c["test_id"] for c in covering]
182
+ if not fn_tests and not no_confidence_filter:
183
+ # No covering tests found
184
+ return CheckResult(
185
+ status="run-all",
186
+ is_fallback=True,
187
+ fallback_reason=f"No covering tests found with confidence >= 0.8 for: {fn.id}",
188
+ changed_functions=[f.id for f in changed_functions],
189
+ selected_tests=["tests"],
190
+ )
191
+ fn.covering_tests = fn_tests
192
+ all_tests.update(fn_tests)
193
+
194
+ # Dynamic dispatch widening
195
+ if not no_dynamic_widening and g is not None:
196
+ dynamic_funcs = [fn for fn in changed_functions if fn.has_dynamic_dispatch]
197
+ if dynamic_funcs:
198
+ for fn in dynamic_funcs:
199
+ stem = Path(fn.file).stem
200
+ mod_q = "MATCH (t:Test) WHERE t.file CONTAINS $mod_stem RETURN t.id"
201
+ try:
202
+ mod_res = g.query(mod_q, {"mod_stem": f"test_{stem}"}).result_set
203
+ for r in mod_res:
204
+ all_tests.add(r[0])
205
+ except Exception:
206
+ pass
207
+
208
+ if not all_tests:
209
+ return CheckResult(
210
+ status="run-all",
211
+ is_fallback=True,
212
+ fallback_reason="No covering tests found",
213
+ changed_functions=[f.id for f in changed_functions],
214
+ selected_tests=["tests"],
215
+ )
216
+
217
+ sorted_tests = sorted(all_tests)
218
+ return CheckResult(
219
+ status="select",
220
+ is_fallback=False,
221
+ fallback_reason=None,
222
+ changed_functions=[f.id for f in changed_functions],
223
+ selected_tests=sorted_tests,
224
+ )
225
+
226
+
227
+ def guard_check(
228
+ diff_text: str | None = None,
229
+ diff_file: Path | str | None = None,
230
+ repo_path: Path | str | None = None,
231
+ graph_name: str = "guard",
232
+ no_confidence_filter: bool = False,
233
+ no_dynamic_widening: bool = False,
234
+ ) -> CheckResult:
235
+ """Evaluates a diff and returns the test selection decision."""
236
+ import time
237
+ start_time = time.time()
238
+
239
+ if diff_text is None:
240
+ if diff_file:
241
+ diff_text = Path(diff_file).read_text(encoding="utf-8")
242
+ else:
243
+ # Run git diff HEAD in repo_path
244
+ cwd = str(repo_path) if repo_path else None
245
+ proc = subprocess.run(["git", "diff", "HEAD"], cwd=cwd, capture_output=True, text=True)
246
+ diff_text = proc.stdout
247
+
248
+ file_lines = parse_unified_diff(diff_text)
249
+
250
+ # Check if test files were touched
251
+ test_files_touched = any(
252
+ "test" in p.lower() or p.startswith("tests/")
253
+ for p in file_lines.keys()
254
+ )
255
+
256
+ # Resolve unique repo graph name if available
257
+ if repo_path and (graph_name == "guard" or graph_name is None):
258
+ try:
259
+ from guard.backend import get_repo_graph_name
260
+ repo_graph = get_repo_graph_name(repo_path)
261
+ cand = db.get_graph(repo_graph, repo_path=repo_path)
262
+ counts = db.get_schema_counts(cand)
263
+ if sum(counts.values()) > 0:
264
+ graph_name = repo_graph
265
+ except Exception:
266
+ pass
267
+
268
+ g = db.get_graph(graph_name, repo_path=repo_path)
269
+ changed_funcs = resolve_changed_functions(g, file_lines)
270
+ result = thin_slice_decision(
271
+ g,
272
+ changed_funcs,
273
+ test_files_touched=test_files_touched,
274
+ no_confidence_filter=no_confidence_filter,
275
+ no_dynamic_widening=no_dynamic_widening,
276
+ )
277
+ result.execution_time_ms = round((time.time() - start_time) * 1000.0, 2)
278
+ return result
guard_cli/__init__.py ADDED
@@ -0,0 +1,37 @@
1
+ """guard_cli: Graph-powered safety layer for AI coding agents."""
2
+ import guard
3
+ from guard import __version__
4
+ from guard import (
5
+ api,
6
+ backend,
7
+ cli,
8
+ db,
9
+ decision,
10
+ doctor_cmd,
11
+ events,
12
+ feedback,
13
+ hook,
14
+ init_cmd,
15
+ state,
16
+ thin_slice,
17
+ ingest,
18
+ integrations,
19
+ )
20
+
21
+ __all__ = [
22
+ "__version__",
23
+ "api",
24
+ "backend",
25
+ "cli",
26
+ "db",
27
+ "decision",
28
+ "doctor_cmd",
29
+ "events",
30
+ "feedback",
31
+ "hook",
32
+ "init_cmd",
33
+ "state",
34
+ "thin_slice",
35
+ "ingest",
36
+ "integrations",
37
+ ]
guard_cli/api.py ADDED
@@ -0,0 +1,2 @@
1
+ """guard_cli.api forwarding module."""
2
+ from guard.api import *
guard_cli/backend.py ADDED
@@ -0,0 +1,2 @@
1
+ """guard_cli.backend forwarding module."""
2
+ from guard.backend import *
guard_cli/cli.py ADDED
@@ -0,0 +1,3 @@
1
+ """guard_cli.cli forwarding module."""
2
+ from guard.cli import *
3
+ from guard.cli import main
guard_cli/db.py ADDED
@@ -0,0 +1,2 @@
1
+ """guard_cli.db forwarding module."""
2
+ from guard.db import *
guard_cli/decision.py ADDED
@@ -0,0 +1,2 @@
1
+ """guard_cli.decision forwarding module."""
2
+ from guard.decision import *
@@ -0,0 +1,2 @@
1
+ """guard_cli.doctor_cmd forwarding module."""
2
+ from guard.doctor_cmd import *
guard_cli/events.py ADDED
@@ -0,0 +1,2 @@
1
+ """guard_cli.events forwarding module."""
2
+ from guard.events import *
guard_cli/feedback.py ADDED
@@ -0,0 +1,2 @@
1
+ """guard_cli.feedback forwarding module."""
2
+ from guard.feedback import *
guard_cli/hook.py ADDED
@@ -0,0 +1,2 @@
1
+ """guard_cli.hook forwarding module."""
2
+ from guard.hook import *
@@ -0,0 +1 @@
1
+ from guard.ingest import *
@@ -0,0 +1 @@
1
+ from guard.ingest.coverage import *
@@ -0,0 +1 @@
1
+ from guard.ingest.static import *
guard_cli/init_cmd.py ADDED
@@ -0,0 +1,2 @@
1
+ """guard_cli.init_cmd forwarding module."""
2
+ from guard.init_cmd import *
@@ -0,0 +1 @@
1
+ from guard.integrations import *
@@ -0,0 +1 @@
1
+ from guard.integrations.claude import *
guard_cli/state.py ADDED
@@ -0,0 +1,2 @@
1
+ """guard_cli.state forwarding module."""
2
+ from guard.state import *
@@ -0,0 +1,2 @@
1
+ """guard_cli.thin_slice forwarding module."""
2
+ from guard.thin_slice import *