diff --git a/scripts/vigia-imagen.py b/scripts/vigia-imagen.py new file mode 100644 index 00000000..be860723 --- /dev/null +++ b/scripts/vigia-imagen.py @@ -0,0 +1,250 @@ +#!/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. +# 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). +# +# ══ 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). +# Están en QML_EN_RUNTIME y se excluyen; la lista se amplía con lo que se demuestre, no con lo que +# se suponga. 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 + +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/hammer") + +# 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", +} + +# Módulos QML que NADIE instala en disco y aun así resuelven: los registra un binario en runtime. +QML_EN_RUNTIME = { + "org.kde.plasma.shell", # plasmashell los registra al arrancar el shell + "org.kde.plasma.shell.panel", + "org.kde.kwin", # kwin_wayland registra su propia API de efectos + "org.kde.kwin.effect", +} + +_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 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 = [] + for (cola, nombre) in sorted(nodos): + d = artefacto(cola, nombre) + if not d: + sin_artefacto.append(nombre) + continue + # ── 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) + if not os.path.exists(os.path.join(td, "index.theme")): + continue # un directorio sin index.theme NO es un tema + 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: + temas.setdefault(t, (nombre, n_ico)) + if n_cur: + cursores.setdefault(t, (nombre, n_cur)) + if t == "hicolor": + 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) + faltan_qml = {m: v for m, v in qml_imports.items() + if m not in qml_modulos and m not in QML_EN_RUNTIME + and not m.startswith(("QtQuick", "QtQml", "QtCore", "QtTest", "Qt5", "Qt.")) + and m != "Qt"} + return temas, cursores, fuentes, terminales, faltan_qml, sin_artefacto + + +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 = 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") + linea(not faltan_qml, "qml", + "todos los import resuelven" if not faltan_qml + else "%d módulo(s) importado(s) y NO instalado(s)" % len(faltan_qml)) + 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]))) + if parciales: + print("\n⚠ perfiles medidos a medias (deuda en el store): %s" % ", ".join(sorted(parciales))) + if fallar and fallos: + 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())