Un commit, tres fallos silenciosos: el backup borró el archivo equivocado, el JSON se escribió mal y las comillas quedaron aplanadas


Tu script de backup termina e imprime una última línea: «Backup complete», código de salida 0. Te vas a la cama. Al día siguiente vas a por el hermes-backup-*.zip más reciente para restaurar algo, y no está. Todos los archivos que siguen ahí son más antiguos que el que necesitabas. El comando nunca dio error; simplemente borró justo el archivo con el que contabas.

O esto otro: una herramienta escribe un valor en tu config.json. Se carga sin problema en tu máquina. Un colega parsea el mismo archivo con otro lenguaje y recibe una excepción: el archivo contiene algo que el JSON estándar no permite.

Esos dos, más un problema de formato más silencioso, comparten un mismo modo de fallo: el comando devolvió éxito mientras hacía lo incorrecto. El PR #130619 (P1, fusionado el 2026-10-01) cierra tres bugs exactamente de esa forma, arreglando los issues #128886, #128888 y #128894. Aquí va cada uno.

Trampa 1: --keep -1 borra tu backup más reciente

Primero, qué se supone que hace --keep. hermes backup --keep N significa: tras un backup completo, elimina los archivos hermes-backup-*.zip más antiguos del directorio de salida más allá de los N más recientes. El valor por defecto es 3, y 0 conserva todo. (Puedes confirmarlo tú mismo con hermes backup --help.)

El bug estaba en cómo se parseaba ese número. Antes del fix, --keep usaba type=int y /snapshot prune N usaba un int() pelado. Eso dejaba pasar valores con signo como -1 o +5, hasta llegar a la línea que hace el trabajo:

newest_first[keep:]

Eso es un slice sobre una lista ordenada de más reciente a más antiguo. Cuando keep es 0 o positivo, recorta los archivos viejos por la cola, lo cual es correcto. Cuando keep es negativo, newest_first[-1:] corta el último elemento —el archivo más reciente— y lo borra. El comando sigue imprimiendo éxito.

Tras el fix, el valor negativo se rechaza en la puerta:

  • hermes_cli/subcommands/backup.py añade _non_negative_keep como tipo de argparse, usando isdecimal() internamente para que solo pase un entero sin signo y no negativo. Una entrada negativa, con signo o no entera ahora falla con código de salida 2 y el mensaje must be a non-negative integer (0 keeps everything).
  • El interactivo /snapshot prune <N> usa la misma regla de isdecimal(): -1 o x imprime la línea de uso (Usage: /snapshot prune [keep-count]) y no borra nada.

El valor por defecto sigue siendo 3, y 0 sigue significando conservar todo.

Trampa 2: una escritura JSON se cuela un NaN

La segunda es más traicionera, porque no aflora en el momento de escribir.

Antes de escribir un archivo, Hermes ejecuta una puerta de validación de sintaxis JSON para detectar contenido malformado. Esa puerta usaba json.loads de Python. El problema: json.loads acepta NaN, Infinity y -Infinity por defecto, extensiones de JavaScript que el JSON estándar no tiene. Así que una escritura que introducía esas constantes pasaba la puerta. Se relee sin problema en Python en tu máquina, pero en el momento en que otro lenguaje (un parser estricto de Rust, Go o Node) lo lee, el archivo ya no vuelve a abrirse.

Tras el fix (tools/file_operations.py), la regla es precisa: rechazar solo cuando esta escritura introduce una constante que el archivo no contenía ya. Los bordes:

  • Un archivo antiguo que ya contiene NaN sigue aceptando ediciones no relacionadas: no queda congelado.
  • Un archivo nuevo que contiene una constante se rechaza, y nunca llega a crearse.
  • Se elimina un BOM antes de parsear, así que un BOM UTF-8 no puede confundir la comprobación.
  • Una cadena "NaN" entre comillas, 1e308 (un número grande legal) y null no se ven afectados.

El mensaje de rechazo dice:

Refusing to write '<path>': candidate content uses NaN, which is not valid JSON. The file was NOT created or modified. Use null or a string instead and retry.

Fíjate en la última frase —«The file was NOT created or modified»—: el rechazo ocurre antes de la escritura, así que no cambia ni un byte en disco.

Trampa 3: replace_all aplanó tus comillas y guiones

La tercera va de tipografía, y solo muerde cuando usas reemplazo por lotes.

El fuzzy matching de Hermes incluye una estrategia unicode_normalized: normaliza variantes Unicode como comillas curvas y em-dashes antes de comparar. De ese modo, una " (comilla recta) en tu búsqueda sigue coincidiendo con «…» (comillas tipográficas) en el archivo. Tras una coincidencia, un paso aparte debe preservar el Unicode original del archivo: mantener las comillas tipográficas propias del archivo en lugar de meter las nuevas rectas, para que editar una frase no reestilice un párrafo entero a ASCII.

