diff --git a/scripts/build-state.py b/scripts/build-state.py index f1df190a..02118fbd 100755 --- a/scripts/build-state.py +++ b/scripts/build-state.py @@ -40,6 +40,7 @@ from pathlib import Path sys.path.insert(0, str(Path(__file__).resolve().parent)) import targets as targets_mod +import khipu # el motor de dependencias INVERSAS: cruza TODAS las colas (ver por qué en su cabecera) ROOT = Path(__file__).resolve().parent.parent os.chdir(ROOT) @@ -220,18 +221,29 @@ def main(): 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: qué imágenes alcanzan cada nodo. `perfiles: []` = no lo necesita ninguna - # imagen declarada (catálogo, no distro) — el dato incómodo que hasta ahora nadie medía. - for r in recs.values(): - r["perfiles"] = [] + # 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. khipu las ve + # siempre y resuelve sibling-first ⇒ el nodo se identifica por (cola, nombre). Ver khipu.py. + todas = khipu.cargar_colas() + memb = khipu.membresia(todas, cargar_perfiles({q for q in todas})) # TODOS los perfiles, no sólo los de esta vista + rev = khipu.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()): - cl = clausura(p["raices"], recs) - for n in sorted(cl): - recs[n]["perfiles"].append(pn) - by_profile[pn] = dict(raices=len(p["raices"]), clausura=len(cl), - **Counter(recs[n]["state"] for n in cl)) - sin_perfil = sum(1 for r in recs.values() if not r["perfiles"]) + 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) diff --git a/scripts/khipu.py b/scripts/khipu.py new file mode 100755 index 00000000..f76c7891 --- /dev/null +++ b/scripts/khipu.py @@ -0,0 +1,230 @@ +#!/usr/bin/env python3 +# khipu.py — el KHIPU del build: el registro vivo del estado de la distro y el motor de consultas +# sobre su grafo. Es la BASE metodológica; el resto de los scripts de estado son sus órganos. +# +# QUÉ ES UN KHIPU. El dispositivo andino de contabilidad: cuerdas anudadas que registran cantidades +# y relaciones. Este es el khipu del build — cada receta es un nudo, cada dep una cuerda, y el +# conjunto es la única fuente de verdad sobre QUÉ hay, QUÉ falta, QUIÉN depende de quién y A QUIÉN +# rompo si toco algo. Antes ese saber vivía disperso (en la cabeza, en `grep`, en docs que +# envejecen) y cada medición a mano mentía distinto. +# +# POR QUÉ EXISTE ESTE FICHERO Y NO SÓLO build-state.py. El 2026-07-22 un cambio a `libdrm` +# (default_library=both, para gtk4) invalidó 118 recetas KDE. El radio se midió contra +# `recipes/*.toml` y NO contra `recipes/incoming-kde/` ⇒ dio 8 en vez de 118. `build-state.py` sin +# `--kde` cometía EXACTAMENTE el mismo error: cargaba sólo el corpus, y su campo `perfiles` llegaba +# a AFIRMAR que tocar `libdrm` sólo afectaba a `escritorio-mirada` — una mentira con confianza. La +# causa es estructural: las dependencias INVERSAS son un hecho del disco entero, no de la vista que +# uno cargó. Este módulo las calcula SIEMPRE sobre TODAS las colas, y es de donde build-state saca +# `perfiles`/`dependientes`. La regla, en una línea: +# +# QUÉ NODOS PUNTÚO es una decisión de vista. QUIÉN ME CONSUME es un hecho — nunca depende de la vista. +# +# RESOLUCIÓN SIBLING-FIRST. Una dep `libdrm` desde una receta de `incoming-kde` resuelve a +# `incoming-kde/libdrm` si existe, y si no cae al `corpus/libdrm` (igual que el sandbox al montar las +# capas). Por eso los nodos se identifican por PAR `(cola, nombre)`, no por nombre: `corpus/fontconfig` +# y `incoming-kde/fontconfig` son nudos distintos y romper uno no rompe a los consumidores del otro. +# +# khipu es la PUERTA ÚNICA de la metodología. Sus verbos son los órganos del ciclo, en el orden en +# que se usan (el mismo que la cadena del plan docs/plan-catalogo-objetivo.md): +# +# radio ¿a QUIÉN rompo si toco esto? (nativo; cruza TODAS las colas) ← ANTES de cambiar nada +# perfiles ¿qué imágenes dependen de esto? (nativo) +# estado regenera el grafo firme → build-state.py +# objetivo qué debe tener cada imagen → targets.py --lista +# frontera qué pide upstream y no tenemos → seed-graph.py --frontera +# triaje clasificar la frontera → triaje.py +# drenar [--perfil] el orden de masticado, en ondas → drenar.py +# +# Uso: scripts/khipu.py radio [--json] +# scripts/khipu.py estado [--kde] | drenar [--todos] | triaje | frontera | objetivo +# Como módulo: from khipu import cargar_colas, membresia, dependientes, resolver +import glob, json, os, subprocess, sys, tomllib +from pathlib import Path + +ROOT = Path(__file__).resolve().parent.parent +sys.path.insert(0, str(ROOT / "scripts")) +import targets as targets_mod + + +def cargar_colas(): + """{cola: {nombre: [deps de build]}} sobre TODAS las colas del disco. `corpus` = recipes/; + `incoming-` = recipes/incoming-/. Es el grafo completo, sin recortar por vista.""" + colas = {} + for f in sorted(glob.glob(str(ROOT / "recipes/*.toml"))): + n = os.path.basename(f)[:-5] + colas.setdefault("corpus", {})[n] = _deps(f) + for f in sorted(glob.glob(str(ROOT / "recipes/incoming-*/*.toml"))): + q = os.path.basename(os.path.dirname(f)) + colas.setdefault(q, {})[os.path.basename(f)[:-5]] = _deps(f) + return colas + + +def _deps(f): + try: + return tomllib.load(open(f, "rb")).get("deps", {}).get("build", []) or [] + except Exception: + return [] + + +def resolver(dep, desde_cola, indice): + """Sibling-first: (cola, nombre) del nudo que provee `dep` visto desde `desde_cola`, o None si + ninguna cola lo tiene (dep externa / nodo `wanted`). `indice` = {cola: set(nombres)}.""" + if dep in indice.get(desde_cola, ()): + return (desde_cola, dep) + if dep in indice.get("corpus", ()): + return ("corpus", dep) + return None + + +def dependientes(colas=None): + """{(cola,nombre): set((cola,nombre) que lo consumen)} — el mapa de dependencias INVERSAS sobre + el grafo entero. Este es el cálculo que, hecho sobre una sola cola, produjo el bug de libdrm.""" + colas = colas or cargar_colas() + indice = {q: set(m) for q, m in colas.items()} + rev = {} + for q, miembros in colas.items(): + for nombre, deps in miembros.items(): + for d in deps: + objetivo = resolver(d, q, indice) + if objetivo: + rev.setdefault(objetivo, set()).add((q, nombre)) + return rev + + +def membresia(colas=None, perfiles=None): + """{(cola,nombre): set(perfiles que lo alcanzan)} sobre el grafo entero, respetando sibling-first. + Un nudo pertenece a una imagen si la clausura de sus raíces —resolviendo cada cuerda desde la + cola del consumidor— llega hasta él. Esto es lo que arregla la mentira de `perfiles`.""" + colas = colas or cargar_colas() + perfiles = perfiles if perfiles is not None else targets_mod.load() + indice = {q: set(m) for q, m in colas.items()} + memb = {} + for pn, p in perfiles.items(): + cola_p = p.get("cola", "corpus") + vistos, pila = set(), [] + for raiz in p["raices"]: + nodo = resolver(raiz, cola_p, indice) + pila.append(nodo if nodo else ("wanted", raiz)) # raíz sin receta = nodo wanted + while pila: + nodo = pila.pop() + if nodo in vistos: + continue + vistos.add(nodo) + memb.setdefault(nodo, set()).add(pn) + q, nombre = nodo + if q == "wanted": + continue + for d in colas.get(q, {}).get(nombre, []): + sig = resolver(d, q, indice) + pila.append(sig if sig else ("wanted", d)) + return memb + + +def _estado(nodo): + """Estado del nudo desde el grafo firme, si está regenerado. Best-effort: el radio funciona sin él.""" + q, nombre = nodo + fichero = "docs/state/build-state-kde.json" if q != "corpus" else "docs/state/build-state.json" + try: + d = json.loads((ROOT / fichero).read_text()) + return d["nodes"].get(nombre, {}).get("state", "?") + except Exception: + return "?" + + +def radio(nombre, como_json=False): + """¿A QUIÉN rompo si re-hasheo `recipes/**/.toml`? La pregunta que, mal medida, costó 118 + recetas. Cruza TODAS las colas por construcción: es imposible que 'olvide' una cola.""" + colas = cargar_colas() + indice = {q: set(m) for q, m in colas.items()} + # ¿En qué cola vive la receta que se va a tocar? Puede estar en varias (variante sombreada). + ubic = [(q, nombre) for q in colas if nombre in colas[q]] + if not ubic: + print(f"no hay receta `{nombre}` en ninguna cola", file=sys.stderr) + return 1 + rev = dependientes(colas) + memb = membresia(colas, targets_mod.load()) + + resultado = {} + for nodo in ubic: + directos = sorted(rev.get(nodo, set())) + # Cierre inverso: quién depende, transitivamente (el radio real de un re-hash en cascada). + vistos, pila = set(), list(rev.get(nodo, set())) + while pila: + n = pila.pop() + if n in vistos: + continue + vistos.add(n) + pila.extend(rev.get(n, set())) + imgs = set() + for n in vistos | {nodo}: + imgs |= memb.get(n, set()) + # Cuántos de los transitivos YA están sellados: ésos son los que HOY están al día y el + # re-hash MANDA A DEUDA (los que ya están en deuda igual habría que reconstruirlos). El + # radio verdadero de rebuild forzado por el cambio. + sellados = sum(1 for n in vistos if _estado(n) == "sealed") + resultado[f"{nodo[0]}/{nodo[1]}"] = dict( + directos=len(directos), transitivos=len(vistos), + imagenes=sorted(imgs), sellados_que_caen=sellados, + dependientes_por_cola=_por_cola(vistos)) + + if como_json: + print(json.dumps(resultado, indent=1, ensure_ascii=False)) + return 0 + for nodo, r in resultado.items(): + print(f"== radio de tocar {nodo}") + print(f" dependientes directos {r['directos']}") + print(f" dependientes transitivos {r['transitivos']} (el radio real de un re-hash en cascada)") + print(f" por cola " + + " ".join(f"{q}={n}" for q, n in sorted(r["dependientes_por_cola"].items()))) + print(f" IMÁGENES afectadas {', '.join(r['imagenes']) or '(ninguna declarada)'}") + print(f" sellados que CAEN a deuda {r['sellados_que_caen']} (hoy al día; el re-hash los fuerza a rebuild)") + if len(resultado) > 1: + print() + print("\n regla: esto cruza TODAS las colas. NO midas radio con `grep recipes/*.toml`.") + return 0 + + +def _por_cola(nodos): + from collections import Counter + return dict(Counter(q for q, _ in nodos)) + + +# Delegación a los órganos: khipu es la puerta, cada script sigue siendo el músculo. Se delega por +# subproceso (no import) para no acoplar sus ciclos de vida ni sus flags — cada uno evoluciona solo. +ORGANOS = { + "estado": ["build-state.py"], + "drenar": ["drenar.py"], + "triaje": ["triaje.py"], + "frontera": ["seed-graph.py", "--frontera"], + "objetivo": ["targets.py", "--lista"], + "sembrar": ["seed-graph.py", "--perfil"], +} + + +def delegar(cmd, extra): + return subprocess.call([sys.executable, str(ROOT / "scripts" / ORGANOS[cmd][0])] + + ORGANOS[cmd][1:] + extra) + + +def main(): + args = sys.argv[1:] + if not args or args[0] in ("-h", "--help"): + print(__doc__) + return 0 + cmd, extra = args[0], args[1:] + if cmd == "radio" and extra: + return radio(extra[0], como_json="--json" in extra) + if cmd == "perfiles" and extra: + memb = membresia() + hits = {f"{q}/{n}": sorted(ps) for (q, n), ps in memb.items() if n == extra[0]} + print(json.dumps(hits, indent=1, ensure_ascii=False) if hits + else f"`{extra[0]}` no lo alcanza ninguna imagen declarada") + return 0 + if cmd in ORGANOS: + return delegar(cmd, extra) + print(f"verbo desconocido: {cmd}\n{__doc__}", file=sys.stderr) + return 1 + + +if __name__ == "__main__": + sys.exit(main())