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.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: a2c485db803ad10d4d7eb1331a8ed30ce0744c0a045ee768697bfa5098f44707
4
- data.tar.gz: c60544c59a2bd2b0af6d80aaa070055ca74d3a88e8e2b53cd2c94050da03e89e
3
+ metadata.gz: cf48cdba2c3cbe1c95eab4c61a4073f7849fda6b7dbc41ec2dedaca59b414242
4
+ data.tar.gz: 171598b7227ced01474c97a14a8167d3c6c64785b08daeb6996e64d1ed42973d
5
5
  SHA512:
6
- metadata.gz: b7f36fd6f1cd77538e0ebcff3ae9c96136594a242f95d15aaa4690f84dcdd376efa3be923b26bb6bad6176c5addaa88c18fdd1d38329c9065535c9666897048c
7
- data.tar.gz: d881e65d43eeccd20f540c135821402b046b8601e130d16204e6c7a501d092270af97ebce0a7757345d3c4cf340b22b78e6858b80ff0f6863fe3dcbba93f7e4c
6
+ metadata.gz: 224a2b34db6d14760ae6105eb67407baebb25a67d7ac1ccad37eaa01823278d3d6c68e6822430f47d986c756f232ce637df248184ec61ca7a4394df3c4ced4c8
7
+ data.tar.gz: f8d133803fdbcf798e05faf1749a3e03efc539d5513142bbe76da9b3954662cf043ae868b79151c0c1cb84129f15b4e3e3af7a9764e301d14134fa352ba38ff5
checksums.yaml.gz.sig CHANGED
Binary file
data/CHANGELOG.md CHANGED
@@ -20,6 +20,13 @@ Please file a bug if you notice a violation of semantic versioning.
20
20
 
21
21
  ### Added
22
22
 
23
+ - Prism AST-based manipulation of ruby during templating
24
+ - Gemfiles
25
+ - gemspecs
26
+ - .simplecov
27
+ - Stop rescuing Exception in certain scenarios (just StandardError)
28
+ - Refactored logging logic and documentation
29
+
23
30
  ### Changed
24
31
 
25
32
  ### Deprecated
@@ -30,6 +37,18 @@ Please file a bug if you notice a violation of semantic versioning.
30
37
 
31
38
  ### Security
32
39
 
40
+ ## [1.2.1] - 2025-11-25
41
+
42
+ - TAG: [v1.2.0][1.2.0t]
43
+ - COVERAGE: 94.38% -- 4066/4308 lines in 26 files
44
+ - BRANCH COVERAGE: 78.81% -- 1674/2124 branches in 26 files
45
+ - 69.14% documented
46
+
47
+ ### Changed
48
+
49
+ - Source merging switched from Regex-based string manipulation to Prism AST-based manipulation
50
+ - Comments are preserved in the resulting file
51
+
33
52
  ## [1.1.60] - 2025-11-23
34
53
 
35
54
  - TAG: [v1.1.60][1.1.60t]
@@ -1458,7 +1477,9 @@ Please file a bug if you notice a violation of semantic versioning.
1458
1477
  - Selecting will run the selected workflow via `act`
1459
1478
  - This may move to its own gem in the future.
1460
1479
 
1461
- [Unreleased]: https://github.com/kettle-rb/kettle-dev/compare/v1.1.60...HEAD
1480
+ [Unreleased]: https://github.com/kettle-rb/kettle-dev/compare/v1.2.0...HEAD
1481
+ [1.2.0]: https://github.com/kettle-rb/kettle-dev/compare/v1.1.60...v1.2.0
1482
+ [1.2.0t]: https://github.com/kettle-rb/kettle-dev/releases/tag/v1.2.0
1462
1483
  [1.1.60]: https://github.com/kettle-rb/kettle-dev/compare/v1.1.59...v1.1.60
1463
1484
  [1.1.60t]: https://github.com/kettle-rb/kettle-dev/releases/tag/v1.1.60
1464
1485
  [1.1.59]: https://github.com/kettle-rb/kettle-dev/compare/v1.1.58...v1.1.59
