Skip to content

Real-world Rails project survey (2026-05-15)

A scratch-record of running rigor check against four real-world Rails codebases under v0.1.5-in-progress (commit 642cf28 and onwards). The goal is twofold:

  1. Measure the analyzer’s reach on Rails-shaped code that was never in the spec corpus — wall time, peak memory, diagnostic mix, plugin coverage gaps.
  2. Surface engine bugs and ergonomic gaps that don’t show up on synthetic fixtures or rigor’s own lib/. Each project rotates the stress on different parts of the engine (size, depth of metaprogramming, gem surface, monkey-patch density).

Methodology and target sizes:

ProjectStatusFilesNotes
Redminelanded347Smallest of the four — used as the engine-bug shakedown.
Discourselanded1,804Forum platform; heavy plugin / hook surface.
Mastodonlanded1,302ActivityPub social server; ActiveJob / Sidekiq heavy.
GitLab FOSSlanded11,130Largest of the original four — Rails monolith with deep metaprogramming.
Foremlanded (round 2)1,250DEV.to community platform.
Soliduslanded (round 2)1,914E-commerce monorepo (core + api + backend + admin + promotions + legacy_promotions).
Chatwootlanded (round 2)802Customer-support platform.
Canvas LMSlanded (round 2)3,248Instructure’s LMS; app + lib + gems (in-tree gems).
OpenProjectlanded (round 2)6,817Project-management platform; app + lib + modules (sub-engines).
Loomiolanded (round 3)563Collaboration / group-decision Rails app.
Publifylanded (round 3)15 (app shell only)Rails app shell; real code lives in the external publify_core gem.
Diasporalanded (round 3)371Federated social-network Rails app.
Dependabot Corelanded (round 3)1,089 (across 19 ecosystem dirs)Not Rails — Ruby SDK / library for dependency-update automation. Useful baseline for “how does the analyser behave on non-Rails idiomatic Ruby with heavy Bundler-internal usage?“
tDiary Corelanded (round 3)244 (lib + plugin + entry scripts)Not Rails — pre-Rails-era Ruby blogging engine. Useful baseline for “how does the analyser behave on classic Ruby idioms without any ActiveSupport in scope?”

Each pass runs:

Terminal window
nix --extra-experimental-features 'nix-command flakes' develop \
--command bundle exec exe/rigor check --format=json \
/tmp/<project>/app /tmp/<project>/lib > <project>.json

Sequential / warm-cache runs are the primary measurement; pool mode (--workers=N) is run for equivalence-check and timing comparison only. “Warm” means the per-project .rigor/ cache has been built by an earlier sequential pass against the same revision.


Source: https://github.com/redmine/redmine (depth-1 shallow clone, no specific revision pinned — the master HEAD on 2026-05-15).

Two snapshots, before and after the two engine improvements this survey triggered (commit 642cf28):

MetricBefore 642cf28After 642cf28Δ
Files scanned347347
Wall time (warm cache)2.82 s2.82 s
Wall time (cold cache)3.77 s3.77 s
Peak RSS266 MB~268 MB
Total diagnostics389343−46
Errors334288−46
Warnings5555

The 46-diagnostic drop is one rule family only: call.possible-nil-receiver 69 → 23. No other rule’s count moved; no new diagnostic was introduced. (Pool-mode comparison below.)

CountRule
243call.undefined-method
23call.possible-nil-receiver
24flow.always-truthy-condition
22(parse errors — Rails-generator template files; see below)
17flow.dead-assignment
14def.ivar-write-mismatch

After the deep-shareability fixes in 642cf28, --workers=4 on Redmine produces a diagnostic stream byte-identical to sequential (389 == 389 on the pre-narrowing snapshot; 343 == 343 after). Timing:

ModeWall timePeak RSS
Sequential (warm)2.82 s266 MB
Pool workers=4 (warm)3.70 s948 MB
Pool workers=8 (warm)8.39 s1.60 GB

Pool mode remains the wrong default on a project this size: per-Ractor env build + Marshal restore dominate the parallel speedup of ~10 ms-per-file inference. This matches the ADR-15 OQ1 caveat. The pool path is now CORRECT (no IsolationErrors, byte-identical output); the question of when it becomes FASTER is unchanged.

call.undefined-method — Rails-extension long tail

Section titled “call.undefined-method — Rails-extension long tail”

Almost all 243 instances are ActiveSupport / Rails core extensions absent from stdlib RBS — not actual bugs:

