Todo time de desenvolvimento acumula convenções não escritas que só existem na cabeça dos membros mais antigos. Tabs ou espaços? Onde vai o erro neste retorno? Esse lock está sendo usado corretamente? Análise estática responde a essas perguntas de forma consistente, para todos os contribuidores, em cada commit.
Go tem uma vantagem única nessa área: a linguagem foi projetada com ferramentas em mente. O compilador é rigoroso, o gofmt é universal e o ecossistema de análise estática é maduro. Integrar essas ferramentas ao fluxo de trabalho é simples e os ganhos em qualidade são imediatos.
gofmt: um único estilo para toda a comunidade
gofmt formata código Go de acordo com o estilo oficial da linguagem. Não existe configuração — não há como ajustar a indentação, a posição das chaves ou o espaçamento. Isso elimina completamente as discussões de estilo entre equipes.
# Formatar um arquivo
gofmt -w arquivo.go
# Formatar recursivamente todos os arquivos Go do projeto
gofmt -w .
# Ver o diff sem aplicar
gofmt -d arquivo.go
# Listar os arquivos que não estão formatados. O código de saída é 0
# mesmo quando a lista não está vazia: no CI, é preciso testar a saída
gofmt -l .
gofmt também realiza simplificações automáticas com a flag -s:
# Simplifica construções redundantes
gofmt -s -w .
Exemplos de simplificações que -s aplica:
// Antes
s := []int{1, 2, 3}
for i, _ := range s { } // → for i := range s {}
ps := []Ponto{Ponto{1, 2}} // → ps := []Ponto{{1, 2}}
a = a[0:len(a)] // → a = a[0:]
goimports: gofmt com gerenciamento de imports
goimports faz tudo que gofmt faz e ainda adiciona imports ausentes e remove imports não utilizados:
go install golang.org/x/tools/cmd/goimports@latest
goimports -w .
A maioria dos editores executa goimports automaticamente ao salvar — é um dos primeiros plugins a instalar ao configurar um ambiente Go.
go vet: análise estática da biblioteca padrão
go vet detecta erros comuns que o compilador não captura:
go vet ./...
Exemplos do que go vet detecta:
// Printf com argumento errado
fmt.Printf("%d", "texto") // vet: fmt.Printf format %d has arg "texto" of wrong type string
// Mutex copiado
mu := sync.Mutex{}
outraMu := mu // vet: assignment copies lock value to outraMu: sync.Mutex
// Código inalcançável após return
func f() int {
return 1
fmt.Println("nunca") // vet: unreachable code
return 2 // sem este return, nem compila: missing return
}
// String de formato inválida
fmt.Sprintf("%!") // vet: fmt.Sprintf format %! has unknown verb !
// Formato vindo de variável, sem outros argumentos
fmt.Printf(msg) // vet: non-constant format string in call to fmt.Printf
staticcheck: análise estática avançada
staticcheck é o analisador estático mais abrangente do ecossistema Go, cobrindo dezenas de verificações que go vet não faz:
go install honnef.co/go/tools/cmd/staticcheck@latest
staticcheck ./...
// SA1000: expressão regular inválida
regexp.MustCompile("[invalid") // error parsing regexp: missing closing ]
// SA1006: formato dinâmico sem outros argumentos; um % dentro de msg
// seria interpretado como verbo
fmt.Printf(msg) // use fmt.Print(msg)
// SA1019: uso de pacote ou função depreciada
ioutil.ReadFile("arquivo") // io/ioutil has been deprecated since Go 1.19; use os.ReadFile
// SA4006: valor atribuído e sobrescrito antes de ser lido
x := calcular()
x = 2 // this value of x is never used (o de calcular)
return x
// SA5008: opção desconhecida em tag de struct; o go vet não acusa
type T struct {
Campo string `json:"campo,omitemtempty"` // invalid appearance of unknown `omitemtempty` tag option
}
// SA9003: ramo vazio
if b {
}
// S1000: select com apenas um case
select {
case v := <-ch:
usar(v)
}
// simplifique para: v := <-ch
// ST1003: sigla com maiúsculas misturadas
func getHttpClient() {} // func getHttpClient should be getHTTPClient
golangci-lint: o orquestrador
golangci-lint executa múltiplos linters em paralelo de forma eficiente. É a ferramenta padrão da indústria para pipelines de CI/CD.
# Instalação da versão 2. O caminho sem /v2 instala a 1.64.8,
# última da linha 1, que não recebe mais atualizações
go install github.com/golangci/golangci-lint/v2/cmd/golangci-lint@latest
# Executar
golangci-lint run ./...
# Com tempo limite maior para projetos grandes
golangci-lint run --timeout 5m ./...
# Mostrar apenas erros novos em relação ao branch principal
golangci-lint run --new-from-rev=main ./...
Configuração do golangci-lint
O arquivo .golangci.yml na raiz do projeto configura quais linters rodar e suas opções. A versão 2 do golangci-lint, de 2025, mudou o formato: o arquivo precisa declarar version: "2", o disable-all virou default: none, as opções passaram para linters.settings, as exclusões para linters.exclusions, e gofmt e goimports viraram formatadores, numa seção própria. Um arquivo no formato antigo é recusado com unsupported version of the configuration, e o comando golangci-lint migrate converte. O exemplo abaixo passa no golangci-lint config verify da versão 2.14:
# .golangci.yml — formato da versão 2 do golangci-lint
version: "2"
run:
tests: true
modules-download-mode: readonly
linters:
default: none # liga só o que está listado abaixo
enable:
# Essenciais
- errcheck # verifica erros ignorados
- govet # análise estática padrão
- ineffassign # atribuições que nunca são usadas
- staticcheck # análise avançada; na v2 inclui gosimple e stylecheck
- unused # código não utilizado
# Estilo e idioma Go
- misspell # erros de ortografia em comentários
- godot # ponto final em comentários
# Qualidade
- bodyclose # verifica fechamento de resp.Body
- contextcheck # uso correto de context
- errorlint # erros compatíveis com errors.Is/As
- exhaustive # switch em enums deve cobrir todos os casos
- gocritic # sugestões variadas de melhoria
- noctx # evita http.NewRequest sem context
- prealloc # pré-alocação de slices
- revive # linter de estilo configurável
- sqlclosecheck # verifica sql.Rows.Close()
- unparam # parâmetros que sempre recebem o mesmo valor
# Segurança
- gosec # verifica vulnerabilidades de segurança
- nilerr # retornar nil em vez de err
settings:
errcheck:
check-type-assertions: true
check-blank: true
govet:
enable-all: true
gocritic:
enabled-tags:
- diagnostic
- performance
- style
misspell:
locale: US
exclusions:
generated: lax # ignora arquivos com o cabeçalho "Code generated ... DO NOT EDIT"
rules:
# Em testes, erros ignorados e alertas de segurança são tolerados
- path: _test\.go
linters:
- errcheck
- gosec
# Na v2, gofmt e goimports são formatadores, e não linters
formatters:
enable:
- gofmt
- goimports
settings:
goimports:
local-prefixes:
- github.com/minha-empresa
issues:
max-issues-per-linter: 0
max-same-issues: 0
Os linters mais importantes
errcheck: erros nunca devem ser silenciados
// ERRADO — errcheck detecta
os.Remove("arquivo.tmp")
json.Unmarshal(dados, &v)
rows.Close()
// CORRETO
if err := os.Remove("arquivo.tmp"); err != nil {
log.Printf("aviso: remover temp: %v", err)
}
if err := json.Unmarshal(dados, &v); err != nil {
return fmt.Errorf("decodificar: %w", err)
}
defer func() {
if err := rows.Close(); err != nil {
log.Printf("fechar rows: %v", err)
}
}()
bodyclose: vazamentos de conexão HTTP
// ERRADO — bodyclose detecta
resp, _ := http.Get("https://exemplo.com")
dados, _ := io.ReadAll(resp.Body)
// CORRETO
resp, err := http.Get("https://exemplo.com")
if err != nil {
return err
}
defer resp.Body.Close()
dados, err := io.ReadAll(resp.Body)
errorlint: erros compatíveis com wrapping
// ERRADO — não funciona com erros embrulhados
if err == sql.ErrNoRows { }
if err == os.ErrNotExist { }
// CORRETO — errorlint reforça uso de errors.Is
if errors.Is(err, sql.ErrNoRows) { }
if errors.Is(err, os.ErrNotExist) { }
// ERRADO — type assertion direta
if e, ok := err.(*MyError); ok { }
// CORRETO
var e *MyError
if errors.As(err, &e) { }
gosec: segurança
// G101: credencial hardcoded
senha := "minha-senha-secreta" // gosec: hardcoded credential
// G304: abertura de arquivo com caminho do usuário
caminho := r.FormValue("arquivo")
os.Open(caminho) // gosec: file inclusion
// G401: algoritmo criptográfico fraco
md5.Sum(dados) // gosec: use sha256 ou superior
// G501: import de pacote inseguro
import "crypto/md5" // gosec
// Correto
import "crypto/sha256"
hash := sha256.Sum256(dados)
prealloc: performance com slices
// ERRADO — prealloc detecta
var resultado []string
for _, item := range itens {
resultado = append(resultado, processar(item))
}
// CORRETO
resultado := make([]string, 0, len(itens))
for _, item := range itens {
resultado = append(resultado, processar(item))
}
Integração com editores
VS Code
// .vscode/settings.json
{
"go.lintTool": "golangci-lint-v2",
"go.lintFlags": ["--fast-only"],
"go.formatTool": "goimports",
"[go]": {
"editor.formatOnSave": true,
"editor.codeActionsOnSave": {
"source.organizeImports": "explicit"
}
}
}
Makefile com targets de qualidade
# Makefile — as linhas de comando começam com TAB, e não com espaços;
# com espaços, o make para com "missing separator"
.PHONY: fmt check-fmt vet lint test test-cover ci
fmt:
goimports -w .
gofmt -s -w .
vet:
go vet ./...
lint:
golangci-lint run ./...
test:
go test -race -count=1 ./...
test-cover:
go test -race -count=1 -coverprofile=cobertura.out ./...
go tool cover -html=cobertura.out -o cobertura.html
# No CI, verificar em vez de formatar: o fmt alteraria os arquivos e passaria
ci: check-fmt vet lint test
@echo "✓ Todas as verificações passaram"
# Falha se houver arquivos não formatados
check-fmt:
@unformatted=$$(gofmt -l .); \
if [ -n "$$unformatted" ]; then \
echo "Arquivos não formatados:"; \
echo "$$unformatted"; \
exit 1; \
fi
GitHub Actions: CI com qualidade automatizada
# .github/workflows/ci.yml
name: CI
on:
push:
branches: [main, develop]
pull_request:
branches: [main]
jobs:
qualidade:
name: Qualidade de Código
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v7
- name: Configurar Go
uses: actions/setup-go@v7
with:
go-version-file: go.mod # a versão vem da diretiva go do projeto
cache: true
- name: Verificar formatação
run: |
unformatted=$(gofmt -l .)
if [ -n "$unformatted" ]; then
echo "Arquivos precisam de formatação:"
echo "$unformatted"
exit 1
fi
- name: go vet
run: go vet ./...
- name: golangci-lint
uses: golangci/golangci-lint-action@v9
with:
version: v2.14 # fixar a versão evita CI quebrado por linter novo
args: --timeout=5m
- name: Testes com cobertura
run: |
go test -race -count=1 \
-coverprofile=cobertura.out \
-covermode=atomic \
./...
- name: Verificar cobertura mínima
run: |
cobertura=$(go tool cover -func=cobertura.out | \
grep total | awk '{print $3}' | sed 's/%//')
echo "Cobertura: ${cobertura}%"
if (( $(echo "$cobertura < 70" | bc -l) )); then
echo "Cobertura abaixo do mínimo de 70%"
exit 1
fi
- name: Upload de cobertura
uses: codecov/codecov-action@v7
with:
files: cobertura.out
Pre-commit hooks com lefthook
go install github.com/evilmartians/lefthook/v2@latest
# lefthook.yml
pre-commit:
parallel: true
commands:
fmt:
glob: "*.go"
run: goimports -w {staged_files}
stage_fixed: true # devolve ao commit os arquivos que o goimports alterou
vet:
glob: "*.go"
run: go vet ./...
lint:
glob: "*.go"
# Pacotes inteiros, e não {staged_files}: arquivos de pacotes diferentes
# dão erro de typecheck, e o golangci-lint termina com "0 issues"
run: golangci-lint run --new-from-rev=HEAD ./...
pre-push:
commands:
tests:
run: go test -race ./...
lefthook install # instala os hooks no repositório git
Baseline: adotando lint em projeto existente
Ao adotar golangci-lint em um projeto com muitos problemas existentes, a abordagem incremental é mais prática. O golangci-lint não tem arquivo de baseline: uma flag --baseline, que aparece em alguns tutoriais, termina com unknown flag. O caminho é comparar com o Git, com --new-from-rev, que reporta apenas problemas em código alterado depois de um commit, ou --new-from-merge-base, que compara com o ponto onde o branch saiu do principal:
# Reporta apenas problemas em código alterado desde o branch main
golangci-lint run --new-from-rev=origin/main ./...
Formatação, go vet, staticcheck e golangci-lint pegam, antes da revisão humana, erros que o compilador deixa passar: formato de Printf errado, mutex copiado, regex inválida, erro ignorado. O custo de adotar é baixo, mas a manutenção pede atenção, porque essas ferramentas mudam: a versão 2 do golangci-lint trocou o formato da configuração e removeu flags, e uma configuração antiga é simplesmente recusada. O que protege contra isso é fixar as versões no CI e conferir a configuração com golangci-lint config verify. E um verificador só vale se puder falhar: o gofmt -l sai com zero, um hook que passa arquivos de vários pacotes termina em "0 issues", e nos dois casos o pipeline fica verde sem ter verificado nada.
Fontes e leituras recomendadas
-
golangci-lint — documentação oficial — Lista completa de linters e configuração. https://golangci-lint.run
-
staticcheck — Documentação de todas as verificações disponíveis. https://staticcheck.dev
-
gosec — Verificações de segurança para código Go. https://github.com/securego/gosec
-
Documentação gofmt — Referência e opções do formatador oficial. https://pkg.go.dev/cmd/gofmt
-
lefthook — Gerenciador de git hooks. https://github.com/evilmartians/lefthook
-
Go Code Review Comments — Guia oficial de boas práticas de código Go. https://go.dev/wiki/CodeReviewComments
Exercícios
Exercício 1
O CI de um time tem o passo run: gofmt -l ., entendido como "falha se algo não estiver formatado". Meses depois, alguém percebe vários arquivos fora do padrão na branch principal, todos aprovados pelo CI. Por que o passo nunca falhou?
Ver resposta
✓ Resposta: O gofmt -l lista os arquivos cuja formatação difere da oficial, mas o código de saída dele reflete só se o programa conseguiu rodar, e não se a lista ficou vazia. Num teste com um arquivo mal formatado, o comando imprimiu o nome do arquivo e terminou com código 0. O passo do CI, portanto, sempre passava, mostrando a lista num log que ninguém lia. A correção é testar a saída, como faz o alvo check-fmt do artigo: guardar o resultado numa variável e sair com 1 quando ela não estiver vazia. No mesmo teste, o alvo falhou e listou o arquivo. O mesmo cuidado vale para qualquer ferramenta posta num pipeline: antes de confiar num passo de verificação, vale fazê-lo falhar de propósito uma vez, com um caso que ele deveria pegar.
Exercício 2
Um projeto atualiza o golangci-lint para a versão 2, e o CI passa a falhar antes de analisar qualquer arquivo, com can't load config: unsupported version of the configuration: "". O .golangci.yml não mudou. O que aconteceu, e qual é o caminho de migração?
Ver resposta
✓ Resposta: A versão 2 do golangci-lint exige que o arquivo declare version: "2" e reorganizou o formato: disable-all virou default: none, linters-settings passou para linters.settings, issues.exclude-rules para linters.exclusions.rules, e gofmt e goimports saíram da lista de linters para uma seção formatters. Sem a declaração de versão, o arquivo é recusado por inteiro, como na mensagem. O comando golangci-lint migrate converte o arquivo antigo; num teste com a configuração da primeira versão deste artigo, ele recusou primeiro a seção output.format, que já era inválida antes da v2, e converteu o resto depois que ela foi removida, incluindo a troca de gosimple, incorporado ao staticcheck. O resultado deve passar no golangci-lint config verify. Para que a próxima atualização não chegue de surpresa, o CI deve fixar a versão do golangci-lint em vez de usar latest, e a atualização vira uma mudança revisada como qualquer outra.
Exercício 3
Um hook de pre-commit roda golangci-lint run {staged_files}. Num commit que altera main.go e internal/banco/repo.go, o hook passa com 0 issues, mas o CI encontra um erro novo em repo.go. Por que o hook não viu?
Ver resposta
✓ Resposta: Quando recebe arquivos em vez de pacotes, o golangci-lint precisa montar um pacote com eles para fazer a verificação de tipos, e arquivos de diretórios diferentes não formam um pacote. Num teste com dois arquivos de pacotes distintos, a execução registrou typechecking error: named files must all be in one directory e terminou com 0 issues, sem falhar. O hook, portanto, não analisou nada. Mesmo com arquivos de um diretório só, a análise de arquivos soltos é incompleta, porque os linters precisam do pacote inteiro para resolver tipos e chamadas. A forma do artigo analisa os pacotes, com ./..., e limita o relatório ao que mudou com --new-from-rev=HEAD, que considera as alterações ainda não commitadas. Se o hook ficar lento, o caminho é deixar o lint completo para o CI, e não passar arquivos soltos.
Exercício 4
Alguém copia o Makefile de um tutorial, roda make lint e recebe Makefile:5: *** missing separator. Stop.. O conteúdo parece idêntico ao do tutorial. O que está errado?
Ver resposta
✓ Resposta: O make exige que cada linha de comando de uma regra comece com um caractere de tabulação. Páginas web, editores e ferramentas de cópia frequentemente trocam a tabulação por espaços, e a linha passa a parecer, para o make, uma definição incompleta: num teste com o Makefile indentado com quatro espaços, a mensagem foi exatamente Makefile:5: *** missing separator. Stop., apontando a primeira receita. A diferença é invisível na tela; cat -A Makefile mostra as tabulações como ^I, e os espaços como espaços. A correção é trocar a indentação das receitas por tabulações; editores costumam ter uma configuração própria para Makefiles, e o .editorconfig pode fixá-la com indent_style = tab para Makefile. O GNU make também aceita outro prefixo com .RECIPEPREFIX, mas isso só troca o problema de lugar.
Exercício 5
Uma struct tem Campo string `json:"campo,omitemtempty"`, com o nome da opção digitado errado. O go vet passa sem aviso, e o JSON sai com "campo":"" quando o campo está vazio. Quem pega esse erro, e por que o vet não pegou?
Ver resposta
✓ Resposta: O analisador structtag do go vet confere a sintaxe das tags, como aspas e o formato chave:"valor", e chaves JSON repetidas, mas não conhece a lista de opções válidas de cada pacote. O encoding/json ignora opções que não reconhece, e o resultado é o campo sem omitempty, sem erro nenhum. Num teste, o go vet não disse nada, e o staticcheck acusou invalid appearance of unknown `omitemtempty` tag option (SA5008). É um bom exemplo de por que o artigo usa as duas ferramentas: o vet é conservador, só reporta o que tem quase certeza de ser erro e vem com o Go; o staticcheck, incluído no golangci-lint, cobre muito mais casos. No mesmo teste, ele também apontou getHttpClient should be getHTTPClient e o pacote io/ioutil depreciado, que o vet deixou passar.