tawasuyu

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 Definiciones

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

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