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, + /// Id de perilla del catálogo. Repetible. + #[arg(long = "knob")] + knobs: Vec, + #[arg(long)] + kconfig: Option, + #[arg(long)] + catalog: Option, + /// Escribe el plan en JSON (el contrato con las UIs). + #[arg(long)] + out: Option, + /// 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, + #[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-`). + #[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, + pub knobs: Vec, + /// Símbolos que se emiten, con procedencia. + pub symbols: Vec, + /// **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, + /// Perillas `side = "recipe"` seleccionadas: declaradas, no aplicadas. + pub recipe_side: Vec, + /// 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, +} + +/// 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 { + let mut wanted: BTreeMap = BTreeMap::new(); + let mut disable_roots: Vec = Vec::new(); + + let push = |wanted: &mut BTreeMap, + 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 { + 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, + /// Pedidos que el `.config` resultante contradice. **Cada uno es una promesa incumplida.** + pub violated: Vec, + /// Pedidos sobre símbolos que el `.config` ni menciona: no existen en esa versión del kernel. + pub absent: Vec, +} + +#[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