data/Gemfile CHANGED
@@ -12,6 +12,9 @@ git_source(:gitlab) { |repo_name| "https://gitlab.com/#{repo_name}" }
12
12
  # Include dependencies from <gem name>.gemspec
13
13
  gemspec
14
14
 
15
+ # Templating
16
+ eval_gemfile "gemfiles/modular/templating.gemfile"
17
+
15
18
  # Debugging
16
19
  eval_gemfile "gemfiles/modular/debug.gemfile"
17
20
 
data/Gemfile.example CHANGED
@@ -12,6 +12,9 @@ git_source(:gitlab) { |repo_name| "https://gitlab.com/#{repo_name}" }
12
12
  # Include dependencies from <gem name>.gemspec
13
13
  gemspec
14
14
 
15
+ # Templating
16
+ eval_gemfile "gemfiles/modular/templating.gemfile"
17
+
15
18
  # Debugging
16
19
  eval_gemfile "gemfiles/modular/debug.gemfile"
17
20
 
data/README.md CHANGED
@@ -574,41 +574,84 @@ What it does:
574
574
  - Stages and commits any bootstrap changes with message: `🎨 Template bootstrap by kettle-dev-setup v<version>`.
575
575
  - Executes `bin/rake kettle:dev:install` with the parsed passthrough args.
576
576
 
577
+ ### Template Manifest and AST Strategies
578
+
579
+ `kettle:dev:template` looks at `template_manifest.yml` to determine how each file should be updated. Each entry has a `path` (exact file or glob) and a `strategy`:
580
+
581
+ | Strategy | Behavior |
582
+ | --- | --- |
583
+ | `skip` | Legacy behavior: template content is copied with token replacements and any bespoke merge logic already in place. |
584
+ | `replace` | Template AST replaces the destination outside of `kettle-dev:freeze` sections. |
585
+ | `append` | Only missing AST nodes (e.g., `gem` or `task` declarations) are appended; existing nodes remain untouched. |
586
+ | `merge` | Destination nodes are updated in-place using the template AST (used for `Gemfile`, `*.gemspec`, and `Rakefile`). |
587
+
588
+ All Ruby files receive this reminder (inserted after shebang/frozen-string-literal lines):
589
+
590
+ ```
591
+ # To force retention during kettle-dev templating:
592
+ # kettle-dev:freeze
593
+ # # ... your code
594
+ # kettle-dev:unfreeze
595
+ ```
596
+
597
+ Wrap any code you never want rewritten between `kettle-dev:freeze` / `kettle-dev:unfreeze` comments. When an AST merge fails, the task emits an error asking you to file an issue at https://github.com/kettle-rb/kettle-dev/issues and then aborts—there is no regex fallback.
598
+
599
+ ### Template Example
600
+
601
+ Here is an example `template_manifest.yml`:
602
+
603
+ ```yaml
604
+ # For each file or glob, specify a strategy for how it should be managed.
605
+ # See https://github.com/kettle-rb/kettle-dev/blob/main/docs/README.md#template-manifest-and-ast-strategies
606
+ # for details on each strategy.
607
+ files:
608
+ - path: "Gemfile"
609
+ strategy: "merge"
610
+ - path: "*.gemspec"
611
+ strategy: "merge"
612
+ - path: "Rakefile"
613
+ strategy: "merge"
614
+ - path: "README.md"
615
+ strategy: "replace"
616
+ - path: ".env.local"
617
+ strategy: "skip"
618
+ ```
619
+
577
620
  ### Open Collective README updater
578
621
 
579
622
  - Script: `exe/kettle-readme-backers` (run as `kettle-readme-backers`)
580
623
  - Purpose: Updates README sections for Open Collective backers (individuals) and sponsors (organizations) by fetching live data from your collective.
581
624
  - Tags updated in README.md (first match wins for backers):
