Files
CodeWhale BotandClaude Sonnet 5.5 3b35f098f7 tooling(split): split tooling v2 (S01/W0): journalled mover, re-export resolver, G11 preflight, widen v2, strict+file counters
Scripts only under scripts/split/. No Rust, no manifest, no budget touched.
Base 00be3d2e4f. tui strict production [M] 634,761 -> 634,761, file level [F]
943,686 -> 943,686, largest SCC 79 -> 79 (partition_report --non-test: [79, 8,
1, 1, 1]). Nothing was compiled: this is python; the rewrites it produces were
checked with rustfmt parse/format, module_graph.py, the path-keyed budget
scripts and counts, on a scratch `git init` export of this base (never the
shared tree). A compile of a rewritten tree is the first Lane A window.

What changed and why (reasoning lives here, not in comments):

move-modules.py (rewritten). R2.5: the old tool exited 0 in a tree without
.git after "moved 6 module(s)" (git failures were check=False), and its
dry-run headline (354 files / 3,487 lines) counted 12 files / 212 lines
inside the moved set that a real run never rewrites.
- --dest-crate/--src-crate replace every hard-coded runtime literal; --json;
  --require-clean (dirty set from `git status --porcelain -z` plus files
  younger than --quiet-minutes, default 15); --apply-cfg (flips only
  `#[cfg(test)]` items that code outside the moved set uses; never touches
  cfg(not(test)) forks, which are an FD-04 decision); --restore <journal>.
- No git mv (the shared index is not touched): plain file operations. Every
  write is compare-and-swap (stat+sha against the bytes the plan was computed
  from, temp file, os.replace) with a pre-image journal under CW/backups/;
  a change between plan and write refuses before writing (exit 3); a change or
  SIGTERM/SIGHUP/SIGINT during the write phase restores every pre-image and
  removes created files and directories (exit 4). git/git grep failures and a
  missing or foreign .git are fatal (exit 2). --require-clean refusals exit 5.
- Rewritten Rust goes through `rustfmt --edition 2024 --emit stdout` on stdin
  per file; a file whose base text is not rustfmt-stable (the 17 include!d
  test_cases_*.rs fragments) is left exactly as rewritten, so no foreign line
  is reflowed.
