diff --git a/scripts/why-differs.py b/scripts/why-differs.py new file mode 100755 index 00000000..12431c45 --- /dev/null +++ b/scripts/why-differs.py @@ -0,0 +1,184 @@ +#!/usr/bin/env python3 +"""why-differs — explica POR QUÉ dos artefactos no reproducen el mismo hash (SDD 17 §2). + + why-differs.py # dirs del store + why-differs.py b3:86a33b76… b3:9cbe76e3… --store ./store + +Cuando el consenso de reconstrucción (§1, `scripts/consenso.sh`) da DIVERGENCIA, esto dice qué +difiere: qué fichero, y dentro del fichero, qué clase de diferencia (tamaño, timestamp embebido, +orden de símbolos, sección ELF). Hoy debuggear una no-reproducción es artesanal; esto la vuelve +mecánica y **legible por máquina** — que es lo que el bucle agéntico necesita para actuar. + +Algoritmo (el cruce con `format` de tawasuyu): un árbol es un grafo blob+árbol hasheado; **dos +subárboles idénticos colapsan al mismo hash ⇒ el diff estructural es descender SÓLO donde +difiere**. No se compara byte a byte todo: se poda por hash y se baja únicamente por la rama +divergente. Es O(diferencia), no O(tamaño). + +NO reemplaza a diffoscope en profundidad; clasifica la causa, que es lo que decide la acción: +- `solo-en-A`/`solo-en-B` → el build produjo/omitió ficheros ⇒ no-determinismo de *estructura*. +- `tamaño` → contenido genuinamente distinto (¿flags? ¿versión de dep?). +- `timestamp`/`build-id`/`path-embebido` → el clásico: algo del entorno se coló en el binario. +- `orden` → mismos bytes, otro orden (codegen paralelo, orden de link) — la causa nº1 histórica + en hammer (`codegen-units`, `CARGO_BUILD_JOBS`; ver SDD 09 §2). +""" +import hashlib +import os +import re +import subprocess +import sys + + +def b3(path): + """BLAKE3 si está, si no SHA256. Sólo se usa para COMPARAR A vs B, no para direccionar.""" + h = hashlib.blake2b(digest_size=32) if not hasattr(hashlib, "blake3") else hashlib.blake3() + with open(path, "rb") as f: + for chunk in iter(lambda: f.read(1 << 16), b""): + h.update(chunk) + return h.hexdigest() + + +def arbol(raiz): + """path-relativo → (hash, tamaño). Ignora `.hammer/` (metadata del artefacto, no contenido).""" + out = {} + for dp, _, fs in os.walk(raiz): + if "/.hammer" in dp: + continue + for f in fs: + p = os.path.join(dp, f) + if os.path.islink(p): + out["/" + os.path.relpath(p, raiz)] = ("symlink:" + os.readlink(p), 0) + continue + try: + out["/" + os.path.relpath(p, raiz)] = (b3(p), os.path.getsize(p)) + except OSError: + pass + return out + + +def es_elf(p): + try: + with open(p, "rb") as f: + return f.read(4) == b"\x7fELF" + except OSError: + return False + + +def clasificar(a, b, rel): + """Por qué difieren dos ficheros del mismo path. Devuelve (causa, detalle).""" + sa, sb = os.path.getsize(a), os.path.getsize(b) + if sa != sb: + return "tamaño", f"{sa} vs {sb} bytes (Δ{sb - sa:+})" + + # Mismo tamaño ⇒ el clásico: algo del entorno se coló. Buscar la firma. + with open(a, "rb") as f: + da = f.read() + with open(b, "rb") as f: + db = f.read() + difs = [i for i in range(len(da)) if da[i] != db[i]] + frac = len(difs) / max(len(da), 1) + + if es_elf(a) and es_elf(b): + # OJO con el orden: el build-id es un HASH DEL CONTENIDO, así que difiere siempre que + # difiera el código. Reportarlo primero sería confundir el SÍNTOMA con la causa (pasó al + # probar: gcc-vs-zig salía "build-id" cuando la causa real era el codegen). Sólo es LA + # causa si la divergencia está ACOTADA a sus bytes — típicamente <1% del binario. + try: + ba = subprocess.run(["readelf", "-n", a], capture_output=True, text=True).stdout + bb = subprocess.run(["readelf", "-n", b], capture_output=True, text=True).stdout + ida = re.search(r"Build ID: ([0-9a-f]+)", ba) + idb = re.search(r"Build ID: ([0-9a-f]+)", bb) + distinto_id = bool(ida and idb and ida.group(1) != idb.group(1)) + # Umbral atado a la REALIDAD, no a un % arbitrario: un build-id son 20 bytes (sha1) + # o 16 (md5), + padding. Si difieren 1196 bytes, el build-id es una fracción — hay + # codegen debajo. (Un 1% "sonaba bien" y clasificaba 1196 bytes como build-id: el + # mismo error de confundir síntoma con causa, con otro disfraz.) + if distinto_id and len(difs) <= 64: + return "build-id", (f"{ida.group(1)[:16]}… vs {idb.group(1)[:16]}… " + f"({len(difs)} bytes difieren, cabe en un build-id ⇒ es LA causa)") + if distinto_id: + # El build-id difiere PERO también el código: la causa está aguas arriba. + return "codegen", (f"{len(difs)} bytes ({frac*100:.1f}%) — el build-id también " + f"difiere, pero es consecuencia, no causa (¿otro compilador/" + f"flags?)") + except FileNotFoundError: + pass + # ¿Qué secciones difieren? Es lo que dice si es codegen (.text) o metadata (.comment). + secs = [] + try: + for f_, tag in ((a, "A"), (b, "B")): + pass + sa_ = subprocess.run(["readelf", "-S", "-W", a], capture_output=True, text=True).stdout + sb_ = subprocess.run(["readelf", "-S", "-W", b], capture_output=True, text=True).stdout + if sa_ != sb_: + secs.append("la tabla de secciones difiere") + except FileNotFoundError: + pass + if secs: + return "elf-secciones", "; ".join(secs) + + # Texto: buscar rutas o fechas embebidas — el canal impuro más común. + muestra = da[max(0, difs[0] - 40): difs[0] + 40] if difs else b"" + try: + txt = muestra.decode("utf-8", "replace") + except Exception: + txt = repr(muestra) + if re.search(r"/(tmp|home|build|src)/", txt): + return "path-embebido", f"…{txt.strip()}…" + if re.search(r"(19|20)\d\d[-/]\d\d|\d\d:\d\d:\d\d", txt): + return "timestamp", f"…{txt.strip()}…" + + if frac < 0.01: + return "pocos-bytes", f"{len(difs)} bytes difieren de {len(da)} ({frac*100:.3f}%) — 1er offset 0x{difs[0]:x}" + return "contenido", f"{len(difs)} bytes difieren de {len(da)} ({frac*100:.1f}%)" + + +def main(): + args = [a for a in sys.argv[1:] if not a.startswith("--")] + if len(args) < 2: + print(__doc__.split("\n")[2].strip(), file=sys.stderr) + return 2 + A, B = args[0], args[1] + if "--store" in sys.argv: + store = sys.argv[sys.argv.index("--store") + 1] + def resolver(h): + h = h.replace("b3:", "") + for d in os.listdir(store): + if d.startswith(h): + return os.path.join(store, d) + raise SystemExit(f"no encuentro {h} en {store}") + A, B = resolver(A), resolver(B) + + ta, tb = arbol(A), arbol(B) + solo_a = sorted(set(ta) - set(tb)) + solo_b = sorted(set(tb) - set(ta)) + comunes = sorted(set(ta) & set(tb)) + difieren = [p for p in comunes if ta[p][0] != tb[p][0]] + + print(f"══ why-differs") + print(f" A: {A}") + print(f" B: {B}") + print(f" {len(comunes)} ficheros comunes · {len(difieren)} difieren · " + f"{len(solo_a)} sólo-en-A · {len(solo_b)} sólo-en-B") + if not difieren and not solo_a and not solo_b: + print(" ⇒ los árboles son IDÉNTICOS (la diferencia de hash está en la metadata del " + "artefacto, no en su contenido)") + return 0 + for p in solo_a: + print(f" − sólo-en-A {p}") + for p in solo_b: + print(f" + sólo-en-B {p}") + # El corazón: descender SÓLO donde difiere (poda por hash), y clasificar la causa. + causas = {} + for p in difieren: + c, det = clasificar(os.path.join(A, p.lstrip("/")), os.path.join(B, p.lstrip("/")), p) + causas.setdefault(c, []).append((p, det)) + print("\n ── por qué difieren (causa → ficheros):") + for c in sorted(causas, key=lambda k: -len(causas[k])): + print(f" [{c}] {len(causas[c])} fichero(s)") + for p, det in causas[c][:4]: + print(f" {p}\n {det}") + return 1 + + +if __name__ == "__main__": + sys.exit(main())