tawasuyu

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 1574Shell 204Rust 111Markdown 993763 Definiciones

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>

895401b479b28cb8aa2a4473e3526d0e77f388ac · por sergio · (hace 3 meses) · padre 6b38b4629 · ver el árbol en este commit

Delta semántico · 48

crates/hammer-cli/src/main.rs

  • cambió de firma enum Cmd
  • cambió por dentro fn main

crates/hammer-core/src/differs.rs

crates/hammer-core/src/lib.rs

4 archivos · +967 −0

diff --git a/crates/hammer-cli/src/main.rs b/crates/hammer-cli/src/main.rs
index 3a8c4977..63aaeaf7 100644
--- a/crates/hammer-cli/src/main.rs
+++ b/crates/hammer-cli/src/main.rs
@@ -49,6 +49,20 @@ enum Cmd {
         #[arg(long)]
         check: bool,
     },
+    /// [SDD 17 §1.2] Explica POR QUÉ dos artefactos difieren — el diffoscope propio. Cuando un hash
+    /// no reproduce, el store sólo sabe decir "no coincide"; esto nombra la CAUSA: un MTIME embebido
+    /// en un gzip, la cabecera `ar` de un `.a`, qué sección ELF cambió (`.comment` ⇒ otro compilador),
+    /// una ruta del árbol de build filtrada al binario. Desciende sólo donde los hashes difieren.
+    /// Exit 0 si los dos árboles son idénticos, 1 si divergen.
+    WhyDiffers {
+        /// Artefacto A: un directorio del store (`store/<hash>-<nombre>`) o cualquier árbol.
+        a: String,
+        /// Artefacto B, el que se compara contra A.
+        b: String,
+        /// Informe JSON — evidencia legible por máquina para el barrido de granja y el bucle agéntico.
+        #[arg(long)]
+        json: bool,
+    },
     /// [Atestación] Verifica un árbol FHS (p.ej. un product-rootfs hidratado) contra su
     /// `/ente/attest.json`: recomputa el BLAKE3 de cada binario crítico y lo compara. Es el gate de
     /// integridad que arje aplicará al boot, hecho hoy por hammer ("reproducir, no confiar"). Exit≠0
@@ -785,6 +799,41 @@ fn main() -> anyhow::Result<()> {
                 println!("{hash}");
             }
         }
