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 1543Shell 203Rust 109Markdown 993728 Definiciones

feat(fuentes): 196 de los 625 tarballs ya se vigilan — no hacía falta un mecanismo, hacía falta leer la URL

Este guion decía que un tarball «no tiene equivalente general» y que saber si hay versión nueva
«pide una pista por receta». Es cierto EN GENERAL y era **falso para el grueso**. Medido: de las
625 recetas con tarball, **196 son de github.com**, y en esas URLs el repo y la versión YA ESTÁN
ESCRITOS — `.../archive/refs/tags/v1.2.3.tar.gz`, `.../releases/download/1.10/sway-1.10.tar.gz`.
El mismo `git ls-remote --tags` que la vigía ya hacía las cubre tal cual: sin API, sin token, sin
pista por receta. El punto ciego no era técnico, era una frase que nadie volvió a medir.

Resultado de la primera corrida: **82 recetas bajan una versión más vieja que la que upstream
publica** — 82 que hasta hoy no aparecían en ningún informe.

⚠ **Y trae su propio freno, porque la primera versión era un generador de ruido.** Comparar contra
el tag más nuevo del repo sin mirar su SERIE daba basura en cuanto upstream publica varias:

    cronie           baja `cronie-1.7.2` → decía «hay v4.3»            (otra serie entera)
    bash-completion  baja `2.18.0`       → decía «hay 20060301-alt06»  (tag de empaquetado ajeno)

Nueve falsos positivos de 91, medidos. `forma()` acota la comparación al mismo prefijo no numérico
y la misma cantidad de componentes: `v10.3.2` sólo contra `v#.#.#`. Sesga hacia el silencio a
propósito —un `1.2` → `1.2.1` queda fuera— y **por eso lo descartado se cuenta y se dice** (11 sin
serie comparable, 4 que bajan por sha, 50 sin tags o inalcanzables) en vez de desaparecer. Un
guardián cuyos hallazgos son casi todos falsos no se apaga: se deja de leer, y entonces tampoco se
ve el verdadero.

Control de que no es un falso nuestro: `gitea v1.27.0 → v28.1.0` sobrevive al freno, y upstream
**sí** publicó `v28.0.0` y `v28.1.0` junto a `v1.27.3`. Adoptarlo o no es del humano — la vigía
propone.

Lo que SIGUE fuera son 429, y el informe **los nombra por host** en vez de resumirlos: 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. Ésos piden un listado por host —no una
pista por receta, que es lo que el SDD 33 §5 suponía— y son su propia unidad de trabajo. «429 sin
mirar» se lee como silencio; «108 en download.kde.org» se lee como la próxima tarea.

El json pasa a `takana-upstream/2` **conservando las claves de /1**, `tarballs_sin_mirar`
incluida, porque el resumen del latido las lee y renombrarlas rompería al consumidor sin avisar.
Lo que cambia es su significado, a mejor: antes «todos los tarballs», ahora «los que siguen fuera
del radar». El resumen del latido lee la clave nueva con `.get(…,'?')` para que un informe viejo no
lo rompa.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

c123163f06229911348ae8da74fb9675e56b2eda · por Sergio · (hace 15 d) · padre ffb1abd45 · ver el árbol en este commit

Delta semántico · 5

scripts/fuentes/upstream-vigia.py

2 archivos · +212 −22

diff --git a/scripts/farm/cosecha-cron.sh b/scripts/farm/cosecha-cron.sh
index 9a029016..4880b93f 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']} con tag nuevo · {d['con_rama_movida']} con rama movida · {d['tarballs_sin_mirar']} tarballs 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['tarballs_sin_mirar']} tarballs AUN 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 f47e5f6d..8997fcfc 100755
--- a/scripts/fuentes/upstream-vigia.py
+++ b/scripts/fuentes/upstream-vigia.py
@@ -12,12 +12,28 @@
 # sistema de build, las banderas, las dependencias y el resultado, y eso no se automatiza a ciegas
 # para código ajeno.
 #
-# ── POR QUÉ SÓLO GIT, Y POR QUÉ ESO YA ES LA MITAD ────────────────────────────────────────────
+# ── QUÉ SE CUBRE, Y POR QUÉ ESTO YA NO ES «SÓLO GIT» ──────────────────────────────────────────
 # `git ls-remote` funciona contra CUALQUIER servidor git sin API, sin token y sin clonar — es lo
