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>
This commit is contained in:
2026-07-05 20:23:19 -04:00
co-authored by Claude Opus 4.8
parent 9be7bdd707
commit bbf4411af0
3 changed files with 143 additions and 4 deletions
+83
View File
@@ -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).
+47
View File
@@ -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}");
}
+13 -4
View File
@@ -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