kettle-dev 1.1.60 → 1.2.1

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.
@@ -3,6 +3,7 @@
3
3
  # External stdlibs
4
4
  require "find"
5
5
  require "set"
6
+ require "yaml"
6
7
 
7
8
  module Kettle
8
9
  module Dev
@@ -17,6 +18,12 @@ module Kettle
17
18
  # The minimum Ruby supported by setup-ruby GHA
18
19
  MIN_SETUP_RUBY = Gem::Version.create("2.3")
19
20
 
21
+ TEMPLATE_MANIFEST_PATH = File.expand_path("../../..", __dir__) + "/template_manifest.yml"
22
+ RUBY_BASENAMES = %w[Gemfile Rakefile Appraisals Appraisal.root.gemfile .simplecov].freeze
23
+ RUBY_SUFFIXES = %w[.gemspec .gemfile].freeze
24
+ RUBY_EXTENSIONS = %w[.rb .rake].freeze
25
+ @@manifestation = nil
26
+
20
27
  module_function
21
28
 
22
29
  # Root of the host project where Rake was invoked
@@ -270,52 +277,25 @@ module Kettle
270
277
 
271
278
  content = File.read(src_path)
272
279
  content = yield(content) if block_given?
273
- # Final global replacements that must occur AFTER normal replacements
280
+ # Replace the explicit template token with the literal "kettle-dev"
281
+ # after upstream/template-specific replacements (i.e. after the yield),
282
+ # so the token itself is not altered by those replacements.
274
283
  begin
275
284
  token = "{KETTLE|DEV|GEM}"
276
285
  content = content.gsub(token, "kettle-dev") if content.include?(token)
277
286
  rescue StandardError => e
278
287
  Kettle::Dev.debug_error(e, __method__)
279
- # If replacement fails unexpectedly, proceed with content as-is
280
288
  end
281
289
 
282
- # If updating the Appraisals file and a destination already exists,
283
- # merge appraise blocks: augment matching blocks with missing gem/eval_gemfile lines,
284
- # preserve destination-only blocks and comments/preamble.
285
- begin
286
- if dest_exists && File.basename(dest_path.to_s) == "Appraisals" && File.file?(dest_path.to_s)
287
- existing = begin
288
- File.read(dest_path)
289
- rescue
290
- ""
291
- end
292
- content = merge_appraisals(content, existing)
293
- end
294
- rescue StandardError => e
295
- Kettle::Dev.debug_error(e, __method__)
296
- # On any error, fall back to generated content
297
- end
298
-
299
- # If updating a Gemfile or modular .gemfile and the destination already exists,
300
- # merge dependency lines from the source into the destination to preserve any
301
- # user-defined gem entries. We append missing `gem "name"` lines; we never
302
- # alter or remove existing gem lines in the destination.
303
- begin
304
- if dest_exists
305
- dest_str = dest_path.to_s
306
- is_gemfile_like = File.basename(dest_str) == "Gemfile" || dest_str.end_with?(".gemfile")
307
- if is_gemfile_like && File.file?(dest_str)
308
- begin
309
- existing = File.read(dest_str)
310
- content = merge_gemfile_dependencies(content, existing)
311
- rescue StandardError => e
312
- Kettle::Dev.debug_error(e, __method__)
313
- # If merging fails, fall back to writing generated content
314
- end
315
- end
290
+ basename = File.basename(dest_path.to_s)
291
+ content = apply_appraisals_merge(content, dest_path) if basename == "Appraisals"
292
+ if basename == "Appraisal.root.gemfile" && File.exist?(dest_path)
293
+ begin
294
+ prior = File.read(dest_path)
295
+ content = merge_gemfile_dependencies(content, prior)
296
+ rescue StandardError => e
297
+ Kettle::Dev.debug_error(e, __method__)
316
298
  end
317
- rescue StandardError => e
318
- Kettle::Dev.debug_error(e, __method__)
319
299
  end
320
300
 
321
301
  write_file(dest_path, content)
@@ -342,311 +322,24 @@ module Kettle
342
322
  # @return [String] merged content
