Até onde a IA consegue autovalidar código Rails?

Algumas semanas atrás, escrevi Stop Reading AI Code, Start Measuring It: A Rails Playbook. A ideia era que, se você parar de revisar manualmente cada linha de código gerado por IA, precisará de um portão automatizado que a IA possa executar após cada alteração e que capture as coisas que os humanos costumavam capturar. Na primeira iteração, acabamos definindo quatro portões: cobertura de código de linha e branch do SimpleCov, Flog e métricas do Rubocop para complexidade, e Mutant para a taxa de eliminação de mutações.

No encerramento daquele post, eu já havia sido explícito sobre o que esses quatro portões não capturam. Apontei a segurança (injeção de SQL e outras vulnerabilidades clássicas) e a performance (consultas N+1, um includes que carrega metade do banco de dados na memória, um loop sem limite, uma requisição de 4 segundos) como lacunas conhecidas. "Um método pode ser curto, bem testado, à prova de mutação e ainda assim derrubar o app." Na última semana, continuei voltando a essa admissão: podemos avançar nessas lacunas exatas? Gastei uma sessão no trend-radar tentando fechá-las com mais quatro portões focados em segurança e performance de runtime.

Acabei mantendo dois desses quatro portões. Durante isso, decidi dividir o portão em uma versão local rápida e uma versão de CI mais lenta, porque o tempo que levava localmente estava começando a prejudicar a entrega da IA, e a parte maluca é que essa divisão revelou um bug que estava inflando silenciosamente minha taxa de eliminação de mutações em 7 pontos percentuais.

Brakeman

Se você não está familiarizado com o Brakeman, ele é uma ferramenta de análise estática construída especificamente para Rails. Ele percorre seu código-fonte sem executá-lo efetivamente, procurando pelos padrões clássicos de vulnerabilidade: SQL injection, cross-site scripting, mass assignment, command injection, deserialização insegura, redirecionamentos abertos, segredos hardcoded, acesso a arquivos inseguro, todo o top 10 da OWASP mais um monte de vulnerabilidades comuns específicas do Rails. Ele roda em alguns segundos e gera um relatório JSON, com cada descoberta marcada por um nível de confiança (high, medium, weak) para que você possa decidir onde traçar a linha.

Quase certamente ele já está no seu Gemfile do Rails, já que a maioria dos apps Rails o recebe através da configuração padrão omakase, mas eu nunca o executei de fato nesta base de código, muito menos o adicionei à nossa tarefa de qualidade. Então, todo o trabalho se resumiu a adicionar uma tarefa rake quality:brakeman com um limite:

desc "Run Brakeman; emit JSON to tmp/quality/brakeman.json; set warnings_max threshold on first run"
task brakeman: :environment do
  FileUtils.mkdir_p(QUALITY_DIR)
  out_path = QUALITY_DIR.join("brakeman.json")
  sh "bundle exec brakeman --format json --confidence-level 2 --no-pager " 
     "-o #{out_path.to_s.shellescape} || true"

  parsed = Quality::BrakemanParser.new(out_path).parse
  set_brakeman_threshold_if_unset!(parsed[:warnings])
end

Duas escolhas de design que valem a pena destacar:

  1. --confidence-level 2 filtra os avisos de confiança baixa e mantém os de confiança média e alta. O nível weak do Brakeman é majoritariamente ruído informativo (padrões que poderiam ser inseguros em alguns contextos, mas geralmente não são), então restringir com base nisso produz um fluxo constante de commits do tipo "sim, eu vi, aceitando" que não protegem nada de verdade. Medium e high são onde reside o valor mais concreto, com avisos que sinalizam um padrão real do qual o Brakeman tem bastante certeza, e restringir rigidamente esses níveis fornece à IA um binário claro para agir sem inundá-la de falsos positivos.
  2. Limite rígido, sem tolerância. Mesma ideia acima. Uma faixa de tolerância de "está tudo bem com +1 aviso" significa que você pode fundir silenciosamente um SQL injection real que entrou junto com uma correção não relacionada. É melhor falhar e forçar um warnings_max: 1 de uma linha.

