Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
21 commits
Select commit Hold shift + click to select a range
bd1d220
Add DeprecationTracker::BootCapture for load-time deprecations
JuanVqz Jul 24, 2026
b392fd8
Add `boot` mode to the deprecations CLI
JuanVqz Jul 24, 2026
051aeef
Test BootCapture command, default paths, and runner script
JuanVqz Jul 24, 2026
8c41c0a
Document `deprecations boot` in the README
JuanVqz Jul 24, 2026
5ef7206
Add CHANGELOG entry for `deprecations boot`
JuanVqz Jul 24, 2026
d45bdfd
Refuse boot capture when the env eager-loads at boot
JuanVqz Jul 24, 2026
924f958
Ignore Gemfile.next (dual-boot / capture test artifact)
JuanVqz Jul 24, 2026
70feecd
Document that boot capture covers eager-load only
JuanVqz Jul 24, 2026
20d4117
Force a recording, non-fatal deprecation setup for boot capture
JuanVqz Jul 24, 2026
8e1f371
Build the boot command as argv instead of a shell string
JuanVqz Jul 24, 2026
fb23cf9
Preserve the previous boot shitlist when a capture fails
JuanVqz Jul 24, 2026
34904a3
Add --path so read commands can target any shitlist
JuanVqz Jul 24, 2026
5e067b2
Ignore *.partial (boot capture writes one before atomic rename)
JuanVqz Jul 24, 2026
840cca8
Store relative paths and require save's stdlib deps for boot capture
JuanVqz Jul 24, 2026
7f39e8f
Tighten boot CLI ergonomics and keep boot_command 2.0-safe
JuanVqz Jul 24, 2026
3a4eb2b
Pair BUNDLE_CACHE_PATH with BUNDLE_GEMFILE for the next bundle
JuanVqz Jul 24, 2026
9919216
Extract boot result classification so the CLI paths are testable
JuanVqz Jul 25, 2026
f6114ff
Use NextRails::Tint instead of the undeclared rainbow gem
JuanVqz Jul 25, 2026
d147461
Require deprecation_tracker in exe/deprecations
JuanVqz Jul 25, 2026
1bf8c41
Smoke test exe/deprecations by actually running it
JuanVqz Jul 25, 2026
036bc26
Add CHANGELOG entries for the two deprecations CLI bugfixes
JuanVqz Jul 25, 2026
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
3 changes: 3 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,9 @@
- [BUGFIX: example](https://github.com/fastruby/next_rails/pull/<number>)
- [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.

Expand Down
29 changes: 28 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), 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

Expand Down Expand Up @@ -190,6 +190,31 @@ 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 **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
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. 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."
>
> 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

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 +279,8 @@ deprecations info
deprecations info --pattern "ActiveRecord::Base"
deprecations merge --delete-shards
deprecations run
deprecations boot # capture eager-load-time deprecations (see "Boot-time deprecations")
deprecations --next boot # same, on the next bundle
deprecations --help
```

Expand Down
133 changes: 116 additions & 17 deletions exe/deprecations
Original file line number Diff line number Diff line change
@@ -1,10 +1,17 @@
#!/usr/bin/env ruby
require "json"
require "rainbow"
require "optparse"
require "set"
require_relative "../lib/deprecation_tracker/valid_modes"
require_relative "../lib/deprecation_tracker/shard_merger"
require "fileutils"

# 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])
Expand All @@ -25,23 +32,78 @@ 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 <boot shitlist>`) 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

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 "Test files: #{data.fetch(:test_files).to_a.join(" ")}" if verbose
puts Rainbow(message).red
puts NextRails::Tint("Occurrences: #{data.fetch(:occurrences)}").bold
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 = 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 = 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 :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 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("Eager-load completed cleanly — no deprecation warnings while loading the app's classes.").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 +112,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 +130,17 @@ 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 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 unsets CI for the stock Rails
template); refuses if the app eager-loads at boot.

Options:
MESSAGE

Expand All @@ -89,6 +164,14 @@ 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("--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

opts.on_tail("-h", "--help", "Prints this help") do
puts opts
exit
Expand All @@ -98,7 +181,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"
Expand All @@ -107,7 +191,22 @@ 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"
if options[:pattern]
abort "--pattern is not supported with 'boot'. Capture, then filter the saved shitlist: " \
"deprecations info --path <boot shitlist> --pattern <regex>"
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}/

Expand All @@ -127,10 +226,10 @@ 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 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
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 "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"
#
Expand Down
Loading
Loading