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…
el frente kmod no existe: los kernels de takana no tienen módulos
1#!/usr/bin/env python3
2"""Vigía del recorte de busybox (docs/plan-botar-busybox.md, paso 1).
3
4Falla si alguna receta, script o card del repo INVOCA un applet que la receta de busybox
5ya no construye. Sin esto el recorte se deshace solo: nada falla al quitar un applet —
6el fallo aparece en boot, en otra máquina, semanas después.
7
8 scripts/busybox-vigia.py # exit 1 si hay invocaciones de applets retirados
9 scripts/busybox-vigia.py --list # imprime los applets retirados y sale 0
10 scripts/busybox-vigia.py --uso # quién invoca los applets que SÓLO provee busybox
11
12`--uso` responde la pregunta que el plan dejó abierta tres veces: **de lo que todavía sólo da
13busybox, ¿qué usa el producto de verdad?** Lee los applets con `proveedor_medido = SOLO-BUSYBOX`
14del TSV y los busca en el repo con las mismas cuatro reglas anti-ruido del vigía. Lo que no invoca
15nadie es candidato a salir del defconfig sin escribir ni migrar nada; lo que sí, es una migración
16con nombre y número de línea.
17
18⚠ Mira ESTE repo, que es donde vive el producto (cards, guiones de imagen, instalador). NO ve los
19`configure` de terceros que corren dentro del sandbox de build: para ésos el consumidor es el
20frente A, y el criterio es otro.
21
22La lista de retirados se LEE de recipes/busybox.toml (los `# CONFIG_X is not set` que el
23recorte inyecta), cruzada con el mapa applet->símbolo de docs/state/busybox-applets.tsv.
24Así no hay dos listas que se desincronicen.
25"""
26import re, sys, pathlib, json
27
28RAIZ = pathlib.Path(__file__).resolve().parent.parent
29EXT = {'.sh', '.rs', '.toml', '.py'}
30SALTAR = ('target/', '.git/', 'store/', 'work/', 'docs/')
31
32# Un applet en posición de COMANDO: inicio de línea, o tras | ; & ` $( exec sudo && ||
33PRE = r'(?:^|[|;&`]|\$\(|\bexec\s+|\bsudo\s+|&&\s*|\|\|\s*)[ \t]*'
34POST = r'(?=[ \t;|&)`"\']|$)'
35
36
37def simbolos_retirados():
38 """Los símbolos Kconfig que la receta apaga: la lista RETIRADOS de su fase configure,
39 más cualquier `# CONFIG_X is not set` suelto (el recorte de `tc`, por ejemplo)."""
40 txt = (RAIZ / 'recipes' / 'busybox.toml').read_text()
41 syms = set(re.findall(r'#\s*CONFIG_([A-Z0-9_]+)\s+is\s+not\s+set', txt))
42 for lista in re.findall(r"RETIRADOS='([^']*)'", txt):
43 syms |= set(lista.split())
44 return syms
45
46
47def applets_retirados():
48 """Applets cuyo símbolo Kconfig la receta apaga.
49
50 El mapa applet->símbolo NO se deriva por mayúsculas: sale de la columna
51 `simbolo_kconfig` del TSV, extraída de las líneas `//applet:` del fuente de busybox.
52 Derivarlo estaría MAL en 6 casos medidos: `[`->TEST1, `[[`->TEST2, arch->BB_ARCH,
53 nbd-client->NBDCLIENT, sh->SH_IS_ASH, sysctl->BB_SYSCTL."""
54 syms = simbolos_retirados()
55 if not syms:
56 return set()
57 tsv = RAIZ / 'docs' / 'state' / 'busybox-applets.tsv'
58 filas = [l.split('\t') for l in tsv.read_text().splitlines()[1:] if l.strip()]
59 return {f[0] for f in filas if len(f) > 5 and f[5] in syms}
60
61
62# Palabras del shell, no applets invocables: `[` y `[[` aparecen en todo script bash del HUB
63# (que corre con el bash del hub, no con busybox) y no son llamadas a un applet.
64NO_VIGILAR = {'[', '[['}
65
66
67# Una cadena de Rust/Python sólo se mira si PARECE una línea de comandos. Sin esto, los literales
68# sueltos ("init" como nombre de capacidad, "tree" como variable) ahogan al vigía: 91 hits, 0 reales.
69PARECE_COMANDO = re.compile(r'(?:\bsh\s+-c\b|/bin/|/usr/bin/|/sbin/|&&|\|\||\$\()')
70
71
72def sin_comentarios(txt, suf):
73 """Quita comentarios y, en los lenguajes que no son shell, deja sólo las cadenas que
74 parecen una línea de comandos. Es el 90 % del ruido."""
75 if suf in ('.sh', '.toml'):
76 return re.sub(r'(?m)(?<!\\)#.*$', '', txt)
77 if suf in ('.rs', '.py'):
78 if suf == '.rs':
79 txt = re.sub(r'(?m)//.*$', '', txt)
80 else:
81 txt = re.sub(r'(?m)(?<!\\)#.*$', '', txt)
82 cad = re.findall(r'"((?:[^"\\\n]|\\.)*)"', txt) + re.findall(r"'((?:[^'\\\n]|\\.)*)'", txt)
83 return '\n'.join(c for c in cad if PARECE_COMANDO.search(c))
84 return txt
85
86
87def funciones_definidas(txt):
88 """Nombres definidos como función de shell EN ESE fichero: `ts() {`, `function ts {`."""
89 return set(re.findall(r'(?m)^\s*(?:function\s+)?([A-Za-z_][A-Za-z0-9_-]*)\s*\(\)\s*\{', txt)) | \
90 set(re.findall(r'(?m)^\s*function\s+([A-Za-z_][A-Za-z0-9_-]*)\s*\{', txt))
91
92
93def solo_busybox():
94 """Applets vivos cuyo `proveedor_medido` (del censo) dice que no los provee nadie más."""
95 tsv = (RAIZ / 'docs' / 'state' / 'busybox-applets.tsv').read_text().splitlines()
96 cab = tsv[0].split('\t')
97 i_ap, i_prov = cab.index('applet'), cab.index('proveedor_medido')
98 i_rec = cab.index('recorte_2026_09_21')
99 out = set()
100 for ln in tsv[1:]:
101 c = ln.split('\t')
102 if len(c) > max(i_ap, i_prov, i_rec) and c[i_rec] != 'RETIRADO' and c[i_prov] == 'SOLO-BUSYBOX':
103 out.add(c[i_ap])
104 return out
105
106
107def escanear(applets):
108 """[(fichero, línea, applet, texto)] de cada invocación en el repo.
109
110 Busca TRES formas, y las tres hacen falta — con sólo la primera este modo dio dos falsos
111 negativos MEDIDOS, y un falso negativo acá significa retirar un applet que el producto usa:
112 1. nombre desnudo en posición de comando `killall foo`
113 2. RUTA ABSOLUTA `exec /usr/bin/setuidgid pacha` — 3 recetas
114 3. primer elemento de un `argv` JSON `"argv": ["getty", "-n", ...]` — la card de la
115 consola, takana-bootstrap/src/lib.rs:241
116 La 2 se escapaba porque el patrón de comando pide el nombre pegado al `exec` y ahí hay un
117 `/usr/bin/` en medio; la 3 porque una cadena JSON suelta no parece una línea de comandos.
118 """
119 alt = '|'.join(re.escape(a) for a in sorted(applets, key=len, reverse=True))
120 rx = re.compile(PRE + '(' + alt + ')' + POST, re.M)
121 rx_ruta = re.compile(r'(?:/usr/bin/|/bin/|/usr/sbin/|/sbin/)(' + alt + r')\b')
122 rx_argv = re.compile(r'(?:argv|exec)["\s:=]+\[?\s*"(' + alt + r')"')
123 hits = []
124 for f in RAIZ.rglob('*'):
125 if not f.is_file() or f.suffix not in EXT:
126 continue
127 rel = str(f.relative_to(RAIZ))
128 if any(rel.startswith(s) or ('/' + s) in rel for s in SALTAR):
129 continue
130 # El propio vigía nombra applets en su documentación y en sus regex: se salta a sí mismo,
131 # o se denuncia en cada corrida y el ruido lo vuelve inservible.
132 if f.resolve() == pathlib.Path(__file__).resolve():
133 continue
134 try:
135 crudo = f.read_text(errors='ignore')
136 except OSError:
137 continue
138 if len(crudo) > 900_000:
139 continue
140 propias = funciones_definidas(crudo)
141 txt = sin_comentarios(crudo, f.suffix)
142 lineas = None
143 # Las formas 2 y 3 se buscan sobre el texto sin el filtro de «parece una línea de comandos»:
144 # una ruta absoluta y un `argv` ya son invocaciones por sí mismas.
145 if f.suffix == '.rs':
146 crudo_sc = re.sub(r'(?m)//.*$', '', crudo)
147 elif f.suffix in ('.sh', '.toml', '.py'):
148 crudo_sc = re.sub(r'(?m)(?<!\\)#.*$', '', crudo)
149 else:
150 crudo_sc = crudo
151 sc_lineas = crudo_sc.splitlines()
152 for m2 in list(rx_ruta.finditer(crudo_sc)) + list(rx_argv.finditer(crudo_sc)):
153 n = crudo_sc[:m2.start()].count('\n') + 1
154 hits.append((rel, n, m2.group(1), sc_lineas[n - 1].strip()[:100] if n <= len(sc_lineas) else ''))
155 for m in rx.finditer(txt):
156 ap = m.group(1)
157 if ap in propias:
158 continue
159 antes, despues = txt[:m.start(1)].rstrip(), txt[m.end(1):m.end(1) + 1]
160 if antes.endswith('|') and despues == ')':
161 continue
162 ini = txt.rfind('\n', 0, m.start(1)) + 1
163 fin = txt.find('\n', m.end(1))
164 if re.search(r'command\s+-v\s+' + re.escape(ap) + r'\b', txt[ini:fin if fin > 0 else len(txt)]):
165 continue
166 if lineas is None:
167 lineas = txt.splitlines()
168 n = txt[:m.start()].count('\n') + 1
169 hits.append((rel, n, ap, lineas[n - 1].strip()[:100]))
170 return hits
171
172
173def modo_uso():
174 applets = solo_busybox() - NO_VIGILAR
175 if not applets:
176 print('busybox-vigia --uso: el censo no marca ningún applet como SOLO-BUSYBOX')
177 return 0
178 hits = escanear(applets)
179 usados = {}
180 for rel, n, ap, linea in hits:
181 usados.setdefault(ap, []).append((rel, n, linea))
182 libres = sorted(applets - set(usados))
183 print(f'busybox-vigia --uso: {len(applets)} applets que hoy SÓLO provee busybox\n')
184 print(f'── {len(usados)} INVOCADOS en el repo — cada uno es una migración, no un borrado ──')
185 for ap in sorted(usados):
186 print(f' {ap}')
187 for rel, n, linea in usados[ap][:4]:
188 print(f' {rel}:{n} {linea}')
189 if len(usados[ap]) > 4:
190 print(f' … y {len(usados[ap]) - 4} más')
191 print(f'\n── {len(libres)} SIN NINGUNA invocación — candidatos al defconfig ──')
192 print(' ' + ' '.join(libres))
193 print()
194 print('\u26a0 \u00abCandidato\u00bb NO es \u00abretirable\u00bb. Un grep ve a quien INVOCA por nombre, y hay dos clases de')
195 print(' consumidor que no aparecen NUNCA y que rompen el producto si se les quita el applet:')
196 print(' \u00b7 el KERNEL — puede llamar a un helper de userspace sin que ningún grep lo vea.')
197 print(' ⚠ Acá NO: los cuatro kernels de takana traen `# CONFIG_MODULES is not set`, CERO')
198 print(' drivers `=m` y ninguna receta hace `modules_install` (medido 2026-09-22). Así que')
199 print(' modprobe/lsmod/depmod/rmmod/modinfo NO tienen consumidor, y con DEVTMPFS_MOUNT=y y')
200 print(' sin UEVENT_HELPER tampoco lo tienen mdev/makedevs/uevent. La advertencia sigue')
201 print(' valiendo como forma —un helper del kernel no aparece en un grep—, pero en ESTE')
202 print(' producto esos nueve son retirables, no escritura pendiente.')
203 print(' \u00b7 el USUARIO de la máquina — clear, reset, vi, traceroute no los invoca ningún guión del')
204 print(' repo y son la CLI que alguien espera encontrar al entrar por ssh.')
205 print(' Lo que esta lista sí dice es cuáles no tienen consumidor DENTRO del producto: es el primer')
206 print(' filtro, no el último.')
207 return 0
208
209
210def main():
211 if '--uso' in sys.argv:
212 return modo_uso()
213 retirados = applets_retirados() - NO_VIGILAR
214 if '--list' in sys.argv:
215 print('\n'.join(sorted(retirados)) or '(la receta no retira ningún applet)')
216 return 0
217 if not retirados:
218 print('busybox-vigia: la receta no retira ningún applet — nada que vigilar')
219 return 0
220 rx = re.compile(PRE + '(' + '|'.join(re.escape(a) for a in
221 sorted(retirados, key=len, reverse=True)) + ')' + POST, re.M)
222 fallos = []
223 for f in RAIZ.rglob('*'):
224 if not f.is_file() or f.suffix not in EXT:
225 continue
226 rel = str(f.relative_to(RAIZ))
227 if any(rel.startswith(s) or ('/' + s) in rel for s in SALTAR):
228 continue
229 try:
230 crudo = f.read_text(errors='ignore')
231 except OSError:
232 continue
233 if len(crudo) > 900_000:
234 continue
235 propias = funciones_definidas(crudo)
236 txt = sin_comentarios(crudo, f.suffix)
237 lineas = None
238 for m in rx.finditer(txt):
239 ap = m.group(1)
240 if ap in propias: # una función local del script, no el applet
241 continue
242 antes, despues = txt[:m.start(1)].rstrip(), txt[m.end(1):m.end(1) + 1]
243 # Patrón de `case`, no invocación: `proc|sys|dev|init) continue ;;`
244 if antes.endswith('|') and despues == ')':
245 continue
246 # Comprobación explícita de existencia: quien escribe `command -v X` ya tolera que falte.
247 ini = txt.rfind('\n', 0, m.start(1)) + 1
248 fin = txt.find('\n', m.end(1))
249 if re.search(r'command\s+-v\s+' + re.escape(ap) + r'\b', txt[ini:fin if fin > 0 else len(txt)]):
250 continue
251 if lineas is None:
252 lineas = txt.splitlines()
253 n = txt[:m.start()].count('\n') + 1
254 fallos.append((rel, n, ap, lineas[n - 1].strip()[:100]))
255 print(f'busybox-vigia: {len(retirados)} applets retirados de la receta')
256 if not fallos:
257 print('busybox-vigia: OK — nadie los invoca')
258 return 0
259 print(f'busybox-vigia: FALLO — {len(fallos)} invocaciones de applets retirados\n')
260 for rel, n, ap, linea in fallos:
261 print(f' {rel}:{n} [{ap}] {linea}')
262 print('\nO se indulta el applet en la receta, o se migra el llamador.')
263 return 1
264
265
266if __name__ == '__main__':
267 sys.exit(main())