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