gioser

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…

TOML 1572Shell 203Rust 109Markdown 993728 Definiciones

⎇ cf2cf54914e5917613be71ffb49109894c1791c7 crates/hammer-cli/src/kernel_cmd.rs Rust · 811 líneas · 27.8 KB Historia Quién tocó qué crudo

kernel: modo reversa y catálogo de bundles — y las cuatro recetas apagan dos símbolos que ya no existen cf2cf5491 · Sergio ·

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}