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 +4 -4
- data/CHANGELOG.md +29 -2
- data/README.md +25 -5
- data/lib/bundler/overrule/dependency_rewriter.rb +24 -0
- data/lib/bundler/overrule/dsl.rb +12 -0
- data/lib/bundler/overrule/registry.rb +4 -0
- data/lib/bundler/overrule/reporter.rb +25 -7
- data/lib/bundler/overrule/rule.rb +54 -0
- data/lib/bundler/overrule/version.rb +1 -1
- data/lib/bundler/overrule.rb +10 -17
- metadata +1 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 0f11226dad5e167fb6701cd49f045369b8e30bce7a3684b6cec799bf57410f24
|
|
4
|
+
data.tar.gz: d554c6fe335658e875f0ec8a0468045b45f51ab815bf27f88ceb94bf8b615b66
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
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.
|
|
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 '
|
|
117
|
+
ban 'mimemagic'
|
|
118
|
+
swap 'httpclient', with: 'byroot-httpclient'
|
|
118
119
|
end
|
|
119
120
|
```
|
|
120
121
|
|
|
121
|
-
### `swap OLD, with: NEW
|
|
122
|
+
### `swap OLD, with: NEW, version: nil, reason: nil`
|
|
122
123
|
|
|
123
|
-
|
|
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
|
-
- [
|
|
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
|
data/lib/bundler/overrule/dsl.rb
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
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
|
|
182
|
-
|
|
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.
|
|
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
|
data/lib/bundler/overrule.rb
CHANGED
|
@@ -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)
|
|
68
|
-
#
|
|
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
|
-
|
|
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
|