> Una distribución Linux construida con un laboratorio funcional y hermético en el > sótano, y una terminal mutable, clásica y anárquica en el piso de arriba — diseñada > desde el suelo para que una IA programadora entienda, modifique y co…
build: resolver deps.build recursivamente en artifact_hash (cierra TODO fase-0)
Lote 2 del track posterior. El hash de un artefacto ahora incluye el hash de
cada dep de build (Merkle sobre el subgrafo), no solo su fuente+flags.
- catálogo = base_dir de la receta: cada dep se resuelve a <base_dir>/<dep>.toml
(firma de artifact_hash/build sin cambios; cero ripple en call sites)
- detección de ciclos con la cadena en el mensaje (a -> b -> a)
- errores claros: dep .toml ausente, o deps sin base_dir (receta de TOML crudo)
- 6 tests nuevos; ninguna receta actual declara deps ⇒ sin impacto en e2e
Prepara Stage 1: seed_hash entrará al hash de toda receta del userland mínimo.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
diff --git a/crates/hammer-build/src/lib.rs b/crates/hammer-build/src/lib.rs
index ed2b010e..a37f9fdf 100644
--- a/crates/hammer-build/src/lib.rs
+++ b/crates/hammer-build/src/lib.rs
@@ -19,16 +19,69 @@ pub use swm_bridge::build_source_patch;
/// Calcula el `ArtifactHash` de una receta, resolviendo recursivamente sus deps de build.
/// Ver `docs/02-build-lab.md` §2 y §5.
+///
+/// **Catálogo = `base_dir` de la receta.** Cada nombre en `deps.build` se resuelve al hermano
+/// `<base_dir>/<dep>.toml`; su hash entra en el de la receta (Merkle sobre el subgrafo de build).
+/// Así dos recetas con la misma fuente pero distinto grafo de deps hashean distinto, y cambiar
+/// una dep transitiva invalida la caché de todo lo que la usa.
+///
+/// Errores: una receta con `deps.build` pero sin `base_dir` (parseada de TOML crudo, no cargada
+/// de disco) no puede resolver su catálogo; un `<dep>.toml` ausente, o un ciclo de deps, también
+/// fallan con un mensaje que nombra la cadena.
pub fn artifact_hash(recipe: &Recipe, _store: &Store) -> hammer_core::Result<ArtifactHash> {
- let dep_hashes: Vec<ArtifactHash> = Vec::new();
- for _dep in &recipe.deps.build {
- // TODO(fase-0): cargar la receta de la dep y recursar.
+ let mut stack: Vec<String> = Vec::new();
+ artifact_hash_rec(recipe, &mut stack)
+}
+
+fn artifact_hash_rec(recipe: &Recipe, stack: &mut Vec<String>) -> hammer_core::Result<ArtifactHash> {
+ if stack.iter().any(|n| n == &recipe.name) {
+ stack.push(recipe.name.clone());
+ return Err(hammer_core::Error::Recipe(format!(
+ "ciclo de dependencias de build: {}",
+ stack.join(" -> ")
+ )));
}
+
+ let dep_hashes = resolve_build_dep_hashes(recipe, stack)?;
let inputs = recipe.hash_inputs(&dep_hashes)?;
let refs: Vec<&[u8]> = inputs.iter().map(|v| v.as_slice()).collect();
Ok(ArtifactHash::of_inputs(&refs))
}
+/// Resuelve y hashea, en orden de declaración, cada `deps.build` de `recipe` cargándolo del
+/// catálogo (su `base_dir`). `stack` lleva la cadena en curso para detectar ciclos.
+fn resolve_build_dep_hashes(
+ recipe: &Recipe,
+ stack: &mut Vec<String>,
+) -> hammer_core::Result<Vec<ArtifactHash>> {
+ if recipe.deps.build.is_empty() {
+ return Ok(Vec::new());
+ }
+ if recipe.base_dir.as_os_str().is_empty() {
+ return Err(hammer_core::Error::Recipe(format!(
+ "la receta '{}' declara deps de build {:?} pero no tiene base_dir; \
+ cárgala con Recipe::load_from_path para resolver el catálogo",
+ recipe.name, recipe.deps.build
+ )));
+ }
+
+ stack.push(recipe.name.clone());
+ let mut hashes = Vec::with_capacity(recipe.deps.build.len());
+ for dep in &recipe.deps.build {
+ let dep_path = recipe.base_dir.join(format!("{dep}.toml"));
+ let dep_recipe = Recipe::load_from_path(&dep_path).map_err(|e| {
+ hammer_core::Error::Recipe(format!(
+ "no pude cargar la dep de build '{dep}' de '{}' ({}): {e}",
+ recipe.name,
+ dep_path.display()
+ ))
+ })?;
+ hashes.push(artifact_hash_rec(&dep_recipe, stack)?);
+ }
+ stack.pop();
+ Ok(hashes)
+}
+
/// Compila una receta y devuelve el hash del artefacto sellado en el store.
/// Si el hash ya existe en el store, devuelve sin recompilar (caché).
pub fn build(
@@ -324,3 +377,103 @@ pub fn hydrate(
let artifact_dir = store.find_by_hash(h.as_str())?;
hydrate::hydrate(&artifact_dir, target_fhs, mode)
}
+
+#[cfg(test)]
+mod dep_hash_tests {
+ use super::*;
+
+ /// Escribe `<dir>/<name>.toml` con `source.commit = commit` y, si hay, `deps.build = deps`.
+ fn write_recipe(dir: &Path, name: &str, commit: &str, deps: &[&str]) {
+ let deps_block = if deps.is_empty() {
+ String::new()
+ } else {
+ let list = deps.iter().map(|d| format!("\"{d}\"")).collect::<Vec<_>>().join(", ");
+ format!("\n[deps]\nbuild = [{list}]\n")
+ };
+ let toml = format!(
+ "name = \"{name}\"\nversion = \"1\"\n[source]\nrepo = \"git://{name}\"\ncommit = \"{commit}\"\n[build]\n{deps_block}"
+ );
+ std::fs::write(dir.join(format!("{name}.toml")), toml).unwrap();
+ }
+
+ fn load(dir: &Path, name: &str) -> Recipe {
+ Recipe::load_from_path(dir.join(format!("{name}.toml"))).unwrap()
+ }
+
+ fn store_in(dir: &Path) -> Store {
+ Store::open(dir.join("store")).unwrap()
+ }
+
+ #[test]
+ fn dep_contributes_to_hash() {
+ let d = tempfile::tempdir().unwrap();
+ let store = store_in(d.path());
+ write_recipe(d.path(), "base", "b0", &[]);
+ write_recipe(d.path(), "app", "a0", &["base"]);
+ write_recipe(d.path(), "app_nodep", "a0", &[]); // misma fuente, sin la dep
+
+ let with_dep = artifact_hash(&load(d.path(), "app"), &store).unwrap();
+ let no_dep = artifact_hash(&load(d.path(), "app_nodep"), &store).unwrap();
+ // `app_nodep` se llama distinto, pero el nombre no entra al hash: la única diferencia
+ // de entrada es la presencia de la dep. (Renombramos sólo para tener dos .toml.)
+ assert_ne!(with_dep, no_dep, "la dep de build debe entrar al hash");
+ }
+
+ #[test]
+ fn transitive_dep_change_propagates() {
+ let d = tempfile::tempdir().unwrap();
+ let store = store_in(d.path());
+ write_recipe(d.path(), "base", "b0", &[]);
+ write_recipe(d.path(), "lib", "l0", &["base"]);
+ write_recipe(d.path(), "app", "a0", &["lib"]);
+ let before = artifact_hash(&load(d.path(), "app"), &store).unwrap();
+
+ // Cambiar la fuente de la dep transitiva `base` debe re-hashear `app`.
+ write_recipe(d.path(), "base", "b1", &[]);
+ let after = artifact_hash(&load(d.path(), "app"), &store).unwrap();
+ assert_ne!(before, after, "un cambio transitivo debe propagarse");
+ }
+
+ #[test]
+ fn missing_dep_toml_errors_with_names() {
+ let d = tempfile::tempdir().unwrap();
+ let store = store_in(d.path());
+ write_recipe(d.path(), "app", "a0", &["pcre2"]); // sin pcre2.toml
+ let err = artifact_hash(&load(d.path(), "app"), &store).unwrap_err().to_string();
+ assert!(err.contains("pcre2"), "el error debe nombrar la dep: {err}");
+ assert!(err.contains("app"), "y la receta que la pide: {err}");
+ }
+
+ #[test]
+ fn cycle_is_detected() {
+ let d = tempfile::tempdir().unwrap();
+ let store = store_in(d.path());
+ write_recipe(d.path(), "a", "a0", &["b"]);
+ write_recipe(d.path(), "b", "b0", &["a"]);
+ let err = artifact_hash(&load(d.path(), "a"), &store).unwrap_err().to_string();
+ assert!(err.contains("ciclo"), "esperaba ciclo: {err}");
+ assert!(err.contains("a -> b -> a"), "debe mostrar la cadena: {err}");
+ }
+
+ #[test]
+ fn deps_without_base_dir_errors() {
+ let store = Store::open(tempfile::tempdir().unwrap().path().join("store")).unwrap();
+ // Parseada de TOML crudo ⇒ base_dir vacío; con deps no hay catálogo que resolver.
+ let r = Recipe::from_toml(
+ "name=\"x\"\nversion=\"1\"\n[source]\nrepo=\"git://x\"\ncommit=\"c0\"\n[build]\n[deps]\nbuild=[\"y\"]\n",
+ )
+ .unwrap();
+ let err = artifact_hash(&r, &store).unwrap_err().to_string();
+ assert!(err.contains("base_dir"), "debe explicar la falta de base_dir: {err}");
+ }
+
+ #[test]
+ fn no_deps_unaffected() {
+ let d = tempfile::tempdir().unwrap();
+ let store = store_in(d.path());
+ write_recipe(d.path(), "solo", "s0", &[]);
+ let a = artifact_hash(&load(d.path(), "solo"), &store).unwrap();
+ let b = artifact_hash(&load(d.path(), "solo"), &store).unwrap();
+ assert_eq!(a, b, "sin deps el hash es estable");
+ }
+}