- Coverage: plain and grouped `use crate::{..}` (3 sites), alias, unqualified
  `<m>::` in the source lib (5 sites; cfg(test)-only use gets a cfg(test)
  import), `<dest>::` -> `crate::` inside moved files (code only), `codewhale_tui::<m>`
  in every other Rust file (cli config_keys 15 lines), sweep of path strings
  (plain, `tui/src/..`, segmented `"tui" / "src" / "<m>"`: this adds
  scripts/check_orcarouter_contract.py, which the old sweep missed) with
  vendor/, crates/config/, scripts/split/, CHANGELOG* and web/data/*.generated.json
  skipped; --derive-facts regenerates web/data (journalled pre-images).
- Reports (and plans, never applies): residue edges from moved files to
  staying modules, super-root residue, manifest needs (deps, features,
  consumer crates), cfg sites by class.
reexports.py (--resolve-reexports): resolves `pub use` facade names (globs
through the upstream sources and the build.rs-generated constants via
catalog_corrections.json / provider_descriptors.json) and rewrites
`crate::config::NAME` and grouped lists to the upstream path.
preflight.py (G11): whole-repo include_str!/include_bytes!/include!/#[path]
resolution against the old-to-new map, features.toml owners, path strings the
sweep cannot fix, `../src/<m>` references, CI test identities; exit 1 on residue.
widen.py: --dest-crate/--src-crate/--packages, signal-safe lints restore
(SIGTERM/SIGHUP/SIGINT and atexit; a manifest left capped by a killed run is
restored first), atomic writes, --prepass static widening, unused_imports
removed with the compiler's machine-applicable suggestions.
count_lines.py promoted from the plan's scratch (identical [M]) with the [F]
counter beside it. testsplit.py: function-level move/stay classifier with call
closure. partition_report/closure_report --non-test; module_graph --tui-src/
--runtime-src and a regex-driven lexer (11.0 s -> 2.5 s over 1,585 files,
0 mismatches against the old lex_mask on every tracked .rs).

Acceptance (scratch export of 00be3d2e4f, config settings config_persistence
config_keys oauth codex_model_cache):
- dry run: staying callers 342 files / 3,275 lines + inside the moved set 12 /
  212 = the old tool's 354 / 3,487; grouped 3; self paths 105 + 5 in comments
  = 110; other crates 2 files / 16 lines (15 cli + 1 runtime doc comment);
  sweep 31 files (+15 skipped); 81 cfg sites (53 test items, 11 test
  mods, 17 not(test) forks = the brief's 17); 9 s.
- real rewrite: 403 paths, 18.6 s, counts identical to the dry run line for
  line; 334 of 351 changed .rs files rustfmt-stable (the 17 others are the
  base-unstable fragments); no `crate::<moved>` left in tui, no
  `codewhale_tui::<moved>` in cli; module_graph.py --check refuses this
  premature move with 43 residue edges (it is the closedness gate working).
- closed module (remote_setup) real move: module_graph --check PASS with the
  unchanged baseline, check-blocking-calls-budget PASS (543 sites, 165 files;
  the sweep re-keyed the path-keyed JSON), dead-code budget PASS 209,
  check-provider-registry PASS, G11 unresolved 0.
- guards on the scratch repo: change between plan and write -> exit 3 nothing
  written; change at write 201 of 403 -> exit 4 and `git status` back to only
  the concurrent edit; SIGTERM at 201 of 403 -> exit 4, status empty; dirty
  file and a file touched < 15 min -> exit 5; no .git -> exit 2.
- widen.py: SIGTERM and SIGHUP while a compile round runs -> exit 130 and
  crates/runtime/Cargo.toml back to `[lints] workspace = true`; a capped
  manifest from an earlier run is restored at start; with a fake cargo JSON
  stream the E0603 widening, --prepass and the unused-import removal applied.
- reexports: 226 config names (213 via models) -> 1,109 direct lines in 142
  files and 68 grouped lists in 60 files outside the cluster, 166 files; the
  ProviderKind figure matches the plan exactly (339 lines / 95 files); the
  plan's 1,189 / 190 and 89 / 75 are not reproduced and likely include the
  cluster's own files; module_graph --check unchanged afterwards.
- preflight on the cluster: residue 8 = lib.rs:20230 include_str, tests/
  integration/main.rs:16 (path + relative), 5 skip-listed mentions in
  crates/config (catalog_corrections.json:4584 and models_dev.bundled.json:11769
  key a source receipt `crates/tui/src/config/models.rs`), vendor picker.rs.
- testsplit: config/tests.rs 407 tests (378 move, 29 stay), scope_tests.rs 8
  (0 move: needs provider_readiness/fleet), whole cluster 687 tests (649 move,
  38 stay); count_lines [M]/[F]: tui 634,761/943,686, cli 20,204/33,761,
  runtime 9,526/15,121, config 29,415/35,830, app-server 7,572/13,095.

Not proven: no cargo build of any rewritten tree; widen.py's compile loop ran
only against a fake cargo; --apply-cfg flipping is planned, not applied or
compiled; Windows/FreeBSD cfg code; whether any test reads the source-receipt
keys in crates/config assets as paths; the re-export resolver does not
rewrite relative `config::NAME` uses (none found at this base); SCC/edge
counts were not re-run after a rewrite (the cargo-free G2 stays Lane A's).

Evidence: /Volumes/VIXinSSD/CW/artifacts/crate-split-w0-20261025/proof-w0.log
and guard-demos.log. Dirty work of other writers in the checkout was left
untouched (about 20 tui/core/models files, window W1).

Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-10-25 14:31:42 -07:00

141 lines
4.4 KiB
Python

#!/usr/bin/env python3
"""Per-crate dependency-closure size report for the crate split.
For every workspace crate: its own Rust lines, the lines in its workspace
dependency closure (what an agent sandbox must hold to build it), and the
lines in its reverse closure (what a change to it can rebuild).
python3 scripts/split/closure_report.py
python3 scripts/split/closure_report.py --json
python3 scripts/split/closure_report.py --sort closure --top 15
"""
from __future__ import annotations
import argparse
import importlib.util
import json
import subprocess
import sys
from pathlib import Path
_spec = importlib.util.spec_from_file_location("count_lines", Path(__file__).with_name("count_lines.py"))
cl = importlib.util.module_from_spec(_spec)
_spec.loader.exec_module(cl)
REPO_ROOT = Path(__file__).resolve().parents[2]
def metadata() -> dict:
out = subprocess.run(
["cargo", "metadata", "--format-version", "1", "--no-deps", "--offline", "--locked"],
cwd=REPO_ROOT,
capture_output=True,
text=True,
)
if out.returncode != 0:
sys.exit(out.stderr.strip() or "cargo metadata failed")
return json.loads(out.stdout)
def rust_lines(root: Path, tests: bool) -> int:
total = 0
for path in root.rglob("*.rs"):
rel = path.relative_to(root).parts
is_test = (
"tests" in rel
or path.name in ("tests.rs", "test_support.rs")
or path.name.endswith("_tests.rs")
or path.name.startswith("test_cases_")
)
if is_test and not tests:
continue
try:
total += sum(1 for _ in path.open("rb"))
except OSError:
pass
return total
def closure(graph: dict[str, set[str]], start: str) -> set[str]:
seen: set[str] = set()
stack = [start]
while stack:
node = stack.pop()
for nxt in graph.get(node, ()):
if nxt not in seen:
seen.add(nxt)
stack.append(nxt)
return seen
def main() -> None:
parser = argparse.ArgumentParser()
parser.add_argument("--json", action="store_true")
parser.add_argument("--sort", choices=["name", "own", "closure", "reverse"], default="closure")
parser.add_argument("--top", type=int, default=0)
parser.add_argument("--tests", action="store_true", help="count test files too")
parser.add_argument(
"--non-test",
action="store_true",
help="size crates by strict production lines (inline cfg(test) code excluded) instead of file-level lines",
)
args = parser.parse_args()
meta = metadata()
packages = {p["name"]: p for p in meta["packages"] if p["id"] in set(meta["workspace_members"])}
forward: dict[str, set[str]] = {}
reverse: dict[str, set[str]] = {name: set() for name in packages}
for name, pkg in packages.items():
deps = {
d["name"]
for d in pkg["dependencies"]
if d["name"] in packages and d.get("kind") != "dev"
}
forward[name] = deps
for dep in deps:
reverse[dep].add(name)
own = {}
for name, pkg in packages.items():
crate_dir = Path(pkg["manifest_path"]).parent
src = crate_dir / "src"
if args.non_test:
_, res = cl.classify_crate(str(crate_dir))
own[name] = sum(prod for (prod, _t, _o) in res.values())
else:
own[name] = rust_lines(src, args.tests) if src.is_dir() else 0
rows = []
for name in packages:
down = closure(forward, name)
up = closure(reverse, name)
rows.append(
{
"crate": name,
"own": own[name],
"closure": own[name] + sum(own[d] for d in down),
"closure_crates": len(down),
"reverse": sum(own[u] for u in up),
"reverse_crates": len(up),
}
)
rows.sort(key=lambda r: r["crate"] if args.sort == "name" else -r[args.sort])
if args.top:
rows = rows[: args.top]
if args.json:
json.dump(rows, sys.stdout, indent=2)
print()
return
print(f"{'crate':32} {'own':>9} {'closure':>10} {'deps':>5} {'reverse':>10} {'users':>6}")
for r in rows:
print(
f"{r['crate']:32} {r['own']:>9} {r['closure']:>10} {r['closure_crates']:>5} "
f"{r['reverse']:>10} {r['reverse_crates']:>6}"
)
if __name__ == "__main__":
main()