kettle-dev 1.1.60 → 1.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: a2c485db803ad10d4d7eb1331a8ed30ce0744c0a045ee768697bfa5098f44707
4
- data.tar.gz: c60544c59a2bd2b0af6d80aaa070055ca74d3a88e8e2b53cd2c94050da03e89e
3
+ metadata.gz: c5ef625cbc6016859cd2c88105ce957e3719465cee97356df1fc77a30c8504c1
4
+ data.tar.gz: e18d15a0d1da90c31991b4b6601b698633dad3e377145724f8fb277ec7b2a75a
5
5
  SHA512:
6
- metadata.gz: b7f36fd6f1cd77538e0ebcff3ae9c96136594a242f95d15aaa4690f84dcdd376efa3be923b26bb6bad6176c5addaa88c18fdd1d38329c9065535c9666897048c
7
- data.tar.gz: d881e65d43eeccd20f540c135821402b046b8601e130d16204e6c7a501d092270af97ebce0a7757345d3c4cf340b22b78e6858b80ff0f6863fe3dcbba93f7e4c
6
+ metadata.gz: 227cb01388512c937d73fddf46deac2edf2b062f93a97b8e788ac0af4e21f8bd1b3a941dc4bf8eabfd5b494401e935e08a8d28d10fc1954b728e7148953e531b
7
+ data.tar.gz: 336fe91aab36fd2ca958967fbc3cfaaa2d6cea7dbb5b1e4d63882526b1271561920d41c016f301b93eb533cb636d252e923882859c9be58fbe4bd586b29d870d
checksums.yaml.gz.sig CHANGED
Binary file
data/CHANGELOG.md CHANGED
@@ -30,6 +30,18 @@ Please file a bug if you notice a violation of semantic versioning.
30
30
 
31
31
  ### Security
32
32
 
33
+ ## [1.2.0] - 2025-11-25
34
+
35
+ - TAG: [v1.2.0][1.2.0t]
36
+ - COVERAGE: 94.38% -- 4066/4308 lines in 26 files
37
+ - BRANCH COVERAGE: 78.81% -- 1674/2124 branches in 26 files
38
+ - 69.14% documented
39
+
40
+ ### Changed
41
+
42
+ - Source merging switched from Regex-based string manipulation to Prism AST-based manipulation
43
+ - Comments are preserved in the resulting file
44
+
33
45
  ## [1.1.60] - 2025-11-23
34
46
 
35
47
  - TAG: [v1.1.60][1.1.60t]
@@ -1458,7 +1470,9 @@ Please file a bug if you notice a violation of semantic versioning.
1458
1470
  - Selecting will run the selected workflow via `act`
1459
1471
  - This may move to its own gem in the future.
1460
1472
 
1461
- [Unreleased]: https://github.com/kettle-rb/kettle-dev/compare/v1.1.60...HEAD
1473
+ [Unreleased]: https://github.com/kettle-rb/kettle-dev/compare/v1.2.0...HEAD
1474
+ [1.2.0]: https://github.com/kettle-rb/kettle-dev/compare/v1.1.60...v1.2.0
1475
+ [1.2.0t]: https://github.com/kettle-rb/kettle-dev/releases/tag/v1.2.0
1462
1476
  [1.1.60]: https://github.com/kettle-rb/kettle-dev/compare/v1.1.59...v1.1.60
1463
1477
  [1.1.60t]: https://github.com/kettle-rb/kettle-dev/releases/tag/v1.1.60
