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 188Rust 55Shell 30Markdown 281211 Definiciones

vigía: provee.py — qué publica de verdad el catálogo, y desde dónde se alcanza

El triaje de apps falló TRES veces, cada vez por una pregunta distinta y cada vez la
anterior daba verde:

  1. ¿existe la receta en el disco?       — el método original
  2. ¿la ALCANZA el consumidor?           — wf-recorder grababa mudo: sus backends de
     audio existían, pero en colas hermanas, que una receta del corpus no ve
  3. ¿la VARIANTE sellada publica la ABI? — imv: mesa existe, es alcanzable, y aun así
     no sirve; las tres variantes no publican libGL.so ni gl.pc

Las tres son la misma equivocación disfrazada: preguntarle al CATÁLOGO lo que sólo
sabe el ARTEFACTO. Este vigía indexa los .pc y las librerías (.a y .so, porque
find_library no mira pkg-config) de todos los artefactos sellados y contesta quién
publica cada nombre y desde qué colas es pedible, aplicando sibling-first: lo del
corpus lo ve todo el mundo, lo de una incoming-* sólo esa cola.

Es el hermano de BUILD de vigia-sonames.py, que cubre la mitad de RUNTIME. La de build
se paga antes: es la que decide si el configure de una receta nueva va a morir.

Tres decisiones de forma que salieron de usarlo y verlo fallar:
- el nombre se busca flojo: gl, gl.pc, libGL.so.1 y librsvg (que tiene que encontrar
  librsvg-2.0.pc) dan lo mismo. El nombre que trae un APKBUILD casi nunca lleva la
  versión, y comparar a lo bruto daba falsos «nadie lo publica».
- agrupado por receta, no por fichero: mesa publica cuatro ficheros de EGL y repetirla
  cuatro veces convierte el informe en ruido justo cuando hay que leerlo rápido.
- --desde <cola> sale con código 1 si algo no se alcanza ⇒ sirve de puerta en cron/CI.

Verificado contra los dos casos conocidos: --desde corpus con lo que swayimg pide da
0, y con lo que imv pedía (gl, opengl) da 1. Barrido completo ~9 s con caché por
ArtifactHash en work/.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XB2iEmxeLZgzqNfhWChrLo

e6cfb470f1bdd8bfdb3025f53f54b902ee29c2f0 · por Sergio · (hace 1 mes) · padre e2bcd96fe · ver el árbol en este commit

Delta semántico · 9

scripts/provee.py

2 archivos · +221 −0

