//! `hammer kernel` — el armador de kernel (SDD 22). //! //! Superficie en inglés (Regla 7.bis), mensajes en castellano. El contrato con las UIs es JSON //! estable, mismo patrón que `/run/hammer/boot-graph.json`. use std::collections::{BTreeMap, BTreeSet}; use std::path::{Path, PathBuf}; use anyhow::{Context, Result}; use clap::Subcommand; use hammer_core::kernel::kconfig::KconfigTree; use hammer_core::kernel::KernelConfig; use serde::Serialize; /// Dónde busca el árbol de Kconfig si no se pasa `--kconfig`. const KCONFIG_ENV: &str = "HAMMER_KCONFIG_ROOT"; /// Ídem para el catálogo de bundles. const CATALOG_ENV: &str = "HAMMER_KERNEL_BUNDLES"; /// Sitios donde buscarlo sin que nadie diga nada: el del repo y el del sistema instalado. const CATALOG_DEFAULTS: &[&str] = &[ "docs/state/kernel-bundles.toml", "/usr/share/hammer/kernel-bundles.toml", ]; #[derive(Subcommand)] pub enum KernelCmd { /// Analiza el grafo de Kconfig: qué muere si se apagan estos símbolos, y por dónde se cuela un /// `select` de vuelta. /// /// Es la herramienta con la que se decide si un bundle «por clausura» es viable (SDD 22 §3). /// No construye ni escribe nada. Closure { /// Símbolos raíz a apagar, SIN el prefijo `CONFIG_` (p.ej. `WIRELESS`). #[arg(required = true)] symbols: Vec, /// Raíz del árbol de fuentes del kernel (el directorio con el `Kconfig` de arriba). #[arg(long)] kconfig: Option, /// Contrasta la clausura contra los símbolos apagados en este `.config`. #[arg(long)] against: Option, /// Cierra la clausura contra sus propias fugas: apaga también a cada selector, y repite. /// Muestra el PRECIO (qué hubo que tragarse de más) en vez de aplicarlo a ciegas. #[arg(long)] fixpoint: bool, /// Emite JSON en vez del informe legible. #[arg(long)] json: bool, /// Cuántos símbolos de la clausura listar en el informe legible. #[arg(long, default_value_t = 20)] limit: usize, }, /// **Modo reversa**: lee el kernel que ya corre y lo muestra por el lente de los bundles. /// /// No compila ni escribe nada. Contesta tres cosas: cuánto de cada bundle ya rige, qué /// capacidad carga este kernel que esta máquina no usa, y dónde el hardware CONTRADICE a un /// bundle aplicado. Probe { /// `.config` a mirar. Por defecto `/proc/config.gz` y si no `/boot/config-`. #[arg(long)] config: Option, #[arg(long)] kconfig: Option, #[arg(long)] catalog: Option, /// No leer el hardware (útil para analizar el config de OTRA máquina). #[arg(long)] no_hardware: bool, #[arg(long)] json: bool, }, /// Lista el catálogo con sus clausuras ya resueltas contra un árbol de Kconfig concreto. /// /// Es también el control de frescura: `--check` sale distinto de cero si algún bundle nombra /// símbolos que ya no existen o dejó fugas `select` sin declarar. Bundles { #[arg(long)] kconfig: Option, #[arg(long)] catalog: Option, /// Falla si el catálogo envejeció respecto de este árbol. #[arg(long)] check: bool, #[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, }, /// **Gate de no-regresión, por objetivo**: ¿el plan deja sin driver algún dispositivo en uso? /// /// No corre sin `--objective`: la regla aplicada global rechazaría recetas sanas (el kernel de /// QEMU apaga USB, HID e INPUT a propósito). Sale distinto de cero si bloquea. Gate { /// El JSON que emitió `hammer kernel plan --out`. #[arg(long)] plan: PathBuf, /// Id de objetivo del catálogo (`qemu-serial`, `servidor`, `metal-escritorio`, `portatil`). #[arg(long)] objective: String, /// Hardware de la máquina DESTINO, capturado con `hammer kernel hw --out`. Sin esto se /// mira el de esta máquina, que no siempre es la misma. #[arg(long)] devices: Option, #[arg(long)] kconfig: Option, #[arg(long)] catalog: Option, #[arg(long)] json: bool, }, /// Vuelca el hardware de esta máquina (huella + dispositivos + drivers bindeados). /// /// Se corre en la máquina DESTINO y el JSON viaja a la de build: el gate necesita saber qué /// funciona allá, no acá. Hw { #[arg(long)] out: Option, }, /// Estadísticas del árbol de Kconfig parseado (control de salud del lector). Stats { #[arg(long)] kconfig: Option, /// Muestra las primeras N advertencias del parseo. #[arg(long, default_value_t = 10)] warnings: usize, }, } pub fn run(cmd: KernelCmd, store: &str) -> Result<()> { match cmd { KernelCmd::Stats { kconfig, warnings } => stats(&kconfig_root(kconfig)?, warnings), KernelCmd::Probe { config, kconfig, catalog, no_hardware, json, } => probe( config.as_deref(), &kconfig_root(kconfig)?, &catalog_path(catalog)?, !no_hardware, json, ), KernelCmd::Bundles { kconfig, catalog, 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::Gate { plan, objective, devices, kconfig, catalog, json, } => gate( &plan, &objective, devices.as_deref(), &kconfig_root(kconfig)?, &catalog_path(catalog)?, json, ), KernelCmd::Hw { out } => hw(out.as_deref()), KernelCmd::Closure { symbols, kconfig, against, fixpoint, json, limit, } => closure( &symbols, &kconfig_root(kconfig)?, against.as_deref(), fixpoint, json, limit, ), } } /// Resuelve la raíz del árbol de Kconfig: `--kconfig` gana, si no la variable de entorno. fn kconfig_root(flag: Option) -> Result { if let Some(p) = flag { return Ok(p); } match std::env::var_os(KCONFIG_ENV) { Some(v) => Ok(PathBuf::from(v)), None => anyhow::bail!( "falta el árbol de fuentes del kernel: pasá --kconfig o exportá {KCONFIG_ENV}" ), } } /// Resuelve el catálogo de bundles: `--catalog`, si no la variable de entorno, si no el del repo. fn catalog_path(flag: Option) -> Result { if let Some(p) = flag { return Ok(p); } if let Some(v) = std::env::var_os(CATALOG_ENV) { return Ok(PathBuf::from(v)); } for c in CATALOG_DEFAULTS { let p = PathBuf::from(c); if p.is_file() { return Ok(p); } } anyhow::bail!( "no encontré el catálogo de bundles: pasá --catalog o exportá {CATALOG_ENV}" ) } /// Dónde está el `.config` del kernel que corre. `/proc/config.gz` es el camino soberano (lo emite /// el propio kernel); el de `/boot` es el respaldo para kernels compilados sin `IKCONFIG_PROC`. fn running_config_path() -> Result { let gz = PathBuf::from("/proc/config.gz"); if gz.is_file() { return Ok(gz); } if let Ok(out) = std::process::Command::new("uname").arg("-r").output() { let rel = String::from_utf8_lossy(&out.stdout).trim().to_string(); let p = PathBuf::from(format!("/boot/config-{rel}")); if p.is_file() { return Ok(p); } } anyhow::bail!( "no encontré el config del kernel vivo (ni /proc/config.gz ni /boot/config-).\n\ Pasá --config para mirar el de otra máquina." ) } fn load_tree(root: &Path) -> Result { if !root.join("Kconfig").is_file() { anyhow::bail!( "{} no parece un árbol de fuentes de kernel (falta ./Kconfig).\n\ Pasá --kconfig o exportá {KCONFIG_ENV}.", root.display() ); } KconfigTree::parse(root, &KconfigTree::vars_x86_64()) .with_context(|| format!("parseando {}", root.display())) } fn stats(root: &Path, show_warnings: usize) -> Result<()> { let t = load_tree(root)?; let visible = t.symbols.values().filter(|s| s.is_visible()).count(); let multi = t.symbols.values().filter(|s| s.defs.len() > 1).count(); let with_help = t.symbols.values().filter(|s| s.help().is_some()).count(); let selects: usize = t.symbols.values().map(|s| s.select_targets().len()).sum(); let implies: usize = t.symbols.values().map(|s| s.imply_targets().len()).sum(); println!("árbol {}", root.display()); println!("ficheros {}", t.files.len()); println!("símbolos {}", t.symbols.len()); println!(" visibles {visible} (marcables en menuconfig)"); println!(" con ayuda {with_help}"); println!(" definidos en >1 sitio {multi}"); println!("aristas select {selects} · imply {implies}"); println!("avisos {}", t.warnings.len()); for w in t.warnings.iter().take(show_warnings) { println!(" · {w}"); } if t.warnings.len() > show_warnings { println!(" … y {} más", t.warnings.len() - show_warnings); } Ok(()) } #[derive(Serialize)] struct ClosureReport { roots: Vec, /// Todo lo que queda inalcanzable si se apagan las raíces (incluidas las raíces). closure: Vec, /// De la clausura, los que un humano puede marcar en `menuconfig`. visible: usize, /// `select` que entran desde fuera: reencienden algo de la clausura ignorando sus `depends on`. leaks: Vec, /// Símbolos distintos de la clausura alcanzados por esas fugas. leaked_targets: usize, #[serde(skip_serializing_if = "Option::is_none")] fixpoint: Option, #[serde(skip_serializing_if = "Option::is_none")] against: Option, } /// El precio de cerrar el bundle contra sus fugas. #[derive(Serialize)] struct FixpointReport { /// La clausura ya cerrada, entera. closure: Vec, /// Tamaño de la clausura una vez cerrada. closure_len: usize, /// Cuánto creció respecto de la clausura estricta. grew_by: usize, rounds: usize, /// Lo que hubo que apagar de más, con la ronda en que apareció. added: Vec<(usize, String)>, /// Fugas que sobrevivieron al tope de rondas. Vacío = cerró. remaining_leaks: Vec, } /// Contraste contra un `.config` real: la validación §3 del SDD 22. #[derive(Serialize)] struct AgainstReport { config: String, /// De la clausura, los que el `.config` tiene ENCENDIDOS (la clausura predice de más, o el /// config tiene una fuga real). on_in_config: Vec, /// De la clausura, los que el `.config` apaga explícitamente. off_in_config: usize, /// De la clausura, los que el `.config` ni menciona. absent_in_config: usize, } fn closure( roots: &[String], kroot: &Path, against: Option<&Path>, fixpoint: bool, json: bool, limit: usize, ) -> Result<()> { let t = load_tree(kroot)?; // Avisar de raíces que no existen: un símbolo mal escrito daría clausura vacía en silencio, // que es exactamente el modo de fallo que este comando existe para no tener. let desconocidas: Vec<&String> = roots.iter().filter(|r| !t.symbols.contains_key(*r)).collect(); if !desconocidas.is_empty() { eprintln!( "aviso: {} símbolo(s) no existen en este árbol: {}", desconocidas.len(), desconocidas .iter() .map(|s| s.as_str()) .collect::>() .join(", ") ); } let cl = t.closure_off(roots); let leaks = t.select_leaks(&cl); let leaked_targets = leaks .iter() .map(|l| l.target.as_str()) .collect::>() .len(); let visible = cl .iter() .filter(|s| t.symbols.get(*s).is_some_and(|s| s.is_visible())) .count(); let fx = fixpoint.then(|| { let f = t.closure_off_fixpoint(roots, 16); FixpointReport { closure_len: f.closure.len(), closure: f.closure.iter().cloned().collect(), grew_by: f.closure.len().saturating_sub(cl.len()), rounds: f.rounds, added: f.added, remaining_leaks: f.remaining_leaks, } }); let against_report = match against { None => None, Some(p) => { let text = read_maybe_gz(p)?; let cfg = KernelConfig::parse(&text); let on: Vec = cl.iter().filter(|s| cfg.is_on(s)).cloned().collect(); let off = cl .iter() .filter(|s| matches!(cfg.get(s), Some(hammer_core::kernel::ConfigValue::No))) .count(); let absent = cl.iter().filter(|s| cfg.get(s).is_none()).count(); Some(AgainstReport { config: p.display().to_string(), on_in_config: on, off_in_config: off, absent_in_config: absent, }) } }; let rep = ClosureReport { roots: roots.to_vec(), closure: cl.iter().cloned().collect(), visible, leaks, leaked_targets, fixpoint: fx, against: against_report, }; if json { println!("{}", serde_json::to_string_pretty(&rep)?); return Ok(()); } println!("raíces apagadas {}", rep.roots.join(" ")); println!( "clausura {} símbolo(s) ({} visibles en menuconfig)", rep.closure.len(), rep.visible ); for s in rep.closure.iter().take(limit) { let prompt = t .symbols .get(s) .and_then(|x| x.prompt()) .unwrap_or("(sin prompt: sólo entra por select)"); println!(" · {s} — {prompt}"); } if rep.closure.len() > limit { println!(" … y {} más", rep.closure.len() - limit); } println!(); if rep.leaks.is_empty() { println!("fugas por `select` ninguna — la clausura es cerrada"); } else { println!( "fugas por `select` {} arista(s) desde fuera reencienden {} símbolo(s) de la clausura", rep.leaks.len(), rep.leaked_targets ); println!(" (`select` FUERZA el destino ignorando sus `depends on`: apagar la raíz no basta)"); let mut por_destino: BTreeMap<&str, Vec<&str>> = BTreeMap::new(); for l in &rep.leaks { por_destino .entry(&l.target) .or_default() .push(&l.selector); } for (target, selectors) in por_destino.iter().take(limit) { println!(" · {target} ← {}", selectors.join(", ")); } if por_destino.len() > limit { println!(" … y {} destino(s) más", por_destino.len() - limit); } } if let Some(f) = &rep.fixpoint { println!(); println!( "punto fijo {} símbolo(s) tras {} ronda(s) — {:+} sobre la clausura estricta", f.closure_len, f.rounds, f.grew_by as i64 ); if f.remaining_leaks.is_empty() { println!(" cerró: ninguna fuga sobrevive"); } else { println!( " ⚠ NO cerró: {} fuga(s) siguen abiertas tras el tope de rondas", f.remaining_leaks.len() ); } if f.added.is_empty() { println!(" no hizo falta apagar nada de más"); } else { println!(" precio: {} símbolo(s) apagados de más", f.added.len()); for (r, s) in f.added.iter().take(limit) { println!(" · ronda {r}: {s}"); } if f.added.len() > limit { println!(" … y {} más", f.added.len() - limit); } } } if let Some(a) = &rep.against { println!(); println!("contraste contra {}", a.config); println!(" apagados explícitos {}", a.off_in_config); println!(" ausentes {}", a.absent_in_config); println!(" ENCENDIDOS {}", a.on_in_config.len()); for s in a.on_in_config.iter().take(limit) { println!(" · {s}"); } if a.on_in_config.len() > limit { println!(" … y {} más", a.on_in_config.len() - limit); } } Ok(()) } // --------------------------------------------------------------------------------------------- // probe — el modo reversa (#9) // --------------------------------------------------------------------------------------------- fn probe( config: Option<&Path>, kroot: &Path, catalog: &Path, with_hw: bool, json: bool, ) -> Result<()> { use hammer_core::kernel::reverse::{BundleState, HwSignal}; let cfg_path = match config { Some(p) => p.to_path_buf(), None => running_config_path()?, }; let cfg = KernelConfig::parse(&read_maybe_gz(&cfg_path)?); if cfg.is_empty() { anyhow::bail!("{} no tiene un solo CONFIG_*: ¿es un .config?", cfg_path.display()); } let tree = load_tree(kroot)?; let cat = hammer_core::kernel::Catalog::load(catalog)?; let hw = with_hw.then(hammer_core::kernel::Hardware::probe_local); let rep = hammer_core::kernel::reverse::analyze(&cat, &tree, &cfg, hw.as_ref()); if json { println!("{}", serde_json::to_string_pretty(&rep)?); return Ok(()); } println!("config {}", cfg_path.display()); if let Some(k) = &rep.kernel { println!("kernel {k}"); } // El modo reversa cruza DOS árboles: el del config vivo y el que se parseó. Si no son la misma // versión, las clausuras son aproximadas — un símbolo puede haberse renombrado o retirado entre // medias (que es justo lo que le pasó a THUNDERBOLT y a REISERFS_FS). Vale igual, pero decirlo. if let (Some(banner), Some(rev)) = (&rep.kernel, &cat.reviewed_against) { if let (Some(a), Some(b)) = (serie(banner), serie(rev)) { if a != b { println!( " ⚠ el config vivo es de la serie {a} y el catálogo se revisó contra \ la {b}: las clausuras son aproximadas" ); } } } println!("símbolos {} declarados", rep.config_symbols); if let Some(h) = &hw { println!("huella {}", rep.fingerprint); if let Some(m) = &h.cpu_model { println!(" {m}"); } let maquina = [h.dmi.get("sys_vendor"), h.dmi.get("product_name")] .into_iter() .flatten() .cloned() .collect::>() .join(" "); if !maquina.is_empty() { println!(" {maquina}"); } println!( " {} dispositivos PCI · {} drivers bindeados", h.pci.len(), h.bound_drivers.len() ); for g in &h.gaps { println!(" ⚠ {g}"); } } println!(); for b in &rep.bundles { let on = b.on_builtin + b.on_module; let estado = match b.state { BundleState::Aplicado => "APLICADO ", BundleState::Parcial => "parcial ", BundleState::Sin => "sin ", }; let hwtxt = match &b.hardware { HwSignal::SinSeñal => String::new(), HwSignal::Ausente => " · hardware ausente".to_string(), HwSignal::Presente { evidencia } => { format!(" · ⚠ HARDWARE PRESENTE ({})", evidencia.len()) } }; println!( "{estado} {:<28} {on:>5}/{:<5} encendidos{hwtxt}", b.id, b.closure_len ); if let HwSignal::Presente { evidencia } = &b.hardware { for e in evidencia.iter().take(4) { println!(" {e}"); } } if !b.unknown_symbols.is_empty() { println!( " ⚠ símbolos que este kernel no tiene: {}", b.unknown_symbols.join(", ") ); } if !b.undeclared_leaks.is_empty() { println!( " ⚠ {} fuga(s) `select` sin declarar en el catálogo", b.undeclared_leaks.len() ); } } println!(); println!( "resumen {} bundle(s): {} ya aplicados, {} con capacidad que este hardware NO usa", rep.bundles.len(), rep.bundles .iter() .filter(|b| b.state == BundleState::Aplicado) .count(), rep.applicable_savings ); if rep.contradictions > 0 { println!( " ⚠ {} bundle(s) aplicados CONTRADICEN al hardware presente", rep.contradictions ); } println!(" (detectar sirve para contradecir, nunca para podar solo)"); Ok(()) } /// Serie `mayor.menor` de un texto que contenga una versión (`"Linux/x86 7.1.4-artix1 …"` → `7.1`, /// `"linux-6.16.12"` → `6.16`). `None` si no hay ninguna. fn serie(texto: &str) -> Option { for tok in texto.split(|c: char| !(c.is_ascii_digit() || c == '.')) { let mut it = tok.split('.'); if let (Some(a), Some(b)) = (it.next(), it.next()) { if !a.is_empty() && !b.is_empty() { return Some(format!("{a}.{b}")); } } } None } // --------------------------------------------------------------------------------------------- // bundles — catálogo + control de frescura // --------------------------------------------------------------------------------------------- #[derive(Serialize)] struct BundleView { id: String, title: String, #[serde(skip_serializing_if = "Option::is_none")] help: Option, disable: Vec, close_leaks: Vec, closure_len: usize, visible: usize, unknown_symbols: Vec, undeclared_leaks: Vec, accepted_leaks: usize, } #[derive(Serialize)] struct BundlesReport { catalog: String, #[serde(skip_serializing_if = "Option::is_none")] reviewed_against: Option, bundles: Vec, knobs: Vec, stale: bool, } #[derive(Serialize)] struct KnobView { id: String, title: String, side: hammer_core::kernel::Side, enable: Vec, disable: Vec, #[serde(skip_serializing_if = "Option::is_none")] recipe_field: Option, unknown_symbols: Vec, } fn bundles(kroot: &Path, catalog: &Path, check: bool, json: bool) -> Result<()> { let tree = load_tree(kroot)?; let cat = hammer_core::kernel::Catalog::load(catalog)?; let mut views = Vec::new(); for b in &cat.bundles { let mut roots = b.disable.clone(); roots.extend(b.close_leaks.iter().cloned()); let unknown: Vec = roots .iter() .chain(b.enable.iter()) .filter(|s| !tree.symbols.contains_key(*s)) .cloned() .collect(); let cl = tree.closure_off(&roots); let aceptadas: BTreeSet<&str> = b.accept_leaks.keys().map(|s| s.as_str()).collect(); let undeclared: Vec<_> = tree .select_leaks(&cl) .into_iter() .filter(|l| !aceptadas.contains(l.target.as_str())) .collect(); views.push(BundleView { id: b.id.clone(), title: b.title.clone(), help: b.help.clone(), disable: b.disable.clone(), close_leaks: b.close_leaks.clone(), closure_len: cl.len(), visible: cl .iter() .filter(|s| tree.symbols.get(*s).is_some_and(|x| x.is_visible())) .count(), unknown_symbols: unknown, undeclared_leaks: undeclared, accepted_leaks: b.accept_leaks.len(), }); } let knobs: Vec = cat .knobs .iter() .map(|k| KnobView { id: k.id.clone(), title: k.title.clone(), side: k.side, enable: k.enable.clone(), disable: k.disable.clone(), recipe_field: k.recipe_field.clone(), unknown_symbols: k .enable .iter() .chain(k.disable.iter()) .filter(|s| !tree.symbols.contains_key(*s)) .cloned() .collect(), }) .collect(); let stale = views .iter() .any(|v| !v.unknown_symbols.is_empty() || !v.undeclared_leaks.is_empty()) || knobs.iter().any(|k| !k.unknown_symbols.is_empty()); let rep = BundlesReport { catalog: catalog.display().to_string(), reviewed_against: cat.reviewed_against.clone(), bundles: views, knobs, stale, }; if json { println!("{}", serde_json::to_string_pretty(&rep)?); } else { println!("catálogo {}", rep.catalog); if let Some(r) = &rep.reviewed_against { println!("revisado contra {r}"); } println!("árbol {}", kroot.display()); println!(); println!("NIVEL 1 — aserciones del dueño de la máquina"); for v in &rep.bundles { println!( " {:<26} {:>5} símbolos ({} visibles) ← {}", v.id, v.closure_len, v.visible, v.disable.join(" ") ); println!(" {}", v.title); if !v.close_leaks.is_empty() { println!(" fugas cerradas: {}", v.close_leaks.join(", ")); } if v.accepted_leaks > 0 { println!(" fugas aceptadas a sabiendas: {}", v.accepted_leaks); } if !v.unknown_symbols.is_empty() { println!( " ⚠ NO EXISTEN en este árbol: {}", v.unknown_symbols.join(", ") ); } for l in v.undeclared_leaks.iter().take(6) { println!(" ⚠ fuga sin declarar: {} ← {}", l.target, l.selector); } if v.undeclared_leaks.len() > 6 { println!(" ⚠ … y {} fuga(s) más", v.undeclared_leaks.len() - 6); } } println!(); println!("NIVEL 2 — política y rendimiento (ojo al lado del que cae cada perilla)"); for k in &rep.knobs { let lado = match k.side { hammer_core::kernel::Side::Kconfig => "kconfig", hammer_core::kernel::Side::Recipe => "receta ", }; let que = match k.side { hammer_core::kernel::Side::Kconfig => { let mut v: Vec = k.enable.iter().map(|s| format!("+{s}")).collect(); v.extend(k.disable.iter().map(|s| format!("-{s}"))); v.join(" ") } hammer_core::kernel::Side::Recipe => { k.recipe_field.clone().unwrap_or_default() } }; println!(" [{lado}] {:<26} {que}", k.id); println!(" {}", k.title); if !k.unknown_symbols.is_empty() { println!( " ⚠ NO EXISTEN en este árbol: {}", k.unknown_symbols.join(", ") ); } } println!(); if rep.stale { println!("⚠ el catálogo ENVEJECIÓ respecto de este árbol (símbolos idos o fugas nuevas)"); } else { println!("✓ el catálogo está al día con este árbol"); } } if check && rep.stale { anyhow::bail!("catálogo desactualizado"); } 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)?; // Las recetas de kernel del repo NO son todas la misma versión (`linux`/`linux-metal` van por // 6.16.12; `linux-metal-dual`/`linux-generic` por 7.1.2). Planear una contra el árbol de la otra // calcularía clausuras sobre símbolos que ahí no existen, y saldría SIN RUIDO. Como el plan // determina un artefacto, acá se falla en vez de avisar. match tree.version.as_deref() { Some(v) if v != base.version => anyhow::bail!( "la receta «{}» es del kernel {} y el árbol de {} es el {v}.\n\ Las clausuras saldrían calculadas sobre otro juego de símbolos. Pasá el árbol correcto \ con --kconfig.", base.name, base.version, kroot.display() ), None => eprintln!( "aviso: {} no trae el Makefile de arriba, así que no puedo comprobar que sea el kernel \ {} de la receta «{}»", kroot.display(), base.version, base.name ), Some(_) => {} } 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()); // Kconfig no emite un símbolo cuyas dependencias no se cumplen: para un `-d`, que no // aparezca ES el resultado buscado. Contarlo como fallo reportaba planes perfectos como // rotos — lo aprendí corriendo un olddefconfig de verdad. println!("apagados por dependencia {} (no aparecen porque cayeron con su raíz)", d.off_by_dependency.len()); if !d.never_existed.is_empty() { println!("no existen en este árbol {}", d.never_existed.len()); for s in &d.never_existed { println!(" {s} — la bandera no hace nada"); } } 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(()) } // --------------------------------------------------------------------------------------------- // gate / hw // --------------------------------------------------------------------------------------------- fn hw(out: Option<&Path>) -> Result<()> { let h = hammer_core::kernel::Hardware::probe_local(); let texto = serde_json::to_string_pretty(&h)?; match out { Some(o) => { if let Some(d) = o.parent() { std::fs::create_dir_all(d).ok(); } std::fs::write(o, &texto).with_context(|| format!("escribiendo {}", o.display()))?; println!("huella {}", h.fingerprint()); println!("hardware → {}", o.display()); println!(" {} PCI · {} USB · {} drivers bindeados", h.pci.len(), h.usb.len(), h.bound_drivers.len()); for g in &h.gaps { println!(" ⚠ {g}"); } } None => println!("{texto}"), } Ok(()) } fn gate( plan_path: &Path, objective: &str, devices: Option<&Path>, kroot: &Path, catalog: &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 cat = hammer_core::kernel::Catalog::load(catalog)?; let tree = load_tree(kroot)?; let modmap = hammer_core::kernel::ModMap::parse(kroot) .with_context(|| format!("leyendo los Makefiles de {}", kroot.display()))?; if modmap.is_empty() { anyhow::bail!( "no encontré una sola regla `obj-$(CONFIG_…)` en {}: sin los Makefiles del kernel el \ gate no puede mapear driver → símbolo, y un gate que no puede mirar no es un gate.", kroot.display() ); } let hardware = match devices { Some(d) => serde_json::from_str::( &std::fs::read_to_string(d).with_context(|| format!("leyendo {}", d.display()))?, ) .with_context(|| format!("{} no es un volcado de `hammer kernel hw`", d.display()))?, None => hammer_core::kernel::Hardware::probe_local(), }; let r = hammer_core::kernel::gate::run( &p, &cat, &tree, &modmap, &hardware.bound_drivers, objective, )?; if json { println!("{}", serde_json::to_string_pretty(&r)?); } else { let obj = cat.objective(objective).map(|o| o.title.as_str()).unwrap_or(""); println!("plan {}", r.plan); println!("objetivo {} — {obj}", r.objective); println!( "hardware {} ({} drivers bindeados, {} reglas obj- en {} Makefiles)", devices.map(|d| d.display().to_string()).unwrap_or_else(|| "esta máquina".into()), r.drivers_in_use, modmap.len(), modmap.files ); println!(); if r.regressions.is_empty() { println!("✓ ningún dispositivo en uso se queda sin driver"); } else { println!("✗ {} REGRESIÓN(ES) — dispositivos en uso que se quedarían sin driver:", r.regressions.len()); for l in &r.regressions { println!( " {:<20} lo apaga {} (símbolos: {})", l.driver, if l.blamed_on.is_empty() { "una perilla".to_string() } else { l.blamed_on.join(", ") }, l.symbols.join(", ") ); } } if !r.allowed.is_empty() { println!(); println!("{} pérdida(s) AUTORIZADAS por el objetivo:", r.allowed.len()); for l in &r.allowed { println!(" {:<20} ← {}", l.driver, l.blamed_on.join(", ")); } } if !r.unmapped.is_empty() { println!(); println!( "⚠ {} driver(s) SIN COMPROBAR (no se pudieron mapear a ningún CONFIG_*):", r.unmapped.len() ); println!(" {}", r.unmapped.join(" ")); println!(" no cuentan como aprobados: el gate no los miró."); } } if r.verdict == hammer_core::kernel::Verdict::Bloquea { anyhow::bail!("el gate bloquea"); } 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 /// diagnóstico, no del camino de build, y el core queda sin dependencias nuevas. pub fn read_maybe_gz(p: &Path) -> Result { if p.extension().is_some_and(|e| e == "gz") { let out = std::process::Command::new("gzip") .arg("-dc") .arg(p) .output() .with_context(|| format!("descomprimiendo {}", p.display()))?; if !out.status.success() { anyhow::bail!( "gzip -dc {} falló: {}", p.display(), String::from_utf8_lossy(&out.stderr).trim() ); } return Ok(String::from_utf8_lossy(&out.stdout).into_owned()); } std::fs::read_to_string(p).with_context(|| format!("leyendo {}", p.display())) } #[cfg(test)] mod tests { use super::*; #[test] fn serie_de_las_dos_formas_que_se_cruzan() { assert_eq!(serie("Linux/x86 7.1.4-artix1 Kernel Configuration").as_deref(), Some("7.1")); assert_eq!(serie("linux-6.16.12").as_deref(), Some("6.16")); assert_eq!(serie("sin versión"), None); } }