#!/usr/bin/env python3 # vigia-imagen.py — ¿qué le falta a una IMAGEN que su clausura no puede echar de menos? # # ══ EL PUNTO CIEGO QUE ESTE VIGÍA CUBRE ════════════════════════════════════════════════════════ # `build-state.py` contesta «¿está sellado?» y `vigia-sonames.py` contesta «¿algún `.so` que pide # esta imagen no lo provee nadie?». Ninguno de los dos contesta la pregunta que se hace un usuario # al arrancar el escritorio: **¿se puede USAR?** # # Y hay dos formas de que no se pueda, las dos medidas el 2026-09-03 arrancando KDE en QEMU: # # 1. **DATA de runtime que ninguna arista de BUILD alcanza.** El panel de Plasma salía con el reloj # y nada más. Los `.so` de todos los applets estaban instalados; lo que faltaba era el TEMA DE # ICONOS — `kickoff`, `systemtray` y `showdesktop` SON un icono, así que sin tema quedan de ancho # cero y el panel parece vacío. `breeze-icons` estaba sellada desde siempre y no la declaraba # ningún perfil. Es la figura de las fuentes ([[gnome-sin-fuentes]]) y de `foot` (sway llegó a # 121/121 sin emulador de terminal): **la métrica de clausura mide lo DECLARADO**, y estas cosas # no las declara nadie porque no son dep de build de nada. # # 2. **Una porción OPCIONAL de una librería que sí está.** El menú de aplicaciones no abría: # `kcoreaddons.toml` pasaba `-DKCOREADDONS_USE_QML=OFF` ⇒ el módulo QML `org.kde.coreaddons` no # existía ⇒ `kickoff` moría en el import. No falta una receta ni una librería: falta una FEATURE. # Ninguna métrica basada en recetas puede verlo. # # ══ QUÉ MIDE, Y CONTRA QUÉ ═════════════════════════════════════════════════════════════════════ # Recorre los ARTEFACTOS del cierre de cada perfil (no las recetas: el artefacto es lo que se # instala) y comprueba cinco invariantes de imagen usable: # # iconos ≥1 tema con `index.theme` Y ficheros de icono adentro. Un directorio sin `index.theme` # NO es un tema: la búsqueda no lo recorre. `hicolor` se cuenta aparte porque es el # FALLBACK obligatorio del estándar y no trae iconos propios: es el contrato, no el # contenido. # cursores ≥1 tema con `cursors/` poblado. Con el cursor por software —obligatorio en virtio-gpu— # sin tema el puntero se mueve INVISIBLE. A diferencia de los iconos, acá NO se exige # `index.theme`: libXcursor abre `/cursors/` directo. # fuentes ≥1 `.ttf/.otf/.ttc/.pfb`. fontconfig es el motor que BUSCA fuentes, no una fuente. # terminal ≥1 `.desktop` con `TerminalEmulator` en Categories. Un escritorio sin terminal no es # uno incompleto: es uno del que no se puede salir cuando algo falla. # qml todo `import ` de los `.qml` INSTALADOS tiene un módulo con `qmldir` en el # cierre. Así se caza la clase (2). # # ══ Y UN SEXTO DATO QUE ESTE VIGÍA NO MIDE: CUÁNTO TARDA EN VERSE ══════════════════════════════ # Los cinco de arriba se miden sobre artefactos, en segundos, y contestan «¿está lo que hace falta?». # Ninguno contesta «¿y cuándo se ve?» — y esa pregunta ya cobró: el §6.10.ter del SDD 26 dio por # hecho que `atuq` «no pinta nunca» en la imagen arrancada CON ESTE VIGÍA EN ✓, y la medición # repetida mostró que pintaba a los +137…+204 s. Lo medido había sido la paciencia. # Y el 2026-09-14, peor: con DOS tarjetas DRM la ventana cae en cualquiera de las dos salidas, y las # capturas miraban UNA ⇒ el «3 de 13» que se publicó medía dónde apuntaba la cámara. Por eso el dato # que se lee acá trae en qué PANTALLA apareció, y las corridas ciegas (dos salidas, una captura) no # se cuentan. # Arrancar la imagen son ~15 min de QEMU sin KVM, así que no se hace acá: `scripts/cosmic/atuq-en- # imagen.py` lo mide y deja el número en `docs/state/primera-pintura.json`, y este vigía lo LEE y lo # dice con sus condiciones (lanzamiento, vídeo, RAM, vcpus) — el tiempo depende de la carga de la # anfitriona, y un número sin condiciones se lee como si fuera del producto. Sin medición, lo dice. # # ══ RUIDO LEGÍTIMO, para poder triar ═══════════════════════════════════════════════════════════ # Hay módulos QML que NO se instalan en disco porque el binario los REGISTRA en runtime # (`org.kde.plasma.shell` lo registra plasmashell; los `Qt*` los trae el propio Qt en otra ruta). # No se excluyen por una lista escrita a mano —eso envejece y no dice por qué— sino comprobando que # el URI esté como literal en un ELF del cierre: ver `registrado_en_binario()`. Se reportan aparte, # nombrando al registrador. Todo lo demás que salga acá es accionable. # # uso: python3 scripts/vigia-imagen.py [perfil...] [--fail] [--qml] # --fail → exit 1 si algún invariante falla; exit 2 si NO SE PUDO medir (nodos del cierre # sin artefacto sellado). Los dos son accionables y no son lo mismo. # --qml → además del resumen, listar los imports sin módulo import os, sys, glob, subprocess, re, json sys.path.insert(0, os.path.join(os.path.dirname(os.path.abspath(__file__)))) import yupana import targets as targets_mod ROOT = yupana.ROOT STORE = os.environ.get("HAMMER_STORE", str(ROOT / "store")) HAMMER = str(ROOT / "target/release/takana") # Invariantes que un perfil NO cumple A PROPÓSITO. Un ✗ permanente por una decisión ya tomada es # deuda fantasma —la misma figura que la terna GNOME que hubo que sacar de targets.toml para que el # grafo dejara de mentir—, así que se declaran acá CON EL MOTIVO y se imprimen como ⊘, no como fallo. EXCEPCIONES = { ("escritorio-mirada", "iconos"): "imagen SLIM a propósito: compositor + greeter, sin escritorio", ("escritorio-mirada", "hicolor"): "idem", ("escritorio-mirada", "cursores"): "el greeter dibuja su propio puntero", ("escritorio-mirada", "fuentes"): "idem slim", ("escritorio-mirada", "terminal"): "no es un escritorio de uso general", ("escritorio-sway", "iconos"): "ninguna de sus 4 apps gráficas pide iconos de tema y zathura " "es GTK4 (iconos de UI compilados); un tema COMPLETO exige " "promover adwaita o breeze al corpus — decisión, no olvido", } # (Acá vivía `QML_EN_RUNTIME`, una lista escrita a mano de módulos que nadie instala en disco. La # reemplaza `registrado_en_binario()`, que contesta lo mismo con evidencia y además NOMBRA al # registrador. Al vaciarla, los cuatro que tenía se resolvieron solos —y `org.kde.kwin.effect` ni # siquiera se importa en ningún cierre: era una entrada muerta que nadie podía saber que sobraba.) _cache = {} def artefacto(cola, nombre): """Directorio del artefacto de (cola,nombre) en el store, o None. Mismo método que vigia-sonames: `hammer hash` sobre la receta de HOY, sin construir — no heurística de mtime.""" 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 ICONO_EXT = (".png", ".svg", ".svgz", ".xpm") FUENTE_EXT = (".ttf", ".otf", ".ttc", ".pfb", ".pcf", ".pcf.gz") def registrado_en_binario(uri, dirs): """¿Algún ELF del cierre lleva `uri` como literal? Entonces lo registra en C++ y NO falta. ── POR QUÉ HACE FALTA ────────────────────────────────────────────────────────────────────── Un `import org.kde.kscreenlocker` no siempre se satisface con un directorio y su `qmldir`. El greeter de la pantalla de bloqueo hace `qmlRegisterUncreatableType( "org.kde.kscreenlocker", …)`: el módulo existe SÓLO dentro del proceso que carga ese `.qml`, y en disco no hay nada que instalar. Buscar el módulo en el sistema de ficheros da ✗ y la imagen está perfecta. Medido el 2026-09-05: los DOS únicos ✗ de qml que reportaba este vigía eran de esta clase —`org.kde.kscreenlocker` en `libexec/kscreenlocker_greet` y `org.kde.newstuff.core` en `libnewstuffqmlplugin.so`— o sea que el 100% de sus hallazgos eran falsos. Y un guardián que grita en falso no se endurece: se ignora. Antes esto se tapaba anotando el módulo a mano en una lista escrita a mano, que envejece y no dice por qué. El literal queda en `.rodata` del binario, así que la pregunta se contesta con evidencia. Se miran primero los artefactos cuyo nombre comparte una palabra con el URI (`kscreenlocker` → `kscreenlocker`), que es donde está casi siempre, y sólo si no aparece se recorre el resto. """ aguja = uri.encode() tokens = [t for t in re.split(r"[.\-_]", uri.lower()) if len(t) > 3] def primero_los_parecidos(par): return 0 if any(t in par[0].lower() for t in tokens) else 1 for nombre, d in sorted(dirs.items(), key=primero_los_parecidos): for raiz, _, ficheros in os.walk(d): for f in ficheros: ruta = os.path.join(raiz, f) try: if os.path.getsize(ruta) > 256 * 1024 * 1024: continue with open(ruta, "rb") as fh: if fh.read(4) != b"\x7fELF": continue fh.seek(0) if aguja in fh.read(): return "%s (%s)" % (os.path.relpath(ruta, d), nombre) except OSError: continue return None def escanear(nodos): """Recorre los artefactos del cierre una sola vez y junta los cinco hechos.""" temas, cursores, fuentes, terminales = {}, {}, 0, {} qml_modulos, qml_imports = set(), {} sin_artefacto = [] dirs = {} for (cola, nombre) in sorted(nodos): d = artefacto(cola, nombre) if not d: sin_artefacto.append(nombre) continue dirs[nombre] = d # ── temas de iconos y de cursores ─────────────────────────────────────────────────────── base = os.path.join(d, "usr/share/icons") if os.path.isdir(base): for t in sorted(os.listdir(base)): td = os.path.join(base, t) # ⚠ ICONOS y CURSORES no se buscan igual, y medirlos con la misma regla dio un ✗ # falso el 2026-09-04: `adwaita-cursors` instalaba `Adwaita/cursors/` poblado y este # vigía seguía diciendo «NINGÚN tema de cursor» porque el directorio no traía # `index.theme`. Para un tema de ICONOS ese requisito es correcto —la búsqueda XDG # recorre `Directories=` y sin el índice el tema no existe—, pero libXcursor y # wlroots abren `/cursors/` DIRECTO; `index.theme` sólo hace falta # para seguir un `Inherits=`. Exigirlo también acá medía el invariante de al lado. tiene_indice = os.path.exists(os.path.join(td, "index.theme")) n_ico = n_cur = 0 for dp, _, fn in os.walk(td): for f in fn: if f.endswith(ICONO_EXT): n_ico += 1 elif os.path.basename(dp) == "cursors": n_cur += 1 if n_ico and tiene_indice: temas.setdefault(t, (nombre, n_ico)) if n_cur: cursores.setdefault(t, (nombre, n_cur)) if t == "hicolor" and tiene_indice: temas.setdefault("hicolor", (nombre, n_ico)) # ── el resto, en UNA pasada por el árbol ──────────────────────────────────────────────── for dp, _, fn in os.walk(d): for f in fn: p = os.path.join(dp, f) if f.endswith(FUENTE_EXT): fuentes += 1 elif f == "qmldir": # el módulo es la ruta relativa desde .../qml/ con puntos m = re.search(r"/qt\d*/qml/(.+)$", dp) or re.search(r"/qml/(.+)$", dp) if m: qml_modulos.add(m.group(1).replace("/", ".")) elif f.endswith(".qml"): # `designer/`: los `*Specifics.qml` de Qt son la integración con Qt Design Studio # (importan `HelperWidgets`, que sólo existe dentro de Qt Creator). Nunca se # cargan en runtime; contarlos era ruido puro. if "/designer/" in dp + "/": continue try: txt = open(p, encoding="utf-8", errors="replace").read() except OSError: continue # Y los imports DENTRO DE UN COMENTARIO no son imports: `QtMultimedia/Video.qml` # trae `import QtSystemInfo 5.0` en un bloque \qml de la documentación. Sin # despojar comentarios, el vigía reporta como hueco un ejemplo de un docstring. txt = re.sub(r"/\*.*?\*/", "", txt, flags=re.S) txt = re.sub(r"^\s*//.*$", "", txt, flags=re.M) for mod in re.findall(r"^\s*import\s+([A-Za-z][\w.]*)", txt, re.M): qml_imports.setdefault(mod, set()).add(nombre) elif f.endswith(".desktop") and "/applications" in dp: try: txt = open(p, encoding="utf-8", errors="replace").read() except OSError: continue if re.search(r"^Categories=.*TerminalEmulator", txt, re.M): terminales.setdefault(f[:-8], nombre) candidatos = {m: v for m, v in qml_imports.items() if m not in qml_modulos and not m.startswith(("QtQuick", "QtQml", "QtCore", "QtTest", "Qt5", "Qt.")) and m != "Qt"} # Último filtro, y el que decide: un módulo registrado en C++ no está «sin instalar». faltan_qml, en_binario = {}, {} for m, v in candidatos.items(): donde = registrado_en_binario(m, dirs) if donde: en_binario[m] = donde else: faltan_qml[m] = v return temas, cursores, fuentes, terminales, faltan_qml, sin_artefacto, en_binario def informe_primera_pintura(): """El sexto dato: CUÁNTO TARDA la ventana en aparecer en la imagen ARRANCADA. Los cinco invariantes de arriba se miden sobre artefactos y contestan «¿está lo que hace falta?». Ninguno contesta «¿y cuándo se ve?», y esa pregunta ya cobró: el §6.10.ter del SDD 26 concluyó que `atuq` «no pinta nunca» en la imagen con este vigía en ✓ — y la medición repetida mostró que pintaba a los +137…+204 s, o sea que lo que se había medido era la paciencia del que miraba. No se mide acá a propósito: arrancar la imagen son ~15 min de QEMU sin KVM y este guion corre en segundos sobre el store. Lo que hace es LEER el número que dejó `scripts/cosmic/atuq-en-imagen.py` y decirlo con sus condiciones —tiempo sin condiciones se lee como si fuera del producto, y depende de la carga de la máquina anfitriona—. Si no hay medición, se dice; un hueco callado acá es un ✓ que no se ganó. """ ruta = os.path.join(str(ROOT), "docs/state/primera-pintura.json") print("\n== primera pintura en la imagen arrancada (no lo mide este vigía)") if not os.path.exists(ruta): print(" ⊘ sin medición — corré: scripts/cosmic/atuq-en-imagen.py --as-user --until-paint") return False try: with open(ruta) as f: corridas = json.load(f).get("corridas", []) except (OSError, ValueError) as e: print(" ⚠ %s ilegible: %s" % (ruta, e)) return False if not corridas: print(" ⊘ el fichero no tiene corridas") return False # Se informan TODAS las corridas y no la última, porque el fenómeno es INTERMITENTE. Y se separan # por CUÁNTAS SALIDAS fotografió cada una: medido el 2026-09-14, con dos tarjetas DRM la ventana # cae en cualquiera de las dos salidas, así que un ✗ de una corrida que sólo miró una pantalla NO # dice que la ventana no esté: dice que no estaba EN ESA. Contarlos juntos fue lo que produjo un # «3 de 13» que medía la cámara y no el producto. ciegas = [c for c in corridas if c.get("pantallas_fotografiadas", 1) < 2 and "dos DRM" in (c.get("video") or "") and c.get("segundos_primera_pintura") is None] corridas = [c for c in corridas if c not in ciegas] pintaron = [c["segundos_primera_pintura"] for c in corridas if c.get("segundos_primera_pintura") is not None] n, m = len(pintaron), len(corridas) marca = "✓" if n == m else ("✗" if n == 0 else "⚠") if ciegas: marca = "⚠" # con corridas ciegas de por medio, un 6/6 no es un ✓: es un «lo que se pudo medir» if pintaron: print(" %s pintaron %d de %d corridas CONCLUYENTES · primera pintura %s" % (marca, n, m, ("+%ds" % pintaron[0]) if n == 1 else "+%d…+%ds" % (min(pintaron), max(pintaron)))) else: print(" %s NINGUNA de %d corridas pintó" % (marca, m)) for c in corridas[-6:]: seg = c.get("segundos_primera_pintura") print(" %s %-9s %-10s %-22s %-26s %s" % ("✓" if seg is not None else "✗", ("+%ds" % seg) if seg is not None else "—", (c.get("pantalla") or "")[:10], c.get("lanzamiento", "?"), c.get("video", "?"), (c.get("medido") or "")[:16])) if ciegas: print(" ⚠ %d corridas NO se cuentan: con dos salidas fotografiaron UNA sola, y su ✗ sólo" " dice que la ventana no estaba en esa pantalla" % len(ciegas)) fallidas = [c for c in corridas if c.get("segundos_primera_pintura") is None] if fallidas: print(" ⚠ en las que NO pintó, el MOZ_LOG igual decía `mapped 1` + «has buffer» +" " WaylandBufferSHM commiteado:") print(" que Gecko commitee cuadros NO prueba que la ventana se vea. La evidencia es" " el píxel, no el log.") return n == 0 def main(): args = [a for a in sys.argv[1:] if not a.startswith("--")] fallar = "--fail" in sys.argv detalle_qml = "--qml" in sys.argv colas = yupana.cargar_colas() perfiles = targets_mod.load() memb = yupana.membresia(colas, perfiles) quiero = args or [p for p in perfiles if p.startswith("escritorio")] fallos = 0 parciales = set() for perfil in quiero: nodos = {n for n, ps in memb.items() if perfil in ps and n[0] != "wanted"} if not nodos: continue temas, cursores, fuentes, terminales, faltan_qml, sin_art, en_binario = escanear(nodos) reales = {t: v for t, v in temas.items() if t != "hicolor"} print("== %-20s %3d nodos del cierre" % (perfil, len(nodos))) parcial = bool(sin_art) if parcial: parciales.add(perfil) # UN AUSENTE FALLA RUIDOSAMENTE: sin el artefacto no se puede afirmar NI que falta NI que # está. Pasó al minuto de escribir este vigía: prender el QML de kcoreaddons dejó 71 # artefactos en deuda y el informe dijo «KDE no tiene terminal» teniendo konsole. print(" ⚠ MEDICIÓN PARCIAL: %d de %d nodos sin artefacto sellado (deuda, o store " "incompleto) ⇒ los ✗ de abajo pueden ser de la deuda, no de la imagen" % (len(sin_art), len(nodos))) print(" sin artefacto: %s%s" % (", ".join(sorted(sin_art)[:8]), " …" if len(sin_art) > 8 else "")) def linea(ok, etiqueta, texto): nonlocal fallos motivo = EXCEPCIONES.get((perfil, etiqueta)) if not ok and motivo: print(" ⊘ %-10s %s" % (etiqueta, motivo)) return if not ok and not parcial: fallos += 1 print(" %s %-10s %s" % ("✓" if ok else "✗", etiqueta, texto)) linea(bool(reales), "iconos", ", ".join("%s (%s, %d)" % (t, v[0], v[1]) for t, v in sorted(reales.items())) or "NINGÚN tema con iconos — los applets/indicadores quedan de ancho cero") linea("hicolor" in temas, "hicolor", "presente" if "hicolor" in temas else "FALTA el fallback freedesktop: los iconos que las apps instalan en " "usr/share/icons/hicolor son invisibles") linea(bool(cursores), "cursores", ", ".join("%s (%s)" % (t, v[0]) for t, v in sorted(cursores.items())) or "NINGÚN tema de cursor — con cursor por software el puntero se mueve invisible") linea(fuentes > 0, "fuentes", "%d ficheros de fuente" % fuentes if fuentes else "CERO fuentes (fontconfig es el motor que las busca, no una fuente)") linea(bool(terminales), "terminal", ", ".join("%s (%s)" % (k, v) for k, v in sorted(terminales.items())) or "NINGÚN emulador de terminal — no se puede salir de un fallo") nota = "" if en_binario: nota = " (%d registrado(s) en C++, no en disco)" % len(en_binario) linea(not faltan_qml, "qml", ("todos los import resuelven" + nota) if not faltan_qml else "%d módulo(s) importado(s) y NO instalado(s)" % len(faltan_qml)) if en_binario and detalle_qml: for m, donde in sorted(en_binario.items()): print(" · %-38s lo registra: %s" % (m, donde)) if faltan_qml and (detalle_qml or True): for m, quienes in sorted(faltan_qml.items()): print(" FALTA %-34s ← lo importa: %s" % (m, ", ".join(sorted(quienes)[:4]))) fallo_pintura = informe_primera_pintura() if parciales: print("\n⚠ perfiles medidos a medias (deuda en el store): %s" % ", ".join(sorted(parciales))) if fallar and (fallos or fallo_pintura): return 1 # medí y FALTA if fallar and parciales: return 2 # NO pude medir — distinto de «está bien» return 0 if __name__ == "__main__": sys.exit(main())