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

410 lines
14 KiB
Python

#!/usr/bin/env python3
"""Production/test line counter for the crate split (read-only).
python3 scripts/split/count_lines.py [crates_dir] [--json out.json] [--crate tui]
Two counters are printed for every crate and gated side by side (FD-07):
* [M] strict production: physical lines of production code, test files and
`cfg(test)`-only items excluded;
* [F] file level: physical lines of every non-test file, inline `cfg(test)`
modules included (the design document's method; [F] = [M] + inline test).
A file is TEST when any of:
A. a path component is `tests`
B. the basename contains `tests` (tests.rs, foo_tests.rs, tests_foo.rs)
C. it is declared by a `#[cfg(test)]`-gated `mod x;` (resolved to x.rs or
x/mod.rs next to the declaring module, recursively everything below)
D. it carries an inner `#![cfg(test)]` attribute
E. a string-literal `include!` from test code pulls it in
Inside non-test files, an item annotated with a test-only cfg
(`#[cfg(test)]`, `#[cfg(all(test, ..))]`, `#[cfg(any(test, ..))]`,
`#[cfg(feature = "test-support")]`) is TEST up to the end of its braces/semicolon.
`benches/`, `examples/` and `build.rs` are counted as OTHER (neither).
Lines are physical lines (newline count). Accuracy on tui is about +/-2%.
"""
import json
import os
import re
import sys
from collections import defaultdict
TOKEN = re.compile(
r"""//[^\n]*"""
r"""|/\*.*?\*/"""
r"""|b?r(?P<h>\#*)".*?"(?P=h)"""
r"""|b?"(?:\\.|[^"\\])*\""""
r"""|'(?:\\.[^']*|[^\\'])'""",
re.S,
)
def blank(s: str) -> str:
return re.sub(r"[^\n]", " ", s)
def mask(src: str):
def comments_only(m):
t = m.group(0)
if t.startswith("//") or t.startswith("/*"):
return blank(t)
return t
def everything(m):
t = m.group(0)
if t.startswith("//") or t.startswith("/*"):
return blank(t)
return t[0] + blank(t[1:-1]) + t[-1] if len(t) >= 2 else t
return TOKEN.sub(comments_only, src), TOKEN.sub(everything, src)
CFG_OPEN = re.compile(r"#\s*\[\s*cfg\s*\(")
CFG_TOK = re.compile(r'\s*(?:(?P<id>[A-Za-z_][A-Za-z0-9_]*)|(?P<str>"[^"]*")|(?P<p>[(),=]))')
def cfg_possible(expr: str):
toks = [m for m in CFG_TOK.finditer(expr)]
pos = [0]
def peek():
return toks[pos[0]] if pos[0] < len(toks) else None
def parse():
t = toks[pos[0]]
pos[0] += 1
name = t.group("id")
n = peek()
if n is not None and n.group("p") == "(":
pos[0] += 1
kids = []
while True:
n = peek()
if n is None:
break
if n.group("p") == ")":
pos[0] += 1
break
if n.group("p") == ",":
pos[0] += 1
continue
kids.append(parse())
if name == "not":
return {not v for v in kids[0]} if kids else {True, False}
if name == "all":
r = set()
if all(True in k for k in kids):
r.add(True)
if any(False in k for k in kids):
r.add(False)
return r or {False}
if name == "any":
r = set()
if any(True in k for k in kids):
r.add(True)
if all(False in k for k in kids):
r.add(False)
return r or {False}
return {True, False}
if n is not None and n.group("p") == "=":
pos[0] += 1
v = peek()
pos[0] += 1
if name == "feature" and v is not None and v.group("str") == '"test-support"':
return {False}
return {True, False}
if name == "test":
return {False}
return {True, False}
try:
return parse()
except Exception:
return {True, False}
INCLUDE = re.compile(r'\binclude!\s*\(\s*"([^"]+)"\s*\)')
PATH_ATTR = re.compile(r'#\s*\[\s*path\s*=\s*"([^"]+)"\s*\]')
INNER_CFG = re.compile(r"#\s*!\s*\[\s*cfg\s*\(\s*test\s*\)\s*\]")
ATTR_CLOSE = re.compile(r"\s*\]")
ATTR = re.compile(r"\s*#\s*\[[^\]]*\]")
MODDECL = re.compile(r"\s*(?:pub(?:\([^)]*\))?\s+)?mod\s+(\w+)\s*;")
BR = re.compile(r"[{}]")
ITEM_KW = re.compile(
r"(?:pub(?:\s*\([^)]*\))?\s+)?(?:async\s+|unsafe\s+|const\s+|extern\s+(?:\"[^\"]*\"\s+)?)*"
r"(?:mod|fn|impl|use|struct|enum|trait|type|static|const|macro_rules|extern|let|union)\b"
)
FALLBACK = []
def item_end(m2: str, j: int) -> int:
depth = 0
n = len(m2)
k = j
comma_ends = not ITEM_KW.match(m2, j) and not m2.startswith("{", j)
if comma_ends:
FALLBACK.append(j)
while k < n:
c = m2[k]
if c in "([":
depth += 1
elif c in ")]":
depth -= 1
elif depth <= 0 and c == ";":
return k
elif depth <= 0 and comma_ends and c == ",":
return k
elif depth <= 0 and c == "{":
d = 0
for b in BR.finditer(m2, k):
if b.group(0) == "{":
d += 1
else:
d -= 1
if d == 0:
return b.start()
return n - 1
k += 1
return n - 1
def analyse_file(path: str, src: str):
"""returns (test_ranges[(a,b) char offsets], cfg_test_mod_names, whole_file_test)"""
m1, m2 = mask(src)
if INNER_CFG.search(m2) or any(
INNER_CFG.match(m1, mm.start()) for mm in re.finditer(r"#\s*!", m2)
):
return [], [], True, [(m.start(), m.group(1)) for m in INCLUDE.finditer(m1) if m2[m.start()] == "i"]
ranges = []
mods = []
covered = -1
spans = []
for m in CFG_OPEN.finditer(m1):
if m2[m.start()] != "#":
continue
depth = 1
k = m.end()
while k < len(m1) and depth:
if m1[k] == "(":
depth += 1
elif m1[k] == ")":
depth -= 1
k += 1
close = ATTR_CLOSE.match(m1, k)
if not close:
continue
expr = m1[m.end():k - 1]
if not re.search(r"\btest\b|test-support", expr):
continue
if True in cfg_possible(expr):
continue
spans.append((m.start(), close.end()))
for ms, me in spans:
if ms <= covered:
continue
j = me
path_attr = None
while True:
a = ATTR.match(m2, j)
if not a:
break
pm = PATH_ATTR.match(m1, j + (len(m1[j:]) - len(m1[j:].lstrip())))
if pm:
path_attr = pm.group(1)
j = a.end()
md = MODDECL.match(m2, j)
if md:
mods.append(("path:" + path_attr) if path_attr else md.group(1))
end = md.end()
else:
while j < len(m2) and m2[j].isspace():
j += 1
end = item_end(m2, j)
ranges.append((ms, end))
covered = end
incs = [(m.start(), m.group(1)) for m in INCLUDE.finditer(m1) if m2[m.start()] == "i"]
return ranges, mods, False, incs
def read(path):
with open(path, encoding="utf-8", errors="replace") as f:
return f.read()
def collect(crate_dir):
files = []
for dp, dn, fn in os.walk(crate_dir):
dn[:] = [d for d in dn if d not in ("target", "node_modules", ".git")]
for f in fn:
if f.endswith(".rs"):
files.append(os.path.join(dp, f))
return sorted(files)
def classify_crate(crate_dir, tui_style=False):
files = collect(crate_dir)
info = {}
for p in files:
src = read(p)
rel = os.path.relpath(p, crate_dir)
info[p] = dict(rel=rel, src=src, nl=src.count("\n") + (0 if src.endswith("\n") or not src else 1))
test_files = set()
other_files = set()
cfg_mods = {}
for p, d in info.items():
rel = d["rel"]
parts = rel.split("/")
base = parts[-1]
if parts[0] in ("benches", "examples") or rel == "build.rs":
other_files.add(p)
continue
if "tests" in parts[:-1] or "tests" in base:
test_files.add(p)
for p, d in info.items():
if p in other_files:
continue
ranges, mods, whole, incs = analyse_file(p, d["src"])
d["ranges"] = ranges
d["incs"] = incs
if whole:
test_files.add(p)
base = os.path.basename(p)
stem = base[:-3]
mdir = os.path.dirname(p) if base in ("mod.rs", "lib.rs", "main.rs") else os.path.join(os.path.dirname(p), stem)
for name in mods:
if name.startswith("path:"):
cands = (os.path.normpath(os.path.join(os.path.dirname(p), name[5:])),)
else:
cands = (os.path.join(mdir, name + ".rs"), os.path.join(mdir, name, "mod.rs"))
for cand in cands:
if cand in info:
test_files.add(cand)
changed = True
while changed:
changed = False
for p, d in info.items():
for off, rel in d.get("incs", []):
tgt = os.path.normpath(os.path.join(os.path.dirname(p), rel))
if tgt not in info or tgt in test_files:
continue
if p in test_files or any(a <= off <= b for a, b in d.get("ranges", [])):
test_files.add(tgt)
changed = True
for p in list(test_files):
base = os.path.basename(p)
stem = base[:-3]
if base == "mod.rs":
sub = os.path.dirname(p) + os.sep
else:
sub = os.path.join(os.path.dirname(p), stem) + os.sep
for q in info:
if q.startswith(sub) and q not in test_files and q not in other_files:
test_files.add(q)
changed = True
res = {}
for p, d in info.items():
nl = d["nl"]
if p in other_files:
res[p] = (0, 0, nl)
elif p in test_files:
res[p] = (0, nl, 0)
else:
src = d["src"]
tl = 0
for a, b in d.get("ranges", []):
tl += src.count("\n", a, b) + 1
tl = min(tl, nl)
res[p] = (nl - tl, tl, 0)
return info, res
def summarise(crates_dir):
out = {}
tui_detail = None
for c in sorted(os.listdir(crates_dir)):
cd = os.path.join(crates_dir, c)
if not os.path.isfile(os.path.join(cd, "Cargo.toml")):
continue
info, res = classify_crate(cd)
prod = sum(v[0] for v in res.values())
test = sum(v[1] for v in res.values())
other = sum(v[2] for v in res.values())
inline_test = sum(tl for (pl, tl, ol) in res.values() if pl or ol)
big = []
for p, d in info.items():
if res[p][0] or (res[p][1] == 0 and res[p][2] == 0):
for a, b in d.get("ranges", []):
big.append((d["src"].count("\n", a, b) + 1, d["rel"], " ".join(d["src"][a:b + 1].split())[:90]))
big.sort(reverse=True)
out[c] = dict(production_lines=prod, file_level_lines=prod + inline_test, test_lines=test,
other_lines=other, inline_cfg_test_lines=inline_test, files=len(res),
inline_ranges=len(big), inline_top=big[:6])
if c == "tui":
tui_detail = (cd, info, res)
tui_mods = {}
if tui_detail:
cd, info, res = tui_detail
src_root = os.path.join(cd, "src") + os.sep
mods = defaultdict(lambda: dict(production_lines=0, file_level_lines=0, test_lines=0, files=0, biggest=[]))
for p, (pl, tl, ol) in res.items():
if not p.startswith(src_root):
continue
rel = p[len(src_root):]
parts = rel.split("/")
if len(parts) == 1:
stem = parts[0][:-3]
mod = {"lib": "<lib.rs>", "main": "<main.rs>"}.get(stem, stem)
else:
mod = parts[0]
m = mods[mod]
m["production_lines"] += pl
m["file_level_lines"] += pl + (tl if (pl or ol) else 0)
m["test_lines"] += tl
m["files"] += 1
if pl:
m["biggest"].append((pl, rel))
for m in mods.values():
m["biggest"] = sorted(m["biggest"], reverse=True)[:3]
tui_mods = dict(mods)
out["tui"]["production_outside_src"] = sum(v[0] for p, v in res.items() if not p.startswith(src_root))
return dict(crates=out, tui_modules=tui_mods, fallback_uses=len(FALLBACK))
def main():
import argparse
repo = os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
ap = argparse.ArgumentParser(description=__doc__.split("\n\n")[0])
ap.add_argument("crates_dir", nargs="?", default=os.path.join(repo, "crates"))
ap.add_argument("--json", metavar="FILE", help="write the full result as JSON ('-' for stdout)")
ap.add_argument("--crate", action="append", help="print only these crates")
ap.add_argument("--top", type=int, default=0, help="also list the N largest tui modules")
args = ap.parse_args()
result = summarise(args.crates_dir)
if args.json:
text = json.dumps(result, indent=1)
if args.json == "-":
print(text)
else:
with open(args.json, "w", encoding="utf-8") as fh:
fh.write(text + "\n")
rows = result["crates"]
names = args.crate or sorted(rows, key=lambda n: -rows[n]["production_lines"])
print(f"{'crate':22}{'[M] prod':>11}{'[F] file':>11}{'test':>11}{'inline test':>13}{'files':>7}")
for n in names:
r = rows[n]
print(f"{n:22}{r['production_lines']:>11,}{r['file_level_lines']:>11,}{r['test_lines']:>11,}{r['inline_cfg_test_lines']:>13,}{r['files']:>7}")
tot_m = sum(r["production_lines"] for r in rows.values())
tot_f = sum(r["file_level_lines"] for r in rows.values())
print(f"{'all crates':22}{tot_m:>11,}{tot_f:>11,}")
if args.top and result["tui_modules"]:
print("\nlargest tui modules ([M] / [F]):")
for name, m in sorted(result["tui_modules"].items(), key=lambda kv: -kv[1]["production_lines"])[: args.top]:
print(f" {name:28}{m['production_lines']:>9,}{m['file_level_lines']:>10,}")
if __name__ == "__main__":
main()