0.1.0-pre.004
This commit is contained in:
174
scripts/audit_rust_export_completeness.py
Executable file
174
scripts/audit_rust_export_completeness.py
Executable file
@@ -0,0 +1,174 @@
|
||||
#!/usr/bin/env python3
|
||||
# file: scripts/audit_rust_export_completeness.py
|
||||
# version: 2
|
||||
|
||||
"""Report missing crate-root exports and replaceable long internal paths.
|
||||
|
||||
The audit distinguishes declarations from their crate-root aliases. Crate-root
|
||||
files are excluded from long-path findings because re-export declarations must
|
||||
name their source module. Findings remain advisory until the affected crate is
|
||||
compiled.
|
||||
"""
|
||||
|
||||
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 root_exports(crate_root: pathlib.Path) -> dict[tuple[str, str], str]:
|
||||
"""Return source module/name pairs and their crate-root aliases."""
|
||||
|
||||
exports: dict[tuple[str, str], str] = {}
|
||||
pattern = re.compile(
|
||||
r"^\s*pub(?:\(crate\))?\s+use\s+crate::([A-Za-z_][A-Za-z0-9_:]*)::([A-Za-z_][A-Za-z0-9_]*)(?:\s+as\s+([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(3) or match.group(2)
|
||||
return exports
|
||||
|
||||
|
||||
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 = root_exports(crate_root)
|
||||
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 advisory export-completeness audit."""
|
||||
|
||||
parser = argparse.ArgumentParser()
|
||||
parser.add_argument("--root", default=".")
|
||||
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 __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
Reference in New Issue
Block a user