#!/usr/bin/env python3 # file: scripts/audit_rust_export_completeness.py # version: 1 """Audit crate-root façade completeness and canonical same-crate paths.""" from __future__ import annotations import argparse import dataclasses import pathlib import re import sys from _rust_audit_common import line_depths, mask_source _IGNORED_DIRECTORY_NAMES = frozenset({ ".git", ".venv", "__pycache__", "build", "builds", "dist", "node_modules", "target", }) @dataclasses.dataclass(frozen=True) class Candidate: """One façade or same-crate path violation 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 kind: str path: pathlib.Path line: int def _is_ignored(path: pathlib.Path, root: pathlib.Path) -> bool: """Return whether a path belongs to a generated or local-only tree.""" try: relative = path.relative_to(root) except ValueError: return True return bool(set(relative.parts) & _IGNORED_DIRECTORY_NAMES) def crate_roots(root: pathlib.Path) -> list[pathlib.Path]: """Discover Rust package roots recursively below one project root.""" crates: set[pathlib.Path] = set() for manifest in root.rglob("Cargo.toml"): if _is_ignored(manifest, root): continue crate = manifest.parent if (crate / "src/lib.rs").is_file() or (crate / "src/main.rs").is_file(): crates.add(crate) return sorted(crates) def crate_root_source(crate: pathlib.Path) -> pathlib.Path | None: """Return the library or binary crate-root source when present.""" library = crate / "src/lib.rs" if library.is_file(): return library binary = crate / "src/main.rs" if binary.is_file(): return binary return None def module_path(crate: pathlib.Path, path: pathlib.Path) -> str: """Return the file-based module path represented by one Rust source file.""" relative = path.relative_to(crate / "src").with_suffix("") parts = list(relative.parts) if parts and parts[-1] == "mod": parts.pop() return "::".join(parts) def declaration_candidates(crate: pathlib.Path, path: pathlib.Path) -> list[Declaration]: """Return root-level public or crate-public declarations from one module file.""" text = path.read_text(encoding="utf-8") lines = text.splitlines() masked, _ = mask_source(text) depths = line_depths(masked) pattern = re.compile( r"^\s*(pub(?:\(crate\))?)\s+" r"(?:(?:async|unsafe|const)\s+)*" r"(const|static|type|struct|enum|trait|union|fn)\s+" r"([A-Za-z_][A-Za-z0-9_]*)" ) found: list[Declaration] = [] for line_number, line in enumerate(lines, 1): if line_number - 1 >= len(depths) or depths[line_number - 1] != 0: continue match = pattern.match(line) if match is None: continue found.append( Declaration( module=module_path(crate, path), name=match.group(3), visibility=match.group(1), kind=match.group(2), path=path, line=line_number, ) ) return found def _parse_export_item(module: str, item: str) -> tuple[tuple[str, str], str] | None: """Parse one simple explicit item from a crate-root re-export.""" stripped = item.strip() if not stripped or stripped == "*" or "{" in stripped or "}" in stripped: return None alias_match = re.fullmatch( r"([A-Za-z_][A-Za-z0-9_]*)(?:\s+as\s+([A-Za-z_][A-Za-z0-9_]*))?", stripped, ) if alias_match is None: return None name = alias_match.group(1) alias = alias_match.group(2) or name return (module, name), alias def root_exports(crate: pathlib.Path) -> dict[tuple[str, str], str]: """Return explicit crate-root re-exports keyed by source module and symbol.""" crate_root = crate_root_source(crate) exports: dict[tuple[str, str], str] = {} if crate_root is None: return exports text = crate_root.read_text(encoding="utf-8") pattern = re.compile( r"^\s*pub(?:\(crate\))?\s+use\s+(?:self|crate)::(.*?);", re.MULTILINE | re.DOTALL, ) for match in pattern.finditer(text): body = " ".join(part.strip() for part in match.group(1).splitlines()) if "{" in body and body.endswith("}"): prefix, group = body.split("{", 1) module = prefix.rstrip().rstrip(":") for item in group[:-1].split(","): parsed = _parse_export_item(module, item) if parsed is not None: key, alias = parsed exports[key] = alias continue if "::" not in body: continue module, item = body.rsplit("::", 1) parsed = _parse_export_item(module, item) if parsed is not None: key, alias = parsed exports[key] = alias return exports def externally_used(crate: pathlib.Path, declaration: Declaration) -> bool: """Return whether a crate-public item is clearly used outside its module file.""" direct = f"crate::{declaration.module}::{declaration.name}" root_direct = f"crate::{declaration.name}" for path in sorted(crate.rglob("*.rs")): if _is_ignored(path, crate) or path.resolve() == declaration.path.resolve(): continue masked, _ = mask_source(path.read_text(encoding="utf-8")) if direct in masked or root_direct in masked: return True return False def audit_crate(workspace: pathlib.Path, crate: pathlib.Path) -> list[Candidate]: """Audit one Rust package for the selected strict façade contract.""" crate_root = crate_root_source(crate) if crate_root is None: return [] exports = root_exports(crate) candidates: list[Candidate] = [] for path in sorted((crate / "src").rglob("*.rs")): if path == crate_root or _is_ignored(path, crate): continue for declaration in declaration_candidates(crate, path): required = declaration.visibility == "pub" or externally_used(crate, declaration) if required and (declaration.module, declaration.name) not in exports: reason = "public item" if declaration.visibility == "pub" else "crate-public item used outside its module" candidates.append( Candidate( code="RUST-API-201", path=path.relative_to(workspace).as_posix(), line=declaration.line, message=f"{reason} `{declaration.name}` requires an explicit 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.rglob("*.rs")): if path == crate_root or _is_ignored(path, crate): continue relative = path.relative_to(workspace).as_posix() masked, _ = mask_source(path.read_text(encoding="utf-8")) for line_number, line in enumerate(masked.splitlines(), 1): for match in long_path.finditer(line): alias = exports.get((match.group(1), match.group(2))) if alias is None: continue candidates.append( Candidate( code="RUST-IMPORT-201", path=relative, line=line_number, message=( f"long same-crate path `crate::{match.group(1)}::{match.group(2)}` " f"must use crate-root `crate::{alias}`" ), ) ) return candidates def audit_workspace(root: pathlib.Path) -> tuple[list[pathlib.Path], list[Candidate]]: """Return discovered crates and all façade candidates below one project root.""" crates = crate_roots(root) candidates = [candidate for crate in crates for candidate in audit_crate(root, crate)] candidates.sort(key=lambda candidate: (candidate.code, candidate.path, candidate.line, candidate.message)) return crates, 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() if not workspace.is_dir(): sys.stderr.write(f"Rust export completeness audit: invalid root: {workspace}\n") return 2 try: crates, candidates = audit_workspace(workspace) except (OSError, UnicodeError) as error: sys.stderr.write(f"Rust export completeness audit: unable to read selected workspace: {error}\n") return 2 if not crates: sys.stderr.write("Rust export completeness audit: no Rust crate root discovered\n") return 2 counts: dict[str, int] = {} for candidate in candidates: counts[candidate.code] = counts.get(candidate.code, 0) + 1 sys.stdout.write(f"Rust export completeness audit: {len(candidates)} candidate(s) across {len(crates)} crate(s)\n") for code in sorted(counts): sys.stdout.write(f"{code}: {counts[code]}\n") if not arguments.summary_only: for candidate in candidates: sys.stdout.write(f"{candidate.code} {candidate.path}:{candidate.line}: {candidate.message}\n") return 0 if arguments.report_only or not candidates else 1 if __name__ == "__main__": raise SystemExit(main())