582
- - The default tag prefix is `OPENCOLLECTIVE`, and it is configurable:
583
- - ENV: `KETTLE_DEV_BACKER_README_OSC_TAG="OPENCOLLECTIVE"`
584
- - YAML (.opencollective.yml): `readme-osc-tag: "OPENCOLLECTIVE"`
585
- - The resulting markers become: `<!-- <TAG>:START --> … <!-- <TAG>:END -->`, `<!-- <TAG>-INDIVIDUALS:START --> … <!-- <TAG>-INDIVIDUALS:END -->`, and `<!-- <TAG>-ORGANIZATIONS:START --> … <!-- <TAG>-ORGANIZATIONS:END -->`.
586
- - ENV overrides YAML.
587
- - Backers (Individuals): `<!-- <TAG>:START --> … <!-- <TAG>:END -->` or `<!-- <TAG>-INDIVIDUALS:START --> … <!-- <TAG>-INDIVIDUALS:END -->`
588
- - Sponsors (Organizations): `<!-- <TAG>-ORGANIZATIONS:START --> … <!-- <TAG>-ORGANIZATIONS:END -->`
625
+ - The default tag prefix is `OPENCOLLECTIVE`, and it is configurable:
626
+ - ENV: `KETTLE_DEV_BACKER_README_OSC_TAG="OPENCOLLECTIVE"`
627
+ - YAML (.opencollective.yml): `readme-osc-tag: "OPENCOLLECTIVE"`
628
+ - The resulting markers become: `<!-- <TAG>:START --> … <!-- <TAG>:END -->`, `<!-- <TAG>-INDIVIDUALS:START --> … <!-- <TAG>-INDIVIDUALS:END -->`, and `<!-- <TAG>-ORGANIZATIONS:START --> … <!-- <TAG>-ORGANIZATIONS:END -->`.
629
+ - ENV overrides YAML.
630
+ - Backers (Individuals): `<!-- <TAG>:START --> … <!-- <TAG>:END -->` or `<!-- <TAG>-INDIVIDUALS:START --> … <!-- <TAG>-INDIVIDUALS:END -->`
631
+ - Sponsors (Organizations): `<!-- <TAG>-ORGANIZATIONS:START --> … <!-- <TAG>-ORGANIZATIONS:END -->`
589
632
  - Handle resolution:
590
- 1. `OPENCOLLECTIVE_HANDLE` environment variable, if set
591
- 2. `opencollective.yml` in the project root (e.g., `collective: "kettle-rb"` in this repo)
633
+ 1. `OPENCOLLECTIVE_HANDLE` environment variable, if set
634
+ 2. `opencollective.yml` in the project root (e.g., `collective: "kettle-rb"` in this repo)
592
635
  - Usage:
593
- - `exe/kettle-readme-backers`
594
- - `OPENCOLLECTIVE_HANDLE=my-collective exe/kettle-readme-backers`
636
+ - `exe/kettle-readme-backers`
637
+ - `OPENCOLLECTIVE_HANDLE=my-collective exe/kettle-readme-backers`
595
638
  - Behavior:
596
- - Writes to README.md only if content between the tags would change.
597
- - If neither the backers nor sponsors tags are present, prints a helpful warning and exits with status 2.
598
- - When there are no entries, inserts a friendly placeholder: "No backers yet. Be the first!" or "No sponsors yet. Be the first!".
599
- - When updates are written and the repository is a git work tree, the script stages README.md and commits with a message thanking new backers and subscribers, including mentions for any newly added backers and subscribers (GitHub @handles when their website/profile is a github.com URL; otherwise their name).
600
- - Customize the commit subject via env var: `KETTLE_README_BACKERS_COMMIT_SUBJECT="💸 Thanks 🙏 to our new backers 🎒 and subscribers 📜"`.
601
- - Or via .opencollective.yml: set `readme-backers-commit-subject: "💸 Thanks 🙏 to our new backers 🎒 and subscribers 📜"`.
602
- - Precedence: ENV overrides .opencollective.yml; if neither is set, a sensible default is used.
603
- - Note: When used with the provided `.git-hooks`, the subject should start with a gitmoji character (see [gitmoji][📌gitmoji]).
639
+ - Writes to README.md only if content between the tags would change.
640
+ - If neither the backers nor sponsors tags are present, prints a helpful warning and exits with status 2.
641
+ - When there are no entries, inserts a friendly placeholder: "No backers yet. Be the first!" or "No sponsors yet. Be the first!".
642
+ - When updates are written and the repository is a git work tree, the script stages README.md and commits with a message thanking new backers and subscribers, including mentions for any newly added backers and subscribers (GitHub @handles when their website/profile is a github.com URL; otherwise their name).
643
+ - Customize the commit subject via env var: `KETTLE_README_BACKERS_COMMIT_SUBJECT="💸 Thanks 🙏 to our new backers 🎒 and subscribers 📜"`.
644
+ - Or via .opencollective.yml: set `readme-backers-commit-subject: "💸 Thanks 🙏 to our new backers 🎒 and subscribers 📜"`.
645
+ - Precedence: ENV overrides .opencollective.yml; if neither is set, a sensible default is used.
646
+ - Note: When used with the provided `.git-hooks`, the subject should start with a gitmoji character (see [gitmoji][📌gitmoji]).
604
647
  - Tip:
