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