Pular para o conteúdo

itaruby, a ser lançado

Um type checker de Ruby que já achou 15 bugs no Discourse, no Rails, no Zammad e no ruby-lsp

O itaruby é um verificador de tipos para Ruby que estou construindo. Em vez de anunciar o que ele promete fazer, esta página mostra o que ele já fez: 14 envios, 3 no discourse/discourse, 5 no rails/rails, 5 no zammad/zammad e 1 no Shopify/ruby-lsp, cada um com o bug verificado por execução no arquivo real do projeto antes de ser enviado. Discourse é fórum, Rails é framework, Zammad é helpdesk e ruby-lsp é language server: domínios diferentes, sem ajuste especial pra nenhum. Do total, 7 já foram aceitos.

14

envios

7

aceitos

+130

linhas

-29

linhas

O que é

Na descrição do próprio commit inicial: "inference-first Ruby type checker in Rust". Infere em vez de exigir que você declare. 4 crates: itaruby (a CLI), itaruby_syntax, itaruby_semantic e itaruby_server (o language server). 98 arquivos .rs, 15.075 linhas de Rust em produção e 13.793 em teste, quase uma linha de teste por linha de código. Motor de recomputação incremental: salsa , a mesma biblioteca do rust-analyzer. Além de checar pela linha de comando, roda como language server: definition e hover em arquivo:linha:coluna.

O commit inicial é de 25 de agosto de 2026, 18h18. O mais recente medido aqui é de 26 de agosto de 2026, 08h30: 26 commits em dois dias (8 no primeiro, 18 no segundo), história sem reescrita. No momento em que os 14 envios desta página foram abertos, o checker tinha cerca de 14 horas de vida.

Avança em waves testadas contra um harness de mutação que quebra código de propósito pra provar que o teste acusaria: a wave 7 pegou 16 mutantes.

O funil

13208 diagnósticos brutos, 14 envios, em quatro triagens de momentos diferentes, não um retrato único: rails, discourse e zammad numa rodada anterior à wave 6; ruby-lsp na wave 6, durante a comparação com o Sorbet. A maioria dos 13208 é ruído: a mesma constante não resolvida, sinalizada de novo a cada referência no código. O funil entre um número e o outro é manual. Eu leio cada diagnóstico, agrupo o que tem a mesma causa, provo por execução o que sobra e só depois vira envio. Nenhum dos 13208 virou envio sozinho.

O que este número deliberadamente não conta: doze pull requests que eu abri no software-factory, um projeto que fez parte desta mesma caminhada e que me destravou pra abrir os outros. Eles não entram porque não foram achados pelo checker. Somar os dois daria a entender que o itaruby encontrou vinte e seis coisas, e a frase "cada envio provado por execução" deixaria de valer pra metade delas. Preferi a contagem menor que é verdadeira inteira.

Repositório Arquivos Diagnósticos Rodada Envios
rails/rails 3457 2364 anterior à wave 6 5
discourse/discourse 11201 7959 anterior à wave 6 3
zammad/zammad 5586 2834 anterior à wave 6 5
Shopify/ruby-lsp 371 51 wave 6 (7d3a781) 1
Total 20615 13208 14

A contagem de rails, discourse e zammad é do momento da triagem, de uma rodada anterior à wave 6, cujo sha não foi registrado. A de ruby-lsp é da wave 6, commit 7d3a781. Nenhum desses números muda depois: a contagem bruta de hoje, mais baixa, está na seção "Wave 8" mais abaixo, sha 5c5c8c3. A contagem de arquivos foi reconfirmada estável entre rodadas; tempo de execução não entrou nesta tabela porque emparelharia tempo de uma rodada com contagem de outra, o mesmo erro que esta página já cometeu em outro lugar. Tempo pertence à seção "Vazão", que usa tempo e contagem da mesma rodada.

Vazão

11201 arquivos do Discourse escaneados em 8,02s. Sem configuração, sem anotação, sem passo de setup: o requisito pra rodar é zero.

Os envios

#42898 mergeado +2 / -3

FIX: two crashes in the Lithium import script

Dois crashes no importador de Lithium. Um file.unlind que não existe em Tempfile, engolido pelo rescue em volta, vazando um arquivo temporário por avatar importado. E um link_post_uploads chamado no CookedPostProcessor, que não tem esse método: esse não é engolido e derruba o pós-processamento de anexos inteiro.

script/import_scripts/lithium.rb

#42897 mergeado +22 / -1

FIX: forward force as a keyword argument in the TopicAssigner shim

O shim deprecated recebia force: como kwarg e repassava posicional para um método que só aceita um posicional. Qualquer chamada explodia, inclusive a mais simples, sem force. Vai com o spec de regressão que faltava na classe.

plugins/discourse-assign/lib/topic_assigner.rb, plugins/discourse-assign/spec/lib/topic_assigner_spec.rb

#42896 mergeado +1 / -1

