Los DOS únicos ✗ que reportaba este vigía eran falsos, y de la misma clase: un `import` no siempre se
satisface con un directorio y su `qmldir`. El greeter de la pantalla de bloqueo hace
`qmlRegisterUncreatableType<PamAuthenticator>("org.kde.kscreenlocker", …)` — el módulo existe SÓLO
dentro del proceso que carga ese `.qml` y en disco no hay nada que instalar. Buscarlo en el sistema
de ficheros da ✗ con la imagen perfecta.
Los dos, verificados uno por uno antes de tocar el script:
· `org.kde.kscreenlocker` ← `usr/lib64/libexec/kscreenlocker_greet`. Y valía la pena mirarlo: los
que lo importan son `lockscreen/LockScreenUi.qml` y `MainBlock.qml` de plasma-desktop, o sea la
pantalla de bloqueo de verdad. Si hubiera faltado no era un diálogo de ajustes.
· `org.kde.newstuff.core` ← `usr/lib/qt6/qml/org/kde/newstuff/libnewstuffqmlplugin.so`.
Antes esto se tapaba anotando el módulo a mano en `QML_EN_RUNTIME`. `registrado_en_binario()` lo
contesta ahora con evidencia —el URI queda como literal en `.rodata`, así que se busca en los ELF del
cierre— y además NOMBRA al registrador, que es lo que permite auditar el veredicto sin repetirlo.
Mira primero los artefactos cuyo nombre comparte una palabra con el URI, que es donde está casi
siempre, y sólo si no aparece recorre el resto.
⇒ la lista escrita a mano SE BORRA ENTERA. Al vaciarla, los cuatro módulos que tenía se resolvieron
solos, cada uno nombrando su binario (plasmashell, kwin_wayland). Y salió gratis un hallazgo que
ninguna lista podía dar: `org.kde.kwin.effect` no se importa en NINGÚN cierre — era una entrada
muerta, y nadie tenía cómo saberlo.
Los cinco perfiles quedan en ✓ de qml, sin un solo ✗ en todo el informe.
La regla detrás: un guardián que grita en falso no se endurece, se ignora. Cuando el 100% de sus
hallazgos son falsos positivos, el trabajo no es anotarlos — es enseñarle a preguntar bien.
316 lines
18 KiB
Python
316 lines
18 KiB
Python
#!/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 `<tema>/cursors/<nombre>` 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 <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).
|
|
# 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
|
|
|
|
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",
|
|
}
|
|
|
|
# (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<PamAuthenticator>(
|
|
"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 `<tema>/cursors/<nombre>` 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 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])))
|
|
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())
|