Skip to content

itaruby, not released yet

A Ruby type checker that has already found 15 bugs in Discourse, Rails, Zammad and ruby-lsp

itaruby is a Ruby type checker I am building. Instead of announcing what it promises to do, this page shows what it has already done: 14 submissions, 3 in discourse/discourse, 5 in rails/rails, 5 in zammad/zammad and 1 in Shopify/ruby-lsp, each bug verified by running it against the real file in the project before it was submitted. Discourse is a forum, Rails is a framework, Zammad is a helpdesk and ruby-lsp is a language server: different domains, no special-casing for any of them. Of the total, 7 have been accepted.

14

submissions

7

accepted

+130

lines

-29

lines

What it is

From the initial commit's own description: "inference-first Ruby type checker in Rust". It infers instead of requiring you to declare. 4 crates: itaruby (the CLI), itaruby_syntax, itaruby_semantic and itaruby_server (the language server). 98 .rs files, 15,075 lines of production Rust and 13,793 of test code, close to a test line for every line of production code. Incremental recomputation engine: salsa, the same one rust-analyzer uses. Beyond the command-line check, it runs as a language server: definition and hover at file:line:column.

The initial commit is from August 25, 2026, 18:18 UTC-3. The most recent one measured here is from August 26, 2026, 08:30 UTC-3: 26 commits over two days (8 on the first, 18 on the second), with no rewritten history. By the time the 14 submissions on this page went out, the checker was about 14 hours old.

It advances in waves, each tested against a mutation harness that deliberately breaks the code to prove the tests would catch it: wave 7 caught 16 mutants.

The funnel

13208 raw diagnostics, 14 submissions, across four triages from different moments, not one snapshot: rails, discourse and zammad in a round before wave 6; ruby-lsp in wave 6, during the Sorbet comparison. Most of the 13208 is noise: the same unresolved constant, flagged again on every reference in the code. The funnel from one number to the other is manual. I read every diagnostic, group the ones that share a cause, prove by execution whatever is left, and only then does it become a submission. None of the 13208 became a submission on its own.

What this number deliberately leaves out: twelve pull requests I opened on software-factory, a project that was part of this same run and the thing that got me unblocked enough to open the rest. They are excluded because the checker did not find them. Adding the two together would suggest itaruby surfaced twenty-six things, and "every submission proved by execution" would stop being true of half of them. I would rather publish the smaller number that is true all the way through.

Repository Files Diagnostics Round Submissions
rails/rails 3457 2364 before wave 6 5
discourse/discourse 11201 7959 before wave 6 3
zammad/zammad 5586 2834 before wave 6 5
Shopify/ruby-lsp 371 51 wave 6 (7d3a781) 1
Total 20615 13208 14

The rails, discourse and zammad counts are from the moment of triage, a round before wave 6 whose binary sha was never recorded. The ruby-lsp count is from wave 6, commit 7d3a781. None of these numbers change afterward: today's raw count, which is lower, is in the "Wave 8" section further down, sha 5c5c8c3. File counts were reconfirmed stable across rounds; scan time is not in this table because it would pair one round's time with another round's count, the same mistake this page calls out elsewhere. Time belongs in the "Throughput" section, which uses time and count from the same round.

Throughput

11201 Discourse files scanned in 8,02s. No configuration, no annotation, no setup step: the requirement to run is zero.

The submissions

#42898 merged +2 / -3

FIX: two crashes in the Lithium import script

Two crashes in the Lithium import script. A file.unlind that does not exist on Tempfile, swallowed by the surrounding rescue, leaking a temp file per imported avatar. And a link_post_uploads called on CookedPostProcessor, which has no such method: that one is not swallowed and takes down the entire attachment post-processing step.

script/import_scripts/lithium.rb

#42897 merged +22 / -1

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

The deprecated shim received force: as a kwarg and forwarded it positionally to a method that only accepts a positional argument. Any call blew up, including the simplest one, without force. Ships with the regression spec the class was missing.

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

#42896 merged +1 / -1

FIX: pass all five arguments to replace_internal_link in Discuz import

The private-message-link branch called replace_internal_link with 2 arguments, and the method requires 5. Every post whose body matched the regexp would crash the import with an ArgumentError.

script/import_scripts/discuz_x.rb

#58569 merged +1 / -1

Remove unreachable default for DerivedSecretKeyProvider#derive_key_from

