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:
@@ -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
|
||||
|
||||
@@ -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(())
|
||||
}
|
||||
|
||||
@@ -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};
|
||||
|
||||
@@ -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());
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user