gioser

sergio/takana

> Una distribución Linux construida con un laboratorio funcional y hermético en el > sótano, y una terminal mutable, clásica y anárquica en el piso de arriba — diseñada > desde el suelo para que una IA programadora entienda, modifique y co…

TOML 1572Shell 203Rust 109Markdown 993728 Sutichaykuna

⎇ 6e2772109867afcb780dbae97f48210ccd45d9ec scripts/why-differs.py Python · 184 siq'ikuna · 8.1 KB Ñawpa willay Pi imata llamkarqan ch'uya

cierres §2: why-differs — explica POR QUÉ dos artefactos no reproducen e9b628372 · sergio ·

1#!/usr/bin/env python3
2"""why-differs — explica POR QUÉ dos artefactos no reproducen el mismo hash (SDD 17 §2).
3
4    why-differs.py <artefacto-A> <artefacto-B>          # dirs del store
5    why-differs.py b3:86a33b76… b3:9cbe76e3… --store ./store
6
7Cuando el consenso de reconstrucción (§1, `scripts/consenso.sh`) da DIVERGENCIA, esto dice qué
8difiere: qué fichero, y dentro del fichero, qué clase de diferencia (tamaño, timestamp embebido,
9orden de símbolos, sección ELF). Hoy debuggear una no-reproducción es artesanal; esto la vuelve
10mecánica y **legible por máquina** — que es lo que el bucle agéntico necesita para actuar.
11
12Algoritmo (el cruce con `format` de tawasuyu): un árbol es un grafo blob+árbol hasheado; **dos
13subárboles idénticos colapsan al mismo hash ⇒ el diff estructural es descender SÓLO donde
14difiere**. No se compara byte a byte todo: se poda por hash y se baja únicamente por la rama
15divergente. Es O(diferencia), no O(tamaño).
16
17NO reemplaza a diffoscope en profundidad; clasifica la causa, que es lo que decide la acción:
18- `solo-en-A`/`solo-en-B` → el build produjo/omitió ficheros ⇒ no-determinismo de *estructura*.
19- `tamaño` → contenido genuinamente distinto (¿flags? ¿versión de dep?).
20- `timestamp`/`build-id`/`path-embebido` → el clásico: algo del entorno se coló en el binario.
21- `orden` → mismos bytes, otro orden (codegen paralelo, orden de link) — la causa nº1 histórica
22  en hammer (`codegen-units`, `CARGO_BUILD_JOBS`; ver SDD 09 §2).
23"""
24import hashlib
25import os
26import re
27import subprocess
28import sys
29
30
31def b3(path):
32    """BLAKE3 si está, si no SHA256. Sólo se usa para COMPARAR A vs B, no para direccionar."""
33    h = hashlib.blake2b(digest_size=32) if not hasattr(hashlib, "blake3") else hashlib.blake3()
34    with open(path, "rb") as f:
35        for chunk in iter(lambda: f.read(1 << 16), b""):
36            h.update(chunk)
37    return h.hexdigest()
38
39
40def arbol(raiz):
41    """path-relativo → (hash, tamaño). Ignora `.hammer/` (metadata del artefacto, no contenido)."""
42    out = {}
43    for dp, _, fs in os.walk(raiz):
44        if "/.hammer" in dp:
45            continue
46        for f in fs:
47            p = os.path.join(dp, f)
48            if os.path.islink(p):
49                out["/" + os.path.relpath(p, raiz)] = ("symlink:" + os.readlink(p), 0)
50                continue
51            try:
52                out["/" + os.path.relpath(p, raiz)] = (b3(p), os.path.getsize(p))
53            except OSError:
54                pass
55    return out
56
57
58def es_elf(p):
59    try:
60        with open(p, "rb") as f:
61            return f.read(4) == b"\x7fELF"
62    except OSError:
63        return False
64
65
66def clasificar(a, b, rel):
67    """Por qué difieren dos ficheros del mismo path. Devuelve (causa, detalle)."""
68    sa, sb = os.path.getsize(a), os.path.getsize(b)
69    if sa != sb:
70        return "tamaño", f"{sa} vs {sb} bytes (Δ{sb - sa:+})"
71
72    # Mismo tamaño ⇒ el clásico: algo del entorno se coló. Buscar la firma.
73    with open(a, "rb") as f:
74        da = f.read()
75    with open(b, "rb") as f:
76        db = f.read()
77    difs = [i for i in range(len(da)) if da[i] != db[i]]
78    frac = len(difs) / max(len(da), 1)
79
80    if es_elf(a) and es_elf(b):
81        # OJO con el orden: el build-id es un HASH DEL CONTENIDO, así que difiere siempre que
82        # difiera el código. Reportarlo primero sería confundir el SÍNTOMA con la causa (pasó al
83        # probar: gcc-vs-zig salía "build-id" cuando la causa real era el codegen). Sólo es LA
84        # causa si la divergencia está ACOTADA a sus bytes — típicamente <1% del binario.
85        try:
86            ba = subprocess.run(["readelf", "-n", a], capture_output=True, text=True).stdout
87            bb = subprocess.run(["readelf", "-n", b], capture_output=True, text=True).stdout
88            ida = re.search(r"Build ID: ([0-9a-f]+)", ba)
89            idb = re.search(r"Build ID: ([0-9a-f]+)", bb)
90            distinto_id = bool(ida and idb and ida.group(1) != idb.group(1))
91            # Umbral atado a la REALIDAD, no a un % arbitrario: un build-id son 20 bytes (sha1)
92            # o 16 (md5), + padding. Si difieren 1196 bytes, el build-id es una fracción — hay
93            # codegen debajo. (Un 1% "sonaba bien" y clasificaba 1196 bytes como build-id: el
94            # mismo error de confundir síntoma con causa, con otro disfraz.)
95            if distinto_id and len(difs) <= 64:
96                return "build-id", (f"{ida.group(1)[:16]}… vs {idb.group(1)[:16]}… "
97                                    f"({len(difs)} bytes difieren, cabe en un build-id ⇒ es LA causa)")
98            if distinto_id:
99                # El build-id difiere PERO también el código: la causa está aguas arriba.
100                return "codegen", (f"{len(difs)} bytes ({frac*100:.1f}%) — el build-id también "
101                                   f"difiere, pero es consecuencia, no causa (¿otro compilador/"
102                                   f"flags?)")
103        except FileNotFoundError:
104            pass
105        # ¿Qué secciones difieren? Es lo que dice si es codegen (.text) o metadata (.comment).
106        secs = []
107        try:
108            for f_, tag in ((a, "A"), (b, "B")):
109                pass
110            sa_ = subprocess.run(["readelf", "-S", "-W", a], capture_output=True, text=True).stdout
111            sb_ = subprocess.run(["readelf", "-S", "-W", b], capture_output=True, text=True).stdout
112            if sa_ != sb_:
113                secs.append("la tabla de secciones difiere")
114        except FileNotFoundError:
115            pass
116        if secs:
117            return "elf-secciones", "; ".join(secs)
118
119    # Texto: buscar rutas o fechas embebidas — el canal impuro más común.
120    muestra = da[max(0, difs[0] - 40): difs[0] + 40] if difs else b""
121    try:
122        txt = muestra.decode("utf-8", "replace")
123    except Exception:
124        txt = repr(muestra)
125    if re.search(r"/(tmp|home|build|src)/", txt):
126        return "path-embebido", f"…{txt.strip()}…"
127    if re.search(r"(19|20)\d\d[-/]\d\d|\d\d:\d\d:\d\d", txt):
128        return "timestamp", f"…{txt.strip()}…"
129
130    if frac < 0.01:
131        return "pocos-bytes", f"{len(difs)} bytes difieren de {len(da)} ({frac*100:.3f}%) — 1er offset 0x{difs[0]:x}"
132    return "contenido", f"{len(difs)} bytes difieren de {len(da)} ({frac*100:.1f}%)"
133
134
135def main():
136    args = [a for a in sys.argv[1:] if not a.startswith("--")]
137    if len(args) < 2:
138        print(__doc__.split("\n")[2].strip(), file=sys.stderr)
139        return 2
140    A, B = args[0], args[1]
141    if "--store" in sys.argv:
142        store = sys.argv[sys.argv.index("--store") + 1]
143        def resolver(h):
144            h = h.replace("b3:", "")
145            for d in os.listdir(store):
146                if d.startswith(h):
147                    return os.path.join(store, d)
148            raise SystemExit(f"no encuentro {h} en {store}")
149        A, B = resolver(A), resolver(B)
150
151    ta, tb = arbol(A), arbol(B)
152    solo_a = sorted(set(ta) - set(tb))
153    solo_b = sorted(set(tb) - set(ta))
154    comunes = sorted(set(ta) & set(tb))
155    difieren = [p for p in comunes if ta[p][0] != tb[p][0]]
156
157    print(f"══ why-differs")
158    print(f"   A: {A}")
159    print(f"   B: {B}")
160    print(f"   {len(comunes)} ficheros comunes · {len(difieren)} difieren · "
161          f"{len(solo_a)} sólo-en-A · {len(solo_b)} sólo-en-B")
162    if not difieren and not solo_a and not solo_b:
163        print("   ⇒ los árboles son IDÉNTICOS (la diferencia de hash está en la metadata del "
164              "artefacto, no en su contenido)")
165        return 0
166    for p in solo_a:
167        print(f"   − sólo-en-A  {p}")
168    for p in solo_b:
169        print(f"   + sólo-en-B  {p}")
170    # El corazón: descender SÓLO donde difiere (poda por hash), y clasificar la causa.
171    causas = {}
172    for p in difieren:
173        c, det = clasificar(os.path.join(A, p.lstrip("/")), os.path.join(B, p.lstrip("/")), p)
174        causas.setdefault(c, []).append((p, det))
175    print("\n   ── por qué difieren (causa → ficheros):")
176    for c in sorted(causas, key=lambda k: -len(causas[k])):
177        print(f"   [{c}] {len(causas[c])} fichero(s)")
178        for p, det in causas[c][:4]:
179            print(f"       {p}\n           {det}")
180    return 1
181
182
183if __name__ == "__main__":
184    sys.exit(main())