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 1572Shell 203Rust 109Markdown 993728 Definiciones

H4d: `hammer compat <repo>` — la búsqueda que particiona un repo contra lo instalado (SDD 15 §H4)

Responde la pregunta que arrancó §H4 ("cuando busco, ¿cuáles puedo adoptar?"): es el `filtrar`
del prototipo wawa-memo, ahora sobre el repo real y READ-ONLY (no construye ni toca nada).
Evalúa cada paquete contra el estado instalado y lo parte en {compatibles, requieren-elección,
incompatibles}, combinando la vía declarada (slots, H4b) con la observada (paths, H4c):
incompatible domina, colisión (de slot o fichero) -> elección, si no compatible.

Verificado e2e real (tests/compat_gate.rs): un repo de dos paquetes se parte correctamente
(uno choca de fichero con lo instalado -> elección; otro limpio -> compatible).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

bbf4411af0a3b9596b997faf58a91b0fdaaca473 · por sergio · (hace 3 meses) · padre 9be7bdd70 · ver el árbol en este commit

Delta semántico · 4

crates/hammer-cli/src/main.rs

crates/hammer-cli/tests/compat_gate.rs

3 archivos · +143 −4

diff --git a/crates/hammer-cli/src/main.rs b/crates/hammer-cli/src/main.rs
index be2fe239..11e6fb16 100644
--- a/crates/hammer-cli/src/main.rs
+++ b/crates/hammer-cli/src/main.rs
@@ -261,6 +261,17 @@ enum Cmd {
         #[arg(long, default_value = hammer_core::installed::DEFAULT_DB)]
         db: PathBuf,
     },
+    /// [H4d] BÚSQUEDA de compatibilidad: particiona un repo entero contra el estado instalado en
+    /// {compatibles, requieren-elección, incompatibles} — sin construir ni tocar nada. Responde
+    /// "cuando busco, ¿cuáles puedo adoptar?" (SDD 15 §H4).
+    Compat {
+        /// Repositorio: directorio local (con `index.json`) o URL HTTP(S).
+        #[arg(long, default_value = DEFAULT_REPO)]
+        repo: String,
+        /// Base de datos de instalados contra la que evaluar.
+        #[arg(long, default_value = hammer_core::installed::DEFAULT_DB)]
+        db: PathBuf,
+    },
     /// [Etapa G] Importa una receta DESDE nixpkgs: toma el JSON normalizado que produce
     /// `scripts/nix-import.sh` (vía `nix eval`) y emite una receta hammer (`.toml`). Trae la
     /// RECETA (source+hash+deps+flags), nunca el binario del cache de nix — hammer reconstruye.
@@ -875,6 +886,7 @@ fn main() -> anyhow::Result<()> {
         }
         Cmd::Uninstall { name, db } => run_uninstall(&name, &db)?,
         Cmd::Installed { db } => run_installed_list(&db)?,
+        Cmd::Compat { repo, db } => run_compat(&repo, &db)?,
         Cmd::ImportNix { file, out } => run_import_nix(&file, out.as_deref())?,
         Cmd::ImportAlpine { file, out } => run_import_alpine(&file, out.as_deref())?,
         Cmd::Pin { recipe, out } => run_pin(&recipe, out.as_deref())?,
@@ -1961,6 +1973,77 @@ fn run_installed_list(db_path: &std::path::Path) -> anyhow::Result<()> {
     Ok(())
 }
 
