Files
Sergio 476168bb07 takana etapa 5c: comentarios de scripts, MOTD, y un BUG que introdujo la etapa 4
250 líneas de comentario en 151 scripts. Control verificado: el diff no toca
NI UNA línea que no empiece por #, y la sintaxis de los 151 pasa.

El barrido saltea heredocs y cadenas triples, y el guardián DISPARÓ 3 veces:
las tres eran el MOTD que el script escribe DENTRO de la imagen construida —
texto del producto, no comentario del script. Se cambiaron aparte y a
propósito, que es rebranding, no limpieza.

Y el hallazgo caro:  casaba contra ,
que es el TARGET de tracing — o sea el module_path!, o sea el nombre del crate.
La etapa 4 lo movió a  y el script quedó casando NADA. No fallaba:
imprimía cero atribuciones, indistinguible de un log sin problemas. Comprobado
con el binario (RUST_LOG=info sobre zlib), no deducido. Ahora acepta las dos, y
tiene que seguir aceptándolas porque los logs viejos en disco dicen la vieja.

Además 14 rutas de módulo  en docs, que el barrido anterior no tocó
porque  no es frontera de palabra.
2026-09-09 19:28:48 +00:00

487 lines
28 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 + `takana 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 — `takana 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/takana), 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/takana"))
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"),
# ⚠ BUILD **+ RUNTIME** (2026-09-06). Antes sólo `build`, y por eso una dep de
# EJECUCIÓN declarada en la receta no entraba en la clausura ni en el rootfs
# hidratado: `firefox` declaraba `runtime = ["gcc-libs"]` y el grafo seguía sin
# conocerla, así que la imagen se armaba sin `libstdc++.so.6` y el navegador no
# arrancaba. `deps.runtime` existe en el esquema de takana desde siempre; lo que
# faltaba era que ESTE contador lo mirara. Un campo que nadie lee es un campo que
# miente. La unión es la definición de clausura: para CORRER hacen falta las dos.
deps=sorted(set(d.get("deps", {}).get("build", []))
| set(d.get("deps", {}).get("runtime", []))),
cls=classify(n, d),
queue=queue,
# `foreign = true`: la receta SELLA bytes ajenos ya compilados, no construye desde
# fuente (ADR 0015 D8; hoy sólo `steam-runtime-sniper`). Se lee acá para que la
# clasificación de abajo la saque del corpus: un prebuilt sellado no puede sumar a
# «cuántas recetas construimos» — es la cifra que el ADR pidió proteger antes de
# que existiera la primera instancia.
foreign=bool(d.get("foreign")),
)
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 _artefacto_presente(ruta):
"""¿Hay un ARTEFACTO en `ruta`, o sólo un nombre?
⚠ Esto era `os.path.isdir(ruta)` y por eso un directorio VACÍO se contaba como `sealed`
(2026-09-01). No es hipotético: una cosecha que murió a mitad dejó `…-mise` sin un solo fichero
y el grafo lo dio por sellado — mismo número, misma línea, imposible de distinguir de la
corrida buena. Es el eslabón que describe la regla 3 del CLAUDE.md: `Store::has` ya rechaza los
vacíos, pero cada consumidor que mire presencia de directorio los vuelve a leer como presencia.
**Un ausente falla ruidosamente; un vacío llega hasta el final diciendo que todo fue bien.**
"""
try:
with os.scandir(ruta) as it:
return next(it, None) is not None
except (NotADirectoryError, FileNotFoundError):
return False
def state_of(iname, h):
if h is None:
return "unhashable"
dirname = f"{h[3:]}-{iname}"
if _artefacto_presente(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 cargar_ajenos():
"""Nodos provistos por una imagen ajena (qorpa, ADR 0015 D5 capa 3).
Degrada igual que `cargar_perfiles`: este script lo corre el cron cada 30 minutos y no puede
caerse porque alguien dejó un TOML a medias."""
f = ROOT / "docs/state/qorpa-ajenos.toml"
if not f.exists():
return {}
try:
import tomllib
return tomllib.loads(f.read_text())
except Exception as e:
print(f" ⚠ qorpa-ajenos.toml inválido ({e}) — grafo sin nodos ajenos", file=sys.stderr)
return {}
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)
# AJENOS ANTES QUE WANTED, y el orden es la mitad del punto: un nodo que provee una imagen
# ajena NO es una receta por escribir. Si se inyectara después, `xwayland` quedaría contado
# como deuda para siempre — y la deuda fantasma es un error que este repo ya pagó caro.
ajenos = cargar_ajenos()
for nombre, a in sorted(ajenos.items()):
if nombre in recs:
# Hay receta de verdad: la receta gana y el declarado sobra. Decirlo, no taparlo.
print(f" ⚠ {nombre} está declarado ajeno pero TIENE receta — gana la receta",
file=sys.stderr)
continue
colas_de = {perfiles[pn]["cola"] for pn in a.get("perfiles", []) if pn in perfiles}
recs[nombre] = dict(path=None, ajeno=True, iname=nombre, version=None, link=None,
compiler=None, deps=[], cls="ajeno",
queue=(sorted(colas_de) or ["corpus"])[0],
imagen=a.get("imagen"))
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)
# Una receta `foreign` es AJENA aunque tenga fichero: hay bytes que sellamos y no construimos.
# Se marca ANTES del recuento para que no entre en el corpus por la puerta de atrás. Y no es lo
# mismo que un ajeno declarado en `qorpa-ajenos.toml`: aquél NO está en el store y éste SÍ, así
# que conserva su hash. Que un artefacto exista y el grafo lo niegue sería otra forma de mentir.
for r in recs.values():
if r.get("foreign"):
r["ajeno"] = True
r["cls"] = "ajeno"
n_recetas = sum(1 for r in recs.values() if not r.get("wanted") and not r.get("ajeno"))
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
# `ajeno` tampoco, y por una razón más fuerte: no tiene procedencia de fuente. Un hash
# afirmaría que lo reproducimos, y no lo reproducimos.
if r.get("ajeno"):
# El ajeno DECLARADO no se hashea (no hay receta que hashear, y un hash afirmaría que
# lo reproducimos). El ajeno SELLADO sí: es un puntero al contenido exacto, y sin él no
# se lo podría encontrar en el store ni pedir por `qorpa import`. Lo que no cambia en
# ninguno de los dos casos es el estado: `ajeno`, fuera del corpus.
r["hash"] = vigente_hash(r["path"]) if r.get("foreign") else None
r["state"] = "ajeno"
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, ()))
# yupana calcula la membresía sobre las RECETAS del disco, y un ajeno no tiene ninguna: sin
# esto quedaría fuera de la clausura de su imagen y el reporte no lo mencionaría, que es
# justo el descuido que la clase `ajeno` existe para impedir. La declaración dice a qué
# imagen sirve; es autoría, igual que la política de D7.
if r.get("ajeno"):
r["perfiles"] = sorted(set(r["perfiles"]) | set(ajenos.get(n, {}).get("perfiles", [])))
# 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,
# `foreign` SÍ viaja al nodo (a diferencia de `ajeno`, que es derivado): es lo que le dice
# a quien lee el grafo que ese `sealed` de ahí son bytes de otro, no un build nuestro.
nodes={n: {k: v for k, v in r.items() if k not in ("path", "wanted", "ajeno")}
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", "ajeno"):
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)")
sellados_ajenos = [n for n, r in recs.items() if r.get("foreign") and r.get("hash")]
if sellados_ajenos:
# Se dice aparte y con nombre: un prebuilt ajeno en el store es una excepción que hay que
# poder auditar de un vistazo, no un renglón más del corpus.
print(f" de las ajenas, {len(sellados_ajenos)} selladas al store (prebuilt pineado, "
f"sin procedencia de fuente): {', '.join(sorted(sellados_ajenos))}")
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)
# El denominador es el CORPUS de la imagen: los ajenos se restan y se dicen aparte. Si
# entraran en la fracción, la cifra que todo el mundo lee como "cuánto construimos"
# crecería sola cada vez que alguien enjaula una app ajena.
propios = c["clausura"] - c.get("ajeno", 0)
print(f" {pn:20} {c.get('sealed',0):3}/{propios:3} listo falta {falta:3}"
f" (raíces {c['raices']}"
+ (f", sin receta {c['wanted']}" if c.get("wanted") else "")
# Se dicen APARTE, nunca sumadas a las nativas: son la parte del sistema sin
# procedencia de fuente y sin reproducibilidad (ADR 0015 §NO-resuelve 3).
+ (f", + {c['ajeno']} ajenas" if c.get("ajeno") 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()