> Una distribución Linux construida con un laboratorio funcional y hermético en el > sótano, y una terminal mutable, clásica y anárquica en el piso de arriba — diseñada > desde el suelo para que una IA programadora entienda, modifique y co…
kernel: el lector de Kconfig y la validación del §3 — la clausura reproduce los bundles a mano
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) <noreply@anthropic.com>
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<String>,
+ /// Raíz del árbol de fuentes del kernel (el directorio con el `Kconfig` de arriba).
+ #[arg(long)]
+ kconfig: Option<PathBuf>,
+ /// Contrasta la clausura contra los símbolos apagados en este `.config`.
+ #[arg(long)]
+ against: Option<PathBuf>,
+ /// 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<PathBuf>,
+ /// 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<PathBuf>) -> Result<PathBuf> {
+ 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 <dir> o exportá {KCONFIG_ENV}"
+ ),
+ }
+}
+
+fn load_tree(root: &Path) -> Result<KconfigTree> {
+ if !root.join("Kconfig").is_file() {
+ anyhow::bail!(
+ "{} no parece un árbol de fuentes de kernel (falta ./Kconfig).\n\
+ Pasá --kconfig <dir> 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<String>,
+ /// Todo lo que queda inalcanzable si se apagan las raíces (incluidas las raíces).
+ closure: Vec<String>,
+ /// 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<hammer_core::kernel::SelectLeak>,
+ /// Símbolos distintos de la clausura alcanzados por esas fugas.
+ leaked_targets: usize,
+ #[serde(skip_serializing_if = "Option::is_none")]
+ fixpoint: Option<FixpointReport>,
+ #[serde(skip_serializing_if = "Option::is_none")]
+ against: Option<AgainstReport>,
+}
+
+/// El precio de cerrar el bundle contra sus fugas.
+#[derive(Serialize)]
+struct FixpointReport {
+ /// La clausura ya cerrada, entera.
+ closure: Vec<String>,
+ /// 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<hammer_core::kernel::SelectLeak>,
+}
+
+/// 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<String>,
+ /// 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::<Vec<_>>()
+ .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::<BTreeSet<_>>()
+ .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<String> = 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<String> {
+ 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<String, ConfigValue>,
+ /// La línea `# Linux/x86 6.16.12 Kernel Configuration`, si está.
+ pub banner: Option<String>,
+}
+
+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<Item = &str> {
+ 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<SymKind> {
+ 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<Expr>),
+ And(Box<Expr>, Box<Expr>),
+ Or(Box<Expr>, Box<Expr>),
+ Cmp(Box<Expr>, CmpOp, Box<Expr>),
+}
+
+impl Expr {
+ /// Todos los símbolos que aparecen, sin importar la posición.
+ pub fn symbols(&self, out: &mut BTreeSet<String>) {
+ 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<String>) {
+ 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<Expr>,
+}
+
+/// 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<String>,
+ /// Conjunción de `depends on` propios **más** los heredados de `menu`/`if`/`choice`.
+ pub depends: Vec<Expr>,
+ pub selects: Vec<Edge>,
+ pub implies: Vec<Edge>,
+ pub help: Option<String>,
+ pub loc: Location,
+}
+
+/// Un símbolo del árbol, con todas sus definiciones.
+#[derive(Debug, Clone)]
+pub struct Symbol {
+ pub name: String,
+ pub defs: Vec<SymbolDef>,
+}
+
+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<String> {
+ 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<String> {
+ 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<String> {
+ self.defs
+ .iter()
+ .flat_map(|d| d.selects.iter())
+ .map(|e| e.target.clone())
+ .collect()
+ }
+
+ pub fn imply_targets(&self) -> BTreeSet<String> {
+ 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<String, Symbol>,
+ /// Ficheros efectivamente leídos (relativos a la raíz).
+ pub files: Vec<String>,
+ /// Todo lo que no se pudo interpretar, contado en vez de abortado.
+ pub warnings: Vec<String>,
+ /// `Y → {X : X depende DURO de Y}`. Apagar `Y` deja a cada `X` inalcanzable.
+ rev_hard_deps: BTreeMap<String, BTreeSet<String>>,
+ /// `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<String, BTreeSet<String>>,
+}
+
+impl KconfigTree {
+ /// Parsea el árbol entero desde `<root>/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<String, String>) -> std::io::Result<KconfigTree> {
+ let mut tree = KconfigTree {
+ root: root.to_path_buf(),
+ ..Default::default()
+ };
+ let mut visited: HashSet<String> = 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<String, String> {
+ 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<String, BTreeSet<String>> = BTreeMap::new();
+ let mut rev_sel: BTreeMap<String, BTreeSet<String>> = 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<String> {
+ static EMPTY: std::sync::OnceLock<BTreeSet<String>> = 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<String> {
+ static EMPTY: std::sync::OnceLock<BTreeSet<String>> = 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<String> {
+ let mut seen: BTreeSet<String> = BTreeSet::new();
+ let mut queue: Vec<String> = 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<String> = roots.to_vec();
+ let mut added: Vec<(usize, String)> = Vec::new();
+ let mut known: BTreeSet<String> = 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<String>) -> Vec<SelectLeak> {
+ 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<String>,
+ /// 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<SelectLeak>,
+}
+
+/// 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<String, String>,
+ visited: &'a mut HashSet<String>,
+}
+
+/// 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<Option<Expr>> = 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<Expr> = 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<Expr> = 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<String> {
+ 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<char> = 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<String> {
+ 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<usize> = 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<Vec<Tok>> {
+ let b: Vec<char> = 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<Tok>,
+ pos: usize,
+}
+
+impl ExprParser {
+ fn peek(&self) -> Option<&Tok> {
+ self.toks.get(self.pos)
+ }
+ fn next(&mut self) -> Option<Tok> {
+ let t = self.toks.get(self.pos).cloned();
+ if t.is_some() {
+ self.pos += 1;
+ }
+ t
+ }
+
+ fn parse_or(&mut self) -> Option<Expr> {
+ 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<Expr> {
+ 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<Expr> {
+ 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<Expr> {
+ 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<Expr> {
+ 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<Expr> {
+ 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<String>,
+) -> Option<Expr> {
+ 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<String> {
+ let mut s = BTreeSet::new();
+ e.symbols(&mut s);
+ s.into_iter().collect()
+ }
+ fn hard(e: &Expr) -> Vec<String> {
+ 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<_>>(),
+ 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**.