gioser

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…

TOML 1572Shell 203Rust 109Markdown 993728 Sutichaykuna

⎇ 0f3a1dc3945ed2bff6e7d14a42b60d8ff0668ebe scripts/yupana.py Python · 353 siq'ikuna · 16.5 KB Ñawpa willay Pi imata llamkarqan ch'uya

yupana: invariante (c) CANÓNICO — detector de duplicados por evidencia de fuente 0f3a1dc39 · sergio ·

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#   estado            regenera el grafo firme          → build-state.py
37#   objetivo          qué debe tener cada imagen        → targets.py --lista
38#   frontera <perfil> qué pide upstream y no tenemos    → seed-graph.py --frontera
39#   triaje            clasificar la frontera            → triaje.py
40#   drenar [--perfil] el orden de masticado, en ondas   → drenar.py
41#
42# Uso:  scripts/yupana.py radio <receta> [--json]
43#       scripts/yupana.py estado [--kde] | drenar [--todos] | triaje | frontera <perfil> | objetivo
44# Como módulo:  from yupana import cargar_colas, membresia, dependientes, resolver
45import glob, json, os, subprocess, sys, tomllib
46from pathlib import Path
47
48ROOT = Path(__file__).resolve().parent.parent
49sys.path.insert(0, str(ROOT / "scripts"))
50import targets as targets_mod
51
52
53def cargar_colas():
54    """{cola: {nombre: [deps de build]}} sobre TODAS las colas del disco. `corpus` = recipes/;
55    `incoming-<x>` = recipes/incoming-<x>/. Es el grafo completo, sin recortar por vista."""
56    colas = {}
57    for f in sorted(glob.glob(str(ROOT / "recipes/*.toml"))):
58        n = os.path.basename(f)[:-5]
59        colas.setdefault("corpus", {})[n] = _deps(f)
60    for f in sorted(glob.glob(str(ROOT / "recipes/incoming-*/*.toml"))):
61        q = os.path.basename(os.path.dirname(f))
62        colas.setdefault(q, {})[os.path.basename(f)[:-5]] = _deps(f)
63    return colas
64
65
66def _deps(f):
67    try:
68        return tomllib.load(open(f, "rb")).get("deps", {}).get("build", []) or []
69    except Exception:
70        return []
71
72
73# --- INVARIANTE (c): CANÓNICO — sin duplicados accidentales entre orígenes ------------------------
74# Sufijos que en hammer marcan una VARIANTE deliberada del mismo paquete (misma fuente, otra decisión
75# de build: estático/shared, kernel por perfil, mesa por backend). Compartir sha con estos NO es un
76# duplicado a fundir — es la diversificación coherente [[etapa-g-gui-chain-boundary]]. `-hello`/`-hola`
77# son demos que vendorean la fuente de otro a propósito.
78SUFIJOS_VARIANTE = ("-shared", "-static", "-libs", "-tiny", "-dev", "-headers", "-minimal",
79                    "-generic", "-metal-dual", "-metal", "-swrast", "-llvmpipe", "-hello", "-hola")
80
81
82def _base(n):
83    """Reduce un nombre a su base de familia quitando sufijos de variante, repetido: `zlib-shared`→
84    `zlib`, `mesa-llvmpipe`→`mesa`, `linux-generic`→`linux`. NO toca el `lib` inicial a propósito
85    (`libpng`→`png` colapsaría paquetes sin relación). Para decidir si dos nudos que comparten fuente
86    son la MISMA familia (variante ok) o paquetes SIN relación (colisión real)."""
87    prev = None
88    while prev != n:
89        prev = n
90        for s in SUFIJOS_VARIANTE:
91            if n.endswith(s) and len(n) > len(s):
92                n = n[: -len(s)]
93    return n
94
95
96def _fuente(f):
97    """(sha, url, version) de una receta — la identidad de lo que BAJA, no de cómo se llama."""
98    try:
99        d = tomllib.load(open(f, "rb"))
100    except Exception:
101        return (None, "", None)
102    s = d.get("source", {})
103    return (s.get("sha256") or s.get("output_hash"),
104            s.get("tarball") or s.get("url") or s.get("git") or "",
105            d.get("version"))
106
107
108def _recetas_con_fuente():
109    """{(cola, nombre): {sha, url, version, deps}} sobre todo el disco."""
110    out = {}
111    for pat, es_incoming in (("recipes/*.toml", False), ("recipes/incoming-*/*.toml", True)):
112        for f in sorted(glob.glob(str(ROOT / pat))):
113            n = os.path.basename(f)[:-5]
114            q = os.path.basename(os.path.dirname(f)) if es_incoming else "corpus"
115            sha, url, ver = _fuente(f)
116            out[(q, n)] = dict(sha=sha, url=url, version=ver, deps=_deps(f))
117    return out
118
119
120def duplicados(como_json=False):
121    """INVARIANTE (c): detecta nudos que son el MISMO paquete bajo nombres/colas distintos, por
122    EVIDENCIA de fuente (sha/url), no por nombre. Clasifica cada grupo:
123      colisión-fuente — mismo sha, nombres de familias DISTINTAS ⇒ casi seguro un sha copiado por
124                        error (itstool tenía el tarball de gettext-tiny). ACCIONABLE.
125      variante        — mismo sha, misma familia (`zlib`/`zlib-shared`, `mesa-*`) ⇒ deliberado, OK.
126      sombra          — mismo NOMBRE en >1 cola. Redundante si versión+sha+deps coinciden; si no,
127                        es una variante justificada de la cola.
128    NO funde nada solo: reporta y deja el juicio al humano (como el triaje). El radio de cada nudo
129    ayuda a elegir el canónico."""
130    recs = _recetas_con_fuente()
131    from collections import defaultdict
132    rev = dependientes()
133
134    # 1) grupos por sha (identidad de fuente)
135    por_sha = defaultdict(list)
136    for k, r in recs.items():
137        if r["sha"]:
138            por_sha[r["sha"]].append(k)
139    colisiones, variantes = [], []
140    for sha, ks in por_sha.items():
141        nombres = {n for _, n in ks}
142        if len(nombres) < 2:
143            continue
144        familias = {_base(n) for _, n in ks}
145        grupo = dict(sha=sha[:16], nudos=sorted(f"{q}/{n}" for q, n in ks),
146                     radio={f"{q}/{n}": len(rev.get((q, n), ())) for q, n in ks})
147        (variantes if len(familias) == 1 else colisiones).append(grupo)
148
149    # 2) sombras: mismo nombre en >1 cola
150    por_nombre = defaultdict(list)
151    for (q, n) in recs:
152        por_nombre[n].append(q)
153    sombras = []
154    for n, qs in por_nombre.items():
155        if len(qs) < 2:
156            continue
157        firmas = {(recs[(q, n)]["version"], recs[(q, n)]["sha"],
158                   tuple(sorted(recs[(q, n)]["deps"]))) for q in qs}
159        sombras.append(dict(nombre=n, colas=sorted(qs),
160                            redundante=len(firmas) == 1,  # idénticas ⇒ la sombra no aporta nada
161                            radio={f"{q}/{n}": len(rev.get((q, n), ())) for q in qs}))
162
163    doc = dict(schema="hammer-duplicados/1",
164               colisiones_fuente=sorted(colisiones, key=lambda g: -max(g["radio"].values() or [0])),
165               variantes=sorted(variantes, key=lambda g: g["nudos"]),
166               sombras=sorted(sombras, key=lambda s: (not s["redundante"], s["nombre"])))
167    (ROOT / "docs/state/duplicados.json").write_text(
168        json.dumps(doc, indent=1, ensure_ascii=False) + "\n")
169
170    if como_json:
171        print(json.dumps(doc, indent=1, ensure_ascii=False))
172        return 0
173    print(f"== INVARIANTE (c) CANÓNICO — duplicados por evidencia de fuente\n")
174    print(f"⚠ COLISIONES DE FUENTE: {len(colisiones)}  (mismo sha, familias distintas — revisá el sha)")
175    for g in doc["colisiones_fuente"]:
176        print(f"   sha {g['sha']}…  {g['nudos']}")
177        print(f"        radio: " + "  ".join(f"{k.split('/')[-1]}={v}" for k, v in g["radio"].items()))
178    red = [s for s in sombras if s["redundante"]]
179    print(f"\n⚠ SOMBRAS REDUNDANTES: {len(red)}  (mismo nombre+versión+sha+deps en 2 colas — una sobra)")
180    for s in red:
181        print(f"   {s['nombre']:24} {s['colas']}  radio " +
182              " ".join(f"{q}={v}" for (q, _), v in
183                       ((k.split('/'), v) for k, v in s["radio"].items())))
184    print(f"\n✓ variantes deliberadas (misma fuente, familia OK): {len(variantes)}  — no son problema")
185    print(f"✓ sombras justificadas (difieren en versión/deps): {len(sombras)-len(red)}")
186    print(f"\n   → docs/state/duplicados.json  ·  el radio ayuda a elegir el canónico (el de mayor radio)")
187    return 0
188
189
190def resolver(dep, desde_cola, indice):
191    """Sibling-first: (cola, nombre) del nudo que provee `dep` visto desde `desde_cola`, o None si
192    ninguna cola lo tiene (dep externa / nodo `wanted`). `indice` = {cola: set(nombres)}."""
193    if dep in indice.get(desde_cola, ()):
194        return (desde_cola, dep)
195    if dep in indice.get("corpus", ()):
196        return ("corpus", dep)
197    return None
198
199
200def dependientes(colas=None):
201    """{(cola,nombre): set((cola,nombre) que lo consumen)} — el mapa de dependencias INVERSAS sobre
202    el grafo entero. Este es el cálculo que, hecho sobre una sola cola, produjo el bug de libdrm."""
203    colas = colas or cargar_colas()
204    indice = {q: set(m) for q, m in colas.items()}
205    rev = {}
206    for q, miembros in colas.items():
207        for nombre, deps in miembros.items():
208            for d in deps:
209                objetivo = resolver(d, q, indice)
210                if objetivo:
211                    rev.setdefault(objetivo, set()).add((q, nombre))
212    return rev
213
214
215def membresia(colas=None, perfiles=None):
216    """{(cola,nombre): set(perfiles que lo alcanzan)} sobre el grafo entero, respetando sibling-first.
217    Un nudo pertenece a una imagen si la clausura de sus raíces —resolviendo cada cuerda desde la
218    cola del consumidor— llega hasta él. Esto es lo que arregla la mentira de `perfiles`."""
219    colas = colas or cargar_colas()
220    perfiles = perfiles if perfiles is not None else targets_mod.load()
221    indice = {q: set(m) for q, m in colas.items()}
222    memb = {}
223    for pn, p in perfiles.items():
224        cola_p = p.get("cola", "corpus")
225        vistos, pila = set(), []
226        for raiz in p["raices"]:
227            nodo = resolver(raiz, cola_p, indice)
228            pila.append(nodo if nodo else ("wanted", raiz))  # raíz sin receta = nodo wanted
229        while pila:
230            nodo = pila.pop()
231            if nodo in vistos:
232                continue
233            vistos.add(nodo)
234            memb.setdefault(nodo, set()).add(pn)
235            q, nombre = nodo
236            if q == "wanted":
237                continue
238            for d in colas.get(q, {}).get(nombre, []):
239                sig = resolver(d, q, indice)
240                pila.append(sig if sig else ("wanted", d))
241    return memb
242
243
244def _estado(nodo):
245    """Estado del nudo desde el grafo firme, si está regenerado. Best-effort: el radio funciona sin él."""
246    q, nombre = nodo
247    fichero = "docs/state/build-state-kde.json" if q != "corpus" else "docs/state/build-state.json"
248    try:
249        d = json.loads((ROOT / fichero).read_text())
250        return d["nodes"].get(nombre, {}).get("state", "?")
251    except Exception:
252        return "?"
253
254
255def radio(nombre, como_json=False):
256    """¿A QUIÉN rompo si re-hasheo `recipes/**/<nombre>.toml`? La pregunta que, mal medida, costó 118
257    recetas. Cruza TODAS las colas por construcción: es imposible que 'olvide' una cola."""
258    colas = cargar_colas()
259    indice = {q: set(m) for q, m in colas.items()}
260    # ¿En qué cola vive la receta que se va a tocar? Puede estar en varias (variante sombreada).
261    ubic = [(q, nombre) for q in colas if nombre in colas[q]]
262    if not ubic:
263        print(f"no hay receta `{nombre}` en ninguna cola", file=sys.stderr)
264        return 1
265    rev = dependientes(colas)
266    memb = membresia(colas, targets_mod.load())
267
268    resultado = {}
269    for nodo in ubic:
270        directos = sorted(rev.get(nodo, set()))
271        # Cierre inverso: quién depende, transitivamente (el radio real de un re-hash en cascada).
272        vistos, pila = set(), list(rev.get(nodo, set()))
273        while pila:
274            n = pila.pop()
275            if n in vistos:
276                continue
277            vistos.add(n)
278            pila.extend(rev.get(n, set()))
279        imgs = set()
280        for n in vistos | {nodo}:
281            imgs |= memb.get(n, set())
282        # Cuántos de los transitivos YA están sellados: ésos son los que HOY están al día y el
283        # re-hash MANDA A DEUDA (los que ya están en deuda igual habría que reconstruirlos). El
284        # radio verdadero de rebuild forzado por el cambio.
285        sellados = sum(1 for n in vistos if _estado(n) == "sealed")
286        resultado[f"{nodo[0]}/{nodo[1]}"] = dict(
287            directos=len(directos), transitivos=len(vistos),
288            imagenes=sorted(imgs), sellados_que_caen=sellados,
289            dependientes_por_cola=_por_cola(vistos))
290
291    if como_json:
292        print(json.dumps(resultado, indent=1, ensure_ascii=False))
293        return 0
294    for nodo, r in resultado.items():
295        print(f"== radio de tocar  {nodo}")
296        print(f"   dependientes directos     {r['directos']}")
297        print(f"   dependientes transitivos  {r['transitivos']}   (el radio real de un re-hash en cascada)")
298        print(f"   por cola                  " +
299              "  ".join(f"{q}={n}" for q, n in sorted(r["dependientes_por_cola"].items())))
300        print(f"   IMÁGENES afectadas        {', '.join(r['imagenes']) or '(ninguna declarada)'}")
301        print(f"   sellados que CAEN a deuda {r['sellados_que_caen']}   (hoy al día; el re-hash los fuerza a rebuild)")
302        if len(resultado) > 1:
303            print()
304    print("\n   regla: esto cruza TODAS las colas. NO midas radio con `grep recipes/*.toml`.")
305    return 0
306
307
308def _por_cola(nodos):
309    from collections import Counter
310    return dict(Counter(q for q, _ in nodos))
311
312
313# Delegación a los órganos: yupana es la puerta, cada script sigue siendo el músculo. Se delega por
314# subproceso (no import) para no acoplar sus ciclos de vida ni sus flags — cada uno evoluciona solo.
315ORGANOS = {
316    "estado":   ["build-state.py"],
317    "drenar":   ["drenar.py"],
318    "triaje":   ["triaje.py"],
319    "frontera": ["seed-graph.py", "--frontera"],
320    "objetivo": ["targets.py", "--lista"],
321    "sembrar":  ["seed-graph.py", "--perfil"],
322}
323
324
325def delegar(cmd, extra):
326    return subprocess.call([sys.executable, str(ROOT / "scripts" / ORGANOS[cmd][0])]
327                           + ORGANOS[cmd][1:] + extra)
328
329
330def main():
331    args = sys.argv[1:]
332    if not args or args[0] in ("-h", "--help"):
333        print(__doc__)
334        return 0
335    cmd, extra = args[0], args[1:]
336    if cmd == "radio" and extra:
337        return radio(extra[0], como_json="--json" in extra)
338    if cmd == "duplicados":
339        return duplicados(como_json="--json" in extra)
340    if cmd == "perfiles" and extra:
341        memb = membresia()
342        hits = {f"{q}/{n}": sorted(ps) for (q, n), ps in memb.items() if n == extra[0]}
343        print(json.dumps(hits, indent=1, ensure_ascii=False) if hits
344              else f"`{extra[0]}` no lo alcanza ninguna imagen declarada")
345        return 0
346    if cmd in ORGANOS:
347        return delegar(cmd, extra)
348    print(f"verbo desconocido: {cmd}\n{__doc__}", file=sys.stderr)
349    return 1
350
351
352if __name__ == "__main__":
353    sys.exit(main())