Files
rune/scripts/check_landing.py
T

315 lines
14 KiB
Python

#!/usr/bin/env python3
"""check_landing.py — phase 0C task 0.15: the landing gate hook.
Every landing passes the unified runner (design D26's landing rule, a
cross-cutting gate like frozen-tests-first). This script is the mechanical
hook the main session wires into its landing discipline: it takes a commit
range and REFUSES (exit 1) unless, for every commit in the range:
1. EVIDENCE: a unified-runner manifest
verification/unified-check-<full-sha>.json exists for that commit's
sha AND its verdict is GREEN or AMBER (AMBER = green with the
documented baseline failure set). A landing without fresh runner
output for the landed sha is refused.
2. HYGIENE: no compiled artifact is introduced — every file ADDED by the
commit is checked by extension AND magic bytes (same detectors as
scripts/check_no_committed_binaries.py), and the current tree must be
binary-clean.
3. BUILD HEALTH (wave shared-build-integrity, 2026-10-16 owner
directive): the LANDED HEAD must build CLEAN across every Go module
— tui/ plus all src/go/*. The sweep is the same one the unified
runner runs (scripts/go_build_sweep.py), executed against the landed
sha's exported tree (git archive — the landed tree, immune to the
working tree's in-flight sibling state) before the landing is
pushed. A landing that leaves any module unbuildable is REFUSED:
the fix is always 'fix the build', never 'skip the gate' (the
no-dead-end law's build-health counterpart — a shared module nobody
builds is the hole that once shipped a sibling's syntax error into
the user's boot). A landing that REMOVES the tui Go module is
refused the same way: the gate does not verify a repo whose face
module evaporated.
4. SWEEP RACE (three 102MB store/scaffold/current.json accidents):
the landed trees AND the staged tree (the index — what the next
commit WOULD land) must never contain the banned runtime-state
path store/scaffold/current.json nor any blob over 20MB. The
scaffold's current.json is live store state the sweep mutates —
it has ballooned to 102MB three times; landing it (or any dump
over the ceiling) is REFUSED. githooks/pre-commit enforces the
staged-tree half at commit time (install once per clone:
git config core.hooksPath githooks).
Usage:
python scripts/check_landing.py # verifies HEAD
python scripts/check_landing.py HEAD~3..HEAD # a range
python scripts/check_landing.py --repo-root PATH # fixture repos
python scripts/check_landing.py --staged-only # the pre-commit gate:
# rule 4 on the index alone
Landing discipline (how future landings are forced through it):
1. land your work but BEFORE calling it landed, run
python scripts/unified_check.py
— it writes verification/unified-check-<sha>.json;
RED means the landing is blocked (fix, re-run);
2. commit the manifest alongside the work (it is the runner output the
gate reads; the manifest for the final tree sha is produced after the
last commit — run the runner once more on the landed tree and include
it in the follow-up/landing commit);
3. python scripts/check_landing.py <range> must exit 0 — manifests,
hygiene AND the landed-sha build sweep all green.
"""
from __future__ import annotations
import argparse
import subprocess
import sys
import tarfile
import tempfile
from pathlib import Path
sys.path.insert(0, str(Path(__file__).resolve().parent))
import go_build_sweep # noqa: E402
BINARY_EXTENSIONS = {
".so", ".a", ".o", ".dll", ".dylib", ".exe", ".pyd", ".node",
".wasm", ".class", ".obj", ".lib",
}
MAGICS = [b"\x7fELF", b"MZ", b"\xfe\xed\xfa\xce", b"\xfe\xed\xfa\xcf",
b"\xca\xfe\xba\xbe", b"!<arch>\n"]
# rule 4: live store state the sweep mutates — it has landed at 102MB
# three times (commits dce911e7, fbb2e5a2, d1d55913's aftermath); it is
# runtime state, never a landed artifact, whatever its size
SWEEP_RACE_BANNED_PATHS = {"store/scaffold/current.json"}
MAX_LANDING_FILE_BYTES = 20 * 1024 * 1024 # any blob over 20MB refuses
def git(root: Path, *args: str) -> str:
r = subprocess.run(["git", "-C", str(root), *args],
capture_output=True, text=True)
if r.returncode != 0:
raise RuntimeError(f"git {' '.join(args[:2])} failed: {r.stderr.strip()}")
return r.stdout
def added_files(root: Path, sha: str) -> list[str]:
out = git(root, "diff-tree", "--no-commit-id", "--name-only",
"--diff-filter=A", "-r", sha)
return [l for l in out.splitlines() if l.strip()]
def blob_is_binary(root: Path, sha: str, path: str) -> str | None:
ext = Path(path).suffix.lower()
if ext in BINARY_EXTENSIONS:
return f"extension {ext}"
r = subprocess.run(["git", "-C", str(root), "cat-file", "blob", f"{sha}:{path}"],
capture_output=True)
head = r.stdout[:16]
for magic in MAGICS:
if head.startswith(magic):
return f"magic {magic!r}"
return None
def check_commit(root: Path, sha: str, ref: str, failures: list[str]) -> None:
manifest = root / "verification" / f"unified-check-{sha}.json"
if not manifest.is_file():
failures.append(f"{ref} ({sha[:12]}): REFUSED — no unified-runner "
f"manifest {manifest.name}; run "
"python scripts/unified_check.py and commit its output")
return
import json
doc = json.loads(manifest.read_text())
verdict = doc.get("verdict")
if verdict not in ("GREEN", "AMBER"):
failures.append(f"{ref} ({sha[:12]}): REFUSED — manifest verdict "
f"{verdict!r} is not green (AMBER = green with the "
"documented baseline)")
for path in added_files(root, sha):
why = blob_is_binary(root, sha, path)
if why:
failures.append(f"{ref} ({sha[:12]}): committed compiled "
f"artifact {path} ({why})")
def _sweep_race_failure(ref: str, sha: str, path: str, size: int | None) -> str:
if path in SWEEP_RACE_BANNED_PATHS:
size_note = f" ({size} bytes)" if size is not None else ""
return (f"{ref} ({sha[:12]}): REFUSED — sweep-race runtime state "
f"{path} in the tree{size_note}; it is live store state "
"the sweep mutates (three 102MB accidents) — unstage/untrack it")
return (f"{ref} ({sha[:12]}): REFUSED — {path} is {size} bytes, over "
f"the {MAX_LANDING_FILE_BYTES // (1024 * 1024)}MB sweep-race "
"ceiling")
def sweep_race_tree_check(root: Path, sha: str, ref: str,
failures: list[str]) -> None:
"""Rule 4, landed-tree half: every commit in the range is inspected
(git ls-tree -r -l) for the banned path and blobs over the ceiling."""
out = git(root, "ls-tree", "-r", "-l", sha)
for line in out.splitlines():
meta, sep, path = line.partition("\t")
if not sep:
continue
parts = meta.split()
if len(parts) < 4 or not parts[3].isdigit():
continue
size = int(parts[3])
if path in SWEEP_RACE_BANNED_PATHS or size > MAX_LANDING_FILE_BYTES:
failures.append(_sweep_race_failure(ref, sha, path, size))
def staged_tree_check(root: Path, failures: list[str]) -> None:
"""Rule 4, staged-tree half: the index — what the NEXT commit would
land — must not carry the banned path nor any blob over the ceiling.
githooks/pre-commit runs this same check (--staged-only)."""
entries: list[tuple[str, str]] = []
for line in git(root, "ls-files", "-s").splitlines():
meta, sep, path = line.partition("\t")
if not sep:
continue
parts = meta.split()
if len(parts) >= 2:
entries.append((parts[1], path))
if not entries:
return
batch = subprocess.run(
["git", "-C", str(root), "cat-file",
"--batch-check=%(objectname) %(objectsize)"],
input="\n".join(sha for sha, _ in entries) + "\n",
capture_output=True, text=True)
sizes: dict[str, int] = {}
for line in batch.stdout.splitlines():
bits = line.split()
if len(bits) == 2 and bits[1].isdigit():
sizes[bits[0]] = int(bits[1])
for sha, path in entries:
size = sizes.get(sha)
if path in SWEEP_RACE_BANNED_PATHS:
failures.append(_sweep_race_failure("staged tree", sha, path, size))
elif size is not None and size > MAX_LANDING_FILE_BYTES:
failures.append(_sweep_race_failure("staged tree", sha, path, size))
def build_sweep_check(root: Path, sha: str, ref: str, failures: list[str]) -> None:
"""Rule 3: the landed tree must build CLEAN across every Go module.
The landed sha is exported with git archive (its committed tree —
never the working tree, whose in-flight sibling state is exactly
what this rule exists to catch) into a temp dir, then the SAME
sweep the unified runner runs (scripts/go_build_sweep.py) builds
tui/ and every src/go module there. Any unbuildable module refuses
the landing; a landing that deletes the tui Go module while the
gate repo still has one is refused before the sweep even runs."""
with tempfile.TemporaryDirectory(prefix="rune-landing-build-") as td:
export = Path(td) / "tree"
export.mkdir()
proc = subprocess.Popen(
["git", "-C", str(root), "archive", "--format=tar", sha],
stdout=subprocess.PIPE)
try:
with tarfile.open(fileobj=proc.stdout, mode="r|") as tf:
tf.extractall(export, filter="data")
except tarfile.ReadError:
pass # a legitimately empty landed tree streams no header
if proc.wait() != 0:
failures.append(f"{ref} ({sha[:12]}): REFUSED — git archive "
"failed; cannot verify landed-tree build health")
return
rp = subprocess.run(
["git", "-C", str(root), "rev-parse", "--verify", "-q",
f"{sha}^"], capture_output=True, text=True)
parent = rp.stdout.strip() if rp.returncode == 0 else ""
parent_had_tui = bool(parent) and subprocess.run(
["git", "-C", str(root), "cat-file", "-e", f"{parent}:tui/go.mod"],
capture_output=True).returncode == 0
if parent_had_tui and not (export / "tui" / "go.mod").is_file():
failures.append(f"{ref} ({sha[:12]}): REFUSED — landing removes "
"the tui Go module (the face the build law and "
"the boot smoke test stand on)")
return
res = go_build_sweep.sweep(export)
if res["failed"]:
named = ", ".join(res["failed"])
failures.append(f"{ref} ({sha[:12]}): REFUSED — landed tree does "
f"not BUILD: module(s) {named} (shared-module "
"law, wave shared-build-integrity: fix the "
"build, never skip the gate)")
def main(argv=None) -> int:
ap = argparse.ArgumentParser(description="landing gate (task 0.15)")
ap.add_argument("range", nargs="?", default="HEAD",
help="commit range (default HEAD — the last landed commit)")
ap.add_argument("--repo-root", default=str(Path(__file__).resolve().parent.parent))
ap.add_argument("--staged-only", action="store_true",
help="the pre-commit gate: rule 4 on the index alone "
"(no manifests, no build sweep — fast enough for "
"githooks/pre-commit)")
args = ap.parse_args(argv)
root = Path(args.repo_root).resolve()
if args.staged_only:
failures: list[str] = []
staged_tree_check(root, failures)
if failures:
print("RED: pre-commit sweep-race refusals:")
for f in failures:
print(f" - {f}")
return 1
print("GREEN: staged tree — no sweep-race runtime state, "
f"no blob over {MAX_LANDING_FILE_BYTES // (1024 * 1024)}MB")
return 0
try:
if ".." in args.range:
shas = git(root, "rev-list", "--reverse", args.range).split()
else:
# a single ref (default HEAD): verify exactly that commit
shas = [git(root, "rev-parse", f"{args.range}^{{commit}}").strip()]
except RuntimeError as exc:
print(f"RED: {exc}")
return 1
if not shas:
print("RED: empty commit range")
return 1
failures: list[str] = []
for i, sha in enumerate(shas):
ref = args.range if len(shas) == 1 else f"commit {i + 1}/{len(shas)}"
check_commit(root, sha, ref, failures)
# rule 4, landed-tree half: every landed tree is inspected for
# the banned runtime-state path and blobs over the ceiling
sweep_race_tree_check(root, sha, ref, failures)
# rule 4, staged-tree half: the index must be clean too (the
# pre-commit moment — what the NEXT commit would land)
staged_tree_check(root, failures)
# rule 3: the landed HEAD (the range's tip) must build clean across
# every Go module — verified on the exported landed tree before the
# landing is pushed
build_sweep_check(root, shas[-1], args.range, failures)
# current tree must also be binary-clean (working tree, uncommitted)
detector = Path(__file__).resolve().parent / "check_no_committed_binaries.py"
r = subprocess.run([sys.executable, str(detector), "--repo-root", str(root)],
capture_output=True, text=True)
if r.returncode != 0:
lines = (r.stdout + r.stderr).strip().splitlines()
failures.append(f"current tree: {lines[0] if lines else 'binary check failed'}")
if failures:
print("RED: landing gate refusals:")
for f in failures:
print(f" - {f}")
return 1
print(f"GREEN: landing gate — {len(shas)} commit(s) carry fresh green "
"unified-runner manifests, no committed binaries, no sweep-race "
"runtime state or oversized blobs, and the landed tree builds "
"clean across every Go module")
return 0
if __name__ == "__main__":
sys.exit(main())