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,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
+ ]