Faltaba la mitad del método. SÍ cruzaba incoming-cosmic (lo usé antes de tocar libdisplay-info, pipewire y glib), pero la campaña no estaba en el KHIPU: build-state.py sólo conocía --kde y --gnome, y targets.toml no declaraba perfil. Y eso no era cosmético. Antes de este commit, decía 21 dependientes transitivos y dos imágenes; ahora dice 31 y TRES — escritorio-cosmic entre ellas, con incoming-cosmic=10 en el reparto por cola. O sea que el próximo que tocara libinput, libudev-zero o mesa habría MEDIDO DE MENOS, que es exactamente el punto ciego que la metodología existe para cerrar. Tres piezas: - build-state.py --cosmic (y su build-state-cosmic.json). - perfil.escritorio-cosmic en targets.toml, con las raíces que cosmic-session levanta MÁS los datos que ningún [deps] alcanza (iconos, xkb, fuentes, bash, dbus). Primera medición: 54/61 listo, faltan 7. - el LATIDO lo regenera, por la misma razón por la que se le agregó GNOME en su momento: un frente que el cron no regenera envejece en silencio, y un grafo viejo miente con la misma cara que uno fresco. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
322 lines
17 KiB
Python
Executable File
322 lines
17 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, 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
|
|
EXTRA_QUEUES = (["recipes/incoming-kde"] if KDE else []) + (["recipes/incoming-gnome"] if GNOME else []) \
|
|
+ (["recipes/incoming-cosmic"] if COSMIC else [])
|
|
_var = "-kde" if KDE else "-gnome" if GNOME else "-cosmic" if COSMIC 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
|
|
|
|
def state_of(iname, h):
|
|
if h is None:
|
|
return "unhashable"
|
|
if os.path.isdir(os.path.join(STORE, f"{h[3:]}-{iname}")):
|
|
return "sealed"
|
|
# ¿hay algún sellado histórico? distingue "cambió" de "nunca construida".
|
|
if glob.glob(os.path.join(STORE, f"*-{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`.
|
|
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 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()
|