kettle-dev 1.1.11 → 1.1.13

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.
@@ -283,6 +283,7 @@ module Kettle
283
283
  # Example match: "- COVERAGE: 97.70% -- 2125/2175 lines in 20 files"
284
284
  m = section.lines.find { |l| l =~ /-\s*COVERAGE:\s*.+--\s*\d+\/(\d+)\s+lines/i }
285
285
  return unless m
286
+
286
287
  denom = m.match(/-\s*COVERAGE:\s*.+--\s*\d+\/(\d+)\s+lines/i)[1].to_i
287
288
  kloc = denom.to_f / 1000.0
288
289
  kloc_str = format("%.3f", kloc)
@@ -296,6 +297,7 @@ module Kettle
296
297
  # Replaces only the numeric portion after "KLOC-" keeping other URL parts intact.
297
298
  def update_badge_number_in_file(path, kloc_str)
298
299
  return unless File.file?(path)
300
+
299
301
  content = File.read(path)
300
302
  # Match the specific reference line, capture groups around the number
301
303
  # Example: [🧮kloc-img]: https://img.shields.io/badge/KLOC-2.175-FFDD67.svg?style=...
@@ -310,6 +312,7 @@ module Kettle
310
312
  def update_rakefile_example_header!(version)
311
313
  path = File.join(@root, "Rakefile.example")
312
314
  return unless File.file?(path)
315
+
313
316
  content = File.read(path)
314
317
  today = Time.now.strftime("%Y-%m-%d")
315
318
  new_line = "# kettle-dev Rakefile v#{version} - #{today}"
@@ -394,6 +397,7 @@ module Kettle
394
397
  def collapse_years(enum)
395
398
  arr = enum.to_a.map(&:to_i).uniq.sort
396
399
  return "" if arr.empty?
400
+
397
401
  segments = []
398
402
  start = arr.first
399
403
  prev = start
@@ -422,10 +426,12 @@ module Kettle
422
426
  unless line =~ /copyright/i
423
427
  next line
424
428
  end
429
+
425
430
  m = line.match(/\A(?<pre>.*?copyright[^0-9]*)(?<years>(?:\b(?:19|20)\d{2}\b(?:\s*[\-–]\s*\b(?:19|20)\d{2}\b)?)(?:\s*,\s*\b(?:19|20)\d{2}\b(?:\s*[\-–]\s*\b(?:19|20)\d{2}\b)?)*)(?<post>.*)\z/i)
426
431
  unless m
427
432
  next line
428
433
  end
434
+
429
435
  new_line = "#{m[:pre]}#{canonical_all}#{m[:post]}"
430
436
  changed ||= (new_line != line)
431
437
  new_line
@@ -444,12 +450,14 @@ module Kettle
444
450
  unless line =~ /copyright/i
445
451
  next line
446
452
  end
453
+
447
454
  # Capture three parts: prefix up to first year, the year blob, and the rest
448
455
  m = line.match(/\A(?<pre>.*?copyright[^0-9]*)(?<years>(?:\b(?:19|20)\d{2}\b(?:\s*[\-–]\s*\b(?:19|20)\d{2}\b)?)(?:\s*,\s*\b(?:19|20)\d{2}\b(?:\s*[\-–]\s*\b(?:19|20)\d{2}\b)?)*)(?<post>.*)\z/i)
449
456
  unless m
450
457
  # No parsable year sequence on this line; leave as-is
451
458
  next line
452
459
  end
460
+
453
461
  years_blob = m[:years]
454
462
  # Reuse extraction logic on just the years blob
455
463
  years = []
@@ -731,15 +739,18 @@ module Kettle
731
739
  def preferred_github_remote
732
740
  cands = github_remote_candidates
733
741
  return if cands.empty?
742
+
734
743
  # Prefer explicitly named GitHub remotes first, then origin (only if it points to GitHub), else the first candidate
735
744
  explicit = cands.find { |n| n == "github" } || cands.find { |n| n == "gh" }
736
745
  return explicit if explicit
737
746
  return "origin" if cands.include?("origin")
747
+
738
748
  cands.first
739
749
  end
740
750
 
741
751
  def parse_github_owner_repo(url)
742
752
  return [nil, nil] unless url
753
+
743
754
  if url =~ %r{git@github.com:(.+?)/(.+?)(\.git)?$}
