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.
This commit is contained in:
Sergio
2026-09-21 18:58:09 +00:00
parent 0db85d51f8
commit ddc6e4845b
13 changed files with 846 additions and 0 deletions
+125
View File
@@ -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.