| 1 | #!/usr/bin/env python3 |
| 2 | """Runtime -> UI boundary ratchet for the runtime/TUI crate split. |
| 3 | |
| 4 | Builds the top-level module graph of `crates/tui/src` and `crates/runtime/src` |
| 5 | with a lexer that masks comments and string literals and expands grouped |
| 6 | `use crate::{...}` imports, then counts every reference from the *runtime |
| 7 | closure* upward into UI code. |
| 8 | |
| 9 | The runtime closure is every module reachable over production edges from the |
| 10 | eight seed modules (`core`, `tools`, `runtime_api`, `runtime_threads`, |
| 11 | `client`, `llm_client`, `config`, `session_manager`) without passing through a |
| 12 | UI module, plus every module that already lives in `crates/runtime`. It is |
| 13 | computed on every run; the member list is never written by hand. |
| 14 | |
| 15 | Categories counted (keyed `from|to`): |
| 16 | |
| 17 | * ``prod`` production references from the closure into UI modules |
| 18 | (`tui`, `commands`, `remote_control`, `context_report`, |
| 19 | `composer_*`) or into private crate-root (`lib.rs`) items; |
| 20 | * ``test`` the same, from `#[cfg(test)]` code and test files; |
| 21 | * ``late`` references (production or test) from the closure into modules |
| 22 | outside it that are not UI either, i.e. modules that move after |
| 23 | the strongly connected core (`exec_agent`, `route_preferences`, |
| 24 | ...). A test edge constrains a crate move exactly like a |
| 25 | production edge; |
| 26 | * ``uilib`` closure files that use a UI library (`ratatui`, `crossterm`, |
| 27 | `codewhale_tui`), keyed `module|library`. `codewhale_palette` is |
| 28 | not one since its `ratatui` feature became optional (RS-3); the |
| 29 | runtime links it with `default-features = false`; |
| 30 | * ``doc`` intra-doc links in closure doc comments that point at UI code |
| 31 | (`crate::tui::...`, `crate::commands::...`). rustdoc checks them |
| 32 | once the module is public in `codewhale-runtime`. |
| 33 | |
| 34 | The baseline is `scripts/runtime-boundary-baseline.json`. `--check` (default) |
| 35 | fails on any increased count or new key and prints `file:line` for it; it also |
| 36 | fails when a count dropped and the baseline was not lowered in the same change, |
| 37 | so the baseline stays honest. `--update` rewrites the baseline and refuses to |
| 38 | raise any count. `--baseline-ref REV` (CI passes the PR base) also fails when |
| 39 | the committed baseline holds any count above the baseline at REV, so a |
| 40 | hand-edited JSON cannot raise the ratchet either. |
| 41 | |
| 42 | `super::` chains that climb to the crate root count like `crate::` paths. |
| 43 | |
| 44 | See docs/design/TUI_DECONSTRUCTION.md (runtime split, ratchet). |
| 45 | """ |
| 46 | |
| 47 | from __future__ import annotations |
| 48 | |
| 49 | import argparse |
| 50 | import collections |
| 51 | import json |
| 52 | import importlib.util |
| 53 | import os |
| 54 | import re |
| 55 | import subprocess |
| 56 | import sys |
| 57 | from dataclasses import dataclass, field |
| 58 | from pathlib import Path |
| 59 | |
| 60 | REPO_ROOT = Path(__file__).resolve().parents[2] |
| 61 | TUI_SRC = REPO_ROOT / "crates" / "tui" / "src" |
| 62 | RUNTIME_SRC = REPO_ROOT / "crates" / "runtime" / "src" |
| 63 | BASELINE_REPO_PATH = "scripts/runtime-boundary-baseline.json" |
| 64 | BASELINE = REPO_ROOT / BASELINE_REPO_PATH |
| 65 | |
| 66 | SEEDS = ( |
| 67 | "core", |
| 68 | "tools", |
| 69 | "runtime_api", |
| 70 | "runtime_threads", |
| 71 | "client", |
| 72 | "llm_client", |
| 73 | "config", |
| 74 | "session_manager", |
| 75 | ) |
| 76 | UI_MODULES = {"tui", "commands", "remote_control", "context_report"} |
| 77 | UI_PREFIXES = ("composer_",) |
| 78 | ROOT_ITEMS = "lib.rs" |
| 79 | CATEGORIES = ("prod", "test", "late", "uilib", "doc") |
| 80 | |
| 81 | HINTS = { |
| 82 | "tui": "move the item down (e.g. into core::authority) or reach the UI " |
| 83 | "through the host_terminal port", |
| 84 | "commands": "read commands through the runtime CommandCatalog, or split " |
| 85 | "the non-UI half of the command into a runtime module", |
| 86 | ROOT_ITEMS: "move the crate-root helper into the runtime module that owns it", |
| 87 | } |
| 88 | |
| 89 | |
| 90 | def is_ui(module: str) -> bool: |
| 91 | return ( |
| 92 | module in UI_MODULES |
| 93 | or module.startswith(UI_PREFIXES) |
| 94 | or module in (ROOT_ITEMS, "main.rs") |
| 95 | ) |
| 96 | |
| 97 | |
| 98 | # -------------------------------------------------------------------------- |
| 99 | # Lexer: blank comments, string and char literals; keep offsets and newlines. |
| 100 | # -------------------------------------------------------------------------- |
| 101 | |
| 102 | _RAW_STR = re.compile(r'b?r(#*)"') |
| 103 | _CHAR = re.compile(r"'(\\.[^']*|[^\\'])'") |
| 104 | |
| 105 | |
| 106 | def lex_mask(src: str) -> str: |
| 107 | out = list(src) |
| 108 | i, n = 0, len(src) |
| 109 | |
| 110 | def blank(a: int, b: int) -> None: |
| 111 | for k in range(a, min(b, n)): |
| 112 | if out[k] != "\n": |
| 113 | out[k] = " " |
| 114 | |
| 115 | while i < n: |
| 116 | c = src[i] |
| 117 | if src.startswith("//", i): |
| 118 | j = src.find("\n", i) |
| 119 | j = n if j < 0 else j |
| 120 | blank(i, j) |
| 121 | i = j |
| 122 | continue |
| 123 | if src.startswith("/*", i): |
| 124 | depth, j = 1, i + 2 |
| 125 | while j < n and depth: |
| 126 | if src.startswith("/*", j): |
| 127 | depth += 1 |
| 128 | j += 2 |
| 129 | elif src.startswith("*/", j): |
| 130 | depth -= 1 |
| 131 | j += 2 |
| 132 | else: |
| 133 | j += 1 |
| 134 | blank(i, j) |
| 135 | i = j |
| 136 | continue |
| 137 | prev_ident = i > 0 and (src[i - 1].isalnum() or src[i - 1] == "_") |
| 138 | m = _RAW_STR.match(src, i, i + 10) |
| 139 | if m and not prev_ident: |
| 140 | end = '"' + m.group(1) |
| 141 | j = src.find(end, i + len(m.group(0))) |
| 142 | j = n if j < 0 else j + len(end) |
| 143 | blank(i + 1, j - 1) |
| 144 | i = j |
| 145 | continue |
| 146 | if c == '"' or (c == "b" and i + 1 < n and src[i + 1] == '"' and not prev_ident): |
| 147 | j = i + (2 if c == "b" else 1) |
| 148 | while j < n and src[j] != '"': |
| 149 | j += 2 if src[j] == "\\" else 1 |
| 150 | blank(i + 1, j) |
| 151 | i = j + 1 |
| 152 | continue |
| 153 | if c == "'": |
| 154 | m = _CHAR.match(src, i, i + 12) |
| 155 | if m: |
| 156 | blank(i + 1, i + len(m.group(0)) - 1) |
| 157 | i += len(m.group(0)) |
| 158 | continue |
| 159 | i += 1 |
| 160 | return "".join(out) |
| 161 | |
| 162 | |
| 163 | def match_brace(code: str, start: int) -> int: |
| 164 | depth = 0 |
| 165 | for k in range(start, len(code)): |
| 166 | if code[k] == "{": |
| 167 | depth += 1 |
| 168 | elif code[k] == "}": |
| 169 | depth -= 1 |
| 170 | if depth == 0: |
| 171 | return k |
| 172 | return len(code) - 1 |
| 173 | |
| 174 | |
| 175 | CFG_TEST = re.compile( |
| 176 | r"#\[cfg\((?:test|all\(test[^\]]*\)|any\(test[^\]]*\)|feature\s*=\s*\"test-support\")\)\]" |
| 177 | ) |
| 178 | |
| 179 | |
| 180 | def strip_cfg_test(code: str, test_mod_decls: list[str]) -> str: |
| 181 | """Blank items annotated `#[cfg(test)]`; collect `mod x;` declarations.""" |
| 182 | out = list(code) |
| 183 | for m in CFG_TEST.finditer(code): |
| 184 | j = m.end() |
| 185 | while True: |
| 186 | ws = re.match(r"\s*(#\[[^\]]*\])?", code[j:]) |
| 187 | if ws and ws.group(1): |
| 188 | j += ws.end() |
| 189 | continue |
| 190 | j += len(code[j:]) - len(code[j:].lstrip()) |
| 191 | break |
| 192 | md = re.match(r"(pub(\([^)]*\))?\s+)?mod\s+(\w+)\s*;", code[j:]) |
| 193 | if md: |
| 194 | test_mod_decls.append(md.group(3)) |
| 195 | semi = code.find(";", j) |
| 196 | brace = code.find("{", j) |
| 197 | if brace >= 0 and (semi < 0 or brace < semi): |
| 198 | end = match_brace(code, brace) |
| 199 | else: |
| 200 | end = semi if semi >= 0 else j |
| 201 | for k in range(m.start(), end + 1): |
| 202 | if out[k] != "\n": |
| 203 | out[k] = " " |
| 204 | return "".join(out) |
| 205 | |
| 206 | |
| 207 | def is_test_path(rel: str) -> bool: |
| 208 | base = os.path.basename(rel) |
| 209 | return ( |
| 210 | "/tests/" in "/" + rel |
| 211 | or base in ("tests.rs", "test_support.rs", "test_env_lock.rs") |
| 212 | or base.endswith(("_tests.rs", "_test.rs", "_acceptance.rs")) |
| 213 | or base == "golden_harness.rs" |
| 214 | or "goldens" in rel |
| 215 | or "/fixtures/" in "/" + rel |
| 216 | ) |
| 217 | |
| 218 | |
| 219 | def top_module(rel: str) -> str: |
| 220 | parts = rel.split("/") |
| 221 | if len(parts) == 1: |
| 222 | stem = parts[0][:-3] |
| 223 | return {"lib": ROOT_ITEMS, "main": "main.rs"}.get(stem, stem) |
| 224 | return parts[0] |
| 225 | |
| 226 | |
| 227 | def split_top(s: str) -> list[str]: |
| 228 | parts, depth, cur = [], 0, "" |
| 229 | for ch in s: |
| 230 | if ch == "{": |
| 231 | depth += 1 |
| 232 | elif ch == "}": |
| 233 | depth -= 1 |
| 234 | if ch == "," and depth == 0: |
| 235 | parts.append(cur) |
| 236 | cur = "" |
| 237 | else: |
| 238 | cur += ch |
| 239 | parts.append(cur) |
| 240 | return [p.strip() for p in parts if p.strip()] |
| 241 | |
| 242 | |
| 243 | def use_leaves(tree: str, prefix: tuple[str, ...] = ()) -> list[tuple[str, tuple[str, ...]]]: |
| 244 | """Flatten a use tree into (bound name, full path) pairs.""" |
| 245 | tree = tree.strip() |
| 246 | if not tree: |
| 247 | return [] |
| 248 | brace = tree.find("{") |
| 249 | if brace >= 0: |
| 250 | head = [p for p in tree[:brace].split("::") if p.strip()] |
| 251 | inner = tree[brace + 1 : tree.rindex("}")] |
| 252 | out = [] |
| 253 | for part in split_top(inner): |
| 254 | out.extend(use_leaves(part, prefix + tuple(h.strip() for h in head))) |
| 255 | return out |
| 256 | alias = None |
| 257 | m = re.match(r"(.*?)\s+as\s+(\w+)$", tree) |
| 258 | if m: |
| 259 | tree, alias = m.group(1), m.group(2) |
| 260 | path = prefix + tuple(p.strip() for p in tree.split("::") if p.strip()) |
| 261 | if not path: |
| 262 | return [] |
| 263 | name = alias or path[-1] |
| 264 | if name == "self": |
| 265 | name = path[-2] if len(path) > 1 else name |
| 266 | return [(name, path)] |
| 267 | |
| 268 | |
| 269 | # -------------------------------------------------------------------------- |
| 270 | # Graph construction |
| 271 | # -------------------------------------------------------------------------- |
| 272 | |
| 273 | |
| 274 | @dataclass |
| 275 | class Ref: |
| 276 | kind: str # "prod" or "test" |
| 277 | src: str # module |
| 278 | dst: str # module, or lib.rs |
| 279 | file: str |
| 280 | line: int |
| 281 | text: str |
| 282 | |
| 283 | |
| 284 | @dataclass |
| 285 | class Crate: |
| 286 | name: str |
| 287 | root: Path |
| 288 | files: dict[str, tuple[str, str, str]] = field(default_factory=dict) |
| 289 | modules: set[str] = field(default_factory=set) |
| 290 | # names bound at the crate root by `use` -> resolving module (or "extern") |
| 291 | root_names: dict[str, str] = field(default_factory=dict) |
| 292 | # modules whose every file is test code (e.g. `#[cfg(test)] mod test_support;`) |
| 293 | test_only: set[str] = field(default_factory=set) |
| 294 | # Actual module/include production conflicts override path heuristics. |
| 295 | production_files: set[str] = field(default_factory=set) |
| 296 | |
| 297 | |
| 298 | USE_RE = re.compile(r"\buse\s+crate::") |
| 299 | PATH_RE = re.compile(r"(?<![\$\w])crate::(\w+)") |
| 300 | # `super::super::tui::x` reaches the crate root just like `crate::tui::x`. |
| 301 | SUPER_RE = re.compile(r"(?<![\$\w:])((?:super\s*::\s*)+)(\{|\w+)") |
| 302 | INLINE_MOD_RE = re.compile(r"\bmod\s+\w+\s*\{") |
| 303 | UILIB_RE = re.compile(r"(?<![\w:])(ratatui|crossterm|codewhale_tui)(?:::|\s*;|\s*\{)") |
| 304 | DOC_LINK_RE = re.compile(r"\[`?crate::(tui|commands)\b") |
| 305 | |
| 306 | |
| 307 | def load_crate(name: str, root: Path) -> Crate: |
| 308 | crate = Crate(name, root) |
| 309 | if not root.is_dir(): |
| 310 | return crate |
| 311 | for dirpath, _, filenames in os.walk(root): |
| 312 | for fname in filenames: |
| 313 | if not fname.endswith(".rs"): |
| 314 | continue |
| 315 | rel = os.path.relpath(os.path.join(dirpath, fname), root).replace(os.sep, "/") |
| 316 | src = Path(dirpath, fname).read_text(encoding="utf-8", errors="replace") |
| 317 | code = lex_mask(src) |
| 318 | crate.files[rel] = (src, code, "") |
| 319 | crate.modules = {top_module(r) for r in crate.files} - {ROOT_ITEMS, "main.rs"} |
| 320 | return crate |
| 321 | |
| 322 | |
| 323 | def resolve_root_names(crate: Crate, runtime_modules: set[str]) -> None: |
| 324 | """Map names the crate root imports with `use` to the module they come from.""" |
| 325 | entry = crate.files.get("lib.rs") |
| 326 | if not entry: |
| 327 | return |
| 328 | code = entry[1] |
| 329 | depth = 0 |
| 330 | i = 0 |
| 331 | # Only top-level `use` items (brace depth 0) bind crate-root names. |
| 332 | for m in re.finditer(r"[{}]|\b(?:pub(?:\([^)]*\))?\s+)?use\s+", code): |
| 333 | tok = m.group(0) |
| 334 | if tok == "{": |
| 335 | depth += 1 |
| 336 | continue |
| 337 | if tok == "}": |
| 338 | depth -= 1 |
| 339 | continue |
| 340 | if depth != 0 or m.start() < i: |
| 341 | continue |
| 342 | end = code.find(";", m.end()) |
| 343 | i = end |
| 344 | for bound, path in use_leaves(code[m.end() : end]): |
| 345 | head = path[0] |
| 346 | if head == "crate" and len(path) > 1: |
| 347 | target = path[1] |
| 348 | crate.root_names[bound] = target if target in crate.modules else ROOT_ITEMS |
| 349 | elif head in ("self", "super"): |
| 350 | continue |
| 351 | elif head in crate.modules: |
| 352 | crate.root_names[bound] = head |
| 353 | elif head == "codewhale_runtime" and len(path) > 1: |
| 354 | crate.root_names[bound] = path[1] |
| 355 | else: |
| 356 | crate.root_names[bound] = "extern" |
| 357 | |
| 358 | |
| 359 | def test_file_set(crate: Crate) -> tuple[set[str], set[str]]: |
| 360 | """Reuse the blocking gate's conservative cfg/module/include graph.""" |
| 361 | for rel, (src, code, _) in list(crate.files.items()): |
| 362 | crate.files[rel] = (src, code, strip_cfg_test(code, [])) |
| 363 | path = Path(__file__).resolve().parents[1] / "check-blocking-calls-budget.py" |
| 364 | spec = importlib.util.spec_from_file_location("boundary_test_scope_graph", path) |
| 365 | if spec is None or spec.loader is None: |
| 366 | raise RuntimeError(f"cannot load shared Rust test-scope reader: {path}") |
| 367 | graph = importlib.util.module_from_spec(spec) |
| 368 | spec.loader.exec_module(graph) |
| 369 | # Include the manifest and custom targets outside src when a real crate |
| 370 | # is supplied. Hermetic source trees do not consult a neighboring crate. |
| 371 | scan_root = crate.root.parent if (crate.root.parent / "Cargo.toml").is_file() else crate.root |
| 372 | test_only, production = graph.module_file_scopes(scan_root) |
| 373 | exact = {rel for rel in crate.files if (crate.root / rel).resolve() in test_only} |
| 374 | crate.production_files = {rel for rel in crate.files if (crate.root / rel).resolve() in production} |
| 375 | # No guessed directory-prefix exemption: classification is per actual file. |
| 376 | return exact, set() |
| 377 | |
| 378 | |
| 379 | def file_is_test( |
| 380 | rel: str, exact: set[str], prefixes: set[str], production: set[str] | None = None |
| 381 | ) -> bool: |
| 382 | if production is not None and rel in production: |
| 383 | return False |
| 384 | return is_test_path(rel) or rel in exact or any(rel.startswith(p) for p in prefixes) |
| 385 | |
| 386 | |
| 387 | def file_depth(rel: str) -> int: |
| 388 | """Module depth of a source file below the crate root (`lib.rs` is 0).""" |
| 389 | parts = rel.split("/") |
| 390 | if len(parts) == 1: |
| 391 | return 0 if parts[0] in ("lib.rs", "main.rs") else 1 |
| 392 | return len(parts) - 1 if parts[-1] == "mod.rs" else len(parts) |
| 393 | |
| 394 | |
| 395 | def inline_mod_spans(code: str) -> list[tuple[int, int]]: |
| 396 | """Byte ranges of inline `mod name { ... }` bodies (each adds one level).""" |
| 397 | return [(m.end() - 1, match_brace(code, m.end() - 1)) for m in INLINE_MOD_RE.finditer(code)] |
| 398 | |
| 399 | |
| 400 | def super_root_targets(code: str, rel: str) -> list[tuple[int, str]]: |
| 401 | """`(offset, first segment)` for every `super::` chain that climbs to the crate root. |
| 402 | |
| 403 | A chain of k `super`s written at module depth d (file depth plus the |
| 404 | enclosing inline `mod` blocks) names the crate root when k == d; a |
| 405 | shorter chain stays inside the module and is not a cross-module edge. |
| 406 | """ |
| 407 | base = file_depth(rel) |
| 408 | spans = inline_mod_spans(code) |
| 409 | out: list[tuple[int, str]] = [] |
| 410 | for m in SUPER_RE.finditer(code): |
| 411 | k = m.group(1).count("super") |
| 412 | depth = base + sum(1 for a, b in spans if a < m.start() < b) |
| 413 | if k != depth: |
| 414 | continue |
| 415 | if m.group(2) == "{": |
| 416 | end = match_brace(code, m.end() - 1) |
| 417 | for _, path in use_leaves(code[m.end() : end]): |
| 418 | out.append((m.start(), path[0])) |
| 419 | else: |
| 420 | out.append((m.start(), m.group(2))) |
| 421 | return out |
| 422 | |
| 423 | |
| 424 | def collect_refs(crate: Crate, all_modules: set[str], prefix: str) -> list[Ref]: |
| 425 | exact, prefixes = test_file_set(crate) |
| 426 | refs: list[Ref] = [] |
| 427 | by_module: dict[str, list[bool]] = collections.defaultdict(list) |
| 428 | for rel in crate.files: |
| 429 | by_module[top_module(rel)].append(file_is_test(rel, exact, prefixes, crate.production_files)) |
| 430 | crate.test_only = {m for m, flags in by_module.items() if all(flags)} |
| 431 | |
| 432 | def target_of(name: str) -> str: |
| 433 | if name in all_modules: |
| 434 | return name |
| 435 | return crate.root_names.get(name, ROOT_ITEMS) |
| 436 | |
| 437 | for rel, (src, code, stripped) in crate.files.items(): |
| 438 | mod = top_module(rel) |
| 439 | whole_test = file_is_test(rel, exact, prefixes, crate.production_files) |
| 440 | lines = src.split("\n") |
| 441 | |
| 442 | def kind_at(pos: int) -> str: |
| 443 | if whole_test: |
| 444 | return "test" |
| 445 | return "prod" if stripped[pos] == code[pos] and not stripped[pos].isspace() else "test" |
| 446 | |
| 447 | covered: set[int] = set() |
| 448 | for m in USE_RE.finditer(code): |
| 449 | end = code.find(";", m.end()) |
| 450 | if end < 0: |
| 451 | continue |
| 452 | covered.update(range(m.start(), end)) |
| 453 | body = code[m.end() : end] |
| 454 | line = code.count("\n", 0, m.start()) + 1 |
| 455 | for _, path in use_leaves(body): |
| 456 | t = path[0] |
| 457 | if t in ("self", "super"): |
| 458 | continue |
| 459 | refs.append(Ref(kind_at(m.start()), mod, target_of(t), f"{prefix}/{rel}", line, lines[line - 1].strip()[:160])) |
| 460 | for m in PATH_RE.finditer(code): |
| 461 | if m.start() in covered: |
| 462 | continue |
| 463 | line = code.count("\n", 0, m.start()) + 1 |
| 464 | refs.append(Ref(kind_at(m.start()), mod, target_of(m.group(1)), f"{prefix}/{rel}", line, lines[line - 1].strip()[:160])) |
| 465 | for pos, name in super_root_targets(code, rel): |
| 466 | # `pub(in super::super)` and glob imports name no item; only a |
| 467 | # known module or crate-root binding is an edge. |
| 468 | if name not in all_modules and name not in crate.root_names: |
| 469 | continue |
| 470 | line = code.count("\n", 0, pos) + 1 |
| 471 | refs.append(Ref(kind_at(pos), mod, target_of(name), f"{prefix}/{rel}", line, lines[line - 1].strip()[:160])) |
| 472 | return refs |
| 473 | |
| 474 | |
| 475 | @dataclass |
| 476 | class Report: |
| 477 | closure: list[str] |
| 478 | counts: dict[str, dict[str, int]] |
| 479 | locations: dict[str, dict[str, list[str]]] |
| 480 | |
| 481 | |
| 482 | def build_report(tui_src: Path = TUI_SRC, runtime_src: Path = RUNTIME_SRC) -> Report: |
| 483 | tui = load_crate("codewhale-tui", tui_src) |
| 484 | runtime = load_crate("codewhale-runtime", runtime_src) |
| 485 | all_modules = tui.modules | runtime.modules |
| 486 | resolve_root_names(tui, runtime.modules) |
| 487 | resolve_root_names(runtime, runtime.modules) |
| 488 | tui_refs = collect_refs(tui, all_modules, "crates/tui/src") |
| 489 | rt_refs = collect_refs(runtime, runtime.modules, "crates/runtime/src") |
| 490 | refs = tui_refs + rt_refs |
| 491 | |
| 492 | prod_edges: dict[str, set[str]] = collections.defaultdict(set) |
| 493 | for r in refs: |
| 494 | if r.kind == "prod" and r.src != r.dst: |
| 495 | prod_edges[r.src].add(r.dst) |
| 496 | |
| 497 | # A test-only module that closure tests use (`test_support`) compiles in |
| 498 | # the same crate as those tests, so it belongs to the closure too and its |
| 499 | # own upward references count as test references. |
| 500 | test_only = tui.test_only | runtime.test_only |
| 501 | test_edges: dict[str, set[str]] = collections.defaultdict(set) |
| 502 | for r in refs: |
| 503 | if r.src != r.dst and r.dst in test_only: |
| 504 | test_edges[r.src].add(r.dst) |
| 505 | |
| 506 | seen: set[str] = set() |
| 507 | stack = [s for s in SEEDS if s in all_modules] + sorted(runtime.modules) |
| 508 | while stack: |
| 509 | m = stack.pop() |
| 510 | if m in seen or is_ui(m) or m == "extern": |
| 511 | continue |
| 512 | seen.add(m) |
| 513 | stack.extend(prod_edges.get(m, ())) |
| 514 | stack.extend(test_edges.get(m, ())) |
| 515 | |
| 516 | counts = {c: collections.Counter() for c in CATEGORIES} |
| 517 | where: dict[str, dict[str, list[str]]] = {c: collections.defaultdict(list) for c in CATEGORIES} |
| 518 | for r in refs: |
| 519 | if r.src not in seen or r.src == r.dst or r.dst == "extern": |
| 520 | continue |
| 521 | if is_ui(r.dst): |
| 522 | cat = r.kind |
| 523 | elif r.dst not in seen: |
| 524 | cat = "late" |
| 525 | else: |
| 526 | continue |
| 527 | key = f"{r.src}|{r.dst}" |
| 528 | counts[cat][key] += 1 |
| 529 | where[cat][key].append(f"{r.file}:{r.line}: {r.text}") |
| 530 | |
| 531 | for crate, prefix in ((tui, "crates/tui/src"), (runtime, "crates/runtime/src")): |
| 532 | for rel, (src, code, _) in crate.files.items(): |
| 533 | mod = top_module(rel) |
| 534 | if mod not in seen: |
| 535 | continue |
| 536 | lines = src.split("\n") |
| 537 | for m in UILIB_RE.finditer(code): |
| 538 | lib = m.group(1) |
| 539 | line = code.count("\n", 0, m.start()) + 1 |
| 540 | key = f"{mod}|{lib}" |
| 541 | counts["uilib"][key] += 1 |
| 542 | where["uilib"][key].append(f"{prefix}/{rel}:{line}: {lines[line - 1].strip()[:160]}") |
| 543 | for line_no, raw in enumerate(lines, start=1): |
| 544 | stripped = raw.lstrip() |
| 545 | if not (stripped.startswith("///") or stripped.startswith("//!")): |
| 546 | continue |
| 547 | for m in DOC_LINK_RE.finditer(stripped): |
| 548 | key = f"{mod}|{m.group(1)}" |
| 549 | counts["doc"][key] += 1 |
| 550 | where["doc"][key].append(f"{prefix}/{rel}:{line_no}: {stripped[:160]}") |
| 551 | |
| 552 | return Report( |
| 553 | sorted(seen), |
| 554 | {c: dict(sorted(counts[c].items())) for c in CATEGORIES}, |
| 555 | {c: dict(where[c]) for c in CATEGORIES}, |
| 556 | ) |
| 557 | |
| 558 | |
| 559 | # -------------------------------------------------------------------------- |
| 560 | # Ratchet |
| 561 | # -------------------------------------------------------------------------- |
| 562 | |
| 563 | |
| 564 | def totals(counts: dict[str, dict[str, int]]) -> dict[str, int]: |
| 565 | return {c: sum(counts.get(c, {}).values()) for c in CATEGORIES} |
| 566 | |
| 567 | |
| 568 | def compare(baseline: dict, report: Report) -> tuple[list[str], list[str]]: |
| 569 | """Return (increases, unrecorded decreases) as human-readable lines.""" |
| 570 | rises: list[str] = [] |
| 571 | drops: list[str] = [] |
| 572 | base_counts = baseline.get("counts", {}) |
| 573 | for cat in CATEGORIES: |
| 574 | base = base_counts.get(cat, {}) |
| 575 | cur = report.counts.get(cat, {}) |
| 576 | for key, n in cur.items(): |
| 577 | b = base.get(key, 0) |
| 578 | if n > b: |
| 579 | dst = key.split("|", 1)[1] |
| 580 | hint = HINTS.get(dst, "see docs/design/TUI_DECONSTRUCTION.md (runtime split blockers)") |
| 581 | head = f"{cat} {key}: {b} -> {n}" + (" (new pair)" if key not in base else "") |
| 582 | rises.append(f"{head}; {hint}") |
| 583 | for loc in report.locations.get(cat, {}).get(key, [])[:20]: |
| 584 | rises.append(f" {loc}") |
| 585 | for key, b in base.items(): |
| 586 | n = cur.get(key, 0) |
| 587 | if n < b: |
| 588 | drops.append(f"{cat} {key}: {b} -> {n}") |
| 589 | return rises, drops |
| 590 | |
| 591 | |
| 592 | def load_baseline(path: Path = BASELINE) -> dict: |
| 593 | return json.loads(path.read_text(encoding="utf-8")) |
| 594 | |
| 595 | |
| 596 | def load_baseline_at_ref(ref: str, root: Path = REPO_ROOT) -> dict | None: |
| 597 | """The baseline as committed at `ref`; None when it did not exist yet.""" |
| 598 | commit = subprocess.run( |
| 599 | ["git", "rev-parse", "--verify", f"{ref}^{{commit}}"], |
| 600 | cwd=root, capture_output=True, text=True, check=False, |
| 601 | ) |
| 602 | if commit.returncode != 0: |
| 603 | raise ValueError(f"baseline ref {ref!r} is unavailable: {commit.stderr.strip()}") |
| 604 | shown = subprocess.run( |
| 605 | ["git", "show", f"{ref}:{BASELINE_REPO_PATH}"], |
| 606 | cwd=root, capture_output=True, text=True, check=False, |
| 607 | ) |
| 608 | if shown.returncode != 0: |
| 609 | return None |
| 610 | return json.loads(shown.stdout) |
| 611 | |
| 612 | |
| 613 | def baseline_raises(previous: dict, current: dict) -> list[str]: |
| 614 | """Counts the committed baseline holds above the baseline it replaces. |
| 615 | |
| 616 | The local `--update` refuses to raise, but the JSON is an ordinary file: |
| 617 | without this comparison a change could add a reference and bump the |
| 618 | count by hand in the same commit, and `check` would pass. |
| 619 | """ |
| 620 | raised: list[str] = [] |
| 621 | prev_counts = previous.get("counts", {}) |
| 622 | for cat in CATEGORIES: |
| 623 | prev = prev_counts.get(cat, {}) |
| 624 | for key, n in current.get("counts", {}).get(cat, {}).items(): |
| 625 | b = prev.get(key, 0) |
| 626 | if n > b: |
| 627 | raised.append(f"{cat} {key}: {b} -> {n}" + (" (new pair)" if key not in prev else "")) |
| 628 | return raised |
| 629 | |
| 630 | |
| 631 | def baseline_document(report: Report) -> dict: |
| 632 | return { |
| 633 | "_comment": ( |
| 634 | "Runtime -> UI boundary ratchet (docs/design/TUI_DECONSTRUCTION.md, runtime split). Counts may " |
| 635 | "only go down. Regenerate with python3 scripts/split/module_graph.py " |
| 636 | "--update after removing references; never raise a count by hand." |
| 637 | ), |
| 638 | "totals": totals(report.counts), |
| 639 | "counts": report.counts, |
| 640 | } |
| 641 | |
| 642 | |
| 643 | def check( |
| 644 | path: Path = BASELINE, |
| 645 | report: Report | None = None, |
| 646 | baseline_ref: str | None = None, |
| 647 | ) -> list[str]: |
| 648 | """Return violation lines (empty when the ratchet holds). |
| 649 | |
| 650 | With `baseline_ref` (CI passes the PR base), the committed baseline must |
| 651 | also be no higher than the one at that revision. |
| 652 | """ |
| 653 | report = report or build_report() |
| 654 | if not path.is_file(): |
| 655 | return [f"missing baseline {path.relative_to(REPO_ROOT)}; run with --update"] |
| 656 | problems = [] |
| 657 | if baseline_ref: |
| 658 | try: |
| 659 | previous = load_baseline_at_ref(baseline_ref) |
| 660 | except (ValueError, json.JSONDecodeError) as error: |
| 661 | return [str(error)] |
| 662 | if previous is not None: |
| 663 | raised = baseline_raises(previous, load_baseline(path)) |
| 664 | if raised: |
| 665 | problems.append( |
| 666 | f"the baseline was raised relative to {baseline_ref} (counts only go down; " |
| 667 | "remove the reference instead of editing the JSON):" |
| 668 | ) |
| 669 | problems.extend(f" {r}" for r in raised) |
| 670 | rises, drops = compare(load_baseline(path), report) |
| 671 | if rises: |
| 672 | problems.append("runtime -> UI references rose (the ratchet only goes down):") |
| 673 | problems.extend(f" {r}" for r in rises) |
| 674 | if drops: |
| 675 | problems.append( |
| 676 | "runtime -> UI references dropped but the baseline was not lowered; " |
| 677 | "run python3 scripts/split/module_graph.py --update and commit it:" |
| 678 | ) |
| 679 | problems.extend(f" {d}" for d in drops) |
| 680 | return problems |
| 681 | |
| 682 | |
| 683 | def main(argv: list[str] | None = None) -> int: |
| 684 | parser = argparse.ArgumentParser(description=__doc__.split("\n\n")[0]) |
| 685 | mode = parser.add_mutually_exclusive_group() |
| 686 | mode.add_argument("--check", action="store_true", help="enforce the ratchet (default)") |
| 687 | mode.add_argument("--update", action="store_true", help="lower the baseline to the current counts") |
| 688 | mode.add_argument("--report", action="store_true", help="print counts and closure as JSON") |
| 689 | parser.add_argument( |
| 690 | "--baseline-ref", |
| 691 | help="git revision whose committed baseline the current one may not exceed (CI: the PR base)", |
| 692 | ) |
| 693 | args = parser.parse_args(argv) |
| 694 | |
| 695 | report = build_report() |
| 696 | if args.report: |
| 697 | json.dump( |
| 698 | {"closure": report.closure, "totals": totals(report.counts), "counts": report.counts, |
| 699 | "locations": report.locations}, |
| 700 | sys.stdout, |
| 701 | indent=1, |
| 702 | ) |
| 703 | print() |
| 704 | return 0 |
| 705 | if args.update: |
| 706 | if BASELINE.is_file(): |
| 707 | rises, _ = compare(load_baseline(), report) |
| 708 | if rises: |
| 709 | print("[runtime-boundary] refusing to raise the baseline:", file=sys.stderr) |
| 710 | for r in rises: |
| 711 | print(f" {r}", file=sys.stderr) |
| 712 | return 1 |
| 713 | BASELINE.write_text(json.dumps(baseline_document(report), indent=2) + "\n", encoding="utf-8") |
| 714 | print(f"[runtime-boundary] baseline written: {totals(report.counts)}") |
| 715 | return 0 |
| 716 | problems = check(report=report, baseline_ref=args.baseline_ref) |
| 717 | if problems: |
| 718 | print("[runtime-boundary] FAIL", file=sys.stderr) |
| 719 | for p in problems: |
| 720 | print(p, file=sys.stderr) |
| 721 | return 1 |
| 722 | print(f"[runtime-boundary] PASS: {totals(report.counts)} ({len(report.closure)} closure modules)") |
| 723 | return 0 |
| 724 | |
| 725 | |
| 726 | if __name__ == "__main__": |
| 727 | sys.exit(main()) |
| 728 |