CountSelector / receiverSource
75String#html_safe, "…".html_safeActiveSupport::SafeBuffer mixin
24Array.wrap(...)ActiveSupport::CoreExtensions::Array::Wrapper
12Time.parsestdlib timerequire 'time' missing in user code
6+Hash#deep_dup, Hash#symbolize_keys etc.ActiveSupport Hash core_ext
6+Integer#days, #minute, #day, #year, #secondsActiveSupport::Duration
4String#constantizeActiveSupport String core_ext
2+String#underscore, #demodulize, #to_hoursActiveSupport String core_ext

A dedicated rigor-activesupport-core-ext plugin is the planned remediation. The pre-evaluation-of-monkey-patches config knob is the complementary remediation for project-private patches. Both are recorded as future directions in the agent-side memory store (project_activesupport_core_ext_plugin) — no committed milestone.

Findings with bug-finding value (Rails-independent)

Section titled “Findings with bug-finding value (Rails-independent)”
  • call.possible-nil-receiver 23 (post-fix). Most remaining cases appear to be loop-iteration patterns or guarded-then-rebound forms not yet covered by narrowing. Example pattern still flagged:

    if cond
    val = compute # nullable
    next if val.nil?
    val.attr # narrowing across `next if` not yet flow-tracked
    end
  • def.ivar-write-mismatch 14. Same ivar bound to incompatible types on different assignments. User#@projects_by_roleNilClass/Hash, Wiki#@page_found_with_redirectFalseClass/TrueClass, etc. Ruby idiom for memoized-vs-not-yet-computed state — usable as a warning but borderline noisy.

  • flow.dead-assignment 17. Unread local-variable assignments in several _controller.rb actions and a few helpers. Worth surfacing upstream: e.g. app/controllers/issues_controller.rb:401 assigns journal in bulk_update and never reads it.

  • flow.always-truthy-condition 24. Constant-folded branches. Example: app/controllers/repositories_controller.rb:427-429.

Engine improvements driven by Redmine (already landed)

Section titled “Engine improvements driven by Redmine (already landed)”

642cf28 — “Bank Redmine real-world findings: pool shareability + assignment narrowing”:

  1. Pool mode (Phase 4b.x follow-up): three deep-shareability gaps surfaced by worker-Ractor IsolationErrors on this project:

    • NumericCatalog#@catalog (deep-share the YAML graph)
    • Type::Refined::CANONICAL_NAMES (nested-Array keys)
    • Builtins::RegexRefinement::RULES (nested-Array rows)
  2. if cond && (var = expr) narrowing. Four new write-node cases in Inference::Narrowing#analyse (LocalVariableWriteNode + ivar/cvar/global). On Redmine this dropped call.possible-nil-receiver from 69 → 23 with zero regressions.

IDItem
O1active_support/core_ext plugin + config-side monkey-patch pre-evaluation. (Memory: project_activesupport_core_ext_plugin.)
O2Macro-template expansion (ERB .rb templates, class_eval <<~RUBY heredocs) — would also recover the 22 rb-with-erb parse errors in lib/generators/redmine_plugin_model/templates/migration.rb. (Memory: project_macro_template_expansion.)
O3next if x.nil? / return if x.nil? flow-tracked narrowing across early-exit guards in the same block.

Bundler-aware analysis — exploring O4 today

Section titled “Bundler-aware analysis — exploring O4 today”

The natural follow-up to the survey: can rigor analyse a project with the project’s gems also in scope — so User.where(...) resolves to ActiveRecord’s where, Sidekiq::Worker#perform matches Sidekiq’s RBS, and so on?

Docker-based bundle install demonstration (Mastodon, 2026-05-15)

Section titled “Docker-based bundle install demonstration (Mastodon, 2026-05-15)”

Following up on the bundle-install hurdles, we ran a real bundle install of Mastodon inside ruby:4.0.3-slim-trixie (Docker). Worked end to end — apt deps installed (libpq-dev, libidn-dev, libxml2-dev, libxslt-dev, libvips-dev, libjemalloc-dev, git, etc.; one package rename: Trixie ships libpcre2-dev, not libpcre3-dev), Bundler 4.0.11 installed, and bundle install --jobs=8 resolved all 343 gems and unpacked them under /tmp/mastodon-bundle (271 MB).

Critical finding: of the 343 installed gems, only 10 ship a sig/ directory in their gem package (~3%). The full list:

prism, aws-sdk-s3, aws-sdk-kms, aws-sdk-core,
playwright-ruby-client, mutex_m, webrick, base64,
stoplight, ffi

