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 1572Shell 203Rust 109Markdown 993728 Definitions

⎇ 7433bcba8308b39d7c5e2565cf67dcb46d0d3940 crates/hammer-cli/src/kernel_cmd.rs Rust · 1557 lines · 55.6 KB History Who touched what raw

kernel: un contrato de capacidades que hace RUIDOSA la falta de MEMCG 7433bcba8 · 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/// Ídem para el contrato de capacidades (SDD 25 §4).
20const CONTRACT_ENV: &str = "HAMMER_KERNEL_CONTRACT";
21/// Sitios donde buscarlo sin que nadie diga nada: el del repo y el del sistema instalado.
22const CATALOG_DEFAULTS: &[&str] = &[
23    "docs/state/kernel-bundles.toml",
24    "/usr/share/hammer/kernel-bundles.toml",
25];
26const CONTRACT_DEFAULTS: &[&str] = &[
27    "docs/state/kernel-contract.toml",
28    "/usr/share/hammer/kernel-contract.toml",
29];
30
31#[derive(Subcommand)]
32pub enum KernelCmd {
33    /// Analiza el grafo de Kconfig: qué muere si se apagan estos símbolos, y por dónde se cuela un
34    /// `select` de vuelta.
35    ///
36    /// Es la herramienta con la que se decide si un bundle «por clausura» es viable (SDD 22 §3).
37    /// No construye ni escribe nada.
38    Closure {
39        /// Símbolos raíz a apagar, SIN el prefijo `CONFIG_` (p.ej. `WIRELESS`).
40        #[arg(required = true)]
41        symbols: Vec<String>,
42        /// Raíz del árbol de fuentes del kernel (el directorio con el `Kconfig` de arriba).
43        #[arg(long)]
44        kconfig: Option<PathBuf>,
45        /// Contrasta la clausura contra los símbolos apagados en este `.config`.
46        #[arg(long)]
47        against: Option<PathBuf>,
48        /// Cierra la clausura contra sus propias fugas: apaga también a cada selector, y repite.
49        /// Muestra el PRECIO (qué hubo que tragarse de más) en vez de aplicarlo a ciegas.
50        #[arg(long)]
51        fixpoint: bool,
52        /// Emite JSON en vez del informe legible.
53        #[arg(long)]
54        json: bool,
55        /// Cuántos símbolos de la clausura listar en el informe legible.
56        #[arg(long, default_value_t = 20)]
57        limit: usize,
58    },
59    /// **Modo reversa**: lee el kernel que ya corre y lo muestra por el lente de los bundles.
60    ///
61    /// No compila ni escribe nada. Contesta tres cosas: cuánto de cada bundle ya rige, qué
62    /// capacidad carga este kernel que esta máquina no usa, y dónde el hardware CONTRADICE a un
63    /// bundle aplicado.
64    Probe {
65        /// `.config` a mirar. Por defecto `/proc/config.gz` y si no `/boot/config-<uname -r>`.
66        #[arg(long)]
67        config: Option<PathBuf>,
68        #[arg(long)]
69        kconfig: Option<PathBuf>,
70        #[arg(long)]
71        catalog: Option<PathBuf>,
72        /// No leer el hardware (útil para analizar el config de OTRA máquina).
73        #[arg(long)]
74        no_hardware: bool,
75        #[arg(long)]
76        json: bool,
77    },
78    /// Lista el catálogo con sus clausuras ya resueltas contra un árbol de Kconfig concreto.
79    ///
80    /// Es también el control de frescura: `--check` sale distinto de cero si algún bundle nombra
81    /// símbolos que ya no existen o dejó fugas `select` sin declarar.
82    Bundles {
83        #[arg(long)]
84        kconfig: Option<PathBuf>,
85        #[arg(long)]
86        catalog: Option<PathBuf>,
87        /// Falla si el catálogo envejeció respecto de este árbol.
88        #[arg(long)]
89        check: bool,
90        #[arg(long)]
91        json: bool,
92    },
93    /// De una selección de bundles a una **receta derivada**, sin construir nada.
94    ///
95    /// El config del kernel vive en la fase `configure` y las fases entran en `hash_inputs` ⇒ el
96    /// config ES la identidad del artefacto. Por eso esto emite una receta con su propio
97    /// `ArtifactHash`, y no finge que el kernel sea un binario parametrizable.
98    Plan {
99        /// Receta base (p.ej. `recipes/linux.toml`).
100        #[arg(long)]
101        recipe: PathBuf,
102        /// Id de bundle del catálogo. Repetible.
103        #[arg(long = "bundle")]
104        bundles: Vec<String>,
105        /// Id de perilla del catálogo. Repetible.
106        #[arg(long = "knob")]
107        knobs: Vec<String>,
108        #[arg(long)]
109        kconfig: Option<PathBuf>,
110        #[arg(long)]
111        catalog: Option<PathBuf>,
112        /// Escribe el plan en JSON (el contrato con las UIs).
113        #[arg(long)]
114        out: Option<PathBuf>,
115        /// Escribe la receta derivada. Para construirla hay que dejarla donde sus `deps.build`
116        /// resuelvan — es decir, junto a la receta base.
117        #[arg(long)]
118        recipe_out: Option<PathBuf>,
119        #[arg(long)]
120        json: bool,
121    },
122    /// **Diff-back**: contrasta un plan contra el `.config` que produjo el kernel.
123    ///
124    /// Sin esto la UI miente: un `-e FOO` cuya dependencia no se cumple se pierde en silencio.
125    DiffBack {
126        /// El JSON que emitió `hammer kernel plan --out`.
127        #[arg(long)]
128        plan: PathBuf,
129        /// El `.config` resultante (el que la receta instala en `/out/boot/config-<versión>`).
130        #[arg(long)]
131        config: PathBuf,
132        #[arg(long)]
133        json: bool,
134    },
135    /// **Gate de no-regresión, por objetivo**: ¿el plan deja sin driver algún dispositivo en uso?
136    ///
137    /// No corre sin `--objective`: la regla aplicada global rechazaría recetas sanas (el kernel de
138    /// QEMU apaga USB, HID e INPUT a propósito). Sale distinto de cero si bloquea.
139    Gate {
140        /// El JSON que emitió `hammer kernel plan --out`. Con `--config` es opcional: sólo sirve
141        /// para atribuir cada pérdida a su bundle.
142        #[arg(long)]
143        plan: Option<PathBuf>,
144        /// `.config` YA PRODUCIDO. Cambia la pregunta: en vez de «¿qué apaga el plan?» mide
145        /// «¿qué funcionaba y deja de funcionar?», que además ve los huecos de la receta base.
146        #[arg(long)]
147        config: Option<PathBuf>,
148        /// Referente contra el que medir la regresión (default `/proc/config.gz`): el kernel que
149        /// arrancó esta máquina es la prueba viva de qué hace falta para arrancarla.
150        #[arg(long)]
151        baseline: Option<PathBuf>,
152        /// Id de objetivo del catálogo (`qemu-serial`, `servidor`, `metal-escritorio`, `portatil`).
153        #[arg(long)]
154        objective: String,
155        /// Hardware de la máquina DESTINO, capturado con `hammer kernel hw --out`. Sin esto se
156        /// mira el de esta máquina, que no siempre es la misma.
157        #[arg(long)]
158        devices: Option<PathBuf>,
159        #[arg(long)]
160        kconfig: Option<PathBuf>,
161        #[arg(long)]
162        catalog: Option<PathBuf>,
163        #[arg(long)]
164        json: bool,
165    },
166    /// Vuelca el hardware de esta máquina (huella + dispositivos + drivers bindeados).
167    ///
168    /// Se corre en la máquina DESTINO y el JSON viaja a la de build: el gate necesita saber qué
169    /// funciona allá, no acá.
170    Hw {
171        #[arg(long)]
172        out: Option<PathBuf>,
173    },
174    /// **Contrato de capacidades**: ¿este kernel trae lo que el userland usa por nombre?
175    ///
176    /// El punto ciego que cierra (SDD 25 §4): sin `CONFIG_MEMCG` el fichero `memory.max` no
177    /// existe, el escritor de arje descarta el error y una Card corre SIN tope de memoria dejando
178    /// sólo un `warn!`. La máquina de desarrollo no lo ve porque su kernel sí lo trae. Se comprueba
179    /// contra un `.config` YA PRODUCIDO, no contra la receta: entre el `scripts/config -e X` y el
180    /// `.config` hay un `olddefconfig` que puede tragarse el símbolo en silencio.
181    ///
182    /// Sale distinto de cero si falta una capacidad EXIGIDA por el perfil.
183    Contract {
184        /// `.config` a comprobar. Repetible. Por defecto, el del kernel vivo.
185        #[arg(long = "config")]
186        configs: Vec<PathBuf>,
187        /// Barre todos los kernels sellados del store (`<hash>-<nombre>/boot/config-*`).
188        #[arg(long)]
189        sealed: bool,
190        /// Perfil del contrato. Obligatorio salvo que el nombre del artefacto ya lo declare.
191        #[arg(long)]
192        profile: Option<String>,
193        #[arg(long)]
194        contract: Option<PathBuf>,
195        /// Lista el contrato (capacidades, perfiles y objetivos) y no comprueba nada.
196        #[arg(long)]
197        list: bool,
198        #[arg(long)]
199        json: bool,
200    },
201    /// Estadísticas del árbol de Kconfig parseado (control de salud del lector).
202    Stats {
203        #[arg(long)]
204        kconfig: Option<PathBuf>,
205        /// Muestra las primeras N advertencias del parseo.
206        #[arg(long, default_value_t = 10)]
207        warnings: usize,
208    },
209}
210
211pub fn run(cmd: KernelCmd, store: &str) -> Result<()> {
212    match cmd {
213        KernelCmd::Stats { kconfig, warnings } => stats(&kconfig_root(kconfig)?, warnings),
214        KernelCmd::Probe {
215            config,
216            kconfig,
217            catalog,
218            no_hardware,
219            json,
220        } => probe(
221            config.as_deref(),
222            &kconfig_root(kconfig)?,
223            &catalog_path(catalog)?,
224            !no_hardware,
225            json,
226        ),
227        KernelCmd::Bundles {
228            kconfig,
229            catalog,
230            check,
231            json,
232        } => bundles(&kconfig_root(kconfig)?, &catalog_path(catalog)?, check, json),
233        KernelCmd::Plan {
234            recipe,
235            bundles: bs,
236            knobs,
237            kconfig,
238            catalog,
239            out,
240            recipe_out,
241            json,
242        } => plan(
243            &recipe,
244            &bs,
245            &knobs,
246            &kconfig_root(kconfig)?,
247            &catalog_path(catalog)?,
248            out.as_deref(),
249            recipe_out.as_deref(),
250            store,
251            json,
252        ),
253        KernelCmd::DiffBack { plan, config, json } => diff_back(&plan, &config, json),
254        KernelCmd::Gate {
255            plan,
256            config,
257            baseline,
258            objective,
259            devices,
260            kconfig,
261            catalog,
262            json,
263        } => gate(
264            plan.as_deref(),
265            config.as_deref(),
266            baseline.as_deref(),
267            &objective,
268            devices.as_deref(),
269            &kconfig_root(kconfig)?,
270            &catalog_path(catalog)?,
271            json,
272        ),
273        KernelCmd::Hw { out } => hw(out.as_deref()),
274        KernelCmd::Contract {
275            configs,
276            sealed,
277            profile,
278            contract,
279            list,
280            json,
281        } => contract_cmd(
282            &configs,
283            sealed,
284            profile.as_deref(),
285            &contract_path(contract)?,
286            store,
287            list,
288            json,
289        ),
290        KernelCmd::Closure {
291            symbols,
292            kconfig,
293            against,
294            fixpoint,
295            json,
296            limit,
297        } => closure(
298            &symbols,
299            &kconfig_root(kconfig)?,
300            against.as_deref(),
301            fixpoint,
302            json,
303            limit,
304        ),
305    }
306}
307
308/// Resuelve la raíz del árbol de Kconfig: `--kconfig` gana, si no la variable de entorno.
309fn kconfig_root(flag: Option<PathBuf>) -> Result<PathBuf> {
310    if let Some(p) = flag {
311        return Ok(p);
312    }
313    match std::env::var_os(KCONFIG_ENV) {
314        Some(v) => Ok(PathBuf::from(v)),
315        None => anyhow::bail!(
316            "falta el árbol de fuentes del kernel: pasá --kconfig <dir> o exportá {KCONFIG_ENV}"
317        ),
318    }
319}
320
321/// Resuelve el catálogo de bundles: `--catalog`, si no la variable de entorno, si no el del repo.
322fn catalog_path(flag: Option<PathBuf>) -> Result<PathBuf> {
323    if let Some(p) = flag {
324        return Ok(p);
325    }
326    if let Some(v) = std::env::var_os(CATALOG_ENV) {
327        return Ok(PathBuf::from(v));
328    }
329    for c in CATALOG_DEFAULTS {
330        let p = PathBuf::from(c);
331        if p.is_file() {
332            return Ok(p);
333        }
334    }
335    anyhow::bail!(
336        "no encontré el catálogo de bundles: pasá --catalog <path> o exportá {CATALOG_ENV}"
337    )
338}
339
340/// Resuelve el contrato de capacidades: `--contract`, si no la variable de entorno, si no el repo.
341fn contract_path(flag: Option<PathBuf>) -> Result<PathBuf> {
342    if let Some(p) = flag {
343        return Ok(p);
344    }
345    if let Some(v) = std::env::var_os(CONTRACT_ENV) {
346        return Ok(PathBuf::from(v));
347    }
348    for c in CONTRACT_DEFAULTS {
349        let p = PathBuf::from(c);
350        if p.is_file() {
351            return Ok(p);
352        }
353    }
354    anyhow::bail!("no encontré el contrato de kernel: pasá --contract <path> o exportá {CONTRACT_ENV}")
355}
356
357/// Dónde está el `.config` del kernel que corre. `/proc/config.gz` es el camino soberano (lo emite
358/// el propio kernel); el de `/boot` es el respaldo para kernels compilados sin `IKCONFIG_PROC`.
359fn running_config_path() -> Result<PathBuf> {
360    let gz = PathBuf::from("/proc/config.gz");
361    if gz.is_file() {
362        return Ok(gz);
363    }
364    if let Ok(out) = std::process::Command::new("uname").arg("-r").output() {
365        let rel = String::from_utf8_lossy(&out.stdout).trim().to_string();
366        let p = PathBuf::from(format!("/boot/config-{rel}"));
367        if p.is_file() {
368            return Ok(p);
369        }
370    }
371    anyhow::bail!(
372        "no encontré el config del kernel vivo (ni /proc/config.gz ni /boot/config-<release>).\n\
373         Pasá --config <path> para mirar el de otra máquina."
374    )
375}
376
377fn load_tree(root: &Path) -> Result<KconfigTree> {
378    if !root.join("Kconfig").is_file() {
379        anyhow::bail!(
380            "{} no parece un árbol de fuentes de kernel (falta ./Kconfig).\n\
381             Pasá --kconfig <dir> o exportá {KCONFIG_ENV}.",
382            root.display()
383        );
384    }
385    KconfigTree::parse(root, &KconfigTree::vars_x86_64())
386        .with_context(|| format!("parseando {}", root.display()))
387}
388
389fn stats(root: &Path, show_warnings: usize) -> Result<()> {
390    let t = load_tree(root)?;
391    let visible = t.symbols.values().filter(|s| s.is_visible()).count();
392    let multi = t.symbols.values().filter(|s| s.defs.len() > 1).count();
393    let with_help = t.symbols.values().filter(|s| s.help().is_some()).count();
394    let selects: usize = t.symbols.values().map(|s| s.select_targets().len()).sum();
395    let implies: usize = t.symbols.values().map(|s| s.imply_targets().len()).sum();
396
397    println!("árbol      {}", root.display());
398    println!("ficheros   {}", t.files.len());
399    println!("símbolos   {}", t.symbols.len());
400    println!("  visibles {visible} (marcables en menuconfig)");
401    println!("  con ayuda {with_help}");
402    println!("  definidos en >1 sitio {multi}");
403    println!("aristas    select {selects} · imply {implies}");
404    println!("avisos     {}", t.warnings.len());
405    for w in t.warnings.iter().take(show_warnings) {
406        println!("  · {w}");
407    }
408    if t.warnings.len() > show_warnings {
409        println!("  … y {} más", t.warnings.len() - show_warnings);
410    }
411    Ok(())
412}
413
414#[derive(Serialize)]
415struct ClosureReport {
416    roots: Vec<String>,
417    /// Todo lo que queda inalcanzable si se apagan las raíces (incluidas las raíces).
418    closure: Vec<String>,
419    /// De la clausura, los que un humano puede marcar en `menuconfig`.
420    visible: usize,
421    /// `select` que entran desde fuera: reencienden algo de la clausura ignorando sus `depends on`.
422    leaks: Vec<hammer_core::kernel::SelectLeak>,
423    /// Símbolos distintos de la clausura alcanzados por esas fugas.
424    leaked_targets: usize,
425    #[serde(skip_serializing_if = "Option::is_none")]
426    fixpoint: Option<FixpointReport>,
427    #[serde(skip_serializing_if = "Option::is_none")]
428    against: Option<AgainstReport>,
429}
430
431/// El precio de cerrar el bundle contra sus fugas.
432#[derive(Serialize)]
433struct FixpointReport {
434    /// La clausura ya cerrada, entera.
435    closure: Vec<String>,
436    /// Tamaño de la clausura una vez cerrada.
437    closure_len: usize,
438    /// Cuánto creció respecto de la clausura estricta.
439    grew_by: usize,
440    rounds: usize,
441    /// Lo que hubo que apagar de más, con la ronda en que apareció.
442    added: Vec<(usize, String)>,
443    /// Fugas que sobrevivieron al tope de rondas. Vacío = cerró.
444    remaining_leaks: Vec<hammer_core::kernel::SelectLeak>,
445}
446
447/// Contraste contra un `.config` real: la validación §3 del SDD 22.
448#[derive(Serialize)]
449struct AgainstReport {
450    config: String,
451    /// De la clausura, los que el `.config` tiene ENCENDIDOS (la clausura predice de más, o el
452    /// config tiene una fuga real).
453    on_in_config: Vec<String>,
454    /// De la clausura, los que el `.config` apaga explícitamente.
455    off_in_config: usize,
456    /// De la clausura, los que el `.config` ni menciona.
457    absent_in_config: usize,
458}
459
460fn closure(
461    roots: &[String],
462    kroot: &Path,
463    against: Option<&Path>,
464    fixpoint: bool,
465    json: bool,
466    limit: usize,
467) -> Result<()> {
468    let t = load_tree(kroot)?;
469
470    // Avisar de raíces que no existen: un símbolo mal escrito daría clausura vacía en silencio,
471    // que es exactamente el modo de fallo que este comando existe para no tener.
472    let desconocidas: Vec<&String> = roots.iter().filter(|r| !t.symbols.contains_key(*r)).collect();
473    if !desconocidas.is_empty() {
474        eprintln!(
475            "aviso: {} símbolo(s) no existen en este árbol: {}",
476            desconocidas.len(),
477            desconocidas
478                .iter()
479                .map(|s| s.as_str())
480                .collect::<Vec<_>>()
481                .join(", ")
482        );
483    }
484
485    let cl = t.closure_off(roots);
486    let leaks = t.select_leaks(&cl);
487    let leaked_targets = leaks
488        .iter()
489        .map(|l| l.target.as_str())
490        .collect::<BTreeSet<_>>()
491        .len();
492    let visible = cl
493        .iter()
494        .filter(|s| t.symbols.get(*s).is_some_and(|s| s.is_visible()))
495        .count();
496
497    let fx = fixpoint.then(|| {
498        let f = t.closure_off_fixpoint(roots, 16);
499        FixpointReport {
500            closure_len: f.closure.len(),
501            closure: f.closure.iter().cloned().collect(),
502            grew_by: f.closure.len().saturating_sub(cl.len()),
503            rounds: f.rounds,
504            added: f.added,
505            remaining_leaks: f.remaining_leaks,
506        }
507    });
508
509    let against_report = match against {
510        None => None,
511        Some(p) => {
512            let text = read_maybe_gz(p)?;
513            let cfg = KernelConfig::parse(&text);
514            let on: Vec<String> = cl.iter().filter(|s| cfg.is_on(s)).cloned().collect();
515            let off = cl
516                .iter()
517                .filter(|s| matches!(cfg.get(s), Some(hammer_core::kernel::ConfigValue::No)))
518                .count();
519            let absent = cl.iter().filter(|s| cfg.get(s).is_none()).count();
520            Some(AgainstReport {
521                config: p.display().to_string(),
522                on_in_config: on,
523                off_in_config: off,
524                absent_in_config: absent,
525            })
526        }
527    };
528
529    let rep = ClosureReport {
530        roots: roots.to_vec(),
531        closure: cl.iter().cloned().collect(),
532        visible,
533        leaks,
534        leaked_targets,
535        fixpoint: fx,
536        against: against_report,
537    };
538
539    if json {
540        println!("{}", serde_json::to_string_pretty(&rep)?);
541        return Ok(());
542    }
543
544    println!("raíces apagadas   {}", rep.roots.join(" "));
545    println!(
546        "clausura          {} símbolo(s) ({} visibles en menuconfig)",
547        rep.closure.len(),
548        rep.visible
549    );
550    for s in rep.closure.iter().take(limit) {
551        let prompt = t
552            .symbols
553            .get(s)
554            .and_then(|x| x.prompt())
555            .unwrap_or("(sin prompt: sólo entra por select)");
556        println!("  · {s} — {prompt}");
557    }
558    if rep.closure.len() > limit {
559        println!("  … y {} más", rep.closure.len() - limit);
560    }
561
562    println!();
563    if rep.leaks.is_empty() {
564        println!("fugas por `select`  ninguna — la clausura es cerrada");
565    } else {
566        println!(
567            "fugas por `select`  {} arista(s) desde fuera reencienden {} símbolo(s) de la clausura",
568            rep.leaks.len(),
569            rep.leaked_targets
570        );
571        println!("  (`select` FUERZA el destino ignorando sus `depends on`: apagar la raíz no basta)");
572        let mut por_destino: BTreeMap<&str, Vec<&str>> = BTreeMap::new();
573        for l in &rep.leaks {
574            por_destino
575                .entry(&l.target)
576                .or_default()
577                .push(&l.selector);
578        }
579        for (target, selectors) in por_destino.iter().take(limit) {
580            println!("  · {target} ← {}", selectors.join(", "));
581        }
582        if por_destino.len() > limit {
583            println!("  … y {} destino(s) más", por_destino.len() - limit);
584        }
585    }
586
587    if let Some(f) = &rep.fixpoint {
588        println!();
589        println!(
590            "punto fijo        {} símbolo(s) tras {} ronda(s) — {:+} sobre la clausura estricta",
591            f.closure_len, f.rounds, f.grew_by as i64
592        );
593        if f.remaining_leaks.is_empty() {
594            println!("  cerró: ninguna fuga sobrevive");
595        } else {
596            println!(
597                "  ⚠ NO cerró: {} fuga(s) siguen abiertas tras el tope de rondas",
598                f.remaining_leaks.len()
599            );
600        }
601        if f.added.is_empty() {
602            println!("  no hizo falta apagar nada de más");
603        } else {
604            println!("  precio: {} símbolo(s) apagados de más", f.added.len());
605            for (r, s) in f.added.iter().take(limit) {
606                println!("    · ronda {r}: {s}");
607            }
608            if f.added.len() > limit {
609                println!("    … y {} más", f.added.len() - limit);
610            }
611        }
612    }
613
614    if let Some(a) = &rep.against {
615        println!();
616        println!("contraste contra {}", a.config);
617        println!("  apagados explícitos  {}", a.off_in_config);
618        println!("  ausentes             {}", a.absent_in_config);
619        println!("  ENCENDIDOS           {}", a.on_in_config.len());
620        for s in a.on_in_config.iter().take(limit) {
621            println!("    · {s}");
622        }
623        if a.on_in_config.len() > limit {
624            println!("    … y {} más", a.on_in_config.len() - limit);
625        }
626    }
627    Ok(())
628}
629
630// ---------------------------------------------------------------------------------------------
631// probe — el modo reversa (#9)
632// ---------------------------------------------------------------------------------------------
633
634fn probe(
635    config: Option<&Path>,
636    kroot: &Path,
637    catalog: &Path,
638    with_hw: bool,
639    json: bool,
640) -> Result<()> {
641    use hammer_core::kernel::reverse::{BundleState, HwSignal};
642
643    let cfg_path = match config {
644        Some(p) => p.to_path_buf(),
645        None => running_config_path()?,
646    };
647    let cfg = KernelConfig::parse(&read_maybe_gz(&cfg_path)?);
648    if cfg.is_empty() {
649        anyhow::bail!("{} no tiene un solo CONFIG_*: ¿es un .config?", cfg_path.display());
650    }
651    let tree = load_tree(kroot)?;
652    let cat = hammer_core::kernel::Catalog::load(catalog)?;
653    let hw = with_hw.then(hammer_core::kernel::Hardware::probe_local);
654    let rep = hammer_core::kernel::reverse::analyze(&cat, &tree, &cfg, hw.as_ref());
655
656    if json {
657        println!("{}", serde_json::to_string_pretty(&rep)?);
658        return Ok(());
659    }
660
661    println!("config      {}", cfg_path.display());
662    if let Some(k) = &rep.kernel {
663        println!("kernel      {k}");
664    }
665    // El modo reversa cruza DOS árboles: el del config vivo y el que se parseó. Si no son la misma
666    // versión, las clausuras son aproximadas — un símbolo puede haberse renombrado o retirado entre
667    // medias (que es justo lo que le pasó a THUNDERBOLT y a REISERFS_FS). Vale igual, pero decirlo.
668    if let (Some(banner), Some(rev)) = (&rep.kernel, &cat.reviewed_against) {
669        if let (Some(a), Some(b)) = (serie(banner), serie(rev)) {
670            if a != b {
671                println!(
672                    "            ⚠ el config vivo es de la serie {a} y el catálogo se revisó contra \
673                     la {b}: las clausuras son aproximadas"
674                );
675            }
676        }
677    }
678    println!("símbolos    {} declarados", rep.config_symbols);
679    if let Some(h) = &hw {
680        println!("huella      {}", rep.fingerprint);
681        if let Some(m) = &h.cpu_model {
682            println!("            {m}");
683        }
684        let maquina = [h.dmi.get("sys_vendor"), h.dmi.get("product_name")]
685            .into_iter()
686            .flatten()
687            .cloned()
688            .collect::<Vec<_>>()
689            .join(" ");
690        if !maquina.is_empty() {
691            println!("            {maquina}");
692        }
693        println!(
694            "            {} dispositivos PCI · {} drivers bindeados",
695            h.pci.len(),
696            h.bound_drivers.len()
697        );
698        for g in &h.gaps {
699            println!("            ⚠ {g}");
700        }
701    }
702    println!();
703
704    for b in &rep.bundles {
705        let on = b.on_builtin + b.on_module;
706        let estado = match b.state {
707            BundleState::Aplicado => "APLICADO ",
708            BundleState::Parcial => "parcial  ",
709            BundleState::Sin => "sin      ",
710        };
711        let hwtxt = match &b.hardware {
712            HwSignal::SinSeñal => String::new(),
713            HwSignal::Ausente => "  · hardware ausente".to_string(),
714            HwSignal::Presente { evidencia } => {
715                format!("  · ⚠ HARDWARE PRESENTE ({})", evidencia.len())
716            }
717        };
718        println!(
719            "{estado} {:<28} {on:>5}/{:<5} encendidos{hwtxt}",
720            b.id, b.closure_len
721        );
722        if let HwSignal::Presente { evidencia } = &b.hardware {
723            for e in evidencia.iter().take(4) {
724                println!("              {e}");
725            }
726        }
727        if !b.unknown_symbols.is_empty() {
728            println!(
729                "              ⚠ símbolos que este kernel no tiene: {}",
730                b.unknown_symbols.join(", ")
731            );
732        }
733        if !b.undeclared_leaks.is_empty() {
734            println!(
735                "              ⚠ {} fuga(s) `select` sin declarar en el catálogo",
736                b.undeclared_leaks.len()
737            );
738        }
739    }
740
741    println!();
742    println!(
743        "resumen     {} bundle(s): {} ya aplicados, {} con capacidad que este hardware NO usa",
744        rep.bundles.len(),
745        rep.bundles
746            .iter()
747            .filter(|b| b.state == BundleState::Aplicado)
748            .count(),
749        rep.applicable_savings
750    );
751    if rep.contradictions > 0 {
752        println!(
753            "            ⚠ {} bundle(s) aplicados CONTRADICEN al hardware presente",
754            rep.contradictions
755        );
756    }
757    println!("            (detectar sirve para contradecir, nunca para podar solo)");
758    Ok(())
759}
760
761/// Serie `mayor.menor` de un texto que contenga una versión (`"Linux/x86 7.1.4-artix1 …"` → `7.1`,
762/// `"linux-6.16.12"` → `6.16`). `None` si no hay ninguna.
763fn serie(texto: &str) -> Option<String> {
764    for tok in texto.split(|c: char| !(c.is_ascii_digit() || c == '.')) {
765        let mut it = tok.split('.');
766        if let (Some(a), Some(b)) = (it.next(), it.next()) {
767            if !a.is_empty() && !b.is_empty() {
768                return Some(format!("{a}.{b}"));
769            }
770        }
771    }
772    None
773}
774
775// ---------------------------------------------------------------------------------------------
776// bundles — catálogo + control de frescura
777// ---------------------------------------------------------------------------------------------
778
779#[derive(Serialize)]
780struct BundleView {
781    id: String,
782    title: String,
783    #[serde(skip_serializing_if = "Option::is_none")]
784    help: Option<String>,
785    disable: Vec<String>,
786    close_leaks: Vec<String>,
787    closure_len: usize,
788    visible: usize,
789    unknown_symbols: Vec<String>,
790    undeclared_leaks: Vec<hammer_core::kernel::SelectLeak>,
791    accepted_leaks: usize,
792}
793
794#[derive(Serialize)]
795struct BundlesReport {
796    catalog: String,
797    #[serde(skip_serializing_if = "Option::is_none")]
798    reviewed_against: Option<String>,
799    bundles: Vec<BundleView>,
800    knobs: Vec<KnobView>,
801    stale: bool,
802}
803
804#[derive(Serialize)]
805struct KnobView {
806    id: String,
807    title: String,
808    side: hammer_core::kernel::Side,
809    enable: Vec<String>,
810    disable: Vec<String>,
811    #[serde(skip_serializing_if = "Option::is_none")]
812    recipe_field: Option<String>,
813    unknown_symbols: Vec<String>,
814}
815
816fn bundles(kroot: &Path, catalog: &Path, check: bool, json: bool) -> Result<()> {
817    let tree = load_tree(kroot)?;
818    let cat = hammer_core::kernel::Catalog::load(catalog)?;
819
820    let mut views = Vec::new();
821    for b in &cat.bundles {
822        let mut roots = b.disable.clone();
823        roots.extend(b.close_leaks.iter().cloned());
824        let unknown: Vec<String> = roots
825            .iter()
826            .chain(b.enable.iter())
827            .filter(|s| !tree.symbols.contains_key(*s))
828            .cloned()
829            .collect();
830        let cl = tree.closure_off(&roots);
831        let aceptadas: BTreeSet<&str> = b.accept_leaks.keys().map(|s| s.as_str()).collect();
832        let undeclared: Vec<_> = tree
833            .select_leaks(&cl)
834            .into_iter()
835            .filter(|l| !aceptadas.contains(l.target.as_str()))
836            .collect();
837        views.push(BundleView {
838            id: b.id.clone(),
839            title: b.title.clone(),
840            help: b.help.clone(),
841            disable: b.disable.clone(),
842            close_leaks: b.close_leaks.clone(),
843            closure_len: cl.len(),
844            visible: cl
845                .iter()
846                .filter(|s| tree.symbols.get(*s).is_some_and(|x| x.is_visible()))
847                .count(),
848            unknown_symbols: unknown,
849            undeclared_leaks: undeclared,
850            accepted_leaks: b.accept_leaks.len(),
851        });
852    }
853
854    let knobs: Vec<KnobView> = cat
855        .knobs
856        .iter()
857        .map(|k| KnobView {
858            id: k.id.clone(),
859            title: k.title.clone(),
860            side: k.side,
861            enable: k.enable.clone(),
862            disable: k.disable.clone(),
863            recipe_field: k.recipe_field.clone(),
864            unknown_symbols: k
865                .enable
866                .iter()
867                .chain(k.disable.iter())
868                .filter(|s| !tree.symbols.contains_key(*s))
869                .cloned()
870                .collect(),
871        })
872        .collect();
873
874    let stale = views
875        .iter()
876        .any(|v| !v.unknown_symbols.is_empty() || !v.undeclared_leaks.is_empty())
877        || knobs.iter().any(|k| !k.unknown_symbols.is_empty());
878
879    let rep = BundlesReport {
880        catalog: catalog.display().to_string(),
881        reviewed_against: cat.reviewed_against.clone(),
882        bundles: views,
883        knobs,
884        stale,
885    };
886
887    if json {
888        println!("{}", serde_json::to_string_pretty(&rep)?);
889    } else {
890        println!("catálogo    {}", rep.catalog);
891        if let Some(r) = &rep.reviewed_against {
892            println!("revisado contra {r}");
893        }
894        println!("árbol       {}", kroot.display());
895        println!();
896        println!("NIVEL 1 — aserciones del dueño de la máquina");
897        for v in &rep.bundles {
898            println!(
899                "  {:<26} {:>5} símbolos ({} visibles)  ← {}",
900                v.id,
901                v.closure_len,
902                v.visible,
903                v.disable.join(" ")
904            );
905            println!("     {}", v.title);
906            if !v.close_leaks.is_empty() {
907                println!("     fugas cerradas: {}", v.close_leaks.join(", "));
908            }
909            if v.accepted_leaks > 0 {
910                println!("     fugas aceptadas a sabiendas: {}", v.accepted_leaks);
911            }
912            if !v.unknown_symbols.is_empty() {
913                println!(
914                    "     ⚠ NO EXISTEN en este árbol: {}",
915                    v.unknown_symbols.join(", ")
916                );
917            }
918            for l in v.undeclared_leaks.iter().take(6) {
919                println!("     ⚠ fuga sin declarar: {} ← {}", l.target, l.selector);
920            }
921            if v.undeclared_leaks.len() > 6 {
922                println!("     ⚠ … y {} fuga(s) más", v.undeclared_leaks.len() - 6);
923            }
924        }
925        println!();
926        println!("NIVEL 2 — política y rendimiento (ojo al lado del que cae cada perilla)");
927        for k in &rep.knobs {
928            let lado = match k.side {
929                hammer_core::kernel::Side::Kconfig => "kconfig",
930                hammer_core::kernel::Side::Recipe => "receta ",
931            };
932            let que = match k.side {
933                hammer_core::kernel::Side::Kconfig => {
934                    let mut v: Vec<String> =
935                        k.enable.iter().map(|s| format!("+{s}")).collect();
936                    v.extend(k.disable.iter().map(|s| format!("-{s}")));
937                    v.join(" ")
938                }
939                hammer_core::kernel::Side::Recipe => {
940                    k.recipe_field.clone().unwrap_or_default()
941                }
942            };
943            println!("  [{lado}] {:<26} {que}", k.id);
944            println!("     {}", k.title);
945            if !k.unknown_symbols.is_empty() {
946                println!(
947                    "     ⚠ NO EXISTEN en este árbol: {}",
948                    k.unknown_symbols.join(", ")
949                );
950            }
951        }
952        println!();
953        if rep.stale {
954            println!("⚠ el catálogo ENVEJECIÓ respecto de este árbol (símbolos idos o fugas nuevas)");
955        } else {
956            println!("✓ el catálogo está al día con este árbol");
957        }
958    }
959
960    if check && rep.stale {
961        anyhow::bail!("catálogo desactualizado");
962    }
963    Ok(())
964}
965
966// ---------------------------------------------------------------------------------------------
967// plan / diff-back
968// ---------------------------------------------------------------------------------------------
969
970#[allow(clippy::too_many_arguments)]
971fn plan(
972    recipe_path: &Path,
973    bundle_ids: &[String],
974    knob_ids: &[String],
975    kroot: &Path,
976    catalog: &Path,
977    out: Option<&Path>,
978    recipe_out: Option<&Path>,
979    store: &str,
980    json: bool,
981) -> Result<()> {
982    if bundle_ids.is_empty() && knob_ids.is_empty() {
983        anyhow::bail!("un plan sin bundles ni perillas es la receta base: pasá --bundle/--knob");
984    }
985    let base = hammer_core::Recipe::load_from_path(recipe_path)?;
986    let tree = load_tree(kroot)?;
987    let cat = hammer_core::kernel::Catalog::load(catalog)?;
988
989    // Las recetas de kernel del repo NO son todas la misma versión (`linux`/`linux-metal` van por
990    // 6.16.12; `linux-metal-dual`/`linux-generic` por 7.1.2). Planear una contra el árbol de la otra
991    // calcularía clausuras sobre símbolos que ahí no existen, y saldría SIN RUIDO. Como el plan
992    // determina un artefacto, acá se falla en vez de avisar.
993    match tree.version.as_deref() {
994        Some(v) if v != base.version => anyhow::bail!(
995            "la receta «{}» es del kernel {} y el árbol de {} es el {v}.\n\
996             Las clausuras saldrían calculadas sobre otro juego de símbolos. Pasá el árbol correcto \
997             con --kconfig.",
998            base.name,
999            base.version,
1000            kroot.display()
1001        ),
1002        None => eprintln!(
1003            "aviso: {} no trae el Makefile de arriba, así que no puedo comprobar que sea el kernel \
1004             {} de la receta «{}»",
1005            kroot.display(),
1006            base.version,
1007            base.name
1008        ),
1009        Some(_) => {}
1010    }
1011
1012    let mut p = hammer_core::kernel::plan::make_plan(
1013        &base,
1014        &recipe_path.display().to_string(),
1015        &cat,
1016        &tree,
1017        bundle_ids,
1018        knob_ids,
1019    )?;
1020
1021    // El plan DETERMINA el artefacto (SDD 22 §1), así que el hash va en el plan: la UI puede decir
1022    // «esto ya está construido y firmado» sin construir nada. Si las deps no están resueltas, se
1023    // dice que no se pudo en vez de emitir un hash inventado.
1024    let derived = hammer_core::kernel::plan::derive_recipe(&base, &p);
1025    match hammer_core::Store::open(Path::new(store))
1026        .map_err(anyhow::Error::from)
1027        .and_then(|s| {
1028            let cfg = hammer_build::BuildConfig::from_env_or_defaults(s.root());
1029            let lab = hammer_core::lab::LabFingerprint::from_rootfs(&cfg.rootfs)
1030                .map_err(anyhow::Error::from)?;
1031            hammer_build::artifact_hash(&derived, &s, &lab).map_err(anyhow::Error::from)
1032        })
1033    {
1034        Ok(h) => p.artifact_hash = Some(h.to_string()),
1035        Err(e) => eprintln!("aviso: no pude calcular el ArtifactHash ({e})"),
1036    }
1037
1038    if let Some(o) = out {
1039        if let Some(d) = o.parent() {
1040            std::fs::create_dir_all(d).ok();
1041        }
1042        std::fs::write(o, serde_json::to_string_pretty(&p)?)
1043            .with_context(|| format!("escribiendo {}", o.display()))?;
1044    }
1045    if let Some(ro) = recipe_out {
1046        if let Some(d) = ro.parent() {
1047            std::fs::create_dir_all(d).ok();
1048        }
1049        let cabecera = format!(
1050            "# Receta DERIVADA, generada por `hammer kernel plan`. No editar a mano: regenerar.\n\
1051             #   base    {}\n#   bundles {}\n#   perillas {}\n\
1052             # Para construirla tiene que vivir junto a la receta base, o sus `deps.build` no\n\
1053             # resuelven ({}).\n",
1054            p.base_recipe,
1055            if p.bundles.is_empty() { "—".into() } else { p.bundles.join(" ") },
1056            if p.knobs.is_empty() { "—".into() } else { p.knobs.join(" ") },
1057            recipe_path
1058                .parent()
1059                .map(|d| d.display().to_string())
1060                .unwrap_or_default(),
1061        );
1062        let cuerpo = hammer_core::kernel::plan::render_recipe_toml(&derived)?;
1063        std::fs::write(ro, format!("{cabecera}{cuerpo}"))
1064            .with_context(|| format!("escribiendo {}", ro.display()))?;
1065    }
1066
1067    if json {
1068        println!("{}", serde_json::to_string_pretty(&p)?);
1069        return Ok(());
1070    }
1071
1072    println!("base        {} ({})", p.base_name, p.base_recipe);
1073    println!("derivada    {}", p.derived_name);
1074    match &p.artifact_hash {
1075        Some(h) => println!("hash        {h}"),
1076        None => println!("hash        (no calculado)"),
1077    }
1078    println!(
1079        "selección   {} bundle(s), {} perilla(s)",
1080        p.bundles.len(),
1081        p.knobs.len()
1082    );
1083    println!();
1084    println!("emite {} bandera(s); con su clausura caen {} símbolo(s)", p.symbols.len(), p.closure_size);
1085    println!("(se emiten RAÍCES: la clausura la calcula el olddefconfig del propio kernel)");
1086    for s in &p.symbols {
1087        let signo = match s.action {
1088            hammer_core::kernel::plan::Action::Enable => "+",
1089            hammer_core::kernel::plan::Action::Disable => "-",
1090        };
1091        println!("  {signo}{:<24} ← {}  ({})", s.symbol, s.from, s.reason);
1092    }
1093    if !p.unknown_symbols.is_empty() {
1094        println!();
1095        println!("⚠ {} símbolo(s) pedidos NO EXISTEN en este árbol y no se emiten:", p.unknown_symbols.len());
1096        for s in &p.unknown_symbols {
1097            println!("  {} ← {}", s.symbol, s.from);
1098        }
1099    }
1100    if !p.recipe_side.is_empty() {
1101        println!();
1102        println!("perillas del lado RECETA (declaradas, este plan no las aplica):");
1103        for k in &p.recipe_side {
1104            println!("  {:<24} {} — {}", k.id, k.recipe_field, k.title);
1105        }
1106    }
1107    println!();
1108    if let Some(o) = out {
1109        println!("plan     → {}", o.display());
1110    }
1111    if let Some(ro) = recipe_out {
1112        println!("receta   → {}", ro.display());
1113    }
1114    Ok(())
1115}
1116
1117fn diff_back(plan_path: &Path, config: &Path, json: bool) -> Result<()> {
1118    let p: hammer_core::kernel::Plan = serde_json::from_str(
1119        &std::fs::read_to_string(plan_path)
1120            .with_context(|| format!("leyendo {}", plan_path.display()))?,
1121    )
1122    .with_context(|| format!("{} no es un plan de `hammer kernel plan`", plan_path.display()))?;
1123    let cfg = KernelConfig::parse(&read_maybe_gz(config)?);
1124    if cfg.is_empty() {
1125        anyhow::bail!("{} no tiene un solo CONFIG_*: ¿es un .config?", config.display());
1126    }
1127    let d = hammer_core::kernel::diff_back(&p, &cfg);
1128
1129    if json {
1130        println!("{}", serde_json::to_string_pretty(&d)?);
1131    } else {
1132        println!("plan        {} ({})", p.derived_name, plan_path.display());
1133        println!("config      {}", config.display());
1134        println!("cumplidos   {}", d.honored.len());
1135        // Kconfig no emite un símbolo cuyas dependencias no se cumplen: para un `-d`, que no
1136        // aparezca ES el resultado buscado. Contarlo como fallo reportaba planes perfectos como
1137        // rotos — lo aprendí corriendo un olddefconfig de verdad.
1138        println!("apagados por dependencia  {} (no aparecen porque cayeron con su raíz)", d.off_by_dependency.len());
1139        if !d.never_existed.is_empty() {
1140            println!("no existen en este árbol  {}", d.never_existed.len());
1141            for s in &d.never_existed {
1142                println!("  {s} — la bandera no hace nada");
1143            }
1144        }
1145        println!("INCUMPLIDOS {}", d.violated.len());
1146        for v in &d.violated {
1147            println!(
1148                "  {} — se pidió {:?} y quedó {} (lo pidió {})",
1149                v.symbol, v.asked, v.got, v.from
1150            );
1151        }
1152    }
1153    if !d.is_clean() {
1154        anyhow::bail!("el .config no honra el plan");
1155    }
1156    Ok(())
1157}
1158
1159// ---------------------------------------------------------------------------------------------
1160// gate / hw
1161// ---------------------------------------------------------------------------------------------
1162
1163fn hw(out: Option<&Path>) -> Result<()> {
1164    let h = hammer_core::kernel::Hardware::probe_local();
1165    let texto = serde_json::to_string_pretty(&h)?;
1166    match out {
1167        Some(o) => {
1168            if let Some(d) = o.parent() {
1169                std::fs::create_dir_all(d).ok();
1170            }
1171            std::fs::write(o, &texto).with_context(|| format!("escribiendo {}", o.display()))?;
1172            println!("huella   {}", h.fingerprint());
1173            println!("hardware → {}", o.display());
1174            println!("  {} PCI · {} USB · {} drivers bindeados", h.pci.len(), h.usb.len(), h.bound_drivers.len());
1175            for g in &h.gaps {
1176                println!("  ⚠ {g}");
1177            }
1178        }
1179        None => println!("{texto}"),
1180    }
1181    Ok(())
1182}
1183
1184#[allow(clippy::too_many_arguments)]
1185fn gate(
1186    plan_path: Option<&Path>,
1187    config: Option<&Path>,
1188    baseline: Option<&Path>,
1189    objective: &str,
1190    devices: Option<&Path>,
1191    kroot: &Path,
1192    catalog: &Path,
1193    json: bool,
1194) -> Result<()> {
1195    if plan_path.is_none() && config.is_none() {
1196        anyhow::bail!("pasá --plan (qué apagaría) o --config (qué quedó): sin uno de los dos no hay nada que comparar");
1197    }
1198    let p: Option<hammer_core::kernel::Plan> = match plan_path {
1199        None => None,
1200        Some(pp) => Some(
1201            serde_json::from_str(
1202                &std::fs::read_to_string(pp).with_context(|| format!("leyendo {}", pp.display()))?,
1203            )
1204            .with_context(|| format!("{} no es un plan de `hammer kernel plan`", pp.display()))?,
1205        ),
1206    };
1207    let cat = hammer_core::kernel::Catalog::load(catalog)?;
1208    let tree = load_tree(kroot)?;
1209    let modmap = hammer_core::kernel::ModMap::parse(kroot)
1210        .with_context(|| format!("leyendo los Makefiles de {}", kroot.display()))?;
1211    if modmap.is_empty() {
1212        anyhow::bail!(
1213            "no encontré una sola regla `obj-$(CONFIG_…)` en {}: sin los Makefiles del kernel el \
1214             gate no puede mapear driver → símbolo, y un gate que no puede mirar no es un gate.",
1215            kroot.display()
1216        );
1217    }
1218
1219    let hardware = match devices {
1220        Some(d) => serde_json::from_str::<hammer_core::kernel::Hardware>(
1221            &std::fs::read_to_string(d).with_context(|| format!("leyendo {}", d.display()))?,
1222        )
1223        .with_context(|| format!("{} no es un volcado de `hammer kernel hw`", d.display()))?,
1224        None => hammer_core::kernel::Hardware::probe_local(),
1225    };
1226
1227    let r = match config {
1228        // Modo fuerte: comparar dos configs reales. Ve también los huecos que ya venían en la base.
1229        Some(c) => {
1230            let base_path = baseline
1231                .map(|b| b.to_path_buf())
1232                .map_or_else(running_config_path, Ok)?;
1233            let base = KernelConfig::parse(&read_maybe_gz(&base_path)?);
1234            let nuevo = KernelConfig::parse(&read_maybe_gz(c)?);
1235            if nuevo.is_empty() {
1236                anyhow::bail!("{} no tiene un solo CONFIG_*: ¿es un .config?", c.display());
1237            }
1238            println!("referente   {} ({} símbolos)", base_path.display(), base.len());
1239            hammer_core::kernel::gate::run_against_config(
1240                &base,
1241                &nuevo,
1242                &cat,
1243                &modmap,
1244                &hardware.bound_drivers,
1245                objective,
1246                p.as_ref(),
1247                Some(&tree),
1248            )?
1249        }
1250        None => hammer_core::kernel::gate::run(
1251            p.as_ref().expect("validado arriba"),
1252            &cat,
1253            &tree,
1254            &modmap,
1255            &hardware.bound_drivers,
1256            objective,
1257        )?,
1258    };
1259
1260    if json {
1261        println!("{}", serde_json::to_string_pretty(&r)?);
1262    } else {
1263        let obj = cat.objective(objective).map(|o| o.title.as_str()).unwrap_or("");
1264        if !r.plan.is_empty() {
1265            println!("plan        {}", r.plan);
1266        }
1267        println!("objetivo    {} — {obj}", r.objective);
1268        println!(
1269            "hardware    {} ({} drivers bindeados, {} reglas obj- en {} Makefiles)",
1270            devices.map(|d| d.display().to_string()).unwrap_or_else(|| "esta máquina".into()),
1271            r.drivers_in_use,
1272            modmap.len(),
1273            modmap.files
1274        );
1275        println!();
1276        if r.regressions.is_empty() {
1277            println!("✓ ningún dispositivo en uso se queda sin driver");
1278        } else {
1279            println!("✗ {} REGRESIÓN(ES) — dispositivos en uso que se quedarían sin driver:", r.regressions.len());
1280            for l in &r.regressions {
1281                // Sin culpable atribuido la causa NO es el plan: es que la receta base nunca
1282                // enciende ese símbolo. Decir «lo apaga una perilla» mandaría a buscar donde no está.
1283                let causa = if l.blamed_on.is_empty() {
1284                    "la receta base no lo enciende".to_string()
1285                } else {
1286                    format!("lo apaga {}", l.blamed_on.join(", "))
1287                };
1288                println!("  {:<20} {causa}  (símbolos: {})", l.driver, l.symbols.join(", "));
1289            }
1290        }
1291        if !r.allowed.is_empty() {
1292            println!();
1293            println!("{} pérdida(s) AUTORIZADAS por el objetivo:", r.allowed.len());
1294            for l in &r.allowed {
1295                println!("  {:<20} ← {}", l.driver, l.blamed_on.join(", "));
1296            }
1297        }
1298        if !r.unmapped.is_empty() {
1299            println!();
1300            println!(
1301                "⚠ {} driver(s) SIN COMPROBAR (no se pudieron mapear a ningún CONFIG_*):",
1302                r.unmapped.len()
1303            );
1304            println!("  {}", r.unmapped.join(" "));
1305            println!("  no cuentan como aprobados: el gate no los miró.");
1306        }
1307    }
1308
1309    if r.verdict == hammer_core::kernel::Verdict::Bloquea {
1310        anyhow::bail!("el gate bloquea");
1311    }
1312    Ok(())
1313}
1314
1315// ---------------------------------------------------------------------------------------------
1316// contract — el contrato de capacidades (SDD 25 §4)
1317// ---------------------------------------------------------------------------------------------
1318
1319/// Nombre del artefacto si la ruta cae dentro de un directorio `<64 hex>-<nombre>` del store.
1320///
1321/// Es lo que permite que `--sealed` sepa qué perfil le toca a cada kernel sin que nadie lo escriba
1322/// dos veces. Fuera del store devuelve `None` y el perfil hay que pasarlo a mano.
1323fn artifact_of(path: &Path) -> Option<String> {
1324    for a in path.ancestors() {
1325        let name = a.file_name()?.to_string_lossy().to_string();
1326        if let Some((hash, art)) = name.split_once('-') {
1327            if hash.len() == 64 && hash.bytes().all(|b| b.is_ascii_hexdigit()) {
1328                return Some(art.to_string());
1329            }
1330        }
1331    }
1332    None
1333}
1334
1335#[derive(Serialize)]
1336struct ContractRun {
1337    contract: String,
1338    reports: Vec<hammer_core::kernel::ContractReport>,
1339    /// Configs que NO se comprobaron por no tener perfil declarado. No cuentan como aprobados.
1340    sin_perfil: Vec<String>,
1341}
1342
1343fn contract_cmd(
1344    configs: &[PathBuf],
1345    sealed: bool,
1346    profile: Option<&str>,
1347    contract: &Path,
1348    store: &str,
1349    list: bool,
1350    json: bool,
1351) -> Result<()> {
1352    use hammer_core::kernel::contract::sealed_kernel_configs;
1353    use hammer_core::kernel::Contract;
1354
1355    let c = Contract::load(contract)?;
1356
1357    if list {
1358        if json {
1359            println!("{}", serde_json::to_string_pretty(&c)?);
1360            return Ok(());
1361        }
1362        println!("contrato    {}  (v{}", contract.display(), c.version);
1363        if let Some(r) = &c.reviewed_against {
1364            println!("revisado    contra kernel {r}");
1365        }
1366        println!();
1367        let req = hammer_core::kernel::contract::required_by(&c);
1368        for cap in &c.capabilities {
1369            let quien = req.get(cap.id.as_str()).map(|v| v.join(", ")).unwrap_or_default();
1370            let quien = if quien.is_empty() { "—".to_string() } else { quien };
1371            println!("{:<24} {}", cap.id, cap.title);
1372            println!("{:<24} símbolos: {}", "", cap.symbols.join(" "));
1373            println!("{:<24} consumidor: {}", "", cap.consumer.trim().replace('\n', " "));
1374            println!("{:<24} exigida por: {quien}", "");
1375            println!();
1376        }
1377        for p in &c.profiles {
1378            println!("perfil {:<17} {}", p.id, p.title);
1379            println!("{:<24} exige: {}", "", p.requires.join(" "));
1380            println!("{:<24} quiere: {}", "", p.wants.join(" "));
1381        }
1382        println!();
1383        for t in &c.targets {
1384            println!("{:<24} → perfil {}", t.artifact, t.profile);
1385        }
1386        return Ok(());
1387    }
1388
1389    // Qué configs se miran, en orden: --sealed, --config, y si no hay nada, el kernel vivo.
1390    let mut objetivos: Vec<(String, Option<String>, PathBuf)> = Vec::new();
1391    if sealed {
1392        let s = sealed_kernel_configs(Path::new(store));
1393        if s.is_empty() {
1394            anyhow::bail!(
1395                "no hay ningún kernel sellado con `boot/config-*` en {store}.\n\
1396                 Un kernel que no instale su config no se puede comprobar — y fingir que pasó \
1397                 sería el mismo fallo que este guardián viene a arreglar."
1398            );
1399        }
1400        for (art, p) in s {
1401            objetivos.push((format!("{art} · {}", p.display()), Some(art), p));
1402        }
1403    }
1404    for p in configs {
1405        objetivos.push((p.display().to_string(), artifact_of(p), p.clone()));
1406    }
1407    if objetivos.is_empty() {
1408        let p = running_config_path()?;
1409        objetivos.push((format!("kernel vivo · {}", p.display()), None, p));
1410    }
1411
1412    let mut run = ContractRun {
1413        contract: contract.display().to_string(),
1414        reports: Vec::new(),
1415        sin_perfil: Vec::new(),
1416    };
1417
1418    for (label, artifact, path) in &objetivos {
1419        let cfg = hammer_core::kernel::KernelConfig::parse(&read_maybe_gz(path)?);
1420        if cfg.is_empty() {
1421            anyhow::bail!("{} no tiene un solo CONFIG_*: ¿es un .config?", path.display());
1422        }
1423        let prof = match profile {
1424            Some(p) => Some(p.to_string()),
1425            None => artifact
1426                .as_deref()
1427                .and_then(|a| c.profile_for(a))
1428                .map(str::to_string),
1429        };
1430        let Some(prof) = prof else {
1431            run.sin_perfil.push(label.clone());
1432            continue;
1433        };
1434        run.reports.push(c.check(&cfg, &prof, label)?);
1435    }
1436
1437    if json {
1438        println!("{}", serde_json::to_string_pretty(&run)?);
1439    } else {
1440        imprimir_contrato(&run);
1441    }
1442
1443    let bloquean = run
1444        .reports
1445        .iter()
1446        .filter(|r| r.verdict == hammer_core::kernel::Verdict::Bloquea)
1447        .count();
1448    if bloquean > 0 {
1449        anyhow::bail!("el contrato NO se cumple en {bloquean} kernel(es)");
1450    }
1451    Ok(())
1452}
1453
1454fn imprimir_contrato(run: &ContractRun) {
1455    println!("contrato    {}", run.contract);
1456    println!();
1457    for r in &run.reports {
1458        let marca = if r.verdict == hammer_core::kernel::Verdict::Bloquea { "✗" } else { "✓" };
1459        println!("{marca} {}", r.config);
1460        println!("  perfil    {}   ({} capacidad(es) miradas)", r.profile, r.checked);
1461        if let Some(k) = &r.kernel {
1462            println!("  kernel    {k}");
1463        }
1464        for m in &r.missing_required {
1465            println!("  ✗ FALTA   {} — {}", m.id, m.title);
1466            if !m.explicit_off.is_empty() {
1467                println!("            apagado a mano: {}", m.explicit_off.join(" "));
1468            }
1469            if !m.absent.is_empty() {
1470                println!(
1471                    "            ni nombrado en el .config: {} (¿renombrado entre versiones?)",
1472                    m.absent.join(" ")
1473                );
1474            }
1475            println!("            lo usa: {}", m.consumer.trim().replace('\n', " "));
1476            if let Some(s) = &m.silent {
1477                println!("            cómo falla hoy: {}", s.trim().replace('\n', " "));
1478            }
1479        }
1480        for m in &r.missing_wanted {
1481            println!(
1482                "  · falta (no bloquea) {} — {} [{}]",
1483                m.id,
1484                m.title,
1485                [m.explicit_off.clone(), m.absent.clone()].concat().join(" ")
1486            );
1487        }
1488        for u in &r.unknown {
1489            println!(
1490                "  ⚠ el perfil nombra «{}», que el contrato no define: NO se comprobó",
1491                u.id
1492            );
1493        }
1494        if r.missing_required.is_empty() {
1495            println!("  ✓ las {} capacidades exigidas están", r.ok.iter().filter(|o| o.required).count());
1496        }
1497        println!();
1498    }
1499    let malos = run
1500        .reports
1501        .iter()
1502        .filter(|r| r.verdict == hammer_core::kernel::Verdict::Bloquea)
1503        .count();
1504    println!(
1505        "resumen     {} de {} config(s) cumplen su perfil{}",
1506        run.reports.len() - malos,
1507        run.reports.len(),
1508        if run.sin_perfil.is_empty() {
1509            String::new()
1510        } else {
1511            format!(" · {} sin comprobar", run.sin_perfil.len())
1512        }
1513    );
1514    println!();
1515    if !run.sin_perfil.is_empty() {
1516        println!("⚠ {} config(s) SIN COMPROBAR (ningún perfil declarado):", run.sin_perfil.len());
1517        for s in &run.sin_perfil {
1518            println!("  {s}");
1519        }
1520        println!("  no cuentan como aprobados. Declaralos en [[target]] o pasá --profile.");
1521    }
1522}
1523
1524/// Lee un `.config`, descomprimiendo si es `.gz` (el caso de `/proc/config.gz`).
1525///
1526/// Se delega a `gzip -dc` en vez de arrastrar un descompresor al core: es una entrada de
1527/// diagnóstico, no del camino de build, y el core queda sin dependencias nuevas.
1528pub fn read_maybe_gz(p: &Path) -> Result<String> {
1529    if p.extension().is_some_and(|e| e == "gz") {
1530        let out = std::process::Command::new("gzip")
1531            .arg("-dc")
1532            .arg(p)
1533            .output()
1534            .with_context(|| format!("descomprimiendo {}", p.display()))?;
1535        if !out.status.success() {
1536            anyhow::bail!(
1537                "gzip -dc {} falló: {}",
1538                p.display(),
1539                String::from_utf8_lossy(&out.stderr).trim()
1540            );
1541        }
1542        return Ok(String::from_utf8_lossy(&out.stdout).into_owned());
1543    }
1544    std::fs::read_to_string(p).with_context(|| format!("leyendo {}", p.display()))
1545}
1546
1547#[cfg(test)]
1548mod tests {
1549    use super::*;
1550
1551    #[test]
1552    fn serie_de_las_dos_formas_que_se_cruzan() {
1553        assert_eq!(serie("Linux/x86 7.1.4-artix1 Kernel Configuration").as_deref(), Some("7.1"));
1554        assert_eq!(serie("linux-6.16.12").as_deref(), Some("6.16"));
1555        assert_eq!(serie("sin versión"), None);
1556    }
1557}