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 1184Shell 70Rust 62Markdown 501503 Definiciones

sh: el banco DIFERENCIAL — compara el ÁRBOL producido, no sólo stdout, y el generador saca el bug de brush sin saber que existe

El banco POSIX de 57 casos lo pasa brush 56/57 y aun así no construye una
receta autotools: mide lo que a uno se le ocurrió preguntar. Este pregunta si
el candidato hace LO MISMO que el control sobre el material que hay.

- compara (rc, stdout, árbol de ficheros producido); stderr es aviso, no
  divergencia — el texto de error diverge legítimamente
- el árbol es la columna que faltaba: el caso 10 diverge SÓLO ahí, que es la
  clase de bug del libtool mal escapado (rc=0, stdout idéntico)
- toda divergencia se confirma re-corriendo control y candidato: separa caso
  no determinista de shell inestable de divergencia real
- fuentes: fixtures de regresión, generador de tortura de comillas, las 1130
  fases del corpus y configure/libtool reales en sh -n

Medido, 1273 casos en 5,5 s: brush 35 duras, bash 8, y CERO en los 1156 casos
de parseo. El generador saca el #1394 entero sin conocerlo. Hallazgo nuevo:
brush no ejecuta nunca el trap EXIT de un subshell — y eso golpea el frente
del PRODUCTO, donde el veredicto del paso 3 decía que no había evidencia en
contra.

ddc6e4845bf502a960047f07d75e4b4053354fe3 · por Sergio · (hace 1 mes) · padre 0db85d51f · ver el árbol en este commit

Delta semántico · 18

scripts/sh-banco-diferencial.py

13 archivos · +846 −0

