Oito Formas do Shell Comer Meus Dados em Silêncio

Passei um dia escrevendo cola — bash chamando curl, encanando em jq, lendo git log, saindo para Python para o parsing que bash não deveria fazer. Trabalho comum. No fim do dia eu tinha oito bugs distintos, e o que eles tinham em comum é o que vale registrar: nenhum deles produziu um erro.

Nenhum código de saída diferente de zero, nenhum aviso em stderr, nenhum crash. Cada um produziu um resultado que parecia inteiramente razoável e estava errado. Esse é o perigo específico da cola em shell: o modo de falha é silencioso, e a saída é plausível o suficiente para ir para produção.

Aqui estão eles, com o sintoma primeiro, porque é assim que você vai encontrá-los.

1. Um heredoc rouba o stdin do programa que ele alimenta

Sintoma. Um pipeline não produz nada. Sem erro. O script Python no fim aparentemente roda e emite um resultado vazio.

collect_activities | python3 - <<'PY'
import sys
for line in sys.stdin:      # nunca vê uma única linha
    print(line.strip())
PY

Causa. python3 - significa leia o programa do stdin. O heredoc está ligado ao stdin, então o Python lê seu próprio código-fonte dali e chega ao EOF. O pipe à esquerda é descartado inteiramente.

Correção. Coloque o programa em outro descritor e deixe o stdin para os dados:

collect_activities | python3 /dev/fd/3 3<<'PY'

Esse me custou duas vezes, porque bati nele novamente em um comando de verificação improvisado uma hora depois de corrigi-lo no script.

2. Tab é espaço em branco do IFS, então campos vazios iniciais colapsam

Sintoma. Todo campo desloca uma posição para a esquerda, mas só para registros cujo primeiro campo é vazio. Uma execução sem ticket vinculado reportava seu slug interno onde deveria haver uma descrição legível.

# a linha é: "\tAlguma descrição\tfeature-slug"
while IFS=$'\t' read -r id desc slug; do
  # id="Alguma descrição", desc="feature-slug", slug=""

Causa. Espaço, tab e newline são espaço em branco do IFS, que o bash trata de forma especial: sequências no início e no fim são removidas e ocorrências consecutivas colapsam em uma. Definir IFS=$'\t' não desativa esse comportamento, porque tab continua sendo espaço em branco.

Correção. Use um delimitador que não seja espaço em branco. O separador de unidade existe para isso:

while IFS=$'\x1f' read -r id desc slug; do

3. Substituição de comando remove bytes NUL

Sintoma. Um aviso que você seria perdoado por ignorar — command substitution: ignored null byte in input — e uma variável sem seu delimitador.

Eu havia usado \0 para separar dois valores em uma única string capturada, com o raciocínio de que nenhum conteúdo real o conteria. Correto, e inútil: $( ) descarta bytes NUL, então o delimitador era justamente a única coisa garantidamente incapaz de sobreviver.

Correção. Não invente delimitadores para dados estruturados. Emita JSON e deixe o jq ler de volta:

merged="$(build | python3 …)"     # imprime {"value": "...", "added": [...]}
value="$(jq -r .value <<<"$merged")"

Isso também corrigiu a segunda metade do bug, que era grep '^\x00' — não é uma regex básica válida, e o grep diz isso com um aviso ameno sobre "stray \ before x" em vez de falhar.

4. O -e do jq reflete o valor, não a validade

Sintoma. Um helper rejeitou null como JSON inválido. null é JSON válido, e no meu caso era o valor significativo — limpar um campo.

echo "$val" | jq -e . >/dev/null || die "JSON inválido: $val"

Causa. -e define o código de saída a partir da saída: null e false dão exit 1. Não é uma verificação de sintaxe.

Correção. jq empty valida a sintaxe e não emite nada:

echo "$val" | jq empty >/dev/null 2>&1 || die "JSON inválido: $val"

5. No jq, o . muda de contexto depois de um pipe

Sintoma. jq: error: Cannot index array with string ("stage"), de uma expressão que se lê perfeitamente bem.