+        Cmd::WhyDiffers { a, b, json } => {
+            let informe = hammer_core::why_differs(std::path::Path::new(&a), std::path::Path::new(&b))?;
+            if json {
+                println!("{}", serde_json::to_string_pretty(&informe)?);
+            } else {
+                println!("== why-differs");
+                println!("   A: {}", informe.a);
+                println!("   B: {}", informe.b);
+                println!(
+                    "   {} entradas idénticas · {} divergen",
+                    informe.identicos,
+                    informe.divergencias.len()
+                );
+                for d in &informe.divergencias {
+                    println!("\n   {}", d.ruta);
+                    println!("     causa: {}", d.explicacion);
+                    if let Some(r) = &d.remedio {
+                        println!("     → {r}");
+                    }
+                }
+                if informe.reproduce() {
+                    println!("\n   ✓ los dos árboles son idénticos: REPRODUCE");
+                } else {
+                    let resumen: Vec<String> = informe
+                        .por_causa()
+                        .iter()
+                        .map(|(c, n)| format!("{c}×{n}"))
+                        .collect();
+                    println!("\n   resumen: {}", resumen.join("  "));
+                }
+            }
+            if !informe.reproduce() {
+                std::process::exit(1);
+            }
+        }
         Cmd::Attest { rootfs } => {
             let report = hammer_bootstrap::verify_attestation(std::path::Path::new(&rootfs))?;
             for p in &report.ok {
diff --git a/crates/hammer-core/src/differs.rs b/crates/hammer-core/src/differs.rs
new file mode 100644
index 00000000..6e26ba10
--- /dev/null
+++ b/crates/hammer-core/src/differs.rs
@@ -0,0 +1,799 @@
+//! `why-differs` — el diffoscope propio de hammer ([SDD 17 §1.2](../../docs/17-cierres-frontera.md)).
+//!
+//! Cuando un artefacto **no reproduce**, el store sólo sabe decir "el hash no coincide". Eso deja el
+//! trabajo entero al humano: desempacar los dos árboles, `cmp` a mano, adivinar. Este módulo responde
+//! la pregunta siguiente — **POR QUÉ** difieren — y la responde en términos de la CAUSA, no del byte:
+//! un MTIME embebido en un gzip, la cabecera `ar` de un `.a`, la sección `.comment` de un ELF (=otro
+//! compilador), una ruta del árbol de build que se coló en el binario.
+//!
+//! Es un multiplicador doble (por eso es uno de los dos elegidos del SDD 17 §5): hace escalar el
+//! barrido de la granja —hoy debuggear una no-reproducción es artesanal— y produce evidencia
+//! **legible por máquina** (`--json`) para el bucle agéntico.
+//!
+//! **Descenso, no comparación total** (el cruce con `format`/`reconcile`): dos subárboles idénticos
+//! colapsan al mismo hash, así que el diff sólo desciende donde los hashes difieren. Comparar dos
+//! artefactos de miles de ficheros cuesta un hash por fichero y un diagnóstico sólo por los que
+//! divergen.
+//!
+//! **Sin dependencias externas**: los parsers (gzip/ar/ELF) son mínimos y viven acá, en el mismo
+//! estilo que `query::parse_elf_info`. Un diffoscope de verdad se apoya en medio mundo de binarios
+//! ajenos; eso es exactamente lo que hammer no puede permitirse (ADR 0004: el catálogo se construye,
+//! no se importa).
+
+use serde::Serialize;
+use std::collections::BTreeMap;
+use std::os::unix::fs::PermissionsExt;
+use std::path::{Path, PathBuf};
+
+/// Cuánto de un fichero divergente se lee para diagnosticar. Un artefacto puede traer un firmware de
+/// cientos de MB; el diagnóstico no mejora por leerlo entero, y sí empeora la latencia.
+const MAX_DIAGNOSTICO: u64 = 256 * 1024 * 1024;
+
+/// Bytes de contexto que se muestran alrededor del primer byte divergente.
+const VENTANA: usize = 24;
+
+/// Una entrada del árbol, reducida a lo que decide si dos artefactos son el mismo.
+#[derive(Debug, Clone, PartialEq, Eq)]
+enum Entrada {
+    Directorio,
+    /// El destino del symlink (NO se sigue: un symlink que apunta a otro lado ES una divergencia).
+    Symlink(String),
+    Fichero {
+        hash: String,
+        tamano: u64,
+        /// Sólo el bit de ejecución importa: el resto del modo lo normaliza el sellado.
+        ejecutable: bool,
+    },
+}
+
+impl Entrada {
+    fn tipo(&self) -> &'static str {
+        match self {
+            Entrada::Directorio => "directorio",
+            Entrada::Symlink(_) => "symlink",
+            Entrada::Fichero { .. } => "fichero",
+        }
+    }
+}
+
+/// La causa de una divergencia — el veredicto que este módulo existe para dar.
+#[derive(Debug, Clone, Serialize, PartialEq, Eq)]
+#[serde(rename_all = "snake_case", tag = "causa")]
+pub enum Causa {
+    /// Presente sólo en el artefacto A.
+    SoloEnA,
+    /// Presente sólo en el artefacto B.
+    SoloEnB,
+    /// La misma ruta es fichero en uno y symlink/directorio en el otro.
+    TipoDistinto { a: String, b: String },
+    /// Symlinks que apuntan a destinos distintos.
+    SymlinkDistinto { a: String, b: String },
+    /// Mismo contenido, distinto bit de ejecución.
+    ModoDistinto { a_ejecutable: bool, b_ejecutable: bool },
+    /// gzip con el MTIME embebido en la cabecera (bytes 4..8). **La causa clásica** de
+    /// no-reproducibilidad en tarballs y páginas de man comprimidas: se mata con `gzip -n`.
+    GzipMtime { a: u32, b: u32 },
+    /// Cabecera de un miembro de archivo `ar` (los `.a`): mtime/uid/gid. Se mata con `ar D` o
+    /// `ARFLAGS=Dcr` (modo determinista, que pone esos campos a 0).
+    ArCabecera { miembro: String, campo: String, a: String, b: String },
+    /// Secciones ELF que difieren. `.comment` ⇒ otro compilador; `.note.gnu.build-id` sólo ⇒ el
+    /// build-id deriva del resto y no es causa raíz; `.debug_*` ⇒ típicamente rutas del árbol de
+    /// build sin `-fdebug-prefix-map`.
+    ElfSecciones {
+        secciones: Vec<String>,
+        interpretacion: String,
+        /// Cadenas concretas que están en un ELF y no en el otro — la evidencia detrás de la
+        /// interpretación (ver `evidencia_de_cadenas`).
+        evidencia: Vec<String>,
+    },
+    /// Fichero de texto: la primera línea que difiere.
+    TextoLinea { linea: usize, a: String, b: String },
+    /// Un fragmento divergente contiene una ruta del árbol de build (`/build`, `work/sources`,
+    /// `/tmp/…`): el build path se filtró al artefacto.
+    RutaDeBuild { a: String, b: String },
+    /// Nada más específico: primer offset divergente y su ventana en hex.
+    Bytes { offset: u64, a_hex: String, b_hex: String },
+    /// Mismo prefijo, distinto tamaño (uno es truncamiento/extensión del otro).
+    Tamano { a: u64, b: u64 },
+}
+
+impl Causa {
+    /// Etiqueta corta y estable — es la que el bucle agéntico agrupa y cuenta.
+    pub fn etiqueta(&self) -> &'static str {
+        match self {
+            Causa::SoloEnA => "solo-en-a",
+            Causa::SoloEnB => "solo-en-b",
+            Causa::TipoDistinto { .. } => "tipo-distinto",
+            Causa::SymlinkDistinto { .. } => "symlink-distinto",
+            Causa::ModoDistinto { .. } => "modo-distinto",
+            Causa::GzipMtime { .. } => "gzip-mtime",
+            Causa::ArCabecera { .. } => "ar-cabecera",
+            Causa::ElfSecciones { .. } => "elf-secciones",
+            Causa::TextoLinea { .. } => "texto-linea",
+            Causa::RutaDeBuild { .. } => "ruta-de-build",
+            Causa::Bytes { .. } => "bytes",
+            Causa::Tamano { .. } => "tamano",
+        }
+    }
+
+    /// Si esta causa se arregla con una acción conocida, cuál. `None` = hay que investigar.
+    pub fn remedio(&self) -> Option<&'static str> {
+        match self {
+            Causa::GzipMtime { .. } => Some("comprimir con `gzip -n` (no embeber MTIME)"),
+            Causa::ArCabecera { .. } => Some("archivar en modo determinista (`ar D` / `ARFLAGS=Dcr`)"),
+            Causa::RutaDeBuild { .. } => {
+                Some("normalizar el build path (`-ffile-prefix-map`) o construir en ruta fija")
+            }
+            _ => None,
+        }
+    }
+}
+
+/// Una divergencia concreta, ya diagnosticada.
+#[derive(Debug, Clone, Serialize)]
+pub struct Divergencia {
+    /// Ruta relativa a la raíz del artefacto.
+    pub ruta: String,
+    #[serde(flatten)]
+    pub causa: Causa,
+    /// Frase lista para imprimir.
+    pub explicacion: String,
+    /// Qué hacer, si se sabe.
+    pub remedio: Option<String>,
+}
+
+/// El resultado completo de comparar dos artefactos.
+#[derive(Debug, Clone, Serialize)]
+pub struct Informe {
+    pub a: String,
+    pub b: String,
+    /// Entradas con hash idéntico (las que el descenso NO tuvo que mirar por dentro).
+    pub identicos: usize,
+    pub divergencias: Vec<Divergencia>,
+}
+
+impl Informe {
+    /// `true` si los dos árboles son idénticos entrada por entrada.
+    pub fn reproduce(&self) -> bool {
+        self.divergencias.is_empty()
+    }
+
+    /// Cuántas divergencias hay de cada causa — el resumen que el barrido de granja agrega.
+    pub fn por_causa(&self) -> BTreeMap<&'static str, usize> {
+        let mut m = BTreeMap::new();
+        for d in &self.divergencias {
+            *m.entry(d.causa.etiqueta()).or_insert(0) += 1;
+        }
+        m
+    }
+}
+
+/// Compara dos árboles de artefacto y explica cada divergencia.
+pub fn why_differs(a: &Path, b: &Path) -> crate::Result<Informe> {
+    let arbol_a = walk(a)?;
+    let arbol_b = walk(b)?;
+
+    let mut divergencias = Vec::new();
+    let mut identicos = 0usize;
+
+    // Unión ordenada de rutas: BTreeMap ya las da ordenadas, así el informe es estable (y por tanto
+    // diffeable entre corridas — un informe que cambia de orden no sirve como evidencia).
+    let mut rutas: Vec<&String> = arbol_a.keys().chain(arbol_b.keys()).collect();
+    rutas.sort();
+    rutas.dedup();
+
+    for ruta in rutas {
+        match (arbol_a.get(ruta), arbol_b.get(ruta)) {
+            (Some(ea), Some(eb)) if ea == eb => identicos += 1,
+            (Some(ea), Some(eb)) => {
+                let causa = comparar_entradas(ea, eb, &a.join(ruta), &b.join(ruta));
+                divergencias.push(construir(ruta, causa));
+            }
+            (Some(_), None) => divergencias.push(construir(ruta, Causa::SoloEnA)),
+            (None, Some(_)) => divergencias.push(construir(ruta, Causa::SoloEnB)),
+            (None, None) => unreachable!("la ruta salió de la unión de ambos árboles"),
+        }
+    }
+
+    Ok(Informe {
+        a: a.display().to_string(),
+        b: b.display().to_string(),
+        identicos,
+        divergencias,
+    })
+}
+
+fn construir(ruta: &str, causa: Causa) -> Divergencia {
+    Divergencia {
+        ruta: ruta.to_string(),
+        explicacion: explicar(&causa),
+        remedio: causa.remedio().map(str::to_string),
+        causa,
+    }
+}
+
+fn comparar_entradas(ea: &Entrada, eb: &Entrada, pa: &Path, pb: &Path) -> Causa {
+    match (ea, eb) {
+        (Entrada::Symlink(sa), Entrada::Symlink(sb)) => Causa::SymlinkDistinto {
+            a: sa.clone(),
+            b: sb.clone(),
+        },
+        (
+            Entrada::Fichero { hash: ha, ejecutable: xa, .. },
+            Entrada::Fichero { hash: hb, ejecutable: xb, .. },
+        ) if ha == hb && xa != xb => Causa::ModoDistinto {
+            a_ejecutable: *xa,
+            b_ejecutable: *xb,
+        },
+        (Entrada::Fichero { tamano: ta, .. }, Entrada::Fichero { tamano: tb, .. }) => {
+            diagnosticar(pa, pb, *ta, *tb)
+        }
+        _ => Causa::TipoDistinto {
+            a: ea.tipo().to_string(),
+            b: eb.tipo().to_string(),
+        },
+    }
+}
+
+/// El corazón: dos ficheros con distinto contenido — ¿por qué?
+///
+/// El orden de los detectores importa: van de la causa **más específica y accionable** (gzip, ar) a
+/// la más genérica (primer byte divergente). El primero que reconoce el formato gana.
+fn diagnosticar(pa: &Path, pb: &Path, ta: u64, tb: u64) -> Causa {
+    if ta > MAX_DIAGNOSTICO || tb > MAX_DIAGNOSTICO {
+        return Causa::Tamano { a: ta, b: tb };
+    }
+    let (da, db) = match (std::fs::read(pa), std::fs::read(pb)) {
+        (Ok(x), Ok(y)) => (x, y),
+        _ => return Causa::Tamano { a: ta, b: tb },
+    };
+
+    if let Some(c) = detectar_gzip(&da, &db) {
+        return c;
+    }
+    if let Some(c) = detectar_ar(&da, &db) {
+        return c;
+    }
+    if let Some(c) = detectar_elf(&da, &db) {
+        return c;
+    }
+    if let Some(c) = detectar_texto(&da, &db) {
+        return c;
+    }
+    detectar_bytes(&da, &db)
+}
+
+// ── detectores ───────────────────────────────────────────────────────────────────────────────
+
+/// gzip: `1f 8b`, y el MTIME en los bytes 4..8 (little-endian). Si es lo único que cambia, la
+/// divergencia entera se explica por no haber usado `gzip -n`.
+fn detectar_gzip(a: &[u8], b: &[u8]) -> Option<Causa> {
+    if a.len() < 8 || b.len() < 8 || a[0..2] != [0x1f, 0x8b] || b[0..2] != [0x1f, 0x8b] {
+        return None;
+    }
+    let ma = u32::from_le_bytes(a[4..8].try_into().ok()?);
+    let mb = u32::from_le_bytes(b[4..8].try_into().ok()?);
+    if ma == mb {
+        return None;
+    }
+    Some(Causa::GzipMtime { a: ma, b: mb })
+}
+
+/// Un miembro de archivo `ar`: cabecera ASCII de 60 bytes —
+/// nombre[16] mtime[12] uid[6] gid[6] modo[8] tamaño[10] fmag[2].
+struct MiembroAr {
+    nombre: String,
+    mtime: String,
+    uid: String,
+    gid: String,
+    modo: String,
+    tamano: usize,
+    inicio: usize,
+}
+
+fn parsear_ar(d: &[u8]) -> Option<Vec<MiembroAr>> {
+    if d.len() < 8 || &d[0..8] != b"!<arch>\n" {
+        return None;
+    }
+    let campo = |b: &[u8]| String::from_utf8_lossy(b).trim().to_string();
+    let mut out = Vec::new();
+    let mut cur = 8usize;
+    while cur + 60 <= d.len() {
+        let h = &d[cur..cur + 60];
+        if &h[58..60] != b"`\n" {
+            break;
+        }
+        let tamano: usize = campo(&h[48..58]).parse().ok()?;
+        out.push(MiembroAr {
+            nombre: campo(&h[0..16]),
+            mtime: campo(&h[16..28]),
+            uid: campo(&h[28..34]),
+            gid: campo(&h[34..40]),
+            modo: campo(&h[40..48]),
+            tamano,
+            inicio: cur + 60,
+        });
+        // Los miembros se alinean a 2 bytes.
+        cur = cur + 60 + tamano + (tamano % 2);
+    }
+    Some(out)
+}
+
+/// `.a` (y `.deb`/`.lib`): si las cabeceras traen mtime/uid/gid distintos, ésa es la causa — el
+/// contenido de los objetos puede ser idéntico. Se mata archivando en modo determinista.
+fn detectar_ar(a: &[u8], b: &[u8]) -> Option<Causa> {
+    let (ma, mb) = (parsear_ar(a)?, parsear_ar(b)?);
+    for (x, y) in ma.iter().zip(mb.iter()) {
+        for (campo, va, vb) in [
+            ("mtime", &x.mtime, &y.mtime),
+            ("uid", &x.uid, &y.uid),
+            ("gid", &x.gid, &y.gid),
+            ("modo", &x.modo, &y.modo),
+        ] {
+            if va != vb {
+                return Some(Causa::ArCabecera {
+                    miembro: x.nombre.clone(),
+                    campo: campo.to_string(),
+                    a: va.clone(),
+                    b: vb.clone(),
+                });
+            }
+        }
+        // Cabeceras iguales pero contenido distinto ⇒ la causa está DENTRO del objeto: se
+        // diagnostica recursivamente (un `.o` es ELF, así que casi siempre cae en detectar_elf).
+        let (fa, fb) = (x.inicio + x.tamano, y.inicio + y.tamano);
+        if fa <= a.len() && fb <= b.len() {
+            let (ca, cb) = (&a[x.inicio..fa], &b[y.inicio..fb]);
+            if ca != cb {
+                if let Some(Causa::ElfSecciones { secciones, interpretacion, evidencia }) =
+                    detectar_elf(ca, cb)
+                {
+                    // Un `.a` trae decenas de objetos: sin decir cuál, el veredicto obliga a
+                    // desarmar el archivo a mano — justo el trabajo artesanal que esto elimina.
+                    return Some(Causa::ElfSecciones {
+                        secciones,
+                        interpretacion: format!("miembro `{}` — {interpretacion}", x.nombre),
+                        evidencia,
+                    });
+                }
+            }
+        }
+    }
+    None
+}
+
+/// Los bytes de una sección concreta, si existe y cae dentro del fichero.
+fn bytes_de_seccion<'a>(d: &'a [u8], nombre: &str) -> Option<&'a [u8]> {
+    let (_, off, size) = secciones_crudas(d)?.into_iter().find(|(n, ..)| n == nombre)?;
+    d.get(off..off + size)
+}
+
+/// Cadenas NUL-terminadas imprimibles de una sección (`.debug_str`, `.comment`: son tablas de
+/// cadenas). Es lo que convierte "difieren las secciones de depuración" en evidencia concreta.
+fn cadenas_de(d: &[u8], nombre: &str) -> Vec<String> {
+    let Some(b) = bytes_de_seccion(d, nombre) else {
+        return Vec::new();
+    };
+    b.split(|&c| c == 0)
+        .filter(|s| s.len() >= 4 && s.iter().all(|&c| (0x20..0x7f).contains(&c)))
+        .map(|s| String::from_utf8_lossy(s).to_string())
+        .collect()
+}
+
+/// La tabla de secciones como (nombre, offset, tamaño).
+///
+/// Cabecera de sección ELF64: sh_name[4] sh_type[4] sh_flags[8] sh_addr[8] sh_offset[8] sh_size[8] …
+fn secciones_crudas(d: &[u8]) -> Option<Vec<(String, usize, usize)>> {
+    if d.len() < 64 || &d[0..4] != b"\x7fELF" || d[4] != 2 || d[5] != 1 {
+        return None;
+    }
+    let e_shoff = u64::from_le_bytes(d[40..48].try_into().ok()?) as usize;
+    let e_shentsize = u16::from_le_bytes(d[58..60].try_into().ok()?) as usize;
+    let e_shnum = u16::from_le_bytes(d[60..62].try_into().ok()?) as usize;
+    let e_shstrndx = u16::from_le_bytes(d[62..64].try_into().ok()?) as usize;
+    if e_shoff == 0 || e_shnum == 0 || e_shstrndx >= e_shnum || e_shentsize < 64 {
+        return None;
+    }
+    let leer = |i: usize| -> Option<(u32, u64, u64)> {
+        let off = e_shoff + i * e_shentsize;
+        if off + 64 > d.len() {
+            return None;
+        }
+        let sh_name = u32::from_le_bytes(d[off..off + 4].try_into().ok()?);
+        let sh_offset = u64::from_le_bytes(d[off + 24..off + 32].try_into().ok()?);
+        let sh_size = u64::from_le_bytes(d[off + 32..off + 40].try_into().ok()?);
+        Some((sh_name, sh_offset, sh_size))
+    };
+    // La tabla de nombres de sección: sus bytes son cadenas NUL-terminadas indexadas por sh_name.
+    let (_, str_off, str_size) = leer(e_shstrndx)?;
+    let (str_off, str_size) = (str_off as usize, str_size as usize);
+    if str_off + str_size > d.len() {
+        return None;
+    }
+    let strtab = &d[str_off..str_off + str_size];
+
+    let mut out = Vec::with_capacity(e_shnum);
+    for i in 0..e_shnum {
+        let (sh_name, sh_offset, sh_size) = leer(i)?;
+        let ini = sh_name as usize;
+        let nombre = if ini < strtab.len() {
+            let fin = strtab[ini..].iter().position(|&c| c == 0).unwrap_or(0) + ini;
+            String::from_utf8_lossy(&strtab[ini..fin]).to_string()
+        } else {
+            format!("<sección {i}>")
+        };
+        out.push((nombre, sh_offset as usize, sh_size as usize));
+    }
+    Some(out)
+}
+
+/// Las secciones con el hash de su contenido — la base de la comparación.
+fn secciones_elf(d: &[u8]) -> Option<BTreeMap<String, (u64, String)>> {
+    let mut out = BTreeMap::new();
+    for (nombre, off, size) in secciones_crudas(d)? {
+        // SHT_NOBITS (.bss) y las secciones fuera de rango no tienen bytes que hashear.
+        let contenido = match d.get(off..off + size) {
+            Some(b) => blake3::hash(b).to_hex().to_string(),
+            None => String::from("<fuera de rango>"),
+        };
+        out.insert(nombre, (size as u64, contenido));
+    }
+    Some(out)
+}
+
+/// ELF: comparar sección por sección. Saber CUÁLES difieren es casi todo el diagnóstico — es la
+/// diferencia entre "el binario cambió" y "sólo cambió `.comment` ⇒ otro compilador".
+fn detectar_elf(a: &[u8], b: &[u8]) -> Option<Causa> {
+    let (sa, sb) = (secciones_elf(a)?, secciones_elf(b)?);
+    let mut difieren: Vec<String> = Vec::new();
+    for (nombre, va) in &sa {
+        match sb.get(nombre) {
+            Some(vb) if vb == va => {}
+            Some(_) => difieren.push(nombre.clone()),
+            None => difieren.push(format!("{nombre} (sólo en A)")),
+        }
+    }
+    for nombre in sb.keys() {
+        if !sa.contains_key(nombre) {
+            difieren.push(format!("{nombre} (sólo en B)"));
+        }
+    }
+    if difieren.is_empty() {
+        return None;
+    }
+    Some(Causa::ElfSecciones {
+        interpretacion: interpretar_secciones(&difieren),
+        evidencia: evidencia_de_cadenas(a, b, &difieren),
+        secciones: difieren,
+    })
+}
+
+/// Cuántas cadenas divergentes se muestran como evidencia. Más que esto es ruido: el patrón se ve
+/// con dos o tres.
+const MAX_EVIDENCIA: usize = 3;
+
+/// Saca las cadenas que están en un ELF y no en el otro, para las secciones de texto que difieren.
+///
+/// Esto es lo que separa la sospecha del hecho. `.debug_str` difiere ⇒ la hipótesis es "rutas del
+/// árbol de build"; mostrar la cadena la confirma o la desmiente. Caso real que motivó esto
+/// (barrido del 2026-07-21, `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/…` — una ruta con un
+/// hash de cargo que cambia entre builds. Sin la cadena, eso era una corazonada.
+fn evidencia_de_cadenas(a: &[u8], b: &[u8], difieren: &[String]) -> Vec<String> {
+    const INTERESANTES: [&str; 3] = [".debug_str", ".comment", ".rodata"];
+    let mut out = Vec::new();
+    for sec in difieren {
+        let nombre = sec.split(' ').next().unwrap_or(sec);
+        if !INTERESANTES.contains(&nombre) {
+            continue;
+        }
+        let (ca, cb) = (cadenas_de(a, nombre), cadenas_de(b, nombre));
+        let (sa, sb): (std::collections::BTreeSet<_>, std::collections::BTreeSet<_>) =
+            (ca.into_iter().collect(), cb.into_iter().collect());
+        for s in sa.difference(&sb).take(MAX_EVIDENCIA) {
+            out.push(format!("{nombre} sólo en A: {}", recortar(s)));
+        }
+        for s in sb.difference(&sa).take(MAX_EVIDENCIA) {
+            out.push(format!("{nombre} sólo en B: {}", recortar(s)));
+        }
+        if out.len() >= MAX_EVIDENCIA * 2 {
+            break;
+        }
+    }
+    out
+}
+
+/// La lectura experta: qué significa que difieran ESAS secciones y no otras.
+fn interpretar_secciones(difieren: &[String]) -> String {
+    let tiene = |p: &str| difieren.iter().any(|s| s.starts_with(p));
+    let solo = |ps: &[&str]| difieren.iter().all(|s| ps.iter().any(|p| s.starts_with(p)));
+
+    if solo(&[".comment"]) {
+        return "sólo `.comment`: mismo código, distinta VERSIÓN de compilador".into();
+    }
+    if solo(&[".note.gnu.build-id"]) {
+        return "sólo el build-id: deriva del resto del binario — si nada más difiere, \
+                es un residuo, no la causa"
+            .into();
+    }
+    if solo(&[".debug", ".zdebug", ".note.gnu.build-id"]) {
+        return "sólo info de depuración: típicamente rutas del árbol de build sin \
+                `-ffile-prefix-map` (el código ejecutable es idéntico)"
+            .into();
+    }
+    if tiene(".text") || tiene(".data") || tiene(".rodata") {
+        let mut s = String::from("difiere código/datos ejecutables");
+        if tiene(".comment") {
+            s.push_str(" Y `.comment` ⇒ sospechar primero la versión del compilador");
+        }
+        return s;
+    }
+    if solo(&[".symtab", ".strtab", ".dynsym", ".dynstr"]) {
+        return "sólo tablas de símbolos: el ORDEN de los símbolos cambió (enlazado no \
+                determinista); el código es idéntico"
+            .into();
+    }
+    "secciones sin patrón conocido — hay que mirar".into()
+}
+
+/// Rutas del árbol de build que no deberían acabar dentro de un artefacto.
+const MARCAS_BUILD: [&str; 5] = ["work/sources/", "/build/", "/tmp/", "/nix/store/", "/out/"];
+
+/// Texto: la primera línea que difiere dice más que cualquier offset.
+fn detectar_texto(a: &[u8], b: &[u8]) -> Option<Causa> {
+    let (ta, tb) = (std::str::from_utf8(a).ok()?, std::str::from_utf8(b).ok()?);
+    for (i, (la, lb)) in ta.lines().zip(tb.lines()).enumerate() {
+        if la != lb {
+            if MARCAS_BUILD.iter().any(|m| la.contains(m) || lb.contains(m)) {
+                return Some(Causa::RutaDeBuild {
+                    a: recortar(la),
+                    b: recortar(lb),
+                });
+            }
+            return Some(Causa::TextoLinea {
+                linea: i + 1,
+                a: recortar(la),
+                b: recortar(lb),
+            });
+        }
+    }
+    // Mismo prefijo línea a línea: uno tiene más líneas que el otro.
+    let (na, nb) = (ta.lines().count(), tb.lines().count());
+    Some(Causa::TextoLinea {
+        linea: na.min(nb) + 1,
+        a: if na > nb { "<línea de más>".into() } else { "<fin>".into() },
+        b: if nb > na { "<línea de más>".into() } else { "<fin>".into() },
+    })
+}
+
+/// Último recurso: el primer byte que difiere y su ventana. Si en la ventana aparece una ruta de
+/// build, eso es más informativo que el hex y se reporta como tal.
+fn detectar_bytes(a: &[u8], b: &[u8]) -> Causa {
+    let offset = a.iter().zip(b.iter()).position(|(x, y)| x != y);
+    let Some(off) = offset else {
+        return Causa::Tamano {
+            a: a.len() as u64,
+            b: b.len() as u64,
+        };
+    };
+    let ini = off.saturating_sub(VENTANA / 2);
+    let va = &a[ini..(ini + VENTANA).min(a.len())];
+    let vb = &b[ini..(ini + VENTANA).min(b.len())];
+
+    let (sa, sb) = (imprimible(va), imprimible(vb));
+    if MARCAS_BUILD.iter().any(|m| sa.contains(m) || sb.contains(m)) {
+        return Causa::RutaDeBuild { a: sa, b: sb };
+    }
+    Causa::Bytes {
+        offset: off as u64,
+        a_hex: hex(va),
+        b_hex: hex(vb),
+    }
+}
+
+fn hex(d: &[u8]) -> String {
+    d.iter().map(|b| format!("{b:02x}")).collect::<Vec<_>>().join(" ")
+}
+
+fn imprimible(d: &[u8]) -> String {
+    d.iter()
+        .map(|&b| if (0x20..0x7f).contains(&b) { b as char } else { '.' })
+        .collect()
+}
+
+fn recortar(s: &str) -> String {
+    const MAX: usize = 120;
+    if s.chars().count() <= MAX {
+        return s.to_string();
+    }
+    let corto: String = s.chars().take(MAX).collect();
+    format!("{corto}…")
+}
+
+/// La frase que se imprime. Vive acá (y no en el CLI) para que el informe JSON y el humano digan
+/// exactamente lo mismo.
+fn explicar(c: &Causa) -> String {
+    match c {
+        Causa::SoloEnA => "existe sólo en A".into(),
+        Causa::SoloEnB => "existe sólo en B".into(),
+        Causa::TipoDistinto { a, b } => format!("es {a} en A y {b} en B"),
+        Causa::SymlinkDistinto { a, b } => format!("symlink → `{a}` en A, → `{b}` en B"),
+        Causa::ModoDistinto { a_ejecutable, .. } => format!(
+            "mismo contenido, distinto bit de ejecución (A {}, B {})",
+            if *a_ejecutable { "x" } else { "no-x" },
+            if *a_ejecutable { "no-x" } else { "x" }
+        ),
+        Causa::GzipMtime { a, b } => {
+            format!("gzip con MTIME embebido: {a} vs {b} (cabecera, bytes 4..8)")
+        }
+        Causa::ArCabecera { miembro, campo, a, b } => {
+            format!("cabecera `ar` del miembro `{miembro}`: {campo} {a} vs {b}")
+        }
+        Causa::ElfSecciones { secciones, interpretacion, evidencia } => {
+            let mut t = format!("ELF, difieren [{}] — {interpretacion}", secciones.join(", "));
+            for e in evidencia {
+                t.push_str(&format!("\n      · {e}"));
+            }
+            t
+        }
+        Causa::TextoLinea { linea, a, b } => format!("texto, línea {linea}: `{a}` vs `{b}`"),
+        Causa::RutaDeBuild { a, b } => {
+            format!("ruta del árbol de build embebida: `{a}` vs `{b}`")
+        }
+        Causa::Bytes { offset, a_hex, b_hex } => {
+            format!("primer byte distinto en 0x{offset:x}\n      A: {a_hex}\n      B: {b_hex}")
+        }
+        Causa::Tamano { a, b } => format!("distinto tamaño: {a} vs {b} bytes"),
+    }
+}
+
+// ── recorrido del árbol ──────────────────────────────────────────────────────────────────────
+
+fn walk(raiz: &Path) -> crate::Result<BTreeMap<String, Entrada>> {
+    if !raiz.exists() {
+        return Err(crate::Error::Store(format!("no existe: {}", raiz.display())));
+    }
+    let mut out = BTreeMap::new();
+    let mut pila: Vec<PathBuf> = vec![raiz.to_path_buf()];
+    while let Some(dir) = pila.pop() {
+        for entrada in std::fs::read_dir(&dir)? {
+            let entrada = entrada?;
+            let ruta = entrada.path();
+            let rel = ruta
+                .strip_prefix(raiz)
+                .map_err(|e| crate::Error::Store(e.to_string()))?
+                .to_string_lossy()
+                .to_string();
+            let md = std::fs::symlink_metadata(&ruta)?;
+            if md.is_symlink() {
+                let destino = std::fs::read_link(&ruta)?.to_string_lossy().to_string();
+                out.insert(rel, Entrada::Symlink(destino));
+            } else if md.is_dir() {
+                out.insert(rel, Entrada::Directorio);
+                pila.push(ruta);
+            } else {
+                out.insert(
+                    rel,
+                    Entrada::Fichero {
+                        hash: hash_fichero(&ruta)?,
+                        tamano: md.len(),
+                        ejecutable: md.permissions().mode() & 0o111 != 0,
+                    },
+                );
+            }
+        }
+    }
+    Ok(out)
+}
+
+/// BLAKE3 por streaming: un artefacto puede traer ficheros de cientos de MB y no hay razón para
+/// tenerlos enteros en memoria sólo para saber si son iguales.
+fn hash_fichero(p: &Path) -> crate::Result<String> {
+    let mut f = std::fs::File::open(p)?;
+    let mut h = blake3::Hasher::new();
+    std::io::copy(&mut f, &mut h)?;
+    Ok(h.finalize().to_hex().to_string())
+}
+
+#[cfg(test)]
+mod tests {
+    use super::*;
+    use std::io::Write;
+
+    fn escribir(dir: &Path, rel: &str, datos: &[u8]) {
+        let p = dir.join(rel);
+        std::fs::create_dir_all(p.parent().unwrap()).unwrap();
+        std::fs::File::create(&p).unwrap().write_all(datos).unwrap();
+    }
+
+    /// Dos árboles idénticos reproducen: sin divergencias y todo contado como idéntico.
+    #[test]
+    fn arboles_identicos_reproducen() {
+        let t = tempfile::tempdir().unwrap();
+        let (a, b) = (t.path().join("a"), t.path().join("b"));
+        for r in [&a, &b] {
+            escribir(r, "usr/bin/hola", b"contenido");
+            escribir(r, "usr/share/doc/leeme", b"texto\n");
+        }
+        let inf = why_differs(&a, &b).unwrap();
+        assert!(inf.reproduce(), "divergencias: {:?}", inf.divergencias);
+        // usr, usr/bin, usr/bin/hola, usr/share, usr/share/doc, usr/share/doc/leeme
+        assert_eq!(inf.identicos, 6);
+    }
+
+    /// El MTIME de un gzip es LA causa clásica; tiene que salir nombrada, no como "bytes distintos".
+    #[test]
+    fn gzip_mtime_se_nombra_y_trae_remedio() {
+        let t = tempfile::tempdir().unwrap();
+        let (a, b) = (t.path().join("a"), t.path().join("b"));
+        // cabecera gzip mínima: magic, CM, FLG, MTIME(4), XFL, OS
+        let mut ga = vec![0x1f, 0x8b, 0x08, 0x00];
+        ga.extend_from_slice(&1_000u32.to_le_bytes());
+        ga.extend_from_slice(&[0x00, 0x03, 0xde, 0xad]);
+        let mut gb = ga.clone();
+        gb[4..8].copy_from_slice(&2_000u32.to_le_bytes());
+        escribir(&a, "m.gz", &ga);
+        escribir(&b, "m.gz", &gb);
+
+        let inf = why_differs(&a, &b).unwrap();
+        assert_eq!(inf.divergencias.len(), 1);
+        let d = &inf.divergencias[0];
+        assert_eq!(d.causa, Causa::GzipMtime { a: 1000, b: 2000 });
+        assert!(d.remedio.as_ref().unwrap().contains("gzip -n"));
+    }
+
+    /// Una ruta del árbol de build embebida se reporta como tal (no como una línea cualquiera).
+    #[test]
+    fn ruta_de_build_embebida() {
+        let t = tempfile::tempdir().unwrap();
+        let (a, b) = (t.path().join("a"), t.path().join("b"));
+        escribir(&a, "cfg", b"prefix=/home/x/work/sources/zlib-aaa\n");
+        escribir(&b, "cfg", b"prefix=/home/x/work/sources/zlib-bbb\n");
+        let inf = why_differs(&a, &b).unwrap();
+        assert_eq!(inf.divergencias[0].causa.etiqueta(), "ruta-de-build");
+        assert!(inf.divergencias[0].remedio.is_some());
+    }
+
+    /// Presencia/ausencia y symlinks divergentes son divergencias de pleno derecho.
+    #[test]
+    fn presencia_y_symlinks() {
+        let t = tempfile::tempdir().unwrap();
+        let (a, b) = (t.path().join("a"), t.path().join("b"));
+        escribir(&a, "solo-a", b"x");
+        escribir(&b, "solo-b", b"x");
+        std::fs::create_dir_all(a.join("d")).unwrap();
+        std::fs::create_dir_all(b.join("d")).unwrap();
+        std::os::unix::fs::symlink("destino-1", a.join("d/enlace")).unwrap();
+        std::os::unix::fs::symlink("destino-2", b.join("d/enlace")).unwrap();
+
+        let inf = why_differs(&a, &b).unwrap();
+        let etiquetas = inf.por_causa();
+        assert_eq!(etiquetas.get("solo-en-a"), Some(&1));
+        assert_eq!(etiquetas.get("solo-en-b"), Some(&1));
+        assert_eq!(etiquetas.get("symlink-distinto"), Some(&1));
+    }
+
+    /// Un `.a` cuyos miembros sólo difieren en el mtime de la cabecera: causa nombrada + remedio.
+    #[test]
+    fn ar_mtime_de_cabecera() {
+        let t = tempfile::tempdir().unwrap();
+        let (a, b) = (t.path().join("a"), t.path().join("b"));
+        let armar = |mtime: &str| {
+            let mut v = Vec::from(*b"!<arch>\n");
+            v.extend_from_slice(format!("{:<16}", "obj.o").as_bytes());
+            v.extend_from_slice(format!("{mtime:<12}").as_bytes());
+            v.extend_from_slice(format!("{:<6}", "0").as_bytes());
+            v.extend_from_slice(format!("{:<6}", "0").as_bytes());
+            v.extend_from_slice(format!("{:<8}", "100644").as_bytes());
+            v.extend_from_slice(format!("{:<10}", "4").as_bytes());
+            v.extend_from_slice(b"`\n");
+            v.extend_from_slice(b"data");
+            v
+        };
+        escribir(&a, "lib.a", &armar("1700000000"));
+        escribir(&b, "lib.a", &armar("1800000000"));
+        let inf = why_differs(&a, &b).unwrap();
+        assert_eq!(inf.divergencias[0].causa.etiqueta(), "ar-cabecera");
+        assert!(inf.divergencias[0].remedio.as_ref().unwrap().contains("determinista"));
+    }
+}
diff --git a/crates/hammer-core/src/lib.rs b/crates/hammer-core/src/lib.rs
index 03e35922..471d16fb 100644
--- a/crates/hammer-core/src/lib.rs
+++ b/crates/hammer-core/src/lib.rs
@@ -7,6 +7,7 @@
 pub mod apply;
 pub mod caps;
 pub mod compat;
+pub mod differs;
 pub mod hash;
 pub mod installed;
 pub mod proto;
@@ -18,6 +19,7 @@ pub mod store;
 pub mod swm;
 
 pub use caps::{AgentCapsConfig, CapRule};
+pub use differs::{why_differs, Causa, Divergencia, Informe};
 pub use hash::ArtifactHash;
 pub use installed::{InstalledDb, InstalledPackage};
 pub use compat::Veredicto as CompatVeredicto;
diff --git a/scripts/why-differs-barrido.sh b/scripts/why-differs-barrido.sh
new file mode 100755
index 00000000..72182151
--- /dev/null
+++ b/scripts/why-differs-barrido.sh
@@ -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()