from __future__ import annotations
import argparse
import json
import tempfile
import unittest
from dataclasses import dataclass, field
from pathlib import Path
from typing import Iterable
DEFAULT_PRODUCTION_RUST_LINE_BUDGET = 1_000
DEFAULT_TEST_FILE_NAMES = ("tests.rs",)
DEFAULT_TEST_FILE_SUFFIXES = ("_test.rs", "_tests.rs")
DEFAULT_CONFIG_RELATIVE_PATH = "scripts/architecture.json"
@dataclass(frozen=True)
class ForbiddenDependency:
path: str
modules: tuple[str, ...]
reason: str
@dataclass(frozen=True)
class ArchitectureConfig:
default_production_line_budget: int = DEFAULT_PRODUCTION_RUST_LINE_BUDGET
legacy_file_budgets: dict[str, int] = field(default_factory=dict)
generated_files: dict[str, str] = field(default_factory=dict)
thin_binary_budgets: dict[str, int] = field(default_factory=dict)
forbidden_dependencies: tuple[ForbiddenDependency, ...] = ()
test_file_names: tuple[str, ...] = DEFAULT_TEST_FILE_NAMES
test_file_suffixes: tuple[str, ...] = DEFAULT_TEST_FILE_SUFFIXES
class ConfigError(ValueError):
@dataclass(frozen=True)
class SizeCheckResult:
checked_files: int
excluded_test_files: int
excluded_generated_files: int
errors: tuple[str, ...]
def repository_root() -> Path:
return Path(__file__).resolve().parent.parent
def relative_path(path: Path, root: Path) -> str:
return path.relative_to(root).as_posix()
def _require(condition: bool, message: str) -> None:
if not condition:
raise ConfigError(message)
def _string_int_map(raw: object, name: str) -> dict[str, int]:
_require(isinstance(raw, dict), f"{name} must be an object")
result: dict[str, int] = {}
for key, value in raw.items(): _require(isinstance(key, str), f"{name} keys must be strings")
_require(
isinstance(value, int) and not isinstance(value, bool),
f"{name}[{key!r}] must be an integer",
)
result[key] = value
return result
def _string_string_map(raw: object, name: str) -> dict[str, str]:
_require(isinstance(raw, dict), f"{name} must be an object")
result: dict[str, str] = {}
for key, value in raw.items(): _require(isinstance(key, str), f"{name} keys must be strings")
_require(isinstance(value, str), f"{name}[{key!r}] must be a string")
result[key] = value
return result
def _string_tuple(raw: object, name: str, default: tuple[str, ...]) -> tuple[str, ...]:
if raw is None:
return default
_require(isinstance(raw, list), f"{name} must be an array")
for value in raw: _require(isinstance(value, str), f"{name} entries must be strings")
return tuple(raw)
def _forbidden_dependencies(raw: object) -> tuple[ForbiddenDependency, ...]:
if raw is None:
return ()
_require(isinstance(raw, list), "forbidden_dependencies must be an array")
entries: list[ForbiddenDependency] = []
for index, item in enumerate(raw): label = f"forbidden_dependencies[{index}]"
_require(isinstance(item, dict), f"{label} must be an object")
path = item.get("path")
modules = item.get("modules")
reason = item.get("reason", "")
_require(isinstance(path, str) and path, f"{label}.path must be a non-empty string")
_require(isinstance(modules, list) and modules, f"{label}.modules must be a non-empty array")
for module in modules:
_require(isinstance(module, str) and module, f"{label}.modules entries must be non-empty strings")
_require(isinstance(reason, str), f"{label}.reason must be a string")
entries.append(ForbiddenDependency(path=path, modules=tuple(modules), reason=reason))
return tuple(entries)
def parse_config(data: object) -> ArchitectureConfig:
_require(isinstance(data, dict), "config root must be an object")
default_budget = data.get("default_production_line_budget", DEFAULT_PRODUCTION_RUST_LINE_BUDGET)
_require(
isinstance(default_budget, int) and not isinstance(default_budget, bool),
"default_production_line_budget must be an integer",
)
return ArchitectureConfig(
default_production_line_budget=default_budget,
legacy_file_budgets=_string_int_map(data.get("legacy_file_budgets", {}), "legacy_file_budgets"),
generated_files=_string_string_map(data.get("generated_files", {}), "generated_files"),
thin_binary_budgets=_string_int_map(data.get("thin_binary_budgets", {}), "thin_binary_budgets"),
forbidden_dependencies=_forbidden_dependencies(data.get("forbidden_dependencies")),
test_file_names=_string_tuple(data.get("test_file_names"), "test_file_names", DEFAULT_TEST_FILE_NAMES),
test_file_suffixes=_string_tuple(
data.get("test_file_suffixes"), "test_file_suffixes", DEFAULT_TEST_FILE_SUFFIXES
),
)
def load_config(path: Path) -> ArchitectureConfig:
if not path.is_file():
return ArchitectureConfig()
try:
data = json.loads(path.read_text(encoding="utf-8"))
except json.JSONDecodeError as error:
raise ConfigError(f"{path}: invalid JSON: {error}") from error
return parse_config(data)
def is_dedicated_test_file(
relative: str,
*,
names: Iterable[str] = DEFAULT_TEST_FILE_NAMES,
suffixes: Iterable[str] = DEFAULT_TEST_FILE_SUFFIXES,
) -> bool:
path = Path(relative)
return (
"tests" in path.parts
or path.name in set(names)
or path.name.endswith(tuple(suffixes))
)
def count_lines(path: Path) -> int:
return len(path.read_text(encoding="utf-8").splitlines())
def production_rust_files(root: Path) -> list[Path]:
files = list((root / "src").rglob("*.rs"))
build_script = root / "build.rs"
if build_script.is_file():
files.append(build_script)
return sorted(files)
def check_file_size_budgets(
root: Path,
*,
legacy_budgets: dict[str, int],
generated_files: dict[str, str],
default_budget: int = DEFAULT_PRODUCTION_RUST_LINE_BUDGET,
test_file_names: Iterable[str] = DEFAULT_TEST_FILE_NAMES,
test_file_suffixes: Iterable[str] = DEFAULT_TEST_FILE_SUFFIXES,
) -> SizeCheckResult:
discovered = production_rust_files(root)
discovered_paths = {relative_path(path, root) for path in discovered}
errors: list[str] = []
for relative in sorted(legacy_budgets):
if relative not in discovered_paths:
errors.append(f"legacy size-budget entry does not exist: {relative}")
elif legacy_budgets[relative] <= default_budget:
errors.append(
f"legacy size-budget entry is no longer needed: {relative} "
f"({legacy_budgets[relative]} <= {default_budget})"
)
for relative in sorted(generated_files):
if relative not in discovered_paths:
errors.append(f"generated-file exclusion does not exist: {relative}")
elif not generated_files[relative].strip():
errors.append(f"generated-file exclusion needs a reason: {relative}")
checked_files = 0
excluded_test_files = 0
excluded_generated_files = 0
for path in discovered:
relative = relative_path(path, root)
if is_dedicated_test_file(relative, names=test_file_names, suffixes=test_file_suffixes):
excluded_test_files += 1
continue
if relative in generated_files:
excluded_generated_files += 1
continue
checked_files += 1
lines = count_lines(path)
budget = legacy_budgets.get(relative, default_budget)
if lines > budget:
policy = "legacy budget" if relative in legacy_budgets else "production-file budget"
errors.append(f"{relative}: {lines} lines exceeds {policy} of {budget}")
return SizeCheckResult(
checked_files=checked_files,
excluded_test_files=excluded_test_files,
excluded_generated_files=excluded_generated_files,
errors=tuple(errors),
)
def rust_tokens(source: str) -> list[str]:
tokens: list[str] = []
index = 0
length = len(source)
while index < length:
char = source[index]
next_char = source[index + 1] if index + 1 < length else ""
if char.isspace():
index += 1
continue
if char == "/" and next_char == "/":
newline = source.find("\n", index + 2)
index = length if newline == -1 else newline + 1
continue
if char == "/" and next_char == "*":
depth = 1
index += 2
while index < length and depth:
pair = source[index : index + 2]
if pair == "/*":
depth += 1
index += 2
elif pair == "*/":
depth -= 1
index += 2
else:
index += 1
continue
raw_prefix_length = 0
if char == "r":
raw_prefix_length = 1
elif char in {"b", "c"} and next_char == "r":
raw_prefix_length = 2
if raw_prefix_length:
marker = index + raw_prefix_length
hashes = 0
while marker + hashes < length and source[marker + hashes] == "#":
hashes += 1
quote = marker + hashes
if quote < length and source[quote] == '"':
terminator = '"' + ("#" * hashes)
end = source.find(terminator, quote + 1)
index = length if end == -1 else end + len(terminator)
continue
string_prefix_length = 1 if char in {"b", "c"} and next_char == '"' else 0
if char == '"' or string_prefix_length:
index += string_prefix_length + 1
while index < length:
if source[index] == "\\":
index += 2
elif source[index] == '"':
index += 1
break
else:
index += 1
continue
if char == "'":
if index + 2 < length and source[index + 2] == "'":
index += 3
continue
if index + 3 < length and next_char == "\\" and source[index + 3] == "'":
index += 4
continue
index += 1
continue
if char.isalpha() or char == "_":
end = index + 1
while end < length and (source[end].isalnum() or source[end] == "_"):
end += 1
tokens.append(source[index:end])
index = end
continue
if char == ":" and next_char == ":":
tokens.append("::")
index += 2
continue
if char in "{};,":
tokens.append(char)
index += 1
return tokens
def references_crate_module(source: str, module: str) -> bool:
tokens = rust_tokens(source)
for index in range(len(tokens) - 2):
if tokens[index : index + 3] == ["crate", "::", module]:
return True
for index in range(len(tokens) - 3):
if tokens[index : index + 4] != ["use", "crate", "::", "{"]:
continue
depth = 1
branch_start = True
cursor = index + 4
while cursor < len(tokens) and depth:
token = tokens[cursor]
if token == "{":
depth += 1
elif token == "}":
depth -= 1
elif depth == 1 and token == ",":
branch_start = True
elif depth == 1 and branch_start:
if token == module:
return True
branch_start = False
cursor += 1
return False
def check_dependency_boundaries(
root: Path, forbidden_dependencies: Iterable[ForbiddenDependency]
) -> list[str]:
errors: list[str] = []
for dependency in sorted(forbidden_dependencies, key=lambda entry: entry.path):
path = root / dependency.path
if not path.is_file():
errors.append(f"dependency-boundary source does not exist: {dependency.path}")
continue
source = path.read_text(encoding="utf-8")
for module in sorted(dependency.modules):
if references_crate_module(source, module):
message = f"{dependency.path}: must not depend on crate::{module}"
if dependency.reason:
message += f"; {dependency.reason}"
errors.append(message)
return errors
def check_thin_binaries(root: Path, thin_binary_budgets: dict[str, int]) -> list[str]:
errors: list[str] = []
for relative, budget in sorted(thin_binary_budgets.items()):
path = root / relative
if not path.is_file():
errors.append(f"thin-binary entry does not exist: {relative}")
continue
lines = count_lines(path)
if lines > budget:
errors.append(f"{relative}: {lines} lines exceeds thin-binary budget of {budget}")
return errors
def print_errors(errors: Iterable[str]) -> None:
for error in errors:
print(f"ERROR: {error}")
def run_checks(root: Path, config: ArchitectureConfig) -> int:
size_result = check_file_size_budgets(
root,
legacy_budgets=config.legacy_file_budgets,
generated_files=config.generated_files,
default_budget=config.default_production_line_budget,
test_file_names=config.test_file_names,
test_file_suffixes=config.test_file_suffixes,
)
errors = list(size_result.errors)
errors.extend(check_dependency_boundaries(root, config.forbidden_dependencies))
errors.extend(check_thin_binaries(root, config.thin_binary_budgets))
if errors:
print_errors(errors)
print(f"architecture checks failed with {len(errors)} error(s)")
return 1
print("architecture checks passed")
print(f" production Rust files checked: {size_result.checked_files}")
print(f" dedicated test files excluded: {size_result.excluded_test_files}")
print(f" generated Rust files excluded: {size_result.excluded_generated_files}")
print(f" legacy file-size budgets: {len(config.legacy_file_budgets)}")
print(f" dependency boundaries: {len(config.forbidden_dependencies)}")
print(f" thin binary budgets: {len(config.thin_binary_budgets)}")
return 0
class ArchitectureCheckTests(unittest.TestCase):
def test_dependency_scanner_handles_direct_and_grouped_crate_imports(self) -> None:
self.assertTrue(references_crate_module("use crate::model::Catalog;", "model"))
self.assertTrue(
references_crate_module("use crate::{provider, model::{Catalog, Model}};", "model")
)
self.assertTrue(
references_crate_module("let value = crate::model::Model::default();", "model")
)
def test_dependency_scanner_ignores_comments_literals_and_nested_items(self) -> None:
source = r'''
// use crate::model::Catalog;
const EXAMPLE: &str = "crate::model::Catalog";
const RAW: &str = r#"use crate::{model::Catalog};" use crate::{provider::{self, model}};
'''
self.assertFalse(references_crate_module(source, "model"))
def test_size_checks_exclude_tests_and_enforce_default_and_legacy_budgets(self) -> None:
with tempfile.TemporaryDirectory() as directory:
root = Path(directory)
(root / "src").mkdir()
(root / "src/lib.rs").write_text("line\n" * 4, encoding="utf-8")
(root / "src/large.rs").write_text("line\n" * 6, encoding="utf-8")
(root / "src/large_tests.rs").write_text("line\n" * 20, encoding="utf-8")
result = check_file_size_budgets(
root,
legacy_budgets={"src/large.rs": 5},
generated_files={},
default_budget=4,
)
self.assertEqual(result.checked_files, 2)
self.assertEqual(result.excluded_test_files, 1)
self.assertEqual(
result.errors,
("src/large.rs: 6 lines exceeds legacy budget of 5",),
)
def test_load_config_returns_defaults_when_file_is_absent(self) -> None:
with tempfile.TemporaryDirectory() as directory:
config = load_config(Path(directory) / "missing.json")
self.assertEqual(config, ArchitectureConfig())
def test_parse_config_reads_policy_and_forbidden_dependencies(self) -> None:
config = parse_config(
{
"default_production_line_budget": 800,
"legacy_file_budgets": {"src/big.rs": 900},
"generated_files": {"src/gen.rs": "protobuf output"},
"thin_binary_budgets": {"src/main.rs": 40},
"forbidden_dependencies": [
{"path": "src/a.rs", "modules": ["model", "web"], "reason": "keep it clean"}
],
}
)
self.assertEqual(config.default_production_line_budget, 800)
self.assertEqual(config.legacy_file_budgets, {"src/big.rs": 900})
self.assertEqual(config.thin_binary_budgets, {"src/main.rs": 40})
self.assertEqual(
config.forbidden_dependencies,
(ForbiddenDependency(path="src/a.rs", modules=("model", "web"), reason="keep it clean"),),
)
def test_parse_config_rejects_malformed_entries(self) -> None:
with self.assertRaises(ConfigError):
parse_config({"legacy_file_budgets": {"src/a.rs": "nope"}})
with self.assertRaises(ConfigError):
parse_config({"forbidden_dependencies": [{"modules": ["model"]}]})
def test_dependency_boundary_message_includes_optional_reason(self) -> None:
with tempfile.TemporaryDirectory() as directory:
root = Path(directory)
(root / "src").mkdir()
(root / "src/a.rs").write_text("use crate::model::Thing;\n", encoding="utf-8")
with_reason = check_dependency_boundaries(
root, [ForbiddenDependency("src/a.rs", ("model",), "because layering")]
)
without_reason = check_dependency_boundaries(
root, [ForbiddenDependency("src/a.rs", ("model",), "")]
)
self.assertEqual(
with_reason, ["src/a.rs: must not depend on crate::model; because layering"]
)
self.assertEqual(without_reason, ["src/a.rs: must not depend on crate::model"])
def run_self_tests() -> int:
suite = unittest.defaultTestLoader.loadTestsFromTestCase(ArchitectureCheckTests)
result = unittest.TextTestRunner(verbosity=2).run(suite)
return 0 if result.wasSuccessful() else 1
def parse_args() -> argparse.Namespace:
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument(
"--root",
type=Path,
default=repository_root(),
help="repository root to check (defaults to the script's repository)",
)
parser.add_argument(
"--config",
type=Path,
default=None,
help=(
"path to the architecture policy config "
f"(defaults to <root>/{DEFAULT_CONFIG_RELATIVE_PATH}; "
"built-in defaults are used when it is absent)"
),
)
parser.add_argument(
"--self-test",
action="store_true",
help="run deterministic scanner, config, and file-budget self-tests",
)
return parser.parse_args()
def main() -> int:
args = parse_args()
if args.self_test:
return run_self_tests()
root = args.root.resolve()
config_path = args.config if args.config is not None else root / DEFAULT_CONFIG_RELATIVE_PATH
try:
config = load_config(config_path)
except ConfigError as error:
print(f"ERROR: {error}")
return 1
return run_checks(root, config)
if __name__ == "__main__":
raise SystemExit(main())