kernel contract: un objetivo sin sellado vigente NO es un objetivo aprobado

Faltaban dos agujeros del mismo tamaño que el que este guardián vino a tapar:

1. La receta derivada de gioser vive fuera de recipes/ a propósito, así que sus deps no
   resolvían y su hash no se podía calcular ⇒ su sellado viejo se comprobaba como si fuera el
   vigente. Ahora se le presta el catálogo (base_dir), salvo que traiga patches — que base_dir
   también los resuelve y moverlo los rompería en silencio.
2. Un objetivo del contrato cuya receta de hoy no tiene NINGÚN sellado simplemente no aparecía
   en el barrido, y no aparecer se leía como que no había nada que objetar. Ahora se nombra con
   el hash que le tocaría y sale != 0: no comprobar no es aprobar.

Hoy eso dice, con nombre y hash, exactamente los 4 kernels que hay que construir para dar H1
por pagado.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01ACUcwo9mZsE5ocYVE9npih
This commit is contained in:
Sergio
2026-08-30 01:52:57 +00:00
co-authored by Claude Opus 5
parent 697d3f21a1
commit 8faede66bd
+74 -9
View File
@@ -1355,6 +1355,16 @@ fn store_dir_of(path: &Path) -> Option<String> {
None
}
/// ¿Resuelven las `deps.build` de esta receta desde `dir`? Misma regla que el build: hermana, y
/// si no, en el directorio padre (que es como una receta de `recipes/incoming-*/` alcanza el
/// catálogo).
fn deps_resuelven_desde(dir: &Path, deps: &[String]) -> bool {
deps.iter().all(|d| {
let f = format!("{d}.toml");
dir.join(&f).exists() || dir.parent().is_some_and(|p| p.join(&f).exists())
})
}
/// La receta cuyo campo `name` es `artifact`, buscada en `dirs`.
///
/// Primero por convención (`<dir>/<artifact>.toml`) y sólo si eso no da, barriendo el directorio:
@@ -1365,7 +1375,7 @@ fn receta_de(artifact: &str, dirs: &[PathBuf]) -> Option<hammer_core::Recipe> {
let directo = d.join(format!("{artifact}.toml"));
if let Ok(r) = hammer_core::Recipe::load_from_path(&directo) {
if r.name == artifact {
return Some(r);
return Some(con_catalogo(r, dirs));
}
}
let Ok(rd) = std::fs::read_dir(d) else { continue };
@@ -1374,7 +1384,7 @@ fn receta_de(artifact: &str, dirs: &[PathBuf]) -> Option<hammer_core::Recipe> {
if p.extension().is_some_and(|x| x == "toml") {
if let Ok(r) = hammer_core::Recipe::load_from_path(&p) {
if r.name == artifact {
return Some(r);
return Some(con_catalogo(r, dirs));
}
}
}
@@ -1383,6 +1393,34 @@ fn receta_de(artifact: &str, dirs: &[PathBuf]) -> Option<hammer_core::Recipe> {
None
}
/// Le presta un catálogo a una receta que vive fuera de uno.
///
/// Las recetas DERIVADAS (`hammer kernel plan --recipe-out`) se guardan a propósito fuera de
/// `recipes/`: dejarlas ahí las mete en el grafo compartido como deuda. Pero sus `deps.build` son
/// las del catálogo padre, así que desde su directorio no resuelven y el hash no se puede calcular
/// — y sin hash no hay vigencia que comparar. Acá se les apunta el `base_dir` al primer directorio
/// de `dirs` donde sí resuelven, que es exactamente lo que hace el runbook cuando la copia a
/// `recipes/incoming-kernel/` para construirla.
///
/// NO se toca si la receta trae `patches`: `base_dir` también los resuelve, y moverlo los rompería
/// en silencio. Ahí es mejor no saber la vigencia (se comprueba todo) que hashear otra cosa.
fn con_catalogo(mut r: hammer_core::Recipe, dirs: &[PathBuf]) -> hammer_core::Recipe {
if r.deps.build.is_empty() || deps_resuelven_desde(&r.base_dir, &r.deps.build) {
return r;
}
if !r.source.patches.is_empty() {
eprintln!(
"aviso: «{}» vive fuera de un catálogo y trae patches ⇒ no le presto base_dir",
r.name
);
return r;
}
if let Some(d) = dirs.iter().find(|d| deps_resuelven_desde(d, &r.deps.build)) {
r.base_dir = d.clone();
}
r
}
/// Qué directorio del store le toca HOY a cada kernel del contrato: `artefacto → <hash>-<nombre>`.
///
/// POR QUÉ. El store guarda TODOS los sellados, no el último. Sin esta pregunta `--sealed` mide
@@ -1432,6 +1470,10 @@ struct ContractRun {
superados: Vec<hammer_core::kernel::ContractReport>,
/// Configs que NO se comprobaron por no tener perfil declarado. No cuentan como aprobados.
sin_perfil: Vec<String>,
/// Objetivos del contrato cuya receta de hoy NO tiene ni un sellado en el store. No se
/// comprobó nada de ellos: se nombran para que la ausencia no pase por aprobado.
#[serde(default)]
sin_construir: Vec<String>,
}
fn contract_cmd(
@@ -1540,6 +1582,7 @@ fn contract_cmd(
reports: Vec::new(),
superados: Vec::new(),
sin_perfil: Vec::new(),
sin_construir: Vec::new(),
};
for (label, artifact, path) in &objetivos {
@@ -1570,6 +1613,18 @@ fn contract_cmd(
}
}
// Un objetivo del contrato cuya receta de hoy no tiene NINGÚN sellado no aparece en el
// barrido — y no aparecer se lee como que no hay nada que objetar. Se nombra.
if let Some(v) = &vigentes {
let vistos: std::collections::BTreeSet<String> =
objetivos.iter().filter_map(|(_, _, p)| store_dir_of(p)).collect();
for (art, dir) in v {
if !vistos.contains(dir) {
run.sin_construir.push(format!("{art} · {dir}"));
}
}
}
if json {
println!("{}", serde_json::to_string_pretty(&run)?);
} else {
@@ -1584,14 +1639,13 @@ fn contract_cmd(
if bloquean > 0 {
anyhow::bail!("el contrato NO se cumple en {bloquean} kernel(es)");
}
// Cero vigentes con superados a la vista NO es aprobado: es que la receta cambió y todavía no
// se reconstruyó nada. Dejarlo pasar en verde sería el fallo de CLAUDE.md §3 —el vacío leído
// como presencia— en el propio guardián.
if sealed && run.reports.is_empty() && !run.superados.is_empty() {
// Un objetivo sin sellado vigente no se comprobó, y no comprobar no es aprobar: es el fallo
// de CLAUDE.md §3 —el vacío leído como presencia— en el propio guardián.
if !run.sin_construir.is_empty() {
anyhow::bail!(
"no hay UN SOLO kernel vigente sellado ({} superado(s) en el store): el contrato no se \
comprobó contra nada. Construí las recetas de kernel antes de leer esto como aprobado.",
run.superados.len()
"{} objetivo(s) del contrato sin un sellado vigente: no se comprobó nada de ellos. \
Construí sus recetas antes de leer esto como aprobado.",
run.sin_construir.len()
);
}
Ok(())
@@ -1688,6 +1742,17 @@ fn imprimir_contrato(run: &ContractRun) {
}
);
println!();
if !run.sin_construir.is_empty() {
println!(
"{} objetivo(s) del contrato SIN UN SELLADO VIGENTE — no se comprobó nada de ellos:",
run.sin_construir.len()
);
for s in &run.sin_construir {
println!(" {s}");
}
println!(" no cuentan como aprobados: el barrido no tuvo qué mirar. Construí sus recetas.");
println!();
}
if !run.sin_perfil.is_empty() {
println!("{} config(s) SIN COMPROBAR (ningún perfil declarado):", run.sin_perfil.len());
for s in &run.sin_perfil {