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.
@@ -0,0 +1,144 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "errors"
4
+
5
+ module Bundler
6
+ module Overrule
7
+ # Applies the monkey-patches, and refuses to run if Bundler's internals
8
+ # have moved somewhere we do not recognise.
9
+ #
10
+ # Two seams carry the whole feature:
11
+ #
12
+ # 1. Bundler::Dsl#to_definition — the Gemfile's own dependency list.
13
+ # This is what makes `force` beat a `gem 'x', '1.5'` line (B2) and
14
+ # what keeps the lockfile's DEPENDENCIES section honest.
15
+ #
16
+ # 2. #dependencies / #runtime_dependencies on the spec classes — every
17
+ # transitive edge. BOTH readers matter: `runtime_dependencies` feeds
18
+ # resolution (via MatchMetadata#expanded_dependencies) and
19
+ # SpecSet#validate_deps, while `dependencies` feeds
20
+ # ParallelInstaller::SpecInstallation#all_dependencies. Filtering only
21
+ # the former resolves correctly and then deadlocks the installer,
22
+ # which sits waiting for a banned gem that will never arrive.
23
+ #
24
+ # Patching the resolver itself (Resolver#to_dependency_hash) also works for
25
+ # resolution and is tempting as a single choke point, but it does not cover
26
+ # installation, so it buys nothing over seam 2 and adds a patch point.
27
+ module Patcher
28
+ # Spec classes whose dependency readers feed resolution and installation.
29
+ SPEC_CLASSES = %w[
30
+ Gem::Specification
31
+ Bundler::RemoteSpecification
32
+ Bundler::EndpointSpecification
33
+ Bundler::StubSpecification
34
+ Bundler::LazySpecification
35
+ ].freeze
36
+
37
+ class << self
38
+ def applied?
39
+ @applied ||= false
40
+ end
41
+
42
+ # Idempotent: Bundler 4.0 may re-load plugins.rb during a single run,
43
+ # and the Gemfile itself is evaluated more than once.
44
+ def apply!
45
+ return if applied?
46
+
47
+ verify_supported!
48
+ patch_dsl!
49
+ patch_spec_classes!
50
+ patch_resolve_short_circuit!
51
+ @applied = true
52
+ end
53
+
54
+ # Test hook. Prepended modules cannot be un-prepended; this only resets
55
+ # the "have we done it" flag, which is all the specs need.
56
+ def reset!
57
+ @applied = false
58
+ end
59
+
60
+ def verify_supported!
61
+ unless ::Gem::Requirement.create(SUPPORTED_BUNDLER).satisfied_by?(::Gem::Version.new(::Bundler::VERSION))
62
+ raise UnsupportedBundlerError, "Bundler #{::Bundler::VERSION} is outside the tested range."
63
+ end
64
+
65
+ missing = SPEC_CLASSES.reject { |name| resolve_constant(name) }
66
+ unless missing.empty?
67
+ raise UnsupportedBundlerError,
68
+ "Expected these classes to exist, but they do not: #{missing.join(", ")}."
69
+ end
70
+
71
+ return if ::Bundler::Dsl.method_defined?(:to_definition)
72
+
73
+ raise UnsupportedBundlerError, "Bundler::Dsl#to_definition is missing."
74
+ end
75
+
76
+ private
77
+
78
+ def patch_dsl!
79
+ prepend_once(::Bundler::Dsl, GemfileDependencies)
80
+ end
81
+
82
+ def patch_spec_classes!
83
+ SPEC_CLASSES.each do |name|
84
+ klass = resolve_constant(name)
85
+ prepend_once(klass, SpecDependencies)
86
+ end
87
+ end
88
+
89
+ # Bundler skips resolution entirely when the lockfile looks current
90
+ # (Definition#no_resolve_needed?). A rule added or edited since the last
91
+ # run would then silently do nothing. Force a re-resolve when the rule
92
+ # set has changed since the lockfile was written.
93
+ def patch_resolve_short_circuit!
94
+ return unless ::Bundler::Definition.method_defined?(:no_resolve_needed?)
95
+
96
+ prepend_once(::Bundler::Definition, ResolveFreshness)
97
+ end
98
+
99
+ def prepend_once(klass, mod)
100
+ klass.prepend(mod) unless klass.ancestors.include?(mod)
101
+ end
102
+
103
+ def resolve_constant(name)
104
+ name.split("::").inject(Object) { |scope, part| scope.const_get(part, false) }
105
+ rescue NameError
106
+ nil
107
+ end
108
+ end
109
+
110
+ # Seam 1 — the Gemfile's own dependencies.
111
+ module GemfileDependencies
112
+ def to_definition(*args, **kwargs)
113
+ # Bundler evaluates the whole Gemfile once through Bundler::Plugin::DSL
114
+ # before any plugin is installed, purely to find `plugin` lines. That
115
+ # pass resolves nothing, so it must not count as a real run — otherwise
116
+ # every rule looks like it "matched nothing".
117
+ Overrule.note_definition_built! unless Overrule.plugin_prepass?(self)
118
+ Overrule.rewrite_gemfile_dependencies!(@dependencies)
119
+ super
120
+ end
121
+ end
122
+
123
+ # Seam 2 — every transitive edge, at both resolution and install time.
124
+ module SpecDependencies
125
+ def dependencies
126
+ Overrule.registry.filter(super, owner: name)
127
+ end
128
+
129
+ def runtime_dependencies
130
+ Overrule.registry.filter(super, owner: name)
131
+ end
132
+ end
133
+
134
+ # See Patcher.patch_resolve_short_circuit!
135
+ module ResolveFreshness
136
+ def no_resolve_needed?
137
+ return false if Overrule.rules_changed?
138
+
139
+ super
140
+ end
141
+ end
142
+ end
143
+ end
144
+ end
@@ -0,0 +1,115 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "digest"
4
+
5
+ require_relative "errors"
6
+ require_relative "rule"
7
+ require_relative "dependency_rewriter"
8
+
9
+ module Bundler
10
+ module Overrule
11
+ # Holds the rules declared in the Gemfile and the edges they rewrote.
12
+ #
13
+ # There is one of these per process (see Overrule.registry). It is not
14
+ # thread-safe by design: rules are declared during single-threaded Gemfile
15
+ # evaluation. Edge *recording* does happen from the parallel installer, so
16
+ # that path appends to a plain array and de-duplicates on read rather than
17
+ # locking on every dependency lookup.
18
+ class Registry
19
+ attr_reader :rules
20
+
21
+ def initialize
22
+ clear!
23
+ end
24
+
25
+ def clear!
26
+ @rules = []
27
+ @rules_by_name = {}
28
+ @edges = []
29
+ self
30
+ end
31
+
32
+ def empty?
33
+ @rules.empty?
34
+ end
35
+
36
+ def any?
37
+ !empty?
38
+ end
39
+
40
+ # Fast lookup used on the hot path (once per dependency, per spec).
41
+ attr_reader :rules_by_name
42
+
43
+ def add(rule)
44
+ existing = @rules_by_name[rule.name]
45
+
46
+ if existing
47
+ # Bundler evaluates the Gemfile more than once per invocation, so an
48
+ # identical re-declaration is expected and must be a no-op. Only a
49
+ # genuinely different rule for the same gem is an error (B4).
50
+ return existing if existing == rule
51
+
52
+ raise DuplicateRuleError, <<~MSG
53
+ duplicate overrule for `#{rule.name}`
54
+
55
+ already declared: #{existing}
56
+ then declared: #{rule}
57
+
58
+ Each gem may have at most one overrule rule. Pick one and delete the other.
59
+ MSG
60
+ end
61
+
62
+ @rules << rule
63
+ @rules_by_name[rule.name] = rule
64
+ rule
65
+ end
66
+
67
+ def rule_for(name)
68
+ @rules_by_name[name]
69
+ end
70
+
71
+ def forces
72
+ @rules.select { |r| r.type == :force }
73
+ end
74
+
75
+ def bans
76
+ @rules.select { |r| r.type == :ban }
77
+ end
78
+
79
+ # Rewrite a dependency list and remember what changed.
80
+ def filter(dependencies, owner: nil)
81
+ return dependencies if empty?
82
+
83
+ rewritten, edges = DependencyRewriter.rewrite(dependencies, @rules_by_name, owner: owner)
84
+ @edges.concat(edges) unless edges.empty?
85
+ rewritten
86
+ end
87
+
88
+ # Every edge a rule actually changed, de-duplicated. Bundler asks specs
89
+ # for their dependencies many times per run, so the raw list is noisy.
90
+ def edges
91
+ @edges.uniq { |e| [e.rule.name, e.owner, e.was, e.now] }
92
+ end
93
+
94
+ def edges_for(rule)
95
+ edges.select { |e| e.rule.name == rule.name }
96
+ end
97
+
98
+ # Rules that matched nothing at all (B3) — `doctor` flags these.
99
+ def stale_rules
100
+ matched = edges.map { |e| e.rule.name }.uniq
101
+ @rules.reject { |r| matched.include?(r.name) }
102
+ end
103
+
104
+ # Identifies the current rule set. Written to the state file so we can
105
+ # tell when rules changed and a re-resolve is required (see Patcher).
106
+ def fingerprint
107
+ return nil if empty?
108
+
109
+ payload = @rules.map { |r| "#{r.type}:#{r.name}:#{r.comparable_requirement}" }.sort.join("\n")
110
+ # ::Digest — Bundler vendors its own Bundler::Digest, which would win here.
111
+ ::Digest::SHA256.hexdigest(payload)[0, 16]
112
+ end
113
+ end
114
+ end
115
+ end
@@ -0,0 +1,286 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+ require "time"
5
+
6
+ require_relative "version"
7
+
8
+ module Bundler
9
+ module Overrule
10
+ # Warnings, `overrule list` / `overrule doctor` output, and the state file.
11
+ #
12
+ # The exact warning text is part of this gem's user experience — it is the
13
+ # only thing standing between an override and a silent surprise — so it is
14
+ # asserted verbatim in the specs.
15
+ class Reporter
16
+ STATE_FILE = "overrule-report.json"
17
+ BANNER = "[bundler-overrule]"
18
+
19
+ TABLE_HEADERS = %w[TYPE GEM REQUIREMENT REASON].freeze
20
+ TABLE_KEYS = %w[type gem requirement reason].freeze
21
+ TABLE_SEPARATOR = " "
22
+
23
+ def initialize(registry, ui: nil)
24
+ @registry = registry
25
+ @ui = ui
26
+ end
27
+
28
+ # The block printed during `bundle install` / `bundle update`.
29
+ def summary
30
+ return [] if @registry.empty?
31
+
32
+ header = "#{BANNER} #{pluralize(@registry.rules.size, "rule")} active — " \
33
+ "YOU own the consequences of these overrides:"
34
+ [header, *@registry.rules.map { |rule| " #{rule_line(rule)}" }]
35
+ end
36
+
37
+ def print_summary
38
+ lines = summary
39
+ return if lines.empty?
40
+
41
+ lines.each { |line| ui.warn(line) }
42
+ end
43
+
44
+ # `bundle overrule list`
45
+ def list
46
+ state = read_state
47
+ return ["#{BANNER} no rules are active.", "", hint_for_empty(state)] if @registry.empty? && state.nil?
48
+
49
+ rules = @registry.any? ? rules_from_registry : rules_from_state(state)
50
+ return ["#{BANNER} no rules are active."] if rules.empty?
51
+
52
+ out = ["#{BANNER} #{pluralize(rules.size, "rule")}:", ""]
53
+ out.concat(render_table(rules))
54
+ out << ""
55
+ out << if state
56
+ "Edges below were recorded during the last resolution " \
57
+ "(#{state["generated_at"]}, Bundler #{state["bundler_version"]})."
58
+ else
59
+ "No resolution recorded yet — run `bundle install` to see which edges are rewritten."
60
+ end
61
+ out
62
+ end
63
+
64
+ # `bundle overrule doctor`
65
+ def doctor
66
+ problems = []
67
+ notes = []
68
+
69
+ problems.concat(check_bundler_supported)
70
+ problems.concat(check_bootstrap_present)
71
+ problems.concat(check_stale_rules)
72
+ notes.concat(check_state_freshness)
73
+
74
+ out = ["#{BANNER} doctor", ""]
75
+ if problems.empty?
76
+ out << " ✓ no problems found"
77
+ else
78
+ problems.each { |p| out << " #{p}" }
79
+ end
80
+ unless notes.empty?
81
+ out << ""
82
+ notes.each { |n| out << " #{n}" }
83
+ end
84
+ out
85
+ end
86
+
87
+ def healthy?
88
+ check_bundler_supported.empty? && check_bootstrap_present.empty? && check_stale_rules.empty?
89
+ end
90
+
91
+ # ---- state file -------------------------------------------------------
92
+
93
+ def state_path
94
+ ::Bundler.app_config_path.join(STATE_FILE)
95
+ rescue StandardError
96
+ nil
97
+ end
98
+
99
+ def write_state!
100
+ path = state_path
101
+ return nil if path.nil? || @registry.empty?
102
+
103
+ payload = {
104
+ "schema" => 1,
105
+ "overrule_version" => Overrule::VERSION,
106
+ "bundler_version" => ::Bundler::VERSION,
107
+ "generated_at" => Time.now.utc.iso8601,
108
+ "fingerprint" => @registry.fingerprint,
109
+ "rules" => @registry.rules.map { |r| rule_to_h(r) },
110
+ "edges" => edges_to_persist
111
+ }
112
+
113
+ require "fileutils"
114
+ FileUtils.mkdir_p(File.dirname(path))
115
+ File.write(path, "#{JSON.pretty_generate(payload)}\n")
116
+ path
117
+ rescue SystemCallError
118
+ # A read-only checkout is not a reason to fail an install.
119
+ nil
120
+ end
121
+
122
+ def read_state
123
+ path = state_path
124
+ return nil if path.nil? || !File.exist?(path)
125
+
126
+ JSON.parse(File.read(path))
127
+ rescue JSON::ParserError, SystemCallError
128
+ nil
129
+ end
130
+
131
+ private
132
+
133
+ # Not every Bundler invocation resolves. `bundle overrule list`, for
134
+ # instance, evaluates the Gemfile (registering rules) but never touches
135
+ # the resolver, so it records no edges. Persisting that empty list would
136
+ # erase the real resolution's record and make `doctor` declare every rule
137
+ # stale. Keep the previous edges whenever the rules themselves are
138
+ # unchanged.
139
+ def edges_to_persist
140
+ current = @registry.edges.map(&:to_h)
141
+ return current unless current.empty?
142
+
143
+ previous = read_state
144
+ return current if previous.nil? || previous["fingerprint"] != @registry.fingerprint
145
+
146
+ previous["edges"] || current
147
+ end
148
+
149
+ def ui
150
+ @ui || ::Bundler.ui
151
+ end
152
+
153
+ 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}"
157
+ line << " #{provenance(rule)}"
158
+ line << " # #{rule.reason}" if rule.reason
159
+ line
160
+ end
161
+
162
+ def provenance(rule)
163
+ edges = @registry.edges_for(rule)
164
+ return "(no effect — rule matched nothing)" if edges.empty?
165
+
166
+ owners = edges.map { |e| e.owner || "?" }.uniq
167
+ shown = owners.first(2).join(", ")
168
+ shown += ", +#{owners.size - 2} more" if owners.size > 2
169
+
170
+ if rule.type == :ban
171
+ "(dropped from: #{shown})"
172
+ else
173
+ was = edges.map(&:was).uniq.first(2).join(", ")
174
+ "(was: #{shown} → #{was})"
175
+ end
176
+ end
177
+
178
+ def rules_from_registry
179
+ @registry.rules.map do |rule|
180
+ { "type" => rule.type.to_s, "gem" => rule.name,
181
+ "requirement" => (rule.type == :force ? rule.requirement.to_s : "—"),
182
+ "reason" => rule.reason || "" }
183
+ end
184
+ end
185
+
186
+ def rules_from_state(state)
187
+ (state["rules"] || []).map do |r|
188
+ { "type" => r["type"], "gem" => r["gem"], "requirement" => r["requirement"] || "—",
189
+ "reason" => r["reason"] || "" }
190
+ end
191
+ end
192
+
193
+ def render_table(rows)
194
+ widths = column_widths(rows)
195
+
196
+ [
197
+ table_row(TABLE_HEADERS, widths),
198
+ widths.map { |width| "-" * width }.join(TABLE_SEPARATOR),
199
+ *rows.map { |row| table_row(TABLE_KEYS.map { |key| row[key].to_s }, widths) }
200
+ ]
201
+ end
202
+
203
+ def column_widths(rows)
204
+ TABLE_KEYS.each_with_index.map do |key, index|
205
+ [TABLE_HEADERS[index].length, *rows.map { |row| row[key].to_s.length }].max
206
+ end
207
+ end
208
+
209
+ def table_row(cells, widths)
210
+ cells.each_with_index.map { |cell, index| cell.ljust(widths[index]) }
211
+ .join(TABLE_SEPARATOR).rstrip
212
+ end
213
+
214
+ def rule_to_h(rule)
215
+ { "type" => rule.type.to_s, "gem" => rule.name,
216
+ "requirement" => (rule.type == :force ? rule.requirement.to_s : nil),
217
+ "reason" => rule.reason }
218
+ end
219
+
220
+ def check_bundler_supported
221
+ requirement = ::Gem::Requirement.create(Overrule::SUPPORTED_BUNDLER)
222
+ if requirement.satisfied_by?(::Gem::Version.new(::Bundler::VERSION))
223
+ []
224
+ else
225
+ ["✗ Bundler #{::Bundler::VERSION} is outside the supported range " \
226
+ "(#{Overrule::SUPPORTED_BUNDLER_DESCRIPTION})."]
227
+ end
228
+ end
229
+
230
+ def check_bootstrap_present
231
+ gemfile = ::Bundler.default_gemfile.read
232
+ return [] if gemfile.include?("bundler-overrule")
233
+
234
+ ["✗ your Gemfile has no bundler-overrule bootstrap line — rules will never load. " \
235
+ "See the README quickstart."]
236
+ rescue StandardError
237
+ []
238
+ end
239
+
240
+ def check_stale_rules
241
+ matched = matched_gem_names
242
+ # Nothing to judge against: no resolution has ever been recorded.
243
+ # Better to say so (check_state_freshness does) than to accuse every
244
+ # rule of being dead.
245
+ return [] if matched.nil?
246
+
247
+ @registry.rules.reject { |rule| matched.include?(rule.name) }.map do |rule|
248
+ "✗ `#{rule}` had no effect — nothing in the graph depends on `#{rule.name}`. " \
249
+ "Delete it."
250
+ end
251
+ end
252
+
253
+ # Which gems a rule actually touched. `bundle overrule doctor` evaluates
254
+ # the Gemfile but never resolves, so in that process the registry has no
255
+ # edges at all — the answer has to come from the last resolution's state
256
+ # file, or the command would report every rule as stale.
257
+ def matched_gem_names
258
+ return @registry.edges.map { |e| e.rule.name }.uniq unless @registry.edges.empty?
259
+
260
+ state = read_state
261
+ return nil if state.nil?
262
+
263
+ (state["edges"] || []).map { |e| e["gem"] }.uniq
264
+ end
265
+
266
+ def check_state_freshness
267
+ state = read_state
268
+ return ["· no resolution recorded yet — run `bundle install`."] if state.nil?
269
+
270
+ if @registry.any? && state["fingerprint"] != @registry.fingerprint
271
+ ["· rules changed since the last resolution — run `bundle install` to apply them."]
272
+ else
273
+ ["· last resolution: #{state["generated_at"]} (Bundler #{state["bundler_version"]})."]
274
+ end
275
+ end
276
+
277
+ def hint_for_empty(_state)
278
+ "Declare rules in your Gemfile with `force` / `ban`. See the README."
279
+ end
280
+
281
+ def pluralize(count, noun)
282
+ "#{count} #{noun}#{"s" unless count == 1}"
283
+ end
284
+ end
285
+ end
286
+ end
@@ -0,0 +1,111 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "errors"
4
+
5
+ module Bundler
6
+ module Overrule
7
+ # A single override declared in the Gemfile.
8
+ #
9
+ # Rules are immutable value objects. Equality is by content, which is what
10
+ # lets us tell a harmless Gemfile re-evaluation (Bundler evaluates the
11
+ # Gemfile more than once per run) apart from a genuine duplicate rule (B4).
12
+ class Rule
13
+ attr_reader :name, :reason
14
+
15
+ def initialize(name, reason: nil)
16
+ @name = normalize_name(name)
17
+ @reason = normalize_reason(reason)
18
+ freeze
19
+ end
20
+
21
+ # :force or :ban — subclasses define it.
22
+ def type
23
+ raise NotImplementedError
24
+ end
25
+
26
+ # Does this rule apply to the given dependency name?
27
+ def applies_to?(dependency_name)
28
+ name == dependency_name
29
+ end
30
+
31
+ def ==(other)
32
+ other.is_a?(Rule) && other.type == type && other.name == name &&
33
+ other.comparable_requirement == comparable_requirement
34
+ end
35
+ alias eql? ==
36
+
37
+ def hash
38
+ [self.class, name, comparable_requirement].hash
39
+ end
40
+
41
+ # Rules that differ only by +reason:+ are still the same rule; we do not
42
+ # want to error on a re-run just because someone reworded a comment.
43
+ def comparable_requirement
44
+ nil
45
+ end
46
+
47
+ def to_s
48
+ raise NotImplementedError
49
+ end
50
+
51
+ private
52
+
53
+ def normalize_name(value)
54
+ string = value.to_s.strip
55
+ raise InvalidRuleError, "gem name can't be blank" if string.empty?
56
+
57
+ string
58
+ end
59
+
60
+ def normalize_reason(value)
61
+ return nil if value.nil?
62
+
63
+ string = value.to_s.strip
64
+ string.empty? ? nil : string
65
+ end
66
+ end
67
+
68
+ # `force 'openssl', '>= 3.0'` — rewrite every constraint on this gem.
69
+ class Force < Rule
70
+ attr_reader :requirement
71
+
72
+ def initialize(name, requirement, reason: nil)
73
+ @requirement = build_requirement(requirement)
74
+ super(name, reason: reason)
75
+ end
76
+
77
+ def type
78
+ :force
79
+ end
80
+
81
+ def comparable_requirement
82
+ requirement.to_s
83
+ end
84
+
85
+ def to_s
86
+ "force #{name} #{requirement}"
87
+ end
88
+
89
+ private
90
+
91
+ def build_requirement(value)
92
+ raise InvalidRuleError, "force '#{value}' requires a version requirement" if value.nil?
93
+
94
+ ::Gem::Requirement.create(value)
95
+ rescue ::Gem::Requirement::BadRequirementError => e
96
+ raise InvalidRuleError, "invalid version requirement #{value.inspect}: #{e.message}"
97
+ end
98
+ end
99
+
100
+ # `ban 'httpclient'` — drop every edge pointing at this gem.
101
+ class Ban < Rule
102
+ def type
103
+ :ban
104
+ end
105
+
106
+ def to_s
107
+ "ban #{name}"
108
+ end
109
+ end
110
+ end
111
+ end
@@ -0,0 +1,7 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Bundler
4
+ module Overrule
5
+ VERSION = "0.2.0"
6
+ end
7
+ end