tawasuyu

sergio/takana

> 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…

TOML 1534Shell 199Rust 109Markdown 993713 Definiciones

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>

1f1fae3508e5838cedd970c1cfd5bc09dba15f0e · por Sergio · (hace 4 meses) · padre ba4d520c4 · ver el árbol en este commit

Delta semántico · 13

crates/hammer-build/src/lib.rs

1 archivos · +156 −3

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");
+    }
+}