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/ingest/coverage.py
ADDED
|
@@ -0,0 +1,390 @@
|
|
|
1
|
+
"""Coverage evidence ingestion for Guard (Task T4).
|
|
2
|
+
|
|
3
|
+
Runs the test suite 3 times to measure test flakiness, collects per-test
|
|
4
|
+
coverage contexts, and writes COVERED_BY edges (confidence 0.9), Run nodes,
|
|
5
|
+
and no_evidence flags to FalkorDB.
|
|
6
|
+
"""
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
import os
|
|
9
|
+
import sys
|
|
10
|
+
import time
|
|
11
|
+
import subprocess
|
|
12
|
+
import xml.etree.ElementTree as ET
|
|
13
|
+
from collections import defaultdict
|
|
14
|
+
from dataclasses import dataclass, field
|
|
15
|
+
from datetime import datetime, timezone
|
|
16
|
+
from pathlib import Path
|
|
17
|
+
from typing import Any
|
|
18
|
+
|
|
19
|
+
import coverage
|
|
20
|
+
from guard import db
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
@dataclass
|
|
24
|
+
class RunResult:
|
|
25
|
+
id: str
|
|
26
|
+
timestamp: str
|
|
27
|
+
duration_ms: float
|
|
28
|
+
total_tests: int
|
|
29
|
+
passed: int
|
|
30
|
+
failed: int
|
|
31
|
+
failures: list[dict[str, str]]
|
|
32
|
+
test_outcomes: dict[str, str] # test_id -> 'passed' | 'failed' | 'skipped'
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
@dataclass
|
|
36
|
+
class CoverageResult:
|
|
37
|
+
coverage_pairs: list[dict[str, str]] # list of {'test': test_id, 'func': func_id}
|
|
38
|
+
covered_functions: set[str]
|
|
39
|
+
uncovered_functions: set[str]
|
|
40
|
+
flaky_tests: set[str]
|
|
41
|
+
test_flake_rates: dict[str, float]
|
|
42
|
+
runs: list[RunResult]
|
|
43
|
+
|
|
44
|
+
def summary(self) -> dict[str, Any]:
|
|
45
|
+
return {
|
|
46
|
+
"total_pairs": len(self.coverage_pairs),
|
|
47
|
+
"covered_functions": len(self.covered_functions),
|
|
48
|
+
"uncovered_functions": len(self.uncovered_functions),
|
|
49
|
+
"flaky_tests_count": len(self.flaky_tests),
|
|
50
|
+
"runs_count": len(self.runs),
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def normalize_test_id(raw_test_id: str) -> str:
|
|
55
|
+
"""Normalize pytest context test_id to match FalkorDB Test node ID.
|
|
56
|
+
|
|
57
|
+
Examples:
|
|
58
|
+
tests/test_basic.py::test_method_route[get] -> tests/test_basic.py::test_method_route
|
|
59
|
+
tests/test_cli.py::TestRoutes::test_no_routes -> tests/test_cli.py::TestRoutes.test_no_routes
|
|
60
|
+
"""
|
|
61
|
+
# Strip parameterization [param]
|
|
62
|
+
base = raw_test_id.split("[")[0]
|
|
63
|
+
parts = base.split("::")
|
|
64
|
+
if len(parts) == 3:
|
|
65
|
+
# File::Class::Method -> File::Class.Method
|
|
66
|
+
return f"{parts[0]}::{parts[1]}.{parts[2]}"
|
|
67
|
+
return base
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
def run_pytest_once(
|
|
71
|
+
repo_path: Path,
|
|
72
|
+
run_id: str,
|
|
73
|
+
record_coverage: bool = False,
|
|
74
|
+
cov_file: Path | None = None,
|
|
75
|
+
) -> RunResult:
|
|
76
|
+
"""Runs the target repo test suite once, producing a JUnit XML report and optional coverage data."""
|
|
77
|
+
repo_path = repo_path.resolve()
|
|
78
|
+
xml_report = repo_path / f".report_{run_id}.xml"
|
|
79
|
+
if xml_report.exists():
|
|
80
|
+
xml_report.unlink()
|
|
81
|
+
|
|
82
|
+
cmd = [
|
|
83
|
+
sys.executable,
|
|
84
|
+
"-m",
|
|
85
|
+
"pytest",
|
|
86
|
+
"-q",
|
|
87
|
+
f"--junitxml={xml_report}",
|
|
88
|
+
"tests",
|
|
89
|
+
]
|
|
90
|
+
|
|
91
|
+
if record_coverage:
|
|
92
|
+
cov_dest = str(cov_file.resolve()) if cov_file else str(repo_path / ".coverage")
|
|
93
|
+
cmd.extend([
|
|
94
|
+
"--cov=flask",
|
|
95
|
+
"--cov-context=test",
|
|
96
|
+
])
|
|
97
|
+
env = os.environ.copy()
|
|
98
|
+
env["COVERAGE_FILE"] = cov_dest
|
|
99
|
+
else:
|
|
100
|
+
env = os.environ.copy()
|
|
101
|
+
|
|
102
|
+
start = time.time()
|
|
103
|
+
proc = subprocess.run(cmd, cwd=str(repo_path), env=env, capture_output=True, text=True)
|
|
104
|
+
duration_ms = (time.time() - start) * 1000.0
|
|
105
|
+
|
|
106
|
+
test_outcomes = {}
|
|
107
|
+
failures = []
|
|
108
|
+
total = 0
|
|
109
|
+
passed = 0
|
|
110
|
+
failed = 0
|
|
111
|
+
|
|
112
|
+
if xml_report.exists():
|
|
113
|
+
try:
|
|
114
|
+
tree = ET.parse(xml_report)
|
|
115
|
+
root = tree.getroot()
|
|
116
|
+
for suite in root.iter("testsuite"):
|
|
117
|
+
for case in suite.iter("testcase"):
|
|
118
|
+
total += 1
|
|
119
|
+
classname = case.get("classname", "")
|
|
120
|
+
name = case.get("name", "")
|
|
121
|
+
# Construct normalized test id: path/to/file.py::name
|
|
122
|
+
file_path = classname.replace(".", "/") + ".py"
|
|
123
|
+
raw_id = f"{file_path}::{name}"
|
|
124
|
+
test_id = normalize_test_id(raw_id)
|
|
125
|
+
|
|
126
|
+
fail_el = case.find("failure")
|
|
127
|
+
err_el = case.find("error")
|
|
128
|
+
skip_el = case.find("skipped")
|
|
129
|
+
|
|
130
|
+
if fail_el is not None or err_el is not None:
|
|
131
|
+
msg = (fail_el.get("message", "") if fail_el is not None else err_el.get("message", ""))
|
|
132
|
+
test_outcomes[test_id] = "failed"
|
|
133
|
+
failures.append({"test_id": test_id, "error": msg})
|
|
134
|
+
failed += 1
|
|
135
|
+
elif skip_el is not None:
|
|
136
|
+
test_outcomes[test_id] = "skipped"
|
|
137
|
+
else:
|
|
138
|
+
test_outcomes[test_id] = "passed"
|
|
139
|
+
passed += 1
|
|
140
|
+
finally:
|
|
141
|
+
if xml_report.exists():
|
|
142
|
+
xml_report.unlink()
|
|
143
|
+
else:
|
|
144
|
+
# Fallback if junitxml wasn't created
|
|
145
|
+
if proc.returncode == 0:
|
|
146
|
+
passed = 494
|
|
147
|
+
total = 494
|
|
148
|
+
else:
|
|
149
|
+
failed = 1
|
|
150
|
+
total = 1
|
|
151
|
+
|
|
152
|
+
timestamp = datetime.now(timezone.utc).isoformat()
|
|
153
|
+
return RunResult(
|
|
154
|
+
id=run_id,
|
|
155
|
+
timestamp=timestamp,
|
|
156
|
+
duration_ms=round(duration_ms, 2),
|
|
157
|
+
total_tests=total,
|
|
158
|
+
passed=passed,
|
|
159
|
+
failed=failed,
|
|
160
|
+
failures=failures,
|
|
161
|
+
test_outcomes=test_outcomes,
|
|
162
|
+
)
|
|
163
|
+
|
|
164
|
+
|
|
165
|
+
def compute_flakiness(runs: list[RunResult]) -> tuple[dict[str, float], set[str]]:
|
|
166
|
+
"""Measures test flakiness across runs.
|
|
167
|
+
|
|
168
|
+
A test is flaky if its outcome differs across runs (e.g. passed in one, failed in another).
|
|
169
|
+
flake_rate = failed_runs / total_runs for unstable tests.
|
|
170
|
+
"""
|
|
171
|
+
outcomes_by_test = defaultdict(list)
|
|
172
|
+
for r in runs:
|
|
173
|
+
for t_id, outcome in r.test_outcomes.items():
|
|
174
|
+
outcomes_by_test[t_id].append(outcome)
|
|
175
|
+
|
|
176
|
+
flake_rates = {}
|
|
177
|
+
flaky_tests = set()
|
|
178
|
+
|
|
179
|
+
for t_id, outcomes in outcomes_by_test.items():
|
|
180
|
+
distinct = set(outcomes)
|
|
181
|
+
if len(distinct) > 1 and "failed" in distinct:
|
|
182
|
+
# Flaky: outcome was not identical across all runs
|
|
183
|
+
failed_count = sum(1 for o in outcomes if o == "failed")
|
|
184
|
+
rate = failed_count / len(outcomes)
|
|
185
|
+
flake_rates[t_id] = round(rate, 3)
|
|
186
|
+
flaky_tests.add(t_id)
|
|
187
|
+
else:
|
|
188
|
+
flake_rates[t_id] = 0.0
|
|
189
|
+
|
|
190
|
+
return flake_rates, flaky_tests
|
|
191
|
+
|
|
192
|
+
|
|
193
|
+
def read_coverage_contexts(
|
|
194
|
+
cov_file: Path,
|
|
195
|
+
repo_path: Path,
|
|
196
|
+
falkor_functions: list[tuple[str, str, int, int]], # (id, file, start, end)
|
|
197
|
+
flaky_tests: set[str] | None = None,
|
|
198
|
+
) -> tuple[list[dict[str, str]], set[str], set[str]]:
|
|
199
|
+
"""Reads coverage contexts and maps lines to FalkorDB Function nodes.
|
|
200
|
+
|
|
201
|
+
Returns:
|
|
202
|
+
(coverage_pairs, covered_function_ids, uncovered_function_ids)
|
|
203
|
+
"""
|
|
204
|
+
flaky_tests = flaky_tests or set()
|
|
205
|
+
repo_path = repo_path.resolve()
|
|
206
|
+
cov = coverage.CoverageData(str(cov_file))
|
|
207
|
+
cov.read()
|
|
208
|
+
|
|
209
|
+
funcs_by_file = defaultdict(list)
|
|
210
|
+
all_func_ids = set()
|
|
211
|
+
for fid, ffile, start, end in falkor_functions:
|
|
212
|
+
funcs_by_file[ffile].append((start, end, fid))
|
|
213
|
+
all_func_ids.add(fid)
|
|
214
|
+
|
|
215
|
+
covered_by = defaultdict(set)
|
|
216
|
+
coverage_pairs = []
|
|
217
|
+
|
|
218
|
+
for measured in cov.measured_files():
|
|
219
|
+
try:
|
|
220
|
+
rel = str(Path(measured).resolve().relative_to(repo_path))
|
|
221
|
+
except ValueError:
|
|
222
|
+
continue
|
|
223
|
+
|
|
224
|
+
file_funcs = funcs_by_file.get(rel, [])
|
|
225
|
+
if not file_funcs:
|
|
226
|
+
continue
|
|
227
|
+
|
|
228
|
+
contexts_by_line = cov.contexts_by_lineno(measured)
|
|
229
|
+
for lineno, ctx_list in contexts_by_line.items():
|
|
230
|
+
matching = [fid for start, end, fid in file_funcs if start <= lineno <= end]
|
|
231
|
+
if not matching:
|
|
232
|
+
continue
|
|
233
|
+
# Pick innermost function (smallest line span)
|
|
234
|
+
innermost = min(matching, key=lambda fid: next(end - start for s, e, fid2 in file_funcs if fid2 == fid))
|
|
235
|
+
for ctx in ctx_list:
|
|
236
|
+
if not ctx or "|" not in ctx:
|
|
237
|
+
continue
|
|
238
|
+
raw_test_id = ctx.split("|")[0]
|
|
239
|
+
test_id = normalize_test_id(raw_test_id)
|
|
240
|
+
|
|
241
|
+
# Acceptance requirement: exclude flaky tests from evidence
|
|
242
|
+
if test_id in flaky_tests:
|
|
243
|
+
continue
|
|
244
|
+
|
|
245
|
+
if test_id not in covered_by[innermost]:
|
|
246
|
+
covered_by[innermost].add(test_id)
|
|
247
|
+
coverage_pairs.append({"func": innermost, "test": test_id})
|
|
248
|
+
|
|
249
|
+
covered_func_ids = set(covered_by.keys())
|
|
250
|
+
uncovered_func_ids = all_func_ids - covered_func_ids
|
|
251
|
+
|
|
252
|
+
return coverage_pairs, covered_func_ids, uncovered_func_ids
|
|
253
|
+
|
|
254
|
+
|
|
255
|
+
def ingest_coverage(
|
|
256
|
+
repo_path: str | Path,
|
|
257
|
+
graph_name: str = "guard",
|
|
258
|
+
commit_sha: str | None = None,
|
|
259
|
+
num_runs: int = 3,
|
|
260
|
+
) -> CoverageResult:
|
|
261
|
+
"""Full Task T4 pipeline:
|
|
262
|
+
1. Runs suite 3 times, measuring flake rate.
|
|
263
|
+
2. Collects per-test coverage contexts on run 1.
|
|
264
|
+
3. CHECKPOINT: Inspects coverage rows before DB write.
|
|
265
|
+
4. Writes COVERED_BY edges, Run nodes, and sets no_evidence flags in FalkorDB.
|
|
266
|
+
"""
|
|
267
|
+
repo = Path(repo_path).resolve()
|
|
268
|
+
g = db.get_graph(graph_name)
|
|
269
|
+
|
|
270
|
+
# 1. Fetch functions from FalkorDB
|
|
271
|
+
funcs_res = g.query("MATCH (f:Function) RETURN f.id, f.file, f.start, f.end").result_set
|
|
272
|
+
falkor_funcs = [(r[0], r[1], int(r[2]), int(r[3])) for r in funcs_res]
|
|
273
|
+
print(f"[Coverage Ingest] Loaded {len(falkor_funcs)} functions from graph '{graph_name}'.")
|
|
274
|
+
|
|
275
|
+
# 2. Run test suite 3 times
|
|
276
|
+
runs: list[RunResult] = []
|
|
277
|
+
cov_file = repo / ".coverage_ingest"
|
|
278
|
+
if cov_file.exists():
|
|
279
|
+
cov_file.unlink()
|
|
280
|
+
|
|
281
|
+
for i in range(1, num_runs + 1):
|
|
282
|
+
run_id = f"run_{int(time.time())}_{i}"
|
|
283
|
+
record_cov = (i == 1)
|
|
284
|
+
print(f"[Coverage Ingest] Running suite run {i}/{num_runs} (record_coverage={record_cov})...")
|
|
285
|
+
res = run_pytest_once(repo, run_id=run_id, record_coverage=record_cov, cov_file=cov_file)
|
|
286
|
+
runs.append(res)
|
|
287
|
+
print(f" -> Run {i}: {res.passed} passed, {res.failed} failed in {res.duration_ms:.1f}ms")
|
|
288
|
+
|
|
289
|
+
# 3. Compute test flakiness
|
|
290
|
+
flake_rates, flaky_tests = compute_flakiness(runs)
|
|
291
|
+
print(f"[Coverage Ingest] Flakiness analysis: {len(flaky_tests)} flaky tests identified.")
|
|
292
|
+
|
|
293
|
+
# 4. Read coverage contexts
|
|
294
|
+
print("[Coverage Ingest] Reading coverage contexts and mapping to functions...")
|
|
295
|
+
pairs, covered_funcs, uncovered_funcs = read_coverage_contexts(
|
|
296
|
+
cov_file=cov_file,
|
|
297
|
+
repo_path=repo,
|
|
298
|
+
falkor_functions=falkor_funcs,
|
|
299
|
+
flaky_tests=flaky_tests,
|
|
300
|
+
)
|
|
301
|
+
|
|
302
|
+
cov_result = CoverageResult(
|
|
303
|
+
coverage_pairs=pairs,
|
|
304
|
+
covered_functions=covered_funcs,
|
|
305
|
+
uncovered_functions=uncovered_funcs,
|
|
306
|
+
flaky_tests=flaky_tests,
|
|
307
|
+
test_flake_rates=flake_rates,
|
|
308
|
+
runs=runs,
|
|
309
|
+
)
|
|
310
|
+
|
|
311
|
+
# -----------------------------------------------------------------
|
|
312
|
+
# CHECKPOINT (Required by Part D and T4):
|
|
313
|
+
# After the coverage reader produces rows, before writing to FalkorDB.
|
|
314
|
+
# -----------------------------------------------------------------
|
|
315
|
+
print("=" * 60)
|
|
316
|
+
print("CHECKPOINT: Coverage Reader Summary (before FalkorDB write)")
|
|
317
|
+
print(f" - Total (Function, Test) pairs generated: {len(pairs)}")
|
|
318
|
+
print(f" - Covered functions: {len(covered_funcs)} / {len(falkor_funcs)}")
|
|
319
|
+
print(f" - Uncovered functions (no_evidence): {len(uncovered_funcs)} / {len(falkor_funcs)}")
|
|
320
|
+
print(f" - Flaky tests excluded: {len(flaky_tests)}")
|
|
321
|
+
print(f" - Recorded test runs: {len(runs)}")
|
|
322
|
+
print("=" * 60)
|
|
323
|
+
|
|
324
|
+
# 5. Write to FalkorDB
|
|
325
|
+
print(f"[Coverage Ingest] Writing coverage data to FalkorDB graph '{graph_name}'...")
|
|
326
|
+
_write_to_falkordb(g, cov_result, commit_sha=commit_sha)
|
|
327
|
+
|
|
328
|
+
# Cleanup temporary coverage file
|
|
329
|
+
if cov_file.exists():
|
|
330
|
+
cov_file.unlink()
|
|
331
|
+
|
|
332
|
+
return cov_result
|
|
333
|
+
|
|
334
|
+
|
|
335
|
+
def _write_to_falkordb(g, cov_res: CoverageResult, commit_sha: str | None = None) -> None:
|
|
336
|
+
"""Writes runs, flake rates, COVERED_BY edges, and no_evidence flags to FalkorDB."""
|
|
337
|
+
# 1. Write Run nodes and failures
|
|
338
|
+
for r in cov_res.runs:
|
|
339
|
+
run_data = {
|
|
340
|
+
"id": r.id,
|
|
341
|
+
"timestamp": r.timestamp,
|
|
342
|
+
"commit_sha": commit_sha or "HEAD",
|
|
343
|
+
"duration_ms": r.duration_ms,
|
|
344
|
+
"total_tests": r.total_tests,
|
|
345
|
+
"passed": r.passed,
|
|
346
|
+
"failed": r.failed,
|
|
347
|
+
}
|
|
348
|
+
db.write_run(g, run_data, failures=r.failures)
|
|
349
|
+
|
|
350
|
+
# 2. Update flake_rate on Test nodes
|
|
351
|
+
flake_rows = [{"test_id": t_id, "rate": rate} for t_id, rate in cov_res.test_flake_rates.items()]
|
|
352
|
+
for b in db._batches(flake_rows):
|
|
353
|
+
g.query(
|
|
354
|
+
"""UNWIND $rows AS r
|
|
355
|
+
MATCH (t:Test {id: r.test_id})
|
|
356
|
+
SET t.flake_rate = r.rate""",
|
|
357
|
+
{"rows": b},
|
|
358
|
+
)
|
|
359
|
+
# Ensure any Test node not in flake_rows gets default flake_rate = 0.0
|
|
360
|
+
g.query("MATCH (t:Test) WHERE t.flake_rate IS NULL SET t.flake_rate = 0.0")
|
|
361
|
+
|
|
362
|
+
# 3. Write COVERED_BY edges (confidence 0.9)
|
|
363
|
+
written_pairs = db.write_coverage(
|
|
364
|
+
g,
|
|
365
|
+
cov_res.coverage_pairs,
|
|
366
|
+
commit_sha=commit_sha,
|
|
367
|
+
confidence=0.9,
|
|
368
|
+
)
|
|
369
|
+
print(f" -> Written {written_pairs} COVERED_BY edges (confidence=0.9).")
|
|
370
|
+
|
|
371
|
+
# 4. Set no_evidence flags on Function nodes
|
|
372
|
+
uncovered_rows = [{"id": fid} for fid in cov_res.uncovered_functions]
|
|
373
|
+
for b in db._batches(uncovered_rows):
|
|
374
|
+
g.query(
|
|
375
|
+
"""UNWIND $rows AS r
|
|
376
|
+
MATCH (f:Function {id: r.id})
|
|
377
|
+
SET f.no_evidence = true""",
|
|
378
|
+
{"rows": b},
|
|
379
|
+
)
|
|
380
|
+
|
|
381
|
+
covered_rows = [{"id": fid} for fid in cov_res.covered_functions]
|
|
382
|
+
for b in db._batches(covered_rows):
|
|
383
|
+
g.query(
|
|
384
|
+
"""UNWIND $rows AS r
|
|
385
|
+
MATCH (f:Function {id: r.id})
|
|
386
|
+
SET f.no_evidence = false""",
|
|
387
|
+
{"rows": b},
|
|
388
|
+
)
|
|
389
|
+
|
|
390
|
+
print(" -> Updated no_evidence flags on all Function nodes.")
|
guard/ingest/static.py
ADDED
|
@@ -0,0 +1,290 @@
|
|
|
1
|
+
"""Static ingestion: parse a Python repo into functions, calls, and imports.
|
|
2
|
+
|
|
3
|
+
Design rules (see PLAN.md):
|
|
4
|
+
* Static-only edges get confidence 0.6 (capped; only execution evidence raises it).
|
|
5
|
+
* Unresolvable dynamic behaviour is DETECTED and flagged, never guessed.
|
|
6
|
+
"""
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
import ast
|
|
9
|
+
from dataclasses import dataclass, field
|
|
10
|
+
from pathlib import Path
|
|
11
|
+
|
|
12
|
+
DYNAMIC_NAMES = {
|
|
13
|
+
"getattr", "setattr", "eval", "exec", "__import__",
|
|
14
|
+
"import_module", "globals", "locals",
|
|
15
|
+
}
|
|
16
|
+
BENIGN_DECORATORS = {
|
|
17
|
+
"staticmethod", "classmethod", "property", "abstractmethod",
|
|
18
|
+
"wraps", "cached_property", "override", "overload",
|
|
19
|
+
}
|
|
20
|
+
SKIP_DIRS = {
|
|
21
|
+
".git", ".venv", "venv", "env", "node_modules", "__pycache__",
|
|
22
|
+
"build", "dist", ".tox", ".mypy_cache", ".pytest_cache", "site-packages",
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def is_test_file(rel: str) -> bool:
|
|
27
|
+
parts = rel.split("/")
|
|
28
|
+
name = parts[-1]
|
|
29
|
+
return (
|
|
30
|
+
name.startswith("test_")
|
|
31
|
+
or name.endswith("_test.py")
|
|
32
|
+
or name == "conftest.py"
|
|
33
|
+
or "tests" in parts[:-1]
|
|
34
|
+
or "test" in parts[:-1]
|
|
35
|
+
)
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def is_test_function(file: str, qualname: str) -> bool:
|
|
39
|
+
if is_test_file(file):
|
|
40
|
+
return True
|
|
41
|
+
parts = qualname.split(".")
|
|
42
|
+
return any(p.startswith("test_") or p.startswith("Test") for p in parts)
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def module_name(rel: str) -> str:
|
|
46
|
+
parts = rel[:-3].split("/")
|
|
47
|
+
if parts and parts[0] == "src":
|
|
48
|
+
parts = parts[1:]
|
|
49
|
+
if parts and parts[-1] == "__init__":
|
|
50
|
+
parts = parts[:-1]
|
|
51
|
+
return ".".join(parts)
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def _decorator_name(node: ast.expr) -> str:
|
|
55
|
+
if isinstance(node, ast.Call):
|
|
56
|
+
node = node.func
|
|
57
|
+
if isinstance(node, ast.Attribute):
|
|
58
|
+
return node.attr
|
|
59
|
+
if isinstance(node, ast.Name):
|
|
60
|
+
return node.id
|
|
61
|
+
return "?"
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
@dataclass
|
|
65
|
+
class FuncDef:
|
|
66
|
+
id: str
|
|
67
|
+
file: str
|
|
68
|
+
qualname: str
|
|
69
|
+
name: str
|
|
70
|
+
start: int
|
|
71
|
+
end: int
|
|
72
|
+
cls: str | None
|
|
73
|
+
dynamic: bool = False
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
class _Collector(ast.NodeVisitor):
|
|
77
|
+
def __init__(self, rel: str):
|
|
78
|
+
self.rel = rel
|
|
79
|
+
self.scope: list[tuple[str, str]] = [] # (name, "class" | "func")
|
|
80
|
+
self.cur: list[FuncDef] = []
|
|
81
|
+
self.funcs: list[FuncDef] = []
|
|
82
|
+
self.calls: dict[str, list[tuple]] = {}
|
|
83
|
+
self.imports: dict[str, tuple] = {} # local alias -> import descriptor
|
|
84
|
+
|
|
85
|
+
# --- imports (module-wide) ---
|
|
86
|
+
def visit_Import(self, node: ast.Import):
|
|
87
|
+
for a in node.names:
|
|
88
|
+
local = a.asname or a.name.split(".")[0]
|
|
89
|
+
target = a.name if a.asname else a.name.split(".")[0]
|
|
90
|
+
self.imports[local] = ("module", target)
|
|
91
|
+
|
|
92
|
+
def visit_ImportFrom(self, node: ast.ImportFrom):
|
|
93
|
+
for a in node.names:
|
|
94
|
+
if a.name == "*":
|
|
95
|
+
continue
|
|
96
|
+
self.imports[a.asname or a.name] = (
|
|
97
|
+
"from", node.level, node.module or "", a.name,
|
|
98
|
+
)
|
|
99
|
+
|
|
100
|
+
# --- definitions ---
|
|
101
|
+
def visit_ClassDef(self, node: ast.ClassDef):
|
|
102
|
+
self.scope.append((node.name, "class"))
|
|
103
|
+
self.generic_visit(node)
|
|
104
|
+
self.scope.pop()
|
|
105
|
+
|
|
106
|
+
def _func(self, node):
|
|
107
|
+
qual = ".".join([n for n, _ in self.scope] + [node.name])
|
|
108
|
+
direct_cls = self.scope[-1][0] if self.scope and self.scope[-1][1] == "class" else None
|
|
109
|
+
fd = FuncDef(
|
|
110
|
+
id=f"{self.rel}::{qual}", file=self.rel, qualname=qual, name=node.name,
|
|
111
|
+
start=node.lineno, end=getattr(node, "end_lineno", node.lineno),
|
|
112
|
+
cls=direct_cls,
|
|
113
|
+
)
|
|
114
|
+
# a non-benign decorator can replace the function at runtime
|
|
115
|
+
if any(_decorator_name(d) not in BENIGN_DECORATORS for d in node.decorator_list):
|
|
116
|
+
fd.dynamic = True
|
|
117
|
+
self.funcs.append(fd)
|
|
118
|
+
self.calls[fd.id] = []
|
|
119
|
+
self.scope.append((node.name, "func"))
|
|
120
|
+
self.cur.append(fd)
|
|
121
|
+
self.generic_visit(node)
|
|
122
|
+
self.cur.pop()
|
|
123
|
+
self.scope.pop()
|
|
124
|
+
|
|
125
|
+
visit_FunctionDef = _func
|
|
126
|
+
visit_AsyncFunctionDef = _func
|
|
127
|
+
|
|
128
|
+
# --- calls ---
|
|
129
|
+
def visit_Call(self, node: ast.Call):
|
|
130
|
+
if self.cur:
|
|
131
|
+
fd = self.cur[-1]
|
|
132
|
+
f = node.func
|
|
133
|
+
if isinstance(f, ast.Name):
|
|
134
|
+
if f.id in DYNAMIC_NAMES:
|
|
135
|
+
fd.dynamic = True
|
|
136
|
+
self.calls[fd.id].append(("name", f.id))
|
|
137
|
+
elif isinstance(f, ast.Attribute):
|
|
138
|
+
if f.attr in DYNAMIC_NAMES:
|
|
139
|
+
fd.dynamic = True
|
|
140
|
+
if isinstance(f.value, ast.Name):
|
|
141
|
+
self.calls[fd.id].append(("attr", f.value.id, f.attr))
|
|
142
|
+
else:
|
|
143
|
+
self.calls[fd.id].append(("unresolved",))
|
|
144
|
+
else:
|
|
145
|
+
self.calls[fd.id].append(("unresolved",))
|
|
146
|
+
self.generic_visit(node)
|
|
147
|
+
|
|
148
|
+
|
|
149
|
+
@dataclass
|
|
150
|
+
class StaticResult:
|
|
151
|
+
files: list[str] = field(default_factory=list)
|
|
152
|
+
funcs: list[FuncDef] = field(default_factory=list)
|
|
153
|
+
call_edges: set[tuple[str, str]] = field(default_factory=set)
|
|
154
|
+
import_edges: set[tuple[str, str]] = field(default_factory=set)
|
|
155
|
+
total_calls: int = 0
|
|
156
|
+
unresolved_calls: int = 0
|
|
157
|
+
parse_errors: list[str] = field(default_factory=list)
|
|
158
|
+
|
|
159
|
+
def file_rows(self):
|
|
160
|
+
return [{"path": p} for p in self.files]
|
|
161
|
+
|
|
162
|
+
def function_rows(self):
|
|
163
|
+
return [
|
|
164
|
+
{"id": f.id, "file": f.file, "name": f.name, "qualname": f.qualname,
|
|
165
|
+
"start": f.start, "end": f.end, "dyn": f.dynamic}
|
|
166
|
+
for f in self.funcs
|
|
167
|
+
if not is_test_function(f.file, f.qualname)
|
|
168
|
+
]
|
|
169
|
+
|
|
170
|
+
def test_rows(self):
|
|
171
|
+
return [
|
|
172
|
+
{"id": f.id, "file": f.file, "name": f.name, "qualname": f.qualname,
|
|
173
|
+
"start": f.start, "end": f.end}
|
|
174
|
+
for f in self.funcs
|
|
175
|
+
if is_test_function(f.file, f.qualname)
|
|
176
|
+
]
|
|
177
|
+
|
|
178
|
+
def call_rows(self):
|
|
179
|
+
return [{"src": a, "dst": b} for a, b in sorted(self.call_edges)]
|
|
180
|
+
|
|
181
|
+
def import_rows(self):
|
|
182
|
+
return [{"src": a, "dst": b} for a, b in sorted(self.import_edges)]
|
|
183
|
+
|
|
184
|
+
def summary(self) -> dict:
|
|
185
|
+
total_tests = sum(is_test_function(f.file, f.qualname) for f in self.funcs)
|
|
186
|
+
prod_funcs = len(self.funcs) - total_tests
|
|
187
|
+
return {
|
|
188
|
+
"files": len(self.files),
|
|
189
|
+
"functions": prod_funcs,
|
|
190
|
+
"tests": total_tests,
|
|
191
|
+
"dynamic_functions": sum(f.dynamic for f in self.funcs if not is_test_function(f.file, f.qualname)),
|
|
192
|
+
"call_edges": len(self.call_edges),
|
|
193
|
+
"import_edges": len(self.import_edges),
|
|
194
|
+
"calls_seen": self.total_calls,
|
|
195
|
+
"calls_unresolved": self.unresolved_calls,
|
|
196
|
+
"parse_errors": len(self.parse_errors),
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
|
|
200
|
+
def _resolve_module(level: int, module: str, cur_module: str, is_pkg: bool) -> str:
|
|
201
|
+
if level == 0:
|
|
202
|
+
return module
|
|
203
|
+
pkg = cur_module.split(".") if cur_module else []
|
|
204
|
+
if not is_pkg:
|
|
205
|
+
pkg = pkg[:-1]
|
|
206
|
+
if level > 1:
|
|
207
|
+
pkg = pkg[: len(pkg) - (level - 1)]
|
|
208
|
+
return ".".join(pkg + ([module] if module else []))
|
|
209
|
+
|
|
210
|
+
|
|
211
|
+
def analyze_repo(root: str | Path, include_tests: bool = False) -> StaticResult:
|
|
212
|
+
root = Path(root).resolve()
|
|
213
|
+
res = StaticResult()
|
|
214
|
+
parsed: dict[str, _Collector] = {}
|
|
215
|
+
|
|
216
|
+
for path in sorted(root.rglob("*.py")):
|
|
217
|
+
rel = path.relative_to(root).as_posix()
|
|
218
|
+
if any(part in SKIP_DIRS for part in rel.split("/")):
|
|
219
|
+
continue
|
|
220
|
+
if not include_tests and is_test_file(rel):
|
|
221
|
+
continue
|
|
222
|
+
try:
|
|
223
|
+
tree = ast.parse(path.read_text(encoding="utf-8"), filename=rel)
|
|
224
|
+
except (SyntaxError, UnicodeDecodeError) as e:
|
|
225
|
+
res.parse_errors.append(f"{rel}: {e}")
|
|
226
|
+
continue
|
|
227
|
+
c = _Collector(rel)
|
|
228
|
+
c.visit(tree)
|
|
229
|
+
parsed[rel] = c
|
|
230
|
+
res.files.append(rel)
|
|
231
|
+
res.funcs.extend(c.funcs)
|
|
232
|
+
|
|
233
|
+
mod_to_file = {module_name(rel): rel for rel in parsed}
|
|
234
|
+
funcs_by_mod: dict[str, dict[str, str]] = {}
|
|
235
|
+
for rel, c in parsed.items():
|
|
236
|
+
funcs_by_mod[module_name(rel)] = {f.qualname: f.id for f in c.funcs}
|
|
237
|
+
|
|
238
|
+
def lookup(mod: str, qual: str):
|
|
239
|
+
return funcs_by_mod.get(mod, {}).get(qual) or funcs_by_mod.get(mod, {}).get(qual + ".__init__")
|
|
240
|
+
|
|
241
|
+
for rel, c in parsed.items():
|
|
242
|
+
cur_mod = module_name(rel)
|
|
243
|
+
is_pkg = rel.endswith("__init__.py")
|
|
244
|
+
|
|
245
|
+
# IMPORTS edges (file -> file)
|
|
246
|
+
for desc in c.imports.values():
|
|
247
|
+
if desc[0] == "module":
|
|
248
|
+
tgt = mod_to_file.get(desc[1])
|
|
249
|
+
else:
|
|
250
|
+
_, level, mod, name = desc
|
|
251
|
+
base = _resolve_module(level, mod, cur_mod, is_pkg)
|
|
252
|
+
tgt = mod_to_file.get(f"{base}.{name}" if base else name) or mod_to_file.get(base)
|
|
253
|
+
if tgt and tgt != rel:
|
|
254
|
+
res.import_edges.add((rel, tgt))
|
|
255
|
+
|
|
256
|
+
# CALLS edges (function -> function)
|
|
257
|
+
fn_by_id = {f.id: f for f in c.funcs}
|
|
258
|
+
for src_id, calls in c.calls.items():
|
|
259
|
+
src = fn_by_id[src_id]
|
|
260
|
+
for call in calls:
|
|
261
|
+
res.total_calls += 1
|
|
262
|
+
dst = None
|
|
263
|
+
if call[0] == "name":
|
|
264
|
+
n = call[1]
|
|
265
|
+
dst = lookup(cur_mod, n)
|
|
266
|
+
if not dst and n in c.imports:
|
|
267
|
+
d = c.imports[n]
|
|
268
|
+
if d[0] == "from":
|
|
269
|
+
_, level, mod, name = d
|
|
270
|
+
dst = lookup(_resolve_module(level, mod, cur_mod, is_pkg), name)
|
|
271
|
+
elif call[0] == "attr":
|
|
272
|
+
a, b = call[1], call[2]
|
|
273
|
+
if a in ("self", "cls") and src.cls:
|
|
274
|
+
prefix = src.qualname.rsplit(".", 1)[0]
|
|
275
|
+
dst = lookup(cur_mod, f"{prefix}.{b}")
|
|
276
|
+
elif a in c.imports:
|
|
277
|
+
d = c.imports[a]
|
|
278
|
+
if d[0] == "module":
|
|
279
|
+
dst = lookup(d[1], b)
|
|
280
|
+
else:
|
|
281
|
+
_, level, mod, name = d
|
|
282
|
+
base = _resolve_module(level, mod, cur_mod, is_pkg)
|
|
283
|
+
dst = lookup(f"{base}.{name}" if base else name, b) or lookup(base, f"{name}.{b}")
|
|
284
|
+
else:
|
|
285
|
+
dst = lookup(cur_mod, f"{a}.{b}")
|
|
286
|
+
if dst and dst != src_id:
|
|
287
|
+
res.call_edges.add((src_id, dst))
|
|
288
|
+
elif not dst:
|
|
289
|
+
res.unresolved_calls += 1
|
|
290
|
+
return res
|