343
323
  def merge_gemfile_dependencies(src_content, dest_content)
344
324
  begin
345
- gem_re = /^\s*gem\s+['"]([^'"\s]+)['"].*$/
346
- # Collect first occurrence of each gem line in source
347
- src_gems = {}
348
- src_content.each_line do |ln|
349
- next if ln.strip.start_with?("#")
350
- if (m = ln.match(gem_re))
351
- name = m[1]
352
- src_gems[name] ||= ln.rstrip
353
- end
354
- end
355
-
356
- # --- Handle `source` replacement/insertion ---
357
- src_source_line = nil
358
- src_content.each_line do |ln|
359
- next if ln.strip.start_with?("#")
360
- if ln =~ /^\s*source\s+['"][^'"]+['"]\s*$/
361
- src_source_line = ln.rstrip + "\n"
362
- break
363
- end
364
- end
365
-
366
- dest_lines = dest_content.lines.dup
367
-
368
- if src_source_line
369
- dest_source_idx = dest_lines.index do |ln|
370
- !ln.strip.start_with?("#") && ln =~ /^\s*source\s+['"][^'"]+['"]\s*$/
371
- end
372
- if dest_source_idx
373
- dest_lines[dest_source_idx] = src_source_line
374
- else
375
- # Insert after any leading contiguous comment/blank block at top of file
376
- insert_idx = 0
377
- while insert_idx < dest_lines.length && (dest_lines[insert_idx].strip.empty? || dest_lines[insert_idx].lstrip.start_with?("#"))
378
- insert_idx += 1
379
- end
380
- dest_lines.insert(insert_idx, src_source_line)
381
- end
382
- end
383
-
384
- # --- Handle `git_source` replacement/insertion ---
385
- # Collect non-comment git_source lines from source (preserve order)
386
- src_git_lines = src_content.each_line.select { |ln| !ln.strip.start_with?("#") && ln =~ /^\s*git_source\s*\(/ }
387
- if src_git_lines.any?
388
- # Insert new git_source lines in the same order as they appear in the source
389
- # When inserting (not replacing), place them immediately after the source line if present
390
- insert_after_source_idx = dest_lines.index { |ln| !ln.strip.start_with?("#") && ln =~ /^\s*source\s+['"][^'"]+['"]\s*$/ }
391
-
392
- # Iterate source git lines in reverse for insertion so order is preserved when inserting at same index
393
- src_git_lines.reverse_each do |gln|
394
- # Attempt to extract the git_source "name" (handles forms like git_source(:github) or git_source :github)
395
- name_match = gln.match(/^\s*git_source\s*\(?\s*:?(\w+)\b/)
396
- name = name_match ? name_match[1].to_s : nil
397
-
398
- replaced = false
399
- if name
400
- # Try to find a git_source in destination with the same name
401
- dest_same_idx = dest_lines.index do |dln|
402
- !dln.strip.start_with?("#") && dln =~ /^\s*git_source\s*\(?\s*:?#{Regexp.escape(name)}\b/
403
- end
404
- if dest_same_idx
405
- dest_lines[dest_same_idx] = gln.rstrip + "\n"
406
- replaced = true
407
- end
408
- end
409
-
410
- unless replaced
411
- # If destination has a github git_source, replace that
412
- dest_github_idx = dest_lines.index do |dln|
413
- !dln.strip.start_with?("#") && dln =~ /^\s*git_source\s*\(?\s*:?github\b/
414
- end
415
- if dest_github_idx
416
- dest_lines[dest_github_idx] = gln.rstrip + "\n"
417
- else
418
- # Insert below the source line if present, otherwise at top (after comments)
419
- insert_idx =
420
- if insert_after_source_idx
421
- insert_after_source_idx + 1
422
- else
423
- 0
424
- end
425
- dest_lines.insert(insert_idx, gln.rstrip + "\n")
426
- end
427
- end
428
- end
429
- end
430
-
431
- # Index existing gems in destination (after potential source/git_source changes)
432
- dest_gems = {}
433
- dest_lines.join.each_line do |ln|
434
- next if ln.strip.start_with?("#")
435
- if (m = ln.match(gem_re))
436
- dest_gems[m[1]] = true
437
- end
438
- end
439
-
440
- missing = src_gems.keys.reject { |n| dest_gems.key?(n) }
441
- # If nothing to change, return original destination content
442
- if missing.empty? && src_source_line.nil? && src_git_lines.empty?
443
- return dest_content
444
- end
445
-
446
- out = dest_lines.join
447
- out << "\n" unless out.end_with?("\n") || out.empty?
448
- if missing.any?
449
- out << missing.map { |n| src_gems[n] }.join("\n")
450
- out << "\n"
451
- end
452
- out
325
+ Kettle::Dev::PrismGemfile.merge_gem_calls(src_content.to_s, dest_content.to_s)
453
326
  rescue StandardError => e
454
327
  Kettle::Dev.debug_error(e, __method__)
455
328
  dest_content
456
329
  end
457
330
  end
458
331
 
459
- # Merge Appraisals template into existing Appraisals file.
460
- # Rules:
461
- # - For each appraise "name" block in template:
462
- # * If destination has same block, ensure all gem/ eval_gemfile lines from template
463
- # exist in destination (append missing just before end), keep other dest lines.
464
- # Use template's contiguous header comment lines (immediately preceding the appraise line)
465
- # if any; otherwise retain destination's header comments.
466
- # * If destination lacks the block, add the full template block (with its header).
467
- # - Preserve destination-only blocks (not present in template) unchanged and after
468
- # the merged template-ordered blocks.
469
- # - Preamble (content before first appraise) comes from template when present, else destination.
470
- def merge_appraisals(template_content, dest_content)
471
- begin
472
- # Helper: extract contiguous leading header lines (comments and blank lines)
473
- extract_leading_header = lambda do |text|
474
- lines = text.lines
475
- header_lines = []
476
- idx = 0
477
- while idx < lines.length
478
- ln = lines[idx]
479
- break unless ln.strip.empty? || ln.lstrip.start_with?("#")
480
- header_lines << ln
481
- idx += 1
482
- end
483
- body = (idx < lines.length) ? lines[idx..-1].join : ""
484
- {header: header_lines.join, body: body}
485
- end
486
-
487
- parse_blocks = lambda do |text|
488
- lines = text.lines
489
- blocks = []
490
- i = 0
491
- while i < lines.length
492
- line = lines[i]
493
- if line =~ /^\s*appraise\s+["']([^"']+)["']\s+do\s*$/
494
- name = $1
495
- # collect header comment lines immediately preceding (contiguous, no blank between comment group and appraise line)
496
- header_lines = []
497
- j = i - 1
498
- while j >= 0
499
- prev = lines[j]
500
- break if prev.strip.empty?
501
- if prev.lstrip.start_with?("#")
502
- header_lines.unshift(prev)
503
- j -= 1
504
- else
505
- break
506
- end
507
- end
508
- body_lines = []
509
- i += 1
510
- while i < lines.length
511
- l2 = lines[i]
512
- if l2 =~ /^\s*end\s*$/
513
- end_line = l2
514
- blocks << {
515
- name: name,
516
- header: header_lines.dup,
517
- body: body_lines.dup,
518
- end_line: end_line,
519
- raw_order: blocks.length,
520
- original_indices: (j ? (j + 1)..i : i),
521
- }
522
- break
523
- else
524
- body_lines << l2
525
- end
526
- i += 1
527
- end
528
- end
529
- i += 1
530
- end
531
- preamble = if blocks.empty?
532
- text
533
- else
534
- # preamble = lines from start up to first block start (exclusive)
535
- first_block = blocks.first
536
- # Take lines up to first occurrence of the appraise line (supports either quote type)
537
- re = /^\s*appraise\s+["']#{Regexp.escape(first_block[:name])}["']\s+do\s*$/
538
- idx = lines.index { |l| l =~ re } || 0
539
- lines[0...idx].join
540
- end
541
- {blocks: blocks, preamble: preamble}
542
- end
543
-
544
- # Extract leading headers from template and destination and parse their bodies
545
- tmpl_parts = extract_leading_header.call(template_content)
546
- dest_parts = extract_leading_header.call(dest_content)
547
-
548
- # If the template does not provide a leading header, preserve the destination as-is
549
- # so that per-block adjacent header comments (including those at the top of file)
550
- # are still parsed and preserved. Only strip the destination leading header when
551
- # the template has a leading header to replace it.
552
- dest_parse_source = if tmpl_parts[:header].to_s.strip.empty?
553
- dest_content
554
- else
555
- dest_parts[:body]
556
- end
557
-
558
- tmpl = parse_blocks.call(tmpl_parts[:body])
559
- dest = parse_blocks.call(dest_parse_source)
560
- tmpl_blocks = tmpl[:blocks]
561
- dest_blocks = dest[:blocks]
562
- dest_by_name = dest_blocks.map { |b| [b[:name], b] }.to_h
563
-
564
- merged_blocks_strings = []
565
- gem_or_eval_re = /^\s*(?:gem|eval_gemfile)\b/
566
-
567
- tmpl_blocks.each do |tb|
568
- if (db = dest_by_name[tb[:name]])
569
- # Merge lines
570
- existing_lines = db[:body].map(&:rstrip)
571
- existing_set = existing_lines.to_set
572
- # Collect template gem/eval lines
573
- tmpl_needed = tb[:body].select { |l| gem_or_eval_re =~ l }
574
- additions = []
575
- tmpl_needed.each do |l|
576
- line_key = l.rstrip
577
- additions << l unless existing_set.include?(line_key)
578
- end
579
- merged_body = db[:body].dup
580
- unless additions.empty?
581
- # insert before end (just append; body excludes 'end')
582
- merged_body += additions
583
- end
584
- header = tb[:header].any? ? tb[:header] : db[:header]
585
- # If the template provides no leading header and the destination preamble
586
- # already ends with this header, skip emitting the header for this block
587
- # to avoid duplicating it (it will already be present at the top of the file).
588
- header_to_emit = if tmpl_parts[:header].to_s.strip.empty? && !dest[:preamble].to_s.strip.empty? && !header.empty? && dest[:preamble].to_s.end_with?(header.join)
589
- []
590
- else
591
- header
592
- end
593
- block_text = +""
594
- block_text << "\n" unless merged_blocks_strings.empty?
595
- header_to_emit.each { |hl| block_text << hl } if header_to_emit.any?
596
- block_text << "appraise \"#{tb[:name]}\" do\n"
597
- merged_body.each { |bl| block_text << bl }
598
- block_text << db[:end_line]
599
- merged_blocks_strings << block_text
600
- dest_by_name.delete(tb[:name])
601
- else
602
- # New block from template
603
- block_text = +""
604
- block_text << "\n" unless merged_blocks_strings.empty?
605
- tb[:header].each { |hl| block_text << hl } if tb[:header].any?
606
- block_text << "appraise \"#{tb[:name]}\" do\n"
607
- tb[:body].each { |bl| block_text << bl }
608
- block_text << tb[:end_line]
609
- merged_blocks_strings << block_text
610
- end
611
- end
612
- # Append destination-only blocks preserving their original text
613
- dest_remaining_order = dest_blocks.select { |b| dest_by_name.key?(b[:name]) }
614
- dest_remaining_order.each do |b|
615
- block_text = +""
616
- block_text << "\n" unless merged_blocks_strings.empty?
617
- b[:header].each { |hl| block_text << hl } if b[:header].any?
618
- block_text << "appraise \"#{b[:name]}\" do\n"
619
- b[:body].each { |bl| block_text << bl }
620
- block_text << b[:end_line]
621
- merged_blocks_strings << block_text
622
- end
623
-
624
- # Build final preamble:
625
- # - If template provides a leading header, use that header and then prefer the
626
- # template's body preamble when present, otherwise fall back to the
627
- # destination's body preamble.
628
- # - If template does NOT provide a leading header, leave the destination
629
- # preamble (including any leading header/comments) intact.
630
- preamble = +""
631
- if tmpl_parts[:header].to_s.strip.empty?
632
- preamble << dest[:preamble].to_s
633
- else
634
- body_preamble = tmpl[:preamble].to_s.strip.empty? ? dest[:preamble].to_s : tmpl[:preamble].to_s
635
- preamble << tmpl_parts[:header].to_s
636
- preamble << body_preamble.to_s
637
- end
638
-
639
- out = +""
640
- out << preamble unless preamble.nil? || preamble.empty?
641
- out << "\n" unless out.end_with?("\n")
642
- out << merged_blocks_strings.join
643
- out << "\n" unless out.end_with?("\n")
644
- out
645
- rescue StandardError => e
646
- Kettle::Dev.debug_error(e, __method__)
647
- # Fallback: prefer destination (user changes) and append template content to allow manual reconciliation
648
- dest_content + "\n# --- TEMPLATE APPRAISALS (unmerged) ---\n" + template_content
332
+ def apply_appraisals_merge(content, dest_path)
333
+ dest = dest_path.to_s
334
+ existing = if File.exist?(dest)
335
+ File.read(dest)
336
+ else
337
+ ""
649
338
  end
339
+ Kettle::Dev::PrismAppraisals.merge(content, existing)
340
+ rescue StandardError => e
341
+ Kettle::Dev.debug_error(e, __method__)
342
+ content
650
343
  end
651
344
 
652
345
  # Copy a directory tree, prompting before creating or overwriting.
@@ -883,8 +576,8 @@ module Kettle
883
576
  # ignore
884
577
  end
885
578
 
886
- # Replace occurrences of the template gem name in text, including inside
887
- # markdown reference labels like [🖼️kettle-dev] and identifiers like kettle-dev-i
579
+ # Replace occurrences of the literal template gem name ("kettle-dev")
580
+ # with the destination gem name.
888
581
  c = c.gsub("kettle-dev", gem_name)
889
582
  c = c.gsub(/\bKettle::Dev\b/u, namespace) unless namespace.empty?
890
583
  c = c.gsub("Kettle%3A%3ADev", namespace_shield) unless namespace_shield.empty?
@@ -899,6 +592,49 @@ module Kettle
899
592
  def gemspec_metadata(root = project_root)
900
593
  Kettle::Dev::GemSpecReader.load(root)
901
594
  end
595
+
596
+ def apply_strategy(content, dest_path)
597
+ return content unless ruby_template?(dest_path)
598
+ strategy = strategy_for(dest_path)
599
+ dest_content = File.exist?(dest_path) ? File.read(dest_path) : ""
600
+ Kettle::Dev::SourceMerger.apply(strategy: strategy, src: content, dest: dest_content, path: rel_path(dest_path))
601
+ end
602
+
603
+ def manifestation
604
+ @@manifestation ||= load_manifest
605
+ end
606
+
607
+ def strategy_for(dest_path)
608
+ relative = rel_path(dest_path)
609
+ manifestation.find do |entry|
610
+ File.fnmatch?(entry[:path], relative, File::FNM_PATHNAME | File::FNM_EXTGLOB | File::FNM_DOTMATCH)
611
+ end&.fetch(:strategy, :skip) || :skip
612
+ end
613
+
614
+ def rel_path(path)
615
+ project = project_root.to_s
616
+ path.to_s.sub(/^#{Regexp.escape(project)}\/?/, "")
617
+ end
618
+
619
+ def ruby_template?(dest_path)
620
+ base = File.basename(dest_path.to_s)
621
+ return true if RUBY_BASENAMES.include?(base)
622
+ return true if RUBY_SUFFIXES.any? { |suffix| base.end_with?(suffix) }
623
+ ext = File.extname(base)
624
+ RUBY_EXTENSIONS.include?(ext)
625
+ end
626
+
627
+ def load_manifest
628
+ raw = YAML.load_file(TEMPLATE_MANIFEST_PATH)
629
+ raw.map do |entry|
630
+ {
631
+ path: entry["path"],
632
+ strategy: entry["strategy"].to_s.strip.downcase.to_sym,
633
+ }
634
+ end
635
+ rescue Errno::ENOENT
636
+ []
637
+ end
902
638
  end
903
639
  end
904
640
  end
@@ -6,7 +6,7 @@ module Kettle
6
6
  module Version
7
7
  # The gem version.
8
8
  # @return [String]
9
- VERSION = "1.1.60"
9
+ VERSION = "1.2.1"
10
10
 
11
11
  module_function
12
12
 
data/lib/kettle/dev.rb CHANGED
@@ -21,8 +21,13 @@ module Kettle
21
21
  autoload :GitAdapter, "kettle/dev/git_adapter"
22
22
  autoload :GitCommitFooter, "kettle/dev/git_commit_footer"
23
23
  autoload :InputAdapter, "kettle/dev/input_adapter"
24
+ autoload :PrismUtils, "kettle/dev/prism_utils"
25
+ autoload :PrismGemspec, "kettle/dev/prism_gemspec"
26
+ autoload :PrismGemfile, "kettle/dev/prism_gemfile"
27
+ autoload :PrismAppraisals, "kettle/dev/prism_appraisals"
24
28
  autoload :ReadmeBackers, "kettle/dev/readme_backers"
25
29
  autoload :OpenCollectiveConfig, "kettle/dev/open_collective_config"
30
+ autoload :SourceMerger, "kettle/dev/source_merger"
26
31
  autoload :ReleaseCLI, "kettle/dev/release_cli"
27
32
  autoload :PreReleaseCLI, "kettle/dev/pre_release_cli"
28
33
  autoload :SetupCLI, "kettle/dev/setup_cli"
@@ -74,15 +79,30 @@ module Kettle
74
79
  @defaults = []
75
80
 
76
81
  class << self
77
- # Emit a debug warning for rescued errors when DEBUG=true.
82
+ # Emit a debug warning for rescued errors when kettle-dev debugging is enabled.
83
+ # Controlled by KETTLE_DEV_DEBUG=true (or DEBUG=true as fallback).
78
84
  # @param error [Exception]
79
85
  # @param context [String, Symbol, nil] optional label, often __method__
80
86
  # @return [void]
81
87
  def debug_error(error, context = nil)
82
88
  return unless DEBUGGING
83
- ctx = context ? context.to_s : "rescue"
89
+
90
+ ctx = context ? context.to_s : "KETTLE-DEV-RESCUE"
84
91
  Kernel.warn("[#{ctx}] #{error.class}: #{error.message}")
85
- rescue Exception
92
+ rescue StandardError
93
+ # never raise from debug logging
94
+ end
95
+
96
+ # Emit a debug log line when kettle-dev debugging is enabled.
97
+ # Controlled by KETTLE_DEV_DEBUG=true (or DEBUG=true as fallback).
98
+ # @param msg [String]
99
+ # @return [void]
100
+ def debug_log(msg, context = nil)
101
+ return unless DEBUGGING
102
+
103
+ ctx = context ? context.to_s : "KETTLE-DEV-DEBUG"
104
+ Kernel.warn("[#{ctx}] #{msg}")
105
+ rescue StandardError
86
106
  # never raise from debug logging
87
107
  end
88
108
 
@@ -0,0 +1,72 @@
1
+ # TypeProf 0.21.11
2
+
3
+ module Kettle
4
+ module Dev
5
+ # AST-driven merger for Appraisals files using Prism
6
+ module AppraisalsAstMerger
7
+ TRACKED_METHODS: Array[Symbol]
8
+
9
+ # Merge template and destination Appraisals files preserving comments
10
+ def self.merge: (String template_content, String dest_content) -> String
11
+
12
+ # Extract blocks and preamble from parse result
13
+ def self.extract_blocks: (
14
+ Prism::ParseResult parse_result,
15
+ String source_content
16
+ ) -> [Array[Prism::Comment], Array[Hash[Symbol, untyped]]]
17
+
18
+ # Check if node is an appraise call
19
+ def self.appraise_call?: (Prism::Node node) -> bool
20
+
21
+ # Extract appraise block name from node
22
+ def self.extract_appraise_name: (Prism::Node? node) -> String?
23
+
24
+ # Merge preamble comments from template and destination
25
+ def self.merge_preambles: (
26
+ Array[Prism::Comment] tmpl_comments,
27
+ Array[Prism::Comment] dest_comments
28
+ ) -> Array[String]
29
+
30
+ # Extract block header comments
31
+ def self.extract_block_header: (
32
+ Prism::Node node,
33
+ Array[String] source_lines,
34
+ Array[Hash[Symbol, untyped]] previous_blocks
35
+ ) -> String
36
+
37
+ # Merge blocks from template and destination
38
+ def self.merge_blocks: (
39
+ Array[Hash[Symbol, untyped]] tmpl_blocks,
40
+ Array[Hash[Symbol, untyped]] dest_blocks,
41
+ Prism::ParseResult tmpl_result,
42
+ Prism::ParseResult dest_result
43
+ ) -> Array[Hash[Symbol, untyped]]
44
+
45
+ # Merge statements within a block
46
+ def self.merge_block_statements: (
47
+ Prism::Node? tmpl_body,
48
+ Prism::Node? dest_body,
49
+ Prism::ParseResult dest_result
50
+ ) -> Array[Hash[Symbol, untyped]]
51
+
52
+ # Generate statement key for deduplication
53
+ def self.statement_key: (Prism::Node node) -> [Symbol, String?]?
54
+
55
+ # Build output from preamble and blocks
56
+ def self.build_output: (
57
+ Array[String] preamble_lines,
58
+ Array[Hash[Symbol, untyped]] blocks
59
+ ) -> String
60
+
61
+ # Normalize statement to use parentheses
62
+ def self.normalize_statement: (Prism::Node node) -> String
63
+
64
+ # Normalize argument to canonical format
65
+ def self.normalize_argument: (Prism::Node arg) -> String
66
+
67
+ # Extract original statements from node
68
+ def self.extract_original_statements: (Prism::Node node) -> Array[Hash[Symbol, untyped]]
69
+ end
70
+ end
71
+ end
72
+
@@ -0,0 +1,64 @@
1
+ # TypeProf 0.21.11
2
+
3
+ module Kettle
4
+ module Dev
5
+ # CLI for updating CHANGELOG.md with new version sections
6
+ class ChangelogCLI
7
+ UNRELEASED_SECTION_HEADING: String
8
+
9
+ @root: String
10
+ @changelog_path: String
11
+ @coverage_path: String
12
+
13
+ def initialize: () -> void
14
+
15
+ # Main entry point that updates CHANGELOG.md
16
+ def run: () -> void
17
+
18
+ private
19
+
20
+ # Abort with error message
21
+ def abort: (String msg) -> void
22
+
23
+ # Detect version from lib/**/version.rb
24
+ def detect_version: () -> String
25
+
26
+ # Extract unreleased section from changelog
27
+ def extract_unreleased: (String content) -> [String?, String?, String?]
28
+
29
+ # Detect previous version from after text
30
+ def detect_previous_version: (String after_text) -> String?
31
+
32
+ # Filter unreleased sections keeping only those with content
33
+ def filter_unreleased_sections: (String unreleased_block) -> String
34
+
35
+ # Get coverage lines from coverage.json
36
+ def coverage_lines: () -> [String?, String?]
37
+
38
+ # Get YARD documentation percentage
39
+ def yard_percent_documented: () -> String?
40
+
41
+ # Convert legacy heading tag suffix to list format
42
+ def convert_heading_tag_suffix_to_list: (String text) -> String
43
+
44
+ # Update link references in changelog
45
+ def update_link_refs: (
46
+ String content,
47
+ String? owner,
48
+ String? repo,
49
+ String? prev_version,
50
+ String new_version
51
+ ) -> String
52
+
53
+ # Normalize spacing around headings
54
+ def normalize_heading_spacing: (String text) -> String
55
+
56
+ # Ensure proper footer spacing
57
+ def ensure_footer_spacing: (String text) -> String
58
+
59
+ # Detect initial compare base from changelog
60
+ def detect_initial_compare_base: (Array[String] lines) -> String
61
+ end
62
+ end
63
+ end
64
+