| 1 | #!/usr/bin/env python3 |
| 2 | """Deterministic crate-boundary gate for the command extraction (FEAT-014). |
| 3 | |
| 4 | Enforces the EPIC-006 boundary contract: |
| 5 | |
| 6 | 1. `codewhale-command-contract` and `codewhale-secrets` may not transitively |
| 7 | depend on `codewhale-tui` (normal edges, via `cargo metadata`). |
| 8 | `codewhale-secrets` owns the shared output sanitizer that portable command |
| 9 | helpers consume (FEAT-025 D4), so it must stay TUI-free before |
| 10 | `codewhale-commands` depends on it. |
| 11 | 2. `codewhale-command-contract` source may not import the concrete `App`, |
| 12 | widget/renderer/view/event-loop surfaces, or `ratatui`/`crossterm`. |
| 13 | 3. No composite `CommandContext` symbol (supertrait/struct/enum) may exist in |
| 14 | the contract — the deep-dive D2 "no super-context" rule. |
| 15 | 4. No boxed handler storage (`Box<`) in the contract — the D1/D4 fn-pointer |
| 16 | transport rule. |
| 17 | |
| 18 | The guard is hermetic: it reads `cargo metadata` and the contract source only; |
| 19 | it never starts the TUI and makes no network calls. |
| 20 | |
| 21 | Usage: |
| 22 | python3 scripts/check-command-crate-boundaries.py # enforce |
| 23 | python3 scripts/check-command-crate-boundaries.py --check # enforce (default) |
| 24 | """ |
| 25 | |
| 26 | from __future__ import annotations |
| 27 | |
| 28 | import json |
| 29 | import re |
| 30 | import subprocess |
| 31 | import sys |
| 32 | from pathlib import Path |
| 33 | |
| 34 | REPO_ROOT = Path(__file__).resolve().parent.parent |
| 35 | CONTRACT_DIR = REPO_ROOT / "crates" / "command-contract" / "src" |
| 36 | CONTRACT_PACKAGE = "codewhale-command-contract" |
| 37 | FORBIDDEN_TUI_PACKAGE = "codewhale-tui" |
| 38 | |
| 39 | # Workspace packages that must stay free of any (normal) path to the TUI. The |
| 40 | # contract carries the portable shapes; `codewhale-secrets` owns the shared pure |
| 41 | # sanitizer those shapes' handlers consume. Both are prerequisites for |
| 42 | # `codewhale-commands` (FEAT-016/043), so a TUI edge here would silently drag |
| 43 | # the whole TUI into the extracted command crate. |
| 44 | TUI_FREE_PACKAGES = (CONTRACT_PACKAGE, "codewhale-secrets") |
| 45 | |
| 46 | # Import lines that must never appear in the contract (narrowly scoped: real |
| 47 | # imports only, comments never match because they do not start with `use`). |
| 48 | FORBIDDEN_IMPORT_PATTERNS = [ |
| 49 | (re.compile(r"^\s*(pub\s+)?use\s+codewhale_tui\b"), "codewhale-tui import"), |
| 50 | (re.compile(r"^\s*(pub\s+)?use\s+ratatui\b"), "ratatui (widget) import"), |
| 51 | (re.compile(r"^\s*(pub\s+)?use\s+crossterm\b"), "crossterm (terminal) import"), |
| 52 | (re.compile(r"^\s*(pub\s+)?use\s+.*\bApp\b"), "concrete App import"), |
| 53 | (re.compile(r"^\s*(pub\s+)?use\s+.*\bBuffer\b"), "render buffer import"), |
| 54 | (re.compile(r"^\s*(pub\s+)?use\s+.*\bWidget\b"), "widget import"), |
| 55 | (re.compile(r"^\s*(pub\s+)?use\s+.*\bViewStack\b"), "view-stack import"), |
| 56 | (re.compile(r"^\s*(pub\s+)?use\s+.*\bEventLoop\b"), "event-loop import"), |
| 57 | ] |
| 58 | |
| 59 | # Composite super-context symbols (D2: exactly `CommandContext`, not the |
| 60 | # plural envelope `CommandContexts` nor facet names like `CommandModelContext`). |
| 61 | COMPOSITE_SYMBOL_PATTERN = re.compile( |
| 62 | r"^\s*(pub\s+)?(trait|struct|enum)\s+CommandContext\b" |
| 63 | ) |
| 64 | # Boxed handler/closure storage (D1: fn pointers only). |
| 65 | BOXED_STORAGE_PATTERN = re.compile(r"\bBox\s*<") |
| 66 | |
| 67 | |
| 68 | class BoundaryViolation: |
| 69 | """One deterministic boundary violation with an actionable diagnostic.""" |
| 70 | |
| 71 | def __init__(self, category: str, location: str, detail: str) -> None: |
| 72 | self.category = category |
| 73 | self.location = location |
| 74 | self.detail = detail |
| 75 | |
| 76 | def __str__(self) -> str: |
| 77 | return f"{self.category}: {self.location}: {self.detail}" |
| 78 | |
| 79 | |
| 80 | def load_workspace_metadata() -> dict: |
| 81 | """Load the locked workspace dependency graph via cargo metadata.""" |
| 82 | result = subprocess.run( |
| 83 | [ |
| 84 | "cargo", |
| 85 | "metadata", |
| 86 | "--format-version", |
| 87 | "1", |
| 88 | "--locked", |
| 89 | "--no-deps", |
| 90 | ], |
| 91 | cwd=REPO_ROOT, |
| 92 | capture_output=True, |
| 93 | text=True, |
| 94 | check=True, |
| 95 | ) |
| 96 | return json.loads(result.stdout) |
| 97 | |
| 98 | |
| 99 | def dependency_graph(metadata: dict) -> dict[str, set[str]]: |
| 100 | """Map package name -> set of direct NORMAL dependency package names. |
| 101 | |
| 102 | Dev- and build-dependencies are excluded: the gate contract checks normal |
| 103 | transitive edges (a dev-dependency on the TUI, e.g. for acceptance |
| 104 | harnesses, must not trip the boundary). |
| 105 | """ |
| 106 | graph: dict[str, set[str]] = {} |
| 107 | for package in metadata["packages"]: |
| 108 | deps = set() |
| 109 | for dep in package.get("dependencies", []): |
| 110 | # kind is None for normal dependencies, "dev" or "build" otherwise. |
| 111 | if dep.get("kind") is not None: |
| 112 | continue |
| 113 | name = dep.get("name") |
| 114 | if name: |
| 115 | deps.add(name) |
| 116 | graph[package["name"]] = deps |
| 117 | return graph |
| 118 | |
| 119 | |
| 120 | def reaches_tui(package: str, graph: dict[str, set[str]]) -> bool: |
| 121 | """Whether `package` transitively reaches the forbidden TUI package.""" |
| 122 | seen: set[str] = set() |
| 123 | stack = list(graph.get(package, set())) |
| 124 | while stack: |
| 125 | name = stack.pop() |
| 126 | if name == FORBIDDEN_TUI_PACKAGE: |
| 127 | return True |
| 128 | if name in seen: |
| 129 | continue |
| 130 | seen.add(name) |
| 131 | stack.extend(graph.get(name, set())) |
| 132 | return False |
| 133 | |
| 134 | |
| 135 | def check_dependency_graph(graph: dict[str, set[str]]) -> list[BoundaryViolation]: |
| 136 | """No TUI-free package may reach codewhale-tui through normal edges.""" |
| 137 | violations: list[BoundaryViolation] = [] |
| 138 | for package in TUI_FREE_PACKAGES: |
| 139 | if package not in graph: |
| 140 | violations.append( |
| 141 | BoundaryViolation( |
| 142 | "dependency-graph", |
| 143 | package, |
| 144 | "workspace package missing from the cargo metadata graph", |
| 145 | ) |
| 146 | ) |
| 147 | continue |
| 148 | if reaches_tui(package, graph): |
| 149 | violations.append( |
| 150 | BoundaryViolation( |
| 151 | "dependency-graph", |
| 152 | package, |
| 153 | f"transitively depends on {FORBIDDEN_TUI_PACKAGE}", |
| 154 | ) |
| 155 | ) |
| 156 | return violations |
| 157 | |
| 158 | |
| 159 | def check_contract_source_text(text: str, display_path: str) -> list[BoundaryViolation]: |
| 160 | """Scan one source text for forbidden imports/symbols (hermetic test hook).""" |
| 161 | violations: list[BoundaryViolation] = [] |
| 162 | for line_no, line in enumerate(text.splitlines(), start=1): |
| 163 | stripped = line.strip() |
| 164 | for pattern, label in FORBIDDEN_IMPORT_PATTERNS: |
| 165 | if pattern.match(stripped): |
| 166 | violations.append( |
| 167 | BoundaryViolation( |
| 168 | "source-scan", |
| 169 | f"{display_path}:{line_no}", |
| 170 | f"forbidden {label}: {stripped}", |
| 171 | ) |
| 172 | ) |
| 173 | if COMPOSITE_SYMBOL_PATTERN.match(stripped): |
| 174 | violations.append( |
| 175 | BoundaryViolation( |
| 176 | "source-scan", |
| 177 | f"{display_path}:{line_no}", |
| 178 | f"composite CommandContext symbol (D2 forbids super-contexts): {stripped}", |
| 179 | ) |
| 180 | ) |
| 181 | if BOXED_STORAGE_PATTERN.search(stripped): |
| 182 | violations.append( |
| 183 | BoundaryViolation( |
| 184 | "source-scan", |
| 185 | f"{display_path}:{line_no}", |
| 186 | f"boxed storage in the contract (D1 requires fn pointers): {stripped}", |
| 187 | ) |
| 188 | ) |
| 189 | return violations |
| 190 | |
| 191 | |
| 192 | def check_contract_source() -> list[BoundaryViolation]: |
| 193 | """Scan contract production source for forbidden imports and symbols.""" |
| 194 | violations: list[BoundaryViolation] = [] |
| 195 | if not CONTRACT_DIR.is_dir(): |
| 196 | return [ |
| 197 | BoundaryViolation( |
| 198 | "source-scan", |
| 199 | str(CONTRACT_DIR), |
| 200 | "command-contract src directory missing", |
| 201 | ) |
| 202 | ] |
| 203 | for path in sorted(CONTRACT_DIR.rglob("*.rs")): |
| 204 | text = path.read_text(encoding="utf-8") |
| 205 | rel = path.relative_to(REPO_ROOT) |
| 206 | violations.extend(check_contract_source_text(text, str(rel))) |
| 207 | return violations |
| 208 | |
| 209 | |
| 210 | def run_checks(metadata: dict | None = None) -> list[BoundaryViolation]: |
| 211 | """Run all boundary checks; return the collected violations.""" |
| 212 | graph = dependency_graph(metadata) if metadata is not None else dependency_graph( |
| 213 | load_workspace_metadata() |
| 214 | ) |
| 215 | return check_dependency_graph(graph) + check_contract_source() |
| 216 | |
| 217 | |
| 218 | def main(argv: list[str] | None = None) -> int: |
| 219 | del argv # reserved for future flags (e.g. --update); check is the default |
| 220 | violations = run_checks() |
| 221 | if violations: |
| 222 | print("[command-crate-boundaries] FAIL", file=sys.stderr) |
| 223 | for violation in violations: |
| 224 | print(f" {violation}", file=sys.stderr) |
| 225 | return 1 |
| 226 | print( |
| 227 | f"[command-crate-boundaries] PASS: " |
| 228 | f"{', '.join(TUI_FREE_PACKAGES)} have no {FORBIDDEN_TUI_PACKAGE} edge; " |
| 229 | "no forbidden import, composite context, or boxed handler in the contract" |
| 230 | ) |
| 231 | return 0 |
| 232 | |
| 233 | |
| 234 | if __name__ == "__main__": |
| 235 | sys.exit(main()) |
| 236 |