|
| 1 | +#!/usr/bin/env python3 |
| 2 | +"""Baseline and diff tool for herkos transpiler output. |
| 3 | +
|
| 4 | +Creates named snapshots of the .rs files generated by herkos-tests and |
| 5 | +lets you diff any two baselines—or a baseline against the current build. |
| 6 | +""" |
| 7 | + |
| 8 | +import argparse |
| 9 | +import os |
| 10 | +import shutil |
| 11 | +import subprocess |
| 12 | +import sys |
| 13 | +import tempfile |
| 14 | +from datetime import datetime, timezone |
| 15 | +from pathlib import Path |
| 16 | + |
| 17 | + |
| 18 | +# --------------------------------------------------------------------------- |
| 19 | +# Paths |
| 20 | +# --------------------------------------------------------------------------- |
| 21 | + |
| 22 | +SCRIPT_DIR = Path(__file__).resolve().parent |
| 23 | +REPO_ROOT = SCRIPT_DIR.parent |
| 24 | +BASELINES_DIR = REPO_ROOT / ".herkos-baselines" |
| 25 | + |
| 26 | + |
| 27 | +def check_repo_root() -> None: |
| 28 | + if not (REPO_ROOT / "Cargo.toml").exists(): |
| 29 | + sys.exit("ERROR: Cargo.toml not found. Please run this script from the repo root.") |
| 30 | + |
| 31 | + |
| 32 | +# --------------------------------------------------------------------------- |
| 33 | +# OUT_DIR discovery |
| 34 | +# --------------------------------------------------------------------------- |
| 35 | + |
| 36 | +def find_out_dir(profile: str) -> Path | None: |
| 37 | + """Find the herkos-tests OUT_DIR that actually contains .rs files.""" |
| 38 | + build_dir = REPO_ROOT / "target" / profile / "build" |
| 39 | + if not build_dir.is_dir(): |
| 40 | + return None |
| 41 | + for candidate in build_dir.glob("herkos-tests-*/out"): |
| 42 | + if candidate.is_dir() and any(candidate.glob("*.rs")): |
| 43 | + return candidate |
| 44 | + return None |
| 45 | + |
| 46 | + |
| 47 | +# --------------------------------------------------------------------------- |
| 48 | +# Git helpers |
| 49 | +# --------------------------------------------------------------------------- |
| 50 | + |
| 51 | +def git(*args: str) -> str: |
| 52 | + result = subprocess.run( |
| 53 | + ["git", "-C", str(REPO_ROOT), *args], |
| 54 | + capture_output=True, text=True, check=True, |
| 55 | + ) |
| 56 | + return result.stdout.strip() |
| 57 | + |
| 58 | + |
| 59 | +# --------------------------------------------------------------------------- |
| 60 | +# Snapshot |
| 61 | +# --------------------------------------------------------------------------- |
| 62 | + |
| 63 | +def cmd_snapshot(name: str | None, profile: str) -> None: |
| 64 | + out_dir = find_out_dir(profile) |
| 65 | + if out_dir is None: |
| 66 | + sys.exit( |
| 67 | + f"ERROR: No populated OUT_DIR found in target/{profile}/build/herkos-tests-*/out/\n" |
| 68 | + " Run 'cargo build -p herkos-tests' first." |
| 69 | + ) |
| 70 | + |
| 71 | + if name is None: |
| 72 | + branch = git("rev-parse", "--abbrev-ref", "HEAD").replace("/", "_") |
| 73 | + commit = git("rev-parse", "--short", "HEAD") |
| 74 | + ts = datetime.now(timezone.utc).strftime("%Y%m%dT%H%M%SZ") |
| 75 | + name = f"{branch}__{commit}__{ts}" |
| 76 | + |
| 77 | + dest = BASELINES_DIR / name / "files" |
| 78 | + if (BASELINES_DIR / name).exists(): |
| 79 | + sys.exit( |
| 80 | + f"ERROR: Baseline '{name}' already exists. Delete it first:\n" |
| 81 | + f" scripts/transpile_diff.py delete {name}" |
| 82 | + ) |
| 83 | + |
| 84 | + dest.mkdir(parents=True) |
| 85 | + _copy_rs_files(out_dir, dest) |
| 86 | + |
| 87 | + rs_count = sum(1 for _ in dest.rglob("*.rs")) |
| 88 | + branch = git("rev-parse", "--abbrev-ref", "HEAD") |
| 89 | + commit_sha = git("rev-parse", "HEAD") |
| 90 | + commit_short = git("rev-parse", "--short", "HEAD") |
| 91 | + |
| 92 | + meta = BASELINES_DIR / name / "meta.env" |
| 93 | + meta.write_text( |
| 94 | + f"TIMESTAMP={datetime.now(timezone.utc).strftime('%Y-%m-%dT%H:%M:%SZ')}\n" |
| 95 | + f"BRANCH={branch}\n" |
| 96 | + f"COMMIT_SHA={commit_sha}\n" |
| 97 | + f"COMMIT_SHORT={commit_short}\n" |
| 98 | + f"HERKOS_OPTIMIZE={os.environ.get('HERKOS_OPTIMIZE', '0')}\n" |
| 99 | + f"PROFILE={profile}\n" |
| 100 | + f"OUT_DIR={out_dir}\n" |
| 101 | + f"RS_FILE_COUNT={rs_count}\n" |
| 102 | + ) |
| 103 | + |
| 104 | + print(f"Snapshot '{name}' saved ({rs_count} .rs files).") |
| 105 | + print(f" Branch: {branch}") |
| 106 | + print(f" Commit: {commit_short}") |
| 107 | + print(f" OUT_DIR: {out_dir}") |
| 108 | + |
| 109 | + |
| 110 | +def _copy_rs_files(src: Path, dest: Path) -> None: |
| 111 | + """Copy all .rs files (except *_src.rs) from src to dest, preserving structure.""" |
| 112 | + for rs_file in src.rglob("*.rs"): |
| 113 | + if rs_file.name.endswith("_src.rs"): |
| 114 | + continue |
| 115 | + rel = rs_file.relative_to(src) |
| 116 | + target = dest / rel |
| 117 | + target.parent.mkdir(parents=True, exist_ok=True) |
| 118 | + shutil.copy2(rs_file, target) |
| 119 | + |
| 120 | + |
| 121 | +# --------------------------------------------------------------------------- |
| 122 | +# List |
| 123 | +# --------------------------------------------------------------------------- |
| 124 | + |
| 125 | +def cmd_list() -> None: |
| 126 | + if not BASELINES_DIR.is_dir() or not any(BASELINES_DIR.iterdir()): |
| 127 | + print(f"No baselines found in {BASELINES_DIR}") |
| 128 | + return |
| 129 | + |
| 130 | + header = f"{'NAME':<42} {'TIMESTAMP':<22} {'BRANCH':<20} {'COMMIT':<10} FILES" |
| 131 | + print(header) |
| 132 | + print("─" * 100) |
| 133 | + |
| 134 | + for meta_path in sorted(BASELINES_DIR.glob("*/meta.env")): |
| 135 | + name = meta_path.parent.name |
| 136 | + meta = _read_meta(meta_path) |
| 137 | + print( |
| 138 | + f"{name:<42} " |
| 139 | + f"{meta.get('TIMESTAMP', '?'):<22} " |
| 140 | + f"{meta.get('BRANCH', '?'):<20} " |
| 141 | + f"{meta.get('COMMIT_SHORT', '?'):<10} " |
| 142 | + f"{meta.get('RS_FILE_COUNT', '?')}" |
| 143 | + ) |
| 144 | + |
| 145 | + |
| 146 | +def _read_meta(path: Path) -> dict[str, str]: |
| 147 | + meta: dict[str, str] = {} |
| 148 | + for line in path.read_text().splitlines(): |
| 149 | + if "=" in line: |
| 150 | + k, _, v = line.partition("=") |
| 151 | + meta[k.strip()] = v.strip() |
| 152 | + return meta |
| 153 | + |
| 154 | + |
| 155 | +# --------------------------------------------------------------------------- |
| 156 | +# Compare |
| 157 | +# --------------------------------------------------------------------------- |
| 158 | + |
| 159 | +def cmd_compare(a: str, b: str, profile: str) -> None: |
| 160 | + dir_a = _resolve_baseline(a) |
| 161 | + tmp_dir: str | None = None |
| 162 | + |
| 163 | + if b == "latest": |
| 164 | + out_dir = find_out_dir(profile) |
| 165 | + if out_dir is None: |
| 166 | + sys.exit( |
| 167 | + f"ERROR: 'latest' requested but no populated OUT_DIR found in target/{profile}/.\n" |
| 168 | + " Run 'cargo build -p herkos-tests' or specify a baseline name." |
| 169 | + ) |
| 170 | + tmp_dir = tempfile.mkdtemp() |
| 171 | + _copy_rs_files(out_dir, Path(tmp_dir)) |
| 172 | + dir_b = Path(tmp_dir) |
| 173 | + label_b = f"latest ({profile} build)" |
| 174 | + else: |
| 175 | + dir_b = _resolve_baseline(b) |
| 176 | + label_b = b |
| 177 | + |
| 178 | + try: |
| 179 | + _run_compare(a, dir_a, label_b, dir_b) |
| 180 | + finally: |
| 181 | + if tmp_dir: |
| 182 | + shutil.rmtree(tmp_dir, ignore_errors=True) |
| 183 | + |
| 184 | + |
| 185 | +def _resolve_baseline(name: str) -> Path: |
| 186 | + path = BASELINES_DIR / name / "files" |
| 187 | + if not path.is_dir(): |
| 188 | + sys.exit( |
| 189 | + f"ERROR: Baseline '{name}' not found in {BASELINES_DIR}/\n" |
| 190 | + " Run 'scripts/transpile_diff.py list' to see available baselines." |
| 191 | + ) |
| 192 | + return path |
| 193 | + |
| 194 | + |
| 195 | +def _run_compare(label_a: str, dir_a: Path, label_b: str, dir_b: Path) -> None: |
| 196 | + print("=== herkos transpiler diff ===") |
| 197 | + print(f" A: {label_a}") |
| 198 | + print(f" B: {label_b}") |
| 199 | + print() |
| 200 | + |
| 201 | + files_a = {p.relative_to(dir_a) for p in dir_a.rglob("*.rs")} |
| 202 | + files_b = {p.relative_to(dir_b) for p in dir_b.rglob("*.rs")} |
| 203 | + |
| 204 | + added = sorted(files_b - files_a) |
| 205 | + removed = sorted(files_a - files_b) |
| 206 | + changed = sorted( |
| 207 | + f for f in files_a & files_b |
| 208 | + if (dir_a / f).read_bytes() != (dir_b / f).read_bytes() |
| 209 | + ) |
| 210 | + |
| 211 | + print(f"Summary: Changed: {len(changed)} | Added: {len(added)} | Removed: {len(removed)}") |
| 212 | + print() |
| 213 | + |
| 214 | + if changed: |
| 215 | + print("Changed files:") |
| 216 | + for f in changed: |
| 217 | + print(f" ~ {f}") |
| 218 | + print() |
| 219 | + if added: |
| 220 | + print("Added files (in B, not in A):") |
| 221 | + for f in added: |
| 222 | + print(f" + {f}") |
| 223 | + print() |
| 224 | + if removed: |
| 225 | + print("Removed files (in A, not in B):") |
| 226 | + for f in removed: |
| 227 | + print(f" - {f}") |
| 228 | + print() |
| 229 | + |
| 230 | + if not (changed or added or removed): |
| 231 | + print("No differences found.") |
| 232 | + return |
| 233 | + |
| 234 | + print("Full diff:") |
| 235 | + print("─" * 60) |
| 236 | + |
| 237 | + diff_proc = subprocess.Popen( |
| 238 | + ["git", "-C", str(REPO_ROOT), "diff", "--no-index", "--color=always", |
| 239 | + str(dir_a), str(dir_b)], |
| 240 | + stdout=subprocess.PIPE, |
| 241 | + ) |
| 242 | + |
| 243 | + if sys.stdout.isatty(): |
| 244 | + pager = subprocess.Popen(["less", "-R"], stdin=diff_proc.stdout) |
| 245 | + diff_proc.stdout.close() # type: ignore[union-attr] |
| 246 | + pager.wait() |
| 247 | + else: |
| 248 | + shutil.copyfileobj(diff_proc.stdout, sys.stdout.buffer) # type: ignore[arg-type] |
| 249 | + |
| 250 | + diff_proc.wait() # exit code 1 is normal when differences exist |
| 251 | + |
| 252 | + |
| 253 | +# --------------------------------------------------------------------------- |
| 254 | +# Delete |
| 255 | +# --------------------------------------------------------------------------- |
| 256 | + |
| 257 | +def cmd_delete(name: str) -> None: |
| 258 | + path = BASELINES_DIR / name |
| 259 | + if not path.is_dir(): |
| 260 | + sys.exit(f"ERROR: Baseline '{name}' not found in {BASELINES_DIR}/") |
| 261 | + shutil.rmtree(path) |
| 262 | + print(f"Deleted baseline '{name}'.") |
| 263 | + |
| 264 | + |
| 265 | +# --------------------------------------------------------------------------- |
| 266 | +# CLI |
| 267 | +# --------------------------------------------------------------------------- |
| 268 | + |
| 269 | +def main() -> None: |
| 270 | + check_repo_root() |
| 271 | + |
| 272 | + parser = argparse.ArgumentParser( |
| 273 | + prog="scripts/transpile_diff.py", |
| 274 | + description="Baseline and diff tool for herkos transpiler output.", |
| 275 | + formatter_class=argparse.RawDescriptionHelpFormatter, |
| 276 | + epilog=""" |
| 277 | +examples: |
| 278 | + scripts/transpile_diff.py snapshot before-optimizer |
| 279 | + scripts/transpile_diff.py snapshot |
| 280 | + scripts/transpile_diff.py list |
| 281 | + scripts/transpile_diff.py compare before-optimizer |
| 282 | + scripts/transpile_diff.py compare before-optimizer after-optimizer |
| 283 | + scripts/transpile_diff.py --release compare main__abc |
| 284 | + scripts/transpile_diff.py delete before-optimizer |
| 285 | +""", |
| 286 | + ) |
| 287 | + parser.add_argument( |
| 288 | + "--release", action="store_true", |
| 289 | + help="Use target/release/ instead of target/debug/ when discovering OUT_DIR.", |
| 290 | + ) |
| 291 | + |
| 292 | + sub = parser.add_subparsers(dest="subcommand", metavar="subcommand") |
| 293 | + sub.required = True |
| 294 | + |
| 295 | + p_snap = sub.add_parser("snapshot", help="Save current transpiler output as a named baseline.") |
| 296 | + p_snap.add_argument("name", nargs="?", default=None, |
| 297 | + help="Baseline name (auto-generated if omitted).") |
| 298 | + |
| 299 | + sub.add_parser("list", help="Show all saved baselines with metadata.") |
| 300 | + |
| 301 | + p_cmp = sub.add_parser("compare", help="Diff baseline A vs B (default B: latest build).") |
| 302 | + p_cmp.add_argument("a", metavar="A", help="First baseline name.") |
| 303 | + p_cmp.add_argument("b", metavar="B", nargs="?", default="latest", |
| 304 | + help="Second baseline name, or 'latest' (default).") |
| 305 | + |
| 306 | + p_del = sub.add_parser("delete", help="Remove a baseline.") |
| 307 | + p_del.add_argument("name", help="Baseline name to delete.") |
| 308 | + |
| 309 | + args = parser.parse_args() |
| 310 | + profile = "release" if args.release else "debug" |
| 311 | + |
| 312 | + if args.subcommand == "snapshot": |
| 313 | + cmd_snapshot(args.name, profile) |
| 314 | + elif args.subcommand == "list": |
| 315 | + cmd_list() |
| 316 | + elif args.subcommand == "compare": |
| 317 | + cmd_compare(args.a, args.b, profile) |
| 318 | + elif args.subcommand == "delete": |
| 319 | + cmd_delete(args.name) |
| 320 | + |
| 321 | + |
| 322 | +if __name__ == "__main__": |
| 323 | + main() |
0 commit comments