tawasuyu

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 1430Shell 145Markdown 87Rust 852973 Definiciones

⎇ 534014c3ec69daea789a1d0523e1d00a69c6ed65 scripts/vigia-interpretes.py Python · 228 líneas · 12.8 KB Historia Quién tocó qué crudo

paso 1: el toolchain sale de las imágenes — servidor 4,9 G → 2,6 G, cero rebuilds 0c795025b · Sergio ·

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())