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
|
@@ -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
|