diff --git a/crates/hammer-agent/src/orchestrator.rs b/crates/hammer-agent/src/orchestrator.rs index ac329573..128c7ea2 100644 --- a/crates/hammer-agent/src/orchestrator.rs +++ b/crates/hammer-agent/src/orchestrator.rs @@ -169,6 +169,7 @@ impl Orchestrator { build, target_bin, expected_hash, + deps: _, } => { let CompileMode::ViaBus { store, timeout, .. } = &self.compile else { skipped_source_patches += 1; @@ -596,6 +597,7 @@ mod tests { }, target_bin: "/bin/x".into(), expected_hash: None, + deps: Default::default(), }], signature: None, }; diff --git a/crates/hammer-build/src/swm_bridge.rs b/crates/hammer-build/src/swm_bridge.rs index 8274f05d..b7e91779 100644 --- a/crates/hammer-build/src/swm_bridge.rs +++ b/crates/hammer-build/src/swm_bridge.rs @@ -27,77 +27,15 @@ pub fn build_source_patch( store: &Store, scratch_root: Option<&Path>, ) -> hammer_core::Result { - let (repo, commit, tarball, sha256, strip_components, patch, patch_url, build_cfg, target_bin, expected) = - match mutation { - Mutation::SourcePatch { - repo, - commit, - tarball, - sha256, - strip_components, - patch, - patch_url, - build, - target_bin, - expected_hash, - } => ( - repo, commit, tarball, sha256, strip_components, patch, patch_url, build, target_bin, - expected_hash, - ), - _ => { - return Err(hammer_core::Error::Recipe( - "build_source_patch: la mutación no es 'source_patch'".into(), - )); - } - }; + let catalog_dir = catalog_dir_for(cfg, scratch_root); + let recipe = recipe_from_source_patch(mutation, None, &catalog_dir)?; - // git xor tarball — misma regla que el schema (`swm_source_kind`). - let source_kind = hammer_core::swm::swm_source_kind( - repo.as_deref(), - commit.as_deref(), - tarball.as_deref(), - sha256.as_deref(), - ) - .map_err(hammer_core::Error::Recipe)?; - - let scratch = scratch_root.unwrap_or(&cfg.work_root).to_path_buf(); - let recipe_dir = scratch.join("swm-recipes"); - std::fs::create_dir_all(&recipe_dir)?; - - // Clave estable del origen para nombrar artefactos a disco: el commit (git) o el - // sha256 (tarball). Idéntica reaplicación ⇒ mismo nombre ⇒ hash de receta determinista. - let source_key: &str = match &source_kind { - hammer_core::SourceKind::Git { commit, .. } => commit, - hammer_core::SourceKind::Tarball { sha256, .. } => sha256, + let expected = match mutation { + Mutation::SourcePatch { expected_hash, .. } => expected_hash.clone(), + _ => None, }; - - // Materializamos el patch a disco bajo un nombre derivado de la clave de origen (estable: - // si el .swm se reaplica, reusamos el mismo archivo y el hash de la receta no depende de - // aleatoriedad). Origen: inline (`patch`) o remoto (`patch_url`); el schema garantiza que - // no vengan ambos. - let mut patches: Vec = Vec::new(); - let patch_path = recipe_dir.join(format!("{source_key}.patch")); - if let Some(text) = patch { - std::fs::write(&patch_path, text.as_bytes())?; - patches.push(patch_path.to_string_lossy().into_owned()); - } else if let Some(url) = patch_url { - tracing::info!(%url, "fetch: descargando patch remoto"); - crate::download::fetch_url_to_file(url, &patch_path)?; - patches.push(patch_path.to_string_lossy().into_owned()); - } - - let name = derive_name(target_bin); - let recipe = synthesize_recipe( - &name, - &source_kind, - build_cfg, - *strip_components, - patches, - &recipe_dir, - )?; - let hash = build(&recipe, cfg, store)?; - if let Some(want) = expected { + if let Some(want) = &expected { let want_hex = want.strip_prefix("b3:").unwrap_or(want); let got_hex = hash .as_str() @@ -112,6 +50,85 @@ pub fn build_source_patch( Ok(hash) } +/// El directorio-catálogo donde se materializan los patches y los `{dep}.toml`. Determinista a +/// partir de `cfg.work_root` (o `scratch_root`): por eso `install` puede pre-poblarlo con los +/// `{dep}.toml` y `build_source_patch` los encontrará al resolver build-deps del mismo `base_dir`. +pub fn catalog_dir_for(cfg: &BuildConfig, scratch_root: Option<&Path>) -> PathBuf { + scratch_root.unwrap_or(&cfg.work_root).join("swm-recipes") +} + +/// Sintetiza (SIN construir) la `Recipe` efímera de un `source_patch`: parsea el origen, +/// materializa el patch inline/remoto a disco bajo `catalog_dir`, y arma la receta con sus +/// `deps` y `base_dir = catalog_dir` (para que las build-deps resuelvan `{dep}.toml` ahí). +/// +/// `name` sobreescribe el nombre derivado del `target_bin`. Es OBLIGATORIO al escribir un +/// `{dep}.toml`: el nombre de la receta DEBE ser el de la dep para que el dependiente la resuelva. +pub fn recipe_from_source_patch( + mutation: &Mutation, + name: Option<&str>, + catalog_dir: &Path, +) -> hammer_core::Result { + let (repo, commit, tarball, sha256, strip_components, patch, patch_url, build_cfg, target_bin, deps) = + match mutation { + Mutation::SourcePatch { + repo, commit, tarball, sha256, strip_components, patch, patch_url, build, + target_bin, deps, .. + } => ( + repo, commit, tarball, sha256, strip_components, patch, patch_url, build, + target_bin, deps, + ), + _ => { + return Err(hammer_core::Error::Recipe( + "recipe_from_source_patch: la mutación no es 'source_patch'".into(), + )); + } + }; + + // git xor tarball — misma regla que el schema (`swm_source_kind`). + let source_kind = hammer_core::swm::swm_source_kind( + repo.as_deref(), + commit.as_deref(), + tarball.as_deref(), + sha256.as_deref(), + ) + .map_err(hammer_core::Error::Recipe)?; + + std::fs::create_dir_all(catalog_dir)?; + + // Clave estable del origen para nombrar el patch a disco: el commit (git) o el sha256 + // (tarball). Idéntica reaplicación ⇒ mismo nombre ⇒ hash de receta determinista. + let source_key: &str = match &source_kind { + hammer_core::SourceKind::Git { commit, .. } => commit, + hammer_core::SourceKind::Tarball { sha256, .. } => sha256, + }; + + let mut patches: Vec = Vec::new(); + if patch.is_some() || patch_url.is_some() { + // Patch bajo un nombre derivado del nombre-de-receta + clave de origen, para no colisionar + // entre `{dep}.patch` distintos en el mismo catálogo. + let label = name.map(|n| n.to_string()).unwrap_or_else(|| derive_name(target_bin)); + let patch_path = catalog_dir.join(format!("{label}-{source_key}.patch")); + if let Some(text) = patch { + std::fs::write(&patch_path, text.as_bytes())?; + } else if let Some(url) = patch_url { + tracing::info!(%url, "fetch: descargando patch remoto"); + crate::download::fetch_url_to_file(url, &patch_path)?; + } + patches.push(patch_path.to_string_lossy().into_owned()); + } + + let name = name.map(|n| n.to_string()).unwrap_or_else(|| derive_name(target_bin)); + synthesize_recipe( + &name, + &source_kind, + build_cfg, + *strip_components, + deps, + patches, + catalog_dir, + ) +} + /// Nombre legible para el directorio del store. `/usr/bin/grep` → `grep`. Si el path no /// tiene basename (raro), usamos `swm-bin`. El nombre afecta SÓLO al sufijo legible /// (`-`), no al hash; cambiarlo no rompe la caché del store. @@ -123,11 +140,13 @@ fn derive_name(target_bin: &str) -> String { .to_string() } +#[allow(clippy::too_many_arguments)] fn synthesize_recipe( name: &str, source: &hammer_core::SourceKind<'_>, build_cfg: &SwmBuild, strip_components: Option, + deps: &hammer_core::Deps, patches: Vec, base_dir: &Path, ) -> hammer_core::Result { @@ -188,7 +207,12 @@ flags = [] } }) .collect(); - recipe.base_dir = PathBuf::from("/"); // patches absolutos: base_dir es irrelevante + // Deps por NOMBRE (de la receta original): el lab las resolverá como `{dep}.toml` bajo el + // `base_dir` — por eso base_dir = el catálogo (no "/"): los patches son absolutos y no lo + // necesitan, pero la resolución de build-deps SÍ. `install` puebla el catálogo con los + // `{dep}.toml` antes de construir; sin deps, el dir simplemente no se consulta. + recipe.deps = deps.clone(); + recipe.base_dir = base_dir.to_path_buf(); Ok(recipe) } @@ -246,6 +270,7 @@ mod tests { }, &fake_swm_build(), None, + &hammer_core::Deps::default(), vec![], d.path(), ) @@ -273,6 +298,7 @@ mod tests { }, &fake_swm_build(), None, + &hammer_core::Deps::default(), vec![], d.path(), ) @@ -329,13 +355,16 @@ mod tests { build: fake_swm_build(), target_bin: "/bin/x".into(), expected_hash: None, + deps: Default::default(), }; let store = Store::open(d.path().join("store")).unwrap(); let cfg = BuildConfig::defaults_for_store(store.root()); let scratch = d.path().join("scratch"); // Falla (no hay rootfs), pero el patch ya debe estar descargado. let _ = build_source_patch(&m, &cfg, &store, Some(&scratch)); - let landed = scratch.join("swm-recipes").join(format!("{commit}.patch")); + // El patch se materializa como `{label}-{source_key}.patch` (label = derive_name del + // target_bin) para no colisionar entre deps en el catálogo. + let landed = scratch.join("swm-recipes").join(format!("x-{commit}.patch")); assert!(landed.is_file(), "el patch remoto debió descargarse a {}", landed.display()); assert_eq!(std::fs::read(&landed).unwrap(), b"--- a\n+++ b\n"); } diff --git a/crates/hammer-cli/src/main.rs b/crates/hammer-cli/src/main.rs index 95a5d62d..8296ddb9 100644 --- a/crates/hammer-cli/src/main.rs +++ b/crates/hammer-cli/src/main.rs @@ -1177,14 +1177,14 @@ fn run_pack( let recipe = hammer_core::Recipe::load_from_path(recipe_path) .map_err(|e| anyhow::anyhow!("no pude cargar la receta {}: {e}", recipe_path.display()))?; - // El `source_patch` aún NO modela `deps` (resolución de dependencias entre paquetes = pieza - // posterior de la paquetería). Una receta con build-deps se empaqueta igual, pero el receptor - // necesitará esas deps ya presentes en su store para reproducir. Avisamos, no perdemos en silencio. - if !recipe.deps.build.is_empty() || !recipe.deps.runtime.is_empty() { + // Las deps por NOMBRE viajan en el `source_patch` y en el índice; `install` resuelve el cierre + // desde el repo. Avisamos sólo para recordar que esas deps deben ESTAR publicadas en el mismo + // repo (si no, `install` fallará al resolver el catálogo). + if !recipe.deps.build.is_empty() { eprintln!( - "⚠ la receta declara deps (build={:?} runtime={:?}) que el source_patch no transporta; \ - el receptor debe tenerlas en su store para reproducir (deps entre paquetes: pieza futura)", - recipe.deps.build, recipe.deps.runtime + "nota: este paquete depende de {:?} — publicá esas deps en el mismo repo para que \ + `install` pueda reproducirlo", + recipe.deps.build ); } @@ -1281,6 +1281,7 @@ fn run_pack( distro_version: distro_version.to_string(), expected_hash: expected_hash.clone(), signed_by: swm.signature.as_ref().map(|s| s.by.clone()), + deps: recipe.deps.build.clone(), }); if let Some(old) = orphan { // Una versión nueva deja huérfano el `.swm` viejo: lo retiramos (best-effort). @@ -1346,7 +1347,52 @@ fn run_install( eprintln!("firma: no verificada (pasá --trust para validar la autoría)"); } - // El resto (schema, base, reproduce source_patch, hidrata) es exactamente `apply`. + // Dependencias: resolvemos el cierre transitivo de build-deps desde el índice (deps antes + // que el dependiente) y poblamos el catálogo de recetas que el lab consultará al reproducir + // el `source_patch`. Sin esto, un paquete con build-deps (openssh→zlib,openssl) no podría + // reconstruirse: el sandbox no tendría headers/libs de las deps. + if !entry.deps.is_empty() && !skip_source_patch { + let store = hammer_core::Store::open(store_path)?; + let cfg = hammer_build::BuildConfig::from_env_or_defaults(store.root()); + let catalog_dir = hammer_build::swm_bridge::catalog_dir_for(&cfg, None); + std::fs::create_dir_all(&catalog_dir)?; + + // El cierre incluye al propio target al final; el catálogo sólo necesita las DEPS (las + // que el target y sus deps resolverán por nombre). Escribimos un `{dep}.toml` por cada una. + let closure = index.resolve_closure(name)?; + let dep_pkgs: Vec<&hammer_core::PackageEntry> = + closure.into_iter().filter(|p| p.name != name).collect(); + if !dep_pkgs.is_empty() { + eprintln!( + "deps: resolviendo catálogo para {} dep(s): {}", + dep_pkgs.len(), + dep_pkgs.iter().map(|p| p.name.as_str()).collect::>().join(", ") + ); + } + for dep in dep_pkgs { + let dep_swm_path = hammer_core::RepoIndex::swm_path(repo_dir, dep); + let dep_swm = load_swm( + dep_swm_path + .to_str() + .ok_or_else(|| anyhow::anyhow!("ruta no UTF-8: {}", dep_swm_path.display()))?, + )?; + let mutation = source_patch_of(&dep_swm).ok_or_else(|| { + anyhow::anyhow!("la dep '{}' no es un paquete source_patch", dep.name) + })?; + // El nombre de la receta DEBE ser el nombre del paquete: así el dependiente la + // resuelve como `{dep}.toml`. + let dep_recipe = hammer_build::swm_bridge::recipe_from_source_patch( + mutation, + Some(&dep.name), + &catalog_dir, + )?; + let toml = dep_recipe.to_toml()?; + std::fs::write(catalog_dir.join(format!("{}.toml", dep.name)), toml)?; + } + } + + // El resto (schema, base, reproduce source_patch CON sus deps del catálogo, hidrata) es + // exactamente `apply`: build_source_patch usa el MISMO catalog_dir determinista. run_apply( store_path, swm_path_str, @@ -1357,6 +1403,13 @@ fn run_install( ) } +/// Devuelve la primera mutación `source_patch` de un `.swm` empaquetado (el caso de `pack`). +fn source_patch_of(swm: &hammer_core::Swm) -> Option<&hammer_core::Mutation> { + swm.mutations + .iter() + .find(|m| matches!(m, hammer_core::Mutation::SourcePatch { .. })) +} + /// Lista los paquetes publicados en un repositorio. fn run_repo_list(repo_dir: &std::path::Path) -> anyhow::Result<()> { let index = hammer_core::RepoIndex::load(repo_dir)?; @@ -1765,6 +1818,7 @@ fn build_export_mutations( }, target_bin, expected_hash: Some(art_hash), + deps: recipe.deps.clone(), }); stats.source_patches += 1; } diff --git a/crates/hammer-core/src/lib.rs b/crates/hammer-core/src/lib.rs index af312885..2f55000c 100644 --- a/crates/hammer-core/src/lib.rs +++ b/crates/hammer-core/src/lib.rs @@ -17,7 +17,7 @@ pub mod swm; pub use caps::{AgentCapsConfig, CapRule}; pub use hash::ArtifactHash; -pub use recipe::{Compiler, LinkMode, Phases, Recipe, Source, SourceKind}; +pub use recipe::{Compiler, Deps, LinkMode, Phases, Recipe, Source, SourceKind}; pub use repo::{PackageEntry, RepoIndex}; pub use sign::{KeyPair, SigStatus, TrustStore}; pub use store::Store; diff --git a/crates/hammer-core/src/recipe.rs b/crates/hammer-core/src/recipe.rs index 5093b137..0a9fe00a 100644 --- a/crates/hammer-core/src/recipe.rs +++ b/crates/hammer-core/src/recipe.rs @@ -129,7 +129,7 @@ impl Phases { } } -#[derive(Debug, Clone, Default, Serialize, Deserialize)] +#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)] pub struct Deps { #[serde(default)] pub build: Vec, @@ -137,6 +137,14 @@ pub struct Deps { pub runtime: Vec, } +impl Deps { + /// `true` si no hay ninguna dep (ni build ni runtime). Sirve a serde para no emitir el + /// bloque `deps` vacío en un `.swm`/receta. + pub fn is_empty(&self) -> bool { + self.build.is_empty() && self.runtime.is_empty() + } +} + /// Compilador del lab, POR RECETA (no global). `zig-cc` por defecto; escotilla a clang/gcc /// para paquetes con gcc-ismos. Ver ADR 0003. #[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize)] diff --git a/crates/hammer-core/src/repo.rs b/crates/hammer-core/src/repo.rs index 60ea81e3..fe06b357 100644 --- a/crates/hammer-core/src/repo.rs +++ b/crates/hammer-core/src/repo.rs @@ -36,6 +36,11 @@ pub struct PackageEntry { pub file: String, /// `base.distro_version` del `.swm` — permite filtrar incompatibles sin abrir el manifiesto. pub distro_version: String, + /// Build-deps del paquete por NOMBRE (de la receta original) — las que el receptor debe tener + /// como paquetes en el repo para reproducir. `install` resuelve este grafo desde el índice sin + /// abrir cada `.swm`. Vacío ⇒ paquete autocontenido. + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub deps: Vec, /// `expected_hash` del `source_patch` (si lo lleva): el ancla "verificar, no confiar". #[serde(default, skip_serializing_if = "Option::is_none")] pub expected_hash: Option, @@ -94,6 +99,52 @@ impl RepoIndex { pub fn swm_path(repo_dir: impl AsRef, entry: &PackageEntry) -> PathBuf { repo_dir.as_ref().join(&entry.file) } + + /// Resuelve el cierre transitivo de build-deps de `name` en orden TOPOLÓGICO (cada dep antes + /// que quien la necesita; `name` queda al final). Es el orden en que `install` debe procesar: + /// poblar el catálogo con las deps antes de construir el dependiente. Error si una dep falta + /// del índice (no se puede reproducir sin ella) o si hay un ciclo. + pub fn resolve_closure<'a>(&'a self, name: &str) -> crate::Result> { + let mut order: Vec<&'a PackageEntry> = Vec::new(); + let mut done: Vec<&'a str> = Vec::new(); // ya emitidos + let mut stack: Vec<&'a str> = Vec::new(); // en la rama DFS actual (detección de ciclo) + self.visit(name, &mut order, &mut done, &mut stack)?; + Ok(order) + } + + fn visit<'a>( + &'a self, + name: &str, + order: &mut Vec<&'a PackageEntry>, + done: &mut Vec<&'a str>, + stack: &mut Vec<&'a str>, + ) -> crate::Result<()> { + if done.iter().any(|d| *d == name) { + return Ok(()); + } + if stack.iter().any(|s| *s == name) { + return Err(crate::Error::Recipe(format!( + "ciclo de dependencias en el repo: {} → {name}", + stack.join(" → ") + ))); + } + let entry = self.find(name).ok_or_else(|| { + crate::Error::Recipe(format!( + "dependencia '{name}' no está en el repo (necesaria por {})", + stack.last().copied().unwrap_or("(raíz)") + )) + })?; + // Guardamos refs con la vida de `self` (no del parámetro `name`): el `.swm` y su nombre + // viven en el índice. `name` sólo sirve para buscar y comparar. + stack.push(entry.name.as_str()); + for dep in &entry.deps { + self.visit(dep, order, done, stack)?; + } + stack.pop(); + done.push(entry.name.as_str()); + order.push(entry); + Ok(()) + } } #[cfg(test)] @@ -101,6 +152,10 @@ mod tests { use super::*; fn entry(name: &str, ver: &str) -> PackageEntry { + entry_deps(name, ver, &[]) + } + + fn entry_deps(name: &str, ver: &str, deps: &[&str]) -> PackageEntry { PackageEntry { name: name.into(), version: ver.into(), @@ -108,6 +163,7 @@ mod tests { distro_version: "dev".into(), expected_hash: None, signed_by: None, + deps: deps.iter().map(|s| s.to_string()).collect(), } } @@ -152,4 +208,53 @@ mod tests { let orphan = idx.upsert(entry("rg", "1.0.0")); assert!(orphan.is_none()); } + + fn names<'a>(v: &[&'a PackageEntry]) -> Vec<&'a str> { + v.iter().map(|e| e.name.as_str()).collect() + } + + #[test] + fn closure_topological_deps_before_dependents() { + // openssh → zlib, openssl ; openssl no tiene deps. Orden: deps antes, target al final. + let mut idx = RepoIndex::default(); + idx.upsert(entry("zlib", "1.3")); + idx.upsert(entry("openssl", "3.5")); + idx.upsert(entry_deps("openssh", "10.3", &["zlib", "openssl"])); + let order = names(&idx.resolve_closure("openssh").unwrap()); + // zlib y openssl preceden a openssh; openssh es el último. + assert_eq!(*order.last().unwrap(), "openssh"); + let p = |n| order.iter().position(|x| *x == n).unwrap(); + assert!(p("zlib") < p("openssh") && p("openssl") < p("openssh")); + } + + #[test] + fn closure_dedups_diamond() { + // a→b,c ; b→d ; c→d. d aparece una sola vez, antes que b y c. + let mut idx = RepoIndex::default(); + idx.upsert(entry("d", "1")); + idx.upsert(entry_deps("b", "1", &["d"])); + idx.upsert(entry_deps("c", "1", &["d"])); + idx.upsert(entry_deps("a", "1", &["b", "c"])); + let order = names(&idx.resolve_closure("a").unwrap()); + assert_eq!(order.iter().filter(|x| **x == "d").count(), 1); + let p = |n| order.iter().position(|x| *x == n).unwrap(); + assert!(p("d") < p("b") && p("d") < p("c") && p("b") < p("a")); + } + + #[test] + fn closure_missing_dep_errors() { + let mut idx = RepoIndex::default(); + idx.upsert(entry_deps("openssh", "10.3", &["openssl"])); // openssl ausente + let err = idx.resolve_closure("openssh").unwrap_err().to_string(); + assert!(err.contains("openssl") && err.contains("no está en el repo"), "{err}"); + } + + #[test] + fn closure_cycle_errors() { + let mut idx = RepoIndex::default(); + idx.upsert(entry_deps("a", "1", &["b"])); + idx.upsert(entry_deps("b", "1", &["a"])); + let err = idx.resolve_closure("a").unwrap_err().to_string(); + assert!(err.contains("ciclo"), "{err}"); + } } diff --git a/crates/hammer-core/src/swm.rs b/crates/hammer-core/src/swm.rs index 37bd2df4..6e2690fb 100644 --- a/crates/hammer-core/src/swm.rs +++ b/crates/hammer-core/src/swm.rs @@ -95,6 +95,12 @@ pub enum Mutation { target_bin: String, #[serde(default, skip_serializing_if = "Option::is_none")] expected_hash: Option, + /// Dependencias del paquete por NOMBRE (las de la receta original). Las build-deps deben + /// estar presentes como paquetes en el repo del receptor: `install` reconstruye con ellas + /// un catálogo de recetas para que el lab las materialice en el sandbox al reproducir. + /// Vacío ⇒ paquete autocontenido (el caso común, binarios estáticos sin deps). + #[serde(default, skip_serializing_if = "crate::recipe::Deps::is_empty")] + deps: crate::recipe::Deps, }, ConfigEdit { file: String, @@ -283,6 +289,7 @@ impl Swm { build, target_bin: target_bin.into(), expected_hash, + deps: recipe.deps.clone(), }], signature: None, }; @@ -570,6 +577,7 @@ mutations: }, target_bin: "/bin/x".into(), expected_hash: None, + deps: Default::default(), }], signature: None, }; @@ -599,6 +607,7 @@ mutations: }, target_bin: "/bin/grep".into(), expected_hash: None, + deps: Default::default(), }], signature: None, }; @@ -625,6 +634,7 @@ mutations: }, target_bin: "/bin/x".into(), expected_hash: None, + deps: Default::default(), }; let err = m.verify_schema().unwrap_err(); assert!(err.contains("no ambos"), "mensaje inesperado: {err}"); @@ -724,6 +734,7 @@ zig_version = "0.13.0" }, target_bin: "/bin/x".into(), expected_hash: None, + deps: Default::default(), }; let err = m.verify_schema().unwrap_err(); assert!(err.contains("faltan campos"), "mensaje inesperado: {err}"); diff --git a/crates/hammerd/src/bus.rs b/crates/hammerd/src/bus.rs index 93ab6089..83fec07f 100644 --- a/crates/hammerd/src/bus.rs +++ b/crates/hammerd/src/bus.rs @@ -378,6 +378,7 @@ fn run_compile(recipe: RecipeInline, store_root: PathBuf, tx: Sender) { }, target_bin: format!("/usr/bin/{name}"), expected_hash: None, + deps: Default::default(), }; let store = match hammer_core::Store::open(&store_root) { Ok(s) => s, diff --git a/docs/06-swm-format.md b/docs/06-swm-format.md index 350ddbd5..9ad2c9b6 100644 --- a/docs/06-swm-format.md +++ b/docs/06-swm-format.md @@ -133,15 +133,16 @@ CLI: construir un paquete distribuible y reproducible-desde-fuente; es la inversa de `apply`. `--target-bin` fija el ancla de sanity (default `/usr/bin/`); `--expected b3:…` o `--build` sellan el `expected_hash` (ancla "verificar, no confiar"); `--sign KEY` lo firma. - El `source_patch` traslada con fidelidad `build.{phases,zig_version,flags}` y, en tarball, - `strip_components`; los patches de la receta viajan inline (concatenados). **Aún no modela - `deps`** (resolución entre paquetes = pieza posterior): si la receta declara build/runtime - deps, `pack` lo advierte y el receptor debe tenerlas ya en su store para reproducir. + El `source_patch` traslada con fidelidad `build.{phases,zig_version,flags}`, en tarball + `strip_components`, y las `deps` por nombre; los patches de la receta viajan inline + (concatenados). Si la receta declara build-deps, `pack` recuerda publicarlas en el mismo repo. Con `--repo DIR` **publica** en un repositorio (ver abajo) en vez de (o además de) `--out`. - `hammer install [--repo DIR]` — **consume** del repositorio: resuelve `nombre` en el - índice, verifica la firma (con `--trust DIR`) y la base (con `--base-ref`), y delega en el - camino de `apply` (reproduce el `source_patch` desde fuente + hidrata). Nunca corre un binario - ajeno. `--prefix`/`--skip-source-patch` para staging y dry-run de schema. + índice, verifica la firma (con `--trust DIR`) y la base (con `--base-ref`), **resuelve el cierre + transitivo de build-deps** desde el índice (orden topológico) poblando un catálogo de recetas, y + delega en el camino de `apply` (reproduce el `source_patch` desde fuente, con sus deps + materializadas en el sandbox, + hidrata). Nunca corre un binario ajeno. `--prefix`/ + `--skip-source-patch` para staging y dry-run de schema. - `hammer repo list [--repo DIR]` — lista el catálogo (`/index.json`). ## 7. Repositorio de paquetes (Etapa F) @@ -154,3 +155,13 @@ publica con el nombre/versión de la receta (upsert idempotente por nombre; una retira el `.swm` huérfano). El transporte del repo (filesystem / sshfs / mirror) es ortogonal: ver `hammer-mirror` para el CAS del store. Tipos en `hammer-core::repo` (`RepoIndex`, `PackageEntry`). + +**Dependencias.** Un `source_patch` lleva sus build-deps por NOMBRE (las de la receta original); +la `PackageEntry` las espeja para resolver el grafo sin abrir cada `.swm`. `install ` +calcula el cierre transitivo (`RepoIndex::resolve_closure`, topológico, error si falta una dep o +hay ciclo) y, para reproducir, **reconstruye un catálogo de recetas** efímero: escribe un +`{dep}.toml` por dep (sintetizado de su `.swm` vía `swm_bridge::recipe_from_source_patch`) en el +`catalog_dir` determinista que el lab consulta al materializar build-deps en el sandbox. Así un +paquete con deps (p. ej. `bwrap`→`libcap`, `openssh`→`zlib,openssl`) se reproduce desde fuente +bit-idéntico — verificado: `install bwrap` reproduce el artefacto cacheado exacto resolviendo +`libcap` del catálogo.