mirror of
https://github.com/codewhale-hq/Codewhale.git
synced 2026-11-08 01:48:20 -10:00
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. 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]>
This commit is contained in:
1 parent
00be3d2e4f
commit
3b35f098f7
10 files changed
+2569
-178
No files matched your search
@@ -13,11 +13,16 @@ lines in its reverse closure (what a change to it can rebuild).
|
||||
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]
|
||||
|
||||
|
||||
@@ -70,6 +75,11 @@ def main() -> None:
|
||||
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()
|
||||
@@ -88,8 +98,13 @@ def main() -> None:
|
||||
|
||||
own = {}
|
||||
for name, pkg in packages.items():
|
||||
src = Path(pkg["manifest_path"]).parent / "src"
|
||||
own[name] = rust_lines(src, args.tests) if src.is_dir() else 0
|
||||
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:
|
||||
|
||||
@@ -0,0 +1,409 @@
|
||||
#!/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()
|
||||
@@ -101,18 +101,33 @@ def is_ui(module: str) -> bool:
|
||||
|
||||
_RAW_STR = re.compile(r'b?r(#*)"')
|
||||
_CHAR = re.compile(r"'(\\.[^']*|[^\\'])'")
|
||||
_START = re.compile(r'//|/\*|b?r#*"|b?"|\'')
|
||||
_BLOCK = re.compile(r'/\*|\*/')
|
||||
_STR_STOP = re.compile(r'[\\"]')
|
||||
_NOT_NL = re.compile(r'[^\n]')
|
||||
|
||||
|
||||
def lex_mask(src: str) -> str:
|
||||
out = list(src)
|
||||
i, n = 0, len(src)
|
||||
n = len(src)
|
||||
out = []
|
||||
last = 0
|
||||
i = 0
|
||||
|
||||
def blank(a: int, b: int) -> None:
|
||||
for k in range(a, min(b, n)):
|
||||
if out[k] != "\n":
|
||||
out[k] = " "
|
||||
def blank(a, b):
|
||||
nonlocal last
|
||||
b = min(b, n)
|
||||
if a >= b:
|
||||
return
|
||||
out.append(src[last:a])
|
||||
out.append(_NOT_NL.sub(" ", src[a:b]))
|
||||
last = b
|
||||
|
||||
while i < n:
|
||||
search = _START.search
|
||||
while True:
|
||||
m = search(src, i)
|
||||
if not m:
|
||||
break
|
||||
i = m.start()
|
||||
c = src[i]
|
||||
if src.startswith("//", i):
|
||||
j = src.find("\n", i)
|
||||
@@ -123,22 +138,23 @@ def lex_mask(src: str) -> str:
|
||||
if src.startswith("/*", i):
|
||||
depth, j = 1, i + 2
|
||||
while j < n and depth:
|
||||
if src.startswith("/*", j):
|
||||
mm = _BLOCK.search(src, j)
|
||||
if not mm:
|
||||
j = n
|
||||
break
|
||||
if mm.group(0) == "/*":
|
||||
depth += 1
|
||||
j += 2
|
||||
elif src.startswith("*/", j):
|
||||
depth -= 1
|
||||
j += 2
|
||||
else:
|
||||
j += 1
|
||||
depth -= 1
|
||||
j = mm.end()
|
||||
blank(i, j)
|
||||
i = j
|
||||
continue
|
||||
prev_ident = i > 0 and (src[i - 1].isalnum() or src[i - 1] == "_")
|
||||
m = _RAW_STR.match(src, i, i + 10)
|
||||
if m and not prev_ident:
|
||||
end = '"' + m.group(1)
|
||||
j = src.find(end, i + len(m.group(0)))
|
||||
rm = _RAW_STR.match(src, i, i + 10)
|
||||
if rm and not prev_ident:
|
||||
end = '"' + rm.group(1)
|
||||
j = src.find(end, i + len(rm.group(0)))
|
||||
j = n if j < 0 else j + len(end)
|
||||
blank(i + 1, j - 1)
|
||||
i = j
|
||||
@@ -146,17 +162,24 @@ def lex_mask(src: str) -> str:
|
||||
if c == '"' or (c == "b" and i + 1 < n and src[i + 1] == '"' and not prev_ident):
|
||||
j = i + (2 if c == "b" else 1)
|
||||
while j < n and src[j] != '"':
|
||||
j += 2 if src[j] == "\\" else 1
|
||||
sm = _STR_STOP.search(src, j)
|
||||
if not sm:
|
||||
j = n
|
||||
break
|
||||
j = sm.start()
|
||||
if src[j] == "\\":
|
||||
j += 2
|
||||
blank(i + 1, j)
|
||||
i = j + 1
|
||||
continue
|
||||
if c == "'":
|
||||
m = _CHAR.match(src, i, i + 12)
|
||||
if m:
|
||||
blank(i + 1, i + len(m.group(0)) - 1)
|
||||
i += len(m.group(0))
|
||||
cm = _CHAR.match(src, i, i + 12)
|
||||
if cm:
|
||||
blank(i + 1, i + len(cm.group(0)) - 1)
|
||||
i += len(cm.group(0))
|
||||
continue
|
||||
i += 1
|
||||
out.append(src[last:])
|
||||
return "".join(out)
|
||||
|
||||
|
||||
@@ -695,9 +718,11 @@ def main(argv: list[str] | None = None) -> int:
|
||||
"--baseline-ref",
|
||||
help="git revision whose committed baseline the current one may not exceed (CI: the PR base)",
|
||||
)
|
||||
parser.add_argument("--tui-src", type=Path, default=TUI_SRC, help="source directory of the crate being split")
|
||||
parser.add_argument("--runtime-src", type=Path, default=RUNTIME_SRC, help="source directory of the destination crate")
|
||||
args = parser.parse_args(argv)
|
||||
|
||||
report = build_report()
|
||||
report = build_report(args.tui_src.resolve(), args.runtime_src.resolve())
|
||||
if args.report:
|
||||
json.dump(
|
||||
{"closure": report.closure, "totals": totals(report.counts), "counts": report.counts,
|
||||
|
||||
+736
-137
@@ -1,177 +1,776 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Move top-level TUI modules into crates/runtime (the runtime/TUI split).
|
||||
"""Move top-level modules from one workspace crate into another (crate split).
|
||||
|
||||
For each named module this:
|
||||
python3 scripts/split/move-modules.py --dry-run --json config oauth
|
||||
python3 scripts/split/move-modules.py --require-clean config oauth
|
||||
python3 scripts/split/move-modules.py --resolve-reexports --dry-run config
|
||||
python3 scripts/split/move-modules.py --restore <journal-dir>
|
||||
|
||||
1. `git mv`s `crates/tui/src/<m>.rs` and/or `crates/tui/src/<m>/` into
|
||||
`crates/runtime/src/`;
|
||||
2. deletes its `mod <m>;` / `pub mod <m>;` line from the TUI `lib.rs` and adds
|
||||
`pub mod <m>;` to the runtime `lib.rs` (sorted);
|
||||
3. rewrites every `crate::<m>` path in `crates/tui/src` to
|
||||
`codewhale_runtime::<m>` and, when the TUI `lib.rs` uses `<m>::` unqualified,
|
||||
adds `use codewhale_runtime::<m>;` there;
|
||||
4. sweeps every string occurrence of the old path across the repository
|
||||
(`crates/tui/src/<m>` and `tui/src/<m>`), found with `git grep`.
|
||||
CHANGELOG files are history and are left alone.
|
||||
For each named module, moves `crates/<src>/src/<m>.rs` and `<m>/` into
|
||||
`crates/<dest>/src/` (default tui -> runtime) by plain file operations, never
|
||||
`git mv`, so the shared index is not touched; and rewrites:
|
||||
|
||||
It changes no item visibility; `scripts/split/widen.py` does that, driven by
|
||||
the compiler.
|
||||
* the `mod <m>;` line (removed from the source lib, `pub mod <m>;` added to the
|
||||
destination lib, sorted);
|
||||
* every `crate::<m>` in the staying source files, plain paths and grouped
|
||||
`use crate::{..}` lists, to `<dest crate>::<m>`; unqualified `<m>::` in the
|
||||
source lib gets a `use <dest crate>::<m>;`;
|
||||
* every `<src crate>::<m>` in any other Rust file of the repository;
|
||||
* `<dest crate>::` to `crate::` inside the moved files;
|
||||
* path strings in tracked text files (`crates/<src>/src/<m>`, `<src>/src/<m>`
|
||||
and segmented `"tui" / "src" / "<m>"`), except `vendor/`, `crates/config/`,
|
||||
`CHANGELOG*` and `web/data/*.generated.json` (regenerate those with
|
||||
`--derive-facts`).
|
||||
|
||||
Usage:
|
||||
python3 scripts/split/move-modules.py <module> [<module> ...]
|
||||
python3 scripts/split/move-modules.py --dry-run <module> ...
|
||||
Safety: git failures are fatal and the root must hold `.git`; `--require-clean`
|
||||
refuses when any file of the rewrite set is dirty or younger than
|
||||
`--quiet-minutes`; writes are compare-and-swap with a pre-image journal under
|
||||
`CW/backups/` and any change between plan and write restores everything;
|
||||
rewritten Rust is formatted in memory with `rustfmt --edition 2024` on stdin.
|
||||
|
||||
`--resolve-reexports` does not move anything: for each facade module it
|
||||
rewrites `crate::<facade>::NAME` to the upstream path that `pub use` hides.
|
||||
Manifest and `cfg` planning is report-only; `--apply-cfg` flips the
|
||||
`#[cfg(test)]` items that code outside the moved set uses to
|
||||
`any(test, feature = "test-support")`. It never touches `cfg(not(test))`
|
||||
behaviour forks (test isolation is a decision, not a rewrite).
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import collections
|
||||
import json
|
||||
import os
|
||||
import re
|
||||
import subprocess
|
||||
import sys
|
||||
import tomllib
|
||||
from pathlib import Path
|
||||
|
||||
REPO_ROOT = Path(__file__).resolve().parents[2]
|
||||
TUI_SRC = REPO_ROOT / "crates" / "tui" / "src"
|
||||
RUNTIME_SRC = REPO_ROOT / "crates" / "runtime" / "src"
|
||||
TUI_LIB = TUI_SRC / "lib.rs"
|
||||
RUNTIME_LIB = RUNTIME_SRC / "lib.rs"
|
||||
sys.path.insert(0, str(Path(__file__).resolve().parent))
|
||||
import splitlib as sl
|
||||
from splitlib import REPO_ROOT, Change, Fingerprint, SplitError
|
||||
|
||||
mg = sl.load_module_graph()
|
||||
|
||||
MUST_SWEEP = (".gitattributes", "scripts/command-migration-topology.json", "docs/features.toml")
|
||||
GENERATED_BY_DERIVE_FACTS = (
|
||||
"web/lib/facts.generated.ts",
|
||||
"web/data/features.generated.json",
|
||||
"web/data/agent-docs.generated.json",
|
||||
)
|
||||
BUILTIN_STD = {"crate", "self", "super", "std", "core", "alloc", "Self"}
|
||||
|
||||
|
||||
def git(*args: str, check: bool = True) -> str:
|
||||
return subprocess.run(
|
||||
["git", *args], cwd=REPO_ROOT, capture_output=True, text=True, check=check
|
||||
).stdout
|
||||
def log(msg: str) -> None:
|
||||
print(msg, file=sys.stderr)
|
||||
|
||||
|
||||
def module_paths(module: str) -> list[Path]:
|
||||
paths = [p for p in (TUI_SRC / f"{module}.rs", TUI_SRC / module) if p.exists()]
|
||||
if not paths:
|
||||
raise SystemExit(f"move-modules: no crates/tui/src/{module}.rs or {module}/")
|
||||
return paths
|
||||
def first_segment(masked_entry: str) -> str | None:
|
||||
m = re.match(r"\s*(?:::)?([A-Za-z_]\w*)", masked_entry)
|
||||
return m.group(1) if m else None
|
||||
|
||||
|
||||
def remove_mod_line(text: str, module: str) -> tuple[str, bool]:
|
||||
pattern = re.compile(rf"^(?:pub(?:\([^)]*\))?\s+)?mod\s+{re.escape(module)}\s*;\n", re.M)
|
||||
match = pattern.search(text)
|
||||
if not match:
|
||||
return text, False
|
||||
# Refuse to drop a mod line that carries attributes (cfg, path): that
|
||||
# module needs a human decision, not a mechanical move.
|
||||
before = text[: match.start()].rstrip("\n").rsplit("\n", 1)[-1].strip()
|
||||
if before.startswith("#["):
|
||||
raise SystemExit(f"move-modules: `mod {module};` carries an attribute ({before}); move it by hand")
|
||||
return text[: match.start()] + text[match.end() :], True
|
||||
def split_entries(text: str, masked: str, a: int, b: int) -> list[tuple[str, str]]:
|
||||
entries, depth, start = [], 0, a
|
||||
for i in range(a, b):
|
||||
ch = masked[i]
|
||||
if ch == "{":
|
||||
depth += 1
|
||||
elif ch == "}":
|
||||
depth -= 1
|
||||
elif ch == "," and depth == 0:
|
||||
entries.append((text[start:i], masked[start:i]))
|
||||
start = i + 1
|
||||
entries.append((text[start:b], masked[start:b]))
|
||||
return [(t.strip(), m.strip()) for t, m in entries if m.strip()]
|
||||
|
||||
|
||||
def add_runtime_mod(text: str, module: str) -> str:
|
||||
line = f"pub mod {module};\n"
|
||||
if line in text:
|
||||
def attribute_lines_above(text: str, pos: int, indent: str) -> str:
|
||||
lines = text[:pos].split("\n")
|
||||
if lines and lines[-1] == "":
|
||||
lines = lines[:-1]
|
||||
block: list[str] = []
|
||||
while lines and re.match(r"^\s*#\[.*\]\s*$", lines[-1]):
|
||||
block.append(lines.pop().strip())
|
||||
return "".join(f"{indent}{line}\n" for line in reversed(block))
|
||||
|
||||
|
||||
class Stats:
|
||||
def __init__(self) -> None:
|
||||
self.plain: dict[str, int] = {}
|
||||
self.comment_hits = 0
|
||||
self.string_hits: list[str] = []
|
||||
self.grouped: list[dict] = []
|
||||
self.unqualified: list[dict] = []
|
||||
self.self_sites = 0
|
||||
self.self_skipped = 0
|
||||
self.self_files = 0
|
||||
self.residue = collections.defaultdict(list)
|
||||
self.super_root: list[dict] = []
|
||||
|
||||
|
||||
def split_groups(text: str, rel: str, bases: list[str], mods: set[str], dest_ident: str, stats: Stats) -> str:
|
||||
base_alt = "|".join(re.escape(b) for b in bases)
|
||||
group_re = re.compile(rf"(?m)^(?P<indent>[ \t]*)(?P<vis>pub(?:\([^)]*\))?[ \t]+)?use[ \t]+(?P<base>{base_alt})::\{{")
|
||||
if not group_re.search(text):
|
||||
return text
|
||||
lines = text.splitlines(keepends=True)
|
||||
mods = [i for i, l in enumerate(lines) if re.match(r"pub mod \w+;\n", l)]
|
||||
if not mods:
|
||||
return text.rstrip("\n") + "\n\n" + line
|
||||
insert = mods[-1] + 1
|
||||
for i in mods:
|
||||
if lines[i] > line:
|
||||
insert = i
|
||||
break
|
||||
lines.insert(insert, line)
|
||||
return "".join(lines)
|
||||
|
||||
|
||||
def rewrite_callers(modules: list[str], dry_run: bool) -> list[str]:
|
||||
alt = "|".join(re.escape(m) for m in modules)
|
||||
pattern = re.compile(rf"\bcrate::({alt})(?![A-Za-z0-9_])")
|
||||
touched: list[str] = []
|
||||
for rel in git("ls-files", "crates/tui/src", check=False).split():
|
||||
if not rel.endswith(".rs"):
|
||||
masked = mg.lex_mask(text)
|
||||
out, pos, changed = [], 0, False
|
||||
for m in group_re.finditer(masked):
|
||||
if m.start() < pos:
|
||||
continue
|
||||
path = REPO_ROOT / rel
|
||||
text = path.read_text(encoding="utf-8")
|
||||
updated = pattern.sub(lambda m: f"codewhale_runtime::{m.group(1)}", text)
|
||||
if updated != text:
|
||||
touched.append(rel)
|
||||
if not dry_run:
|
||||
path.write_text(updated, encoding="utf-8")
|
||||
return touched
|
||||
|
||||
|
||||
def import_unqualified(text: str, modules: list[str]) -> str:
|
||||
needed = [
|
||||
m
|
||||
for m in modules
|
||||
if re.search(rf"(?<![\w:]){re.escape(m)}::", text)
|
||||
and f"use codewhale_runtime::{m};" not in text
|
||||
]
|
||||
if not needed:
|
||||
open_i = m.end() - 1
|
||||
close_i = mg.match_brace(masked, open_i)
|
||||
semi = masked.find(";", close_i)
|
||||
if semi < 0:
|
||||
continue
|
||||
entries = split_entries(text, masked, open_i + 1, close_i)
|
||||
moved = [t for t, mk in entries if first_segment(mk) in mods]
|
||||
if not moved:
|
||||
continue
|
||||
kept = [t for t, mk in entries if first_segment(mk) not in mods]
|
||||
indent, vis, base = m.group("indent"), m.group("vis") or "", m.group("base")
|
||||
line = masked.count("\n", 0, m.start()) + 1
|
||||
stats.grouped.append({"file": rel, "line": line, "moved": [first_segment(mk) for t, mk in entries if t in moved]})
|
||||
moved_stmt = f"{indent}{vis}use {dest_ident}::{{{', '.join(moved)}}};"
|
||||
if kept:
|
||||
attrs = attribute_lines_above(text, m.start(), indent)
|
||||
new = f"{indent}{vis}use {base}::{{{', '.join(kept)}}};\n{attrs}{moved_stmt}"
|
||||
else:
|
||||
new = moved_stmt
|
||||
out.append(text[pos : m.start()])
|
||||
out.append(new)
|
||||
pos = semi + 1
|
||||
changed = True
|
||||
if not changed:
|
||||
return text
|
||||
anchor = re.search(r"^use codewhale_runtime::.*\n", text, re.M) or re.search(r"^use .*\n", text, re.M)
|
||||
lines = "".join(f"use codewhale_runtime::{m};\n" for m in sorted(needed))
|
||||
return text[: anchor.start()] + lines + text[anchor.start() :]
|
||||
out.append(text[pos:])
|
||||
return "".join(out)
|
||||
|
||||
|
||||
def sweep_paths(module: str, dry_run: bool) -> list[str]:
|
||||
patterns = [
|
||||
(f"crates/tui/src/{module}", f"crates/runtime/src/{module}"),
|
||||
(f"tui/src/{module}", f"runtime/src/{module}"),
|
||||
]
|
||||
touched: list[str] = []
|
||||
for old, new in patterns:
|
||||
# The module as a whole path segment: `elapsed.rs`, `elapsed/`,
|
||||
# `elapsed::`, never `elapsed_time.rs`; `tui/src/x` only when it is not
|
||||
# already the tail of `crates/tui/src/x`.
|
||||
regex = re.compile(
|
||||
rf"(?<![A-Za-z0-9_/]){re.escape(old)}(?![A-Za-z0-9_])"
|
||||
if not old.startswith("crates/")
|
||||
else rf"{re.escape(old)}(?![A-Za-z0-9_])"
|
||||
)
|
||||
for rel in git("grep", "-l", "-F", old, check=False).split():
|
||||
if Path(rel).name.startswith("CHANGELOG"):
|
||||
def rewrite_paths(text: str, rel: str, bases: list[str], mods: set[str], dest_ident: str, stats: Stats) -> str:
|
||||
if not any(b + "::" in text for b in bases):
|
||||
return text
|
||||
text = split_groups(text, rel, bases, mods, dest_ident, stats)
|
||||
alt = "|".join(re.escape(m) for m in sorted(mods))
|
||||
base_alt = "|".join(re.escape(b) for b in bases)
|
||||
pat = re.compile(rf"(?<![\$\w])(?:{base_alt})::({alt})(?![A-Za-z0-9_])")
|
||||
hits = list(pat.finditer(text))
|
||||
if not hits:
|
||||
return text
|
||||
lines = set()
|
||||
for m in hits:
|
||||
lineno = text.count("\n", 0, m.start()) + 1
|
||||
lines.add(lineno)
|
||||
prefix = text[text.rfind("\n", 0, m.start()) + 1 : m.start()]
|
||||
if prefix.lstrip().startswith(("//", "*", "/*")) or "//" in prefix:
|
||||
stats.comment_hits += 1
|
||||
elif prefix.count('"') % 2 == 1:
|
||||
stats.string_hits.append(f"{rel}:{lineno}")
|
||||
stats.plain[rel] = len(lines)
|
||||
return pat.sub(lambda m: f"{dest_ident}::{m.group(1)}", text)
|
||||
|
||||
|
||||
def add_unqualified_imports(text: str, rel: str, mods: set[str], dest_ident: str, stats: Stats) -> str:
|
||||
masked = mg.lex_mask(text)
|
||||
prod = mg.strip_cfg_test(masked, [])
|
||||
alt = "|".join(re.escape(m) for m in sorted(mods))
|
||||
pat = re.compile(rf"(?<![\w:$])({alt})::")
|
||||
seen: dict[str, str] = {}
|
||||
for m in pat.finditer(masked):
|
||||
name = m.group(1)
|
||||
if f"use {dest_ident}::{name};" in text:
|
||||
continue
|
||||
line = masked.count("\n", 0, m.start()) + 1
|
||||
in_prod = prod[m.start() : m.end()] == m.group(0)
|
||||
stats.unqualified.append({"file": rel, "line": line, "module": name, "production": in_prod})
|
||||
if in_prod or name not in seen:
|
||||
seen[name] = "prod" if in_prod else "test"
|
||||
elif seen.get(name) != "prod":
|
||||
seen[name] = "test"
|
||||
if not seen:
|
||||
return text
|
||||
anchor = re.search(r"(?m)^use .*", masked)
|
||||
if not anchor:
|
||||
raise SplitError(f"{rel}: no top-level `use` to anchor imports for {sorted(seen)}")
|
||||
block = ""
|
||||
for name in sorted(seen):
|
||||
gate = "" if seen[name] == "prod" else "#[cfg(test)]\n"
|
||||
block += f"{gate}use {dest_ident}::{name};\n"
|
||||
return text[: anchor.start()] + block + text[anchor.start() :]
|
||||
|
||||
|
||||
def rewrite_moved(
|
||||
text: str, rel: str, dest_ident: str, resident: set[str], src_ident: str, mods: set[str], stats: Stats
|
||||
) -> str:
|
||||
masked = mg.lex_mask(text)
|
||||
pat = re.compile(rf"(?<![\w:$]){re.escape(dest_ident)}::")
|
||||
out, pos, sites = [], 0, 0
|
||||
for m in pat.finditer(text):
|
||||
if masked[m.start() : m.end()] != m.group(0):
|
||||
stats.self_skipped += 1
|
||||
continue
|
||||
out.append(text[pos : m.start()])
|
||||
out.append("crate::")
|
||||
pos = m.end()
|
||||
sites += 1
|
||||
if sites:
|
||||
out.append(text[pos:])
|
||||
text = "".join(out)
|
||||
stats.self_sites += sites
|
||||
stats.self_files += 1
|
||||
masked = mg.lex_mask(text)
|
||||
for m in re.finditer(r"(?<![\$\w])crate::(\w+)", masked):
|
||||
target = m.group(1)
|
||||
if target not in resident:
|
||||
line = masked.count("\n", 0, m.start()) + 1
|
||||
stats.residue[target].append(f"{rel}:{line}")
|
||||
for m in re.finditer(r"\buse\s+crate::\{", masked):
|
||||
close = mg.match_brace(masked, m.end() - 1)
|
||||
for _, path in mg.use_leaves(masked[m.end() : close]):
|
||||
if path[0] not in resident and path[0] not in BUILTIN_STD:
|
||||
line = masked.count("\n", 0, m.start()) + 1
|
||||
stats.residue[path[0]].append(f"{rel}:{line}")
|
||||
for pos_, name in mg.super_root_targets(masked, rel.split("/src/", 1)[-1]):
|
||||
if name not in resident and name not in BUILTIN_STD and name in mods | {"lib"}:
|
||||
stats.super_root.append({"file": rel, "line": masked.count("\n", 0, pos_) + 1, "target": name})
|
||||
return text
|
||||
|
||||
|
||||
class Mover:
|
||||
def __init__(self, args: argparse.Namespace) -> None:
|
||||
self.args = args
|
||||
self.mods = list(args.modules)
|
||||
self.modset = set(self.mods)
|
||||
self.src, self.dest = args.src_crate, args.dest_crate
|
||||
self.src_dir = f"crates/{self.src}/src"
|
||||
self.dest_dir = f"crates/{self.dest}/src"
|
||||
self.src_ident = sl.crate_ident(self.src)
|
||||
self.dest_ident = sl.crate_ident(self.dest)
|
||||
self.texts: dict[str, str] = {}
|
||||
self.fps: dict[str, Fingerprint] = {}
|
||||
self.new: dict[str, str | None] = {}
|
||||
self.stats = Stats()
|
||||
self.moves: dict[str, str] = {}
|
||||
self.other_crate_files: list[str] = []
|
||||
self.sweep_files: list[str] = []
|
||||
self.sweep_skipped: list[str] = []
|
||||
self.sweep_segmented = 0
|
||||
self.cfg_sites: list[dict] = []
|
||||
self.manifest: dict = {}
|
||||
self.extra_changes: list[str] = []
|
||||
|
||||
def load(self, rel: str) -> str:
|
||||
if rel in self.texts:
|
||||
return self.texts[rel]
|
||||
path = REPO_ROOT / rel
|
||||
with open(path, "rb") as fh:
|
||||
st = os.fstat(fh.fileno())
|
||||
data = fh.read()
|
||||
self.fps[rel] = Fingerprint(st.st_size, st.st_mtime_ns, st.st_ino, sl.sha256_bytes(data))
|
||||
self.texts[rel] = data.decode("utf-8")
|
||||
return self.texts[rel]
|
||||
|
||||
def plan_moves(self) -> None:
|
||||
for m in self.mods:
|
||||
files = sl.tracked_files(f"{self.src_dir}/{m}.rs", f"{self.src_dir}/{m}")
|
||||
files = [f for f in files if f == f"{self.src_dir}/{m}.rs" or f.startswith(f"{self.src_dir}/{m}/")]
|
||||
if not files:
|
||||
raise SplitError(f"no tracked {self.src_dir}/{m}.rs or {m}/")
|
||||
disk = REPO_ROOT / self.src_dir / m
|
||||
if disk.is_dir():
|
||||
tracked = set(files)
|
||||
loose = [
|
||||
str(p.relative_to(REPO_ROOT))
|
||||
for p in disk.rglob("*")
|
||||
if p.is_file() and str(p.relative_to(REPO_ROOT)) not in tracked
|
||||
]
|
||||
if loose:
|
||||
raise SplitError(f"untracked files under {self.src_dir}/{m}: {loose[:5]}")
|
||||
for f in files:
|
||||
dest = self.dest_dir + f[len(self.src_dir) :]
|
||||
if (REPO_ROOT / dest).exists():
|
||||
raise SplitError(f"destination {dest} already exists")
|
||||
self.moves[f] = dest
|
||||
|
||||
def resident_modules(self) -> set[str]:
|
||||
out = set(self.modset)
|
||||
base = REPO_ROOT / self.dest_dir
|
||||
for p in base.iterdir():
|
||||
if p.is_dir():
|
||||
out.add(p.name)
|
||||
elif p.suffix == ".rs" and p.stem not in ("lib", "main"):
|
||||
out.add(p.stem)
|
||||
return out
|
||||
|
||||
def run_plan(self) -> None:
|
||||
sl.require_git_repo()
|
||||
src_lib, dest_lib = f"{self.src_dir}/lib.rs", f"{self.dest_dir}/lib.rs"
|
||||
if not (REPO_ROOT / dest_lib).is_file():
|
||||
raise SplitError(f"{dest_lib} does not exist")
|
||||
self.plan_moves()
|
||||
resident = self.resident_modules()
|
||||
all_src = [f for f in sl.tracked_files(self.src_dir) if f.endswith(".rs")]
|
||||
staying = [f for f in all_src if f not in self.moves]
|
||||
bases = ["crate", self.src_ident]
|
||||
|
||||
lib_text = self.load(src_lib)
|
||||
for m in self.mods:
|
||||
lib_text = self.remove_mod_line(lib_text, m)
|
||||
dest_text = self.load(dest_lib)
|
||||
for m in self.mods:
|
||||
dest_text = self.add_dest_mod(dest_text, m)
|
||||
self.new[dest_lib] = dest_text
|
||||
|
||||
for rel in staying:
|
||||
text = lib_text if rel == src_lib else self.load(rel)
|
||||
new = rewrite_paths(text, rel, bases, self.modset, self.dest_ident, self.stats)
|
||||
if rel == src_lib:
|
||||
new = add_unqualified_imports(new, rel, self.modset, self.dest_ident, self.stats)
|
||||
masked_needed = "super::" in new and any(m in new for m in self.mods)
|
||||
if masked_needed:
|
||||
masked = mg.lex_mask(new)
|
||||
for pos_, name in mg.super_root_targets(masked, rel[len(self.src_dir) + 1 :]):
|
||||
if name in self.modset:
|
||||
self.stats.super_root.append({"file": rel, "line": masked.count("\n", 0, pos_) + 1, "target": name})
|
||||
if new != self.load(rel):
|
||||
self.new[rel] = new
|
||||
|
||||
internal_lines = internal_files = 0
|
||||
pat = re.compile(rf"(?<![\$\w])crate::({'|'.join(re.escape(m) for m in self.mods)})(?![A-Za-z0-9_])")
|
||||
for src_rel, dest_rel in self.moves.items():
|
||||
if not src_rel.endswith(".rs"):
|
||||
self.new[dest_rel] = self.load(src_rel)
|
||||
continue
|
||||
path = REPO_ROOT / rel
|
||||
text = path.read_text(encoding="utf-8")
|
||||
updated = regex.sub(new, text)
|
||||
text = self.load(src_rel)
|
||||
hit_lines = {text.count("\n", 0, m.start()) + 1 for m in pat.finditer(text)}
|
||||
if hit_lines:
|
||||
internal_files += 1
|
||||
internal_lines += len(hit_lines)
|
||||
self.new[dest_rel] = rewrite_moved(
|
||||
text, dest_rel, self.dest_ident, resident, self.src_ident, self.modset, self.stats
|
||||
)
|
||||
self.internal = (internal_files, internal_lines)
|
||||
|
||||
self.rewrite_other_crates(set(staying) | set(self.moves) | {dest_lib})
|
||||
self.sweep()
|
||||
self.plan_cfg()
|
||||
self.plan_manifests()
|
||||
|
||||
def remove_mod_line(self, text: str, module: str) -> str:
|
||||
pattern = re.compile(rf"^(?:pub(?:\([^)]*\))?\s+)?mod\s+{re.escape(module)}\s*;[ \t]*(?://[^\n]*)?\n", re.M)
|
||||
match = pattern.search(text)
|
||||
if not match:
|
||||
raise SplitError(f"no `mod {module};` line in {self.src_dir}/lib.rs")
|
||||
before = text[: match.start()].rstrip("\n").rsplit("\n", 1)[-1].strip()
|
||||
if before.startswith("#["):
|
||||
raise SplitError(f"`mod {module};` carries an attribute ({before}); move it by hand")
|
||||
return text[: match.start()] + text[match.end() :]
|
||||
|
||||
@staticmethod
|
||||
def add_dest_mod(text: str, module: str) -> str:
|
||||
line = f"pub mod {module};\n"
|
||||
if line in text:
|
||||
raise SplitError(f"destination lib already declares `pub mod {module};`")
|
||||
lines = text.splitlines(keepends=True)
|
||||
mods = [i for i, l in enumerate(lines) if re.match(r"pub mod \w+;\n", l)]
|
||||
if not mods:
|
||||
return text.rstrip("\n") + "\n\n" + line
|
||||
insert = mods[-1] + 1
|
||||
for i in mods:
|
||||
if lines[i] > line:
|
||||
insert = i
|
||||
break
|
||||
lines.insert(insert, line)
|
||||
return "".join(lines)
|
||||
|
||||
def rewrite_other_crates(self, already: set[str]) -> None:
|
||||
base = self.src_ident
|
||||
files: set[str] = set()
|
||||
for m in self.mods:
|
||||
files.update(sl.grep_files(f"{base}::{m}", "*.rs"))
|
||||
files = {f for f in files if f not in already and not f.startswith("vendor/")}
|
||||
bases = [base]
|
||||
for rel in sorted(files):
|
||||
text = self.load(rel)
|
||||
new = rewrite_paths(text, rel, bases, self.modset, self.dest_ident, self.stats)
|
||||
if new != text:
|
||||
self.new[rel] = new
|
||||
self.other_crate_files.append(rel)
|
||||
|
||||
def sweep(self) -> None:
|
||||
regexes = sl.sweep_regexes(self.src, self.dest, self.mods)
|
||||
patterns = []
|
||||
for m in self.mods:
|
||||
patterns += [f"{self.src}/src/{m}"]
|
||||
candidates: set[str] = set()
|
||||
for pat in patterns:
|
||||
candidates.update(sl.grep_files(pat))
|
||||
for quote in ('"', "'"):
|
||||
candidates.update(
|
||||
sl.grep_files(f"{quote}{self.src}{quote}", "*.py", "*.mjs", "*.js", "*.ts", "*.sh", "*.rs")
|
||||
)
|
||||
for rel in sorted(candidates):
|
||||
if sl.sweep_skipped(rel):
|
||||
self.sweep_skipped.append(rel)
|
||||
continue
|
||||
if rel in self.moves:
|
||||
key, text = self.moves[rel], self.new[self.moves[rel]]
|
||||
elif rel in self.new:
|
||||
key, text = rel, self.new[rel]
|
||||
else:
|
||||
key = rel
|
||||
try:
|
||||
text = self.load(rel)
|
||||
except UnicodeDecodeError:
|
||||
self.sweep_skipped.append(rel)
|
||||
continue
|
||||
updated = text
|
||||
for rx, repl in regexes:
|
||||
updated = rx.sub(repl, updated)
|
||||
if updated != text:
|
||||
touched.append(rel)
|
||||
if not dry_run:
|
||||
path.write_text(updated, encoding="utf-8")
|
||||
return sorted(set(touched))
|
||||
self.new[key] = updated
|
||||
self.sweep_files.append(rel)
|
||||
self.sweep_files = sorted(set(self.sweep_files))
|
||||
self.sweep_skipped = sorted(set(self.sweep_skipped))
|
||||
regenerated = [p for p in GENERATED_BY_DERIVE_FACTS if (REPO_ROOT / p).exists()]
|
||||
self.derive_needed = any(f.startswith(("docs/", "web/")) for f in self.sweep_files)
|
||||
self.derive_outputs = regenerated
|
||||
|
||||
def plan_cfg(self) -> None:
|
||||
item_re = re.compile(
|
||||
r"(?:pub(?:\([^)]*\)\s+)?)?(?:async\s+|unsafe\s+|const\s+|extern\s+(?:\"[^\"]*\"\s+)?)*"
|
||||
r"(?P<kind>mod|fn|struct|enum|trait|type|static|const|use|impl|macro_rules!)\s*(?P<name>\w+)?"
|
||||
)
|
||||
sites: list[dict] = []
|
||||
wanted: set[str] = set()
|
||||
edits_by_file: dict[str, list[tuple[int, int, dict]]] = {}
|
||||
for dest_rel in sorted(self.moves.values()):
|
||||
if not dest_rel.endswith(".rs"):
|
||||
continue
|
||||
text = self.new[dest_rel]
|
||||
if "cfg(" not in text:
|
||||
continue
|
||||
masked = mg.lex_mask(text)
|
||||
for m in re.finditer(r"#\[cfg\(", masked):
|
||||
depth, k = 1, m.end()
|
||||
while k < len(text) and depth:
|
||||
depth += {"(": 1, ")": -1}.get(masked[k], 0)
|
||||
k += 1
|
||||
if masked[k : k + 1] != "]":
|
||||
continue
|
||||
expr = text[m.end() : k - 1].strip()
|
||||
atoms = set(re.findall(r"[A-Za-z_][\w-]*", expr)) | set(re.findall(r'"([^"]*)"', expr))
|
||||
if "test" not in atoms and "test-support" not in atoms:
|
||||
continue
|
||||
line = text.count("\n", 0, m.start()) + 1
|
||||
j = k + 1
|
||||
while True:
|
||||
skip = re.match(r"\s*(?:#\[[^\]]*\]|///[^\n]*|//![^\n]*)", text[j:])
|
||||
if not skip:
|
||||
break
|
||||
j += skip.end()
|
||||
j += len(masked[j:]) - len(masked[j:].lstrip())
|
||||
im = item_re.match(masked, j)
|
||||
kind = im.group("kind") if im else "?"
|
||||
name = im.group("name") if im else None
|
||||
names: list[str] = []
|
||||
if kind == "impl":
|
||||
brace = masked.find("{", j)
|
||||
names = re.findall(r"\bfn\s+(\w+)", masked[j : mg.match_brace(masked, brace)]) if brace >= 0 else []
|
||||
name = "impl{" + ",".join(names[:4]) + ("..." if len(names) > 4 else "") + "}"
|
||||
elif kind == "use":
|
||||
stmt_end = masked.find(";", j)
|
||||
names = [leaf for leaf, _ in mg.use_leaves(masked[j + 3 : stmt_end])]
|
||||
name = ",".join(names[:4])
|
||||
elif name:
|
||||
names = [name]
|
||||
if expr == "test":
|
||||
cls = "test_mod" if kind == "mod" else "test_item"
|
||||
elif expr == "not(test)":
|
||||
cls = "fork_not_test"
|
||||
else:
|
||||
cls = "compound"
|
||||
site = {"file": dest_rel, "line": line, "cfg": expr, "class": cls, "item": f"{kind} {name or ''}".strip(), "used_outside": False, "_names": names}
|
||||
sites.append(site)
|
||||
if cls == "test_item":
|
||||
wanted.update(names)
|
||||
edits_by_file.setdefault(dest_rel, []).append((m.start(), k + 1, site))
|
||||
used: set[str] = set()
|
||||
if wanted:
|
||||
rx = re.compile(r"\b(?:" + "|".join(re.escape(n) for n in sorted(wanted)) + r")\b")
|
||||
moved_dests = set(self.moves.values())
|
||||
for f in sl.tracked_files("crates", "tests", "pet"):
|
||||
if not f.endswith(".rs") or f in self.moves or f in moved_dests:
|
||||
continue
|
||||
try:
|
||||
used.update(rx.findall(self.load(f)))
|
||||
except UnicodeDecodeError:
|
||||
continue
|
||||
for site in sites:
|
||||
site["used_outside"] = any(n in used for n in site.pop("_names"))
|
||||
self.cfg_sites = sites
|
||||
if self.args.apply_cfg:
|
||||
for dest_rel, edits in edits_by_file.items():
|
||||
text = self.new[dest_rel]
|
||||
for a_, b_, site in sorted(edits, reverse=True):
|
||||
if site["used_outside"]:
|
||||
text = text[:a_] + '#[cfg(any(test, feature = "test-support"))]' + text[b_:]
|
||||
self.new[dest_rel] = text
|
||||
|
||||
def plan_manifests(self) -> None:
|
||||
def deps_of(crate_dir: str) -> tuple[set[str], set[str], set[str]]:
|
||||
data = tomllib.loads((REPO_ROOT / "crates" / crate_dir / "Cargo.toml").read_text(encoding="utf-8"))
|
||||
normal, dev, feats = set(), set(), set(data.get("features", {}))
|
||||
def collect(tbl, into):
|
||||
for k, v in tbl.get("dependencies", {}).items():
|
||||
into.add(k.replace("-", "_"))
|
||||
if isinstance(v, dict) and "package" in v:
|
||||
into.add(v["package"].replace("-", "_"))
|
||||
for k, v in tbl.get("build-dependencies", {}).items():
|
||||
into.add(k.replace("-", "_"))
|
||||
collect(data, normal)
|
||||
for tbl in data.get("target", {}).values():
|
||||
collect(tbl, normal)
|
||||
for k in data.get("dev-dependencies", {}):
|
||||
dev.add(k.replace("-", "_"))
|
||||
return normal, dev, feats
|
||||
|
||||
src_normal, src_dev, _ = deps_of(self.src)
|
||||
dest_normal, dest_dev, dest_feats = deps_of(self.dest)
|
||||
used_prod: set[str] = set()
|
||||
used_any: set[str] = set()
|
||||
features: set[str] = set()
|
||||
for dest_rel in self.moves.values():
|
||||
if not dest_rel.endswith(".rs"):
|
||||
continue
|
||||
text = self.new[dest_rel]
|
||||
masked = mg.lex_mask(text)
|
||||
prod = mg.strip_cfg_test(masked, [])
|
||||
roots = set(re.findall(r"(?<![\w:$])([a-z_][a-z0-9_]*)::", masked))
|
||||
prod_roots = set(re.findall(r"(?<![\w:$])([a-z_][a-z0-9_]*)::", prod))
|
||||
is_test_file = mg.is_test_path(dest_rel)
|
||||
used_any |= roots
|
||||
if not is_test_file:
|
||||
used_prod |= prod_roots
|
||||
features |= set(re.findall(r'feature\s*=\s*"([^"]+)"', text))
|
||||
resident_names = self.resident_modules()
|
||||
wanted_prod = {r for r in used_prod if r in src_normal and r not in resident_names and r not in BUILTIN_STD}
|
||||
wanted_any = {r for r in used_any if (r in src_normal or r in src_dev) and r not in resident_names and r not in BUILTIN_STD}
|
||||
self.manifest = {
|
||||
"dest_missing_normal": sorted(wanted_prod - dest_normal),
|
||||
"dest_missing_dev_or_normal": sorted((wanted_any - wanted_prod) - dest_normal - dest_dev),
|
||||
"features_used_not_declared_in_dest": sorted(f for f in features if f not in dest_feats),
|
||||
"features_used": sorted(features),
|
||||
}
|
||||
missing_consumers = []
|
||||
for rel in self.other_crate_files:
|
||||
parts = rel.split("/")
|
||||
if parts[0] != "crates" or len(parts) < 3:
|
||||
continue
|
||||
crate = parts[1]
|
||||
if crate == self.dest:
|
||||
continue
|
||||
normal, dev, _ = deps_of(crate)
|
||||
ident = self.dest_ident
|
||||
kind = "dev" if len(parts) > 2 and parts[2] in ("tests", "benches", "examples") else "normal"
|
||||
have = ident in normal or (kind == "dev" and ident in dev)
|
||||
if not have:
|
||||
missing_consumers.append({"crate": crate, "file": rel, "needs": kind})
|
||||
self.manifest["consumers_missing_dependency"] = sorted(
|
||||
{(c["crate"], c["needs"]) for c in missing_consumers}
|
||||
)
|
||||
|
||||
def rewrite_set(self) -> list[str]:
|
||||
paths = set(self.new) | set(self.moves)
|
||||
return sorted(paths)
|
||||
|
||||
def check_clean(self) -> dict:
|
||||
rewrite = self.rewrite_set()
|
||||
existing = [p for p in rewrite if (REPO_ROOT / p).exists()]
|
||||
dirty = sorted(sl.dirty_paths() & set(rewrite))
|
||||
young = [(p, round(a, 1)) for p, a in sl.recently_modified(existing, self.args.quiet_minutes)]
|
||||
return {"dirty": dirty, "recent": young}
|
||||
|
||||
def changes(self) -> list[Change]:
|
||||
out: list[Change] = []
|
||||
fmt_items: dict[str, tuple[str | None, str]] = {}
|
||||
final: dict[str, str | None] = dict(self.new)
|
||||
for src in self.moves:
|
||||
final[src] = None
|
||||
for rel, text in final.items():
|
||||
if text is None or not rel.endswith(".rs") or self.args.no_format:
|
||||
continue
|
||||
base_src = next((s for s, d in self.moves.items() if d == rel), rel)
|
||||
base = self.texts.get(base_src)
|
||||
if base is not None and base == text:
|
||||
continue
|
||||
fmt_items[rel] = (base, text)
|
||||
if fmt_items:
|
||||
formatted, unstable = sl.format_many(fmt_items)
|
||||
self.unformatted_base = unstable
|
||||
for rel, text in formatted.items():
|
||||
final[rel] = text
|
||||
else:
|
||||
self.unformatted_base = []
|
||||
for rel, text in sorted(final.items()):
|
||||
if rel in self.moves.values():
|
||||
src = next(s for s, d in self.moves.items() if d == rel)
|
||||
out.append(Change(rel, text, None, mode_from=src))
|
||||
else:
|
||||
out.append(Change(rel, text, self.fps.get(rel)))
|
||||
return out
|
||||
|
||||
def report(self, clean: dict | None) -> dict:
|
||||
s = self.stats
|
||||
internal_files, internal_lines = self.internal
|
||||
staying = {r: n for r, n in s.plain.items() if r.startswith(self.src_dir + "/")}
|
||||
other = {r: n for r, n in s.plain.items() if not r.startswith(self.src_dir + "/")}
|
||||
residue = {k: {"sites": len(v), "first": v[:3]} for k, v in sorted(s.residue.items(), key=lambda kv: -len(kv[1]))}
|
||||
return {
|
||||
"modules": self.mods,
|
||||
"src_crate": self.src,
|
||||
"dest_crate": self.dest,
|
||||
"files_moved": len(self.moves),
|
||||
"caller_rewrites": {
|
||||
"files": len(staying),
|
||||
"lines": sum(staying.values()),
|
||||
"in_comments": s.comment_hits,
|
||||
"in_string_literals": s.string_hits,
|
||||
},
|
||||
"other_crate_rewrites": {"files": len(other), "lines": sum(other.values())},
|
||||
"internal_refs_not_rewritten": {"files": internal_files, "lines": internal_lines},
|
||||
"legacy_dry_run_equivalent": {
|
||||
"files": len(staying) + internal_files,
|
||||
"lines": sum(staying.values()) + internal_lines,
|
||||
},
|
||||
"grouped_imports": s.grouped,
|
||||
"unqualified_in_source_lib": s.unqualified,
|
||||
"self_paths": {"sites": s.self_sites, "files": s.self_files, "skipped_in_comments_or_strings": s.self_skipped},
|
||||
"other_crate_files": self.other_crate_files,
|
||||
"sweep": {
|
||||
"files": self.sweep_files,
|
||||
"skipped": self.sweep_skipped,
|
||||
"must_sweep_covered": {p: (p in self.sweep_files) for p in MUST_SWEEP},
|
||||
"run_derive_facts": self.derive_needed,
|
||||
"derive_facts_outputs": self.derive_outputs,
|
||||
},
|
||||
"residue_moved_to_staying": residue,
|
||||
"super_root_residue": s.super_root,
|
||||
"manifest_plan": self.manifest,
|
||||
"cfg_plan": {
|
||||
"applied": bool(self.args.apply_cfg),
|
||||
"sites": len(self.cfg_sites),
|
||||
"by_class": dict(collections.Counter(c["class"] for c in self.cfg_sites)),
|
||||
"used_outside": sum(1 for c in self.cfg_sites if c["used_outside"] and c["class"] == "test_item"),
|
||||
"detail": self.cfg_sites,
|
||||
},
|
||||
"rewrite_set": self.rewrite_set(),
|
||||
"clean": clean,
|
||||
}
|
||||
|
||||
|
||||
def run_mover(args: argparse.Namespace) -> int:
|
||||
mover = Mover(args)
|
||||
mover.run_plan()
|
||||
clean = mover.check_clean()
|
||||
if args.require_clean and (clean["dirty"] or clean["recent"]):
|
||||
lines = [f"dirty: {p}" for p in clean["dirty"]] + [f"modified {a} min ago: {p}" for p, a in clean["recent"]]
|
||||
raise SplitError("--require-clean refused; the rewrite set overlaps live work:\n " + "\n ".join(lines), 5)
|
||||
changes = None if args.dry_run else mover.changes()
|
||||
rep = mover.report(clean)
|
||||
if args.json:
|
||||
json.dump(rep, sys.stdout, indent=1)
|
||||
print()
|
||||
else:
|
||||
print_report(rep, args.dry_run)
|
||||
if args.dry_run:
|
||||
return 0
|
||||
jdir = Path(args.journal_dir) if args.journal_dir else sl.default_journal_dir("move-" + "-".join(args.modules)[:40])
|
||||
sl.apply_changes(changes, jdir, args.pause_after_plan, args.pause_mid_write, log)
|
||||
log(f"applied {len(changes)} change(s); journal {jdir}")
|
||||
if args.derive_facts:
|
||||
derive_facts(jdir)
|
||||
return 0
|
||||
|
||||
|
||||
def derive_facts(jdir: Path) -> None:
|
||||
before = {p: (REPO_ROOT / p).read_bytes() if (REPO_ROOT / p).exists() else None for p in GENERATED_BY_DERIVE_FACTS}
|
||||
proc = subprocess.run(["node", "web/scripts/derive-facts.mjs"], cwd=REPO_ROOT, capture_output=True, text=True)
|
||||
if proc.returncode != 0:
|
||||
log(f"derive-facts failed: {proc.stderr.strip()[:500]}; generated files may be stale")
|
||||
return
|
||||
pre = jdir / "derive-pre"
|
||||
pre.mkdir(exist_ok=True)
|
||||
changed = []
|
||||
for p, old in before.items():
|
||||
new = (REPO_ROOT / p).read_bytes() if (REPO_ROOT / p).exists() else None
|
||||
if new != old:
|
||||
changed.append(p)
|
||||
if old is not None:
|
||||
(pre / p.replace("/", "__")).write_bytes(old)
|
||||
log("derive-facts regenerated: " + (", ".join(changed) if changed else "nothing changed") + " (add these to the rewrite set; pre-images in " + str(pre) + ")")
|
||||
|
||||
|
||||
def print_report(rep: dict, dry: bool) -> None:
|
||||
cr = rep["caller_rewrites"]
|
||||
print(f"modules: {' '.join(rep['modules'])} {rep['src_crate']} -> {rep['dest_crate']} files moved: {rep['files_moved']}{' (dry run)' if dry else ''}")
|
||||
print(f"callers rewritten: {cr['files']} files, {cr['lines']} lines (comments {cr['in_comments']}, string literals {len(cr['in_string_literals'])})")
|
||||
ir = rep["internal_refs_not_rewritten"]
|
||||
leg = rep["legacy_dry_run_equivalent"]
|
||||
print(f"inside the moved set (kept as crate::): {ir['files']} files, {ir['lines']} lines; old-tool dry-run equivalent: {leg['files']} files, {leg['lines']} lines")
|
||||
print(f"grouped imports split: {len(rep['grouped_imports'])}; unqualified in source lib: {len(rep['unqualified_in_source_lib'])}")
|
||||
sp = rep["self_paths"]
|
||||
print(f"self paths rewritten to crate::: {sp['sites']} in {sp['files']} files (skipped in comments/strings: {sp['skipped_in_comments_or_strings']})")
|
||||
oc = rep["other_crate_rewrites"]
|
||||
print(f"other crates' files rewritten: {oc['files']} files, {oc['lines']} lines")
|
||||
sw = rep["sweep"]
|
||||
print(f"path sweep: {len(sw['files'])} files ({len(sw['skipped'])} skipped by the skip list); derive-facts needed: {sw['run_derive_facts']}")
|
||||
print(f"residue moved->staying: {sum(v['sites'] for v in rep['residue_moved_to_staying'].values())} sites in {len(rep['residue_moved_to_staying'])} modules; super-root residue: {len(rep['super_root_residue'])}")
|
||||
print(f"cfg sites: {rep['cfg_plan']['sites']} {rep['cfg_plan']['by_class']}; manifest: {json.dumps(rep['manifest_plan'])}")
|
||||
c = rep["clean"]
|
||||
print(f"rewrite set: {len(rep['rewrite_set'])} paths; dirty overlap: {len(c['dirty'])}; recent (<min): {len(c['recent'])}")
|
||||
|
||||
|
||||
def restore(path: str) -> int:
|
||||
sl.require_git_repo()
|
||||
problems = sl.rollback(Path(path))
|
||||
for p in problems:
|
||||
log(f"restore problem: {p}")
|
||||
log(f"restored pre-images from {path}")
|
||||
return 1 if problems else 0
|
||||
|
||||
|
||||
def main(argv: list[str] | None = None) -> int:
|
||||
parser = argparse.ArgumentParser(description=__doc__.split("\n\n")[0])
|
||||
parser.add_argument("modules", nargs="+")
|
||||
parser.add_argument("modules", nargs="*")
|
||||
parser.add_argument("--dry-run", action="store_true")
|
||||
parser.add_argument("--json", action="store_true")
|
||||
parser.add_argument("--require-clean", action="store_true")
|
||||
parser.add_argument("--quiet-minutes", type=float, default=15.0)
|
||||
parser.add_argument("--dest-crate", default="runtime")
|
||||
parser.add_argument("--src-crate", default="tui")
|
||||
parser.add_argument("--apply-cfg", action="store_true")
|
||||
parser.add_argument("--resolve-reexports", action="store_true")
|
||||
parser.add_argument("--journal-dir")
|
||||
parser.add_argument("--pause-after-plan", type=float, default=0.0, help="seconds to wait between plan and first write (lets a test change a file)")
|
||||
parser.add_argument("--pause-mid-write", type=float, default=0.0, help="seconds to wait once half the writes are done (exercises the restore path)")
|
||||
parser.add_argument("--restore", metavar="JOURNAL_DIR")
|
||||
parser.add_argument("--no-format", action="store_true")
|
||||
parser.add_argument("--derive-facts", action="store_true")
|
||||
args = parser.parse_args(argv)
|
||||
if args.restore:
|
||||
return restore(args.restore)
|
||||
if not args.modules:
|
||||
parser.error("name at least one module")
|
||||
if args.resolve_reexports:
|
||||
import reexports
|
||||
|
||||
if not RUNTIME_LIB.is_file():
|
||||
raise SystemExit("move-modules: crates/runtime/src/lib.rs does not exist yet")
|
||||
tui_lib = TUI_LIB.read_text(encoding="utf-8")
|
||||
runtime_lib = RUNTIME_LIB.read_text(encoding="utf-8")
|
||||
for module in args.modules:
|
||||
paths = module_paths(module)
|
||||
tui_lib, found = remove_mod_line(tui_lib, module)
|
||||
if not found:
|
||||
raise SystemExit(f"move-modules: no `mod {module};` line in crates/tui/src/lib.rs")
|
||||
runtime_lib = add_runtime_mod(runtime_lib, module)
|
||||
if not args.dry_run:
|
||||
for path in paths:
|
||||
git("mv", str(path.relative_to(REPO_ROOT)), str((RUNTIME_SRC / path.name).relative_to(REPO_ROOT)))
|
||||
if not args.dry_run:
|
||||
tui_lib = import_unqualified(tui_lib, args.modules)
|
||||
TUI_LIB.write_text(tui_lib, encoding="utf-8")
|
||||
RUNTIME_LIB.write_text(runtime_lib, encoding="utf-8")
|
||||
for rel in rewrite_callers(args.modules, args.dry_run):
|
||||
print(f"callers rewritten: {rel}")
|
||||
swept: set[str] = set()
|
||||
for module in args.modules:
|
||||
swept.update(sweep_paths(module, args.dry_run))
|
||||
for rel in sorted(swept):
|
||||
print(f"path sweep: {rel}")
|
||||
print(f"moved {len(args.modules)} module(s){' (dry run)' if args.dry_run else ''}")
|
||||
return 0
|
||||
return reexports.run(args, sl, mg)
|
||||
return run_mover(args)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||
sys.exit(sl.run_main(main))
|
||||
@@ -27,11 +27,29 @@ mg = importlib.util.module_from_spec(_spec)
|
||||
sys.modules["module_graph"] = mg
|
||||
_spec.loader.exec_module(mg)
|
||||
|
||||
_cl_spec = importlib.util.spec_from_file_location("count_lines", Path(__file__).with_name("count_lines.py"))
|
||||
cl = importlib.util.module_from_spec(_cl_spec)
|
||||
_cl_spec.loader.exec_module(cl)
|
||||
|
||||
ROOT = mg.ROOT_ITEMS
|
||||
|
||||
|
||||
def analyse() -> dict:
|
||||
tui, runtime, refs = mg.load_graph()
|
||||
def strict_lines(src_dirs: list[Path]) -> collections.Counter:
|
||||
strict: collections.Counter = collections.Counter()
|
||||
for src in src_dirs:
|
||||
crate_dir = src.parent
|
||||
if not crate_dir.is_dir():
|
||||
continue
|
||||
_, res = cl.classify_crate(str(crate_dir))
|
||||
for path, (prod, _test, _other) in res.items():
|
||||
rel = Path(path).relative_to(src) if Path(path).is_relative_to(src) else None
|
||||
if rel is not None:
|
||||
strict[mg.top_module(rel.as_posix())] += prod
|
||||
return strict
|
||||
|
||||
|
||||
def analyse(tui_src: Path = mg.TUI_SRC, runtime_src: Path = mg.RUNTIME_SRC, non_test: bool = False) -> dict:
|
||||
tui, runtime, refs = mg.load_graph(tui_src, runtime_src)
|
||||
resident = set(runtime.modules)
|
||||
modules = (tui.modules | runtime.modules) | {ROOT}
|
||||
prod_lines: collections.Counter = collections.Counter()
|
||||
@@ -42,6 +60,7 @@ def analyse() -> dict:
|
||||
module = mg.top_module(rel)
|
||||
bucket = test_lines if mg.file_is_test(rel, exact, prefixes, crate.production_files) else prod_lines
|
||||
bucket[module] += src.count("\n") + 1
|
||||
strict = strict_lines([tui_src, runtime_src]) if non_test else None
|
||||
out: dict[str, collections.Counter] = collections.defaultdict(collections.Counter)
|
||||
inbound: dict[str, collections.Counter] = collections.defaultdict(collections.Counter)
|
||||
for ref in refs:
|
||||
@@ -76,6 +95,7 @@ def analyse() -> dict:
|
||||
"resident": m in resident,
|
||||
"prod_lines": prod_lines[m],
|
||||
"test_lines": test_lines[m],
|
||||
**({"strict_lines": strict[m]} if strict is not None else {}),
|
||||
"out": len(out[m]),
|
||||
"in": len(inbound[m]),
|
||||
"scc": len(comp_members[c]),
|
||||
@@ -163,17 +183,20 @@ def strongly_connected(graph: dict[str, set[str]]) -> dict[str, int]:
|
||||
def render(result: dict, only: str | None) -> str:
|
||||
rows = [r for r in result["rows"] if not only or r["module"] == only]
|
||||
rows.sort(key=lambda r: (r["ui"], r["wave"], -r["prod_lines"]))
|
||||
has_strict = any("strict_lines" in r for r in rows)
|
||||
strict_head = f"{'strict':>9}" if has_strict else ""
|
||||
lines = [
|
||||
f"largest strongly connected components: {result['scc_sizes']}",
|
||||
f"largest SCC ({len(result['largest_scc'])} modules): {', '.join(result['largest_scc'])}",
|
||||
"",
|
||||
f"{'module':<26}{'wave':>5}{'prod':>9}{'test':>9}{'out':>5}{'in':>5}{'scc':>5} {'root':>5} blockers (module:refs)",
|
||||
f"{'module':<26}{'wave':>5}{'prod':>9}{strict_head}{'test':>9}{'out':>5}{'in':>5}{'scc':>5} {'root':>5} blockers (module:refs)",
|
||||
]
|
||||
for r in rows:
|
||||
tag = "UI " if r["ui"] else ("rt " if r["resident"] else " ")
|
||||
blockers = ", ".join(f"{d}:{n}" for d, n in r["blockers"][:5])
|
||||
strict_cell = f"{r.get('strict_lines', 0):>9}" if has_strict else ""
|
||||
lines.append(
|
||||
f"{tag}{r['module']:<23}{r['wave']:>5}{r['prod_lines']:>9}{r['test_lines']:>9}"
|
||||
f"{tag}{r['module']:<23}{r['wave']:>5}{r['prod_lines']:>9}{strict_cell}{r['test_lines']:>9}"
|
||||
f"{r['out']:>5}{r['in']:>5}{r['scc']:>5} {r['to_root']:>5} {blockers}"
|
||||
)
|
||||
free = [r for r in result["rows"] if not r["ui"] and r["wave"] == 0 and not r["resident"] and r["to_root"] == 0]
|
||||
@@ -186,8 +209,11 @@ def main() -> int:
|
||||
parser.add_argument("--json", action="store_true")
|
||||
parser.add_argument("--module")
|
||||
parser.add_argument("--scc-edges", type=int, metavar="N", help="print the N heaviest edges inside the largest cycle")
|
||||
parser.add_argument("--non-test", action="store_true", help="add a strict production column (inline cfg(test) code excluded)")
|
||||
parser.add_argument("--tui-src", type=Path, default=mg.TUI_SRC, help="source directory of the crate being split")
|
||||
parser.add_argument("--runtime-src", type=Path, default=mg.RUNTIME_SRC, help="source directory of the destination crate")
|
||||
args = parser.parse_args()
|
||||
result = analyse()
|
||||
result = analyse(args.tui_src, args.runtime_src, args.non_test)
|
||||
if args.scc_edges:
|
||||
print(scc_edges(result, args.scc_edges, 5))
|
||||
return 0
|
||||
|
||||
@@ -0,0 +1,266 @@
|
||||
#!/usr/bin/env python3
|
||||
"""G11 path-coupling preflight for the crate split (whole repository, cargo free).
|
||||
|
||||
python3 scripts/split/preflight.py config oauth settings # one window
|
||||
python3 scripts/split/preflight.py --json config oauth
|
||||
python3 scripts/split/preflight.py # inventory only
|
||||
|
||||
Resolves every coupling that `cargo check` cannot see against the old-to-new
|
||||
path map of the named modules (`crates/<src>/src/<m>.rs` and `<m>/` move to
|
||||
`crates/<dest>/src/`):
|
||||
|
||||
* `include_str!` / `include_bytes!` / `include!` and `#[path]` anywhere in the
|
||||
repository (`crates`, `pet`, `tests`, ...): the target must still resolve
|
||||
from the includer, whether one, both or neither moves;
|
||||
* `docs/features.toml` owner paths (`tests/feature_registry.rs` requires them
|
||||
to exist);
|
||||
* path strings in tracked text files (`crates/tui/src/<m>`, `tui/src/<m>`,
|
||||
segmented `"tui" / "src" / "<m>"`): fixed by the mover's sweep, except in the
|
||||
sweep's skip list, which are reported as residue;
|
||||
* manifest-relative and `../src/<m>` references from inside the source crate
|
||||
(tests, scripts, assets) that no sweep rewrites;
|
||||
* CI test identities (`<m>::` names in workflows and scripts).
|
||||
|
||||
Exit status is 1 when any residue remains, so the run is a gate; the residue
|
||||
lines say which file to edit by hand for the window.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import collections
|
||||
import json
|
||||
import os
|
||||
import re
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
sys.path.insert(0, str(Path(__file__).resolve().parent))
|
||||
import splitlib as sl
|
||||
from splitlib import REPO_ROOT
|
||||
|
||||
INCLUDE_RE = re.compile(r"\b(include_str|include_bytes|include)!\s*\(")
|
||||
PATH_ATTR_RE = re.compile(r'#\s*\[\s*path\s*=\s*"([^"]+)"\s*\]')
|
||||
STRING_RE = re.compile(r'"((?:\\.|[^"\\])*)"')
|
||||
|
||||
|
||||
def balanced(text: str, open_i: int) -> int:
|
||||
depth = 0
|
||||
for i in range(open_i, len(text)):
|
||||
if text[i] == "(":
|
||||
depth += 1
|
||||
elif text[i] == ")":
|
||||
depth -= 1
|
||||
if depth == 0:
|
||||
return i
|
||||
return len(text) - 1
|
||||
|
||||
|
||||
class Preflight:
|
||||
def __init__(self, mods: list[str], src: str, dest: str):
|
||||
self.mods, self.src, self.dest = mods, src, dest
|
||||
self.map: dict[str, str] = {}
|
||||
src_dir = f"crates/{src}/src"
|
||||
for m in mods:
|
||||
for rel in sl.tracked_files(f"{src_dir}/{m}.rs", f"{src_dir}/{m}"):
|
||||
if rel == f"{src_dir}/{m}.rs" or rel.startswith(f"{src_dir}/{m}/"):
|
||||
self.map[rel] = f"crates/{dest}/src" + rel[len(src_dir) :]
|
||||
self.rows: list[dict] = []
|
||||
|
||||
def add(self, kind: str, status: str, file: str, line: int, detail: str, residue: bool) -> None:
|
||||
self.rows.append({"kind": kind, "status": status, "file": file, "line": line, "detail": detail, "residue": residue})
|
||||
|
||||
def resolve(self, includer: str, literal: str, manifest_dir: str | None = None) -> str:
|
||||
base = manifest_dir if manifest_dir is not None else os.path.dirname(includer)
|
||||
return os.path.normpath(os.path.join(base, literal)) if not literal.startswith("/") else literal
|
||||
|
||||
def includes(self) -> None:
|
||||
files = set()
|
||||
for pat in ("include_str!", "include_bytes!", "include!(", "#[path", "# [path"):
|
||||
files.update(sl.grep_files(pat, "*.rs"))
|
||||
for rel in sorted(files):
|
||||
if rel.startswith("vendor/"):
|
||||
continue
|
||||
try:
|
||||
text = sl.read_text(rel)
|
||||
except UnicodeDecodeError:
|
||||
continue
|
||||
masked = None
|
||||
crate_dir = None
|
||||
parts = rel.split("/")
|
||||
if parts[0] == "crates" and len(parts) > 2:
|
||||
crate_dir = "/".join(parts[:2])
|
||||
entries: list[tuple[int, str, str | None, str]] = []
|
||||
for m in INCLUDE_RE.finditer(text):
|
||||
prefix = text[text.rfind("\n", 0, m.start()) + 1 : m.start()]
|
||||
if prefix.lstrip().startswith("//"):
|
||||
continue
|
||||
end = balanced(text, m.end() - 1)
|
||||
args = text[m.end() : end]
|
||||
lits = STRING_RE.findall(args)
|
||||
line = text.count("\n", 0, m.start()) + 1
|
||||
if "OUT_DIR" in args:
|
||||
self.add("include", "generated-out-dir", rel, line, args.strip()[:90], False)
|
||||
continue
|
||||
if not lits:
|
||||
self.add("include", "dynamic", rel, line, args.strip()[:90], False)
|
||||
continue
|
||||
if "CARGO_MANIFEST_DIR" in args and crate_dir:
|
||||
entries.append((line, lits[-1].lstrip("/"), crate_dir, m.group(1)))
|
||||
else:
|
||||
entries.append((line, "".join(lits), None, m.group(1)))
|
||||
for m in PATH_ATTR_RE.finditer(text):
|
||||
line = text.count("\n", 0, m.start()) + 1
|
||||
entries.append((line, m.group(1), None, "path"))
|
||||
for line, literal, manifest_dir, kind in entries:
|
||||
target = self.resolve(rel, literal, manifest_dir)
|
||||
self.classify_include(rel, line, kind, literal, target)
|
||||
|
||||
def classify_include(self, rel: str, line: int, kind: str, literal: str, target: str) -> None:
|
||||
includer_moves, target_moves = rel in self.map, target in self.map
|
||||
if not (REPO_ROOT / target).exists() and not target_moves:
|
||||
self.add("include", "unresolvable-today", rel, line, f"{kind}!({literal}) -> {target}", False)
|
||||
return
|
||||
if not includer_moves and not target_moves:
|
||||
self.add("include", "unaffected", rel, line, f"{kind} {literal}", False)
|
||||
elif includer_moves and target_moves:
|
||||
new_inc, new_tgt = self.map[rel], self.map[target]
|
||||
if os.path.normpath(os.path.join(os.path.dirname(new_inc), literal)) == new_tgt:
|
||||
self.add("include", "both-move-relative-kept", rel, line, f"{kind} {literal}", False)
|
||||
else:
|
||||
self.add("include", "BROKEN-relative-changed", rel, line, f"{kind} {literal}", True)
|
||||
elif target_moves:
|
||||
new_tgt = self.map[target]
|
||||
hint = os.path.relpath(new_tgt, os.path.dirname(rel))
|
||||
self.add("include", "BROKEN-target-moved", rel, line, f"{kind} {literal} -> {hint}", True)
|
||||
else:
|
||||
new_inc = self.map[rel]
|
||||
if os.path.normpath(os.path.join(os.path.dirname(new_inc), literal)) == target:
|
||||
self.add("include", "includer-moves-relative-kept", rel, line, f"{kind} {literal}", False)
|
||||
else:
|
||||
hint = os.path.relpath(target, os.path.dirname(new_inc))
|
||||
self.add("include", "BROKEN-includer-moved", rel, line, f"{kind} {literal} -> {hint}", True)
|
||||
|
||||
def features_owners(self) -> None:
|
||||
path = "docs/features.toml"
|
||||
if not (REPO_ROOT / path).is_file():
|
||||
return
|
||||
swept = not sl.sweep_skipped(path)
|
||||
for lineno, line in enumerate(sl.read_text(path).splitlines(), 1):
|
||||
m = re.match(r'\s*owner\s*=\s*"([^"]+)"', line)
|
||||
if not m:
|
||||
continue
|
||||
owner = m.group(1)
|
||||
if owner in self.map:
|
||||
self.add("features-owner", "moves-and-swept" if swept else "moves-NOT-swept", path, lineno, f"{owner} -> {self.map[owner]}", not swept)
|
||||
elif not (REPO_ROOT / owner).exists():
|
||||
self.add("features-owner", "missing-today", path, lineno, owner, True)
|
||||
|
||||
def strings(self) -> None:
|
||||
if not self.mods:
|
||||
return
|
||||
regexes = sl.sweep_regexes(self.src, self.dest, self.mods)
|
||||
cands: set[str] = set()
|
||||
for m in self.mods:
|
||||
cands.update(sl.grep_files(f"{self.src}/src/{m}"))
|
||||
for q in ('"', "'"):
|
||||
cands.update(sl.grep_files(f"{q}{self.src}{q}", "*.py", "*.mjs", "*.js", "*.ts", "*.sh", "*.rs"))
|
||||
for rel in sorted(cands):
|
||||
try:
|
||||
text = sl.read_text(rel)
|
||||
except UnicodeDecodeError:
|
||||
continue
|
||||
hits = [(rx, mo) for rx, _ in regexes for mo in rx.finditer(text)]
|
||||
if not hits:
|
||||
continue
|
||||
first = min(text.count("\n", 0, mo.start()) + 1 for _, mo in hits)
|
||||
if sl.SWEEP_SKIP_GENERATED.match(rel):
|
||||
self.add("path-string", "generated-run-derive-facts", rel, first, f"{len(hits)} hits", False)
|
||||
elif Path(rel).name.startswith("CHANGELOG"):
|
||||
self.add("path-string", "history-left-alone", rel, first, f"{len(hits)} hits", False)
|
||||
elif sl.sweep_skipped(rel):
|
||||
snippet = text.splitlines()[first - 1].strip()[:90]
|
||||
self.add("path-string", "SKIPPED-BY-SWEEP", rel, first, f"{len(hits)} hits: {snippet}", True)
|
||||
else:
|
||||
self.add("path-string", "swept", rel, first, f"{len(hits)} hits", False)
|
||||
|
||||
def relative_refs(self) -> None:
|
||||
if not self.mods:
|
||||
return
|
||||
alt = "|".join(re.escape(m) for m in self.mods)
|
||||
parent = re.compile(rf"(?:\.\./)+src/({alt})(?:\.rs|/)")
|
||||
manifest = re.compile(rf"""["']/?src/({alt})(?:\.rs|/)""")
|
||||
base = f"crates/{self.src}"
|
||||
for rel in sl.tracked_files(base):
|
||||
if rel.startswith(f"{base}/src/"):
|
||||
continue
|
||||
try:
|
||||
text = sl.read_text(rel)
|
||||
except UnicodeDecodeError:
|
||||
continue
|
||||
use_manifest = rel.endswith(".rs") and "CARGO_MANIFEST_DIR" in text
|
||||
for rx in (parent, manifest) if use_manifest else (parent,):
|
||||
for mo in rx.finditer(text):
|
||||
line = text.count("\n", 0, mo.start()) + 1
|
||||
self.add("relative-ref", "UNRESOLVED-relative", rel, line, mo.group(0), True)
|
||||
|
||||
def test_identities(self) -> None:
|
||||
if not self.mods:
|
||||
return
|
||||
alt = "|".join(re.escape(m) for m in self.mods)
|
||||
pat = re.compile(rf"(?<![\w:$])({alt})::\w")
|
||||
for rel in sl.grep_files("::", ".github", "scripts/*.sh"):
|
||||
try:
|
||||
text = sl.read_text(rel)
|
||||
except UnicodeDecodeError:
|
||||
continue
|
||||
for mo in pat.finditer(text):
|
||||
line = text.count("\n", 0, mo.start()) + 1
|
||||
ctx = text[text.rfind("\n", 0, mo.start()) + 1 : text.find("\n", mo.end())].strip()[:110]
|
||||
self.add("test-identity", "REVIEW", rel, line, ctx, True)
|
||||
|
||||
|
||||
def main(argv: list[str] | None = None) -> int:
|
||||
parser = argparse.ArgumentParser(description=__doc__.split("\n\n")[0])
|
||||
parser.add_argument("modules", nargs="*")
|
||||
parser.add_argument("--src-crate", default="tui")
|
||||
parser.add_argument("--dest-crate", default="runtime")
|
||||
parser.add_argument("--json", action="store_true")
|
||||
parser.add_argument("--list", action="store_true", help="print every row, not only the residue")
|
||||
args = parser.parse_args(argv)
|
||||
sl.require_git_repo()
|
||||
pf = Preflight(args.modules, args.src_crate, args.dest_crate)
|
||||
pf.includes()
|
||||
pf.features_owners()
|
||||
pf.strings()
|
||||
pf.relative_refs()
|
||||
pf.test_identities()
|
||||
residue = [r for r in pf.rows if r["residue"]]
|
||||
summary = collections.Counter((r["kind"], r["status"]) for r in pf.rows)
|
||||
if args.json:
|
||||
json.dump(
|
||||
{
|
||||
"modules": args.modules,
|
||||
"files_in_move_map": len(pf.map),
|
||||
"summary": {f"{k}:{s}": n for (k, s), n in sorted(summary.items())},
|
||||
"residue_count": len(residue),
|
||||
"residue": residue,
|
||||
"rows": pf.rows,
|
||||
},
|
||||
sys.stdout,
|
||||
indent=1,
|
||||
)
|
||||
print()
|
||||
else:
|
||||
print(f"G11 path-coupling preflight: modules {' '.join(args.modules) or '(inventory)'}; {len(pf.map)} files in the move map")
|
||||
for (kind, status), n in sorted(summary.items()):
|
||||
print(f" {kind:15} {status:28} {n}")
|
||||
shown = pf.rows if args.list else residue
|
||||
for r in shown:
|
||||
print(f" {'RESIDUE' if r['residue'] else 'ok '} {r['kind']}:{r['status']} {r['file']}:{r['line']} {r['detail']}")
|
||||
print(f"unresolved: {len(residue)}")
|
||||
return 1 if residue else 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(sl.run_main(main))
|
||||
@@ -0,0 +1,319 @@
|
||||
"""`move-modules.py --resolve-reexports`: retire a re-export facade.
|
||||
|
||||
For each facade module F (for example `config`) in the source crate, finds the
|
||||
names its `pub use` lines re-export from other crates, enumerates glob
|
||||
re-exports from the upstream sources (generated `include!(OUT_DIR/..)` modules
|
||||
through their JSON data), and rewrites `crate::F::NAME` in the staying files to
|
||||
the upstream path. Grouped `use crate::F::{..}` lists are split by upstream
|
||||
parent. Relative `F::NAME` uses are reported, not rewritten.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import collections
|
||||
import json
|
||||
import re
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
def flatten_groups_impl(mg, text: str, facades: list[str]) -> str:
|
||||
masked = mg.lex_mask(text)
|
||||
fac = "|".join(re.escape(f) for f in facades)
|
||||
group_re = re.compile(rf"(?m)^(?P<indent>[ \t]*)(?P<vis>pub(?:\([^)]*\))?[ \t]+)?use[ \t]+crate::\{{")
|
||||
out, pos = [], 0
|
||||
for m in group_re.finditer(masked):
|
||||
if m.start() < pos:
|
||||
continue
|
||||
open_i = m.end() - 1
|
||||
close_i = mg.match_brace(masked, open_i)
|
||||
semi = masked.find(";", close_i)
|
||||
depth, start, ents = 0, open_i + 1, []
|
||||
for i in range(open_i + 1, close_i):
|
||||
ch = masked[i]
|
||||
depth += (ch == "{") - (ch == "}")
|
||||
if ch == "," and depth == 0:
|
||||
ents.append((text[start:i].strip(), masked[start:i].strip()))
|
||||
start = i + 1
|
||||
ents.append((text[start:close_i].strip(), masked[start:close_i].strip()))
|
||||
ents = [(t, k) for t, k in ents if k]
|
||||
split = [t for t, k in ents if re.match(rf"(?:{fac})::", k)]
|
||||
if not split:
|
||||
continue
|
||||
kept = [t for t, k in ents if not re.match(rf"(?:{fac})::", k)]
|
||||
indent, vis = m.group("indent"), m.group("vis") or ""
|
||||
stmts = [f"{indent}{vis}use crate::{t};" for t in split]
|
||||
if kept:
|
||||
stmts.insert(0, f"{indent}{vis}use crate::{{{', '.join(kept)}}};")
|
||||
out.append(text[pos : m.start()])
|
||||
out.append("\n".join(stmts))
|
||||
pos = semi + 1
|
||||
if not out:
|
||||
return text
|
||||
out.append(text[pos:])
|
||||
return "".join(out)
|
||||
|
||||
|
||||
def run(args, sl, mg) -> int:
|
||||
flatten_groups = lambda text, facades: flatten_groups_impl(mg, text, facades)
|
||||
REPO = sl.REPO_ROOT
|
||||
sl.require_git_repo()
|
||||
src = args.src_crate
|
||||
src_dir = f"crates/{src}/src"
|
||||
facades = list(args.modules)
|
||||
crate_dirs = {sl.crate_ident(p.name): p.name for p in (REPO / "crates").iterdir() if (p / "Cargo.toml").is_file()}
|
||||
warnings: list[str] = []
|
||||
|
||||
def generated_names(file_name: str, owner_dir: str) -> set[str]:
|
||||
base = REPO / "crates" / owner_dir
|
||||
names: set[str] = set()
|
||||
if file_name == "catalog_constants.rs":
|
||||
rev = json.loads((base / "assets/catalog_corrections.json").read_text(encoding="utf-8"))["reviewed"]
|
||||
names |= set(rev["constants"]) | set(rev["numeric_refs"]) | set(rev["groups"])
|
||||
build = (base / "build.rs").read_text(encoding="utf-8")
|
||||
names |= set(re.findall(r'\(\s*"([A-Z][A-Z0-9_]+_MODELS)"\s*,\s*"[a-z-]+"\s*,', build))
|
||||
names |= {"compatibility_alias", "completion_names"}
|
||||
elif file_name == "provider_defaults.rs":
|
||||
data = json.loads((base / "assets/provider_descriptors.json").read_text(encoding="utf-8"))
|
||||
names |= set(data["constant_refs"]) | set(data["compatibility_constants"])
|
||||
else:
|
||||
warnings.append(f"cannot enumerate generated names of {file_name} in crates/{owner_dir}")
|
||||
return names
|
||||
|
||||
def module_text(crate_ident: str, segs: tuple[str, ...]):
|
||||
owner = crate_dirs.get(crate_ident)
|
||||
if owner is None:
|
||||
return None, None
|
||||
mod_dir = REPO / "crates" / owner / "src"
|
||||
text = (mod_dir / "lib.rs").read_text(encoding="utf-8")
|
||||
for seg in segs:
|
||||
masked = mg.lex_mask(text)
|
||||
m = re.search(rf"(?m)^\s*(?:pub(?:\([^)]*\))?\s+)?mod\s+{re.escape(seg)}\s*(;|\{{)", masked)
|
||||
if not m:
|
||||
return None, owner
|
||||
if m.group(1) == "{":
|
||||
text = text[m.end() : mg.match_brace(masked, m.end() - 1)]
|
||||
else:
|
||||
path = next((c for c in (mod_dir / f"{seg}.rs", mod_dir / seg / "mod.rs") if c.is_file()), None)
|
||||
if path is None:
|
||||
return None, owner
|
||||
text = path.read_text(encoding="utf-8")
|
||||
mod_dir = mod_dir / seg
|
||||
return text, owner
|
||||
|
||||
def pub_names(crate_ident: str, segs: tuple[str, ...]) -> set[str]:
|
||||
text, owner = module_text(crate_ident, segs)
|
||||
if text is None:
|
||||
warnings.append(f"cannot find {crate_ident}::{'::'.join(segs)}")
|
||||
return set()
|
||||
masked = mg.lex_mask(text)
|
||||
names: set[str] = set()
|
||||
depth = 0
|
||||
for m in re.finditer(r"[{}]|(?m:^)[ \t]*pub(?:\([^)]*\))?\s+(?:async\s+|unsafe\s+|const\s+)*(?:const|static|fn|struct|enum|type|trait|mod|union)\s+(\w+)|(?m:^)[ \t]*pub\s+use\s+([^;]*);", masked):
|
||||
tok = m.group(0)
|
||||
if tok == "{":
|
||||
depth += 1
|
||||
elif tok == "}":
|
||||
depth -= 1
|
||||
elif depth == 0:
|
||||
if m.group(1):
|
||||
names.add(m.group(1))
|
||||
elif m.group(2) is not None:
|
||||
for bound, path in mg.use_leaves(m.group(2)):
|
||||
if bound != "*":
|
||||
names.add(bound)
|
||||
for inc in re.finditer(r'include!\s*\(\s*concat!\s*\(\s*env!\s*\(\s*"OUT_DIR"\s*\)\s*,\s*"/([\w.]+)"\s*\)\s*\)', text):
|
||||
names |= generated_names(inc.group(1), owner)
|
||||
return names
|
||||
|
||||
exports: dict[str, dict[str, tuple[str, ...]]] = {}
|
||||
|
||||
def facade_files(facade: str):
|
||||
out = {}
|
||||
for rel in sl.tracked_files(f"{src_dir}/{facade}.rs", f"{src_dir}/{facade}"):
|
||||
if not rel.endswith(".rs") or mg.is_test_path(rel):
|
||||
continue
|
||||
sub = rel[len(src_dir) + 1 :]
|
||||
parts = sub[:-3].split("/")
|
||||
if parts[-1] == "mod":
|
||||
parts = parts[:-1]
|
||||
out["::".join(parts)] = rel
|
||||
return out
|
||||
|
||||
local_children: dict[str, set[str]] = {}
|
||||
for facade in facades:
|
||||
files = facade_files(facade)
|
||||
for modpath, rel in files.items():
|
||||
text = sl.read_text(rel)
|
||||
masked = mg.lex_mask(text)
|
||||
ex = exports.setdefault(modpath, {})
|
||||
child_names = {k.split("::")[-1] for k in files if k.startswith(modpath + "::") and k.count("::") == modpath.count("::") + 1}
|
||||
local_children[modpath] = child_names
|
||||
for m in re.finditer(r"(?m)^(?P<attrs>(?:[ \t]*#\[[^\n]*\]\s*\n)*)pub(?:\([^)]*\))?[ \t]+use[ \t]+", masked):
|
||||
if "cfg(test)" in m.group("attrs"):
|
||||
continue
|
||||
end = masked.find(";", m.end())
|
||||
for bound, path in mg.use_leaves(masked[m.end() : end]):
|
||||
head = path[0]
|
||||
if head in ("self", "super", "crate") or head in child_names:
|
||||
continue
|
||||
if bound == "*":
|
||||
for name in pub_names(head, path[1:-1]):
|
||||
ex[name] = path[:-1] + (name,)
|
||||
else:
|
||||
ex[bound] = path
|
||||
for modpath in sorted(files, key=lambda p: -p.count("::")):
|
||||
text = sl.read_text(files[modpath])
|
||||
masked = mg.lex_mask(text)
|
||||
for m in re.finditer(r"(?m)^pub(?:\([^)]*\))?[ \t]+use[ \t]+(\w+)::\*[ \t]*;", masked):
|
||||
child = f"{modpath}::{m.group(1)}"
|
||||
if child in exports:
|
||||
exports[modpath].update(exports[child])
|
||||
|
||||
alt_by_module = {
|
||||
mp: re.compile(
|
||||
r"(?<![\$\w])crate::" + mp.replace("::", "::") + r"::(" + "|".join(re.escape(n) for n in sorted(ex, key=lambda n: -len(n))) + r")(?![A-Za-z0-9_])"
|
||||
)
|
||||
for mp, ex in exports.items()
|
||||
if ex
|
||||
}
|
||||
|
||||
all_src = [f for f in sl.tracked_files(src_dir) if f.endswith(".rs")]
|
||||
facade_rels = set()
|
||||
for facade in facades:
|
||||
facade_rels |= set(facade_files(facade).values())
|
||||
new: dict[str, str] = {}
|
||||
fps: dict[str, object] = {}
|
||||
|
||||
def read(rel: str) -> str:
|
||||
p = REPO / rel
|
||||
fps[rel] = sl.Fingerprint.of(p)
|
||||
return p.read_text(encoding="utf-8")
|
||||
|
||||
direct_lines = direct_files = 0
|
||||
grouped = []
|
||||
relative_uses = collections.Counter()
|
||||
direct_files_set = set()
|
||||
for rel in all_src:
|
||||
if rel in facade_rels:
|
||||
continue
|
||||
text = read(rel)
|
||||
if "crate::" not in text and not any(f + "::" in text for f in facades):
|
||||
continue
|
||||
out = flatten_groups(text, facades)
|
||||
masked = mg.lex_mask(out)
|
||||
for facade in facades:
|
||||
group_re = re.compile(rf"(?m)^(?P<indent>[ \t]*)(?P<vis>pub(?:\([^)]*\))?[ \t]+)?use[ \t]+crate::(?P<path>{re.escape(facade)}(?:::\w+)*)::\{{")
|
||||
pieces, pos = [], 0
|
||||
for m in group_re.finditer(masked):
|
||||
if m.start() < pos:
|
||||
continue
|
||||
ex = exports.get(m.group("path").replace("::", "::"))
|
||||
if not ex:
|
||||
continue
|
||||
open_i = m.end() - 1
|
||||
close_i = mg.match_brace(masked, open_i)
|
||||
semi = masked.find(";", close_i)
|
||||
depth, start, ents = 0, open_i + 1, []
|
||||
for i in range(open_i + 1, close_i):
|
||||
ch = masked[i]
|
||||
depth += (ch == "{") - (ch == "}")
|
||||
if ch == "," and depth == 0:
|
||||
ents.append((out[start:i].strip(), masked[start:i].strip()))
|
||||
start = i + 1
|
||||
ents.append((out[start:close_i].strip(), masked[start:close_i].strip()))
|
||||
ents = [(t, k) for t, k in ents if k]
|
||||
moved, kept = [], []
|
||||
for t, k in ents:
|
||||
name = re.match(r"(\w+)", k)
|
||||
(moved if name and name.group(1) in ex else kept).append((t, name.group(1) if name else None))
|
||||
if not moved:
|
||||
continue
|
||||
by_parent = collections.defaultdict(list)
|
||||
for t, name in moved:
|
||||
up = ex[name]
|
||||
rest = t[len(name) :]
|
||||
by_parent["::".join(up[:-1])].append(up[-1] + rest)
|
||||
indent, vis = m.group("indent"), m.group("vis") or ""
|
||||
stmts = []
|
||||
if kept:
|
||||
stmts.append(f"{indent}{vis}use crate::{m.group('path')}::{{{', '.join(t for t, _ in kept)}}};")
|
||||
for parent, leaves in by_parent.items():
|
||||
body = leaves[0] if len(leaves) == 1 else "{" + ", ".join(leaves) + "}"
|
||||
stmts.append(f"{indent}{vis}use {parent}::{body};")
|
||||
pieces.append(out[pos : m.start()])
|
||||
pieces.append("\n".join(stmts))
|
||||
pos = semi + 1
|
||||
grouped.append(f"{rel}:{masked.count(chr(10), 0, m.start()) + 1}")
|
||||
if pieces:
|
||||
pieces.append(out[pos:])
|
||||
out = "".join(pieces)
|
||||
masked = mg.lex_mask(out)
|
||||
lines = set()
|
||||
|
||||
def sub(mp, rx):
|
||||
nonlocal out
|
||||
def repl(mo):
|
||||
lines.add(out.count("\n", 0, mo.start()) + 1)
|
||||
return "::".join(exports[mp][mo.group(1)])
|
||||
out = rx.sub(repl, out)
|
||||
|
||||
for mp in sorted(alt_by_module, key=lambda p: -p.count("::")):
|
||||
sub(mp, alt_by_module[mp])
|
||||
if lines:
|
||||
direct_lines += len(lines)
|
||||
direct_files_set.add(rel)
|
||||
for facade in facades:
|
||||
if re.search(rf"\buse\s+crate::{re.escape(facade)}\s*(;|as\b)|crate::\{{[^}}]*\b{re.escape(facade)}\b[^:]", masked):
|
||||
ex = exports.get(facade, {})
|
||||
for mo in re.finditer(rf"(?<![\w:$]){re.escape(facade)}::(\w+)", masked):
|
||||
if mo.group(1) in ex:
|
||||
relative_uses[rel] += 1
|
||||
if out != text:
|
||||
new[rel] = out
|
||||
sweep = []
|
||||
report = {
|
||||
"facades": facades,
|
||||
"reexported_names": {mp: len(ex) for mp, ex in exports.items()},
|
||||
"direct_lines": direct_lines,
|
||||
"direct_files": len(direct_files_set),
|
||||
"grouped_lists": len(grouped),
|
||||
"grouped_files": len({g.split(":")[0] for g in grouped}),
|
||||
"files_rewritten": len(new),
|
||||
"relative_uses_not_rewritten": {"sites": sum(relative_uses.values()), "files": len(relative_uses)},
|
||||
"warnings": warnings,
|
||||
"upstream_paths": {mp: {n: "::".join(p) for n, p in sorted(ex.items())} for mp, ex in exports.items()},
|
||||
}
|
||||
if args.json:
|
||||
json.dump({**report, "rewrite_set": sorted(new)}, sys.stdout, indent=1)
|
||||
print()
|
||||
else:
|
||||
print(f"re-exported names: {report['reexported_names']}")
|
||||
print(f"direct crate::F::NAME: {direct_lines} lines in {len(direct_files_set)} files; grouped use lists split: {len(grouped)} in {report['grouped_files']} files")
|
||||
print(f"relative F::NAME uses (not rewritten): {report['relative_uses_not_rewritten']}; files rewritten: {len(new)}")
|
||||
for w in warnings:
|
||||
print(f"warning: {w}")
|
||||
if args.dry_run:
|
||||
return 0
|
||||
if args.require_clean:
|
||||
dirty = sorted(sl.dirty_paths() & set(new))
|
||||
young = sl.recently_modified(sorted(new), args.quiet_minutes)
|
||||
if dirty or young:
|
||||
raise sl.SplitError(
|
||||
"--require-clean refused; the rewrite set overlaps live work:\n "
|
||||
+ "\n ".join([f"dirty: {p}" for p in dirty] + [f"modified {a:.1f} min ago: {p}" for p, a in young]),
|
||||
5,
|
||||
)
|
||||
changes, fmt = [], {}
|
||||
originals = {rel: read(rel) if rel not in fps else None for rel in new}
|
||||
originals = {rel: (sl.REPO_ROOT / rel).read_text(encoding="utf-8") for rel in new}
|
||||
if not args.no_format:
|
||||
formatted, _ = sl.format_many({rel: (originals[rel], text) for rel, text in new.items()})
|
||||
else:
|
||||
formatted = new
|
||||
for rel, text in sorted(formatted.items()):
|
||||
changes.append(sl.Change(rel, text, fps[rel]))
|
||||
jdir = Path(args.journal_dir) if args.journal_dir else sl.default_journal_dir("reexports-" + "-".join(facades))
|
||||
sl.apply_changes(changes, jdir, args.pause_after_plan, args.pause_mid_write)
|
||||
print(f"applied {len(changes)} change(s); journal {jdir}", file=sys.stderr)
|
||||
return 0
|
||||
@@ -0,0 +1,412 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Shared safety layer for the crate-split tools.
|
||||
|
||||
Everything here exists so a mechanical rewrite on the shared checkout can fail
|
||||
visibly and leave the tree as it found it:
|
||||
|
||||
* git failures are fatal, and the repository root must itself hold `.git`
|
||||
(a `git archive` export would otherwise silently do nothing);
|
||||
* every write is a compare-and-swap against the bytes the plan was computed
|
||||
from, through a temp file and `os.replace`, with a pre-image journal;
|
||||
* any exception or SIGTERM/SIGHUP/SIGINT during the apply phase restores the
|
||||
journalled pre-images in reverse order;
|
||||
* rewritten Rust goes through `rustfmt --edition 2024 --emit stdout` on stdin,
|
||||
one file at a time, so no `mod` child and no neighbouring writer's file is
|
||||
reformatted.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import hashlib
|
||||
import importlib.util
|
||||
import json
|
||||
import os
|
||||
import re
|
||||
import shutil
|
||||
import signal
|
||||
import subprocess
|
||||
import sys
|
||||
import time
|
||||
from concurrent.futures import ThreadPoolExecutor
|
||||
from dataclasses import dataclass
|
||||
from pathlib import Path
|
||||
|
||||
REPO_ROOT = Path(__file__).resolve().parents[2]
|
||||
|
||||
|
||||
class SplitError(SystemExit):
|
||||
def __init__(self, message: str, code: int = 2):
|
||||
self.message = f"split: {message}"
|
||||
self.exit_code = code
|
||||
super().__init__(self.message)
|
||||
|
||||
def __str__(self) -> str:
|
||||
return self.message
|
||||
|
||||
|
||||
def run_main(fn) -> int:
|
||||
try:
|
||||
return fn()
|
||||
except SplitError as exc:
|
||||
print(exc.message, file=sys.stderr)
|
||||
return exc.exit_code
|
||||
|
||||
|
||||
def git(*args: str, input_text: str | None = None) -> str:
|
||||
proc = subprocess.run(
|
||||
["git", *args], cwd=REPO_ROOT, capture_output=True, text=True, input=input_text
|
||||
)
|
||||
if proc.returncode != 0:
|
||||
raise SplitError(f"`git {' '.join(args)}` failed ({proc.returncode}): {proc.stderr.strip()}")
|
||||
return proc.stdout
|
||||
|
||||
|
||||
def require_git_repo() -> None:
|
||||
if not (REPO_ROOT / ".git").exists():
|
||||
raise SplitError(f"{REPO_ROOT} has no .git; refusing to run on a tree git cannot see")
|
||||
top = Path(git("rev-parse", "--show-toplevel").strip()).resolve()
|
||||
if top != REPO_ROOT.resolve():
|
||||
raise SplitError(f"git toplevel {top} is not {REPO_ROOT}; refusing to run")
|
||||
|
||||
|
||||
def tracked_files(*pathspecs: str) -> list[str]:
|
||||
out = git("ls-files", "-z", "--", *pathspecs)
|
||||
return [p for p in out.split("\0") if p]
|
||||
|
||||
|
||||
def grep_files(pattern: str, *pathspecs: str, fixed: bool = True) -> list[str]:
|
||||
args = ["grep", "-I", "-l", "-z"]
|
||||
if fixed:
|
||||
args.append("-F")
|
||||
args += ["-e", pattern, "--", *(pathspecs or (".",))]
|
||||
proc = subprocess.run(["git", *args], cwd=REPO_ROOT, capture_output=True, text=True)
|
||||
if proc.returncode not in (0, 1):
|
||||
raise SplitError(f"`git {' '.join(args)}` failed ({proc.returncode}): {proc.stderr.strip()}")
|
||||
return [p for p in proc.stdout.split("\0") if p]
|
||||
|
||||
|
||||
def dirty_paths() -> set[str]:
|
||||
out = git("status", "--porcelain=v1", "-z", "--untracked-files=all")
|
||||
paths: set[str] = set()
|
||||
entries = out.split("\0")
|
||||
i = 0
|
||||
while i < len(entries):
|
||||
entry = entries[i]
|
||||
i += 1
|
||||
if len(entry) < 4:
|
||||
continue
|
||||
status, path = entry[:2], entry[3:]
|
||||
paths.add(path)
|
||||
if status[0] in "RC":
|
||||
if i < len(entries) and entries[i]:
|
||||
paths.add(entries[i])
|
||||
i += 1
|
||||
return paths
|
||||
|
||||
|
||||
def recently_modified(rels: list[str], minutes: float) -> list[tuple[str, float]]:
|
||||
if minutes <= 0:
|
||||
return []
|
||||
now = time.time()
|
||||
out = []
|
||||
for rel in rels:
|
||||
try:
|
||||
age = (now - (REPO_ROOT / rel).stat().st_mtime) / 60.0
|
||||
except OSError:
|
||||
continue
|
||||
if age < minutes:
|
||||
out.append((rel, age))
|
||||
return out
|
||||
|
||||
|
||||
def sha256_bytes(data: bytes) -> str:
|
||||
return hashlib.sha256(data).hexdigest()
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class Fingerprint:
|
||||
size: int
|
||||
mtime_ns: int
|
||||
ino: int
|
||||
sha: str
|
||||
|
||||
@staticmethod
|
||||
def of(path: Path) -> "Fingerprint":
|
||||
st = path.stat()
|
||||
return Fingerprint(st.st_size, st.st_mtime_ns, st.st_ino, sha256_bytes(path.read_bytes()))
|
||||
|
||||
def matches(self, path: Path) -> bool:
|
||||
try:
|
||||
st = path.stat()
|
||||
except OSError:
|
||||
return False
|
||||
if (st.st_size, st.st_mtime_ns, st.st_ino) == (self.size, self.mtime_ns, self.ino):
|
||||
return True
|
||||
return st.st_size == self.size and sha256_bytes(path.read_bytes()) == self.sha
|
||||
|
||||
|
||||
def read_text(rel: str) -> str:
|
||||
return (REPO_ROOT / rel).read_text(encoding="utf-8")
|
||||
|
||||
|
||||
def rustfmt_text(text: str, label: str) -> str:
|
||||
proc = subprocess.run(
|
||||
["rustfmt", "--edition", "2024", "--emit", "stdout"],
|
||||
cwd=REPO_ROOT,
|
||||
input=text,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
)
|
||||
if proc.returncode != 0:
|
||||
raise SplitError(f"rustfmt failed on {label}: {proc.stderr.strip()[:600]}")
|
||||
return proc.stdout
|
||||
|
||||
|
||||
def format_many(items: dict[str, tuple[str | None, str]], workers: int = 8) -> tuple[dict[str, str], list[str]]:
|
||||
"""Format rewritten Rust in memory.
|
||||
|
||||
`items` maps a label to (base_text_or_None, new_text). A file whose base
|
||||
text is not already rustfmt-stable is left exactly as rewritten, so a
|
||||
rewrite never reflows lines it did not change; the labels are returned.
|
||||
"""
|
||||
result: dict[str, str] = {}
|
||||
unstable: list[str] = []
|
||||
|
||||
def one(label: str) -> tuple[str, str, bool]:
|
||||
base, new = items[label]
|
||||
formatted = rustfmt_text(new, label)
|
||||
if base is None:
|
||||
return label, formatted, False
|
||||
if rustfmt_text(base, label) != base:
|
||||
return label, new, True
|
||||
return label, formatted, False
|
||||
|
||||
with ThreadPoolExecutor(max_workers=workers) as pool:
|
||||
for label, text, was_unstable in pool.map(one, sorted(items)):
|
||||
result[label] = text
|
||||
if was_unstable:
|
||||
unstable.append(label)
|
||||
return result, unstable
|
||||
|
||||
|
||||
@dataclass
|
||||
class Change:
|
||||
rel: str
|
||||
text: str | None # None deletes the file
|
||||
base: Fingerprint | None = None # None means the file must not exist yet
|
||||
mode_from: str | None = None # copy permission bits from this tracked path
|
||||
|
||||
|
||||
class Interrupted(Exception):
|
||||
pass
|
||||
|
||||
|
||||
class Journal:
|
||||
def __init__(self, root: Path):
|
||||
self.root = root
|
||||
self.pre = root / "pre"
|
||||
self.ops: list[dict] = []
|
||||
self.created_dirs: list[str] = []
|
||||
self.pre.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
def _flush(self) -> None:
|
||||
tmp = self.root / "manifest.json.tmp"
|
||||
tmp.write_text(
|
||||
json.dumps({"ops": self.ops, "created_dirs": self.created_dirs}, indent=1), encoding="utf-8"
|
||||
)
|
||||
os.replace(tmp, self.root / "manifest.json")
|
||||
|
||||
def record(self, op: dict, pre_bytes: bytes | None) -> None:
|
||||
if pre_bytes is not None:
|
||||
name = f"{len(self.ops):06d}"
|
||||
(self.pre / name).write_bytes(pre_bytes)
|
||||
op["pre"] = name
|
||||
self.ops.append(op)
|
||||
self._flush()
|
||||
|
||||
def note_dir(self, rel: str) -> None:
|
||||
self.created_dirs.append(rel)
|
||||
self._flush()
|
||||
|
||||
|
||||
def default_journal_dir(label: str) -> Path:
|
||||
stamp = time.strftime("%Y%m%d-%H%M%S")
|
||||
backups = REPO_ROOT.parent / "backups"
|
||||
if not backups.is_dir():
|
||||
raise SplitError(f"{backups} does not exist; pass --journal-dir")
|
||||
return backups / f"crate-split-{label}-{stamp}"
|
||||
|
||||
|
||||
def rollback(journal_root: Path, repo: Path = REPO_ROOT) -> list[str]:
|
||||
manifest = json.loads((journal_root / "manifest.json").read_text(encoding="utf-8"))
|
||||
problems: list[str] = []
|
||||
for op in reversed(manifest["ops"]):
|
||||
path = repo / op["path"]
|
||||
try:
|
||||
if op["op"] in ("write", "delete") and "pre" in op:
|
||||
data = (journal_root / "pre" / op["pre"]).read_bytes()
|
||||
path.parent.mkdir(parents=True, exist_ok=True)
|
||||
tmp = path.with_name(path.name + ".split-restore")
|
||||
tmp.write_bytes(data)
|
||||
if "mode" in op:
|
||||
os.chmod(tmp, op["mode"])
|
||||
os.replace(tmp, path)
|
||||
elif op["op"] == "write":
|
||||
if path.exists():
|
||||
path.unlink()
|
||||
except OSError as exc:
|
||||
problems.append(f"{op['path']}: {exc}")
|
||||
for rel in reversed(manifest["created_dirs"]):
|
||||
try:
|
||||
(repo / rel).rmdir()
|
||||
except OSError:
|
||||
pass
|
||||
return problems
|
||||
|
||||
|
||||
def apply_changes(
|
||||
changes: list[Change],
|
||||
journal_dir: Path,
|
||||
pause_after_plan: float = 0.0,
|
||||
pause_mid_write: float = 0.0,
|
||||
log=lambda msg: print(msg, file=sys.stderr),
|
||||
) -> None:
|
||||
"""Compare-and-swap every change in, or restore every pre-image and raise."""
|
||||
journal_dir.mkdir(parents=True, exist_ok=False)
|
||||
journal = Journal(journal_dir)
|
||||
if pause_after_plan > 0:
|
||||
log(f"plan frozen; writing in {pause_after_plan:g}s (journal {journal_dir})")
|
||||
time.sleep(pause_after_plan)
|
||||
|
||||
stale = []
|
||||
for ch in changes:
|
||||
path = REPO_ROOT / ch.rel
|
||||
if ch.base is None:
|
||||
if path.exists() and ch.text is not None:
|
||||
stale.append(f"{ch.rel}: appeared after the plan was made")
|
||||
elif not ch.base.matches(path):
|
||||
stale.append(f"{ch.rel}: changed after the plan was made")
|
||||
if stale:
|
||||
shutil.rmtree(journal_dir)
|
||||
raise SplitError("compare-and-swap refused, nothing written:\n " + "\n ".join(stale), 3)
|
||||
|
||||
def on_signal(signum, _frame):
|
||||
raise Interrupted(f"signal {signum}")
|
||||
|
||||
previous = {}
|
||||
for sig in (signal.SIGTERM, signal.SIGHUP, signal.SIGINT):
|
||||
previous[sig] = signal.signal(sig, on_signal)
|
||||
try:
|
||||
for index, ch in enumerate(changes):
|
||||
if pause_mid_write > 0 and index == len(changes) // 2:
|
||||
log(f"{index} of {len(changes)} written; pausing {pause_mid_write:g}s (test hook)")
|
||||
time.sleep(pause_mid_write)
|
||||
path = REPO_ROOT / ch.rel
|
||||
if ch.base is not None and not ch.base.matches(path):
|
||||
raise SplitError(f"{ch.rel} changed while writing", 3)
|
||||
if ch.base is None and ch.text is not None and path.exists():
|
||||
raise SplitError(f"{ch.rel} appeared while writing", 3)
|
||||
pre = path.read_bytes() if ch.base is not None else None
|
||||
mode = path.stat().st_mode & 0o7777 if ch.base is not None else None
|
||||
if ch.text is None:
|
||||
journal.record({"op": "delete", "path": ch.rel, "mode": mode}, pre)
|
||||
path.unlink()
|
||||
continue
|
||||
op = {"op": "write", "path": ch.rel, "post_sha": sha256_bytes(ch.text.encode("utf-8"))}
|
||||
if mode is not None:
|
||||
op["mode"] = mode
|
||||
journal.record(op, pre)
|
||||
parent = path.parent
|
||||
missing = []
|
||||
while not parent.exists():
|
||||
missing.append(parent)
|
||||
parent = parent.parent
|
||||
for d in reversed(missing):
|
||||
d.mkdir()
|
||||
journal.note_dir(str(d.relative_to(REPO_ROOT)))
|
||||
tmp = path.with_name(path.name + ".split-tmp")
|
||||
tmp.write_bytes(ch.text.encode("utf-8"))
|
||||
if mode is None and ch.mode_from:
|
||||
try:
|
||||
mode = (REPO_ROOT / ch.mode_from).stat().st_mode & 0o7777
|
||||
except OSError:
|
||||
mode = None
|
||||
if mode is not None:
|
||||
os.chmod(tmp, mode)
|
||||
os.replace(tmp, path)
|
||||
except BaseException as exc:
|
||||
log(f"aborting: {exc}; restoring pre-images from {journal_dir}")
|
||||
for sig, handler in previous.items():
|
||||
signal.signal(sig, signal.SIG_IGN)
|
||||
problems = rollback(journal_dir)
|
||||
for sig, handler in previous.items():
|
||||
signal.signal(sig, handler)
|
||||
for ch in changes:
|
||||
stray = (REPO_ROOT / ch.rel).with_name(Path(ch.rel).name + ".split-tmp")
|
||||
if stray.exists():
|
||||
stray.unlink()
|
||||
if problems:
|
||||
log("restore problems:\n " + "\n ".join(problems))
|
||||
(journal_dir / "ROLLED_BACK").write_text(str(exc) + "\n", encoding="utf-8")
|
||||
if isinstance(exc, SplitError):
|
||||
raise
|
||||
raise SplitError(f"{exc}; tree restored from the journal", 4) from exc
|
||||
finally:
|
||||
for sig, handler in previous.items():
|
||||
signal.signal(sig, handler)
|
||||
(journal_dir / "COMMITTED").write_text(f"{len(changes)} change(s)\n", encoding="utf-8")
|
||||
|
||||
|
||||
def load_module_graph():
|
||||
path = Path(__file__).with_name("module_graph.py")
|
||||
spec = importlib.util.spec_from_file_location("module_graph", path)
|
||||
mod = importlib.util.module_from_spec(spec)
|
||||
sys.modules.setdefault("module_graph", mod)
|
||||
spec.loader.exec_module(mod)
|
||||
return mod
|
||||
|
||||
|
||||
def crate_ident(crate_dir: str) -> str:
|
||||
manifest = (REPO_ROOT / "crates" / crate_dir / "Cargo.toml").read_text(encoding="utf-8")
|
||||
in_package = False
|
||||
for line in manifest.splitlines():
|
||||
stripped = line.strip()
|
||||
if stripped.startswith("["):
|
||||
in_package = stripped == "[package]"
|
||||
continue
|
||||
if in_package and stripped.startswith("name"):
|
||||
return stripped.split("=", 1)[1].strip().strip('"').replace("-", "_")
|
||||
raise SplitError(f"no [package] name in crates/{crate_dir}/Cargo.toml")
|
||||
|
||||
|
||||
SWEEP_SKIP_PREFIXES = ("vendor/", "crates/config/", "scripts/split/")
|
||||
SWEEP_SKIP_GENERATED = re.compile(r"^web/data/[^/]+\.generated\.json$")
|
||||
|
||||
|
||||
def sweep_skipped(rel: str) -> bool:
|
||||
return (
|
||||
rel.startswith(SWEEP_SKIP_PREFIXES)
|
||||
or Path(rel).name.startswith("CHANGELOG")
|
||||
or bool(SWEEP_SKIP_GENERATED.match(rel))
|
||||
)
|
||||
|
||||
|
||||
def sweep_regexes(src: str, dest: str, mods: list[str]) -> list[tuple[re.Pattern, object]]:
|
||||
"""Path-string rewrites shared by the mover and the preflight."""
|
||||
out: list[tuple[re.Pattern, object]] = []
|
||||
for m in mods:
|
||||
em = re.escape(m)
|
||||
out.append((re.compile(rf"crates/{src}/src/{em}(?![A-Za-z0-9_])"), f"crates/{dest}/src/{m}"))
|
||||
out.append((re.compile(rf"(?<![A-Za-z0-9_/]){src}/src/{em}(?![A-Za-z0-9_])"), f"{dest}/src/{m}"))
|
||||
segmented = re.compile(
|
||||
rf"""(?P<a>(["'])){src}(?P=a)(?P<s1>\s*(?:,|/)\s*)(?P<b>(["']))src(?P=b)(?P<s2>\s*(?:,|/)\s*)(?P<c>(["'])){em}(?:\.rs)?(?P=c)"""
|
||||
)
|
||||
out.append(
|
||||
(
|
||||
segmented,
|
||||
lambda mo, dest=dest, src=src: mo.group(0).replace(
|
||||
f"{mo.group('a')}{src}{mo.group('a')}", f"{mo.group('a')}{dest}{mo.group('a')}", 1
|
||||
),
|
||||
)
|
||||
)
|
||||
return out
|
||||
@@ -0,0 +1,192 @@
|
||||
#!/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))
|
||||
+139
-11
@@ -19,23 +19,37 @@ compiler names:
|
||||
It never bulk-rewrites `pub(crate)`. Every widened item is printed; items in
|
||||
security-relevant modules are flagged for human review.
|
||||
|
||||
While the loop runs, the runtime manifest's `[lints] workspace = true` is
|
||||
While the loop runs, the destination manifest's `[lints] workspace = true` is
|
||||
commented out (lints capped to warnings), because an item that is dead inside
|
||||
the runtime would otherwise fail the runtime build under `warnings = "deny"`
|
||||
before cargo ever reached the TUI's privacy errors. The stanza is restored
|
||||
when the loop ends, and a final check runs with it.
|
||||
the destination would otherwise fail its build under `warnings = "deny"`
|
||||
before cargo ever reached the consumer's privacy errors. The stanza is
|
||||
restored on exit, on SIGTERM, SIGHUP and SIGINT (build-lock.py forwards the
|
||||
first two), and a manifest left capped by a killed earlier run is restored
|
||||
before anything else; a final check runs with the stanza back.
|
||||
|
||||
`--prepass` widens, before the first compile, every `pub(crate)`/`pub(super)`
|
||||
declaration in the destination whose name also appears in the consumer crates,
|
||||
which saves most of the compile rounds (privacy errors only show when the
|
||||
consumer is compiled). `unused_imports` warnings left by the move are removed
|
||||
with the compiler's own machine-applicable suggestions, because
|
||||
`warnings = "deny"` turns each one into a hard error.
|
||||
|
||||
Usage:
|
||||
python3 scripts/split/widen.py [--cargo "scripts/dev-cargo.sh"] [--max-rounds N]
|
||||
python3 scripts/split/widen.py [--dest-crate runtime] [--src-crate tui]
|
||||
[--packages codewhale-runtime,codewhale-tui,codewhale-cli]
|
||||
[--cargo "scripts/dev-cargo.sh"] [--max-rounds N] [--prepass]
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import atexit
|
||||
import collections
|
||||
import json
|
||||
import os
|
||||
import re
|
||||
import shlex
|
||||
import signal
|
||||
import subprocess
|
||||
import sys
|
||||
from pathlib import Path
|
||||
@@ -43,6 +57,22 @@ from pathlib import Path
|
||||
REPO_ROOT = Path(__file__).resolve().parents[2]
|
||||
RUNTIME_SRC = REPO_ROOT / "crates" / "runtime" / "src"
|
||||
RUNTIME_MANIFEST = REPO_ROOT / "crates" / "runtime" / "Cargo.toml"
|
||||
CONSUMER_DIRS = ["tui", "cli"]
|
||||
COMMON_NAMES = {
|
||||
"new", "fmt", "from", "into", "default", "len", "get", "set", "build", "name", "id", "path", "run",
|
||||
"load", "save", "parse", "take", "text", "kind", "value", "data", "state", "config", "error",
|
||||
}
|
||||
|
||||
|
||||
class Interrupted(BaseException):
|
||||
pass
|
||||
|
||||
|
||||
def configure(dest: str, src: str) -> None:
|
||||
global RUNTIME_SRC, RUNTIME_MANIFEST, CONSUMER_DIRS
|
||||
RUNTIME_SRC = REPO_ROOT / "crates" / dest / "src"
|
||||
RUNTIME_MANIFEST = REPO_ROOT / "crates" / dest / "Cargo.toml"
|
||||
CONSUMER_DIRS = [src, "cli"] if src != "cli" else ["cli"]
|
||||
LINTS_ON = "[lints]\nworkspace = true\n"
|
||||
LINTS_CAPPED = "# [lints] capped by scripts/split/widen.py\n# workspace = true\n"
|
||||
PRIVACY_CODES = {"E0603", "E0624", "E0616", "E0451"}
|
||||
@@ -149,10 +179,82 @@ def apply(edits: dict[tuple[Path, int], str | None]) -> list[str]:
|
||||
else:
|
||||
lines[line_no - 1] = new
|
||||
report.append(f"widened {rel}:{line_no}: {new.strip()}{flag}")
|
||||
path.write_text("\n".join(lines), encoding="utf-8")
|
||||
atomic_write(path, "\n".join(lines).encode("utf-8"))
|
||||
return report
|
||||
|
||||
|
||||
def atomic_write(path: Path, data: bytes) -> None:
|
||||
tmp = path.with_name(path.name + ".widen-tmp")
|
||||
tmp.write_bytes(data)
|
||||
os.chmod(tmp, path.stat().st_mode & 0o7777)
|
||||
os.replace(tmp, path)
|
||||
|
||||
|
||||
def unused_import_suggestions(messages: list[dict]) -> dict[Path, list[tuple[int, int, str]]]:
|
||||
out: dict[Path, set[tuple[int, int, str]]] = collections.defaultdict(set)
|
||||
for msg in messages:
|
||||
if (msg.get("code") or {}).get("code") != "unused_imports":
|
||||
continue
|
||||
for child in msg.get("children", []):
|
||||
for span in child.get("spans", []):
|
||||
if span.get("suggested_replacement") is None or span.get("suggestion_applicability") != "MachineApplicable":
|
||||
continue
|
||||
path = (REPO_ROOT / span["file_name"]).resolve()
|
||||
if str(path).startswith(str(REPO_ROOT / "crates")):
|
||||
out[path].add((span["byte_start"], span["byte_end"], span["suggested_replacement"]))
|
||||
return {p: sorted(v) for p, v in out.items()}
|
||||
|
||||
|
||||
def apply_suggestions(suggestions: dict[Path, list[tuple[int, int, str]]]) -> list[str]:
|
||||
report = []
|
||||
for path, spans in suggestions.items():
|
||||
data = path.read_bytes()
|
||||
last = len(data) + 1
|
||||
applied = 0
|
||||
for start, end, repl in sorted(spans, reverse=True):
|
||||
if end > last:
|
||||
continue
|
||||
data = data[:start] + repl.encode("utf-8") + data[end:]
|
||||
last = start
|
||||
applied += 1
|
||||
atomic_write(path, data)
|
||||
report.append(f"unused import: {applied} removal(s) in {path.relative_to(REPO_ROOT)}")
|
||||
return report
|
||||
|
||||
|
||||
def static_prepass() -> list[str]:
|
||||
decl = re.compile(
|
||||
r"^[ \t]*pub\((?:crate|super)\)[ \t]+(?:async\s+|unsafe\s+|const\s+)*(?:(?:fn|struct|enum|const|static|type|trait|mod)[ \t]+([A-Za-z_]\w*))",
|
||||
re.M,
|
||||
)
|
||||
tok = re.compile(r"[A-Za-z_]\w*")
|
||||
consumer_tokens: set[str] = set()
|
||||
for crate in CONSUMER_DIRS:
|
||||
for path in (REPO_ROOT / "crates" / crate).rglob("*.rs"):
|
||||
if "target" in path.parts:
|
||||
continue
|
||||
consumer_tokens.update(tok.findall(path.read_text(encoding="utf-8", errors="replace")))
|
||||
edits: dict[tuple[Path, int], str | None] = {}
|
||||
skipped = 0
|
||||
for path in RUNTIME_SRC.rglob("*.rs"):
|
||||
lines = path.read_text(encoding="utf-8").split("\n")
|
||||
for i, line in enumerate(lines):
|
||||
m = decl.match(line)
|
||||
if not m:
|
||||
continue
|
||||
name = m.group(1)
|
||||
if name in COMMON_NAMES or (len(name) < 6 and "_" not in name):
|
||||
skipped += 1
|
||||
continue
|
||||
if name in consumer_tokens:
|
||||
new = widen_line(line, name)
|
||||
if new is not None:
|
||||
edits[(path, i + 1)] = new
|
||||
log = apply(edits) if edits else []
|
||||
log.append(f"prepass: {len(edits)} declaration(s) widened, {skipped} common/short name(s) left to the compiler")
|
||||
return log
|
||||
|
||||
|
||||
def plan_edits(messages: list[dict]) -> tuple[dict, list[str]]:
|
||||
edits: dict[tuple[Path, int], str | None] = {}
|
||||
unresolved: list[str] = []
|
||||
@@ -217,31 +319,57 @@ def set_lints(capped: bool) -> None:
|
||||
text = RUNTIME_MANIFEST.read_text(encoding="utf-8")
|
||||
old, new = (LINTS_ON, LINTS_CAPPED) if capped else (LINTS_CAPPED, LINTS_ON)
|
||||
if old in text:
|
||||
RUNTIME_MANIFEST.write_text(text.replace(old, new), encoding="utf-8")
|
||||
atomic_write(RUNTIME_MANIFEST, text.replace(old, new).encode("utf-8"))
|
||||
|
||||
|
||||
def install_signal_handlers() -> None:
|
||||
def handler(signum, _frame):
|
||||
raise Interrupted(signal.Signals(signum).name)
|
||||
|
||||
for sig in (signal.SIGTERM, signal.SIGHUP, signal.SIGINT):
|
||||
signal.signal(sig, handler)
|
||||
|
||||
|
||||
def main(argv: list[str] | None = None) -> int:
|
||||
parser = argparse.ArgumentParser(description=__doc__.split("\n\n")[0])
|
||||
parser.add_argument("--cargo", default="scripts/dev-cargo.sh")
|
||||
parser.add_argument("--max-rounds", type=int, default=12)
|
||||
parser.add_argument("--packages", default="codewhale-runtime,codewhale-tui")
|
||||
parser.add_argument("--dest-crate", default="runtime")
|
||||
parser.add_argument("--src-crate", default="tui")
|
||||
parser.add_argument("--packages", help="comma separated; default codewhale-<dest>,codewhale-<src>")
|
||||
parser.add_argument("--extra", default="--lib --tests")
|
||||
parser.add_argument("--prepass", action="store_true", help="widen declarations the consumers name before the first compile")
|
||||
parser.add_argument("--keep-unused-imports", action="store_true", help="do not apply the compiler's unused-import removals")
|
||||
args = parser.parse_args(argv)
|
||||
configure(args.dest_crate, args.src_crate)
|
||||
cargo = shlex.split(args.cargo)
|
||||
packages = args.packages.split(",")
|
||||
packages = (args.packages or f"codewhale-{args.dest_crate},codewhale-{args.src_crate}").split(",")
|
||||
extra = shlex.split(args.extra)
|
||||
|
||||
if LINTS_CAPPED in RUNTIME_MANIFEST.read_text(encoding="utf-8"):
|
||||
print(f"{RUNTIME_MANIFEST.relative_to(REPO_ROOT)} was left capped by an earlier run; restoring it first", flush=True)
|
||||
set_lints(capped=False)
|
||||
|
||||
log: list[str] = []
|
||||
install_signal_handlers()
|
||||
atexit.register(lambda: set_lints(capped=False) if RUNTIME_MANIFEST.exists() else None)
|
||||
set_lints(capped=True)
|
||||
try:
|
||||
if args.prepass:
|
||||
log.extend(static_prepass())
|
||||
for round_no in range(1, args.max_rounds + 1):
|
||||
messages = run_cargo(cargo, packages, extra)
|
||||
edits, unresolved = plan_edits(messages)
|
||||
if not edits:
|
||||
suggestions = {} if args.keep_unused_imports else unused_import_suggestions(messages)
|
||||
if not edits and not suggestions:
|
||||
break
|
||||
changes = apply(edits)
|
||||
changes = apply(edits) if edits else []
|
||||
changes += apply_suggestions(suggestions)
|
||||
log.extend(changes)
|
||||
print(f"round {round_no}: {len(changes)} edit(s)", flush=True)
|
||||
except Interrupted as exc:
|
||||
print(f"interrupted by {exc}; manifest restored, edits so far stay in the tree", flush=True)
|
||||
return 130
|
||||
finally:
|
||||
set_lints(capped=False)
|
||||
for line in log:
|
||||
|
||||
Reference in new issue
Block a user