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:
@@ -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).
|
||||
|
||||
@@ -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}");
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user