| 1 | #!/usr/bin/env python3 |
| 2 | """Deterministic crate-boundary gate (FEAT-014, runtime split RS-0). |
| 3 | |
| 4 | The gate is table-driven: `BOUNDARY_RULES` names each guarded package, how its |
| 5 | dependency graph is read (`metadata` or `tree`, see below), the packages it |
| 6 | may never reach, and the source scan for its crate. The runtime/TUI split adds |
| 7 | the runtime -> UI reference ratchet (`scripts/split/module_graph.py`). |
| 8 | |
| 9 | Dependency modes: |
| 10 | |
| 11 | * ``metadata`` reads `cargo metadata --no-deps` and walks normal edges between |
| 12 | workspace packages. Cheap and exact for "never reach this workspace crate". |
| 13 | * ``tree`` runs `cargo tree -p <package> -e normal,build --prefix none`, which |
| 14 | resolves features for that package alone. `cargo metadata` unifies features |
| 15 | across the workspace, so a rule like "the runtime never reaches ratatui" |
| 16 | must use this mode once the TUI enables palette's `ratatui` feature. |
| 17 | |
| 18 | Enforces the EPIC-006 boundary contract: |
| 19 | |
| 20 | 1. `codewhale-command-contract` and `codewhale-secrets` may not transitively |
| 21 | depend on `codewhale-tui` (normal edges, via `cargo metadata`). |
| 22 | Pure portable sanitization lives in `codewhale-sanitize`; contract, protocol |
| 23 | and sanitizer also reject runtime and storage dependencies through tree rules. |
| 24 | 2. `codewhale-command-contract` source may not import the concrete `App`, |
| 25 | widget/renderer/view/event-loop surfaces, or `ratatui`/`crossterm`. |
| 26 | 3. No composite `CommandContext` symbol (supertrait/struct/enum) may exist in |
| 27 | the contract — the deep-dive D2 "no super-context" rule. |
| 28 | 4. No boxed handler storage (`Box<`) in the contract — the D1/D4 fn-pointer |
| 29 | transport rule. |
| 30 | |
| 31 | The guard is hermetic: it reads `cargo metadata` and the contract source only; |
| 32 | it never starts the TUI and makes no network calls. |
| 33 | |
| 34 | Usage: |
| 35 | python3 scripts/check-command-crate-boundaries.py # enforce |
| 36 | python3 scripts/check-command-crate-boundaries.py --check # enforce (default) |
| 37 | """ |
| 38 | |
| 39 | from __future__ import annotations |
| 40 | |
| 41 | import argparse |
| 42 | import importlib.util |
| 43 | import json |
| 44 | import re |
| 45 | import subprocess |
| 46 | import sys |
| 47 | from dataclasses import dataclass |
| 48 | from pathlib import Path |
| 49 | from typing import Callable |
| 50 | |
| 51 | REPO_ROOT = Path(__file__).resolve().parent.parent |
| 52 | CONTRACT_DIR = REPO_ROOT / "crates" / "command-contract" / "src" |
| 53 | CONTRACT_PACKAGE = "codewhale-command-contract" |
| 54 | FORBIDDEN_TUI_PACKAGE = "codewhale-tui" |
| 55 | |
| 56 | |
| 57 | @dataclass(frozen=True) |
| 58 | class BoundaryRule: |
| 59 | """One guarded package: how to read its graph and what it may not reach.""" |
| 60 | |
| 61 | package: str |
| 62 | mode: str # "metadata" or "tree" |
| 63 | forbidden_packages: tuple[str, ...] |
| 64 | reason: str |
| 65 | source_dir: Path | None = None |
| 66 | source_scan: Callable[[str, str], list] | None = None |
| 67 | |
| 68 | |
| 69 | RUNTIME_PACKAGE = "codewhale-runtime" |
| 70 | RUNTIME_DIR = REPO_ROOT / "crates" / "runtime" / "src" |
| 71 | RUNTIME_FORBIDDEN_PACKAGES = ( |
| 72 | "codewhale-tui", |
| 73 | "codewhale-cli", |
| 74 | "ratatui", |
| 75 | "ratatui-core", |
| 76 | "ratatui-widgets", |
| 77 | "crossterm", |
| 78 | "ansi-to-tui", |
| 79 | "codewhale-tui-kit", |
| 80 | "codewhale-ratatui", |
| 81 | ) |
| 82 | RUNTIME_FORBIDDEN_SOURCE = [ |
| 83 | (re.compile(r"\b(ratatui|crossterm|codewhale_tui)::"), "terminal UI path"), |
| 84 | (re.compile(r"^\s*(pub\s+)?use\s+(ratatui|crossterm|codewhale_tui)\b"), "terminal UI import"), |
| 85 | (re.compile(r"include_(str|bytes)!\(\s*\"[^\"]*\.\./tui/"), "include reaching into crates/tui"), |
| 86 | ] |
| 87 | |
| 88 | # Filled in below once the scan functions exist. |
| 89 | BOUNDARY_RULES: tuple[BoundaryRule, ...] = () |
| 90 | |
| 91 | # Import lines that must never appear in the contract (narrowly scoped: real |
| 92 | # imports only, comments never match because they do not start with `use`). |
| 93 | FORBIDDEN_IMPORT_PATTERNS = [ |
| 94 | (re.compile(r"^\s*(pub\s+)?use\s+codewhale_tui\b"), "codewhale-tui import"), |
| 95 | (re.compile(r"^\s*(pub\s+)?use\s+ratatui\b"), "ratatui (widget) import"), |
| 96 | (re.compile(r"^\s*(pub\s+)?use\s+crossterm\b"), "crossterm (terminal) import"), |
| 97 | (re.compile(r"^\s*(pub\s+)?use\s+.*\bApp\b"), "concrete App import"), |
| 98 | (re.compile(r"^\s*(pub\s+)?use\s+.*\bBuffer\b"), "render buffer import"), |
| 99 | (re.compile(r"^\s*(pub\s+)?use\s+.*\bWidget\b"), "widget import"), |
| 100 | (re.compile(r"^\s*(pub\s+)?use\s+.*\bViewStack\b"), "view-stack import"), |
| 101 | (re.compile(r"^\s*(pub\s+)?use\s+.*\bEventLoop\b"), "event-loop import"), |
| 102 | ] |
| 103 | |
| 104 | # Composite super-context symbols (D2: exactly `CommandContext`, not the |
| 105 | # plural envelope `CommandContexts` nor facet names like `CommandModelContext`). |
| 106 | COMPOSITE_SYMBOL_PATTERN = re.compile( |
| 107 | r"^\s*(pub\s+)?(trait|struct|enum)\s+CommandContext\b" |
| 108 | ) |
| 109 | # Boxed handler/closure storage (D1: fn pointers only). |
| 110 | BOXED_STORAGE_PATTERN = re.compile(r"\bBox\s*<") |
| 111 | |
| 112 | |
| 113 | class BoundaryViolation: |
| 114 | """One deterministic boundary violation with an actionable diagnostic.""" |
| 115 | |
| 116 | def __init__(self, category: str, location: str, detail: str) -> None: |
| 117 | self.category = category |
| 118 | self.location = location |
| 119 | self.detail = detail |
| 120 | |
| 121 | def __str__(self) -> str: |
| 122 | return f"{self.category}: {self.location}: {self.detail}" |
| 123 | |
| 124 | |
| 125 | def load_workspace_metadata() -> dict: |
| 126 | """Load the locked workspace dependency graph via cargo metadata.""" |
| 127 | result = subprocess.run( |
| 128 | [ |
| 129 | "cargo", |
| 130 | "metadata", |
| 131 | "--format-version", |
| 132 | "1", |
| 133 | "--locked", |
| 134 | "--no-deps", |
| 135 | ], |
| 136 | cwd=REPO_ROOT, |
| 137 | capture_output=True, |
| 138 | text=True, |
| 139 | check=True, |
| 140 | ) |
| 141 | return json.loads(result.stdout) |
| 142 | |
| 143 | |
| 144 | def dependency_graph(metadata: dict) -> dict[str, set[str]]: |
| 145 | """Map package name -> set of direct NORMAL dependency package names. |
| 146 | |
| 147 | Dev- and build-dependencies are excluded: the gate contract checks normal |
| 148 | transitive edges (a dev-dependency on the TUI, e.g. for acceptance |
| 149 | harnesses, must not trip the boundary). |
| 150 | """ |
| 151 | graph: dict[str, set[str]] = {} |
| 152 | for package in metadata["packages"]: |
| 153 | deps = set() |
| 154 | for dep in package.get("dependencies", []): |
| 155 | # kind is None for normal dependencies, "dev" or "build" otherwise. |
| 156 | if dep.get("kind") is not None: |
| 157 | continue |
| 158 | name = dep.get("name") |
| 159 | if name: |
| 160 | deps.add(name) |
| 161 | graph[package["name"]] = deps |
| 162 | return graph |
| 163 | |
| 164 | |
| 165 | def reaches(package: str, forbidden: set[str], graph: dict[str, set[str]]) -> str | None: |
| 166 | """The first forbidden package `package` transitively reaches, if any.""" |
| 167 | seen: set[str] = set() |
| 168 | stack = list(graph.get(package, set())) |
| 169 | while stack: |
| 170 | name = stack.pop() |
| 171 | if name in forbidden: |
| 172 | return name |
| 173 | if name in seen: |
| 174 | continue |
| 175 | seen.add(name) |
| 176 | stack.extend(graph.get(name, set())) |
| 177 | return None |
| 178 | |
| 179 | |
| 180 | def reaches_tui(package: str, graph: dict[str, set[str]]) -> bool: |
| 181 | """Whether `package` transitively reaches the forbidden TUI package.""" |
| 182 | return reaches(package, {FORBIDDEN_TUI_PACKAGE}, graph) is not None |
| 183 | |
| 184 | |
| 185 | def metadata_rules() -> tuple[BoundaryRule, ...]: |
| 186 | return tuple(rule for rule in BOUNDARY_RULES if rule.mode == "metadata") |
| 187 | |
| 188 | |
| 189 | def tree_rules() -> tuple[BoundaryRule, ...]: |
| 190 | return tuple(rule for rule in BOUNDARY_RULES if rule.mode == "tree") |
| 191 | |
| 192 | |
| 193 | def check_dependency_graph(graph: dict[str, set[str]]) -> list[BoundaryViolation]: |
| 194 | """No metadata-mode package may reach a forbidden package through normal edges.""" |
| 195 | violations: list[BoundaryViolation] = [] |
| 196 | for rule in metadata_rules(): |
| 197 | package = rule.package |
| 198 | if package not in graph: |
| 199 | violations.append( |
| 200 | BoundaryViolation( |
| 201 | "dependency-graph", |
| 202 | package, |
| 203 | "workspace package missing from the cargo metadata graph", |
| 204 | ) |
| 205 | ) |
| 206 | continue |
| 207 | hit = reaches(package, set(rule.forbidden_packages), graph) |
| 208 | if hit: |
| 209 | violations.append( |
| 210 | BoundaryViolation( |
| 211 | "dependency-graph", |
| 212 | package, |
| 213 | f"transitively depends on {hit} ({rule.reason})", |
| 214 | ) |
| 215 | ) |
| 216 | return violations |
| 217 | |
| 218 | |
| 219 | def cargo_tree_packages(package: str) -> set[str]: |
| 220 | """Package names in `cargo tree -p <package> -e normal,build` (per-package features).""" |
| 221 | result = subprocess.run( |
| 222 | # `--target all`: a dependency behind `cfg(windows)` counts too, not |
| 223 | # only the ones the host target resolves. |
| 224 | [ |
| 225 | "cargo", "tree", "-p", package, "-e", "normal,build", "--target", "all", |
| 226 | "--prefix", "none", "--locked", |
| 227 | ], |
| 228 | cwd=REPO_ROOT, |
| 229 | capture_output=True, |
| 230 | text=True, |
| 231 | check=True, |
| 232 | ) |
| 233 | return parse_cargo_tree(result.stdout) |
| 234 | |
| 235 | |
| 236 | def parse_cargo_tree(text: str) -> set[str]: |
| 237 | names: set[str] = set() |
| 238 | for line in text.splitlines(): |
| 239 | parts = line.split() |
| 240 | if parts: |
| 241 | names.add(parts[0]) |
| 242 | return names |
| 243 | |
| 244 | |
| 245 | def check_tree_packages(rule: BoundaryRule, names: set[str]) -> list[BoundaryViolation]: |
| 246 | """A tree-mode package's resolved dependency set must avoid its forbidden list.""" |
| 247 | return [ |
| 248 | BoundaryViolation( |
| 249 | "dependency-tree", |
| 250 | rule.package, |
| 251 | f"cargo tree reaches {name} ({rule.reason})", |
| 252 | ) |
| 253 | for name in sorted(names & set(rule.forbidden_packages)) |
| 254 | ] |
| 255 | |
| 256 | |
| 257 | def check_contract_source_text(text: str, display_path: str) -> list[BoundaryViolation]: |
| 258 | """Scan one source text for forbidden imports/symbols (hermetic test hook).""" |
| 259 | violations: list[BoundaryViolation] = [] |
| 260 | for line_no, line in enumerate(text.splitlines(), start=1): |
| 261 | stripped = line.strip() |
| 262 | for pattern, label in FORBIDDEN_IMPORT_PATTERNS: |
| 263 | if pattern.match(stripped): |
| 264 | violations.append( |
| 265 | BoundaryViolation( |
| 266 | "source-scan", |
| 267 | f"{display_path}:{line_no}", |
| 268 | f"forbidden {label}: {stripped}", |
| 269 | ) |
| 270 | ) |
| 271 | if COMPOSITE_SYMBOL_PATTERN.match(stripped): |
| 272 | violations.append( |
| 273 | BoundaryViolation( |
| 274 | "source-scan", |
| 275 | f"{display_path}:{line_no}", |
| 276 | f"composite CommandContext symbol (D2 forbids super-contexts): {stripped}", |
| 277 | ) |
| 278 | ) |
| 279 | if BOXED_STORAGE_PATTERN.search(stripped): |
| 280 | violations.append( |
| 281 | BoundaryViolation( |
| 282 | "source-scan", |
| 283 | f"{display_path}:{line_no}", |
| 284 | f"boxed storage in the contract (D1 requires fn pointers): {stripped}", |
| 285 | ) |
| 286 | ) |
| 287 | return violations |
| 288 | |
| 289 | |
| 290 | def check_source_dir(rule: BoundaryRule) -> list[BoundaryViolation]: |
| 291 | """Scan one rule's production source for forbidden imports and symbols.""" |
| 292 | assert rule.source_dir is not None and rule.source_scan is not None |
| 293 | if not rule.source_dir.is_dir(): |
| 294 | return [ |
| 295 | BoundaryViolation( |
| 296 | "source-scan", |
| 297 | str(rule.source_dir), |
| 298 | f"{rule.package} src directory missing", |
| 299 | ) |
| 300 | ] |
| 301 | violations: list[BoundaryViolation] = [] |
| 302 | for path in sorted(rule.source_dir.rglob("*.rs")): |
| 303 | text = path.read_text(encoding="utf-8") |
| 304 | rel = path.relative_to(REPO_ROOT) |
| 305 | violations.extend(rule.source_scan(text, str(rel))) |
| 306 | return violations |
| 307 | |
| 308 | |
| 309 | def check_runtime_source_text(text: str, display_path: str) -> list[BoundaryViolation]: |
| 310 | """Runtime source may not name a terminal UI crate (comments ignored).""" |
| 311 | violations: list[BoundaryViolation] = [] |
| 312 | for line_no, line in enumerate(text.splitlines(), start=1): |
| 313 | code = line.split("//", 1)[0] |
| 314 | if not code.strip(): |
| 315 | continue |
| 316 | for pattern, label in RUNTIME_FORBIDDEN_SOURCE: |
| 317 | if pattern.search(code): |
| 318 | violations.append( |
| 319 | BoundaryViolation( |
| 320 | "source-scan", |
| 321 | f"{display_path}:{line_no}", |
| 322 | f"forbidden {label} in codewhale-runtime: {line.strip()}", |
| 323 | ) |
| 324 | ) |
| 325 | return violations |
| 326 | |
| 327 | |
| 328 | def check_contract_source() -> list[BoundaryViolation]: |
| 329 | """Scan contract production source for forbidden imports and symbols.""" |
| 330 | return check_source_dir(next(r for r in BOUNDARY_RULES if r.package == CONTRACT_PACKAGE)) |
| 331 | |
| 332 | |
| 333 | def load_runtime_ratchet(): |
| 334 | """Import scripts/split/module_graph.py (the runtime -> UI ratchet).""" |
| 335 | path = REPO_ROOT / "scripts" / "split" / "module_graph.py" |
| 336 | spec = importlib.util.spec_from_file_location("runtime_module_graph", path) |
| 337 | assert spec and spec.loader |
| 338 | module = importlib.util.module_from_spec(spec) |
| 339 | sys.modules[spec.name] = module |
| 340 | spec.loader.exec_module(module) |
| 341 | return module |
| 342 | |
| 343 | |
| 344 | def check_runtime_ratchet(baseline_ref: str | None = None) -> list[BoundaryViolation]: |
| 345 | """Runtime -> UI references may only go down (docs/design/TUI_DECONSTRUCTION.md, runtime split).""" |
| 346 | problems = load_runtime_ratchet().check(baseline_ref=baseline_ref) |
| 347 | return [BoundaryViolation("runtime-ratchet", "scripts/runtime-boundary-baseline.json", p) for p in problems] |
| 348 | |
| 349 | |
| 350 | def run_checks( |
| 351 | metadata: dict | None = None, |
| 352 | tree: Callable[[str], set[str]] | None = None, |
| 353 | ratchet: bool = True, |
| 354 | baseline_ref: str | None = None, |
| 355 | ) -> list[BoundaryViolation]: |
| 356 | """Run all boundary checks; return the collected violations.""" |
| 357 | graph = dependency_graph(metadata) if metadata is not None else dependency_graph( |
| 358 | load_workspace_metadata() |
| 359 | ) |
| 360 | violations = check_dependency_graph(graph) |
| 361 | tree = tree or cargo_tree_packages |
| 362 | for rule in tree_rules(): |
| 363 | violations.extend(check_tree_packages(rule, tree(rule.package))) |
| 364 | for rule in BOUNDARY_RULES: |
| 365 | if rule.source_dir is not None: |
| 366 | violations.extend(check_source_dir(rule)) |
| 367 | if ratchet: |
| 368 | violations.extend(check_runtime_ratchet(baseline_ref)) |
| 369 | return violations |
| 370 | |
| 371 | |
| 372 | def main(argv: list[str] | None = None) -> int: |
| 373 | parser = argparse.ArgumentParser(description=(__doc__ or "").split("\n\n")[0]) |
| 374 | parser.add_argument( |
| 375 | "--baseline-ref", |
| 376 | help="git revision (CI: the PR base) whose runtime ratchet baseline may not be exceeded", |
| 377 | ) |
| 378 | args = parser.parse_args(argv) |
| 379 | violations = run_checks(baseline_ref=args.baseline_ref) |
| 380 | if violations: |
| 381 | print("[command-crate-boundaries] FAIL", file=sys.stderr) |
| 382 | for violation in violations: |
| 383 | print(f" {violation}", file=sys.stderr) |
| 384 | return 1 |
| 385 | print( |
| 386 | f"[command-crate-boundaries] PASS: " |
| 387 | f"{', '.join(rule.package for rule in BOUNDARY_RULES)} avoid their forbidden " |
| 388 | "packages; no forbidden import, composite context, or boxed handler in the " |
| 389 | "contract; runtime -> UI ratchet holds" |
| 390 | ) |
| 391 | return 0 |
| 392 | |
| 393 | |
| 394 | BOUNDARY_RULES = ( |
| 395 | # The contract carries the portable command shapes (EPIC-006). |
| 396 | BoundaryRule( |
| 397 | CONTRACT_PACKAGE, |
| 398 | "metadata", |
| 399 | (FORBIDDEN_TUI_PACKAGE,), |
| 400 | "the command contract must stay UI-free", |
| 401 | CONTRACT_DIR, |
| 402 | check_contract_source_text, |
| 403 | ), |
| 404 | # Storage remains UI-free; portable callers use codewhale-sanitize directly. |
| 405 | BoundaryRule( |
| 406 | "codewhale-secrets", |
| 407 | "metadata", |
| 408 | (FORBIDDEN_TUI_PACKAGE,), |
| 409 | "secret storage must stay UI-free", |
| 410 | ), |
| 411 | *(BoundaryRule( |
| 412 | package, "tree", |
| 413 | ("codewhale-tui", "codewhale-core", "codewhale-runtime", "codewhale-config", |
| 414 | "codewhale-state", "codewhale-mcp", "codewhale-hooks", "codewhale-secrets", |
| 415 | "reqwest", "tokio", "rusqlite", "keyring", "dbus", "zbus", "ratatui", "crossterm"), |
| 416 | "portable shapes and sanitization must not import host services", |
| 417 | ) for package in ("codewhale-command-contract", "codewhale-protocol", "codewhale-sanitize")), |
| 418 | # The headless runtime split out of the TUI (docs/design/TUI_DECONSTRUCTION.md): |
| 419 | # never a terminal UI crate or library, checked with per-package feature |
| 420 | # resolution because the TUI turns on palette's `ratatui` feature. |
| 421 | BoundaryRule( |
| 422 | RUNTIME_PACKAGE, |
| 423 | "tree", |
| 424 | RUNTIME_FORBIDDEN_PACKAGES, |
| 425 | "the runtime must not link terminal UI code", |
| 426 | RUNTIME_DIR, |
| 427 | check_runtime_source_text, |
| 428 | ), |
| 429 | ) |
| 430 | TUI_FREE_PACKAGES = tuple(rule.package for rule in metadata_rules()) |
| 431 | |
| 432 | |
| 433 | if __name__ == "__main__": |
| 434 | sys.exit(main()) |
| 435 |