#!/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())