+/// [H4d] La BÚSQUEDA de compatibilidad: evalúa cada paquete del repo contra el estado instalado
+/// y lo clasifica. Es `compat::filtrar` del prototipo `wawa-memo`, ahora sobre el repo real —
+/// read-only, no construye ni toca nada. Combina la vía DECLARADA (slots, H4b) con la OBSERVADA
+/// (paths que el paquete escribe, H4c): incompatible (requisito sin resolver) domina; si no, una
+/// colisión (de slot o de fichero) ⇒ requiere elección; si no, compatible.
+fn run_compat(repo: &str, db_path: &std::path::Path) -> anyhow::Result<()> {
+    let source = RepoSource::parse(repo);
+    let index = source.read_index()?;
+    let idb = hammer_core::InstalledDb::load(db_path)?;
+    let estado = idb.system_state();
+
+    // Materializamos TODOS los `.swm` (para HTTP los baja; para local es el propio dir).
+    let all_files: Vec<&str> = index.packages.iter().map(|p| p.file.as_str()).collect();
+    let mut _tmp: Option<tempfile::TempDir> = None;
+    let repo_dir = source.materialize(&index, &all_files, &mut _tmp)?;
+
+    let (mut compatibles, mut elegibles, mut incompatibles) = (Vec::new(), Vec::new(), Vec::new());
+    for entry in &index.packages {
+        let swm_path = hammer_core::RepoIndex::swm_path(&repo_dir, entry);
+        let swm = match swm_path.to_str().map(load_swm) {
+            Some(Ok(s)) => s,
+            _ => {
+                eprintln!("aviso: no pude leer {} — omito", entry.name);
+                continue;
+            }
+        };
+        // 1) Vía DECLARADA (slots): una incompatibilidad domina todo.
+        let slots = source_patch_slots(&swm);
+        let veredicto = hammer_core::compat::evaluar(&estado, &slots);
+        if let hammer_core::CompatVeredicto::Incompatible { slot, requerido, presente } = &veredicto {
+            incompatibles.push(format!(
+                "{:<16} requiere '{slot}' @ {requerido}, hay {}",
+                entry.name,
+                presente.as_deref().unwrap_or("(ausente)")
+            ));
+            continue;
+        }
+        // 2) Colisión: de slot declarado (H4b) o de fichero observado (H4c) ⇒ requiere elección.
+        let paths = hammer_core::compat::output_paths(&swm);
+        let pcols = hammer_core::compat::path_collisions(&idb, &entry.name, &paths);
+        if let hammer_core::CompatVeredicto::Colision { slot, entrante, .. } = &veredicto {
+            elegibles.push(format!("{:<16} colisión de slot '{slot}' (quiere {entrante})", entry.name));
+        } else if !pcols.is_empty() {
+            let d = pcols.iter().map(|c| format!("{} (de '{}')", c.path, c.propietario)).collect::<Vec<_>>().join(", ");
+            elegibles.push(format!("{:<16} colisión de fichero: {d}", entry.name));
+        } else {
+            compatibles.push(entry.name.clone());
+        }
+    }
+
+    println!(
+        "compat: {} paquete(s) en {} contra {} superficie(s) instalada(s)",
+        index.packages.len(),
+        source.label(),
+        estado.len()
+    );
+    println!("\ncompatibles ({}):", compatibles.len());
+    for n in &compatibles {
+        println!("  ✓ {n}");
+    }
+    println!("\nrequieren elección ({}) — pasá `install --force-slots`:", elegibles.len());
+    for n in &elegibles {
+        println!("  ~ {n}");
+    }
+    println!("\nincompatibles ({}) — descartar:", incompatibles.len());
+    for n in &incompatibles {
+        println!("  ✗ {n}");
+    }
+    Ok(())
+}
+
 /// Desinstala un paquete: borra los ficheros que registró (salvo los que otro paquete instalado
 /// también aporta — refcount) y quita su entrada de la DB. NO deshace `config_edit` (modifican
 /// ficheros ajenos, no creados por el paquete).
diff --git a/crates/hammer-cli/tests/compat_gate.rs b/crates/hammer-cli/tests/compat_gate.rs
index 47bac76a..7bf025d0 100644
--- a/crates/hammer-cli/tests/compat_gate.rs
+++ b/crates/hammer-cli/tests/compat_gate.rs
@@ -75,3 +75,50 @@ fn install_aborta_ante_colision_de_fichero_y_force_slots_la_supera() {
         "el fichero debía escribirse con --force-slots. stderr: {err}"
     );
 }
