sergio/takana
> Una distribución Linux construida con un laboratorio funcional y hermético en el > sótano, y una terminal mutable, clásica y anárquica en el piso de arriba — diseñada > desde el suelo para que una IA programadora entienda, modifique y co…
yupana: keystones — el camino crítico real, con la matemática correcta (AND, no OR)
1#!/usr/bin/env python3
2# yupana.py — la YUPANA del build: el ábaco que RECKONA sobre el estado de la distro.
3#
4# DOS OBJETOS ANDINOS, DOS ROLES. En los Andes el khipu GUARDABA (cuerdas anudadas que registran
5# cantidades y relaciones) y la yupana CALCULABA sobre ese registro (el ábaco inca). Acá es igual y
6# la distinción es la arquitectura, no adorno:
7# · `docs/state/build-state.json` es el KHIPU — el registro firme y versionado: los nudos
8# (recetas) y las cuerdas (deps), la única fuente de verdad de QUÉ hay y QUÉ falta.
9# · este módulo es la YUPANA — el motor que reckona sobre el khipu: a QUIÉN rompo si toco algo
10# (radio), qué imágenes dependen de un nudo (perfiles), en qué orden masticar (ondas).
11# Es la BASE metodológica y la PUERTA ÚNICA; el resto de los scripts de estado son sus órganos.
12# Antes este saber vivía disperso (en la cabeza, en `grep`, en docs que envejecen) y cada medición
13# a mano mentía distinto.
14#
15# POR QUÉ EXISTE ESTE FICHERO Y NO SÓLO build-state.py. El 2026-07-22 un cambio a `libdrm`
16# (default_library=both, para gtk4) invalidó 118 recetas KDE. El radio se midió contra
17# `recipes/*.toml` y NO contra `recipes/incoming-kde/` ⇒ dio 8 en vez de 118. `build-state.py` sin
18# `--kde` cometía EXACTAMENTE el mismo error: cargaba sólo el corpus, y su campo `perfiles` llegaba
19# a AFIRMAR que tocar `libdrm` sólo afectaba a `escritorio-mirada` — una mentira con confianza. La
20# causa es estructural: las dependencias INVERSAS son un hecho del disco entero, no de la vista que
21# uno cargó. Este módulo las calcula SIEMPRE sobre TODAS las colas, y es de donde build-state saca
22# `perfiles`/`dependientes`. La regla, en una línea:
23#
24# QUÉ NODOS PUNTÚO es una decisión de vista. QUIÉN ME CONSUME es un hecho — nunca depende de la vista.
25#
26# RESOLUCIÓN SIBLING-FIRST. Una dep `libdrm` desde una receta de `incoming-kde` resuelve a
27# `incoming-kde/libdrm` si existe, y si no cae al `corpus/libdrm` (igual que el sandbox al montar las
28# capas). Por eso los nodos se identifican por PAR `(cola, nombre)`, no por nombre: `corpus/fontconfig`
29# y `incoming-kde/fontconfig` son nudos distintos y romper uno no rompe a los consumidores del otro.
30#
31# yupana es la PUERTA ÚNICA de la metodología. Sus verbos son los órganos del ciclo, en el orden en
32# que se usan (el mismo que la cadena del plan docs/plan-catalogo-objetivo.md):
33#
34# radio <receta> ¿a QUIÉN rompo si toco esto? (nativo; cruza TODAS las colas) ← ANTES de cambiar nada
35# perfiles <receta> ¿qué imágenes dependen de esto? (nativo)
36# duplicados invariante (c): nudos que son el mismo paquete bajo dos nombres (por sha) (nativo)
37# keystones nudos en deuda que gatean el máximo trabajo bloqueado (cierre inverso, AND) (nativo)
38# estado regenera el grafo firme → build-state.py
39# objetivo qué debe tener cada imagen → targets.py --lista
40# frontera <perfil> qué pide upstream y no tenemos → seed-graph.py --frontera
41# triaje clasificar la frontera → triaje.py
42# drenar [--perfil] el orden de masticado, en ondas → drenar.py
43#
44# Uso: scripts/yupana.py radio <receta> [--json]
45# scripts/yupana.py estado [--kde] | drenar [--todos] | triaje | frontera <perfil> | objetivo
46# Como módulo: from yupana import cargar_colas, membresia, dependientes, resolver
47import glob, json, os, subprocess, sys, tomllib
48from pathlib import Path
49
50ROOT = Path(__file__).resolve().parent.parent
51sys.path.insert(0, str(ROOT / "scripts"))
52import targets as targets_mod
53
54
55def cargar_colas():
56 """{cola: {nombre: [deps de build]}} sobre TODAS las colas del disco. `corpus` = recipes/;
57 `incoming-<x>` = recipes/incoming-<x>/. Es el grafo completo, sin recortar por vista."""
58 colas = {}
59 for f in sorted(glob.glob(str(ROOT / "recipes/*.toml"))):
60 n = os.path.basename(f)[:-5]
61 colas.setdefault("corpus", {})[n] = _deps(f)
62 for f in sorted(glob.glob(str(ROOT / "recipes/incoming-*/*.toml"))):
63 q = os.path.basename(os.path.dirname(f))
64 colas.setdefault(q, {})[os.path.basename(f)[:-5]] = _deps(f)
65 return colas
66
67
68def _deps(f):
69 try:
70 return tomllib.load(open(f, "rb")).get("deps", {}).get("build", []) or []
71 except Exception:
72 return []
73
74
75# --- INVARIANTE (c): CANÓNICO — sin duplicados accidentales entre orígenes ------------------------
76# Sufijos que en hammer marcan una VARIANTE deliberada del mismo paquete (misma fuente, otra decisión
77# de build: estático/shared, kernel por perfil, mesa por backend). Compartir sha con estos NO es un
78# duplicado a fundir — es la diversificación coherente [[etapa-g-gui-chain-boundary]]. `-hello`/`-hola`
79# son demos que vendorean la fuente de otro a propósito.
80SUFIJOS_VARIANTE = ("-shared", "-static", "-libs", "-tiny", "-dev", "-headers", "-minimal",
81 "-generic", "-metal-dual", "-metal", "-swrast", "-llvmpipe", "-hello", "-hola")
82
83
84def _base(n):
85 """Reduce un nombre a su base de familia quitando sufijos de variante, repetido: `zlib-shared`→
86 `zlib`, `mesa-llvmpipe`→`mesa`, `linux-generic`→`linux`. NO toca el `lib` inicial a propósito
87 (`libpng`→`png` colapsaría paquetes sin relación). Para decidir si dos nudos que comparten fuente
88 son la MISMA familia (variante ok) o paquetes SIN relación (colisión real)."""
89 prev = None
90 while prev != n:
91 prev = n
92 for s in SUFIJOS_VARIANTE:
93 if n.endswith(s) and len(n) > len(s):
94 n = n[: -len(s)]
95 return n
96
97
98# --- KEYSTONES: los nudos que gatean el trabajo -------------------------------------------------
99# ADVERTENCIA MATEMÁTICA (medida, no supuesta). El árbol de dominadores INGENUO NO sirve acá: asume
100# alcanzabilidad OR (basta un camino), pero construir es AND (hacen falta TODAS las deps). `kcoreaddons`
101# depende de `dbus`/`libdrm`/`mesa` además de `qtbase` ⇒ en el grafo de desbloqueo hay un camino que
102# evita qtbase, y el dominador-OR lo pierde. La métrica correcta del "cuánto gateo" bajo AND es el
103# CIERRE INVERSO TRANSITIVO. Pero el crudo lo gana el toolchain (make=313, inútil): el keystone
104# ACCIONABLE es el cierre inverso restringido a la DEUDA — cuánto trabajo BLOQUEADO libero. El
105# dominador SÍ tiene un uso legítimo, otro: la RETENCIÓN (lo exclusivamente mío), abajo.
106
107def _cierre_inverso(objetivo, rev):
108 """Nodos que dependen de `objetivo`, transitivamente (semántica AND: si X me tiene en su clausura,
109 X no se construye sin mí)."""
110 vis, pila = set(), [objetivo]
111 while pila:
112 x = pila.pop()
113 for c in rev.get(x, ()):
114 if c not in vis:
115 vis.add(c); pila.append(c)
116 return vis
117
118
119def _dominador_retencion(colas, perfiles):
120 """RETENCIÓN estilo GC: para cada nudo, cuántos otros son EXCLUSIVAMENTE suyos — sólo alcanzables
121 (como dependencia, desde las raíces de las imágenes) a través de él. Este SÍ es el árbol de
122 dominadores, en su uso correcto: 'si suelto este nudo, cuánto más puedo soltar'. Grafo
123 consumidor→dep, raíz virtual sobre todas las raíces de perfil; retención = tamaño del subárbol de
124 dominadores. Iterativo (Cooper-Harvey-Kennedy), suficiente para ~1000 nudos."""
125 idx = {q: set(m) for q, m in colas.items()}
126 R = ("__raiz__", "")
127 succ = {R: set()}
128 for pn, p in perfiles.items():
129 for raiz in p["raices"]:
130 nodo = resolver(raiz, p.get("cola", "corpus"), idx)
131 if nodo:
132 succ[R].add(nodo)
133 # aristas consumidor→dep
134 for q, miembros in colas.items():
135 for n in miembros:
136 nodo = (q, n)
137 succ.setdefault(nodo, set())
138 for d in miembros[n]:
139 r = resolver(d, q, idx)
140 if r:
141 succ[nodo].add(r)
142 # sólo lo alcanzable desde R
143 orden, vis = [], set()
144 def dfs(u):
145 vis.add(u)
146 for v in succ.get(u, ()):
147 if v not in vis:
148 dfs(v)
149 orden.append(u)
150 sys.setrecursionlimit(10000)
151 dfs(R)
152 rpo = list(reversed(orden))
153 pos = {n: i for i, n in enumerate(rpo)}
154 preds = {}
155 for u in succ:
156 for v in succ[u]:
157 preds.setdefault(v, set()).add(u)
158 idom = {R: R}
159 def inter(a, b):
160 while a != b:
161 while pos[a] > pos[b]:
162 a = idom[a]
163 while pos[b] > pos[a]:
164 b = idom[b]
165 return a
166 cambio = True
167 while cambio:
168 cambio = False
169 for u in rpo:
170 if u == R:
171 continue
172 ps = [p for p in preds.get(u, ()) if p in idom]
173 if not ps:
174 continue
175 nuevo = ps[0]
176 for p in ps[1:]:
177 nuevo = inter(nuevo, p)
178 if idom.get(u) != nuevo:
179 idom[u] = nuevo; cambio = True
180 # tamaño de subárbol (retención) = cuántos nodos domina cada uno
181 hijos = {}
182 for n, d in idom.items():
183 if n != d:
184 hijos.setdefault(d, []).append(n)
185 ret = {}
186 def subtam(u):
187 t = 0
188 for h in hijos.get(u, ()):
189 t += 1 + subtam(h)
190 ret[u] = t
191 return t
192 subtam(R)
193 return ret
194
195
196def keystones(como_json=False):
197 """Los nudos que GATEAN más trabajo. Métrica accionable: cierre inverso transitivo restringido a
198 la DEUDA (cuánto bloqueado libero al sellar esto). Segunda columna: RETENCIÓN por dominadores (lo
199 exclusivamente mío). El grafo elegido es el que más deuda tiene (KDE si existe)."""
200 from collections import Counter
201 colas = cargar_colas()
202 rev = dependientes(colas)
203 perfiles = targets_mod.load()
204 # estados desde el khipu más completo
205 estados = {}
206 for f in ("docs/state/build-state-kde.json", "docs/state/build-state.json"):
207 p = ROOT / f
208 if p.exists():
209 for n, r in json.loads(p.read_text())["nodes"].items():
210 estados.setdefault(n, r.get("state", "?"))
211 DEUDA = ("debt", "never", "unhashable", "wanted")
212 ret = _dominador_retencion(colas, perfiles)
213
214 filas = []
215 for nodo in list(rev):
216 cerr = _cierre_inverso(nodo, rev)
217 en_deuda = sum(1 for (q, n) in cerr if estados.get(n) in DEUDA)
218 q, n = nodo
219 yo_deuda = estados.get(n) in DEUDA
220 filas.append(dict(nudo=f"{q}/{n}", gateo_total=len(cerr), gateo_en_deuda=en_deuda,
221 exclusivo=ret.get(nodo, 0), en_deuda=yo_deuda))
222 # KEYSTONE ACCIONABLE: un nodo SELLADO ya está disponible ⇒ construirlo no libera nada (make gatea
223 # 65 en deuda pero está hecho). El que importa está EN DEUDA y gatea el máximo trabajo bloqueado.
224 # Orden: primero los en-deuda, por gateo-en-deuda desc. Los sellados quedan como contexto (su
225 # retención `exclusivo` sí es info válida, pero no son trabajo).
226 filas.sort(key=lambda r: (not r["en_deuda"], -r["gateo_en_deuda"], -r["gateo_total"]))
227 accionables = [r for r in filas if r["en_deuda"]]
228 doc = dict(schema="hammer-keystones/2", nudos=filas[:80], en_deuda=len(accionables))
229 (ROOT / "docs/state/keystones.json").write_text(json.dumps(doc, indent=1, ensure_ascii=False) + "\n")
230
231 if como_json:
232 print(json.dumps(doc, indent=1, ensure_ascii=False)); return 0
233 print("== KEYSTONES — nudos EN DEUDA que gatean el máximo trabajo bloqueado (semántica AND)")
234 print(" 'gateo en deuda' = otro trabajo bloqueado que se libera al sellar ESTE. La métrica accionable:")
235 print(" sellá de arriba hacia abajo y la cascada cae lo antes posible.\n")
236 print(f" {'nudo (en deuda)':30} {'gateo-deuda':>11} {'gateo-total':>11} {'exclusivo':>9}")
237 if not accionables:
238 print(" (ningún nodo en deuda — todas las imágenes en ámbito están completas)")
239 for r in accionables[:20]:
240 print(f" {r['nudo']:30} {r['gateo_en_deuda']:11} {r['gateo_total']:11} {r['exclusivo']:9}")
241 print(f"\n {len(accionables)} nudos en deuda en total · → docs/state/keystones.json")
242 print(" nota: el dominador-OR ingenuo NO sirve (build es AND, no OR); el 'exclusivo' SÍ es")
243 print(" dominador, en su uso correcto (retención). Advertencia completa en yupana.py.")
244 return 0
245
246
247def _fuente(f):
248 """(sha, url, version) de una receta — la identidad de lo que BAJA, no de cómo se llama."""
249 try:
250 d = tomllib.load(open(f, "rb"))
251 except Exception:
252 return (None, "", None)
253 s = d.get("source", {})
254 return (s.get("sha256") or s.get("output_hash"),
255 s.get("tarball") or s.get("url") or s.get("git") or "",
256 d.get("version"))
257
258
259def _recetas_con_fuente():
260 """{(cola, nombre): {sha, url, version, deps}} sobre todo el disco."""
261 out = {}
262 for pat, es_incoming in (("recipes/*.toml", False), ("recipes/incoming-*/*.toml", True)):
263 for f in sorted(glob.glob(str(ROOT / pat))):
264 n = os.path.basename(f)[:-5]
265 q = os.path.basename(os.path.dirname(f)) if es_incoming else "corpus"
266 sha, url, ver = _fuente(f)
267 out[(q, n)] = dict(sha=sha, url=url, version=ver, deps=_deps(f))
268 return out
269
270
271def duplicados(como_json=False):
272 """INVARIANTE (c): detecta nudos que son el MISMO paquete bajo nombres/colas distintos, por
273 EVIDENCIA de fuente (sha/url), no por nombre. Clasifica cada grupo:
274 colisión-fuente — mismo sha, nombres de familias DISTINTAS ⇒ casi seguro un sha copiado por
275 error (itstool tenía el tarball de gettext-tiny). ACCIONABLE.
276 variante — mismo sha, misma familia (`zlib`/`zlib-shared`, `mesa-*`) ⇒ deliberado, OK.
277 sombra — mismo NOMBRE en >1 cola. Redundante si versión+sha+deps coinciden; si no,
278 es una variante justificada de la cola.
279 NO funde nada solo: reporta y deja el juicio al humano (como el triaje). El radio de cada nudo
280 ayuda a elegir el canónico."""
281 recs = _recetas_con_fuente()
282 from collections import defaultdict
283 rev = dependientes()
284
285 # 1) grupos por sha (identidad de fuente)
286 por_sha = defaultdict(list)
287 for k, r in recs.items():
288 if r["sha"]:
289 por_sha[r["sha"]].append(k)
290 colisiones, variantes = [], []
291 for sha, ks in por_sha.items():
292 nombres = {n for _, n in ks}
293 if len(nombres) < 2:
294 continue
295 familias = {_base(n) for _, n in ks}
296 grupo = dict(sha=sha[:16], nudos=sorted(f"{q}/{n}" for q, n in ks),
297 radio={f"{q}/{n}": len(rev.get((q, n), ())) for q, n in ks})
298 (variantes if len(familias) == 1 else colisiones).append(grupo)
299
300 # 2) sombras: mismo nombre en >1 cola
301 por_nombre = defaultdict(list)
302 for (q, n) in recs:
303 por_nombre[n].append(q)
304 sombras = []
305 for n, qs in por_nombre.items():
306 if len(qs) < 2:
307 continue
308 firmas = {(recs[(q, n)]["version"], recs[(q, n)]["sha"],
309 tuple(sorted(recs[(q, n)]["deps"]))) for q in qs}
310 sombras.append(dict(nombre=n, colas=sorted(qs),
311 redundante=len(firmas) == 1, # idénticas ⇒ la sombra no aporta nada
312 radio={f"{q}/{n}": len(rev.get((q, n), ())) for q in qs}))
313
314 doc = dict(schema="hammer-duplicados/1",
315 colisiones_fuente=sorted(colisiones, key=lambda g: -max(g["radio"].values() or [0])),
316 variantes=sorted(variantes, key=lambda g: g["nudos"]),
317 sombras=sorted(sombras, key=lambda s: (not s["redundante"], s["nombre"])))
318 (ROOT / "docs/state/duplicados.json").write_text(
319 json.dumps(doc, indent=1, ensure_ascii=False) + "\n")
320
321 if como_json:
322 print(json.dumps(doc, indent=1, ensure_ascii=False))
323 return 0
324 print(f"== INVARIANTE (c) CANÓNICO — duplicados por evidencia de fuente\n")
325 print(f"⚠ COLISIONES DE FUENTE: {len(colisiones)} (mismo sha, familias distintas — revisá el sha)")
326 for g in doc["colisiones_fuente"]:
327 print(f" sha {g['sha']}… {g['nudos']}")
328 print(f" radio: " + " ".join(f"{k.split('/')[-1]}={v}" for k, v in g["radio"].items()))
329 red = [s for s in sombras if s["redundante"]]
330 print(f"\n⚠ SOMBRAS REDUNDANTES: {len(red)} (mismo nombre+versión+sha+deps en 2 colas — una sobra)")
331 for s in red:
332 print(f" {s['nombre']:24} {s['colas']} radio " +
333 " ".join(f"{q}={v}" for (q, _), v in
334 ((k.split('/'), v) for k, v in s["radio"].items())))
335 print(f"\n✓ variantes deliberadas (misma fuente, familia OK): {len(variantes)} — no son problema")
336 print(f"✓ sombras justificadas (difieren en versión/deps): {len(sombras)-len(red)}")
337 print(f"\n → docs/state/duplicados.json · el radio ayuda a elegir el canónico (el de mayor radio)")
338 return 0
339
340
341def resolver(dep, desde_cola, indice):
342 """Sibling-first: (cola, nombre) del nudo que provee `dep` visto desde `desde_cola`, o None si
343 ninguna cola lo tiene (dep externa / nodo `wanted`). `indice` = {cola: set(nombres)}."""
344 if dep in indice.get(desde_cola, ()):
345 return (desde_cola, dep)
346 if dep in indice.get("corpus", ()):
347 return ("corpus", dep)
348 return None
349
350
351def dependientes(colas=None):
352 """{(cola,nombre): set((cola,nombre) que lo consumen)} — el mapa de dependencias INVERSAS sobre
353 el grafo entero. Este es el cálculo que, hecho sobre una sola cola, produjo el bug de libdrm."""
354 colas = colas or cargar_colas()
355 indice = {q: set(m) for q, m in colas.items()}
356 rev = {}
357 for q, miembros in colas.items():
358 for nombre, deps in miembros.items():
359 for d in deps:
360 objetivo = resolver(d, q, indice)
361 if objetivo:
362 rev.setdefault(objetivo, set()).add((q, nombre))
363 return rev
364
365
366def membresia(colas=None, perfiles=None):
367 """{(cola,nombre): set(perfiles que lo alcanzan)} sobre el grafo entero, respetando sibling-first.
368 Un nudo pertenece a una imagen si la clausura de sus raíces —resolviendo cada cuerda desde la
369 cola del consumidor— llega hasta él. Esto es lo que arregla la mentira de `perfiles`."""
370 colas = colas or cargar_colas()
371 perfiles = perfiles if perfiles is not None else targets_mod.load()
372 indice = {q: set(m) for q, m in colas.items()}
373 memb = {}
374 for pn, p in perfiles.items():
375 cola_p = p.get("cola", "corpus")
376 vistos, pila = set(), []
377 for raiz in p["raices"]:
378 nodo = resolver(raiz, cola_p, indice)
379 pila.append(nodo if nodo else ("wanted", raiz)) # raíz sin receta = nodo wanted
380 while pila:
381 nodo = pila.pop()
382 if nodo in vistos:
383 continue
384 vistos.add(nodo)
385 memb.setdefault(nodo, set()).add(pn)
386 q, nombre = nodo
387 if q == "wanted":
388 continue
389 for d in colas.get(q, {}).get(nombre, []):
390 sig = resolver(d, q, indice)
391 pila.append(sig if sig else ("wanted", d))
392 return memb
393
394
395def _estado(nodo):
396 """Estado del nudo desde el grafo firme, si está regenerado. Best-effort: el radio funciona sin él."""
397 q, nombre = nodo
398 fichero = "docs/state/build-state-kde.json" if q != "corpus" else "docs/state/build-state.json"
399 try:
400 d = json.loads((ROOT / fichero).read_text())
401 return d["nodes"].get(nombre, {}).get("state", "?")
402 except Exception:
403 return "?"
404
405
406def radio(nombre, como_json=False):
407 """¿A QUIÉN rompo si re-hasheo `recipes/**/<nombre>.toml`? La pregunta que, mal medida, costó 118
408 recetas. Cruza TODAS las colas por construcción: es imposible que 'olvide' una cola."""
409 colas = cargar_colas()
410 indice = {q: set(m) for q, m in colas.items()}
411 # ¿En qué cola vive la receta que se va a tocar? Puede estar en varias (variante sombreada).
412 ubic = [(q, nombre) for q in colas if nombre in colas[q]]
413 if not ubic:
414 print(f"no hay receta `{nombre}` en ninguna cola", file=sys.stderr)
415 return 1
416 rev = dependientes(colas)
417 memb = membresia(colas, targets_mod.load())
418
419 resultado = {}
420 for nodo in ubic:
421 directos = sorted(rev.get(nodo, set()))
422 # Cierre inverso: quién depende, transitivamente (el radio real de un re-hash en cascada).
423 vistos, pila = set(), list(rev.get(nodo, set()))
424 while pila:
425 n = pila.pop()
426 if n in vistos:
427 continue
428 vistos.add(n)
429 pila.extend(rev.get(n, set()))
430 imgs = set()
431 for n in vistos | {nodo}:
432 imgs |= memb.get(n, set())
433 # Cuántos de los transitivos YA están sellados: ésos son los que HOY están al día y el
434 # re-hash MANDA A DEUDA (los que ya están en deuda igual habría que reconstruirlos). El
435 # radio verdadero de rebuild forzado por el cambio.
436 sellados = sum(1 for n in vistos if _estado(n) == "sealed")
437 resultado[f"{nodo[0]}/{nodo[1]}"] = dict(
438 directos=len(directos), transitivos=len(vistos),
439 imagenes=sorted(imgs), sellados_que_caen=sellados,
440 dependientes_por_cola=_por_cola(vistos))
441
442 if como_json:
443 print(json.dumps(resultado, indent=1, ensure_ascii=False))
444 return 0
445 for nodo, r in resultado.items():
446 print(f"== radio de tocar {nodo}")
447 print(f" dependientes directos {r['directos']}")
448 print(f" dependientes transitivos {r['transitivos']} (el radio real de un re-hash en cascada)")
449 print(f" por cola " +
450 " ".join(f"{q}={n}" for q, n in sorted(r["dependientes_por_cola"].items())))
451 print(f" IMÁGENES afectadas {', '.join(r['imagenes']) or '(ninguna declarada)'}")
452 print(f" sellados que CAEN a deuda {r['sellados_que_caen']} (hoy al día; el re-hash los fuerza a rebuild)")
453 if len(resultado) > 1:
454 print()
455 print("\n regla: esto cruza TODAS las colas. NO midas radio con `grep recipes/*.toml`.")
456 return 0
457
458
459def _por_cola(nodos):
460 from collections import Counter
461 return dict(Counter(q for q, _ in nodos))
462
463
464# Delegación a los órganos: yupana es la puerta, cada script sigue siendo el músculo. Se delega por
465# subproceso (no import) para no acoplar sus ciclos de vida ni sus flags — cada uno evoluciona solo.
466ORGANOS = {
467 "estado": ["build-state.py"],
468 "drenar": ["drenar.py"],
469 "triaje": ["triaje.py"],
470 "frontera": ["seed-graph.py", "--frontera"],
471 "objetivo": ["targets.py", "--lista"],
472 "sembrar": ["seed-graph.py", "--perfil"],
473}
474
475
476def delegar(cmd, extra):
477 return subprocess.call([sys.executable, str(ROOT / "scripts" / ORGANOS[cmd][0])]
478 + ORGANOS[cmd][1:] + extra)
479
480
481def main():
482 args = sys.argv[1:]
483 if not args or args[0] in ("-h", "--help"):
484 print(__doc__)
485 return 0
486 cmd, extra = args[0], args[1:]
487 if cmd == "radio" and extra:
488 return radio(extra[0], como_json="--json" in extra)
489 if cmd == "duplicados":
490 return duplicados(como_json="--json" in extra)
491 if cmd == "keystones":
492 return keystones(como_json="--json" in extra)
493 if cmd == "perfiles" and extra:
494 memb = membresia()
495 hits = {f"{q}/{n}": sorted(ps) for (q, n), ps in memb.items() if n == extra[0]}
496 print(json.dumps(hits, indent=1, ensure_ascii=False) if hits
497 else f"`{extra[0]}` no lo alcanza ninguna imagen declarada")
498 return 0
499 if cmd in ORGANOS:
500 return delegar(cmd, extra)
501 print(f"verbo desconocido: {cmd}\n{__doc__}", file=sys.stderr)
502 return 1
503
504
505if __name__ == "__main__":
506 sys.exit(main())