-# mismo que ya hace `takana pin`. Con eso se cubren las 660 recetas que pinean `repo` + `commit`
-# (607 en github, 37 en tawasuyu). Las que traen `tarball` + `sha256` no tienen equivalente
-# general: saber si hay una versión nueva de un tarball pide una pista por receta (dónde mirar y
-# con qué patrón), y eso es trabajo aparte — se cuentan y se dicen, no se adivinan.
+# mismo que ya hace `takana pin`. Con eso se cubren las 660 recetas que pinean `repo` + `commit`.
+#
+# ⚠ **Y TAMBIÉN 196 DE LAS 625 QUE PINEAN TARBALL** (2026-10-07). La primera versión de este guion
+# decía que un tarball «no tiene equivalente general» y que saber si hay una versión nueva «pide
+# una pista por receta». Es cierto en general y era **falso para el grueso**: medido, 196 de los
+# 625 tarballs son de `github.com`, y en esas URLs el repo y la versión YA ESTÁN ESCRITOS
+# (`.../archive/refs/tags/v1.2.3.tar.gz`, `.../releases/download/1.10/sway-1.10.tar.gz`). No hacía
+# falta API, ni token, ni pista por receta: hacía falta LEER LA URL y reusar el `ls-remote` que ya
+# estaba acá. Cubrirlas no costó un mecanismo nuevo.
+#
+# Lo que SIGUE fuera son 429, y son índices de directorio con forma conocida: download.kde.org
+# (108), download.gnome.org (70), ftp.gnu.org (32), gitlab.freedesktop.org (26), xorg/x.org (29),
+# download.qt.io (12), kernel.org (18). Ésos piden **un listado por host**, no una pista por receta
+# — su propia unidad de trabajo. Se cuentan y **se nombran por host** en el informe: «429 sin
+# mirar» se lee como silencio, «108 en download.kde.org» se lee como la próxima tarea.
+#
+# ⚠ Y se prefiere CALLAR a adivinar. Tres cosas no se comparan nunca, y cada una se informa en vez
+# de desaparecer: las que bajan por SHA (un sha no tiene orden de versión), las que no tienen tag
+# de la MISMA serie que la URL (ver `forma`: evitó 9 falsos positivos medidos, del tipo
+# «cronie-1.7.2 → v4.3») y las que no tienen tags o están inalcanzables.
 #
 # 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.
@@ -41,16 +57,6 @@ def recetas_git():
     return out
 
 
-def recetas_tarball():
-    n = 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()
-        if re.search(r'^\s*tarball\s*=', txt, re.M):
-            n += 1
-    return n
-
-
 # Un tag de versión, sin `^{}` (el objeto desreferenciado que git lista aparte) ni prereleases.
 TAG = re.compile(r'^refs/tags/(v?\d+[\w.\-]*)$')
 PRE = re.compile(r'(?i)(alpha|beta|rc\d|-rc|dev|pre|snapshot)')
@@ -62,6 +68,141 @@ def clave(t):
     return (nums + [0] * 5)[:5]
 
 
