kernel: plan (receta derivada) y diff-back — dos hashes para el mismo código fuente

Paso 4 del §8 del SDD 22, y el corazón del armador.

hammer kernel plan --recipe recipes/linux.toml --bundle sin-wifi --bundle sin-audio
--bundle solo-ext4 --knob jaula-y-eio-moderna
  base      b3:cb926743…
  derivada  b3:47a52b2e…   (16 banderas, 1775 símbolos con su clausura)

El config vive en la fase `configure` y las fases entran en hash_inputs ⇒ el config ES la
identidad del artefacto. Por eso el plan emite una RECETA DERIVADA y no finge que el
kernel sea un binario parametrizable. Y como el plan DETERMINA el artefacto, el JSON lleva
su ArtifactHash: la UI puede decir "esto ya está construido y firmado" sin construir nada.

Tres decisiones que no eran obvias:
  · Se emiten RAÍCES, no clausuras: 16 banderas, no 1775 líneas. La clausura la calcula el
    olddefconfig del propio kernel. hammer la sabe sólo para poder explicarla — la app
    nunca escribe un .config.
  · La fase derivada AÑADE una segunda ronda (…&& scripts/config … && make olddefconfig)
    en vez de reescribir la base: no hay que parsear el shell de nadie, olddefconfig es
    idempotente, y la base sigue siendo literalmente la de siempre en el diff.
  · Los conflictos se RECHAZAN, no se ordenan. Resolver por orden de aparición sería una
    respuesta plausible y arbitraria. Y hay un segundo conflicto que el símbolo solo no
    delata: encender algo que cae DENTRO de la clausura de lo que otro bundle apaga —
    olddefconfig lo descartaría sin decir nada.

diff-back: la mitad que faltaba del §6 del handoff. Clasifica cada símbolo pedido en
cumplido / INCUMPLIDO (el .config dice otra cosa) / ausente (el kernel ni lo menciona: la
bandera fue un no-op), con la procedencia de quién lo pidió, y sale != 0 si el config no
honra el plan. Probado contra /proc/config.gz de gioser: 3 incumplidos, 1 ausente.

