From 4c79ff69fa696873347cec3bf755b1c8f6fc9a88 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Juan=20V=C3=A1squez?= Date: Wed, 5 Aug 2026 11:22:48 -0600 Subject: [PATCH 1/3] Add DeprecationTracker::BootCapture for boot-time deprecations 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/.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. --- .gitignore | 2 + lib/deprecation_tracker.rb | 4 + lib/deprecation_tracker/boot_capture.rb | 120 ++++++++++ .../boot_capture_runner.rb | 158 ++++++++++++ spec/deprecation_tracker/boot_capture_spec.rb | 226 ++++++++++++++++++ 5 files changed, 510 insertions(+) create mode 100644 lib/deprecation_tracker/boot_capture.rb create mode 100644 lib/deprecation_tracker/boot_capture_runner.rb create mode 100644 spec/deprecation_tracker/boot_capture_spec.rb diff --git a/.gitignore b/.gitignore index 235bff1..8475ba0 100644 --- a/.gitignore +++ b/.gitignore @@ -14,4 +14,6 @@ .ruby-version Gemfile.lock +Gemfile.next .gem +*.partial diff --git a/lib/deprecation_tracker.rb b/lib/deprecation_tracker.rb index cbbefa4..a154e4b 100644 --- a/lib/deprecation_tracker.rb +++ b/lib/deprecation_tracker.rb @@ -1,5 +1,9 @@ require_relative "next_rails/tint" require "json" +# save/diff use these; they happen to be loaded under RSpec/Minitest, but not +# necessarily when the boot capture runs the tracker in a bare Ruby process. +require "tempfile" +require "fileutils" # A shitlist for deprecation warnings during test runs. It has two modes: "save" and "compare" # diff --git a/lib/deprecation_tracker/boot_capture.rb b/lib/deprecation_tracker/boot_capture.rb new file mode 100644 index 0000000..6fefb65 --- /dev/null +++ b/lib/deprecation_tracker/boot_capture.rb @@ -0,0 +1,120 @@ +# frozen_string_literal: true + +class DeprecationTracker + # Capture deprecation warnings emitted while the app EAGER-LOADS its classes -- + # the association / scope / callback declaration warnings that fire when a class + # body is evaluated -- rather than during a test run. + # + # This is the one such surface the test-run tracker structurally misses: + # `track_rspec` / `track_minitest` attach per-example, so a warning that fires at + # class-body-evaluation time never reaches them. Eager-loading the whole app with + # the tracker already listening surfaces exactly those. + # + # 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). + # + # It deliberately does NOT reimplement any capture logic. `boot_capture_runner.rb` + # drives the existing DeprecationTracker (init_tracker installs the + # version-correct hooks; add / after_run collect and write). BootCapture only + # provides the command that runs that script in the app's bundle. + # + # Kept compatible with the gem's supported Rubies (>= 2.0): no safe-navigation, + # no squiggly heredocs, stdlib only. + class BootCapture + # The runner script shipped with the gem, run with `bundle exec ruby`. + RUNNER_PATH = File.expand_path("boot_capture_runner.rb", __dir__) + + # Env var the command uses to tell the runner where to write the shitlist. + OUTPUT_ENV = "DEPRECATION_BOOT_OUTPUT" + + # Env var the command uses to tell the runner which directory holds + # config/application.rb. The CLI runs from the app root, so this is Dir.pwd. + APP_ROOT_ENV = "DEPRECATION_BOOT_APP_ROOT" + + # Exit status the runner uses when there is no config/application.rb to load + # (the CLI was run outside a Rails app root). The CLI branches on this so the + # runner's specific explanation isn't buried under a generic "boot failed" guess. + NO_APP_EXIT = 4 + + # Follows the tracker's spec/support convention (DeprecationTracker::DEFAULT_PATH + # is spec/support/deprecation_warning.shitlist.json); ".boot" keeps the boot + # capture from clobbering the test-run shitlist, ".next" separates the bundles. + def self.default_output_path(next_mode: false) + name = next_mode ? "deprecation_warning.boot.next.shitlist.json" : "deprecation_warning.boot.shitlist.json" + File.join("spec", "support", name) + end + + # 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). + # + # Returned as `[env_hash, *argv]` for `system(*command)` — NOT a shell string — + # so neither the output path nor the gem's RUNNER_PATH can be word-split or + # interpreted by a shell (a checkout living under a path with spaces is enough + # to matter). Values that would have needed quoting are ordinary array elements + # and env values here. + # + # Env keys: + # * APP_ROOT_ENV tells the runner where config/application.rb lives. The CLI is + # run from the app root, so it is Dir.pwd. CI is deliberately left alone: the + # runner attaches before initialize!, so an environment that eager-loads at + # boot (the generated test.rb ties config.eager_load to ENV["CI"]) is captured + # rather than refused, and this boot sees the same config CI would. + # * The next bundle sets BUNDLE_GEMFILE=Gemfile.next AND BUNDLE_CACHE_PATH= + # vendor/cache.next — the pair `next`/`gem-next-diff` always use together + # (exe/next.sh, exe/gem-next-diff). Setting only the Gemfile would resolve + # against the current bundle's vendored cache. The current bundle leaves both + # at Bundler's defaults (Gemfile, vendor/cache). + # * RAILS_ENV=test skips dev-only initializers. + # output_path is required, but declared as an optional kwarg + guard rather + # than a required kwarg (`output_path:`) so the file parses on Ruby 2.0 — the + # gem's stated floor, which the rest of the code keeps to. + def self.boot_command(output_path: nil, next_mode: false, app_root: nil) + raise ArgumentError, "output_path is required" unless output_path + env = { + "RAILS_ENV" => "test", + OUTPUT_ENV => output_path.to_s, + APP_ROOT_ENV => (app_root || Dir.pwd).to_s + } + if next_mode + env["BUNDLE_GEMFILE"] = "Gemfile.next" + env["BUNDLE_CACHE_PATH"] = "vendor/cache.next" + end + [env, "bundle", "exec", "ruby", RUNNER_PATH] + end + + # A human-readable, shell-like rendering of `boot_command` for logging. Not + # executed — `system(*boot_command(...))` runs the real thing without a shell. + # Unset (nil) env vars are omitted rather than shown as `KEY=`, which would + # read as "blanked" and contradict the unset semantics the command relies on. + def self.command_display(command) + env, argv = command[0], command[1..-1] + env_str = env.reject { |_key, value| value.nil? }.map { |key, value| "#{key}=#{value}" }.join(" ") + parts = env_str.empty? ? argv : [env_str] + argv + parts.join(" ") + end + + # The sibling file the CLI writes to, renamed onto output_path only on success + # so a failed/refused boot never destroys a previous capture. + def self.partial_path_for(output_path) + "#{output_path}.partial" + end + + # Classify a boot attempt from its process result so the CLI's branching is + # testable without shelling out. `succeeded` is the truthiness of system's + # return, `exit_status` the child's exit code (nil if it couldn't run), + # `output_written` whether the partial file exists afterward. + # :no_app - there is no config/application.rb to boot; explained already + # :failed - the app did not boot; no usable capture + # :ok - the partial was written and can be promoted + def self.boot_result(succeeded, exit_status, output_written) + return :no_app if exit_status == NO_APP_EXIT + return :failed unless succeeded && output_written + :ok + end + end +end diff --git a/lib/deprecation_tracker/boot_capture_runner.rb b/lib/deprecation_tracker/boot_capture_runner.rb new file mode 100644 index 0000000..0a02801 --- /dev/null +++ b/lib/deprecation_tracker/boot_capture_runner.rb @@ -0,0 +1,158 @@ +# frozen_string_literal: true + +# Boots the app itself and captures the deprecations it emits while doing so. +# Executed via `bundle exec ruby` from the app root (see BootCapture.boot_command). +# NOT meant to be `require`d -- it runs on load. +# +# Why boot the app here instead of running inside `rails runner`: `rails runner` +# hands us an app that is ALREADY initialized, so every warning emitted while gems +# were required and while the initializers ran has already gone by. Doing what +# config/environment.rb does -- require config/application, then initialize! -- +# with our hook wedged in the middle moves the attach point as early as a script +# can reach: +# +# require the tracker <- Kernel#warn patch is live from here on +# require config/application (this is where Bundler.require loads the gems) +# configure deprecations <- before any initializer runs +# initialize! <- app + framework initializers, now captured +# eager_load! <- class bodies, now captured +# +# Still out of reach: anything emitted inside config/boot.rb, and deprecator-based +# warnings during Bundler.require -- config/application.rb runs Bundler.require +# itself, so gems are loaded by the time that require returns. Plain Kernel#warn +# during gem load IS captured, because KernelWarnTracker is installed first. +# +# Capture logic is not reimplemented: init_tracker installs the version-correct +# hooks and KernelWarnTracker, and add / after_run collect and write. +# +# Kept compatible with the gem's supported Rubies (>= 2.0): no safe-navigation, +# no squiggly heredocs, stdlib only. +require "deprecation_tracker" +require "deprecation_tracker/boot_capture" + +app_root = ENV["DEPRECATION_BOOT_APP_ROOT"] || Dir.pwd +application_path = File.expand_path(File.join(app_root, "config", "application")) + +unless File.exist?("#{application_path}.rb") + STDERR.puts "deprecations boot: no config/application.rb under #{app_root}. " \ + "Run this from a Rails app root, or pass --app-root." + exit DeprecationTracker::BootCapture::NO_APP_EXIT +end + +# Strips the absolute Rails.root prefix (the same gsub the RSpec/Minitest setups +# use) so the shitlist stores project-relative, committable paths. Rails is not +# loaded yet when this lambda is built, and warnings can arrive before Rails.root +# exists (gem-require time), so resolve it per message and pass the message +# through untouched until it does. +transform_message = lambda do |message| + if defined?(Rails) && Rails.respond_to?(:root) && Rails.root + message.gsub("#{Rails.root}/", "") + else + message + end +end + +# Installs KernelWarnTracker before the app requires a single gem, so plain +# Kernel#warn deprecations from gem load are recorded too. The deprecator hooks +# init_tracker also installs find nothing yet (no app, so no deprecators); the +# config assignment below is what covers those. +tracker = DeprecationTracker.init_tracker( + :shitlist_path => ENV.fetch(DeprecationTracker::BootCapture::OUTPUT_ENV), + :mode => "save", + :transform_message => transform_message +) +tracker.bucket = "boot" +collector = lambda { |message, _callstack = nil, _deprecation_horizon = nil, _gem_name = nil| tracker.add(message) } + +# Loads config/boot.rb (Bundler, bootsnap) and config/application.rb (which runs +# Bundler.require, so all gems load here) and defines the application class. No +# initializer has run yet. +require application_path + +application = Rails.application + +# Configure the capture BEFORE initialize!, through config rather than by touching +# the deprecators directly. Rails' own `active_support.deprecation_behavior` +# initializer assigns behavior from these config values (and silences everything +# when report_deprecations is false), so anything set on the deprecators now would +# be overwritten by it. Setting the config instead means Rails installs our +# collector for us, and on 7.1+ the collection propagates it to every deprecator a +# gem or engine registers later. +# +# Apps mid-upgrade commonly configure one of these, and each would defeat capture: +# * silenced (report_deprecations = false) -- Reporting#warn returns early on +# `silenced` before behavior is consulted, so nothing is recorded (false clean); +# * :raise -- the first warning aborts the boot before after_run, and the CLI is +# left blaming a generic boot failure. +# We only want to RECORD warnings here, not silence or fail on them. +# +# report_deprecations / disallowed_deprecation only exist on newer Rails; on older +# ones config.active_support is an OrderedOptions, so the unknown keys are simply +# ignored rather than raising. +# +# Note: this prints deprecations to stderr even for apps that normally silence them. +application.config.active_support.report_deprecations = true +application.config.active_support.deprecation = [:stderr, collector] +application.config.active_support.disallowed_deprecation = [:stderr, collector] + +# Assigns the collector onto whatever deprecators exist at that moment, replacing the +# behavior list rather than appending, so nothing collects twice. +install_collector = lambda do + if defined?(Rails.application.deprecators) + application.deprecators.silenced = false + application.deprecators.behavior = [:stderr, collector] + application.deprecators.disallowed_behavior = [:stderr, collector] + elsif defined?(ActiveSupport) && defined?(ActiveSupport::Deprecation) + ActiveSupport::Deprecation.silenced = false + ActiveSupport::Deprecation.behavior = [:stderr, collector] + if ActiveSupport::Deprecation.respond_to?(:disallowed_behavior=) + ActiveSupport::Deprecation.disallowed_behavior = [:stderr, collector] + end + end +end + +# The config above is not enough on its own: config/environments/.rb is loaded +# DURING initialize! (the :load_environment_config initializer), and a line as ordinary +# as `config.active_support.deprecation = :stderr` there replaces our collector before a +# single app initializer has run. So re-install after that file is loaded, from a railtie +# initializer ordered right behind it. Railtie subclasses defined before initialize! are +# picked up, which is why this is declared here rather than in the gem's normal code. +module DeprecationTracker::BootCapture::Collector + class Railtie < Rails::Railtie + initializer "deprecation_tracker.boot_capture", :after => :load_environment_config do + DeprecationTracker::BootCapture::Collector.install.call + end + end + + def self.install + @install + end + + def self.install=(callable) + @install = callable + end +end +DeprecationTracker::BootCapture::Collector.install = install_collector + +# And once more immediately before eager-load, for apps whose config/initializers touch +# deprecation settings after the railtie initializer above has run. Rails fires this hook +# from the finisher, right before it eager-loads (which is where the environments that +# eager-load at boot do it), so it lands after every initializer and before the class +# bodies are evaluated. +ActiveSupport.on_load(:before_eager_load) { install_collector.call } + +application.initialize! + +# Last re-install, covering the environments that do NOT eager-load during boot: there +# the before_eager_load hook never fired, and config/initializers have had their chance +# to assign ActiveSupport::Deprecation directly, bypassing config. On 7.1+ this also +# picks up every deprecator registered by a gem or engine while initializing. +install_collector.call + +# Eager-load so declaration-time warnings (associations, scopes, callbacks) fire. +# 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. +application.eager_load! +tracker.after_run diff --git a/spec/deprecation_tracker/boot_capture_spec.rb b/spec/deprecation_tracker/boot_capture_spec.rb new file mode 100644 index 0000000..7795eb3 --- /dev/null +++ b/spec/deprecation_tracker/boot_capture_spec.rb @@ -0,0 +1,226 @@ +# frozen_string_literal: true + +require "spec_helper" + +require "tmpdir" +require "fileutils" +require "rbconfig" +require_relative "../../lib/deprecation_tracker/boot_capture" + +RSpec.describe DeprecationTracker::BootCapture do + describe ".boot_command" do + it "returns an env hash + argv for system (no shell), running the gem runner" do + env, *argv = described_class.boot_command(output_path: "spec/support/deprecation_warning.boot.shitlist.json") + # Plain `ruby`: the runner boots the app itself so it can attach before the + # initializers run, which `rails runner` would have already done for us. + expect(argv).to eq(["bundle", "exec", "ruby", described_class::RUNNER_PATH]) + expect(env["RAILS_ENV"]).to eq("test") + expect(env["DEPRECATION_BOOT_OUTPUT"]).to eq("spec/support/deprecation_warning.boot.shitlist.json") + expect(env["DEPRECATION_BOOT_APP_ROOT"]).to eq(Dir.pwd) + end + + it "selects the next bundle with BUNDLE_GEMFILE + BUNDLE_CACHE_PATH, not bin/next" do + # bin/next may not exist in every project; BUNDLE_GEMFILE is what it wraps, + # and `next`/gem-next-diff always pair it with BUNDLE_CACHE_PATH=vendor/cache.next. + env, *argv = described_class.boot_command(output_path: "out.json", next_mode: true) + expect(env["BUNDLE_GEMFILE"]).to eq("Gemfile.next") + expect(env["BUNDLE_CACHE_PATH"]).to eq("vendor/cache.next") + expect(argv).not_to include("bin/next") + end + + it "leaves CI alone, since an env that eager-loads at boot is captured, not refused" do + # The runner attaches before initialize!, so config.eager_load = ENV["CI"].present? + # eager-loading during boot is fine. Unsetting CI would only hide the config CI runs with. + env, * = described_class.boot_command(output_path: "out.json") + expect(env).not_to have_key("CI") + end + + it "takes the app root from the caller so the runner can find config/application" do + env, * = described_class.boot_command(output_path: "out.json", app_root: "/somewhere/app") + expect(env["DEPRECATION_BOOT_APP_ROOT"]).to eq("/somewhere/app") + end + + it "leaves BUNDLE_GEMFILE and BUNDLE_CACHE_PATH at Bundler defaults for the current bundle" do + env, * = described_class.boot_command(output_path: "out.json") + expect(env).not_to have_key("BUNDLE_GEMFILE") + expect(env).not_to have_key("BUNDLE_CACHE_PATH") + end + + it "requires output_path (declared 2.0-safe: optional kwarg + guard, not a required kwarg)" do + expect { described_class.boot_command }.to raise_error(ArgumentError, /output_path/) + end + + it "passes the output path as an env value, never spliced into a shell string" do + awkward = "a path with spaces.json" + env, *argv = described_class.boot_command(output_path: awkward) + expect(env["DEPRECATION_BOOT_OUTPUT"]).to eq(awkward) + expect(argv).to eq(["bundle", "exec", "ruby", described_class::RUNNER_PATH]) + expect(argv.join(" ")).not_to include(awkward) + end + end + + describe ".command_display" do + it "renders a readable line and omits unset (nil) env vars" do + display = described_class.command_display(described_class.boot_command(output_path: "out.json")) + expect(display).to include("RAILS_ENV=test") + expect(display).to include("DEPRECATION_BOOT_OUTPUT=out.json") + expect(display).to include("bundle exec ruby #{described_class::RUNNER_PATH}") + end + + it "omits unset (nil) env vars rather than rendering them as blank assignments" do + display = described_class.command_display([{ "KEEP" => "1", "DROP" => nil }, "bundle"]) + expect(display).to include("KEEP=1") + expect(display).not_to include("DROP") + end + end + + describe ".default_output_path" do + it "follows the tracker's spec/support convention with a .boot marker" do + expect(described_class.default_output_path).to eq("spec/support/deprecation_warning.boot.shitlist.json") + end + + it "distinguishes the next bundle" do + expect(described_class.default_output_path(next_mode: true)).to eq("spec/support/deprecation_warning.boot.next.shitlist.json") + end + end + + describe "RUNNER_PATH" do + # Accepted limitation: the runner only does its real work against a real Rails + # app (require config/application, initialize!, eager_load!), which this gem's + # suite has no fixture for. The examples below assert the runner's *structure* + # (source text): that the attach happens before the app is required, that the + # deprecation config is set before initialize! and re-asserted after it, and that + # capture is delegated to DeprecationTracker. spec/deprecations_cli_spec.rb covers + # the CLI's boot branches against a stubbed `bundle`, so what is still unexercised + # is only the runner's body inside a real Rails app. + it "points at a real, gem-shipped script (no temp file written at runtime)" do + expect(File.file?(described_class::RUNNER_PATH)).to be(true) + end + + 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 + + it "reuses DeprecationTracker rather than reimplementing capture" do + script = File.read(described_class::RUNNER_PATH) + expect(script).to include("require \"deprecation_tracker\"") + expect(script).to include("DeprecationTracker.init_tracker") + expect(script).to include("tracker.bucket =") + expect(script).to include("application.eager_load!") + expect(script).to include("tracker.after_run") + # Reads the same output env the command sets, via the shared constant. + expect(script).to include("ENV.fetch(DeprecationTracker::BootCapture::OUTPUT_ENV)") + expect(described_class::OUTPUT_ENV).to eq("DEPRECATION_BOOT_OUTPUT") + end + + it "stores project-relative paths by stripping Rails.root (committable shitlist)" do + script = File.read(described_class::RUNNER_PATH) + expect(script).to include("transform_message") + expect(script).to include('gsub("#{Rails.root}/"') + end + + it "installs the tracker before requiring the app, so gem-load warnings are in scope" do + # KernelWarnTracker is patched in by init_tracker; requiring config/application + # is what loads the gems (Bundler.require), so the order here is the whole point. + script = File.read(described_class::RUNNER_PATH) + expect(script.index("DeprecationTracker.init_tracker")).to be < script.index("require application_path") + end + + it "boots the app itself instead of relying on an already-initialized one" do + script = File.read(described_class::RUNNER_PATH) + expect(script).to include("require application_path") + expect(script).to include("application.initialize!") + expect(script.index("application.initialize!")).to be < script.index("application.eager_load!") + end + + it "configures a recording, non-silenced, non-raising setup through config before initialize!" do + # Rails' own active_support.deprecation_behavior initializer assigns behavior from + # these config values (and silences everything when report_deprecations is false), + # so it would overwrite anything set on the deprecators directly beforehand. Going + # through config means Rails installs the collector, and on 7.1+ the collection + # propagates it to deprecators registered later. + script = File.read(described_class::RUNNER_PATH) + expect(script).to include("config.active_support.report_deprecations = true") + expect(script).to include("config.active_support.deprecation = [:stderr, collector]") + expect(script).to include("config.active_support.disallowed_deprecation = [:stderr, collector]") + expect(script.index("config.active_support.deprecation = [:stderr, collector]")).to be < script.index("application.initialize!") + end + + it "re-installs the collector from a railtie initializer behind :load_environment_config" do + # config/environments/.rb is loaded during initialize!, and a plain + # `config.active_support.deprecation = :stderr` there replaces the collector we + # configured beforehand. Re-installing right after that file is loaded is what + # keeps initializer-time and eager-load-time warnings recorded. (Seen for real: + # a Rails 3.2 app whose test.rb sets exactly that line.) + script = File.read(described_class::RUNNER_PATH) + expect(script).to include("class Railtie < Rails::Railtie") + expect(script).to include(":after => :load_environment_config") + expect(script.index("class Railtie < Rails::Railtie")).to be < script.index("application.initialize!") + end + + it "re-installs immediately before eager-load, and again after initialize!" do + # before_eager_load fires from the finisher after every initializer, covering the + # environments that eager-load during boot. The call after initialize! covers the + # ones that do not, where that hook never fires and we eager-load ourselves. + script = File.read(described_class::RUNNER_PATH) + expect(script).to include("ActiveSupport.on_load(:before_eager_load) { install_collector.call }") + last_call = script.rindex("install_collector.call") + expect(last_call).to be > script.index("application.initialize!") + expect(last_call).to be < script.index("application.eager_load!") + end + + it "installs on both the 7.1+ deprecators collection and the older singleton" do + script = File.read(described_class::RUNNER_PATH) + expect(script).to include("application.deprecators.silenced = false") + expect(script).to include("ActiveSupport::Deprecation.silenced = false") + end + + it "refuses with a distinct exit code when there is no app to boot" do + script = File.read(described_class::RUNNER_PATH) + expect(script).to include("exit DeprecationTracker::BootCapture::NO_APP_EXIT") + expect(script.index("NO_APP_EXIT")).to be < script.index("require application_path") + end + end + + describe ".partial_path_for" do + it "is the output path plus .partial (a sibling, for a same-dir atomic rename)" do + expect(described_class.partial_path_for("spec/support/x.json")).to eq("spec/support/x.json.partial") + end + end + + describe ".boot_result" do + it "flags a missing app by its exit status, whatever else happened" do + expect(described_class.boot_result(false, described_class::NO_APP_EXIT, false)).to eq(:no_app) + end + + it "is :failed when the process failed or wrote no partial" do + expect(described_class.boot_result(false, 1, true)).to eq(:failed) # non-zero exit + expect(described_class.boot_result(true, 0, false)).to eq(:failed) # no output written + expect(described_class.boot_result(nil, nil, false)).to eq(:failed) # Ctrl-C: system -> nil + end + + it "is :ok only when the process succeeded and the partial was written" do + expect(described_class.boot_result(true, 0, true)).to eq(:ok) + end + end + + describe "DeprecationTracker save outside a test process (boot runs it via `rails runner`)" do + it "saves from a bare Ruby process that hasn't loaded the stdlib RSpec pulls in" do + # rails runner in a slim app is such a process; save uses Tempfile/FileUtils. + # A subprocess is the only way to prove the requires, since RSpec has already + # loaded them here. Fails with NameError before the requires were added. + lib = File.expand_path("../../lib", __dir__) + path = File.join(Dir.tmpdir, "nr-boot-#{Process.pid}-#{rand(100_000)}.json") + script = "require 'deprecation_tracker'; " \ + "t = DeprecationTracker.new(#{path.inspect}, nil, :save); " \ + "t.bucket = 'boot'; t.add('x'); t.after_run" + begin + expect(system(RbConfig.ruby, "-I#{lib}", "-e", script)).to be(true) + expect(File.exist?(path)).to be(true) + ensure + FileUtils.rm_f(path) + end + end + end +end From 4952e92f38f8bb65674de9b682f49947292ef9ff Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Juan=20V=C3=A1squez?= Date: Wed, 5 Aug 2026 11:22:48 -0600 Subject: [PATCH 2/3] Add `boot` mode to the deprecations CLI `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. --- README.md | 24 +++++++- exe/deprecations | 83 +++++++++++++++++++++++--- spec/deprecations_cli_spec.rb | 107 +++++++++++++++++++++++++++++++--- 3 files changed, 197 insertions(+), 17 deletions(-) diff --git a/README.md b/README.md index bd7cad6..7a7f609 100644 --- a/README.md +++ b/README.md @@ -9,7 +9,7 @@ A toolkit to upgrade your next Rails application. It helps you set up **dual boo ## Features - **Dual Boot** — Run your app against two sets of dependencies (e.g. Rails 7.1 and Rails 7.2) side by side -- **Deprecation Tracking** — Capture and compare deprecation warnings across test runs (RSpec & Minitest) +- **Deprecation Tracking** — Capture and compare deprecation warnings across test runs (RSpec & Minitest), and capture boot-time warnings via `deprecations boot` - **Bundle Report** — Check gem compatibility with a target Rails or Ruby version - **Ruby Check** — Find the minimum Ruby version compatible with a target Rails version @@ -190,6 +190,26 @@ DEPRECATION_TRACKER=save rspec DEPRECATION_TRACKER=compare rspec ``` +### Boot-time deprecations + +The test-run tracker above attaches per-example, so it only sees deprecations raised *while a test runs*. It structurally misses everything the app emits while **booting**: association / scope / callback declaration warnings that fire when a class body is evaluated, plus whatever the initializers warn about. `deprecations boot` catches those. It boots the app itself, attaching the tracker before any initializer runs, then eager-loads, prints an `info`-style summary, and writes an ordinary shitlist. (Still out of scope: anything emitted from `config/boot.rb`, and deprecator-based warnings during `Bundler.require`, since loading `config/application.rb` is what requires the gems. Plain `Kernel#warn` output during gem load *is* captured.) + +```bash +# Capture boot-time deprecations on the current bundle +deprecations boot + +# ...or on the next bundle (dual boot): sets BUNDLE_GEMFILE=Gemfile.next and +# BUNDLE_CACHE_PATH=vendor/cache.next, the pair `next` uses. +deprecations --next boot +``` + +The result is written to `spec/support/deprecation_warning.boot.shitlist.json` (or `deprecation_warning.boot.next.shitlist.json` with `--next`), keyed under a single `boot` bucket. The summary is printed when the capture runs; commit the file to diff later captures against it. + +> [!NOTE] +> Use `--next` only once the next bundle already boots cleanly (dual boot set up and breaking changes fixed — see [Dual Boot](#dual-boot)); that's when it captures the next version's warnings in bulk. If the app can't boot, `boot` exits non-zero with the reason, a load failure (exit 1) or no `config/application.rb` in this directory (exit 4), rather than a misleading clean result. Only a genuine clean boot reports "no deprecation warnings." +> +> Run it from the app root; that is where it looks for `config/application.rb`. `config.eager_load` can be either value: the tracker is attached before the app initializes, so an environment that eager-loads during boot is captured just the same. For the duration of the capture the app runs with deprecations recorded and printed to stderr, never silenced and never raising, even if its own config says otherwise. + ### Parallel CI support When running tests across parallel CI nodes, each node can write to its own shard file to avoid conflicts. The tracker auto-detects the node index from common CI environment variables (`CI_NODE_INDEX`, `CIRCLE_NODE_INDEX`, `BUILDKITE_PARALLEL_JOB`, `SEMAPHORE_JOB_INDEX`, `CI_NODE_INDEX` for GitLab), or you can set it explicitly via the `node_index` option. @@ -254,6 +274,8 @@ deprecations info deprecations info --pattern "ActiveRecord::Base" deprecations merge --delete-shards deprecations run +deprecations boot # capture boot-time deprecations (see "Boot-time deprecations") +deprecations --next boot # same, on the next bundle deprecations --help ``` diff --git a/exe/deprecations b/exe/deprecations index 4021252..6c40c66 100755 --- a/exe/deprecations +++ b/exe/deprecations @@ -2,9 +2,11 @@ require "json" require "optparse" require "set" +require "fileutils" require_relative "../lib/next_rails/tint" require_relative "../lib/deprecation_tracker/valid_modes" require_relative "../lib/deprecation_tracker/shard_merger" +require_relative "../lib/deprecation_tracker/boot_capture" def run_tests(deprecation_warnings, opts = {}) tracker_mode = DeprecationTracker.sanitize_mode(opts[:tracker_mode]) @@ -25,10 +27,13 @@ end def print_info(deprecation_warnings, opts = {}) verbose = !!opts[:verbose] - frequency_by_message = deprecation_warnings.each_with_object({}) do |(test_file, messages), hash| + # Bucket label for the verbose line. Test-run shitlists bucket by spec file + # ("Test files"); a boot capture buckets under the single synthetic "boot". + bucket_label = deprecation_warnings.keys == ["boot"] ? "Source" : "Test files" + frequency_by_message = deprecation_warnings.each_with_object({}) do |(bucket, messages), hash| messages.each do |message| - hash[message] ||= { test_files: Set.new, occurrences: 0 } - hash[message][:test_files] << test_file + hash[message] ||= { buckets: Set.new, occurrences: 0 } + hash[message][:buckets] << bucket hash[message][:occurrences] += 1 end end.sort_by {|message, data| data[:occurrences] }.reverse.to_h @@ -36,12 +41,62 @@ def print_info(deprecation_warnings, opts = {}) puts NextRails::Tint("Ten most common deprecation warnings:").underline frequency_by_message.take(10).each do |message, data| puts NextRails::Tint("Occurrences: #{data.fetch(:occurrences)}").bold - puts "Test files: #{data.fetch(:test_files).to_a.join(" ")}" if verbose + puts "#{bucket_label}: #{data.fetch(:buckets).to_a.join(" ")}" if verbose puts NextRails::Tint(message).red puts "----------" end end +def run_boot(opts = {}) + next_mode = !!opts[:next] + output_path = DeprecationTracker::BootCapture.default_output_path(next_mode: next_mode) + # Write to a partial file and only replace the real output on success, so a boot + # that fails or refuses never destroys a previous good capture. The stale partial + # is cleared below, and the runner writes only when it reaches after_run, so a + # partial that exists afterward was written by THIS run, and a missing one means + # the run never got that far (the app failed to boot or to eager-load). + partial_path = DeprecationTracker::BootCapture.partial_path_for(output_path) + + FileUtils.mkdir_p(File.dirname(output_path)) + FileUtils.rm_f(partial_path) # clear a stale partial, never the real output + + command = DeprecationTracker::BootCapture.boot_command( + output_path: partial_path, + next_mode: next_mode + ) + puts DeprecationTracker::BootCapture.command_display(command) + booted = system(*command) # array form: no shell, so output_path can't inject + status = $? + exit_status = status ? status.exitstatus : nil + + case DeprecationTracker::BootCapture.boot_result(booted, exit_status, File.exist?(partial_path)) + when :no_app + # The runner already printed the specific explanation; surface its exit code + # rather than the generic guess below. + FileUtils.rm_f(partial_path) + exit exit_status + when :failed + FileUtils.rm_f(partial_path) + STDERR.puts NextRails::Tint("Boot did not complete — the app failed to load (see the error above).").red + STDERR.puts "No shitlist was written, so this is NOT a clean result. A common cause is a missing" + STDERR.puts "config/database.yml or unset env in this checkout. Fix the boot error and re-run." + exit 1 + end + + # Success: atomically replace the previous capture (same dir, so it's a rename). + FileUtils.mv(partial_path, output_path) + + # The runner wrote an ordinary shitlist; reuse print_info to summarize it. + deprecation_warnings = JSON.parse(File.read(output_path)) + if deprecation_warnings.empty? + puts NextRails::Tint("Boot completed cleanly, no deprecation warnings while loading the app.").green + return + end + + puts NextRails::Tint("Boot-time deprecations written to #{output_path}").underline + print_info(deprecation_warnings, verbose: opts[:verbose]) +end + options = {} option_parser = OptionParser.new do |opts| opts.banner = <<-MESSAGE @@ -50,11 +105,13 @@ option_parser = OptionParser.new do |opts| Parses the deprecation warning shitlist and show info or run tests. Examples: - bin/deprecations info # Show top ten deprecations - bin/deprecations --next info # Show top ten deprecations for Rails 5 + bin/deprecations info # Show top ten deprecations + bin/deprecations --next info # Show top ten deprecations for Rails 5 + bin/deprecations boot # Capture boot-time (eager-load) deprecations the test tracker can't see + bin/deprecations --next boot # Same, booting the next bundle bin/deprecations --pattern "ActiveRecord::Base" --verbose info # Show full details on deprecations matching pattern - bin/deprecations --tracker-mode save --pattern "pass" run # Run tests that output deprecations matching pattern and update shitlist - bin/deprecations merge --delete-shards # Merge parallel CI shards and remove shard files + bin/deprecations --tracker-mode save --pattern "pass" run # Run tests that output deprecations matching pattern and update shitlist + bin/deprecations merge --delete-shards # Merge parallel CI shards and remove shard files Modes: info @@ -66,6 +123,9 @@ option_parser = OptionParser.new do |opts| merge Merge parallel CI shard files into the canonical shitlist. Use with --delete-shards to remove shard files after merging. + boot + Boot the app with the tracker listening, catching the deprecations the per-test tracker never sees: those from the initializers and from class bodies during eager-load. Writes spec/support/deprecation_warning.boot[.next].shitlist.json (bucket "boot") and summarizes it like `info`. + Options: MESSAGE @@ -107,7 +167,12 @@ when "merge" result = output[:result] 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] + run_boot(next: options[:next], verbose: options[:verbose]) when "run", "info" + abort "No shitlist found at #{path}." unless File.exist?(path) + pattern_string = options.fetch(:pattern, ".+") pattern = /#{pattern_string}/ @@ -127,7 +192,7 @@ when "run", "info" print_info(deprecation_warnings, verbose: options[:verbose]) end when nil - STDERR.puts NextRails::Tint("Must pass a mode: run, info, or merge").red + STDERR.puts NextRails::Tint("Must pass a mode: run, info, merge, or boot").red puts option_parser exit 1 else diff --git a/spec/deprecations_cli_spec.rb b/spec/deprecations_cli_spec.rb index 9cf6ee5..d9dfa6e 100644 --- a/spec/deprecations_cli_spec.rb +++ b/spec/deprecations_cli_spec.rb @@ -6,12 +6,13 @@ require "tmpdir" require "fileutils" require "rbconfig" +require "deprecation_tracker/boot_capture" # Smoke tests that actually execute exe/deprecations. Everything here runs the real # script in a subprocess, so it catches the class of breakage unit tests on lib/ -# cannot: a missing require, or a mode that raises before doing any work. Two shipped -# bugs (an undeclared `rainbow` require and a NoMethodError in `run`) survived -# precisely because nothing ever loaded this executable. +# cannot: a missing require, a mode that raises before doing any work, a flag guard +# that never fires. Two shipped bugs (an undeclared `rainbow` require and a +# NoMethodError in `run`) survived precisely because nothing ever loaded this file. RSpec.describe "exe/deprecations" do def cli_path File.expand_path("../exe/deprecations", __dir__) @@ -22,11 +23,22 @@ def shitlist_path end # Runs the CLI in `chdir` and returns [stdout, stderr, exitstatus]. - def run_cli(args, chdir:) - stdout, stderr, status = Open3.capture3(RbConfig.ruby, cli_path, *args, chdir: chdir) + def run_cli(args, chdir:, env: {}) + stdout, stderr, status = Open3.capture3(env, RbConfig.ruby, cli_path, *args, chdir: chdir) [stdout, stderr, status.exitstatus] end + # A stand-in for `bundle` on PATH, so the boot branches can be driven without a + # Rails app. `behavior` is the body of a /bin/sh script. + def stub_bundle(dir, behavior) + bin = File.join(dir, "fake_bin") + Dir.mkdir(bin) unless File.directory?(bin) + path = File.join(bin, "bundle") + File.write(path, "#!/bin/sh\n#{behavior}\n") + File.chmod(0o755, path) + { "PATH" => "#{bin}:#{ENV["PATH"]}" } + end + around do |example| Dir.mktmpdir("deprecations-cli") do |dir| @dir = dir @@ -72,12 +84,21 @@ def write_shitlist(relative, contents) expect(stdout).not_to include("partial rendering") end - it "lists the test files with --verbose" do + it "labels spec-file buckets as test files" do shitlist stdout, _stderr, = run_cli(["info", "--verbose"], chdir: dir) expect(stdout).to include("Test files: ") expect(stdout).to include("user_spec.rb") + expect(stdout).not_to include("Source:") + end + + it "aborts with a readable message when the shitlist is missing" do + _stdout, stderr, status = run_cli(["info"], chdir: dir) + + expect(status).to eq(1) + expect(stderr).to include("No shitlist found at") + expect(stderr).not_to include("Errno::ENOENT") end it "exits non-zero when no message matches --pattern" do @@ -92,13 +113,14 @@ def write_shitlist(relative, contents) describe "run" do # Regression: run called DeprecationTracker.sanitize_mode while the CLI only # required valid_modes, so every invocation died with NoMethodError before doing - # any work. Reaching the mode validation at all proves the tracker is loaded. + # any work. Reaching the mode validation at all proves the method resolves. it "validates --tracker-mode instead of raising NoMethodError" do shitlist _stdout, stderr, status = run_cli(["run", "--tracker-mode", "bogus"], chdir: dir) expect(status).to eq(1) expect(stderr).to include("Invalid --tracker-mode") + expect(stderr).to include("save, compare") expect(stderr).not_to include("NoMethodError") end end @@ -157,4 +179,75 @@ def write_shitlist(relative, contents) expect(stderr).to include("Unknown mode") end end + + describe "boot" do + let(:output_path) { File.join(dir, "spec/support/deprecation_warning.boot.shitlist.json") } + let(:partial_path) { "#{output_path}.partial" } + + before { File.write(output_path, JSON.generate("boot" => ["PREVIOUS CAPTURE"])) } + + it "rejects --pattern, which it cannot apply" do + _stdout, stderr, status = run_cli(["boot", "--pattern", "anything"], chdir: dir) + + expect(status).to eq(1) + expect(stderr).to include("--pattern is not supported with 'boot'") + end + + it "tells the runner where the app root is" do + env = stub_bundle(dir, 'echo "{\"boot\":[\"root=$DEPRECATION_BOOT_APP_ROOT\"]}" > "$DEPRECATION_BOOT_OUTPUT"') + + stdout, _stderr, status = run_cli(["boot"], chdir: dir, env: env) + + expect(status).to eq(0) + # Compared through realpath: on macOS the tmpdir is reached via a /private symlink. + expect(stdout).to include("root=#{File.realpath(dir)}") + end + + it "promotes the partial and summarizes it when the boot succeeds" do + env = stub_bundle(dir, 'echo \'{"boot":["DEPRECATION WARNING: captured"]}\' > "$DEPRECATION_BOOT_OUTPUT"') + + stdout, _stderr, status = run_cli(["boot"], chdir: dir, env: env) + + expect(status).to eq(0) + expect(stdout).to include("Boot-time deprecations written to") + expect(stdout).to include("captured") + expect(JSON.parse(File.read(output_path))).to eq("boot" => ["DEPRECATION WARNING: captured"]) + expect(File.exist?(partial_path)).to be(false) + end + + it "reports a clean boot when the capture is empty" do + env = stub_bundle(dir, 'echo \'{}\' > "$DEPRECATION_BOOT_OUTPUT"') + + stdout, _stderr, status = run_cli(["boot"], chdir: dir, env: env) + + expect(status).to eq(0) + expect(stdout).to include("Boot completed cleanly") + expect(File.exist?(partial_path)).to be(false) + end + + it "keeps the previous capture when the app fails to boot" do + env = stub_bundle(dir, "echo 'boom' >&2; exit 1") + + _stdout, stderr, status = run_cli(["boot"], chdir: dir, env: env) + + expect(status).to eq(1) + expect(stderr).to include("Boot did not complete") + expect(JSON.parse(File.read(output_path))).to eq("boot" => ["PREVIOUS CAPTURE"]) + expect(File.exist?(partial_path)).to be(false) + end + + it "surfaces the runner's own explanation when it refuses to capture" do + exit_code = DeprecationTracker::BootCapture::NO_APP_EXIT + env = stub_bundle(dir, "echo 'no config/application.rb under here' >&2; exit #{exit_code}") + + _stdout, stderr, status = run_cli(["boot"], chdir: dir, env: env) + + expect(status).to eq(exit_code) + expect(stderr).to include("no config/application.rb") + # The generic guess must not bury the runner's specific explanation. + expect(stderr).not_to include("Boot did not complete") + expect(JSON.parse(File.read(output_path))).to eq("boot" => ["PREVIOUS CAPTURE"]) + expect(File.exist?(partial_path)).to be(false) + end + end end From d2e25ff86b534507912b31f20d91655129329f14 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Juan=20V=C3=A1squez?= Date: Wed, 5 Aug 2026 13:28:50 -0600 Subject: [PATCH 3/3] Add CHANGELOG entry for `deprecations boot` --- CHANGELOG.md | 1 + 1 file changed, 1 insertion(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 7603a50..9f6d044 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,7 @@ - [BUGFIX: `bundle_report outdated` no longer confuses a locally-sourced (`path:`) gem with a same-named public gem on rubygems; local gems are excluded from the out-of-date check and counted separately](https://github.com/fastruby/next_rails/pull/189) - [BUGFIX: The `deprecations` executable no longer requires the undeclared `rainbow` gem, which made it fail to load on a clean install](https://github.com/fastruby/next_rails/pull/197) - [BUGFIX: `deprecations run` no longer raises `NoMethodError` before doing any work](https://github.com/fastruby/next_rails/pull/198) +- [FEATURE: Add `deprecations boot` to capture the deprecation warnings the per-test tracker misses: those emitted while the app boots, from the initializers and from class bodies during eager-load](https://github.com/fastruby/next_rails/pull/199) * Your changes/patches go here.