bump como o sinal explícito de "sim, eu vejo, aceito": visível no diff do PR, decisão deliberada. E a mesma ressalva se aplica: uma regra de tolerância zero como esta seria horrível sem IA no circuito, mas graças a Deus agora temos nosso estagiário Claude. A primeira execução captura o valor observado (0 no meu caso) e o escreve em config/quality_thresholds.yml. Próximas execuções que introduzam um novo aviso falham. Para verificar se isso realmente captura algo, adicionei temporariamente User.where("name = '#{params[:name]}'") a um controller, executei o gate e vi que ele ficou vermelho.

Bullet (N+1)

Bullet foi a segunda vitória fácil. É uma gem muito conhecida na comunidade Rails para capturar queries N+1, e por um bom motivo: ela se conecta ao ActiveRecord em tempo de execução e observa o que seu código faz com associações durante uma requisição. Ela sinaliza três formas específicas de bug de performance do Rails: queries N+1 (carregar 100 registros e então chamar .posts em cada um sem preloading), eager loading não utilizado (você escreveu .includes(:posts), mas a requisição nunca toca nos posts) e falta de counter caches (.size em um has_many disparando uma query COUNT quando uma coluna de contador desnormalizada resolveria). A detecção é heurística, não é perfeita, mas captura os problemas de performance de banco de dados mais comuns do Rails, e faz isso apenas executando sua suite de specs do Rails.

Para fazer com que as detecções do Bullet realmente façam o gate falhar, eu o adiciono na suite de specs via spec/support/bullet.rb:

require "bullet"

Bullet.enable      = true
Bullet.raise       = true
Bullet.bullet_logger = false
Bullet.console     = false

RSpec.configure do |config|
  config.before(:each) { Bullet.start_request }
  config.after(:each) do
    Bullet.perform_out_of_channel_notifications if Bullet.notification?
    Bullet.end_request
  end
end

Bullet.raise = true é o que transforma uma detecção em uma falha de spec. E uma falha de spec faz falhar bin/rspec, que faz falhar quality:coverage, que faz falhar o gate guarda-chuva. Sem novo threshold, sem artefato JSON, sem parser. Binário.

A primeira vez que executei isso no trend-radar, seis request specs falharam, e todas as seis vieram de dois falsos positivos do Bullet, zero N+1s reais na base de código. Um foi validate_per_user_limit em TopicSubscription, chamando user.topic_subscriptions.count, uma única consulta COUNT em vez de iteração. A outra era TopicIndexProps#call fazendo @user.topic_subscriptions.index_by(&:topic_id), que carrega as inscrições do usuário exatamente uma vez na memória e permite que a linha seguinte as consuma.

A heurística do Bullet sinaliza qualquer chamada de associação em um registro carregado sem pré-carregamento. Ela não consegue distinguir entre um N+1 real e um caminho de código que usa legitimamente uma associação exatamente uma vez.

As duas correções mais limpas aqui são refatorar para ignorar a heurística do Bullet (usar TopicSubscription.where(user_id: user_id) em vez de user.topic_subscriptions), ou adicionar entradas Bullet.add_safelist. Eu decidi refatorar pois, no fim, isso gera o mesmo SQL, sem disparar a heurística e sem comentários de safelist para manter.

As refatorações não são gratuitas, porém. Elas custam algo real: o código fica ligeiramente menos semântico depois.user.topic_subscriptions.count lê-se como "quantas inscrições este usuário tem?" e expressa a relação de domínio diretamente. TopicSubscription.where(user_id: user_id).count lê-se como "conte as linhas nesta tabela onde o user_id coincide": centrada na tabela, com uma indireção a mais em relação ao modelo conceitual. O SQL é provavelmente idêntico, mas adicionamos um custo cognitivo que será pago por cada futuro leitor desses dois métodos.

