codetruth 0.2.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.
- codetruth/__init__.py +16 -0
- codetruth/api.py +102 -0
- codetruth/cli.py +183 -0
- codetruth/core/__init__.py +0 -0
- codetruth/core/cache.py +104 -0
- codetruth/core/config.py +74 -0
- codetruth/core/deletion.py +165 -0
- codetruth/core/evidence.py +306 -0
- codetruth/core/graph.py +44 -0
- codetruth/core/models.py +193 -0
- codetruth/core/plugin.py +88 -0
- codetruth/core/report.py +107 -0
- codetruth/core/scanner.py +228 -0
- codetruth/languages/__init__.py +0 -0
- codetruth/languages/go/__init__.py +1 -0
- codetruth/languages/javascript/__init__.py +8 -0
- codetruth/languages/javascript/edges.py +361 -0
- codetruth/languages/javascript/extractor.py +402 -0
- codetruth/languages/javascript/plugin.py +52 -0
- codetruth/languages/javascript/rules.py +201 -0
- codetruth/languages/python/__init__.py +0 -0
- codetruth/languages/python/edges.py +474 -0
- codetruth/languages/python/extractor.py +271 -0
- codetruth/languages/python/plugin.py +49 -0
- codetruth/languages/python/rules.py +332 -0
- codetruth/mcp_server.py +138 -0
- codetruth/rules/python/common_dynamic.yaml +70 -0
- codetruth/rules/python/django.yaml +57 -0
- codetruth/rules/python/fastapi.yaml +38 -0
- codetruth/rules/python/orm_web.yaml +50 -0
- codetruth/runtime/__init__.py +283 -0
- codetruth-0.2.0.dist-info/METADATA +215 -0
- codetruth-0.2.0.dist-info/RECORD +37 -0
- codetruth-0.2.0.dist-info/WHEEL +5 -0
- codetruth-0.2.0.dist-info/entry_points.txt +2 -0
- codetruth-0.2.0.dist-info/licenses/LICENSE +21 -0
- codetruth-0.2.0.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,474 @@
|
|
|
1
|
+
"""Layer 2 — relationship graph construction for Python.
|
|
2
|
+
|
|
3
|
+
Second pass over each module's AST with the full cross-repo symbol table
|
|
4
|
+
available. Produces directed edges (call / import / inherit / attribute /
|
|
5
|
+
reference) classified strong or weak at creation time:
|
|
6
|
+
|
|
7
|
+
- strong: the reference resolves deterministically to a repo symbol
|
|
8
|
+
(direct call, explicit import, inheritance, `self.method()`).
|
|
9
|
+
- weak: name-based matching only (attribute access on an unresolved object
|
|
10
|
+
matches every symbol with that name — like `vulture`'s conservative model).
|
|
11
|
+
"""
|
|
12
|
+
from __future__ import annotations
|
|
13
|
+
|
|
14
|
+
import ast
|
|
15
|
+
from collections import defaultdict
|
|
16
|
+
from typing import Optional
|
|
17
|
+
|
|
18
|
+
from ...core.graph import CodeGraph
|
|
19
|
+
from ...core.models import (Edge, EdgeKind, EdgeStrength, Marker, MarkerKind,
|
|
20
|
+
Symbol, SymbolType)
|
|
21
|
+
from .extractor import ModuleInfo
|
|
22
|
+
|
|
23
|
+
MAX_NAME_MATCHES = 60 # skip weak fan-out beyond this (too generic to mean anything)
|
|
24
|
+
|
|
25
|
+
# External bases that don't imply framework callbacks on subclass methods.
|
|
26
|
+
BENIGN_BASES = {
|
|
27
|
+
"object", "Exception", "BaseException", "ValueError", "TypeError",
|
|
28
|
+
"RuntimeError", "KeyError", "AttributeError", "OSError", "IOError",
|
|
29
|
+
"NotImplementedError", "StopIteration", "Enum", "IntEnum", "StrEnum",
|
|
30
|
+
"enum.Enum", "enum.IntEnum", "enum.StrEnum", "str", "int", "float",
|
|
31
|
+
"dict", "list", "set", "tuple", "frozenset", "bytes", "NamedTuple",
|
|
32
|
+
"typing.NamedTuple", "TypedDict", "typing.TypedDict", "Protocol",
|
|
33
|
+
"typing.Protocol", "ABC", "abc.ABC", "Generic", "typing.Generic",
|
|
34
|
+
"BaseModel", "pydantic.BaseModel", # pydantic models: fields, not callbacks
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
class SymbolIndex:
|
|
39
|
+
"""Cross-repo lookup tables built once from all extracted modules."""
|
|
40
|
+
|
|
41
|
+
def __init__(self, modules: list[ModuleInfo]):
|
|
42
|
+
self.modules: dict[str, ModuleInfo] = {m.name: m for m in modules}
|
|
43
|
+
self.by_id: dict[str, Symbol] = {}
|
|
44
|
+
self.toplevel: dict[str, dict[str, str]] = defaultdict(dict)
|
|
45
|
+
self.by_name: dict[str, list[str]] = defaultdict(list)
|
|
46
|
+
self.class_members: dict[str, dict[str, str]] = defaultdict(dict)
|
|
47
|
+
self._module_prefixes: set[str] = set()
|
|
48
|
+
for m in modules:
|
|
49
|
+
for part_count in range(1, len(m.name.split(".")) + 1):
|
|
50
|
+
self._module_prefixes.add(".".join(m.name.split(".")[:part_count]))
|
|
51
|
+
for s in m.symbols:
|
|
52
|
+
self.by_id[s.id] = s
|
|
53
|
+
if s.type is SymbolType.MODULE:
|
|
54
|
+
continue
|
|
55
|
+
self.by_name[s.name].append(s.id)
|
|
56
|
+
if s.parent == m.name:
|
|
57
|
+
self.toplevel[m.name][s.name] = s.id
|
|
58
|
+
elif s.parent and s.parent in self.by_id \
|
|
59
|
+
and self.by_id[s.parent].type is SymbolType.CLASS:
|
|
60
|
+
self.class_members[s.parent][s.name] = s.id
|
|
61
|
+
|
|
62
|
+
def is_internal_module_prefix(self, name: str) -> bool:
|
|
63
|
+
return name in self._module_prefixes
|
|
64
|
+
|
|
65
|
+
def all_symbols(self) -> list[Symbol]:
|
|
66
|
+
return list(self.by_id.values())
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
def _relative_base(mi: ModuleInfo, level: int) -> Optional[str]:
|
|
70
|
+
"""Package that a relative import of the given level resolves against."""
|
|
71
|
+
parts = mi.name.split(".")
|
|
72
|
+
if not mi.is_package:
|
|
73
|
+
parts = parts[:-1]
|
|
74
|
+
drop = level - 1
|
|
75
|
+
if drop > len(parts):
|
|
76
|
+
return None
|
|
77
|
+
return ".".join(parts[:len(parts) - drop]) if parts[:len(parts) - drop] else None
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
def build_scope(mi: ModuleInfo, idx: SymbolIndex, graph: CodeGraph) -> dict:
|
|
81
|
+
"""Map local names -> ('symbol', id) | ('module', name) | ('ext', None).
|
|
82
|
+
Emits strong import edges as a side effect."""
|
|
83
|
+
scope: dict[str, tuple[str, Optional[str]]] = {}
|
|
84
|
+
for name, sid in idx.toplevel.get(mi.name, {}).items():
|
|
85
|
+
scope[name] = ("symbol", sid)
|
|
86
|
+
|
|
87
|
+
for imp in mi.imports:
|
|
88
|
+
if imp.kind == "import":
|
|
89
|
+
target = imp.module
|
|
90
|
+
internal = idx.is_internal_module_prefix(target)
|
|
91
|
+
if imp.asname:
|
|
92
|
+
scope[imp.asname] = ("module", target) if internal else ("ext", None)
|
|
93
|
+
else:
|
|
94
|
+
top = target.split(".")[0]
|
|
95
|
+
scope[top] = ("module", top) if idx.is_internal_module_prefix(top) \
|
|
96
|
+
else ("ext", None)
|
|
97
|
+
if internal and target in idx.modules:
|
|
98
|
+
graph.add_edge(Edge(mi.name, target, EdgeKind.IMPORT,
|
|
99
|
+
EdgeStrength.STRONG, mi.rel_path, imp.lineno,
|
|
100
|
+
f"import {target}"))
|
|
101
|
+
continue
|
|
102
|
+
|
|
103
|
+
# from-import
|
|
104
|
+
if imp.level > 0:
|
|
105
|
+
base = _relative_base(mi, imp.level)
|
|
106
|
+
if base is None:
|
|
107
|
+
continue
|
|
108
|
+
base = f"{base}.{imp.module}" if imp.module else base
|
|
109
|
+
else:
|
|
110
|
+
base = imp.module
|
|
111
|
+
|
|
112
|
+
if imp.name == "*":
|
|
113
|
+
src_mod = idx.modules.get(base)
|
|
114
|
+
if src_mod is not None:
|
|
115
|
+
graph.add_edge(Edge(mi.name, base, EdgeKind.IMPORT,
|
|
116
|
+
EdgeStrength.STRONG, mi.rel_path, imp.lineno,
|
|
117
|
+
f"from {base} import *"))
|
|
118
|
+
for name, sid in idx.toplevel.get(base, {}).items():
|
|
119
|
+
sym = idx.by_id[sid]
|
|
120
|
+
if src_mod.all_names is not None:
|
|
121
|
+
if sym.name in src_mod.all_names:
|
|
122
|
+
scope[name] = ("symbol", sid)
|
|
123
|
+
elif not name.startswith("_"):
|
|
124
|
+
scope[name] = ("symbol", sid)
|
|
125
|
+
continue
|
|
126
|
+
|
|
127
|
+
bound = imp.asname or imp.name
|
|
128
|
+
sid = idx.toplevel.get(base, {}).get(imp.name)
|
|
129
|
+
if sid:
|
|
130
|
+
scope[bound] = ("symbol", sid)
|
|
131
|
+
graph.add_edge(Edge(mi.name, sid, EdgeKind.IMPORT,
|
|
132
|
+
EdgeStrength.STRONG, mi.rel_path, imp.lineno,
|
|
133
|
+
f"from {base} import {imp.name}"))
|
|
134
|
+
# Importing a name from a module is also a use of the module.
|
|
135
|
+
if base in idx.modules and base != mi.name:
|
|
136
|
+
graph.add_edge(Edge(mi.name, base, EdgeKind.IMPORT,
|
|
137
|
+
EdgeStrength.STRONG, mi.rel_path,
|
|
138
|
+
imp.lineno, f"from {base} import ..."))
|
|
139
|
+
elif f"{base}.{imp.name}" in idx.modules:
|
|
140
|
+
sub = f"{base}.{imp.name}"
|
|
141
|
+
scope[bound] = ("module", sub)
|
|
142
|
+
graph.add_edge(Edge(mi.name, sub, EdgeKind.IMPORT,
|
|
143
|
+
EdgeStrength.STRONG, mi.rel_path, imp.lineno,
|
|
144
|
+
f"from {base} import {imp.name}"))
|
|
145
|
+
elif base in idx.modules:
|
|
146
|
+
# Name lives in an internal module but wasn't extracted (e.g.
|
|
147
|
+
# re-export chain). Credit the module; keeps it provably used.
|
|
148
|
+
graph.add_edge(Edge(mi.name, base, EdgeKind.IMPORT,
|
|
149
|
+
EdgeStrength.STRONG, mi.rel_path, imp.lineno,
|
|
150
|
+
f"from {base} import {imp.name}"))
|
|
151
|
+
scope[bound] = ("ext", None)
|
|
152
|
+
else:
|
|
153
|
+
scope[bound] = ("ext", None)
|
|
154
|
+
return scope
|
|
155
|
+
|
|
156
|
+
|
|
157
|
+
def _collect_chain(node: ast.AST) -> tuple[list[str], Optional[ast.AST]]:
|
|
158
|
+
"""Split `a.b.c` into (['a','b','c'], None), or (['send'], base_expr)
|
|
159
|
+
for `expr().send` where the base is not a plain name chain."""
|
|
160
|
+
parts: list[str] = []
|
|
161
|
+
cur = node
|
|
162
|
+
while isinstance(cur, ast.Attribute):
|
|
163
|
+
parts.append(cur.attr)
|
|
164
|
+
cur = cur.value
|
|
165
|
+
if isinstance(cur, ast.Name):
|
|
166
|
+
parts.append(cur.id)
|
|
167
|
+
parts.reverse()
|
|
168
|
+
return parts, None
|
|
169
|
+
parts.reverse()
|
|
170
|
+
return parts, cur
|
|
171
|
+
|
|
172
|
+
|
|
173
|
+
class EdgeVisitor(ast.NodeVisitor):
|
|
174
|
+
def __init__(self, mi: ModuleInfo, scope: dict, idx: SymbolIndex,
|
|
175
|
+
graph: CodeGraph):
|
|
176
|
+
self.mi = mi
|
|
177
|
+
self.scopes: list[dict] = [scope] # innermost last
|
|
178
|
+
self.idx = idx
|
|
179
|
+
self.graph = graph
|
|
180
|
+
self.src_stack: list[str] = [mi.name]
|
|
181
|
+
self.qual_stack: list[str] = []
|
|
182
|
+
self.class_stack: list[str] = [] # class symbol ids
|
|
183
|
+
self._handled: set[int] = set()
|
|
184
|
+
|
|
185
|
+
def _lookup(self, name: str) -> tuple[Optional[str], Optional[str]]:
|
|
186
|
+
for scope in reversed(self.scopes):
|
|
187
|
+
if name in scope:
|
|
188
|
+
return scope[name]
|
|
189
|
+
return None, None
|
|
190
|
+
|
|
191
|
+
def _local_defs(self, body) -> dict:
|
|
192
|
+
"""Names of defs nested directly in this body (incl. conditional
|
|
193
|
+
blocks) — they are referenced by bare name in the enclosing scope."""
|
|
194
|
+
local: dict = {}
|
|
195
|
+
prefix = ".".join(self.qual_stack)
|
|
196
|
+
|
|
197
|
+
def collect(stmts):
|
|
198
|
+
for node in stmts:
|
|
199
|
+
if isinstance(node, (ast.FunctionDef, ast.AsyncFunctionDef,
|
|
200
|
+
ast.ClassDef)):
|
|
201
|
+
qual = f"{prefix}.{node.name}" if prefix else node.name
|
|
202
|
+
sid = f"{self.mi.name}:{qual}"
|
|
203
|
+
if sid in self.idx.by_id:
|
|
204
|
+
local[node.name] = ("symbol", sid)
|
|
205
|
+
elif isinstance(node, ast.If):
|
|
206
|
+
collect(node.body)
|
|
207
|
+
collect(node.orelse)
|
|
208
|
+
elif isinstance(node, ast.Try):
|
|
209
|
+
collect(node.body)
|
|
210
|
+
collect(node.orelse)
|
|
211
|
+
collect(node.finalbody)
|
|
212
|
+
for h in node.handlers:
|
|
213
|
+
collect(h.body)
|
|
214
|
+
elif isinstance(node, ast.With):
|
|
215
|
+
collect(node.body)
|
|
216
|
+
|
|
217
|
+
collect(body)
|
|
218
|
+
return local
|
|
219
|
+
|
|
220
|
+
# -- helpers ------------------------------------------------------------
|
|
221
|
+
|
|
222
|
+
@property
|
|
223
|
+
def src(self) -> str:
|
|
224
|
+
return self.src_stack[-1]
|
|
225
|
+
|
|
226
|
+
def _emit(self, dst: str, kind: EdgeKind, strength: EdgeStrength,
|
|
227
|
+
node: ast.AST, detail: str = "") -> None:
|
|
228
|
+
self.graph.add_edge(Edge(self.src, dst, kind, strength,
|
|
229
|
+
self.mi.rel_path, getattr(node, "lineno", 0),
|
|
230
|
+
detail))
|
|
231
|
+
|
|
232
|
+
def _resolve_parts(self, parts: list[str]) -> tuple[Optional[str], bool]:
|
|
233
|
+
"""Resolve a dotted name chain against scope + index.
|
|
234
|
+
Returns (deepest resolved symbol id or None, fully_resolved)."""
|
|
235
|
+
kind, target = self._lookup(parts[0])
|
|
236
|
+
if kind is None or kind == "ext":
|
|
237
|
+
return None, False
|
|
238
|
+
i = 1
|
|
239
|
+
cur_id: Optional[str] = None
|
|
240
|
+
if kind == "symbol":
|
|
241
|
+
cur_id = target
|
|
242
|
+
else: # module
|
|
243
|
+
cur_mod = target
|
|
244
|
+
while i < len(parts):
|
|
245
|
+
nxt_sym = self.idx.toplevel.get(cur_mod, {}).get(parts[i])
|
|
246
|
+
if nxt_sym:
|
|
247
|
+
cur_id = nxt_sym
|
|
248
|
+
i += 1
|
|
249
|
+
break
|
|
250
|
+
sub = f"{cur_mod}.{parts[i]}"
|
|
251
|
+
if sub in self.idx.modules:
|
|
252
|
+
cur_mod = sub
|
|
253
|
+
i += 1
|
|
254
|
+
else:
|
|
255
|
+
return (cur_mod if cur_mod in self.idx.by_id else None), False
|
|
256
|
+
else:
|
|
257
|
+
return (cur_mod if cur_mod in self.idx.by_id else None), True
|
|
258
|
+
# Descend through class members.
|
|
259
|
+
while i < len(parts) and cur_id:
|
|
260
|
+
sym = self.idx.by_id.get(cur_id)
|
|
261
|
+
if sym and sym.type is SymbolType.CLASS:
|
|
262
|
+
member = self.idx.class_members.get(cur_id, {}).get(parts[i])
|
|
263
|
+
if member:
|
|
264
|
+
cur_id = member
|
|
265
|
+
i += 1
|
|
266
|
+
continue
|
|
267
|
+
return cur_id, False
|
|
268
|
+
return cur_id, i >= len(parts)
|
|
269
|
+
|
|
270
|
+
def _weak_name_edges(self, attr: str, node: ast.AST, kind: EdgeKind) -> None:
|
|
271
|
+
if attr.startswith("__") or len(attr) < 2:
|
|
272
|
+
return
|
|
273
|
+
matches = self.idx.by_name.get(attr, [])
|
|
274
|
+
if not matches or len(matches) > MAX_NAME_MATCHES:
|
|
275
|
+
return
|
|
276
|
+
for sid in matches:
|
|
277
|
+
if sid == self.src:
|
|
278
|
+
continue
|
|
279
|
+
self._emit(sid, kind, EdgeStrength.WEAK, node,
|
|
280
|
+
f"attribute name match '.{attr}'")
|
|
281
|
+
|
|
282
|
+
def _mark_handled_chain(self, node: ast.AST) -> Optional[ast.AST]:
|
|
283
|
+
"""Mark the Attribute/Name chain as handled; return the non-chain
|
|
284
|
+
base expression (still needs visiting), if any."""
|
|
285
|
+
cur = node
|
|
286
|
+
while isinstance(cur, ast.Attribute):
|
|
287
|
+
self._handled.add(id(cur))
|
|
288
|
+
cur = cur.value
|
|
289
|
+
if isinstance(cur, ast.Name):
|
|
290
|
+
self._handled.add(id(cur))
|
|
291
|
+
return None
|
|
292
|
+
return cur
|
|
293
|
+
|
|
294
|
+
def _handle_ref(self, node: ast.AST, kind: EdgeKind) -> None:
|
|
295
|
+
"""Shared logic for Name / Attribute loads and call targets."""
|
|
296
|
+
parts, base_expr = _collect_chain(node)
|
|
297
|
+
rest = self._mark_handled_chain(node)
|
|
298
|
+
if base_expr is None:
|
|
299
|
+
# Chain rooted at a plain name: self/cls handling first.
|
|
300
|
+
if parts[0] in ("self", "cls") and self.class_stack and len(parts) >= 2:
|
|
301
|
+
member = self.idx.class_members.get(self.class_stack[-1], {}) \
|
|
302
|
+
.get(parts[1])
|
|
303
|
+
if member:
|
|
304
|
+
self._emit(member, kind, EdgeStrength.STRONG, node,
|
|
305
|
+
f"{parts[0]}.{parts[1]}")
|
|
306
|
+
else:
|
|
307
|
+
self._weak_name_edges(parts[1], node, EdgeKind.ATTRIBUTE)
|
|
308
|
+
return
|
|
309
|
+
resolved, full = self._resolve_parts(parts)
|
|
310
|
+
if resolved:
|
|
311
|
+
self._emit(resolved, kind, EdgeStrength.STRONG, node,
|
|
312
|
+
".".join(parts))
|
|
313
|
+
if not full:
|
|
314
|
+
# Weak name-match every unresolved attribute segment, not
|
|
315
|
+
# just the last — `ctx.index.by_name` must keep `.index`
|
|
316
|
+
# looking alive too.
|
|
317
|
+
for attr in parts[1:]:
|
|
318
|
+
self._weak_name_edges(attr, node, EdgeKind.ATTRIBUTE)
|
|
319
|
+
return
|
|
320
|
+
# Attribute on a computed expression. If the expression is a direct
|
|
321
|
+
# constructor call of a known class — `Extractor(x).run()` — resolve
|
|
322
|
+
# the attribute as a strong member access.
|
|
323
|
+
resolved_member = False
|
|
324
|
+
if parts and isinstance(base_expr, ast.Call) \
|
|
325
|
+
and isinstance(base_expr.func, (ast.Name, ast.Attribute)):
|
|
326
|
+
fparts, fbase = _collect_chain(base_expr.func)
|
|
327
|
+
if fbase is None:
|
|
328
|
+
cls_id, full = self._resolve_parts(fparts)
|
|
329
|
+
if cls_id and full:
|
|
330
|
+
cls = self.idx.by_id.get(cls_id)
|
|
331
|
+
if cls and cls.type is SymbolType.CLASS:
|
|
332
|
+
member = self.idx.class_members.get(cls_id, {}) \
|
|
333
|
+
.get(parts[0])
|
|
334
|
+
if member:
|
|
335
|
+
self._emit(member, kind, EdgeStrength.STRONG, node,
|
|
336
|
+
f"{'.'.join(fparts)}(...).{parts[0]}")
|
|
337
|
+
resolved_member = True
|
|
338
|
+
if parts and not resolved_member:
|
|
339
|
+
for attr in parts:
|
|
340
|
+
self._weak_name_edges(attr, node, EdgeKind.ATTRIBUTE)
|
|
341
|
+
if rest is not None:
|
|
342
|
+
self.visit(rest)
|
|
343
|
+
|
|
344
|
+
# -- definition scoping ---------------------------------------------------
|
|
345
|
+
|
|
346
|
+
def _current_def_id(self) -> str:
|
|
347
|
+
return f"{self.mi.name}:{'.'.join(self.qual_stack)}"
|
|
348
|
+
|
|
349
|
+
def visit_FunctionDef(self, node):
|
|
350
|
+
self._visit_def(node, is_class=False)
|
|
351
|
+
|
|
352
|
+
def visit_AsyncFunctionDef(self, node):
|
|
353
|
+
self._visit_def(node, is_class=False)
|
|
354
|
+
|
|
355
|
+
def visit_ClassDef(self, node):
|
|
356
|
+
self._visit_def(node, is_class=True)
|
|
357
|
+
|
|
358
|
+
def _visit_def(self, node, is_class: bool):
|
|
359
|
+
# Decorators, defaults, annotations, and bases execute in the
|
|
360
|
+
# enclosing scope — visit them before pushing the new scope.
|
|
361
|
+
for dec in node.decorator_list:
|
|
362
|
+
self.visit(dec)
|
|
363
|
+
if is_class:
|
|
364
|
+
pass # bases handled below with the class as src
|
|
365
|
+
else:
|
|
366
|
+
for d in list(node.args.defaults) + [d for d in node.args.kw_defaults if d]:
|
|
367
|
+
self.visit(d)
|
|
368
|
+
|
|
369
|
+
self.qual_stack.append(node.name)
|
|
370
|
+
sym_id = self._current_def_id()
|
|
371
|
+
known = sym_id in self.idx.by_id
|
|
372
|
+
self.src_stack.append(sym_id if known else self.src_stack[-1])
|
|
373
|
+
# Defs nested in this body are referenced by bare name from here.
|
|
374
|
+
self.scopes.append(self._local_defs(node.body))
|
|
375
|
+
if is_class and known:
|
|
376
|
+
self.class_stack.append(sym_id)
|
|
377
|
+
for base in node.bases:
|
|
378
|
+
parts, base_expr = _collect_chain(base)
|
|
379
|
+
if base_expr is None and parts:
|
|
380
|
+
self._mark_handled_chain(base)
|
|
381
|
+
resolved, full = self._resolve_parts(parts)
|
|
382
|
+
if resolved and full:
|
|
383
|
+
self._emit(resolved, EdgeKind.INHERIT,
|
|
384
|
+
EdgeStrength.STRONG, base, ".".join(parts))
|
|
385
|
+
for child in node.body:
|
|
386
|
+
self.visit(child)
|
|
387
|
+
self.scopes.pop()
|
|
388
|
+
if is_class and known:
|
|
389
|
+
self.class_stack.pop()
|
|
390
|
+
self.src_stack.pop()
|
|
391
|
+
self.qual_stack.pop()
|
|
392
|
+
|
|
393
|
+
# -- reference sites ------------------------------------------------------
|
|
394
|
+
|
|
395
|
+
def visit_Call(self, node):
|
|
396
|
+
if isinstance(node.func, (ast.Name, ast.Attribute)):
|
|
397
|
+
self._handle_ref(node.func, EdgeKind.CALL)
|
|
398
|
+
for arg in node.args:
|
|
399
|
+
self.visit(arg)
|
|
400
|
+
for kw in node.keywords:
|
|
401
|
+
self.visit(kw.value)
|
|
402
|
+
if not isinstance(node.func, (ast.Name, ast.Attribute)):
|
|
403
|
+
self.visit(node.func)
|
|
404
|
+
|
|
405
|
+
def visit_Attribute(self, node):
|
|
406
|
+
if id(node) in self._handled:
|
|
407
|
+
return
|
|
408
|
+
if isinstance(node.ctx, ast.Load):
|
|
409
|
+
self._handle_ref(node, EdgeKind.ATTRIBUTE)
|
|
410
|
+
else:
|
|
411
|
+
self.generic_visit(node)
|
|
412
|
+
|
|
413
|
+
def visit_Name(self, node):
|
|
414
|
+
if id(node) in self._handled:
|
|
415
|
+
return
|
|
416
|
+
if isinstance(node.ctx, ast.Load):
|
|
417
|
+
kind, target = self._lookup(node.id)
|
|
418
|
+
if kind == "symbol" and target != self.src:
|
|
419
|
+
self._emit(target, EdgeKind.REFERENCE, EdgeStrength.STRONG,
|
|
420
|
+
node, node.id)
|
|
421
|
+
elif kind == "module" and target in self.idx.by_id:
|
|
422
|
+
self._emit(target, EdgeKind.REFERENCE, EdgeStrength.STRONG,
|
|
423
|
+
node, node.id)
|
|
424
|
+
|
|
425
|
+
|
|
426
|
+
def build_edges(modules: list[ModuleInfo], idx: SymbolIndex, graph: CodeGraph,
|
|
427
|
+
markers: list[Marker]) -> None:
|
|
428
|
+
for sym in idx.all_symbols():
|
|
429
|
+
graph.add_symbol(sym)
|
|
430
|
+
|
|
431
|
+
for mi in modules:
|
|
432
|
+
if mi.tree is None:
|
|
433
|
+
continue
|
|
434
|
+
scope = build_scope(mi, idx, graph)
|
|
435
|
+
EdgeVisitor(mi, scope, idx, graph).visit(mi.tree)
|
|
436
|
+
|
|
437
|
+
# Methods on classes with unresolvable (external) bases may satisfy an
|
|
438
|
+
# interface the framework calls — flag them so they never look provably dead.
|
|
439
|
+
for mi in modules:
|
|
440
|
+
for sym in mi.symbols:
|
|
441
|
+
if sym.type is not SymbolType.CLASS or not sym.bases:
|
|
442
|
+
continue
|
|
443
|
+
external = [b for b in sym.bases
|
|
444
|
+
if b.split(".")[-1] not in {x.split(".")[-1] for x in BENIGN_BASES}
|
|
445
|
+
and b not in BENIGN_BASES
|
|
446
|
+
and not _base_is_internal(b, sym, mi, idx)]
|
|
447
|
+
if not external:
|
|
448
|
+
continue
|
|
449
|
+
for member_id in idx.class_members.get(sym.id, {}).values():
|
|
450
|
+
member = idx.by_id[member_id]
|
|
451
|
+
if member.type is SymbolType.METHOD and not member.name.startswith("__"):
|
|
452
|
+
markers.append(Marker(
|
|
453
|
+
member_id, MarkerKind.CAUTION,
|
|
454
|
+
f"class {sym.qualname} inherits external base "
|
|
455
|
+
f"{external[0]!r}; this method may implement its interface",
|
|
456
|
+
rule="external-base", file=sym.file, line=sym.line))
|
|
457
|
+
|
|
458
|
+
|
|
459
|
+
def _base_is_internal(base: str, cls: Symbol, mi: ModuleInfo,
|
|
460
|
+
idx: SymbolIndex) -> bool:
|
|
461
|
+
"""True if the base name resolves to a class defined in this repo.
|
|
462
|
+
Must never match the subclass itself — `class TextWrapper(textwrap.
|
|
463
|
+
TextWrapper)` extends the stdlib, not itself."""
|
|
464
|
+
parts = base.split(".")
|
|
465
|
+
if len(parts) > 1:
|
|
466
|
+
# Dotted base: internal only when the root is an internal module
|
|
467
|
+
# or a name defined in this module.
|
|
468
|
+
return (idx.is_internal_module_prefix(parts[0])
|
|
469
|
+
or parts[0] in idx.toplevel.get(mi.name, {}))
|
|
470
|
+
for m in idx.modules.values():
|
|
471
|
+
sid = idx.toplevel.get(m.name, {}).get(base)
|
|
472
|
+
if sid and sid != cls.id and idx.by_id[sid].type is SymbolType.CLASS:
|
|
473
|
+
return True
|
|
474
|
+
return False
|