#!/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, perfiles[]}. # state ∈ { sealed | debt | never | unhashable | wanted } # 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. # wanted — NO HAY RECETA: una imagen lo pide (docs/state/targets.toml) y no existe todavía. # ARISTA = dep de build (name → dep). El grafo debe CERRAR: toda dep apunta a una receta del corpus. # # LA FRONTERA (2026-07-22, plan docs/plan-catalogo-objetivo.md). Hasta acá el grafo modelaba sólo lo # que EXISTE, y por eso cerraba siempre: "¿cuánto falta?" no tenía respuesta porque el destino no # estaba escrito en ningún lado. `targets.toml` lo escribe, y este script lo cruza con el corpus: # · un objetivo sin receta se materializa como nodo `wanted` — está DECLARADO, así que no es una # dep huérfana y el gate `--check` conserva su significado (arista colgando que nadie quiere). # `wanted` ≠ `unhashable`: el primero es "no hay receta todavía", el segundo "hay receta rota". # · cada nodo gana `perfiles[]`: qué imágenes lo alcanzan siguiendo las aristas desde las raíces. # Responde "si rompo zlib, qué imágenes se caen" y —más incómodo— "qué recetas no las necesita # ninguna imagen". # · `by_profile` es el "cuánto falta", POR IMAGEN. Es una COTA INFERIOR mientras haya nodos # `wanted`: no se conocen sus deps hasta construirlos (o hasta sembrarlas, P3 del plan), así que # la clausura de un perfil crece a medida que la frontera se drena. Un número que converge es # mejor que ningún número, pero no es el total final. # # 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 sys.path.insert(0, str(Path(__file__).resolve().parent)) import targets as targets_mod import yupana # la YUPANA: el motor de dependencias INVERSAS que cruza TODAS las colas (ver su cabecera) 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")) CHECK = "--check" in sys.argv # `--kde`: además del corpus canónico, cubre la cola `recipes/incoming-kde/` (206 recetas del # escritorio KDE Plasma). Las KDE SOMBREAN a las canónicas homónimas (`fontconfig` vs KDE, `dbus`…) # igual que hace el sandbox al resolver deps (sibling-first), así el grafo modela lo que un build KDE # ve. Sale a un JSON aparte para no mezclar los totales del corpus enviable con el frente en curso. KDE = "--kde" in sys.argv # `--gnome`: idéntico a `--kde` pero para la cola `recipes/incoming-gnome/` (frente GNOME Wayland). # Enciende la CAPA GRAFO del yupana-gnome: sin esto el perfil escritorio-gnome se saltaba (cola no # cargada) y `yupana keystones/drenar --perfil escritorio-gnome` no reckonaban. Sale a su JSON aparte. GNOME = "--gnome" in sys.argv # `--cosmic`: lo mismo para `recipes/incoming-cosmic/` (COSMIC de System76, 4º escritorio). Sin esto # la campaña entera es INVISIBLE para el khipu: el latido regenera build-state{,-kde,-gnome}.json cada # media hora y COSMIC no aparecería en ninguno, así que el avance no quedaría en el registro versionado # que es `git log docs/state/`. Y —lo que importa más— `yupana radio` sobre una receta compartida # (libinput, libxkbcommon, libudev-zero…) no reportaría la imagen COSMIC entre las afectadas, o sea que # el próximo que la toque MEDIRÍA DE MENOS. COSMIC = "--cosmic" in sys.argv # `--wlr`: la cola `recipes/incoming-wlr/` — wlroots y los WMs Wayland LIGEROS (sway y sus # accesorios, fuzzel, yambar, wl-clipboard). Es un frente de otra naturaleza que los tres de arriba: # no es un escritorio completo con su torre de C, sino una decena de binarios pequeños sobre wlroots. # Va con su flag propio por la misma razón que COSMIC: sin esto el frente es INVISIBLE para el # khipu, y `yupana radio` sobre una receta compartida (wayland, libinput, pixman, mesa, # libxkbcommon…) no reportaría la imagen sway entre las afectadas — o sea que el próximo que toque # una de esas MEDIRÍA DE MENOS y creería que no rompe nada. WLR = "--wlr" in sys.argv EXTRA_QUEUES = (["recipes/incoming-kde"] if KDE else []) + (["recipes/incoming-gnome"] if GNOME else []) \ + (["recipes/incoming-cosmic"] if COSMIC else []) + (["recipes/incoming-wlr"] if WLR else []) _var = "-kde" if KDE else "-gnome" if GNOME else "-cosmic" if COSMIC else "-wlr" if WLR else "" OUT = ROOT / f"docs/state/build-state{_var}.json" # 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, dep rust, o —la señal fuerte— copia el binario de `target/release/` en sus # fases (todo binario cargo sale de ahí). Medido: 225 recetas lo tienen, 0 con dep `go` ⇒ sin # falsos positivos con Go. LÍMITE conocido: un crate BuildSys::Cargo PURO (Cargo.toml en el árbol, # sin fase install propia — p.ej. zellij) no deja rastro en el TOML y cae a "c"; sólo se distingue # bajando la fuente, que este generador no hace a propósito. if "cargo" in blob or "cargo" in deps or "rust" in deps or "target/release" in blob: return "rust" if any(g in name for g in GUI_RE): return "gui" return "c" def load_recipes(): recs = {} # Orden importa: primero el corpus (queue=corpus), luego las colas extra que SOMBREAN por nombre # (queue=el basename de la cola), replicando el sibling-first del sandbox. for root, queue in [("recipes", "corpus")] + [(q, os.path.basename(q)) for q in EXTRA_QUEUES]: for f in sorted(glob.glob(f"{root}/*.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), queue=queue) continue b = d.get("build", {}) recs[n] = dict( path=f, # el artefacto se sella por el `name` INTERNO, que puede diferir del basename del # fichero (qtbase.toml → name="qt6-qtbase"). El store lookup usa iname; las aristas de # deps siguen usando el basename (así resuelve `resolve_dep_path`). iname=d.get("name") or n, version=d.get("version"), link=b.get("link"), compiler=b.get("compiler"), deps=d.get("deps", {}).get("build", []), cls=classify(n, d), queue=queue, ) 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 # ── El store dejó de vivir en el laptop (2026-08-09) ─────────────────────────────────────────── # El store de trabajo se mudó al VOLUMEN de la granja y el disco local se quedó sólo con lo que la # granja NO puede rehacer (las recetas de fuente privada) más lo que aún no está respaldado. Si el # grafo siguiera leyendo únicamente `./store`, el latido regeneraría un estado que dice `never` sobre # 979 artefactos que existen y están sellados — y lo COMMITEARÍA, que es peor que no regenerarlo: # un grafo viejo miente con la misma cara que uno fresco, pero uno recién escrito miente con más # autoridad. El error no sería del disco, sería de haber confundido «dónde están los bytes» con # «qué está construido». # # Así que la presencia se resuelve contra la UNIÓN de dos fuentes: el store local y el MANIFIESTO de # sellados que la granja deja en `work/farm-sellados.txt`. La unión es monótona a propósito — sólo # puede evitar un `never`/`debt` falso, nunca inventar uno. El riesgo simétrico (dar por sellado algo # que ya no está en ningún lado) queda VISIBLE en los totales como `sealed_remoto`, en vez de # disolverse en el número grande. # Dos registros, y hacen falta LOS DOS: el de la granja se escribe al cosechar y va por delante, # pero el 2026-08-09 le faltaban 5 sellados que el respaldo sí tenía ⇒ solo con él, 5 recetas caían # de `sealed` a `debt` sin que nada se hubiera roto. El del respaldo se refresca con # `scripts/respaldo-storagebox.sh listar` y es el más completo, pero llega por red y envejece. MANIFIESTOS = ("farm-sellados.txt", "respaldo-sellados.txt") def _manifiesto_sellados(): out = set() for nombre in MANIFIESTOS: try: txt = (ROOT / "work" / nombre).read_text() except OSError: continue out |= {l.strip() for l in txt.splitlines() if l.strip() and not l.startswith("#")} return out SELLADOS_REMOTOS = _manifiesto_sellados() # `*-{iname}` del manifiesto, para el fallback de «hay algún sellado histórico» sin tocar el disco. _REMOTOS_POR_INAME = {} for _d in SELLADOS_REMOTOS: _REMOTOS_POR_INAME.setdefault(_d.split("-", 1)[1] if "-" in _d else _d, []).append(_d) REMOTO_ONLY = set() def state_of(iname, h): if h is None: return "unhashable" dirname = f"{h[3:]}-{iname}" if os.path.isdir(os.path.join(STORE, dirname)): return "sealed" if dirname in SELLADOS_REMOTOS: REMOTO_ONLY.add(dirname) return "sealed" # ¿hay algún sellado histórico? distingue "cambió" de "nunca construida". if glob.glob(os.path.join(STORE, f"*-{iname}")) or _REMOTOS_POR_INAME.get(iname): 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 cargar_perfiles(colas_activas): """Perfiles del manifiesto cuyas raíces viven en una cola CARGADA (así un perfil de `incoming-kde` no reporta raíces fantasma en la corrida del corpus). Degrada sin romper: si el manifiesto falta o está mal, el grafo sale como siempre — este script lo corre el cron cada 30m y no puede caerse por un TOML.""" try: todos = targets_mod.load() except FileNotFoundError: print(" (sin docs/state/targets.toml — grafo sin perfiles)", file=sys.stderr) return {} except Exception as e: print(f" ⚠ targets.toml inválido ({e}) — grafo sin perfiles", file=sys.stderr) return {} return {n: p for n, p in todos.items() if p["cola"] in colas_activas} def clausura(raices, recs): """Nodos alcanzables desde `raices` siguiendo las aristas name→dep. Un nodo `wanted` CORTA la exploración (no se conocen sus deps hasta construirlo): de ahí que la clausura sea cota inferior.""" vistos, pila = set(), list(raices) while pila: n = pila.pop() if n in vistos or n not in recs: continue vistos.add(n) pila.extend(recs[n].get("deps") or []) return vistos def main(): from collections import Counter recs = load_recipes() # OBJETIVO → FRONTERA. Toda raíz de un perfil sin receta entra como nodo `wanted`. Se hace ANTES # de buscar huérfanas a propósito: un objetivo declarado no es una arista colgando. colas = {"corpus"} | {os.path.basename(q) for q in EXTRA_QUEUES} perfiles = cargar_perfiles(colas) for pn, p in sorted(perfiles.items()): for raiz in p["raices"]: if raiz not in recs: recs[raiz] = dict(path=None, wanted=True, iname=raiz, version=None, link=None, compiler=None, deps=[], cls="wanted", queue=p["cola"]) orphans = set() for n, r in recs.items(): for dep in r.get("deps", []): if dep not in recs: orphans.add(dep) n_recetas = sum(1 for r in recs.values() if not r.get("wanted")) print(f"== {n_recetas} recetas; computando hash vigente (~{n_recetas*18//1000}s)…", file=sys.stderr) for n, r in recs.items(): # `wanted` no tiene fichero que hashear: es el objetivo, no una receta. if r.get("wanted"): r["hash"] = None; r["state"] = "wanted"; continue if "parse_error" in r: r["hash"] = None; r["state"] = "unhashable"; continue h = vigente_hash(r["path"]) r["hash"] = h r["state"] = state_of(r.get("iname", 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. # `wanted` cuenta como bloqueante: una imagen que depende de algo sin receta está trabada igual. DEBT_STATES = ("debt", "never", "unhashable", "wanted") 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", [])) # MEMBRESÍA DE PERFIL y DEPENDIENTES INVERSOS: los dos se calculan sobre el GRAFO ENTERO (todas # las colas del disco), NO sobre los nodos que esta vista cargó. Es la corrección del bug de # libdrm: en la corrida del corpus, `perfiles` llegaba a decir `['escritorio-mirada']` sobre un # paquete que sostiene KDE entero, porque no veía las recetas de incoming-kde. yupana las ve # siempre y resuelve sibling-first ⇒ el nodo se identifica por (cola, nombre). Ver yupana.py. todas = yupana.cargar_colas() memb = yupana.membresia(todas, cargar_perfiles({q for q in todas})) # TODOS los perfiles, no sólo los de esta vista rev = yupana.dependientes(todas) for n, r in recs.items(): clave = (r.get("queue", "corpus"), n) r["perfiles"] = sorted(memb.get(clave, ())) # Dependientes inversos DEL GRAFO ENTERO: cuántas recetas (de cualquier cola) me consumen. Es # el radio real de un re-hash. `unblocks` (abajo) es local a la deuda de esta vista; este no. r["dependientes_total"] = len(rev.get(clave, ())) sin_perfil = sum(1 for r in recs.values() if not r["perfiles"]) # `by_profile` (el "cuánto falta" por imagen) SÍ es local: sólo cuenta estados de nodos que esta # vista cargó ⇒ se limita a los perfiles cuya cola está cargada (los demás darían conteos falsos). by_profile = {} for pn, p in sorted(perfiles.items()): miembros = [n for n, r in recs.items() if pn in r["perfiles"]] by_profile[pn] = dict(raices=len(p["raices"]), clausura=len(miembros), **Counter(recs[n]["state"] for n in miembros)) 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) # Reparto por cola (corpus vs incoming-kde…): dónde está cada cosa. by_queue = {} for r in recs.values(): q = r.get("queue", "corpus") by_queue.setdefault(q, Counter())[r["state"]] += 1 doc = dict( schema="hammer-build-state/1", # `recipes` sigue contando SÓLO las que tienen fichero (los consumidores lo leen como el # tamaño del corpus); `nodes` incluye la frontera `wanted`. # ⚠ `sealed_remoto` NO va acá, y la razón se descubrió al montar un segundo hub (2026-08-09): # es un número que depende de QUÉ MÁQUINA lo calcula — el laptop tiene 219 artefactos en # disco y gioser 19, así que el mismo corpus da 700 y 751. Metido en un fichero que se # commitea, las dos máquinas se lo pisarían mutuamente en cada regeneración, para siempre. # El estado del corpus es compartido; cuánto de él tiene ESTA máquina en el disco, no. # Se sigue diciendo —abajo, en el resumen— donde sí es útil y no genera churn. totals=dict(recipes=n_recetas, nodes=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)}, by_queue={q: dict(c) for q, c in sorted(by_queue.items())}, by_profile=by_profile, sin_perfil=sin_perfil, orphan_deps=sorted(orphans), topo_ok=order is not None, nodes={n: {k: v for k, v in r.items() if k not in ("path", "wanted")} 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 ({n_recetas} recetas) → {OUT.relative_to(ROOT)}") for s in ("sealed", "debt", "never", "unhashable", "wanted"): if by_state.get(s): print(f" {s:11} {by_state[s]}") if REMOTO_ONLY: # Propio de ESTA máquina, por eso se imprime y no se escribe: de los `sealed`, cuántos no # están en su disco y se dan por buenos por los manifiestos. Si sube sin que nadie haya # sembrado, es la señal de que un manifiesto afirma algo que ya no existe en ningún lado. print(f" de los sellados, {len(REMOTO_ONLY)} NO están en el disco de esta máquina " f"(los avalan los manifiestos)") if debt_by_cls: print(" deuda por clase:", " ".join(f"{k}={v}" for k, v in sorted(debt_by_cls.items()))) if len(by_queue) > 1: for q, c in sorted(by_queue.items()): print(f" cola {q:14} sellado={c.get('sealed',0):3} deuda={c.get('debt',0):3} " f"nunca={c.get('never',0):2}") if by_profile: print("\n== objetivo por imagen (docs/state/targets.toml) — clausura = COTA INFERIOR") for pn, c in by_profile.items(): falta = c.get("debt", 0) + c.get("never", 0) + c.get("unhashable", 0) + c.get("wanted", 0) print(f" {pn:20} {c.get('sealed',0):3}/{c['clausura']:3} listo falta {falta:3}" f" (raíces {c['raices']}" + (f", sin receta {c['wanted']}" if c.get("wanted") else "") + ")") print(f" {sin_perfil} recetas no las alcanza ninguna imagen (catálogo, no distro)") print(f"\n 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))) # `wanted` NO es fallo: es trabajo declarado y pendiente. El gate sigue midiendo lo mismo que # antes — aristas colgando, recetas rotas, ciclos. if CHECK and (orphans or by_state.get("unhashable") or not order): sys.exit(1) if __name__ == "__main__": main()