diff --git a/docs/plan-apps-usuario-final.md b/docs/plan-apps-usuario-final.md
index a4d7d0e0..06674515 100644
--- a/docs/plan-apps-usuario-final.md
+++ b/docs/plan-apps-usuario-final.md
@@ -163,6 +163,19 @@ día y subió a **5.5**. La cadena quedó verificada de punta a punta: `swayimg
 ni sólo «¿la alcanza?», sino **«¿la variante sellada publica el `.pc` y el `.so` que el consumidor
 pide?»**. Se contesta mirando el artefacto, no el catálogo.
 
+Y como acá cada punto ciego se convierte en un guardián, esa pregunta ya tiene instrumento:
+**`scripts/provee.py`**, que indexa los `.pc` y las librerías de TODOS los artefactos sellados y
+contesta quién publica cada nombre y desde qué colas se alcanza. Los tres defectos del método caen
+con un solo comando:
+
+```sh
+scripts/provee.py --desde corpus gl opengl     # ✗ nadie lo publica  → exit 1  (el caso imv)
+scripts/provee.py exiv2 librsvg                # ✓ existen, alcanzables SÓLO desde su cola
+```
+
+Es el hermano de build de `vigia-sonames.py`, que cubre la mitad de runtime. **El triaje de la
+próxima app empieza por acá**, no por contar `makedepends` contra `recipes/*.toml`.
+
 ## Los dos veredictos que corrigen al ADR
 
 ### Firefox NO es montón A
@@ -219,6 +232,11 @@ de mentir.
 
 ## Reproducir la medición
 
+⚠ **Ya no se reproduce así.** Lo que sigue describe el método ORIGINAL, que es el que falló las
+tres veces documentadas arriba; se deja escrito para que se entienda de dónde salieron los números
+de la tabla. El triaje de una app nueva se hace hoy cruzando sus `makedepends` contra
+`scripts/provee.py --desde corpus`, que pregunta por el artefacto y no por el catálogo.
+
 `/tmp/.../triaje-apps.py` fue un script de una sola vez; si hace falta repetirlo, lo que hace es:
 bajar el APKBUILD de cada candidata, extraer `makedepends`/`depends`, quitarles el sufijo `-dev`, y
 cruzar contra `recipes/*.toml` + `recipes/incoming-*/*.toml`, separando una lista fija de nombres
diff --git a/scripts/provee.py b/scripts/provee.py
new file mode 100755
index 00000000..dc915b8c
--- /dev/null
+++ b/scripts/provee.py
@@ -0,0 +1,203 @@
+#!/usr/bin/env python3
+# provee.py — ¿QUÉ publica de verdad el catálogo, y desde dónde se alcanza?
+#
+# ══ EL PUNTO CIEGO QUE ESTE VIGÍA CUBRE ════════════════════════════════════════════════════════
+# El triaje de apps de `docs/plan-apps-usuario-final.md` falló TRES veces, cada vez por una pregunta
+# distinta, y cada vez la anterior daba verde:
+#
+#   1. «¿existe la receta en el disco?»  — la pregunta original. Contaba `makedepends` de Alpine
+#      contra `recipes/*.toml`.
+#   2. «¿la ALCANZA el consumidor?»      — wf-recorder daba 0/0 y grababa MUDO: sus dos backends de
+#      audio existían, pero en colas hermanas, que una receta del corpus no ve.
+#   3. «¿la VARIANTE sellada publica la ABI?» — imv: `mesa` existe, es alcanzable desde cualquier
+#      lado, y aun así no sirve. Las tres variantes van `-Dglx=disabled -Dglvnd=false` ⇒ publican
+#      `libEGL.so.1` y `libGLESv2.so.2` y **ningún `libGL.so` ni `gl.pc`**. La trampa fina: mesa SÍ
+#      instala `/usr/include/GL/gl.h`, así que la app compila entera y muere recién al LIGAR.
+#
+# Las tres son la misma equivocación con distinto disfraz: preguntarle al CATÁLOGO lo que sólo sabe
+# el ARTEFACTO. Este script pregunta lo único que importa —**¿quién publica este `.pc` / esta
+# librería, y desde qué colas se alcanza?**— mirando los artefactos sellados, no las recetas.
+#
+# Es hermano de `vigia-sonames.py`, que resuelve la mitad de RUNTIME («qué `.so` pide una imagen que
+# nadie provee»). Éste resuelve la mitad de BUILD, que es la que se paga antes: qué puede pedir una
+# receta nueva sin que el configure muera.
+#
+# ══ LA REGLA DE ALCANCE, QUE ES LO QUE HACE ÚTIL AL INFORME ════════════════════════════════════
+# Una receta resuelve **sibling-first**: primero su propia cola, después el catálogo PADRE, y
+# NUNCA una cola hermana. Entonces lo que publica `corpus` lo ve todo el mundo, y lo que publica
+# `incoming-kde` sólo lo ven las recetas de `incoming-kde`. Por eso la columna que importa no es
+# «¿existe?» sino «desde qué colas se puede pedir», y por eso `exiv2` y `librsvg` —que existen— no
+# le sirven a una app del corpus.
+#
+#   uso:  scripts/provee.py gl.pc libGL.so opengl          # ¿quién publica esto? ¿desde dónde?
+#         scripts/provee.py --listar [cola]                # inventario completo de una cola
+#         scripts/provee.py --desde corpus luajit.pc ...   # exit 1 si algo NO se alcanza desde ahí
+#
+# El nombre se busca flojo a propósito: `gl`, `gl.pc`, `libGL.so` y `libGL.so.1` encuentran lo mismo.
+# Un triaje se hace con el nombre que trae el APKBUILD, que no siempre es el del fichero.
+import os, re, sys, glob, json, subprocess
+
+sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
+import yupana
+
+ROOT = yupana.ROOT
+STORE = os.environ.get("HAMMER_STORE", str(ROOT / "store"))
+HAMMER = str(ROOT / "target/release/hammer")
+CACHE = ROOT / "work/provee-cache.json"
+
+
+def receta(cola, nombre):
+    return ROOT / ("recipes/%s.toml" % nombre if cola == "corpus"
+                   else "recipes/%s/%s.toml" % (cola, nombre))
+
+
+def artefacto(cola, nombre):
+    """Directorio del artefacto sellado de (cola,nombre), o None si no está en el store."""
+    f = receta(cola, nombre)
+    if not f.exists():
+        return None
+    r = subprocess.run([HAMMER, "--store", STORE, "hash", str(f)],
+                       capture_output=True, text=True)
+    h = r.stdout.strip().replace("b3:", "")
+    if len(h) != 64:
+        return None
+    cand = glob.glob(os.path.join(STORE, h + "-*"))
+    return cand[0] if cand else None
+
+
+def escanear_uno(d):
+    """(pc[], libs[]) publicados por un artefacto. `libs` incluye `.a` y `.so*`: un `.pc` no es la
+    única forma de pedir una librería — `cc.find_library('gif')` no mira pkg-config."""
+    pc, libs = set(), set()
+    for raiz, _, ficheros in os.walk(d):
+        en_pkgconfig = raiz.endswith("/pkgconfig")
+        for fn in ficheros:
+            if en_pkgconfig and fn.endswith(".pc"):
+                pc.add(fn)
+            elif fn.startswith("lib") and (fn.endswith(".a") or ".so" in fn):
+                libs.add(fn)
+    return sorted(pc), sorted(libs)
+
+
+def inventario(refrescar=False):
+    """{cola: {nombre: {"pc": [...], "libs": [...]}}} sobre TODO el catálogo sellado.
+
+    Cachea en `work/provee-cache.json` con la clave = ArtifactHash, porque el barrido son ~1100
+    invocaciones de `hammer hash` y una receta que no cambió publica lo mismo que ayer."""
+    cache = {}
+    if CACHE.exists() and not refrescar:
+        try:
+            cache = json.load(open(CACHE))
+        except Exception:
+            cache = {}
+    colas = yupana.cargar_colas()
+    inv, nuevo = {}, {}
+    for cola, nombres in sorted(colas.items()):
+        for nombre in sorted(nombres):
+            d = artefacto(cola, nombre)
+            if not d:
+                continue
+            clave = os.path.basename(d)
+            if clave in cache:
+                pc, libs = cache[clave]["pc"], cache[clave]["libs"]
+            else:
+                pc, libs = escanear_uno(d)
+            nuevo[clave] = {"pc": list(pc), "libs": list(libs)}
+            if pc or libs:
+                inv.setdefault(cola, {})[nombre] = {"pc": pc, "libs": libs}
+    CACHE.parent.mkdir(parents=True, exist_ok=True)
+    json.dump(nuevo, open(CACHE, "w"))
+    return inv
+
+
+def _base(n):
+    """`libGL.so.1` → `gl`; `librsvg-2.0.pc` → `rsvg`; `wayland-client.pc` → `wayland-client`.
+    Quita la extensión, el `lib` de adelante y el sufijo de VERSIÓN — que es lo que hace que
+    `librsvg` no encuentre a `librsvg-2.0.pc` si uno compara a lo bruto."""
+    n = n.lower()
+    n = n.split(".so")[0] if ".so" in n else re.sub(r"\.(pc|a)$", "", n)
+    n = re.sub(r"^lib", "", n)
+    return n
+
+
+def _casa(consulta, fichero):
+    """`gl`, `gl.pc`, `libGL.so` y `libGL.so.1` tienen que encontrar lo mismo, y `librsvg` tiene que
+    encontrar a `librsvg-2.0.pc`: el nombre que trae un APKBUILD casi nunca lleva la versión."""
+    q, f = _base(consulta), _base(fichero)
+    if q == f:
+        return True
+    # sufijo de versión: `rsvg-2.0` ~ `rsvg`, `tiff-4` ~ `tiff`, `xkbcommon-x11` NO ~ `xkbcommon`
+    return bool(re.fullmatch(re.escape(q) + r"[-_]?[0-9][0-9.]*", f))
+
+
+def buscar(inv, consulta):
+    """{(cola, nombre): [ficheros]} que publican algo que case con `consulta`. Agrupado por receta
+    a propósito: `mesa` publica cuatro ficheros de EGL y repetir la receta cuatro veces convierte
+    el informe en ruido justo cuando hay que leerlo rápido."""
+    hits = {}
+    for cola, recetas in inv.items():
+        for nombre, pub in recetas.items():
+            casan = [fn for fn in pub["pc"] + pub["libs"] if _casa(consulta, fn)]
+            if casan:
+                hits[(cola, nombre)] = sorted(casan)
+    return dict(sorted(hits.items()))
+
+
+def alcance(colas_que_publican):
+    """Colas desde las que se puede PEDIR, dado quién lo publica. Sibling-first: `corpus` lo ve
+    todo el mundo; una `incoming-<x>` sólo se ve a sí misma."""
+    todas = set(yupana.cargar_colas())
+    if "corpus" in colas_que_publican:
+        return sorted(todas)
+    return sorted(colas_que_publican)
+
+
+def main():
+    argv = sys.argv[1:]
+    refrescar = "--refrescar" in argv
+    argv = [a for a in argv if a != "--refrescar"]
+
+    if argv and argv[0] == "--listar":
+        inv = inventario(refrescar)
+        quiero = argv[1:] or sorted(inv)
+        for cola in quiero:
+            recetas = inv.get(cola, {})
+            npc = sum(len(v["pc"]) for v in recetas.values())
+            print("== %-18s %3d recetas publican algo · %4d .pc" % (cola, len(recetas), npc))
+            for nombre, pub in sorted(recetas.items()):
+                if pub["pc"]:
+                    print("   %-28s %s" % (nombre, " ".join(pub["pc"])))
+        return 0
+
+    desde = None
+    if argv and argv[0] == "--desde":
+        desde, argv = argv[1], argv[2:]
+    if not argv:
+        print(__doc__ or "uso: scripts/provee.py <nombre>... | --listar [cola] | --desde <cola> <nombre>...")
+        return 2
+
+    inv = inventario(refrescar)
+    huecos = 0
+    for consulta in argv:
+        hits = buscar(inv, consulta)
+        if not hits:
+            print("%-22s ✗ NADIE LO PUBLICA en ninguna cola" % consulta)
+            huecos += 1
+            continue
+        visible = alcance({c for c, _ in hits})
+        marca = ""
+        if desde:
+            ok = desde in visible
+            marca = "  ⇒ desde %s: %s" % (desde, "SÍ" if ok else "NO ALCANZABLE")
+            if not ok:
+                huecos += 1
+        print("%-22s ✓ %s%s" % (consulta, ", ".join("%s/%s" % k for k in hits), marca))
+        for (c, n), ficheros in hits.items():
+            print("      %-22s %s" % ("%s/%s" % (c, n), " ".join(ficheros)))
+        if not desde:
+            print("      alcanzable desde: %s" % ", ".join(visible))
+    return 1 if (desde and huecos) else 0
+
+
+if __name__ == "__main__":
+    sys.exit(main())