返回 CodeWhale
module_graph.py
根目录 / scripts / split / module_graph.py
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
728 lines PYTHON