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>
This commit is contained in:
Sergio
2026-06-10 23:59:34 +00:00
co-authored by Claude Opus 4.8
parent ba4d520c44
commit 1f1fae3508
+156 -3
View File
@@ -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");
}
}