#!/usr/bin/env python3 # file: scripts/audit_rust_export_completeness.py # version: 2 """Audit Rust crate-root façade completeness and canonical same-crate references.""" from __future__ import annotations import argparse import dataclasses import pathlib import re import sys import tomllib 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 canonical-reference 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 column: int @dataclasses.dataclass(frozen=True) class Target: """One Rust crate target and the module files reachable from its root.""" package: pathlib.Path root: pathlib.Path modules: tuple[tuple[pathlib.Path, str], ...] @dataclasses.dataclass(frozen=True) class CanonicalItem: """One visible item with its canonical crate-root name.""" declaration: Declaration canonical_name: str 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() or _explicit_target_paths(crate): crates.add(crate) return sorted(crates) def _explicit_target_paths(crate: pathlib.Path) -> tuple[pathlib.Path, ...]: """Return existing explicit lib/bin target paths declared in Cargo.toml.""" manifest = crate / "Cargo.toml" if not manifest.is_file(): return () try: data = tomllib.loads(manifest.read_text(encoding="utf-8")) except (OSError, tomllib.TOMLDecodeError): return () paths: set[pathlib.Path] = set() lib = data.get("lib") if isinstance(lib, dict) and isinstance(lib.get("path"), str): candidate = crate / lib["path"] if candidate.is_file(): paths.add(candidate) bins = data.get("bin", []) if isinstance(bins, list): for entry in bins: if isinstance(entry, dict) and isinstance(entry.get("path"), str): candidate = crate / entry["path"] if candidate.is_file(): paths.add(candidate) return tuple(sorted(paths)) def target_roots(crate: pathlib.Path) -> list[pathlib.Path]: """Return standard and explicitly configured Rust crate target roots.""" roots: set[pathlib.Path] = set(_explicit_target_paths(crate)) manifest = crate / "Cargo.toml" package: dict[str, object] = {} if manifest.is_file(): try: parsed = tomllib.loads(manifest.read_text(encoding="utf-8")) raw_package = parsed.get("package", {}) if isinstance(raw_package, dict): package = raw_package except (OSError, tomllib.TOMLDecodeError): package = {} library = crate / "src/lib.rs" if library.is_file(): roots.add(library) if package.get("autobins", True) is not False: main = crate / "src/main.rs" if main.is_file(): roots.add(main) bin_dir = crate / "src/bin" if bin_dir.is_dir(): roots.update(path for path in bin_dir.glob("*.rs") if path.is_file()) roots.update(path for path in bin_dir.glob("*/main.rs") if path.is_file()) return sorted(roots) def _standard_child_module_path(current: pathlib.Path, target_root: pathlib.Path, name: str) -> pathlib.Path | None: """Resolve a standard `mod name;` file relative to one source module.""" if current == target_root or current.name == "mod.rs": base = current.parent else: base = current.parent / current.stem direct = base / f"{name}.rs" nested = base / name / "mod.rs" if direct.is_file(): return direct if nested.is_file(): return nested return None def _module_children(path: pathlib.Path, target_root: pathlib.Path) -> list[tuple[pathlib.Path, str]]: """Return top-level external module declarations from one Rust source file.""" text = path.read_text(encoding="utf-8") lines = text.splitlines() masked, _ = mask_source(text) masked_lines = masked.splitlines() depths = line_depths(masked) module_pattern = re.compile(r"^\s*(?:(?:pub|pub\([^)]*\))\s+)?mod\s+([A-Za-z_][A-Za-z0-9_]*)\s*;") path_pattern = re.compile(r'^\s*#\[path\s*=\s*"([^"]+)"\]\s*$') found: list[tuple[pathlib.Path, str]] = [] for index, masked_line in enumerate(masked_lines): if index >= len(depths) or depths[index] != 0: continue match = module_pattern.match(masked_line) if match is None: continue module_name = match.group(1) explicit: pathlib.Path | None = None probe = index - 1 while probe >= 0 and lines[probe].lstrip().startswith("#["): path_match = path_pattern.match(lines[probe]) if path_match is not None: candidate = (path.parent / path_match.group(1)).resolve() if candidate.is_file(): explicit = candidate break probe -= 1 child = explicit or _standard_child_module_path(path, target_root, module_name) if child is not None: found.append((child.resolve(), module_name)) return found def target_modules(crate: pathlib.Path, target_root: pathlib.Path) -> dict[pathlib.Path, str]: """Return module files reachable from one target root and their logical paths.""" root = target_root.resolve() modules: dict[pathlib.Path, str] = {root: ""} pending: list[pathlib.Path] = [root] while pending: current = pending.pop() parent_module = modules[current] for child, name in _module_children(current, root): if _is_ignored(child, crate) or child in modules: continue module = f"{parent_module}::{name}" if parent_module else name modules[child] = module pending.append(child) return modules def targets(crate: pathlib.Path) -> list[Target]: """Return independently compiled crate targets for one Cargo package.""" found: list[Target] = [] for root in target_roots(crate): modules = target_modules(crate, root) found.append(Target(crate, root.resolve(), tuple(sorted(modules.items(), key=lambda item: item[0].as_posix())))) return found def declaration_candidates(path: pathlib.Path, module: str) -> list[Declaration]: """Return top-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, name=match.group(3), visibility=match.group(1), kind=match.group(2), path=path, line=line_number, column=match.start(3), ) ) 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(root_source: pathlib.Path) -> dict[tuple[str, str], str]: """Return explicit crate-root re-exports keyed by source module and symbol.""" exports: dict[tuple[str, str], str] = {} text = root_source.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 _reexport_lines(root_source: pathlib.Path) -> set[int]: """Return crate-root line numbers occupied by explicit re-export statements.""" text = root_source.read_text(encoding="utf-8") pattern = re.compile( r"^\s*pub(?:\(crate\))?\s+use\s+(?:self|crate)::.*?;", re.MULTILINE | re.DOTALL, ) lines: set[int] = set() for match in pattern.finditer(text): start = text.count("\n", 0, match.start()) + 1 end = text.count("\n", 0, match.end()) + 1 lines.update(range(start, end + 1)) return lines def _target_declarations(target: Target) -> list[Declaration]: """Return visible module-level declarations for one crate target.""" return [ declaration for path, module in target.modules for declaration in declaration_candidates(path, module) ] def _external_usage(target: Target, declaration: Declaration) -> bool: """Return whether a crate-public item is clearly referenced from another module file.""" if not declaration.module: return True direct = f"crate::{declaration.module}::{declaration.name}" root_direct = f"crate::{declaration.name}" super_direct = f"super::{declaration.name}" for path, _ in target.modules: if path.resolve() == declaration.path.resolve(): continue masked, _ = mask_source(path.read_text(encoding="utf-8")) if direct in masked or root_direct in masked or super_direct in masked: return True return False def _declaration_lookup(declarations: list[Declaration]) -> dict[tuple[pathlib.Path, int, str], Declaration]: """Index declaration-name occurrences that must not be treated as references.""" return {(item.path.resolve(), item.line, item.name): item for item in declarations} def _canonical_reference_candidates( workspace: pathlib.Path, target: Target, declarations: list[Declaration], exports: dict[tuple[str, str], str], ) -> list[Candidate]: """Return non-canonical references to items that have a crate-root identity.""" items: list[CanonicalItem] = [] for declaration in declarations: if declaration.path.resolve() == target.root and not declaration.module: items.append(CanonicalItem(declaration, declaration.name)) continue alias = exports.get((declaration.module, declaration.name)) if alias is not None: items.append(CanonicalItem(declaration, alias)) by_token: dict[str, list[CanonicalItem]] = {} for item in items: by_token.setdefault(item.declaration.name, []).append(item) if item.canonical_name != item.declaration.name: by_token.setdefault(item.canonical_name, []).append(item) if not by_token: return [] token_pattern = re.compile(r"\b(?:" + "|".join(re.escape(name) for name in sorted(by_token, key=len, reverse=True)) + r")\b") declaration_lookup = _declaration_lookup(declarations) root_reexport_lines = _reexport_lines(target.root) internal_roots = {segment for _, module in target.modules if module for segment in module.split("::")} candidates: list[Candidate] = [] for path, _ in target.modules: 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 token_pattern.finditer(line): token = match.group(0) for item in by_token[token]: declaration = item.declaration if declaration.kind == "fn" and re.match(r"\s*(?:\(|::<)", line[match.end():]) is None: continue prefix = line[:match.start()] if re.search(r"(?:const|static|type|struct|enum|trait|union|fn)\s+$", prefix) is not None: continue if (path.resolve(), line_number, token) in declaration_lookup and token == declaration.name: continue if path.resolve() == target.root and line_number in root_reexport_lines: continue if token == item.canonical_name and re.search(r"\bcrate::\s*$", prefix) is not None: continue if match.start() > 0 and line[match.start() - 1] == ".": continue qualified = re.search( r"([A-Za-z_][A-Za-z0-9_]*(?:::[A-Za-z_][A-Za-z0-9_]*)*)::\s*$", prefix, ) if qualified is not None: first = qualified.group(1).split("::", 1)[0] if first not in internal_roots and first not in {"crate", "self", "super"}: continue candidates.append( Candidate( code="RUST-IMPORT-201", path=relative, line=line_number, message=( f"non-canonical same-crate reference `{token}` for shared " f"{declaration.visibility} `{declaration.name}`; use `crate::{item.canonical_name}`" ), ) ) return candidates def audit_target(workspace: pathlib.Path, target: Target) -> list[Candidate]: """Audit one independently compiled Rust crate target.""" declarations = _target_declarations(target) exports = root_exports(target.root) candidates: list[Candidate] = [] for declaration in declarations: if declaration.path.resolve() == target.root and not declaration.module: continue required = declaration.visibility == "pub" or _external_usage(target, 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=declaration.path.relative_to(workspace).as_posix(), line=declaration.line, message=f"{reason} `{declaration.name}` requires an explicit crate-root re-export", ) ) candidates.extend(_canonical_reference_candidates(workspace, target, declarations, exports)) return candidates def audit_crate(workspace: pathlib.Path, crate: pathlib.Path) -> list[Candidate]: """Audit every independently compiled target in one Cargo package.""" return [candidate for target in targets(crate) for candidate in audit_target(workspace, target)] def audit_workspace(root: pathlib.Path) -> tuple[list[pathlib.Path], list[Candidate]]: """Return discovered packages and all façade candidates below one project root.""" crates = crate_roots(root) candidates = list({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 crates, candidates = audit_workspace(workspace) if not crates: sys.stderr.write(f"Rust export completeness audit: no Rust package found below {workspace}\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)} package(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())