bundler-overrule 0.2.0 → 0.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 211e077f4842c7fe2b966596c15fb5a368855c82c6b5c766e3d716f9cf330c59
4
- data.tar.gz: caa8412b3e12623347571872404eb6e462fa0d7978fda22d5c9c0b0896a0e0a9
3
+ metadata.gz: 0f11226dad5e167fb6701cd49f045369b8e30bce7a3684b6cec799bf57410f24
4
+ data.tar.gz: d554c6fe335658e875f0ec8a0468045b45f51ab815bf27f88ceb94bf8b615b66
5
5
  SHA512:
6
- metadata.gz: 7d157303868882f4a3bccb4d931d9e4451fce5f535137a28ebaf0174788615eb3858db8ea3301dac0e89b461ac0eb0fd714bb37cca9431fe667efd29e7f8b62d
7
- data.tar.gz: 759bf851a997b806005a4afa326ce520adce55fb0f267122ca5c1b5b03f3032e4d7ca20a7513e6f1fad9a752800eeb65f25f79f0a658537da6055e55b1cd59c5
6
+ metadata.gz: 90d88393f5dcc4a9ebbe3ad5731a0d8100f8edf0bcff1a850b366e1e09054bac66e56875751981a0e8a31b1191abed065e7eec4f0ba1015b79e6cfd27ad44eb4
7
+ data.tar.gz: b612931c189d30877225b49c0e041559f4919032f07a76acc22d79e7082ffa0bb9c8091dc7ddee0112ac2776ad6d59ca4a17ea7b6dd4cf1b26f67f9bedeed66a
data/CHANGELOG.md CHANGED
@@ -9,9 +9,35 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
9
9
 
10
10
  ### Planned
11
11
 
12
- - `swap 'old', with: 'new'` — substitute an API-compatible fork published under a different name.
13
12
  - Windows CI.
14
13
 
14
+ ## [0.3.0] - 2026-08-16
15
+
16
+ ### Added
17
+
18
+ - `swap OLD, with: NEW, version: nil, reason: nil` — points every dependency edge at a
19
+ different gem, typically a maintained fork published under another name. Applies to
20
+ transitive edges and to a gem your own Gemfile declares directly.
21
+ - `version:` constrains the substitute. Without it the substitute resolves at `>= 0`.
22
+ This is the only way to pin a swapped-in gem: `force` cannot do it, because `force` only
23
+ rewrites edges that already exist and nothing in the graph names the substitute yet.
24
+ - `swap` rules appear in the install summary, `overrule list`, `overrule doctor` and the
25
+ state file, which now records the substitute's name.
26
+
27
+ ### Behavior notes
28
+
29
+ - The substitute does **not** inherit the original edge's requirement. A fork's version
30
+ numbers are its own lineage, and carrying `~> 2.8` onto a fork that restarted at 1.0
31
+ would be unresolvable for a reason the user never wrote down.
32
+ - Unlike `ban`, `swap` on a directly declared gem is allowed and rewrites the `gem` line —
33
+ it is a substitution, so it applies everywhere.
34
+ - Options that located the *old* gem (`git:`, `path:`, `source:`, `branch:`, `ref:`, …) are
35
+ dropped when substituting; `group:`, `platforms:` and `require:` are kept.
36
+ - Swapping a gem with itself is rejected.
37
+ - `swap` is install-time only. It does **not** alias `require` at runtime — that would mean
38
+ shipping code into production, which this gem does not do. The substitute must ship a
39
+ compatible require path.
40
+
15
41
  ## [0.2.0] - 2026-08-16
16
42
 
17
43
  Initial public release. Ships `force` and `ban` together, because the documented
@@ -44,5 +70,6 @@ quickstart uses both.
44
70
  - An unsupported Bundler aborts before resolution rather than patching internals it does not
45
71
  recognise.
46
72
 
47
- [Unreleased]: https://github.com/TheSoloHacker47/bundler-overrule/compare/v0.2.0...HEAD
73
+ [Unreleased]: https://github.com/TheSoloHacker47/bundler-overrule/compare/v0.3.0...HEAD
74
+ [0.3.0]: https://github.com/TheSoloHacker47/bundler-overrule/releases/tag/v0.3.0
48
75
  [0.2.0]: https://github.com/TheSoloHacker47/bundler-overrule/releases/tag/v0.2.0
data/README.md CHANGED
@@ -114,18 +114,38 @@ Sugar for grouping; same registry underneath.
114
114
  ```ruby
115
115
  overrule do
116
116
  force 'openssl', '>= 3.0'
117
- ban 'httpclient'
117
+ ban 'mimemagic'
118
+ swap 'httpclient', with: 'byroot-httpclient'
118
119
  end
119
120
  ```
