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,306 @@
1
+ """Layer 4 — evidence assembly and the 4-way classification.
2
+
3
+ The logic inversion lives here: a symbol is only `safe_to_delete` when the
4
+ system FAILS to find any usage path — no strong edge, no weak edge, no
5
+ framework marker, no dynamic access in its module, and no public-API
6
+ exposure that static analysis can't rule out.
7
+ """
8
+ from __future__ import annotations
9
+
10
+ from collections import defaultdict
11
+ from typing import Optional
12
+
13
+ from .graph import CodeGraph
14
+ from .models import (Action, Edge, EdgeKind, EdgeStrength, EvidenceRecord,
15
+ Marker, MarkerKind, RiskLevel, Status, Symbol, SymbolType)
16
+
17
+ MAX_EVIDENCE_ITEMS = 8
18
+
19
+ # How strongly one weak inbound edge argues *against* deletion, by kind.
20
+ # Attribute name-matches are the noisiest signal (a bare `.name` matches every
21
+ # symbol called `name`); a string-literal or reflection reference is far more
22
+ # likely to be a real dynamic use. These weights spread the otherwise-flat
23
+ # uncertain_dynamic_risk bucket so an agent can triage it.
24
+ _WEAK_WEIGHT = {
25
+ EdgeKind.ATTRIBUTE: 0.15,
26
+ EdgeKind.REFERENCE: 0.30,
27
+ EdgeKind.CALL: 0.30,
28
+ EdgeKind.IMPORT: 0.40,
29
+ EdgeKind.STRING_REF: 0.50,
30
+ EdgeKind.DYNAMIC: 0.50,
31
+ EdgeKind.INHERIT: 0.60,
32
+ }
33
+
34
+
35
+ def _rank_score(status: Status, weak: list[Edge], cautions: list,
36
+ runtime_zero: list, in_dynamic_module: bool,
37
+ strong_test: list, is_public_api: bool,
38
+ treat_public_as_api: bool, sym: Symbol) -> float:
39
+ """Ordering key in [0, 1]; higher = review/delete first. Deterministic
40
+ heuristic, not a calibrated probability (PLAN.md §4)."""
41
+ if status is Status.DEFINITELY_USED:
42
+ return 0.0
43
+ if status is Status.SAFE_TO_DELETE:
44
+ # Runtime tracing that observed zero calls is the strongest signal.
45
+ return 1.0 if runtime_zero else 0.95
46
+ if status is Status.LIKELY_DEAD:
47
+ c = 0.75
48
+ if is_public_api and treat_public_as_api:
49
+ c -= 0.10 # could be imported by an external consumer
50
+ if sym.type is SymbolType.MODULE:
51
+ c -= 0.10 # could be an external entry point (cron/script)
52
+ if strong_test:
53
+ c -= 0.10 # its own test suite still references it
54
+ return round(max(0.50, c), 3)
55
+ # UNCERTAIN_DYNAMIC_RISK: rank by how much weak evidence exists.
56
+ pressure = sum(_WEAK_WEIGHT.get(e.kind, 0.30) for e in weak)
57
+ pressure += 0.40 * len(cautions)
58
+ if in_dynamic_module:
59
+ pressure += 0.50
60
+ if strong_test:
61
+ pressure += 0.20
62
+ return round(max(0.10, min(0.45, 0.45 / (1.0 + pressure))), 3)
63
+
64
+
65
+ def _edge_is_from_test(edge: Edge, symbols_by_id: dict[str, Symbol]) -> bool:
66
+ src = symbols_by_id.get(edge.src)
67
+ if src is not None:
68
+ return src.is_test
69
+ # Pseudo-source ("file:...") — treat config references as non-test.
70
+ return False
71
+
72
+
73
+ def _compute_live(symbols: list[Symbol], graph: CodeGraph,
74
+ markers_by_symbol: dict[str, list[Marker]],
75
+ treat_public_as_api: bool,
76
+ reachability: str = "default") -> set[str]:
77
+ """Reachability: the set of symbols provably executable from a live root.
78
+
79
+ Default mode roots: modules (their top-level code runs on import),
80
+ framework/test/runtime entrypoints, and — in library mode — every public
81
+ symbol (an external consumer could call it).
82
+
83
+ Strict mode ("useless clump" detection) roots are ONLY real entry points:
84
+ framework routes/commands, __main__ modules, tests, runtime observations,
85
+ and entrypoints declared in .codetruth.toml. Code that is internally
86
+ well-connected but never reached from any entry point — an orphaned
87
+ island — then surfaces in the review queue. Modules are not automatic
88
+ roots in strict mode; a module is live only if something live imports it
89
+ or a rule marked it (e.g. a __main__ guard).
90
+
91
+ Liveness propagates along STRONG edges only; a strong reference from
92
+ unreachable code proves nothing, which is exactly the dead-cluster case.
93
+ Weak edges never propagate liveness; they feed the uncertainty tier
94
+ directly regardless of their source, which stays conservative.
95
+ """
96
+ strict = reachability == "strict"
97
+ live: set[str] = set()
98
+ for s in symbols:
99
+ if any(m.kind in (MarkerKind.ENTRYPOINT, MarkerKind.RUNTIME_USED)
100
+ for m in markers_by_symbol.get(s.id, ())):
101
+ live.add(s.id)
102
+ elif not strict and s.type is SymbolType.MODULE:
103
+ live.add(s.id)
104
+ elif not strict and treat_public_as_api and s.is_public:
105
+ live.add(s.id)
106
+
107
+ stack = list(live)
108
+ while stack:
109
+ src = stack.pop()
110
+ if src not in graph.g:
111
+ continue
112
+ for _src, dst, data in graph.g.out_edges(src, data=True):
113
+ if data["edge"].strength is EdgeStrength.STRONG and dst not in live:
114
+ live.add(dst)
115
+ stack.append(dst)
116
+ return live
117
+
118
+
119
+ def _dead_clusters(symbols: list[Symbol], graph: CodeGraph,
120
+ live: set[str]) -> dict[str, list[str]]:
121
+ """Group unreachable symbols into connected clumps. Two dead symbols
122
+ joined by a strong edge belong to the same island; reporting them as a
123
+ unit ('delete as a group') is far more actionable than scattered rows."""
124
+ dead = {s.id for s in symbols
125
+ if s.id not in live and s.type is not SymbolType.MODULE}
126
+ if not dead:
127
+ return {}
128
+ adjacency: dict[str, set[str]] = defaultdict(set)
129
+ for e in graph.edges:
130
+ if e.strength is EdgeStrength.STRONG and e.src in dead and e.dst in dead:
131
+ adjacency[e.src].add(e.dst)
132
+ adjacency[e.dst].add(e.src)
133
+ clusters: dict[str, list[str]] = {}
134
+ seen: set[str] = set()
135
+ for start in adjacency:
136
+ if start in seen:
137
+ continue
138
+ component, stack = set(), [start]
139
+ while stack:
140
+ node = stack.pop()
141
+ if node in component:
142
+ continue
143
+ component.add(node)
144
+ stack.extend(adjacency[node] - component)
145
+ seen |= component
146
+ if len(component) > 1:
147
+ members = sorted(component)
148
+ for node in component:
149
+ clusters[node] = members
150
+ return clusters
151
+
152
+
153
+ def build_records(symbols: list[Symbol], graph: CodeGraph,
154
+ markers: list[Marker],
155
+ treat_public_as_api: bool = True,
156
+ reachability: str = "default") -> list[EvidenceRecord]:
157
+ symbols_by_id = {s.id: s for s in symbols}
158
+ markers_by_symbol: dict[str, list[Marker]] = defaultdict(list)
159
+ dynamic_modules: set[str] = set()
160
+ for m in markers:
161
+ markers_by_symbol[m.symbol].append(m)
162
+ if m.kind is MarkerKind.DYNAMIC_MODULE:
163
+ dynamic_modules.add(m.symbol)
164
+
165
+ live = _compute_live(symbols, graph, markers_by_symbol,
166
+ treat_public_as_api, reachability)
167
+ clusters = _dead_clusters(symbols, graph, live)
168
+
169
+ records = []
170
+ for sym in symbols:
171
+ records.append(_classify(sym, graph, markers_by_symbol.get(sym.id, []),
172
+ dynamic_modules, symbols_by_id,
173
+ treat_public_as_api, live,
174
+ clusters.get(sym.id), reachability))
175
+ return records
176
+
177
+
178
+ def _classify(sym: Symbol, graph: CodeGraph, sym_markers: list[Marker],
179
+ dynamic_modules: set[str], symbols_by_id: dict[str, Symbol],
180
+ treat_public_as_api: bool, live: set[str],
181
+ cluster: Optional[list[str]] = None,
182
+ reachability: str = "default") -> EvidenceRecord:
183
+ strong, weak = graph.inbound_split(sym.id)
184
+ strong_nontest = [e for e in strong if not _edge_is_from_test(e, symbols_by_id)]
185
+ strong_test = [e for e in strong if _edge_is_from_test(e, symbols_by_id)]
186
+ # A strong reference only proves use when its source is itself reachable.
187
+ # Unknown sources (not in the symbol table) are treated as live — never
188
+ # assume deadness about code we can't see.
189
+ strong_live = [e for e in strong_nontest
190
+ if e.src in live or e.src not in symbols_by_id]
191
+ strong_deadsrc = [e for e in strong_nontest
192
+ if e.src in symbols_by_id and e.src not in live]
193
+
194
+ entry = [m for m in sym_markers if m.kind in (MarkerKind.ENTRYPOINT,
195
+ MarkerKind.RUNTIME_USED)]
196
+ cautions = [m for m in sym_markers if m.kind is MarkerKind.CAUTION]
197
+ runtime_zero = [m for m in sym_markers if m.kind is MarkerKind.RUNTIME_ZERO]
198
+ in_dynamic_module = sym.module in dynamic_modules
199
+
200
+ for_del: list[str] = []
201
+ against: list[str] = []
202
+
203
+ # ---- evidence both ways, assembled regardless of final status ----------
204
+ if entry:
205
+ against.extend(m.reason for m in entry[:MAX_EVIDENCE_ITEMS])
206
+ if strong_live:
207
+ against.append(f"{len(strong_live)} strong reference(s), e.g. "
208
+ + strong_live[0].describe())
209
+ if strong_deadsrc and not strong_live:
210
+ srcs = sorted({e.src for e in strong_deadsrc})
211
+ against.append(
212
+ f"{len(strong_deadsrc)} strong reference(s) exist, but only from "
213
+ f"code that is itself unreachable ({', '.join(srcs[:4])}) — "
214
+ "deleting this symbol alone would break that dead code; delete "
215
+ "the cluster together")
216
+ if strong_test and not strong_nontest:
217
+ against.append(f"{len(strong_test)} reference(s) from tests only, e.g. "
218
+ + strong_test[0].describe())
219
+ for e in weak[:MAX_EVIDENCE_ITEMS]:
220
+ against.append("Possible dynamic usage: " + e.describe())
221
+ if len(weak) > MAX_EVIDENCE_ITEMS:
222
+ against.append(f"...and {len(weak) - MAX_EVIDENCE_ITEMS} more weak reference(s)")
223
+ for m in cautions[:MAX_EVIDENCE_ITEMS]:
224
+ against.append(m.reason)
225
+ if in_dynamic_module:
226
+ against.append(f"module {sym.module} contains non-literal dynamic access "
227
+ "(getattr/eval/import_module) — usage cannot be ruled out")
228
+
229
+ if not strong:
230
+ for_del.append("No strong references (calls/imports/inheritance) found "
231
+ "in the repository")
232
+ elif not strong_live and not strong_test and strong_deadsrc:
233
+ for_del.append("Every strong reference originates from unreachable "
234
+ "(dead) code — no live usage path exists")
235
+ if cluster:
236
+ others = [c for c in cluster if c != sym.id]
237
+ for_del.append(
238
+ f"Part of an unreachable {len(cluster)}-symbol cluster with "
239
+ f"{', '.join(others[:6])}{'…' if len(others) > 6 else ''} — the "
240
+ "clump can be reviewed and deleted as a group")
241
+ if reachability == "strict" and sym.id not in live \
242
+ and sym.type is not SymbolType.MODULE and not entry:
243
+ for_del.append("Strict mode: not reachable from any entry point "
244
+ "(route, command, __main__, test, declared entrypoint)")
245
+ if not weak:
246
+ for_del.append("No string-literal, reflection, or attribute-name "
247
+ "references detected")
248
+ if not entry:
249
+ for_del.append("Not matched by any framework/entry-point rule")
250
+ if not strong_test and not sym.is_test:
251
+ for_del.append("Not referenced by the test suite")
252
+ for m in runtime_zero:
253
+ for_del.append(m.reason)
254
+
255
+ is_public_api = sym.is_public and sym.type is not SymbolType.MODULE
256
+ if is_public_api and treat_public_as_api:
257
+ against.append("Symbol is public — may be consumed outside this "
258
+ "repository (pass treat_public_as_api=False for "
259
+ "application code)")
260
+
261
+ # ---- decision ladder (conservative by construction) --------------------
262
+ if entry:
263
+ status = Status.DEFINITELY_USED
264
+ elif strong_live:
265
+ status = Status.DEFINITELY_USED
266
+ elif strong_test:
267
+ # Only its own tests keep it alive.
268
+ status = Status.UNCERTAIN_DYNAMIC_RISK if weak else Status.LIKELY_DEAD
269
+ elif weak:
270
+ status = Status.UNCERTAIN_DYNAMIC_RISK
271
+ elif cautions or in_dynamic_module:
272
+ status = Status.UNCERTAIN_DYNAMIC_RISK
273
+ elif strong_deadsrc:
274
+ # Dead-cluster interior: no live path reaches it, but deleting it
275
+ # alone would break its (dead) referrers. Never safe standalone —
276
+ # the advice is to review and delete the cluster as a group.
277
+ status = Status.LIKELY_DEAD
278
+ elif sym.type is SymbolType.MODULE:
279
+ # An unimported module might still be an external entry point
280
+ # (cron, script, service runner) — never provably dead statically.
281
+ status = Status.LIKELY_DEAD
282
+ against.append("Modules can be executed directly or referenced "
283
+ "externally — static analysis cannot prove otherwise")
284
+ elif is_public_api and treat_public_as_api:
285
+ status = Status.LIKELY_DEAD
286
+ else:
287
+ status = Status.SAFE_TO_DELETE
288
+
289
+ risk, action = {
290
+ Status.DEFINITELY_USED: (RiskLevel.LOW, Action.KEEP),
291
+ Status.SAFE_TO_DELETE: (RiskLevel.LOW, Action.DELETE),
292
+ Status.LIKELY_DEAD: (RiskLevel.MEDIUM, Action.REVIEW_REQUIRED),
293
+ Status.UNCERTAIN_DYNAMIC_RISK: (RiskLevel.HIGH, Action.REVIEW_REQUIRED),
294
+ }[status]
295
+
296
+ rank = _rank_score(status, weak, cautions, runtime_zero, in_dynamic_module,
297
+ strong_test, is_public_api, treat_public_as_api, sym)
298
+
299
+ return EvidenceRecord(
300
+ symbol=sym.id, name=sym.name, type=sym.type, file=sym.file,
301
+ line=sym.line, status=status, risk_level=risk,
302
+ recommended_action=action, evidence_for_deletion=for_del,
303
+ evidence_against_deletion=against, inbound_strong=len(strong),
304
+ inbound_weak=len(weak), exported=sym.exported, rank_score=rank,
305
+ cluster=list(cluster) if cluster else None,
306
+ )
@@ -0,0 +1,44 @@
1
+ """Layer 2 container: directed multigraph of symbols with strong/weak edges."""
2
+ from __future__ import annotations
3
+
4
+ import networkx as nx
5
+
6
+ from .models import Edge, EdgeStrength, Symbol
7
+
8
+
9
+ class CodeGraph:
10
+ """Wraps a networkx MultiDiGraph. Nodes are symbol ids (plus pseudo-nodes
11
+ like 'file:config.yaml' for edges sourced from non-code files)."""
12
+
13
+ def __init__(self) -> None:
14
+ self.g = nx.MultiDiGraph()
15
+ self.edges: list[Edge] = []
16
+
17
+ def add_symbol(self, sym: Symbol) -> None:
18
+ self.g.add_node(sym.id, symbol=sym)
19
+
20
+ def add_edge(self, edge: Edge) -> None:
21
+ # Self-references (recursion, a class referring to itself) are not
22
+ # evidence of external use — drop them at insertion time.
23
+ if edge.src == edge.dst:
24
+ return
25
+ self.edges.append(edge)
26
+ self.g.add_edge(edge.src, edge.dst, edge=edge)
27
+
28
+ def inbound(self, symbol_id: str) -> list[Edge]:
29
+ """Inbound edges, excluding edges whose source is nested inside the
30
+ target (a method referencing its own class proves nothing)."""
31
+ if symbol_id not in self.g:
32
+ return []
33
+ result = []
34
+ for src, _dst, data in self.g.in_edges(symbol_id, data=True):
35
+ if src.startswith(symbol_id + ".") or src.startswith(symbol_id + ":"):
36
+ continue
37
+ result.append(data["edge"])
38
+ return result
39
+
40
+ def inbound_split(self, symbol_id: str) -> tuple[list[Edge], list[Edge]]:
41
+ strong, weak = [], []
42
+ for e in self.inbound(symbol_id):
43
+ (strong if e.strength is EdgeStrength.STRONG else weak).append(e)
44
+ return strong, weak
@@ -0,0 +1,193 @@
1
+ """Core data models shared by every layer and every language plugin."""
2
+ from __future__ import annotations
3
+
4
+ from dataclasses import dataclass, field
5
+ from enum import Enum
6
+ from typing import Optional
7
+
8
+
9
+ class SymbolType(str, Enum):
10
+ MODULE = "module"
11
+ CLASS = "class"
12
+ FUNCTION = "function"
13
+ METHOD = "method"
14
+ VARIABLE = "variable"
15
+
16
+
17
+ class EdgeKind(str, Enum):
18
+ CALL = "call"
19
+ IMPORT = "import"
20
+ INHERIT = "inherit"
21
+ ATTRIBUTE = "attribute"
22
+ REFERENCE = "reference"
23
+ STRING_REF = "string_ref"
24
+ DYNAMIC = "dynamic"
25
+
26
+
27
+ class EdgeStrength(str, Enum):
28
+ STRONG = "strong"
29
+ WEAK = "weak"
30
+
31
+
32
+ class Status(str, Enum):
33
+ SAFE_TO_DELETE = "safe_to_delete"
34
+ LIKELY_DEAD = "likely_dead"
35
+ UNCERTAIN_DYNAMIC_RISK = "uncertain_dynamic_risk"
36
+ DEFINITELY_USED = "definitely_used"
37
+
38
+
39
+ class RiskLevel(str, Enum):
40
+ LOW = "low"
41
+ MEDIUM = "medium"
42
+ HIGH = "high"
43
+
44
+
45
+ class Action(str, Enum):
46
+ DELETE = "delete"
47
+ REVIEW_REQUIRED = "review_required"
48
+ KEEP = "keep"
49
+
50
+
51
+ class MarkerKind(str, Enum):
52
+ # Proof of use: framework entry point, dunder, test, runtime observation.
53
+ ENTRYPOINT = "entrypoint"
54
+ RUNTIME_USED = "runtime_used"
55
+ # Not proof of use, but a reason deletion is riskier than the graph shows.
56
+ CAUTION = "caution"
57
+ # Module contains non-literal reflection/eval — nothing in it is provably dead.
58
+ DYNAMIC_MODULE = "dynamic_module"
59
+ # Runtime tracking registered the symbol and observed zero calls.
60
+ RUNTIME_ZERO = "runtime_zero"
61
+
62
+
63
+ @dataclass
64
+ class Symbol:
65
+ """One node in the graph. id format: 'pkg.mod:Qual.name' ('pkg.mod' for modules)."""
66
+ id: str
67
+ name: str
68
+ qualname: str
69
+ type: SymbolType
70
+ file: str # repo-relative path, forward slashes
71
+ line: int
72
+ end_line: int
73
+ module: str # dotted module name the symbol lives in
74
+ parent: Optional[str] = None # enclosing symbol id (module id for top-level)
75
+ exported: bool = False # in __all__, or public top-level when no __all__
76
+ is_public: bool = False # top-level or method, name has no leading underscore
77
+ is_test: bool = False # defined in a test file
78
+ decorators: list[str] = field(default_factory=list)
79
+ bases: list[str] = field(default_factory=list) # dotted base names (classes only)
80
+
81
+ def to_dict(self) -> dict:
82
+ return {
83
+ "id": self.id, "name": self.name, "qualname": self.qualname,
84
+ "type": self.type.value, "file": self.file, "line": self.line,
85
+ "end_line": self.end_line, "module": self.module, "parent": self.parent,
86
+ "exported": self.exported, "is_public": self.is_public,
87
+ "is_test": self.is_test, "decorators": self.decorators, "bases": self.bases,
88
+ }
89
+
90
+
91
+ @dataclass
92
+ class Edge:
93
+ """A directed usage edge: src uses dst. Strength is assigned at creation time."""
94
+ src: str # symbol id, or pseudo-node like 'file:config/app.yaml'
95
+ dst: str # symbol id
96
+ kind: EdgeKind
97
+ strength: EdgeStrength
98
+ file: str
99
+ line: int
100
+ detail: str = ""
101
+
102
+ def to_dict(self) -> dict:
103
+ return {
104
+ "src": self.src, "dst": self.dst, "kind": self.kind.value,
105
+ "strength": self.strength.value, "file": self.file,
106
+ "line": self.line, "detail": self.detail,
107
+ }
108
+
109
+ def describe(self) -> str:
110
+ base = f"{self.kind.value} from {self.src} ({self.file}:{self.line})"
111
+ return f"{base} — {self.detail}" if self.detail else base
112
+
113
+
114
+ @dataclass
115
+ class Marker:
116
+ """A rule-produced fact about a symbol that isn't a graph edge."""
117
+ symbol: str
118
+ kind: MarkerKind
119
+ reason: str
120
+ rule: str = ""
121
+ file: str = ""
122
+ line: int = 0
123
+
124
+ def to_dict(self) -> dict:
125
+ return {
126
+ "symbol": self.symbol, "kind": self.kind.value, "reason": self.reason,
127
+ "rule": self.rule, "file": self.file, "line": self.line,
128
+ }
129
+
130
+
131
+ @dataclass
132
+ class EvidenceRecord:
133
+ """Layer 4 output: the structured record an agent reads before deciding."""
134
+ symbol: str
135
+ name: str
136
+ type: SymbolType
137
+ file: str
138
+ line: int
139
+ status: Status
140
+ risk_level: RiskLevel
141
+ recommended_action: Action
142
+ evidence_for_deletion: list[str] = field(default_factory=list)
143
+ evidence_against_deletion: list[str] = field(default_factory=list)
144
+ inbound_strong: int = 0
145
+ inbound_weak: int = 0
146
+ exported: bool = False
147
+ # Deterministic ordering heuristic for the review queue, in [0, 1]:
148
+ # higher = weaker evidence of use = review/delete first. NOT a calibrated
149
+ # probability (see PLAN.md §4) — it exists only to rank candidates within
150
+ # a status so the strongest deletion targets surface first.
151
+ rank_score: float = 0.0
152
+ # Advisory description of what a deletion would involve (exact span,
153
+ # orphaned imports, __all__ entry). Attached to safe_to_delete records;
154
+ # never applied by the tool.
155
+ deletion_plan: Optional[dict] = None
156
+ # Ids of the unreachable clump this symbol belongs to (itself included)
157
+ # when it is part of a dead cluster — reviewable/deletable as a group.
158
+ cluster: Optional[list[str]] = None
159
+
160
+ def to_dict(self) -> dict:
161
+ d = {
162
+ "symbol": self.symbol, "name": self.name, "type": self.type.value,
163
+ "file": self.file, "line": self.line, "status": self.status.value,
164
+ "risk_level": self.risk_level.value,
165
+ "recommended_action": self.recommended_action.value,
166
+ "rank_score": self.rank_score,
167
+ "evidence_for_deletion": self.evidence_for_deletion,
168
+ "evidence_against_deletion": self.evidence_against_deletion,
169
+ "inbound_strong": self.inbound_strong, "inbound_weak": self.inbound_weak,
170
+ "exported": self.exported,
171
+ }
172
+ if self.deletion_plan is not None:
173
+ d["deletion_plan"] = self.deletion_plan
174
+ if self.cluster:
175
+ d["cluster"] = self.cluster
176
+ return d
177
+
178
+ @classmethod
179
+ def from_dict(cls, d: dict) -> "EvidenceRecord":
180
+ return cls(
181
+ symbol=d["symbol"], name=d["name"], type=SymbolType(d["type"]),
182
+ file=d["file"], line=d["line"], status=Status(d["status"]),
183
+ risk_level=RiskLevel(d["risk_level"]),
184
+ recommended_action=Action(d["recommended_action"]),
185
+ evidence_for_deletion=list(d.get("evidence_for_deletion", [])),
186
+ evidence_against_deletion=list(d.get("evidence_against_deletion", [])),
187
+ inbound_strong=d.get("inbound_strong", 0),
188
+ inbound_weak=d.get("inbound_weak", 0),
189
+ exported=d.get("exported", False),
190
+ rank_score=d.get("rank_score", 0.0),
191
+ deletion_plan=d.get("deletion_plan"),
192
+ cluster=d.get("cluster"),
193
+ )
@@ -0,0 +1,88 @@
1
+ """Language-plugin and rule interfaces. The core engine only talks to these."""
2
+ from __future__ import annotations
3
+
4
+ from abc import ABC, abstractmethod
5
+ from dataclasses import dataclass, field
6
+ from pathlib import Path
7
+ from typing import TYPE_CHECKING, Any
8
+
9
+ from .graph import CodeGraph
10
+ from .models import Edge, Marker, Symbol
11
+
12
+ if TYPE_CHECKING:
13
+ pass
14
+
15
+
16
+ @dataclass
17
+ class RuleContext:
18
+ """Everything a Layer 3 rule may inspect, plus sinks for its findings."""
19
+ repo_path: Path
20
+ modules: list[Any] # language-specific ModuleInfo objects
21
+ index: Any # language-specific symbol index
22
+ graph: CodeGraph
23
+ markers: list[Marker]
24
+ config_files: list[Path] = field(default_factory=list)
25
+
26
+ def add_edge(self, edge: Edge) -> None:
27
+ self.graph.add_edge(edge)
28
+
29
+ def add_marker(self, marker: Marker) -> None:
30
+ self.markers.append(marker)
31
+
32
+
33
+ class Rule(ABC):
34
+ """One Layer 3 pattern detector. Emits weak edges and/or markers."""
35
+ id: str = "rule"
36
+
37
+ @abstractmethod
38
+ def apply(self, ctx: RuleContext) -> None: ...
39
+
40
+
41
+ class LanguagePlugin(ABC):
42
+ """A language implementation: extraction (L1), edges (L2), rules (L3)."""
43
+ name: str = ""
44
+
45
+ @abstractmethod
46
+ def extract(self, repo_path: Path,
47
+ ignores: tuple[str, ...] = ()) -> tuple[list[Any], list[str]]:
48
+ """Return (modules, warnings). Each module carries its symbols.
49
+ `ignores` are user-configured path globs to skip entirely."""
50
+
51
+ @abstractmethod
52
+ def build_index(self, modules: list[Any]) -> Any: ...
53
+
54
+ @abstractmethod
55
+ def build_edges(self, repo_path: Path, modules: list[Any], index: Any,
56
+ graph: CodeGraph, markers: list[Marker]) -> None: ...
57
+
58
+ @abstractmethod
59
+ def rules(self) -> list[Rule]: ...
60
+
61
+ @abstractmethod
62
+ def symbols(self, modules: list[Any]) -> list[Symbol]: ...
63
+
64
+
65
+ _PLUGINS: dict[str, LanguagePlugin] = {}
66
+
67
+
68
+ def register_plugin(plugin: LanguagePlugin) -> None:
69
+ _PLUGINS[plugin.name] = plugin
70
+
71
+
72
+ def get_plugin(name: str) -> LanguagePlugin:
73
+ if name not in _PLUGINS:
74
+ # Lazy-load built-in plugins on first request.
75
+ if name == "python":
76
+ from ..languages.python.plugin import PythonPlugin
77
+ register_plugin(PythonPlugin())
78
+ elif name in ("javascript", "typescript", "js", "ts"):
79
+ from ..languages.javascript.plugin import JavaScriptPlugin
80
+ plugin = JavaScriptPlugin()
81
+ for alias in ("javascript", "typescript", "js", "ts"):
82
+ _PLUGINS[alias] = plugin
83
+ elif name == "go":
84
+ raise NotImplementedError(
85
+ "The 'go' plugin is a stub — python and javascript ship today.")
86
+ else:
87
+ raise ValueError(f"Unknown language plugin: {name!r}")
88
+ return _PLUGINS[name]