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…
feat(distro): el censo completo — 152 huecos, y kitty era uno de ellos
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 json
40import os
41import sys
42
43RAIZ = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
44sys.path.insert(0, os.path.join(RAIZ, 'scripts'))
45
46# Cada entrada: categoría → (por_qué_importa, [nombres candidatos]).
47# El «por qué» no es adorno: un renglón sin motivo se discute para siempre.
48CHECKLIST = {
49 # ── LO QUE DEJA A GENTE AFUERA ──────────────────────────────────────────────────────────────
50 'escritura del mundo': (
51 'las únicas fuentes del corpus son dejavu y su nerd: sin estas, la distro muestra '
52 'cuadraditos en chino, japonés, coreano, árabe, hindi, tailandés y emoji',
53 ['noto-fonts-cjk', 'noto-fonts-emoji', 'noto-fonts', 'noto-fonts-extra']),
54 'escribir en esos idiomas': (
55 'una fuente deja LEER; para ESCRIBIR hace falta un método de entrada',
56 ['fcitx5', 'ibus', 'anthy', 'libpinyin', 'hangul', 'rime']),
57 'accesibilidad': (
58 'sin lector de pantalla ni voz la distro es inusable para quien no ve',
59 ['orca', 'speech-dispatcher', 'espeak-ng', 'brltty']),
60
61 # ── LO QUE ALGUIEN BUSCA EL PRIMER DÍA ──────────────────────────────────────────────────────
62 'terminales populares': (
63 '`foot` está y alcanza; pero quien llega de otra distro busca el suyo por nombre, y el '
64 'doc 20 §II.2 ya los listaba como «barato». kitty: 13 de sus 14 deps ya están en el '
65 'catálogo (falta librsync, opcional)',
66 ['kitty', 'alacritty', 'wezterm', 'ghostty', 'xterm']),
67 'editores': (
68 '`vim` y `helix` están. ⚠ Existe `bob-nvim` —el GESTOR DE VERSIONES de neovim— y no '
69 'existe neovim: el conductor sin el motor, como `crun` sin podman',
70 ['neovim', 'emacs', 'micro', 'kakoune', 'nano']),
71 'shells': ('bash, zsh y nushell están', ['fish', 'elvish']),
72 'lanzador, notificaciones y bloqueo': (
73 'el esqueleto de un escritorio Wayland que no sea GNOME/KDE: sin lanzador no se abre nada',
74 ['rofi', 'wofi', 'fuzzel', 'bemenu', 'tofi', 'mako', 'dunst', 'swaync', 'swaylock',
75 'gtklock', 'waylock']),
76 'audio de escritorio': (
77 'PipeWire suena y no hay con qué subirle el volumen desde una ventana',
78 ['pavucontrol', 'pamixer', 'playerctl', 'easyeffects', 'wiremix']),
79 'capturas y portapapeles': ('grim, slurp, wl-clipboard y cliphist ya están',
80 ['satty', 'swappy', 'flameshot', 'wtype', 'kanshi']),
81 'visores de imagen': ('ninguno: una foto no se abre',
82 ['imv', 'nsxiv', 'feh', 'loupe', 'eog']),
83
84 # ── CAPACIDADES ENTERAS SIN NINGÚN CAMINO ───────────────────────────────────────────────────
85 'archivos comprimidos ajenos': (
86 'no se puede abrir un .zip, un .7z ni un .rar — un adjunto cualquiera queda cerrado. '
87 '`libzip` es una BIBLIOTECA, no el comando',
88 ['unzip', 'zip', 'p7zip', '7zip', 'unrar']),
89 'imprimir de verdad': ('cups solo no imprime: los filtros y los drivers son otro paquete',
90 ['cups', 'cups-filters', 'ghostscript', 'gutenprint', 'hplip']),
91 'escanear': ('ningún camino hoy', ['sane-backends', 'simple-scan']),
92 'documentos técnicos': ('nada que componga un PDF desde texto marcado',
93 ['texlive', 'pandoc', 'typst', 'groff']),
94 'páginas de manual': ('`mandoc` LEE man; falta el CONTENIDO y el formateador de info',
95 ['man-pages', 'texinfo']),
96 'virtualización': ('ni invitado ni anfitrión: no se puede probar la propia ISO sin salir',
97 ['qemu', 'libvirt', 'virt-manager']),
98 'contenedores': ('`crun` está (el motor); falta quien lo conduzca',
99 ['podman', 'docker', 'buildah', 'flatpak', 'distrobox']),
100 'java': ('un ecosistema entero queda fuera sin una JVM', ['openjdk', 'jdk', 'temurin']),
101 'bases de datos de servidor': ('sqlite está; un servicio con varios clientes no',
102 ['postgresql', 'mariadb', 'redis', 'valkey']),
103 'depurar y medir': ('se construye la distro entera y no hay con qué mirar un core',
104 ['gdb', 'lldb', 'valgrind', 'ltrace', 'perf', 'patchelf']),
105 'discos y salud del equipo': ('nada que avise de un disco muriéndose',
106 ['smartmontools', 'nvme-cli', 'hdparm', 'gparted']),
107
108 # ── LENGUAJES Y HERRAMIENTAS DE CONSTRUCCIÓN ────────────────────────────────────────────────
109 'otros lenguajes': (
110 'python3, lua, go, rust y perl están. ⚠ `zig` NO es receta: vive como artefacto '
111 '`seed-zig` fuera del catálogo, y es el compilador de TODO el corpus',
112 ['nodejs', 'deno', 'bun', 'ruby', 'php', 'julia', 'ghc', 'ocaml', 'elixir', 'erlang']),
113 'empaquetadores de python': ('pip no viaja y no hay forma de instalar una herramienta python',
114 ['pipx', 'uv', 'poetry']),
115 'construcción C clásica': (
116 'cmake, meson y make existen (sin viajar). ⚠ `gcc` está RETENIDO a propósito (sólo '
117 '`gcc-libs`) y `clang` existe como `clang18`: eso NO es hueco, es decisión',
118 ['autoconf', 'automake', 'ninja']),
119
120 # ── RED, SEGURIDAD, DATOS ───────────────────────────────────────────────────────────────────
121 'vpn y red gestionada': ('networkmanager, wireguard-tools y wpa_supplicant están',
122 ['openvpn', 'iwd', 'nmtui', 'impala', 'mosh', 'sshfs']),
123 'bluetooth usable': ('`bluez` viaja; falta con qué emparejar un auricular',
124 ['blueman', 'bluetuith']),
125 'contraseñas': ('nada: la gente guarda sus claves en un .txt o no usa la distro',
126 ['keepassxc', 'pass', 'gopass']),
127 'respaldos': ('restic y rclone están', ['borgbackup', 'rsnapshot']),
128 'descargas y torrent': ('curl y wget están', ['aria2', 'transmission', 'qbittorrent']),
129
130 # ── APLICACIONES DE ESCRITORIO ──────────────────────────────────────────────────────────────
131 'navegadores': ('firefox y atuq están; el motor Blink no tiene ningún camino',
132 ['chromium', 'qutebrowser', 'librewolf', 'brave']),
133 'mensajería': ('ningún cliente de chat: ni XMPP, ni Matrix, ni los propietarios',
134 ['dino', 'gajim', 'element-desktop', 'telegram-desktop', 'signal-desktop']),
135 'música': ('mpv y cmus están para reproducir un fichero; falta biblioteca y daemon',
136 ['mpd', 'ncmpcpp', 'rhythmbox', 'audacious']),
137 'video': ('mpv está', ['vlc', 'celluloid', 'totem']),
138 'imagen y diseño': (
139 '⚠ la suite ArtCraft propia cubre esto con 12 apps (photocraft, vectorcraft, …): estos '
140 'renglones son los nombres AJENOS que alguien busca, no un hueco de capacidad',
141 ['gimp', 'inkscape', 'krita', 'darktable']),
142 'gestores de archivos': ('yazi, nnn, lf, broot y dolphin/nautilus están',
143 ['thunar', 'ranger']),
144 'lectores de PDF': ('zathura y okular están', ['mupdf', 'sioyek', 'evince']),
145 'notas': ('ninguno', ['obsidian', 'logseq', 'joplin', 'zettlr']),
146
147 # ── DECISIONES ABIERTAS, NO OLVIDOS ─────────────────────────────────────────────────────────
148 'otros compositores Wayland': (
149 'hay cinco escritorios (mirada, sway, cosmic, gnome, kde); éstos son los que la gente '
150 'nombra y no están. Decisión de alcance, no olvido',
151 ['hyprland', 'river', 'niri', 'labwc', 'wayfire']),
152 'juegos': ('decisión abierta: arrastra Vulkan, que la distro hoy no publica',
153 ['wine', 'steam', 'gamescope', 'lutris', 'mangohud']),
154 'infra ajena': ('decisión abierta: son herramientas de operar OTRA cosa',
155 ['kubectl', 'helm', 'terraform', 'ansible']),
156}
157
158
159def nombres_conocidos():
160 """Toda receta alcanzable: catálogo, colas `incoming-*` y `tandas/`."""
161 n = set()
162 for f in glob.glob(os.path.join(RAIZ, 'recipes/*.toml')):
163 n.add(os.path.basename(f)[:-5])
164 for f in glob.glob(os.path.join(RAIZ, 'recipes/incoming-*/*.toml')):
165 n.add(os.path.basename(f)[:-5])
166 for f in glob.glob(os.path.join(RAIZ, 'tandas/**/*.toml'), recursive=True):
167 n.add(os.path.basename(f)[:-5])
168 return n
169
170
171def clasificar(cand, conocidos, en_imagen):
172 """`tiene-y-viaja` · `tiene-sin-viajar` · `falta` (+ pistas de nombre parecido, que NO absuelven)."""
173 if cand in conocidos:
174 return ('tiene-y-viaja' if cand in en_imagen else 'tiene-sin-viajar'), []
175 raiz = cand.split('-')[0]
176 # Un pariente es: una receta cuyo nombre EMPIEZA por la raíz del candidato (`cups` → `cups`,
177 # `steam` → `steampipe`), o una receta que es PREFIJO o SUFIJO del candidato (`mutt` dentro de
178 # `neomutt`). Y no «contenida en cualquier posición», que es lo que hacía la primera versión:
179 # con eso `age` salía pariente de `virt-manager` y `patch` de `speech-dispatcher`. Un pariente
180 # falso no es inocuo —manda a revisar algo que no hay que revisar— aunque sea menos grave que
181 # un hueco falso. Raíz de 3+ caracteres: con 2 todo se parece a todo.
182 cerca = sorted(x for x in conocidos
183 if (len(raiz) >= 3 and x.startswith(raiz))
184 or (len(x) >= 3 and (cand.startswith(x) or cand.endswith(x))))
185 return 'falta', cerca[:4]
186
187
188def main():
189 ap = argparse.ArgumentParser()
190 ap.add_argument('--json')
191 a = ap.parse_args()
192
193 import yupana
194 conocidos = nombres_conocidos()
195 en_imagen = {n for (c, n) in yupana.membresia_imagen() if c != 'wanted'}
196
197 filas, resumen = [], {'tiene-y-viaja': 0, 'tiene-sin-viajar': 0, 'falta': 0}
198 for cat, (porque, cands) in CHECKLIST.items():
199 items = []
200 for c in cands:
201 estado, cerca = clasificar(c, conocidos, en_imagen)
202 resumen[estado] += 1
203 items.append({'nombre': c, 'estado': estado, 'parientes': cerca})
204 filas.append({'categoria': cat, 'porque': porque, 'items': items,
205 'faltan': [i['nombre'] for i in items if i['estado'] == 'falta']})
206 for i in items:
207 if i['estado'] == 'falta' and i['parientes']:
208 i['pista'] = 'nombre parecido en el catálogo (verificar, NO es equivalencia)'
209
210 print('== qué le falta a la distro para usarse (checklist de JUICIO, estado MEDIDO) ==')
211 print(' recetas alcanzables: %d · de ellas en alguna imagen: %d'
212 % (len(conocidos), len(en_imagen)))
213 for f in sorted(filas, key=lambda x: -len(x['faltan'])):
214 marca = '✗' if f['faltan'] else '·'
215 print(' %s %-28s %d/%d falta%s' % (marca, f['categoria'], len(f['faltan']),
216 len(f['items']),
217 ': ' + ', '.join(f['faltan']) if f['faltan'] else 'n'))
218 rev = [(i['nombre'], i['parientes']) for f in filas for i in f['items']
219 if i['estado'] == 'falta' and i['parientes']]
220 if rev:
221 print(' — PISTAS: falta, pero hay un nombre parecido (verificar; NO es equivalencia):')
222 for n, p in rev:
223 print(' %-20s ~ %s' % (n, ', '.join(p)))
224 print(' resumen: %s' % json.dumps(resumen, ensure_ascii=False))
225
226 if a.json:
227 with open(a.json, 'w', encoding='utf-8') as fh:
228 json.dump({'schema': 'takana-censo-distro/1',
229 'recetas_alcanzables': len(conocidos),
230 'en_alguna_imagen': len(en_imagen),
231 'resumen': resumen, 'categorias': filas},
232 fh, ensure_ascii=False, indent=1, sort_keys=True)
233 fh.write('\n')
234 print(' → %s' % a.json)
235 return 0
236
237
238if __name__ == '__main__':
239 sys.exit(main())