#!/usr/bin/env python3 # build-state.py — el GRAFO DE ESTADO del corpus, en un formato firme y versionado. # # POR QUÉ EXISTE. El estado real del build (qué está sellado-al-día, qué es deuda de rebuild, qué # depende de qué) vivía disperso: en la cabeza del que construía, en docs que envejecen (el de # matar-gcc decía 47 y eran 16), en el store (que guarda TODOS los sellados históricos, no sólo el # vigente). Cada medición a mano mentía distinto. Este script deriva el estado de la ÚNICA fuente de # verdad —las recetas + `hammer hash` + el store— y lo escribe a `docs/state/build-state.json`. # Versionado: el `git diff` de ese fichero ES el avance entre dos corridas (qué se saldó, qué se # rompió). La vista humana la arma `build-state-view.py` a partir del mismo JSON. # # NODO = receta: {name, class, link, compiler, deps[], hash, state}. # state ∈ { sealed | debt | never | unhashable } # sealed — el artefacto del hash VIGENTE (el de la receta de hoy) está en el store. Al día. # debt — hay sellados históricos pero NINGUNO es el vigente ⇒ la receta cambió, falta rebuild. # never — no hay ningún sellado de esta receta en el store. # unhashable — `hammer hash` falló (receta ilegible / dep rota). Un HUECO real del grafo. # ARISTA = dep de build (name → dep). El grafo debe CERRAR: toda dep apunta a una receta del corpus. # # Uso: scripts/build-state.py # regenera docs/state/build-state.json + resumen # scripts/build-state.py --check # exit 1 si hay unhashable o deps huérfanas (gate CI) # Env: HAMMER (def target/release/hammer), STORE (def ./store). import json, os, subprocess, sys, glob, tomllib from pathlib import Path ROOT = Path(__file__).resolve().parent.parent os.chdir(ROOT) HAMMER = os.environ.get("HAMMER", str(ROOT / "target/release/hammer")) STORE = os.environ.get("STORE", str(ROOT / "store")) OUT = ROOT / "docs/state/build-state.json" CHECK = "--check" in sys.argv # Clase de la receta — sólo para colorear/agrupar la vista; heurística por contenido, no dato duro. GUI_RE = ("cairo", "pango", "gtk", "harfbuzz", "glib", "gdk-pixbuf", "libadwaita", "gtksourceview", "fribidi", "graphene", "fontconfig", "adwaita", "fcft", "mirada", "appstream", "gnome", "wayland", "libdrm", "mesa", "seatd", "libepoxy", "libxkbcommon", "libinput", "pixman", "sourceview", "tllist", "foot") def classify(name, d): ph = d.get("build", {}).get("phases", {}) blob = " ".join(str(ph.get(k, "")) for k in ("configure", "compile", "install")) deps = d.get("deps", {}).get("build", []) if name.startswith("linux"): return "kernel" # Go: declara el toolchain `go` como dep de build (la vía Go auto lo exige). 362 recetas. if "go" in deps and name != "go": return "go" # Rust: fase cargo, o dep de un toolchain rust, o el linker zig-cc wrapper típico de cargo. if "cargo" in blob or "cargo" in deps or "rust" in deps: return "rust" if any(g in name for g in GUI_RE): return "gui" return "c" def load_recipes(): recs = {} for f in sorted(glob.glob("recipes/*.toml")): n = os.path.basename(f)[:-5] try: d = tomllib.load(open(f, "rb")) except Exception as e: recs[n] = dict(path=f, parse_error=str(e)) continue b = d.get("build", {}) recs[n] = dict( path=f, version=d.get("version"), link=b.get("link"), compiler=b.get("compiler"), deps=d.get("deps", {}).get("build", []), cls=classify(n, d), ) return recs def vigente_hash(path): try: out = subprocess.run([HAMMER, "--store", STORE, "hash", path], capture_output=True, text=True, timeout=60) h = out.stdout.strip() return h if h.startswith("b3:") else None except Exception: return None def state_of(name, h): if h is None: return "unhashable" if os.path.isdir(os.path.join(STORE, f"{h[3:]}-{name}")): return "sealed" # ¿hay algún sellado histórico? distingue "cambió" de "nunca construida". if glob.glob(os.path.join(STORE, f"*-{name}")): return "debt" return "never" def toposort(nodes): # Kahn sobre las aristas name→dep (sólo deps dentro del corpus). Devuelve orden o None si hay ciclo. indeg = {n: 0 for n in nodes} adj = {n: [] for n in nodes} for n, r in nodes.items(): for dep in r.get("deps", []): if dep in nodes: adj[dep].append(n); indeg[n] += 1 q = sorted([n for n, d in indeg.items() if d == 0]) order = [] while q: n = q.pop(0); order.append(n) for m in sorted(adj[n]): indeg[m] -= 1 if indeg[m] == 0: q.append(m) return order if len(order) == len(nodes) else None def main(): recs = load_recipes() orphans = set() for n, r in recs.items(): for dep in r.get("deps", []): if dep not in recs: orphans.add(dep) print(f"== {len(recs)} recetas; computando hash vigente (~{len(recs)*18//1000}s)…", file=sys.stderr) for n, r in recs.items(): if "parse_error" in r: r["hash"] = None; r["state"] = "unhashable"; continue h = vigente_hash(r["path"]) r["hash"] = h r["state"] = state_of(n, h) order = toposort(recs) from collections import Counter by_state = Counter(r["state"] for r in recs.values()) by_cls = Counter(r["cls"] for r in recs.values() if "cls" in r) # deuda por clase: dónde están los huecos. debt_by_cls = Counter(r["cls"] for r in recs.values() if r.get("state") in ("debt", "never") and "cls" in r) doc = dict( schema="hammer-build-state/1", totals=dict(recipes=len(recs), **by_state), by_class={k: by_cls[k] for k in sorted(by_cls)}, debt_by_class={k: debt_by_cls[k] for k in sorted(debt_by_cls)}, orphan_deps=sorted(orphans), topo_ok=order is not None, nodes={n: {k: v for k, v in r.items() if k != "path"} for n, r in sorted(recs.items())}, ) OUT.parent.mkdir(parents=True, exist_ok=True) OUT.write_text(json.dumps(doc, indent=1, sort_keys=False) + "\n") # Resumen humano a stdout. print(f"\n== estado del corpus ({len(recs)} recetas) → {OUT.relative_to(ROOT)}") for s in ("sealed", "debt", "never", "unhashable"): if by_state.get(s): print(f" {s:11} {by_state[s]}") if debt_by_cls: print(" deuda por clase:", " ".join(f"{k}={v}" for k, v in sorted(debt_by_cls.items()))) print(f" grafo: {'CIERRA' if not orphans else f'{len(orphans)} deps huérfanas'} | " f"topo-sort: {'OK' if order else 'CICLO'}") if orphans: print(" huérfanas:", " ".join(sorted(orphans))) if CHECK and (orphans or by_state.get("unhashable") or not order): sys.exit(1) if __name__ == "__main__": main()