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,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
|
+
)
|
codetruth/core/graph.py
ADDED
|
@@ -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
|
codetruth/core/models.py
ADDED
|
@@ -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
|
+
)
|
codetruth/core/plugin.py
ADDED
|
@@ -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]
|