tawasuyu

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…

⎇ main scripts/fuentes/upstream-vigia.py Python · 808 líneas · 42.7 KB Historia Quién tocó qué crudo

feat(fuentes): la FECHA del índice asciende 230 indicios a evidencia — y el primer intento tenía un falso negativo 556888e00 · Sergio ·

1#!/usr/bin/env python3
2# upstream-vigia.py — ¿qué se movió RÍO ARRIBA desde que pineamos?
3#
4# ══ QUÉ CONTESTA, Y QUÉ NO ════════════════════════════════════════════════════════════════════
5# `fuentes-vigia.sh` pregunta si la fuente SIGUE VIVA (¿responde la URL?). Ésta pregunta otra cosa:
6# **¿hay algo más nuevo que lo que tenemos clavado?** Son las dos mitades del mismo problema y se
7# confunden: una fuente viva puede llevar dos años sin tocarse, y una que se movió ayer puede estar
8# muerta mañana.
9#
10# **No modifica ninguna receta.** Informa. Quién decide aplicar un salto de versión —y con qué
11# pruebas— es la política del SDD 33, no este guion: una versión nueva de upstream puede cambiar el
12# sistema de build, las banderas, las dependencias y el resultado, y eso no se automatiza a ciegas
13# para código ajeno.
14#
15# ── QUÉ SE CUBRE, Y POR QUÉ ESTO YA NO ES «SÓLO GIT» ──────────────────────────────────────────
16# `git ls-remote` funciona contra CUALQUIER servidor git sin API, sin token y sin clonar — es lo
17# mismo que ya hace `takana pin`. Con eso se cubren las 660 recetas que pinean `repo` + `commit`.
18#
19# ⚠ **Y TAMBIÉN 196 DE LAS 625 QUE PINEAN TARBALL** (2026-10-07). La primera versión de este guion
20# decía que un tarball «no tiene equivalente general» y que saber si hay una versión nueva «pide
21# una pista por receta». Es cierto en general y era **falso para el grueso**: medido, 196 de los
22# 625 tarballs son de `github.com`, y en esas URLs el repo y la versión YA ESTÁN ESCRITOS
23# (`.../archive/refs/tags/v1.2.3.tar.gz`, `.../releases/download/1.10/sway-1.10.tar.gz`). No hacía
24# falta API, ni token, ni pista por receta: hacía falta LEER LA URL y reusar el `ls-remote` que ya
25# estaba acá. Cubrirlas no costó un mecanismo nuevo.
26#
27# Lo que SIGUE fuera son 429, y son índices de directorio con forma conocida: download.kde.org
28# (108), download.gnome.org (70), ftp.gnu.org (32), gitlab.freedesktop.org (26), xorg/x.org (29),
29# download.qt.io (12), kernel.org (18). Ésos piden **un listado por host**, no una pista por receta
30# — su propia unidad de trabajo. Se cuentan y **se nombran por host** en el informe: «429 sin
31# mirar» se lee como silencio, «108 en download.kde.org» se lee como la próxima tarea.
32#
33# ⚠ Y se prefiere CALLAR a adivinar. Tres cosas no se comparan nunca, y cada una se informa en vez
34# de desaparecer: las que bajan por SHA (un sha no tiene orden de versión), las que no tienen tag
35# de la MISMA serie que la URL (ver `forma`: evitó 9 falsos positivos medidos, del tipo
36# «cronie-1.7.2 → v4.3») y las que no tienen tags o están inalcanzables.
37#
38# Uso:  scripts/fuentes/upstream-vigia.py [--json <fichero>] [--solo tawasuyu] [--jobs N]
39#       La superficie va en INGLÉS (`CLAUDE.md` regla 4); lo que se lee, en castellano.
40import argparse, concurrent.futures as cf, datetime, glob, json, os, re, ssl, subprocess, sys
41import threading, time, urllib.parse, urllib.request
42
43RAIZ = os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
44
45
46def recetas_git():
47    """(receta, repo, commit, version) de cada receta que pinea un repo git."""
48    out = []
49    for r in sorted(set(glob.glob(os.path.join(RAIZ, 'recipes/*.toml'))
50                        + glob.glob(os.path.join(RAIZ, 'recipes/*/*.toml')))):
51        txt = open(r, encoding='utf-8', errors='replace').read()
52        repo = re.search(r'^\s*repo\s*=\s*"([^"]+)"', txt, re.M)
53        com = re.search(r'^\s*commit\s*=\s*"([^"]+)"', txt, re.M)
54        ver = re.search(r'^\s*version\s*=\s*"([^"]+)"', txt, re.M)
55        if repo and com:
56            out.append((os.path.relpath(r, RAIZ), repo.group(1), com.group(1),
57                        ver.group(1) if ver else None))
58    return out
59
60
61# Un tag de versión, sin `^{}` (el objeto desreferenciado que git lista aparte) ni prereleases.
62TAG = re.compile(r'^refs/tags/(v?\d+[\w.\-]*)$')
63PRE = re.compile(r'(?i)(alpha|beta|rc\d|-rc|dev|pre|snapshot)')
64
65
66def clave(t):
67    """Orden por versión: (1,2,10) va DESPUÉS de (1,2,9), que es donde el orden alfabético miente."""
68    nums = [int(x) for x in re.findall(r'\d+', t)[:5]]
69    return (nums + [0] * 5)[:5]
70
71
72# ── TARBALLS DE GITHUB: la mitad del punto ciego, y sin mecanismo nuevo ───────────────────────
73# El comentario de la cabecera decía que un tarball «no tiene equivalente general» y que haría
74# falta una pista por receta. Es cierto EN GENERAL y era falso para el grueso: medido el
75# 2026-10-07, de las **625 recetas con tarball, 196 son de github.com** — y para ésas la URL YA
76# CONTIENE el repo y la versión, así que el `git ls-remote --tags` que esta vigía ya hace las cubre
77# tal cual. No hacía falta API, ni token, ni pista: hacía falta LEER LA URL.
78#
79# Los otros hosts gordos son índices de directorio con forma conocida (download.kde.org 108,
80# download.gnome.org 70, ftp.gnu.org 32, x.org/xorg 29, kernel.org 18, qt.io 12). Ésos sí piden su
81# propia unidad de trabajo —un listado por host, no una pista por receta— y se CUENTAN Y SE DICEN
82# al final, que es lo que evita que las que quedan fuera del radar se lean como silencio.
83# ── TARBALLS DE FORJA: github Y gitlab, con el ls-remote que ya estaba ────────────────────────
84# El comentario de la cabecera decía que un tarball «no tiene equivalente general» y que haría
85# falta una pista por receta. Es cierto EN GENERAL y era falso para el grueso: de las 625 recetas
86# con tarball, **196 son de github.com** y **26 de gitlab.freedesktop.org**, y en esas URLs el
87# repo y la versión YA ESTÁN ESCRITOS. El `git ls-remote --tags` que esta vigía ya hacía las cubre
88# tal cual: sin API, sin token, sin pista. No hacía falta un mecanismo; hacía falta LEER LA URL.
89#
90# GitLab se reconoce por el `/-/`, que es inequívoco: todo lo que va antes es la ruta del proyecto,
91# así que vale para cualquier instancia (freedesktop, gnome, salsa, gitlab.com), no sólo las que
92# hoy aparecen en el catálogo.
93GH_TARBALL = re.compile(
94    r'^https?://(?:www\.)?(?:github\.com|codeload\.github\.com)/'
95    r'([^/]+)/([^/]+?)(?:\.git)?/'
96    r'(?:archive/(?:refs/tags/)?|releases/download/|tar\.gz/|zipball/|tarball/)'
97    r'(.+)$')
98GL_TARBALL = re.compile(
99    r'^(https?://[^/]+)/(.+?)/-/'
100    r'(?:archive/|releases/)'
101    r'(.+)$')
102SUFIJOS = ('.tar.gz', '.tar.xz', '.tar.bz2', '.tar.zst', '.tgz', '.txz', '.tbz2', '.zip', '.tar')
103SOLO_SHA = re.compile(r'^[0-9a-f]{7,40}$')
104
105
106def _forja(url):
107    """(repo, ref) si la URL es un tarball de forja git; None si no."""
108    g = GH_TARBALL.match(url)
109    if g:
110        return 'https://github.com/%s/%s' % (g.group(1), g.group(2)), _limpiar_ref(g.group(3))
111    g = GL_TARBALL.match(url)
112    if g:
113        return '%s/%s' % (g.group(1), g.group(2)), _limpiar_ref(g.group(3))
114    return None
115
116
117# ⚠ UN SEGUNDO REGEX DE TAG, MÁS LAXO, Y SÓLO PARA EL CAMINO DE LOS TARBALLS (2026-10-09).
118# `TAG` exige que el tag empiece por dígito (con `v` opcional), y eso descarta los esquemas más
119# comunes del mundo real: `llvmorg-18.1.8`, `fuse-3.16.2`, `R_2_6_0`, `release-74-2`. Medido: **53
120# recetas de tarball salían «sin-tags» contra repos que tienen cientos** —llvm-project, libexpat,
121# icu, libfuse— porque el filtro las tiraba antes de mirarlas.
122#
123# Por qué laxo acá y estricto allá, que es la parte que importa: en el camino del tarball **la URL
124# nos dice la forma que buscamos** (`llvmorg-18.1.8`), así que `forma()` acota la comparación a esa
125# serie y un regex amplio no puede colar basura — sólo compite lo que tiene el mismo prefijo y la
126# misma cantidad de números. En el camino de `repo`+`commit` no hay URL que diga la forma, así que
127# ahí la estrechez de `TAG` es la protección y se deja como está: aflojarla dejaría que un tag raro
128# gane el `max()` y cambiaría `al_dia` en 660 recetas que hoy se miden bien.
129TAG_LIBRE = re.compile(r'^refs/tags/([A-Za-z_.\-]{0,24}\d[\w.\-]*)$')
130
131
132def forma(t):
133    """(prefijo, cuántos números) de un tag. Es la 'serie' a la que pertenece.
134
135    ⚠ EXISTE POR LOS FALSOS POSITIVOS, Y SON MEDIDOS (2026-10-07). Comparar el tag más nuevo del
136    repo contra el que la URL descarga, sin más, produce basura en cuanto el repo publica tags de
137    varias series:
138        cronie          baja `cronie-1.7.2`  → decía «hay v4.3»           (otra serie entera)
139        bash-completion baja `2.18.0`        → decía «hay 20060301-alt06» (tag de empaquetado ajeno)
140        gitea           baja `v1.27.0`       → decía «hay v28.1.0`
141    Y un guardián cuyos hallazgos son casi todos falsos no se apaga: se deja de leer, y entonces
142    tampoco se ve el hallazgo verdadero. Por eso acá se prefiere CALLAR a adivinar.
143
144    La regla es la más simple que se puede explicar: sólo se comparan tags con el MISMO prefijo no
145    numérico y la MISMA cantidad de componentes numéricos. `v10.3.2` sólo contra `v#.#.#`,
146    `cronie-1.7.2` sólo contra `cronie-#.#.#`. Sesga hacia el silencio a propósito — un salto
147    `1.2` → `1.2.1` queda fuera —, y por eso lo que se descarta se CUENTA y se DICE en el informe
148    en vez de desaparecer.
149    """
150    pref = re.match(r'^[^0-9]*', t).group(0)
151    return (pref, len(re.findall(r'\d+', t)))
152
153
154def _limpiar_ref(ref):
155    """De la cola de la URL al tag: se queda con el primer segmento (`releases/download/<tag>/<fichero>`
156    trae el fichero detrás) y le quita el sufijo del archivo."""
157    ref = ref.split('/')[0]
158    for suf in SUFIJOS:
159        if ref.endswith(suf):
160            ref = ref[: -len(suf)]
161            break
162    return ref
163
164
165def recetas_tarball():
166    """(total, [(receta, repo, ref, version)] de forja, [(receta, url, version)] del resto,
167    {host: n} del resto). El `resto` NO se descarta: va al sondeador de índices."""
168    total, forja, resto, por_host = 0, [], [], {}
169    for r in sorted(set(glob.glob(os.path.join(RAIZ, 'recipes/*.toml'))
170                        + glob.glob(os.path.join(RAIZ, 'recipes/*/*.toml')))):
171        txt = open(r, encoding='utf-8', errors='replace').read()
172        m = re.search(r'^\s*tarball\s*=\s*"([^"]+)"', txt, re.M)
173        if not m:
174            continue
175        total += 1
176        url, rel = m.group(1), os.path.relpath(r, RAIZ)
177        mv = re.search(r'^\s*version\s*=\s*"([^"]+)"', txt, re.M)
178        ver = mv.group(1) if mv else None
179        f = _forja(url)
180        if f:
181            forja.append((rel, f[0], f[1], ver))
182        else:
183            resto.append((rel, url, ver))
184            host = url.split('/')[2] if '://' in url else '(sin host)'
185            por_host[host] = por_host.get(host, 0) + 1
186    return total, forja, resto, por_host
187
188
189# ── RECETAS ANCLADAS POR SU PROPIO NOMBRE ─────────────────────────────────────────────────────
190# `clang18`, `llvm21`, `lld21`, `gtk3`, `python3`: el nombre LLEVA el major, y eso no es un
191# descuido — es la decisión de quedarse en esa rama. Decirle a `clang18` que existe
192# `llvmorg-23.1.3` es cierto y es **inaccionable**: si se adoptara, la receta debería llamarse
193# `clang23`, que es otra receta.
194#
195# Medido el 2026-10-09: sin esto, el frente llvm aportaba 5 hallazgos de salto de major sobre
196# recetas cuyo nombre dice explícitamente que no van a saltar. No se ocultan —siguen en el detalle
197# del json— pero se cuentan aparte, porque un informe que mezcla «hay que mirar esto» con «esto es
198# así a propósito» obliga a filtrar a mano cada vez, y lo que se filtra a mano se deja de leer.
199def anclada_por_nombre(receta, version_actual, version_nueva):
200    """¿La receta fija su major en el nombre, y el hallazgo lo cruza?"""
201    if not (version_actual and version_nueva):
202        return False
203    m = re.match(r'^(.*?)(\d+)$', os.path.basename(receta).rsplit('.toml', 1)[0])
204    if not m:
205        return False
206    nombre_major = m.group(2)
207    act = re.findall(r'\d+', version_actual)
208    nue = re.findall(r'\d+', version_nueva)
209    if not (act and nue):
210        return False
211    # El número del nombre tiene que ser el major que la receta usa HOY — si no, el nombre habla de
212    # otra cosa (p.ej. `libxcb2` no es un major) y esto no aplica.
213    return act[0] == nombre_major and nue[0] != act[0]
214
215
216def mirar_tarball(item, timeout):
217    """Para un tarball de github: ¿hay un tag más nuevo que el que la URL descarga?"""
218    receta, repo, ref_url, version = item
219    env = dict(os.environ, GIT_TERMINAL_PROMPT='0', GIT_ASKPASS='/bin/true')
220    r = {'receta': receta, 'repo': repo, 'ref_en_url': ref_url, 'version': version,
221         'fuente': 'tarball-github'}
222    try:
223        p = subprocess.run(['git', 'ls-remote', '--tags', '--refs', repo],
224                           capture_output=True, text=True, timeout=timeout, env=env)
225    except subprocess.TimeoutExpired:
226        r['estado'] = 'timeout'
227        return r
228    if p.returncode != 0:
229        r['estado'] = 'inalcanzable'
230        err = (p.stderr or '').strip().splitlines()
231        r['error'] = err[-1][:160] if err else None
232        return r
233    tags = {}
234    for linea in p.stdout.splitlines():
235        try:
236            sha, refname = linea.split('\t', 1)
237        except ValueError:
238            continue
239        mt = TAG_LIBRE.match(refname)
240        if mt:
241            tags[mt.group(1)] = sha
242    estables = {t: v for t, v in tags.items() if not PRE.search(t)}
243    usar = estables or tags
244    r['tags'] = len(tags)
245    if not usar:
246        # Sin tags no hay con qué comparar, y decir «al día» sería inventarlo.
247        r['estado'] = 'sin-tags'
248        return r
249    # ⚠ Si la URL descarga por SHA en vez de por tag, NO se compara: un sha no tiene orden de
250    # versión, así que afirmar que está atrasado —o al día— sería adivinar.
251    if SOLO_SHA.match(ref_url):
252        r['ultimo_tag'] = max(usar, key=clave)
253        r['estado'] = 'pin-por-sha'
254        return r
255    # Sólo la MISMA SERIE (ver `forma`): comparar contra el tag más nuevo del repo sin mirar su
256    # serie daba «cronie-1.7.2 → v4.3» y «2.18.0 → 20060301-alt06». Lo descartado se informa.
257    misma = {t: v for t, v in usar.items() if forma(t) == forma(ref_url)}
258    r['tags_de_la_serie'] = len(misma)
259    _pref, _n = forma(ref_url)
260    r['serie'] = "prefijo '%s' · %d numero(s)" % (_pref, _n)
261    if not misma:
262        # El repo no publica NINGÚN tag con la forma que esta receta descarga. Puede ser que la
263        # receta use un tag raro, o que upstream cambiara de esquema. No se afirma nada.
264        r['ultimo_tag_global'] = max(usar, key=clave)
265        r['estado'] = 'sin-serie-comparable'
266        return r
267    ult = max(misma, key=clave)
268    r['ultimo_tag'] = ult
269    # Comparación NUMÉRICA, no textual: `v1.2.10` vs `v1.2.9` miente alfabéticamente. Es el mismo
270    # motivo por el que `clave()` existe para los tags del bloque de arriba.
271    r['al_dia'] = clave(ref_url) >= clave(ult)
272    if not r['al_dia'] and anclada_por_nombre(receta, ref_url, ult):
273        r['anclada_por_nombre'] = True
274    r['estado'] = 'ok'
275    return r
276
277
278# ══ SEGUNDA MITAD DEL PUNTO CIEGO: LOS ÍNDICES DE DIRECTORIO (2026-10-09) ═════════════════════
279# Tras cubrir github, quedaban 429 tarballs, y la medición dijo que NO están dispersos: 108 en
280# download.kde.org, 70 en download.gnome.org, 32 en ftp.gnu.org, 29 en xorg/x.org, 26 en
281# gitlab.freedesktop.org, 18 en kernel.org, 12 en qt.io. Dos mecanismos los cubren casi todos, y
282# el primero no es nuevo:
283#
284#   1. **gitlab.freedesktop.org es una FORJA GIT.** Sus URLs `/-/archive/<tag>/<fichero>` llevan el
285#      repo escrito igual que las de github ⇒ el mismo `ls-remote`. Cero mecanismo nuevo. (+26)
286#   2. **El resto son índices de directorio servidos por HTTP.** `https://ftp.gnu.org/gnu/bash/`
287#      lista `bash-5.3.tar.gz`; `https://download.kde.org/stable/frameworks/` lista `6.26/ 6.27/`.
288#      Un sondeador genérico los lee. (+~230)
289#
290# ⚠ **EL PYTHON DEL CORPUS NO ENCUENTRA EL BUNDLE DE CA, y falla con `CERTIFICATE_VERIFY_FAILED`**
291# (medido 2026-10-09 en la jaula). Es el mismo patrón que el `GIT_EXEC_PATH` de la regla 2 de
292# CLAUDE.md: el binario está bien, lo que falta es decirle dónde están sus cosas. En el ANFITRIÓN
293# —donde corre el latido— el python del sistema funciona sin tocar nada, así que esto se descubre
294# y sólo se fija si hace falta: cablear la ruta rompería en la otra caja.
295# ⚠ ══ EL LÍMITE DEL MÉTODO POR ÍNDICE, DICHO ANTES DE USARLO ═══════════════════════════════════
296# Un índice de directorio contesta **«¿hay un número mayor?»**, y eso NO es lo mismo que «hay una
297# release más nueva». La diferencia no es teórica, es el caso que la encontró (2026-10-09):
298#
299#   `recipes/gtk3.toml` baja de `sources/gtk+/3.24/`, y ese directorio TODAVÍA guarda las series
300#   `3.89`…`3.96` — las alfas de GTK4, de 2017. `3.94 > 3.24` es cierto numéricamente y falso
301#   semánticamente: no hay ningún GTK3 más nuevo que 3.24.
302#
303# Y **ninguna regla numérica barata lo distingue**: 94 es par (así que el par/impar de GNOME no lo
304# toca), está dentro del mismo major, y el directorio `3.94/` contiene un `gtk+-3.94.0.tar.xz` de
305# verdad, así que verificar que el fichero existe tampoco ayuda.
306#
307# ⇒ Por eso los hallazgos por índice se informan como **INDICIO**, en su propio recuento y con su
308# propia palabra, y no se suman a los de forja como si fueran la misma cosa. Un tag con su filtro de
309# prereleases es evidencia; un directorio con número mayor es una pista que alguien tiene que mirar.
310# Sobrevalorarlo es la forma de que 160 indicios buenos se tiren a la basura junto con los malos.
311#
312# Lo que SÍ se filtra, porque es una convención documentada y no una corazonada: las series de
313# minor IMPAR de download.gnome.org son inestables (`glib 2.89` lo es; `2.90` no). Quita ruido real
314# sin inventar nada.
315#
316# La pista que queda sin usar, para quien siga: los índices de Apache traen la FECHA de cada
317# entrada, y `3.24/` está tocado hace meses mientras `3.94/` lleva ocho años quieto. Ordenar por
318# fecha en vez de por número resolvería justo este caso. No se hace acá porque pide parsear el
319# formato de cada índice, y eso es su propia unidad de trabajo.
320GNOME_INESTABLE = re.compile(r'^\d+\.(\d+)$')
321
322
323def _serie_descartable(host, nombre):
324    """¿Esta serie es inestable por convención del host? Sólo se afirma donde está documentado."""
325    if 'download.gnome.org' in host:
326        m = GNOME_INESTABLE.match(nombre)
327        if m and int(m.group(1)) % 2 == 1:
328            return True
329    return False
330
331
332CANDIDATOS_CA = ('/etc/ssl/certs/ca-certificates.crt', '/etc/ssl/cert.pem',
333                 '/etc/ca-certificates/extracted/tls-ca-bundle.pem',
334                 '/etc/pki/tls/certs/ca-bundle.crt')
335
336
337def _contexto_tls():
338    """Contexto TLS que verifica, con el bundle del sistema si el de serie no sirve.
339
340    NO se desactiva la verificación en ningún camino: un índice bajado sin verificar puede decir
341    que hay una versión nueva que no existe, y esta vigía sólo vale si lo que informa es cierto.
342    """
343    ctx = ssl.create_default_context()
344    try:
345        # Sonda baratísima contra el propio almacén: si no cargó NADA, no va a verificar nada.
346        if ctx.cert_store_stats().get('x509_ca', 0) > 0:
347            return ctx
348    except Exception:
349        pass
350    for c in CANDIDATOS_CA:
351        if os.path.exists(c):
352            try:
353                return ssl.create_default_context(cafile=c)
354            except Exception:
355                continue
356    return ctx   # se devuelve el de serie: fallará ruidosamente, que es mejor que no verificar
357
358
359CTX = None            # se crea una vez en main()
360IDX_CACHE = {}
361IDX_LOCK = threading.Lock()
362HREF = re.compile(r'(?i)href="([^"?#]+)"')
363# Un segmento de ruta que ES una versión: `6.27`, `25.04.3`, `1.7`, `v1.2`. No `src`, no `individual`.
364SEG_VER = re.compile(r'^v?\d+(?:\.\d+)*$')
365
366
367def _bajar_indice(url, timeout):
368    """El listado de un directorio, CACHEADO por URL — y la caché es lo que lo hace viable: las 108
369    recetas de KDE comparten 3 directorios y las 29 de xorg comparten uno. Sin caché serían 429
370    peticiones; con caché son unas pocas docenas."""
371    with IDX_LOCK:
372        if url in IDX_CACHE:
373            return IDX_CACHE[url]
374    datos = None
375    try:
376        req = urllib.request.Request(url, headers={'User-Agent': 'takana-upstream-vigia'})
377        with urllib.request.urlopen(req, timeout=timeout, context=CTX) as f:
378            datos = f.read(4_000_000).decode('utf-8', 'replace')
379    except Exception:
380        # No se distingue «no existe» de «no se pudo bajar», y decir cuál sería inventarlo.
381        datos = None
382    with IDX_LOCK:
383        IDX_CACHE[url] = datos
384    return datos
385
386
387def _nombres(html):
388    """Los nombres de entrada de un índice. Vale para los índices de Apache/nginx y para cualquier
389    página con enlaces: se queda con el último segmento del href y le quita la barra final."""
390    fuera = set()
391    for h in HREF.findall(html):
392        n = urllib.parse.unquote(h.rstrip('/')).split('/')[-1]
393        if n and n not in ('..', '.'):
394            fuera.add(n)
395    return fuera
396
397
398# ══ LA FECHA DEL ÍNDICE: CÓMO UN INDICIO SE VUELVE EVIDENCIA (2026-10-09) ═════════════════════
399# El sondeo por índice contestaba «¿hay un número mayor?», y eso no es «hay una release más nueva».
400# El caso que lo probaba era `gtk3`, que baja de `sources/gtk+/3.24/` mientras ese directorio aún
401# guarda las alfas de GTK4. **La fecha lo resuelve, y está en el índice todo el tiempo:**
402#
403#     3.24/   2024-Jul-10 17:49      ← lo que usamos
404#     3.94/   2018-Jun-26 11:58      ← seis años MÁS VIEJO: rama muerta, no versión nueva
405#     3.89/   2017-Mar-14 00:36
406#
407# La regla, que es la que upstream no puede violar sin querer: **nadie vuelve a tocar una rama
408# abandonada.** Si lo que usamos hoy se modificó DESPUÉS que el candidato «más nuevo», el candidato
409# está muerto. Comprobado en el otro sentido con KDE, donde el hallazgo es real y la fecha lo
410# confirma: `6.27/` es de 2026-06-11 y `6.30/` de 2026-09-04.
411#
412# Tres formatos en tres servidores, y hay que cubrir los tres o la regla no se aplica donde más
413# falta: `2026-06-11 10:12` (KDE, ftp.gnu.org), `2024-Jul-10 17:49` (download.gnome.org),
414# `15-Sep-2025 10:23` (autoindex clásico de Apache).
415MESES = {m: i for i, m in enumerate(
416    ('jan', 'feb', 'mar', 'apr', 'may', 'jun', 'jul', 'aug', 'sep', 'oct', 'nov', 'dec'), 1)}
417FECHAS = (
418    re.compile(r'(\d{4})-(\d{2})-(\d{2})'),                       # 2026-06-11
419    re.compile(r'(?i)(\d{4})-([a-z]{3})-(\d{2})'),                # 2024-Jul-10
420    re.compile(r'(?i)(\d{2})-([a-z]{3})-(\d{4})'),                # 15-Sep-2025
421)
422
423
424def _fecha(txt):
425    """(año, mes, día) de la primera fecha reconocible, o None. Tupla y no `datetime` a propósito:
426    sólo se usa para ORDENAR, y un orden lexicográfico de enteros basta y no puede fallar por zona
427    horaria ni por un formato raro."""
428    m = FECHAS[0].search(txt)
429    if m:
430        return (int(m.group(1)), int(m.group(2)), int(m.group(3)))
431    m = FECHAS[1].search(txt)
432    if m and m.group(2).lower() in MESES:
433        return (int(m.group(1)), MESES[m.group(2).lower()], int(m.group(3)))
434    m = FECHAS[2].search(txt)
435    if m and m.group(2).lower() in MESES:
436        return (int(m.group(3)), MESES[m.group(2).lower()], int(m.group(1)))
437    return None
438
439
440def _entradas(html):
441    """{nombre: fecha|None} de un índice. La fecha se busca en los 200 caracteres QUE SIGUEN al
442    href, que es donde la ponen los tres autoindex; no se parte por líneas porque hay servidores
443    que mandan el índice entero en una."""
444    fuera = {}
445    for m in HREF.finditer(html):
446        n = urllib.parse.unquote(m.group(1).rstrip('/')).split('/')[-1]
447        if not n or n in ('..', '.'):
448            continue
449        # El primer href gana: si el mismo nombre aparece dos veces (icono + enlace), la fecha
450        # buena es la de la fila, y la fila empieza en el primero.
451        if n not in fuera:
452            fuera[n] = _fecha(html[m.end(): m.end() + 200])
453    return fuera
454
455
456# ⚠ **«MÁS VIEJO» NO ALCANZA: TIENE QUE SER MUCHO MÁS VIEJO, Y ESTO ES UN FALSO NEGATIVO MEDIDO.**
457# La primera versión de esta regla descartaba el candidato en cuanto su fecha fuera ANTERIOR a la de
458# lo que usamos. Rompía así:
459#
460#     glib: la serie `2.90/` salió el 2026-Oct-06 — es la nueva de verdad, con 2.90.0 y 2.90.1
461#           dentro— y `2.88/` recibió un parche (2.88.4) el 2026-Oct-08, DOS DÍAS DESPUÉS.
462#     ⇒ la regla estricta tiraba la serie nueva porque la de MANTENIMIENTO se tocó más tarde.
463#
464# Una rama que sigue recibiendo parches es normal y no debe tapar una serie nueva; una rama
465# ABANDONADA lleva años quieta (`gtk+/3.94/` es de 2018 contra `3.24/` de 2024). Entre dos días y
466# seis años hay sitio de sobra, así que el umbral no está calibrado contra ninguno de los dos casos
467# — que es justo lo que [[umbral-a-mano-envejece]] avisa de no hacer. Un año: lejos de los dos
468# bordes, y explicable en una frase.
469DIAS_ABANDONO = 365
470
471
472def _no_es_rama_muerta(fechas, actual, candidato):
473    """¿El candidato está vivo, o es una rama abandonada con número mayor?
474
475    Devuelve (veredicto, con_fecha). `con_fecha=False` significa que no se pudo comprobar: el
476    hallazgo sigue valiendo como INDICIO, pero no se asciende a evidencia. **Nunca se descarta por
477    falta de fecha**, que sería perder información real por no tener la corroboración.
478    """
479    fa, fc = fechas.get(actual), fechas.get(candidato)
480    if fa is None or fc is None:
481        return True, False
482    dia_a = datetime.date(*fa).toordinal()
483    dia_c = datetime.date(*fc).toordinal()
484    return (dia_a - dia_c) <= DIAS_ABANDONO, True
485
486
487def _partir(nombre):
488    """`bash-5.3.tar.gz` → ('bash', '5.3'). None si no tiene forma de <algo>-<versión><sufijo>."""
489    for suf in SUFIJOS:
490        if nombre.endswith(suf):
491            base = nombre[: -len(suf)]
492            m = re.match(r'^(.*?)[-_](v?\d[\w.]*?)$', base)
493            return (m.group(1), m.group(2)) if m else None
494    return None
495
496
497def mirar_indice(item, timeout):
498    """¿Hay algo más nuevo en el índice del que este tarball cuelga?
499
500    Dos sondeos, y se informa el mayor de los dos:
501      (a) EN SU PROPIO DIRECTORIO, un `<mismo-nombre>-<versión-mayor>`. Cubre ftp.gnu.org, xorg,
502          kernel.org: un directorio por paquete con todas sus versiones.
503      (b) UNA SERIE MÁS NUEVA. Cubre KDE y GNOME, donde la versión está en un SEGMENTO DE LA RUTA
504          (`stable/frameworks/6.27/`, `sources/libadwaita/1.7/`) y el directorio propio sólo
505          tiene la serie vieja — mirarlo diría «al día» para siempre.
506
507    Y los dos pasan por el filtro de FECHA (`_no_es_rama_muerta`): un número mayor con fecha
508    ANTERIOR a la que usamos es una rama abandonada, no una versión nueva. Si no hay fecha el
509    hallazgo no se descarta —sería perder información real— pero no se asciende a evidencia.
510    """
511    receta, url, version = item
512    r = {'receta': receta, 'tarball': url, 'version': version, 'fuente': 'indice'}
513    dir_url, _, fichero = url.rpartition('/')
514    partido = _partir(fichero)
515    if not partido:
516        # Sin `<nombre>-<versión>` en el fichero no hay con qué comparar. Pasa con
517        # files.pythonhosted.org, cuyas URLs llevan un hash en la ruta.
518        r['estado'] = 'fichero-no-versionado'
519        return r
520    stem, ver_fichero = partido
521    r['stem'], r['version_en_url'] = stem, ver_fichero
522    hallazgos = []
523    muertas = 0
524
525    # (a) mismo directorio
526    html = _bajar_indice(dir_url + '/', timeout)
527    if html:
528        ent = _entradas(html)
529        # Las fechas de los FICHEROS, indexadas por su versión, para poder comparar fecha a fecha.
530        fechas_ver, cand = {}, []
531        for n, f in ent.items():
532            pp = _partir(n)
533            if pp and pp[0] == stem and forma(pp[1]) == forma(ver_fichero):
534                cand.append(pp[1])
535                if pp[1] not in fechas_ver or f:
536                    fechas_ver[pp[1]] = f
537        if cand:
538            mejor = max(cand, key=clave)
539            r['mejor_en_su_dir'] = mejor
540            if clave(mejor) > clave(ver_fichero):
541                vivo, con_fecha = _no_es_rama_muerta(fechas_ver, ver_fichero, mejor)
542                if vivo:
543                    hallazgos.append(('fichero', mejor, con_fecha))
544                else:
545                    muertas += 1
546
547    # (b) serie más nueva: el segmento de versión más EXTERNO de la ruta
548    partes = urllib.parse.urlparse(url)
549    segs = partes.path.split('/')
550    idx = next((i for i, s in enumerate(segs[:-1]) if SEG_VER.match(s)), None)
551    if idx is not None:
552        padre = '%s://%s%s/' % (partes.scheme, partes.netloc, '/'.join(segs[:idx]))
553        html2 = _bajar_indice(padre, timeout)
554        if html2:
555            ent2 = _entradas(html2)
556            series = [n for n in ent2
557                      if SEG_VER.match(n) and forma(n) == forma(segs[idx])
558                      and not _serie_descartable(partes.netloc, n)]
559            if series:
560                mejor_s = max(series, key=clave)
561                r['serie_en_url'] = segs[idx]
562                r['mejor_serie'] = mejor_s
563                if clave(mejor_s) > clave(segs[idx]):
564                    vivo, con_fecha = _no_es_rama_muerta(ent2, segs[idx], mejor_s)
565                    if vivo:
566                        hallazgos.append(('serie', mejor_s, con_fecha))
567                    else:
568                        # ⚠ ESTE es el caso `gtk+`: `3.94/` existe, es un número mayor, tiene un
569                        # tarball dentro… y es de 2018 mientras `3.24/` es de 2024.
570                        muertas += 1
571                        r['descartado_por_fecha'] = mejor_s
572
573    if muertas:
574        r['ramas_muertas_descartadas'] = muertas
575    if not hallazgos:
576        if 'mejor_en_su_dir' in r or 'mejor_serie' in r:
577            r['al_dia'] = True
578            r['estado'] = 'ok'
579        else:
580            # Se llegó al índice pero no había nada comparable, o no se llegó. No se afirma nada.
581            r['estado'] = 'sin-comparable' if (html or '') else 'inalcanzable'
582        return r
583    clase, valor, con_fecha = max(hallazgos, key=lambda h: clave(h[1]))
584    r['al_dia'] = False
585    r['nuevo'] = valor
586    r['nuevo_es'] = clase
587    r['con_fecha'] = con_fecha
588    actual = r.get('serie_en_url') if clase == 'serie' else ver_fichero
589    if anclada_por_nombre(receta, actual, valor):
590        r['anclada_por_nombre'] = True
591    r['estado'] = 'ok'
592    return r
593
594
595def mirar(item, timeout):
596    receta, repo, commit, version = item
597    env = dict(os.environ, GIT_TERMINAL_PROMPT='0', GIT_ASKPASS='/bin/true')
598    r = {'receta': receta, 'repo': repo, 'commit': commit, 'version': version}
599    try:
600        p = subprocess.run(['git', 'ls-remote', '--tags', '--refs', '--heads', repo],
601                           capture_output=True, text=True, timeout=timeout, env=env)
602    except subprocess.TimeoutExpired:
603        r['estado'] = 'timeout'
604        return r
605    except FileNotFoundError:
606        r['estado'] = 'sin-git'
607        return r
608    if p.returncode != 0:
609        # No se puede alcanzar: puede ser red, puede ser que el repo desapareció. No se distingue
610        # desde acá y decir cuál sería inventar.
611        r['estado'] = 'inalcanzable'
612        r['error'] = (p.stderr or '').strip().splitlines()[-1:] and (p.stderr.strip().splitlines()[-1])[:160]
613        return r
614    tags, cabezas = {}, {}
615    for linea in p.stdout.splitlines():
616        try:
617            sha, ref = linea.split('\t', 1)
618        except ValueError:
619            continue
620        m = TAG.match(ref)
621        if m:
622            tags[m.group(1)] = sha
623        elif ref.startswith('refs/heads/'):
624            cabezas[ref[len('refs/heads/'):]] = sha
625    estables = {t: s for t, s in tags.items() if not PRE.search(t)}
626    usar = estables or tags
627    r['tags'] = len(tags)
628    if usar:
629        ult = max(usar, key=clave)
630        r['ultimo_tag'] = ult
631        r['ultimo_tag_sha'] = usar[ult]
632        r['al_dia'] = usar[ult].startswith(commit) or commit.startswith(usar[ult][:12])
633    # La rama por defecto no se puede saber sin `HEAD`, pero con `main`/`master` se cubre casi todo;
634    # si no está, se informa el conjunto y no se elige uno al azar.
635    for nombre in ('main', 'master', 'trunk'):
636        if nombre in cabezas:
637            r['rama'] = nombre
638            r['cabeza'] = cabezas[nombre]
639            r['pin_es_la_cabeza'] = cabezas[nombre].startswith(commit) or commit.startswith(cabezas[nombre][:12])
640            break
641    r['estado'] = 'ok'
642    return r
643
644
645def main():
646    ap = argparse.ArgumentParser(description='¿qué se movió río arriba desde que pineamos?')
647    ap.add_argument('--json', help='escribir el informe en este fichero')
648    ap.add_argument('--only', default='', help='sólo las recetas cuyo repo contenga esta cadena')
649    ap.add_argument('--jobs', type=int, default=16)
650    ap.add_argument('--timeout', type=int, default=25)
651    ap.add_argument('--no-http', action='store_true',
652                    help='sólo ls-remote: salta el sondeo de índices por HTTP')
653    a = ap.parse_args()
654
655    global CTX
656    CTX = _contexto_tls()
657    items = recetas_git()
658    tb_total, tb_forja, tb_resto, tb_por_host = recetas_tarball()
659    if a.only:
660        items = [i for i in items if a.only in i[1]]
661        tb_forja = [i for i in tb_forja if a.only in i[1]]
662        tb_resto = [i for i in tb_resto if a.only in i[1]]
663    if a.no_http:
664        tb_resto = []
665    t0 = time.time()
666    res = []
667    with cf.ThreadPoolExecutor(max_workers=a.jobs) as ex:
668        for r in ex.map(lambda i: mirar(i, a.timeout), items):
669            res.append(r)
670        # Los tarballs de forja van por el MISMO pool y el mismo `ls-remote`: son la misma
671        # pregunta con la pista leída de la URL en vez del campo `repo`.
672        res_tb = list(ex.map(lambda i: mirar_tarball(i, a.timeout), tb_forja))
673        # Y el resto por índice HTTP. Van DESPUÉS a propósito: el `ls-remote` no depende de la
674        # caché de índices, así que si el HTTP se atasca lo de arriba ya está contestado.
675        res_ix = list(ex.map(lambda i: mirar_indice(i, a.timeout), tb_resto))
676    seg = int(time.time() - t0)
677
678    ok = [r for r in res if r['estado'] == 'ok']
679    atras_tag = [r for r in ok if r.get('ultimo_tag') and not r.get('al_dia')]
680    atras_rama = [r for r in ok if 'pin_es_la_cabeza' in r and not r['pin_es_la_cabeza']]
681    inalcanzables = [r for r in res if r['estado'] != 'ok']
682
683    print('== upstream: %d receta(s) con repo git · %ds ==' % (len(res), seg))
684    print('   %d con un tag más nuevo que el pin' % len(atras_tag))
685    print('   %d con la rama movida desde el pin' % len(atras_rama))
686    if inalcanzables:
687        print('   ⚠ %d no se pudieron consultar' % len(inalcanzables))
688    tb_ok = [r for r in res_tb if r['estado'] == 'ok']
689    tb_atras = [r for r in tb_ok if not r.get('al_dia') and not r.get('anclada_por_nombre')]
690    tb_ancladas = [r for r in tb_ok if not r.get('al_dia') and r.get('anclada_por_nombre')]
691    tb_sha = [r for r in res_tb if r['estado'] == 'pin-por-sha']
692    tb_mudos = [r for r in res_tb if r['estado'] in ('sin-tags', 'inalcanzable', 'timeout')]
693    tb_sin_serie = [r for r in res_tb if r['estado'] == 'sin-serie-comparable']
694    ix_ok = [r for r in res_ix if r['estado'] == 'ok']
695    ix_atras = [r for r in ix_ok if not r.get('al_dia') and not r.get('anclada_por_nombre')]
696    ix_ancladas = [r for r in ix_ok if not r.get('al_dia') and r.get('anclada_por_nombre')]
697    ix_mudos = [r for r in res_ix if r['estado'] != 'ok']
698    # ⚠ «Fuera del radar» = aquello de lo que NO hay respuesta: total menos lo que contestó cada
699    # mecanismo. Se cuenta así, y no restando lo intentado, porque intentar no es contestar — un
700    # índice que no respondió deja la receta igual de a oscuras que no haberla mirado.
701    tb_fuera = tb_total - len(tb_ok) - len(ix_ok)
702    print('\n== tarballs: %d receta(s) con tarball ==' % tb_total)
703    print('   %d de FORJA git (github/gitlab) — la URL trae el repo ⇒ sirve el mismo ls-remote'
704          % len(res_tb))
705    print('     %d con una versión más nueva que la que descargan' % len(tb_atras))
706    if tb_ancladas or ix_ancladas:
707        print('     %d ANCLADAS POR SU NOMBRE (clang18, llvm21…): el salto de major es a propósito'
708              % (len(tb_ancladas) + len(ix_ancladas)))
709    if tb_sha:
710        print('     %d pinean por SHA ⇒ no se comparan: un sha no tiene orden de versión' % len(tb_sha))
711    if tb_sin_serie:
712        print('     %d sin tag de la MISMA serie que la URL ⇒ no se comparan (ver `forma`)'
713              % len(tb_sin_serie))
714    if tb_mudos:
715        print('     %d sin tags o inalcanzables ⇒ no se afirma nada' % len(tb_mudos))
716    print('   %d por ÍNDICE de directorio HTTP (%d peticiones distintas, el resto por caché)'
717          % (len(res_ix), len([k for k, v in IDX_CACHE.items() if v is not None])))
718    # Tres niveles de confianza, y nombrarlos es lo que hace usable el montón: un número mayor
719    # CON FECHA que lo respalda no es lo mismo que uno sin comprobar, y ninguno de los dos es un
720    # tag firmado. Mezclarlos haría que los 231 buenos se tiraran junto con los 30 dudosos.
721    ix_con_fecha = [r for r in ix_atras if r.get('con_fecha')]
722    ix_sin_fecha = [r for r in ix_atras if not r.get('con_fecha')]
723    ix_muertas = [r for r in res_ix if r.get('ramas_muertas_descartadas')]
724    print('     %d con un número MAYOR río arriba, de los cuales:' % len(ix_atras))
725    print('       %d con la FECHA del índice respaldándolo (el candidato no es rama abandonada)'
726          % len(ix_con_fecha))
727    if ix_sin_fecha:
728        print('       %d sin fecha comprobable ⇒ INDICIO a secas, pide una mirada' % len(ix_sin_fecha))
729    if ix_muertas:
730        print('     %d con una rama ABANDONADA descartada por fecha (p.ej. gtk+ 3.94, de 2018)'
731              % len(ix_muertas))
732    if ix_mudos:
733        # Se dice en vez de desaparecer: un índice que no se pudo leer NO es un «al día».
734        porqué = {}
735        for r in ix_mudos:
736            porqué[r['estado']] = porqué.get(r['estado'], 0) + 1
737        print('     %d sin respuesta útil: %s' % (len(ix_mudos),
738              ', '.join('%d %s' % (n, k) for k, n in sorted(porqué.items(), key=lambda kv: -kv[1]))))
739    if ix_atras:
740        print('\n   por índice, con algo más nuevo (los 20 primeros):')
741        for r in sorted(ix_atras, key=lambda x: x['receta'])[:20]:
742            # ⚠ Se compara CONTRA LO MISMO. Si el hallazgo es una SERIE (la versión vive en un
743            # segmento de la ruta, como en KDE y GNOME), lo que tiene hoy es la serie de la URL, no
744            # la versión del fichero: imprimir «baja 6.27.0 · hay 6.30» sugiere que 6.27.0 y 6.30
745            # son la misma magnitud y no lo son. Mezclar las dos escalas en una línea es la forma
746            # barata de que un informe correcto se lea como un error.
747            if r['nuevo_es'] == 'serie':
748                tiene, etiq = r.get('serie_en_url', '?'), 'serie'
749            else:
750                tiene, etiq = r.get('version_en_url', '?'), 'fichero'
751            print('     %-46s %-8s %-12s → %s' % (r['receta'], etiq, tiene, r['nuevo']))
752    if tb_atras:
753        print('\n   de forja, con versión nueva (los 20 primeros):')
754        for r in sorted(tb_atras, key=lambda x: x['receta'])[:20]:
755            print('     %-42s baja %-14s hay %s' % (r['receta'], r['ref_en_url'], r['ultimo_tag']))
756
757    if a.json:
758        with open(a.json, 'w', encoding='utf-8') as f:
759            # schema /2: se AÑADEN las claves de tarball y se conservan las de /1 —
760            # `tarballs_sin_mirar` incluido— porque el resumen de `cosecha-cron.sh` las lee y
761            # renombrarlas rompería al consumidor sin avisar. Lo que cambia es su significado a
762            # mejor: antes era «todos los tarballs», ahora es «los que siguen fuera del radar».
763            # schema /3: se AÑADEN las claves del sondeo por índice y se CONSERVAN las de /1 y
764            # /2 —`tarballs_sin_mirar` y `tarballs_con_version_nueva` incluidas— porque el resumen
765            # de `cosecha-cron.sh` las lee y renombrarlas rompería al consumidor sin avisar. Lo que
766            # cambia es su significado, a mejor: `tarballs_con_version_nueva` ahora suma los dos
767            # mecanismos, y `tarballs_sin_mirar` son los que NINGUNO pudo contestar.
768            json.dump({'schema': 'takana-upstream/3',
769                       'total': len(res), 'segundos': seg,
770                       'con_tag_nuevo': len(atras_tag), 'con_rama_movida': len(atras_rama),
771                       'inalcanzables': len(inalcanzables),
772                       'tarballs_total': tb_total,
773                       'tarballs_forja': len(res_tb),
774                       'tarballs_forja_contestados': len(tb_ok),
775                       'tarballs_forja_con_version_nueva': len(tb_atras),
776                       'tarballs_forja_sin_serie': len(tb_sin_serie),
777                       'tarballs_forja_pin_por_sha': len(tb_sha),
778                       'tarballs_forja_mudos': len(tb_mudos),
779                       'tarballs_indice': len(res_ix),
780                       'tarballs_indice_contestados': len(ix_ok),
781                       'tarballs_indice_con_novedad': len(ix_atras),
782                       'tarballs_indice_mudos': len(ix_mudos),
783                       # ⚠ `tarballs_con_version_nueva` SIGUE SIENDO SÓLO LA DE FORJA, como en
784                       # /2. Sumarle los indicios por índice habría cambiado el significado de una
785                       # clave que el latido ya publica, y además mezcla evidencia con pista: 100
786                       # tags verificados y 266 números mayores no son la misma afirmación.
787                       'tarballs_con_version_nueva': len(tb_atras),
788                       'tarballs_con_indicio': len(ix_atras),
789                       'tarballs_ancladas_por_nombre': len(tb_ancladas) + len(ix_ancladas),
790                       'tarballs_indicio_con_fecha': len(ix_con_fecha),
791                       'tarballs_indicio_sin_fecha': len(ix_sin_fecha),
792                       'tarballs_ramas_muertas_descartadas': len(ix_muertas),
793                       'tarballs_sin_mirar': tb_fuera,
794                       'tarballs_por_host_no_forja': dict(sorted(tb_por_host.items(),
795                                                                 key=lambda kv: -kv[1])),
796                       'peticiones_http': len([k for k, v in IDX_CACHE.items() if v is not None]),
797                       'detalle': sorted(res, key=lambda x: x['receta']),
798                       'detalle_tarballs': sorted(res_tb, key=lambda x: x['receta']),
799                       'detalle_indices': sorted(res_ix, key=lambda x: x['receta'])}, f,
800                      ensure_ascii=False, indent=1)
801        print('\n   informe: %s' % a.json)
802    # Sale 0 siempre: esto INFORMA. Un cron que falle porque upstream sacó una versión nueva sería
803    # un guardián que grita todos los días y al que nadie mira (la lección de los guardianes ruidosos).
804    return 0
805
806
807if __name__ == '__main__':
808    sys.exit(main())