166 lines
8.2 KiB
Python
Executable File
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[2]
|
|
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.")
|