qorpa export: shims generados, y la clase ajeno para que nadie los cuente mal
Paso 5 del ADR 0015, sus dos mitades. SHIMS. `hammer qorpa export <id>` genera lanzadores finos y `.desktop` en el espacio del host, desde lo DECLARADO en `[export]` — nunca todo: exportar todo haría que el `ls` de la imagen compita con el nuestro, que es la falla de Bedrock (arbitra en tiempo de exec, por heurística). Gana en tres cosas contra un FUSE: cero costo en runtime, `cat` al shim y ves qué hace, y se revoca borrándolos. Se GENERAN, no se copian. El `.desktop` se arma con lista BLANCA de claves, así que `Exec`, `TryExec`, `Path` y `DBusActivatable` quedan fuera por definición y no por enumeración — una lista negra dejaría entrar la próxima clave ejecutable que invente el estándar. El Exec original se cita en un comentario del fichero generado, para que se vea qué decía y qué no se copió. El icono se busca en la vista merged (upper primero, imagen después: si no, se perdería lo que instaló el gestor de paquetes) y se copia al host, porque un icono que el host no resuelve se ve como un cuadrito gris. Y `exported.json` registra cada fichero escrito, para que `--remove` borre EXACTAMENTE eso y no por patrón sobre el ~/.local/bin de alguien. Probado de punta a punta con un .desktop ajeno real de la imagen de Arch: el shim corre `pacman -Q` del huésped desde el host, el X-KDE-Wayland-Interfaces quedó fuera, el icono viajó, y --remove dejó 0 ficheros con la instancia intacta. CLASE `ajeno`. build-state.py inyecta los nodos declarados en el nuevo docs/state/qorpa-ajenos.toml ANTES que los `wanted`, y ese orden es la mitad del punto: un nodo que provee una imagen ajena no es una receta por escribir. Con eso el `xwayland` de escritorio-kde deja de ser deuda y pasa a contarse aparte: escritorio-kde 187/188 listo falta 1 (raíces 14, + 1 ajenas) Dos decisiones que sostienen esa cifra: los ajenos se RESTAN del denominador (si entraran, el número que se lee como "cuánto construimos" crecería solo cada vez que alguien enjaula una app), y la declaración vive en el REPO y no se lee de /var/lib/hammer — build-state.json se commitea y lo regenera el cron en dos máquinas; si la clase saliera de las instancias instaladas, cada una diría algo distinto y se pisarían en cada cosecha. Es el error que ya se cometió con sealed_remoto. Qué provee una imagen ajena es diseño; qué tenés instalado, no. Un ajeno tampoco se hashea, y no por comodidad: no tiene procedencia de fuente, así que un hash afirmaría que lo reproducimos. 2 tests nuevos (que del .desktop ajeno no sobreviva nada ejecutable; que el shim no se rompa con rutas raras). 47/47. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01U5cQtQrYNjWJVXVE6aEpQ2
This commit is contained in:
+55
-5
@@ -240,6 +240,22 @@ def cargar_perfiles(colas_activas):
|
||||
return {n: p for n, p in todos.items() if p["cola"] in colas_activas}
|
||||
|
||||
|
||||
def cargar_ajenos():
|
||||
"""Nodos provistos por una imagen ajena (qorpa, ADR 0015 D5 capa 3).
|
||||
|
||||
Degrada igual que `cargar_perfiles`: este script lo corre el cron cada 30 minutos y no puede
|
||||
caerse porque alguien dejó un TOML a medias."""
|
||||
f = ROOT / "docs/state/qorpa-ajenos.toml"
|
||||
if not f.exists():
|
||||
return {}
|
||||
try:
|
||||
import tomllib
|
||||
return tomllib.loads(f.read_text())
|
||||
except Exception as e:
|
||||
print(f" ⚠ qorpa-ajenos.toml inválido ({e}) — grafo sin nodos ajenos", file=sys.stderr)
|
||||
return {}
|
||||
|
||||
|
||||
def clausura(raices, recs):
|
||||
"""Nodos alcanzables desde `raices` siguiendo las aristas name→dep. Un nodo `wanted` CORTA la
|
||||
exploración (no se conocen sus deps hasta construirlo): de ahí que la clausura sea cota inferior."""
|
||||
@@ -261,6 +277,23 @@ def main():
|
||||
# de buscar huérfanas a propósito: un objetivo declarado no es una arista colgando.
|
||||
colas = {"corpus"} | {os.path.basename(q) for q in EXTRA_QUEUES}
|
||||
perfiles = cargar_perfiles(colas)
|
||||
|
||||
# AJENOS ANTES QUE WANTED, y el orden es la mitad del punto: un nodo que provee una imagen
|
||||
# ajena NO es una receta por escribir. Si se inyectara después, `xwayland` quedaría contado
|
||||
# como deuda para siempre — y la deuda fantasma es un error que este repo ya pagó caro.
|
||||
ajenos = cargar_ajenos()
|
||||
for nombre, a in sorted(ajenos.items()):
|
||||
if nombre in recs:
|
||||
# Hay receta de verdad: la receta gana y el declarado sobra. Decirlo, no taparlo.
|
||||
print(f" ⚠ {nombre} está declarado ajeno pero TIENE receta — gana la receta",
|
||||
file=sys.stderr)
|
||||
continue
|
||||
colas_de = {perfiles[pn]["cola"] for pn in a.get("perfiles", []) if pn in perfiles}
|
||||
recs[nombre] = dict(path=None, ajeno=True, iname=nombre, version=None, link=None,
|
||||
compiler=None, deps=[], cls="ajeno",
|
||||
queue=(sorted(colas_de) or ["corpus"])[0],
|
||||
imagen=a.get("imagen"))
|
||||
|
||||
for pn, p in sorted(perfiles.items()):
|
||||
for raiz in p["raices"]:
|
||||
if raiz not in recs:
|
||||
@@ -273,12 +306,16 @@ def main():
|
||||
if dep not in recs:
|
||||
orphans.add(dep)
|
||||
|
||||
n_recetas = sum(1 for r in recs.values() if not r.get("wanted"))
|
||||
n_recetas = sum(1 for r in recs.values() if not r.get("wanted") and not r.get("ajeno"))
|
||||
print(f"== {n_recetas} recetas; computando hash vigente (~{n_recetas*18//1000}s)…", file=sys.stderr)
|
||||
for n, r in recs.items():
|
||||
# `wanted` no tiene fichero que hashear: es el objetivo, no una receta.
|
||||
if r.get("wanted"):
|
||||
r["hash"] = None; r["state"] = "wanted"; continue
|
||||
# `ajeno` tampoco, y por una razón más fuerte: no tiene procedencia de fuente. Un hash
|
||||
# afirmaría que lo reproducimos, y no lo reproducimos.
|
||||
if r.get("ajeno"):
|
||||
r["hash"] = None; r["state"] = "ajeno"; continue
|
||||
if "parse_error" in r:
|
||||
r["hash"] = None; r["state"] = "unhashable"; continue
|
||||
h = vigente_hash(r["path"])
|
||||
@@ -311,6 +348,12 @@ def main():
|
||||
for n, r in recs.items():
|
||||
clave = (r.get("queue", "corpus"), n)
|
||||
r["perfiles"] = sorted(memb.get(clave, ()))
|
||||
# yupana calcula la membresía sobre las RECETAS del disco, y un ajeno no tiene ninguna: sin
|
||||
# esto quedaría fuera de la clausura de su imagen y el reporte no lo mencionaría, que es
|
||||
# justo el descuido que la clase `ajeno` existe para impedir. La declaración dice a qué
|
||||
# imagen sirve; es autoría, igual que la política de D7.
|
||||
if r.get("ajeno"):
|
||||
r["perfiles"] = sorted(set(r["perfiles"]) | set(ajenos.get(n, {}).get("perfiles", [])))
|
||||
# Dependientes inversos DEL GRAFO ENTERO: cuántas recetas (de cualquier cola) me consumen. Es
|
||||
# el radio real de un re-hash. `unblocks` (abajo) es local a la deuda de esta vista; este no.
|
||||
r["dependientes_total"] = len(rev.get(clave, ()))
|
||||
@@ -354,7 +397,7 @@ def main():
|
||||
sin_perfil=sin_perfil,
|
||||
orphan_deps=sorted(orphans),
|
||||
topo_ok=order is not None,
|
||||
nodes={n: {k: v for k, v in r.items() if k not in ("path", "wanted")}
|
||||
nodes={n: {k: v for k, v in r.items() if k not in ("path", "wanted", "ajeno")}
|
||||
for n, r in sorted(recs.items())},
|
||||
)
|
||||
OUT.parent.mkdir(parents=True, exist_ok=True)
|
||||
@@ -362,7 +405,7 @@ def main():
|
||||
|
||||
# Resumen humano a stdout.
|
||||
print(f"\n== estado del corpus ({n_recetas} recetas) → {OUT.relative_to(ROOT)}")
|
||||
for s in ("sealed", "debt", "never", "unhashable", "wanted"):
|
||||
for s in ("sealed", "debt", "never", "unhashable", "wanted", "ajeno"):
|
||||
if by_state.get(s):
|
||||
print(f" {s:11} {by_state[s]}")
|
||||
if REMOTO_ONLY:
|
||||
@@ -381,9 +424,16 @@ def main():
|
||||
print("\n== objetivo por imagen (docs/state/targets.toml) — clausura = COTA INFERIOR")
|
||||
for pn, c in by_profile.items():
|
||||
falta = c.get("debt", 0) + c.get("never", 0) + c.get("unhashable", 0) + c.get("wanted", 0)
|
||||
print(f" {pn:20} {c.get('sealed',0):3}/{c['clausura']:3} listo falta {falta:3}"
|
||||
# El denominador es el CORPUS de la imagen: los ajenos se restan y se dicen aparte. Si
|
||||
# entraran en la fracción, la cifra que todo el mundo lee como "cuánto construimos"
|
||||
# crecería sola cada vez que alguien enjaula una app ajena.
|
||||
propios = c["clausura"] - c.get("ajeno", 0)
|
||||
print(f" {pn:20} {c.get('sealed',0):3}/{propios:3} listo falta {falta:3}"
|
||||
f" (raíces {c['raices']}"
|
||||
+ (f", sin receta {c['wanted']}" if c.get("wanted") else "") + ")")
|
||||
+ (f", sin receta {c['wanted']}" if c.get("wanted") else "")
|
||||
# Se dicen APARTE, nunca sumadas a las nativas: son la parte del sistema sin
|
||||
# procedencia de fuente y sin reproducibilidad (ADR 0015 §NO-resuelve 3).
|
||||
+ (f", + {c['ajeno']} ajenas" if c.get("ajeno") else "") + ")")
|
||||
print(f" {sin_perfil} recetas no las alcanza ninguna imagen (catálogo, no distro)")
|
||||
print(f"\n grafo: {'CIERRA' if not orphans else f'{len(orphans)} deps huérfanas'} | "
|
||||
f"topo-sort: {'OK' if order else 'CICLO'}")
|
||||
|
||||
Reference in New Issue
Block a user