MÓDULO 1.6 — PROJETO FINAL

🎯 Seu Primeiro Hook de Validação: Do SPEC ao Código

📚

Tópicos

6

⏱️

Minutos

50

🎯

Nível

Iniciante

🔨

Tipo

Projeto Prático

1

🎯 O projeto — hook de validação de commits

O projeto final da Trilha 1 é construir um PreToolUse hook que intercepta git commits e bloqueia mensagens que não são descritivas. Este é o caso de uso mais comum de hook de validação e aplica todos os conceitos dos módulos anteriores.

📋 O que vamos construir

PreToolUse hook que intercepta chamadas ao Bash com "git commit"
Valida que a mensagem tem pelo menos 20 caracteres
Rejeita mensagens genéricas: "fix", "update", "changes", "wip" sozinhos
Aprova git commit --amend e commits de merge automaticamente
Logar cada execução em .claude/logs/validate-commit.log
Fail-open quando qualquer dependência falha

💡 Por que este projeto

Mensagens de commit ruins são o problema de qualidade mais comum em projetos de engenharia — e são fáceis de automatizar. Este hook é imediatamente útil, simples o suficiente para ser o primeiro, e complexo o suficiente para cobrir todos os conceitos da trilha.

2

📋 Escrevendo o SPEC antes do código

Antes de uma linha de código, escrevemos o SPEC. Isso força decisões difíceis quando ainda são baratas: o que é uma "mensagem ruim"? Como capturar a mensagem das diferentes flags do git commit?

📋 SPEC.md completo

# SPEC: validate-commit hook

## Objetivo
Bloquear git commits do Claude Code com mensagens que não são
descritivas — garantindo um histórico de git útil.

## Entrada
- Evento: PreToolUse
- Ferramenta: Bash
- Condição: comando contém "git commit"

## Critérios de BLOQUEIO
1. Mensagem tem menos de 20 caracteres
2. Mensagem é exatamente um dos termos genéricos:
   fix, update, changes, wip, temp, test, misc, stuff

## Critérios de APROVAÇÃO (exceções)
1. git commit --amend (re-commit não precisa re-validar)
2. git commit --no-edit (merge commit automático)
3. Mensagem começa com "Merge " (merge commit)
4. Mensagem começa com "Revert " (revert automático)
5. Hook falha por qualquer razão (fail-open)

## Extração da mensagem
- Flag -m "mensagem": extrair entre aspas após -m
- Flag --message="mensagem": extrair valor após =
- Sem -m: git commit abrirá editor — não interceptar

## Invariantes
- Nunca bloqueia se jq não estiver disponível (fail-open)
- Sempre logar a decisão (aprovar ou bloquear) com timestamp
- Mensagem de erro é acionável: diz o que está errado E como corrigir

## Decisões de design
- Comprimento mínimo 20 chars: suficiente para uma descrição útil
- Lista de termos genéricos pequena: falsos positivos são piores que falsos negativos
- Não validar formato Conventional Commits: escopo desta versão é só comprimento
3

💻 Implementando o hook em shell

Com o SPEC em mãos, a implementação é direta. Cada decisão do SPEC tem correspondência exata no código — isso é o que um bom SPEC produz.

💻 validate-commit.sh completo

#!/bin/bash
# .claude/hooks/validate-commit.sh
# PreToolUse hook: valida mensagens de git commit
# SPEC: .claude/SPEC.md

set -uo pipefail  # Não usar set -e — queremos controlar erros

LOG_FILE=".claude/logs/validate-commit.log"
mkdir -p "$(dirname "$LOG_FILE")"

# Logging com timestamp
log() {
  echo "[$(date -u +%Y-%m-%dT%H:%M:%SZ)] validate-commit: $*" >> "$LOG_FILE"
}

# Fail-open: em qualquer erro inesperado, aprovar
fail_open() {
  log "WARN: $* — fail-open, aprovando"
  exit 0
}

log "Hook iniciado — PID: $$"

# Lê o payload JSON do stdin
PAYLOAD=$(cat) || fail_open "Falha ao ler stdin"

# Verifica se jq está disponível
if ! command -v jq &>/dev/null; then
  fail_open "jq não encontrado"
fi

# Extrai ferramenta e comando
TOOL_NAME=$(echo "$PAYLOAD" | jq -r '.tool.name // empty' 2>/dev/null) || fail_open "jq falhou ao parsear tool.name"
COMMAND=$(echo "$PAYLOAD" | jq -r '.tool.input.command // empty' 2>/dev/null) || fail_open "jq falhou ao parsear command"

log "Ferramenta: $TOOL_NAME"

# Só processa chamadas ao Bash
[ "$TOOL_NAME" = "Bash" ] || { log "Não é Bash — aprovando"; exit 0; }

