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.
784 lines
38 KiB
Python
Executable File
784 lines
38 KiB
Python
Executable File
#!/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 <receta> ¿a QUIÉN rompo si toco esto? (nativo; cruza TODAS las colas) ← ANTES de cambiar nada
|
||
# perfiles <receta> ¿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 <r> 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 <perfil> 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
|
||
#
|
||
# La ayuda operativa vive en el DOCSTRING de abajo y no en estos comentarios, por una razón medida:
|
||
# `main()` hace `print(__doc__)` para `--help` y para un verbo desconocido, y mientras todo el
|
||
# encabezado fue `#` eso imprimía literalmente `None`. La puerta única de la metodología no sabía
|
||
# explicarse, y el error de verbo desconocido tampoco decía cuáles son los verbos. El diseño y el
|
||
# porqué se quedan arriba como comentario; lo que un humano necesita para USARLO va en el docstring.
|
||
"""yupana — el ábaco del build: reckona sobre el khipu (docs/state/build-state.json).
|
||
|
||
Verbos, en el orden del ciclo:
|
||
|
||
radio <receta> [--json] ¿a QUIÉN rompo si toco esto? cruza TODAS las colas ← ANTES de cambiar nada
|
||
perfiles <receta> ¿qué imágenes dependen de esto?
|
||
duplicados [--json] el mismo paquete bajo dos nombres (por sha)
|
||
keystones [--json] nudos en deuda que gatean el máximo trabajo bloqueado
|
||
preflight <receta> ¿choca con un límite del entorno que el grafo no ve?
|
||
estado [--kde|--gnome|--cosmic|--wlr] regenera el grafo firme
|
||
objetivo qué debe tener cada imagen
|
||
frontera <perfil> qué pide upstream y no tenemos
|
||
triaje clasificar la frontera
|
||
sembrar <perfil> sembrar las recetas que la frontera reveló
|
||
drenar [--todos|--perfil <p>] el orden de masticado, en ondas
|
||
|
||
Uso: scripts/yupana.py radio <receta> [--json]
|
||
scripts/yupana.py estado [--kde] | drenar [--todos] | triaje | frontera <perfil> | 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-<x>` = recipes/incoming-<x>/. 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:
|
||
d = tomllib.load(open(f, "rb")).get("deps", {})
|
||
# ⚠ BUILD **+ RUNTIME** (2026-09-06). Leía sólo `build`, y por eso una dep de EJECUCIÓN no
|
||
# existía para NINGUNA herramienta que use yupana —el vigía de sonames, la membresía de
|
||
# perfiles, el grafo de estado—. Medido: `firefox` declaraba `runtime = ["gcc-libs"]` y el
|
||
# cierre de las cuatro imágenes seguía sin `libstdc++.so.6`, así que el navegador no
|
||
# arrancaba y el vigía lo seguía reportando como hueco después de haberlo arreglado.
|
||
# `deps.runtime` está en el esquema de takana desde siempre; lo que faltaba era que ALGUIEN
|
||
# lo leyera. Un campo que nadie lee es un campo que miente.
|
||
# La unión es la definición de cierre: para CORRER hacen falta las dos.
|
||
return sorted(set(d.get("build", []) or []) | set(d.get("runtime", []) or []))
|
||
except Exception:
|
||
return []
|
||
|
||
|
||
# --- INVARIANTE (c): CANÓNICO — sin duplicados accidentales entre orígenes ------------------------
|
||
# Sufijos que en takana 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 _khipus():
|
||
"""TODOS los grafos de estado del disco, descubiertos y no escritos a mano.
|
||
|
||
La lista estaba codificada como `(kde, gnome, corpus)` en dos sitios y **omitía cosmic y wlr**,
|
||
que son dos de los cuatro escritorios. Es la misma forma del fallo que motivó este fichero (ver
|
||
la cabecera): medir contra un subconjunto de las colas y que el número salga chico sin que nada
|
||
avise. Un grafo nuevo entra solo; ninguno se puede olvidar.
|
||
"""
|
||
return sorted(glob.glob(str(ROOT / "docs/state/build-state*.json")))
|
||
|
||
|
||
def _estados_por_cola():
|
||
"""`{(cola, nombre): estado}` sobre todos los khipus.
|
||
|
||
Va por (COLA, nombre) y no por nombre: el mismo nombre vive en varias colas con recetas y hashes
|
||
DISTINTOS —`gdk-pixbuf` está en corpus, en incoming-kde y en incoming-wlr— así que un índice por
|
||
nombre devuelve el estado de otra receta con toda confianza. Eso es peor que no saber.
|
||
"""
|
||
estados = {}
|
||
for f in _khipus():
|
||
try:
|
||
nodos = json.loads(Path(f).read_text())["nodes"]
|
||
except Exception:
|
||
continue
|
||
for n, r in nodos.items():
|
||
q = r.get("queue")
|
||
if q:
|
||
estados.setdefault((q, n), r.get("state", "?"))
|
||
return estados
|
||
|
||
|
||
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 TODOS los khipus, indexados por (cola, nombre)
|
||
estados = _estados_por_cola()
|
||
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 nd in cerr if estados.get(nd) in DEUDA)
|
||
q, n = nodo
|
||
yo_deuda = estados.get(nodo) 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((q, 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."""
|
||
return _estados_por_cola().get(nodo, "?")
|
||
|
||
|
||
def radio(nombre, como_json=False):
|
||
"""¿A QUIÉN rompo si re-hasheo `recipes/**/<nombre>.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())
|