#!/usr/bin/env python3 # file: scripts/audit_rust_general_rules.py # version: 2 """Audit mechanically verifiable Rust normalization rules used by games.sasedev.""" 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 tracked-style Rust sources outside generated directories.""" crates = root / "crates" return sorted(path for path in crates.rglob("*.rs") if "target" not in path.parts and ".git" not in path.parts) def mask_rust_source(text: str) -> str: """Mask comments and literals while preserving braces, newlines and offsets.""" output = list(text) index = 0 state = "code" block_depth = 0 raw_hashes = 0 while index < len(text): char = text[index] nxt = text[index + 1] if index + 1 < len(text) else "" if state == "code": if char == "/" and nxt == "/": output[index] = " " output[index + 1] = " " index += 2 state = "line_comment" continue if char == "/" and nxt == "*": output[index] = " " output[index + 1] = " " index += 2 state = "block_comment" block_depth = 1 continue if char == '"': output[index] = " " index += 1 state = "string" continue if char == "'": # Treat a quote as a char literal only when it can close shortly; # lifetimes such as `'a` remain code. probe = index + 1 escaped = False found = False while probe < min(len(text), index + 8) and text[probe] != "\n": if not escaped and text[probe] == "'": found = True break if not escaped and text[probe] == "\\": escaped = True else: escaped = False probe += 1 if found: output[index] = " " index += 1 state = "char" continue if char == "r": probe = index + 1 hashes = 0 while probe < len(text) and text[probe] == "#": hashes += 1 probe += 1 if probe < len(text) and text[probe] == '"': for masked in range(index, probe + 1): output[masked] = " " index = probe + 1 raw_hashes = hashes state = "raw_string" continue index += 1 continue if state == "line_comment": if char == "\n": state = "code" else: output[index] = " " index += 1 continue if state == "block_comment": if char == "/" and nxt == "*": output[index] = " " output[index + 1] = " " block_depth += 1 index += 2 continue if char == "*" and nxt == "/": output[index] = " " output[index + 1] = " " block_depth -= 1 index += 2 if block_depth == 0: state = "code" continue if char != "\n": output[index] = " " index += 1 continue if state == "string": if char == "\\" and index + 1 < len(text): output[index] = " " if text[index + 1] != "\n": output[index + 1] = " " index += 2 continue if char == '"': output[index] = " " state = "code" elif char != "\n": output[index] = " " index += 1 continue if state == "char": if char == "\\" and index + 1 < len(text): output[index] = " " if text[index + 1] != "\n": output[index + 1] = " " index += 2 continue if char == "'": output[index] = " " state = "code" elif char != "\n": output[index] = " " index += 1 continue if state == "raw_string": if char == '"' and text.startswith("#" * raw_hashes, index + 1): output[index] = " " for masked in range(index + 1, index + 1 + raw_hashes): output[masked] = " " index += 1 + raw_hashes state = "code" continue if char != "\n": output[index] = " " index += 1 continue return "".join(output) def line_depths(masked: str) -> list[int]: """Return brace depth at the start of every line.""" depths: list[int] = [] depth = 0 for line in masked.splitlines(): depths.append(depth) depth += line.count("{") - line.count("}") if depth < 0: depth = 0 return depths def preceding_doc_line(lines: list[str], index: int) -> bool: """Return whether one declaration has adjacent useful line rustdoc.""" cursor = index - 2 while cursor >= 0 and lines[cursor].strip().startswith("#["): cursor -= 1 return cursor >= 0 and lines[cursor].lstrip().startswith("///") def natural_key(value: str) -> tuple[tuple[int, object], ...]: """Return a natural case-sensitive ordering key.""" tokens = re.findall(r"[A-Za-z_]+|[0-9]+", value) return tuple((1, int(token)) if token.isdigit() else (0, token) for token in tokens) def public_declaration(stripped: str) -> re.Match[str] | None: """Match a public or crate-public item or associated item declaration.""" return re.match( r"^pub(?:\(crate\))?\s+(?:(?:async|const|unsafe)\s+)*(?:const|static|type|struct|enum|trait|union|fn)\s+([A-Za-z_][A-Za-z0-9_]*)", stripped, ) def public_field(stripped: str) -> re.Match[str] | None: """Match a public or crate-public named field declaration.""" return re.match(r"^pub(?:\(crate\))?\s+([A-Za-z_][A-Za-z0-9_]*)\s*:", stripped) def declaration_start(stripped: str, kind: str) -> bool: """Return whether a line starts a declaration of one requested kind.""" prefixes = r"(?:(?:pub|pub\(crate\))\s+)?(?:(?:async|const|unsafe)\s+)*" return re.match(rf"^{prefixes}{kind}\b", stripped) is not None @dataclasses.dataclass(frozen=True) class RustItem: """One mechanically detected Rust item used for spacing checks.""" kind: str visibility: str start: int declaration: int end: int depth: int braced: bool def item_visibility(stripped: str) -> str: """Return normalized visibility for one item declaration.""" if stripped.startswith("pub(crate) "): return "pub(crate)" if stripped.startswith("pub "): return "pub" return "private" def item_kind(stripped: str) -> str | None: """Return the normalized declaration kind for spacing checks.""" prefix = r"(?:(?:pub|pub\(crate\))\s+)?" qualifiers = r"(?:(?:async|const|unsafe)\s+)*" if re.match(rf"^{prefix}{qualifiers}fn\b", stripped): return "fn" for kind in ("struct", "enum", "union", "trait", "impl", "const", "static", "type"): if re.match(rf"^{prefix}{qualifiers}{kind}\b", stripped): return kind return None def item_leading_line(lines: list[str], declaration_index: int) -> int: """Return the first rustdoc/attribute line attached to an item.""" cursor = declaration_index - 2 while cursor >= 0: stripped = lines[cursor].strip() if stripped.startswith("///") or stripped.startswith("#["): cursor -= 1 continue break return cursor + 2 def item_end_line(masked_lines: list[str], depths: list[int], declaration_index: int, kind: str) -> tuple[int, bool]: """Return the end line and whether the item owns a braced body.""" start_depth = depths[declaration_index - 1] if kind in {"const", "static", "type"}: for line_index in range(declaration_index - 1, len(masked_lines)): if ";" in masked_lines[line_index]: return line_index + 1, False return declaration_index, False body_started = False for line_index in range(declaration_index - 1, len(masked_lines)): masked_line = masked_lines[line_index] if not body_started and ";" in masked_line and "{" not in masked_line: return line_index + 1, False if "{" in masked_line: body_started = True if body_started: end_depth = depths[line_index] + masked_line.count("{") - masked_line.count("}") if end_depth == start_depth: return line_index + 1, True return declaration_index, body_started def rust_items(lines: list[str], masked_lines: list[str], depths: list[int]) -> list[RustItem]: """Return mechanically detected Rust items with source spans.""" result: list[RustItem] = [] for idx, line in enumerate(lines, 1): stripped = line.strip() kind = item_kind(stripped) if kind is None: continue end, braced = item_end_line(masked_lines, depths, idx, kind) result.append(RustItem(kind, item_visibility(stripped), item_leading_line(lines, idx), idx, end, depths[idx - 1], braced)) return result def item_parent(item: RustItem, items: list[RustItem]) -> RustItem | None: """Return the smallest braced item that contains another item.""" parents = [candidate for candidate in items if candidate.braced and candidate.declaration < item.declaration <= candidate.end and candidate.depth < item.depth] if not parents: return None return max(parents, key=lambda candidate: candidate.depth) def audit_item_spacing_and_nesting(relative: str, lines: list[str], masked_lines: list[str], depths: list[int]) -> list[Violation]: """Enforce exact item separation and reject declarations nested in functions.""" violations: list[Violation] = [] items = rust_items(lines, masked_lines, depths) for item in items: parent = item_parent(item, items) if parent is not None and parent.kind == "fn": body_depth = parent.depth + 1 preceding = lines[parent.declaration:item.declaration - 1] preceding_depths = depths[parent.declaration:item.declaration - 1] returned = any(depth == body_depth and re.match(r"^\s*return\b.*;\s*$", line) is not None for line, depth in zip(preceding, preceding_depths, strict=True)) if returned: violations.append(Violation("RUST-FMT-110", relative, item.declaration, f"item `{item.kind}` follows an unconditional function-level return; probable misplaced closing brace")) children: dict[tuple[int, int] | None, list[RustItem]] = {} for item in items: parent = item_parent(item, items) key = None if parent is None else (parent.declaration, parent.end) children.setdefault(key, []).append(item) for siblings in children.values(): siblings.sort(key=lambda item: item.declaration) for previous, current in zip(siblings, siblings[1:]): # Do not infer spacing across unparsed syntax such as macro invocations. between = lines[previous.end:current.start - 1] if any(candidate.strip() for candidate in between): continue blank_count = sum(1 for candidate in between if not candidate.strip()) same_homogeneous_block = previous.kind == current.kind and previous.kind in {"const", "static", "type"} and previous.visibility == current.visibility expected = 0 if same_homogeneous_block else 1 if blank_count != expected: rule = "RUST-FMT-111" if expected == 1 else "RUST-FMT-112" expectation = "exactly one blank line between Rust items" if expected == 1 else "no blank line inside one homogeneous declaration block" violations.append(Violation(rule, relative, current.start, f"{expectation}; found {blank_count}")) return violations def audit_blank_lines_in_bodies(relative: str, lines: list[str], masked_lines: list[str], depths: list[int]) -> list[Violation]: """Reject blank lines inside functions/methods and struct/enum bodies.""" violations: list[Violation] = [] pending_kind: str | None = None pending_depth = 0 body_kind: str | None = None body_depth = 0 for idx, (line, masked_line) in enumerate(zip(lines, masked_lines, strict=True), 1): stripped = line.strip() if body_kind is not None: if not stripped and depths[idx - 1] >= body_depth: violations.append(Violation("RUST-FMT-101", relative, idx, f"blank line inside {body_kind} body")) if depths[idx - 1] < body_depth: body_kind = None if body_kind is None and pending_kind is None: if declaration_start(stripped, "fn"): pending_kind = "function/method" pending_depth = depths[idx - 1] elif declaration_start(stripped, "struct"): pending_kind = "struct" pending_depth = depths[idx - 1] elif declaration_start(stripped, "enum"): pending_kind = "enum" pending_depth = depths[idx - 1] if pending_kind is not None and "{" in masked_line: body_kind = pending_kind body_depth = pending_depth + 1 pending_kind = None elif pending_kind is not None and ";" in masked_line: pending_kind = None return violations def audit_top_level_const_blocks(relative: str, lines: list[str], depths: list[int]) -> list[Violation]: """Audit visibility/order/spacing for mechanically homogeneous const blocks.""" violations: list[Violation] = [] const_pattern = re.compile(r"^(pub\s+|pub\(crate\)\s+)?const\s+([A-Za-z_][A-Za-z0-9_]*)") previous: tuple[int, int, str] | None = None # rank: public, crate-public, private for idx, line in enumerate(lines, 1): if depths[idx - 1] != 0: continue match = const_pattern.match(line.strip()) if match is None: # Docs and attributes belong to the surrounding block and do not break it. if line.strip() and not line.lstrip().startswith(("///", "#[")): previous = None continue visibility = match.group(1) or "" rank = 0 if visibility == "pub " else 1 if visibility == "pub(crate) " else 2 name = match.group(2) if previous is not None: previous_line, previous_rank, previous_name = previous if rank < previous_rank: violations.append(Violation("RUST-FMT-103", relative, idx, "const visibility order must be pub, pub(crate), then private")) if rank == previous_rank and natural_key(name) < natural_key(previous_name): violations.append(Violation("RUST-FMT-104", relative, idx, "const block is not alphabetically ordered")) previous = (idx, rank, name) return violations def audit_module_order(relative: str, lines: list[str], depths: list[int]) -> list[Violation]: """Audit top-level module declaration ordering with test modules last.""" violations: list[Violation] = [] modules: list[tuple[int, bool, str]] = [] for idx, line in enumerate(lines, 1): if depths[idx - 1] != 0: continue match = re.match(r"^mod\s+([A-Za-z_][A-Za-z0-9_]*)\s*(?:;|\{)", line.strip()) if match is None: continue cursor = idx - 2 is_test = False while cursor >= 0 and lines[cursor].strip().startswith("#["): if "cfg(test)" in lines[cursor].replace(" ", ""): is_test = True cursor -= 1 modules.append((idx, is_test, match.group(1))) production = [(idx, name) for idx, is_test, name in modules if not is_test] tests = [(idx, name) for idx, is_test, name in modules if is_test] if tests and production and min(idx for idx, _ in tests) < max(idx for idx, _ in production): first_test = min(idx for idx, _ in tests) violations.append(Violation("RUST-FMT-108", relative, first_test, "test modules must follow production modules")) for group, label in ((production, "production"), (tests, "test")): names = [name for _, name in group] if names != sorted(names, key=natural_key): for idx, name in group: expected = sorted(names, key=natural_key) if names.index(name) != expected.index(name): violations.append(Violation("RUST-FMT-109", relative, idx, f"{label} module declarations are not alphabetically ordered")) break return violations def audit_file(root: pathlib.Path, path: pathlib.Path) -> list[Violation]: """Audit one Rust source against general normalization rules.""" relative = path.relative_to(root).as_posix() text = path.read_text(encoding="utf-8") lines = text.splitlines() masked = mask_rust_source(text) masked_lines = masked.splitlines() depths = line_depths(masked) violations: list[Violation] = [] expected_header = f"// file: {relative}" if not lines or lines[0] != expected_header: violations.append(Violation("RUST-BASE-101", 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("RUST-BASE-102", relative, 2, "missing positive file version")) if not text.endswith("\n") or text.endswith("\n\n"): violations.append(Violation("RUST-FMT-100", relative, max(len(lines), 1), "file must end with exactly one newline")) 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} seen_declaration_before_use = False use_rows: list[tuple[int, str]] = [] for idx, line in enumerate(lines, 1): stripped = line.strip() depth = depths[idx - 1] if idx - 1 < len(depths) else 0 if re.match(r"^(?:pub\s+)?extern\s+crate\s+[A-Za-z_][A-Za-z0-9_]*\s+as\s+", stripped): violations.append(Violation("RUST-IMPORT-104", relative, idx, "extern crate aliases are forbidden")) if "pub(in " in stripped or "pub(super)" in stripped: violations.append(Violation("RUST-API-102", relative, idx, "restricted visibility is forbidden; use private or pub(crate) with crate-root re-export")) if stripped.startswith("pub mod "): violations.append(Violation("RUST-API-101", relative, idx, "modules must remain private and APIs use explicit re-exports")) if depth == 0 and re.match(r"^(?:pub(?:\(crate\))?\s+)?(?:const|static|type|struct|enum|trait|union|fn|impl)\b", stripped): seen_declaration_before_use = True use_match = re.match(r"^(pub(?:\(crate\))?\s+)?use\s+", stripped) if use_match is not None: is_export = stripped.startswith("pub use ") or stripped.startswith("pub(crate) use ") if depth != 0: violations.append(Violation("RUST-IMPORT-111", relative, idx, "use declarations must be at module scope, never inside a function/method/block")) if not is_export: use_rows.append((idx, stripped)) if seen_declaration_before_use: violations.append(Violation("RUST-IMPORT-112", relative, idx, "trait imports must remain at the beginning of the module before declarations")) if "rust-rules: trait-import" not in stripped: violations.append(Violation("RUST-IMPORT-101", relative, idx, "ordinary use requires an explicit trait import justification")) if "::*" in stripped: violations.append(Violation("RUST-IMPORT-102", relative, idx, "glob imports are forbidden")) if "{" in stripped or "}" in stripped: violations.append(Violation("RUST-IMPORT-103", relative, idx, "grouped use/re-export declarations are forbidden")) if re.search(r"\s+as\s+[A-Za-z_][A-Za-z0-9_]*", stripped): violations.append(Violation("RUST-IMPORT-104", relative, idx, "use/re-export aliases are forbidden")) if is_export and re.match(r"^pub(?:\(crate\))?\s+use\s+crate::", stripped): violations.append(Violation("RUST-IMPORT-105", relative, idx, "internal re-export must start with self::")) declaration = public_declaration(stripped) field = public_field(stripped) if declaration is not None or field is not None: if not preceding_doc_line(lines, idx): name = (declaration or field).group(1) violations.append(Violation("RUST-DOC-101", relative, idx, f"public/crate-public `{name}` requires adjacent rustdoc")) # The ordinary module import block is contiguous and alphabetic. for (previous_idx, previous_line), (current_idx, current_line) in zip(use_rows, use_rows[1:]): between = lines[previous_idx:current_idx - 1] if any(not candidate.strip() for candidate in between): violations.append(Violation("RUST-IMPORT-113", relative, current_idx, "module use block contains an empty line")) previous_path = previous_line.split("use ", 1)[1].split(";", 1)[0] current_path = current_line.split("use ", 1)[1].split(";", 1)[0] if natural_key(current_path) < natural_key(previous_path): violations.append(Violation("RUST-IMPORT-114", relative, current_idx, "module use block is not alphabetically ordered")) # Crate façade constraints. if path.name in {"lib.rs", "main.rs"}: ffi_export_exception = relative == "crates/apps/game-android-entrypoint/src/lib.rs" required_attributes = ["#![warn(missing_docs)]", "#![deny(unreachable_pub)]"] required_attributes.append("#![allow(unsafe_code)]" if ffi_export_exception else "#![forbid(unsafe_code)]") for attribute in required_attributes: if attribute not in lines[:24]: violations.append(Violation("RUST-BASE-103", relative, 1, f"missing `{attribute}`")) if ffi_export_exception: required_export_attributes = ( '#[unsafe(export_name = "Java_com_sasedev_games_common_NativeBridge_nativeContractVersion")]', '#[unsafe(export_name = "Java_com_sasedev_games_common_NativeBridge_nativeRequestPlatformBack")]', '#[unsafe(export_name = "SDL_main")]', ) for export_attribute in required_export_attributes: if text.count(export_attribute) != 1: violations.append(Violation("RUST-FFI-101", relative, 1, f"Android entrypoint must contain exactly one required export attribute: {export_attribute}")) unsafe_export_count = text.count("#[unsafe(export_name = ") if unsafe_export_count != len(required_export_attributes): violations.append(Violation("RUST-FFI-103", relative, 1, "Android entrypoint contains an unexpected unsafe export attribute")) if "unsafe {" in text or re.search(r"\bunsafe\s+fn\b", text) is not None or re.search(r"\bunsafe\s+extern\b", text) is not None: violations.append(Violation("RUST-FFI-102", relative, 1, "Android entrypoint exception permits named unsafe export attributes only; unsafe blocks/functions remain forbidden")) exports: list[tuple[int, str]] = [] for idx, line in enumerate(lines, 1): stripped = line.strip() if stripped.startswith("pub use ") or stripped.startswith("pub(crate) use "): visibility = "pub(crate)" if stripped.startswith("pub(crate) use ") else "pub" exports.append((idx, visibility)) if not preceding_doc_line(lines, idx): violations.append(Violation("RUST-DOC-102", relative, idx, "crate-root re-export requires adjacent rustdoc")) source_match = re.match(r"^pub(?:\(crate\))?\s+use\s+(?:self::)?([A-Za-z_][A-Za-z0-9_]*)::", stripped) if source_match is not None and source_match.group(1) in local_modules and not re.match(r"^pub(?:\(crate\))?\s+use\s+self::", stripped): violations.append(Violation("RUST-IMPORT-106", relative, idx, "crate-root internal re-export must start with self::")) public_exports = [item for item in exports if item[1] == "pub"] crate_exports = [item for item in exports if item[1] == "pub(crate)"] if public_exports and crate_exports: public_end = public_exports[-1][0] crate_start = crate_exports[0][0] transition = lines[public_end:crate_start - 1] blank_count = sum(1 for candidate in transition if not candidate.strip()) if blank_count != 1: violations.append(Violation("RUST-FMT-113", relative, crate_start, f"pub use and pub(crate) use blocks require exactly one blank line; found {blank_count}")) crate_seen = False previous_by_visibility: dict[str, int] = {} for idx, visibility in exports: if visibility == "pub(crate)": crate_seen = True elif crate_seen: violations.append(Violation("RUST-FMT-105", relative, idx, "pub use block must precede pub(crate) use block")) previous_idx = previous_by_visibility.get(visibility) if previous_idx is not None: # One homogeneous block may contain rustdocs but no blank line. # rustfmt is authoritative for intra-block re-export ordering; # this audit must not impose a competing exported-symbol sort. between = lines[previous_idx:idx - 1] if any(not candidate.strip() for candidate in between): violations.append(Violation("RUST-FMT-106", relative, idx, f"{visibility} use block contains an empty line")) previous_by_visibility[visibility] = idx violations.extend(audit_blank_lines_in_bodies(relative, lines, masked_lines, depths)) violations.extend(audit_item_spacing_and_nesting(relative, lines, masked_lines, depths)) violations.extend(audit_module_order(relative, lines, depths)) violations.extend(audit_top_level_const_blocks(relative, lines, depths)) return violations def main() -> int: """Run the general Rust normalization 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") counts: dict[str, int] = {} for item in violations: counts[item.code] = counts.get(item.code, 0) + 1 for code in sorted(counts): sys.stdout.write(f"{code}: {counts[code]}\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())