Skip to content

Commit 7e893d2

Browse files
author
metrics
committed
Merge branch 'main' of https://github.com/arnoox/herkos into pr-f4/licm
2 parents fdd3c7c + 32549f4 commit 7e893d2

3 files changed

Lines changed: 328 additions & 0 deletions

File tree

.gitignore

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,9 @@ docs/.ub_cache/
2929
*.profraw
3030
*.profdata
3131

32+
# Transpiler output baselines (created by scripts/transpile_diff.sh)
33+
.herkos-baselines/
34+
3235
# Temporary files
3336
*.tmp
3437
*.bak

scripts/transpile_diff.py

Lines changed: 323 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,323 @@
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()

scripts/transpile_diff.sh

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,2 @@
1+
#!/usr/bin/env bash
2+
exec python3 "$(dirname "$0")/transpile_diff.py" "$@"

0 commit comments

Comments
 (0)