605
- - Run this locally before committing to keep your README current, or schedule it in CI to refresh periodically.
606
- - It runs automatically on a once-a-week schedule by the .github/workflows/opencollective.yml workflow that is part of the kettle-dev template.
648
+ - Run this locally before committing to keep your README current, or schedule it in CI to refresh periodically.
649
+ - It runs automatically on a once-a-week schedule by the .github/workflows/opencollective.yml workflow that is part of the kettle-dev template.
607
650
  - Authentication requirement:
608
- - When running in CI with the provided workflow, you must provide an organization-level Actions secret named `README_UPDATER_TOKEN`.
609
- - Create it under your GitHub organization settings: `https://github.com/organizations/<YOUR_ORG>/settings/secrets/actions`.
610
- - The updater will look for `REPO` or `GITHUB_REPOSITORY` (both usually set by GitHub Actions) to infer `<YOUR_ORG>` for guidance.
611
- - If `README_UPDATER_TOKEN` is missing, the tool prints a helpful error to STDERR and aborts, including a direct link to the expected org settings page.
651
+ - When running in CI with the provided workflow, you must provide an organization-level Actions secret named `README_UPDATER_TOKEN`.
652
+ - Create it under your GitHub organization settings: `https://github.com/organizations/<YOUR_ORG>/settings/secrets/actions`.
653
+ - The updater will look for `REPO` or `GITHUB_REPOSITORY` (both usually set by GitHub Actions) to infer `<YOUR_ORG>` for guidance.
654
+ - If `README_UPDATER_TOKEN` is missing, the tool prints a helpful error to STDERR and aborts, including a direct link to the expected org settings page.
612
655
 
613
656
  ## 🦷 FLOSS Funding
614
657
 
@@ -679,6 +722,12 @@ We [![Keep A Changelog][📗keep-changelog-img]][📗keep-changelog] so if you m
679
722
 
680
723
  See [CONTRIBUTING.md][🤝contributing] for more detailed instructions.
681
724
 
725
+ ### Roadmap
726
+
727
+ - [ ] Template the RSpec test harness.
728
+ - [ ] Enhance gitlab pipeline configuration.
729
+ - [ ] Add focused, packaged, named, templating strategies, allowing, for example, only refreshing the Appraisals related template files.
730
+
682
731
  ### 🚀 Release Instructions
683
732
 
684
733
  See [CONTRIBUTING.md][🤝contributing].
data/Rakefile.example CHANGED
@@ -1,6 +1,6 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- # kettle-dev Rakefile v1.1.60 - 2025-11-23
3
+ # kettle-dev Rakefile v1.2.1 - 2025-11-26
4
4
  # Ruby 2.3 (Safe Navigation) or higher required
5
5
  #
6
6
  # MIT License (see License.txt)
@@ -13,7 +13,7 @@ gem "rubocop-on-rbs", "~> 1.8" # ruby >= 3.1.0
13
13
  gem "benchmark", "~> 0.4", ">= 0.4.1" # Removed from Std Lib in Ruby 3.5
14
14
 
