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/__init__.py
ADDED
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
"""CodeTruth — a verification layer that lets AI agents safely delete code.
|
|
2
|
+
|
|
3
|
+
from codetruth import scan, check_deletion_safety, track
|
|
4
|
+
"""
|
|
5
|
+
from .api import check_deletion_safety, plan_deletion, scan
|
|
6
|
+
from .core.models import (Action, Edge, EvidenceRecord, RiskLevel, Status,
|
|
7
|
+
Symbol)
|
|
8
|
+
from .runtime import track
|
|
9
|
+
|
|
10
|
+
__version__ = "0.2.0"
|
|
11
|
+
|
|
12
|
+
__all__ = [
|
|
13
|
+
"scan", "check_deletion_safety", "plan_deletion", "track",
|
|
14
|
+
"Status", "RiskLevel", "Action", "Symbol", "Edge", "EvidenceRecord",
|
|
15
|
+
"__version__",
|
|
16
|
+
]
|
codetruth/api.py
ADDED
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
"""Programmatic API — what the MCP server and scripts call.
|
|
2
|
+
|
|
3
|
+
from codetruth import scan, check_deletion_safety, plan_deletion
|
|
4
|
+
"""
|
|
5
|
+
from __future__ import annotations
|
|
6
|
+
|
|
7
|
+
from pathlib import Path
|
|
8
|
+
from typing import Optional
|
|
9
|
+
|
|
10
|
+
from .core.deletion import build_deletion_plan
|
|
11
|
+
from .core.plugin import get_plugin
|
|
12
|
+
from .core.scanner import ScanResult, scan_repo
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
def scan(repo_path: str | Path, language: str = "python",
|
|
16
|
+
treat_public_as_api: Optional[bool] = None,
|
|
17
|
+
runtime_log: Optional[str | Path] = None,
|
|
18
|
+
use_cache: bool = True,
|
|
19
|
+
reachability: str = "default") -> ScanResult:
|
|
20
|
+
"""Run all four layers over a repository and return the evidence set.
|
|
21
|
+
|
|
22
|
+
treat_public_as_api: True (conservative, the default when neither the
|
|
23
|
+
caller nor .codetruth.toml says otherwise) caps unreferenced public
|
|
24
|
+
symbols at `likely_dead` because a library's consumers are invisible.
|
|
25
|
+
False for application code; None defers to `app_mode` in .codetruth.toml.
|
|
26
|
+
|
|
27
|
+
reachability: "default", or "strict" to detect useless clumps — code
|
|
28
|
+
that is internally connected but never reached from any real entry point
|
|
29
|
+
(route, CLI command, __main__, test, declared entrypoint). Strict-mode
|
|
30
|
+
findings surface in the review queue with cluster grouping.
|
|
31
|
+
|
|
32
|
+
use_cache=True reuses a persisted result (<repo>/.codetruth/index.json)
|
|
33
|
+
when no source or config file has changed since the last scan.
|
|
34
|
+
"""
|
|
35
|
+
return scan_repo(repo_path, language=language,
|
|
36
|
+
treat_public_as_api=treat_public_as_api,
|
|
37
|
+
runtime_log=runtime_log, use_cache=use_cache,
|
|
38
|
+
reachability=reachability)
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def check_deletion_safety(repo_path: str | Path, symbol: str,
|
|
42
|
+
result: Optional[ScanResult] = None,
|
|
43
|
+
**scan_kwargs) -> dict:
|
|
44
|
+
"""The agent-facing question: 'may I delete this symbol?'
|
|
45
|
+
|
|
46
|
+
Returns the evidence record(s) for the symbol, or candidate matches if
|
|
47
|
+
the name is ambiguous. The agent should only act on `safe_to_delete`.
|
|
48
|
+
"""
|
|
49
|
+
if result is None:
|
|
50
|
+
result = scan(repo_path, **scan_kwargs)
|
|
51
|
+
matches = result.find(symbol)
|
|
52
|
+
if not matches:
|
|
53
|
+
return {
|
|
54
|
+
"found": False, "symbol": symbol,
|
|
55
|
+
"message": "Symbol not found in the scanned repository. "
|
|
56
|
+
"Do NOT delete based on this response — verify the "
|
|
57
|
+
"symbol id (format: 'pkg.module:Qual.name').",
|
|
58
|
+
}
|
|
59
|
+
if len(matches) > 1:
|
|
60
|
+
return {
|
|
61
|
+
"found": True, "ambiguous": True, "symbol": symbol,
|
|
62
|
+
"message": f"{len(matches)} symbols match — re-query with an exact id.",
|
|
63
|
+
"candidates": [m.to_dict() for m in matches[:20]],
|
|
64
|
+
}
|
|
65
|
+
record = matches[0]
|
|
66
|
+
return {"found": True, "ambiguous": False, "record": record.to_dict()}
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
def plan_deletion(repo_path: str | Path, symbol: str,
|
|
70
|
+
language: str = "python",
|
|
71
|
+
result: Optional[ScanResult] = None,
|
|
72
|
+
**scan_kwargs) -> dict:
|
|
73
|
+
"""Advisory plan for removing one symbol: exact span, orphaned imports,
|
|
74
|
+
__all__ entry. Works for any status — the response carries the verdict so
|
|
75
|
+
the reader knows whether acting on the plan is advised. CodeTruth never
|
|
76
|
+
applies the plan.
|
|
77
|
+
"""
|
|
78
|
+
safety = check_deletion_safety(repo_path, symbol, result=result,
|
|
79
|
+
**scan_kwargs)
|
|
80
|
+
if not safety.get("found") or safety.get("ambiguous"):
|
|
81
|
+
return safety
|
|
82
|
+
|
|
83
|
+
record = safety["record"]
|
|
84
|
+
if record.get("deletion_plan"):
|
|
85
|
+
plan = record["deletion_plan"]
|
|
86
|
+
else:
|
|
87
|
+
repo = Path(repo_path).resolve()
|
|
88
|
+
plugin = get_plugin(language)
|
|
89
|
+
modules, _warnings = plugin.extract(repo)
|
|
90
|
+
sym = next((s for m in modules for s in m.symbols
|
|
91
|
+
if s.id == record["symbol"]), None)
|
|
92
|
+
plan = build_deletion_plan(repo, sym) if sym is not None else None
|
|
93
|
+
|
|
94
|
+
return {
|
|
95
|
+
"found": True, "symbol": record["symbol"],
|
|
96
|
+
"status": record["status"],
|
|
97
|
+
"recommended_action": record["recommended_action"],
|
|
98
|
+
"plan": plan,
|
|
99
|
+
"warning": None if record["status"] == "safe_to_delete" else
|
|
100
|
+
f"status is {record['status']} — this plan is informational; "
|
|
101
|
+
"review is required before anyone acts on it",
|
|
102
|
+
}
|
codetruth/cli.py
ADDED
|
@@ -0,0 +1,183 @@
|
|
|
1
|
+
"""Human/script CLI.
|
|
2
|
+
|
|
3
|
+
codetruth scan ./repo [--json out.json] [--status likely_dead] [--app-mode]
|
|
4
|
+
codetruth scan ./repo --strict --min-rank 0.5 --group
|
|
5
|
+
codetruth scan ./repo --ci # exit 1 if safe_to_delete found
|
|
6
|
+
codetruth scan ./repo --html report.html
|
|
7
|
+
codetruth check ./repo pkg.module:symbol
|
|
8
|
+
codetruth plan ./repo pkg.module:symbol
|
|
9
|
+
codetruth mcp
|
|
10
|
+
"""
|
|
11
|
+
from __future__ import annotations
|
|
12
|
+
|
|
13
|
+
import argparse
|
|
14
|
+
import json
|
|
15
|
+
import sys
|
|
16
|
+
from collections import defaultdict
|
|
17
|
+
|
|
18
|
+
from .api import check_deletion_safety, plan_deletion, scan
|
|
19
|
+
from .core.models import Status
|
|
20
|
+
from .core.report import write_html_report
|
|
21
|
+
|
|
22
|
+
STATUS_ICON = {
|
|
23
|
+
Status.SAFE_TO_DELETE: "[SAFE] ",
|
|
24
|
+
Status.LIKELY_DEAD: "[DEAD?] ",
|
|
25
|
+
Status.UNCERTAIN_DYNAMIC_RISK: "[RISK] ",
|
|
26
|
+
Status.DEFINITELY_USED: "[USED] ",
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def _print_record(r, verbose: bool, indent: str = "") -> None:
|
|
31
|
+
print(f"{indent}{STATUS_ICON[r.status]}{r.rank_score:.2f} {r.symbol} "
|
|
32
|
+
f"({r.file}:{r.line})")
|
|
33
|
+
if verbose:
|
|
34
|
+
for e in r.evidence_for_deletion:
|
|
35
|
+
print(f"{indent} + {e}")
|
|
36
|
+
for e in r.evidence_against_deletion:
|
|
37
|
+
print(f"{indent} - {e}")
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def _cmd_scan(args) -> int:
|
|
41
|
+
result = scan(args.repo, language=args.language,
|
|
42
|
+
treat_public_as_api=False if args.app_mode else None,
|
|
43
|
+
runtime_log=args.runtime_log, use_cache=not args.no_cache,
|
|
44
|
+
reachability="strict" if args.strict else "default")
|
|
45
|
+
summary = result.summary()
|
|
46
|
+
|
|
47
|
+
if args.json:
|
|
48
|
+
result.save(args.json)
|
|
49
|
+
print(f"Wrote full evidence to {args.json}")
|
|
50
|
+
if args.html:
|
|
51
|
+
write_html_report(result, args.html)
|
|
52
|
+
print(f"Wrote HTML report to {args.html}")
|
|
53
|
+
|
|
54
|
+
records = result.candidates()
|
|
55
|
+
if args.status:
|
|
56
|
+
records = [r for r in records if r.status.value == args.status]
|
|
57
|
+
if args.min_rank is not None:
|
|
58
|
+
records = [r for r in records if r.rank_score >= args.min_rank]
|
|
59
|
+
|
|
60
|
+
shown = records[: args.limit]
|
|
61
|
+
if args.group:
|
|
62
|
+
by_file: dict[str, list] = defaultdict(list)
|
|
63
|
+
for r in shown:
|
|
64
|
+
by_file[r.file].append(r)
|
|
65
|
+
for file in sorted(by_file):
|
|
66
|
+
print(f"\n{file}")
|
|
67
|
+
for r in by_file[file]:
|
|
68
|
+
_print_record(r, args.verbose, indent=" ")
|
|
69
|
+
else:
|
|
70
|
+
for r in shown:
|
|
71
|
+
_print_record(r, args.verbose)
|
|
72
|
+
if len(records) > args.limit:
|
|
73
|
+
print(f"... {len(records) - args.limit} more (raise --limit or use --json)")
|
|
74
|
+
|
|
75
|
+
c = summary["status_counts"]
|
|
76
|
+
print(f"\n{summary['symbols']} symbols, {summary['edges']} edges | "
|
|
77
|
+
f"safe_to_delete: {c['safe_to_delete']} "
|
|
78
|
+
f"likely_dead: {c['likely_dead']} "
|
|
79
|
+
f"uncertain: {c['uncertain_dynamic_risk']} "
|
|
80
|
+
f"used: {c['definitely_used']}")
|
|
81
|
+
if result.warnings:
|
|
82
|
+
print(f"warnings: {len(result.warnings)} (parse failures)")
|
|
83
|
+
|
|
84
|
+
if args.ci:
|
|
85
|
+
# Report-gate: fail the build when provably-dead code exists so a
|
|
86
|
+
# human looks. Never deletes anything — advisory, as always.
|
|
87
|
+
n = c["safe_to_delete"]
|
|
88
|
+
if n:
|
|
89
|
+
print(f"\nCI gate: {n} safe_to_delete symbol(s) found — failing "
|
|
90
|
+
"(review and remove, or mark as an entrypoint).",
|
|
91
|
+
file=sys.stderr)
|
|
92
|
+
return 1
|
|
93
|
+
return 0
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
def _cmd_check(args) -> int:
|
|
97
|
+
response = check_deletion_safety(
|
|
98
|
+
args.repo, args.symbol,
|
|
99
|
+
treat_public_as_api=False if args.app_mode else None)
|
|
100
|
+
print(json.dumps(response, indent=2))
|
|
101
|
+
return 0
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
def _cmd_plan(args) -> int:
|
|
105
|
+
response = plan_deletion(
|
|
106
|
+
args.repo, args.symbol,
|
|
107
|
+
treat_public_as_api=False if args.app_mode else None)
|
|
108
|
+
print(json.dumps(response, indent=2))
|
|
109
|
+
return 0
|
|
110
|
+
|
|
111
|
+
|
|
112
|
+
def _cmd_mcp(_args) -> int:
|
|
113
|
+
try:
|
|
114
|
+
from .mcp_server import main as mcp_main
|
|
115
|
+
except ImportError:
|
|
116
|
+
print("The MCP server requires the 'mcp' package: pip install codetruth[mcp]",
|
|
117
|
+
file=sys.stderr)
|
|
118
|
+
return 1
|
|
119
|
+
mcp_main()
|
|
120
|
+
return 0
|
|
121
|
+
|
|
122
|
+
|
|
123
|
+
def main(argv: list[str] | None = None) -> int:
|
|
124
|
+
parser = argparse.ArgumentParser(
|
|
125
|
+
prog="codetruth",
|
|
126
|
+
description="Deletion-safety evidence for code symbols. "
|
|
127
|
+
"A risk assessor, not an oracle: only act on safe_to_delete.")
|
|
128
|
+
sub = parser.add_subparsers(dest="command", required=True)
|
|
129
|
+
|
|
130
|
+
p_scan = sub.add_parser("scan", help="scan a repository")
|
|
131
|
+
p_scan.add_argument("repo")
|
|
132
|
+
p_scan.add_argument("--language", "-l", default="python",
|
|
133
|
+
choices=["python", "javascript", "typescript"],
|
|
134
|
+
help="language plugin (javascript needs "
|
|
135
|
+
"`pip install codetruth[javascript]`)")
|
|
136
|
+
p_scan.add_argument("--json", help="write full evidence JSON to this file")
|
|
137
|
+
p_scan.add_argument("--status", choices=[s.value for s in Status])
|
|
138
|
+
p_scan.add_argument("--limit", type=int, default=50)
|
|
139
|
+
p_scan.add_argument("--verbose", "-v", action="store_true",
|
|
140
|
+
help="print evidence lines per symbol")
|
|
141
|
+
p_scan.add_argument("--app-mode", action="store_true",
|
|
142
|
+
help="treat public symbols as internal (application, "
|
|
143
|
+
"not library) — allows safe_to_delete on them")
|
|
144
|
+
p_scan.add_argument("--runtime-log", help="path to a runtime.jsonl trace")
|
|
145
|
+
p_scan.add_argument("--no-cache", action="store_true",
|
|
146
|
+
help="ignore the persisted .codetruth/index.json result")
|
|
147
|
+
p_scan.add_argument("--strict", action="store_true",
|
|
148
|
+
help="strict reachability: flag code not reachable "
|
|
149
|
+
"from any real entry point (detects internally-"
|
|
150
|
+
"connected but orphaned clumps)")
|
|
151
|
+
p_scan.add_argument("--min-rank", type=float, default=None,
|
|
152
|
+
help="only show candidates with rank_score >= this "
|
|
153
|
+
"(0-1); trims the noisy tail of the review queue")
|
|
154
|
+
p_scan.add_argument("--group", action="store_true",
|
|
155
|
+
help="group the output by file")
|
|
156
|
+
p_scan.add_argument("--html", help="write a standalone HTML report here")
|
|
157
|
+
p_scan.add_argument("--ci", action="store_true",
|
|
158
|
+
help="exit non-zero if any safe_to_delete code exists "
|
|
159
|
+
"(a dead-code report gate; never deletes)")
|
|
160
|
+
p_scan.set_defaults(func=_cmd_scan)
|
|
161
|
+
|
|
162
|
+
p_check = sub.add_parser("check", help="check one symbol's deletion safety")
|
|
163
|
+
p_check.add_argument("repo")
|
|
164
|
+
p_check.add_argument("symbol", help="e.g. pkg.module:function_name")
|
|
165
|
+
p_check.add_argument("--app-mode", action="store_true")
|
|
166
|
+
p_check.set_defaults(func=_cmd_check)
|
|
167
|
+
|
|
168
|
+
p_plan = sub.add_parser(
|
|
169
|
+
"plan", help="advisory deletion plan for one symbol (never applied)")
|
|
170
|
+
p_plan.add_argument("repo")
|
|
171
|
+
p_plan.add_argument("symbol", help="e.g. pkg.module:function_name")
|
|
172
|
+
p_plan.add_argument("--app-mode", action="store_true")
|
|
173
|
+
p_plan.set_defaults(func=_cmd_plan)
|
|
174
|
+
|
|
175
|
+
p_mcp = sub.add_parser("mcp", help="run the MCP server (stdio)")
|
|
176
|
+
p_mcp.set_defaults(func=_cmd_mcp)
|
|
177
|
+
|
|
178
|
+
args = parser.parse_args(argv)
|
|
179
|
+
return args.func(args)
|
|
180
|
+
|
|
181
|
+
|
|
182
|
+
if __name__ == "__main__":
|
|
183
|
+
sys.exit(main())
|
|
File without changes
|
codetruth/core/cache.py
ADDED
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
"""Phase-5 performance: a persistent scan cache keyed by file fingerprints.
|
|
2
|
+
|
|
3
|
+
A scan is deterministic given the repo's source + config bytes, so we can
|
|
4
|
+
skip it entirely when nothing has changed since the last run. The cache lives
|
|
5
|
+
at <repo>/.codetruth/index.json and survives process restarts — which the
|
|
6
|
+
MCP server's in-memory TTL cache does not.
|
|
7
|
+
|
|
8
|
+
Invalidation is whole-repo and mtime+size based: if any tracked file's
|
|
9
|
+
(mtime_ns, size) differs from what produced the cached result, the cache
|
|
10
|
+
misses and a full rescan runs. We deliberately do NOT patch the graph
|
|
11
|
+
incrementally per changed file — edges cross files, and a stale edge could
|
|
12
|
+
turn a used symbol into a false safe_to_delete, the one error class the tool
|
|
13
|
+
exists to prevent. Correctness first; the cache only ever short-circuits an
|
|
14
|
+
exact, verified match.
|
|
15
|
+
"""
|
|
16
|
+
from __future__ import annotations
|
|
17
|
+
|
|
18
|
+
import json
|
|
19
|
+
from pathlib import Path
|
|
20
|
+
from typing import TYPE_CHECKING, Optional
|
|
21
|
+
|
|
22
|
+
from .models import EvidenceRecord
|
|
23
|
+
|
|
24
|
+
if TYPE_CHECKING:
|
|
25
|
+
from .scanner import ScanResult
|
|
26
|
+
|
|
27
|
+
# Bump on ANY change to classification, ranking, or the record schema — a
|
|
28
|
+
# cached result from older logic must never be served by newer code.
|
|
29
|
+
CACHE_VERSION = 3
|
|
30
|
+
CACHE_DIRNAME = ".codetruth"
|
|
31
|
+
CACHE_FILENAME = "index.json"
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def _cache_path(repo: Path) -> Path:
|
|
35
|
+
return repo / CACHE_DIRNAME / CACHE_FILENAME
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def fingerprint(repo: Path, source_files: list[Path]) -> dict[str, list[int]]:
|
|
39
|
+
"""{rel_path: [mtime_ns, size]} for every file whose bytes affect a scan."""
|
|
40
|
+
fp: dict[str, list[int]] = {}
|
|
41
|
+
for path in source_files:
|
|
42
|
+
try:
|
|
43
|
+
st = path.stat()
|
|
44
|
+
except OSError:
|
|
45
|
+
continue
|
|
46
|
+
rel = path.relative_to(repo).as_posix()
|
|
47
|
+
fp[rel] = [st.st_mtime_ns, st.st_size]
|
|
48
|
+
return fp
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def load(repo: Path, language: str, treat_public_as_api: bool,
|
|
52
|
+
current_fp: dict[str, list[int]],
|
|
53
|
+
reachability: str = "default") -> Optional["ScanResult"]:
|
|
54
|
+
"""Return a cached ScanResult iff it matches the current inputs exactly."""
|
|
55
|
+
from .scanner import ScanResult # local import: avoid a cycle
|
|
56
|
+
|
|
57
|
+
path = _cache_path(repo)
|
|
58
|
+
if not path.is_file():
|
|
59
|
+
return None
|
|
60
|
+
try:
|
|
61
|
+
doc = json.loads(path.read_text(encoding="utf-8"))
|
|
62
|
+
except (OSError, json.JSONDecodeError):
|
|
63
|
+
return None
|
|
64
|
+
|
|
65
|
+
if (doc.get("version") != CACHE_VERSION
|
|
66
|
+
or doc.get("language") != language
|
|
67
|
+
or doc.get("treat_public_as_api") != treat_public_as_api
|
|
68
|
+
or doc.get("reachability", "default") != reachability
|
|
69
|
+
or doc.get("fingerprint") != current_fp):
|
|
70
|
+
return None
|
|
71
|
+
|
|
72
|
+
try:
|
|
73
|
+
records = [EvidenceRecord.from_dict(r) for r in doc["records"]]
|
|
74
|
+
return ScanResult(
|
|
75
|
+
repo_path=str(repo), language=language, records=records,
|
|
76
|
+
symbol_count=doc["symbol_count"], edge_count=doc["edge_count"],
|
|
77
|
+
warnings=list(doc.get("warnings", [])),
|
|
78
|
+
)
|
|
79
|
+
except (KeyError, ValueError):
|
|
80
|
+
return None # schema drift — treat as a miss and rescan
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
def save(repo: Path, result: "ScanResult", language: str,
|
|
84
|
+
treat_public_as_api: bool, current_fp: dict[str, list[int]],
|
|
85
|
+
reachability: str = "default") -> None:
|
|
86
|
+
doc = {
|
|
87
|
+
"version": CACHE_VERSION,
|
|
88
|
+
"language": language,
|
|
89
|
+
"treat_public_as_api": treat_public_as_api,
|
|
90
|
+
"reachability": reachability,
|
|
91
|
+
"fingerprint": current_fp,
|
|
92
|
+
"symbol_count": result.symbol_count,
|
|
93
|
+
"edge_count": result.edge_count,
|
|
94
|
+
"warnings": result.warnings,
|
|
95
|
+
"records": [r.to_dict() for r in result.records],
|
|
96
|
+
}
|
|
97
|
+
path = _cache_path(repo)
|
|
98
|
+
try:
|
|
99
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
100
|
+
tmp = path.with_suffix(".tmp")
|
|
101
|
+
tmp.write_text(json.dumps(doc), encoding="utf-8")
|
|
102
|
+
tmp.replace(path) # atomic on POSIX and Windows
|
|
103
|
+
except OSError:
|
|
104
|
+
pass # a read-only repo just means no persistent cache; not fatal
|
codetruth/core/config.py
ADDED
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
"""Repo-level configuration: let users teach the scanner about usage it
|
|
2
|
+
cannot see, and scope what it looks at.
|
|
3
|
+
|
|
4
|
+
`.codetruth.toml` at the repository root:
|
|
5
|
+
|
|
6
|
+
[codetruth]
|
|
7
|
+
app_mode = true # public symbols are internal (application)
|
|
8
|
+
entrypoints = [ # externally-reached symbols (cron, RPC, ...)
|
|
9
|
+
"jobs.nightly:run",
|
|
10
|
+
"services.handlers.*",
|
|
11
|
+
]
|
|
12
|
+
ignore_paths = [ # not scanned at all
|
|
13
|
+
"migrations/",
|
|
14
|
+
"vendor/**",
|
|
15
|
+
]
|
|
16
|
+
|
|
17
|
+
Inline suppression: a `# codetruth: keep` comment on (or directly above) a
|
|
18
|
+
definition marks it as an entry point.
|
|
19
|
+
"""
|
|
20
|
+
from __future__ import annotations
|
|
21
|
+
|
|
22
|
+
import fnmatch
|
|
23
|
+
from dataclasses import dataclass, field
|
|
24
|
+
from pathlib import Path
|
|
25
|
+
from typing import Optional
|
|
26
|
+
|
|
27
|
+
try:
|
|
28
|
+
import tomllib # Python 3.11+
|
|
29
|
+
except ImportError: # pragma: no cover - Python 3.10
|
|
30
|
+
try:
|
|
31
|
+
import tomli as tomllib
|
|
32
|
+
except ImportError:
|
|
33
|
+
tomllib = None
|
|
34
|
+
|
|
35
|
+
CONFIG_FILENAME = ".codetruth.toml"
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
@dataclass
|
|
39
|
+
class RepoConfig:
|
|
40
|
+
app_mode: Optional[bool] = None
|
|
41
|
+
entrypoints: list[str] = field(default_factory=list)
|
|
42
|
+
ignore_paths: list[str] = field(default_factory=list)
|
|
43
|
+
|
|
44
|
+
def is_ignored(self, rel_path: str) -> bool:
|
|
45
|
+
rel = rel_path.replace("\\", "/")
|
|
46
|
+
for pat in self.ignore_paths:
|
|
47
|
+
pat = pat.replace("\\", "/")
|
|
48
|
+
base = pat.rstrip("/*")
|
|
49
|
+
if fnmatch.fnmatch(rel, pat) or fnmatch.fnmatch(rel, base + "/*") \
|
|
50
|
+
or rel == base or rel.startswith(base + "/"):
|
|
51
|
+
return True
|
|
52
|
+
return False
|
|
53
|
+
|
|
54
|
+
def is_declared_entrypoint(self, symbol_id: str) -> bool:
|
|
55
|
+
dotted = symbol_id.replace(":", ".")
|
|
56
|
+
return any(fnmatch.fnmatch(symbol_id, pat)
|
|
57
|
+
or fnmatch.fnmatch(dotted, pat.replace(":", "."))
|
|
58
|
+
for pat in self.entrypoints)
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
def load_config(repo: Path) -> RepoConfig:
|
|
62
|
+
path = repo / CONFIG_FILENAME
|
|
63
|
+
if tomllib is None or not path.is_file():
|
|
64
|
+
return RepoConfig()
|
|
65
|
+
try:
|
|
66
|
+
doc = tomllib.loads(path.read_text(encoding="utf-8"))
|
|
67
|
+
except (OSError, ValueError):
|
|
68
|
+
return RepoConfig()
|
|
69
|
+
section = doc.get("codetruth", doc) # tolerate a bare top-level table
|
|
70
|
+
return RepoConfig(
|
|
71
|
+
app_mode=section.get("app_mode"),
|
|
72
|
+
entrypoints=[str(x) for x in section.get("entrypoints", [])],
|
|
73
|
+
ignore_paths=[str(x) for x in section.get("ignore_paths", [])],
|
|
74
|
+
)
|
|
@@ -0,0 +1,165 @@
|
|
|
1
|
+
"""Advisory deletion plans: *describe* exactly what removing a symbol would
|
|
2
|
+
involve — the tool never applies any of it (advisory-only by design).
|
|
3
|
+
|
|
4
|
+
A plan answers the three questions a reviewer has to work out by hand:
|
|
5
|
+
1. What is the exact span to remove (decorators through end of block,
|
|
6
|
+
including trailing blank lines)?
|
|
7
|
+
2. Which imports in the same file become orphaned — their only remaining
|
|
8
|
+
user is the symbol being removed?
|
|
9
|
+
3. Does anything else reference the name administratively (an `__all__`
|
|
10
|
+
entry) that would need cleanup?
|
|
11
|
+
"""
|
|
12
|
+
from __future__ import annotations
|
|
13
|
+
|
|
14
|
+
import ast
|
|
15
|
+
from pathlib import Path
|
|
16
|
+
from typing import Optional
|
|
17
|
+
|
|
18
|
+
from .models import Symbol, SymbolType
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def build_deletion_plan(repo: Path, sym: Symbol) -> Optional[dict]:
|
|
22
|
+
"""Compute an advisory plan for one symbol. Returns None when the file
|
|
23
|
+
can't be parsed or the definition can't be located (never raises)."""
|
|
24
|
+
if sym.type is SymbolType.MODULE:
|
|
25
|
+
return {
|
|
26
|
+
"symbol": sym.id, "file": sym.file, "kind": "module",
|
|
27
|
+
"note": "whole-file removal candidate — review the module, then "
|
|
28
|
+
"remove the file and any references to the module name",
|
|
29
|
+
}
|
|
30
|
+
try:
|
|
31
|
+
source = (repo / sym.file).read_text(encoding="utf-8", errors="replace")
|
|
32
|
+
tree = ast.parse(source)
|
|
33
|
+
except (OSError, SyntaxError, ValueError):
|
|
34
|
+
return None
|
|
35
|
+
|
|
36
|
+
node = _locate(tree, sym.qualname)
|
|
37
|
+
if node is None:
|
|
38
|
+
return None
|
|
39
|
+
|
|
40
|
+
lines = source.splitlines()
|
|
41
|
+
start = node.lineno
|
|
42
|
+
if getattr(node, "decorator_list", None):
|
|
43
|
+
start = min(d.lineno for d in node.decorator_list)
|
|
44
|
+
end = node.end_lineno or node.lineno
|
|
45
|
+
# Consume trailing blank lines so the removal leaves no gap.
|
|
46
|
+
end_with_blanks = end
|
|
47
|
+
while end_with_blanks < len(lines) and not lines[end_with_blanks].strip():
|
|
48
|
+
end_with_blanks += 1
|
|
49
|
+
|
|
50
|
+
plan = {
|
|
51
|
+
"symbol": sym.id,
|
|
52
|
+
"file": sym.file,
|
|
53
|
+
"kind": sym.type.value,
|
|
54
|
+
"span": {"start_line": start, "end_line": end},
|
|
55
|
+
"span_with_trailing_blanks": {"start_line": start,
|
|
56
|
+
"end_line": end_with_blanks},
|
|
57
|
+
"orphaned_imports": _orphaned_imports(tree, start, end),
|
|
58
|
+
"note": "advisory only — CodeTruth never applies deletions",
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
dunder_all = _all_entry_line(tree, sym.name)
|
|
62
|
+
if dunder_all is not None:
|
|
63
|
+
plan["dunder_all_entry"] = {
|
|
64
|
+
"file": sym.file, "line": dunder_all,
|
|
65
|
+
"note": f"remove '{sym.name}' from __all__",
|
|
66
|
+
}
|
|
67
|
+
return plan
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
def _locate(tree: ast.Module, qualname: str) -> Optional[ast.AST]:
|
|
71
|
+
"""Walk the def/class nesting to the node matching a dotted qualname.
|
|
72
|
+
Variables resolve to their (first) assignment statement."""
|
|
73
|
+
parts = qualname.split(".")
|
|
74
|
+
body = tree.body
|
|
75
|
+
node: Optional[ast.AST] = None
|
|
76
|
+
for i, part in enumerate(parts):
|
|
77
|
+
node = _find_in_body(body, part)
|
|
78
|
+
if node is None:
|
|
79
|
+
return None
|
|
80
|
+
if i < len(parts) - 1:
|
|
81
|
+
if not isinstance(node, (ast.FunctionDef, ast.AsyncFunctionDef,
|
|
82
|
+
ast.ClassDef)):
|
|
83
|
+
return None
|
|
84
|
+
body = node.body
|
|
85
|
+
return node
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
def _find_in_body(body, name: str) -> Optional[ast.AST]:
|
|
89
|
+
for stmt in body:
|
|
90
|
+
if isinstance(stmt, (ast.FunctionDef, ast.AsyncFunctionDef,
|
|
91
|
+
ast.ClassDef)) and stmt.name == name:
|
|
92
|
+
return stmt
|
|
93
|
+
if isinstance(stmt, ast.Assign):
|
|
94
|
+
for t in stmt.targets:
|
|
95
|
+
if isinstance(t, ast.Name) and t.id == name:
|
|
96
|
+
return stmt
|
|
97
|
+
if isinstance(stmt, ast.AnnAssign) and isinstance(stmt.target, ast.Name) \
|
|
98
|
+
and stmt.target.id == name:
|
|
99
|
+
return stmt
|
|
100
|
+
if isinstance(stmt, (ast.If, ast.Try, ast.With)):
|
|
101
|
+
blocks = [getattr(stmt, "body", [])] + \
|
|
102
|
+
[getattr(stmt, "orelse", [])] + \
|
|
103
|
+
[getattr(stmt, "finalbody", [])] + \
|
|
104
|
+
[h.body for h in getattr(stmt, "handlers", [])]
|
|
105
|
+
for blk in blocks:
|
|
106
|
+
found = _find_in_body(blk, name)
|
|
107
|
+
if found is not None:
|
|
108
|
+
return found
|
|
109
|
+
return None
|
|
110
|
+
|
|
111
|
+
|
|
112
|
+
def _orphaned_imports(tree: ast.Module, start: int, end: int) -> list[dict]:
|
|
113
|
+
"""Imports whose bound name is used ONLY inside the [start, end] span —
|
|
114
|
+
they become dead once the span is removed."""
|
|
115
|
+
# bound name -> import statement line
|
|
116
|
+
bound: dict[str, int] = {}
|
|
117
|
+
import_lines: set[int] = set()
|
|
118
|
+
for node in ast.walk(tree):
|
|
119
|
+
if isinstance(node, ast.Import):
|
|
120
|
+
import_lines.add(node.lineno)
|
|
121
|
+
for alias in node.names:
|
|
122
|
+
bound[alias.asname or alias.name.split(".")[0]] = node.lineno
|
|
123
|
+
elif isinstance(node, ast.ImportFrom):
|
|
124
|
+
import_lines.add(node.lineno)
|
|
125
|
+
for alias in node.names:
|
|
126
|
+
if alias.name != "*":
|
|
127
|
+
bound[alias.asname or alias.name] = node.lineno
|
|
128
|
+
|
|
129
|
+
if not bound:
|
|
130
|
+
return []
|
|
131
|
+
|
|
132
|
+
# name -> line numbers of every load-context usage outside import stmts
|
|
133
|
+
usages: dict[str, list[int]] = {}
|
|
134
|
+
for node in ast.walk(tree):
|
|
135
|
+
if isinstance(node, ast.Name) and isinstance(node.ctx, ast.Load) \
|
|
136
|
+
and node.lineno not in import_lines:
|
|
137
|
+
usages.setdefault(node.id, []).append(node.lineno)
|
|
138
|
+
elif isinstance(node, ast.Attribute):
|
|
139
|
+
root = node
|
|
140
|
+
while isinstance(root, ast.Attribute):
|
|
141
|
+
root = root.value
|
|
142
|
+
if isinstance(root, ast.Name) and root.lineno not in import_lines:
|
|
143
|
+
usages.setdefault(root.id, []).append(root.lineno)
|
|
144
|
+
|
|
145
|
+
orphaned = []
|
|
146
|
+
for name, imp_line in sorted(bound.items(), key=lambda kv: kv[1]):
|
|
147
|
+
lines = usages.get(name, [])
|
|
148
|
+
inside = [ln for ln in lines if start <= ln <= end]
|
|
149
|
+
outside = [ln for ln in lines if not (start <= ln <= end)]
|
|
150
|
+
if inside and not outside:
|
|
151
|
+
orphaned.append({"name": name, "import_line": imp_line,
|
|
152
|
+
"note": "only used by the symbol being removed"})
|
|
153
|
+
return orphaned
|
|
154
|
+
|
|
155
|
+
|
|
156
|
+
def _all_entry_line(tree: ast.Module, name: str) -> Optional[int]:
|
|
157
|
+
for stmt in tree.body:
|
|
158
|
+
if isinstance(stmt, ast.Assign):
|
|
159
|
+
for t in stmt.targets:
|
|
160
|
+
if isinstance(t, ast.Name) and t.id == "__all__":
|
|
161
|
+
if isinstance(stmt.value, (ast.List, ast.Tuple)):
|
|
162
|
+
for e in stmt.value.elts:
|
|
163
|
+
if isinstance(e, ast.Constant) and e.value == name:
|
|
164
|
+
return stmt.lineno
|
|
165
|
+
return None
|