Files
takana/scripts/build-state.py
T
sergioandClaude Opus 4.8 a30e0d3146 estado: orden de ataque por impacto de desbloqueo (lectura accionable del DAG)
El grafo ahora computa, sobre las dependencias, dos campos por receta:
  blocked_by — deps que están en deuda (lo que impide construirla ya)
  unblocks   — cuántas recetas EN DEUDA la declaran como dep (su impacto de desbloqueo)

Una receta en deuda sin blocked_by es construible YA; ordenadas por unblocks desc, dan el orden que
libera el grafo más rápido. La vista muestra la pastilla ↑N en las filas listas y ordena por ahí.

Top de impacto (todo el stack GUI concentrado, más zstd/perl de C-base):
  glib ↑14  wayland ↑13  pixman ↑12  libdrm ↑11  fontconfig ↑11  zstd ↑10  libxkbcommon ↑9  perl ↑8

Es la guía para cuando se levante la granja: construir esas primero desbloquea el grueso.

De paso, diagnóstico de las 8 'never' (ninguna es cruft ni bug): dwarves BLOQUEADA-documentada
(necesita libdw, elfutils da sólo libelf a propósito — sub-proyecto elfutils-libdw); llimphi-counter
es un EJEMPLO/plantilla intencional; las otras 6 son imports Go/Rust que van al worker. Y confirmado
parseando: 0 recetas con FIXME real en el campo sha256 (el grep decía 43, todas comentarios) — otra
vez grep miente, el grafo (parsea) dice la verdad.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-17 11:48:28 -04:00

177 lines
7.7 KiB
Python
Executable File

#!/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)
# Lectura ACCIONABLE del grafo topológico: para cada receta, qué deps la BLOQUEAN (están en
# deuda) y a cuántas otras en deuda DESBLOQUEA (la tienen como dep). Una receta en deuda sin
# bloqueadores es construible YA; ordenar las listas por `unblocks` da el orden que libera más.
DEBT_STATES = ("debt", "never", "unhashable")
for n, r in recs.items():
r["blocked_by"] = [d for d in r.get("deps", [])
if d in recs and recs[d]["state"] in DEBT_STATES]
for n, r in recs.items():
# cuántas recetas EN DEUDA me declaran como dep directa (mi impacto de desbloqueo).
r["unblocks"] = sum(1 for m, rm in recs.items()
if rm["state"] in DEBT_STATES and n in rm.get("deps", []))
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()