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…
⎇ 138e6e2524001b8f62700ec0880b6d05657cc62c
scripts/censo-distro.py
Ñawpa willay
Pi imata llamkarqan
ch'uya
feat(distro): qué le falta para USARSE — la pregunta que ninguna métrica contestaba
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⚠ **Y no cuenta un hueco sin antes buscar un PARIENTE de nombre.** Es el error que este censo cometió en su
23primera versión y que ya había costado dos veces el mismo día: decir «falta `clang`» cuando existe
24`clang18`, «falta `rustc`» cuando existe `rust`, «falta `neomutt`» cuando existe `mutt`. Un hueco
25con un pariente cerca sale como `revisar`, nunca como `falta` — porque la diferencia entre las dos
26palabras es la que hace que alguien trabaje de más.
27
28Uso: censo-distro.py [--json docs/state/distro-huecos.json]
29"""
30import argparse
31import glob
32import json
33import os
34import sys
35
36RAIZ = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
37sys.path.insert(0, os.path.join(RAIZ, 'scripts'))
38
39# Cada entrada: categoría → (por_qué_importa, [nombres candidatos]).
40# El «por qué» no es adorno: un renglón sin motivo se discute para siempre.
41CHECKLIST = {
42 'escritura del mundo': (
43 'sin estas fuentes la distro muestra cuadraditos en chino, japonés, coreano, árabe, hindi, '
44 'tailandés y emoji — o sea para la mayor parte de la humanidad',
45 ['noto-fonts-cjk', 'noto-fonts-emoji', 'noto-fonts', 'noto-fonts-extra']),
46 'escribir en esos idiomas': (
47 'una fuente deja LEER; para ESCRIBIR hace falta un método de entrada',
48 ['fcitx5', 'ibus', 'anthy', 'libpinyin', 'hangul', 'rime']),
49 'accesibilidad': (
50 'sin lector de pantalla ni voz la distro es inusable para quien no ve',
51 ['orca', 'speech-dispatcher', 'espeak-ng', 'brltty']),
52 'imprimir de verdad': (
53 'cups solo no imprime: los filtros y los drivers son otro paquete',
54 ['cups', 'cups-filters', 'ghostscript', 'gutenprint', 'hplip']),
55 'escanear': ('ningún camino hoy', ['sane-backends', 'simple-scan']),
56 'java': ('un ecosistema entero queda fuera sin una JVM', ['openjdk', 'jdk', 'temurin']),
57 'documentos técnicos': ('nada que componga un PDF desde texto marcado',
58 ['texlive', 'pandoc', 'typst']),
59 'bases de datos de servidor': ('sqlite está; un servicio con varios clientes no',
60 ['postgresql', 'mariadb', 'redis', 'valkey']),
61 'contenedores': ('`crun` está (el motor); falta quien lo conduzca',
62 ['podman', 'docker', 'buildah', 'flatpak']),
63 'virtualización': ('ni invitado ni anfitrión', ['qemu', 'libvirt', 'virt-manager']),
64 'archivos comprimidos ajenos': ('no se puede abrir un .zip, un .7z ni un .rar — un adjunto '
65 'cualquiera queda cerrado',
66 ['unzip', 'zip', 'p7zip', '7zip', 'unrar']),
67 'vpn y red gestionada': ('networkmanager y wireguard están; el resto no',
68 ['networkmanager', 'wireguard-tools', 'openvpn', 'iwd']),
69 'juegos': ('decisión abierta, no un olvido', ['wine', 'steam', 'gamescope', 'lutris']),
70 'discos y salud del equipo': ('nada que avise de un disco muriéndose',
71 ['smartmontools', 'nvme-cli', 'hdparm']),
72}
73
74
75def nombres_conocidos():
76 """Toda receta alcanzable: catálogo, colas `incoming-*` y `tandas/`."""
77 n = set()
78 for f in glob.glob(os.path.join(RAIZ, 'recipes/*.toml')):
79 n.add(os.path.basename(f)[:-5])
80 for f in glob.glob(os.path.join(RAIZ, 'recipes/incoming-*/*.toml')):
81 n.add(os.path.basename(f)[:-5])
82 for f in glob.glob(os.path.join(RAIZ, 'tandas/**/*.toml'), recursive=True):
83 n.add(os.path.basename(f)[:-5])
84 return n
85
86
87def clasificar(cand, conocidos, en_imagen):
88 """`tiene` (y si viaja) · `revisar` (hay un pariente) · `falta` (ni por subcadena)."""
89 if cand in conocidos:
90 return ('tiene-y-viaja' if cand in en_imagen else 'tiene-sin-viajar'), []
91 raiz = cand.split('-')[0]
92 # Un pariente es: una receta cuyo nombre EMPIEZA por la raíz del candidato (`cups` → `cups`,
93 # `steam` → `steampipe`), o una receta que es PREFIJO o SUFIJO del candidato (`mutt` dentro de
94 # `neomutt`). Y no «contenida en cualquier posición», que es lo que hacía la primera versión:
95 # con eso `age` salía pariente de `virt-manager` y `patch` de `speech-dispatcher`. Un pariente
96 # falso no es inocuo —manda a revisar algo que no hay que revisar— aunque sea menos grave que
97 # un hueco falso. Raíz de 3+ caracteres: con 2 todo se parece a todo.
98 cerca = sorted(x for x in conocidos
99 if (len(raiz) >= 3 and x.startswith(raiz))
100 or (len(x) >= 3 and (cand.startswith(x) or cand.endswith(x))))
101 return ('revisar' if cerca else 'falta'), cerca[:4]
102
103
104def main():
105 ap = argparse.ArgumentParser()
106 ap.add_argument('--json')
107 a = ap.parse_args()
108
109 import yupana
110 conocidos = nombres_conocidos()
111 en_imagen = {n for (c, n) in yupana.membresia_imagen() if c != 'wanted'}
112
113 filas, resumen = [], {'tiene-y-viaja': 0, 'tiene-sin-viajar': 0, 'revisar': 0, 'falta': 0}
114 for cat, (porque, cands) in CHECKLIST.items():
115 items = []
116 for c in cands:
117 estado, cerca = clasificar(c, conocidos, en_imagen)
118 resumen[estado] += 1
119 items.append({'nombre': c, 'estado': estado, 'parientes': cerca})
120 filas.append({'categoria': cat, 'porque': porque, 'items': items,
121 'faltan': [i['nombre'] for i in items if i['estado'] == 'falta']})
122
123 print('== qué le falta a la distro para usarse (checklist de JUICIO, estado MEDIDO) ==')
124 print(' recetas alcanzables: %d · de ellas en alguna imagen: %d'
125 % (len(conocidos), len(en_imagen)))
126 for f in sorted(filas, key=lambda x: -len(x['faltan'])):
127 marca = '✗' if f['faltan'] else '·'
128 print(' %s %-28s %d/%d falta%s' % (marca, f['categoria'], len(f['faltan']),
129 len(f['items']),
130 ': ' + ', '.join(f['faltan']) if f['faltan'] else 'n'))
131 rev = [(i['nombre'], i['parientes']) for f in filas for i in f['items'] if i['estado'] == 'revisar']
132 if rev:
133 print(' — a REVISAR (hay un pariente, no se cuentan como hueco):')
134 for n, p in rev:
135 print(' %-20s → %s' % (n, ', '.join(p)))
136 print(' resumen: %s' % json.dumps(resumen, ensure_ascii=False))
137
138 if a.json:
139 with open(a.json, 'w', encoding='utf-8') as fh:
140 json.dump({'schema': 'takana-censo-distro/1',
141 'recetas_alcanzables': len(conocidos),
142 'en_alguna_imagen': len(en_imagen),
143 'resumen': resumen, 'categorias': filas},
144 fh, ensure_ascii=False, indent=1, sort_keys=True)
145 fh.write('\n')
146 print(' → %s' % a.json)
147 return 0
148
149
150if __name__ == '__main__':
151 sys.exit(main())