├── .rspec ├── lib ├── next_rails │ ├── version.rb │ ├── bundle_report │ │ ├── ruby_version_compatibility.rb │ │ ├── rails_version_compatibility.rb │ │ └── cli.rb │ ├── init.rb │ ├── gem_info.rb │ └── bundle_report.rb ├── next_rails.rb └── deprecation_tracker.rb ├── Rakefile ├── bin ├── setup └── console ├── exe ├── bundle_report ├── next ├── next_rails ├── gem-next-diff ├── next.sh └── deprecations ├── Gemfile ├── .gitignore ├── .github ├── dependabot.yml ├── workflows │ └── main.yml └── ISSUE_TEMPLATE │ ├── feature_request.md │ └── bug_report.md ├── pull_request_template.md ├── LICENSE.txt ├── spec ├── next_rails_spec.rb ├── next_rails │ ├── bundle_report │ │ ├── cli_spec.rb │ │ ├── rails_version_compatibility_spec.rb │ │ └── ruby_version_compatibility_spec.rb │ ├── init_spec.rb │ ├── gem_info_spec.rb │ └── bundle_report_spec.rb ├── spec_helper.rb └── deprecation_tracker_spec.rb ├── next_rails.gemspec ├── CONTRIBUTING.md ├── CODE_OF_CONDUCT.md ├── deprecation_tracker.md ├── README.md └── CHANGELOG.md /.rspec: -------------------------------------------------------------------------------- 1 | --format documentation 2 | --color 3 | --require spec_helper 4 | -------------------------------------------------------------------------------- /lib/next_rails/version.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module NextRails 4 | VERSION = "1.4.7" 5 | end 6 | -------------------------------------------------------------------------------- /Rakefile: -------------------------------------------------------------------------------- 1 | require "bundler/gem_tasks" 2 | require "rspec/core/rake_task" 3 | 4 | RSpec::Core::RakeTask.new(:spec) 5 | 6 | task :default => :spec 7 | -------------------------------------------------------------------------------- /bin/setup: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | set -euo pipefail 3 | IFS=$'\n\t' 4 | set -vx 5 | 6 | bundle install 7 | 8 | # Do any other automated setup that you need to do here 9 | -------------------------------------------------------------------------------- /exe/bundle_report: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env ruby 2 | 3 | # Needs to happen first 4 | require "bundler/setup" 5 | require "next_rails" 6 | NextRails::BundleReport::CLI.new(ARGV).run 7 | -------------------------------------------------------------------------------- /Gemfile: -------------------------------------------------------------------------------- 1 | source "https://rubygems.org" 2 | 3 | git_source(:github) {|repo_name| "https://github.com/#{repo_name}" } 4 | 5 | # Specify your gem's dependencies in next_rails.gemspec 6 | gemspec 7 | -------------------------------------------------------------------------------- /exe/next: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env ruby 2 | # Ruby wrapper around next.sh so it works with Rubygems 3 | exe_dir = File.expand_path(File.dirname(__FILE__)) 4 | exec(File.join(exe_dir, 'next.sh'), *ARGV) 5 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | /.bundle/ 2 | /.yardoc 3 | /_yardoc/ 4 | /coverage/ 5 | /doc/ 6 | /pkg/ 7 | /spec/reports/ 8 | /tmp/ 9 | 10 | .byebug_history 11 | 12 | # rspec failure tracking 13 | .rspec_status 14 | 15 | .ruby-version 16 | Gemfile.lock 17 | .gem 18 | -------------------------------------------------------------------------------- /bin/console: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env ruby 2 | 3 | require "bundler/setup" 4 | require "next_rails" 5 | 6 | # You can add fixtures and/or initialization code here to make experimenting 7 | # with your gem easier. You can also use a different console, if you like. 8 | 9 | # (If you use this, don't forget to add pry to your Gemfile!) 10 | # require "pry" 11 | # Pry.start 12 | 13 | require "irb" 14 | IRB.start(__FILE__) 15 | -------------------------------------------------------------------------------- /.github/dependabot.yml: -------------------------------------------------------------------------------- 1 | # To get started with Dependabot version updates, you'll need to specify which 2 | # package ecosystems to update and where the package manifests are located. 3 | # Please see the documentation for all configuration options: 4 | # https://help.github.com/github/administering-a-repository/configuration-options-for-dependency-updates 5 | 6 | version: 2 7 | updates: 8 | - package-ecosystem: "bundler" 9 | directory: "/" 10 | schedule: 11 | interval: "daily" 12 | -------------------------------------------------------------------------------- /.github/workflows/main.yml: -------------------------------------------------------------------------------- 1 | name: Continuous Integration 2 | 3 | on: 4 | push: 5 | branches: 6 | - main 7 | pull_request: 8 | branches: 9 | - main 10 | 11 | jobs: 12 | test: 13 | runs-on: ubuntu-latest 14 | strategy: 15 | matrix: 16 | ruby-version: ["3.4", "3.3", "3.2", "3.1", "3.0", "2.7", "2.6", "2.5", "2.4", "2.3"] 17 | 18 | steps: 19 | - uses: actions/checkout@v4 20 | - name: Set up Ruby ${{ matrix.ruby-version }} 21 | uses: ruby/setup-ruby@v1 22 | with: 23 | ruby-version: ${{ matrix.ruby-version }} 24 | bundler-cache: true 25 | - name: Run rake task 26 | run: bundle exec rake 27 | -------------------------------------------------------------------------------- /pull_request_template.md: -------------------------------------------------------------------------------- 1 | ## Description 2 | 3 | 4 | ## Motivation and Context 5 | 6 | 7 | 8 | ## How Has This Been Tested? 9 | 10 | 11 | 12 | ## Screenshots: 13 | 14 | 15 | **I will abide by the [code of conduct](https://github.com/fastruby/next_rails/blob/main/CODE_OF_CONDUCT.md)** 16 | -------------------------------------------------------------------------------- /exe/next_rails: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env ruby 2 | require "optparse" 3 | require "next_rails/version" 4 | require "next_rails" 5 | 6 | options = {} 7 | option_parser = OptionParser.new do |opts| 8 | opts.banner = <<-MESSAGE 9 | Usage: #{__FILE__.to_s} [options] 10 | 11 | Examples: 12 | bin/next_rails --version info # Show the version of the gem installed 13 | MESSAGE 14 | 15 | opts.on("--init", "Setup the dual-boot configuration") do 16 | options[:init] = true 17 | end 18 | 19 | opts.on("--version", "show version of the gem") do 20 | options[:version] = true 21 | end 22 | 23 | opts.on_tail("-h", "--help", "Prints this help") do 24 | puts opts 25 | exit 26 | end 27 | end 28 | 29 | option_parser.parse! 30 | 31 | puts NextRails::Init.call if options.fetch(:init, false) 32 | 33 | if options.fetch(:version, false) 34 | puts NextRails::VERSION 35 | exit 2 36 | end 37 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/feature_request.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Feature request 3 | about: Request a new feature 4 | title: "[REQUEST]" 5 | labels: enhancement 6 | assignees: "" 7 | --- 8 | 9 | 10 | 11 | 12 | 13 | ## Description 14 | 15 | 16 | ## Possible Implementation 17 | 18 | 19 | ## Resources: 20 | 21 | 22 | 23 | **I will abide by the [code of conduct](https://github.com/fastruby/next_rails/blob/main/CODE_OF_CONDUCT.md)** 24 | -------------------------------------------------------------------------------- /LICENSE.txt: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | 3 | Copyright (c) 2018 Jordan Raine 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in 13 | all copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN 21 | THE SOFTWARE. 22 | -------------------------------------------------------------------------------- /lib/next_rails.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require "next_rails/gem_info" 4 | require "next_rails/version" 5 | require "next_rails/init" 6 | require "next_rails/bundle_report" 7 | require "next_rails/bundle_report/cli" 8 | require "next_rails/bundle_report/ruby_version_compatibility" 9 | require "next_rails/bundle_report/rails_version_compatibility" 10 | require "deprecation_tracker" 11 | 12 | module NextRails 13 | @@next_bundle_gemfile = nil 14 | 15 | # This method will check your environment 16 | # (e.g. `ENV['BUNDLE_GEMFILE]`) to determine whether your application is 17 | # running with the next set of dependencies or the current set of dependencies. 18 | # 19 | # @return [Boolean] 20 | def self.next? 21 | return @@next_bundle_gemfile unless @@next_bundle_gemfile.nil? 22 | 23 | @@next_bundle_gemfile = File.exist?(ENV["BUNDLE_GEMFILE"]) && File.basename(ENV["BUNDLE_GEMFILE"]) == "Gemfile.next" 24 | end 25 | 26 | # This method will reset the @@next_bundle_gemfile variable. Then next time 27 | # you call `NextRails.next?` it will check the environment once again. 28 | def self.reset_next_bundle_gemfile 29 | @@next_bundle_gemfile = nil 30 | end 31 | end 32 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/bug_report.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Bug report 3 | about: Create a report to help us improve 4 | title: "[BUG] " 5 | labels: bug 6 | assignees: "" 7 | --- 8 | 9 | 10 | 11 | 12 | 13 | ## Expected Behavior 14 | 15 | 16 | 17 | ## Actual Behavior 18 | 19 | 20 | 21 | ## Possible Fix 22 | 23 | 24 | ## To Reproduce 25 | 26 | 27 | 28 | 1. 29 | 2. 30 | 3. 31 | 4. 32 | 33 | ## Additional Information 34 | 35 | 36 | 37 | **I will abide by the [code of conduct](https://github.com/fastruby/next_rails/blob/main/CODE_OF_CONDUCT.md)** 38 | -------------------------------------------------------------------------------- /lib/next_rails/bundle_report/ruby_version_compatibility.rb: -------------------------------------------------------------------------------- 1 | require "rainbow" 2 | 3 | class NextRails::BundleReport::RubyVersionCompatibility 4 | MINIMAL_VERSION = 1.0 5 | attr_reader :gems, :options 6 | 7 | def initialize(gems: NextRails::GemInfo.all, options: {}) 8 | @gems = gems 9 | @options = options 10 | end 11 | 12 | def generate 13 | return invalid_message unless valid? 14 | 15 | message 16 | end 17 | 18 | private 19 | 20 | def message 21 | output = Rainbow("=> Incompatible gems with Ruby #{ruby_version}:").white.bold 22 | incompatible.each do |gem| 23 | output += Rainbow("\n#{gem.name} - required Ruby version: #{gem.gem_specification.required_ruby_version}").magenta 24 | end 25 | output += Rainbow("\n\n#{incompatible.length} incompatible #{incompatible.one? ? 'gem' : 'gems' } with Ruby #{ruby_version}").red 26 | output 27 | end 28 | 29 | def incompatible 30 | gems.reject { |gem| gem.compatible_with_ruby?(ruby_version) } 31 | end 32 | 33 | def ruby_version 34 | options[:ruby_version].to_f 35 | end 36 | 37 | def invalid_message 38 | Rainbow("=> Invalid Ruby version: #{options[:ruby_version]}.").red.bold 39 | end 40 | 41 | def valid? 42 | ruby_version > MINIMAL_VERSION 43 | end 44 | end 45 | -------------------------------------------------------------------------------- /spec/next_rails_spec.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require "spec_helper" 4 | 5 | require "fileutils" 6 | 7 | RSpec.describe NextRails do 8 | it "has a version number" do 9 | expect(NextRails::VERSION).not_to be nil 10 | end 11 | 12 | describe "NextRails.next?" do 13 | context "when BUNDLE_GEMFILE is not set" do 14 | it "returns false" do 15 | expect(NextRails.next?).to be_falsey 16 | end 17 | end 18 | 19 | context "when BUNDLE_GEMFILE is set" do 20 | context "when it is set to Gemfile.next" do 21 | it "returns true" do 22 | FileUtils.touch("Gemfile.next") 23 | NextRails.reset_next_bundle_gemfile 24 | 25 | with_env("BUNDLE_GEMFILE" => "Gemfile.next") 26 | expect(NextRails.next?).to be_truthy 27 | end 28 | 29 | context "when Gemfile.next file does not exist" do 30 | it "returns false" do 31 | FileUtils.rm("Gemfile.next") 32 | NextRails.reset_next_bundle_gemfile 33 | 34 | with_env("BUNDLE_GEMFILE" => "Gemfile.next") 35 | expect(NextRails.next?).to be_falsey 36 | end 37 | end 38 | end 39 | 40 | context "when it is set to something else" do 41 | it "returns false" do 42 | FileUtils.touch("Gemfile4") 43 | NextRails.reset_next_bundle_gemfile 44 | 45 | with_env("BUNDLE_GEMFILE" => "Gemfile4") 46 | expect(NextRails.next?).to be_falsey 47 | 48 | FileUtils.rm("Gemfile4") 49 | end 50 | end 51 | end 52 | end 53 | 54 | end 55 | -------------------------------------------------------------------------------- /spec/next_rails/bundle_report/cli_spec.rb: -------------------------------------------------------------------------------- 1 | require "spec_helper" 2 | 3 | RSpec.describe NextRails::BundleReport::CLI do 4 | describe '#initialize' do 5 | it 'calls with called with any arguemnt' do 6 | expect(NextRails::BundleReport).to receive(:rails_compatibility) 7 | described_class.new([]).run 8 | end 9 | 10 | it 'raises if called with invalid arguments' do 11 | expect { described_class.new(['invalid_report_type']) } 12 | .to raise_error(ArgumentError, 13 | /Invalid report type 'invalid_report_type'. Valid types are: outdated, compatibility, ruby_check./) 14 | end 15 | 16 | it "calls outdated if called with outdated" do 17 | expect(NextRails::BundleReport).to receive(:outdated) 18 | described_class.new(["outdated"]).run 19 | end 20 | 21 | it "calls compatible_ruby_version if called with ruby_check" do 22 | expect(NextRails::BundleReport).to receive(:compatible_ruby_version) 23 | described_class.new(["ruby_check", "--rails-version=8.0.0"]).run 24 | end 25 | 26 | it 'calls rails_compatibility if called with compatibility with rails-version option' do 27 | expect(NextRails::BundleReport).to receive(:rails_compatibility) 28 | described_class.new(["compatibility", "--rails-version=8.0.0"]).run 29 | end 30 | 31 | it 'calls ruby_compatibility if called with compatibility with ruby-version option' do 32 | expect(NextRails::BundleReport).to receive(:ruby_compatibility) 33 | described_class.new(["compatibility", "--ruby-version=3.4.0"]).run 34 | end 35 | end 36 | end 37 | -------------------------------------------------------------------------------- /next_rails.gemspec: -------------------------------------------------------------------------------- 1 | lib = File.expand_path("../lib", __FILE__) 2 | $LOAD_PATH.unshift(lib) unless $LOAD_PATH.include?(lib) 3 | require "next_rails/version" 4 | 5 | Gem::Specification.new do |spec| 6 | spec.name = "next_rails" 7 | spec.version = NextRails::VERSION 8 | spec.authors = ["Ernesto Tagwerker", "Luis Sagastume"] 9 | spec.email = ["ernesto@ombulabs.com", "luis@ombulabs.com"] 10 | 11 | spec.summary = %q{A toolkit to upgrade your next Rails application} 12 | spec.description = %q{A set of handy tools to upgrade your Rails application and keep it up to date} 13 | spec.homepage = "https://github.com/fastruby/next_rails" 14 | spec.license = "MIT" 15 | 16 | spec.required_ruby_version = ">= 2.0" 17 | 18 | spec.files = `git ls-files -z`.split("\x0").reject do |f| 19 | f.match(%r{^(test|spec|features)/}) 20 | end 21 | spec.bindir = "exe" 22 | spec.executables = spec.files.grep(%r{^exe/}) { |f| File.basename(f) } 23 | spec.require_paths = ["lib"] 24 | 25 | spec.add_dependency "rainbow", ">= 3" 26 | spec.add_development_dependency "bundler", ">= 1.16", "< 3.0" 27 | spec.add_development_dependency "rake" 28 | spec.add_development_dependency "rspec", "~> 3.0" 29 | spec.add_development_dependency "simplecov", "~> 0.17.1" 30 | spec.add_development_dependency "timecop", "~> 0.9.1" 31 | spec.add_development_dependency "byebug" 32 | spec.add_development_dependency "rexml", "3.2.5" # limited on purpose, new versions don't work with old rubies 33 | spec.add_development_dependency "webmock", "3.16.2" 34 | spec.add_development_dependency "base64" 35 | end 36 | -------------------------------------------------------------------------------- /exe/gem-next-diff: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env ruby 2 | # 3 | # Output a markdown table containing the version differences between the Gemfile and Gemfile.next. 4 | # This can be used as a TODO list to bring the two Gemfiles as close to parity as possible, 5 | # either by upgrading gems within `Gemfile` or downgrading gems within `Gemfile.next`. 6 | # 7 | 8 | def parse_gem_versions(&block) 9 | bundler_output = block.call 10 | bundler_output.split("\n").each_with_object({}) do |line, hash| 11 | next unless line.start_with?("Using") 12 | 13 | gem_name, version = line.split(/\s+/)[1..2] 14 | hash[gem_name] = version 15 | end 16 | end 17 | 18 | rails_gems = [ 19 | "rails", 20 | "activemodel", 21 | "activerecord", 22 | "actionmailer", 23 | "actioncable", 24 | "actionpack", 25 | "actionview", 26 | "activejob", 27 | "activestorage", 28 | "activesupport", 29 | "railties", 30 | ] 31 | 32 | gems = parse_gem_versions { `BUNDLE_CACHE_PATH=vendor/cache BUNDLE_GEMFILE=Gemfile bundle install` } 33 | gems_next = parse_gem_versions { `BUNDLE_CACHE_PATH=vendor/cache.next BUNDLE_GEMFILE=Gemfile.next bundle install` } 34 | all_gem_names = (gems.keys + gems_next.keys).uniq.sort 35 | 36 | puts "| Gem | `Gemfile` version | `Gemfile.next` version | Rails internals |" 37 | puts "|-----|-------------------|------------------------|-----------------| " 38 | all_gem_names.each do |gem_name| 39 | gem_version = gems[gem_name] || "-" 40 | gem_next_version = gems_next[gem_name] || "-" 41 | rails_internal = rails_gems.include?(gem_name) ? "✅" : "" 42 | 43 | if gem_version != gem_next_version 44 | puts "| **#{gem_name}** | #{gem_version} | #{gem_next_version} | #{rails_internal} |" 45 | end 46 | end 47 | -------------------------------------------------------------------------------- /spec/next_rails/bundle_report/rails_version_compatibility_spec.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require "spec_helper" 4 | 5 | RSpec.describe NextRails::BundleReport::RailsVersionCompatibility do 6 | describe "#generate" do 7 | it "returns non incompatible gems" do 8 | output = NextRails::BundleReport::RailsVersionCompatibility.new(options: { rails_version: 7.0 }).generate 9 | expect(output).to match "gems incompatible with Rails 7.0" 10 | end 11 | 12 | it "returns incompatible with compatible versions" do 13 | next_rails_version = 7.1 14 | specification = Gem::Specification.new do |s| 15 | s.name = "audited" 16 | s.version = "5.1.0" 17 | s.add_dependency "rails", ">= 5.0", "< 7.1" 18 | end 19 | audited = NextRails::GemInfo.new(specification) 20 | gems = [audited] 21 | 22 | allow_any_instance_of(described_class).to receive(:incompatible_gems_by_state) 23 | .and_return({ found_compatible: gems }) 24 | 25 | allow(audited).to receive(:latest_compatible_version).and_return(Gem::Version.new("5.8.0")) 26 | 27 | output = 28 | NextRails::BundleReport::RailsVersionCompatibility.new( 29 | gems: gems, 30 | options: { rails_version: next_rails_version, include_rails_gems: false } 31 | ).generate 32 | 33 | expect(output).to include("Incompatible with Rails 7.1 (with new versions that are compatible):") 34 | expect(output).to include("These gems will need to be upgraded before upgrading to Rails 7.1.") 35 | expect(output).to include("- upgrade to 5.8.0") 36 | expect(output).to include("gems incompatible with Rails 7.1") 37 | end 38 | end 39 | end 40 | 41 | -------------------------------------------------------------------------------- /exe/next.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | if [[ "${@}" == "--init" ]]; then 3 | echo "The next --init command is deprecated. Please use the next_rails --init command instead." 4 | # Add next? top of Gemfile 5 | cat <<-STRING > Gemfile.tmp 6 | def next? 7 | File.basename(__FILE__) == "Gemfile.next" 8 | end 9 | STRING 10 | cat Gemfile >> Gemfile.tmp 11 | mv Gemfile.tmp Gemfile 12 | 13 | ln -s Gemfile Gemfile.next 14 | 15 | # Initialize the Gemfile.next.lock 16 | # Prevents major version jumps when we start without a Gemfile.next.lock 17 | if [ -f "Gemfile.lock" ] && [ ! -f "Gemfile.next.lock" ]; then 18 | cp Gemfile.lock Gemfile.next.lock 19 | fi 20 | 21 | echo <<-MESSAGE 22 | Created Gemfile.next (a symlink to your Gemfile). Your Gemfile has been modified to support dual-booting! 23 | 24 | There's just one more step: modify your Gemfile to use a newer version of Rails using the \`next?\` helper method. 25 | 26 | For example, here's how to go from 5.2.3 to 6.0: 27 | 28 | if next? 29 | gem "rails", "6.0.0" 30 | else 31 | gem "rails", "5.2.3" 32 | end 33 | MESSAGE 34 | exit $? 35 | fi 36 | 37 | if [[ "${@}" =~ ^bundle ]]; then 38 | BUNDLE_GEMFILE=Gemfile.next BUNDLE_CACHE_PATH=vendor/cache.next $@ 39 | else 40 | BUNDLE_GEMFILE=Gemfile.next BUNDLE_CACHE_PATH=vendor/cache.next bundle exec $@ 41 | fi 42 | 43 | COMMAND_EXIT=$? 44 | 45 | GEM_NOT_FOUND=7 # https://github.com/bundler/bundler/blob/master/lib/bundler/errors.rb#L35 46 | EXECUTABLE_NOT_FOUND=127 # https://github.com/bundler/bundler/blob/master/lib/bundler/cli/exec.rb#L62 47 | if [[ $COMMAND_EXIT -eq $GEM_NOT_FOUND || $COMMAND_EXIT -eq $EXECUTABLE_NOT_FOUND ]]; then 48 | BLUE='\033[0;34m' 49 | UNDERLINE_WHITE='\033[37m' 50 | NO_COLOR='\033[0m' 51 | 52 | echo -e "${BLUE}Having trouble running commands with ${UNDERLINE_WHITE}bin/next${BLUE}?" 53 | echo -e "Try running ${UNDERLINE_WHITE}bin/next bundle install${BLUE}, then try your command again.${NO_COLOR}" 54 | fi 55 | 56 | exit $COMMAND_EXIT 57 | -------------------------------------------------------------------------------- /spec/spec_helper.rb: -------------------------------------------------------------------------------- 1 | if ENV['COVERAGE'] == 'true' 2 | require 'simplecov' 3 | SimpleCov.start do 4 | # Disambiguates individual test runs 5 | command_name "Job #{ENV["TEST_ENV_NUMBER"]}" if ENV["TEST_ENV_NUMBER"] 6 | 7 | if ENV['CI'] 8 | formatter SimpleCov::Formatter::SimpleFormatter 9 | else 10 | formatter SimpleCov::Formatter::MultiFormatter.new([ 11 | SimpleCov::Formatter::SimpleFormatter, 12 | SimpleCov::Formatter::HTMLFormatter 13 | ]) 14 | end 15 | 16 | track_files "lib/**/*.rb" 17 | end 18 | end 19 | 20 | require "bundler/setup" 21 | require "next_rails" 22 | 23 | require 'webmock/rspec' 24 | WebMock.disable_net_connect!(allow_localhost: true) 25 | 26 | RSpec.configure do |config| 27 | # Enable flags like --only-failures and --next-failure 28 | config.example_status_persistence_file_path = ".rspec_status" 29 | 30 | # Disable RSpec exposing methods globally on `Module` and `main` 31 | config.disable_monkey_patching! 32 | 33 | config.expect_with :rspec do |c| 34 | c.syntax = :expect 35 | end 36 | 37 | config.before(:each) do 38 | stub_request(:get, /rubygems.org\/api\/v2\/rubygems\/rails\/versions/). 39 | with(headers: {'Accept'=>'*/*', 'Accept-Encoding'=>'gzip;q=1.0,deflate;q=0.6,identity;q=0.3', 'Host'=>'rubygems.org', 'User-Agent'=>'Ruby'}). 40 | to_return(status: 200, body: "{\"ruby_version\": \">= 2.7.0\"}", headers: {}) 41 | 42 | stub_request(:get, /rubygems.org\/api\/v1\/versions\/rails.json/). 43 | with(headers: {'Accept'=>'*/*', 'Accept-Encoding'=>'gzip;q=1.0,deflate;q=0.6,identity;q=0.3', 'Host'=>'rubygems.org', 'User-Agent'=>'Ruby'}). 44 | to_return(status: 200, body: "[{\"number\": \"7.0.0\"}, {\"number\": \"6.1.6\"}]", headers: {}) 45 | end 46 | end 47 | 48 | def with_env(env_hash) 49 | stub_const("ENV", ENV.to_hash.merge!(env_hash)) 50 | end 51 | 52 | def with_captured_stdout 53 | old_stdout = $stdout 54 | $stdout = StringIO.new 55 | yield 56 | $stdout.string 57 | ensure 58 | $stdout = old_stdout 59 | end 60 | -------------------------------------------------------------------------------- /spec/next_rails/init_spec.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require 'spec_helper' 4 | require 'fileutils' 5 | 6 | RSpec.describe NextRails::Init do 7 | let(:gemfile_content) { "source 'https://rubygems.org'\ngem 'rails'\n" } 8 | 9 | before(:all) do 10 | FileUtils.cp('Gemfile', 'Gemfile.original') 11 | end 12 | 13 | after(:all) do 14 | FileUtils.cp('Gemfile.original', 'Gemfile') 15 | FileUtils.rm_f('Gemfile.original') 16 | end 17 | 18 | before do 19 | FileUtils.rm_f('Gemfile') 20 | FileUtils.rm_f('Gemfile.lock') 21 | FileUtils.rm_f('Gemfile.next') 22 | FileUtils.rm_f('Gemfile.next.lock') 23 | end 24 | 25 | after do 26 | FileUtils.rm_f('Gemfile') 27 | FileUtils.rm_f('Gemfile.lock') 28 | FileUtils.rm_f('Gemfile.next') 29 | FileUtils.rm_f('Gemfile.next.lock') 30 | end 31 | 32 | describe '.call' do 33 | it 'already has next Gemfile files' do 34 | File.write('Gemfile', gemfile_content) 35 | FileUtils.touch('Gemfile.lock') 36 | File.write('Gemfile.next', gemfile_content) 37 | 38 | expect(described_class.call).to eq('The next_rails --init command has already been run.') 39 | end 40 | 41 | it 'does not have Gemfile files' do 42 | expect(described_class.call).to eq('You must have a Gemfile and Gemfile.lock to run the next_rails --init command.') 43 | end 44 | 45 | it 'creates Gemfile.next and Gemfile.next.lock' do 46 | File.write('Gemfile', gemfile_content) 47 | FileUtils.touch('Gemfile.lock') 48 | 49 | expect do 50 | described_class.call 51 | end.to change { File.exist?('Gemfile.next') }.from(false).to(true) 52 | .and change { File.exist?('Gemfile.next.lock') }.from(false).to(true) 53 | end 54 | 55 | it 'returns a success message' do 56 | File.write('Gemfile', gemfile_content) 57 | FileUtils.touch('Gemfile.lock') 58 | 59 | message = described_class.call 60 | expect(message).to include('Created Gemfile.next (a symlink to your Gemfile).') 61 | expect(message).to include("For example, here's how to go from 5.2.8.1 to 6.0.6.1:") 62 | end 63 | end 64 | end 65 | -------------------------------------------------------------------------------- /lib/next_rails/init.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require 'fileutils' 4 | 5 | module NextRails 6 | # This class is responsible for installing the dual-boot files for your. 7 | class Init 8 | def self.call 9 | new.call 10 | end 11 | 12 | def call 13 | return gemfiles_message unless gemfiles? 14 | return next_gemfiles_message if next_gemfiles? 15 | 16 | add_next_conditional 17 | create_sym_link 18 | copy_gemfile_lock 19 | message 20 | end 21 | 22 | private 23 | 24 | def gemfiles? 25 | %w[Gemfile Gemfile.lock].any? { |file| File.exist?(file) } 26 | end 27 | 28 | def gemfiles_message 29 | 'You must have a Gemfile and Gemfile.lock to run the next_rails --init command.' 30 | end 31 | 32 | def next_gemfiles? 33 | %w[Gemfile.next Gemfile.next.lock].any? { |file| File.exist?(file) } 34 | end 35 | 36 | def next_gemfiles_message 37 | 'The next_rails --init command has already been run.' 38 | end 39 | 40 | def add_next_conditional 41 | File.open('Gemfile.tmp', 'w') do |file| 42 | file.write <<-STRING 43 | def next? 44 | File.basename(__FILE__) == "Gemfile.next" 45 | end 46 | STRING 47 | end 48 | 49 | File.open('Gemfile', 'r') do |original| 50 | File.open('Gemfile.tmp', 'a') do |temp| 51 | temp.write(original.read) 52 | end 53 | end 54 | 55 | File.rename('Gemfile.tmp', 'Gemfile') 56 | end 57 | 58 | def create_sym_link 59 | File.symlink('Gemfile', 'Gemfile.next') 60 | end 61 | 62 | def copy_gemfile_lock 63 | FileUtils.cp('Gemfile.lock', 'Gemfile.next.lock') 64 | end 65 | 66 | def message 67 | <<-MESSAGE 68 | Created Gemfile.next (a symlink to your Gemfile). Your Gemfile has been modified to support dual-booting! 69 | 70 | There's just one more step: modify your Gemfile to use a newer version of Rails using the \`next?\` helper method. 71 | 72 | For example, here's how to go from 5.2.8.1 to 6.0.6.1: 73 | 74 | if next? 75 | gem "rails", "6.0.6.1" 76 | else 77 | gem "rails", "5.2.8.1" 78 | end 79 | MESSAGE 80 | end 81 | end 82 | end 83 | -------------------------------------------------------------------------------- /CONTRIBUTING.md: -------------------------------------------------------------------------------- 1 | # Contributing to next_rails 2 | 3 | Have a fix for a problem you've been running into or an idea for a new feature you think would be useful? Bug reports and pull requests are welcome on GitHub at [https://github.com/fastruby/next_rails](https://github.com/fastruby/next_rails). 4 | 5 | Here's what you need to do: 6 | 7 | - Read and understand the [Code of Conduct](https://github.com/fastruby/next_rails/blob/main/CODE_OF_CONDUCT.md). 8 | - Fork this repo and clone your fork to somewhere on your machine. 9 | - [Ensure that you have a working environment](#setting-up-your-environment) 10 | - Read up on [run the tests](#running-all-tests). 11 | - Open a new branch and write a failing test for the feature or bug fix you plan on implementing. 12 | - [Update the changelog when applicable](#a-word-on-the-changelog). 13 | - Push to your fork and submit a pull request. 14 | - [Make sure the test suite passes on GitHub Actions and make any necessary changes to your branch to bring it to green.](#continuous-integration). 15 | 16 | ## Setting up your environment 17 | To install the dependencies, run: 18 | 19 | ```bash 20 | bin/setup 21 | ``` 22 | 23 | You can also run `bin/console` for an interactive prompt that will allow you to experiment with the gem. 24 | 25 | To install this gem onto your local machine, run: 26 | 27 | `bundle exec rake install`. 28 | 29 | ### Running all tests 30 | 31 | To run all of the tests, simply run: 32 | 33 | ```bash 34 | bundle exec rake 35 | ``` 36 | 37 | ## A word on the changelog 38 | 39 | You may also notice that we have a changelog in the form of [CHANGELOG.md](CHANGELOG.md). We use a format based on [Keep A Changelog](https://keepachangelog.com/en/1.0.0/). 40 | 41 | The important things to keep in mind are: 42 | 43 | - If your PR closes any open GitHub issue, make sure you include `Closes #XXXX` in your comment. 44 | - New additions get added under the main (unreleased) heading; 45 | - Attach a link to the PR with the following format: 46 | 47 | * [: Description of changes](github.com/link/to/pr). 48 | 49 | ## When Submitting a Pull Request: 50 | 51 | * If your PR closes any open GitHub issues, please include `Closes #XXXX` in your comment. 52 | * Please include a summary of the change and which issue is fixed or which feature is introduced. 53 | * If changes to the behavior are made, clearly describe what are the changes and why. 54 | * If changes to the UI are made, please include screenshots of the before and after. 55 | 56 | ## Continuous integration 57 | 58 | After opening your Pull Request, please make sure that all tests pass on the CI, to make sure your changes work in all possible environments. GitHub Actions will kick in after you push up a branch or open a PR. 59 | 60 | If the build fails, click on a failed job and scroll through its output to verify what is the problem. Push your changes to your branch until the build is green. 61 | -------------------------------------------------------------------------------- /spec/next_rails/bundle_report/ruby_version_compatibility_spec.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require "spec_helper" 4 | 5 | RSpec.describe NextRails::BundleReport::RubyVersionCompatibility do 6 | let(:ruby_3_0_gem) do 7 | Gem::Specification.new do |s| 8 | s.name = "ruby_3_0_gem" 9 | s.version = "1.0.0" 10 | s.required_ruby_version = ">= 3.0" 11 | end 12 | end 13 | 14 | let(:ruby_2_5_gem) do 15 | Gem::Specification.new do |s| 16 | s.name = "ruby_2_5_gem" 17 | s.version = "1.0.0" 18 | s.required_ruby_version = ">= 2.5" 19 | end 20 | end 21 | 22 | let(:ruby_2_3_to_2_5_gem) do 23 | Gem::Specification.new do |s| 24 | s.name = "ruby_2_3_to_2_5_gem" 25 | s.version = "1.0.0" 26 | s.required_ruby_version = [">= 2.3", "< 2.5"] 27 | end 28 | end 29 | 30 | let(:no_ruby_version_gem) do 31 | Gem::Specification.new do |s| 32 | s.name = "no_ruby_version_gem" 33 | s.version = "1.0.0" 34 | end 35 | end 36 | 37 | describe "#generate" do 38 | context "with invalid ruby version" do 39 | it "returns invalid message" do 40 | options = { ruby_version: "hola" } 41 | 42 | result = described_class.new(gems: [], options: options).generate 43 | expect(result).to include "Invalid Ruby version: hola" 44 | end 45 | end 46 | 47 | context "with valid ruby version" do 48 | it "returns 0 incompatible gems" do 49 | options = { ruby_version: "3.0" } 50 | gems = [NextRails::GemInfo.new(ruby_3_0_gem)] 51 | 52 | result = described_class.new(gems: gems, options: options).generate 53 | expect(result).to include "0 incompatible gems with Ruby 3.0" 54 | end 55 | 56 | it "returns 1 incompatible gem" do 57 | options = { ruby_version: "2.5" } 58 | gems = [NextRails::GemInfo.new(ruby_3_0_gem)] 59 | 60 | result = described_class.new(gems: gems, options: options).generate 61 | 62 | expect(result).to include "Incompatible gems with Ruby 2.5" 63 | expect(result).to include "ruby_3_0_gem - required Ruby version: >= 3.0" 64 | expect(result).to include "1 incompatible gem with Ruby 2.5" 65 | end 66 | 67 | it "returns 2 incompatible gems" do 68 | options = { ruby_version: "2.7" } 69 | gems = [ 70 | NextRails::GemInfo.new(ruby_3_0_gem), 71 | NextRails::GemInfo.new(ruby_2_5_gem), 72 | NextRails::GemInfo.new(ruby_2_3_to_2_5_gem), 73 | NextRails::GemInfo.new(no_ruby_version_gem) 74 | ] 75 | 76 | result = described_class.new(gems: gems, options: options).generate 77 | 78 | expect(result).to include "Incompatible gems with Ruby 2.7" 79 | expect(result).to include "ruby_3_0_gem - required Ruby version: >= 3.0" 80 | expect(result).to include "ruby_2_3_to_2_5_gem - required Ruby version:" # >= 2.3, < 2.5" 81 | expect(result).to include "2 incompatible gems with Ruby 2.7" 82 | end 83 | end 84 | end 85 | end 86 | -------------------------------------------------------------------------------- /lib/next_rails/bundle_report/rails_version_compatibility.rb: -------------------------------------------------------------------------------- 1 | class NextRails::BundleReport::RailsVersionCompatibility 2 | def initialize(gems: NextRails::GemInfo.all, options: {}) 3 | @gems = gems 4 | @options = options 5 | end 6 | 7 | def generate 8 | erb_output 9 | end 10 | 11 | def incompatible_gems_by_state 12 | @incompatible_gems_by_state ||= begin 13 | incompatible_gems.each { |gem| gem.find_latest_compatible(rails_version: rails_version) } 14 | incompatible_gems.group_by { |gem| gem.state(rails_version) } 15 | end 16 | end 17 | 18 | private 19 | 20 | def erb_output 21 | template = <<-ERB 22 | <% if incompatible_gems_by_state[:found_compatible] -%> 23 | <%= Rainbow("=> Incompatible with Rails #{rails_version} (with new versions that are compatible):").white.bold %> 24 | <%= Rainbow("These gems will need to be upgraded before upgrading to Rails #{rails_version}.").italic %> 25 | 26 | <% incompatible_gems_by_state[:found_compatible].each do |gem| -%> 27 | <%= gem_header(gem) %> - upgrade to <%= gem.latest_compatible_version.version %> 28 | <% end -%> 29 | 30 | <% end -%> 31 | <% if incompatible_gems_by_state[:incompatible] -%> 32 | <%= Rainbow("=> Incompatible with Rails #{rails_version} (with no new compatible versions):").white.bold %> 33 | <%= Rainbow("These gems will need to be removed or replaced before upgrading to Rails #{rails_version}.").italic %> 34 | 35 | <% incompatible_gems_by_state[:incompatible].each do |gem| -%> 36 | <%= gem_header(gem) %> - new version, <%= gem.latest_version.version %>, is not compatible with Rails #{rails_version} 37 | <% end -%> 38 | 39 | <% end -%> 40 | <% if incompatible_gems_by_state[:no_new_version] -%> 41 | <%= Rainbow("=> Incompatible with Rails #{rails_version} (with no new versions):").white.bold %> 42 | <%= Rainbow("These gems will need to be upgraded by us or removed before upgrading to Rails #{rails_version}.").italic %> 43 | <%= Rainbow("This list is likely to contain internal gems, like Cuddlefish.").italic %> 44 | 45 | <% incompatible_gems_by_state[:no_new_version].each do |gem| -%> 46 | <%= gem_header(gem) %> - new version not found 47 | <% end -%> 48 | 49 | <% end -%> 50 | <%= Rainbow(incompatible_gems.length.to_s).red %> gems incompatible with Rails <%= rails_version %> 51 | ERB 52 | 53 | erb_version = ERB.version 54 | if erb_version =~ /erb.rb \[([\d\.]+) .*\]/ 55 | erb_version = $1 56 | end 57 | 58 | if Gem::Version.new(erb_version) < Gem::Version.new("2.2") 59 | ERB.new(template, nil, "-").result(binding) 60 | else 61 | ERB.new(template, trim_mode: "-").result(binding) 62 | end 63 | end 64 | 65 | def gem_header(_gem) 66 | header = Rainbow("#{_gem.name} #{_gem.version}").bold 67 | header << Rainbow(" (loaded from git)").magenta if _gem.sourced_from_git? 68 | header 69 | end 70 | 71 | def incompatible_gems 72 | @incompatible_gems ||= @gems.reject do |gem| 73 | gem.compatible_with_rails?(rails_version: rails_version) || (!include_rails_gems && gem.from_rails?) 74 | end.sort_by { |gem| gem.name } 75 | end 76 | 77 | def rails_version 78 | @options[:rails_version] 79 | end 80 | 81 | def include_rails_gems 82 | @options[:include_rails_gems] 83 | end 84 | end 85 | -------------------------------------------------------------------------------- /CODE_OF_CONDUCT.md: -------------------------------------------------------------------------------- 1 | # Contributor Covenant Code of Conduct 2 | 3 | ## Our Pledge 4 | 5 | In the interest of fostering an open and welcoming environment, we as 6 | contributors and maintainers pledge to make participation in our project and 7 | our community a harassment-free experience for everyone, regardless of age, body 8 | size, disability, ethnicity, sex characteristics, gender identity and expression, 9 | level of experience, education, socio-economic status, nationality, personal 10 | appearance, race, religion, or sexual identity and orientation. 11 | 12 | ## Our Standards 13 | 14 | Examples of behavior that contributes to creating a positive environment 15 | include: 16 | 17 | * Using welcoming and inclusive language 18 | * Being respectful of differing viewpoints and experiences 19 | * Gracefully accepting constructive criticism 20 | * Focusing on what is best for the community 21 | * Showing empathy towards other community members 22 | 23 | Examples of unacceptable behavior by participants include: 24 | 25 | * The use of sexualized language or imagery and unwelcome sexual attention or 26 | advances 27 | * Trolling, insulting/derogatory comments, and personal or political attacks 28 | * Public or private harassment 29 | * Publishing others' private information, such as a physical or electronic 30 | address, without explicit permission 31 | * Other conduct which could reasonably be considered inappropriate in a 32 | professional setting 33 | 34 | ## Our Responsibilities 35 | 36 | Project maintainers are responsible for clarifying the standards of acceptable 37 | behavior and are expected to take appropriate and fair corrective action in 38 | response to any instances of unacceptable behavior. 39 | 40 | Project maintainers have the right and responsibility to remove, edit, or 41 | reject comments, commits, code, wiki edits, issues, and other contributions 42 | that are not aligned to this Code of Conduct, or to ban temporarily or 43 | permanently any contributor for other behaviors that they deem inappropriate, 44 | threatening, offensive, or harmful. 45 | 46 | ## Scope 47 | 48 | This Code of Conduct applies within all project spaces, and it also applies when 49 | an individual is representing the project or its community in public spaces. 50 | Examples of representing a project or community include using an official 51 | project e-mail address, posting via an official social media account, or acting 52 | as an appointed representative at an online or offline event. Representation of 53 | a project may be further defined and clarified by project maintainers. 54 | 55 | ## Enforcement 56 | 57 | Instances of abusive, harassing, or otherwise unacceptable behavior may be 58 | reported by contacting the project team at oss@ombulabs.com. All 59 | complaints will be reviewed and investigated and will result in a response that 60 | is deemed necessary and appropriate to the circumstances. The project team is 61 | obligated to maintain confidentiality with regard to the reporter of an incident. 62 | Further details of specific enforcement policies may be posted separately. 63 | 64 | Project maintainers who do not follow or enforce the Code of Conduct in good 65 | faith may face temporary or permanent repercussions as determined by other 66 | members of the project's leadership. 67 | 68 | ## Attribution 69 | 70 | This Code of Conduct is adapted from the [Contributor Covenant][homepage], version 1.4, 71 | available at https://www.contributor-covenant.org/version/1/4/code-of-conduct.html 72 | 73 | [homepage]: https://www.contributor-covenant.org 74 | 75 | For answers to common questions about this code of conduct, see 76 | https://www.contributor-covenant.org/faq 77 | -------------------------------------------------------------------------------- /spec/next_rails/gem_info_spec.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require "spec_helper" 4 | 5 | require "timecop" 6 | 7 | RSpec.describe NextRails::GemInfo do 8 | let(:release_date) { Time.utc(2019, 7, 6, 0, 0, 0) } 9 | let(:now) { Time.utc(2019, 7, 6, 12, 0, 0) } 10 | let(:spec) do 11 | Gem::Specification.new do |s| 12 | s.date = release_date 13 | s.version = "1.0.0" 14 | end 15 | end 16 | 17 | subject { NextRails::GemInfo.new(spec) } 18 | 19 | describe "#age" do 20 | around do |example| 21 | Timecop.travel(now) do 22 | example.run 23 | end 24 | end 25 | 26 | let(:result) { now.strftime("%b %e, %Y") } 27 | 28 | it "returns a date" do 29 | expect(subject.age).to eq(result) 30 | end 31 | end 32 | 33 | describe "#up_to_date?" do 34 | it "is up to date" do 35 | allow(Gem).to receive(:latest_spec_for).and_return(spec) 36 | expect(subject.up_to_date?).to be_truthy 37 | end 38 | end 39 | 40 | describe "#state" do 41 | let(:mock_gem) { Struct.new(:name, :version, :runtime_dependencies) } 42 | let(:mocked_dependency) { Struct.new(:name, :requirement) } 43 | 44 | it "returns :incompatible if gem specifies a rails dependency but no compatible version is found" do 45 | # set up a mock gem with with a rails dependency that is unsatisfied by the version given 46 | mocked_dependency_requirement = double("requirement") 47 | allow(mocked_dependency_requirement).to receive(:satisfied_by?).and_return(false) 48 | runtime_deps = [mocked_dependency.new("rails", mocked_dependency_requirement)] 49 | incompatible_gem = mock_gem.new('incompatible', '0.0.1', runtime_deps) 50 | 51 | rails_version = "7.0.0" 52 | gem_info = NextRails::GemInfo.new(incompatible_gem) 53 | 54 | expect(gem_info.state(rails_version)).to eq(:incompatible) 55 | end 56 | 57 | it "returns :no_new_version if a gem specifies an unsatisfied rails dependency and no other specs are returned" do 58 | # set up a mock gem with with a rails dependency that is unsatisfied by the version given 59 | mocked_dependency_requirement = double("requirement") 60 | allow(mocked_dependency_requirement).to receive(:satisfied_by?).and_return(false) 61 | runtime_deps = [mocked_dependency.new("rails", mocked_dependency_requirement)] 62 | incompatible_gem = mock_gem.new('incompatible', '0.0.1', runtime_deps) 63 | 64 | # Set up a mock SpecFetcher to return an empty list 65 | fetcher_double = double("spec_fetcher") 66 | allow(fetcher_double).to receive(:available_specs).and_return([[],[]]) 67 | allow(Gem::SpecFetcher).to receive(:new).and_return(fetcher_double) 68 | 69 | rails_version = "7.0.0" 70 | gem_info = NextRails::GemInfo.new(incompatible_gem) 71 | gem_info.find_latest_compatible 72 | 73 | expect(gem_info.state(rails_version)).to eq(:no_new_version) 74 | end 75 | end 76 | 77 | describe "#find_latest_compatible" do 78 | let(:mock_gem) { Struct.new(:name, :version) } 79 | 80 | it "sets latest_compatible_version to NullGem if no specs are found" do 81 | gem = mock_gem.new('gem_name', "0.0.1") 82 | 83 | # Set up a mock SpecFetcher to return an empty list 84 | fetcher_double = double("spec_fetcher") 85 | allow(fetcher_double).to receive(:available_specs).and_return([[],[]]) 86 | allow(Gem::SpecFetcher).to receive(:fetcher).and_return(fetcher_double) 87 | 88 | gem_info = NextRails::GemInfo.new(gem) 89 | gem_info.find_latest_compatible 90 | expect(gem_info.latest_compatible_version).to be_a(NextRails::GemInfo::NullGemInfo) 91 | end 92 | end 93 | end 94 | -------------------------------------------------------------------------------- /lib/next_rails/bundle_report/cli.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require "optparse" 4 | require "next_rails" 5 | require "next_rails/bundle_report" 6 | require "byebug" 7 | 8 | class NextRails::BundleReport::CLI 9 | def initialize(argv) 10 | validate_arguments(argv) 11 | @argv = argv 12 | end 13 | 14 | def validate_arguments(argv) 15 | return unless argv.any? 16 | 17 | valid_report_types = %w[outdated compatibility ruby_check] 18 | report_type = argv.first 19 | 20 | unless valid_report_types.include?(report_type) 21 | raise ArgumentError, "Invalid report type '#{report_type}'. Valid types are: #{valid_report_types.join(', ')}." 22 | end 23 | 24 | argv.each do |arg| 25 | if arg.start_with?("--rails-version") && !/--rails-version=+\d+(\.\d+)*$/.match(arg) 26 | raise ArgumentError, "Invalid Rails version format. Example: --rails-version=5.0.7" 27 | end 28 | 29 | if arg.start_with?("--ruby-version") && !/--ruby-version=+\d+(\.\d+)*$/.match(arg) 30 | raise ArgumentError, "Invalid Ruby version format. Example: --ruby-version=3.3" 31 | end 32 | end 33 | end 34 | 35 | def run 36 | options = parse_options 37 | execute_report(@argv.first, options) 38 | end 39 | 40 | private 41 | 42 | def parse_options 43 | options = {} 44 | option_parser = OptionParser.new do |opts| 45 | opts.banner = <<-EOS 46 | Usage: #{$0} [report-type] [options] 47 | 48 | report-type There are three report types available: `outdated`, `compatibility` and `ruby_check`. 49 | 50 | Examples: 51 | #{$0} compatibility --rails-version 5.0 52 | #{$0} compatibility --ruby-version 3.3 53 | #{$0} outdated 54 | #{$0} outdated --json 55 | 56 | ruby_check To find a compatible ruby version for the target rails version 57 | 58 | Examples: 59 | #{$0} ruby_check --rails-version 7.0.0 60 | 61 | EOS 62 | 63 | opts.separator "" 64 | opts.separator "Options:" 65 | 66 | opts.on("--rails-version [STRING]", 67 | "Rails version to check compatibility against (defaults to 5.0)") do |rails_version| 68 | options[:rails_version] = rails_version 69 | end 70 | 71 | opts.on("--ruby-version [STRING]", 72 | "Ruby version to check compatibility against (defaults to 2.3)") do |ruby_version| 73 | options[:ruby_version] = ruby_version 74 | end 75 | 76 | opts.on("--include-rails-gems", "Include Rails gems in compatibility report (defaults to false)") do 77 | options[:include_rails_gems] = true 78 | end 79 | 80 | opts.on("--json", "Output JSON in outdated report (defaults to false)") do 81 | options[:format] = "json" 82 | end 83 | 84 | opts.on_tail("-h", "--help", "Show this message") do 85 | puts opts 86 | exit 87 | end 88 | end 89 | 90 | begin 91 | option_parser.parse!(@argv) 92 | rescue OptionParser::ParseError => e 93 | warn Rainbow(e.message).red 94 | puts option_parser 95 | exit 1 96 | end 97 | 98 | options 99 | end 100 | 101 | def execute_report(report_type, options) 102 | case report_type 103 | when "ruby_check" 104 | NextRails::BundleReport.compatible_ruby_version(rails_version: options.fetch(:rails_version)) 105 | when "outdated" 106 | NextRails::BundleReport.outdated(options.fetch(:format, nil)) 107 | else 108 | if options[:ruby_version] 109 | NextRails::BundleReport.ruby_compatibility(ruby_version: options.fetch(:ruby_version, "2.3")) 110 | else 111 | NextRails::BundleReport.rails_compatibility( 112 | rails_version: options.fetch(:rails_version, "5.0"), 113 | include_rails_gems: options.fetch(:include_rails_gems, false) 114 | ) 115 | end 116 | end 117 | end 118 | end 119 | -------------------------------------------------------------------------------- /exe/deprecations: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env ruby 2 | require "json" 3 | require "rainbow" 4 | require "optparse" 5 | require "set" 6 | 7 | def run_tests(deprecation_warnings, opts = {}) 8 | tracker_mode = opts[:tracker_mode] 9 | next_mode = opts[:next_mode] 10 | rspec_command = if next_mode 11 | "bin/next rspec" 12 | else 13 | "bundle exec rspec" 14 | end 15 | 16 | command = "DEPRECATION_TRACKER=#{tracker_mode} #{rspec_command} #{deprecation_warnings.keys.join(" ")}" 17 | puts command 18 | exec command 19 | end 20 | 21 | def print_info(deprecation_warnings, opts = {}) 22 | verbose = !!opts[:verbose] 23 | frequency_by_message = deprecation_warnings.each_with_object({}) do |(test_file, messages), hash| 24 | messages.each do |message| 25 | hash[message] ||= { test_files: Set.new, occurrences: 0 } 26 | hash[message][:test_files] << test_file 27 | hash[message][:occurrences] += 1 28 | end 29 | end.sort_by {|message, data| data[:occurrences] }.reverse.to_h 30 | 31 | puts Rainbow("Ten most common deprecation warnings:").underline 32 | frequency_by_message.take(10).each do |message, data| 33 | puts Rainbow("Occurrences: #{data.fetch(:occurrences)}").bold 34 | puts "Test files: #{data.fetch(:test_files).to_a.join(" ")}" if verbose 35 | puts Rainbow(message).red 36 | puts "----------" 37 | end 38 | end 39 | 40 | options = {} 41 | option_parser = OptionParser.new do |opts| 42 | opts.banner = <<-MESSAGE 43 | Usage: #{__FILE__.to_s} [options] [mode] 44 | 45 | Parses the deprecation warning shitlist and show info or run tests. 46 | 47 | Examples: 48 | bin/deprecations info # Show top ten deprecations 49 | bin/deprecations --next info # Show top ten deprecations for Rails 5 50 | bin/deprecations --pattern "ActiveRecord::Base" --verbose info # Show full details on deprecations matching pattern 51 | bin/deprecations --tracker-mode save --pattern "pass" run # Run tests that output deprecations matching pattern and update shitlist 52 | 53 | Modes: 54 | info 55 | Show information on the ten most frequent deprceation warnings. 56 | 57 | run 58 | Run tests that are known to cause deprecation warnings. Use --pattern to filter what tests are run. 59 | 60 | Options: 61 | MESSAGE 62 | 63 | opts.on("--next", "Run against the next shitlist") do |next_mode| 64 | options[:next] = next_mode 65 | end 66 | 67 | opts.on("--tracker-mode MODE", "Set DEPRECATION_TRACKER in test mode. Options: save or compare") do |tracker_mode| 68 | options[:tracker_mode] = tracker_mode 69 | end 70 | 71 | opts.on("--pattern RUBY_REGEX", "Filter deprecation warnings with a pattern.") do |pattern| 72 | options[:pattern] = pattern 73 | end 74 | 75 | opts.on("--verbose", "show more information") do 76 | options[:verbose] = true 77 | end 78 | 79 | opts.on_tail("-h", "--help", "Prints this help") do 80 | puts opts 81 | exit 82 | end 83 | end 84 | 85 | option_parser.parse! 86 | 87 | options[:mode] = ARGV.last 88 | path = options[:next] ? "spec/support/deprecation_warning.next.shitlist.json" : "spec/support/deprecation_warning.shitlist.json" 89 | 90 | pattern_string = options.fetch(:pattern, ".+") 91 | pattern = /#{pattern_string}/ 92 | 93 | deprecation_warnings = JSON.parse(File.read(path)).each_with_object({}) do |(test_file, messages), hash| 94 | filtered_messages = messages.select {|message| message.match(pattern) } 95 | hash[test_file] = filtered_messages if !filtered_messages.empty? 96 | end 97 | 98 | if deprecation_warnings.empty? 99 | abort "No test files with deprecations matching #{pattern.inspect}." 100 | exit 2 101 | end 102 | 103 | case options.fetch(:mode, "info") 104 | when "run" then run_tests(deprecation_warnings, next_mode: options[:next], tracker_mode: options[:tracker_mode]) 105 | when "info" then print_info(deprecation_warnings, verbose: options[:verbose]) 106 | when nil 107 | STDERR.puts Rainbow("Must pass a mode: run or info").red 108 | puts option_parser 109 | exit 1 110 | else 111 | STDERR.puts Rainbow("Unknown mode: #{options[:mode]}").red 112 | exit 1 113 | end 114 | -------------------------------------------------------------------------------- /lib/next_rails/gem_info.rb: -------------------------------------------------------------------------------- 1 | module NextRails 2 | class GemInfo 3 | class NullGemInfo < GemInfo 4 | def initialize; end 5 | 6 | def age 7 | "-" 8 | end 9 | 10 | def created_at 11 | Time.now 12 | end 13 | 14 | def up_to_date? 15 | false 16 | end 17 | 18 | def version 19 | "NOT FOUND" 20 | end 21 | 22 | def unsatisfied_rails_dependencies(*) 23 | ["unknown"] 24 | end 25 | 26 | def state(_) 27 | :null 28 | end 29 | end 30 | 31 | RAILS_GEMS = [ 32 | "rails", 33 | "activemodel", 34 | "activerecord", 35 | "actionmailer", 36 | "actioncable", 37 | "actionpack", 38 | "actionview", 39 | "activejob", 40 | "activestorage", 41 | "activesupport", 42 | "actionmailbox", 43 | "actiontext", 44 | "railties", 45 | ].freeze 46 | 47 | def self.all 48 | Gem::Specification.each.map do |gem_specification| 49 | new(gem_specification) 50 | end 51 | end 52 | 53 | attr_reader :gem_specification, :version, :name, :latest_compatible_version 54 | 55 | def initialize(gem_specification) 56 | @gem_specification = gem_specification 57 | @version = gem_specification.version 58 | @name = gem_specification.name 59 | end 60 | 61 | def age 62 | created_at.strftime("%b %e, %Y") 63 | end 64 | 65 | def sourced_from_git? 66 | !!gem_specification.git_version 67 | end 68 | 69 | def created_at 70 | @created_at ||= gem_specification.date 71 | end 72 | 73 | def up_to_date? 74 | version == latest_version.version 75 | end 76 | 77 | def from_rails? 78 | RAILS_GEMS.include?(name) 79 | end 80 | 81 | def state(rails_version) 82 | if compatible_with_rails?(rails_version: rails_version) 83 | :compatible 84 | elsif latest_compatible_version && latest_compatible_version.version == "NOT FOUND" 85 | :no_new_version 86 | elsif latest_compatible_version 87 | :found_compatible 88 | else 89 | :incompatible 90 | end 91 | end 92 | 93 | def latest_version 94 | latest_gem_specification = Gem.latest_spec_for(name) 95 | return NullGemInfo.new unless latest_gem_specification 96 | 97 | GemInfo.new(latest_gem_specification) 98 | rescue 99 | NullGemInfo.new 100 | end 101 | 102 | def compatible_with_rails?(rails_version: nil) 103 | unsatisfied_rails_dependencies(rails_version: rails_version).empty? 104 | end 105 | 106 | def unsatisfied_rails_dependencies(rails_version: nil) 107 | spec_compatible_with_rails?(specification: gem_specification, rails_version: rails_version) 108 | end 109 | 110 | def find_latest_compatible(rails_version: nil) 111 | dependency = Gem::Dependency.new(@name) 112 | fetcher = Gem::SpecFetcher.fetcher # Use fetcher instead of ::new to reduce object allocation. 113 | 114 | # list all available data for released gems 115 | list, errors = fetcher.available_specs(:released) 116 | 117 | specs = [] 118 | # filter only specs for the current gem and older versions 119 | list.each do |source, gem_tuples| 120 | gem_tuples.each do |gem_tuple| 121 | if gem_tuple.name == @name && gem_tuple.version > @version 122 | specs << source.fetch_spec(gem_tuple) 123 | end 124 | end 125 | end 126 | 127 | # if nothing is found, consider gem incompatible 128 | if specs.empty? 129 | @latest_compatible_version = NullGemInfo.new 130 | return 131 | end 132 | 133 | # if specs are found, look for the first one from that is compatible 134 | # with the desired rails version starting from the end 135 | specs.reverse.each do |spec| 136 | if spec_compatible_with_rails?(specification: spec, rails_version: rails_version).empty? 137 | @latest_compatible_version = spec 138 | break 139 | end 140 | end 141 | end 142 | 143 | def spec_compatible_with_rails?(specification: nil, rails_version: nil) 144 | rails_dependencies = specification.runtime_dependencies.select {|dependency| RAILS_GEMS.include?(dependency.name) } 145 | 146 | rails_dependencies.reject do |rails_dependency| 147 | rails_dependency.requirement.satisfied_by?(Gem::Version.new(rails_version)) 148 | end 149 | end 150 | 151 | def compatible_with_ruby?(ruby_version) 152 | gem_specification.required_ruby_version.satisfied_by?(Gem::Version.new(ruby_version)) 153 | end 154 | end 155 | end 156 | -------------------------------------------------------------------------------- /spec/next_rails/bundle_report_spec.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require "rainbow" 4 | require "spec_helper" 5 | 6 | RSpec.describe NextRails::BundleReport do 7 | describe '.outdated' do 8 | let(:mock_version) { Struct.new(:version, :age) } 9 | let(:mock_gem) { Struct.new(:name, :version, :age, :latest_version, :up_to_date?, :created_at, :sourced_from_git?) } 10 | let(:format_str) { '%b %e, %Y' } 11 | let(:alpha_date) { Date.parse('2022-01-01') } 12 | let(:alpha_age) { alpha_date.strftime(format_str) } 13 | let(:bravo_date) { Date.parse('2022-02-02') } 14 | let(:bravo_age) { bravo_date.strftime(format_str) } 15 | let(:charlie_date) { Date.parse('2022-03-03') } 16 | let(:charlie_age) { charlie_date.strftime(format_str) } 17 | 18 | before do 19 | allow(NextRails::GemInfo).to receive(:all).and_return( 20 | [ 21 | mock_gem.new('alpha', '0.0.1', alpha_age, mock_version.new('0.0.2', bravo_age), false, alpha_date, false), 22 | mock_gem.new('bravo', '0.2.0', bravo_age, mock_version.new('0.2.2', charlie_age), false, bravo_date, true) 23 | ] 24 | ) 25 | end 26 | 27 | context 'when writing human-readable output' do 28 | #subject { described_class.outdated } 29 | 30 | it 'invokes $stdout.puts properly', :aggregate_failures do 31 | allow($stdout) 32 | .to receive(:puts) 33 | .with("#{Rainbow('alpha 0.0.1').bold.white}: released #{alpha_age} (latest version, 0.0.2, released #{bravo_age})\n") 34 | allow($stdout) 35 | .to receive(:puts) 36 | .with("#{Rainbow('bravo 0.2.0').bold.white}: released #{bravo_age} (latest version, 0.2.2, released #{charlie_age})\n") 37 | allow($stdout).to receive(:puts).with('') 38 | allow($stdout).to receive(:puts).with(<<-EO_MULTLINE_STRING) 39 | #{Rainbow('1').yellow} gems are sourced from git 40 | #{Rainbow('2').red} of the 2 gems are out-of-date (100%) 41 | EO_MULTLINE_STRING 42 | end 43 | end 44 | 45 | context 'when writing JSON output' do 46 | it 'JSON is correctly formatted' do 47 | gems = NextRails::GemInfo.all 48 | out_of_date_gems = gems.reject(&:up_to_date?).sort_by(&:created_at) 49 | sourced_from_git = gems.select(&:sourced_from_git?) 50 | 51 | expect(NextRails::BundleReport.build_json(out_of_date_gems, gems.count, sourced_from_git.count)).to eq( 52 | { 53 | outdated_gems: [ 54 | { name: 'alpha', installed_version: '0.0.1', installed_age: alpha_age, latest_version: '0.0.2', 55 | latest_age: bravo_age }, 56 | { name: 'bravo', installed_version: '0.2.0', installed_age: bravo_age, latest_version: '0.2.2', 57 | latest_age: charlie_age } 58 | ], 59 | sourced_from_git_count: sourced_from_git.count, 60 | total_gem_count: gems.count 61 | } 62 | ) 63 | end 64 | end 65 | end 66 | 67 | describe ".rails_compatibility" do 68 | it "returns empty output invalid rails version" do 69 | output = with_captured_stdout do 70 | NextRails::BundleReport.rails_compatibility(rails_version: nil) 71 | end 72 | expect(output).to be_empty 73 | end 74 | end 75 | 76 | describe ".ruby_compatibility" do 77 | it "returns empty output invalid ruby version" do 78 | output = with_captured_stdout do 79 | NextRails::BundleReport.ruby_compatibility(ruby_version: nil) 80 | end 81 | expect(output).to be_empty 82 | end 83 | end 84 | 85 | describe "#compatible_ruby_version" do 86 | context "when rails_version is a valid one" do 87 | it "returns the correct ruby version" do 88 | rails_version = { rails_version: "7.0.0" } 89 | ruby_version = NextRails::BundleReport.compatible_ruby_version(rails_version) 90 | expect(ruby_version).to eq(">= 2.7.0") 91 | end 92 | end 93 | 94 | context "when partial rails_version is passed as argument" do 95 | it "returns the correct ruby version" do 96 | rails_version = { rails_version: "7.0" } 97 | ruby_version = NextRails::BundleReport.compatible_ruby_version(rails_version) 98 | expect(ruby_version).to eq(">= 2.7.0") 99 | end 100 | end 101 | 102 | context "when rails_version is an invalid one" do 103 | it "returns nil for ruby version" do 104 | rails_version = { rails_version: "0.0.0" } 105 | ruby_version = NextRails::BundleReport.compatible_ruby_version(rails_version) 106 | expect(ruby_version).to eq(nil) 107 | end 108 | end 109 | end 110 | end 111 | -------------------------------------------------------------------------------- /lib/next_rails/bundle_report.rb: -------------------------------------------------------------------------------- 1 | require "rainbow" 2 | require "cgi" 3 | require "erb" 4 | require "json" 5 | require "net/http" 6 | 7 | module NextRails 8 | module BundleReport 9 | extend self 10 | 11 | def ruby_compatibility(ruby_version: nil) 12 | return unless ruby_version 13 | 14 | options = { ruby_version: ruby_version } 15 | puts RubyVersionCompatibility.new(options: options).generate 16 | end 17 | 18 | def rails_compatibility(rails_version: nil, include_rails_gems: nil) 19 | return unless rails_version 20 | 21 | options = { rails_version: rails_version, include_rails_gems: include_rails_gems } 22 | puts RailsVersionCompatibility.new(options: options).generate 23 | end 24 | 25 | def compatible_ruby_version(rails_version) 26 | # find all the versions of rails gem 27 | uri = URI('https://rubygems.org/api/v1/versions/rails.json') 28 | res = Net::HTTP.get_response(uri) 29 | all_versions_res = JSON.parse(res.body) 30 | 31 | # push all the versions in an array 32 | all_versions = [] 33 | all_versions_res.each { |rv| all_versions << rv['number'] } 34 | 35 | rv = rails_version[:rails_version] 36 | matched_versions = all_versions.select { |h| h.start_with?(rv) } 37 | 38 | # the list can either have the exact version or the latest version in the series of versions 39 | # you are looking at 40 | # ex: matched_versions = ["6.1.4.2", "6.1.4.1", "6.1.4"] 41 | # if you have passed "6.1.4" and the list has the exact version, it will match and send 42 | # the ruby version for it bu tif you had passed "6.1", then it will look for the 43 | # latest version matching "6.1" which is "6.1.4.2" in this case and will return ruby 44 | # version for it. 45 | exact_version = matched_versions.include?(rv) ? rv : matched_versions[0] 46 | 47 | if exact_version 48 | uri = URI("https://rubygems.org/api/v2/rubygems/rails/versions/#{exact_version}.json") 49 | res = Net::HTTP.get_response(uri) 50 | ruby_version = JSON.parse(res.body)["ruby_version"] 51 | else 52 | ruby_version = nil 53 | end 54 | 55 | 56 | if ruby_version 57 | puts "The required ruby version is #{ruby_version} for matched rails version #{exact_version}" 58 | ruby_version 59 | else 60 | puts "Could not find a compatible ruby version" 61 | end 62 | end 63 | 64 | def outdated(format = nil) 65 | gems = NextRails::GemInfo.all 66 | out_of_date_gems = gems.reject(&:up_to_date?).sort_by(&:created_at) 67 | sourced_from_git = gems.select(&:sourced_from_git?) 68 | 69 | if format == 'json' 70 | output_to_json(out_of_date_gems, gems.count, sourced_from_git.count) 71 | else 72 | output_to_stdout(out_of_date_gems, gems.count, sourced_from_git.count) 73 | end 74 | end 75 | 76 | def output_to_json(out_of_date_gems, total_gem_count, sourced_from_git_count) 77 | obj = build_json(out_of_date_gems, total_gem_count, sourced_from_git_count) 78 | puts JSON.pretty_generate(obj) 79 | end 80 | 81 | def build_json(out_of_date_gems, total_gem_count, sourced_from_git_count) 82 | output = Hash.new { [] } 83 | out_of_date_gems.each do |gem| 84 | output[:outdated_gems] += [ 85 | { 86 | name: gem.name, 87 | installed_version: gem.version, 88 | installed_age: gem.age, 89 | latest_version: gem.latest_version.version, 90 | latest_age: gem.latest_version.age 91 | } 92 | ] 93 | end 94 | 95 | output.merge( 96 | { 97 | sourced_from_git_count: sourced_from_git_count, 98 | total_gem_count: total_gem_count 99 | } 100 | ) 101 | end 102 | 103 | def output_to_stdout(out_of_date_gems, total_gem_count, sourced_from_git_count) 104 | out_of_date_gems.each do |gem| 105 | header = "#{gem.name} #{gem.version}" 106 | 107 | puts <<-MESSAGE 108 | #{Rainbow(header).bold.white}: released #{gem.age} (latest version, #{gem.latest_version.version}, released #{gem.latest_version.age}) 109 | MESSAGE 110 | end 111 | 112 | percentage_out_of_date = ((out_of_date_gems.count / total_gem_count.to_f) * 100).round 113 | footer = <<-MESSAGE 114 | #{Rainbow(sourced_from_git_count.to_s).yellow} gems are sourced from git 115 | #{Rainbow(out_of_date_gems.count.to_s).red} of the #{total_gem_count} gems are out-of-date (#{percentage_out_of_date}%) 116 | MESSAGE 117 | 118 | puts '' 119 | puts footer 120 | end 121 | end 122 | end 123 | -------------------------------------------------------------------------------- /deprecation_tracker.md: -------------------------------------------------------------------------------- 1 | # Deprecation Tracker 2 | 3 | In order to control the deprecation warnings that occur during a test run, we create a shitlist of deprecation warnings that we expect to see for each spec file. If code run by a spec file changes how often or what deprecation warning occurs, an error is raised after the RSpec run is complete. 4 | 5 | It looks something like this: 6 | 7 | ``` 8 | An error occurred in an `after(:suite)` hook. 9 | Failure/Error: raise UnexpectedDeprecations, message 10 | 11 | DeprecationTracker::UnexpectedDeprecations: 12 | ⚠️ Deprecation warnings have changed! 13 | 14 | Code called by the following spec files is now generating different deprecation warnings: 15 | 16 | ./spec/deprecation_spec.rb 17 | 18 | Here is a diff between what is expected and what was generated by this process: 19 | 20 | diff --git a/spec/support/deprecation_tracker.json b/var/folders/mv/x81dlrp92w5053_m9bgqbkmm0000gp/T/test-deprecations20180328-33449-xgor20 21 | index 76d2118f9f2..257be30d46c 100644 22 | --- a/spec/support/deprecation_tracker.json 23 | +++ b/var/folders/mv/x81dlrp92w5053_m9bgqbkmm0000gp/T/test-deprecations20180328-33449-xgor20 24 | @@ -1,7 +1,8 @@ 25 | { 26 | "./spec/deprecation_spec.rb": [ 27 | "DEPRECATION WARNING: `ActiveRecord::Base.symbolized_base_class` is deprecated and will be removed without replacement. (called from block (2 levels) in at /Users/Jordan/projects/themis3/spec/deprecation_spec.rb:5)", 28 | - "DEPRECATION WARNING: `ActiveRecord::Base.symbolized_base_class` is deprecated and will be removed without replacement. (called from block (2 levels) in at /Users/Jordan/projects/themis3/spec/deprecation_spec.rb:6)" 29 | + "DEPRECATION WARNING: `ActiveRecord::Base.symbolized_base_class` is deprecated and will be removed without replacement. (called from block (2 levels) in at /Users/Jordan/projects/themis3/spec/deprecation_spec.rb:6)", 30 | + "DEPRECATION WARNING: `ActiveRecord::Base.symbolized_base_class` is deprecated and will be removed without replacement. (called from block (2 levels) in at /Users/Jordan/projects/themis3/spec/deprecation_spec.rb:7)" 31 | ], 32 | "./spec/models/refund_spec.rb": [ 33 | 34 | # ./spec/support/deprecation_tracker.rb:65:in `compare' 35 | # ./spec/support/deprecation_tracker.rb:29:in `block in track_rspec' 36 | # /Users/Jordan/.rbenv/versions/2.4.3/bin/rspec:23:in `load' 37 | # /Users/Jordan/.rbenv/versions/2.4.3/bin/rspec:23:in `' 38 | # /Users/Jordan/.rbenv/versions/2.4.3/bin/bundle:23:in `load' 39 | # /Users/Jordan/.rbenv/versions/2.4.3/bin/bundle:23:in `
' 40 | 41 | Finished in 1.83 seconds (files took 9.09 seconds to load) 42 | 1 example, 0 failures, 1 error occurred outside of examples 43 | ``` 44 | 45 | When the diff shows a line was removed, it means we expected to see that deprecation message but didn't. 46 | When the diff shows a line was added, it means didn't expect to see that deprecation message. 47 | 48 | > **Protip**: If you find the diff difficult to read in your terminal, copy/paste it into your text editor and set syntax highlighting to "diff" for a colorized view. 49 | 50 | Keeping a list of all deprecation warnings has two primary benefits: 51 | 52 | - We can fail CI when new deprecation warnings are added (i.e., "stop the bleeding") 53 | - We can more easily find and eliminate deprecation warnings 54 | 55 | ## Modes 56 | 57 | The deprecation tracker has three mode: `compare`, `save`, and off. 58 | 59 | - `DEPRECATION_TRACKER=compare rspec foo_spec.rb`: in `compare` mode, changes to the deprecation warnings output by a test file will trigger an error. 60 | - `DEPRECATION_TRACKER=save rspec foo_spec.rb`: in `save` mode, changes to the deprecation warnings output by a test file will update the shitlist. 61 | - `rspec foo_spec.rb`: when turned off, changes to the deprecation warnings output by a test file won't trigger a warning or update the shitlist. 62 | 63 | 64 | ## What does it track? 65 | 66 | This tracks deprecations from Rails and `Kernel#warn`, the latter often used by gems that don't use ActiveSupport. 67 | 68 | It only tracks deprecation warnings that occur during a test and not before or after. This means that some deprecations, for example the ones you might see while the app boots, won't be tracked. 69 | 70 | It also doesn't track constant deprecations (`warning: constant Foo is deprecated`) because those [happen in C code](http://ruby-doc.org/core-2.3.0/Module.html#method-i-deprecate_constant). (If you can think of a way to capture these, do it!) 71 | 72 | ## How do I get my pull request green on CI? 73 | 74 | ### There are _added_ deprecation warnings 75 | 76 | If the diff shows added deprecation warnings, you'll need to go back and change code until those messages don't happen. Under normal circumstances, we shouldn't increase the number of deprecation warnings. 77 | 78 | You can check if your test file has changed deprecation warnings by running this command: 79 | 80 | ```bash 81 | DEPRECATION_TRACKER=compare bundle exec rspec spec/foo_spec.rb 82 | ``` 83 | 84 | In this case, an error will be raised if `spec/foo_spec.rb` triggers deprecation warnings that are different than we expect. 85 | 86 | > An example of when it would be OK to increase the number of deprecation warnings is during a Rails upgrade. We're not introducing code that adds deprecation warnings but rather change libraries so what used to be safe to call is now deprecated. 87 | 88 | ### There are _removed_ deprecation warnings 89 | 90 | If the diff shows removed deprecation warnings, congratulations! Pat yourself on the back and then run this command: 91 | 92 | ```ruby 93 | DEPRECATION_TRACKER=save bundle exec rspec 94 | ``` 95 | 96 | This will rerun the tests that reduces the number of deprecation warnings and save that to the log. 97 | 98 | Be sure to commit your changes, then push and wait for CI to go ✅. 99 | 100 | ### The deprecation warnings are _different_ 101 | 102 | If the diff shows added and removed lines, the deprecation messages we expected to see may have changed. Most deprecation warnings contain the file and line of app code that caused it. If the line number changes, the deprecation warning message will also change. In this case, you can follow the same steps as you would if you removed deprecation warnings to update the stale deprecation messages: 103 | 104 | ```ruby 105 | DEPRECATION_TRACKER=save bundle exec rspec 106 | ``` 107 | 108 | This will update the deprecation warning shitlist. Be sure to commit your changes, then push and wait for CI to go ✅. 109 | 110 | ## Where is the shitlist stored? 111 | 112 | ``` 113 | spec/support/deprecation_warnings.shitlist.json 114 | ``` 115 | 116 | ## How does it work? 117 | 118 | While running tests, we keep track of what test file is being run and record each deprecation warning as it happens. After the test run is complete, we compare the deprecation messages we saw with the deprecation messages we expected to see and raise an error if the two differ. 119 | 120 | See `spec/support/deprecation_tracker.rb` for implementation details. 121 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Next Rails 2 | 3 | [![Continuous Integration](https://github.com/fastruby/next_rails/actions/workflows/main.yml/badge.svg)](https://github.com/fastruby/next_rails/actions/workflows/main.yml) 4 | 5 | This is a toolkit to upgrade your next Rails application. It will help you 6 | set up dual booting, track deprecation warnings, and get a report on outdated 7 | dependencies for any Rails application. 8 | 9 | This project is a fork of [`ten_years_rails`](https://github.com/clio/ten_years_rails) 10 | 11 | ## History 12 | 13 | This gem started as a companion to the "[Ten Years of Rails Upgrades](https://www.youtube.com/watch?v=6aCfc0DkSFo)" 14 | conference talk by Jordan Raine. 15 | 16 | > You'll find various utilities that we use at Clio to help us prepare for and 17 | > complete Rails upgrades. 18 | 19 | > These scripts are still early days and may not work in every environment or app. 20 | 21 | > I wouldn't recommend adding this to your Gemfile long-term. Rather, try out 22 | > the scripts and use them as a point of reference. Feel free to tweak them to 23 | > better fit your environment. 24 | 25 | ## Usage 26 | 27 | ### `bundle_report` 28 | 29 | Learn about your Gemfile and see what needs updating. 30 | 31 | ```bash 32 | # Show all out-of-date gems 33 | bundle_report outdated 34 | 35 | # Show five oldest, out-of-date gems 36 | bundle_report outdated | head -n 5 37 | 38 | # Show all out-of-date gems in machine readable JSON format 39 | bundle_report outdated --json 40 | 41 | # Show gems that don't work with Rails 5.2.0 42 | bundle_report compatibility --rails-version=5.2.0 43 | 44 | # Show gems that don't work with Ruby 3.0 45 | bundle_report compatibility --ruby-version=3.0 46 | 47 | # Find minimum compatible ruby version with Rails 7.0.0 48 | bundle_report ruby_check --rails-version=7.0.0 49 | 50 | # Show the usual help message 51 | bundle_report --help 52 | ``` 53 | 54 | ### Application usage 55 | 56 | Every now and then it will be necessary to add code like this to your 57 | application: 58 | 59 | ```ruby 60 | if NextRails.next? 61 | # Do things "the Rails 7 way" 62 | else 63 | # Do things "the Rails 6.1 way" 64 | end 65 | ``` 66 | 67 | The `NextRails.next?` method will use your environment 68 | (e.g. `ENV['BUNDLE_GEMFILE]`) to determine whether your application is 69 | running with the next set of dependencies or the current set of dependencies. 70 | 71 | This might come in handy if you need to inject 72 | [Ruby or Rails shims](https://www.fastruby.io/blog/rails/upgrades/rails-upgrade-shims.html). 73 | 74 | ### Deprecation tracking 75 | 76 | If you're using RSpec, add this snippet to `rails_helper.rb` or `spec_helper.rb` (whichever loads Rails). 77 | 78 | ```ruby 79 | RSpec.configure do |config| 80 | # Tracker deprecation messages in each file 81 | if ENV["DEPRECATION_TRACKER"] 82 | DeprecationTracker.track_rspec( 83 | config, 84 | shitlist_path: "spec/support/deprecation_warning.shitlist.json", 85 | mode: ENV["DEPRECATION_TRACKER"], 86 | transform_message: -> (message) { message.gsub("#{Rails.root}/", "") } 87 | ) 88 | end 89 | end 90 | ``` 91 | 92 | If using minitest, add this somewhere close to the top of your `test_helper.rb`: 93 | 94 | ```ruby 95 | # Tracker deprecation messages in each file 96 | if ENV["DEPRECATION_TRACKER"] 97 | DeprecationTracker.track_minitest( 98 | shitlist_path: "test/support/deprecation_warning.shitlist.json", 99 | mode: ENV["DEPRECATION_TRACKER"], 100 | transform_message: -> (message) { message.gsub("#{Rails.root}/", "") } 101 | ) 102 | end 103 | ``` 104 | 105 | > Keep in mind this is currently not compatible with the `minitest/parallel_fork` gem! 106 | 107 | Once you have that, you can start using deprecation tracking in your tests: 108 | 109 | ```bash 110 | # Run your tests and save the deprecations to the shitlist 111 | DEPRECATION_TRACKER=save rspec 112 | # Run your tests and raise an error when the deprecations change 113 | DEPRECATION_TRACKER=compare rspec 114 | ``` 115 | 116 | #### `deprecations` command 117 | 118 | Once you have stored your deprecations, you can use `deprecations` to display common warnings, run specs, or update the shitlist file. 119 | 120 | ```bash 121 | deprecations info 122 | deprecations info --pattern "ActiveRecord::Base" 123 | deprecations run 124 | deprecations --help # For more options and examples 125 | ``` 126 | 127 | Right now, the path to the shitlist is hardcoded so make sure you store yours at `spec/support/deprecation_warning.shitlist.json`. 128 | 129 | #### `next_rails` command 130 | 131 | You can use `next_rails` to fetch the version of the gem installed. 132 | 133 | ```bash 134 | next_rails --version 135 | next_rails --help # For more options and examples 136 | ``` 137 | 138 | ### Dual-boot Rails next 139 | 140 | This command helps you dual-boot your application. 141 | 142 | ```bash 143 | next_rails --init # Create Gemfile.next and Gemfile.next.lock 144 | vim Gemfile # Tweak your dependencies conditionally using `next?` 145 | next bundle install # Install new gems 146 | next rails s # Start server using Gemfile.next 147 | ``` 148 | 149 | ## Installation 150 | 151 | Add this line to your application's Gemfile 152 | 153 | > NOTE: If you add this gem to a group, make sure it is the test env group 154 | 155 | ```ruby 156 | gem 'next_rails' 157 | ``` 158 | 159 | And then execute: 160 | 161 | $ bundle 162 | 163 | Or install it yourself as: 164 | 165 | $ gem install next_rails 166 | 167 | ## Setup 168 | 169 | Execute: 170 | 171 | $ next_rails --init 172 | 173 | Init will create a Gemfile.next and an initialized Gemfile.next.lock. 174 | The Gemfile.next.lock is initialized with the contents of your existing 175 | Gemfile.lock lock file. We initialize the Gemfile.next.lock to prevent 176 | major version jumps when running the next version of Rails. 177 | 178 | ## Contributing 179 | 180 | Have a fix for a problem you've been running into or an idea for a new feature you think would be useful? Want to see how you can support `next_rails`? 181 | 182 | Take a look at the [Contributing document](CONTRIBUTING.md) for instructions to set up the repo on your machine! 183 | 184 | ## Releases 185 | 186 | `next_rails` adheres to [semver](https://semver.org). So given a version number MAJOR.MINOR.PATCH, we will increment the: 187 | 188 | 1. MAJOR version when you make incompatible API changes, 189 | 2. MINOR version when you add functionality in a backwards compatible manner, and 190 | 3. PATCH version when you make backwards compatible bug fixes. 191 | 192 | Here are the steps to release a new version: 193 | 194 | 1. Update the `version.rb` file with the proper version number 195 | 2. Update `CHANGELOG.md` to have the right headers 196 | 3. Commit your changes to a `release/v-1-1-0` branch 197 | 4. Push your changes and submit a pull request 198 | 5. Merge your pull request to the `main` branch 199 | 6. Git tag the latest version of the `main` branch (`git tag v1.1.0`) 200 | 7. Push tags to GitHub (`git push --tags`) 201 | 8. Build the gem (`gem build next_rails.gemspec`) 202 | 9. Push the .gem package to Rubygems.org (`gem push next_rails-1.1.0.gem`) 203 | 10. You are all done! 204 | 205 | ## License 206 | 207 | The gem is available as open source under the terms of the [MIT License](https://opensource.org/licenses/MIT). 208 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # main [(unreleased)](https://github.com/fastruby/next_rails/compare/v1.4.6...main) 2 | 3 | - [BUGFIX: example](https://github.com/fastruby/next_rails/pull/) 4 | - [CHORE: Create an entry point for the BundleReport command](https://github.com/fastruby/next_rails/pull/154) 5 | - [CHORE: Bring back support of Ruby 2.3, 2.4 and 2.5](https://github.com/fastruby/next_rails/pull/155) 6 | - [BUGFIX: deprecation_tracker breaking with unknown keywords](https://github.com/fastruby/next_rails/pull/158) 7 | 8 | * Your changes/patches go here. 9 | 10 | # v1.4.6 / 2025-04-15 [(commits)](https://github.com/fastruby/next_rails/compare/v1.4.5...v1.4.6) 11 | 12 | - [BUFIX: Fix compatibilities performance bug](https://github.com/fastruby/next_rails/pull/150) 13 | 14 | # v1.4.5 / 2025-03-07 [(commits)](https://github.com/fastruby/next_rails/compare/v1.4.4...v1.4.5) 15 | 16 | - [Move rails_version compatibility to its own class](https://github.com/fastruby/next_rails/pull/137) 17 | 18 | # v1.4.4 / 2025-02-26 [(commits)](https://github.com/fastruby/next_rails/compare/v1.4.3...v1.4.4) 19 | 20 | - [FEATURE: Update deprecation tracker to support newer Rails versions (7.1+)](https://github.com/fastruby/next_rails/pull/142) 21 | 22 | # v1.4.3 / 2025-02-20 [(commits)](https://github.com/fastruby/next_rails/compare/v1.4.2...v1.4.3) 23 | 24 | - [Add next_rails --init](https://github.com/fastruby/next_rails/pull/139) 25 | - [Add Ruby 3.4 support](https://github.com/fastruby/next_rails/pull/133) 26 | 27 | # v1.4.2 / 2024-10-25 [(commits)](https://github.com/fastruby/next_rails/compare/v1.4.1...v1.4.2) 28 | 29 | - [BUGFIX: Rainbow patch: the methods (bold & white) are not on String](https://github.com/fastruby/next_rails/pull/132) 30 | 31 | # v1.4.1 / 2024-10-22 [(commits)](https://github.com/fastruby/next_rails/compare/v1.4.0...v1.4.1) 32 | 33 | - [BUGFIX: Fix performance regression due to rainbow refinement](https://github.com/fastruby/next_rails/pull/131) 34 | 35 | # v1.4.0 / 2024-09-24 [(commits)](https://github.com/fastruby/next_rails/compare/v1.3.0...v1.4.0) 36 | 37 | - [CHORE: Use next_rails namespace on spec tests.](https://github.com/fastruby/next_rails/pull/117) 38 | - [CHORE: Remove 2.0.0, 2.1, 2.2 Ruby support](https://github.com/fastruby/next_rails/pull/126) 39 | - [CHORE: Update compatibility for Ruby versions to use Rainbow](https://github.com/fastruby/next_rails/pull/125) 40 | - [FEATURE: Support compatibility for Ruby versions](https://github.com/fastruby/next_rails/pull/116) 41 | - [CHORE: Remove GPL licensed dependency Colorize and replace it with Rainbow] 42 | 43 | # v1.3.0 / 2023-06-16 [(commits)](https://github.com/fastruby/next_rails/compare/v1.2.4...v1.3.0) 44 | 45 | - [FEATURE: Add NextRails.next? for application usage (e.g. Rails shims)](https://github.com/fastruby/next_rails/pull/97) 46 | - [BUGFIX: Support ERB versions older than 2.2.0](https://github.com/fastruby/next_rails/pull/100) 47 | 48 | # v1.2.4 / 2023-04-21 [(commits)](https://github.com/fastruby/next_rails/compare/v1.2.3...v1.2.4) 49 | 50 | - [BUGFIX: Update the warn method signature to support for Ruby 3] 51 | 52 | # v1.2.3 / 2023-04-12 [(commits)](https://github.com/fastruby/next_rails/compare/v1.2.2...v1.2.3) 53 | 54 | - [Fix ERB deprecation warning in Ruby 3.1] 55 | 56 | - [Remove Rails gems from compatibility check] 57 | 58 | # v1.2.2 / 2023-03-03 [(commits)](https://github.com/fastruby/next_rails/compare/v1.2.1...v1.2.2) 59 | * [BUGFIX: Fixed `KernelWarnTracker#warn signature to match `Kernel#warn` for ruby 2.5+](https://github.com/fastruby/next_rails/pull/82) 60 | * [CHORE: Added updated templates for bug fixes, feature requests and pull requests](https://github.com/fastruby/next_rails/pull/64) as per [this RFC](https://github.com/fastruby/RFCs/blob/main/2021-10-13-github-templates.md) 61 | * [FEATURE: Turn BundleReport into a module](https://github.com/fastruby/next_rails/pull/63) 62 | 63 | # v1.2.1 / 2022-09-26 [(commits)](https://github.com/fastruby/next_rails/compare/v1.2.0...v1.2.1) 64 | 65 | - [BUGFIX: SimpleCov was not reporting accurately due to a bug in the spec helper code](https://github.com/fastruby/next_rails/pull/66) 66 | 67 | - [FEATURE: Better documentation for contributing and releasing versions of this gem](https://github.com/fastruby/next_rails/pull/53) 68 | 69 | - [BUGFIX: bundle_report outdated was giving an exception due to missing method latest_version](https://github.com/fastruby/next_rails/pull/62) 70 | 71 | - [FEATURE: `bundle_report outdated` outputs in JSON format when passed optional argument](https://github.com/fastruby/next_rails/pull/61) 72 | 73 | # v1.2.0 / 2022-08-12 [(commits)](https://github.com/fastruby/next_rails/compare/v1.1.0...v1.2.0) 74 | 75 | - [FEATURE: Support Ruby versions as old as Ruby 2.0](https://github.com/fastruby/next_rails/pull/54) 76 | 77 | - [FEATURE: Better documentation for contributing and releasing versions of this gem](https://github.com/fastruby/next_rails/pull/53) 78 | 79 | # v1.1.0 / 2022-06-30 [(commits)](https://github.com/fastruby/next_rails/compare/v1.0.5...v1.1.0) 80 | 81 | - [FEATURE: Try to find the latest **compatible** version of a gem if the latest version is not compatible with the desired Rails version when checking compatibility](https://github.com/fastruby/next_rails/pull/49) 82 | 83 | - [FEATURE: Added option --version to get the version of the gem being used](https://github.com/fastruby/next_rails/pull/38) 84 | 85 | - [Added github action workflow](https://github.com/fastruby/next_rails/pull/40) 86 | 87 | - [FEATURE: Add support to use DeprecationTracker with Minitest](Add support to use DeprecationTracker with Minitest) 88 | 89 | - [FEATURE: Add dependabot](https://github.com/fastruby/next_rails/pull/41) 90 | 91 | - [DOCUMENTATION: Update the code of conduct link in PR template](https://github.com/fastruby/next_rails/pull/46) 92 | 93 | - [DOCUMENTATION: Add FEATURE REQUEST and BUG REPORT templates ](https://github.com/fastruby/next_rails/pull/48) 94 | 95 | - [BUGFIX: Make behavior arguments optional](https://github.com/fastruby/next_rails/pull/44) 96 | 97 | - [FEATURE: Command line option to check for recommended ruby version for the desired Rails version](https://github.com/fastruby/next_rails/pull/39) 98 | 99 | # v1.0.5 / 2022-03-29 [(commits)](https://github.com/fastruby/next_rails/compare/v1.0.4...v1.0.5) 100 | 101 | - [FEATURE: Initialize the Gemfile.next.lock to avoid major version jumps when used without an initial Gemfile.next.lock](https://github.com/fastruby/next_rails/pull/25) 102 | - [FEATURE: Drop `actionview` dependency because it is not really used](https://github.com/fastruby/next_rails/pull/26) 103 | - [BUGFIX: If shitlist path does not exist, create it for the user of the gem](https://github.com/fastruby/next_rails/pull/37) 104 | 105 | # v1.0.4 / 2021-04-09 [(commits)](https://github.com/fastruby/next_rails/compare/v1.0.3...v1.0.4) 106 | 107 | - [BUGFIX: Fixes issue with `bundle_report` and `actionview`](https://github.com/fastruby/next_rails/pull/22) 108 | 109 | # v1.0.3 / 2021-04-05 [(commits)](https://github.com/fastruby/next_rails/compare/v1.0.2...v1.0.3) 110 | 111 | - [BUGFIX: Update README.md to better document this `ten_years_rails` fork](https://github.com/fastruby/next_rails/pull/11) 112 | - [BUGFIX: Make ActionView an optional dependency](https://github.com/fastruby/next_rails/pull/6) 113 | 114 | # v1.0.2 / 2020-01-20 115 | 116 | # v1.0.1 / 2019-07-26 117 | 118 | # v1.0.0 / 2019-07-24 119 | 120 | - Official Release 121 | -------------------------------------------------------------------------------- /lib/deprecation_tracker.rb: -------------------------------------------------------------------------------- 1 | require "rainbow" 2 | require "json" 3 | 4 | # A shitlist for deprecation warnings during test runs. It has two modes: "save" and "compare" 5 | # 6 | # DEPRECATION_TRACKER=save 7 | # Record deprecation warnings, grouped by spec file. After the test run, save to a file. 8 | # 9 | # DEPRECATION_TRACKER=compare 10 | # Tracks deprecation warnings, grouped by spec file. After the test run, compare against shitlist of expected 11 | # deprecation warnings. If anything is added or removed, raise an error with a diff of the changes. 12 | # 13 | class DeprecationTracker 14 | UnexpectedDeprecations = Class.new(StandardError) 15 | 16 | module KernelWarnTracker 17 | def self.callbacks 18 | @callbacks ||= [] 19 | end 20 | 21 | def warn(*messages, uplevel: nil, category: nil, **kwargs) 22 | KernelWarnTracker.callbacks.each do |callback| 23 | messages.each { |message| callback.call(message) } 24 | end 25 | 26 | ruby_version = Gem::Version.new(RUBY_VERSION) 27 | 28 | if ruby_version >= Gem::Version.new("3.2.0") 29 | # Kernel#warn supports uplevel, category 30 | super(*messages, uplevel: uplevel, category: category) 31 | elsif ruby_version >= Gem::Version.new("2.5.0") 32 | # Kernel#warn supports only uplevel 33 | super(*messages, uplevel: uplevel) 34 | else 35 | # No keyword args supported 36 | super(*messages) 37 | end 38 | end 39 | end 40 | 41 | module MinitestExtension 42 | def self.new(deprecation_tracker) 43 | @@deprecation_tracker = deprecation_tracker 44 | 45 | Module.new do 46 | def before_setup 47 | test_file_name = method(name).source_location.first.to_s 48 | @@deprecation_tracker.bucket = test_file_name.gsub(Rails.root.to_s, ".") 49 | super 50 | end 51 | 52 | def after_teardown 53 | super 54 | @@deprecation_tracker.bucket = nil 55 | end 56 | end 57 | end 58 | end 59 | 60 | # There are two forms of the `warn` method: one for class Kernel and one for instances of Kernel (i.e., every Object) 61 | if Object.respond_to?(:prepend) 62 | Object.prepend(KernelWarnTracker) 63 | else 64 | Object.extend(KernelWarnTracker) 65 | end 66 | 67 | # Ruby 2.2 and lower doesn't appear to allow overriding of Kernel.warn using `singleton_class.prepend`. 68 | if Gem::Version.new(RUBY_VERSION) >= Gem::Version.new("2.3.0") 69 | Kernel.singleton_class.prepend(KernelWarnTracker) 70 | else 71 | def Kernel.warn(*args, &block) 72 | Object.warn(*args, &block) 73 | end 74 | end 75 | 76 | def self.init_tracker(opts = {}) 77 | shitlist_path = opts[:shitlist_path] 78 | mode = opts[:mode] 79 | transform_message = opts[:transform_message] 80 | deprecation_tracker = DeprecationTracker.new(shitlist_path, transform_message, mode) 81 | # Since Rails 7.1 the preferred way to track deprecations is to use the deprecation trackers via 82 | # `Rails.application.deprecators`. 83 | # We fallback to tracking deprecations via the ActiveSupport singleton object if Rails.application.deprecators is 84 | # not defined for older Rails versions. 85 | if defined?(Rails) && defined?(Rails.application) && defined?(Rails.application.deprecators) 86 | Rails.application.deprecators.each do |deprecator| 87 | deprecator.behavior << -> (message, _callstack = nil, _deprecation_horizon = nil, _gem_name = nil) { 88 | deprecation_tracker.add(message) 89 | } 90 | end 91 | elsif defined?(ActiveSupport) 92 | ActiveSupport::Deprecation.behavior << -> (message, _callstack = nil, _deprecation_horizon = nil, _gem_name = nil) { 93 | deprecation_tracker.add(message) 94 | } 95 | end 96 | KernelWarnTracker.callbacks << -> (message) { deprecation_tracker.add(message) } 97 | 98 | deprecation_tracker 99 | end 100 | 101 | def self.track_rspec(rspec_config, opts = {}) 102 | deprecation_tracker = init_tracker(opts) 103 | 104 | rspec_config.around do |example| 105 | deprecation_tracker.bucket = example.metadata.fetch(:rerun_file_path) 106 | 107 | begin 108 | example.run 109 | ensure 110 | deprecation_tracker.bucket = nil 111 | end 112 | end 113 | 114 | rspec_config.after(:suite) do 115 | deprecation_tracker.after_run 116 | end 117 | end 118 | 119 | def self.track_minitest(opts = {}) 120 | tracker = init_tracker(opts) 121 | 122 | Minitest.after_run do 123 | tracker.after_run 124 | end 125 | 126 | ActiveSupport::TestCase.include(MinitestExtension.new(tracker)) 127 | end 128 | 129 | attr_reader :deprecation_messages, :shitlist_path, :transform_message, :bucket, :mode 130 | 131 | def initialize(shitlist_path, transform_message = nil, mode = :save) 132 | @shitlist_path = shitlist_path 133 | @transform_message = transform_message || -> (message) { message } 134 | @deprecation_messages = {} 135 | @mode = mode.to_sym 136 | end 137 | 138 | def add(message) 139 | return if bucket.nil? 140 | 141 | @deprecation_messages[bucket] << transform_message.(message) 142 | end 143 | 144 | def bucket=(value) 145 | @bucket = value 146 | @deprecation_messages[value] ||= [] unless value.nil? 147 | end 148 | 149 | def after_run 150 | if mode == :save 151 | save 152 | elsif mode == :compare 153 | compare 154 | end 155 | end 156 | 157 | def compare 158 | shitlist = read_shitlist 159 | 160 | changed_buckets = [] 161 | normalized_deprecation_messages.each do |bucket, messages| 162 | if shitlist[bucket] != messages 163 | changed_buckets << bucket 164 | end 165 | end 166 | 167 | if changed_buckets.length > 0 168 | message = <<-MESSAGE 169 | ⚠️ Deprecation warnings have changed! 170 | 171 | Code called by the following spec files is now generating different deprecation warnings: 172 | 173 | #{changed_buckets.join("\n")} 174 | 175 | To check your failures locally, you can run: 176 | 177 | DEPRECATION_TRACKER=compare bundle exec rspec #{changed_buckets.join(" ")} 178 | 179 | Here is a diff between what is expected and what was generated by this process: 180 | 181 | #{diff} 182 | 183 | See \e[4;37mdev-docs/testing/deprecation_tracker.md\e[0;31m for more information. 184 | MESSAGE 185 | 186 | raise UnexpectedDeprecations, Rainbow(message).red 187 | end 188 | end 189 | 190 | def diff 191 | new_shitlist = create_temp_shitlist 192 | `git diff --no-index #{shitlist_path} #{new_shitlist.path}` 193 | ensure 194 | new_shitlist.delete 195 | end 196 | 197 | def save 198 | new_shitlist = create_temp_shitlist 199 | create_if_shitlist_path_does_not_exist 200 | FileUtils.cp(new_shitlist.path, shitlist_path) 201 | ensure 202 | new_shitlist.delete if new_shitlist 203 | end 204 | 205 | def create_if_shitlist_path_does_not_exist 206 | dirname = File.dirname(shitlist_path) 207 | unless File.directory?(dirname) 208 | FileUtils.mkdir_p(dirname) 209 | end 210 | end 211 | 212 | def create_temp_shitlist 213 | temp_file = Tempfile.new("temp-deprecation-tracker-shitlist") 214 | temp_file.write(JSON.pretty_generate(normalized_deprecation_messages)) 215 | temp_file.flush 216 | 217 | temp_file 218 | end 219 | 220 | # Normalize deprecation messages to reduce noise from file output and test files to be tracked with separate test runs 221 | def normalized_deprecation_messages 222 | normalized = read_shitlist.merge(deprecation_messages).each_with_object({}) do |(bucket, messages), hash| 223 | hash[bucket] = messages.sort 224 | end 225 | 226 | # not using `to_h` here to support older ruby versions 227 | {}.tap do |h| 228 | normalized.reject {|_key, value| value.empty? }.sort_by {|key, _value| key }.each do |k ,v| 229 | h[k] = v 230 | end 231 | end 232 | end 233 | 234 | def read_shitlist 235 | return {} unless File.exist?(shitlist_path) 236 | JSON.parse(File.read(shitlist_path)) 237 | rescue JSON::ParserError => e 238 | raise "#{shitlist_path} is not valid JSON: #{e.message}" 239 | end 240 | end 241 | -------------------------------------------------------------------------------- /spec/deprecation_tracker_spec.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require "spec_helper" 4 | 5 | require "date" 6 | require "tempfile" 7 | require_relative "../lib/deprecation_tracker" 8 | 9 | RSpec::Matchers.define_negated_matcher :not_raise_error, :raise_error 10 | 11 | RSpec.describe DeprecationTracker do 12 | let(:shitlist_path) do 13 | shitlist_path = Tempfile.new("tmp").path 14 | FileUtils.rm(shitlist_path) 15 | shitlist_path 16 | end 17 | 18 | describe "#add" do 19 | it "groups messages by bucket" do 20 | subject = DeprecationTracker.new("/tmp/foo.txt") 21 | 22 | subject.bucket = "bucket 1" 23 | subject.add("error 1") 24 | subject.add("error 2") 25 | 26 | subject.bucket = "bucket 2" 27 | subject.add("error 3") 28 | subject.add("error 4") 29 | 30 | expect(subject.deprecation_messages).to eq( 31 | "bucket 1" => ["error 1", "error 2"], 32 | "bucket 2" => ["error 3", "error 4"] 33 | ) 34 | end 35 | 36 | it "ignores messages when bucket null" do 37 | subject = DeprecationTracker.new("/tmp/foo.txt") 38 | 39 | subject.bucket = nil 40 | subject.add("error 1") 41 | subject.add("error 2") 42 | 43 | expect(subject.deprecation_messages).to eq({}) 44 | end 45 | 46 | it "transforms messages before adding them" do 47 | subject = DeprecationTracker.new("/tmp/foo.txt", -> (message) { message + " foo" }) 48 | 49 | subject.bucket = "bucket 1" 50 | subject.add("a") 51 | 52 | expect(subject.deprecation_messages).to eq( 53 | "bucket 1" => ["a foo"] 54 | ) 55 | end 56 | end 57 | 58 | describe "#compare" do 59 | it "ignores buckets that have no messages" do 60 | setup_tracker = DeprecationTracker.new(shitlist_path) 61 | setup_tracker.bucket = "bucket 1" 62 | setup_tracker.add("a") 63 | setup_tracker.bucket = "bucket 2" 64 | setup_tracker.add("a") 65 | setup_tracker.save 66 | 67 | subject = DeprecationTracker.new(shitlist_path) 68 | 69 | subject.bucket = "bucket 2" 70 | subject.add("a") 71 | 72 | expect { subject.compare }.not_to raise_error 73 | end 74 | 75 | it "raises an error when recorded messages are different for a given bucket" do 76 | setup_tracker = DeprecationTracker.new(shitlist_path) 77 | setup_tracker.bucket = "bucket 1" 78 | setup_tracker.add("a") 79 | setup_tracker.save 80 | 81 | subject = DeprecationTracker.new(shitlist_path) 82 | 83 | subject.bucket = "bucket 1" 84 | subject.add("b") 85 | 86 | expect { subject.compare }.to raise_error(DeprecationTracker::UnexpectedDeprecations, /Deprecation warnings have changed/) 87 | end 88 | end 89 | 90 | describe "#save" do 91 | it "saves to disk" do 92 | subject = DeprecationTracker.new(shitlist_path) 93 | 94 | subject.bucket = "bucket 1" 95 | subject.add("b") 96 | subject.add("b") 97 | subject.add("a") 98 | 99 | subject.save 100 | 101 | expected_json = <<-JSON.chomp 102 | { 103 | "bucket 1": [ 104 | "a", 105 | "b", 106 | "b" 107 | ] 108 | } 109 | JSON 110 | expect(File.read(shitlist_path)).to eq(expected_json) 111 | end 112 | 113 | it "creates the directory if shitlist directory does not exist" do 114 | FileUtils.mkdir_p("/tmp/test") 115 | shitlist_path = Tempfile.new("tmp", "/tmp/test").path 116 | FileUtils.rm(shitlist_path) 117 | shitlist_path 118 | subject = DeprecationTracker.new(shitlist_path) 119 | 120 | subject.bucket = "bucket 1" 121 | subject.add("b") 122 | subject.add("b") 123 | subject.add("a") 124 | 125 | subject.save 126 | 127 | expected_json = <<-JSON.chomp 128 | { 129 | "bucket 1": [ 130 | "a", 131 | "b", 132 | "b" 133 | ] 134 | } 135 | JSON 136 | expect(File.read(shitlist_path)).to eq(expected_json) 137 | FileUtils.rm_r "/tmp/test" 138 | end 139 | 140 | it "combines recorded and stored messages" do 141 | setup_tracker = DeprecationTracker.new(shitlist_path) 142 | setup_tracker.bucket = "bucket 1" 143 | setup_tracker.add("a") 144 | setup_tracker.save 145 | 146 | subject = DeprecationTracker.new(shitlist_path) 147 | 148 | subject.bucket = "bucket 2" 149 | subject.add("a") 150 | subject.save 151 | 152 | expected_json = <<-JSON.chomp 153 | { 154 | "bucket 1": [ 155 | "a" 156 | ], 157 | "bucket 2": [ 158 | "a" 159 | ] 160 | } 161 | JSON 162 | expect(File.read(shitlist_path)).to eq(expected_json) 163 | end 164 | 165 | it "overwrites stored messages with recorded messages with the same bucket" do 166 | setup_tracker = DeprecationTracker.new(shitlist_path) 167 | setup_tracker.bucket = "bucket 1" 168 | setup_tracker.add("a") 169 | setup_tracker.save 170 | 171 | subject = DeprecationTracker.new(shitlist_path) 172 | 173 | subject.bucket = "bucket 1" 174 | subject.add("b") 175 | subject.save 176 | 177 | expected_json = <<-JSON.chomp 178 | { 179 | "bucket 1": [ 180 | "b" 181 | ] 182 | } 183 | JSON 184 | expect(File.read(shitlist_path)).to eq(expected_json) 185 | end 186 | 187 | it "sorts by bucket" do 188 | subject = DeprecationTracker.new(shitlist_path) 189 | subject.bucket = "bucket 2" 190 | subject.add("a") 191 | subject.bucket = "bucket 1" 192 | subject.add("a") 193 | subject.save 194 | 195 | expected_json = <<-JSON.chomp 196 | { 197 | "bucket 1": [ 198 | "a" 199 | ], 200 | "bucket 2": [ 201 | "a" 202 | ] 203 | } 204 | JSON 205 | expect(File.read(shitlist_path)).to eq(expected_json) 206 | end 207 | 208 | it "sorts messages" do 209 | subject = DeprecationTracker.new(shitlist_path) 210 | subject.bucket = "bucket 1" 211 | subject.add("b") 212 | subject.add("c") 213 | subject.add("a") 214 | subject.save 215 | 216 | expected_json = <<-JSON.chomp 217 | { 218 | "bucket 1": [ 219 | "a", 220 | "b", 221 | "c" 222 | ] 223 | } 224 | JSON 225 | expect(File.read(shitlist_path)).to eq(expected_json) 226 | end 227 | end 228 | 229 | describe "#after_run" do 230 | let(:shitlist_path) { "some_path" } 231 | 232 | it "calls save if in save mode" do 233 | tracker = DeprecationTracker.new(shitlist_path, nil, :save) 234 | expect(tracker).to receive(:save) 235 | expect(tracker).not_to receive(:compare) 236 | tracker.after_run 237 | end 238 | 239 | it "calls compare if in compare mode" do 240 | tracker = DeprecationTracker.new(shitlist_path, nil, "compare") 241 | expect(tracker).not_to receive(:save) 242 | expect(tracker).to receive(:compare) 243 | tracker.after_run 244 | end 245 | 246 | it "does not save nor compare if mode is invalid" do 247 | tracker = DeprecationTracker.new(shitlist_path, nil, "random_stuff") 248 | expect(tracker).not_to receive(:save) 249 | expect(tracker).not_to receive(:compare) 250 | tracker.after_run 251 | end 252 | end 253 | 254 | describe "#init_tracker" do 255 | it "returns a new instance of DeprecationTracker" do 256 | tracker = DeprecationTracker.init_tracker({mode: "save"}) 257 | expect(tracker).to be_a(DeprecationTracker) 258 | end 259 | 260 | it "subscribes to KernelWarnTracker deprecation events" do 261 | expect do 262 | DeprecationTracker.init_tracker({mode: "save"}) 263 | end.to change(DeprecationTracker::KernelWarnTracker.callbacks, :size).by(1) 264 | end 265 | 266 | context "when Rails.application.deprecation is not defined and ActiveSupport is defined" do 267 | it "sets the ActiveSupport::Deprecation behavior" do 268 | # Stub ActiveSupport::Deprecation with a simple behavior array 269 | stub_const("ActiveSupport::Deprecation", Class.new { 270 | def self.behavior 271 | @behavior ||= [] 272 | end 273 | }) 274 | 275 | expect do 276 | DeprecationTracker.init_tracker({mode: "save"}) 277 | end.to change(ActiveSupport::Deprecation.behavior, :size).by(1) 278 | end 279 | end 280 | 281 | context "when Rails.application.deprecation is defined" do 282 | it "sets the behavior of each Rails.application.deprecators" do 283 | # Stub Rails.application.deprecators with an array of mock deprecators 284 | fake_deprecator_1 = double("Deprecator", behavior: []) 285 | fake_deprecator_2 = double("Deprecator", behavior: []) 286 | stub_const("Rails", Module.new) 287 | allow(Rails).to receive_message_chain(:application, :deprecators).and_return([fake_deprecator_1, fake_deprecator_2]) 288 | 289 | expect do 290 | DeprecationTracker.init_tracker({ mode: "save" }) 291 | end.to change(fake_deprecator_1.behavior, :size).by(1).and change(fake_deprecator_2.behavior, :size).by(1) 292 | end 293 | end 294 | end 295 | 296 | describe DeprecationTracker::KernelWarnTracker do 297 | before { DeprecationTracker::KernelWarnTracker.callbacks.clear } 298 | 299 | it "captures Kernel#warn" do 300 | warn_messages = [] 301 | DeprecationTracker::KernelWarnTracker.callbacks << -> (message) { warn_messages << message } 302 | 303 | expect do 304 | Kernel.warn "oh" 305 | Kernel.warn "no" 306 | end.to output("oh\nno\n").to_stderr 307 | 308 | expect(warn_messages).to eq(["oh", "no"]) 309 | end 310 | 311 | it "captures Kernel.warn" do 312 | warn_messages = [] 313 | DeprecationTracker::KernelWarnTracker.callbacks << -> (message) { warn_messages << message } 314 | 315 | expect do 316 | Kernel.warn "oh" 317 | Kernel.warn "no" 318 | end.to output("oh\nno\n").to_stderr 319 | 320 | expect(warn_messages).to eq(["oh", "no"]) 321 | end 322 | 323 | describe "bug when warning uses the uplevel keyword argument" do 324 | context "given I setup the DeprecationTracker::KernelWarnTracker with a callback that manipulates messages as Strings" do 325 | 326 | DeprecationTracker::KernelWarnTracker.callbacks << -> (message) { message.gsub("Rails.root/", "") } 327 | 328 | context "and given that I call code that emits a warning using the uplevel keyword arg" do 329 | it "throws a MissingMethod Error" do 330 | expect { Kernel.warn("Oh no", uplevel: 1) }.to not_raise_error.and output.to_stderr 331 | end 332 | end 333 | end 334 | end 335 | 336 | describe "bug when warning uses unexpected keyword arguments" do 337 | it "does not raise an error with unknown keyword args like :deprecation, :span, :stack" do 338 | DeprecationTracker::KernelWarnTracker.callbacks << -> (message) { message.to_s } 339 | 340 | expect { 341 | warn("Unknown deprecation warning", deprecation: true, span: 1.2, stack: ["line1", "line2"]) 342 | }.to not_raise_error.and output.to_stderr 343 | end 344 | end 345 | 346 | it "handles known and unknown keyword arguments without raising" do 347 | warnings = [] 348 | DeprecationTracker::KernelWarnTracker.callbacks << ->(msg) { warnings << msg } 349 | 350 | expect { 351 | warn( 352 | "This is a test warning", 353 | uplevel: 1, 354 | category: :deprecated, 355 | deprecation: true, 356 | span: 1.2, 357 | stack: ["line"] 358 | ) 359 | }.to not_raise_error 360 | 361 | expect(warnings).to include("This is a test warning") 362 | end 363 | 364 | end 365 | end 366 | --------------------------------------------------------------------------------