guard-cli 0.1.0rc1__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,119 @@
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) -> bool:
23
+ """Applies patch to target repo."""
24
+ proc = subprocess.run(["git", "apply", str(diff_file)], cwd=str(REPO_ROOT), capture_output=True, text=True)
25
+ if proc.returncode != 0:
26
+ patch_proc = subprocess.run(["patch", "-p1", "-i", str(diff_file)], cwd=str(REPO_ROOT), capture_output=True)
27
+ return patch_proc.returncode == 0
28
+ return True
29
+
30
+
31
+ def reset_repo():
32
+ """Restores target repo to clean state."""
33
+ subprocess.run(["git", "checkout", "--", "."], cwd=str(REPO_ROOT), capture_output=True)
34
+ subprocess.run(["git", "clean", "-fdx"], cwd=str(REPO_ROOT), capture_output=True)
35
+
36
+
37
+ def count_collected_tests(test_targets: list[str]) -> int:
38
+ """Counts how many test items are collected for the targets."""
39
+ cmd = [sys.executable, "-m", "pytest", "--collect-only", "-q"] + test_targets
40
+ proc = subprocess.run(cmd, cwd=str(REPO_ROOT), capture_output=True, text=True)
41
+ lines = proc.stdout.strip().splitlines()
42
+ for line in reversed(lines):
43
+ if "test" in line:
44
+ parts = line.split()
45
+ for p in parts:
46
+ if p.isdigit():
47
+ return int(p)
48
+ return len(test_targets)
49
+
50
+
51
+ def run_agent_guard(
52
+ diff_file: str | Path | None = None,
53
+ repo_path: str | Path | None = None,
54
+ graph_name: str = "guard",
55
+ ) -> tuple[dict[str, Any], int]:
56
+ """Runs Guard check and executes tests according to trust level.
57
+
58
+ Returns:
59
+ (report_dict, exit_code)
60
+ """
61
+ repo = Path(repo_path).resolve() if repo_path else REPO_ROOT
62
+ diff_path = Path(diff_file).resolve() if diff_file else None
63
+
64
+ # 1. Run guard check
65
+ check_res = guard_check(
66
+ diff_file=diff_path,
67
+ repo_path=repo,
68
+ graph_name=graph_name,
69
+ )
70
+
71
+ forced_full = check_res.is_fallback or (check_res.status == "run-all")
72
+ tests_to_run = ["tests"] if forced_full else check_res.selected_tests
73
+
74
+ # 2. If a patch file was passed, apply it temporarily for test execution
75
+ applied = False
76
+ if diff_path and diff_path.exists():
77
+ reset_repo()
78
+ applied = apply_patch(diff_path)
79
+ if not applied:
80
+ return {
81
+ "status": "error",
82
+ "forced_full": True,
83
+ "fallback_reason": "Failed to apply patch diff",
84
+ "changed_functions": [],
85
+ "tests_run": [],
86
+ "tests_run_count": 0,
87
+ "duration_s": 0.0,
88
+ "tests_passed": False,
89
+ "exit_code": 1,
90
+ "output_summary": "Error: Failed to apply patch cleanly to target repo.",
91
+ }, 1
92
+
93
+ try:
94
+ # Count collected tests
95
+ test_count = count_collected_tests(tests_to_run)
96
+
97
+ # 3. Execute pytest
98
+ cmd = [sys.executable, "-m", "pytest", "-q", "--tb=short"] + tests_to_run
99
+ start = time.time()
100
+ proc = subprocess.run(cmd, cwd=str(repo), capture_output=True, text=True)
101
+ duration_s = time.time() - start
102
+
103
+ passed = (proc.returncode == 0)
104
+ report = {
105
+ "status": check_res.status,
106
+ "forced_full": forced_full,
107
+ "fallback_reason": check_res.fallback_reason,
108
+ "changed_functions": check_res.changed_functions,
109
+ "tests_run": tests_to_run,
110
+ "tests_run_count": test_count,
111
+ "duration_s": round(duration_s, 3),
112
+ "tests_passed": passed,
113
+ "exit_code": proc.returncode,
114
+ "output_summary": proc.stdout.strip().splitlines()[-1] if proc.stdout.strip() else "",
115
+ }
116
+ return report, proc.returncode
117
+ finally:
118
+ if applied:
119
+ reset_repo()
guard/cli.py ADDED
@@ -0,0 +1,194 @@
1
+ """Guard CLI. Commands: ingest-static, callers, stats, covering-tests."""
2
+ from __future__ import annotations
3
+ import argparse
4
+ import json
5
+ import sys
6
+
7
+ from guard.ingest.static import analyze_repo
8
+
9
+
10
+ def cmd_ingest_static(args) -> int:
11
+ res = analyze_repo(args.repo, include_tests=args.include_tests)
12
+ print(json.dumps(res.summary(), indent=2))
13
+ for err in res.parse_errors[:5]:
14
+ print("parse error:", err, file=sys.stderr)
15
+ if args.dry_run:
16
+ return 0
17
+ from guard import db
18
+
19
+ g = db.get_graph(args.graph)
20
+ db.reset_graph(g)
21
+ g = db.get_graph(args.graph)
22
+ db.ensure_schema(g)
23
+ db.write_static(g, res, commit_sha=args.commit_sha)
24
+ print(f"written to graph '{args.graph}'")
25
+ return 0
26
+
27
+
28
+ def cmd_callers(args) -> int:
29
+ from guard import db
30
+
31
+ g = db.get_graph(args.graph)
32
+ hops = int(args.hops)
33
+ callers = db.get_callers(g, args.function, hops=hops, min_confidence=float(args.min_confidence))
34
+ for c in callers:
35
+ print(f"{c['hops']} {c['caller']}")
36
+ return 0
37
+
38
+
39
+ def cmd_stats(args) -> int:
40
+ from guard import db
41
+
42
+ g = db.get_graph(args.graph)
43
+ counts = db.get_schema_counts(g)
44
+ print("=== Nodes ===")
45
+ for label in ("File", "Function", "Test", "Run"):
46
+ print(f"{label}: {counts.get(label, 0)}")
47
+ print("\n=== Edges ===")
48
+ for rel in ("CALLS", "IMPORTS", "DEFINED_IN", "COVERED_BY", "FAILED_IN"):
49
+ print(f"{rel}: {counts.get(rel, 0)}")
50
+ print(f"\nDynamic functions: {counts.get('dynamic_functions', 0)}")
51
+ return 0
52
+
53
+
54
+ def cmd_covering_tests(args) -> int:
55
+ from guard import db
56
+
57
+ g = db.get_graph(args.graph)
58
+ tests = db.get_covering_tests(g, args.function, min_confidence=float(args.min_confidence))
59
+ for t in tests:
60
+ print(f"[{t['confidence']:.2f}] {t['test_id']} (source: {t['source']})")
61
+ return 0
62
+
63
+
64
+ def cmd_ingest_coverage(args) -> int:
65
+ from guard.ingest.coverage import ingest_coverage
66
+
67
+ res = ingest_coverage(
68
+ repo_path=args.repo,
69
+ graph_name=args.graph,
70
+ commit_sha=args.commit_sha,
71
+ num_runs=args.runs,
72
+ )
73
+ print(json.dumps(res.summary(), indent=2))
74
+ return 0
75
+
76
+
77
+ def cmd_check(args) -> int:
78
+ from guard.thin_slice import guard_check
79
+
80
+ res = guard_check(
81
+ diff_file=args.diff,
82
+ repo_path=args.repo,
83
+ graph_name=args.graph,
84
+ )
85
+ if args.json:
86
+ print(json.dumps(res.to_dict(), indent=2))
87
+ else:
88
+ print(f"Status: {res.status}")
89
+ print(f"Fallback: {res.is_fallback}")
90
+ if res.fallback_reason:
91
+ print(f"Reason: {res.fallback_reason}")
92
+ print(f"Changed Functions ({len(res.changed_functions)}): {res.changed_functions}")
93
+ print(f"Selected Tests ({len(res.selected_tests)}): {res.selected_tests}")
94
+ return 0
95
+
96
+
97
+ def cmd_feedback(args) -> int:
98
+ from guard import db
99
+ from guard.feedback import record_miss
100
+
101
+ g = db.get_graph(args.graph)
102
+ res = record_miss(
103
+ g,
104
+ function_id=args.function,
105
+ test_id=args.test,
106
+ new_confidence=args.confidence,
107
+ )
108
+ if args.json:
109
+ print(json.dumps(res, indent=2))
110
+ else:
111
+ print(f"Repaired: function='{res['function']}' -> test='{res['test']}'")
112
+ print(f"Confidence: {res['old_confidence']} -> {res['new_confidence']} (source: {res['source']})")
113
+ return 0
114
+
115
+
116
+ def cmd_run(args) -> int:
117
+ from guard.agent_integration import run_agent_guard
118
+
119
+ report, exit_code = run_agent_guard(
120
+ diff_file=args.diff,
121
+ repo_path=args.repo,
122
+ graph_name=args.graph,
123
+ )
124
+ if args.json:
125
+ print(json.dumps(report, indent=2))
126
+ else:
127
+ print(f"Status: {report['status']}")
128
+ print(f"Forced Full Suite: {report['forced_full']}")
129
+ if report['fallback_reason']:
130
+ print(f"Fallback Reason: {report['fallback_reason']}")
131
+ print(f"Tests Run ({report['tests_run_count']}): {report['tests_run']}")
132
+ print(f"Passed: {report['tests_passed']} in {report['duration_s']}s")
133
+ if report['output_summary']:
134
+ print(f"Summary: {report['output_summary']}")
135
+ return exit_code
136
+
137
+
138
+ def main(argv=None) -> int:
139
+ p = argparse.ArgumentParser(prog="guard")
140
+ p.add_argument("--graph", default="guard")
141
+ sub = p.add_subparsers(dest="cmd", required=True)
142
+
143
+ rn = sub.add_parser("run")
144
+ rn.add_argument("--diff", default=None, help="path to unified diff file")
145
+ rn.add_argument("--repo", default=None, help="path to target repo")
146
+ rn.add_argument("--json", action="store_true", help="output JSON")
147
+ rn.set_defaults(fn=cmd_run)
148
+
149
+ chk = sub.add_parser("check")
150
+ chk.add_argument("--diff", default=None, help="path to unified diff file")
151
+ chk.add_argument("--repo", default=None, help="path to repo to run git diff HEAD")
152
+ chk.add_argument("--json", action="store_true", help="output JSON")
153
+ chk.set_defaults(fn=cmd_check)
154
+
155
+ fb = sub.add_parser("feedback")
156
+ fb.add_argument("--function", required=True, help="Function ID where fault was missed")
157
+ fb.add_argument("--test", required=True, help="Test ID that failed and was missed")
158
+ fb.add_argument("--confidence", default=1.0, type=float, help="Confidence of repair edge (default: 1.0)")
159
+ fb.add_argument("--json", action="store_true", help="output JSON")
160
+ fb.set_defaults(fn=cmd_feedback)
161
+
162
+ s = sub.add_parser("ingest-static")
163
+ s.add_argument("repo")
164
+ s.add_argument("--dry-run", action="store_true", help="parse only, no database")
165
+ s.add_argument("--include-tests", action="store_true")
166
+ s.add_argument("--commit-sha", default=None, help="commit SHA to associate with edges")
167
+ s.set_defaults(fn=cmd_ingest_static)
168
+
169
+ ic = sub.add_parser("ingest-coverage")
170
+ ic.add_argument("repo")
171
+ ic.add_argument("--runs", default=3, type=int, help="number of test runs for flake measurement")
172
+ ic.add_argument("--commit-sha", default=None, help="commit SHA to associate with edges")
173
+ ic.set_defaults(fn=cmd_ingest_coverage)
174
+
175
+ c = sub.add_parser("callers")
176
+ c.add_argument("function", help="function id, e.g. pkg/mod.py::Class.method")
177
+ c.add_argument("--hops", default=4, type=int)
178
+ c.add_argument("--min-confidence", default=0.0, type=float)
179
+ c.set_defaults(fn=cmd_callers)
180
+
181
+ st = sub.add_parser("stats")
182
+ st.set_defaults(fn=cmd_stats)
183
+
184
+ cov = sub.add_parser("covering-tests")
185
+ cov.add_argument("function", help="function id, e.g. pkg/mod.py::Class.method")
186
+ cov.add_argument("--min-confidence", default=0.0, type=float)
187
+ cov.set_defaults(fn=cmd_covering_tests)
188
+
189
+ args = p.parse_args(argv)
190
+ return args.fn(args)
191
+
192
+
193
+ if __name__ == "__main__":
194
+ raise SystemExit(main())
guard/db.py ADDED
@@ -0,0 +1,243 @@
1
+ """FalkorDB connection, complete schema, and batched writers for Guard (Task T2)."""
2
+ from __future__ import annotations
3
+ import os
4
+ from typing import Any, Iterable, Sequence
5
+ from falkordb import FalkorDB
6
+
7
+ GRAPH_NAME = os.getenv("GUARD_GRAPH", "guard")
8
+
9
+ INDEXES = [
10
+ "CREATE INDEX FOR (f:Function) ON (f.id)",
11
+ "CREATE INDEX FOR (t:Test) ON (t.id)",
12
+ "CREATE INDEX FOR (x:File) ON (x.path)",
13
+ "CREATE INDEX FOR (r:Run) ON (r.id)",
14
+ ]
15
+
16
+
17
+ def get_graph(name: str = GRAPH_NAME):
18
+ db = FalkorDB(
19
+ host=os.getenv("FALKORDB_HOST", "localhost"),
20
+ port=int(os.getenv("FALKORDB_PORT", "6379")),
21
+ )
22
+ return db.select_graph(name)
23
+
24
+
25
+ def reset_graph(g) -> None:
26
+ try:
27
+ g.delete()
28
+ except Exception:
29
+ pass # graph did not exist yet
30
+
31
+
32
+ def ensure_schema(g) -> None:
33
+ for q in INDEXES:
34
+ try:
35
+ g.query(q)
36
+ except Exception:
37
+ pass # index already exists
38
+
39
+
40
+ def _batches(rows: Sequence[Any], size: int = 500) -> Iterable[Sequence[Any]]:
41
+ for i in range(0, len(rows), size):
42
+ yield rows[i : i + size]
43
+
44
+
45
+ def write_static(g, result, commit_sha: str | None = None) -> None:
46
+ """Write StaticResult nodes and edges into FalkorDB with T2 schema requirements."""
47
+ # 1. File nodes
48
+ for b in _batches(result.file_rows()):
49
+ g.query("UNWIND $rows AS r MERGE (x:File {path: r.path})", {"rows": b})
50
+
51
+ # 2. Function nodes & DEFINED_IN edges
52
+ for b in _batches(result.function_rows()):
53
+ g.query(
54
+ """UNWIND $rows AS r
55
+ MERGE (f:Function {id: r.id})
56
+ SET f.file = r.file, f.name = r.name, f.qualname = r.qualname,
57
+ f.start = r.start, f.end = r.end,
58
+ f.has_dynamic_dispatch = r.dyn
59
+ WITH f, r
60
+ MATCH (x:File {path: r.file})
61
+ MERGE (f)-[:DEFINED_IN]->(x)""",
62
+ {"rows": b},
63
+ )
64
+
65
+ # 3. Test nodes & DEFINED_IN edges
66
+ if hasattr(result, "test_rows"):
67
+ test_rows = result.test_rows()
68
+ else:
69
+ test_rows = []
70
+
71
+ for b in _batches(test_rows):
72
+ g.query(
73
+ """UNWIND $rows AS r
74
+ MERGE (t:Test {id: r.id})
75
+ SET t.file = r.file, t.name = r.name, t.qualname = r.qualname,
76
+ t.start = r.start, t.end = r.end
77
+ WITH t, r
78
+ MATCH (x:File {path: r.file})
79
+ MERGE (t)-[:DEFINED_IN]->(x)""",
80
+ {"rows": b},
81
+ )
82
+
83
+ # 4. CALLS edges (Function -> Function)
84
+ for b in _batches(result.call_rows()):
85
+ g.query(
86
+ """UNWIND $rows AS r
87
+ MATCH (a:Function {id: r.src}), (b:Function {id: r.dst})
88
+ MERGE (a)-[e:CALLS]->(b)
89
+ SET e.source = 'static',
90
+ e.confidence = 0.6,
91
+ e.evidence_count = coalesce(e.evidence_count, 0),
92
+ e.last_verified_sha = $sha""",
93
+ {"rows": b, "sha": commit_sha},
94
+ )
95
+
96
+ # 5. IMPORTS edges (File -> File)
97
+ for b in _batches(result.import_rows()):
98
+ g.query(
99
+ """UNWIND $rows AS r
100
+ MATCH (a:File {path: r.src}), (b:File {path: r.dst})
101
+ MERGE (a)-[e:IMPORTS]->(b)
102
+ SET e.source = 'static',
103
+ e.confidence = 0.6,
104
+ e.last_verified_sha = $sha""",
105
+ {"rows": b, "sha": commit_sha},
106
+ )
107
+
108
+
109
+ def write_coverage(
110
+ g,
111
+ test_to_funcs: dict[str, list[str]] | list[dict[str, str]],
112
+ commit_sha: str | None = None,
113
+ confidence: float = 0.9,
114
+ ) -> int:
115
+ """Write COVERED_BY edges from Function to Test nodes.
116
+
117
+ Args:
118
+ test_to_funcs: Mapping test_id -> list of function_ids, or list of {'test': id, 'func': id}
119
+ commit_sha: Commit SHA when coverage was verified
120
+ confidence: Confidence score (default 0.9 for dynamic coverage evidence)
121
+
122
+ Returns:
123
+ Number of coverage pairs written
124
+ """
125
+ if isinstance(test_to_funcs, dict):
126
+ pairs = [
127
+ {"test": t_id, "func": f_id}
128
+ for t_id, funcs in test_to_funcs.items()
129
+ for f_id in funcs
130
+ ]
131
+ else:
132
+ pairs = list(test_to_funcs)
133
+
134
+ for b in _batches(pairs):
135
+ g.query(
136
+ """UNWIND $rows AS r
137
+ MATCH (f:Function {id: r.func}), (t:Test {id: r.test})
138
+ MERGE (f)-[e:COVERED_BY]->(t)
139
+ SET e.source = 'coverage',
140
+ e.confidence = $conf,
141
+ e.evidence_count = coalesce(e.evidence_count, 0) + 1,
142
+ e.last_verified_sha = $sha""",
143
+ {"rows": b, "conf": confidence, "sha": commit_sha},
144
+ )
145
+ return len(pairs)
146
+
147
+
148
+ def write_run(
149
+ g,
150
+ run_data: dict[str, Any],
151
+ failures: list[dict[str, Any]] | None = None,
152
+ ) -> None:
153
+ """Write a Run node and optional FAILED_IN edges.
154
+
155
+ Args:
156
+ run_data: Dict with keys: id, timestamp, commit_sha, duration_ms, total_tests, passed, failed
157
+ failures: List of dicts: {'test_id': str, 'error': str}
158
+ """
159
+ g.query(
160
+ """MERGE (r:Run {id: $r.id})
161
+ SET r.timestamp = $r.timestamp,
162
+ r.commit_sha = $r.commit_sha,
163
+ r.duration_ms = $r.duration_ms,
164
+ r.total_tests = $r.total_tests,
165
+ r.passed = $r.passed,
166
+ r.failed = $r.failed""",
167
+ {"r": run_data},
168
+ )
169
+
170
+ if failures:
171
+ for b in _batches(failures):
172
+ g.query(
173
+ """UNWIND $rows AS f
174
+ MATCH (t:Test {id: f.test_id}), (r:Run {id: $run_id})
175
+ MERGE (t)-[e:FAILED_IN]->(r)
176
+ SET e.source = 'test_run',
177
+ e.error_message = f.error,
178
+ e.commit_sha = $sha""",
179
+ {
180
+ "rows": b,
181
+ "run_id": run_data["id"],
182
+ "sha": run_data.get("commit_sha"),
183
+ },
184
+ )
185
+
186
+
187
+ def get_schema_counts(g) -> dict[str, int]:
188
+ """Return dictionary with counts of all nodes and relationships."""
189
+ counts = {}
190
+ for label in ("File", "Function", "Test", "Run"):
191
+ try:
192
+ res = g.query(f"MATCH (n:{label}) RETURN count(n)").result_set
193
+ counts[label] = res[0][0] if res else 0
194
+ except Exception:
195
+ counts[label] = 0
196
+
197
+ for rel in ("CALLS", "IMPORTS", "DEFINED_IN", "COVERED_BY", "FAILED_IN"):
198
+ try:
199
+ res = g.query(f"MATCH ()-[e:{rel}]->() RETURN count(e)").result_set
200
+ counts[rel] = res[0][0] if res else 0
201
+ except Exception:
202
+ counts[rel] = 0
203
+
204
+ try:
205
+ res = g.query(
206
+ "MATCH (f:Function {has_dynamic_dispatch: true}) RETURN count(f)"
207
+ ).result_set
208
+ counts["dynamic_functions"] = res[0][0] if res else 0
209
+ except Exception:
210
+ counts["dynamic_functions"] = 0
211
+
212
+ return counts
213
+
214
+
215
+ def get_callers(
216
+ g, function_id: str, hops: int = 4, min_confidence: float = 0.0
217
+ ) -> list[dict[str, Any]]:
218
+ """Traverse inbound CALLS edges up to N hops filtered by min_confidence."""
219
+ q = (
220
+ f"MATCH p=(caller:Function)-[:CALLS*1..{hops}]->(target:Function {{id: $id}}) "
221
+ "WHERE all(e IN relationships(p) WHERE coalesce(e.confidence, 0.6) >= $min_conf) "
222
+ "RETURN caller.id AS caller, min(length(p)) AS hops "
223
+ "ORDER BY hops, caller"
224
+ )
225
+ res = g.query(q, {"id": function_id, "min_conf": min_confidence}).result_set
226
+ return [{"caller": row[0], "hops": row[1]} for row in res]
227
+
228
+
229
+ def get_covering_tests(
230
+ g, function_id: str, min_confidence: float = 0.0
231
+ ) -> list[dict[str, Any]]:
232
+ """Find tests covering a given function, ordered by confidence."""
233
+ q = (
234
+ "MATCH (f:Function {id: $id})-[e:COVERED_BY]->(t:Test) "
235
+ "WHERE e.confidence >= $min_conf "
236
+ "RETURN t.id AS test_id, e.confidence AS confidence, e.source AS source "
237
+ "ORDER BY confidence DESC"
238
+ )
239
+ res = g.query(q, {"id": function_id, "min_conf": min_confidence}).result_set
240
+ return [
241
+ {"test_id": row[0], "confidence": row[1], "source": row[2]}
242
+ for row in res
243
+ ]