#!/usr/bin/env python3 """why-differs-barrido — pasa `hammer why-differs` por TODO el store y agrega las causas. Para qué: el store guarda varios sellados por paquete (recetas que cambiaron a lo largo del tiempo). Comparar dos sellados del mismo nombre responde una pregunta que hasta ahora nadie hacía a escala: **¿por qué difieren?** Y sobre todo separa los dos casos que se confunden a ojo: · `.hammer/recipe.toml` DIFIERE ⇒ son recetas distintas. Que el artefacto cambie es lo esperado. · `.hammer/recipe.toml` IDÉNTICO ⇒ misma receta, distinto artefacto: **sospechoso**. CUIDADO CON EL SEGUNDO CASO (y por qué este script no grita "no-reproducción"): el artefacto sólo guarda su `recipe.toml`, NO los hashes de sus dependencias — pero el ArtifactHash SÍ los incluye. Dos sellados con el mismo `recipe.toml` pueden diferir **legítimamente** porque una dep cambió (glib nuevo ⇒ gtk4 se re-sella con contenido distinto). "Mismo recipe.toml, artefacto distinto" es un SUPERCONJUNTO de las no-reproducciones, no una prueba. Lo que sí se puede afirmar mirando la CAUSA, y por eso el informe separa dos grupos: · **no-determinismo probable** — todas las divergencias son metadatos que no dependen de las deps: MTIME de gzip, cabecera `ar`, ruta de build embebida, o secciones ELF puramente informativas (`.debug_*`, `.comment`, `.note.gnu.build-id`, tablas de símbolos). Una dep distinta no explica que cambie SÓLO eso. · **puede ser una dep** — difiere `.text`/`.data`/`.rodata` o hay ficheros presentes/ausentes. Indistinguible de "la dependencia cambió" sin reconstruir. Reconstruir es la única prueba. Es el "multiplicador de granja" del SDD 17 §1.2: debuggear una no-reproducción deja de ser artesanal. Uso: scripts/why-differs-barrido.sh # barre todo el store LIMITE=50 scripts/why-differs-barrido.sh # sólo los primeros 50 paquetes JSON=informe.json scripts/why-differs-barrido.sh """ import collections import json import os import pathlib import subprocess import sys RAIZ = pathlib.Path(__file__).resolve().parent.parent STORE = RAIZ / "store" HAMMER = RAIZ / "target/release/hammer" LIMITE = int(os.environ.get("LIMITE", "0")) SALIDA_JSON = os.environ.get("JSON") # Un artefacto puede pesar GB (qt6, kernels): hashearlo entero para el barrido no aporta. TIMEOUT = int(os.environ.get("TIMEOUT", "120")) def sellados_por_nombre(): """{nombre: [rutas…]} — el nombre es lo que sigue al hash en `-`.""" m = collections.defaultdict(list) for d in STORE.iterdir(): if not d.is_dir(): continue _, sep, nombre = d.name.partition("-") if sep: m[nombre].append(d) return {n: sorted(v, key=lambda p: p.stat().st_mtime) for n, v in m.items() if len(v) >= 2} # Secciones ELF que son metadatos: su contenido no cambia porque una dependencia cambie. SECCIONES_META = (".debug", ".zdebug", ".comment", ".note", ".symtab", ".strtab", ".shstrtab") # Causas que, por sí solas, no las explica un cambio de dependencia. CAUSAS_META = {"gzip_mtime", "ar_cabecera", "ruta_de_build"} def solo_metadatos(d): """¿Esta divergencia es explicable SÓLO por no-determinismo del build?""" if d["causa"] in CAUSAS_META: return True if d["causa"] != "elf_secciones": return False return all(s.split(" ")[0].startswith(SECCIONES_META) for s in d["secciones"]) def main(): if not HAMMER.exists(): sys.exit(f"falta {HAMMER} (cargo build --release --bin hammer)") grupos = sellados_por_nombre() nombres = sorted(grupos) if LIMITE: nombres = nombres[:LIMITE] print(f"== barrido why-differs: {len(nombres)} paquetes con ≥2 sellados\n") causas = collections.Counter() no_determinismo = [] # misma receta y sólo metadatos ⇒ el build no es determinista quiza_dep = [] # misma receta pero difiere código ⇒ pudo cambiar una dependencia recetas_distintas = 0 errores = [] for i, nombre in enumerate(nombres, 1): a, b = grupos[nombre][-2], grupos[nombre][-1] # los dos más recientes try: r = subprocess.run( [str(HAMMER), "why-differs", str(a), str(b), "--json"], capture_output=True, text=True, timeout=TIMEOUT, ) informe = json.loads(r.stdout) except (subprocess.TimeoutExpired, json.JSONDecodeError) as e: errores.append((nombre, type(e).__name__)) continue divs = informe["divergencias"] if not divs: continue receta_cambio = any(d["ruta"] == ".hammer/recipe.toml" for d in divs) for d in divs: causas[d["causa"]] += 1 if receta_cambio: recetas_distintas += 1 elif all(solo_metadatos(d) for d in divs): no_determinismo.append((nombre, a.name[:12], b.name[:12], divs)) else: quiza_dep.append((nombre, a.name[:12], b.name[:12], divs)) if i % 50 == 0: print(f" … {i}/{len(nombres)}") print(f"\n── recetas distintas (divergencia esperada): {recetas_distintas}") print(f"── misma receta, SÓLO metadatos difieren (no-determinismo probable): {len(no_determinismo)}") print(f"── misma receta, difiere código/ficheros (puede ser una dep — no concluyente): {len(quiza_dep)}") if errores: print(f"── no analizados: {len(errores)} ({errores[:3]}…)") print("\n── causas agregadas:") for c, n in causas.most_common(): print(f" {c:22s} {n}") if no_determinismo: print("\n══ NO-DETERMINISMO PROBABLE (misma receta; sólo difieren metadatos que las deps") print(" no explican — reconstruir para confirmar):") for nombre, ha, hb, divs in no_determinismo: print(f"\n {nombre} [{ha}… vs {hb}…] {len(divs)} divergencia(s)") for d in divs[:4]: print(f" · {d['ruta']}: {d['explicacion'].splitlines()[0][:150]}") if d.get("remedio"): print(f" → {d['remedio']}") if SALIDA_JSON: pathlib.Path(SALIDA_JSON).write_text(json.dumps({ "causas": dict(causas), "recetas_distintas": recetas_distintas, "no_determinismo": [ {"nombre": n, "a": a, "b": b, "divergencias": d} for n, a, b, d in no_determinismo ], "quiza_dep": [ {"nombre": n, "a": a, "b": b, "divergencias": d} for n, a, b, d in quiza_dep ], }, indent=2, ensure_ascii=False)) print(f"\n informe → {SALIDA_JSON}") if __name__ == "__main__": main()