From 4f609080df4626ed0ea9fedd94f57eaa3f59a208 Mon Sep 17 00:00:00 2001 From: sergio Date: Wed, 22 Jul 2026 20:14:10 -0400 Subject: [PATCH] =?UTF-8?q?yupana:=20los=202=20"bugs"=20de=20duplicados=20?= =?UTF-8?q?eran=20falsos=20positivos=20M=C3=8DOS=20=E2=80=94=20detector=20?= =?UTF-8?q?afinado,=200=20reales?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit El usuario pidió arreglar itstool y la sombra de dbus. Apliqué el reflejo (`yupana radio` + diff completo antes de tocar) y NINGUNO era un bug: ambos eran patrones deliberados y documentados que mi detector, demasiado superficial, leyó mal. Si los "arreglaba" a ciegas rompía dos builds. · dbus: corpus/dbus (estático) e incoming-kde/dbus (dinámico) NO son redundantes — qtbase linkea libdbus-1.so dinámicamente para Qt6DBus. El detector comparó sólo versión+sha+deps, no el BUILD. Borrar la sombra rompía KDE. · itstool: es un STUB documentado (`[source]=carrier`). El itstool real es Python con libxml2-bindings ausentes en el lab; este genera un script inline y sólo PRESTA el tarball de gettext-tiny. Apuntarlo al itstool real rompía appstream (radio 5) con un source que ni compila acá. Los 3 *-hello son el mismo patrón carrier; prison/prison-scanner una variante cross-nombre (misma fuente, WITH_ZXING distinto). FIX = el detector, no las recetas. Tres señales que le faltaban: 1. huella de BUILD en la firma de sombra (estático≠dinámico ⇒ no redundante). 2. CARRIER: ≤1 receta del grupo construye la fuente ⇒ el resto presta el tarball. Señal: ¿invoca make/meson/cmake/ninja/cargo? (comentarios strippeados — la prosa "invocación de meson" de un stub daba falso builder, lo cazó el guardián). 3. variante cross-nombre: ≥2 builders con BUILD distinto = deliberado, no bug. Resultado: 0 colisiones reales (invariante c se cumple), 4 carrier + 14 variantes + 12 sombras justificadas, todas benignas. Guardián extendido: ancla que el detector agrupa por FUENTE (itstool↔gettext-tiny) y NO cría lobos (itstool=carrier, dbus=variante deliberada). LECCIÓN: el reflejo del radio/diff antes de tocar evitó dos borrados destructivos guiados por un falso positivo de mi propia herramienta. Medir antes de creer, aun a la propia yupana. Co-Authored-By: Claude Opus 4.8 (1M context) --- docs/state/duplicados.json | 136 +++++++++++++++++++---------------- scripts/test-yupana-radio.py | 33 ++++++--- scripts/yupana.py | 84 ++++++++++++++++------ 3 files changed, 160 insertions(+), 93 deletions(-) diff --git a/docs/state/duplicados.json b/docs/state/duplicados.json index 5977fbba..e6b90af4 100644 --- a/docs/state/duplicados.json +++ b/docs/state/duplicados.json @@ -1,53 +1,6 @@ { - "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 - } - } - ], + "schema": "hammer-duplicados/2", + "colisiones_fuente": [], "variantes": [ { "sha": "ab5a03176ee106d3", @@ -93,17 +46,6 @@ "incoming-kde/glib-shared": 10 } }, - { - "sha": "e1817c650ddc3261", - "nudos": [ - "corpus/gtk4", - "corpus/gtk4-hello" - ], - "radio": { - "corpus/gtk4-hello": 0, - "corpus/gtk4": 6 - } - }, { "sha": "075920b826834ac4", "nudos": [ @@ -196,6 +138,17 @@ "incoming-kde/zlib-shared": 34 } }, + { + "sha": "760903e9ae401f8b", + "nudos": [ + "incoming-kde/prison", + "incoming-kde/prison-scanner" + ], + "radio": { + "incoming-kde/prison-scanner": 1, + "incoming-kde/prison": 3 + } + }, { "sha": "809afafab61593f9", "nudos": [ @@ -208,6 +161,67 @@ } } ], + "carriers": [ + { + "sha": "5eacc5550f0bdbba", + "nudos": [ + "corpus/adwaita-hello", + "corpus/libadwaita" + ], + "radio": { + "corpus/adwaita-hello": 0, + "corpus/libadwaita": 3 + }, + "carriers": [ + "corpus/adwaita-hello" + ] + }, + { + "sha": "29cc165e27e83d2b", + "nudos": [ + "corpus/gettext-tiny", + "corpus/itstool" + ], + "radio": { + "corpus/gettext-tiny": 78, + "corpus/itstool": 1 + }, + "carriers": [ + "corpus/itstool" + ] + }, + { + "sha": "e1817c650ddc3261", + "nudos": [ + "corpus/gtk4", + "corpus/gtk4-hello" + ], + "radio": { + "corpus/gtk4-hello": 0, + "corpus/gtk4": 6 + }, + "carriers": [ + "corpus/gtk4-hello" + ] + }, + { + "sha": "ab35d420102f3e8b", + "nudos": [ + "corpus/gtksourceview", + "corpus/hammer-edit", + "corpus/sourceview-hello" + ], + "radio": { + "corpus/gtksourceview": 2, + "corpus/hammer-edit": 0, + "corpus/sourceview-hello": 0 + }, + "carriers": [ + "corpus/hammer-edit", + "corpus/sourceview-hello" + ] + } + ], "sombras": [ { "nombre": "dbus", @@ -215,7 +229,7 @@ "corpus", "incoming-kde" ], - "redundante": true, + "redundante": false, "radio": { "corpus/dbus": 0, "incoming-kde/dbus": 114 diff --git a/scripts/test-yupana-radio.py b/scripts/test-yupana-radio.py index 9f5df895..147b0a17 100755 --- a/scripts/test-yupana-radio.py +++ b/scripts/test-yupana-radio.py @@ -38,19 +38,31 @@ 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. + # 3) INVARIANTE (c): el detector debe AGRUPAR por fuente (sha), no por nombre — la identidad de un + # nudo es lo que BAJA. Ancla: itstool y gettext-tiny comparten sha (itstool le presta el tarball + # como carrier). Si el detector deja de ver ese grupo, está agrupando por nombre = invariante ciego. 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)") + por_sha.setdefault(r["sha"], set()).add(k) + sha_it = recs_f.get(("corpus", "itstool"), {}).get("sha") + if not sha_it or ("corpus", "gettext-tiny") not in por_sha.get(sha_it, set()): + fallos.append("el detector no agrupa itstool con gettext-tiny por sha — " + "¿agrupa por nombre en vez de por fuente? (invariante c ciego)") + + # 3b) NO crIar lobos: itstool NO es un bug — es un CARRIER (stub que presta el tarball, no + # construye la fuente). Si el detector lo llama construye_fuente=True, lo reportaría como + # colisión falsa (casi se "arregla" un stub apuntándolo al itstool real que no compila en el lab). + if recs_f.get(("corpus", "itstool"), {}).get("construye_fuente"): + fallos.append("itstool marcado como que CONSTRUYE su fuente — es un carrier/stub, " + "el detector lo reportaría como colisión falsa") + # 3c) dbus: corpus (estático) vs incoming-kde (dinámico, para Qt6DBus) — misma fuente, BUILD + # distinto ⇒ variante deliberada, NO sombra redundante. Casi se borra load-bearing (2026-07-22). + if ("corpus", "dbus") in recs_f and ("incoming-kde", "dbus") in recs_f: + if recs_f[("corpus", "dbus")]["build_fp"] == recs_f[("incoming-kde", "dbus")]["build_fp"]: + fallos.append("corpus/dbus e incoming-kde/dbus tienen build_fp IGUAL — el detector " + "los marcaría redundantes y son variantes estático/dinámico deliberadas") if fallos: print("✗ REGRESIÓN de yupana:") @@ -61,7 +73,8 @@ 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)") + print(f"✓ detector agrupa por fuente (itstool↔gettext-tiny) y no cría lobos " + f"(itstool=carrier, dbus=variante)") return 0 if __name__ == "__main__": diff --git a/scripts/yupana.py b/scripts/yupana.py index 931e9daf..fb030764 100755 --- a/scripts/yupana.py +++ b/scripts/yupana.py @@ -44,7 +44,7 @@ # Uso: scripts/yupana.py radio [--json] # scripts/yupana.py estado [--kde] | drenar [--todos] | triaje | frontera | objetivo # Como módulo: from yupana import cargar_colas, membresia, dependientes, resolver -import glob, json, os, subprocess, sys, tomllib +import glob, json, os, re, subprocess, sys, tomllib from pathlib import Path ROOT = Path(__file__).resolve().parent.parent @@ -245,26 +245,46 @@ def keystones(como_json=False): def _fuente(f): - """(sha, url, version) de una receta — la identidad de lo que BAJA, no de cómo se llama.""" + """(sha, url, version, build_fp) de una receta. Los tres primeros = identidad de lo que BAJA. El + cuarto = HUELLA DE BUILD (link, compiler, fases) — porque dos recetas con la MISMA fuente pero + build distinto NO son redundantes: son variantes deliberadas (corpus/dbus estático vs + incoming-kde/dbus dinámico para Qt6DBus). Ignorar esto hacía que el detector marcara `dbus` como + sombra redundante y casi borra una receta load-bearing (2026-07-22).""" try: d = tomllib.load(open(f, "rb")) except Exception: - return (None, "", None) + return (None, "", None, None, False) s = d.get("source", {}) + b = d.get("build", {}) + ph = b.get("phases", {}) or {} + build_fp = (b.get("link"), b.get("compiler"), + tuple(sorted((k, str(v)) for k, v in ph.items()))) + # ¿construye la FUENTE descargada, o es un CARRIER (stub/demo que genera todo inline y sólo presta + # el tarball)? Señal: invoca un sistema de build. itstool/-hello no ⇒ carrier; su colisión de sha + # es esperada, no un error de copiado. (2026-07-22: sin esto el detector marcaba 4 falsos bugs.) + blob = "\n".join(str(ph.get(k, "")) for k in ("configure", "compile", "install")) + # Strip comentarios shell (`# …` a fin de línea) ANTES de buscar: si no, la prosa de un stub + # ("# parsea la invocación de meson") matchea y marca al carrier como builder (falso positivo + # que el guardián cazó, 2026-07-22). + sin_comentarios = re.sub(r"#.*", "", blob) + construye = any(re.search(rx, sin_comentarios) for rx in + (r"\bmake\b", r"\bmeson\b", r"\bcmake\b", r"\bninja\b", r"\bcargo\b", + r"\./configure", r"\bautogen", r"\bautoreconf")) return (s.get("sha256") or s.get("output_hash"), s.get("tarball") or s.get("url") or s.get("git") or "", - d.get("version")) + d.get("version"), build_fp, construye) def _recetas_con_fuente(): - """{(cola, nombre): {sha, url, version, deps}} sobre todo el disco.""" + """{(cola, nombre): {sha, url, version, build_fp, construye_fuente, 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)) + sha, url, ver, build_fp, construye = _fuente(f) + out[(q, n)] = dict(sha=sha, url=url, version=ver, build_fp=build_fp, + construye_fuente=construye, deps=_deps(f)) return out @@ -287,15 +307,27 @@ def duplicados(como_json=False): for k, r in recs.items(): if r["sha"]: por_sha[r["sha"]].append(k) - colisiones, variantes = [], [] + colisiones, variantes, carriers = [], [], [] 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) + builders = [k for k in ks if recs[k]["construye_fuente"]] + familias = {_base(n) for _, n in ks} + # Clasificación por evidencia, de más benigno a más sospechoso: + if len(builders) <= 1: + # ≤1 receta construye la fuente ⇒ el resto son CARRIERS que le prestan el tarball. Benigno. + grupo["carriers"] = sorted(f"{q}/{n}" for (q, n) in ks + if not recs[(q, n)]["construye_fuente"]) + carriers.append(grupo) + elif len(familias) == 1: + variantes.append(grupo) # misma familia (zlib/zlib-shared): variante deliberada + elif len({recs[k]["build_fp"] for k in builders}) > 1: + variantes.append(grupo) # familias distintas pero BUILD distinto: variante cross-nombre + else: # ≥2 builders, MISMO build, familias distintas ⇒ sospechoso + colisiones.append(grupo) # 2) sombras: mismo nombre en >1 cola por_nombre = defaultdict(list) @@ -305,15 +337,18 @@ def duplicados(como_json=False): for n, qs in por_nombre.items(): if len(qs) < 2: continue - firmas = {(recs[(q, n)]["version"], recs[(q, n)]["sha"], + # Redundante SÓLO si coinciden fuente Y BUILD: mismo tarball no basta, dos builds distintos + # del mismo source (estático vs dinámico) son variantes deliberadas, no redundancia. + firmas = {(recs[(q, n)]["version"], recs[(q, n)]["sha"], recs[(q, n)]["build_fp"], 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 + redundante=len(firmas) == 1, # idénticas en fuente+build ⇒ una sobra radio={f"{q}/{n}": len(rev.get((q, n), ())) for q in qs})) - doc = dict(schema="hammer-duplicados/1", + doc = dict(schema="hammer-duplicados/2", colisiones_fuente=sorted(colisiones, key=lambda g: -max(g["radio"].values() or [0])), variantes=sorted(variantes, key=lambda g: g["nudos"]), + carriers=sorted(carriers, 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") @@ -321,20 +356,25 @@ def duplicados(como_json=False): if como_json: print(json.dumps(doc, indent=1, ensure_ascii=False)) return 0 + red = [s for s in sombras if s["redundante"]] 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)") + print(f"⚠ COLISIONES DE FUENTE REALES: {len(colisiones)} (≥2 recetas construyen el MISMO source,") + print(f" mismo build, familias distintas — casi seguro un sha copiado por error)") 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)") + if not colisiones: + print(" (ninguna — el invariante (c) se cumple)") + print(f"\n⚠ SOMBRAS REDUNDANTES: {len(red)} (mismo nombre+fuente+BUILD 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)") + print(f" {s['nombre']:24} {s['colas']}") + if not red: + print(" (ninguna)") + print(f"\n✓ BENIGNOS (no son problema):") + print(f" {len(carriers)} grupos CARRIER (stub/demo que presta el tarball: itstool, *-hello)") + print(f" {len(variantes)} variantes deliberadas (misma fuente, build/familia distintos: zlib-shared, prison-scanner)") + print(f" {len(sombras)-len(red)} sombras justificadas (mismo nombre, build distinto: dbus estático/dinámico)") + print(f"\n → docs/state/duplicados.json") return 0