Eu estava navegando pelo Twitter outro dia — lendo por cima, sem procurar nada especificamente — e este tweet do Uncle Bob chamou minha atenção:

Minha primeira reação foi: mentira, você ainda precisa ler o código.
Depois, eu refleti por um minuto e percebi que estava mentindo para mim mesmo.
Eu não leio cada linha de código gerado por IA. Ninguém lê. Nós passamos o olho no diff, garantimos que parece “mais ou menos certo”, rodamos os testes e fazemos o deploy. A questão real não é se estamos lendo o código linha por linha — não estamos — mas se existe qualquer outra coisa segurando a linha quando não lemos.
A lista do Uncle Bob é esse something else. Então peguei um pequeno app Rails que eu estava construindo com o Claude e conectei cada uma dessas métricas em um único gate. A proposta: se a IA tocar na base de código, o gate decide se o código vai para produção. Este post detalha o que cada métrica realmente mede, a ferramenta Ruby que a computa e os pedaços de código que eu colei para fazer bin/rake quality funcionar.
Ao final, você terá um blueprint para qualquer app Rails e, como você entregará a implementação real ao seu agente de codificação, isso deve levar bem menos que uma tarde.
The setup
O app é um pequeno serviço Rails 8 chamado trend-radar — 5 controllers, 4 models, 1 service, RSpec + rubocop-rails-omakase para a base. O objetivo: um comando (bin/rake quality) que execute cada métrica, compare cada uma com um threshold em config/quality_thresholds.yml e saia com erro (non-zero) se qualquer gate falhar.
Aqui está como fica a saída final:
Quality gates
=============
Line coverage 96.6% >= 95.0% ✓
Branch coverage 91.1% >= 90.0% ✓
Flog max (method) 19.8 <= 20 ✓
Flog max (class) 66.8 <= 70 ✓
Mutation kill ratio 69.6% >= 69.5% ✓
5/5 gates passed.Essa é a cerca. Toda alteração produzida por IA — seja uma nova ação de controller, uma refatoração ou a correção de um bug — precisa passar por essa barra antes que eu declare a tarefa concluída.
Vou guiá-los por cada linha.
Metric 1 — Test Coverage (the bare minimum)
O conceito: a cobertura de testes mede qual porcentagem do seu código fonte realmente executa}em durante a suíte de testes. Existem dois tipos:
- Line coverage — esta linha foi executada?
- Branch coverage — para cada
if/unless/&&/ternário, ambos os caminhos foram executados?
A cobertura de branch é a mais rigorosa. Um método pode ter 100% de cobertura de linha enquanto exercita apenas o caminho feliz de cada condicional; a cobertura de branch detecta isso.
Por que importa para código de IA: a cobertura é necessária, mas não suficiente. Um número baixo definitivamente significa “esta IA escreveu código que nenhum teste toca” — inseguro para deploy. Um número alto significa “pelo menos os testes rodam o código” — mas não diz nada sobre se os testes verificam algo (chegaremos a isso com testes de mutação).
A ferramenta: SimpleCov. A gem de cobertura de fato do Ruby, conectada ao RSpec via um require no topo de spec/spec_helper.rb.
A implementação:
# spec/spec_helper.rb — before anything else
require "simplecov"
SimpleCov.start "rails" do
enable_coverage :branch
add_filter "/spec/"
add_filter "/config/"
add_filter "/db/"
add_filter "/lib/tasks/"
endApós cada execução de spec, o SimpleCov escreve coverage/.last_run.json:
{ "result": { "line": 96.65, "branch": 91.07 } }Um pequeno parser lê isso e entrega ao agregador:
# lib/quality/coverage_parser.rb
require "json"
module Quality
class CoverageParser
def initialize(path)
@path = path
end
def parse
result = JSON.parse(File.read(@path)).fetch("result")
{ line: result["line"], branch: result["branch"] }
end
end
endThreshold: 95% de linha, 90% de branch. Aspiracional — escolhido para forçar a IA (e a mim) a realmente testar o código novo, não apenas escrevê-lo.
Metric 2 — Cyclomatic Complexity & Method Size (the smell detector)
O conceito: quanta lógica está compactada em um único método? Existem duas medidas que se sobrepõem:
- Complexidade ciclomática — conta os caminhos independentes através de um método. Cada
if,unless,case/when,&&em uma condição,rescueadiciona um. Números altos significam que o método tem muitos branches para raciocinar. - Flog (ABC score) — uma contagem ponderada de Assignments (atribuições), Branches (chamadas de método, incluindo operadores como
==e+), e Conditions. O score ésqrt(A² + B² + C²)— sua distância pitagoriana de “este método não faz nada”.
As duas métricas se sobrepõem, mas não são idênticas. A ciclomática pergunta quantos caminhos, o Flog pergunta quanta coisa. Um método com um fluxo linear de 30 chamadas de método tem baixa complexidade ciclomática, mas um score Flog alto.
Por que importa para código de IA: a IA adora produzir métodos monolíticos com aparência plausível. Uma ação de controller que faz três queries no banco, monta um hash com dez chaves, renderiza condicionalmente de uma de quatro formas — é exatamente o que um LLM gera quando você pede uma feature. Métricas de complexidade colocam um teto sobre o quão fundo esse buraco pode ir antes que o gate falhe.
As ferramentas:
- Metrics cops do Rubocop (
Metrics/CyclomaticComplexity,Metrics/MethodLength,Metrics/AbcSize,Metrics/ClassLength) — parte do Rubocop padrão.rubocop-rails-omakaseos desativa por padrão; nós os reativamos. - Flog (a gem
flog) — para complexidade baseada em ABC.
A implementação — ativando as cops do Rubocop:
# .rubocop.yml
inherit_gem: { rubocop-rails-omakase: rubocop.yml }
Metrics/ClassLength:
Enabled: true
Max: 100
Exclude:
- 'spec/**/*'
- 'db/**/*'
Metrics/MethodLength:
Enabled: true
Max: 15
Metrics/AbcSize:
Enabled: true
Max: 15
Metrics/CyclomaticComplexity:
Enabled: true
Max: 6
Metrics/PerceivedComplexity:
Enabled: true
Max: 7A implementação — Flog via sua API Ruby:
# lib/quality/flog_parser.rb
require "flog"
module Quality
class FlogParser
def initialize(paths)
@paths = Array(paths)
end
def parse
flog = Flog.new
flog.flog(*@paths) # pass ALL paths; Flog#flog resets state per call
totals = flog.totals
return { method_max: 0.0, class_max: 0.0 } if totals.empty?
{
method_max: totals.values.max,
class_max: max_class_score(totals)
}
end
private
def max_class_score(totals)
totals
.group_by { |method_name, _| method_name.split(/[#.]/, 2).first }
.values
.map { |entries| entries.sum { |_, score| score } }
.max
end
end
endUm detalhe que vale destacar: inicialmente escrevi o parser para fazer loop @paths.each { |p| flog.flog(p) } — o que destrói dados silenciosamente, porque Flog#flog reseta o estado interno a cada chamada. Sempre passe caminhos como um splat.
Thresholds: Flog de método ≤ 20, Flog de classe ≤ 70. Comecei com os números agressivos do Uncle Bob (15 e 40) e descobri que eles punem controllers CRUD normais do Rails — um admin/topics_controller de 5 ações naturalmente soma ~60 de Flog em todos os seus métodos. Calibrar para a realidade foi uma escolha deliberada e vale ser honesto sobre isso: as métricas são diretrizes, não dogmas. Se o threshold força você a refatorar idiomatismos do Rails que funcionam apenas para passar, o threshold está errado.
Uma nota rápida que vale adicionar aqui: na era pré-IA, as cops de complexidade eram genuinamente irritantes. Toda vez que Metrics/MethodLength gritava com você por um método de 16 linhas, você tinha que parar o que estava fazendo, encontrar a divisão mais limpa, rodar os testes, consertar o que quebrou na divisão, rodar o rubocop de novo, repetir o processo. As métricas eram úteis; a manutenção era um imposto. A maioria dos times desativava essas cops por um motivo.
Essa equação inverte quando um agente de codificação está fazendo a refatoração. Você reativa Metrics/MethodLength, o gate falha, você cola a mensagem para o Claude, ele extrai um método privado, atualiza os testes, roda o gate novamente — trinta segundos, pronto. Métricas que sempre foram boas ideias, mas para as quais ninguém tinha tempo, finalmente são baratas de impor. O argumento do Uncle Bob soa diferente sob essa ótica: ele não está substituindo o esforço humano por números, ele está apontando que os números que sempre soubemos serem valiosos tornaram-se viáveis porque a máquina que escreve o código é também a máquina que o limpa.
Metric 3 — Module Sizes (the single-responsibility nudge)
O conceito: quantas linhas em uma classe ou módulo? Um model User que cresceu para 400 linhas está tentando lhe dizer algo: ele acumulou responsabilidades que não pertencem juntas.
Isso é um proxy bruto, porém barato, para o Princípio da Responsabilidade Única — o S do SOLID, e uma das regras do próprio Robert Martin. O SRP diz que uma classe deve ter “apenas um motivo para mudar”. Uma classe que se espalhou por mais de 300 linhas quase sempre tem vários motivos para mudar, porque está fazendo vários trabalhos não relacionados sob o mesmo teto. A contagem de linhas não pode provar}em uma violação de SRP, mas é o indicador mais barato que temos: classe longa → provavelmente múltiplas responsabilidades → hora de dividir.
Há uma ironia interessante aqui também: a mesma pessoa tuitando sobre medir código gerado por IA deu nome ao SRP trinta anos atrás. Sua lista de “medir em vez de ler” é, implicitamente, uma lista de verificações mecânicas para os princípios que ele passou a carreira tentando ensinar aos humanos manualmente.
Por que importa para código de IA: a IA fica feliz em continuar adicionando métodos a qualquer arquivo que você peça para modificar. Não existe uma voz interna dizendo “isso deveria ser um novo service object”. O gate de tamanho de módulo torna-se essa voz — e como extrair um serviço é barato para o agente de codificação fazer (veja a nota da Métrica 2), você pode finalmente manter a linha do SRP sem pagar o imposto usual de refatoração.
A ferramenta: as cops Metrics/ClassLength e Metrics/ModuleLength do Rubocop (já ativadas no snippet acima).
Threshold: 100 linhas por classe, excluindo specs e migrations.
Não há parser sofisticado aqui — a saída JSON do Rubocop já inclui as ofensas para essas cops. O mesmo RubocopParser que lê violações de complexidade lê violações de comprimento também, extraindo o valor medido da mensagem de ofensa via regex:
# lib/quality/rubocop_parser.rb
require "json"
module Quality
class RubocopParser
COPS = {
class_length_max: "Metrics/ClassLength",
method_length_max: "Metrics/MethodLength",
abc_size_max: "Metrics/AbcSize",
cyclomatic_complexity_max: "Metrics/CyclomaticComplexity",
perceived_complexity_max: "Metrics/PerceivedComplexity"
}.freeze
# Captures the first "<measured>/<max>" pair in the offense message.
# Handles "[143/100]" and "[<7, 18, 3> 19.55/15]" shapes.
MEASURE_RE = %r{([d.]+)/[d.]+}
def initialize(path)
@path = path
end
def parse
offenses = JSON.parse(File.read(@path)).fetch("files").flat_map { |f| f["offenses"] }
COPS.transform_values { |cop_name| max_for(cop_name, offenses) }
end
private
def max_for(cop_name, offenses)
values = offenses
.select { |o| o["cop_name"] == cop_name }
.map { |o| o["message"][MEASURE_RE, 1]&.to_f }
.compact
return nil if values.empty?
picked = values.max
picked == picked.to_i ? picked.to_i : picked
end
end
endMetric 4 — Mutation Testing (the truth detector)
Esta é a métrica que a maioria dos projetos Rails ignora, e é a que mais importa para código de IA.
O conceito: a cobertura de testes diz que uma linha rodou}em durante os testes. Ela não diz nada sobre se os testes notariam se aquela linha estivesse errada. O teste de mutação prova a diferença.
O algoritmo:
- Sua suíte de testes está verde.
- Uma ferramenta pega seu código fonte e aplica uma pequena mudança automatizada — troca
>por>=, substituitrueporfalse, deleta uma chamada.save!, nega uma condição. Isso é chamado de mutação. - Rode novamente os testes contra o código mutado.
- Se ao menos um teste agora falhar, a mutação foi morta (seus testes a pegaram).
- Se todos os testes ainda passarem, a mutação sobrevive}em — seus testes não assertam realmente esse comportamento.
- Taxa de morte = mutações mortas / total de mutações.
Uma cobertura de linha de 100% com uma taxa de morte de mutação de 30% significa que seus testes executam o código, mas mal verificam qualquer coisa. A mutação é a única métrica que mede se os testes assertam comportamento}em vs. apenas exercitam linhas.
Por que importa para código de IA — crucialmente: a IA pode escrever testes que parecem abrangentes, mas não verificam as coisas importantes. Um LLM pode escrever:
it "creates a subscription" do
post :create, params: { topic_id: topic.id }
expect(response).to redirect_to(topics_path)
endEsse teste tem 100% de cobertura de linha de create. O teste de mutação notará imediatamente: se você deletar subscription.save, o teste ainda passa (o redirecionamento acontece de qualquer maneira). O teste exercitou a ação; ele não assertou o que a ação deveria fazer.
A ferramenta: Mutant (mbj/mutant) com sua integração RSpec. Um aviso sobre licenciamento: o Mutant usa um modelo de licença dupla — gratuito para projetos open-source, licença comercial paga ($30/mês ou $250/ano por desenvolvedor) para bases de código privadas. Para este projeto de pesquisa, estou rodando sob os termos de open-source. O principal fork da comunidade, mutest, existe, mas não teve um release desde 2019, então não é uma alternativa prática no Ruby moderno.
Vale notar: os mantenedores do Mutant veem o problema que estamos discutindo da mesma forma que nós. O slogan literal na página inicial do GitHub deles diz:
“A IA escreve seu código. A IA escreve seus testes. Mas quem testa os testes?”
— descrição do projeto Mutant no GitHub
A implementação — o Mutant precisa do eager-load do Rails antes de poder descobrir os sujeitos:
# script/mutant_bootstrap.rb
require_relative "../config/environment"
Rails.application.eager_load!A tarefa rake que roda o Mutant, captura sua saída, analisa a taxa de morte e aplica o ratchet:
desc "Run Mutant against app/ and lib/quality; ratchet threshold on first run"
task mutation: :environment do
FileUtils.mkdir_p(QUALITY_DIR)
txt_path = QUALITY_DIR.join("mutation.txt")
cmd = [
"bundle", "exec", "mutant", "run",
"--integration", "rspec",
"--require", "./script/mutant_bootstrap.rb",
"--usage", "opensource",
"--", *MUTANT_SUBJECTS
]
sh "#{cmd.shelljoin} > #{txt_path.to_s.shellescape} 2>&1 || true"
parsed = Quality::MutantParser.new(txt_path).parse
File.write(QUALITY_DIR.join("mutation.json"), JSON.pretty_generate(parsed))
ratchet_if_unset!(parsed[:kill_ratio])
endE um parser que extrai a taxa de morte do relatório de texto do Mutant:
# lib/quality/mutant_parser.rb
module Quality
class MutantParser
class ParseError < StandardError; end
PATTERNS = {
mutations: /^Mutations:s+(d+)$/,
kills: /^Kills:s+(d+)$/,
coverage: /^Coverage:s+([d.]+)%$/
}.freeze
def initialize(path)
@path = path
end
def parse
text = File.read(@path)
extracted = PATTERNS.transform_values { |re| text.match(re)&.[](1) }
raise ParseError, "Could not find Coverage line in #{@path}" if extracted[:coverage].nil?
{
mutations: extracted[:mutations].to_i,
kills: extracted[:kills].to_i,
kill_ratio: extracted[:coverage].to_f
}
end
end
endA filosofia do threshold — esta é especial. Levar uma base de código de 50% de taxa de morte de mutação para 90% é um investimento genuíno. Forçar isso de imediato mataria o projeto. Então, este gate usa um ratchet (catraca): na primeiríssima execução, qualquer taxa que sair torna-se o piso. Toda execução futura tem que igualar ou bater esse número. A regra do gate é simples: não piore.
Aqui está a lógica do ratchet:
def ratchet_if_unset!(kill_ratio)
path = Rails.root.join("config/quality_thresholds.yml")
thresholds = YAML.load_file(path)
return unless thresholds.dig("mutation", "kill_ratio_min").nil?
thresholds["mutation"]["kill_ratio_min"] = kill_ratio
File.write(path, thresholds.to_yaml)
puts "[quality:mutation] Ratchet set: mutation.kill_ratio_min = #{kill_ratio}"
endNossa execução inicial produziu 69,46% em 1.775 mutações. Esse é o baseline.
Metric 5 — Dependency Structure (the one we didn’t implement)
O conceito: a quinta métrica do Uncle Bob pergunta: sua arquitetura tem ciclos? Módulos de alto nível dependem de módulos de baixo nível? As fronteiras de pacotes estão sendo violadas? Em uma arquitetura principiada (hexagonal, clean, etc.), a resposta deve ser não.
A ferramenta Ruby: Packwerk (da Shopify) — define sua base de código como pacotes com APIs públicas, falha o build quando um pacote acessa os internos de outro.
Por que pulamos: este é um monólito Rails de 5 controllers. Esculpindo-o em três pacotes com fronteiras públicas seria “teatro de arquitetura” — mais manutenção de pack.yaml do que trabalho real de feature. E o Rails idiomático já lida com a maior parte do que “estrutura de dependência” significa na prática: o esqueleto MVC impõe uma camada bruta, o autoloading do ActiveSupport não deixa você criar ciclos surpreendentes, e um app pequeno não tem onde esconder violações.
A resposta honesta do blog-post: esta métrica não se aplica bem a um monólito Rails pequeno. Não vou fingir que medi quando não medi. Se o app crescesse para 40 controllers, eu revisitaria.
Vale a pena dizer isso em voz alta porque a tentação do desenvolvimento orientado por métricas é inventar um número para cada categoria apenas para marcar a caixa. Não faça isso. Uma métrica pulada, documentada honestamente, é mais valiosa do que uma métrica que você teve que torturar sua arquitetura para satisfazer.
Putting it all together
Tudo acima colapsa em uma única tarefa rake. As 4 sub-tarefas que produzem dados escrevem cada uma um blob JSON em tmp/quality/. O agregador as lê, as compara com config/quality_thresholds.yml, imprime a tabela e sai com 0 ou 1.
# config/quality_thresholds.yml
coverage:
line_min: 95.0
branch_min: 90.0
flog:
method_max: 20
class_max: 70
rubocop_metrics:
class_length_max: 100
method_length_max: 15
abc_size_max: 15
cyclomatic_complexity_max: 6
perceived_complexity_max: 7
mutation:
kill_ratio_min: 69.46 # ratcheted from first runO agregador tem ~50 linhas e é testável isoladamente com dados de fixture sintetizados:
# lib/quality/report.rb (excerpted)
module Quality
class Report
GATES = [
{ name: "Line coverage", measure: [:coverage, :line], threshold: ["coverage", "line_min"], cmp: :>=, unit: "%" },
{ name: "Branch coverage", measure: [:coverage, :branch], threshold: ["coverage", "branch_min"], cmp: :>=, unit: "%" },
{ name: "Flog max (method)", measure: [:flog, :method_max], threshold: ["flog", "method_max"], cmp: :<=, unit: "" },
{ name: "Flog max (class)", measure: [:flog, :class_max], threshold: ["flog", "class_max"], cmp: :<=, unit: "" },
{ name: "Mutation kill ratio", measure: [:mutation, :kill_ratio], threshold: ["mutation", "kill_ratio_min"], cmp: :>=, unit: "%" }
].freeze
def initialize(measurements:, thresholds:)
@measurements = measurements
@thresholds = thresholds
@gate_results = build_gate_results
end
def passed?
@gate_results.all?(&:passed?)
end
end
endE o final — uma linha adicionada ao CLAUDE.md do projeto para que a própria IA rode o gate antes de declarar o trabalho concluído:
Rode o quality gate antes de commitar. Rode
bin/rake qualityantes de declarar uma tarefa completa. Não commite se qualquer gate falhar. Relate os números do gate na sua resposta para que regressões fiquem visíveis.
What this buys you
Uma vez que isso está implementado, algumas coisas mudam na forma como trabalho com IA.
Posso confiar na IA mais facilmente e gastar minha atenção onde ela realmente importa. O gate cuida das verificações mecânicas — “Você testou isso?”, “Este método acabou de dobrar de complexidade?”, “Os testes assertam algo real?” — então eu não preciso fazer isso. Minha revisão muda de “nitpicking” linha por linha para as coisas que apenas um humano deve decidir: esta é a abstração correta? isso condiz com a intenção do produto? estamos resolvendo o problema real? As partes chatas são automatizadas; as partes interessantes recebem meu foco total.
Regressões tornam-se visíveis instantaneamente. “A cobertura de linha caiu 2 pontos” é algo concreto para questionar. “Eu não gosto deste código” não é.
A saída da IA melhora com o tempo. Uma vez que o Claude sabe que bin/rake quality é o último passo, ele começa a se antecipar ao gate — escrevendo os testes junto com o código, mantendo métodos curtos, evitando controllers monolíticos. Não porque ficou mais inteligente, mas porque o loop de feedback ficou mais curto.
Tenho dados reais para argumentar. O tweet do Uncle Bob é provocativo. Os números são persuasivos. Quando um colega questiona o código da IA, posso mostrar a tabela do gate. Quando a IA produz algo objetivamente pior que o baseline, posso mostrar a ela a mesma tabela.
The limits
Algumas coisas que isso não}em pega, e vale a pena ser lúcido sobre elas:
- Segurança. Gates de complexidade não pegam SQL injection ou outras vulnerabilidades clássicas.
- Performance. O gate não tem opinião sobre queries N+1, um
includesrebelde que carrega metade do banco de dados na memória, um loop sem limites ou uma requisição de 4 segundos. Um método pode ser curto, bem testado, imune a mutações e ainda assim derrubar o app sob carga. - Race conditions & bugs de concorrência. O teste de mutação pode dizer se seus testes assertam}em o caminho feliz; ele não pode dizer se duas requisições concurrentes alterando a mesma linha corromperão seus dados. Testes de concorrência são difíceis de escrever, e nem as métricas de complexidade nem a de mutação evidenciarão a ausência deles.
- Memory leaks. Um cache de nível de classe que cresce sem limites, uma lista global de inscritos que nunca desinscreve, estado memoizado em um objeto de longa vida — tudo isso passa em todos os gates no primeiro dia, e derruba o processo após uma semana em produção. Testes rodam em processos novos e não veem a acumulação.
- Ajuste idiomático. Um método pode passar em todos os gates e ainda assim “não ser como fazemos aqui”. É para isso que serve a revisão de código — mas limitada a arquitetura e gosto, não à correção linha por linha.
- Intenção. O gate não consegue dizer se você construiu a feature certa. Ele só consegue dizer que o código que você construiu é adequado para o propósito.
Cada um desses tópicos merece seu próprio tratamento — quais ferramentas ajudam, como acoplá-las ao mesmo tipo de gate, quando valem o overhead — e este post já está longo o suficiente. Vou cobri-los em uma continuação.
O argumento do Uncle Bob não é “pare de pensar no código”. É: “pare de gastar atenção humana em coisas que métricas podem verificar, para que você possa gastá-la nas coisas que realmente precisam de um cérebro humano”.
Isso, eu acho, é o caminho.
Resources
- Tweet do Uncle Bob: a citação no topo deste post
- SimpleCov: https://github.com/simplecov-ruby/simplecov
- Flog: https://github.com/seattlerb/flog
- Rubocop Metrics cops: https://docs.rubocop.org/rubocop/cops_metrics.html
- Mutant: https://github.com/mbj/mutant
- Packwerk (para quando seu monólito crescer): https://github.com/Shopify/packwerk
A implementação completa neste post é código real de um app Rails 8 em andamento que estou construindo com o Claude. Se quiser ver o resto — a fiação da rake task, os parsers, os testes — está tudo no repo.
We want to work with you. Check out our Services page!

