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:
co-authored by
Claude Opus 4.8
parent
ba4d520c44
commit
1f1fae3508
@@ -19,16 +19,69 @@ pub use swm_bridge::build_source_patch;
|
|||||||
|
|
||||||
/// Calcula el `ArtifactHash` de una receta, resolviendo recursivamente sus deps de build.
|
/// Calcula el `ArtifactHash` de una receta, resolviendo recursivamente sus deps de build.
|
||||||
/// Ver `docs/02-build-lab.md` §2 y §5.
|
/// 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> {
|
pub fn artifact_hash(recipe: &Recipe, _store: &Store) -> hammer_core::Result<ArtifactHash> {
|
||||||
let dep_hashes: Vec<ArtifactHash> = Vec::new();
|
let mut stack: Vec<String> = Vec::new();
|
||||||
for _dep in &recipe.deps.build {
|
artifact_hash_rec(recipe, &mut stack)
|
||||||
// TODO(fase-0): cargar la receta de la dep y recursar.
|
}
|
||||||
|
|
||||||
|
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 inputs = recipe.hash_inputs(&dep_hashes)?;
|
||||||
let refs: Vec<&[u8]> = inputs.iter().map(|v| v.as_slice()).collect();
|
let refs: Vec<&[u8]> = inputs.iter().map(|v| v.as_slice()).collect();
|
||||||
Ok(ArtifactHash::of_inputs(&refs))
|
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.
|
/// 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é).
|
/// Si el hash ya existe en el store, devuelve sin recompilar (caché).
|
||||||
pub fn build(
|
pub fn build(
|
||||||
@@ -324,3 +377,103 @@ pub fn hydrate(
|
|||||||
let artifact_dir = store.find_by_hash(h.as_str())?;
|
let artifact_dir = store.find_by_hash(h.as_str())?;
|
||||||
hydrate::hydrate(&artifact_dir, target_fhs, mode)
|
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");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user