The using: key_generator default points to a method that does not exist on the instance, so falling into it raises NameError. Unreachable: the method is private and the only caller passes the argument explicitly.

activerecord/lib/active_record/encryption/derived_secret_key_provider.rb

#58568 merged +27 / -9

Remove unused Active Record internals

Two dead code paths. An else branch that falls into expr, a method that does not exist anywhere in Arel, and a columns_hash that calls the connection without the argument it requires.

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

#58567 merged +0 / -4

Remove unused `request` helper from RouteSetTest

A helper that memoizes TestRequest.new with no arguments, while the constructor requires three. Any call would raise ArgumentError. Nobody calls it.

actionpack/test/controller/routing_test.rb

#58566 applied by commit +13 / -1

Fix QueryCacheRegistry#clear calling an undefined method

The method called @map.synchronize. The class's mutex is @mutex; @map is a Hash and does not respond to synchronize, so clearing the cache would blow up.

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

Finding applied in commit 22a02ee12e , not through this PR's branch.

#58565 open +13 / -1

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

call returned response, a local variable that does not exist. The value is in @response. Any request routed to this test app raised NameError.

actionpack/test/dispatch/routing/route_set_test.rb

#6328 open +6 / -1

Fix: Wrong number of arguments in create_knowledge_base_categories call

A call passed 4 positional arguments to a method that takes a hash, raising ArgumentError: wrong number of arguments (given 4, expected 1). The right shape is documented in the file's own docblock.

lib/fill_db.rb

#6329 open +15 / -1

Fix: Compare limit against @size, not undefined size

The comparison used size, which does not exist in scope; only @size exists, used on four other lines. Raises NameError the first time anyone chains .limit before .as_batches.

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

#6330 open +0 / -4

Remove unused OnlineNotificationPolicy#relation

The method called a name that does not exist and nothing reaches it. Proven from pundit's own source, whose only dynamic dispatch is the action name plus a question mark.

app/policies/online_notification_policy.rb

#6331 open +14 / -1

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

The method called method_object instead of authentication_method_object, which exists and is used correctly on two other lines in the same class.

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

#6332 open +14 / -1

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

One line forgot the job. receiver, so the bare identifier raises NameError; the neighboring lines in the same method already used the right receiver.

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

#4197 open +2 / -0

Fix NameError raised by RubyIndexer's abstract method guard

A raise AbstractMethodInvokedError inside RubyIndexer::Entry::Member points to a constant that only exists as RubyLsp::AbstractMethodInvokedError, a sibling module, not an ancestor: the guard raises NameError instead of the intended error. It is the only one of the 17 sites of this raise in the repository outside lib/ruby_lsp/; the other 16 resolve correctly. A latent defensive guard, not reachable today: both concrete subclasses of Member already implement signatures.

lib/ruby_indexer/lib/ruby_indexer/entry.rb

The gates

Once the PR is open, what decides whether it moves forward is not the patch. Three gates, measured, not about the tool.

Shopify's CLA wants a human signature plus a comment that matches the workflow's regex. My first comment, "CLA signed.", passed the workflow's if, the job concluded success, and the log said Comment does not match CLA pattern: a green run does not mean the gate is cleared. What worked was "I have signed the CLA."

action_required is first-contribution workflow approval, per repository: it blocks zammad (5 PRs) and ruby-lsp (1). Rails and Discourse do not have this gate.

On GitLab, quota and membership block even the push: that is why 6 ready MRs remain unopened.

None of the three gates have anything to do with patch quality, and none of them show up before the PR is open.

How each finding was verified

A checker's finding is a hypothesis, not a bug. Every item on this list went through two-sided proof against the project's real file: run and fail before the fix, run and pass after. Reproducing it in a separate file does not count, because that tests my understanding of the code, not the project's behavior.

For dead-code removals the bar is different: prove that nothing reaches that code. That means scanning the whole repository, including dynamic dispatch (send, public_send, respond_to?, method_missing, delegate) and the class's subclasses, not just direct callers. That last part almost slipped past me on #58569 in Rails: the class has two subclasses, and if either one had called the method the story would have flipped from cleanup to real bug.

The shape of the PRs did not come from personal taste either. It came from measuring what the project already accepts. I first counted Rails's last hundred merges, found no dead-code removal and concluded there was no precedent. A hundred Rails merges cover two weeks: searching the full history turned up a twelve-PR removal campaign merged a month earlier. A short sample does not measure a rare category.

