Um commit, três falhas silenciosas: o backup apagou o arquivo errado, o JSON foi gravado errado, as aspas foram achatadas


Seu script de backup termina e imprime uma última linha: “Backup complete”, código de saída 0. Você vai dormir. No dia seguinte, você pega o hermes-backup-*.zip mais recente para restaurar algo — e ele sumiu. Todo arquivo que ainda está lá é mais antigo do que o que você precisava. O comando nunca deu erro; ele simplesmente apagou exatamente o arquivo com que você contava.

Ou isto: uma ferramenta grava um valor no seu config.json. Ele carrega sem problema na sua máquina. Um colega analisa o mesmo arquivo com outra linguagem e recebe uma exceção — o arquivo contém algo que o JSON padrão não permite.

Esses dois, mais um problema de formatação mais discreto, compartilham um mesmo modo de falha: o comando retornou sucesso fazendo a coisa errada. O PR #130619 (P1, mesclado em 2026-10-01) fecha três bugs exatamente desse tipo, corrigindo as issues #128886, #128888 e #128894. Veja cada um.

Armadilha 1: --keep -1 apaga o seu backup mais recente

Primeiro, o que --keep deveria fazer. hermes backup --keep N significa: após um backup completo, apagar os arquivos hermes-backup-*.zip mais antigos no diretório de saída além dos N mais recentes. O padrão é 3, e 0 mantém tudo. (Você pode confirmar isso com hermes backup --help.)

O bug estava em como esse número era interpretado. Antes da correção, --keep usava type=int e /snapshot prune N usava um int() cru. Isso deixava passar valores com sinal, como -1 ou +5, todo o caminho até a linha que faz o trabalho:

newest_first[keep:]

É um slice sobre uma lista ordenada do mais recente para o mais antigo. Quando keep é 0 ou positivo, ele corta os arquivos antigos do fim, o que está correto. Quando keep é negativo, newest_first[-1:] recorta o último elemento — o arquivo mais recente — e o apaga. O comando ainda imprime sucesso.

Após a correção, o valor negativo é rejeitado logo na entrada:

  • hermes_cli/subcommands/backup.py adiciona _non_negative_keep como tipo do argparse, usando isdecimal() internamente, de modo que só passa um inteiro não negativo e sem sinal. Entrada negativa, com sinal ou não inteira agora falha com código de saída 2 e a mensagem must be a non-negative integer (0 keeps everything).
  • O /snapshot prune <N> interativo usa a mesma regra de isdecimal(): -1 ou x imprime a linha de uso (Usage: /snapshot prune [keep-count]) e não apaga nada.

O padrão continua sendo 3, e 0 ainda significa manter tudo.

Armadilha 2: uma gravação de JSON deixa passar NaN

A segunda é mais sorrateira, porque não aparece no momento da gravação.

Antes de gravar um arquivo, o Hermes roda uma verificação de sintaxe JSON para pegar conteúdo malformado. Essa verificação usava o json.loads do Python. O problema: json.loads aceita NaN, Infinity e -Infinity por padrão — extensões do JavaScript que o JSON padrão não tem. Então uma gravação que introduzia essas constantes passava pela verificação. Ela é lida de volta sem problema no Python da sua máquina, mas no momento em que outra linguagem (um parser estrito de Rust, Go ou Node) a lê, o arquivo nunca mais abre.

Após a correção (tools/file_operations.py), a regra é precisa: recusar apenas quando esta gravação introduz uma constante que o arquivo já não continha. Os casos extremos:

  • Um arquivo antigo que já contém NaN continua aceitando edições não relacionadas — ele não fica congelado.
  • Um arquivo novo que contenha uma constante é recusado e nunca chega a ser criado.
  • Um BOM é removido antes da análise, então um BOM UTF-8 não confunde a verificação.
  • Uma string "NaN" entre aspas, 1e308 (um número grande legal) e null não são afetados.

A mensagem de rejeição diz:

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.

Repare na última frase — “The file was NOT created or modified”: a recusa acontece antes da gravação, então não muda um único byte no disco.

Armadilha 3: o replace_all achatou suas aspas e travessões

A terceira é sobre tipografia e só morde quando você usa substituição em batch.

O fuzzy matching do Hermes inclui uma estratégia unicode_normalized: ele normaliza variantes Unicode, como aspas curvas e travessões (em-dashes), antes de comparar. Assim, uma " (aspa reta) na sua busca ainda corresponde a “…” (aspas inteligentes) no arquivo. Após uma correspondência, um passo separado deveria preservar o Unicode original do arquivo — manter as aspas inteligentes do próprio arquivo em vez de deixar as novas aspas retas entrarem, para que editar uma frase não reestilize um parágrafo inteiro para ASCII.

