vigia-imagen: el guardián de los dos huecos de hoy — data de runtime y features apagadas
La regla del repo es que cada punto ciego se vuelve un guardián. Hoy aparecieron dos, los dos
arrancando KDE en QEMU y ninguno visible para las métricas que ya había:
1. DATA de runtime que ninguna arista de BUILD alcanza — el tema de iconos. `build-state` mide
lo DECLARADO y nadie declara lo que no es dep de build de nadie.
2. Una porción OPCIONAL de una librería que sí está — el módulo QML de kcoreaddons. No falta una
receta ni una librería: falta una FEATURE, y ninguna métrica sobre recetas puede verlo.
`vigia-imagen.py` 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, hicolor, cursores,
fuentes, terminal y que todo `import` de los `.qml` instalados tenga un módulo con `qmldir`.
Hermano de `vigia-sonames.py`, que cubre la otra mitad del runtime (los SONAME sin proveedor).
Tres decisiones de diseño que salieron de usarlo contra el corpus real:
- **MEDICIÓN PARCIAL, gritada.** El primer informe dijo «KDE no tiene terminal» teniendo konsole:
los 71 artefactos que la cascada de kcoreaddons dejó en deuda no existen en el store, y sin
artefacto no se puede afirmar NI que falta NI que está. Ahora cuenta los nodos sin artefacto, lo
dice arriba de todo y no cuenta esos ✗ como fallos. `--fail` distingue **exit 1 = medí y falta**
de **exit 2 = no pude medir**. Es la regla del ausente ruidoso, aplicada al propio vigía.
- **EXCEPCIONES con motivo escrito.** `escritorio-mirada` es slim a propósito y `escritorio-sway`
no lleva tema de iconos por una decisión medida. Un ✗ permanente por algo ya decidido es deuda
fantasma — la figura de la terna GNOME que hubo que sacar de targets.toml.
- **Ruido eliminado midiendo, no suponiendo.** `QtSystemInfo` salía como hueco y estaba DENTRO de
un bloque \qml de la documentación de `Video.qml`; `HelperWidgets` viene de los `*Specifics.qml`
de `designer/`, que sólo carga Qt Design Studio. Se despojan comentarios y se saltea `designer/`.
Estado hoy: gnome ✓ en los seis; cosmic y sway ✓ salvo **cursores**, que queda por triar — el
puntero SÍ se ve en las capturas de las dos, así que puede ser fallback embebido del compositor
(smithay trae uno) y no un hueco. KDE sale parcial por la deuda de kcoreaddons.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01YGXe6mShYYw6V8ea1dQ13u
This commit is contained in:
@@ -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 <módulo>` 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())
|
||||
Reference in New Issue
Block a user