gioser

sergio/takana

> Una distribución Linux construida con un laboratorio funcional y hermético en el > sótano, y una terminal mutable, clásica y anárquica en el piso de arriba — diseñada > desde el suelo para que una IA programadora entienda, modifique y co…

TOML 1389Shell 137Markdown 80Rust 802602 Definiciones

⎇ 8faede66bdacd61db2cbd8fa67027b3866fee7b2 crates/hammer-cli/src/kernel_cmd.rs Rust · 1808 líneas · 66.3 KB Historia Quién tocó qué crudo

kernel contract: un objetivo sin sellado vigente NO es un objetivo aprobado 8faede66b · 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        /// Dónde buscar las recetas de kernel para saber qué artefacto es el VIGENTE. Repetible.
191        /// Por defecto `recipes/` y `docs/state/kernel-plans/`.
192        #[arg(long = "recipes")]
193        recipes: Vec<PathBuf>,
194        /// Perfil del contrato. Obligatorio salvo que el nombre del artefacto ya lo declare.
195        #[arg(long)]
196        profile: Option<String>,
197        #[arg(long)]
198        contract: Option<PathBuf>,
199        /// Lista el contrato (capacidades, perfiles y objetivos) y no comprueba nada.
200        #[arg(long)]
201        list: bool,
202        #[arg(long)]
203        json: bool,
204    },
205    /// Estadísticas del árbol de Kconfig parseado (control de salud del lector).
206    Stats {
207        #[arg(long)]
208        kconfig: Option<PathBuf>,
209        /// Muestra las primeras N advertencias del parseo.
210        #[arg(long, default_value_t = 10)]
211        warnings: usize,
212    },
213}
214
215pub fn run(cmd: KernelCmd, store: &str) -> Result<()> {
216    match cmd {
217        KernelCmd::Stats { kconfig, warnings } => stats(&kconfig_root(kconfig)?, warnings),
218        KernelCmd::Probe {
219            config,
220            kconfig,
221            catalog,
222            no_hardware,
223            json,
224        } => probe(
225            config.as_deref(),
226            &kconfig_root(kconfig)?,
227            &catalog_path(catalog)?,
228            !no_hardware,
229            json,
230        ),
231        KernelCmd::Bundles {
232            kconfig,
233            catalog,
234            check,
235            json,
236        } => bundles(&kconfig_root(kconfig)?, &catalog_path(catalog)?, check, json),
237        KernelCmd::Plan {
238            recipe,
239            bundles: bs,
240            knobs,
241            kconfig,
242            catalog,
243            out,
244            recipe_out,
245            json,
246        } => plan(
247            &recipe,
248            &bs,
249            &knobs,
250            &kconfig_root(kconfig)?,
251            &catalog_path(catalog)?,
252            out.as_deref(),
253            recipe_out.as_deref(),
254            store,
255            json,
256        ),
257        KernelCmd::DiffBack { plan, config, json } => diff_back(&plan, &config, json),
258        KernelCmd::Gate {
259            plan,
260            config,
261            baseline,
262            objective,
263            devices,
264            kconfig,
265            catalog,
266            json,
267        } => gate(
268            plan.as_deref(),
269            config.as_deref(),
270            baseline.as_deref(),
271            &objective,
272            devices.as_deref(),
273            &kconfig_root(kconfig)?,
274            &catalog_path(catalog)?,
275            json,
276        ),
277        KernelCmd::Hw { out } => hw(out.as_deref()),
278        KernelCmd::Contract {
279            configs,
280            sealed,
281            recipes,
282            profile,
283            contract,
284            list,
285            json,
286        } => contract_cmd(
287            &configs,
288            sealed,
289            &recipes,
290            profile.as_deref(),
291            &contract_path(contract)?,
292            store,
293            list,
294            json,
295        ),
296        KernelCmd::Closure {
297            symbols,
298            kconfig,
299            against,
300            fixpoint,
301            json,
302            limit,
303        } => closure(
304            &symbols,
305            &kconfig_root(kconfig)?,
306            against.as_deref(),
307            fixpoint,
308            json,
309            limit,
310        ),
311    }
312}
313
314/// Resuelve la raíz del árbol de Kconfig: `--kconfig` gana, si no la variable de entorno.
315fn kconfig_root(flag: Option<PathBuf>) -> Result<PathBuf> {
316    if let Some(p) = flag {
317        return Ok(p);
318    }
319    match std::env::var_os(KCONFIG_ENV) {
320        Some(v) => Ok(PathBuf::from(v)),
321        None => anyhow::bail!(
322            "falta el árbol de fuentes del kernel: pasá --kconfig <dir> o exportá {KCONFIG_ENV}"
323        ),
324    }
325}
326
327/// Resuelve el catálogo de bundles: `--catalog`, si no la variable de entorno, si no el del repo.
328fn catalog_path(flag: Option<PathBuf>) -> Result<PathBuf> {
329    if let Some(p) = flag {
330        return Ok(p);
331    }
332    if let Some(v) = std::env::var_os(CATALOG_ENV) {
333        return Ok(PathBuf::from(v));
334    }
335    for c in CATALOG_DEFAULTS {
336        let p = PathBuf::from(c);
337        if p.is_file() {
338            return Ok(p);
339        }
340    }
341    anyhow::bail!(
342        "no encontré el catálogo de bundles: pasá --catalog <path> o exportá {CATALOG_ENV}"
343    )
344}
345
346/// Resuelve el contrato de capacidades: `--contract`, si no la variable de entorno, si no el repo.
347fn contract_path(flag: Option<PathBuf>) -> Result<PathBuf> {
348    if let Some(p) = flag {
349        return Ok(p);
350    }
351    if let Some(v) = std::env::var_os(CONTRACT_ENV) {
352        return Ok(PathBuf::from(v));
353    }
354    for c in CONTRACT_DEFAULTS {
355        let p = PathBuf::from(c);
356        if p.is_file() {
357            return Ok(p);
358        }
359    }
360    anyhow::bail!("no encontré el contrato de kernel: pasá --contract <path> o exportá {CONTRACT_ENV}")
361}
362
363/// Dónde está el `.config` del kernel que corre. `/proc/config.gz` es el camino soberano (lo emite
364/// el propio kernel); el de `/boot` es el respaldo para kernels compilados sin `IKCONFIG_PROC`.
365fn running_config_path() -> Result<PathBuf> {
366    let gz = PathBuf::from("/proc/config.gz");
367    if gz.is_file() {
368        return Ok(gz);
369    }
370    if let Ok(out) = std::process::Command::new("uname").arg("-r").output() {
371        let rel = String::from_utf8_lossy(&out.stdout).trim().to_string();
372        let p = PathBuf::from(format!("/boot/config-{rel}"));
373        if p.is_file() {
374            return Ok(p);
375        }
376    }
377    anyhow::bail!(
378        "no encontré el config del kernel vivo (ni /proc/config.gz ni /boot/config-<release>).\n\
379         Pasá --config <path> para mirar el de otra máquina."
380    )
381}
382
383fn load_tree(root: &Path) -> Result<KconfigTree> {
384    if !root.join("Kconfig").is_file() {
385        anyhow::bail!(
386            "{} no parece un árbol de fuentes de kernel (falta ./Kconfig).\n\
387             Pasá --kconfig <dir> o exportá {KCONFIG_ENV}.",
388            root.display()
389        );
390    }
391    KconfigTree::parse(root, &KconfigTree::vars_x86_64())
392        .with_context(|| format!("parseando {}", root.display()))
393}
394
395fn stats(root: &Path, show_warnings: usize) -> Result<()> {
396    let t = load_tree(root)?;
397    let visible = t.symbols.values().filter(|s| s.is_visible()).count();
398    let multi = t.symbols.values().filter(|s| s.defs.len() > 1).count();
399    let with_help = t.symbols.values().filter(|s| s.help().is_some()).count();
400    let selects: usize = t.symbols.values().map(|s| s.select_targets().len()).sum();
401    let implies: usize = t.symbols.values().map(|s| s.imply_targets().len()).sum();
402
403    println!("árbol      {}", root.display());
404    println!("ficheros   {}", t.files.len());
405    println!("símbolos   {}", t.symbols.len());
406    println!("  visibles {visible} (marcables en menuconfig)");
407    println!("  con ayuda {with_help}");
408    println!("  definidos en >1 sitio {multi}");
409    println!("aristas    select {selects} · imply {implies}");
410    println!("avisos     {}", t.warnings.len());
411    for w in t.warnings.iter().take(show_warnings) {
412        println!("  · {w}");
413    }
414    if t.warnings.len() > show_warnings {
415        println!("  … y {} más", t.warnings.len() - show_warnings);
416    }
417    Ok(())
418}
419
420#[derive(Serialize)]
421struct ClosureReport {
422    roots: Vec<String>,
423    /// Todo lo que queda inalcanzable si se apagan las raíces (incluidas las raíces).
424    closure: Vec<String>,
425    /// De la clausura, los que un humano puede marcar en `menuconfig`.
426    visible: usize,
427    /// `select` que entran desde fuera: reencienden algo de la clausura ignorando sus `depends on`.
428    leaks: Vec<hammer_core::kernel::SelectLeak>,
429    /// Símbolos distintos de la clausura alcanzados por esas fugas.
430    leaked_targets: usize,
431    #[serde(skip_serializing_if = "Option::is_none")]
432    fixpoint: Option<FixpointReport>,
433    #[serde(skip_serializing_if = "Option::is_none")]
434    against: Option<AgainstReport>,
435}
436
437/// El precio de cerrar el bundle contra sus fugas.
438#[derive(Serialize)]
439struct FixpointReport {
440    /// La clausura ya cerrada, entera.
441    closure: Vec<String>,
442    /// Tamaño de la clausura una vez cerrada.
443    closure_len: usize,
444    /// Cuánto creció respecto de la clausura estricta.
445    grew_by: usize,
446    rounds: usize,
447    /// Lo que hubo que apagar de más, con la ronda en que apareció.
448    added: Vec<(usize, String)>,
449    /// Fugas que sobrevivieron al tope de rondas. Vacío = cerró.
450    remaining_leaks: Vec<hammer_core::kernel::SelectLeak>,
451}
452
453/// Contraste contra un `.config` real: la validación §3 del SDD 22.
454#[derive(Serialize)]
455struct AgainstReport {
456    config: String,
457    /// De la clausura, los que el `.config` tiene ENCENDIDOS (la clausura predice de más, o el
458    /// config tiene una fuga real).
459    on_in_config: Vec<String>,
460    /// De la clausura, los que el `.config` apaga explícitamente.
461    off_in_config: usize,
462    /// De la clausura, los que el `.config` ni menciona.
463    absent_in_config: usize,
464}
465
466fn closure(
467    roots: &[String],
468    kroot: &Path,
469    against: Option<&Path>,
470    fixpoint: bool,
471    json: bool,
472    limit: usize,
473) -> Result<()> {
474    let t = load_tree(kroot)?;
475
476    // Avisar de raíces que no existen: un símbolo mal escrito daría clausura vacía en silencio,
477    // que es exactamente el modo de fallo que este comando existe para no tener.
478    let desconocidas: Vec<&String> = roots.iter().filter(|r| !t.symbols.contains_key(*r)).collect();
479    if !desconocidas.is_empty() {
480        eprintln!(
481            "aviso: {} símbolo(s) no existen en este árbol: {}",
482            desconocidas.len(),
483            desconocidas
484                .iter()
485                .map(|s| s.as_str())
486                .collect::<Vec<_>>()
487                .join(", ")
488        );
489    }
490
491    let cl = t.closure_off(roots);
492    let leaks = t.select_leaks(&cl);
493    let leaked_targets = leaks
494        .iter()
495        .map(|l| l.target.as_str())
496        .collect::<BTreeSet<_>>()
497        .len();
498    let visible = cl
499        .iter()
500        .filter(|s| t.symbols.get(*s).is_some_and(|s| s.is_visible()))
501        .count();
502
503    let fx = fixpoint.then(|| {
504        let f = t.closure_off_fixpoint(roots, 16);
505        FixpointReport {
506            closure_len: f.closure.len(),
507            closure: f.closure.iter().cloned().collect(),
508            grew_by: f.closure.len().saturating_sub(cl.len()),
509            rounds: f.rounds,
510            added: f.added,
511            remaining_leaks: f.remaining_leaks,
512        }
513    });
514
515    let against_report = match against {
516        None => None,
517        Some(p) => {
518            let text = read_maybe_gz(p)?;
519            let cfg = KernelConfig::parse(&text);
520            let on: Vec<String> = cl.iter().filter(|s| cfg.is_on(s)).cloned().collect();
521            let off = cl
522                .iter()
523                .filter(|s| matches!(cfg.get(s), Some(hammer_core::kernel::ConfigValue::No)))
524                .count();
525            let absent = cl.iter().filter(|s| cfg.get(s).is_none()).count();
526            Some(AgainstReport {
527                config: p.display().to_string(),
528                on_in_config: on,
529                off_in_config: off,
530                absent_in_config: absent,
531            })
532        }
533    };
534
535    let rep = ClosureReport {
536        roots: roots.to_vec(),
537        closure: cl.iter().cloned().collect(),
538        visible,
539        leaks,
540        leaked_targets,
541        fixpoint: fx,
542        against: against_report,
543    };
544
545    if json {
546        println!("{}", serde_json::to_string_pretty(&rep)?);
547        return Ok(());
548    }
549
550    println!("raíces apagadas   {}", rep.roots.join(" "));
551    println!(
552        "clausura          {} símbolo(s) ({} visibles en menuconfig)",
553        rep.closure.len(),
554        rep.visible
555    );
556    for s in rep.closure.iter().take(limit) {
557        let prompt = t
558            .symbols
559            .get(s)
560            .and_then(|x| x.prompt())
561            .unwrap_or("(sin prompt: sólo entra por select)");
562        println!("  · {s} — {prompt}");
563    }
564    if rep.closure.len() > limit {
565        println!("  … y {} más", rep.closure.len() - limit);
566    }
567
568    println!();
569    if rep.leaks.is_empty() {
570        println!("fugas por `select`  ninguna — la clausura es cerrada");
571    } else {
572        println!(
573            "fugas por `select`  {} arista(s) desde fuera reencienden {} símbolo(s) de la clausura",
574            rep.leaks.len(),
575            rep.leaked_targets
576        );
577        println!("  (`select` FUERZA el destino ignorando sus `depends on`: apagar la raíz no basta)");
578        let mut por_destino: BTreeMap<&str, Vec<&str>> = BTreeMap::new();
579        for l in &rep.leaks {
580            por_destino
581                .entry(&l.target)
582                .or_default()
583                .push(&l.selector);
584        }
585        for (target, selectors) in por_destino.iter().take(limit) {
586            println!("  · {target} ← {}", selectors.join(", "));
587        }
588        if por_destino.len() > limit {
589            println!("  … y {} destino(s) más", por_destino.len() - limit);
590        }
591    }
592
593    if let Some(f) = &rep.fixpoint {
594        println!();
595        println!(
596            "punto fijo        {} símbolo(s) tras {} ronda(s) — {:+} sobre la clausura estricta",
597            f.closure_len, f.rounds, f.grew_by as i64
598        );
599        if f.remaining_leaks.is_empty() {
600            println!("  cerró: ninguna fuga sobrevive");
601        } else {
602            println!(
603                "  ⚠ NO cerró: {} fuga(s) siguen abiertas tras el tope de rondas",
604                f.remaining_leaks.len()
605            );
606        }
607        if f.added.is_empty() {
608            println!("  no hizo falta apagar nada de más");
609        } else {
610            println!("  precio: {} símbolo(s) apagados de más", f.added.len());
611            for (r, s) in f.added.iter().take(limit) {
612                println!("    · ronda {r}: {s}");
613            }
614            if f.added.len() > limit {
615                println!("    … y {} más", f.added.len() - limit);
616            }
617        }
618    }
619
620    if let Some(a) = &rep.against {
621        println!();
622        println!("contraste contra {}", a.config);
623        println!("  apagados explícitos  {}", a.off_in_config);
624        println!("  ausentes             {}", a.absent_in_config);
625        println!("  ENCENDIDOS           {}", a.on_in_config.len());
626        for s in a.on_in_config.iter().take(limit) {
627            println!("    · {s}");
628        }
629        if a.on_in_config.len() > limit {
630            println!("    … y {} más", a.on_in_config.len() - limit);
631        }
632    }
633    Ok(())
634}
635
636// ---------------------------------------------------------------------------------------------
637// probe — el modo reversa (#9)
638// ---------------------------------------------------------------------------------------------
639
640fn probe(
641    config: Option<&Path>,
642    kroot: &Path,
643    catalog: &Path,
644    with_hw: bool,
645    json: bool,
646) -> Result<()> {
647    use hammer_core::kernel::reverse::{BundleState, HwSignal};
648
649    let cfg_path = match config {
650        Some(p) => p.to_path_buf(),
651        None => running_config_path()?,
652    };
653    let cfg = KernelConfig::parse(&read_maybe_gz(&cfg_path)?);
654    if cfg.is_empty() {
655        anyhow::bail!("{} no tiene un solo CONFIG_*: ¿es un .config?", cfg_path.display());
656    }
657    let tree = load_tree(kroot)?;
658    let cat = hammer_core::kernel::Catalog::load(catalog)?;
659    let hw = with_hw.then(hammer_core::kernel::Hardware::probe_local);
660    let rep = hammer_core::kernel::reverse::analyze(&cat, &tree, &cfg, hw.as_ref());
661
662    if json {
663        println!("{}", serde_json::to_string_pretty(&rep)?);
664        return Ok(());
665    }
666
667    println!("config      {}", cfg_path.display());
668    if let Some(k) = &rep.kernel {
669        println!("kernel      {k}");
670    }
671    // El modo reversa cruza DOS árboles: el del config vivo y el que se parseó. Si no son la misma
672    // versión, las clausuras son aproximadas — un símbolo puede haberse renombrado o retirado entre
673    // medias (que es justo lo que le pasó a THUNDERBOLT y a REISERFS_FS). Vale igual, pero decirlo.
674    if let (Some(banner), Some(rev)) = (&rep.kernel, &cat.reviewed_against) {
675        if let (Some(a), Some(b)) = (serie(banner), serie(rev)) {
676            if a != b {
677                println!(
678                    "            ⚠ el config vivo es de la serie {a} y el catálogo se revisó contra \
679                     la {b}: las clausuras son aproximadas"
680                );
681            }
682        }
683    }
684    println!("símbolos    {} declarados", rep.config_symbols);
685    if let Some(h) = &hw {
686        println!("huella      {}", rep.fingerprint);
687        if let Some(m) = &h.cpu_model {
688            println!("            {m}");
689        }
690        let maquina = [h.dmi.get("sys_vendor"), h.dmi.get("product_name")]
691            .into_iter()
692            .flatten()
693            .cloned()
694            .collect::<Vec<_>>()
695            .join(" ");
696        if !maquina.is_empty() {
697            println!("            {maquina}");
698        }
699        println!(
700            "            {} dispositivos PCI · {} drivers bindeados",
701            h.pci.len(),
702            h.bound_drivers.len()
703        );
704        for g in &h.gaps {
705            println!("            ⚠ {g}");
706        }
707    }
708    println!();
709
710    for b in &rep.bundles {
711        let on = b.on_builtin + b.on_module;
712        let estado = match b.state {
713            BundleState::Aplicado => "APLICADO ",
714            BundleState::Parcial => "parcial  ",
715            BundleState::Sin => "sin      ",
716        };
717        let hwtxt = match &b.hardware {
718            HwSignal::SinSeñal => String::new(),
719            HwSignal::Ausente => "  · hardware ausente".to_string(),
720            HwSignal::Presente { evidencia } => {
721                format!("  · ⚠ HARDWARE PRESENTE ({})", evidencia.len())
722            }
723        };
724        println!(
725            "{estado} {:<28} {on:>5}/{:<5} encendidos{hwtxt}",
726            b.id, b.closure_len
727        );
728        if let HwSignal::Presente { evidencia } = &b.hardware {
729            for e in evidencia.iter().take(4) {
730                println!("              {e}");
731            }
732        }
733        if !b.unknown_symbols.is_empty() {
734            println!(
735                "              ⚠ símbolos que este kernel no tiene: {}",
736                b.unknown_symbols.join(", ")
737            );
738        }
739        if !b.undeclared_leaks.is_empty() {
740            println!(
741                "              ⚠ {} fuga(s) `select` sin declarar en el catálogo",
742                b.undeclared_leaks.len()
743            );
744        }
745    }
746
747    println!();
748    println!(
749        "resumen     {} bundle(s): {} ya aplicados, {} con capacidad que este hardware NO usa",
750        rep.bundles.len(),
751        rep.bundles
752            .iter()
753            .filter(|b| b.state == BundleState::Aplicado)
754            .count(),
755        rep.applicable_savings
756    );
757    if rep.contradictions > 0 {
758        println!(
759            "            ⚠ {} bundle(s) aplicados CONTRADICEN al hardware presente",
760            rep.contradictions
761        );
762    }
763    println!("            (detectar sirve para contradecir, nunca para podar solo)");
764    Ok(())
765}
766
767/// Serie `mayor.menor` de un texto que contenga una versión (`"Linux/x86 7.1.4-artix1 …"` → `7.1`,
768/// `"linux-6.16.12"` → `6.16`). `None` si no hay ninguna.
769fn serie(texto: &str) -> Option<String> {
770    for tok in texto.split(|c: char| !(c.is_ascii_digit() || c == '.')) {
771        let mut it = tok.split('.');
772        if let (Some(a), Some(b)) = (it.next(), it.next()) {
773            if !a.is_empty() && !b.is_empty() {
774                return Some(format!("{a}.{b}"));
775            }
776        }
777    }
778    None
779}
780
781// ---------------------------------------------------------------------------------------------
782// bundles — catálogo + control de frescura
783// ---------------------------------------------------------------------------------------------
784
785#[derive(Serialize)]
786struct BundleView {
787    id: String,
788    title: String,
789    #[serde(skip_serializing_if = "Option::is_none")]
790    help: Option<String>,
791    disable: Vec<String>,
792    close_leaks: Vec<String>,
793    closure_len: usize,
794    visible: usize,
795    unknown_symbols: Vec<String>,
796    undeclared_leaks: Vec<hammer_core::kernel::SelectLeak>,
797    accepted_leaks: usize,
798}
799
800#[derive(Serialize)]
801struct BundlesReport {
802    catalog: String,
803    #[serde(skip_serializing_if = "Option::is_none")]
804    reviewed_against: Option<String>,
805    bundles: Vec<BundleView>,
806    knobs: Vec<KnobView>,
807    stale: bool,
808}
809
810#[derive(Serialize)]
811struct KnobView {
812    id: String,
813    title: String,
814    side: hammer_core::kernel::Side,
815    enable: Vec<String>,
816    disable: Vec<String>,
817    #[serde(skip_serializing_if = "Option::is_none")]
818    recipe_field: Option<String>,
819    unknown_symbols: Vec<String>,
820}
821
822fn bundles(kroot: &Path, catalog: &Path, check: bool, json: bool) -> Result<()> {
823    let tree = load_tree(kroot)?;
824    let cat = hammer_core::kernel::Catalog::load(catalog)?;
825
826    let mut views = Vec::new();
827    for b in &cat.bundles {
828        let mut roots = b.disable.clone();
829        roots.extend(b.close_leaks.iter().cloned());
830        let unknown: Vec<String> = roots
831            .iter()
832            .chain(b.enable.iter())
833            .filter(|s| !tree.symbols.contains_key(*s))
834            .cloned()
835            .collect();
836        let cl = tree.closure_off(&roots);
837        let aceptadas: BTreeSet<&str> = b.accept_leaks.keys().map(|s| s.as_str()).collect();
838        let undeclared: Vec<_> = tree
839            .select_leaks(&cl)
840            .into_iter()
841            .filter(|l| !aceptadas.contains(l.target.as_str()))
842            .collect();
843        views.push(BundleView {
844            id: b.id.clone(),
845            title: b.title.clone(),
846            help: b.help.clone(),
847            disable: b.disable.clone(),
848            close_leaks: b.close_leaks.clone(),
849            closure_len: cl.len(),
850            visible: cl
851                .iter()
852                .filter(|s| tree.symbols.get(*s).is_some_and(|x| x.is_visible()))
853                .count(),
854            unknown_symbols: unknown,
855            undeclared_leaks: undeclared,
856            accepted_leaks: b.accept_leaks.len(),
857        });
858    }
859
860    let knobs: Vec<KnobView> = cat
861        .knobs
862        .iter()
863        .map(|k| KnobView {
864            id: k.id.clone(),
865            title: k.title.clone(),
866            side: k.side,
867            enable: k.enable.clone(),
868            disable: k.disable.clone(),
869            recipe_field: k.recipe_field.clone(),
870            unknown_symbols: k
871                .enable
872                .iter()
873                .chain(k.disable.iter())
874                .filter(|s| !tree.symbols.contains_key(*s))
875                .cloned()
876                .collect(),
877        })
878        .collect();
879
880    let stale = views
881        .iter()
882        .any(|v| !v.unknown_symbols.is_empty() || !v.undeclared_leaks.is_empty())
883        || knobs.iter().any(|k| !k.unknown_symbols.is_empty());
884
885    let rep = BundlesReport {
886        catalog: catalog.display().to_string(),
887        reviewed_against: cat.reviewed_against.clone(),
888        bundles: views,
889        knobs,
890        stale,
891    };
892
893    if json {
894        println!("{}", serde_json::to_string_pretty(&rep)?);
895    } else {
896        println!("catálogo    {}", rep.catalog);
897        if let Some(r) = &rep.reviewed_against {
898            println!("revisado contra {r}");
899        }
900        println!("árbol       {}", kroot.display());
901        println!();
902        println!("NIVEL 1 — aserciones del dueño de la máquina");
903        for v in &rep.bundles {
904            println!(
905                "  {:<26} {:>5} símbolos ({} visibles)  ← {}",
906                v.id,
907                v.closure_len,
908                v.visible,
909                v.disable.join(" ")
910            );
911            println!("     {}", v.title);
912            if !v.close_leaks.is_empty() {
913                println!("     fugas cerradas: {}", v.close_leaks.join(", "));
914            }
915            if v.accepted_leaks > 0 {
916                println!("     fugas aceptadas a sabiendas: {}", v.accepted_leaks);
917            }
918            if !v.unknown_symbols.is_empty() {
919                println!(
920                    "     ⚠ NO EXISTEN en este árbol: {}",
921                    v.unknown_symbols.join(", ")
922                );
923            }
924            for l in v.undeclared_leaks.iter().take(6) {
925                println!("     ⚠ fuga sin declarar: {} ← {}", l.target, l.selector);
926            }
927            if v.undeclared_leaks.len() > 6 {
928                println!("     ⚠ … y {} fuga(s) más", v.undeclared_leaks.len() - 6);
929            }
930        }
931        println!();
932        println!("NIVEL 2 — política y rendimiento (ojo al lado del que cae cada perilla)");
933        for k in &rep.knobs {
934            let lado = match k.side {
935                hammer_core::kernel::Side::Kconfig => "kconfig",
936                hammer_core::kernel::Side::Recipe => "receta ",
937            };
938            let que = match k.side {
939                hammer_core::kernel::Side::Kconfig => {
940                    let mut v: Vec<String> =
941                        k.enable.iter().map(|s| format!("+{s}")).collect();
942                    v.extend(k.disable.iter().map(|s| format!("-{s}")));
943                    v.join(" ")
944                }
945                hammer_core::kernel::Side::Recipe => {
946                    k.recipe_field.clone().unwrap_or_default()
947                }
948            };
949            println!("  [{lado}] {:<26} {que}", k.id);
950            println!("     {}", k.title);
951            if !k.unknown_symbols.is_empty() {
952                println!(
953                    "     ⚠ NO EXISTEN en este árbol: {}",
954                    k.unknown_symbols.join(", ")
955                );
956            }
957        }
958        println!();
959        if rep.stale {
960            println!("⚠ el catálogo ENVEJECIÓ respecto de este árbol (símbolos idos o fugas nuevas)");
961        } else {
962            println!("✓ el catálogo está al día con este árbol");
963        }
964    }
965
966    if check && rep.stale {
967        anyhow::bail!("catálogo desactualizado");
968    }
969    Ok(())
970}
971
972// ---------------------------------------------------------------------------------------------
973// plan / diff-back
974// ---------------------------------------------------------------------------------------------
975
976#[allow(clippy::too_many_arguments)]
977fn plan(
978    recipe_path: &Path,
979    bundle_ids: &[String],
980    knob_ids: &[String],
981    kroot: &Path,
982    catalog: &Path,
983    out: Option<&Path>,
984    recipe_out: Option<&Path>,
985    store: &str,
986    json: bool,
987) -> Result<()> {
988    if bundle_ids.is_empty() && knob_ids.is_empty() {
989        anyhow::bail!("un plan sin bundles ni perillas es la receta base: pasá --bundle/--knob");
990    }
991    let base = hammer_core::Recipe::load_from_path(recipe_path)?;
992    let tree = load_tree(kroot)?;
993    let cat = hammer_core::kernel::Catalog::load(catalog)?;
994
995    // Las recetas de kernel del repo NO son todas la misma versión (`linux`/`linux-metal` van por
996    // 6.16.12; `linux-metal-dual`/`linux-generic` por 7.1.2). Planear una contra el árbol de la otra
997    // calcularía clausuras sobre símbolos que ahí no existen, y saldría SIN RUIDO. Como el plan
998    // determina un artefacto, acá se falla en vez de avisar.
999    match tree.version.as_deref() {
1000        Some(v) if v != base.version => anyhow::bail!(
1001            "la receta «{}» es del kernel {} y el árbol de {} es el {v}.\n\
1002             Las clausuras saldrían calculadas sobre otro juego de símbolos. Pasá el árbol correcto \
1003             con --kconfig.",
1004            base.name,
1005            base.version,
1006            kroot.display()
1007        ),
1008        None => eprintln!(
1009            "aviso: {} no trae el Makefile de arriba, así que no puedo comprobar que sea el kernel \
1010             {} de la receta «{}»",
1011            kroot.display(),
1012            base.version,
1013            base.name
1014        ),
1015        Some(_) => {}
1016    }
1017
1018    let mut p = hammer_core::kernel::plan::make_plan(
1019        &base,
1020        &recipe_path.display().to_string(),
1021        &cat,
1022        &tree,
1023        bundle_ids,
1024        knob_ids,
1025    )?;
1026
1027    // El plan DETERMINA el artefacto (SDD 22 §1), así que el hash va en el plan: la UI puede decir
1028    // «esto ya está construido y firmado» sin construir nada. Si las deps no están resueltas, se
1029    // dice que no se pudo en vez de emitir un hash inventado.
1030    let derived = hammer_core::kernel::plan::derive_recipe(&base, &p);
1031    match hammer_core::Store::open(Path::new(store))
1032        .map_err(anyhow::Error::from)
1033        .and_then(|s| {
1034            let cfg = hammer_build::BuildConfig::from_env_or_defaults(s.root());
1035            let lab = hammer_core::lab::LabFingerprint::from_rootfs(&cfg.rootfs)
1036                .map_err(anyhow::Error::from)?;
1037            hammer_build::artifact_hash(&derived, &s, &lab).map_err(anyhow::Error::from)
1038        })
1039    {
1040        Ok(h) => p.artifact_hash = Some(h.to_string()),
1041        Err(e) => eprintln!("aviso: no pude calcular el ArtifactHash ({e})"),
1042    }
1043
1044    if let Some(o) = out {
1045        if let Some(d) = o.parent() {
1046            std::fs::create_dir_all(d).ok();
1047        }
1048        std::fs::write(o, serde_json::to_string_pretty(&p)?)
1049            .with_context(|| format!("escribiendo {}", o.display()))?;
1050    }
1051    if let Some(ro) = recipe_out {
1052        if let Some(d) = ro.parent() {
1053            std::fs::create_dir_all(d).ok();
1054        }
1055        let cabecera = format!(
1056            "# Receta DERIVADA, generada por `hammer kernel plan`. No editar a mano: regenerar.\n\
1057             #   base    {}\n#   bundles {}\n#   perillas {}\n\
1058             # Para construirla tiene que vivir junto a la receta base, o sus `deps.build` no\n\
1059             # resuelven ({}).\n",
1060            p.base_recipe,
1061            if p.bundles.is_empty() { "—".into() } else { p.bundles.join(" ") },
1062            if p.knobs.is_empty() { "—".into() } else { p.knobs.join(" ") },
1063            recipe_path
1064                .parent()
1065                .map(|d| d.display().to_string())
1066                .unwrap_or_default(),
1067        );
1068        let cuerpo = hammer_core::kernel::plan::render_recipe_toml(&derived)?;
1069        std::fs::write(ro, format!("{cabecera}{cuerpo}"))
1070            .with_context(|| format!("escribiendo {}", ro.display()))?;
1071    }
1072
1073    if json {
1074        println!("{}", serde_json::to_string_pretty(&p)?);
1075        return Ok(());
1076    }
1077
1078    println!("base        {} ({})", p.base_name, p.base_recipe);
1079    println!("derivada    {}", p.derived_name);
1080    match &p.artifact_hash {
1081        Some(h) => println!("hash        {h}"),
1082        None => println!("hash        (no calculado)"),
1083    }
1084    println!(
1085        "selección   {} bundle(s), {} perilla(s)",
1086        p.bundles.len(),
1087        p.knobs.len()
1088    );
1089    println!();
1090    println!("emite {} bandera(s); con su clausura caen {} símbolo(s)", p.symbols.len(), p.closure_size);
1091    println!("(se emiten RAÍCES: la clausura la calcula el olddefconfig del propio kernel)");
1092    for s in &p.symbols {
1093        let signo = match s.action {
1094            hammer_core::kernel::plan::Action::Enable => "+",
1095            hammer_core::kernel::plan::Action::Disable => "-",
1096        };
1097        println!("  {signo}{:<24} ← {}  ({})", s.symbol, s.from, s.reason);
1098    }
1099    if !p.unknown_symbols.is_empty() {
1100        println!();
1101        println!("⚠ {} símbolo(s) pedidos NO EXISTEN en este árbol y no se emiten:", p.unknown_symbols.len());
1102        for s in &p.unknown_symbols {
1103            println!("  {} ← {}", s.symbol, s.from);
1104        }
1105    }
1106    if !p.recipe_side.is_empty() {
1107        println!();
1108        println!("perillas del lado RECETA (declaradas, este plan no las aplica):");
1109        for k in &p.recipe_side {
1110            println!("  {:<24} {} — {}", k.id, k.recipe_field, k.title);
1111        }
1112    }
1113    println!();
1114    if let Some(o) = out {
1115        println!("plan     → {}", o.display());
1116    }
1117    if let Some(ro) = recipe_out {
1118        println!("receta   → {}", ro.display());
1119    }
1120    Ok(())
1121}
1122
1123fn diff_back(plan_path: &Path, config: &Path, json: bool) -> Result<()> {
1124    let p: hammer_core::kernel::Plan = serde_json::from_str(
1125        &std::fs::read_to_string(plan_path)
1126            .with_context(|| format!("leyendo {}", plan_path.display()))?,
1127    )
1128    .with_context(|| format!("{} no es un plan de `hammer kernel plan`", plan_path.display()))?;
1129    let cfg = KernelConfig::parse(&read_maybe_gz(config)?);
1130    if cfg.is_empty() {
1131        anyhow::bail!("{} no tiene un solo CONFIG_*: ¿es un .config?", config.display());
1132    }
1133    let d = hammer_core::kernel::diff_back(&p, &cfg);
1134
1135    if json {
1136        println!("{}", serde_json::to_string_pretty(&d)?);
1137    } else {
1138        println!("plan        {} ({})", p.derived_name, plan_path.display());
1139        println!("config      {}", config.display());
1140        println!("cumplidos   {}", d.honored.len());
1141        // Kconfig no emite un símbolo cuyas dependencias no se cumplen: para un `-d`, que no
1142        // aparezca ES el resultado buscado. Contarlo como fallo reportaba planes perfectos como
1143        // rotos — lo aprendí corriendo un olddefconfig de verdad.
1144        println!("apagados por dependencia  {} (no aparecen porque cayeron con su raíz)", d.off_by_dependency.len());
1145        if !d.never_existed.is_empty() {
1146            println!("no existen en este árbol  {}", d.never_existed.len());
1147            for s in &d.never_existed {
1148                println!("  {s} — la bandera no hace nada");
1149            }
1150        }
1151        println!("INCUMPLIDOS {}", d.violated.len());
1152        for v in &d.violated {
1153            println!(
1154                "  {} — se pidió {:?} y quedó {} (lo pidió {})",
1155                v.symbol, v.asked, v.got, v.from
1156            );
1157        }
1158    }
1159    if !d.is_clean() {
1160        anyhow::bail!("el .config no honra el plan");
1161    }
1162    Ok(())
1163}
1164
1165// ---------------------------------------------------------------------------------------------
1166// gate / hw
1167// ---------------------------------------------------------------------------------------------
1168
1169fn hw(out: Option<&Path>) -> Result<()> {
1170    let h = hammer_core::kernel::Hardware::probe_local();
1171    let texto = serde_json::to_string_pretty(&h)?;
1172    match out {
1173        Some(o) => {
1174            if let Some(d) = o.parent() {
1175                std::fs::create_dir_all(d).ok();
1176            }
1177            std::fs::write(o, &texto).with_context(|| format!("escribiendo {}", o.display()))?;
1178            println!("huella   {}", h.fingerprint());
1179            println!("hardware → {}", o.display());
1180            println!("  {} PCI · {} USB · {} drivers bindeados", h.pci.len(), h.usb.len(), h.bound_drivers.len());
1181            for g in &h.gaps {
1182                println!("  ⚠ {g}");
1183            }
1184        }
1185        None => println!("{texto}"),
1186    }
1187    Ok(())
1188}
1189
1190#[allow(clippy::too_many_arguments)]
1191fn gate(
1192    plan_path: Option<&Path>,
1193    config: Option<&Path>,
1194    baseline: Option<&Path>,
1195    objective: &str,
1196    devices: Option<&Path>,
1197    kroot: &Path,
1198    catalog: &Path,
1199    json: bool,
1200) -> Result<()> {
1201    if plan_path.is_none() && config.is_none() {
1202        anyhow::bail!("pasá --plan (qué apagaría) o --config (qué quedó): sin uno de los dos no hay nada que comparar");
1203    }
1204    let p: Option<hammer_core::kernel::Plan> = match plan_path {
1205        None => None,
1206        Some(pp) => Some(
1207            serde_json::from_str(
1208                &std::fs::read_to_string(pp).with_context(|| format!("leyendo {}", pp.display()))?,
1209            )
1210            .with_context(|| format!("{} no es un plan de `hammer kernel plan`", pp.display()))?,
1211        ),
1212    };
1213    let cat = hammer_core::kernel::Catalog::load(catalog)?;
1214    let tree = load_tree(kroot)?;
1215    let modmap = hammer_core::kernel::ModMap::parse(kroot)
1216        .with_context(|| format!("leyendo los Makefiles de {}", kroot.display()))?;
1217    if modmap.is_empty() {
1218        anyhow::bail!(
1219            "no encontré una sola regla `obj-$(CONFIG_…)` en {}: sin los Makefiles del kernel el \
1220             gate no puede mapear driver → símbolo, y un gate que no puede mirar no es un gate.",
1221            kroot.display()
1222        );
1223    }
1224
1225    let hardware = match devices {
1226        Some(d) => serde_json::from_str::<hammer_core::kernel::Hardware>(
1227            &std::fs::read_to_string(d).with_context(|| format!("leyendo {}", d.display()))?,
1228        )
1229        .with_context(|| format!("{} no es un volcado de `hammer kernel hw`", d.display()))?,
1230        None => hammer_core::kernel::Hardware::probe_local(),
1231    };
1232
1233    let r = match config {
1234        // Modo fuerte: comparar dos configs reales. Ve también los huecos que ya venían en la base.
1235        Some(c) => {
1236            let base_path = baseline
1237                .map(|b| b.to_path_buf())
1238                .map_or_else(running_config_path, Ok)?;
1239            let base = KernelConfig::parse(&read_maybe_gz(&base_path)?);
1240            let nuevo = KernelConfig::parse(&read_maybe_gz(c)?);
1241            if nuevo.is_empty() {
1242                anyhow::bail!("{} no tiene un solo CONFIG_*: ¿es un .config?", c.display());
1243            }
1244            println!("referente   {} ({} símbolos)", base_path.display(), base.len());
1245            hammer_core::kernel::gate::run_against_config(
1246                &base,
1247                &nuevo,
1248                &cat,
1249                &modmap,
1250                &hardware.bound_drivers,
1251                objective,
1252                p.as_ref(),
1253                Some(&tree),
1254            )?
1255        }
1256        None => hammer_core::kernel::gate::run(
1257            p.as_ref().expect("validado arriba"),
1258            &cat,
1259            &tree,
1260            &modmap,
1261            &hardware.bound_drivers,
1262            objective,
1263        )?,
1264    };
1265
1266    if json {
1267        println!("{}", serde_json::to_string_pretty(&r)?);
1268    } else {
1269        let obj = cat.objective(objective).map(|o| o.title.as_str()).unwrap_or("");
1270        if !r.plan.is_empty() {
1271            println!("plan        {}", r.plan);
1272        }
1273        println!("objetivo    {} — {obj}", r.objective);
1274        println!(
1275            "hardware    {} ({} drivers bindeados, {} reglas obj- en {} Makefiles)",
1276            devices.map(|d| d.display().to_string()).unwrap_or_else(|| "esta máquina".into()),
1277            r.drivers_in_use,
1278            modmap.len(),
1279            modmap.files
1280        );
1281        println!();
1282        if r.regressions.is_empty() {
1283            println!("✓ ningún dispositivo en uso se queda sin driver");
1284        } else {
1285            println!("✗ {} REGRESIÓN(ES) — dispositivos en uso que se quedarían sin driver:", r.regressions.len());
1286            for l in &r.regressions {
1287                // Sin culpable atribuido la causa NO es el plan: es que la receta base nunca
1288                // enciende ese símbolo. Decir «lo apaga una perilla» mandaría a buscar donde no está.
1289                let causa = if l.blamed_on.is_empty() {
1290                    "la receta base no lo enciende".to_string()
1291                } else {
1292                    format!("lo apaga {}", l.blamed_on.join(", "))
1293                };
1294                println!("  {:<20} {causa}  (símbolos: {})", l.driver, l.symbols.join(", "));
1295            }
1296        }
1297        if !r.allowed.is_empty() {
1298            println!();
1299            println!("{} pérdida(s) AUTORIZADAS por el objetivo:", r.allowed.len());
1300            for l in &r.allowed {
1301                println!("  {:<20} ← {}", l.driver, l.blamed_on.join(", "));
1302            }
1303        }
1304        if !r.unmapped.is_empty() {
1305            println!();
1306            println!(
1307                "⚠ {} driver(s) SIN COMPROBAR (no se pudieron mapear a ningún CONFIG_*):",
1308                r.unmapped.len()
1309            );
1310            println!("  {}", r.unmapped.join(" "));
1311            println!("  no cuentan como aprobados: el gate no los miró.");
1312        }
1313    }
1314
1315    if r.verdict == hammer_core::kernel::Verdict::Bloquea {
1316        anyhow::bail!("el gate bloquea");
1317    }
1318    Ok(())
1319}
1320
1321// ---------------------------------------------------------------------------------------------
1322// contract — el contrato de capacidades (SDD 25 §4)
1323// ---------------------------------------------------------------------------------------------
1324
1325/// Nombre del artefacto si la ruta cae dentro de un directorio `<64 hex>-<nombre>` del store.
1326///
1327/// Es lo que permite que `--sealed` sepa qué perfil le toca a cada kernel sin que nadie lo escriba
1328/// dos veces. Fuera del store devuelve `None` y el perfil hay que pasarlo a mano.
1329fn artifact_of(path: &Path) -> Option<String> {
1330    for a in path.ancestors() {
1331        let name = a.file_name()?.to_string_lossy().to_string();
1332        if let Some((hash, art)) = name.split_once('-') {
1333            if hash.len() == 64 && hash.bytes().all(|b| b.is_ascii_hexdigit()) {
1334                return Some(art.to_string());
1335            }
1336        }
1337    }
1338    None
1339}
1340
1341/// Dónde viven las recetas de kernel cuando nadie pasa `--recipes`. La segunda es la de las
1342/// recetas DERIVADAS que emite `hammer kernel plan` (p. ej. `linux-gioser`).
1343const RECIPE_DEFAULTS: [&str; 2] = ["recipes", "docs/state/kernel-plans"];
1344
1345/// Nombre del directorio del store (`<64 hex>-<nombre>`) que contiene esta ruta.
1346fn store_dir_of(path: &Path) -> Option<String> {
1347    for a in path.ancestors() {
1348        let name = a.file_name()?.to_string_lossy().to_string();
1349        if let Some((hash, _)) = name.split_once('-') {
1350            if hash.len() == 64 && hash.bytes().all(|b| b.is_ascii_hexdigit()) {
1351                return Some(name);
1352            }
1353        }
1354    }
1355    None
1356}
1357
1358/// ¿Resuelven las `deps.build` de esta receta desde `dir`? Misma regla que el build: hermana, y
1359/// si no, en el directorio padre (que es como una receta de `recipes/incoming-*/` alcanza el
1360/// catálogo).
1361fn deps_resuelven_desde(dir: &Path, deps: &[String]) -> bool {
1362    deps.iter().all(|d| {
1363        let f = format!("{d}.toml");
1364        dir.join(&f).exists() || dir.parent().is_some_and(|p| p.join(&f).exists())
1365    })
1366}
1367
1368/// La receta cuyo campo `name` es `artifact`, buscada en `dirs`.
1369///
1370/// Primero por convención (`<dir>/<artifact>.toml`) y sólo si eso no da, barriendo el directorio:
1371/// **el artefacto se nombra por el campo `name`, no por el fichero**, y en este corpus hay recetas
1372/// donde los dos difieren.
1373fn receta_de(artifact: &str, dirs: &[PathBuf]) -> Option<hammer_core::Recipe> {
1374    for d in dirs {
1375        let directo = d.join(format!("{artifact}.toml"));
1376        if let Ok(r) = hammer_core::Recipe::load_from_path(&directo) {
1377            if r.name == artifact {
1378                return Some(con_catalogo(r, dirs));
1379            }
1380        }
1381        let Ok(rd) = std::fs::read_dir(d) else { continue };
1382        for e in rd.flatten() {
1383            let p = e.path();
1384            if p.extension().is_some_and(|x| x == "toml") {
1385                if let Ok(r) = hammer_core::Recipe::load_from_path(&p) {
1386                    if r.name == artifact {
1387                        return Some(con_catalogo(r, dirs));
1388                    }
1389                }
1390            }
1391        }
1392    }
1393    None
1394}
1395
1396/// Le presta un catálogo a una receta que vive fuera de uno.
1397///
1398/// Las recetas DERIVADAS (`hammer kernel plan --recipe-out`) se guardan a propósito fuera de
1399/// `recipes/`: dejarlas ahí las mete en el grafo compartido como deuda. Pero sus `deps.build` son
1400/// las del catálogo padre, así que desde su directorio no resuelven y el hash no se puede calcular
1401/// — y sin hash no hay vigencia que comparar. Acá se les apunta el `base_dir` al primer directorio
1402/// de `dirs` donde sí resuelven, que es exactamente lo que hace el runbook cuando la copia a
1403/// `recipes/incoming-kernel/` para construirla.
1404///
1405/// NO se toca si la receta trae `patches`: `base_dir` también los resuelve, y moverlo los rompería
1406/// en silencio. Ahí es mejor no saber la vigencia (se comprueba todo) que hashear otra cosa.
1407fn con_catalogo(mut r: hammer_core::Recipe, dirs: &[PathBuf]) -> hammer_core::Recipe {
1408    if r.deps.build.is_empty() || deps_resuelven_desde(&r.base_dir, &r.deps.build) {
1409        return r;
1410    }
1411    if !r.source.patches.is_empty() {
1412        eprintln!(
1413            "aviso: «{}» vive fuera de un catálogo y trae patches ⇒ no le presto base_dir",
1414            r.name
1415        );
1416        return r;
1417    }
1418    if let Some(d) = dirs.iter().find(|d| deps_resuelven_desde(d, &r.deps.build)) {
1419        r.base_dir = d.clone();
1420    }
1421    r
1422}
1423
1424/// Qué directorio del store le toca HOY a cada kernel del contrato: `artefacto → <hash>-<nombre>`.
1425///
1426/// POR QUÉ. El store guarda TODOS los sellados, no el último. Sin esta pregunta `--sealed` mide
1427/// también los kernels que la receta de hoy ya no produce, y entonces el gate se queda rojo para
1428/// siempre por artefactos que nadie va a volver a construir: **un portón que no puede ponerse
1429/// verde deja de leerse, que es exactamente como se pierde un guardián**. Con ella, el superado se
1430/// informa (sigue siendo cierto que le falta la capacidad: si un USB por ahí arranca ese kernel,
1431/// sus Cards corren sin tope) pero no bloquea.
1432///
1433/// Es la misma vigencia que `hammer hash --check`: la del ArtifactHash de la receta de hoy con el
1434/// lab de hoy — o sea que un kernel sellado con otro rootfs también sale superado, y es correcto
1435/// (SDD 25: el lab entra en el artefacto).
1436fn dirs_vigentes(
1437    c: &hammer_core::kernel::Contract,
1438    dirs: &[PathBuf],
1439    store: &str,
1440) -> Result<std::collections::BTreeMap<String, String>> {
1441    let st = hammer_core::Store::open(Path::new(store))?;
1442    let cfg = hammer_build::BuildConfig::from_env_or_defaults(st.root());
1443    let lab = hammer_core::lab::LabFingerprint::from_rootfs(&cfg.rootfs)?;
1444    let mut out = std::collections::BTreeMap::new();
1445    for t in &c.targets {
1446        let Some(r) = receta_de(&t.artifact, dirs) else {
1447            eprintln!(
1448                "aviso: no encontré la receta de «{}» en {} ⇒ sus sellados se comprueban todos",
1449                t.artifact,
1450                dirs.iter().map(|d| d.display().to_string()).collect::<Vec<_>>().join(" ")
1451            );
1452            continue;
1453        };
1454        match hammer_build::artifact_hash(&r, &st, &lab) {
1455            Ok(h) => {
1456                out.insert(t.artifact.clone(), h.store_dir_name(&r.name));
1457            }
1458            Err(e) => eprintln!("aviso: no pude calcular el hash vigente de «{}»: {e}", t.artifact),
1459        }
1460    }
1461    Ok(out)
1462}
1463
1464#[derive(Serialize)]
1465struct ContractRun {
1466    contract: String,
1467    reports: Vec<hammer_core::kernel::ContractReport>,
1468    /// Sellados que la receta de HOY ya no produce. Se informan y no bloquean: ver `dirs_vigentes`.
1469    #[serde(default)]
1470    superados: Vec<hammer_core::kernel::ContractReport>,
1471    /// Configs que NO se comprobaron por no tener perfil declarado. No cuentan como aprobados.
1472    sin_perfil: Vec<String>,
1473    /// Objetivos del contrato cuya receta de hoy NO tiene ni un sellado en el store. No se
1474    /// comprobó nada de ellos: se nombran para que la ausencia no pase por aprobado.
1475    #[serde(default)]
1476    sin_construir: Vec<String>,
1477}
1478
1479fn contract_cmd(
1480    configs: &[PathBuf],
1481    sealed: bool,
1482    recipes: &[PathBuf],
1483    profile: Option<&str>,
1484    contract: &Path,
1485    store: &str,
1486    list: bool,
1487    json: bool,
1488) -> Result<()> {
1489    use hammer_core::kernel::contract::sealed_kernel_configs;
1490    use hammer_core::kernel::Contract;
1491
1492    let c = Contract::load(contract)?;
1493
1494    if list {
1495        if json {
1496            println!("{}", serde_json::to_string_pretty(&c)?);
1497            return Ok(());
1498        }
1499        println!("contrato    {}  (v{}", contract.display(), c.version);
1500        if let Some(r) = &c.reviewed_against {
1501            println!("revisado    contra kernel {r}");
1502        }
1503        println!();
1504        let req = hammer_core::kernel::contract::required_by(&c);
1505        for cap in &c.capabilities {
1506            let quien = req.get(cap.id.as_str()).map(|v| v.join(", ")).unwrap_or_default();
1507            let quien = if quien.is_empty() { "—".to_string() } else { quien };
1508            println!("{:<24} {}", cap.id, cap.title);
1509            println!("{:<24} símbolos: {}", "", cap.symbols.join(" "));
1510            println!("{:<24} consumidor: {}", "", cap.consumer.trim().replace('\n', " "));
1511            println!("{:<24} exigida por: {quien}", "");
1512            println!();
1513        }
1514        for p in &c.profiles {
1515            println!("perfil {:<17} {}", p.id, p.title);
1516            println!("{:<24} exige: {}", "", p.requires.join(" "));
1517            println!("{:<24} quiere: {}", "", p.wants.join(" "));
1518        }
1519        println!();
1520        for t in &c.targets {
1521            println!("{:<24} → perfil {}", t.artifact, t.profile);
1522        }
1523        return Ok(());
1524    }
1525
1526    // Qué configs se miran, en orden: --sealed, --config, y si no hay nada, el kernel vivo.
1527    let mut objetivos: Vec<(String, Option<String>, PathBuf)> = Vec::new();
1528
1529    // Vigencia: sólo tiene sentido en el barrido del store. Un `--config` a mano es una pregunta
1530    // explícita sobre ESE fichero y se comprueba siempre.
1531    let vigentes = if sealed {
1532        let dirs: Vec<PathBuf> = if recipes.is_empty() {
1533            RECIPE_DEFAULTS.iter().map(PathBuf::from).filter(|d| d.is_dir()).collect()
1534        } else {
1535            recipes.to_vec()
1536        };
1537        if dirs.is_empty() {
1538            eprintln!(
1539                "aviso: no hay dónde mirar las recetas ({}) ⇒ no puedo distinguir vigente de \
1540                 superado y comprueba TODO lo sellado",
1541                RECIPE_DEFAULTS.join(" ")
1542            );
1543            None
1544        } else {
1545            match dirs_vigentes(&c, &dirs, store) {
1546                Ok(v) => Some(v),
1547                Err(e) => {
1548                    // Callar acá sería fingir que todo está al día: si no se puede clasificar, se
1549                    // comprueba todo (ruidoso) y se dice por qué.
1550                    eprintln!("aviso: no pude resolver los artefactos vigentes ({e}) ⇒ comprueba TODO lo sellado");
1551                    None
1552                }
1553            }
1554        }
1555    } else {
1556        None
1557    };
1558
1559    if sealed {
1560        let s = sealed_kernel_configs(Path::new(store));
1561        if s.is_empty() {
1562            anyhow::bail!(
1563                "no hay ningún kernel sellado con `boot/config-*` en {store}.\n\
1564                 Un kernel que no instale su config no se puede comprobar — y fingir que pasó \
1565                 sería el mismo fallo que este guardián viene a arreglar."
1566            );
1567        }
1568        for (art, p) in s {
1569            objetivos.push((format!("{art} · {}", p.display()), Some(art), p));
1570        }
1571    }
1572    for p in configs {
1573        objetivos.push((p.display().to_string(), artifact_of(p), p.clone()));
1574    }
1575    if objetivos.is_empty() {
1576        let p = running_config_path()?;
1577        objetivos.push((format!("kernel vivo · {}", p.display()), None, p));
1578    }
1579
1580    let mut run = ContractRun {
1581        contract: contract.display().to_string(),
1582        reports: Vec::new(),
1583        superados: Vec::new(),
1584        sin_perfil: Vec::new(),
1585        sin_construir: Vec::new(),
1586    };
1587
1588    for (label, artifact, path) in &objetivos {
1589        let cfg = hammer_core::kernel::KernelConfig::parse(&read_maybe_gz(path)?);
1590        if cfg.is_empty() {
1591            anyhow::bail!("{} no tiene un solo CONFIG_*: ¿es un .config?", path.display());
1592        }
1593        let prof = match profile {
1594            Some(p) => Some(p.to_string()),
1595            None => artifact
1596                .as_deref()
1597                .and_then(|a| c.profile_for(a))
1598                .map(str::to_string),
1599        };
1600        let Some(prof) = prof else {
1601            run.sin_perfil.push(label.clone());
1602            continue;
1603        };
1604        let rep = c.check(&cfg, &prof, label)?;
1605        let superado = match (&vigentes, artifact.as_deref(), store_dir_of(path)) {
1606            (Some(v), Some(art), Some(dir)) => v.get(art).is_some_and(|d| *d != dir),
1607            _ => false,
1608        };
1609        if superado {
1610            run.superados.push(rep);
1611        } else {
1612            run.reports.push(rep);
1613        }
1614    }
1615
1616    // Un objetivo del contrato cuya receta de hoy no tiene NINGÚN sellado no aparece en el
1617    // barrido — y no aparecer se lee como que no hay nada que objetar. Se nombra.
1618    if let Some(v) = &vigentes {
1619        let vistos: std::collections::BTreeSet<String> =
1620            objetivos.iter().filter_map(|(_, _, p)| store_dir_of(p)).collect();
1621        for (art, dir) in v {
1622            if !vistos.contains(dir) {
1623                run.sin_construir.push(format!("{art} · {dir}"));
1624            }
1625        }
1626    }
1627
1628    if json {
1629        println!("{}", serde_json::to_string_pretty(&run)?);
1630    } else {
1631        imprimir_contrato(&run);
1632    }
1633
1634    let bloquean = run
1635        .reports
1636        .iter()
1637        .filter(|r| r.verdict == hammer_core::kernel::Verdict::Bloquea)
1638        .count();
1639    if bloquean > 0 {
1640        anyhow::bail!("el contrato NO se cumple en {bloquean} kernel(es)");
1641    }
1642    // Un objetivo sin sellado vigente no se comprobó, y no comprobar no es aprobar: es el fallo
1643    // de CLAUDE.md §3 —el vacío leído como presencia— en el propio guardián.
1644    if !run.sin_construir.is_empty() {
1645        anyhow::bail!(
1646            "{} objetivo(s) del contrato sin un sellado vigente: no se comprobó nada de ellos. \
1647             Construí sus recetas antes de leer esto como aprobado.",
1648            run.sin_construir.len()
1649        );
1650    }
1651    Ok(())
1652}
1653
1654fn imprimir_contrato(run: &ContractRun) {
1655    println!("contrato    {}", run.contract);
1656    println!();
1657    for r in &run.reports {
1658        let marca = if r.verdict == hammer_core::kernel::Verdict::Bloquea { "✗" } else { "✓" };
1659        println!("{marca} {}", r.config);
1660        println!("  perfil    {}   ({} capacidad(es) miradas)", r.profile, r.checked);
1661        if let Some(k) = &r.kernel {
1662            println!("  kernel    {k}");
1663        }
1664        for m in &r.missing_required {
1665            println!("  ✗ FALTA   {} — {}", m.id, m.title);
1666            if !m.explicit_off.is_empty() {
1667                println!("            apagado a mano: {}", m.explicit_off.join(" "));
1668            }
1669            if !m.absent.is_empty() {
1670                println!(
1671                    "            ni nombrado en el .config: {} (¿renombrado entre versiones?)",
1672                    m.absent.join(" ")
1673                );
1674            }
1675            println!("            lo usa: {}", m.consumer.trim().replace('\n', " "));
1676            if let Some(s) = &m.silent {
1677                println!("            cómo falla hoy: {}", s.trim().replace('\n', " "));
1678            }
1679        }
1680        for m in &r.missing_wanted {
1681            println!(
1682                "  · falta (no bloquea) {} — {} [{}]",
1683                m.id,
1684                m.title,
1685                [m.explicit_off.clone(), m.absent.clone()].concat().join(" ")
1686            );
1687        }
1688        for u in &r.unknown {
1689            println!(
1690                "  ⚠ el perfil nombra «{}», que el contrato no define: NO se comprobó",
1691                u.id
1692            );
1693        }
1694        if r.missing_required.is_empty() {
1695            println!("  ✓ las {} capacidades exigidas están", r.ok.iter().filter(|o| o.required).count());
1696        }
1697        println!();
1698    }
1699    let malos = run
1700        .reports
1701        .iter()
1702        .filter(|r| r.verdict == hammer_core::kernel::Verdict::Bloquea)
1703        .count();
1704    if !run.superados.is_empty() {
1705        let sup_malos = run
1706            .superados
1707            .iter()
1708            .filter(|r| r.verdict == hammer_core::kernel::Verdict::Bloquea)
1709            .count();
1710        println!(
1711            "· {} sellado(s) SUPERADO(s) por la receta de hoy — no bloquean, {} con faltas:",
1712            run.superados.len(),
1713            sup_malos
1714        );
1715        for r in &run.superados {
1716            let faltan: Vec<&str> = r.missing_required.iter().map(|m| m.id.as_str()).collect();
1717            println!(
1718                "  {}  {}",
1719                r.config,
1720                if faltan.is_empty() { "cumplía".to_string() } else { format!("le falta: {}", faltan.join(" ")) }
1721            );
1722        }
1723        println!(
1724            "  Siguen siendo ciertos: si una máquina arranca uno de éstos, sus Cards corren con lo \
1725             que ese kernel traiga. Se reconstruyen o se podan (`scripts/store-gc.sh`)."
1726        );
1727        println!();
1728    }
1729    println!(
1730        "resumen     {} de {} config(s) VIGENTES cumplen su perfil{}{}",
1731        run.reports.len() - malos,
1732        run.reports.len(),
1733        if run.superados.is_empty() {
1734            String::new()
1735        } else {
1736            format!(" · {} superado(s)", run.superados.len())
1737        },
1738        if run.sin_perfil.is_empty() {
1739            String::new()
1740        } else {
1741            format!(" · {} sin comprobar", run.sin_perfil.len())
1742        }
1743    );
1744    println!();
1745    if !run.sin_construir.is_empty() {
1746        println!(
1747            "⚠ {} objetivo(s) del contrato SIN UN SELLADO VIGENTE — no se comprobó nada de ellos:",
1748            run.sin_construir.len()
1749        );
1750        for s in &run.sin_construir {
1751            println!("  {s}");
1752        }
1753        println!("  no cuentan como aprobados: el barrido no tuvo qué mirar. Construí sus recetas.");
1754        println!();
1755    }
1756    if !run.sin_perfil.is_empty() {
1757        println!("⚠ {} config(s) SIN COMPROBAR (ningún perfil declarado):", run.sin_perfil.len());
1758        for s in &run.sin_perfil {
1759            println!("  {s}");
1760        }
1761        println!("  no cuentan como aprobados. Declaralos en [[target]] o pasá --profile.");
1762    }
1763}
1764
1765/// Lee un `.config`, descomprimiendo si es `.gz` (el caso de `/proc/config.gz`).
1766///
1767/// Se delega a `gzip -dc` en vez de arrastrar un descompresor al core: es una entrada de
1768/// diagnóstico, no del camino de build, y el core queda sin dependencias nuevas.
1769pub fn read_maybe_gz(p: &Path) -> Result<String> {
1770    if p.extension().is_some_and(|e| e == "gz") {
1771        let out = std::process::Command::new("gzip")
1772            .arg("-dc")
1773            .arg(p)
1774            .output()
1775            .with_context(|| format!("descomprimiendo {}", p.display()))?;
1776        if !out.status.success() {
1777            anyhow::bail!(
1778                "gzip -dc {} falló: {}",
1779                p.display(),
1780                String::from_utf8_lossy(&out.stderr).trim()
1781            );
1782        }
1783        return Ok(String::from_utf8_lossy(&out.stdout).into_owned());
1784    }
1785    std::fs::read_to_string(p).with_context(|| format!("leyendo {}", p.display()))
1786}
1787
1788#[cfg(test)]
1789mod tests {
1790    use super::*;
1791
1792    #[test]
1793    fn store_dir_of_saca_el_directorio_y_artifact_of_el_nombre() {
1794        let hex = "a".repeat(64);
1795        let p = PathBuf::from(format!("./store/{hex}-linux-metal/boot/config-6.16.12-metal"));
1796        assert_eq!(store_dir_of(&p).as_deref(), Some(format!("{hex}-linux-metal").as_str()));
1797        assert_eq!(artifact_of(&p).as_deref(), Some("linux-metal"));
1798        // Fuera del store no hay vigencia que comparar: mejor None que una comparación inventada.
1799        assert_eq!(store_dir_of(Path::new("/boot/config-7.1.4")), None);
1800    }
1801
1802    #[test]
1803    fn serie_de_las_dos_formas_que_se_cruzan() {
1804        assert_eq!(serie("Linux/x86 7.1.4-artix1 Kernel Configuration").as_deref(), Some("7.1"));
1805        assert_eq!(serie("linux-6.16.12").as_deref(), Some("6.16"));
1806        assert_eq!(serie("sin versión"), None);
1807    }
1808}