All of pg, mysql2, nokogiri, bcrypt, redis, idn-ruby, actionpack, activerecord, activesupport, sidekiq, devise, pundit, kaminari, puma, and the rest of the popular Rails / auth / cache / queue family ship NO sig/ in their gem package. gem_rbs_collection is the de facto source of typed contracts for those gems.

This data point reinforces the design decision behind commit f9b94d2: shipping vendored RBS for the half-dozen most-common native-extension gems inside rigor itself is the realistic “out-of-the-box” path. End users would otherwise need to wire gem_rbs_collection into their signature_paths: per-gem-version manually — and that wiring hits O7 (see below) anyway.

Open item O7 (RBS env-build cliff) is more severe than initially characterised. Adding even ONE gem-shipped sig/ directory (specifically: prism’s sig, ~19 .rbs files) on top of rigor’s own loaded sigs causes RBS::Environment.from_loader to hang for >5 minutes (killed). The Diaspora 16-paths-cold experiment (11+ min) is the same symptom but at higher path count. Plausibly: prism’s sig declares classes that overlap with rigor’s pre-loaded prism RBS (rigor uses prism internally), and the resolver explodes on duplicate-class graph traversal. Until O7 is fixed, the gem-shipped sig path is not usable even when bundle install succeeds.

Latest status (after vendored RBS landed in commit f9b94d2)

Section titled “Latest status (after vendored RBS landed in commit f9b94d2)”

Rigor now ships built-in RBS stubs for six common native-extension gems (pg / mysql2 / nokogiri / bcrypt / redis / idn-ruby). The stubs live under data/vendored_gem_sigs/<gem>/ and load automatically — no signature_paths: configuration required. Out of the box, RBS classes available rose from 1,134 to 1,273 (+139).

Quantitative impact across the 14 survey projects:

ProjectBaselinewith O1 v2+ vendored stubs
Redmine389157157
Discourse1,439423429
Mastodon521124124
GitLab FOSS2,982489491
Forem691146149
Solidus5284242
Chatwoot3001921
Canvas LMS3,2961,4961,506
OpenProject2,356175176
Loomio2076363
Publify000
Diaspora6555
Dependabot Core2055858
tDiary Core111106106
Total13,0903,3033,327

The vendored stubs produce a +24 net increase — the precision / coverage trade-off where added RBS catches both real new issues AND incomplete-stub false positives. Most projects see +0; the small bumps cluster on Canvas LMS (+10), Discourse (+6), Forem (+3), GitLab FOSS (+2), and Chatwoot (+2), all of which exercise gem APIs absent from the vendored 4.2 / 1.11 snapshots. Closing those gaps is incremental via the per-gem <gem>_extras.rbs files (nokogiri_html5.rbs and redis_extras.rbs are the first two).

The bigger-picture wins:

  • Mysql2::Client / PG::Connection / Nokogiri::XML::Node receivers stop being Dynamic[top] — every call site now has precise dispatch.
  • Mastodon’s idn-ruby blocker is moot for static analysis. Users no longer need libidn system-installed to get useful Mastodon analysis.
  • Out-of-the-box config: end users no longer have to wire each gem’s RBS into signature_paths: manually.

What works today (BEFORE vendoring — kept for context)

Section titled “What works today (BEFORE vendoring — kept for context)”

Two paths exist without any new analyzer code:

  1. Run rigor INSIDE the target project’s Bundler context. BUNDLE_GEMFILE=<target>/Gemfile bundle exec rigor check ... makes RBS::EnvironmentLoader.add(library: gem_name) find every gem with bundled sig/. Today’s RbsLoader.build_env_for does honour this via the libraries: config — but the user has to enumerate the libraries explicitly; rigor doesn’t yet auto-discover them from the target’s Gemfile.lock.
  2. Add gem RBS to signature_paths:. gem_rbs_collection is the community RBS repository — 172 gems as of 2026-05-15, versioned per-gem (e.g. gems/activerecord/{6.0, 6.1, 7.0, 7.1, 7.2, 8.0}). Add the relevant per-version paths to .rigor.yml’s signature_paths: and rigor will pick them up.
  • Native gem builds. bundle install against Mastodon failed on idn-ruby (libidn missing in the Nix shell). Rails projects routinely depend on pg / mysql2 / nokogiri / idn-ruby / ffi etc. that need system libraries. End users would resolve this by running rigor on their own dev / CI machine where the Bundler context already builds; the survey machine doesn’t.
  • Ruby version mismatches. Most of the 14 surveyed projects pin Ruby 3.3 / 3.4 in .ruby-version; rigor’s Flake provides 4.0.4. Bundler refuses to install when the version mismatches. Mastodon (ruby '>= 3.3.0', '< 4.1.0') was the only project in the survey with a Ruby version range that admitted 4.0.4.
  • gem_rbs_collection version pinning. Because the collection is structured gems/<name>/<version>/, the user has to pick the right version per gem. Rigor doesn’t do this resolution itself — that’s the missing piece O4 would close.

