From 040c768def885c03d6159cbd79a7508f3db2f280 Mon Sep 17 00:00:00 2001 From: Sergio Date: Mon, 10 Aug 2026 13:04:04 +0000 Subject: [PATCH] =?UTF-8?q?kernel:=20el=20lector=20de=20Kconfig=20y=20la?= =?UTF-8?q?=20validaci=C3=B3n=20del=20=C2=A73=20=E2=80=94=20la=20clausura?= =?UTF-8?q?=20reproduce=20los=20bundles=20a=20mano?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Primer paso del SDD 22 (armador de kernel), en el orden que fija su §8. Regla dura respetada literalmente: hammer LEE el grafo de Kconfig, no lo resuelve — el .config lo sigue produciendo el olddefconfig del propio kernel. hammer-core/src/kernel/: lector tolerante (18.212 símbolos, 1646 ficheros, 0 avisos de parseo sobre 6.16.12) + lector de .config. hammer kernel {stats,closure}. La semántica de arista, que el §3 pedía definir antes de escribir el predicado: · dependencia dura = símbolo en posición CONJUNTIVA (en "A && (B|C)" sólo A). La disyunción, la negación y las comparaciones no aportan. Conservador a propósito: apagar de menos se nota, apagar de más hace un ladrillo. · símbolo con varias definiciones ⇒ INTERSECCIÓN entre ellas, no unión. · select es el portillo, no una arista más: fuerza el destino IGNORANDO sus depends. select_leaks las enumera; closure_off_fixpoint cierra el bundle contra ellas y REPORTA el precio en vez de aplicarlo solo. La medición que decide §2.1, contra el bundle N1 hecho a mano de recipes/linux.toml: clausura estricta de WIRELESS ......................... 350 punto fijo (3 fugas: WLAN, IWLEGACY, GELIC_WIRELESS) .. 406, cierra en 1 ronda bundle a mano ......................................... 421 SOBRA 0 · falta 15 Los 15 son todos RFKILL, que no es wifi sino el interruptor de radio compartido con bluetooth y NFC. El humano apagó DOS bundles en la misma línea ⇒ el catálogo necesita "sin radios" como entrada propia. §2.1 es viable. Y el punto fijo también dice cuándo no: cerrar "sin audio" exige tragarse DRM_I915/ NOUVEAU/AMD_DC, que hacen select del códec HDMI. En linux.toml sale gratis porque los gráficos ya están apagados; en un escritorio sería una decisión. De regalo: linux.toml apaga REISERFS_FS, que 6.16.12 ya no tiene. Un -d a un símbolo inexistente se pierde HOY en silencio — justo lo que el diff-back (paso 4) va a atrapar. Co-Authored-By: Claude Opus 5 (1M context) --- crates/hammer-cli/src/kernel_cmd.rs | 364 +++++++ crates/hammer-cli/src/main.rs | 9 + crates/hammer-core/src/kernel/config.rs | 145 +++ crates/hammer-core/src/kernel/kconfig.rs | 1195 ++++++++++++++++++++++ crates/hammer-core/src/kernel/mod.rs | 23 + crates/hammer-core/src/lib.rs | 1 + docs/22-configurador-kernel.md | 88 ++ 7 files changed, 1825 insertions(+) create mode 100644 crates/hammer-cli/src/kernel_cmd.rs create mode 100644 crates/hammer-core/src/kernel/config.rs create mode 100644 crates/hammer-core/src/kernel/kconfig.rs create mode 100644 crates/hammer-core/src/kernel/mod.rs diff --git a/crates/hammer-cli/src/kernel_cmd.rs b/crates/hammer-cli/src/kernel_cmd.rs new file mode 100644 index 00000000..054a652d --- /dev/null +++ b/crates/hammer-cli/src/kernel_cmd.rs @@ -0,0 +1,364 @@ +//! `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"; + +#[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, + }, + /// 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) -> Result<()> { + match cmd { + KernelCmd::Stats { kconfig, warnings } => stats(&kconfig_root(kconfig)?, warnings), + 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}" + ), + } +} + +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(()) +} + +/// 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())) +} diff --git a/crates/hammer-cli/src/main.rs b/crates/hammer-cli/src/main.rs index 63aaeaf7..91426266 100644 --- a/crates/hammer-cli/src/main.rs +++ b/crates/hammer-cli/src/main.rs @@ -9,6 +9,7 @@ use std::path::PathBuf; use clap::{Parser, Subcommand}; mod alpine_import; +mod kernel_cmd; mod nix_import; const DEFAULT_STORE: &str = "/store"; @@ -446,6 +447,13 @@ enum Cmd { #[command(subcommand)] sub: BootCmd, }, + /// [SDD 22] El armador de kernel: analiza el grafo de Kconfig, lee el kernel que ya corre y + /// emite recetas derivadas. **Nunca escribe un `.config`**: eso lo hace el `olddefconfig` del + /// propio kernel. + Kernel { + #[command(subcommand)] + sub: kernel_cmd::KernelCmd, + }, } #[derive(Subcommand)] @@ -1414,6 +1422,7 @@ fn main() -> anyhow::Result<()> { } } } + Cmd::Kernel { sub } => kernel_cmd::run(sub)?, } Ok(()) } diff --git a/crates/hammer-core/src/kernel/config.rs b/crates/hammer-core/src/kernel/config.rs new file mode 100644 index 00000000..2788b530 --- /dev/null +++ b/crates/hammer-core/src/kernel/config.rs @@ -0,0 +1,145 @@ +//! Lectura de un `.config` de kernel (o del `/proc/config.gz` ya descomprimido). +//! +//! Es el sustrato del **modo reversa** (#9 del handoff): antes de compilar nada, leer el kernel que +//! ya corre y mirarlo a través del lente de bundles. Cero riesgo, cero build. + +use std::collections::BTreeMap; + +use serde::{Deserialize, Serialize}; + +/// Valor de un símbolo en un `.config` materializado. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "lowercase")] +pub enum ConfigValue { + /// `=y` + Yes, + /// `=m` + Module, + /// `# CONFIG_X is not set` — presencia explícita del apagado, que **no** es lo mismo que + /// ausencia: un símbolo ausente puede no existir en esta versión del kernel. + No, + /// `="texto"` + Str(String), + /// `=42`, `=0x10` + Num(String), +} + +impl ConfigValue { + /// ¿Está presente en el kernel, como built-in o como módulo? + pub fn is_on(&self) -> bool { + matches!(self, ConfigValue::Yes | ConfigValue::Module) + } +} + +/// Un `.config` leído. Las claves van **sin** el prefijo `CONFIG_`, igual que en los ficheros +/// Kconfig, para que un símbolo se llame igual en los dos lados del análisis. +#[derive(Debug, Clone, Default, Serialize, Deserialize)] +pub struct KernelConfig { + pub values: BTreeMap, + /// La línea `# Linux/x86 6.16.12 Kernel Configuration`, si está. + pub banner: Option, +} + +impl KernelConfig { + pub fn parse(text: &str) -> KernelConfig { + let mut cfg = KernelConfig::default(); + for line in text.lines() { + let t = line.trim(); + if t.is_empty() { + continue; + } + if let Some(rest) = t.strip_prefix("# CONFIG_") { + if let Some(name) = rest.strip_suffix(" is not set") { + cfg.values.insert(name.to_string(), ConfigValue::No); + } + continue; + } + if let Some(rest) = t.strip_prefix('#') { + let rest = rest.trim(); + if cfg.banner.is_none() && rest.contains("Kernel Configuration") { + cfg.banner = Some(rest.to_string()); + } + continue; + } + let Some(rest) = t.strip_prefix("CONFIG_") else { + continue; + }; + let Some((name, val)) = rest.split_once('=') else { + continue; + }; + let v = match val { + "y" => ConfigValue::Yes, + "m" => ConfigValue::Module, + "n" => ConfigValue::No, + _ if val.starts_with('"') => { + ConfigValue::Str(val.trim_matches('"').to_string()) + } + _ => ConfigValue::Num(val.to_string()), + }; + cfg.values.insert(name.to_string(), v); + } + cfg + } + + pub fn get(&self, name: &str) -> Option<&ConfigValue> { + self.values.get(name) + } + + /// `true` sólo si el símbolo está `=y` o `=m`. Ausente cuenta como apagado. + pub fn is_on(&self, name: &str) -> bool { + self.values.get(name).is_some_and(|v| v.is_on()) + } + + /// Los símbolos encendidos (`=y` o `=m`), en orden. + pub fn enabled(&self) -> impl Iterator { + self.values + .iter() + .filter(|(_, v)| v.is_on()) + .map(|(k, _)| k.as_str()) + } + + pub fn len(&self) -> usize { + self.values.len() + } + + pub fn is_empty(&self) -> bool { + self.values.is_empty() + } +} + +#[cfg(test)] +mod tests { + use super::*; + + const SAMPLE: &str = r#" +# +# Automatically generated file; DO NOT EDIT. +# Linux/x86 6.16.12 Kernel Configuration +# +CONFIG_CC_VERSION_TEXT="gcc (GCC) 15.1.1" +CONFIG_64BIT=y +CONFIG_WLAN=m +# CONFIG_WIRELESS is not set +CONFIG_NR_CPUS=64 +"#; + + #[test] + fn parsea_las_cuatro_formas() { + let c = KernelConfig::parse(SAMPLE); + assert_eq!(c.get("64BIT"), Some(&ConfigValue::Yes)); + assert_eq!(c.get("WLAN"), Some(&ConfigValue::Module)); + assert_eq!(c.get("WIRELESS"), Some(&ConfigValue::No)); + assert_eq!(c.get("NR_CPUS"), Some(&ConfigValue::Num("64".into()))); + assert!(matches!(c.get("CC_VERSION_TEXT"), Some(ConfigValue::Str(_)))); + assert_eq!(c.banner.as_deref(), Some("Linux/x86 6.16.12 Kernel Configuration")); + } + + #[test] + fn modulo_cuenta_como_encendido() { + let c = KernelConfig::parse(SAMPLE); + assert!(c.is_on("WLAN")); + assert!(!c.is_on("WIRELESS")); + // Ausente ≠ apagado explícito, pero para `is_on` los dos son «no está». + assert!(!c.is_on("NO_EXISTE")); + } +} diff --git a/crates/hammer-core/src/kernel/kconfig.rs b/crates/hammer-core/src/kernel/kconfig.rs new file mode 100644 index 00000000..2c791db1 --- /dev/null +++ b/crates/hammer-core/src/kernel/kconfig.rs @@ -0,0 +1,1195 @@ +//! Lector del grafo de Kconfig — **sólo para analizar, nunca para resolver**. +//! +//! Regla dura heredada del handoff (§6) y ratificada en `docs/22-configurador-kernel.md`: hammer +//! **no** implementa un solver de Kconfig. El `.config` lo sigue produciendo el `olddefconfig` del +//! propio kernel. Lo que este módulo hace es *leer* los ~15 000 símbolos para poder contestar +//! preguntas sobre el grafo — «¿qué muere si apago `WIRELESS`?», «¿quién lo enciende por la +//! espalda?»— que es lo que necesita el catálogo de bundles por clausura (§2.1 del handoff). +//! +//! ## Por qué el parser es tolerante y no estricto +//! Un parser estricto de Kconfig tendría que evaluar `$(shell,…)`, `$(cc-option,…)` y la maquinaria +//! de macros de `scripts/Kconfig.include`, que depende del compilador presente. Nada de eso cambia +//! la **topología** del grafo, que es lo único que se consulta acá. Los macros se conservan como +//! constantes opacas y las construcciones desconocidas se acumulan en [`KconfigTree::warnings`] +//! en vez de abortar: una advertencia contada es medible, un `panic` a mitad de árbol no. +//! +//! ## La sutileza que hace honesto el análisis +//! Un símbolo puede definirse en **varios ficheros**, cada definición con sus propios `depends on`. +//! Un símbolo sólo se vuelve inalcanzable si **todas** sus definiciones lo son ⇒ las dependencias +//! duras son la **intersección** entre definiciones, no la unión. Ver [`Symbol::hard_deps`]. + +use std::collections::{BTreeMap, BTreeSet, HashSet}; +use std::path::{Path, PathBuf}; + +use serde::{Deserialize, Serialize}; + +/// Tipo declarado del símbolo. `Unknown` es legítimo: hay símbolos que sólo se declaran por +/// `default`/`select` desde otro fichero. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "lowercase")] +pub enum SymKind { + Bool, + Tristate, + Int, + Hex, + Str, + Unknown, +} + +impl SymKind { + fn from_keyword(kw: &str) -> Option { + Some(match kw { + "bool" | "def_bool" => SymKind::Bool, + "tristate" | "def_tristate" => SymKind::Tristate, + "int" => SymKind::Int, + "hex" => SymKind::Hex, + "string" => SymKind::Str, + _ => return None, + }) + } +} + +/// Dónde se declaró una definición. Es la materia prima de la procedencia por símbolo (#7 del +/// handoff): poder contestar «¿por qué mi kernel tiene `CONFIG_FOO`?» con un fichero y una línea. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct Location { + /// Ruta relativa a la raíz del árbol de fuentes. + pub file: String, + pub line: usize, +} + +impl std::fmt::Display for Location { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + write!(f, "{}:{}", self.file, self.line) + } +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum CmpOp { + Eq, + Ne, + Lt, + Le, + Gt, + Ge, +} + +/// Expresión de Kconfig. Se conserva el árbol completo (no sólo la lista de símbolos) porque la +/// **posición** de un símbolo decide si apagarlo mata la expresión: en `A && (B || C)` sólo `A` es +/// dura. Aplanar a «símbolos referenciados» perdería justo ese dato. +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum Expr { + /// Referencia a otro símbolo (sin el prefijo `CONFIG_`, como se escribe en los Kconfig). + Sym(String), + /// `y`, `n`, `m`, números, cadenas entrecomilladas y macros `$(...)` sin evaluar. + Const(String), + Not(Box), + And(Box, Box), + Or(Box, Box), + Cmp(Box, CmpOp, Box), +} + +impl Expr { + /// Todos los símbolos que aparecen, sin importar la posición. + pub fn symbols(&self, out: &mut BTreeSet) { + match self { + Expr::Sym(s) => { + out.insert(s.clone()); + } + Expr::Const(_) => {} + Expr::Not(e) => e.symbols(out), + Expr::And(a, b) | Expr::Or(a, b) => { + a.symbols(out); + b.symbols(out); + } + Expr::Cmp(a, _, b) => { + a.symbols(out); + b.symbols(out); + } + } + } + + /// Símbolos en posición **conjuntiva**: si cualquiera de ellos vale `n`, la expresión entera + /// vale `n`. Es la única lectura de «X no puede existir sin Y» que no miente. + /// + /// Deliberadamente conservador: `!X`, las ramas de un `||` y los dos lados de una comparación + /// **no** aportan nada. Preferimos sub-reportar (un bundle que apaga de menos, y se nota) a + /// sobre-reportar (un bundle que apaga de más, y hace un ladrillo). + pub fn hard_symbols(&self, out: &mut BTreeSet) { + match self { + Expr::Sym(s) => { + out.insert(s.clone()); + } + Expr::And(a, b) => { + a.hard_symbols(out); + b.hard_symbols(out); + } + Expr::Const(_) | Expr::Not(_) | Expr::Or(_, _) | Expr::Cmp(_, _, _) => {} + } + } +} + +/// Arista `select`/`imply`: destino + condición opcional (`select FOO if BAR`). +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct Edge { + pub target: String, + pub cond: Option, +} + +/// Una definición concreta de un símbolo, en un fichero concreto. +#[derive(Debug, Clone)] +pub struct SymbolDef { + pub kind: SymKind, + /// Texto del prompt si la definición es visible al usuario. `None` = símbolo interno, + /// seleccionable sólo por otros (no aparece en `menuconfig`). + pub prompt: Option, + /// Conjunción de `depends on` propios **más** los heredados de `menu`/`if`/`choice`. + pub depends: Vec, + pub selects: Vec, + pub implies: Vec, + pub help: Option, + pub loc: Location, +} + +/// Un símbolo del árbol, con todas sus definiciones. +#[derive(Debug, Clone)] +pub struct Symbol { + pub name: String, + pub defs: Vec, +} + +impl Symbol { + /// Primer tipo declarado que no sea `Unknown`. + pub fn kind(&self) -> SymKind { + self.defs + .iter() + .map(|d| d.kind) + .find(|k| *k != SymKind::Unknown) + .unwrap_or(SymKind::Unknown) + } + + /// ¿Lo puede marcar un humano en `menuconfig`, o sólo entra por `select`? + pub fn is_visible(&self) -> bool { + self.defs.iter().any(|d| d.prompt.is_some()) + } + + pub fn prompt(&self) -> Option<&str> { + self.defs.iter().find_map(|d| d.prompt.as_deref()) + } + + pub fn help(&self) -> Option<&str> { + self.defs.iter().find_map(|d| d.help.as_deref()) + } + + pub fn locations(&self) -> Vec<&Location> { + self.defs.iter().map(|d| &d.loc).collect() + } + + /// Dependencias duras del símbolo: **intersección** entre definiciones. + /// + /// Un símbolo definido en dos ficheros sobrevive si a alguna de sus definiciones le quedan las + /// dependencias satisfechas. Tomar la unión (el error natural) diría que apagar `Y` mata a `X` + /// cuando `X` tiene otra definición que ni menciona a `Y`. + pub fn hard_deps(&self) -> BTreeSet { + let mut iter = self.defs.iter().map(|d| { + let mut s = BTreeSet::new(); + for e in &d.depends { + e.hard_symbols(&mut s); + } + s + }); + let Some(first) = iter.next() else { + return BTreeSet::new(); + }; + iter.fold(first, |acc, s| acc.intersection(&s).cloned().collect()) + } + + /// Todos los símbolos mencionados en cualquier `depends on` de cualquier definición. + pub fn all_dep_symbols(&self) -> BTreeSet { + let mut s = BTreeSet::new(); + for d in &self.defs { + for e in &d.depends { + e.symbols(&mut s); + } + } + s + } + + pub fn select_targets(&self) -> BTreeSet { + self.defs + .iter() + .flat_map(|d| d.selects.iter()) + .map(|e| e.target.clone()) + .collect() + } + + pub fn imply_targets(&self) -> BTreeSet { + self.defs + .iter() + .flat_map(|d| d.implies.iter()) + .map(|e| e.target.clone()) + .collect() + } +} + +/// El árbol de Kconfig parseado, con los índices inversos ya construidos. +#[derive(Debug, Default)] +pub struct KconfigTree { + pub root: PathBuf, + pub symbols: BTreeMap, + /// Ficheros efectivamente leídos (relativos a la raíz). + pub files: Vec, + /// Todo lo que no se pudo interpretar, contado en vez de abortado. + pub warnings: Vec, + /// `Y → {X : X depende DURO de Y}`. Apagar `Y` deja a cada `X` inalcanzable. + rev_hard_deps: BTreeMap>, + /// `Y → {X : X hace `select Y`}`. Encender cualquier `X` fuerza `Y` **ignorando** sus + /// `depends on` — la semántica que rompe la clausura ingenua (§3 del SDD 22). + rev_selects: BTreeMap>, +} + +impl KconfigTree { + /// Parsea el árbol entero desde `/Kconfig`, resolviendo los `source` recursivamente. + /// + /// `vars` alimenta la sustitución de `$(SRCARCH)` y compañía en las rutas de `source`. Para + /// x86_64: `SRCARCH=x86`, `ARCH=x86_64`. + pub fn parse(root: &Path, vars: &BTreeMap) -> std::io::Result { + let mut tree = KconfigTree { + root: root.to_path_buf(), + ..Default::default() + }; + let mut visited: HashSet = HashSet::new(); + let mut parser = Parser { + tree: &mut tree, + vars, + visited: &mut visited, + }; + parser.parse_file("Kconfig", &[])?; + tree.build_indices(); + Ok(tree) + } + + /// Variables por defecto para un análisis de x86_64. + pub fn vars_x86_64() -> BTreeMap { + BTreeMap::from([ + ("SRCARCH".into(), "x86".into()), + ("ARCH".into(), "x86_64".into()), + ("HEADER_ARCH".into(), "x86".into()), + ]) + } + + fn build_indices(&mut self) { + let mut rev_hard: BTreeMap> = BTreeMap::new(); + let mut rev_sel: BTreeMap> = BTreeMap::new(); + for (name, sym) in &self.symbols { + for dep in sym.hard_deps() { + rev_hard.entry(dep).or_default().insert(name.clone()); + } + for t in sym.select_targets() { + rev_sel.entry(t).or_default().insert(name.clone()); + } + } + self.rev_hard_deps = rev_hard; + self.rev_selects = rev_sel; + } + + /// Símbolos que dependen DURO de `sym`, en un solo salto. + pub fn dependents_of(&self, sym: &str) -> &BTreeSet { + static EMPTY: std::sync::OnceLock> = std::sync::OnceLock::new(); + self.rev_hard_deps + .get(sym) + .unwrap_or_else(|| EMPTY.get_or_init(BTreeSet::new)) + } + + /// Símbolos que hacen `select sym`, en un solo salto. + pub fn selectors_of(&self, sym: &str) -> &BTreeSet { + static EMPTY: std::sync::OnceLock> = std::sync::OnceLock::new(); + self.rev_selects + .get(sym) + .unwrap_or_else(|| EMPTY.get_or_init(BTreeSet::new)) + } + + /// **La clausura**: todo lo que se vuelve inalcanzable si se apagan `roots`. + /// + /// Es el cierre transitivo de `depends on` en dirección inversa. Incluye a las propias raíces. + pub fn closure_off(&self, roots: &[String]) -> BTreeSet { + let mut seen: BTreeSet = BTreeSet::new(); + let mut queue: Vec = Vec::new(); + for r in roots { + if seen.insert(r.clone()) { + queue.push(r.clone()); + } + } + while let Some(cur) = queue.pop() { + for d in self.dependents_of(&cur) { + if seen.insert(d.clone()) { + queue.push(d.clone()); + } + } + } + seen + } + + /// Cierra la clausura contra sus propias fugas: apaga también a cada `selector` y repite. + /// + /// **No se aplica solo — se reporta.** Es la mitad del §3 del SDD 22 que faltaba: la clausura + /// por `depends on` sub-reporta (deja fugas) y el punto fijo puede sobre-reportar (para cerrar + /// «sin audio» hay que tragarse drivers gráficos, porque hacen `select` del códec HDMI). El + /// valor de `added` es el precio del bundle, y quien lo paga es una persona, no el algoritmo. + /// + /// `max_rounds` acota una divergencia patológica; en 6.16.12 los bundles reales cierran en ≤3. + pub fn closure_off_fixpoint(&self, roots: &[String], max_rounds: usize) -> Fixpoint { + let mut current: Vec = roots.to_vec(); + let mut added: Vec<(usize, String)> = Vec::new(); + let mut known: BTreeSet = roots.iter().cloned().collect(); + let mut rounds = 0usize; + loop { + let cl = self.closure_off(¤t); + let leaks = self.select_leaks(&cl); + if leaks.is_empty() || rounds >= max_rounds { + return Fixpoint { + closure: cl, + added, + rounds, + remaining_leaks: leaks, + }; + } + rounds += 1; + for l in &leaks { + if known.insert(l.selector.clone()) { + added.push((rounds, l.selector.clone())); + current.push(l.selector.clone()); + } + } + } + } + + /// Los **portillos** de una clausura: aristas `select` que entran desde fuera. + /// + /// Ésta es la objeción del §3 del SDD 22 vuelta medición. `select` fuerza el destino + /// **ignorando sus `depends on`**, así que un símbolo de fuera del bundle puede reencender algo + /// que el bundle apagó. Si esta lista está vacía, la clausura es cerrada de verdad; si no, cada + /// entrada es un símbolo que hay que apagar también o aceptar como fuga consciente. + pub fn select_leaks(&self, closure: &BTreeSet) -> Vec { + let mut out = Vec::new(); + for target in closure { + for selector in self.selectors_of(target) { + if !closure.contains(selector) { + out.push(SelectLeak { + selector: selector.clone(), + target: target.clone(), + }); + } + } + } + out.sort(); + out.dedup(); + out + } +} + +/// Resultado de cerrar una clausura contra sus propias fugas por `select`. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct Fixpoint { + /// La clausura ya cerrada (o lo que se alcanzó al agotar las rondas). + pub closure: BTreeSet, + /// Símbolos que hubo que apagar ADEMÁS de las raíces, con la ronda en que aparecieron. + /// Es el coste real del bundle, y lo que un humano tiene que aprobar. + pub added: Vec<(usize, String)>, + pub rounds: usize, + /// Fugas que quedan si se agotaron las rondas. Vacío = cerró de verdad. + pub remaining_leaks: Vec, +} + +/// Un `select` que entra a la clausura desde fuera: `selector` reenciende `target`. +#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)] +pub struct SelectLeak { + pub selector: String, + pub target: String, +} + +// --------------------------------------------------------------------------------------------- +// Parser +// --------------------------------------------------------------------------------------------- + +struct Parser<'a> { + tree: &'a mut KconfigTree, + vars: &'a BTreeMap, + visited: &'a mut HashSet, +} + +/// Estado del bloque que se está leyendo dentro de un fichero. +enum Cur { + None, + /// Índice de la definición abierta, en `symbols[name].defs`. + Sym { + name: String, + idx: usize, + }, +} + +impl Parser<'_> { + /// `inherited` son los `depends on` de los `menu`/`if`/`choice` que envuelven a este fichero. + fn parse_file(&mut self, rel: &str, inherited: &[Expr]) -> std::io::Result<()> { + if !self.visited.insert(rel.to_string()) { + // Un fichero sourceado dos veces se lee una sola. En mainline sólo ocurre con + // ficheros sin `depends on` envolvente, así que no se pierde contexto; se anota igual + // para que un cambio de upstream no pase callado. + self.tree + .warnings + .push(format!("{rel}: sourceado más de una vez, se lee una sola")); + return Ok(()); + } + let path = self.tree.root.join(rel); + let Ok(raw) = std::fs::read_to_string(&path) else { + self.tree + .warnings + .push(format!("{rel}: no se pudo leer, se omite")); + return Ok(()); + }; + self.tree.files.push(rel.to_string()); + + let lines = join_continuations(&raw); + // Contextos abiertos (menu / if / choice), cada uno con su condición opcional. + let mut ctx: Vec> = Vec::new(); + let mut cur = Cur::None; + let mut i = 0usize; + + while i < lines.len() { + let (lineno, raw_line) = &lines[i]; + i += 1; + let line = strip_comment(raw_line); + let t = line.trim(); + if t.is_empty() { + continue; + } + let (kw, rest) = split_keyword(t); + + match kw { + "config" | "menuconfig" => { + let name = rest.trim().to_string(); + if name.is_empty() { + continue; + } + let mut depends: Vec = inherited.to_vec(); + depends.extend(ctx.iter().flatten().cloned()); + let def = SymbolDef { + kind: SymKind::Unknown, + prompt: None, + depends, + selects: Vec::new(), + implies: Vec::new(), + help: None, + loc: Location { + file: rel.to_string(), + line: *lineno, + }, + }; + let sym = self + .tree + .symbols + .entry(name.clone()) + .or_insert_with(|| Symbol { + name: name.clone(), + defs: Vec::new(), + }); + sym.defs.push(def); + let idx = sym.defs.len() - 1; + cur = Cur::Sym { name, idx }; + } + "menu" | "choice" => { + ctx.push(None); + cur = Cur::None; + } + "endmenu" | "endchoice" => { + ctx.pop(); + cur = Cur::None; + } + "if" => { + ctx.push(parse_expr_tolerant(rest, rel, *lineno, &mut self.tree.warnings)); + cur = Cur::None; + } + "endif" => { + ctx.pop(); + cur = Cur::None; + } + "source" => { + cur = Cur::None; + let Some(target) = self.resolve_source(rest, rel, *lineno) else { + continue; + }; + let mut inh: Vec = inherited.to_vec(); + inh.extend(ctx.iter().flatten().cloned()); + self.parse_file(&target, &inh)?; + } + "comment" | "mainmenu" => { + cur = Cur::None; + } + "help" | "---help---" => { + // El bloque de ayuda va hasta la primera línea no vacía menos indentada que + // la primera línea de ayuda. + let (text, next) = take_help(&lines, i); + i = next; + if let Cur::Sym { name, idx } = &cur { + if let Some(s) = self.tree.symbols.get_mut(name) { + s.defs[*idx].help = Some(text); + } + } + } + "depends" => { + // `depends on X` — el `on` es parte de la palabra clave, no de la expresión. + let rest = rest.trim_start(); + let rest = rest.strip_prefix("on").unwrap_or(rest); + let e = parse_expr_tolerant(rest, rel, *lineno, &mut self.tree.warnings); + match &cur { + Cur::Sym { name, idx } => { + if let (Some(e), Some(s)) = (e, self.tree.symbols.get_mut(name)) { + s.defs[*idx].depends.push(e); + } + } + // `depends on` suelto tras `menu`/`choice`: aplica al contexto abierto. + Cur::None => { + if let Some(slot) = ctx.last_mut() { + *slot = match (slot.take(), e) { + (Some(a), Some(b)) => Some(Expr::And(Box::new(a), Box::new(b))), + (a, b) => a.or(b), + }; + } + } + } + } + "select" | "imply" => { + let Cur::Sym { name, idx } = &cur else { continue }; + let (target, cond) = split_if(rest); + let target = target.trim().to_string(); + if target.is_empty() { + continue; + } + let cond = cond.and_then(|c| { + parse_expr_tolerant(c, rel, *lineno, &mut self.tree.warnings) + }); + if let Some(s) = self.tree.symbols.get_mut(name) { + let edge = Edge { target, cond }; + if kw == "select" { + s.defs[*idx].selects.push(edge); + } else { + s.defs[*idx].implies.push(edge); + } + } + } + "bool" | "tristate" | "int" | "hex" | "string" | "def_bool" | "def_tristate" => { + let Cur::Sym { name, idx } = &cur else { continue }; + let kind = SymKind::from_keyword(kw).unwrap_or(SymKind::Unknown); + // `bool "prompt" if COND` — el `if` es visibilidad, no dependencia dura, pero + // sin prompt visible el símbolo tampoco es marcable; se guarda el texto y ya. + let (head, cond) = split_if(rest); + let prompt = extract_quoted(head); + // `def_bool COND` no lleva prompt: lo que sigue es el valor por defecto. + let is_def = kw.starts_with("def_"); + if let Some(s) = self.tree.symbols.get_mut(name) { + let d = &mut s.defs[*idx]; + if d.kind == SymKind::Unknown { + d.kind = kind; + } + if !is_def { + if let Some(p) = prompt { + d.prompt = Some(p); + } + } + // Una condición de visibilidad no restringe el valor: se ignora a + // propósito para no inventar dependencias duras que no existen. + let _ = cond; + } + } + "prompt" => { + let Cur::Sym { name, idx } = &cur else { continue }; + let (head, _) = split_if(rest); + if let (Some(p), Some(s)) = (extract_quoted(head), self.tree.symbols.get_mut(name)) + { + s.defs[*idx].prompt = Some(p); + } + } + // Atributos que no cambian la topología del grafo. + "default" | "range" | "option" | "optional" | "visible" | "modules" + | "requires" => {} + _ => { + // Asignaciones de macro (`comma := ,`) y demás: no son parte del grafo. + if !t.contains(":=") && !t.contains('=') && !t.starts_with("$(") { + self.tree + .warnings + .push(format!("{rel}:{lineno}: no entendí «{t}»")); + } + } + } + } + Ok(()) + } + + /// `source "ruta"` con sustitución de `$(VAR)`. Devuelve `None` si la ruta no se puede resolver. + fn resolve_source(&mut self, rest: &str, rel: &str, lineno: usize) -> Option { + let raw = extract_quoted(rest).unwrap_or_else(|| rest.trim().to_string()); + let mut out = String::new(); + let b = raw.as_bytes(); + let mut i = 0; + while i < b.len() { + if b[i] == b'$' && i + 1 < b.len() && b[i + 1] == b'(' { + let Some(close) = raw[i + 2..].find(')') else { + out.push('$'); + i += 1; + continue; + }; + let var = &raw[i + 2..i + 2 + close]; + match self.vars.get(var) { + Some(v) => out.push_str(v), + None => { + self.tree.warnings.push(format!( + "{rel}:{lineno}: source con variable desconocida $({var}), se omite" + )); + return None; + } + } + i += 2 + close + 1; + } else { + out.push(raw.as_bytes()[i] as char); + i += 1; + } + } + if out.is_empty() { + return None; + } + Some(out) + } +} + +/// Une las líneas terminadas en `\` y devuelve `(número de línea original, texto)`. +fn join_continuations(raw: &str) -> Vec<(usize, String)> { + let mut out: Vec<(usize, String)> = Vec::new(); + let mut pending: Option<(usize, String)> = None; + for (n, line) in raw.lines().enumerate() { + let lineno = n + 1; + let (text, cont) = match line.strip_suffix('\\') { + Some(head) => (head.to_string(), true), + None => (line.to_string(), false), + }; + match &mut pending { + Some((_, acc)) => { + acc.push(' '); + acc.push_str(text.trim_start()); + } + None => pending = Some((lineno, text)), + } + if !cont { + out.push(pending.take().unwrap()); + } + } + if let Some(p) = pending { + out.push(p); + } + out +} + +/// Quita el comentario `#` que no esté dentro de comillas. +fn strip_comment(line: &str) -> String { + let mut out = String::with_capacity(line.len()); + let mut quote: Option = None; + let mut prev_backslash = false; + for c in line.chars() { + match quote { + Some(q) => { + out.push(c); + if c == q && !prev_backslash { + quote = None; + } + } + None => { + if c == '#' { + break; + } + if c == '"' || c == '\'' { + quote = Some(c); + } + out.push(c); + } + } + prev_backslash = c == '\\' && !prev_backslash; + } + out +} + +fn split_keyword(t: &str) -> (&str, &str) { + match t.find(|c: char| c.is_whitespace()) { + Some(p) => (&t[..p], &t[p..]), + None => (t, ""), + } +} + +/// Separa `X if COND` en `("X", Some("COND"))`, respetando comillas. +fn split_if(s: &str) -> (&str, Option<&str>) { + let b = s.as_bytes(); + let mut quote = false; + let mut i = 0; + while i < b.len() { + if b[i] == b'"' { + quote = !quote; + } else if !quote + && b[i..].starts_with(b"if") + && (i == 0 || b[i - 1].is_ascii_whitespace()) + && b.get(i + 2).is_none_or(|c| c.is_ascii_whitespace()) + { + return (&s[..i], Some(&s[i + 2..])); + } + i += 1; + } + (s, None) +} + +fn extract_quoted(s: &str) -> Option { + let start = s.find('"')?; + let rest = &s[start + 1..]; + let end = rest.find('"')?; + Some(rest[..end].to_string()) +} + +/// Indentación en **columnas**, con tabuladores a paradas de 8. +/// +/// No es un detalle cosmético: los bloques de ayuda del kernel mezclan `\t ` (10 columnas) con +/// `\t\t` (16). Contando caracteres, el segundo parece MENOS indentado que el primero (2 < 3) y el +/// bloque de ayuda se corta a la mitad — con lo que 400 líneas de prosa se leen como sintaxis rota. +fn indent_columns(l: &str) -> usize { + let mut col = 0usize; + for c in l.chars() { + match c { + '\t' => col = (col / 8 + 1) * 8, + ' ' => col += 1, + _ => break, + } + } + col +} + +/// Consume el bloque de ayuda a partir del índice `start`; devuelve el texto y el índice siguiente. +fn take_help(lines: &[(usize, String)], start: usize) -> (String, usize) { + let mut i = start; + let mut indent: Option = None; + let mut text = String::new(); + while i < lines.len() { + let l = &lines[i].1; + if l.trim().is_empty() { + text.push('\n'); + i += 1; + continue; + } + let ind = indent_columns(l); + match indent { + None => indent = Some(ind), + Some(base) if ind < base => break, + Some(_) => {} + } + text.push_str(l.trim()); + text.push('\n'); + i += 1; + } + // No consumir las líneas en blanco finales: pertenecen al bloque siguiente. + (text.trim().to_string(), i) +} + +// --------------------------------------------------------------------------------------------- +// Expresiones +// --------------------------------------------------------------------------------------------- + +#[derive(Debug, Clone, PartialEq, Eq)] +enum Tok { + LParen, + RParen, + Not, + And, + Or, + Op(CmpOp), + Word(String), + Quoted(String), + Macro(String), +} + +fn tokenize(s: &str) -> Option> { + let b: Vec = s.chars().collect(); + let mut out = Vec::new(); + let mut i = 0; + while i < b.len() { + let c = b[i]; + if c.is_whitespace() { + i += 1; + continue; + } + match c { + '(' => { + out.push(Tok::LParen); + i += 1; + } + ')' => { + out.push(Tok::RParen); + i += 1; + } + '!' => { + if b.get(i + 1) == Some(&'=') { + out.push(Tok::Op(CmpOp::Ne)); + i += 2; + } else { + out.push(Tok::Not); + i += 1; + } + } + '&' => { + if b.get(i + 1) == Some(&'&') { + out.push(Tok::And); + i += 2; + } else { + return None; + } + } + '|' => { + if b.get(i + 1) == Some(&'|') { + out.push(Tok::Or); + i += 2; + } else { + return None; + } + } + '=' => { + out.push(Tok::Op(CmpOp::Eq)); + i += 1; + } + '<' | '>' => { + let eq = b.get(i + 1) == Some(&'='); + let op = match (c, eq) { + ('<', false) => CmpOp::Lt, + ('<', true) => CmpOp::Le, + ('>', false) => CmpOp::Gt, + _ => CmpOp::Ge, + }; + out.push(Tok::Op(op)); + i += if eq { 2 } else { 1 }; + } + '"' | '\'' => { + let q = c; + let mut val = String::new(); + i += 1; + while i < b.len() && b[i] != q { + val.push(b[i]); + i += 1; + } + if i >= b.len() { + return None; + } + i += 1; + out.push(Tok::Quoted(val)); + } + '$' => { + // Macro `$(...)`: se toma entera con paréntesis balanceados y queda opaca. + if b.get(i + 1) != Some(&'(') { + return None; + } + let mut depth = 0usize; + let start = i; + while i < b.len() { + if b[i] == '(' { + depth += 1; + } else if b[i] == ')' { + depth -= 1; + if depth == 0 { + i += 1; + break; + } + } + i += 1; + } + out.push(Tok::Macro(b[start..i].iter().collect())); + } + _ => { + let start = i; + while i < b.len() + && (b[i].is_alphanumeric() || b[i] == '_' || b[i] == '-' || b[i] == '.') + { + i += 1; + } + if i == start { + return None; + } + out.push(Tok::Word(b[start..i].iter().collect())); + } + } + } + Some(out) +} + +struct ExprParser { + toks: Vec, + pos: usize, +} + +impl ExprParser { + fn peek(&self) -> Option<&Tok> { + self.toks.get(self.pos) + } + fn next(&mut self) -> Option { + let t = self.toks.get(self.pos).cloned(); + if t.is_some() { + self.pos += 1; + } + t + } + + fn parse_or(&mut self) -> Option { + let mut lhs = self.parse_and()?; + while self.peek() == Some(&Tok::Or) { + self.next(); + let rhs = self.parse_and()?; + lhs = Expr::Or(Box::new(lhs), Box::new(rhs)); + } + Some(lhs) + } + + fn parse_and(&mut self) -> Option { + let mut lhs = self.parse_unary()?; + while self.peek() == Some(&Tok::And) { + self.next(); + let rhs = self.parse_unary()?; + lhs = Expr::And(Box::new(lhs), Box::new(rhs)); + } + Some(lhs) + } + + fn parse_unary(&mut self) -> Option { + if self.peek() == Some(&Tok::Not) { + self.next(); + return Some(Expr::Not(Box::new(self.parse_unary()?))); + } + self.parse_atom() + } + + fn parse_atom(&mut self) -> Option { + if self.peek() == Some(&Tok::LParen) { + self.next(); + let e = self.parse_or()?; + if self.next() != Some(Tok::RParen) { + return None; + } + return Some(e); + } + let lhs = self.parse_primary()?; + if let Some(Tok::Op(op)) = self.peek().cloned() { + self.next(); + let rhs = self.parse_primary()?; + return Some(Expr::Cmp(Box::new(lhs), op, Box::new(rhs))); + } + Some(lhs) + } + + fn parse_primary(&mut self) -> Option { + match self.next()? { + Tok::Word(w) => Some(if is_const_word(&w) { + Expr::Const(w) + } else { + Expr::Sym(w) + }), + Tok::Quoted(q) => Some(Expr::Const(q)), + Tok::Macro(m) => Some(Expr::Const(m)), + _ => None, + } + } +} + +/// `y`/`n`/`m` y los literales numéricos son constantes, no símbolos. +fn is_const_word(w: &str) -> bool { + matches!(w, "y" | "n" | "m") + || w.chars().next().is_some_and(|c| c.is_ascii_digit()) +} + +pub fn parse_expr(s: &str) -> Option { + let toks = tokenize(s)?; + if toks.is_empty() { + return None; + } + let mut p = ExprParser { toks, pos: 0 }; + let e = p.parse_or()?; + if p.pos != p.toks.len() { + return None; + } + Some(e) +} + +fn parse_expr_tolerant( + s: &str, + file: &str, + line: usize, + warnings: &mut Vec, +) -> Option { + let t = s.trim(); + if t.is_empty() { + return None; + } + match parse_expr(t) { + Some(e) => Some(e), + None => { + warnings.push(format!("{file}:{line}: expresión no parseada «{t}»")); + None + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn syms(e: &Expr) -> Vec { + let mut s = BTreeSet::new(); + e.symbols(&mut s); + s.into_iter().collect() + } + fn hard(e: &Expr) -> Vec { + let mut s = BTreeSet::new(); + e.hard_symbols(&mut s); + s.into_iter().collect() + } + + #[test] + fn precedencia_y_duros() { + let e = parse_expr("A && (B || C)").unwrap(); + assert_eq!(syms(&e), vec!["A", "B", "C"]); + // Ésta es LA propiedad: en `A && (B||C)` sólo `A` mata la expresión. + assert_eq!(hard(&e), vec!["A"]); + } + + #[test] + fn negacion_no_es_dura() { + let e = parse_expr("A && !B").unwrap(); + assert_eq!(hard(&e), vec!["A"]); + } + + #[test] + fn comparacion_no_aporta_duros() { + let e = parse_expr("GCC_VERSION >= 40800 && X").unwrap(); + assert_eq!(hard(&e), vec!["X"]); + } + + #[test] + fn constantes_no_son_simbolos() { + let e = parse_expr("A && y").unwrap(); + assert_eq!(syms(&e), vec!["A"]); + } + + #[test] + fn macros_quedan_opacas() { + let e = parse_expr("$(success,test -x /bin/sh) && A").unwrap(); + assert_eq!(syms(&e), vec!["A"]); + } + + #[test] + fn split_if_respeta_comillas() { + let (head, cond) = split_if(r#""Wi-Fi if you like" if WIRELESS"#); + assert_eq!(head.trim(), r#""Wi-Fi if you like""#); + assert_eq!(cond.unwrap().trim(), "WIRELESS"); + } + + #[test] + fn comentario_dentro_de_comillas_no_corta() { + assert_eq!(strip_comment(r#"bool "a # b" # real"#).trim(), r#"bool "a # b""#); + } + + /// Árbol mínimo con la forma exacta que tiene el wifi en 6.16.12: `WLAN` **selecciona** + /// `WIRELESS` en vez de depender de él, que es lo que hace fallar a la clausura ingenua. + fn arbol_de_juguete() -> (tempfile::TempDir, KconfigTree) { + 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 ATH10K + tristate "ath10k" + depends on CFG80211 + +config WLAN + bool "WLAN drivers" + select WIRELESS + +config UNRELATED + bool "Otra cosa" + depends on PCI +"#, + ) + .unwrap(); + let t = KconfigTree::parse(d.path(), &BTreeMap::new()).unwrap(); + (d, t) + } + + #[test] + fn clausura_transitiva_por_depends() { + let (_d, t) = arbol_de_juguete(); + let cl = t.closure_off(&["WIRELESS".into()]); + // Transitivo: WIRELESS → CFG80211 → ATH10K. + assert!(cl.contains("CFG80211") && cl.contains("ATH10K")); + assert!(!cl.contains("UNRELATED")); + // Y el hallazgo real de 6.16.12: WLAN NO cae en la clausura, porque no depende — selecciona. + assert!(!cl.contains("WLAN")); + } + + #[test] + fn la_fuga_por_select_se_detecta() { + let (_d, t) = arbol_de_juguete(); + let cl = t.closure_off(&["WIRELESS".into()]); + let leaks = t.select_leaks(&cl); + assert_eq!( + leaks, + vec![SelectLeak { + selector: "WLAN".into(), + target: "WIRELESS".into() + }] + ); + } + + #[test] + fn el_punto_fijo_absorbe_al_selector() { + let (_d, t) = arbol_de_juguete(); + let fx = t.closure_off_fixpoint(&["WIRELESS".into()], 8); + assert!(fx.remaining_leaks.is_empty(), "tenía que cerrar"); + assert!(fx.closure.contains("WLAN")); + assert_eq!(fx.added, vec![(1, "WLAN".to_string())]); + } + + #[test] + fn hard_deps_es_interseccion_entre_definiciones() { + // Un símbolo con dos definiciones: apagar `B` no lo mata porque la segunda no lo pide. + let sym = Symbol { + name: "X".into(), + defs: vec![ + SymbolDef { + kind: SymKind::Bool, + prompt: None, + depends: vec![parse_expr("A && B").unwrap()], + selects: vec![], + implies: vec![], + help: None, + loc: Location { file: "a".into(), line: 1 }, + }, + SymbolDef { + kind: SymKind::Bool, + prompt: None, + depends: vec![parse_expr("A").unwrap()], + selects: vec![], + implies: vec![], + help: None, + loc: Location { file: "b".into(), line: 1 }, + }, + ], + }; + assert_eq!( + sym.hard_deps().into_iter().collect::>(), + vec!["A".to_string()] + ); + } +} diff --git a/crates/hammer-core/src/kernel/mod.rs b/crates/hammer-core/src/kernel/mod.rs new file mode 100644 index 00000000..c6873fa8 --- /dev/null +++ b/crates/hammer-core/src/kernel/mod.rs @@ -0,0 +1,23 @@ +//! El armador de kernel: catálogo de bundles, clausuras sobre Kconfig y modo reversa. +//! +//! Implementa `docs/22-configurador-kernel.md`, que a su vez contesta +//! `tawasuyu/HANDOFF-KERNEL-CONFIG-A-HAMMER.md`. +//! +//! **El hecho que ordena todo el módulo** (SDD 22 §1): 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**. Cambiar un símbolo cambia el `ArtifactHash`. De ahí dos consecuencias +//! que el código respeta literalmente: +//! +//! 1. Una «perilla» de la UI **no es un parámetro de runtime, es una edición de receta**. Por eso +//! `plan` emite una **receta derivada**, no un binario parametrizable. +//! 2. Atestar «este config booteó en esta huella» sale casi gratis: ya es un artefacto CAS. +//! +//! Y la regla dura que nunca se cruza: **hammer no resuelve Kconfig**. Emite fragmentos +//! (`scripts/config -e/-d`) y deja que el `olddefconfig` del propio kernel produzca el `.config`. +//! Ver [`kconfig`]. + +pub mod config; +pub mod kconfig; + +pub use config::{ConfigValue, KernelConfig}; +pub use kconfig::{Expr, KconfigTree, SelectLeak, SymKind, Symbol}; diff --git a/crates/hammer-core/src/lib.rs b/crates/hammer-core/src/lib.rs index 471d16fb..001034e6 100644 --- a/crates/hammer-core/src/lib.rs +++ b/crates/hammer-core/src/lib.rs @@ -10,6 +10,7 @@ pub mod compat; pub mod differs; pub mod hash; pub mod installed; +pub mod kernel; pub mod proto; pub mod query; pub mod recipe; diff --git a/docs/22-configurador-kernel.md b/docs/22-configurador-kernel.md index 61662ad2..b985ce0c 100644 --- a/docs/22-configurador-kernel.md +++ b/docs/22-configurador-kernel.md @@ -221,3 +221,91 @@ De acuerdo con el reparto tal como está. Dos precisiones desde este lado: **No implementar todavía.** Este documento existe para que la decisión se tome con los seis datos verificados, no con las suposiciones del handoff — que su autor, correctamente, marcó como tales. + +--- + +# ADENDA — 2026-08-10: implementación en marcha + +El usuario decidió. Lo que sigue ya no es análisis: es lo medido contra el código escrito. Se sigue +el orden del §8. + +## 9. Paso 2 hecho: la clausura SÍ reproduce los bundles a mano + +`hammer kernel` (`crates/hammer-core/src/kernel/`, `crates/hammer-cli/src/kernel_cmd.rs`) trae un +lector de Kconfig **de sólo análisis** — la regla dura del §6 del handoff se respeta literalmente: no +hay solver, el `.config` lo sigue produciendo `olddefconfig`. + +Salud del lector, sobre `linux-6.16.12` (`hammer kernel stats`): + +| ficheros | símbolos | visibles | con ayuda | aristas `select` | aristas `imply` | avisos de parseo | +|---|---|---|---|---|---|---| +| 1646 | 18 212 | 15 169 | 14 866 | 15 165 | 445 | **0** | + +### La semántica de arista, resuelta +El §3 pedía definirla antes de escribir el predicado. Quedó así, y las dos mitades importan: + +- **`depends on` en posición conjuntiva** — sólo cuenta como dependencia dura el símbolo que, puesto + a `n`, apaga la expresión entera. En `A && (B || C)` sólo `A` es duro; `!X`, las ramas de un `||` y + los dos lados de una comparación no aportan nada. Es deliberadamente conservador: preferimos un + bundle que apague **de menos** (se nota) a uno que apague de más (hace un ladrillo). +- **Un símbolo con varias definiciones** muere sólo si mueren todas ⇒ las dependencias duras son la + **intersección entre definiciones**, no la unión. El error natural (la unión) diría que apagar `Y` + mata a `X` cuando `X` tiene otra definición que ni menciona a `Y`. +- **`select` no es una arista más: es el portillo.** Fuerza el destino **ignorando** sus + `depends on`, así que un símbolo de fuera del bundle reenciende lo que el bundle apagó. + `select_leaks` las enumera, y `closure_off_fixpoint` cierra el bundle contra ellas — **reportando + el precio, nunca aplicándolo solo**. + +### La medición que decide §2.1 +Corpus: el juego de bundles N1 escrito a mano en la fase `configure` de `recipes/linux.toml`, sobre +un kernel que arranca. Pregunta: *¿la clausura calculada desde la raíz mínima reproduce lo que +escribió el humano?* + +``` +hammer kernel closure --kconfig <árbol> WIRELESS --fixpoint +``` + +| | símbolos | +|---|---| +| clausura estricta de `WIRELESS` (sólo `depends on`) | 350 | +| punto fijo (cierra las 3 fugas `select`: `WLAN`, `IWLEGACY`, `GELIC_WIRELESS`) | **406** | +| bundle `-d WLAN -d WIRELESS -d CFG80211 -d MAC80211 -d RFKILL` hecho a mano | **421** | +| **sobra** (apagaría algo que el humano dejó vivo) | **0** | +| **falta** | **15** | + +**Sobra cero.** La clausura no toca un solo símbolo que el humano quisiera conservar — que era el +riesgo real, porque sobrar es hacer un ladrillo. Cierra en **una ronda**. + +Los 15 que faltan son **todos de `RFKILL`** (`RFKILL{,_GPIO,_INPUT,_LEDS}` y los drivers de laptop +que dependen de él: `DELL_RBTN`, `IDEAPAD_LAPTOP`, `MSI_LAPTOP`, `AMILO_RFKILL`…). Y `RFKILL` **no +es wifi**: es el interruptor de radio compartido, que bluetooth y NFC declaran con el mismo idioma +(`depends on RFKILL || !RFKILL` en `net/{bluetooth,nfc,wireless}/Kconfig`). Que quede fuera no es un +fallo de la clausura — es que el humano apagó **dos** bundles a la vez («no necesito wifi» y «no +necesito radios») y los escribió en la misma línea. ⇒ **el catálogo N1 necesita «sin radios» como +entrada propia.** Nótese que ese idioma es un `||`: la regla conservadora lo excluye de los duros, +que es exactamente lo correcto. + +⇒ **§2.1 es viable y la objeción del §3 queda resuelta**, con una corrección al diseño: un bundle no +es `clausura(raíz)` sino **`clausura(raíz)` + el punto fijo sobre `select`**, y las fugas se +aprueban a mano una vez, no en cada release. + +### Y el punto fijo también dice cuándo NO +No siempre cierra barato, y eso es información, no un fallo. Medido sobre los otros tres bundles a +mano de `linux.toml`: + +| bundle a mano | clausura | fugas `select` | qué las causa | +|---|---|---|---| +| `WLAN WIRELESS CFG80211 MAC80211 RFKILL` | 421 | **0** | cerrado | +| `SOUND SND` | 1323 | 19 sobre 7 destinos | `DRM_I915`/`DRM_NOUVEAU`/`DRM_AMD_DC` hacen `select` del códec HDMI | +| `DRM DRM_I915 … FB AGP` | 770 | 15 sobre 6 destinos | drivers USB-C (`TYPEC_*`) hacen `select DRM_AUX_BRIDGE` | +| `XFS_FS BTRFS_FS … NTFS3_FS` | 46 | 1 | `NTFS3_FS ← NTFS_FS` | + +Para cerrar «no necesito audio» hay que tragarse drivers gráficos. En `linux.toml` sale gratis +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. + +### 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 +**diff-back** (paso 4 del §8) existe para atrapar, encontrado antes de implementarlo: hoy un +`-e`/`-d` sobre un símbolo inexistente **se pierde en silencio**.