gioser

sergio/takana

> 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…

TOML 1389Shell 137Markdown 80Rust 802602 Definiciones

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.

0c795025b5c6c8ee6e50135eef4e56b9227a0f2e · por Sergio · (hace 1 mes) · padre d607c655d · ver el árbol en este commit

Delta semántico · 16

scripts/build-state.py

  • cambió por dentro fn main

scripts/hydrate-profile.py

  • cambió por dentro fn main

scripts/targets.py

scripts/vigia-interpretes.py

scripts/vigia-sonames.py

  • cambió por dentro fn main

scripts/yupana.py

8 archivos · +611 −19

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, "?")