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.
Files changed (37) hide show
  1. codetruth/__init__.py +16 -0
  2. codetruth/api.py +102 -0
  3. codetruth/cli.py +183 -0
  4. codetruth/core/__init__.py +0 -0
  5. codetruth/core/cache.py +104 -0
  6. codetruth/core/config.py +74 -0
  7. codetruth/core/deletion.py +165 -0
  8. codetruth/core/evidence.py +306 -0
  9. codetruth/core/graph.py +44 -0
  10. codetruth/core/models.py +193 -0
  11. codetruth/core/plugin.py +88 -0
  12. codetruth/core/report.py +107 -0
  13. codetruth/core/scanner.py +228 -0
  14. codetruth/languages/__init__.py +0 -0
  15. codetruth/languages/go/__init__.py +1 -0
  16. codetruth/languages/javascript/__init__.py +8 -0
  17. codetruth/languages/javascript/edges.py +361 -0
  18. codetruth/languages/javascript/extractor.py +402 -0
  19. codetruth/languages/javascript/plugin.py +52 -0
  20. codetruth/languages/javascript/rules.py +201 -0
  21. codetruth/languages/python/__init__.py +0 -0
  22. codetruth/languages/python/edges.py +474 -0
  23. codetruth/languages/python/extractor.py +271 -0
  24. codetruth/languages/python/plugin.py +49 -0
  25. codetruth/languages/python/rules.py +332 -0
  26. codetruth/mcp_server.py +138 -0
  27. codetruth/rules/python/common_dynamic.yaml +70 -0
  28. codetruth/rules/python/django.yaml +57 -0
  29. codetruth/rules/python/fastapi.yaml +38 -0
  30. codetruth/rules/python/orm_web.yaml +50 -0
  31. codetruth/runtime/__init__.py +283 -0
  32. codetruth-0.2.0.dist-info/METADATA +215 -0
  33. codetruth-0.2.0.dist-info/RECORD +37 -0
  34. codetruth-0.2.0.dist-info/WHEEL +5 -0
  35. codetruth-0.2.0.dist-info/entry_points.txt +2 -0
  36. codetruth-0.2.0.dist-info/licenses/LICENSE +21 -0
  37. 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