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 +2 -0
- guard/agent_integration.py +119 -0
- guard/cli.py +194 -0
- guard/db.py +243 -0
- guard/decision.py +316 -0
- guard/feedback.py +86 -0
- guard/ingest/__init__.py +1 -0
- guard/ingest/coverage.py +390 -0
- guard/ingest/static.py +290 -0
- guard/thin_slice.py +266 -0
- guard_cli-0.1.0rc1.dist-info/METADATA +210 -0
- guard_cli-0.1.0rc1.dist-info/RECORD +15 -0
- guard_cli-0.1.0rc1.dist-info/WHEEL +5 -0
- guard_cli-0.1.0rc1.dist-info/entry_points.txt +2 -0
- guard_cli-0.1.0rc1.dist-info/top_level.txt +1 -0
guard/__init__.py
ADDED
|
@@ -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
|
+
]
|