mirror of
https://github.com/codewhale-hq/Codewhale.git
synced 2026-11-09 09:59:45 -10:00
Scripts only under scripts/split/. No Rust, no manifest, no budget touched. Base00be3d2e4f. tui strict production [M] 634,761 -> 634,761, file level [F] 943,686 -> 943,686, largest SCC 79 -> 79 (partition_report --non-test: [79, 8, 1, 1, 1]). Nothing was compiled: this is python; the rewrites it produces were checked with rustfmt parse/format, module_graph.py, the path-keyed budget scripts and counts, on a scratch `git init` export of this base (never the shared tree). A compile of a rewritten tree is the first Lane A window. What changed and why (reasoning lives here, not in comments): move-modules.py (rewritten). R2.5: the old tool exited 0 in a tree without .git after "moved 6 module(s)" (git failures were check=False), and its dry-run headline (354 files / 3,487 lines) counted 12 files / 212 lines inside the moved set that a real run never rewrites. - --dest-crate/--src-crate replace every hard-coded runtime literal; --json; --require-clean (dirty set from `git status --porcelain -z` plus files younger than --quiet-minutes, default 15); --apply-cfg (flips only `#[cfg(test)]` items that code outside the moved set uses; never touches cfg(not(test)) forks, which are an FD-04 decision); --restore <journal>. - No git mv (the shared index is not touched): plain file operations. Every write is compare-and-swap (stat+sha against the bytes the plan was computed from, temp file, os.replace) with a pre-image journal under CW/backups/; a change between plan and write refuses before writing (exit 3); a change or SIGTERM/SIGHUP/SIGINT during the write phase restores every pre-image and removes created files and directories (exit 4). git/git grep failures and a missing or foreign .git are fatal (exit 2). --require-clean refusals exit 5. - Rewritten Rust goes through `rustfmt --edition 2024 --emit stdout` on stdin per file; a file whose base text is not rustfmt-stable (the 17 include!d test_cases_*.rs fragments) is left exactly as rewritten, so no foreign line is reflowed. - Coverage: plain and grouped `use crate::{..}` (3 sites), alias, unqualified `<m>::` in the source lib (5 sites; cfg(test)-only use gets a cfg(test) import), `<dest>::` -> `crate::` inside moved files (code only), `codewhale_tui::<m>` in every other Rust file (cli config_keys 15 lines), sweep of path strings (plain, `tui/src/..`, segmented `"tui" / "src" / "<m>"`: this adds scripts/check_orcarouter_contract.py, which the old sweep missed) with vendor/, crates/config/, scripts/split/, CHANGELOG* and web/data/*.generated.json skipped; --derive-facts regenerates web/data (journalled pre-images). - Reports (and plans, never applies): residue edges from moved files to staying modules, super-root residue, manifest needs (deps, features, consumer crates), cfg sites by class. reexports.py (--resolve-reexports): resolves `pub use` facade names (globs through the upstream sources and the build.rs-generated constants via catalog_corrections.json / provider_descriptors.json) and rewrites `crate::config::NAME` and grouped lists to the upstream path. preflight.py (G11): whole-repo include_str!/include_bytes!/include!/#[path] resolution against the old-to-new map, features.toml owners, path strings the sweep cannot fix, `../src/<m>` references, CI test identities; exit 1 on residue. widen.py: --dest-crate/--src-crate/--packages, signal-safe lints restore (SIGTERM/SIGHUP/SIGINT and atexit; a manifest left capped by a killed run is restored first), atomic writes, --prepass static widening, unused_imports removed with the compiler's machine-applicable suggestions. count_lines.py promoted from the plan's scratch (identical [M]) with the [F] counter beside it. testsplit.py: function-level move/stay classifier with call closure. partition_report/closure_report --non-test; module_graph --tui-src/ --runtime-src and a regex-driven lexer (11.0 s -> 2.5 s over 1,585 files, 0 mismatches against the old lex_mask on every tracked .rs). Acceptance (scratch export of00be3d2e4f, config settings config_persistence config_keys oauth codex_model_cache): - dry run: staying callers 342 files / 3,275 lines + inside the moved set 12 / 212 = the old tool's 354 / 3,487; grouped 3; self paths 105 + 5 in comments = 110; other crates 2 files / 16 lines (15 cli + 1 runtime doc comment); sweep 31 files (+15 skipped); 81 cfg sites (53 test items, 11 test mods, 17 not(test) forks = the brief's 17); 9 s. - real rewrite: 403 paths, 18.6 s, counts identical to the dry run line for line; 334 of 351 changed .rs files rustfmt-stable (the 17 others are the base-unstable fragments); no `crate::<moved>` left in tui, no `codewhale_tui::<moved>` in cli; module_graph.py --check refuses this premature move with 43 residue edges (it is the closedness gate working). - closed module (remote_setup) real move: module_graph --check PASS with the unchanged baseline, check-blocking-calls-budget PASS (543 sites, 165 files; the sweep re-keyed the path-keyed JSON), dead-code budget PASS 209, check-provider-registry PASS, G11 unresolved 0. - guards on the scratch repo: change between plan and write -> exit 3 nothing written; change at write 201 of 403 -> exit 4 and `git status` back to only the concurrent edit; SIGTERM at 201 of 403 -> exit 4, status empty; dirty file and a file touched < 15 min -> exit 5; no .git -> exit 2. - widen.py: SIGTERM and SIGHUP while a compile round runs -> exit 130 and crates/runtime/Cargo.toml back to `[lints] workspace = true`; a capped manifest from an earlier run is restored at start; with a fake cargo JSON stream the E0603 widening, --prepass and the unused-import removal applied. - reexports: 226 config names (213 via models) -> 1,109 direct lines in 142 files and 68 grouped lists in 60 files outside the cluster, 166 files; the ProviderKind figure matches the plan exactly (339 lines / 95 files); the plan's 1,189 / 190 and 89 / 75 are not reproduced and likely include the cluster's own files; module_graph --check unchanged afterwards. - preflight on the cluster: residue 8 = lib.rs:20230 include_str, tests/ integration/main.rs:16 (path + relative), 5 skip-listed mentions in crates/config (catalog_corrections.json:4584 and models_dev.bundled.json:11769 key a source receipt `crates/tui/src/config/models.rs`), vendor picker.rs. - testsplit: config/tests.rs 407 tests (378 move, 29 stay), scope_tests.rs 8 (0 move: needs provider_readiness/fleet), whole cluster 687 tests (649 move, 38 stay); count_lines [M]/[F]: tui 634,761/943,686, cli 20,204/33,761, runtime 9,526/15,121, config 29,415/35,830, app-server 7,572/13,095. Not proven: no cargo build of any rewritten tree; widen.py's compile loop ran only against a fake cargo; --apply-cfg flipping is planned, not applied or compiled; Windows/FreeBSD cfg code; whether any test reads the source-receipt keys in crates/config assets as paths; the re-export resolver does not rewrite relative `config::NAME` uses (none found at this base); SCC/edge counts were not re-run after a rewrite (the cargo-free G2 stays Lane A's). Evidence: /Volumes/VIXinSSD/CW/artifacts/crate-split-w0-20261025/proof-w0.log and guard-demos.log. Dirty work of other writers in the checkout was left untouched (about 20 tui/core/models files, window W1). Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
267 lines
12 KiB
Python
267 lines
12 KiB
Python
#!/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))
|