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:
Sergio
2026-09-03 06:15:19 +00:00
co-authored by Claude Opus 5
parent 3448ca4678
commit f64859bade
8 changed files with 495 additions and 65 deletions
+55 -5
View File
@@ -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'}")