Files
Sergio 2f90d8eb40 arnés de la imagen: el contador de caídas viaja con la medición, y las envenenadas se apartan
`atuq-en-imagen.py` ahora lee `toolkit.startup.recent_crashes` del perfil ANTES y DESPUÉS de cada
corrida y lo anota junto al tiempo de primera pintura (`recent_crashes_antes` / `_despues` /
`_fijado`). Los dos volcados, no uno: Gecko BORRA el contador al mostrar el diálogo de Modo de
resolución de problemas, así que un perfil envenenado se ve limpio si se lo mira después — y la
corrida siguiente parece arreglarse sola mientras la anterior parece intermitente.

Con eso, `vigia-imagen.py` saca de la tasa las corridas con el contador > 3 (ahí el sujeto no es el
navegador sino un diálogo modal) y para las 21 anteriores al campo dice que NO SE PUEDEN CLASIFICAR
en vez de contarlas como buenas. Un denominador se marca, no se borra.

Mandos y guardas nuevas:

· `--crashes N` fija el contador en el `user.js` del perfil: 0 limpia, >3 reproduce. Es el control en
  los dos sentidos, que es lo único que distingue causa de correlación con suerte;
· `WidgetScreen:5` en el MOZ_LOG y una sonda `ATUQ-DIAG` en la propia página (screen/outer/inner por
  `dump()`), que es lo que refutó la carrera con `wl_output`;
· la lista ordenada del protocolo ya no se ahoga en los ~60 modos que anuncia QEMU —el `head`
  cortaba antes del `set_window_geometry`— y trae `set_title`/`set_app_id`, que es lo que identificó
  la ventana;
· si QEMU no arranca, se dice en el primer segundo leyendo `qemu.log`. El fallo real es «Failed to
  get write lock» con otra VM sobre la misma imagen, y sin esta guarda se veía como diez minutos
  esperando una marca del serial: el socket queda creado y el `connect()` funciona.

Las fases (`--xulstore`), el `ATUQ-EXIT=$?`, la copia del log del arranque anterior y el `find` del
perfil sobre la raíz entera los escribió la otra sesión que trabaja este frente; conviven acá porque
el fichero es compartido.
2026-09-15 17:46:30 +00:00

427 lines
25 KiB
Python
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
#!/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).
#
# ══ 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<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 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]
# ⚠ Y SE SEPARAN LAS CORRIDAS CON EL PERFIL ENVENENADO. Medido el 2026-09-15 (§6.10.terdecies):
# con `toolkit.startup.recent_crashes` por encima de `max_resumed_crashes` —3 por defecto— el
# navegador NO abre una ventana de navegador: abre el diálogo de Modo de resolución de problemas,
# que es modal y mide 117×70. El ✗ de esas corridas es cierto y no dice NADA del producto. Y el
# contador lo subía este mismo andamiaje al matar la VM con el navegador vivo, así que la serie
# vieja está contaminada: las corridas anteriores a que el arnés anotara el campo NO SE PUEDEN
# clasificar, y eso se dice en vez de contarlas como si fueran buenas.
def envenenada(c):
fijado = c.get("recent_crashes_fijado") # lo que PUSO el arnés (user.js gana)
valor = fijado if fijado is not None else c.get("recent_crashes_antes")
return valor is not None and valor > 3
envenenadas = [c for c in corridas if envenenada(c)]
corridas = [c for c in corridas if c not in envenenadas]
sin_clasificar = [c for c in corridas if "recent_crashes_antes" not in c
and c.get("recent_crashes_fijado") is None]
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 sin_clasificar:
marca = "⚠" # una tasa con corridas de procedencia desconocida adentro no es una tasa
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))
if envenenadas:
print(" ⊘ %d corridas APARTE: el perfil tenía recent_crashes > 3 ⇒ lo que abrió fue el "
"diálogo de Modo de resolución de problemas, no el navegador (SDD 26 §6.10.terdecies)"
% len(envenenadas))
if sin_clasificar:
print(" ⚠ %d de esas %d corridas son de ANTES de que se anotara el contador de caídas: "
"no se puede decir si midieron el navegador o el diálogo" % (len(sin_clasificar), 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())