Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -14,4 +14,6 @@

.ruby-version
Gemfile.lock
Gemfile.next
.gem
*.partial
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@
- [BUGFIX: `bundle_report outdated` no longer confuses a locally-sourced (`path:`) gem with a same-named public gem on rubygems; local gems are excluded from the out-of-date check and counted separately](https://github.com/fastruby/next_rails/pull/189)
- [BUGFIX: The `deprecations` executable no longer requires the undeclared `rainbow` gem, which made it fail to load on a clean install](https://github.com/fastruby/next_rails/pull/197)
- [BUGFIX: `deprecations run` no longer raises `NoMethodError` before doing any work](https://github.com/fastruby/next_rails/pull/198)
- [FEATURE: Add `deprecations boot` to capture the deprecation warnings the per-test tracker misses: those emitted while the app boots, from the initializers and from class bodies during eager-load](https://github.com/fastruby/next_rails/pull/199)

* Your changes/patches go here.

Expand Down
24 changes: 23 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@ A toolkit to upgrade your next Rails application. It helps you set up **dual boo
## Features

- **Dual Boot** — Run your app against two sets of dependencies (e.g. Rails 7.1 and Rails 7.2) side by side
- **Deprecation Tracking** — Capture and compare deprecation warnings across test runs (RSpec & Minitest)
- **Deprecation Tracking** — Capture and compare deprecation warnings across test runs (RSpec & Minitest), and capture boot-time warnings via `deprecations boot`
- **Bundle Report** — Check gem compatibility with a target Rails or Ruby version
- **Ruby Check** — Find the minimum Ruby version compatible with a target Rails version

Expand Down Expand Up @@ -190,6 +190,26 @@ DEPRECATION_TRACKER=save rspec
DEPRECATION_TRACKER=compare rspec
```

### Boot-time deprecations

The test-run tracker above attaches per-example, so it only sees deprecations raised *while a test runs*. It structurally misses everything the app emits while **booting**: association / scope / callback declaration warnings that fire when a class body is evaluated, plus whatever the initializers warn about. `deprecations boot` catches those. It boots the app itself, attaching the tracker before any initializer runs, then eager-loads, prints an `info`-style summary, and writes an ordinary shitlist. (Still out of scope: anything emitted from `config/boot.rb`, and deprecator-based warnings during `Bundler.require`, since loading `config/application.rb` is what requires the gems. Plain `Kernel#warn` output during gem load *is* captured.)

```bash
# Capture boot-time deprecations on the current bundle
deprecations boot

# ...or on the next bundle (dual boot): sets BUNDLE_GEMFILE=Gemfile.next and
# BUNDLE_CACHE_PATH=vendor/cache.next, the pair `next` uses.
deprecations --next boot
```

The result is written to `spec/support/deprecation_warning.boot.shitlist.json` (or `deprecation_warning.boot.next.shitlist.json` with `--next`), keyed under a single `boot` bucket. The summary is printed when the capture runs; commit the file to diff later captures against it.

> [!NOTE]
> Use `--next` only once the next bundle already boots cleanly (dual boot set up and breaking changes fixed — see [Dual Boot](#dual-boot)); that's when it captures the next version's warnings in bulk. If the app can't boot, `boot` exits non-zero with the reason, a load failure (exit 1) or no `config/application.rb` in this directory (exit 4), rather than a misleading clean result. Only a genuine clean boot reports "no deprecation warnings."
>
> Run it from the app root; that is where it looks for `config/application.rb`. `config.eager_load` can be either value: the tracker is attached before the app initializes, so an environment that eager-loads during boot is captured just the same. For the duration of the capture the app runs with deprecations recorded and printed to stderr, never silenced and never raising, even if its own config says otherwise.

### Parallel CI support

When running tests across parallel CI nodes, each node can write to its own shard file to avoid conflicts. The tracker auto-detects the node index from common CI environment variables (`CI_NODE_INDEX`, `CIRCLE_NODE_INDEX`, `BUILDKITE_PARALLEL_JOB`, `SEMAPHORE_JOB_INDEX`, `CI_NODE_INDEX` for GitLab), or you can set it explicitly via the `node_index` option.
Expand Down Expand Up @@ -254,6 +274,8 @@ deprecations info
deprecations info --pattern "ActiveRecord::Base"
deprecations merge --delete-shards
deprecations run
deprecations boot # capture boot-time deprecations (see "Boot-time deprecations")
deprecations --next boot # same, on the next bundle
deprecations --help
```

Expand Down
83 changes: 74 additions & 9 deletions exe/deprecations
Original file line number Diff line number Diff line change
Expand Up @@ -2,9 +2,11 @@
require "json"
require "optparse"
require "set"
require "fileutils"
require_relative "../lib/next_rails/tint"
require_relative "../lib/deprecation_tracker/valid_modes"
require_relative "../lib/deprecation_tracker/shard_merger"
require_relative "../lib/deprecation_tracker/boot_capture"

def run_tests(deprecation_warnings, opts = {})
tracker_mode = DeprecationTracker.sanitize_mode(opts[:tracker_mode])
Expand All @@ -25,23 +27,76 @@ end

def print_info(deprecation_warnings, opts = {})
verbose = !!opts[:verbose]
frequency_by_message = deprecation_warnings.each_with_object({}) do |(test_file, messages), hash|
# Bucket label for the verbose line. Test-run shitlists bucket by spec file
# ("Test files"); a boot capture buckets under the single synthetic "boot".
bucket_label = deprecation_warnings.keys == ["boot"] ? "Source" : "Test files"
frequency_by_message = deprecation_warnings.each_with_object({}) do |(bucket, messages), hash|
messages.each do |message|
hash[message] ||= { test_files: Set.new, occurrences: 0 }
hash[message][:test_files] << test_file
hash[message] ||= { buckets: Set.new, occurrences: 0 }
hash[message][:buckets] << bucket
hash[message][:occurrences] += 1
end
end.sort_by {|message, data| data[:occurrences] }.reverse.to_h

puts NextRails::Tint("Ten most common deprecation warnings:").underline
frequency_by_message.take(10).each do |message, data|
puts NextRails::Tint("Occurrences: #{data.fetch(:occurrences)}").bold
puts "Test files: #{data.fetch(:test_files).to_a.join(" ")}" if verbose
puts "#{bucket_label}: #{data.fetch(:buckets).to_a.join(" ")}" if verbose
puts NextRails::Tint(message).red
puts "----------"
end
end

def run_boot(opts = {})
next_mode = !!opts[:next]
output_path = DeprecationTracker::BootCapture.default_output_path(next_mode: next_mode)
# Write to a partial file and only replace the real output on success, so a boot
# that fails or refuses never destroys a previous good capture. The stale partial
# is cleared below, and the runner writes only when it reaches after_run, so a
# partial that exists afterward was written by THIS run, and a missing one means
# the run never got that far (the app failed to boot or to eager-load).
partial_path = DeprecationTracker::BootCapture.partial_path_for(output_path)

FileUtils.mkdir_p(File.dirname(output_path))
FileUtils.rm_f(partial_path) # clear a stale partial, never the real output

command = DeprecationTracker::BootCapture.boot_command(
output_path: partial_path,
next_mode: next_mode
)
puts DeprecationTracker::BootCapture.command_display(command)
booted = system(*command) # array form: no shell, so output_path can't inject
status = $?
exit_status = status ? status.exitstatus : nil

case DeprecationTracker::BootCapture.boot_result(booted, exit_status, File.exist?(partial_path))
when :no_app
# The runner already printed the specific explanation; surface its exit code
# rather than the generic guess below.
FileUtils.rm_f(partial_path)
exit exit_status
when :failed
FileUtils.rm_f(partial_path)
STDERR.puts NextRails::Tint("Boot did not complete — the app failed to load (see the error above).").red
STDERR.puts "No shitlist was written, so this is NOT a clean result. A common cause is a missing"
STDERR.puts "config/database.yml or unset env in this checkout. Fix the boot error and re-run."
exit 1
end

# Success: atomically replace the previous capture (same dir, so it's a rename).
FileUtils.mv(partial_path, output_path)

# The runner wrote an ordinary shitlist; reuse print_info to summarize it.
deprecation_warnings = JSON.parse(File.read(output_path))
if deprecation_warnings.empty?
puts NextRails::Tint("Boot completed cleanly, no deprecation warnings while loading the app.").green
return
end

puts NextRails::Tint("Boot-time deprecations written to #{output_path}").underline
print_info(deprecation_warnings, verbose: opts[:verbose])
end

options = {}
option_parser = OptionParser.new do |opts|
opts.banner = <<-MESSAGE
Expand All @@ -50,11 +105,13 @@ option_parser = OptionParser.new do |opts|
Parses the deprecation warning shitlist and show info or run tests.

Examples:
bin/deprecations info # Show top ten deprecations
bin/deprecations --next info # Show top ten deprecations for Rails 5
bin/deprecations info # Show top ten deprecations
bin/deprecations --next info # Show top ten deprecations for Rails 5
bin/deprecations boot # Capture boot-time (eager-load) deprecations the test tracker can't see
bin/deprecations --next boot # Same, booting the next bundle
bin/deprecations --pattern "ActiveRecord::Base" --verbose info # Show full details on deprecations matching pattern
bin/deprecations --tracker-mode save --pattern "pass" run # Run tests that output deprecations matching pattern and update shitlist
bin/deprecations merge --delete-shards # Merge parallel CI shards and remove shard files
bin/deprecations --tracker-mode save --pattern "pass" run # Run tests that output deprecations matching pattern and update shitlist
bin/deprecations merge --delete-shards # Merge parallel CI shards and remove shard files

Modes:
info
Expand All @@ -66,6 +123,9 @@ option_parser = OptionParser.new do |opts|
merge
Merge parallel CI shard files into the canonical shitlist. Use with --delete-shards to remove shard files after merging.

boot
Boot the app with the tracker listening, catching the deprecations the per-test tracker never sees: those from the initializers and from class bodies during eager-load. Writes spec/support/deprecation_warning.boot[.next].shitlist.json (bucket "boot") and summarizes it like `info`.

Options:
MESSAGE

Expand Down Expand Up @@ -107,7 +167,12 @@ when "merge"
result = output[:result]
total_messages = result.values.map(&:size).reduce(0, :+)
puts "Merged #{shards} shard files into #{path} (#{result.size} buckets, #{total_messages} deprecation messages)"
when "boot"
abort "--pattern is not supported with 'boot': it captures everything the boot emits." if options[:pattern]
run_boot(next: options[:next], verbose: options[:verbose])
when "run", "info"
abort "No shitlist found at #{path}." unless File.exist?(path)

pattern_string = options.fetch(:pattern, ".+")
pattern = /#{pattern_string}/

Expand All @@ -127,7 +192,7 @@ when "run", "info"
print_info(deprecation_warnings, verbose: options[:verbose])
end
when nil
STDERR.puts NextRails::Tint("Must pass a mode: run, info, or merge").red
STDERR.puts NextRails::Tint("Must pass a mode: run, info, merge, or boot").red
puts option_parser
exit 1
else
Expand Down
4 changes: 4 additions & 0 deletions lib/deprecation_tracker.rb
Original file line number Diff line number Diff line change
@@ -1,5 +1,9 @@
require_relative "next_rails/tint"
require "json"
# save/diff use these; they happen to be loaded under RSpec/Minitest, but not
# necessarily when the boot capture runs the tracker in a bare Ruby process.
require "tempfile"
require "fileutils"

# A shitlist for deprecation warnings during test runs. It has two modes: "save" and "compare"
#
Expand Down
120 changes: 120 additions & 0 deletions lib/deprecation_tracker/boot_capture.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,120 @@
# frozen_string_literal: true

class DeprecationTracker
# Capture deprecation warnings emitted while the app EAGER-LOADS its classes --
# the association / scope / callback declaration warnings that fire when a class
# body is evaluated -- rather than during a test run.
#
# This is the one such surface the test-run tracker structurally misses:
# `track_rspec` / `track_minitest` attach per-example, so a warning that fires at
# class-body-evaluation time never reaches them. Eager-loading the whole app with
# the tracker already listening surfaces exactly those.
#
# The runner boots the app itself (require config/application, configure, then
# initialize!) rather than running inside an already-initialized `rails runner`,
# so initializer-time and gem-load-time warnings are in scope too. See
# boot_capture_runner.rb for what remains out of reach (config/boot.rb, and
# deprecator-based warnings during Bundler.require).
#
# It deliberately does NOT reimplement any capture logic. `boot_capture_runner.rb`
# drives the existing DeprecationTracker (init_tracker installs the
# version-correct hooks; add / after_run collect and write). BootCapture only
# provides the command that runs that script in the app's bundle.
#
# Kept compatible with the gem's supported Rubies (>= 2.0): no safe-navigation,
# no squiggly heredocs, stdlib only.
class BootCapture
# The runner script shipped with the gem, run with `bundle exec ruby`.
RUNNER_PATH = File.expand_path("boot_capture_runner.rb", __dir__)

# Env var the command uses to tell the runner where to write the shitlist.
OUTPUT_ENV = "DEPRECATION_BOOT_OUTPUT"

# Env var the command uses to tell the runner which directory holds
# config/application.rb. The CLI runs from the app root, so this is Dir.pwd.
APP_ROOT_ENV = "DEPRECATION_BOOT_APP_ROOT"

# Exit status the runner uses when there is no config/application.rb to load
# (the CLI was run outside a Rails app root). The CLI branches on this so the
# runner's specific explanation isn't buried under a generic "boot failed" guess.
NO_APP_EXIT = 4

# Follows the tracker's spec/support convention (DeprecationTracker::DEFAULT_PATH
# is spec/support/deprecation_warning.shitlist.json); ".boot" keeps the boot
# capture from clobbering the test-run shitlist, ".next" separates the bundles.
def self.default_output_path(next_mode: false)
name = next_mode ? "deprecation_warning.boot.next.shitlist.json" : "deprecation_warning.boot.shitlist.json"
File.join("spec", "support", name)
end

# The command that runs the gem's runner script in the app's bundle. Plain
# `bundle exec ruby`, not `rails runner`: the runner boots the app itself so it
# can attach before the initializers run, and `rails runner` would have already
# booted it (and, on Rails 3.x, would eval the script instead of loading it).
#
# Returned as `[env_hash, *argv]` for `system(*command)` — NOT a shell string —
# so neither the output path nor the gem's RUNNER_PATH can be word-split or
# interpreted by a shell (a checkout living under a path with spaces is enough
# to matter). Values that would have needed quoting are ordinary array elements
# and env values here.
#
# Env keys:
# * APP_ROOT_ENV tells the runner where config/application.rb lives. The CLI is
# run from the app root, so it is Dir.pwd. CI is deliberately left alone: the
# runner attaches before initialize!, so an environment that eager-loads at
# boot (the generated test.rb ties config.eager_load to ENV["CI"]) is captured
# rather than refused, and this boot sees the same config CI would.
# * The next bundle sets BUNDLE_GEMFILE=Gemfile.next AND BUNDLE_CACHE_PATH=
# vendor/cache.next — the pair `next`/`gem-next-diff` always use together
# (exe/next.sh, exe/gem-next-diff). Setting only the Gemfile would resolve
# against the current bundle's vendored cache. The current bundle leaves both
# at Bundler's defaults (Gemfile, vendor/cache).
# * RAILS_ENV=test skips dev-only initializers.
# output_path is required, but declared as an optional kwarg + guard rather
# than a required kwarg (`output_path:`) so the file parses on Ruby 2.0 — the
# gem's stated floor, which the rest of the code keeps to.
def self.boot_command(output_path: nil, next_mode: false, app_root: nil)
raise ArgumentError, "output_path is required" unless output_path
env = {
"RAILS_ENV" => "test",
OUTPUT_ENV => output_path.to_s,
APP_ROOT_ENV => (app_root || Dir.pwd).to_s
}
if next_mode
env["BUNDLE_GEMFILE"] = "Gemfile.next"
env["BUNDLE_CACHE_PATH"] = "vendor/cache.next"
end
[env, "bundle", "exec", "ruby", RUNNER_PATH]
end

# A human-readable, shell-like rendering of `boot_command` for logging. Not
# executed — `system(*boot_command(...))` runs the real thing without a shell.
# Unset (nil) env vars are omitted rather than shown as `KEY=`, which would
# read as "blanked" and contradict the unset semantics the command relies on.
def self.command_display(command)
env, argv = command[0], command[1..-1]
env_str = env.reject { |_key, value| value.nil? }.map { |key, value| "#{key}=#{value}" }.join(" ")
parts = env_str.empty? ? argv : [env_str] + argv
parts.join(" ")
end

# The sibling file the CLI writes to, renamed onto output_path only on success
# so a failed/refused boot never destroys a previous capture.
def self.partial_path_for(output_path)
"#{output_path}.partial"
end

# Classify a boot attempt from its process result so the CLI's branching is
# testable without shelling out. `succeeded` is the truthiness of system's
# return, `exit_status` the child's exit code (nil if it couldn't run),
# `output_written` whether the partial file exists afterward.
# :no_app - there is no config/application.rb to boot; explained already
# :failed - the app did not boot; no usable capture
# :ok - the partial was written and can be promoted
def self.boot_result(succeeded, exit_status, output_written)
return :no_app if exit_status == NO_APP_EXIT
return :failed unless succeeded && output_written
:ok
end
end
end
Loading
Loading