bundler-overrule 0.2.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 +7 -0
- data/CHANGELOG.md +48 -0
- data/LICENSE +21 -0
- data/README.md +205 -0
- data/lib/bundler/overrule/command.rb +95 -0
- data/lib/bundler/overrule/dependency_rewriter.rb +101 -0
- data/lib/bundler/overrule/dsl.rb +36 -0
- data/lib/bundler/overrule/errors.rb +47 -0
- data/lib/bundler/overrule/patcher.rb +144 -0
- data/lib/bundler/overrule/registry.rb +115 -0
- data/lib/bundler/overrule/reporter.rb +286 -0
- data/lib/bundler/overrule/rule.rb +111 -0
- data/lib/bundler/overrule/version.rb +7 -0
- data/lib/bundler/overrule.rb +158 -0
- data/lib/bundler-overrule.rb +32 -0
- data/plugins.rb +33 -0
- metadata +65 -0
checksums.yaml
ADDED
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
---
|
|
2
|
+
SHA256:
|
|
3
|
+
metadata.gz: 211e077f4842c7fe2b966596c15fb5a368855c82c6b5c766e3d716f9cf330c59
|
|
4
|
+
data.tar.gz: caa8412b3e12623347571872404eb6e462fa0d7978fda22d5c9c0b0896a0e0a9
|
|
5
|
+
SHA512:
|
|
6
|
+
metadata.gz: 7d157303868882f4a3bccb4d931d9e4451fce5f535137a28ebaf0174788615eb3858db8ea3301dac0e89b461ac0eb0fd714bb37cca9431fe667efd29e7f8b62d
|
|
7
|
+
data.tar.gz: 759bf851a997b806005a4afa326ce520adce55fb0f267122ca5c1b5b03f3032e4d7ca20a7513e6f1fad9a752800eeb65f25f79f0a658537da6055e55b1cd59c5
|
data/CHANGELOG.md
ADDED
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
# Changelog
|
|
2
|
+
|
|
3
|
+
All notable changes to this project are documented here.
|
|
4
|
+
|
|
5
|
+
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
|
|
6
|
+
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
|
7
|
+
|
|
8
|
+
## [Unreleased]
|
|
9
|
+
|
|
10
|
+
### Planned
|
|
11
|
+
|
|
12
|
+
- `swap 'old', with: 'new'` — substitute an API-compatible fork published under a different name.
|
|
13
|
+
- Windows CI.
|
|
14
|
+
|
|
15
|
+
## [0.2.0] - 2026-08-16
|
|
16
|
+
|
|
17
|
+
Initial public release. Ships `force` and `ban` together, because the documented
|
|
18
|
+
quickstart uses both.
|
|
19
|
+
|
|
20
|
+
### Added
|
|
21
|
+
|
|
22
|
+
- `force NAME, REQUIREMENT, reason: nil` — rewrites every dependency edge pointing at `NAME`,
|
|
23
|
+
from other gems' gemspecs and from your own Gemfile.
|
|
24
|
+
- `ban NAME, reason: nil` — removes a gem from resolution entirely.
|
|
25
|
+
- `overrule do ... end` block form for grouping rules.
|
|
26
|
+
- `bundle overrule list` — active rules and the exact constraints each one rewrote.
|
|
27
|
+
- `bundle overrule doctor` — flags stale rules, an unsupported Bundler, and a missing
|
|
28
|
+
bootstrap line. Exits non-zero on problems, so it works as a CI check.
|
|
29
|
+
- A one-line-per-rule summary printed on every `bundle install` / `bundle update`, including
|
|
30
|
+
which gem's constraint was overridden and the `reason:` you gave.
|
|
31
|
+
- `.bundle/overrule-report.json` recording the rules and rewritten edges of the last
|
|
32
|
+
resolution. Nothing overrule-specific is ever written into `Gemfile.lock`.
|
|
33
|
+
|
|
34
|
+
### Behavior notes
|
|
35
|
+
|
|
36
|
+
- With no rules declared, zero patches are applied and resolution is byte-identical to
|
|
37
|
+
vanilla Bundler.
|
|
38
|
+
- Two conflicting rules for one gem is a hard error; an identical re-declaration is not
|
|
39
|
+
(Bundler evaluates the Gemfile more than once per run).
|
|
40
|
+
- Banning a gem the Gemfile requires directly is a hard error.
|
|
41
|
+
- Editing a rule forces a re-resolve even when the Gemfile's own dependencies are unchanged,
|
|
42
|
+
which Bundler would otherwise skip.
|
|
43
|
+
- Development dependencies are never rewritten or dropped.
|
|
44
|
+
- An unsupported Bundler aborts before resolution rather than patching internals it does not
|
|
45
|
+
recognise.
|
|
46
|
+
|
|
47
|
+
[Unreleased]: https://github.com/TheSoloHacker47/bundler-overrule/compare/v0.2.0...HEAD
|
|
48
|
+
[0.2.0]: https://github.com/TheSoloHacker47/bundler-overrule/releases/tag/v0.2.0
|
data/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Nikhil Nelson
|
|
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 all
|
|
13
|
+
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 THE
|
|
21
|
+
SOFTWARE.
|
data/README.md
ADDED
|
@@ -0,0 +1,205 @@
|
|
|
1
|
+
# bundler-overrule
|
|
2
|
+
|
|
3
|
+
> Force, ban, and swap gem versions in your Gemfile — the dependency overrides that Cargo (`[patch]`), npm (`overrides`), and yarn (`resolutions`) have had for years, now for Bundler.
|
|
4
|
+
|
|
5
|
+
[](https://rubygems.org/gems/bundler-overrule)
|
|
6
|
+
[](https://github.com/TheSoloHacker47/bundler-overrule/actions)
|
|
7
|
+
[](LICENSE)
|
|
8
|
+
|
|
9
|
+
## The problem
|
|
10
|
+
|
|
11
|
+
You try to upgrade one gem:
|
|
12
|
+
|
|
13
|
+
```
|
|
14
|
+
$ bundle update openssl
|
|
15
|
+
# ...nothing happens
|
|
16
|
+
```
|
|
17
|
+
|
|
18
|
+
Because somewhere in your Gemfile, `web-push` pins `openssl ~> 2.2`. Upgrading `web-push`
|
|
19
|
+
requires `jwt ~> 3.0`, which conflicts with four other gems pinned to `jwt ~> 2.0` — some of
|
|
20
|
+
which haven't shipped a release in years. Your options today:
|
|
21
|
+
|
|
22
|
+
- fork every blocking gem just to relax a one-line version constraint, and maintain those forks forever, or
|
|
23
|
+
- point your Gemfile at git repos you don't control, or
|
|
24
|
+
- give up.
|
|
25
|
+
|
|
26
|
+
Pessimistic upper bounds (`~>`) are guesses about versions that didn't exist when they were
|
|
27
|
+
written — and they're very often wrong. **Your Gemfile is your domain.** You should get the
|
|
28
|
+
final say.
|
|
29
|
+
|
|
30
|
+
## The fix
|
|
31
|
+
|
|
32
|
+
```ruby
|
|
33
|
+
# Gemfile
|
|
34
|
+
source 'https://rubygems.org'
|
|
35
|
+
|
|
36
|
+
plugin 'bundler-overrule'
|
|
37
|
+
if (paths = Bundler::Plugin.index.load_paths('bundler-overrule'))
|
|
38
|
+
$LOAD_PATH.unshift(*paths)
|
|
39
|
+
require 'bundler-overrule'
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
gem 'rails'
|
|
43
|
+
gem 'web-push'
|
|
44
|
+
|
|
45
|
+
if defined?(Bundler::Overrule)
|
|
46
|
+
force 'openssl', '>= 3.0',
|
|
47
|
+
reason: 'web-push pins ~> 2.2 but works fine with 3.x (verified in CI)'
|
|
48
|
+
|
|
49
|
+
ban 'httpclient',
|
|
50
|
+
reason: 'unmaintained since 2016; vendored root certs expired'
|
|
51
|
+
end
|
|
52
|
+
```
|
|
53
|
+
|
|
54
|
+
```
|
|
55
|
+
$ bundle install
|
|
56
|
+
[bundler-overrule] 2 rules active — YOU own the consequences of these overrides:
|
|
57
|
+
force openssl >= 3.0 (was: web-push → ~> 2.2) # web-push pins ~> 2.2 but works fine with 3.x
|
|
58
|
+
ban httpclient (dropped from: jira-ruby) # unmaintained since 2016; vendored root certs expired
|
|
59
|
+
```
|
|
60
|
+
|
|
61
|
+
Done. No forks. No `github:` pins. One honest, self-documenting line per override.
|
|
62
|
+
|
|
63
|
+
## Installation
|
|
64
|
+
|
|
65
|
+
```
|
|
66
|
+
$ bundle plugin install bundler-overrule
|
|
67
|
+
```
|
|
68
|
+
|
|
69
|
+
Then add the bootstrap block shown above to the top of your Gemfile.
|
|
70
|
+
|
|
71
|
+
Requires **Ruby >= 3.1** and **Bundler >= 2.6**. Full tested matrix in [CI](.github/workflows/ci.yml).
|
|
72
|
+
|
|
73
|
+
### Why the Gemfile needs those extra lines
|
|
74
|
+
|
|
75
|
+
Bundler plugins can't extend the Gemfile DSL on their own, so the `require` line is what makes
|
|
76
|
+
`force` and `ban` available while your Gemfile is being evaluated. Two details matter:
|
|
77
|
+
|
|
78
|
+
- **Guard the `require`.** `Bundler::Plugin.index.load_paths` returns `nil` — not an empty
|
|
79
|
+
array — when the plugin isn't installed. The `if` is what turns "plugin missing" into a
|
|
80
|
+
quiet no-op instead of a crash. Do **not** replace it with a trailing `rescue nil`: that
|
|
81
|
+
swallows real errors too, and you would get zero overrides with zero warning.
|
|
82
|
+
- **Keep the `if defined?` guard.** On the very first `bundle install`, Bundler evaluates your
|
|
83
|
+
whole Gemfile once *before* installing any plugin, just to find the `plugin` lines. During
|
|
84
|
+
that pass `force`/`ban` don't exist yet. The guard skips them; the real evaluation pass a
|
|
85
|
+
moment later has the plugin loaded and applies them.
|
|
86
|
+
|
|
87
|
+
## Usage
|
|
88
|
+
|
|
89
|
+
### `force NAME, REQUIREMENT, reason: nil`
|
|
90
|
+
|
|
91
|
+
Overrides **every** constraint on `NAME` — from other gems' gemspecs *and* from your own
|
|
92
|
+
Gemfile — with `REQUIREMENT`. Bundler resolves as if everyone had asked for your version.
|
|
93
|
+
|
|
94
|
+
```ruby
|
|
95
|
+
force 'nokogiri', '~> 1.18'
|
|
96
|
+
force 'rack', '>= 3.0', reason: 'CVE-2025-XXXXX; sinatra pin is stale'
|
|
97
|
+
```
|
|
98
|
+
|
|
99
|
+
### `ban NAME, reason: nil`
|
|
100
|
+
|
|
101
|
+
Removes `NAME` from dependency resolution entirely. Useful for transitively-pulled gems you
|
|
102
|
+
know your code paths never hit, or abandoned gems you've replaced.
|
|
103
|
+
|
|
104
|
+
```ruby
|
|
105
|
+
ban 'mimemagic', reason: 'we use marcel'
|
|
106
|
+
```
|
|
107
|
+
|
|
108
|
+
Banning a gem your Gemfile requires directly is an error — remove the `gem` line instead.
|
|
109
|
+
|
|
110
|
+
### Block form
|
|
111
|
+
|
|
112
|
+
Sugar for grouping; same registry underneath.
|
|
113
|
+
|
|
114
|
+
```ruby
|
|
115
|
+
overrule do
|
|
116
|
+
force 'openssl', '>= 3.0'
|
|
117
|
+
ban 'httpclient'
|
|
118
|
+
end
|
|
119
|
+
```
|
|
120
|
+
|
|
121
|
+
### `swap OLD, with: NEW` *(v0.3 — roadmap)*
|
|
122
|
+
|
|
123
|
+
Substitute a maintained, API-compatible fork published under a different name:
|
|
124
|
+
|
|
125
|
+
```ruby
|
|
126
|
+
swap 'httpclient', with: 'byroot-httpclient'
|
|
127
|
+
```
|
|
128
|
+
|
|
129
|
+
### Inspecting active rules
|
|
130
|
+
|
|
131
|
+
```
|
|
132
|
+
$ bundle overrule list # every rule + exactly which constraints it rewrote
|
|
133
|
+
$ bundle overrule doctor # stale rules, unsupported Bundler, missing bootstrap line
|
|
134
|
+
```
|
|
135
|
+
|
|
136
|
+
`doctor` exits non-zero when it finds a problem, so it works as a CI check.
|
|
137
|
+
|
|
138
|
+
## How it works
|
|
139
|
+
|
|
140
|
+
`bundler-overrule` is a standard [Bundler plugin](https://bundler.io/guides/bundler_plugins.html).
|
|
141
|
+
When (and only when) you declare a rule, it filters the dependency lists that gem specifications
|
|
142
|
+
report to Bundler's resolver: `force` rewrites matching dependency requirements, `ban` drops
|
|
143
|
+
them. The resolver itself is untouched, and your `Gemfile.lock` is a perfectly ordinary
|
|
144
|
+
lockfile — deployment mode, `--frozen`, and CI caching all work unchanged.
|
|
145
|
+
|
|
146
|
+
With no rules declared, the plugin applies **zero** patches and your resolution is byte-identical
|
|
147
|
+
to vanilla Bundler (we test this — see scenario B7).
|
|
148
|
+
|
|
149
|
+
Each run writes `.bundle/overrule-report.json` recording which edges were rewritten. That file
|
|
150
|
+
is what `list` and `doctor` read; it is safe to delete and safe to gitignore. Nothing
|
|
151
|
+
overrule-specific is ever written into `Gemfile.lock`.
|
|
152
|
+
|
|
153
|
+
## Use responsibly
|
|
154
|
+
|
|
155
|
+
An override is you telling Bundler "I know better than this gem's author." Sometimes you do —
|
|
156
|
+
upper bounds are written before the future exists. But:
|
|
157
|
+
|
|
158
|
+
- **Test the result.** You are opting out of the maintainer's compatibility promise.
|
|
159
|
+
- **Write the `reason:`.** Your future self and your teammates will thank you.
|
|
160
|
+
- **Don't report bugs upstream** for gem combinations their maintainers never declared support
|
|
161
|
+
for — reproduce without the override first.
|
|
162
|
+
- Prefer deleting a rule once upstream relaxes the constraint (`bundle overrule doctor` flags
|
|
163
|
+
rules that no longer have any effect).
|
|
164
|
+
|
|
165
|
+
## Compatibility
|
|
166
|
+
|
|
167
|
+
| | |
|
|
168
|
+
|---|---|
|
|
169
|
+
| Ruby | 3.1, 3.2, 3.3, 3.4 |
|
|
170
|
+
| Bundler | 2.6, 2.7, 4.0 (weekly CI run against latest) |
|
|
171
|
+
| OS | Linux, macOS (Windows: best effort, CI planned) |
|
|
172
|
+
|
|
173
|
+
Bundler 2.4 and 2.5 are not supported: they lack the internals this plugin relies on. There is
|
|
174
|
+
no Bundler 3.x — the line went 2.7 → 4.0.
|
|
175
|
+
|
|
176
|
+
If a new Bundler release moves the internals we patch, the plugin fails loudly with a clear
|
|
177
|
+
message rather than silently mis-resolving. Pin your Bundler version in CI like you already should.
|
|
178
|
+
|
|
179
|
+
## Roadmap
|
|
180
|
+
|
|
181
|
+
- [x] v0.1 — `force`, warnings, `overrule list`
|
|
182
|
+
- [x] v0.2 — `ban`, `overrule doctor`
|
|
183
|
+
- [ ] v0.3 — `swap` (gem substitution)
|
|
184
|
+
- [ ] Windows CI
|
|
185
|
+
|
|
186
|
+
## Prior art & credits
|
|
187
|
+
|
|
188
|
+
- [byroot's "The Missing Bundler Features"](https://byroot.github.io/ruby/bundler/2026/04/20/bundle-features.html) — this gem implements the `force`/ban/substitute semantics proposed there.
|
|
189
|
+
- Longstanding Bundler feature requests: rubygems/bundler#1549 (2011), #2412 (2013), rubygems/rfcs#54.
|
|
190
|
+
- [bundler-override](https://github.com/tarnowsc/bundler-override) and ManageIQ's bundler-inject — earlier takes on the same pain.
|
|
191
|
+
- Cargo `[patch]`, npm `overrides`, pnpm/yarn `resolutions` — proof this belongs in every package manager.
|
|
192
|
+
|
|
193
|
+
## Contributing
|
|
194
|
+
|
|
195
|
+
Bug reports and PRs welcome — see [CONTRIBUTING.md](CONTRIBUTING.md). Quick start:
|
|
196
|
+
|
|
197
|
+
```
|
|
198
|
+
$ git clone https://github.com/TheSoloHacker47/bundler-overrule && cd bundler-overrule
|
|
199
|
+
$ bin/setup
|
|
200
|
+
$ bundle exec rake spec integration
|
|
201
|
+
```
|
|
202
|
+
|
|
203
|
+
## License
|
|
204
|
+
|
|
205
|
+
[MIT](LICENSE).
|
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Bundler
|
|
4
|
+
module Overrule
|
|
5
|
+
# `bundle overrule list` / `bundle overrule doctor`.
|
|
6
|
+
#
|
|
7
|
+
# Bundler hands plugin commands the raw ARGV tail, so option parsing is
|
|
8
|
+
# ours to do. There is very little of it on purpose.
|
|
9
|
+
class Command
|
|
10
|
+
SUBCOMMANDS = %w[list doctor].freeze
|
|
11
|
+
|
|
12
|
+
USAGE = <<~USAGE
|
|
13
|
+
Usage: bundle overrule SUBCOMMAND
|
|
14
|
+
|
|
15
|
+
Subcommands:
|
|
16
|
+
list show every active rule and which dependency edges it rewrote
|
|
17
|
+
doctor check for stale rules, an unsupported Bundler, a missing bootstrap line
|
|
18
|
+
|
|
19
|
+
Options:
|
|
20
|
+
-h, --help show this message
|
|
21
|
+
-v, --version show the bundler-overrule version
|
|
22
|
+
USAGE
|
|
23
|
+
|
|
24
|
+
def initialize(registry: nil, reporter: nil, out: $stdout)
|
|
25
|
+
@registry = registry
|
|
26
|
+
@reporter = reporter
|
|
27
|
+
@out = out
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
# @return [Integer] process exit status
|
|
31
|
+
def call(args)
|
|
32
|
+
subcommand = Array(args).first
|
|
33
|
+
|
|
34
|
+
return help(0) if subcommand.nil? || ["-h", "--help"].include?(subcommand)
|
|
35
|
+
return print_version if ["-v", "--version"].include?(subcommand)
|
|
36
|
+
return unknown_subcommand(subcommand) unless SUBCOMMANDS.include?(subcommand)
|
|
37
|
+
|
|
38
|
+
load_gemfile_rules
|
|
39
|
+
dispatch(subcommand)
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
private
|
|
43
|
+
|
|
44
|
+
def dispatch(subcommand)
|
|
45
|
+
case subcommand
|
|
46
|
+
when "list"
|
|
47
|
+
emit(reporter.list)
|
|
48
|
+
0
|
|
49
|
+
when "doctor"
|
|
50
|
+
emit(reporter.doctor)
|
|
51
|
+
reporter.healthy? ? 0 : 1
|
|
52
|
+
end
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
def emit(lines)
|
|
56
|
+
lines.each { |line| @out.puts(line) }
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
def print_version
|
|
60
|
+
@out.puts "bundler-overrule #{Overrule::VERSION}"
|
|
61
|
+
0
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
def unknown_subcommand(subcommand)
|
|
65
|
+
@out.puts "Unknown subcommand `#{subcommand}`."
|
|
66
|
+
@out.puts
|
|
67
|
+
help(1)
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
def registry
|
|
71
|
+
@registry ||= Overrule.registry
|
|
72
|
+
end
|
|
73
|
+
|
|
74
|
+
def reporter
|
|
75
|
+
@reporter ||= Overrule.reporter
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
def help(status)
|
|
79
|
+
@out.puts USAGE
|
|
80
|
+
status
|
|
81
|
+
end
|
|
82
|
+
|
|
83
|
+
# Evaluating the Gemfile is what registers the rules. Without it `list`
|
|
84
|
+
# could only ever report what the state file remembered.
|
|
85
|
+
def load_gemfile_rules
|
|
86
|
+
Overrule.suppress_summary!
|
|
87
|
+
return if registry.any?
|
|
88
|
+
|
|
89
|
+
::Bundler.definition
|
|
90
|
+
rescue StandardError => e
|
|
91
|
+
@out.puts "[bundler-overrule] could not evaluate the Gemfile: #{e.message}"
|
|
92
|
+
end
|
|
93
|
+
end
|
|
94
|
+
end
|
|
95
|
+
end
|
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Bundler
|
|
4
|
+
module Overrule
|
|
5
|
+
# One dependency edge that a rule actually changed.
|
|
6
|
+
#
|
|
7
|
+
# +owner+ is the gem whose gemspec declared the edge, or "Gemfile" for a
|
|
8
|
+
# direct dependency. +was+ / +now+ are requirement strings; +now+ is nil
|
|
9
|
+
# for a ban, because the edge is gone.
|
|
10
|
+
Edge = Struct.new(:rule, :owner, :was, :now) do
|
|
11
|
+
def banned?
|
|
12
|
+
now.nil?
|
|
13
|
+
end
|
|
14
|
+
|
|
15
|
+
def to_h
|
|
16
|
+
{ "type" => rule.type.to_s, "gem" => rule.name, "owner" => owner,
|
|
17
|
+
"was" => was, "now" => now, "reason" => rule.reason }
|
|
18
|
+
end
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
# The heart of the gem: given a list of Gem::Dependency objects and a set
|
|
22
|
+
# of rules, return the rewritten list plus the edges that changed.
|
|
23
|
+
#
|
|
24
|
+
# Deliberately pure — no Bundler objects, no global state, no I/O — so the
|
|
25
|
+
# behavior contract can be unit-tested without running Bundler at all.
|
|
26
|
+
# Both the resolution seam and the installation seam call this same
|
|
27
|
+
# function, which is what keeps the lockfile and the installed gems from
|
|
28
|
+
# ever disagreeing about what a dependency list contains.
|
|
29
|
+
module DependencyRewriter
|
|
30
|
+
module_function
|
|
31
|
+
|
|
32
|
+
# @param dependencies [Array<Gem::Dependency>]
|
|
33
|
+
# @param rules_by_name [Hash{String => Rule}]
|
|
34
|
+
# @param owner [String, nil] gem name that declared these dependencies
|
|
35
|
+
# @return [Array(Array<Gem::Dependency>, Array<Edge>)]
|
|
36
|
+
def rewrite(dependencies, rules_by_name, owner: nil)
|
|
37
|
+
return [dependencies, []] if rules_by_name.empty? || dependencies.nil? || dependencies.empty?
|
|
38
|
+
|
|
39
|
+
edges = []
|
|
40
|
+
rewritten = []
|
|
41
|
+
|
|
42
|
+
dependencies.each do |dependency|
|
|
43
|
+
rule = rules_by_name[dependency.name]
|
|
44
|
+
replacement, edge = apply_rule(dependency, rule, owner)
|
|
45
|
+
|
|
46
|
+
rewritten << replacement unless replacement.nil?
|
|
47
|
+
edges << edge if edge
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
[rewritten, edges]
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
# Decide what one dependency becomes under one rule.
|
|
54
|
+
#
|
|
55
|
+
# @return [Array(Gem::Dependency, Edge)] the replacement (nil to drop the
|
|
56
|
+
# dependency entirely) and the edge to record (nil if nothing changed)
|
|
57
|
+
def apply_rule(dependency, rule, owner)
|
|
58
|
+
# Development dependencies never reach the resolver, but spec objects
|
|
59
|
+
# still report them. Rewriting or dropping one would be a lie about
|
|
60
|
+
# what the gem declares, for no benefit.
|
|
61
|
+
return [dependency, nil] if rule.nil? || development?(dependency)
|
|
62
|
+
|
|
63
|
+
case rule.type
|
|
64
|
+
when :ban
|
|
65
|
+
[nil, Edge.new(rule, owner, requirement_string(dependency), nil)]
|
|
66
|
+
when :force
|
|
67
|
+
return [dependency, nil] if dependency.requirement == rule.requirement
|
|
68
|
+
|
|
69
|
+
[replace(dependency, rule.requirement),
|
|
70
|
+
Edge.new(rule, owner, requirement_string(dependency), rule.requirement.to_s)]
|
|
71
|
+
else
|
|
72
|
+
[dependency, nil]
|
|
73
|
+
end
|
|
74
|
+
end
|
|
75
|
+
|
|
76
|
+
def development?(dependency)
|
|
77
|
+
dependency.respond_to?(:type) && dependency.type == :development
|
|
78
|
+
end
|
|
79
|
+
|
|
80
|
+
def requirement_string(dependency)
|
|
81
|
+
dependency.requirement.to_s
|
|
82
|
+
end
|
|
83
|
+
|
|
84
|
+
# Swap in a new requirement while keeping everything else about the
|
|
85
|
+
# dependency intact.
|
|
86
|
+
#
|
|
87
|
+
# We copy-and-mutate rather than construct a fresh object on purpose.
|
|
88
|
+
# Bundler::Dependency derives #groups, #source, #git, #path, #github,
|
|
89
|
+
# #branch and #ref lazily from an options hash it keeps privately, and
|
|
90
|
+
# the shape of that hash has changed across Bundler versions. Rebuilding
|
|
91
|
+
# from public readers silently drops whichever of those we forgot — a
|
|
92
|
+
# `force` on a gem declared with `git:` would quietly lose its source.
|
|
93
|
+
# Duplicating preserves the exact class and every option, forever.
|
|
94
|
+
def replace(dependency, requirement)
|
|
95
|
+
replacement = dependency.dup
|
|
96
|
+
replacement.instance_variable_set(:@requirement, requirement)
|
|
97
|
+
replacement
|
|
98
|
+
end
|
|
99
|
+
end
|
|
100
|
+
end
|
|
101
|
+
end
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Bundler
|
|
4
|
+
module Overrule
|
|
5
|
+
# The Gemfile DSL. Prepended into Bundler::Dsl by the bootstrap file, which
|
|
6
|
+
# is why `force` and `ban` are callable at the top level of a Gemfile.
|
|
7
|
+
module Dsl
|
|
8
|
+
# Override every constraint on +name+ — from other gems' gemspecs and
|
|
9
|
+
# from your own Gemfile — with +requirement+.
|
|
10
|
+
#
|
|
11
|
+
# force 'openssl', '>= 3.0', reason: 'web-push pins ~> 2.2; 3.x is fine'
|
|
12
|
+
def force(name, requirement, reason: nil)
|
|
13
|
+
Overrule.force(name, requirement, reason: reason)
|
|
14
|
+
end
|
|
15
|
+
|
|
16
|
+
# Remove +name+ from dependency resolution entirely.
|
|
17
|
+
#
|
|
18
|
+
# ban 'httpclient', reason: 'unmaintained; vendored certs expired'
|
|
19
|
+
def ban(name, reason: nil)
|
|
20
|
+
Overrule.ban(name, reason: reason)
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
# Sugar for grouping rules. Same registry underneath.
|
|
24
|
+
#
|
|
25
|
+
# overrule do
|
|
26
|
+
# force 'openssl', '>= 3.0'
|
|
27
|
+
# ban 'httpclient'
|
|
28
|
+
# end
|
|
29
|
+
def overrule(&block)
|
|
30
|
+
raise ArgumentError, "overrule requires a block" unless block
|
|
31
|
+
|
|
32
|
+
instance_eval(&block)
|
|
33
|
+
end
|
|
34
|
+
end
|
|
35
|
+
end
|
|
36
|
+
end
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "bundler"
|
|
4
|
+
|
|
5
|
+
module Bundler
|
|
6
|
+
module Overrule
|
|
7
|
+
# Base class for every error this plugin raises.
|
|
8
|
+
#
|
|
9
|
+
# Inherits Bundler::BundlerError so Bundler's friendly-error handler prints
|
|
10
|
+
# the message on its own, without a Ruby backtrace — these are all user
|
|
11
|
+
# mistakes in a Gemfile, not crashes.
|
|
12
|
+
class Error < ::Bundler::BundlerError
|
|
13
|
+
def status_code
|
|
14
|
+
59
|
|
15
|
+
end
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
# Raised when a rule is malformed: a bad requirement string, a blank gem
|
|
19
|
+
# name, an unusable +reason:+.
|
|
20
|
+
class InvalidRuleError < Error; end
|
|
21
|
+
|
|
22
|
+
# Raised when two conflicting rules target the same gem (B4).
|
|
23
|
+
class DuplicateRuleError < Error; end
|
|
24
|
+
|
|
25
|
+
# Raised when a `ban` targets a gem the Gemfile requires directly (B6).
|
|
26
|
+
class BannedDirectDependencyError < Error; end
|
|
27
|
+
|
|
28
|
+
# Raised when the running Bundler does not expose the internals we patch.
|
|
29
|
+
# We would rather abort loudly than silently mis-resolve (G5, B9).
|
|
30
|
+
class UnsupportedBundlerError < Error
|
|
31
|
+
def initialize(detail)
|
|
32
|
+
super(<<~MSG)
|
|
33
|
+
bundler-overrule does not support Bundler #{::Bundler::VERSION}.
|
|
34
|
+
|
|
35
|
+
#{detail}
|
|
36
|
+
|
|
37
|
+
Supported: Bundler #{Overrule::SUPPORTED_BUNDLER_DESCRIPTION}.
|
|
38
|
+
|
|
39
|
+
This is a deliberate hard stop. bundler-overrule rewrites dependency
|
|
40
|
+
edges by patching Bundler internals; if those internals have moved,
|
|
41
|
+
continuing could silently resolve the wrong versions. Pin your Bundler
|
|
42
|
+
version, or upgrade bundler-overrule if a newer release supports this one.
|
|
43
|
+
MSG
|
|
44
|
+
end
|
|
45
|
+
end
|
|
46
|
+
end
|
|
47
|
+
end
|