cierre #2 del SDD 17: hammer why-differs — el diffoscope propio

Cuando un artefacto no reproduce, el store sólo sabe decir "el hash no coincide" y el resto es
trabajo artesanal. Esto responde POR QUÉ, en términos de la CAUSA y no del byte:

- gzip con MTIME embebido (bytes 4..8)      → remedio: `gzip -n`
- cabecera `ar` de un `.a` (mtime/uid/gid)  → remedio: modo determinista (`ar D`)
- secciones ELF, con lectura experta: sólo `.comment` ⇒ otra versión de compilador; sólo
  `.debug_*` ⇒ rutas de build; sólo `.symtab`/`.dynsym` ⇒ orden de símbolos (código idéntico);
  sólo build-id ⇒ residuo, no causa raíz. En un `.a` dice QUÉ MIEMBRO difiere.
- ruta del árbol de build embebida, texto (línea que difiere), y bytes como último recurso.

Y sobre todo trae la EVIDENCIA, no sólo la hipótesis: para las secciones de texto extrae las
cadenas que están en un ELF y no en el otro. Caso real que lo motivó (alsa-lib): la
interpretación decía "típicamente rutas de build" y la evidencia mostró
`/src/target/release/build/libsodium-sys-<hash-cargo>/out/…`. Sin la cadena era una corazonada;
reproducir eso a mano cuesta varios readelf, la herramienta lo da en 40ms.

Descenso, no comparación total: sólo baja donde los hashes difieren (el cruce con format/
reconcile del SDD 17). Sin dependencias externas — parsers gzip/ar/ELF propios, como manda el
ADR 0004: un diffoscope de verdad se apoya en medio mundo de binarios ajenos.

`--json` para el bucle agéntico; exit 0 si reproduce, 1 si diverge (encadenable en scripts).
`scripts/why-differs-barrido.sh` lo pasa por todo el store y separa los dos casos que se
confunden a ojo: recipe.toml distinto (divergencia esperada) vs recipe.toml IDÉNTICO y artefacto
distinto (no-reproducción a investigar).

5 tests nuevos; los 142 de hammer-core siguen en verde.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-07-21 22:35:03 -04:00
co-authored by Claude Opus 4.8
parent 6b38b46293
commit 895401b479
4 changed files with 967 additions and 0 deletions
+117
View File
@@ -0,0 +1,117 @@
#!/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 = NO-REPRODUCCIÓN REAL.**
Eso es un bug de reproducibilidad, y el informe ya dice la causa (gzip mtime, cabecera ar,
sección ELF, ruta de build embebida…).
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 `<hash>-<nombre>`."""
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}
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_reproducen = [] # misma receta, distinto artefacto ⇒ bug real
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
else:
no_reproducen.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, artefacto distinto (NO REPRODUCE): {len(no_reproducen)}")
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_reproducen:
print("\n══ NO-REPRODUCCIONES (misma receta ⇒ el artefacto debería ser idéntico):")
for nombre, ha, hb, divs in no_reproducen:
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_reproducen": [
{"nombre": n, "a": a, "b": b, "divergencias": d} for n, a, b, d in no_reproducen
],
}, indent=2, ensure_ascii=False))
print(f"\n informe → {SALIDA_JSON}")
if __name__ == "__main__":
main()