A razão pela qual estou sequer considerando essa troca é precisamente porque prefiro priorizar a observabilidade de IA sobre a legibilidade humana. A mesma regra do Bullet que dispara nesses falsos positivos é a que disparará em um N+1 real na próxima vez que alguém escrever user.topics.each { |t| t.posts.first }. Ou eu tolero o ruído do Bullet aqui e refaturo os dois métodos afetados, ou adiciono Bullet.add_safelistentradas. A segunda rota mantém o código mais semântico, mas gera um custo diferente: qualquer lista de exceções que você precise manter manualmente (safelists, allowlists, arquivos de ignore, tabelas de threshold, entre outras) eventualmente se torna um vazamento. Entradas são adicionadas em momentos de urgência, ninguém as audita e, um dia, uma regressão real passa despercebida, escondida atrás de uma isenção obsoleta.

Estou disposto a pagar esse custo por enquanto, porque estou tentando levar ao limite o quanto a IA consegue gerar código confiável sem monitoramento usando essas ferramentas, mas em uma base de código mais robusta, eu provavelmente consideraria outra abordagem como Bullet.add_safelist, para evitar uma grande refatoração ao introduzir esse gate, e lidaria lentamente com a safe list.

O que eu recuei: alocações e contagens de SQL por ação

Aqui é onde ficou interessante. Implementei mais dois gates, fiz com que funcionassem, cheguei a fazer um experimento falso para verificar se eles disparavam em regressões e decidi que não valiam a pena.

Alocações em toda a suite via GC.stat

Lembrete rápido caso GC não seja um conceito familiar para você. O garbage collector do Ruby é o subsistema de runtime que libera a memória que você não precisa mais. Você não aloca ou libera objetos manualmente no Ruby; cada String.new, cada Hash.new, cada User.find retorna um objeto novo, e o GC rastreia quais deles ainda são acessíveis e quais podem ser recuperados. O módulo GC expõe uma pequena API: GC.start força uma coleta, GC.disable a pausa temporariamente, GC.count informa quantas coletas ocorreram e GC.stat retorna um hash de contadores internos que cobrem todo o tempo de vida do processo (páginas de heap, slots em uso, alocações, marcações, promoções, entre outros).

O contador que nos interessa é :total_allocated_objects: um inteiro monotonicamente crescente que conta cada objeto que o Ruby alocou desde que o processo começou. Envolva qualquer trecho de código com duas leituras desse contador e subtraia-as, e você terá a contagem exata de quantos objetos aquele trecho criou. Sem amostragem, sem gem extra, sem overhead de instrumentação além das duas leituras em si.

Essa é toda a premissa deste gate. Envolva a suíte de specs em hooks before(:suite) / after(:suite), capture GC.stat(:total_allocated_objects) no início e no fim, escreva a diferença em tmp/quality/allocations.json, compare com um baseline com uma banda de tolerância de +15%:

RSpec.configure do |config|
  config.before(:suite) do
    QualityAllocationsState.start = GC.stat(:total_allocated_objects)
  end

  config.after(:suite) do
    next if defined?(Mutant)  # Mutant's RSpec session would overwrite the artifact
    diff = GC.stat(:total_allocated_objects) - QualityAllocationsState.start
    File.write(out_dir.join("allocations.json"),
               JSON.pretty_generate(total_allocated_objects: diff))
  end
end

No trend-radar, a primeira execução resultou em cerca de 3,07M de objetos alocados em toda a suíte, que se tornou o baseline. Com a banda de tolerância de +15%, o teto efetivo do gate ficou logo abaixo de 3,53M. Para verificar se ele realmente detecta uma regressão real, inseri 1_000_000.times { String.new("waste") } em uma spec de modelo aleatória e executei o gate novamente. O total subiu para 5,08M, a linha ficou vermelha e o gate a detectou na primeira tentativa. Funcionou exatamente como projetado.

Contagem de SQL por ação via ActiveSupport::Notifications

Para o rastreamento por ação, eu precisava de algo mais granular do que o total de toda a suíte do GC.stat. Após um brainstorming com IA, decidi usar ActiveSupport::Notifications. A maioria dos desenvolvedores Rails vê sua saída todos os dias sem perceber. É o pipeline pub/sub que alimenta as linhas de log do Started GET "/" Completed 200 OK in 42ms em desenvolvimento, e é o mesmo mecanismo no qual gems como Skylight e NewRelic são construídas.

