yupana: los 2 "bugs" de duplicados eran falsos positivos MÍOS — detector afinado, 0 reales
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) <noreply@anthropic.com>
This commit is contained in:
@@ -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__":
|
||||
|
||||
+62
-22
@@ -44,7 +44,7 @@
|
||||
# Uso: scripts/yupana.py radio <receta> [--json]
|
||||
# scripts/yupana.py estado [--kde] | drenar [--todos] | triaje | frontera <perfil> | 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
|
||||
|
||||
|
||||
|
||||
Reference in New Issue
Block a user