15
15
  if ENV.fetch("RUBOCOP_LTS_LOCAL", "false").casecmp("true").zero?
16
- home = ENV["HOME"]
16
+ home = ENV["HOME"] || Dir.home
17
17
  gem "rubocop-lts", path: "#{home}/src/rubocop-lts/rubocop-lts"
18
18
  gem "rubocop-lts-rspec", path: "#{home}/src/rubocop-lts/rubocop-lts-rspec"
19
19
  gem "{RUBOCOP|RUBY|GEM}", path: "#{home}/src/rubocop-lts/{RUBOCOP|RUBY|GEM}"
@@ -0,0 +1,3 @@
1
+ # Ruby parsing for advanced templating
2
+ gem "prism", "~> 1.6"
3
+ gem "unparser", "~> 0.8", ">= 0.8.1"
@@ -2,14 +2,27 @@
2
2
 
3
3
  module Kettle
4
4
  module Dev
5
+ # CLI for updating CHANGELOG.md with new version sections
6
+ #
7
+ # Automatically extracts unreleased changes, formats them into a new version section,
8
+ # includes coverage and YARD stats, and updates link references.
5
9
  class ChangelogCLI
6
10
  UNRELEASED_SECTION_HEADING = "[Unreleased]:"
11
+
12
+ # Initialize the changelog CLI
13
+ # Sets up paths for CHANGELOG.md and coverage.json
7
14
  def initialize
8
15
  @root = Kettle::Dev::CIHelpers.project_root
9
16
  @changelog_path = File.join(@root, "CHANGELOG.md")
10
17
  @coverage_path = File.join(@root, "coverage", "coverage.json")
11
18
  end
12
19
 
20
+ # Main entry point to update CHANGELOG.md
21
+ #
22
+ # Detects current version, extracts unreleased changes, formats them into
23
+ # a new version section with coverage/YARD stats, and updates all link references.
24
+ #
25
+ # @return [void]
13
26
  def run
14
27
  version = Kettle::Dev::Versioning.detect_version(@root)
15
28
  today = Time.now.strftime("%Y-%m-%d")
@@ -22,19 +22,24 @@ module Kettle
22
22
  def sync!(helpers:, project_root:, gem_checkout_root:, min_ruby: nil)
23
23
  # 4a) gemfiles/modular/*.gemfile except style.gemfile (handled below)
24
24
  # Note: `injected.gemfile` is only intended for testing this gem, and isn't even actively used there. It is not part of the template.
25
+ # Note: `style.gemfile` is handled separately below.
25
26
  modular_gemfiles = %w[
26
27
  coverage
27
28
  debug
28
29
  documentation
29
30
  optional
30
31
  runtime_heads
32
+ templating
31
33
  x_std_libs
32
34
  ]
33
35
  modular_gemfiles.each do |base|
34
36
  modular_gemfile = "#{base}.gemfile"
35
37
  src = helpers.prefer_example(File.join(gem_checkout_root, MODULAR_GEMFILE_DIR, modular_gemfile))
36
38
  dest = File.join(project_root, MODULAR_GEMFILE_DIR, modular_gemfile)
37
- helpers.copy_file_with_prompt(src, dest, allow_create: true, allow_replace: true)
39
+ helpers.copy_file_with_prompt(src, dest, allow_create: true, allow_replace: true) do |content|
40
+ # Use apply_strategy for proper AST-based merging with Prism
41
+ helpers.apply_strategy(content, dest)
42
+ end
38
43
  end
39
44
 
40
45
  # 4b) gemfiles/modular/style.gemfile with dynamic rubocop constraints
@@ -92,10 +97,14 @@ module Kettle
92
97
  token = "{RUBOCOP|RUBY|GEM}"
93
98
  content.gsub!(token, "rubocop-ruby#{rubocop_ruby_gem_version}") if content.include?(token)
94
99
  end
95
- content
100
+ # Use apply_strategy for proper AST-based merging with Prism
101
+ helpers.apply_strategy(content, dest)
96
102
  end
97
103
  else