Quantitative experiment (Diaspora + Mastodon, medium gem subset)

Section titled “Quantitative experiment (Diaspora + Mastodon, medium gem subset)”

For Diaspora (Rails 6.1) with O1 v2 + the five-gem subset (activerecord/6.1 + activesupport/7.0 + activemodel/7.1 + actionpack/7.2 + activejob/6.0):

MetricO1 v2 only+ 5-gem RBS subset
RBS classes available1,0392,478
Cold wall time1.35 s9.47 s
Warm wall time(n/a)1.05 s
Diagnostics53

For Mastodon (Rails 8) with O1 v2 + the same subset:

MetricBaselineO1 v2+ 5-gem RBS subset
RBS classes available1,0391,0392,505
Cold wall time3.31 s(similar)12.39 s
Diagnostics521124128

Mastodon’s diagnostic count slightly increased under the gem subset — a textbook precision/coverage trade-off: more known RBS means more methods can be checked (so the residual call.undefined-method for AR / AS-Inflector etc. drops to ~0) and more nullable-receiver narrowing fires correctly (lifting call.possible-nil-receiver from ~70 to 97). Some of the new diagnostics will be real bugs rigor previously couldn’t see; some will be false positives where the gem RBS itself is too strict (typically inputs declared String that real callers also pass ActiveSupport::SafeBuffer etc.).

Loading >10 gem RBS sigs at once into signature_paths: cold-loaded for 11+ minutes on Diaspora before being killed. The same workload with 5 paths completes in 7-9 s. Likely a non-linear interaction in RBS::Environment.from_loader / resolve_type_names when many overlapping namespaces converge. Worth investigating before O4 lands — a real-world Rails project’s Gemfile.lock typically lists 50-150 gems, not 5.

  • Auto-discover Gemfile.lock next to the analysed paths.
  • Per-gem version resolution: Bundler.locked_gems.specs.find { |s| s.name == "activerecord" }.version -> match to gem_rbs_collection’s available versions or fall back to “raw” RBS env.
  • Per-gem RBS source resolution: prefer in-gem sig/ (some gems ship their own); fall back to gem_rbs_collection; final fallback to the ADR-10 dependencies.source_inference walker.
  • Caching: each Gemfile.lock digest gets one RBS::Environment cache slot, keyed by the per-gem-version tuple.
  • A graceful degradation message when a gem’s RBS isn’t available (so users know to install it or opt into source-inference).

Round-3 projects (Loomio / Publify / Diaspora / Dependabot Core / tDiary Core)

Section titled “Round-3 projects (Loomio / Publify / Diaspora / Dependabot Core / tDiary Core)”

Third-round sweep. Includes three Rails apps (Loomio / Publify / Diaspora) at small / micro / medium size, and two non-Rails Ruby projects to calibrate how the analyser and the ActiveSupport-shaped RBS bundle behave outside the Rails idiom.

ProjectFilesWall (warm)Peak RSSBaselinewith O1 v2Δ
Loomio5632.36 s238 MB20763−144 (−70%)
Publify (app shell only)150.66 s243 MB000
Diaspora3711.35 s258 MB655−60 (−92%)
Dependabot Core (non-Rails)1,08913.02 s226 MB20558−147 (−72%)
tDiary Core (non-Rails)2441.61 s254 MB111106−5 (−5%)

