Skip to content

Capture boot-time deprecations by booting the app from the capture script - #199

Open
JuanVqz wants to merge 3 commits into
mainfrom
feature/deprecations-self-boot
Open

Capture boot-time deprecations by booting the app from the capture script#199
JuanVqz wants to merge 3 commits into
mainfrom
feature/deprecations-self-boot

Conversation

@JuanVqz

@JuanVqz JuanVqz commented Aug 5, 2026

Copy link
Copy Markdown
Member

Replaces #194. Same goal, different attach point: the capture script boots the app itself instead of running inside rails runner, which is what lets it see the deprecations an app emits before eager-load.

Why

The per-test tracker attaches per example, so it never sees what an app emits while booting: initializer-time warnings, and the association / scope / callback declaration warnings that fire when a class body is evaluated. No test necessarily triggers those, so they stay invisible until an upgrade turns them into errors.

rails runner hands a script an app that is already initialized, so those warnings are gone before the script gets control. Booting the app ourselves moves the attach point as early as a script can reach:

install the tracker         <- Kernel#warn patch live from here on
require config/application  <- Bundler.require loads the gems here
configure deprecations      <- before any initializer runs
initialize!                 <- app + framework initializers, captured
eager_load!                 <- class bodies, captured

Out of reach either way: config/boot.rb, and deprecator-based warnings during Bundler.require, since loading config/application.rb is what requires the gems.

Measured against #194

Controlled, on freshly generated rails new --minimal apps with two deliberate probes, one warning from an initializer and one from a model's class body:

Rails 7.1.6 Rails 8.1.3
this PR 2/2 captured 2/2 captured
#194 1/2 1/2

In every #194 run the initializer-time warning was printed to stderr and absent from the shitlist. Same result with CI=1, where the generated test.rb eager-loads during boot.

On a real dual-boot app (Rails 3.2 current bundle / 4.0 next bundle), same commit, same day, both configured to succeed:

occurrences unique
#194 207 167
this PR 232 189

Strict superset: nothing #194 finds is missing here. The 22 extra unique warnings are concentrated in models an initializer happens to reference (Person, User, Contact, EmailInvitation, the CareManagement::Messaging cluster). Their class bodies are evaluated during initialize!, so by the time #194 runs eager_load! the constants are already loaded and nothing warns again. The warnings are not late, they are already gone.

Runtime is a wash: 26s vs 23s with warm caches. Both boot the app once.

Two further differences:

  • Add deprecations boot to capture load-time deprecations #194 needs an app config change on some apps. When eager_load is truthy it refuses (correctly, rather than reporting a false clean) and tells the operator to set config.eager_load = false. It avoids that on stock modern apps by unsetting CI for the boot, but not where the app arrives at a truthy eager_load anyway, which is the default outcome for --next on the app above. This PR needs no app edit: eager_load either way, deprecation = :raise, and report_deprecations = false are all handled, so the refusal path and the CI unsetting are both gone.
  • Add deprecations boot to capture load-time deprecations #194 raises NoMethodError on Rails < 4.0, where config.eager_load does not exist. This PR works on 3.2.

@JuanVqz
JuanVqz force-pushed the feature/deprecations-self-boot branch from 41f3fa2 to d2e25ff Compare August 5, 2026 23:52
@JuanVqz
JuanVqz marked this pull request as ready for review August 5, 2026 23:58
JuanVqz added 3 commits August 7, 2026 11:06
The per-test tracker attaches per example, so it never sees the deprecations an
app emits while it boots: the ones from initializers, and the association /
scope / callback declaration warnings that fire when a class body is evaluated
during eager-load. No test necessarily triggers those, so they stay invisible
until a Rails upgrade turns them into errors.

BootCapture provides the command; boot_capture_runner.rb does the work. The
runner boots the app itself, doing what config/environment.rb does with the
attach wedged in the middle:

  install the tracker      <- Kernel#warn patch live from here on
  require config/application  (Bundler.require loads the gems here)
  configure deprecations   <- before any initializer runs
  initialize!              <- app and framework initializers, captured
  eager_load!              <- class bodies, captured

Running inside `rails runner` instead would hand the script an app that is
already initialized, so everything left of that last arrow would already be
gone. Measured against a Rails 4.0 app: 189 unique warnings this way versus 167
attaching after boot, with the difference concentrated in models an initializer
happens to reference, whose class bodies are already loaded by eager-load time
and so never warn again.

The deprecation setup goes through config.active_support rather than the
deprecators, because Rails' own active_support.deprecation_behavior initializer
assigns behavior from those config values and would overwrite anything set
beforehand. It is re-installed from a railtie initializer ordered behind
:load_environment_config, since config/environments/<env>.rb is loaded during
initialize! and a plain `config.active_support.deprecation = :stderr` there
would otherwise replace the collector; again from before_eager_load, after every
initializer, for environments that eager-load during boot; and once more after
initialize! for environments that do not.

No capture logic is reimplemented: init_tracker installs the version-correct
hooks and KernelWarnTracker, and add / after_run collect and write. Verified on
Rails 3.2.22.5, 4.0.13 and 8.1, on Ruby 2.3.8 and 3.4.
`deprecations boot` runs the capture and summarizes it like `info` does. The
result is written to spec/support/deprecation_warning.boot.shitlist.json (or the
.next variant with --next, which selects the next bundle through BUNDLE_GEMFILE
and BUNDLE_CACHE_PATH), keyed under a single "boot" bucket.

