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…
⎇ cf2cf54914e5917613be71ffb49109894c1791c7
crates/hammer-cli/src/kernel_cmd.rs
Ñawpa willay
Pi imata llamkarqan
ch'uya
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 /// Estadísticas del árbol de Kconfig parseado (control de salud del lector).
88 Stats {
89 #[arg(long)]
90 kconfig: Option<PathBuf>,
91 /// Muestra las primeras N advertencias del parseo.
92 #[arg(long, default_value_t = 10)]
93 warnings: usize,
94 },
95}
96
97pub fn run(cmd: KernelCmd) -> Result<()> {
98 match cmd {
99 KernelCmd::Stats { kconfig, warnings } => stats(&kconfig_root(kconfig)?, warnings),
100 KernelCmd::Probe {
101 config,
102 kconfig,
103 catalog,
104 no_hardware,
105 json,
106 } => probe(
107 config.as_deref(),
108 &kconfig_root(kconfig)?,
109 &catalog_path(catalog)?,
110 !no_hardware,
111 json,
112 ),
113 KernelCmd::Bundles {
114 kconfig,
115 catalog,
116 check,
117 json,
118 } => bundles(&kconfig_root(kconfig)?, &catalog_path(catalog)?, check, json),
119 KernelCmd::Closure {
120 symbols,
121 kconfig,
122 against,
123 fixpoint,
124 json,
125 limit,
126 } => closure(
127 &symbols,
128 &kconfig_root(kconfig)?,
129 against.as_deref(),
130 fixpoint,
131 json,
132 limit,
133 ),
134 }
135}
136
137/// Resuelve la raíz del árbol de Kconfig: `--kconfig` gana, si no la variable de entorno.
138fn kconfig_root(flag: Option<PathBuf>) -> Result<PathBuf> {
139 if let Some(p) = flag {
140 return Ok(p);
141 }
142 match std::env::var_os(KCONFIG_ENV) {
143 Some(v) => Ok(PathBuf::from(v)),
144 None => anyhow::bail!(
145 "falta el árbol de fuentes del kernel: pasá --kconfig <dir> o exportá {KCONFIG_ENV}"
146 ),
147 }
148}
149
150/// Resuelve el catálogo de bundles: `--catalog`, si no la variable de entorno, si no el del repo.
151fn catalog_path(flag: Option<PathBuf>) -> Result<PathBuf> {
152 if let Some(p) = flag {
153 return Ok(p);
154 }
155 if let Some(v) = std::env::var_os(CATALOG_ENV) {
156 return Ok(PathBuf::from(v));
157 }
158 for c in CATALOG_DEFAULTS {
159 let p = PathBuf::from(c);
160 if p.is_file() {
161 return Ok(p);
162 }
163 }
164 anyhow::bail!(
165 "no encontré el catálogo de bundles: pasá --catalog <path> o exportá {CATALOG_ENV}"
166 )
167}
168
169/// Dónde está el `.config` del kernel que corre. `/proc/config.gz` es el camino soberano (lo emite
170/// el propio kernel); el de `/boot` es el respaldo para kernels compilados sin `IKCONFIG_PROC`.
171fn running_config_path() -> Result<PathBuf> {
172 let gz = PathBuf::from("/proc/config.gz");
173 if gz.is_file() {
174 return Ok(gz);
175 }
176 if let Ok(out) = std::process::Command::new("uname").arg("-r").output() {
177 let rel = String::from_utf8_lossy(&out.stdout).trim().to_string();
178 let p = PathBuf::from(format!("/boot/config-{rel}"));
179 if p.is_file() {
180 return Ok(p);
181 }
182 }
183 anyhow::bail!(
184 "no encontré el config del kernel vivo (ni /proc/config.gz ni /boot/config-<release>).\n\
185 Pasá --config <path> para mirar el de otra máquina."
186 )
187}
188
189fn load_tree(root: &Path) -> Result<KconfigTree> {
190 if !root.join("Kconfig").is_file() {
191 anyhow::bail!(
192 "{} no parece un árbol de fuentes de kernel (falta ./Kconfig).\n\
193 Pasá --kconfig <dir> o exportá {KCONFIG_ENV}.",
194 root.display()
195 );
196 }
197 KconfigTree::parse(root, &KconfigTree::vars_x86_64())
198 .with_context(|| format!("parseando {}", root.display()))
199}
200
201fn stats(root: &Path, show_warnings: usize) -> Result<()> {
202 let t = load_tree(root)?;
203 let visible = t.symbols.values().filter(|s| s.is_visible()).count();
204 let multi = t.symbols.values().filter(|s| s.defs.len() > 1).count();
205 let with_help = t.symbols.values().filter(|s| s.help().is_some()).count();
206 let selects: usize = t.symbols.values().map(|s| s.select_targets().len()).sum();
207 let implies: usize = t.symbols.values().map(|s| s.imply_targets().len()).sum();
208
209 println!("árbol {}", root.display());
210 println!("ficheros {}", t.files.len());
211 println!("símbolos {}", t.symbols.len());
212 println!(" visibles {visible} (marcables en menuconfig)");
213 println!(" con ayuda {with_help}");
214 println!(" definidos en >1 sitio {multi}");
215 println!("aristas select {selects} · imply {implies}");
216 println!("avisos {}", t.warnings.len());
217 for w in t.warnings.iter().take(show_warnings) {
218 println!(" · {w}");
219 }
220 if t.warnings.len() > show_warnings {
221 println!(" … y {} más", t.warnings.len() - show_warnings);
222 }
223 Ok(())
224}
225
226#[derive(Serialize)]
227struct ClosureReport {
228 roots: Vec<String>,
229 /// Todo lo que queda inalcanzable si se apagan las raíces (incluidas las raíces).
230 closure: Vec<String>,
231 /// De la clausura, los que un humano puede marcar en `menuconfig`.
232 visible: usize,
233 /// `select` que entran desde fuera: reencienden algo de la clausura ignorando sus `depends on`.
234 leaks: Vec<hammer_core::kernel::SelectLeak>,
235 /// Símbolos distintos de la clausura alcanzados por esas fugas.
236 leaked_targets: usize,
237 #[serde(skip_serializing_if = "Option::is_none")]
238 fixpoint: Option<FixpointReport>,
239 #[serde(skip_serializing_if = "Option::is_none")]
240 against: Option<AgainstReport>,
241}
242
243/// El precio de cerrar el bundle contra sus fugas.
244#[derive(Serialize)]
245struct FixpointReport {
246 /// La clausura ya cerrada, entera.
247 closure: Vec<String>,
248 /// Tamaño de la clausura una vez cerrada.
249 closure_len: usize,
250 /// Cuánto creció respecto de la clausura estricta.
251 grew_by: usize,
252 rounds: usize,
253 /// Lo que hubo que apagar de más, con la ronda en que apareció.
254 added: Vec<(usize, String)>,
255 /// Fugas que sobrevivieron al tope de rondas. Vacío = cerró.
256 remaining_leaks: Vec<hammer_core::kernel::SelectLeak>,
257}
258
259/// Contraste contra un `.config` real: la validación §3 del SDD 22.
260#[derive(Serialize)]
261struct AgainstReport {
262 config: String,
263 /// De la clausura, los que el `.config` tiene ENCENDIDOS (la clausura predice de más, o el
264 /// config tiene una fuga real).
265 on_in_config: Vec<String>,
266 /// De la clausura, los que el `.config` apaga explícitamente.
267 off_in_config: usize,
268 /// De la clausura, los que el `.config` ni menciona.
269 absent_in_config: usize,
270}
271
272fn closure(
273 roots: &[String],
274 kroot: &Path,
275 against: Option<&Path>,
276 fixpoint: bool,
277 json: bool,
278 limit: usize,
279) -> Result<()> {
280 let t = load_tree(kroot)?;
281
282 // Avisar de raíces que no existen: un símbolo mal escrito daría clausura vacía en silencio,
283 // que es exactamente el modo de fallo que este comando existe para no tener.
284 let desconocidas: Vec<&String> = roots.iter().filter(|r| !t.symbols.contains_key(*r)).collect();
285 if !desconocidas.is_empty() {
286 eprintln!(
287 "aviso: {} símbolo(s) no existen en este árbol: {}",
288 desconocidas.len(),
289 desconocidas
290 .iter()
291 .map(|s| s.as_str())
292 .collect::<Vec<_>>()
293 .join(", ")
294 );
295 }
296
297 let cl = t.closure_off(roots);
298 let leaks = t.select_leaks(&cl);
299 let leaked_targets = leaks
300 .iter()
301 .map(|l| l.target.as_str())
302 .collect::<BTreeSet<_>>()
303 .len();
304 let visible = cl
305 .iter()
306 .filter(|s| t.symbols.get(*s).is_some_and(|s| s.is_visible()))
307 .count();
308
309 let fx = fixpoint.then(|| {
310 let f = t.closure_off_fixpoint(roots, 16);
311 FixpointReport {
312 closure_len: f.closure.len(),
313 closure: f.closure.iter().cloned().collect(),
314 grew_by: f.closure.len().saturating_sub(cl.len()),
315 rounds: f.rounds,
316 added: f.added,
317 remaining_leaks: f.remaining_leaks,
318 }
319 });
320
321 let against_report = match against {
322 None => None,
323 Some(p) => {
324 let text = read_maybe_gz(p)?;
325 let cfg = KernelConfig::parse(&text);
326 let on: Vec<String> = cl.iter().filter(|s| cfg.is_on(s)).cloned().collect();
327 let off = cl
328 .iter()
329 .filter(|s| matches!(cfg.get(s), Some(hammer_core::kernel::ConfigValue::No)))
330 .count();
331 let absent = cl.iter().filter(|s| cfg.get(s).is_none()).count();
332 Some(AgainstReport {
333 config: p.display().to_string(),
334 on_in_config: on,
335 off_in_config: off,
336 absent_in_config: absent,
337 })
338 }
339 };
340
341 let rep = ClosureReport {
342 roots: roots.to_vec(),
343 closure: cl.iter().cloned().collect(),
344 visible,
345 leaks,
346 leaked_targets,
347 fixpoint: fx,
348 against: against_report,
349 };
350
351 if json {
352 println!("{}", serde_json::to_string_pretty(&rep)?);
353 return Ok(());
354 }
355
356 println!("raíces apagadas {}", rep.roots.join(" "));
357 println!(
358 "clausura {} símbolo(s) ({} visibles en menuconfig)",
359 rep.closure.len(),
360 rep.visible
361 );
362 for s in rep.closure.iter().take(limit) {
363 let prompt = t
364 .symbols
365 .get(s)
366 .and_then(|x| x.prompt())
367 .unwrap_or("(sin prompt: sólo entra por select)");
368 println!(" · {s} — {prompt}");
369 }
370 if rep.closure.len() > limit {
371 println!(" … y {} más", rep.closure.len() - limit);
372 }
373
374 println!();
375 if rep.leaks.is_empty() {
376 println!("fugas por `select` ninguna — la clausura es cerrada");
377 } else {
378 println!(
379 "fugas por `select` {} arista(s) desde fuera reencienden {} símbolo(s) de la clausura",
380 rep.leaks.len(),
381 rep.leaked_targets
382 );
383 println!(" (`select` FUERZA el destino ignorando sus `depends on`: apagar la raíz no basta)");
384 let mut por_destino: BTreeMap<&str, Vec<&str>> = BTreeMap::new();
385 for l in &rep.leaks {
386 por_destino
387 .entry(&l.target)
388 .or_default()
389 .push(&l.selector);
390 }
391 for (target, selectors) in por_destino.iter().take(limit) {
392 println!(" · {target} ← {}", selectors.join(", "));
393 }
394 if por_destino.len() > limit {
395 println!(" … y {} destino(s) más", por_destino.len() - limit);
396 }
397 }
398
399 if let Some(f) = &rep.fixpoint {
400 println!();
401 println!(
402 "punto fijo {} símbolo(s) tras {} ronda(s) — {:+} sobre la clausura estricta",
403 f.closure_len, f.rounds, f.grew_by as i64
404 );
405 if f.remaining_leaks.is_empty() {
406 println!(" cerró: ninguna fuga sobrevive");
407 } else {
408 println!(
409 " ⚠ NO cerró: {} fuga(s) siguen abiertas tras el tope de rondas",
410 f.remaining_leaks.len()
411 );
412 }
413 if f.added.is_empty() {
414 println!(" no hizo falta apagar nada de más");
415 } else {
416 println!(" precio: {} símbolo(s) apagados de más", f.added.len());
417 for (r, s) in f.added.iter().take(limit) {
418 println!(" · ronda {r}: {s}");
419 }
420 if f.added.len() > limit {
421 println!(" … y {} más", f.added.len() - limit);
422 }
423 }
424 }
425
426 if let Some(a) = &rep.against {
427 println!();
428 println!("contraste contra {}", a.config);
429 println!(" apagados explícitos {}", a.off_in_config);
430 println!(" ausentes {}", a.absent_in_config);
431 println!(" ENCENDIDOS {}", a.on_in_config.len());
432 for s in a.on_in_config.iter().take(limit) {
433 println!(" · {s}");
434 }
435 if a.on_in_config.len() > limit {
436 println!(" … y {} más", a.on_in_config.len() - limit);
437 }
438 }
439 Ok(())
440}
441
442// ---------------------------------------------------------------------------------------------
443// probe — el modo reversa (#9)
444// ---------------------------------------------------------------------------------------------
445
446fn probe(
447 config: Option<&Path>,
448 kroot: &Path,
449 catalog: &Path,
450 with_hw: bool,
451 json: bool,
452) -> Result<()> {
453 use hammer_core::kernel::reverse::{BundleState, HwSignal};
454
455 let cfg_path = match config {
456 Some(p) => p.to_path_buf(),
457 None => running_config_path()?,
458 };
459 let cfg = KernelConfig::parse(&read_maybe_gz(&cfg_path)?);
460 if cfg.is_empty() {
461 anyhow::bail!("{} no tiene un solo CONFIG_*: ¿es un .config?", cfg_path.display());
462 }
463 let tree = load_tree(kroot)?;
464 let cat = hammer_core::kernel::Catalog::load(catalog)?;
465 let hw = with_hw.then(hammer_core::kernel::Hardware::probe_local);
466 let rep = hammer_core::kernel::reverse::analyze(&cat, &tree, &cfg, hw.as_ref());
467
468 if json {
469 println!("{}", serde_json::to_string_pretty(&rep)?);
470 return Ok(());
471 }
472
473 println!("config {}", cfg_path.display());
474 if let Some(k) = &rep.kernel {
475 println!("kernel {k}");
476 }
477 // El modo reversa cruza DOS árboles: el del config vivo y el que se parseó. Si no son la misma
478 // versión, las clausuras son aproximadas — un símbolo puede haberse renombrado o retirado entre
479 // medias (que es justo lo que le pasó a THUNDERBOLT y a REISERFS_FS). Vale igual, pero decirlo.
480 if let (Some(banner), Some(rev)) = (&rep.kernel, &cat.reviewed_against) {
481 if let (Some(a), Some(b)) = (serie(banner), serie(rev)) {
482 if a != b {
483 println!(
484 " ⚠ el config vivo es de la serie {a} y el catálogo se revisó contra \
485 la {b}: las clausuras son aproximadas"
486 );
487 }
488 }
489 }
490 println!("símbolos {} declarados", rep.config_symbols);
491 if let Some(h) = &hw {
492 println!("huella {}", rep.fingerprint);
493 if let Some(m) = &h.cpu_model {
494 println!(" {m}");
495 }
496 let maquina = [h.dmi.get("sys_vendor"), h.dmi.get("product_name")]
497 .into_iter()
498 .flatten()
499 .cloned()
500 .collect::<Vec<_>>()
501 .join(" ");
502 if !maquina.is_empty() {
503 println!(" {maquina}");
504 }
505 println!(
506 " {} dispositivos PCI · {} drivers bindeados",
507 h.pci.len(),
508 h.bound_drivers.len()
509 );
510 for g in &h.gaps {
511 println!(" ⚠ {g}");
512 }
513 }
514 println!();
515
516 for b in &rep.bundles {
517 let on = b.on_builtin + b.on_module;
518 let estado = match b.state {
519 BundleState::Aplicado => "APLICADO ",
520 BundleState::Parcial => "parcial ",
521 BundleState::Sin => "sin ",
522 };
523 let hwtxt = match &b.hardware {
524 HwSignal::SinSeñal => String::new(),
525 HwSignal::Ausente => " · hardware ausente".to_string(),
526 HwSignal::Presente { evidencia } => {
527 format!(" · ⚠ HARDWARE PRESENTE ({})", evidencia.len())
528 }
529 };
530 println!(
531 "{estado} {:<28} {on:>5}/{:<5} encendidos{hwtxt}",
532 b.id, b.closure_len
533 );
534 if let HwSignal::Presente { evidencia } = &b.hardware {
535 for e in evidencia.iter().take(4) {
536 println!(" {e}");
537 }
538 }
539 if !b.unknown_symbols.is_empty() {
540 println!(
541 " ⚠ símbolos que este kernel no tiene: {}",
542 b.unknown_symbols.join(", ")
543 );
544 }
545 if !b.undeclared_leaks.is_empty() {
546 println!(
547 " ⚠ {} fuga(s) `select` sin declarar en el catálogo",
548 b.undeclared_leaks.len()
549 );
550 }
551 }
552
553 println!();
554 println!(
555 "resumen {} bundle(s): {} ya aplicados, {} con capacidad que este hardware NO usa",
556 rep.bundles.len(),
557 rep.bundles
558 .iter()
559 .filter(|b| b.state == BundleState::Aplicado)
560 .count(),
561 rep.applicable_savings
562 );
563 if rep.contradictions > 0 {
564 println!(
565 " ⚠ {} bundle(s) aplicados CONTRADICEN al hardware presente",
566 rep.contradictions
567 );
568 }
569 println!(" (detectar sirve para contradecir, nunca para podar solo)");
570 Ok(())
571}
572
573/// Serie `mayor.menor` de un texto que contenga una versión (`"Linux/x86 7.1.4-artix1 …"` → `7.1`,
574/// `"linux-6.16.12"` → `6.16`). `None` si no hay ninguna.
575fn serie(texto: &str) -> Option<String> {
576 for tok in texto.split(|c: char| !(c.is_ascii_digit() || c == '.')) {
577 let mut it = tok.split('.');
578 if let (Some(a), Some(b)) = (it.next(), it.next()) {
579 if !a.is_empty() && !b.is_empty() {
580 return Some(format!("{a}.{b}"));
581 }
582 }
583 }
584 None
585}
586
587// ---------------------------------------------------------------------------------------------
588// bundles — catálogo + control de frescura
589// ---------------------------------------------------------------------------------------------
590
591#[derive(Serialize)]
592struct BundleView {
593 id: String,
594 title: String,
595 #[serde(skip_serializing_if = "Option::is_none")]
596 help: Option<String>,
597 disable: Vec<String>,
598 close_leaks: Vec<String>,
599 closure_len: usize,
600 visible: usize,
601 unknown_symbols: Vec<String>,
602 undeclared_leaks: Vec<hammer_core::kernel::SelectLeak>,
603 accepted_leaks: usize,
604}
605
606#[derive(Serialize)]
607struct BundlesReport {
608 catalog: String,
609 #[serde(skip_serializing_if = "Option::is_none")]
610 reviewed_against: Option<String>,
611 bundles: Vec<BundleView>,
612 knobs: Vec<KnobView>,
613 stale: bool,
614}
615
616#[derive(Serialize)]
617struct KnobView {
618 id: String,
619 title: String,
620 side: hammer_core::kernel::Side,
621 enable: Vec<String>,
622 disable: Vec<String>,
623 #[serde(skip_serializing_if = "Option::is_none")]
624 recipe_field: Option<String>,
625 unknown_symbols: Vec<String>,
626}
627
628fn bundles(kroot: &Path, catalog: &Path, check: bool, json: bool) -> Result<()> {
629 let tree = load_tree(kroot)?;
630 let cat = hammer_core::kernel::Catalog::load(catalog)?;
631
632 let mut views = Vec::new();
633 for b in &cat.bundles {
634 let mut roots = b.disable.clone();
635 roots.extend(b.close_leaks.iter().cloned());
636 let unknown: Vec<String> = roots
637 .iter()
638 .chain(b.enable.iter())
639 .filter(|s| !tree.symbols.contains_key(*s))
640 .cloned()
641 .collect();
642 let cl = tree.closure_off(&roots);
643 let aceptadas: BTreeSet<&str> = b.accept_leaks.keys().map(|s| s.as_str()).collect();
644 let undeclared: Vec<_> = tree
645 .select_leaks(&cl)
646 .into_iter()
647 .filter(|l| !aceptadas.contains(l.target.as_str()))
648 .collect();
649 views.push(BundleView {
650 id: b.id.clone(),
651 title: b.title.clone(),
652 help: b.help.clone(),
653 disable: b.disable.clone(),
654 close_leaks: b.close_leaks.clone(),
655 closure_len: cl.len(),
656 visible: cl
657 .iter()
658 .filter(|s| tree.symbols.get(*s).is_some_and(|x| x.is_visible()))
659 .count(),
660 unknown_symbols: unknown,
661 undeclared_leaks: undeclared,
662 accepted_leaks: b.accept_leaks.len(),
663 });
664 }
665
666 let knobs: Vec<KnobView> = cat
667 .knobs
668 .iter()
669 .map(|k| KnobView {
670 id: k.id.clone(),
671 title: k.title.clone(),
672 side: k.side,
673 enable: k.enable.clone(),
674 disable: k.disable.clone(),
675 recipe_field: k.recipe_field.clone(),
676 unknown_symbols: k
677 .enable
678 .iter()
679 .chain(k.disable.iter())
680 .filter(|s| !tree.symbols.contains_key(*s))
681 .cloned()
682 .collect(),
683 })
684 .collect();
685
686 let stale = views
687 .iter()
688 .any(|v| !v.unknown_symbols.is_empty() || !v.undeclared_leaks.is_empty())
689 || knobs.iter().any(|k| !k.unknown_symbols.is_empty());
690
691 let rep = BundlesReport {
692 catalog: catalog.display().to_string(),
693 reviewed_against: cat.reviewed_against.clone(),
694 bundles: views,
695 knobs,
696 stale,
697 };
698
699 if json {
700 println!("{}", serde_json::to_string_pretty(&rep)?);
701 } else {
702 println!("catálogo {}", rep.catalog);
703 if let Some(r) = &rep.reviewed_against {
704 println!("revisado contra {r}");
705 }
706 println!("árbol {}", kroot.display());
707 println!();
708 println!("NIVEL 1 — aserciones del dueño de la máquina");
709 for v in &rep.bundles {
710 println!(
711 " {:<26} {:>5} símbolos ({} visibles) ← {}",
712 v.id,
713 v.closure_len,
714 v.visible,
715 v.disable.join(" ")
716 );
717 println!(" {}", v.title);
718 if !v.close_leaks.is_empty() {
719 println!(" fugas cerradas: {}", v.close_leaks.join(", "));
720 }
721 if v.accepted_leaks > 0 {
722 println!(" fugas aceptadas a sabiendas: {}", v.accepted_leaks);
723 }
724 if !v.unknown_symbols.is_empty() {
725 println!(
726 " ⚠ NO EXISTEN en este árbol: {}",
727 v.unknown_symbols.join(", ")
728 );
729 }
730 for l in v.undeclared_leaks.iter().take(6) {
731 println!(" ⚠ fuga sin declarar: {} ← {}", l.target, l.selector);
732 }
733 if v.undeclared_leaks.len() > 6 {
734 println!(" ⚠ … y {} fuga(s) más", v.undeclared_leaks.len() - 6);
735 }
736 }
737 println!();
738 println!("NIVEL 2 — política y rendimiento (ojo al lado del que cae cada perilla)");
739 for k in &rep.knobs {
740 let lado = match k.side {
741 hammer_core::kernel::Side::Kconfig => "kconfig",
742 hammer_core::kernel::Side::Recipe => "receta ",
743 };
744 let que = match k.side {
745 hammer_core::kernel::Side::Kconfig => {
746 let mut v: Vec<String> =
747 k.enable.iter().map(|s| format!("+{s}")).collect();
748 v.extend(k.disable.iter().map(|s| format!("-{s}")));
749 v.join(" ")
750 }
751 hammer_core::kernel::Side::Recipe => {
752 k.recipe_field.clone().unwrap_or_default()
753 }
754 };
755 println!(" [{lado}] {:<26} {que}", k.id);
756 println!(" {}", k.title);
757 if !k.unknown_symbols.is_empty() {
758 println!(
759 " ⚠ NO EXISTEN en este árbol: {}",
760 k.unknown_symbols.join(", ")
761 );
762 }
763 }
764 println!();
765 if rep.stale {
766 println!("⚠ el catálogo ENVEJECIÓ respecto de este árbol (símbolos idos o fugas nuevas)");
767 } else {
768 println!("✓ el catálogo está al día con este árbol");
769 }
770 }
771
772 if check && rep.stale {
773 anyhow::bail!("catálogo desactualizado");
774 }
775 Ok(())
776}
777
778/// Lee un `.config`, descomprimiendo si es `.gz` (el caso de `/proc/config.gz`).
779///
780/// Se delega a `gzip -dc` en vez de arrastrar un descompresor al core: es una entrada de
781/// diagnóstico, no del camino de build, y el core queda sin dependencias nuevas.
782pub fn read_maybe_gz(p: &Path) -> Result<String> {
783 if p.extension().is_some_and(|e| e == "gz") {
784 let out = std::process::Command::new("gzip")
785 .arg("-dc")
786 .arg(p)
787 .output()
788 .with_context(|| format!("descomprimiendo {}", p.display()))?;
789 if !out.status.success() {
790 anyhow::bail!(
791 "gzip -dc {} falló: {}",
792 p.display(),
793 String::from_utf8_lossy(&out.stderr).trim()
794 );
795 }
796 return Ok(String::from_utf8_lossy(&out.stdout).into_owned());
797 }
798 std::fs::read_to_string(p).with_context(|| format!("leyendo {}", p.display()))
799}
800
801#[cfg(test)]
802mod tests {
803 use super::*;
804
805 #[test]
806 fn serie_de_las_dos_formas_que_se_cruzan() {
807 assert_eq!(serie("Linux/x86 7.1.4-artix1 Kernel Configuration").as_deref(), Some("7.1"));
808 assert_eq!(serie("linux-6.16.12").as_deref(), Some("6.16"));
809 assert_eq!(serie("sin versión"), None);
810 }
811}