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"(?", r"(?&])>", "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"(?", r"(?&])>", "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"(? 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())