kettle-dev 2.0.0 → 2.0.2

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.
@@ -1,6 +1,10 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  require "open3"
4
+ require "json"
5
+ require "net/http"
6
+ require "uri"
7
+ require "fileutils"
4
8
 
5
9
  module Kettle
6
10
  module Dev
@@ -10,15 +14,23 @@ module Kettle
10
14
  # includes coverage and YARD stats, and updates link references.
11
15
  class ChangelogCLI
12
16
  UNRELEASED_SECTION_HEADING = "[Unreleased]:"
17
+ # Matches a Markdown link-reference definition line, e.g. `[key]: https://...`
18
+ LINK_REF_DEF_RE = /^\s*\[[^\]]+\]:\s+\S+/
19
+ # Matches an ATX heading at H4 or deeper (####, #####, ...)
20
+ DEEP_HEADING_RE = /^[#]{4,}\s/
13
21
 
14
22
  # Initialize the changelog CLI
15
23
  # Sets up paths for CHANGELOG.md and coverage.json
16
24
  # @param strict [Boolean] when true (default), require coverage and yard data; raise errors if unavailable
17
- def initialize(strict: true)
25
+ # @param enforce_coverage_thresholds [Boolean] when true, fail strict coverage generation below project thresholds
26
+ # @param update_prep [Boolean] when true, update the most recent prepared release section in place
27
+ def initialize(strict: true, enforce_coverage_thresholds: true, update_prep: false)
18
28
  @root = Kettle::Dev::CIHelpers.project_root
19
29
  @changelog_path = File.join(@root, "CHANGELOG.md")
20
30
  @coverage_path = File.join(@root, "coverage", "coverage.json")
21
31
  @strict = strict
32
+ @enforce_coverage_thresholds = enforce_coverage_thresholds
33
+ @update_prep = update_prep
22
34
  end
23
35
 
24
36
  # Main entry point to update CHANGELOG.md
@@ -36,28 +48,21 @@ module Kettle
36
48
  warn("Make sure 'origin' points to github.com. Alternatively, set origin or update links manually afterward.")
37
49
  end
38
50
 
51
+ changelog = File.read(@changelog_path)
52
+ plan = @update_prep ? explicit_update_prep_plan(changelog) : detect_plan(changelog, version)
53
+ confirm_plan!(plan)
54
+
55
+ if plan.fetch(:action) == :reformat_only
56
+ reformat_changelog!(changelog)
57
+ return
58
+ end
59
+
39
60
  line_cov_line, branch_cov_line = coverage_lines
40
61
  yard_line = yard_percent_documented
41
62
 
42
- changelog = File.read(@changelog_path)
43
-
44
- # If the detected version already exists in the changelog, offer reformat-only mode
45
- if changelog =~ /^## \[#{Regexp.escape(version)}\]/
46
- warn("CHANGELOG.md already has a section for version #{version}.")
47
- warn("It appears the version has not been bumped. You can reformat CHANGELOG.md without adding a new release section.")
48
- print("Proceed with reformat only? [y/N]: ")
49
- ans = Kettle::Dev::InputAdapter.gets&.strip&.downcase
50
- if ans == "y" || ans == "yes"
51
- updated = convert_heading_tag_suffix_to_list(changelog)
52
- updated = normalize_heading_spacing(updated)
53
- updated = ensure_footer_spacing(updated)
54
- updated = updated.rstrip + "\n"
55
- File.write(@changelog_path, updated)
56
- puts "CHANGELOG.md reformatted. No new version section added."
57
- return
58
- else
59
- abort("Aborting: version not bumped. Re-run after bumping version or answer 'y' to reformat-only.")
60
- end
63
+ if plan.fetch(:action) == :update_prepared_release
64
+ update_prepared_release!(changelog, today, owner, repo, line_cov_line, branch_cov_line, yard_line)
65
+ return
61
66
  end
62
67
 
63
68
  unreleased_block, before, after = extract_unreleased(changelog)
@@ -136,6 +141,160 @@ module Kettle
136
141
  Kettle::Dev::ExitAdapter.abort(msg)
137
142
  end
138
143
 
144
+ def detect_plan(changelog, version)
145
+ latest_overall = nil
146
+ latest_for_series = nil
147
+ gem_name = nil
148
+ begin
149
+ gem_name = detect_gem_name
150
+ latest_overall, latest_for_series = latest_released_versions(gem_name, version)
151
+ rescue StandardError => e
152
+ warn("[kettle-changelog] gem.coop release check failed: #{e.class}: #{e.message}")
153
+ warn("Proceeding without live release info.")
154
+ end
155
+
156
+ _unreleased_block, _before, after = extract_unreleased(changelog)
157
+ latest_changelog_version = detect_previous_version(after.to_s)
158
+ section_exists = release_section_exists?(changelog, version)
159
+ latest_target = latest_release_target(version, latest_overall, latest_for_series)
160
+
161
+ if latest_target && Gem::Version.new(version) < Gem::Version.new(latest_target)
162
+ abort("Aborting: version.rb (#{version}) is lower than the latest released version for this release line (#{latest_target}).")
163
+ end
164
+
165
+ if section_exists && latest_changelog_version != version
166
+ abort("Aborting: CHANGELOG.md already contains a #{version} section, but the most recent release section is #{latest_changelog_version || "missing"}.")
167
+ end
168
+
169
+ action = if section_exists && latest_target == version
170
+ :reformat_only
171
+ elsif section_exists
172
+ :update_prepared_release
173
+ elsif latest_target == version
174
+ abort("Aborting: version.rb (#{version}) matches the latest released version, but CHANGELOG.md does not have #{version} as the most recent release section.")
175
+ else
176
+ :new_release
177
+ end
178
+
179
+ {
180
+ action: action,
181
+ version: version,
182
+ gem_name: gem_name,
183
+ latest_overall: latest_overall,
184
+ latest_for_series: latest_for_series,
185
+ latest_target: latest_target,
186
+ latest_changelog_version: latest_changelog_version,
187
+ }
188
+ end
189
+
190
+ def explicit_update_prep_plan(changelog)
191
+ _unreleased_block, _before, after = extract_unreleased(changelog)
192
+ prepared_version = detect_previous_version(after.to_s)
193
+ abort("Could not find a prepared release section after '## [Unreleased]' in CHANGELOG.md") unless prepared_version
194
+
195
+ {
196
+ action: :update_prepared_release,
197
+ version: prepared_version,
198
+ gem_name: nil,
199
+ latest_overall: nil,
200
+ latest_for_series: nil,
201
+ latest_target: nil,
202
+ latest_changelog_version: prepared_version,
203
+ explicit: true,
204
+ }
205
+ end
206
+
207
+ def confirm_plan!(plan)
208
+ puts "kettle-changelog selected plan: #{plan_label(plan.fetch(:action))}"
209
+ puts " version.rb: #{plan.fetch(:version)}"
210
+ puts " latest released: #{plan.fetch(:latest_overall) || "unknown"}"
211
+ puts " latest released for current series: #{plan.fetch(:latest_for_series) || "unknown"}"
212
+ puts " latest CHANGELOG.md release: #{plan.fetch(:latest_changelog_version) || "none"}"
213
+ puts " gem: #{plan.fetch(:gem_name) || "unknown"}"
214
+ print("Continue with this plan? [y/N]: ")
215
+ ans = Kettle::Dev::InputAdapter.gets&.strip&.downcase
216
+ return if ans == "y" || ans == "yes"
217
+
218
+ abort("Aborting: changelog plan was not confirmed.")
219
+ end
220
+
221
+ def plan_label(action)
222
+ case action
223
+ when :new_release
224
+ "create a new release section"
225
+ when :update_prepared_release
226
+ "update the prepared release section in place"
227
+ when :reformat_only
228
+ "reformat CHANGELOG.md without adding a release section"
229
+ else
230
+ action.to_s
231
+ end
232
+ end
233
+
234
+ def reformat_changelog!(changelog)
235
+ updated = convert_heading_tag_suffix_to_list(changelog)
236
+ updated = normalize_heading_spacing(updated)
237
+ updated = ensure_footer_spacing(updated)
238
+ updated = updated.rstrip + "\n"
239
+ File.write(@changelog_path, updated)
240
+ puts "CHANGELOG.md reformatted. No new version section added."
241
+ end
242
+
243
+ def release_section_exists?(changelog, version)
244
+ changelog.match?(/^## \[#{Regexp.escape(version)}\]/)
245
+ end
246
+
247
+ def detect_gem_name
248
+ gemspecs = Dir[File.join(@root, "*.gemspec")]
249
+ abort("Could not find a .gemspec in project root.") if gemspecs.empty?
250
+ path = gemspecs.min
251
+ content = File.read(path)
252
+ m = content.match(/spec\.name\s*=\s*(["'])([^"']+)\1/)
253
+ abort("Could not determine gem name from #{Kettle::Dev.display_path(path)}.") unless m
254
+
255
+ m[2]
256
+ end
257
+
258
+ def latest_released_versions(gem_name, current_version)
259
+ uri = URI("https://gem.coop/api/v1/versions/#{gem_name}.json")
260
+ res = Net::HTTP.get_response(uri)
261
+ return [nil, nil] unless res.is_a?(Net::HTTPSuccess)
262
+
263
+ data = JSON.parse(res.body)
264
+ versions = data.map { |h| h["number"] }.compact
265
+ versions.reject! { |v| v.to_s.include?("-pre") || v.to_s.include?(".pre") || v.to_s.match?(/[a-zA-Z]/) }
266
+ gversions = versions.map { |s| Gem::Version.new(s) }.sort
267
+ latest_overall = gversions.last&.to_s
268
+
269
+ cur = Gem::Version.new(current_version)
270
+ series = cur.segments[0, 2]
271
+ latest_series = gversions.reverse.find { |gv| gv.segments[0, 2] == series }&.to_s
272
+ [latest_overall, latest_series]
273
+ rescue StandardError => e
274
+ Kettle::Dev.debug_error(e, __method__)
275
+ [nil, nil]
276
+ end
277
+
278
+ def latest_release_target(version, latest_overall, latest_for_series)
279
+ return unless latest_overall
280
+
281
+ cur = Gem::Version.new(version)
282
+ overall = Gem::Version.new(latest_overall)
283
+ cur_series = cur.segments[0, 2]
284
+ overall_series = overall.segments[0, 2]
285
+
286
+ if latest_for_series
287
+ lfs_series = Gem::Version.new(latest_for_series).segments[0, 2]
288
+ latest_for_series = nil unless lfs_series == cur_series
289
+ end
290
+
291
+ if (cur_series <=> overall_series) == -1
292
+ latest_for_series
293
+ else
294
+ latest_overall
295
+ end
296
+ end
297
+
139
298
  def detect_version
140
299
  candidates = Dir[File.join(@root, "lib", "**", "version.rb")]
141
300
  abort("Could not find version.rb under lib/**.") if candidates.empty?
@@ -163,8 +322,23 @@ module Kettle
163
322
  end
164
323
  # Now next_i points to the next section heading or EOF
165
324
  before = lines[0..(start_i - 1)].join
166
- unreleased_block = lines[(start_i + 1)..(next_i - 1)].join
167
- after = lines[next_i..-1]&.join || ""
325
+ unreleased_body = lines[(start_i + 1)..(next_i - 1)] || []
326
+ after_lines = lines[next_i..-1] || []
327
+
328
+ # When this is the very first release there is no `## [X.Y.Z]` heading to act
329
+ # as a boundary, so the footer link-ref block ([Unreleased]: ...) sits at the
330
+ # end of the unreleased body. Move everything from the [Unreleased]: line
331
+ # onward into `after` so those refs are not mistaken for section content.
332
+ if next_i == lines.length
333
+ footer_i = unreleased_body.index { |l| l.start_with?(UNRELEASED_SECTION_HEADING) }
334
+ if footer_i
335
+ after_lines = unreleased_body[footer_i..-1] + after_lines
336
+ unreleased_body = unreleased_body[0...footer_i]
337
+ end
338
+ end
339
+
340
+ unreleased_block = unreleased_body.join
341
+ after = after_lines.join
168
342
  [unreleased_block, before, after]
169
343
  end
170
344
 
@@ -176,6 +350,116 @@ module Kettle
176
350
  nil
177
351
  end
178
352
 
353
+ def update_prepared_release!(changelog, today, owner, repo, line_cov_line, branch_cov_line, yard_line)
354
+ unreleased_block, before, after = extract_unreleased(changelog)
355
+ abort("Could not find '## [Unreleased]' section in CHANGELOG.md") if unreleased_block.nil?
356
+
357
+ release_heading = after.to_s.match(/\A## \[(\d+\.\d+\.\d+)\][^\n]*\n/)
358
+ abort("Could not find a prepared release section after '## [Unreleased]' in CHANGELOG.md") unless release_heading
359
+
360
+ prepared_version = release_heading[1]
361
+ release_and_tail = after.lines
362
+ next_release_index = release_and_tail[1..-1].to_a.index { |line| line.start_with?("## [") }
363
+ release_line_count = next_release_index ? next_release_index + 1 : release_and_tail.length
364
+ release_lines = release_and_tail[0...release_line_count]
365
+ tail = release_and_tail[release_line_count..-1].to_a.join
366
+
367
+ release_body = release_lines[1..-1].to_a.join
368
+ merged_body = merge_release_body_with_unreleased(release_body, unreleased_block)
369
+
370
+ release_section = +""
371
+ release_section << "## [#{prepared_version}] - #{today}\n"
372
+ release_section << "- TAG: [v#{prepared_version}][#{prepared_version}t]\n"
373
+ release_section << "- #{line_cov_line}\n" if line_cov_line
374
+ release_section << "- #{branch_cov_line}\n" if branch_cov_line
375
+ release_section << "- #{yard_line}\n" if yard_line
376
+ release_section << merged_body
377
+ release_section.rstrip!
378
+ release_section << "\n\n"
379
+
380
+ unreleased_reset = <<~MD
381
+ ## [Unreleased]
382
+ ### Added
383
+ ### Changed
384
+ ### Deprecated
385
+ ### Removed
386
+ ### Fixed
387
+ ### Security
388
+ MD
389
+
390
+ previous_version = detect_previous_version(tail)
391
+ updated = before + unreleased_reset + "\n" + release_section + tail
392
+ updated = update_link_refs(updated, owner, repo, previous_version, prepared_version)
393
+ updated = convert_heading_tag_suffix_to_list(updated)
394
+ updated = normalize_heading_spacing(updated)
395
+ updated = updated.rstrip + "\n"
396
+
397
+ File.write(@changelog_path, updated)
398
+ puts "CHANGELOG.md updated in place for v#{prepared_version}."
399
+ end
400
+
401
+ def merge_release_body_with_unreleased(release_body, unreleased_block)
402
+ existing = strip_release_metadata(release_body)
403
+ incoming = filter_unreleased_sections(unreleased_block)
404
+ return existing if incoming.strip.empty?
405
+ return incoming if existing.strip.empty?
406
+
407
+ leading, sections = split_h3_sections(existing)
408
+ _incoming_leading, incoming_sections = split_h3_sections(incoming)
409
+ return [existing.rstrip, incoming.rstrip, ""].join("\n\n") if sections.empty? || incoming_sections.empty?
410
+
411
+ incoming_sections.each do |incoming_section|
412
+ section = sections.find { |candidate| candidate.fetch(:heading) == incoming_section.fetch(:heading) }
413
+ if section
414
+ section.fetch(:lines) << "\n" unless section.fetch(:lines).empty? || section.fetch(:lines).last.to_s.strip.empty?
415
+ section.fetch(:lines).concat(trim_blank_lines(incoming_section.fetch(:lines)))
416
+ else
417
+ sections << incoming_section
418
+ end
419
+ end
420
+
421
+ ([leading] + sections.map { |section| section.fetch(:heading) + trim_blank_lines(section.fetch(:lines)).join }).join.rstrip + "\n"
422
+ end
423
+
424
+ def strip_release_metadata(release_body)
425
+ lines = release_body.lines
426
+ while lines.any? && lines.first.strip.empty?
427
+ lines.shift
428
+ end
429
+ while lines.any?
430
+ stripped = lines.first.strip
431
+ break unless stripped.start_with?("- TAG:", "- COVERAGE:", "- BRANCH COVERAGE:") || stripped.match?(/\A- \d+(?:\.\d+)?%\s+documented\z/)
432
+
433
+ lines.shift
434
+ end
435
+ lines.join
436
+ end
437
+
438
+ def split_h3_sections(text)
439
+ leading = +""
440
+ sections = []
441
+ current = nil
442
+ text.lines.each do |line|
443
+ if line.start_with?("### ")
444
+ current = {heading: line, lines: []}
445
+ sections << current
446
+ elsif current
447
+ current.fetch(:lines) << line
448
+ else
449
+ leading << line
450
+ end
451
+ end
452
+ [leading, sections]
453
+ end
454
+
455
+ def trim_blank_lines(lines)
456
+ trimmed = lines.dup
457
+ trimmed.shift while trimmed.any? && trimmed.first.to_s.strip.empty?
458
+ trimmed.pop while trimmed.any? && trimmed.last.to_s.strip.empty?
459
+ trimmed << "\n" if trimmed.any? && !trimmed.last.end_with?("\n")
460
+ trimmed
461
+ end
462
+
179
463
  # From the Unreleased block, keep only sections that have content.
180
464
  # We detect sections as lines starting with '### '. A section has content if there is at least
181
465
  # one non-empty, non-heading line under it before the next '###' or '##'. Typically these are list items.
@@ -194,8 +478,10 @@ module Kettle
194
478
  chunk << lines[i]
195
479
  i += 1
196
480
  end
197
- # Determine if chunk has any content (non-blank)
198
- content_present = chunk.any? { |l| l.strip != "" }
481
+ # A section has real content only if it contains at least one non-blank line that is
482
+ # neither a link-reference definition ([key]: url) nor a deeper heading (H4+).
483
+ # Link-ref defs and H4+ headings alone are not meaningful section content.
484
+ content_present = chunk.any? { |l| l.strip != "" && l !~ LINK_REF_DEF_RE && l !~ DEEP_HEADING_RE }
199
485
  if content_present
200
486
  # Trim leading blank lines so there is no blank line after the header
201
487
  while chunk.any? && chunk.first.strip == ""
@@ -224,39 +510,28 @@ module Kettle
224
510
  # Delete old coverage files to ensure we get current data
225
511
  coverage_dir = File.dirname(@coverage_path)
226
512
  if Dir.exist?(coverage_dir)
227
- puts "Cleaning old coverage data from #{coverage_dir}..."
513
+ puts "Cleaning old coverage data from #{Kettle::Dev.display_path(coverage_dir)}..."
228
514
  Dir.glob(File.join(coverage_dir, "*")).each do |file|
229
515
  File.delete(file) if File.file?(file)
230
516
  end
231
517
  end
232
518
 
233
- puts "Generating fresh coverage data by running: bin/rake coverage"
234
-
235
- # Run bin/rake coverage to generate coverage.json
236
- rake_cmd = File.join(@root, "bin", "rake")
237
- unless File.executable?(rake_cmd)
238
- raise "bin/rake not found or not executable; cannot generate coverage data"
239
- end
519
+ puts "Generating fresh coverage data by running: bundle exec kettle-test"
240
520
 
241
- # Run the command exactly as the user would run it manually
242
- # The coverage task knows how to configure itself properly
243
- success = system(rake_cmd, "coverage", chdir: @root)
521
+ success = system(changelog_coverage_env, "bundle", "exec", "kettle-test", chdir: @root)
244
522
 
245
523
  unless success
246
- raise "bin/rake coverage failed with exit status #{$?.exitstatus || "unknown"}"
524
+ raise "bundle exec kettle-test failed with exit status #{$?.exitstatus || "unknown"}"
247
525
  end
248
526
 
249
527
  puts "Coverage generation complete."
250
528
 
251
- # Check if coverage.json was generated
252
- unless File.file?(@coverage_path)
253
- raise "Coverage JSON not found at #{@coverage_path} after running bin/rake coverage"
254
- end
529
+ ensure_changelog_coverage_json!
255
530
  else
256
531
  # Non-strict mode: check if coverage.json exists, warn if not
257
532
  unless File.file?(@coverage_path)
258
- warn("Coverage JSON not found at #{@coverage_path}.")
259
- warn("Run: bin/rake coverage to generate it")
533
+ warn(coverage_json_missing_message)
534
+ warn("Run: K_SOUP_COV_FORMATTERS=json bundle exec kettle-test to generate it")
260
535
  return [nil, nil]
261
536
  end
262
537
  end
@@ -310,41 +585,100 @@ module Kettle
310
585
  end
311
586
  end
312
587
 
588
+ def changelog_coverage_env
589
+ {
590
+ "K_SOUP_COV_DO" => "true",
591
+ "K_SOUP_COV_FORMATTERS" => "json",
592
+ "K_SOUP_COV_MIN_HARD" => @enforce_coverage_thresholds ? "true" : "false",
593
+ "K_SOUP_COV_MULTI_FORMATTERS" => "true",
594
+ "K_SOUP_COV_OPEN_BIN" => "",
595
+ }
596
+ end
597
+
598
+ def ensure_changelog_coverage_json!
599
+ return if File.file?(@coverage_path)
600
+
601
+ raise coverage_json_missing_message
602
+ end
603
+
604
+ def coverage_json_missing_message
605
+ [
606
+ "Coverage JSON not found at #{Kettle::Dev.display_path(@coverage_path)} after running bundle exec kettle-test.",
607
+ "kettle-test runs specs in parallel and is expected to collate parallel SimpleCov results into this canonical file.",
608
+ "If it is missing, coverage was not enabled in ENV config or the rake/task hooks did not load the coverage integration.",
609
+ ].join(" ")
610
+ end
611
+
313
612
  def yard_percent_documented
314
- cmd = File.join(@root, "bin", "yard")
315
- unless File.executable?(cmd)
613
+ commands = yard_documentation_commands
614
+ if commands.empty?
316
615
  if @strict
317
- raise "bin/yard not found or not executable; ensure yard is installed via bundler"
616
+ raise "bin/rake and bin/yard not found or not executable; ensure rake and yard are installed via bundler"
318
617
  else
319
- warn("bin/yard not found or not executable; ensure yard is installed via bundler")
618
+ warn("bin/rake and bin/yard not found or not executable; ensure rake and yard are installed via bundler")
320
619
  return
321
620
  end
322
621
  end
323
622
 
324
623
  begin
325
- # Run bin/yard to get documentation percentage
326
- out, _ = Open3.capture2(cmd, {chdir: @root})
327
- # Look for a line containing e.g., "95.35% documented"
328
- line = out.lines.find { |l| l =~ /\d+(?:\.\d+)?%\s+documented/ }
329
-
330
- if line
331
- line.strip
332
- elsif @strict
333
- raise "Could not find documented percentage in bin/yard output"
624
+ # Run the canonical docs task to get the documentation percentage.
625
+ out = +""
626
+ commands.each do |command|
627
+ prepare_yard_fence_tmp_files if command == [File.join(@root, "bin", "yard")]
628
+ output, _status = Open3.capture2(*command, {chdir: @root})
629
+ out << output
630
+ line = documented_percent_line(output)
631
+ return line if line
632
+ end
633
+
634
+ if @strict
635
+ raise "Could not find documented percentage in bin/rake yard output"
334
636
  else
335
- warn("Could not find documented percentage in bin/yard output.")
637
+ warn("Could not find documented percentage in bin/rake yard output.")
336
638
  nil
337
639
  end
338
640
  rescue StandardError => e
339
641
  if @strict
340
- raise "Failed to run bin/yard: #{e.class}: #{e.message}"
642
+ raise "Failed to run bin/rake yard: #{e.class}: #{e.message}"
341
643
  else
342
- warn("Failed to run bin/yard: #{e.class}: #{e.message}")
644
+ warn("Failed to run bin/rake yard: #{e.class}: #{e.message}")
343
645
  nil
344
646
  end
345
647
  end
346
648
  end
347
649
 
650
+ def yard_documentation_commands
651
+ commands = []
652
+ rake = File.join(@root, "bin", "rake")
653
+ commands << [rake, "yard"] if File.executable?(rake)
654
+ yard = File.join(@root, "bin", "yard")
655
+ commands << [yard] if File.executable?(yard)
656
+ commands
657
+ end
658
+
659
+ def prepare_yard_fence_tmp_files
660
+ yardopts = File.join(@root, ".yardopts")
661
+ return unless File.file?(yardopts)
662
+ return unless File.read(yardopts).include?("tmp/yard-fence")
663
+
664
+ require "yard/fence"
665
+ outdir = File.join(@root, "tmp", "yard-fence")
666
+ FileUtils.rm_rf(outdir)
667
+ FileUtils.mkdir_p(outdir)
668
+ Dir.glob(File.join(@root, Yard::Fence::GLOB_PATTERN)).each do |src|
669
+ next unless File.file?(src)
670
+
671
+ content = File.read(src)
672
+ sanitized = Yard::Fence.sanitize_text(content)
673
+ File.write(File.join(outdir, File.basename(src)), sanitized)
674
+ end
675
+ end
676
+
677
+ def documented_percent_line(output)
678
+ line = output.lines.find { |l| /\d+(?:\.\d+)?%\s+documented/.match?(l) }
679
+ line&.strip
680
+ end
681
+
348
682
  # Transform legacy release headings that include a tag suffix, e.g.:
349
683
  # "## [1.2.3] 2022-08-29 ([tag][1.2.3t])"
350
684
  # into a heading followed by a list item:
@@ -511,7 +845,7 @@ module Kettle
511
845
  end
512
846
 
513
847
  # Rebuild and sort the reference block so Unreleased is first, then newest to oldest versions, preserving everything above first_ref
514
- ref_lines = lines[first_ref..-1].select { |l| l =~ /^\[[^\]]+\]:\s+http/ }
848
+ ref_lines = lines[first_ref..-1].select { |l| /^\[[^\]]+\]:\s+http/.match?(l) }
515
849
  # Deduplicate by key (text inside the square brackets)
516
850
  by_key = {}
517
851
  ref_lines.each do |l|
@@ -560,12 +894,12 @@ module Kettle
560
894
  fence_re = /^\s*```/
561
895
  heading_re = /^\s*#+\s+.+/
562
896
  lines.each_with_index do |ln, idx|
563
- if ln =~ fence_re
897
+ if fence_re.match?(ln)
564
898
  in_fence = !in_fence
565
899
  out << ln
566
900
  next
567
901
  end
568
- if !in_fence && ln =~ heading_re
902
+ if !in_fence && heading_re.match?(ln)
569
903
  # Ensure previous line is blank (unless start of file or already blank)
570
904
  prev_blank = out.empty? ? false : out.last.to_s.strip == ""
571
905
  out << "" unless out.empty? || prev_blank
@@ -607,16 +941,40 @@ module Kettle
607
941
  (head + tail).join("\n")
608
942
  end
609
943
 
610
- def detect_initial_compare_base(lines)
611
- # Fallback when prev_version is unknown: try to find the first compare base used historically
612
- # e.g., for 1.0.0 it may be a commit SHA instead of a tag
613
- ref = lines.find { |l| l =~ /^\[1\.0\.0\]:\s+https:\/\/github\.com\// }
614
- if ref && (m = ref.match(%r{compare/([^\.]+)\.\.\.v\d+})).is_a?(MatchData)
615
- m[1]
616
- else
617
- # Default to previous tag name if none found (unlikely to be correct, but better than empty)
618
- "HEAD^"
619
- end
944
+ # Determine the "from" side of the compare URL for the very first release.
945
+ #
946
+ # Priority:
947
+ # 1. KETTLE_CHANGELOG_INITIAL_SHA env var explicit override, required for hard-forks
948
+ # (e.g. turbo_tests2 which forked from an upstream commit SHA).
949
+ # 2. `git rev-list --max-parents=0 HEAD` — the root commit of this repository.
950
+ # Correct for the overwhelming majority of new gems.
951
+ # 3. "HEAD^" last-resort fallback when git is unavailable or the command fails.
952
+ #
953
+ # @param _lines [Array<String>] kept for API compatibility (no longer used)
954
+ # @return [String] the compare base: a commit SHA, tag, or fallback string
955
+ def detect_initial_compare_base(_lines = nil)
956
+ env_sha = ENV.fetch("KETTLE_CHANGELOG_INITIAL_SHA", nil)
957
+ return env_sha.strip if env_sha && !env_sha.strip.empty?
958
+
959
+ sha = git_root_commit
960
+ return sha if sha
961
+
962
+ warn(
963
+ "Could not determine initial git root commit; using HEAD^ as compare base. " \
964
+ "Set KETTLE_CHANGELOG_INITIAL_SHA to override.",
965
+ )
966
+ "HEAD^"
967
+ end
968
+
969
+ # Return the root commit SHA of the current repository, or nil on failure.
970
+ # Uses the generic GitAdapter#capture escape hatch so tests can stub it.
971
+ def git_root_commit
972
+ adapter = Kettle::Dev::GitAdapter.new
973
+ out, ok = adapter.capture(["rev-list", "--max-parents=0", "HEAD"])
974
+ sha = out.to_s.lines.last&.strip # take last line in case of multiple root commits
975
+ (ok && sha && !sha.empty?) ? sha : nil
976
+ rescue StandardError
977
+ nil
620
978
  end
621
979
  end
622
980
  end