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.pyadiciona_non_negative_keepcomo tipo do argparse, usandoisdecimal()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 mensagemmust be a non-negative integer (0 keeps everything).- O
/snapshot prune <N>interativo usa a mesma regra deisdecimal():-1ouximprime 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
NaNcontinua 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) enullnã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 quereplace_allpossa reutilizá-la._apply_replacementsaplica_preserve_unicode_in_replacementa 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:
- Dê uma olhada no seu diretório de backup.
ls ~/hermes-backup-*.zipe confirme que o mais recente está presente, com um timestamp sensato. Se você já usou um keep negativo, verifique isso com atenção. - Valide arquivos
.jsonimportantes 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. - Confira arquivos que você processou em batch com
replace_all. Façagrep -npor 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 -1e/snapshot prune -1antes 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/-Infinityagora é recusada e nunca é efetivada; um arquivo que já tinha a constante continua aceitando edições não relacionadas. unicode_normalizedmaisreplace_allantes 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.