> 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…
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>
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 <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