from __future__ import annotations
import argparse
import re
import subprocess
import sys
from pathlib import Path
from typing import Iterable, Set
REPO_ROOT = Path(__file__).resolve().parents[1]
CRATES_DIR = REPO_ROOT / "crates"
TARGET_DEBUG = REPO_ROOT / "target" / "debug"
ALLOWLIST_PATH = REPO_ROOT / "tools" / "mod_reachability_allowlist.txt"
def parse_dep_info(dep_content: str, crate_src_dir: Path) -> Set[Path]:
src_prefix = str(crate_src_dir).replace("\\", "/").rstrip("/") + "/"
joined = dep_content.replace("\\\n", " ") files: Set[Path] = set()
for line in joined.splitlines():
if ":" not in line:
continue
targets = line.split(":", 1)[1] for tok in targets.split():
tok = tok.strip().replace("\\", "/")
if not tok.endswith(".rs"):
continue
if tok.startswith(src_prefix):
files.add(Path(tok))
return files
def diff_orphans(on_disk: Iterable[Path], compiled: Iterable[Path]) -> Set[Path]:
compiled_strs = {str(p) for p in compiled}
return {p for p in on_disk if str(p) not in compiled_strs}
def list_src_files(crate_src_dir: Path) -> Set[Path]:
return set(crate_src_dir.rglob("*.rs"))
def is_test_only_module(orphan: Path, crate_src_dir: Path) -> bool:
stem = orphan.stem mod_pattern = re.compile(rf"\b(?:pub\s+)?mod\s+{re.escape(stem)}\b")
for src_file in crate_src_dir.rglob("*.rs"):
try:
text = src_file.read_text(encoding="utf-8")
except (OSError, UnicodeDecodeError):
continue
lines = text.splitlines()
for i, line in enumerate(lines):
if not line.strip().startswith("#"):
continue
if "cfg(test)" not in line:
continue
j = i + 1
while j < len(lines):
stripped = lines[j].strip()
if stripped == "" or stripped.startswith("#"):
j += 1
continue
break
if j < len(lines) and mod_pattern.search(lines[j]):
return True
return False
def crate_dep_file(crate_name: str) -> Path:
suffix = crate_name.replace("-", "_")
matches = sorted(TARGET_DEBUG.glob(f"lib{suffix}*.d"), key=lambda p: p.stat().st_mtime)
return matches[-1] if matches else TARGET_DEBUG / f"lib{suffix}.d"
def orphan_key(crate: str, path: Path) -> str:
try:
rel = str(path.relative_to(REPO_ROOT))
except ValueError:
rel = str(path)
return f"{crate}: {rel}"
def read_allowlist(path: Path) -> Set[str]:
if not path.is_file():
return set()
keys: Set[str] = set()
for line in path.read_text(encoding="utf-8").splitlines():
line = line.strip()
if line and not line.startswith("#"):
keys.add(line)
return keys
def main() -> int:
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument("--crate", help="仅检查单个 crate(默认全 workspace)")
parser.add_argument(
"--allowlist",
default=str(ALLOWLIST_PATH),
help=f"allowlist 路径(默认 {ALLOWLIST_PATH.name})",
)
parser.add_argument(
"--update-allowlist",
action="store_true",
help="重生 allowlist 基线(写入当前全部孤儿,退出码 0)",
)
args = parser.parse_args()
crate_dirs = sorted(d for d in CRATES_DIR.iterdir() if (d / "src" / "lib.rs").is_file())
if args.crate:
crate_dirs = [d for d in crate_dirs if d.name == args.crate]
if not crate_dirs:
print(f"❌ 未找到 crate:{args.crate}", file=sys.stderr)
return 2
if args.crate:
cmd = ["cargo", "build", "--lib", "--all-features", "-p", args.crate]
else:
cmd = ["cargo", "build", "--workspace", "--all-features"]
proc = subprocess.run(cmd, cwd=REPO_ROOT, capture_output=True, text=True)
if proc.returncode != 0:
print(f"❌ 编译失败(先修编译错误):\n{proc.stderr}", file=sys.stderr)
return 1
all_orphans: list[tuple[str, Path]] = []
for crate_dir in crate_dirs:
crate_src = crate_dir / "src"
dep = crate_dep_file(crate_dir.name)
if not dep.is_file():
print(f"⚠️ 跳过 {crate_dir.name}:未找到 dep 文件 {dep.name}", file=sys.stderr)
continue
compiled = parse_dep_info(dep.read_text(encoding="utf-8"), crate_src)
on_disk = list_src_files(crate_src)
orphans = diff_orphans(on_disk, compiled)
for o in sorted(orphans):
if is_test_only_module(o, crate_src):
continue
all_orphans.append((crate_dir.name, o))
all_keys = {orphan_key(c, p) for c, p in all_orphans}
if args.update_allowlist:
allowlist_path = Path(args.allowlist)
lines = sorted(all_keys)
header = (
"# mod 可达性守卫 allowlist(存量孤儿基线)\n"
"# 由 `python3 tools/check_mod_reachability.py --update-allowlist` 生成。\n"
"# CI 只对【新增】孤儿失败;各 crate 修复死代码后从此文件删除对应行,\n"
"# 再跑 --update-allowlist 或直接编辑收敛。\n"
f"# 当前存量孤儿:{len(lines)} 个\n\n"
)
allowlist_path.write_text(header + "\n".join(lines) + ("\n" if lines else ""), encoding="utf-8")
print(f"✅ allowlist 已更新:{allowlist_path}({len(lines)} 个存量孤儿)")
return 0
known = read_allowlist(Path(args.allowlist))
new_orphans = [(c, p) for c, p in all_orphans if orphan_key(c, p) not in known]
stale_known = known - all_keys
if new_orphans:
print(f"❌ 发现 {len(new_orphans)} 个【新增】孤儿文件(不在 allowlist 内):\n")
for crate, path in new_orphans:
print(f" {orphan_key(crate, path)}")
print(
"\n修复:在对应 crate 的 lib.rs / 上级 mod.rs 声明该模块,或删除该文件。\n"
"若为已知存量,运行 `python3 tools/check_mod_reachability.py --update-allowlist` 更新基线。"
)
return 1
msg = f"✅ 无新增孤儿(存量 {len(all_orphans)} 个已在 allowlist,CI 放行)。"
if stale_known:
msg += f"\n💡 allowlist 有 {len(stale_known)} 条已修复,可清理后跑 --update-allowlist 收敛。"
print(msg)
return 0
if __name__ == "__main__":
raise SystemExit(main())