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…
⎇ 02991dd9d85898c24f1cf52eb27bf7dfdf0f21f7
crates/hammer-cli/src/kernel_cmd.rs
History
Who touched what
raw
kernel: plan (receta derivada) y diff-back — dos hashes para el mismo código fuente
1//! `hammer kernel` — el armador de kernel (SDD 22).
2//!
3//! Superficie en inglés (Regla 7.bis), mensajes en castellano. El contrato con las UIs es JSON
4//! estable, mismo patrón que `/run/hammer/boot-graph.json`.
5
6use std::collections::{BTreeMap, BTreeSet};
7use std::path::{Path, PathBuf};
8
9use anyhow::{Context, Result};
10use clap::Subcommand;
11use hammer_core::kernel::kconfig::KconfigTree;
12use hammer_core::kernel::KernelConfig;
13use serde::Serialize;
14
15/// Dónde busca el árbol de Kconfig si no se pasa `--kconfig`.
16const KCONFIG_ENV: &str = "HAMMER_KCONFIG_ROOT";
17/// Ídem para el catálogo de bundles.
18const CATALOG_ENV: &str = "HAMMER_KERNEL_BUNDLES";
19/// Sitios donde buscarlo sin que nadie diga nada: el del repo y el del sistema instalado.
20const CATALOG_DEFAULTS: &[&str] = &[
21 "docs/state/kernel-bundles.toml",
22 "/usr/share/hammer/kernel-bundles.toml",
23];
24
25#[derive(Subcommand)]
26pub enum KernelCmd {
27 /// Analiza el grafo de Kconfig: qué muere si se apagan estos símbolos, y por dónde se cuela un
28 /// `select` de vuelta.
29 ///
30 /// Es la herramienta con la que se decide si un bundle «por clausura» es viable (SDD 22 §3).
31 /// No construye ni escribe nada.
32 Closure {
33 /// Símbolos raíz a apagar, SIN el prefijo `CONFIG_` (p.ej. `WIRELESS`).
34 #[arg(required = true)]
35 symbols: Vec<String>,
36 /// Raíz del árbol de fuentes del kernel (el directorio con el `Kconfig` de arriba).
37 #[arg(long)]
38 kconfig: Option<PathBuf>,
39 /// Contrasta la clausura contra los símbolos apagados en este `.config`.
40 #[arg(long)]
41 against: Option<PathBuf>,
42 /// Cierra la clausura contra sus propias fugas: apaga también a cada selector, y repite.
43 /// Muestra el PRECIO (qué hubo que tragarse de más) en vez de aplicarlo a ciegas.
44 #[arg(long)]
45 fixpoint: bool,
46 /// Emite JSON en vez del informe legible.
47 #[arg(long)]
48 json: bool,
49 /// Cuántos símbolos de la clausura listar en el informe legible.
50 #[arg(long, default_value_t = 20)]
51 limit: usize,
52 },
53 /// **Modo reversa**: lee el kernel que ya corre y lo muestra por el lente de los bundles.
54 ///
55 /// No compila ni escribe nada. Contesta tres cosas: cuánto de cada bundle ya rige, qué
56 /// capacidad carga este kernel que esta máquina no usa, y dónde el hardware CONTRADICE a un
57 /// bundle aplicado.
58 Probe {
59 /// `.config` a mirar. Por defecto `/proc/config.gz` y si no `/boot/config-<uname -r>`.
60 #[arg(long)]
61 config: Option<PathBuf>,
62 #[arg(long)]
63 kconfig: Option<PathBuf>,
64 #[arg(long)]
65 catalog: Option<PathBuf>,
66 /// No leer el hardware (útil para analizar el config de OTRA máquina).
67 #[arg(long)]
68 no_hardware: bool,
69 #[arg(long)]
70 json: bool,
71 },
72 /// Lista el catálogo con sus clausuras ya resueltas contra un árbol de Kconfig concreto.
73 ///
74 /// Es también el control de frescura: `--check` sale distinto de cero si algún bundle nombra
75 /// símbolos que ya no existen o dejó fugas `select` sin declarar.
76 Bundles {
77 #[arg(long)]
78 kconfig: Option<PathBuf>,
79 #[arg(long)]
80 catalog: Option<PathBuf>,
81 /// Falla si el catálogo envejeció respecto de este árbol.
82 #[arg(long)]
83 check: bool,
84 #[arg(long)]
85 json: bool,
86 },
87 /// De una selección de bundles a una **receta derivada**, sin construir nada.
88 ///
89 /// El config del kernel vive en la fase `configure` y las fases entran en `hash_inputs` ⇒ el
90 /// config ES la identidad del artefacto. Por eso esto emite una receta con su propio
91 /// `ArtifactHash`, y no finge que el kernel sea un binario parametrizable.
92 Plan {
93 /// Receta base (p.ej. `recipes/linux.toml`).
94 #[arg(long)]
95 recipe: PathBuf,
96 /// Id de bundle del catálogo. Repetible.
97 #[arg(long = "bundle")]
98 bundles: Vec<String>,
99 /// Id de perilla del catálogo. Repetible.
100 #[arg(long = "knob")]
101 knobs: Vec<String>,
102 #[arg(long)]
103 kconfig: Option<PathBuf>,
104 #[arg(long)]
105 catalog: Option<PathBuf>,
106 /// Escribe el plan en JSON (el contrato con las UIs).
107 #[arg(long)]
108 out: Option<PathBuf>,
109 /// Escribe la receta derivada. Para construirla hay que dejarla donde sus `deps.build`
110 /// resuelvan — es decir, junto a la receta base.
111 #[arg(long)]
112 recipe_out: Option<PathBuf>,
113 #[arg(long)]
114 json: bool,
115 },
116 /// **Diff-back**: contrasta un plan contra el `.config` que produjo el kernel.
117 ///
118 /// Sin esto la UI miente: un `-e FOO` cuya dependencia no se cumple se pierde en silencio.
119 DiffBack {
120 /// El JSON que emitió `hammer kernel plan --out`.
121 #[arg(long)]
122 plan: PathBuf,
123 /// El `.config` resultante (el que la receta instala en `/out/boot/config-<versión>`).
124 #[arg(long)]
125 config: PathBuf,
126 #[arg(long)]
127 json: bool,
128 },
129 /// Estadísticas del árbol de Kconfig parseado (control de salud del lector).
130 Stats {
131 #[arg(long)]
132 kconfig: Option<PathBuf>,
133 /// Muestra las primeras N advertencias del parseo.
134 #[arg(long, default_value_t = 10)]
135 warnings: usize,
136 },
137}
138
139pub fn run(cmd: KernelCmd, store: &str) -> Result<()> {
140 match cmd {
141 KernelCmd::Stats { kconfig, warnings } => stats(&kconfig_root(kconfig)?, warnings),
142 KernelCmd::Probe {
143 config,
144 kconfig,
145 catalog,
146 no_hardware,
147 json,
148 } => probe(
149 config.as_deref(),
150 &kconfig_root(kconfig)?,
151 &catalog_path(catalog)?,
152 !no_hardware,
153 json,
154 ),
155 KernelCmd::Bundles {
156 kconfig,
157 catalog,
158 check,
159 json,
160 } => bundles(&kconfig_root(kconfig)?, &catalog_path(catalog)?, check, json),
161 KernelCmd::Plan {
162 recipe,
163 bundles: bs,
164 knobs,
165 kconfig,
166 catalog,
167 out,
168 recipe_out,
169 json,
170 } => plan(
171 &recipe,
172 &bs,
173 &knobs,
174 &kconfig_root(kconfig)?,
175 &catalog_path(catalog)?,
176 out.as_deref(),
177 recipe_out.as_deref(),
178 store,
179 json,
180 ),
181 KernelCmd::DiffBack { plan, config, json } => diff_back(&plan, &config, json),
182 KernelCmd::Closure {
183 symbols,
184 kconfig,
185 against,
186 fixpoint,
187 json,
188 limit,
189 } => closure(
190 &symbols,
191 &kconfig_root(kconfig)?,
192 against.as_deref(),
193 fixpoint,
194 json,
195 limit,
196 ),
197 }
198}
199
200/// Resuelve la raíz del árbol de Kconfig: `--kconfig` gana, si no la variable de entorno.
201fn kconfig_root(flag: Option<PathBuf>) -> Result<PathBuf> {
202 if let Some(p) = flag {
203 return Ok(p);
204 }
205 match std::env::var_os(KCONFIG_ENV) {
206 Some(v) => Ok(PathBuf::from(v)),
207 None => anyhow::bail!(
208 "falta el árbol de fuentes del kernel: pasá --kconfig <dir> o exportá {KCONFIG_ENV}"
209 ),
210 }
211}
212
213/// Resuelve el catálogo de bundles: `--catalog`, si no la variable de entorno, si no el del repo.
214fn catalog_path(flag: Option<PathBuf>) -> Result<PathBuf> {
215 if let Some(p) = flag {
216 return Ok(p);
217 }
218 if let Some(v) = std::env::var_os(CATALOG_ENV) {
219 return Ok(PathBuf::from(v));
220 }
221 for c in CATALOG_DEFAULTS {
222 let p = PathBuf::from(c);
223 if p.is_file() {
224 return Ok(p);
225 }
226 }
227 anyhow::bail!(
228 "no encontré el catálogo de bundles: pasá --catalog <path> o exportá {CATALOG_ENV}"
229 )
230}
231
232/// Dónde está el `.config` del kernel que corre. `/proc/config.gz` es el camino soberano (lo emite
233/// el propio kernel); el de `/boot` es el respaldo para kernels compilados sin `IKCONFIG_PROC`.
234fn running_config_path() -> Result<PathBuf> {
235 let gz = PathBuf::from("/proc/config.gz");
236 if gz.is_file() {
237 return Ok(gz);
238 }
239 if let Ok(out) = std::process::Command::new("uname").arg("-r").output() {
240 let rel = String::from_utf8_lossy(&out.stdout).trim().to_string();
241 let p = PathBuf::from(format!("/boot/config-{rel}"));
242 if p.is_file() {
243 return Ok(p);
244 }
245 }
246 anyhow::bail!(
247 "no encontré el config del kernel vivo (ni /proc/config.gz ni /boot/config-<release>).\n\
248 Pasá --config <path> para mirar el de otra máquina."
249 )
250}
251
252fn load_tree(root: &Path) -> Result<KconfigTree> {
253 if !root.join("Kconfig").is_file() {
254 anyhow::bail!(
255 "{} no parece un árbol de fuentes de kernel (falta ./Kconfig).\n\
256 Pasá --kconfig <dir> o exportá {KCONFIG_ENV}.",
257 root.display()
258 );
259 }
260 KconfigTree::parse(root, &KconfigTree::vars_x86_64())
261 .with_context(|| format!("parseando {}", root.display()))
262}
263
264fn stats(root: &Path, show_warnings: usize) -> Result<()> {
265 let t = load_tree(root)?;
266 let visible = t.symbols.values().filter(|s| s.is_visible()).count();
267 let multi = t.symbols.values().filter(|s| s.defs.len() > 1).count();
268 let with_help = t.symbols.values().filter(|s| s.help().is_some()).count();
269 let selects: usize = t.symbols.values().map(|s| s.select_targets().len()).sum();
270 let implies: usize = t.symbols.values().map(|s| s.imply_targets().len()).sum();
271
272 println!("árbol {}", root.display());
273 println!("ficheros {}", t.files.len());
274 println!("símbolos {}", t.symbols.len());
275 println!(" visibles {visible} (marcables en menuconfig)");
276 println!(" con ayuda {with_help}");
277 println!(" definidos en >1 sitio {multi}");
278 println!("aristas select {selects} · imply {implies}");
279 println!("avisos {}", t.warnings.len());
280 for w in t.warnings.iter().take(show_warnings) {
281 println!(" · {w}");
282 }
283 if t.warnings.len() > show_warnings {
284 println!(" … y {} más", t.warnings.len() - show_warnings);
285 }
286 Ok(())
287}
288
289#[derive(Serialize)]
290struct ClosureReport {
291 roots: Vec<String>,
292 /// Todo lo que queda inalcanzable si se apagan las raíces (incluidas las raíces).
293 closure: Vec<String>,
294 /// De la clausura, los que un humano puede marcar en `menuconfig`.
295 visible: usize,
296 /// `select` que entran desde fuera: reencienden algo de la clausura ignorando sus `depends on`.
297 leaks: Vec<hammer_core::kernel::SelectLeak>,
298 /// Símbolos distintos de la clausura alcanzados por esas fugas.
299 leaked_targets: usize,
300 #[serde(skip_serializing_if = "Option::is_none")]
301 fixpoint: Option<FixpointReport>,
302 #[serde(skip_serializing_if = "Option::is_none")]
303 against: Option<AgainstReport>,
304}
305
306/// El precio de cerrar el bundle contra sus fugas.
307#[derive(Serialize)]
308struct FixpointReport {
309 /// La clausura ya cerrada, entera.
310 closure: Vec<String>,
311 /// Tamaño de la clausura una vez cerrada.
312 closure_len: usize,
313 /// Cuánto creció respecto de la clausura estricta.
314 grew_by: usize,
315 rounds: usize,
316 /// Lo que hubo que apagar de más, con la ronda en que apareció.
317 added: Vec<(usize, String)>,
318 /// Fugas que sobrevivieron al tope de rondas. Vacío = cerró.
319 remaining_leaks: Vec<hammer_core::kernel::SelectLeak>,
320}
321
322/// Contraste contra un `.config` real: la validación §3 del SDD 22.
323#[derive(Serialize)]
324struct AgainstReport {
325 config: String,
326 /// De la clausura, los que el `.config` tiene ENCENDIDOS (la clausura predice de más, o el
327 /// config tiene una fuga real).
328 on_in_config: Vec<String>,
329 /// De la clausura, los que el `.config` apaga explícitamente.
330 off_in_config: usize,
331 /// De la clausura, los que el `.config` ni menciona.
332 absent_in_config: usize,
333}
334
335fn closure(
336 roots: &[String],
337 kroot: &Path,
338 against: Option<&Path>,
339 fixpoint: bool,
340 json: bool,
341 limit: usize,
342) -> Result<()> {
343 let t = load_tree(kroot)?;
344
345 // Avisar de raíces que no existen: un símbolo mal escrito daría clausura vacía en silencio,
346 // que es exactamente el modo de fallo que este comando existe para no tener.
347 let desconocidas: Vec<&String> = roots.iter().filter(|r| !t.symbols.contains_key(*r)).collect();
348 if !desconocidas.is_empty() {
349 eprintln!(
350 "aviso: {} símbolo(s) no existen en este árbol: {}",
351 desconocidas.len(),
352 desconocidas
353 .iter()
354 .map(|s| s.as_str())
355 .collect::<Vec<_>>()
356 .join(", ")
357 );
358 }
359
360 let cl = t.closure_off(roots);
361 let leaks = t.select_leaks(&cl);
362 let leaked_targets = leaks
363 .iter()
364 .map(|l| l.target.as_str())
365 .collect::<BTreeSet<_>>()
366 .len();
367 let visible = cl
368 .iter()
369 .filter(|s| t.symbols.get(*s).is_some_and(|s| s.is_visible()))
370 .count();
371
372 let fx = fixpoint.then(|| {
373 let f = t.closure_off_fixpoint(roots, 16);
374 FixpointReport {
375 closure_len: f.closure.len(),
376 closure: f.closure.iter().cloned().collect(),
377 grew_by: f.closure.len().saturating_sub(cl.len()),
378 rounds: f.rounds,
379 added: f.added,
380 remaining_leaks: f.remaining_leaks,
381 }
382 });
383
384 let against_report = match against {
385 None => None,
386 Some(p) => {
387 let text = read_maybe_gz(p)?;
388 let cfg = KernelConfig::parse(&text);
389 let on: Vec<String> = cl.iter().filter(|s| cfg.is_on(s)).cloned().collect();
390 let off = cl
391 .iter()
392 .filter(|s| matches!(cfg.get(s), Some(hammer_core::kernel::ConfigValue::No)))
393 .count();
394 let absent = cl.iter().filter(|s| cfg.get(s).is_none()).count();
395 Some(AgainstReport {
396 config: p.display().to_string(),
397 on_in_config: on,
398 off_in_config: off,
399 absent_in_config: absent,
400 })
401 }
402 };
403
404 let rep = ClosureReport {
405 roots: roots.to_vec(),
406 closure: cl.iter().cloned().collect(),
407 visible,
408 leaks,
409 leaked_targets,
410 fixpoint: fx,
411 against: against_report,
412 };
413
414 if json {
415 println!("{}", serde_json::to_string_pretty(&rep)?);
416 return Ok(());
417 }
418
419 println!("raíces apagadas {}", rep.roots.join(" "));
420 println!(
421 "clausura {} símbolo(s) ({} visibles en menuconfig)",
422 rep.closure.len(),
423 rep.visible
424 );
425 for s in rep.closure.iter().take(limit) {
426 let prompt = t
427 .symbols
428 .get(s)
429 .and_then(|x| x.prompt())
430 .unwrap_or("(sin prompt: sólo entra por select)");
431 println!(" · {s} — {prompt}");
432 }
433 if rep.closure.len() > limit {
434 println!(" … y {} más", rep.closure.len() - limit);
435 }
436
437 println!();
438 if rep.leaks.is_empty() {
439 println!("fugas por `select` ninguna — la clausura es cerrada");
440 } else {
441 println!(
442 "fugas por `select` {} arista(s) desde fuera reencienden {} símbolo(s) de la clausura",
443 rep.leaks.len(),
444 rep.leaked_targets
445 );
446 println!(" (`select` FUERZA el destino ignorando sus `depends on`: apagar la raíz no basta)");
447 let mut por_destino: BTreeMap<&str, Vec<&str>> = BTreeMap::new();
448 for l in &rep.leaks {
449 por_destino
450 .entry(&l.target)
451 .or_default()
452 .push(&l.selector);
453 }
454 for (target, selectors) in por_destino.iter().take(limit) {
455 println!(" · {target} ← {}", selectors.join(", "));
456 }
457 if por_destino.len() > limit {
458 println!(" … y {} destino(s) más", por_destino.len() - limit);
459 }
460 }
461
462 if let Some(f) = &rep.fixpoint {
463 println!();
464 println!(
465 "punto fijo {} símbolo(s) tras {} ronda(s) — {:+} sobre la clausura estricta",
466 f.closure_len, f.rounds, f.grew_by as i64
467 );
468 if f.remaining_leaks.is_empty() {
469 println!(" cerró: ninguna fuga sobrevive");
470 } else {
471 println!(
472 " ⚠ NO cerró: {} fuga(s) siguen abiertas tras el tope de rondas",
473 f.remaining_leaks.len()
474 );
475 }
476 if f.added.is_empty() {
477 println!(" no hizo falta apagar nada de más");
478 } else {
479 println!(" precio: {} símbolo(s) apagados de más", f.added.len());
480 for (r, s) in f.added.iter().take(limit) {
481 println!(" · ronda {r}: {s}");
482 }
483 if f.added.len() > limit {
484 println!(" … y {} más", f.added.len() - limit);
485 }
486 }
487 }
488
489 if let Some(a) = &rep.against {
490 println!();
491 println!("contraste contra {}", a.config);
492 println!(" apagados explícitos {}", a.off_in_config);
493 println!(" ausentes {}", a.absent_in_config);
494 println!(" ENCENDIDOS {}", a.on_in_config.len());
495 for s in a.on_in_config.iter().take(limit) {
496 println!(" · {s}");
497 }
498 if a.on_in_config.len() > limit {
499 println!(" … y {} más", a.on_in_config.len() - limit);
500 }
501 }
502 Ok(())
503}
504
505// ---------------------------------------------------------------------------------------------
506// probe — el modo reversa (#9)
507// ---------------------------------------------------------------------------------------------
508
509fn probe(
510 config: Option<&Path>,
511 kroot: &Path,
512 catalog: &Path,
513 with_hw: bool,
514 json: bool,
515) -> Result<()> {
516 use hammer_core::kernel::reverse::{BundleState, HwSignal};
517
518 let cfg_path = match config {
519 Some(p) => p.to_path_buf(),
520 None => running_config_path()?,
521 };
522 let cfg = KernelConfig::parse(&read_maybe_gz(&cfg_path)?);
523 if cfg.is_empty() {
524 anyhow::bail!("{} no tiene un solo CONFIG_*: ¿es un .config?", cfg_path.display());
525 }
526 let tree = load_tree(kroot)?;
527 let cat = hammer_core::kernel::Catalog::load(catalog)?;
528 let hw = with_hw.then(hammer_core::kernel::Hardware::probe_local);
529 let rep = hammer_core::kernel::reverse::analyze(&cat, &tree, &cfg, hw.as_ref());
530
531 if json {
532 println!("{}", serde_json::to_string_pretty(&rep)?);
533 return Ok(());
534 }
535
536 println!("config {}", cfg_path.display());
537 if let Some(k) = &rep.kernel {
538 println!("kernel {k}");
539 }
540 // El modo reversa cruza DOS árboles: el del config vivo y el que se parseó. Si no son la misma
541 // versión, las clausuras son aproximadas — un símbolo puede haberse renombrado o retirado entre
542 // medias (que es justo lo que le pasó a THUNDERBOLT y a REISERFS_FS). Vale igual, pero decirlo.
543 if let (Some(banner), Some(rev)) = (&rep.kernel, &cat.reviewed_against) {
544 if let (Some(a), Some(b)) = (serie(banner), serie(rev)) {
545 if a != b {
546 println!(
547 " ⚠ el config vivo es de la serie {a} y el catálogo se revisó contra \
548 la {b}: las clausuras son aproximadas"
549 );
550 }
551 }
552 }
553 println!("símbolos {} declarados", rep.config_symbols);
554 if let Some(h) = &hw {
555 println!("huella {}", rep.fingerprint);
556 if let Some(m) = &h.cpu_model {
557 println!(" {m}");
558 }
559 let maquina = [h.dmi.get("sys_vendor"), h.dmi.get("product_name")]
560 .into_iter()
561 .flatten()
562 .cloned()
563 .collect::<Vec<_>>()
564 .join(" ");
565 if !maquina.is_empty() {
566 println!(" {maquina}");
567 }
568 println!(
569 " {} dispositivos PCI · {} drivers bindeados",
570 h.pci.len(),
571 h.bound_drivers.len()
572 );
573 for g in &h.gaps {
574 println!(" ⚠ {g}");
575 }
576 }
577 println!();
578
579 for b in &rep.bundles {
580 let on = b.on_builtin + b.on_module;
581 let estado = match b.state {
582 BundleState::Aplicado => "APLICADO ",
583 BundleState::Parcial => "parcial ",
584 BundleState::Sin => "sin ",
585 };
586 let hwtxt = match &b.hardware {
587 HwSignal::SinSeñal => String::new(),
588 HwSignal::Ausente => " · hardware ausente".to_string(),
589 HwSignal::Presente { evidencia } => {
590 format!(" · ⚠ HARDWARE PRESENTE ({})", evidencia.len())
591 }
592 };
593 println!(
594 "{estado} {:<28} {on:>5}/{:<5} encendidos{hwtxt}",
595 b.id, b.closure_len
596 );
597 if let HwSignal::Presente { evidencia } = &b.hardware {
598 for e in evidencia.iter().take(4) {
599 println!(" {e}");
600 }
601 }
602 if !b.unknown_symbols.is_empty() {
603 println!(
604 " ⚠ símbolos que este kernel no tiene: {}",
605 b.unknown_symbols.join(", ")
606 );
607 }
608 if !b.undeclared_leaks.is_empty() {
609 println!(
610 " ⚠ {} fuga(s) `select` sin declarar en el catálogo",
611 b.undeclared_leaks.len()
612 );
613 }
614 }
615
616 println!();
617 println!(
618 "resumen {} bundle(s): {} ya aplicados, {} con capacidad que este hardware NO usa",
619 rep.bundles.len(),
620 rep.bundles
621 .iter()
622 .filter(|b| b.state == BundleState::Aplicado)
623 .count(),
624 rep.applicable_savings
625 );
626 if rep.contradictions > 0 {
627 println!(
628 " ⚠ {} bundle(s) aplicados CONTRADICEN al hardware presente",
629 rep.contradictions
630 );
631 }
632 println!(" (detectar sirve para contradecir, nunca para podar solo)");
633 Ok(())
634}
635
636/// Serie `mayor.menor` de un texto que contenga una versión (`"Linux/x86 7.1.4-artix1 …"` → `7.1`,
637/// `"linux-6.16.12"` → `6.16`). `None` si no hay ninguna.
638fn serie(texto: &str) -> Option<String> {
639 for tok in texto.split(|c: char| !(c.is_ascii_digit() || c == '.')) {
640 let mut it = tok.split('.');
641 if let (Some(a), Some(b)) = (it.next(), it.next()) {
642 if !a.is_empty() && !b.is_empty() {
643 return Some(format!("{a}.{b}"));
644 }
645 }
646 }
647 None
648}
649
650// ---------------------------------------------------------------------------------------------
651// bundles — catálogo + control de frescura
652// ---------------------------------------------------------------------------------------------
653
654#[derive(Serialize)]
655struct BundleView {
656 id: String,
657 title: String,
658 #[serde(skip_serializing_if = "Option::is_none")]
659 help: Option<String>,
660 disable: Vec<String>,
661 close_leaks: Vec<String>,
662 closure_len: usize,
663 visible: usize,
664 unknown_symbols: Vec<String>,
665 undeclared_leaks: Vec<hammer_core::kernel::SelectLeak>,
666 accepted_leaks: usize,
667}
668
669#[derive(Serialize)]
670struct BundlesReport {
671 catalog: String,
672 #[serde(skip_serializing_if = "Option::is_none")]
673 reviewed_against: Option<String>,
674 bundles: Vec<BundleView>,
675 knobs: Vec<KnobView>,
676 stale: bool,
677}
678
679#[derive(Serialize)]
680struct KnobView {
681 id: String,
682 title: String,
683 side: hammer_core::kernel::Side,
684 enable: Vec<String>,
685 disable: Vec<String>,
686 #[serde(skip_serializing_if = "Option::is_none")]
687 recipe_field: Option<String>,
688 unknown_symbols: Vec<String>,
689}
690
691fn bundles(kroot: &Path, catalog: &Path, check: bool, json: bool) -> Result<()> {
692 let tree = load_tree(kroot)?;
693 let cat = hammer_core::kernel::Catalog::load(catalog)?;
694
695 let mut views = Vec::new();
696 for b in &cat.bundles {
697 let mut roots = b.disable.clone();
698 roots.extend(b.close_leaks.iter().cloned());
699 let unknown: Vec<String> = roots
700 .iter()
701 .chain(b.enable.iter())
702 .filter(|s| !tree.symbols.contains_key(*s))
703 .cloned()
704 .collect();
705 let cl = tree.closure_off(&roots);
706 let aceptadas: BTreeSet<&str> = b.accept_leaks.keys().map(|s| s.as_str()).collect();
707 let undeclared: Vec<_> = tree
708 .select_leaks(&cl)
709 .into_iter()
710 .filter(|l| !aceptadas.contains(l.target.as_str()))
711 .collect();
712 views.push(BundleView {
713 id: b.id.clone(),
714 title: b.title.clone(),
715 help: b.help.clone(),
716 disable: b.disable.clone(),
717 close_leaks: b.close_leaks.clone(),
718 closure_len: cl.len(),
719 visible: cl
720 .iter()
721 .filter(|s| tree.symbols.get(*s).is_some_and(|x| x.is_visible()))
722 .count(),
723 unknown_symbols: unknown,
724 undeclared_leaks: undeclared,
725 accepted_leaks: b.accept_leaks.len(),
726 });
727 }
728
729 let knobs: Vec<KnobView> = cat
730 .knobs
731 .iter()
732 .map(|k| KnobView {
733 id: k.id.clone(),
734 title: k.title.clone(),
735 side: k.side,
736 enable: k.enable.clone(),
737 disable: k.disable.clone(),
738 recipe_field: k.recipe_field.clone(),
739 unknown_symbols: k
740 .enable
741 .iter()
742 .chain(k.disable.iter())
743 .filter(|s| !tree.symbols.contains_key(*s))
744 .cloned()
745 .collect(),
746 })
747 .collect();
748
749 let stale = views
750 .iter()
751 .any(|v| !v.unknown_symbols.is_empty() || !v.undeclared_leaks.is_empty())
752 || knobs.iter().any(|k| !k.unknown_symbols.is_empty());
753
754 let rep = BundlesReport {
755 catalog: catalog.display().to_string(),
756 reviewed_against: cat.reviewed_against.clone(),
757 bundles: views,
758 knobs,
759 stale,
760 };
761
762 if json {
763 println!("{}", serde_json::to_string_pretty(&rep)?);
764 } else {
765 println!("catálogo {}", rep.catalog);
766 if let Some(r) = &rep.reviewed_against {
767 println!("revisado contra {r}");
768 }
769 println!("árbol {}", kroot.display());
770 println!();
771 println!("NIVEL 1 — aserciones del dueño de la máquina");
772 for v in &rep.bundles {
773 println!(
774 " {:<26} {:>5} símbolos ({} visibles) ← {}",
775 v.id,
776 v.closure_len,
777 v.visible,
778 v.disable.join(" ")
779 );
780 println!(" {}", v.title);
781 if !v.close_leaks.is_empty() {
782 println!(" fugas cerradas: {}", v.close_leaks.join(", "));
783 }
784 if v.accepted_leaks > 0 {
785 println!(" fugas aceptadas a sabiendas: {}", v.accepted_leaks);
786 }
787 if !v.unknown_symbols.is_empty() {
788 println!(
789 " ⚠ NO EXISTEN en este árbol: {}",
790 v.unknown_symbols.join(", ")
791 );
792 }
793 for l in v.undeclared_leaks.iter().take(6) {
794 println!(" ⚠ fuga sin declarar: {} ← {}", l.target, l.selector);
795 }
796 if v.undeclared_leaks.len() > 6 {
797 println!(" ⚠ … y {} fuga(s) más", v.undeclared_leaks.len() - 6);
798 }
799 }
800 println!();
801 println!("NIVEL 2 — política y rendimiento (ojo al lado del que cae cada perilla)");
802 for k in &rep.knobs {
803 let lado = match k.side {
804 hammer_core::kernel::Side::Kconfig => "kconfig",
805 hammer_core::kernel::Side::Recipe => "receta ",
806 };
807 let que = match k.side {
808 hammer_core::kernel::Side::Kconfig => {
809 let mut v: Vec<String> =
810 k.enable.iter().map(|s| format!("+{s}")).collect();
811 v.extend(k.disable.iter().map(|s| format!("-{s}")));
812 v.join(" ")
813 }
814 hammer_core::kernel::Side::Recipe => {
815 k.recipe_field.clone().unwrap_or_default()
816 }
817 };
818 println!(" [{lado}] {:<26} {que}", k.id);
819 println!(" {}", k.title);
820 if !k.unknown_symbols.is_empty() {
821 println!(
822 " ⚠ NO EXISTEN en este árbol: {}",
823 k.unknown_symbols.join(", ")
824 );
825 }
826 }
827 println!();
828 if rep.stale {
829 println!("⚠ el catálogo ENVEJECIÓ respecto de este árbol (símbolos idos o fugas nuevas)");
830 } else {
831 println!("✓ el catálogo está al día con este árbol");
832 }
833 }
834
835 if check && rep.stale {
836 anyhow::bail!("catálogo desactualizado");
837 }
838 Ok(())
839}
840
841// ---------------------------------------------------------------------------------------------
842// plan / diff-back
843// ---------------------------------------------------------------------------------------------
844
845#[allow(clippy::too_many_arguments)]
846fn plan(
847 recipe_path: &Path,
848 bundle_ids: &[String],
849 knob_ids: &[String],
850 kroot: &Path,
851 catalog: &Path,
852 out: Option<&Path>,
853 recipe_out: Option<&Path>,
854 store: &str,
855 json: bool,
856) -> Result<()> {
857 if bundle_ids.is_empty() && knob_ids.is_empty() {
858 anyhow::bail!("un plan sin bundles ni perillas es la receta base: pasá --bundle/--knob");
859 }
860 let base = hammer_core::Recipe::load_from_path(recipe_path)?;
861 let tree = load_tree(kroot)?;
862 let cat = hammer_core::kernel::Catalog::load(catalog)?;
863
864 let mut p = hammer_core::kernel::plan::make_plan(
865 &base,
866 &recipe_path.display().to_string(),
867 &cat,
868 &tree,
869 bundle_ids,
870 knob_ids,
871 )?;
872
873 // El plan DETERMINA el artefacto (SDD 22 §1), así que el hash va en el plan: la UI puede decir
874 // «esto ya está construido y firmado» sin construir nada. Si las deps no están resueltas, se
875 // dice que no se pudo en vez de emitir un hash inventado.
876 let derived = hammer_core::kernel::plan::derive_recipe(&base, &p);
877 match hammer_core::Store::open(Path::new(store))
878 .map_err(anyhow::Error::from)
879 .and_then(|s| hammer_build::artifact_hash(&derived, &s).map_err(anyhow::Error::from))
880 {
881 Ok(h) => p.artifact_hash = Some(h.to_string()),
882 Err(e) => eprintln!("aviso: no pude calcular el ArtifactHash ({e})"),
883 }
884
885 if let Some(o) = out {
886 if let Some(d) = o.parent() {
887 std::fs::create_dir_all(d).ok();
888 }
889 std::fs::write(o, serde_json::to_string_pretty(&p)?)
890 .with_context(|| format!("escribiendo {}", o.display()))?;
891 }
892 if let Some(ro) = recipe_out {
893 if let Some(d) = ro.parent() {
894 std::fs::create_dir_all(d).ok();
895 }
896 let cabecera = format!(
897 "# Receta DERIVADA, generada por `hammer kernel plan`. No editar a mano: regenerar.\n\
898 # base {}\n# bundles {}\n# perillas {}\n\
899 # Para construirla tiene que vivir junto a la receta base, o sus `deps.build` no\n\
900 # resuelven ({}).\n",
901 p.base_recipe,
902 if p.bundles.is_empty() { "—".into() } else { p.bundles.join(" ") },
903 if p.knobs.is_empty() { "—".into() } else { p.knobs.join(" ") },
904 recipe_path
905 .parent()
906 .map(|d| d.display().to_string())
907 .unwrap_or_default(),
908 );
909 let cuerpo = hammer_core::kernel::plan::render_recipe_toml(&derived)?;
910 std::fs::write(ro, format!("{cabecera}{cuerpo}"))
911 .with_context(|| format!("escribiendo {}", ro.display()))?;
912 }
913
914 if json {
915 println!("{}", serde_json::to_string_pretty(&p)?);
916 return Ok(());
917 }
918
919 println!("base {} ({})", p.base_name, p.base_recipe);
920 println!("derivada {}", p.derived_name);
921 match &p.artifact_hash {
922 Some(h) => println!("hash {h}"),
923 None => println!("hash (no calculado)"),
924 }
925 println!(
926 "selección {} bundle(s), {} perilla(s)",
927 p.bundles.len(),
928 p.knobs.len()
929 );
930 println!();
931 println!("emite {} bandera(s); con su clausura caen {} símbolo(s)", p.symbols.len(), p.closure_size);
932 println!("(se emiten RAÍCES: la clausura la calcula el olddefconfig del propio kernel)");
933 for s in &p.symbols {
934 let signo = match s.action {
935 hammer_core::kernel::plan::Action::Enable => "+",
936 hammer_core::kernel::plan::Action::Disable => "-",
937 };
938 println!(" {signo}{:<24} ← {} ({})", s.symbol, s.from, s.reason);
939 }
940 if !p.unknown_symbols.is_empty() {
941 println!();
942 println!("⚠ {} símbolo(s) pedidos NO EXISTEN en este árbol y no se emiten:", p.unknown_symbols.len());
943 for s in &p.unknown_symbols {
944 println!(" {} ← {}", s.symbol, s.from);
945 }
946 }
947 if !p.recipe_side.is_empty() {
948 println!();
949 println!("perillas del lado RECETA (declaradas, este plan no las aplica):");
950 for k in &p.recipe_side {
951 println!(" {:<24} {} — {}", k.id, k.recipe_field, k.title);
952 }
953 }
954 println!();
955 if let Some(o) = out {
956 println!("plan → {}", o.display());
957 }
958 if let Some(ro) = recipe_out {
959 println!("receta → {}", ro.display());
960 }
961 Ok(())
962}
963
964fn diff_back(plan_path: &Path, config: &Path, json: bool) -> Result<()> {
965 let p: hammer_core::kernel::Plan = serde_json::from_str(
966 &std::fs::read_to_string(plan_path)
967 .with_context(|| format!("leyendo {}", plan_path.display()))?,
968 )
969 .with_context(|| format!("{} no es un plan de `hammer kernel plan`", plan_path.display()))?;
970 let cfg = KernelConfig::parse(&read_maybe_gz(config)?);
971 if cfg.is_empty() {
972 anyhow::bail!("{} no tiene un solo CONFIG_*: ¿es un .config?", config.display());
973 }
974 let d = hammer_core::kernel::diff_back(&p, &cfg);
975
976 if json {
977 println!("{}", serde_json::to_string_pretty(&d)?);
978 } else {
979 println!("plan {} ({})", p.derived_name, plan_path.display());
980 println!("config {}", config.display());
981 println!("cumplidos {}", d.honored.len());
982 println!("ausentes {}", d.absent.len());
983 for s in &d.absent {
984 println!(" {s} — el kernel ni lo menciona: la bandera fue un no-op");
985 }
986 println!("INCUMPLIDOS {}", d.violated.len());
987 for v in &d.violated {
988 println!(
989 " {} — se pidió {:?} y quedó {} (lo pidió {})",
990 v.symbol, v.asked, v.got, v.from
991 );
992 }
993 }
994 if !d.is_clean() {
995 anyhow::bail!("el .config no honra el plan");
996 }
997 Ok(())
998}
999
1000/// Lee un `.config`, descomprimiendo si es `.gz` (el caso de `/proc/config.gz`).
1001///
1002/// Se delega a `gzip -dc` en vez de arrastrar un descompresor al core: es una entrada de
1003/// diagnóstico, no del camino de build, y el core queda sin dependencias nuevas.
1004pub fn read_maybe_gz(p: &Path) -> Result<String> {
1005 if p.extension().is_some_and(|e| e == "gz") {
1006 let out = std::process::Command::new("gzip")
1007 .arg("-dc")
1008 .arg(p)
1009 .output()
1010 .with_context(|| format!("descomprimiendo {}", p.display()))?;
1011 if !out.status.success() {
1012 anyhow::bail!(
1013 "gzip -dc {} falló: {}",
1014 p.display(),
1015 String::from_utf8_lossy(&out.stderr).trim()
1016 );
1017 }
1018 return Ok(String::from_utf8_lossy(&out.stdout).into_owned());
1019 }
1020 std::fs::read_to_string(p).with_context(|| format!("leyendo {}", p.display()))
1021}
1022
1023#[cfg(test)]
1024mod tests {
1025 use super::*;
1026
1027 #[test]
1028 fn serie_de_las_dos_formas_que_se_cruzan() {
1029 assert_eq!(serie("Linux/x86 7.1.4-artix1 Kernel Configuration").as_deref(), Some("7.1"));
1030 assert_eq!(serie("linux-6.16.12").as_deref(), Some("6.16"));
1031 assert_eq!(serie("sin versión"), None);
1032 }
1033}