1464
1478
  [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.0 - 2025-11-25
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"
@@ -0,0 +1,383 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "prism"
4
+ require "kettle/dev/prism_utils"
5
+
6
+ module Kettle
7
+ module Dev
8
+ # AST-driven merger for Appraisals files using Prism.
9
+ # Preserves all comments: preamble headers, block headers, and inline comments.
10
+ # Uses PrismUtils for shared Prism AST operations.
11
+ module AppraisalsAstMerger
12
+ TRACKED_METHODS = [:gem, :eval_gemfile, :gemfile].freeze
13
+
14
+ module_function
15
+
16
+ # Merge template and destination Appraisals files preserving comments
17
+ #
18
+ # Merges appraise blocks by name, preserving:
19
+ # - Preamble comments (deduplicated)
20
+ # - Block headers (comments before each appraise block)
21
+ # - Inline comments on gem/eval_gemfile statements
22
+ # - Leading comments before statements
23
+ #
24
+ # @param template_content [String] Template Appraisals content
25
+ # @param dest_content [String] Destination Appraisals content
26
+ # @return [String] Merged Appraisals content with preserved comments
27
+ # @example
28
+ # AppraisalsAstMerger.merge(
29
+ # 'appraise "rails-7" { gem "rails", "~> 7.0" }',
30
+ # 'appraise "custom" { gem "foo" }'
31
+ # )
32
+ def merge(template_content, dest_content)
33
+ template_content ||= ""
34
+ dest_content ||= ""
35
+
36
+ return template_content if dest_content.strip.empty?
37
+ return dest_content if template_content.strip.empty?
38
+
39
+ # Parse with Prism to get AST and comments
40
+ tmpl_result = PrismUtils.parse_with_comments(template_content)
41
+ dest_result = PrismUtils.parse_with_comments(dest_content)
42
+
43
+ # Extract preamble and blocks with their headers
44
+ tmpl_preamble, tmpl_blocks = extract_blocks(tmpl_result, template_content)
45
+ dest_preamble, dest_blocks = extract_blocks(dest_result, dest_content)
46
+
47
+ # Merge preambles
48
+ merged_preamble = merge_preambles(tmpl_preamble, dest_preamble)
49
+
50
+ # Merge blocks
51
+ merged_blocks = merge_blocks(tmpl_blocks, dest_blocks, tmpl_result, dest_result)
52
+
53
+ # Build output
54
+ build_output(merged_preamble, merged_blocks)
55
+ end
56
+
57
+ def extract_blocks(parse_result, source_content)
58
+ root = parse_result.value
59
+ return [[], []] unless root&.statements&.body
60
+
61
+ source_lines = source_content.lines
62
+ blocks = []
63
+ first_appraise_line = nil
64
+
65
+ # Find all appraise blocks
66
+ root.statements.body.each do |node|
67
+ if appraise_call?(node)
68
+ first_appraise_line ||= node.location.start_line
69
+ name = extract_appraise_name(node)
70
+ next unless name
71
+
72
+ # Extract block header (comments immediately before this block)
73
+ block_header = extract_block_header(node, source_lines, blocks)
74
+
75
+ blocks << {
76
+ node: node,
77
+ name: name,
78
+ header: block_header,
79
+ }
80
+ end
81
+ end
82
+
83
+ # Preamble is all comments before first appraise block
84
+ preamble_comments = if first_appraise_line
85
+ parse_result.comments.select { |c| c.location.start_line < first_appraise_line }
86
+ else
87
+ parse_result.comments
88
+ end
89
+
90
+ # Filter out comments that are part of block headers
91
+ block_header_lines = blocks.flat_map { |b| b[:header].lines.map { |l| l.strip } }.to_set
92
+ preamble_comments = preamble_comments.reject { |c| block_header_lines.include?(c.slice.strip) }
93
+
94
+ [preamble_comments, blocks]
95
+ end
96
+
97
+ def appraise_call?(node)
98
+ PrismUtils.block_call_to?(node, :appraise)
99
+ end
100
+
101
+ def extract_appraise_name(node)
102
+ return unless node.is_a?(Prism::CallNode)
103
+ # Use PrismUtils for extracting literal values
104
+ PrismUtils.extract_literal_value(node.arguments&.arguments&.first)
105
+ end
106
+
107
+ def merge_preambles(tmpl_comments, dest_comments)
108
+ tmpl_lines = tmpl_comments.map { |c| c.slice.strip }
109
+ dest_lines = dest_comments.map { |c| c.slice.strip }
110
+
111
+ # Remove magic comments from dest if template has them
112
+ magic_pattern = /^#.*frozen_string_literal/
113
+ if tmpl_lines.any? { |line| line.match?(magic_pattern) }
114
+ dest_lines.reject! { |line| line.match?(magic_pattern) }
115
+ end
116
+
117
+ # Merge unique lines (case-insensitive), template first
118
+ merged = []
119
+ seen = Set.new
120
+
121
+ (tmpl_lines + dest_lines).each do |line|
122
+ normalized = line.downcase
123
+ unless seen.include?(normalized)
124
+ merged << line
125
+ seen << normalized
126
+ end
127
+ end
128
+
129
+ merged
130
+ end
131
+
132
+ def extract_block_header(node, source_lines, previous_blocks)
133
+ # Get the line number where this block starts (1-indexed from Prism)
134
+ begin_line = node.location.start_line
135
+
136
+ # Find the end of the previous block or start of file
137
+ min_line = if previous_blocks.empty?
138
+ 1
139
+ else
140
+ previous_blocks.last[:node].location.end_line + 1
141
+ end
142
+
143
+ # Convert to 0-indexed and look at the line before the block
144
+ check_line = begin_line - 2 # e.g., if block is on line 2 (1-indexed), check line 0 (0-indexed, which is line 1 in file)
145
+
146
+ # Look backwards from the block start to find contiguous comment lines
147
+ header_lines = []
148
+
149
+ while check_line >= 0 && (check_line + 1) >= min_line
150
+ line = source_lines[check_line]
151
+ break unless line
152
+
153
+ # Stop at empty line
154
+ if line.strip.empty?
155
+ break
156
+ elsif line.lstrip.start_with?("#")
157
+ header_lines.unshift(line)
158
+ check_line -= 1
159
+ else
160
+ # Non-comment, non-blank line - stop
161
+ break
162
+ end
163
+ end
164
+
165
+ header_lines.join
166
+ rescue StandardError => e
167
+ Kettle::Dev.debug_error(e, __method__) if defined?(Kettle::Dev.debug_error)
168
+ ""
169
+ end
170
+
171
+ def merge_blocks(template_blocks, dest_blocks, tmpl_result, dest_result)
172
+ merged = []
173
+ dest_by_name = dest_blocks.each_with_object({}) { |b, h| h[b[:name]] = b }
174
+ template_names = template_blocks.map { |b| b[:name] }.to_set
175
+
176
+ # Track which dest blocks we've already placed
177
+ placed_dest = Set.new
178
+
179
+ # For each template block, merge it and insert any dest-only blocks that come before it
180
+ template_blocks.each_with_index do |tmpl_block, idx|
181
+ name = tmpl_block[:name]
182
+
183
+ # Find dest-only blocks that should come before this template block
184
+ # (i.e., blocks that appear in dest before this shared block)
185
+ if idx == 0 || dest_by_name[name]
186
+ # For first template block or when we have a dest version of this block,
187
+ # check if there are dest-only blocks to insert before it
188
+ dest_blocks.each do |db|
189
+ next if template_names.include?(db[:name])
190
+ next if placed_dest.include?(db[:name])
191
+
192
+ # Check if this dest-only block comes before current shared block in dest
193
+ dest_idx_of_shared = dest_blocks.index { |b| b[:name] == name }
194
+ dest_idx_of_only = dest_blocks.index { |b| b[:name] == db[:name] }
195
+
196
+ if dest_idx_of_only && dest_idx_of_shared && dest_idx_of_only < dest_idx_of_shared
197
+ merged << db
198
+ placed_dest << db[:name]
199
+ end
200
+ end
201
+ end
202
+
203
+ # Now add the template block (merged or template-only)
204
+ dest_block = dest_by_name[name]
205
+ if dest_block
206
+ # Merge this block
207
+ merged_header = merge_block_headers(tmpl_block[:header], dest_block[:header])
208
+ merged_statements = merge_block_statements(
209
+ tmpl_block[:node].block.body,
210
+ dest_block[:node].block.body,
211
+ dest_result,
212
+ )
213
+ merged << {
214
+ name: name,
215
+ header: merged_header,
216
+ node: tmpl_block[:node],
217
+ statements: merged_statements,
218
+ }
219
+ placed_dest << name
220
+ else
221
+ # Template-only block
222
+ merged << tmpl_block
223
+ end
224
+ end
225
+
226
+ # Add any remaining destination-only blocks that haven't been placed
227
+ dest_blocks.each do |dest_block|
228
+ next if placed_dest.include?(dest_block[:name])
229
+ next if template_names.include?(dest_block[:name])
230
+ merged << dest_block
231
+ end
232
+
233
+ merged
234
+ end
235
+
236
+ def merge_block_headers(tmpl_header, dest_header)
237
+ tmpl_lines = tmpl_header.to_s.lines.map(&:strip).reject(&:empty?)
238
+ dest_lines = dest_header.to_s.lines.map(&:strip).reject(&:empty?)
239
+
240
+ # Merge without duplicates (case-insensitive comparison), template first
241
+ merged = []
242
+ seen = Set.new
243
+
244
+ (tmpl_lines + dest_lines).each do |line|
245
+ normalized = line.downcase
246
+ unless seen.include?(normalized)
247
+ merged << line
248
+ seen << normalized
249
+ end
250
+ end
251
+
252
+ return "" if merged.empty?
253
+ merged.join("\n") + "\n"
254
+ end
255
+
256
+ def merge_block_statements(tmpl_body, dest_body, dest_result)
257
+ tmpl_stmts = PrismUtils.extract_statements(tmpl_body)
258
+ dest_stmts = PrismUtils.extract_statements(dest_body)
259
+
260
+ # Build statement keys for both
261
+ tmpl_keys = Set.new
262
+ tmpl_key_to_node = {}
263
+ tmpl_stmts.each do |stmt|
264
+ key = statement_key(stmt)
265
+ if key
266
+ tmpl_keys << key
267
+ tmpl_key_to_node[key] = stmt
268
+ end
269
+ end
270
+
271
+ dest_keys = Set.new
272
+ dest_stmts.each do |stmt|
273
+ key = statement_key(stmt)
274
+ dest_keys << key if key
275
+ end
276
+
277
+ # Process dest statements in order, preserving their position and comments
278
+ merged = []
279
+ dest_stmts.each_with_index do |dest_stmt, idx|
280
+ dest_key = statement_key(dest_stmt)
281
+
282
+ if dest_key && tmpl_keys.include?(dest_key)
283
+ # Shared statement - use template version but preserve dest position
284
+ # Add it with no comments (template version is canonical)
285
+ merged << {node: tmpl_key_to_node[dest_key], inline_comments: [], leading_comments: [], shared: true, key: dest_key}
286
+ else
287
+ # Dest-only statement - preserve with all comments
288
+ inline_comments = PrismUtils.inline_comments_for_node(dest_result, dest_stmt)
289
+
290
+ prev_stmt = (idx > 0) ? dest_stmts[idx - 1] : nil
291
+ leading_comments = PrismUtils.find_leading_comments(dest_result, dest_stmt, prev_stmt, dest_body)
292
+
293
+ merged << {node: dest_stmt, inline_comments: inline_comments, leading_comments: leading_comments, shared: false}
294
+ end
295
+ end
296
+
297
+ # Add template-only statements (those not in dest) at the end
298
+ tmpl_stmts.each do |tmpl_stmt|
299
+ tmpl_key = statement_key(tmpl_stmt)
300
+ unless tmpl_key && dest_keys.include?(tmpl_key)
301
+ merged << {node: tmpl_stmt, inline_comments: [], leading_comments: [], shared: false}
302
+ end
303
+ end
304
+
305
+ # Clean up - remove the tracking fields
306
+ merged.each do |item|
307
+ item.delete(:shared)
308
+ item.delete(:key)
309
+ end
310
+
311
+ merged
312
+ end
313
+
314
+ def statement_key(node)
315
+ # Use PrismUtils for statement key generation with Appraisals-specific tracked methods
316
+ PrismUtils.statement_key(node, tracked_methods: TRACKED_METHODS)
317
+ end
318
+
319
+ def build_output(preamble_lines, blocks)
320
+ output = []
321
+
322
+ # Add preamble
323
+ preamble_lines.each { |line| output << line }
324
+ output << "" unless preamble_lines.empty?
325
+
326
+ # Add blocks
327
+ blocks.each do |block|
328
+ # Add block header (no blank line before it)
329
+ header = block[:header]
330
+ if header && !header.strip.empty?
331
+ output << header.rstrip
332
+ end
333
+
334
+ # Add appraise call - using parentheses and curly braces format
335
+ name = block[:name]
336
+ output << "appraise(\"#{name}\") {"
337
+
338
+ # Add statements
339
+ statements = block[:statements] || extract_original_statements(block[:node])
340
+ statements.each do |stmt_info|
341
+ # Add any leading comments before this statement
342
+ leading = stmt_info[:leading_comments] || []
343
+ leading.each do |comment|
344
+ output << " #{comment.slice.strip}"
345
+ end
346
+
347
+ node = stmt_info[:node]
348
+ # Normalize the statement to use parentheses
349
+ line = normalize_statement(node)
350
+
351
+ inline = stmt_info[:inline_comments] || []
352
+ inline_str = inline.map { |c| c.slice.strip }.join(" ")
353
+ output << " #{line}#{" #{inline_str}" unless inline_str.empty?}"
354
+ end
355
+
356
+ output << "}"
357
+ output << ""
358
+ end
359
+
360
+ output.join("\n").strip + "\n"
361
+ end
362
+
363
+ def normalize_statement(node)
364
+ # Use PrismUtils for normalizing call nodes
365
+ return PrismUtils.node_to_source(node) unless node.is_a?(Prism::CallNode)
366
+ PrismUtils.normalize_call_node(node)
367
+ end
368
+
369
+ def normalize_argument(arg)
370
+ # Use PrismUtils for argument normalization
371
+ PrismUtils.normalize_argument(arg)
372
+ end
373
+
374
+ def extract_original_statements(node)
375
+ body = node.block&.body
376
+ return [] unless body
377
+
378
+ statements = body.is_a?(Prism::StatementsNode) ? body.body : [body]
379
+ statements.compact.map { |stmt| {node: stmt, inline_comments: [], leading_comments: []} }
380
+ end
381
+ end
382
+ end
383
+ end
@@ -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")