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…
⎇ 0093c1f19cbb2a56bc17bc093c1e3f027eb4437a
scripts/build-state.py
Ñawpa willay
Pi imata llamkarqan
ch'uya
catálogo objetivo P2: estado `wanted` + membresía de perfil en el grafo
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 + `hammer 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 — `hammer 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/hammer), 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
43
44ROOT = Path(__file__).resolve().parent.parent
45os.chdir(ROOT)
46HAMMER = os.environ.get("HAMMER", str(ROOT / "target/release/hammer"))
47STORE = os.environ.get("STORE", str(ROOT / "store"))
48CHECK = "--check" in sys.argv
49# `--kde`: además del corpus canónico, cubre la cola `recipes/incoming-kde/` (206 recetas del
50# escritorio KDE Plasma). Las KDE SOMBREAN a las canónicas homónimas (`fontconfig` vs KDE, `dbus`…)
51# igual que hace el sandbox al resolver deps (sibling-first), así el grafo modela lo que un build KDE
52# ve. Sale a un JSON aparte para no mezclar los totales del corpus enviable con el frente en curso.
53KDE = "--kde" in sys.argv
54EXTRA_QUEUES = ["recipes/incoming-kde"] if KDE else []
55OUT = ROOT / ("docs/state/build-state-kde.json" if KDE else "docs/state/build-state.json")
56
57# Clase de la receta — sólo para colorear/agrupar la vista; heurística por contenido, no dato duro.
58GUI_RE = ("cairo", "pango", "gtk", "harfbuzz", "glib", "gdk-pixbuf", "libadwaita", "gtksourceview",
59 "fribidi", "graphene", "fontconfig", "adwaita", "fcft", "mirada", "appstream", "gnome",
60 "wayland", "libdrm", "mesa", "seatd", "libepoxy", "libxkbcommon", "libinput", "pixman",
61 "sourceview", "tllist", "foot")
62
63def classify(name, d):
64 ph = d.get("build", {}).get("phases", {})
65 blob = " ".join(str(ph.get(k, "")) for k in ("configure", "compile", "install"))
66 deps = d.get("deps", {}).get("build", [])
67 if name.startswith("linux"):
68 return "kernel"
69 # Go: declara el toolchain `go` como dep de build (la vía Go auto lo exige). 362 recetas.
70 if "go" in deps and name != "go":
71 return "go"
72 # Rust: fase cargo, dep rust, o —la señal fuerte— copia el binario de `target/release/` en sus
73 # fases (todo binario cargo sale de ahí). Medido: 225 recetas lo tienen, 0 con dep `go` ⇒ sin
74 # falsos positivos con Go. LÍMITE conocido: un crate BuildSys::Cargo PURO (Cargo.toml en el árbol,
75 # sin fase install propia — p.ej. zellij) no deja rastro en el TOML y cae a "c"; sólo se distingue
76 # bajando la fuente, que este generador no hace a propósito.
77 if "cargo" in blob or "cargo" in deps or "rust" in deps or "target/release" in blob:
78 return "rust"
79 if any(g in name for g in GUI_RE):
80 return "gui"
81 return "c"
82
83def load_recipes():
84 recs = {}
85 # Orden importa: primero el corpus (queue=corpus), luego las colas extra que SOMBREAN por nombre
86 # (queue=el basename de la cola), replicando el sibling-first del sandbox.
87 for root, queue in [("recipes", "corpus")] + [(q, os.path.basename(q)) for q in EXTRA_QUEUES]:
88 for f in sorted(glob.glob(f"{root}/*.toml")):
89 n = os.path.basename(f)[:-5]
90 try:
91 d = tomllib.load(open(f, "rb"))
92 except Exception as e:
93 recs[n] = dict(path=f, parse_error=str(e), queue=queue)
94 continue
95 b = d.get("build", {})
96 recs[n] = dict(
97 path=f,
98 # el artefacto se sella por el `name` INTERNO, que puede diferir del basename del
99 # fichero (qtbase.toml → name="qt6-qtbase"). El store lookup usa iname; las aristas de
100 # deps siguen usando el basename (así resuelve `resolve_dep_path`).
101 iname=d.get("name") or n,
102 version=d.get("version"),
103 link=b.get("link"),
104 compiler=b.get("compiler"),
105 deps=d.get("deps", {}).get("build", []),
106 cls=classify(n, d),
107 queue=queue,
108 )
109 return recs
110
111def vigente_hash(path):
112 try:
113 out = subprocess.run([HAMMER, "--store", STORE, "hash", path],
114 capture_output=True, text=True, timeout=60)
115 h = out.stdout.strip()
116 return h if h.startswith("b3:") else None
117 except Exception:
118 return None
119
120def state_of(iname, h):
121 if h is None:
122 return "unhashable"
123 if os.path.isdir(os.path.join(STORE, f"{h[3:]}-{iname}")):
124 return "sealed"
125 # ¿hay algún sellado histórico? distingue "cambió" de "nunca construida".
126 if glob.glob(os.path.join(STORE, f"*-{iname}")):
127 return "debt"
128 return "never"
129
130def toposort(nodes):
131 # Kahn sobre las aristas name→dep (sólo deps dentro del corpus). Devuelve orden o None si hay ciclo.
132 indeg = {n: 0 for n in nodes}
133 adj = {n: [] for n in nodes}
134 for n, r in nodes.items():
135 for dep in r.get("deps", []):
136 if dep in nodes:
137 adj[dep].append(n); indeg[n] += 1
138 q = sorted([n for n, d in indeg.items() if d == 0])
139 order = []
140 while q:
141 n = q.pop(0); order.append(n)
142 for m in sorted(adj[n]):
143 indeg[m] -= 1
144 if indeg[m] == 0: q.append(m)
145 return order if len(order) == len(nodes) else None
146
147def cargar_perfiles(colas_activas):
148 """Perfiles del manifiesto cuyas raíces viven en una cola CARGADA (así un perfil de
149 `incoming-kde` no reporta raíces fantasma en la corrida del corpus). Degrada sin romper: si el
150 manifiesto falta o está mal, el grafo sale como siempre — este script lo corre el cron cada 30m
151 y no puede caerse por un TOML."""
152 try:
153 todos = targets_mod.load()
154 except FileNotFoundError:
155 print(" (sin docs/state/targets.toml — grafo sin perfiles)", file=sys.stderr)
156 return {}
157 except Exception as e:
158 print(f" ⚠ targets.toml inválido ({e}) — grafo sin perfiles", file=sys.stderr)
159 return {}
160 return {n: p for n, p in todos.items() if p["cola"] in colas_activas}
161
162
163def clausura(raices, recs):
164 """Nodos alcanzables desde `raices` siguiendo las aristas name→dep. Un nodo `wanted` CORTA la
165 exploración (no se conocen sus deps hasta construirlo): de ahí que la clausura sea cota inferior."""
166 vistos, pila = set(), list(raices)
167 while pila:
168 n = pila.pop()
169 if n in vistos or n not in recs:
170 continue
171 vistos.add(n)
172 pila.extend(recs[n].get("deps") or [])
173 return vistos
174
175
176def main():
177 from collections import Counter
178 recs = load_recipes()
179
180 # OBJETIVO → FRONTERA. Toda raíz de un perfil sin receta entra como nodo `wanted`. Se hace ANTES
181 # de buscar huérfanas a propósito: un objetivo declarado no es una arista colgando.
182 colas = {"corpus"} | {os.path.basename(q) for q in EXTRA_QUEUES}
183 perfiles = cargar_perfiles(colas)
184 for pn, p in sorted(perfiles.items()):
185 for raiz in p["raices"]:
186 if raiz not in recs:
187 recs[raiz] = dict(path=None, wanted=True, iname=raiz, version=None, link=None,
188 compiler=None, deps=[], cls="wanted", queue=p["cola"])
189
190 orphans = set()
191 for n, r in recs.items():
192 for dep in r.get("deps", []):
193 if dep not in recs:
194 orphans.add(dep)
195
196 n_recetas = sum(1 for r in recs.values() if not r.get("wanted"))
197 print(f"== {n_recetas} recetas; computando hash vigente (~{n_recetas*18//1000}s)…", file=sys.stderr)
198 for n, r in recs.items():
199 # `wanted` no tiene fichero que hashear: es el objetivo, no una receta.
200 if r.get("wanted"):
201 r["hash"] = None; r["state"] = "wanted"; continue
202 if "parse_error" in r:
203 r["hash"] = None; r["state"] = "unhashable"; continue
204 h = vigente_hash(r["path"])
205 r["hash"] = h
206 r["state"] = state_of(r.get("iname", n), h)
207
208 order = toposort(recs)
209
210 # Lectura ACCIONABLE del grafo topológico: para cada receta, qué deps la BLOQUEAN (están en
211 # deuda) y a cuántas otras en deuda DESBLOQUEA (la tienen como dep). Una receta en deuda sin
212 # bloqueadores es construible YA; ordenar las listas por `unblocks` da el orden que libera más.
213 # `wanted` cuenta como bloqueante: una imagen que depende de algo sin receta está trabada igual.
214 DEBT_STATES = ("debt", "never", "unhashable", "wanted")
215 for n, r in recs.items():
216 r["blocked_by"] = [d for d in r.get("deps", [])
217 if d in recs and recs[d]["state"] in DEBT_STATES]
218 for n, r in recs.items():
219 # cuántas recetas EN DEUDA me declaran como dep directa (mi impacto de desbloqueo).
220 r["unblocks"] = sum(1 for m, rm in recs.items()
221 if rm["state"] in DEBT_STATES and n in rm.get("deps", []))
222
223 # MEMBRESÍA DE PERFIL: qué imágenes alcanzan cada nodo. `perfiles: []` = no lo necesita ninguna
224 # imagen declarada (catálogo, no distro) — el dato incómodo que hasta ahora nadie medía.
225 for r in recs.values():
226 r["perfiles"] = []
227 by_profile = {}
228 for pn, p in sorted(perfiles.items()):
229 cl = clausura(p["raices"], recs)
230 for n in sorted(cl):
231 recs[n]["perfiles"].append(pn)
232 by_profile[pn] = dict(raices=len(p["raices"]), clausura=len(cl),
233 **Counter(recs[n]["state"] for n in cl))
234 sin_perfil = sum(1 for r in recs.values() if not r["perfiles"])
235
236 by_state = Counter(r["state"] for r in recs.values())
237 by_cls = Counter(r["cls"] for r in recs.values() if "cls" in r)
238 # deuda por clase: dónde están los huecos.
239 debt_by_cls = Counter(r["cls"] for r in recs.values()
240 if r.get("state") in ("debt", "never") and "cls" in r)
241
242 # Reparto por cola (corpus vs incoming-kde…): dónde está cada cosa.
243 by_queue = {}
244 for r in recs.values():
245 q = r.get("queue", "corpus")
246 by_queue.setdefault(q, Counter())[r["state"]] += 1
247
248 doc = dict(
249 schema="hammer-build-state/1",
250 # `recipes` sigue contando SÓLO las que tienen fichero (los consumidores lo leen como el
251 # tamaño del corpus); `nodes` incluye la frontera `wanted`.
252 totals=dict(recipes=n_recetas, nodes=len(recs), **by_state),
253 by_class={k: by_cls[k] for k in sorted(by_cls)},
254 debt_by_class={k: debt_by_cls[k] for k in sorted(debt_by_cls)},
255 by_queue={q: dict(c) for q, c in sorted(by_queue.items())},
256 by_profile=by_profile,
257 sin_perfil=sin_perfil,
258 orphan_deps=sorted(orphans),
259 topo_ok=order is not None,
260 nodes={n: {k: v for k, v in r.items() if k not in ("path", "wanted")}
261 for n, r in sorted(recs.items())},
262 )
263 OUT.parent.mkdir(parents=True, exist_ok=True)
264 OUT.write_text(json.dumps(doc, indent=1, sort_keys=False) + "\n")
265
266 # Resumen humano a stdout.
267 print(f"\n== estado del corpus ({n_recetas} recetas) → {OUT.relative_to(ROOT)}")
268 for s in ("sealed", "debt", "never", "unhashable", "wanted"):
269 if by_state.get(s):
270 print(f" {s:11} {by_state[s]}")
271 if debt_by_cls:
272 print(" deuda por clase:", " ".join(f"{k}={v}" for k, v in sorted(debt_by_cls.items())))
273 if len(by_queue) > 1:
274 for q, c in sorted(by_queue.items()):
275 print(f" cola {q:14} sellado={c.get('sealed',0):3} deuda={c.get('debt',0):3} "
276 f"nunca={c.get('never',0):2}")
277 if by_profile:
278 print("\n== objetivo por imagen (docs/state/targets.toml) — clausura = COTA INFERIOR")
279 for pn, c in by_profile.items():
280 falta = c.get("debt", 0) + c.get("never", 0) + c.get("unhashable", 0) + c.get("wanted", 0)
281 print(f" {pn:20} {c.get('sealed',0):3}/{c['clausura']:3} listo falta {falta:3}"
282 f" (raíces {c['raices']}"
283 + (f", sin receta {c['wanted']}" if c.get("wanted") else "") + ")")
284 print(f" {sin_perfil} recetas no las alcanza ninguna imagen (catálogo, no distro)")
285 print(f"\n grafo: {'CIERRA' if not orphans else f'{len(orphans)} deps huérfanas'} | "
286 f"topo-sort: {'OK' if order else 'CICLO'}")
287 if orphans:
288 print(" huérfanas:", " ".join(sorted(orphans)))
289
290 # `wanted` NO es fallo: es trabajo declarado y pendiente. El gate sigue midiendo lo mismo que
291 # antes — aristas colgando, recetas rotas, ciclos.
292 if CHECK and (orphans or by_state.get("unhashable") or not order):
293 sys.exit(1)
294
295if __name__ == "__main__":
296 main()