El peso que le faltaba a las ondas: sin él, critical-path = nº de pasos; con él, ETA real en segundos. INSTRUMENTACIÓN (worker): scripts/farm/build-timed.sh envuelve `hammer build` midiendo la pared y la registra en $STORE/.times/<hash>-<host>.json — un SIDECAR del store, NUNCA dentro del artefacto. La duración es no determinista (varía por máquina/carga) ⇒ no puede tocar el ArtifactHash. Keyed por hash (CAS-safe: dos workers no se pisan) + host (varias muestras por receta). Viaja al hub con el rsync de store que ya hace la cosecha; ninguna herramienta del store lo confunde con artefacto. Sólo builds REALES (pared ≥ UMBRAL 3s) — los cache-hits no envenenan la mediana. campana-deuda.sh ahora construye vía build-timed.sh (transparente, mismo exit). CONSUMO (hub): yupana._tiempos() carga name→mediana de segundos; keystones computa el CAMINO CRÍTICO pesado = longest weighted path del subgrafo de deuda (peso = segundos de build). La cadena más larga hay que construirla en SERIE aunque haya ∞ workers ⇒ es la ETA con paralelismo infinito. Degrada con gracia: sin datos, peso=1 y el camino crítico = nº de pasos (lo que ya daban las ondas). Verificado: con muestras sintéticas da "ETA 19 min sobre 5 pasos, kio → kparts → frameworkintegration → breeze → plasma-integration"; sin datos degrada a "5 pasos (SIN datos)". store/.times está git-ignorado (metadata de máquina, no se commitea). LÍMITE honesto (en la cabecera de build-timed.sh): `hammer build` arrastra deps ⇒ la pared incluye deps no selladas. En orden topológico (drenar) las deps ya están selladas y la pared mide sobre todo ESTA receta — cota superior buena para pesar. La precisión exacta pediría instrumentar el sellado dentro de hammer-build (Rust). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
631 lines
30 KiB
Python
Executable File
631 lines
30 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)
|
|
# 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
|
|
#
|
|
# 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:
|
|
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.
|
|
|
|
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.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-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/**/<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 == "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())
|