Files
khadhroony-bot3/scripts/audit_rust_export_completeness.py
2026-08-01 21:27:14 +02:00

193 lines
6.8 KiB
Python
Executable File

#!/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())