mirror of
https://github.com/codewhale-hq/Codewhale.git
synced 2026-11-09 01:49:49 -10:00
Scripts only under scripts/split/. No Rust, no manifest, no budget touched. Base00be3d2e4f. 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 of00be3d2e4f, 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]>
193 lines
8.5 KiB
Python
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))
|