From e6cfb470f1bdd8bfdb3025f53f54b902ee29c2f0 Mon Sep 17 00:00:00 2001 From: Sergio Date: Thu, 3 Sep 2026 11:40:40 +0000 Subject: [PATCH] =?UTF-8?q?vig=C3=ADa:=20provee.py=20=E2=80=94=20qu=C3=A9?= =?UTF-8?q?=20publica=20de=20verdad=20el=20cat=C3=A1logo,=20y=20desde=20d?= =?UTF-8?q?=C3=B3nde=20se=20alcanza?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 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) Claude-Session: https://claude.ai/code/session_01XB2iEmxeLZgzqNfhWChrLo --- docs/plan-apps-usuario-final.md | 18 +++ scripts/provee.py | 203 ++++++++++++++++++++++++++++++++ 2 files changed, 221 insertions(+) create mode 100755 scripts/provee.py 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-` 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 ... | --listar [cola] | --desde ...") + 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())