From 0f3a1dc3945ed2bff6e7d14a42b60d8ff0668ebe Mon Sep 17 00:00:00 2001 From: sergio Date: Wed, 22 Jul 2026 19:59:20 -0400 Subject: [PATCH] =?UTF-8?q?yupana:=20invariante=20(c)=20CAN=C3=93NICO=20?= =?UTF-8?q?=E2=80=94=20detector=20de=20duplicados=20por=20evidencia=20de?= =?UTF-8?q?=20fuente?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `yupana duplicados` caza nudos que son el MISMO paquete bajo nombres/colas distintos, agrupando por lo que BAJA (sha/url), no por el nombre. Clasifica: colisión-fuente — mismo sha, familias de nombre DISTINTAS ⇒ casi seguro un sha copiado por error. ACCIONABLE. variante — mismo sha, misma familia (zlib/zlib-shared, mesa-*) ⇒ diversificación deliberada estático/shared, OK (14 vistas). sombra — mismo nombre en >1 cola; redundante si versión+sha+deps coinciden (una sobra), justificada si difieren. HALLAZGO INMEDIATO — el detector encontró un bug real en su primera corrida: itstool.toml dice versión 2.0.7 pero su url+sha apuntan al tarball de gettext-tiny (29cc165e…). Construiría la fuente equivocada. radio itstool=1, gettext-tiny=78 ⇒ el sha copiado es el de itstool. Otras 3 colisiones son demos (adwaita-hello/libadwaita, sourceview-hello/ gtksourceview, radio 0) o subcomponentes (prison/prison-scanner) — a revisar, no urgentes. Y 1 sombra redundante: incoming-kde/dbus es byte-idéntica a corpus/dbus (mismo hash de artefacto) ⇒ los 114 consumidores KDE podrían apuntar a corpus/dbus sin rebuild. NO funde nada solo: reporta a docs/state/duplicados.json y deja el juicio al humano (como el triaje). El radio de cada nudo dice cuál es el canónico (el de mayor radio). Guardián extendido: test-yupana-radio.py falla si el detector deja de ver colisiones de fuente (agrupar por nombre en vez de sha = invariante c ciego). Co-Authored-By: Claude Opus 4.8 (1M context) --- docs/state/duplicados.json | 357 +++++++++++++++++++++++++++++++++++ scripts/test-yupana-radio.py | 17 +- scripts/yupana.py | 119 ++++++++++++ 3 files changed, 492 insertions(+), 1 deletion(-) create mode 100644 docs/state/duplicados.json diff --git a/docs/state/duplicados.json b/docs/state/duplicados.json new file mode 100644 index 00000000..5977fbba --- /dev/null +++ b/docs/state/duplicados.json @@ -0,0 +1,357 @@ +{ + "schema": "hammer-duplicados/1", + "colisiones_fuente": [ + { + "sha": "29cc165e27e83d2b", + "nudos": [ + "corpus/gettext-tiny", + "corpus/itstool" + ], + "radio": { + "corpus/gettext-tiny": 78, + "corpus/itstool": 1 + } + }, + { + "sha": "5eacc5550f0bdbba", + "nudos": [ + "corpus/adwaita-hello", + "corpus/libadwaita" + ], + "radio": { + "corpus/adwaita-hello": 0, + "corpus/libadwaita": 3 + } + }, + { + "sha": "760903e9ae401f8b", + "nudos": [ + "incoming-kde/prison", + "incoming-kde/prison-scanner" + ], + "radio": { + "incoming-kde/prison-scanner": 1, + "incoming-kde/prison": 3 + } + }, + { + "sha": "ab35d420102f3e8b", + "nudos": [ + "corpus/gtksourceview", + "corpus/hammer-edit", + "corpus/sourceview-hello" + ], + "radio": { + "corpus/gtksourceview": 2, + "corpus/hammer-edit": 0, + "corpus/sourceview-hello": 0 + } + } + ], + "variantes": [ + { + "sha": "ab5a03176ee106d3", + "nudos": [ + "corpus/bzip2", + "incoming-kde/bzip2-shared" + ], + "radio": { + "corpus/bzip2": 0, + "incoming-kde/bzip2-shared": 3 + } + }, + { + "sha": "2300f3dbfa7253b3", + "nudos": [ + "corpus/fontconfig", + "incoming-kde/fontconfig-shared" + ], + "radio": { + "corpus/fontconfig": 17, + "incoming-kde/fontconfig-shared": 2 + } + }, + { + "sha": "36bc4f1cc4133353", + "nudos": [ + "corpus/freetype", + "incoming-kde/freetype-shared" + ], + "radio": { + "corpus/freetype": 19, + "incoming-kde/freetype-shared": 3 + } + }, + { + "sha": "51ab804c56f6eab3", + "nudos": [ + "corpus/glib", + "incoming-kde/glib-shared" + ], + "radio": { + "corpus/glib": 14, + "incoming-kde/glib-shared": 10 + } + }, + { + "sha": "e1817c650ddc3261", + "nudos": [ + "corpus/gtk4", + "corpus/gtk4-hello" + ], + "radio": { + "corpus/gtk4-hello": 0, + "corpus/gtk4": 6 + } + }, + { + "sha": "075920b826834ac4", + "nudos": [ + "corpus/libjpeg-turbo", + "incoming-kde/libjpeg-turbo-shared" + ], + "radio": { + "corpus/libjpeg-turbo": 9, + "incoming-kde/libjpeg-turbo-shared": 6 + } + }, + { + "sha": "8c9b05b675ca7301", + "nudos": [ + "corpus/libpng", + "incoming-kde/libpng-shared" + ], + "radio": { + "corpus/libpng": 16, + "incoming-kde/libpng-shared": 8 + } + }, + { + "sha": "ffc6af80b014ddeb", + "nudos": [ + "corpus/linux", + "corpus/linux-headers", + "corpus/linux-metal" + ], + "radio": { + "corpus/linux-headers": 29, + "corpus/linux-metal": 0, + "corpus/linux": 0 + } + }, + { + "sha": "b43e0a2db2a89fc5", + "nudos": [ + "corpus/linux-generic", + "corpus/linux-metal-dual" + ], + "radio": { + "corpus/linux-generic": 0, + "corpus/linux-metal-dual": 0 + } + }, + { + "sha": "51aa686ca4060e38", + "nudos": [ + "corpus/mesa", + "corpus/mesa-llvmpipe", + "corpus/mesa-swrast" + ], + "radio": { + "corpus/mesa-llvmpipe": 0, + "corpus/mesa-swrast": 0, + "corpus/mesa": 124 + } + }, + { + "sha": "47fe8c99461250d4", + "nudos": [ + "corpus/pcre2", + "incoming-kde/pcre2-shared" + ], + "radio": { + "corpus/pcre2": 19, + "incoming-kde/pcre2-shared": 11 + } + }, + { + "sha": "fff1ffcf2b0da84d", + "nudos": [ + "corpus/xz", + "incoming-kde/xz-shared" + ], + "radio": { + "corpus/xz": 0, + "incoming-kde/xz-shared": 4 + } + }, + { + "sha": "9a93b2b7dfdac77c", + "nudos": [ + "corpus/zlib", + "incoming-kde/zlib-shared" + ], + "radio": { + "corpus/zlib": 67, + "incoming-kde/zlib-shared": 34 + } + }, + { + "sha": "809afafab61593f9", + "nudos": [ + "incoming-kde/qcoro", + "incoming-kde/qcoro-shared" + ], + "radio": { + "incoming-kde/qcoro-shared": 2, + "incoming-kde/qcoro": 1 + } + } + ], + "sombras": [ + { + "nombre": "dbus", + "colas": [ + "corpus", + "incoming-kde" + ], + "redundante": true, + "radio": { + "corpus/dbus": 0, + "incoming-kde/dbus": 114 + } + }, + { + "nombre": "libarchive", + "colas": [ + "corpus", + "incoming-kde" + ], + "redundante": false, + "radio": { + "corpus/libarchive": 0, + "incoming-kde/libarchive": 1 + } + }, + { + "nombre": "libevdev", + "colas": [ + "corpus", + "incoming-kde" + ], + "redundante": false, + "radio": { + "corpus/libevdev": 2, + "incoming-kde/libevdev": 2 + } + }, + { + "nombre": "libgcrypt", + "colas": [ + "corpus", + "incoming-kde" + ], + "redundante": false, + "radio": { + "corpus/libgcrypt": 1, + "incoming-kde/libgcrypt": 16 + } + }, + { + "nombre": "libgpg-error", + "colas": [ + "corpus", + "incoming-kde" + ], + "redundante": false, + "radio": { + "corpus/libgpg-error": 4, + "incoming-kde/libgpg-error": 16 + } + }, + { + "nombre": "libinput", + "colas": [ + "corpus", + "incoming-kde" + ], + "redundante": false, + "radio": { + "corpus/libinput": 1, + "incoming-kde/libinput": 1 + } + }, + { + "nombre": "libnl", + "colas": [ + "corpus", + "incoming-kde" + ], + "redundante": false, + "radio": { + "corpus/libnl": 1, + "incoming-kde/libnl": 2 + } + }, + { + "nombre": "libxkbcommon", + "colas": [ + "corpus", + "incoming-kde" + ], + "redundante": false, + "radio": { + "corpus/libxkbcommon": 9, + "incoming-kde/libxkbcommon": 114 + } + }, + { + "nombre": "libxml2", + "colas": [ + "corpus", + "incoming-kde" + ], + "redundante": false, + "radio": { + "corpus/libxml2": 7, + "incoming-kde/libxml2": 4 + } + }, + { + "nombre": "mtdev", + "colas": [ + "corpus", + "incoming-kde" + ], + "redundante": false, + "radio": { + "corpus/mtdev": 2, + "incoming-kde/mtdev": 2 + } + }, + { + "nombre": "openssl", + "colas": [ + "corpus", + "incoming-kde" + ], + "redundante": false, + "radio": { + "corpus/openssl": 25, + "incoming-kde/openssl": 21 + } + }, + { + "nombre": "util-linux", + "colas": [ + "corpus", + "incoming-kde" + ], + "redundante": false, + "radio": { + "corpus/util-linux": 1, + "incoming-kde/util-linux": 20 + } + } + ] +} diff --git a/scripts/test-yupana-radio.py b/scripts/test-yupana-radio.py index bd9c6509..9f5df895 100755 --- a/scripts/test-yupana-radio.py +++ b/scripts/test-yupana-radio.py @@ -38,8 +38,22 @@ def main(): if consumidores and not memb.get((q, n)) and any(cq != "corpus" for cq in colas_cons): pass # puede no tener perfil declarado; no es fallo duro, sólo libdrm es el guardián + # 3) INVARIANTE (c): el detector de duplicados debe seguir viendo la fuente, no el nombre. La + # identidad de un nudo es lo que BAJA (sha), no cómo se llama. Si esto se rompe (p.ej. alguien + # agrupa por nombre), las colisiones de fuente —shas copiados por error— dejan de verse. + recs_f = yupana._recetas_con_fuente() + por_sha = {} + for k, r in recs_f.items(): + if r["sha"]: + por_sha.setdefault(r["sha"], set()).add(k[1]) # nombres por sha + colis = {s: ns for s, ns in por_sha.items() + if len({yupana._base(n) for n in ns}) > 1} # familias distintas, misma fuente + if not colis: + fallos.append("el detector de duplicados no ve NINGUNA colisión de fuente — " + "¿se está agrupando por nombre en vez de por sha? (invariante c ciego)") + if fallos: - print("✗ REGRESIÓN del radio cruzado:") + print("✗ REGRESIÓN de yupana:") for f in fallos: print(" -", f) return 1 @@ -47,6 +61,7 @@ def main(): print(f"✓ radio cruza colas: libdrm consumido por {d} recetas de " f"{len({q for q,_ in rev.get(('corpus','libdrm'),())})} colas, atribuido a " f"{sorted(memb.get(('corpus','libdrm'), set()))}") + print(f"✓ detector de duplicados por fuente: {len(colis)} colisiones vistas (invariante c activo)") return 0 if __name__ == "__main__": diff --git a/scripts/yupana.py b/scripts/yupana.py index c195d567..6e29ca94 100755 --- a/scripts/yupana.py +++ b/scripts/yupana.py @@ -70,6 +70,123 @@ def _deps(f): return [] +# --- INVARIANTE (c): CANÓNICO — sin duplicados accidentales entre orígenes ------------------------ +# Sufijos que en hammer marcan una VARIANTE deliberada del mismo paquete (misma fuente, otra decisión +# de build: estático/shared, kernel por perfil, mesa por backend). Compartir sha con estos NO es un +# duplicado a fundir — es la diversificación coherente [[etapa-g-gui-chain-boundary]]. `-hello`/`-hola` +# son demos que vendorean la fuente de otro a propósito. +SUFIJOS_VARIANTE = ("-shared", "-static", "-libs", "-tiny", "-dev", "-headers", "-minimal", + "-generic", "-metal-dual", "-metal", "-swrast", "-llvmpipe", "-hello", "-hola") + + +def _base(n): + """Reduce un nombre a su base de familia quitando sufijos de variante, repetido: `zlib-shared`→ + `zlib`, `mesa-llvmpipe`→`mesa`, `linux-generic`→`linux`. NO toca el `lib` inicial a propósito + (`libpng`→`png` colapsaría paquetes sin relación). Para decidir si dos nudos que comparten fuente + son la MISMA familia (variante ok) o paquetes SIN relación (colisión real).""" + prev = None + while prev != n: + prev = n + for s in SUFIJOS_VARIANTE: + if n.endswith(s) and len(n) > len(s): + n = n[: -len(s)] + return n + + +def _fuente(f): + """(sha, url, version) de una receta — la identidad de lo que BAJA, no de cómo se llama.""" + try: + d = tomllib.load(open(f, "rb")) + except Exception: + return (None, "", None) + s = d.get("source", {}) + return (s.get("sha256") or s.get("output_hash"), + s.get("tarball") or s.get("url") or s.get("git") or "", + d.get("version")) + + +def _recetas_con_fuente(): + """{(cola, nombre): {sha, url, version, deps}} sobre todo el disco.""" + out = {} + for pat, es_incoming in (("recipes/*.toml", False), ("recipes/incoming-*/*.toml", True)): + for f in sorted(glob.glob(str(ROOT / pat))): + n = os.path.basename(f)[:-5] + q = os.path.basename(os.path.dirname(f)) if es_incoming else "corpus" + sha, url, ver = _fuente(f) + out[(q, n)] = dict(sha=sha, url=url, version=ver, deps=_deps(f)) + return out + + +def duplicados(como_json=False): + """INVARIANTE (c): detecta nudos que son el MISMO paquete bajo nombres/colas distintos, por + EVIDENCIA de fuente (sha/url), no por nombre. Clasifica cada grupo: + colisión-fuente — mismo sha, nombres de familias DISTINTAS ⇒ casi seguro un sha copiado por + error (itstool tenía el tarball de gettext-tiny). ACCIONABLE. + variante — mismo sha, misma familia (`zlib`/`zlib-shared`, `mesa-*`) ⇒ deliberado, OK. + sombra — mismo NOMBRE en >1 cola. Redundante si versión+sha+deps coinciden; si no, + es una variante justificada de la cola. + NO funde nada solo: reporta y deja el juicio al humano (como el triaje). El radio de cada nudo + ayuda a elegir el canónico.""" + recs = _recetas_con_fuente() + from collections import defaultdict + rev = dependientes() + + # 1) grupos por sha (identidad de fuente) + por_sha = defaultdict(list) + for k, r in recs.items(): + if r["sha"]: + por_sha[r["sha"]].append(k) + colisiones, variantes = [], [] + for sha, ks in por_sha.items(): + nombres = {n for _, n in ks} + if len(nombres) < 2: + continue + familias = {_base(n) for _, n in ks} + grupo = dict(sha=sha[:16], nudos=sorted(f"{q}/{n}" for q, n in ks), + radio={f"{q}/{n}": len(rev.get((q, n), ())) for q, n in ks}) + (variantes if len(familias) == 1 else colisiones).append(grupo) + + # 2) sombras: mismo nombre en >1 cola + por_nombre = defaultdict(list) + for (q, n) in recs: + por_nombre[n].append(q) + sombras = [] + for n, qs in por_nombre.items(): + if len(qs) < 2: + continue + firmas = {(recs[(q, n)]["version"], recs[(q, n)]["sha"], + tuple(sorted(recs[(q, n)]["deps"]))) for q in qs} + sombras.append(dict(nombre=n, colas=sorted(qs), + redundante=len(firmas) == 1, # idénticas ⇒ la sombra no aporta nada + radio={f"{q}/{n}": len(rev.get((q, n), ())) for q in qs})) + + doc = dict(schema="hammer-duplicados/1", + colisiones_fuente=sorted(colisiones, key=lambda g: -max(g["radio"].values() or [0])), + variantes=sorted(variantes, key=lambda g: g["nudos"]), + sombras=sorted(sombras, key=lambda s: (not s["redundante"], s["nombre"]))) + (ROOT / "docs/state/duplicados.json").write_text( + json.dumps(doc, indent=1, ensure_ascii=False) + "\n") + + if como_json: + print(json.dumps(doc, indent=1, ensure_ascii=False)) + return 0 + print(f"== INVARIANTE (c) CANÓNICO — duplicados por evidencia de fuente\n") + print(f"⚠ COLISIONES DE FUENTE: {len(colisiones)} (mismo sha, familias distintas — revisá el sha)") + for g in doc["colisiones_fuente"]: + print(f" sha {g['sha']}… {g['nudos']}") + print(f" radio: " + " ".join(f"{k.split('/')[-1]}={v}" for k, v in g["radio"].items())) + red = [s for s in sombras if s["redundante"]] + print(f"\n⚠ SOMBRAS REDUNDANTES: {len(red)} (mismo nombre+versión+sha+deps en 2 colas — una sobra)") + for s in red: + print(f" {s['nombre']:24} {s['colas']} radio " + + " ".join(f"{q}={v}" for (q, _), v in + ((k.split('/'), v) for k, v in s["radio"].items()))) + print(f"\n✓ variantes deliberadas (misma fuente, familia OK): {len(variantes)} — no son problema") + print(f"✓ sombras justificadas (difieren en versión/deps): {len(sombras)-len(red)}") + print(f"\n → docs/state/duplicados.json · el radio ayuda a elegir el canónico (el de mayor radio)") + return 0 + + def resolver(dep, desde_cola, indice): """Sibling-first: (cola, nombre) del nudo que provee `dep` visto desde `desde_cola`, o None si ninguna cola lo tiene (dep externa / nodo `wanted`). `indice` = {cola: set(nombres)}.""" @@ -218,6 +335,8 @@ def main(): cmd, extra = args[0], args[1:] if cmd == "radio" and extra: return radio(extra[0], como_json="--json" in extra) + if cmd == "duplicados": + return duplicados(como_json="--json" in extra) if cmd == "perfiles" and extra: memb = membresia() hits = {f"{q}/{n}": sorted(ps) for (q, n), ps in memb.items() if n == extra[0]}