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.
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
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
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
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
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
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
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.
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
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
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
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
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
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
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.
Mensagem enviada. Retorno em breve.
Não foi possível enviar. Tente novamente.
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.