From bd1d2200c240d09e935330120f443d49d43f87b2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Juan=20V=C3=A1squez?= Date: Fri, 24 Jul 2026 13:56:44 -0600 Subject: [PATCH 01/22] Add DeprecationTracker::BootCapture for load-time deprecations Capture deprecation warnings emitted while the app loads and eager-loads, the surface the per-example test tracker structurally misses (association / scope / callback declaration warnings that fire when a class body is evaluated). It does not reimplement capture: a gem-shipped runner script drives the existing DeprecationTracker (init_tracker installs the version-correct hooks across Rails 2-8.1 -- Rails.application.deprecators on 7.1+, the ActiveSupport::Deprecation singleton before that, plus KernelWarnTracker), sets a bucket, eager-loads so declaration-time warnings fire while it listens, then after_run writes an ordinary shitlist. BootCapture only builds the boot command and resolves the shitlist path (spec/support convention, ".boot" marker, ".next" for the next bundle). --- lib/deprecation_tracker/boot_capture.rb | 46 +++++++++++++++++++ .../boot_capture_runner.rb | 22 +++++++++ 2 files changed, 68 insertions(+) create mode 100644 lib/deprecation_tracker/boot_capture.rb create mode 100644 lib/deprecation_tracker/boot_capture_runner.rb diff --git a/lib/deprecation_tracker/boot_capture.rb b/lib/deprecation_tracker/boot_capture.rb new file mode 100644 index 0000000..23339fe --- /dev/null +++ b/lib/deprecation_tracker/boot_capture.rb @@ -0,0 +1,46 @@ +# frozen_string_literal: true + +class DeprecationTracker + # Capture deprecation warnings emitted at BOOT time -- while the app runs its + # initializers and eager-loads its classes -- rather than during a test run. + # + # This is the one deprecation surface the test-run tracker structurally misses. + # `track_rspec` / `track_minitest` attach per-example, so a warning that fires + # when a class body is evaluated (association / scope / callback declarations) + # never reaches them. Eager-loading the whole app with the tracker already + # listening surfaces exactly those. + # + # 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 boots the app and runs that script. + # + # 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 and executed via `rails runner`. + 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" + + # 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 boots the app and runs the gem's runner script. The next + # bundle is selected with BUNDLE_GEMFILE=Gemfile.next (what `bin/next` wraps, + # and it works in projects that never generated the shim). RAILS_ENV=test + # skips dev-only initializers and matches CI. + def self.boot_command(output_path:, next_mode: false) + env = "RAILS_ENV=test" + env += " BUNDLE_GEMFILE=Gemfile.next" if next_mode + env += " #{OUTPUT_ENV}=#{output_path}" + "#{env} bundle exec rails runner #{RUNNER_PATH}" + 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..cf83af9 --- /dev/null +++ b/lib/deprecation_tracker/boot_capture_runner.rb @@ -0,0 +1,22 @@ +# frozen_string_literal: true + +# Executed inside a booted app via `rails runner` (see DeprecationTracker::BootCapture.boot_command). +# NOT meant to be `require`d -- it runs on load. +# +# Reuses DeprecationTracker: init_tracker installs the version-correct deprecation +# hooks (Rails.application.deprecators on 7.1+, the ActiveSupport::Deprecation +# singleton before that, plus KernelWarnTracker). We give it a bucket so `add` +# records, eager-load so declaration-time warnings (associations, scopes, +# callbacks) fire while it is listening, then `after_run` writes the shitlist. +# +# Eager-loading is the whole point of this command -- those declaration-time +# warnings are exactly what the per-test tracker misses -- so it is not optional. +require "deprecation_tracker" + +tracker = DeprecationTracker.init_tracker( + :shitlist_path => ENV.fetch("DEPRECATION_BOOT_OUTPUT"), + :mode => "save" +) +tracker.bucket = "boot" +Rails.application.eager_load! +tracker.after_run From b392fd81bf95cf7792e06c5042e5ea30555919cd Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Juan=20V=C3=A1squez?= Date: Fri, 24 Jul 2026 13:56:44 -0600 Subject: [PATCH 02/22] Add `boot` mode to the deprecations CLI `deprecations boot` (and `--next boot`) boots the app, runs the BootCapture runner, and reuses print_info to summarize the resulting shitlist. Adds the --output option and help text. A boot failure is reported honestly: the result file is cleared first, and a non-zero exit or missing output prints "Boot did not complete ... NOT a clean result" and exits 1, so a crashing boot is never mistaken for "no deprecations." Selecting the next bundle uses BUNDLE_GEMFILE=Gemfile.next rather than bin/next, which need not exist in every project. --- exe/deprecations | 68 ++++++++++++++++++++++++++++++++++++++++++++---- 1 file changed, 63 insertions(+), 5 deletions(-) diff --git a/exe/deprecations b/exe/deprecations index baf914c..a4c0ad6 100755 --- a/exe/deprecations +++ b/exe/deprecations @@ -5,6 +5,8 @@ require "optparse" require "set" require_relative "../lib/deprecation_tracker/valid_modes" require_relative "../lib/deprecation_tracker/shard_merger" +require_relative "../lib/deprecation_tracker/boot_capture" +require "fileutils" def run_tests(deprecation_warnings, opts = {}) tracker_mode = DeprecationTracker.sanitize_mode(opts[:tracker_mode]) @@ -42,6 +44,43 @@ def print_info(deprecation_warnings, opts = {}) end end +def run_boot(opts = {}) + next_mode = !!opts[:next] + output_path = opts[:output] || DeprecationTracker::BootCapture.default_output_path(next_mode: next_mode) + + FileUtils.mkdir_p(File.dirname(output_path)) + + # Remove any prior result so its presence afterward proves THIS boot wrote it. + # The runner only writes the shitlist once it reaches `after_run`, so a missing + # (or non-zero-exit) result means the app failed to boot — which must not be + # mistaken for "no deprecations." + FileUtils.rm_f(output_path) + + command = DeprecationTracker::BootCapture.boot_command( + output_path: output_path, + next_mode: next_mode + ) + puts command + booted = system(command) + + if !booted || !File.exist?(output_path) + STDERR.puts Rainbow("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 + + # 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 Rainbow("Boot completed cleanly — no deprecation warnings at load time.").green + return + end + + puts Rainbow("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 +89,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 +107,13 @@ 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 and eager-load it with DeprecationTracker listening, to catch + the deprecations that fire at load time — association/scope/callback + declaration warnings the per-test tracker never sees. Writes an ordinary + shitlist (bucket "boot") under spec/support and summarizes it like `info`. + A boot failure is reported as such (exit 1), never as "no deprecations." + Options: MESSAGE @@ -89,6 +137,10 @@ option_parser = OptionParser.new do |opts| options[:delete_shards] = true end + opts.on("--output PATH", "boot: write the boot shitlist to PATH (default: spec/support/deprecation_warning.boot[.next].shitlist.json)") do |output| + options[:output] = output + end + opts.on_tail("-h", "--help", "Prints this help") do puts opts exit @@ -107,6 +159,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" + run_boot( + next: options[:next], + output: options[:output], + verbose: options[:verbose] + ) when "run", "info" pattern_string = options.fetch(:pattern, ".+") pattern = /#{pattern_string}/ @@ -127,7 +185,7 @@ when "run", "info" print_info(deprecation_warnings, verbose: options[:verbose]) end when nil - STDERR.puts Rainbow("Must pass a mode: run, info, or merge").red + STDERR.puts Rainbow("Must pass a mode: run, info, merge, or boot").red puts option_parser exit 1 else From 051aeef1979f39765fa1acc66b9eff2919daa16f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Juan=20V=C3=A1squez?= Date: Fri, 24 Jul 2026 13:56:44 -0600 Subject: [PATCH 03/22] Test BootCapture command, default paths, and runner script Covers the boot command shape (RAILS_ENV=test, output env, BUNDLE_GEMFILE for --next, no bin/next), the spec/support default paths, and that the shipped runner is valid Ruby that reuses DeprecationTracker and always eager-loads. --- spec/deprecation_tracker/boot_capture_spec.rb | 55 +++++++++++++++++++ 1 file changed, 55 insertions(+) create mode 100644 spec/deprecation_tracker/boot_capture_spec.rb diff --git a/spec/deprecation_tracker/boot_capture_spec.rb b/spec/deprecation_tracker/boot_capture_spec.rb new file mode 100644 index 0000000..6959b6a --- /dev/null +++ b/spec/deprecation_tracker/boot_capture_spec.rb @@ -0,0 +1,55 @@ +# frozen_string_literal: true + +require "spec_helper" + +require_relative "../../lib/deprecation_tracker/boot_capture" + +RSpec.describe DeprecationTracker::BootCapture do + describe ".boot_command" do + it "runs the gem's runner script under RAILS_ENV=test" do + command = described_class.boot_command(output_path: "spec/support/deprecation_warning.boot.shitlist.json") + expect(command).to start_with("RAILS_ENV=test ") + expect(command).to include("bundle exec rails runner #{described_class::RUNNER_PATH}") + expect(command).to include("DEPRECATION_BOOT_OUTPUT=spec/support/deprecation_warning.boot.shitlist.json") + end + + it "selects the next bundle with BUNDLE_GEMFILE, not bin/next" do + # bin/next may not exist in every project; BUNDLE_GEMFILE is what it wraps. + command = described_class.boot_command(output_path: "out.json", next_mode: true) + expect(command).to include("BUNDLE_GEMFILE=Gemfile.next") + expect(command).to include("bundle exec rails runner") + expect(command).not_to include("bin/next") + 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 + 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 + expect { RubyVM::InstructionSequence.compile(File.read(described_class::RUNNER_PATH)) }.not_to raise_error + end + + it "reuses DeprecationTracker rather than reimplementing capture, and always eager-loads" 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("Rails.application.eager_load!") + expect(script).to include("tracker.after_run") + # Reads the same output env the command sets. + expect(script).to include("ENV.fetch(\"DEPRECATION_BOOT_OUTPUT\")") + end + end +end From 8c41c0a60a4b76d93a93767abaf023a3b33987d8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Juan=20V=C3=A1squez?= Date: Fri, 24 Jul 2026 13:56:44 -0600 Subject: [PATCH 04/22] Document `deprecations boot` in the README Adds a Boot-time deprecations section (what it captures, the two commands, output path) plus a note on when --next applies, and lists the mode in the Features and command sections. --- README.md | 21 ++++++++++++++++++++- 1 file changed, 20 insertions(+), 1 deletion(-) diff --git a/README.md b/README.md index bd7cad6..a5c3113 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), plus load-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,23 @@ 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 the deprecations that fire when the app **loads** — association / scope / callback declaration warnings that fire when a class body is evaluated, which no test necessarily triggers. `deprecations boot` catches those: it boots the app and eager-loads it with the tracker already listening, then writes an ordinary shitlist you can read with `info`. + +```bash +# Capture load-time deprecations on the current bundle +deprecations boot + +# ...or on the next bundle (dual boot). This just prepends BUNDLE_GEMFILE=Gemfile.next. +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, and summarized like `info`. Override the path with `--output`. + +> [!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 and says so rather than reporting "no deprecations." + ### 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 +271,8 @@ deprecations info deprecations info --pattern "ActiveRecord::Base" deprecations merge --delete-shards deprecations run +deprecations boot # capture load-time deprecations (see "Boot-time deprecations") +deprecations --next boot # same, on the next bundle deprecations --help ``` From 5ef72066555e529c5f02bce7655b1fa104ac8bab Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Juan=20V=C3=A1squez?= Date: Fri, 24 Jul 2026 13:58:39 -0600 Subject: [PATCH 05/22] Add CHANGELOG entry for `deprecations boot` References PR #194 (the predicted next number). Update the link if the PR is assigned a different number when opened. --- CHANGELOG.md | 1 + 1 file changed, 1 insertion(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 5ef91b8..47cc6c4 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -3,6 +3,7 @@ - [BUGFIX: example](https://github.com/fastruby/next_rails/pull/) - [FEATURE: Validate the DeprecationTracker mode at initialization, treating a blank mode as the default `save`](https://github.com/fastruby/next_rails/pull/186) - [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) +- [FEATURE: Add `deprecations boot` to capture load-time (eager-load) deprecation warnings the per-test tracker misses, such as association/scope/callback declaration warnings](https://github.com/fastruby/next_rails/pull/194) * Your changes/patches go here. From d45bdfd0e6f06d934a5f27d5e7e195dfeb79f2a0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Juan=20V=C3=A1squez?= Date: Fri, 24 Jul 2026 14:58:49 -0600 Subject: [PATCH 06/22] Refuse boot capture when the env eager-loads at boot rails runner fully boots the app before this script loads, so an env with config.eager_load = true has already eager-loaded and the declaration-time warnings fired before the tracker could attach. Re-running eager_load! is blocked by Zeitwerk's @eager_loaded guard, so the capture silently reported "clean". Pass CI= so the stock Rails 7.1+ test env (eager_load = ENV["CI"].present?) does not eager-load, and refuse with a distinct exit code for apps that hardcode it, which the CLI surfaces instead of its generic boot-failure guess. --- README.md | 2 ++ exe/deprecations | 9 ++++++ lib/deprecation_tracker/boot_capture.rb | 26 ++++++++++++--- .../boot_capture_runner.rb | 32 +++++++++++++------ spec/deprecation_tracker/boot_capture_spec.rb | 18 +++++++++-- 5 files changed, 70 insertions(+), 17 deletions(-) diff --git a/README.md b/README.md index a5c3113..47efc9d 100644 --- a/README.md +++ b/README.md @@ -206,6 +206,8 @@ The result is written to `spec/support/deprecation_warning.boot.shitlist.json` ( > [!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 and says so rather than reporting "no deprecations." +> +> Capture must attach *before* eager-load, so the command needs `config.eager_load = false`. It passes `CI=` to keep the stock Rails 7.1+ test env (`config.eager_load = ENV["CI"].present?`) from eager-loading at boot; if your app hardcodes `config.eager_load = true`, `boot` refuses and tells you to set it false for the run. ### Parallel CI support diff --git a/exe/deprecations b/exe/deprecations index a4c0ad6..4004638 100755 --- a/exe/deprecations +++ b/exe/deprecations @@ -62,6 +62,13 @@ def run_boot(opts = {}) ) puts command booted = system(command) + status = $? + + # The runner refused because the env eager-loads at boot; it already printed the + # specific explanation, so don't bury it under the generic guess below. + if status && status.exitstatus == DeprecationTracker::BootCapture::EAGER_LOAD_EXIT + exit status.exitstatus + end if !booted || !File.exist?(output_path) STDERR.puts Rainbow("Boot did not complete — the app failed to load (see the error above).").red @@ -113,6 +120,8 @@ option_parser = OptionParser.new do |opts| declaration warnings the per-test tracker never sees. Writes an ordinary shitlist (bucket "boot") under spec/support and summarizes it like `info`. A boot failure is reported as such (exit 1), never as "no deprecations." + Needs config.eager_load = false (it passes CI= for the stock Rails + template); refuses if the app eager-loads at boot. Options: MESSAGE diff --git a/lib/deprecation_tracker/boot_capture.rb b/lib/deprecation_tracker/boot_capture.rb index 23339fe..edf849f 100644 --- a/lib/deprecation_tracker/boot_capture.rb +++ b/lib/deprecation_tracker/boot_capture.rb @@ -24,6 +24,12 @@ class BootCapture # Env var the command uses to tell the runner where to write the shitlist. OUTPUT_ENV = "DEPRECATION_BOOT_OUTPUT" + # Exit status the runner uses when it refuses because the environment + # eager-loads at boot (see boot_capture_runner.rb). The CLI branches on this + # so the runner's specific explanation isn't buried under a generic + # "boot failed" guess. + EAGER_LOAD_EXIT = 3 + # 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. @@ -32,12 +38,22 @@ def self.default_output_path(next_mode: false) File.join("spec", "support", name) end - # The command that boots the app and runs the gem's runner script. The next - # bundle is selected with BUNDLE_GEMFILE=Gemfile.next (what `bin/next` wraps, - # and it works in projects that never generated the shim). RAILS_ENV=test - # skips dev-only initializers and matches CI. + # The command that boots the app (via `rails runner`, which fully boots and so + # registers the framework deprecators the runner attaches to) and runs the + # gem's runner script. + # + # `CI=` blanks the CI env var for this one boot. The Rails 7.1+ generated + # test.rb sets `config.eager_load = ENV["CI"].present?`, so on CI the app + # would eager-load at boot — before the tracker can attach — and the runner + # would have to refuse. Forcing CI blank keeps that template's eager_load off + # so capture works even on CI; apps that hardcode `eager_load = true` are + # still caught by the runner's guard. + # + # The next bundle is selected with BUNDLE_GEMFILE=Gemfile.next (what `bin/next` + # wraps, and it works in projects that never generated the shim). RAILS_ENV=test + # skips dev-only initializers. def self.boot_command(output_path:, next_mode: false) - env = "RAILS_ENV=test" + env = "CI= RAILS_ENV=test" env += " BUNDLE_GEMFILE=Gemfile.next" if next_mode env += " #{OUTPUT_ENV}=#{output_path}" "#{env} bundle exec rails runner #{RUNNER_PATH}" diff --git a/lib/deprecation_tracker/boot_capture_runner.rb b/lib/deprecation_tracker/boot_capture_runner.rb index cf83af9..c46759f 100644 --- a/lib/deprecation_tracker/boot_capture_runner.rb +++ b/lib/deprecation_tracker/boot_capture_runner.rb @@ -1,17 +1,31 @@ # frozen_string_literal: true -# Executed inside a booted app via `rails runner` (see DeprecationTracker::BootCapture.boot_command). +# Executed inside a booted app via `rails runner` (see BootCapture.boot_command). # NOT meant to be `require`d -- it runs on load. # -# Reuses DeprecationTracker: init_tracker installs the version-correct deprecation -# hooks (Rails.application.deprecators on 7.1+, the ActiveSupport::Deprecation -# singleton before that, plus KernelWarnTracker). We give it a bucket so `add` -# records, eager-load so declaration-time warnings (associations, scopes, -# callbacks) fire while it is listening, then `after_run` writes the shitlist. -# -# Eager-loading is the whole point of this command -- those declaration-time -# warnings are exactly what the per-test tracker misses -- so it is not optional. +# Reuses DeprecationTracker: init_tracker installs the version-correct hooks +# (Rails.application.deprecators on 7.1+, the ActiveSupport::Deprecation singleton +# before that, plus KernelWarnTracker). We set a bucket, eager-load so +# declaration-time warnings (associations, scopes, callbacks) fire while the +# tracker listens, then after_run writes the shitlist. `rails runner` is what +# fully boots the app and registers the framework deprecators we attach to -- a +# hand-rolled boot leaves that collection empty. require "deprecation_tracker" +require "deprecation_tracker/boot_capture" + +# If this environment eager-loads at boot (config.eager_load = true), eager-load +# already ran before this script — the declaration-time warnings fired before the +# tracker could attach and are lost, and re-running eager_load! is a no-op. Refuse +# rather than report a false "clean". The CLI passes CI= to keep the stock Rails +# 7.1+ template (config.eager_load = ENV["CI"].present?) from eager-loading, so +# this only trips for apps that hardcode eager_load = true. Exit with a distinct +# status so the CLI surfaces this explanation instead of its generic guess. +if Rails.application.config.eager_load + STDERR.puts "deprecations boot: this environment eager-loads at boot (config.eager_load = true), " \ + "so load-time deprecations fired before capture could attach. Set config.eager_load = false " \ + "for this run (the CLI already passes CI= for the stock Rails template)." + exit DeprecationTracker::BootCapture::EAGER_LOAD_EXIT +end tracker = DeprecationTracker.init_tracker( :shitlist_path => ENV.fetch("DEPRECATION_BOOT_OUTPUT"), diff --git a/spec/deprecation_tracker/boot_capture_spec.rb b/spec/deprecation_tracker/boot_capture_spec.rb index 6959b6a..582ed36 100644 --- a/spec/deprecation_tracker/boot_capture_spec.rb +++ b/spec/deprecation_tracker/boot_capture_spec.rb @@ -6,9 +6,11 @@ RSpec.describe DeprecationTracker::BootCapture do describe ".boot_command" do - it "runs the gem's runner script under RAILS_ENV=test" do + it "runs the gem's runner script via rails runner under RAILS_ENV=test with CI blanked" do command = described_class.boot_command(output_path: "spec/support/deprecation_warning.boot.shitlist.json") - expect(command).to start_with("RAILS_ENV=test ") + # CI= keeps the stock Rails 7.1+ template from eager-loading at boot. + expect(command).to start_with("CI= RAILS_ENV=test ") + # `rails runner` fully boots the app, which registers the deprecators the runner attaches to. expect(command).to include("bundle exec rails runner #{described_class::RUNNER_PATH}") expect(command).to include("DEPRECATION_BOOT_OUTPUT=spec/support/deprecation_warning.boot.shitlist.json") end @@ -41,7 +43,7 @@ expect { RubyVM::InstructionSequence.compile(File.read(described_class::RUNNER_PATH)) }.not_to raise_error end - it "reuses DeprecationTracker rather than reimplementing capture, and always eager-loads" do + 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") @@ -51,5 +53,15 @@ # Reads the same output env the command sets. expect(script).to include("ENV.fetch(\"DEPRECATION_BOOT_OUTPUT\")") end + + it "refuses with a distinct exit code, before eager_load!, when the env eager-loads at boot" do + # If config.eager_load is true, eager-load already ran before the tracker + # attached — the warnings are lost, so refuse rather than say "clean," and + # exit with EAGER_LOAD_EXIT so the CLI can surface the runner's explanation. + script = File.read(described_class::RUNNER_PATH) + expect(script).to include("Rails.application.config.eager_load") + expect(script).to include("exit DeprecationTracker::BootCapture::EAGER_LOAD_EXIT") + expect(script.index("config.eager_load")).to be < script.index("Rails.application.eager_load!") + end end end From 924f958a5669731fcbca3019d6b1bdf76beb0451 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Juan=20V=C3=A1squez?= Date: Fri, 24 Jul 2026 14:58:49 -0600 Subject: [PATCH 07/22] Ignore Gemfile.next (dual-boot / capture test artifact) --- .gitignore | 1 + 1 file changed, 1 insertion(+) diff --git a/.gitignore b/.gitignore index 235bff1..94a1baf 100644 --- a/.gitignore +++ b/.gitignore @@ -14,4 +14,5 @@ .ruby-version Gemfile.lock +Gemfile.next .gem From 70feecd1d52fed9d3b72adaf7c9ccd5d3cf1ebad Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Juan=20V=C3=A1squez?= Date: Fri, 24 Jul 2026 15:26:21 -0600 Subject: [PATCH 08/22] Document that boot capture covers eager-load only rails runner finishes initialize! before the runner script loads, and init_tracker appends to the deprecators registered at that moment, so warnings from gem requires and initializers are already gone. The docs and the success message claimed "load-time" coverage the command does not have; the strongest case was "no deprecation warnings at load time" printed on an empty result. Narrow the wording to eager-load and state the out-of-scope surface where each claim is made. Those warnings still print to stderr during this boot, so they are visible, just not collected. --- CHANGELOG.md | 2 +- README.md | 8 ++++---- exe/deprecations | 8 +++++--- lib/deprecation_tracker/boot_capture.rb | 19 ++++++++++++------- .../boot_capture_runner.rb | 8 ++++++-- 5 files changed, 28 insertions(+), 17 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 47cc6c4..fc036e5 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -3,7 +3,7 @@ - [BUGFIX: example](https://github.com/fastruby/next_rails/pull/) - [FEATURE: Validate the DeprecationTracker mode at initialization, treating a blank mode as the default `save`](https://github.com/fastruby/next_rails/pull/186) - [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) -- [FEATURE: Add `deprecations boot` to capture load-time (eager-load) deprecation warnings the per-test tracker misses, such as association/scope/callback declaration warnings](https://github.com/fastruby/next_rails/pull/194) +- [FEATURE: Add `deprecations boot` to capture eager-load-time deprecation warnings the per-test tracker misses — association/scope/callback declaration warnings that fire when a class body is evaluated](https://github.com/fastruby/next_rails/pull/194) * Your changes/patches go here. diff --git a/README.md b/README.md index 47efc9d..c9a1302 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), plus load-time warnings via `deprecations boot` +- **Deprecation Tracking** — Capture and compare deprecation warnings across test runs (RSpec & Minitest), plus eager-load-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 @@ -192,10 +192,10 @@ 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 the deprecations that fire when the app **loads** — association / scope / callback declaration warnings that fire when a class body is evaluated, which no test necessarily triggers. `deprecations boot` catches those: it boots the app and eager-loads it with the tracker already listening, then writes an ordinary shitlist you can read with `info`. +The test-run tracker above attaches per-example, so it only sees deprecations raised *while a test runs*. It structurally misses the deprecations that fire when the app **eager-loads** — association / scope / callback declaration warnings that fire when a class body is evaluated, which no test necessarily triggers. `deprecations boot` catches those: it boots the app and eager-loads it with the tracker already listening, then writes an ordinary shitlist you can read with `info`. (Warnings emitted earlier, during gem require or initializers, fire before the tracker attaches and are out of scope.) ```bash -# Capture load-time deprecations on the current bundle +# Capture eager-load-time deprecations on the current bundle deprecations boot # ...or on the next bundle (dual boot). This just prepends BUNDLE_GEMFILE=Gemfile.next. @@ -273,7 +273,7 @@ deprecations info deprecations info --pattern "ActiveRecord::Base" deprecations merge --delete-shards deprecations run -deprecations boot # capture load-time deprecations (see "Boot-time deprecations") +deprecations boot # capture eager-load-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 4004638..f9f0043 100755 --- a/exe/deprecations +++ b/exe/deprecations @@ -80,7 +80,7 @@ def run_boot(opts = {}) # 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 Rainbow("Boot completed cleanly — no deprecation warnings at load time.").green + puts Rainbow("Eager-load completed cleanly — no deprecation warnings while loading the app's classes.").green return end @@ -116,8 +116,10 @@ option_parser = OptionParser.new do |opts| boot Boot the app and eager-load it with DeprecationTracker listening, to catch - the deprecations that fire at load time — association/scope/callback - declaration warnings the per-test tracker never sees. Writes an ordinary + the deprecations that fire as class bodies are evaluated — association/ + scope/callback declaration warnings the per-test tracker never sees. + Scope is eager-load only: warnings from gem requires and initializers fire + before the tracker attaches and are printed, not collected. Writes an ordinary shitlist (bucket "boot") under spec/support and summarizes it like `info`. A boot failure is reported as such (exit 1), never as "no deprecations." Needs config.eager_load = false (it passes CI= for the stock Rails diff --git a/lib/deprecation_tracker/boot_capture.rb b/lib/deprecation_tracker/boot_capture.rb index edf849f..a8d205e 100644 --- a/lib/deprecation_tracker/boot_capture.rb +++ b/lib/deprecation_tracker/boot_capture.rb @@ -1,14 +1,19 @@ # frozen_string_literal: true class DeprecationTracker - # Capture deprecation warnings emitted at BOOT time -- while the app runs its - # initializers and eager-loads its classes -- rather than during a test run. + # 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 deprecation surface the test-run tracker structurally misses. - # `track_rspec` / `track_minitest` attach per-example, so a warning that fires - # when a class body is evaluated (association / scope / callback declarations) - # never reaches them. Eager-loading the whole app with the tracker already - # listening surfaces exactly those. + # 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. + # + # Scope: warnings emitted EARLIER in boot -- during `Bundler.require` or the + # framework/app initializers, before `rails runner` hands control to the runner + # script and the tracker attaches -- are NOT captured. Only eager-load-time (and + # later) warnings are in scope. # # It deliberately does NOT reimplement any capture logic. `boot_capture_runner.rb` # drives the existing DeprecationTracker (init_tracker installs the diff --git a/lib/deprecation_tracker/boot_capture_runner.rb b/lib/deprecation_tracker/boot_capture_runner.rb index c46759f..33c2be7 100644 --- a/lib/deprecation_tracker/boot_capture_runner.rb +++ b/lib/deprecation_tracker/boot_capture_runner.rb @@ -10,19 +10,23 @@ # tracker listens, then after_run writes the shitlist. `rails runner` is what # fully boots the app and registers the framework deprecators we attach to -- a # hand-rolled boot leaves that collection empty. +# +# The app is already booted by the time this runs, so warnings emitted during +# gem require / initializers fired before we attached and are out of scope; only +# eager-load-time warnings are captured. require "deprecation_tracker" require "deprecation_tracker/boot_capture" # If this environment eager-loads at boot (config.eager_load = true), eager-load # already ran before this script — the declaration-time warnings fired before the -# tracker could attach and are lost, and re-running eager_load! is a no-op. Refuse +# tracker could attach and are lost, and re-running eager_load! does nothing. Refuse # rather than report a false "clean". The CLI passes CI= to keep the stock Rails # 7.1+ template (config.eager_load = ENV["CI"].present?) from eager-loading, so # this only trips for apps that hardcode eager_load = true. Exit with a distinct # status so the CLI surfaces this explanation instead of its generic guess. if Rails.application.config.eager_load STDERR.puts "deprecations boot: this environment eager-loads at boot (config.eager_load = true), " \ - "so load-time deprecations fired before capture could attach. Set config.eager_load = false " \ + "so eager-load deprecations fired before capture could attach. Set config.eager_load = false " \ "for this run (the CLI already passes CI= for the stock Rails template)." exit DeprecationTracker::BootCapture::EAGER_LOAD_EXIT end From 20d4117b4b57c12db67928ccfdf40df37e3f043b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Juan=20V=C3=A1squez?= Date: Fri, 24 Jul 2026 15:44:59 -0600 Subject: [PATCH 09/22] Force a recording, non-fatal deprecation setup for boot capture A silenced deprecator (config.active_support.report_deprecations = false, or ActiveSupport::Deprecation.silenced = true) returns early in Reporting#warn before behavior is consulted, so capture recorded nothing and reported a false "clean". A :raise-configured env aborts on the first eager-load warning before after_run. Both defeat capture. Before attaching the collector, un-silence and force :stderr / disallowed :stderr via the collection-level setters, which update the collection's stored options so deprecators a gem/engine registers during eager-load inherit the non-fatal setup instead of the app's :raise. Mirrors init_tracker's deprecators-vs-singleton version fork. --- .../boot_capture_runner.rb | 24 +++++++++++++++++++ spec/deprecation_tracker/boot_capture_spec.rb | 11 +++++++++ 2 files changed, 35 insertions(+) diff --git a/lib/deprecation_tracker/boot_capture_runner.rb b/lib/deprecation_tracker/boot_capture_runner.rb index 33c2be7..e355ed3 100644 --- a/lib/deprecation_tracker/boot_capture_runner.rb +++ b/lib/deprecation_tracker/boot_capture_runner.rb @@ -31,6 +31,30 @@ exit DeprecationTracker::BootCapture::EAGER_LOAD_EXIT end +# Force a recording, non-fatal deprecation setup for the capture. Apps mid-upgrade +# commonly do one of these in the test env, and each would defeat capture: +# * silence deprecations (config.active_support.report_deprecations = false, or +# ActiveSupport::Deprecation.silenced = true) — Reporting#warn returns early on +# `silenced` before behavior is consulted, so nothing is recorded (false clean); +# * set deprecation = :raise — the first eager-load warning raises and aborts +# before after_run, leaving the CLI blaming a generic boot failure. +# We only want to RECORD warnings here, not silence or fail on them. Use the +# COLLECTION-level setters, not a per-deprecator loop: they update the collection's +# stored options, so a deprecator a gem/engine registers during eager-load inherits +# the same non-fatal setup instead of the app's :raise. init_tracker appends its +# collector after this, so both :stderr and the collector run. Mirrors init_tracker's +# deprecators-vs-singleton version fork. +# Note: this prints deprecations to stderr even for apps that normally silence them. +if defined?(Rails) && defined?(Rails.application) && defined?(Rails.application.deprecators) + Rails.application.deprecators.silenced = false + Rails.application.deprecators.behavior = :stderr + Rails.application.deprecators.disallowed_behavior = :stderr +elsif defined?(ActiveSupport) && defined?(ActiveSupport::Deprecation) + ActiveSupport::Deprecation.silenced = false + ActiveSupport::Deprecation.behavior = :stderr + ActiveSupport::Deprecation.disallowed_behavior = :stderr if ActiveSupport::Deprecation.respond_to?(:disallowed_behavior=) +end + tracker = DeprecationTracker.init_tracker( :shitlist_path => ENV.fetch("DEPRECATION_BOOT_OUTPUT"), :mode => "save" diff --git a/spec/deprecation_tracker/boot_capture_spec.rb b/spec/deprecation_tracker/boot_capture_spec.rb index 582ed36..ca12de5 100644 --- a/spec/deprecation_tracker/boot_capture_spec.rb +++ b/spec/deprecation_tracker/boot_capture_spec.rb @@ -63,5 +63,16 @@ expect(script).to include("exit DeprecationTracker::BootCapture::EAGER_LOAD_EXIT") expect(script.index("config.eager_load")).to be < script.index("Rails.application.eager_load!") end + + it "un-silences and forces a non-raising behavior before attaching the collector" do + # A silenced env records nothing (Reporting#warn returns early on `silenced`) + # and a :raise env aborts on the first warning; both defeat capture. Un-silence + # and force :stderr / disallowed :stderr first, before init_tracker and eager_load!. + script = File.read(described_class::RUNNER_PATH) + expect(script).to include("silenced = false") + expect(script).to include("behavior = :stderr") + expect(script).to include("disallowed_behavior = :stderr") + expect(script.index("silenced = false")).to be < script.index("DeprecationTracker.init_tracker") + end end end From 8e1f3716daf78766d0c4fa39d10a5191a21ddee3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Juan=20V=C3=A1squez?= Date: Fri, 24 Jul 2026 16:07:21 -0600 Subject: [PATCH 10/22] Build the boot command as argv instead of a shell string boot_command interpolated the operator-supplied --output path and the gem's RUNNER_PATH into one string passed to system, so a path containing spaces broke the run and --output 'x.json; rm -rf foo' executed. Both surfaced as the misleading "app failed to load / check database.yml". Return [env_hash, *argv] and call system(*command), which bypasses the shell entirely. Add command_display for the logged line, which is rendered but never executed. CI is now unset rather than set to an empty string, so apps testing ENV["CI"] for truthiness also see it absent. --- README.md | 2 +- exe/deprecations | 6 +-- lib/deprecation_tracker/boot_capture.rb | 41 +++++++++++++------ .../boot_capture_runner.rb | 4 +- spec/deprecation_tracker/boot_capture_spec.rb | 41 ++++++++++++++----- 5 files changed, 65 insertions(+), 29 deletions(-) diff --git a/README.md b/README.md index c9a1302..2bc012f 100644 --- a/README.md +++ b/README.md @@ -207,7 +207,7 @@ The result is written to `spec/support/deprecation_warning.boot.shitlist.json` ( > [!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 and says so rather than reporting "no deprecations." > -> Capture must attach *before* eager-load, so the command needs `config.eager_load = false`. It passes `CI=` to keep the stock Rails 7.1+ test env (`config.eager_load = ENV["CI"].present?`) from eager-loading at boot; if your app hardcodes `config.eager_load = true`, `boot` refuses and tells you to set it false for the run. +> Capture must attach *before* eager-load, so the command needs `config.eager_load = false`. It unsets `CI` to keep the stock Rails 7.1+ test env (`config.eager_load = ENV["CI"].present?`) from eager-loading at boot; if your app hardcodes `config.eager_load = true`, `boot` refuses and tells you to set it false for the run. ### Parallel CI support diff --git a/exe/deprecations b/exe/deprecations index f9f0043..c598e57 100755 --- a/exe/deprecations +++ b/exe/deprecations @@ -60,8 +60,8 @@ def run_boot(opts = {}) output_path: output_path, next_mode: next_mode ) - puts command - booted = system(command) + puts DeprecationTracker::BootCapture.command_display(command) + booted = system(*command) # array form: no shell, so output_path can't inject status = $? # The runner refused because the env eager-loads at boot; it already printed the @@ -122,7 +122,7 @@ option_parser = OptionParser.new do |opts| before the tracker attaches and are printed, not collected. Writes an ordinary shitlist (bucket "boot") under spec/support and summarizes it like `info`. A boot failure is reported as such (exit 1), never as "no deprecations." - Needs config.eager_load = false (it passes CI= for the stock Rails + Needs config.eager_load = false (it unsets CI for the stock Rails template); refuses if the app eager-loads at boot. Options: diff --git a/lib/deprecation_tracker/boot_capture.rb b/lib/deprecation_tracker/boot_capture.rb index a8d205e..b79a0fd 100644 --- a/lib/deprecation_tracker/boot_capture.rb +++ b/lib/deprecation_tracker/boot_capture.rb @@ -47,21 +47,36 @@ def self.default_output_path(next_mode: false) # registers the framework deprecators the runner attaches to) and runs the # gem's runner script. # - # `CI=` blanks the CI env var for this one boot. The Rails 7.1+ generated - # test.rb sets `config.eager_load = ENV["CI"].present?`, so on CI the app - # would eager-load at boot — before the tracker can attach — and the runner - # would have to refuse. Forcing CI blank keeps that template's eager_load off - # so capture works even on CI; apps that hardcode `eager_load = true` are - # still caught by the runner's guard. + # Returned as `[env_hash, *argv]` for `system(*command)` — NOT a shell string — + # so an operator-supplied `output_path` (via --output) and the gem's RUNNER_PATH + # can never be word-split or interpreted by a shell (e.g. a path with spaces, or + # `--output 'x.json; rm -rf foo'`). Values that would have needed quoting in a + # shell string are ordinary array elements / env values here. # - # The next bundle is selected with BUNDLE_GEMFILE=Gemfile.next (what `bin/next` - # wraps, and it works in projects that never generated the shim). RAILS_ENV=test - # skips dev-only initializers. + # Env keys: + # * CI => nil unsets CI for this one boot. The Rails 7.1+ generated test.rb sets + # `config.eager_load = ENV["CI"].present?`, so on CI the app would eager-load + # at boot — before the tracker can attach — and the runner would have to refuse. + # Unsetting CI keeps that template's eager_load off so capture works even on CI; + # apps that hardcode `eager_load = true` are still caught by the runner's guard. + # * BUNDLE_GEMFILE=Gemfile.next selects the next bundle (what `bin/next` wraps, + # and it works in projects that never generated the shim). + # * RAILS_ENV=test skips dev-only initializers. def self.boot_command(output_path:, next_mode: false) - env = "CI= RAILS_ENV=test" - env += " BUNDLE_GEMFILE=Gemfile.next" if next_mode - env += " #{OUTPUT_ENV}=#{output_path}" - "#{env} bundle exec rails runner #{RUNNER_PATH}" + env = { "CI" => nil, "RAILS_ENV" => "test", OUTPUT_ENV => output_path.to_s } + env["BUNDLE_GEMFILE"] = "Gemfile.next" if next_mode + [env, "bundle", "exec", "rails", "runner", 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 end end diff --git a/lib/deprecation_tracker/boot_capture_runner.rb b/lib/deprecation_tracker/boot_capture_runner.rb index e355ed3..1dc1dd9 100644 --- a/lib/deprecation_tracker/boot_capture_runner.rb +++ b/lib/deprecation_tracker/boot_capture_runner.rb @@ -20,14 +20,14 @@ # If this environment eager-loads at boot (config.eager_load = true), eager-load # already ran before this script — the declaration-time warnings fired before the # tracker could attach and are lost, and re-running eager_load! does nothing. Refuse -# rather than report a false "clean". The CLI passes CI= to keep the stock Rails +# rather than report a false "clean". The CLI unsets CI to keep the stock Rails # 7.1+ template (config.eager_load = ENV["CI"].present?) from eager-loading, so # this only trips for apps that hardcode eager_load = true. Exit with a distinct # status so the CLI surfaces this explanation instead of its generic guess. if Rails.application.config.eager_load STDERR.puts "deprecations boot: this environment eager-loads at boot (config.eager_load = true), " \ "so eager-load deprecations fired before capture could attach. Set config.eager_load = false " \ - "for this run (the CLI already passes CI= for the stock Rails template)." + "for this run (the CLI already unsets CI for the stock Rails template)." exit DeprecationTracker::BootCapture::EAGER_LOAD_EXIT end diff --git a/spec/deprecation_tracker/boot_capture_spec.rb b/spec/deprecation_tracker/boot_capture_spec.rb index ca12de5..f4702b2 100644 --- a/spec/deprecation_tracker/boot_capture_spec.rb +++ b/spec/deprecation_tracker/boot_capture_spec.rb @@ -6,21 +6,42 @@ RSpec.describe DeprecationTracker::BootCapture do describe ".boot_command" do - it "runs the gem's runner script via rails runner under RAILS_ENV=test with CI blanked" do - command = described_class.boot_command(output_path: "spec/support/deprecation_warning.boot.shitlist.json") - # CI= keeps the stock Rails 7.1+ template from eager-loading at boot. - expect(command).to start_with("CI= RAILS_ENV=test ") + 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") # `rails runner` fully boots the app, which registers the deprecators the runner attaches to. - expect(command).to include("bundle exec rails runner #{described_class::RUNNER_PATH}") - expect(command).to include("DEPRECATION_BOOT_OUTPUT=spec/support/deprecation_warning.boot.shitlist.json") + expect(argv).to eq(["bundle", "exec", "rails", "runner", 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") + # CI unset (nil) keeps the stock Rails 7.1+ template from eager-loading at boot. + expect(env).to have_key("CI") + expect(env["CI"]).to be_nil end it "selects the next bundle with BUNDLE_GEMFILE, not bin/next" do # bin/next may not exist in every project; BUNDLE_GEMFILE is what it wraps. - command = described_class.boot_command(output_path: "out.json", next_mode: true) - expect(command).to include("BUNDLE_GEMFILE=Gemfile.next") - expect(command).to include("bundle exec rails runner") - expect(command).not_to include("bin/next") + env, *argv = described_class.boot_command(output_path: "out.json", next_mode: true) + expect(env["BUNDLE_GEMFILE"]).to eq("Gemfile.next") + expect(argv).not_to include("bin/next") + end + + it "cannot be shell-injected through --output (value stays a single env entry)" do + malicious = "x.json; rm -rf foo" + env, *argv = described_class.boot_command(output_path: malicious) + # The value is passed as an env var, never spliced into a shell string. + expect(env["DEPRECATION_BOOT_OUTPUT"]).to eq(malicious) + expect(argv).to eq(["bundle", "exec", "rails", "runner", described_class::RUNNER_PATH]) + expect(argv.join(" ")).not_to include("rm -rf") + 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")) + # CI is unset (nil); don't render it as a misleading "CI=" blank assignment. + expect(display).not_to include("CI=") + expect(display).to include("RAILS_ENV=test") + expect(display).to include("DEPRECATION_BOOT_OUTPUT=out.json") + expect(display).to include("bundle exec rails runner #{described_class::RUNNER_PATH}") end end From fb23cf9b4cb2a67ba0f1f118efba720ddcd7979a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Juan=20V=C3=A1squez?= Date: Fri, 24 Jul 2026 16:16:22 -0600 Subject: [PATCH 11/22] Preserve the previous boot shitlist when a capture fails run_boot deleted the output file before running, so a boot that failed or refused left the user with no capture at all, including the good one from last time. Write to a sibling .partial file and rename it into place only after the runner succeeds; clear the partial on both failure paths. Same-directory rename, so the replacement is atomic. --- exe/deprecations | 21 +++++++++++++-------- 1 file changed, 13 insertions(+), 8 deletions(-) diff --git a/exe/deprecations b/exe/deprecations index c598e57..ab06d3f 100755 --- a/exe/deprecations +++ b/exe/deprecations @@ -47,17 +47,17 @@ end def run_boot(opts = {}) next_mode = !!opts[:next] output_path = opts[:output] || 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 partial's + # presence afterward also proves THIS boot wrote it (the runner only writes once + # it reaches after_run), so a missing partial means the app failed to boot. + partial_path = "#{output_path}.partial" FileUtils.mkdir_p(File.dirname(output_path)) - - # Remove any prior result so its presence afterward proves THIS boot wrote it. - # The runner only writes the shitlist once it reaches `after_run`, so a missing - # (or non-zero-exit) result means the app failed to boot — which must not be - # mistaken for "no deprecations." - FileUtils.rm_f(output_path) + FileUtils.rm_f(partial_path) # clear a stale partial, never the real output command = DeprecationTracker::BootCapture.boot_command( - output_path: output_path, + output_path: partial_path, next_mode: next_mode ) puts DeprecationTracker::BootCapture.command_display(command) @@ -67,16 +67,21 @@ def run_boot(opts = {}) # The runner refused because the env eager-loads at boot; it already printed the # specific explanation, so don't bury it under the generic guess below. if status && status.exitstatus == DeprecationTracker::BootCapture::EAGER_LOAD_EXIT + FileUtils.rm_f(partial_path) exit status.exitstatus end - if !booted || !File.exist?(output_path) + if !booted || !File.exist?(partial_path) + FileUtils.rm_f(partial_path) STDERR.puts Rainbow("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? From 34904a3e6380c1ba3a41791925094fbe65475198 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Juan=20V=C3=A1squez?= Date: Fri, 24 Jul 2026 16:17:04 -0600 Subject: [PATCH 12/22] Add --path so read commands can target any shitlist The boot capture writes to spec/support/deprecation_warning.boot.shitlist.json, but info/run/merge hardcoded the test-run path, so the README's claim that you could read it with info was false. --path selects the file to read (overriding --next); --output still selects where boot writes. --- README.md | 9 +++++++-- exe/deprecations | 7 ++++++- 2 files changed, 13 insertions(+), 3 deletions(-) diff --git a/README.md b/README.md index 2bc012f..560e9d6 100644 --- a/README.md +++ b/README.md @@ -192,7 +192,7 @@ 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 the deprecations that fire when the app **eager-loads** — association / scope / callback declaration warnings that fire when a class body is evaluated, which no test necessarily triggers. `deprecations boot` catches those: it boots the app and eager-loads it with the tracker already listening, then writes an ordinary shitlist you can read with `info`. (Warnings emitted earlier, during gem require or initializers, fire before the tracker attaches and are out of scope.) +The test-run tracker above attaches per-example, so it only sees deprecations raised *while a test runs*. It structurally misses the deprecations that fire when the app **eager-loads** — association / scope / callback declaration warnings that fire when a class body is evaluated, which no test necessarily triggers. `deprecations boot` catches those: it boots the app and eager-loads it with the tracker already listening, prints an `info`-style summary, and writes an ordinary shitlist. (Warnings emitted earlier, during gem require or initializers, fire before the tracker attaches and are out of scope.) ```bash # Capture eager-load-time deprecations on the current bundle @@ -202,7 +202,12 @@ deprecations boot 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, and summarized like `info`. Override the path with `--output`. +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. Override where it's written with `--output`. To re-inspect or filter a saved boot shitlist later, point the read commands at it with `--path`: + +```bash +deprecations info --path spec/support/deprecation_warning.boot.shitlist.json +deprecations info --pattern "ActiveRecord" --path spec/support/deprecation_warning.boot.shitlist.json +``` > [!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 and says so rather than reporting "no deprecations." diff --git a/exe/deprecations b/exe/deprecations index ab06d3f..84943d1 100755 --- a/exe/deprecations +++ b/exe/deprecations @@ -157,6 +157,10 @@ option_parser = OptionParser.new do |opts| options[:output] = output end + opts.on("--path PATH", "info/run/merge: read this shitlist instead of the default (overrides --next). Intended for `info` — a boot shitlist buckets under 'boot', not test files") do |read_path| + options[:path] = read_path + end + opts.on_tail("-h", "--help", "Prints this help") do puts opts exit @@ -166,7 +170,8 @@ end option_parser.parse! options[:mode] = ARGV.last -path = options[:next] ? "spec/support/deprecation_warning.next.shitlist.json" : "spec/support/deprecation_warning.shitlist.json" +default_path = options[:next] ? "spec/support/deprecation_warning.next.shitlist.json" : "spec/support/deprecation_warning.shitlist.json" +path = options[:path] || default_path case options[:mode] when "merge" From 5e067b2d3d3309cc50b9703c368d8d174963718e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Juan=20V=C3=A1squez?= Date: Fri, 24 Jul 2026 16:17:28 -0600 Subject: [PATCH 13/22] Ignore *.partial (boot capture writes one before atomic rename) --- .gitignore | 1 + 1 file changed, 1 insertion(+) diff --git a/.gitignore b/.gitignore index 94a1baf..8475ba0 100644 --- a/.gitignore +++ b/.gitignore @@ -16,3 +16,4 @@ Gemfile.lock Gemfile.next .gem +*.partial From 840cca87e67a5a744465fcad85d4429ea57b6e3d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Juan=20V=C3=A1squez?= Date: Fri, 24 Jul 2026 16:29:02 -0600 Subject: [PATCH 14/22] Store relative paths and require save's stdlib deps for boot capture MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Pass the Rails.root-stripping transform_message so the boot shitlist stores project-relative, committable paths (matching the RSpec/Minitest setups), and require tempfile/fileutils in deprecation_tracker so save/diff work under `rails runner` in a slim app where those stdlib files aren't already loaded — previously a NameError that run_boot misreported as a boot failure. The stdlib requirement is covered by a subprocess spec (bare Ruby, no RSpec preloading) that reproduces and guards the crash. --- lib/deprecation_tracker.rb | 4 +++ .../boot_capture_runner.rb | 6 +++- spec/deprecation_tracker/boot_capture_spec.rb | 28 +++++++++++++++++++ 3 files changed, 37 insertions(+), 1 deletion(-) diff --git a/lib/deprecation_tracker.rb b/lib/deprecation_tracker.rb index 9c4cefc..db67881 100644 --- a/lib/deprecation_tracker.rb +++ b/lib/deprecation_tracker.rb @@ -1,5 +1,9 @@ require "next_rails/tint" require "json" +# save/diff use these; they happen to be loaded under RSpec/Minitest, but not +# necessarily when the tracker runs via `rails runner` in a slim app. +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_runner.rb b/lib/deprecation_tracker/boot_capture_runner.rb index 1dc1dd9..3932d9e 100644 --- a/lib/deprecation_tracker/boot_capture_runner.rb +++ b/lib/deprecation_tracker/boot_capture_runner.rb @@ -55,9 +55,13 @@ ActiveSupport::Deprecation.disallowed_behavior = :stderr if ActiveSupport::Deprecation.respond_to?(:disallowed_behavior=) end +# transform_message strips the absolute Rails.root prefix (the same gsub the +# RSpec/Minitest setups use) so the shitlist stores project-relative paths — +# committable and stable across machines instead of "/Users/.../app/...". tracker = DeprecationTracker.init_tracker( :shitlist_path => ENV.fetch("DEPRECATION_BOOT_OUTPUT"), - :mode => "save" + :mode => "save", + :transform_message => lambda { |message| message.gsub("#{Rails.root}/", "") } ) tracker.bucket = "boot" Rails.application.eager_load! diff --git a/spec/deprecation_tracker/boot_capture_spec.rb b/spec/deprecation_tracker/boot_capture_spec.rb index f4702b2..1eaea5d 100644 --- a/spec/deprecation_tracker/boot_capture_spec.rb +++ b/spec/deprecation_tracker/boot_capture_spec.rb @@ -2,6 +2,9 @@ require "spec_helper" +require "tmpdir" +require "fileutils" +require "rbconfig" require_relative "../../lib/deprecation_tracker/boot_capture" RSpec.describe DeprecationTracker::BootCapture do @@ -75,6 +78,12 @@ expect(script).to include("ENV.fetch(\"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 "refuses with a distinct exit code, before eager_load!, when the env eager-loads at boot" do # If config.eager_load is true, eager-load already ran before the tracker # attached — the warnings are lost, so refuse rather than say "clean," and @@ -96,4 +105,23 @@ expect(script.index("silenced = false")).to be < script.index("DeprecationTracker.init_tracker") 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 7f39e8facd450e3a14bf6b62e15c4786af25a06b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Juan=20V=C3=A1squez?= Date: Fri, 24 Jul 2026 16:50:21 -0600 Subject: [PATCH 15/22] Tighten boot CLI ergonomics and keep boot_command 2.0-safe MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit boot no longer silently ignores --pattern (rejects it, pointing to `info --path`). print_info derives the bucket label from the data, so a boot shitlist shows "Source" instead of mislabeling the synthetic "boot" bucket a test file — on both the post-capture summary and a later `info --path`. `run --path` is rejected (its buckets may not be spec files) and a missing shitlist aborts with a message instead of a raw Errno::ENOENT. boot_command declares output_path as an optional kwarg + guard rather than a required kwarg, so the file parses on Ruby 2.0 — the gemspec's floor, which the rest of the lib keeps to (it was the only required kwarg present). --- exe/deprecations | 25 +++++++++++++++---- lib/deprecation_tracker/boot_capture.rb | 6 ++++- spec/deprecation_tracker/boot_capture_spec.rb | 4 +++ 3 files changed, 29 insertions(+), 6 deletions(-) diff --git a/exe/deprecations b/exe/deprecations index 84943d1..281d224 100755 --- a/exe/deprecations +++ b/exe/deprecations @@ -27,10 +27,16 @@ 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 shitlist buckets under the single synthetic "boot". + # Derive it from the data so BOTH call sites (run_boot's own summary and a later + # `info --path `) label it correctly without the caller opting in. + bucket_label = opts[: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 @@ -38,7 +44,7 @@ def print_info(deprecation_warnings, opts = {}) puts Rainbow("Ten most common deprecation warnings:").underline frequency_by_message.take(10).each do |message, data| puts Rainbow("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 Rainbow(message).red puts "----------" end @@ -157,7 +163,7 @@ option_parser = OptionParser.new do |opts| options[:output] = output end - opts.on("--path PATH", "info/run/merge: read this shitlist instead of the default (overrides --next). Intended for `info` — a boot shitlist buckets under 'boot', not test files") do |read_path| + opts.on("--path PATH", "info/merge: read this shitlist instead of the default (overrides --next). Intended for `info` — a boot shitlist buckets under 'boot', not test files") do |read_path| options[:path] = read_path end @@ -181,12 +187,21 @@ when "merge" total_messages = result.values.map(&:size).reduce(0, :+) puts "Merged #{shards} shard files into #{path} (#{result.size} buckets, #{total_messages} deprecation messages)" when "boot" + if options[:pattern] + abort "--pattern is not supported with 'boot'. Capture, then filter the saved shitlist: " \ + "deprecations info --path --pattern " + end run_boot( next: options[:next], output: options[:output], verbose: options[:verbose] ) when "run", "info" + if options[:mode] == "run" && options[:path] + abort "--path can't be combined with 'run': its buckets may not be spec files (e.g. a boot shitlist buckets under 'boot'). Use --path with info instead." + end + abort "No shitlist found at #{path}." unless File.exist?(path) + pattern_string = options.fetch(:pattern, ".+") pattern = /#{pattern_string}/ diff --git a/lib/deprecation_tracker/boot_capture.rb b/lib/deprecation_tracker/boot_capture.rb index b79a0fd..21ebbe4 100644 --- a/lib/deprecation_tracker/boot_capture.rb +++ b/lib/deprecation_tracker/boot_capture.rb @@ -62,7 +62,11 @@ def self.default_output_path(next_mode: false) # * BUNDLE_GEMFILE=Gemfile.next selects the next bundle (what `bin/next` wraps, # and it works in projects that never generated the shim). # * RAILS_ENV=test skips dev-only initializers. - def self.boot_command(output_path:, next_mode: false) + # 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) + raise ArgumentError, "output_path is required" unless output_path env = { "CI" => nil, "RAILS_ENV" => "test", OUTPUT_ENV => output_path.to_s } env["BUNDLE_GEMFILE"] = "Gemfile.next" if next_mode [env, "bundle", "exec", "rails", "runner", RUNNER_PATH] diff --git a/spec/deprecation_tracker/boot_capture_spec.rb b/spec/deprecation_tracker/boot_capture_spec.rb index 1eaea5d..d9f3002 100644 --- a/spec/deprecation_tracker/boot_capture_spec.rb +++ b/spec/deprecation_tracker/boot_capture_spec.rb @@ -27,6 +27,10 @@ expect(argv).not_to include("bin/next") 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 "cannot be shell-injected through --output (value stays a single env entry)" do malicious = "x.json; rm -rf foo" env, *argv = described_class.boot_command(output_path: malicious) From 3a4eb2bc7d4bb5e34ca3d9e1e9b7ab535b4a9ba1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Juan=20V=C3=A1squez?= Date: Fri, 24 Jul 2026 17:02:31 -0600 Subject: [PATCH 16/22] Pair BUNDLE_CACHE_PATH with BUNDLE_GEMFILE for the next bundle boot_command set only BUNDLE_GEMFILE=Gemfile.next for --next, so the next boot resolved against the current bundle's vendored cache. `next` and gem-next-diff always pair Gemfile.next with BUNDLE_CACHE_PATH=vendor/cache.next; do the same. The current bundle keeps Bundler's defaults (Gemfile, vendor/cache). --- README.md | 3 ++- lib/deprecation_tracker/boot_capture.rb | 12 +++++++++--- spec/deprecation_tracker/boot_capture_spec.rb | 12 ++++++++++-- 3 files changed, 21 insertions(+), 6 deletions(-) diff --git a/README.md b/README.md index 560e9d6..01f2827 100644 --- a/README.md +++ b/README.md @@ -198,7 +198,8 @@ The test-run tracker above attaches per-example, so it only sees deprecations ra # Capture eager-load-time deprecations on the current bundle deprecations boot -# ...or on the next bundle (dual boot). This just prepends BUNDLE_GEMFILE=Gemfile.next. +# ...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 ``` diff --git a/lib/deprecation_tracker/boot_capture.rb b/lib/deprecation_tracker/boot_capture.rb index 21ebbe4..c379236 100644 --- a/lib/deprecation_tracker/boot_capture.rb +++ b/lib/deprecation_tracker/boot_capture.rb @@ -59,8 +59,11 @@ def self.default_output_path(next_mode: false) # at boot — before the tracker can attach — and the runner would have to refuse. # Unsetting CI keeps that template's eager_load off so capture works even on CI; # apps that hardcode `eager_load = true` are still caught by the runner's guard. - # * BUNDLE_GEMFILE=Gemfile.next selects the next bundle (what `bin/next` wraps, - # and it works in projects that never generated the shim). + # * 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 @@ -68,7 +71,10 @@ def self.default_output_path(next_mode: false) def self.boot_command(output_path: nil, next_mode: false) raise ArgumentError, "output_path is required" unless output_path env = { "CI" => nil, "RAILS_ENV" => "test", OUTPUT_ENV => output_path.to_s } - env["BUNDLE_GEMFILE"] = "Gemfile.next" if next_mode + if next_mode + env["BUNDLE_GEMFILE"] = "Gemfile.next" + env["BUNDLE_CACHE_PATH"] = "vendor/cache.next" + end [env, "bundle", "exec", "rails", "runner", RUNNER_PATH] end diff --git a/spec/deprecation_tracker/boot_capture_spec.rb b/spec/deprecation_tracker/boot_capture_spec.rb index d9f3002..f689b5d 100644 --- a/spec/deprecation_tracker/boot_capture_spec.rb +++ b/spec/deprecation_tracker/boot_capture_spec.rb @@ -20,13 +20,21 @@ expect(env["CI"]).to be_nil end - it "selects the next bundle with BUNDLE_GEMFILE, not bin/next" do - # bin/next may not exist in every project; BUNDLE_GEMFILE is what it wraps. + 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 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 From 99192161b4b5db4fe65397e5e8eb6f2c33a15956 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Juan=20V=C3=A1squez?= Date: Fri, 24 Jul 2026 18:04:06 -0600 Subject: [PATCH 17/22] Extract boot result classification so the CLI paths are testable run_boot's branching lived inline in exe/deprecations, which no spec loads, so the partial-promotion and cleanup logic was unverified. Move the partial path and the success/failure/refusal classification onto BootCapture and cover them directly. Also switch the runner syntax check to `ruby -c`, since RubyVM::InstructionSequence is MRI-only. --- exe/deprecations | 16 ++++----- lib/deprecation_tracker/boot_capture.rb | 19 +++++++++++ spec/deprecation_tracker/boot_capture_spec.rb | 33 ++++++++++++++++++- 3 files changed, 59 insertions(+), 9 deletions(-) diff --git a/exe/deprecations b/exe/deprecations index 281d224..4d27952 100755 --- a/exe/deprecations +++ b/exe/deprecations @@ -57,7 +57,7 @@ def run_boot(opts = {}) # that fails or refuses never destroys a previous good capture. The partial's # presence afterward also proves THIS boot wrote it (the runner only writes once # it reaches after_run), so a missing partial means the app failed to boot. - partial_path = "#{output_path}.partial" + 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 @@ -69,15 +69,15 @@ def run_boot(opts = {}) 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 - # The runner refused because the env eager-loads at boot; it already printed the - # specific explanation, so don't bury it under the generic guess below. - if status && status.exitstatus == DeprecationTracker::BootCapture::EAGER_LOAD_EXIT + case DeprecationTracker::BootCapture.boot_result(booted, exit_status, File.exist?(partial_path)) + when :eager_load_refused + # The runner already printed the specific explanation; surface its exit code + # rather than the generic guess below. FileUtils.rm_f(partial_path) - exit status.exitstatus - end - - if !booted || !File.exist?(partial_path) + exit exit_status + when :failed FileUtils.rm_f(partial_path) STDERR.puts Rainbow("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" diff --git a/lib/deprecation_tracker/boot_capture.rb b/lib/deprecation_tracker/boot_capture.rb index c379236..e7ceb23 100644 --- a/lib/deprecation_tracker/boot_capture.rb +++ b/lib/deprecation_tracker/boot_capture.rb @@ -88,5 +88,24 @@ def self.command_display(command) 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. + # :eager_load_refused - the runner refused (see EAGER_LOAD_EXIT); 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 :eager_load_refused if exit_status == EAGER_LOAD_EXIT + return :failed unless succeeded && output_written + :ok + end end end diff --git a/spec/deprecation_tracker/boot_capture_spec.rb b/spec/deprecation_tracker/boot_capture_spec.rb index f689b5d..b1972a3 100644 --- a/spec/deprecation_tracker/boot_capture_spec.rb +++ b/spec/deprecation_tracker/boot_capture_spec.rb @@ -71,12 +71,21 @@ end describe "RUNNER_PATH" do + # Accepted limitation: the runner only does its real work inside a booted Rails + # app (init_tracker + eager_load!), which this gem's suite has no fixture for. + # The examples below assert the runner's *structure* (source text) — that the + # eager-load guard, the un-silence/behavior setup, the transform_message, and + # the init_tracker reuse are present and correctly ordered. Behavioral coverage + # of the running runner comes from a CLI smoke against a fixture app, which is + # blocked until `exe/deprecations` can load (see the rainbow require fix); until + # then these structural checks plus the maintained manual verification stand in. 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 - expect { RubyVM::InstructionSequence.compile(File.read(described_class::RUNNER_PATH)) }.not_to raise_error + # `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 @@ -118,6 +127,28 @@ 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 the eager-load refusal by its exit status, whatever else happened" do + expect(described_class.boot_result(false, described_class::EAGER_LOAD_EXIT, false)).to eq(:eager_load_refused) + 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. From f6114ffce37ab2976de2e08f948bcfa877cd1f17 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Juan=20V=C3=A1squez?= Date: Fri, 24 Jul 2026 18:06:07 -0600 Subject: [PATCH 18/22] Use NextRails::Tint instead of the undeclared rainbow gem exe/deprecations required rainbow, which the gemspec never declares as a runtime dependency, so the shipped executable raised LoadError on a clean install and only worked where rainbow happened to be present for another reason. The gem already has a dependency-free ANSI wrapper used throughout lib/. Add the underline style it was missing and switch the CLI to it. --- exe/deprecations | 21 +++++++++++---------- lib/next_rails/tint.rb | 1 + spec/next_rails/tint_spec.rb | 4 ++++ 3 files changed, 16 insertions(+), 10 deletions(-) diff --git a/exe/deprecations b/exe/deprecations index 4d27952..8f801c3 100755 --- a/exe/deprecations +++ b/exe/deprecations @@ -1,12 +1,13 @@ #!/usr/bin/env ruby require "json" -require "rainbow" 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" -require "fileutils" def run_tests(deprecation_warnings, opts = {}) tracker_mode = DeprecationTracker.sanitize_mode(opts[:tracker_mode]) @@ -41,11 +42,11 @@ def print_info(deprecation_warnings, opts = {}) end end.sort_by {|message, data| data[:occurrences] }.reverse.to_h - puts Rainbow("Ten most common deprecation warnings:").underline + puts NextRails::Tint("Ten most common deprecation warnings:").underline frequency_by_message.take(10).each do |message, data| - puts Rainbow("Occurrences: #{data.fetch(:occurrences)}").bold + puts NextRails::Tint("Occurrences: #{data.fetch(:occurrences)}").bold puts "#{bucket_label}: #{data.fetch(:buckets).to_a.join(" ")}" if verbose - puts Rainbow(message).red + puts NextRails::Tint(message).red puts "----------" end end @@ -79,7 +80,7 @@ def run_boot(opts = {}) exit exit_status when :failed FileUtils.rm_f(partial_path) - STDERR.puts Rainbow("Boot did not complete — the app failed to load (see the error above).").red + 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 @@ -91,11 +92,11 @@ def run_boot(opts = {}) # 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 Rainbow("Eager-load completed cleanly — no deprecation warnings while loading the app's classes.").green + puts NextRails::Tint("Eager-load completed cleanly — no deprecation warnings while loading the app's classes.").green return end - puts Rainbow("Boot-time deprecations written to #{output_path}").underline + puts NextRails::Tint("Boot-time deprecations written to #{output_path}").underline print_info(deprecation_warnings, verbose: opts[:verbose]) end @@ -221,10 +222,10 @@ when "run", "info" print_info(deprecation_warnings, verbose: options[:verbose]) end when nil - STDERR.puts Rainbow("Must pass a mode: run, info, merge, or boot").red + STDERR.puts NextRails::Tint("Must pass a mode: run, info, merge, or boot").red puts option_parser exit 1 else - STDERR.puts Rainbow("Unknown mode: #{options[:mode]}").red + STDERR.puts NextRails::Tint("Unknown mode: #{options[:mode]}").red exit 1 end diff --git a/lib/next_rails/tint.rb b/lib/next_rails/tint.rb index dcfa21d..3d907c0 100644 --- a/lib/next_rails/tint.rb +++ b/lib/next_rails/tint.rb @@ -13,6 +13,7 @@ class Tint CODES = { bold: 1, italic: 3, + underline: 4, red: 31, green: 32, yellow: 33, diff --git a/spec/next_rails/tint_spec.rb b/spec/next_rails/tint_spec.rb index d3e03f9..73d1afa 100644 --- a/spec/next_rails/tint_spec.rb +++ b/spec/next_rails/tint_spec.rb @@ -12,6 +12,10 @@ expect(NextRails::Tint("hello").red.to_s).to eq("\e[31mhello\e[0m") end + it "supports underline (the CLI headlines use it)" do + expect(NextRails::Tint("hello").underline.to_s).to eq("\e[4mhello\e[0m") + end + it "chains multiple styles into one escape sequence" do expect(NextRails::Tint("hello").bold.white.to_s).to eq("\e[1;37mhello\e[0m") end From d147461b84b8a809c8b32f727148d07ba76e8437 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Juan=20V=C3=A1squez?= Date: Fri, 24 Jul 2026 18:06:40 -0600 Subject: [PATCH 19/22] Require deprecation_tracker in exe/deprecations `deprecations run` called DeprecationTracker.sanitize_mode, which is defined in deprecation_tracker.rb, but the CLI only required valid_modes/shard_merger/boot_capture. Every invocation of run mode raised NoMethodError before reaching any of its own logic. --- exe/deprecations | 12 ++++++++---- 1 file changed, 8 insertions(+), 4 deletions(-) diff --git a/exe/deprecations b/exe/deprecations index 8f801c3..6dd1122 100755 --- a/exe/deprecations +++ b/exe/deprecations @@ -4,10 +4,14 @@ 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" +# The whole tracker, not just valid_modes: `run` calls DeprecationTracker.sanitize_mode. +# deprecation_tracker.rb requires "next_rails/tint" by name, so the gem's lib has to +# be on the load path — it already is for an installed gem, but not when this script +# runs straight out of a checkout. +$LOAD_PATH.unshift(File.expand_path("../lib", __dir__)) +require "deprecation_tracker" +require "deprecation_tracker/shard_merger" +require "deprecation_tracker/boot_capture" def run_tests(deprecation_warnings, opts = {}) tracker_mode = DeprecationTracker.sanitize_mode(opts[:tracker_mode]) From 1bf8c4173bfde34da3e1602608ec3b05f6e7edff Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Juan=20V=C3=A1squez?= Date: Fri, 24 Jul 2026 18:08:55 -0600 Subject: [PATCH 20/22] Smoke test exe/deprecations by actually running it No spec loaded the CLI, which is how an undeclared rainbow require and a NoMethodError in run mode both shipped. Execute the real script in a subprocess and cover every mode: info's summary, filtering and labelling, merge, the flag guards, and unknown/missing modes. Drive boot's three outcomes with a stub `bundle` on PATH, so the partial promotion, the preserved previous capture on failure, and the refusal exit code are verified end to end rather than only through boot_result. --- spec/deprecations_cli_spec.rb | 235 ++++++++++++++++++++++++++++++++++ 1 file changed, 235 insertions(+) create mode 100644 spec/deprecations_cli_spec.rb diff --git a/spec/deprecations_cli_spec.rb b/spec/deprecations_cli_spec.rb new file mode 100644 index 0000000..b332c2b --- /dev/null +++ b/spec/deprecations_cli_spec.rb @@ -0,0 +1,235 @@ +# frozen_string_literal: true + +require "spec_helper" +require "json" +require "open3" +require "tmpdir" +require "deprecation_tracker/boot_capture" + +# Smoke tests that actually execute exe/deprecations. Everything below runs the +# real script in a subprocess, so it catches the class of breakage unit tests on +# lib/ 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 loaded this file. +RSpec.describe "exe/deprecations" do + CLI_PATH = File.expand_path("../exe/deprecations", __dir__) + + # Runs the CLI in `chdir` and returns [stdout, stderr, exitstatus]. + 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 + FileUtils.mkdir_p(File.join(dir, "spec", "support")) + example.run + end + end + + attr_reader :dir + + def write_shitlist(relative, contents) + path = File.join(dir, relative) + FileUtils.mkdir_p(File.dirname(path)) + File.write(path, JSON.pretty_generate(contents)) + path + end + + let(:boot_shitlist) do + write_shitlist( + "spec/support/deprecation_warning.boot.shitlist.json", + "boot" => ["DEPRECATION WARNING: default_timezone", "DEPRECATION WARNING: partial rendering"] + ) + end + + let(:test_shitlist) do + write_shitlist( + "spec/support/deprecation_warning.shitlist.json", + "./spec/models/user_spec.rb" => ["DEPRECATION WARNING: shared"], + "./spec/models/post_spec.rb" => ["DEPRECATION WARNING: shared"] + ) + end + + describe "info" do + it "summarizes a shitlist read through --path" do + stdout, _stderr, status = run_cli(["info", "--path", boot_shitlist], chdir: dir) + + expect(status).to eq(0) + expect(stdout).to include("Ten most common deprecation warnings:") + expect(stdout).to include("default_timezone") + expect(stdout).to include("partial rendering") + end + + it "narrows the output with --pattern" do + stdout, _stderr, status = run_cli( + ["info", "--pattern", "default_timezone", "--path", boot_shitlist], chdir: dir + ) + + expect(status).to eq(0) + expect(stdout).to include("default_timezone") + expect(stdout).not_to include("partial rendering") + end + + it "labels a boot bucket as a source, not a test file" do + stdout, _stderr, = run_cli(["info", "--verbose", "--path", boot_shitlist], chdir: dir) + + expect(stdout).to include("Source: boot") + expect(stdout).not_to include("Test files:") + end + + it "still labels spec-file buckets as test files" do + stdout, _stderr, = run_cli(["info", "--verbose", "--path", test_shitlist], 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", "--path", File.join(dir, "nope.json")], 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 + _stdout, stderr, status = run_cli( + ["info", "--pattern", "nothing-matches-this", "--path", boot_shitlist], chdir: dir + ) + + expect(status).to eq(1) + expect(stderr).to include("No test files with deprecations") + end + end + + describe "run" do + # Regression: run called DeprecationTracker.sanitize_mode while the CLI only + # required valid_modes, so every invocation died with NoMethodError. Reaching + # the mode validation at all proves the tracker is loaded. + it "validates --tracker-mode instead of raising NoMethodError" do + test_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 + + it "refuses --path, whose buckets may not be spec files" do + _stdout, stderr, status = run_cli(["run", "--path", boot_shitlist], chdir: dir) + + expect(status).to eq(1) + expect(stderr).to include("--path can't be combined with 'run'") + end + end + + describe "merge" do + it "merges shard files into the canonical shitlist" do + write_shitlist( + "spec/support/deprecation_warning.shitlist.node-1.json", + "./spec/models/user_spec.rb" => ["DEPRECATION WARNING: from shard 1"] + ) + + stdout, _stderr, status = run_cli(["merge"], chdir: dir) + + expect(status).to eq(0) + expect(stdout).to include("Merged 1 shard files") + merged = JSON.parse(File.read(File.join(dir, "spec/support/deprecation_warning.shitlist.json"))) + expect(merged.fetch("./spec/models/user_spec.rb")).to eq(["DEPRECATION WARNING: from shard 1"]) + end + end + + describe "mode handling" do + it "exits non-zero with usage when no mode is given" do + _stdout, stderr, status = run_cli([], chdir: dir) + + expect(status).to eq(1) + expect(stderr).to include("Must pass a mode") + end + + it "exits non-zero on an unknown mode" do + _stdout, stderr, status = run_cli(["nonsense"], chdir: dir) + + expect(status).to eq(1) + 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 "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 eager-load 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("Eager-load 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::EAGER_LOAD_EXIT + env = stub_bundle(dir, "echo 'this environment eager-loads at boot' >&2; exit #{exit_code}") + + _stdout, stderr, status = run_cli(["boot"], chdir: dir, env: env) + + expect(status).to eq(exit_code) + expect(stderr).to include("eager-loads at boot") + # 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 036bc26b36f798fb6d24dde9b6a6b7213414efba Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Juan=20V=C3=A1squez?= Date: Fri, 24 Jul 2026 18:09:21 -0600 Subject: [PATCH 21/22] Add CHANGELOG entries for the two deprecations CLI bugfixes --- CHANGELOG.md | 2 ++ 1 file changed, 2 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index fc036e5..9edb973 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,8 @@ - [FEATURE: Validate the DeprecationTracker mode at initialization, treating a blank mode as the default `save`](https://github.com/fastruby/next_rails/pull/186) - [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) - [FEATURE: Add `deprecations boot` to capture eager-load-time deprecation warnings the per-test tracker misses — association/scope/callback declaration warnings that fire when a class body is evaluated](https://github.com/fastruby/next_rails/pull/194) +- [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/194) +- [BUGFIX: `deprecations run` no longer raises `NoMethodError` before doing any work](https://github.com/fastruby/next_rails/pull/194) * Your changes/patches go here. From b95480cc608c7437cdb1e1dff45acb94dfc39439 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Juan=20V=C3=A1squez?= Date: Mon, 27 Jul 2026 09:04:40 -0600 Subject: [PATCH 22/22] Sync README with the boot CLI's actual behavior - boot only captures (save mode); compare stays test-run-only - describe the real failure modes (load failure exit 1, eager-load refusal exit 3) instead of a vague "no deprecations" - list `info --path` in the deprecations command reference --- README.md | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index 01f2827..c0ecd23 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), plus eager-load-time warnings via `deprecations boot` +- **Deprecation Tracking** — Capture and compare deprecation warnings across test runs (RSpec & Minitest), and capture eager-load-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 @@ -211,7 +211,7 @@ deprecations info --pattern "ActiveRecord" --path spec/support/deprecation_warni ``` > [!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 and says so rather than reporting "no deprecations." +> 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 a refusal because the env eager-loads at boot (exit 3) — rather than a misleading clean result. Only a genuine clean boot reports "no deprecation warnings." > > Capture must attach *before* eager-load, so the command needs `config.eager_load = false`. It unsets `CI` to keep the stock Rails 7.1+ test env (`config.eager_load = ENV["CI"].present?`) from eager-loading at boot; if your app hardcodes `config.eager_load = true`, `boot` refuses and tells you to set it false for the run. @@ -277,6 +277,7 @@ View, filter, and manage stored deprecation warnings: ```bash deprecations info deprecations info --pattern "ActiveRecord::Base" +deprecations info --path PATH # read a specific shitlist (e.g. a boot capture) deprecations merge --delete-shards deprecations run deprecations boot # capture eager-load-time deprecations (see "Boot-time deprecations")