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