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…
⎇ ca9bde0251d05fbfd34c93033d1aff2eb2ee62b5
crates/hammer-cli/src/kernel_cmd.rs
Historia
Quién tocó qué
crudo
kernel: la clausura contrastada contra un olddefconfig de verdad — 65/68 con 0 falsos positivos
1//! `hammer kernel` — el armador de kernel (SDD 22).
2//!
3//! Superficie en inglés (Regla 7.bis), mensajes en castellano. El contrato con las UIs es JSON
4//! estable, mismo patrón que `/run/hammer/boot-graph.json`.
5
6use std::collections::{BTreeMap, BTreeSet};
7use std::path::{Path, PathBuf};
8
9use anyhow::{Context, Result};
10use clap::Subcommand;
11use hammer_core::kernel::kconfig::KconfigTree;
12use hammer_core::kernel::KernelConfig;
13use serde::Serialize;
14
15/// Dónde busca el árbol de Kconfig si no se pasa `--kconfig`.
16const KCONFIG_ENV: &str = "HAMMER_KCONFIG_ROOT";
17/// Ídem para el catálogo de bundles.
18const CATALOG_ENV: &str = "HAMMER_KERNEL_BUNDLES";
19/// Sitios donde buscarlo sin que nadie diga nada: el del repo y el del sistema instalado.
20const CATALOG_DEFAULTS: &[&str] = &[
21 "docs/state/kernel-bundles.toml",
22 "/usr/share/hammer/kernel-bundles.toml",
23];
24
25#[derive(Subcommand)]
26pub enum KernelCmd {
27 /// Analiza el grafo de Kconfig: qué muere si se apagan estos símbolos, y por dónde se cuela un
28 /// `select` de vuelta.
29 ///
30 /// Es la herramienta con la que se decide si un bundle «por clausura» es viable (SDD 22 §3).
31 /// No construye ni escribe nada.
32 Closure {
33 /// Símbolos raíz a apagar, SIN el prefijo `CONFIG_` (p.ej. `WIRELESS`).
34 #[arg(required = true)]
35 symbols: Vec<String>,
36 /// Raíz del árbol de fuentes del kernel (el directorio con el `Kconfig` de arriba).
37 #[arg(long)]
38 kconfig: Option<PathBuf>,
39 /// Contrasta la clausura contra los símbolos apagados en este `.config`.
40 #[arg(long)]
41 against: Option<PathBuf>,
42 /// Cierra la clausura contra sus propias fugas: apaga también a cada selector, y repite.
43 /// Muestra el PRECIO (qué hubo que tragarse de más) en vez de aplicarlo a ciegas.
44 #[arg(long)]
45 fixpoint: bool,
46 /// Emite JSON en vez del informe legible.
47 #[arg(long)]
48 json: bool,
49 /// Cuántos símbolos de la clausura listar en el informe legible.
50 #[arg(long, default_value_t = 20)]
51 limit: usize,
52 },
53 /// **Modo reversa**: lee el kernel que ya corre y lo muestra por el lente de los bundles.
54 ///
55 /// No compila ni escribe nada. Contesta tres cosas: cuánto de cada bundle ya rige, qué
56 /// capacidad carga este kernel que esta máquina no usa, y dónde el hardware CONTRADICE a un
57 /// bundle aplicado.
58 Probe {
59 /// `.config` a mirar. Por defecto `/proc/config.gz` y si no `/boot/config-<uname -r>`.
60 #[arg(long)]
61 config: Option<PathBuf>,
62 #[arg(long)]
63 kconfig: Option<PathBuf>,
64 #[arg(long)]
65 catalog: Option<PathBuf>,
66 /// No leer el hardware (útil para analizar el config de OTRA máquina).
67 #[arg(long)]
68 no_hardware: bool,
69 #[arg(long)]
70 json: bool,
71 },
72 /// Lista el catálogo con sus clausuras ya resueltas contra un árbol de Kconfig concreto.
73 ///
74 /// Es también el control de frescura: `--check` sale distinto de cero si algún bundle nombra
75 /// símbolos que ya no existen o dejó fugas `select` sin declarar.
76 Bundles {
77 #[arg(long)]
78 kconfig: Option<PathBuf>,
79 #[arg(long)]
80 catalog: Option<PathBuf>,
81 /// Falla si el catálogo envejeció respecto de este árbol.
82 #[arg(long)]
83 check: bool,
84 #[arg(long)]
85 json: bool,
86 },
87 /// De una selección de bundles a una **receta derivada**, sin construir nada.
88 ///
89 /// El config del kernel vive en la fase `configure` y las fases entran en `hash_inputs` ⇒ el
90 /// config ES la identidad del artefacto. Por eso esto emite una receta con su propio
91 /// `ArtifactHash`, y no finge que el kernel sea un binario parametrizable.
92 Plan {
93 /// Receta base (p.ej. `recipes/linux.toml`).
94 #[arg(long)]
95 recipe: PathBuf,
96 /// Id de bundle del catálogo. Repetible.
97 #[arg(long = "bundle")]
98 bundles: Vec<String>,
99 /// Id de perilla del catálogo. Repetible.
100 #[arg(long = "knob")]
101 knobs: Vec<String>,
102 #[arg(long)]
103 kconfig: Option<PathBuf>,
104 #[arg(long)]
105 catalog: Option<PathBuf>,
106 /// Escribe el plan en JSON (el contrato con las UIs).
107 #[arg(long)]
108 out: Option<PathBuf>,
109 /// Escribe la receta derivada. Para construirla hay que dejarla donde sus `deps.build`
110 /// resuelvan — es decir, junto a la receta base.
111 #[arg(long)]
112 recipe_out: Option<PathBuf>,
113 #[arg(long)]
114 json: bool,
115 },
116 /// **Diff-back**: contrasta un plan contra el `.config` que produjo el kernel.
117 ///
118 /// Sin esto la UI miente: un `-e FOO` cuya dependencia no se cumple se pierde en silencio.
119 DiffBack {
120 /// El JSON que emitió `hammer kernel plan --out`.
121 #[arg(long)]
122 plan: PathBuf,
123 /// El `.config` resultante (el que la receta instala en `/out/boot/config-<versión>`).
124 #[arg(long)]
125 config: PathBuf,
126 #[arg(long)]
127 json: bool,
128 },
129 /// **Gate de no-regresión, por objetivo**: ¿el plan deja sin driver algún dispositivo en uso?
130 ///
131 /// No corre sin `--objective`: la regla aplicada global rechazaría recetas sanas (el kernel de
132 /// QEMU apaga USB, HID e INPUT a propósito). Sale distinto de cero si bloquea.
133 Gate {
134 /// El JSON que emitió `hammer kernel plan --out`.
135 #[arg(long)]
136 plan: PathBuf,
137 /// Id de objetivo del catálogo (`qemu-serial`, `servidor`, `metal-escritorio`, `portatil`).
138 #[arg(long)]
139 objective: String,
140 /// Hardware de la máquina DESTINO, capturado con `hammer kernel hw --out`. Sin esto se
141 /// mira el de esta máquina, que no siempre es la misma.
142 #[arg(long)]
143 devices: Option<PathBuf>,
144 #[arg(long)]
145 kconfig: Option<PathBuf>,
146 #[arg(long)]
147 catalog: Option<PathBuf>,
148 #[arg(long)]
149 json: bool,
150 },
151 /// Vuelca el hardware de esta máquina (huella + dispositivos + drivers bindeados).
152 ///
153 /// Se corre en la máquina DESTINO y el JSON viaja a la de build: el gate necesita saber qué
154 /// funciona allá, no acá.
155 Hw {
156 #[arg(long)]
157 out: Option<PathBuf>,
158 },
159 /// Estadísticas del árbol de Kconfig parseado (control de salud del lector).
160 Stats {
161 #[arg(long)]
162 kconfig: Option<PathBuf>,
163 /// Muestra las primeras N advertencias del parseo.
164 #[arg(long, default_value_t = 10)]
165 warnings: usize,
166 },
167}
168
169pub fn run(cmd: KernelCmd, store: &str) -> Result<()> {
170 match cmd {
171 KernelCmd::Stats { kconfig, warnings } => stats(&kconfig_root(kconfig)?, warnings),
172 KernelCmd::Probe {
173 config,
174 kconfig,
175 catalog,
176 no_hardware,
177 json,
178 } => probe(
179 config.as_deref(),
180 &kconfig_root(kconfig)?,
181 &catalog_path(catalog)?,
182 !no_hardware,
183 json,
184 ),
185 KernelCmd::Bundles {
186 kconfig,
187 catalog,
188 check,
189 json,
190 } => bundles(&kconfig_root(kconfig)?, &catalog_path(catalog)?, check, json),
191 KernelCmd::Plan {
192 recipe,
193 bundles: bs,
194 knobs,
195 kconfig,
196 catalog,
197 out,
198 recipe_out,
199 json,
200 } => plan(
201 &recipe,
202 &bs,
203 &knobs,
204 &kconfig_root(kconfig)?,
205 &catalog_path(catalog)?,
206 out.as_deref(),
207 recipe_out.as_deref(),
208 store,
209 json,
210 ),
211 KernelCmd::DiffBack { plan, config, json } => diff_back(&plan, &config, json),
212 KernelCmd::Gate {
213 plan,
214 objective,
215 devices,
216 kconfig,
217 catalog,
218 json,
219 } => gate(
220 &plan,
221 &objective,
222 devices.as_deref(),
223 &kconfig_root(kconfig)?,
224 &catalog_path(catalog)?,
225 json,
226 ),
227 KernelCmd::Hw { out } => hw(out.as_deref()),
228 KernelCmd::Closure {
229 symbols,
230 kconfig,
231 against,
232 fixpoint,
233 json,
234 limit,
235 } => closure(
236 &symbols,
237 &kconfig_root(kconfig)?,
238 against.as_deref(),
239 fixpoint,
240 json,
241 limit,
242 ),
243 }
244}
245
246/// Resuelve la raíz del árbol de Kconfig: `--kconfig` gana, si no la variable de entorno.
247fn kconfig_root(flag: Option<PathBuf>) -> Result<PathBuf> {
248 if let Some(p) = flag {
249 return Ok(p);
250 }
251 match std::env::var_os(KCONFIG_ENV) {
252 Some(v) => Ok(PathBuf::from(v)),
253 None => anyhow::bail!(
254 "falta el árbol de fuentes del kernel: pasá --kconfig <dir> o exportá {KCONFIG_ENV}"
255 ),
256 }
257}
258
259/// Resuelve el catálogo de bundles: `--catalog`, si no la variable de entorno, si no el del repo.
260fn catalog_path(flag: Option<PathBuf>) -> Result<PathBuf> {
261 if let Some(p) = flag {
262 return Ok(p);
263 }
264 if let Some(v) = std::env::var_os(CATALOG_ENV) {
265 return Ok(PathBuf::from(v));
266 }
267 for c in CATALOG_DEFAULTS {
268 let p = PathBuf::from(c);
269 if p.is_file() {
270 return Ok(p);
271 }
272 }
273 anyhow::bail!(
274 "no encontré el catálogo de bundles: pasá --catalog <path> o exportá {CATALOG_ENV}"
275 )
276}
277
278/// Dónde está el `.config` del kernel que corre. `/proc/config.gz` es el camino soberano (lo emite
279/// el propio kernel); el de `/boot` es el respaldo para kernels compilados sin `IKCONFIG_PROC`.
280fn running_config_path() -> Result<PathBuf> {
281 let gz = PathBuf::from("/proc/config.gz");
282 if gz.is_file() {
283 return Ok(gz);
284 }
285 if let Ok(out) = std::process::Command::new("uname").arg("-r").output() {
286 let rel = String::from_utf8_lossy(&out.stdout).trim().to_string();
287 let p = PathBuf::from(format!("/boot/config-{rel}"));
288 if p.is_file() {
289 return Ok(p);
290 }
291 }
292 anyhow::bail!(
293 "no encontré el config del kernel vivo (ni /proc/config.gz ni /boot/config-<release>).\n\
294 Pasá --config <path> para mirar el de otra máquina."
295 )
296}
297
298fn load_tree(root: &Path) -> Result<KconfigTree> {
299 if !root.join("Kconfig").is_file() {
300 anyhow::bail!(
301 "{} no parece un árbol de fuentes de kernel (falta ./Kconfig).\n\
302 Pasá --kconfig <dir> o exportá {KCONFIG_ENV}.",
303 root.display()
304 );
305 }
306 KconfigTree::parse(root, &KconfigTree::vars_x86_64())
307 .with_context(|| format!("parseando {}", root.display()))
308}
309
310fn stats(root: &Path, show_warnings: usize) -> Result<()> {
311 let t = load_tree(root)?;
312 let visible = t.symbols.values().filter(|s| s.is_visible()).count();
313 let multi = t.symbols.values().filter(|s| s.defs.len() > 1).count();
314 let with_help = t.symbols.values().filter(|s| s.help().is_some()).count();
315 let selects: usize = t.symbols.values().map(|s| s.select_targets().len()).sum();
316 let implies: usize = t.symbols.values().map(|s| s.imply_targets().len()).sum();
317
318 println!("árbol {}", root.display());
319 println!("ficheros {}", t.files.len());
320 println!("símbolos {}", t.symbols.len());
321 println!(" visibles {visible} (marcables en menuconfig)");
322 println!(" con ayuda {with_help}");
323 println!(" definidos en >1 sitio {multi}");
324 println!("aristas select {selects} · imply {implies}");
325 println!("avisos {}", t.warnings.len());
326 for w in t.warnings.iter().take(show_warnings) {
327 println!(" · {w}");
328 }
329 if t.warnings.len() > show_warnings {
330 println!(" … y {} más", t.warnings.len() - show_warnings);
331 }
332 Ok(())
333}
334
335#[derive(Serialize)]
336struct ClosureReport {
337 roots: Vec<String>,
338 /// Todo lo que queda inalcanzable si se apagan las raíces (incluidas las raíces).
339 closure: Vec<String>,
340 /// De la clausura, los que un humano puede marcar en `menuconfig`.
341 visible: usize,
342 /// `select` que entran desde fuera: reencienden algo de la clausura ignorando sus `depends on`.
343 leaks: Vec<hammer_core::kernel::SelectLeak>,
344 /// Símbolos distintos de la clausura alcanzados por esas fugas.
345 leaked_targets: usize,
346 #[serde(skip_serializing_if = "Option::is_none")]
347 fixpoint: Option<FixpointReport>,
348 #[serde(skip_serializing_if = "Option::is_none")]
349 against: Option<AgainstReport>,
350}
351
352/// El precio de cerrar el bundle contra sus fugas.
353#[derive(Serialize)]
354struct FixpointReport {
355 /// La clausura ya cerrada, entera.
356 closure: Vec<String>,
357 /// Tamaño de la clausura una vez cerrada.
358 closure_len: usize,
359 /// Cuánto creció respecto de la clausura estricta.
360 grew_by: usize,
361 rounds: usize,
362 /// Lo que hubo que apagar de más, con la ronda en que apareció.
363 added: Vec<(usize, String)>,
364 /// Fugas que sobrevivieron al tope de rondas. Vacío = cerró.
365 remaining_leaks: Vec<hammer_core::kernel::SelectLeak>,
366}
367
368/// Contraste contra un `.config` real: la validación §3 del SDD 22.
369#[derive(Serialize)]
370struct AgainstReport {
371 config: String,
372 /// De la clausura, los que el `.config` tiene ENCENDIDOS (la clausura predice de más, o el
373 /// config tiene una fuga real).
374 on_in_config: Vec<String>,
375 /// De la clausura, los que el `.config` apaga explícitamente.
376 off_in_config: usize,
377 /// De la clausura, los que el `.config` ni menciona.
378 absent_in_config: usize,
379}
380
381fn closure(
382 roots: &[String],
383 kroot: &Path,
384 against: Option<&Path>,
385 fixpoint: bool,
386 json: bool,
387 limit: usize,
388) -> Result<()> {
389 let t = load_tree(kroot)?;
390
391 // Avisar de raíces que no existen: un símbolo mal escrito daría clausura vacía en silencio,
392 // que es exactamente el modo de fallo que este comando existe para no tener.
393 let desconocidas: Vec<&String> = roots.iter().filter(|r| !t.symbols.contains_key(*r)).collect();
394 if !desconocidas.is_empty() {
395 eprintln!(
396 "aviso: {} símbolo(s) no existen en este árbol: {}",
397 desconocidas.len(),
398 desconocidas
399 .iter()
400 .map(|s| s.as_str())
401 .collect::<Vec<_>>()
402 .join(", ")
403 );
404 }
405
406 let cl = t.closure_off(roots);
407 let leaks = t.select_leaks(&cl);
408 let leaked_targets = leaks
409 .iter()
410 .map(|l| l.target.as_str())
411 .collect::<BTreeSet<_>>()
412 .len();
413 let visible = cl
414 .iter()
415 .filter(|s| t.symbols.get(*s).is_some_and(|s| s.is_visible()))
416 .count();
417
418 let fx = fixpoint.then(|| {
419 let f = t.closure_off_fixpoint(roots, 16);
420 FixpointReport {
421 closure_len: f.closure.len(),
422 closure: f.closure.iter().cloned().collect(),
423 grew_by: f.closure.len().saturating_sub(cl.len()),
424 rounds: f.rounds,
425 added: f.added,
426 remaining_leaks: f.remaining_leaks,
427 }
428 });
429
430 let against_report = match against {
431 None => None,
432 Some(p) => {
433 let text = read_maybe_gz(p)?;
434 let cfg = KernelConfig::parse(&text);
435 let on: Vec<String> = cl.iter().filter(|s| cfg.is_on(s)).cloned().collect();
436 let off = cl
437 .iter()
438 .filter(|s| matches!(cfg.get(s), Some(hammer_core::kernel::ConfigValue::No)))
439 .count();
440 let absent = cl.iter().filter(|s| cfg.get(s).is_none()).count();
441 Some(AgainstReport {
442 config: p.display().to_string(),
443 on_in_config: on,
444 off_in_config: off,
445 absent_in_config: absent,
446 })
447 }
448 };
449
450 let rep = ClosureReport {
451 roots: roots.to_vec(),
452 closure: cl.iter().cloned().collect(),
453 visible,
454 leaks,
455 leaked_targets,
456 fixpoint: fx,
457 against: against_report,
458 };
459
460 if json {
461 println!("{}", serde_json::to_string_pretty(&rep)?);
462 return Ok(());
463 }
464
465 println!("raíces apagadas {}", rep.roots.join(" "));
466 println!(
467 "clausura {} símbolo(s) ({} visibles en menuconfig)",
468 rep.closure.len(),
469 rep.visible
470 );
471 for s in rep.closure.iter().take(limit) {
472 let prompt = t
473 .symbols
474 .get(s)
475 .and_then(|x| x.prompt())
476 .unwrap_or("(sin prompt: sólo entra por select)");
477 println!(" · {s} — {prompt}");
478 }
479 if rep.closure.len() > limit {
480 println!(" … y {} más", rep.closure.len() - limit);
481 }
482
483 println!();
484 if rep.leaks.is_empty() {
485 println!("fugas por `select` ninguna — la clausura es cerrada");
486 } else {
487 println!(
488 "fugas por `select` {} arista(s) desde fuera reencienden {} símbolo(s) de la clausura",
489 rep.leaks.len(),
490 rep.leaked_targets
491 );
492 println!(" (`select` FUERZA el destino ignorando sus `depends on`: apagar la raíz no basta)");
493 let mut por_destino: BTreeMap<&str, Vec<&str>> = BTreeMap::new();
494 for l in &rep.leaks {
495 por_destino
496 .entry(&l.target)
497 .or_default()
498 .push(&l.selector);
499 }
500 for (target, selectors) in por_destino.iter().take(limit) {
501 println!(" · {target} ← {}", selectors.join(", "));
502 }
503 if por_destino.len() > limit {
504 println!(" … y {} destino(s) más", por_destino.len() - limit);
505 }
506 }
507
508 if let Some(f) = &rep.fixpoint {
509 println!();
510 println!(
511 "punto fijo {} símbolo(s) tras {} ronda(s) — {:+} sobre la clausura estricta",
512 f.closure_len, f.rounds, f.grew_by as i64
513 );
514 if f.remaining_leaks.is_empty() {
515 println!(" cerró: ninguna fuga sobrevive");
516 } else {
517 println!(
518 " ⚠ NO cerró: {} fuga(s) siguen abiertas tras el tope de rondas",
519 f.remaining_leaks.len()
520 );
521 }
522 if f.added.is_empty() {
523 println!(" no hizo falta apagar nada de más");
524 } else {
525 println!(" precio: {} símbolo(s) apagados de más", f.added.len());
526 for (r, s) in f.added.iter().take(limit) {
527 println!(" · ronda {r}: {s}");
528 }
529 if f.added.len() > limit {
530 println!(" … y {} más", f.added.len() - limit);
531 }
532 }
533 }
534
535 if let Some(a) = &rep.against {
536 println!();
537 println!("contraste contra {}", a.config);
538 println!(" apagados explícitos {}", a.off_in_config);
539 println!(" ausentes {}", a.absent_in_config);
540 println!(" ENCENDIDOS {}", a.on_in_config.len());
541 for s in a.on_in_config.iter().take(limit) {
542 println!(" · {s}");
543 }
544 if a.on_in_config.len() > limit {
545 println!(" … y {} más", a.on_in_config.len() - limit);
546 }
547 }
548 Ok(())
549}
550
551// ---------------------------------------------------------------------------------------------
552// probe — el modo reversa (#9)
553// ---------------------------------------------------------------------------------------------
554
555fn probe(
556 config: Option<&Path>,
557 kroot: &Path,
558 catalog: &Path,
559 with_hw: bool,
560 json: bool,
561) -> Result<()> {
562 use hammer_core::kernel::reverse::{BundleState, HwSignal};
563
564 let cfg_path = match config {
565 Some(p) => p.to_path_buf(),
566 None => running_config_path()?,
567 };
568 let cfg = KernelConfig::parse(&read_maybe_gz(&cfg_path)?);
569 if cfg.is_empty() {
570 anyhow::bail!("{} no tiene un solo CONFIG_*: ¿es un .config?", cfg_path.display());
571 }
572 let tree = load_tree(kroot)?;
573 let cat = hammer_core::kernel::Catalog::load(catalog)?;
574 let hw = with_hw.then(hammer_core::kernel::Hardware::probe_local);
575 let rep = hammer_core::kernel::reverse::analyze(&cat, &tree, &cfg, hw.as_ref());
576
577 if json {
578 println!("{}", serde_json::to_string_pretty(&rep)?);
579 return Ok(());
580 }
581
582 println!("config {}", cfg_path.display());
583 if let Some(k) = &rep.kernel {
584 println!("kernel {k}");
585 }
586 // El modo reversa cruza DOS árboles: el del config vivo y el que se parseó. Si no son la misma
587 // versión, las clausuras son aproximadas — un símbolo puede haberse renombrado o retirado entre
588 // medias (que es justo lo que le pasó a THUNDERBOLT y a REISERFS_FS). Vale igual, pero decirlo.
589 if let (Some(banner), Some(rev)) = (&rep.kernel, &cat.reviewed_against) {
590 if let (Some(a), Some(b)) = (serie(banner), serie(rev)) {
591 if a != b {
592 println!(
593 " ⚠ el config vivo es de la serie {a} y el catálogo se revisó contra \
594 la {b}: las clausuras son aproximadas"
595 );
596 }
597 }
598 }
599 println!("símbolos {} declarados", rep.config_symbols);
600 if let Some(h) = &hw {
601 println!("huella {}", rep.fingerprint);
602 if let Some(m) = &h.cpu_model {
603 println!(" {m}");
604 }
605 let maquina = [h.dmi.get("sys_vendor"), h.dmi.get("product_name")]
606 .into_iter()
607 .flatten()
608 .cloned()
609 .collect::<Vec<_>>()
610 .join(" ");
611 if !maquina.is_empty() {
612 println!(" {maquina}");
613 }
614 println!(
615 " {} dispositivos PCI · {} drivers bindeados",
616 h.pci.len(),
617 h.bound_drivers.len()
618 );
619 for g in &h.gaps {
620 println!(" ⚠ {g}");
621 }
622 }
623 println!();
624
625 for b in &rep.bundles {
626 let on = b.on_builtin + b.on_module;
627 let estado = match b.state {
628 BundleState::Aplicado => "APLICADO ",
629 BundleState::Parcial => "parcial ",
630 BundleState::Sin => "sin ",
631 };
632 let hwtxt = match &b.hardware {
633 HwSignal::SinSeñal => String::new(),
634 HwSignal::Ausente => " · hardware ausente".to_string(),
635 HwSignal::Presente { evidencia } => {
636 format!(" · ⚠ HARDWARE PRESENTE ({})", evidencia.len())
637 }
638 };
639 println!(
640 "{estado} {:<28} {on:>5}/{:<5} encendidos{hwtxt}",
641 b.id, b.closure_len
642 );
643 if let HwSignal::Presente { evidencia } = &b.hardware {
644 for e in evidencia.iter().take(4) {
645 println!(" {e}");
646 }
647 }
648 if !b.unknown_symbols.is_empty() {
649 println!(
650 " ⚠ símbolos que este kernel no tiene: {}",
651 b.unknown_symbols.join(", ")
652 );
653 }
654 if !b.undeclared_leaks.is_empty() {
655 println!(
656 " ⚠ {} fuga(s) `select` sin declarar en el catálogo",
657 b.undeclared_leaks.len()
658 );
659 }
660 }
661
662 println!();
663 println!(
664 "resumen {} bundle(s): {} ya aplicados, {} con capacidad que este hardware NO usa",
665 rep.bundles.len(),
666 rep.bundles
667 .iter()
668 .filter(|b| b.state == BundleState::Aplicado)
669 .count(),
670 rep.applicable_savings
671 );
672 if rep.contradictions > 0 {
673 println!(
674 " ⚠ {} bundle(s) aplicados CONTRADICEN al hardware presente",
675 rep.contradictions
676 );
677 }
678 println!(" (detectar sirve para contradecir, nunca para podar solo)");
679 Ok(())
680}
681
682/// Serie `mayor.menor` de un texto que contenga una versión (`"Linux/x86 7.1.4-artix1 …"` → `7.1`,
683/// `"linux-6.16.12"` → `6.16`). `None` si no hay ninguna.
684fn serie(texto: &str) -> Option<String> {
685 for tok in texto.split(|c: char| !(c.is_ascii_digit() || c == '.')) {
686 let mut it = tok.split('.');
687 if let (Some(a), Some(b)) = (it.next(), it.next()) {
688 if !a.is_empty() && !b.is_empty() {
689 return Some(format!("{a}.{b}"));
690 }
691 }
692 }
693 None
694}
695
696// ---------------------------------------------------------------------------------------------
697// bundles — catálogo + control de frescura
698// ---------------------------------------------------------------------------------------------
699
700#[derive(Serialize)]
701struct BundleView {
702 id: String,
703 title: String,
704 #[serde(skip_serializing_if = "Option::is_none")]
705 help: Option<String>,
706 disable: Vec<String>,
707 close_leaks: Vec<String>,
708 closure_len: usize,
709 visible: usize,
710 unknown_symbols: Vec<String>,
711 undeclared_leaks: Vec<hammer_core::kernel::SelectLeak>,
712 accepted_leaks: usize,
713}
714
715#[derive(Serialize)]
716struct BundlesReport {
717 catalog: String,
718 #[serde(skip_serializing_if = "Option::is_none")]
719 reviewed_against: Option<String>,
720 bundles: Vec<BundleView>,
721 knobs: Vec<KnobView>,
722 stale: bool,
723}
724
725#[derive(Serialize)]
726struct KnobView {
727 id: String,
728 title: String,
729 side: hammer_core::kernel::Side,
730 enable: Vec<String>,
731 disable: Vec<String>,
732 #[serde(skip_serializing_if = "Option::is_none")]
733 recipe_field: Option<String>,
734 unknown_symbols: Vec<String>,
735}
736
737fn bundles(kroot: &Path, catalog: &Path, check: bool, json: bool) -> Result<()> {
738 let tree = load_tree(kroot)?;
739 let cat = hammer_core::kernel::Catalog::load(catalog)?;
740
741 let mut views = Vec::new();
742 for b in &cat.bundles {
743 let mut roots = b.disable.clone();
744 roots.extend(b.close_leaks.iter().cloned());
745 let unknown: Vec<String> = roots
746 .iter()
747 .chain(b.enable.iter())
748 .filter(|s| !tree.symbols.contains_key(*s))
749 .cloned()
750 .collect();
751 let cl = tree.closure_off(&roots);
752 let aceptadas: BTreeSet<&str> = b.accept_leaks.keys().map(|s| s.as_str()).collect();
753 let undeclared: Vec<_> = tree
754 .select_leaks(&cl)
755 .into_iter()
756 .filter(|l| !aceptadas.contains(l.target.as_str()))
757 .collect();
758 views.push(BundleView {
759 id: b.id.clone(),
760 title: b.title.clone(),
761 help: b.help.clone(),
762 disable: b.disable.clone(),
763 close_leaks: b.close_leaks.clone(),
764 closure_len: cl.len(),
765 visible: cl
766 .iter()
767 .filter(|s| tree.symbols.get(*s).is_some_and(|x| x.is_visible()))
768 .count(),
769 unknown_symbols: unknown,
770 undeclared_leaks: undeclared,
771 accepted_leaks: b.accept_leaks.len(),
772 });
773 }
774
775 let knobs: Vec<KnobView> = cat
776 .knobs
777 .iter()
778 .map(|k| KnobView {
779 id: k.id.clone(),
780 title: k.title.clone(),
781 side: k.side,
782 enable: k.enable.clone(),
783 disable: k.disable.clone(),
784 recipe_field: k.recipe_field.clone(),
785 unknown_symbols: k
786 .enable
787 .iter()
788 .chain(k.disable.iter())
789 .filter(|s| !tree.symbols.contains_key(*s))
790 .cloned()
791 .collect(),
792 })
793 .collect();
794
795 let stale = views
796 .iter()
797 .any(|v| !v.unknown_symbols.is_empty() || !v.undeclared_leaks.is_empty())
798 || knobs.iter().any(|k| !k.unknown_symbols.is_empty());
799
800 let rep = BundlesReport {
801 catalog: catalog.display().to_string(),
802 reviewed_against: cat.reviewed_against.clone(),
803 bundles: views,
804 knobs,
805 stale,
806 };
807
808 if json {
809 println!("{}", serde_json::to_string_pretty(&rep)?);
810 } else {
811 println!("catálogo {}", rep.catalog);
812 if let Some(r) = &rep.reviewed_against {
813 println!("revisado contra {r}");
814 }
815 println!("árbol {}", kroot.display());
816 println!();
817 println!("NIVEL 1 — aserciones del dueño de la máquina");
818 for v in &rep.bundles {
819 println!(
820 " {:<26} {:>5} símbolos ({} visibles) ← {}",
821 v.id,
822 v.closure_len,
823 v.visible,
824 v.disable.join(" ")
825 );
826 println!(" {}", v.title);
827 if !v.close_leaks.is_empty() {
828 println!(" fugas cerradas: {}", v.close_leaks.join(", "));
829 }
830 if v.accepted_leaks > 0 {
831 println!(" fugas aceptadas a sabiendas: {}", v.accepted_leaks);
832 }
833 if !v.unknown_symbols.is_empty() {
834 println!(
835 " ⚠ NO EXISTEN en este árbol: {}",
836 v.unknown_symbols.join(", ")
837 );
838 }
839 for l in v.undeclared_leaks.iter().take(6) {
840 println!(" ⚠ fuga sin declarar: {} ← {}", l.target, l.selector);
841 }
842 if v.undeclared_leaks.len() > 6 {
843 println!(" ⚠ … y {} fuga(s) más", v.undeclared_leaks.len() - 6);
844 }
845 }
846 println!();
847 println!("NIVEL 2 — política y rendimiento (ojo al lado del que cae cada perilla)");
848 for k in &rep.knobs {
849 let lado = match k.side {
850 hammer_core::kernel::Side::Kconfig => "kconfig",
851 hammer_core::kernel::Side::Recipe => "receta ",
852 };
853 let que = match k.side {
854 hammer_core::kernel::Side::Kconfig => {
855 let mut v: Vec<String> =
856 k.enable.iter().map(|s| format!("+{s}")).collect();
857 v.extend(k.disable.iter().map(|s| format!("-{s}")));
858 v.join(" ")
859 }
860 hammer_core::kernel::Side::Recipe => {
861 k.recipe_field.clone().unwrap_or_default()
862 }
863 };
864 println!(" [{lado}] {:<26} {que}", k.id);
865 println!(" {}", k.title);
866 if !k.unknown_symbols.is_empty() {
867 println!(
868 " ⚠ NO EXISTEN en este árbol: {}",
869 k.unknown_symbols.join(", ")
870 );
871 }
872 }
873 println!();
874 if rep.stale {
875 println!("⚠ el catálogo ENVEJECIÓ respecto de este árbol (símbolos idos o fugas nuevas)");
876 } else {
877 println!("✓ el catálogo está al día con este árbol");
878 }
879 }
880
881 if check && rep.stale {
882 anyhow::bail!("catálogo desactualizado");
883 }
884 Ok(())
885}
886
887// ---------------------------------------------------------------------------------------------
888// plan / diff-back
889// ---------------------------------------------------------------------------------------------
890
891#[allow(clippy::too_many_arguments)]
892fn plan(
893 recipe_path: &Path,
894 bundle_ids: &[String],
895 knob_ids: &[String],
896 kroot: &Path,
897 catalog: &Path,
898 out: Option<&Path>,
899 recipe_out: Option<&Path>,
900 store: &str,
901 json: bool,
902) -> Result<()> {
903 if bundle_ids.is_empty() && knob_ids.is_empty() {
904 anyhow::bail!("un plan sin bundles ni perillas es la receta base: pasá --bundle/--knob");
905 }
906 let base = hammer_core::Recipe::load_from_path(recipe_path)?;
907 let tree = load_tree(kroot)?;
908 let cat = hammer_core::kernel::Catalog::load(catalog)?;
909
910 // Las recetas de kernel del repo NO son todas la misma versión (`linux`/`linux-metal` van por
911 // 6.16.12; `linux-metal-dual`/`linux-generic` por 7.1.2). Planear una contra el árbol de la otra
912 // calcularía clausuras sobre símbolos que ahí no existen, y saldría SIN RUIDO. Como el plan
913 // determina un artefacto, acá se falla en vez de avisar.
914 match tree.version.as_deref() {
915 Some(v) if v != base.version => anyhow::bail!(
916 "la receta «{}» es del kernel {} y el árbol de {} es el {v}.\n\
917 Las clausuras saldrían calculadas sobre otro juego de símbolos. Pasá el árbol correcto \
918 con --kconfig.",
919 base.name,
920 base.version,
921 kroot.display()
922 ),
923 None => eprintln!(
924 "aviso: {} no trae el Makefile de arriba, así que no puedo comprobar que sea el kernel \
925 {} de la receta «{}»",
926 kroot.display(),
927 base.version,
928 base.name
929 ),
930 Some(_) => {}
931 }
932
933 let mut p = hammer_core::kernel::plan::make_plan(
934 &base,
935 &recipe_path.display().to_string(),
936 &cat,
937 &tree,
938 bundle_ids,
939 knob_ids,
940 )?;
941
942 // El plan DETERMINA el artefacto (SDD 22 §1), así que el hash va en el plan: la UI puede decir
943 // «esto ya está construido y firmado» sin construir nada. Si las deps no están resueltas, se
944 // dice que no se pudo en vez de emitir un hash inventado.
945 let derived = hammer_core::kernel::plan::derive_recipe(&base, &p);
946 match hammer_core::Store::open(Path::new(store))
947 .map_err(anyhow::Error::from)
948 .and_then(|s| hammer_build::artifact_hash(&derived, &s).map_err(anyhow::Error::from))
949 {
950 Ok(h) => p.artifact_hash = Some(h.to_string()),
951 Err(e) => eprintln!("aviso: no pude calcular el ArtifactHash ({e})"),
952 }
953
954 if let Some(o) = out {
955 if let Some(d) = o.parent() {
956 std::fs::create_dir_all(d).ok();
957 }
958 std::fs::write(o, serde_json::to_string_pretty(&p)?)
959 .with_context(|| format!("escribiendo {}", o.display()))?;
960 }
961 if let Some(ro) = recipe_out {
962 if let Some(d) = ro.parent() {
963 std::fs::create_dir_all(d).ok();
964 }
965 let cabecera = format!(
966 "# Receta DERIVADA, generada por `hammer kernel plan`. No editar a mano: regenerar.\n\
967 # base {}\n# bundles {}\n# perillas {}\n\
968 # Para construirla tiene que vivir junto a la receta base, o sus `deps.build` no\n\
969 # resuelven ({}).\n",
970 p.base_recipe,
971 if p.bundles.is_empty() { "—".into() } else { p.bundles.join(" ") },
972 if p.knobs.is_empty() { "—".into() } else { p.knobs.join(" ") },
973 recipe_path
974 .parent()
975 .map(|d| d.display().to_string())
976 .unwrap_or_default(),
977 );
978 let cuerpo = hammer_core::kernel::plan::render_recipe_toml(&derived)?;
979 std::fs::write(ro, format!("{cabecera}{cuerpo}"))
980 .with_context(|| format!("escribiendo {}", ro.display()))?;
981 }
982
983 if json {
984 println!("{}", serde_json::to_string_pretty(&p)?);
985 return Ok(());
986 }
987
988 println!("base {} ({})", p.base_name, p.base_recipe);
989 println!("derivada {}", p.derived_name);
990 match &p.artifact_hash {
991 Some(h) => println!("hash {h}"),
992 None => println!("hash (no calculado)"),
993 }
994 println!(
995 "selección {} bundle(s), {} perilla(s)",
996 p.bundles.len(),
997 p.knobs.len()
998 );
999 println!();
1000 println!("emite {} bandera(s); con su clausura caen {} símbolo(s)", p.symbols.len(), p.closure_size);
1001 println!("(se emiten RAÍCES: la clausura la calcula el olddefconfig del propio kernel)");
1002 for s in &p.symbols {
1003 let signo = match s.action {
1004 hammer_core::kernel::plan::Action::Enable => "+",
1005 hammer_core::kernel::plan::Action::Disable => "-",
1006 };
1007 println!(" {signo}{:<24} ← {} ({})", s.symbol, s.from, s.reason);
1008 }
1009 if !p.unknown_symbols.is_empty() {
1010 println!();
1011 println!("⚠ {} símbolo(s) pedidos NO EXISTEN en este árbol y no se emiten:", p.unknown_symbols.len());
1012 for s in &p.unknown_symbols {
1013 println!(" {} ← {}", s.symbol, s.from);
1014 }
1015 }
1016 if !p.recipe_side.is_empty() {
1017 println!();
1018 println!("perillas del lado RECETA (declaradas, este plan no las aplica):");
1019 for k in &p.recipe_side {
1020 println!(" {:<24} {} — {}", k.id, k.recipe_field, k.title);
1021 }
1022 }
1023 println!();
1024 if let Some(o) = out {
1025 println!("plan → {}", o.display());
1026 }
1027 if let Some(ro) = recipe_out {
1028 println!("receta → {}", ro.display());
1029 }
1030 Ok(())
1031}
1032
1033fn diff_back(plan_path: &Path, config: &Path, json: bool) -> Result<()> {
1034 let p: hammer_core::kernel::Plan = serde_json::from_str(
1035 &std::fs::read_to_string(plan_path)
1036 .with_context(|| format!("leyendo {}", plan_path.display()))?,
1037 )
1038 .with_context(|| format!("{} no es un plan de `hammer kernel plan`", plan_path.display()))?;
1039 let cfg = KernelConfig::parse(&read_maybe_gz(config)?);
1040 if cfg.is_empty() {
1041 anyhow::bail!("{} no tiene un solo CONFIG_*: ¿es un .config?", config.display());
1042 }
1043 let d = hammer_core::kernel::diff_back(&p, &cfg);
1044
1045 if json {
1046 println!("{}", serde_json::to_string_pretty(&d)?);
1047 } else {
1048 println!("plan {} ({})", p.derived_name, plan_path.display());
1049 println!("config {}", config.display());
1050 println!("cumplidos {}", d.honored.len());
1051 // Kconfig no emite un símbolo cuyas dependencias no se cumplen: para un `-d`, que no
1052 // aparezca ES el resultado buscado. Contarlo como fallo reportaba planes perfectos como
1053 // rotos — lo aprendí corriendo un olddefconfig de verdad.
1054 println!("apagados por dependencia {} (no aparecen porque cayeron con su raíz)", d.off_by_dependency.len());
1055 if !d.never_existed.is_empty() {
1056 println!("no existen en este árbol {}", d.never_existed.len());
1057 for s in &d.never_existed {
1058 println!(" {s} — la bandera no hace nada");
1059 }
1060 }
1061 println!("INCUMPLIDOS {}", d.violated.len());
1062 for v in &d.violated {
1063 println!(
1064 " {} — se pidió {:?} y quedó {} (lo pidió {})",
1065 v.symbol, v.asked, v.got, v.from
1066 );
1067 }
1068 }
1069 if !d.is_clean() {
1070 anyhow::bail!("el .config no honra el plan");
1071 }
1072 Ok(())
1073}
1074
1075// ---------------------------------------------------------------------------------------------
1076// gate / hw
1077// ---------------------------------------------------------------------------------------------
1078
1079fn hw(out: Option<&Path>) -> Result<()> {
1080 let h = hammer_core::kernel::Hardware::probe_local();
1081 let texto = serde_json::to_string_pretty(&h)?;
1082 match out {
1083 Some(o) => {
1084 if let Some(d) = o.parent() {
1085 std::fs::create_dir_all(d).ok();
1086 }
1087 std::fs::write(o, &texto).with_context(|| format!("escribiendo {}", o.display()))?;
1088 println!("huella {}", h.fingerprint());
1089 println!("hardware → {}", o.display());
1090 println!(" {} PCI · {} USB · {} drivers bindeados", h.pci.len(), h.usb.len(), h.bound_drivers.len());
1091 for g in &h.gaps {
1092 println!(" ⚠ {g}");
1093 }
1094 }
1095 None => println!("{texto}"),
1096 }
1097 Ok(())
1098}
1099
1100fn gate(
1101 plan_path: &Path,
1102 objective: &str,
1103 devices: Option<&Path>,
1104 kroot: &Path,
1105 catalog: &Path,
1106 json: bool,
1107) -> Result<()> {
1108 let p: hammer_core::kernel::Plan = serde_json::from_str(
1109 &std::fs::read_to_string(plan_path)
1110 .with_context(|| format!("leyendo {}", plan_path.display()))?,
1111 )
1112 .with_context(|| format!("{} no es un plan de `hammer kernel plan`", plan_path.display()))?;
1113 let cat = hammer_core::kernel::Catalog::load(catalog)?;
1114 let tree = load_tree(kroot)?;
1115 let modmap = hammer_core::kernel::ModMap::parse(kroot)
1116 .with_context(|| format!("leyendo los Makefiles de {}", kroot.display()))?;
1117 if modmap.is_empty() {
1118 anyhow::bail!(
1119 "no encontré una sola regla `obj-$(CONFIG_…)` en {}: sin los Makefiles del kernel el \
1120 gate no puede mapear driver → símbolo, y un gate que no puede mirar no es un gate.",
1121 kroot.display()
1122 );
1123 }
1124
1125 let hardware = match devices {
1126 Some(d) => serde_json::from_str::<hammer_core::kernel::Hardware>(
1127 &std::fs::read_to_string(d).with_context(|| format!("leyendo {}", d.display()))?,
1128 )
1129 .with_context(|| format!("{} no es un volcado de `hammer kernel hw`", d.display()))?,
1130 None => hammer_core::kernel::Hardware::probe_local(),
1131 };
1132
1133 let r = hammer_core::kernel::gate::run(
1134 &p,
1135 &cat,
1136 &tree,
1137 &modmap,
1138 &hardware.bound_drivers,
1139 objective,
1140 )?;
1141
1142 if json {
1143 println!("{}", serde_json::to_string_pretty(&r)?);
1144 } else {
1145 let obj = cat.objective(objective).map(|o| o.title.as_str()).unwrap_or("");
1146 println!("plan {}", r.plan);
1147 println!("objetivo {} — {obj}", r.objective);
1148 println!(
1149 "hardware {} ({} drivers bindeados, {} reglas obj- en {} Makefiles)",
1150 devices.map(|d| d.display().to_string()).unwrap_or_else(|| "esta máquina".into()),
1151 r.drivers_in_use,
1152 modmap.len(),
1153 modmap.files
1154 );
1155 println!();
1156 if r.regressions.is_empty() {
1157 println!("✓ ningún dispositivo en uso se queda sin driver");
1158 } else {
1159 println!("✗ {} REGRESIÓN(ES) — dispositivos en uso que se quedarían sin driver:", r.regressions.len());
1160 for l in &r.regressions {
1161 println!(
1162 " {:<20} lo apaga {} (símbolos: {})",
1163 l.driver,
1164 if l.blamed_on.is_empty() { "una perilla".to_string() } else { l.blamed_on.join(", ") },
1165 l.symbols.join(", ")
1166 );
1167 }
1168 }
1169 if !r.allowed.is_empty() {
1170 println!();
1171 println!("{} pérdida(s) AUTORIZADAS por el objetivo:", r.allowed.len());
1172 for l in &r.allowed {
1173 println!(" {:<20} ← {}", l.driver, l.blamed_on.join(", "));
1174 }
1175 }
1176 if !r.unmapped.is_empty() {
1177 println!();
1178 println!(
1179 "⚠ {} driver(s) SIN COMPROBAR (no se pudieron mapear a ningún CONFIG_*):",
1180 r.unmapped.len()
1181 );
1182 println!(" {}", r.unmapped.join(" "));
1183 println!(" no cuentan como aprobados: el gate no los miró.");
1184 }
1185 }
1186
1187 if r.verdict == hammer_core::kernel::Verdict::Bloquea {
1188 anyhow::bail!("el gate bloquea");
1189 }
1190 Ok(())
1191}
1192
1193/// Lee un `.config`, descomprimiendo si es `.gz` (el caso de `/proc/config.gz`).
1194///
1195/// Se delega a `gzip -dc` en vez de arrastrar un descompresor al core: es una entrada de
1196/// diagnóstico, no del camino de build, y el core queda sin dependencias nuevas.
1197pub fn read_maybe_gz(p: &Path) -> Result<String> {
1198 if p.extension().is_some_and(|e| e == "gz") {
1199 let out = std::process::Command::new("gzip")
1200 .arg("-dc")
1201 .arg(p)
1202 .output()
1203 .with_context(|| format!("descomprimiendo {}", p.display()))?;
1204 if !out.status.success() {
1205 anyhow::bail!(
1206 "gzip -dc {} falló: {}",
1207 p.display(),
1208 String::from_utf8_lossy(&out.stderr).trim()
1209 );
1210 }
1211 return Ok(String::from_utf8_lossy(&out.stdout).into_owned());
1212 }
1213 std::fs::read_to_string(p).with_context(|| format!("leyendo {}", p.display()))
1214}
1215
1216#[cfg(test)]
1217mod tests {
1218 use super::*;
1219
1220 #[test]
1221 fn serie_de_las_dos_formas_que_se_cruzan() {
1222 assert_eq!(serie("Linux/x86 7.1.4-artix1 Kernel Configuration").as_deref(), Some("7.1"));
1223 assert_eq!(serie("linux-6.16.12").as_deref(), Some("6.16"));
1224 assert_eq!(serie("sin versión"), None);
1225 }
1226}