Los llamadores EJECUTABLES: scripts/ (incluida toda la granja), los runbooks y CLAUDE.md. Seguro porque la 3a ya garantiza que el worker emite los dos binarios, y porque en farm-lab-sync.sh el cargo build remoto precede a la invocación remota en el mismo script. Verificado: sintaxis de los 49 (bash -n / py_compile — ojo que why-differs-barrido.sh es Python con extensión .sh) y `takana hash` devuelve hash real sobre el store. NO se toca en esta etapa, a propósito: - La variable de entorno HAMMER=. Es interfaz de los scripts entre sí y hay llamadores que la fijan; renombrarla va con la etapa 4. - docs/evidencia/ y los HANDOFF: son REGISTRO de lo que se corrió ese día. Reescribir un comando dentro de una evidencia la falsifica. - docs/state/: es generado, se regenera solo. - Los ADR y los docs de diseño: texto, y `hammer` sigue funcionando. Van con la etapa 5, que es la de churn de texto.
204 lines
8.9 KiB
Python
Executable File
204 lines
8.9 KiB
Python
Executable File
#!/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/takana")
|
|
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())
|