El artefacto guarda su recipe.toml pero NO los hashes de sus deps, y el ArtifactHash sí los incluye ⇒ dos sellados con el mismo recipe.toml pueden diferir LEGÍTIMAMENTE porque cambió una dependencia. La primera versión gritaba "NO-REPRODUCCIÓN REAL" sobre 128 paquetes; eso era un superconjunto, no una prueba. Ahora separa por la CAUSA, que sí discrimina: si TODO lo que difiere son metadatos que un cambio de dep no explica (MTIME de gzip, cabecera ar, ruta de build, secciones ELF informativas), es no-determinismo probable; si difiere .text/.data o hay ficheros de más, es indistinguible de un cambio de dep sin reconstruir. 128 → 92 probables + 36 no concluyentes. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
154 lines
6.6 KiB
Bash
Executable File
154 lines
6.6 KiB
Bash
Executable File
#!/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 `<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}
|
|
|
|
|
|
# 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()
|