Acceptance is not always the same thing either. At Zammad, real development happens on an internal GitLab; GitHub is a mirror, and native merging there has been stalled since 2021. When a maintainer accepts a fix, they apply the patch as a manual commit and close the PR citing the hash. That is why the list distinguishes merged from applied by commit: two ways of saying yes, without hiding which one it was.

Proof, not adjective

Rails #58566 is the most complete case in this section: closed without a merge, with a single comment, from byroot (Jean Boussier, Rails core): "If it's never called, let's just remove the method entirely." That was not a rejection of the finding, it was a rejection of the form: I sent a fix for a method that was dead, when removal was the right call. I had classified the method as reachable without proving it. The proof, done afterward: @thread_query_caches appears in exactly two places in the repository, the instantiation and one call to compute_if_absent; .clear is never called. And the method's body called synchronize on an object that does not have that method, so it would raise an error if anyone called it: dead and broken, and I sent a fix instead of a removal. The outcome is the applied by commit state: byroot removed the method himself in commit 22a02ee12e , literal message:

Get rid of `QueryCacheRegistry#clear`

The method is dead code.

With a co-author credit for the finding. It went in, just not through my branch.

On Rails #58568 , the method itaruby flagged had zero test coverage in the suite. The first proof that the code was dead came from a grep 'def expr' against the repository, which would never find an attr_accessor :expr. The right instrument was following the inheritance chain, not grepping for the method name. The mistake was caught and fixed before the PR shipped, and the missing test went in with it.

Discourse #42898 shows the same kind of failure with opposite consequences, in the same Lithium importer file: Two crashes in the Lithium import script. A file.unlind that does not exist on Tempfile, swallowed by the surrounding rescue, leaking a temp file per imported avatar. And a link_post_uploads called on CookedPostProcessor, which has no such method: that one is not swallowed and takes down the entire attachment post-processing step.