120
121
 
121
- ### `swap OLD, with: NEW` *(v0.3 roadmap)*
122
+ ### `swap OLD, with: NEW, version: nil, reason: nil`
122
123
 
123
- Substitute a maintained, API-compatible fork published under a different name:
124
+ Point every edge at a different gem — typically a maintained fork published under another
125
+ name. Works on transitively-pulled gems and on gems your own Gemfile declares.
124
126
 
125
127
  ```ruby
126
- swap 'httpclient', with: 'byroot-httpclient'
128
+ swap 'httpclient', with: 'byroot-httpclient', reason: 'unmaintained since 2016'
129
+ swap 'httpclient', with: 'byroot-httpclient', version: '~> 3.0'
127
130
  ```
128
131
 
132
+ Two things worth knowing:
133
+
134
+ - **The substitute does not inherit the original's version requirement.** A fork's version
135
+ numbers are its own lineage, so carrying `~> 2.8` onto a fork that restarted at 1.0 would
136
+ be unresolvable for a reason you never wrote down. The substitute resolves at `>= 0` unless
137
+ you pass `version:`. That keyword is the only way to pin it — `force` can't, because force
138
+ only rewrites edges that already exist and nothing in your graph names the fork yet.
139
+ - **`swap` does not alias `require`.** This is an install-time tool; nothing is loaded in
140
+ your app at runtime, by design. So the substitute must ship the same require path — that
141
+ is, `require 'httpclient'` must keep working because the fork provides `lib/httpclient.rb`.
142
+ Forks published as drop-in replacements normally do. If yours doesn't, use `bundle add`
143
+ and change your `require` lines instead.
144
+
145
+ Options that located the old gem (`git:`, `path:`, `source:`, `branch:`, `ref:`) are dropped
146
+ when substituting, since they pointed at the gem you just replaced. `group:`, `platforms:`
147
+ and `require:` are kept.
148
+
129
149
  ### Inspecting active rules
130
150
 
