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 1572Shell 203Rust 109Markdown 993728 Definitions

⎇ d611f8577d0ebb0f265143be9007a5b94a7dd1fc scripts/vigia-imagen.py Python · 315 lines · 18.0 KB History Who touched what raw

takana etapa 3b: las 49 invocaciones pasan a ./target/release/takana 8730aad34 · Sergio ·

1#!/usr/bin/env python3
2# vigia-imagen.py — ¿qué le falta a una IMAGEN que su clausura no puede echar de menos?
3#
4# ══ EL PUNTO CIEGO QUE ESTE VIGÍA CUBRE ════════════════════════════════════════════════════════
5# `build-state.py` contesta «¿está sellado?» y `vigia-sonames.py` contesta «¿algún `.so` que pide
6# esta imagen no lo provee nadie?». Ninguno de los dos contesta la pregunta que se hace un usuario
7# al arrancar el escritorio: **¿se puede USAR?**
8#
9# Y hay dos formas de que no se pueda, las dos medidas el 2026-09-03 arrancando KDE en QEMU:
10#
11#  1. **DATA de runtime que ninguna arista de BUILD alcanza.** El panel de Plasma salía con el reloj
12#     y nada más. Los `.so` de todos los applets estaban instalados; lo que faltaba era el TEMA DE
13#     ICONOS — `kickoff`, `systemtray` y `showdesktop` SON un icono, así que sin tema quedan de ancho
14#     cero y el panel parece vacío. `breeze-icons` estaba sellada desde siempre y no la declaraba
15#     ningún perfil. Es la figura de las fuentes ([[gnome-sin-fuentes]]) y de `foot` (sway llegó a
16#     121/121 sin emulador de terminal): **la métrica de clausura mide lo DECLARADO**, y estas cosas
17#     no las declara nadie porque no son dep de build de nada.
18#
19#  2. **Una porción OPCIONAL de una librería que sí está.** El menú de aplicaciones no abría:
20#     `kcoreaddons.toml` pasaba `-DKCOREADDONS_USE_QML=OFF` ⇒ el módulo QML `org.kde.coreaddons` no
21#     existía ⇒ `kickoff` moría en el import. No falta una receta ni una librería: falta una FEATURE.
22#     Ninguna métrica basada en recetas puede verlo.
23#
24# ══ QUÉ MIDE, Y CONTRA QUÉ ═════════════════════════════════════════════════════════════════════
25# Recorre los ARTEFACTOS del cierre de cada perfil (no las recetas: el artefacto es lo que se
26# instala) y comprueba cinco invariantes de imagen usable:
27#
28#   iconos     ≥1 tema con `index.theme` Y ficheros de icono adentro. Un directorio sin `index.theme`
29#              NO es un tema: la búsqueda no lo recorre. `hicolor` se cuenta aparte porque es el
30#              FALLBACK obligatorio del estándar y no trae iconos propios: es el contrato, no el
31#              contenido.
32#   cursores   ≥1 tema con `cursors/` poblado. Con el cursor por software —obligatorio en virtio-gpu—
33#              sin tema el puntero se mueve INVISIBLE. A diferencia de los iconos, acá NO se exige
34#              `index.theme`: libXcursor abre `<tema>/cursors/<nombre>` directo.
35#   fuentes    ≥1 `.ttf/.otf/.ttc/.pfb`. fontconfig es el motor que BUSCA fuentes, no una fuente.
36#   terminal   ≥1 `.desktop` con `TerminalEmulator` en Categories. Un escritorio sin terminal no es
37#              uno incompleto: es uno del que no se puede salir cuando algo falla.
38#   qml        todo `import <módulo>` de los `.qml` INSTALADOS tiene un módulo con `qmldir` en el
39#              cierre. Así se caza la clase (2).
40#
41# ══ RUIDO LEGÍTIMO, para poder triar ═══════════════════════════════════════════════════════════
42# Hay módulos QML que NO se instalan en disco porque el binario los REGISTRA en runtime
43# (`org.kde.plasma.shell` lo registra plasmashell; los `Qt*` los trae el propio Qt en otra ruta).
44# No se excluyen por una lista escrita a mano —eso envejece y no dice por qué— sino comprobando que
45# el URI esté como literal en un ELF del cierre: ver `registrado_en_binario()`. Se reportan aparte,
46# nombrando al registrador. Todo lo demás que salga acá es accionable.
47#
48#   uso:  python3 scripts/vigia-imagen.py [perfil...] [--fail] [--qml]
49#         --fail  → exit 1 si algún invariante falla; exit 2 si NO SE PUDO medir (nodos del cierre
50#                   sin artefacto sellado). Los dos son accionables y no son lo mismo.
51#         --qml   → además del resumen, listar los imports sin módulo
52import os, sys, glob, subprocess, re
53
54sys.path.insert(0, os.path.join(os.path.dirname(os.path.abspath(__file__))))
55import yupana
56import targets as targets_mod
57
58ROOT = yupana.ROOT
59STORE = os.environ.get("HAMMER_STORE", str(ROOT / "store"))
60HAMMER = str(ROOT / "target/release/takana")
61
62# Invariantes que un perfil NO cumple A PROPÓSITO. Un ✗ permanente por una decisión ya tomada es
63# deuda fantasma —la misma figura que la terna GNOME que hubo que sacar de targets.toml para que el
64# grafo dejara de mentir—, así que se declaran acá CON EL MOTIVO y se imprimen como ⊘, no como fallo.
65EXCEPCIONES = {
66    ("escritorio-mirada", "iconos"):   "imagen SLIM a propósito: compositor + greeter, sin escritorio",
67    ("escritorio-mirada", "hicolor"):  "idem",
68    ("escritorio-mirada", "cursores"): "el greeter dibuja su propio puntero",
69    ("escritorio-mirada", "fuentes"):  "idem slim",
70    ("escritorio-mirada", "terminal"): "no es un escritorio de uso general",
71    ("escritorio-sway", "iconos"):     "ninguna de sus 4 apps gráficas pide iconos de tema y zathura "
72                                       "es GTK4 (iconos de UI compilados); un tema COMPLETO exige "
73                                       "promover adwaita o breeze al corpus — decisión, no olvido",
74}
75
76# (Acá vivía `QML_EN_RUNTIME`, una lista escrita a mano de módulos que nadie instala en disco. La
77# reemplaza `registrado_en_binario()`, que contesta lo mismo con evidencia y además NOMBRA al
78# registrador. Al vaciarla, los cuatro que tenía se resolvieron solos —y `org.kde.kwin.effect` ni
79# siquiera se importa en ningún cierre: era una entrada muerta que nadie podía saber que sobraba.)
80
81_cache = {}
82def artefacto(cola, nombre):
83    """Directorio del artefacto de (cola,nombre) en el store, o None. Mismo método que vigia-sonames:
84    `hammer hash` sobre la receta de HOY, sin construir — no heurística de mtime."""
85    if (cola, nombre) in _cache:
86        return _cache[(cola, nombre)]
87    receta = ROOT / ("recipes/%s.toml" % nombre if cola == "corpus"
88                     else "recipes/%s/%s.toml" % (cola, nombre))
89    d = None
90    if receta.exists():
91        r = subprocess.run([HAMMER, "--store", STORE, "hash", str(receta)],
92                           capture_output=True, text=True)
93        h = r.stdout.strip().replace("b3:", "")
94        if len(h) == 64:
95            cand = glob.glob(os.path.join(STORE, h + "-*"))
96            d = cand[0] if cand else None
97    _cache[(cola, nombre)] = d
98    return d
99
100
101ICONO_EXT = (".png", ".svg", ".svgz", ".xpm")
102FUENTE_EXT = (".ttf", ".otf", ".ttc", ".pfb", ".pcf", ".pcf.gz")
103
104
105def registrado_en_binario(uri, dirs):
106    """¿Algún ELF del cierre lleva `uri` como literal? Entonces lo registra en C++ y NO falta.
107
108    ── POR QUÉ HACE FALTA ──────────────────────────────────────────────────────────────────────
109    Un `import org.kde.kscreenlocker` no siempre se satisface con un directorio y su `qmldir`. El
110    greeter de la pantalla de bloqueo hace `qmlRegisterUncreatableType<PamAuthenticator>(
111    "org.kde.kscreenlocker", …)`: el módulo existe SÓLO dentro del proceso que carga ese `.qml`, y
112    en disco no hay nada que instalar. Buscar el módulo en el sistema de ficheros da ✗ y la imagen
113    está perfecta.
114
115    Medido el 2026-09-05: los DOS únicos ✗ de qml que reportaba este vigía eran de esta clase
116    —`org.kde.kscreenlocker` en `libexec/kscreenlocker_greet` y `org.kde.newstuff.core` en
117    `libnewstuffqmlplugin.so`— o sea que el 100% de sus hallazgos eran falsos. Y un guardián que
118    grita en falso no se endurece: se ignora. Antes esto se tapaba anotando el módulo a mano en
119    una lista escrita a mano, que envejece y no dice por qué.
120
121    El literal queda en `.rodata` del binario, así que la pregunta se contesta con evidencia. Se
122    miran primero los artefactos cuyo nombre comparte una palabra con el URI (`kscreenlocker` →
123    `kscreenlocker`), que es donde está casi siempre, y sólo si no aparece se recorre el resto.
124    """
125    aguja = uri.encode()
126    tokens = [t for t in re.split(r"[.\-_]", uri.lower()) if len(t) > 3]
127
128    def primero_los_parecidos(par):
129        return 0 if any(t in par[0].lower() for t in tokens) else 1
130
131    for nombre, d in sorted(dirs.items(), key=primero_los_parecidos):
132        for raiz, _, ficheros in os.walk(d):
133            for f in ficheros:
134                ruta = os.path.join(raiz, f)
135                try:
136                    if os.path.getsize(ruta) > 256 * 1024 * 1024:
137                        continue
138                    with open(ruta, "rb") as fh:
139                        if fh.read(4) != b"\x7fELF":
140                            continue
141                        fh.seek(0)
142                        if aguja in fh.read():
143                            return "%s (%s)" % (os.path.relpath(ruta, d), nombre)
144                except OSError:
145                    continue
146    return None
147
148
149def escanear(nodos):
150    """Recorre los artefactos del cierre una sola vez y junta los cinco hechos."""
151    temas, cursores, fuentes, terminales = {}, {}, 0, {}
152    qml_modulos, qml_imports = set(), {}
153    sin_artefacto = []
154    dirs = {}
155    for (cola, nombre) in sorted(nodos):
156        d = artefacto(cola, nombre)
157        if not d:
158            sin_artefacto.append(nombre)
159            continue
160        dirs[nombre] = d
161        # ── temas de iconos y de cursores ───────────────────────────────────────────────────────
162        base = os.path.join(d, "usr/share/icons")
163        if os.path.isdir(base):
164            for t in sorted(os.listdir(base)):
165                td = os.path.join(base, t)
166                # ⚠ ICONOS y CURSORES no se buscan igual, y medirlos con la misma regla dio un ✗
167                # falso el 2026-09-04: `adwaita-cursors` instalaba `Adwaita/cursors/` poblado y este
168                # vigía seguía diciendo «NINGÚN tema de cursor» porque el directorio no traía
169                # `index.theme`. Para un tema de ICONOS ese requisito es correcto —la búsqueda XDG
170                # recorre `Directories=` y sin el índice el tema no existe—, pero libXcursor y
171                # wlroots abren `<tema>/cursors/<nombre>` DIRECTO; `index.theme` sólo hace falta
172                # para seguir un `Inherits=`. Exigirlo también acá medía el invariante de al lado.
173                tiene_indice = os.path.exists(os.path.join(td, "index.theme"))
174                n_ico = n_cur = 0
175                for dp, _, fn in os.walk(td):
176                    for f in fn:
177                        if f.endswith(ICONO_EXT):
178                            n_ico += 1
179                        elif os.path.basename(dp) == "cursors":
180                            n_cur += 1
181                if n_ico and tiene_indice:
182                    temas.setdefault(t, (nombre, n_ico))
183                if n_cur:
184                    cursores.setdefault(t, (nombre, n_cur))
185                if t == "hicolor" and tiene_indice:
186                    temas.setdefault("hicolor", (nombre, n_ico))
187        # ── el resto, en UNA pasada por el árbol ────────────────────────────────────────────────
188        for dp, _, fn in os.walk(d):
189            for f in fn:
190                p = os.path.join(dp, f)
191                if f.endswith(FUENTE_EXT):
192                    fuentes += 1
193                elif f == "qmldir":
194                    # el módulo es la ruta relativa desde .../qml/ con puntos
195                    m = re.search(r"/qt\d*/qml/(.+)$", dp) or re.search(r"/qml/(.+)$", dp)
196                    if m:
197                        qml_modulos.add(m.group(1).replace("/", "."))
198                elif f.endswith(".qml"):
199                    # `designer/`: los `*Specifics.qml` de Qt son la integración con Qt Design Studio
200                    # (importan `HelperWidgets`, que sólo existe dentro de Qt Creator). Nunca se
201                    # cargan en runtime; contarlos era ruido puro.
202                    if "/designer/" in dp + "/":
203                        continue
204                    try:
205                        txt = open(p, encoding="utf-8", errors="replace").read()
206                    except OSError:
207                        continue
208                    # Y los imports DENTRO DE UN COMENTARIO no son imports: `QtMultimedia/Video.qml`
209                    # trae `import QtSystemInfo 5.0` en un bloque \qml de la documentación. Sin
210                    # despojar comentarios, el vigía reporta como hueco un ejemplo de un docstring.
211                    txt = re.sub(r"/\*.*?\*/", "", txt, flags=re.S)
212                    txt = re.sub(r"^\s*//.*$", "", txt, flags=re.M)
213                    for mod in re.findall(r"^\s*import\s+([A-Za-z][\w.]*)", txt, re.M):
214                        qml_imports.setdefault(mod, set()).add(nombre)
215                elif f.endswith(".desktop") and "/applications" in dp:
216                    try:
217                        txt = open(p, encoding="utf-8", errors="replace").read()
218                    except OSError:
219                        continue
220                    if re.search(r"^Categories=.*TerminalEmulator", txt, re.M):
221                        terminales.setdefault(f[:-8], nombre)
222    candidatos = {m: v for m, v in qml_imports.items()
223                  if m not in qml_modulos
224                  and not m.startswith(("QtQuick", "QtQml", "QtCore", "QtTest", "Qt5", "Qt."))
225                  and m != "Qt"}
226    # Último filtro, y el que decide: un módulo registrado en C++ no está «sin instalar».
227    faltan_qml, en_binario = {}, {}
228    for m, v in candidatos.items():
229        donde = registrado_en_binario(m, dirs)
230        if donde:
231            en_binario[m] = donde
232        else:
233            faltan_qml[m] = v
234    return temas, cursores, fuentes, terminales, faltan_qml, sin_artefacto, en_binario
235
236
237def main():
238    args = [a for a in sys.argv[1:] if not a.startswith("--")]
239    fallar = "--fail" in sys.argv
240    detalle_qml = "--qml" in sys.argv
241    colas = yupana.cargar_colas()
242    perfiles = targets_mod.load()
243    memb = yupana.membresia(colas, perfiles)
244    quiero = args or [p for p in perfiles if p.startswith("escritorio")]
245    fallos = 0
246    parciales = set()
247    for perfil in quiero:
248        nodos = {n for n, ps in memb.items() if perfil in ps and n[0] != "wanted"}
249        if not nodos:
250            continue
251        temas, cursores, fuentes, terminales, faltan_qml, sin_art, en_binario = escanear(nodos)
252        reales = {t: v for t, v in temas.items() if t != "hicolor"}
253        print("== %-20s %3d nodos del cierre" % (perfil, len(nodos)))
254        parcial = bool(sin_art)
255        if parcial:
256            parciales.add(perfil)
257            # UN AUSENTE FALLA RUIDOSAMENTE: sin el artefacto no se puede afirmar NI que falta NI que
258            # está. Pasó al minuto de escribir este vigía: prender el QML de kcoreaddons dejó 71
259            # artefactos en deuda y el informe dijo «KDE no tiene terminal» teniendo konsole.
260            print("   ⚠ MEDICIÓN PARCIAL: %d de %d nodos sin artefacto sellado (deuda, o store "
261                  "incompleto) ⇒ los ✗ de abajo pueden ser de la deuda, no de la imagen"
262                  % (len(sin_art), len(nodos)))
263            print("     sin artefacto: %s%s" % (", ".join(sorted(sin_art)[:8]),
264                                                " …" if len(sin_art) > 8 else ""))
265
266        def linea(ok, etiqueta, texto):
267            nonlocal fallos
268            motivo = EXCEPCIONES.get((perfil, etiqueta))
269            if not ok and motivo:
270                print("   ⊘ %-10s %s" % (etiqueta, motivo))
271                return
272            if not ok and not parcial:
273                fallos += 1
274            print("   %s %-10s %s" % ("✓" if ok else "✗", etiqueta, texto))
275
276        linea(bool(reales), "iconos",
277              ", ".join("%s (%s, %d)" % (t, v[0], v[1]) for t, v in sorted(reales.items()))
278              or "NINGÚN tema con iconos — los applets/indicadores quedan de ancho cero")
279        linea("hicolor" in temas, "hicolor",
280              "presente" if "hicolor" in temas
281              else "FALTA el fallback freedesktop: los iconos que las apps instalan en "
282                   "usr/share/icons/hicolor son invisibles")
283        linea(bool(cursores), "cursores",
284              ", ".join("%s (%s)" % (t, v[0]) for t, v in sorted(cursores.items()))
285              or "NINGÚN tema de cursor — con cursor por software el puntero se mueve invisible")
286        linea(fuentes > 0, "fuentes",
287              "%d ficheros de fuente" % fuentes if fuentes
288              else "CERO fuentes (fontconfig es el motor que las busca, no una fuente)")
289        linea(bool(terminales), "terminal",
290              ", ".join("%s (%s)" % (k, v) for k, v in sorted(terminales.items()))
291              or "NINGÚN emulador de terminal — no se puede salir de un fallo")
292        nota = ""
293        if en_binario:
294            nota = " (%d registrado(s) en C++, no en disco)" % len(en_binario)
295        linea(not faltan_qml, "qml",
296              ("todos los import resuelven" + nota) if not faltan_qml
297              else "%d módulo(s) importado(s) y NO instalado(s)" % len(faltan_qml))
298        if en_binario and detalle_qml:
299            for m, donde in sorted(en_binario.items()):
300                print("        · %-38s lo registra: %s" % (m, donde))
301        if faltan_qml and (detalle_qml or True):
302            for m, quienes in sorted(faltan_qml.items()):
303                print("        FALTA %-34s ← lo importa: %s"
304                      % (m, ", ".join(sorted(quienes)[:4])))
305    if parciales:
306        print("\n⚠ perfiles medidos a medias (deuda en el store): %s" % ", ".join(sorted(parciales)))
307    if fallar and fallos:
308        return 1          # medí y FALTA
309    if fallar and parciales:
310        return 2          # NO pude medir — distinto de «está bien»
311    return 0
312
313
314if __name__ == "__main__":
315    sys.exit(main())