tawasuyu

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…

TOML 1534Shell 199Rust 109Markdown 993713 Definiciones

⎇ ca9bde0251d05fbfd34c93033d1aff2eb2ee62b5 crates/hammer-core/src/kernel/kconfig.rs Rust · 1346 líneas · 47.4 KB Historia Quién tocó qué crudo

kernel: la clausura contrastada contra un olddefconfig de verdad — 65/68 con 0 falsos positivos ca9bde025 · Sergio ·

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(&current);
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}