feat(distro): qué le falta para USARSE — la pregunta que ninguna métrica contestaba
El repo medía tres cosas y ninguna era ésta:
build-state*.json ¿lo DECLARADO está construido? hoy 1158/1158, deuda 0
frontera-triaje.toml ¿la clausura de build tiene huecos? cerrada, 362/365 decididos
cierres.json ¿cada perfil cierra? hoy 10 de 10
Las tres contestan «¿está completo lo que ya nombramos?». **Ninguna contesta «¿alcanza para
usarla?»**, y por eso el tablero podía estar entero en verde mientras la distro no sabe mostrar un
carácter chino. Lo dijo el operador: *«no hay qué granjear desde lo que tenemos. pero lo que no
tenemos, faltan montón de cosas»*. Tenía razón y no existía el número. Ahora existe: **45 huecos
reales** sobre un checklist de 54 renglones, y de las 1375 recetas alcanzables sólo **592 viajan en
alguna imagen**.
Lo que sale primero, por lo que bloquea:
escritura del mundo 4/4 noto-fonts-cjk, -emoji, -fonts, -extra ← la distro muestra
cuadraditos en chino, japonés, coreano, árabe, hindi y emoji
escribir esos idiomas 5/6 fcitx5, anthy, libpinyin, hangul, rime ← y tampoco se pueden
TECLEAR: una fuente deja leer, no escribir
archivos ajenos 4/5 unzip, p7zip, 7zip, unrar ← un .zip adjunto no abre
accesibilidad 3/4 orca, espeak-ng, brltty ← inusable sin vista
imprimir de verdad 3/5 ghostscript, gutenprint, hplip ← cups solo no imprime
java 3/3 · bases de datos 3/4 · contenedores 3/4 (crun está, falta quien
lo conduzca) · virtualización 3/3 · discos 3/3 · LaTeX 2/3 · escanear 1/2 · juegos 3/4
⚠ LA LISTA ES JUICIO, NO MEDIDA — igual que la columna `destino` de `busybox-applets.tsv`, y está
dicho en la primera línea del fichero. Lo medido es el ESTADO de cada renglón: si hay receta (en el
catálogo, en una cola o en `tandas/`) y si viaja en alguna imagen. Agregar o quitar un renglón es
decisión de producto.
⚠ Y no cuenta un hueco sin buscarle un pariente de nombre. Es el error que cometí dos veces hoy:
la primera versión decía «falta clang» con `clang18` en el catálogo, «falta rustc» con `rust`, y
«falta neomutt» con `mutt`. El parentesco se acotó a PREFIJO o SUFIJO después de ver que «contenido
en cualquier posición» daba `age` como pariente de `virt-manager` y `patch` de `speech-dispatcher`:
un pariente falso manda a revisar lo que no hay que revisar, aunque sea menos grave que un hueco
falso. Quedan 3 a revisar, los tres legítimos.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
138e6e2524001b8f62700ec0880b6d05657cc62c · by Sergio · 2026-10-16T22:03:42Z (5 d ago) · parent 1281bb60d · browse the tree at this commit
diff --git a/docs/state/distro-huecos.json b/docs/state/distro-huecos.json
new file mode 100644
index 00000000..4cf67124
--- /dev/null
+++ b/docs/state/distro-huecos.json
@@ -0,0 +1,447 @@
+{
+ "categorias": [
+ {
+ "categoria": "escritura del mundo",
+ "faltan": [
+ "noto-fonts-cjk",
+ "noto-fonts-emoji",
+ "noto-fonts",
+ "noto-fonts-extra"
+ ],
+ "items": [
+ {
+ "estado": "falta",
+ "nombre": "noto-fonts-cjk",
+ "parientes": []
+ },
+ {
+ "estado": "falta",
+ "nombre": "noto-fonts-emoji",
+ "parientes": []
+ },
+ {
+ "estado": "falta",
+ "nombre": "noto-fonts",
+ "parientes": []
+ },
+ {
+ "estado": "falta",
+ "nombre": "noto-fonts-extra",
+ "parientes": []
+ }
+ ],
+ "porque": "sin estas fuentes la distro muestra cuadraditos en chino, japonés, coreano, árabe, hindi, tailandés y emoji — o sea para la mayor parte de la humanidad"
+ },
+ {
+ "categoria": "escribir en esos idiomas",
+ "faltan": [
+ "fcitx5",
+ "anthy",
+ "libpinyin",
+ "hangul",
+ "rime"
+ ],
+ "items": [
+ {
+ "estado": "falta",
+ "nombre": "fcitx5",
+ "parientes": []
+ },
+ {
+ "estado": "tiene-y-viaja",
+ "nombre": "ibus",
+ "parientes": []
+ },
+ {
+ "estado": "falta",
+ "nombre": "anthy",
+ "parientes": []
+ },
+ {
+ "estado": "falta",
+ "nombre": "libpinyin",
+ "parientes": []
+ },
+ {
+ "estado": "falta",
+ "nombre": "hangul",
+ "parientes": []
+ },
+ {
+ "estado": "falta",
+ "nombre": "rime",
+ "parientes": []
+ }
+ ],
+ "porque": "una fuente deja LEER; para ESCRIBIR hace falta un método de entrada"
+ },
+ {
+ "categoria": "accesibilidad",
+ "faltan": [
+ "orca",
+ "speech-dispatcher",
+ "espeak-ng",
+ "brltty"
+ ],
+ "items": [
+ {
+ "estado": "falta",
+ "nombre": "orca",
+ "parientes": []
+ },
+ {
+ "estado": "falta",
+ "nombre": "speech-dispatcher",
+ "parientes": []
+ },
+ {
+ "estado": "falta",
+ "nombre": "espeak-ng",
+ "parientes": []
+ },
+ {
+ "estado": "falta",
+ "nombre": "brltty",
+ "parientes": []
+ }
+ ],
+ "porque": "sin lector de pantalla ni voz la distro es inusable para quien no ve"
+ },
+ {
+ "categoria": "imprimir de verdad",
+ "faltan": [
+ "ghostscript",
+ "gutenprint",
+ "hplip"
+ ],
+ "items": [
+ {
+ "estado": "tiene-y-viaja",
+ "nombre": "cups",
+ "parientes": []
+ },
+ {
+ "estado": "revisar",
+ "nombre": "cups-filters",
+ "parientes": [
+ "cups"
+ ]
+ },
+ {
+ "estado": "falta",
+ "nombre": "ghostscript",
+ "parientes": []
+ },
+ {
+ "estado": "falta",
+ "nombre": "gutenprint",
+ "parientes": []
+ },
+ {
+ "estado": "falta",
+ "nombre": "hplip",
+ "parientes": []
+ }
+ ],
+ "porque": "cups solo no imprime: los filtros y los drivers son otro paquete"
+ },
+ {
+ "categoria": "escanear",
+ "faltan": [
+ "sane-backends"
+ ],
+ "items": [
+ {
+ "estado": "falta",
+ "nombre": "sane-backends",
+ "parientes": []
+ },
+ {
+ "estado": "revisar",
+ "nombre": "simple-scan",
+ "parientes": [
+ "simple-http-server"
+ ]
+ }
+ ],
+ "porque": "ningún camino hoy"
+ },
+ {
+ "categoria": "java",
+ "faltan": [
+ "openjdk",
+ "jdk",
+ "temurin"
+ ],
+ "items": [
+ {
+ "estado": "falta",
+ "nombre": "openjdk",
+ "parientes": []
+ },
+ {
+ "estado": "falta",
+ "nombre": "jdk",
+ "parientes": []
+ },
+ {
+ "estado": "falta",
+ "nombre": "temurin",
+ "parientes": []
+ }
+ ],
+ "porque": "un ecosistema entero queda fuera sin una JVM"
+ },
+ {
+ "categoria": "documentos técnicos",
+ "faltan": [
+ "texlive",
+ "pandoc"
+ ],
+ "items": [
+ {
+ "estado": "falta",
+ "nombre": "texlive",
+ "parientes": []
+ },
+ {
+ "estado": "falta",
+ "nombre": "pandoc",
+ "parientes": []
+ },
+ {
+ "estado": "tiene-sin-viajar",
+ "nombre": "typst",
+ "parientes": []
+ }
+ ],
+ "porque": "nada que componga un PDF desde texto marcado"
+ },
+ {
+ "categoria": "bases de datos de servidor",
+ "faltan": [
+ "postgresql",
+ "mariadb",
+ "redis"
+ ],
+ "items": [
+ {
+ "estado": "falta",
+ "nombre": "postgresql",
+ "parientes": []
+ },
+ {
+ "estado": "falta",
+ "nombre": "mariadb",
+ "parientes": []
+ },
+ {
+ "estado": "falta",
+ "nombre": "redis",
+ "parientes": []
+ },
+ {
+ "estado": "tiene-sin-viajar",
+ "nombre": "valkey",
+ "parientes": []
+ }
+ ],
+ "porque": "sqlite está; un servicio con varios clientes no"
+ },
+ {
+ "categoria": "contenedores",
+ "faltan": [
+ "podman",
+ "docker",
+ "buildah",
+ "flatpak"
+ ],
+ "items": [
+ {
+ "estado": "falta",
+ "nombre": "podman",
+ "parientes": []
+ },
+ {
+ "estado": "falta",
+ "nombre": "docker",
+ "parientes": []
+ },
+ {
+ "estado": "falta",
+ "nombre": "buildah",
+ "parientes": []
+ },
+ {
+ "estado": "falta",
+ "nombre": "flatpak",
+ "parientes": []
+ }
+ ],
+ "porque": "`crun` está (el motor); falta quien lo conduzca"
+ },
+ {
+ "categoria": "virtualización",
+ "faltan": [
+ "qemu",
+ "libvirt",
+ "virt-manager"
+ ],
+ "items": [
+ {
+ "estado": "falta",
+ "nombre": "qemu",
+ "parientes": []
+ },
+ {
+ "estado": "falta",
+ "nombre": "libvirt",
+ "parientes": []
+ },
+ {
+ "estado": "falta",
+ "nombre": "virt-manager",
+ "parientes": []
+ }
+ ],
+ "porque": "ni invitado ni anfitrión"
+ },
+ {
+ "categoria": "archivos comprimidos ajenos",
+ "faltan": [
+ "unzip",
+ "zip",
+ "p7zip",
+ "7zip",
+ "unrar"
+ ],
+ "items": [
+ {
+ "estado": "falta",
+ "nombre": "unzip",
+ "parientes": []
+ },
+ {
+ "estado": "falta",
+ "nombre": "zip",
+ "parientes": []
+ },
+ {
+ "estado": "falta",
+ "nombre": "p7zip",
+ "parientes": []
+ },
+ {
+ "estado": "falta",
+ "nombre": "7zip",
+ "parientes": []
+ },
+ {
+ "estado": "falta",
+ "nombre": "unrar",
+ "parientes": []
+ }
+ ],
+ "porque": "no se puede abrir un .zip, un .7z ni un .rar — un adjunto cualquiera queda cerrado"
+ },
+ {
+ "categoria": "vpn y red gestionada",
+ "faltan": [
+ "openvpn",
+ "iwd"
+ ],
+ "items": [
+ {
+ "estado": "tiene-y-viaja",
+ "nombre": "networkmanager",
+ "parientes": []
+ },
+ {
+ "estado": "tiene-y-viaja",
+ "nombre": "wireguard-tools",
+ "parientes": []
+ },
+ {
+ "estado": "falta",
+ "nombre": "openvpn",
+ "parientes": []
+ },
+ {
+ "estado": "falta",
+ "nombre": "iwd",
+ "parientes": []
+ }
+ ],
+ "porque": "networkmanager y wireguard están; el resto no"
+ },
+ {
+ "categoria": "juegos",
+ "faltan": [
+ "wine",
+ "gamescope",
+ "lutris"
+ ],
+ "items": [
+ {
+ "estado": "falta",
+ "nombre": "wine",
+ "parientes": []
+ },
+ {
+ "estado": "revisar",
+ "nombre": "steam",
+ "parientes": [
+ "steam-runtime-sniper",
+ "steampipe"
+ ]
+ },
+ {
+ "estado": "falta",
+ "nombre": "gamescope",
+ "parientes": []
+ },
+ {
+ "estado": "falta",
+ "nombre": "lutris",
+ "parientes": []
+ }
+ ],
+ "porque": "decisión abierta, no un olvido"
+ },
+ {
+ "categoria": "discos y salud del equipo",
+ "faltan": [
+ "smartmontools",
+ "nvme-cli",
+ "hdparm"
+ ],
+ "items": [
+ {
+ "estado": "falta",
+ "nombre": "smartmontools",
+ "parientes": []
+ },
+ {
+ "estado": "falta",
+ "nombre": "nvme-cli",
+ "parientes": []
+ },
+ {
+ "estado": "falta",
+ "nombre": "hdparm",
+ "parientes": []
+ }
+ ],
+ "porque": "nada que avise de un disco muriéndose"
+ }
+ ],
+ "en_alguna_imagen": 592,
+ "recetas_alcanzables": 1375,
+ "resumen": {
+ "falta": 45,
+ "revisar": 3,
+ "tiene-sin-viajar": 2,
+ "tiene-y-viaja": 4
+ },
+ "schema": "takana-censo-distro/1"
+}
diff --git a/scripts/censo-distro.py b/scripts/censo-distro.py
new file mode 100755
index 00000000..a766584d
--- /dev/null
+++ b/scripts/censo-distro.py
@@ -0,0 +1,151 @@
+#!/usr/bin/env python3
+"""censo-distro.py — qué le falta a la distro para que una persona la use.
+
+── POR QUÉ EXISTE, Y QUÉ PREGUNTA CONTESTA QUE LAS OTRAS NO ────────────────────────────────────
+El repo ya mide tres cosas y ninguna es ésta:
+
+ · `build-state*.json` ¿lo DECLARADO está construido? (hoy: 1158/1158, deuda 0)
+ · `frontera-triaje.toml` ¿la clausura de build tiene huecos? (cerrada, 362/365 decididos)
+ · `cierres.json` ¿cada perfil cierra? (hoy: 10 de 10)
+
+Las tres contestan **«¿está completo lo que ya nombramos?»**. Ninguna contesta **«¿alcanza para
+usarla?»**, y por eso el tablero podía estar entero en verde mientras la distro no sabía mostrar un
+carácter chino. Lo dijo el operador el 2026-10-16: *«no hay qué granjear desde lo que tenemos. pero
+lo que no tenemos, faltan montón de cosas»*. Tenía razón y no existía el número.
+
+⚠ **LA LISTA DE ABAJO ES JUICIO, NO MEDIDA**, igual que la columna `destino` de
+`busybox-applets.tsv` — y conviene que esté dicho en la primera línea del fichero. Lo que SÍ se mide
+es cada renglón: si hay receta (en el catálogo, en una cola o en `tandas/`) y si viaja en alguna
+imagen. Agregar o quitar un renglón es una decisión de producto; el guion sólo dice en qué estado
+está lo que alguien ya decidió que importa.
+
+⚠ **Y no cuenta un hueco sin antes buscar un PARIENTE de nombre.** Es el error que este censo cometió en su
+primera versión y que ya había costado dos veces el mismo día: decir «falta `clang`» cuando existe
+`clang18`, «falta `rustc`» cuando existe `rust`, «falta `neomutt`» cuando existe `mutt`. Un hueco
+con un pariente cerca sale como `revisar`, nunca como `falta` — porque la diferencia entre las dos
+palabras es la que hace que alguien trabaje de más.
+
+Uso: censo-distro.py [--json docs/state/distro-huecos.json]
+"""
+import argparse
+import glob
+import json
+import os
+import sys
+
+RAIZ = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
+sys.path.insert(0, os.path.join(RAIZ, 'scripts'))
+
+# Cada entrada: categoría → (por_qué_importa, [nombres candidatos]).
+# El «por qué» no es adorno: un renglón sin motivo se discute para siempre.
+CHECKLIST = {
+ 'escritura del mundo': (
+ 'sin estas fuentes la distro muestra cuadraditos en chino, japonés, coreano, árabe, hindi, '
+ 'tailandés y emoji — o sea para la mayor parte de la humanidad',
+ ['noto-fonts-cjk', 'noto-fonts-emoji', 'noto-fonts', 'noto-fonts-extra']),
+ 'escribir en esos idiomas': (
+ 'una fuente deja LEER; para ESCRIBIR hace falta un método de entrada',
+ ['fcitx5', 'ibus', 'anthy', 'libpinyin', 'hangul', 'rime']),
+ 'accesibilidad': (
+ 'sin lector de pantalla ni voz la distro es inusable para quien no ve',
+ ['orca', 'speech-dispatcher', 'espeak-ng', 'brltty']),
+ 'imprimir de verdad': (
+ 'cups solo no imprime: los filtros y los drivers son otro paquete',
+ ['cups', 'cups-filters', 'ghostscript', 'gutenprint', 'hplip']),
+ 'escanear': ('ningún camino hoy', ['sane-backends', 'simple-scan']),
+ 'java': ('un ecosistema entero queda fuera sin una JVM', ['openjdk', 'jdk', 'temurin']),
+ 'documentos técnicos': ('nada que componga un PDF desde texto marcado',
+ ['texlive', 'pandoc', 'typst']),
+ 'bases de datos de servidor': ('sqlite está; un servicio con varios clientes no',
+ ['postgresql', 'mariadb', 'redis', 'valkey']),
+ 'contenedores': ('`crun` está (el motor); falta quien lo conduzca',
+ ['podman', 'docker', 'buildah', 'flatpak']),
+ 'virtualización': ('ni invitado ni anfitrión', ['qemu', 'libvirt', 'virt-manager']),
+ 'archivos comprimidos ajenos': ('no se puede abrir un .zip, un .7z ni un .rar — un adjunto '
+ 'cualquiera queda cerrado',
+ ['unzip', 'zip', 'p7zip', '7zip', 'unrar']),
+ 'vpn y red gestionada': ('networkmanager y wireguard están; el resto no',
+ ['networkmanager', 'wireguard-tools', 'openvpn', 'iwd']),
+ 'juegos': ('decisión abierta, no un olvido', ['wine', 'steam', 'gamescope', 'lutris']),
+ 'discos y salud del equipo': ('nada que avise de un disco muriéndose',
+ ['smartmontools', 'nvme-cli', 'hdparm']),
+}
+
+
+def nombres_conocidos():
+ """Toda receta alcanzable: catálogo, colas `incoming-*` y `tandas/`."""
+ n = set()
+ for f in glob.glob(os.path.join(RAIZ, 'recipes/*.toml')):
+ n.add(os.path.basename(f)[:-5])
+ for f in glob.glob(os.path.join(RAIZ, 'recipes/incoming-*/*.toml')):
+ n.add(os.path.basename(f)[:-5])
+ for f in glob.glob(os.path.join(RAIZ, 'tandas/**/*.toml'), recursive=True):
+ n.add(os.path.basename(f)[:-5])
+ return n
+
+
+def clasificar(cand, conocidos, en_imagen):
+ """`tiene` (y si viaja) · `revisar` (hay un pariente) · `falta` (ni por subcadena)."""
+ if cand in conocidos:
+ return ('tiene-y-viaja' if cand in en_imagen else 'tiene-sin-viajar'), []
+ raiz = cand.split('-')[0]
+ # Un pariente es: una receta cuyo nombre EMPIEZA por la raíz del candidato (`cups` → `cups`,
+ # `steam` → `steampipe`), o una receta que es PREFIJO o SUFIJO del candidato (`mutt` dentro de
+ # `neomutt`). Y no «contenida en cualquier posición», que es lo que hacía la primera versión:
+ # con eso `age` salía pariente de `virt-manager` y `patch` de `speech-dispatcher`. Un pariente
+ # falso no es inocuo —manda a revisar algo que no hay que revisar— aunque sea menos grave que
+ # un hueco falso. Raíz de 3+ caracteres: con 2 todo se parece a todo.
+ cerca = sorted(x for x in conocidos
+ if (len(raiz) >= 3 and x.startswith(raiz))
+ or (len(x) >= 3 and (cand.startswith(x) or cand.endswith(x))))
+ return ('revisar' if cerca else 'falta'), cerca[:4]
+
+
+def main():
+ ap = argparse.ArgumentParser()
+ ap.add_argument('--json')
+ a = ap.parse_args()
+
+ import yupana
+ conocidos = nombres_conocidos()
+ en_imagen = {n for (c, n) in yupana.membresia_imagen() if c != 'wanted'}
+
+ filas, resumen = [], {'tiene-y-viaja': 0, 'tiene-sin-viajar': 0, 'revisar': 0, 'falta': 0}
+ for cat, (porque, cands) in CHECKLIST.items():
+ items = []
+ for c in cands:
+ estado, cerca = clasificar(c, conocidos, en_imagen)
+ resumen[estado] += 1
+ items.append({'nombre': c, 'estado': estado, 'parientes': cerca})
+ filas.append({'categoria': cat, 'porque': porque, 'items': items,
+ 'faltan': [i['nombre'] for i in items if i['estado'] == 'falta']})
+
+ print('== qué le falta a la distro para usarse (checklist de JUICIO, estado MEDIDO) ==')
+ print(' recetas alcanzables: %d · de ellas en alguna imagen: %d'
+ % (len(conocidos), len(en_imagen)))
+ for f in sorted(filas, key=lambda x: -len(x['faltan'])):
+ marca = '✗' if f['faltan'] else '·'
+ print(' %s %-28s %d/%d falta%s' % (marca, f['categoria'], len(f['faltan']),
+ len(f['items']),
+ ': ' + ', '.join(f['faltan']) if f['faltan'] else 'n'))
+ rev = [(i['nombre'], i['parientes']) for f in filas for i in f['items'] if i['estado'] == 'revisar']
+ if rev:
+ print(' — a REVISAR (hay un pariente, no se cuentan como hueco):')
+ for n, p in rev:
+ print(' %-20s → %s' % (n, ', '.join(p)))
+ print(' resumen: %s' % json.dumps(resumen, ensure_ascii=False))
+
+ if a.json:
+ with open(a.json, 'w', encoding='utf-8') as fh:
+ json.dump({'schema': 'takana-censo-distro/1',
+ 'recetas_alcanzables': len(conocidos),
+ 'en_alguna_imagen': len(en_imagen),
+ 'resumen': resumen, 'categorias': filas},
+ fh, ensure_ascii=False, indent=1, sort_keys=True)
+ fh.write('\n')
+ print(' → %s' % a.json)
+ return 0
+
+
+if __name__ == '__main__':
+ sys.exit(main())