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>
This commit is contained in:
Sergio
2026-08-10 13:04:04 +00:00
co-authored by Claude Opus 5
parent 0377f4fa7a
commit 040c768def
7 changed files with 1825 additions and 0 deletions
+364
View File
@@ -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()))
}
+9
View File
@@ -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(())
}
+145
View File
@@ -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"));
}
}
File diff suppressed because it is too large Load Diff
+23
View File
@@ -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};
+1
View File
@@ -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;
+88
View File
@@ -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**.