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…
⎇ fa027e246e93b8f6b42f49fe85690071dad08f6a
scripts/sh-banco-diferencial.py
Ñawpa willay
Pi imata llamkarqan
ch'uya
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())