diff --git a/.gitignore b/.gitignore index 235bff1..8475ba0 100644 --- a/.gitignore +++ b/.gitignore @@ -14,4 +14,6 @@ .ruby-version Gemfile.lock +Gemfile.next .gem +*.partial diff --git a/CHANGELOG.md b/CHANGELOG.md index 5ef91b8..9edb973 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -3,6 +3,9 @@ - [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 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. diff --git a/README.md b/README.md index bd7cad6..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) +- **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 @@ -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 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. + ### 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. @@ -252,8 +277,11 @@ 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") +deprecations --next boot # same, on the next bundle deprecations --help ``` diff --git a/exe/deprecations b/exe/deprecations index baf914c..6dd1122 100755 --- a/exe/deprecations +++ b/exe/deprecations @@ -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]) @@ -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 `) 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 @@ -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 @@ -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 @@ -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 @@ -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" @@ -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 --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}/ @@ -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 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.rb b/lib/deprecation_tracker/boot_capture.rb new file mode 100644 index 0000000..e7ceb23 --- /dev/null +++ b/lib/deprecation_tracker/boot_capture.rb @@ -0,0 +1,111 @@ +# 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. + # + # 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 + # 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" + + # 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. + 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 (via `rails runner`, which fully boots and so + # registers the framework deprecators the runner attaches to) and runs the + # gem's runner script. + # + # 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. + # + # 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. + # * 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) + raise ArgumentError, "output_path is required" unless output_path + env = { "CI" => nil, "RAILS_ENV" => "test", OUTPUT_ENV => output_path.to_s } + if next_mode + env["BUNDLE_GEMFILE"] = "Gemfile.next" + env["BUNDLE_CACHE_PATH"] = "vendor/cache.next" + end + [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 + + # 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/lib/deprecation_tracker/boot_capture_runner.rb b/lib/deprecation_tracker/boot_capture_runner.rb new file mode 100644 index 0000000..3932d9e --- /dev/null +++ b/lib/deprecation_tracker/boot_capture_runner.rb @@ -0,0 +1,68 @@ +# frozen_string_literal: true + +# 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 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. +# +# 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! does nothing. Refuse +# 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 unsets CI for the stock Rails template)." + 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 + +# 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", + :transform_message => lambda { |message| message.gsub("#{Rails.root}/", "") } +) +tracker.bucket = "boot" +Rails.application.eager_load! +tracker.after_run 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/deprecation_tracker/boot_capture_spec.rb b/spec/deprecation_tracker/boot_capture_spec.rb new file mode 100644 index 0000000..b1972a3 --- /dev/null +++ b/spec/deprecation_tracker/boot_capture_spec.rb @@ -0,0 +1,170 @@ +# frozen_string_literal: true + +require "spec_helper" + +require "tmpdir" +require "fileutils" +require "rbconfig" +require_relative "../../lib/deprecation_tracker/boot_capture" + +RSpec.describe DeprecationTracker::BootCapture do + describe ".boot_command" do + it "returns an env hash + argv for system (no shell), running the gem runner" do + env, *argv = described_class.boot_command(output_path: "spec/support/deprecation_warning.boot.shitlist.json") + # `rails runner` fully boots the app, which registers the deprecators the runner attaches to. + 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 + 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 + + 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 + + describe ".default_output_path" do + it "follows the tracker's spec/support convention with a .boot marker" do + expect(described_class.default_output_path).to eq("spec/support/deprecation_warning.boot.shitlist.json") + end + + it "distinguishes the next bundle" do + expect(described_class.default_output_path(next_mode: true)).to eq("spec/support/deprecation_warning.boot.next.shitlist.json") + end + end + + describe "RUNNER_PATH" do + # Accepted limitation: the runner only does its real work 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 + # `ruby -c` is portable across implementations; RubyVM::InstructionSequence is MRI-only. + expect(system(RbConfig.ruby, "-c", described_class::RUNNER_PATH, out: File::NULL)).to be(true) + end + + it "reuses DeprecationTracker rather than reimplementing capture" do + script = File.read(described_class::RUNNER_PATH) + expect(script).to include("require \"deprecation_tracker\"") + expect(script).to include("DeprecationTracker.init_tracker") + expect(script).to include("tracker.bucket =") + expect(script).to include("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 + + 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 + # 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 + + 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 + + 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. + # 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 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 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