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…
⎇ ca9bde0251d05fbfd34c93033d1aff2eb2ee62b5
crates/hammer-core/src/kernel/kconfig.rs
Historia
Quién tocó qué
crudo
kernel: la clausura contrastada contra un olddefconfig de verdad — 65/68 con 0 falsos positivos
1//! Lector del grafo de Kconfig — **sólo para analizar, nunca para resolver**.
2//!
3//! Regla dura heredada del handoff (§6) y ratificada en `docs/22-configurador-kernel.md`: hammer
4//! **no** implementa un solver de Kconfig. El `.config` lo sigue produciendo el `olddefconfig` del
5//! propio kernel. Lo que este módulo hace es *leer* los ~15 000 símbolos para poder contestar
6//! preguntas sobre el grafo — «¿qué muere si apago `WIRELESS`?», «¿quién lo enciende por la
7//! espalda?»— que es lo que necesita el catálogo de bundles por clausura (§2.1 del handoff).
8//!
9//! ## Por qué el parser es tolerante y no estricto
10//! Un parser estricto de Kconfig tendría que evaluar `$(shell,…)`, `$(cc-option,…)` y la maquinaria
11//! de macros de `scripts/Kconfig.include`, que depende del compilador presente. Nada de eso cambia
12//! la **topología** del grafo, que es lo único que se consulta acá. Los macros se conservan como
13//! constantes opacas y las construcciones desconocidas se acumulan en [`KconfigTree::warnings`]
14//! en vez de abortar: una advertencia contada es medible, un `panic` a mitad de árbol no.
15//!
16//! ## La sutileza que hace honesto el análisis
17//! Un símbolo puede definirse en **varios ficheros**, cada definición con sus propios `depends on`.
18//! Un símbolo sólo se vuelve inalcanzable si **todas** sus definiciones lo son ⇒ las dependencias
19//! duras son la **intersección** entre definiciones, no la unión. Ver [`Symbol::hard_deps`].
20
21use std::collections::{BTreeMap, BTreeSet, HashSet};
22use std::path::{Path, PathBuf};
23
24use serde::{Deserialize, Serialize};
25
26/// Tipo declarado del símbolo. `Unknown` es legítimo: hay símbolos que sólo se declaran por
27/// `default`/`select` desde otro fichero.
28#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
29#[serde(rename_all = "lowercase")]
30pub enum SymKind {
31 Bool,
32 Tristate,
33 Int,
34 Hex,
35 Str,
36 Unknown,
37}
38
39impl SymKind {
40 fn from_keyword(kw: &str) -> Option<SymKind> {
41 Some(match kw {
42 "bool" | "def_bool" => SymKind::Bool,
43 "tristate" | "def_tristate" => SymKind::Tristate,
44 "int" => SymKind::Int,
45 "hex" => SymKind::Hex,
46 "string" => SymKind::Str,
47 _ => return None,
48 })
49 }
50}
51
52/// Dónde se declaró una definición. Es la materia prima de la procedencia por símbolo (#7 del
53/// handoff): poder contestar «¿por qué mi kernel tiene `CONFIG_FOO`?» con un fichero y una línea.
54#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
55pub struct Location {
56 /// Ruta relativa a la raíz del árbol de fuentes.
57 pub file: String,
58 pub line: usize,
59}
60
61impl std::fmt::Display for Location {
62 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
63 write!(f, "{}:{}", self.file, self.line)
64 }
65}
66
67#[derive(Debug, Clone, Copy, PartialEq, Eq)]
68pub enum CmpOp {
69 Eq,
70 Ne,
71 Lt,
72 Le,
73 Gt,
74 Ge,
75}
76
77/// Expresión de Kconfig. Se conserva el árbol completo (no sólo la lista de símbolos) porque la
78/// **posición** de un símbolo decide si apagarlo mata la expresión: en `A && (B || C)` sólo `A` es
79/// dura. Aplanar a «símbolos referenciados» perdería justo ese dato.
80#[derive(Debug, Clone, PartialEq, Eq)]
81pub enum Expr {
82 /// Referencia a otro símbolo (sin el prefijo `CONFIG_`, como se escribe en los Kconfig).
83 Sym(String),
84 /// `y`, `n`, `m`, números, cadenas entrecomilladas y macros `$(...)` sin evaluar.
85 Const(String),
86 Not(Box<Expr>),
87 And(Box<Expr>, Box<Expr>),
88 Or(Box<Expr>, Box<Expr>),
89 Cmp(Box<Expr>, CmpOp, Box<Expr>),
90}
91
92impl Expr {
93 /// Todos los símbolos que aparecen, sin importar la posición.
94 pub fn symbols(&self, out: &mut BTreeSet<String>) {
95 match self {
96 Expr::Sym(s) => {
97 out.insert(s.clone());
98 }
99 Expr::Const(_) => {}
100 Expr::Not(e) => e.symbols(out),
101 Expr::And(a, b) | Expr::Or(a, b) => {
102 a.symbols(out);
103 b.symbols(out);
104 }
105 Expr::Cmp(a, _, b) => {
106 a.symbols(out);
107 b.symbols(out);
108 }
109 }
110 }
111
112 /// Símbolos en posición **conjuntiva**: si cualquiera de ellos vale `n`, la expresión entera
113 /// vale `n`. Es la única lectura de «X no puede existir sin Y» que no miente.
114 ///
115 /// Deliberadamente conservador: `!X`, las ramas de un `||` y los dos lados de una comparación
116 /// **no** aportan nada. Preferimos sub-reportar (un bundle que apaga de menos, y se nota) a
117 /// sobre-reportar (un bundle que apaga de más, y hace un ladrillo).
118 pub fn hard_symbols(&self, out: &mut BTreeSet<String>) {
119 match self {
120 Expr::Sym(s) => {
121 out.insert(s.clone());
122 }
123 Expr::And(a, b) => {
124 a.hard_symbols(out);
125 b.hard_symbols(out);
126 }
127 // `X = y`, `X = m` y `X != n` SÍ son duras: si `X` vale `n` la comparación es falsa y
128 // se lleva la conjunción entera. Es la forma con la que medio `drivers/video/fbdev`
129 // declara su dependencia de `FB` (`depends on (FB = y) && ARM`), así que tratarlas como
130 // opacas dejaba fuera de la clausura a drivers que sí mueren.
131 // `X = n` queda fuera a propósito: con `X` en `n` es VERDADERA.
132 Expr::Cmp(a, op, b) => {
133 let par = match (a.as_ref(), b.as_ref()) {
134 (Expr::Sym(s), Expr::Const(c)) => Some((s, c.as_str())),
135 (Expr::Const(c), Expr::Sym(s)) => Some((s, c.as_str())),
136 _ => None,
137 };
138 if let Some((s, c)) = par {
139 let dura = match op {
140 CmpOp::Eq => c == "y" || c == "m",
141 CmpOp::Ne => c == "n",
142 _ => false,
143 };
144 if dura {
145 out.insert(s.clone());
146 }
147 }
148 }
149 Expr::Const(_) | Expr::Not(_) | Expr::Or(_, _) => {}
150 }
151 }
152}
153
154/// Arista `select`/`imply`: destino + condición opcional (`select FOO if BAR`).
155#[derive(Debug, Clone, PartialEq, Eq)]
156pub struct Edge {
157 pub target: String,
158 pub cond: Option<Expr>,
159}
160
161/// Una definición concreta de un símbolo, en un fichero concreto.
162#[derive(Debug, Clone)]
163pub struct SymbolDef {
164 pub kind: SymKind,
165 /// Texto del prompt si la definición es visible al usuario. `None` = símbolo interno,
166 /// seleccionable sólo por otros (no aparece en `menuconfig`).
167 pub prompt: Option<String>,
168 /// Conjunción de `depends on` propios **más** los heredados de `menu`/`if`/`choice`.
169 pub depends: Vec<Expr>,
170 pub selects: Vec<Edge>,
171 pub implies: Vec<Edge>,
172 pub help: Option<String>,
173 pub loc: Location,
174}
175
176/// Un símbolo del árbol, con todas sus definiciones.
177#[derive(Debug, Clone)]
178pub struct Symbol {
179 pub name: String,
180 pub defs: Vec<SymbolDef>,
181}
182
183impl Symbol {
184 /// Primer tipo declarado que no sea `Unknown`.
185 pub fn kind(&self) -> SymKind {
186 self.defs
187 .iter()
188 .map(|d| d.kind)
189 .find(|k| *k != SymKind::Unknown)
190 .unwrap_or(SymKind::Unknown)
191 }
192
193 /// ¿Lo puede marcar un humano en `menuconfig`, o sólo entra por `select`?
194 pub fn is_visible(&self) -> bool {
195 self.defs.iter().any(|d| d.prompt.is_some())
196 }
197
198 pub fn prompt(&self) -> Option<&str> {
199 self.defs.iter().find_map(|d| d.prompt.as_deref())
200 }
201
202 pub fn help(&self) -> Option<&str> {
203 self.defs.iter().find_map(|d| d.help.as_deref())
204 }
205
206 pub fn locations(&self) -> Vec<&Location> {
207 self.defs.iter().map(|d| &d.loc).collect()
208 }
209
210 /// Dependencias duras del símbolo: **intersección** entre definiciones.
211 ///
212 /// Un símbolo definido en dos ficheros sobrevive si a alguna de sus definiciones le quedan las
213 /// dependencias satisfechas. Tomar la unión (el error natural) diría que apagar `Y` mata a `X`
214 /// cuando `X` tiene otra definición que ni menciona a `Y`.
215 pub fn hard_deps(&self) -> BTreeSet<String> {
216 let mut iter = self.defs.iter().map(|d| {
217 let mut s = BTreeSet::new();
218 for e in &d.depends {
219 e.hard_symbols(&mut s);
220 }
221 s
222 });
223 let Some(first) = iter.next() else {
224 return BTreeSet::new();
225 };
226 iter.fold(first, |acc, s| acc.intersection(&s).cloned().collect())
227 }
228
229 /// Todos los símbolos mencionados en cualquier `depends on` de cualquier definición.
230 pub fn all_dep_symbols(&self) -> BTreeSet<String> {
231 let mut s = BTreeSet::new();
232 for d in &self.defs {
233 for e in &d.depends {
234 e.symbols(&mut s);
235 }
236 }
237 s
238 }
239
240 pub fn select_targets(&self) -> BTreeSet<String> {
241 self.defs
242 .iter()
243 .flat_map(|d| d.selects.iter())
244 .map(|e| e.target.clone())
245 .collect()
246 }
247
248 pub fn imply_targets(&self) -> BTreeSet<String> {
249 self.defs
250 .iter()
251 .flat_map(|d| d.implies.iter())
252 .map(|e| e.target.clone())
253 .collect()
254 }
255}
256
257/// El árbol de Kconfig parseado, con los índices inversos ya construidos.
258#[derive(Debug, Default)]
259pub struct KconfigTree {
260 pub root: PathBuf,
261 pub symbols: BTreeMap<String, Symbol>,
262 /// Ficheros efectivamente leídos (relativos a la raíz).
263 pub files: Vec<String>,
264 /// Versión que el árbol declara de sí mismo (`VERSION.PATCHLEVEL.SUBLEVEL` del `Makefile` de
265 /// arriba). `None` si no se extrajo el `Makefile`.
266 ///
267 /// No es cosmética: **las recetas de kernel del repo no son todas la misma versión** (`linux` y
268 /// `linux-metal` van por 6.16.12; `linux-metal-dual` y `linux-generic` por 7.1.2). Planear una
269 /// contra el árbol de la otra daría clausuras calculadas sobre símbolos que ahí no existen —
270 /// y saldría sin ruido, que es el modo de fallo que este campo existe para impedir.
271 pub version: Option<String>,
272 /// Todo lo que no se pudo interpretar, contado en vez de abortado.
273 pub warnings: Vec<String>,
274 /// `Y → {X : X depende DURO de Y}`. Apagar `Y` deja a cada `X` inalcanzable.
275 rev_hard_deps: BTreeMap<String, BTreeSet<String>>,
276 /// `Y → {X : X hace `select Y`}`. Encender cualquier `X` fuerza `Y` **ignorando** sus
277 /// `depends on` — la semántica que rompe la clausura ingenua (§3 del SDD 22).
278 rev_selects: BTreeMap<String, BTreeSet<String>>,
279}
280
281impl KconfigTree {
282 /// Parsea el árbol entero desde `<root>/Kconfig`, resolviendo los `source` recursivamente.
283 ///
284 /// `vars` alimenta la sustitución de `$(SRCARCH)` y compañía en las rutas de `source`. Para
285 /// x86_64: `SRCARCH=x86`, `ARCH=x86_64`.
286 pub fn parse(root: &Path, vars: &BTreeMap<String, String>) -> std::io::Result<KconfigTree> {
287 let mut tree = KconfigTree {
288 root: root.to_path_buf(),
289 ..Default::default()
290 };
291 let mut visited: HashSet<String> = HashSet::new();
292 let mut parser = Parser {
293 tree: &mut tree,
294 vars,
295 visited: &mut visited,
296 };
297 parser.parse_file("Kconfig", &[])?;
298 tree.version = read_makefile_version(root);
299 tree.build_indices();
300 Ok(tree)
301 }
302
303 /// Variables por defecto para un análisis de x86_64.
304 pub fn vars_x86_64() -> BTreeMap<String, String> {
305 BTreeMap::from([
306 ("SRCARCH".into(), "x86".into()),
307 ("ARCH".into(), "x86_64".into()),
308 ("HEADER_ARCH".into(), "x86".into()),
309 ])
310 }
311
312 fn build_indices(&mut self) {
313 let mut rev_hard: BTreeMap<String, BTreeSet<String>> = BTreeMap::new();
314 let mut rev_sel: BTreeMap<String, BTreeSet<String>> = BTreeMap::new();
315 for (name, sym) in &self.symbols {
316 for dep in sym.hard_deps() {
317 rev_hard.entry(dep).or_default().insert(name.clone());
318 }
319 for t in sym.select_targets() {
320 rev_sel.entry(t).or_default().insert(name.clone());
321 }
322 }
323 self.rev_hard_deps = rev_hard;
324 self.rev_selects = rev_sel;
325 }
326
327 /// Símbolos que dependen DURO de `sym`, en un solo salto.
328 pub fn dependents_of(&self, sym: &str) -> &BTreeSet<String> {
329 static EMPTY: std::sync::OnceLock<BTreeSet<String>> = std::sync::OnceLock::new();
330 self.rev_hard_deps
331 .get(sym)
332 .unwrap_or_else(|| EMPTY.get_or_init(BTreeSet::new))
333 }
334
335 /// Símbolos que hacen `select sym`, en un solo salto.
336 pub fn selectors_of(&self, sym: &str) -> &BTreeSet<String> {
337 static EMPTY: std::sync::OnceLock<BTreeSet<String>> = std::sync::OnceLock::new();
338 self.rev_selects
339 .get(sym)
340 .unwrap_or_else(|| EMPTY.get_or_init(BTreeSet::new))
341 }
342
343 /// **La clausura**: todo lo que se vuelve **inalcanzable** si se apagan `roots`.
344 ///
345 /// Ojo con la palabra: *inalcanzable*, no *apagado ahora*. Son dos preguntas distintas y
346 /// confundirlas es el error natural (ver la nota de abajo). Ésta contesta «¿puede este símbolo
347 /// volver a encenderse alguna vez?», que es la que define un bundle.
348 ///
349 /// Dos mecanismos, ambos necesarios (medidos contra un `olddefconfig` real, SDD 22 §12):
350 /// 1. **Cierre transitivo de `depends on` en inversa** — si muere de quien dependo, muero.
351 /// 2. **Huérfanos de `select`** — un símbolo **sin prompt** no se puede marcar a mano: sólo
352 /// entra por `select`. Si TODOS los que lo seleccionan caen, él también, aunque nadie
353 /// dependa de él. Sin esta regla la clausura se dejaba fuera cosas como `ACPI_NHLT`.
354 ///
355 /// Se exige **al menos un selector**: un símbolo sin prompt y sin selectores entra por otra vía
356 /// (un `default`), y darlo por muerto mataría media tabla de golpe.
357 ///
358 /// # Lo que deliberadamente NO incluye
359 /// Un símbolo con selectores **fuera** de la clausura sigue vivo aunque en el `.config` de hoy
360 /// quede apagado: sólo se quedó sin usuarios *en esta configuración*. `CRYPTO_LIB_ARC4` se
361 /// apaga al quitar el wifi, pero vuelve con `PPP_MPPE`. Meterlo en la clausura sería afirmar
362 /// que el bundle lo mata, y no es cierto.
363 pub fn closure_off(&self, roots: &[String]) -> BTreeSet<String> {
364 let mut seen: BTreeSet<String> = BTreeSet::new();
365 let mut queue: Vec<String> = Vec::new();
366 for r in roots {
367 if seen.insert(r.clone()) {
368 queue.push(r.clone());
369 }
370 }
371 loop {
372 while let Some(cur) = queue.pop() {
373 for d in self.dependents_of(&cur) {
374 if seen.insert(d.clone()) {
375 queue.push(d.clone());
376 }
377 }
378 }
379 // Huérfanos de `select`. Va en un bucle exterior porque matar a uno puede dejar
380 // huérfano al siguiente, y cada nuevo muerto reabre el cierre por `depends on`.
381 let mut nuevos: Vec<String> = Vec::new();
382 for (name, sym) in &self.symbols {
383 if seen.contains(name) || sym.is_visible() {
384 continue;
385 }
386 let sels = self.selectors_of(name);
387 if sels.is_empty() || !sels.iter().all(|s| seen.contains(s)) {
388 continue;
389 }
390 nuevos.push(name.clone());
391 }
392 if nuevos.is_empty() {
393 return seen;
394 }
395 for n in nuevos {
396 if seen.insert(n.clone()) {
397 queue.push(n);
398 }
399 }
400 }
401 }
402
403 /// Cierra la clausura contra sus propias fugas: apaga también a cada `selector` y repite.
404 ///
405 /// **No se aplica solo — se reporta.** Es la mitad del §3 del SDD 22 que faltaba: la clausura
406 /// por `depends on` sub-reporta (deja fugas) y el punto fijo puede sobre-reportar (para cerrar
407 /// «sin audio» hay que tragarse drivers gráficos, porque hacen `select` del códec HDMI). El
408 /// valor de `added` es el precio del bundle, y quien lo paga es una persona, no el algoritmo.
409 ///
410 /// `max_rounds` acota una divergencia patológica; en 6.16.12 los bundles reales cierran en ≤3.
411 pub fn closure_off_fixpoint(&self, roots: &[String], max_rounds: usize) -> Fixpoint {
412 let mut current: Vec<String> = roots.to_vec();
413 let mut added: Vec<(usize, String)> = Vec::new();
414 let mut known: BTreeSet<String> = roots.iter().cloned().collect();
415 let mut rounds = 0usize;
416 loop {
417 let cl = self.closure_off(¤t);
418 let leaks = self.select_leaks(&cl);
419 if leaks.is_empty() || rounds >= max_rounds {
420 return Fixpoint {
421 closure: cl,
422 added,
423 rounds,
424 remaining_leaks: leaks,
425 };
426 }
427 rounds += 1;
428 for l in &leaks {
429 if known.insert(l.selector.clone()) {
430 added.push((rounds, l.selector.clone()));
431 current.push(l.selector.clone());
432 }
433 }
434 }
435 }
436
437 /// Los **portillos** de una clausura: aristas `select` que entran desde fuera.
438 ///
439 /// Ésta es la objeción del §3 del SDD 22 vuelta medición. `select` fuerza el destino
440 /// **ignorando sus `depends on`**, así que un símbolo de fuera del bundle puede reencender algo
441 /// que el bundle apagó. Si esta lista está vacía, la clausura es cerrada de verdad; si no, cada
442 /// entrada es un símbolo que hay que apagar también o aceptar como fuga consciente.
443 pub fn select_leaks(&self, closure: &BTreeSet<String>) -> Vec<SelectLeak> {
444 let mut out = Vec::new();
445 for target in closure {
446 for selector in self.selectors_of(target) {
447 if !closure.contains(selector) {
448 out.push(SelectLeak {
449 selector: selector.clone(),
450 target: target.clone(),
451 });
452 }
453 }
454 }
455 out.sort();
456 out.dedup();
457 out
458 }
459}
460
461/// Resultado de cerrar una clausura contra sus propias fugas por `select`.
462#[derive(Debug, Clone, Serialize, Deserialize)]
463pub struct Fixpoint {
464 /// La clausura ya cerrada (o lo que se alcanzó al agotar las rondas).
465 pub closure: BTreeSet<String>,
466 /// Símbolos que hubo que apagar ADEMÁS de las raíces, con la ronda en que aparecieron.
467 /// Es el coste real del bundle, y lo que un humano tiene que aprobar.
468 pub added: Vec<(usize, String)>,
469 pub rounds: usize,
470 /// Fugas que quedan si se agotaron las rondas. Vacío = cerró de verdad.
471 pub remaining_leaks: Vec<SelectLeak>,
472}
473
474/// Un `select` que entra a la clausura desde fuera: `selector` reenciende `target`.
475#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)]
476pub struct SelectLeak {
477 pub selector: String,
478 pub target: String,
479}
480
481// ---------------------------------------------------------------------------------------------
482// Parser
483// ---------------------------------------------------------------------------------------------
484
485struct Parser<'a> {
486 tree: &'a mut KconfigTree,
487 vars: &'a BTreeMap<String, String>,
488 visited: &'a mut HashSet<String>,
489}
490
491/// Estado del bloque que se está leyendo dentro de un fichero.
492enum Cur {
493 None,
494 /// Índice de la definición abierta, en `symbols[name].defs`.
495 Sym {
496 name: String,
497 idx: usize,
498 },
499}
500
501impl Parser<'_> {
502 /// `inherited` son los `depends on` de los `menu`/`if`/`choice` que envuelven a este fichero.
503 fn parse_file(&mut self, rel: &str, inherited: &[Expr]) -> std::io::Result<()> {
504 if !self.visited.insert(rel.to_string()) {
505 // Un fichero sourceado dos veces se lee una sola. En mainline sólo ocurre con
506 // ficheros sin `depends on` envolvente, así que no se pierde contexto; se anota igual
507 // para que un cambio de upstream no pase callado.
508 self.tree
509 .warnings
510 .push(format!("{rel}: sourceado más de una vez, se lee una sola"));
511 return Ok(());
512 }
513 let path = self.tree.root.join(rel);
514 let Ok(raw) = std::fs::read_to_string(&path) else {
515 self.tree
516 .warnings
517 .push(format!("{rel}: no se pudo leer, se omite"));
518 return Ok(());
519 };
520 self.tree.files.push(rel.to_string());
521
522 let lines = join_continuations(&raw);
523 // Contextos abiertos (menu / if / choice), cada uno con su condición opcional.
524 let mut ctx: Vec<Option<Expr>> = Vec::new();
525 let mut cur = Cur::None;
526 let mut i = 0usize;
527
528 while i < lines.len() {
529 let (lineno, raw_line) = &lines[i];
530 i += 1;
531 let line = strip_comment(raw_line);
532 let t = line.trim();
533 if t.is_empty() {
534 continue;
535 }
536 let (kw, rest) = split_keyword(t);
537
538 match kw {
539 "config" | "menuconfig" => {
540 let name = rest.trim().to_string();
541 if name.is_empty() {
542 continue;
543 }
544 let mut depends: Vec<Expr> = inherited.to_vec();
545 depends.extend(ctx.iter().flatten().cloned());
546 let def = SymbolDef {
547 kind: SymKind::Unknown,
548 prompt: None,
549 depends,
550 selects: Vec::new(),
551 implies: Vec::new(),
552 help: None,
553 loc: Location {
554 file: rel.to_string(),
555 line: *lineno,
556 },
557 };
558 let sym = self
559 .tree
560 .symbols
561 .entry(name.clone())
562 .or_insert_with(|| Symbol {
563 name: name.clone(),
564 defs: Vec::new(),
565 });
566 sym.defs.push(def);
567 let idx = sym.defs.len() - 1;
568 cur = Cur::Sym { name, idx };
569 }
570 "menu" | "choice" => {
571 ctx.push(None);
572 cur = Cur::None;
573 }
574 "endmenu" | "endchoice" => {
575 ctx.pop();
576 cur = Cur::None;
577 }
578 "if" => {
579 ctx.push(parse_expr_tolerant(rest, rel, *lineno, &mut self.tree.warnings));
580 cur = Cur::None;
581 }
582 "endif" => {
583 ctx.pop();
584 cur = Cur::None;
585 }
586 "source" => {
587 cur = Cur::None;
588 let Some(target) = self.resolve_source(rest, rel, *lineno) else {
589 continue;
590 };
591 let mut inh: Vec<Expr> = inherited.to_vec();
592 inh.extend(ctx.iter().flatten().cloned());
593 self.parse_file(&target, &inh)?;
594 }
595 "comment" | "mainmenu" => {
596 cur = Cur::None;
597 }
598 "help" | "---help---" => {
599 // El bloque de ayuda va hasta la primera línea no vacía menos indentada que
600 // la primera línea de ayuda.
601 let (text, next) = take_help(&lines, i);
602 i = next;
603 if let Cur::Sym { name, idx } = &cur {
604 if let Some(s) = self.tree.symbols.get_mut(name) {
605 s.defs[*idx].help = Some(text);
606 }
607 }
608 }
609 "depends" => {
610 // `depends on X` — el `on` es parte de la palabra clave, no de la expresión.
611 let rest = rest.trim_start();
612 let rest = rest.strip_prefix("on").unwrap_or(rest);
613 let e = parse_expr_tolerant(rest, rel, *lineno, &mut self.tree.warnings);
614 match &cur {
615 Cur::Sym { name, idx } => {
616 if let (Some(e), Some(s)) = (e, self.tree.symbols.get_mut(name)) {
617 s.defs[*idx].depends.push(e);
618 }
619 }
620 // `depends on` suelto tras `menu`/`choice`: aplica al contexto abierto.
621 Cur::None => {
622 if let Some(slot) = ctx.last_mut() {
623 *slot = match (slot.take(), e) {
624 (Some(a), Some(b)) => Some(Expr::And(Box::new(a), Box::new(b))),
625 (a, b) => a.or(b),
626 };
627 }
628 }
629 }
630 }
631 "select" | "imply" => {
632 let Cur::Sym { name, idx } = &cur else { continue };
633 let (target, cond) = split_if(rest);
634 let target = target.trim().to_string();
635 if target.is_empty() {
636 continue;
637 }
638 let cond = cond.and_then(|c| {
639 parse_expr_tolerant(c, rel, *lineno, &mut self.tree.warnings)
640 });
641 if let Some(s) = self.tree.symbols.get_mut(name) {
642 let edge = Edge { target, cond };
643 if kw == "select" {
644 s.defs[*idx].selects.push(edge);
645 } else {
646 s.defs[*idx].implies.push(edge);
647 }
648 }
649 }
650 "bool" | "tristate" | "int" | "hex" | "string" | "def_bool" | "def_tristate" => {
651 let Cur::Sym { name, idx } = &cur else { continue };
652 let kind = SymKind::from_keyword(kw).unwrap_or(SymKind::Unknown);
653 // `bool "prompt" if COND` — el `if` es visibilidad, no dependencia dura, pero
654 // sin prompt visible el símbolo tampoco es marcable; se guarda el texto y ya.
655 let (head, cond) = split_if(rest);
656 let prompt = extract_quoted(head);
657 // `def_bool COND` no lleva prompt: lo que sigue es el valor por defecto.
658 let is_def = kw.starts_with("def_");
659 if let Some(s) = self.tree.symbols.get_mut(name) {
660 let d = &mut s.defs[*idx];
661 if d.kind == SymKind::Unknown {
662 d.kind = kind;
663 }
664 if !is_def {
665 if let Some(p) = prompt {
666 d.prompt = Some(p);
667 }
668 }
669 // Una condición de visibilidad no restringe el valor: se ignora a
670 // propósito para no inventar dependencias duras que no existen.
671 let _ = cond;
672 }
673 }
674 "prompt" => {
675 let Cur::Sym { name, idx } = &cur else { continue };
676 let (head, _) = split_if(rest);
677 if let (Some(p), Some(s)) = (extract_quoted(head), self.tree.symbols.get_mut(name))
678 {
679 s.defs[*idx].prompt = Some(p);
680 }
681 }
682 // Atributos que no cambian la topología del grafo.
683 "default" | "range" | "option" | "optional" | "visible" | "modules"
684 | "requires" => {}
685 _ => {
686 // Asignaciones de macro (`comma := ,`) y demás: no son parte del grafo.
687 if !t.contains(":=") && !t.contains('=') && !t.starts_with("$(") {
688 self.tree
689 .warnings
690 .push(format!("{rel}:{lineno}: no entendí «{t}»"));
691 }
692 }
693 }
694 }
695 Ok(())
696 }
697
698 /// `source "ruta"` con sustitución de `$(VAR)`. Devuelve `None` si la ruta no se puede resolver.
699 fn resolve_source(&mut self, rest: &str, rel: &str, lineno: usize) -> Option<String> {
700 let raw = extract_quoted(rest).unwrap_or_else(|| rest.trim().to_string());
701 let mut out = String::new();
702 let b = raw.as_bytes();
703 let mut i = 0;
704 while i < b.len() {
705 if b[i] == b'$' && i + 1 < b.len() && b[i + 1] == b'(' {
706 let Some(close) = raw[i + 2..].find(')') else {
707 out.push('$');
708 i += 1;
709 continue;
710 };
711 let var = &raw[i + 2..i + 2 + close];
712 match self.vars.get(var) {
713 Some(v) => out.push_str(v),
714 None => {
715 self.tree.warnings.push(format!(
716 "{rel}:{lineno}: source con variable desconocida $({var}), se omite"
717 ));
718 return None;
719 }
720 }
721 i += 2 + close + 1;
722 } else {
723 out.push(raw.as_bytes()[i] as char);
724 i += 1;
725 }
726 }
727 if out.is_empty() {
728 return None;
729 }
730 Some(out)
731 }
732}
733
734/// `VERSION`/`PATCHLEVEL`/`SUBLEVEL` del `Makefile` de arriba → `"6.16.12"`.
735fn read_makefile_version(root: &Path) -> Option<String> {
736 let text = std::fs::read_to_string(root.join("Makefile")).ok()?;
737 let mut v = [None, None, None];
738 for line in text.lines().take(20) {
739 let Some((k, val)) = line.split_once('=') else {
740 continue;
741 };
742 let val = val.trim();
743 match k.trim() {
744 "VERSION" => v[0] = Some(val.to_string()),
745 "PATCHLEVEL" => v[1] = Some(val.to_string()),
746 "SUBLEVEL" => v[2] = Some(val.to_string()),
747 _ => {}
748 }
749 }
750 match v {
751 [Some(a), Some(b), Some(c)] => Some(format!("{a}.{b}.{c}")),
752 _ => None,
753 }
754}
755
756/// Une las líneas terminadas en `\` y devuelve `(número de línea original, texto)`.
757fn join_continuations(raw: &str) -> Vec<(usize, String)> {
758 let mut out: Vec<(usize, String)> = Vec::new();
759 let mut pending: Option<(usize, String)> = None;
760 for (n, line) in raw.lines().enumerate() {
761 let lineno = n + 1;
762 let (text, cont) = match line.strip_suffix('\\') {
763 Some(head) => (head.to_string(), true),
764 None => (line.to_string(), false),
765 };
766 match &mut pending {
767 Some((_, acc)) => {
768 acc.push(' ');
769 acc.push_str(text.trim_start());
770 }
771 None => pending = Some((lineno, text)),
772 }
773 if !cont {
774 out.push(pending.take().unwrap());
775 }
776 }
777 if let Some(p) = pending {
778 out.push(p);
779 }
780 out
781}
782
783/// Quita el comentario `#` que no esté dentro de comillas.
784fn strip_comment(line: &str) -> String {
785 let mut out = String::with_capacity(line.len());
786 let mut quote: Option<char> = None;
787 let mut prev_backslash = false;
788 for c in line.chars() {
789 match quote {
790 Some(q) => {
791 out.push(c);
792 if c == q && !prev_backslash {
793 quote = None;
794 }
795 }
796 None => {
797 if c == '#' {
798 break;
799 }
800 if c == '"' || c == '\'' {
801 quote = Some(c);
802 }
803 out.push(c);
804 }
805 }
806 prev_backslash = c == '\\' && !prev_backslash;
807 }
808 out
809}
810
811fn split_keyword(t: &str) -> (&str, &str) {
812 match t.find(|c: char| c.is_whitespace()) {
813 Some(p) => (&t[..p], &t[p..]),
814 None => (t, ""),
815 }
816}
817
818/// Separa `X if COND` en `("X", Some("COND"))`, respetando comillas.
819fn split_if(s: &str) -> (&str, Option<&str>) {
820 let b = s.as_bytes();
821 let mut quote = false;
822 let mut i = 0;
823 while i < b.len() {
824 if b[i] == b'"' {
825 quote = !quote;
826 } else if !quote
827 && b[i..].starts_with(b"if")
828 && (i == 0 || b[i - 1].is_ascii_whitespace())
829 && b.get(i + 2).is_none_or(|c| c.is_ascii_whitespace())
830 {
831 return (&s[..i], Some(&s[i + 2..]));
832 }
833 i += 1;
834 }
835 (s, None)
836}
837
838fn extract_quoted(s: &str) -> Option<String> {
839 let start = s.find('"')?;
840 let rest = &s[start + 1..];
841 let end = rest.find('"')?;
842 Some(rest[..end].to_string())
843}
844
845/// Indentación en **columnas**, con tabuladores a paradas de 8.
846///
847/// No es un detalle cosmético: los bloques de ayuda del kernel mezclan `\t ` (10 columnas) con
848/// `\t\t` (16). Contando caracteres, el segundo parece MENOS indentado que el primero (2 < 3) y el
849/// bloque de ayuda se corta a la mitad — con lo que 400 líneas de prosa se leen como sintaxis rota.
850fn indent_columns(l: &str) -> usize {
851 let mut col = 0usize;
852 for c in l.chars() {
853 match c {
854 '\t' => col = (col / 8 + 1) * 8,
855 ' ' => col += 1,
856 _ => break,
857 }
858 }
859 col
860}
861
862/// Consume el bloque de ayuda a partir del índice `start`; devuelve el texto y el índice siguiente.
863fn take_help(lines: &[(usize, String)], start: usize) -> (String, usize) {
864 let mut i = start;
865 let mut indent: Option<usize> = None;
866 let mut text = String::new();
867 while i < lines.len() {
868 let l = &lines[i].1;
869 if l.trim().is_empty() {
870 text.push('\n');
871 i += 1;
872 continue;
873 }
874 let ind = indent_columns(l);
875 match indent {
876 None => indent = Some(ind),
877 Some(base) if ind < base => break,
878 Some(_) => {}
879 }
880 text.push_str(l.trim());
881 text.push('\n');
882 i += 1;
883 }
884 // No consumir las líneas en blanco finales: pertenecen al bloque siguiente.
885 (text.trim().to_string(), i)
886}
887
888// ---------------------------------------------------------------------------------------------
889// Expresiones
890// ---------------------------------------------------------------------------------------------
891
892#[derive(Debug, Clone, PartialEq, Eq)]
893enum Tok {
894 LParen,
895 RParen,
896 Not,
897 And,
898 Or,
899 Op(CmpOp),
900 Word(String),
901 Quoted(String),
902 Macro(String),
903}
904
905fn tokenize(s: &str) -> Option<Vec<Tok>> {
906 let b: Vec<char> = s.chars().collect();
907 let mut out = Vec::new();
908 let mut i = 0;
909 while i < b.len() {
910 let c = b[i];
911 if c.is_whitespace() {
912 i += 1;
913 continue;
914 }
915 match c {
916 '(' => {
917 out.push(Tok::LParen);
918 i += 1;
919 }
920 ')' => {
921 out.push(Tok::RParen);
922 i += 1;
923 }
924 '!' => {
925 if b.get(i + 1) == Some(&'=') {
926 out.push(Tok::Op(CmpOp::Ne));
927 i += 2;
928 } else {
929 out.push(Tok::Not);
930 i += 1;
931 }
932 }
933 '&' => {
934 if b.get(i + 1) == Some(&'&') {
935 out.push(Tok::And);
936 i += 2;
937 } else {
938 return None;
939 }
940 }
941 '|' => {
942 if b.get(i + 1) == Some(&'|') {
943 out.push(Tok::Or);
944 i += 2;
945 } else {
946 return None;
947 }
948 }
949 '=' => {
950 out.push(Tok::Op(CmpOp::Eq));
951 i += 1;
952 }
953 '<' | '>' => {
954 let eq = b.get(i + 1) == Some(&'=');
955 let op = match (c, eq) {
956 ('<', false) => CmpOp::Lt,
957 ('<', true) => CmpOp::Le,
958 ('>', false) => CmpOp::Gt,
959 _ => CmpOp::Ge,
960 };
961 out.push(Tok::Op(op));
962 i += if eq { 2 } else { 1 };
963 }
964 '"' | '\'' => {
965 let q = c;
966 let mut val = String::new();
967 i += 1;
968 while i < b.len() && b[i] != q {
969 val.push(b[i]);
970 i += 1;
971 }
972 if i >= b.len() {
973 return None;
974 }
975 i += 1;
976 out.push(Tok::Quoted(val));
977 }
978 '$' => {
979 // Macro `$(...)`: se toma entera con paréntesis balanceados y queda opaca.
980 if b.get(i + 1) != Some(&'(') {
981 return None;
982 }
983 let mut depth = 0usize;
984 let start = i;
985 while i < b.len() {
986 if b[i] == '(' {
987 depth += 1;
988 } else if b[i] == ')' {
989 depth -= 1;
990 if depth == 0 {
991 i += 1;
992 break;
993 }
994 }
995 i += 1;
996 }
997 out.push(Tok::Macro(b[start..i].iter().collect()));
998 }
999 _ => {
1000 let start = i;
1001 while i < b.len()
1002 && (b[i].is_alphanumeric() || b[i] == '_' || b[i] == '-' || b[i] == '.')
1003 {
1004 i += 1;
1005 }
1006 if i == start {
1007 return None;
1008 }
1009 out.push(Tok::Word(b[start..i].iter().collect()));
1010 }
1011 }
1012 }
1013 Some(out)
1014}
1015
1016struct ExprParser {
1017 toks: Vec<Tok>,
1018 pos: usize,
1019}
1020
1021impl ExprParser {
1022 fn peek(&self) -> Option<&Tok> {
1023 self.toks.get(self.pos)
1024 }
1025 fn next(&mut self) -> Option<Tok> {
1026 let t = self.toks.get(self.pos).cloned();
1027 if t.is_some() {
1028 self.pos += 1;
1029 }
1030 t
1031 }
1032
1033 fn parse_or(&mut self) -> Option<Expr> {
1034 let mut lhs = self.parse_and()?;
1035 while self.peek() == Some(&Tok::Or) {
1036 self.next();
1037 let rhs = self.parse_and()?;
1038 lhs = Expr::Or(Box::new(lhs), Box::new(rhs));
1039 }
1040 Some(lhs)
1041 }
1042
1043 fn parse_and(&mut self) -> Option<Expr> {
1044 let mut lhs = self.parse_unary()?;
1045 while self.peek() == Some(&Tok::And) {
1046 self.next();
1047 let rhs = self.parse_unary()?;
1048 lhs = Expr::And(Box::new(lhs), Box::new(rhs));
1049 }
1050 Some(lhs)
1051 }
1052
1053 fn parse_unary(&mut self) -> Option<Expr> {
1054 if self.peek() == Some(&Tok::Not) {
1055 self.next();
1056 return Some(Expr::Not(Box::new(self.parse_unary()?)));
1057 }
1058 self.parse_atom()
1059 }
1060
1061 fn parse_atom(&mut self) -> Option<Expr> {
1062 if self.peek() == Some(&Tok::LParen) {
1063 self.next();
1064 let e = self.parse_or()?;
1065 if self.next() != Some(Tok::RParen) {
1066 return None;
1067 }
1068 return Some(e);
1069 }
1070 let lhs = self.parse_primary()?;
1071 if let Some(Tok::Op(op)) = self.peek().cloned() {
1072 self.next();
1073 let rhs = self.parse_primary()?;
1074 return Some(Expr::Cmp(Box::new(lhs), op, Box::new(rhs)));
1075 }
1076 Some(lhs)
1077 }
1078
1079 fn parse_primary(&mut self) -> Option<Expr> {
1080 match self.next()? {
1081 Tok::Word(w) => Some(if is_const_word(&w) {
1082 Expr::Const(w)
1083 } else {
1084 Expr::Sym(w)
1085 }),
1086 Tok::Quoted(q) => Some(Expr::Const(q)),
1087 Tok::Macro(m) => Some(Expr::Const(m)),
1088 _ => None,
1089 }
1090 }
1091}
1092
1093/// `y`/`n`/`m` y los literales numéricos son constantes, no símbolos.
1094fn is_const_word(w: &str) -> bool {
1095 matches!(w, "y" | "n" | "m")
1096 || w.chars().next().is_some_and(|c| c.is_ascii_digit())
1097}
1098
1099pub fn parse_expr(s: &str) -> Option<Expr> {
1100 let toks = tokenize(s)?;
1101 if toks.is_empty() {
1102 return None;
1103 }
1104 let mut p = ExprParser { toks, pos: 0 };
1105 let e = p.parse_or()?;
1106 if p.pos != p.toks.len() {
1107 return None;
1108 }
1109 Some(e)
1110}
1111
1112fn parse_expr_tolerant(
1113 s: &str,
1114 file: &str,
1115 line: usize,
1116 warnings: &mut Vec<String>,
1117) -> Option<Expr> {
1118 let t = s.trim();
1119 if t.is_empty() {
1120 return None;
1121 }
1122 match parse_expr(t) {
1123 Some(e) => Some(e),
1124 None => {
1125 warnings.push(format!("{file}:{line}: expresión no parseada «{t}»"));
1126 None
1127 }
1128 }
1129}
1130
1131#[cfg(test)]
1132mod tests {
1133 use super::*;
1134
1135 fn syms(e: &Expr) -> Vec<String> {
1136 let mut s = BTreeSet::new();
1137 e.symbols(&mut s);
1138 s.into_iter().collect()
1139 }
1140 fn hard(e: &Expr) -> Vec<String> {
1141 let mut s = BTreeSet::new();
1142 e.hard_symbols(&mut s);
1143 s.into_iter().collect()
1144 }
1145
1146 #[test]
1147 fn precedencia_y_duros() {
1148 let e = parse_expr("A && (B || C)").unwrap();
1149 assert_eq!(syms(&e), vec!["A", "B", "C"]);
1150 // Ésta es LA propiedad: en `A && (B||C)` sólo `A` mata la expresión.
1151 assert_eq!(hard(&e), vec!["A"]);
1152 }
1153
1154 #[test]
1155 fn negacion_no_es_dura() {
1156 let e = parse_expr("A && !B").unwrap();
1157 assert_eq!(hard(&e), vec!["A"]);
1158 }
1159
1160 #[test]
1161 fn comparacion_no_aporta_duros() {
1162 let e = parse_expr("GCC_VERSION >= 40800 && X").unwrap();
1163 assert_eq!(hard(&e), vec!["X"]);
1164 }
1165
1166 /// `depends on (FB = y) && ARM` es como medio `drivers/video/fbdev` declara que necesita `FB`.
1167 /// Tratar toda comparación como opaca dejaba esos drivers fuera de la clausura.
1168 #[test]
1169 fn comparar_contra_y_o_m_si_es_dura() {
1170 assert_eq!(hard(&parse_expr("(FB = y) && ARM").unwrap()), vec!["ARM", "FB"]);
1171 assert_eq!(hard(&parse_expr("FB = m").unwrap()), vec!["FB"]);
1172 assert_eq!(hard(&parse_expr("FB != n").unwrap()), vec!["FB"]);
1173 // `FB = n` es VERDADERA cuando FB vale n ⇒ no es una dependencia dura.
1174 assert!(hard(&parse_expr("FB = n").unwrap()).is_empty());
1175 // Y el orden de los operandos no cambia nada.
1176 assert_eq!(hard(&parse_expr("y = FB").unwrap()), vec!["FB"]);
1177 }
1178
1179 #[test]
1180 fn constantes_no_son_simbolos() {
1181 let e = parse_expr("A && y").unwrap();
1182 assert_eq!(syms(&e), vec!["A"]);
1183 }
1184
1185 #[test]
1186 fn macros_quedan_opacas() {
1187 let e = parse_expr("$(success,test -x /bin/sh) && A").unwrap();
1188 assert_eq!(syms(&e), vec!["A"]);
1189 }
1190
1191 #[test]
1192 fn split_if_respeta_comillas() {
1193 let (head, cond) = split_if(r#""Wi-Fi if you like" if WIRELESS"#);
1194 assert_eq!(head.trim(), r#""Wi-Fi if you like""#);
1195 assert_eq!(cond.unwrap().trim(), "WIRELESS");
1196 }
1197
1198 #[test]
1199 fn comentario_dentro_de_comillas_no_corta() {
1200 assert_eq!(strip_comment(r#"bool "a # b" # real"#).trim(), r#"bool "a # b""#);
1201 }
1202
1203 /// Árbol mínimo con la forma exacta que tiene el wifi en 6.16.12: `WLAN` **selecciona**
1204 /// `WIRELESS` en vez de depender de él, que es lo que hace fallar a la clausura ingenua.
1205 fn arbol_de_juguete() -> (tempfile::TempDir, KconfigTree) {
1206 let d = tempfile::tempdir().unwrap();
1207 std::fs::write(
1208 d.path().join("Kconfig"),
1209 r#"
1210config WIRELESS
1211 bool "Wireless"
1212
1213config CFG80211
1214 tristate "cfg80211"
1215 depends on WIRELESS
1216
1217config ATH10K
1218 tristate "ath10k"
1219 depends on CFG80211
1220
1221config WLAN
1222 bool "WLAN drivers"
1223 select WIRELESS
1224
1225config UNRELATED
1226 bool "Otra cosa"
1227 depends on PCI
1228"#,
1229 )
1230 .unwrap();
1231 let t = KconfigTree::parse(d.path(), &BTreeMap::new()).unwrap();
1232 (d, t)
1233 }
1234
1235 #[test]
1236 fn clausura_transitiva_por_depends() {
1237 let (_d, t) = arbol_de_juguete();
1238 let cl = t.closure_off(&["WIRELESS".into()]);
1239 // Transitivo: WIRELESS → CFG80211 → ATH10K.
1240 assert!(cl.contains("CFG80211") && cl.contains("ATH10K"));
1241 assert!(!cl.contains("UNRELATED"));
1242 // Y el hallazgo real de 6.16.12: WLAN NO cae en la clausura, porque no depende — selecciona.
1243 assert!(!cl.contains("WLAN"));
1244 }
1245
1246 #[test]
1247 fn la_fuga_por_select_se_detecta() {
1248 let (_d, t) = arbol_de_juguete();
1249 let cl = t.closure_off(&["WIRELESS".into()]);
1250 let leaks = t.select_leaks(&cl);
1251 assert_eq!(
1252 leaks,
1253 vec![SelectLeak {
1254 selector: "WLAN".into(),
1255 target: "WIRELESS".into()
1256 }]
1257 );
1258 }
1259
1260 /// El caso `ACPI_NHLT`, encontrado corriendo un `olddefconfig` de verdad (SDD 22 §12).
1261 #[test]
1262 fn el_huerfano_de_select_tambien_muere() {
1263 let d = tempfile::tempdir().unwrap();
1264 std::fs::write(
1265 d.path().join("Kconfig"),
1266 r#"
1267config WIRELESS
1268 bool "Wireless"
1269
1270config CFG80211
1271 tristate "cfg80211"
1272 depends on WIRELESS
1273 select HELPER_SOLO_DE_WIFI
1274 select HELPER_COMPARTIDO
1275
1276config PPP
1277 bool "PPP"
1278 select HELPER_COMPARTIDO
1279
1280# Sin prompt: NO se puede marcar a mano, sólo entra por select.
1281config HELPER_SOLO_DE_WIFI
1282 bool
1283
1284config HELPER_COMPARTIDO
1285 bool
1286
1287# Sin prompt y sin selectores: entra por otra vía (un default). No se toca.
1288config HELPER_HUERFANO_DE_NACIMIENTO
1289 bool
1290"#,
1291 )
1292 .unwrap();
1293 let t = KconfigTree::parse(d.path(), &BTreeMap::new()).unwrap();
1294 let cl = t.closure_off(&["WIRELESS".into()]);
1295
1296 // Su único selector (CFG80211) cae ⇒ no puede volver a encenderse nunca.
1297 assert!(cl.contains("HELPER_SOLO_DE_WIFI"));
1298 // Lo selecciona también PPP, que sigue vivo ⇒ NO es del bundle, aunque en un `.config`
1299 // concreto pueda quedar apagado por falta de usuarios. Meterlo sería afirmar que el bundle
1300 // lo mata, y no es cierto.
1301 assert!(!cl.contains("HELPER_COMPARTIDO"));
1302 // Sin selectores no se deduce nada: darlo por muerto mataría media tabla.
1303 assert!(!cl.contains("HELPER_HUERFANO_DE_NACIMIENTO"));
1304 }
1305
1306 #[test]
1307 fn el_punto_fijo_absorbe_al_selector() {
1308 let (_d, t) = arbol_de_juguete();
1309 let fx = t.closure_off_fixpoint(&["WIRELESS".into()], 8);
1310 assert!(fx.remaining_leaks.is_empty(), "tenía que cerrar");
1311 assert!(fx.closure.contains("WLAN"));
1312 assert_eq!(fx.added, vec![(1, "WLAN".to_string())]);
1313 }
1314
1315 #[test]
1316 fn hard_deps_es_interseccion_entre_definiciones() {
1317 // Un símbolo con dos definiciones: apagar `B` no lo mata porque la segunda no lo pide.
1318 let sym = Symbol {
1319 name: "X".into(),
1320 defs: vec![
1321 SymbolDef {
1322 kind: SymKind::Bool,
1323 prompt: None,
1324 depends: vec![parse_expr("A && B").unwrap()],
1325 selects: vec![],
1326 implies: vec![],
1327 help: None,
1328 loc: Location { file: "a".into(), line: 1 },
1329 },
1330 SymbolDef {
1331 kind: SymKind::Bool,
1332 prompt: None,
1333 depends: vec![parse_expr("A").unwrap()],
1334 selects: vec![],
1335 implies: vec![],
1336 help: None,
1337 loc: Location { file: "b".into(), line: 1 },
1338 },
1339 ],
1340 };
1341 assert_eq!(
1342 sym.hard_deps().into_iter().collect::<Vec<_>>(),
1343 vec!["A".to_string()]
1344 );
1345 }
1346}