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…
takana etapa 5d: comentarios de crates, CLAUDE.md y el skill de granja
1//! El catálogo de bundles (N1) y perillas (N2).
2//!
3//! ## Por qué un bundle no es una lista de símbolos
4//! Escrito como lista, «no necesito wifi» son ~400 `CONFIG_*` que envejecen en cada release y
5//! exigen un curador humano para siempre (handoff §2.1). Escrito como **predicado sobre el grafo**
6//! —una raíz y su clausura— son dos líneas y se auto-cura: el driver que entra en 6.17 cae dentro
7//! sin que nadie lo toque.
8//!
9//! Lo que la medición del SDD 22 §9 agregó a esa idea: **la clausura sola no cierra**. `select`
10//! fuerza símbolos ignorando sus `depends on`, así que un bundle honesto declara además qué hace
11//! con cada fuga — cerrarla (`close_leaks`) o aceptarla a sabiendas (`accept_leaks`). Esa revisión
12//! se hace **una vez por bundle**, no una vez por release.
13//!
14//! ## Por qué las perillas N2 llevan un campo `side`
15//! «La mitad de N2 no es Kconfig sino variables de receta» (handoff §5). Con el hecho del §1 del
16//! SDD 22 en la mano —el config ES la identidad del artefacto— eso es más grave de lo que parece:
17//! las dos mitades mueven el hash igual, pero se aplican en fases distintas y fallan distinto. Una
18//! UI que no sepa de qué lado cae cada perilla prometerá diffs que no puede explicar.
19
20use std::collections::BTreeMap;
21use std::path::Path;
22
23use serde::{Deserialize, Serialize};
24
25/// Formato del fichero de catálogo. Se versiona para poder migrarlo sin adivinar.
26pub const CATALOG_VERSION: u32 = 1;
27
28#[derive(Debug, Clone, Serialize, Deserialize)]
29pub struct Catalog {
30 pub version: u32,
31 /// Kernel contra el que se revisaron las fugas. Cambiar de versión no invalida el catálogo,
32 /// pero sí obliga a re-mirar las fugas (`takana kernel closure`).
33 #[serde(default)]
34 pub reviewed_against: Option<String>,
35 #[serde(default, rename = "bundle")]
36 pub bundles: Vec<Bundle>,
37 #[serde(default, rename = "knob")]
38 pub knobs: Vec<Knob>,
39 #[serde(default, rename = "objective")]
40 pub objectives: Vec<Objective>,
41}
42
43/// Para qué es este kernel. **El gate de no-regresión no corre sin uno.**
44///
45/// `recipes/linux.toml` apaga USB, HID e INPUT a propósito — es el kernel de QEMU con consola
46/// serie. Un gate global «todo dispositivo en uso debe seguir teniendo driver» rechazaría esa
47/// receta, que está sana. El objetivo declara qué pérdidas son legítimas para este uso.
48#[derive(Debug, Clone, Serialize, Deserialize)]
49pub struct Objective {
50 pub id: String,
51 pub title: String,
52 #[serde(default)]
53 pub help: Option<String>,
54 /// Bundles cuyas pérdidas de driver este objetivo autoriza. Todo lo demás bloquea.
55 #[serde(default)]
56 pub allow_bundles: Vec<String>,
57}
58
59/// Un bundle de nivel 1: una aserción del dueño de la máquina («no necesito wifi»).
60#[derive(Debug, Clone, Serialize, Deserialize)]
61pub struct Bundle {
62 /// Identificador estable, en kebab-case. Es lo que la UI y `plan` se pasan.
63 pub id: String,
64 /// Título en castellano, para el humano.
65 pub title: String,
66 #[serde(default)]
67 pub help: Option<String>,
68 /// **El predicado**: apagar estas raíces y, con ellas, todo lo que dependa duro de ellas.
69 #[serde(default)]
70 pub disable: Vec<String>,
71 /// Encender estos símbolos (bundles «positivos», raros en N1 pero necesarios en N2).
72 #[serde(default)]
73 pub enable: Vec<String>,
74 /// Fugas `select` que se cierran también, revisadas a mano. Cada entrada es un símbolo de
75 /// FUERA de la clausura que reencendería algo de dentro.
76 #[serde(default)]
77 pub close_leaks: Vec<String>,
78 /// Fugas que se dejan abiertas a sabiendas, con su motivo. Clave = símbolo destino.
79 #[serde(default)]
80 pub accept_leaks: BTreeMap<String, String>,
81 /// Detección de hardware, **en un solo sentido**: sirve para CONTRADECIR al usuario
82 /// («marcaste "no necesito wifi" y tenés un AX211 activo ahora mismo»), nunca para podar solo.
83 /// Un kernel podado por autodetección es cómo se fabrica un ladrillo (handoff §6).
84 #[serde(default)]
85 pub contradicted_by: Contradiction,
86 #[serde(default)]
87 pub notes: Option<String>,
88}
89
90/// Señales de hardware que contradicen a un bundle si están presentes AHORA.
91#[derive(Debug, Clone, Default, Serialize, Deserialize)]
92pub struct Contradiction {
93 /// Clases PCI, como los 6 dígitos hex de `/sys/bus/pci/devices/*/class`, o un prefijo
94 /// (`"0280"` = controlador de red inalámbrica).
95 #[serde(default)]
96 pub pci_class: Vec<String>,
97 /// Drivers con dispositivo bindeado ahora mismo (`/sys/bus/*/devices/*/driver`).
98 #[serde(default)]
99 pub driver: Vec<String>,
100 /// Flags de CPU de `/proc/cpuinfo`.
101 #[serde(default)]
102 pub cpu_flag: Vec<String>,
103}
104
105impl Contradiction {
106 pub fn is_empty(&self) -> bool {
107 self.pci_class.is_empty() && self.driver.is_empty() && self.cpu_flag.is_empty()
108 }
109}
110
111/// De qué lado del build cae una perilla de nivel 2.
112#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
113#[serde(rename_all = "lowercase")]
114pub enum Side {
115 /// Símbolos de Kconfig: se aplican en la fase `configure`.
116 Kconfig,
117 /// Variables de la receta (LTO, `-march`, `zig_version`): otra fase, otro modo de fallo.
118 /// Mueven el `ArtifactHash` igual que los símbolos.
119 Recipe,
120}
121
122/// Una perilla de nivel 2: política y rendimiento.
123#[derive(Debug, Clone, Serialize, Deserialize)]
124pub struct Knob {
125 pub id: String,
126 pub title: String,
127 #[serde(default)]
128 pub help: Option<String>,
129 /// La mitad de N2 no es Kconfig. Este campo obliga a decirlo.
130 pub side: Side,
131 #[serde(default)]
132 pub enable: Vec<String>,
133 #[serde(default)]
134 pub disable: Vec<String>,
135 /// Sólo con `side = "recipe"`: qué campo de la receta toca, en texto, para que la UI pueda
136 /// explicar el diff aunque `plan` todavía no sepa aplicarlo.
137 #[serde(default)]
138 pub recipe_field: Option<String>,
139 #[serde(default)]
140 pub notes: Option<String>,
141}
142
143impl Catalog {
144 pub fn load(path: &Path) -> crate::Result<Catalog> {
145 let text = std::fs::read_to_string(path)?;
146 let cat: Catalog = toml::from_str(&text)
147 .map_err(|e| crate::Error::Recipe(format!("{}: {e}", path.display())))?;
148 cat.validate(path)?;
149 Ok(cat)
150 }
151
152 fn validate(&self, path: &Path) -> crate::Result<()> {
153 let p = path.display();
154 if self.version != CATALOG_VERSION {
155 return Err(crate::Error::Recipe(format!(
156 "{p}: version = {}, esperaba {CATALOG_VERSION}",
157 self.version
158 )));
159 }
160 let mut ids = std::collections::BTreeSet::new();
161 for b in &self.bundles {
162 if !ids.insert(("bundle", b.id.as_str())) {
163 return Err(crate::Error::Recipe(format!("{p}: bundle duplicado «{}»", b.id)));
164 }
165 if b.disable.is_empty() && b.enable.is_empty() {
166 return Err(crate::Error::Recipe(format!(
167 "{p}: el bundle «{}» no apaga ni enciende nada",
168 b.id
169 )));
170 }
171 }
172 for k in &self.knobs {
173 if !ids.insert(("knob", k.id.as_str())) {
174 return Err(crate::Error::Recipe(format!("{p}: perilla duplicada «{}»", k.id)));
175 }
176 // Una perilla de receta que no dice qué campo toca es exactamente el diff que la UI no
177 // va a poder explicar.
178 if k.side == Side::Recipe && k.recipe_field.is_none() {
179 return Err(crate::Error::Recipe(format!(
180 "{p}: la perilla «{}» es side=recipe y no declara recipe_field",
181 k.id
182 )));
183 }
184 if k.side == Side::Kconfig && k.enable.is_empty() && k.disable.is_empty() {
185 return Err(crate::Error::Recipe(format!(
186 "{p}: la perilla «{}» es side=kconfig y no toca ningún símbolo",
187 k.id
188 )));
189 }
190 }
191 let conocidos: std::collections::BTreeSet<&str> =
192 self.bundles.iter().map(|b| b.id.as_str()).collect();
193 for o in &self.objectives {
194 if !ids.insert(("objective", o.id.as_str())) {
195 return Err(crate::Error::Recipe(format!("{p}: objetivo duplicado «{}»", o.id)));
196 }
197 // Un `allow_bundles` con un id mal escrito autorizaría NADA y el gate bloquearía sin
198 // que se entienda por qué. Se rechaza al cargar, no en el portón.
199 for b in &o.allow_bundles {
200 if !conocidos.contains(b.as_str()) {
201 return Err(crate::Error::Recipe(format!(
202 "{p}: el objetivo «{}» autoriza el bundle «{b}», que no existe",
203 o.id
204 )));
205 }
206 }
207 }
208 Ok(())
209 }
210
211 pub fn bundle(&self, id: &str) -> Option<&Bundle> {
212 self.bundles.iter().find(|b| b.id == id)
213 }
214
215 pub fn knob(&self, id: &str) -> Option<&Knob> {
216 self.knobs.iter().find(|k| k.id == id)
217 }
218
219 pub fn objective(&self, id: &str) -> Option<&Objective> {
220 self.objectives.iter().find(|o| o.id == id)
221 }
222}
223
224#[cfg(test)]
225mod tests {
226 use super::*;
227
228 fn escribir(s: &str) -> (tempfile::TempDir, std::path::PathBuf) {
229 let d = tempfile::tempdir().unwrap();
230 let p = d.path().join("bundles.toml");
231 std::fs::write(&p, s).unwrap();
232 (d, p)
233 }
234
235 #[test]
236 fn carga_y_resuelve_por_id() {
237 let (_d, p) = escribir(
238 r#"
239version = 1
240[[bundle]]
241id = "sin-wifi"
242title = "No necesito wifi"
243disable = ["WIRELESS"]
244close_leaks = ["WLAN"]
245[bundle.accept_leaks]
246MAC80211_LEDS = "lo deja encendido IWLEGACY, que igual no se compila"
247[bundle.contradicted_by]
248pci_class = ["0280"]
249"#,
250 );
251 let c = Catalog::load(&p).unwrap();
252 let b = c.bundle("sin-wifi").unwrap();
253 assert_eq!(b.disable, vec!["WIRELESS"]);
254 assert_eq!(b.close_leaks, vec!["WLAN"]);
255 assert_eq!(b.accept_leaks.len(), 1);
256 assert_eq!(b.contradicted_by.pci_class, vec!["0280"]);
257 }
258
259 #[test]
260 fn perilla_de_receta_sin_campo_es_error() {
261 let (_d, p) = escribir(
262 r#"
263version = 1
264[[knob]]
265id = "lto"
266title = "LTO"
267side = "recipe"
268"#,
269 );
270 // Sin `recipe_field` la UI prometería un diff que no puede explicar (handoff §5).
271 let e = Catalog::load(&p).unwrap_err().to_string();
272 assert!(e.contains("recipe_field"), "{e}");
273 }
274
275 #[test]
276 fn bundle_vacio_es_error() {
277 let (_d, p) = escribir(
278 r#"
279version = 1
280[[bundle]]
281id = "nada"
282title = "Nada"
283"#,
284 );
285 assert!(Catalog::load(&p).is_err());
286 }
287
288 #[test]
289 fn version_ajena_es_error() {
290 let (_d, p) = escribir("version = 99\n");
291 assert!(Catalog::load(&p).is_err());
292 }
293}