Files
hmbown--codewhale/scripts/check-source-structure-budget.py
Hmbown 38268346f0 test: ratchet source ownership structure
Add an offline source-only budget for workspace packages, binary targets, thousand-line owned module identities, the largest owned Rust file, and aggregate owned Rust lines. Permit deletion and line-neutral consolidation while requiring explicit reviewed updates for new structural surface, then run the checker in the existing heavy CI gate.
2026-07-30 10:46:06 -07:00

329 lines
12 KiB
Python
Executable File

#!/usr/bin/env python3
"""Enforce one-way ceilings on Codewhale's source ownership structure.
The budget permits deletion and line-neutral ownership moves freely. Adding a
workspace package or binary, creating a new thousand-line production Rust
module, increasing the largest owned file, or growing aggregate owned Rust
source requires an explicit budget update and review.
"""
from __future__ import annotations
import argparse
import json
import os
import stat
import subprocess
import sys
import tempfile
from dataclasses import dataclass
from pathlib import Path
from typing import Any, Sequence
REPO_ROOT = Path(__file__).resolve().parent.parent
BUDGET_PATH = REPO_ROOT / "scripts" / "source-structure-budget.json"
DOCUMENT_KIND = "codewhale.source_structure_budget"
SCHEMA_VERSION = 1
LARGE_MODULE_THRESHOLD = 1_000
class StructureBudgetError(ValueError):
"""The budget or measured source structure is invalid."""
@dataclass(frozen=True)
class StructureSnapshot:
workspace_packages: tuple[str, ...]
binary_targets: tuple[str, ...]
module_lines: dict[str, int]
@dataclass(frozen=True)
class StructureBudget:
workspace_packages: tuple[str, ...]
binary_targets: tuple[str, ...]
allowed_large_modules: tuple[str, ...]
max_large_module_count: int
max_module_lines: int
max_total_owned_rust_lines: int
def production_rust_files(root: Path) -> list[Path]:
files = []
for path in (root / "crates").glob("*/src/**/*.rs"):
relative = path.relative_to(root)
if path.name == "tests.rs" or "tests" in relative.parts:
continue
files.append(path)
return sorted(files)
def source_line_count(path: Path) -> int:
return len(path.read_bytes().splitlines())
def cargo_metadata(root: Path) -> dict[str, Any]:
environment = os.environ.copy()
environment["CARGO_NET_OFFLINE"] = "true"
process = subprocess.run(
[
"cargo",
"metadata",
"--offline",
"--locked",
"--no-deps",
"--format-version",
"1",
],
cwd=root,
env=environment,
capture_output=True,
text=True,
check=False,
)
if process.returncode != 0:
sys.stderr.write(process.stderr)
raise StructureBudgetError(
f"cargo metadata failed with exit code {process.returncode}"
)
try:
document = json.loads(process.stdout)
except json.JSONDecodeError as error:
raise StructureBudgetError(f"cargo metadata emitted invalid JSON: {error}") from error
if not isinstance(document, dict) or not isinstance(document.get("packages"), list):
raise StructureBudgetError("cargo metadata did not contain a package list")
return document
def measure(root: Path = REPO_ROOT) -> StructureSnapshot:
metadata = cargo_metadata(root)
packages = tuple(sorted(package["name"] for package in metadata["packages"]))
binaries = tuple(
sorted(
f"{package['name']}:{target['name']}"
for package in metadata["packages"]
for target in package.get("targets", [])
if "bin" in target.get("kind", [])
)
)
module_lines = {
path.relative_to(root).as_posix(): source_line_count(path)
for path in production_rust_files(root)
}
return StructureSnapshot(packages, binaries, module_lines)
def require_string_list(document: dict[str, Any], field: str) -> tuple[str, ...]:
value = document.get(field)
if (
not isinstance(value, list)
or any(not isinstance(item, str) or not item for item in value)
or value != sorted(set(value))
):
raise StructureBudgetError(f"`{field}` must be sorted unique non-empty strings")
return tuple(value)
def require_non_negative_int(document: dict[str, Any], field: str) -> int:
value = document.get(field)
if isinstance(value, bool) or not isinstance(value, int) or value < 0:
raise StructureBudgetError(f"`{field}` must be a non-negative integer")
return value
def validate_budget(document: dict[str, Any]) -> StructureBudget:
if document.get("document_kind") != DOCUMENT_KIND:
raise StructureBudgetError(f"document_kind must be `{DOCUMENT_KIND}`")
schema_version = document.get("schema_version")
if isinstance(schema_version, bool) or schema_version != SCHEMA_VERSION:
raise StructureBudgetError(f"schema_version must be {SCHEMA_VERSION}")
threshold = document.get("large_module_threshold_lines")
if isinstance(threshold, bool) or threshold != LARGE_MODULE_THRESHOLD:
raise StructureBudgetError(
f"large_module_threshold_lines must be {LARGE_MODULE_THRESHOLD}"
)
allowed_large_modules = require_string_list(document, "allowed_large_modules")
for path in allowed_large_modules:
if (
not path.startswith("crates/")
or Path(path).is_absolute()
or ".." in Path(path).parts
):
raise StructureBudgetError(f"invalid large-module path: {path!r}")
return StructureBudget(
require_string_list(document, "workspace_packages"),
require_string_list(document, "binary_targets"),
allowed_large_modules,
require_non_negative_int(document, "max_large_module_count"),
require_non_negative_int(document, "max_module_lines"),
require_non_negative_int(document, "max_total_owned_rust_lines"),
)
def load_budget(path: Path) -> tuple[dict[str, Any], StructureBudget]:
try:
document = json.loads(path.read_text(encoding="utf-8"))
except FileNotFoundError as error:
raise StructureBudgetError(f"missing source-structure budget: {path}") from error
except (OSError, json.JSONDecodeError) as error:
raise StructureBudgetError(f"invalid source-structure budget {path}: {error}") from error
if not isinstance(document, dict):
raise StructureBudgetError("source-structure budget must be a JSON object")
return document, validate_budget(document)
def compare(current: StructureSnapshot, budget: StructureBudget) -> tuple[list[str], list[str]]:
failures: list[str] = []
improvements: list[str] = []
added_packages = sorted(set(current.workspace_packages) - set(budget.workspace_packages))
if added_packages:
failures.append(f"workspace packages added: {', '.join(added_packages)}")
removed_packages = sorted(set(budget.workspace_packages) - set(current.workspace_packages))
if removed_packages:
improvements.append(f"workspace packages removed: {', '.join(removed_packages)}")
added_binaries = sorted(set(current.binary_targets) - set(budget.binary_targets))
if added_binaries:
failures.append(f"binary targets added: {', '.join(added_binaries)}")
removed_binaries = sorted(set(budget.binary_targets) - set(current.binary_targets))
if removed_binaries:
improvements.append(f"binary targets removed: {', '.join(removed_binaries)}")
current_large = {
path for path, lines in current.module_lines.items() if lines >= LARGE_MODULE_THRESHOLD
}
allowed_large = set(budget.allowed_large_modules)
for path in sorted(current_large - allowed_large):
failures.append(
f"new thousand-line production module: {path} has "
f"{current.module_lines[path]} lines"
)
removed_large = sorted(allowed_large - current_large)
if removed_large:
improvements.append(
"large modules removed or split below "
f"{LARGE_MODULE_THRESHOLD}: {', '.join(removed_large)}"
)
large_count = len(current_large)
if large_count > budget.max_large_module_count:
failures.append(
f"large-module count grew: {large_count} > {budget.max_large_module_count}"
)
elif large_count < budget.max_large_module_count:
improvements.append(
f"large-module count shrank: {large_count} < {budget.max_large_module_count}"
)
largest = max(current.module_lines.values(), default=0)
if largest > budget.max_module_lines:
failures.append(f"largest module grew: {largest} > {budget.max_module_lines} lines")
elif largest < budget.max_module_lines:
improvements.append(f"largest module shrank: {largest} < {budget.max_module_lines} lines")
total = sum(current.module_lines.values())
if total > budget.max_total_owned_rust_lines:
failures.append(
"aggregate owned Rust source grew: "
f"{total} > {budget.max_total_owned_rust_lines} lines"
)
elif total < budget.max_total_owned_rust_lines:
improvements.append(
"aggregate owned Rust source shrank: "
f"{total} < {budget.max_total_owned_rust_lines} lines"
)
return failures, improvements
def budget_document(snapshot: StructureSnapshot) -> dict[str, Any]:
large_modules = sorted(
path for path, lines in snapshot.module_lines.items() if lines >= LARGE_MODULE_THRESHOLD
)
return {
"_comment": (
"One-way source ownership ceilings. Deletion and line-neutral ownership moves "
"pass; new packages, binaries, 1000-line module paths, a larger maximum module, "
"or aggregate owned Rust growth require an explicit reviewed update."
),
"allowed_large_modules": large_modules,
"binary_targets": list(snapshot.binary_targets),
"document_kind": DOCUMENT_KIND,
"large_module_threshold_lines": LARGE_MODULE_THRESHOLD,
"max_large_module_count": len(large_modules),
"max_module_lines": max(snapshot.module_lines.values(), default=0),
"max_total_owned_rust_lines": sum(snapshot.module_lines.values()),
"schema_version": SCHEMA_VERSION,
"workspace_packages": list(snapshot.workspace_packages),
}
def write_json_atomic(path: Path, document: dict[str, Any]) -> None:
mode = stat.S_IMODE(path.stat().st_mode) if path.exists() else 0o644
path.parent.mkdir(parents=True, exist_ok=True)
descriptor, temporary_name = tempfile.mkstemp(
prefix=f".{path.name}.", suffix=".tmp", dir=path.parent
)
temporary = Path(temporary_name)
try:
with os.fdopen(descriptor, "w", encoding="utf-8") as handle:
json.dump(document, handle, indent=2, sort_keys=True)
handle.write("\n")
handle.flush()
os.fsync(handle.fileno())
os.chmod(temporary, mode)
os.replace(temporary, path)
except BaseException:
temporary.unlink(missing_ok=True)
raise
def main(argv: Sequence[str] | None = None) -> int:
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument("--budget", type=Path, default=BUDGET_PATH, help=argparse.SUPPRESS)
parser.add_argument("--update", action="store_true", help="lock in current improvements")
args = parser.parse_args(argv)
try:
current = measure()
if args.update and not args.budget.exists():
write_json_atomic(args.budget, budget_document(current))
print(f"[source-structure-budget] initialized {args.budget}")
return 0
_document, budget = load_budget(args.budget)
failures, improvements = compare(current, budget)
except (OSError, StructureBudgetError) as error:
print(f"[source-structure-budget] ERROR: {error}", file=sys.stderr)
return 2
if failures:
print("[source-structure-budget] FAIL:", file=sys.stderr)
for failure in failures:
print(f" {failure}", file=sys.stderr)
return 1
if args.update:
try:
write_json_atomic(args.budget, budget_document(current))
except OSError as error:
print(f"[source-structure-budget] ERROR: {error}", file=sys.stderr)
return 2
print(
f"[source-structure-budget] tightened {args.budget}: "
f"{len(improvements)} improvement(s) locked in"
)
return 0
print(
"[source-structure-budget] PASS: "
f"{len(current.workspace_packages)} packages, "
f"{len(current.binary_targets)} binaries, "
f"{sum(lines >= LARGE_MODULE_THRESHOLD for lines in current.module_lines.values())} "
"large owned modules, "
f"{sum(current.module_lines.values())} owned Rust lines"
)
for improvement in improvements:
print(f" can tighten: {improvement}")
return 0
if __name__ == "__main__":
raise SystemExit(main())