131
151
  ```
@@ -180,7 +200,7 @@ message rather than silently mis-resolving. Pin your Bundler version in CI like
180
200
 
181
201
  - [x] v0.1 — `force`, warnings, `overrule list`
182
202
  - [x] v0.2 — `ban`, `overrule doctor`
183
- - [ ] v0.3 — `swap` (gem substitution)
203
+ - [x] v0.3 — `swap` (gem substitution)
184
204
  - [ ] Windows CI
185
205
 
186
206
  ## Prior art & credits
@@ -68,6 +68,10 @@ module Bundler
68
68
 
69
69
  [replace(dependency, rule.requirement),
70
70
  Edge.new(rule, owner, requirement_string(dependency), rule.requirement.to_s)]
71
+ when :swap
72
+ [substitute(dependency, rule.replacement, rule.requirement),
73
+ Edge.new(rule, owner, requirement_string(dependency),
74
+ "#{rule.replacement} #{rule.requirement}")]
71
75
  else
72
76
  [dependency, nil]
73
77
  end
@@ -96,6 +100,26 @@ module Bundler
96
100
  replacement.instance_variable_set(:@requirement, requirement)
97
101
  replacement
98
102
  end
103
+
104
+ # Options on a Gemfile dependency that describe *which gem*, as opposed to
105
+ # how it is used. A swap points at a different gem, so these must not be
106
+ # carried over — `gem 'httpclient', git: '...'` swapped to a fork would
107
+ # otherwise fetch the fork's name from the old gem's git remote.
108
+ LOCATOR_OPTIONS = %w[
109
+ source git github gist bitbucket path branch ref tag glob submodules
110
+ ].freeze
111
+
112
+ # Point a dependency at a different gem entirely, keeping the options that
113
+ # still make sense (groups, platforms, the require path).
114
+ def substitute(dependency, name, requirement)
115
+ unless dependency.is_a?(::Bundler::Dependency)
116
+ return ::Gem::Dependency.new(name, requirement, dependency.type)
117
+ end
118
+
119
+ options = dependency.instance_variable_get(:@options) || {}
120
+ kept = options.reject { |key, _| LOCATOR_OPTIONS.include?(key.to_s) }
121
+ ::Bundler::Dependency.new(name, requirement.as_list, kept)
122
+ end
99
123
  end
100
124
  end
101
125
  end
@@ -20,6 +20,18 @@ module Bundler
20
20
  Overrule.ban(name, reason: reason)
21
21
  end
22
22
 
23
+ # Substitute a different gem for +name+ everywhere it is depended on —
24
+ # typically a maintained fork published under another name.
25
+ #
26
+ # swap 'httpclient', with: 'byroot-httpclient'
27
+ # swap 'httpclient', with: 'byroot-httpclient', version: '~> 3.0'
28
+ #
29
+ # The substitute must ship the same require path; this is an install-time
30
+ # tool and does not alias `require` at runtime.
31
+ def swap(name, with:, version: nil, reason: nil)
32
+ Overrule.swap(name, with: with, version: version, reason: reason)
33
+ end
34
+
23
35
  # Sugar for grouping rules. Same registry underneath.
24
36
  #
25
37
  # overrule do
@@ -76,6 +76,10 @@ module Bundler
76
76
  @rules.select { |r| r.type == :ban }
77
77
  end
78
78
 
79
+ def swaps
80
+ @rules.select { |r| r.type == :swap }
81
+ end
82
+
79
83
  # Rewrite a dependency list and remember what changed.
80
84
  def filter(dependencies, owner: nil)
81
85
  return dependencies if empty?
@@ -15,6 +15,7 @@ module Bundler
15
15
  class Reporter
16
16
  STATE_FILE = "overrule-report.json"
17
17
  BANNER = "[bundler-overrule]"
18
+ RULE_LABELS = { force: "force", ban: "ban ", swap: "swap " }.freeze
18
19
 
19
20
  TABLE_HEADERS = %w[TYPE GEM REQUIREMENT REASON].freeze
20
21
  TABLE_KEYS = %w[type gem requirement reason].freeze
@@ -151,14 +152,20 @@ module Bundler
151
152
  end
152
153
 
153
154
  def rule_line(rule)
154
- label = rule.type == :force ? "force" : "ban "
155
- subject = rule.type == :force ? "#{rule.name} #{rule.requirement}" : rule.name
156
- line = "#{label} #{subject}"
155
+ line = "#{RULE_LABELS.fetch(rule.type, rule.type.to_s)} #{rule_subject(rule)}"
157
156
  line << " #{provenance(rule)}"
158
157
  line << " # #{rule.reason}" if rule.reason
159
158
  line
160
159
  end
161
160
 
161
+ def rule_subject(rule)
162
+ case rule.type
163
+ when :force then "#{rule.name} #{rule.requirement}"
164
+ when :swap then "#{rule.name} → #{rule.replacement}#{" #{rule.requirement}" if rule.pinned?}"
165
+ else rule.name
166
+ end
167
+ end
168
+
162
169
  def provenance(rule)
163
170
  edges = @registry.edges_for(rule)
164
171
  return "(no effect — rule matched nothing)" if edges.empty?
@@ -167,8 +174,11 @@ module Bundler
167
174
  shown = owners.first(2).join(", ")
168
175
  shown += ", +#{owners.size - 2} more" if owners.size > 2
169
176
 
170
- if rule.type == :ban
177
+ case rule.type
178
+ when :ban
171
179
  "(dropped from: #{shown})"
180
+ when :swap
181
+ "(substituted in: #{shown})"
172
182
  else
173
183
  was = edges.map(&:was).uniq.first(2).join(", ")
174
184
  "(was: #{shown} → #{was})"
@@ -178,8 +188,15 @@ module Bundler
178
188
  def rules_from_registry
179
189
  @registry.rules.map do |rule|
180
190
  { "type" => rule.type.to_s, "gem" => rule.name,
181
- "requirement" => (rule.type == :force ? rule.requirement.to_s : ""),
182
- "reason" => rule.reason || "" }
191
+ "requirement" => table_requirement(rule), "reason" => rule.reason || "" }
192
+ end
193
+ end
194
+
195
+ def table_requirement(rule)
196
+ case rule.type
197
+ when :force then rule.requirement.to_s
198
+ when :swap then "→ #{rule.replacement}#{" #{rule.requirement}" if rule.pinned?}"
199
+ else "—"
183
200
  end
184
201
  end
185
202
 
@@ -213,7 +230,8 @@ module Bundler
213
230
 
214
231
  def rule_to_h(rule)
215
232
  { "type" => rule.type.to_s, "gem" => rule.name,
216
- "requirement" => (rule.type == :force ? rule.requirement.to_s : nil),
233
+ "requirement" => (rule.respond_to?(:requirement) ? rule.requirement.to_s : nil),
234
+ "replacement" => (rule.type == :swap ? rule.replacement : nil),
217
235
  "reason" => rule.reason }
218
236
  end
219
237
 
@@ -107,5 +107,59 @@ module Bundler
107
107
  "ban #{name}"
108
108
  end
109
109
  end
110
+
111
+ # `swap 'httpclient', with: 'byroot-httpclient'` — point every edge at a
112
+ # different gem instead.
113
+ #
114
+ # The substitute defaults to `>= 0` rather than inheriting the original
115
+ # edge's requirement: a fork's version numbers are its own lineage, and
116
+ # carrying `~> 2.8` onto a fork that restarted at 1.0 would be
117
+ # unresolvable for reasons the user never wrote down. Pass `version:` to
118
+ # constrain it.
119
+ class Swap < Rule
120
+ DEFAULT_REQUIREMENT = ">= 0"
121
+
122
+ attr_reader :replacement, :requirement
123
+
124
+ def initialize(name, replacement, version: nil, reason: nil)
125
+ @replacement = normalize_replacement(replacement, name)
126
+ @requirement = build_requirement(version || DEFAULT_REQUIREMENT)
127
+ super(name, reason: reason)
128
+ end
129
+
130
+ def type
131
+ :swap
132
+ end
133
+
134
+ def comparable_requirement
135
+ "#{replacement} #{requirement}"
136
+ end
137
+
138
+ # True when the caller pinned the substitute, rather than taking `>= 0`.
139
+ def pinned?
140
+ requirement != ::Gem::Requirement.create(DEFAULT_REQUIREMENT)
141
+ end
142
+
143
+ def to_s
144
+ pinned? ? "swap #{name} with #{replacement} #{requirement}" : "swap #{name} with #{replacement}"
145
+ end
146
+
147
+ private
148
+
149
+ def normalize_replacement(value, original)
150
+ replacement = normalize_name(value)
151
+ if replacement == normalize_name(original)
152
+ raise InvalidRuleError, "swap '#{replacement}' with itself does nothing"
153
+ end
154
+
155
+ replacement
156
+ end
157
+
158
+ def build_requirement(value)
159
+ ::Gem::Requirement.create(value)
160
+ rescue ::Gem::Requirement::BadRequirementError => e
161
+ raise InvalidRuleError, "invalid version requirement #{value.inspect}: #{e.message}"
162
+ end
163
+ end
110
164
  end
111
165
  end
@@ -2,6 +2,6 @@
2
2
 
3
3
  module Bundler
4
4
  module Overrule
5
- VERSION = "0.2.0"
5
+ VERSION = "0.3.0"
6
6
  end
7
7
  end
@@ -55,6 +55,10 @@ module Bundler
55
55
  register(Ban.new(name, reason: reason))
56
56
  end
57
57
 
58
+ def swap(name, with:, version: nil, reason: nil)
59
+ register(Swap.new(name, with, version: version, reason: reason))
60
+ end
61
+
58
62
  def register(rule)
59
63
  rule = registry.add(rule)
60
64
  Patcher.apply!
@@ -64,14 +68,16 @@ module Bundler
64
68
  # Called from the Dsl#to_definition seam with the Gemfile's own
65
69
  # dependency array, which we mutate in place.
66
70
  #
67
- # `force` has to beat a Gemfile pin (B2), and `ban` of a directly
68
- # required gem is a user error, not something to silently honour (B6).
71
+ # `force` has to beat a Gemfile pin (B2) and `swap` has to substitute a
72
+ # directly declared gem (B13), so both run over the Gemfile's own list
73
+ # through the same rewriter every other edge uses. `ban` is the exception:
74
+ # banning a gem you directly require is a user error, not something to
75
+ # silently honour (B6).
69
76
  def rewrite_gemfile_dependencies!(dependencies)
70
77
  return dependencies if registry.empty? || dependencies.nil?
71
78
 
72
79
  reject_banned_direct_dependencies!(dependencies)
73
- apply_forces_to_gemfile!(dependencies)
74
- dependencies
80
+ dependencies.replace(registry.filter(dependencies, owner: "Gemfile"))
75
81
  end
76
82
 
77
83
  # Has the rule set changed since the lockfile was last written? Used to
@@ -140,19 +146,6 @@ module Bundler
140
146
  MSG
141
147
  end
142
148
  end
143
-
144
- def apply_forces_to_gemfile!(dependencies)
145
- registry.forces.each do |rule|
146
- index = dependencies.index { |dep| dep.name == rule.name }
147
- next if index.nil?
148
-
149
- existing = dependencies[index]
150
- next if existing.requirement == rule.requirement
151
-
152
- registry.filter([existing], owner: "Gemfile")
153
- dependencies[index] = DependencyRewriter.replace(existing, rule.requirement)
154
- end
155
- end
156
149
  end
157
150
  end
158
151
  end
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: bundler-overrule
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.2.0
4
+ version: 0.3.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Nikhil Nelson