Pool ≡ sequential on all five (zero IsolationErrors).

  • Publify is just the Rails app shell (15 .rb files in app/ + lib/). The real Publify code lives in the external publify_core / publify_amazon_sidebar / publify_textfilter_code gems referenced via gem "publify_core", github: .... Rigor only sees what’s checked into this repo, so the diagnostic count is zero — a useful boundary case but not representative of Publify proper.
  • Diaspora is the cleanest Rails app in the survey — 5 diagnostics on 371 files after O1 v2.
  • Dependabot Core (non-Rails) still benefits substantially from the ActiveSupport-shaped bundle (−72%). The reason: many non-Rails Ruby projects load ActiveSupport (or fragments via active_support/core_ext/...) at boot, and their code uses the same Object#blank? / #present? / #try / String#exclude? / Enumerable#index_by idioms as Rails apps. The remaining 58 diagnostics are dominated by Bundler-internal Singleton-class calls (Bundler::Definition.build × 10, Bundler.settings × 7, Bundler::Dependency.new(...) flagged as wrong-arity 5×) — all of which are O4 (target-Bundler awareness) symptoms. Dependabot ships its own monkey-patches against Bundler in bundler/helpers/v*/monkey_patches/ that Rigor would need to pre-evaluate to type correctly.
  • tDiary Core barely benefits from O1 (−5%). It pre-dates the ActiveSupport-as-utility idiom — the Ruby is classic stdlib-only style. tDiary’s residual diagnostics are dominated by #month= / #year= setters flagged as on Object (35 instances in misc/plugin/category-legacy.rb). The plugin file is instance_eval’d into a host plugin class at runtime, and rigor can’t see the receiver class because the defs sit at file top level — exactly the macro-expansion path queued under open item O2 (heredoc / instance_eval Ruby expansion).
  • Loomio’s mix is unusual — 34 of 63 are flow.dead-assignment (54%) and only 11 are call.undefined-method. The codebase is noticeably less idiomatic-AS than the others; less to gain from the RBS bundle.
  1. Pool ≡ sequential proven on all 14 projects swept so far (zero Ractor::IsolationError across ~29,560 files). Phase 4b.x’s four shareability follow-ups + the CONSTANT_CONSTRUCTORS lambda fix are robust against the diversity of real-world targets.
  2. The ActiveSupport-shaped RBS bundle is useful for non-Rails Ruby too — Dependabot Core’s −72% confirms ActiveSupport idioms (Object#blank? family, Enumerable#index_by, String#exclude?) are widespread outside Rails.
  3. tDiary’s instance_eval plugin pattern motivates O2 — pre-Rails-era idioms hit the same kind of metaprogramming barrier as Rails generators’ .rb-as-ERB templates.

Round-2 projects (Forem / Solidus / Chatwoot / Canvas LMS / OpenProject)

Section titled “Round-2 projects (Forem / Solidus / Chatwoot / Canvas LMS / OpenProject)”

A second-round sweep of five additional Rails projects, run after the first-round engine fixes (Pool deep-shareability follow-ups #1 through #3, narrowing extension, parametrized-ancestor projection, and the v1 RBS bundle).

ProjectTotalcall.undefined-methodpossible-nil-receiverflow.always-truthy-conditiondef.ivar-write-mismatchcall.wrong-arity
Forem14655471527
Solidus42333411
Chatwoot1961112
Canvas LMS1,4967664451948311
OpenProject175138271184

The v1 RBS bundle was extended to v2 with five extra method families surfaced in this round:

  1. Array#compact_blank / Hash#compact_blank (Rails 6.1+).
  2. Array#exclude? / String#exclude? / Hash#exclude? (Enumerable re-exposed too).
  3. Enumerable#index_with / #index_by / #pluck / #pick / #sole / #including / #excluding / #without.
  4. Hash.from_xml, Hash#reverse_merge / #reverse_merge!.
  5. DateTime calculations (#utc, #in_time_zone, #yesterday, #tomorrow, #beginning_of_*, #end_of_*, #ago, #since).

Combined v1 + v2 quantitative impact across all nine survey projects: total 12,502 → 3,071 (-75%), call.undefined-method 10,589 → 1,426 (-87%).

  • Solidus’s lib/ count is misleading (just 2 files at the repo root); the engine sub-trees (core/, api/, backend/, admin/, promotions/, legacy_promotions/) are where the code lives. The rigor config enumerates each sub-tree as an explicit path. Solidus’s diagnostic count drops to 42 — extremely clean.
  • Canvas LMS dominates round-2 residual (1,496 of 1,878 — 80%). Top selectors: []= on Integer (70 — likely a wrong receiver inference), []= on nil (51), << on nil (40). These are narrowing-tier limitations, not RBS coverage gaps. Canvas also ships project-private Numeric#decimal_megabytes, File.mime_type, and friends; closing the long tail there needs O4 (target-Bundler awareness) plus a Canvas-specific monkey-patch declaration in .rigor.yml.
  • OpenProject’s from_xml / compact_blank clusters were the v1 → v2 motivatorHash.from_xml alone accounted for 10 of OpenProject’s residual undefined-methods.

Source: https://github.com/discourse/discourse (depth-1 shallow clone, master HEAD on 2026-05-15).

Quantitative summary (after 642cf28 + the Discourse-driven shareability fix)

Section titled “Quantitative summary (after 642cf28 + the Discourse-driven shareability fix)”
MetricSequential warmPool workers=4 warm
Files scanned1,8041,804
Wall time7.46 s5.82 s (1.28× faster than sequential)
Peak RSS244 MB842 MB
Total diagnostics1,4391,439
Errors1,3251,325

Pool is faster than sequential at this size — the first wall-clock crossover the survey observed.

CountRule
1,078call.undefined-method
217call.possible-nil-receiver
61flow.always-truthy-condition
46def.ivar-write-mismatch
22call.wrong-arity
8call.argument-type-mismatch
7flow.dead-assignment

Pool’s first run on Discourse surfaced 8 Ractor::IsolationError on worker dispatch into Rigor::Inference::MethodDispatcher::ShapeDispatch::REFINED_STRING_PROJECTIONS (a Hash keyed by two-element Symbol arrays — same shape as the three Phase 4b.x follow-up sites the Redmine pass surfaced). Now Ractor.make_shareable; new audit assertion pins the invariant (spec/rigor/ractor_readiness_spec.rb § “Phase 4b.x — module catalog shareability”). After the fix, pool ≡ sequential.

  • Time.zone 182 instancesActiveSupport::TimeWithZone extension. Even bigger ActiveSupport-extension volume than Redmine.
  • Integer#day / #hour / #minute / #days / #minutes / #hoursActiveSupport::Duration numeric coercions; hundreds of instances.
  • call.wrong-arity on Class 18 instances — Discourse’s service classes (DatabaseRestorer.new(...), MetaDataHandler.new(...), OpenStruct.new(...)). The receiver class isn’t in rigor’s RBS env, so dispatch falls back to Class#new (zero-arg default initializer) and reports the arg count as wrong. OpenStruct specifically lost its default-gem status in Ruby 4.0; Discourse’s Gemfile.lock pins it but rigor’s analysis env doesn’t see the target project’s Bundler context, so the gem’s RBS is not loaded.
  • call.argument-type-mismatch on URI.encode_www_form 5+ instances — RBS signature is (?Enumerable[[_, _]]) but real-world callers pass Hash. Hash IS Enumerable over [K, V] pairs at runtime; rigor’s subtyping doesn’t recognise the Hash → Enumerable[[K, V]] relation here. Worth investigating as a separate engine track.
IDItem
O4Target-project Bundler awareness — load the target’s gem RBS when running outside the project’s bundle exec context (covers OpenStruct in Ruby 4.0+ and any non-default gem with shipped RBS).
O5Hash <: Enumerable[[K, V]] subtyping for the parameter-binder.

Source: https://github.com/mastodon/mastodon (depth-1 shallow clone, master HEAD on 2026-05-15).

MetricSequential warmPool workers=4 warm
Files scanned1,3021,302
Wall time3.31 s3.98 s
Peak RSS238 MB878 MB
Total diagnostics521521 (≡ sequential)
Errors487487

Pool ≡ sequential out of the box — no new engine bugs found. Pool is slower than sequential at this size; the crossover sits between Mastodon (1.3 K files) and Discourse (1.8 K files), shifted by the Marshal-restore overhead.

CountRule
414call.undefined-method
73call.possible-nil-receiver
26def.ivar-write-mismatch
8flow.always-truthy-condition
  • Same Rails-extension long tail (Integer#day/#hour/#minute/#minutes/#seconds, String#squish, Time.zone). The ranking differs but the cause is identical to Redmine and Discourse: missing active_support/core_ext RBS coverage.

Source: https://gitlab.com/gitlab-org/gitlab-foss (depth-1 shallow clone, master HEAD on 2026-05-15). The largest target in the survey.

MetricSequential warmPool workers=8 warm
Files scanned11,13011,130
Wall time (warm)25.27 s15.43 s (1.64× faster than sequential)
Wall time (cold)25.33 s
Peak RSS248 MB1.30 GB
Total diagnostics2,9822,983 (+1; see below)
Errors2,8572,858

Pool is comfortably faster than sequential on a project this size. Peak RSS at 1.3 GB is the cost — 5× sequential. The crossover is solidly established here; the question for future pool-mode work is whether the RSS / wall-clock tradeoff can move further with the deferred per-Ractor Cache::Store-shared facade (ADR-15 § OQ1).

CountRule
2,676call.undefined-method
136call.possible-nil-receiver
71def.ivar-write-mismatch
52flow.always-truthy-condition
43call.wrong-arity
2flow.dead-assignment
1call.argument-type-mismatch
1(Prism parse-error from a .erb-shaped .rb generator template, like Redmine)

Pool vs sequential — deterministic +1 divergence

Section titled “Pool vs sequential — deterministic +1 divergence”

Pool emits one diagnostic sequential does not, deterministically across workers=4 / workers=8 and multiple runs:

lib/gitlab/mail_room.rb:17:56
call.argument-type-mismatch
argument type mismatch at parameter `dir` of `expand_path` on Pathname:
expected String, got String | nil

Minimal repro (sequential is silent, pool emits the diagnostic):

require "pathname"
x = Pathname.new("../..")
y = x.expand_path(__dir__) # __dir__ returns String | nil per RBS

__dir__’s RBS return is String?. Sequential constant-folds the call through the try_fold_pathname_binary tier in MethodDispatcher::ConstantFolding; pool reaches the RBS-dispatch tier where the parameter check rejects String | nil. The divergence is deterministic and rare (1 site in 11,130 files), but the contract is byte-identical output — recorded as open item O6.

  • Time.current 324 instances — ActiveSupport. By far the top Rails-extension absentee in this corpus.
  • Array.wrap 228 instances, Integer#minute 163, Time.zone 125 — same active_support/core_ext tail as the smaller targets, proportionally larger.
  • String#demodulize 34, #underscore 32, #squish 37 — the Inflector / ActiveSupport String core_ext.
  • The user-defined-class wrong-arity issue (Discourse O4) repeats here at a larger scale.
IDItem
O6Pool vs sequential precision divergence on Pathname argument check. Pool reaches RBS dispatch when sequential folds through try_fold_pathname_binary; both paths are individually defensible but the contract requires byte-identical output.

ProjectFilesSeq warmPool warmPool ÷ SeqPeak RSS (seq / pool)Diagnostics (baseline)
Redmine3472.82 s3.70 s (w=4)1.31× slower266 MB / 948 MB389
Chatwoot8022.67 s(anomalous run; system load)n/a274 MB / —300
Mastodon1,3023.31 s3.98 s (w=4)1.20× slower238 MB / 878 MB521
Forem1,2504.31 s4.60 s (w=4)1.07× slower260 MB / —691
Discourse1,8047.46 s5.82 s (w=4)0.78× (faster)244 MB / 842 MB1,439
Solidus1,9147.36 s4.91 s (w=4)0.67× (faster)275 MB / —528
Canvas LMS3,24817.32 s11.16 s (w=4)0.64× (faster)272 MB / —3,296
OpenProject6,81718.84 s10.24 s (w=4)0.54× (faster)246 MB / —2,356
GitLab FOSS11,13025.27 s15.43 s (w=8)0.61× (faster)248 MB / 1.30 GB2,982
Publify (shell only)150.66 s(not measured)n/a243 MB / —0
Diaspora3711.35 s(not measured)n/a258 MB / —65
Loomio5632.36 s(not measured)n/a238 MB / —207
tDiary Core (non-Rails)2441.61 s(not measured)n/a254 MB / —111
Dependabot Core (non-Rails)1,08913.02 s(not measured)n/a226 MB / —205

Pool wall-clock crossover sits between Mastodon / Forem (~1.3 K files, pool slower) and Discourse / Solidus (~1.8 K files, pool 1.3-1.5× faster). Pool memory cost is 3-5× sequential. The ADR-15 OQ1 “per-Ractor cache facade” remains the avenue for moving the crossover lower and capping peak RSS.

Pool ≡ sequential proven on all fourteen projects. After the four Phase 4b.x deep-shareability follow-ups (NumericCatalog, CANONICAL_NAMES, RegexRefinement::RULES, ShapeDispatch::REFINED_STRING_PROJECTIONS) and the CONSTANT_CONSTRUCTORS lambda fix, every project in the survey — including the two non-Rails projects (Dependabot Core and tDiary Core) — produces byte-identical diagnostic streams between sequential and pool modes. Zero IsolationErrors across the 31,840 files swept.

Engine fixes banked during the survey (commit 642cf28 + the Discourse fix):

  1. Pool deep-shareability gaps (4 sites in total): NumericCatalog#@catalog, Type::Refined::CANONICAL_NAMES, Builtins::RegexRefinement::RULES, MethodDispatcher::ShapeDispatch::REFINED_STRING_PROJECTIONS.
  2. if cond && (var = expr) narrowing (4 new write-node cases in Inference::Narrowing#analyse).

The four shareability sites all share the same shape — a Hash / Array of nested arrays whose outer container was shallow-frozen but whose inner rows weren’t. The audit spec now has explicit assertions for each of the four so a future equivalent regression fails the audit instead of crashing real-world target projects.

Diagnostic surface dominated by Rails-extension absence. Across all four projects, call.undefined-method accounts for 64-90% of all diagnostics, and the top selectors are uniformly ActiveSupport::Duration numeric coercions (#days, #hours, #minutes), Inflector / String core_ext (#demodulize, #underscore, #squish, #html_safe, #constantize), Array.wrap, Hash core_ext (#deep_dup, #symbolize_keys, #stringify_keys), and Time.current / Time.zone. The dedicated rigor-activesupport-core-ext plugin would close most of this surface; the config-side monkey-patch pre-evaluation knob would close the project-private remainder.

IDStatusItem
O1landed (MVP, v2)plugins/rigor-activesupport-core-ext/ — community RBS bundle covering the top ~50 ActiveSupport core-ext selectors. Opt-in via signature_paths.
O2queuedMacro-template / heredoc-Ruby expansion. tDiary’s instance_eval plugin pattern (round 3) is a concrete motivating case alongside Rails-generator .rb-as-ERB templates.
O3not-an-issuenext if x.nil? / return if x.nil? already narrowed — survey-residual nil-receivers are mostly Object#blank? / #present? / #try ActiveSupport extensions, which O1’s RBS bundle covers.
O4Layer 1+2 landedTarget-project Bundler awareness. bundler.bundle_path: (explicit) and bundler.auto_detect: (.bundle/configvendor/bundle/) now auto-feed gem-shipped sig/ into signature_paths:. Auto-skip list prevents prism/stdlib conflicts. Layer 3 (Gemfile.lock parse + gem_rbs_collection matching) still queued.
O5landed (ac14c45)Hash <: Enumerable[[K, V]] subtyping in the parameter binder.
O6landed (4698437)Pool vs sequential precision divergence at the constant-fold / RBS-dispatch boundary (Pathname).
O7landed (2026-05-15)RBS env-build performance falls off a cliff when a signature_paths: entry duplicates a stdlib RBS declaration. Root cause traced through five rounds of bisection on a Mastodon controller analysis: gem-shipped prism/sig/prism.rbs redeclares Prism::VERSION: String, clashing with Rigor’s bundled stdlib RBS (Ruby 4.0+ ships prism in core). RBS::Environment.from_loader(...)...resolve_type_names raises RBS::DuplicatedDeclarationError. Pre-fix, the `@state[:env]

After opting into the new RBS bundle (sequential warm cache; v2 of the RBS bundle, which adds compact_blank / exclude? / index_with / index_by / Hash.from_xml / DateTime#utc and the Enumerable mixins on top of v1):

ProjectBaselineWith O1 v2Δ totalcall.undefined-method before → after
Redmine389157−232 (−60%)243 → 60 (−75%)
Discourse1,439423−1,016 (−71%)1,078 → 134 (−88%)
Mastodon521124−397 (−76%)414 → 27 (−93%)
GitLab FOSS2,982489−2,493 (−84%)2,676 → 207 (−92%)
Forem691146−545 (−79%)590 → 55
Solidus52842−486 (−92%)520 → 33
Chatwoot30019−281 (−94%)282 → 6
Canvas LMS3,2961,496−1,800 (−55%)2,493 → 766
OpenProject2,356175−2,181 (−93%)2,293 → 138
Total12,5023,071−9,431 (−75%)10,589 → 1,426 (−87%)

The remaining call.undefined-method instances are mostly:

  • Canvas LMS dominates the residual — 1,496 of 3,071 (49%). Top selectors: []= on Integer (70), []= on nil (51), << on nil (40) — narrowing limitations rather than missing RBS — plus Canvas-specific extensions (#decimal_megabytes is a project-private refinement on Numeric; File.mime_type is a Marcel/Mimemagic-style helper not in stdlib).
  • Project-private monkey-patches. Discourse, Forem, Canvas, and GitLab each ship their own String / Array / Hash extensions. Closing this needs O4 (project-side monkey-patch pre-evaluation config knob).
  • Gem-specific methods absent from the analyzer’s RBS env. The target project’s Gemfile.lock gems aren’t loaded by rigor’s out-of-process Bundler context. Gems with shipped RBS would benefit from O4 (target-Bundler awareness).
  • Concentrated nil-receiver patterns. Multi-assignment inside a block followed by guard-then-use inside the same block; not yet flow-tracked.
  • Other Rails core_ext methods outside the bundle’s ~50-selector scope. PRs to extend the RBS bundle are welcome.

© 2026 TypedDuck. Licensed under CC BY-SA 4.0.