ActiveSupport::Notifications dispara um evento nomeado em cada momento interessante do ciclo de vida (uma query SQL sendo executada, uma ação de controller começando ou terminando, a renderização de um template, uma busca em cache, o disparo de um mailer) com vários detalhes. Qualquer pessoa no processo pode assinar esses eventos pelo nome e executar código em resposta. Portanto, tudo o que eu tinha que fazer era assinar estes três eventos:

  • start_processing.action_controlleré disparado quando o Rails começa a processar uma requisição, com o nome do controller e da action em seu payload.
  • sql.active_record é disparado para cada consulta SQL que a requisição executa.
  • process_action.action_controller é disparado quando a action é concluída.

A implementação é curta e mecânica. Quando uma requisição começa, eu zero um contador local da thread. A cada evento sql.active_record, eu o incremento. Quando a action termina, eu pego o valor atual do contador e atualizo o máximo registrado para aquela Controller#action. Depois que toda a suite é executada, eu exporto esses máximos para JSON. Tudo isso ficava em spec/support/action_metrics.rb:

require "fileutils"
require "json"

module ActionMetricsTracker
  SKIPPED_QUERY_NAMES = %w[ SCHEMA TRANSACTION CACHE ].freeze
  @data = {}

  class << self
    attr_reader :data

    def reset
      Thread.current[:action_metrics_sql_count] = 0
    end

    def increment
      Thread.current[:action_metrics_sql_count] =
        (Thread.current[:action_metrics_sql_count] || 0) + 1
    end

    def current
      Thread.current[:action_metrics_sql_count] || 0
    end

    def record(controller:, action:, count:)
      key = "#{controller}##{action}"
      @data[key] ||= { sql_count_max: 0 }
      @data[key][:sql_count_max] = [ @data[key][:sql_count_max], count ].max
    end
  end
end

ActiveSupport::Notifications.subscribe("start_processing.action_controller") do |*|
  ActionMetricsTracker.reset
end

ActiveSupport::Notifications.subscribe("sql.active_record") do |*args|
  payload = args.last
  next if ActionMetricsTracker::SKIPPED_QUERY_NAMES.include?(payload[:name])
  ActionMetricsTracker.increment
end

ActiveSupport::Notifications.subscribe("process_action.action_controller") do |*args|
  payload = args.last
  ActionMetricsTracker.record(
    controller: payload[:controller],
    action: payload[:action],
    count: ActionMetricsTracker.current
  )
end

RSpec.configure do |config|
  config.after(:suite) do
    next if defined?(Mutant)  # Mutant's RSpec session would overwrite the artifact
    out_dir = Rails.root.join("tmp/quality")
    FileUtils.mkdir_p(out_dir)
    File.write(
      out_dir.join("action_metrics.json"),
      JSON.pretty_generate(ActionMetricsTracker.data)
    )
  end
end

O que produz uma saída como:

{
  "DashboardController#index":   { "sql_count_max": 3 },
  "TopicSubscriptionsController#create": { "sql_count_max": 5 },
  "MatchesController#dismiss":   { "sql_count_max": 2 }
}

A regra de limite para cada action que decidi aqui é "+25% OU +3 consultas, o que for mais permissivo". O objetivo de combinar uma faixa percentual com um piso absoluto é dar espaço para o app crescer naturalmente sem que eu precise alterar os limites em cada PR: a porcentagem absorve o crescimento proporcional em actions maiores, e o piso absoluto absorve o pequeno crescimento absoluto em actions minúsculas (uma action de 3 consultas que passa para 4 não é uma regressão de 33% que justifique um alerta, mas se passasse para 7, provavelmente algo real mudou). Eu fiz um teste de sanidade da mesma forma que testei o gate de alocações: inseri 10.times { User.find_by(id: -1) } em DashboardController#index e observei aquela linha específica falhar (13 ≤ 6 ✗) enquanto todas as outras actions permaneciam verdes.

Por que removi ambos os gates

