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>
365 lines
13 KiB
Rust
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()))
|
|
}
|