kettle-dev 1.2.0 → 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: c5ef625cbc6016859cd2c88105ce957e3719465cee97356df1fc77a30c8504c1
4
- data.tar.gz: e18d15a0d1da90c31991b4b6601b698633dad3e377145724f8fb277ec7b2a75a
3
+ metadata.gz: cf48cdba2c3cbe1c95eab4c61a4073f7849fda6b7dbc41ec2dedaca59b414242
4
+ data.tar.gz: 171598b7227ced01474c97a14a8167d3c6c64785b08daeb6996e64d1ed42973d
5
5
  SHA512:
6
- metadata.gz: 227cb01388512c937d73fddf46deac2edf2b062f93a97b8e788ac0af4e21f8bd1b3a941dc4bf8eabfd5b494401e935e08a8d28d10fc1954b728e7148953e531b
7
- data.tar.gz: 336fe91aab36fd2ca958967fbc3cfaaa2d6cea7dbb5b1e4d63882526b1271561920d41c016f301b93eb533cb636d252e923882859c9be58fbe4bd586b29d870d
6
+ metadata.gz: 224a2b34db6d14760ae6105eb67407baebb25a67d7ac1ccad37eaa01823278d3d6c68e6822430f47d986c756f232ce637df248184ec61ca7a4394df3c4ced4c8
7
+ data.tar.gz: f8d133803fdbcf798e05faf1749a3e03efc539d5513142bbe76da9b3954662cf043ae868b79151c0c1cb84129f15b4e3e3af7a9764e301d14134fa352ba38ff5
checksums.yaml.gz.sig CHANGED
@@ -1 +1,2 @@
1
- !�5å����Ԇ��,@K:�v���Fr���%�������&��lv��,j_��r��Wy��yU%GU����6�knu|�b������bg/&��5"K������DF`t<|�q����sǩ�:5^��'B `1����*˲���hVJ�ҥ,��.��*(�y���+�im(�$-�T�W-�x>o%Psw���kt)� �~F�Ȕ�h�‡,�\ �� d̎�������AEkH���>J�S���"9�,Y̛;�̄C�����ZN��ƍ �$hLx`�=�U����� �1J�;l~T��_��FiOm���6�dSс��o�aD^�1��/���Uw�����F|�3aX31��A��T'$���b�N#�P{Fz���v�
1
+ YGZ�ܧ*٫e�.ڴ��C��G7� ��QMhzG��o����7v��<^�x��g�|2��������I@ -N9pG}��D�^��ӓU���F�nA�|3oރ���苊E
2
+ Z�ff�^v� ��hP�xvD���ޓ���HV�~60-6���h��U M��/q�o0}�dy+���k��3����񿡤�tr:��t��$�u����%`���{�`z!XBG+�$[��z��_�Y</l�3���)�X�; �c� ���U=��l$��a��<O0��q��۠�U�nr�f�1y����J޸ �V� ���T񾹈7�T���=�O��
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,7 +37,7 @@ Please file a bug if you notice a violation of semantic versioning.
30
37
 
31
38
  ### Security
32
39
 
33
- ## [1.2.0] - 2025-11-25
40
+ ## [1.2.1] - 2025-11-25
34
41
 
35
42
  - TAG: [v1.2.0][1.2.0t]
36
43
  - COVERAGE: 94.38% -- 4066/4308 lines in 26 files
data/Rakefile.example CHANGED
@@ -1,6 +1,6 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- # kettle-dev Rakefile v1.2.0 - 2025-11-25
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)
@@ -36,9 +36,9 @@ module Kettle
36
36
  modular_gemfile = "#{base}.gemfile"
37
37
  src = helpers.prefer_example(File.join(gem_checkout_root, MODULAR_GEMFILE_DIR, modular_gemfile))
38
38
  dest = File.join(project_root, MODULAR_GEMFILE_DIR, modular_gemfile)
39
- existing = File.exist?(dest) ? File.read(dest) : nil
40
39
  helpers.copy_file_with_prompt(src, dest, allow_create: true, allow_replace: true) do |content|
41
- existing ? helpers.merge_gemfile_dependencies(content, existing) : content
40
+ # Use apply_strategy for proper AST-based merging with Prism
41
+ helpers.apply_strategy(content, dest)
42
42
  end
43
43
  end
44
44
 
@@ -46,7 +46,6 @@ module Kettle
46
46
  modular_gemfile = "style.gemfile"
47
47
  src = helpers.prefer_example(File.join(gem_checkout_root, MODULAR_GEMFILE_DIR, modular_gemfile))
48
48
  dest = File.join(project_root, MODULAR_GEMFILE_DIR, modular_gemfile)
49
- existing_style = File.exist?(dest) ? File.read(dest) : nil
50
49
  if File.basename(src).sub(/\.example\z/, "") == "style.gemfile"
51
50
  helpers.copy_file_with_prompt(src, dest, allow_create: true, allow_replace: true) do |content|
52
51
  # Adjust rubocop-lts constraint based on min_ruby
@@ -98,11 +97,13 @@ module Kettle
98
97
  token = "{RUBOCOP|RUBY|GEM}"
99
98
  content.gsub!(token, "rubocop-ruby#{rubocop_ruby_gem_version}") if content.include?(token)
100
99
  end
101
- existing_style ? helpers.merge_gemfile_dependencies(content, existing_style) : content
100
+ # Use apply_strategy for proper AST-based merging with Prism
101
+ helpers.apply_strategy(content, dest)
102
102
  end
103
103
  else
104
104
  helpers.copy_file_with_prompt(src, dest, allow_create: true, allow_replace: true) do |content|
105
- existing_style ? helpers.merge_gemfile_dependencies(content, existing_style) : content
105
+ # Use apply_strategy for proper AST-based merging with Prism
106
+ helpers.apply_strategy(content, dest)
106
107
  end
107
108
  end
108
109
 
@@ -1,34 +1,18 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  require "prism"
4
- require "kettle/dev/prism_utils"
5
4
 
6
5
  module Kettle
7
6
  module Dev
8
7
  # AST-driven merger for Appraisals files using Prism.
9
8
  # Preserves all comments: preamble headers, block headers, and inline comments.
10
9
  # Uses PrismUtils for shared Prism AST operations.
11
- module AppraisalsAstMerger
10
+ module PrismAppraisals
12
11
  TRACKED_METHODS = [:gem, :eval_gemfile, :gemfile].freeze
13
12
 
14
13
  module_function
15
14
 
16
15
  # 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
16
  def merge(template_content, dest_content)
33
17
  template_content ||= ""
34
18
  dest_content ||= ""
@@ -36,24 +20,19 @@ module Kettle
36
20
  return template_content if dest_content.strip.empty?
37
21
  return dest_content if template_content.strip.empty?
38
22
 
39
- # Parse with Prism to get AST and comments
40
23
  tmpl_result = PrismUtils.parse_with_comments(template_content)
41
24
  dest_result = PrismUtils.parse_with_comments(dest_content)
42
25
 
43
- # Extract preamble and blocks with their headers
44
26
  tmpl_preamble, tmpl_blocks = extract_blocks(tmpl_result, template_content)
45
27
  dest_preamble, dest_blocks = extract_blocks(dest_result, dest_content)
46
28
 
47
- # Merge preambles
48
29
  merged_preamble = merge_preambles(tmpl_preamble, dest_preamble)
49
-
50
- # Merge blocks
51
30
  merged_blocks = merge_blocks(tmpl_blocks, dest_blocks, tmpl_result, dest_result)
52
31
 
53
- # Build output
54
32
  build_output(merged_preamble, merged_blocks)
55
33
  end
56
34
 
35
+ # ...existing helper methods copied from original AppraisalsAstMerger...
57
36
  def extract_blocks(parse_result, source_content)
58
37
  root = parse_result.value
59
38
  return [[], []] unless root&.statements&.body
@@ -62,14 +41,12 @@ module Kettle
62
41
  blocks = []
63
42
  first_appraise_line = nil
64
43
 
65
- # Find all appraise blocks
66
44
  root.statements.body.each do |node|
67
45
  if appraise_call?(node)
68
46
  first_appraise_line ||= node.location.start_line
69
47
  name = extract_appraise_name(node)
70
48
  next unless name
71
49
 
72
- # Extract block header (comments immediately before this block)
73
50
  block_header = extract_block_header(node, source_lines, blocks)
74
51
 
75
52
  blocks << {
@@ -80,14 +57,12 @@ module Kettle
80
57
  end
81
58
  end
82
59
 
83
- # Preamble is all comments before first appraise block
84
60
  preamble_comments = if first_appraise_line
85
61
  parse_result.comments.select { |c| c.location.start_line < first_appraise_line }
86
62
  else
87
63
  parse_result.comments
88
64
  end
89
65
 
90
- # Filter out comments that are part of block headers
91
66
  block_header_lines = blocks.flat_map { |b| b[:header].lines.map { |l| l.strip } }.to_set
92
67
  preamble_comments = preamble_comments.reject { |c| block_header_lines.include?(c.slice.strip) }
93
68
 
@@ -100,7 +75,6 @@ module Kettle
100
75
 
101
76
  def extract_appraise_name(node)
102
77
  return unless node.is_a?(Prism::CallNode)
103
- # Use PrismUtils for extracting literal values
104
78
  PrismUtils.extract_literal_value(node.arguments&.arguments&.first)
105
79
  end
106
80
 
@@ -108,13 +82,11 @@ module Kettle
108
82
  tmpl_lines = tmpl_comments.map { |c| c.slice.strip }
109
83
  dest_lines = dest_comments.map { |c| c.slice.strip }
110
84
 
111
- # Remove magic comments from dest if template has them
112
85
  magic_pattern = /^#.*frozen_string_literal/
113
86
  if tmpl_lines.any? { |line| line.match?(magic_pattern) }
114
87
  dest_lines.reject! { |line| line.match?(magic_pattern) }
115
88
  end
116
89
 
117
- # Merge unique lines (case-insensitive), template first
118
90
  merged = []
119
91
  seen = Set.new
120
92
 
@@ -130,38 +102,26 @@ module Kettle
130
102
  end
131
103
 
132
104
  def extract_block_header(node, source_lines, previous_blocks)
133
- # Get the line number where this block starts (1-indexed from Prism)
134
105
  begin_line = node.location.start_line
135
-
136
- # Find the end of the previous block or start of file
137
106
  min_line = if previous_blocks.empty?
138
107
  1
139
108
  else
140
109
  previous_blocks.last[:node].location.end_line + 1
141
110
  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
111
+ check_line = begin_line - 2
147
112
  header_lines = []
148
-
149
113
  while check_line >= 0 && (check_line + 1) >= min_line
150
114
  line = source_lines[check_line]
151
115
  break unless line
152
-
153
- # Stop at empty line
154
116
  if line.strip.empty?
155
117
  break
156
118
  elsif line.lstrip.start_with?("#")
157
119
  header_lines.unshift(line)
158
120
  check_line -= 1
159
121
  else
160
- # Non-comment, non-blank line - stop
161
122
  break
162
123
  end
163
124
  end
164
-
165
125
  header_lines.join
166
126
  rescue StandardError => e
167
127
  Kettle::Dev.debug_error(e, __method__) if defined?(Kettle::Dev.debug_error)
@@ -172,27 +132,16 @@ module Kettle
172
132
  merged = []
173
133
  dest_by_name = dest_blocks.each_with_object({}) { |b, h| h[b[:name]] = b }
174
134
  template_names = template_blocks.map { |b| b[:name] }.to_set
175
-
176
- # Track which dest blocks we've already placed
177
135
  placed_dest = Set.new
178
136
 
179
- # For each template block, merge it and insert any dest-only blocks that come before it
180
137
  template_blocks.each_with_index do |tmpl_block, idx|
181
138
  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
139
  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
140
  dest_blocks.each do |db|
189
141
  next if template_names.include?(db[:name])
190
142
  next if placed_dest.include?(db[:name])
191
-
192
- # Check if this dest-only block comes before current shared block in dest
193
143
  dest_idx_of_shared = dest_blocks.index { |b| b[:name] == name }
194
144
  dest_idx_of_only = dest_blocks.index { |b| b[:name] == db[:name] }
195
-
196
145
  if dest_idx_of_only && dest_idx_of_shared && dest_idx_of_only < dest_idx_of_shared
197
146
  merged << db
198
147
  placed_dest << db[:name]
@@ -200,10 +149,8 @@ module Kettle
200
149
  end
201
150
  end
202
151
 
203
- # Now add the template block (merged or template-only)
204
152
  dest_block = dest_by_name[name]
205
153
  if dest_block
206
- # Merge this block
207
154
  merged_header = merge_block_headers(tmpl_block[:header], dest_block[:header])
208
155
  merged_statements = merge_block_statements(
209
156
  tmpl_block[:node].block.body,
@@ -218,12 +165,10 @@ module Kettle
218
165
  }
219
166
  placed_dest << name
220
167
  else
221
- # Template-only block
222
168
  merged << tmpl_block
223
169
  end
224
170
  end
225
171
 
226
- # Add any remaining destination-only blocks that haven't been placed
227
172
  dest_blocks.each do |dest_block|
228
173
  next if placed_dest.include?(dest_block[:name])
229
174
  next if template_names.include?(dest_block[:name])
@@ -236,11 +181,8 @@ module Kettle
236
181
  def merge_block_headers(tmpl_header, dest_header)
237
182
  tmpl_lines = tmpl_header.to_s.lines.map(&:strip).reject(&:empty?)
238
183
  dest_lines = dest_header.to_s.lines.map(&:strip).reject(&:empty?)
239
-
240
- # Merge without duplicates (case-insensitive comparison), template first
241
184
  merged = []
242
185
  seen = Set.new
243
-
244
186
  (tmpl_lines + dest_lines).each do |line|
245
187
  normalized = line.downcase
246
188
  unless seen.include?(normalized)
@@ -248,7 +190,6 @@ module Kettle
248
190
  seen << normalized
249
191
  end
250
192
  end
251
-
252
193
  return "" if merged.empty?
253
194
  merged.join("\n") + "\n"
254
195
  end
@@ -257,7 +198,6 @@ module Kettle
257
198
  tmpl_stmts = PrismUtils.extract_statements(tmpl_body)
258
199
  dest_stmts = PrismUtils.extract_statements(dest_body)
259
200
 
260
- # Build statement keys for both
261
201
  tmpl_keys = Set.new
262
202
  tmpl_key_to_node = {}
263
203
  tmpl_stmts.each do |stmt|
@@ -274,27 +214,20 @@ module Kettle
274
214
  dest_keys << key if key
275
215
  end
276
216
 
277
- # Process dest statements in order, preserving their position and comments
278
217
  merged = []
279
218
  dest_stmts.each_with_index do |dest_stmt, idx|
280
219
  dest_key = statement_key(dest_stmt)
281
220
 
282
221
  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
222
  merged << {node: tmpl_key_to_node[dest_key], inline_comments: [], leading_comments: [], shared: true, key: dest_key}
286
223
  else
287
- # Dest-only statement - preserve with all comments
288
224
  inline_comments = PrismUtils.inline_comments_for_node(dest_result, dest_stmt)
289
-
290
225
  prev_stmt = (idx > 0) ? dest_stmts[idx - 1] : nil
291
226
  leading_comments = PrismUtils.find_leading_comments(dest_result, dest_stmt, prev_stmt, dest_body)
292
-
293
227
  merged << {node: dest_stmt, inline_comments: inline_comments, leading_comments: leading_comments, shared: false}
294
228
  end
295
229
  end
296
230
 
297
- # Add template-only statements (those not in dest) at the end
298
231
  tmpl_stmts.each do |tmpl_stmt|
299
232
  tmpl_key = statement_key(tmpl_stmt)
300
233
  unless tmpl_key && dest_keys.include?(tmpl_key)
@@ -302,7 +235,6 @@ module Kettle
302
235
  end
303
236
  end
304
237
 
305
- # Clean up - remove the tracking fields
306
238
  merged.each do |item|
307
239
  item.delete(:shared)
308
240
  item.delete(:key)
@@ -312,69 +244,60 @@ module Kettle
312
244
  end
313
245
 
314
246
  def statement_key(node)
315
- # Use PrismUtils for statement key generation with Appraisals-specific tracked methods
316
247
  PrismUtils.statement_key(node, tracked_methods: TRACKED_METHODS)
317
248
  end
318
249
 
319
250
  def build_output(preamble_lines, blocks)
320
251
  output = []
321
-
322
- # Add preamble
323
252
  preamble_lines.each { |line| output << line }
324
253
  output << "" unless preamble_lines.empty?
325
254
 
326
- # Add blocks
327
255
  blocks.each do |block|
328
- # Add block header (no blank line before it)
329
256
  header = block[:header]
330
257
  if header && !header.strip.empty?
331
258
  output << header.rstrip
332
259
  end
333
260
 
334
- # Add appraise call - using parentheses and curly braces format
335
261
  name = block[:name]
336
262
  output << "appraise(\"#{name}\") {"
337
263
 
338
- # Add statements
339
264
  statements = block[:statements] || extract_original_statements(block[:node])
340
265
  statements.each do |stmt_info|
341
- # Add any leading comments before this statement
342
266
  leading = stmt_info[:leading_comments] || []
343
267
  leading.each do |comment|
344
268
  output << " #{comment.slice.strip}"
345
269
  end
346
270
 
347
271
  node = stmt_info[:node]
348
- # Normalize the statement to use parentheses
349
272
  line = normalize_statement(node)
273
+ # Remove any leading whitespace/newlines from the normalized line
274
+ line = line.to_s.sub(/\A\s+/, "")
350
275
 
351
276
  inline = stmt_info[:inline_comments] || []
352
277
  inline_str = inline.map { |c| c.slice.strip }.join(" ")
353
- output << " #{line}#{" #{inline_str}" unless inline_str.empty?}"
278
+ output << " #{line}#{" " + inline_str unless inline_str.empty?}"
354
279
  end
355
280
 
356
281
  output << "}"
357
282
  output << ""
358
283
  end
359
284
 
360
- output.join("\n").strip + "\n"
285
+ build = output.join("\n").strip + "\n"
286
+ build
361
287
  end
362
288
 
363
289
  def normalize_statement(node)
364
- # Use PrismUtils for normalizing call nodes
365
290
  return PrismUtils.node_to_source(node) unless node.is_a?(Prism::CallNode)
366
291
  PrismUtils.normalize_call_node(node)
367
292
  end
368
293
 
369
294
  def normalize_argument(arg)
370
- # Use PrismUtils for argument normalization
371
295
  PrismUtils.normalize_argument(arg)
372
296
  end
373
297
 
374
298
  def extract_original_statements(node)
375
299
  body = node.block&.body
376
300
  return [] unless body
377
-
378
301
  statements = body.is_a?(Prism::StatementsNode) ? body.body : [body]
379
302
  statements.compact.map { |stmt| {node: stmt, inline_comments: [], leading_comments: []} }
380
303
  end
@@ -0,0 +1,136 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Kettle
4
+ module Dev
5
+ # Prism helpers for Gemfile-like merging.
6
+ module PrismGemfile
7
+ module_function
8
+
9
+ # Merge gem calls from src_content into dest_content.
10
+ # - Replaces dest `source` call with src's if present.
11
+ # - Replaces or inserts non-comment `git_source` definitions.
12
+ # - Appends missing `gem` calls (by name) from src to dest preserving dest content and newlines.
13
+ # This is a conservative, comment-preserving approach using Prism to detect call nodes.
14
+ def merge_gem_calls(src_content, dest_content)
15
+ src_res = PrismUtils.parse_with_comments(src_content)
16
+ dest_res = PrismUtils.parse_with_comments(dest_content)
17
+
18
+ src_stmts = PrismUtils.extract_statements(src_res.value.statements)
19
+ dest_stmts = PrismUtils.extract_statements(dest_res.value.statements)
20
+
21
+ # Find source nodes
22
+ src_source_node = src_stmts.find { |n| PrismUtils.call_to?(n, :source) }
23
+ dest_source_node = dest_stmts.find { |n| PrismUtils.call_to?(n, :source) }
24
+
25
+ out = dest_content.dup
26
+ dest_lines = out.lines
27
+
28
+ # Replace or insert source line
29
+ if src_source_node
30
+ src_src = src_source_node.slice
31
+ if dest_source_node
32
+ out = out.sub(dest_source_node.slice, src_src)
33
+ dest_lines = out.lines
34
+ else
35
+ # insert after any leading comment/blank block
36
+ insert_idx = 0
37
+ while insert_idx < dest_lines.length && (dest_lines[insert_idx].strip.empty? || dest_lines[insert_idx].lstrip.start_with?("#"))
38
+ insert_idx += 1
39
+ end
40
+ dest_lines.insert(insert_idx, src_src.rstrip + "\n")
41
+ out = dest_lines.join
42
+ dest_lines = out.lines
43
+ end
44
+ end
45
+
46
+ # --- Handle git_source replacement/insertion ---
47
+ src_git_nodes = src_stmts.select { |n| PrismUtils.call_to?(n, :git_source) }
48
+ if src_git_nodes.any?
49
+ # We'll operate on dest_lines for insertion; recompute dest_stmts if we changed out
50
+ dest_res = PrismUtils.parse_with_comments(out)
51
+ dest_stmts = PrismUtils.extract_statements(dest_res.value.statements)
52
+
53
+ # Iterate in reverse when inserting so that inserting at the same index
54
+ # preserves the original order from the source (we insert at a fixed index).
55
+ src_git_nodes.reverse_each do |gnode|
56
+ key = PrismUtils.statement_key(gnode) # => [:git_source, name]
57
+ name = key && key[1]
58
+ replaced = false
59
+
60
+ if name
61
+ dest_same_idx = dest_stmts.index { |d| PrismUtils.statement_key(d) && PrismUtils.statement_key(d)[0] == :git_source && PrismUtils.statement_key(d)[1] == name }
62
+ if dest_same_idx
63
+ # Replace the matching dest node slice
64
+ out = out.sub(dest_stmts[dest_same_idx].slice, gnode.slice)
65
+ replaced = true
66
+ end
67
+ end
68
+
69
+ # If not replaced, prefer to replace an existing github entry in destination
70
+ # (this mirrors previous behavior in template_helpers which favored replacing
71
+ # a github git_source when inserting others).
72
+ unless replaced
73
+ dest_github_idx = dest_stmts.index { |d| PrismUtils.statement_key(d) && PrismUtils.statement_key(d)[0] == :git_source && PrismUtils.statement_key(d)[1] == "github" }
74
+ if dest_github_idx
75
+ out = out.sub(dest_stmts[dest_github_idx].slice, gnode.slice)
76
+ replaced = true
77
+ end
78
+ end
79
+
80
+ unless replaced
81
+ # Insert below source line if present, else at top after comments
82
+ dest_lines = out.lines
83
+ insert_idx = dest_lines.index { |ln| !ln.strip.start_with?("#") && ln =~ /^\s*source\s+/ } || 0
84
+ insert_idx += 1 if insert_idx
85
+ dest_lines.insert(insert_idx, gnode.slice.rstrip + "\n")
86
+ out = dest_lines.join
87
+ end
88
+
89
+ # Recompute dest_stmts for subsequent iterations
90
+ dest_res = PrismUtils.parse_with_comments(out)
91
+ dest_stmts = PrismUtils.extract_statements(dest_res.value.statements)
92
+ end
93
+ end
94
+
95
+ # Collect gem names present in dest (top-level only)
96
+ dest_res = PrismUtils.parse_with_comments(out)
97
+ dest_stmts = PrismUtils.extract_statements(dest_res.value.statements)
98
+ dest_gem_names = dest_stmts.map { |n| PrismUtils.statement_key(n) }.compact.select { |k| k[0] == :gem }.map { |k| k[1] }.to_set
99
+
100
+ # Find gem call nodes in src and append missing ones (top-level only)
101
+ missing_nodes = src_stmts.select do |n|
102
+ k = PrismUtils.statement_key(n)
103
+ k && k.first == :gem && !dest_gem_names.include?(k[1])
104
+ end
105
+ if missing_nodes.any?
106
+ out << "\n" unless out.end_with?("\n") || out.empty?
107
+ missing_nodes.each do |n|
108
+ # Preserve inline comments for the source node when appending
109
+ inline = begin
110
+ PrismUtils.inline_comments_for_node(src_res, n)
111
+ rescue
112
+ []
113
+ end
114
+ line = n.slice.rstrip
115
+ if inline && inline.any?
116
+ inline_text = inline.map { |c| c.slice.strip }.join(" ")
117
+ # Only append the inline text if it's not already part of the slice
118
+ line = line + " " + inline_text unless line.include?(inline_text)
119
+ end
120
+ out << line + "\n"
121
+ end
122
+ end
123
+
124
+ out
125
+ rescue StandardError => e
126
+ # Use debug_log if available, otherwise Kettle::Dev.debug_error
127
+ if defined?(Kettle::Dev) && Kettle::Dev.respond_to?(:debug_error)
128
+ Kettle::Dev.debug_error(e, __method__)
129
+ else
130
+ Kernel.warn("[#{__method__}] #{e.class}: #{e.message}")
131
+ end
132
+ dest_content
133
+ end
134
+ end
135
+ end
136
+ end