Files
khadhroony-solana-project/scripts/audit_rust_export_completeness.py

284 lines
13 KiB
Python

#!/usr/bin/env python3
# file: scripts/audit_rust_export_completeness.py
# version: 4
"""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"(?<![\w:.]){name}\s*\(")
if declaration.kind in {"const", "static", "type", "struct", "enum", "trait", "union"}:
return re.compile(rf"(?<![\w:]){name}\b")
return re.compile(rf"(?<![\w:]){name}\b")
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 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())