Para ser justo, ambos os gates estavam tecnicamente cumprindo seu papel. Eles pegaram as regressões que eu criei, e os números pareciam adequados. O que eu queria ver a seguir era como eles se comportariam diante do tipo de crescimento que acontece naturalmente conforme um projeto aumenta, então adicionei 50 chamadas extras de create(:user) a um before(:all)e executei a suíte novamente. As alocações subiram cerca de 1,6%, longe dos limites de 15% que adicionei, e a contagem de SQL por ação não mudou nada, já que ela conta apenas as consultas durante a própria invocação do controller, e não quaisquer fixtures que o teste configure previamente. Então, no papel, ambos os gates tinham bastante folga: dúzias de PRs de funcionalidades de tamanho normal poderiam entrar antes que qualquer um dos limites precisasse de um aumento.

Uma coisa que vale a pena ser sincero: esses dois gates não foram ideias que tive por conta própria. Foram sugestões de IA que anotei enquanto fazia brainstorming de maneiras de fechar a lacuna de desempenho, e as implementei principalmente para ver o que aconteceria. Portanto, toda esta seção trata mais de um experimento que não deu certo do que de algo em que eu tivesse uma convicção forte desde o início.

Mas acabei decidindo removê-los mesmo assim, e o motivo tem mais a ver com o tipo de resultado que eles produzem do que com os números que defini.

Um gate de alocação verde ou um gate de SQL por ação verde não diz que "o código está bom". Ele diz que "o código não cresceu demais ainda". Cada nova funcionalidade que adicionamos aumenta lentamente o número e, eventualmente, você atingirá o limite. Você o aumenta porque o crescimento era legítimo. Você o aumenta mais uma vez. O gate torna-se um overhead com a maioria de falsos positivos, então, sempre que surge uma regressão real, o caminho mais provável é que a IA e até os revisores simplesmente a ignorem e isso provavelmente seria enviado.

Os gates que mantive têm todos um sinal diferente; eles geram um sinal binário ("sem nova injeção de SQL", "sem padrões N+1", "specs cobrem este branch") em vez de um número que continua aumentando com o tempo. O sinal binário gera uma necessidade clara, essa necessidade de mudar, enquanto o número que continua aumentando gera um sinal que a IA precisa interpretar, deixando muito espaço para a IA acumular resíduos ao longo do tempo.

Um problema aqui é que ainda não conseguimos resolver o problema de desempenho, se é que isso é completamente resolvível, para ser sincero, mas continuarei insistindo e, assim que tiver atualizações significativas, farei um post de acompanhamento.

Binary x  Continuous Gates

The local/CI split

À medida que nossa suíte de testes cresce, o teste de mutação começou a ficar bem mais lento, impactando bastante o loop da IA. No trend-radar, quality:mutation normalmente leva cerca de 2:50 localmente e cerca de 14 minutos em um runner gratuito do GitHub, porque o Mutant precisa executar novamente toda a suíte de especificações para cada mutação sobrevivente, e isso soma centenas de execuções. Todos os outros gates combinados terminam em cerca de 15 segundos.

Como isso inevitavelmente só vai crescer, ter que esperar esse tempo toda vez que a IA gera novo código começaria a acumular tempo ocioso, e o agente perderia produtividade lentamente.

bin/rake quality:local   # ~15 seconds; everything except mutation
bin/rake quality         # ~3 min locally / ~15 min CI; full gate including mutation

A tarefa local também faz a limpeza após a execução, para que um mutation.json obsoleto de uma execução completa anterior não apareça no próximo relatório e confunda quem o estiver lendo:

namespace :quality do
  task :clean_mutation_artifact do
    path = QUALITY_DIR.join("mutation.json")
    path.delete if path.exist?
  end

  desc "Fast local quality gate (everything except mutation testing). ~15s runtime."
  task local: %w[
    quality:clean_mutation_artifact
    quality:coverage
    quality:rubocop
    quality:critic
    quality:flog
    quality:brakeman
    quality:report
  ]
end

O GitHub Actions ainda executa a versão completa em cada PR, com a concorrência ativada para que commits rápidos não se acumulem na fila. O CLAUDE.md orienta a IA a basear-se no quality:local durante a iteração e recorrer ao quality completo apenas quando achar que realmente terminou.

Há uma troca aqui, é claro. Uma regressão que apenas o teste de mutação detectaria poderia, em teoria, passar pelo quality:local e falhar apenas no CI, mas na prática isso quase nunca acontece, o CI a detecta na rara vez que ocorre, e o aumento da velocidade de iteração acabou valendo muito mais do que o risco.

