kettle-deps-floor 0.1.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,236 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+ require "kettle/ndjson"
5
+ require "open3"
6
+ require "optparse"
7
+ require "yaml"
8
+
9
+ module Kettle
10
+ module Deps
11
+ module Floor
12
+ # Command-line entry point shared by the generic executable and project
13
+ # compatibility wrappers such as kettle-jem-deps-floor.
14
+ class CLI
15
+ DEFAULT_UPGRADE = "major"
16
+
17
+ def self.run(argv = ARGV, out: $stdout, err: $stderr, defaults: {})
18
+ options = parse_options(argv, defaults: defaults)
19
+ if options[:help]
20
+ out.puts(parser(defaults: defaults))
21
+ return 0
22
+ end
23
+ if options[:events] && options[:json]
24
+ raise OptionParser::InvalidOption, "--events cannot be combined with --json"
25
+ end
26
+ config = load_config(options.fetch(:config_path))
27
+ sources = options.fetch(:sources).empty? ? Array(config["sources"] || defaults[:sources] || ["*.gemspec"]) : options.fetch(:sources)
28
+ target_versions = config.fetch("target_versions", {}).merge(options.fetch(:target_versions))
29
+ holds = config.fetch("holds", {})
30
+ placeholder_pattern = options[:placeholder_pattern] || config["placeholder_pattern"] || defaults[:placeholder_pattern]
31
+ event_types = %w[run_start command_step summary]
32
+ events = Kettle::Ndjson.event_stream(out, types: event_types.join(","), event_types: event_types) if options[:events]
33
+ Kettle::Ndjson.emit_event(events, "run_start", command: "kettle-deps-floor", project_root: options.fetch(:project_root))
34
+ updater_options = {
35
+ project_root: options.fetch(:project_root),
36
+ sources: sources,
37
+ target_versions: target_versions,
38
+ upgrade: options.fetch(:upgrade),
39
+ gem_names: options.fetch(:gem_names),
40
+ holds: holds,
41
+ placeholder_pattern: placeholder_pattern
42
+ }
43
+ planned = Updater.new(**updater_options).run
44
+
45
+ if options[:write] && options[:commit] && planned.fetch(:updates).positive?
46
+ paths = planned.fetch(:planned_changes).map { |change| change.fetch(:relative_path) }.uniq
47
+ paths << "CHANGELOG.md" if options[:changelog]
48
+ dirty = dirty_paths(options.fetch(:project_root), paths)
49
+ raise "Refusing to commit because target files were already dirty: #{dirty.join(", ")}" unless dirty.empty?
50
+ end
51
+
52
+ result = if options[:write]
53
+ Updater.new(**updater_options, write: true).run
54
+ else
55
+ planned
56
+ end
57
+ emit_dependency_floor_events(events, result, write: options[:write])
58
+ changelog_result = write_changelog(options, config, result, defaults) if options[:write] && options[:changelog] && result.fetch(:updates).positive?
59
+ commit_result = commit_changes(options, result, changelog_result) if options[:write] && options[:commit] && result.fetch(:updates).positive?
60
+ print_result(options[:events] ? err : out, result, commit_result, changelog_result, json: options[:json])
61
+ return 1 if options[:check] && result.fetch(:updates).positive?
62
+
63
+ 0
64
+ rescue OptionParser::ParseError, ArgumentError => error
65
+ err.puts(error.message)
66
+ err.puts(parser(defaults: defaults))
67
+ 2
68
+ rescue => error
69
+ err.puts("#{error.class}: #{error.message}")
70
+ 1
71
+ end
72
+
73
+ def self.parse_options(argv, defaults: {})
74
+ options = {
75
+ project_root: File.expand_path(defaults.fetch(:project_root, Dir.pwd)),
76
+ config_path: nil,
77
+ sources: [],
78
+ target_versions: {},
79
+ gem_names: [],
80
+ upgrade: DEFAULT_UPGRADE,
81
+ write: false,
82
+ check: false,
83
+ commit: true,
84
+ changelog: true,
85
+ json: false,
86
+ events: false,
87
+ placeholder_pattern: nil
88
+ }
89
+ parser(defaults: defaults, options: options).parse!(argv, into: options)
90
+ options[:project_root] = File.expand_path(argv.shift || options.fetch(:project_root))
91
+ raise OptionParser::InvalidArgument, "Expected at most one PROJECT_ROOT" unless argv.empty?
92
+
93
+ options[:config_path] ||= File.join(options.fetch(:project_root), defaults.fetch(:config_name, ".kettle-deps-floor.yml"))
94
+ options
95
+ end
96
+
97
+ def self.parser(defaults: {}, options: {})
98
+ OptionParser.new do |opts|
99
+ opts.banner = "Usage: #{defaults.fetch(:executable, "kettle-deps-floor")} [PROJECT_ROOT] [options]"
100
+ opts.on("--source GLOB", "Ruby source glob to scan; may be repeated.") { |value| options[:sources] << value }
101
+ opts.on("--target GEM=VERSION", "Set a family/release target version; may be repeated.") do |value|
102
+ name, version = value.split("=", 2)
103
+ raise OptionParser::InvalidArgument, "Expected GEM=VERSION, got #{value.inspect}" if name.to_s.empty? || version.to_s.empty?
104
+
105
+ options[:target_versions][name] = version
106
+ end
107
+ opts.on("--only GEMS", "Limit updates to comma-separated gem names.") { |value| options[:gem_names].concat(value.split(",").map(&:strip)) }
108
+ opts.on("--upgrade LEVEL", "Upgrade strategy: major, minor, patch (default: #{DEFAULT_UPGRADE}).") { |value| options[:upgrade] = value }
109
+ opts.on("-w", "--write", "Write dependency floor updates.") { options[:write] = true }
110
+ opts.on("--check", "Exit 1 when updates are available, without writing.") { options[:check] = true }
111
+ opts.on("--[no-]commit", "Commit the changed files when writing (default: true).") { |value| options[:commit] = value }
112
+ opts.on("--[no-]changelog", "Upsert an Unreleased changelog entry when writing (default: true).") { |value| options[:changelog] = value }
113
+ opts.on("--config PATH", "Read additional sources and holds from YAML.") { |value| options[:config_path] = File.expand_path(value) }
114
+ opts.on("--json", "Print a machine-readable JSON report.") { options[:json] = true }
115
+ opts.on("--events", "Emit compact NDJSON progress events to stdout; send the human report to stderr.") { options[:events] = true }
116
+ opts.on("--placeholder-pattern REGEXP", "Treat matches as opaque template tokens when parsing sources.") { |value| options[:placeholder_pattern] = value }
117
+ opts.on("-h", "--help", "Print this help.") { options[:help] = true }
118
+ end
119
+ end
120
+
121
+ def self.load_config(path)
122
+ return {} unless File.file?(path)
123
+
124
+ config = YAML.safe_load_file(path, permitted_classes: [], aliases: false) || {}
125
+ raise ArgumentError, "#{path} must contain a mapping" unless config.is_a?(Hash)
126
+
127
+ config
128
+ end
129
+
130
+ def self.write_changelog(options, config, result, defaults)
131
+ key = config.fetch("changelog_key", defaults.fetch(:changelog_key, "kettle-deps-floor"))
132
+ details = result.fetch(:planned_changes).group_by { |change| change.fetch(:name) }.map do |name, changes|
133
+ initial = changes.map { |change| Gem::Version.new(change.fetch(:current_floor)) }.min
134
+ latest = changes.map { |change| Gem::Version.new(change.fetch(:new_floor)) }.max
135
+ " - #{name} (>= #{initial} -> >= #{latest})"
136
+ end
137
+ label = config.fetch("changelog_entry", defaults.fetch(:changelog_entry, "Update dependency floors:"))
138
+ entry = ([label] + details).join("\n")
139
+ command = ["kettle-changelog", "--upsert-unreleased-entry", "--section", "Changed", "--key", key, "--entry", entry]
140
+ stdout, stderr, status = Open3.capture3(*command, chdir: options.fetch(:project_root))
141
+ raise "kettle-changelog failed: #{stderr.empty? ? stdout : stderr}" unless status.success?
142
+
143
+ {status: "updated", output: stdout.strip}
144
+ end
145
+
146
+ def self.dirty_paths(root, paths)
147
+ return [] unless git(root, "rev-parse", "--show-toplevel", allow_failure: true)
148
+
149
+ git(root, "status", "--porcelain", "--", *paths).lines.map { |line| line[3..].to_s.strip }
150
+ end
151
+
152
+ def self.commit_changes(options, result, changelog_result)
153
+ root = options.fetch(:project_root)
154
+ relative_paths = result.fetch(:planned_changes).map { |change| change.fetch(:relative_path) }.uniq
155
+ relative_paths << "CHANGELOG.md" if changelog_result
156
+ relative_paths.uniq!
157
+ top = git(root, "rev-parse", "--show-toplevel", allow_failure: true)
158
+ return {status: "unavailable", reason: "not_git_repository"} unless top
159
+
160
+ changed = relative_paths.select { |path| File.file?(File.join(root, path)) }
161
+ return {status: "clean_noop"} if changed.empty?
162
+
163
+ git(root, "add", "--", *changed)
164
+ git(root, "commit", "-m", defaults_commit_message(result))
165
+ {status: "committed", files: changed}
166
+ end
167
+
168
+ def self.defaults_commit_message(result)
169
+ names = result.fetch(:updated_dependencies)
170
+ "⬆️ Raise dependency floors: #{names.join(", ")}"
171
+ end
172
+
173
+ def self.git(root, *args, allow_failure: false)
174
+ stdout, stderr, status = Open3.capture3("git", *args, chdir: root)
175
+ return stdout if status.success?
176
+ return nil if allow_failure
177
+
178
+ raise "git #{args.join(" ")} failed: #{stderr.empty? ? stdout : stderr}"
179
+ end
180
+
181
+ def self.emit_dependency_floor_events(events, result, write:)
182
+ return unless events
183
+
184
+ result.fetch(:planned_changes).each_with_index do |change, index|
185
+ Kettle::Ndjson.emit_event(
186
+ events,
187
+ "command_step",
188
+ phase: "dependency_floor",
189
+ name: "raise_floor",
190
+ status: write ? "updated" : "planned",
191
+ index: index + 1,
192
+ total: result.fetch(:planned_changes).length,
193
+ gem_name: change.fetch(:name),
194
+ path: change.fetch(:relative_path),
195
+ current_floor: change.fetch(:current_floor),
196
+ new_floor: change.fetch(:new_floor)
197
+ )
198
+ end
199
+ status = if write
200
+ "updated"
201
+ else
202
+ "planned"
203
+ end
204
+ status = "ok" unless result.fetch(:updates).positive?
205
+ Kettle::Ndjson.emit_summary_event(
206
+ events,
207
+ command: "kettle-deps-floor",
208
+ status: status,
209
+ updates: result.fetch(:updates),
210
+ updated_dependencies: result.fetch(:updated_dependencies)
211
+ )
212
+ end
213
+
214
+ def self.print_result(out, result, commit_result, changelog_result, json:)
215
+ if json
216
+ out.puts(JSON.pretty_generate(result.merge(commit: commit_result, changelog: changelog_result)))
217
+ return
218
+ end
219
+
220
+ out.puts("deps-floor: mode=#{result.fetch(:mode)} upgrade=#{result.fetch(:upgrade)}")
221
+ out.puts("deps-floor: #{result.fetch(:updates)} update#{"s" unless result.fetch(:updates) == 1}")
222
+ result.fetch(:planned_changes).each do |change|
223
+ out.puts(" #{change.fetch(:name)} #{change.fetch(:relative_path)} >= #{change.fetch(:current_floor)} -> >= #{change.fetch(:new_floor)}")
224
+ end
225
+ out.puts("changelog: #{changelog_result.fetch(:status)}") if changelog_result
226
+ out.puts("commit: #{commit_result.fetch(:status)}") if commit_result
227
+ if result.fetch(:mode) == "dry-run" && result.fetch(:updates).positive?
228
+ out.puts("hint: rerun with --write --upgrade #{result.fetch(:upgrade)} to apply updates")
229
+ end
230
+ end
231
+
232
+ private_class_method :parser
233
+ end
234
+ end
235
+ end
236
+ end
@@ -0,0 +1,31 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "kettle/dev"
4
+
5
+ module Kettle
6
+ module Deps
7
+ module Floor
8
+ # Registry adapter backed by kettle-dev's shared persistent RubyGems cache.
9
+ class RegistryResolver
10
+ def versions(gem_name, requirements: nil)
11
+ Kettle::Dev::RubyGemsVersions.fetch(gem_name).filter_map do |entry|
12
+ number = entry["number"] || entry[:number]
13
+ next if number.to_s.empty?
14
+ next if prerelease_entry?(entry, number)
15
+ next if requirements && !Gem::Requirement.new(*requirements).satisfied_by?(Gem::Version.new(number.to_s))
16
+
17
+ {number: number.to_s}
18
+ end
19
+ end
20
+
21
+ private
22
+
23
+ def prerelease_entry?(entry, number)
24
+ prerelease = entry["prerelease"] || entry[:prerelease]
25
+ explicitly_prerelease = prerelease == true || prerelease.to_s.match?(/\A(?:1|true|yes|on)\z/i)
26
+ explicitly_prerelease || Gem::Version.new(number.to_s).prerelease?
27
+ end
28
+ end
29
+ end
30
+ end
31
+ end
@@ -0,0 +1,398 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "fileutils"
4
+ require "pathname"
5
+ require "rubygems"
6
+ require "kettle/dev"
7
+
8
+ module Kettle
9
+ module Deps
10
+ module Floor
11
+ # Finds explicit lower-bound dependency requirements in configured Ruby
12
+ # sources and raises them either to a supplied family version or to a
13
+ # registry version bounded by the selected upgrade level.
14
+ class Updater
15
+ DEPENDENCY_METHODS = %i[add_dependency add_runtime_dependency add_development_dependency].freeze
16
+ GEMFILE_METHODS = %i[gem].freeze
17
+ ASSIGNMENT_NAMES = %i[nomono_requirements nomono_activation_requirements].freeze
18
+ VALID_UPGRADES = %w[major minor patch].freeze
19
+
20
+ def initialize(project_root:, sources: ["*.gemspec"], resolver: RegistryResolver.new, target_versions: {},
21
+ upgrade: "major", write: false, check: false, gem_names: nil, holds: {}, registry_updates: true,
22
+ placeholder_pattern: nil)
23
+ @project_root = File.expand_path(project_root)
24
+ @sources = Array(sources).map(&:to_s)
25
+ @resolver = resolver
26
+ @target_versions = target_versions.transform_keys(&:to_s).transform_values { |value| Gem::Version.new(value.to_s) }
27
+ @upgrade = upgrade.to_s
28
+ @write = write
29
+ @check = check
30
+ @gem_names = Array(gem_names).map(&:to_s)
31
+ @holds = holds.transform_keys(&:to_s)
32
+ @registry_updates = registry_updates
33
+ @placeholder_pattern = placeholder_pattern && Regexp.new(placeholder_pattern.to_s)
34
+ end
35
+
36
+ attr_reader :project_root, :sources, :resolver, :target_versions, :upgrade, :gem_names, :holds, :registry_updates, :placeholder_pattern
37
+
38
+ def self.dependency_names(project_root:, sources:)
39
+ new(project_root: project_root, sources: sources).dependencies
40
+ end
41
+
42
+ def dependencies
43
+ dependency_records.map { |record| record.fetch(:name) }.uniq
44
+ end
45
+
46
+ def run
47
+ validate_options!
48
+ records = dependency_records
49
+ planned_changes = records.filter_map { |record| plan_record(record) }
50
+ apply_changes(planned_changes) if @write
51
+ {
52
+ mode: @write ? "write" : "dry-run",
53
+ check: !!@check,
54
+ upgrade: upgrade,
55
+ discovered_dependencies: records.map { |record| record.fetch(:name) }.uniq.sort,
56
+ updates: planned_changes.length,
57
+ updated_dependencies: planned_changes.map { |change| change.fetch(:name) }.uniq.sort,
58
+ planned_changes: planned_changes.map { |change| serializable_change(change) }
59
+ }
60
+ end
61
+
62
+ private
63
+
64
+ def validate_options!
65
+ return if VALID_UPGRADES.include?(upgrade)
66
+
67
+ raise ArgumentError, "Invalid upgrade level #{upgrade.inspect}; use one of: #{VALID_UPGRADES.join(", ")}"
68
+ end
69
+
70
+ def dependency_records
71
+ source_paths.flat_map { |relative_path| records_for_path(relative_path) }.uniq do |record|
72
+ [record.fetch(:relative_path), record.fetch(:name), record.fetch(:floor_range).fetch(:start_offset)]
73
+ end
74
+ end
75
+
76
+ def source_paths
77
+ sources.flat_map do |pattern|
78
+ Dir.glob(File.join(project_root, pattern), File::FNM_DOTMATCH).select { |path| File.file?(path) }
79
+ end.uniq.sort.map { |path| relative_path(path) }
80
+ end
81
+
82
+ def relative_path(path)
83
+ Pathname(path).relative_path_from(Pathname(project_root)).to_s
84
+ end
85
+
86
+ def records_for_path(relative_path)
87
+ path = File.join(project_root, relative_path)
88
+ source = File.read(path)
89
+ spans = placeholder_spans(source)
90
+ parse_result = Kettle::Dev::VersionBump.parse_source(spans.empty? ? source : sanitize_placeholders(source, spans), path)
91
+ nodes = Kettle::Dev::VersionBump.each_node(parse_result.value)
92
+ records = nodes.flat_map do |node|
93
+ [
94
+ call_record(node, source, relative_path),
95
+ assignment_record(node, source, relative_path),
96
+ *Array(registry_records(node, source, relative_path))
97
+ ].compact
98
+ end + embedded_text_records(source, relative_path)
99
+ return records if spans.empty?
100
+
101
+ # Records whose floor or pessimistic requirement sits inside a
102
+ # substituted placeholder span are not editable (the placeholder owns
103
+ # those bytes), and names manufactured by the substitution sentinel
104
+ # are not real gems.
105
+ records.reject do |record|
106
+ sentinel_name?(record.fetch(:name)) ||
107
+ span_touched?(spans, record.fetch(:floor_range)) ||
108
+ (record[:pessimistic_range] && span_touched?(spans, record.fetch(:pessimistic_range)))
109
+ end
110
+ rescue Kettle::Dev::Error => error
111
+ raise Error, error.message
112
+ end
113
+
114
+ # Template placeholders such as kettle-jem's {KJ|TOKEN} are not valid
115
+ # Ruby in code positions, which makes the whole source unparseable.
116
+ # Each placeholder span is replaced with an equal-length constant token
117
+ # so Prism offsets still map 1:1 onto the original bytes; a constant
118
+ # parses in every position a placeholder occupies (statement, value,
119
+ # argument, `::` namespace segment), and it never becomes a StringNode,
120
+ # so substituted regions can never yield editable floor records.
121
+ # A placeholder glued to a preceding closing token (`)`, `"`, `}`)
122
+ # gets a `::`-prefixed constant instead, so e.g. `call("exe"){KJ|X}`
123
+ # parses as a constant path; inside string literals either shape is
124
+ # inert text.
125
+ def placeholder_spans(source)
126
+ return [] unless placeholder_pattern
127
+
128
+ spans = []
129
+ position = 0
130
+ while (match = placeholder_pattern.match(source, position))
131
+ spans << [match.begin(0), match.end(0)]
132
+ position = match.end(0)
133
+ end
134
+ spans
135
+ end
136
+
137
+ def sanitize_placeholders(source, spans)
138
+ sanitized = source.dup
139
+ spans.each do |start_offset, end_offset|
140
+ length = end_offset - start_offset
141
+ previous = start_offset.zero? ? nil : sanitized[start_offset - 1]
142
+ token = if previous && %[) " }].include?(previous) && length >= 3
143
+ "::C" + ("x" * (length - 3))
144
+ else
145
+ "C" + ("x" * (length - 1))
146
+ end
147
+ sanitized[start_offset...end_offset] = token
148
+ end
149
+ sanitized
150
+ end
151
+
152
+ def sentinel_name?(name)
153
+ name.match?(/\ACx{4,}\z/)
154
+ end
155
+
156
+ def span_touched?(spans, range)
157
+ spans.any? { |start_offset, end_offset| range.fetch(:start_offset) < end_offset && start_offset < range.fetch(:end_offset) }
158
+ end
159
+
160
+ def call_record(node, source, relative_path)
161
+ return unless node.is_a?(Prism::CallNode)
162
+ dependency_call = DEPENDENCY_METHODS.include?(node.name)
163
+ gemfile_call = node.receiver.nil? && GEMFILE_METHODS.include?(node.name)
164
+ return if !dependency_call && !gemfile_call
165
+
166
+ arguments = node.arguments&.arguments || []
167
+ name_node = arguments.first
168
+ return unless name_node.is_a?(Prism::StringNode)
169
+
170
+ record_from_nodes(name_node.unescaped, arguments.drop(1), source, relative_path, "gemspec_or_gemfile")
171
+ end
172
+
173
+ def assignment_record(node, source, relative_path)
174
+ return unless node.is_a?(Prism::LocalVariableWriteNode) && ASSIGNMENT_NAMES.include?(node.name)
175
+ return unless node.value.is_a?(Prism::ArrayNode)
176
+
177
+ record_from_nodes("nomono", node.value.elements, source, relative_path, "requirement_assignment")
178
+ end
179
+
180
+ # Registry entries are ordinary Ruby AST; only embedded examples need
181
+ # text scanning because their %(...) delimiters are not valid Ruby.
182
+ def registry_records(node, source, relative_path)
183
+ return unless node.is_a?(Prism::ConstantWriteNode)
184
+ registry = node.name
185
+ value = node.value
186
+ value = value.receiver if value.is_a?(Prism::CallNode) && value.name == :freeze
187
+ case registry
188
+ when :TEMPLATE_MANAGED_DEPENDENCIES
189
+ return unless value.is_a?(Prism::ArrayNode)
190
+ value.elements.filter_map do |entry|
191
+ next unless entry.is_a?(Prism::HashNode)
192
+ fields = entry.elements.to_h do |assoc|
193
+ [assoc.key.unescaped.to_s, assoc.value] if assoc.is_a?(Prism::AssocNode) && assoc.key.is_a?(Prism::SymbolNode)
194
+ end.compact
195
+ name_node = fields["name"]
196
+ name = name_node.unescaped if name_node.is_a?(Prism::StringNode)
197
+ reqs = fields["requirements"]
198
+ record_from_nodes(name, reqs.elements, source, relative_path, "template_registry") if reqs.is_a?(Prism::ArrayNode)
199
+ end
200
+ when :GEMSPEC_DEPENDENCY_MINIMUM_REQUIREMENTS
201
+ return unless value.is_a?(Prism::HashNode)
202
+ value.elements.filter_map do |assoc|
203
+ next unless assoc.is_a?(Prism::AssocNode) && assoc.key.is_a?(Prism::StringNode) && assoc.value.is_a?(Prism::StringNode)
204
+ record_from_nodes(assoc.key.unescaped, [assoc.value], source, relative_path, "gemspec_floor")
205
+ end
206
+ end
207
+ end
208
+
209
+ def embedded_text_records(source, relative_path)
210
+ # Regex is intentionally limited to invalid-Ruby embedded template text;
211
+ # Prism cannot expose dependency calls inside %(...) string contents.
212
+ return [] unless source.include?("%(")
213
+
214
+ pattern = /%\(gem\s+"([A-Za-z0-9_.-]+)"\s*,\s*"(~> [^"]+)"\s*,\s*"(>= [^"]+)"/
215
+ # A position-loop with explicit MatchData: Regexp.last_match inside a
216
+ # to_enum(:scan) block is unreliable on TruffleRuby.
217
+ records = []
218
+ position = 0
219
+ while (match = pattern.match(source, position))
220
+ position = match.end(0)
221
+ pessimistic_offset = source[0...match.begin(2)].bytesize - 1
222
+ floor_offset = source[0...match.begin(3)].bytesize - 1
223
+ record = record_from_ranges(
224
+ match[1],
225
+ "embedded_template_text",
226
+ source,
227
+ relative_path,
228
+ match[2],
229
+ {start_offset: pessimistic_offset, end_offset: pessimistic_offset + match[2].bytesize + 2},
230
+ match[3],
231
+ {start_offset: floor_offset, end_offset: floor_offset + match[3].bytesize + 2},
232
+ [match[2], match[3]]
233
+ )
234
+ records << record if record
235
+ end
236
+ records
237
+ end
238
+
239
+ def record_from_nodes(name, nodes, source, relative_path, source_kind)
240
+ return unless name.is_a?(String) && name.match?(/\A[a-zA-Z0-9_.-]+\z/)
241
+ return if !gem_names.empty? && !gem_names.include?(name)
242
+
243
+ requirements = string_nodes(nodes)
244
+ floor = requirements.find { |entry| entry.unescaped.start_with?(">=") }
245
+ return unless floor
246
+
247
+ pessimistic = requirements.find { |entry| entry.unescaped.start_with?("~>") }
248
+ record_from_ranges(
249
+ name,
250
+ source_kind,
251
+ source,
252
+ relative_path,
253
+ pessimistic&.unescaped,
254
+ pessimistic && node_range(pessimistic),
255
+ floor.unescaped,
256
+ node_range(floor),
257
+ requirements.map(&:unescaped)
258
+ )
259
+ end
260
+
261
+ def record_from_ranges(name, source_kind, source, relative_path, pessimistic, pessimistic_range, floor, floor_range, requirements)
262
+ return unless name.is_a?(String) && name.match?(/\A[a-zA-Z0-9_.-]+\z/)
263
+ return if !gem_names.empty? && !gem_names.include?(name)
264
+
265
+ {
266
+ name: name,
267
+ relative_path: relative_path,
268
+ source_kind: source_kind,
269
+ source: source,
270
+ current_pessimistic: pessimistic,
271
+ current_floor: floor_version(floor),
272
+ pessimistic_range: pessimistic_range,
273
+ floor_range: floor_range,
274
+ requirements: requirements
275
+ }
276
+ rescue ArgumentError
277
+ nil
278
+ end
279
+
280
+ def string_nodes(nodes)
281
+ Array(nodes).flat_map do |node|
282
+ case node
283
+ when Prism::StringNode
284
+ [node]
285
+ when Prism::ArrayNode
286
+ string_nodes(node.elements)
287
+ else
288
+ []
289
+ end
290
+ end
291
+ end
292
+
293
+ def floor_version(requirement)
294
+ entry = Gem::Requirement.new(requirement).requirements.find { |operator, _version| operator == ">=" }
295
+ raise ArgumentError, "Expected >= requirement, got #{requirement.inspect}" unless entry
296
+
297
+ entry.last
298
+ end
299
+
300
+ def node_range(node)
301
+ {start_offset: node.location.start_offset, end_offset: node.location.end_offset}
302
+ end
303
+
304
+ def plan_record(record)
305
+ name = record.fetch(:name)
306
+ return if holds.key?(name)
307
+
308
+ current = record.fetch(:current_floor)
309
+ target = target_versions[name]
310
+ target ||= registry_target(record, current) if registry_updates
311
+ return unless target && target > current
312
+ return unless target_compatible?(record, target)
313
+
314
+ new_pessimistic = if target_versions.key?(name) || !record[:pessimistic_range]
315
+ nil
316
+ else
317
+ pessimistic_requirement_for(target)
318
+ end
319
+ record.merge(
320
+ current_floor_version: current,
321
+ new_floor_version: target,
322
+ new_pessimistic: new_pessimistic
323
+ )
324
+ end
325
+
326
+ def registry_target(record, current)
327
+ requirements = requirements_for_upgrade(current)
328
+ versions = resolver.versions(record.fetch(:name), requirements: requirements)
329
+ Array(versions).filter_map do |entry|
330
+ version = entry[:number] || entry["number"]
331
+ Gem::Version.new(version.to_s) unless version.to_s.empty?
332
+ end.max
333
+ end
334
+
335
+ def requirements_for_upgrade(current)
336
+ major = current.segments.fetch(0)
337
+ minor = current.segments.fetch(1, 0)
338
+ case upgrade
339
+ when "patch"
340
+ [">= #{current}", "~> #{major}.#{minor}.0"]
341
+ when "minor"
342
+ [">= #{current}", "~> #{major}.0"]
343
+ else
344
+ [">= #{current}"]
345
+ end
346
+ end
347
+
348
+ def target_compatible?(record, target)
349
+ fixed_constraints = record.fetch(:requirements).reject { |requirement| requirement.start_with?(">=") }
350
+ Gem::Requirement.new(*fixed_constraints).satisfied_by?(target)
351
+ rescue ArgumentError
352
+ false
353
+ end
354
+
355
+ def pessimistic_requirement_for(version)
356
+ segments = version.segments
357
+ "~> #{segments.fetch(0)}.#{segments.fetch(1, 0)}"
358
+ end
359
+
360
+ def apply_changes(changes)
361
+ edits = changes.flat_map do |change|
362
+ replacements = []
363
+ if change[:new_pessimistic]
364
+ replacements << edit(change, change.fetch(:pessimistic_range), change.fetch(:new_pessimistic))
365
+ end
366
+ replacements << edit(change, change.fetch(:floor_range), ">= #{change.fetch(:new_floor_version)}")
367
+ replacements
368
+ end
369
+ Kettle::Dev::VersionBump.write_edits(edits)
370
+ end
371
+
372
+ def edit(change, range, value)
373
+ source = change.fetch(:source)
374
+ original = source.byteslice(range.fetch(:start_offset), range.fetch(:end_offset) - range.fetch(:start_offset))
375
+ Kettle::Dev::VersionBump.file_edit(
376
+ File.join(project_root, change.fetch(:relative_path)),
377
+ source,
378
+ range.fetch(:start_offset),
379
+ range.fetch(:end_offset),
380
+ Kettle::Dev::VersionBump.quote_like(original, value)
381
+ )
382
+ end
383
+
384
+ def serializable_change(change)
385
+ {
386
+ name: change.fetch(:name),
387
+ relative_path: change.fetch(:relative_path),
388
+ source_kind: change.fetch(:source_kind),
389
+ current_floor: change.fetch(:current_floor_version).to_s,
390
+ new_floor: change.fetch(:new_floor_version).to_s,
391
+ current_pessimistic: change[:current_pessimistic],
392
+ new_pessimistic: change[:new_pessimistic]
393
+ }
394
+ end
395
+ end
396
+ end
397
+ end
398
+ end
@@ -0,0 +1,15 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Kettle
4
+ module Deps
5
+ module Floor
6
+ # Version namespace for this gem.
7
+ module Version
8
+ # Current gem version.
9
+ VERSION = "0.1.0"
10
+ end
11
+ # Current gem version exposed at the traditional constant location.
12
+ VERSION = Version::VERSION # Traditional Constant Location
13
+ end
14
+ end
15
+ end