GemPatch: monkeypatch rubygems responsibly

If you must extend or change a gem’s functionality, it’s a good idea to make sure that your change won’t break with the next update. There isn’t really an automated way to make sure if that, so instead I prefer to crash the app until someone reviews the patch code.

Wouldn’t tests catch this?

I find that relying on private methods can have low-level insidious consequences that are hard to cover it tests. This is why I prefer this extra precaution. Besides, if your idea got upstreamed into the gem, you probably want to remove the patch.

Usage

  1. Copy the below code into your lib/gem_patch.rb (or wherever is appropriate in your case).
  2. In Rails: create a file config/initializers/_gem_patch.rb and require 'gem_patch' there.
  3. Add the following line before or after the monkeypatch:
GemPatch.require 'gem-name-you-are-patching', '1.3.0', details: <<-TEXT
  Explain what to look out for when reviewing this patch.
TEXT

Now you’ve indicated that the code in this file depends on this particular version of the gem. You can also use version constraints like ~> 1.3.0, but you probably don’t want to risk relaxing the constraint if you’re patching something that you shouldn’t.

Any time Ruby encounters the above line, and the loaded gem version is not matching, it will raise error with a helpful explanation that looks like this:

GemPatch::CompatibilityError: The patch specified in /path/to/your/file:1 expected gem gem-name to have version 1.2.3, but it is now 1.2.4. If the patch is obsolete, remove it. Otherwise, update it as necessary, and bump the required gem version.

The code for lib/gem_patch.rb

# GemPatch helps you monkeypatch gems responsibly.
#
# If you monkeypatch a gem, or depend on its private code, `GemPatch.require`
# will crash your app when the gem updates. To fix it, you are instructed to
# review the code and either update the patch, remove it, or confirm that it's
# still compatible by updating the required version.
#
# EXAMPLE:
#
#     GemPatch.require 'gem-name', '~> 2.3.1', details: <<-TEXT
#       This feature should be added sometime in the next versions of gem-name.
#       1. If the feature has been added, remove the patch.
#       2. Otherwise, review to make sure that code is still compatible.
#     TEXT
#
#     class GemName
#       def some_method
#         # Monkeypatch code
#       end
#     end
#
module GemPatch
  module_function

  CompatibilityError = Class.new(RuntimeError)

  def require(name, *expected, details:, actual: Gem.loaded_specs[name].version)
    constraint = Gem::Dependency.new(name, *expected)
    unless constraint.match?(name, actual)
      raise CompatibilityError, "The patch specified in " \
        "#{caller(1..1).first} expected gem `#{name}` to have version " \
        "#{expected.join(", ")}, but it is now #{actual}." + <<~TEXT


          If the patch is obsolete, remove it. Otherwise, update it as necessary,
          and bump the required gem version.

          DETAILS

          #{indent(details.strip_heredoc, 2)}
        TEXT
    end
  end

  def indent(text, amount)
    ' ' * amount + text.split("\n").join("\n" + ' ' * amount)
  end
end

Tests for test/lib/gem_patch_test.rb

require 'test_helper'

class GemPatchTest < ActiveSupport::TestCase
  test '.require raises error when version does not match constraint' do
    exception = assert_raises(GemPatch::CompatibilityError) {
      GemPatch.require 'gem', '~> 0.2.0', actual: '0.1.0', details: <<-TEXT
        Some additional instructions:
        1. Point one
        2. Point two
      TEXT
    }

    assert_includes exception.message, 'gem_patch_test.rb'
    assert_includes exception.message, '0.2.0'
    assert_includes exception.message, '0.1.0'
    assert_includes exception.message, 'additional instructions'
  end

  test '.require does not raise error when version matches the constraint' do
    assert_nothing_raised {
      GemPatch.require 'gem', '~> 0.2.0', actual: '0.2.2', details: <<-TEXT
        Some additional instructions:
        1. Point one
        2. Point two
      TEXT
    }
  end
end

Code snippets in this post are covered by MIT License.



Date
July 11, 2023