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/cli.py
ADDED
|
@@ -0,0 +1,320 @@
|
|
|
1
|
+
"""Guard CLI.
|
|
2
|
+
|
|
3
|
+
Commands:
|
|
4
|
+
- `check`: Evaluate proposed changes and select tests.
|
|
5
|
+
- `run`: Execute selected tests according to trust policy.
|
|
6
|
+
- `hook`: Manage and run agent lifecycle hooks (e.g. Claude Code stop hook).
|
|
7
|
+
- `covering-tests`: Show tests covering a function.
|
|
8
|
+
- `callers`: Show multi-hop callers of a function.
|
|
9
|
+
- `feedback`: Record an escape / missed fault for graph repair.
|
|
10
|
+
- `ingest-static`: Ingest static AST call graph into FalkorDB.
|
|
11
|
+
- `ingest-coverage`: Ingest pytest-cov execution traces into FalkorDB.
|
|
12
|
+
- `stats`: Display graph node and relationship counts.
|
|
13
|
+
"""
|
|
14
|
+
from __future__ import annotations
|
|
15
|
+
import argparse
|
|
16
|
+
import json
|
|
17
|
+
import sys
|
|
18
|
+
|
|
19
|
+
from guard.ingest.static import analyze_repo
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def cmd_ingest_static(args) -> int:
|
|
23
|
+
res = analyze_repo(args.repo, include_tests=args.include_tests)
|
|
24
|
+
print(json.dumps(res.summary(), indent=2))
|
|
25
|
+
for err in res.parse_errors[:5]:
|
|
26
|
+
print("parse error:", err, file=sys.stderr)
|
|
27
|
+
if args.dry_run:
|
|
28
|
+
return 0
|
|
29
|
+
from guard import db
|
|
30
|
+
|
|
31
|
+
g = db.get_graph(args.graph)
|
|
32
|
+
db.reset_graph(g)
|
|
33
|
+
g = db.get_graph(args.graph)
|
|
34
|
+
db.ensure_schema(g)
|
|
35
|
+
db.write_static(g, res, commit_sha=args.commit_sha)
|
|
36
|
+
print(f"written to graph '{args.graph}'")
|
|
37
|
+
return 0
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def cmd_callers(args) -> int:
|
|
41
|
+
from guard import api
|
|
42
|
+
|
|
43
|
+
hops = int(args.hops)
|
|
44
|
+
callers = api.callers(
|
|
45
|
+
args.function,
|
|
46
|
+
hops=hops,
|
|
47
|
+
min_confidence=float(args.min_confidence),
|
|
48
|
+
graph_name=args.graph,
|
|
49
|
+
strict=getattr(args, "strict", False),
|
|
50
|
+
)
|
|
51
|
+
for c in callers:
|
|
52
|
+
print(f"{c['hops']} {c['caller']}")
|
|
53
|
+
return 0
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def cmd_stats(args) -> int:
|
|
57
|
+
from guard import db
|
|
58
|
+
|
|
59
|
+
g = db.get_graph(args.graph)
|
|
60
|
+
counts = db.get_schema_counts(g)
|
|
61
|
+
print("=== Nodes ===")
|
|
62
|
+
for label in ("File", "Function", "Test", "Run"):
|
|
63
|
+
print(f"{label}: {counts.get(label, 0)}")
|
|
64
|
+
print("\n=== Edges ===")
|
|
65
|
+
for rel in ("CALLS", "IMPORTS", "DEFINED_IN", "COVERED_BY", "FAILED_IN"):
|
|
66
|
+
print(f"{rel}: {counts.get(rel, 0)}")
|
|
67
|
+
print(f"\nDynamic functions: {counts.get('dynamic_functions', 0)}")
|
|
68
|
+
return 0
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
def cmd_covering_tests(args) -> int:
|
|
72
|
+
from guard import api
|
|
73
|
+
|
|
74
|
+
tests = api.covering_tests(
|
|
75
|
+
args.function,
|
|
76
|
+
min_confidence=float(args.min_confidence),
|
|
77
|
+
graph_name=args.graph,
|
|
78
|
+
strict=getattr(args, "strict", False),
|
|
79
|
+
)
|
|
80
|
+
for t in tests:
|
|
81
|
+
print(f"[{t['confidence']:.2f}] {t['test_id']} (source: {t['source']})")
|
|
82
|
+
return 0
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
def cmd_ingest_coverage(args) -> int:
|
|
86
|
+
from guard.ingest.coverage import ingest_coverage
|
|
87
|
+
|
|
88
|
+
res = ingest_coverage(
|
|
89
|
+
repo_path=args.repo,
|
|
90
|
+
graph_name=args.graph,
|
|
91
|
+
commit_sha=args.commit_sha,
|
|
92
|
+
num_runs=args.runs,
|
|
93
|
+
)
|
|
94
|
+
print(json.dumps(res.summary(), indent=2))
|
|
95
|
+
return 0
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
def cmd_check(args) -> int:
|
|
99
|
+
from guard import api
|
|
100
|
+
|
|
101
|
+
res = api.check(
|
|
102
|
+
diff_file=args.diff,
|
|
103
|
+
repo_path=args.repo,
|
|
104
|
+
graph_name=args.graph,
|
|
105
|
+
strict=getattr(args, "strict", False),
|
|
106
|
+
)
|
|
107
|
+
if args.json:
|
|
108
|
+
print(json.dumps(res, indent=2))
|
|
109
|
+
else:
|
|
110
|
+
print(f"Status: {res['status']}")
|
|
111
|
+
print(f"Policy: {res['policy']} (Trust: {res['trust']:.2f})")
|
|
112
|
+
print(f"Fallback: {res['is_fallback']}")
|
|
113
|
+
if res.get("fallback_reason"):
|
|
114
|
+
print(f"Reason: {res['fallback_reason']}")
|
|
115
|
+
if res.get("overrides_triggered"):
|
|
116
|
+
print(f"Overrides: {', '.join(res['overrides_triggered'])}")
|
|
117
|
+
print(f"Changed Functions ({len(res.get('changed_functions', []))}): {res.get('changed_functions', [])}")
|
|
118
|
+
print(f"Selected Tests ({len(res.get('selected_tests', []))}): {res.get('selected_tests', [])}")
|
|
119
|
+
return 0
|
|
120
|
+
|
|
121
|
+
|
|
122
|
+
def cmd_feedback(args) -> int:
|
|
123
|
+
from guard import api
|
|
124
|
+
|
|
125
|
+
res = api.report_miss(
|
|
126
|
+
function_id=args.function,
|
|
127
|
+
test_id=args.test,
|
|
128
|
+
new_confidence=args.confidence,
|
|
129
|
+
graph_name=args.graph,
|
|
130
|
+
strict=getattr(args, "strict", False),
|
|
131
|
+
)
|
|
132
|
+
if args.json:
|
|
133
|
+
print(json.dumps(res, indent=2))
|
|
134
|
+
else:
|
|
135
|
+
print(f"Repaired: function='{res['function']}' -> test='{res['test']}'")
|
|
136
|
+
print(f"Confidence: {res.get('old_confidence', 0.0)} -> {res.get('new_confidence', 1.0)} (source: {res.get('source')})")
|
|
137
|
+
return 0
|
|
138
|
+
|
|
139
|
+
|
|
140
|
+
def cmd_run(args) -> int:
|
|
141
|
+
from guard import api
|
|
142
|
+
|
|
143
|
+
report, exit_code = api.run(
|
|
144
|
+
diff_file=args.diff,
|
|
145
|
+
repo_path=args.repo,
|
|
146
|
+
graph_name=args.graph,
|
|
147
|
+
strict=getattr(args, "strict", False),
|
|
148
|
+
)
|
|
149
|
+
if args.json:
|
|
150
|
+
print(json.dumps(report, indent=2))
|
|
151
|
+
else:
|
|
152
|
+
print(f"Status: {report['status']}")
|
|
153
|
+
print(f"Policy: {report['policy']} (Trust: {report['trust']:.2f})")
|
|
154
|
+
print(f"Forced Full Suite: {report.get('forced_full', False)}")
|
|
155
|
+
if report.get("fallback_reason"):
|
|
156
|
+
print(f"Fallback Reason: {report['fallback_reason']}")
|
|
157
|
+
print(f"Tests Run ({report.get('tests_run_count', 0)}): {report.get('tests_run', [])}")
|
|
158
|
+
print(f"Passed: {report.get('tests_passed', False)} in {report.get('duration_s', 0.0)}s")
|
|
159
|
+
if report.get("failures"):
|
|
160
|
+
print(f"Failures ({len(report['failures'])}): {report['failures']}")
|
|
161
|
+
if report.get("output_summary"):
|
|
162
|
+
print(f"Summary: {report['output_summary']}")
|
|
163
|
+
return exit_code
|
|
164
|
+
|
|
165
|
+
|
|
166
|
+
def cmd_hook_stop(args) -> int:
|
|
167
|
+
from guard.hook import run_stop_hook
|
|
168
|
+
return run_stop_hook(
|
|
169
|
+
repo_override=args.repo,
|
|
170
|
+
strict=getattr(args, "strict", False),
|
|
171
|
+
)
|
|
172
|
+
|
|
173
|
+
|
|
174
|
+
def cmd_hook_install(args) -> int:
|
|
175
|
+
from guard.integrations import claude
|
|
176
|
+
ok, msg = claude.install(repo_path=args.repo, shared=args.shared)
|
|
177
|
+
print(msg)
|
|
178
|
+
return 0 if ok else 1
|
|
179
|
+
|
|
180
|
+
|
|
181
|
+
def cmd_hook_uninstall(args) -> int:
|
|
182
|
+
from guard.integrations import claude
|
|
183
|
+
ok, msg = claude.uninstall(repo_path=args.repo, shared=args.shared)
|
|
184
|
+
print(msg)
|
|
185
|
+
return 0 if ok else 1
|
|
186
|
+
|
|
187
|
+
|
|
188
|
+
def cmd_hook_status(args) -> int:
|
|
189
|
+
from guard.integrations import claude
|
|
190
|
+
stat = claude.status(repo_path=args.repo, shared=args.shared)
|
|
191
|
+
print(json.dumps(stat, indent=2))
|
|
192
|
+
return 0
|
|
193
|
+
|
|
194
|
+
|
|
195
|
+
def cmd_init(args) -> int:
|
|
196
|
+
from guard.init_cmd import run_init
|
|
197
|
+
|
|
198
|
+
return run_init(
|
|
199
|
+
repo_path=getattr(args, "repo", None),
|
|
200
|
+
backend_preference=getattr(args, "backend", None),
|
|
201
|
+
skip_coverage=getattr(args, "skip_coverage", False),
|
|
202
|
+
auto_yes=getattr(args, "yes", False),
|
|
203
|
+
agents=getattr(args, "agents", "claude,cursor,codex"),
|
|
204
|
+
runs=getattr(args, "runs", None),
|
|
205
|
+
)
|
|
206
|
+
|
|
207
|
+
|
|
208
|
+
def cmd_doctor(args) -> int:
|
|
209
|
+
from guard.doctor_cmd import run_doctor
|
|
210
|
+
|
|
211
|
+
return run_doctor(
|
|
212
|
+
repo_path=getattr(args, "repo", None),
|
|
213
|
+
json_mode=getattr(args, "json", False),
|
|
214
|
+
fix_mode=getattr(args, "fix", False),
|
|
215
|
+
)
|
|
216
|
+
|
|
217
|
+
|
|
218
|
+
def main(argv=None) -> int:
|
|
219
|
+
import guard
|
|
220
|
+
|
|
221
|
+
p = argparse.ArgumentParser(prog="guard")
|
|
222
|
+
p.add_argument("-v", "--version", action="version", version=f"guard {guard.__version__}")
|
|
223
|
+
p.add_argument("--graph", default="guard")
|
|
224
|
+
sub = p.add_subparsers(dest="cmd", required=True)
|
|
225
|
+
|
|
226
|
+
ini = sub.add_parser("init", help="Initialize Guard for repository")
|
|
227
|
+
ini.add_argument("repo", nargs="?", default=None, help="target repository path (default: current directory)")
|
|
228
|
+
ini.add_argument("--backend", choices=["server", "docker", "embedded"], default=None, help="database backend")
|
|
229
|
+
ini.add_argument("--skip-coverage", action="store_true", help="skip per-test coverage ingestion")
|
|
230
|
+
ini.add_argument("-y", "--yes", action="store_true", help="non-interactive mode accepting all defaults")
|
|
231
|
+
ini.add_argument("--agents", default="claude,cursor,codex", help="comma-separated agents to configure")
|
|
232
|
+
ini.add_argument("--runs", type=int, default=None, help="number of baseline test runs (1 or 3)")
|
|
233
|
+
ini.set_defaults(fn=cmd_init)
|
|
234
|
+
|
|
235
|
+
doc = sub.add_parser("doctor", help="Check Guard system health and diagnostics")
|
|
236
|
+
doc.add_argument("repo", nargs="?", default=None, help="target repository path (default: current directory)")
|
|
237
|
+
doc.add_argument("--json", action="store_true", help="output structured JSON")
|
|
238
|
+
doc.add_argument("--fix", action="store_true", help="attempt automated repairs for warnings/failures")
|
|
239
|
+
doc.set_defaults(fn=cmd_doctor)
|
|
240
|
+
|
|
241
|
+
rn = sub.add_parser("run")
|
|
242
|
+
rn.add_argument("--diff", default=None, help="path to unified diff file")
|
|
243
|
+
rn.add_argument("--repo", default=None, help="path to target repo")
|
|
244
|
+
rn.add_argument("--json", action="store_true", help="output JSON")
|
|
245
|
+
rn.add_argument("--strict", action="store_true", help="fail closed on guard error")
|
|
246
|
+
rn.set_defaults(fn=cmd_run)
|
|
247
|
+
|
|
248
|
+
chk = sub.add_parser("check")
|
|
249
|
+
chk.add_argument("--diff", default=None, help="path to unified diff file")
|
|
250
|
+
chk.add_argument("--repo", default=None, help="path to repo to run git diff HEAD")
|
|
251
|
+
chk.add_argument("--json", action="store_true", help="output JSON")
|
|
252
|
+
chk.add_argument("--strict", action="store_true", help="fail closed on guard error")
|
|
253
|
+
chk.set_defaults(fn=cmd_check)
|
|
254
|
+
|
|
255
|
+
hk = sub.add_parser("hook", help="Agent hook management")
|
|
256
|
+
hk_sub = hk.add_subparsers(dest="hook_cmd", required=True)
|
|
257
|
+
|
|
258
|
+
hk_stop = hk_sub.add_parser("stop", help="Run Claude Code Stop hook adapter")
|
|
259
|
+
hk_stop.add_argument("--repo", default=None, help="path to repo root")
|
|
260
|
+
hk_stop.add_argument("--strict", action="store_true", help="fail closed on error")
|
|
261
|
+
hk_stop.set_defaults(fn=cmd_hook_stop)
|
|
262
|
+
|
|
263
|
+
hk_inst = hk_sub.add_parser("install", help="Install Stop hook into Claude Code settings")
|
|
264
|
+
hk_inst.add_argument("--repo", default=None, help="path to repo root")
|
|
265
|
+
hk_inst.add_argument("--shared", action="store_true", help="install to settings.json instead of settings.local.json")
|
|
266
|
+
hk_inst.set_defaults(fn=cmd_hook_install)
|
|
267
|
+
|
|
268
|
+
hk_uninst = hk_sub.add_parser("uninstall", help="Uninstall Stop hook from Claude Code settings")
|
|
269
|
+
hk_uninst.add_argument("--repo", default=None, help="path to repo root")
|
|
270
|
+
hk_uninst.add_argument("--shared", action="store_true", help="uninstall from settings.json instead of settings.local.json")
|
|
271
|
+
hk_uninst.set_defaults(fn=cmd_hook_uninstall)
|
|
272
|
+
|
|
273
|
+
hk_stat = hk_sub.add_parser("status", help="Check Claude Code hook installation status")
|
|
274
|
+
hk_stat.add_argument("--repo", default=None, help="path to repo root")
|
|
275
|
+
hk_stat.add_argument("--shared", action="store_true", help="check settings.json instead of settings.local.json")
|
|
276
|
+
hk_stat.set_defaults(fn=cmd_hook_status)
|
|
277
|
+
|
|
278
|
+
fb = sub.add_parser("feedback")
|
|
279
|
+
fb.add_argument("--function", required=True, help="Function ID where fault was missed")
|
|
280
|
+
fb.add_argument("--test", required=True, help="Test ID that failed and was missed")
|
|
281
|
+
fb.add_argument("--confidence", default=1.0, type=float, help="Confidence of repair edge (default: 1.0)")
|
|
282
|
+
fb.add_argument("--json", action="store_true", help="output JSON")
|
|
283
|
+
fb.add_argument("--strict", action="store_true", help="fail closed on guard error")
|
|
284
|
+
fb.set_defaults(fn=cmd_feedback)
|
|
285
|
+
|
|
286
|
+
s = sub.add_parser("ingest-static")
|
|
287
|
+
s.add_argument("repo")
|
|
288
|
+
s.add_argument("--dry-run", action="store_true", help="parse only, no database")
|
|
289
|
+
s.add_argument("--include-tests", action="store_true")
|
|
290
|
+
s.add_argument("--commit-sha", default=None, help="commit SHA to associate with edges")
|
|
291
|
+
s.set_defaults(fn=cmd_ingest_static)
|
|
292
|
+
|
|
293
|
+
ic = sub.add_parser("ingest-coverage")
|
|
294
|
+
ic.add_argument("repo")
|
|
295
|
+
ic.add_argument("--runs", default=3, type=int, help="number of test runs for flake measurement")
|
|
296
|
+
ic.add_argument("--commit-sha", default=None, help="commit SHA to associate with edges")
|
|
297
|
+
ic.set_defaults(fn=cmd_ingest_coverage)
|
|
298
|
+
|
|
299
|
+
c = sub.add_parser("callers")
|
|
300
|
+
c.add_argument("function", help="function id, e.g. pkg/mod.py::Class.method")
|
|
301
|
+
c.add_argument("--hops", default=4, type=int)
|
|
302
|
+
c.add_argument("--min-confidence", default=0.0, type=float)
|
|
303
|
+
c.add_argument("--strict", action="store_true", help="fail closed on error")
|
|
304
|
+
c.set_defaults(fn=cmd_callers)
|
|
305
|
+
|
|
306
|
+
st = sub.add_parser("stats")
|
|
307
|
+
st.set_defaults(fn=cmd_stats)
|
|
308
|
+
|
|
309
|
+
cov = sub.add_parser("covering-tests")
|
|
310
|
+
cov.add_argument("function", help="function id, e.g. pkg/mod.py::Class.method")
|
|
311
|
+
cov.add_argument("--min-confidence", default=0.0, type=float)
|
|
312
|
+
cov.add_argument("--strict", action="store_true", help="fail closed on error")
|
|
313
|
+
cov.set_defaults(fn=cmd_covering_tests)
|
|
314
|
+
|
|
315
|
+
args = p.parse_args(argv)
|
|
316
|
+
return args.fn(args)
|
|
317
|
+
|
|
318
|
+
|
|
319
|
+
if __name__ == "__main__":
|
|
320
|
+
raise SystemExit(main())
|
guard/db.py
ADDED
|
@@ -0,0 +1,271 @@
|
|
|
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
|
+
_active_backend: Any = None
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def set_backend(backend: Any) -> None:
|
|
21
|
+
"""Explicitly set active GraphBackend instance."""
|
|
22
|
+
global _active_backend
|
|
23
|
+
_active_backend = backend
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def get_backend(repo_path: Any = None) -> Any:
|
|
27
|
+
"""Return active or resolved GraphBackend."""
|
|
28
|
+
global _active_backend
|
|
29
|
+
if _active_backend is not None:
|
|
30
|
+
return _active_backend
|
|
31
|
+
from guard.backend import resolve_backend, ServerBackend
|
|
32
|
+
|
|
33
|
+
if "FALKORDB_HOST" in os.environ or "FALKORDB_PORT" in os.environ:
|
|
34
|
+
host = os.getenv("FALKORDB_HOST", "localhost")
|
|
35
|
+
port = int(os.getenv("FALKORDB_PORT", "6379"))
|
|
36
|
+
return ServerBackend(host=host, port=port)
|
|
37
|
+
return resolve_backend(repo_path=repo_path)
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def get_graph(
|
|
41
|
+
name: str | None = None,
|
|
42
|
+
backend: Any = None,
|
|
43
|
+
repo_path: Any = None,
|
|
44
|
+
):
|
|
45
|
+
"""Retrieve FalkorDB graph using resolved backend and repository name."""
|
|
46
|
+
if backend is None:
|
|
47
|
+
backend = get_backend(repo_path=repo_path)
|
|
48
|
+
if name is None:
|
|
49
|
+
name = os.getenv("GUARD_GRAPH", GRAPH_NAME)
|
|
50
|
+
return backend.get_graph(name)
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def reset_graph(g) -> None:
|
|
54
|
+
try:
|
|
55
|
+
g.delete()
|
|
56
|
+
except Exception:
|
|
57
|
+
pass # graph did not exist yet
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
def ensure_schema(g) -> None:
|
|
61
|
+
for q in INDEXES:
|
|
62
|
+
try:
|
|
63
|
+
g.query(q)
|
|
64
|
+
except Exception:
|
|
65
|
+
pass # index already exists
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
def _batches(rows: Sequence[Any], size: int = 500) -> Iterable[Sequence[Any]]:
|
|
69
|
+
for i in range(0, len(rows), size):
|
|
70
|
+
yield rows[i : i + size]
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
def write_static(g, result, commit_sha: str | None = None) -> None:
|
|
74
|
+
"""Write StaticResult nodes and edges into FalkorDB with T2 schema requirements."""
|
|
75
|
+
# 1. File nodes
|
|
76
|
+
for b in _batches(result.file_rows()):
|
|
77
|
+
g.query("UNWIND $rows AS r MERGE (x:File {path: r.path})", {"rows": b})
|
|
78
|
+
|
|
79
|
+
# 2. Function nodes & DEFINED_IN edges
|
|
80
|
+
for b in _batches(result.function_rows()):
|
|
81
|
+
g.query(
|
|
82
|
+
"""UNWIND $rows AS r
|
|
83
|
+
MERGE (f:Function {id: r.id})
|
|
84
|
+
SET f.file = r.file, f.name = r.name, f.qualname = r.qualname,
|
|
85
|
+
f.start = r.start, f.end = r.end,
|
|
86
|
+
f.has_dynamic_dispatch = r.dyn
|
|
87
|
+
WITH f, r
|
|
88
|
+
MATCH (x:File {path: r.file})
|
|
89
|
+
MERGE (f)-[:DEFINED_IN]->(x)""",
|
|
90
|
+
{"rows": b},
|
|
91
|
+
)
|
|
92
|
+
|
|
93
|
+
# 3. Test nodes & DEFINED_IN edges
|
|
94
|
+
if hasattr(result, "test_rows"):
|
|
95
|
+
test_rows = result.test_rows()
|
|
96
|
+
else:
|
|
97
|
+
test_rows = []
|
|
98
|
+
|
|
99
|
+
for b in _batches(test_rows):
|
|
100
|
+
g.query(
|
|
101
|
+
"""UNWIND $rows AS r
|
|
102
|
+
MERGE (t:Test {id: r.id})
|
|
103
|
+
SET t.file = r.file, t.name = r.name, t.qualname = r.qualname,
|
|
104
|
+
t.start = r.start, t.end = r.end
|
|
105
|
+
WITH t, r
|
|
106
|
+
MATCH (x:File {path: r.file})
|
|
107
|
+
MERGE (t)-[:DEFINED_IN]->(x)""",
|
|
108
|
+
{"rows": b},
|
|
109
|
+
)
|
|
110
|
+
|
|
111
|
+
# 4. CALLS edges (Function -> Function)
|
|
112
|
+
for b in _batches(result.call_rows()):
|
|
113
|
+
g.query(
|
|
114
|
+
"""UNWIND $rows AS r
|
|
115
|
+
MATCH (a:Function {id: r.src}), (b:Function {id: r.dst})
|
|
116
|
+
MERGE (a)-[e:CALLS]->(b)
|
|
117
|
+
SET e.source = 'static',
|
|
118
|
+
e.confidence = 0.6,
|
|
119
|
+
e.evidence_count = coalesce(e.evidence_count, 0),
|
|
120
|
+
e.last_verified_sha = $sha""",
|
|
121
|
+
{"rows": b, "sha": commit_sha},
|
|
122
|
+
)
|
|
123
|
+
|
|
124
|
+
# 5. IMPORTS edges (File -> File)
|
|
125
|
+
for b in _batches(result.import_rows()):
|
|
126
|
+
g.query(
|
|
127
|
+
"""UNWIND $rows AS r
|
|
128
|
+
MATCH (a:File {path: r.src}), (b:File {path: r.dst})
|
|
129
|
+
MERGE (a)-[e:IMPORTS]->(b)
|
|
130
|
+
SET e.source = 'static',
|
|
131
|
+
e.confidence = 0.6,
|
|
132
|
+
e.last_verified_sha = $sha""",
|
|
133
|
+
{"rows": b, "sha": commit_sha},
|
|
134
|
+
)
|
|
135
|
+
|
|
136
|
+
|
|
137
|
+
def write_coverage(
|
|
138
|
+
g,
|
|
139
|
+
test_to_funcs: dict[str, list[str]] | list[dict[str, str]],
|
|
140
|
+
commit_sha: str | None = None,
|
|
141
|
+
confidence: float = 0.9,
|
|
142
|
+
) -> int:
|
|
143
|
+
"""Write COVERED_BY edges from Function to Test nodes.
|
|
144
|
+
|
|
145
|
+
Args:
|
|
146
|
+
test_to_funcs: Mapping test_id -> list of function_ids, or list of {'test': id, 'func': id}
|
|
147
|
+
commit_sha: Commit SHA when coverage was verified
|
|
148
|
+
confidence: Confidence score (default 0.9 for dynamic coverage evidence)
|
|
149
|
+
|
|
150
|
+
Returns:
|
|
151
|
+
Number of coverage pairs written
|
|
152
|
+
"""
|
|
153
|
+
if isinstance(test_to_funcs, dict):
|
|
154
|
+
pairs = [
|
|
155
|
+
{"test": t_id, "func": f_id}
|
|
156
|
+
for t_id, funcs in test_to_funcs.items()
|
|
157
|
+
for f_id in funcs
|
|
158
|
+
]
|
|
159
|
+
else:
|
|
160
|
+
pairs = list(test_to_funcs)
|
|
161
|
+
|
|
162
|
+
for b in _batches(pairs):
|
|
163
|
+
g.query(
|
|
164
|
+
"""UNWIND $rows AS r
|
|
165
|
+
MATCH (f:Function {id: r.func}), (t:Test {id: r.test})
|
|
166
|
+
MERGE (f)-[e:COVERED_BY]->(t)
|
|
167
|
+
SET e.source = 'coverage',
|
|
168
|
+
e.confidence = $conf,
|
|
169
|
+
e.evidence_count = coalesce(e.evidence_count, 0) + 1,
|
|
170
|
+
e.last_verified_sha = $sha""",
|
|
171
|
+
{"rows": b, "conf": confidence, "sha": commit_sha},
|
|
172
|
+
)
|
|
173
|
+
return len(pairs)
|
|
174
|
+
|
|
175
|
+
|
|
176
|
+
def write_run(
|
|
177
|
+
g,
|
|
178
|
+
run_data: dict[str, Any],
|
|
179
|
+
failures: list[dict[str, Any]] | None = None,
|
|
180
|
+
) -> None:
|
|
181
|
+
"""Write a Run node and optional FAILED_IN edges.
|
|
182
|
+
|
|
183
|
+
Args:
|
|
184
|
+
run_data: Dict with keys: id, timestamp, commit_sha, duration_ms, total_tests, passed, failed
|
|
185
|
+
failures: List of dicts: {'test_id': str, 'error': str}
|
|
186
|
+
"""
|
|
187
|
+
g.query(
|
|
188
|
+
"""MERGE (r:Run {id: $r.id})
|
|
189
|
+
SET r.timestamp = $r.timestamp,
|
|
190
|
+
r.commit_sha = $r.commit_sha,
|
|
191
|
+
r.duration_ms = $r.duration_ms,
|
|
192
|
+
r.total_tests = $r.total_tests,
|
|
193
|
+
r.passed = $r.passed,
|
|
194
|
+
r.failed = $r.failed""",
|
|
195
|
+
{"r": run_data},
|
|
196
|
+
)
|
|
197
|
+
|
|
198
|
+
if failures:
|
|
199
|
+
for b in _batches(failures):
|
|
200
|
+
g.query(
|
|
201
|
+
"""UNWIND $rows AS f
|
|
202
|
+
MATCH (t:Test {id: f.test_id}), (r:Run {id: $run_id})
|
|
203
|
+
MERGE (t)-[e:FAILED_IN]->(r)
|
|
204
|
+
SET e.source = 'test_run',
|
|
205
|
+
e.error_message = f.error,
|
|
206
|
+
e.commit_sha = $sha""",
|
|
207
|
+
{
|
|
208
|
+
"rows": b,
|
|
209
|
+
"run_id": run_data["id"],
|
|
210
|
+
"sha": run_data.get("commit_sha"),
|
|
211
|
+
},
|
|
212
|
+
)
|
|
213
|
+
|
|
214
|
+
|
|
215
|
+
def get_schema_counts(g) -> dict[str, int]:
|
|
216
|
+
"""Return dictionary with counts of all nodes and relationships."""
|
|
217
|
+
counts = {}
|
|
218
|
+
for label in ("File", "Function", "Test", "Run"):
|
|
219
|
+
try:
|
|
220
|
+
res = g.query(f"MATCH (n:{label}) RETURN count(n)").result_set
|
|
221
|
+
counts[label] = res[0][0] if res else 0
|
|
222
|
+
except Exception:
|
|
223
|
+
counts[label] = 0
|
|
224
|
+
|
|
225
|
+
for rel in ("CALLS", "IMPORTS", "DEFINED_IN", "COVERED_BY", "FAILED_IN"):
|
|
226
|
+
try:
|
|
227
|
+
res = g.query(f"MATCH ()-[e:{rel}]->() RETURN count(e)").result_set
|
|
228
|
+
counts[rel] = res[0][0] if res else 0
|
|
229
|
+
except Exception:
|
|
230
|
+
counts[rel] = 0
|
|
231
|
+
|
|
232
|
+
try:
|
|
233
|
+
res = g.query(
|
|
234
|
+
"MATCH (f:Function {has_dynamic_dispatch: true}) RETURN count(f)"
|
|
235
|
+
).result_set
|
|
236
|
+
counts["dynamic_functions"] = res[0][0] if res else 0
|
|
237
|
+
except Exception:
|
|
238
|
+
counts["dynamic_functions"] = 0
|
|
239
|
+
|
|
240
|
+
return counts
|
|
241
|
+
|
|
242
|
+
|
|
243
|
+
def get_callers(
|
|
244
|
+
g, function_id: str, hops: int = 4, min_confidence: float = 0.0
|
|
245
|
+
) -> list[dict[str, Any]]:
|
|
246
|
+
"""Traverse inbound CALLS edges up to N hops filtered by min_confidence."""
|
|
247
|
+
q = (
|
|
248
|
+
f"MATCH p=(caller:Function)-[:CALLS*1..{hops}]->(target:Function {{id: $id}}) "
|
|
249
|
+
"WHERE all(e IN relationships(p) WHERE coalesce(e.confidence, 0.6) >= $min_conf) "
|
|
250
|
+
"RETURN caller.id AS caller, min(length(p)) AS hops "
|
|
251
|
+
"ORDER BY hops, caller"
|
|
252
|
+
)
|
|
253
|
+
res = g.query(q, {"id": function_id, "min_conf": min_confidence}).result_set
|
|
254
|
+
return [{"caller": row[0], "hops": row[1]} for row in res]
|
|
255
|
+
|
|
256
|
+
|
|
257
|
+
def get_covering_tests(
|
|
258
|
+
g, function_id: str, min_confidence: float = 0.0
|
|
259
|
+
) -> list[dict[str, Any]]:
|
|
260
|
+
"""Find tests covering a given function, ordered by confidence."""
|
|
261
|
+
q = (
|
|
262
|
+
"MATCH (f:Function {id: $id})-[e:COVERED_BY]->(t:Test) "
|
|
263
|
+
"WHERE e.confidence >= $min_conf "
|
|
264
|
+
"RETURN t.id AS test_id, e.confidence AS confidence, e.source AS source "
|
|
265
|
+
"ORDER BY confidence DESC"
|
|
266
|
+
)
|
|
267
|
+
res = g.query(q, {"id": function_id, "min_conf": min_confidence}).result_set
|
|
268
|
+
return [
|
|
269
|
+
{"test_id": row[0], "confidence": row[1], "source": row[2]}
|
|
270
|
+
for row in res
|
|
271
|
+
]
|