#!/usr/bin/env python3 # file: scripts/audit_rust_export_completeness.py # version: 2 """Report missing crate-root exports and replaceable long internal paths. The audit distinguishes declarations from their crate-root aliases. Crate-root files are excluded from long-path findings because re-export declarations must name their source module. Findings remain advisory until the affected crate is compiled. """ from __future__ import annotations import argparse import dataclasses import pathlib import re import sys @dataclasses.dataclass(frozen=True) class Candidate: """One export-completeness candidate.""" code: str path: str line: int message: str @dataclasses.dataclass(frozen=True) class Declaration: """One module-level public or crate-public declaration.""" module: str name: str visibility: str path: pathlib.Path line: int def crate_roots(root: pathlib.Path) -> list[pathlib.Path]: """Return workspace crate directories that contain a Rust crate root.""" crates: list[pathlib.Path] = [] for manifest in root.glob("*/Cargo.toml"): crate = manifest.parent if (crate / "src/lib.rs").is_file() or (crate / "src/main.rs").is_file(): crates.append(crate) return sorted(crates) def module_path(crate: pathlib.Path, path: pathlib.Path) -> str: """Return the Rust module path represented by one source file.""" relative = path.relative_to(crate / "src").with_suffix("") return "::".join(relative.parts) def declaration_candidates(crate: pathlib.Path, path: pathlib.Path) -> list[Declaration]: """Return module-level public declarations from one Rust module.""" text = path.read_text(encoding="utf-8") depth = 0 found: list[Declaration] = [] pattern = re.compile( r"^\s*(pub(?:\(crate\))?)\s+(?:(?:async|unsafe|const)\s+)*(?:const|static|type|struct|enum|trait|union|fn)\s+([A-Za-z_][A-Za-z0-9_]*)" ) for number, line in enumerate(text.splitlines(), 1): if depth == 0: match = pattern.match(line) if match is not None: found.append( Declaration( module_path(crate, path), match.group(2), match.group(1), path, number, ) ) depth += line.count("{") - line.count("}") if depth < 0: depth = 0 return found def root_exports(crate_root: pathlib.Path) -> dict[tuple[str, str], str]: """Return source module/name pairs and their crate-root aliases.""" exports: dict[tuple[str, str], str] = {} pattern = re.compile( r"^\s*pub(?:\(crate\))?\s+use\s+crate::([A-Za-z_][A-Za-z0-9_:]*)::([A-Za-z_][A-Za-z0-9_]*)(?:\s+as\s+([A-Za-z_][A-Za-z0-9_]*))?\s*;", re.MULTILINE, ) for match in pattern.finditer(crate_root.read_text(encoding="utf-8")): exports[(match.group(1), match.group(2))] = match.group(3) or match.group(2) return exports def audit_crate(workspace: pathlib.Path, crate: pathlib.Path) -> list[Candidate]: """Audit one crate and return advisory candidates.""" crate_root = crate / "src/lib.rs" if not crate_root.is_file(): crate_root = crate / "src/main.rs" exports = root_exports(crate_root) candidates: list[Candidate] = [] declarations: dict[tuple[str, str], Declaration] = {} for path in sorted((crate / "src").rglob("*.rs")): if path == crate_root: continue for declaration in declaration_candidates(crate, path): key = (declaration.module, declaration.name) declarations[key] = declaration if key not in exports: relative = path.relative_to(workspace).as_posix() candidates.append( Candidate( "EXPORT001", relative, declaration.line, f"{declaration.visibility} `{declaration.name}` has no crate-root re-export", ) ) long_path = re.compile( r"\bcrate::([A-Za-z_][A-Za-z0-9_:]*)::([A-Za-z_][A-Za-z0-9_]*)" ) for path in sorted((crate / "src").rglob("*.rs")): if path == crate_root: continue for number, line in enumerate(path.read_text(encoding="utf-8").splitlines(), 1): for match in long_path.finditer(line): key = (match.group(1), match.group(2)) alias = exports.get(key) if alias is None: continue relative = path.relative_to(workspace).as_posix() candidates.append( Candidate( "EXPORT002", relative, number, f"long internal path `crate::{match.group(1)}::{match.group(2)}` must use `crate::{alias}`", ) ) return candidates def main() -> int: """Run the advisory export-completeness audit.""" parser = argparse.ArgumentParser() parser.add_argument("--root", default=".") parser.add_argument("--summary-only", action="store_true") arguments = parser.parse_args() workspace = pathlib.Path(arguments.root).resolve() candidates = [item for crate in crate_roots(workspace) for item in audit_crate(workspace, crate)] candidates.sort(key=lambda item: (item.code, item.path, item.line, item.message)) counts: dict[str, int] = {} for item in candidates: counts[item.code] = counts.get(item.code, 0) + 1 sys.stdout.write(f"Rust export completeness audit: {len(candidates)} candidate(s)\n") for code in sorted(counts): sys.stdout.write(f"{code}: {counts[code]}\n") if not arguments.summary_only: for item in candidates: sys.stdout.write(f"{item.code} {item.path}:{item.line}: {item.message}\n") return 0 if __name__ == "__main__": raise SystemExit(main())