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 1569Shell 203Rust 109Markdown 993728 Definiciones

⎇ main crates/takana-cli/src/kernel_cmd.rs Rust · 2283 líneas · 87.2 KB Historia Quién tocó qué crudo

takana metal: la tapa del instalador — tres verbos en vez del motor a la vista a1bcfa9b1 · Sergio ·

1//! `takana 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 takana_core::kernel::kconfig::KconfigTree;
12use takana_core::kernel::KernelConfig;
13use serde::Serialize;
14
15/// Dónde busca el árbol de Kconfig si no se pasa `--kconfig`.
16const KCONFIG_ENV: &str = "TAKANA_KCONFIG_ROOT";
17/// Ídem para el catálogo de bundles.
18const CATALOG_ENV: &str = "TAKANA_KERNEL_BUNDLES";
19/// Ídem para el contrato de capacidades (SDD 25 §4).
20const CONTRACT_ENV: &str = "TAKANA_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ó `takana 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ó `takana 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 `takana 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    /// Stage a kernel artifact as the boot candidate for the NEXT boot only (ADR 0017).
206    ///
207    /// Copia su bzImage a una ranura PROPIA de la ESP —nunca encima del estable— y pone `BootNext`.
208    /// `BootNext` es de un solo uso: si el candidato no arranca, el firmware ya no la encuentra en
209    /// el arranque siguiente y vuelve solo al estable. La vuelta atrás del caso «no arranca» no la
210    /// hace takana: la hace el firmware, y por eso no se puede romper.
211    Stage {
212        /// Artefacto del kernel en el store (nombre de directorio o ruta).
213        artefacto: String,
214        /// ESP ya montada.
215        #[arg(long, default_value = "/boot/efi")]
216        esp: PathBuf,
217        /// Root of efivarfs.
218        #[arg(long, default_value = crate::efi_boot::EFIVARS)]
219        efivars: PathBuf,
220        /// Arranques que tiene el candidato para ser confirmado.
221        #[arg(long, default_value_t = 1)]
222        tries: u32,
223        /// A/B state file.
224        #[arg(long, default_value = crate::kernel_ab::ESTADO)]
225        state: PathBuf,
226        /// Say what would happen and touch nothing.
227        #[arg(long)]
228        dry_run: bool,
229    },
230    /// Soft reboot into a kernel: skip firmware and bootloader (ADR 0017 §1).
231    ///
232    /// ⚠ **NO es «actualizar sin rebootear».** El userspace muere igual — es un reboot que se salta
233    /// el firmware. Ahorra los 20-30 s de POST/UEFI, que en un servidor remoto es la mitad del
234    /// downtime y en un portátil no es nada. Se dice acá porque media industria vende esto como
235    /// «live kernel update» y no lo es.
236    Kexec {
237        /// Artefacto del kernel en el store (nombre de directorio o ruta). No hace falta con `--probe`.
238        artefacto: Option<String>,
239        /// initramfs a cargar con él.
240        #[arg(long)]
241        initrd: Option<PathBuf>,
242        /// Kernel command line for the new kernel.
243        #[arg(long, default_value = "console=tty0 console=ttyS0,115200")]
244        cmdline: String,
245        /// Load it and stop there: the jump needs `--jump`.
246        #[arg(long)]
247        load_only: bool,
248        /// Actually jump. Without this it only loads and reports.
249        #[arg(long)]
250        jump: bool,
251        /// Only answer whether this machine CAN kexec, and why not. Loads nothing, jumps nowhere.
252        ///
253        /// Existe porque quien decide no es quien salta: un instalador tiene que elegir el camino
254        /// **antes** de tocar nada, y la única forma honesta de saberlo era intentar el salto.
255        /// Exit 0 = se puede; exit 1 = no, con el motivo dicho.
256        #[arg(long)]
257        probe: bool,
258    },
259    /// Say where this boot came from and what is on trial.
260    BootStatus {
261        #[arg(long, default_value = crate::efi_boot::EFIVARS)]
262        efivars: PathBuf,
263        #[arg(long, default_value = crate::kernel_ab::ESTADO)]
264        state: PathBuf,
265    },
266    /// Promote the candidate that is currently running to stable.
267    Confirm {
268        #[arg(long, default_value = "/boot/efi")]
269        esp: PathBuf,
270        #[arg(long, default_value = crate::efi_boot::EFIVARS)]
271        efivars: PathBuf,
272        #[arg(long, default_value = crate::kernel_ab::ESTADO)]
273        state: PathBuf,
274    },
275    /// Drop the candidate and keep the stable kernel.
276    Rollback {
277        #[arg(long, default_value = "/boot/efi")]
278        esp: PathBuf,
279        #[arg(long, default_value = crate::efi_boot::EFIVARS)]
280        efivars: PathBuf,
281        #[arg(long, default_value = crate::kernel_ab::ESTADO)]
282        state: PathBuf,
283    },
284    /// Estadísticas del árbol de Kconfig parseado (control de salud del lector).
285    Stats {
286        #[arg(long)]
287        kconfig: Option<PathBuf>,
288        /// Muestra las primeras N advertencias del parseo.
289        #[arg(long, default_value_t = 10)]
290        warnings: usize,
291    },
292}
293
294pub fn run(cmd: KernelCmd, store: &str) -> Result<()> {
295    match cmd {
296        KernelCmd::Stage { artefacto, esp, efivars, tries, state, dry_run } =>
297            ab_stage(&artefacto, store, &esp, &efivars, tries, &state, dry_run),
298        KernelCmd::Kexec { artefacto, initrd, cmdline, load_only, jump, probe } =>
299            kexec_cmd(artefacto.as_deref(), store, initrd.as_deref(), &cmdline, load_only, jump, probe),
300        KernelCmd::BootStatus { efivars, state } => ab_status(&efivars, &state),
301        KernelCmd::Confirm { esp, efivars, state } => ab_confirm(&esp, &efivars, &state),
302        KernelCmd::Rollback { esp, efivars, state } => ab_rollback(&esp, &efivars, &state),
303        KernelCmd::Stats { kconfig, warnings } => stats(&kconfig_root(kconfig)?, warnings),
304        KernelCmd::Probe {
305            config,
306            kconfig,
307            catalog,
308            no_hardware,
309            json,
310        } => probe(
311            config.as_deref(),
312            &kconfig_root(kconfig)?,
313            &catalog_path(catalog)?,
314            !no_hardware,
315            json,
316        ),
317        KernelCmd::Bundles {
318            kconfig,
319            catalog,
320            check,
321            json,
322        } => bundles(&kconfig_root(kconfig)?, &catalog_path(catalog)?, check, json),
323        KernelCmd::Plan {
324            recipe,
325            bundles: bs,
326            knobs,
327            kconfig,
328            catalog,
329            out,
330            recipe_out,
331            json,
332        } => plan(
333            &recipe,
334            &bs,
335            &knobs,
336            &kconfig_root(kconfig)?,
337            &catalog_path(catalog)?,
338            out.as_deref(),
339            recipe_out.as_deref(),
340            store,
341            json,
342        ),
343        KernelCmd::DiffBack { plan, config, json } => diff_back(&plan, &config, json),
344        KernelCmd::Gate {
345            plan,
346            config,
347            baseline,
348            objective,
349            devices,
350            kconfig,
351            catalog,
352            json,
353        } => gate(
354            plan.as_deref(),
355            config.as_deref(),
356            baseline.as_deref(),
357            &objective,
358            devices.as_deref(),
359            &kconfig_root(kconfig)?,
360            &catalog_path(catalog)?,
361            json,
362        ),
363        KernelCmd::Hw { out } => hw(out.as_deref()),
364        KernelCmd::Contract {
365            configs,
366            sealed,
367            recipes,
368            profile,
369            contract,
370            list,
371            json,
372        } => contract_cmd(
373            &configs,
374            sealed,
375            &recipes,
376            profile.as_deref(),
377            &contract_path(contract)?,
378            store,
379            list,
380            json,
381        ),
382        KernelCmd::Closure {
383            symbols,
384            kconfig,
385            against,
386            fixpoint,
387            json,
388            limit,
389        } => closure(
390            &symbols,
391            &kconfig_root(kconfig)?,
392            against.as_deref(),
393            fixpoint,
394            json,
395            limit,
396        ),
397    }
398}
399
400/// Resuelve la raíz del árbol de Kconfig: `--kconfig` gana, si no la variable de entorno.
401fn kconfig_root(flag: Option<PathBuf>) -> Result<PathBuf> {
402    if let Some(p) = flag {
403        return Ok(p);
404    }
405    match takana_core::env::var_os(KCONFIG_ENV) {
406        Some(v) => Ok(PathBuf::from(v)),
407        None => anyhow::bail!(
408            "falta el árbol de fuentes del kernel: pasá --kconfig <dir> o exportá {KCONFIG_ENV}"
409        ),
410    }
411}
412
413/// Resuelve el catálogo de bundles: `--catalog`, si no la variable de entorno, si no el del repo.
414fn catalog_path(flag: Option<PathBuf>) -> Result<PathBuf> {
415    if let Some(p) = flag {
416        return Ok(p);
417    }
418    if let Some(v) = takana_core::env::var_os(CATALOG_ENV) {
419        return Ok(PathBuf::from(v));
420    }
421    for c in CATALOG_DEFAULTS {
422        let p = PathBuf::from(c);
423        if p.is_file() {
424            return Ok(p);
425        }
426    }
427    anyhow::bail!(
428        "no encontré el catálogo de bundles: pasá --catalog <path> o exportá {CATALOG_ENV}"
429    )
430}
431
432/// Resuelve el contrato de capacidades: `--contract`, si no la variable de entorno, si no el repo.
433fn contract_path(flag: Option<PathBuf>) -> Result<PathBuf> {
434    if let Some(p) = flag {
435        return Ok(p);
436    }
437    if let Some(v) = takana_core::env::var_os(CONTRACT_ENV) {
438        return Ok(PathBuf::from(v));
439    }
440    for c in CONTRACT_DEFAULTS {
441        let p = PathBuf::from(c);
442        if p.is_file() {
443            return Ok(p);
444        }
445    }
446    anyhow::bail!("no encontré el contrato de kernel: pasá --contract <path> o exportá {CONTRACT_ENV}")
447}
448
449/// Dónde está el `.config` del kernel que corre. `/proc/config.gz` es el camino soberano (lo emite
450/// el propio kernel); el de `/boot` es el respaldo para kernels compilados sin `IKCONFIG_PROC`.
451fn running_config_path() -> Result<PathBuf> {
452    let gz = PathBuf::from("/proc/config.gz");
453    if gz.is_file() {
454        return Ok(gz);
455    }
456    if let Ok(out) = std::process::Command::new("uname").arg("-r").output() {
457        let rel = String::from_utf8_lossy(&out.stdout).trim().to_string();
458        let p = PathBuf::from(format!("/boot/config-{rel}"));
459        if p.is_file() {
460            return Ok(p);
461        }
462    }
463    anyhow::bail!(
464        "no encontré el config del kernel vivo (ni /proc/config.gz ni /boot/config-<release>).\n\
465         Pasá --config <path> para mirar el de otra máquina."
466    )
467}
468
469fn load_tree(root: &Path) -> Result<KconfigTree> {
470    if !root.join("Kconfig").is_file() {
471        anyhow::bail!(
472            "{} no parece un árbol de fuentes de kernel (falta ./Kconfig).\n\
473             Pasá --kconfig <dir> o exportá {KCONFIG_ENV}.",
474            root.display()
475        );
476    }
477    KconfigTree::parse(root, &KconfigTree::vars_x86_64())
478        .with_context(|| format!("parseando {}", root.display()))
479}
480
481fn stats(root: &Path, show_warnings: usize) -> Result<()> {
482    let t = load_tree(root)?;
483    let visible = t.symbols.values().filter(|s| s.is_visible()).count();
484    let multi = t.symbols.values().filter(|s| s.defs.len() > 1).count();
485    let with_help = t.symbols.values().filter(|s| s.help().is_some()).count();
486    let selects: usize = t.symbols.values().map(|s| s.select_targets().len()).sum();
487    let implies: usize = t.symbols.values().map(|s| s.imply_targets().len()).sum();
488
489    println!("árbol      {}", root.display());
490    println!("ficheros   {}", t.files.len());
491    println!("símbolos   {}", t.symbols.len());
492    println!("  visibles {visible} (marcables en menuconfig)");
493    println!("  con ayuda {with_help}");
494    println!("  definidos en >1 sitio {multi}");
495    println!("aristas    select {selects} · imply {implies}");
496    println!("avisos     {}", t.warnings.len());
497    for w in t.warnings.iter().take(show_warnings) {
498        println!("  · {w}");
499    }
500    if t.warnings.len() > show_warnings {
501        println!("  … y {} más", t.warnings.len() - show_warnings);
502    }
503    Ok(())
504}
505
506#[derive(Serialize)]
507struct ClosureReport {
508    roots: Vec<String>,
509    /// Todo lo que queda inalcanzable si se apagan las raíces (incluidas las raíces).
510    closure: Vec<String>,
511    /// De la clausura, los que un humano puede marcar en `menuconfig`.
512    visible: usize,
513    /// `select` que entran desde fuera: reencienden algo de la clausura ignorando sus `depends on`.
514    leaks: Vec<takana_core::kernel::SelectLeak>,
515    /// Símbolos distintos de la clausura alcanzados por esas fugas.
516    leaked_targets: usize,
517    #[serde(skip_serializing_if = "Option::is_none")]
518    fixpoint: Option<FixpointReport>,
519    #[serde(skip_serializing_if = "Option::is_none")]
520    against: Option<AgainstReport>,
521}
522
523/// El precio de cerrar el bundle contra sus fugas.
524#[derive(Serialize)]
525struct FixpointReport {
526    /// La clausura ya cerrada, entera.
527    closure: Vec<String>,
528    /// Tamaño de la clausura una vez cerrada.
529    closure_len: usize,
530    /// Cuánto creció respecto de la clausura estricta.
531    grew_by: usize,
532    rounds: usize,
533    /// Lo que hubo que apagar de más, con la ronda en que apareció.
534    added: Vec<(usize, String)>,
535    /// Fugas que sobrevivieron al tope de rondas. Vacío = cerró.
536    remaining_leaks: Vec<takana_core::kernel::SelectLeak>,
537}
538
539/// Contraste contra un `.config` real: la validación §3 del SDD 22.
540#[derive(Serialize)]
541struct AgainstReport {
542    config: String,
543    /// De la clausura, los que el `.config` tiene ENCENDIDOS (la clausura predice de más, o el
544    /// config tiene una fuga real).
545    on_in_config: Vec<String>,
546    /// De la clausura, los que el `.config` apaga explícitamente.
547    off_in_config: usize,
548    /// De la clausura, los que el `.config` ni menciona.
549    absent_in_config: usize,
550}
551
552fn closure(
553    roots: &[String],
554    kroot: &Path,
555    against: Option<&Path>,
556    fixpoint: bool,
557    json: bool,
558    limit: usize,
559) -> Result<()> {
560    let t = load_tree(kroot)?;
561
562    // Avisar de raíces que no existen: un símbolo mal escrito daría clausura vacía en silencio,
563    // que es exactamente el modo de fallo que este comando existe para no tener.
564    let desconocidas: Vec<&String> = roots.iter().filter(|r| !t.symbols.contains_key(*r)).collect();
565    if !desconocidas.is_empty() {
566        eprintln!(
567            "aviso: {} símbolo(s) no existen en este árbol: {}",
568            desconocidas.len(),
569            desconocidas
570                .iter()
571                .map(|s| s.as_str())
572                .collect::<Vec<_>>()
573                .join(", ")
574        );
575    }
576
577    let cl = t.closure_off(roots);
578    let leaks = t.select_leaks(&cl);
579    let leaked_targets = leaks
580        .iter()
581        .map(|l| l.target.as_str())
582        .collect::<BTreeSet<_>>()
583        .len();
584    let visible = cl
585        .iter()
586        .filter(|s| t.symbols.get(*s).is_some_and(|s| s.is_visible()))
587        .count();
588
589    let fx = fixpoint.then(|| {
590        let f = t.closure_off_fixpoint(roots, 16);
591        FixpointReport {
592            closure_len: f.closure.len(),
593            closure: f.closure.iter().cloned().collect(),
594            grew_by: f.closure.len().saturating_sub(cl.len()),
595            rounds: f.rounds,
596            added: f.added,
597            remaining_leaks: f.remaining_leaks,
598        }
599    });
600
601    let against_report = match against {
602        None => None,
603        Some(p) => {
604            let text = read_maybe_gz(p)?;
605            let cfg = KernelConfig::parse(&text);
606            let on: Vec<String> = cl.iter().filter(|s| cfg.is_on(s)).cloned().collect();
607            let off = cl
608                .iter()
609                .filter(|s| matches!(cfg.get(s), Some(takana_core::kernel::ConfigValue::No)))
610                .count();
611            let absent = cl.iter().filter(|s| cfg.get(s).is_none()).count();
612            Some(AgainstReport {
613                config: p.display().to_string(),
614                on_in_config: on,
615                off_in_config: off,
616                absent_in_config: absent,
617            })
618        }
619    };
620
621    let rep = ClosureReport {
622        roots: roots.to_vec(),
623        closure: cl.iter().cloned().collect(),
624        visible,
625        leaks,
626        leaked_targets,
627        fixpoint: fx,
628        against: against_report,
629    };
630
631    if json {
632        println!("{}", serde_json::to_string_pretty(&rep)?);
633        return Ok(());
634    }
635
636    println!("raíces apagadas   {}", rep.roots.join(" "));
637    println!(
638        "clausura          {} símbolo(s) ({} visibles en menuconfig)",
639        rep.closure.len(),
640        rep.visible
641    );
642    for s in rep.closure.iter().take(limit) {
643        let prompt = t
644            .symbols
645            .get(s)
646            .and_then(|x| x.prompt())
647            .unwrap_or("(sin prompt: sólo entra por select)");
648        println!("  · {s} — {prompt}");
649    }
650    if rep.closure.len() > limit {
651        println!("  … y {} más", rep.closure.len() - limit);
652    }
653
654    println!();
655    if rep.leaks.is_empty() {
656        println!("fugas por `select`  ninguna — la clausura es cerrada");
657    } else {
658        println!(
659            "fugas por `select`  {} arista(s) desde fuera reencienden {} símbolo(s) de la clausura",
660            rep.leaks.len(),
661            rep.leaked_targets
662        );
663        println!("  (`select` FUERZA el destino ignorando sus `depends on`: apagar la raíz no basta)");
664        let mut por_destino: BTreeMap<&str, Vec<&str>> = BTreeMap::new();
665        for l in &rep.leaks {
666            por_destino
667                .entry(&l.target)
668                .or_default()
669                .push(&l.selector);
670        }
671        for (target, selectors) in por_destino.iter().take(limit) {
672            println!("  · {target} ← {}", selectors.join(", "));
673        }
674        if por_destino.len() > limit {
675            println!("  … y {} destino(s) más", por_destino.len() - limit);
676        }
677    }
678
679    if let Some(f) = &rep.fixpoint {
680        println!();
681        println!(
682            "punto fijo        {} símbolo(s) tras {} ronda(s) — {:+} sobre la clausura estricta",
683            f.closure_len, f.rounds, f.grew_by as i64
684        );
685        if f.remaining_leaks.is_empty() {
686            println!("  cerró: ninguna fuga sobrevive");
687        } else {
688            println!(
689                "  ⚠ NO cerró: {} fuga(s) siguen abiertas tras el tope de rondas",
690                f.remaining_leaks.len()
691            );
692        }
693        if f.added.is_empty() {
694            println!("  no hizo falta apagar nada de más");
695        } else {
696            println!("  precio: {} símbolo(s) apagados de más", f.added.len());
697            for (r, s) in f.added.iter().take(limit) {
698                println!("    · ronda {r}: {s}");
699            }
700            if f.added.len() > limit {
701                println!("    … y {} más", f.added.len() - limit);
702            }
703        }
704    }
705
706    if let Some(a) = &rep.against {
707        println!();
708        println!("contraste contra {}", a.config);
709        println!("  apagados explícitos  {}", a.off_in_config);
710        println!("  ausentes             {}", a.absent_in_config);
711        println!("  ENCENDIDOS           {}", a.on_in_config.len());
712        for s in a.on_in_config.iter().take(limit) {
713            println!("    · {s}");
714        }
715        if a.on_in_config.len() > limit {
716            println!("    … y {} más", a.on_in_config.len() - limit);
717        }
718    }
719    Ok(())
720}
721
722// ---------------------------------------------------------------------------------------------
723// probe — el modo reversa (#9)
724// ---------------------------------------------------------------------------------------------
725
726fn probe(
727    config: Option<&Path>,
728    kroot: &Path,
729    catalog: &Path,
730    with_hw: bool,
731    json: bool,
732) -> Result<()> {
733    use takana_core::kernel::reverse::{BundleState, HwSignal};
734
735    let cfg_path = match config {
736        Some(p) => p.to_path_buf(),
737        None => running_config_path()?,
738    };
739    let cfg = KernelConfig::parse(&read_maybe_gz(&cfg_path)?);
740    if cfg.is_empty() {
741        anyhow::bail!("{} no tiene un solo CONFIG_*: ¿es un .config?", cfg_path.display());
742    }
743    let tree = load_tree(kroot)?;
744    let cat = takana_core::kernel::Catalog::load(catalog)?;
745    let hw = with_hw.then(takana_core::kernel::Hardware::probe_local);
746    let rep = takana_core::kernel::reverse::analyze(&cat, &tree, &cfg, hw.as_ref());
747
748    if json {
749        println!("{}", serde_json::to_string_pretty(&rep)?);
750        return Ok(());
751    }
752
753    println!("config      {}", cfg_path.display());
754    if let Some(k) = &rep.kernel {
755        println!("kernel      {k}");
756    }
757    // El modo reversa cruza DOS árboles: el del config vivo y el que se parseó. Si no son la misma
758    // versión, las clausuras son aproximadas — un símbolo puede haberse renombrado o retirado entre
759    // medias (que es justo lo que le pasó a THUNDERBOLT y a REISERFS_FS). Vale igual, pero decirlo.
760    if let (Some(banner), Some(rev)) = (&rep.kernel, &cat.reviewed_against) {
761        if let (Some(a), Some(b)) = (serie(banner), serie(rev)) {
762            if a != b {
763                println!(
764                    "            ⚠ el config vivo es de la serie {a} y el catálogo se revisó contra \
765                     la {b}: las clausuras son aproximadas"
766                );
767            }
768        }
769    }
770    println!("símbolos    {} declarados", rep.config_symbols);
771    if let Some(h) = &hw {
772        println!("huella      {}", rep.fingerprint);
773        if let Some(m) = &h.cpu_model {
774            println!("            {m}");
775        }
776        let maquina = [h.dmi.get("sys_vendor"), h.dmi.get("product_name")]
777            .into_iter()
778            .flatten()
779            .cloned()
780            .collect::<Vec<_>>()
781            .join(" ");
782        if !maquina.is_empty() {
783            println!("            {maquina}");
784        }
785        println!(
786            "            {} dispositivos PCI · {} drivers bindeados",
787            h.pci.len(),
788            h.bound_drivers.len()
789        );
790        for g in &h.gaps {
791            println!("            ⚠ {g}");
792        }
793    }
794    println!();
795
796    for b in &rep.bundles {
797        let on = b.on_builtin + b.on_module;
798        let estado = match b.state {
799            BundleState::Aplicado => "APLICADO ",
800            BundleState::Parcial => "parcial  ",
801            BundleState::Sin => "sin      ",
802        };
803        let hwtxt = match &b.hardware {
804            HwSignal::SinSeñal => String::new(),
805            HwSignal::Ausente => "  · hardware ausente".to_string(),
806            HwSignal::Presente { evidencia } => {
807                format!("  · ⚠ HARDWARE PRESENTE ({})", evidencia.len())
808            }
809        };
810        println!(
811            "{estado} {:<28} {on:>5}/{:<5} encendidos{hwtxt}",
812            b.id, b.closure_len
813        );
814        if let HwSignal::Presente { evidencia } = &b.hardware {
815            for e in evidencia.iter().take(4) {
816                println!("              {e}");
817            }
818        }
819        if !b.unknown_symbols.is_empty() {
820            println!(
821                "              ⚠ símbolos que este kernel no tiene: {}",
822                b.unknown_symbols.join(", ")
823            );
824        }
825        if !b.undeclared_leaks.is_empty() {
826            println!(
827                "              ⚠ {} fuga(s) `select` sin declarar en el catálogo",
828                b.undeclared_leaks.len()
829            );
830        }
831    }
832
833    println!();
834    println!(
835        "resumen     {} bundle(s): {} ya aplicados, {} con capacidad que este hardware NO usa",
836        rep.bundles.len(),
837        rep.bundles
838            .iter()
839            .filter(|b| b.state == BundleState::Aplicado)
840            .count(),
841        rep.applicable_savings
842    );
843    if rep.contradictions > 0 {
844        println!(
845            "            ⚠ {} bundle(s) aplicados CONTRADICEN al hardware presente",
846            rep.contradictions
847        );
848    }
849    println!("            (detectar sirve para contradecir, nunca para podar solo)");
850    Ok(())
851}
852
853/// Serie `mayor.menor` de un texto que contenga una versión (`"Linux/x86 7.1.4-artix1 …"` → `7.1`,
854/// `"linux-6.16.12"` → `6.16`). `None` si no hay ninguna.
855fn serie(texto: &str) -> Option<String> {
856    for tok in texto.split(|c: char| !(c.is_ascii_digit() || c == '.')) {
857        let mut it = tok.split('.');
858        if let (Some(a), Some(b)) = (it.next(), it.next()) {
859            if !a.is_empty() && !b.is_empty() {
860                return Some(format!("{a}.{b}"));
861            }
862        }
863    }
864    None
865}
866
867// ---------------------------------------------------------------------------------------------
868// bundles — catálogo + control de frescura
869// ---------------------------------------------------------------------------------------------
870
871#[derive(Serialize)]
872struct BundleView {
873    id: String,
874    title: String,
875    #[serde(skip_serializing_if = "Option::is_none")]
876    help: Option<String>,
877    disable: Vec<String>,
878    close_leaks: Vec<String>,
879    closure_len: usize,
880    visible: usize,
881    unknown_symbols: Vec<String>,
882    undeclared_leaks: Vec<takana_core::kernel::SelectLeak>,
883    accepted_leaks: usize,
884}
885
886#[derive(Serialize)]
887struct BundlesReport {
888    catalog: String,
889    #[serde(skip_serializing_if = "Option::is_none")]
890    reviewed_against: Option<String>,
891    bundles: Vec<BundleView>,
892    knobs: Vec<KnobView>,
893    stale: bool,
894}
895
896#[derive(Serialize)]
897struct KnobView {
898    id: String,
899    title: String,
900    side: takana_core::kernel::Side,
901    enable: Vec<String>,
902    disable: Vec<String>,
903    #[serde(skip_serializing_if = "Option::is_none")]
904    recipe_field: Option<String>,
905    unknown_symbols: Vec<String>,
906}
907
908fn bundles(kroot: &Path, catalog: &Path, check: bool, json: bool) -> Result<()> {
909    let tree = load_tree(kroot)?;
910    let cat = takana_core::kernel::Catalog::load(catalog)?;
911
912    let mut views = Vec::new();
913    for b in &cat.bundles {
914        let mut roots = b.disable.clone();
915        roots.extend(b.close_leaks.iter().cloned());
916        let unknown: Vec<String> = roots
917            .iter()
918            .chain(b.enable.iter())
919            .filter(|s| !tree.symbols.contains_key(*s))
920            .cloned()
921            .collect();
922        let cl = tree.closure_off(&roots);
923        let aceptadas: BTreeSet<&str> = b.accept_leaks.keys().map(|s| s.as_str()).collect();
924        let undeclared: Vec<_> = tree
925            .select_leaks(&cl)
926            .into_iter()
927            .filter(|l| !aceptadas.contains(l.target.as_str()))
928            .collect();
929        views.push(BundleView {
930            id: b.id.clone(),
931            title: b.title.clone(),
932            help: b.help.clone(),
933            disable: b.disable.clone(),
934            close_leaks: b.close_leaks.clone(),
935            closure_len: cl.len(),
936            visible: cl
937                .iter()
938                .filter(|s| tree.symbols.get(*s).is_some_and(|x| x.is_visible()))
939                .count(),
940            unknown_symbols: unknown,
941            undeclared_leaks: undeclared,
942            accepted_leaks: b.accept_leaks.len(),
943        });
944    }
945
946    let knobs: Vec<KnobView> = cat
947        .knobs
948        .iter()
949        .map(|k| KnobView {
950            id: k.id.clone(),
951            title: k.title.clone(),
952            side: k.side,
953            enable: k.enable.clone(),
954            disable: k.disable.clone(),
955            recipe_field: k.recipe_field.clone(),
956            unknown_symbols: k
957                .enable
958                .iter()
959                .chain(k.disable.iter())
960                .filter(|s| !tree.symbols.contains_key(*s))
961                .cloned()
962                .collect(),
963        })
964        .collect();
965
966    let stale = views
967        .iter()
968        .any(|v| !v.unknown_symbols.is_empty() || !v.undeclared_leaks.is_empty())
969        || knobs.iter().any(|k| !k.unknown_symbols.is_empty());
970
971    let rep = BundlesReport {
972        catalog: catalog.display().to_string(),
973        reviewed_against: cat.reviewed_against.clone(),
974        bundles: views,
975        knobs,
976        stale,
977    };
978
979    if json {
980        println!("{}", serde_json::to_string_pretty(&rep)?);
981    } else {
982        println!("catálogo    {}", rep.catalog);
983        if let Some(r) = &rep.reviewed_against {
984            println!("revisado contra {r}");
985        }
986        println!("árbol       {}", kroot.display());
987        println!();
988        println!("NIVEL 1 — aserciones del dueño de la máquina");
989        for v in &rep.bundles {
990            println!(
991                "  {:<26} {:>5} símbolos ({} visibles)  ← {}",
992                v.id,
993                v.closure_len,
994                v.visible,
995                v.disable.join(" ")
996            );
997            println!("     {}", v.title);
998            if !v.close_leaks.is_empty() {
999                println!("     fugas cerradas: {}", v.close_leaks.join(", "));
1000            }
1001            if v.accepted_leaks > 0 {
1002                println!("     fugas aceptadas a sabiendas: {}", v.accepted_leaks);
1003            }
1004            if !v.unknown_symbols.is_empty() {
1005                println!(
1006                    "     ⚠ NO EXISTEN en este árbol: {}",
1007                    v.unknown_symbols.join(", ")
1008                );
1009            }
1010            for l in v.undeclared_leaks.iter().take(6) {
1011                println!("     ⚠ fuga sin declarar: {} ← {}", l.target, l.selector);
1012            }
1013            if v.undeclared_leaks.len() > 6 {
1014                println!("     ⚠ … y {} fuga(s) más", v.undeclared_leaks.len() - 6);
1015            }
1016        }
1017        println!();
1018        println!("NIVEL 2 — política y rendimiento (ojo al lado del que cae cada perilla)");
1019        for k in &rep.knobs {
1020            let lado = match k.side {
1021                takana_core::kernel::Side::Kconfig => "kconfig",
1022                takana_core::kernel::Side::Recipe => "receta ",
1023            };
1024            let que = match k.side {
1025                takana_core::kernel::Side::Kconfig => {
1026                    let mut v: Vec<String> =
1027                        k.enable.iter().map(|s| format!("+{s}")).collect();
1028                    v.extend(k.disable.iter().map(|s| format!("-{s}")));
1029                    v.join(" ")
1030                }
1031                takana_core::kernel::Side::Recipe => {
1032                    k.recipe_field.clone().unwrap_or_default()
1033                }
1034            };
1035            println!("  [{lado}] {:<26} {que}", k.id);
1036            println!("     {}", k.title);
1037            if !k.unknown_symbols.is_empty() {
1038                println!(
1039                    "     ⚠ NO EXISTEN en este árbol: {}",
1040                    k.unknown_symbols.join(", ")
1041                );
1042            }
1043        }
1044        println!();
1045        if rep.stale {
1046            println!("⚠ el catálogo ENVEJECIÓ respecto de este árbol (símbolos idos o fugas nuevas)");
1047        } else {
1048            println!("✓ el catálogo está al día con este árbol");
1049        }
1050    }
1051
1052    if check && rep.stale {
1053        anyhow::bail!("catálogo desactualizado");
1054    }
1055    Ok(())
1056}
1057
1058// ---------------------------------------------------------------------------------------------
1059// plan / diff-back
1060// ---------------------------------------------------------------------------------------------
1061
1062#[allow(clippy::too_many_arguments)]
1063fn plan(
1064    recipe_path: &Path,
1065    bundle_ids: &[String],
1066    knob_ids: &[String],
1067    kroot: &Path,
1068    catalog: &Path,
1069    out: Option<&Path>,
1070    recipe_out: Option<&Path>,
1071    store: &str,
1072    json: bool,
1073) -> Result<()> {
1074    if bundle_ids.is_empty() && knob_ids.is_empty() {
1075        anyhow::bail!("un plan sin bundles ni perillas es la receta base: pasá --bundle/--knob");
1076    }
1077    let base = takana_core::Recipe::load_from_path(recipe_path)?;
1078    let tree = load_tree(kroot)?;
1079    let cat = takana_core::kernel::Catalog::load(catalog)?;
1080
1081    // Las recetas de kernel del repo NO son todas la misma versión (`linux`/`linux-metal` van por
1082    // 6.16.12; `linux-metal-dual`/`linux-generic` por 7.1.2). Planear una contra el árbol de la otra
1083    // calcularía clausuras sobre símbolos que ahí no existen, y saldría SIN RUIDO. Como el plan
1084    // determina un artefacto, acá se falla en vez de avisar.
1085    match tree.version.as_deref() {
1086        Some(v) if v != base.version => anyhow::bail!(
1087            "la receta «{}» es del kernel {} y el árbol de {} es el {v}.\n\
1088             Las clausuras saldrían calculadas sobre otro juego de símbolos. Pasá el árbol correcto \
1089             con --kconfig.",
1090            base.name,
1091            base.version,
1092            kroot.display()
1093        ),
1094        None => eprintln!(
1095            "aviso: {} no trae el Makefile de arriba, así que no puedo comprobar que sea el kernel \
1096             {} de la receta «{}»",
1097            kroot.display(),
1098            base.version,
1099            base.name
1100        ),
1101        Some(_) => {}
1102    }
1103
1104    let mut p = takana_core::kernel::plan::make_plan(
1105        &base,
1106        &recipe_path.display().to_string(),
1107        &cat,
1108        &tree,
1109        bundle_ids,
1110        knob_ids,
1111    )?;
1112
1113    // El plan DETERMINA el artefacto (SDD 22 §1), así que el hash va en el plan: la UI puede decir
1114    // «esto ya está construido y firmado» sin construir nada. Si las deps no están resueltas, se
1115    // dice que no se pudo en vez de emitir un hash inventado.
1116    let derived = takana_core::kernel::plan::derive_recipe(&base, &p);
1117    match takana_core::Store::open(Path::new(store))
1118        .map_err(anyhow::Error::from)
1119        .and_then(|s| {
1120            let cfg = takana_build::BuildConfig::from_env_or_defaults(s.root());
1121            let lab = takana_core::lab::LabFingerprint::from_rootfs(&cfg.rootfs)
1122                .map_err(anyhow::Error::from)?;
1123            takana_build::artifact_hash(&derived, &s, &lab).map_err(anyhow::Error::from)
1124        })
1125    {
1126        Ok(h) => p.artifact_hash = Some(h.to_string()),
1127        Err(e) => eprintln!("aviso: no pude calcular el ArtifactHash ({e})"),
1128    }
1129
1130    if let Some(o) = out {
1131        if let Some(d) = o.parent() {
1132            std::fs::create_dir_all(d).ok();
1133        }
1134        std::fs::write(o, serde_json::to_string_pretty(&p)?)
1135            .with_context(|| format!("escribiendo {}", o.display()))?;
1136    }
1137    if let Some(ro) = recipe_out {
1138        if let Some(d) = ro.parent() {
1139            std::fs::create_dir_all(d).ok();
1140        }
1141        let cabecera = format!(
1142            "# Receta DERIVADA, generada por `hammer kernel plan`. No editar a mano: regenerar.\n\
1143             #   base    {}\n#   bundles {}\n#   perillas {}\n\
1144             # Para construirla tiene que vivir junto a la receta base, o sus `deps.build` no\n\
1145             # resuelven ({}).\n",
1146            p.base_recipe,
1147            if p.bundles.is_empty() { "—".into() } else { p.bundles.join(" ") },
1148            if p.knobs.is_empty() { "—".into() } else { p.knobs.join(" ") },
1149            recipe_path
1150                .parent()
1151                .map(|d| d.display().to_string())
1152                .unwrap_or_default(),
1153        );
1154        let cuerpo = takana_core::kernel::plan::render_recipe_toml(&derived)?;
1155        std::fs::write(ro, format!("{cabecera}{cuerpo}"))
1156            .with_context(|| format!("escribiendo {}", ro.display()))?;
1157    }
1158
1159    if json {
1160        println!("{}", serde_json::to_string_pretty(&p)?);
1161        return Ok(());
1162    }
1163
1164    println!("base        {} ({})", p.base_name, p.base_recipe);
1165    println!("derivada    {}", p.derived_name);
1166    match &p.artifact_hash {
1167        Some(h) => println!("hash        {h}"),
1168        None => println!("hash        (no calculado)"),
1169    }
1170    println!(
1171        "selección   {} bundle(s), {} perilla(s)",
1172        p.bundles.len(),
1173        p.knobs.len()
1174    );
1175    println!();
1176    println!("emite {} bandera(s); con su clausura caen {} símbolo(s)", p.symbols.len(), p.closure_size);
1177    println!("(se emiten RAÍCES: la clausura la calcula el olddefconfig del propio kernel)");
1178    for s in &p.symbols {
1179        let signo = match s.action {
1180            takana_core::kernel::plan::Action::Enable => "+",
1181            takana_core::kernel::plan::Action::Disable => "-",
1182        };
1183        println!("  {signo}{:<24} ← {}  ({})", s.symbol, s.from, s.reason);
1184    }
1185    if !p.unknown_symbols.is_empty() {
1186        println!();
1187        println!("⚠ {} símbolo(s) pedidos NO EXISTEN en este árbol y no se emiten:", p.unknown_symbols.len());
1188        for s in &p.unknown_symbols {
1189            println!("  {} ← {}", s.symbol, s.from);
1190        }
1191    }
1192    if !p.recipe_side.is_empty() {
1193        println!();
1194        println!("perillas del lado RECETA (declaradas, este plan no las aplica):");
1195        for k in &p.recipe_side {
1196            println!("  {:<24} {} — {}", k.id, k.recipe_field, k.title);
1197        }
1198    }
1199    println!();
1200    if let Some(o) = out {
1201        println!("plan     → {}", o.display());
1202    }
1203    if let Some(ro) = recipe_out {
1204        println!("receta   → {}", ro.display());
1205    }
1206    Ok(())
1207}
1208
1209fn diff_back(plan_path: &Path, config: &Path, json: bool) -> Result<()> {
1210    let p: takana_core::kernel::Plan = serde_json::from_str(
1211        &std::fs::read_to_string(plan_path)
1212            .with_context(|| format!("leyendo {}", plan_path.display()))?,
1213    )
1214    .with_context(|| format!("{} no es un plan de `hammer kernel plan`", plan_path.display()))?;
1215    let cfg = KernelConfig::parse(&read_maybe_gz(config)?);
1216    if cfg.is_empty() {
1217        anyhow::bail!("{} no tiene un solo CONFIG_*: ¿es un .config?", config.display());
1218    }
1219    let d = takana_core::kernel::diff_back(&p, &cfg);
1220
1221    if json {
1222        println!("{}", serde_json::to_string_pretty(&d)?);
1223    } else {
1224        println!("plan        {} ({})", p.derived_name, plan_path.display());
1225        println!("config      {}", config.display());
1226        println!("cumplidos   {}", d.honored.len());
1227        // Kconfig no emite un símbolo cuyas dependencias no se cumplen: para un `-d`, que no
1228        // aparezca ES el resultado buscado. Contarlo como fallo reportaba planes perfectos como
1229        // rotos — lo aprendí corriendo un olddefconfig de verdad.
1230        println!("apagados por dependencia  {} (no aparecen porque cayeron con su raíz)", d.off_by_dependency.len());
1231        if !d.never_existed.is_empty() {
1232            println!("no existen en este árbol  {}", d.never_existed.len());
1233            for s in &d.never_existed {
1234                println!("  {s} — la bandera no hace nada");
1235            }
1236        }
1237        println!("INCUMPLIDOS {}", d.violated.len());
1238        for v in &d.violated {
1239            println!(
1240                "  {} — se pidió {:?} y quedó {} (lo pidió {})",
1241                v.symbol, v.asked, v.got, v.from
1242            );
1243        }
1244    }
1245    if !d.is_clean() {
1246        anyhow::bail!("el .config no honra el plan");
1247    }
1248    Ok(())
1249}
1250
1251// ---------------------------------------------------------------------------------------------
1252// gate / hw
1253// ---------------------------------------------------------------------------------------------
1254
1255fn hw(out: Option<&Path>) -> Result<()> {
1256    let h = takana_core::kernel::Hardware::probe_local();
1257    let texto = serde_json::to_string_pretty(&h)?;
1258    match out {
1259        Some(o) => {
1260            if let Some(d) = o.parent() {
1261                std::fs::create_dir_all(d).ok();
1262            }
1263            std::fs::write(o, &texto).with_context(|| format!("escribiendo {}", o.display()))?;
1264            println!("huella   {}", h.fingerprint());
1265            println!("hardware → {}", o.display());
1266            println!("  {} PCI · {} USB · {} drivers bindeados", h.pci.len(), h.usb.len(), h.bound_drivers.len());
1267            for g in &h.gaps {
1268                println!("  ⚠ {g}");
1269            }
1270        }
1271        None => println!("{texto}"),
1272    }
1273    Ok(())
1274}
1275
1276#[allow(clippy::too_many_arguments)]
1277fn gate(
1278    plan_path: Option<&Path>,
1279    config: Option<&Path>,
1280    baseline: Option<&Path>,
1281    objective: &str,
1282    devices: Option<&Path>,
1283    kroot: &Path,
1284    catalog: &Path,
1285    json: bool,
1286) -> Result<()> {
1287    if plan_path.is_none() && config.is_none() {
1288        anyhow::bail!("pasá --plan (qué apagaría) o --config (qué quedó): sin uno de los dos no hay nada que comparar");
1289    }
1290    let p: Option<takana_core::kernel::Plan> = match plan_path {
1291        None => None,
1292        Some(pp) => Some(
1293            serde_json::from_str(
1294                &std::fs::read_to_string(pp).with_context(|| format!("leyendo {}", pp.display()))?,
1295            )
1296            .with_context(|| format!("{} no es un plan de `hammer kernel plan`", pp.display()))?,
1297        ),
1298    };
1299    let cat = takana_core::kernel::Catalog::load(catalog)?;
1300    let tree = load_tree(kroot)?;
1301    let modmap = takana_core::kernel::ModMap::parse(kroot)
1302        .with_context(|| format!("leyendo los Makefiles de {}", kroot.display()))?;
1303    if modmap.is_empty() {
1304        anyhow::bail!(
1305            "no encontré una sola regla `obj-$(CONFIG_…)` en {}: sin los Makefiles del kernel el \
1306             gate no puede mapear driver → símbolo, y un gate que no puede mirar no es un gate.",
1307            kroot.display()
1308        );
1309    }
1310
1311    let hardware = match devices {
1312        Some(d) => serde_json::from_str::<takana_core::kernel::Hardware>(
1313            &std::fs::read_to_string(d).with_context(|| format!("leyendo {}", d.display()))?,
1314        )
1315        .with_context(|| format!("{} no es un volcado de `hammer kernel hw`", d.display()))?,
1316        None => takana_core::kernel::Hardware::probe_local(),
1317    };
1318
1319    let r = match config {
1320        // Modo fuerte: comparar dos configs reales. Ve también los huecos que ya venían en la base.
1321        Some(c) => {
1322            let base_path = baseline
1323                .map(|b| b.to_path_buf())
1324                .map_or_else(running_config_path, Ok)?;
1325            let base = KernelConfig::parse(&read_maybe_gz(&base_path)?);
1326            let nuevo = KernelConfig::parse(&read_maybe_gz(c)?);
1327            if nuevo.is_empty() {
1328                anyhow::bail!("{} no tiene un solo CONFIG_*: ¿es un .config?", c.display());
1329            }
1330            println!("referente   {} ({} símbolos)", base_path.display(), base.len());
1331            takana_core::kernel::gate::run_against_config(
1332                &base,
1333                &nuevo,
1334                &cat,
1335                &modmap,
1336                &hardware.bound_drivers,
1337                objective,
1338                p.as_ref(),
1339                Some(&tree),
1340            )?
1341        }
1342        None => takana_core::kernel::gate::run(
1343            p.as_ref().expect("validado arriba"),
1344            &cat,
1345            &tree,
1346            &modmap,
1347            &hardware.bound_drivers,
1348            objective,
1349        )?,
1350    };
1351
1352    if json {
1353        println!("{}", serde_json::to_string_pretty(&r)?);
1354    } else {
1355        let obj = cat.objective(objective).map(|o| o.title.as_str()).unwrap_or("");
1356        if !r.plan.is_empty() {
1357            println!("plan        {}", r.plan);
1358        }
1359        println!("objetivo    {} — {obj}", r.objective);
1360        println!(
1361            "hardware    {} ({} drivers bindeados, {} reglas obj- en {} Makefiles)",
1362            devices.map(|d| d.display().to_string()).unwrap_or_else(|| "esta máquina".into()),
1363            r.drivers_in_use,
1364            modmap.len(),
1365            modmap.files
1366        );
1367        println!();
1368        if r.regressions.is_empty() {
1369            println!("✓ ningún dispositivo en uso se queda sin driver");
1370        } else {
1371            println!("✗ {} REGRESIÓN(ES) — dispositivos en uso que se quedarían sin driver:", r.regressions.len());
1372            for l in &r.regressions {
1373                // Sin culpable atribuido la causa NO es el plan: es que la receta base nunca
1374                // enciende ese símbolo. Decir «lo apaga una perilla» mandaría a buscar donde no está.
1375                let causa = if l.blamed_on.is_empty() {
1376                    "la receta base no lo enciende".to_string()
1377                } else {
1378                    format!("lo apaga {}", l.blamed_on.join(", "))
1379                };
1380                println!("  {:<20} {causa}  (símbolos: {})", l.driver, l.symbols.join(", "));
1381            }
1382        }
1383        if !r.allowed.is_empty() {
1384            println!();
1385            println!("{} pérdida(s) AUTORIZADAS por el objetivo:", r.allowed.len());
1386            for l in &r.allowed {
1387                println!("  {:<20} ← {}", l.driver, l.blamed_on.join(", "));
1388            }
1389        }
1390        if !r.unmapped.is_empty() {
1391            println!();
1392            println!(
1393                "⚠ {} driver(s) SIN COMPROBAR (no se pudieron mapear a ningún CONFIG_*):",
1394                r.unmapped.len()
1395            );
1396            println!("  {}", r.unmapped.join(" "));
1397            println!("  no cuentan como aprobados: el gate no los miró.");
1398        }
1399    }
1400
1401    if r.verdict == takana_core::kernel::Verdict::Bloquea {
1402        anyhow::bail!("el gate bloquea");
1403    }
1404    Ok(())
1405}
1406
1407// ---------------------------------------------------------------------------------------------
1408// contract — el contrato de capacidades (SDD 25 §4)
1409// ---------------------------------------------------------------------------------------------
1410
1411/// Nombre del artefacto si la ruta cae dentro de un directorio `<64 hex>-<nombre>` del store.
1412///
1413/// Es lo que permite que `--sealed` sepa qué perfil le toca a cada kernel sin que nadie lo escriba
1414/// dos veces. Fuera del store devuelve `None` y el perfil hay que pasarlo a mano.
1415fn artifact_of(path: &Path) -> Option<String> {
1416    for a in path.ancestors() {
1417        let name = a.file_name()?.to_string_lossy().to_string();
1418        if let Some((hash, art)) = name.split_once('-') {
1419            if hash.len() == 64 && hash.bytes().all(|b| b.is_ascii_hexdigit()) {
1420                return Some(art.to_string());
1421            }
1422        }
1423    }
1424    None
1425}
1426
1427/// Dónde viven las recetas de kernel cuando nadie pasa `--recipes`. La segunda es la de las
1428/// recetas DERIVADAS que emite `takana kernel plan` (p. ej. `linux-gioser`).
1429const RECIPE_DEFAULTS: [&str; 2] = ["recipes", "docs/state/kernel-plans"];
1430
1431/// Nombre del directorio del store (`<64 hex>-<nombre>`) que contiene esta ruta.
1432fn store_dir_of(path: &Path) -> Option<String> {
1433    for a in path.ancestors() {
1434        let name = a.file_name()?.to_string_lossy().to_string();
1435        if let Some((hash, _)) = name.split_once('-') {
1436            if hash.len() == 64 && hash.bytes().all(|b| b.is_ascii_hexdigit()) {
1437                return Some(name);
1438            }
1439        }
1440    }
1441    None
1442}
1443
1444/// ¿Resuelven las `deps.build` de esta receta desde `dir`? Misma regla que el build: hermana, y
1445/// si no, en el directorio padre (que es como una receta de `recipes/incoming-*/` alcanza el
1446/// catálogo).
1447fn deps_resuelven_desde(dir: &Path, deps: &[String]) -> bool {
1448    deps.iter().all(|d| {
1449        let f = format!("{d}.toml");
1450        dir.join(&f).exists() || dir.parent().is_some_and(|p| p.join(&f).exists())
1451    })
1452}
1453
1454/// La receta cuyo campo `name` es `artifact`, buscada en `dirs`.
1455///
1456/// Primero por convención (`<dir>/<artifact>.toml`) y sólo si eso no da, barriendo el directorio:
1457/// **el artefacto se nombra por el campo `name`, no por el fichero**, y en este corpus hay recetas
1458/// donde los dos difieren.
1459fn receta_de(artifact: &str, dirs: &[PathBuf]) -> Option<takana_core::Recipe> {
1460    for d in dirs {
1461        let directo = d.join(format!("{artifact}.toml"));
1462        if let Ok(r) = takana_core::Recipe::load_from_path(&directo) {
1463            if r.name == artifact {
1464                return Some(con_catalogo(r, dirs));
1465            }
1466        }
1467        let Ok(rd) = std::fs::read_dir(d) else { continue };
1468        for e in rd.flatten() {
1469            let p = e.path();
1470            if p.extension().is_some_and(|x| x == "toml") {
1471                if let Ok(r) = takana_core::Recipe::load_from_path(&p) {
1472                    if r.name == artifact {
1473                        return Some(con_catalogo(r, dirs));
1474                    }
1475                }
1476            }
1477        }
1478    }
1479    None
1480}
1481
1482/// Le presta un catálogo a una receta que vive fuera de uno.
1483///
1484/// Las recetas DERIVADAS (`takana kernel plan --recipe-out`) se guardan a propósito fuera de
1485/// `recipes/`: dejarlas ahí las mete en el grafo compartido como deuda. Pero sus `deps.build` son
1486/// las del catálogo padre, así que desde su directorio no resuelven y el hash no se puede calcular
1487/// — y sin hash no hay vigencia que comparar. Acá se les apunta el `base_dir` al primer directorio
1488/// de `dirs` donde sí resuelven, que es exactamente lo que hace el runbook cuando la copia a
1489/// `recipes/incoming-kernel/` para construirla.
1490///
1491/// NO se toca si la receta trae `patches`: `base_dir` también los resuelve, y moverlo los rompería
1492/// en silencio. Ahí es mejor no saber la vigencia (se comprueba todo) que hashear otra cosa.
1493fn con_catalogo(mut r: takana_core::Recipe, dirs: &[PathBuf]) -> takana_core::Recipe {
1494    if r.deps.build.is_empty() || deps_resuelven_desde(&r.base_dir, &r.deps.build) {
1495        return r;
1496    }
1497    if !r.source.patches.is_empty() {
1498        eprintln!(
1499            "aviso: «{}» vive fuera de un catálogo y trae patches ⇒ no le presto base_dir",
1500            r.name
1501        );
1502        return r;
1503    }
1504    if let Some(d) = dirs.iter().find(|d| deps_resuelven_desde(d, &r.deps.build)) {
1505        r.base_dir = d.clone();
1506    }
1507    r
1508}
1509
1510/// Qué directorio del store le toca HOY a cada kernel del contrato: `artefacto → <hash>-<nombre>`.
1511///
1512/// POR QUÉ. El store guarda TODOS los sellados, no el último. Sin esta pregunta `--sealed` mide
1513/// también los kernels que la receta de hoy ya no produce, y entonces el gate se queda rojo para
1514/// siempre por artefactos que nadie va a volver a construir: **un portón que no puede ponerse
1515/// verde deja de leerse, que es exactamente como se pierde un guardián**. Con ella, el superado se
1516/// informa (sigue siendo cierto que le falta la capacidad: si un USB por ahí arranca ese kernel,
1517/// sus Cards corren sin tope) pero no bloquea.
1518///
1519/// Es la misma vigencia que `hammer hash --check`: la del ArtifactHash de la receta de hoy con el
1520/// lab de hoy — o sea que un kernel sellado con otro rootfs también sale superado, y es correcto
1521/// (SDD 25: el lab entra en el artefacto).
1522fn dirs_vigentes(
1523    c: &takana_core::kernel::Contract,
1524    dirs: &[PathBuf],
1525    store: &str,
1526) -> Result<std::collections::BTreeMap<String, String>> {
1527    let st = takana_core::Store::open(Path::new(store))?;
1528    let cfg = takana_build::BuildConfig::from_env_or_defaults(st.root());
1529    let lab = takana_core::lab::LabFingerprint::from_rootfs(&cfg.rootfs)?;
1530    let mut out = std::collections::BTreeMap::new();
1531    for t in &c.targets {
1532        let Some(r) = receta_de(&t.artifact, dirs) else {
1533            eprintln!(
1534                "aviso: no encontré la receta de «{}» en {} ⇒ sus sellados se comprueban todos",
1535                t.artifact,
1536                dirs.iter().map(|d| d.display().to_string()).collect::<Vec<_>>().join(" ")
1537            );
1538            continue;
1539        };
1540        match takana_build::artifact_hash(&r, &st, &lab) {
1541            Ok(h) => {
1542                out.insert(t.artifact.clone(), h.store_dir_name(&r.name));
1543            }
1544            Err(e) => eprintln!("aviso: no pude calcular el hash vigente de «{}»: {e}", t.artifact),
1545        }
1546    }
1547    Ok(out)
1548}
1549
1550#[derive(Serialize)]
1551struct ContractRun {
1552    contract: String,
1553    reports: Vec<takana_core::kernel::ContractReport>,
1554    /// Sellados que la receta de HOY ya no produce. Se informan y no bloquean: ver `dirs_vigentes`.
1555    #[serde(default)]
1556    superados: Vec<takana_core::kernel::ContractReport>,
1557    /// Configs que NO se comprobaron por no tener perfil declarado. No cuentan como aprobados.
1558    sin_perfil: Vec<String>,
1559    /// Objetivos del contrato cuya receta de hoy NO tiene ni un sellado en el store. No se
1560    /// comprobó nada de ellos: se nombran para que la ausencia no pase por aprobado.
1561    #[serde(default)]
1562    sin_construir: Vec<String>,
1563}
1564
1565fn contract_cmd(
1566    configs: &[PathBuf],
1567    sealed: bool,
1568    recipes: &[PathBuf],
1569    profile: Option<&str>,
1570    contract: &Path,
1571    store: &str,
1572    list: bool,
1573    json: bool,
1574) -> Result<()> {
1575    use takana_core::kernel::contract::sealed_kernel_configs;
1576    use takana_core::kernel::Contract;
1577
1578    let c = Contract::load(contract)?;
1579
1580    if list {
1581        if json {
1582            println!("{}", serde_json::to_string_pretty(&c)?);
1583            return Ok(());
1584        }
1585        println!("contrato    {}  (v{}", contract.display(), c.version);
1586        if let Some(r) = &c.reviewed_against {
1587            println!("revisado    contra kernel {r}");
1588        }
1589        println!();
1590        let req = takana_core::kernel::contract::required_by(&c);
1591        for cap in &c.capabilities {
1592            let quien = req.get(cap.id.as_str()).map(|v| v.join(", ")).unwrap_or_default();
1593            let quien = if quien.is_empty() { "—".to_string() } else { quien };
1594            println!("{:<24} {}", cap.id, cap.title);
1595            println!("{:<24} símbolos: {}", "", cap.symbols.join(" "));
1596            println!("{:<24} consumidor: {}", "", cap.consumer.trim().replace('\n', " "));
1597            println!("{:<24} exigida por: {quien}", "");
1598            println!();
1599        }
1600        for p in &c.profiles {
1601            println!("perfil {:<17} {}", p.id, p.title);
1602            println!("{:<24} exige: {}", "", p.requires.join(" "));
1603            println!("{:<24} quiere: {}", "", p.wants.join(" "));
1604        }
1605        println!();
1606        for t in &c.targets {
1607            println!("{:<24} → perfil {}", t.artifact, t.profile);
1608        }
1609        return Ok(());
1610    }
1611
1612    // Qué configs se miran, en orden: --sealed, --config, y si no hay nada, el kernel vivo.
1613    let mut objetivos: Vec<(String, Option<String>, PathBuf)> = Vec::new();
1614
1615    // Vigencia: sólo tiene sentido en el barrido del store. Un `--config` a mano es una pregunta
1616    // explícita sobre ESE fichero y se comprueba siempre.
1617    let vigentes = if sealed {
1618        let dirs: Vec<PathBuf> = if recipes.is_empty() {
1619            RECIPE_DEFAULTS.iter().map(PathBuf::from).filter(|d| d.is_dir()).collect()
1620        } else {
1621            recipes.to_vec()
1622        };
1623        if dirs.is_empty() {
1624            eprintln!(
1625                "aviso: no hay dónde mirar las recetas ({}) ⇒ no puedo distinguir vigente de \
1626                 superado y comprueba TODO lo sellado",
1627                RECIPE_DEFAULTS.join(" ")
1628            );
1629            None
1630        } else {
1631            match dirs_vigentes(&c, &dirs, store) {
1632                Ok(v) => Some(v),
1633                Err(e) => {
1634                    // Callar acá sería fingir que todo está al día: si no se puede clasificar, se
1635                    // comprueba todo (ruidoso) y se dice por qué.
1636                    eprintln!("aviso: no pude resolver los artefactos vigentes ({e}) ⇒ comprueba TODO lo sellado");
1637                    None
1638                }
1639            }
1640        }
1641    } else {
1642        None
1643    };
1644
1645    if sealed {
1646        let s = sealed_kernel_configs(Path::new(store));
1647        if s.is_empty() {
1648            anyhow::bail!(
1649                "no hay ningún kernel sellado con `boot/config-*` en {store}.\n\
1650                 Un kernel que no instale su config no se puede comprobar — y fingir que pasó \
1651                 sería el mismo fallo que este guardián viene a arreglar."
1652            );
1653        }
1654        for (art, p) in s {
1655            objetivos.push((format!("{art} · {}", p.display()), Some(art), p));
1656        }
1657    }
1658    for p in configs {
1659        objetivos.push((p.display().to_string(), artifact_of(p), p.clone()));
1660    }
1661    if objetivos.is_empty() {
1662        let p = running_config_path()?;
1663        objetivos.push((format!("kernel vivo · {}", p.display()), None, p));
1664    }
1665
1666    let mut run = ContractRun {
1667        contract: contract.display().to_string(),
1668        reports: Vec::new(),
1669        superados: Vec::new(),
1670        sin_perfil: Vec::new(),
1671        sin_construir: Vec::new(),
1672    };
1673
1674    for (label, artifact, path) in &objetivos {
1675        let cfg = takana_core::kernel::KernelConfig::parse(&read_maybe_gz(path)?);
1676        if cfg.is_empty() {
1677            anyhow::bail!("{} no tiene un solo CONFIG_*: ¿es un .config?", path.display());
1678        }
1679        let prof = match profile {
1680            Some(p) => Some(p.to_string()),
1681            None => artifact
1682                .as_deref()
1683                .and_then(|a| c.profile_for(a))
1684                .map(str::to_string),
1685        };
1686        let Some(prof) = prof else {
1687            run.sin_perfil.push(label.clone());
1688            continue;
1689        };
1690        let rep = c.check(&cfg, &prof, label)?;
1691        let superado = match (&vigentes, artifact.as_deref(), store_dir_of(path)) {
1692            (Some(v), Some(art), Some(dir)) => v.get(art).is_some_and(|d| *d != dir),
1693            _ => false,
1694        };
1695        if superado {
1696            run.superados.push(rep);
1697        } else {
1698            run.reports.push(rep);
1699        }
1700    }
1701
1702    // Un objetivo del contrato cuya receta de hoy no tiene NINGÚN sellado no aparece en el
1703    // barrido — y no aparecer se lee como que no hay nada que objetar. Se nombra.
1704    if let Some(v) = &vigentes {
1705        let vistos: std::collections::BTreeSet<String> =
1706            objetivos.iter().filter_map(|(_, _, p)| store_dir_of(p)).collect();
1707        for (art, dir) in v {
1708            if !vistos.contains(dir) {
1709                run.sin_construir.push(format!("{art} · {dir}"));
1710            }
1711        }
1712    }
1713
1714    if json {
1715        println!("{}", serde_json::to_string_pretty(&run)?);
1716    } else {
1717        imprimir_contrato(&run);
1718    }
1719
1720    let bloquean = run
1721        .reports
1722        .iter()
1723        .filter(|r| r.verdict == takana_core::kernel::Verdict::Bloquea)
1724        .count();
1725    if bloquean > 0 {
1726        anyhow::bail!("el contrato NO se cumple en {bloquean} kernel(es)");
1727    }
1728    // Un objetivo sin sellado vigente no se comprobó, y no comprobar no es aprobar: es el fallo
1729    // de CLAUDE.md §3 —el vacío leído como presencia— en el propio guardián.
1730    if !run.sin_construir.is_empty() {
1731        anyhow::bail!(
1732            "{} objetivo(s) del contrato sin un sellado vigente: no se comprobó nada de ellos. \
1733             Construí sus recetas antes de leer esto como aprobado.",
1734            run.sin_construir.len()
1735        );
1736    }
1737    Ok(())
1738}
1739
1740fn imprimir_contrato(run: &ContractRun) {
1741    println!("contrato    {}", run.contract);
1742    println!();
1743    for r in &run.reports {
1744        let marca = if r.verdict == takana_core::kernel::Verdict::Bloquea { "✗" } else { "✓" };
1745        println!("{marca} {}", r.config);
1746        println!("  perfil    {}   ({} capacidad(es) miradas)", r.profile, r.checked);
1747        if let Some(k) = &r.kernel {
1748            println!("  kernel    {k}");
1749        }
1750        for m in &r.missing_required {
1751            println!("  ✗ FALTA   {} — {}", m.id, m.title);
1752            if !m.explicit_off.is_empty() {
1753                println!("            apagado a mano: {}", m.explicit_off.join(" "));
1754            }
1755            if !m.absent.is_empty() {
1756                println!(
1757                    "            ni nombrado en el .config: {} (¿renombrado entre versiones?)",
1758                    m.absent.join(" ")
1759                );
1760            }
1761            println!("            lo usa: {}", m.consumer.trim().replace('\n', " "));
1762            if let Some(s) = &m.silent {
1763                println!("            cómo falla hoy: {}", s.trim().replace('\n', " "));
1764            }
1765        }
1766        for m in &r.missing_wanted {
1767            println!(
1768                "  · falta (no bloquea) {} — {} [{}]",
1769                m.id,
1770                m.title,
1771                [m.explicit_off.clone(), m.absent.clone()].concat().join(" ")
1772            );
1773        }
1774        for u in &r.unknown {
1775            println!(
1776                "  ⚠ el perfil nombra «{}», que el contrato no define: NO se comprobó",
1777                u.id
1778            );
1779        }
1780        if r.missing_required.is_empty() {
1781            println!("  ✓ las {} capacidades exigidas están", r.ok.iter().filter(|o| o.required).count());
1782        }
1783        println!();
1784    }
1785    let malos = run
1786        .reports
1787        .iter()
1788        .filter(|r| r.verdict == takana_core::kernel::Verdict::Bloquea)
1789        .count();
1790    if !run.superados.is_empty() {
1791        let sup_malos = run
1792            .superados
1793            .iter()
1794            .filter(|r| r.verdict == takana_core::kernel::Verdict::Bloquea)
1795            .count();
1796        println!(
1797            "· {} sellado(s) SUPERADO(s) por la receta de hoy — no bloquean, {} con faltas:",
1798            run.superados.len(),
1799            sup_malos
1800        );
1801        for r in &run.superados {
1802            let faltan: Vec<&str> = r.missing_required.iter().map(|m| m.id.as_str()).collect();
1803            println!(
1804                "  {}  {}",
1805                r.config,
1806                if faltan.is_empty() { "cumplía".to_string() } else { format!("le falta: {}", faltan.join(" ")) }
1807            );
1808        }
1809        println!(
1810            "  Siguen siendo ciertos: si una máquina arranca uno de éstos, sus Cards corren con lo \
1811             que ese kernel traiga. Se reconstruyen o se podan (`scripts/store-gc.sh`)."
1812        );
1813        println!();
1814    }
1815    println!(
1816        "resumen     {} de {} config(s) VIGENTES cumplen su perfil{}{}",
1817        run.reports.len() - malos,
1818        run.reports.len(),
1819        if run.superados.is_empty() {
1820            String::new()
1821        } else {
1822            format!(" · {} superado(s)", run.superados.len())
1823        },
1824        if run.sin_perfil.is_empty() {
1825            String::new()
1826        } else {
1827            format!(" · {} sin comprobar", run.sin_perfil.len())
1828        }
1829    );
1830    println!();
1831    if !run.sin_construir.is_empty() {
1832        println!(
1833            "⚠ {} objetivo(s) del contrato SIN UN SELLADO VIGENTE — no se comprobó nada de ellos:",
1834            run.sin_construir.len()
1835        );
1836        for s in &run.sin_construir {
1837            println!("  {s}");
1838        }
1839        println!("  no cuentan como aprobados: el barrido no tuvo qué mirar. Construí sus recetas.");
1840        println!();
1841    }
1842    if !run.sin_perfil.is_empty() {
1843        println!("⚠ {} config(s) SIN COMPROBAR (ningún perfil declarado):", run.sin_perfil.len());
1844        for s in &run.sin_perfil {
1845            println!("  {s}");
1846        }
1847        println!("  no cuentan como aprobados. Declaralos en [[target]] o pasá --profile.");
1848    }
1849}
1850
1851/// Lee un `.config`, descomprimiendo si es `.gz` (el caso de `/proc/config.gz`).
1852///
1853/// Se delega a `gzip -dc` en vez de arrastrar un descompresor al core: es una entrada de
1854/// diagnóstico, no del camino de build, y el core queda sin dependencias nuevas.
1855pub fn read_maybe_gz(p: &Path) -> Result<String> {
1856    if p.extension().is_some_and(|e| e == "gz") {
1857        let out = std::process::Command::new("gzip")
1858            .arg("-dc")
1859            .arg(p)
1860            .output()
1861            .with_context(|| format!("descomprimiendo {}", p.display()))?;
1862        if !out.status.success() {
1863            anyhow::bail!(
1864                "gzip -dc {} falló: {}",
1865                p.display(),
1866                String::from_utf8_lossy(&out.stderr).trim()
1867            );
1868        }
1869        return Ok(String::from_utf8_lossy(&out.stdout).into_owned());
1870    }
1871    std::fs::read_to_string(p).with_context(|| format!("leyendo {}", p.display()))
1872}
1873
1874#[cfg(test)]
1875mod tests {
1876    use super::*;
1877
1878    #[test]
1879    fn store_dir_of_saca_el_directorio_y_artifact_of_el_nombre() {
1880        let hex = "a".repeat(64);
1881        let p = PathBuf::from(format!("./store/{hex}-linux-metal/boot/config-6.16.12-metal"));
1882        assert_eq!(store_dir_of(&p).as_deref(), Some(format!("{hex}-linux-metal").as_str()));
1883        assert_eq!(artifact_of(&p).as_deref(), Some("linux-metal"));
1884        // Fuera del store no hay vigencia que comparar: mejor None que una comparación inventada.
1885        assert_eq!(store_dir_of(Path::new("/boot/config-7.1.4")), None);
1886    }
1887
1888    #[test]
1889    fn serie_de_las_dos_formas_que_se_cruzan() {
1890        assert_eq!(serie("Linux/x86 7.1.4-artix1 Kernel Configuration").as_deref(), Some("7.1"));
1891        assert_eq!(serie("linux-6.16.12").as_deref(), Some("6.16"));
1892        assert_eq!(serie("sin versión"), None);
1893    }
1894}
1895
1896// ══ A/B del kernel (ADR 0017 §2) ═══════════════════════════════════════════════════════════════
1897
1898use crate::efi_boot;
1899use crate::kernel_ab::{self, Veredicto};
1900use anyhow::bail;
1901
1902/// Localiza el bzImage de un artefacto del store, aceptando nombre de directorio, hash o ruta.
1903fn bzimage_de(artefacto: &str, store: &str) -> Result<(PathBuf, String)> {
1904    let directo = PathBuf::from(artefacto);
1905    let dir = if directo.is_dir() {
1906        directo
1907    } else {
1908        let s = PathBuf::from(store);
1909        let exacto = s.join(artefacto);
1910        if exacto.is_dir() {
1911            exacto
1912        } else {
1913            // Por prefijo de hash o por nombre de receta: útil a mano, y si hay varios se dice
1914            // en vez de elegir por orden de lectura del directorio.
1915            let pat = artefacto.trim_start_matches("b3:");
1916            let mut cand: Vec<PathBuf> = std::fs::read_dir(&s)
1917                .with_context(|| format!("no puedo leer el store {}", s.display()))?
1918                .filter_map(|e| e.ok())
1919                .map(|e| e.path())
1920                .filter(|p| {
1921                    let n = p.file_name().unwrap_or_default().to_string_lossy().to_string();
1922                    p.is_dir() && (n.starts_with(pat) || n.split_once('-').map(|(_, r)| r == pat).unwrap_or(false))
1923                })
1924                .collect();
1925            cand.sort();
1926            match cand.len() {
1927                1 => cand.remove(0),
1928                0 => bail!("no encuentro el artefacto «{artefacto}» en {}", s.display()),
1929                n => bail!(
1930                    "«{artefacto}» casa con {n} artefactos; usá el nombre completo:\n  {}",
1931                    cand.iter()
1932                        .map(|p| p.file_name().unwrap_or_default().to_string_lossy().to_string())
1933                        .collect::<Vec<_>>()
1934                        .join("\n  ")
1935                ),
1936            }
1937        }
1938    };
1939    let bz = dir.join("boot/bzImage");
1940    if !bz.is_file() {
1941        bail!("{} no trae boot/bzImage: ¿es un artefacto de kernel?", dir.display());
1942    }
1943    let nombre = dir.file_name().unwrap_or_default().to_string_lossy().to_string();
1944    Ok((bz, nombre))
1945}
1946
1947fn ab_stage(
1948    artefacto: &str,
1949    store: &str,
1950    esp: &Path,
1951    efivars: &Path,
1952    tries: u32,
1953    state: &Path,
1954    dry_run: bool,
1955) -> Result<()> {
1956    let (bz, nombre) = bzimage_de(artefacto, store)?;
1957    let tam = std::fs::metadata(&bz)?.len();
1958    println!("candidato: {nombre}");
1959    println!("  bzImage:  {} ({:.1} MiB)", bz.display(), tam as f64 / 1048576.0);
1960
1961    if !esp.is_dir() {
1962        bail!(
1963            "la ESP no está montada en {}: montala primero\n  mount -t vfat <partición-ESP> {}",
1964            esp.display(),
1965            esp.display()
1966        );
1967    }
1968    if !efi_boot::hay_firmware_efi(efivars) {
1969        bail!(
1970            "sin NVRAM en {}: sin firmware UEFI no hay BootNext, y sin BootNext no hay A/B",
1971            efivars.display()
1972        );
1973    }
1974
1975    // Que entre ANTES de copiar: una ESP llena a media copia deja un candidato truncado, y un
1976    // candidato truncado no falla — arranca y muere en el firmware.
1977    let destino = kernel_ab::en_esp(esp, kernel_ab::RUTA_CANDIDATO);
1978    println!("  ranura:   {} (propia; el estable no se toca)", destino.display());
1979
1980    if dry_run {
1981        println!("--dry-run: no se copió nada ni se tocó la NVRAM");
1982        return Ok(());
1983    }
1984
1985    let n = kernel_ab::instalar_candidato(&bz, esp, kernel_ab::RUTA_CANDIDATO)?;
1986    println!("  ✓ copiado ({n} bytes) de forma atómica (tmp + rename)");
1987
1988    // Entrada NVRAM propia del candidato, apuntando a SU ranura.
1989    let (disco, parte_n) = {
1990        let discos = efi_boot::block_disks(Path::new("/sys/block")).unwrap_or_default();
1991        let esps = efi_boot::find_esps(&discos);
1992        match esps.len() {
1993            1 => esps[0].clone(),
1994            0 => bail!("no encuentro ninguna ESP para apuntar la entrada de arranque"),
1995            n => bail!("hay {n} ESP: no voy a adivinar cuál es la de este arranque"),
1996        }
1997    };
1998    let parte = efi_boot::read_partition(&disco, parte_n)?;
1999    let r = efi_boot::reconcile(
2000        efivars,
2001        &parte,
2002        kernel_ab::RUTA_CANDIDATO,
2003        kernel_ab::ETIQUETA_CANDIDATO,
2004        false,
2005    )?;
2006    println!("  ✓ entrada Boot{:04X} «{}»", r.slot, kernel_ab::ETIQUETA_CANDIDATO);
2007
2008    // OJO: `reconcile` deja al candidato PRIMERO en BootOrder, y eso no es lo que queremos — el
2009    // candidato tiene que arrancar UNA vez, no volverse el default. Se repone el orden y se usa
2010    // BootNext, que es de un solo uso.
2011    if r.orden_cambiado {
2012        let ruta_orden = efivars.join(format!("BootOrder-{}", efi_boot::GLOBAL_GUID));
2013        efi_boot::write_var(
2014            &ruta_orden,
2015            efi_boot::ATTRS,
2016            &efi_boot::serialize_boot_order(&r.orden_antes),
2017        )?;
2018        println!("  ✓ BootOrder repuesto: el candidato NO se vuelve el default");
2019    }
2020    kernel_ab::poner_boot_next(efivars, r.slot)?;
2021    println!("  ✓ BootNext = Boot{:04X} (un solo uso)", r.slot);
2022
2023    let mut e = kernel_ab::leer_estado(state);
2024    e.candidato = Some(kernel_ab::Candidato {
2025        hash: nombre.split_once('-').map(|(h, _)| format!("b3:{h}")).unwrap_or(nombre.clone()),
2026        ruta: kernel_ab::RUTA_CANDIDATO.into(),
2027        slot: r.slot,
2028        intentos: tries,
2029    });
2030    kernel_ab::escribir_estado(state, &e)?;
2031    println!("\nEl PRÓXIMO arranque usa el candidato. Si no arranca, el firmware vuelve solo al");
2032    println!("estable — BootNext es de un solo uso. Si arranca y anda: `takana kernel confirm`.");
2033    Ok(())
2034}
2035
2036fn ab_status(efivars: &Path, state: &Path) -> Result<()> {
2037    let e = kernel_ab::leer_estado(state);
2038    let actual = kernel_ab::boot_current(efivars);
2039    match actual {
2040        Some(a) => println!("arrancamos por Boot{a:04X}"),
2041        None => println!("el firmware no publica BootCurrent (o no hay NVRAM)"),
2042    }
2043    if let Some(n) = kernel_ab::boot_next(efivars) {
2044        println!("BootNext pendiente: Boot{n:04X} (el próximo arranque irá por ahí)");
2045    }
2046    match kernel_ab::evaluar(&e, actual) {
2047        Veredicto::SinCandidato => {
2048            println!("sin candidato: el kernel que corre es el estable");
2049            // El firmware es la fuente de verdad, no nuestro fichero. Si el estado se perdió —el
2050            // caso real es que viviera en algo que no persiste— pero la NVRAM todavía tiene la
2051            // entrada del candidato, hay basura que nadie va a limpiar porque nadie sabe que está.
2052            // Medido: con el estado en un tmpfs, `stage` + reboot dejaba exactamente esto.
2053            let huerfanas: Vec<u16> = efi_boot::list_entries(efivars)
2054                .unwrap_or_default()
2055                .into_iter()
2056                .filter(|(_, lo)| lo.description == kernel_ab::ETIQUETA_CANDIDATO)
2057                .map(|(n, _)| n)
2058                .collect();
2059            if !huerfanas.is_empty() {
2060                println!("⚠ pero la NVRAM tiene {} entrada(s) «{}» sin estado que las respalde:",
2061                    huerfanas.len(), kernel_ab::ETIQUETA_CANDIDATO);
2062                for n in &huerfanas {
2063                    println!("    Boot{n:04X}");
2064                }
2065                println!("  El fichero de estado se perdió o nunca persistió. `kernel rollback` no");
2066                println!("  puede limpiarlas sin saber cuál era: borralas a mano o volvé a hacer `stage`.");
2067            }
2068        }
2069        Veredicto::EnPrueba { slot, intentos_restantes } => {
2070            println!("⏳ EN PRUEBA: Boot{slot:04X} es el candidato y es el que está corriendo");
2071            println!("   le quedan {intentos_restantes} arranque(s) sin confirmar");
2072            println!("   → `takana kernel confirm` si anda, `takana kernel rollback` si no");
2073        }
2074        Veredicto::CandidatoFallo { slot } => {
2075            println!("✗ el candidato Boot{slot:04X} NO arrancó: esta sesión vino del estable");
2076            println!("   → `takana kernel rollback` lo descarta (o `stage` otra vez para reintentar)");
2077        }
2078        Veredicto::CandidatoAgotado { slot } => {
2079            println!("✗ el candidato Boot{slot:04X} agotó sus intentos sin que nadie lo confirmara");
2080            println!("   → `takana kernel rollback`");
2081        }
2082    }
2083    Ok(())
2084}
2085
2086fn ab_confirm(esp: &Path, efivars: &Path, state: &Path) -> Result<()> {
2087    let mut e = kernel_ab::leer_estado(state);
2088    let Some(c) = e.candidato.clone() else {
2089        println!("no hay candidato que confirmar");
2090        return Ok(());
2091    };
2092    let actual = kernel_ab::boot_current(efivars);
2093    // Confirmar un kernel que NO es el que está corriendo sería promover algo que nadie probó, que
2094    // es exactamente lo contrario de para qué existe esto.
2095    if let Some(a) = actual {
2096        if a != c.slot {
2097            bail!(
2098                "esta sesión arrancó por Boot{a:04X} y el candidato es Boot{:04X}: no se confirma \
2099                 un kernel que no es el que está corriendo",
2100                c.slot
2101            );
2102        }
2103    }
2104    let cand = kernel_ab::en_esp(esp, &c.ruta);
2105    let estable = kernel_ab::en_esp(esp, kernel_ab::RUTA_ESTABLE);
2106    let dir = estable.parent().context("ruta estable sin directorio")?;
2107    let tmp = dir.join("takana-prom.tmp");
2108    std::fs::copy(&cand, &tmp)
2109        .with_context(|| format!("copiando {} → {}", cand.display(), tmp.display()))?;
2110    std::fs::rename(&tmp, &estable)?;
2111    println!("✓ promovido: el candidato es ahora el kernel estable ({})", estable.display());
2112
2113    // La ruta fallback también, que es la otra mitad del seguro del ADR 0018 §3.
2114    let fallback = kernel_ab::en_esp(esp, "/EFI/BOOT/BOOTX64.EFI");
2115    if fallback.exists() {
2116        let t2 = dir.join("takana-fb.tmp");
2117        if std::fs::copy(&cand, &t2).is_ok() && std::fs::rename(&t2, &fallback).is_ok() {
2118            println!("✓ y la ruta fallback, que apunta al mismo kernel (ADR 0018 §3)");
2119        }
2120    }
2121    let _ = std::fs::remove_file(&cand);
2122    kernel_ab::borrar_boot_next(efivars);
2123    e.estable_hash = Some(c.hash.clone());
2124    e.candidato = None;
2125    kernel_ab::escribir_estado(state, &e)?;
2126    println!("  estable: {}", c.hash);
2127    Ok(())
2128}
2129
2130fn ab_rollback(esp: &Path, efivars: &Path, state: &Path) -> Result<()> {
2131    let mut e = kernel_ab::leer_estado(state);
2132    let Some(c) = e.candidato.clone() else {
2133        println!("no hay candidato que descartar");
2134        return Ok(());
2135    };
2136    kernel_ab::borrar_boot_next(efivars);
2137    let cand = kernel_ab::en_esp(esp, &c.ruta);
2138    if cand.exists() {
2139        let _ = std::fs::remove_file(&cand);
2140        println!("✓ borrada la ranura del candidato ({})", cand.display());
2141    }
2142    // La entrada NVRAM del candidato se va también: dejarla sería ofrecer en el menú del firmware
2143    // un arranque que apunta a un fichero que ya no está.
2144    let var = efi_boot::boot_var_path(efivars, c.slot);
2145    if var.exists() {
2146        let _ = std::fs::remove_file(&var);
2147        println!("✓ borrada la entrada Boot{:04X}", c.slot);
2148    }
2149    e.candidato = None;
2150    kernel_ab::escribir_estado(state, &e)?;
2151    println!("el kernel estable sigue siendo el que estaba. No se tocó.");
2152    Ok(())
2153}
2154
2155/// ¿Qué impide el salto en ESTA máquina? Las tres puertas, en el orden en que se cierran.
2156///
2157/// Se responde **preguntándole al kernel** (un `kexec_file_load` con fd inválido distingue ENOSYS de
2158/// EBADF sin cargar nada) y leyendo `/sys/kernel/security/lockdown`, no adivinando por el `.config`:
2159/// el `.config` que hay en `/boot` puede no ser el del kernel que corre.
2160pub(crate) fn kexec_probe() -> (bool, Vec<String>) {
2161    let mut motivos = Vec::new();
2162    #[cfg(target_family = "unix")]
2163    let root = unsafe { libc_getuid() } == 0;
2164    #[cfg(not(target_family = "unix"))]
2165    let root = true;
2166
2167    // ⚠ **La detección del syscall sólo es CONCLUYENTE como root, y da un falso positivo al revés.**
2168    // Medido acá el 2026-09-23 sobre el MISMO kernel: como uid 1001 dentro de la jaula el sondeo dijo
2169    // «el syscall existe» y como root del anfitrión dijo ENOSYS. La causa es el orden de los chequeos
2170    // del kernel: sin CAP_SYS_BOOT contesta EPERM **antes** de llegar a decir que no lo implementa,
2171    // y el sondeo sólo sabe distinguir ENOSYS de lo demás. O sea que un «te falta ser root» puede
2172    // estar tapando un «además tu kernel no lo trae». Un NO sigue siendo fiable; un «todo bien menos
2173    // root» no lo es, y decirlo es la diferencia entre un diagnóstico y una corazonada.
2174    if !kernel_ab::kexec_file_disponible() {
2175        motivos.push(
2176            "el kernel que CORRE no implementa kexec_file_load (le falta CONFIG_KEXEC_FILE). \
2177             Los kernels del corpus traen sólo el syscall viejo; la variante \
2178             `recipes/linux-metal-kexec.toml` lo trae encendido."
2179                .to_string(),
2180        );
2181    }
2182    // El syscall existe pero lo cierra CAP_SYS_BOOT. Se dice aparte porque se arregla distinto
2183    // (correr como root) y confundirlo con lo de arriba manda a recompilar un kernel que está bien.
2184    if !root {
2185        motivos.push(
2186            "no somos root: kexec_file_load pide CAP_SYS_BOOT. Y OJO — sin privilegios el resto de \
2187             este diagnóstico no es concluyente: el kernel contesta EPERM antes de decir si \
2188             implementa el syscall, así que hay que repetir el probe COMO ROOT antes de creerle."
2189                .to_string(),
2190        );
2191    }
2192    // Lockdown es lo que enciende Secure Boot, y es la puerta que sorprende: el kernel tiene el
2193    // flag, sos root, y aun así EPERM. En modo `integrity` o `confidentiality` el kernel sólo
2194    // acepta una imagen FIRMADA, y las nuestras no lo están (ADR 0018, decisión abierta de firma).
2195    if let Ok(t) = std::fs::read_to_string("/sys/kernel/security/lockdown") {
2196        let t = t.trim().to_string();
2197        if !t.contains("[none]") && !t.is_empty() {
2198            motivos.push(format!(
2199                "el kernel está en LOCKDOWN ({t}) — lo enciende Secure Boot, y en ese modo sólo \
2200                 acepta kernels firmados"
2201            ));
2202        }
2203    }
2204    (motivos.is_empty(), motivos)
2205}
2206
2207/// Si somos root. Se expone porque un «te falta ser root» y un «tu kernel no trae el syscall» se
2208/// arreglan de formas opuestas —repetir con `sudo` contra arrancar de un USB— y quien pone la cara
2209/// amable necesita distinguirlos sin leer las cadenas del diagnóstico.
2210#[cfg(target_family = "unix")]
2211pub(crate) fn soy_root() -> bool {
2212    unsafe { libc_getuid() == 0 }
2213}
2214#[cfg(not(target_family = "unix"))]
2215pub(crate) fn soy_root() -> bool {
2216    true
2217}
2218
2219/// `getuid()` sin arrastrar la crate `libc` al workspace, igual que el syscall de kexec.
2220#[cfg(target_arch = "x86_64")]
2221unsafe fn libc_getuid() -> u32 {
2222    let ret: i64;
2223    unsafe {
2224        std::arch::asm!("syscall", inlateout("rax") 102u64 => ret,
2225            lateout("rcx") _, lateout("r11") _, options(nostack));
2226    }
2227    ret as u32
2228}
2229#[cfg(not(target_arch = "x86_64"))]
2230unsafe fn libc_getuid() -> u32 { 0 }
2231
2232fn kexec_cmd(
2233    artefacto: Option<&str>,
2234    store: &str,
2235    initrd: Option<&Path>,
2236    cmdline: &str,
2237    load_only: bool,
2238    jump: bool,
2239    probe: bool,
2240) -> Result<()> {
2241    if probe {
2242        let (puede, motivos) = kexec_probe();
2243        if puede {
2244            println!("kexec: SÍ — esta máquina puede saltar a otro kernel sin pasar por el firmware");
2245            return Ok(());
2246        }
2247        println!("kexec: NO — no se puede saltar en esta máquina:");
2248        for m in &motivos {
2249            println!("  · {m}");
2250        }
2251        std::process::exit(1);
2252    }
2253    let artefacto = artefacto.ok_or_else(|| anyhow::anyhow!("falta el artefacto del kernel (o pasá --probe)"))?;
2254    let (bz, nombre) = bzimage_de(artefacto, store)?;
2255    println!("kexec: {nombre}");
2256    println!("  kernel:  {}", bz.display());
2257    match initrd {
2258        Some(p) => println!("  initrd:  {}", p.display()),
2259        None => println!("  initrd:  (ninguno)"),
2260    }
2261    println!("  cmdline: {cmdline}");
2262
2263    // Ojo: esto detecta si el SYSCALL existe, no si podemos usarlo. Un kernel con el flag puesto y
2264    // un usuario sin privilegios pasa esta puerta y falla después con EPERM, que es lo correcto:
2265    // mejor el error real del syscall que una sospecha nuestra.
2266    if !kernel_ab::kexec_file_disponible() {
2267        bail!(
2268            "este kernel no implementa kexec_file_load (falta CONFIG_KEXEC_FILE).\n  \
2269             Los kernels del corpus traen CONFIG_KEXEC —el syscall viejo— que exige armar los \
2270             segmentos y el purgatory a mano, o sea kexec-tools entero.\n  \
2271             La variante `recipes/linux-metal-kexec.toml` lo trae encendido."
2272        );
2273    }
2274
2275    kernel_ab::kexec_cargar(&bz, initrd, cmdline)?;
2276    println!("✓ kernel cargado en memoria");
2277    if load_only || !jump {
2278        println!("  (no se salta: pasá --jump. El kernel queda cargado hasta el próximo reboot.)");
2279        return Ok(());
2280    }
2281    println!("⚡ saltando — el userspace muere ACÁ. Esto no vuelve.");
2282    kernel_ab::kexec_saltar()
2283}