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

193 lines
8.5 KiB
Python

#!/usr/bin/env python3
"""Function-level test partitioner for a module move (S09 pre-split helper).
python3 scripts/split/testsplit.py config settings oauth config_persistence
python3 scripts/split/testsplit.py config --file <tests file under the source crate> --json
python3 scripts/split/testsplit.py config --emit-dir /tmp/split-out
For the files of the named modules that hold tests (`tests.rs`, `*_tests.rs`
and the inline `mod tests` of the module files), classifies every top-level
item as `move` or `stay`:
* an item STAYS when it names a staying source-crate module (`crate::M` with M
neither moved nor already resident in the destination) or an identifier that
a `use crate::M::..` import binds from one;
* staying propagates through the in-file call graph: a test that calls a
helper that stays, stays with it.
`#[test]` functions are counted individually, including those inside a nested
test module (which is classified as a whole). `--emit-dir` writes
`<stem>.move.rs` and `<stem>.stay.rs`: every `use` is kept in both, support
items needed by both sides are duplicated and listed as `shared`, so the
caller decides whether to widen them with `test-support` instead.
"""
from __future__ import annotations
import argparse
import collections
import json
import re
import sys
from pathlib import Path
sys.path.insert(0, str(Path(__file__).resolve().parent))
import splitlib as sl
mg = sl.load_module_graph()
TEST_ATTR = re.compile(r"#\[\s*(?:tokio::test|test|rstest|async_std::test)\b")
ITEM_NAME = re.compile(r"\b(?:fn|struct|enum|const|static|type|trait|mod|union)\s+(\w+)")
def items_of(text: str, masked: str, start: int, end: int):
out, depth, item_start, i = [], 0, start, start
while i < end:
c = masked[i]
if c == "{":
depth += 1
elif c == "}":
depth -= 1
if depth == 0:
out.append((item_start, i + 1))
item_start = i + 1
elif c == ";" and depth == 0:
out.append((item_start, i + 1))
item_start = i + 1
i += 1
return [(a, b) for a, b in out if masked[a:b].strip()]
def analyse_file(rel: str, stay_modules: set[str], inline_body: tuple[int, int] | None = None):
text = sl.read_text(rel)
masked = mg.lex_mask(text)
a, b = inline_body or (0, len(text))
spans = items_of(text, masked, a, b)
items = []
imports: dict[str, str] = {}
for sa, sb in spans:
code = masked[sa:sb]
stripped = code.strip()
head = re.sub(r"(?s)^(?:\s*#\[[^\]]*\])*\s*", "", stripped)
kind = "use" if re.match(r"(?:pub(?:\([^)]*\))?\s+)?use\b", head) else "item"
name_m = ITEM_NAME.search(head) if kind == "item" else None
item = {
"a": sa, "b": sb, "kind": kind, "name": name_m.group(1) if name_m else None,
"tests": len(TEST_ATTR.findall(code)), "lines": text.count("\n", sa, sb) + 1,
"tokens": set(re.findall(r"[A-Za-z_]\w*", code)),
"direct": set(re.findall(r"(?<![\w$])crate::(\w+)", code)) & stay_modules,
}
if kind == "use":
body = head[head.index("use") + 3 : head.rindex(";")] if ";" in head else head
for bound, path in mg.use_leaves(body):
if path[0] == "crate" and len(path) > 1:
imports[bound] = path[1]
items.append(item)
defined = {it["name"]: i for i, it in enumerate(items) if it["name"]}
for it in items:
it["via_import"] = {imports[t] for t in it["tokens"] if t in imports} & stay_modules
it["deps"] = {defined[t] for t in it["tokens"] if t in defined and t != it["name"]}
it["stay"] = bool(it["direct"] or it["via_import"])
it["why"] = sorted(it["direct"] | it["via_import"])
changed = True
while changed:
changed = False
for i, it in enumerate(items):
if not it["stay"]:
for d in it["deps"]:
if items[d]["stay"]:
it["stay"] = True
it["why"] = [f"via {items[d]['name']}"] + items[d]["why"][:2]
changed = True
break
return text, items
def main(argv: list[str] | None = None) -> int:
ap = argparse.ArgumentParser(description=__doc__.split("\n\n")[0])
ap.add_argument("modules", nargs="+")
ap.add_argument("--src-crate", default="tui")
ap.add_argument("--dest-crate", default="runtime")
ap.add_argument("--file", action="append", help="classify only these files")
ap.add_argument("--json", action="store_true")
ap.add_argument("--emit-dir")
args = ap.parse_args(argv)
sl.require_git_repo()
src_dir = REPO = sl.REPO_ROOT / "crates" / args.src_crate / "src"
dest_dir = sl.REPO_ROOT / "crates" / args.dest_crate / "src"
moved = set(args.modules)
def top_modules(d: Path) -> set[str]:
return {p.name if p.is_dir() else p.stem for p in d.iterdir() if p.is_dir() or p.suffix == ".rs"} - {"lib", "main"}
stay_modules = top_modules(src_dir) - moved - top_modules(dest_dir)
files = args.file or []
inline: dict[str, tuple[int, int]] = {}
if not files:
for m in args.modules:
for rel in sl.tracked_files(f"crates/{args.src_crate}/src/{m}.rs", f"crates/{args.src_crate}/src/{m}"):
if not rel.endswith(".rs"):
continue
name = Path(rel).name
if name == "tests.rs" or name.endswith(("_tests.rs", "_test.rs")) or name.startswith("test_cases"):
files.append(rel)
else:
text = sl.read_text(rel)
masked = mg.lex_mask(text)
mo = re.search(r"#\[cfg\(test\)\]\s*mod\s+tests\s*\{", masked)
if mo:
files.append(rel)
inline[rel] = (mo.end(), mg.match_brace(masked, mo.end() - 1))
report = []
for rel in files:
text, items = analyse_file(rel, stay_modules, inline.get(rel))
tests = [it for it in items if it["tests"]]
move_tests = sum(it["tests"] for it in tests if not it["stay"])
stay_tests = sum(it["tests"] for it in tests if it["stay"])
blockers = collections.Counter(w for it in tests if it["stay"] for w in it["why"][:1])
helpers_stay = [it["name"] for it in items if it["stay"] and not it["tests"] and it["name"]]
report.append(
{
"file": rel,
"inline": rel in inline,
"tests_total": move_tests + stay_tests,
"tests_move": move_tests,
"tests_stay": stay_tests,
"stay_helpers": helpers_stay,
"blocked_by": dict(blockers.most_common()),
"lines_move": sum(it["lines"] for it in items if not it["stay"]),
"lines_stay": sum(it["lines"] for it in items if it["stay"]),
}
)
if args.emit_dir:
out = Path(args.emit_dir)
out.mkdir(parents=True, exist_ok=True)
uses = [it for it in items if it["kind"] == "use"]
stem = Path(rel).stem
for side, want in (("move", False), ("stay", True)):
needed: set[int] = set()
body = [i for i, it in enumerate(items) if it["kind"] != "use" and it["stay"] == want and it["tests"]]
stack = list(body)
while stack:
i = stack.pop()
if i in needed:
continue
needed.add(i)
stack.extend(items[i]["deps"])
chosen = [it for it in uses] + [items[i] for i in sorted(needed)]
(out / f"{stem}.{side}.rs").write_text("".join(text[it["a"] : it["b"]] for it in chosen), encoding="utf-8")
total = {k: sum(r[k] for r in report) for k in ("tests_total", "tests_move", "tests_stay")}
if args.json:
json.dump({"stay_modules": sorted(stay_modules), "files": report, "total": total}, sys.stdout, indent=1)
print()
else:
print(f"staying source-crate modules considered: {len(stay_modules)}")
for r in report:
print(f"{r['file']}: {r['tests_total']} tests, move {r['tests_move']}, stay {r['tests_stay']}; stay helpers {len(r['stay_helpers'])}; blocked by {dict(list(r['blocked_by'].items())[:5])}")
print(f"total: {total['tests_total']} tests, move {total['tests_move']}, stay {total['tests_stay']}")
return 0
if __name__ == "__main__":
sys.exit(sl.run_main(main))