O bug aparecia quando replace_all (substituir todas as ocorrências) estava ativo. Esse passo de preservação comparava todas as regiões correspondentes concatenadas entre si com o old_string normalizado. Com uma única correspondência, funcionava; com várias, nunca podia funcionar — a concatenação simplesmente não é o mesmo texto que um único old_string. A comparação falhava, o código caía de volta no caminho de inserir a versão ASCII em todos os pontos, e toda aspa inteligente e travessão do arquivo eram achatados para " e --.

Após a correção (tools/fuzzy_match.py):

  • _unicode_edit_plan() calcula a tabela de edição de antigo para novo uma vez, independente de qualquer região específica do arquivo, para que replace_all possa reutilizá-la.
  • _apply_replacements aplica _preserve_unicode_in_replacement a cada região correspondente separadamente, depois reindenta. Cada acerto mantém sua própria tipografia original — uma ocorrência pode usar aspas inteligentes enquanto outra usa aspas retas, e cada uma permanece como estava.

O que as três têm em comum: todas retornam sucesso

Alinhe as três e elas são três faces de uma mesma coisa:

  • --keep -1: imprime sucesso, sai com 0, apaga o backup mais recente.
  • A gravação de NaN: passa pela verificação de sintaxe, “tem sucesso”, só explode em outro parser.
  • O achatamento: reporta sucesso com uma contagem de acertos correta, mas o arquivo é danificado mais do que você pediu.

Nenhuma delas trava. Um travamento seria fácil — você o veria, faria rollback e seguiria em frente. A parte perigosa é retornar sucesso: sem erro, sem exceção, sem código de saída diferente de zero. Você só descobre o dano no próximo uso (restaurar o backup, ler o JSON em outro lugar, fazer diff e dar de cara com uma parede de aspas retas), muitas vezes bem depois.

A causa raiz também é compartilhada: a validação estava no lugar errado. O --keep deveria ter rejeitado negativos na entrada, mas os deixava chegar ao slice. A verificação de JSON deveria ter checado “isso introduz algo que o padrão proíbe?”, mas só checava “o Python consegue ler?”. A preservação de tipografia deveria ter rodado por ocorrência, mas usava um todo concatenado. Toda correção faz a mesma coisa: mover a verificação para o ponto onde a decisão de fato acontece.

O que fazer agora

Nada para configurar; são alguns minutos de autoverificação:

  1. Dê uma olhada no seu diretório de backup. ls ~/hermes-backup-*.zip e confirme que o mais recente está presente, com um timestamp sensato. Se você já usou um keep negativo, verifique isso com atenção.
  2. Valide arquivos .json importantes com um parser estrito. Em Python, json.loads(..., parse_constant=...) rejeita ativamente NaN/Infinity, ou simplesmente leia o arquivo com qualquer biblioteca JSON estrita em outra linguagem. “Abre na minha máquina” não significa que abre em outro lugar.
  3. Confira arquivos que você processou em batch com replace_all. Faça grep -n por uma aspa reta " ou um hífen duplicado -- — onde deveria haver uma aspa inteligente ou um travessão, essa é a assinatura do achatamento.

Sobre o status de versão, honestidade antes de tudo: o release estável mais recente é a v0.21.5 (tag v2026.9.24, lançado em 2026-09-24), e essa mudança só chegou em 2026-10-01 — então ela está apenas na branch main e ainda não saiu em nenhum release. Se você acompanha a main, seu próximo hermes update a traz; usuários da versão estável a receberão na próxima tag.

A versão curta

  • Três issues P1 (#128886 / #128888 / #128894) são corrigidas juntas pelo PR #130619 — todas falhas silenciosas que “retornaram sucesso fazendo a coisa errada”.
  • hermes backup --keep -1 e /snapshot prune -1 antes apagavam o arquivo mais recente; os negativos agora são rejeitados na entrada (código de saída 2 ou uma linha de uso), enquanto o padrão 3 e “0 mantém tudo” continuam iguais.
  • Uma gravação de JSON que introduz NaN/Infinity/-Infinity agora é recusada e nunca é efetivada; um arquivo que já tinha a constante continua aceitando edições não relacionadas.
  • unicode_normalized mais replace_all antes achatavam toda aspa inteligente e travessão; agora cada ocorrência mantém sua própria tipografia.
  • A mudança está somente na main, não em nenhum release, e chega aos usuários da versão estável na próxima tag.

Para a mecânica com mais profundidade, veja a referência do comando hermes backup, a referência do comando hermes config, e os artigos sobre redação de credenciais e criptografia de secrets no Keychain — todos cobrem a mesma família de problema de confiança: parece tudo bem localmente e quebra a uma camada de distância.