98
- helpers.copy_file_with_prompt(src, dest, allow_create: true, allow_replace: true)
104
+ helpers.copy_file_with_prompt(src, dest, allow_create: true, allow_replace: true) do |content|
105
+ # Use apply_strategy for proper AST-based merging with Prism
106
+ helpers.apply_strategy(content, dest)
107
+ end
99
108
  end
100
109
 
101
110
  # 4c) Copy modular directories with nested/versioned files
@@ -0,0 +1,306 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "prism"
4
+
5
+ module Kettle
6
+ module Dev
7
+ # AST-driven merger for Appraisals files using Prism.
8
+ # Preserves all comments: preamble headers, block headers, and inline comments.
9
+ # Uses PrismUtils for shared Prism AST operations.
10
+ module PrismAppraisals
11
+ TRACKED_METHODS = [:gem, :eval_gemfile, :gemfile].freeze
12
+
13
+ module_function
14
+
15
+ # Merge template and destination Appraisals files preserving comments
16
+ def merge(template_content, dest_content)
17
+ template_content ||= ""
18
+ dest_content ||= ""
19
+
20
+ return template_content if dest_content.strip.empty?
21
+ return dest_content if template_content.strip.empty?
22
+
23
+ tmpl_result = PrismUtils.parse_with_comments(template_content)
24
+ dest_result = PrismUtils.parse_with_comments(dest_content)
25
+
26
+ tmpl_preamble, tmpl_blocks = extract_blocks(tmpl_result, template_content)
27
+ dest_preamble, dest_blocks = extract_blocks(dest_result, dest_content)
28
+
29
+ merged_preamble = merge_preambles(tmpl_preamble, dest_preamble)
30
+ merged_blocks = merge_blocks(tmpl_blocks, dest_blocks, tmpl_result, dest_result)
31
+
32
+ build_output(merged_preamble, merged_blocks)
33
+ end
34
+
35
+ # ...existing helper methods copied from original AppraisalsAstMerger...
36
+ def extract_blocks(parse_result, source_content)
37
+ root = parse_result.value
38
+ return [[], []] unless root&.statements&.body
39
+
40
+ source_lines = source_content.lines
41
+ blocks = []
42
+ first_appraise_line = nil
43
+
44
+ root.statements.body.each do |node|
45
+ if appraise_call?(node)
46
+ first_appraise_line ||= node.location.start_line
47
+ name = extract_appraise_name(node)
48
+ next unless name
49
+
50
+ block_header = extract_block_header(node, source_lines, blocks)
51
+
52
+ blocks << {
53
+ node: node,
54
+ name: name,
55
+ header: block_header,
56
+ }
57
+ end
58
+ end
59
+
60
+ preamble_comments = if first_appraise_line
61
+ parse_result.comments.select { |c| c.location.start_line < first_appraise_line }
62
+ else
63
+ parse_result.comments
64
+ end
65
+
66
+ block_header_lines = blocks.flat_map { |b| b[:header].lines.map { |l| l.strip } }.to_set
67
+ preamble_comments = preamble_comments.reject { |c| block_header_lines.include?(c.slice.strip) }
68
+
69
+ [preamble_comments, blocks]
70
+ end
71
+
72
+ def appraise_call?(node)
73
+ PrismUtils.block_call_to?(node, :appraise)
74
+ end
75
+
76
+ def extract_appraise_name(node)
77
+ return unless node.is_a?(Prism::CallNode)
78
+ PrismUtils.extract_literal_value(node.arguments&.arguments&.first)
79
+ end
80
+
81
+ def merge_preambles(tmpl_comments, dest_comments)
82
+ tmpl_lines = tmpl_comments.map { |c| c.slice.strip }
83
+ dest_lines = dest_comments.map { |c| c.slice.strip }
84
+
85
+ magic_pattern = /^#.*frozen_string_literal/
86
+ if tmpl_lines.any? { |line| line.match?(magic_pattern) }
87
+ dest_lines.reject! { |line| line.match?(magic_pattern) }
88
+ end
89
+
90
+ merged = []
91
+ seen = Set.new
92
+
93
+ (tmpl_lines + dest_lines).each do |line|
94
+ normalized = line.downcase
95
+ unless seen.include?(normalized)
96
+ merged << line
97
+ seen << normalized
98
+ end
99
+ end
100
+
101
+ merged
102
+ end
103
+
104
+ def extract_block_header(node, source_lines, previous_blocks)
105
+ begin_line = node.location.start_line
106
+ min_line = if previous_blocks.empty?
107
+ 1
108
+ else
109
+ previous_blocks.last[:node].location.end_line + 1
110
+ end
111
+ check_line = begin_line - 2
112
+ header_lines = []
113
+ while check_line >= 0 && (check_line + 1) >= min_line
114
+ line = source_lines[check_line]
115
+ break unless line
116
+ if line.strip.empty?
117
+ break
118
+ elsif line.lstrip.start_with?("#")
119
+ header_lines.unshift(line)
120
+ check_line -= 1
121
+ else
122
+ break
123
+ end
124
+ end
125
+ header_lines.join
126
+ rescue StandardError => e
127
+ Kettle::Dev.debug_error(e, __method__) if defined?(Kettle::Dev.debug_error)
128
+ ""
129
+ end
130
+
131
+ def merge_blocks(template_blocks, dest_blocks, tmpl_result, dest_result)
132
+ merged = []
133
+ dest_by_name = dest_blocks.each_with_object({}) { |b, h| h[b[:name]] = b }
134
+ template_names = template_blocks.map { |b| b[:name] }.to_set
135
+ placed_dest = Set.new
136
+
137
+ template_blocks.each_with_index do |tmpl_block, idx|
138
+ name = tmpl_block[:name]
139
+ if idx == 0 || dest_by_name[name]
140
+ dest_blocks.each do |db|
141
+ next if template_names.include?(db[:name])
142
+ next if placed_dest.include?(db[:name])
143
+ dest_idx_of_shared = dest_blocks.index { |b| b[:name] == name }
144
+ dest_idx_of_only = dest_blocks.index { |b| b[:name] == db[:name] }
145
+ if dest_idx_of_only && dest_idx_of_shared && dest_idx_of_only < dest_idx_of_shared
146
+ merged << db
147
+ placed_dest << db[:name]
148
+ end
149
+ end
150
+ end
151
+
152
+ dest_block = dest_by_name[name]
153
+ if dest_block
154
+ merged_header = merge_block_headers(tmpl_block[:header], dest_block[:header])
155
+ merged_statements = merge_block_statements(
156
+ tmpl_block[:node].block.body,
157
+ dest_block[:node].block.body,
158
+ dest_result,
159
+ )
160
+ merged << {
161
+ name: name,
162
+ header: merged_header,
163
+ node: tmpl_block[:node],
164
+ statements: merged_statements,
165
+ }
166
+ placed_dest << name
167
+ else
168
+ merged << tmpl_block
169
+ end
170
+ end
171
+
172
+ dest_blocks.each do |dest_block|
173
+ next if placed_dest.include?(dest_block[:name])
174
+ next if template_names.include?(dest_block[:name])
175
+ merged << dest_block
176
+ end
177
+
178
+ merged
179
+ end
180
+
181
+ def merge_block_headers(tmpl_header, dest_header)
182
+ tmpl_lines = tmpl_header.to_s.lines.map(&:strip).reject(&:empty?)
183
+ dest_lines = dest_header.to_s.lines.map(&:strip).reject(&:empty?)
184
+ merged = []
185
+ seen = Set.new
186
+ (tmpl_lines + dest_lines).each do |line|
187
+ normalized = line.downcase
188
+ unless seen.include?(normalized)
189
+ merged << line
190
+ seen << normalized
191
+ end
192
+ end
193
+ return "" if merged.empty?
194
+ merged.join("\n") + "\n"
195
+ end
196
+
197
+ def merge_block_statements(tmpl_body, dest_body, dest_result)
198
+ tmpl_stmts = PrismUtils.extract_statements(tmpl_body)
199
+ dest_stmts = PrismUtils.extract_statements(dest_body)
200
+
201
+ tmpl_keys = Set.new
202
+ tmpl_key_to_node = {}
203
+ tmpl_stmts.each do |stmt|
204
+ key = statement_key(stmt)
205
+ if key
206
+ tmpl_keys << key
207
+ tmpl_key_to_node[key] = stmt
208
+ end
209
+ end
210
+
211
+ dest_keys = Set.new
212
+ dest_stmts.each do |stmt|
213
+ key = statement_key(stmt)
214
+ dest_keys << key if key
215
+ end
216
+
217
+ merged = []
218
+ dest_stmts.each_with_index do |dest_stmt, idx|
219
+ dest_key = statement_key(dest_stmt)
220
+
221
+ if dest_key && tmpl_keys.include?(dest_key)
222
+ merged << {node: tmpl_key_to_node[dest_key], inline_comments: [], leading_comments: [], shared: true, key: dest_key}
223
+ else
224
+ inline_comments = PrismUtils.inline_comments_for_node(dest_result, dest_stmt)
225
+ prev_stmt = (idx > 0) ? dest_stmts[idx - 1] : nil
226
+ leading_comments = PrismUtils.find_leading_comments(dest_result, dest_stmt, prev_stmt, dest_body)
227
+ merged << {node: dest_stmt, inline_comments: inline_comments, leading_comments: leading_comments, shared: false}
228
+ end
229
+ end
230
+
231
+ tmpl_stmts.each do |tmpl_stmt|
232
+ tmpl_key = statement_key(tmpl_stmt)
233
+ unless tmpl_key && dest_keys.include?(tmpl_key)
234
+ merged << {node: tmpl_stmt, inline_comments: [], leading_comments: [], shared: false}
235
+ end
236
+ end
237
+
238
+ merged.each do |item|
239
+ item.delete(:shared)
240
+ item.delete(:key)
241
+ end
242
+
243
+ merged
244
+ end
245
+
246
+ def statement_key(node)
247
+ PrismUtils.statement_key(node, tracked_methods: TRACKED_METHODS)
248
+ end
249
+
250
+ def build_output(preamble_lines, blocks)
251
+ output = []
252
+ preamble_lines.each { |line| output << line }
253
+ output << "" unless preamble_lines.empty?
254
+
255
+ blocks.each do |block|
256
+ header = block[:header]
257
+ if header && !header.strip.empty?
258
+ output << header.rstrip
259
+ end
260
+
261
+ name = block[:name]
262
+ output << "appraise(\"#{name}\") {"
263
+
264
+ statements = block[:statements] || extract_original_statements(block[:node])
265
+ statements.each do |stmt_info|
266
+ leading = stmt_info[:leading_comments] || []
267
+ leading.each do |comment|
268
+ output << " #{comment.slice.strip}"
269
+ end
270
+
271
+ node = stmt_info[:node]
272
+ line = normalize_statement(node)
273
+ # Remove any leading whitespace/newlines from the normalized line
274
+ line = line.to_s.sub(/\A\s+/, "")
275
+
276
+ inline = stmt_info[:inline_comments] || []
277
+ inline_str = inline.map { |c| c.slice.strip }.join(" ")
278
+ output << " #{line}#{" " + inline_str unless inline_str.empty?}"
279
+ end
280
+
281
+ output << "}"
282
+ output << ""
283
+ end
284
+
285
+ build = output.join("\n").strip + "\n"
286
+ build
287
+ end
288
+
289
+ def normalize_statement(node)
290
+ return PrismUtils.node_to_source(node) unless node.is_a?(Prism::CallNode)
291
+ PrismUtils.normalize_call_node(node)
292
+ end
293
+
294
+ def normalize_argument(arg)
295
+ PrismUtils.normalize_argument(arg)
296
+ end
297
+
298
+ def extract_original_statements(node)
299
+ body = node.block&.body
300
+ return [] unless body
301
+ statements = body.is_a?(Prism::StatementsNode) ? body.body : [body]
302
+ statements.compact.map { |stmt| {node: stmt, inline_comments: [], leading_comments: []} }
303
+ end
304
+ end
305
+ end
306
+ end