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…
⎇ 5e4014a6c240dc426fc8db6648956a0b5d23f4f1
scripts/vigia-interpretes.py
History
Who touched what
raw
paso 1: el toolchain sale de las imágenes — servidor 4,9 G → 2,6 G, cero rebuilds
1#!/usr/bin/env python3
2# vigia-interpretes.py — ¿qué script de una imagen pide un `#!` que esa imagen NO lleva?
3#
4# ══ EL PUNTO CIEGO QUE ESTE VIGÍA CUBRE ════════════════════════════════════════════════════════
5# `vigia-sonames.py` contesta la misma pregunta para los `.so` y es el guardián del cierre dinámico.
6# **No ve un `#!`.** Y el 2026-09-22, al declarar `[solo-build]` en `targets.toml` para sacar la
7# cadena de compilación de las imágenes, esa diferencia era justo la que importaba: con `python3` y
8# `perl` fuera, el vigía de sonames daba **cero huecos en los 8 perfiles** —los dos intérpretes no
9# los enlaza nadie— y sin embargo quedaban **10 scripts de usuario inertes**, entre ellos
10# `git send-email` y `sensors-detect`. Ningún instrumento del repo lo habría dicho.
11#
12# La regla: **un cierre no es sólo lo que se enlaza; es también lo que se ejecuta.** Un `#!` es una
13# dependencia de ejecución igual de real que un `NEEDED`, con la diferencia de que falla RUIDOSO y
14# nombrando al intérprete (`exec: no such file or directory`), no silenciosamente contra el sysroot
15# del lab. Eso hace la poda defendible — y este vigía es lo que la mantiene honesta.
16#
17# ══ LAS DOS TABLAS, Y POR QUÉ SON DOS ══════════════════════════════════════════════════════════
18# Igual que `hydrate-profile.py` separa `RAIZ_POR_CONTRATO` de `RAIZ_DEUDA_DECIDIDA`:
19# · `POR_CONTRATO` — andamiaje de BUILD horneado dentro de una librería que sí viaja
20# (`glib-genmarshal`, `g-ir-scanner`, los módulos de ECM, el `libexec` de qtbase). Inerte por
21# diseño: nadie los ejecuta en una caja instalada. Se imprimen con ⊘ y no fallan.
22# · `POR_DECISION` — los que un usuario SÍ podría ejecutar y quedaron inertes a propósito al no
23# hornear 293 MiB de intérpretes en las 8 imágenes. Se imprimen con • y **no fallan, pero se
24# listan siempre**: una decisión que deja de verse se convierte en un olvido.
25# Cualquier otra cosa sale con ✗ y hace fallar con `--fail`. Un ofensor NUEVO no se puede perder en
26# el ruido de uno viejo — es la misma lección que el bloque de raíces sucias.
27#
28# uso: python3 scripts/vigia-interpretes.py [perfil...] [--fail] [--store DIR] [--rootfs DIR]
29# --fail → exit 1 si hay un `#!` sin proveedor que no esté en las tablas
30# --rootfs → comprueba contra un rootfs YA hidratado (exacto: no asume nada de la capa
31# base). Sin él se mide el cierre de imagen del perfil + `CAPA_BASE`.
32#
33# La superficie va en INGLÉS (regla 4 del CLAUDE.md); los mensajes, en castellano.
34import os, re, sys, glob, subprocess
35
36sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
37import yupana
38import targets as targets_mod
39
40ROOT = yupana.ROOT
41HAMMER = str(ROOT / "target/release/takana")
42
43# Lo que pone la CAPA BASE y no el cierre del perfil: el `product-rootfs` del bootstrap
44# (`STAGE1_COMPONENTS` = musl busybox hammerd arje-zero · `USERLAND_COMPONENTS` = uutils findutils
45# findutils-xargs diffutils ripgrep). Se declara explícito y corto porque asumirlo en silencio sería
46# el mismo error que este vigía existe para cazar. Con `--rootfs` no se asume: se mira el árbol.
47CAPA_BASE = {"bin/sh", "bin/busybox", "usr/bin/env", "bin/env", "usr/bin/coreutils"}
48
49# Andamiaje de build horneado dentro de un paquete de runtime. {paquete: (motivo, patrón de rutas)}
50POR_CONTRATO = {
51 # `gtester-report` y `glib-gettextize` no empiezan por `glib-`: el patrón corto los dejaba en ✗.
52 "glib": ("codegen del build de GLib, no programas del sistema",
53 r"usr/bin/(glib-|gdbus-codegen|gtester-report)"),
54 "glib-shared": ("idem glib", r"usr/bin/(glib-|gdbus-codegen|gtester-report)"),
55 "glib-introspected": ("idem glib", r"usr/bin/(glib-|gdbus-codegen|gtester-report)"),
56 "gobject-introspection":("el escáner de introspección corre en el BUILD", r"usr/bin/g-ir-"),
57 "extra-cmake-modules": ("son módulos de cmake, no ejecutables de la imagen", r"usr/share/ECM/"),
58 "qtbase": ("andamiaje de build de Qt (qt-cmake, mkspecs, libexec)",
59 r"usr/lib/(qt6/(libexec|bin|mkspecs)/|cmake/Qt6/)"),
60 "libevent": ("generador de RPC del build", r"usr/bin/event_rpcgen\.py"),
61 "upower": ("los dos son TESTS del upstream, no parte del servicio", r"usr/libexec/upower/"),
62 "nghttp2": ("`fetch-ocsp-response`: helper opcional del servidor de ejemplo",
63 r"usr/share/nghttp2/"),
64 "libpsl": ("generador de la lista de sufijos, del build", r"usr/bin/psl-make-dafsa"),
65 "nspr": ("generador de tablas de error, del build", r"usr/bin/compile-et\.pl"),
66 "vim": ("ejemplos que vim DOCUMENTA bajo share/vim, no comandos", r"usr/share/vim/"),
67 "helix": ("completado de OTRA shell (fish): es dato, no un comando", r"usr/share/fish/"),
68 "zsh": ("fixtures de test del upstream (con su `/usr/local` horneado)", r"usr/share/zsh/functions/"),
69 "libtool": ("fuentes de libltdl, no ejecutables", r"usr/share/libtool/"),
70 "opencv": ("script de entorno para compilar CONTRA opencv", r"usr/bin/setup_vars_opencv4\.sh"),
71 "plasma-desktop": ("script de extracción de traducciones del tema", r"usr/share/sddm/themes/"),
72 # CPython hornea `#!/usr/local/bin/python` en `cgi.py` desde siempre (es un módulo que se
73 # IMPORTA, no un programa). Aparece sólo en los perfiles donde `python3` sí viaja.
74 "python3": ("el `#!` del upstream en el stdlib: `cgi.py` se importa, no se ejecuta",
75 r"usr/lib/python3\.\d+/"),
76}
77
78# Inertes por la DECISIÓN de `[solo-build]` (los 293 MiB de `python3` + `perl`). Cada entrada dice
79# qué se pierde, para que la decisión se pueda revisar con el dato delante y no de memoria.
80POR_DECISION = {
81 # ⚠ Las rutas de git son TRES sitios, no uno, y el patrón corto las dejaba caer a ✗: además del
82 # `libexec`, hay `usr/bin/git-cvsserver`, el `gitweb.cgi` y un hook de plantilla. Lo destapó este
83 # vigía en su primera corrida, que es exactamente para lo que se escribió.
84 "git": ("10 scripts perl: send-email, svn, cvs*, instaweb, gitweb.cgi y el hook "
85 "fsmonitor-watchman.sample", r"usr/(libexec/git-core/|bin/git-cvsserver|share/git(-core|web)/)"),
86 "lm-sensors": ("4 scripts perl: sensors-detect, fancontrol, pwmconfig, sensors-conf-convert "
87 "(el `sensors` en C y libsensors siguen)", r"usr/(sbin/|bin/sensors-conf-convert)"),
88 "libinput": ("las herramientas de diagnóstico libinput-analyze/measure", r"usr/libexec/libinput/"),
89 "iproute2": ("`routel`, el listador de rutas en python", r"sbin/routel"),
90 "rsync": ("`rrsync`, el envoltorio restringido para ssh", r"usr/bin/rrsync"),
91 "usbutils": ("`lsusb.py`, la vista de árbol (el `lsusb` en C sigue)", r"usr/bin/lsusb\.py"),
92 "aerc": ("el filtro de invitaciones .ics y `carddav-query`",
93 r"usr/(libexec/aerc/filters/|bin/carddav-query)"),
94 "gnome-shell":("`gnome-shell-test-tool`, sólo para pruebas", r"usr/bin/gnome-shell-test-tool"),
95 "evolution-data-server": ("`csv2vcard`, importador de contactos", r"usr/libexec/evolution-data-server/"),
96 "squid": ("`helper-mux`, multiplexor de helpers perl", r"usr/lib/squid/helper-mux"),
97}
98
99_cache = {}
100def artefacto(cola, nombre, store):
101 if (cola, nombre) in _cache:
102 return _cache[(cola, nombre)]
103 receta = ROOT / ("recipes/%s.toml" % nombre if cola == "corpus"
104 else "recipes/%s/%s.toml" % (cola, nombre))
105 d = None
106 if receta.exists():
107 r = subprocess.run([HAMMER, "--store", store, "hash", str(receta)],
108 capture_output=True, text=True)
109 h = r.stdout.strip().replace("b3:", "")
110 if len(h) == 64:
111 cand = glob.glob(os.path.join(store, h + "-*"))
112 d = cand[0] if cand else None
113 _cache[(cola, nombre)] = d
114 return d
115
116
117def shebang(p):
118 """La línea `#!` de un fichero, o None. Lee 2 bytes antes de decidir: abrir y leer una línea de
119 todo el rootfs es caro y la inmensa mayoría no son scripts."""
120 try:
121 with open(p, "rb") as fh:
122 if fh.read(2) != b"#!":
123 return None
124 fh.seek(0)
125 return fh.readline(300).decode("utf8", "replace").strip()
126 except OSError:
127 return None
128
129
130def interprete(linea, provee):
131 """Del `#!` a la RUTA del intérprete, resuelta. `#!/usr/bin/env python3` no nombra una ruta: se
132 busca `python3` entre lo que la imagen provee — y si no está, se devuelve el nombre para poder
133 decir QUÉ falta (`env python3`, no `env`)."""
134 partes = linea[2:].strip().split()
135 if not partes:
136 return None
137 ruta = partes[0].lstrip("/")
138 if os.path.basename(ruta) == "env" and len(partes) > 1:
139 nombre = partes[1]
140 if nombre.startswith("-"): # `env -S foo` y amigos
141 nombre = partes[-1]
142 for cand in provee:
143 if os.path.basename(cand) == nombre:
144 return cand
145 return "env " + nombre
146 return ruta
147
148
149def escanear_dir(raiz_dir, etiqueta, provee, scripts):
150 for raiz, _, ficheros in os.walk(raiz_dir):
151 for fn in ficheros:
152 p = os.path.join(raiz, fn)
153 rel = os.path.relpath(p, raiz_dir)
154 if os.path.islink(p):
155 provee.add(rel)
156 continue
157 provee.add(rel)
158 # `/share/doc` y `/testdata` no son cierre: nadie los ejecuta. Mismo criterio que el
159 # vigía de sonames con el `testdata/` de Go.
160 if "/share/doc/" in p or "/testdata/" in p:
161 continue
162 linea = shebang(p)
163 if linea:
164 scripts.append((etiqueta, rel, linea))
165
166
167def main():
168 args = [a for a in sys.argv[1:] if not a.startswith("--")]
169 fallar = "--fail" in sys.argv
170 def opcion(f):
171 return sys.argv[sys.argv.index(f) + 1] if f in sys.argv else None
172 store = opcion("--store") or os.environ.get("HAMMER_STORE", str(ROOT / "store"))
173 rootfs = opcion("--rootfs")
174 # los valores de las flags no son perfiles
175 args = [a for a in args if a not in (store, rootfs)]
176
177 perfiles = targets_mod.load()
178 ofensores = 0
179
180 if rootfs:
181 provee, scripts = set(), []
182 escanear_dir(rootfs, "(rootfs)", provee, scripts)
183 casos = [("%s" % rootfs, provee, scripts)]
184 else:
185 memb = yupana.membresia_imagen()
186 casos = []
187 for perfil in (args or sorted(perfiles)):
188 provee, scripts = set(CAPA_BASE), []
189 for (cola, nombre) in sorted(n for n, ps in memb.items()
190 if perfil in ps and n[0] != "wanted"):
191 d = artefacto(cola, nombre, store)
192 if d:
193 escanear_dir(d, nombre, provee, scripts)
194 casos.append((perfil, provee, scripts))
195
196 for etiqueta, provee, scripts in casos:
197 faltan = [(pkg, rel, it) for pkg, rel, ln in scripts
198 for it in [interprete(ln, provee)] if it and it not in provee]
199 print("== %-20s %4d scripts con `#!` · %3d sin intérprete en la imagen"
200 % (etiqueta, len(scripts), len(faltan)))
201 # agrupado por (paquete, intérprete): 22 líneas iguales de libinput no informan más que una
202 grupos = {}
203 for pkg, rel, it in faltan:
204 grupos.setdefault((pkg, it), []).append(rel)
205 nuevos = []
206 for (pkg, it), rutas in sorted(grupos.items()):
207 tabla, marca = None, "✗"
208 for t, m in ((POR_CONTRATO, "⊘"), (POR_DECISION, "•")):
209 motivo_patron = t.get(pkg)
210 if motivo_patron and all(re.match(motivo_patron[1], r) for r in rutas):
211 tabla, marca = motivo_patron, m
212 break
213 print(" %s %-22s %-14s %3d %s" % (marca, pkg, it, len(rutas), rutas[0]))
214 if tabla:
215 print(" %s" % tabla[0])
216 else:
217 nuevos.append("%s (%s)" % (pkg, it))
218 if nuevos:
219 ofensores += len(nuevos)
220 print("\n ✗ %d caso(s) NO declarado(s): %s\n"
221 " Un `#!` que no resuelve falla al ejecutar, no al instalar: o el intérprete\n"
222 " entra a la imagen (una línea en `paquetes`), o el caso entra a una tabla de\n"
223 " este vigía con su motivo." % (len(nuevos), ", ".join(nuevos)))
224 return 1 if (fallar and ofensores) else 0
225
226
227if __name__ == "__main__":
228 sys.exit(main())