H4e: requires OBSERVADOS — dep de runtime divergida = incompatible (SDD 15 §H4)

Cierra la simetría de la vía observada: además de observar lo que un paquete escribe (H4c),
observa de qué depende A UNA VERSIÓN. Fuente observable sin declaración: el 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 que H4b captura cuando el autor
declara requires, ahora leído del cierre).

- compat::observed_requires(swm, index) + version_conflicts(db, req) — reusan deps del .swm,
  expected_hash del índice e InstalledDb.hash (cero declaración nueva).
- Cableado en install (rechazo duro, no lo salva --force-slots) y en `hammer compat`.
- Verificado e2e real: `hammer compat` marca app INCOMPATIBLE por su dep wayland-protocol
  instalada a un hash divergido del repo (read-only, ve el source_patch sin construirlo).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-07-06 06:29:22 -04:00
co-authored by Claude Opus 4.8
parent bbf4411af0
commit 3e520d4477
4 changed files with 226 additions and 5 deletions
+29 -1
View File
@@ -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::<Vec<_>>()
.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::<Vec<_>>().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 {
+41
View File
@@ -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}");
}
+137
View File
@@ -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<String, String> {
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<String, String>,
) -> Vec<ConflictoVersion> {
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 {
+19 -4
View File
@@ -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 <repo>`: 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