Skip to content

Filter prepended-into-ancestors modules in ActionControllerHelpers DSL compiler#2597

Open
atraver-shopify wants to merge 1 commit intomainfrom
adam/fix-prepended-ancestors-in-action-controller-helpers
Open

Filter prepended-into-ancestors modules in ActionControllerHelpers DSL compiler#2597
atraver-shopify wants to merge 1 commit intomainfrom
adam/fix-prepended-ancestors-in-action-controller-helpers

Conversation

@atraver-shopify
Copy link
Copy Markdown

Motivation

When debug/session is loaded in the Ruby process that runs the ActionControllerHelpers DSL compiler, generated controller RBIs gain a spurious include ::DEBUGGER__::TrapInterceptor line. Sorbet then fails with:

sorbet/rbi/dsl/application_controller.rbi: Unable to resolve constant DEBUGGER__ https://srb.help/5002

This is a direct consequence of how debug.gem installs itself:

# debug-1.11.x/lib/debug/session.rb
module ::Kernel
  prepend DEBUGGER__::TrapInterceptor
end

That prepend inserts TrapInterceptor before ::Kernel in every module's ancestor chain that traverses ::Kernel. The ActionControllerHelpers compiler walks _helpers.ancestors and emits an include ::X for each entry, so the prepended module ends up in the generated RBI even though no user wrote include ::DEBUGGER__::TrapInterceptor.

In practice this is hit whenever the Tapioca Ruby LSP add-on (lib/ruby_lsp/tapioca/addon.rb) triggers in-process DSL regeneration inside a ruby-lsp process that has debug/session loaded. It's especially confusing because the same DSL command run from a plain shell produces a clean RBI — the bad output depends on what's loaded into the host process.

The same mechanism produces quieter leaks for any module prepended into something the user's helper chain touches. For example, ActiveSupport does:

# activesupport/lib/active_support/core_ext/erb/util.rb
module ::ERB::Util
  prepend ActiveSupport::CoreExt::ERBUtilPrivate
  singleton_class.prepend ActiveSupport::CoreExt::ERBUtil
end

Those two modules show up in committed RBIs as include ::ActiveSupport::CoreExt::ERBUtil and include ::ActiveSupport::CoreExt::ERBUtilPrivate in any Rails app whose helpers chain reaches ::ERB::Util. They're semantically wrong — no user wrote include for them — but because the referenced modules are typechecked, nothing has flagged the lines so far.

Implementation

In Tapioca::Dsl::Compilers::ActionControllerHelpers#gather_includes, walk the target module's ancestors and precompute the set of modules that appear in another ancestor's prepend chain (i.e. appear before that ancestor in its own .ancestors). Reject those from the emitted include list.

This matches the intent of the compiler: it is translating explicit helper X / include X calls into include ::X RBI lines. Prepended modules weren't included by the user and should not appear.

Manual reproduction

On main, in any Rails app where at least one helper resolves ::Kernel in its ancestor chain:

$ RUBYOPT="-rdebug/session" bin/tapioca dsl ApplicationController
$ grep DEBUGGER sorbet/rbi/dsl/application_controller.rbi
    include ::DEBUGGER__::TrapInterceptor
$ bin/srb tc
sorbet/rbi/dsl/application_controller.rbi:N: Unable to resolve constant DEBUGGER__

With this patch the bad include is absent and srb tc is clean.

Tests

Added a new case in spec/tapioca/dsl/compilers/action_controller_helpers_spec.rb that:

  1. Requires debug/session, mirroring the in-editor scenario where the Ruby LSP Tapioca add-on runs in a process with the debugger loaded.
  2. Defines a helper module that explicitly include Kernel (the realistic case where Kernel ends up in the HelperMethods ancestor chain).
  3. Asserts the generated RBI does not contain DEBUGGER__.

The test fails on main and passes with this change. bin/test, bin/style, and bin/typecheck are all clean.

Note for downstream users

Existing committed DSL RBIs in Rails apps may currently contain include ::ActiveSupport::CoreExt::ERBUtil, include ::ActiveSupport::CoreExt::ERBUtilPrivate, or other prepend-side-effect includes accumulated over multiple Rails/ActiveSupport versions. After this change, bin/tapioca dsl --verify will flag those lines as stale. The resolution is a one-time bin/tapioca dsl + commit to clean them up. No functional impact — those lines were always semantically wrong; they just weren't producing Sorbet errors because the referenced modules were themselves typechecked.

When a module is prepended into another ancestor in the helpers chain
(e.g. `DEBUGGER__::TrapInterceptor` prepended into `::Kernel` by
debug.gem, or `ActiveSupport::CoreExt::ERBUtil*` prepended into
`::ERB::Util` by ActiveSupport), it appears in `mod.ancestors` even
though the user never explicitly included it. Emitting `include ::X`
for those modules pollutes generated RBIs and, in the DEBUGGER case,
causes Sorbet to fail with "Unable to resolve constant DEBUGGER__"
because debug.gem is not typechecked.

This shows up in practice when the Ruby LSP Tapioca add-on regenerates
DSL RBIs inside a ruby-lsp process that has `debug/session` loaded --
the chain is visible in the LSP but not in plain-shell invocations of
`bin/tapioca dsl`, which is very confusing to diagnose.

Fix: in `gather_includes`, compute the set of modules that appear in
any ancestor's prepend chain and exclude them from the output.
@atraver-shopify atraver-shopify requested a review from a team as a code owner April 17, 2026 02:48
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant