tawasuyu

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

⎇ 7c794568a963b18b68d747959e6bbd07cf2ebff7 scripts/sh-banco-diferencial.py Python · 618 siq'ikuna · 25.2 KB Ñawpa willay Pi imata llamkarqan ch'uya

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

1#!/usr/bin/env python3
2"""Banco DIFERENCIAL para candidatos a /bin/sh — SDD: docs/plan-botar-busybox.md.
3
4No pregunta «¿este shell hace lo que yo creo que POSIX manda?» (eso es
5`scripts/sh-banco-posix.sh`, 57 casos escritos a mano: brush lo pasa 56/57 y aun
6así no construye una sola receta autotools). Pregunta otra cosa:
7
8    ¿este shell hace LO MISMO que el control, sobre el material que hay?
9
10Cada caso se corre bajo N shells en un directorio limpio y se compara la tupla
11completa contra el control:
12
13    (rc, stdout, ÁRBOL DE FICHEROS PRODUCIDO)   ← divergencia DURA
14    (stderr normalizado)                        ← AVISO: el texto de error
15                                                  diverge legítimamente
16
17El árbol de ficheros es la parte que importa y la que faltaba: el bug que tumbó
18a brush (POSIX 2.6.3, la barra invertida dentro de `…`) no se ve en el stdout de
19ningún caso sintético — se ve en el `libtool` que el shell ESCRIBE, 25 líneas con
20un nivel de escapado de menos. Un banco que sólo mira stdout lo deja pasar.
21
22Fuentes de casos (combinables):
23
24  --cases DIR     ficheros .sh sueltos (regresiones conocidas)
25  --gen           generador de tortura de comillas: producto cartesiano de
26                  forma de sustitución × contexto × profundidad de barras ×
27                  carácter objetivo. Es el que encuentra el bug de brush SIN
28                  saber que existe
29  --phases        las fases [build.phases] de recipes/*.toml, en modo `sh -n`
30  --files GLOB    ficheros reales (configure, libtool, config.status) en `sh -n`
31  --latency N     N invocaciones de `sh -c :` por shell, sin comparar nada
32
33Uso:
34    scripts/sh-banco-diferencial.py --gen \\
35        control=/store/2a2b…-busybox/bin/busybox \\
36        brush=/store/7152…-brush/usr/bin/brush
37
38El PRIMER shell es el control salvo que se nombre uno `control=` o se pase
39--control NOMBRE. Salida: tabla por shell + TSV. Estado de salida = número de
40divergencias duras del peor shell (0 = todos coinciden con el control).
41"""
42
43from __future__ import annotations
44
45import argparse
46import concurrent.futures
47import glob as globmod
48import hashlib
49import os
50import re
51import shutil
52import subprocess
53import sys
54import tempfile
55import time
56from dataclasses import dataclass, field
57from pathlib import Path
58
59RAIZ = Path(__file__).resolve().parent.parent
60
61
62# ─────────────────────────────────────────────────────────── modelo
63
64@dataclass
65class Shell:
66    nombre: str
67    binario: Path
68    dir_sh: Path | None = None  # dir con un symlink `sh` -> binario (argv[0]=sh)
69
70    @property
71    def sh(self) -> Path:
72        return (self.dir_sh / "sh") if self.dir_sh else self.binario
73
74
75@dataclass
76class Caso:
77    nombre: str
78    origen: str          # cases | gen | phases | files
79    codigo: str
80    modo: str = "run"    # run | parse  (parse = `sh -n`, no ejecuta)
81
82
83@dataclass
84class Resultado:
85    rc: int
86    out: str
87    err: str
88    arbol: str
89    ms: float
90    timeout: bool = False
91
92
93@dataclass
94class Divergencia:
95    caso: Caso
96    shell: str
97    clase: str = "dura"  # dura | aviso | no-determinista | inestable
98    campos: list[str] = field(default_factory=list)   # rc | stdout | arbol
99    avisos: list[str] = field(default_factory=list)   # stderr
100    control: Resultado | None = None
101    obtenido: Resultado | None = None
102
103
104# ─────────────────────────────────────────────────────────── casos
105
106TORTURA_OBJETIVOS = [
107    ("var", "$V"),
108    ("backtick", "`"),
109    ("barra", "\\"),
110    ("comilla", '"'),
111    ("literal", "x"),
112]
113
114
115def generar_tortura_comillas() -> list[Caso]:
116    """Producto cartesiano de la tortura de comillas.
117
118    POSIX.1-2024 §2.6.3: dentro de `…` la barra invertida conserva su
119    significado literal SALVO cuando precede a `$`, `` ` `` o `\\`, donde se
120    ELIMINA antes de parsear el texto del comando. Dentro de $( ) la regla es
121    otra: el contenido se parsea como un script completo. brush aplica la de
122    $( ) a las dos — y eso es exactamente lo que este generador barre.
123    """
124    casos: list[Caso] = []
125    for forma, abre, cierra in (("bt", "`", "`"), ("dollar", "$(", ")")):
126        for ctx in ("desnudo", "comillado"):
127            for prof in range(0, 5):
128                for etiqueta, objetivo in TORTURA_OBJETIVOS:
129                    cuerpo = "\\" * prof + objetivo
130                    interior = f"echo {cuerpo}"
131                    sust = f"{abre}{interior}{cierra}"
132                    linea = f"echo [{sust}]" if ctx == "desnudo" else f'echo "[{sust}]"'
133                    casos.append(Caso(
134                        nombre=f"tortura/{forma}-{ctx}-b{prof}-{etiqueta}",
135                        origen="gen",
136                        codigo=f"V=hola\n{linea}\n",
137                    ))
138
139    # Anidamientos: el otro sitio donde los shells se separan.
140    anidados = {
141        "anid/bt-en-dollar":    'V=hola\necho "[$(echo `echo \\\\$V`)]"\n',
142        "anid/dollar-en-bt":    'V=hola\necho "[`echo $(echo \\\\$V)`]"\n',
143        "anid/bt-en-bt":        'V=hola\necho "[`echo \\`echo $V\\``]"\n',
144        "anid/dollar-en-dollar": 'V=hola\necho "[$(echo $(echo $V))]"\n',
145        "anid/comilla-en-bt":   'V=hola\necho "[`echo "$V"`]"\n',
146    }
147    for nombre, codigo in anidados.items():
148        casos.append(Caso(nombre=nombre, origen="gen", codigo=codigo))
149
150    # El bucle REAL de config.status que rompió libtool (plan-botar-busybox.md).
151    # No es un caso sintético: es el material que decide.
152    casos.append(Caso(
153        nombre="libtool/case-de-config-status",
154        origen="gen",
155        codigo=r'''ECHO="printf %s\\n"
156SED=sed
157GREP=grep
158sed_quote_subst='s/\([`"$\\]\)/\\\1/g'
159for var in ECHO SED GREP; do
160    case `eval \\$ECHO \\""\\$$var"\\"` in
161    *[\\\`\"\$]*)  eval "lt_$var=\\\"\`\$ECHO \"\$$var\" | \$SED \"\$sed_quote_subst\"\`\\\"" ;;
162    *)             eval "lt_$var=\\\"\$$var\\\"" ;;
163    esac
164done
165echo "ECHO=[$lt_ECHO]"
166echo "SED=[$lt_SED]"
167echo "GREP=[$lt_GREP]"
168''',
169    ))
170
171    # Un caso que ESCRIBE un fichero: verifica que el árbol se compara de verdad.
172    casos.append(Caso(
173        nombre="libtool/escribe-fichero-escapado",
174        origen="gen",
175        codigo=r'''V='a"b`c\d$e'
176{
177  echo "directo=$V"
178  echo "bt=`echo \\$V`"
179  echo "dollar=$(echo \\$V)"
180  printf '%s\n' "$V"
181} > generado.txt
182''',
183    ))
184    return casos
185
186
187def cargar_casos_dir(directorio: Path) -> list[Caso]:
188    casos = []
189    for f in sorted(directorio.rglob("*.sh")):
190        casos.append(Caso(
191            nombre=f"cases/{f.relative_to(directorio)}",
192            origen="cases",
193            codigo=f.read_text(encoding="utf-8", errors="replace"),
194        ))
195    return casos
196
197
198RE_FASES = re.compile(r"\[build\.phases\](.*?)(?=\n\[|\Z)", re.S)
199RE_CLAVE = re.compile(r'^\s*(\w+)\s*=\s*(.*)$', re.S)
200
201
202def cargar_fases(patron: str) -> list[Caso]:
203    """Extrae las fases de shell de las recetas. Modo `parse`: no se ejecutan
204    (necesitarían el sandbox y sus fuentes), pero el parser sí se mide."""
205    import tomllib
206    casos = []
207    for ruta in sorted(globmod.glob(patron)):
208        try:
209            datos = tomllib.loads(Path(ruta).read_text(encoding="utf-8", errors="replace"))
210        except Exception:
211            continue
212        fases = datos.get("build", {}).get("phases")
213        if not isinstance(fases, dict):
214            continue
215        nombre_receta = Path(ruta).stem
216        for fase, cuerpo in fases.items():
217            if isinstance(cuerpo, list):
218                cuerpo = "\n".join(str(x) for x in cuerpo)
219            if not isinstance(cuerpo, str) or not cuerpo.strip():
220                continue
221            casos.append(Caso(
222                nombre=f"phases/{nombre_receta}:{fase}",
223                origen="phases",
224                codigo=cuerpo if cuerpo.endswith("\n") else cuerpo + "\n",
225                modo="parse",
226            ))
227    return casos
228
229
230def cargar_ficheros(patrones: list[str]) -> list[Caso]:
231    casos = []
232    for patron in patrones:
233        for ruta in sorted(globmod.glob(patron, recursive=True)):
234            p = Path(ruta)
235            if not p.is_file():
236                continue
237            casos.append(Caso(
238                nombre=f"files/{p.name}",
239                origen="files",
240                codigo=p.read_text(encoding="utf-8", errors="replace"),
241                modo="parse",
242            ))
243    return casos
244
245
246# ─────────────────────────────────────────────────────────── ejecución
247
248def hash_arbol(raiz: Path) -> str:
249    """Huella del árbol de ficheros producido: ruta + bit de ejecución +
250    sha256 del contenido. Esto es lo que atrapa al libtool mal escapado."""
251    filas = []
252    for dirpath, dirnames, filenames in os.walk(raiz):
253        dirnames.sort()
254        for nombre in sorted(filenames):
255            f = Path(dirpath) / nombre
256            rel = f.relative_to(raiz).as_posix()
257            try:
258                if f.is_symlink():
259                    filas.append(f"l {rel} {os.readlink(f)}")
260                    continue
261                datos = f.read_bytes()
262            except OSError as e:
263                filas.append(f"e {rel} {e.errno}")
264                continue
265            ejec = "x" if os.access(f, os.X_OK) else "-"
266            filas.append(f"f {rel} {ejec} {hashlib.sha256(datos).hexdigest()[:16]}")
267    if not filas:
268        return "(vacío)"
269    return hashlib.sha256("\n".join(filas).encode()).hexdigest()[:16] + f" [{len(filas)}]"
270
271
272def entorno(shell: Shell, bindir: Path, home: Path) -> dict[str, str]:
273    # PATH: primero el shell bajo prueba (para que un `sh` recursivo caiga en
274    # ÉL), después SIEMPRE el mismo userland — las divergencias tienen que
275    # venir del shell, no de un sed distinto.
276    return {
277        "PATH": f"{shell.dir_sh}:{bindir}" if shell.dir_sh else str(bindir),
278        "HOME": str(home),
279        "LC_ALL": "C",
280        "LANG": "C",
281        "TZ": "UTC",
282        "SHELL": str(shell.sh),
283        "TERM": "dumb",
284    }
285
286
287def ejecutar(shell: Shell, caso: Caso, bindir: Path, timeout: float) -> Resultado:
288    base = Path(tempfile.mkdtemp(prefix="shdif-"))
289    try:
290        prog = base / "prog.sh"
291        prog.write_text(caso.codigo, encoding="utf-8")
292        cwd = base / "cwd"
293        cwd.mkdir()
294        home = base / "home"
295        home.mkdir()
296        argv = [str(shell.sh)]
297        if caso.modo == "parse":
298            argv.append("-n")
299        argv.append(str(prog))
300        t0 = time.monotonic()
301        try:
302            p = subprocess.run(
303                argv, cwd=cwd, env=entorno(shell, bindir, home),
304                stdin=subprocess.DEVNULL, capture_output=True, timeout=timeout,
305            )
306            rc, out, err, to = p.returncode, p.stdout, p.stderr, False
307        except subprocess.TimeoutExpired as e:
308            rc, out, err, to = -9, e.stdout or b"", e.stderr or b"", True
309        ms = (time.monotonic() - t0) * 1000
310        return Resultado(
311            rc=rc,
312            out=out.decode("utf-8", "replace"),
313            err=err.decode("utf-8", "replace"),
314            arbol=hash_arbol(cwd),
315            ms=ms,
316            timeout=to,
317        )
318    finally:
319        shutil.rmtree(base, ignore_errors=True)
320
321
322RE_RUIDO = [
323    (re.compile(r"/tmp/shdif-[A-Za-z0-9_]+"), "<TMP>"),
324    (re.compile(r"^[^\s:]*(?:sh|brush|bash|busybox)[^\s:]*:\s*", re.M), ""),
325    (re.compile(r"\bline \d+\b"), "line N"),
326    (re.compile(r"\blínea \d+\b"), "line N"),
327    (re.compile(r"prog\.sh"), "<PROG>"),
328]
329
330
331def normalizar_err(texto: str) -> str:
332    for rx, rep in RE_RUIDO:
333        texto = rx.sub(rep, texto)
334    return " ".join(texto.split())
335
336
337def comparar(ctrl: Resultado, otro: Resultado) -> tuple[list[str], list[str]]:
338    duras, avisos = [], []
339    if ctrl.rc != otro.rc:
340        duras.append("rc")
341    if ctrl.out != otro.out:
342        duras.append("stdout")
343    if ctrl.arbol != otro.arbol:
344        duras.append("arbol")
345    if normalizar_err(ctrl.err) != normalizar_err(otro.err):
346        avisos.append("stderr")
347    return duras, avisos
348
349
350# ─────────────────────────────────────────────────────────── informe
351
352def recorta(texto: str, n: int = 240) -> str:
353    texto = texto.replace("\n", "\\n")
354    return texto if len(texto) <= n else texto[:n] + f"…(+{len(texto)-n})"
355
356
357def medir_latencia(shells: list[Shell], n: int, bindir: Path) -> None:
358    print(f"\n── Latencia de arranque · {n} invocaciones de `sh -c :`")
359    base = Path(tempfile.mkdtemp(prefix="shdif-lat-"))
360    try:
361        for sh in shells:
362            env = entorno(sh, bindir, base)
363            subprocess.run([str(sh.sh), "-c", ":"], env=env, cwd=base,
364                           stdin=subprocess.DEVNULL, capture_output=True)  # calentar
365            t0 = time.monotonic()
366            for _ in range(n):
367                subprocess.run([str(sh.sh), "-c", ":"], env=env, cwd=base,
368                               stdin=subprocess.DEVNULL, capture_output=True)
369            total = time.monotonic() - t0
370            tam = sh.binario.stat().st_size / 1024 / 1024
371            print(f"   {sh.nombre:<12} {total/n*1e6:8.0f} µs/invocación   "
372                  f"binario {tam:5.2f} MB")
373    finally:
374        shutil.rmtree(base, ignore_errors=True)
375    print("   ⚠ incluye el fork+exec de Python: sirve para COMPARAR, no como cifra absoluta.")
376
377
378def main() -> int:
379    ap = argparse.ArgumentParser(
380        description="Banco diferencial de shells: compara rc, stdout y árbol producido "
381                    "contra un shell de control.",
382        formatter_class=argparse.RawDescriptionHelpFormatter,
383        epilog="ejemplo:\n  %(prog)s --gen control=/store/…-busybox/bin/busybox "
384               "brush=/store/…-brush/usr/bin/brush\n",
385    )
386    ap.add_argument("shells", nargs="+", metavar="NOMBRE=RUTA",
387                    help="shells a comparar; el primero es el control salvo --control")
388    ap.add_argument("--control", metavar="NOMBRE", help="cuál de los shells es el control")
389    ap.add_argument("--cases", metavar="DIR", default=str(RAIZ / "scripts/fixtures/sh-casos"),
390                    help="directorio de casos .sh (por defecto: scripts/fixtures/sh-casos)")
391    ap.add_argument("--no-cases", action="store_true", help="no cargar el directorio de casos")
392    ap.add_argument("--gen", action="store_true", help="añadir el generador de tortura de comillas")
393    ap.add_argument("--phases", action="store_true",
394                    help="añadir las fases [build.phases] de recipes/*.toml en modo `sh -n`")
395    ap.add_argument("--files", metavar="GLOB", action="append", default=[],
396                    help="añadir ficheros reales (configure, libtool…) en modo `sh -n`")
397    ap.add_argument("--latency", metavar="N", type=int, default=0,
398                    help="medir latencia de arranque con N invocaciones y salir")
399    ap.add_argument("--timeout", metavar="SEG", type=float, default=10.0)
400    ap.add_argument("--jobs", metavar="N", type=int, default=4)
401    ap.add_argument("--tsv", metavar="RUTA", default=str(RAIZ / "work/sh-diferencial.tsv"))
402    ap.add_argument("--verbose", action="store_true", help="volcar el diff de cada divergencia")
403    ap.add_argument("--max-show", metavar="N", type=int, default=12,
404                    help="cuántas divergencias detallar por shell (0 = todas)")
405    args = ap.parse_args()
406
407    # ── shells
408    tmp_sh = Path(tempfile.mkdtemp(prefix="shdif-bin-"))
409    shells: list[Shell] = []
410    for spec in args.shells:
411        if "=" not in spec:
412            print(f"✗ shell mal declarado (falta NOMBRE=): {spec}", file=sys.stderr)
413            return 2
414        nombre, ruta = spec.split("=", 1)
415        binario = Path(ruta).resolve()
416        if not binario.is_file() or not os.access(binario, os.X_OK):
417            print(f"✗ no es un ejecutable: {binario}", file=sys.stderr)
418            return 2
419        # symlink `sh` -> binario, para que argv[0] sea `sh` en todos por igual
420        # (busybox despacha por argv[0]; brush también cambia de modo).
421        d = tmp_sh / nombre
422        d.mkdir(parents=True)
423        (d / "sh").symlink_to(binario)
424        shells.append(Shell(nombre=nombre, binario=binario, dir_sh=d))
425
426    nombre_control = args.control or ("control" if any(s.nombre == "control" for s in shells)
427                                      else shells[0].nombre)
428    try:
429        control = next(s for s in shells if s.nombre == nombre_control)
430    except StopIteration:
431        print(f"✗ el control «{nombre_control}» no está entre los shells", file=sys.stderr)
432        return 2
433    otros = [s for s in shells if s is not control]
434    if not otros:
435        print("✗ hace falta al menos un shell además del control", file=sys.stderr)
436        return 2
437
438    # ── userland común: el busybox del control si lo es, o el del sistema
439    bindir = tmp_sh / "_bin"
440    bindir.mkdir()
441    bb = None
442    for s in shells:
443        if "busybox" in s.binario.name or "busybox" in str(s.binario):
444            bb = s.binario
445            break
446    if bb:
447        subprocess.run([str(bb), "--install", "-s", str(bindir)],
448                       capture_output=True)
449        # `sh` del userland NO: cada shell trae el suyo en su propio dir
450        (bindir / "sh").unlink(missing_ok=True)
451        (bindir / "ash").unlink(missing_ok=True)
452    if not any(bindir.iterdir()):
453        for herr in ("sed", "grep", "awk", "tr", "wc", "cat", "printf", "echo", "ls",
454                     "rm", "mkdir", "cp", "mv", "sort", "head", "tail", "cut", "expr",
455                     "test", "true", "false", "sleep", "env", "date", "basename", "dirname"):
456            r = shutil.which(herr)
457            if r:
458                (bindir / herr).symlink_to(r)
459
460    try:
461        if args.latency:
462            medir_latencia(shells, args.latency, bindir)
463            return 0
464
465        # ── casos
466        casos: list[Caso] = []
467        dir_casos = Path(args.cases)
468        if not args.no_cases and dir_casos.is_dir():
469            casos += cargar_casos_dir(dir_casos)
470        if args.gen:
471            casos += generar_tortura_comillas()
472        if args.phases:
473            casos += cargar_fases(str(RAIZ / "recipes/*.toml"))
474        if args.files:
475            casos += cargar_ficheros(args.files)
476        if not casos:
477            print("✗ ningún caso cargado — pasá --gen, --phases, --files o poblá "
478                  f"{dir_casos}", file=sys.stderr)
479            return 2
480
481        por_origen: dict[str, int] = {}
482        for c in casos:
483            por_origen[c.origen] = por_origen.get(c.origen, 0) + 1
484        print(f"Banco diferencial · {len(casos)} casos "
485              f"({', '.join(f'{k}={v}' for k, v in sorted(por_origen.items()))})")
486        print(f"Control: {control.nombre} → {control.binario}")
487        for s in otros:
488            print(f"  vs     {s.nombre} → {s.binario}")
489
490        # ── corrida
491        #
492        # Toda divergencia dura se CONFIRMA volviendo a correr control y shell. Un caso que
493        # imprime un PID o una fecha diverge siempre y no dice nada; un shell que diverge de sí
494        # mismo es un hallazgo distinto (y peor). Sin esta confirmación el banco se llena de
495        # ruido y deja de leerse — que es como muere un banco.
496        def tarea(caso: Caso) -> tuple[Caso, dict[str, Resultado], list[Divergencia]]:
497            res = {s.nombre: ejecutar(s, caso, bindir, args.timeout) for s in shells}
498            ctrl = res[control.nombre]
499            hallazgos: list[Divergencia] = []
500            for s in otros:
501                duras, avisos = comparar(ctrl, res[s.nombre])
502                if duras:
503                    ctrl_bis = ejecutar(control, caso, bindir, args.timeout)
504                    otro_bis = ejecutar(s, caso, bindir, args.timeout)
505                    if comparar(ctrl, ctrl_bis)[0]:
506                        clase = "no-determinista"
507                    elif comparar(res[s.nombre], otro_bis)[0]:
508                        clase = "inestable"
509                    else:
510                        clase = "dura"
511                elif avisos:
512                    clase = "aviso"
513                else:
514                    continue
515                hallazgos.append(Divergencia(
516                    caso=caso, shell=s.nombre, clase=clase, campos=duras, avisos=avisos,
517                    control=ctrl, obtenido=res[s.nombre],
518                ))
519            return caso, res, hallazgos
520
521        divergencias: list[Divergencia] = []
522        cuenta: dict[str, dict[str, int]] = {
523            s.nombre: {"dura": 0, "aviso": 0, "no-determinista": 0, "inestable": 0}
524            for s in otros
525        }
526        timeouts: dict[str, int] = {s.nombre: 0 for s in shells}
527        ms_por_shell: dict[str, float] = {s.nombre: 0.0 for s in shells}
528
529        with concurrent.futures.ThreadPoolExecutor(max_workers=args.jobs) as ex:
530            for i, (caso, res, hallazgos) in enumerate(ex.map(tarea, casos), 1):
531                for s in shells:
532                    ms_por_shell[s.nombre] += res[s.nombre].ms
533                    if res[s.nombre].timeout:
534                        timeouts[s.nombre] += 1
535                for d in hallazgos:
536                    cuenta[d.shell][d.clase] += 1
537                    divergencias.append(d)
538                if i % 200 == 0:
539                    print(f"   … {i}/{len(casos)}", file=sys.stderr)
540
541        # ── informe
542        print("\n── Resumen")
543        for s in shells:
544            if s is control:
545                print(f"   {s.nombre:<12} (control)   "
546                      f"{ms_por_shell[s.nombre]/len(casos):6.1f} ms/caso"
547                      + (f"   timeouts={timeouts[s.nombre]}" if timeouts[s.nombre] else ""))
548            else:
549                c = cuenta[s.nombre]
550                extra = ""
551                if c["no-determinista"]:
552                    extra += f"   casos-no-deterministas={c['no-determinista']}"
553                if c["inestable"]:
554                    extra += f"   INESTABLE={c['inestable']}"
555                if timeouts[s.nombre]:
556                    extra += f"   timeouts={timeouts[s.nombre]}"
557                print(f"   {s.nombre:<12} DIVERGE={c['dura']:<5} avisos(stderr)={c['aviso']:<5} "
558                      f"{ms_por_shell[s.nombre]/len(casos):6.1f} ms/caso" + extra)
559
560        for clase, titulo in (("no-determinista", "Casos NO DETERMINISTAS — el control no se "
561                               "repite a sí mismo; el caso está mal escrito, no el shell"),
562                              ("inestable", "Shell INESTABLE — no se repite a sí mismo. Es un "
563                               "hallazgo peor que una divergencia")):
564            cuales = [d for d in divergencias if d.clase == clase]
565            if cuales:
566                print(f"\n── {titulo} ({len(cuales)})")
567                for d in cuales:
568                    print(f"   ⚠ [{d.shell}] {d.caso.nombre}")
569
570        duras = [d for d in divergencias if d.clase == "dura"]
571        if duras:
572            print(f"\n── Divergencias DURAS ({len(duras)})")
573            for s in otros:
574                suyas = [d for d in duras if d.shell == s.nombre]
575                if not suyas:
576                    continue
577                mostrar = suyas if args.max_show == 0 else suyas[:args.max_show]
578                for d in mostrar:
579                    print(f"\n  ✗ [{d.shell}] {d.caso.nombre}   ({', '.join(d.campos)})")
580                    if args.verbose or True:
581                        print(f"      código:   {recorta(d.caso.codigo)}")
582                        if "rc" in d.campos:
583                            print(f"      rc:       control={d.control.rc}  "
584                                  f"{d.shell}={d.obtenido.rc}")
585                        if "stdout" in d.campos:
586                            print(f"      control:  {recorta(d.control.out)}")
587                            print(f"      {d.shell:<9} {recorta(d.obtenido.out)}")
588                        if "arbol" in d.campos:
589                            print(f"      árbol:    control={d.control.arbol}  "
590                                  f"{d.shell}={d.obtenido.arbol}")
591                if args.max_show and len(suyas) > args.max_show:
592                    print(f"\n  … y {len(suyas)-args.max_show} divergencias duras más de "
593                          f"{s.nombre} (ver el TSV; --max-show 0 las muestra todas)")
594
595        # ── TSV
596        tsv = Path(args.tsv)
597        tsv.parent.mkdir(parents=True, exist_ok=True)
598        with tsv.open("w", encoding="utf-8") as f:
599            f.write("shell\tcaso\torigen\tmodo\tclase\tcampos\trc_control\trc_shell\t"
600                    "arbol_control\tarbol_shell\n")
601            for d in divergencias:
602                f.write(f"{d.shell}\t{d.caso.nombre}\t{d.caso.origen}\t{d.caso.modo}\t{d.clase}\t"
603                        f"{','.join(d.campos + ['stderr'] * bool(d.avisos))}\t"
604                        f"{d.control.rc}\t{d.obtenido.rc}\t"
605                        f"{d.control.arbol}\t{d.obtenido.arbol}\n")
606        print(f"\nTSV: {tsv}")
607
608        peor = max((c["dura"] for c in cuenta.values()), default=0)
609        if peor == 0:
610            print("\n✓ ningún shell diverge del control en rc, stdout ni árbol producido.")
611            print("  ⚠ y eso NO es prueba de suficiencia: mide el material que se le dio.")
612        return min(peor, 250)
613    finally:
614        shutil.rmtree(tmp_sh, ignore_errors=True)
615
616
617if __name__ == "__main__":
618    sys.exit(main())