#!/usr/bin/env python3 # yupana.py — la YUPANA del build: el ábaco que RECKONA sobre el estado de la distro. # # DOS OBJETOS ANDINOS, DOS ROLES. En los Andes el khipu GUARDABA (cuerdas anudadas que registran # cantidades y relaciones) y la yupana CALCULABA sobre ese registro (el ábaco inca). Acá es igual y # la distinción es la arquitectura, no adorno: # · `docs/state/build-state.json` es el KHIPU — el registro firme y versionado: los nudos # (recetas) y las cuerdas (deps), la única fuente de verdad de QUÉ hay y QUÉ falta. # · este módulo es la YUPANA — el motor que reckona sobre el khipu: a QUIÉN rompo si toco algo # (radio), qué imágenes dependen de un nudo (perfiles), en qué orden masticar (ondas). # Es la BASE metodológica y la PUERTA ÚNICA; el resto de los scripts de estado son sus órganos. # Antes este 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. # # yupana 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) # duplicados invariante (c): nudos que son el mismo paquete bajo dos nombres (por sha) (nativo) # keystones nudos en deuda que gatean el máximo trabajo bloqueado (cierre inverso, AND) (nativo) # preflight modelo EXTRA-GRAFO: ¿choca con un límite del entorno que el grafo no ve? (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/yupana.py radio [--json] # scripts/yupana.py estado [--kde] | drenar [--todos] | triaje | frontera | objetivo # Como módulo: from yupana import cargar_colas, membresia, dependientes, resolver import glob, json, os, re, 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 [] # --- INVARIANTE (c): CANÓNICO — sin duplicados accidentales entre orígenes ------------------------ # Sufijos que en hammer marcan una VARIANTE deliberada del mismo paquete (misma fuente, otra decisión # de build: estático/shared, kernel por perfil, mesa por backend). Compartir sha con estos NO es un # duplicado a fundir — es la diversificación coherente [[etapa-g-gui-chain-boundary]]. `-hello`/`-hola` # son demos que vendorean la fuente de otro a propósito. SUFIJOS_VARIANTE = ("-shared", "-static", "-libs", "-tiny", "-dev", "-headers", "-minimal", "-generic", "-metal-dual", "-metal", "-swrast", "-llvmpipe", "-hello", "-hola") def _base(n): """Reduce un nombre a su base de familia quitando sufijos de variante, repetido: `zlib-shared`→ `zlib`, `mesa-llvmpipe`→`mesa`, `linux-generic`→`linux`. NO toca el `lib` inicial a propósito (`libpng`→`png` colapsaría paquetes sin relación). Para decidir si dos nudos que comparten fuente son la MISMA familia (variante ok) o paquetes SIN relación (colisión real).""" prev = None while prev != n: prev = n for s in SUFIJOS_VARIANTE: if n.endswith(s) and len(n) > len(s): n = n[: -len(s)] return n # --- KEYSTONES: los nudos que gatean el trabajo ------------------------------------------------- # ADVERTENCIA MATEMÁTICA (medida, no supuesta). El árbol de dominadores INGENUO NO sirve acá: asume # alcanzabilidad OR (basta un camino), pero construir es AND (hacen falta TODAS las deps). `kcoreaddons` # depende de `dbus`/`libdrm`/`mesa` además de `qtbase` ⇒ en el grafo de desbloqueo hay un camino que # evita qtbase, y el dominador-OR lo pierde. La métrica correcta del "cuánto gateo" bajo AND es el # CIERRE INVERSO TRANSITIVO. Pero el crudo lo gana el toolchain (make=313, inútil): el keystone # ACCIONABLE es el cierre inverso restringido a la DEUDA — cuánto trabajo BLOQUEADO libero. El # dominador SÍ tiene un uso legítimo, otro: la RETENCIÓN (lo exclusivamente mío), abajo. # --- MODELO EXTRA-GRAFO: patrones de hueco de ENTORNO ------------------------------------------- # El grafo modela deps (R1: Receta×Receta). El entorno es OTRA capa: R2 (Receta×Capacidad, qué exige # un build) y R3 (Host×Capacidad, qué ofrece la máquina). Construible = clausura(R1) ∧ R2⊆R3. Estos # huecos viven FUERA del grafo (infra del sandbox, toolchain, layout de fs) y una matemática de grafos # sola no los ve — pero SÍ se aprenden, como patrones con predictor, calibrados contra fallos REALES. # Cada patrón: (nombre, deps) → veredicto|None. `preflight` los corre ANTES de construir. # # HOY: 1 patrón (overlay-lowerdir), la cicatriz de kio (2026-07-22). Futuros: capacidad-de-host # (rootfs laptop≠worker, techo MSRV — acumular observaciones (receta,host,lo-que-faltó)); skew de # toolchain (zig-skew — fingerprint del toolchain por artefacto). harkaq ya MIDE R2 (evidencia # negativa: qué path del entorno tocó un build); esto lo vuelve consultable + pre-vuela. # # CALIBRADO, no adivinado: el estimador daba corto hasta usar el prefijo que bwrap VE (`/oldroot/...`, # la ruta runtime del worker, no la del hub). Con eso: kio estimado 5346B vs real 5315B (31B de error). # El primer predictor "obvio" (prefijo del hub) mentía con confianza — la lección de siempre. PAGINA_OVERLAY = 4096 # tope del string de opciones de un mount overlayfs = PAGE_SIZE # Prefijo que bwrap ve en runtime (bajo /oldroot, ruta del worker). Ajustable por host. _PREF_SANDBOX = os.environ.get("PREF_STORE_SANDBOX", "/oldroot/opt/hammer/store/") _OVERHEAD_OVERLAY = len("upperdir=/tmp-overlay-upper-0,workdir=/tmp-overlay-work-0,") \ + len("/oldroot/opt/hammer/.dev-fs/alpine") + len(",userxattr") def _lowerdir_estimado(deps): """Bytes del string `lowerdir=` que bwrap arma apilando `deps` como capas overlay directas. Es la cantidad de GRAFO (nº y nombre de deps) que cruza el umbral de ENTORNO (una página).""" return _OVERHEAD_OVERLAY + sum(len(_PREF_SANDBOX) + 64 + 1 + len(n) + 1 for n in deps) def patron_overlay(nombre, deps): """¿El apilado directo de las deps desborda el mount de overlay? Si sí, la receta SÓLO monta gracias al merge de deps (cp -al a store/.dmerge, 1 sola capa). El pre-flight hace VISIBLE esa dependencia de una mitigación —y su precondición— que antes era invisible hasta que fallaba.""" est = _lowerdir_estimado(deps) if est <= PAGINA_OVERLAY: return None # entra directo, sin riesgo return dict( patron="overlay-lowerdir", detalle=f"{len(deps)} deps ⇒ lowerdir ~{est}B > {PAGINA_OVERLAY}B (una página)", riesgo="depende-del-merge", nota=("apilado directo DESBORDA ⇒ sólo monta vía el merge de deps (cp -al a store/.dmerge, " "1 capa). Precondición: .dmerge mismo-fs que el store — OK por construcción (vive DENTRO " "del store, fix 6a0dc40). Si el store cambiara de layout y el merge fallara, esta receta " "NO montaría, silenciosamente.")) PATRONES_ENTORNO = [patron_overlay] def preflight(objetivo): """Corre los patrones de entorno sobre una receta o (con --perfil) sobre todas las de una imagen, ANTES de construir. Responde la pregunta que kio contestó tarde y cara: ¿este build va a chocar con un límite del entorno que el grafo no ve?""" colas = cargar_colas() idx = {q: set(m) for q, m in colas.items()} if objetivo.startswith("perfil:"): pn = objetivo.split(":", 1)[1] p = targets_mod.load().get(pn) if not p: print(f"perfil desconocido: {pn}", file=sys.stderr); return 1 cola = p.get("cola", "corpus") nombres = [(cola if n in idx.get(cola, ()) else "corpus", n) for n in colas.get(cola, {})] if cola != "corpus" else \ [("corpus", n) for n in colas["corpus"]] # sólo los del ámbito: todas las recetas de la cola del perfil objetivos = [(q, n) for q in ({cola} | {"corpus"}) for n in colas.get(q, {})] else: ubic = [(q, objetivo) for q in colas if objetivo in colas[q]] if not ubic: print(f"no hay receta `{objetivo}` en ninguna cola", file=sys.stderr); return 1 objetivos = ubic hits = [] for (q, n) in objetivos: deps = colas[q][n] for patron in PATRONES_ENTORNO: v = patron(n, deps) if v: hits.append((f"{q}/{n}", v)) hits.sort(key=lambda h: -_lowerdir_estimado(colas[h[0].split("/")[0]][h[0].split("/", 1)[1]])) if not objetivo.startswith("perfil:"): q, n = objetivos[0] print(f"== preflight de {q}/{n} ({len(colas[q][n])} deps)") if not hits: print(" ✓ sin riesgo de entorno conocido (entra en el mount overlay directo).") for _, v in hits: print(f" ⚠ {v['patron']}: {v['detalle']}") print(f" {v['nota']}") return 0 print(f"== preflight del perfil (patrones: {', '.join(p.__name__ for p in PATRONES_ENTORNO)})") print(f" {len(hits)} recetas dependen del merge para montar (apilado directo desbordaría):") for nombre, v in hits[:25]: print(f" ⚠ {nombre:28} {v['detalle']}") print(f"\n son construibles HOY (el merge compacta), pero su montaje CUELGA de que store/.dmerge") print(f" sea mismo-fs que el store. Si un host rompe eso, fallan silenciosas — pre-vuela antes.") return 0 def _tiempos(): """nombre → mediana de segundos de build, desde `store/.times/*.json` (sidecar no determinista, lo escribe `scripts/farm/build-timed.sh` en el worker y llega con el rsync de cosecha). Vacío hasta que corran builds instrumentados; los consumidores degradan a peso=1.""" from statistics import median muestras = {} for f in glob.glob(str(ROOT / "store/.times/*.json")): try: d = json.loads(open(f).read()) except Exception: continue if d.get("name") and isinstance(d.get("seconds"), (int, float)): muestras.setdefault(d["name"], []).append(d["seconds"]) return {n: median(v) for n, v in muestras.items()} def _camino_critico(nodos, deps_debt, peso): """Longest weighted path en el subgrafo de DEUDA (DP sobre topológico). El peso de un nudo = sus segundos de build (1 si no hay dato). La suma sobre la cadena más larga = ETA con paralelismo infinito: hay que construir esa cadena en SERIE. Devuelve (segundos, secuencia).""" memo, camino = {}, {} def cp(n): if n in memo: return memo[n] mejor, via = 0, None for d in deps_debt.get(n, ()): c = cp(d) if c > mejor: mejor, via = c, d memo[n] = peso(n) + mejor camino[n] = via return memo[n] for n in nodos: cp(n) if not memo: return 0, [] fin = max(memo, key=memo.get) seq = [] x = fin while x is not None: seq.append(x); x = camino.get(x) return memo[fin], list(reversed(seq)) def _cierre_inverso(objetivo, rev): """Nodos que dependen de `objetivo`, transitivamente (semántica AND: si X me tiene en su clausura, X no se construye sin mí).""" vis, pila = set(), [objetivo] while pila: x = pila.pop() for c in rev.get(x, ()): if c not in vis: vis.add(c); pila.append(c) return vis def _dominador_retencion(colas, perfiles): """RETENCIÓN estilo GC: para cada nudo, cuántos otros son EXCLUSIVAMENTE suyos — sólo alcanzables (como dependencia, desde las raíces de las imágenes) a través de él. Este SÍ es el árbol de dominadores, en su uso correcto: 'si suelto este nudo, cuánto más puedo soltar'. Grafo consumidor→dep, raíz virtual sobre todas las raíces de perfil; retención = tamaño del subárbol de dominadores. Iterativo (Cooper-Harvey-Kennedy), suficiente para ~1000 nudos.""" idx = {q: set(m) for q, m in colas.items()} R = ("__raiz__", "") succ = {R: set()} for pn, p in perfiles.items(): for raiz in p["raices"]: nodo = resolver(raiz, p.get("cola", "corpus"), idx) if nodo: succ[R].add(nodo) # aristas consumidor→dep for q, miembros in colas.items(): for n in miembros: nodo = (q, n) succ.setdefault(nodo, set()) for d in miembros[n]: r = resolver(d, q, idx) if r: succ[nodo].add(r) # sólo lo alcanzable desde R orden, vis = [], set() def dfs(u): vis.add(u) for v in succ.get(u, ()): if v not in vis: dfs(v) orden.append(u) sys.setrecursionlimit(10000) dfs(R) rpo = list(reversed(orden)) pos = {n: i for i, n in enumerate(rpo)} preds = {} for u in succ: for v in succ[u]: preds.setdefault(v, set()).add(u) idom = {R: R} def inter(a, b): while a != b: while pos[a] > pos[b]: a = idom[a] while pos[b] > pos[a]: b = idom[b] return a cambio = True while cambio: cambio = False for u in rpo: if u == R: continue ps = [p for p in preds.get(u, ()) if p in idom] if not ps: continue nuevo = ps[0] for p in ps[1:]: nuevo = inter(nuevo, p) if idom.get(u) != nuevo: idom[u] = nuevo; cambio = True # tamaño de subárbol (retención) = cuántos nodos domina cada uno hijos = {} for n, d in idom.items(): if n != d: hijos.setdefault(d, []).append(n) ret = {} def subtam(u): t = 0 for h in hijos.get(u, ()): t += 1 + subtam(h) ret[u] = t return t subtam(R) return ret def keystones(como_json=False): """Los nudos que GATEAN más trabajo. Métrica accionable: cierre inverso transitivo restringido a la DEUDA (cuánto bloqueado libero al sellar esto). Segunda columna: RETENCIÓN por dominadores (lo exclusivamente mío). El grafo elegido es el que más deuda tiene (KDE si existe).""" from collections import Counter colas = cargar_colas() rev = dependientes(colas) perfiles = targets_mod.load() # estados desde el khipu más completo estados = {} for f in ("docs/state/build-state-kde.json", "docs/state/build-state-gnome.json", "docs/state/build-state.json"): p = ROOT / f if p.exists(): for n, r in json.loads(p.read_text())["nodes"].items(): estados.setdefault(n, r.get("state", "?")) DEUDA = ("debt", "never", "unhashable", "wanted") ret = _dominador_retencion(colas, perfiles) filas = [] for nodo in list(rev): cerr = _cierre_inverso(nodo, rev) en_deuda = sum(1 for (q, n) in cerr if estados.get(n) in DEUDA) q, n = nodo yo_deuda = estados.get(n) in DEUDA filas.append(dict(nudo=f"{q}/{n}", gateo_total=len(cerr), gateo_en_deuda=en_deuda, exclusivo=ret.get(nodo, 0), en_deuda=yo_deuda)) # KEYSTONE ACCIONABLE: un nodo SELLADO ya está disponible ⇒ construirlo no libera nada (make gatea # 65 en deuda pero está hecho). El que importa está EN DEUDA y gatea el máximo trabajo bloqueado. # Orden: primero los en-deuda, por gateo-en-deuda desc. Los sellados quedan como contexto (su # retención `exclusivo` sí es info válida, pero no son trabajo). filas.sort(key=lambda r: (not r["en_deuda"], -r["gateo_en_deuda"], -r["gateo_total"])) accionables = [r for r in filas if r["en_deuda"]] # CAMINO CRÍTICO PESADO: subgrafo de deuda, aristas dep entre nodos en deuda, peso = segundos de # build. La cadena más larga = ETA con paralelismo infinito. Sin datos de tiempo, peso=1 y el # camino crítico = nº de pasos (lo que ya daban las ondas). Con datos, es la ETA real en segundos. tiempos = _tiempos() idx = {q: set(m) for q, m in colas.items()} deuda_set = {(q, n) for q in colas for n in colas[q] if estados.get(n) in DEUDA} deps_debt = {} for (q, n) in deuda_set: dd = set() for d in colas.get(q, {}).get(n, []): r = resolver(d, q, idx) if r in deuda_set: dd.add(r) deps_debt[(q, n)] = dd def peso(nodo): return tiempos.get(nodo[1], 1 if not tiempos else _mediana_o_uno(tiempos)) seg, seq = _camino_critico(deuda_set, deps_debt, peso) cubiertos = sum(1 for nodo in deuda_set if nodo[1] in tiempos) critico = dict(con_datos=bool(tiempos), segundos=seg if tiempos else None, pasos=len(seq), cobertura=f"{cubiertos}/{len(deuda_set)}", secuencia=[f"{q}/{n}" for q, n in seq]) doc = dict(schema="hammer-keystones/3", nudos=filas[:80], en_deuda=len(accionables), camino_critico=critico) (ROOT / "docs/state/keystones.json").write_text(json.dumps(doc, indent=1, ensure_ascii=False) + "\n") if como_json: print(json.dumps(doc, indent=1, ensure_ascii=False)); return 0 print("== KEYSTONES — nudos EN DEUDA que gatean el máximo trabajo bloqueado (semántica AND)") print(" 'gateo en deuda' = otro trabajo bloqueado que se libera al sellar ESTE. La métrica accionable:") print(" sellá de arriba hacia abajo y la cascada cae lo antes posible.\n") print(f" {'nudo (en deuda)':30} {'gateo-deuda':>11} {'gateo-total':>11} {'exclusivo':>9}") if not accionables: print(" (ningún nodo en deuda — todas las imágenes en ámbito están completas)") for r in accionables[:20]: print(f" {r['nudo']:30} {r['gateo_en_deuda']:11} {r['gateo_total']:11} {r['exclusivo']:9}") print(f"\n {len(accionables)} nudos en deuda en total") print(f"\n== CAMINO CRÍTICO (la cadena más larga; hay que construirla en SERIE aunque haya ∞ workers)") if critico["con_datos"]: m = critico["segundos"] // 60 print(f" ETA (paralelismo infinito): {m} min sobre {critico['pasos']} pasos " f"· cobertura de tiempos {critico['cobertura']}") else: print(f" {critico['pasos']} pasos (SIN datos de tiempo aún ⇒ peso=1; instrumentá builds en") print(f" el worker con campana-deuda.sh y el rsync de cosecha trae store/.times).") if seq: muestra = " → ".join(n.split("/")[-1] for n in critico["secuencia"][:8]) print(f" {muestra}" + (" → …" if len(seq) > 8 else "")) print(f"\n → docs/state/keystones.json") print(" nota: dominador-OR ingenuo NO sirve (build es AND); 'exclusivo' SÍ es dominador (retención).") return 0 def _mediana_o_uno(tiempos): from statistics import median return median(tiempos.values()) if tiempos else 1 def _fuente(f): """(sha, url, version, build_fp) de una receta. Los tres primeros = identidad de lo que BAJA. El cuarto = HUELLA DE BUILD (link, compiler, fases) — porque dos recetas con la MISMA fuente pero build distinto NO son redundantes: son variantes deliberadas (corpus/dbus estático vs incoming-kde/dbus dinámico para Qt6DBus). Ignorar esto hacía que el detector marcara `dbus` como sombra redundante y casi borra una receta load-bearing (2026-07-22).""" try: d = tomllib.load(open(f, "rb")) except Exception: return (None, "", None, None, False) s = d.get("source", {}) b = d.get("build", {}) ph = b.get("phases", {}) or {} build_fp = (b.get("link"), b.get("compiler"), tuple(sorted((k, str(v)) for k, v in ph.items()))) # ¿construye la FUENTE descargada, o es un CARRIER (stub/demo que genera todo inline y sólo presta # el tarball)? Señal: invoca un sistema de build. itstool/-hello no ⇒ carrier; su colisión de sha # es esperada, no un error de copiado. (2026-07-22: sin esto el detector marcaba 4 falsos bugs.) blob = "\n".join(str(ph.get(k, "")) for k in ("configure", "compile", "install")) # Strip comentarios shell (`# …` a fin de línea) ANTES de buscar: si no, la prosa de un stub # ("# parsea la invocación de meson") matchea y marca al carrier como builder (falso positivo # que el guardián cazó, 2026-07-22). sin_comentarios = re.sub(r"#.*", "", blob) construye = any(re.search(rx, sin_comentarios) for rx in (r"\bmake\b", r"\bmeson\b", r"\bcmake\b", r"\bninja\b", r"\bcargo\b", r"\./configure", r"\bautogen", r"\bautoreconf")) return (s.get("sha256") or s.get("output_hash"), s.get("tarball") or s.get("url") or s.get("git") or "", d.get("version"), build_fp, construye) def _recetas_con_fuente(): """{(cola, nombre): {sha, url, version, build_fp, construye_fuente, deps}} sobre todo el disco.""" out = {} for pat, es_incoming in (("recipes/*.toml", False), ("recipes/incoming-*/*.toml", True)): for f in sorted(glob.glob(str(ROOT / pat))): n = os.path.basename(f)[:-5] q = os.path.basename(os.path.dirname(f)) if es_incoming else "corpus" sha, url, ver, build_fp, construye = _fuente(f) out[(q, n)] = dict(sha=sha, url=url, version=ver, build_fp=build_fp, construye_fuente=construye, deps=_deps(f)) return out def duplicados(como_json=False): """INVARIANTE (c): detecta nudos que son el MISMO paquete bajo nombres/colas distintos, por EVIDENCIA de fuente (sha/url), no por nombre. Clasifica cada grupo: colisión-fuente — mismo sha, nombres de familias DISTINTAS ⇒ casi seguro un sha copiado por error (itstool tenía el tarball de gettext-tiny). ACCIONABLE. variante — mismo sha, misma familia (`zlib`/`zlib-shared`, `mesa-*`) ⇒ deliberado, OK. sombra — mismo NOMBRE en >1 cola. Redundante si versión+sha+deps coinciden; si no, es una variante justificada de la cola. NO funde nada solo: reporta y deja el juicio al humano (como el triaje). El radio de cada nudo ayuda a elegir el canónico.""" recs = _recetas_con_fuente() from collections import defaultdict rev = dependientes() # 1) grupos por sha (identidad de fuente) por_sha = defaultdict(list) for k, r in recs.items(): if r["sha"]: por_sha[r["sha"]].append(k) colisiones, variantes, carriers = [], [], [] for sha, ks in por_sha.items(): nombres = {n for _, n in ks} if len(nombres) < 2: continue grupo = dict(sha=sha[:16], nudos=sorted(f"{q}/{n}" for q, n in ks), radio={f"{q}/{n}": len(rev.get((q, n), ())) for q, n in ks}) builders = [k for k in ks if recs[k]["construye_fuente"]] familias = {_base(n) for _, n in ks} # Clasificación por evidencia, de más benigno a más sospechoso: if len(builders) <= 1: # ≤1 receta construye la fuente ⇒ el resto son CARRIERS que le prestan el tarball. Benigno. grupo["carriers"] = sorted(f"{q}/{n}" for (q, n) in ks if not recs[(q, n)]["construye_fuente"]) carriers.append(grupo) elif len(familias) == 1: variantes.append(grupo) # misma familia (zlib/zlib-shared): variante deliberada elif len({recs[k]["build_fp"] for k in builders}) > 1: variantes.append(grupo) # familias distintas pero BUILD distinto: variante cross-nombre else: # ≥2 builders, MISMO build, familias distintas ⇒ sospechoso colisiones.append(grupo) # 2) sombras: mismo nombre en >1 cola por_nombre = defaultdict(list) for (q, n) in recs: por_nombre[n].append(q) sombras = [] for n, qs in por_nombre.items(): if len(qs) < 2: continue # Redundante SÓLO si coinciden fuente Y BUILD: mismo tarball no basta, dos builds distintos # del mismo source (estático vs dinámico) son variantes deliberadas, no redundancia. firmas = {(recs[(q, n)]["version"], recs[(q, n)]["sha"], recs[(q, n)]["build_fp"], tuple(sorted(recs[(q, n)]["deps"]))) for q in qs} sombras.append(dict(nombre=n, colas=sorted(qs), redundante=len(firmas) == 1, # idénticas en fuente+build ⇒ una sobra radio={f"{q}/{n}": len(rev.get((q, n), ())) for q in qs})) doc = dict(schema="hammer-duplicados/2", colisiones_fuente=sorted(colisiones, key=lambda g: -max(g["radio"].values() or [0])), variantes=sorted(variantes, key=lambda g: g["nudos"]), carriers=sorted(carriers, key=lambda g: g["nudos"]), sombras=sorted(sombras, key=lambda s: (not s["redundante"], s["nombre"]))) (ROOT / "docs/state/duplicados.json").write_text( json.dumps(doc, indent=1, ensure_ascii=False) + "\n") if como_json: print(json.dumps(doc, indent=1, ensure_ascii=False)) return 0 red = [s for s in sombras if s["redundante"]] print(f"== INVARIANTE (c) CANÓNICO — duplicados por evidencia de fuente\n") print(f"⚠ COLISIONES DE FUENTE REALES: {len(colisiones)} (≥2 recetas construyen el MISMO source,") print(f" mismo build, familias distintas — casi seguro un sha copiado por error)") for g in doc["colisiones_fuente"]: print(f" sha {g['sha']}… {g['nudos']}") print(f" radio: " + " ".join(f"{k.split('/')[-1]}={v}" for k, v in g["radio"].items())) if not colisiones: print(" (ninguna — el invariante (c) se cumple)") print(f"\n⚠ SOMBRAS REDUNDANTES: {len(red)} (mismo nombre+fuente+BUILD en 2 colas — una sobra)") for s in red: print(f" {s['nombre']:24} {s['colas']}") if not red: print(" (ninguna)") print(f"\n✓ BENIGNOS (no son problema):") print(f" {len(carriers)} grupos CARRIER (stub/demo que presta el tarball: itstool, *-hello)") print(f" {len(variantes)} variantes deliberadas (misma fuente, build/familia distintos: zlib-shared, prison-scanner)") print(f" {len(sombras)-len(red)} sombras justificadas (mismo nombre, build distinto: dbus estático/dinámico)") print(f"\n → docs/state/duplicados.json") return 0 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-gnome.json" if q == "incoming-gnome" else "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: yupana 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 == "duplicados": return duplicados(como_json="--json" in extra) if cmd == "keystones": return keystones(como_json="--json" in extra) if cmd == "preflight" and extra: obj = f"perfil:{extra[extra.index('--perfil') + 1]}" if "--perfil" in extra else extra[0] return preflight(obj) 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())