Inevitavelmente revisitaremos isso conforme nossa suíte crescer, já que até suítes de testes normais começarão a ficar muito lentas, então precisaremos de uma maneira de disparar essas etapas apenas para arquivos relacionados às alterações que fizemos, mas por enquanto, isso está ok.

The mutation bootstrap bug

A divisão não apenas acelerou a iteração, ela acabou revelando um bug que eu vinha ignorando por semanas. Uma vez que tive duas execuções de mutação independentes para comparar, os números começaram a divergir: o local bin/rake quality reportou uma taxa de eliminação de 74.77%, enquanto o CI reportou 67.89%. O mesmo código exato, a mesma versão do Mutant exata, a mesma versão do Ruby exata (4.0.2 em ambos). Uma diferença de 7 por cento é grande demais para ser ignorada.

Meu primeiro instinto foi que talvez uma das execuções estivesse apenas com ruído, então executei a mutação duas vezes localmente para descartar isso. Ambas retornaram exatamente 74.77%, completamente determinísticas, o que significava que a diferença era real e algo no CI estava realmente fazendo o Mutant se comportar de forma diferente.

Esta também é uma parte do post na qual eu queria destacar algo, porque acho que mostra algo subestimado sobre trabalhar com IA agora. Até este ponto, eu estava usando o Claude principalmente como um gerador de código, mas aqui eu tinha um problema real de depuração sem um lugar óbvio para começar: dois números discordando em dois ambientes, e nenhum culpado claro. Então, em vez de tentar descobrir sozinho primeiro, eu simplesmente entreguei toda a investigação e observei o que o Claude Code fez.

A primeira coisa que ele sugeriu: "nós não podemos comparar nada até que o CI faça o upload do seu mutation.txtas an artifact, that should be step zero." Isso parece óbvio, mas dado o quão pouco eu sei sobre configurar GitHub Actions, era uma possibilidade que nem estava no meu radar no início desta depuração. Eu provavelmente teria lido um monte de código tentando entender o que estava acontecendo e teria batido em vários becos sem saída ao longo do caminho. Aqui está o código que foi adicionado ao meu workflow do GitHub.

- name: Upload quality artifacts (mutation.txt etc.) for inspection
  if: always()
  uses: actions/upload-artifact@v4
  with:
    name: quality-artifacts
    path: tmp/quality/
    retention-days: 14

Uma mudança really simples no meu yaml de CI, e isso me fornece um zip para download após qualquer execução, incluindo falhas. Eu reiniciei o CI, baixei o zip e apontei o Claude para ambos os arquivos mutation.txt. Comparar 4361 mutações de cada lado manualmente jamais aconteceria, mas para um modelo com um arquivo na frente, isso é um trabalho de cinco minutos. Minha única instrução real foi "descubra quais mutações estão vivas no CI, mas mortas localmente, agrupe-as por arquivo e me diga qual é o padrão".

A resposta veio quase imediatamente, e foi um pouco constrangedor: script/mutant_bootstrap.rb não estava definindo RAILS_ENV. O CI define RAILS_ENV: test no nível do workflow, então o Rails inicia corretamente no modo de teste lá. Localmente, bin/rake quality não estava exportando RAILS_ENV para lugar nenhum, o que significava que o Rails estava iniciando no modo development o tempo todo. Proteção CSRF ligada, middlewares de dev carregados, show_exceptions se comportando de forma diferente. Isso resultou em 33 request specs falhando localmente, todas retornando 403 em vez do redirecionamento esperado.

Aqui é onde a taxa de abate (kill ratio) do Mutant fica um pouco contra-intuitiva. Cada assunto (um par de classe+método) recebe uma inserção "neutra" (o código original, intocado) e N mutações "malignas". Uma neutra conta como morta apenas se a spec passar e uma maligna conta como morta apenas se a spec falhar. O que significa que, quando uma spec está quebrada na base, a matemática se inverte:

  • Neutro: spec falha → neutro torna-se "vivo" (1 por assunto quebrado; relatado, mas não altera muito a taxa de kill ratio)
  • Cada mutação malévola sob aquele assunto: spec também falha → todas as malévolas são mortas

