#!/usr/bin/env python3 # file: scripts/audit_rust_export_completeness.py # version: 1 """Audit crate-root export completeness and canonical same-crate paths.""" from __future__ import annotations import argparse import dataclasses import pathlib import re import sys from audit_rust_general_rules import line_depths, mask_rust_source @dataclasses.dataclass(frozen=True) class Candidate: """One export/path normalization 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 Rust workspace crate directories under crates/.""" crates: list[pathlib.Path] = [] for manifest in (root / "crates").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 a 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 source module.""" text = path.read_text(encoding="utf-8") lines = text.splitlines() depths = line_depths(mask_rust_source(text)) 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 idx, line in enumerate(lines, 1): if depths[idx - 1] != 0: continue match = pattern.match(line) if match is not None: found.append(Declaration(module_path(crate, path), match.group(2), match.group(1), path, idx)) return found 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 / "src/lib.rs" if not crate_root.is_file(): crate_root = crate / "src/main.rs" exports: dict[tuple[str, str], str] = {} if not crate_root.is_file(): return exports pattern = re.compile( r"^pub(?:\(crate\))?\s+use\s+self::([A-Za-z_][A-Za-z0-9_:]*)::([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(2) return exports def unit_test_parents(crate: pathlib.Path) -> dict[pathlib.Path, pathlib.Path]: """Map separated unit-test files to the production module that owns them.""" mapping: dict[pathlib.Path, pathlib.Path] = {} pattern = re.compile(r'#\[path\s*=\s*"\.\./unit_tests/([^\"]+)"\]') for source in sorted((crate / "src").rglob("*.rs")): for match in pattern.finditer(source.read_text(encoding="utf-8")): test = crate / "unit_tests" / match.group(1) if test.is_file(): mapping[test.resolve()] = source.resolve() return mapping def external_usage(crate: pathlib.Path, declaration: Declaration, test_parents: dict[pathlib.Path, pathlib.Path]) -> bool: """Return whether a crate-public item is referenced outside its declaration module.""" direct = f"crate::{declaration.module}::{declaration.name}" root_direct = f"crate::{declaration.name}" super_ref = f"super::{declaration.name}" for path in sorted(crate.rglob("*.rs")): if "target" in path.parts: continue resolved = path.resolve() if resolved == declaration.path.resolve(): continue text = path.read_text(encoding="utf-8") if direct in text or root_direct in text: return True if test_parents.get(resolved) == declaration.path.resolve() and super_ref in text: return True return False def audit_crate(workspace: pathlib.Path, crate: pathlib.Path) -> list[Candidate]: """Audit one crate for export completeness and canonical paths.""" crate_root = crate / "src/lib.rs" if not crate_root.is_file(): crate_root = crate / "src/main.rs" exports = root_exports(crate) tests = unit_test_parents(crate) declarations: dict[tuple[str, str], Declaration] = {} candidates: list[Candidate] = [] 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 required = declaration.visibility == "pub" or external_usage(crate, declaration, tests) if required and key not in exports: relative = path.relative_to(workspace).as_posix() reason = "public item" if declaration.visibility == "pub" else "crate-public item used outside its module" candidates.append(Candidate("RUST-API-201", relative, declaration.line, f"{reason} `{declaration.name}` requires a crate-root re-export")) # Canonical crate::Item paths for exported items. 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 "target" in path.parts or path == crate_root: continue relative = path.relative_to(workspace).as_posix() for idx, line in enumerate(path.read_text(encoding="utf-8").splitlines(), 1): for match in long_path.finditer(line): alias = exports.get((match.group(1), match.group(2))) if alias is not None: candidates.append(Candidate("RUST-IMPORT-201", relative, idx, f"long internal path `crate::{match.group(1)}::{match.group(2)}` must use `crate::{alias}`")) # `super::Item` is reserved to strictly private parent items in separated unit tests. declarations_by_source: dict[pathlib.Path, dict[str, Declaration]] = {} for declaration in declarations.values(): declarations_by_source.setdefault(declaration.path.resolve(), {})[declaration.name] = declaration super_pattern = re.compile(r"\bsuper::([A-Za-z_][A-Za-z0-9_]*)") for test, parent in tests.items(): parent_declarations = declarations_by_source.get(parent, {}) relative = test.relative_to(workspace).as_posix() for idx, line in enumerate(test.read_text(encoding="utf-8").splitlines(), 1): for match in super_pattern.finditer(line): declaration = parent_declarations.get(match.group(1)) if declaration is not None and declaration.visibility in {"pub", "pub(crate)"}: candidates.append(Candidate("RUST-IMPORT-202", relative, idx, f"`super::{declaration.name}` targets {declaration.visibility}; use crate-root `crate::{declaration.name}`")) 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())