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.
@@ -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