744
755
  [Regexp.last_match(1), Regexp.last_match(2).sub(/\.git\z/, "")]
745
756
  elsif url =~ %r{https://github.com/(.+?)/(.+?)(\.git)?$}
@@ -761,6 +772,7 @@ module Kettle
761
772
  def ahead_behind_counts(local_ref, remote_ref)
762
773
  out, ok = git_output(["rev-list", "--left-right", "--count", "#{local_ref}...#{remote_ref}"])
763
774
  return [0, 0] unless ok && !out.empty?
775
+
764
776
  parts = out.split
765
777
  left = parts[0].to_i
766
778
  right = parts[1].to_i
@@ -769,6 +781,7 @@ module Kettle
769
781
 
770
782
  def trunk_behind_remote?(trunk, remote)
771
783
  return false unless remote_branch_exists?(remote, trunk)
784
+
772
785
  _ahead, behind = ahead_behind_counts(trunk, "#{remote}/#{trunk}")
773
786
  behind.positive?
774
787
  end
@@ -781,6 +794,7 @@ module Kettle
781
794
  missing_from = []
782
795
  remotes.each do |r|
783
796
  next if r == "all"
797
+
784
798
  if remote_branch_exists?(r, trunk)
785
799
  _ahead, behind = ahead_behind_counts(trunk, "#{r}/#{trunk}")
786
800
  missing_from << r if behind.positive?
@@ -853,6 +867,7 @@ module Kettle
853
867
 
854
868
  def merge_feature_into_trunk_and_push!(trunk, feature)
855
869
  return if feature.nil? || feature == trunk
870
+
856
871
  puts "Merging #{feature} into #{trunk} (after CI success)..."
857
872
  checkout!(trunk)
858
873
  run_cmd!("git pull --rebase origin #{Shellwords.escape(trunk)}")
@@ -971,12 +986,14 @@ module Kettle
971
986
  def extract_changelog_for_version(version)
972
987
  path = File.join(@root, "CHANGELOG.md")
973
988
  return [nil, nil, nil] unless File.file?(path)
989
+
974
990
  content = File.read(path)
975
991
  lines = content.lines
976
992
 
977
993
  # Find section start
978
994
  start_idx = lines.index { |l| l.start_with?("## [#{version}]") }
979
995
  return [nil, nil, nil] unless start_idx
996
+
980
997
  i = start_idx + 1
981
998
  # Find next section heading or EOF
982
999
  while i < lines.length && !lines[i].start_with?("## [")
@@ -999,12 +1016,14 @@ module Kettle
999
1016
  def extract_release_notes_footer
1000
1017
  path = File.join(@root, "FUNDING.md")
1001
1018
  return unless File.file?(path)
1019
+
1002
1020
  content = File.read(path)
1003
1021
  start_tag = "<!-- RELEASE-NOTES-FOOTER-START -->"
1004
1022
  end_tag = "<!-- RELEASE-NOTES-FOOTER-END -->"
1005
1023
  s = content.index(start_tag)
1006
1024
  e = content.index(end_tag)
1007
1025
  return unless s && e && e > s
1026
+
1008
1027
  # Extract between tags, excluding the tags themselves
1009
1028
  block = content[(s + start_tag.length)...e]
1010
1029
  # Normalize: trim trailing whitespace but keep internal formatting
@@ -31,6 +31,8 @@ module Kettle
31
31
  say("Starting kettle-dev setup…")
32
32
  prechecks!
33
33
  ensure_dev_deps!
34
+ ensure_gemfile_from_example!
35
+ ensure_modular_gemfiles!
34
36
  ensure_bin_setup!
35
37
  ensure_rakefile!
36
38
  run_bin_setup!
@@ -44,14 +46,72 @@ module Kettle
44
46
 
45
47
  def debug(msg)
46
48
  return if ENV.fetch("DEBUG", "false").casecmp("true").nonzero?
49
+
47
50
  $stderr.puts("[kettle-dev-setup] DEBUG: #{msg}")
48
51
  end
49
52
 
53
+ # Attempt to derive a funding organization from the git remote 'origin' when
54
+ # not explicitly provided via env or .opencollective.yml.
55
+ # This is a soft helper that only sets ENV["FUNDING_ORG"] if a plausible
56
+ # GitHub org can be parsed from the origin URL.
57
+ # @return [void]
58
+ def derive_funding_org_from_git_if_missing!
59
+ # Respect explicit bypass
60
+ env_val = ENV["FUNDING_ORG"]
61
+ return if env_val && env_val.to_s.strip.casecmp("false").zero?
62
+
63
+ # If already provided via env, do nothing
64
+ return if ENV["FUNDING_ORG"].to_s.strip != ""
65
+ return if ENV["OPENCOLLECTIVE_HANDLE"].to_s.strip != ""
66
+
67
+ # If project provides an .opencollective.yml with org, do nothing
68
+ begin
69
+ oc_path = File.join(Dir.pwd, ".opencollective.yml")
70
+ if File.file?(oc_path)
71
+ txt = File.read(oc_path)
72
+ return if txt =~ /\borg:\s*([\w\-]+)/i
73
+ end
74
+ rescue StandardError => e
75
+ debug("Reading .opencollective.yml failed: #{e.class}: #{e.message}")
76
+ end
77
+
78
+ # Attempt to get origin URL and parse GitHub org
79
+ begin
80
+ ga = Kettle::Dev::GitAdapter.new
81
+ origin_url = nil
82
+ origin_url = ga.remote_url("origin") if ga.respond_to?(:remote_url)
83
+ if origin_url.nil? && ga.respond_to?(:remotes_with_urls)
84
+ begin
85
+ urls = ga.remotes_with_urls
86
+ origin_url = urls["origin"] if urls
87
+ rescue StandardError => e
88
+ # graceful fallback if adapter backend errs; keep silent behavior
89
+ debug("remotes_with_urls failed: #{e.class}: #{e.message}")
90
+ end
91
+ end
92
+ origin_url = origin_url.to_s.strip
93
+ if (m = origin_url.match(%r{github\.com[/:]([^/]+)/}i))
94
+ org = m[1].to_s
95
+ if !org.empty?
96
+ ENV["FUNDING_ORG"] = org
97
+ debug("Derived FUNDING_ORG from git origin: #{org}")
98
+ end
99
+ end
100
+ rescue StandardError => e
101
+ # Be silent; this is a best-effort and shouldn't fail setup
102
+ debug("Could not derive funding org from git: #{e.class}: #{e.message}")
103
+ end
104
+ end
105
+
50
106
  def parse!
51
107
  parser = OptionParser.new do |opts|
52
108
  opts.banner = "Usage: kettle-dev-setup [options]"
53
109
  opts.on("--allowed=VAL", "Pass through to kettle:dev:install") { |v| @passthrough << "allowed=#{v}" }
54
- opts.on("--force", "Pass through to kettle:dev:install") { @passthrough << "force=true" }
110
+ opts.on("--force", "Pass through to kettle:dev:install") do
111
+ # Ensure in-process helpers (TemplateHelpers.ask) also see force mode
112
+ ENV["force"] = "true"
113
+ @passthrough << "force=true"
114
+ end
55
115
  opts.on("--hook_templates=VAL", "Pass through to kettle:dev:install") { |v| @passthrough << "hook_templates=#{v}" }
56
116
  opts.on("--only=VAL", "Pass through to kettle:dev:install") { |v| @passthrough << "only=#{v}" }
57
117
  opts.on("-h", "--help", "Show help") do
@@ -110,6 +170,9 @@ module Kettle
110
170
 
111
171
  # Gemfile
112
172
  abort!("No Gemfile found; bundler is required.") unless File.exist?("Gemfile")
173
+
174
+ # Seed FUNDING_ORG from git remote origin org when not provided elsewhere
175
+ derive_funding_org_from_git_if_missing!
113
176
  end
114
177
 
115
178
  # 3. Sync dev dependencies from this gem's example gemspec into target gemspec
@@ -181,6 +244,103 @@ module Kettle
181
244
  say("Copied bin/setup.")
182
245
  end
183
246
 
247
+ # 3b. Ensure Gemfile contains required lines from example without duplicating directives
248
+ # - Copies source, git_source, gemspec, and eval_gemfile lines that are missing
249
+ # - Idempotent (running multiple times does not duplicate entries)
250
+ def ensure_gemfile_from_example!
251
+ source_path = installed_path("Gemfile.example")
252
+ abort!("Internal error: Gemfile.example not found within installed gem.") unless source_path && File.exist?(source_path)
253
+
254
+ example = File.read(source_path)
255
+ target_path = "Gemfile"
256
+ target = File.exist?(target_path) ? File.read(target_path) : ""
257
+
258
+ # Extract interesting lines from example
259
+ ex_sources = []
260
+ ex_git_sources = [] # names (e.g., :github)
261
+ ex_git_source_lines = {}
262
+ ex_has_gemspec = false
263
+ ex_eval_paths = []
264
+
265
+ example.each_line do |ln|
266
+ s = ln.strip
267
+ next if s.empty?
268
+
269
+ if s.start_with?("source ")
270
+ ex_sources << ln.rstrip
271
+ elsif (m = s.match(/^git_source\(\s*:(\w+)\s*\)/))
272
+ name = m[1]
273
+ ex_git_sources << name
274
+ ex_git_source_lines[name] = ln.rstrip
275
+ elsif s.start_with?("gemspec")
276
+ ex_has_gemspec = true
277
+ elsif (m = s.match(/^eval_gemfile\s+["']([^"']+)["']/))
278
+ ex_eval_paths << m[1]
279
+ end
280
+ end
281
+
282
+ # Scan target for presence
283
+ tg_sources = target.each_line.map(&:rstrip).select { |l| l.strip.start_with?("source ") }
284
+ tg_git_sources = {}
285
+ target.each_line do |ln|
286
+ if (m = ln.strip.match(/^git_source\(\s*:(\w+)\s*\)/))
287
+ tg_git_sources[m[1]] = true
288
+ end
289
+ end
290
+ tg_has_gemspec = !!target.each_line.find { |l| l.strip.start_with?("gemspec") }
291
+ tg_eval_paths = target.each_line.map do |ln|
292
+ if (m = ln.strip.match(/^eval_gemfile\s+["']([^"']+)["']/))
293
+ m[1]
294
+ end
295
+ end.compact
296
+
297
+ additions = []
298
+ # Add missing sources (exact line match)
299
+ ex_sources.each do |src_line|
300
+ additions << src_line unless tg_sources.include?(src_line)
301
+ end
302
+ # Add missing git_source by name
303
+ ex_git_sources.each do |name|
304
+ additions << ex_git_source_lines[name] unless tg_git_sources[name]
305
+ end
306
+ # Add gemspec if example has it and target lacks it
307
+ additions << "gemspec" if ex_has_gemspec && !tg_has_gemspec
308
+ # Add missing eval_gemfile paths (recreate the exact example line when possible)
309
+ ex_eval_paths.each do |path|
310
+ next if tg_eval_paths.include?(path)
311
+
312
+ additions << "eval_gemfile \"#{path}\""
313
+ end
314
+
315
+ return say("Gemfile already contains required entries from example.") if additions.empty?
316
+
317
+ # Ensure file ends with a newline
318
+ target << "\n" unless target.end_with?("\n") || target.empty?
319
+ new_content = target + additions.join("\n") + "\n"
320
+ File.write(target_path, new_content)
321
+ say("Updated Gemfile with entries from Gemfile.example (added #{additions.size}).")
322
+ end
323
+
324
+ # 3c. Ensure gemfiles/modular/* are present (copied like template task)
325
+ def ensure_modular_gemfiles!
326
+ helpers = Kettle::Dev::TemplateHelpers
327
+ project_root = helpers.project_root
328
+ gem_checkout_root = helpers.gem_checkout_root
329
+ # Gather min_ruby for style.gemfile adjustments
330
+ min_ruby = begin
331
+ md = helpers.gemspec_metadata(project_root)
332
+ md[:min_ruby]
333
+ rescue StandardError
334
+ nil
335
+ end
336
+ Kettle::Dev::ModularGemfiles.sync!(
337
+ helpers: helpers,
338
+ project_root: project_root,
339
+ gem_checkout_root: gem_checkout_root,
340
+ min_ruby: min_ruby,
341
+ )
342
+ end
343
+
184
344
  # 5. Ensure Rakefile matches example (replace or create)
185
345
  def ensure_rakefile!
186
346
  source = installed_path("Rakefile.example")
@@ -245,6 +405,7 @@ module Kettle
245
405
  here = File.expand_path(File.join(__dir__, "..", "..", "..")) # lib/kettle/dev/ -> project root
246
406
  path = File.join(here, rel)
247
407
  return path if File.exist?(path)
408
+
248
409
  nil
249
410
  end
250
411
  end
@@ -174,14 +174,19 @@ module Kettle
174
174
  end
175
175
  end
176
176
 
177
- # If no grapheme found in README H1, ask the user which to use
177
+ # If no grapheme found in README H1, either use a default in force mode, or ask the user.
178
178
  if chosen_grapheme.nil? || chosen_grapheme.empty?
179
- puts "No grapheme found after README H1. Enter a grapheme (emoji/symbol) to use for README, summary, and description:"
180
- print("Grapheme: ")
181
- ans = Kettle::Dev::InputAdapter.gets&.strip.to_s
182
- chosen_grapheme = ans[/\A\X/u].to_s
183
- # If still empty, skip synchronization silently
184
- chosen_grapheme = nil if chosen_grapheme.empty?
179
+ if ENV.fetch("force", "").to_s =~ ENV_TRUE_RE
180
+ # Non-interactive install: default to pizza slice to match template style.
181
+ chosen_grapheme = "🍕"
182
+ else
183
+ puts "No grapheme found after README H1. Enter a grapheme (emoji/symbol) to use for README, summary, and description:"
184
+ print("Grapheme: ")
185
+ ans = Kettle::Dev::InputAdapter.gets&.strip.to_s
186
+ chosen_grapheme = ans[/\A\X/u].to_s
187
+ # If still empty, skip synchronization silently
188
+ chosen_grapheme = nil if chosen_grapheme.empty?
189
+ end
185
190
  end
186
191
 
187
192
  if chosen_grapheme
@@ -311,15 +316,18 @@ module Kettle
311
316
 
312
317
  github_repo_from_url = lambda do |url|
313
318
  return unless url
319
+
314
320
  url = url.strip
315
321
  m = url.match(%r{github\.com[/:]([^/\s:]+)/([^/\s]+?)(?:\.git)?/?\z}i)
316
322
  return unless m
323
+
317
324
  [m[1], m[2]]
318
325
  end
319
326
 
320
327
  github_homepage_literal = lambda do |val|
321
328
  return false unless val
322
329
  return false if val.include?('#{')
330
+
323
331
  v = val.to_s.strip
324
332
  if (v.start_with?("\"") && v.end_with?("\"")) || (v.start_with?("'") && v.end_with?("'"))
325
333
  v = begin
@@ -329,6 +337,7 @@ module Kettle
329
337
  end
330
338
  end
331
339
  return false unless v =~ %r{\Ahttps?://github\.com/}i
340
+
332
341
  !!github_repo_from_url.call(v)
333
342
  end
334
343
 
@@ -364,12 +373,13 @@ module Kettle
364
373
  puts "Current spec.homepage appears #{interpolated ? "interpolated" : "invalid"}: #{assigned}"
365
374
  puts "Suggested literal homepage: \"#{suggested}\""
366
375
  print("Update #{File.basename(gemspec_path)} to use this homepage? [Y/n]: ")
367
- ans = Kettle::Dev::InputAdapter.gets&.strip
368
- do_update = if ENV.fetch("force", "").to_s =~ ENV_TRUE_RE
369
- true
370
- else
371
- ans.nil? || ans.empty? || ans =~ /\Ay(es)?\z/i
372
- end
376
+ do_update =
377
+ if ENV.fetch("force", "").to_s =~ ENV_TRUE_RE
378
+ true
379
+ else
380
+ ans = Kettle::Dev::InputAdapter.gets&.strip
381
+ ans.nil? || ans.empty? || ans =~ /\Ay(es)?\z/i
382
+ end
373
383
 
374
384
  if do_update
375
385
  new_line = homepage_line.sub(/=.*/, "= \"#{suggested}\"\n")
@@ -385,6 +395,7 @@ module Kettle
385
395
  rescue StandardError => e
386
396
  # Do not swallow intentional task aborts signaled via Kettle::Dev::Error
387
397
  raise if e.is_a?(Kettle::Dev::Error)
398
+
388
399
  puts "WARNING: An error occurred while checking gemspec homepage: #{e.class}: #{e.message}"
389
400
  end
390
401
 
@@ -406,6 +417,7 @@ module Kettle
406
417
  [:create, :replace, :dir_create, :dir_replace].each do |sym|
407
418
  items = meaningful.select { |_, rec| rec[:action] == sym }.map { |path, _| path }
408
419
  next if items.empty?
420
+
409
421
  puts " #{action_labels[sym]}:"
410
422
  items.sort.each do |abs|
411
423
  rel = begin
@@ -472,10 +484,8 @@ module Kettle
472
484
 
473
485
  if defined?(updated_envrc_by_install) && updated_envrc_by_install
474
486
  allowed_truthy = ENV.fetch("allowed", "").to_s =~ ENV_TRUE_RE
475
- force_truthy = ENV.fetch("force", "").to_s =~ ENV_TRUE_RE
476
- if allowed_truthy || force_truthy
477
- reason = allowed_truthy ? "allowed=true" : "force=true"
478
- puts "Proceeding after .envrc update because #{reason}."
487
+ if allowed_truthy
488
+ puts "Proceeding after .envrc update because allowed=true."
479
489
  else
480
490
  puts
481
491
  puts "IMPORTANT: .envrc was updated during kettle:dev:install."
@@ -509,7 +519,10 @@ module Kettle
509
519
  puts "Would you like to add '.env.local' to #{gitignore_path}?"
510
520
  print("Add to .gitignore now [Y/n]: ")
511
521
  answer = Kettle::Dev::InputAdapter.gets&.strip
512
- add_it = if ENV.fetch("force", "").to_s =~ ENV_TRUE_RE
522
+ # Respect an explicit negative answer even when force=true
523
+ add_it = if answer && answer =~ /\An(o)?\z/i
524
+ false
525
+ elsif ENV.fetch("force", "").to_s =~ ENV_TRUE_RE
513
526
  true
514
527
  else
515
528
  answer.nil? || answer.empty? || answer =~ /\Ay(es)?\z/i
@@ -104,91 +104,13 @@ module Kettle
104
104
  allow_replace: true,
105
105
  )
106
106
 
107
- # 4a) gemfiles/modular/*.gemfile
108
- # from gem's gemfiles/modular,
109
- # except `style.gemfile` which has special handling below
110
- modular_gemfiles = %w[
111
- coverage
112
- debug
113
- documentation
114
- injected
115
- optional
116
- runtime_heads
117
- x_std_libs
118
- ]
119
- modular_gemfiles.each do |base|
120
- modular_gemfile = "#{base}.gemfile"
121
- src = helpers.prefer_example(File.join(gem_checkout_root, MODULAR_GEMFILE_DIR, modular_gemfile))
122
- dest = File.join(project_root, MODULAR_GEMFILE_DIR, modular_gemfile)
123
- helpers.copy_file_with_prompt(src, dest, allow_create: true, allow_replace: true)
124
- end
125
-
126
- # 4b) gemfiles/modular/style.gemfile
127
- modular_gemfile = "style.gemfile"
128
- src = helpers.prefer_example(File.join(gem_checkout_root, MODULAR_GEMFILE_DIR, modular_gemfile))
129
- dest = File.join(project_root, MODULAR_GEMFILE_DIR, modular_gemfile)
130
- if File.basename(src).sub(/\.example\z/, "") == "style.gemfile"
131
- helpers.copy_file_with_prompt(src, dest, allow_create: true, allow_replace: true) do |content|
132
- # Adjust rubocop-lts constraint based on min_ruby
133
- version_map = [
134
- [Gem::Version.new("1.8"), "~> 0.1"],
135
- [Gem::Version.new("1.9"), "~> 2.0"],
136
- [Gem::Version.new("2.0"), "~> 4.0"],
137
- [Gem::Version.new("2.1"), "~> 6.0"],
138
- [Gem::Version.new("2.2"), "~> 8.0"],
139
- [Gem::Version.new("2.3"), "~> 10.0"],
140
- [Gem::Version.new("2.4"), "~> 12.0"],
141
- [Gem::Version.new("2.5"), "~> 14.0"],
142
- [Gem::Version.new("2.6"), "~> 16.0"],
143
- [Gem::Version.new("2.7"), "~> 18.0"],
144
- [Gem::Version.new("3.0"), "~> 20.0"],
145
- [Gem::Version.new("3.1"), "~> 22.0"],
146
- [Gem::Version.new("3.2"), "~> 24.0"],
147
- [Gem::Version.new("3.3"), "~> 26.0"],
148
- [Gem::Version.new("3.4"), "~> 28.0"],
149
- ]
150
- new_constraint = nil
151
- rubocop_ruby_gem_version = nil
152
- ruby1_8 = version_map.first
153
- begin
154
- if min_ruby
155
- version_map.reverse_each do |min, req|
156
- if min_ruby >= min
157
- new_constraint = req
158
- rubocop_ruby_gem_version = min.segments.join("_")
159
- break
160
- end
161
- end
162
- end
163
- if !new_constraint || !rubocop_ruby_gem_version
164
- # A gem with no declared minimum ruby is effectively >= 1.8.7
165
- new_constraint = ruby1_8[1]
166
- rubocop_ruby_gem_version = ruby1_8[0].segments.join("_")
167
- end
168
- rescue StandardError => e
169
- Kettle::Dev.debug_error(e, __method__)
170
- # ignore, use default
171
- ensure
172
- new_constraint ||= ruby1_8[1]
173
- rubocop_ruby_gem_version ||= ruby1_8[0].segments.join("_")
174
- end
175
- if new_constraint && rubocop_ruby_gem_version
176
- token = "{RUBOCOP|LTS|CONSTRAINT}"
177
- content.gsub!(token, new_constraint) if content.include?(token)
178
- token = "{RUBOCOP|RUBY|GEM}"
179
- content.gsub!(token, "rubocop-ruby#{rubocop_ruby_gem_version}") if content.include?(token)
180
- end
181
- end
182
- else
183
- helpers.copy_file_with_prompt(src, dest, allow_create: true, allow_replace: true)
184
- end
185
-
186
- # 4c) Copy modular directories with nested/versioned files
187
- %w[erb mutex_m stringio x_std_libs].each do |dir|
188
- src_dir = File.join(gem_checkout_root, MODULAR_GEMFILE_DIR, dir)
189
- dest_dir = File.join(project_root, MODULAR_GEMFILE_DIR, dir)
190
- helpers.copy_dir_with_prompt(src_dir, dest_dir)
191
- end
107
+ # 4) gemfiles/modular/* and nested directories (delegated for DRYness)
108
+ Kettle::Dev::ModularGemfiles.sync!(
109
+ helpers: helpers,
110
+ project_root: project_root,
111
+ gem_checkout_root: gem_checkout_root,
112
+ min_ruby: min_ruby,
113
+ )
192
114
 
193
115
  # 5) spec/spec_helper.rb (no create)
194
116
  dest_spec_helper = File.join(project_root, "spec/spec_helper.rb")
@@ -427,6 +349,7 @@ module Kettle
427
349
  src = helpers.prefer_example(File.join(gem_checkout_root, rel))
428
350
  dest = File.join(project_root, rel)
429
351
  next unless File.exist?(src)
352
+
430
353
  if File.basename(rel) == "README.md"
431
354
  # Precompute destination README H1 prefix (emoji(s) or first grapheme) before any overwrite occurs
432
355
  prev_readme = File.exist?(dest) ? File.read(dest) : nil
@@ -442,6 +365,7 @@ module Kettle
442
365
  loop do
443
366
  cluster = s[/\A\X/u]
444
367
  break if cluster.nil? || cluster.empty?
368
+
445
369
  if emoji_re =~ cluster
446
370
  out << cluster
447
371
  s = s[cluster.length..-1].to_s
@@ -481,6 +405,7 @@ module Kettle
481
405
  # Parse Markdown headings while ignoring fenced code blocks (``` ... ```)
482
406
  build_sections = lambda do |md|
483
407
  return {lines: [], sections: [], line_count: 0} unless md
408
+
484
409
  lines = md.split("\n", -1)
485
410
  line_count = lines.length
486
411
 
@@ -494,6 +419,7 @@ module Kettle
494
419
  next
495
420
  end
496
421
  next if in_code
422
+
497
423
  if (m = ln.match(/^(#+)\s+.+/))
498
424
  level = m[1].length
499
425
  title = ln.sub(/^#+\s+/, "")
@@ -527,6 +453,7 @@ module Kettle
527
453
  j = i + 1
528
454
  while j < sections_arr.length
529
455
  return sections_arr[j][:start] - 1 if sections_arr[j][:level] <= current[:level]
456
+
530
457
  j += 1
531
458
  end
532
459
  total_lines - 1
@@ -542,6 +469,7 @@ module Kettle
542
469
  base = s[:base]
543
470
  # Only set once (first occurrence wins)
544
471
  next if dest_lookup.key?(base)
472
+
545
473
  be = branch_end_index.call(dest_parsed[:sections], idx, dest_parsed[:line_count])
546
474
  body_lines = dest_parsed[:lines][(s[:start] + 1)..be] || []
547
475
  dest_lookup[base] = {body_branch: body_lines.join("\n"), level: s[:level]}
@@ -563,6 +491,7 @@ module Kettle
563
491
  # Iterate in reverse to keep indices valid
564
492
  src_parsed[:sections].reverse_each.with_index do |sec, rev_i|
565
493
  next unless targets.include?(sec[:base])
494
+
566
495
  # Determine branch range in src for this section
567
496
  # rev_i is reverse index; compute forward index
568
497
  i = src_parsed[:sections].length - 1 - rev_i
@@ -730,6 +659,7 @@ module Kettle
730
659
  rescue StandardError => e
731
660
  # Do not swallow intentional task aborts
732
661
  raise if e.is_a?(Kettle::Dev::Error)
662
+
733
663
  puts "WARNING: Could not determine env file changes: #{e.class}: #{e.message}"
734
664
  end
735
665
 
@@ -831,6 +761,7 @@ module Kettle
831
761
  hook_pairs = [[hook_ruby_src, "commit-msg", 0o755], [hook_sh_src, "prepare-commit-msg", 0o755]]
832
762
  hook_pairs.each do |src, base, mode|
833
763
  next unless File.file?(src)
764
+
834
765
  hook_dests.each do |dstdir|
835
766
  begin
836
767
  FileUtils.mkdir_p(dstdir)
@@ -42,9 +42,13 @@ module Kettle
42
42
  print("#{prompt} #{default ? "[Y/n]" : "[y/N]"}: ")
43
43
  ans = Kettle::Dev::InputAdapter.gets&.strip
44
44
  ans = "" if ans.nil?
45
+ # Normalize explicit no first
46
+ return false if ans =~ /\An(o)?\z/i
45
47
  if default
48
+ # Empty -> default true; explicit yes -> true; anything else -> false
46
49
  ans.empty? || ans =~ /\Ay(es)?\z/i
47
50
  else
51
+ # Empty -> default false; explicit yes -> true; others (including garbage) -> false
48
52
  ans =~ /\Ay(es)?\z/i
49
53
  end
50
54
  end
@@ -6,7 +6,7 @@ module Kettle
6
6
  module Version
7
7
  # The gem version.
8
8
  # @return [String]
9
- VERSION = "1.1.11"
9
+ VERSION = "1.1.13"
10
10
 
11
11
  module_function
12
12
 
@@ -16,6 +16,7 @@ module Kettle
16
16
  content = File.read(path)
17
17
  m = content.match(/VERSION\s*=\s*(["'])([^"']+)\1/)
18
18
  next unless m
19
+
19
20
  m[2]
20
21
  end.compact
21
22
  abort!("VERSION constant not found in #{root}/lib/**/version.rb") if versions.none?
@@ -39,6 +40,7 @@ module Kettle
39
40
 
40
41
  if cmaj > pmaj
41
42
  return :epic if cmaj && cmaj > 1000
43
+
42
44
  :major
43
45
  elsif cmin > pmin
44
46
  :minor
data/lib/kettle/dev.rb CHANGED
@@ -26,6 +26,7 @@ module Kettle
26
26
  autoload :PreReleaseCLI, "kettle/dev/pre_release_cli"
27
27
  autoload :SetupCLI, "kettle/dev/setup_cli"
28
28
  autoload :TemplateHelpers, "kettle/dev/template_helpers"
29
+ autoload :ModularGemfiles, "kettle/dev/modular_gemfiles"
29
30
  autoload :Version, "kettle/dev/version"
30
31
  autoload :Versioning, "kettle/dev/versioning"
31
32