FIX: pass all five arguments to replace_internal_link in Discuz import

O branch de link de mensagem privada chamava replace_internal_link com 2 argumentos, e o método exige 5. Todo post cujo corpo casasse a regexp derrubava a importação com ArgumentError.

script/import_scripts/discuz_x.rb

#58569 mergeado +1 / -1

Remove unreachable default for DerivedSecretKeyProvider#derive_key_from

O default using: key_generator aponta para um método que não existe na instância, então cair nele levanta NameError. Inalcançável: o método é privado e o único caller passa o argumento explícito.

activerecord/lib/active_record/encryption/derived_secret_key_provider.rb

#58568 mergeado +27 / -9

Remove unused Active Record internals

Dois trechos mortos. Um branch else que cai em expr, método que não existe em nenhum lugar do Arel, e um columns_hash que chama a connection sem o argumento que ela exige.

activerecord/lib/arel/nodes/homogeneous_in.rb, activerecord/test/cases/arel/support/fake_record.rb

#58567 mergeado +0 / -4

Remove unused `request` helper from RouteSetTest

Helper que memoiza TestRequest.new sem argumento nenhum, enquanto o construtor exige três. Qualquer chamada levantaria ArgumentError. Ninguém chama.

actionpack/test/controller/routing_test.rb

#58566 aplicado por commit +13 / -1

Fix QueryCacheRegistry#clear calling an undefined method

O método chamava @map.synchronize. O mutex da classe é @mutex; @map é um Hash e não responde a synchronize, então limpar o cache explodia.

activerecord/lib/active_record/connection_adapters/abstract/query_cache.rb

Achado aplicado no commit 22a02ee12e , não pela branch deste PR.

#58565 aberto +13 / -1

Fix RouteSetTest::SimpleApp#call referencing an undefined local variable

O call devolvia response, uma variável local que não existe. O valor está em @response. Qualquer requisição roteada para esse app de teste levantava NameError.

actionpack/test/dispatch/routing/route_set_test.rb

#6328 aberto +6 / -1

Fix: Wrong number of arguments in create_knowledge_base_categories call

Uma chamada passava 4 argumentos posicionais para um método que recebe um hash, levantando ArgumentError: wrong number of arguments (given 4, expected 1). O caminho é documentado no docblock do próprio arquivo.

lib/fill_db.rb

#6329 aberto +15 / -1

Fix: Compare limit against @size, not undefined size

A comparação usava size, que não existe no escopo; só @size existe, usado em quatro outras linhas. Levanta NameError na primeira vez que alguém encadear .limit antes de .as_batches.

config/initializers/active_record_as_batches.rb, spec/lib/core_ext/relation/as_batches_spec.rb

#6330 aberto +0 / -4

Remove unused OnlineNotificationPolicy#relation

O método chamava um nome inexistente e ninguém o alcança. Provado pelo fonte do pundit, cujo único despacho dinâmico é o nome da ação com interrogação.

app/policies/online_notification_policy.rb

#6331 aberto +14 / -1

Fix: Auth::TwoFactor#initiate_authentication calls an undefined method

O método chamava method_object em vez de authentication_method_object, que existe e é usada corretamente em duas outras linhas da mesma classe.

lib/auth/two_factor.rb, spec/lib/auth/two_factor_spec.rb

#6332 aberto +14 / -1

Fix: Cleanup action job name misses job prefix on payload args

Uma linha esqueceu o receptor job., então o identificador nu levanta NameError; as linhas vizinhas do mesmo método já usavam o receptor certo.

lib/background_services/service/base_delayed_jobs/cleanup_action.rb, spec/lib/background_services/service/base_delayed_jobs/cleanup_action_spec.rb

#4197 aberto +2 / -0

Fix NameError raised by RubyIndexer's abstract method guard

Um raise AbstractMethodInvokedError dentro de RubyIndexer::Entry::Member aponta para uma constante que só existe como RubyLsp::AbstractMethodInvokedError, em módulo irmão, não ancestral: a guarda sobe NameError em vez do erro pretendido. É o único fora de lib/ruby_lsp/ entre os 17 sítios desse raise no repositório; os outros 16 resolvem certo. Guarda defensiva latente, não alcançável hoje: as duas subclasses concretas de Member já implementam signatures.

lib/ruby_indexer/lib/ruby_indexer/entry.rb

Os portões

Depois que o PR está aberto, o que decide se ele avança não é o patch. São três portões, medidos, não sobre a ferramenta.

O CLA da Shopify pede assinatura humana mais um comentário que case o regex do workflow. Meu primeiro comentário, "CLA signed.", passou o if do workflow, o job concluiu success, e o log dizia Comment does not match CLA pattern: run verde não significa portão vencido. O que funcionou foi "I have signed the CLA."

action_required é aprovação de workflow pra primeira contribuição, por repositório: trava o zammad (5 PRs) e o ruby-lsp (1). Rails e Discourse não têm essa trava.

