#!/usr/bin/env python3 """Report Solidity contracts that are not reachable from current tests or scripts.""" from __future__ import annotations import argparse import json import re import sys from collections import Counter from pathlib import Path EXCLUDED_PARTS = {"artifacts", "broadcast", "cache", "lib", "node_modules", "out"} IMPORT_RE = re.compile(r'^\s*import\s+(?:\{[^}]+\}\s+from\s+)?["\']([^"\']+)["\'];', re.M) def discover_sources(repo_root: Path) -> dict[str, Path]: return { path.relative_to(repo_root).as_posix(): path for path in repo_root.rglob("*.sol") if path.is_file() and not any(part in EXCLUDED_PARTS for part in path.parts) } def resolve_import(importer: Path, import_path: str, repo_root: Path) -> str | None: if import_path.startswith(("./", "../")): target = (importer.parent / import_path).resolve() try: return target.relative_to(repo_root).as_posix() except ValueError: return None if import_path.startswith("@emoney/"): return f"contracts/emoney/{import_path[len('@emoney/'):]}" return None def build_graph(repo_root: Path, files: dict[str, Path]) -> tuple[dict[str, list[str]], dict[str, set[str]]]: imports: dict[str, list[str]] = {} inbound: dict[str, set[str]] = {rel: set() for rel in files} for rel_path, file_path in files.items(): text = file_path.read_text(errors="ignore") resolved_targets: list[str] = [] for import_path in IMPORT_RE.findall(text): target = resolve_import(file_path, import_path, repo_root) if target and target in files: resolved_targets.append(target) inbound[target].add(rel_path) imports[rel_path] = resolved_targets return imports, inbound def walk_reachable(imports: dict[str, list[str]], roots: list[str]) -> set[str]: seen: set[str] = set() stack = list(roots) while stack: current = stack.pop() if current in seen: continue seen.add(current) stack.extend(imports.get(current, [])) return seen def create_report(repo_root: Path) -> dict[str, object]: files = discover_sources(repo_root) imports, inbound = build_graph(repo_root, files) contract_files = sorted(rel for rel in files if rel.startswith("contracts/")) root_files = sorted(rel for rel in files if rel.startswith(("test/", "script/"))) reachable = walk_reachable(imports, root_files) unreachable = [rel for rel in contract_files if rel not in reachable] no_inbound = [rel for rel in contract_files if not inbound[rel]] unreachable_by_bucket = Counter(path.split("/")[1] for path in unreachable) no_inbound_by_bucket = Counter(path.split("/")[1] for path in no_inbound) return { "repoRoot": repo_root.as_posix(), "summary": { "contractsTotal": len(contract_files), "rootsTotal": len(root_files), "contractsReachableFromTestsOrScripts": len(contract_files) - len(unreachable), "contractsUnreachableFromTestsOrScripts": len(unreachable), "contractsWithNoLocalInboundRefs": len(no_inbound), }, "unreachableByBucket": dict(unreachable_by_bucket.most_common()), "noInboundByBucket": dict(no_inbound_by_bucket.most_common()), "unreachableContracts": unreachable, "contractsWithNoLocalInboundRefs": no_inbound, } def print_text(report: dict[str, object]) -> None: summary = report["summary"] print(f"repo root: {report['repoRoot']}") print(f"contracts total: {summary['contractsTotal']}") print(f"tests/scripts roots total: {summary['rootsTotal']}") print(f"contracts reachable from tests or scripts: {summary['contractsReachableFromTestsOrScripts']}") print(f"contracts unreachable from tests or scripts: {summary['contractsUnreachableFromTestsOrScripts']}") print(f"contracts with no local inbound refs: {summary['contractsWithNoLocalInboundRefs']}") print() print("Unreachable by top-level bucket:") for bucket, count in report["unreachableByBucket"].items(): print(f" {bucket}: {count}") print() print("Archive candidates (unreachable from current tests/scripts):") for contract in report["unreachableContracts"]: print(f" {contract}") print() print("Note: unreachable does not prove safe deletion; it only means this repo's current Solidity tests/scripts do not import the file.") def parse_args(argv: list[str]) -> argparse.Namespace: parser = argparse.ArgumentParser(description=__doc__) parser.add_argument("--json", action="store_true", help="emit machine-readable JSON") return parser.parse_args(argv) def main(argv: list[str]) -> int: args = parse_args(argv) repo_root = Path(__file__).resolve().parents[2] report = create_report(repo_root) if args.json: json.dump(report, sys.stdout, indent=2) sys.stdout.write("\n") return 0 print_text(report) return 0 if __name__ == "__main__": raise SystemExit(main(sys.argv[1:]))