El bug aparecía cuando replace_all (reemplazar cada aparición) estaba activado. Ese paso de preservación comparaba todas las regiones coincidentes concatenadas contra el old_string normalizado. Con una sola coincidencia se sostenía; con varias nunca podía: la concatenación simplemente no es el mismo texto que un único old_string. La comparación fallaba, el código recurría a empalmar la versión ASCII en cada punto, y todas las comillas tipográficas y em-dashes del archivo quedaban aplanados a " y --.

Tras el fix (tools/fuzzy_match.py):

  • _unicode_edit_plan() calcula la tabla de edición de viejo a nuevo una sola vez, independiente de cualquier región concreta del archivo, para que replace_all pueda reutilizarla.
  • _apply_replacements aplica _preserve_unicode_in_replacement a cada región coincidente por separado, y luego reindenta. Cada acierto conserva su propia tipografía original: una aparición puede usar comillas tipográficas mientras otra usa comillas rectas, y cada una se queda como estaba.

Qué tienen en común las tres: todas devuelven éxito

Ponlas en fila y son tres caras de una misma cosa:

  • --keep -1: imprime éxito, sale con 0, borra el backup más reciente.
  • La escritura NaN: pasa la puerta de sintaxis, «tiene éxito», solo explota en otro parser.
  • El aplanado: reporta éxito con un conteo de aciertos correcto, pero el archivo queda dañado más de lo que pediste.

Ninguna se cuelga. Un crash sería fácil: lo verías, revertirías y seguirías. La parte peligrosa es devolver éxito: sin error, sin excepción, sin salida distinta de cero. Solo descubres el daño en el siguiente uso (restaurar el backup, leer el JSON en otro sitio, hacer diff y encontrarte con un muro de comillas rectas), a menudo mucho después.

La causa raíz también es compartida: la validación estaba en el lugar equivocado. --keep debería haber rechazado los negativos en la puerta, pero los dejaba llegar al slice. La puerta JSON debería haber comprobado «¿esto introduce algo que el estándar prohíbe?», pero solo comprobaba «¿puede Python leerlo?». La preservación de tipografía debería haberse ejecutado por aparición, pero usaba un todo concatenado. Cada fix hace lo mismo: mover la comprobación al punto donde la decisión ocurre de verdad.

Qué hacer ahora

Nada que configurar; son unos minutos de auto-chequeo:

  1. Echa un vistazo a tu directorio de backups. ls ~/hermes-backup-*.zip y confirma que el más reciente está ahí con un timestamp sensato. Si alguna vez usaste un keep negativo, revísalo con lupa.
  2. Valida los archivos .json importantes con un parser estricto. En Python, json.loads(..., parse_constant=...) rechazará activamente NaN/Infinity, o simplemente lee el archivo con cualquier librería JSON estricta de otro lenguaje. «Se abre en mi máquina» no significa que se abra en otro sitio.
  3. Revisa los archivos que pasaste por replace_all. grep -n busca una comilla recta " o un guion doble --: donde debería haber una comilla tipográfica o un em-dash, esa es la firma del aplanado.

En cuanto al estado de versión, honestidad ante todo: la última release estable es v0.21.5 (tag v2026.9.24, publicada el 2026-09-24), y este cambio solo aterrizó el 2026-10-01, así que está solo en la rama main y no ha salido en ninguna release todavía. Si sigues main, tu siguiente hermes update lo trae; los usuarios de estable lo tendrán en el próximo tag.

La versión corta

  • Tres issues P1 (#128886 / #128888 / #128894) se arreglan juntos con el PR #130619: todos fallos silenciosos de «devolvió éxito mientras hacía lo incorrecto».
  • hermes backup --keep -1 y /snapshot prune -1 antes borraban el archivo más reciente; ahora los negativos se rechazan en la puerta (código de salida 2, o una línea de uso), mientras que el 3 por defecto y «0 conserva todo» no cambian.
  • Una escritura JSON que introduce NaN/Infinity/-Infinity ahora se rechaza y nunca aterriza; un archivo que ya tenía la constante sigue aceptando ediciones no relacionadas.
  • unicode_normalized más replace_all antes aplanaba cada comilla tipográfica y em-dash; ahora cada aparición conserva su propia tipografía.
  • El cambio está solo en main, no en ninguna release, y llega a los usuarios de estable en el próximo tag.

Para la mecánica con más detalle, consulta la referencia del comando hermes backup, la referencia del comando hermes config y los artículos sobre redacción de credenciales y cifrado de secretos en el Keychain: todos cubren la misma familia de problema de confianza: parece estar bien en local y se rompe una capa más allá.