+
+/// H4d e2e: `hammer compat <repo>` particiona un repo contra el estado instalado. La búsqueda
+/// del prototipo `wawa-memo` (`filtrar`), ahora sobre el repo real y read-only.
+#[test]
+fn compat_particiona_el_repo_contra_lo_instalado() {
+    let tmp = tempfile::tempdir().unwrap();
+    let repo = tmp.path().join("repo");
+    std::fs::create_dir_all(&repo).unwrap();
+    let db = tmp.path().join("installed.json");
+
+    // Instalado: 'tema-claro' posee /share/logo.png.
+    std::fs::write(
+        &db,
+        r#"{"packages":[{"name":"tema-claro","version":"1","files":["/share/logo.png"]}]}"#,
+    )
+    .unwrap();
+
+    // Dos paquetes en el repo: uno choca (mismo path que tema-claro), otro es limpio (otro path).
+    for (name, path) in [("tema-oscuro", "/share/logo.png"), ("fondo-montaña", "/share/fondo.png")] {
+        let content = format!("bytes-{name}");
+        let hash = ArtifactHash::of_inputs(&[content.as_bytes()]);
+        let b64 = STANDARD.encode(content.as_bytes());
+        let swm = format!(
+            "swm_version: 1\nbase:\n  distro_version: dev\n  pins: {{}}\nmutations:\n\
+             - type: file_drop\n  path: {path}\n  content_hash: {}\n  content_b64: {}\n",
+            hash.as_str(), b64
+        );
+        std::fs::write(repo.join(format!("{name}-1.swm")), swm).unwrap();
+    }
+    let index = r#"{"packages":[
+        {"name":"tema-oscuro","version":"1","file":"tema-oscuro-1.swm","distro_version":"dev","deps":[]},
+        {"name":"fondo-montaña","version":"1","file":"fondo-montaña-1.swm","distro_version":"dev","deps":[]}
+    ]}"#;
+    std::fs::write(repo.join("index.json"), index).unwrap();
+
+    let out = Command::new(env!("CARGO_BIN_EXE_hammer"))
+        .args(["compat", "--repo", repo.to_str().unwrap(), "--db", db.to_str().unwrap()])
+        .output()
+        .unwrap();
+    assert!(out.status.success());
+    let stdout = String::from_utf8_lossy(&out.stdout);
+    // fondo-montaña es compatible; tema-oscuro requiere elección (colisión de fichero).
+    assert!(stdout.contains("compatibles (1)"), "stdout: {stdout}");
+    assert!(stdout.contains("✓ fondo-montaña"), "stdout: {stdout}");
+    assert!(stdout.contains("requieren elección (1)"), "stdout: {stdout}");
+    assert!(stdout.contains("tema-oscuro"), "stdout: {stdout}");
+}
diff --git a/docs/15-frontier-ai-native.md b/docs/15-frontier-ai-native.md
index 93de023a..9f7afa2f 100644
--- a/docs/15-frontier-ai-native.md
+++ b/docs/15-frontier-ai-native.md
@@ -336,9 +336,17 @@ La definición dura de compatibilidad de una config `C` contra mi estado `E`:
     escriben `/share/logo.png`; el segundo aborta con "COLISIÓN de fichero" **y no escribe nada**;
     con `--force-slots` la elección se respeta y el fichero se escribe.
   Con H4c, un paquete **sin declarar slots** ya participa del gate por lo que de verdad toca.
-  Lo que queda abierto es **derivar la otra mitad** (los `requires` observados: de qué depende
-  el paquete) y subir el grano del path-slot a superficies más ricas (un módulo wasm de wawa,
-  un componente), además del **colapso** con el lado proceso.
+- **H4d** ✅ — **La BÚSQUEDA: `hammer compat <repo>`.** El `filtrar` del prototipo `wawa-memo`,
+  ahora sobre el repo real y **read-only** (no construye ni toca nada). Responde la pregunta que
+  arrancó §H4 — *"cuando busco, ¿cuáles puedo adoptar?"*: evalúa **cada** paquete del repo contra
+  el estado instalado y lo particiona en **{compatibles, requieren-elección, incompatibles}**,
+  combinando la vía declarada (slots, H4b) con la observada (paths, H4c) — incompatible domina,
+  colisión (de slot o de fichero) ⇒ elección, si no compatible. **Verificado e2e real**
+  (`tests/compat_gate.rs`): un repo de dos paquetes se parte correctamente (uno choca de fichero
+  con lo instalado ⇒ elección, otro limpio ⇒ compatible).
+  Lo que queda abierto: **derivar la otra mitad** (los `requires` observados: de qué depende el
+  paquete, no sólo qué escribe), subir el grano del path-slot a superficies más ricas (un módulo
+  wasm de wawa, un componente), y el **colapso** con el lado proceso.
 
 **Frontera honesta.** H4a demuestra el *álgebra* sobre hashes abstractos; lo que **no**
 resuelve es la **granularidad de los slots** — dos configs pueden no colisionar en el slot
@@ -400,7 +408,8 @@ H3a (design-doc)  ──► registrar la visión, barato
              └► H4a ✅ (config = conjunto de slots por hash: compatible/completa/segura)
                   └► H4b ✅ (slots en la receta/.swm real + gate en `hammer install`)
                        └► H4c ✅ (superficies OBSERVADAS: colisión de fichero, sin declarar slots)
-                            └► [proceso] replay del MonotonicLog  ──► plan OS-CRDT (otro agente)
+                            └► H4d ✅ (`hammer compat <repo>`: la búsqueda que particiona un repo)
+                                 └► [proceso] replay del MonotonicLog  ──► plan OS-CRDT (otro agente)
 ```
 
 Recomendación: **H1 primero** (empuja la frontera que ya tenemos, sin apuestas). **H2a** en