0.0.5-alpha.5
This commit is contained in:
292
scripts/audit_rust_export_completeness.py
Normal file
292
scripts/audit_rust_export_completeness.py
Normal file
@@ -0,0 +1,292 @@
|
||||
#!/usr/bin/env python3
|
||||
# file: scripts/audit_rust_export_completeness.py
|
||||
# version: 1
|
||||
|
||||
"""Audit crate-root façade completeness and canonical same-crate paths."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import dataclasses
|
||||
import pathlib
|
||||
import re
|
||||
import sys
|
||||
|
||||
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 same-crate path 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
|
||||
|
||||
|
||||
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():
|
||||
crates.add(crate)
|
||||
return sorted(crates)
|
||||
|
||||
|
||||
def crate_root_source(crate: pathlib.Path) -> pathlib.Path | None:
|
||||
"""Return the library or binary crate-root source when present."""
|
||||
|
||||
library = crate / "src/lib.rs"
|
||||
if library.is_file():
|
||||
return library
|
||||
binary = crate / "src/main.rs"
|
||||
if binary.is_file():
|
||||
return binary
|
||||
return None
|
||||
|
||||
|
||||
def module_path(crate: pathlib.Path, path: pathlib.Path) -> str:
|
||||
"""Return the file-based module path represented by one Rust source file."""
|
||||
|
||||
relative = path.relative_to(crate / "src").with_suffix("")
|
||||
parts = list(relative.parts)
|
||||
if parts and parts[-1] == "mod":
|
||||
parts.pop()
|
||||
return "::".join(parts)
|
||||
|
||||
|
||||
def declaration_candidates(crate: pathlib.Path, path: pathlib.Path) -> list[Declaration]:
|
||||
"""Return root-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_path(crate, path),
|
||||
name=match.group(3),
|
||||
visibility=match.group(1),
|
||||
kind=match.group(2),
|
||||
path=path,
|
||||
line=line_number,
|
||||
)
|
||||
)
|
||||
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(crate: pathlib.Path) -> dict[tuple[str, str], str]:
|
||||
"""Return explicit crate-root re-exports keyed by source module and symbol."""
|
||||
|
||||
crate_root = crate_root_source(crate)
|
||||
exports: dict[tuple[str, str], str] = {}
|
||||
if crate_root is None:
|
||||
return exports
|
||||
|
||||
text = crate_root.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 externally_used(crate: pathlib.Path, declaration: Declaration) -> bool:
|
||||
"""Return whether a crate-public item is clearly used outside its module file."""
|
||||
|
||||
direct = f"crate::{declaration.module}::{declaration.name}"
|
||||
root_direct = f"crate::{declaration.name}"
|
||||
for path in sorted(crate.rglob("*.rs")):
|
||||
if _is_ignored(path, crate) or path.resolve() == declaration.path.resolve():
|
||||
continue
|
||||
masked, _ = mask_source(path.read_text(encoding="utf-8"))
|
||||
if direct in masked or root_direct in masked:
|
||||
return True
|
||||
return False
|
||||
|
||||
|
||||
def audit_crate(workspace: pathlib.Path, crate: pathlib.Path) -> list[Candidate]:
|
||||
"""Audit one Rust package for the selected strict façade contract."""
|
||||
|
||||
crate_root = crate_root_source(crate)
|
||||
if crate_root is None:
|
||||
return []
|
||||
exports = root_exports(crate)
|
||||
candidates: list[Candidate] = []
|
||||
|
||||
for path in sorted((crate / "src").rglob("*.rs")):
|
||||
if path == crate_root or _is_ignored(path, crate):
|
||||
continue
|
||||
for declaration in declaration_candidates(crate, path):
|
||||
required = declaration.visibility == "pub" or externally_used(crate, 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=path.relative_to(workspace).as_posix(),
|
||||
line=declaration.line,
|
||||
message=f"{reason} `{declaration.name}` requires an explicit 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.rglob("*.rs")):
|
||||
if path == crate_root or _is_ignored(path, crate):
|
||||
continue
|
||||
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 long_path.finditer(line):
|
||||
alias = exports.get((match.group(1), match.group(2)))
|
||||
if alias is None:
|
||||
continue
|
||||
candidates.append(
|
||||
Candidate(
|
||||
code="RUST-IMPORT-201",
|
||||
path=relative,
|
||||
line=line_number,
|
||||
message=(
|
||||
f"long same-crate path `crate::{match.group(1)}::{match.group(2)}` "
|
||||
f"must use crate-root `crate::{alias}`"
|
||||
),
|
||||
)
|
||||
)
|
||||
return candidates
|
||||
|
||||
|
||||
def audit_workspace(root: pathlib.Path) -> tuple[list[pathlib.Path], list[Candidate]]:
|
||||
"""Return discovered crates and all façade candidates below one project root."""
|
||||
|
||||
crates = crate_roots(root)
|
||||
candidates = [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
|
||||
|
||||
try:
|
||||
crates, candidates = audit_workspace(workspace)
|
||||
except (OSError, UnicodeError) as error:
|
||||
sys.stderr.write(f"Rust export completeness audit: unable to read selected workspace: {error}\n")
|
||||
return 2
|
||||
|
||||
if not crates:
|
||||
sys.stderr.write("Rust export completeness audit: no Rust crate root discovered\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)} crate(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())
|
||||
Reference in New Issue
Block a user