tawasuyu

sergio/takana

> 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…

TOML 1534Shell 199Rust 109Markdown 993713 Definiciones

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>

040c768def885c03d6159cbd79a7508f3db2f280 · por Sergio · (hace 2 meses) · padre 0377f4fa7 · ver el árbol en este commit

Delta semántico · 102

crates/hammer-cli/src/kernel_cmd.rs

crates/hammer-cli/src/main.rs

crates/hammer-core/src/kernel/config.rs

crates/hammer-core/src/kernel/kconfig.rs

crates/hammer-core/src/kernel/mod.rs

crates/hammer-core/src/lib.rs

7 archivos · +1825 −0

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(&current);
+            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**.