240 lines
9.6 KiB
Python
Executable File
240 lines
9.6 KiB
Python
Executable File
#!/usr/bin/env python3
|
|
# file: scripts/audit_rust_general_rules.py
|
|
# version: 11
|
|
|
|
"""Audit mechanically verifiable Rust rules shared by all Rust projects."""
|
|
|
|
from __future__ import annotations
|
|
|
|
import argparse
|
|
import dataclasses
|
|
import pathlib
|
|
import re
|
|
import sys
|
|
|
|
|
|
@dataclasses.dataclass(frozen=True)
|
|
class Violation:
|
|
"""One mechanically detected general Rust rule violation."""
|
|
|
|
code: str
|
|
path: str
|
|
line: int
|
|
message: str
|
|
|
|
|
|
def rust_files(root: pathlib.Path) -> list[pathlib.Path]:
|
|
"""Return Rust sources outside generated build directories."""
|
|
|
|
return sorted(
|
|
path
|
|
for path in root.rglob("*.rs")
|
|
if "target" not in path.parts
|
|
and ".git" not in path.parts
|
|
and "mnt" not in path.relative_to(root).parts
|
|
)
|
|
|
|
|
|
def rustfmt_re_export_key(export_path: str) -> tuple[object, ...]:
|
|
"""Return the natural path order used by rustfmt for separate imports."""
|
|
|
|
path = export_path.removesuffix(";")
|
|
segments = path.split("::")
|
|
root_priority = 0 if segments[0] == "self" else 1 if segments[0] == "super" else 2
|
|
segment_keys: list[tuple[tuple[int, object], ...]] = []
|
|
for segment in segments:
|
|
raw_tokens = re.findall(r"[A-Za-z]+|[0-9]+", segment)
|
|
tokens: list[tuple[int, object]] = []
|
|
for index, token in enumerate(raw_tokens):
|
|
if token.isdigit():
|
|
previous = raw_tokens[index - 1].lower() if index > 0 else ""
|
|
priority = -1 if token == "2022" and previous == "token" else 1
|
|
tokens.append((priority, int(token)))
|
|
else:
|
|
tokens.append((0, token))
|
|
segment_keys.append(tuple(tokens))
|
|
return (root_priority, *segment_keys)
|
|
|
|
|
|
def audit_file(root: pathlib.Path, path: pathlib.Path) -> list[Violation]:
|
|
"""Audit one Rust source against general rules."""
|
|
|
|
relative = path.relative_to(root).as_posix()
|
|
text = path.read_text(encoding="utf-8")
|
|
lines = text.splitlines()
|
|
violations: list[Violation] = []
|
|
local_modules = {
|
|
match.group(1)
|
|
for line in lines
|
|
if (match := re.fullmatch(r"\s*mod\s+([A-Za-z_][A-Za-z0-9_]*)\s*;", line)) is not None
|
|
}
|
|
expected_header = f"// file: {relative}"
|
|
if not lines or lines[0] != expected_header:
|
|
violations.append(Violation("RUST001", relative, 1, f"expected `{expected_header}`"))
|
|
if len(lines) < 2 or re.fullmatch(r"// version: [1-9][0-9]*", lines[1]) is None:
|
|
violations.append(Violation("RUST002", relative, 2, "missing positive file version"))
|
|
if not text.endswith("\n") or text.endswith("\n\n"):
|
|
violations.append(Violation("RUST003", relative, max(len(lines), 1), "file must end with exactly one newline"))
|
|
for index, line in enumerate(lines, 1):
|
|
stripped = line.strip()
|
|
if "pub(in " in stripped or "pub(super)" in stripped:
|
|
violations.append(
|
|
Violation(
|
|
"RUST027",
|
|
relative,
|
|
index,
|
|
"restricted public visibility is forbidden; use private or `pub(crate)` with crate-root re-export",
|
|
)
|
|
)
|
|
if stripped.startswith("pub mod "):
|
|
violations.append(
|
|
Violation(
|
|
"RUST014",
|
|
relative,
|
|
index,
|
|
"modules must remain private and APIs must use explicit re-exports",
|
|
)
|
|
)
|
|
if re.match(r"^(?:pub(?:\(crate\))?\s+)?use\s+", stripped) is None:
|
|
continue
|
|
is_export = stripped.startswith("pub use ") or stripped.startswith("pub(crate) use ")
|
|
justified_trait = "rust-rules: trait-import" in stripped
|
|
justified_derive = "rust-rules: derive-import" in stripped
|
|
if not is_export and not justified_trait and not justified_derive:
|
|
violations.append(Violation("RUST010", relative, index, "ordinary use requires a trait or derive import justification"))
|
|
if "::*" in stripped:
|
|
violations.append(Violation("RUST012", relative, index, "glob imports are forbidden"))
|
|
if "{" in stripped or "}" in stripped:
|
|
code = "RUST011" if is_export else "RUST013"
|
|
violations.append(Violation(code, relative, index, "grouped use declarations are forbidden"))
|
|
internal_re_export = re.match(
|
|
r"^pub(?:\(crate\))?\s+use\s+(?:crate::)?([A-Za-z_][A-Za-z0-9_]*)::",
|
|
stripped,
|
|
)
|
|
if is_export and (
|
|
re.match(r"^pub(?:\(crate\))?\s+use\s+crate::", stripped)
|
|
or (
|
|
internal_re_export is not None
|
|
and internal_re_export.group(1) in local_modules
|
|
)
|
|
):
|
|
violations.append(Violation("RUST015", relative, index, "internal re-export must start with `self::`"))
|
|
if is_export and re.search(r"\s+as\s+[A-Za-z_][A-Za-z0-9_]*\s*;", stripped):
|
|
violations.append(Violation("RUST016", relative, index, "re-export aliases are forbidden"))
|
|
previous_export_line: int | None = None
|
|
previous_export_visibility: str | None = None
|
|
previous_export_key: tuple[object, ...] | None = None
|
|
blank_since_export = False
|
|
crate_visibility_seen = False
|
|
enforce_visibility_order = relative == "ks-lib/src/lib.rs" or relative.startswith(
|
|
"ks-lib/src/executor"
|
|
)
|
|
for index, line in enumerate(lines, 1):
|
|
if not line.strip():
|
|
if previous_export_line is not None:
|
|
blank_since_export = True
|
|
continue
|
|
if line.startswith("///"):
|
|
continue
|
|
if line.startswith("pub use ") or line.startswith("pub(crate) use "):
|
|
visibility = "pub(crate)" if line.startswith("pub(crate) use ") else "pub"
|
|
if enforce_visibility_order and visibility == "pub" and crate_visibility_seen:
|
|
violations.append(
|
|
Violation(
|
|
"RUST024",
|
|
relative,
|
|
index,
|
|
"`pub use` block must precede the `pub(crate) use` block",
|
|
)
|
|
)
|
|
if visibility == "pub(crate)":
|
|
crate_visibility_seen = True
|
|
if (
|
|
enforce_visibility_order
|
|
and
|
|
previous_export_line is not None
|
|
and previous_export_visibility != visibility
|
|
and not blank_since_export
|
|
):
|
|
violations.append(
|
|
Violation(
|
|
"RUST025",
|
|
relative,
|
|
index,
|
|
"re-export blocks with different visibility require one empty line",
|
|
)
|
|
)
|
|
if (
|
|
previous_export_line is not None
|
|
and previous_export_visibility == visibility
|
|
and blank_since_export
|
|
):
|
|
violations.append(
|
|
Violation(
|
|
"RUST022",
|
|
relative,
|
|
index,
|
|
"re-export block contains an empty line",
|
|
)
|
|
)
|
|
export_path = line.split(" use ", 1)[1]
|
|
export_key = rustfmt_re_export_key(export_path)
|
|
if (
|
|
previous_export_line is not None
|
|
and previous_export_visibility == visibility
|
|
and not blank_since_export
|
|
and previous_export_key is not None
|
|
and export_key < previous_export_key
|
|
):
|
|
violations.append(
|
|
Violation(
|
|
"RUST023",
|
|
relative,
|
|
index,
|
|
"re-export block is not ordered alphabetically",
|
|
)
|
|
)
|
|
previous_export_line = index
|
|
previous_export_visibility = visibility
|
|
previous_export_key = export_key
|
|
blank_since_export = False
|
|
continue
|
|
previous_export_line = None
|
|
previous_export_visibility = None
|
|
previous_export_key = None
|
|
blank_since_export = False
|
|
if path.name in {"lib.rs", "main.rs"}:
|
|
for attribute in ("#![warn(missing_docs)]", "#![deny(unreachable_pub)]", "#![forbid(unsafe_code)]"):
|
|
if attribute not in lines[:20]:
|
|
violations.append(Violation("RUST020", relative, 1, f"missing `{attribute}`"))
|
|
for index, line in enumerate(lines, 1):
|
|
if not (line.startswith("pub use ") or line.startswith("pub(crate) use ")):
|
|
continue
|
|
previous = lines[index - 2].strip() if index >= 2 else ""
|
|
if not previous.startswith("///"):
|
|
violations.append(Violation("RUST021", relative, index, "crate-root re-export requires adjacent rustdoc"))
|
|
return violations
|
|
|
|
|
|
def main() -> int:
|
|
"""Run the general Rust audit."""
|
|
|
|
parser = argparse.ArgumentParser()
|
|
parser.add_argument("--root", default=".")
|
|
parser.add_argument("--report-only", action="store_true")
|
|
arguments = parser.parse_args()
|
|
root = pathlib.Path(arguments.root).resolve()
|
|
violations = [item for path in rust_files(root) for item in audit_file(root, path)]
|
|
violations.sort(key=lambda item: (item.code, item.path, item.line, item.message))
|
|
if not violations:
|
|
sys.stdout.write("General Rust rule audit: clean\n")
|
|
return 0
|
|
sys.stdout.write(f"General Rust rule audit: {len(violations)} violation(s)\n")
|
|
for item in violations:
|
|
sys.stdout.write(f"{item.code} {item.path}:{item.line}: {item.message}\n")
|
|
return 0 if arguments.report_only else 1
|
|
|
|
|
|
if __name__ == "__main__":
|
|
raise SystemExit(main())
|