# Só processa git commit
echo "$COMMAND" | grep -q 'git commit' || { log "Não é git commit — aprovando"; exit 0; }

log "Analisando: $COMMAND"

# Exceções: --amend, --no-edit
if echo "$COMMAND" | grep -qE -- '--amend|--no-edit'; then
  log "Exceção --amend/--no-edit — aprovando"
  exit 0
fi

# Extrai a mensagem de commit (flag -m ou --message)
MSG=""
if echo "$COMMAND" | grep -qE -- '-m\s+|--message=|--message\s+'; then
  # Tenta extrair a mensagem das aspas após -m
  MSG=$(echo "$COMMAND" | grep -oP '(?<=-m\s)["\x27]?\K[^"'\'']+(?=["\x27]?)' | head -1)
  # Fallback: grep mais simples
  if [ -z "$MSG" ]; then
    MSG=$(echo "$COMMAND" | sed -nE "s/.*-m ['\"]([^'\"]+)['\"].*/\1/p")
  fi
fi

# Se não encontrou mensagem (ex: commit sem -m, abrirá editor)
if [ -z "$MSG" ]; then
  log "Mensagem não extraída — pode ser editor interativo — aprovando"
  exit 0
fi

log "Mensagem extraída: '$MSG'"

# Exceções: merge e revert automático
if echo "$MSG" | grep -qE '^(Merge |Revert )'; then
  log "Merge/Revert commit — aprovando"
  exit 0
fi

# Validação 1: comprimento mínimo
MSG_LEN=${#MSG}
if [ "$MSG_LEN" -lt 20 ]; then
  log "BLOQUEADO: mensagem muito curta ($MSG_LEN chars)"
  echo "HOOK DE QUALIDADE: Mensagem de commit muito curta ($MSG_LEN caracteres).

Mínimo: 20 caracteres
Sua mensagem: '$MSG'

Exemplos de mensagens boas:
  'feat: adiciona autenticação por OAuth2'
  'fix: corrige cálculo de desconto no checkout'
  'refactor: extrai lógica de validação para módulo separado'" >&2
  exit 1
fi

# Validação 2: termos genéricos
GENERIC_TERMS="^(fix|update|changes|wip|temp|test|misc|stuff)$"
MSG_LOWER=$(echo "$MSG" | tr '[:upper:]' '[:lower:]' | tr -d ' ')
if echo "$MSG_LOWER" | grep -qiE "$GENERIC_TERMS"; then
  log "BLOQUEADO: mensagem genérica '$MSG'"
  echo "HOOK DE QUALIDADE: Mensagem de commit genérica.

Sua mensagem: '$MSG'

Mensagens genéricas como 'fix', 'update', 'wip' não descrevem
o que foi alterado. Use uma mensagem específica.

Exemplos:
  'fix: corrige NullPointerException no módulo de usuários'
  'update: atualiza dependências para versão LTS'" >&2
  exit 1
fi

log "Aprovado: '$MSG' ($MSG_LEN chars)"
exit 0
4

🧪 Testando o fail-open

Antes de ativar o hook em produção, teste deliberadamente os cenários de falha. Você precisa ter confiança de que o hook não vai travar o Claude Code quando algo der errado.

🧪 Suite de testes completa

#!/bin/bash
# .claude/tests/test-validate-commit.sh

HOOK=".claude/hooks/validate-commit.sh"
PASS=0; FAIL=0

t() {
  local name="$1" input="$2" expected_exit="$3"
  actual=$(echo "$input" | bash "$HOOK" 2>/dev/null; echo $?)
  actual_exit=$(echo "$input" | bash "$HOOK" > /dev/null 2>&1; echo $?)
  if [ "$actual_exit" = "$expected_exit" ]; then
    echo "  ✓ $name"; PASS=$((PASS + 1))
  else
    echo "  ✗ $name (esperado $expected_exit, obteve $actual_exit)"; FAIL=$((FAIL + 1))
  fi
}

BASE='{"tool":{"name":"Bash","input":{"command":'

echo "=== Bloqueios esperados ==="
t "mensagem curta" "${BASE}\"git commit -m 'fix'\"}}" 1
t "mensagem genérica update" "${BASE}\"git commit -m 'update'\"}}" 1
t "mensagem genérica wip" "${BASE}\"git commit -m 'wip'\"}}" 1

echo "=== Aprovações esperadas ==="
t "mensagem boa" "${BASE}\"git commit -m 'feat: adiciona autenticação OAuth2'\"}}" 0
t "amend" "${BASE}\"git commit --amend --no-edit\"}}" 0
t "no-edit" "${BASE}\"git commit --no-edit\"}}" 0
t "merge commit" "${BASE}\"git commit -m 'Merge branch main'\"}}" 0
t "não é bash" '{"tool":{"name":"Read","input":{"file":"test"}}}' 0
t "não é git commit" "${BASE}\"git status\"}}" 0

echo "=== Fail-open ==="
t "payload vazio" '{}' 0
t "payload inválido" 'invalid json' 0

echo ""
echo "Resultado: $PASS passou, $FAIL falhou"
[ "$FAIL" = "0" ] && exit 0 || exit 1

💡 Testando sem jq instalado

Para simular jq ausente: PATH="" echo '{"tool":{"name":"Bash"...}}' | .claude/hooks/validate-commit.sh. O hook deve retornar exit 0 (fail-open) mesmo sem jq no PATH.

5

🔗 Integrando com settings.json

Com o hook testado, o último passo é registrá-lo no settings.json e verificar que o Claude Code o executa corretamente em um teste end-to-end real.

1

Dar permissão de execução

chmod +x .claude/hooks/validate-commit.sh
# Verifica: ls -la .claude/hooks/validate-commit.sh
# Deve mostrar: -rwxr-xr-x
2

Registrar no .claude/settings.json

{
  "hooks": {
    "PreToolUse": [
      {
        "matcher": "Bash",
        "hooks": [
          {
            "type": "command",
            "command": ".claude/hooks/validate-commit.sh"
          }
        ]
      }
    ]
  }
}
3

Teste end-to-end no Claude Code

# Inicia o Claude Code no diretório do projeto
claude

# Teste de bloqueio — deve falhar com mensagem do hook:
"Execute: git commit -m 'fix'"

# Teste de aprovação — deve funcionar:
"Execute: git commit -m 'test: verifica que hook está funcionando'"

# Verifica o log:
"Mostre o conteúdo de .claude/logs/validate-commit.log"
6

🔄 Iteração incremental — melhorando o hook

O hook está funcional. Agora iteramos — cada melhoria é um commit separado, mantendo o histórico claro de por que cada feature foi adicionada.

Iteração 1: Modo DRY_RUN

Adiciona variável de ambiente para testar sem bloquear de verdade.

# Adicionar antes do exit 1 nos bloqueios:
if [ "${VALIDATE_COMMIT_DRY_RUN:-}" = "1" ]; then
  log "DRY_RUN: teria bloqueado — $MSG"
  exit 0
fi

# Uso: VALIDATE_COMMIT_DRY_RUN=1 claude

Commit: hook(validate-commit): adiciona modo DRY_RUN para testes não-destrutivos

Iteração 2: Estatísticas de uso

Conta quantos commits foram bloqueados vs aprovados.

STATS_FILE=".claude/state/commit-stats.json"

update_stats() {
  local action="$1"  # "blocked" ou "approved"
  local current=$(cat "$STATS_FILE" 2>/dev/null || echo '{"blocked":0,"approved":0}')
  local new=$(echo "$current" | jq ".$action += 1 | .last_action = \"$action\" | .last_at = \"$(date -u +%Y-%m-%dT%H:%M:%SZ)\"")
  local tmp=$(mktemp "$(dirname "$STATS_FILE")/stats.XXXXXX.json")
  echo "$new" > "$tmp" && mv "$tmp" "$STATS_FILE"
}

# Chamar antes de cada exit:
update_stats "blocked" && exit 1
update_stats "approved" && exit 0

Commit: hook(validate-commit): adiciona estatísticas de bloqueios em JSON

Iteração 3: Sugestão de mensagem melhorada

A mensagem de erro sugere o que o usuário deveria ter escrito.

# Na mensagem de bloqueio por comprimento, adicionar:
echo "Sua mensagem foi: '$MSG'
Que tal tentar algo como:
  'feat: descreva aqui o que a mudança adiciona'
  'fix: explique aqui o bug que foi corrigido'
  'refactor: descreva o que foi reestruturado'" >&2

Commit: hook(validate-commit): melhora mensagem de erro com exemplos de formato

🏆 Parabéns! Você concluiu a Trilha 1

Você agora tem um hook de validação funcional e todo o conhecimento da base para construir sistemas mais complexos. Estes são os fundamentos que tornam possível tudo que vem na Trilha 2.

Módulo 1.1: Claude Code como agente — Bash, Read, Edit, Write, settings.json, CLAUDE.md
Módulo 1.2: Hooks — 4 tipos, protocolo stdin/stdout, exit codes, fail-open
Módulo 1.3: Slash commands e skills — escopo local/global, $args, regra dos 3 usos
Módulo 1.4: Estado persistente — YAML/JSON, escrita atômica, CAS, lockfiles
Módulo 1.5: Ambiente — estrutura de repo, testes isolados, logs, SPEC.md, jq/yq/shellcheck
Módulo 1.6: Projeto prático — do SPEC ao código, testes de fail-open, iteração incremental

Próxima Trilha:

Trilha 2 — Construindo com Claude Code — como o Claudex foi construído do zero usando 7 prompts incrementais verificados