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
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
@@ -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
@@ -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