gioser

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…

TOML 1430Shell 145Markdown 87Rust 852973 Definiciones

⎇ 3786b4de9f64ef894661d9dde2ebca6d9dc4a299 scripts/censo-distro.py Python · 520 líneas · 36.0 KB Historia Quién tocó qué crudo

censo: dos filas medidas — contenedores es una campaña, texinfo no vale la pena d26589f06 · Sergio ·

1#!/usr/bin/env python3
2"""censo-distro.py — qué le falta a la distro para que una persona la use.
3
4── POR QUÉ EXISTE, Y QUÉ PREGUNTA CONTESTA QUE LAS OTRAS NO ────────────────────────────────────
5El repo ya mide tres cosas y ninguna es ésta:
6
7  · `build-state*.json`    ¿lo DECLARADO está construido?        (hoy: 1158/1158, deuda 0)
8  · `frontera-triaje.toml` ¿la clausura de build tiene huecos?   (cerrada, 362/365 decididos)
9  · `cierres.json`         ¿cada perfil cierra?                  (hoy: 10 de 10)
10
11Las tres contestan **«¿está completo lo que ya nombramos?»**. Ninguna contesta **«¿alcanza para
12usarla?»**, y por eso el tablero podía estar entero en verde mientras la distro no sabía mostrar un
13carácter chino. Lo dijo el operador el 2026-10-16: *«no hay qué granjear desde lo que tenemos. pero
14lo que no tenemos, faltan montón de cosas»*. Tenía razón y no existía el número.
15
16⚠ **LA LISTA DE ABAJO ES JUICIO, NO MEDIDA**, igual que la columna `destino` de
17`busybox-applets.tsv` — y conviene que esté dicho en la primera línea del fichero. Lo que SÍ se mide
18es cada renglón: si hay receta (en el catálogo, en una cola o en `tandas/`) y si viaja en alguna
19imagen. Agregar o quitar un renglón es una decisión de producto; el guion sólo dice en qué estado
20está lo que alguien ya decidió que importa.
21
22⚠ **EL PARENTESCO DE NOMBRE ES UNA PISTA, NO UN VEREDICTO — y esto se corrigió DOS VECES.**
23
24La v1 cantaba huecos falsos: «falta `clang`» con `clang18` en el catálogo, «falta `rustc`» con
25`rust`, «falta `neomutt`» con `mutt`. Se arregló buscando un pariente de nombre y sacando esos
26renglones del recuento… y eso fue **peor**, porque entonces el censo escondía huecos REALES:
27`neovim` salía como «revisar → vim» (y neovim falta de verdad: existe `bob-nvim`, su gestor de
28versiones, sin neovim), `swaync` → `sway`, `patchelf` → `patch`, `man-pages` → `mandoc` (que es el
29LECTOR, no el contenido).
30
31⇒ **Un pariente ANOTA, no absuelve.** Un candidato sin receta cuenta como `falta`, siempre; si hay
32un nombre parecido se añade como `pista` para que nadie repita la búsqueda. Las dos formas de
33mentir tienen el mismo antídoto: que la pista viaje al lado del hueco en vez de reemplazarlo.
34
35Uso:  censo-distro.py [--json docs/state/distro-huecos.json]
36"""
37import argparse
38import glob
39import importlib.util
40import json
41import os
42import sys
43
44RAIZ = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
45sys.path.insert(0, os.path.join(RAIZ, 'scripts'))
46
47# Cada entrada: categoría → (por_qué_importa, [nombres candidatos]).
48# El «por qué» no es adorno: un renglón sin motivo se discute para siempre.
49CHECKLIST = {
50    # ── LO QUE DEJA A GENTE AFUERA ──────────────────────────────────────────────────────────────
51    'escritura del mundo': (
52        '✅ CERRADO como CAPACIDAD: `noto-fonts` + `noto-fonts-cjk` + `noto-fonts-emoji` están '
53        'SELLADAS y declaradas en los perfiles de escritorio, así que la distro YA pinta chino, '
54        'japonés, coreano, árabe, hindi, tailandés y emoji. ⚠ Este texto decía «las únicas '
55        'fuentes del corpus son dejavu y su nerd» y era CIERTO cuando se escribió y FALSO al '
56        'leerlo: la cuenta (1/4) se actualiza sola y la prosa NO, así que el renglón se '
57        'contradecía consigo mismo. Lo que falta, `noto-fonts-extra`, son los PESOS restantes '
58        '(Light, Thin, Condensed): `noto-fonts` ya toma Regular+Bold hinted de todas las '
59        'familias. ⇒ no es un alfabeto que no se vea, es tipografía fina',
60        ['noto-fonts-cjk', 'noto-fonts-emoji', 'noto-fonts', 'noto-fonts-extra']),
61    'escribir en esos idiomas': (
62        'una fuente deja LEER; para ESCRIBIR hace falta un método de entrada. ✅ CERRADO para los '
63        'TRES grandes: marco `fcitx5` + COREANO (`fcitx5-hangul`, 2026-10-20, que viaja por su '
64        'diccionario hanja con la ruta horneada en la .a) + JAPONÉS (`fcitx5-anthy`) + CHINO '
65        '(`fcitx5-chinese-addons` con libime y sus tablas de pinyin). Lo único que falta, '
66        '`fcitx5-rime`, es un motor ALTERNATIVO de chino: redundante con el que ya entró, no un '
67        'idioma sin cubrir. ⚠ Y acá también la prosa envejeció antes que la cuenta: decía '
68        '«faltan los otros idiomas» con el japonés y el chino ya sellados. '
69        '⚠ Los renglones se nombran como los PAQUETES de fcitx5, no como las bibliotecas motor: mi '
70        'primera versión pedía `anthy`, `libpinyin` y `hangul`, y con `fcitx5-hangul` ya sellado el '
71        'censo seguía contando «hangul» como hueco — un hueco falso por nombrar la pieza de abajo '
72        'en vez de la que se declara',
73        ['fcitx5', 'ibus', 'fcitx5-hangul', 'fcitx5-anthy', 'fcitx5-chinese-addons',
74         'fcitx5-rime']),
75    'accesibilidad': (
76        'sin lector de pantalla ni voz la distro es inusable para quien no ve',
77        ['orca', 'speech-dispatcher', 'espeak-ng', 'brltty']),
78
79    # ── LO QUE ALGUIEN BUSCA EL PRIMER DÍA ──────────────────────────────────────────────────────
80    'terminales populares': (
81        '`foot` está y alcanza; pero quien llega de otra distro busca el suyo por nombre, y el '
82        'doc 20 §II.2 ya los listaba como «barato». kitty: 13 de sus 14 deps ya están en el '
83        'catálogo (falta librsync, opcional)',
84        ['kitty', 'alacritty', 'wezterm', 'ghostty', 'xterm']),
85    'editores': (
86        '`vim` y `helix` están. ⚠ Existe `bob-nvim` —el GESTOR DE VERSIONES de neovim— y no '
87        'existe neovim: el conductor sin el motor, como `crun` sin podman',
88        ['neovim', 'emacs', 'micro', 'kakoune', 'nano']),
89    'shells': ('bash, zsh y nushell están', ['fish', 'elvish']),
90    'lanzador, notificaciones y bloqueo': (
91        'el esqueleto de un escritorio Wayland que no sea GNOME/KDE: sin lanzador no se abre nada',
92        ['rofi', 'wofi', 'fuzzel', 'bemenu', 'tofi', 'mako', 'dunst', 'swaync', 'swaylock',
93         'gtklock', 'waylock']),
94    'audio de escritorio': (
95        '✅ CUBIERTO 2026-10-21: `pamixer` viaja en los CINCO escritorios (mirada, kde, gnome, cosmic, '
96        'sway) ⇒ ya se le puede subir el volumen. Lo que falta son los nombres ajenos que alguien '
97        'busca (`pavucontrol`) y el control de reproducción (`playerctl`), que es otra capacidad',
98        ['pavucontrol', 'pamixer', 'playerctl', 'easyeffects', 'wiremix']),
99    'capturas y portapapeles': ('grim, slurp, wl-clipboard y cliphist ya están',
100                                ['satty', 'swappy', 'flameshot', 'wtype', 'kanshi']),
101    # ✅ CERRADO el 2026-10-20 con `swayimg`, y la fila cambia de forma a propósito: decía «5 de 5
102    # ausente» listando los cinco visores famosos, y **ninguno de los cinco puede funcionar acá**.
103    # Dejar esa fila en rojo invitaba a una campaña imposible; lo honesto es que el candidato que SÍ
104    # sirve esté en la lista y que los muros de los otros queden escritos (ver `MUROS`).
105    'visores de imagen': ('swayimg está: png/jpeg/webp/tiff/gif + rotación EXIF, y modo DRM para '
106                          'consola. Los cinco de abajo están TAPADOS, ver MUROS',
107                          ['swayimg', 'imv', 'nsxiv', 'feh', 'loupe', 'eog']),
108
109    # ── CAPACIDADES ENTERAS SIN NINGÚN CAMINO ───────────────────────────────────────────────────
110    'archivos comprimidos ajenos': (
111        '⚠ EL ARGUMENTO DE ESTA FILA ERA FALSO Y LO MEDÍ DESPUÉS: decía «no se puede abrir un .zip» '
112        'porque `libzip` es una biblioteca y no el comando. Cierto para libzip — y falso como '
113        'conclusión: `libarchive` estaba SELLADO y trae `bsdunzip`/`bsdtar`, verificados con un zip '
114        'real en lectura Y escritura. Se declaró en `cli` el 2026-10-19. Lo que falta son los ALIAS '
115        '(`unzip`, `zip`) para que un script ajeno que los invoque no falle, y 7z/rar sin medir',
116        ['unzip', 'zip', 'p7zip', '7zip', 'unrar']),
117    'imprimir de verdad': ('cups solo no imprime: los filtros y los drivers son otro paquete',
118                           ['cups', 'cups-filters', 'ghostscript', 'gutenprint', 'hplip']),
119    'escanear': ('ningún camino hoy', ['sane-backends', 'simple-scan']),
120    'documentos técnicos': ('✅ CUBIERTO: `typst` viaja en `cli` y compone PDF desde texto marcado — que era exactamente lo '
121        'que esta fila decía que no existía. Lo que falta son los ecosistemas AJENOS: `texlive` '
122        '(que es gigante) y `pandoc` (conversor entre formatos, otra capacidad). `groff` es el '
123        'formateador de las páginas man, que es el renglón de arriba',
124                            ['texlive', 'pandoc', 'typst', 'groff']),
125    'páginas de manual': ('✅ CUBIERTO el 2026-10-21: `man-pages` 6.20 entró en `perfil.cli` — 3.153 páginas, 513 de '
126        'ellas de SECCIÓN 2, de la que el corpus entero tenía CERO. `man open`, `man mmap` y '
127        '`man malloc` ya contestan (probado con el artefacto, rc=0). Ojo con el diagnóstico '
128        'viejo: decía «falta el CONTENIDO» y no era que no hubiera páginas —había 4.710 de '
129        'sección 3, porque cada paquete trae las suyas— sino que faltaban las del proyecto '
130        'Linux man-pages: syscalls, libc y conceptos. ⚠ Y lo que SIGUE roto no es contenido: '
131        '`apropos`/`whatis`/`man -k` devuelven «nothing appropriate» porque `makewhatis` no '
132        'logra escribir el índice (su lector dice «Inconsistent file size 260 (expected 4)» '
133        'sobre un fichero que él mismo escribió). Es defecto del empaquetado de mandoc, y '
134        'tocarlo reconstruye `base`. Lo que falta de verdad acá es `texinfo`, el formateador '
135        'de páginas info. ⚠ Y MEDIDO el 2026-10-21: NO vale la pena. El store tiene 83 ficheros '
136        '`.info`, pero **sólo 12 viajan en una imagen** (tar 4, y uno cada uno de grep, sed, gzip, '
137        'nano, parted, bash, wget, libffi-shared); los otros 71 son de herramientas de build que '
138        'no se embarcan (gdb 15, binutils 8, gmp 6, xorriso 5…). Y cada uno de esos doce trae '
139        'también su página de manual, que `mandoc` ya lee. ⇒ `texinfo` sería un LECTOR para doce '
140        'documentos que ya se pueden leer de otra forma: la figura del lector sin nada que leer. '
141        'Su valor real es como herramienta de BUILD (`makeinfo`), no como paquete de imagen',
142                          ['man-pages', 'texinfo']),
143    'virtualización': ('ni invitado ni anfitrión: no se puede probar la propia ISO sin salir',
144                       ['qemu', 'libvirt', 'virt-manager']),
145    'contenedores': ('⚠ ALCANCE MEDIDO 2026-10-21, y la frase vieja («`crun` está (el motor); falta quien lo '
146        'conduzca») engañaba por optimista: `crun` está SELLADO pero **declarado en NINGÚN perfil**, '
147        'así que ni el motor viaja — es catálogo, no distro. La pila mínima para un `podman run` '
148        'útil son 3 recetas nuevas (`conmon`, `containers-common`, `podman`) y con red del host; '
149        'para red propia hacen falta otras 2 (`netavark`, `aardvark-dns`) y para almacenamiento '
150        'sin root `fuse-overlayfs`. Sellados hoy: `crun`, `libslirp`, `go`. ⇒ es una CAMPAÑA de '
151        '5-7 recetas, no un olvido; `docker` (otro demonio entero) y `flatpak` (otro formato de '
152        'paquete, con su propio runtime) son decisiones aparte',
153                     ['podman', 'docker', 'buildah', 'flatpak', 'distrobox']),
154    'java': ('un ecosistema entero queda fuera sin una JVM', ['openjdk', 'jdk', 'temurin']),
155    'bases de datos de servidor': ('sqlite está; un servicio con varios clientes no',
156                                   ['postgresql', 'mariadb', 'redis', 'valkey']),
157    'depurar y medir': ('⚠ MATIZADO 2026-10-21, y el matiz es el punto: `gdb` 16.3 está SELLADO y probado (PIE, cero '
158        'R_X86_64_COPY, abre un core y resuelve símbolos) pero NO viaja en ninguna imagen — y eso '
159        'NO es un descuido. Son **232 MB** (el binario solo 166 MB, del que el 45 % es info de '
160        'depuración: separándola con el SDD 23 quedarían ~90 MB, igual de pesados para una imagen '
161        'de producto). ⇒ es el patrón «opcional = receta SIN perfil»: la Etapa F lo publica y se '
162        'INSTALA cuando hace falta, con cero peso de imagen. La primera versión de este texto '
163        'decía «no hay con qué mirar un core», que se leía como hueco de capacidad cuando es una '
164        'decisión de peso. Lo que falta de verdad: perf, valgrind, lldb, ltrace',
165                        ['gdb', 'lldb', 'valgrind', 'ltrace', 'perf', 'patchelf']),
166    'discos y salud del equipo': ('✅ CUBIERTO 2026-10-21: `smartmontools` viaja en `base`, o sea en TODAS las imágenes, con su '
167        'drivedb de 792 entradas anclada ⇒ un disco muriéndose ya avisa. Faltan `nvme-cli` '
168        '(gestión NVMe, no diagnóstico SMART), `hdparm` y `gparted`',
169                                  ['smartmontools', 'nvme-cli', 'hdparm', 'gparted']),
170
171    # ── LENGUAJES Y HERRAMIENTAS DE CONSTRUCCIÓN ────────────────────────────────────────────────
172    'otros lenguajes': (
173        'python3, lua, go, rust y perl están. ⚠ `zig` NO es receta: vive como artefacto '
174        '`seed-zig` fuera del catálogo, y es el compilador de TODO el corpus',
175        ['nodejs', 'deno', 'bun', 'ruby', 'php', 'julia', 'ghc', 'ocaml', 'elixir', 'erlang']),
176    'empaquetadores de python': ('✅ CUBIERTO en lo esencial: `uv` viaja en `cli` e instala herramientas de Python (`uv tool '
177        'install`), que era lo que esta fila decía imposible. Y `py3-pip` entró el 2026-10-20. '
178        'Lo que falta, `pipx` y `poetry`, son los nombres que alguien trae de otra distro',
179                                 ['pipx', 'uv', 'poetry']),
180    'construcción C clásica': (
181        '⚠ ESTA FILA ES LA MÁS DUDOSA DEL CHECKLIST y conviene que lo diga: cmake, meson, make y '
182        'samurai existen y NO viajan en ninguna imagen — probablemente a propósito, porque una '
183        'imagen de producto no se construye a sí misma. La receta de `nodejs` ya tomó esa decisión '
184        'por escrito para su caso («acá Node es un compilador más, no software que la distro '
185        'publique»). ⇒ lo que falta acá puede no ser un hueco sino un PERFIL de desarrollo que no '
186        'existe. Se deja medido y sin declarar. `ninja` además lo instala `samurai` con ese nombre '
187        'exacto (1.9.0, medido). Y `gcc` está RETENIDO a propósito (sólo `gcc-libs`), `clang` es '
188        '`clang18`: eso no es hueco, es decisión',
189        ['autoconf', 'automake', 'ninja']),
190
191    # ── RED, SEGURIDAD, DATOS ───────────────────────────────────────────────────────────────────
192    'vpn y red gestionada': ('networkmanager, wireguard-tools y wpa_supplicant están',
193                             ['openvpn', 'iwd', 'nmtui', 'impala', 'mosh', 'sshfs']),
194    'bluetooth usable': ('✅ EL AUDIO POR BLUETOOTH FUNCIONA DESDE EL 2026-10-21. Antes esta fila '
195        'decía «falta con qué emparejar un auricular» y estaba mal EN LAS DOS DIRECCIONES. '
196        'Emparejar ya se podía: `bluez` trae `bluetoothctl` 5.87 (probado) y su daemon, y los '
197        'cuatro escritorios HABILITAN el servicio `bluetoothd`. Lo que faltaba era un piso más '
198        'abajo y la fila no lo veía: `pipewire` iba con `-Dbluez5=disabled`, así que se '
199        'emparejaba y NO SONABA. Arreglado: receta `sbc` (el único códec que A2DP exige) + '
200        '`bluez` recompilado con PIC (su .a no entraba en una .so) + `-Dbluez5=enabled`. '
201        'Verificado en el artefacto: /usr/lib/spa-0.2/bluez5/ con libspa-bluez5.so y su códec, '
202        '14 símbolos sbc_* DEFINIDOS. Costó rearmar 19 recetas, la cadena PGO de Gecko '
203        'incluida. ⇒ Lo que queda son los FRONTENDS (`blueman` gráfico, `bluetuith` de '
204        'terminal): comodidad, no capacidad',
205                        ['blueman', 'bluetuith']),
206    'contraseñas': ('✅ CUBIERTO el 2026-10-21: `pass` entró en `perfil.cli`, que por herencia lo pone en '
207        'CINCO imágenes (cli, kde, gnome, cosmic, sway) más servidor. Guarda y devuelve '
208        'secretos de verdad — el guardián de la receta hace el ciclo init+insert+show y '
209        'compara. Costó ~400 KB porque `gnupg` (40 MB), `git`, `pinentry` y `util-linux` ya '
210        'viajaban en `base`. ⚠ Este texto decía «nada: la gente guarda sus claves en un .txt '
211        'o no usa la distro» y era cierto hasta ese día: la cuenta se actualiza sola y la '
212        'prosa no (ya pasó dos veces hoy con fuentes y métodos de entrada). Lo que queda, '
213        '`keepassxc`, es la variante GRÁFICA con su base de datos propia — otra capacidad, '
214        'no la misma; `gopass` es un reimplementación en Go del mismo formato, redundante',
215                    ['keepassxc', 'pass', 'gopass']),
216    'respaldos': ('restic y rclone están', ['borgbackup', 'rsnapshot']),
217    'descargas y torrent': ('curl y wget están', ['aria2', 'transmission', 'qbittorrent']),
218
219    # ── APLICACIONES DE ESCRITORIO ──────────────────────────────────────────────────────────────
220    'navegadores': ('firefox y atuq están; el motor Blink no tiene ningún camino',
221                    ['chromium', 'qutebrowser', 'librewolf', 'brave']),
222    'mensajería': ('ningún cliente de chat: ni XMPP, ni Matrix, ni los propietarios',
223                   ['dino', 'gajim', 'element-desktop', 'telegram-desktop', 'signal-desktop']),
224    'música': ('mpv y cmus están para reproducir un fichero; falta biblioteca y daemon',
225               ['mpd', 'ncmpcpp', 'rhythmbox', 'audacious']),
226    'video': ('mpv está', ['vlc', 'celluloid', 'totem']),
227    'imagen y diseño': (
228        '⚠ la suite ArtCraft propia cubre esto con 12 apps (photocraft, vectorcraft, …): estos '
229        'renglones son los nombres AJENOS que alguien busca, no un hueco de capacidad',
230        ['gimp', 'inkscape', 'krita', 'darktable']),
231    'gestores de archivos': ('yazi, nnn, lf, broot y dolphin/nautilus están',
232                             ['thunar', 'ranger']),
233    'lectores de PDF': ('zathura y okular están', ['mupdf', 'sioyek', 'evince']),
234    'notas': ('ninguno', ['obsidian', 'logseq', 'joplin', 'zettlr']),
235
236    # ── DECISIONES ABIERTAS, NO OLVIDOS ─────────────────────────────────────────────────────────
237    'otros compositores Wayland': (
238        'hay cinco escritorios (mirada, sway, cosmic, gnome, kde); éstos son los que la gente '
239        'nombra y no están. Decisión de alcance, no olvido',
240        ['hyprland', 'river', 'niri', 'labwc', 'wayfire']),
241    'juegos': ('decisión abierta: arrastra Vulkan, que la distro hoy no publica',
242               ['wine', 'steam', 'gamescope', 'lutris', 'mangohud']),
243    'infra ajena': ('decisión abierta: son herramientas de operar OTRA cosa',
244                    ['kubectl', 'helm', 'terraform', 'ansible']),
245}
246
247
248# ── CAPACIDAD YA CUBIERTA CON OTRO NOMBRE ──────────────────────────────────────────────────────
249# Lo simétrico de `MUROS`: el renglón falta POR NOMBRE y la capacidad SÍ está, medida. No absuelve
250# el hueco —un script que invoque `unzip` sigue fallando, y el censo hace bien en contarlo— pero
251# cambia el presupuesto de «no hay camino» a «faltan los alias», que es otra cosa y mucho más
252# barata. Sólo entra lo verificado ejecutando, no lo que upstream promete.
253CUBIERTOS = {
254    'unzip': ('libarchive', 'bsdunzip -o x.zip extrae y conserva el árbol — probado con un zip '
255                            'deflate con subdirectorio el 2026-10-19'),
256    'zip':   ('libarchive', 'bsdtar -a -cf x.zip … crea un zip que python zipfile lee y verifica '
257                            '— probado el 2026-10-19'),
258}
259
260# ── POR QUÉ ESTÁ CADA HUECO: receta ausente ≠ capacidad ausente ─────────────────────────────────
261# «Falta la receta» y «la distro no publica eso» son dos cosas con presupuestos distintos, y el
262# censo mentía por omisión al contarlas juntas. Sólo entran acá los muros MEDIDOS, con el dato que
263# los prueba; lo demás se queda sin anotar, que es más honesto que adivinar.
264MUROS = {
265    # ── lectores de PDF: la capacidad ESTÁ (zathura en 3 escritorios, okular en KDE) ─────────────
266    'mupdf': ('sin-visor-wayland',
267              'medido en su Makefile: sus únicos visores son mupdf-gl (GLUT/OpenGL) y '
268              'mupdf-x11/-curl. No hay visor Wayland, y este corpus no publica GL de escritorio. '
269              'Su CLI `mutool` SÍ funcionaría: lo que falta no es leer un PDF, es mostrarlo con mupdf'),
270    'evince': ('introspeccion-gnome',
271               'GNOME + introspección, igual que loupe y eog; librsvg sólo vive en incoming-gnome'),
272    # ── visores de imagen: los cinco famosos están tapados, y por DOS razones distintas ──────────
273    # Medido el 2026-10-20 al cerrar la fila con `swayimg`. Esto está acá para que nadie vuelva a
274    # intentar el «obvio»: el muro no es que falten deps, es la arquitectura de esta distro.
275    'imv': ('sin-opengl-de-escritorio',
276            'sus dependency() piden egl, gl, opengl, glu y wayland-egl, y este corpus no publica '
277            'libGL.so ni gl.pc (comprobado en el store). Mismo muro que kitty'),
278    'nsxiv': ('wayland-only',
279              'fork de sxiv, Xlib puro. La distro es Wayland-only (ver frente-gnome: X11 al tacho)'),
280    'feh': ('wayland-only', 'Xlib puro, igual que nsxiv'),
281    'loupe': ('introspeccion-gnome',
282              'GTK4 + librsvg + introspección; librsvg sólo existe en incoming-gnome y promover esa '
283              'raíz cuesta 35 recetas (medido promoviendo y revirtiendo)'),
284    'eog': ('introspeccion-gnome', 'igual que loupe, y además es la generación anterior de GNOME'),
285
286    # ✅ `fcitx5` ESTUVO ACÁ Y SE FUE: el muro se derribó el 2026-10-19/20. Decía
287    # `corpus-estatico-sin-pic` con 7365 errores de PIC medidos, y era cierto — hasta que el
288    # operador decidió publicar `libpng`/`freetype`/`fontconfig` con `--with-pic` (40 recetas de
289    # cascada, las 5 imágenes de escritorio). Con eso **fcitx5 SELLA**: daemon + 17 addons, mismo
290    # hash en el hub y en el worker. Se quita de `MUROS` en el mismo movimiento, porque un muro
291    # derribado que sigue anotado es exactamente la clase de pendiente inventado que este fichero
292    # existe para no producir — y hoy ya me pasó con el `.conf` de los emoji.
293    # ⇒ fcitx5 pasa a `tiene-sin-viajar`, que es la verdad: construido y en ningún perfil, porque
294    # el marco sin un motor de idioma daría una barra de IME que no convierte nada.
295    'kitty': ('sin-opengl-de-escritorio',
296              "setup.py:804 hace pkg_config('gl', '--libs') y data-types.h:25 exige OpenGL >= 3.x "
297              "core. Medido en el store: NADIE publica `gl.pc` ni `libGL.so` — mesa da egl.pc, "
298              "glesv2.pc, gbm.pc y dri.pc; libglvnd da opengl.pc con libOpenGL.so/libGLdispatch.so "
299              "y sólo viaja en escritorio-kde. Compilar no basta: gl.c:68 aborta en runtime con "
300              "«OpenGL version is X.Y, version >= 3.3 required for kitty». Mismo muro que OBS."),
301}
302
303
304# ── ¿LO INSTALA YA ALGÚN ARTEFACTO VIGENTE, CON ESE MISMO NOMBRE? ──────────────────────────────
305# Automatiza el hallazgo que destapó `libarchive`: un renglón puede faltar como RECETA y existir
306# como BINARIO dentro de otro paquete. Se mide contra los `bin`/`sbin`/`usr/bin`/`usr/sbin` de los
307# artefactos sellados, igual que `busybox-censo-proveedores.py`.
308#
309# ⚠ SÓLO ARTEFACTOS VIGENTES, y es un defecto medido de mi primera versión (2026-10-19): barriendo
310# `/store/*` entero, `unzip` y `hdparm` salían «ya los instala busybox» — y era el busybox
311# SUPERADO (`2a2b1280…`). El vigente (`4bd934f6…`) ya no los trae, porque la campaña de botar
312# busybox los recortó. **Un artefacto superado no dice nada de lo que hay en una imagen**, y
313# contarlo convierte el instrumento que caza huecos falsos en una fábrica de huecos falsos.
314# Es la misma lección que los «sellados rancios» del static-audit.
315#
316# ⚠ Y los rootfs ENSAMBLADOS se excluyen por nombre: traen el busybox del piso adentro, así que
317# todo applet se «proveería» a sí mismo. Mismo criterio que el censo de applets.
318ROOTFS = ('product-rootfs', 'product-attested-rootfs', 'stage1-rootfs')
319DIRS_BIN = ('bin', 'sbin', 'usr/bin', 'usr/sbin')
320
321
322def _cargar(nombre, ruta):
323    """Se importa por RUTA porque los ficheros llevan guion y no son módulos importables."""
324    spec = importlib.util.spec_from_file_location(nombre, ruta)
325    mod = importlib.util.module_from_spec(spec)
326    spec.loader.exec_module(mod)
327    return mod
328
329
330def indice_de_binarios(cp):
331    """{nombre de binario: {recetas VIGENTES que lo instalan}}."""
332    hashes, _ = cp.mapa_hashes()
333    vigentes = {}
334    for (cola, nombre), (h, estado) in hashes.items():
335        if not h or estado != 'sealed' or nombre in ROOTFS:
336            continue
337        vigentes.setdefault(nombre, (h or '').replace('b3:', ''))
338    idx = {}
339    for nombre, h in vigentes.items():
340        base = os.path.join('/store', '%s-%s' % (h, nombre))
341        for sub in DIRS_BIN:
342            d = os.path.join(base, sub)
343            if not os.path.isdir(d):
344                continue
345            try:
346                entradas = os.listdir(d)
347            except OSError:
348                continue
349            for e in entradas:
350                idx.setdefault(e, set()).add(nombre)
351    return idx
352
353
354def nombres_conocidos():
355    """Toda receta alcanzable: catálogo, colas `incoming-*` y `tandas/`."""
356    n = set()
357    for f in glob.glob(os.path.join(RAIZ, 'recipes/*.toml')):
358        n.add(os.path.basename(f)[:-5])
359    for f in glob.glob(os.path.join(RAIZ, 'recipes/incoming-*/*.toml')):
360        n.add(os.path.basename(f)[:-5])
361    for f in glob.glob(os.path.join(RAIZ, 'tandas/**/*.toml'), recursive=True):
362        n.add(os.path.basename(f)[:-5])
363    return n
364
365
366def clasificar(cand, conocidos, en_imagen):
367    """`tiene-y-viaja` · `tiene-sin-viajar` · `falta` (+ pistas de nombre parecido, que NO absuelven)."""
368    if cand in conocidos:
369        return ('tiene-y-viaja' if cand in en_imagen else 'tiene-sin-viajar'), []
370    raiz = cand.split('-')[0]
371    # Un pariente es: una receta cuyo nombre EMPIEZA por la raíz del candidato (`cups` → `cups`,
372    # `steam` → `steampipe`), o una receta que es PREFIJO o SUFIJO del candidato (`mutt` dentro de
373    # `neomutt`). Y no «contenida en cualquier posición», que es lo que hacía la primera versión:
374    # con eso `age` salía pariente de `virt-manager` y `patch` de `speech-dispatcher`. Un pariente
375    # falso no es inocuo —manda a revisar algo que no hay que revisar— aunque sea menos grave que
376    # un hueco falso. Raíz de 3+ caracteres: con 2 todo se parece a todo.
377    cerca = sorted(x for x in conocidos
378                   if (len(raiz) >= 3 and x.startswith(raiz))
379                   or (len(x) >= 3 and (cand.startswith(x) or cand.endswith(x))))
380    return 'falta', cerca[:4]
381
382
383def main():
384    ap = argparse.ArgumentParser()
385    ap.add_argument('--json')
386    a = ap.parse_args()
387
388    import yupana
389    cp = _cargar('cierre_perfiles', os.path.join(RAIZ, 'scripts', 'cierre-perfiles.py'))
390    conocidos = nombres_conocidos()
391    en_imagen = {n for (c, n) in yupana.membresia_imagen() if c != 'wanted'}
392    try:
393        binarios = indice_de_binarios(cp)
394    except Exception as e:          # sin store legible el censo sigue sirviendo; se dice y se sigue
395        print('   (no pude indexar los binarios del store: %s)' % e)
396        binarios = {}
397
398    filas, resumen = [], {'tiene-y-viaja': 0, 'tiene-sin-viajar': 0, 'falta': 0}
399    for cat, (porque, cands) in CHECKLIST.items():
400        items = []
401        for c in cands:
402            estado, cerca = clasificar(c, conocidos, en_imagen)
403            resumen[estado] += 1
404            items.append({'nombre': c, 'estado': estado, 'parientes': cerca})
405        filas.append({'categoria': cat, 'porque': porque, 'items': items,
406                      'faltan': [i['nombre'] for i in items if i['estado'] == 'falta']})
407        for i in items:
408            if i['estado'] == 'falta' and i['parientes']:
409                i['pista'] = 'nombre parecido en el catálogo (verificar, NO es equivalencia)'
410            if i['nombre'] in MUROS:
411                i['muro'], i['muro_porque'] = MUROS[i['nombre']]
412            if i['nombre'] in CUBIERTOS:
413                i['cubierto_por'], i['cubierto_porque'] = CUBIERTOS[i['nombre']]
414            elif i['estado'] == 'falta' and i['nombre'] in binarios:
415                quien = sorted(binarios[i['nombre']])
416                i['cubierto_por'] = ', '.join(quien)
417                i['cubierto_porque'] = ('MEDIDO: un artefacto vigente instala un binario con ese '
418                                        'nombre exacto. Falta la receta propia, no la capacidad')
419
420    print('== qué le falta a la distro para usarse (checklist de JUICIO, estado MEDIDO) ==')
421    print('   recetas alcanzables: %d · de ellas en alguna imagen: %d'
422          % (len(conocidos), len(en_imagen)))
423    for f in sorted(filas, key=lambda x: -len(x['faltan'])):
424        marca = '✗' if f['faltan'] else '·'
425        print('   %s %-28s %d/%d falta%s' % (marca, f['categoria'], len(f['faltan']),
426                                             len(f['items']),
427                                             ': ' + ', '.join(f['faltan']) if f['faltan'] else 'n'))
428        # ⚠ **EL `porque` SE IMPRIME, Y ESTE ARREGLO SALIÓ DE CAER EN LA TRAMPA.**
429        # Cada fila guarda desde siempre un campo que dice qué hay YA cubierto o por qué importa
430        # —`'lectores de PDF': ('zathura y okular están', [...])`— y **no se mostraba nunca**. La
431        # salida decía `✗ lectores de PDF 3/3 falta: mupdf, sioyek, evince`, que se lee como «no hay
432        # lector de PDF»… y el 2026-10-20 me puse a buscar candidatos para construir uno, hasta que
433        # al mirar el fichero vi que zathura ya estaba sellada y viajando en tres escritorios.
434        #
435        # O sea: el dato que evitaba una campaña inútil estaba en el censo y no llegaba al ojo.
436        # Una métrica que guarda el matiz y muestra sólo el número rojo invita exactamente al
437        # trabajo que debía evitar.
438        if f['faltan'] and f['porque']:
439            print('        ↳ ya cubierto / contexto: %s' % f['porque'])
440            # ⚠ GUARDIÁN DEL PROPIO CENSO (2026-10-21) — la cuenta se recalcula, la prosa NO.
441            #
442            # En UN SOLO DÍA tres filas quedaron contradiciéndose consigo mismas: «escritura del
443            # mundo» decía que las únicas fuentes eran dejavu con las tres Noto ya selladas;
444            # «escribir en esos idiomas» decía «faltan los otros idiomas» con anthy y
445            # chinese-addons dentro; y «contraseñas» decía «nada: la gente guarda sus claves en un
446            # .txt» el mismo día que `pass` entró en cinco imágenes.
447            #
448            # Las tres las escribí yo y las tres las invalidé yo. Tres en un día no es descuido:
449            # es que el número sale del grafo y la explicación de la cabeza de alguien, y sólo uno
450            # de los dos se actualiza. Y el daño es el que este censo existe para evitar — un
451            # «nada» falso manda a construir algo que ya está.
452            #
453            # Heurística deliberadamente tonta y por eso útil: si la prosa AFIRMA ausencia y la
454            # cuenta dice que hay piezas presentes, se avisa. No juzga el contenido; sólo detecta
455            # la contradicción entre las dos mitades de la misma fila.
456            #
457            # ⚠ **ALCANCE, dicho a propósito: caza la familia «nada / no hay / ninguno», no todas
458            # las formas de afirmar ausencia.** Se le escapó la fila «páginas de manual», cuya
459            # prosa decía «falta el CONTENIDO» el día que entraron 3.153 páginas. Añadir `falta`
460            # a la lista NO es la solución: 14 de las 40 filas lo dicen legítimamente —una fila
461            # PUEDE decir correctamente «lo que falta es X»— y un guardián ruidoso termina
462            # ignorado, que es la forma de morir de los guardianes ([[guardianes-el-ruido-los-mata]]).
463            #
464            # El arreglo de verdad sería que cada fila guardara la cuenta contra la que se
465            # escribió su prosa (`visto=N`) y avisar cuando presentes > N: sin palabras clave y
466            # completo. No se hace acá porque un mecanismo así sólo vale si están las 40 filas
467            # pobladas, y a medias da confianza falsa. Queda anotado como la siguiente vuelta si
468            # esto vuelve a pasar.
469            niega = ('nada:', 'nada que', 'ninguno', 'ninguna cola', 'ningún cliente',
470                     'ningún camino', 'cero ', 'no hay ', 'las únicas', 'es lo único',
471                     'faltan los otros')
472            # ⚠ Y distingue VIAJA de SÓLO CONSTRUIDO, porque no es el mismo aviso: si la
473            # pieza está sellada y en ninguna imagen, la prosa que dice «no hay» sigue siendo
474            # VERDAD para quien usa la distro — el hueco es real y se cierra con una declaración,
475            # no con un build. Meter las dos cosas en el mismo saco fue mi primera versión, y
476            # habría empujado a marcar `gdb` como cubierto cuando no viaja a ningún sitio.
477            viajan = [i['nombre'] for i in f['items'] if i['estado'] == 'tiene-y-viaja']
478            solo   = [i['nombre'] for i in f['items'] if i['estado'] == 'tiene-sin-viajar']
479            bajo = f['porque'].lower()
480            if any(w in bajo for w in niega) and '✅' not in f['porque']:
481                if viajan:
482                    print('        ⚠ PROSA SOSPECHOSA: el texto afirma ausencia y hay %d que SÍ'
483                          ' viaja(n) en una imagen (%s). Revisá la explicación antes de creerle.'
484                          % (len(viajan), ', '.join(viajan[:4])))
485                elif solo:
486                    print('        ↳ matiz: %s está(n) SELLADA(S) pero en ninguna imagen — el hueco'
487                          ' es real para quien usa la distro, y se cierra DECLARANDO, no'
488                          ' construyendo.' % ', '.join(solo[:4]))
489    rev = [(i['nombre'], i['parientes']) for f in filas for i in f['items']
490           if i['estado'] == 'falta' and i['parientes']]
491    if rev:
492        print('   — PISTAS: falta, pero hay un nombre parecido (verificar; NO es equivalencia):')
493        for n, p in rev:
494            print('        %-20s ~ %s' % (n, ', '.join(p)))
495    cub = [(i['nombre'], i['cubierto_por']) for f in filas for i in f['items'] if i.get('cubierto_por')]
496    if cub:
497        print('   — CAPACIDAD YA CUBIERTA con otro nombre (falta el alias, no el camino):')
498        for n, q in cub:
499            print('        %-20s lo hace %s' % (n, q))
500    con_muro = [(i['nombre'], i['muro']) for f in filas for i in f['items'] if i.get('muro')]
501    if con_muro:
502        print('   — MUROS MEDIDOS (no es que falte la receta: la distro no publica eso):')
503        for n, m in con_muro:
504            print('        %-20s %s' % (n, m))
505    print('   resumen: %s' % json.dumps(resumen, ensure_ascii=False))
506
507    if a.json:
508        with open(a.json, 'w', encoding='utf-8') as fh:
509            json.dump({'schema': 'takana-censo-distro/1',
510                       'recetas_alcanzables': len(conocidos),
511                       'en_alguna_imagen': len(en_imagen),
512                       'resumen': resumen, 'categorias': filas},
513                      fh, ensure_ascii=False, indent=1, sort_keys=True)
514            fh.write('\n')
515        print('   → %s' % a.json)
516    return 0
517
518
519if __name__ == '__main__':
520    sys.exit(main())