+# ── TARBALLS DE GITHUB: la mitad del punto ciego, y sin mecanismo nuevo ───────────────────────
+# 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: medido el
+# 2026-10-07, de las **625 recetas con tarball, 196 son de github.com** — y para ésas la URL YA
+# CONTIENE el repo y la versión, así que el `git ls-remote --tags` que esta vigía ya hace las cubre
+# tal cual. No hacía falta API, ni token, ni pista: hacía falta LEER LA URL.
+#
+# Los otros hosts gordos son índices de directorio con forma conocida (download.kde.org 108,
+# 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.
+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'(.+)$')
+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 forma(t):
+    """(prefijo, cuántos números) de un tag. Es la 'serie' a la que pertenece.
+
+    ⚠ EXISTE POR LOS FALSOS POSITIVOS, Y SON MEDIDOS (2026-10-07). Comparar el tag más nuevo del
+    repo contra el que la URL descarga, sin más, produce basura en cuanto el repo publica tags de
+    varias series:
+        cronie          baja `cronie-1.7.2`  → decía «hay v4.3»           (otra serie entera)
+        bash-completion baja `2.18.0`        → decía «hay 20060301-alt06» (tag de empaquetado ajeno)
+        gitea           baja `v1.27.0`       → decía «hay v28.1.0`
+    Y un guardián cuyos hallazgos son casi todos falsos no se apaga: se deja de leer, y entonces
+    tampoco se ve el hallazgo verdadero. Por eso acá se prefiere CALLAR a adivinar.
+
+    La regla es la más simple que se puede explicar: sólo se comparan tags con el MISMO prefijo no
+    numérico y la MISMA cantidad de componentes numéricos. `v10.3.2` sólo contra `v#.#.#`,
+    `cronie-1.7.2` sólo contra `cronie-#.#.#`. Sesga hacia el silencio a propósito — un salto
+    `1.2` → `1.2.1` queda fuera —, y por eso lo que se descarta se CUENTA y se DICE en el informe
+    en vez de desaparecer.
+    """
+    pref = re.match(r'^[^0-9]*', t).group(0)
+    return (pref, len(re.findall(r'\d+', t)))
+
+
+def _limpiar_ref(ref):
+    """De la cola de la URL al tag: se queda con el primer segmento (`releases/download/<tag>/<fichero>`
+    trae el fichero detrás) y le quita el sufijo del archivo."""
+    ref = ref.split('/')[0]
+    for suf in SUFIJOS:
+        if ref.endswith(suf):
+            ref = ref[: -len(suf)]
+            break
+    return ref
+
+
+def recetas_tarball():
+    """(total, [(receta, repo_derivado, ref_en_url, version)] de github, {host: n} de las que no)."""
+    total, gh, otros = 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()
+        m = re.search(r'^\s*tarball\s*=\s*"([^"]+)"', txt, re.M)
+        if not m:
+            continue
+        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))
+        else:
+            host = url.split('/')[2] if '://' in url else '(sin host)'
+            otros[host] = otros.get(host, 0) + 1
+    return total, gh, otros
+
+
+def mirar_tarball(item, timeout):
+    """Para un tarball de github: ¿hay un tag más nuevo que el que la URL descarga?"""
+    receta, repo, ref_url, version = item
+    env = dict(os.environ, GIT_TERMINAL_PROMPT='0', GIT_ASKPASS='/bin/true')
+    r = {'receta': receta, 'repo': repo, 'ref_en_url': ref_url, 'version': version,
+         'fuente': 'tarball-github'}
+    try:
+        p = subprocess.run(['git', 'ls-remote', '--tags', '--refs', repo],
+                           capture_output=True, text=True, timeout=timeout, env=env)
+    except subprocess.TimeoutExpired:
+        r['estado'] = 'timeout'
+        return r
+    if p.returncode != 0:
+        r['estado'] = 'inalcanzable'
+        err = (p.stderr or '').strip().splitlines()
+        r['error'] = err[-1][:160] if err else None
+        return r
+    tags = {}
+    for linea in p.stdout.splitlines():
+        try:
+            sha, refname = linea.split('\t', 1)
+        except ValueError:
+            continue
+        mt = TAG.match(refname)
+        if mt:
+            tags[mt.group(1)] = sha
+    estables = {t: v for t, v in tags.items() if not PRE.search(t)}
+    usar = estables or tags
+    r['tags'] = len(tags)
+    if not usar:
+        # Sin tags no hay con qué comparar, y decir «al día» sería inventarlo.
+        r['estado'] = 'sin-tags'
+        return r
+    # ⚠ Si la URL descarga por SHA en vez de por tag, NO se compara: un sha no tiene orden de
+    # versión, así que afirmar que está atrasado —o al día— sería adivinar.
+    if SOLO_SHA.match(ref_url):
+        r['ultimo_tag'] = max(usar, key=clave)
+        r['estado'] = 'pin-por-sha'
+        return r
+    # Sólo la MISMA SERIE (ver `forma`): comparar contra el tag más nuevo del repo sin mirar su
+    # serie daba «cronie-1.7.2 → v4.3» y «2.18.0 → 20060301-alt06». Lo descartado se informa.
+    misma = {t: v for t, v in usar.items() if forma(t) == forma(ref_url)}
+    r['tags_de_la_serie'] = len(misma)
+    _pref, _n = forma(ref_url)
+    r['serie'] = "prefijo '%s' · %d numero(s)" % (_pref, _n)
+    if not misma:
+        # El repo no publica NINGÚN tag con la forma que esta receta descarga. Puede ser que la
+        # receta use un tag raro, o que upstream cambiara de esquema. No se afirma nada.
+        r['ultimo_tag_global'] = max(usar, key=clave)
+        r['estado'] = 'sin-serie-comparable'
+        return r
+    ult = max(misma, key=clave)
+    r['ultimo_tag'] = ult
+    # 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)
+    r['estado'] = 'ok'
+    return r
+
+
 def mirar(item, timeout):
     receta, repo, commit, version = item
     env = dict(os.environ, GIT_TERMINAL_PROMPT='0', GIT_ASKPASS='/bin/true')
@@ -123,11 +264,17 @@ def main():
     items = recetas_git()
     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]]
     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
+        # 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))
     seg = int(time.time() - t0)
 
     ok = [r for r in res if r['estado'] == 'ok']
@@ -140,9 +287,40 @@ def main():
     print('   %d con la rama movida desde el pin' % len(atras_rama))
     if inalcanzables:
         print('   ⚠ %d no se pudieron consultar' % len(inalcanzables))
-    tb = recetas_tarball()
-    print('   · %d recetas pinean un TARBALL y esta vigía no las mira: no hay forma general de' % tb)
-    print('     preguntarle a un tarball si hay una versión nueva (hace falta una pista por receta)')
+    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_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())
+    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)'
+          % len(res_tb))
+    print('     %d con una versión más nueva que la que descargan' % len(tb_atras))
+    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)))
+    if tb_atras:
+        print('\n   tarballs 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í
@@ -159,12 +337,24 @@ def main():
 
     if a.json:
         with open(a.json, 'w', encoding='utf-8') as f:
-            json.dump({'schema': 'takana-upstream/1',
+            # 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',
                        'total': len(res), 'segundos': seg,
                        'con_tag_nuevo': len(atras_tag), 'con_rama_movida': len(atras_rama),
                        'inalcanzables': len(inalcanzables),
-                       'tarballs_sin_mirar': tb,
-                       'detalle': sorted(res, key=lambda x: x['receta'])}, f,
+                       'tarballs_total': tb_total,
+                       'tarballs_consultados': len(res_tb),
+                       '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_sin_mirar': tb_fuera,
+                       'tarballs_fuera_por_host': dict(sorted(tb_otros.items(), key=lambda kv: -kv[1])),
+                       'detalle': sorted(res, key=lambda x: x['receta']),
+                       'detalle_tarballs': sorted(res_tb, 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