Etapa F paquetería #3: deps entre paquetes — install resuelve el cierre y reconstruye el catálogo

Cierra el hueco que pack/install advertían: un paquete con build-deps (bwrap→libcap,
openssh→zlib,openssl) ahora se instala por nombre reproduciéndose desde fuente CON sus deps.

Modelo: las build-deps viajan por NOMBRE en el source_patch y en la PackageEntry; install
resuelve el cierre transitivo desde el índice y reconstruye un catálogo de recetas que el lab
consulta al materializar deps en el sandbox.

- hammer-core: `Mutation::SourcePatch.deps` (Deps, serde-skip si vacío) + `from_recipe` lo
  carga. `Deps::is_empty`. `RepoIndex`/`PackageEntry.deps` + `resolve_closure(name)` (DFS
  topológico, deps antes que dependientes, detecta dep faltante y ciclo). 8 tests nuevos.
- hammer-build/swm_bridge: refactor — `recipe_from_source_patch` (síntesis pública, setea deps
  + base_dir=catálogo) + `catalog_dir_for` (dir determinista compartido). build_source_patch
  lo reusa. synthesize_recipe ahora setea recipe.deps + base_dir al catálogo (no "/").
- hammer-cli: pack puebla PackageEntry.deps; install resuelve el cierre y escribe un {dep}.toml
  por dep en el catalog_dir ANTES de aplicar el target (mismo dir determinista que usa
  build_source_patch ⇒ el lab resuelve {dep}.toml por nombre). Warning de pack actualizado.
- VALIDADO E2E REAL contra ./store: `install bwrap` resuelve libcap del catálogo y reproduce
  el artefacto CACHEADO EXACTO (b3:f89e716…) → hidrata bwrap (1.8MB ELF). El paquete con dep
  hashea bit-idéntico al original. Resolución/diamante/faltante/ciclo unit-tested. 31 suites verde.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-06-21 06:43:30 -04:00
co-authored by Claude Opus 4.8
parent e867bac388
commit b89e2dba51
9 changed files with 308 additions and 87 deletions
+2
View File
@@ -169,6 +169,7 @@ impl<T: IntentTranslator> Orchestrator<T> {
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,
};
+99 -70
View File
@@ -27,77 +27,15 @@ pub fn build_source_patch(
store: &Store,
scratch_root: Option<&Path>,
) -> hammer_core::Result<ArtifactHash> {
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<String> = 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<Recipe> {
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<String> = 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
/// (`<hash>-<name>`), 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<usize>,
deps: &hammer_core::Deps,
patches: Vec<String>,
base_dir: &Path,
) -> hammer_core::Result<Recipe> {
@@ -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");
}
+62 -8
View File
@@ -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 <dir> 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::<Vec<_>>().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;
}
+1 -1
View File
@@ -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;
+9 -1
View File
@@ -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<String>,
@@ -137,6 +137,14 @@ pub struct Deps {
pub runtime: Vec<String>,
}
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)]
+105
View File
@@ -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<String>,
/// `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<String>,
@@ -94,6 +99,52 @@ impl RepoIndex {
pub fn swm_path(repo_dir: impl AsRef<Path>, 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<Vec<&'a PackageEntry>> {
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}");
}
}
+11
View File
@@ -95,6 +95,12 @@ pub enum Mutation {
target_bin: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
expected_hash: Option<String>,
/// 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}");
+1
View File
@@ -378,6 +378,7 @@ fn run_compile(recipe: RecipeInline, store_root: PathBuf, tx: Sender<Event>) {
},
target_bin: format!("/usr/bin/{name}"),
expected_hash: None,
deps: Default::default(),
};
let store = match hammer_core::Store::open(&store_root) {
Ok(s) => s,
+18 -7
View File
@@ -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/<name>`); `--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 <nombre> [--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 (`<repo>/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 <nombre>`
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.