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…
⎇ 7cce81611c922bc05a8400df9c8b543127ea3cf9
scripts/sh-superficie-scripts.py
Ñawpa willay
Pi imata llamkarqan
ch'uya
scripts: la superficie medida — 0 guiones POSIX roto bajo ash, y los 28 bash lo son por ARRAYS
1#!/usr/bin/env python3
2"""Mide la superficie de shell de `scripts/` — SDD: docs/plan-botar-busybox.md.
3
4Hermano de `sh-superficie-cards.py`, con el escáner compartido en `scripts/lib/sh_analisis.py`.
5**Pero contesta una pregunta distinta, y conviene no mezclarlas:**
6
7 - las CARDS las ejecuta el producto ⇒ su superficie es el contrato de un `/bin/sh` propio HOY;
8 - `scripts/` lo ejecuta el HUB y el worker, bajo el bash del anfitrión ⇒ su superficie sólo
9 obliga el día del auto-alojamiento (Etapa C). Hoy lo que importa acá es otra cosa:
10
11 **¿algún guion se declara POSIX y no lo es?** Porque ése SÍ se rompe hoy —
12 en la imagen `/bin/sh` es el ash de busybox, no bash.
13
14Tres poblaciones, por la PRIMERA línea (no por «contiene un shebang»: dos scripts bash empotran un
15`#!/bin/sh` en un heredoc y un grep ingenuo los cuenta como POSIX — pasó):
16
17 1. **POSIX** — `#!/bin/sh`, `#!/usr/bin/env sh`, `#!/bin/ash`
18 2. **bash** — `#!/bin/bash`, `#!/usr/bin/env bash`
19 3. **EMPOTRADOS** — guiones `#!/bin/sh` que otro script ESCRIBE con un heredoc (un `/init`, un
20 envoltorio). Ésos viajan a la imagen y los corre el ash, así que son los que más importan y
21 los que nadie parsea.
22
23De cada uno: si lo parsea el ash, si lo parsea bash, qué construcciones usa y qué órdenes invoca.
24Para la población bash, además, **por qué** es bash: qué bashismo concreto trae.
25
26Uso:
27 scripts/sh-superficie-scripts.py # resumen + TSV
28 scripts/sh-superficie-scripts.py --list # el detalle por fichero
29 scripts/sh-superficie-scripts.py --dump-dir D # los empotrados como ficheros, para el banco
30"""
31
32from __future__ import annotations
33
34import argparse
35import collections
36import glob as globmod
37import re
38import subprocess
39import sys
40from dataclasses import dataclass, field
41from pathlib import Path
42
43RAIZ = Path(__file__).resolve().parent.parent
44sys.path.insert(0, str(Path(__file__).resolve().parent / "lib"))
45from sh_analisis import (RASGOS, RUTA_APPLETS, applets_conocidos, # noqa: E402
46 desnudas_y_absolutas, heredocs, piezas_shell,
47 rasgos_y_ordenes)
48
49SHEBANG_POSIX = {"#!/bin/sh", "#!/usr/bin/env sh", "#!/bin/ash", "#!/bin/busybox sh"}
50SHEBANG_BASH = {"#!/bin/bash", "#!/usr/bin/env bash", "#!/bin/bash -e"}
51
52# Lo que hace que un guion NO sea POSIX. Se busca sobre el texto de SINTAXIS (fuera de comillas),
53# así un `[[` dentro de un mensaje no cuenta.
54BASHISMOS = [
55 ("[[ ]]", r"\[\["),
56 ("array=( )", r"^\s*\w+=\(|\bdeclare\s+-[aA]\b|\blocal\s+-[aA]\b"),
57 ("${arr[i]}", r"\$\{\w+\[[^]]*\]"),
58 ("(( ))", r"(?<!\$)\(\("),
59 ("<<< (here-string)", r"<<<"),
60 ("<( ) proceso", r"<\("),
61 ("declare/typeset", r"\bdeclare\b|\btypeset\b"),
62 ("mapfile/readarray", r"\bmapfile\b|\breadarray\b"),
63 ("${v//x/y}", r"\$\{\w+//"),
64 ("${v^^} ${v,,}", r"\$\{\w+\^\^|\$\{\w+,,"),
65 ("+= en asignación", r"^\s*\w+\+="),
66 ("function nombre", r"\bfunction\s+\w+"),
67 ("PIPESTATUS/BASH_*", r"\bPIPESTATUS\b|\bBASH_\w+"),
68 ("echo -e", r"\becho\s+-e\b"),
69 ("read -a / -d", r"\bread\s+-[ad]\b"),
70 ("source (no `.`)", r"^\s*source\s"),
71]
72
73
74@dataclass
75class Guion:
76 ruta: str
77 poblacion: str # posix | bash | empotrado | sin-shebang
78 shebang: str
79 codigo: str
80 padre: str | None = None # para los empotrados: quién lo escribe
81 ash_ok: bool | None = None
82 bash_ok: bool | None = None
83 ash_error: str = ""
84 rasgos: set[str] = field(default_factory=set)
85 bashismos: set[str] = field(default_factory=set)
86 builtins: collections.Counter = field(default_factory=collections.Counter)
87 externas: collections.Counter = field(default_factory=collections.Counter)
88 lineas: int = 0
89
90
91# ───────────────────────────────────────────────── recolección
92
93# Ficheros de shell SIN extensión `.sh` y sin shebang que igual son shell. Se nombran uno por uno
94# a propósito: el balde automático traía `.html`, `.mp4` y `.c`, y un balde así no es una medición.
95# `perfil/profile` importa doble porque VIAJA A LA IMAGEN: lo lee toda shell de login.
96EXTRAS = {"scripts/servidor/perfil/profile"}
97
98FUERA = ("/fixtures/sh-casos/", "/__pycache__/", "/.git/")
99
100
101def es_shell(ruta: Path, primera: str) -> bool:
102 """Un fichero cuenta como shell si lo dice su extensión, su shebang, o la lista EXTRAS."""
103 if str(ruta.relative_to(RAIZ)) in EXTRAS:
104 return True
105 if ruta.suffix == ".sh":
106 return True
107 return primera in SHEBANG_POSIX or primera in SHEBANG_BASH
108
109
110def ficheros_shell() -> list[tuple[Path, str, str]]:
111 """`(ruta, texto, primera línea)` de cada fichero de shell, y cuántos se descartaron."""
112 salida, descartados = [], 0
113 for ruta in sorted(RAIZ.glob("scripts/**/*")):
114 if not ruta.is_file() or any(f in f"/{ruta.as_posix()}/" for f in FUERA):
115 continue
116 try:
117 texto = ruta.read_text(encoding="utf-8", errors="replace")
118 except OSError:
119 continue
120 primera = texto.splitlines()[0].strip() if texto.strip() else ""
121 if es_shell(ruta, primera):
122 salida.append((ruta, texto, primera))
123 else:
124 descartados += 1
125 ficheros_shell.descartados = descartados
126 return salida
127
128
129def empotrados(texto: str) -> list[tuple[str, str]]:
130 """Los cuerpos de heredoc que SON guiones de shell: `(delimitador, cuerpo)`.
131
132 Un heredoc de configuración —o de Python— no es un guion. La separación la hace
133 `sh_analisis.heredocs`; acá sólo se filtra por shebang.
134 """
135 return [(d, c) for d, c in heredocs(texto)[1]
136 if c.lstrip().startswith("#!") and ("sh" in c.lstrip().splitlines()[0])]
137
138
139def recolectar() -> list[Guion]:
140 guiones: list[Guion] = []
141 for ruta, texto, primera in ficheros_shell():
142 rel = str(ruta.relative_to(RAIZ))
143 if primera in SHEBANG_POSIX:
144 pob = "posix"
145 elif primera in SHEBANG_BASH:
146 pob = "bash"
147 elif primera.startswith("#!"):
148 pob = "otro-interprete"
149 else:
150 pob = "sin-shebang" # se hace `source`, no se ejecuta
151 if pob != "otro-interprete":
152 guiones.append(Guion(ruta=rel, poblacion=pob, shebang=primera or "(ninguno)",
153 codigo=texto, lineas=texto.count("\n")))
154 for delim, cuerpo in empotrados(texto):
155 guiones.append(Guion(ruta=f"{rel}«{delim}»", poblacion="empotrado",
156 shebang=cuerpo.lstrip().splitlines()[0], codigo=cuerpo,
157 padre=rel, lineas=cuerpo.count("\n")))
158 return guiones
159
160
161# ───────────────────────────────────────────────── análisis
162
163def parsea(binario: list[str], codigo: str, tmp: Path) -> tuple[bool, str]:
164 tmp.write_text(codigo, encoding="utf-8")
165 p = subprocess.run(binario + ["-n", str(tmp)], capture_output=True, timeout=30)
166 err = p.stderr.decode("utf-8", "replace").strip().splitlines()
167 return p.returncode == 0, (err[0].split(": ", 1)[-1] if err else "")
168
169
170def analizar(g: Guion, ash: list[str], bash: list[str], tmp: Path) -> None:
171 # El ANÁLISIS va sobre el texto sin cuerpos de heredoc (ésos se miden como población
172 # `empotrado`, o no se miden si no son shell); el PARSEO va sobre el fichero entero, que es
173 # lo que el shell ve de verdad.
174 propio = g.codigo if g.poblacion == "empotrado" else heredocs(g.codigo)[0]
175 g.rasgos, g.builtins, g.externas = rasgos_y_ordenes(propio)
176 sintaxis = "\n".join(s for _, s, _ in piezas_shell(propio))
177 g.bashismos = {n for n, patron in BASHISMOS if re.search(patron, sintaxis, re.M)}
178 g.ash_ok, g.ash_error = parsea(ash, g.codigo, tmp)
179 g.bash_ok, _ = parsea(bash, g.codigo, tmp)
180
181
182def descubrir(patron: str, sufijo: str) -> list[str] | None:
183 for d in sorted(globmod.glob(patron)):
184 p = Path(d) / sufijo
185 if p.is_file():
186 return [str(p)]
187 return None
188
189
190# ───────────────────────────────────────────────── informe
191
192def main() -> int:
193 ap = argparse.ArgumentParser(description="Mide la superficie de shell de scripts/.")
194 ap.add_argument("--ash", metavar="RUTA", help="binario del shell POSIX de control (busybox)")
195 ap.add_argument("--bash", metavar="RUTA", help="binario de bash")
196 ap.add_argument("--list", action="store_true", help="detalle por fichero")
197 ap.add_argument("--dump-dir", metavar="DIR", help="volcar los guiones EMPOTRADOS como ficheros")
198 ap.add_argument("--tsv", metavar="RUTA", default=str(RAIZ / "work/sh-superficie-scripts.tsv"))
199 args = ap.parse_args()
200
201 ash = [args.ash, "sh"] if args.ash else (
202 (descubrir("/store/*-busybox", "bin/busybox") or [None]) + ["sh"])
203 bash = [args.bash] if args.bash else descubrir("/store/*-bash", "bin/bash")
204 if not ash[0] or not bash:
205 print("✗ no encontré los shells. Pasá --ash y --bash "
206 "(p.ej. /store/…-busybox/bin/busybox y /store/…-bash/bin/bash)", file=sys.stderr)
207 return 2
208 print(f"Control POSIX: {' '.join(ash)}\nbash: {bash[0]}\n")
209
210 guiones = recolectar()
211 print(f"Ficheros de scripts/ descartados por no ser shell (.html, .c, .mp4, …): "
212 f"{ficheros_shell.descartados}")
213 tmp = Path(RAIZ / "work" / ".sh-superficie-tmp.sh")
214 tmp.parent.mkdir(parents=True, exist_ok=True)
215 try:
216 for g in guiones:
217 analizar(g, ash, bash, tmp)
218 finally:
219 tmp.unlink(missing_ok=True)
220
221 por = collections.defaultdict(list)
222 for g in guiones:
223 por[g.poblacion].append(g)
224
225 # ── inventario y la matriz de parseo
226 print("── Poblaciones, y quién las parsea")
227 print(f" {'población':<16} {'n':>4} {'líneas':>7} parsea ash parsea bash")
228 for pob in ("posix", "bash", "empotrado", "sin-shebang"):
229 gs = por.get(pob, [])
230 if not gs:
231 continue
232 print(f" {pob:<16} {len(gs):>4} {sum(g.lineas for g in gs):>7} "
233 f"{sum(1 for g in gs if g.ash_ok):>5}/{len(gs):<5} "
234 f"{sum(1 for g in gs if g.bash_ok):>5}/{len(gs)}")
235
236 # ── lo que se rompe HOY: un guion que se declara POSIX y el ash no lo parsea
237 rotos = [g for g in guiones if g.poblacion in ("posix", "empotrado") and not g.ash_ok]
238 print(f"\n── Se declaran POSIX y el ash NO los parsea: {len(rotos)}")
239 if rotos:
240 for g in rotos:
241 print(f" ⛔ {g.ruta} ({g.shebang})")
242 print(f" ash: {g.ash_error}")
243 print(f" bash lo parsea: {'sí ⇒ es un bashismo' if g.bash_ok else 'no ⇒ error de sintaxis'}")
244 if g.bashismos:
245 print(f" bashismos detectados: {', '.join(sorted(g.bashismos))}")
246 else:
247 print(" ✓ ninguno. Todo lo que dice `#!/bin/sh` lo parsea el ash de busybox.")
248
249 # ── los bash: ¿por qué son bash?
250 bashs = por.get("bash", [])
251 if bashs:
252 solo_bash = [g for g in bashs if not g.ash_ok]
253 print(f"\n── Los {len(bashs)} guiones `#!bash`: {len(bashs)-len(solo_bash)} parsean tal cual "
254 f"bajo ash, {len(solo_bash)} no")
255 print(" (parsear no es correr: un `echo -e` o un `local` parsean en los dos y se portan distinto)")
256 hist = collections.Counter()
257 for g in bashs:
258 hist.update(g.bashismos)
259 cat = [n for n, _ in BASHISMOS]
260 print("\n Bashismos, por cuántos guiones los traen:")
261 for n, c in sorted(hist.items(), key=lambda kv: (-kv[1], cat.index(kv[0]))):
262 print(f" {n:<20} {c:>3} {'█' * c}")
263 sin = [n for n, _ in BASHISMOS if n not in hist]
264 if sin:
265 print(f" no aparece: {', '.join(sin)}")
266
267 # ── la superficie del lenguaje, por población
268 for pob in ("posix", "bash", "empotrado"):
269 gs = por.get(pob, [])
270 if not gs:
271 continue
272 hist = collections.Counter()
273 for g in gs:
274 hist.update(g.rasgos)
275 cat = [r for r, _, _ in RASGOS]
276 print(f"\n── Construcciones · población {pob} ({len(gs)} guiones)")
277 for r, c in sorted(hist.items(), key=lambda kv: (-kv[1], cat.index(kv[0]))):
278 print(f" {r:<20} {c:>4}/{len(gs)}")
279 faltan = [r for r in cat if r not in hist]
280 if faltan:
281 print(f" no aparece: {', '.join(faltan)}")
282
283 # ── órdenes
284 bi, ex = collections.Counter(), collections.Counter()
285 for g in guiones:
286 bi.update(g.builtins)
287 ex.update(g.externas)
288 print(f"\n── Builtins usados en todo scripts/ ({len(bi)})")
289 print(" " + " ".join(f"{k}×{v}" for k, v in sorted(bi.items(), key=lambda kv: (-kv[1], kv[0]))))
290 desnudas, absolutas = desnudas_y_absolutas(ex)
291 top = sorted(desnudas.items(), key=lambda kv: (-kv[1], kv[0]))
292 print(f"\n── Órdenes externas por NOMBRE DESNUDO ({len(desnudas)} distintas, las resuelve el "
293 f"PATH) · las 30 más usadas")
294 print(" " + " ".join(f"{k}×{v}" for k, v in top[:30]))
295 abs_top = sorted(absolutas.items(), key=lambda kv: (-kv[1], kv[0]))
296 print(f"\n── …y por RUTA ABSOLUTA ({len(absolutas)} distintas, no pasan por PATH) · las 15 más "
297 f"usadas")
298 print(" " + " ".join(f"{k}×{v}" for k, v in abs_top[:15]))
299
300 applets = applets_conocidos()
301 if applets:
302 retiradas = [(k, v) for k, v in top if applets.get(k, ("", ""))[1] == "RETIRADO"]
303 print(f"\n── Cruce con el recorte ({RUTA_APPLETS.name})")
304 print(f" nombres desnudos que son applet RETIRADO: {len(retiradas)}"
305 + (f" → {', '.join(k for k, _ in retiradas)}" if retiradas else ""))
306 print(" ⚠ sólo se cruzan los DESNUDOS: una ruta absoluta no se resuelve por PATH y por lo"
307 "\n tanto no puede ser un applet. Cruzar las absolutas daba un falso positivo"
308 "\n medido (`/sbin/init`, que en el producto es arje).")
309 print(" ⚠ la PUERTA es `scripts/busybox-vigia.py`, que ya falla si alguien invoca uno;"
310 "\n esto es inventario, y su lista es más ancha (incluye no-applets).")
311
312 if args.list:
313 print("\n── Detalle")
314 for g in sorted(guiones, key=lambda x: (x.poblacion, x.ruta)):
315 marca = "" if g.ash_ok else " ⛔ ash no parsea"
316 print(f"\n [{g.poblacion}] {g.ruta} ({g.lineas} líneas){marca}")
317 if g.bashismos:
318 print(f" bashismos: {', '.join(sorted(g.bashismos))}")
319 print(f" rasgos: {', '.join(sorted(g.rasgos))}")
320
321 if args.dump_dir:
322 d = Path(args.dump_dir)
323 d.mkdir(parents=True, exist_ok=True)
324 n = 0
325 for g in por.get("empotrado", []):
326 nombre = re.sub(r"[^\w.-]", "_", g.ruta) + ".sh"
327 (d / nombre).write_text(g.codigo, encoding="utf-8")
328 n += 1
329 print(f"\nEmpotrados escritos en {d} ({n} ficheros)")
330
331 tsv = Path(args.tsv)
332 tsv.parent.mkdir(parents=True, exist_ok=True)
333 with tsv.open("w", encoding="utf-8") as fh:
334 fh.write("poblacion\truta\tpadre\tshebang\tlineas\tash_ok\tbash_ok\tash_error\t"
335 "bashismos\trasgos\tbuiltins\texternas\n")
336 for g in guiones:
337 fh.write(f"{g.poblacion}\t{g.ruta}\t{g.padre or ''}\t{g.shebang}\t{g.lineas}\t"
338 f"{'si' if g.ash_ok else 'no'}\t{'si' if g.bash_ok else 'no'}\t{g.ash_error}\t"
339 f"{'|'.join(sorted(g.bashismos))}\t{'|'.join(sorted(g.rasgos))}\t"
340 f"{'|'.join(sorted(g.builtins))}\t{'|'.join(sorted(g.externas))}\n")
341 print(f"\nTSV: {tsv}")
342 return len(rotos)
343
344
345if __name__ == "__main__":
346 sys.exit(main())