scripts: la superficie medida — 0 guiones POSIX roto bajo ash, y los 28 bash lo son por ARRAYS

Pregunta distinta a la de las cards, y el doc lo dice: scripts/ corre en el hub
bajo el bash del anfitrión, así que su superficie sólo obliga el día del
auto-alojamiento. Lo que importa hoy es si algún guion se declara POSIX y no lo
es, porque ése se rompe en la imagen, donde /bin/sh es el ash de busybox.

- 81 POSIX (10.167 líneas), 76 bash (10.269), 21 EMPOTRADOS por heredoc (los
  /init que viajan a la imagen, y que hoy nadie parsea), 6 que se hacen source
- los 81 POSIX y los 21 empotrados los parsea el ash: CERO roto
- 48 de los 76 bash parsean tal cual bajo ash; de los 28 que no, 26 lo son por
  arrays y nada más. Ése es el presupuesto entero de un sh propio para correr la
  herramienta del proyecto
- 27 builtins, 257 órdenes por nombre desnudo y 35 por ruta absoluta; 0 applets
  retirados, o sea que este inventario y busybox-vigia.py ahora coinciden

El escáner se extrajo a scripts/lib/sh_analisis.py para no tener dos copias que
divergan (ADR 0019 un piso más abajo). Comprobado que la extracción no cambió la
salida de sh-superficie-cards.py, byte a byte, y que el orden de los empates es
determinista para que sea diffable.