No GitLab, cota e membership impedem até o push: por isso 6 MRs prontos seguem não abertos.

Nenhum dos três portões tem a ver com a qualidade do patch, e nenhum aparece antes do PR estar aberto.

Como cada achado foi verificado

Um apontamento de checker é hipótese, não bug. Todo item desta lista passou por prova de dois lados no arquivo real do repositório: roda e falha antes do conserto, roda e passa depois. Reproduzir num arquivo separado não vale, porque isso testa o meu entendimento do código, não o comportamento do projeto.

Nos casos de remoção de código morto a exigência é outra: provar que ninguém alcança aquele código. Isso significa varredura no repositório inteiro, incluindo despacho dinâmico (send, public_send, respond_to?, method_missing, delegate) e as subclasses da classe, não só quem chama direto. Foi essa última parte que quase escapou no #58569 do Rails: a classe tem duas subclasses, e se qualquer uma delas chamasse o método a história mudava de limpeza para bug real.

O formato dos PRs também não saiu de gosto pessoal. Saiu de medição do que o projeto já aceitou. Contei primeiro os cem últimos merges do Rails, não achei nenhuma remoção de código morto e concluí que não havia precedente. Cem merges no Rails cobrem duas semanas: a busca no histórico inteiro mostrou uma campanha de doze PRs de remoção mergeados um mês antes. Amostra curta não mede categoria rara.

Aceitação também não é sempre a mesma coisa. No Zammad o desenvolvimento real acontece num GitLab interno; o GitHub é espelho, e o merge nativo por lá está parado desde 2021. Quando o mantenedor aceita um fix, ele aplica o patch como commit à mão e fecha o PR citando o hash. Por isso a lista distingue mergeado de aplicado por commit: dois jeitos de dizer sim, sem esconder qual foi qual.

Prova, não adjetivo

O #58566 do Rails é o caso mais completo da seção: fechado sem merge, com um comentário só, de byroot (Jean Boussier, do core do Rails): "If it's never called, let's just remove the method entirely." Não foi recusa do achado, foi recusa da forma: eu mandei correção pra um método que estava morto, quando o certo era remoção. Classifiquei o método como alcançável sem provar. A prova, feita depois: @thread_query_caches aparece em exatamente dois lugares no repositório, a instanciação e uma chamada a compute_if_absent; .clear nunca é chamado. E o corpo do método chamava synchronize num objeto que não tem esse método, então levantaria erro se alguém chamasse: morto e quebrado, e eu mandei consertar em vez de remover. O desfecho é o estado aplicado por commit: ele mesmo removeu o método no commit 22a02ee12e , mensagem literal:

Get rid of `QueryCacheRegistry#clear`

The method is dead code.

Com crédito de coautoria pro achado. Entrou, só não pela minha branch.

No #58568 do Rails, o próprio método que o itaruby apontou não tinha teste nenhum na suíte. A prova inicial de que o código era morto veio de um grep 'def expr' no repositório, que jamais acharia um attr_accessor :expr. O instrumento certo era seguir a cadeia de herança, não grepar pelo nome do método. O erro foi corrigido antes do PR sair, e o teste que faltava entrou junto.

O #42898 do Discourse mostra o mesmo tipo de falha com consequências opostas, no mesmo arquivo do importador de Lithium: Dois crashes no importador de Lithium. Um file.unlind que não existe em Tempfile, engolido pelo rescue em volta, vazando um arquivo temporário por avatar importado. E um link_post_uploads chamado no CookedPostProcessor, que não tem esse método: esse não é engolido e derruba o pós-processamento de anexos inteiro.

