yupana: invariante (c) CANÓNICO — detector de duplicados por evidencia de fuente
`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) <noreply@anthropic.com>
This commit is contained in:
@@ -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
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -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__":
|
||||
|
||||
@@ -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]}
|
||||
|
||||
Reference in New Issue
Block a user