Skip to content

Add deprecations boot to capture load-time deprecations#194

Draft
JuanVqz wants to merge 21 commits into
mainfrom
feature/deprecations-boot-capture
Draft

Add deprecations boot to capture load-time deprecations#194
JuanVqz wants to merge 21 commits into
mainfrom
feature/deprecations-boot-capture

Conversation

@JuanVqz

@JuanVqz JuanVqz commented Jul 24, 2026

Copy link
Copy Markdown
Member

What

We got a request on our upgrade skill (claude-code_rails-upgrade-skill #113) about apps that don't have a solid test suite. The per-example DeprecationTracker only catches what the tests actually exercise, so on a thin suite a lot of deprecations never show up. I wanted a way to find them regardless of coverage.

This is that attempt, built on the same pattern next_rails already uses but moved to boot time: it keeps the app from eager-loading on its own, attaches the tracker, and then eager-loads, so every declaration-time warning gets recorded into a shitlist. Living in next_rails means it's a shared solution instead of keeping it in the skill only.

deprecations boot captures the warnings that fire at eager-load time (association, scope, and callback declarations evaluated as the class bodies load), which the per-example tracker never sees.

deprecations boot          # current bundle
deprecations --next boot   # next bundle (sets BUNDLE_GEMFILE=Gemfile.next + BUNDLE_CACHE_PATH=vendor/cache.next)

It writes an ordinary shitlist to spec/support/deprecation_warning.boot.shitlist.json and summarizes it the same way info does.

Notes

  • It reuses DeprecationTracker rather than adding new capture logic (init_tracker, eager-load, after_run). That also means it talks to whichever deprecator the Rails version exposes: Rails.application.deprecators on 7.1+, and the ActiveSupport::Deprecation singleton before that. So it works from Rails 2 through 8.1.
  • --next only gives you the full set once the next bundle already boots (dual boot is green). Before that, eager-load stops at the first breaking change, one at a time, which is the boot-smoke problem rather than this one. When a boot fails or is refused, the command exits non-zero and says why, instead of pretending the app is clean.
  • While adding the CLI smoke test, two pre-existing bugs turned up and are fixed here too: the executable required rainbow, which is no longer a dependency, so it wouldn't even load on a clean install; and deprecations run blew up with a NoMethodError before doing any work.

JuanVqz added 5 commits July 24, 2026 13:56
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).
`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.
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.
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.
References PR #194 (the predicted next number). Update the link if the PR is
assigned a different number when opened.
@JuanVqz JuanVqz self-assigned this Jul 24, 2026
JuanVqz added 16 commits July 24, 2026 14:58
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.
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.
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_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.
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.
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.
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.
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).
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).
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 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.
`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.
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.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant