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.
- checksums.yaml +7 -0
- checksums.yaml.gz.sig +0 -0
- data/CHANGELOG.md +138 -0
- data/LICENSE.md +10 -0
- data/README.md +460 -0
- data/exe/kettle-deps-floor +12 -0
- data/lib/kettle/deps/floor/cli.rb +236 -0
- data/lib/kettle/deps/floor/registry_resolver.rb +31 -0
- data/lib/kettle/deps/floor/updater.rb +398 -0
- data/lib/kettle/deps/floor/version.rb +15 -0
- data/lib/kettle/deps/floor.rb +19 -0
- data/sig/kettle/deps/floor.rbs +10 -0
- data.tar.gz.sig +0 -0
- metadata +357 -0
- metadata.gz.sig +0 -0
|
@@ -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
|