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,271 @@
|
|
|
1
|
+
"""Layer 1 — symbol extraction for Python via the ast module.
|
|
2
|
+
|
|
3
|
+
Walks every .py file in the repo and produces a flat symbol table:
|
|
4
|
+
modules, classes, functions, methods, and module-level variables, plus the
|
|
5
|
+
raw import statements and __all__ needed by the edge builder (Layer 2).
|
|
6
|
+
"""
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
import ast
|
|
10
|
+
from dataclasses import dataclass, field
|
|
11
|
+
from pathlib import Path
|
|
12
|
+
from typing import Optional
|
|
13
|
+
|
|
14
|
+
from ...core.models import Symbol, SymbolType
|
|
15
|
+
|
|
16
|
+
SKIP_DIRS = {
|
|
17
|
+
".git", ".hg", ".svn", "__pycache__", ".venv", "venv", "env", ".env",
|
|
18
|
+
"node_modules", ".tox", ".nox", ".mypy_cache", ".pytest_cache",
|
|
19
|
+
".ruff_cache", "build", "dist", ".eggs", "site-packages", ".idea", ".vscode",
|
|
20
|
+
".codetruth", # CodeTruth's own cache — must never be scanned or fingerprinted
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
CONFIG_EXTS = {".json", ".yaml", ".yml", ".toml", ".ini", ".cfg"}
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
@dataclass
|
|
27
|
+
class ImportRec:
|
|
28
|
+
kind: str # "import" | "from"
|
|
29
|
+
module: str # dotted module ('' for `from . import x`)
|
|
30
|
+
name: str # imported name ('' for plain `import x`, '*' for star)
|
|
31
|
+
asname: Optional[str]
|
|
32
|
+
level: int # relative-import level
|
|
33
|
+
lineno: int
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
@dataclass
|
|
37
|
+
class ModuleInfo:
|
|
38
|
+
name: str # dotted module name
|
|
39
|
+
rel_path: str # repo-relative path, forward slashes
|
|
40
|
+
abs_path: str
|
|
41
|
+
is_test: bool
|
|
42
|
+
is_package: bool # file is an __init__.py
|
|
43
|
+
tree: Optional[ast.Module] = None
|
|
44
|
+
symbols: list[Symbol] = field(default_factory=list)
|
|
45
|
+
imports: list[ImportRec] = field(default_factory=list)
|
|
46
|
+
all_names: Optional[list[str]] = None # contents of __all__ if present
|
|
47
|
+
has_main_guard: bool = False
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def is_test_path(rel_path: str) -> bool:
|
|
51
|
+
parts = rel_path.replace("\\", "/").split("/")
|
|
52
|
+
base = parts[-1]
|
|
53
|
+
if base == "conftest.py" or base.startswith("test_") or base.endswith("_test.py"):
|
|
54
|
+
return True
|
|
55
|
+
return any(p in ("tests", "test", "testing") for p in parts[:-1])
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def _ignored(rel_posix: str, ignores: tuple[str, ...]) -> bool:
|
|
59
|
+
if not ignores:
|
|
60
|
+
return False
|
|
61
|
+
from ...core.config import RepoConfig
|
|
62
|
+
return RepoConfig(ignore_paths=list(ignores)).is_ignored(rel_posix)
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
def iter_py_files(root: Path, ignores: tuple[str, ...] = ()):
|
|
66
|
+
for path in sorted(root.rglob("*.py")):
|
|
67
|
+
rel = path.relative_to(root)
|
|
68
|
+
if any(part in SKIP_DIRS for part in rel.parts):
|
|
69
|
+
continue
|
|
70
|
+
if _ignored(rel.as_posix(), ignores):
|
|
71
|
+
continue
|
|
72
|
+
yield path
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
def iter_config_files(root: Path, ignores: tuple[str, ...] = ()):
|
|
76
|
+
for path in sorted(root.rglob("*")):
|
|
77
|
+
if not path.is_file() or path.suffix.lower() not in CONFIG_EXTS:
|
|
78
|
+
continue
|
|
79
|
+
rel = path.relative_to(root)
|
|
80
|
+
if any(part in SKIP_DIRS for part in rel.parts):
|
|
81
|
+
continue
|
|
82
|
+
if _ignored(rel.as_posix(), ignores):
|
|
83
|
+
continue
|
|
84
|
+
yield path
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
def module_name_for(path: Path, root: Path) -> tuple[str, bool]:
|
|
88
|
+
"""Dotted module name from the file path; returns (name, is_package)."""
|
|
89
|
+
rel = path.relative_to(root)
|
|
90
|
+
parts = list(rel.parts)
|
|
91
|
+
is_package = parts[-1] == "__init__.py"
|
|
92
|
+
if is_package:
|
|
93
|
+
parts = parts[:-1]
|
|
94
|
+
else:
|
|
95
|
+
parts[-1] = parts[-1][:-3] # strip .py
|
|
96
|
+
if not parts: # __init__.py at repo root
|
|
97
|
+
parts = [root.name]
|
|
98
|
+
return ".".join(parts), is_package
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
def dotted_name(node: ast.AST) -> Optional[str]:
|
|
102
|
+
"""Best-effort dotted name for decorators / base classes / call targets."""
|
|
103
|
+
if isinstance(node, ast.Call):
|
|
104
|
+
return dotted_name(node.func)
|
|
105
|
+
if isinstance(node, ast.Attribute):
|
|
106
|
+
base = dotted_name(node.value)
|
|
107
|
+
return f"{base}.{node.attr}" if base else node.attr
|
|
108
|
+
if isinstance(node, ast.Name):
|
|
109
|
+
return node.id
|
|
110
|
+
return None
|
|
111
|
+
|
|
112
|
+
|
|
113
|
+
def _is_main_guard(node: ast.If) -> bool:
|
|
114
|
+
t = node.test
|
|
115
|
+
if isinstance(t, ast.Compare) and len(t.comparators) == 1:
|
|
116
|
+
left, right = t.left, t.comparators[0]
|
|
117
|
+
for a, b in ((left, right), (right, left)):
|
|
118
|
+
if (isinstance(a, ast.Name) and a.id == "__name__"
|
|
119
|
+
and isinstance(b, ast.Constant) and b.value == "__main__"):
|
|
120
|
+
return True
|
|
121
|
+
return False
|
|
122
|
+
|
|
123
|
+
|
|
124
|
+
class _Extractor:
|
|
125
|
+
def __init__(self, mi: ModuleInfo):
|
|
126
|
+
self.mi = mi
|
|
127
|
+
|
|
128
|
+
def run(self) -> None:
|
|
129
|
+
mi = self.mi
|
|
130
|
+
mod_sym = Symbol(
|
|
131
|
+
id=mi.name, name=mi.name.rsplit(".", 1)[-1], qualname=mi.name,
|
|
132
|
+
type=SymbolType.MODULE, file=mi.rel_path, line=1,
|
|
133
|
+
end_line=getattr(mi.tree, "end_lineno", 1) or 1,
|
|
134
|
+
module=mi.name, parent=None, exported=True, is_public=True,
|
|
135
|
+
is_test=mi.is_test,
|
|
136
|
+
)
|
|
137
|
+
mi.symbols.append(mod_sym)
|
|
138
|
+
self._walk_body(mi.tree.body, prefix="", parent=mi.name, in_class=False, depth=0)
|
|
139
|
+
# Resolve exported flags now that __all__ is known.
|
|
140
|
+
for s in mi.symbols:
|
|
141
|
+
if s.type is SymbolType.MODULE or s.parent != mi.name:
|
|
142
|
+
continue
|
|
143
|
+
if mi.all_names is not None:
|
|
144
|
+
s.exported = s.name in mi.all_names
|
|
145
|
+
else:
|
|
146
|
+
s.exported = not s.name.startswith("_")
|
|
147
|
+
|
|
148
|
+
def _walk_body(self, body, prefix: str, parent: str, in_class: bool, depth: int):
|
|
149
|
+
for node in body:
|
|
150
|
+
if isinstance(node, (ast.FunctionDef, ast.AsyncFunctionDef)):
|
|
151
|
+
self._add_def(node, prefix, parent, in_class, depth, is_class=False)
|
|
152
|
+
elif isinstance(node, ast.ClassDef):
|
|
153
|
+
self._add_def(node, prefix, parent, in_class, depth, is_class=True)
|
|
154
|
+
elif isinstance(node, (ast.Assign, ast.AnnAssign)):
|
|
155
|
+
if depth == 0 or in_class:
|
|
156
|
+
self._add_variables(node, prefix, parent, depth)
|
|
157
|
+
elif isinstance(node, (ast.Import, ast.ImportFrom)):
|
|
158
|
+
self._add_import(node)
|
|
159
|
+
elif isinstance(node, ast.If):
|
|
160
|
+
if depth == 0 and _is_main_guard(node):
|
|
161
|
+
self.mi.has_main_guard = True
|
|
162
|
+
# Descend into conditional blocks at the same scope (covers
|
|
163
|
+
# TYPE_CHECKING imports, platform-conditional defs, etc.).
|
|
164
|
+
self._walk_body(node.body, prefix, parent, in_class, depth)
|
|
165
|
+
self._walk_body(node.orelse, prefix, parent, in_class, depth)
|
|
166
|
+
elif isinstance(node, ast.Try):
|
|
167
|
+
for blk in (node.body, node.orelse, node.finalbody):
|
|
168
|
+
self._walk_body(blk, prefix, parent, in_class, depth)
|
|
169
|
+
for handler in node.handlers:
|
|
170
|
+
self._walk_body(handler.body, prefix, parent, in_class, depth)
|
|
171
|
+
elif isinstance(node, ast.With):
|
|
172
|
+
self._walk_body(node.body, prefix, parent, in_class, depth)
|
|
173
|
+
else:
|
|
174
|
+
# Function-level imports still create aliases; catch them.
|
|
175
|
+
for sub in ast.walk(node):
|
|
176
|
+
if isinstance(sub, (ast.Import, ast.ImportFrom)):
|
|
177
|
+
self._add_import(sub)
|
|
178
|
+
|
|
179
|
+
def _add_def(self, node, prefix, parent, in_class, depth, is_class: bool):
|
|
180
|
+
mi = self.mi
|
|
181
|
+
qual = f"{prefix}{node.name}"
|
|
182
|
+
sym_id = f"{mi.name}:{qual}"
|
|
183
|
+
if is_class:
|
|
184
|
+
stype = SymbolType.CLASS
|
|
185
|
+
else:
|
|
186
|
+
stype = SymbolType.METHOD if in_class else SymbolType.FUNCTION
|
|
187
|
+
decorators = [d for d in (dotted_name(x) for x in node.decorator_list) if d]
|
|
188
|
+
bases = []
|
|
189
|
+
if is_class:
|
|
190
|
+
bases = [b for b in (dotted_name(x) for x in node.bases) if b]
|
|
191
|
+
sym = Symbol(
|
|
192
|
+
id=sym_id, name=node.name, qualname=qual, type=stype,
|
|
193
|
+
file=mi.rel_path, line=node.lineno,
|
|
194
|
+
end_line=node.end_lineno or node.lineno, module=mi.name,
|
|
195
|
+
parent=parent, exported=False,
|
|
196
|
+
is_public=(depth == 0 or in_class) and not node.name.startswith("_"),
|
|
197
|
+
is_test=mi.is_test, decorators=decorators, bases=bases,
|
|
198
|
+
)
|
|
199
|
+
mi.symbols.append(sym)
|
|
200
|
+
# Function-level imports inside the def still matter for edges.
|
|
201
|
+
self._walk_body(node.body, prefix=f"{qual}.", parent=sym_id,
|
|
202
|
+
in_class=is_class, depth=depth + 1)
|
|
203
|
+
|
|
204
|
+
def _add_variables(self, node, prefix, parent, depth):
|
|
205
|
+
mi = self.mi
|
|
206
|
+
targets = node.targets if isinstance(node, ast.Assign) else [node.target]
|
|
207
|
+
for t in targets:
|
|
208
|
+
names = []
|
|
209
|
+
if isinstance(t, ast.Name):
|
|
210
|
+
names = [t.id]
|
|
211
|
+
elif isinstance(t, (ast.Tuple, ast.List)):
|
|
212
|
+
names = [e.id for e in t.elts if isinstance(e, ast.Name)]
|
|
213
|
+
for name in names:
|
|
214
|
+
if name == "__all__" and depth == 0:
|
|
215
|
+
mi.all_names = self._literal_str_list(node.value)
|
|
216
|
+
continue
|
|
217
|
+
if name.startswith("__") and name.endswith("__"):
|
|
218
|
+
continue # dunder module metadata (__version__, etc.)
|
|
219
|
+
qual = f"{prefix}{name}"
|
|
220
|
+
sym_id = f"{mi.name}:{qual}"
|
|
221
|
+
if any(s.id == sym_id for s in mi.symbols):
|
|
222
|
+
continue # reassignment — keep first definition site
|
|
223
|
+
mi.symbols.append(Symbol(
|
|
224
|
+
id=sym_id, name=name, qualname=qual, type=SymbolType.VARIABLE,
|
|
225
|
+
file=mi.rel_path, line=node.lineno,
|
|
226
|
+
end_line=node.end_lineno or node.lineno, module=mi.name,
|
|
227
|
+
parent=parent, exported=False,
|
|
228
|
+
is_public=not name.startswith("_"), is_test=mi.is_test,
|
|
229
|
+
))
|
|
230
|
+
|
|
231
|
+
@staticmethod
|
|
232
|
+
def _literal_str_list(value) -> list[str]:
|
|
233
|
+
out = []
|
|
234
|
+
if isinstance(value, (ast.List, ast.Tuple)):
|
|
235
|
+
for e in value.elts:
|
|
236
|
+
if isinstance(e, ast.Constant) and isinstance(e.value, str):
|
|
237
|
+
out.append(e.value)
|
|
238
|
+
return out
|
|
239
|
+
|
|
240
|
+
def _add_import(self, node) -> None:
|
|
241
|
+
mi = self.mi
|
|
242
|
+
if isinstance(node, ast.Import):
|
|
243
|
+
for alias in node.names:
|
|
244
|
+
mi.imports.append(ImportRec("import", alias.name, "",
|
|
245
|
+
alias.asname, 0, node.lineno))
|
|
246
|
+
else:
|
|
247
|
+
for alias in node.names:
|
|
248
|
+
mi.imports.append(ImportRec("from", node.module or "", alias.name,
|
|
249
|
+
alias.asname, node.level, node.lineno))
|
|
250
|
+
|
|
251
|
+
|
|
252
|
+
def extract_repo(repo_path: Path,
|
|
253
|
+
ignores: tuple[str, ...] = ()) -> tuple[list[ModuleInfo], list[str]]:
|
|
254
|
+
"""Parse every Python file under repo_path. Returns (modules, warnings)."""
|
|
255
|
+
modules: list[ModuleInfo] = []
|
|
256
|
+
warnings: list[str] = []
|
|
257
|
+
for path in iter_py_files(repo_path, ignores):
|
|
258
|
+
rel = path.relative_to(repo_path).as_posix()
|
|
259
|
+
name, is_package = module_name_for(path, repo_path)
|
|
260
|
+
mi = ModuleInfo(name=name, rel_path=rel, abs_path=str(path),
|
|
261
|
+
is_test=is_test_path(rel), is_package=is_package)
|
|
262
|
+
try:
|
|
263
|
+
source = path.read_text(encoding="utf-8", errors="replace")
|
|
264
|
+
mi.tree = ast.parse(source, filename=rel)
|
|
265
|
+
except (SyntaxError, ValueError) as exc:
|
|
266
|
+
warnings.append(f"parse error in {rel}: {exc}")
|
|
267
|
+
modules.append(mi)
|
|
268
|
+
continue
|
|
269
|
+
_Extractor(mi).run()
|
|
270
|
+
modules.append(mi)
|
|
271
|
+
return modules, warnings
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
"""The v1 language plugin: full Python support."""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
from pathlib import Path
|
|
5
|
+
|
|
6
|
+
from ...core.graph import CodeGraph
|
|
7
|
+
from ...core.models import Marker, Symbol
|
|
8
|
+
from ...core.plugin import LanguagePlugin, Rule
|
|
9
|
+
from . import edges as edges_mod
|
|
10
|
+
from . import rules as rules_mod
|
|
11
|
+
from .extractor import (ModuleInfo, extract_repo, iter_config_files,
|
|
12
|
+
iter_py_files)
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
class PythonPlugin(LanguagePlugin):
|
|
16
|
+
name = "python"
|
|
17
|
+
|
|
18
|
+
def extract(self, repo_path: Path,
|
|
19
|
+
ignores: tuple[str, ...] = ()) -> tuple[list[ModuleInfo], list[str]]:
|
|
20
|
+
return extract_repo(repo_path, ignores)
|
|
21
|
+
|
|
22
|
+
def build_index(self, modules: list[ModuleInfo]) -> edges_mod.SymbolIndex:
|
|
23
|
+
return edges_mod.SymbolIndex(modules)
|
|
24
|
+
|
|
25
|
+
def build_edges(self, repo_path: Path, modules: list[ModuleInfo],
|
|
26
|
+
index: edges_mod.SymbolIndex, graph: CodeGraph,
|
|
27
|
+
markers: list[Marker]) -> None:
|
|
28
|
+
edges_mod.build_edges(modules, index, graph, markers)
|
|
29
|
+
|
|
30
|
+
def rules(self) -> list[Rule]:
|
|
31
|
+
return rules_mod.default_rules()
|
|
32
|
+
|
|
33
|
+
def symbols(self, modules: list[ModuleInfo]) -> list[Symbol]:
|
|
34
|
+
return [s for m in modules for s in m.symbols]
|
|
35
|
+
|
|
36
|
+
def config_files(self, repo_path: Path,
|
|
37
|
+
ignores: tuple[str, ...] = ()) -> list[Path]:
|
|
38
|
+
return list(iter_config_files(repo_path, ignores))
|
|
39
|
+
|
|
40
|
+
def source_files(self, repo_path: Path,
|
|
41
|
+
ignores: tuple[str, ...] = ()) -> list[Path]:
|
|
42
|
+
"""Every file whose bytes affect a scan — used for cache fingerprints.
|
|
43
|
+
Includes .codetruth.toml so config changes invalidate the cache."""
|
|
44
|
+
files = list(iter_py_files(repo_path, ignores)) \
|
|
45
|
+
+ list(iter_config_files(repo_path, ignores))
|
|
46
|
+
toml = repo_path / ".codetruth.toml"
|
|
47
|
+
if toml.is_file():
|
|
48
|
+
files.append(toml)
|
|
49
|
+
return files
|
|
@@ -0,0 +1,332 @@
|
|
|
1
|
+
"""Layer 3 — semantic safety rules for Python.
|
|
2
|
+
|
|
3
|
+
Each rule scans ASTs / the symbol table for one dynamic-usage pattern and
|
|
4
|
+
emits weak edges or markers. Framework knowledge (FastAPI, Django, Celery,
|
|
5
|
+
pytest, ...) lives in YAML files under codetruth/rules/python/ so coverage
|
|
6
|
+
can grow without code changes.
|
|
7
|
+
"""
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import ast
|
|
11
|
+
import fnmatch
|
|
12
|
+
import re
|
|
13
|
+
from pathlib import Path
|
|
14
|
+
|
|
15
|
+
import yaml
|
|
16
|
+
|
|
17
|
+
from ...core.models import (Edge, EdgeKind, EdgeStrength, Marker, MarkerKind,
|
|
18
|
+
SymbolType)
|
|
19
|
+
from ...core.plugin import Rule, RuleContext
|
|
20
|
+
from .extractor import dotted_name
|
|
21
|
+
|
|
22
|
+
RULES_DIR = Path(__file__).resolve().parents[2] / "rules" / "python"
|
|
23
|
+
|
|
24
|
+
IDENT_RE = re.compile(r"^[A-Za-z_][A-Za-z0-9_]*$")
|
|
25
|
+
DOTTED_RE = re.compile(r"[A-Za-z_][A-Za-z0-9_]*(?:\.[A-Za-z_][A-Za-z0-9_]*)+")
|
|
26
|
+
|
|
27
|
+
# Bare identifiers too common to count as a string reference to a symbol.
|
|
28
|
+
COMMON_WORDS = {
|
|
29
|
+
"main", "test", "data", "name", "type", "value", "true", "false", "none",
|
|
30
|
+
"null", "user", "file", "path", "list", "dict", "info", "debug", "error",
|
|
31
|
+
"warning", "default", "config", "utf-8", "ascii", "json", "yaml", "text",
|
|
32
|
+
"html", "http", "https", "get", "post", "put", "delete", "read", "write",
|
|
33
|
+
"id", "key", "app", "api", "run", "start", "stop", "date", "time",
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
class DeclaredEntrypointRule(Rule):
|
|
38
|
+
"""Symbols the user declared externally reached in .codetruth.toml
|
|
39
|
+
(cron jobs, service runners, cross-repo handlers)."""
|
|
40
|
+
id = "declared-entrypoints"
|
|
41
|
+
|
|
42
|
+
def apply(self, ctx: RuleContext) -> None:
|
|
43
|
+
from ...core.config import load_config
|
|
44
|
+
cfg = load_config(ctx.repo_path)
|
|
45
|
+
if not cfg.entrypoints:
|
|
46
|
+
return
|
|
47
|
+
for sym in ctx.index.all_symbols():
|
|
48
|
+
if cfg.is_declared_entrypoint(sym.id):
|
|
49
|
+
ctx.add_marker(Marker(
|
|
50
|
+
sym.id, MarkerKind.ENTRYPOINT,
|
|
51
|
+
"declared as an entry point in .codetruth.toml",
|
|
52
|
+
rule=self.id, file=sym.file, line=sym.line))
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
class KeepCommentRule(Rule):
|
|
56
|
+
"""`# codetruth: keep` on (or directly above) a definition marks it as
|
|
57
|
+
an entry point — the user asserts it is reached in a way the scanner
|
|
58
|
+
cannot see."""
|
|
59
|
+
id = "keep-comment"
|
|
60
|
+
TAG = "codetruth: keep"
|
|
61
|
+
|
|
62
|
+
def apply(self, ctx: RuleContext) -> None:
|
|
63
|
+
for mi in ctx.modules:
|
|
64
|
+
if mi.tree is None or not mi.symbols:
|
|
65
|
+
continue
|
|
66
|
+
try:
|
|
67
|
+
lines = Path(mi.abs_path).read_text(
|
|
68
|
+
encoding="utf-8", errors="replace").splitlines()
|
|
69
|
+
except OSError:
|
|
70
|
+
continue
|
|
71
|
+
if not any(self.TAG in ln for ln in lines):
|
|
72
|
+
continue
|
|
73
|
+
for sym in mi.symbols:
|
|
74
|
+
for lineno in (sym.line, sym.line - 1):
|
|
75
|
+
if 1 <= lineno <= len(lines) and self.TAG in lines[lineno - 1]:
|
|
76
|
+
ctx.add_marker(Marker(
|
|
77
|
+
sym.id, MarkerKind.ENTRYPOINT,
|
|
78
|
+
"marked '# codetruth: keep' in source",
|
|
79
|
+
rule=self.id, file=sym.file, line=sym.line))
|
|
80
|
+
break
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
class DunderRule(Rule):
|
|
84
|
+
"""Dunder methods are invoked implicitly by the interpreter."""
|
|
85
|
+
id = "python-dunder-methods"
|
|
86
|
+
|
|
87
|
+
def apply(self, ctx: RuleContext) -> None:
|
|
88
|
+
for sym in ctx.index.all_symbols():
|
|
89
|
+
if sym.type is SymbolType.METHOD and sym.name.startswith("__") \
|
|
90
|
+
and sym.name.endswith("__"):
|
|
91
|
+
ctx.add_marker(Marker(sym.id, MarkerKind.ENTRYPOINT,
|
|
92
|
+
"dunder method — invoked implicitly by Python",
|
|
93
|
+
rule=self.id, file=sym.file, line=sym.line))
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
class TestEntryRule(Rule):
|
|
97
|
+
"""Test functions/classes are collected by the test runner, not called."""
|
|
98
|
+
id = "python-test-entrypoints"
|
|
99
|
+
|
|
100
|
+
def apply(self, ctx: RuleContext) -> None:
|
|
101
|
+
for sym in ctx.index.all_symbols():
|
|
102
|
+
if not sym.is_test:
|
|
103
|
+
continue
|
|
104
|
+
if (sym.type in (SymbolType.FUNCTION, SymbolType.METHOD)
|
|
105
|
+
and sym.name.startswith("test")) or \
|
|
106
|
+
(sym.type is SymbolType.CLASS and sym.name.startswith("Test")):
|
|
107
|
+
ctx.add_marker(Marker(sym.id, MarkerKind.ENTRYPOINT,
|
|
108
|
+
"test entry point — collected by the test runner",
|
|
109
|
+
rule=self.id, file=sym.file, line=sym.line))
|
|
110
|
+
|
|
111
|
+
|
|
112
|
+
class MainGuardRule(Rule):
|
|
113
|
+
"""Modules with a __main__ guard are executable scripts."""
|
|
114
|
+
id = "python-main-guard"
|
|
115
|
+
|
|
116
|
+
def apply(self, ctx: RuleContext) -> None:
|
|
117
|
+
for mi in ctx.modules:
|
|
118
|
+
if mi.has_main_guard:
|
|
119
|
+
ctx.add_marker(Marker(mi.name, MarkerKind.ENTRYPOINT,
|
|
120
|
+
"has `if __name__ == '__main__'` guard — "
|
|
121
|
+
"executable script", rule=self.id,
|
|
122
|
+
file=mi.rel_path, line=1))
|
|
123
|
+
|
|
124
|
+
|
|
125
|
+
class ReflectionRule(Rule):
|
|
126
|
+
"""getattr/setattr/hasattr, importlib, __import__, eval/exec, globals()[...].
|
|
127
|
+
|
|
128
|
+
Literal targets become weak DYNAMIC edges. Non-literal reflection poisons
|
|
129
|
+
the whole module: nothing defined there can be *proved* dead, so every
|
|
130
|
+
symbol in it is capped below `safe_to_delete`.
|
|
131
|
+
"""
|
|
132
|
+
id = "python-reflection"
|
|
133
|
+
|
|
134
|
+
REFLECT_FUNCS = {"getattr", "setattr", "hasattr", "delattr"}
|
|
135
|
+
DYNAMIC_FUNCS = {"eval", "exec", "__import__", "globals", "locals", "vars"}
|
|
136
|
+
|
|
137
|
+
def apply(self, ctx: RuleContext) -> None:
|
|
138
|
+
for mi in ctx.modules:
|
|
139
|
+
if mi.tree is None:
|
|
140
|
+
continue
|
|
141
|
+
for node in ast.walk(mi.tree):
|
|
142
|
+
if not isinstance(node, ast.Call):
|
|
143
|
+
continue
|
|
144
|
+
fname = dotted_name(node.func) or ""
|
|
145
|
+
short = fname.split(".")[-1]
|
|
146
|
+
if short in self.REFLECT_FUNCS and len(node.args) >= 2:
|
|
147
|
+
self._reflect(ctx, mi, node, fname)
|
|
148
|
+
elif fname in ("importlib.import_module", "import_module") \
|
|
149
|
+
and node.args:
|
|
150
|
+
self._import_module(ctx, mi, node)
|
|
151
|
+
elif short in self.DYNAMIC_FUNCS and fname == short:
|
|
152
|
+
ctx.add_marker(Marker(
|
|
153
|
+
mi.name, MarkerKind.DYNAMIC_MODULE,
|
|
154
|
+
f"non-literal dynamic access `{short}(...)` at "
|
|
155
|
+
f"{mi.rel_path}:{node.lineno}", rule=self.id,
|
|
156
|
+
file=mi.rel_path, line=node.lineno))
|
|
157
|
+
|
|
158
|
+
def _reflect(self, ctx, mi, node, fname):
|
|
159
|
+
arg = node.args[1]
|
|
160
|
+
if isinstance(arg, ast.Constant) and isinstance(arg.value, str):
|
|
161
|
+
for sid in ctx.index.by_name.get(arg.value, []):
|
|
162
|
+
ctx.add_edge(Edge(mi.name, sid, EdgeKind.DYNAMIC,
|
|
163
|
+
EdgeStrength.WEAK, mi.rel_path, node.lineno,
|
|
164
|
+
f"{fname}(..., '{arg.value}')"))
|
|
165
|
+
else:
|
|
166
|
+
ctx.add_marker(Marker(
|
|
167
|
+
mi.name, MarkerKind.DYNAMIC_MODULE,
|
|
168
|
+
f"non-literal `{fname}()` at {mi.rel_path}:{node.lineno} — "
|
|
169
|
+
"symbols in this module cannot be proved unreachable",
|
|
170
|
+
rule=self.id, file=mi.rel_path, line=node.lineno))
|
|
171
|
+
|
|
172
|
+
def _import_module(self, ctx, mi, node):
|
|
173
|
+
arg = node.args[0]
|
|
174
|
+
if isinstance(arg, ast.Constant) and isinstance(arg.value, str):
|
|
175
|
+
target = arg.value
|
|
176
|
+
if target in ctx.index.modules:
|
|
177
|
+
ctx.add_edge(Edge(mi.name, target, EdgeKind.DYNAMIC,
|
|
178
|
+
EdgeStrength.WEAK, mi.rel_path, node.lineno,
|
|
179
|
+
f"importlib.import_module('{target}')"))
|
|
180
|
+
else:
|
|
181
|
+
ctx.add_marker(Marker(
|
|
182
|
+
mi.name, MarkerKind.DYNAMIC_MODULE,
|
|
183
|
+
f"non-literal import_module() at {mi.rel_path}:{node.lineno}",
|
|
184
|
+
rule=self.id, file=mi.rel_path, line=node.lineno))
|
|
185
|
+
|
|
186
|
+
|
|
187
|
+
class StringReferenceRule(Rule):
|
|
188
|
+
"""String literals naming a symbol (dotted path or exact identifier) in
|
|
189
|
+
Python source or config files create weak string_ref edges — the
|
|
190
|
+
config-driven / dispatch-table usage pattern."""
|
|
191
|
+
id = "python-string-references"
|
|
192
|
+
|
|
193
|
+
def apply(self, ctx: RuleContext) -> None:
|
|
194
|
+
# Python source strings
|
|
195
|
+
for mi in ctx.modules:
|
|
196
|
+
if mi.tree is None:
|
|
197
|
+
continue
|
|
198
|
+
for node in ast.walk(mi.tree):
|
|
199
|
+
if isinstance(node, ast.Constant) and isinstance(node.value, str):
|
|
200
|
+
self._match(ctx, node.value, mi.name, mi.rel_path,
|
|
201
|
+
node.lineno)
|
|
202
|
+
# Config files
|
|
203
|
+
for path in ctx.config_files:
|
|
204
|
+
rel = path.relative_to(ctx.repo_path).as_posix()
|
|
205
|
+
try:
|
|
206
|
+
text = path.read_text(encoding="utf-8", errors="replace")
|
|
207
|
+
except OSError:
|
|
208
|
+
continue
|
|
209
|
+
for lineno, line in enumerate(text.splitlines(), 1):
|
|
210
|
+
self._match(ctx, line, f"file:{rel}", rel, lineno,
|
|
211
|
+
whole_line=True)
|
|
212
|
+
|
|
213
|
+
def _match(self, ctx, text: str, src: str, file: str, lineno: int,
|
|
214
|
+
whole_line: bool = False) -> None:
|
|
215
|
+
stripped = text.strip().strip("\"'")
|
|
216
|
+
# Exact identifier match (dispatch keys, registry names).
|
|
217
|
+
if not whole_line and IDENT_RE.match(stripped):
|
|
218
|
+
if len(stripped) >= 4 and stripped.lower() not in COMMON_WORDS:
|
|
219
|
+
for sid in ctx.index.by_name.get(stripped, []):
|
|
220
|
+
ctx.add_edge(Edge(src, sid, EdgeKind.STRING_REF,
|
|
221
|
+
EdgeStrength.WEAK, file, lineno,
|
|
222
|
+
f"string literal '{stripped}'"))
|
|
223
|
+
return
|
|
224
|
+
# Dotted-path matches anywhere in the text ("app.views.login").
|
|
225
|
+
for m in DOTTED_RE.finditer(text):
|
|
226
|
+
dotted = m.group(0)
|
|
227
|
+
self._match_dotted(ctx, dotted, src, file, lineno)
|
|
228
|
+
|
|
229
|
+
def _match_dotted(self, ctx, dotted: str, src, file, lineno) -> None:
|
|
230
|
+
if dotted in ctx.index.modules:
|
|
231
|
+
ctx.add_edge(Edge(src, dotted, EdgeKind.STRING_REF,
|
|
232
|
+
EdgeStrength.WEAK, file, lineno,
|
|
233
|
+
f"dotted string reference '{dotted}'"))
|
|
234
|
+
return
|
|
235
|
+
parts = dotted.split(".")
|
|
236
|
+
for split in range(len(parts) - 1, 0, -1):
|
|
237
|
+
mod = ".".join(parts[:split])
|
|
238
|
+
if mod in ctx.index.modules:
|
|
239
|
+
qual = ".".join(parts[split:])
|
|
240
|
+
sid = f"{mod}:{qual}"
|
|
241
|
+
if sid in ctx.index.by_id:
|
|
242
|
+
ctx.add_edge(Edge(src, sid, EdgeKind.STRING_REF,
|
|
243
|
+
EdgeStrength.WEAK, file, lineno,
|
|
244
|
+
f"dotted string reference '{dotted}'"))
|
|
245
|
+
return
|
|
246
|
+
|
|
247
|
+
|
|
248
|
+
class DecoratorPatternRule(Rule):
|
|
249
|
+
"""YAML-defined: decorators that register a symbol with a framework."""
|
|
250
|
+
|
|
251
|
+
def __init__(self, rule_id: str, patterns: list[str], reason: str,
|
|
252
|
+
effect: str = "entrypoint"):
|
|
253
|
+
self.id = rule_id
|
|
254
|
+
self.patterns = patterns
|
|
255
|
+
self.reason = reason
|
|
256
|
+
self.effect = effect
|
|
257
|
+
|
|
258
|
+
def _matches(self, dec: str) -> bool:
|
|
259
|
+
for pat in self.patterns:
|
|
260
|
+
if fnmatch.fnmatch(dec, pat):
|
|
261
|
+
return True
|
|
262
|
+
if "." not in pat and dec.split(".")[-1] == pat:
|
|
263
|
+
return True
|
|
264
|
+
return False
|
|
265
|
+
|
|
266
|
+
def apply(self, ctx: RuleContext) -> None:
|
|
267
|
+
for sym in ctx.index.all_symbols():
|
|
268
|
+
for dec in sym.decorators:
|
|
269
|
+
if self._matches(dec):
|
|
270
|
+
kind = MarkerKind.ENTRYPOINT if self.effect == "entrypoint" \
|
|
271
|
+
else MarkerKind.CAUTION
|
|
272
|
+
ctx.add_marker(Marker(sym.id, kind,
|
|
273
|
+
f"@{dec}: {self.reason}", rule=self.id,
|
|
274
|
+
file=sym.file, line=sym.line))
|
|
275
|
+
break
|
|
276
|
+
|
|
277
|
+
|
|
278
|
+
class NamePatternRule(Rule):
|
|
279
|
+
"""YAML-defined: symbols whose name+path+type mark them framework-owned
|
|
280
|
+
(Django settings constants, management commands, migrations, wsgi app)."""
|
|
281
|
+
|
|
282
|
+
def __init__(self, rule_id: str, reason: str, name_regex: str = "",
|
|
283
|
+
path_glob: str = "", symbol_type: str = ""):
|
|
284
|
+
self.id = rule_id
|
|
285
|
+
self.reason = reason
|
|
286
|
+
self.name_re = re.compile(name_regex) if name_regex else None
|
|
287
|
+
self.path_glob = path_glob
|
|
288
|
+
self.symbol_type = symbol_type
|
|
289
|
+
|
|
290
|
+
def apply(self, ctx: RuleContext) -> None:
|
|
291
|
+
for sym in ctx.index.all_symbols():
|
|
292
|
+
if self.symbol_type and sym.type.value != self.symbol_type:
|
|
293
|
+
continue
|
|
294
|
+
if self.name_re and not self.name_re.match(sym.name):
|
|
295
|
+
continue
|
|
296
|
+
if self.path_glob and not fnmatch.fnmatch(sym.file, self.path_glob):
|
|
297
|
+
continue
|
|
298
|
+
ctx.add_marker(Marker(sym.id, MarkerKind.ENTRYPOINT, self.reason,
|
|
299
|
+
rule=self.id, file=sym.file, line=sym.line))
|
|
300
|
+
|
|
301
|
+
|
|
302
|
+
def load_yaml_rules(rules_dir: Path = RULES_DIR) -> list[Rule]:
|
|
303
|
+
rules: list[Rule] = []
|
|
304
|
+
if not rules_dir.is_dir():
|
|
305
|
+
return rules
|
|
306
|
+
for path in sorted(rules_dir.glob("*.yaml")):
|
|
307
|
+
doc = yaml.safe_load(path.read_text(encoding="utf-8")) or {}
|
|
308
|
+
for spec in doc.get("rules", []):
|
|
309
|
+
rtype = spec.get("type")
|
|
310
|
+
if rtype == "decorator":
|
|
311
|
+
rules.append(DecoratorPatternRule(
|
|
312
|
+
spec["id"], spec["patterns"], spec.get("reason", ""),
|
|
313
|
+
spec.get("effect", "entrypoint")))
|
|
314
|
+
elif rtype == "name":
|
|
315
|
+
rules.append(NamePatternRule(
|
|
316
|
+
spec["id"], spec.get("reason", ""),
|
|
317
|
+
spec.get("name_regex", ""), spec.get("path_glob", ""),
|
|
318
|
+
spec.get("symbol_type", "")))
|
|
319
|
+
return rules
|
|
320
|
+
|
|
321
|
+
|
|
322
|
+
def default_rules() -> list[Rule]:
|
|
323
|
+
return [
|
|
324
|
+
DeclaredEntrypointRule(),
|
|
325
|
+
KeepCommentRule(),
|
|
326
|
+
DunderRule(),
|
|
327
|
+
TestEntryRule(),
|
|
328
|
+
MainGuardRule(),
|
|
329
|
+
ReflectionRule(),
|
|
330
|
+
StringReferenceRule(),
|
|
331
|
+
*load_yaml_rules(),
|
|
332
|
+
]
|