Três achados do Zammad (#6329, #6331, #6332) vieram com spec de regressão que a suíte não tinha. Nos três o conserto de produção é uma linha; o resto do diff, entre treze e quatorze linhas, é teste.

Onde o Sorbet ganha

O que está provado: o itaruby não exige nada, e lê RBS inline se você tiver. Sem configuração, sem passo de adoção. Os 14 bugs desta página saíram de 3 repositórios com zero sorbet/, zero .rbi e zero extend T::Sig: 13 envios, 7 aceitos. O repositório restante do corpus, ruby-lsp, roda Sorbet e tem seu próprio bug, na próxima seção.

Nota de escopo: de bloco sig do Sorbet, o itaruby aproveita o tipo de retorno; não aproveita o tipo de parâmetro. A sig já fala retorno, falta ela falar parâmetro. Isso importa menos do que parece, porque o corpus quase não usa sig. Testei: uma classe com extend T::Sig e sig { params(n: Integer).returns(String) }, chamada passando String onde a assinatura pede Integer no parâmetro, não gera diagnóstico nenhum: é exatamente o lado que ainda falta. Mas rails, discourse e zammad têm zero sig. O ruby-lsp, o repositório mais tipado do corpus, tem zero sig real em produção: o texto sig que sobra no repositório é comentário de documentação e fixture de spec, não anotação. O tapioca tem um, em lib/.

Existe uma razão mais afiada por trás daquele zero, e ela é o comportamento documentado do próprio Sorbet, não uma opinião sobre ele. Arquivo sem sigil cai em # typed: false por padrão, e nesse nível o Sorbet reporta só sintaxe, resolução de constante e correção de sig. O manual dele é explícito: o nível "apenas impede que o corpo daquele método seja verificado". Então num repositório sem anotação o Sorbet não está falhando em achar esses bugs: ele nunca é convidado a olhar. É essa a lacuna inteira que o itaruby ataca, e é por isso que a comparação é sobre alcance, não sobre velocidade. sorbet.org/docs/static

O que muda a leitura: o itaruby lê RBS inline. É a anotação que o próprio time do tapioca passou a usar no código de produção do ruby-lsp, em vez de sig: um comentário de uma linha em cima do método, no formato #: (Tipo) -> Tipo. Testei o mesmo método nas três formas de anotar:

# sem anotação
def formata(n)
  n.to_s
end
formata("7")
# itaruby: silêncio (correto, não tem de onde saber)

# com RBS inline
#: (Integer) -> String
def formata(n)
  n.to_s
end
formata("7")
# itaruby: p1.rb:7:18: error[E0103]: argument 1 of 'formata' expects Integer, got String

# com sig do Sorbet
extend T::Sig
sig { params(n: Integer).returns(String) }
def formata(n)
  n.to_s
end
formata("7")
# itaruby: silêncio

O contraste de custo é o dado mais forte da seção. Pro Sorbet enxergar as dependências, ruby-lsp e tapioca mantêm 754.669 linhas de .rbi vendorizado (246.830 + 507.839), mais gem de runtime, mais sigil por arquivo, mais passo de geração. RBS inline não pede nada disso: é um comentário em cima do método, zero arquivo vendorizado, zero gem de runtime, zero geração.

RBS de core/stdlib, em arquivo separado, não inline: não consome. RBS de gem via rbs collection, também em arquivo separado: não consome. RBI não é falta de cobertura, é busca: declaração dentro de .rbi escrita como bloco aninhado indexa certo, o caminho completo existe no índice; quem falha é a consulta, que procura pelo caminho qualificado num mapa onde esse nome foi registrado sem qualificação, e desiste antes de aplicar o refiltro que existe justamente pra isso. Só a forma compacta module X::Y evita o problema. É o mesmo bug que explica por que a correção de alias curou o ruby-lsp e não o tapioca, na seção "O ciclo" logo abaixo.

Esta tabela mudou desde a publicação original desta página: o ruby-lsp caiu de pior pra melhor do corpus depois de uma correção no próprio itaruby, não no código escaneado. O mecanismo e a previsão verificada estão na seção "O ciclo", logo depois de "Cara a cara com o Sorbet".

Repositório Diagnósticos / mil arquivos
ruby-lsp 137
tapioca 275
zammad/zammad 505
rails/rails 670
discourse/discourse 704

O que fica de pé, antes e depois da correção: nem "pior" nem "melhor" mediam tipagem. Mediam o quanto o itaruby ainda tinha bug de parsing específico de repositório. O contrato que sobra não é "funciona melhor com mais tipo": é sem exigência nenhuma, e lê RBS inline quando o código já tiver.

Cara a cara com o Sorbet

O teste mais duro que dá pra fazer: rodar o itaruby contra o Sorbet nos dois únicos repositórios do corpus exploratório que já adotaram Sorbet de verdade. srb tc global, sem bundle install, nos dois:

Ferramenta Repositório Arquivos Tempo Diagnósticos
srb tc ruby-lsp 235 0,172s 0
ita check ruby-lsp 371 0,496s 51
srb tc tapioca 370 0,242s 0
ita check tapioca 218 0,664s 60

As quatro medições de tempo são da mesma sessão, mesma máquina, três execuções cada, mediana. O Sorbet é 2,9x mais rápido no ruby-lsp e 2,7x no tapioca. Tempo de parede não é comparável entre sessões diferentes nesta página: numa remedição anterior o ita check no ruby-lsp deu 445ms; nesta, 496ms, sem nenhuma mudança de binário entre as duas. Variação de sessão pra sessão de 10% a 20% nesta máquina, o que inviabiliza qualquer leitura de "a correção custou tempo" sem uma medição antes/depois feita na mesma rodada.

A contagem de arquivos difere porque o srb aplica os --ignore do sorbet/config do projeto (fixtures, vendor) e conta os .rbi junto; o itaruby conta os .rb crus e não tem lista de ignore equivalente.

O Sorbet está verde nos dois: saída literal No errors! Great job.. O itaruby, no mesmo código, produz 111 diagnósticos brutos. Não achei 111 bugs. Classifiquei os dois conjuntos inteiros, item por item, não uma amostra:

Repositório Diagnósticos Bug real Falso positivo Ruído genuíno
ruby-lsp 51 1 16 (31%) 34 (67%)
tapioca 60 0 34 (57%) 26 (43%)

srb tc continua em zero erro nos dois.

Um número melhorou e o outro piorou, e os dois valem o mesmo destaque: na amostra de 10 do ruby-lsp lá em cima, 7 de 10 eram falso positivo (70%); na classificação exaustiva de 51, caiu pra 31%. O tapioca foi na direção contrária: virou o repositório com a maior proporção de falso positivo do corpus, 57%. Não foi estado terminal: a causa tinha nome, endereço no código, e a correção entrou. O resultado está na seção "Wave 8" logo abaixo: tapioca caiu de 17 para 1, teto documentado, não reclassificação a zero.

Reconferi os 10 itens originais do ruby-lsp contra a classificação exaustiva: 2 desapareceram (eram falso positivo, resolvidos pela correção de alias), 8 permanecem, e 1 desses mudou de classificação porque a evidência anterior tinha conferido o diretório errado. Erro de medição nosso, não da ferramenta.

Os falsos positivos têm causa. A primeira vez que medi essa causa o número saiu errado; a versão corrigida está abaixo. Reclassifiquei os 41 avisos de constante que sobraram no ruby-lsp depois da correção de alias, por mecanismo:

Mecanismo Avisos O que é
Declarado como T.let no RBI já commitado 11 Test::Unit::* (7), LanguageServer::Protocol::Constant::* (3), RuboCop::Version::STRING (1)
Existe no RBS de core/stdlib do disco 9 Minitest::Spec (7), OptionParser e um de Gem::
Fixture proposital do próprio repositório 11 constantes falsas que os testes do ruby-lsp definem de propósito
Resto 10 inclui o bug do entry.rb que virou o PR #4197

As duas alavancas de tipo, RBI já commitado e RBS de core/stdlib do disco, são quase do mesmo tamanho: 11 contra 9. Nenhuma sozinha resolve metade dos 41. Não existe fonte de tipo salvadora aqui: é sempre as duas juntas, e mesmo as duas juntas não cobrem tudo.

11 dos 41 são fixture: constantes falsas que o próprio ruby-lsp define de propósito pra testar seu detector de erro de sintaxe. O sorbet/config do projeto exclui essa pasta via --ignore; o itaruby não honra esse --ignore. Isso é 27% do que sobrou sendo o itaruby reportar sobre arquivo que o dono do projeto já declarou fora do escopo, uma limitação concreta e verificável.

Sobre o método, porque ele é a base de todo número desta página: a primeira medição desse mecanismo tinha dois erros. O denominador original, 160 constantes únicas não resolvidas, caiu para 29 assim que as causadas por alias saíram da lista com a correção do commit 7d3a781. E o numerador original, 78, também estava errado: o regex casava constante por sufixo do nome, não pelo caminho completo, então nomes como A e C entravam na conta por terminarem igual a uma declaração real de T.let em outro lugar do arquivo. Os dois erros só apareceram quando troquei grep por consulta direta à API do RBS, carregando as 61 bibliotecas de stdlib e perguntando por nome de tipo em vez de por texto. O número certo, depois disso, é o da tabela acima: 11 de 41, 27%. A troca de grep por API resolveu o erro de sufixo, mas não bastou sozinha: a consulta aceitava um atalho, nome de topo existindo já contava como achado, então Gem existir bastava pra Gem::DefaultUserInteraction passar, quando a única ocorrência real dessa constante em todo o RBS é um comentário. A lição completa não é "API em vez de texto": é que a pergunta tem que ser pelo caminho exato, porque aceitar prefixo é grep de sufixo vestido de API.

Os 7 diagnósticos do ruby-lsp que não são aviso de constante têm partição completa, verificada na fonte, não amostra:

Mecanismo Diagnósticos Onde
Estreitamento de fluxo 3 declaration_listener.rb:512 e :515 (is_a? em ternário), code_action_resolve.rb:435 (case sem else)
Genérico [T] na assinatura 3 erb_document_test.rb:161, ruby_document_test.rb:906 e :1010 (todos em cache_set)
Cast #: as untyped ignorado 1 addon_test.rb:149

O caso de code_action_resolve.rb:435 merece linha própria: o cast #: as !nil colado no end da linha 417 é lido certo, isso está confirmado. O que falta é estreitar o tipo antes de o cast chegar: as linhas 410 a 417 são um case @code_action[:title] com três when e sem else, e sem esse ramo o tipo que sobra é String | nil. O cast não é ignorado: ele é aplicado sobre um tipo que já chegou errado.

Nem toda linha da tabela é bug. Estreitamento de case sem else é família declarada fora de escopo por decisão, fronteira escolhida, não falha à espera de descoberta. O genérico [T] tratado como tipo fixo é bug real, e é a terceira vez que esse mecanismo aparece nesta investigação: já tem correção priorizada. O cast #: as untyped ignorado segue sem correção.

O enquadramento que fecha esta seção: o valor está em código NÃO tipado. Nos 3 repositórios sem tipagem o funil deu 14 bugs reais, 13 envios, 7 aceitos. Nos dois repositórios com Sorbet, o Sorbet já está verde e o itaruby produz majoritariamente falso positivo. Isso é escopo, não derrota: onde já existe Sorbet configurado e verde, o itaruby hoje não acrescenta, atrapalha.

Único bug real que o itaruby achou nos dois repositórios com Sorbet, num achado em 111 diagnósticos brutos nesses dois repositórios. Não é vitória, é a exceção que confirma o escopo. O bug: um raise AbstractMethodInvokedError dentro de module RubyIndexer quando a constante só existe como RubyLsp::AbstractMethodInvokedError, módulo irmão, não ancestral. O raise nunca resolve; reproduzi a estrutura separada e confirmei: sobe NameError. Dos 17 sítios desse mesmo raise no repositório, 16 estão em lib/ruby_lsp/ e resolvem certo; só esse está fora do namespace certo. É guarda defensiva latente, não crash em produção, porque as duas subclasses concretas já implementam o método abstrato. O PR está aberto, #4197 , no ruby-lsp: 2 linhas adicionadas , nenhuma removida. Contribuição de fora da Shopify passa por dois portões antes de virar código: o CLA da empresa e a aprovação de workflow de primeira contribuição, os dois pendentes agora.

O dado mais interessante da seção: esse bug estava num repositório que roda Sorbet em CI, verde, e o Sorbet não pega porque a flag --enable-experimental-rbs-comments do sorbet/config do próprio projeto silencia esse tipo de guarda. Não é "o itaruby é mais forte que o Sorbet": é uma flag de configuração que criou um ponto cego, e uma ferramenta que não lê a configuração do projeto passou por ele sem saber que existia. É o argumento real a favor de rodar duas ferramentas diferentes em vez de uma.

Sobreposição entre os dois conjuntos de erro real: zero. Não é achado, é ausência de dado: o conjunto do Sorbet nos dois repositórios é vazio, então não existe interseção pra medir. Não é vitória, é grau de liberdade zero na comparação.

O ciclo

A tabela de taxa lá em cima mostrou o ruby-lsp em 1240 diagnósticos por mil arquivos, o pior do corpus, sendo o repositório mais tipado. Contraintuitivo o bastante pra valer investigar a causa em vez de aceitar o número.

A causa tem nome e linha, em lib/ruby_lsp/utils.rb:6-7:

Interface = LanguageServer::Protocol::Interface
Constant = LanguageServer::Protocol::Constant

O itaruby não seguia esse tipo de alias de constante; toda referência a Interface::X ou Constant::X no resto do arquivo virava diagnóstico de constante não resolvida. Isso respondia por 405 dos 449 avisos de constante do repositório.

Previsão feita antes de tocar no código: cerca de 405 avisos deveriam desaparecer com a correção. Depois da correção, commit 7d3a781: 408 desapareceram. Erro de previsão de 0,7%.

Essa conclusão saiu de comparar totais por repositório, antes e depois, e comparação de totais não estabelece afirmação sobre conjunto: eu mesmo cometi esse erro depois, na wave 7, onde o total do rails ficou idêntico entre duas waves, 2316 contra 2316, escondendo 5 diagnósticos saindo e 5 entrando. O diff no nível de conjunto desta correção de alias nunca foi rodado, e não tenho mais o binário daquela wave pra rodar agora: não sei dizer se algum diagnóstico novo apareceu em algum lugar do corpus.

Repositório Diagnósticos antes Diagnósticos depois Diag/1k antes Diag/1k depois
ruby-lsp 456 51 1240 137
tapioca 60 60 289 275
rails/rails 2364 2316 684 670
discourse/discourse 7959 7884 711 704
zammad/zammad 2834 2822 507 505

Medido no commit 7d3a781, mesma contagem de arquivos de antes.

O que sobrevive dessa comparação é a magnitude, não o conjunto: os repositórios sem tipagem quase não se moveram, deltas líquidos de rails -48, discourse -75, zammad -12, enquanto o repositório tipado caiu 405 (delta líquido também). Se o ganho tivesse vindo espalhado por igual, seria motivo de desconfiança: um fix genérico que reduz ruído em todo lugar por igual não prova mecanismo nenhum. Aqui o ganho ficou exatamente onde o mecanismo previu que ficaria.

Por que a correção curou o ruby-lsp e deixou o tapioca intacto, os dois com o mesmo tipo de alias? Isolei com um teste de 9 linhas, três variações do mesmo alias mudando só onde o alvo é declarado:

Caso Alvo declarado em Forma Resultado
A arquivo .rb bloco aninhado resolve
B arquivo .rbi bloco aninhado falha
C arquivo .rbi module X::Y compacto resolve

Em arquivo .rbi, declaração escrita como bloco aninhado indexa certo: o caminho completo existe no índice. Quem falha é a consulta, que procura pelo caminho qualificado num mapa onde esse nome foi registrado sem qualificação, e desiste antes de aplicar o refiltro que existe justamente pra isso. Só a forma compacta evita o problema. Em .rb as duas funcionam. As duas linhas reais, lado a lado:

# ruby-lsp, lib/ruby_lsp/utils.rb:7 (alias num .rb, curado)
Constant = LanguageServer::Protocol::Constant

# tapioca, sorbet/rbi/gems/ruby-lsp@0.26.10.rbi:2380 (alias num .rbi, não curado)
RubyLsp::Constant = LanguageServer::Protocol::Constant

A diferença entre os dois repositórios não é perfil de código, é um bug de indexação com endereço. E a consequência que interessa a quem lê: consertar isso não é aumentar cobertura, é derrubar falso positivo no repositório onde a ferramenta parece pior.

A reclassificação fechou, exaustiva, item por item, não amostra de 20. Isso substitui a leitura provisória de "não decidido" que eu tinha deixado aqui: no ruby-lsp, falso positivo caiu de 70% (7 de 10, amostra antiga) para 31% (16 de 51, contagem completa); no tapioca foi na direção contrária e virou o repositório com a maior proporção de falso positivo do corpus, 57% (34 de 60). srb tc segue em zero erro nos dois. A frase de duas horas atrás, "onde já existe Sorbet configurado e verde, o itaruby hoje não acrescenta, atrapalha", não serve mais como frase única pros dois repositórios: no ruby-lsp ficou mais fraca, falso positivo já é minoria (31%); no tapioca ficou mais forte, falso positivo é maioria clara (57%), e não foi estado terminal: a causa era o bug de indexação de .rbi acima, tinha endereço no código, e a correção entrou. O resultado está na seção "Wave 8": tapioca caiu de 17 para 1, teto documentado.

Wave 8

Wave 8 substitui a wave 7 como retrato atual: binário compilado do commit 5c5c8c3, 2026-08-26, mesmas árvores fixadas por sha de antes. Composição por código de diagnóstico, os seis repositórios do corpus:

Repositório Total Composição
ruby-lsp 30 24 E0104, 3 E0103, 3 E0001
tapioca 1 1 E0104
rails/rails 2302 2079 E0104, 222 E0101, 1 E0102
discourse/discourse 6217 6200 E0104, 12 E0101, 5 E0102
zammad/zammad 2821 2611 E0104, 207 E0101, 3 E0102
chatwoot/chatwoot 2915 2915 E0104

O chatwoot teve sua primeira medição na wave 7. Esta é a primeira comparação que existe pra ele: o diff de conjunto silenciou 2 diagnósticos, zero novos.

Dos 30 do ruby-lsp, 3 são E0001 (erro de sintaxe) em test/fixtures/syntax_diagnostics.rb e test/fixtures/multiple_invocations.rb , arquivos que são Ruby inválido de propósito, escritos pro próprio ruby-lsp testar diagnóstico de sintaxe. O checker está certo ali: não é acerto contra bug real, é reconhecer fixture proposital como o que é.

O tapioca, que esta página classificou como 60 diagnósticos, 0 bug real e 34 falso positivo (57%, o pior do corpus em proporção), caiu para 1. Não é tapioca limpo: sobrou 1, e é teto documentado. O autor diz que esse restante é um caso onde INSTANCE é valor, limitação conhecida e não defeito aberto.

Trajetória dos dois repositórios tipados, quatro rodadas de medição: ruby-lsp 456 -> 51 -> 31 -> 30; tapioca 60 -> 60 -> 17 -> 1.

Diff de conjunto entre wave 7 e wave 8, chaveado por caminho, linha, coluna e código:

Repositório Silenciados Novos
ruby-lsp 1 0
tapioca 16 0
rails/rails 14 0
discourse/discourse 1658 0
zammad/zammad 0 0
chatwoot/chatwoot 2 0

Zero diagnóstico novo nos seis repositórios do corpus. Dessa vez a afirmação está estabelecida por diff de conjunto, não por soma de totais, que é exatamente a diferença que a seção "Comparar totais não é comparar conjuntos" denuncia. Onde esta página já disse que essa afirmação não tinha sido estabelecida pra alguma wave, ela continua verdadeira pra aquela wave: é aqui, na wave 8, que ela passou a ser estabelecida.

A chave do diff ganhou uma coluna, e essa é uma correção da própria regra que esta página publica. A seção "Comparar totais não é comparar conjuntos", logo abaixo, diz que o diff deve ser chaveado por arquivo, linha e código, nunca por mensagem. Essa regra estava incompleta: arquivo:linha:código não distingue dois diagnósticos do mesmo código na mesma linha, e existem pares reais assim no discourse. Chavear sem coluna faz esses pares parecerem silenciados quando os dois continuam sendo acusados corretamente. A chave certa inclui a coluna: arquivo:linha:coluna:código.

A maior parte da queda do discourse, 1658 , é a raiz única já contada na seção abaixo: 1642 desses são o DB escrito com cbase. O resto, 16 , é guarda defined?.

Comparar totais não é comparar conjuntos

A wave 7 foi declarada sem drift pelo autor do checker. Remedi, 5c5c8c3, e o que parecia 4 falsos positivos isolados eram duas raízes com 1675 sítios ao todo, não quatro casos, uma delas com ordem de grandeza muito maior do que a primeira leitura sugeria.

Raiz 1, escrita de constante de topo com cbase: 1645 sítios em 3 repositórios.

Repositório Sítios Detalhe
discourse/discourse 1642 todos o mesmo nome, DB, escrito como ::DB = MiniSqlMultisiteConnection.instance num inicializador
rails/rails 2 ::DEFAULT_APP_FILES e ::DEFAULT_PLUGIN_FILES, cada um definido no topo do próprio arquivo de teste que o lê
Shopify/ruby-lsp 1 ::C2, a própria fixture const.rb do ruby-lsp, que escreve ::C2 = 42 na linha 2

Quando o código escreve uma constante de topo na forma com cbase, ::X = valor, essa constante nunca entrava na tabela de constantes de topo. Segundo lado do mesmo defeito: a checagem de existência chaveava o fallback de cbase pelo nome não trimado.

Raiz 2, guarda defined?: 30 sítios em 3 repositórios.

Repositório Sítios
rails/rails 12
discourse/discourse 16
chatwoot/chatwoot 2

Acusar o argumento de defined? nunca foi o problema: isso o checker já fazia certo. O falso positivo real era ler a mesma constante no ramo então do if ou do ternário, o ramo que só executa quando ela existe. Idioma de dependência opcional: AppBuilder, PluginBuilder, ENGINE_ROOT, Nokogiri::HTML5, MiniProfiler, RailsFailover, WebPush, Facebook::Messenger::Subscriptions. A regra que consertou tem controle dos dois lados: leitura no ramo senão continua sendo acusada, porque ali ela é insegura de verdade, e defined?(X) guardando leitura de Y continua acusando Y.

O maior nome único do corpus inteiro não era constante de gem faltando declaração: era um bug de indexação nosso. Uma linha de código do discourse gerava 1642 diagnósticos falsos.

As duas raízes foram consertadas na wave 8, binário compilado do commit 5c5c8c3, e verifiquei o conserto por rodada minha:

Constante Antes Depois
DB (discourse/discourse) 1642 0
DEFAULT_APP_FILES (rails/rails) 1 0
AppBuilder (rails/rails) 1 0

A prova do outro lado, que separa consertar de suprimir: as constantes que são de gem de verdade continuam sendo acusadas. ::Trilogy segue em 3 no rails/rails, sem mudança. Além disso, zero diagnóstico novo nos seis repositórios, e dessa vez essa afirmação está estabelecida por diff de conjunto, não por comparação de totais.

Vale publicar como verificação, não elogio: a atribuição que o autor mandou (cbase contra guarda defined?) soma exatamente com os totais que medi de forma independente, repositório por repositório:

Repositório Total independente cbase defined? Soma do autor
rails/rails 14 2 12 14
discourse/discourse 1658 1642 16 1658
chatwoot/chatwoot 2 0 2 2
Shopify/ruby-lsp 1 1 0 1

Duas contagens feitas por caminhos diferentes fecharam no mesmo número, repositório por repositório. É o oposto do caso da wave 7, onde a composição não fechava com o total e sobravam 3.

O achado de método que motivou essa checagem: comparar totais não é comparar conjuntos. No rails, o total ficou idêntico entre as duas waves, 2316 e 2316, e por dentro 5 diagnósticos saíram e 5 entraram: um delta líquido zero escondia 10 itens se movendo.

Pior: se o diff fosse chaveado pela mensagem em vez de por arquivo:linha:código, o rails teria acusado 187 mudanças, porque a wave 7 passou a imprimir o nome da constante na forma absoluta (::WebSocket::Driver em vez de WebSocket::Driver) e todo diagnóstico mudou de texto sem mudar de sítio. Número grande e falso é mais perigoso que número pequeno e certo.

O valor de medir por conta própria não é desconfiar do outro: é que delta líquido e reescrita cosmética escondem coisa em direções opostas.

A própria página cometeu esse erro na seção da correção de alias, mais acima: a passagem foi corrigida, não apagada.

Acompanhar o lançamento

O itaruby ainda não tem repositório público. Deixe seu email pra saber quando abrir.

Estado de cada envio conferido via CLI do host: gh pr view <n> --repo <owner/repo> no GitHub, glab mr view <n> --repo <owner/repo> no GitLab.