> Una distribución Linux construida con un laboratorio funcional y hermético en el > sótano, y una terminal mutable, clásica y anárquica en el piso de arriba — diseñada > desde el suelo para que una IA programadora entienda, modifique y co…
feat(fuentes): los tarballs pasan de 625 ciegos a 44 — y lo que es indicio se llama indicio
Cierra el punto ciego que quedaba. Dos mecanismos, y el primero tampoco es nuevo:
**1. La forja, generalizada a GitLab.** `gitlab.freedesktop.org` es una forja git: sus URLs
`/-/archive/<tag>/…` llevan el repo escrito igual que las de github, así que el mismo `ls-remote`
las cubre. Se reconoce por el `/-/`, que es inequívoco, de modo que vale para cualquier instancia
(freedesktop, gnome, salsa, alpine) y no sólo para las que hoy hay en el catálogo. 196 → 223.
**2. Un sondeador de índices de directorio HTTP** para el resto: 402 recetas con **236 peticiones**
gracias a la caché por directorio —las 108 de KDE comparten 13 y las 29 de xorg comparten 3—. Hace
dos sondeos: un `<nombre>-<versión-mayor>` en su propio directorio (ftp.gnu.org, xorg, kernel.org)
y una SERIE más nueva cuando la versión vive en un segmento de la ruta (KDE `stable/frameworks/6.27/`,
GNOME `sources/libadwaita/1.7/`), donde mirar sólo el directorio propio diría «al día» para siempre.
⚠ **Y el regex de tags era un bug mío**, no una limitación: `TAG` exige que el tag empiece por
dígito, así que descartaba `llvmorg-18.1.8`, `R_2_6_0`, `fuse-3.16.2`, `release-74-2` — y **53
recetas salían «sin-tags» contra repos que tienen cientos**. `TAG_LIBRE` acepta el prefijo, y sólo
en el camino del tarball: ahí la URL nos dice la forma que buscamos y `forma()` acota a esa serie,
mientras en el camino `repo`+`commit` no hay URL que lo diga y la estrechez es la protección.
Aflojarla allí cambiaría `al_dia` en 660 recetas que hoy se miden bien.
```
625 tarballs → 581 contestados · 44 sin mirar (eran 625 ciegos el 2026-10-07)
223 de forja → 150 con versión nueva, 1 sin contestar
402 por índice → 265 con un número mayor
104 s · 236 peticiones HTTP
```
**Dos clases de ruido se etiquetan en vez de esconderse, y es la mitad del trabajo:**
- **Indicio ≠ evidencia.** Un índice contesta «¿hay un número mayor?», que NO es «hay una release
más nueva». El caso que lo encontró: `gtk3` baja de `sources/gtk+/3.24/` y ese directorio todavía
guarda las series `3.89`…`3.96` — las alfas de GTK4, de 2017. `3.94 > 3.24` es cierto y
semánticamente falso, y ninguna regla numérica barata lo distingue (94 es par, mismo major, y el
directorio contiene un tarball de verdad). ⇒ los hallazgos por índice van en su propio recuento y
con su propia palabra. La pista sin usar, para quien siga: los índices traen la FECHA, y `3.24/`
está tocado hace meses mientras `3.94/` lleva ocho años quieto.
- **Recetas ancladas por su nombre.** `clang18`, `llvm21`, `lld21`: el nombre lleva el major y eso
es la decisión de quedarse ahí. Decirle a `clang18` que existe `llvmorg-23.1.3` es cierto e
inaccionable — si se adoptara, la receta se llamaría `clang23`. Siete, contadas aparte.
Y el resumen del latido deja de sumar peras con manzanas: dice «150 con versión nueva · 265
indicios» en vez de 415. `tarballs_con_version_nueva` conserva su significado de /2 (sólo forja)
porque el latido ya publica esa clave.
⚠ El python del corpus no encuentra el bundle de CA y falla con `CERTIFICATE_VERIFY_FAILED` — mismo
patrón que el `GIT_EXEC_PATH` de la regla 2. Se descubre y se fija sólo si hace falta: en el
anfitrión, donde corre el latido, el python del sistema ya funciona, y cablear la ruta rompería
allí. La verificación TLS no se desactiva en ningún camino: un índice sin verificar podría anunciar
una versión que no existe, y esta vigía sólo vale si lo que dice es cierto.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
diff --git a/scripts/farm/cosecha-cron.sh b/scripts/farm/cosecha-cron.sh
index 4880b93f..4e038b90 100755
--- a/scripts/farm/cosecha-cron.sh
+++ b/scripts/farm/cosecha-cron.sh
@@ -390,7 +390,7 @@ if [ -x "$HAMMER" ]; then
mv work/.upstream.tmp docs/state/upstream.json
echo " upstream.json ✓ $(python3 -c "
import json; d=json.load(open('docs/state/upstream.json'))
-print(f\"{d['con_tag_nuevo']} git con tag nuevo · {d.get('tarballs_con_version_nueva','?')} tarballs con version nueva · {d['tarballs_sin_mirar']} tarballs AUN sin mirar\")" 2>/dev/null || echo '(sin resumen)')"
+print(f\"{d['con_tag_nuevo']} git con tag nuevo · {d.get('tarballs_con_version_nueva','?')} tarballs con version nueva · {d.get('tarballs_con_indicio','?')} indicios por indice · {d['tarballs_sin_mirar']} sin mirar\")" 2>/dev/null || echo '(sin resumen)')"
else
echo " ⚠ upstream-vigia falló o salió vacío — NO piso el informe anterior"
sed -n '$p' work/.upstream-vigia.salida 2>/dev/null | sed 's/^/ /'
diff --git a/scripts/fuentes/upstream-vigia.py b/scripts/fuentes/upstream-vigia.py
index 8997fcfc..0e9b4e13 100755
--- a/scripts/fuentes/upstream-vigia.py
+++ b/scripts/fuentes/upstream-vigia.py
@@ -37,7 +37,8 @@
#
# Uso: scripts/fuentes/upstream-vigia.py [--json <fichero>] [--solo tawasuyu] [--jobs N]
# La superficie va en INGLÉS (`CLAUDE.md` regla 4); lo que se lee, en castellano.
-import argparse, concurrent.futures as cf, glob, json, os, re, subprocess, sys, time
+import argparse, concurrent.futures as cf, glob, json, os, re, ssl, subprocess, sys
+import threading, time, urllib.parse, urllib.request
RAIZ = os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
@@ -79,15 +80,55 @@ def clave(t):
# download.gnome.org 70, ftp.gnu.org 32, x.org/xorg 29, kernel.org 18, qt.io 12). Ésos sí piden su
# propia unidad de trabajo —un listado por host, no una pista por receta— y se CUENTAN Y SE DICEN
# al final, que es lo que evita que las que quedan fuera del radar se lean como silencio.
+# ── TARBALLS DE FORJA: github Y gitlab, con el ls-remote que ya estaba ────────────────────────
+# El comentario de la cabecera decía que un tarball «no tiene equivalente general» y que haría
+# falta una pista por receta. Es cierto EN GENERAL y era falso para el grueso: de las 625 recetas
+# con tarball, **196 son de github.com** y **26 de gitlab.freedesktop.org**, y en esas URLs el
+# repo y la versión YA ESTÁN ESCRITOS. El `git ls-remote --tags` que esta vigía ya hacía las cubre
+# tal cual: sin API, sin token, sin pista. No hacía falta un mecanismo; hacía falta LEER LA URL.
+#
+# GitLab se reconoce por el `/-/`, que es inequívoco: todo lo que va antes es la ruta del proyecto,
+# así que vale para cualquier instancia (freedesktop, gnome, salsa, gitlab.com), no sólo las que
+# hoy aparecen en el catálogo.
GH_TARBALL = re.compile(
r'^https?://(?:www\.)?(?:github\.com|codeload\.github\.com)/'
r'([^/]+)/([^/]+?)(?:\.git)?/'
r'(?:archive/(?:refs/tags/)?|releases/download/|tar\.gz/|zipball/|tarball/)'
r'(.+)$')
+GL_TARBALL = re.compile(
+ r'^(https?://[^/]+)/(.+?)/-/'
+ r'(?:archive/|releases/)'
+ r'(.+)$')
SUFIJOS = ('.tar.gz', '.tar.xz', '.tar.bz2', '.tar.zst', '.tgz', '.txz', '.tbz2', '.zip', '.tar')
SOLO_SHA = re.compile(r'^[0-9a-f]{7,40}$')
+def _forja(url):
+ """(repo, ref) si la URL es un tarball de forja git; None si no."""
+ g = GH_TARBALL.match(url)
+ if g:
+ return 'https://github.com/%s/%s' % (g.group(1), g.group(2)), _limpiar_ref(g.group(3))
+ g = GL_TARBALL.match(url)
+ if g:
+ return '%s/%s' % (g.group(1), g.group(2)), _limpiar_ref(g.group(3))
+ return None
+
+
+# ⚠ UN SEGUNDO REGEX DE TAG, MÁS LAXO, Y SÓLO PARA EL CAMINO DE LOS TARBALLS (2026-10-09).
+# `TAG` exige que el tag empiece por dígito (con `v` opcional), y eso descarta los esquemas más
+# comunes del mundo real: `llvmorg-18.1.8`, `fuse-3.16.2`, `R_2_6_0`, `release-74-2`. Medido: **53
+# recetas de tarball salían «sin-tags» contra repos que tienen cientos** —llvm-project, libexpat,
+# icu, libfuse— porque el filtro las tiraba antes de mirarlas.
+#
+# Por qué laxo acá y estricto allá, que es la parte que importa: en el camino del tarball **la URL
+# nos dice la forma que buscamos** (`llvmorg-18.1.8`), así que `forma()` acota la comparación a esa
+# serie y un regex amplio no puede colar basura — sólo compite lo que tiene el mismo prefijo y la
+# misma cantidad de números. En el camino de `repo`+`commit` no hay URL que diga la forma, así que
+# ahí la estrechez de `TAG` es la protección y se deja como está: aflojarla dejaría que un tag raro
+# gane el `max()` y cambiaría `al_dia` en 660 recetas que hoy se miden bien.
+TAG_LIBRE = re.compile(r'^refs/tags/([A-Za-z_.\-]{0,24}\d[\w.\-]*)$')
+
+
def forma(t):
"""(prefijo, cuántos números) de un tag. Es la 'serie' a la que pertenece.
@@ -122,8 +163,9 @@ def _limpiar_ref(ref):
def recetas_tarball():
- """(total, [(receta, repo_derivado, ref_en_url, version)] de github, {host: n} de las que no)."""
- total, gh, otros = 0, [], {}
+ """(total, [(receta, repo, ref, version)] de forja, [(receta, url, version)] del resto,
+ {host: n} del resto). El `resto` NO se descarta: va al sondeador de índices."""
+ total, forja, resto, por_host = 0, [], [], {}
for r in sorted(set(glob.glob(os.path.join(RAIZ, 'recipes/*.toml'))
+ glob.glob(os.path.join(RAIZ, 'recipes/*/*.toml')))):
txt = open(r, encoding='utf-8', errors='replace').read()
@@ -133,14 +175,42 @@ def recetas_tarball():
total += 1
url, rel = m.group(1), os.path.relpath(r, RAIZ)
mv = re.search(r'^\s*version\s*=\s*"([^"]+)"', txt, re.M)
- g = GH_TARBALL.match(url)
- if g:
- gh.append((rel, 'https://github.com/%s/%s' % (g.group(1), g.group(2)),
- _limpiar_ref(g.group(3)), mv.group(1) if mv else None))
+ ver = mv.group(1) if mv else None
+ f = _forja(url)
+ if f:
+ forja.append((rel, f[0], f[1], ver))
else:
+ resto.append((rel, url, ver))
host = url.split('/')[2] if '://' in url else '(sin host)'
- otros[host] = otros.get(host, 0) + 1
- return total, gh, otros
+ por_host[host] = por_host.get(host, 0) + 1
+ return total, forja, resto, por_host
+
+
+# ── RECETAS ANCLADAS POR SU PROPIO NOMBRE ─────────────────────────────────────────────────────
+# `clang18`, `llvm21`, `lld21`, `gtk3`, `python3`: el nombre LLEVA el major, y eso no es un
+# descuido — es la decisión de quedarse en esa rama. Decirle a `clang18` que existe
+# `llvmorg-23.1.3` es cierto y es **inaccionable**: si se adoptara, la receta debería llamarse
+# `clang23`, que es otra receta.
+#
+# Medido el 2026-10-09: sin esto, el frente llvm aportaba 5 hallazgos de salto de major sobre
+# recetas cuyo nombre dice explícitamente que no van a saltar. No se ocultan —siguen en el detalle
+# del json— pero se cuentan aparte, porque un informe que mezcla «hay que mirar esto» con «esto es
+# así a propósito» obliga a filtrar a mano cada vez, y lo que se filtra a mano se deja de leer.
+def anclada_por_nombre(receta, version_actual, version_nueva):
+ """¿La receta fija su major en el nombre, y el hallazgo lo cruza?"""
+ if not (version_actual and version_nueva):
+ return False
+ m = re.match(r'^(.*?)(\d+)$', os.path.basename(receta).rsplit('.toml', 1)[0])
+ if not m:
+ return False
+ nombre_major = m.group(2)
+ act = re.findall(r'\d+', version_actual)
+ nue = re.findall(r'\d+', version_nueva)
+ if not (act and nue):
+ return False
+ # El número del nombre tiene que ser el major que la receta usa HOY — si no, el nombre habla de
+ # otra cosa (p.ej. `libxcb2` no es un major) y esto no aplica.
+ return act[0] == nombre_major and nue[0] != act[0]
def mirar_tarball(item, timeout):
@@ -166,7 +236,7 @@ def mirar_tarball(item, timeout):
sha, refname = linea.split('\t', 1)
except ValueError:
continue
- mt = TAG.match(refname)
+ mt = TAG_LIBRE.match(refname)
if mt:
tags[mt.group(1)] = sha
estables = {t: v for t, v in tags.items() if not PRE.search(t)}
@@ -199,6 +269,213 @@ def mirar_tarball(item, timeout):
# Comparación NUMÉRICA, no textual: `v1.2.10` vs `v1.2.9` miente alfabéticamente. Es el mismo
# motivo por el que `clave()` existe para los tags del bloque de arriba.
r['al_dia'] = clave(ref_url) >= clave(ult)
+ if not r['al_dia'] and anclada_por_nombre(receta, ref_url, ult):
+ r['anclada_por_nombre'] = True
+ r['estado'] = 'ok'
+ return r
+
+
+# ══ SEGUNDA MITAD DEL PUNTO CIEGO: LOS ÍNDICES DE DIRECTORIO (2026-10-09) ═════════════════════
+# Tras cubrir github, quedaban 429 tarballs, y la medición dijo que NO están dispersos: 108 en
+# download.kde.org, 70 en download.gnome.org, 32 en ftp.gnu.org, 29 en xorg/x.org, 26 en
+# gitlab.freedesktop.org, 18 en kernel.org, 12 en qt.io. Dos mecanismos los cubren casi todos, y
+# el primero no es nuevo:
+#
+# 1. **gitlab.freedesktop.org es una FORJA GIT.** Sus URLs `/-/archive/<tag>/<fichero>` llevan el
+# repo escrito igual que las de github ⇒ el mismo `ls-remote`. Cero mecanismo nuevo. (+26)
+# 2. **El resto son índices de directorio servidos por HTTP.** `https://ftp.gnu.org/gnu/bash/`
+# lista `bash-5.3.tar.gz`; `https://download.kde.org/stable/frameworks/` lista `6.26/ 6.27/`.
+# Un sondeador genérico los lee. (+~230)
+#
+# ⚠ **EL PYTHON DEL CORPUS NO ENCUENTRA EL BUNDLE DE CA, y falla con `CERTIFICATE_VERIFY_FAILED`**
+# (medido 2026-10-09 en la jaula). Es el mismo patrón que el `GIT_EXEC_PATH` de la regla 2 de
+# CLAUDE.md: el binario está bien, lo que falta es decirle dónde están sus cosas. En el ANFITRIÓN
+# —donde corre el latido— el python del sistema funciona sin tocar nada, así que esto se descubre
+# y sólo se fija si hace falta: cablear la ruta rompería en la otra caja.
+# ⚠ ══ EL LÍMITE DEL MÉTODO POR ÍNDICE, DICHO ANTES DE USARLO ═══════════════════════════════════
+# Un índice de directorio contesta **«¿hay un número mayor?»**, y eso NO es lo mismo que «hay una
+# release más nueva». La diferencia no es teórica, es el caso que la encontró (2026-10-09):
+#
+# `recipes/gtk3.toml` baja de `sources/gtk+/3.24/`, y ese directorio TODAVÍA guarda las series
+# `3.89`…`3.96` — las alfas de GTK4, de 2017. `3.94 > 3.24` es cierto numéricamente y falso
+# semánticamente: no hay ningún GTK3 más nuevo que 3.24.
+#
+# Y **ninguna regla numérica barata lo distingue**: 94 es par (así que el par/impar de GNOME no lo
+# toca), está dentro del mismo major, y el directorio `3.94/` contiene un `gtk+-3.94.0.tar.xz` de
+# verdad, así que verificar que el fichero existe tampoco ayuda.
+#
+# ⇒ Por eso los hallazgos por índice se informan como **INDICIO**, en su propio recuento y con su
+# propia palabra, y no se suman a los de forja como si fueran la misma cosa. Un tag con su filtro de
+# prereleases es evidencia; un directorio con número mayor es una pista que alguien tiene que mirar.
+# Sobrevalorarlo es la forma de que 160 indicios buenos se tiren a la basura junto con los malos.
+#
+# Lo que SÍ se filtra, porque es una convención documentada y no una corazonada: las series de
+# minor IMPAR de download.gnome.org son inestables (`glib 2.89` lo es; `2.90` no). Quita ruido real
+# sin inventar nada.
+#
+# La pista que queda sin usar, para quien siga: los índices de Apache traen la FECHA de cada
+# entrada, y `3.24/` está tocado hace meses mientras `3.94/` lleva ocho años quieto. Ordenar por
+# fecha en vez de por número resolvería justo este caso. No se hace acá porque pide parsear el
+# formato de cada índice, y eso es su propia unidad de trabajo.
+GNOME_INESTABLE = re.compile(r'^\d+\.(\d+)$')
+
+
+def _serie_descartable(host, nombre):
+ """¿Esta serie es inestable por convención del host? Sólo se afirma donde está documentado."""
+ if 'download.gnome.org' in host:
+ m = GNOME_INESTABLE.match(nombre)
+ if m and int(m.group(1)) % 2 == 1:
+ return True
+ return False
+
+
+CANDIDATOS_CA = ('/etc/ssl/certs/ca-certificates.crt', '/etc/ssl/cert.pem',
+ '/etc/ca-certificates/extracted/tls-ca-bundle.pem',
+ '/etc/pki/tls/certs/ca-bundle.crt')
+
+
+def _contexto_tls():
+ """Contexto TLS que verifica, con el bundle del sistema si el de serie no sirve.
+
+ NO se desactiva la verificación en ningún camino: un índice bajado sin verificar puede decir
+ que hay una versión nueva que no existe, y esta vigía sólo vale si lo que informa es cierto.
+ """
+ ctx = ssl.create_default_context()
+ try:
+ # Sonda baratísima contra el propio almacén: si no cargó NADA, no va a verificar nada.
+ if ctx.cert_store_stats().get('x509_ca', 0) > 0:
+ return ctx
+ except Exception:
+ pass
+ for c in CANDIDATOS_CA:
+ if os.path.exists(c):
+ try:
+ return ssl.create_default_context(cafile=c)
+ except Exception:
+ continue
+ return ctx # se devuelve el de serie: fallará ruidosamente, que es mejor que no verificar
+
+
+CTX = None # se crea una vez en main()
+IDX_CACHE = {}
+IDX_LOCK = threading.Lock()
+HREF = re.compile(r'(?i)href="([^"?#]+)"')
+# Un segmento de ruta que ES una versión: `6.27`, `25.04.3`, `1.7`, `v1.2`. No `src`, no `individual`.
+SEG_VER = re.compile(r'^v?\d+(?:\.\d+)*$')
+
+
+def _bajar_indice(url, timeout):
+ """El listado de un directorio, CACHEADO por URL — y la caché es lo que lo hace viable: las 108
+ recetas de KDE comparten 3 directorios y las 29 de xorg comparten uno. Sin caché serían 429
+ peticiones; con caché son unas pocas docenas."""
+ with IDX_LOCK:
+ if url in IDX_CACHE:
+ return IDX_CACHE[url]
+ datos = None
+ try:
+ req = urllib.request.Request(url, headers={'User-Agent': 'takana-upstream-vigia'})
+ with urllib.request.urlopen(req, timeout=timeout, context=CTX) as f:
+ datos = f.read(4_000_000).decode('utf-8', 'replace')
+ except Exception:
+ # No se distingue «no existe» de «no se pudo bajar», y decir cuál sería inventarlo.
+ datos = None
+ with IDX_LOCK:
+ IDX_CACHE[url] = datos
+ return datos
+
+
+def _nombres(html):
+ """Los nombres de entrada de un índice. Vale para los índices de Apache/nginx y para cualquier
+ página con enlaces: se queda con el último segmento del href y le quita la barra final."""
+ fuera = set()
+ for h in HREF.findall(html):
+ n = urllib.parse.unquote(h.rstrip('/')).split('/')[-1]
+ if n and n not in ('..', '.'):
+ fuera.add(n)
+ return fuera
+
+
+def _partir(nombre):
+ """`bash-5.3.tar.gz` → ('bash', '5.3'). None si no tiene forma de <algo>-<versión><sufijo>."""
+ for suf in SUFIJOS:
+ if nombre.endswith(suf):
+ base = nombre[: -len(suf)]
+ m = re.match(r'^(.*?)[-_](v?\d[\w.]*?)$', base)
+ return (m.group(1), m.group(2)) if m else None
+ return None
+
+
+def mirar_indice(item, timeout):
+ """¿Hay algo más nuevo en el índice del que este tarball cuelga?
+
+ Dos sondeos, y se informa el mayor de los dos:
+ (a) EN SU PROPIO DIRECTORIO, un `<mismo-nombre>-<versión-mayor>`. Cubre ftp.gnu.org, xorg,
+ kernel.org: un directorio por paquete con todas sus versiones.
+ (b) UNA SERIE MÁS NUEVA. Cubre KDE y GNOME, donde la versión está en un SEGMENTO DE LA RUTA
+ (`stable/frameworks/6.27/…`, `sources/libadwaita/1.7/…`) y el directorio propio sólo
+ tiene la serie vieja — mirarlo diría «al día» para siempre. Se lista el PADRE del
+ segmento de versión y se buscan segmentos de la misma forma, más nuevos.
+ """
+ receta, url, version = item
+ r = {'receta': receta, 'tarball': url, 'version': version, 'fuente': 'indice'}
+ dir_url, _, fichero = url.rpartition('/')
+ partido = _partir(fichero)
+ if not partido:
+ # Sin `<nombre>-<versión>` en el fichero no hay con qué comparar. Pasa con
+ # files.pythonhosted.org, cuyas URLs llevan un hash en la ruta.
+ r['estado'] = 'fichero-no-versionado'
+ return r
+ stem, ver_fichero = partido
+ r['stem'], r['version_en_url'] = stem, ver_fichero
+ hallazgos = []
+
+ # (a) mismo directorio
+ html = _bajar_indice(dir_url + '/', timeout)
+ if html:
+ cand = []
+ for n in _nombres(html):
+ pp = _partir(n)
+ if pp and pp[0] == stem and forma(pp[1]) == forma(ver_fichero):
+ cand.append(pp[1])
+ if cand:
+ mejor = max(cand, key=clave)
+ r['mejor_en_su_dir'] = mejor
+ if clave(mejor) > clave(ver_fichero):
+ hallazgos.append(('fichero', mejor))
+
+ # (b) serie más nueva: el segmento de versión más EXTERNO de la ruta
+ partes = urllib.parse.urlparse(url)
+ segs = partes.path.split('/')
+ idx = next((i for i, s in enumerate(segs[:-1]) if SEG_VER.match(s)), None)
+ if idx is not None:
+ padre = '%s://%s%s/' % (partes.scheme, partes.netloc, '/'.join(segs[:idx]))
+ html2 = _bajar_indice(padre, timeout)
+ if html2:
+ series = [n for n in _nombres(html2)
+ if SEG_VER.match(n) and forma(n) == forma(segs[idx])
+ and not _serie_descartable(partes.netloc, n)]
+ if series:
+ mejor_s = max(series, key=clave)
+ r['serie_en_url'] = segs[idx]
+ r['mejor_serie'] = mejor_s
+ if clave(mejor_s) > clave(segs[idx]):
+ hallazgos.append(('serie', mejor_s))
+
+ if not hallazgos:
+ if 'mejor_en_su_dir' in r or 'mejor_serie' in r:
+ r['al_dia'] = True
+ r['estado'] = 'ok'
+ else:
+ # Se llegó al índice pero no había nada comparable, o no se llegó. No se afirma nada.
+ r['estado'] = 'sin-comparable' if (html or '') else 'inalcanzable'
+ return r
+ clase, valor = max(hallazgos, key=lambda h: clave(h[1]))
+ r['al_dia'] = False
+ r['nuevo'] = valor
+ r['nuevo_es'] = clase
+ actual = r.get('serie_en_url') if clase == 'serie' else ver_fichero
+ if anclada_por_nombre(receta, actual, valor):
+ r['anclada_por_nombre'] = True
r['estado'] = 'ok'
return r
@@ -259,22 +536,31 @@ def main():
ap.add_argument('--only', default='', help='sólo las recetas cuyo repo contenga esta cadena')
ap.add_argument('--jobs', type=int, default=16)
ap.add_argument('--timeout', type=int, default=25)
+ ap.add_argument('--no-http', action='store_true',
+ help='sólo ls-remote: salta el sondeo de índices por HTTP')
a = ap.parse_args()
+ global CTX
+ CTX = _contexto_tls()
items = recetas_git()
+ tb_total, tb_forja, tb_resto, tb_por_host = recetas_tarball()
if a.only:
items = [i for i in items if a.only in i[1]]
- tb_total, tb_gh, tb_otros = recetas_tarball()
- if a.only:
- tb_gh = [i for i in tb_gh if a.only in i[1]]
+ tb_forja = [i for i in tb_forja if a.only in i[1]]
+ tb_resto = [i for i in tb_resto if a.only in i[1]]
+ if a.no_http:
+ tb_resto = []
t0 = time.time()
res = []
with cf.ThreadPoolExecutor(max_workers=a.jobs) as ex:
for r in ex.map(lambda i: mirar(i, a.timeout), items):
res.append(r)
- # Los tarballs de github van por el MISMO pool y el mismo `ls-remote`: son la misma
+ # Los tarballs de forja van por el MISMO pool y el mismo `ls-remote`: son la misma
# pregunta con la pista leída de la URL en vez del campo `repo`.
- res_tb = list(ex.map(lambda i: mirar_tarball(i, a.timeout), tb_gh))
+ res_tb = list(ex.map(lambda i: mirar_tarball(i, a.timeout), tb_forja))
+ # Y el resto por índice HTTP. Van DESPUÉS a propósito: el `ls-remote` no depende de la
+ # caché de índices, así que si el HTTP se atasca lo de arriba ya está contestado.
+ res_ix = list(ex.map(lambda i: mirar_indice(i, a.timeout), tb_resto))
seg = int(time.time() - t0)
ok = [r for r in res if r['estado'] == 'ok']
@@ -288,73 +574,105 @@ def main():
if inalcanzables:
print(' ⚠ %d no se pudieron consultar' % len(inalcanzables))
tb_ok = [r for r in res_tb if r['estado'] == 'ok']
- tb_atras = [r for r in tb_ok if not r.get('al_dia')]
+ tb_atras = [r for r in tb_ok if not r.get('al_dia') and not r.get('anclada_por_nombre')]
+ tb_ancladas = [r for r in tb_ok if not r.get('al_dia') and r.get('anclada_por_nombre')]
tb_sha = [r for r in res_tb if r['estado'] == 'pin-por-sha']
tb_mudos = [r for r in res_tb if r['estado'] in ('sin-tags', 'inalcanzable', 'timeout')]
tb_sin_serie = [r for r in res_tb if r['estado'] == 'sin-serie-comparable']
- # ⚠ Lo que queda fuera se cuenta por lo NO CLASIFICADO, no restando lo consultado: con
- # `--only` activo la resta daba 625 mientras el desglose por host sumaba 429, y un total que
- # no cuadra con su propio desglose hace dudar del informe entero.
- tb_fuera = sum(tb_otros.values())
+ ix_ok = [r for r in res_ix if r['estado'] == 'ok']
+ ix_atras = [r for r in ix_ok if not r.get('al_dia') and not r.get('anclada_por_nombre')]
+ ix_ancladas = [r for r in ix_ok if not r.get('al_dia') and r.get('anclada_por_nombre')]
+ ix_mudos = [r for r in res_ix if r['estado'] != 'ok']
+ # ⚠ «Fuera del radar» = aquello de lo que NO hay respuesta: total menos lo que contestó cada
+ # mecanismo. Se cuenta así, y no restando lo intentado, porque intentar no es contestar — un
+ # índice que no respondió deja la receta igual de a oscuras que no haberla mirado.
+ tb_fuera = tb_total - len(tb_ok) - len(ix_ok)
print('\n== tarballs: %d receta(s) con tarball ==' % tb_total)
- print(' %d de github CONSULTADAS (la URL trae el repo y la versión ⇒ sirve el mismo ls-remote)'
+ print(' %d de FORJA git (github/gitlab) — la URL trae el repo ⇒ sirve el mismo ls-remote'
% len(res_tb))
print(' %d con una versión más nueva que la que descargan' % len(tb_atras))
+ if tb_ancladas or ix_ancladas:
+ print(' %d ANCLADAS POR SU NOMBRE (clang18, llvm21…): el salto de major es a propósito'
+ % (len(tb_ancladas) + len(ix_ancladas)))
if tb_sha:
print(' %d pinean por SHA ⇒ no se comparan: un sha no tiene orden de versión' % len(tb_sha))
if tb_sin_serie:
- # Se dice en vez de desaparecer: la regla de la serie sesga hacia el silencio, y un sesgo
- # que no se cuenta se lee como «todo comprobado».
print(' %d sin tag de la MISMA serie que la URL ⇒ no se comparan (ver `forma`)'
% len(tb_sin_serie))
if tb_mudos:
print(' %d sin tags o inalcanzables ⇒ no se afirma nada' % len(tb_mudos))
- # ⚠ Lo que queda FUERA se nombra por host, no se resume en un número: «400 sin mirar» se lee
- # como silencio, y «108 en download.kde.org» se lee como la próxima unidad de trabajo.
- print(' %d FUERA del radar todavía, por host:' % tb_fuera)
- for host, n in sorted(tb_otros.items(), key=lambda kv: -kv[1])[:8]:
- print(' %4d %s' % (n, host))
- resto = sum(n for _h, n in sorted(tb_otros.items(), key=lambda kv: -kv[1])[8:])
- if resto:
- print(' %4d (en otros %d hosts)' % (resto, max(0, len(tb_otros) - 8)))
+ print(' %d por ÍNDICE de directorio HTTP (%d peticiones distintas, el resto por caché)'
+ % (len(res_ix), len([k for k, v in IDX_CACHE.items() if v is not None])))
+ # «INDICIO» y no «versión nueva» a propósito: ver el bloque EL LÍMITE DEL MÉTODO POR ÍNDICE.
+ # Un número mayor en un índice no es una release verificada, y llamarlo igual que un tag haría
+ # que los hallazgos buenos se tiraran junto con los malos.
+ print(' %d con un número MAYOR río arriba (INDICIO, pide una mirada — no es un tag)'
+ % len(ix_atras))
+ if ix_mudos:
+ # Se dice en vez de desaparecer: un índice que no se pudo leer NO es un «al día».
+ porqué = {}
+ for r in ix_mudos:
+ porqué[r['estado']] = porqué.get(r['estado'], 0) + 1
+ print(' %d sin respuesta útil: %s' % (len(ix_mudos),
+ ', '.join('%d %s' % (n, k) for k, n in sorted(porqué.items(), key=lambda kv: -kv[1]))))
+ if ix_atras:
+ print('\n por índice, con algo más nuevo (los 20 primeros):')
+ for r in sorted(ix_atras, key=lambda x: x['receta'])[:20]:
+ # ⚠ Se compara CONTRA LO MISMO. Si el hallazgo es una SERIE (la versión vive en un
+ # segmento de la ruta, como en KDE y GNOME), lo que tiene hoy es la serie de la URL, no
+ # la versión del fichero: imprimir «baja 6.27.0 · hay 6.30» sugiere que 6.27.0 y 6.30
+ # son la misma magnitud y no lo son. Mezclar las dos escalas en una línea es la forma
+ # barata de que un informe correcto se lea como un error.
+ if r['nuevo_es'] == 'serie':
+ tiene, etiq = r.get('serie_en_url', '?'), 'serie'
+ else:
+ tiene, etiq = r.get('version_en_url', '?'), 'fichero'
+ print(' %-46s %-8s %-12s → %s' % (r['receta'], etiq, tiene, r['nuevo']))
if tb_atras:
- print('\n tarballs con versión nueva (los 20 primeros):')
+ print('\n de forja, con versión nueva (los 20 primeros):')
for r in sorted(tb_atras, key=lambda x: x['receta'])[:20]:
print(' %-42s baja %-14s hay %s' % (r['receta'], r['ref_en_url'], r['ultimo_tag']))
- # ⚠ NO se imprime «version → tag», y es a propósito: en un MONOREPO (tawasuyu son 37 recetas
- # sobre el mismo repo) el tag es del repositorio y el `version` es el de la caja concreta, así
- # que ponerlos con una flecha en medio sugiere una comparación que no existe. Lo que sí compara
- # es el PIN contra lo que hay hoy.
- if atras_tag:
- print('\n con un tag más nuevo que el pin (los 20 primeros):')
- for r in sorted(atras_tag, key=lambda x: x['receta'])[:20]:
- print(' %-42s pin %s… tag %s' % (r['receta'], r['commit'][:10], r['ultimo_tag']))
- if atras_rama:
- print('\n ramas movidas (los 20 primeros):')
- for r in sorted(atras_rama, key=lambda x: x['receta'])[:20]:
- print(' %-46s %s… → %s…' % (r['receta'], r['commit'][:10], r['cabeza'][:10]))
-
if a.json:
with open(a.json, 'w', encoding='utf-8') as f:
# schema /2: se AÑADEN las claves de tarball y se conservan las de /1 —
# `tarballs_sin_mirar` incluido— porque el resumen de `cosecha-cron.sh` las lee y
# renombrarlas rompería al consumidor sin avisar. Lo que cambia es su significado a
# mejor: antes era «todos los tarballs», ahora es «los que siguen fuera del radar».
- json.dump({'schema': 'takana-upstream/2',
+ # schema /3: se AÑADEN las claves del sondeo por índice y se CONSERVAN las de /1 y
+ # /2 —`tarballs_sin_mirar` y `tarballs_con_version_nueva` incluidas— porque el resumen
+ # de `cosecha-cron.sh` las lee y renombrarlas rompería al consumidor sin avisar. Lo que
+ # cambia es su significado, a mejor: `tarballs_con_version_nueva` ahora suma los dos
+ # mecanismos, y `tarballs_sin_mirar` son los que NINGUNO pudo contestar.
+ json.dump({'schema': 'takana-upstream/3',
'total': len(res), 'segundos': seg,
'con_tag_nuevo': len(atras_tag), 'con_rama_movida': len(atras_rama),
'inalcanzables': len(inalcanzables),
'tarballs_total': tb_total,
- 'tarballs_consultados': len(res_tb),
+ 'tarballs_forja': len(res_tb),
+ 'tarballs_forja_contestados': len(tb_ok),
+ 'tarballs_forja_con_version_nueva': len(tb_atras),
+ 'tarballs_forja_sin_serie': len(tb_sin_serie),
+ 'tarballs_forja_pin_por_sha': len(tb_sha),
+ 'tarballs_forja_mudos': len(tb_mudos),
+ 'tarballs_indice': len(res_ix),
+ 'tarballs_indice_contestados': len(ix_ok),
+ 'tarballs_indice_con_novedad': len(ix_atras),
+ 'tarballs_indice_mudos': len(ix_mudos),
+ # ⚠ `tarballs_con_version_nueva` SIGUE SIENDO SÓLO LA DE FORJA, como en
+ # /2. Sumarle los indicios por índice habría cambiado el significado de una
+ # clave que el latido ya publica, y además mezcla evidencia con pista: 100
+ # tags verificados y 266 números mayores no son la misma afirmación.
'tarballs_con_version_nueva': len(tb_atras),
- 'tarballs_sin_serie_comparable': len(tb_sin_serie),
- 'tarballs_pin_por_sha': len(tb_sha),
- 'tarballs_mudos': len(tb_mudos),
+ 'tarballs_con_indicio': len(ix_atras),
+ 'tarballs_ancladas_por_nombre': len(tb_ancladas) + len(ix_ancladas),
'tarballs_sin_mirar': tb_fuera,
- 'tarballs_fuera_por_host': dict(sorted(tb_otros.items(), key=lambda kv: -kv[1])),
+ 'tarballs_por_host_no_forja': dict(sorted(tb_por_host.items(),
+ key=lambda kv: -kv[1])),
+ 'peticiones_http': len([k for k, v in IDX_CACHE.items() if v is not None]),
'detalle': sorted(res, key=lambda x: x['receta']),
- 'detalle_tarballs': sorted(res_tb, key=lambda x: x['receta'])}, f,
+ 'detalle_tarballs': sorted(res_tb, key=lambda x: x['receta']),
+ 'detalle_indices': sorted(res_ix, key=lambda x: x['receta'])}, f,
ensure_ascii=False, indent=1)
print('\n informe: %s' % a.json)
# Sale 0 siempre: esto INFORMA. Un cron que falle porque upstream sacó una versión nueva sería