gioser

sergio/takana

> Una distribución Linux construida con un laboratorio funcional y hermético en el > sótano, y una terminal mutable, clásica y anárquica en el piso de arriba — diseñada > desde el suelo para que una IA programadora entienda, modifique y co…

TOML 1572Shell 203Rust 109Markdown 993728 Sutichaykuna

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.

a4105cba118388833d022938b1945515cad62874 · ruwaq Sergio · (1 killa ñawpaq) · tayta a66346fb0 · kay commitpi sach'ata qhaway

Semántico sayk'uy · 23

scripts/lib/sh_analisis.py

scripts/sh-superficie-cards.py

scripts/sh-superficie-scripts.py

4 qillqakuna · +867 −214

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())