From bbf4411af0a3b9596b997faf58a91b0fdaaca473 Mon Sep 17 00:00:00 2001 From: sergio Date: Sun, 5 Jul 2026 20:23:19 -0400 Subject: [PATCH] =?UTF-8?q?H4d:=20`hammer=20compat=20`=20=E2=80=94?= =?UTF-8?q?=20la=20b=C3=BAsqueda=20que=20particiona=20un=20repo=20contra?= =?UTF-8?q?=20lo=20instalado=20(SDD=2015=20=C2=A7H4)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- crates/hammer-cli/src/main.rs | 83 ++++++++++++++++++++++++++ crates/hammer-cli/tests/compat_gate.rs | 47 +++++++++++++++ docs/15-frontier-ai-native.md | 17 ++++-- 3 files changed, 143 insertions(+), 4 deletions(-) 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 = 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::>().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 ` 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 `.** 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 `: 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