Files
RustProxy/scripts/rust_module_contracts.py
T
2026-10-05 12:22:41 +02:00

166 lines
8.2 KiB
Python
Executable File

#!/usr/bin/env python3
"""Static structure contracts for the split Rust source tree.
These checks are intentionally compiler-independent. They prevent the module split
from regressing into large monoliths, keep unit tests out of implementation files,
and catch the class of dangling-attribute/broken-include mistakes that can otherwise
surface only at cargo check time.
"""
from __future__ import annotations
from pathlib import Path
import re
from rust_source import rust_source
ROOT = Path(__file__).resolve().parents[1]
SRC = ROOT / "src"
MAX_LINES = 500
def is_test_source(path: Path) -> bool:
relative = path.relative_to(SRC)
return path.name == "tests.rs" or path.stem.endswith("_tests") or "tests" in relative.parts
include_re = re.compile(r'include!\(\s*"([^"]+\.rs)"\s*\)')
test_attr_re = re.compile(r'(?m)^\s*#\[(?:tokio::)?test(?:\([^\]]*\))?\]')
dangling_doc_before_close_re = re.compile(r'(?ms)(?:^\s*///[^\n]*\n){1,8}\s*}')
dangling_attr_before_close_re = re.compile(r'(?ms)^\s*#\[[^\n]+\]\s*\n\s*}')
def delimiters_balanced(text: str) -> bool:
stack: list[str] = []
pairs = {"}": "{", ")": "(", "]": "["}
i = 0
state = "code"
block_depth = 0
raw_hashes = 0
while i < len(text):
char = text[i]
nxt = text[i + 1] if i + 1 < len(text) else ""
if state == "code":
if char == "/" and nxt == "/":
state = "line"; i += 2; continue
if char == "/" and nxt == "*":
state = "block"; block_depth = 1; i += 2; continue
if char == '"':
state = "string"; i += 1; continue
if char == "r":
raw = re.match(r'r(#{0,16})"', text[i:])
if raw:
raw_hashes = len(raw.group(1)); state = "raw"; i += len(raw.group(0)); continue
if char == "'":
# Lifetimes are identifiers; only enter char mode when a nearby closing quote exists.
cursor = i + 1
closing = False
while cursor < min(len(text), i + 8):
if text[cursor] == "\\": cursor += 2; continue
if text[cursor] == "'": closing = True; break
if text[cursor] in "\r\n": break
cursor += 1
if closing:
state = "char"; i += 1; continue
if char in "({[": stack.append(char)
elif char in "})]":
if not stack or stack[-1] != pairs[char]: return False
stack.pop()
i += 1
elif state == "line":
if char == "\n": state = "code"
i += 1
elif state == "block":
if char == "/" and nxt == "*": block_depth += 1; i += 2
elif char == "*" and nxt == "/":
block_depth -= 1; i += 2
if block_depth == 0: state = "code"
else: i += 1
elif state == "string":
if char == "\\": i += 2
elif char == '"': state = "code"; i += 1
else: i += 1
elif state == "char":
if char == "\\": i += 2
elif char == "'": state = "code"; i += 1
else: i += 1
elif state == "raw":
terminator = '"' + "#" * raw_hashes
if text.startswith(terminator, i): i += len(terminator); state = "code"
else: i += 1
return not stack and state in {"code", "line"}
# The 500-line rule applies to every Rust source in the repository, including integration tests.
for path in sorted(ROOT.rglob("*.rs")):
if any(part in {"target", ".git"} for part in path.parts):
continue
lines = path.read_text(encoding="utf-8").splitlines()
assert len(lines) <= MAX_LINES, f"{path.relative_to(ROOT)} has {len(lines)} lines (max {MAX_LINES})"
for path in sorted(SRC.rglob("*.rs")):
text = path.read_text(encoding="utf-8")
lines = text.splitlines()
assert delimiters_balanced(text), f"unbalanced delimiters in split Rust source: {path.relative_to(ROOT)}"
nonblank = [line.strip() for line in lines if line.strip()]
if nonblank:
assert not nonblank[-1].startswith(("#[", "///", "//!")), f"dangling Rust attribute/doc comment at EOF: {path.relative_to(ROOT)}"
assert not dangling_doc_before_close_re.search(text), f"doc comment before closing brace does not document an item: {path.relative_to(ROOT)}"
assert not dangling_attr_before_close_re.search(text), f"attribute before closing brace does not decorate an item: {path.relative_to(ROOT)}"
for target in include_re.findall(text):
included = path.parent / target
assert included.is_file(), f"broken include! in {path.relative_to(ROOT)}: {target}"
included_text = included.read_text(encoding="utf-8")
# include! expands into the caller's existing module/item scope. Inner module docs
# (`//!`) in a split fragment are therefore invalid once another item precedes them.
assert not re.search(r'(?m)^\s*//!', included_text), (
f"inner module doc comment in include! fragment: {included.relative_to(ROOT)}"
)
if not is_test_source(path):
assert not test_attr_re.search(text), f"unit test remains in implementation source: {path.relative_to(ROOT)}"
# Test-only implementation must live in a separate file and be pulled in explicitly.
for match in re.finditer(r'(?m)^\s*#\[cfg\(test\)\]\s*$', text):
tail = text[match.end():].lstrip()
assert tail.startswith("include!(") or tail.startswith("mod tests;"), (
f"test cfg in implementation must target a separate test file: {path.relative_to(ROOT)}"
)
# Explicit regressions from the first Web Server split.
response = (SRC / "web_server/response.rs").read_text(encoding="utf-8")
assert not response.rstrip().endswith("#[derive(Debug, Clone)]"), "dangling derive returned in Web Server response module"
fastcgi = rust_source(ROOT, "src/web_server/fastcgi.rs")
request = (SRC / "web_server/request.rs").read_text(encoding="utf-8")
assert "use anyhow::{Context" in fastcgi or "Context," in fastcgi or ", Context" in fastcgi
assert "htaccess," in fastcgi
assert "request::server_protocol" in fastcgi
assert "pub(super) fn server_protocol" in request
headers = (SRC / "web_server/response/headers.rs").read_text(encoding="utf-8")
assert "htaccess," in headers, "Web Server response headers must import sibling htaccess after module split"
assert "ACCEPT_ENCODING," not in headers, "stale ACCEPT_ENCODING import reintroduced in response headers"
# Regressions exposed by a real `cargo check --locked --all-targets` after the 0.25.1 split.
assert "pub(super) trait FastCgiIo" in fastcgi, "FastCgiIo visibility must match sibling-visible FastCGI helpers"
for relative in ("src/proxy/websocket/tests.rs", "tests/http3.rs"):
text = (ROOT / relative).read_text(encoding="utf-8")
assert 'parse::<h3::ext::Protocol>().unwrap()' not in text, (
f"{relative} uses Result::unwrap() for h3::ext::Protocol even though InvalidProtocol has no Debug"
)
server_tests = (SRC / "server/serve/tests.rs").read_text(encoding="utf-8")
assert server_tests.count("serve_inner(") == server_tests.count("Http2Connections::default()"), (
"direct serve_inner test calls must pass the Http2Connections argument"
)
cache_policy_tests = (SRC / "cache/tests/tests/part_03.rs").read_text(encoding="utf-8")
assert "let plan = plan(" not in cache_policy_tests, "cache policy test shadows the plan() function"
cold_tests = (SRC / "cache/tests/cold_tier_tests/part_02.rs").read_text(encoding="utf-8")
for field in ("scheme:", "authority:", "target:", "tags:", "created_at:"):
assert field in cold_tests, f"ColdEntry test fixture is missing current field {field}"
admin_tests = (SRC / "admin/tests/part_02.rs").read_text(encoding="utf-8")
assert "let configuration = router\n .clone()\n .oneshot(" in admin_tests, (
"admin persistence test must retain Router before Tower::oneshot consumes a clone"
)
web_tests_1 = (SRC / "web_server/tests/part_01.rs").read_text(encoding="utf-8")
assert "ETAG, EXPIRES," not in web_tests_1, "unused EXPIRES import returned in Web Server tests"
print(f"PASS: Rust modules are <= {MAX_LINES} lines, test code is isolated, include! targets resolve, and split-module/all-target regressions are guarded.")