> Una distribución Linux construida con un laboratorio funcional y hermético en el > sótano, y una terminal mutable, clásica y anárquica en el piso de arriba — diseñada > desde el suelo para que una IA programadora entienda, modifique y co…
paso 1: el toolchain sale de las imágenes — servidor 4,9 G → 2,6 G, cero rebuilds
`hydrate-profile.py` proyectaba `membresia()`, que es `build ∪ runtime`, así que cada rootfs salía
con la cadena de compilación adentro: llvm21 (1.527 MiB), go, python3, binutils, cmake, perl, nasm,
wasi-sdk, nodejs. Es la forma del caso busybox, que llegaba a los 7 perfiles por 24 `[deps] build`.
La poda es por NODO y no por tipo de arista (podar las aristas de build es lo que dejó a firefox sin
libstdc++): una receta de `[solo-build]` no entra ni se explora desde ella, y lo que llega por otro
camino se queda. Válvula: una raíz declarada gana, y por eso `servidor` conserva rust/lld21 y `cli`
mold. Vive en targets.toml y no en la receta para no entrar en `hash_inputs`.
Medido hidratando de verdad, con `--closure build` como control y `du` por separado (con hardlinks,
un solo `du` de los dos árboles deduplica y miente):
base 15.743 ficheros / 1,4 G → 8.228 / 871 M
servidor 40.235 ficheros / 4,9 G → 11.516 / 2,6 G
Los dos vigías que validan la lista, los dos en cero:
- `vigia-sonames.py --imagen --fail` ⇒ rc=0, 0 huecos en los 8 perfiles. Así se cayó `libtool` de
la lista: provee libltdl.so.7 y lo pide libcanberra.
- `vigia-interpretes.py` (NUEVO): un `#!` es dependencia de ejecución y ningún soname lo ve.
Encontró que sin python3 no abre `gnome-tweaks` (una app entera), ni `gdctl` de mutter, ni corre
el kconf_update de plasma-workspace, que MIGRA config al actualizar ⇒ python3 se declara en los
perfiles de GNOME y KDE (2 % de 10-13 G) y NO en base, cli, servidor, sway, cosmic ni mirada,
donde era el 17 % de `base`. Lo que queda inerte está contado y nombrado en su tabla.
`build-state.py` sigue contando `clausura` = build ∪ runtime (es «qué construir») e informa `imagen`
al lado. `repo-perfil.sh` no cambia: un repositorio no es una imagen. `targets.py --selftest` gana 6
casos con roturas a propósito.
diff --git a/docs/plan-que-sigue-tras-busybox.md b/docs/plan-que-sigue-tras-busybox.md
index 2c9d9648..e56ad768 100644
--- a/docs/plan-que-sigue-tras-busybox.md
+++ b/docs/plan-que-sigue-tras-busybox.md
@@ -345,3 +345,102 @@ ajeno sin decirlo. ⚠ Y el ADR 0018 deja abierta la de Secure Boot, que es la q
Los cinco primeros **no cuestan una sola reconstrucción**. Eso es lo que los pone antes: la lección
de `uutils`/`brush`/`foot` —trabajo hecho y no cobrado— aplicada al calendario.
+
+---
+
+# Paso 1 — HECHO: el toolchain sale de las imágenes (2026-09-22)
+
+**Cero reconstrucciones, como estaba previsto: no se tocó ninguna receta.** Lo que cambió es la
+proyección, declarada en `[solo-build]` de `docs/state/targets.toml` (44 entradas, cada una con su
+motivo) y aplicada por `yupana.membresia_imagen()`.
+
+## La regla que se implementó, y la que se descartó
+
+**Se poda por NODO, no por tipo de ARISTA.** Podar las aristas de build es lo que dejó a firefox sin
+`libstdc++.so.6`; acá una receta de la lista no entra a la imagen y no se explora desde ella, y todo
+lo demás sigue entrando por donde entraba. Consecuencia buscada: `qtbase`, `glib` o `harfbuzz`
+llegan por aristas de build de apps que sí viajan y **se quedan**. Decidir sobre cada librería es
+otra unidad de trabajo.
+
+**La válvula: una raíz declarada gana.** Por eso `servidor` sigue llevando `rust` y `lld21`, y `cli`
+sigue llevando `mold` — «la caja que sirve el repo y construye, sí».
+
+## Medido en disco, no calculado — hidratando de verdad con `--closure build|image`
+
+`hydrate-profile.py` ganó `--closure build` para poder medir el ANTES: sin control, un «−X MiB» es
+una afirmación sin testigo.
+
+| perfil | ficheros antes → después | tamaño antes → después |
+|---|---|---|
+| `base` | 15.743 → **8.228** | 1,4 G → **871 M** (−38 %) |
+| `servidor` | 40.235 → **11.516** | 4,9 G → **2,6 G** (−47 %) |
+
+⚠ Los dos lados se hidrataron con los **mismos 12 nodos ausentes** de este hub (`chrony`, `cronie`,
+`rust`, `nftables`… están en el store del worker): la comparación es válida, el absoluto es cota
+inferior. Y ⚠ **`du` de los dos árboles en UN solo comando miente**: proyectan por hardlink y `du`
+deduplica entre sus argumentos — daba 45 M para el segundo. Se miden por separado.
+
+Nodos por perfil (`build` = qué hay que construir, sigue igual; `imagen` = qué viaja):
+
+| perfil | build | imagen | fuera |
+|---|---|---|---|
+| `base` | 85 | **68** | 17 |
+| `cli` | 152 | **132** | 20 |
+| `servidor` | 185 | **165** | 20 |
+| `escritorio-mirada` | 41 | **23** | 18 |
+| `escritorio-kde` | 443 | **409** | 34 |
+| `escritorio-gnome` | 339 | **307** | 32 |
+| `escritorio-cosmic` | 295 | **260** | 35 |
+| `escritorio-sway` | 281 | **248** | 33 |
+
+El `base` hidratado ya no tiene `python3`, `perl`, `cmake`, `meson`, `binutils`, `nasm`, `bison`,
+`flex`, `m4`, `pkgconf`, `go` ni `rustc` — comprobado buscándolos en el árbol, no en la lista. Lo
+único que queda con ese nombre es `usr/share/cmake`, que son ficheros de datos que instala otra
+receta, no el programa.
+
+## El instrumento que faltaba: `scripts/vigia-interpretes.py`
+
+El vigía de sonames dio **0 huecos en los 8 perfiles** con el toolchain fuera (`--imagen --fail`,
+`rc=0`). Pero **un `.so` no es la única dependencia de ejecución: un `#!` también**, y de eso no
+había instrumento. El guardián nuevo compara cada script del cierre de imagen contra lo que la
+imagen provee, con dos tablas —`POR_CONTRATO` (andamiaje de build horneado dentro de una librería) y
+`POR_DECISION` (lo que se aceptó perder)— y falla con `--fail` ante cualquier caso no declarado.
+
+**Encontró tres cosas que ningún soname delataba, y las tres cambiaron la decisión:**
+
+| hallazgo | qué era | qué se hizo |
+|---|---|---|
+| `gnome-tweaks` es un **programa Python entero** | sin `python3` la app no abre | `python3` declarado en `escritorio-gnome` |
+| `gdctl` de mutter (configurar salidas) | idem | idem |
+| el `kconf_update` de plasma-workspace | **migra la config al ACTUALIZAR**; si no corre, no migra y nada lo dice | `python3` declarado en `escritorio-kde` |
+
+O sea: `python3` (209 MiB) viaja en las dos imágenes grandes —donde es el 2 % de 10-13 G— y **no**
+en `base`, `cli`, `servidor`, `sway`, `cosmic` ni `mirada`, donde en `base` era el 17 %.
+
+Lo que queda inerte a propósito, contado y nombrado (`•` en la salida del vigía): los 10 scripts
+perl de `git` (`send-email`, `svn`, `cvs*`, `gitweb.cgi`), los 4 de `lm-sensors`
+(`sensors-detect`…), los diagnósticos de `libinput`, `routel`, `rrsync`, `lsusb.py`, los filtros de
+`aerc` y `helper-mux` de squid. Un `#!` que no resuelve **falla ruidoso y nombra al intérprete**;
+por eso la decisión es defendible y no una fuga silenciosa. Revertir cualquiera es una línea en el
+`paquetes` del perfil.
+
+⚠ **Lo que no se midió:** un binario que llame al intérprete con `system()` sin `#!`. No hay
+instrumento para eso; lo caza el arranque, y por eso el criterio de terminado incluye
+`scripts/product-boot-test.sh` en QEMU, que esta jaula no puede correr.
+
+## Lo que NO cambió, a propósito
+
+- **`build-state.py` sigue contando `clausura` = `build ∪ runtime`** — es «qué hay que construir» y
+ para eso el toolchain hace falta. Ahora informa `imagen` al lado, en el mismo renglón.
+- **`repo-perfil.sh` sigue publicando el cierre entero**: un repositorio no es una imagen. Que se
+ pueda instalar `cmake` desde el repo es correcto; que venga horneado en el rootfs, no.
+- **Las recetas**: ninguna. `[solo-build]` vive en el manifiesto y no en el `.toml` justamente para
+ no entrar en `hash_inputs`.
+
+## Guardado contra regresiones
+
+`scripts/targets.py --selftest` gana 6 casos con roturas a propósito sobre la poda (control sin
+poda, poda transitiva, la válvula, podar algo inalcanzable, y que ninguna entrada de la lista sea
+raíz de todos los perfiles). Y se corrigió un comentario de `vigia-sonames.py` que afirmaba que «la
+hidratación de la imagen NO las instala: va por lista explícita» — era del OTRO linaje
+(`bootstrap product`), y por creerlo nadie miró los 1.527 MiB de LLVM que sí se instalaban.
diff --git a/docs/state/targets.toml b/docs/state/targets.toml
index 909d4c13..87ad77dc 100644
--- a/docs/state/targets.toml
+++ b/docs/state/targets.toml
@@ -19,6 +19,95 @@
# (marca los nodos `wanted` y calcula la membresía de perfil de cada receta).
schema = "hammer-targets/1"
+# ══ LO QUE NO VIAJA EN NINGUNA IMAGEN ══════════════════════════════════════════════════════════
+#
+# El cierre de un perfil se calcula sobre `build ∪ runtime` (`scripts/yupana.py:98`), porque para
+# CONSTRUIR hacen falta las dos y porque leer sólo `build` dejó a firefox sin `libstdc++.so.6`. Pero
+# la IMAGEN no se construye a sí misma: **medido el 2026-09-22, las 8 imágenes llevaban adentro
+# `llvm21` (1.527 MiB), `go`, `python3`, `binutils`, `cmake`, `perl`, `nasm`, `bison`, `flex`,
+# `wasi-sdk`, `nodejs`…** — el 44 % de los bytes de `servidor` y el 41 % de los de `base`.
+#
+# Es la MISMA forma que el caso busybox: `busybox` nunca estuvo declarado acá y llegaba a los 7
+# perfiles por la clausura, porque 24 recetas lo pedían en `[deps] build`. Lo que el paso 6 de
+# `docs/plan-botar-busybox.md` arregló para un nombre, esta lista lo arregla para la clase.
+#
+# ── LA REGLA, y por qué es ésta y no «usá sólo las aristas de runtime» ────────────────────────
+# Podar por tipo de ARISTA es lo que rompió firefox. Acá se poda por NODO: una receta de esta lista
+# no entra a ninguna imagen **ni se sigue explorando desde ella**, y todo lo demás sigue entrando
+# por donde entraba. Es conservador a propósito: `qtbase`, `glib` o `harfbuzz` llegan por aristas de
+# build de apps que sí viajan, y **se quedan** — decidir sobre cada librería es otra unidad de
+# trabajo, no ésta.
+#
+# ── LA VÁLVULA: una RAÍZ DECLARADA GANA ──────────────────────────────────────────────────────
+# Si un perfil declara en `paquetes` algo de esta lista, viaja en ESE perfil. No es una excepción
+# incómoda: es cómo `servidor` sigue llevando `rust`, `lld21` y `mold` —«la caja que sirve el repo y
+# construye, sí»— mientras los otros siete perfiles no los ven. Querer `python3` en una imagen es
+# una línea en su `paquetes`.
+#
+# ── CÓMO SE VALIDÓ (dos instrumentos, porque uno solo no alcanza) ─────────────────────────────
+# 1. `scripts/vigia-sonames.py --imagen --fail` — ¿queda algún `NEEDED` sin proveedor al podar?
+# Así se cayó **`libtool`** de esta lista: provee `libltdl.so.7` y lo pide `libcanberra`, que sí
+# viaja. Los `.so` los ve este vigía y NADIE MÁS.
+# 2. `scripts/vigia-interpretes.py` — los SONAME no ven un `#!`. Así se supo el precio exacto de
+# podar `perl` y `python3` (abajo), que ningún soname delataba.
+#
+# ⚠ **Esto NO re-hashea nada.** Es una propiedad de la IMAGEN, no de la receta: si el campo viviera
+# en el `.toml` entraría en `hash_inputs` y marcar 40 herramientas costaría 40 reconstrucciones más
+# las de sus consumidores. Vive acá, en el manifiesto de qué lleva cada imagen, que es de quien es.
+#
+# ⚠ **El cierre del GRAFO no cambia.** `build-state.py` sigue contando `clausura` = build ∪ runtime
+# (es «qué hay que construir», y para eso el toolchain hace falta) y ahora informa `imagen` al lado.
+# `repo-perfil.sh` tampoco cambia: un REPOSITORIO no es una imagen — que se pueda instalar `cmake`
+# desde el repo es correcto; que venga horneado en el rootfs, no.
+[solo-build]
+paquetes = [
+ # ── la cadena de compilación ──────────────────────────────────────────────────────────────────
+ # `llvm21` es el caso de libro: 1.527 MiB en la imagen del servidor y `rustc` NO lo abre —
+ # `readelf -d …/rustc` da `librustc_driver`+`libc` y nada más, porque `recipes/llvm21.toml:75`
+ # apaga el DYLIB a propósito («rustc quiere las estáticas»). 1,5 G para que nadie los mire.
+ "llvm18", "llvm21", "clang18",
+ # Enlazadores. `lld21` y `mold` son RAÍZ declarada (servidor y cli) ⇒ la válvula los deja viajar
+ # ahí; en los otros perfiles entraban por aristas de build y no tenían por qué.
+ "lld21", "mold", "binutils", "nasm",
+ # `rust` es RAÍZ de `servidor` (SDD 31, decisión escrita más abajo) ⇒ sigue viajando ahí. Se lista
+ # porque el día que otro perfil lo alcance por una arista de build, no debe entrar por eso.
+ "rust",
+ # Generadores y parsers del build. `samurai` y `meson` son los que arrastran a `python3`.
+ "make", "cmake", "meson", "samurai", "pkgconf", "bison", "flex", "m4", "gperf", "cbindgen",
+ # `nodejs`: el JS del build de Gecko. Llega a las 4 imágenes de escritorio por `atuq → firefox`.
+ "nodejs",
+ # `go`: el toolchain, 222 MiB. 366 recetas lo declaran build-dep y ninguna imagen lo necesita
+ # para CORRER lo que esas recetas producen (los binarios Go son estáticos).
+ "go",
+ # ── entradas de build que no son programas: headers, datos de perfilado, código a generar ─────
+ "linux-headers", "simde", "uthash", "nlohmann-json", "vulkan-headers", "glad",
+ "wasi-sdk", "wasi-libc", "wasi-libc-headers", "wasi-libcxx", "wasi-compiler-rt",
+ # `firefox-pgo-profile`: 118 MiB de perfil de ejecución que alimenta al compilador (ADR 0013, su
+ # fuente vive sólo en el mirror). Es una ENTRADA del build de firefox, no algo que firefox abra.
+ "firefox-pgo-profile",
+ # ── generadores de documentación y de traducciones ────────────────────────────────────────────
+ "scdoc", "itstool", "intltool", "perl-xml-parser", "sassc", "libsass",
+ # ── módulos de Python que sólo existen para que corran meson y los scripts de build ───────────
+ "markupsafe", "jinja2", "mako", "packaging",
+ # ── LOS DOS INTÉRPRETES, y lo que cuesta cada uno (medido, no estimado) ───────────────────────
+ # `python3` 209 MiB · `perl` 84 MiB. Ningún artefacto que viaja pide su `.so`: el vigía de sonames
+ # da cero huecos con los dos fuera. Lo que sí hay son SCRIPTS con `#!`, y por eso existe
+ # `vigia-interpretes.py`: los cuenta, los nombra y separa los que son codegen de build horneado
+ # dentro de una librería (`glib-genmarshal`, `g-ir-scanner`, ECM, `qt-*`: inertes por diseño) de
+ # los que un usuario podría ejecutar. Los segundos son **10**, y quedan inertes a propósito:
+ # perl → 9 de `git` (`git-send-email`, `git-svn`…) · `sensors-detect` de lm-sensors
+ # python3 → `libinput-analyze/measure` (22) · `routel` · `rrsync` · `lsusb.py` · 2 filtros de aerc
+ # Un `#!` que no resuelve falla RUIDOSO y nombra al intérprete (`exec: no such file`), que es el
+ # modo de fallo bueno — no es un `.so` que se resuelva contra el sysroot del lab ni un artefacto
+ # vacío. La alternativa era hornear 293 MiB en las 8 imágenes para esos 10 scripts.
+ # ⚠ Se revierte con UNA línea: `"perl"` o `"python3"` en el `paquetes` del perfil que los quiera.
+ # ⚠ Lo que NO se midió: un binario que llame al intérprete por `system()` sin `#!`. No hay
+ # instrumento para eso acá; lo caza el arranque en QEMU, y por eso el criterio de terminado del
+ # paso 1 incluye `product-boot-test.sh`.
+ "python3", "perl",
+]
+
+
[perfil.base]
descripcion = "userland foundational C: la distro arranca y se usa (shell, VCS, privilegios, red, disco, TLS, proceso, editor, docs)"
# Lift verbatim de BASE_SYSTEM_PKGS (scripts/product-userland-from-repo.sh). De-Alpinizado y firmado,
@@ -274,6 +363,13 @@ hereda = ["cli"]
# Raíces del rootfs de metal (docs/HANDOFF-noche-kde-2026-07-14.md:135). El resto del escritorio
# —137 artefactos hidratados— es CLAUSURA de estas 7, no lista a mano.
paquetes = [
+ # ── `python3` (2026-09-22): la VÁLVULA de `[solo-build]`, usada con el dato delante ───────────
+ # `python3` está en `[solo-build]` y NO viaja en `base`/`cli`/`servidor`/`sway`/`cosmic`/`mirada`.
+ # Acá SÍ, y no por precaución: `scripts/vigia-interpretes.py` nombró lo que se rompía sin él, y
+ # no son scripts accesorios —el `kconf_update` de plasma-workspace, que MIGRA la config del calendario al actualizar (si no corre, la migración no pasa y nada lo dice). Un `#!` que no resuelve falla al EJECUTAR, o
+ # sea el día que el usuario abre la app, no al armar la imagen.
+ # Coste: 209 MiB sobre una imagen de 12,9 G ⇒ ~2%. En `base` eran 209 sobre 1.070: el 17%.
+ "python3",
# `desktop-file-utils` (2026-09-09): `update-desktop-database` construye la caché
# MIME→APLICACIÓN. Con `shared-mime-info` el escritorio ya sabe QUÉ TIPO es un fichero; sin
# ésta sigue sin saber CON QUÉ abrirlo — el menú «abrir con» sale vacío y no hay aplicación
@@ -630,6 +726,13 @@ hereda = ["cli"]
# Dos documentos del repo diciendo cosas opuestas, y el que se mira primero es el grafo.
# Si algún día se autora GTK3, se vuelven a añadir acá y el objetivo reaparece solo.
paquetes = [
+ # ── `python3` (2026-09-22): la VÁLVULA de `[solo-build]`, usada con el dato delante ───────────
+ # `python3` está en `[solo-build]` y NO viaja en `base`/`cli`/`servidor`/`sway`/`cosmic`/`mirada`.
+ # Acá SÍ, y no por precaución: `scripts/vigia-interpretes.py` nombró lo que se rompía sin él, y
+ # no son scripts accesorios —`gnome-tweaks`, que es una app entera, y el `gdctl` de mutter (configurar las salidas). Un `#!` que no resuelve falla al EJECUTAR, o
+ # sea el día que el usuario abre la app, no al armar la imagen.
+ # Coste: 209 MiB sobre una imagen de 10,8 G ⇒ ~2%. En `base` eran 209 sobre 1.070: el 17%.
+ "python3",
# `desktop-file-utils` (2026-09-09): `update-desktop-database` construye la caché
# MIME→APLICACIÓN. Con `shared-mime-info` el escritorio ya sabe QUÉ TIPO es un fichero; sin
# ésta sigue sin saber CON QUÉ abrirlo — el menú «abrir con» sale vacío y no hay aplicación
diff --git a/scripts/build-state.py b/scripts/build-state.py
index 3a8ab5e9..433c9fc8 100755
--- a/scripts/build-state.py
+++ b/scripts/build-state.py
@@ -390,10 +390,19 @@ def main():
# `by_profile` (el "cuánto falta" por imagen) SÍ es local: sólo cuenta estados de nodos que esta
# vista cargó ⇒ se limita a los perfiles cuya cola está cargada (los demás darían conteos falsos).
+ # ⚠ **`clausura` e `imagen` son DOS NÚMEROS Y NO UNO** (2026-09-22). `clausura` sigue siendo
+ # `build ∪ runtime` porque contesta «qué hay que construir para tener esta imagen», y para eso el
+ # toolchain hace falta. `imagen` es lo que el rootfs LLEVA: descuenta las herramientas declaradas
+ # en `[solo-build]` de `targets.toml` (`llvm21`, `go`, `python3`, `perl`, `cmake`…), que es lo que
+ # `hydrate-profile.py` proyecta desde hoy. Se informan los dos porque el día que sólo se informó
+ # uno, las 8 imágenes llevaban 1,5 G de LLVM adentro y ninguna métrica lo decía.
+ memb_img = yupana.membresia_imagen(todas, cargar_perfiles({q for q in todas}))
+ en_imagen = {n: sorted(memb_img.get((r.get("queue", "corpus"), n), ())) for n, r in recs.items()}
by_profile = {}
for pn, p in sorted(perfiles.items()):
miembros = [n for n, r in recs.items() if pn in r["perfiles"]]
by_profile[pn] = dict(raices=len(p["raices"]), clausura=len(miembros),
+ imagen=sum(1 for n in miembros if pn in en_imagen[n]),
**Counter(recs[n]["state"] for n in miembros))
by_state = Counter(r["state"] for r in recs.values())
@@ -466,6 +475,7 @@ def main():
# 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" · imagen {c.get('imagen', c['clausura']):3}"
f" (raíces {c['raices']}"
+ (f", sin receta {c['wanted']}" if c.get("wanted") else "")
# Se dicen APARTE, nunca sumadas a las nativas: son la parte del sistema sin
diff --git a/scripts/hydrate-profile.py b/scripts/hydrate-profile.py
index 3d76a912..66a179df 100755
--- a/scripts/hydrate-profile.py
+++ b/scripts/hydrate-profile.py
@@ -1,5 +1,10 @@
#!/usr/bin/env python3
-# hydrate-profile.py — proyecta al FHS el cierre ENTERO de un perfil de `targets.toml`.
+# hydrate-profile.py — proyecta al FHS el cierre de IMAGEN de un perfil de `targets.toml`.
+#
+# ⚠ Decía «el cierre ENTERO», y eso era literal y era el bug: proyectaba `build ∪ runtime`, o sea la
+# cadena de compilación adentro de cada imagen (`llvm21`, `go`, `python3`, `perl`, `cmake`…). Desde el
+# 2026-09-22 proyecta `yupana.membresia_imagen()`, que descuenta las herramientas declaradas en
+# `[solo-build]` de `targets.toml` — salvo las que un perfil declare como raíz, donde gana el perfil.
#
# ══ POR QUÉ EXISTE, SI YA HAY UN hydrate POR ESCRITORIO ════════════════════════════════════════
# `scripts/{kde,gnome,cosmic}/hydrate-*.sh` traen la lista de raíces ESCRITA A MANO dentro del
@@ -25,6 +30,9 @@
#
# uso: scripts/hydrate-profile.py <perfil> [--into DIR] [--store DIR]
# [--link static|dynamic] [--keep] [--allow-missing]
+# [--closure image|build]
+# --closure build → proyecta el cierre ENTERO (con herramientas), como antes del
+# 2026-09-22. Es el CONTROL para poder medir el antes y el después.
#
# La superficie va en INGLÉS (`CLAUDE.md` regla 4); los mensajes, en castellano.
import os, sys, glob, shutil, subprocess
@@ -231,12 +239,33 @@ def main():
if perfil not in perfiles:
sys.exit("perfil desconocido: %s (hay: %s)" % (perfil, ", ".join(sorted(perfiles))))
colas = yupana.cargar_colas()
- memb = yupana.membresia(colas, perfiles)
+ # ⚠ **EL CIERRE DE IMAGEN, NO EL DE BUILD** (2026-09-22). Esto proyectaba `membresia()`, que es
+ # `build ∪ runtime`, así que el rootfs salía con la cadena de compilación adentro: `llvm21`
+ # (1.527 MiB), `go`, `python3`, `binutils`, `cmake`, `perl`, `nasm`, `wasi-sdk`, `nodejs`… — el
+ # 44 % de los bytes de `servidor`. Nadie lo había medido porque el cierre de build es la
+ # respuesta correcta a OTRA pregunta («qué hay que construir»), y acá se usaba para ésta.
+ # La lista de lo que no viaja está declarada en `[solo-build]` de `targets.toml`, con sus dos
+ # vigías. No toca ninguna receta ⇒ CERO reconstrucciones.
+ # `--closure build` proyecta el cierre VIEJO (con herramientas). No es una opción de uso: es el
+ # control del experimento — sin poder medir el antes, «−X MiB» es una afirmación sin testigo.
+ cierre = opcion("--closure", "image")
+ if cierre not in ("image", "build"):
+ sys.exit("--closure: `image` (lo que viaja) o `build` (el cierre entero, para comparar)")
+ memb = (yupana.membresia_imagen(colas, perfiles) if cierre == "image"
+ else yupana.membresia(colas, perfiles))
nodos = sorted(n for n, ps in memb.items() if perfil in ps and n[0] != "wanted")
if not nodos:
sys.exit("el cierre de %s salió vacío — ¿el perfil declara raíces?" % perfil)
- print("== %s: %d nodos en el cierre → %s" % (perfil, len(nodos), into))
+ # Se imprimen los DOS números y la diferencia. Un recorte que no se ve en la salida es un recorte
+ # que alguien va a diagnosticar como «faltan paquetes» dentro de seis meses.
+ memb_build = yupana.membresia(colas, perfiles)
+ n_build = sum(1 for n, ps in memb_build.items() if perfil in ps and n[0] != "wanted")
+ print("== %s: %d nodos en el cierre de %s → %s"
+ % (perfil, len(nodos), "IMAGEN" if cierre == "image" else "BUILD", into))
+ if cierre == "image":
+ print(" (%d en el cierre de build; %d herramientas de `[solo-build]` no viajan)"
+ % (n_build, n_build - len(nodos)))
# ── resolver ANTES de tocar el disco: si falta algo, que se sepa antes de borrar nada ──────
artefactos, faltan = [], []
@@ -333,7 +362,7 @@ def main():
sys.exit(1 if (fallidos or (faltan and not permitir_faltantes)) else 0)
-FLAGS_CON_VALOR = {"--into", "--store", "--link"}
+FLAGS_CON_VALOR = {"--into", "--store", "--link", "--closure"}
# Lo que un artefacto PUEDE poner en la raíz. Todo lo demás es una fase `install` que se equivocó de
# destino, y en un rootfs fundido eso se lee como si fuera parte del sistema.
diff --git a/scripts/targets.py b/scripts/targets.py
index 9e0080b8..cd78f0c5 100755
--- a/scripts/targets.py
+++ b/scripts/targets.py
@@ -7,6 +7,9 @@
# Uso: scripts/targets.py cli # raíces expandidas, una línea separada por espacios
# scripts/targets.py --lista # perfiles disponibles, con su descripción
# scripts/targets.py --json # todo el manifiesto expandido
+# ⚠ 2026-09-22: la salida pasó de ser `{perfil: {...}}` a `{"perfil": {...}, "solo-build":
+# [...]}`. Se comprobó que NADIE la consumía (grep en todo el árbol) antes de cambiarla.
+# scripts/targets.py --tools # las herramientas que NO viajan en ninguna imagen
# scripts/targets.py --services cli # los servicios que ESA imagen arranca, resueltos
# scripts/targets.py --selftest # roturas a propósito + un control que TIENE que pasar
# scripts/targets.py --service-paths gnome # rutas de receta de los servicios de SISTEMA
@@ -75,6 +78,21 @@ def load(path=DEFAULT):
return perfiles
+def solo_build(path=DEFAULT):
+ """Los paquetes declarados `[solo-build]`: herramientas de construcción que NO viajan en ninguna
+ imagen (el porqué largo, y los dos vigías que validan la lista, están en el propio `targets.toml`).
+
+ Se lee de acá y NO de la receta a propósito: en la receta el campo entraría en `hash_inputs` y
+ marcar 44 herramientas costaría 44 reconstrucciones más las de sus consumidores. «No viaja en una
+ imagen» es una propiedad de la imagen.
+
+ ⚠ No valida que los nombres existan: una herramienta que no está en el corpus es una entrada
+ INERTE, no un error — y la lista tiene que poder nombrar lo que todavía no se importó. Quien
+ quiera esa comprobación es `build-state.py`, que ya conoce las colas."""
+ doc = tomllib.load(open(path, "rb"))
+ return list(doc.get("solo-build", {}).get("paquetes", []))
+
+
# ── El lado «habilitar»: perfil → servicios resueltos ────────────────────────────────────────────
#
# Un servicio se declara en la receta (`[[service]]`, SDD 30 §3.1) y se HABILITA en el perfil
@@ -267,6 +285,57 @@ def autotest():
return todo_ok
+def autotest_imagen():
+ """Roturas a propósito sobre la PODA de `[solo-build]` (la vista de imagen de `yupana`).
+
+ Va acá y no en yupana porque acá está el arnés —y porque la poda es una propiedad del
+ MANIFIESTO, no del grafo—. `import` perezoso: yupana importa este módulo arriba del todo.
+
+ El primer caso es el CONTROL, por la misma razón de siempre: un guardián que sólo se prueba con
+ casos rotos puede estar gritando siempre."""
+ import yupana
+
+ # grafo de juguete: app → lib → libhonda ; app → cmake → libsolodelbuild
+ colas = {"corpus": {"app": ["lib", "cmake"], "lib": ["libhonda"], "libhonda": [],
+ "cmake": ["libsolodelbuild"], "libsolodelbuild": [], "go": []}}
+ def perfil(raices):
+ return {"p": dict(cola="corpus", raices=list(raices), paquetes=list(raices),
+ hereda=[], descripcion="", servicios=[], servicios_exp=[])}
+ def cierre(raices, podar):
+ m = yupana.membresia(colas, perfil(raices), podar=podar)
+ return {n for (q, n), ps in m.items() if "p" in ps}
+
+ casos = [
+ ("CONTROL: sin poda entra todo, como siempre",
+ cierre(["app"], None), {"app", "lib", "libhonda", "cmake", "libsolodelbuild"}),
+ ("podada una herramienta: no entra ELLA ni lo que sólo ella arrastra",
+ cierre(["app"], {"cmake"}), {"app", "lib", "libhonda"}),
+ ("y lo que llega por OTRO camino se queda (la poda es por nodo, no por arista)",
+ cierre(["app"], {"libhonda"}), {"app", "lib", "cmake", "libsolodelbuild"}),
+ ("LA VÁLVULA: declarada como raíz, la herramienta viaja igual",
+ cierre(["app", "cmake"], {"cmake"}), {"app", "lib", "libhonda", "cmake", "libsolodelbuild"}),
+ ("podar algo que nadie alcanza no cambia nada",
+ cierre(["app"], {"go"}), {"app", "lib", "libhonda", "cmake", "libsolodelbuild"}),
+ ]
+ todo_ok = True
+ for nombre, obtenido, esperado in casos:
+ ok = obtenido == esperado
+ todo_ok &= ok
+ print(f"{'✓' if ok else '✗'} {nombre}")
+ if not ok:
+ print(f" esperaba {sorted(esperado)}\n obtuvo {sorted(obtenido)}")
+ # Y el caso que el juguete no puede probar: que la lista REAL no nombre algo que además es raíz
+ # de TODOS los perfiles (sería una entrada que no poda nada y engaña al que la lee).
+ perfiles = load()
+ inutiles = [n for n in solo_build()
+ if perfiles and all(n in p["raices"] for p in perfiles.values())]
+ ok = not inutiles
+ todo_ok &= ok
+ print(f"{'✓' if ok else '✗'} ninguna entrada de `[solo-build]` es raíz de TODOS los perfiles"
+ + ("" if ok else f" — inerte: {', '.join(inutiles)}"))
+ return todo_ok
+
+
def main():
args = sys.argv[1:]
try:
@@ -281,7 +350,15 @@ def main():
print(f"{n:20} {len(p['raices']):3} raíces [{p['cola']}] {p['descripcion']}")
return 0
if args[0] == "--selftest":
- return 0 if autotest() else 1
+ ok = autotest()
+ print()
+ ok &= autotest_imagen()
+ return 0 if ok else 1
+ if args[0] == "--tools":
+ # La superficie va en INGLÉS (regla 4): esto lo consumen los scripts de imagen.
+ for n in solo_build():
+ print(n)
+ return 0
if args[0] == "--service-paths":
# Las RUTAS de receta de los servicios de SISTEMA habilitados, para que el ensamblado de la
# imagen se las pase a `takana service cards`. Sólo `system`: una Card de sesión en el
@@ -348,7 +425,10 @@ def main():
# Sólo un ERROR hace fallar: un AVISO es para leer, no para romper el cron.
return 1 if any(x.startswith("ERROR") for x in problemas) else 0
if args[0] == "--json":
- print(json.dumps(perfiles, indent=1, ensure_ascii=False)); return 0
+ # `solo-build` va al lado de los perfiles y no dentro de cada uno: es UNA lista global, y
+ # repetirla por perfil invitaría a que divergieran.
+ print(json.dumps({"perfil": perfiles, "solo-build": solo_build()},
+ indent=1, ensure_ascii=False)); return 0
n = args[0]
if n not in perfiles:
diff --git a/scripts/vigia-interpretes.py b/scripts/vigia-interpretes.py
new file mode 100755
index 00000000..efc9c0d5
--- /dev/null
+++ b/scripts/vigia-interpretes.py
@@ -0,0 +1,228 @@
+#!/usr/bin/env python3
+# vigia-interpretes.py — ¿qué script de una imagen pide un `#!` que esa imagen NO lleva?
+#
+# ══ EL PUNTO CIEGO QUE ESTE VIGÍA CUBRE ════════════════════════════════════════════════════════
+# `vigia-sonames.py` contesta la misma pregunta para los `.so` y es el guardián del cierre dinámico.
+# **No ve un `#!`.** Y el 2026-09-22, al declarar `[solo-build]` en `targets.toml` para sacar la
+# cadena de compilación de las imágenes, esa diferencia era justo la que importaba: con `python3` y
+# `perl` fuera, el vigía de sonames daba **cero huecos en los 8 perfiles** —los dos intérpretes no
+# los enlaza nadie— y sin embargo quedaban **10 scripts de usuario inertes**, entre ellos
+# `git send-email` y `sensors-detect`. Ningún instrumento del repo lo habría dicho.
+#
+# La regla: **un cierre no es sólo lo que se enlaza; es también lo que se ejecuta.** Un `#!` es una
+# dependencia de ejecución igual de real que un `NEEDED`, con la diferencia de que falla RUIDOSO y
+# nombrando al intérprete (`exec: no such file or directory`), no silenciosamente contra el sysroot
+# del lab. Eso hace la poda defendible — y este vigía es lo que la mantiene honesta.
+#
+# ══ LAS DOS TABLAS, Y POR QUÉ SON DOS ══════════════════════════════════════════════════════════
+# Igual que `hydrate-profile.py` separa `RAIZ_POR_CONTRATO` de `RAIZ_DEUDA_DECIDIDA`:
+# · `POR_CONTRATO` — andamiaje de BUILD horneado dentro de una librería que sí viaja
+# (`glib-genmarshal`, `g-ir-scanner`, los módulos de ECM, el `libexec` de qtbase). Inerte por
+# diseño: nadie los ejecuta en una caja instalada. Se imprimen con ⊘ y no fallan.
+# · `POR_DECISION` — los que un usuario SÍ podría ejecutar y quedaron inertes a propósito al no
+# hornear 293 MiB de intérpretes en las 8 imágenes. Se imprimen con • y **no fallan, pero se
+# listan siempre**: una decisión que deja de verse se convierte en un olvido.
+# Cualquier otra cosa sale con ✗ y hace fallar con `--fail`. Un ofensor NUEVO no se puede perder en
+# el ruido de uno viejo — es la misma lección que el bloque de raíces sucias.
+#
+# uso: python3 scripts/vigia-interpretes.py [perfil...] [--fail] [--store DIR] [--rootfs DIR]
+# --fail → exit 1 si hay un `#!` sin proveedor que no esté en las tablas
+# --rootfs → comprueba contra un rootfs YA hidratado (exacto: no asume nada de la capa
+# base). Sin él se mide el cierre de imagen del perfil + `CAPA_BASE`.
+#
+# La superficie va en INGLÉS (regla 4 del CLAUDE.md); los mensajes, en castellano.
+import os, re, sys, glob, subprocess
+
+sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
+import yupana
+import targets as targets_mod
+
+ROOT = yupana.ROOT
+HAMMER = str(ROOT / "target/release/takana")
+
+# Lo que pone la CAPA BASE y no el cierre del perfil: el `product-rootfs` del bootstrap
+# (`STAGE1_COMPONENTS` = musl busybox hammerd arje-zero · `USERLAND_COMPONENTS` = uutils findutils
+# findutils-xargs diffutils ripgrep). Se declara explícito y corto porque asumirlo en silencio sería
+# el mismo error que este vigía existe para cazar. Con `--rootfs` no se asume: se mira el árbol.
+CAPA_BASE = {"bin/sh", "bin/busybox", "usr/bin/env", "bin/env", "usr/bin/coreutils"}
+
+# Andamiaje de build horneado dentro de un paquete de runtime. {paquete: (motivo, patrón de rutas)}
+POR_CONTRATO = {
+ # `gtester-report` y `glib-gettextize` no empiezan por `glib-`: el patrón corto los dejaba en ✗.
+ "glib": ("codegen del build de GLib, no programas del sistema",
+ r"usr/bin/(glib-|gdbus-codegen|gtester-report)"),
+ "glib-shared": ("idem glib", r"usr/bin/(glib-|gdbus-codegen|gtester-report)"),
+ "glib-introspected": ("idem glib", r"usr/bin/(glib-|gdbus-codegen|gtester-report)"),
+ "gobject-introspection":("el escáner de introspección corre en el BUILD", r"usr/bin/g-ir-"),
+ "extra-cmake-modules": ("son módulos de cmake, no ejecutables de la imagen", r"usr/share/ECM/"),
+ "qtbase": ("andamiaje de build de Qt (qt-cmake, mkspecs, libexec)",
+ r"usr/lib/(qt6/(libexec|bin|mkspecs)/|cmake/Qt6/)"),
+ "libevent": ("generador de RPC del build", r"usr/bin/event_rpcgen\.py"),
+ "upower": ("los dos son TESTS del upstream, no parte del servicio", r"usr/libexec/upower/"),
+ "nghttp2": ("`fetch-ocsp-response`: helper opcional del servidor de ejemplo",
+ r"usr/share/nghttp2/"),
+ "libpsl": ("generador de la lista de sufijos, del build", r"usr/bin/psl-make-dafsa"),
+ "nspr": ("generador de tablas de error, del build", r"usr/bin/compile-et\.pl"),
+ "vim": ("ejemplos que vim DOCUMENTA bajo share/vim, no comandos", r"usr/share/vim/"),
+ "helix": ("completado de OTRA shell (fish): es dato, no un comando", r"usr/share/fish/"),
+ "zsh": ("fixtures de test del upstream (con su `/usr/local` horneado)", r"usr/share/zsh/functions/"),
+ "libtool": ("fuentes de libltdl, no ejecutables", r"usr/share/libtool/"),
+ "opencv": ("script de entorno para compilar CONTRA opencv", r"usr/bin/setup_vars_opencv4\.sh"),
+ "plasma-desktop": ("script de extracción de traducciones del tema", r"usr/share/sddm/themes/"),
+ # CPython hornea `#!/usr/local/bin/python` en `cgi.py` desde siempre (es un módulo que se
+ # IMPORTA, no un programa). Aparece sólo en los perfiles donde `python3` sí viaja.
+ "python3": ("el `#!` del upstream en el stdlib: `cgi.py` se importa, no se ejecuta",
+ r"usr/lib/python3\.\d+/"),
+}
+
+# Inertes por la DECISIÓN de `[solo-build]` (los 293 MiB de `python3` + `perl`). Cada entrada dice
+# qué se pierde, para que la decisión se pueda revisar con el dato delante y no de memoria.
+POR_DECISION = {
+ # ⚠ Las rutas de git son TRES sitios, no uno, y el patrón corto las dejaba caer a ✗: además del
+ # `libexec`, hay `usr/bin/git-cvsserver`, el `gitweb.cgi` y un hook de plantilla. Lo destapó este
+ # vigía en su primera corrida, que es exactamente para lo que se escribió.
+ "git": ("10 scripts perl: send-email, svn, cvs*, instaweb, gitweb.cgi y el hook "
+ "fsmonitor-watchman.sample", r"usr/(libexec/git-core/|bin/git-cvsserver|share/git(-core|web)/)"),
+ "lm-sensors": ("4 scripts perl: sensors-detect, fancontrol, pwmconfig, sensors-conf-convert "
+ "(el `sensors` en C y libsensors siguen)", r"usr/(sbin/|bin/sensors-conf-convert)"),
+ "libinput": ("las herramientas de diagnóstico libinput-analyze/measure", r"usr/libexec/libinput/"),
+ "iproute2": ("`routel`, el listador de rutas en python", r"sbin/routel"),
+ "rsync": ("`rrsync`, el envoltorio restringido para ssh", r"usr/bin/rrsync"),
+ "usbutils": ("`lsusb.py`, la vista de árbol (el `lsusb` en C sigue)", r"usr/bin/lsusb\.py"),
+ "aerc": ("el filtro de invitaciones .ics y `carddav-query`",
+ r"usr/(libexec/aerc/filters/|bin/carddav-query)"),
+ "gnome-shell":("`gnome-shell-test-tool`, sólo para pruebas", r"usr/bin/gnome-shell-test-tool"),
+ "evolution-data-server": ("`csv2vcard`, importador de contactos", r"usr/libexec/evolution-data-server/"),
+ "squid": ("`helper-mux`, multiplexor de helpers perl", r"usr/lib/squid/helper-mux"),
+}
+
+_cache = {}
+def artefacto(cola, nombre, store):
+ if (cola, nombre) in _cache:
+ return _cache[(cola, nombre)]
+ receta = ROOT / ("recipes/%s.toml" % nombre if cola == "corpus"
+ else "recipes/%s/%s.toml" % (cola, nombre))
+ d = None
+ if receta.exists():
+ r = subprocess.run([HAMMER, "--store", store, "hash", str(receta)],
+ capture_output=True, text=True)
+ h = r.stdout.strip().replace("b3:", "")
+ if len(h) == 64:
+ cand = glob.glob(os.path.join(store, h + "-*"))
+ d = cand[0] if cand else None
+ _cache[(cola, nombre)] = d
+ return d
+
+
+def shebang(p):
+ """La línea `#!` de un fichero, o None. Lee 2 bytes antes de decidir: abrir y leer una línea de
+ todo el rootfs es caro y la inmensa mayoría no son scripts."""
+ try:
+ with open(p, "rb") as fh:
+ if fh.read(2) != b"#!":
+ return None
+ fh.seek(0)
+ return fh.readline(300).decode("utf8", "replace").strip()
+ except OSError:
+ return None
+
+
+def interprete(linea, provee):
+ """Del `#!` a la RUTA del intérprete, resuelta. `#!/usr/bin/env python3` no nombra una ruta: se
+ busca `python3` entre lo que la imagen provee — y si no está, se devuelve el nombre para poder
+ decir QUÉ falta (`env python3`, no `env`)."""
+ partes = linea[2:].strip().split()
+ if not partes:
+ return None
+ ruta = partes[0].lstrip("/")
+ if os.path.basename(ruta) == "env" and len(partes) > 1:
+ nombre = partes[1]
+ if nombre.startswith("-"): # `env -S foo` y amigos
+ nombre = partes[-1]
+ for cand in provee:
+ if os.path.basename(cand) == nombre:
+ return cand
+ return "env " + nombre
+ return ruta
+
+
+def escanear_dir(raiz_dir, etiqueta, provee, scripts):
+ for raiz, _, ficheros in os.walk(raiz_dir):
+ for fn in ficheros:
+ p = os.path.join(raiz, fn)
+ rel = os.path.relpath(p, raiz_dir)
+ if os.path.islink(p):
+ provee.add(rel)
+ continue
+ provee.add(rel)
+ # `/share/doc` y `/testdata` no son cierre: nadie los ejecuta. Mismo criterio que el
+ # vigía de sonames con el `testdata/` de Go.
+ if "/share/doc/" in p or "/testdata/" in p:
+ continue
+ linea = shebang(p)
+ if linea:
+ scripts.append((etiqueta, rel, linea))
+
+
+def main():
+ args = [a for a in sys.argv[1:] if not a.startswith("--")]
+ fallar = "--fail" in sys.argv
+ def opcion(f):
+ return sys.argv[sys.argv.index(f) + 1] if f in sys.argv else None
+ store = opcion("--store") or os.environ.get("HAMMER_STORE", str(ROOT / "store"))
+ rootfs = opcion("--rootfs")
+ # los valores de las flags no son perfiles
+ args = [a for a in args if a not in (store, rootfs)]
+
+ perfiles = targets_mod.load()
+ ofensores = 0
+
+ if rootfs:
+ provee, scripts = set(), []
+ escanear_dir(rootfs, "(rootfs)", provee, scripts)
+ casos = [("%s" % rootfs, provee, scripts)]
+ else:
+ memb = yupana.membresia_imagen()
+ casos = []
+ for perfil in (args or sorted(perfiles)):
+ provee, scripts = set(CAPA_BASE), []
+ for (cola, nombre) in sorted(n for n, ps in memb.items()
+ if perfil in ps and n[0] != "wanted"):
+ d = artefacto(cola, nombre, store)
+ if d:
+ escanear_dir(d, nombre, provee, scripts)
+ casos.append((perfil, provee, scripts))
+
+ for etiqueta, provee, scripts in casos:
+ faltan = [(pkg, rel, it) for pkg, rel, ln in scripts
+ for it in [interprete(ln, provee)] if it and it not in provee]
+ print("== %-20s %4d scripts con `#!` · %3d sin intérprete en la imagen"
+ % (etiqueta, len(scripts), len(faltan)))
+ # agrupado por (paquete, intérprete): 22 líneas iguales de libinput no informan más que una
+ grupos = {}
+ for pkg, rel, it in faltan:
+ grupos.setdefault((pkg, it), []).append(rel)
+ nuevos = []
+ for (pkg, it), rutas in sorted(grupos.items()):
+ tabla, marca = None, "✗"
+ for t, m in ((POR_CONTRATO, "⊘"), (POR_DECISION, "•")):
+ motivo_patron = t.get(pkg)
+ if motivo_patron and all(re.match(motivo_patron[1], r) for r in rutas):
+ tabla, marca = motivo_patron, m
+ break
+ print(" %s %-22s %-14s %3d %s" % (marca, pkg, it, len(rutas), rutas[0]))
+ if tabla:
+ print(" %s" % tabla[0])
+ else:
+ nuevos.append("%s (%s)" % (pkg, it))
+ if nuevos:
+ ofensores += len(nuevos)
+ print("\n ✗ %d caso(s) NO declarado(s): %s\n"
+ " Un `#!` que no resuelve falla al ejecutar, no al instalar: o el intérprete\n"
+ " entra a la imagen (una línea en `paquetes`), o el caso entra a una tabla de\n"
+ " este vigía con su motivo." % (len(nuevos), ", ".join(nuevos)))
+ return 1 if (fallar and ofensores) else 0
+
+
+if __name__ == "__main__":
+ sys.exit(main())
diff --git a/scripts/vigia-sonames.py b/scripts/vigia-sonames.py
index 9145ccbf..bc07fe0e 100755
--- a/scripts/vigia-sonames.py
+++ b/scripts/vigia-sonames.py
@@ -28,14 +28,22 @@
# decisión de vista; QUIÉN ME CONSUME —y a qué imagen pertenezco— es un hecho.
#
# ══ CÓMO LEER LA SALIDA, que tiene ruido legítimo ══════════════════════════════════════════════
-# El cierre de un perfil incluye sus HERRAMIENTAS DE BUILD (python3, cmake, perl, go…), y la
-# hidratación de la imagen NO las instala: va por lista explícita. Sus NEEDED aparecen igual acá y
-# **no son un bug de la imagen**. Lo accionable es lo que pide un componente de RUNTIME — mesa,
-# wayland, el compositor, una app. Por eso se imprime siempre QUIÉN pide cada soname: sin esa
-# columna el informe no se puede triar.
+# El cierre de BUILD de un perfil incluye sus herramientas (python3, cmake, perl, go…) y sus NEEDED
+# aparecen acá. Lo accionable es lo que pide un componente de RUNTIME — mesa, wayland, el compositor,
+# una app. Por eso se imprime siempre QUIÉN pide cada soname: sin esa columna no se puede triar.
#
-# uso: python3 scripts/vigia-sonames.py [perfil...] [--fail]
-# --fail → exit 1 si algo falta (para cron/CI)
+# ⚠ **Y este comentario decía algo FALSO hasta el 2026-09-22**: «la hidratación de la imagen NO las
+# instala: va por lista explícita». La lista explícita es la del OTRO linaje —`takana bootstrap
+# product`, con sus `STAGE1_COMPONENTS`/`USERLAND_COMPONENTS`—; `hydrate-profile.py` proyectaba el
+# cierre entero, herramientas incluidas. O sea que el ruido que este vigía enseñaba a ignorar era, en
+# la imagen del perfil, contenido real: 1.527 MiB de `llvm21` adentro de `servidor`. Ahora existe
+# `[solo-build]` en `targets.toml` y con `--imagen` se mide lo que de verdad se proyecta.
+#
+# uso: python3 scripts/vigia-sonames.py [perfil...] [--fail] [--imagen]
+# --fail → exit 1 si algo falta (para cron/CI)
+# --imagen → mide el cierre de IMAGEN (sin las herramientas de `[solo-build]`), que es lo
+# que `hydrate-profile.py` proyecta de verdad. Sin la flag mide el de build, que
+# es la vista histórica de este vigía.
import os, sys, glob, subprocess
sys.path.insert(0, os.path.join(os.path.dirname(os.path.abspath(__file__))))
import yupana
@@ -132,7 +140,9 @@ def main():
fallar = "--fail" in sys.argv
colas = yupana.cargar_colas()
perfiles = targets_mod.load()
- memb = yupana.membresia(colas, perfiles)
+ solo_imagen = "--imagen" in sys.argv
+ memb = (yupana.membresia_imagen(colas, perfiles) if solo_imagen
+ else yupana.membresia(colas, perfiles))
# ⚠ **POR DEFECTO, TODOS LOS PERFILES.** Antes era `p.startswith("escritorio")`, y ese filtro
# dejaba fuera a `base`, `cli` y `servidor` — o sea al objetivo de la mudanza. El coste se midió
# el 2026-09-13 apuntándolo a mano: los tres tenían `FALTA libffi.so.8 ← lo piden: python3`, con
@@ -148,8 +158,9 @@ def main():
provee, pide = escanear(nodos)
# `libc.so` es el intérprete musl: lo pone el rootfs base, no una receta del cierre.
faltan = {s: v for s, v in pide.items() if s not in provee and s != "libc.so"}
- print("== %-20s %3d nodos · %4d sonames provistos · %2d sin proveedor"
- % (perfil, len(nodos), len(provee), len(faltan)))
+ print("== %-20s %3d nodos (%s) · %4d sonames provistos · %2d sin proveedor"
+ % (perfil, len(nodos), "imagen" if solo_imagen else "build",
+ len(provee), len(faltan)))
for s, quienes in sorted(faltan.items()):
print(" FALTA %-24s ← lo piden: %s" % (s, ", ".join(sorted(quienes))))
huecos += len(faltan)
diff --git a/scripts/yupana.py b/scripts/yupana.py
index 80980113..8bcb2deb 100755
--- a/scripts/yupana.py
+++ b/scripts/yupana.py
@@ -660,16 +660,25 @@ def dependientes(colas=None):
return rev
-def membresia(colas=None, perfiles=None):
+def membresia(colas=None, perfiles=None, podar=None):
"""{(cola,nombre): set(perfiles que lo alcanzan)} sobre el grafo entero, respetando sibling-first.
Un nudo pertenece a una imagen si la clausura de sus raíces —resolviendo cada cuerda desde la
- cola del consumidor— llega hasta él. Esto es lo que arregla la mentira de `perfiles`."""
+ cola del consumidor— llega hasta él. Esto es lo que arregla la mentira de `perfiles`.
+
+ `podar` (conjunto de NOMBRES de receta) recorta la exploración: un nudo podado no entra a la
+ membresía **y no se sigue explorando desde él**, salvo que sea RAÍZ DECLARADA del perfil, donde la
+ declaración gana. Es la vista de IMAGEN (`membresia_imagen`); el default —sin poda— sigue siendo
+ la de BUILD, que es la que necesita el grafo de estado («qué hay que construir»)."""
colas = colas or cargar_colas()
perfiles = perfiles if perfiles is not None else targets_mod.load()
+ podar = set(podar or ())
indice = {q: set(m) for q, m in colas.items()}
memb = {}
for pn, p in perfiles.items():
cola_p = p.get("cola", "corpus")
+ # La válvula. Se compara contra las raíces EXPANDIDAS (`hereda` incluido): una herramienta
+ # declarada en `cli` tiene que seguir viajando en `servidor`, que hereda de `cli`.
+ raices_p = set(p["raices"])
vistos, pila = set(), []
for raiz in p["raices"]:
nodo = resolver(raiz, cola_p, indice)
@@ -685,10 +694,33 @@ def membresia(colas=None, perfiles=None):
continue
for d in colas.get(q, {}).get(nombre, []):
sig = resolver(d, q, indice)
- pila.append(sig if sig else ("wanted", d))
+ sig = sig if sig else ("wanted", d)
+ if sig[1] in podar and sig[1] not in raices_p:
+ continue
+ pila.append(sig)
return memb
+def membresia_imagen(colas=None, perfiles=None):
+ """La membresía de la IMAGEN: `membresia()` menos las herramientas de `[solo-build]`.
+
+ ══ POR QUÉ EXISTEN DOS VISTAS Y NO UNA ════════════════════════════════════════════════════════
+ El cierre de un perfil se calcula sobre `build ∪ runtime` porque para CONSTRUIR hacen falta las
+ dos (y porque leer sólo `build` dejó a firefox sin `libstdc++.so.6`, ver `_deps`). Pero la imagen
+ no se construye a sí misma: medido el 2026-09-22, las 8 imágenes llevaban adentro `llvm21`
+ (1.527 MiB), `go`, `python3`, `binutils`, `cmake`, `perl`… — el 44 % de los bytes de `servidor`.
+ Es la misma forma que el caso busybox, que llegaba a los 7 perfiles por 24 `[deps] build`.
+
+ **La poda es por NODO, no por tipo de ARISTA**, y eso no es un detalle: podar las aristas de
+ build es lo que rompió firefox. Así, una librería que llega por la arista de build de una app que
+ sí viaja (`qtbase`, `glib`, `harfbuzz`) SE QUEDA — conservador a propósito.
+
+ Quién la usa: `hydrate-profile.py` (arma el rootfs), `vigia-sonames.py --imagen` y
+ `vigia-interpretes.py`. Quién NO: `build-state.py` para su `clausura` (es «qué construir») ni
+ `repo-perfil.sh` (un repositorio no es una imagen: instalar `cmake` desde el repo es correcto)."""
+ return membresia(colas, perfiles, podar=targets_mod.solo_build())
+
+
def _estado(nodo):
"""Estado del nudo desde el grafo firme, si está regenerado. Best-effort: el radio funciona sin él."""
return _estados_por_cola().get(nodo, "?")