kettle-dev 2.4.5 → 2.5.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,442 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "set"
4
+ require "shellwords"
5
+ require "fileutils"
6
+ require "json"
7
+ require "net/http"
8
+ require "uri"
9
+ require "bundler"
10
+
11
+ module Kettle
12
+ module Dev
13
+ class LockfileReset
14
+ DEFAULT_DISABLED_ENV = {
15
+ "KETTLE_DEV_DEV" => "false",
16
+ "K_JEM_TEMPLATING" => "false",
17
+ "STRUCTUREDMERGE_DEV" => "false",
18
+ "TREE_SITTER_LANGUAGE_PACK_DEV" => "false",
19
+ "RUBOCOP_LTS_LOCAL" => "false",
20
+ "GALTZO_FLOSS_DEV" => "false",
21
+ "UR_BRAIN_DEV" => "false"
22
+ }.freeze
23
+ RELEASE_LOCKFILES_TARGET = "release-lockfiles"
24
+ SUPPORTED_TARGETS = ["Gemfile.lock", "Appraisal.root.gemfile.lock", RELEASE_LOCKFILES_TARGET].freeze
25
+ UNBUNDLED_ENV_KEYS = %w[
26
+ BUNDLE_BIN_PATH
27
+ BUNDLE_FROZEN
28
+ BUNDLER_VERSION
29
+ RUBYOPT
30
+ ].freeze
31
+
32
+ def initialize(root:, command_runner:)
33
+ @root = root
34
+ @command_runner = command_runner
35
+ end
36
+
37
+ def reset(target)
38
+ paths = lockfile_paths_for(target)
39
+ force_full_update = release_lockfiles_target?(target)
40
+ uninstall_unreleased_local_gems(paths) if force_full_update
41
+ paths.each do |path|
42
+ reset_lockfile!(path, full_update: force_full_update) if force_full_update || normalization_needed?(path)
43
+ end
44
+ if force_full_update && uninstall_unreleased_local_gems(paths)
45
+ paths.each do |path|
46
+ reset_lockfile!(path, full_update: true)
47
+ end
48
+ end
49
+ diagnostics = paths.flat_map { |path| diagnostics(path) }
50
+ raise Error, validation_message(target, diagnostics) unless diagnostics.empty?
51
+
52
+ paths
53
+ end
54
+
55
+ def reset_lockfile!(path, full_update: false)
56
+ gemfile = gemfile_for_lockfile(path)
57
+ unless gemfile && File.file?(gemfile)
58
+ warn("Cannot reset #{display_path(path)} because its Gemfile was not found.")
59
+ return
60
+ end
61
+
62
+ command = reset_command(path: path, gemfile: gemfile, full_update: full_update)
63
+ if full_update || has_local_path_remote?(path)
64
+ rebuild_lockfile(path) { command_runner.call(command) }
65
+ else
66
+ command_runner.call(command)
67
+ end
68
+ end
69
+
70
+ def reset_command(path:, gemfile:, full_update: false)
71
+ update_gems = (full_update || has_local_path_remote?(path)) ? [] : reset_update_gems(path)
72
+ env = normalization_env.merge(
73
+ "BUNDLE_GEMFILE" => gemfile,
74
+ "BUNDLE_LOCKFILE" => path
75
+ )
76
+ command = +"env"
77
+ UNBUNDLED_ENV_KEYS.each do |key|
78
+ command << " -u #{key}"
79
+ end
80
+ env.each do |key, value|
81
+ command << " #{key}=#{Shellwords.escape(value)}"
82
+ end
83
+ command << " bundle lock"
84
+ command << " --update"
85
+ command << " #{update_gems.map { |gem_name| Shellwords.escape(gem_name) }.join(" ")}" unless update_gems.empty?
86
+ command << " --add-checksums"
87
+ command
88
+ end
89
+
90
+ def reset_update_gems(path)
91
+ (
92
+ path_source_gems(path).to_a |
93
+ path_dependency_gems(path).to_a |
94
+ empty_registry_checksums(path).map(&:first)
95
+ ).uniq.sort
96
+ end
97
+
98
+ def normalization_needed?(path)
99
+ has_local_path_remote?(path) || !empty_registry_checksums(path).empty?
100
+ end
101
+
102
+ def diagnostics(path)
103
+ diagnostics = []
104
+ local_path_remote_lines(path).each do |line_number|
105
+ diagnostics << "#{display_path(path)} has local path remote at line #{line_number}"
106
+ end
107
+ empty_registry_checksums(path).each do |name, version, line_number|
108
+ diagnostics << "#{display_path(path)} CHECKSUMS has no sha256 for #{name} #{version} at line #{line_number}"
109
+ end
110
+ diagnostics
111
+ end
112
+
113
+ def lockfile_paths
114
+ candidates = [
115
+ File.join(root, "Gemfile.lock"),
116
+ File.join(root, "Appraisal.root.gemfile.lock")
117
+ ]
118
+ candidates.select { |path| File.file?(path) }.sort
119
+ end
120
+
121
+ def lockfile_path_for(target)
122
+ paths = lockfile_paths_for(target)
123
+ raise Error, "reset target #{target.inspect} resolves to multiple lockfiles; use lockfile_paths_for" if paths.length != 1
124
+
125
+ paths.first
126
+ end
127
+
128
+ def lockfile_paths_for(target)
129
+ normalized = target.to_s.strip
130
+ raise Error, "reset requires TARGET" if normalized.empty?
131
+ if release_lockfiles_target?(normalized)
132
+ paths = lockfile_paths
133
+ raise Error, "no release lockfiles found" if paths.empty?
134
+
135
+ return paths
136
+ end
137
+ supported = SUPPORTED_TARGETS.reject { |candidate| candidate == RELEASE_LOCKFILES_TARGET }
138
+ match = supported.find { |candidate| candidate.casecmp(normalized).zero? }
139
+ unless match
140
+ raise Error, "reset target #{normalized.inspect} is not supported; supported targets: #{SUPPORTED_TARGETS.join(", ")}"
141
+ end
142
+
143
+ [File.join(root, match)]
144
+ end
145
+
146
+ def release_lockfiles_target?(target)
147
+ target.to_s.strip.casecmp(RELEASE_LOCKFILES_TARGET).zero?
148
+ end
149
+
150
+ def gemfile_for_lockfile(path)
151
+ basename = File.basename(path)
152
+ return File.join(root, "Gemfile") if basename == "Gemfile.lock"
153
+
154
+ path.delete_suffix(".lock")
155
+ end
156
+
157
+ def normalization_env
158
+ DEFAULT_DISABLED_ENV.merge(dynamic_local_path_env)
159
+ end
160
+
161
+ def dynamic_local_path_env
162
+ ENV.each_with_object({}) do |(key, value), env|
163
+ next unless key.end_with?("_DEV", "_LOCAL")
164
+ next unless local_path_env_value?(value)
165
+
166
+ env[key] = "false"
167
+ end
168
+ end
169
+
170
+ def local_path_env_value?(value)
171
+ text = value.to_s.strip
172
+ return false if text.empty? || text.casecmp("false").zero?
173
+ return true if text.start_with?("/", "./", "../", "~")
174
+
175
+ text.include?(File::SEPARATOR)
176
+ end
177
+
178
+ def has_local_path_remote?(path)
179
+ !local_path_remote_lines(path).empty?
180
+ end
181
+
182
+ def local_path_remote_lines(path)
183
+ in_path = false
184
+ File.readlines(path).filter_map.with_index(1) do |line, index|
185
+ stripped = line.strip
186
+ if stripped.match?(/\A[A-Z][A-Z ]*\z/)
187
+ in_path = stripped == "PATH"
188
+ next
189
+ end
190
+ next unless in_path
191
+ next unless stripped.start_with?("remote:")
192
+ next if stripped == "remote: ."
193
+ next unless stripped.start_with?("remote: /", "remote: ./", "remote: ../")
194
+
195
+ index
196
+ end
197
+ end
198
+
199
+ def empty_registry_checksums(path)
200
+ path_gems = path_source_gems(path) | path_dependency_gems(path) | self_path_source_gems(path)
201
+ in_checksums = false
202
+ entries = File.readlines(path).filter_map.with_index(1) do |line, index|
203
+ stripped = line.strip
204
+ if stripped == "CHECKSUMS"
205
+ in_checksums = true
206
+ next
207
+ end
208
+ next unless in_checksums
209
+ next if stripped.empty?
210
+ next if stripped == "BUNDLED WITH"
211
+
212
+ match = stripped.match(/\A([A-Za-z0-9_.-]+) \(([^)]+)\)(?:\s+(sha256=.*))?\z/)
213
+ next unless match
214
+
215
+ name = match[1]
216
+ checksum = match[3].to_s
217
+ next unless checksum.empty?
218
+ next if path_gems.include?(name)
219
+
220
+ [name, match[2], index]
221
+ end
222
+ return [] unless has_any_sha_checksum?(path)
223
+
224
+ entries
225
+ end
226
+
227
+ def has_any_sha_checksum?(path)
228
+ in_checksums = false
229
+ File.readlines(path).any? do |line|
230
+ stripped = line.strip
231
+ if stripped == "CHECKSUMS"
232
+ in_checksums = true
233
+ next false
234
+ end
235
+ next false unless in_checksums
236
+
237
+ stripped.include?("sha256=")
238
+ end
239
+ end
240
+
241
+ def path_source_gems(path)
242
+ lockfile_parser(path).specs.each_with_object(Set.new) do |spec, gems|
243
+ source = spec.source
244
+ gems << spec.name if path_source?(source) && !self_path_source?(source)
245
+ end
246
+ end
247
+
248
+ def path_dependency_gems(path)
249
+ lockfile_parser(path).dependencies.each_value.each_with_object(Set.new) do |dependency, gems|
250
+ source = dependency.source
251
+ gems << dependency.name if path_source?(source) && !self_path_source?(source)
252
+ end
253
+ end
254
+
255
+ def self_path_source_gems(path)
256
+ lockfile_parser(path).specs.each_with_object(Set.new) do |spec, gems|
257
+ gems << spec.name if self_path_source?(spec.source)
258
+ end
259
+ end
260
+
261
+ def display_path(path)
262
+ Kettle::Dev.display_path(path)
263
+ end
264
+
265
+ private
266
+
267
+ attr_reader :root, :command_runner
268
+
269
+ def lockfile_parser(path)
270
+ # Bundler validates checksum digest syntax while parsing, but this class
271
+ # only needs Bundler's source model here. Keep checksum diagnostics in
272
+ # the line scanner below so malformed or empty checksum entries can be
273
+ # reported without breaking source classification.
274
+ Bundler::LockfileParser.new(lockfile_source_content(path))
275
+ end
276
+
277
+ def lockfile_source_content(path)
278
+ in_checksums = false
279
+ File.readlines(path).filter_map do |line|
280
+ stripped = line.strip
281
+ if stripped == "CHECKSUMS"
282
+ in_checksums = true
283
+ next
284
+ end
285
+ if in_checksums && stripped.match?(/\A[A-Z][A-Z ]*\z/)
286
+ in_checksums = false
287
+ end
288
+ next if in_checksums
289
+
290
+ line
291
+ end.join
292
+ end
293
+
294
+ def path_source?(source)
295
+ source.instance_of?(::Bundler::Source::Path)
296
+ end
297
+
298
+ def self_path_source?(source)
299
+ path_source?(source) && source.path.to_s == "."
300
+ end
301
+
302
+ def rebuild_lockfile(path)
303
+ backup = backup_path_for(path)
304
+ FileUtils.mkdir_p(File.dirname(backup))
305
+ FileUtils.cp(path, backup)
306
+ FileUtils.rm_f(path)
307
+ yield
308
+ FileUtils.mv(backup, path, force: true) unless File.file?(path)
309
+ FileUtils.rm_f(backup)
310
+ rescue
311
+ FileUtils.mv(backup, path, force: true) if File.file?(backup)
312
+ raise
313
+ end
314
+
315
+ def backup_path_for(path)
316
+ backup_dir = File.join(root, "tmp", "kettle-reset")
317
+ "#{File.join(backup_dir, File.basename(path))}.#{Process.pid}.bak"
318
+ end
319
+
320
+ def uninstall_unreleased_local_gems(paths)
321
+ local_names = local_workspace_gem_names
322
+ return false if local_names.empty?
323
+
324
+ uninstalled = false
325
+ locked_registry_specs(paths).each do |name, version|
326
+ next unless local_names.include?(name)
327
+ next unless locally_installed?(name, version)
328
+ next if ruby_gems_version_available?(name, version)
329
+
330
+ uninstall_unreleased_local_gem(name, version)
331
+ uninstalled = true
332
+ end
333
+ uninstalled
334
+ end
335
+
336
+ def uninstall_unreleased_local_gem(name, version)
337
+ command_runner.call(uninstall_command(name, version))
338
+ rescue => error
339
+ # Family release waves can ask several member processes to remove the same
340
+ # unreleased sibling version. If another process won that race, the reset
341
+ # goal is already satisfied; otherwise keep the original uninstall failure.
342
+ raise error if locally_installed?(name, version)
343
+ end
344
+
345
+ def locked_registry_specs(paths)
346
+ paths.flat_map { |path| registry_specs(path) }.uniq.sort
347
+ end
348
+
349
+ def registry_specs(path)
350
+ specs = []
351
+ in_gem = false
352
+ in_specs = false
353
+ File.readlines(path).each do |line|
354
+ stripped = line.chomp
355
+ if stripped == "GEM"
356
+ in_gem = true
357
+ in_specs = false
358
+ next
359
+ end
360
+ next unless in_gem
361
+ break if !stripped.empty? && stripped == stripped.upcase && !stripped.start_with?(" ")
362
+
363
+ if stripped == " specs:"
364
+ in_specs = true
365
+ next
366
+ end
367
+ next unless in_specs
368
+
369
+ match = stripped.match(/\A ([A-Za-z0-9_.-]+) \(([^)]+)\)/)
370
+ specs << [match[1], match[2]] if match
371
+ end
372
+ specs
373
+ end
374
+
375
+ def locally_installed?(name, version)
376
+ Gem::Specification.find_all_by_name(name, "= #{version}").any?
377
+ rescue
378
+ false
379
+ end
380
+
381
+ def ruby_gems_version_available?(name, version)
382
+ version_text = version.to_s
383
+ platform = "ruby"
384
+ if version_text =~ /\A(.+)-([^-]+-[^-]+(?:-[^-]+)?)\z/
385
+ version_text = Regexp.last_match(1)
386
+ platform = Regexp.last_match(2)
387
+ end
388
+ ruby_gems_versions(name).any? do |entry|
389
+ entry.fetch("number", entry["version"]) == version_text &&
390
+ %W[ruby #{platform}].include?(entry.fetch("platform", "ruby").to_s)
391
+ end
392
+ end
393
+
394
+ def ruby_gems_versions(name)
395
+ @ruby_gems_versions ||= {}
396
+ @ruby_gems_versions.fetch(name) do
397
+ uri = URI("https://rubygems.org/api/v1/versions/#{URI.encode_www_form_component(name)}.json")
398
+ response = Net::HTTP.get_response(uri)
399
+ unless response.is_a?(Net::HTTPSuccess)
400
+ raise Error, "Could not verify released versions for #{name} from RubyGems.org (HTTP #{response.code})"
401
+ end
402
+
403
+ versions = JSON.parse(response.body)
404
+ @ruby_gems_versions[name] = versions
405
+ end
406
+ rescue JSON::ParserError => error
407
+ raise Error, "Could not parse released versions for #{name} from RubyGems.org: #{error.message}"
408
+ end
409
+
410
+ def uninstall_command(name, version)
411
+ command = +"env"
412
+ UNBUNDLED_ENV_KEYS.each do |key|
413
+ command << " -u #{key}"
414
+ end
415
+ command << " gem uninstall #{Shellwords.escape(name)} -v #{Shellwords.escape(version)} -x -I"
416
+ command
417
+ end
418
+
419
+ def local_workspace_gem_names
420
+ paths = dynamic_local_path_env.keys.filter_map do |key|
421
+ value = ENV[key].to_s.strip
422
+ value unless value.empty?
423
+ end
424
+ paths << File.expand_path("..", root)
425
+ paths.each_with_object(Set.new) do |path, names|
426
+ next unless File.directory?(path)
427
+
428
+ Dir[File.join(path, "*", "*.gemspec")].each do |gemspec|
429
+ names << File.basename(gemspec, ".gemspec")
430
+ end
431
+ end
432
+ end
433
+
434
+ def validation_message(target, diagnostics)
435
+ <<~MSG
436
+ Reset #{target} failed validation:
437
+ #{diagnostics.map { |diagnostic| " - #{diagnostic}" }.join("\n")}
438
+ MSG
439
+ end
440
+ end
441
+ end
442
+ end
@@ -7,12 +7,13 @@ minitest_files = FileList["test/*test*.rb", "test/**/*test*.rb"].to_a.uniq
7
7
  rspec_files = FileList["spec/*_spec.rb", "spec/**/*_spec.rb"].to_a.uniq
8
8
  minitest_project = !minitest_files.empty?
9
9
  rspec_project = !rspec_files.empty?
10
+ skip_test_tasks = Kettle::Dev.skip_test_tasks?
10
11
 
11
12
  # Set up MiniTest
12
13
  begin
13
14
  require "rake/testtask"
14
15
 
15
- if minitest_project && !Rake::Task.task_defined?(:test)
16
+ if minitest_project && !Rake::Task.task_defined?(:test) && !skip_test_tasks
16
17
  Rake::TestTask.new(:test) do |t|
17
18
  t.libs << "test"
18
19
  t.test_files = minitest_files
@@ -36,6 +37,11 @@ setup_spec_task = ->(default:) {
36
37
  unless Rake::Task.task_defined?(:spec)
37
38
  desc("Run RSpec code examples")
38
39
  task(:spec) do
40
+ if Kettle::Dev.skip_test_tasks?
41
+ puts "Skipping specs because KETTLE_DEV_SKIP_TESTS=true."
42
+ next
43
+ end
44
+
39
45
  begin
40
46
  skip_bundle_audit = ENV.delete("KETTLE_DEV_SKIP_BUNDLE_AUDIT")
41
47
  sh("bundle", "exec", "kettle-test")
@@ -56,7 +62,7 @@ setup_spec_task = ->(default:) {
56
62
 
57
63
  # Setup RSpec only when this project has specs, or when no MiniTest suite exists
58
64
  # and the historical kettle-test/RSpec default remains the best available task.
59
- if rspec_project || !minitest_project
65
+ if !skip_test_tasks && (rspec_project || !minitest_project)
60
66
  if defined?(Kettle::Dev::IS_CI)
61
67
  if Kettle::Dev::IS_CI
62
68
  # then we should not have a coverage task, but do want a spec test.
@@ -25,6 +25,17 @@ begin
25
25
  # yard-timekeeper not available - that's fine
26
26
  end
27
27
 
28
+ namespace :yard do
29
+ desc "Lint YARD Documentation"
30
+ task :lint do
31
+ # Keep warning-only lint runs compact in default/release flows, but rerun
32
+ # with full output when lint fails so the blocking diagnostics are visible.
33
+ sh("bundle", "exec", "yard-lint", "lib") unless system("bundle", "exec", "yard-lint", "--quiet", "lib")
34
+ end
35
+ end
36
+
37
+ task yard: "yard:lint"
38
+ Kettle::Dev.register_default("yard:lint")
28
39
  Kettle::Dev.register_default("yard")
29
40
  rescue LoadError
30
41
  warn("[kettle-dev][yard.rake] failed to load yard") if Kettle::Dev::DEBUGGING