The capture is written to a .partial sibling and renamed onto the real output
only on success, so a boot that fails never destroys a previous good capture. A
missing partial afterwards means the run never reached after_run, which is why a
failed boot reports the failure instead of an empty, misleading "clean" result.

--pattern is refused, since the capture cannot filter what an app emits while
booting. Verbose output labels the bucket "Source" rather than "Test files",
because "boot" is not a spec file.

The boot branches are covered by driving the CLI against a stubbed `bundle`, so
they run without a Rails app.
@JuanVqz
JuanVqz force-pushed the feature/deprecations-self-boot branch from d2e25ff to c553e32 Compare August 7, 2026 17:08
@JuanVqz
JuanVqz requested a review from arielj August 14, 2026 23:08

@arielj arielj left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'm requesting some changes mainly because of two things:

  • the comments added in this PR are too verbose, some things don't really look like code comments but just PR comments but something like Claude added them there, when comments are so many it's hard to understand which comments are relevant and which ones are not
  • there are A LOT of tests just testing implementation (almost all the boot_capture_spec.rb tests), the correct way to test this is to add one (or a few) minimal rails app in the test directory with specific known deprecations this feature would capture and make sure they are captured, all the implementation should be irrelevant for the tests as long as things work as expected

Comment thread exe/deprecations
total_messages = result.values.map(&:size).reduce(0, :+)
puts "Merged #{shards} shard files into #{path} (#{result.size} buckets, #{total_messages} deprecation messages)"
when "boot"
abort "--pattern is not supported with 'boot': it captures everything the boot emits." if options[:pattern]

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

maybe we can just warn an not abort here? using the Tint module, a yellow/orange message should be fine, I don't think it's that critical to ignore the pattern and still run the thing

Comment on lines +13 to +17
# The runner boots the app itself (require config/application, configure, then
# initialize!) rather than running inside an already-initialized `rails runner`,
# so initializer-time and gem-load-time warnings are in scope too. See
# boot_capture_runner.rb for what remains out of reach (config/boot.rb, and
# deprecator-based warnings during Bundler.require).

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

this paragraph sounds out of context, it's an explanation on why the previous implementation was changed to the new implementation in this same PR, but for anyone reading the comment in the future it's irrelevant that this is not using "rails runner", feels like a comment that belongs to just the PR and not the actual code

Comment on lines +24 to +25
# Kept compatible with the gem's supported Rubies (>= 2.0): no safe-navigation,
# no squiggly heredocs, stdlib only.

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

this comment also feels out of context, this class has no heredocs for example, it's not relevant to mention it's not using squiggly heredocs for compatibility when no heredocs at all are being used

this feels like something the tests would catch, no need for a comment

I understand some of these things are added by Claude because it's too verbose, but too many comments make the real important comments harder to get

Comment on lines +50 to +53
# The command that runs the gem's runner script in the app's bundle. Plain
# `bundle exec ruby`, not `rails runner`: the runner boots the app itself so it
# can attach before the initializers run, and `rails runner` would have already
# booted it (and, on Rails 3.x, would eval the script instead of loading it).

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Similar to a previous comment, it's irrelevant that this is not using "rails runner" here, there's no reason to expect this to run with "rails runner" other than having seen the previous implementation.

# Safe to call even when the environment already eager-loaded during initialize!
# (config.eager_load = true, or Rails 3.x cache_classes): loading is idempotent, and
# because we attached before initialize! those warnings were captured either way.
# That is why this approach needs no "this environment eager-loads at boot" refusal.

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

this comment is also irrelevant, it's only an explanation of why the approach changes in this same PR, but for anyone without that context there's no "this environment eager-loads at boot" refusal approach to understand this line.

Comment on lines +100 to +103
it "is valid Ruby" do
# `ruby -c` is portable across implementations; RubyVM::InstructionSequence is MRI-only.
expect(system(RbConfig.ruby, "-c", described_class::RUNNER_PATH, out: File::NULL)).to be(true)
end

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

this is a really weird test, not sure what it's trying to test about this feature

expect(system(RbConfig.ruby, "-c", described_class::RUNNER_PATH, out: File::NULL)).to be(true)
end

it "reuses DeprecationTracker rather than reimplementing capture" do

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

this sounds too much like "testing implementation", which is always a bad idea for tests

I don't think it's relevant to test that it's not reimplementing something

expect(described_class::OUTPUT_ENV).to eq("DEPRECATION_BOOT_OUTPUT")
end

it "stores project-relative paths by stripping Rails.root (committable shitlist)" do

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

this sounds like it's testing that the actual code has a method called "transform_message" and calls "gsub(..."? it's just testing implementation, I would remove this

expect(script).to include('gsub("#{Rails.root}/"')
end

it "installs the tracker before requiring the app, so gem-load warnings are in scope" do

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

all these tests seem to be testing the implementation, that the actual code is where it is, which is a bad idea

I don't think most of these tests are relevant and we should test behavior, not implementation (so if the implementation changes but we get the same result then it's fine)

end
end

describe "DeprecationTracker save outside a test process (boot runs it via `rails runner`)" do

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'm confused about this test, we don't use rails runner anymore, I'm not sure what this test is testing

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants