Files
takana/crates/hammer-cli/src/kernel_cmd.rs
T
SergioandClaude Opus 5 040c768def 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>
2026-08-10 13:04:04 +00:00

365 lines
13 KiB
Rust

//! `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()))
}