#!/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 kind: 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(3), match.group(1), match.group(2), path, idx)) return found def private_declaration_candidates(crate: pathlib.Path, path: pathlib.Path) -> list[Declaration]: """Return module-level strictly private 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*(?:(?: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 if line.lstrip().startswith(("pub ", "pub(crate) ")): continue match = pattern.match(line) if match is not None: found.append(Declaration(module_path(crate, path), match.group(2), "private", match.group(1), path, idx)) return found def unqualified_test_reference_pattern(declaration: Declaration) -> re.Pattern[str]: """Return a conservative pattern for one unqualified parent-item reference in a separated unit test.""" name = re.escape(declaration.name) if declaration.kind == "fn": return re.compile(rf"(? 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 crate_root_symbols(crate: pathlib.Path) -> set[str]: """Return names that can resolve directly after `crate::`.""" crate_root = crate / "src/lib.rs" if not crate_root.is_file(): crate_root = crate / "src/main.rs" symbols: set[str] = set() if crate_root.is_file(): text = crate_root.read_text(encoding="utf-8") for match in re.finditer(r"^\s*mod\s+([A-Za-z_][A-Za-z0-9_]*)\s*(?:;|\{)", text, re.MULTILINE): symbols.add(match.group(1)) for match in re.finditer(r"^\s*(?:(?:pub|pub\(crate\))\s+)?(?:(?:async|const|unsafe)\s+)*(?:const|static|type|struct|enum|trait|union|fn)\s+([A-Za-z_][A-Za-z0-9_]*)", text, re.MULTILINE): symbols.add(match.group(1)) for match in re.finditer(r"^\s*pub(?:\(crate\))?\s+use\s+[^;]*::([A-Za-z_][A-Za-z0-9_]*)\s*;", text, re.MULTILINE): symbols.add(match.group(1)) for match in re.finditer(r"^\s*pub\s+extern\s+crate\s+([A-Za-z_][A-Za-z0-9_]*)\s*;", text, re.MULTILINE): symbols.add(match.group(1)) for path in sorted((crate / "src").rglob("*.rs")): text = path.read_text(encoding="utf-8") pattern = re.compile(r"#\[macro_export\]\s*\n\s*macro_rules!\s+([A-Za-z_][A-Za-z0-9_]*)") for match in pattern.finditer(text): symbols.add(match.group(1)) return symbols 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] = {} private_declarations_by_source: dict[pathlib.Path, dict[str, Declaration]] = {} candidates: list[Candidate] = [] for path in sorted((crate / "src").rglob("*.rs")): if path == crate_root: continue private_declarations_by_source[path.resolve()] = {item.name: item for item in private_declaration_candidates(crate, path)} 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}`")) # Simple `crate::Item` references must resolve at the crate root. root_symbols = crate_root_symbols(crate) simple_root = re.compile(r"\bcrate::([A-Za-z_][A-Za-z0-9_]*)\b(?!::)") for path in sorted(crate.rglob("*.rs")): if "target" in path.parts: continue relative = path.relative_to(workspace).as_posix() text = path.read_text(encoding="utf-8") masked = mask_rust_source(text) for idx, line in enumerate(masked.splitlines(), 1): for match in simple_root.finditer(line): symbol = match.group(1) if symbol not in root_symbols: candidates.append(Candidate("RUST-IMPORT-203", relative, idx, f"`crate::{symbol}` does not resolve to a declared/re-exported crate-root symbol")) # Separated unit tests use `super::Item` only for strictly private parent items; visible items use the crate-root façade. 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, {}) parent_private_declarations = private_declarations_by_source.get(parent, {}) relative = test.relative_to(workspace).as_posix() text = test.read_text(encoding="utf-8") masked_lines = mask_rust_source(text).splitlines() for idx, line in enumerate(masked_lines, 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}`")) for declaration in parent_private_declarations.values(): if unqualified_test_reference_pattern(declaration).search(line) is not None: candidates.append(Candidate("RUST-IMPORT-204", relative, idx, f"strictly private parent item `{declaration.name}` must be accessed as `super::{declaration.name}` in separated unit tests")) for declaration in parent_declarations.values(): if unqualified_test_reference_pattern(declaration).search(line) is not None: candidates.append(Candidate("RUST-IMPORT-205", relative, idx, f"{declaration.visibility} parent item `{declaration.name}` must be accessed through crate-root `crate::{declaration.name}` in separated unit tests")) 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())