> 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…
kernel: plan (receta derivada) y diff-back — dos hashes para el mismo código fuente
Paso 4 del §8 del SDD 22, y el corazón del armador.
hammer kernel plan --recipe recipes/linux.toml --bundle sin-wifi --bundle sin-audio
--bundle solo-ext4 --knob jaula-y-eio-moderna
base b3:cb926743…
derivada b3:47a52b2e… (16 banderas, 1775 símbolos con su clausura)
El config vive en la fase `configure` y las fases entran en hash_inputs ⇒ el config ES la
identidad del artefacto. Por eso el plan emite una RECETA DERIVADA y no finge que el
kernel sea un binario parametrizable. Y como el plan DETERMINA el artefacto, el JSON lleva
su ArtifactHash: la UI puede decir "esto ya está construido y firmado" sin construir nada.
Tres decisiones que no eran obvias:
· Se emiten RAÍCES, no clausuras: 16 banderas, no 1775 líneas. La clausura la calcula el
olddefconfig del propio kernel. hammer la sabe sólo para poder explicarla — la app
nunca escribe un .config.
· La fase derivada AÑADE una segunda ronda (…&& scripts/config … && make olddefconfig)
en vez de reescribir la base: no hay que parsear el shell de nadie, olddefconfig es
idempotente, y la base sigue siendo literalmente la de siempre en el diff.
· Los conflictos se RECHAZAN, no se ordenan. Resolver por orden de aparición sería una
respuesta plausible y arbitraria. Y hay un segundo conflicto que el símbolo solo no
delata: encender algo que cae DENTRO de la clausura de lo que otro bundle apaga —
olddefconfig lo descartaría sin decir nada.
diff-back: la mitad que faltaba del §6 del handoff. Clasifica cada símbolo pedido en
cumplido / INCUMPLIDO (el .config dice otra cosa) / ausente (el kernel ni lo menciona: la
bandera fue un no-op), con la procedencia de quién lo pidió, y sale != 0 si el config no
honra el plan. Probado contra /proc/config.gz de gioser: 3 incumplidos, 1 ausente.
La procedencia por símbolo (#7 del handoff) sale de regalo: cada bandera carga quién la
pidió y por qué (raíz del bundle / fuga select cerrada / perilla).
Y el orden del fragmento es estable a propósito: ese texto entra al hash, así que un orden
que dependiera del recorrido daría dos hashes para el mismo plan.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
diff --git a/crates/hammer-cli/src/kernel_cmd.rs b/crates/hammer-cli/src/kernel_cmd.rs
index b1b2254b..9b2651b8 100644
--- a/crates/hammer-cli/src/kernel_cmd.rs
+++ b/crates/hammer-cli/src/kernel_cmd.rs
@@ -84,6 +84,48 @@ pub enum KernelCmd {
#[arg(long)]
json: bool,
},
+ /// De una selección de bundles a una **receta derivada**, sin construir nada.
+ ///
+ /// El config del kernel vive en la fase `configure` y las fases entran en `hash_inputs` ⇒ el
+ /// config ES la identidad del artefacto. Por eso esto emite una receta con su propio
+ /// `ArtifactHash`, y no finge que el kernel sea un binario parametrizable.
+ Plan {
+ /// Receta base (p.ej. `recipes/linux.toml`).
+ #[arg(long)]
+ recipe: PathBuf,
+ /// Id de bundle del catálogo. Repetible.
+ #[arg(long = "bundle")]
+ bundles: Vec<String>,
+ /// Id de perilla del catálogo. Repetible.
+ #[arg(long = "knob")]
+ knobs: Vec<String>,
+ #[arg(long)]
+ kconfig: Option<PathBuf>,
+ #[arg(long)]
+ catalog: Option<PathBuf>,
+ /// Escribe el plan en JSON (el contrato con las UIs).
+ #[arg(long)]
+ out: Option<PathBuf>,
+ /// Escribe la receta derivada. Para construirla hay que dejarla donde sus `deps.build`
+ /// resuelvan — es decir, junto a la receta base.
+ #[arg(long)]
+ recipe_out: Option<PathBuf>,
+ #[arg(long)]
+ json: bool,
+ },
+ /// **Diff-back**: contrasta un plan contra el `.config` que produjo el kernel.
+ ///
+ /// Sin esto la UI miente: un `-e FOO` cuya dependencia no se cumple se pierde en silencio.
+ DiffBack {
+ /// El JSON que emitió `hammer kernel plan --out`.
+ #[arg(long)]
+ plan: PathBuf,
+ /// El `.config` resultante (el que la receta instala en `/out/boot/config-<versión>`).
+ #[arg(long)]
+ config: PathBuf,
+ #[arg(long)]
+ json: bool,
+ },
/// Estadísticas del árbol de Kconfig parseado (control de salud del lector).
Stats {
#[arg(long)]
@@ -94,7 +136,7 @@ pub enum KernelCmd {
},
}
-pub fn run(cmd: KernelCmd) -> Result<()> {
+pub fn run(cmd: KernelCmd, store: &str) -> Result<()> {
match cmd {
KernelCmd::Stats { kconfig, warnings } => stats(&kconfig_root(kconfig)?, warnings),
KernelCmd::Probe {
@@ -116,6 +158,27 @@ pub fn run(cmd: KernelCmd) -> Result<()> {
check,
json,
} => bundles(&kconfig_root(kconfig)?, &catalog_path(catalog)?, check, json),
+ KernelCmd::Plan {
+ recipe,
+ bundles: bs,
+ knobs,
+ kconfig,
+ catalog,
+ out,
+ recipe_out,
+ json,
+ } => plan(
+ &recipe,
+ &bs,
+ &knobs,
+ &kconfig_root(kconfig)?,
+ &catalog_path(catalog)?,
+ out.as_deref(),
+ recipe_out.as_deref(),
+ store,
+ json,
+ ),
+ KernelCmd::DiffBack { plan, config, json } => diff_back(&plan, &config, json),
KernelCmd::Closure {
symbols,
kconfig,
@@ -775,6 +838,165 @@ fn bundles(kroot: &Path, catalog: &Path, check: bool, json: bool) -> Result<()>
Ok(())
}
+// ---------------------------------------------------------------------------------------------
+// plan / diff-back
+// ---------------------------------------------------------------------------------------------
+
+#[allow(clippy::too_many_arguments)]
+fn plan(
+ recipe_path: &Path,
+ bundle_ids: &[String],
+ knob_ids: &[String],
+ kroot: &Path,
+ catalog: &Path,
+ out: Option<&Path>,
+ recipe_out: Option<&Path>,
+ store: &str,
+ json: bool,
+) -> Result<()> {
+ if bundle_ids.is_empty() && knob_ids.is_empty() {
+ anyhow::bail!("un plan sin bundles ni perillas es la receta base: pasá --bundle/--knob");
+ }
+ let base = hammer_core::Recipe::load_from_path(recipe_path)?;
+ let tree = load_tree(kroot)?;
+ let cat = hammer_core::kernel::Catalog::load(catalog)?;
+
+ let mut p = hammer_core::kernel::plan::make_plan(
+ &base,
+ &recipe_path.display().to_string(),
+ &cat,
+ &tree,
+ bundle_ids,
+ knob_ids,
+ )?;
+
+ // El plan DETERMINA el artefacto (SDD 22 §1), así que el hash va en el plan: la UI puede decir
+ // «esto ya está construido y firmado» sin construir nada. Si las deps no están resueltas, se
+ // dice que no se pudo en vez de emitir un hash inventado.
+ let derived = hammer_core::kernel::plan::derive_recipe(&base, &p);
+ match hammer_core::Store::open(Path::new(store))
+ .map_err(anyhow::Error::from)
+ .and_then(|s| hammer_build::artifact_hash(&derived, &s).map_err(anyhow::Error::from))
+ {
+ Ok(h) => p.artifact_hash = Some(h.to_string()),
+ Err(e) => eprintln!("aviso: no pude calcular el ArtifactHash ({e})"),
+ }
+
+ if let Some(o) = out {
+ if let Some(d) = o.parent() {
+ std::fs::create_dir_all(d).ok();
+ }
+ std::fs::write(o, serde_json::to_string_pretty(&p)?)
+ .with_context(|| format!("escribiendo {}", o.display()))?;
+ }
+ if let Some(ro) = recipe_out {
+ if let Some(d) = ro.parent() {
+ std::fs::create_dir_all(d).ok();
+ }
+ let cabecera = format!(
+ "# Receta DERIVADA, generada por `hammer kernel plan`. No editar a mano: regenerar.\n\
+ # base {}\n# bundles {}\n# perillas {}\n\
+ # Para construirla tiene que vivir junto a la receta base, o sus `deps.build` no\n\
+ # resuelven ({}).\n",
+ p.base_recipe,
+ if p.bundles.is_empty() { "—".into() } else { p.bundles.join(" ") },
+ if p.knobs.is_empty() { "—".into() } else { p.knobs.join(" ") },
+ recipe_path
+ .parent()
+ .map(|d| d.display().to_string())
+ .unwrap_or_default(),
+ );
+ let cuerpo = hammer_core::kernel::plan::render_recipe_toml(&derived)?;
+ std::fs::write(ro, format!("{cabecera}{cuerpo}"))
+ .with_context(|| format!("escribiendo {}", ro.display()))?;
+ }
+
+ if json {
+ println!("{}", serde_json::to_string_pretty(&p)?);
+ return Ok(());
+ }
+
+ println!("base {} ({})", p.base_name, p.base_recipe);
+ println!("derivada {}", p.derived_name);
+ match &p.artifact_hash {
+ Some(h) => println!("hash {h}"),
+ None => println!("hash (no calculado)"),
+ }
+ println!(
+ "selección {} bundle(s), {} perilla(s)",
+ p.bundles.len(),
+ p.knobs.len()
+ );
+ println!();
+ println!("emite {} bandera(s); con su clausura caen {} símbolo(s)", p.symbols.len(), p.closure_size);
+ println!("(se emiten RAÍCES: la clausura la calcula el olddefconfig del propio kernel)");
+ for s in &p.symbols {
+ let signo = match s.action {
+ hammer_core::kernel::plan::Action::Enable => "+",
+ hammer_core::kernel::plan::Action::Disable => "-",
+ };
+ println!(" {signo}{:<24} ← {} ({})", s.symbol, s.from, s.reason);
+ }
+ if !p.unknown_symbols.is_empty() {
+ println!();
+ println!("⚠ {} símbolo(s) pedidos NO EXISTEN en este árbol y no se emiten:", p.unknown_symbols.len());
+ for s in &p.unknown_symbols {
+ println!(" {} ← {}", s.symbol, s.from);
+ }
+ }
+ if !p.recipe_side.is_empty() {
+ println!();
+ println!("perillas del lado RECETA (declaradas, este plan no las aplica):");
+ for k in &p.recipe_side {
+ println!(" {:<24} {} — {}", k.id, k.recipe_field, k.title);
+ }
+ }
+ println!();
+ if let Some(o) = out {
+ println!("plan → {}", o.display());
+ }
+ if let Some(ro) = recipe_out {
+ println!("receta → {}", ro.display());
+ }
+ Ok(())
+}
+
+fn diff_back(plan_path: &Path, config: &Path, json: bool) -> Result<()> {
+ let p: hammer_core::kernel::Plan = serde_json::from_str(
+ &std::fs::read_to_string(plan_path)
+ .with_context(|| format!("leyendo {}", plan_path.display()))?,
+ )
+ .with_context(|| format!("{} no es un plan de `hammer kernel plan`", plan_path.display()))?;
+ let cfg = KernelConfig::parse(&read_maybe_gz(config)?);
+ if cfg.is_empty() {
+ anyhow::bail!("{} no tiene un solo CONFIG_*: ¿es un .config?", config.display());
+ }
+ let d = hammer_core::kernel::diff_back(&p, &cfg);
+
+ if json {
+ println!("{}", serde_json::to_string_pretty(&d)?);
+ } else {
+ println!("plan {} ({})", p.derived_name, plan_path.display());
+ println!("config {}", config.display());
+ println!("cumplidos {}", d.honored.len());
+ println!("ausentes {}", d.absent.len());
+ for s in &d.absent {
+ println!(" {s} — el kernel ni lo menciona: la bandera fue un no-op");
+ }
+ println!("INCUMPLIDOS {}", d.violated.len());
+ for v in &d.violated {
+ println!(
+ " {} — se pidió {:?} y quedó {} (lo pidió {})",
+ v.symbol, v.asked, v.got, v.from
+ );
+ }
+ }
+ if !d.is_clean() {
+ anyhow::bail!("el .config no honra el plan");
+ }
+ Ok(())
+}
+
/// Lee un `.config`, descomprimiendo si es `.gz` (el caso de `/proc/config.gz`).
///
/// Se delega a `gzip -dc` en vez de arrastrar un descompresor al core: es una entrada de
diff --git a/crates/hammer-cli/src/main.rs b/crates/hammer-cli/src/main.rs
index 91426266..2ec075fc 100644
--- a/crates/hammer-cli/src/main.rs
+++ b/crates/hammer-cli/src/main.rs
@@ -1422,7 +1422,7 @@ fn main() -> anyhow::Result<()> {
}
}
}
- Cmd::Kernel { sub } => kernel_cmd::run(sub)?,
+ Cmd::Kernel { sub } => kernel_cmd::run(sub, &cli.store)?,
}
Ok(())
}
diff --git a/crates/hammer-core/src/kernel/mod.rs b/crates/hammer-core/src/kernel/mod.rs
index d4217473..31bbeae3 100644
--- a/crates/hammer-core/src/kernel/mod.rs
+++ b/crates/hammer-core/src/kernel/mod.rs
@@ -20,10 +20,12 @@ pub mod catalog;
pub mod config;
pub mod hw;
pub mod kconfig;
+pub mod plan;
pub mod reverse;
pub use catalog::{Bundle, Catalog, Knob, Side};
pub use config::{ConfigValue, KernelConfig};
pub use hw::Hardware;
pub use kconfig::{Expr, KconfigTree, SelectLeak, SymKind, Symbol};
+pub use plan::{diff_back, make_plan, DiffBack, Plan};
pub use reverse::{BundleState, HwSignal, ReverseReport};
diff --git a/crates/hammer-core/src/kernel/plan.rs b/crates/hammer-core/src/kernel/plan.rs
new file mode 100644
index 00000000..970d005f
--- /dev/null
+++ b/crates/hammer-core/src/kernel/plan.rs
@@ -0,0 +1,558 @@
+//! `plan` — de una selección de bundles a una **receta derivada**.
+//!
+//! ## Por qué una receta y no un parámetro
+//! Las fases de build entran en `Recipe::hash_inputs` y el config del kernel vive en la fase
+//! `configure` ⇒ **el config ES la identidad del artefacto** (SDD 22 §1). No hay forma de tener un
+//! config variable sin generar recetas: una «perilla» de la UI no es un parámetro de runtime, es
+//! una edición de receta. Fingir lo contrario daría un store que dice que todo está al día mientras
+//! los artefactos ya no corresponden — en un sistema direccionado por contenido, el peor fallo
+//! posible no es fallar, es **mentir**.
+//!
+//! De ahí sale gratis lo que §7 del handoff pedía: el plan **determina** el artefacto, así que el
+//! JSON lleva su `ArtifactHash` y la UI puede decir «esto ya está construido y firmado» sin
+//! construir nada.
+//!
+//! ## Qué se emite y qué no
+//! Se emiten **raíces**, no clausuras. `-d WIRELESS -d WLAN` son dos banderas; los ~400 símbolos
+//! que caen con ellas los calcula el `olddefconfig` **del propio kernel**. hammer sabe cuáles son
+//! (para poder explicarlos), pero no los escribe: la app nunca escribe un `.config`.
+//!
+//! ## Procedencia por símbolo
+//! Cada símbolo emitido carga quién lo pidió ([`PlannedSymbol::from`]). Es el #7 del handoff, y con
+//! él el diff entre dos generaciones deja de ser «347 símbolos cambiaron» para ser «activaste
+//! bluetooth y entraron 12 símbolos».
+
+use std::collections::BTreeMap;
+
+use serde::{Deserialize, Serialize};
+
+use super::catalog::{Catalog, Side};
+use super::kconfig::KconfigTree;
+use crate::recipe::Recipe;
+
+/// Qué se le pide a `scripts/config` para un símbolo.
+#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
+#[serde(rename_all = "lowercase")]
+pub enum Action {
+ Enable,
+ Disable,
+}
+
+impl Action {
+ fn flag(self) -> &'static str {
+ match self {
+ Action::Enable => "-e",
+ Action::Disable => "-d",
+ }
+ }
+}
+
+/// Un símbolo emitido, con su procedencia.
+#[derive(Debug, Clone, Serialize, Deserialize)]
+pub struct PlannedSymbol {
+ pub symbol: String,
+ pub action: Action,
+ /// `bundle:sin-wifi`, `knob:tick-1000`.
+ pub from: String,
+ /// Por qué está: raíz declarada, o fuga `select` que hubo que cerrar.
+ pub reason: String,
+}
+
+/// Una perilla que **no** cae del lado de Kconfig: el plan la declara y no la aplica.
+#[derive(Debug, Clone, Serialize, Deserialize)]
+pub struct RecipeKnob {
+ pub id: String,
+ pub title: String,
+ pub recipe_field: String,
+}
+
+#[derive(Debug, Clone, Serialize, Deserialize)]
+pub struct Plan {
+ pub base_recipe: String,
+ pub base_name: String,
+ pub derived_name: String,
+ pub bundles: Vec<String>,
+ pub knobs: Vec<String>,
+ /// Símbolos que se emiten, con procedencia.
+ pub symbols: Vec<PlannedSymbol>,
+ /// **Diff-back estático**: pedidos que no existen en este árbol. Hoy un `-d` sobre uno de éstos
+ /// es un no-op que nadie ve — le pasa a `THUNDERBOLT` y a `REISERFS_FS` en las cuatro recetas
+ /// de kernel del repo.
+ pub unknown_symbols: Vec<PlannedSymbol>,
+ /// Perillas `side = "recipe"` seleccionadas: declaradas, no aplicadas.
+ pub recipe_side: Vec<RecipeKnob>,
+ /// Cuántos símbolos apaga de verdad la selección, contando la clausura. Es informativo: quien
+ /// la calcula de vuelta, y manda, es `olddefconfig`.
+ pub closure_size: usize,
+ /// La invocación de `scripts/config` que se inserta.
+ pub fragment: String,
+ /// La fase `configure` derivada, entera.
+ pub configure: String,
+ /// El `ArtifactHash` de la receta derivada. `None` si no se pudo calcular (deps sin resolver).
+ #[serde(skip_serializing_if = "Option::is_none")]
+ pub artifact_hash: Option<String>,
+}
+
+/// Construye el plan. No toca el disco.
+pub fn make_plan(
+ base: &Recipe,
+ base_path: &str,
+ cat: &Catalog,
+ tree: &KconfigTree,
+ bundle_ids: &[String],
+ knob_ids: &[String],
+) -> crate::Result<Plan> {
+ let mut wanted: BTreeMap<String, PlannedSymbol> = BTreeMap::new();
+ let mut disable_roots: Vec<String> = Vec::new();
+
+ let push = |wanted: &mut BTreeMap<String, PlannedSymbol>,
+ sym: &str,
+ action: Action,
+ from: String,
+ reason: &str|
+ -> crate::Result<()> {
+ // Dos selecciones que se contradicen sobre el mismo símbolo no se resuelven por orden de
+ // aparición: se rechazan. El orden sería una respuesta plausible y arbitraria.
+ if let Some(prev) = wanted.get(sym) {
+ if prev.action != action {
+ return Err(crate::Error::Recipe(format!(
+ "conflicto en {sym}: «{}» lo quiere {:?} y «{from}» lo quiere {:?}",
+ prev.from, prev.action, action
+ )));
+ }
+ return Ok(());
+ }
+ wanted.insert(
+ sym.to_string(),
+ PlannedSymbol {
+ symbol: sym.to_string(),
+ action,
+ from,
+ reason: reason.to_string(),
+ },
+ );
+ Ok(())
+ };
+
+ for id in bundle_ids {
+ let b = cat.bundle(id).ok_or_else(|| {
+ crate::Error::Recipe(format!("no existe el bundle «{id}» en el catálogo"))
+ })?;
+ let from = format!("bundle:{id}");
+ for s in &b.disable {
+ push(&mut wanted, s, Action::Disable, from.clone(), "raíz del bundle")?;
+ disable_roots.push(s.clone());
+ }
+ for s in &b.close_leaks {
+ push(
+ &mut wanted,
+ s,
+ Action::Disable,
+ from.clone(),
+ "fuga `select` cerrada: sin esto el olddefconfig lo reenciende",
+ )?;
+ disable_roots.push(s.clone());
+ }
+ for s in &b.enable {
+ push(&mut wanted, s, Action::Enable, from.clone(), "raíz del bundle")?;
+ }
+ }
+
+ let mut recipe_side = Vec::new();
+ for id in knob_ids {
+ let k = cat.knob(id).ok_or_else(|| {
+ crate::Error::Recipe(format!("no existe la perilla «{id}» en el catálogo"))
+ })?;
+ let from = format!("knob:{id}");
+ if k.side == Side::Recipe {
+ recipe_side.push(RecipeKnob {
+ id: k.id.clone(),
+ title: k.title.clone(),
+ recipe_field: k.recipe_field.clone().unwrap_or_default(),
+ });
+ continue;
+ }
+ for s in &k.enable {
+ push(&mut wanted, s, Action::Enable, from.clone(), "perilla")?;
+ }
+ for s in &k.disable {
+ push(&mut wanted, s, Action::Disable, from.clone(), "perilla")?;
+ disable_roots.push(s.clone());
+ }
+ }
+
+ // Un `enable` que cae DENTRO de lo que otro bundle apaga es un conflicto que el símbolo solo no
+ // delata: `olddefconfig` lo descartaría en silencio por dependencia insatisfecha.
+ let closure = tree.closure_off(&disable_roots);
+ for p in wanted.values() {
+ if p.action == Action::Enable && closure.contains(&p.symbol) {
+ return Err(crate::Error::Recipe(format!(
+ "conflicto: «{}» quiere encender {} pero cae dentro de lo que apaga la selección \
+ (el olddefconfig lo descartaría sin decir nada)",
+ p.from, p.symbol
+ )));
+ }
+ }
+
+ let (known, unknown_symbols): (Vec<_>, Vec<_>) = wanted
+ .into_values()
+ .partition(|p| tree.symbols.contains_key(&p.symbol));
+
+ let arch = detect_arch(base);
+ let fragment = render_fragment(&known);
+ let base_configure = base
+ .build
+ .phases
+ .configure
+ .as_deref()
+ .ok_or_else(|| {
+ crate::Error::Recipe(format!(
+ "la receta base «{}» no tiene fase `configure`: no hay dónde insertar el config",
+ base.name
+ ))
+ })?
+ .trim_end()
+ .trim_end_matches('\\')
+ .trim_end()
+ .to_string();
+
+ // Se AÑADE una segunda ronda en vez de reescribir la fase base: no hay que parsear el shell de
+ // nadie, la base sigue siendo literalmente la de siempre en el diff, y `olddefconfig` es
+ // idempotente. Lo que la app aporta es un fragmento; resolver sigue siendo del kernel.
+ let configure = format!("{base_configure} && \\\n{fragment} && \\\nmake ARCH={arch} olddefconfig\n");
+
+ let derived_name = derive_name(&base.name, bundle_ids, knob_ids);
+
+ Ok(Plan {
+ base_recipe: base_path.to_string(),
+ base_name: base.name.clone(),
+ derived_name,
+ bundles: bundle_ids.to_vec(),
+ knobs: knob_ids.to_vec(),
+ symbols: known,
+ unknown_symbols,
+ recipe_side,
+ closure_size: closure.len(),
+ fragment,
+ configure,
+ artifact_hash: None,
+ })
+}
+
+/// La receta derivada: la base con la fase `configure` del plan y un nombre propio.
+///
+/// Conserva el `base_dir` de la base a propósito — es lo que hace resolubles sus `deps.build` y,
+/// con ellas, calculable el `ArtifactHash`.
+pub fn derive_recipe(base: &Recipe, plan: &Plan) -> Recipe {
+ let mut r = base.clone();
+ r.name = plan.derived_name.clone();
+ r.build.phases.configure = Some(plan.configure.clone());
+ r
+}
+
+/// Serializa la receta derivada a TOML. Vive acá y no en la CLI porque el formato de receta es del
+/// core: quien lo cambie tiene que cambiarlo en un solo sitio.
+pub fn render_recipe_toml(r: &Recipe) -> crate::Result<String> {
+ toml::to_string_pretty(r).map_err(|e| crate::Error::Serde(e.to_string()))
+}
+
+fn detect_arch(base: &Recipe) -> String {
+ let hay = base
+ .build
+ .phases
+ .configure
+ .as_deref()
+ .unwrap_or_default();
+ for tok in hay.split_whitespace() {
+ if let Some(v) = tok.strip_prefix("ARCH=") {
+ if !v.is_empty() {
+ return v.to_string();
+ }
+ }
+ }
+ "x86_64".to_string()
+}
+
+/// `scripts/config -d A -d B -e C …`, en líneas de ancho razonable y **orden estable**: el texto
+/// entra al hash, así que un orden que dependiera del recorrido daría dos hashes para el mismo plan.
+fn render_fragment(syms: &[PlannedSymbol]) -> String {
+ let mut out = String::from("scripts/config");
+ let mut col = out.len();
+ for p in syms {
+ let pieza = format!(" {} {}", p.action.flag(), p.symbol);
+ if col + pieza.len() > 96 {
+ out.push_str(" \\\n ");
+ col = 1;
+ }
+ col += pieza.len();
+ out.push_str(&pieza);
+ }
+ out
+}
+
+/// Nombre de la receta derivada. Lleva un hash corto de la SELECCIÓN, no del contenido: dos planes
+/// con los mismos bundles dan el mismo nombre, y el `ArtifactHash` sigue siendo quien manda.
+fn derive_name(base: &str, bundles: &[String], knobs: &[String]) -> String {
+ let mut material = String::new();
+ let mut b: Vec<&String> = bundles.iter().collect();
+ b.sort();
+ for x in b {
+ material.push_str("bundle:");
+ material.push_str(x);
+ material.push('\n');
+ }
+ let mut k: Vec<&String> = knobs.iter().collect();
+ k.sort();
+ for x in k {
+ material.push_str("knob:");
+ material.push_str(x);
+ material.push('\n');
+ }
+ let h = crate::hash::ArtifactHash::of_bytes(material.as_bytes());
+ let hex = h.as_str().trim_start_matches("b3:");
+ format!("{base}-plan-{}", &hex[..8])
+}
+
+/// **Diff-back** (§6 del handoff, la mitad que faltaba): ¿sobrevivió cada símbolo pedido al
+/// `olddefconfig`?
+///
+/// Sin esto la UI miente: un `-e FOO` cuya dependencia no se cumple se pierde en silencio.
+#[derive(Debug, Clone, Serialize, Deserialize)]
+pub struct DiffBack {
+ pub honored: Vec<String>,
+ /// Pedidos que el `.config` resultante contradice. **Cada uno es una promesa incumplida.**
+ pub violated: Vec<Violation>,
+ /// Pedidos sobre símbolos que el `.config` ni menciona: no existen en esa versión del kernel.
+ pub absent: Vec<String>,
+}
+
+#[derive(Debug, Clone, Serialize, Deserialize)]
+pub struct Violation {
+ pub symbol: String,
+ pub asked: Action,
+ /// Cómo quedó de verdad.
+ pub got: String,
+ pub from: String,
+}
+
+impl DiffBack {
+ pub fn is_clean(&self) -> bool {
+ self.violated.is_empty() && self.absent.is_empty()
+ }
+}
+
+/// Contrasta el plan contra el `.config` que produjo el kernel.
+pub fn diff_back(plan: &Plan, produced: &super::KernelConfig) -> DiffBack {
+ use super::ConfigValue;
+ let mut d = DiffBack {
+ honored: Vec::new(),
+ violated: Vec::new(),
+ absent: Vec::new(),
+ };
+ for p in &plan.symbols {
+ match (p.action, produced.get(&p.symbol)) {
+ (_, None) => d.absent.push(p.symbol.clone()),
+ (Action::Enable, Some(v)) if v.is_on() => d.honored.push(p.symbol.clone()),
+ (Action::Disable, Some(ConfigValue::No)) => d.honored.push(p.symbol.clone()),
+ (asked, Some(v)) => d.violated.push(Violation {
+ symbol: p.symbol.clone(),
+ asked,
+ got: format!("{v:?}"),
+ from: p.from.clone(),
+ }),
+ }
+ }
+ // Los que ya sabíamos inexistentes en el árbol también son promesa incumplida.
+ for p in &plan.unknown_symbols {
+ d.absent.push(p.symbol.clone());
+ }
+ d.absent.sort();
+ d.absent.dedup();
+ d
+}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+ use std::collections::BTreeMap;
+
+ fn escenario() -> (tempfile::TempDir, KconfigTree, Catalog, Recipe) {
+ let d = tempfile::tempdir().unwrap();
+ std::fs::write(
+ d.path().join("Kconfig"),
+ r#"
+config WIRELESS
+ bool "Wireless"
+
+config CFG80211
+ tristate "cfg80211"
+ depends on WIRELESS
+
+config WLAN
+ bool "WLAN"
+ select WIRELESS
+
+config AUDIT
+ bool "Audit"
+
+config MAC80211_LEDS
+ bool "leds"
+ depends on CFG80211
+"#,
+ )
+ .unwrap();
+ let tree = KconfigTree::parse(d.path(), &BTreeMap::new()).unwrap();
+ let cat: Catalog = toml::from_str(
+ r#"
+version = 1
+[[bundle]]
+id = "sin-wifi"
+title = "No necesito wifi"
+disable = ["WIRELESS"]
+close_leaks = ["WLAN"]
+
+[[bundle]]
+id = "con-leds"
+title = "Quiero los leds"
+enable = ["MAC80211_LEDS"]
+
+[[knob]]
+id = "auditoria"
+title = "Auditoría"
+side = "kconfig"
+enable = ["AUDIT"]
+
+[[knob]]
+id = "lto"
+title = "LTO"
+side = "recipe"
+recipe_field = "build.compiler"
+"#,
+ )
+ .unwrap();
+ let recipe: Recipe = toml::from_str(
+ r#"
+name = "linux"
+version = "6.16.12"
+[source]
+tarball = "https://x/linux.tar.gz"
+sha256 = "aa"
+[build]
+compiler = "zig-cc"
+target = "x86_64-linux-musl"
+link = "dynamic"
+[build.phases]
+configure = "make ARCH=x86_64 defconfig && make ARCH=x86_64 olddefconfig"
+compile = "make bzImage"
+"#,
+ )
+ .unwrap();
+ (d, tree, cat, recipe)
+ }
+
+ #[test]
+ fn emite_raices_no_clausuras() {
+ let (_d, tree, cat, base) = escenario();
+ let p = make_plan(&base, "recipes/linux.toml", &cat, &tree, &["sin-wifi".into()], &[]).unwrap();
+ // Dos banderas, no cuatro símbolos: la clausura la calcula olddefconfig.
+ assert_eq!(p.symbols.len(), 2);
+ assert!(p.fragment.contains("-d WIRELESS"));
+ assert!(p.fragment.contains("-d WLAN"));
+ assert!(!p.fragment.contains("CFG80211"));
+ // …pero hammer SABE cuántos caen, para poder explicarlo.
+ assert_eq!(p.closure_size, 4);
+ }
+
+ #[test]
+ fn la_fase_derivada_conserva_la_base_y_reresuelve() {
+ let (_d, tree, cat, base) = escenario();
+ let p = make_plan(&base, "recipes/linux.toml", &cat, &tree, &["sin-wifi".into()], &[]).unwrap();
+ assert!(p.configure.starts_with("make ARCH=x86_64 defconfig"));
+ assert!(p.configure.trim_end().ends_with("make ARCH=x86_64 olddefconfig"));
+ // El fragmento va ANTES del olddefconfig final: si fuera después, no se resolvería.
+ let i_frag = p.configure.find("scripts/config").unwrap();
+ let i_last = p.configure.rfind("olddefconfig").unwrap();
+ assert!(i_frag < i_last);
+ }
+
+ #[test]
+ fn el_nombre_derivado_es_estable_e_independiente_del_orden() {
+ let (_d, tree, cat, base) = escenario();
+ let a = make_plan(&base, "r", &cat, &tree, &["sin-wifi".into()], &["auditoria".into()]).unwrap();
+ let b = make_plan(&base, "r", &cat, &tree, &["sin-wifi".into()], &["auditoria".into()]).unwrap();
+ assert_eq!(a.derived_name, b.derived_name);
+ assert!(a.derived_name.starts_with("linux-plan-"));
+ let c = make_plan(&base, "r", &cat, &tree, &["sin-wifi".into()], &[]).unwrap();
+ assert_ne!(a.derived_name, c.derived_name);
+ }
+
+ #[test]
+ fn encender_algo_que_la_seleccion_apaga_es_conflicto() {
+ let (_d, tree, cat, base) = escenario();
+ // MAC80211_LEDS depende de CFG80211, que cae con WIRELESS. olddefconfig lo tiraría callado.
+ let e = make_plan(
+ &base,
+ "r",
+ &cat,
+ &tree,
+ &["sin-wifi".into(), "con-leds".into()],
+ &[],
+ )
+ .unwrap_err()
+ .to_string();
+ assert!(e.contains("MAC80211_LEDS"), "{e}");
+ }
+
+ #[test]
+ fn la_perilla_de_receta_se_declara_y_no_se_aplica() {
+ let (_d, tree, cat, base) = escenario();
+ let p = make_plan(&base, "r", &cat, &tree, &[], &["lto".into()]).unwrap();
+ assert!(p.symbols.is_empty());
+ assert_eq!(p.recipe_side.len(), 1);
+ assert_eq!(p.recipe_side[0].recipe_field, "build.compiler");
+ }
+
+ #[test]
+ fn el_simbolo_inexistente_no_se_pierde_en_silencio() {
+ let (_d, tree, _cat, base) = escenario();
+ let cat: Catalog = toml::from_str(
+ r#"
+version = 1
+[[bundle]]
+id = "fantasma"
+title = "Apaga algo que ya no existe"
+disable = ["REISERFS_FS"]
+"#,
+ )
+ .unwrap();
+ let p = make_plan(&base, "r", &cat, &tree, &["fantasma".into()], &[]).unwrap();
+ assert!(p.symbols.is_empty(), "no se emite una bandera que sería no-op");
+ assert_eq!(p.unknown_symbols.len(), 1);
+ assert_eq!(p.unknown_symbols[0].symbol, "REISERFS_FS");
+ }
+
+ #[test]
+ fn el_diff_back_ve_la_promesa_incumplida() {
+ let (_d, tree, cat, base) = escenario();
+ let p = make_plan(&base, "r", &cat, &tree, &["sin-wifi".into()], &["auditoria".into()]).unwrap();
+ // El kernel produjo un config donde WLAN sobrevivió y AUDIT no se pudo encender.
+ let cfg = super::super::KernelConfig::parse(
+ "# CONFIG_WIRELESS is not set\nCONFIG_WLAN=y\n# CONFIG_AUDIT is not set\n",
+ );
+ let d = diff_back(&p, &cfg);
+ assert!(!d.is_clean());
+ let violados: Vec<&str> = d.violated.iter().map(|v| v.symbol.as_str()).collect();
+ assert_eq!(violados, vec!["AUDIT", "WLAN"]);
+ assert_eq!(d.honored, vec!["WIRELESS"]);
+ }
+
+ #[test]
+ fn el_diff_back_limpio_es_limpio() {
+ let (_d, tree, cat, base) = escenario();
+ let p = make_plan(&base, "r", &cat, &tree, &["sin-wifi".into()], &[]).unwrap();
+ let cfg = super::super::KernelConfig::parse(
+ "# CONFIG_WIRELESS is not set\n# CONFIG_WLAN is not set\n",
+ );
+ assert!(diff_back(&p, &cfg).is_clean());
+ }
+}
diff --git a/docs/22-configurador-kernel.md b/docs/22-configurador-kernel.md
index b985ce0c..2fdfffa7 100644
--- a/docs/22-configurador-kernel.md
+++ b/docs/22-configurador-kernel.md
@@ -304,6 +304,43 @@ Para cerrar «no necesito audio» hay que tragarse drivers gráficos. En `linux.
porque los gráficos también están apagados, pero en un perfil de escritorio **no**: ahí la fuga es
una decisión, y por eso el punto fijo la muestra en vez de aplicarla.
+## 10. Pasos 1 y 4 hechos: modo reversa, plan y diff-back
+
+**Modo reversa** (`hammer kernel probe`) sobre gioser, con el catálogo `docs/state/kernel-bundles.toml`
+(15 bundles N1, 8 perillas N2): 10 551 símbolos declarados, 20 dispositivos PCI, 38 drivers
+bindeados, **7 bundles con capacidad que este hardware no usa**. Y avisa de lo que el propio caso
+destapó: el config vivo es de la serie 7.1 y el catálogo se revisó contra la 6.16 ⇒ las clausuras
+son aproximadas. Se dice en vez de callarlo.
+
+**Plan** (`hammer kernel plan`): emite una **receta derivada**, como manda §1. Medido sobre
+`recipes/linux.toml` con `sin-wifi` + `sin-audio` + `solo-ext4` + `jaula-y-eio-moderna`:
+
+| | |
+|---|---|
+| banderas emitidas | **16** |
+| símbolos que caen con su clausura | **1775** |
+| `ArtifactHash` de la base | `b3:cb926743…` |
+| `ArtifactHash` de la derivada | `b3:47a52b2e…` |
+
+Dos hashes distintos para el mismo código fuente: **el config es la identidad**, y ahora se ve. El
+plan lleva su hash dentro ⇒ la UI puede decir «esto ya está construido y firmado» sin construir.
+
+Tres detalles del plan que no eran obvios:
+- **Se emiten raíces, no clausuras.** 16 banderas, no 1775 líneas. La clausura la calcula el
+ `olddefconfig` del propio kernel; hammer la sabe sólo para poder explicarla.
+- **La fase derivada AÑADE una segunda ronda** (`… && scripts/config … && make olddefconfig`) en vez
+ de reescribir la base. No hay que parsear el shell de nadie y la base sigue siendo literalmente la
+ de siempre en el diff.
+- **Los conflictos se rechazan, no se ordenan.** Dos selecciones que se contradicen sobre un símbolo
+ darían una respuesta plausible y arbitraria según el orden de aparición. Y hay un segundo conflicto
+ que el símbolo solo no delata: encender algo que **cae dentro de la clausura** de lo que otro
+ bundle apaga — `olddefconfig` lo descartaría sin decir nada.
+
+**Diff-back** (`hammer kernel diff-back --plan … --config …`): la mitad que faltaba del §6 del
+handoff. Clasifica cada símbolo pedido en cumplido / **incumplido** (el `.config` dice otra cosa) /
+ausente (el kernel ni lo menciona: la bandera fue un no-op), con la procedencia de quién lo pidió, y
+sale distinto de cero si el config no honra el plan.
+
### Un hallazgo de regalo
`recipes/linux.toml` apaga **`REISERFS_FS`, que no existe en 6.16.12** (upstream lo retiró). El
`scripts/config -d REISERFS_FS` es un no-op que nadie vio. Es exactamente el modo de fallo que el