Então 33 assuntos quebrados traduziram-se localmente em aproximadamente 300 mutações creditadas como kills que aqueles specs não conquistaram na verdade, que é exatamente de onde vinha a inflação de 7 pontos percentuais. Os 67,89% do CI eram o número verdadeiro; os 74,77% locais que eu estava vendo por duas semanas estavam simplesmente errados.

A correção acabou sendo de duas linhas:

# script/mutant_bootstrap.rb
ENV["RAILS_ENV"] ||= "test"
require_relative "../config/environment"
Rails.application.eager_load!

Após essa mudança, a taxa de kill ratio local caiu para 67,89%, correspondendo à porcentagem do CI.

O que tiro de tudo isso é bem simples: um quality gate que mente para você é pior do que nenhum gate. Por semanas, eu estive mesclando código sob a suposição de que 74% das mutações estavam sendo mortas, quando o número real era próximo a 68%. E a única razão para tudo isso ter vindo à tona é que a divisão local/CI me deu duas execuções independentes para comparar. Sem isso, eu teria continuado mesclando sob o número inflacionado para sempre e provavelmente nunca teria notado.

O que ainda falta

Duas lacunas reais que eu não fechei.

Execução de gate ciente do diff

No momento, o bin/rake quality executa cada verificação contra toda a base de código, independentemente do que o PR realmente fez. Uma mudança de uma linha em um controller ainda dispara execuções completas do Mutant em 4.361 mutações, das quais provavelmente ~3.000 não têm relação. O Mutant suporta --since main para limitar as mutações às linhas alteradas em relação a uma base. O RuboCop tem padrões de modo diff semelhantes. O Brakeman re-escaneia tudo, mas a saída JSON poderia ser filtrada.

Uma versão do gate ciente do diff poderia ser muito mais rápida na maioria dos PRs, o que potencializa a melhoria da iteração de IA.

Detecção real de regressão de performance

Removi o gate de alocações e o gate de SQL por ação. Eles funcionavam, tecnicamente, mas como discutimos, eles impõem uma propriedade de drift contínuo que gera um ritual de ajuste de limiar em vez de um sinal real.

Mas o problema ainda existe: nada no gate atual detecta "este controller agora leva 200ms em vez de 80ms". O Bullet detecta certos padrões N+1; a cobertura detecta "você não escreveu um teste"; a mutação detecta "seu teste é superficial demais". Não temos um único gate que detecte efetivamente "este código está mais lento".

Para ser honesto, tenho algumas suposições possíveis sobre como podemos fechar essa lacuna, mas são apenas suposições, então provavelmente farei um follow-up assim que tiver algum progresso nessa frente.

Closing thoughts

A lição principal do post anterior foi: pare de ler código gerado por IA, meça-o. A lição principal deste é um pouco mais sutil.

Nem toda medição é um sinal. Os gates de drift contínuo que removi produziam números que eram tecnicamente precisos e operacionalmente inúteis. Os gates categóricos que mantive produziam sinais binários nos quais a IA podia realmente agir.

Um gate que mente é pior do que nenhum gate. O bug de bootstrap de mutação foi uma mentira de 7 pontos que eu vinha aceitando via merge por duas semanas. Encontrá-lo exigiu não confiar no gate que me dava boas notícias.

Velocidade de feedback e profundidade de feedback são um equilíbrio, não uma escolha. Você precisa de feedback rápido para continuar produtivo enquanto itera, e de feedback profundo para realmente confiar no que está saindo do outro lado. Dividir o gate em uma passagem local de 15 segundos e uma execução completa de CI de ~15 minutos foi como consegui ambos ao mesmo tempo: a IA mantém um loop apertado enquanto itera, e as verificações lentas ainda rodam antes que qualquer coisa seja enviada.

A implementação completa está no repositório trend-radar: lib/tasks/quality.rake, spec/support/bullet.rb, script/mutant_bootstrap.rb e .github/workflows/quality.yml são os arquivos onde a maior parte deste trabalho reside.

We want to work with you. Check out our Services page!