diff --git a/crates/hammer-cli/src/main.rs b/crates/hammer-cli/src/main.rs index 11e6fb16..ba79e4cd 100644 --- a/crates/hammer-cli/src/main.rs +++ b/crates/hammer-cli/src/main.rs @@ -1707,6 +1707,26 @@ fn run_install( } } + // H4e — vía OBSERVADA de los requisitos: por cada dep de runtime, la versión (expected_hash) + // que hay en el repo (contra la que el paquete se construyó). Si la tengo instalada a OTRO + // hash, la diverguí ⇒ rechazo duro (el caso wayland, derivado — no lo salva --force-slots). + { + let idb = hammer_core::InstalledDb::load(db_path)?; + let req = hammer_core::compat::observed_requires(&entrant_swm, &index); + let conflicts = hammer_core::compat::version_conflicts(&idb, &req); + if !conflicts.is_empty() { + let d = conflicts + .iter() + .map(|c| format!("{} necesita {} pero tenés {}", c.dep, c.requerido, c.instalado)) + .collect::>() + .join("; "); + anyhow::bail!( + "compat: INCOMPATIBLE — '{name}' se construyó contra deps que divergiste: {d}. \ + No encaja con tu sistema (rechazo duro, no lo fuerza --force-slots)." + ); + } + } + // H4b — vía DECLARADA: las superficies (`slots`) que el autor declara reclamar/requerir. let entrant_slots = source_patch_slots(&entrant_swm); if !entrant_slots.is_empty() { @@ -1999,7 +2019,15 @@ fn run_compat(repo: &str, db_path: &std::path::Path) -> anyhow::Result<()> { continue; } }; - // 1) Vía DECLARADA (slots): una incompatibilidad domina todo. + // 1a) Vía OBSERVADA de requisitos (H4e): una dep de runtime divergida ⇒ incompatible. + let req = hammer_core::compat::observed_requires(&swm, &index); + let vconf = hammer_core::compat::version_conflicts(&idb, &req); + if !vconf.is_empty() { + let d = vconf.iter().map(|c| format!("{}@{} (tenés {})", c.dep, c.requerido, c.instalado)).collect::>().join(", "); + incompatibles.push(format!("{:<16} deps divergidas: {d}", entry.name)); + continue; + } + // 1b) 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 { diff --git a/crates/hammer-cli/tests/compat_gate.rs b/crates/hammer-cli/tests/compat_gate.rs index 7bf025d0..8d043496 100644 --- a/crates/hammer-cli/tests/compat_gate.rs +++ b/crates/hammer-cli/tests/compat_gate.rs @@ -122,3 +122,44 @@ fn compat_particiona_el_repo_contra_lo_instalado() { assert!(stdout.contains("requieren elección (1)"), "stdout: {stdout}"); assert!(stdout.contains("tema-oscuro"), "stdout: {stdout}"); } + +/// H4e e2e: `hammer compat` marca INCOMPATIBLE un paquete cuya dep de runtime divergiste +/// (el caso wayland, DERIVADO — sin que el paquete declare `requires`). Read-only: ve el +/// source_patch sin construirlo. +#[test] +fn compat_marca_incompatible_una_dep_divergida() { + 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"); + + // Tengo wayland-protocol instalado a b3:mine (lo diverguí del stock del repo). + std::fs::write( + &db, + r#"{"packages":[{"name":"wayland-protocol","version":"1","hash":"b3:mine","files":[]}]}"#, + ) + .unwrap(); + + // 'app' es un source_patch que depende (runtime) de wayland-protocol. + let app_swm = "swm_version: 1\nbase:\n distro_version: dev\n pins: {}\nmutations:\n\ + - type: source_patch\n repo: git://x/app.git\n commit: abc\n target_bin: /usr/bin/app\n\ + \x20 build:\n compiler: zig-cc\n deps:\n runtime:\n - wayland-protocol\n"; + std::fs::write(repo.join("app-1.swm"), app_swm).unwrap(); + // wayland-protocol también está en el repo, a b3:stock (la versión contra la que app se hizo). + let w_swm = "swm_version: 1\nbase:\n distro_version: dev\n pins: {}\nmutations: []\n"; + std::fs::write(repo.join("w-1.swm"), w_swm).unwrap(); + let index = r#"{"packages":[ + {"name":"app","version":"1","file":"app-1.swm","distro_version":"dev","deps":["wayland-protocol"],"expected_hash":"b3:app"}, + {"name":"wayland-protocol","version":"1","file":"w-1.swm","distro_version":"dev","deps":[],"expected_hash":"b3:stock"} + ]}"#; + 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); + assert!(stdout.contains("incompatibles (1)"), "stdout: {stdout}"); + assert!(stdout.contains("app") && stdout.contains("deps divergidas"), "stdout: {stdout}"); +} diff --git a/crates/hammer-core/src/compat.rs b/crates/hammer-core/src/compat.rs index cf63574d..452f7d6f 100644 --- a/crates/hammer-core/src/compat.rs +++ b/crates/hammer-core/src/compat.rs @@ -163,6 +163,76 @@ pub fn path_collisions( cols } +// ───────────────────────────────────────────────────────────────────────── +// H4e — `requires` OBSERVADOS: la versión contra la que el paquete se construyó +// ───────────────────────────────────────────────────────────────────────── +// +// H4c observó lo que un paquete *escribe*; falta observar de qué *depende a una +// versión* (el caso wayland automático). La fuente observable: un paquete se +// construyó contra la versión de sus deps que hay EN EL REPO. Si el usuario tiene +// esa dep instalada a OTRO hash, la diverguió ⇒ el paquete no encaja. No hace +// falta que el autor prometa `requires`: se lee del cierre (deps del `.swm` + +// `expected_hash` de cada dep en el índice). Es el análogo de sistema a un +// requisito que no resuelve — el mismo rechazo duro de H4b, ahora derivado. + +/// [H4e] Los `requires` OBSERVADOS de un paquete: por cada dep de **runtime** +/// declarada en su `.swm`, la versión (`expected_hash`) que esa dep tiene **en el +/// índice** — o sea, contra la que el paquete se construyó. Deps sin `expected_hash` +/// en el índice se omiten (no hay versión observable que exigir). +pub fn observed_requires( + swm: &Swm, + index: &crate::RepoIndex, +) -> std::collections::BTreeMap { + let mut req = std::collections::BTreeMap::new(); + for m in &swm.mutations { + if let Mutation::SourcePatch { deps, .. } = m { + for dep in &deps.runtime { + if let Some(h) = index.find(dep).and_then(|e| e.expected_hash.clone()) { + req.insert(dep.clone(), h); + } + } + } + } + req +} + +/// Un conflicto de versión observado: una dep de runtime está instalada a un hash +/// **distinto** del que el paquete espera (el que hay en el repo). Es el caso +/// *wayland* derivado: divergiste una superficie de la que este paquete depende. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct ConflictoVersion { + pub dep: String, + /// El hash que el paquete espera (la versión del repo contra la que se construyó). + pub requerido: String, + /// El hash al que la dep está instalada AHORA (divergido). + pub instalado: String, +} + +/// Conflictos de versión del entrante contra el estado instalado: por cada +/// `requires` observado, si la dep está instalada a otro hash. Una dep que NO está +/// instalada no cuenta acá (es un asunto de presencia, no de divergencia) — sólo +/// pisar una versión distinta rechaza. Reusa lo que `InstalledDb` ya registra. +pub fn version_conflicts( + db: &crate::InstalledDb, + requires: &std::collections::BTreeMap, +) -> Vec { + let mut out = Vec::new(); + for (dep, requerido) in requires { + if let Some(pkg) = db.find(dep) { + if let Some(instalado) = &pkg.hash { + if instalado != requerido { + out.push(ConflictoVersion { + dep: dep.clone(), + requerido: requerido.clone(), + instalado: instalado.clone(), + }); + } + } + } + } + out +} + #[cfg(test)] mod tests { use super::*; @@ -258,6 +328,73 @@ mod tests { assert!(path_collisions(&db, "tema-oscuro", &paths).is_empty()); } + // ── H4e: requires observados (versión de la dep contra la que se construyó) ── + + #[test] + fn wayland_divergido_observado_da_conflicto_de_version() { + // El paquete 'app' depende (runtime) de 'wayland-protocol'. En el repo, wayland está a + // b3:stock (la versión contra la que app se construyó). + let swm = crate::Swm { + swm_version: 1, + base: crate::swm::Base { distro_version: "dev".into(), pins: Default::default() }, + mutations: vec![Mutation::SourcePatch { + repo: Some("git://x/app.git".into()), + commit: Some("abc".into()), + tarball: None, + sha256: None, + strip_components: None, + patch: None, + patch_url: None, + build: crate::swm::SwmBuild { + compiler: "zig-cc".into(), + target: "x86_64-linux-musl".into(), + link: "static".into(), + flags: vec![], + phases: Default::default(), + zig_version: None, + }, + target_bin: "/usr/bin/app".into(), + expected_hash: Some("b3:app".into()), + deps: crate::recipe::Deps { build: vec![], runtime: vec!["wayland-protocol".into()] }, + evidence: Default::default(), + slots: Default::default(), + }], + signature: None, + }; + let index: crate::RepoIndex = serde_json::from_str( + r#"{"packages":[{"name":"wayland-protocol","version":"1","file":"w.swm","distro_version":"dev","deps":[],"expected_hash":"b3:stock"}]}"#, + ) + .unwrap(); + + let req = observed_requires(&swm, &index); + assert_eq!(req.get("wayland-protocol").unwrap(), "b3:stock"); + + // Tengo wayland instalado a b3:mine (divergido) ⇒ conflicto. + let mut db = crate::InstalledDb::default(); + db.record(crate::InstalledPackage { + name: "wayland-protocol".into(), + version: "1".into(), + hash: Some("b3:mine".into()), + files: vec![], + claims: Default::default(), + }); + let cs = version_conflicts(&db, &req); + assert_eq!(cs.len(), 1); + assert_eq!(cs[0].requerido, "b3:stock"); + assert_eq!(cs[0].instalado, "b3:mine"); + + // Si tengo la versión stock, no hay conflicto. + let mut ok = crate::InstalledDb::default(); + ok.record(crate::InstalledPackage { + name: "wayland-protocol".into(), + version: "1".into(), + hash: Some("b3:stock".into()), + files: vec![], + claims: Default::default(), + }); + assert!(version_conflicts(&ok, &req).is_empty()); + } + #[test] fn output_paths_lee_target_bin_y_file_drop() { let swm = crate::Swm { diff --git a/docs/15-frontier-ai-native.md b/docs/15-frontier-ai-native.md index 9f7afa2f..4c946d4a 100644 --- a/docs/15-frontier-ai-native.md +++ b/docs/15-frontier-ai-native.md @@ -344,9 +344,23 @@ La definición dura de compatibilidad de una config `C` contra mi estado `E`: 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. +- **H4e** ✅ — **`requires` OBSERVADOS: la versión contra la que el paquete se construyó.** Cierra + la simetría de H4c: además de observar lo que un paquete *escribe*, observamos de qué *depende a + una versión*. La fuente observable, sin que el autor prometa nada: un paquete se construyó contra + la versión de sus deps que hay **en el repo** (`deps.runtime` del `.swm` + `expected_hash` de cada + dep en el índice); si el usuario tiene esa dep instalada a **otro** hash, la divergió ⇒ rechazo + duro. Es el **caso wayland derivado** — el mismo que H4b captura cuando el autor declara + `requires`, ahora leído del cierre. + - `compat::observed_requires(swm, index)` + `compat::version_conflicts(db, req)` (reusan `deps` + del `.swm`, `expected_hash` del índice y `InstalledDb.hash` — cero declaración nueva). + - Cableado en `install` (rechazo duro, no lo salva `--force-slots`: es un requisito, no una + elección) y en `hammer compat` (bucket incompatibles). + - **Verificado e2e real** (`tests/compat_gate.rs`): `hammer compat` marca `app` INCOMPATIBLE + porque su dep `wayland-protocol` está instalada a un hash divergido del repo — read-only, ve + el `source_patch` sin construirlo. + Con H4e, la vía observada es simétrica: **lo que el paquete escribe** (H4c) *y* **de qué depende** + (H4e), ambas sin declaración. Lo que queda abierto: 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 @@ -409,7 +423,8 @@ H3a (design-doc) ──► registrar la visión, barato └► H4b ✅ (slots en la receta/.swm real + gate en `hammer install`) └► H4c ✅ (superficies OBSERVADAS: colisión de fichero, sin declarar slots) └► H4d ✅ (`hammer compat `: la búsqueda que particiona un repo) - └► [proceso] replay del MonotonicLog ──► plan OS-CRDT (otro agente) + └► H4e ✅ (requires OBSERVADOS: dep divergida = incompatible) + └► [proceso] replay del MonotonicLog ──► plan OS-CRDT (otro agente) ``` Recomendación: **H1 primero** (empuja la frontera que ya tenemos, sin apuestas). **H2a** en