diff --git a/docs/plan-botar-busybox.md b/docs/plan-botar-busybox.md
index 327cfc59..bf15919d 100644
--- a/docs/plan-botar-busybox.md
+++ b/docs/plan-botar-busybox.md
@@ -454,3 +454,128 @@ ripgrep` en el store del worker.
 - **Las otras 21 dependientes**, cuando la granja llegue.
 - El siguiente corte natural son los `BORRAR` que no se tocaron por prudencia y los 272 que siguen:
   el número baja a 0 por el paso 2 (declarar la munición sellada) y el 5 (`grep`/`sed` POSIX).
+
+---
+
+# El banco DIFERENCIAL — construido y CORRIDO, 2026-09-21
+
+**Instrumento:** `scripts/sh-banco-diferencial.py` · **casos de regresión:**
+`scripts/fixtures/sh-casos/` · **dónde:** el hub, contra los tres shells sellados que ya están en
+`/store` (`2a2b1280…-busybox`, `715237c7…-brush`, `87bf1a59…-bash`). No tocó la granja ni el lock.
+
+## Por qué, si ya existía `sh-banco-posix.sh`
+
+El veredicto del paso 3 dejó escrita la lección y no el instrumento:
+
+> **brush pasa 56/57 del banco POSIX y aun así no construye una sola receta autotools.**
+
+Un banco de casos escritos a mano mide lo que a uno se le ocurrió preguntar. El banco diferencial
+pregunta otra cosa, y no hace falta saber la respuesta de antemano:
+
+> **¿este shell hace LO MISMO que el control, sobre el material que hay?**
+
+Tres decisiones de diseño, y las tres se ganaron con el bug de brush:
+
+1. **Se compara el ÁRBOL DE FICHEROS PRODUCIDO, no sólo stdout.** El `libtool` mal escapado salía
+   con `rc=0` y stdout idéntico: **ningún banco de stdout puede verlo**. Cada caso corre en un
+   directorio descartable y se le toma huella (ruta + bit de ejecución + sha256) a todo lo que
+   escribió. El caso `10-escribe-arbol.sh` diverge **sólo en el árbol** — es la demostración de que
+   esta columna hace falta.
+2. **El esperado es el control, no una aserción.** Un caso no declara resultado: declara material.
+   Eso permite tirarle al banco las 1130 fases del corpus y 26 `configure`/`libtool` reales sin
+   escribir una línea de expectativa.
+3. **Toda divergencia se CONFIRMA** volviendo a correr control y candidato. Un caso que imprime un
+   PID diverge siempre y no dice nada (pasó en el primer intento con la card de arje, que imprimía
+   `$$`); un shell que diverge **de sí mismo** es un hallazgo distinto y peor. Sin esto el banco se
+   llena de ruido y deja de leerse, que es como muere un banco.
+
+Además: todos los shells se invocan por un symlink `sh` (argv[0] igual para todos, que es como se
+despliegan), con el **mismo userland busybox en el `PATH`** —las divergencias tienen que venir del
+shell, no de un `sed` distinto— y con `PATH` apuntando primero al shell bajo prueba, así un `sh`
+recursivo cae en ÉL y no en el del sistema.
+
+## Lo medido — 1273 casos, 5,5 s
+
+```
+scripts/sh-banco-diferencial.py --gen --phases \
+    --files '/work/sergio/work/sources/*/configure' \
+    --files '/work/sergio/work/sources/*/libtool' \
+    control=…-busybox/bin/busybox brush=…-brush/usr/bin/brush bash=…-bash/bin/bash
+```
+
+| fuente | casos | modo | brush | bash |
+|---|---|---|---|---|
+| `cases` — regresiones conocidas | 10 | ejecutado | **4 divergen** | 0 |
+| `gen` — tortura de comillas | 107 | ejecutado | **31 divergen** | 8 |
+| `phases` — las fases de las 549 recetas | 1130 | `sh -n` | **0** | 0 |
+| `files` — `configure` y `libtool` reales | 26 | `sh -n` | **0** | 0 |
+
+**Los 1156 casos de parseo no divergen en ningún shell.** Confirma lo que el paso 3 ya decía —el
+parser de brush está bien— y lo dice ahora con bash de tercero en discordia y con el árbol
+comparado. **Sirve sobre todo para saber dónde NO buscar.**
+
+## Lo que el generador encontró SOLO
+
+El generador barre el producto cartesiano `forma de sustitución × contexto × profundidad de barras
+invertidas × carácter objetivo` (107 casos). **No sabe que el bug #1394 existe, y lo escupe entero:**
+
+```
+✗ [brush] tortura/bt-desnudo-b1-var    control: [hola]    brush: [$V]
+✗ [brush] tortura/bt-desnudo-b2-var    control: [$V]      brush: [\hola]
+✗ [brush] tortura/bt-desnudo-b2-literal control: [x]      brush: [\x]
+✗ [brush] libtool/case-de-config-status  ← el bucle REAL de config.status, divergente
+```
+
+Ése es el punto del ejercicio: **el bug de brush apareció en el primer build real, después de
+sellar la receta y de montar un lab entero. El generador lo habría dado el día cero, en cinco
+segundos, sin saber qué buscaba.**
+
+## Un hallazgo NUEVO, y no es del frente A
+
+El fixture `08-trap-exit-y-estado.sh` destapó algo que no estaba en el veredicto del paso 3:
+
+```sh
+( trap "echo trap-sub" EXIT; true ); echo despues
+# busybox → trap-sub / despues      bash → trap-sub / despues      brush → despues
+```
+
+**brush no ejecuta NUNCA el `trap … EXIT` de un subshell**, ni con `exit` explícito. Es independiente
+de #1394 y, a diferencia de aquél, **golpea el frente B**: un script de limpieza que confía en su
+trap no limpia, en silencio, con `rc=0`. La frase del veredicto —«en el producto brush no tiene
+ninguna evidencia en contra»— ya no se sostiene: ahora la tiene. **Sin reportar upstream todavía.**
+
+Y se confirmó la tercera, ya conocida: `shift` más allá de `$#` devuelve 2 (POSIX/busybox/bash: 1).
+
+## Las 8 de bash, que enseñan a leer el banco
+
+bash diverge en 8 casos del generador, **todos de la misma familia**: comilla o backtick sin cerrar
+dentro de `` `…` ``, donde busybox da `rc=2` y bash acepta. No es que bash esté mal — es que **el
+control es una referencia, no la verdad**. Cuando el candidato diverge, hay que ir a POSIX; el banco
+dice dónde mirar, no quién tiene razón.
+
+## Latencia, de paso
+
+300 invocaciones de `sh -c :` por shell, en el hub:
+
+| shell | µs/invocación | binario |
+|---|---|---|
+| busybox | 464 | 1,17 MB |
+| bash | 864 | 1,57 MB |
+| brush | 1724 | 6,92 MB |
+
+**brush/busybox = 3,7×** acá, contra el 8,9× medido en el worker: esta cifra incluye el `fork`+`exec`
+de Python y comprime el factor. Sirve para comparar, no como número absoluto. La dirección es la
+misma y el argumento del veredicto no cambia.
+
+## Lo que este banco NO mide (y hay que decirlo así)
+
+- **No EJECUTA las fases del corpus** — sólo las parsea. Ejecutarlas necesita el sandbox y sus
+  fuentes, que es la extensión natural y la que cerraría el caso: correr un `configure` real bajo el
+  candidato y **comparar el árbol generado** contra el del control. La maquinaria de comparación ya
+  está; falta engancharla al sandbox.
+- **El generador sólo tortura comillas.** Faltan familias enteras: expansión de parámetros, `IFS` y
+  división en campos, aritmética, here-docs, señales y control de trabajos.
+- **Nada de interactivo** — ni edición de línea, ni historial, ni `job control`.
+- **El árbol no mira permisos finos ni mtimes**, sólo ruta + bit de ejecución + contenido.
+- **El banco no prueba suficiencia.** Es la misma advertencia de siempre, y ahora con dos
+  instrumentos que la ilustran en vez de uno.
diff --git a/scripts/fixtures/sh-casos/01-brush-1394-comillas-invertidas.sh b/scripts/fixtures/sh-casos/01-brush-1394-comillas-invertidas.sh
new file mode 100644
index 00000000..0e88e200
--- /dev/null
+++ b/scripts/fixtures/sh-casos/01-brush-1394-comillas-invertidas.sh
@@ -0,0 +1,8 @@
+# POSIX.1-2024 §2.6.3: dentro de `…` la barra invertida es literal SALVO ante $, ` y \.
+# Reproductor exacto de reubeno/brush#1394 (docs/plan-botar-busybox.md, veredicto del paso 3).
+# Es el bug que hizo que 0 de 3 recetas autotools construyeran bajo brush.
+V=hola
+echo "a: [`echo \\$V`]"
+echo "b: [`echo \\\\`]"
+echo "c: [`echo \$V`]"
+echo "d: [$(echo \\$V)]"
diff --git a/scripts/fixtures/sh-casos/02-shift-mas-alla-de-argc.sh b/scripts/fixtures/sh-casos/02-shift-mas-alla-de-argc.sh
new file mode 100644
index 00000000..23c98e99
--- /dev/null
+++ b/scripts/fixtures/sh-casos/02-shift-mas-alla-de-argc.sh
@@ -0,0 +1,5 @@
+# La única divergencia que el banco POSIX de 57 casos encontró en brush: `shift` más allá de $#
+# devuelve 2; POSIX/busybox/bash devuelven 1. Cosmético salvo bajo `set -e`.
+set --
+shift 2>/dev/null
+echo "rc=$?"
diff --git a/scripts/fixtures/sh-casos/03-card-arje-exec.sh b/scripts/fixtures/sh-casos/03-card-arje-exec.sh
new file mode 100644
index 00000000..efd2ec06
--- /dev/null
+++ b/scripts/fixtures/sh-casos/03-card-arje-exec.sh
@@ -0,0 +1,10 @@
+# La forma exacta del payload de las cards de arje (crates/takana-bootstrap/src/lib.rs:240):
+#   argv = ["sh", "-c", "<cmd>; <cmd>; exec <daemon>"]
+# El `exec` final tiene que REEMPLAZAR el proceso, no forkear: si el shell forkea, arje supervisa
+# al shell y no al daemon, y el reinicio queda apuntando al proceso equivocado.
+# El caso compara PIDs entre sí, nunca los imprime: un PID en stdout es ruido no determinista.
+echo paso1 > paso1.txt
+echo $$ > pid-antes.txt
+echo paso2
+exec sh -c 'if [ "$(cat pid-antes.txt)" = "$$" ]; then r=si; else r=no; fi; rm -f pid-antes.txt; echo "exec-reemplaza=$r"'
+echo NO-DEBERIA-LLEGAR
diff --git a/scripts/fixtures/sh-casos/04-heredoc-comillado-y-guion.sh b/scripts/fixtures/sh-casos/04-heredoc-comillado-y-guion.sh
new file mode 100644
index 00000000..6891da06
--- /dev/null
+++ b/scripts/fixtures/sh-casos/04-heredoc-comillado-y-guion.sh
@@ -0,0 +1,12 @@
+# 40 heredocs en 29 recetas del corpus. El delimitador comillado NO expande; el `<<-` come tabs
+# de indentación (tabs REALES, no espacios) y nada más.
+V=expandido
+cat <<EOF
+uno: $V
+EOF
+cat <<"EOF"
+dos: $V
+EOF
+cat <<-EOF
+	tres: $V
+	EOF
diff --git a/scripts/fixtures/sh-casos/05-set-e-y-subshell.sh b/scripts/fixtures/sh-casos/05-set-e-y-subshell.sh
new file mode 100644
index 00000000..287a6e41
--- /dev/null
+++ b/scripts/fixtures/sh-casos/05-set-e-y-subshell.sh
@@ -0,0 +1,9 @@
+# `set -e` es la semántica históricamente más divergente entre shells. ADR 0020 dice que las fases
+# de build corren SIN -e, pero las cards y los scripts del producto sí lo usan.
+set -e
+( false ) || echo "or-salva"
+if false; then echo no; fi
+echo "sigo-vivo"
+f() { false; echo "en-funcion-tras-false"; }
+f || echo "funcion-abortada"
+echo fin
diff --git a/scripts/fixtures/sh-casos/06-ifs-split-y-glob.sh b/scripts/fixtures/sh-casos/06-ifs-split-y-glob.sh
new file mode 100644
index 00000000..c1ecc66a
--- /dev/null
+++ b/scripts/fixtures/sh-casos/06-ifs-split-y-glob.sh
@@ -0,0 +1,13 @@
+# Orden de expansiones: sustitución → división en campos por IFS → expansión de rutas → quita de
+# comillas. Un shell que las ordene mal rompe cualquier `for f in $LISTA`.
+: > a.txt; : > b.txt
+IFS=,
+set -- $(echo "uno,dos,tres")
+echo "campos=$#  todos=[$*]"
+IFS=' '
+for f in *.txt; do echo "glob=$f"; done
+for f in noexiste*; do echo "sin-match=$f"; done
+x="a  b"
+echo "comillado=[$x]  desnudo=[$x]" 
+set -- $x
+echo "partido=$#"
diff --git a/scripts/fixtures/sh-casos/07-printf-y-echo.sh b/scripts/fixtures/sh-casos/07-printf-y-echo.sh
new file mode 100644
index 00000000..f4fd57fc
--- /dev/null
+++ b/scripts/fixtures/sh-casos/07-printf-y-echo.sh
@@ -0,0 +1,6 @@
+# `echo` con barras invertidas es territorio no portable (XSI lo expande, otros no) y el corpus lo
+# usa. `printf` es el contrato real. Esto mide en qué se separan.
+printf '%s|%03d|%s\n' uno 7 'a\tb'
+printf '%b\n' 'a\tb'
+echo 'a\tb'
+echo -n "sin-salto"; echo ""
diff --git a/scripts/fixtures/sh-casos/08-trap-exit-y-estado.sh b/scripts/fixtures/sh-casos/08-trap-exit-y-estado.sh
new file mode 100644
index 00000000..1d1e37da
--- /dev/null
+++ b/scripts/fixtures/sh-casos/08-trap-exit-y-estado.sh
@@ -0,0 +1,5 @@
+# `trap ... EXIT` + el estado de salida propagado: lo usan los scripts de la granja para limpiar.
+trap 'echo "trap-exit rc=$?"' EXIT
+( trap 'echo "trap-subshell"' EXIT; true )
+false
+echo "ultimo=$?"
diff --git a/scripts/fixtures/sh-casos/09-local-en-funcion.sh b/scripts/fixtures/sh-casos/09-local-en-funcion.sh
new file mode 100644
index 00000000..778a1d05
--- /dev/null
+++ b/scripts/fixtures/sh-casos/09-local-en-funcion.sh
@@ -0,0 +1,8 @@
+# `local` NO es POSIX y aun así lo usan 20 recetas del corpus. Cualquier sh propio lo necesita, y
+# los shells que lo traen no coinciden en si hereda valor ni en el rc de `local x=$(false)`.
+x=fuera
+f() { local x=dentro; echo "dentro=$x"; }
+f
+echo "fuera=$x"
+g() { local y; echo "vacia=[$y]"; }
+g
diff --git a/scripts/fixtures/sh-casos/10-escribe-arbol.sh b/scripts/fixtures/sh-casos/10-escribe-arbol.sh
new file mode 100644
index 00000000..bbe244e1
--- /dev/null
+++ b/scripts/fixtures/sh-casos/10-escribe-arbol.sh
@@ -0,0 +1,12 @@
+# Caso de ÁRBOL: rc y stdout idénticos, lo que cambia es el fichero escrito. Es la clase de bug
+# que tumbó a brush (el libtool generado con un nivel de escapado de menos) y la que ningún banco
+# de stdout puede ver.
+V='a"b`c\d$e'
+{
+  echo "directo=$V"
+  echo "bt=`echo \\$V`"
+  echo "dollar=$(echo \\$V)"
+  printf '%s\n' "$V"
+} > generado.txt
+mkdir -p sub && cp generado.txt sub/copia.txt
+echo listo
diff --git a/scripts/fixtures/sh-casos/LEEME.md b/scripts/fixtures/sh-casos/LEEME.md
new file mode 100644
index 00000000..120a789b
--- /dev/null
+++ b/scripts/fixtures/sh-casos/LEEME.md
@@ -0,0 +1,15 @@
+# Casos de regresión del banco diferencial
+
+Un fichero `.sh` por caso. Los corre `scripts/sh-banco-diferencial.py` bajo cada shell en un
+directorio limpio y compara **rc + stdout + árbol de ficheros producido** contra el control.
+
+Reglas:
+
+- **Un caso por fichero**, con un comentario arriba que diga QUÉ mide y DE DÓNDE salió
+  (bug, receta, card). Un caso sin procedencia es un caso que nadie sabe por qué está.
+- **Nada de rutas absolutas ni red.** El `cwd` es descartable y el `PATH` trae el userland de
+  busybox más el shell bajo prueba; `HOME`, `LC_ALL=C`, `TZ=UTC` fijos.
+- **Si el caso escribe ficheros, mejor** — el árbol producido es lo que atrapa los bugs que no se
+  ven en stdout (el `libtool` mal escapado de brush salía con rc=0 y stdout idéntico).
+- El caso **no declara resultado esperado**: el esperado es lo que haga el control. Esto es un
+  banco diferencial, no una suite de aserciones.
diff --git a/scripts/sh-banco-diferencial.py b/scripts/sh-banco-diferencial.py
new file mode 100755
index 00000000..92d0920c
--- /dev/null
+++ b/scripts/sh-banco-diferencial.py
@@ -0,0 +1,618 @@
+#!/usr/bin/env python3
+"""Banco DIFERENCIAL para candidatos a /bin/sh — SDD: docs/plan-botar-busybox.md.
+
+No pregunta «¿este shell hace lo que yo creo que POSIX manda?» (eso es
+`scripts/sh-banco-posix.sh`, 57 casos escritos a mano: brush lo pasa 56/57 y aun
+así no construye una sola receta autotools). Pregunta otra cosa:
+
+    ¿este shell hace LO MISMO que el control, sobre el material que hay?
+
+Cada caso se corre bajo N shells en un directorio limpio y se compara la tupla
+completa contra el control:
+
+    (rc, stdout, ÁRBOL DE FICHEROS PRODUCIDO)   ← divergencia DURA
+    (stderr normalizado)                        ← AVISO: el texto de error
+                                                  diverge legítimamente
+
+El árbol de ficheros es la parte que importa y la que faltaba: el bug que tumbó
+a brush (POSIX 2.6.3, la barra invertida dentro de `…`) no se ve en el stdout de
+ningún caso sintético — se ve en el `libtool` que el shell ESCRIBE, 25 líneas con
+un nivel de escapado de menos. Un banco que sólo mira stdout lo deja pasar.
+
+Fuentes de casos (combinables):
+
+  --cases DIR     ficheros .sh sueltos (regresiones conocidas)
+  --gen           generador de tortura de comillas: producto cartesiano de
+                  forma de sustitución × contexto × profundidad de barras ×
+                  carácter objetivo. Es el que encuentra el bug de brush SIN
+                  saber que existe
+  --phases        las fases [build.phases] de recipes/*.toml, en modo `sh -n`
+  --files GLOB    ficheros reales (configure, libtool, config.status) en `sh -n`
+  --latency N     N invocaciones de `sh -c :` por shell, sin comparar nada
+
+Uso:
+    scripts/sh-banco-diferencial.py --gen \\
+        control=/store/2a2b…-busybox/bin/busybox \\
+        brush=/store/7152…-brush/usr/bin/brush
+
+El PRIMER shell es el control salvo que se nombre uno `control=` o se pase
+--control NOMBRE. Salida: tabla por shell + TSV. Estado de salida = número de
+divergencias duras del peor shell (0 = todos coinciden con el control).
+"""
+
+from __future__ import annotations
+
+import argparse
+import concurrent.futures
+import glob as globmod
+import hashlib
+import os
+import re
+import shutil
+import subprocess
+import sys
+import tempfile
+import time
+from dataclasses import dataclass, field
+from pathlib import Path
+
+RAIZ = Path(__file__).resolve().parent.parent
+
+
+# ─────────────────────────────────────────────────────────── modelo
+
+@dataclass
+class Shell:
+    nombre: str
+    binario: Path
+    dir_sh: Path | None = None  # dir con un symlink `sh` -> binario (argv[0]=sh)
+
+    @property
+    def sh(self) -> Path:
+        return (self.dir_sh / "sh") if self.dir_sh else self.binario
+
+
+@dataclass
+class Caso:
+    nombre: str
+    origen: str          # cases | gen | phases | files
+    codigo: str
+    modo: str = "run"    # run | parse  (parse = `sh -n`, no ejecuta)
+
+
+@dataclass
+class Resultado:
+    rc: int
+    out: str
+    err: str
+    arbol: str
+    ms: float
+    timeout: bool = False
+
+
+@dataclass
+class Divergencia:
+    caso: Caso
+    shell: str
+    clase: str = "dura"  # dura | aviso | no-determinista | inestable
+    campos: list[str] = field(default_factory=list)   # rc | stdout | arbol
+    avisos: list[str] = field(default_factory=list)   # stderr
+    control: Resultado | None = None
+    obtenido: Resultado | None = None
+
+
+# ─────────────────────────────────────────────────────────── casos
+
+TORTURA_OBJETIVOS = [
+    ("var", "$V"),
+    ("backtick", "`"),
+    ("barra", "\\"),
+    ("comilla", '"'),
+    ("literal", "x"),
+]
+
+
+def generar_tortura_comillas() -> list[Caso]:
+    """Producto cartesiano de la tortura de comillas.
+
+    POSIX.1-2024 §2.6.3: dentro de `…` la barra invertida conserva su
+    significado literal SALVO cuando precede a `$`, `` ` `` o `\\`, donde se
+    ELIMINA antes de parsear el texto del comando. Dentro de $( ) la regla es
+    otra: el contenido se parsea como un script completo. brush aplica la de
+    $( ) a las dos — y eso es exactamente lo que este generador barre.
+    """
+    casos: list[Caso] = []
+    for forma, abre, cierra in (("bt", "`", "`"), ("dollar", "$(", ")")):
+        for ctx in ("desnudo", "comillado"):
+            for prof in range(0, 5):
+                for etiqueta, objetivo in TORTURA_OBJETIVOS:
+                    cuerpo = "\\" * prof + objetivo
+                    interior = f"echo {cuerpo}"
+                    sust = f"{abre}{interior}{cierra}"
+                    linea = f"echo [{sust}]" if ctx == "desnudo" else f'echo "[{sust}]"'
+                    casos.append(Caso(
+                        nombre=f"tortura/{forma}-{ctx}-b{prof}-{etiqueta}",
+                        origen="gen",
+                        codigo=f"V=hola\n{linea}\n",
+                    ))
+
+    # Anidamientos: el otro sitio donde los shells se separan.
+    anidados = {
+        "anid/bt-en-dollar":    'V=hola\necho "[$(echo `echo \\\\$V`)]"\n',
+        "anid/dollar-en-bt":    'V=hola\necho "[`echo $(echo \\\\$V)`]"\n',
+        "anid/bt-en-bt":        'V=hola\necho "[`echo \\`echo $V\\``]"\n',
+        "anid/dollar-en-dollar": 'V=hola\necho "[$(echo $(echo $V))]"\n',
+        "anid/comilla-en-bt":   'V=hola\necho "[`echo "$V"`]"\n',
+    }
+    for nombre, codigo in anidados.items():
+        casos.append(Caso(nombre=nombre, origen="gen", codigo=codigo))
+
+    # El bucle REAL de config.status que rompió libtool (plan-botar-busybox.md).
+    # No es un caso sintético: es el material que decide.
+    casos.append(Caso(
+        nombre="libtool/case-de-config-status",
+        origen="gen",
+        codigo=r'''ECHO="printf %s\\n"
+SED=sed
+GREP=grep
+sed_quote_subst='s/\([`"$\\]\)/\\\1/g'
+for var in ECHO SED GREP; do
+    case `eval \\$ECHO \\""\\$$var"\\"` in
+    *[\\\`\"\$]*)  eval "lt_$var=\\\"\`\$ECHO \"\$$var\" | \$SED \"\$sed_quote_subst\"\`\\\"" ;;
+    *)             eval "lt_$var=\\\"\$$var\\\"" ;;
+    esac
+done
+echo "ECHO=[$lt_ECHO]"
+echo "SED=[$lt_SED]"
+echo "GREP=[$lt_GREP]"
+''',
+    ))
+
+    # Un caso que ESCRIBE un fichero: verifica que el árbol se compara de verdad.
+    casos.append(Caso(
+        nombre="libtool/escribe-fichero-escapado",
+        origen="gen",
+        codigo=r'''V='a"b`c\d$e'
+{
+  echo "directo=$V"
+  echo "bt=`echo \\$V`"
+  echo "dollar=$(echo \\$V)"
+  printf '%s\n' "$V"
+} > generado.txt
+''',
+    ))
+    return casos
+
+
+def cargar_casos_dir(directorio: Path) -> list[Caso]:
+    casos = []
+    for f in sorted(directorio.rglob("*.sh")):
+        casos.append(Caso(
+            nombre=f"cases/{f.relative_to(directorio)}",
+            origen="cases",
+            codigo=f.read_text(encoding="utf-8", errors="replace"),
+        ))
+    return casos
+
+
+RE_FASES = re.compile(r"\[build\.phases\](.*?)(?=\n\[|\Z)", re.S)
+RE_CLAVE = re.compile(r'^\s*(\w+)\s*=\s*(.*)$', re.S)
+
+
+def cargar_fases(patron: str) -> list[Caso]:
+    """Extrae las fases de shell de las recetas. Modo `parse`: no se ejecutan
+    (necesitarían el sandbox y sus fuentes), pero el parser sí se mide."""
+    import tomllib
+    casos = []
+    for ruta in sorted(globmod.glob(patron)):
+        try:
+            datos = tomllib.loads(Path(ruta).read_text(encoding="utf-8", errors="replace"))
+        except Exception:
+            continue
+        fases = datos.get("build", {}).get("phases")
+        if not isinstance(fases, dict):
+            continue
+        nombre_receta = Path(ruta).stem
+        for fase, cuerpo in fases.items():
+            if isinstance(cuerpo, list):
+                cuerpo = "\n".join(str(x) for x in cuerpo)
+            if not isinstance(cuerpo, str) or not cuerpo.strip():
+                continue
+            casos.append(Caso(
+                nombre=f"phases/{nombre_receta}:{fase}",
+                origen="phases",
+                codigo=cuerpo if cuerpo.endswith("\n") else cuerpo + "\n",
+                modo="parse",
+            ))
+    return casos
+
+
+def cargar_ficheros(patrones: list[str]) -> list[Caso]:
+    casos = []
+    for patron in patrones:
+        for ruta in sorted(globmod.glob(patron, recursive=True)):
+            p = Path(ruta)
+            if not p.is_file():
+                continue
+            casos.append(Caso(
+                nombre=f"files/{p.name}",
+                origen="files",
+                codigo=p.read_text(encoding="utf-8", errors="replace"),
+                modo="parse",
+            ))
+    return casos
+
+
+# ─────────────────────────────────────────────────────────── ejecución
+
+def hash_arbol(raiz: Path) -> str:
+    """Huella del árbol de ficheros producido: ruta + bit de ejecución +
+    sha256 del contenido. Esto es lo que atrapa al libtool mal escapado."""
+    filas = []
+    for dirpath, dirnames, filenames in os.walk(raiz):
+        dirnames.sort()
+        for nombre in sorted(filenames):
+            f = Path(dirpath) / nombre
+            rel = f.relative_to(raiz).as_posix()
+            try:
+                if f.is_symlink():
+                    filas.append(f"l {rel} {os.readlink(f)}")
+                    continue
+                datos = f.read_bytes()
+            except OSError as e:
+                filas.append(f"e {rel} {e.errno}")
+                continue
+            ejec = "x" if os.access(f, os.X_OK) else "-"
+            filas.append(f"f {rel} {ejec} {hashlib.sha256(datos).hexdigest()[:16]}")
+    if not filas:
+        return "(vacío)"
+    return hashlib.sha256("\n".join(filas).encode()).hexdigest()[:16] + f" [{len(filas)}]"
+
+
+def entorno(shell: Shell, bindir: Path, home: Path) -> dict[str, str]:
+    # PATH: primero el shell bajo prueba (para que un `sh` recursivo caiga en
+    # ÉL), después SIEMPRE el mismo userland — las divergencias tienen que
+    # venir del shell, no de un sed distinto.
+    return {
+        "PATH": f"{shell.dir_sh}:{bindir}" if shell.dir_sh else str(bindir),
+        "HOME": str(home),
+        "LC_ALL": "C",
+        "LANG": "C",
+        "TZ": "UTC",
+        "SHELL": str(shell.sh),
+        "TERM": "dumb",
+    }
+
+
+def ejecutar(shell: Shell, caso: Caso, bindir: Path, timeout: float) -> Resultado:
+    base = Path(tempfile.mkdtemp(prefix="shdif-"))
+    try:
+        prog = base / "prog.sh"
+        prog.write_text(caso.codigo, encoding="utf-8")
+        cwd = base / "cwd"
+        cwd.mkdir()
+        home = base / "home"
+        home.mkdir()
+        argv = [str(shell.sh)]
+        if caso.modo == "parse":
+            argv.append("-n")
+        argv.append(str(prog))
+        t0 = time.monotonic()
+        try:
+            p = subprocess.run(
+                argv, cwd=cwd, env=entorno(shell, bindir, home),
+                stdin=subprocess.DEVNULL, capture_output=True, timeout=timeout,
+            )
+            rc, out, err, to = p.returncode, p.stdout, p.stderr, False
+        except subprocess.TimeoutExpired as e:
+            rc, out, err, to = -9, e.stdout or b"", e.stderr or b"", True
+        ms = (time.monotonic() - t0) * 1000
+        return Resultado(
+            rc=rc,
+            out=out.decode("utf-8", "replace"),
+            err=err.decode("utf-8", "replace"),
+            arbol=hash_arbol(cwd),
+            ms=ms,
+            timeout=to,
+        )
+    finally:
+        shutil.rmtree(base, ignore_errors=True)
+
+
+RE_RUIDO = [
+    (re.compile(r"/tmp/shdif-[A-Za-z0-9_]+"), "<TMP>"),
+    (re.compile(r"^[^\s:]*(?:sh|brush|bash|busybox)[^\s:]*:\s*", re.M), ""),
+    (re.compile(r"\bline \d+\b"), "line N"),
+    (re.compile(r"\blínea \d+\b"), "line N"),
+    (re.compile(r"prog\.sh"), "<PROG>"),
+]
+
+
+def normalizar_err(texto: str) -> str:
+    for rx, rep in RE_RUIDO:
+        texto = rx.sub(rep, texto)
+    return " ".join(texto.split())
+
+
+def comparar(ctrl: Resultado, otro: Resultado) -> tuple[list[str], list[str]]:
+    duras, avisos = [], []
+    if ctrl.rc != otro.rc:
+        duras.append("rc")
+    if ctrl.out != otro.out:
+        duras.append("stdout")
+    if ctrl.arbol != otro.arbol:
+        duras.append("arbol")
+    if normalizar_err(ctrl.err) != normalizar_err(otro.err):
+        avisos.append("stderr")
+    return duras, avisos
+
+
+# ─────────────────────────────────────────────────────────── informe
+
+def recorta(texto: str, n: int = 240) -> str:
+    texto = texto.replace("\n", "\\n")
+    return texto if len(texto) <= n else texto[:n] + f"…(+{len(texto)-n})"
+
+
+def medir_latencia(shells: list[Shell], n: int, bindir: Path) -> None:
+    print(f"\n── Latencia de arranque · {n} invocaciones de `sh -c :`")
+    base = Path(tempfile.mkdtemp(prefix="shdif-lat-"))
+    try:
+        for sh in shells:
+            env = entorno(sh, bindir, base)
+            subprocess.run([str(sh.sh), "-c", ":"], env=env, cwd=base,
+                           stdin=subprocess.DEVNULL, capture_output=True)  # calentar
+            t0 = time.monotonic()
+            for _ in range(n):
+                subprocess.run([str(sh.sh), "-c", ":"], env=env, cwd=base,
+                               stdin=subprocess.DEVNULL, capture_output=True)
+            total = time.monotonic() - t0
+            tam = sh.binario.stat().st_size / 1024 / 1024
+            print(f"   {sh.nombre:<12} {total/n*1e6:8.0f} µs/invocación   "
+                  f"binario {tam:5.2f} MB")
+    finally:
+        shutil.rmtree(base, ignore_errors=True)
+    print("   ⚠ incluye el fork+exec de Python: sirve para COMPARAR, no como cifra absoluta.")
+
+
+def main() -> int:
+    ap = argparse.ArgumentParser(
+        description="Banco diferencial de shells: compara rc, stdout y árbol producido "
+                    "contra un shell de control.",
+        formatter_class=argparse.RawDescriptionHelpFormatter,
+        epilog="ejemplo:\n  %(prog)s --gen control=/store/…-busybox/bin/busybox "
+               "brush=/store/…-brush/usr/bin/brush\n",
+    )
+    ap.add_argument("shells", nargs="+", metavar="NOMBRE=RUTA",
+                    help="shells a comparar; el primero es el control salvo --control")
+    ap.add_argument("--control", metavar="NOMBRE", help="cuál de los shells es el control")
+    ap.add_argument("--cases", metavar="DIR", default=str(RAIZ / "scripts/fixtures/sh-casos"),
+                    help="directorio de casos .sh (por defecto: scripts/fixtures/sh-casos)")
+    ap.add_argument("--no-cases", action="store_true", help="no cargar el directorio de casos")
+    ap.add_argument("--gen", action="store_true", help="añadir el generador de tortura de comillas")
+    ap.add_argument("--phases", action="store_true",
+                    help="añadir las fases [build.phases] de recipes/*.toml en modo `sh -n`")
+    ap.add_argument("--files", metavar="GLOB", action="append", default=[],
+                    help="añadir ficheros reales (configure, libtool…) en modo `sh -n`")
+    ap.add_argument("--latency", metavar="N", type=int, default=0,
+                    help="medir latencia de arranque con N invocaciones y salir")
+    ap.add_argument("--timeout", metavar="SEG", type=float, default=10.0)
+    ap.add_argument("--jobs", metavar="N", type=int, default=4)
+    ap.add_argument("--tsv", metavar="RUTA", default=str(RAIZ / "work/sh-diferencial.tsv"))
+    ap.add_argument("--verbose", action="store_true", help="volcar el diff de cada divergencia")
+    ap.add_argument("--max-show", metavar="N", type=int, default=12,
+                    help="cuántas divergencias detallar por shell (0 = todas)")
+    args = ap.parse_args()
+
+    # ── shells
+    tmp_sh = Path(tempfile.mkdtemp(prefix="shdif-bin-"))
+    shells: list[Shell] = []
+    for spec in args.shells:
+        if "=" not in spec:
+            print(f"✗ shell mal declarado (falta NOMBRE=): {spec}", file=sys.stderr)
+            return 2
+        nombre, ruta = spec.split("=", 1)
+        binario = Path(ruta).resolve()
+        if not binario.is_file() or not os.access(binario, os.X_OK):
+            print(f"✗ no es un ejecutable: {binario}", file=sys.stderr)
+            return 2
+        # symlink `sh` -> binario, para que argv[0] sea `sh` en todos por igual
+        # (busybox despacha por argv[0]; brush también cambia de modo).
+        d = tmp_sh / nombre
+        d.mkdir(parents=True)
+        (d / "sh").symlink_to(binario)
+        shells.append(Shell(nombre=nombre, binario=binario, dir_sh=d))
+
+    nombre_control = args.control or ("control" if any(s.nombre == "control" for s in shells)
+                                      else shells[0].nombre)
+    try:
+        control = next(s for s in shells if s.nombre == nombre_control)
+    except StopIteration:
+        print(f"✗ el control «{nombre_control}» no está entre los shells", file=sys.stderr)
+        return 2
+    otros = [s for s in shells if s is not control]
+    if not otros:
+        print("✗ hace falta al menos un shell además del control", file=sys.stderr)
+        return 2
+
+    # ── userland común: el busybox del control si lo es, o el del sistema
+    bindir = tmp_sh / "_bin"
+    bindir.mkdir()
+    bb = None
+    for s in shells:
+        if "busybox" in s.binario.name or "busybox" in str(s.binario):
+            bb = s.binario
+            break
+    if bb:
+        subprocess.run([str(bb), "--install", "-s", str(bindir)],
+                       capture_output=True)
+        # `sh` del userland NO: cada shell trae el suyo en su propio dir
+        (bindir / "sh").unlink(missing_ok=True)
+        (bindir / "ash").unlink(missing_ok=True)
+    if not any(bindir.iterdir()):
+        for herr in ("sed", "grep", "awk", "tr", "wc", "cat", "printf", "echo", "ls",
+                     "rm", "mkdir", "cp", "mv", "sort", "head", "tail", "cut", "expr",
+                     "test", "true", "false", "sleep", "env", "date", "basename", "dirname"):
+            r = shutil.which(herr)
+            if r:
+                (bindir / herr).symlink_to(r)
+
+    try:
+        if args.latency:
+            medir_latencia(shells, args.latency, bindir)
+            return 0
+
+        # ── casos
+        casos: list[Caso] = []
+        dir_casos = Path(args.cases)
+        if not args.no_cases and dir_casos.is_dir():
+            casos += cargar_casos_dir(dir_casos)
+        if args.gen:
+            casos += generar_tortura_comillas()
+        if args.phases:
+            casos += cargar_fases(str(RAIZ / "recipes/*.toml"))
+        if args.files:
+            casos += cargar_ficheros(args.files)
+        if not casos:
+            print("✗ ningún caso cargado — pasá --gen, --phases, --files o poblá "
+                  f"{dir_casos}", file=sys.stderr)
+            return 2
+
+        por_origen: dict[str, int] = {}
+        for c in casos:
+            por_origen[c.origen] = por_origen.get(c.origen, 0) + 1
+        print(f"Banco diferencial · {len(casos)} casos "
+              f"({', '.join(f'{k}={v}' for k, v in sorted(por_origen.items()))})")
+        print(f"Control: {control.nombre} → {control.binario}")
+        for s in otros:
+            print(f"  vs     {s.nombre} → {s.binario}")
+
+        # ── corrida
+        #
+        # Toda divergencia dura se CONFIRMA volviendo a correr control y shell. Un caso que
+        # imprime un PID o una fecha diverge siempre y no dice nada; un shell que diverge de sí
+        # mismo es un hallazgo distinto (y peor). Sin esta confirmación el banco se llena de
+        # ruido y deja de leerse — que es como muere un banco.
+        def tarea(caso: Caso) -> tuple[Caso, dict[str, Resultado], list[Divergencia]]:
+            res = {s.nombre: ejecutar(s, caso, bindir, args.timeout) for s in shells}
+            ctrl = res[control.nombre]
+            hallazgos: list[Divergencia] = []
+            for s in otros:
+                duras, avisos = comparar(ctrl, res[s.nombre])
+                if duras:
+                    ctrl_bis = ejecutar(control, caso, bindir, args.timeout)
+                    otro_bis = ejecutar(s, caso, bindir, args.timeout)
+                    if comparar(ctrl, ctrl_bis)[0]:
+                        clase = "no-determinista"
+                    elif comparar(res[s.nombre], otro_bis)[0]:
+                        clase = "inestable"
+                    else:
+                        clase = "dura"
+                elif avisos:
+                    clase = "aviso"
+                else:
+                    continue
+                hallazgos.append(Divergencia(
+                    caso=caso, shell=s.nombre, clase=clase, campos=duras, avisos=avisos,
+                    control=ctrl, obtenido=res[s.nombre],
+                ))
+            return caso, res, hallazgos
+
+        divergencias: list[Divergencia] = []
+        cuenta: dict[str, dict[str, int]] = {
+            s.nombre: {"dura": 0, "aviso": 0, "no-determinista": 0, "inestable": 0}
+            for s in otros
+        }
+        timeouts: dict[str, int] = {s.nombre: 0 for s in shells}
+        ms_por_shell: dict[str, float] = {s.nombre: 0.0 for s in shells}
+
+        with concurrent.futures.ThreadPoolExecutor(max_workers=args.jobs) as ex:
+            for i, (caso, res, hallazgos) in enumerate(ex.map(tarea, casos), 1):
+                for s in shells:
+                    ms_por_shell[s.nombre] += res[s.nombre].ms
+                    if res[s.nombre].timeout:
+                        timeouts[s.nombre] += 1
+                for d in hallazgos:
+                    cuenta[d.shell][d.clase] += 1
+                    divergencias.append(d)
+                if i % 200 == 0:
+                    print(f"   … {i}/{len(casos)}", file=sys.stderr)
+
+        # ── informe
+        print("\n── Resumen")
+        for s in shells:
+            if s is control:
+                print(f"   {s.nombre:<12} (control)   "
+                      f"{ms_por_shell[s.nombre]/len(casos):6.1f} ms/caso"
+                      + (f"   timeouts={timeouts[s.nombre]}" if timeouts[s.nombre] else ""))
+            else:
+                c = cuenta[s.nombre]
+                extra = ""
+                if c["no-determinista"]:
+                    extra += f"   casos-no-deterministas={c['no-determinista']}"
+                if c["inestable"]:
+                    extra += f"   INESTABLE={c['inestable']}"
+                if timeouts[s.nombre]:
+                    extra += f"   timeouts={timeouts[s.nombre]}"
+                print(f"   {s.nombre:<12} DIVERGE={c['dura']:<5} avisos(stderr)={c['aviso']:<5} "
+                      f"{ms_por_shell[s.nombre]/len(casos):6.1f} ms/caso" + extra)
+
+        for clase, titulo in (("no-determinista", "Casos NO DETERMINISTAS — el control no se "
+                               "repite a sí mismo; el caso está mal escrito, no el shell"),
+                              ("inestable", "Shell INESTABLE — no se repite a sí mismo. Es un "
+                               "hallazgo peor que una divergencia")):
+            cuales = [d for d in divergencias if d.clase == clase]
+            if cuales:
+                print(f"\n── {titulo} ({len(cuales)})")
+                for d in cuales:
+                    print(f"   ⚠ [{d.shell}] {d.caso.nombre}")
+
+        duras = [d for d in divergencias if d.clase == "dura"]
+        if duras:
+            print(f"\n── Divergencias DURAS ({len(duras)})")
+            for s in otros:
+                suyas = [d for d in duras if d.shell == s.nombre]
+                if not suyas:
+                    continue
+                mostrar = suyas if args.max_show == 0 else suyas[:args.max_show]
+                for d in mostrar:
+                    print(f"\n  ✗ [{d.shell}] {d.caso.nombre}   ({', '.join(d.campos)})")
+                    if args.verbose or True:
+                        print(f"      código:   {recorta(d.caso.codigo)}")
+                        if "rc" in d.campos:
+                            print(f"      rc:       control={d.control.rc}  "
+                                  f"{d.shell}={d.obtenido.rc}")
+                        if "stdout" in d.campos:
+                            print(f"      control:  {recorta(d.control.out)}")
+                            print(f"      {d.shell:<9} {recorta(d.obtenido.out)}")
+                        if "arbol" in d.campos:
+                            print(f"      árbol:    control={d.control.arbol}  "
+                                  f"{d.shell}={d.obtenido.arbol}")
+                if args.max_show and len(suyas) > args.max_show:
+                    print(f"\n  … y {len(suyas)-args.max_show} divergencias duras más de "
+                          f"{s.nombre} (ver el TSV; --max-show 0 las muestra todas)")
+
+        # ── TSV
+        tsv = Path(args.tsv)
+        tsv.parent.mkdir(parents=True, exist_ok=True)
+        with tsv.open("w", encoding="utf-8") as f:
+            f.write("shell\tcaso\torigen\tmodo\tclase\tcampos\trc_control\trc_shell\t"
+                    "arbol_control\tarbol_shell\n")
+            for d in divergencias:
+                f.write(f"{d.shell}\t{d.caso.nombre}\t{d.caso.origen}\t{d.caso.modo}\t{d.clase}\t"
+                        f"{','.join(d.campos + ['stderr'] * bool(d.avisos))}\t"
+                        f"{d.control.rc}\t{d.obtenido.rc}\t"
+                        f"{d.control.arbol}\t{d.obtenido.arbol}\n")
+        print(f"\nTSV: {tsv}")
+
+        peor = max((c["dura"] for c in cuenta.values()), default=0)
+        if peor == 0:
+            print("\n✓ ningún shell diverge del control en rc, stdout ni árbol producido.")
+            print("  ⚠ y eso NO es prueba de suficiencia: mide el material que se le dio.")
+        return min(peor, 250)
+    finally:
+        shutil.rmtree(tmp_sh, ignore_errors=True)
+
+
+if __name__ == "__main__":
+    sys.exit(main())