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 1471Shell 173Rust 105Markdown 943545 Definiciones

⎇ 98edf488bbffd2dae507a6317a78bb61d5a9c07a scripts/censo-distro.py Python · 366 líneas · 22.7 KB Historia Quién tocó qué crudo

feat(perfiles): se puede ESCRIBIR coreano — el primer método de entrada entra a los 5 escritorios 594d4819e · 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        'las únicas fuentes del corpus son dejavu y su nerd: sin estas, la distro muestra '
53        'cuadraditos en chino, japonés, coreano, árabe, hindi, tailandés y emoji',
54        ['noto-fonts-cjk', 'noto-fonts-emoji', 'noto-fonts', 'noto-fonts-extra']),
55    'escribir en esos idiomas': (
56        'una fuente deja LEER; para ESCRIBIR hace falta un método de entrada. ✅ El marco `fcitx5` '
57        'y el motor COREANO entraron el 2026-10-20 (con `libhangul`, que viaja por su diccionario '
58        'hanja: la ruta está horneada en la .a). Faltan los otros idiomas. '
59        '⚠ Los renglones se nombran como los PAQUETES de fcitx5, no como las bibliotecas motor: mi '
60        'primera versión pedía `anthy`, `libpinyin` y `hangul`, y con `fcitx5-hangul` ya sellado el '
61        'censo seguía contando «hangul» como hueco — un hueco falso por nombrar la pieza de abajo '
62        'en vez de la que se declara',
63        ['fcitx5', 'ibus', 'fcitx5-hangul', 'fcitx5-anthy', 'fcitx5-chinese-addons',
64         'fcitx5-rime']),
65    'accesibilidad': (
66        'sin lector de pantalla ni voz la distro es inusable para quien no ve',
67        ['orca', 'speech-dispatcher', 'espeak-ng', 'brltty']),
68
69    # ── LO QUE ALGUIEN BUSCA EL PRIMER DÍA ──────────────────────────────────────────────────────
70    'terminales populares': (
71        '`foot` está y alcanza; pero quien llega de otra distro busca el suyo por nombre, y el '
72        'doc 20 §II.2 ya los listaba como «barato». kitty: 13 de sus 14 deps ya están en el '
73        'catálogo (falta librsync, opcional)',
74        ['kitty', 'alacritty', 'wezterm', 'ghostty', 'xterm']),
75    'editores': (
76        '`vim` y `helix` están. ⚠ Existe `bob-nvim` —el GESTOR DE VERSIONES de neovim— y no '
77        'existe neovim: el conductor sin el motor, como `crun` sin podman',
78        ['neovim', 'emacs', 'micro', 'kakoune', 'nano']),
79    'shells': ('bash, zsh y nushell están', ['fish', 'elvish']),
80    'lanzador, notificaciones y bloqueo': (
81        'el esqueleto de un escritorio Wayland que no sea GNOME/KDE: sin lanzador no se abre nada',
82        ['rofi', 'wofi', 'fuzzel', 'bemenu', 'tofi', 'mako', 'dunst', 'swaync', 'swaylock',
83         'gtklock', 'waylock']),
84    'audio de escritorio': (
85        'PipeWire suena y no hay con qué subirle el volumen desde una ventana',
86        ['pavucontrol', 'pamixer', 'playerctl', 'easyeffects', 'wiremix']),
87    'capturas y portapapeles': ('grim, slurp, wl-clipboard y cliphist ya están',
88                                ['satty', 'swappy', 'flameshot', 'wtype', 'kanshi']),
89    'visores de imagen': ('ninguno: una foto no se abre',
90                          ['imv', 'nsxiv', 'feh', 'loupe', 'eog']),
91
92    # ── CAPACIDADES ENTERAS SIN NINGÚN CAMINO ───────────────────────────────────────────────────
93    'archivos comprimidos ajenos': (
94        '⚠ EL ARGUMENTO DE ESTA FILA ERA FALSO Y LO MEDÍ DESPUÉS: decía «no se puede abrir un .zip» '
95        'porque `libzip` es una biblioteca y no el comando. Cierto para libzip — y falso como '
96        'conclusión: `libarchive` estaba SELLADO y trae `bsdunzip`/`bsdtar`, verificados con un zip '
97        'real en lectura Y escritura. Se declaró en `cli` el 2026-10-19. Lo que falta son los ALIAS '
98        '(`unzip`, `zip`) para que un script ajeno que los invoque no falle, y 7z/rar sin medir',
99        ['unzip', 'zip', 'p7zip', '7zip', 'unrar']),
100    'imprimir de verdad': ('cups solo no imprime: los filtros y los drivers son otro paquete',
101                           ['cups', 'cups-filters', 'ghostscript', 'gutenprint', 'hplip']),
102    'escanear': ('ningún camino hoy', ['sane-backends', 'simple-scan']),
103    'documentos técnicos': ('nada que componga un PDF desde texto marcado',
104                            ['texlive', 'pandoc', 'typst', 'groff']),
105    'páginas de manual': ('`mandoc` LEE man; falta el CONTENIDO y el formateador de info',
106                          ['man-pages', 'texinfo']),
107    'virtualización': ('ni invitado ni anfitrión: no se puede probar la propia ISO sin salir',
108                       ['qemu', 'libvirt', 'virt-manager']),
109    'contenedores': ('`crun` está (el motor); falta quien lo conduzca',
110                     ['podman', 'docker', 'buildah', 'flatpak', 'distrobox']),
111    'java': ('un ecosistema entero queda fuera sin una JVM', ['openjdk', 'jdk', 'temurin']),
112    'bases de datos de servidor': ('sqlite está; un servicio con varios clientes no',
113                                   ['postgresql', 'mariadb', 'redis', 'valkey']),
114    'depurar y medir': ('se construye la distro entera y no hay con qué mirar un core',
115                        ['gdb', 'lldb', 'valgrind', 'ltrace', 'perf', 'patchelf']),
116    'discos y salud del equipo': ('nada que avise de un disco muriéndose',
117                                  ['smartmontools', 'nvme-cli', 'hdparm', 'gparted']),
118
119    # ── LENGUAJES Y HERRAMIENTAS DE CONSTRUCCIÓN ────────────────────────────────────────────────
120    'otros lenguajes': (
121        'python3, lua, go, rust y perl están. ⚠ `zig` NO es receta: vive como artefacto '
122        '`seed-zig` fuera del catálogo, y es el compilador de TODO el corpus',
123        ['nodejs', 'deno', 'bun', 'ruby', 'php', 'julia', 'ghc', 'ocaml', 'elixir', 'erlang']),
124    'empaquetadores de python': ('pip no viaja y no hay forma de instalar una herramienta python',
125                                 ['pipx', 'uv', 'poetry']),
126    'construcción C clásica': (
127        '⚠ ESTA FILA ES LA MÁS DUDOSA DEL CHECKLIST y conviene que lo diga: cmake, meson, make y '
128        'samurai existen y NO viajan en ninguna imagen — probablemente a propósito, porque una '
129        'imagen de producto no se construye a sí misma. La receta de `nodejs` ya tomó esa decisión '
130        'por escrito para su caso («acá Node es un compilador más, no software que la distro '
131        'publique»). ⇒ lo que falta acá puede no ser un hueco sino un PERFIL de desarrollo que no '
132        'existe. Se deja medido y sin declarar. `ninja` además lo instala `samurai` con ese nombre '
133        'exacto (1.9.0, medido). Y `gcc` está RETENIDO a propósito (sólo `gcc-libs`), `clang` es '
134        '`clang18`: eso no es hueco, es decisión',
135        ['autoconf', 'automake', 'ninja']),
136
137    # ── RED, SEGURIDAD, DATOS ───────────────────────────────────────────────────────────────────
138    'vpn y red gestionada': ('networkmanager, wireguard-tools y wpa_supplicant están',
139                             ['openvpn', 'iwd', 'nmtui', 'impala', 'mosh', 'sshfs']),
140    'bluetooth usable': ('`bluez` viaja; falta con qué emparejar un auricular',
141                         ['blueman', 'bluetuith']),
142    'contraseñas': ('nada: la gente guarda sus claves en un .txt o no usa la distro',
143                    ['keepassxc', 'pass', 'gopass']),
144    'respaldos': ('restic y rclone están', ['borgbackup', 'rsnapshot']),
145    'descargas y torrent': ('curl y wget están', ['aria2', 'transmission', 'qbittorrent']),
146
147    # ── APLICACIONES DE ESCRITORIO ──────────────────────────────────────────────────────────────
148    'navegadores': ('firefox y atuq están; el motor Blink no tiene ningún camino',
149                    ['chromium', 'qutebrowser', 'librewolf', 'brave']),
150    'mensajería': ('ningún cliente de chat: ni XMPP, ni Matrix, ni los propietarios',
151                   ['dino', 'gajim', 'element-desktop', 'telegram-desktop', 'signal-desktop']),
152    'música': ('mpv y cmus están para reproducir un fichero; falta biblioteca y daemon',
153               ['mpd', 'ncmpcpp', 'rhythmbox', 'audacious']),
154    'video': ('mpv está', ['vlc', 'celluloid', 'totem']),
155    'imagen y diseño': (
156        '⚠ la suite ArtCraft propia cubre esto con 12 apps (photocraft, vectorcraft, …): estos '
157        'renglones son los nombres AJENOS que alguien busca, no un hueco de capacidad',
158        ['gimp', 'inkscape', 'krita', 'darktable']),
159    'gestores de archivos': ('yazi, nnn, lf, broot y dolphin/nautilus están',
160                             ['thunar', 'ranger']),
161    'lectores de PDF': ('zathura y okular están', ['mupdf', 'sioyek', 'evince']),
162    'notas': ('ninguno', ['obsidian', 'logseq', 'joplin', 'zettlr']),
163
164    # ── DECISIONES ABIERTAS, NO OLVIDOS ─────────────────────────────────────────────────────────
165    'otros compositores Wayland': (
166        'hay cinco escritorios (mirada, sway, cosmic, gnome, kde); éstos son los que la gente '
167        'nombra y no están. Decisión de alcance, no olvido',
168        ['hyprland', 'river', 'niri', 'labwc', 'wayfire']),
169    'juegos': ('decisión abierta: arrastra Vulkan, que la distro hoy no publica',
170               ['wine', 'steam', 'gamescope', 'lutris', 'mangohud']),
171    'infra ajena': ('decisión abierta: son herramientas de operar OTRA cosa',
172                    ['kubectl', 'helm', 'terraform', 'ansible']),
173}
174
175
176# ── CAPACIDAD YA CUBIERTA CON OTRO NOMBRE ──────────────────────────────────────────────────────
177# Lo simétrico de `MUROS`: el renglón falta POR NOMBRE y la capacidad SÍ está, medida. No absuelve
178# el hueco —un script que invoque `unzip` sigue fallando, y el censo hace bien en contarlo— pero
179# cambia el presupuesto de «no hay camino» a «faltan los alias», que es otra cosa y mucho más
180# barata. Sólo entra lo verificado ejecutando, no lo que upstream promete.
181CUBIERTOS = {
182    'unzip': ('libarchive', 'bsdunzip -o x.zip extrae y conserva el árbol — probado con un zip '
183                            'deflate con subdirectorio el 2026-10-19'),
184    'zip':   ('libarchive', 'bsdtar -a -cf x.zip … crea un zip que python zipfile lee y verifica '
185                            '— probado el 2026-10-19'),
186}
187
188# ── POR QUÉ ESTÁ CADA HUECO: receta ausente ≠ capacidad ausente ─────────────────────────────────
189# «Falta la receta» y «la distro no publica eso» son dos cosas con presupuestos distintos, y el
190# censo mentía por omisión al contarlas juntas. Sólo entran acá los muros MEDIDOS, con el dato que
191# los prueba; lo demás se queda sin anotar, que es más honesto que adivinar.
192MUROS = {
193    # ✅ `fcitx5` ESTUVO ACÁ Y SE FUE: el muro se derribó el 2026-10-19/20. Decía
194    # `corpus-estatico-sin-pic` con 7365 errores de PIC medidos, y era cierto — hasta que el
195    # operador decidió publicar `libpng`/`freetype`/`fontconfig` con `--with-pic` (40 recetas de
196    # cascada, las 5 imágenes de escritorio). Con eso **fcitx5 SELLA**: daemon + 17 addons, mismo
197    # hash en el hub y en el worker. Se quita de `MUROS` en el mismo movimiento, porque un muro
198    # derribado que sigue anotado es exactamente la clase de pendiente inventado que este fichero
199    # existe para no producir — y hoy ya me pasó con el `.conf` de los emoji.
200    # ⇒ fcitx5 pasa a `tiene-sin-viajar`, que es la verdad: construido y en ningún perfil, porque
201    # el marco sin un motor de idioma daría una barra de IME que no convierte nada.
202    'kitty': ('sin-opengl-de-escritorio',
203              "setup.py:804 hace pkg_config('gl', '--libs') y data-types.h:25 exige OpenGL >= 3.x "
204              "core. Medido en el store: NADIE publica `gl.pc` ni `libGL.so` — mesa da egl.pc, "
205              "glesv2.pc, gbm.pc y dri.pc; libglvnd da opengl.pc con libOpenGL.so/libGLdispatch.so "
206              "y sólo viaja en escritorio-kde. Compilar no basta: gl.c:68 aborta en runtime con "
207              "«OpenGL version is X.Y, version >= 3.3 required for kitty». Mismo muro que OBS."),
208}
209
210
211# ── ¿LO INSTALA YA ALGÚN ARTEFACTO VIGENTE, CON ESE MISMO NOMBRE? ──────────────────────────────
212# Automatiza el hallazgo que destapó `libarchive`: un renglón puede faltar como RECETA y existir
213# como BINARIO dentro de otro paquete. Se mide contra los `bin`/`sbin`/`usr/bin`/`usr/sbin` de los
214# artefactos sellados, igual que `busybox-censo-proveedores.py`.
215#
216# ⚠ SÓLO ARTEFACTOS VIGENTES, y es un defecto medido de mi primera versión (2026-10-19): barriendo
217# `/store/*` entero, `unzip` y `hdparm` salían «ya los instala busybox» — y era el busybox
218# SUPERADO (`2a2b1280…`). El vigente (`4bd934f6…`) ya no los trae, porque la campaña de botar
219# busybox los recortó. **Un artefacto superado no dice nada de lo que hay en una imagen**, y
220# contarlo convierte el instrumento que caza huecos falsos en una fábrica de huecos falsos.
221# Es la misma lección que los «sellados rancios» del static-audit.
222#
223# ⚠ Y los rootfs ENSAMBLADOS se excluyen por nombre: traen el busybox del piso adentro, así que
224# todo applet se «proveería» a sí mismo. Mismo criterio que el censo de applets.
225ROOTFS = ('product-rootfs', 'product-attested-rootfs', 'stage1-rootfs')
226DIRS_BIN = ('bin', 'sbin', 'usr/bin', 'usr/sbin')
227
228
229def _cargar(nombre, ruta):
230    """Se importa por RUTA porque los ficheros llevan guion y no son módulos importables."""
231    spec = importlib.util.spec_from_file_location(nombre, ruta)
232    mod = importlib.util.module_from_spec(spec)
233    spec.loader.exec_module(mod)
234    return mod
235
236
237def indice_de_binarios(cp):
238    """{nombre de binario: {recetas VIGENTES que lo instalan}}."""
239    hashes, _ = cp.mapa_hashes()
240    vigentes = {}
241    for (cola, nombre), (h, estado) in hashes.items():
242        if not h or estado != 'sealed' or nombre in ROOTFS:
243            continue
244        vigentes.setdefault(nombre, (h or '').replace('b3:', ''))
245    idx = {}
246    for nombre, h in vigentes.items():
247        base = os.path.join('/store', '%s-%s' % (h, nombre))
248        for sub in DIRS_BIN:
249            d = os.path.join(base, sub)
250            if not os.path.isdir(d):
251                continue
252            try:
253                entradas = os.listdir(d)
254            except OSError:
255                continue
256            for e in entradas:
257                idx.setdefault(e, set()).add(nombre)
258    return idx
259
260
261def nombres_conocidos():
262    """Toda receta alcanzable: catálogo, colas `incoming-*` y `tandas/`."""
263    n = set()
264    for f in glob.glob(os.path.join(RAIZ, 'recipes/*.toml')):
265        n.add(os.path.basename(f)[:-5])
266    for f in glob.glob(os.path.join(RAIZ, 'recipes/incoming-*/*.toml')):
267        n.add(os.path.basename(f)[:-5])
268    for f in glob.glob(os.path.join(RAIZ, 'tandas/**/*.toml'), recursive=True):
269        n.add(os.path.basename(f)[:-5])
270    return n
271
272
273def clasificar(cand, conocidos, en_imagen):
274    """`tiene-y-viaja` · `tiene-sin-viajar` · `falta` (+ pistas de nombre parecido, que NO absuelven)."""
275    if cand in conocidos:
276        return ('tiene-y-viaja' if cand in en_imagen else 'tiene-sin-viajar'), []
277    raiz = cand.split('-')[0]
278    # Un pariente es: una receta cuyo nombre EMPIEZA por la raíz del candidato (`cups` → `cups`,
279    # `steam` → `steampipe`), o una receta que es PREFIJO o SUFIJO del candidato (`mutt` dentro de
280    # `neomutt`). Y no «contenida en cualquier posición», que es lo que hacía la primera versión:
281    # con eso `age` salía pariente de `virt-manager` y `patch` de `speech-dispatcher`. Un pariente
282    # falso no es inocuo —manda a revisar algo que no hay que revisar— aunque sea menos grave que
283    # un hueco falso. Raíz de 3+ caracteres: con 2 todo se parece a todo.
284    cerca = sorted(x for x in conocidos
285                   if (len(raiz) >= 3 and x.startswith(raiz))
286                   or (len(x) >= 3 and (cand.startswith(x) or cand.endswith(x))))
287    return 'falta', cerca[:4]
288
289
290def main():
291    ap = argparse.ArgumentParser()
292    ap.add_argument('--json')
293    a = ap.parse_args()
294
295    import yupana
296    cp = _cargar('cierre_perfiles', os.path.join(RAIZ, 'scripts', 'cierre-perfiles.py'))
297    conocidos = nombres_conocidos()
298    en_imagen = {n for (c, n) in yupana.membresia_imagen() if c != 'wanted'}
299    try:
300        binarios = indice_de_binarios(cp)
301    except Exception as e:          # sin store legible el censo sigue sirviendo; se dice y se sigue
302        print('   (no pude indexar los binarios del store: %s)' % e)
303        binarios = {}
304
305    filas, resumen = [], {'tiene-y-viaja': 0, 'tiene-sin-viajar': 0, 'falta': 0}
306    for cat, (porque, cands) in CHECKLIST.items():
307        items = []
308        for c in cands:
309            estado, cerca = clasificar(c, conocidos, en_imagen)
310            resumen[estado] += 1
311            items.append({'nombre': c, 'estado': estado, 'parientes': cerca})
312        filas.append({'categoria': cat, 'porque': porque, 'items': items,
313                      'faltan': [i['nombre'] for i in items if i['estado'] == 'falta']})
314        for i in items:
315            if i['estado'] == 'falta' and i['parientes']:
316                i['pista'] = 'nombre parecido en el catálogo (verificar, NO es equivalencia)'
317            if i['nombre'] in MUROS:
318                i['muro'], i['muro_porque'] = MUROS[i['nombre']]
319            if i['nombre'] in CUBIERTOS:
320                i['cubierto_por'], i['cubierto_porque'] = CUBIERTOS[i['nombre']]
321            elif i['estado'] == 'falta' and i['nombre'] in binarios:
322                quien = sorted(binarios[i['nombre']])
323                i['cubierto_por'] = ', '.join(quien)
324                i['cubierto_porque'] = ('MEDIDO: un artefacto vigente instala un binario con ese '
325                                        'nombre exacto. Falta la receta propia, no la capacidad')
326
327    print('== qué le falta a la distro para usarse (checklist de JUICIO, estado MEDIDO) ==')
328    print('   recetas alcanzables: %d · de ellas en alguna imagen: %d'
329          % (len(conocidos), len(en_imagen)))
330    for f in sorted(filas, key=lambda x: -len(x['faltan'])):
331        marca = '✗' if f['faltan'] else '·'
332        print('   %s %-28s %d/%d falta%s' % (marca, f['categoria'], len(f['faltan']),
333                                             len(f['items']),
334                                             ': ' + ', '.join(f['faltan']) if f['faltan'] else 'n'))
335    rev = [(i['nombre'], i['parientes']) for f in filas for i in f['items']
336           if i['estado'] == 'falta' and i['parientes']]
337    if rev:
338        print('   — PISTAS: falta, pero hay un nombre parecido (verificar; NO es equivalencia):')
339        for n, p in rev:
340            print('        %-20s ~ %s' % (n, ', '.join(p)))
341    cub = [(i['nombre'], i['cubierto_por']) for f in filas for i in f['items'] if i.get('cubierto_por')]
342    if cub:
343        print('   — CAPACIDAD YA CUBIERTA con otro nombre (falta el alias, no el camino):')
344        for n, q in cub:
345            print('        %-20s lo hace %s' % (n, q))
346    con_muro = [(i['nombre'], i['muro']) for f in filas for i in f['items'] if i.get('muro')]
347    if con_muro:
348        print('   — MUROS MEDIDOS (no es que falte la receta: la distro no publica eso):')
349        for n, m in con_muro:
350            print('        %-20s %s' % (n, m))
351    print('   resumen: %s' % json.dumps(resumen, ensure_ascii=False))
352
353    if a.json:
354        with open(a.json, 'w', encoding='utf-8') as fh:
355            json.dump({'schema': 'takana-censo-distro/1',
356                       'recetas_alcanzables': len(conocidos),
357                       'en_alguna_imagen': len(en_imagen),
358                       'resumen': resumen, 'categorias': filas},
359                      fh, ensure_ascii=False, indent=1, sort_keys=True)
360            fh.write('\n')
361        print('   → %s' % a.json)
362    return 0
363
364
365if __name__ == '__main__':
366    sys.exit(main())