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