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/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