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