sergio/takana
> Una distribución Linux construida con un laboratorio funcional y hermético en el > sótano, y una terminal mutable, clásica y anárquica en el piso de arriba — diseñada > desde el suelo para que una IA programadora entienda, modifique y co…
cursores: cosmic y sway corrían con el puntero INVISIBLE — receta propia, y el vigía medía mal
1#!/usr/bin/env python3
2# vigia-imagen.py — ¿qué le falta a una IMAGEN que su clausura no puede echar de menos?
3#
4# ══ EL PUNTO CIEGO QUE ESTE VIGÍA CUBRE ════════════════════════════════════════════════════════
5# `build-state.py` contesta «¿está sellado?» y `vigia-sonames.py` contesta «¿algún `.so` que pide
6# esta imagen no lo provee nadie?». Ninguno de los dos contesta la pregunta que se hace un usuario
7# al arrancar el escritorio: **¿se puede USAR?**
8#
9# Y hay dos formas de que no se pueda, las dos medidas el 2026-09-03 arrancando KDE en QEMU:
10#
11# 1. **DATA de runtime que ninguna arista de BUILD alcanza.** El panel de Plasma salía con el reloj
12# y nada más. Los `.so` de todos los applets estaban instalados; lo que faltaba era el TEMA DE
13# ICONOS — `kickoff`, `systemtray` y `showdesktop` SON un icono, así que sin tema quedan de ancho
14# cero y el panel parece vacío. `breeze-icons` estaba sellada desde siempre y no la declaraba
15# ningún perfil. Es la figura de las fuentes ([[gnome-sin-fuentes]]) y de `foot` (sway llegó a
16# 121/121 sin emulador de terminal): **la métrica de clausura mide lo DECLARADO**, y estas cosas
17# no las declara nadie porque no son dep de build de nada.
18#
19# 2. **Una porción OPCIONAL de una librería que sí está.** El menú de aplicaciones no abría:
20# `kcoreaddons.toml` pasaba `-DKCOREADDONS_USE_QML=OFF` ⇒ el módulo QML `org.kde.coreaddons` no
21# existía ⇒ `kickoff` moría en el import. No falta una receta ni una librería: falta una FEATURE.
22# Ninguna métrica basada en recetas puede verlo.
23#
24# ══ QUÉ MIDE, Y CONTRA QUÉ ═════════════════════════════════════════════════════════════════════
25# Recorre los ARTEFACTOS del cierre de cada perfil (no las recetas: el artefacto es lo que se
26# instala) y comprueba cinco invariantes de imagen usable:
27#
28# iconos ≥1 tema con `index.theme` Y ficheros de icono adentro. Un directorio sin `index.theme`
29# NO es un tema: la búsqueda no lo recorre. `hicolor` se cuenta aparte porque es el
30# FALLBACK obligatorio del estándar y no trae iconos propios: es el contrato, no el
31# contenido.
32# cursores ≥1 tema con `cursors/` poblado. Con el cursor por software —obligatorio en virtio-gpu—
33# sin tema el puntero se mueve INVISIBLE. A diferencia de los iconos, acá NO se exige
34# `index.theme`: libXcursor abre `<tema>/cursors/<nombre>` directo.
35# fuentes ≥1 `.ttf/.otf/.ttc/.pfb`. fontconfig es el motor que BUSCA fuentes, no una fuente.
36# terminal ≥1 `.desktop` con `TerminalEmulator` en Categories. Un escritorio sin terminal no es
37# uno incompleto: es uno del que no se puede salir cuando algo falla.
38# qml todo `import <módulo>` de los `.qml` INSTALADOS tiene un módulo con `qmldir` en el
39# cierre. Así se caza la clase (2).
40#
41# ══ RUIDO LEGÍTIMO, para poder triar ═══════════════════════════════════════════════════════════
42# Hay módulos QML que NO se instalan en disco porque el binario los REGISTRA en runtime
43# (`org.kde.plasma.shell` lo registra plasmashell; los `Qt*` los trae el propio Qt en otra ruta).
44# Están en QML_EN_RUNTIME y se excluyen; la lista se amplía con lo que se demuestre, no con lo que
45# se suponga. Todo lo demás que salga acá es accionable.
46#
47# uso: python3 scripts/vigia-imagen.py [perfil...] [--fail] [--qml]
48# --fail → exit 1 si algún invariante falla; exit 2 si NO SE PUDO medir (nodos del cierre
49# sin artefacto sellado). Los dos son accionables y no son lo mismo.
50# --qml → además del resumen, listar los imports sin módulo
51import os, sys, glob, subprocess, re
52
53sys.path.insert(0, os.path.join(os.path.dirname(os.path.abspath(__file__))))
54import yupana
55import targets as targets_mod
56
57ROOT = yupana.ROOT
58STORE = os.environ.get("HAMMER_STORE", str(ROOT / "store"))
59HAMMER = str(ROOT / "target/release/hammer")
60
61# Invariantes que un perfil NO cumple A PROPÓSITO. Un ✗ permanente por una decisión ya tomada es
62# deuda fantasma —la misma figura que la terna GNOME que hubo que sacar de targets.toml para que el
63# grafo dejara de mentir—, así que se declaran acá CON EL MOTIVO y se imprimen como ⊘, no como fallo.
64EXCEPCIONES = {
65 ("escritorio-mirada", "iconos"): "imagen SLIM a propósito: compositor + greeter, sin escritorio",
66 ("escritorio-mirada", "hicolor"): "idem",
67 ("escritorio-mirada", "cursores"): "el greeter dibuja su propio puntero",
68 ("escritorio-mirada", "fuentes"): "idem slim",
69 ("escritorio-mirada", "terminal"): "no es un escritorio de uso general",
70 ("escritorio-sway", "iconos"): "ninguna de sus 4 apps gráficas pide iconos de tema y zathura "
71 "es GTK4 (iconos de UI compilados); un tema COMPLETO exige "
72 "promover adwaita o breeze al corpus — decisión, no olvido",
73}
74
75# Módulos QML que NADIE instala en disco y aun así resuelven: los registra un binario en runtime.
76QML_EN_RUNTIME = {
77 "org.kde.plasma.shell", # plasmashell los registra al arrancar el shell
78 "org.kde.plasma.shell.panel",
79 "org.kde.kwin", # kwin_wayland registra su propia API de efectos
80 "org.kde.kwin.effect",
81}
82
83_cache = {}
84def artefacto(cola, nombre):
85 """Directorio del artefacto de (cola,nombre) en el store, o None. Mismo método que vigia-sonames:
86 `hammer hash` sobre la receta de HOY, sin construir — no heurística de mtime."""
87 if (cola, nombre) in _cache:
88 return _cache[(cola, nombre)]
89 receta = ROOT / ("recipes/%s.toml" % nombre if cola == "corpus"
90 else "recipes/%s/%s.toml" % (cola, nombre))
91 d = None
92 if receta.exists():
93 r = subprocess.run([HAMMER, "--store", STORE, "hash", str(receta)],
94 capture_output=True, text=True)
95 h = r.stdout.strip().replace("b3:", "")
96 if len(h) == 64:
97 cand = glob.glob(os.path.join(STORE, h + "-*"))
98 d = cand[0] if cand else None
99 _cache[(cola, nombre)] = d
100 return d
101
102
103ICONO_EXT = (".png", ".svg", ".svgz", ".xpm")
104FUENTE_EXT = (".ttf", ".otf", ".ttc", ".pfb", ".pcf", ".pcf.gz")
105
106
107def escanear(nodos):
108 """Recorre los artefactos del cierre una sola vez y junta los cinco hechos."""
109 temas, cursores, fuentes, terminales = {}, {}, 0, {}
110 qml_modulos, qml_imports = set(), {}
111 sin_artefacto = []
112 for (cola, nombre) in sorted(nodos):
113 d = artefacto(cola, nombre)
114 if not d:
115 sin_artefacto.append(nombre)
116 continue
117 # ── temas de iconos y de cursores ───────────────────────────────────────────────────────
118 base = os.path.join(d, "usr/share/icons")
119 if os.path.isdir(base):
120 for t in sorted(os.listdir(base)):
121 td = os.path.join(base, t)
122 # ⚠ ICONOS y CURSORES no se buscan igual, y medirlos con la misma regla dio un ✗
123 # falso el 2026-09-04: `adwaita-cursors` instalaba `Adwaita/cursors/` poblado y este
124 # vigía seguía diciendo «NINGÚN tema de cursor» porque el directorio no traía
125 # `index.theme`. Para un tema de ICONOS ese requisito es correcto —la búsqueda XDG
126 # recorre `Directories=` y sin el índice el tema no existe—, pero libXcursor y
127 # wlroots abren `<tema>/cursors/<nombre>` DIRECTO; `index.theme` sólo hace falta
128 # para seguir un `Inherits=`. Exigirlo también acá medía el invariante de al lado.
129 tiene_indice = os.path.exists(os.path.join(td, "index.theme"))
130 n_ico = n_cur = 0
131 for dp, _, fn in os.walk(td):
132 for f in fn:
133 if f.endswith(ICONO_EXT):
134 n_ico += 1
135 elif os.path.basename(dp) == "cursors":
136 n_cur += 1
137 if n_ico and tiene_indice:
138 temas.setdefault(t, (nombre, n_ico))
139 if n_cur:
140 cursores.setdefault(t, (nombre, n_cur))
141 if t == "hicolor" and tiene_indice:
142 temas.setdefault("hicolor", (nombre, n_ico))
143 # ── el resto, en UNA pasada por el árbol ────────────────────────────────────────────────
144 for dp, _, fn in os.walk(d):
145 for f in fn:
146 p = os.path.join(dp, f)
147 if f.endswith(FUENTE_EXT):
148 fuentes += 1
149 elif f == "qmldir":
150 # el módulo es la ruta relativa desde .../qml/ con puntos
151 m = re.search(r"/qt\d*/qml/(.+)$", dp) or re.search(r"/qml/(.+)$", dp)
152 if m:
153 qml_modulos.add(m.group(1).replace("/", "."))
154 elif f.endswith(".qml"):
155 # `designer/`: los `*Specifics.qml` de Qt son la integración con Qt Design Studio
156 # (importan `HelperWidgets`, que sólo existe dentro de Qt Creator). Nunca se
157 # cargan en runtime; contarlos era ruido puro.
158 if "/designer/" in dp + "/":
159 continue
160 try:
161 txt = open(p, encoding="utf-8", errors="replace").read()
162 except OSError:
163 continue
164 # Y los imports DENTRO DE UN COMENTARIO no son imports: `QtMultimedia/Video.qml`
165 # trae `import QtSystemInfo 5.0` en un bloque \qml de la documentación. Sin
166 # despojar comentarios, el vigía reporta como hueco un ejemplo de un docstring.
167 txt = re.sub(r"/\*.*?\*/", "", txt, flags=re.S)
168 txt = re.sub(r"^\s*//.*$", "", txt, flags=re.M)
169 for mod in re.findall(r"^\s*import\s+([A-Za-z][\w.]*)", txt, re.M):
170 qml_imports.setdefault(mod, set()).add(nombre)
171 elif f.endswith(".desktop") and "/applications" in dp:
172 try:
173 txt = open(p, encoding="utf-8", errors="replace").read()
174 except OSError:
175 continue
176 if re.search(r"^Categories=.*TerminalEmulator", txt, re.M):
177 terminales.setdefault(f[:-8], nombre)
178 faltan_qml = {m: v for m, v in qml_imports.items()
179 if m not in qml_modulos and m not in QML_EN_RUNTIME
180 and not m.startswith(("QtQuick", "QtQml", "QtCore", "QtTest", "Qt5", "Qt."))
181 and m != "Qt"}
182 return temas, cursores, fuentes, terminales, faltan_qml, sin_artefacto
183
184
185def main():
186 args = [a for a in sys.argv[1:] if not a.startswith("--")]
187 fallar = "--fail" in sys.argv
188 detalle_qml = "--qml" in sys.argv
189 colas = yupana.cargar_colas()
190 perfiles = targets_mod.load()
191 memb = yupana.membresia(colas, perfiles)
192 quiero = args or [p for p in perfiles if p.startswith("escritorio")]
193 fallos = 0
194 parciales = set()
195 for perfil in quiero:
196 nodos = {n for n, ps in memb.items() if perfil in ps and n[0] != "wanted"}
197 if not nodos:
198 continue
199 temas, cursores, fuentes, terminales, faltan_qml, sin_art = escanear(nodos)
200 reales = {t: v for t, v in temas.items() if t != "hicolor"}
201 print("== %-20s %3d nodos del cierre" % (perfil, len(nodos)))
202 parcial = bool(sin_art)
203 if parcial:
204 parciales.add(perfil)
205 # UN AUSENTE FALLA RUIDOSAMENTE: sin el artefacto no se puede afirmar NI que falta NI que
206 # está. Pasó al minuto de escribir este vigía: prender el QML de kcoreaddons dejó 71
207 # artefactos en deuda y el informe dijo «KDE no tiene terminal» teniendo konsole.
208 print(" ⚠ MEDICIÓN PARCIAL: %d de %d nodos sin artefacto sellado (deuda, o store "
209 "incompleto) ⇒ los ✗ de abajo pueden ser de la deuda, no de la imagen"
210 % (len(sin_art), len(nodos)))
211 print(" sin artefacto: %s%s" % (", ".join(sorted(sin_art)[:8]),
212 " …" if len(sin_art) > 8 else ""))
213
214 def linea(ok, etiqueta, texto):
215 nonlocal fallos
216 motivo = EXCEPCIONES.get((perfil, etiqueta))
217 if not ok and motivo:
218 print(" ⊘ %-10s %s" % (etiqueta, motivo))
219 return
220 if not ok and not parcial:
221 fallos += 1
222 print(" %s %-10s %s" % ("✓" if ok else "✗", etiqueta, texto))
223
224 linea(bool(reales), "iconos",
225 ", ".join("%s (%s, %d)" % (t, v[0], v[1]) for t, v in sorted(reales.items()))
226 or "NINGÚN tema con iconos — los applets/indicadores quedan de ancho cero")
227 linea("hicolor" in temas, "hicolor",
228 "presente" if "hicolor" in temas
229 else "FALTA el fallback freedesktop: los iconos que las apps instalan en "
230 "usr/share/icons/hicolor son invisibles")
231 linea(bool(cursores), "cursores",
232 ", ".join("%s (%s)" % (t, v[0]) for t, v in sorted(cursores.items()))
233 or "NINGÚN tema de cursor — con cursor por software el puntero se mueve invisible")
234 linea(fuentes > 0, "fuentes",
235 "%d ficheros de fuente" % fuentes if fuentes
236 else "CERO fuentes (fontconfig es el motor que las busca, no una fuente)")
237 linea(bool(terminales), "terminal",
238 ", ".join("%s (%s)" % (k, v) for k, v in sorted(terminales.items()))
239 or "NINGÚN emulador de terminal — no se puede salir de un fallo")
240 linea(not faltan_qml, "qml",
241 "todos los import resuelven" if not faltan_qml
242 else "%d módulo(s) importado(s) y NO instalado(s)" % len(faltan_qml))
243 if faltan_qml and (detalle_qml or True):
244 for m, quienes in sorted(faltan_qml.items()):
245 print(" FALTA %-34s ← lo importa: %s"
246 % (m, ", ".join(sorted(quienes)[:4])))
247 if parciales:
248 print("\n⚠ perfiles medidos a medias (deuda en el store): %s" % ", ".join(sorted(parciales)))
249 if fallar and fallos:
250 return 1 # medí y FALTA
251 if fallar and parciales:
252 return 2 # NO pude medir — distinto de «está bien»
253 return 0
254
255
256if __name__ == "__main__":
257 sys.exit(main())