sort_by([ (["done","failed"] | index(.stage)) != null, .updatedAt ])

Causa. Dentro de ["done","failed"] | index(.stage), o ponto já mudou para aquele array literal. .stage está pedindo a um array uma chave de string.

Correção. Vincule antes do pipe:

sort_by([ ((.stage // "") as $s | (["done","failed"] | index($s))) != null, .updatedAt ])

O que tornou esse caro foi que o shell ao redor tinha 2>/dev/null || true na chamada, então o erro nunca apareceu. A função simplesmente retornava vazio e tudo adiante tratava isso como "nenhuma correspondência encontrada". Uma expressão idêntica em outro lugar da mesma base de código estava correta, e é por isso que eu não havia suspeitado do padrão.

6. @csv coloca strings entre aspas, e APIs rejeitam as aspas

Sintoma. HTTP 400 de uma requisição cujo parâmetro de ids parecia correto.

[.relations[] | (.url | split("/") | last)] | @csv    # → "6764","6765"

Causa. @csv coloca valores de string entre aspas, corretamente, porque é o que CSV exige. A API queria 6764,6765. Uma chamada quase idêntica em outro lugar funcionava, porque ali os ids saíam do JSON como números, e @csv não coloca números entre aspas.

Correção. join(",") quando você quer uma lista nua — ou converta para números primeiro se você especificamente quer o escaping do @csv.

7. zsh lê :x depois de um $var nu como um modificador

Sintoma. Três chamadas curl idênticas em um laço retornam 404. A mesma URL, colada à mão, funciona.

for r in 3 5 6; do
  curl ... "https://…/values/Sheet%21L$r:clear"
done

Causa. zsh suporta modificadores de estilo histórico em expansão de parâmetro, e $r:clear é interpretado como $r seguido de um modificador, em vez de $r seguido de um literal :clear.

Correção. Use chaves na expansão — ${r}:clear. Vale saber se você escreve scripts com #!/usr/bin/env bash mas os cola em um zsh interativo, que é exatamente o desencontro em que eu estava.

Quero destacar o erro de diagnóstico que cometi aqui, já que é mais instrutivo que o bug: vi o 404 e presumi que a codificação de URL do ! era a culpada, porque é o caractere de aparência interessante. Gastei duas tentativas codificando e recodificando. O caractere que estava de fato quebrado era o sem graça.

8. git log --all inclui refs/stash

Sintoma. Um relatório de "no que trabalhei hoje" contendo WIP on main: 0685722 e index on main: 0685722.

Causa. --all significa todas as refs, e refs/stash é uma ref. Todo stash que você já fez é um commit com um assunto gerado, e ele cai na saída parecendo trabalho.

Correção. Seja explícito sobre o que você quer:

git log --branches --remotes --no-merges --author="$email" --since=

--no-merges entra por um motivo relacionado: Merge pull request #48 from … é processo, não atividade, e estava em maior número que os assuntos reais.

O padrão

Sete dos oito foram causados por uma ferramenta fazendo algo razoável sobre o qual eu não havia perguntado. @csv coloca aspas porque CSV precisa de aspas. -e reporta veracidade porque é seu trabalho documentado. Tab colapsa porque tab é espaço em branco. Nada disso é bug da ferramenta.

A única lição que eu de fato generalizaria é sobre os diagnósticos, não sobre as ferramentas. Dois desses foram caros puramente porque um erro estava sendo engolido — 2>/dev/null || true numa chamada de jq, e um head -1 na saída capturada que escondia tudo depois da primeira linha. As duas supressões eram deliberadas, e as duas eram razoáveis isoladamente: eu não queria um aviso ruidoso derrubando uma execução.

Se você vai descartar o stderr de uma ferramenta, descarte no ponto em que você já decidiu que a falha é sobrevivível — e garanta que algo ainda diga que uma falha aconteceu. Um resultado vazio e um erro silenciado são indistinguíveis de um resultado legitimamente vazio, e você vai gastar uma hora nessa diferença.