sergio/takana
> Una distribución Linux construida con un laboratorio funcional y hermético en el > sótano, y una terminal mutable, clásica y anárquica en el piso de arriba — diseñada > desde el suelo para que una IA programadora entienda, modifique y co…
⎇ 534014c3ec69daea789a1d0523e1d00a69c6ed65
scripts/build-state.py
Ñawpa willay
Pi imata llamkarqan
ch'uya
paso 1: el toolchain sale de las imágenes — servidor 4,9 G → 2,6 G, cero rebuilds
1#!/usr/bin/env python3
2# build-state.py — el GRAFO DE ESTADO del corpus, en un formato firme y versionado.
3#
4# POR QUÉ EXISTE. El estado real del build (qué está sellado-al-día, qué es deuda de rebuild, qué
5# depende de qué) vivía disperso: en la cabeza del que construía, en docs que envejecen (el de
6# matar-gcc decía 47 y eran 16), en el store (que guarda TODOS los sellados históricos, no sólo el
7# vigente). Cada medición a mano mentía distinto. Este script deriva el estado de la ÚNICA fuente de
8# verdad —las recetas + `takana hash` + el store— y lo escribe a `docs/state/build-state.json`.
9# Versionado: el `git diff` de ese fichero ES el avance entre dos corridas (qué se saldó, qué se
10# rompió). La vista humana la arma `build-state-view.py` a partir del mismo JSON.
11#
12# NODO = receta: {name, class, link, compiler, deps[], hash, state, perfiles[]}.
13# state ∈ { sealed | debt | never | unhashable | wanted }
14# sealed — el artefacto del hash VIGENTE (el de la receta de hoy) está en el store. Al día.
15# debt — hay sellados históricos pero NINGUNO es el vigente ⇒ la receta cambió, falta rebuild.
16# never — no hay ningún sellado de esta receta en el store.
17# unhashable — `takana hash` falló (receta ilegible / dep rota). Un HUECO real del grafo.
18# wanted — NO HAY RECETA: una imagen lo pide (docs/state/targets.toml) y no existe todavía.
19# ARISTA = dep de build (name → dep). El grafo debe CERRAR: toda dep apunta a una receta del corpus.
20#
21# LA FRONTERA (2026-07-22, plan docs/plan-catalogo-objetivo.md). Hasta acá el grafo modelaba sólo lo
22# que EXISTE, y por eso cerraba siempre: "¿cuánto falta?" no tenía respuesta porque el destino no
23# estaba escrito en ningún lado. `targets.toml` lo escribe, y este script lo cruza con el corpus:
24# · un objetivo sin receta se materializa como nodo `wanted` — está DECLARADO, así que no es una
25# dep huérfana y el gate `--check` conserva su significado (arista colgando que nadie quiere).
26# `wanted` ≠ `unhashable`: el primero es "no hay receta todavía", el segundo "hay receta rota".
27# · cada nodo gana `perfiles[]`: qué imágenes lo alcanzan siguiendo las aristas desde las raíces.
28# Responde "si rompo zlib, qué imágenes se caen" y —más incómodo— "qué recetas no las necesita
29# ninguna imagen".
30# · `by_profile` es el "cuánto falta", POR IMAGEN. Es una COTA INFERIOR mientras haya nodos
31# `wanted`: no se conocen sus deps hasta construirlos (o hasta sembrarlas, P3 del plan), así que
32# la clausura de un perfil crece a medida que la frontera se drena. Un número que converge es
33# mejor que ningún número, pero no es el total final.
34#
35# Uso: scripts/build-state.py # regenera docs/state/build-state.json + resumen
36# scripts/build-state.py --check # exit 1 si hay unhashable o deps huérfanas (gate CI)
37# Env: HAMMER (def target/release/takana), STORE (def ./store).
38import json, os, subprocess, sys, glob, tomllib
39from pathlib import Path
40
41sys.path.insert(0, str(Path(__file__).resolve().parent))
42import targets as targets_mod
43import yupana # la YUPANA: el motor de dependencias INVERSAS que cruza TODAS las colas (ver su cabecera)
44
45ROOT = Path(__file__).resolve().parent.parent
46os.chdir(ROOT)
47HAMMER = os.environ.get("HAMMER", str(ROOT / "target/release/takana"))
48STORE = os.environ.get("STORE", str(ROOT / "store"))
49CHECK = "--check" in sys.argv
50# `--kde`: además del corpus canónico, cubre la cola `recipes/incoming-kde/` (206 recetas del
51# escritorio KDE Plasma). Las KDE SOMBREAN a las canónicas homónimas (`fontconfig` vs KDE, `dbus`…)
52# igual que hace el sandbox al resolver deps (sibling-first), así el grafo modela lo que un build KDE
53# ve. Sale a un JSON aparte para no mezclar los totales del corpus enviable con el frente en curso.
54KDE = "--kde" in sys.argv
55# `--gnome`: idéntico a `--kde` pero para la cola `recipes/incoming-gnome/` (frente GNOME Wayland).
56# Enciende la CAPA GRAFO del yupana-gnome: sin esto el perfil escritorio-gnome se saltaba (cola no
57# cargada) y `yupana keystones/drenar --perfil escritorio-gnome` no reckonaban. Sale a su JSON aparte.
58GNOME = "--gnome" in sys.argv
59# `--cosmic`: lo mismo para `recipes/incoming-cosmic/` (COSMIC de System76, 4º escritorio). Sin esto
60# la campaña entera es INVISIBLE para el khipu: el latido regenera build-state{,-kde,-gnome}.json cada
61# media hora y COSMIC no aparecería en ninguno, así que el avance no quedaría en el registro versionado
62# que es `git log docs/state/`. Y —lo que importa más— `yupana radio` sobre una receta compartida
63# (libinput, libxkbcommon, libudev-zero…) no reportaría la imagen COSMIC entre las afectadas, o sea que
64# el próximo que la toque MEDIRÍA DE MENOS.
65COSMIC = "--cosmic" in sys.argv
66# `--wlr`: la cola `recipes/incoming-wlr/` — wlroots y los WMs Wayland LIGEROS (sway y sus
67# accesorios, fuzzel, yambar, wl-clipboard). Es un frente de otra naturaleza que los tres de arriba:
68# no es un escritorio completo con su torre de C, sino una decena de binarios pequeños sobre wlroots.
69# Va con su flag propio por la misma razón que COSMIC: sin esto el frente es INVISIBLE para el
70# khipu, y `yupana radio` sobre una receta compartida (wayland, libinput, pixman, mesa,
71# libxkbcommon…) no reportaría la imagen sway entre las afectadas — o sea que el próximo que toque
72# una de esas MEDIRÍA DE MENOS y creería que no rompe nada.
73WLR = "--wlr" in sys.argv
74EXTRA_QUEUES = (["recipes/incoming-kde"] if KDE else []) + (["recipes/incoming-gnome"] if GNOME else []) \
75 + (["recipes/incoming-cosmic"] if COSMIC else []) + (["recipes/incoming-wlr"] if WLR else [])
76_var = "-kde" if KDE else "-gnome" if GNOME else "-cosmic" if COSMIC else "-wlr" if WLR else ""
77OUT = ROOT / f"docs/state/build-state{_var}.json"
78
79# Clase de la receta — sólo para colorear/agrupar la vista; heurística por contenido, no dato duro.
80GUI_RE = ("cairo", "pango", "gtk", "harfbuzz", "glib", "gdk-pixbuf", "libadwaita", "gtksourceview",
81 "fribidi", "graphene", "fontconfig", "adwaita", "fcft", "mirada", "appstream", "gnome",
82 "wayland", "libdrm", "mesa", "seatd", "libepoxy", "libxkbcommon", "libinput", "pixman",
83 "sourceview", "tllist", "foot")
84
85def classify(name, d):
86 ph = d.get("build", {}).get("phases", {})
87 blob = " ".join(str(ph.get(k, "")) for k in ("configure", "compile", "install"))
88 deps = d.get("deps", {}).get("build", [])
89 if name.startswith("linux"):
90 return "kernel"
91 # Go: declara el toolchain `go` como dep de build (la vía Go auto lo exige). 362 recetas.
92 if "go" in deps and name != "go":
93 return "go"
94 # Rust: fase cargo, dep rust, o —la señal fuerte— copia el binario de `target/release/` en sus
95 # fases (todo binario cargo sale de ahí). Medido: 225 recetas lo tienen, 0 con dep `go` ⇒ sin
96 # falsos positivos con Go. LÍMITE conocido: un crate BuildSys::Cargo PURO (Cargo.toml en el árbol,
97 # sin fase install propia — p.ej. zellij) no deja rastro en el TOML y cae a "c"; sólo se distingue
98 # bajando la fuente, que este generador no hace a propósito.
99 if "cargo" in blob or "cargo" in deps or "rust" in deps or "target/release" in blob:
100 return "rust"
101 if any(g in name for g in GUI_RE):
102 return "gui"
103 return "c"
104
105def load_recipes():
106 recs = {}
107 # Orden importa: primero el corpus (queue=corpus), luego las colas extra que SOMBREAN por nombre
108 # (queue=el basename de la cola), replicando el sibling-first del sandbox.
109 for root, queue in [("recipes", "corpus")] + [(q, os.path.basename(q)) for q in EXTRA_QUEUES]:
110 for f in sorted(glob.glob(f"{root}/*.toml")):
111 n = os.path.basename(f)[:-5]
112 try:
113 d = tomllib.load(open(f, "rb"))
114 except Exception as e:
115 recs[n] = dict(path=f, parse_error=str(e), queue=queue)
116 continue
117 b = d.get("build", {})
118 recs[n] = dict(
119 path=f,
120 # el artefacto se sella por el `name` INTERNO, que puede diferir del basename del
121 # fichero (qtbase.toml → name="qt6-qtbase"). El store lookup usa iname; las aristas de
122 # deps siguen usando el basename (así resuelve `resolve_dep_path`).
123 iname=d.get("name") or n,
124 version=d.get("version"),
125 link=b.get("link"),
126 compiler=b.get("compiler"),
127 # ⚠ BUILD **+ RUNTIME** (2026-09-06). Antes sólo `build`, y por eso una dep de
128 # EJECUCIÓN declarada en la receta no entraba en la clausura ni en el rootfs
129 # hidratado: `firefox` declaraba `runtime = ["gcc-libs"]` y el grafo seguía sin
130 # conocerla, así que la imagen se armaba sin `libstdc++.so.6` y el navegador no
131 # arrancaba. `deps.runtime` existe en el esquema de takana desde siempre; lo que
132 # faltaba era que ESTE contador lo mirara. Un campo que nadie lee es un campo que
133 # miente. La unión es la definición de clausura: para CORRER hacen falta las dos.
134 deps=sorted(set(d.get("deps", {}).get("build", []))
135 | set(d.get("deps", {}).get("runtime", []))),
136 cls=classify(n, d),
137 queue=queue,
138 # `foreign = true`: la receta SELLA bytes ajenos ya compilados, no construye desde
139 # fuente (ADR 0015 D8; hoy sólo `steam-runtime-sniper`). Se lee acá para que la
140 # clasificación de abajo la saque del corpus: un prebuilt sellado no puede sumar a
141 # «cuántas recetas construimos» — es la cifra que el ADR pidió proteger antes de
142 # que existiera la primera instancia.
143 foreign=bool(d.get("foreign")),
144 )
145 return recs
146
147def vigente_hash(path):
148 try:
149 out = subprocess.run([HAMMER, "--store", STORE, "hash", path],
150 capture_output=True, text=True, timeout=60)
151 h = out.stdout.strip()
152 return h if h.startswith("b3:") else None
153 except Exception:
154 return None
155
156# ── El store dejó de vivir en el laptop (2026-08-09) ───────────────────────────────────────────
157# El store de trabajo se mudó al VOLUMEN de la granja y el disco local se quedó sólo con lo que la
158# granja NO puede rehacer (las recetas de fuente privada) más lo que aún no está respaldado. Si el
159# grafo siguiera leyendo únicamente `./store`, el latido regeneraría un estado que dice `never` sobre
160# 979 artefactos que existen y están sellados — y lo COMMITEARÍA, que es peor que no regenerarlo:
161# un grafo viejo miente con la misma cara que uno fresco, pero uno recién escrito miente con más
162# autoridad. El error no sería del disco, sería de haber confundido «dónde están los bytes» con
163# «qué está construido».
164#
165# Así que la presencia se resuelve contra la UNIÓN de dos fuentes: el store local y el MANIFIESTO de
166# sellados que la granja deja en `work/farm-sellados.txt`. La unión es monótona a propósito — sólo
167# puede evitar un `never`/`debt` falso, nunca inventar uno. El riesgo simétrico (dar por sellado algo
168# que ya no está en ningún lado) queda VISIBLE en los totales como `sealed_remoto`, en vez de
169# disolverse en el número grande.
170# Dos registros, y hacen falta LOS DOS: el de la granja se escribe al cosechar y va por delante,
171# pero el 2026-08-09 le faltaban 5 sellados que el respaldo sí tenía ⇒ solo con él, 5 recetas caían
172# de `sealed` a `debt` sin que nada se hubiera roto. El del respaldo se refresca con
173# `scripts/respaldo-storagebox.sh listar` y es el más completo, pero llega por red y envejece.
174MANIFIESTOS = ("farm-sellados.txt", "respaldo-sellados.txt")
175
176def _manifiesto_sellados():
177 out = set()
178 for nombre in MANIFIESTOS:
179 try:
180 txt = (ROOT / "work" / nombre).read_text()
181 except OSError:
182 continue
183 out |= {l.strip() for l in txt.splitlines() if l.strip() and not l.startswith("#")}
184 return out
185
186SELLADOS_REMOTOS = _manifiesto_sellados()
187# `*-{iname}` del manifiesto, para el fallback de «hay algún sellado histórico» sin tocar el disco.
188_REMOTOS_POR_INAME = {}
189for _d in SELLADOS_REMOTOS:
190 _REMOTOS_POR_INAME.setdefault(_d.split("-", 1)[1] if "-" in _d else _d, []).append(_d)
191REMOTO_ONLY = set()
192
193def _artefacto_presente(ruta):
194 """¿Hay un ARTEFACTO en `ruta`, o sólo un nombre?
195
196 ⚠ Esto era `os.path.isdir(ruta)` y por eso un directorio VACÍO se contaba como `sealed`
197 (2026-09-01). No es hipotético: una cosecha que murió a mitad dejó `…-mise` sin un solo fichero
198 y el grafo lo dio por sellado — mismo número, misma línea, imposible de distinguir de la
199 corrida buena. Es el eslabón que describe la regla 3 del CLAUDE.md: `Store::has` ya rechaza los
200 vacíos, pero cada consumidor que mire presencia de directorio los vuelve a leer como presencia.
201 **Un ausente falla ruidosamente; un vacío llega hasta el final diciendo que todo fue bien.**
202 """
203 try:
204 with os.scandir(ruta) as it:
205 return next(it, None) is not None
206 except (NotADirectoryError, FileNotFoundError):
207 return False
208
209
210def state_of(iname, h):
211 if h is None:
212 return "unhashable"
213 dirname = f"{h[3:]}-{iname}"
214 if _artefacto_presente(os.path.join(STORE, dirname)):
215 return "sealed"
216 if dirname in SELLADOS_REMOTOS:
217 REMOTO_ONLY.add(dirname)
218 return "sealed"
219 # ¿hay algún sellado histórico? distingue "cambió" de "nunca construida".
220 if glob.glob(os.path.join(STORE, f"*-{iname}")) or _REMOTOS_POR_INAME.get(iname):
221 return "debt"
222 return "never"
223
224def toposort(nodes):
225 # Kahn sobre las aristas name→dep (sólo deps dentro del corpus). Devuelve orden o None si hay ciclo.
226 indeg = {n: 0 for n in nodes}
227 adj = {n: [] for n in nodes}
228 for n, r in nodes.items():
229 for dep in r.get("deps", []):
230 if dep in nodes:
231 adj[dep].append(n); indeg[n] += 1
232 q = sorted([n for n, d in indeg.items() if d == 0])
233 order = []
234 while q:
235 n = q.pop(0); order.append(n)
236 for m in sorted(adj[n]):
237 indeg[m] -= 1
238 if indeg[m] == 0: q.append(m)
239 return order if len(order) == len(nodes) else None
240
241def cargar_perfiles(colas_activas):
242 """Perfiles del manifiesto cuyas raíces viven en una cola CARGADA (así un perfil de
243 `incoming-kde` no reporta raíces fantasma en la corrida del corpus). Degrada sin romper: si el
244 manifiesto falta o está mal, el grafo sale como siempre — este script lo corre el cron cada 30m
245 y no puede caerse por un TOML."""
246 try:
247 todos = targets_mod.load()
248 except FileNotFoundError:
249 print(" (sin docs/state/targets.toml — grafo sin perfiles)", file=sys.stderr)
250 return {}
251 except Exception as e:
252 print(f" ⚠ targets.toml inválido ({e}) — grafo sin perfiles", file=sys.stderr)
253 return {}
254 return {n: p for n, p in todos.items() if p["cola"] in colas_activas}
255
256
257def cargar_ajenos():
258 """Nodos provistos por una imagen ajena (qorpa, ADR 0015 D5 capa 3).
259
260 Degrada igual que `cargar_perfiles`: este script lo corre el cron cada 30 minutos y no puede
261 caerse porque alguien dejó un TOML a medias."""
262 f = ROOT / "docs/state/qorpa-ajenos.toml"
263 if not f.exists():
264 return {}
265 try:
266 import tomllib
267 return tomllib.loads(f.read_text())
268 except Exception as e:
269 print(f" ⚠ qorpa-ajenos.toml inválido ({e}) — grafo sin nodos ajenos", file=sys.stderr)
270 return {}
271
272
273def clausura(raices, recs):
274 """Nodos alcanzables desde `raices` siguiendo las aristas name→dep. Un nodo `wanted` CORTA la
275 exploración (no se conocen sus deps hasta construirlo): de ahí que la clausura sea cota inferior."""
276 vistos, pila = set(), list(raices)
277 while pila:
278 n = pila.pop()
279 if n in vistos or n not in recs:
280 continue
281 vistos.add(n)
282 pila.extend(recs[n].get("deps") or [])
283 return vistos
284
285
286def main():
287 from collections import Counter
288 recs = load_recipes()
289
290 # OBJETIVO → FRONTERA. Toda raíz de un perfil sin receta entra como nodo `wanted`. Se hace ANTES
291 # de buscar huérfanas a propósito: un objetivo declarado no es una arista colgando.
292 colas = {"corpus"} | {os.path.basename(q) for q in EXTRA_QUEUES}
293 perfiles = cargar_perfiles(colas)
294
295 # AJENOS ANTES QUE WANTED, y el orden es la mitad del punto: un nodo que provee una imagen
296 # ajena NO es una receta por escribir. Si se inyectara después, `xwayland` quedaría contado
297 # como deuda para siempre — y la deuda fantasma es un error que este repo ya pagó caro.
298 ajenos = cargar_ajenos()
299 for nombre, a in sorted(ajenos.items()):
300 if nombre in recs:
301 # Hay receta de verdad: la receta gana y el declarado sobra. Decirlo, no taparlo.
302 print(f" ⚠ {nombre} está declarado ajeno pero TIENE receta — gana la receta",
303 file=sys.stderr)
304 continue
305 colas_de = {perfiles[pn]["cola"] for pn in a.get("perfiles", []) if pn in perfiles}
306 recs[nombre] = dict(path=None, ajeno=True, iname=nombre, version=None, link=None,
307 compiler=None, deps=[], cls="ajeno",
308 queue=(sorted(colas_de) or ["corpus"])[0],
309 imagen=a.get("imagen"))
310
311 for pn, p in sorted(perfiles.items()):
312 for raiz in p["raices"]:
313 if raiz not in recs:
314 recs[raiz] = dict(path=None, wanted=True, iname=raiz, version=None, link=None,
315 compiler=None, deps=[], cls="wanted", queue=p["cola"])
316
317 orphans = set()
318 for n, r in recs.items():
319 for dep in r.get("deps", []):
320 if dep not in recs:
321 orphans.add(dep)
322
323 # Una receta `foreign` es AJENA aunque tenga fichero: hay bytes que sellamos y no construimos.
324 # Se marca ANTES del recuento para que no entre en el corpus por la puerta de atrás. Y no es lo
325 # mismo que un ajeno declarado en `qorpa-ajenos.toml`: aquél NO está en el store y éste SÍ, así
326 # que conserva su hash. Que un artefacto exista y el grafo lo niegue sería otra forma de mentir.
327 for r in recs.values():
328 if r.get("foreign"):
329 r["ajeno"] = True
330 r["cls"] = "ajeno"
331
332 n_recetas = sum(1 for r in recs.values() if not r.get("wanted") and not r.get("ajeno"))
333 print(f"== {n_recetas} recetas; computando hash vigente (~{n_recetas*18//1000}s)…", file=sys.stderr)
334 for n, r in recs.items():
335 # `wanted` no tiene fichero que hashear: es el objetivo, no una receta.
336 if r.get("wanted"):
337 r["hash"] = None; r["state"] = "wanted"; continue
338 # `ajeno` tampoco, y por una razón más fuerte: no tiene procedencia de fuente. Un hash
339 # afirmaría que lo reproducimos, y no lo reproducimos.
340 if r.get("ajeno"):
341 # El ajeno DECLARADO no se hashea (no hay receta que hashear, y un hash afirmaría que
342 # lo reproducimos). El ajeno SELLADO sí: es un puntero al contenido exacto, y sin él no
343 # se lo podría encontrar en el store ni pedir por `qorpa import`. Lo que no cambia en
344 # ninguno de los dos casos es el estado: `ajeno`, fuera del corpus.
345 r["hash"] = vigente_hash(r["path"]) if r.get("foreign") else None
346 r["state"] = "ajeno"
347 continue
348 if "parse_error" in r:
349 r["hash"] = None; r["state"] = "unhashable"; continue
350 h = vigente_hash(r["path"])
351 r["hash"] = h
352 r["state"] = state_of(r.get("iname", n), h)
353
354 order = toposort(recs)
355
356 # Lectura ACCIONABLE del grafo topológico: para cada receta, qué deps la BLOQUEAN (están en
357 # deuda) y a cuántas otras en deuda DESBLOQUEA (la tienen como dep). Una receta en deuda sin
358 # bloqueadores es construible YA; ordenar las listas por `unblocks` da el orden que libera más.
359 # `wanted` cuenta como bloqueante: una imagen que depende de algo sin receta está trabada igual.
360 DEBT_STATES = ("debt", "never", "unhashable", "wanted")
361 for n, r in recs.items():
362 r["blocked_by"] = [d for d in r.get("deps", [])
363 if d in recs and recs[d]["state"] in DEBT_STATES]
364 for n, r in recs.items():
365 # cuántas recetas EN DEUDA me declaran como dep directa (mi impacto de desbloqueo).
366 r["unblocks"] = sum(1 for m, rm in recs.items()
367 if rm["state"] in DEBT_STATES and n in rm.get("deps", []))
368
369 # MEMBRESÍA DE PERFIL y DEPENDIENTES INVERSOS: los dos se calculan sobre el GRAFO ENTERO (todas
370 # las colas del disco), NO sobre los nodos que esta vista cargó. Es la corrección del bug de
371 # libdrm: en la corrida del corpus, `perfiles` llegaba a decir `['escritorio-mirada']` sobre un
372 # paquete que sostiene KDE entero, porque no veía las recetas de incoming-kde. yupana las ve
373 # siempre y resuelve sibling-first ⇒ el nodo se identifica por (cola, nombre). Ver yupana.py.
374 todas = yupana.cargar_colas()
375 memb = yupana.membresia(todas, cargar_perfiles({q for q in todas})) # TODOS los perfiles, no sólo los de esta vista
376 rev = yupana.dependientes(todas)
377 for n, r in recs.items():
378 clave = (r.get("queue", "corpus"), n)
379 r["perfiles"] = sorted(memb.get(clave, ()))
380 # yupana calcula la membresía sobre las RECETAS del disco, y un ajeno no tiene ninguna: sin
381 # esto quedaría fuera de la clausura de su imagen y el reporte no lo mencionaría, que es
382 # justo el descuido que la clase `ajeno` existe para impedir. La declaración dice a qué
383 # imagen sirve; es autoría, igual que la política de D7.
384 if r.get("ajeno"):
385 r["perfiles"] = sorted(set(r["perfiles"]) | set(ajenos.get(n, {}).get("perfiles", [])))
386 # Dependientes inversos DEL GRAFO ENTERO: cuántas recetas (de cualquier cola) me consumen. Es
387 # el radio real de un re-hash. `unblocks` (abajo) es local a la deuda de esta vista; este no.
388 r["dependientes_total"] = len(rev.get(clave, ()))
389 sin_perfil = sum(1 for r in recs.values() if not r["perfiles"])
390
391 # `by_profile` (el "cuánto falta" por imagen) SÍ es local: sólo cuenta estados de nodos que esta
392 # vista cargó ⇒ se limita a los perfiles cuya cola está cargada (los demás darían conteos falsos).
393 # ⚠ **`clausura` e `imagen` son DOS NÚMEROS Y NO UNO** (2026-09-22). `clausura` sigue siendo
394 # `build ∪ runtime` porque contesta «qué hay que construir para tener esta imagen», y para eso el
395 # toolchain hace falta. `imagen` es lo que el rootfs LLEVA: descuenta las herramientas declaradas
396 # en `[solo-build]` de `targets.toml` (`llvm21`, `go`, `python3`, `perl`, `cmake`…), que es lo que
397 # `hydrate-profile.py` proyecta desde hoy. Se informan los dos porque el día que sólo se informó
398 # uno, las 8 imágenes llevaban 1,5 G de LLVM adentro y ninguna métrica lo decía.
399 memb_img = yupana.membresia_imagen(todas, cargar_perfiles({q for q in todas}))
400 en_imagen = {n: sorted(memb_img.get((r.get("queue", "corpus"), n), ())) for n, r in recs.items()}
401 by_profile = {}
402 for pn, p in sorted(perfiles.items()):
403 miembros = [n for n, r in recs.items() if pn in r["perfiles"]]
404 by_profile[pn] = dict(raices=len(p["raices"]), clausura=len(miembros),
405 imagen=sum(1 for n in miembros if pn in en_imagen[n]),
406 **Counter(recs[n]["state"] for n in miembros))
407
408 by_state = Counter(r["state"] for r in recs.values())
409 by_cls = Counter(r["cls"] for r in recs.values() if "cls" in r)
410 # deuda por clase: dónde están los huecos.
411 debt_by_cls = Counter(r["cls"] for r in recs.values()
412 if r.get("state") in ("debt", "never") and "cls" in r)
413
414 # Reparto por cola (corpus vs incoming-kde…): dónde está cada cosa.
415 by_queue = {}
416 for r in recs.values():
417 q = r.get("queue", "corpus")
418 by_queue.setdefault(q, Counter())[r["state"]] += 1
419
420 doc = dict(
421 schema="hammer-build-state/1",
422 # `recipes` sigue contando SÓLO las que tienen fichero (los consumidores lo leen como el
423 # tamaño del corpus); `nodes` incluye la frontera `wanted`.
424 # ⚠ `sealed_remoto` NO va acá, y la razón se descubrió al montar un segundo hub (2026-08-09):
425 # es un número que depende de QUÉ MÁQUINA lo calcula — el laptop tiene 219 artefactos en
426 # disco y gioser 19, así que el mismo corpus da 700 y 751. Metido en un fichero que se
427 # commitea, las dos máquinas se lo pisarían mutuamente en cada regeneración, para siempre.
428 # El estado del corpus es compartido; cuánto de él tiene ESTA máquina en el disco, no.
429 # Se sigue diciendo —abajo, en el resumen— donde sí es útil y no genera churn.
430 totals=dict(recipes=n_recetas, nodes=len(recs), **by_state),
431 by_class={k: by_cls[k] for k in sorted(by_cls)},
432 debt_by_class={k: debt_by_cls[k] for k in sorted(debt_by_cls)},
433 by_queue={q: dict(c) for q, c in sorted(by_queue.items())},
434 by_profile=by_profile,
435 sin_perfil=sin_perfil,
436 orphan_deps=sorted(orphans),
437 topo_ok=order is not None,
438 # `foreign` SÍ viaja al nodo (a diferencia de `ajeno`, que es derivado): es lo que le dice
439 # a quien lee el grafo que ese `sealed` de ahí son bytes de otro, no un build nuestro.
440 nodes={n: {k: v for k, v in r.items() if k not in ("path", "wanted", "ajeno")}
441 for n, r in sorted(recs.items())},
442 )
443 OUT.parent.mkdir(parents=True, exist_ok=True)
444 OUT.write_text(json.dumps(doc, indent=1, sort_keys=False) + "\n")
445
446 # Resumen humano a stdout.
447 print(f"\n== estado del corpus ({n_recetas} recetas) → {OUT.relative_to(ROOT)}")
448 for s in ("sealed", "debt", "never", "unhashable", "wanted", "ajeno"):
449 if by_state.get(s):
450 print(f" {s:11} {by_state[s]}")
451 if REMOTO_ONLY:
452 # Propio de ESTA máquina, por eso se imprime y no se escribe: de los `sealed`, cuántos no
453 # están en su disco y se dan por buenos por los manifiestos. Si sube sin que nadie haya
454 # sembrado, es la señal de que un manifiesto afirma algo que ya no existe en ningún lado.
455 print(f" de los sellados, {len(REMOTO_ONLY)} NO están en el disco de esta máquina "
456 f"(los avalan los manifiestos)")
457 sellados_ajenos = [n for n, r in recs.items() if r.get("foreign") and r.get("hash")]
458 if sellados_ajenos:
459 # Se dice aparte y con nombre: un prebuilt ajeno en el store es una excepción que hay que
460 # poder auditar de un vistazo, no un renglón más del corpus.
461 print(f" de las ajenas, {len(sellados_ajenos)} selladas al store (prebuilt pineado, "
462 f"sin procedencia de fuente): {', '.join(sorted(sellados_ajenos))}")
463 if debt_by_cls:
464 print(" deuda por clase:", " ".join(f"{k}={v}" for k, v in sorted(debt_by_cls.items())))
465 if len(by_queue) > 1:
466 for q, c in sorted(by_queue.items()):
467 print(f" cola {q:14} sellado={c.get('sealed',0):3} deuda={c.get('debt',0):3} "
468 f"nunca={c.get('never',0):2}")
469 if by_profile:
470 print("\n== objetivo por imagen (docs/state/targets.toml) — clausura = COTA INFERIOR")
471 for pn, c in by_profile.items():
472 falta = c.get("debt", 0) + c.get("never", 0) + c.get("unhashable", 0) + c.get("wanted", 0)
473 # El denominador es el CORPUS de la imagen: los ajenos se restan y se dicen aparte. Si
474 # entraran en la fracción, la cifra que todo el mundo lee como "cuánto construimos"
475 # crecería sola cada vez que alguien enjaula una app ajena.
476 propios = c["clausura"] - c.get("ajeno", 0)
477 print(f" {pn:20} {c.get('sealed',0):3}/{propios:3} listo falta {falta:3}"
478 f" · imagen {c.get('imagen', c['clausura']):3}"
479 f" (raíces {c['raices']}"
480 + (f", sin receta {c['wanted']}" if c.get("wanted") else "")
481 # Se dicen APARTE, nunca sumadas a las nativas: son la parte del sistema sin
482 # procedencia de fuente y sin reproducibilidad (ADR 0015 §NO-resuelve 3).
483 + (f", + {c['ajeno']} ajenas" if c.get("ajeno") else "") + ")")
484 print(f" {sin_perfil} recetas no las alcanza ninguna imagen (catálogo, no distro)")
485 print(f"\n grafo: {'CIERRA' if not orphans else f'{len(orphans)} deps huérfanas'} | "
486 f"topo-sort: {'OK' if order else 'CICLO'}")
487 if orphans:
488 print(" huérfanas:", " ".join(sorted(orphans)))
489
490 # `wanted` NO es fallo: es trabajo declarado y pendiente. El gate sigue midiendo lo mismo que
491 # antes — aristas colgando, recetas rotas, ciclos.
492 if CHECK and (orphans or by_state.get("unhashable") or not order):
493 sys.exit(1)
494
495if __name__ == "__main__":
496 main()