Three Zammad findings (#6329, #6331, #6332) shipped with a regression spec the suite did not have. In all three, the production fix is one line; the rest of the diff, thirteen to fourteen lines, is test.

Where Sorbet wins

What is proven: itaruby does not require anything, and it reads inline RBS if you have it. No configuration, no adoption step. The 14 bugs on this page came out of 3 repositories with zero sorbet/, zero .rbi and zero extend T::Sig: 13 submissions, 7 accepted. The remaining repository in the corpus, ruby-lsp, runs Sorbet and has its own bug, in the next section.

Scope note: from a Sorbet sig block, itaruby picks up the return type; it does not pick up the parameter type. The sig already speaks return, it still has to speak parameter. This matters less than it looks, because the corpus barely uses sig at all. I tested it: a class with extend T::Sig and sig { params(n: Integer).returns(String) }, called passing a String where the signature wants an Integer parameter, produces no diagnostic at all: that is exactly the side that is still missing. But rails, discourse and zammad have zero sig. ruby-lsp, the most typed repository in the corpus, has zero real production sig: the leftover sig text in the repository is documentation comments and spec fixtures, not annotation. tapioca has one, in lib/.

There is a sharper reason behind the zero above, and it is Sorbet's own documented behaviour rather than an opinion about it. A file with no sigil defaults to # typed: false, and at that level Sorbet reports only syntax, constant resolution and sig correctness. Its manual is explicit that the level "only prevents that method's body from being typechecked". So on a repository with no annotations, Sorbet is not failing to find these bugs: it is never asked to look. That is the whole gap itaruby aims at, and it is also why the comparison is about reach, not about speed. sorbet.org/docs/static

What changes the read: itaruby reads inline RBS. It is the annotation the tapioca team itself started using in ruby-lsp's production code, instead of sig: a one-line comment above the method, in the #: (Type) -> Type format. I tested the same method in the three annotation styles:

# no annotation
def format(n)
  n.to_s
end
format("7")
# itaruby: silence (correct, nothing to go on)

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

# with Sorbet sig
extend T::Sig
sig { params(n: Integer).returns(String) }
def format(n)
  n.to_s
end
format("7")
# itaruby: silence

The cost contrast is the strongest data point in this section. For Sorbet to see the dependencies, ruby-lsp and tapioca keep 754,669 lines of vendored .rbi (246,830 + 507,839), plus a runtime gem, plus a sigil per file, plus a generation step. Inline RBS asks for none of that: it is a comment above the method, zero vendored files, zero runtime gem, zero generation.

RBS for core/stdlib, in a separate file, not inline: not consumed. RBS for a gem via rbs collection, also in a separate file: not consumed. RBI is not missing coverage, it is a lookup problem: a declaration inside .rbi written as a nested block indexes correctly, the full path exists in the index; the query is what fails, looking up the qualified path in a map where that name was registered unqualified, and giving up before applying the refilter that exists exactly for this. Only the compact module X::Y form avoids the problem. It is the same bug that explains why the alias fix cured ruby-lsp and not tapioca, in the "The cycle" section further down.

This table changed since this page's original publication: ruby-lsp went from worst to best in the corpus after a fix in itaruby itself, not in the scanned code. The mechanism and the verified prediction are in "The cycle" section, right after "Head-to-head with Sorbet".

Repository Diagnostics / 1,000 files
ruby-lsp 137
tapioca 275
zammad/zammad 505
rails/rails 670
discourse/discourse 704

What holds up, before and after the fix: neither "worst" nor "best" measured typing. They measured how much repository-specific parsing bug itaruby still had. The contract that survives is not "works better with more types": it is no requirement at all, and it reads inline RBS when the code already has it.

Head-to-head with Sorbet

The hardest test I can run: pit itaruby against Sorbet on the only two repositories in the exploratory corpus that have genuinely adopted Sorbet. Global srb tc, no bundle install, on both:

Tool Repository Files Time Diagnostics
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

All four time measurements are from the same session, same machine, median of three runs each. Sorbet is 2,9x faster on ruby-lsp and 2,7x on tapioca. Wall-clock time is not comparable across different sessions on this page: in an earlier remeasurement, ita check on ruby-lsp took 445ms; in this one, 496ms, with no binary change between the two. Session-to-session variance of 10% to 20% on this machine, which rules out any reading of "the fix cost time" without a before/after measurement taken in the same round.

File counts differ because srb applies the project's sorbet/config --ignore list (fixtures, vendor) and counts .rbi files alongside them; itaruby counts raw .rb files and has no equivalent ignore list.

Sorbet is green on both: literal output No errors! Great job.. itaruby, on the same code, produces 111 raw diagnostics. I did not find 111 bugs. I classified both full sets, item by item, not a sample:

Repository Diagnostics Real bug False positive Genuine noise
ruby-lsp 51 1 16 (31%) 34 (67%)
tapioca 60 0 34 (57%) 26 (43%)

srb tc remains at zero errors on both.

One number improved and the other got worse, and both deserve equal billing: in the earlier sample of 10 for ruby-lsp above, 7 of 10 were false positives (70%); in the exhaustive classification of 51 , that fell to 31%. tapioca went the other way: it became the repository with the highest false-positive share in the corpus, 57%. It was not a terminal state: the cause had a name, an address in the code, and the fix went in. The result is in the "Wave 8" section further down: tapioca dropped from 17 to 1, a documented ceiling, not a reclassification to zero.

I rechecked the original 10 ruby-lsp items against the exhaustive classification: 2 disappeared (they were false positives, resolved by the alias fix), 8 remain, and 1 of those changed classification because the earlier evidence had checked the wrong directory. A measurement mistake on our side, not a tool behavior.

False positives have a cause. The first time I measured that cause, the number came out wrong; the corrected version is below. I reclassified the 41 constant warnings left in ruby-lsp after the alias fix, by mechanism:

Mechanism Warnings What it is
Declared as T.let in already-committed RBI 11 Test::Unit::* (7), LanguageServer::Protocol::Constant::* (3), RuboCop::Version::STRING (1)
Exists in on-disk core/stdlib RBS 9 Minitest::Spec (7), OptionParser and one from Gem::
Deliberate fixture in the repository itself 11 fake constants ruby-lsp's own tests define on purpose
Rest 10 includes the entry.rb bug that became PR #4197

The two type-info levers, already-committed RBI and RBS from core/stdlib on disk, are close to the same size: 11 against 9. Neither one alone resolves half of the 41. There is no single saving type source here: it is always both together, and even both together do not cover everything.

11 of the 41 are fixture: fake constants that ruby-lsp itself defines on purpose to test its own syntax diagnostic detector. The project's sorbet/config excludes that folder via --ignore; itaruby does not honor that --ignore. That is 27% of what is left being itaruby reporting on a file the project's own maintainers already declared out of scope, a concrete and verifiable limitation.

About the method, because it is the foundation of every number on this page: the first measurement of this mechanism had two mistakes. The original denominator, 160 unique unresolved constants, dropped to 29 as soon as the alias-caused ones left the list with the fix in commit 7d3a781. And the original numerator, 78, was also wrong: the regex matched a constant by name suffix, not full path, so names like A and C counted because they happened to end the same way as a real T.let declaration somewhere else in the file. Both mistakes only surfaced when I swapped grep for a direct query against the RBS API, loading the 61 stdlib libraries and asking by type name instead of by text. The correct number, after that, is the one in the table above: 11 of 41, 27%. Swapping grep for the API fixed the suffix bug, but was not enough on its own: the query accepted a shortcut, a top-level name existing already counted as a match, so Gem existing was enough for Gem::DefaultUserInteraction to pass, when that constant's only real occurrence in the entire RBS is a comment. The full lesson is not "API instead of text": it is that the question has to be about the exact path, because accepting a prefix is suffix grep dressed up as an API.

The 7 ruby-lsp diagnostics that are not constant warnings have a complete partition, verified against the source, not a sample:

Mechanism Diagnostics Where
Flow narrowing 3 declaration_listener.rb:512 and :515 (is_a? in a ternary), code_action_resolve.rb:435 (case without else)
Generic [T] in the signature 3 erb_document_test.rb:161, ruby_document_test.rb:906 and :1010 (all in cache_set)
Cast #: as untyped ignored 1 addon_test.rb:149

The code_action_resolve.rb:435 case deserves its own line: the #: as !nil cast stuck to the end on line 417 is read correctly, that is confirmed. What is missing is narrowing the type before the cast is reached: lines 410 to 417 are a case @code_action[:title] with three when branches and no else, and without that branch the type left over is String | nil. The cast is not ignored: it is applied to a type that already arrived wrong.

Not every row in the table is a bug. Narrowing on a case without else is a family declared out of scope by decision, a chosen boundary, not a flaw waiting to be found. The generic [T] treated as a fixed type is a real bug, and it is the third time this mechanism has shown up in this investigation: it already has a prioritized fix. The #: as untyped cast being ignored remains unfixed.

The framing that closes this section: the value is in untyped code. Across the 3 untyped repositories, the funnel produced 14 real bugs, 13 submissions, 7 accepted. Across the two repositories with Sorbet, Sorbet is already green and itaruby produces mostly false positives. That is scope, not defeat: where Sorbet is already configured and green, itaruby today does not add, it gets in the way.

The one real bug itaruby found across the two repositories with Sorbet, out of 111 raw diagnostics in those two repositories. Not a win, it is the exception that proves the scope. The bug: a raise AbstractMethodInvokedError inside module RubyIndexer when the constant only exists as RubyLsp::AbstractMethodInvokedError, a sibling module, not an ancestor. The raise never resolves; I reproduced the structure separately and confirmed it: it raises NameError. Of the 17 sites of this same raise in the repository, 16 are in lib/ruby_lsp/ and resolve correctly; only this one is outside the right namespace. It is a latent defensive guard, not a production crash, because both concrete subclasses already implement the abstract method. The PR is open, #4197 , on ruby-lsp: 2 lines added, none removed. Contributions from outside Shopify go through two gates before shipping: the company's CLA and first-contribution workflow approval, both pending now.

The most interesting data point in this section: this bug was in a repository that runs Sorbet in CI, green, and Sorbet does not catch it because the project's own sorbet/config --enable-experimental-rbs-comments flag silences this kind of guard. It is not "itaruby is stronger than Sorbet": it is a configuration flag that created a blind spot, and a tool that does not read the project's configuration went right through it without knowing it existed. That is the real argument for running two different tools instead of one.

Overlap between the two real-error sets: zero. That is not a finding, it is an absence of data: Sorbet's set in the two repositories is empty, so there is no intersection to measure. Not a win, it is zero degrees of freedom in the comparison.

The cycle

The rate table above showed ruby-lsp at 1240 diagnostics per thousand files, the worst in the corpus, while being the most typed repository. Counterintuitive enough to be worth investigating the cause instead of taking the number at face value.

The cause has a name and a line, in lib/ruby_lsp/utils.rb:6-7:

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

itaruby did not follow this kind of constant alias; every reference to Interface::X or Constant::X in the rest of the file turned into an unresolved-constant diagnostic. That accounted for 405 of the 449 constant warnings in the repository.

Prediction made before touching the code: about 405 warnings should disappear with the fix. After the fix, commit 7d3a781: 408 disappeared. Prediction error of 0,7%.

That conclusion came from comparing totals per repository, before and after, and comparing totals does not establish a claim about a set: I made this exact mistake myself later, in wave 7, where rails's total came out identical between two waves, 2316 against 2316, hiding 5 diagnostics leaving and 5 entering. The set-level diff for this alias fix was never run, and I no longer have that wave's binary to run it now: I cannot say whether any new diagnostic showed up anywhere in the corpus.

Repository Diagnostics before Diagnostics after Diag/1k before Diag/1k after
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

Measured at commit 7d3a781, same file count as before.

What survives this comparison is magnitude, not the set: the untyped repositories barely moved, net deltas of rails -48, discourse -75, zammad -12, while the typed repository dropped 405 (also a net delta). If the gain had come spread out evenly, that would be cause for suspicion: a generic fix that reduces noise everywhere equally proves no mechanism at all. Here the gain landed exactly where the mechanism predicted it would.

Why did the fix cure ruby-lsp and leave tapioca untouched, when both have the same kind of alias? I isolated it with a 9-line test, three variations of the same alias, changing only where the target is declared:

Case Target declared in Form Result
A .rb file nested block resolves
B .rbi file nested block fails
C .rbi file compact module X::Y resolves

In a .rbi file, a declaration written as a nested block indexes correctly: the full path exists in the index. The query is what fails, looking up the qualified path in a map where that name was registered unqualified, and giving up before applying the refilter that exists exactly for this. Only the compact form avoids the problem. In .rb both work. The two real lines, side by side:

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

# tapioca, sorbet/rbi/gems/ruby-lsp@0.26.10.rbi:2380 (alias in .rbi, not cured)
RubyLsp::Constant = LanguageServer::Protocol::Constant

The difference between the two repositories is not a code profile, it is a lookup bug with an address. And the consequence that matters to a reader: fixing this is not increasing coverage, it is knocking down false positives in the repository where the tool looks worst.

The reclassification closed, exhaustive, item by item, not a sample of 20. This replaces the provisional "undecided" reading I had left here: on ruby-lsp, false positives dropped from 70% (7 of 10, old sample) to 31% (16 of 51, full count); on tapioca it went the other way and it became the repository with the highest false-positive share in the corpus, 57% (34 of 60). srb tc remains at zero errors on both. The sentence from two hours earlier, "where Sorbet is already configured and green, itaruby today does not add, it gets in the way", no longer holds as a single sentence for both repositories: on ruby-lsp it got weaker, false positives are already a minority (31%); on tapioca it got stronger, false positives are a clear majority (57%), and it was not a terminal state: the cause was the .rbi lookup bug above, it had an address in the code, and the fix went in. The result is in the "Wave 8" section: tapioca dropped from 17 to 1, a documented ceiling.

Wave 8

Wave 8 replaces wave 7 as the current snapshot: binary compiled from commit 5c5c8c3, 2026-08-26, same trees pinned by sha as before. Composition by diagnostic code, all six repositories in the corpus:

Repository Total Composition
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

chatwoot had its first measurement in wave 7. This is the first comparison that exists for it: the set diff silenced 2 diagnostics, zero new ones.

Of ruby-lsp's 30, 3 are E0001 (syntax error) in test/fixtures/syntax_diagnostics.rb and test/fixtures/multiple_invocations.rb , files that are invalid Ruby on purpose, written for ruby-lsp itself to test its own syntax diagnostic. The checker is right there: it is not a hit against a real bug, it is recognizing a deliberate fixture for what it is.

tapioca, which this page classified as 60 diagnostics, 0 real bug and 34 false positives (57%, the worst share in the corpus), dropped to 1. tapioca is not clean: 1 remains, and it is a documented ceiling. The author says this remaining case is a case where INSTANCE is a value, a known limitation, not an open defect.

Trajectory of the two typed repositories, four measurement rounds: ruby-lsp 456 -> 51 -> 31 -> 30; tapioca 60 -> 60 -> 17 -> 1.

Set diff between wave 7 and wave 8, keyed by path, line, column and code:

Repository Silenced New
ruby-lsp 1 0
tapioca 16 0
rails/rails 14 0
discourse/discourse 1658 0
zammad/zammad 0 0
chatwoot/chatwoot 2 0

Zero new diagnostics across the six repositories in the corpus. This time the claim is established by a set diff, not by a totals comparison, which is exactly the distinction the "Comparing totals is not comparing sets" section calls out. Where this page already said that claim had not been established for a given wave, it still holds for that wave: this is where, in wave 8, it became established.

The diff key gained a column, and this is a correction to this page's own published rule. The "Comparing totals is not comparing sets" section, further down, says the diff must be keyed by file, line and code, never by message. That rule was incomplete: file:line:code does not distinguish two diagnostics with the same code on the same line, and there are real pairs like that in discourse. Keying without a column makes those pairs look silenced when both are still correctly flagged. The correct key includes the column: file:line:column:code.

Most of discourse's drop, 1658 , is the single root cause already covered in the section below: 1642 of those are DB written with cbase. The rest, 16 , is the defined? guard.

Comparing totals is not comparing sets

Wave 7 was declared drift-free by the checker's author. I remeasured it, 5c5c8c3, and what looked like 4 isolated false positives were two root causes with 1675 sites total, not four separate cases, one of them an order of magnitude bigger than the first read suggested.

Root 1, top-level constant write with cbase: 1645 sites across 3 repositories.

Repository Sites Detail
discourse/discourse 1642 all the same name, DB, written as ::DB = MiniSqlMultisiteConnection.instance in an initializer
rails/rails 2 ::DEFAULT_APP_FILES and ::DEFAULT_PLUGIN_FILES, each defined at the top of the very test file that reads it
Shopify/ruby-lsp 1 ::C2, ruby-lsp's own const.rb fixture, which writes ::C2 = 42 on line 2

When code writes a top-level constant using cbase form, ::X = value, that constant never made it into the top-level constant table. The other side of the same defect: the existence check keyed the cbase fallback off the untrimmed name.

Root 2, defined? guard: 30 sites across 3 repositories.

Repository Sites
rails/rails 12
discourse/discourse 16
chatwoot/chatwoot 2

Flagging defined?'s argument was never the problem: the checker already got that right. The real false positive was reading that same constant in the if or ternary's then branch, the branch that only runs when it exists. The rule that fixed it controls both sides: a read in the else branch is still flagged, because there it is genuinely unsafe, and defined?(X) guarding a read of Y still flags Y.

The single biggest name in the whole corpus was not a gem constant missing a declaration: it was a lookup bug on our side. One line of discourse code produced 1642 false diagnostics.

Both roots were fixed at the source in wave 8, binary compiled from commit 5c5c8c3, and I verified the fix myself:

Constant Before After
DB (discourse/discourse) 1642 0
DEFAULT_APP_FILES (rails/rails) 1 0
AppBuilder (rails/rails) 1 0

The proof for the other side, the one that separates fixing from suppressing: constants that are genuinely gem constants keep getting flagged. ::Trilogy stays at 3 in rails/rails, unchanged. On top of that, zero new diagnostics across the six repositories, and this time that claim is established by a set diff, not a totals comparison.

Worth publishing as verification, not praise: the attribution the author sent (cbase versus the defined? guard) sums up exactly to the totals I measured independently, repository by repository:

Repository Independent total cbase defined? Author's sum
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

Two counts taken through different paths landed on the same number, repository by repository. It is the opposite of the wave 7 case, where the composition did not add up to the total and 3 were left unaccounted for.

The method finding that triggered this check: comparing totals is not comparing sets. On rails, the total came out identical between the two waves, 2316 and 2316, and underneath, 5 diagnostics left and 5 entered: a net-zero delta was hiding 10 items moving.

Worse: if the diff had been keyed by message instead of by file:line:code, rails would have flagged 187 changes, because wave 7 started printing the constant name in absolute form (::WebSocket::Driver instead of WebSocket::Driver) and every diagnostic's text changed without its site changing. A large false number is more dangerous than a small correct one.

The value of measuring things yourself is not distrusting the other side: it is that a net-zero delta and a cosmetic rewrite hide things in opposite directions.

This page itself made this exact mistake in the alias-fix section, further up: the passage was corrected, not deleted.

Follow the launch

itaruby does not have a public repository yet. Leave your email to hear when it opens.

Each submission's state checked via the host CLI: gh pr view <n> --repo <owner/repo> on GitHub, glab mr view <n> --repo <owner/repo> on GitLab.