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
codetruth/core/report.py
ADDED
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
"""Standalone HTML report for a scan — a single self-contained file, no
|
|
2
|
+
external assets, so it opens anywhere and can be attached to a PR or CI run."""
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import html
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
from typing import TYPE_CHECKING
|
|
8
|
+
|
|
9
|
+
from .models import Status
|
|
10
|
+
|
|
11
|
+
if TYPE_CHECKING:
|
|
12
|
+
from .scanner import ScanResult
|
|
13
|
+
|
|
14
|
+
_STATUS_META = {
|
|
15
|
+
Status.SAFE_TO_DELETE: ("safe to delete", "#1a7f37"),
|
|
16
|
+
Status.LIKELY_DEAD: ("likely dead", "#9a6700"),
|
|
17
|
+
Status.UNCERTAIN_DYNAMIC_RISK: ("uncertain / dynamic risk", "#bc4c00"),
|
|
18
|
+
Status.DEFINITELY_USED: ("definitely used", "#57606a"),
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def _esc(x) -> str:
|
|
23
|
+
return html.escape(str(x))
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def render_html(result: "ScanResult") -> str:
|
|
27
|
+
s = result.summary()
|
|
28
|
+
c = s["status_counts"]
|
|
29
|
+
rows = []
|
|
30
|
+
for r in result.candidates():
|
|
31
|
+
label, color = _STATUS_META[r.status]
|
|
32
|
+
ev_for = "".join(f"<li>{_esc(e)}</li>" for e in r.evidence_for_deletion)
|
|
33
|
+
ev_against = "".join(f"<li>{_esc(e)}</li>"
|
|
34
|
+
for e in r.evidence_against_deletion)
|
|
35
|
+
cluster = ""
|
|
36
|
+
if r.cluster:
|
|
37
|
+
cluster = ("<div class='cluster'>clump: "
|
|
38
|
+
+ ", ".join(_esc(x) for x in r.cluster) + "</div>")
|
|
39
|
+
rows.append(f"""
|
|
40
|
+
<tr data-status="{r.status.value}">
|
|
41
|
+
<td><span class="pill" style="background:{color}">{label}</span></td>
|
|
42
|
+
<td class="rank">{r.rank_score:.2f}</td>
|
|
43
|
+
<td><code>{_esc(r.symbol)}</code><div class="loc">{_esc(r.file)}:{r.line}</div>{cluster}</td>
|
|
44
|
+
<td class="ev">
|
|
45
|
+
<ul class="for">{ev_for}</ul>
|
|
46
|
+
<ul class="against">{ev_against}</ul>
|
|
47
|
+
</td>
|
|
48
|
+
</tr>""")
|
|
49
|
+
|
|
50
|
+
return f"""<!doctype html>
|
|
51
|
+
<html lang="en"><head><meta charset="utf-8">
|
|
52
|
+
<meta name="viewport" content="width=device-width, initial-scale=1">
|
|
53
|
+
<title>CodeTruth report — {_esc(s['repo'])}</title>
|
|
54
|
+
<style>
|
|
55
|
+
:root {{ color-scheme: light dark; }}
|
|
56
|
+
body {{ font: 14px/1.5 system-ui, sans-serif; margin: 0; padding: 1.5rem;
|
|
57
|
+
max-width: 1100px; margin-inline: auto; }}
|
|
58
|
+
h1 {{ font-size: 1.25rem; margin: 0 0 .25rem; }}
|
|
59
|
+
.sub {{ color: #6e7781; margin-bottom: 1rem; }}
|
|
60
|
+
.counts {{ display: flex; gap: .5rem; flex-wrap: wrap; margin-bottom: 1rem; }}
|
|
61
|
+
.count {{ padding: .35rem .7rem; border-radius: 6px; background: #f6f8fa;
|
|
62
|
+
border: 1px solid #d0d7de; }}
|
|
63
|
+
.count b {{ font-size: 1.1rem; }}
|
|
64
|
+
@media (prefers-color-scheme: dark) {{
|
|
65
|
+
.count {{ background: #161b22; border-color: #30363d; }} }}
|
|
66
|
+
table {{ border-collapse: collapse; width: 100%; }}
|
|
67
|
+
th, td {{ text-align: left; padding: .5rem .6rem; vertical-align: top;
|
|
68
|
+
border-top: 1px solid #d0d7de40; }}
|
|
69
|
+
th {{ position: sticky; top: 0; background: Canvas; }}
|
|
70
|
+
.pill {{ color: #fff; padding: .1rem .5rem; border-radius: 999px;
|
|
71
|
+
font-size: .75rem; white-space: nowrap; }}
|
|
72
|
+
.rank {{ font-variant-numeric: tabular-nums; text-align: right; }}
|
|
73
|
+
.loc {{ color: #6e7781; font-size: .8rem; }}
|
|
74
|
+
.cluster {{ color: #bc4c00; font-size: .78rem; margin-top: .2rem; }}
|
|
75
|
+
code {{ font: 12px ui-monospace, monospace; }}
|
|
76
|
+
.ev ul {{ margin: 0; padding-left: 1.1rem; }}
|
|
77
|
+
.ev .for li {{ color: #1a7f37; }}
|
|
78
|
+
.ev .against li {{ color: #a04100; }}
|
|
79
|
+
.note {{ margin-top: 1.5rem; color: #6e7781; font-size: .85rem; }}
|
|
80
|
+
input {{ margin-bottom: 1rem; padding: .4rem; width: 16rem; }}
|
|
81
|
+
</style></head><body>
|
|
82
|
+
<h1>CodeTruth report</h1>
|
|
83
|
+
<div class="sub">{_esc(s['repo'])} · {s['symbols']} symbols · {s['edges']} edges</div>
|
|
84
|
+
<div class="counts">
|
|
85
|
+
<div class="count"><b>{c['safe_to_delete']}</b> safe to delete</div>
|
|
86
|
+
<div class="count"><b>{c['likely_dead']}</b> likely dead</div>
|
|
87
|
+
<div class="count"><b>{c['uncertain_dynamic_risk']}</b> uncertain</div>
|
|
88
|
+
<div class="count"><b>{c['definitely_used']}</b> used</div>
|
|
89
|
+
</div>
|
|
90
|
+
<input id="filter" placeholder="filter symbols…" oninput="filt()">
|
|
91
|
+
<table id="t"><thead><tr><th>status</th><th>rank</th><th>symbol</th>
|
|
92
|
+
<th>evidence (for / against deletion)</th></tr></thead>
|
|
93
|
+
<tbody>{''.join(rows)}</tbody></table>
|
|
94
|
+
<p class="note">Advisory only — CodeTruth never deletes code. Act on
|
|
95
|
+
<b>safe to delete</b>; everything else needs human review.</p>
|
|
96
|
+
<script>
|
|
97
|
+
function filt() {{
|
|
98
|
+
const q = document.getElementById('filter').value.toLowerCase();
|
|
99
|
+
for (const tr of document.querySelectorAll('#t tbody tr'))
|
|
100
|
+
tr.style.display = tr.textContent.toLowerCase().includes(q) ? '' : 'none';
|
|
101
|
+
}}
|
|
102
|
+
</script>
|
|
103
|
+
</body></html>"""
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
def write_html_report(result: "ScanResult", path: str | Path) -> None:
|
|
107
|
+
Path(path).write_text(render_html(result), encoding="utf-8")
|
|
@@ -0,0 +1,228 @@
|
|
|
1
|
+
"""Orchestrator: repo path in, ScanResult out. Runs all four layers."""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
import json
|
|
5
|
+
import re
|
|
6
|
+
from dataclasses import dataclass, field
|
|
7
|
+
from pathlib import Path
|
|
8
|
+
from typing import Optional
|
|
9
|
+
|
|
10
|
+
from . import cache
|
|
11
|
+
from .config import load_config
|
|
12
|
+
from .deletion import build_deletion_plan
|
|
13
|
+
from .evidence import build_records
|
|
14
|
+
from .graph import CodeGraph
|
|
15
|
+
from .models import (Action, EvidenceRecord, Marker, MarkerKind, RiskLevel,
|
|
16
|
+
Status, Symbol)
|
|
17
|
+
from .plugin import RuleContext, get_plugin
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
@dataclass
|
|
21
|
+
class ScanResult:
|
|
22
|
+
repo_path: str
|
|
23
|
+
language: str
|
|
24
|
+
records: list[EvidenceRecord]
|
|
25
|
+
symbol_count: int
|
|
26
|
+
edge_count: int
|
|
27
|
+
warnings: list[str] = field(default_factory=list)
|
|
28
|
+
|
|
29
|
+
def summary(self) -> dict:
|
|
30
|
+
counts = {s.value: 0 for s in Status}
|
|
31
|
+
for r in self.records:
|
|
32
|
+
counts[r.status.value] += 1
|
|
33
|
+
return {
|
|
34
|
+
"repo": self.repo_path, "language": self.language,
|
|
35
|
+
"symbols": self.symbol_count, "edges": self.edge_count,
|
|
36
|
+
"status_counts": counts, "warnings": len(self.warnings),
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
def candidates(self) -> list[EvidenceRecord]:
|
|
40
|
+
"""Everything not proven used — the review queue. Ordered by status
|
|
41
|
+
tier, then by rank_score (strongest deletion targets first within a
|
|
42
|
+
tier), so the top of the list is where an agent should look first."""
|
|
43
|
+
order = {Status.SAFE_TO_DELETE: 0, Status.LIKELY_DEAD: 1,
|
|
44
|
+
Status.UNCERTAIN_DYNAMIC_RISK: 2}
|
|
45
|
+
return sorted((r for r in self.records
|
|
46
|
+
if r.status is not Status.DEFINITELY_USED),
|
|
47
|
+
key=lambda r: (order[r.status], -r.rank_score,
|
|
48
|
+
r.file, r.line))
|
|
49
|
+
|
|
50
|
+
def find(self, query: str) -> list[EvidenceRecord]:
|
|
51
|
+
"""Locate records by exact id, dotted path, or trailing name match."""
|
|
52
|
+
exact = [r for r in self.records if r.symbol == query]
|
|
53
|
+
if exact:
|
|
54
|
+
return exact
|
|
55
|
+
norm = query.replace(":", ".")
|
|
56
|
+
dotted = [r for r in self.records
|
|
57
|
+
if r.symbol.replace(":", ".") == norm]
|
|
58
|
+
if dotted:
|
|
59
|
+
return dotted
|
|
60
|
+
return [r for r in self.records
|
|
61
|
+
if r.symbol.replace(":", ".").endswith("." + norm)
|
|
62
|
+
or r.name == query]
|
|
63
|
+
|
|
64
|
+
def to_dict(self, include_used: bool = True) -> dict:
|
|
65
|
+
records = self.records if include_used else self.candidates()
|
|
66
|
+
return {
|
|
67
|
+
"summary": self.summary(),
|
|
68
|
+
"records": [r.to_dict() for r in records],
|
|
69
|
+
"warnings": self.warnings,
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
def save(self, path: str | Path) -> None:
|
|
73
|
+
Path(path).write_text(json.dumps(self.to_dict(), indent=2),
|
|
74
|
+
encoding="utf-8")
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
def _verify_safe_candidates(repo: Path, records: list[EvidenceRecord],
|
|
78
|
+
modules: list, config_files: list[Path],
|
|
79
|
+
symbols: list[Symbol]) -> None:
|
|
80
|
+
"""Independent textual audit of every safe_to_delete verdict.
|
|
81
|
+
|
|
82
|
+
The graph proves no *structural* usage path. This pass additionally
|
|
83
|
+
requires the symbol's name to appear nowhere else in the repository's
|
|
84
|
+
text — source, comments, docstrings, or config. Any stray occurrence is
|
|
85
|
+
a possible usage path, so the verdict is demoted to
|
|
86
|
+
uncertain_dynamic_risk. This makes the core guarantee mechanical:
|
|
87
|
+
safe_to_delete is only ever emitted when no usage path can be found.
|
|
88
|
+
"""
|
|
89
|
+
candidates = [r for r in records if r.status is Status.SAFE_TO_DELETE]
|
|
90
|
+
if not candidates:
|
|
91
|
+
return
|
|
92
|
+
symbols_by_id = {s.id: s for s in symbols}
|
|
93
|
+
|
|
94
|
+
texts: dict[str, str] = {}
|
|
95
|
+
for m in modules:
|
|
96
|
+
try:
|
|
97
|
+
texts[m.rel_path] = Path(m.abs_path).read_text(
|
|
98
|
+
encoding="utf-8", errors="replace")
|
|
99
|
+
except OSError:
|
|
100
|
+
continue
|
|
101
|
+
for path in config_files:
|
|
102
|
+
try:
|
|
103
|
+
texts[path.relative_to(repo).as_posix()] = path.read_text(
|
|
104
|
+
encoding="utf-8", errors="replace")
|
|
105
|
+
except OSError:
|
|
106
|
+
continue
|
|
107
|
+
|
|
108
|
+
for rec in candidates:
|
|
109
|
+
pattern = re.compile(rf"\b{re.escape(rec.name)}\b")
|
|
110
|
+
end_line = symbols_by_id[rec.symbol].end_line
|
|
111
|
+
hit = _find_external_occurrence(rec, end_line, pattern, texts)
|
|
112
|
+
if hit is None:
|
|
113
|
+
rec.evidence_for_deletion.append(
|
|
114
|
+
"Verified: symbol name occurs nowhere else in the "
|
|
115
|
+
"repository's text (source, comments, or config)")
|
|
116
|
+
continue
|
|
117
|
+
rec.status = Status.UNCERTAIN_DYNAMIC_RISK
|
|
118
|
+
rec.risk_level = RiskLevel.HIGH
|
|
119
|
+
rec.recommended_action = Action.REVIEW_REQUIRED
|
|
120
|
+
# A bare text mention (comment/docstring/config) is weak evidence of
|
|
121
|
+
# use, so this ranks near the top of the uncertain tier — but it must
|
|
122
|
+
# no longer carry its former safe-tier score.
|
|
123
|
+
rec.rank_score = 0.40
|
|
124
|
+
rec.evidence_against_deletion.append(
|
|
125
|
+
f"Text occurrence outside the definition at {hit} — possible "
|
|
126
|
+
"usage path the graph cannot classify")
|
|
127
|
+
|
|
128
|
+
|
|
129
|
+
def _find_external_occurrence(rec: EvidenceRecord, end_line: int,
|
|
130
|
+
pattern: re.Pattern,
|
|
131
|
+
texts: dict[str, str]) -> Optional[str]:
|
|
132
|
+
"""First occurrence of the symbol's name outside its own definition
|
|
133
|
+
span, as 'file:line' — or None if the name appears nowhere else.
|
|
134
|
+
The span in the home file (decorators through end of block) is 'self';
|
|
135
|
+
anything beyond it, in any file, counts as a possible usage path."""
|
|
136
|
+
for rel, text in texts.items():
|
|
137
|
+
for lineno, line in enumerate(text.splitlines(), 1):
|
|
138
|
+
if not pattern.search(line):
|
|
139
|
+
continue
|
|
140
|
+
if rel == rec.file and rec.line <= lineno <= end_line:
|
|
141
|
+
continue
|
|
142
|
+
return f"{rel}:{lineno}"
|
|
143
|
+
return None
|
|
144
|
+
|
|
145
|
+
|
|
146
|
+
def scan_repo(repo_path: str | Path, language: str = "python",
|
|
147
|
+
treat_public_as_api: Optional[bool] = None,
|
|
148
|
+
runtime_log: Optional[str | Path] = None,
|
|
149
|
+
use_cache: bool = True,
|
|
150
|
+
reachability: str = "default") -> ScanResult:
|
|
151
|
+
repo = Path(repo_path).resolve()
|
|
152
|
+
if not repo.is_dir():
|
|
153
|
+
raise FileNotFoundError(f"Not a directory: {repo}")
|
|
154
|
+
if reachability not in ("default", "strict"):
|
|
155
|
+
raise ValueError("reachability must be 'default' or 'strict'")
|
|
156
|
+
|
|
157
|
+
plugin = get_plugin(language)
|
|
158
|
+
repo_cfg = load_config(repo)
|
|
159
|
+
if treat_public_as_api is None:
|
|
160
|
+
# .codetruth.toml app_mode=true means "application code":
|
|
161
|
+
# public symbols are internal.
|
|
162
|
+
treat_public_as_api = not repo_cfg.app_mode if repo_cfg.app_mode is not None \
|
|
163
|
+
else True
|
|
164
|
+
|
|
165
|
+
ignores = tuple(repo_cfg.ignore_paths)
|
|
166
|
+
|
|
167
|
+
# Persistent cache: a scan is a pure function of the source+config bytes
|
|
168
|
+
# (the .codetruth.toml is fingerprinted too). Runtime logs are not part
|
|
169
|
+
# of the fingerprint, so bypass the cache when one is supplied.
|
|
170
|
+
fp = None
|
|
171
|
+
if use_cache and not runtime_log and hasattr(plugin, "source_files"):
|
|
172
|
+
fp = cache.fingerprint(repo, plugin.source_files(repo, ignores))
|
|
173
|
+
cached = cache.load(repo, language, treat_public_as_api, fp,
|
|
174
|
+
reachability)
|
|
175
|
+
if cached is not None:
|
|
176
|
+
return cached
|
|
177
|
+
|
|
178
|
+
# Layer 1 — symbols
|
|
179
|
+
modules, warnings = plugin.extract(repo, ignores)
|
|
180
|
+
index = plugin.build_index(modules)
|
|
181
|
+
symbols: list[Symbol] = plugin.symbols(modules)
|
|
182
|
+
|
|
183
|
+
# Layer 2 — relationship graph
|
|
184
|
+
graph = CodeGraph()
|
|
185
|
+
markers: list[Marker] = []
|
|
186
|
+
plugin.build_edges(repo, modules, index, graph, markers)
|
|
187
|
+
|
|
188
|
+
# Layer 3 — semantic safety rules
|
|
189
|
+
config_files = plugin.config_files(repo, ignores) \
|
|
190
|
+
if hasattr(plugin, "config_files") else []
|
|
191
|
+
ctx = RuleContext(repo_path=repo, modules=modules, index=index,
|
|
192
|
+
graph=graph, markers=markers, config_files=config_files)
|
|
193
|
+
for rule in plugin.rules():
|
|
194
|
+
rule.apply(ctx)
|
|
195
|
+
|
|
196
|
+
# Phase 6 — runtime evidence (strongest tier when present)
|
|
197
|
+
if runtime_log:
|
|
198
|
+
from ..runtime import load_runtime_markers
|
|
199
|
+
markers.extend(load_runtime_markers(runtime_log, {s.id for s in symbols}))
|
|
200
|
+
|
|
201
|
+
# Layer 4 — evidence + decision
|
|
202
|
+
records = build_records(symbols, graph, markers,
|
|
203
|
+
treat_public_as_api=treat_public_as_api,
|
|
204
|
+
reachability=reachability)
|
|
205
|
+
|
|
206
|
+
# Final backstop: never emit safe_to_delete when ANY usage path exists —
|
|
207
|
+
# including ones the AST can't see (comments, docstrings, templates).
|
|
208
|
+
_verify_safe_candidates(repo, records, modules, config_files, symbols)
|
|
209
|
+
|
|
210
|
+
# Attach advisory deletion plans to the records that earned one.
|
|
211
|
+
symbols_by_id = {s.id: s for s in symbols}
|
|
212
|
+
for rec in records:
|
|
213
|
+
if rec.status is Status.SAFE_TO_DELETE:
|
|
214
|
+
try:
|
|
215
|
+
rec.deletion_plan = build_deletion_plan(repo,
|
|
216
|
+
symbols_by_id[rec.symbol])
|
|
217
|
+
except Exception: # a plan is advice; never fail the scan for it
|
|
218
|
+
rec.deletion_plan = None
|
|
219
|
+
|
|
220
|
+
result = ScanResult(
|
|
221
|
+
repo_path=str(repo), language=language, records=records,
|
|
222
|
+
symbol_count=len(symbols), edge_count=len(graph.edges),
|
|
223
|
+
warnings=warnings,
|
|
224
|
+
)
|
|
225
|
+
if fp is not None:
|
|
226
|
+
cache.save(repo, result, language, treat_public_as_api, fp,
|
|
227
|
+
reachability)
|
|
228
|
+
return result
|
|
File without changes
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
"""Go plugin — v2+ stub. See PLAN.md §7."""
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
"""JavaScript/TypeScript plugin (beta).
|
|
2
|
+
|
|
3
|
+
Extraction and edge building via tree-sitter; Layer 3 covers package.json
|
|
4
|
+
entry points, constructors, eval/new Function poisoning, string references,
|
|
5
|
+
and declared entrypoints. Install the parser dependency with:
|
|
6
|
+
|
|
7
|
+
pip install codetruth[javascript]
|
|
8
|
+
"""
|