From 8faede66bdacd61db2cbd8fa67027b3866fee7b2 Mon Sep 17 00:00:00 2001 From: Sergio Date: Sun, 30 Aug 2026 01:52:57 +0000 Subject: [PATCH] kernel contract: un objetivo sin sellado vigente NO es un objetivo aprobado MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 Claude-Session: https://claude.ai/code/session_01ACUcwo9mZsE5ocYVE9npih --- crates/hammer-cli/src/kernel_cmd.rs | 83 +++++++++++++++++++++++++---- 1 file changed, 74 insertions(+), 9 deletions(-) diff --git a/crates/hammer-cli/src/kernel_cmd.rs b/crates/hammer-cli/src/kernel_cmd.rs index 8505e73a..5f39e17d 100644 --- a/crates/hammer-cli/src/kernel_cmd.rs +++ b/crates/hammer-cli/src/kernel_cmd.rs @@ -1355,6 +1355,16 @@ fn store_dir_of(path: &Path) -> Option { 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 (`/.toml`) y sólo si eso no da, barriendo el directorio: @@ -1365,7 +1375,7 @@ fn receta_de(artifact: &str, dirs: &[PathBuf]) -> Option { 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 { 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 { 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 → -`. /// /// POR QUÉ. El store guarda TODOS los sellados, no el último. Sin esta pregunta `--sealed` mide @@ -1432,6 +1470,10 @@ struct ContractRun { superados: Vec, /// Configs que NO se comprobaron por no tener perfil declarado. No cuentan como aprobados. sin_perfil: Vec, + /// 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, } 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 = + 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 {