> 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…
Markdown 19Rust 18TOML 8Shell 1189 Definiciones
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.
diff --git a/docs/plan-botar-busybox.md b/docs/plan-botar-busybox.md
index ff5cd6f7..beb14217 100644
--- a/docs/plan-botar-busybox.md
+++ b/docs/plan-botar-busybox.md
@@ -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 3–7 **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.
diff --git a/scripts/lib/sh_analisis.py b/scripts/lib/sh_analisis.py
new file mode 100644
index 00000000..0cab151f
--- /dev/null
+++ b/scripts/lib/sh_analisis.py
@@ -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
diff --git a/scripts/sh-superficie-cards.py b/scripts/sh-superficie-cards.py
index 6c99c0e2..29b00457 100755
--- a/scripts/sh-superficie-cards.py
+++ b/scripts/sh-superficie-cards.py
@@ -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)
diff --git a/scripts/sh-superficie-scripts.py b/scripts/sh-superficie-scripts.py
new file mode 100755
index 00000000..86dd2b44
--- /dev/null
+++ b/scripts/sh-superficie-scripts.py
@@ -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())