La procedencia por símbolo (#7 del handoff) sale de regalo: cada bandera carga quién la
pidió y por qué (raíz del bundle / fuga select cerrada / perilla).

Y el orden del fragmento es estable a propósito: ese texto entra al hash, así que un orden
que dependiera del recorrido daría dos hashes para el mismo plan.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
Sergio
2026-08-10 13:19:17 +00:00
co-authored by Claude Opus 5
parent cf2cf54914
commit 02991dd9d8
5 changed files with 821 additions and 2 deletions
+223 -1
View File
@@ -84,6 +84,48 @@ pub enum KernelCmd {
#[arg(long)]
json: bool,
},
/// De una selección de bundles a una **receta derivada**, sin construir nada.
///
/// El config del kernel vive en la fase `configure` y las fases entran en `hash_inputs` ⇒ el
/// config ES la identidad del artefacto. Por eso esto emite una receta con su propio
/// `ArtifactHash`, y no finge que el kernel sea un binario parametrizable.
Plan {
/// Receta base (p.ej. `recipes/linux.toml`).
#[arg(long)]
recipe: PathBuf,
/// Id de bundle del catálogo. Repetible.
#[arg(long = "bundle")]
bundles: Vec<String>,
/// Id de perilla del catálogo. Repetible.
#[arg(long = "knob")]
knobs: Vec<String>,
#[arg(long)]
kconfig: Option<PathBuf>,
#[arg(long)]
catalog: Option<PathBuf>,
/// Escribe el plan en JSON (el contrato con las UIs).
#[arg(long)]
out: Option<PathBuf>,
/// Escribe la receta derivada. Para construirla hay que dejarla donde sus `deps.build`
/// resuelvan — es decir, junto a la receta base.
#[arg(long)]
recipe_out: Option<PathBuf>,
#[arg(long)]
json: bool,
},
/// **Diff-back**: contrasta un plan contra el `.config` que produjo el kernel.
///
/// Sin esto la UI miente: un `-e FOO` cuya dependencia no se cumple se pierde en silencio.
DiffBack {
/// El JSON que emitió `hammer kernel plan --out`.
#[arg(long)]
plan: PathBuf,
/// El `.config` resultante (el que la receta instala en `/out/boot/config-<versión>`).
#[arg(long)]
config: PathBuf,
#[arg(long)]
json: bool,
},
/// Estadísticas del árbol de Kconfig parseado (control de salud del lector).
Stats {
#[arg(long)]
@@ -94,7 +136,7 @@ pub enum KernelCmd {
},
}
pub fn run(cmd: KernelCmd) -> Result<()> {
pub fn run(cmd: KernelCmd, store: &str) -> Result<()> {
match cmd {
KernelCmd::Stats { kconfig, warnings } => stats(&kconfig_root(kconfig)?, warnings),
KernelCmd::Probe {
@@ -116,6 +158,27 @@ pub fn run(cmd: KernelCmd) -> Result<()> {
check,
json,
} => bundles(&kconfig_root(kconfig)?, &catalog_path(catalog)?, check, json),
KernelCmd::Plan {
recipe,
bundles: bs,
knobs,
kconfig,
catalog,
out,
recipe_out,
json,
} => plan(
&recipe,
&bs,
&knobs,
&kconfig_root(kconfig)?,
&catalog_path(catalog)?,
out.as_deref(),
recipe_out.as_deref(),
store,
json,
),
KernelCmd::DiffBack { plan, config, json } => diff_back(&plan, &config, json),
KernelCmd::Closure {
symbols,
kconfig,
@@ -775,6 +838,165 @@ fn bundles(kroot: &Path, catalog: &Path, check: bool, json: bool) -> Result<()>
Ok(())
}
// ---------------------------------------------------------------------------------------------
// plan / diff-back
// ---------------------------------------------------------------------------------------------
#[allow(clippy::too_many_arguments)]
fn plan(
recipe_path: &Path,
bundle_ids: &[String],
knob_ids: &[String],
kroot: &Path,
catalog: &Path,
out: Option<&Path>,
recipe_out: Option<&Path>,
store: &str,
json: bool,
) -> Result<()> {
if bundle_ids.is_empty() && knob_ids.is_empty() {
anyhow::bail!("un plan sin bundles ni perillas es la receta base: pasá --bundle/--knob");
}
let base = hammer_core::Recipe::load_from_path(recipe_path)?;
let tree = load_tree(kroot)?;
let cat = hammer_core::kernel::Catalog::load(catalog)?;
let mut p = hammer_core::kernel::plan::make_plan(
&base,
&recipe_path.display().to_string(),
&cat,
&tree,
bundle_ids,
knob_ids,
)?;
// El plan DETERMINA el artefacto (SDD 22 §1), así que el hash va en el plan: la UI puede decir
// «esto ya está construido y firmado» sin construir nada. Si las deps no están resueltas, se
// dice que no se pudo en vez de emitir un hash inventado.
let derived = hammer_core::kernel::plan::derive_recipe(&base, &p);
match hammer_core::Store::open(Path::new(store))
.map_err(anyhow::Error::from)
.and_then(|s| hammer_build::artifact_hash(&derived, &s).map_err(anyhow::Error::from))
{
Ok(h) => p.artifact_hash = Some(h.to_string()),
Err(e) => eprintln!("aviso: no pude calcular el ArtifactHash ({e})"),
}
if let Some(o) = out {
if let Some(d) = o.parent() {
std::fs::create_dir_all(d).ok();
}
std::fs::write(o, serde_json::to_string_pretty(&p)?)
.with_context(|| format!("escribiendo {}", o.display()))?;
}
if let Some(ro) = recipe_out {
if let Some(d) = ro.parent() {
std::fs::create_dir_all(d).ok();
}
let cabecera = format!(
"# Receta DERIVADA, generada por `hammer kernel plan`. No editar a mano: regenerar.\n\
# base {}\n# bundles {}\n# perillas {}\n\
# Para construirla tiene que vivir junto a la receta base, o sus `deps.build` no\n\
# resuelven ({}).\n",
p.base_recipe,
if p.bundles.is_empty() { "".into() } else { p.bundles.join(" ") },
if p.knobs.is_empty() { "".into() } else { p.knobs.join(" ") },
recipe_path
.parent()
.map(|d| d.display().to_string())
.unwrap_or_default(),
);
let cuerpo = hammer_core::kernel::plan::render_recipe_toml(&derived)?;
std::fs::write(ro, format!("{cabecera}{cuerpo}"))
.with_context(|| format!("escribiendo {}", ro.display()))?;
}
if json {
println!("{}", serde_json::to_string_pretty(&p)?);
return Ok(());
}
println!("base {} ({})", p.base_name, p.base_recipe);
println!("derivada {}", p.derived_name);
match &p.artifact_hash {
Some(h) => println!("hash {h}"),
None => println!("hash (no calculado)"),
}
println!(
"selección {} bundle(s), {} perilla(s)",
p.bundles.len(),
p.knobs.len()
);
println!();
println!("emite {} bandera(s); con su clausura caen {} símbolo(s)", p.symbols.len(), p.closure_size);
println!("(se emiten RAÍCES: la clausura la calcula el olddefconfig del propio kernel)");
for s in &p.symbols {
let signo = match s.action {
hammer_core::kernel::plan::Action::Enable => "+",
hammer_core::kernel::plan::Action::Disable => "-",
};
println!(" {signo}{:<24}{} ({})", s.symbol, s.from, s.reason);
}
if !p.unknown_symbols.is_empty() {
println!();
println!("{} símbolo(s) pedidos NO EXISTEN en este árbol y no se emiten:", p.unknown_symbols.len());
for s in &p.unknown_symbols {
println!(" {}{}", s.symbol, s.from);
}
}
if !p.recipe_side.is_empty() {
println!();
println!("perillas del lado RECETA (declaradas, este plan no las aplica):");
for k in &p.recipe_side {
println!(" {:<24} {}{}", k.id, k.recipe_field, k.title);
}
}
println!();
if let Some(o) = out {
println!("plan → {}", o.display());
}
if let Some(ro) = recipe_out {
println!("receta → {}", ro.display());
}
Ok(())
}
fn diff_back(plan_path: &Path, config: &Path, json: bool) -> Result<()> {
let p: hammer_core::kernel::Plan = serde_json::from_str(
&std::fs::read_to_string(plan_path)
.with_context(|| format!("leyendo {}", plan_path.display()))?,
)
.with_context(|| format!("{} no es un plan de `hammer kernel plan`", plan_path.display()))?;
let cfg = KernelConfig::parse(&read_maybe_gz(config)?);
if cfg.is_empty() {
anyhow::bail!("{} no tiene un solo CONFIG_*: ¿es un .config?", config.display());
}
let d = hammer_core::kernel::diff_back(&p, &cfg);
if json {
println!("{}", serde_json::to_string_pretty(&d)?);
} else {
println!("plan {} ({})", p.derived_name, plan_path.display());
println!("config {}", config.display());
println!("cumplidos {}", d.honored.len());
println!("ausentes {}", d.absent.len());
for s in &d.absent {
println!(" {s} — el kernel ni lo menciona: la bandera fue un no-op");
}
println!("INCUMPLIDOS {}", d.violated.len());
for v in &d.violated {
println!(
" {} — se pidió {:?} y quedó {} (lo pidió {})",
v.symbol, v.asked, v.got, v.from
);
}
}
if !d.is_clean() {
anyhow::bail!("el .config no honra el plan");
}
Ok(())
}
/// Lee un `.config`, descomprimiendo si es `.gz` (el caso de `/proc/config.gz`).
///
/// Se delega a `gzip -dc` en vez de arrastrar un descompresor al core: es una entrada de
+1 -1
View File
@@ -1422,7 +1422,7 @@ fn main() -> anyhow::Result<()> {
}
}
}
Cmd::Kernel { sub } => kernel_cmd::run(sub)?,
Cmd::Kernel { sub } => kernel_cmd::run(sub, &cli.store)?,
}
Ok(())
}
+2
View File
@@ -20,10 +20,12 @@ pub mod catalog;
pub mod config;
pub mod hw;
pub mod kconfig;
pub mod plan;
pub mod reverse;
pub use catalog::{Bundle, Catalog, Knob, Side};
pub use config::{ConfigValue, KernelConfig};
pub use hw::Hardware;
pub use kconfig::{Expr, KconfigTree, SelectLeak, SymKind, Symbol};
pub use plan::{diff_back, make_plan, DiffBack, Plan};
pub use reverse::{BundleState, HwSignal, ReverseReport};
+558
View File
@@ -0,0 +1,558 @@
//! `plan` — de una selección de bundles a una **receta derivada**.
//!
//! ## Por qué una receta y no un parámetro
//! Las fases de build entran en `Recipe::hash_inputs` y el config del kernel vive en la fase
//! `configure` ⇒ **el config ES la identidad del artefacto** (SDD 22 §1). No hay forma de tener un
//! config variable sin generar recetas: una «perilla» de la UI no es un parámetro de runtime, es
//! una edición de receta. Fingir lo contrario daría un store que dice que todo está al día mientras
//! los artefactos ya no corresponden — en un sistema direccionado por contenido, el peor fallo
//! posible no es fallar, es **mentir**.
//!
//! De ahí sale gratis lo que §7 del handoff pedía: el plan **determina** el artefacto, así que el
//! JSON lleva su `ArtifactHash` y la UI puede decir «esto ya está construido y firmado» sin
//! construir nada.
//!
//! ## Qué se emite y qué no
//! Se emiten **raíces**, no clausuras. `-d WIRELESS -d WLAN` son dos banderas; los ~400 símbolos
//! que caen con ellas los calcula el `olddefconfig` **del propio kernel**. hammer sabe cuáles son
//! (para poder explicarlos), pero no los escribe: la app nunca escribe un `.config`.
//!
//! ## Procedencia por símbolo
//! Cada símbolo emitido carga quién lo pidió ([`PlannedSymbol::from`]). Es el #7 del handoff, y con
//! él el diff entre dos generaciones deja de ser «347 símbolos cambiaron» para ser «activaste
//! bluetooth y entraron 12 símbolos».
use std::collections::BTreeMap;
use serde::{Deserialize, Serialize};
use super::catalog::{Catalog, Side};
use super::kconfig::KconfigTree;
use crate::recipe::Recipe;
/// Qué se le pide a `scripts/config` para un símbolo.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "lowercase")]
pub enum Action {
Enable,
Disable,
}
impl Action {
fn flag(self) -> &'static str {
match self {
Action::Enable => "-e",
Action::Disable => "-d",
}
}
}
/// Un símbolo emitido, con su procedencia.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct PlannedSymbol {
pub symbol: String,
pub action: Action,
/// `bundle:sin-wifi`, `knob:tick-1000`.
pub from: String,
/// Por qué está: raíz declarada, o fuga `select` que hubo que cerrar.
pub reason: String,
}
/// Una perilla que **no** cae del lado de Kconfig: el plan la declara y no la aplica.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct RecipeKnob {
pub id: String,
pub title: String,
pub recipe_field: String,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Plan {
pub base_recipe: String,
pub base_name: String,
pub derived_name: String,
pub bundles: Vec<String>,
pub knobs: Vec<String>,
/// Símbolos que se emiten, con procedencia.
pub symbols: Vec<PlannedSymbol>,
/// **Diff-back estático**: pedidos que no existen en este árbol. Hoy un `-d` sobre uno de éstos
/// es un no-op que nadie ve — le pasa a `THUNDERBOLT` y a `REISERFS_FS` en las cuatro recetas
/// de kernel del repo.
pub unknown_symbols: Vec<PlannedSymbol>,
/// Perillas `side = "recipe"` seleccionadas: declaradas, no aplicadas.
pub recipe_side: Vec<RecipeKnob>,
/// Cuántos símbolos apaga de verdad la selección, contando la clausura. Es informativo: quien
/// la calcula de vuelta, y manda, es `olddefconfig`.
pub closure_size: usize,
/// La invocación de `scripts/config` que se inserta.
pub fragment: String,
/// La fase `configure` derivada, entera.
pub configure: String,
/// El `ArtifactHash` de la receta derivada. `None` si no se pudo calcular (deps sin resolver).
#[serde(skip_serializing_if = "Option::is_none")]
pub artifact_hash: Option<String>,
}
/// Construye el plan. No toca el disco.
pub fn make_plan(
base: &Recipe,
base_path: &str,
cat: &Catalog,
tree: &KconfigTree,
bundle_ids: &[String],
knob_ids: &[String],
) -> crate::Result<Plan> {
let mut wanted: BTreeMap<String, PlannedSymbol> = BTreeMap::new();
let mut disable_roots: Vec<String> = Vec::new();
let push = |wanted: &mut BTreeMap<String, PlannedSymbol>,
sym: &str,
action: Action,
from: String,
reason: &str|
-> crate::Result<()> {
// Dos selecciones que se contradicen sobre el mismo símbolo no se resuelven por orden de
// aparición: se rechazan. El orden sería una respuesta plausible y arbitraria.
if let Some(prev) = wanted.get(sym) {
if prev.action != action {
return Err(crate::Error::Recipe(format!(
"conflicto en {sym}: «{}» lo quiere {:?} y «{from}» lo quiere {:?}",
prev.from, prev.action, action
)));
}
return Ok(());
}
wanted.insert(
sym.to_string(),
PlannedSymbol {
symbol: sym.to_string(),
action,
from,
reason: reason.to_string(),
},
);
Ok(())
};
for id in bundle_ids {
let b = cat.bundle(id).ok_or_else(|| {
crate::Error::Recipe(format!("no existe el bundle «{id}» en el catálogo"))
})?;
let from = format!("bundle:{id}");
for s in &b.disable {
push(&mut wanted, s, Action::Disable, from.clone(), "raíz del bundle")?;
disable_roots.push(s.clone());
}
for s in &b.close_leaks {
push(
&mut wanted,
s,
Action::Disable,
from.clone(),
"fuga `select` cerrada: sin esto el olddefconfig lo reenciende",
)?;
disable_roots.push(s.clone());
}
for s in &b.enable {
push(&mut wanted, s, Action::Enable, from.clone(), "raíz del bundle")?;
}
}
let mut recipe_side = Vec::new();
for id in knob_ids {
let k = cat.knob(id).ok_or_else(|| {
crate::Error::Recipe(format!("no existe la perilla «{id}» en el catálogo"))
})?;
let from = format!("knob:{id}");
if k.side == Side::Recipe {
recipe_side.push(RecipeKnob {
id: k.id.clone(),
title: k.title.clone(),
recipe_field: k.recipe_field.clone().unwrap_or_default(),
});
continue;
}
for s in &k.enable {
push(&mut wanted, s, Action::Enable, from.clone(), "perilla")?;
}
for s in &k.disable {
push(&mut wanted, s, Action::Disable, from.clone(), "perilla")?;
disable_roots.push(s.clone());
}
}
// Un `enable` que cae DENTRO de lo que otro bundle apaga es un conflicto que el símbolo solo no
// delata: `olddefconfig` lo descartaría en silencio por dependencia insatisfecha.
let closure = tree.closure_off(&disable_roots);
for p in wanted.values() {
if p.action == Action::Enable && closure.contains(&p.symbol) {
return Err(crate::Error::Recipe(format!(
"conflicto: «{}» quiere encender {} pero cae dentro de lo que apaga la selección \
(el olddefconfig lo descartaría sin decir nada)",
p.from, p.symbol
)));
}
}
let (known, unknown_symbols): (Vec<_>, Vec<_>) = wanted
.into_values()
.partition(|p| tree.symbols.contains_key(&p.symbol));
let arch = detect_arch(base);
let fragment = render_fragment(&known);
let base_configure = base
.build
.phases
.configure
.as_deref()
.ok_or_else(|| {
crate::Error::Recipe(format!(
"la receta base «{}» no tiene fase `configure`: no hay dónde insertar el config",
base.name
))
})?
.trim_end()
.trim_end_matches('\\')
.trim_end()
.to_string();
// Se AÑADE una segunda ronda en vez de reescribir la fase base: no hay que parsear el shell de
// nadie, la base sigue siendo literalmente la de siempre en el diff, y `olddefconfig` es
// idempotente. Lo que la app aporta es un fragmento; resolver sigue siendo del kernel.
let configure = format!("{base_configure} && \\\n{fragment} && \\\nmake ARCH={arch} olddefconfig\n");
let derived_name = derive_name(&base.name, bundle_ids, knob_ids);
Ok(Plan {
base_recipe: base_path.to_string(),
base_name: base.name.clone(),
derived_name,
bundles: bundle_ids.to_vec(),
knobs: knob_ids.to_vec(),
symbols: known,
unknown_symbols,
recipe_side,
closure_size: closure.len(),
fragment,
configure,
artifact_hash: None,
})
}
/// La receta derivada: la base con la fase `configure` del plan y un nombre propio.
///
/// Conserva el `base_dir` de la base a propósito — es lo que hace resolubles sus `deps.build` y,
/// con ellas, calculable el `ArtifactHash`.
pub fn derive_recipe(base: &Recipe, plan: &Plan) -> Recipe {
let mut r = base.clone();
r.name = plan.derived_name.clone();
r.build.phases.configure = Some(plan.configure.clone());
r
}
/// Serializa la receta derivada a TOML. Vive acá y no en la CLI porque el formato de receta es del
/// core: quien lo cambie tiene que cambiarlo en un solo sitio.
pub fn render_recipe_toml(r: &Recipe) -> crate::Result<String> {
toml::to_string_pretty(r).map_err(|e| crate::Error::Serde(e.to_string()))
}
fn detect_arch(base: &Recipe) -> String {
let hay = base
.build
.phases
.configure
.as_deref()
.unwrap_or_default();
for tok in hay.split_whitespace() {
if let Some(v) = tok.strip_prefix("ARCH=") {
if !v.is_empty() {
return v.to_string();
}
}
}
"x86_64".to_string()
}
/// `scripts/config -d A -d B -e C …`, en líneas de ancho razonable y **orden estable**: el texto
/// entra al hash, así que un orden que dependiera del recorrido daría dos hashes para el mismo plan.
fn render_fragment(syms: &[PlannedSymbol]) -> String {
let mut out = String::from("scripts/config");
let mut col = out.len();
for p in syms {
let pieza = format!(" {} {}", p.action.flag(), p.symbol);
if col + pieza.len() > 96 {
out.push_str(" \\\n ");
col = 1;
}
col += pieza.len();
out.push_str(&pieza);
}
out
}
/// Nombre de la receta derivada. Lleva un hash corto de la SELECCIÓN, no del contenido: dos planes
/// con los mismos bundles dan el mismo nombre, y el `ArtifactHash` sigue siendo quien manda.
fn derive_name(base: &str, bundles: &[String], knobs: &[String]) -> String {
let mut material = String::new();
let mut b: Vec<&String> = bundles.iter().collect();
b.sort();
for x in b {
material.push_str("bundle:");
material.push_str(x);
material.push('\n');
}
let mut k: Vec<&String> = knobs.iter().collect();
k.sort();
for x in k {
material.push_str("knob:");
material.push_str(x);
material.push('\n');
}
let h = crate::hash::ArtifactHash::of_bytes(material.as_bytes());
let hex = h.as_str().trim_start_matches("b3:");
format!("{base}-plan-{}", &hex[..8])
}
/// **Diff-back** (§6 del handoff, la mitad que faltaba): ¿sobrevivió cada símbolo pedido al
/// `olddefconfig`?
///
/// Sin esto la UI miente: un `-e FOO` cuya dependencia no se cumple se pierde en silencio.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct DiffBack {
pub honored: Vec<String>,
/// Pedidos que el `.config` resultante contradice. **Cada uno es una promesa incumplida.**
pub violated: Vec<Violation>,
/// Pedidos sobre símbolos que el `.config` ni menciona: no existen en esa versión del kernel.
pub absent: Vec<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Violation {
pub symbol: String,
pub asked: Action,
/// Cómo quedó de verdad.
pub got: String,
pub from: String,
}
impl DiffBack {
pub fn is_clean(&self) -> bool {
self.violated.is_empty() && self.absent.is_empty()
}
}
/// Contrasta el plan contra el `.config` que produjo el kernel.
pub fn diff_back(plan: &Plan, produced: &super::KernelConfig) -> DiffBack {
use super::ConfigValue;
let mut d = DiffBack {
honored: Vec::new(),
violated: Vec::new(),
absent: Vec::new(),
};
for p in &plan.symbols {
match (p.action, produced.get(&p.symbol)) {
(_, None) => d.absent.push(p.symbol.clone()),
(Action::Enable, Some(v)) if v.is_on() => d.honored.push(p.symbol.clone()),
(Action::Disable, Some(ConfigValue::No)) => d.honored.push(p.symbol.clone()),
(asked, Some(v)) => d.violated.push(Violation {
symbol: p.symbol.clone(),
asked,
got: format!("{v:?}"),
from: p.from.clone(),
}),
}
}
// Los que ya sabíamos inexistentes en el árbol también son promesa incumplida.
for p in &plan.unknown_symbols {
d.absent.push(p.symbol.clone());
}
d.absent.sort();
d.absent.dedup();
d
}
#[cfg(test)]
mod tests {
use super::*;
use std::collections::BTreeMap;
fn escenario() -> (tempfile::TempDir, KconfigTree, Catalog, Recipe) {
let d = tempfile::tempdir().unwrap();
std::fs::write(
d.path().join("Kconfig"),
r#"
config WIRELESS
bool "Wireless"
config CFG80211
tristate "cfg80211"
depends on WIRELESS
config WLAN
bool "WLAN"
select WIRELESS
config AUDIT
bool "Audit"
config MAC80211_LEDS
bool "leds"
depends on CFG80211
"#,
)
.unwrap();
let tree = KconfigTree::parse(d.path(), &BTreeMap::new()).unwrap();
let cat: Catalog = toml::from_str(
r#"
version = 1
[[bundle]]
id = "sin-wifi"
title = "No necesito wifi"
disable = ["WIRELESS"]
close_leaks = ["WLAN"]
[[bundle]]
id = "con-leds"
title = "Quiero los leds"
enable = ["MAC80211_LEDS"]
[[knob]]
id = "auditoria"
title = "Auditoría"
side = "kconfig"
enable = ["AUDIT"]
[[knob]]
id = "lto"
title = "LTO"
side = "recipe"
recipe_field = "build.compiler"
"#,
)
.unwrap();
let recipe: Recipe = toml::from_str(
r#"
name = "linux"
version = "6.16.12"
[source]
tarball = "https://x/linux.tar.gz"
sha256 = "aa"
[build]
compiler = "zig-cc"
target = "x86_64-linux-musl"
link = "dynamic"
[build.phases]
configure = "make ARCH=x86_64 defconfig && make ARCH=x86_64 olddefconfig"
compile = "make bzImage"
"#,
)
.unwrap();
(d, tree, cat, recipe)
}
#[test]
fn emite_raices_no_clausuras() {
let (_d, tree, cat, base) = escenario();
let p = make_plan(&base, "recipes/linux.toml", &cat, &tree, &["sin-wifi".into()], &[]).unwrap();
// Dos banderas, no cuatro símbolos: la clausura la calcula olddefconfig.
assert_eq!(p.symbols.len(), 2);
assert!(p.fragment.contains("-d WIRELESS"));
assert!(p.fragment.contains("-d WLAN"));
assert!(!p.fragment.contains("CFG80211"));
// …pero hammer SABE cuántos caen, para poder explicarlo.
assert_eq!(p.closure_size, 4);
}
#[test]
fn la_fase_derivada_conserva_la_base_y_reresuelve() {
let (_d, tree, cat, base) = escenario();
let p = make_plan(&base, "recipes/linux.toml", &cat, &tree, &["sin-wifi".into()], &[]).unwrap();
assert!(p.configure.starts_with("make ARCH=x86_64 defconfig"));
assert!(p.configure.trim_end().ends_with("make ARCH=x86_64 olddefconfig"));
// El fragmento va ANTES del olddefconfig final: si fuera después, no se resolvería.
let i_frag = p.configure.find("scripts/config").unwrap();
let i_last = p.configure.rfind("olddefconfig").unwrap();
assert!(i_frag < i_last);
}
#[test]
fn el_nombre_derivado_es_estable_e_independiente_del_orden() {
let (_d, tree, cat, base) = escenario();
let a = make_plan(&base, "r", &cat, &tree, &["sin-wifi".into()], &["auditoria".into()]).unwrap();
let b = make_plan(&base, "r", &cat, &tree, &["sin-wifi".into()], &["auditoria".into()]).unwrap();
assert_eq!(a.derived_name, b.derived_name);
assert!(a.derived_name.starts_with("linux-plan-"));
let c = make_plan(&base, "r", &cat, &tree, &["sin-wifi".into()], &[]).unwrap();
assert_ne!(a.derived_name, c.derived_name);
}
#[test]
fn encender_algo_que_la_seleccion_apaga_es_conflicto() {
let (_d, tree, cat, base) = escenario();
// MAC80211_LEDS depende de CFG80211, que cae con WIRELESS. olddefconfig lo tiraría callado.
let e = make_plan(
&base,
"r",
&cat,
&tree,
&["sin-wifi".into(), "con-leds".into()],
&[],
)
.unwrap_err()
.to_string();
assert!(e.contains("MAC80211_LEDS"), "{e}");
}
#[test]
fn la_perilla_de_receta_se_declara_y_no_se_aplica() {
let (_d, tree, cat, base) = escenario();
let p = make_plan(&base, "r", &cat, &tree, &[], &["lto".into()]).unwrap();
assert!(p.symbols.is_empty());
assert_eq!(p.recipe_side.len(), 1);
assert_eq!(p.recipe_side[0].recipe_field, "build.compiler");
}
#[test]
fn el_simbolo_inexistente_no_se_pierde_en_silencio() {
let (_d, tree, _cat, base) = escenario();
let cat: Catalog = toml::from_str(
r#"
version = 1
[[bundle]]
id = "fantasma"
title = "Apaga algo que ya no existe"
disable = ["REISERFS_FS"]
"#,
)
.unwrap();
let p = make_plan(&base, "r", &cat, &tree, &["fantasma".into()], &[]).unwrap();
assert!(p.symbols.is_empty(), "no se emite una bandera que sería no-op");
assert_eq!(p.unknown_symbols.len(), 1);
assert_eq!(p.unknown_symbols[0].symbol, "REISERFS_FS");
}
#[test]
fn el_diff_back_ve_la_promesa_incumplida() {
let (_d, tree, cat, base) = escenario();
let p = make_plan(&base, "r", &cat, &tree, &["sin-wifi".into()], &["auditoria".into()]).unwrap();
// El kernel produjo un config donde WLAN sobrevivió y AUDIT no se pudo encender.
let cfg = super::super::KernelConfig::parse(
"# CONFIG_WIRELESS is not set\nCONFIG_WLAN=y\n# CONFIG_AUDIT is not set\n",
);
let d = diff_back(&p, &cfg);
assert!(!d.is_clean());
let violados: Vec<&str> = d.violated.iter().map(|v| v.symbol.as_str()).collect();
assert_eq!(violados, vec!["AUDIT", "WLAN"]);
assert_eq!(d.honored, vec!["WIRELESS"]);
}
#[test]
fn el_diff_back_limpio_es_limpio() {
let (_d, tree, cat, base) = escenario();
let p = make_plan(&base, "r", &cat, &tree, &["sin-wifi".into()], &[]).unwrap();
let cfg = super::super::KernelConfig::parse(
"# CONFIG_WIRELESS is not set\n# CONFIG_WLAN is not set\n",
);
assert!(diff_back(&p, &cfg).is_clean());
}
}
+37
View File
@@ -304,6 +304,43 @@ Para cerrar «no necesito audio» hay que tragarse drivers gráficos. En `linux.
porque los gráficos también están apagados, pero en un perfil de escritorio **no**: ahí la fuga es
una decisión, y por eso el punto fijo la muestra en vez de aplicarla.
## 10. Pasos 1 y 4 hechos: modo reversa, plan y diff-back
**Modo reversa** (`hammer kernel probe`) sobre gioser, con el catálogo `docs/state/kernel-bundles.toml`
(15 bundles N1, 8 perillas N2): 10 551 símbolos declarados, 20 dispositivos PCI, 38 drivers
bindeados, **7 bundles con capacidad que este hardware no usa**. Y avisa de lo que el propio caso
destapó: el config vivo es de la serie 7.1 y el catálogo se revisó contra la 6.16 ⇒ las clausuras
son aproximadas. Se dice en vez de callarlo.
**Plan** (`hammer kernel plan`): emite una **receta derivada**, como manda §1. Medido sobre
`recipes/linux.toml` con `sin-wifi` + `sin-audio` + `solo-ext4` + `jaula-y-eio-moderna`:
| | |
|---|---|
| banderas emitidas | **16** |
| símbolos que caen con su clausura | **1775** |
| `ArtifactHash` de la base | `b3:cb926743…` |
| `ArtifactHash` de la derivada | `b3:47a52b2e…` |
Dos hashes distintos para el mismo código fuente: **el config es la identidad**, y ahora se ve. El
plan lleva su hash dentro ⇒ la UI puede decir «esto ya está construido y firmado» sin construir.
Tres detalles del plan que no eran obvios:
- **Se emiten raíces, no clausuras.** 16 banderas, no 1775 líneas. La clausura la calcula el
`olddefconfig` del propio kernel; hammer la sabe sólo para poder explicarla.
- **La fase derivada AÑADE una segunda ronda** (`… && scripts/config … && make olddefconfig`) en vez
de reescribir la base. No hay que parsear el shell de nadie y la base sigue siendo literalmente la
de siempre en el diff.
- **Los conflictos se rechazan, no se ordenan.** Dos selecciones que se contradicen sobre un símbolo
darían una respuesta plausible y arbitraria según el orden de aparición. Y hay un segundo conflicto
que el símbolo solo no delata: encender algo que **cae dentro de la clausura** de lo que otro
bundle apaga — `olddefconfig` lo descartaría sin decir nada.
**Diff-back** (`hammer kernel diff-back --plan … --config …`): la mitad que faltaba del §6 del
handoff. Clasifica cada símbolo pedido en cumplido / **incumplido** (el `.config` dice otra cosa) /
ausente (el kernel ni lo menciona: la bandera fue un no-op), con la procedencia de quién lo pidió, y
sale distinto de cero si el config no honra el plan.
### Un hallazgo de regalo
`recipes/linux.toml` apaga **`REISERFS_FS`, que no existe en 6.16.12** (upstream lo retiró). El
`scripts/config -d REISERFS_FS` es un no-op que nadie vio. Es exactamente el modo de fallo que el