Van anotados los SIETE bugs del instrumento, que es la parte que enseña: los
comentarios sin quitar (89 backticks de prosa en cosecha-cron.sh), shlex
desincronizándose sobre el fichero entero, los cuerpos de heredoc (uno era
Python y aportaba la orden 'p' — sacarlos bajó las externas de 680 a 257), los
patrones de case dando 'init', los cuerpos de $(( )) dando 'i+1', los
descriptores dando '2', y la ruta absoluta confundida con applet. Ninguno se
veía en la salida: los siete daban números plausibles.
This commit is contained in:
Sergio
2026-09-21 19:54:12 +00:00
parent a66346fb05
commit a4105cba11
4 changed files with 867 additions and 214 deletions
+137 -6
View File
@@ -665,18 +665,23 @@ test -f /etc/x/x.conf || { echo 'x: falta … — es config del SITIO' >&2; exit
**Órdenes externas que la imagen tiene que traer: 14.** `mkdir`×14 `grep`×11 `chown`×4 `id`×4
`netup`×2 `ssh-keygen`×2 `chmod`×2 `mount`×2 `nft` `hostname` `basename` `blkid` `df` `awk`.
## El guardián que faltaba, ahora corre
Cruzar esas 14 con `docs/state/busybox-applets.tsv` es exactamente el guardián que este documento
pedía y nadie tenía:
## El cruce con el recorte — y una CORRECCIÓN
```
siguen en busybox: 11 · no son applets (otro paquete): 3 (netup, nft, ssh-keygen) · RETIRADAS: 0
✓ ninguna card invoca un applet retirado por el recorte 401 → 277.
```
El recorte no rompió ninguna card. **Y ahora es un número que se vuelve a sacar en un segundo**, en
vez de un fallo que aparecería en el arranque de otra máquina semanas después.
⚠ **Acá se escribió que esto «es el guardián que el plan pedía y nadie tenía». Es falso: el
guardián EXISTE** — `scripts/busybox-vigia.py`, escrito con el paso 1, que lee los retirados de
`recipes/busybox.toml`, los cruza con la columna `simbolo_kconfig` del TSV y **falla si alguna
receta, script o card invoca uno**. Corrido hoy: `123 applets retirados de la receta · OK — nadie
los invoca`, `rc=0`. La sección «Falta un guardián» de más arriba quedó vieja el mismo día.
Lo que el cruce de este instrumento agrega es otra cosa, y conviene no confundirlas: el vigía
contesta «¿alguien invoca algo que ya no está?» (una puerta, con `rc≠0`); esto contesta **«¿qué
necesitan las cards para arrancar?»** — inventario, no puerta, y por eso incluye las que no son
applets (`netup`, `nft`, `ssh-keygen`), que al vigía no le importan y a un perfil de imagen sí.
## Y los 23 fragmentos, por el banco diferencial
@@ -775,3 +780,129 @@ sandbox de build sigue siendo otro planeta**, y es el que conviene no mezclar.
- **No mide `scripts/`**, que es su propia unidad de trabajo y es mucho más grande.
- **No mide las cards REALES de la mudanza**: dependen de un censo que no está en el árbol.
- **No ejecuta los fragmentos**, sólo los parsea.
---
# La superficie de `scripts/`, MEDIDA — 2026-09-21
**Instrumento:** `scripts/sh-superficie-scripts.py` · **escáner compartido:**
`scripts/lib/sh_analisis.py` · **salida:** `work/sh-superficie-scripts.tsv`.
⚠ **Es una pregunta DISTINTA a la de las cards, y mezclarlas es el error.** Las cards las ejecuta
el producto ⇒ su superficie es el contrato de un `/bin/sh` propio hoy. `scripts/` lo ejecutan el
hub y el worker bajo el bash del anfitrión ⇒ su superficie sólo obliga el día del auto-alojamiento.
Lo que sí importa hoy acá es otra cosa:
> **¿algún guion se declara POSIX y no lo es?** Porque ése se rompe HOY: en la imagen `/bin/sh` es
> el ash de busybox, no bash.
## Las cuatro poblaciones, y quién las parsea
De 118 ficheros descartados por no ser shell (`.html`, `.c`, `.mp4`, `.toml`…):
| población | n | líneas | parsea ash | parsea bash |
|---|---|---|---|---|
| **POSIX** (`#!/bin/sh`, `#!/usr/bin/env sh`) | 81 | 10 167 | **81/81** | 81/81 |
| **bash** (`#!/bin/bash`, `#!/usr/bin/env bash`) | 76 | 10 269 | **48/76** | 76/76 |
| **EMPOTRADOS** (un `/init` escrito por heredoc) | 21 | 714 | **21/21** | 21/21 |
| **`source`-only** (`.sh` sin shebang) | 6 | 138 | 6/6 | 6/6 |
> **Cero guiones se declaran POSIX y fallan bajo ash.** Ni los 81 sueltos ni los 21 empotrados.
La clasificación es por la **primera línea**, no por «contiene un shebang»: un `grep -rl '^#!/bin/sh'`
da 86 porque cuenta los scripts bash que EMPOTRAN un `#!/bin/sh` en un heredoc. Con ese grep,
`mirada-usb.sh` y `selfhost-verify.sh` aparecían como «POSIX roto bajo ash» — y son bash legítimos.
**Los 21 empotrados son la población que importa para el producto**, y hoy nadie los parsea: son los
`/init` de los initramfs y los envoltorios que viajan a la imagen, escritos por 13 ficheros
(`takana-live-install.sh` ×3, `metal-desktop-image.sh` ×5, `install-image-efi.sh` ×2, `disk-image.sh`,
`metal-iso.sh`, `metal-usb-sdboot.sh`, `mirada-usb.sh`, `selfhost-verify.sh`, `cc-por-zig.sh`,
`jaula-herramientas.sh`, `atuq-en-cosmic.sh`, `dunst-headless.sh`, `metal-desktop-image-dual.sh`).
**Los 21 los parsea el ash.**
## Por qué los 28 bash son bash: arrays, y casi nada más
| bashismo | guiones |
|---|---|
| **`array=( )`** | **31** |
| `declare`/`typeset` | 7 |
| `+=` en asignación | 6 |
| `[[ ]]` | 5 |
| `<<<` | 4 |
| `<( )` | 4 |
| `${arr[i]}` | 3 |
| `(( ))` | 3 |
| `mapfile` | 1 |
No aparece ninguna vez: `${v//x/y}`, `${v^^}`, `function nombre`, `PIPESTATUS`/`BASH_*`, `echo -e`,
`read -a/-d`, `source` en vez de `.`.
**De los 28 que el ash no parsea, 26 lo son por arrays y nada más.** Ése es el presupuesto completo
de un `sh` propio que quisiera correr la herramienta del proyecto: **arrays indexados**. Los otros
dos suman `[[ ]]`, `<( )` y `mapfile`.
⚠ **Parsear no es correr.** 48 de los 76 `#!bash` parsean tal cual bajo ash y eso NO significa que
funcionen: `local`, `echo -e` o un `trap` de subshell parsean en los dos y se portan distinto (lo de
brush#1396 es justo eso). La tabla mide sintaxis.
## La superficie del lenguaje
Los 81 POSIX usan **todo** lo que las cards no usan: `if` 63/81, funciones 30/81, `${…}` 74/81,
posicionales 66/81, here-docs 22/81, `case` 26/81, `until` 3/81. **Comparado con las cards —8
builtins, ningún `if`, ninguna función— es otro lenguaje.** Builtins en todo `scripts/`: 27
(`echo`×1996, `[`×1270, `exit`×473, `export`×239, `printf`×236, `trap`×40, `local`×19, `ulimit`×3…).
Órdenes externas: **257 por nombre desnudo** (las resuelve el `PATH`) y **35 por ruta absoluta** (no
pasan por `PATH` y por lo tanto **no pueden ser un applet**). Cruzadas con el recorte:
**0 applets retirados** — que es lo que dice `busybox-vigia.py`, y ahora los dos instrumentos
coinciden.
## Los siete bugs del INSTRUMENTO, que son la parte que hay que contar
Cada número de arriba salió mal primero. Vale más anotar esto que la tabla:
1. **Comentarios.** El escáner no los quitaba, y en este repo los comentarios son prosa llena de
`` `backticks` ``: medido en `scripts/farm/cosecha-cron.sh`, **89 backticks y los 89 dentro de
comentarios**. Daba «sustitución `` `` `` 75/81» cuando la cifra real es 9/81.
2. **`shlex` sobre el fichero entero.** Se desincroniza —no es un parser de shell— y una comilla
suelta desplaza el estado de TODO lo que sigue: `takana-live-install.sh` daba la orden `init`
porque el token venía de dentro de un `echo "…(busybox + /init …)"`. Reescrito sobre el texto
que el escáner ya deja sin comillas.
3. **Cuerpos de heredoc.** No son shell del script que los contiene: `scripts/desplegar-strip.sh`
empotra un programa de **Python**, y su `for p in glob.glob(…)` aparecía como la orden `p`.
Sacarlos bajó las órdenes externas de 680 a 257 — o sea que **el 62 % del inventario era ruido**.
4. **Patrones de `case`.** `case $n in proc|sys|dev|init) continue ;;` daba `init` como orden
invocada, y eso marcaba un applet retirado que nadie invoca — contradiciendo al vigía, que tenía
razón. Ahora hay estado de `case`.
5. **Cuerpos de `$(( ))`.** `i=$((i+1))` daba la orden `i+1`.
6. **Descriptores de fichero.** `>&2` daba la orden `2`.
7. **Nombre desnudo vs ruta absoluta.** `exec switch_root /newroot /sbin/init` marcaba el applet
`init`; pero una ruta absoluta no se resuelve por `PATH` y no puede ser un applet — en el
producto ese `/sbin/init` es arje (ADR 0007). Ahora se cuentan por separado, y de paso eso
**mide algo útil**: 35 órdenes van por ruta absoluta y no dependen del `PATH`.
**Ninguno de los siete se veía en la salida.** Todos daban números plausibles. Es la misma lección
del banco diferencial —un banco sintético pasa y el consumidor real falla— aplicada al instrumento
en vez de al shell.
## Una librería, no dos copias
El escáner vive en `scripts/lib/sh_analisis.py` y lo importan los dos instrumentos. Duplicarlo era
repetir el problema del ADR 0019 un piso más abajo: dos emisores del mismo formato, cada uno con un
campo distinto mal. Comprobado que la extracción no cambió nada: la salida de
`sh-superficie-cards.py` es byte-idéntica antes y después (y su md5 se repite entre corridas — el
orden de los empates se volvió determinista a propósito, para que la salida sea diffable).
Y los arreglos 37 **corrigieron también la medición de las cards**: apareció `sleep`, que el
tokenizador viejo se comía, y desapareció `i+1`.
## Lo que esto NO mide
- **`|` dentro de un patrón de `case`** se cuenta como tubería. Inflaba poco y no se arregló: hace
falta gramática, no regex.
- **`sh -n` no es ejecución.** Nada de esto dice que los 48 `#!bash` que el ash parsea funcionen
bajo ash.
- **No se midió qué guion corre DÓNDE.** Que `takana-install.sh` sea bash no obliga a nada porque
corre en el hub; el que corre dentro de la imagen es `takana-live-install.sh`, y ése es POSIX.
La lista de «quién corre en la imagen» sigue sin estar medida, salvo los 21 empotrados, que lo son
por construcción.
+366
View File
@@ -0,0 +1,366 @@
#!/usr/bin/env python3
"""sh_analisis — el escáner de shell compartido por los instrumentos de superficie.
Lo usan `scripts/sh-superficie-cards.py` (las cards de arje) y
`scripts/sh-superficie-scripts.py` (los guiones del repo). Vive acá por una razón concreta: es
lógica delicada —quoting, anidamiento, posición de orden— y **dos copias divergen**. Es el mismo
problema que el ADR 0019 describe un piso más arriba: dos emisores del mismo formato, cada uno con
un campo distinto mal.
Se importa así, que es el idioma que ya usa `scripts/mudanza/`:
sys.path.insert(0, str(Path(__file__).resolve().parent / "lib"))
from sh_analisis import piezas_shell, RASGOS, rasgos_y_ordenes, applets_conocidos
Lo que NO es: un parser. Es detección por patrón sobre un escáner de comillas que sí entiende
comillas simples, dobles, comentarios, barra invertida y anidamiento de sustituciones. Aproximado
y dicho así — pero el estado de comillas SÍ es correcto, y eso es lo que separa una medición de un
montón de falsos positivos.
"""
from __future__ import annotations
import collections
import re
from pathlib import Path
RAIZ = Path(__file__).resolve().parent.parent.parent
def piezas_shell(codigo: str) -> list[tuple[str, str, str]]:
"""Descompone el código en PIEZAS y devuelve, por pieza, `(original, sintaxis, expansiones)`.
Una pieza es un nivel de shell: el código de la card, y el cuerpo de cada sustitución de
comando — que es shell OTRA VEZ y casi siempre vive dentro de comillas dobles. Sin esto, el
`df -h /vvv | awk …` de `montar-trabajo` no existía para la medición: ni su tubería ni sus dos
órdenes externas.
Los tres textos, y por qué hacen falta tres:
- **original**: el texto tal cual, con el cuerpo de cada sustitución retirado (queda en su
propia pieza, así no se cuenta dos veces). No lo usa la medición — sirve para depurar y para
ver qué vio el escáner.
- **sintaxis**: lo de fuera de comillas. Ahí se buscan `;`, `&&`, `|`, `{ }`, `>`, `for`,
`case`, `*`. Sin separar por comillas, un mensaje de error que dice «lo genera
`minga init`» se cuenta como sustitución de comando — pasó, y daba 11 backticks falsos de
23 cards, más una docena de «órdenes externas» que eran palabras sueltas de la prosa.
- **expansiones**: lo que el shell EXPANDE, o sea lo de fuera de comillas más lo de dentro de
comillas DOBLES. Ahí se buscan `$( )`, `` ` ``, `$(( ))`, `${…}`, `$1`.
Escáner de estados, no regex: comillas simples (literal absoluto), dobles (expanden), barra
invertida y anidamiento de paréntesis.
"""
piezas: list[tuple[str, str, str]] = []
def escanear(texto: str) -> None:
orig, sin, exp = [], [], []
estado = None # None | "'" | '"'
i = 0
# Un `#` abre comentario sólo si está FUERA de comillas y al principio de una palabra.
# Sin esto la medición es basura en este repo, donde los comentarios son prosa llena de
# `backticks`: medido en scripts/farm/cosecha-cron.sh, 89 backticks y los 89 en comentarios.
# `${x#pre}` y `a#b` NO son comentarios, y por eso hace falta el estado de palabra.
palabra = True
while i < len(texto):
c = texto[i]
if estado is None and c == "#" and palabra:
j = texto.find("\n", i)
if j < 0:
break
orig.append("\n"); sin.append("\n"); exp.append("\n")
i = j + 1
continue
if estado is None:
palabra = c in " \t\n;&|()<>" or (not orig and not sin)
if estado is None and c == "\\" and i + 1 < len(texto):
orig.append(texto[i:i + 2]); sin.append(" "); exp.append(" ")
i += 2
continue
vivo = estado != "'"
if vivo and texto.startswith("$((", i):
# Aritmética: NO es una pieza de shell (su cuerpo no son órdenes). Se retira de la
# SINTAXIS para que el tokenizador no lea `i+1` como una orden — pasó — y se deja
# marcada en las EXPANSIONES para que el rasgo se siga detectando.
prof, j = 2, i + 3
while j < len(texto) and prof:
if texto[j] == "(":
prof += 1
elif texto[j] == ")":
prof -= 1
j += 1
orig.append(" _ARIT_ "); sin.append(" _ "); exp.append("$((_))")
i = j
continue
if vivo and texto.startswith("$(", i):
prof, j = 1, i + 2
while j < len(texto) and prof:
if texto[j] == "(":
prof += 1
elif texto[j] == ")":
prof -= 1
j += 1
escanear(texto[i + 2:j - 1])
orig.append(" _SUST_ "); sin.append(" "); exp.append("$(_)")
i = j
continue
if vivo and c == "`":
j = texto.find("`", i + 1)
if j < 0:
j = len(texto)
escanear(texto[i + 1:j])
orig.append(" _SUST_ "); sin.append(" "); exp.append("`_`")
i = j + 1
continue
if estado is None and c in "'\"":
estado = c
orig.append(c); sin.append("_")
i += 1
continue
if estado is not None and c == estado:
estado = None
orig.append(c)
i += 1
continue
orig.append(c)
if estado is None:
sin.append(c); exp.append(c)
elif estado == '"':
sin.append("_"); exp.append(c)
else:
sin.append("_")
i += 1
piezas.append(("".join(orig), "".join(sin), "".join(exp)))
escanear(codigo)
return piezas
RE_HEREDOC = re.compile(r"<<-?\s*(['\"]?)([A-Za-z_][A-Za-z0-9_]*)\1")
def heredocs(texto: str) -> tuple[str, list[tuple[str, str]]]:
"""Separa el texto de sus cuerpos de heredoc: `(texto_sin_cuerpos, [(delim, cuerpo)…])`.
Hace falta porque **el cuerpo de un heredoc no es shell del script que lo contiene**: puede
ser otro lenguaje entero. Medido: `scripts/desplegar-strip.sh` empotra un programa de Python,
y su `for p in glob.glob(…)` aparecía como la orden `p` en el inventario del árbol.
Los cuerpos se devuelven aparte porque algunos SÍ son guiones —un `/init` que viaja a la
imagen— y ésos se miden como población propia.
"""
lineas = texto.splitlines()
fuera, cuerpos = [], []
i = 0
while i < len(lineas):
fuera.append(lineas[i])
m = RE_HEREDOC.search(lineas[i])
if not m:
i += 1
continue
delim = m.group(2)
cuerpo, j = [], i + 1
while j < len(lineas) and lineas[j].strip() != delim:
cuerpo.append(lineas[j])
j += 1
cuerpos.append((delim, "\n".join(cuerpo) + "\n"))
i = j + 1
return "\n".join(fuera) + "\n", cuerpos
# (rasgo, patrón, sobre-qué): "s" = sintaxis (fuera de comillas), "e" = expansiones.
RASGOS = [
("bucle for", r"\bfor\b\s+\w+\s+in\b", "s"),
("bucle while", r"\bwhile\b", "s"),
("bucle until", r"\buntil\b", "s"),
("condicional if", r"\bif\b", "s"),
("case", r"\bcase\b", "s"),
("función", r"\w+\s*\(\)\s*\{", "s"),
("sustitución $( )", r"\$\((?!\()", "e"),
("sustitución `…`", r"`", "e"),
("aritmética $(( ))", r"\$\(\(", "e"),
("expansión ${…}", r"\$\{", "e"),
("variable $x", r"\$\w", "e"),
("posicionales $1 $@", r"\$[0-9@*#]", "e"),
("estado $?", r"\$\?", "e"),
("y/o && ||", r"&&|\|\|", "s"),
("tubería |", r"(?<![|&])\|(?!\|)", "s"),
("grupo { }", r"\{[^{}]*;\s*\}", "s"),
("subshell ( )", r"(?<![$(])\((?!\()", "s"),
("redirección >", r"(?<![0-9<>&])>", "s"),
("redirección 2>", r"\d>", "s"),
("aquí-doc <<", r"<<", "s"),
("glob *", r"\*", "s"),
("negación !", r"(?:^|[;&|(])\s*!\s", "s"),
("secuencia ;", r";", "s"),
("segundo plano &", r"(?<![&>])&(?![&>])", "s"),
]
BUILTINS_POSIX = {
":", ".", "break", "continue", "eval", "exec", "exit", "export", "readonly", "return",
"set", "shift", "times", "trap", "unset", "cd", "echo", "printf", "pwd", "read", "test",
"[", "command", "getopts", "hash", "umask", "wait", "alias", "unalias", "type", "kill",
"jobs", "fg", "bg", "ulimit", "local", "true", "false",
}
PALABRAS_CLAVE = {
"if", "then", "elif", "else", "fi", "for", "while", "until", "do", "done", "case", "esac",
"in", "{", "}", "(", ")", "!", "time", "function", "select",
}
SEPARADORES = {";", "&&", "||", "|", "&", "(", ")", "{", "}", "do", "then", "else", "elif"}
RE_SEP = re.compile(r"(\n|;;|;|&&|\|\||\||&>|>&|&|\(|\)|\{|\}|<<-|<<|<|>>|>)")
REDIRECCIONES = {"<<-", "<<", "<", ">>", ">", ">&", "&>"}
RE_FUNCION = re.compile(r"^[ \t]*(?:function[ \t]+)?([A-Za-z_][A-Za-z0-9_-]*)[ \t]*\(\)", re.M)
def funciones_definidas(sintaxis: str) -> set[str]:
"""Nombres de función definidos en el propio texto. No son órdenes externas, y en este repo
son muchas: `say`, `log`, `ok`, `dump`… salían como las órdenes más «usadas» del árbol."""
return set(RE_FUNCION.findall(sintaxis))
def ordenes(sintaxis: str) -> list[str]:
"""Palabras en posición de ORDEN, sobre el texto de SINTAXIS (ya sin comillas ni comentarios).
⚠ **No usar `shlex` sobre un fichero entero.** Se probó y se desincroniza: `shlex` no es un
parser de shell, así que una comilla suelta en cualquier línea —o el cuerpo de un heredoc—
desplaza el estado de comillas de TODO lo que sigue. Medido: `takana-live-install.sh` daba
`init` como orden porque el token venía de dentro de un `echo "…(busybox + /init …)"`. Acá el
estado de comillas ya lo resolvió `piezas_shell`, que sí entiende shell; esto sólo parte en
palabras y separadores.
"""
salida: list[str] = []
esperando, saltar_palabra, saltar_hasta = True, False, None
# Estado de `case`: entre el `in` (o un `;;`) y el `)` van PATRONES, no órdenes. Sin esto,
# `case $n in proc|sys|dev|init) continue ;;` daba `init` como orden invocada — y eso marcaba
# un applet retirado que nadie invoca, contradiciendo a `busybox-vigia.py`, que tiene razón.
en_case, patron = False, False
for trozo in RE_SEP.split(sintaxis):
if trozo in REDIRECCIONES:
saltar_palabra = True
continue
if RE_SEP.fullmatch(trozo):
if en_case and trozo == ")":
patron, esperando = False, True
continue
if en_case and trozo == ";;":
patron = True
continue
if patron:
continue
esperando = True
continue
for palabra in trozo.split():
if palabra == "esac":
en_case, patron = False, False
continue
if palabra == "case":
en_case, patron = True, False
saltar_hasta = {"in"}
esperando = False
continue
if patron:
continue
if saltar_palabra:
saltar_palabra = False
continue
if saltar_hasta:
if palabra in saltar_hasta:
saltar_hasta = None
if en_case and palabra == "in":
patron = True
esperando = palabra in {"do", ";"}
continue
if palabra in ("for", "select"):
saltar_hasta = {"do", "in"}
esperando = False
continue
if not esperando:
continue
if palabra in PALABRAS_CLAVE or palabra.startswith("-"):
continue
if set(palabra) <= {"_"}: # era un literal entrecomillado
continue
if palabra.isdigit(): # un descriptor de fichero, no una orden
continue
if re.fullmatch(r"\w+=.*", palabra): # asignación previa a la orden
continue
salida.append(palabra)
esperando = False
return salida
RUTA_APPLETS = RAIZ / "docs/state/busybox-applets.tsv"
def applets_conocidos() -> dict[str, tuple[str, str]]:
"""`applet → (destino, sigue|RETIRADO)`, del inventario del recorte 401 → 277.
Cruzarlo con lo que invocan las cards ES el guardián que `plan-botar-busybox.md` dice que
falta: hoy, una card que llame a un applet ya retirado del `defconfig` no falla en ningún
test — falla **en el arranque de otra máquina, semanas después**.
"""
if not RUTA_APPLETS.is_file():
return {}
filas = {}
with RUTA_APPLETS.open(encoding="utf-8") as fh:
cabecera = fh.readline().rstrip("\n").split("\t")
try:
i_dest = cabecera.index("destino")
i_rec = [i for i, c in enumerate(cabecera) if c.startswith("recorte")][0]
except (ValueError, IndexError):
return {}
for linea in fh:
campos = linea.rstrip("\n").split("\t")
if len(campos) > max(i_dest, i_rec):
filas[campos[0]] = (campos[i_dest], campos[i_rec])
return filas
def rasgos_y_ordenes(codigo: str) -> tuple[set[str], collections.Counter, collections.Counter]:
"""`(rasgos, builtins, externas)` de un fragmento de shell.
Es la entrada única de la librería: quien mide no vuelve a decidir cómo se separan las
comillas, qué cuenta como orden ni qué es una función propia.
"""
piezas = piezas_shell(codigo)
sintaxis = "\n".join(s for _, s, _ in piezas)
expansiones = "\n".join(e for _, _, e in piezas)
rasgos = {n for n, patron, donde in RASGOS
if re.search(patron, sintaxis if donde == "s" else expansiones, re.M)}
propias = funciones_definidas(sintaxis)
builtins: collections.Counter = collections.Counter()
externas: collections.Counter = collections.Counter()
for _, s, _ in piezas:
for o in ordenes(s):
base = Path(o).name if o.startswith("/") else o
if base in propias:
continue
if base in BUILTINS_POSIX:
builtins[base] += 1
elif re.fullmatch(r"[\w.+-]+", o):
externas[o] += 1 # nombre desnudo: lo resuelve el PATH
elif o.startswith("/") and re.fullmatch(r"[\w./+-]+", o):
externas[o] += 1 # ruta ABSOLUTA: no pasa por PATH
return rasgos, builtins, externas
def desnudas_y_absolutas(externas: collections.Counter) -> tuple[collections.Counter,
collections.Counter]:
"""Separa las órdenes por nombre desnudo de las invocadas por ruta absoluta.
La distinción no es cosmética: **sólo un nombre desnudo se resuelve por `PATH`**, y por lo
tanto sólo un nombre desnudo puede ser un applet de busybox. Confundirlas daba un falso
positivo medido: `exec switch_root /newroot /sbin/init` marcaba el applet `init` como
invocado, cuando lo que se ejecuta es el `/sbin/init` del producto, que es arje (ADR 0007).
"""
desnudas: collections.Counter = collections.Counter()
absolutas: collections.Counter = collections.Counter()
for k, v in externas.items():
nombre = Path(k).name if "/" in k else k
if not nombre: # un token que era sólo `/`
continue
(absolutas if "/" in k else desnudas)[nombre] += v
return desnudas, absolutas
+18 -208
View File
@@ -44,6 +44,9 @@ from dataclasses import dataclass, field
from pathlib import Path
RAIZ = Path(__file__).resolve().parent.parent
sys.path.insert(0, str(Path(__file__).resolve().parent / "lib"))
from sh_analisis import (RASGOS, RUTA_APPLETS, applets_conocidos, # noqa: E402
desnudas_y_absolutas, rasgos_y_ordenes)
# Los shells que una card puede invocar, y el multicall que los trae.
SHELLS = {"sh", "ash", "bash", "busybox"}
@@ -191,213 +194,12 @@ def de_mudanza() -> list[Fragmento]:
# ───────────────────────────────────────────────── análisis
def piezas_shell(codigo: str) -> list[tuple[str, str, str]]:
"""Descompone el código en PIEZAS y devuelve, por pieza, `(original, sintaxis, expansiones)`.
Una pieza es un nivel de shell: el código de la card, y el cuerpo de cada sustitución de
comando — que es shell OTRA VEZ y casi siempre vive dentro de comillas dobles. Sin esto, el
`df -h /vvv | awk …` de `montar-trabajo` no existía para la medición: ni su tubería ni sus dos
órdenes externas.
Los tres textos, y por qué hacen falta tres:
- **original**: el texto tal cual, con el cuerpo de cada sustitución retirado (queda en su
propia pieza, así no se cuenta dos veces). Va a `shlex`, que sí entiende comillas, para
sacar las ÓRDENES.
- **sintaxis**: lo de fuera de comillas. Ahí se buscan `;`, `&&`, `|`, `{ }`, `>`, `for`,
`case`, `*`. Sin separar por comillas, un mensaje de error que dice «lo genera
`minga init`» se cuenta como sustitución de comando — pasó, y daba 11 backticks falsos de
23 cards, más una docena de «órdenes externas» que eran palabras sueltas de la prosa.
- **expansiones**: lo que el shell EXPANDE, o sea lo de fuera de comillas más lo de dentro de
comillas DOBLES. Ahí se buscan `$( )`, `` ` ``, `$(( ))`, `${…}`, `$1`.
Escáner de estados, no regex: comillas simples (literal absoluto), dobles (expanden), barra
invertida y anidamiento de paréntesis.
"""
piezas: list[tuple[str, str, str]] = []
def escanear(texto: str) -> None:
orig, sin, exp = [], [], []
estado = None # None | "'" | '"'
i = 0
while i < len(texto):
c = texto[i]
if estado is None and c == "\\" and i + 1 < len(texto):
orig.append(texto[i:i + 2]); sin.append(" "); exp.append(" ")
i += 2
continue
vivo = estado != "'"
if vivo and texto.startswith("$(", i) and not texto.startswith("$((", i):
prof, j = 1, i + 2
while j < len(texto) and prof:
if texto[j] == "(":
prof += 1
elif texto[j] == ")":
prof -= 1
j += 1
escanear(texto[i + 2:j - 1])
orig.append(" _SUST_ "); sin.append(" "); exp.append("$(_)")
i = j
continue
if vivo and c == "`":
j = texto.find("`", i + 1)
if j < 0:
j = len(texto)
escanear(texto[i + 1:j])
orig.append(" _SUST_ "); sin.append(" "); exp.append("`_`")
i = j + 1
continue
if estado is None and c in "'\"":
estado = c
orig.append(c); sin.append("_")
i += 1
continue
if estado is not None and c == estado:
estado = None
orig.append(c)
i += 1
continue
orig.append(c)
if estado is None:
sin.append(c); exp.append(c)
elif estado == '"':
sin.append("_"); exp.append(c)
else:
sin.append("_")
i += 1
piezas.append(("".join(orig), "".join(sin), "".join(exp)))
escanear(codigo)
return piezas
# (rasgo, patrón, sobre-qué): "s" = sintaxis (fuera de comillas), "e" = expansiones.
RASGOS = [
("bucle for", r"\bfor\b\s+\w+\s+in\b", "s"),
("bucle while", r"\bwhile\b", "s"),
("bucle until", r"\buntil\b", "s"),
("condicional if", r"\bif\b", "s"),
("case", r"\bcase\b", "s"),
("función", r"\w+\s*\(\)\s*\{", "s"),
("sustitución $( )", r"\$\((?!\()", "e"),
("sustitución `…`", r"`", "e"),
("aritmética $(( ))", r"\$\(\(", "e"),
("expansión ${…}", r"\$\{", "e"),
("variable $x", r"\$\w", "e"),
("posicionales $1 $@", r"\$[0-9@*#]", "e"),
("estado $?", r"\$\?", "e"),
("y/o && ||", r"&&|\|\|", "s"),
("tubería |", r"(?<![|&])\|(?!\|)", "s"),
("grupo { }", r"\{[^{}]*;\s*\}", "s"),
("subshell ( )", r"(?<![$(])\((?!\()", "s"),
("redirección >", r"(?<![0-9<>&])>", "s"),
("redirección 2>", r"\d>", "s"),
("aquí-doc <<", r"<<", "s"),
("glob *", r"\*", "s"),
("negación !", r"(?:^|[;&|(])\s*!\s", "s"),
("secuencia ;", r";", "s"),
("segundo plano &", r"(?<![&>])&(?![&>])", "s"),
]
BUILTINS_POSIX = {
":", ".", "break", "continue", "eval", "exec", "exit", "export", "readonly", "return",
"set", "shift", "times", "trap", "unset", "cd", "echo", "printf", "pwd", "read", "test",
"[", "command", "getopts", "hash", "umask", "wait", "alias", "unalias", "type", "kill",
"jobs", "fg", "bg", "ulimit", "local", "true", "false",
}
PALABRAS_CLAVE = {
"if", "then", "elif", "else", "fi", "for", "while", "until", "do", "done", "case", "esac",
"in", "{", "}", "(", ")", "!", "time", "function", "select",
}
SEPARADORES = {";", "&&", "||", "|", "&", "(", ")", "{", "}", "do", "then", "else", "elif"}
def ordenes(codigo: str) -> list[str]:
"""Palabras en posición de ORDEN. Tokeniza respetando comillas (shlex) y toma la primera
palabra de cada comando simple. Aproximado: `shlex` no entiende la gramática del shell, sólo
las comillas. Sirve para inventariar, no para decidir.
Casos que sí se tratan porque ensuciaban la cuenta medida: `for h in …` daba `h` como orden,
y el `!` de `[ ! -S … ]` daba `-S`.
"""
try:
lex = shlex.shlex(codigo, punctuation_chars=True, posix=True)
lex.whitespace_split = True
toks = list(lex)
except ValueError:
return []
salida, esperando, saltar_hasta = [], True, None
for t in toks:
if saltar_hasta:
if t in saltar_hasta:
saltar_hasta = None
esperando = t in {"do", ";"}
continue
if t in ("for", "case", "select"):
saltar_hasta = {"do", ";", "in"} if t == "for" else {"in"}
esperando = False
continue
if t in SEPARADORES:
esperando = True
continue
if not esperando:
continue
if t in PALABRAS_CLAVE or t.startswith("-") or t == "_SUST_":
continue
if re.fullmatch(r"\w+=.*", t): # asignación previa al comando
continue
salida.append(t)
esperando = False
return salida
def analizar(f: Fragmento) -> None:
"""Rellena rasgos/builtins/externas del fragmento. El escáner vive en
`scripts/lib/sh_analisis.py`, compartido con `sh-superficie-scripts.py`."""
if not f.codigo:
return
piezas = piezas_shell(f.codigo)
sintaxis = "\n".join(s for _, s, _ in piezas)
expansiones = "\n".join(e for _, _, e in piezas)
for nombre, patron, donde in RASGOS:
if re.search(patron, sintaxis if donde == "s" else expansiones, re.M):
f.rasgos.add(nombre)
todas = []
for original, _, _ in piezas:
todas.extend(ordenes(original))
for o in todas:
base = Path(o).name if o.startswith("/") else o
if base in BUILTINS_POSIX:
f.builtins[base] += 1
elif re.fullmatch(r"[\w.+-]+", base):
f.externas[base] += 1
RUTA_APPLETS = RAIZ / "docs/state/busybox-applets.tsv"
def applets_conocidos() -> dict[str, tuple[str, str]]:
"""`applet → (destino, sigue|RETIRADO)`, del inventario del recorte 401 → 277.
Cruzarlo con lo que invocan las cards ES el guardián que `plan-botar-busybox.md` dice que
falta: hoy, una card que llame a un applet ya retirado del `defconfig` no falla en ningún
test — falla **en el arranque de otra máquina, semanas después**.
"""
if not RUTA_APPLETS.is_file():
return {}
filas = {}
with RUTA_APPLETS.open(encoding="utf-8") as fh:
cabecera = fh.readline().rstrip("\n").split("\t")
try:
i_dest = cabecera.index("destino")
i_rec = [i for i, c in enumerate(cabecera) if c.startswith("recorte")][0]
except (ValueError, IndexError):
return {}
for linea in fh:
campos = linea.rstrip("\n").split("\t")
if len(campos) > max(i_dest, i_rec):
filas[campos[0]] = (campos[i_dest], campos[i_rec])
return filas
f.rasgos, f.builtins, f.externas = rasgos_y_ordenes(f.codigo)
# ───────────────────────────────────────────────── informe
@@ -434,7 +236,10 @@ def main() -> int:
for f in con_shell:
hist.update(f.rasgos)
ancho = max((len(r) for r in hist), default=10)
for rasgo, n in hist.most_common():
catalogo = [r for r, _, _ in RASGOS]
# Orden DETERMINISTA: por cuenta y, en el empate, por el orden del catálogo. Con `most_common`
# los empates salían en el orden de iteración de un `set`, y la salida no era diffable.
for rasgo, n in sorted(hist.items(), key=lambda kv: (-kv[1], catalogo.index(kv[0]))):
barra = "" * n
print(f" {rasgo:<{ancho}} {n:3d} {barra}")
no_usados = [r for r, _, _ in RASGOS if r not in hist]
@@ -447,15 +252,20 @@ def main() -> int:
bi.update(f.builtins)
ex.update(f.externas)
print(f"\n── Builtins que el shell tiene que traer ({len(bi)})")
print(" " + " ".join(f"{k}×{v}" for k, v in bi.most_common()))
print(" " + " ".join(f"{k}×{v}" for k, v in sorted(bi.items(), key=lambda kv: (-kv[1], kv[0]))))
print(f"\n── Órdenes EXTERNAS que la imagen tiene que traer ({len(ex)})")
print(" " + " ".join(f"{k}×{v}" for k, v in ex.most_common()))
desnudas, absolutas = desnudas_y_absolutas(ex)
orden_ex = sorted(desnudas.items(), key=lambda kv: (-kv[1], kv[0]))
print(" desnudas (las resuelve el PATH): "
+ " ".join(f"{k}×{v}" for k, v in orden_ex))
print(" por ruta absoluta (no pasan por PATH): "
+ " ".join(f"{k}×{v}" for k, v in sorted(absolutas.items(), key=lambda kv: (-kv[1], kv[0]))))
# ── el cruce con el recorte de busybox: acá vive el guardián que el plan dice que falta
applets = applets_conocidos()
if applets:
retirados, siguen, ajenas = [], [], []
for orden, n in ex.most_common():
for orden, n in orden_ex:
fila = applets.get(orden)
if fila is None:
ajenas.append(orden)
+346
View File
@@ -0,0 +1,346 @@
#!/usr/bin/env python3
"""Mide la superficie de shell de `scripts/` — SDD: docs/plan-botar-busybox.md.
Hermano de `sh-superficie-cards.py`, con el escáner compartido en `scripts/lib/sh_analisis.py`.
**Pero contesta una pregunta distinta, y conviene no mezclarlas:**
- las CARDS las ejecuta el producto ⇒ su superficie es el contrato de un `/bin/sh` propio HOY;
- `scripts/` lo ejecuta el HUB y el worker, bajo el bash del anfitrión ⇒ su superficie sólo
obliga el día del auto-alojamiento (Etapa C). Hoy lo que importa acá es otra cosa:
**¿algún guion se declara POSIX y no lo es?** Porque ése SÍ se rompe hoy —
en la imagen `/bin/sh` es el ash de busybox, no bash.
Tres poblaciones, por la PRIMERA línea (no por «contiene un shebang»: dos scripts bash empotran un
`#!/bin/sh` en un heredoc y un grep ingenuo los cuenta como POSIX — pasó):
1. **POSIX** — `#!/bin/sh`, `#!/usr/bin/env sh`, `#!/bin/ash`
2. **bash** — `#!/bin/bash`, `#!/usr/bin/env bash`
3. **EMPOTRADOS** — guiones `#!/bin/sh` que otro script ESCRIBE con un heredoc (un `/init`, un
envoltorio). Ésos viajan a la imagen y los corre el ash, así que son los que más importan y
los que nadie parsea.
De cada uno: si lo parsea el ash, si lo parsea bash, qué construcciones usa y qué órdenes invoca.
Para la población bash, además, **por qué** es bash: qué bashismo concreto trae.
Uso:
scripts/sh-superficie-scripts.py # resumen + TSV
scripts/sh-superficie-scripts.py --list # el detalle por fichero
scripts/sh-superficie-scripts.py --dump-dir D # los empotrados como ficheros, para el banco
"""
from __future__ import annotations
import argparse
import collections
import glob as globmod
import re
import subprocess
import sys
from dataclasses import dataclass, field
from pathlib import Path
RAIZ = Path(__file__).resolve().parent.parent
sys.path.insert(0, str(Path(__file__).resolve().parent / "lib"))
from sh_analisis import (RASGOS, RUTA_APPLETS, applets_conocidos, # noqa: E402
desnudas_y_absolutas, heredocs, piezas_shell,
rasgos_y_ordenes)
SHEBANG_POSIX = {"#!/bin/sh", "#!/usr/bin/env sh", "#!/bin/ash", "#!/bin/busybox sh"}
SHEBANG_BASH = {"#!/bin/bash", "#!/usr/bin/env bash", "#!/bin/bash -e"}
# Lo que hace que un guion NO sea POSIX. Se busca sobre el texto de SINTAXIS (fuera de comillas),
# así un `[[` dentro de un mensaje no cuenta.
BASHISMOS = [
("[[ ]]", r"\[\["),
("array=( )", r"^\s*\w+=\(|\bdeclare\s+-[aA]\b|\blocal\s+-[aA]\b"),
("${arr[i]}", r"\$\{\w+\[[^]]*\]"),
("(( ))", r"(?<!\$)\(\("),
("<<< (here-string)", r"<<<"),
("<( ) proceso", r"<\("),
("declare/typeset", r"\bdeclare\b|\btypeset\b"),
("mapfile/readarray", r"\bmapfile\b|\breadarray\b"),
("${v//x/y}", r"\$\{\w+//"),
("${v^^} ${v,,}", r"\$\{\w+\^\^|\$\{\w+,,"),
("+= en asignación", r"^\s*\w+\+="),
("function nombre", r"\bfunction\s+\w+"),
("PIPESTATUS/BASH_*", r"\bPIPESTATUS\b|\bBASH_\w+"),
("echo -e", r"\becho\s+-e\b"),
("read -a / -d", r"\bread\s+-[ad]\b"),
("source (no `.`)", r"^\s*source\s"),
]
@dataclass
class Guion:
ruta: str
poblacion: str # posix | bash | empotrado | sin-shebang
shebang: str
codigo: str
padre: str | None = None # para los empotrados: quién lo escribe
ash_ok: bool | None = None
bash_ok: bool | None = None
ash_error: str = ""
rasgos: set[str] = field(default_factory=set)
bashismos: set[str] = field(default_factory=set)
builtins: collections.Counter = field(default_factory=collections.Counter)
externas: collections.Counter = field(default_factory=collections.Counter)
lineas: int = 0
# ───────────────────────────────────────────────── recolección
# Ficheros de shell SIN extensión `.sh` y sin shebang que igual son shell. Se nombran uno por uno
# a propósito: el balde automático traía `.html`, `.mp4` y `.c`, y un balde así no es una medición.
# `perfil/profile` importa doble porque VIAJA A LA IMAGEN: lo lee toda shell de login.
EXTRAS = {"scripts/servidor/perfil/profile"}
FUERA = ("/fixtures/sh-casos/", "/__pycache__/", "/.git/")
def es_shell(ruta: Path, primera: str) -> bool:
"""Un fichero cuenta como shell si lo dice su extensión, su shebang, o la lista EXTRAS."""
if str(ruta.relative_to(RAIZ)) in EXTRAS:
return True
if ruta.suffix == ".sh":
return True
return primera in SHEBANG_POSIX or primera in SHEBANG_BASH
def ficheros_shell() -> list[tuple[Path, str, str]]:
"""`(ruta, texto, primera línea)` de cada fichero de shell, y cuántos se descartaron."""
salida, descartados = [], 0
for ruta in sorted(RAIZ.glob("scripts/**/*")):
if not ruta.is_file() or any(f in f"/{ruta.as_posix()}/" for f in FUERA):
continue
try:
texto = ruta.read_text(encoding="utf-8", errors="replace")
except OSError:
continue
primera = texto.splitlines()[0].strip() if texto.strip() else ""
if es_shell(ruta, primera):
salida.append((ruta, texto, primera))
else:
descartados += 1
ficheros_shell.descartados = descartados
return salida
def empotrados(texto: str) -> list[tuple[str, str]]:
"""Los cuerpos de heredoc que SON guiones de shell: `(delimitador, cuerpo)`.
Un heredoc de configuración —o de Python— no es un guion. La separación la hace
`sh_analisis.heredocs`; acá sólo se filtra por shebang.
"""
return [(d, c) for d, c in heredocs(texto)[1]
if c.lstrip().startswith("#!") and ("sh" in c.lstrip().splitlines()[0])]
def recolectar() -> list[Guion]:
guiones: list[Guion] = []
for ruta, texto, primera in ficheros_shell():
rel = str(ruta.relative_to(RAIZ))
if primera in SHEBANG_POSIX:
pob = "posix"
elif primera in SHEBANG_BASH:
pob = "bash"
elif primera.startswith("#!"):
pob = "otro-interprete"
else:
pob = "sin-shebang" # se hace `source`, no se ejecuta
if pob != "otro-interprete":
guiones.append(Guion(ruta=rel, poblacion=pob, shebang=primera or "(ninguno)",
codigo=texto, lineas=texto.count("\n")))
for delim, cuerpo in empotrados(texto):
guiones.append(Guion(ruta=f"{rel}«{delim}»", poblacion="empotrado",
shebang=cuerpo.lstrip().splitlines()[0], codigo=cuerpo,
padre=rel, lineas=cuerpo.count("\n")))
return guiones
# ───────────────────────────────────────────────── análisis
def parsea(binario: list[str], codigo: str, tmp: Path) -> tuple[bool, str]:
tmp.write_text(codigo, encoding="utf-8")
p = subprocess.run(binario + ["-n", str(tmp)], capture_output=True, timeout=30)
err = p.stderr.decode("utf-8", "replace").strip().splitlines()
return p.returncode == 0, (err[0].split(": ", 1)[-1] if err else "")
def analizar(g: Guion, ash: list[str], bash: list[str], tmp: Path) -> None:
# El ANÁLISIS va sobre el texto sin cuerpos de heredoc (ésos se miden como población
# `empotrado`, o no se miden si no son shell); el PARSEO va sobre el fichero entero, que es
# lo que el shell ve de verdad.
propio = g.codigo if g.poblacion == "empotrado" else heredocs(g.codigo)[0]
g.rasgos, g.builtins, g.externas = rasgos_y_ordenes(propio)
sintaxis = "\n".join(s for _, s, _ in piezas_shell(propio))
g.bashismos = {n for n, patron in BASHISMOS if re.search(patron, sintaxis, re.M)}
g.ash_ok, g.ash_error = parsea(ash, g.codigo, tmp)
g.bash_ok, _ = parsea(bash, g.codigo, tmp)
def descubrir(patron: str, sufijo: str) -> list[str] | None:
for d in sorted(globmod.glob(patron)):
p = Path(d) / sufijo
if p.is_file():
return [str(p)]
return None
# ───────────────────────────────────────────────── informe
def main() -> int:
ap = argparse.ArgumentParser(description="Mide la superficie de shell de scripts/.")
ap.add_argument("--ash", metavar="RUTA", help="binario del shell POSIX de control (busybox)")
ap.add_argument("--bash", metavar="RUTA", help="binario de bash")
ap.add_argument("--list", action="store_true", help="detalle por fichero")
ap.add_argument("--dump-dir", metavar="DIR", help="volcar los guiones EMPOTRADOS como ficheros")
ap.add_argument("--tsv", metavar="RUTA", default=str(RAIZ / "work/sh-superficie-scripts.tsv"))
args = ap.parse_args()
ash = [args.ash, "sh"] if args.ash else (
(descubrir("/store/*-busybox", "bin/busybox") or [None]) + ["sh"])
bash = [args.bash] if args.bash else descubrir("/store/*-bash", "bin/bash")
if not ash[0] or not bash:
print("✗ no encontré los shells. Pasá --ash y --bash "
"(p.ej. /store/…-busybox/bin/busybox y /store/…-bash/bin/bash)", file=sys.stderr)
return 2
print(f"Control POSIX: {' '.join(ash)}\nbash: {bash[0]}\n")
guiones = recolectar()
print(f"Ficheros de scripts/ descartados por no ser shell (.html, .c, .mp4, …): "
f"{ficheros_shell.descartados}")
tmp = Path(RAIZ / "work" / ".sh-superficie-tmp.sh")
tmp.parent.mkdir(parents=True, exist_ok=True)
try:
for g in guiones:
analizar(g, ash, bash, tmp)
finally:
tmp.unlink(missing_ok=True)
por = collections.defaultdict(list)
for g in guiones:
por[g.poblacion].append(g)
# ── inventario y la matriz de parseo
print("── Poblaciones, y quién las parsea")
print(f" {'población':<16} {'n':>4} {'líneas':>7} parsea ash parsea bash")
for pob in ("posix", "bash", "empotrado", "sin-shebang"):
gs = por.get(pob, [])
if not gs:
continue
print(f" {pob:<16} {len(gs):>4} {sum(g.lineas for g in gs):>7} "
f"{sum(1 for g in gs if g.ash_ok):>5}/{len(gs):<5} "
f"{sum(1 for g in gs if g.bash_ok):>5}/{len(gs)}")
# ── lo que se rompe HOY: un guion que se declara POSIX y el ash no lo parsea
rotos = [g for g in guiones if g.poblacion in ("posix", "empotrado") and not g.ash_ok]
print(f"\n── Se declaran POSIX y el ash NO los parsea: {len(rotos)}")
if rotos:
for g in rotos:
print(f"{g.ruta} ({g.shebang})")
print(f" ash: {g.ash_error}")
print(f" bash lo parsea: {'sí ⇒ es un bashismo' if g.bash_ok else 'no ⇒ error de sintaxis'}")
if g.bashismos:
print(f" bashismos detectados: {', '.join(sorted(g.bashismos))}")
else:
print(" ✓ ninguno. Todo lo que dice `#!/bin/sh` lo parsea el ash de busybox.")
# ── los bash: ¿por qué son bash?
bashs = por.get("bash", [])
if bashs:
solo_bash = [g for g in bashs if not g.ash_ok]
print(f"\n── Los {len(bashs)} guiones `#!bash`: {len(bashs)-len(solo_bash)} parsean tal cual "
f"bajo ash, {len(solo_bash)} no")
print(" (parsear no es correr: un `echo -e` o un `local` parsean en los dos y se portan distinto)")
hist = collections.Counter()
for g in bashs:
hist.update(g.bashismos)
cat = [n for n, _ in BASHISMOS]
print("\n Bashismos, por cuántos guiones los traen:")
for n, c in sorted(hist.items(), key=lambda kv: (-kv[1], cat.index(kv[0]))):
print(f" {n:<20} {c:>3} {'' * c}")
sin = [n for n, _ in BASHISMOS if n not in hist]
if sin:
print(f" no aparece: {', '.join(sin)}")
# ── la superficie del lenguaje, por población
for pob in ("posix", "bash", "empotrado"):
gs = por.get(pob, [])
if not gs:
continue
hist = collections.Counter()
for g in gs:
hist.update(g.rasgos)
cat = [r for r, _, _ in RASGOS]
print(f"\n── Construcciones · población {pob} ({len(gs)} guiones)")
for r, c in sorted(hist.items(), key=lambda kv: (-kv[1], cat.index(kv[0]))):
print(f" {r:<20} {c:>4}/{len(gs)}")
faltan = [r for r in cat if r not in hist]
if faltan:
print(f" no aparece: {', '.join(faltan)}")
# ── órdenes
bi, ex = collections.Counter(), collections.Counter()
for g in guiones:
bi.update(g.builtins)
ex.update(g.externas)
print(f"\n── Builtins usados en todo scripts/ ({len(bi)})")
print(" " + " ".join(f"{k}×{v}" for k, v in sorted(bi.items(), key=lambda kv: (-kv[1], kv[0]))))
desnudas, absolutas = desnudas_y_absolutas(ex)
top = sorted(desnudas.items(), key=lambda kv: (-kv[1], kv[0]))
print(f"\n── Órdenes externas por NOMBRE DESNUDO ({len(desnudas)} distintas, las resuelve el "
f"PATH) · las 30 más usadas")
print(" " + " ".join(f"{k}×{v}" for k, v in top[:30]))
abs_top = sorted(absolutas.items(), key=lambda kv: (-kv[1], kv[0]))
print(f"\n── …y por RUTA ABSOLUTA ({len(absolutas)} distintas, no pasan por PATH) · las 15 más "
f"usadas")
print(" " + " ".join(f"{k}×{v}" for k, v in abs_top[:15]))
applets = applets_conocidos()
if applets:
retiradas = [(k, v) for k, v in top if applets.get(k, ("", ""))[1] == "RETIRADO"]
print(f"\n── Cruce con el recorte ({RUTA_APPLETS.name})")
print(f" nombres desnudos que son applet RETIRADO: {len(retiradas)}"
+ (f"{', '.join(k for k, _ in retiradas)}" if retiradas else ""))
print(" ⚠ sólo se cruzan los DESNUDOS: una ruta absoluta no se resuelve por PATH y por lo"
"\n tanto no puede ser un applet. Cruzar las absolutas daba un falso positivo"
"\n medido (`/sbin/init`, que en el producto es arje).")
print(" ⚠ la PUERTA es `scripts/busybox-vigia.py`, que ya falla si alguien invoca uno;"
"\n esto es inventario, y su lista es más ancha (incluye no-applets).")
if args.list:
print("\n── Detalle")
for g in sorted(guiones, key=lambda x: (x.poblacion, x.ruta)):
marca = "" if g.ash_ok else " ⛔ ash no parsea"
print(f"\n [{g.poblacion}] {g.ruta} ({g.lineas} líneas){marca}")
if g.bashismos:
print(f" bashismos: {', '.join(sorted(g.bashismos))}")
print(f" rasgos: {', '.join(sorted(g.rasgos))}")
if args.dump_dir:
d = Path(args.dump_dir)
d.mkdir(parents=True, exist_ok=True)
n = 0
for g in por.get("empotrado", []):
nombre = re.sub(r"[^\w.-]", "_", g.ruta) + ".sh"
(d / nombre).write_text(g.codigo, encoding="utf-8")
n += 1
print(f"\nEmpotrados escritos en {d} ({n} ficheros)")
tsv = Path(args.tsv)
tsv.parent.mkdir(parents=True, exist_ok=True)
with tsv.open("w", encoding="utf-8") as fh:
fh.write("poblacion\truta\tpadre\tshebang\tlineas\tash_ok\tbash_ok\tash_error\t"
"bashismos\trasgos\tbuiltins\texternas\n")
for g in guiones:
fh.write(f"{g.poblacion}\t{g.ruta}\t{g.padre or ''}\t{g.shebang}\t{g.lineas}\t"
f"{'si' if g.ash_ok else 'no'}\t{'si' if g.bash_ok else 'no'}\t{g.ash_error}\t"
f"{'|'.join(sorted(g.bashismos))}\t{'|'.join(sorted(g.rasgos))}\t"
f"{'|'.join(sorted(g.builtins))}\t{'|'.join(sorted(g.externas))}\n")
print(f"\nTSV: {tsv}")
return len(rotos)
if __name__ == "__main__":
sys.exit(main())