返回 CodeWhale
check-command-crate-boundaries.py
根目录 / scripts / check-command-crate-boundaries.py
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
236 lines PYTHON