Linting, formatação e análise estática: gofmt, golangci-lint

Linting, formatação e análise estática: gofmt, golangci-lint

Qualidade de código em Go: gofmt, goimports, go vet e staticcheck com exemplos medidos, golangci-lint na versão 2 com configuração verificada, Makefile com tabulações, CI no GitHub Actions com versões fixas e hooks com lefthook que analisam pacotes, e não arquivos soltos.
Golang

• • 16 min de leitura

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

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.

Comentários

Mais em Golang

Padrões de concorrência: fan-out, fan-in e pipelines
Padrões de concorrência: fan-out, fan-in e pipelines

Padrões de concorrência em Go: pipelines com cancelamento, fan-out e fan-in…

Funções: declaração, múltiplos retornos e variádicas
Funções: declaração, múltiplos retornos e variádicas

Funções em Go: parâmetros agrupados, múltiplos retornos e o padrão (resultado…

Compilação cruzada, binários estáticos e Docker com Go
Compilação cruzada, binários estáticos e Docker com Go

Compilação cruzada e Docker com Go: -ldflags e o que o debug.ReadBuildInfo já…