#!/usr/bin/env python3 # file: scripts/audit_rust_export_completeness.py # version: 4 """Audit missing crate-root exports and replaceable long internal paths. The audit resolves explicit transitive `self::` re-exports through private module façades. Crate-root files are excluded from long-path findings because re-export declarations must name their source module. """ 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 transitive_root_exports(crate: pathlib.Path) -> dict[tuple[str, str], str]: """Return declarations that reach the crate root through explicit re-exports.""" edges: dict[tuple[str, str], set[tuple[str, str]]] = {} pattern = re.compile( r"^\s*pub(?:\(crate\))?\s+use\s+self::([A-Za-z_][A-Za-z0-9_:]*)::([A-Za-z_][A-Za-z0-9_]*)\s*;", re.MULTILINE, ) for path in sorted((crate / "src").rglob("*.rs")): current = "" if path.name in {"lib.rs", "main.rs"} else module_path(crate, path) for match in pattern.finditer(path.read_text(encoding="utf-8")): source_module = "::".join(part for part in (current, match.group(1)) if part) source = (source_module, match.group(2)) destination = (current, match.group(2)) edges.setdefault(source, set()).add(destination) resolved: dict[tuple[str, str], str] = {} for source in edges: pending = [source] visited: set[tuple[str, str]] = set() while pending: candidate = pending.pop() if candidate in visited: continue visited.add(candidate) if candidate[0] == "": resolved[source] = candidate[1] break pending.extend(edges.get(candidate, set())) return resolved 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 = transitive_root_exports(crate) 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 export-completeness audit.""" parser = argparse.ArgumentParser() parser.add_argument("--root", default=".") parser.add_argument("--report-only", action="store_true") 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 arguments.report_only or not candidates else 1 if __name__ == "__main__": raise SystemExit(main())