diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index e9dc2c8..b838287 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -7,6 +7,7 @@ jobs: runs-on: ubuntu-latest name: Ruby ${{ matrix.ruby }} / ${{ matrix.gemfile }} strategy: + fail-fast: false matrix: gemfile: [gemfiles/activesupport_5.2.gemfile, gemfiles/activesupport_6.0.gemfile, gemfiles/activesupport_6.1.gemfile, gemfiles/activesupport_7.0.gemfile, gemfiles/activesupport_edge.gemfile] ruby: ["2.6", "2.7", "3.0", "3.1"] diff --git a/CHANGELOG.md b/CHANGELOG.md index 12c548c..c812275 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,8 @@ ## main (unreleased) +* [#60](https://github.com/Shopify/deprecation_toolkit/pull/60): Normalize paths in deprecation messages. (@sambostock) + ## 2.0.0 (2022-03-16) * [#58](https://github.com/Shopify/deprecation_toolkit/pull/58): Drop support for Ruby < 2.6 & Active Support < 5.2. (@sambostock) diff --git a/README.md b/README.md index ad09c1d..671007c 100644 --- a/README.md +++ b/README.md @@ -120,6 +120,38 @@ This setting accepts an array of regular expressions. To match on all warnings, DeprecationToolkit::Configuration.warnings_treated_as_deprecation = [//] ``` +### 🔨 `#DeprecationToolkit::Configuration#message_normalizers` + +Deprecation Toolkit allows the normalization of deprecation warnings by registering message normalizers. + +Out-of-the-box, various path normalizers are included, to ensure that any paths included in deprecation warnings are consistent from machine to machine (see [`lib/deprecation_toolkit/configuration.rb`](lib/deprecation_toolkit/configuration.rb) for details). + +### Customizing normalization + +Additional normalizers can be added by appending then to the array: + +```ruby +DeprecationToolkit::Configuration.message_normalizers << ->(msg) { msg.downcase } +``` + +Normalizers are expected to respond to `.call`, accepting a `String` and returning a normalized `String`, and are applied in order of registration. + +If you wish to normalize a custom path, you can create your own `DeprecationToolkit::PathPrefixNormalizer`: + +```ruby +DeprecationToolkit::Configuration.message_normalizers << + DeprecationToolkit::PathPrefixNormalizer.new( + '/path/to/something', + replacement: 'optional replacement string', + ) +``` + +You may optionally disable any or all normalizers by mutating or replacing the array. + +```ruby +DeprecationToolkit::Configuration.message_normalizers = [] # remove all normalizers +``` + ## RSpec By default Deprecation Toolkit uses Minitest as its test runner. To use Deprecation Toolkit with RSpec you'll have to configure it. diff --git a/lib/deprecation_toolkit.rb b/lib/deprecation_toolkit.rb index 13b9317..2f99fcd 100644 --- a/lib/deprecation_toolkit.rb +++ b/lib/deprecation_toolkit.rb @@ -8,6 +8,7 @@ module DeprecationToolkit autoload :DeprecationSubscriber, "deprecation_toolkit/deprecation_subscriber" autoload :Configuration, "deprecation_toolkit/configuration" autoload :Collector, "deprecation_toolkit/collector" + autoload :PathPrefixNormalizer, "deprecation_toolkit/path_prefix_normalizer" autoload :ReadWriteHelper, "deprecation_toolkit/read_write_helper" autoload :TestTriggerer, "deprecation_toolkit/test_triggerer" diff --git a/lib/deprecation_toolkit/configuration.rb b/lib/deprecation_toolkit/configuration.rb index 3814905..2e0ec70 100644 --- a/lib/deprecation_toolkit/configuration.rb +++ b/lib/deprecation_toolkit/configuration.rb @@ -12,5 +12,34 @@ class Configuration config_accessor(:deprecation_path) { "test/deprecations" } config_accessor(:test_runner) { :minitest } config_accessor(:warnings_treated_as_deprecation) { [] } + + config_accessor(:message_normalizers) do + normalizers = [] + + normalizers << PathPrefixNormalizer.new(Rails.root) if defined?(Rails) + normalizers << PathPrefixNormalizer.new(Bundler.root) if defined?(Bundler) + normalizers << PathPrefixNormalizer.new(Dir.pwd) + + if defined?(Gem) + Gem.loaded_specs.each_value do |spec| + normalizers << PathPrefixNormalizer.new(spec.bin_dir, replacement: "") + normalizers << PathPrefixNormalizer.new(spec.extension_dir, replacement: "") + normalizers << PathPrefixNormalizer.new(spec.gem_dir, replacement: "") + end + normalizers << PathPrefixNormalizer.new(*Gem.path, replacement: "") + end + + begin + require "rbconfig" + normalizers << PathPrefixNormalizer.new( + *RbConfig::CONFIG.values_at("prefix", "sitelibdir", "rubylibdir"), + replacement: "", + ) + rescue LoadError + # skip normalizing ruby internal paths + end + + normalizers + end end end diff --git a/lib/deprecation_toolkit/deprecation_subscriber.rb b/lib/deprecation_toolkit/deprecation_subscriber.rb index d46f3b7..40c7592 100644 --- a/lib/deprecation_toolkit/deprecation_subscriber.rb +++ b/lib/deprecation_toolkit/deprecation_subscriber.rb @@ -9,18 +9,24 @@ def self.already_attached? end def deprecation(event) - message = event.payload[:message] + message = normalize_message(event.payload[:message]) - Collector.collect(message) unless deprecation_allowed?(event.payload) + Collector.collect(message) unless deprecation_allowed?(message, event.payload[:callstack]) end private - def deprecation_allowed?(payload) + def deprecation_allowed?(message, callstack) allowed_deprecations, procs = Configuration.allowed_deprecations.partition { |el| el.is_a?(Regexp) } - allowed_deprecations.any? { |regex| regex =~ payload[:message] } || - procs.any? { |proc| proc.call(payload[:message], payload[:callstack]) } + allowed_deprecations.any? { |regex| regex =~ message } || + procs.any? { |proc| proc.call(message, callstack) } + end + + def normalize_message(message) + Configuration + .message_normalizers + .reduce(message) { |message, normalizer| normalizer.call(message) } end end end diff --git a/lib/deprecation_toolkit/path_prefix_normalizer.rb b/lib/deprecation_toolkit/path_prefix_normalizer.rb new file mode 100644 index 0000000..0eb2ebb --- /dev/null +++ b/lib/deprecation_toolkit/path_prefix_normalizer.rb @@ -0,0 +1,41 @@ +# frozen_string_literal: true + +require "pathname" + +module DeprecationToolkit + class PathPrefixNormalizer + attr_reader :path_prefixes, :replacement + + def initialize(*path_prefixes, replacement: "") + @path_prefixes = path_prefixes.compact.map do |path_prefix| + raise ArgumentError, "path prefixes must be absolute: #{path_prefix}" unless Pathname.new(path_prefix).absolute? + + ending_in_separator(path_prefix) + end.sort_by { |path| -path.length } + @replacement = replacement.empty? ? replacement : ending_in_separator(replacement) + end + + def call(message) + message.gsub(pattern, replacement) + end + + def to_s + "s#{pattern}#{replacement}" + end + + def pattern + # Naively anchor to the start of a path. + # The last character of each segment of a path is likely to match /\w/. + # Therefore, if the preceeding character does not match /w/, we're probably not in in the middle of a path. + # e.g. In a containerized environment, we may be given `/app` as a path prefix (perhaps from Rails.root). + # Given the following path: `/app/components/foo/app/models/bar.rb`, + # we should replace the prefix, producing: `components/foo/app/models/bar.rb`, + # without corrupting other occurences: `components/foomodels/bar.rb` + @pattern ||= /(?/rubygems/core_ext/kernel_warn.rb:22) + MESSAGE + end + + test "Rails.root is normalized in deprecation messages" do + rails_stub = Object.new + rails_stub.define_singleton_method(:inspect) { "Rails (stub)" } + rails_stub.define_singleton_method(:root) { "/path/to/rails/root" } + + original_rails = defined?(::Rails) && ::Rails + Object.const_set(:Rails, rails_stub) + + assert_normalizes( + from: "#{Rails.root}/app/models/whatever.rb", + to: "app/models/whatever.rb", + ) + ensure + if original_rails.nil? + Object.send(:remove_const, :Rails) + else + Object.const_set(:Rails, original_rails) + end + end + + test "Bundler.root is normalized in deprecation messages" do + assert_normalizes( + from: "#{Bundler.root}/lib/whatever.rb", + to: "lib/whatever.rb", + ) + end + + test "Gem spec gem_dirs are normalized in deprecation messages" do + spec = Gem.loaded_specs.each_value.first + assert_normalizes( + from: "#{spec.gem_dir}/lib/whatever.rb", + to: "/lib/whatever.rb", + ) + end + + test "Gem spec extension_dirs are normalized in deprecation messages" do + spec = Gem.loaded_specs.each_value.first + assert_normalizes( + from: "#{spec.extension_dir}/lib/whatever.rb", + to: "/lib/whatever.rb", + ) + end + + test "Gem spec bin_dirs are normalized in deprecation messages" do + spec = Gem.loaded_specs.each_value.first + assert_normalizes( + from: "#{spec.bin_dir}/lib/whatever.rb", + to: "/lib/whatever.rb", + ) + end + + test "Gem paths are normalized in deprecation messages" do + paths = Gem.path + puts + puts + pp Configuration.message_normalizers + puts + pp paths # TODO: Remove this (debugging CI) + puts + puts + assert_normalizes( + from: paths.map.with_index { |path, index| "#{path}/file-#{index}" }.join("\n"), + to: Array.new(paths.length) { |index| "/file-#{index}" }.join("\n"), + ) + end + + test "RbConfig paths are normalized in deprecation messages" do + paths = RbConfig::CONFIG.values_at("prefix", "sitelibdir", "rubylibdir").compact + assert_normalizes( + from: paths.map.with_index { |path, index| "#{path}/file-#{index}" }.join("\n"), + to: Array.new(paths.length) { |index| "/file-#{index}" }.join("\n"), + ) + end + + private + + def assert_normalizes(from:, to:) + Configuration.warnings_treated_as_deprecation = [/test deprecation/] + error = assert_raises(Behaviors::DeprecationIntroduced) do + warn("test deprecation: #{from}.") + trigger_deprecation_toolkit_behavior + end + assert_includes(error.message, to) + end end end