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 1373Shell 116Rust 74Markdown 641915 Definitions

⎇ 02991dd9d85898c24f1cf52eb27bf7dfdf0f21f7 crates/hammer-cli/src/kernel_cmd.rs Rust · 1033 lines · 35.8 KB History Who touched what raw

kernel: plan (receta derivada) y diff-back — dos hashes para el mismo código fuente 02991dd9d · 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    /// 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    /// Estadísticas del árbol de Kconfig parseado (control de salud del lector).
130    Stats {
131        #[arg(long)]
132        kconfig: Option<PathBuf>,
133        /// Muestra las primeras N advertencias del parseo.
134        #[arg(long, default_value_t = 10)]
135        warnings: usize,
136    },
137}
138
139pub fn run(cmd: KernelCmd, store: &str) -> Result<()> {
140    match cmd {
141        KernelCmd::Stats { kconfig, warnings } => stats(&kconfig_root(kconfig)?, warnings),
142        KernelCmd::Probe {
143            config,
144            kconfig,
145            catalog,
146            no_hardware,
147            json,
148        } => probe(
149            config.as_deref(),
150            &kconfig_root(kconfig)?,
151            &catalog_path(catalog)?,
152            !no_hardware,
153            json,
154        ),
155        KernelCmd::Bundles {
156            kconfig,
157            catalog,
158            check,
159            json,
160        } => bundles(&kconfig_root(kconfig)?, &catalog_path(catalog)?, check, json),
161        KernelCmd::Plan {
162            recipe,
163            bundles: bs,
164            knobs,
165            kconfig,
166            catalog,
167            out,
168            recipe_out,
169            json,
170        } => plan(
171            &recipe,
172            &bs,
173            &knobs,
174            &kconfig_root(kconfig)?,
175            &catalog_path(catalog)?,
176            out.as_deref(),
177            recipe_out.as_deref(),
178            store,
179            json,
180        ),
181        KernelCmd::DiffBack { plan, config, json } => diff_back(&plan, &config, json),
182        KernelCmd::Closure {
183            symbols,
184            kconfig,
185            against,
186            fixpoint,
187            json,
188            limit,
189        } => closure(
190            &symbols,
191            &kconfig_root(kconfig)?,
192            against.as_deref(),
193            fixpoint,
194            json,
195            limit,
196        ),
197    }
198}
199
200/// Resuelve la raíz del árbol de Kconfig: `--kconfig` gana, si no la variable de entorno.
201fn kconfig_root(flag: Option<PathBuf>) -> Result<PathBuf> {
202    if let Some(p) = flag {
203        return Ok(p);
204    }
205    match std::env::var_os(KCONFIG_ENV) {
206        Some(v) => Ok(PathBuf::from(v)),
207        None => anyhow::bail!(
208            "falta el árbol de fuentes del kernel: pasá --kconfig <dir> o exportá {KCONFIG_ENV}"
209        ),
210    }
211}
212
213/// Resuelve el catálogo de bundles: `--catalog`, si no la variable de entorno, si no el del repo.
214fn catalog_path(flag: Option<PathBuf>) -> Result<PathBuf> {
215    if let Some(p) = flag {
216        return Ok(p);
217    }
218    if let Some(v) = std::env::var_os(CATALOG_ENV) {
219        return Ok(PathBuf::from(v));
220    }
221    for c in CATALOG_DEFAULTS {
222        let p = PathBuf::from(c);
223        if p.is_file() {
224            return Ok(p);
225        }
226    }
227    anyhow::bail!(
228        "no encontré el catálogo de bundles: pasá --catalog <path> o exportá {CATALOG_ENV}"
229    )
230}
231
232/// Dónde está el `.config` del kernel que corre. `/proc/config.gz` es el camino soberano (lo emite
233/// el propio kernel); el de `/boot` es el respaldo para kernels compilados sin `IKCONFIG_PROC`.
234fn running_config_path() -> Result<PathBuf> {
235    let gz = PathBuf::from("/proc/config.gz");
236    if gz.is_file() {
237        return Ok(gz);
238    }
239    if let Ok(out) = std::process::Command::new("uname").arg("-r").output() {
240        let rel = String::from_utf8_lossy(&out.stdout).trim().to_string();
241        let p = PathBuf::from(format!("/boot/config-{rel}"));
242        if p.is_file() {
243            return Ok(p);
244        }
245    }
246    anyhow::bail!(
247        "no encontré el config del kernel vivo (ni /proc/config.gz ni /boot/config-<release>).\n\
248         Pasá --config <path> para mirar el de otra máquina."
249    )
250}
251
252fn load_tree(root: &Path) -> Result<KconfigTree> {
253    if !root.join("Kconfig").is_file() {
254        anyhow::bail!(
255            "{} no parece un árbol de fuentes de kernel (falta ./Kconfig).\n\
256             Pasá --kconfig <dir> o exportá {KCONFIG_ENV}.",
257            root.display()
258        );
259    }
260    KconfigTree::parse(root, &KconfigTree::vars_x86_64())
261        .with_context(|| format!("parseando {}", root.display()))
262}
263
264fn stats(root: &Path, show_warnings: usize) -> Result<()> {
265    let t = load_tree(root)?;
266    let visible = t.symbols.values().filter(|s| s.is_visible()).count();
267    let multi = t.symbols.values().filter(|s| s.defs.len() > 1).count();
268    let with_help = t.symbols.values().filter(|s| s.help().is_some()).count();
269    let selects: usize = t.symbols.values().map(|s| s.select_targets().len()).sum();
270    let implies: usize = t.symbols.values().map(|s| s.imply_targets().len()).sum();
271
272    println!("árbol      {}", root.display());
273    println!("ficheros   {}", t.files.len());
274    println!("símbolos   {}", t.symbols.len());
275    println!("  visibles {visible} (marcables en menuconfig)");
276    println!("  con ayuda {with_help}");
277    println!("  definidos en >1 sitio {multi}");
278    println!("aristas    select {selects} · imply {implies}");
279    println!("avisos     {}", t.warnings.len());
280    for w in t.warnings.iter().take(show_warnings) {
281        println!("  · {w}");
282    }
283    if t.warnings.len() > show_warnings {
284        println!("  … y {} más", t.warnings.len() - show_warnings);
285    }
286    Ok(())
287}
288
289#[derive(Serialize)]
290struct ClosureReport {
291    roots: Vec<String>,
292    /// Todo lo que queda inalcanzable si se apagan las raíces (incluidas las raíces).
293    closure: Vec<String>,
294    /// De la clausura, los que un humano puede marcar en `menuconfig`.
295    visible: usize,
296    /// `select` que entran desde fuera: reencienden algo de la clausura ignorando sus `depends on`.
297    leaks: Vec<hammer_core::kernel::SelectLeak>,
298    /// Símbolos distintos de la clausura alcanzados por esas fugas.
299    leaked_targets: usize,
300    #[serde(skip_serializing_if = "Option::is_none")]
301    fixpoint: Option<FixpointReport>,
302    #[serde(skip_serializing_if = "Option::is_none")]
303    against: Option<AgainstReport>,
304}
305
306/// El precio de cerrar el bundle contra sus fugas.
307#[derive(Serialize)]
308struct FixpointReport {
309    /// La clausura ya cerrada, entera.
310    closure: Vec<String>,
311    /// Tamaño de la clausura una vez cerrada.
312    closure_len: usize,
313    /// Cuánto creció respecto de la clausura estricta.
314    grew_by: usize,
315    rounds: usize,
316    /// Lo que hubo que apagar de más, con la ronda en que apareció.
317    added: Vec<(usize, String)>,
318    /// Fugas que sobrevivieron al tope de rondas. Vacío = cerró.
319    remaining_leaks: Vec<hammer_core::kernel::SelectLeak>,
320}
321
322/// Contraste contra un `.config` real: la validación §3 del SDD 22.
323#[derive(Serialize)]
324struct AgainstReport {
325    config: String,
326    /// De la clausura, los que el `.config` tiene ENCENDIDOS (la clausura predice de más, o el
327    /// config tiene una fuga real).
328    on_in_config: Vec<String>,
329    /// De la clausura, los que el `.config` apaga explícitamente.
330    off_in_config: usize,
331    /// De la clausura, los que el `.config` ni menciona.
332    absent_in_config: usize,
333}
334
335fn closure(
336    roots: &[String],
337    kroot: &Path,
338    against: Option<&Path>,
339    fixpoint: bool,
340    json: bool,
341    limit: usize,
342) -> Result<()> {
343    let t = load_tree(kroot)?;
344
345    // Avisar de raíces que no existen: un símbolo mal escrito daría clausura vacía en silencio,
346    // que es exactamente el modo de fallo que este comando existe para no tener.
347    let desconocidas: Vec<&String> = roots.iter().filter(|r| !t.symbols.contains_key(*r)).collect();
348    if !desconocidas.is_empty() {
349        eprintln!(
350            "aviso: {} símbolo(s) no existen en este árbol: {}",
351            desconocidas.len(),
352            desconocidas
353                .iter()
354                .map(|s| s.as_str())
355                .collect::<Vec<_>>()
356                .join(", ")
357        );
358    }
359
360    let cl = t.closure_off(roots);
361    let leaks = t.select_leaks(&cl);
362    let leaked_targets = leaks
363        .iter()
364        .map(|l| l.target.as_str())
365        .collect::<BTreeSet<_>>()
366        .len();
367    let visible = cl
368        .iter()
369        .filter(|s| t.symbols.get(*s).is_some_and(|s| s.is_visible()))
370        .count();
371
372    let fx = fixpoint.then(|| {
373        let f = t.closure_off_fixpoint(roots, 16);
374        FixpointReport {
375            closure_len: f.closure.len(),
376            closure: f.closure.iter().cloned().collect(),
377            grew_by: f.closure.len().saturating_sub(cl.len()),
378            rounds: f.rounds,
379            added: f.added,
380            remaining_leaks: f.remaining_leaks,
381        }
382    });
383
384    let against_report = match against {
385        None => None,
386        Some(p) => {
387            let text = read_maybe_gz(p)?;
388            let cfg = KernelConfig::parse(&text);
389            let on: Vec<String> = cl.iter().filter(|s| cfg.is_on(s)).cloned().collect();
390            let off = cl
391                .iter()
392                .filter(|s| matches!(cfg.get(s), Some(hammer_core::kernel::ConfigValue::No)))
393                .count();
394            let absent = cl.iter().filter(|s| cfg.get(s).is_none()).count();
395            Some(AgainstReport {
396                config: p.display().to_string(),
397                on_in_config: on,
398                off_in_config: off,
399                absent_in_config: absent,
400            })
401        }
402    };
403
404    let rep = ClosureReport {
405        roots: roots.to_vec(),
406        closure: cl.iter().cloned().collect(),
407        visible,
408        leaks,
409        leaked_targets,
410        fixpoint: fx,
411        against: against_report,
412    };
413
414    if json {
415        println!("{}", serde_json::to_string_pretty(&rep)?);
416        return Ok(());
417    }
418
419    println!("raíces apagadas   {}", rep.roots.join(" "));
420    println!(
421        "clausura          {} símbolo(s) ({} visibles en menuconfig)",
422        rep.closure.len(),
423        rep.visible
424    );
425    for s in rep.closure.iter().take(limit) {
426        let prompt = t
427            .symbols
428            .get(s)
429            .and_then(|x| x.prompt())
430            .unwrap_or("(sin prompt: sólo entra por select)");
431        println!("  · {s} — {prompt}");
432    }
433    if rep.closure.len() > limit {
434        println!("  … y {} más", rep.closure.len() - limit);
435    }
436
437    println!();
438    if rep.leaks.is_empty() {
439        println!("fugas por `select`  ninguna — la clausura es cerrada");
440    } else {
441        println!(
442            "fugas por `select`  {} arista(s) desde fuera reencienden {} símbolo(s) de la clausura",
443            rep.leaks.len(),
444            rep.leaked_targets
445        );
446        println!("  (`select` FUERZA el destino ignorando sus `depends on`: apagar la raíz no basta)");
447        let mut por_destino: BTreeMap<&str, Vec<&str>> = BTreeMap::new();
448        for l in &rep.leaks {
449            por_destino
450                .entry(&l.target)
451                .or_default()
452                .push(&l.selector);
453        }
454        for (target, selectors) in por_destino.iter().take(limit) {
455            println!("  · {target} ← {}", selectors.join(", "));
456        }
457        if por_destino.len() > limit {
458            println!("  … y {} destino(s) más", por_destino.len() - limit);
459        }
460    }
461
462    if let Some(f) = &rep.fixpoint {
463        println!();
464        println!(
465            "punto fijo        {} símbolo(s) tras {} ronda(s) — {:+} sobre la clausura estricta",
466            f.closure_len, f.rounds, f.grew_by as i64
467        );
468        if f.remaining_leaks.is_empty() {
469            println!("  cerró: ninguna fuga sobrevive");
470        } else {
471            println!(
472                "  ⚠ NO cerró: {} fuga(s) siguen abiertas tras el tope de rondas",
473                f.remaining_leaks.len()
474            );
475        }
476        if f.added.is_empty() {
477            println!("  no hizo falta apagar nada de más");
478        } else {
479            println!("  precio: {} símbolo(s) apagados de más", f.added.len());
480            for (r, s) in f.added.iter().take(limit) {
481                println!("    · ronda {r}: {s}");
482            }
483            if f.added.len() > limit {
484                println!("    … y {} más", f.added.len() - limit);
485            }
486        }
487    }
488
489    if let Some(a) = &rep.against {
490        println!();
491        println!("contraste contra {}", a.config);
492        println!("  apagados explícitos  {}", a.off_in_config);
493        println!("  ausentes             {}", a.absent_in_config);
494        println!("  ENCENDIDOS           {}", a.on_in_config.len());
495        for s in a.on_in_config.iter().take(limit) {
496            println!("    · {s}");
497        }
498        if a.on_in_config.len() > limit {
499            println!("    … y {} más", a.on_in_config.len() - limit);
500        }
501    }
502    Ok(())
503}
504
505// ---------------------------------------------------------------------------------------------
506// probe — el modo reversa (#9)
507// ---------------------------------------------------------------------------------------------
508
509fn probe(
510    config: Option<&Path>,
511    kroot: &Path,
512    catalog: &Path,
513    with_hw: bool,
514    json: bool,
515) -> Result<()> {
516    use hammer_core::kernel::reverse::{BundleState, HwSignal};
517
518    let cfg_path = match config {
519        Some(p) => p.to_path_buf(),
520        None => running_config_path()?,
521    };
522    let cfg = KernelConfig::parse(&read_maybe_gz(&cfg_path)?);
523    if cfg.is_empty() {
524        anyhow::bail!("{} no tiene un solo CONFIG_*: ¿es un .config?", cfg_path.display());
525    }
526    let tree = load_tree(kroot)?;
527    let cat = hammer_core::kernel::Catalog::load(catalog)?;
528    let hw = with_hw.then(hammer_core::kernel::Hardware::probe_local);
529    let rep = hammer_core::kernel::reverse::analyze(&cat, &tree, &cfg, hw.as_ref());
530
531    if json {
532        println!("{}", serde_json::to_string_pretty(&rep)?);
533        return Ok(());
534    }
535
536    println!("config      {}", cfg_path.display());
537    if let Some(k) = &rep.kernel {
538        println!("kernel      {k}");
539    }
540    // El modo reversa cruza DOS árboles: el del config vivo y el que se parseó. Si no son la misma
541    // versión, las clausuras son aproximadas — un símbolo puede haberse renombrado o retirado entre
542    // medias (que es justo lo que le pasó a THUNDERBOLT y a REISERFS_FS). Vale igual, pero decirlo.
543    if let (Some(banner), Some(rev)) = (&rep.kernel, &cat.reviewed_against) {
544        if let (Some(a), Some(b)) = (serie(banner), serie(rev)) {
545            if a != b {
546                println!(
547                    "            ⚠ el config vivo es de la serie {a} y el catálogo se revisó contra \
548                     la {b}: las clausuras son aproximadas"
549                );
550            }
551        }
552    }
553    println!("símbolos    {} declarados", rep.config_symbols);
554    if let Some(h) = &hw {
555        println!("huella      {}", rep.fingerprint);
556        if let Some(m) = &h.cpu_model {
557            println!("            {m}");
558        }
559        let maquina = [h.dmi.get("sys_vendor"), h.dmi.get("product_name")]
560            .into_iter()
561            .flatten()
562            .cloned()
563            .collect::<Vec<_>>()
564            .join(" ");
565        if !maquina.is_empty() {
566            println!("            {maquina}");
567        }
568        println!(
569            "            {} dispositivos PCI · {} drivers bindeados",
570            h.pci.len(),
571            h.bound_drivers.len()
572        );
573        for g in &h.gaps {
574            println!("            ⚠ {g}");
575        }
576    }
577    println!();
578
579    for b in &rep.bundles {
580        let on = b.on_builtin + b.on_module;
581        let estado = match b.state {
582            BundleState::Aplicado => "APLICADO ",
583            BundleState::Parcial => "parcial  ",
584            BundleState::Sin => "sin      ",
585        };
586        let hwtxt = match &b.hardware {
587            HwSignal::SinSeñal => String::new(),
588            HwSignal::Ausente => "  · hardware ausente".to_string(),
589            HwSignal::Presente { evidencia } => {
590                format!("  · ⚠ HARDWARE PRESENTE ({})", evidencia.len())
591            }
592        };
593        println!(
594            "{estado} {:<28} {on:>5}/{:<5} encendidos{hwtxt}",
595            b.id, b.closure_len
596        );
597        if let HwSignal::Presente { evidencia } = &b.hardware {
598            for e in evidencia.iter().take(4) {
599                println!("              {e}");
600            }
601        }
602        if !b.unknown_symbols.is_empty() {
603            println!(
604                "              ⚠ símbolos que este kernel no tiene: {}",
605                b.unknown_symbols.join(", ")
606            );
607        }
608        if !b.undeclared_leaks.is_empty() {
609            println!(
610                "              ⚠ {} fuga(s) `select` sin declarar en el catálogo",
611                b.undeclared_leaks.len()
612            );
613        }
614    }
615
616    println!();
617    println!(
618        "resumen     {} bundle(s): {} ya aplicados, {} con capacidad que este hardware NO usa",
619        rep.bundles.len(),
620        rep.bundles
621            .iter()
622            .filter(|b| b.state == BundleState::Aplicado)
623            .count(),
624        rep.applicable_savings
625    );
626    if rep.contradictions > 0 {
627        println!(
628            "            ⚠ {} bundle(s) aplicados CONTRADICEN al hardware presente",
629            rep.contradictions
630        );
631    }
632    println!("            (detectar sirve para contradecir, nunca para podar solo)");
633    Ok(())
634}
635
636/// Serie `mayor.menor` de un texto que contenga una versión (`"Linux/x86 7.1.4-artix1 …"` → `7.1`,
637/// `"linux-6.16.12"` → `6.16`). `None` si no hay ninguna.
638fn serie(texto: &str) -> Option<String> {
639    for tok in texto.split(|c: char| !(c.is_ascii_digit() || c == '.')) {
640        let mut it = tok.split('.');
641        if let (Some(a), Some(b)) = (it.next(), it.next()) {
642            if !a.is_empty() && !b.is_empty() {
643                return Some(format!("{a}.{b}"));
644            }
645        }
646    }
647    None
648}
649
650// ---------------------------------------------------------------------------------------------
651// bundles — catálogo + control de frescura
652// ---------------------------------------------------------------------------------------------
653
654#[derive(Serialize)]
655struct BundleView {
656    id: String,
657    title: String,
658    #[serde(skip_serializing_if = "Option::is_none")]
659    help: Option<String>,
660    disable: Vec<String>,
661    close_leaks: Vec<String>,
662    closure_len: usize,
663    visible: usize,
664    unknown_symbols: Vec<String>,
665    undeclared_leaks: Vec<hammer_core::kernel::SelectLeak>,
666    accepted_leaks: usize,
667}
668
669#[derive(Serialize)]
670struct BundlesReport {
671    catalog: String,
672    #[serde(skip_serializing_if = "Option::is_none")]
673    reviewed_against: Option<String>,
674    bundles: Vec<BundleView>,
675    knobs: Vec<KnobView>,
676    stale: bool,
677}
678
679#[derive(Serialize)]
680struct KnobView {
681    id: String,
682    title: String,
683    side: hammer_core::kernel::Side,
684    enable: Vec<String>,
685    disable: Vec<String>,
686    #[serde(skip_serializing_if = "Option::is_none")]
687    recipe_field: Option<String>,
688    unknown_symbols: Vec<String>,
689}
690
691fn bundles(kroot: &Path, catalog: &Path, check: bool, json: bool) -> Result<()> {
692    let tree = load_tree(kroot)?;
693    let cat = hammer_core::kernel::Catalog::load(catalog)?;
694
695    let mut views = Vec::new();
696    for b in &cat.bundles {
697        let mut roots = b.disable.clone();
698        roots.extend(b.close_leaks.iter().cloned());
699        let unknown: Vec<String> = roots
700            .iter()
701            .chain(b.enable.iter())
702            .filter(|s| !tree.symbols.contains_key(*s))
703            .cloned()
704            .collect();
705        let cl = tree.closure_off(&roots);
706        let aceptadas: BTreeSet<&str> = b.accept_leaks.keys().map(|s| s.as_str()).collect();
707        let undeclared: Vec<_> = tree
708            .select_leaks(&cl)
709            .into_iter()
710            .filter(|l| !aceptadas.contains(l.target.as_str()))
711            .collect();
712        views.push(BundleView {
713            id: b.id.clone(),
714            title: b.title.clone(),
715            help: b.help.clone(),
716            disable: b.disable.clone(),
717            close_leaks: b.close_leaks.clone(),
718            closure_len: cl.len(),
719            visible: cl
720                .iter()
721                .filter(|s| tree.symbols.get(*s).is_some_and(|x| x.is_visible()))
722                .count(),
723            unknown_symbols: unknown,
724            undeclared_leaks: undeclared,
725            accepted_leaks: b.accept_leaks.len(),
726        });
727    }
728
729    let knobs: Vec<KnobView> = cat
730        .knobs
731        .iter()
732        .map(|k| KnobView {
733            id: k.id.clone(),
734            title: k.title.clone(),
735            side: k.side,
736            enable: k.enable.clone(),
737            disable: k.disable.clone(),
738            recipe_field: k.recipe_field.clone(),
739            unknown_symbols: k
740                .enable
741                .iter()
742                .chain(k.disable.iter())
743                .filter(|s| !tree.symbols.contains_key(*s))
744                .cloned()
745                .collect(),
746        })
747        .collect();
748
749    let stale = views
750        .iter()
751        .any(|v| !v.unknown_symbols.is_empty() || !v.undeclared_leaks.is_empty())
752        || knobs.iter().any(|k| !k.unknown_symbols.is_empty());
753
754    let rep = BundlesReport {
755        catalog: catalog.display().to_string(),
756        reviewed_against: cat.reviewed_against.clone(),
757        bundles: views,
758        knobs,
759        stale,
760    };
761
762    if json {
763        println!("{}", serde_json::to_string_pretty(&rep)?);
764    } else {
765        println!("catálogo    {}", rep.catalog);
766        if let Some(r) = &rep.reviewed_against {
767            println!("revisado contra {r}");
768        }
769        println!("árbol       {}", kroot.display());
770        println!();
771        println!("NIVEL 1 — aserciones del dueño de la máquina");
772        for v in &rep.bundles {
773            println!(
774                "  {:<26} {:>5} símbolos ({} visibles)  ← {}",
775                v.id,
776                v.closure_len,
777                v.visible,
778                v.disable.join(" ")
779            );
780            println!("     {}", v.title);
781            if !v.close_leaks.is_empty() {
782                println!("     fugas cerradas: {}", v.close_leaks.join(", "));
783            }
784            if v.accepted_leaks > 0 {
785                println!("     fugas aceptadas a sabiendas: {}", v.accepted_leaks);
786            }
787            if !v.unknown_symbols.is_empty() {
788                println!(
789                    "     ⚠ NO EXISTEN en este árbol: {}",
790                    v.unknown_symbols.join(", ")
791                );
792            }
793            for l in v.undeclared_leaks.iter().take(6) {
794                println!("     ⚠ fuga sin declarar: {} ← {}", l.target, l.selector);
795            }
796            if v.undeclared_leaks.len() > 6 {
797                println!("     ⚠ … y {} fuga(s) más", v.undeclared_leaks.len() - 6);
798            }
799        }
800        println!();
801        println!("NIVEL 2 — política y rendimiento (ojo al lado del que cae cada perilla)");
802        for k in &rep.knobs {
803            let lado = match k.side {
804                hammer_core::kernel::Side::Kconfig => "kconfig",
805                hammer_core::kernel::Side::Recipe => "receta ",
806            };
807            let que = match k.side {
808                hammer_core::kernel::Side::Kconfig => {
809                    let mut v: Vec<String> =
810                        k.enable.iter().map(|s| format!("+{s}")).collect();
811                    v.extend(k.disable.iter().map(|s| format!("-{s}")));
812                    v.join(" ")
813                }
814                hammer_core::kernel::Side::Recipe => {
815                    k.recipe_field.clone().unwrap_or_default()
816                }
817            };
818            println!("  [{lado}] {:<26} {que}", k.id);
819            println!("     {}", k.title);
820            if !k.unknown_symbols.is_empty() {
821                println!(
822                    "     ⚠ NO EXISTEN en este árbol: {}",
823                    k.unknown_symbols.join(", ")
824                );
825            }
826        }
827        println!();
828        if rep.stale {
829            println!("⚠ el catálogo ENVEJECIÓ respecto de este árbol (símbolos idos o fugas nuevas)");
830        } else {
831            println!("✓ el catálogo está al día con este árbol");
832        }
833    }
834
835    if check && rep.stale {
836        anyhow::bail!("catálogo desactualizado");
837    }
838    Ok(())
839}
840
841// ---------------------------------------------------------------------------------------------
842// plan / diff-back
843// ---------------------------------------------------------------------------------------------
844
845#[allow(clippy::too_many_arguments)]
846fn plan(
847    recipe_path: &Path,
848    bundle_ids: &[String],
849    knob_ids: &[String],
850    kroot: &Path,
851    catalog: &Path,
852    out: Option<&Path>,
853    recipe_out: Option<&Path>,
854    store: &str,
855    json: bool,
856) -> Result<()> {
857    if bundle_ids.is_empty() && knob_ids.is_empty() {
858        anyhow::bail!("un plan sin bundles ni perillas es la receta base: pasá --bundle/--knob");
859    }
860    let base = hammer_core::Recipe::load_from_path(recipe_path)?;
861    let tree = load_tree(kroot)?;
862    let cat = hammer_core::kernel::Catalog::load(catalog)?;
863
864    let mut p = hammer_core::kernel::plan::make_plan(
865        &base,
866        &recipe_path.display().to_string(),
867        &cat,
868        &tree,
869        bundle_ids,
870        knob_ids,
871    )?;
872
873    // El plan DETERMINA el artefacto (SDD 22 §1), así que el hash va en el plan: la UI puede decir
874    // «esto ya está construido y firmado» sin construir nada. Si las deps no están resueltas, se
875    // dice que no se pudo en vez de emitir un hash inventado.
876    let derived = hammer_core::kernel::plan::derive_recipe(&base, &p);
877    match hammer_core::Store::open(Path::new(store))
878        .map_err(anyhow::Error::from)
879        .and_then(|s| hammer_build::artifact_hash(&derived, &s).map_err(anyhow::Error::from))
880    {
881        Ok(h) => p.artifact_hash = Some(h.to_string()),
882        Err(e) => eprintln!("aviso: no pude calcular el ArtifactHash ({e})"),
883    }
884
885    if let Some(o) = out {
886        if let Some(d) = o.parent() {
887            std::fs::create_dir_all(d).ok();
888        }
889        std::fs::write(o, serde_json::to_string_pretty(&p)?)
890            .with_context(|| format!("escribiendo {}", o.display()))?;
891    }
892    if let Some(ro) = recipe_out {
893        if let Some(d) = ro.parent() {
894            std::fs::create_dir_all(d).ok();
895        }
896        let cabecera = format!(
897            "# Receta DERIVADA, generada por `hammer kernel plan`. No editar a mano: regenerar.\n\
898             #   base    {}\n#   bundles {}\n#   perillas {}\n\
899             # Para construirla tiene que vivir junto a la receta base, o sus `deps.build` no\n\
900             # resuelven ({}).\n",
901            p.base_recipe,
902            if p.bundles.is_empty() { "—".into() } else { p.bundles.join(" ") },
903            if p.knobs.is_empty() { "—".into() } else { p.knobs.join(" ") },
904            recipe_path
905                .parent()
906                .map(|d| d.display().to_string())
907                .unwrap_or_default(),
908        );
909        let cuerpo = hammer_core::kernel::plan::render_recipe_toml(&derived)?;
910        std::fs::write(ro, format!("{cabecera}{cuerpo}"))
911            .with_context(|| format!("escribiendo {}", ro.display()))?;
912    }
913
914    if json {
915        println!("{}", serde_json::to_string_pretty(&p)?);
916        return Ok(());
917    }
918
919    println!("base        {} ({})", p.base_name, p.base_recipe);
920    println!("derivada    {}", p.derived_name);
921    match &p.artifact_hash {
922        Some(h) => println!("hash        {h}"),
923        None => println!("hash        (no calculado)"),
924    }
925    println!(
926        "selección   {} bundle(s), {} perilla(s)",
927        p.bundles.len(),
928        p.knobs.len()
929    );
930    println!();
931    println!("emite {} bandera(s); con su clausura caen {} símbolo(s)", p.symbols.len(), p.closure_size);
932    println!("(se emiten RAÍCES: la clausura la calcula el olddefconfig del propio kernel)");
933    for s in &p.symbols {
934        let signo = match s.action {
935            hammer_core::kernel::plan::Action::Enable => "+",
936            hammer_core::kernel::plan::Action::Disable => "-",
937        };
938        println!("  {signo}{:<24} ← {}  ({})", s.symbol, s.from, s.reason);
939    }
940    if !p.unknown_symbols.is_empty() {
941        println!();
942        println!("⚠ {} símbolo(s) pedidos NO EXISTEN en este árbol y no se emiten:", p.unknown_symbols.len());
943        for s in &p.unknown_symbols {
944            println!("  {} ← {}", s.symbol, s.from);
945        }
946    }
947    if !p.recipe_side.is_empty() {
948        println!();
949        println!("perillas del lado RECETA (declaradas, este plan no las aplica):");
950        for k in &p.recipe_side {
951            println!("  {:<24} {} — {}", k.id, k.recipe_field, k.title);
952        }
953    }
954    println!();
955    if let Some(o) = out {
956        println!("plan     → {}", o.display());
957    }
958    if let Some(ro) = recipe_out {
959        println!("receta   → {}", ro.display());
960    }
961    Ok(())
962}
963
964fn diff_back(plan_path: &Path, config: &Path, json: bool) -> Result<()> {
965    let p: hammer_core::kernel::Plan = serde_json::from_str(
966        &std::fs::read_to_string(plan_path)
967            .with_context(|| format!("leyendo {}", plan_path.display()))?,
968    )
969    .with_context(|| format!("{} no es un plan de `hammer kernel plan`", plan_path.display()))?;
970    let cfg = KernelConfig::parse(&read_maybe_gz(config)?);
971    if cfg.is_empty() {
972        anyhow::bail!("{} no tiene un solo CONFIG_*: ¿es un .config?", config.display());
973    }
974    let d = hammer_core::kernel::diff_back(&p, &cfg);
975
976    if json {
977        println!("{}", serde_json::to_string_pretty(&d)?);
978    } else {
979        println!("plan        {} ({})", p.derived_name, plan_path.display());
980        println!("config      {}", config.display());
981        println!("cumplidos   {}", d.honored.len());
982        println!("ausentes    {}", d.absent.len());
983        for s in &d.absent {
984            println!("  {s} — el kernel ni lo menciona: la bandera fue un no-op");
985        }
986        println!("INCUMPLIDOS {}", d.violated.len());
987        for v in &d.violated {
988            println!(
989                "  {} — se pidió {:?} y quedó {} (lo pidió {})",
990                v.symbol, v.asked, v.got, v.from
991            );
992        }
993    }
994    if !d.is_clean() {
995        anyhow::bail!("el .config no honra el plan");
996    }
997    Ok(())
998}
999
1000/// Lee un `.config`, descomprimiendo si es `.gz` (el caso de `/proc/config.gz`).
1001///
1002/// Se delega a `gzip -dc` en vez de arrastrar un descompresor al core: es una entrada de
1003/// diagnóstico, no del camino de build, y el core queda sin dependencias nuevas.
1004pub fn read_maybe_gz(p: &Path) -> Result<String> {
1005    if p.extension().is_some_and(|e| e == "gz") {
1006        let out = std::process::Command::new("gzip")
1007            .arg("-dc")
1008            .arg(p)
1009            .output()
1010            .with_context(|| format!("descomprimiendo {}", p.display()))?;
1011        if !out.status.success() {
1012            anyhow::bail!(
1013                "gzip -dc {} falló: {}",
1014                p.display(),
1015                String::from_utf8_lossy(&out.stderr).trim()
1016            );
1017        }
1018        return Ok(String::from_utf8_lossy(&out.stdout).into_owned());
1019    }
1020    std::fs::read_to_string(p).with_context(|| format!("leyendo {}", p.display()))
1021}
1022
1023#[cfg(test)]
1024mod tests {
1025    use super::*;
1026
1027    #[test]
1028    fn serie_de_las_dos_formas_que_se_cruzan() {
1029        assert_eq!(serie("Linux/x86 7.1.4-artix1 Kernel Configuration").as_deref(), Some("7.1"));
1030        assert_eq!(serie("linux-6.16.12").as_deref(), Some("6.16"));
1031        assert_eq!(serie("sin versión"), None);
1032    }
1033}