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.
@@ -22,25 +22,31 @@ 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
+ existing = File.exist?(dest) ? File.read(dest) : nil
40
+ helpers.copy_file_with_prompt(src, dest, allow_create: true, allow_replace: true) do |content|
41
+ existing ? helpers.merge_gemfile_dependencies(content, existing) : content
42
+ end
38
43
  end
39
44
 
40
45
  # 4b) gemfiles/modular/style.gemfile with dynamic rubocop constraints
41
46
  modular_gemfile = "style.gemfile"
42
47
  src = helpers.prefer_example(File.join(gem_checkout_root, MODULAR_GEMFILE_DIR, modular_gemfile))
43
48
  dest = File.join(project_root, MODULAR_GEMFILE_DIR, modular_gemfile)
49
+ existing_style = File.exist?(dest) ? File.read(dest) : nil
44
50
  if File.basename(src).sub(/\.example\z/, "") == "style.gemfile"
45
51
  helpers.copy_file_with_prompt(src, dest, allow_create: true, allow_replace: true) do |content|
46
52
  # Adjust rubocop-lts constraint based on min_ruby
@@ -92,10 +98,12 @@ module Kettle
92
98
  token = "{RUBOCOP|RUBY|GEM}"
93
99
  content.gsub!(token, "rubocop-ruby#{rubocop_ruby_gem_version}") if content.include?(token)
94
100
  end
95
- content
101
+ existing_style ? helpers.merge_gemfile_dependencies(content, existing_style) : content
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
+ existing_style ? helpers.merge_gemfile_dependencies(content, existing_style) : content
106
+ end
99
107
  end
100
108
 
101
109
  # 4c) Copy modular directories with nested/versioned files
@@ -0,0 +1,188 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "prism"
4
+
5
+ module Kettle
6
+ module Dev
7
+ # Shared utilities for working with Prism AST nodes.
8
+ # Provides parsing, node inspection, and source generation helpers
9
+ # used by both PrismMerger and AppraisalsAstMerger.
10
+ #
11
+ # Uses Prism's native methods for source generation (via .slice) to preserve
12
+ # original formatting and comments. For normalized output (e.g., adding parentheses),
13
+ # use normalize_call_node instead.
14
+ module PrismUtils
15
+ module_function
16
+
17
+ # Parse Ruby source code and return Prism parse result with comments
18
+ # @param source [String] Ruby source code
19
+ # @return [Prism::ParseResult] Parse result containing AST and comments
20
+ def parse_with_comments(source)
21
+ Prism.parse(source)
22
+ end
23
+
24
+ # Extract statements from a Prism body node
25
+ # @param body_node [Prism::Node, nil] Body node (typically StatementsNode)
26
+ # @return [Array<Prism::Node>] Array of statement nodes
27
+ def extract_statements(body_node)
28
+ return [] unless body_node
29
+
30
+ if body_node.is_a?(Prism::StatementsNode)
31
+ body_node.body.compact
32
+ else
33
+ [body_node].compact
34
+ end
35
+ end
36
+
37
+ # Generate a unique key for a statement node to identify equivalent statements
38
+ # Used for merge/append operations to detect duplicates
39
+ # @param node [Prism::Node] Statement node
40
+ # @param tracked_methods [Array<Symbol>] Methods to track (default: gem, source, eval_gemfile, git_source)
41
+ # @return [Array, nil] Key array like [:gem, "foo"] or nil if not trackable
42
+ def statement_key(node, tracked_methods: %i[gem source eval_gemfile git_source])
43
+ return unless node.is_a?(Prism::CallNode)
44
+ return unless tracked_methods.include?(node.name)
45
+
46
+ first_arg = node.arguments&.arguments&.first
47
+ arg_value = extract_literal_value(first_arg)
48
+
49
+ [node.name, arg_value] if arg_value
50
+ end
51
+
52
+ # Extract literal value from string or symbol nodes
53
+ # @param node [Prism::Node, nil] Node to extract from
54
+ # @return [String, Symbol, nil] Literal value or nil
55
+ def extract_literal_value(node)
56
+ case node
57
+ when Prism::StringNode then node.unescaped
58
+ when Prism::SymbolNode then node.unescaped
59
+ end
60
+ end
61
+
62
+ # Extract qualified constant name from a constant node
63
+ # @param node [Prism::Node, nil] Constant node
64
+ # @return [String, nil] Qualified name like "Gem::Specification" or nil
65
+ def extract_const_name(node)
66
+ case node
67
+ when Prism::ConstantReadNode
68
+ node.name.to_s
69
+ when Prism::ConstantPathNode
70
+ parent = extract_const_name(node.parent)
71
+ child = node.name || node.child&.name
72
+ (parent && child) ? "#{parent}::#{child}" : child.to_s
73
+ end
74
+ end
75
+
76
+ # Find leading comments for a statement node
77
+ # Leading comments are those that appear after the previous statement
78
+ # and before the current statement
79
+ # @param parse_result [Prism::ParseResult] Parse result with comments
80
+ # @param current_stmt [Prism::Node] Current statement node
81
+ # @param prev_stmt [Prism::Node, nil] Previous statement node
82
+ # @param body_node [Prism::Node] Body containing the statements
83
+ # @return [Array<Prism::Comment>] Leading comments
84
+ def find_leading_comments(parse_result, current_stmt, prev_stmt, body_node)
85
+ start_line = prev_stmt ? prev_stmt.location.end_line : body_node.location.start_line
86
+ end_line = current_stmt.location.start_line
87
+
88
+ parse_result.comments.select do |comment|
89
+ comment.location.start_line > start_line &&
90
+ comment.location.start_line < end_line
91
+ end
92
+ end
93
+
94
+ # Find inline comments for a statement node
95
+ # Inline comments are those that appear on the same line as the statement's end
96
+ # @param parse_result [Prism::ParseResult] Parse result with comments
97
+ # @param stmt [Prism::Node] Statement node
98
+ # @return [Array<Prism::Comment>] Inline comments
99
+ def inline_comments_for_node(parse_result, stmt)
100
+ parse_result.comments.select do |comment|
101
+ comment.location.start_line == stmt.location.end_line &&
102
+ comment.location.start_offset > stmt.location.end_offset
103
+ end
104
+ end
105
+
106
+ # Convert a Prism AST node to Ruby source code
107
+ # Uses Prism's native slice method which preserves the original source exactly.
108
+ # This is preferable to Unparser for Prism nodes as it maintains original formatting
109
+ # and comments without requiring transformation.
110
+ # @param node [Prism::Node] AST node
111
+ # @return [String] Ruby source code
112
+ def node_to_source(node)
113
+ return "" unless node
114
+ # Prism nodes have a slice method that returns the original source
115
+ node.slice
116
+ end
117
+
118
+ # Normalize a call node to use parentheses format
119
+ # Converts `gem "foo"` to `gem("foo")` style
120
+ # @param node [Prism::CallNode] Call node
121
+ # @return [String] Normalized source code
122
+ def normalize_call_node(node)
123
+ return node.slice.strip unless node.is_a?(Prism::CallNode)
124
+
125
+ method_name = node.name
126
+ args = node.arguments&.arguments || []
127
+
128
+ if args.empty?
129
+ "#{method_name}()"
130
+ else
131
+ arg_strings = args.map { |arg| normalize_argument(arg) }
132
+ "#{method_name}(#{arg_strings.join(", ")})"
133
+ end
134
+ end
135
+
136
+ # Normalize an argument node to canonical format
137
+ # @param arg [Prism::Node] Argument node
138
+ # @return [String] Normalized argument source
139
+ def normalize_argument(arg)
140
+ case arg
141
+ when Prism::StringNode
142
+ "\"#{arg.unescaped}\""
143
+ when Prism::SymbolNode
144
+ ":#{arg.unescaped}"
145
+ when Prism::KeywordHashNode
146
+ # Handle hash arguments like {key: value}
147
+ pairs = arg.elements.map do |assoc|
148
+ key = case assoc.key
149
+ when Prism::SymbolNode then "#{assoc.key.unescaped}:"
150
+ when Prism::StringNode then "\"#{assoc.key.unescaped}\" =>"
151
+ else "#{assoc.key.slice} =>"
152
+ end
153
+ value = normalize_argument(assoc.value)
154
+ "#{key} #{value}"
155
+ end.join(", ")
156
+ pairs
157
+ when Prism::HashNode
158
+ # Handle explicit hash syntax
159
+ pairs = arg.elements.map do |assoc|
160
+ key_part = normalize_argument(assoc.key)
161
+ value_part = normalize_argument(assoc.value)
162
+ "#{key_part} => #{value_part}"
163
+ end.join(", ")
164
+ "{#{pairs}}"
165
+ else
166
+ # For other types (numbers, arrays, etc.), use the original source
167
+ arg.slice.strip
168
+ end
169
+ end
170
+
171
+ # Check if a node is a specific method call
172
+ # @param node [Prism::Node] Node to check
173
+ # @param method_name [Symbol] Method name to check for
174
+ # @return [Boolean] True if node is a call to the specified method
175
+ def call_to?(node, method_name)
176
+ node.is_a?(Prism::CallNode) && node.name == method_name
177
+ end
178
+
179
+ # Check if a node is a block call to a specific method
180
+ # @param node [Prism::Node] Node to check
181
+ # @param method_name [Symbol] Method name to check for
182
+ # @return [Boolean] True if node is a block call to the specified method
183
+ def block_call_to?(node, method_name)
184
+ node.is_a?(Prism::CallNode) && node.name == method_name && !node.block.nil?
185
+ end
186
+ end
187
+ end
188
+ end
@@ -0,0 +1,345 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "yaml"
4
+ require "set"
5
+ require "prism"
6
+ require "kettle/dev/prism_utils"
7
+
8
+ module Kettle
9
+ module Dev
10
+ # Prism-based AST merging for templated Ruby files.
11
+ # Handles universal freeze reminders, kettle-dev:freeze blocks, and
12
+ # strategy dispatch (skip/replace/append/merge).
13
+ #
14
+ # Uses Prism for parsing with first-class comment support, enabling
15
+ # preservation of inline and leading comments throughout the merge process.
16
+ module SourceMerger
17
+ FREEZE_START = /#\s*kettle-dev:freeze/i
18
+ FREEZE_END = /#\s*kettle-dev:unfreeze/i
19
+ FREEZE_BLOCK = Regexp.new("(#{FREEZE_START.source}).*?(#{FREEZE_END.source})", Regexp::IGNORECASE | Regexp::MULTILINE)
20
+ FREEZE_REMINDER = <<~RUBY
21
+ # To retain during kettle-dev templating:
22
+ # kettle-dev:freeze
23
+ # # ... your code
24
+ # kettle-dev:unfreeze
25
+ RUBY
26
+ BUG_URL = "https://github.com/kettle-rb/kettle-dev/issues"
27
+
28
+ module_function
29
+
30
+ # Apply a templating strategy to merge source and destination Ruby files
31
+ #
32
+ # @param strategy [Symbol] Merge strategy - :skip, :replace, :append, or :merge
33
+ # @param src [String] Template source content
34
+ # @param dest [String] Destination file content
35
+ # @param path [String] File path (for error messages)
36
+ # @return [String] Merged content with freeze blocks and comments preserved
37
+ # @raise [Kettle::Dev::Error] If strategy is unknown or merge fails
38
+ # @example
39
+ # SourceMerger.apply(
40
+ # strategy: :merge,
41
+ # src: 'gem "foo"',
42
+ # dest: 'gem "bar"',
43
+ # path: "Gemfile"
44
+ # )
45
+ def apply(strategy:, src:, dest:, path:)
46
+ strategy = normalize_strategy(strategy)
47
+ dest ||= ""
48
+ src_with_reminder = ensure_reminder(src)
49
+ content =
50
+ case strategy
51
+ when :skip
52
+ src_with_reminder
53
+ when :replace
54
+ normalize_source(src_with_reminder)
55
+ when :append
56
+ apply_append(src_with_reminder, dest)
57
+ when :merge
58
+ apply_merge(src_with_reminder, dest)
59
+ else
60
+ raise Kettle::Dev::Error, "Unknown templating strategy '#{strategy}' for #{path}."
61
+ end
62
+ content = merge_freeze_blocks(content, dest)
63
+ content = restore_custom_leading_comments(dest, content)
64
+ ensure_trailing_newline(content)
65
+ rescue StandardError => error
66
+ warn_bug(path, error)
67
+ raise Kettle::Dev::Error, "Template merge failed for #{path}: #{error.message}"
68
+ end
69
+
70
+ # Ensure freeze reminder comment is present at the top of content
71
+ #
72
+ # @param content [String] Ruby source content
73
+ # @return [String] Content with freeze reminder prepended if missing
74
+ # @api private
75
+ def ensure_reminder(content)
76
+ return content if reminder_present?(content)
77
+ insertion_index = reminder_insertion_index(content)
78
+ before = content[0...insertion_index]
79
+ after = content[insertion_index..-1]
80
+ snippet = FREEZE_REMINDER
81
+ snippet += "\n" unless snippet.end_with?("\n\n")
82
+ [before, snippet, after].join
83
+ end
84
+
85
+ # Normalize source code while preserving formatting
86
+ #
87
+ # @param source [String] Ruby source code
88
+ # @return [String] Normalized source with trailing newline
89
+ # @api private
90
+ def normalize_source(source)
91
+ parse_result = PrismUtils.parse_with_comments(source)
92
+ return ensure_trailing_newline(source) unless parse_result.success?
93
+
94
+ # Use Prism's slice to preserve original formatting
95
+ ensure_trailing_newline(source)
96
+ end
97
+
98
+ def reminder_present?(content)
99
+ content.include?(FREEZE_REMINDER.lines.first.strip)
100
+ end
101
+
102
+ def reminder_insertion_index(content)
103
+ cursor = 0
104
+ lines = content.lines
105
+ lines.each do |line|
106
+ break unless shebang?(line) || frozen_comment?(line)
107
+ cursor += line.length
108
+ end
109
+ cursor
110
+ end
111
+
112
+ def shebang?(line)
113
+ line.start_with?("#!")
114
+ end
115
+
116
+ def frozen_comment?(line)
117
+ line.match?(/#\s*frozen_string_literal:/)
118
+ end
119
+
120
+ # Merge kettle-dev:freeze blocks from destination into source content
121
+ # Preserves user customizations wrapped in freeze/unfreeze markers
122
+ #
123
+ # @param src_content [String] Template source content
124
+ # @param dest_content [String] Destination file content
125
+ # @return [String] Merged content with freeze blocks from destination
126
+ # @api private
127
+ def merge_freeze_blocks(src_content, dest_content)
128
+ dest_blocks = freeze_blocks(dest_content)
129
+ return src_content if dest_blocks.empty?
130
+ src_blocks = freeze_blocks(src_content)
131
+ updated = src_content.dup
132
+ # Replace matching freeze sections by textual markers rather than index ranges
133
+ dest_blocks.each do |dest_block|
134
+ marker = dest_block[:text]
135
+ next if updated.include?(marker)
136
+ # If the template had a placeholder block, replace the first occurrence of a freeze stub
137
+ placeholder = src_blocks.find { |blk| blk[:start_marker] == dest_block[:start_marker] }
138
+ if placeholder
139
+ updated.sub!(placeholder[:text], marker)
140
+ else
141
+ updated << "\n" unless updated.end_with?("\n")
142
+ updated << marker
143
+ end
144
+ end
145
+ updated
146
+ end
147
+
148
+ def freeze_blocks(text)
149
+ return [] unless text&.match?(FREEZE_START)
150
+ blocks = []
151
+ text.to_enum(:scan, FREEZE_BLOCK).each do
152
+ match = Regexp.last_match
153
+ start_idx = match&.begin(0)
154
+ end_idx = match&.end(0)
155
+ next unless start_idx && end_idx
156
+ segment = match[0]
157
+ start_marker = segment.lines.first&.strip
158
+ blocks << {range: start_idx...end_idx, text: segment, start_marker: start_marker}
159
+ end
160
+ blocks
161
+ end
162
+
163
+ def normalize_strategy(strategy)
164
+ return :skip if strategy.nil?
165
+ strategy.to_s.downcase.strip.to_sym
166
+ end
167
+
168
+ def warn_bug(path, error)
169
+ puts "ERROR: kettle-dev templating failed for #{path}: #{error.message}"
170
+ puts "Please file a bug at #{BUG_URL} with the file contents so we can improve the AST merger."
171
+ end
172
+
173
+ def ensure_trailing_newline(text)
174
+ return "" if text.nil?
175
+ text.end_with?("\n") ? text : text + "\n"
176
+ end
177
+
178
+ def apply_append(src_content, dest_content)
179
+ prism_merge(src_content, dest_content) do |src_nodes, dest_nodes, _src_result, _dest_result|
180
+ existing = Set.new(dest_nodes.map { |node| node_signature(node[:node]) })
181
+ appended = dest_nodes.dup
182
+ src_nodes.each do |node_info|
183
+ sig = node_signature(node_info[:node])
184
+ next if existing.include?(sig)
185
+ appended << node_info
186
+ existing << sig
187
+ end
188
+ appended
189
+ end
190
+ end
191
+
192
+ def apply_merge(src_content, dest_content)
193
+ prism_merge(src_content, dest_content) do |src_nodes, dest_nodes, _src_result, _dest_result|
194
+ src_map = src_nodes.each_with_object({}) do |node_info, memo|
195
+ sig = node_signature(node_info[:node])
196
+ memo[sig] ||= node_info
197
+ end
198
+ merged = dest_nodes.map do |node_info|
199
+ sig = node_signature(node_info[:node])
200
+ if (src_node_info = src_map[sig])
201
+ merge_node_info(sig, node_info, src_node_info)
202
+ else
203
+ node_info
204
+ end
205
+ end
206
+ existing = merged.map { |ni| node_signature(ni[:node]) }.to_set
207
+ src_nodes.each do |node_info|
208
+ sig = node_signature(node_info[:node])
209
+ next if existing.include?(sig)
210
+ merged << node_info
211
+ existing << sig
212
+ end
213
+ merged
214
+ end
215
+ end
216
+
217
+ def merge_node_info(signature, _dest_node_info, src_node_info)
218
+ return src_node_info unless signature.is_a?(Array)
219
+ case signature[1]
220
+ when :gem_specification
221
+ merge_block_node_info(src_node_info)
222
+ else
223
+ src_node_info
224
+ end
225
+ end
226
+
227
+ def merge_block_node_info(src_node_info)
228
+ # For block merging, we need to merge the statements within the block
229
+ # This is complex - for now, prefer template version
230
+ # TODO: Implement deep block statement merging with comment preservation
231
+ src_node_info
232
+ end
233
+
234
+ def prism_merge(src_content, dest_content)
235
+ src_result = PrismUtils.parse_with_comments(src_content)
236
+ dest_result = PrismUtils.parse_with_comments(dest_content)
237
+
238
+ src_nodes = extract_nodes_with_comments(src_result)
239
+ dest_nodes = extract_nodes_with_comments(dest_result)
240
+
241
+ merged_nodes = yield(src_nodes, dest_nodes, src_result, dest_result)
242
+
243
+ build_source_from_nodes(merged_nodes)
244
+ end
245
+
246
+ def extract_nodes_with_comments(parse_result)
247
+ return [] unless parse_result.success?
248
+
249
+ statements = PrismUtils.extract_statements(parse_result.value.statements)
250
+ return [] if statements.empty?
251
+
252
+ statements.map.with_index do |stmt, idx|
253
+ prev_stmt = (idx > 0) ? statements[idx - 1] : nil
254
+ body_node = parse_result.value.statements
255
+
256
+ {
257
+ node: stmt,
258
+ leading_comments: PrismUtils.find_leading_comments(parse_result, stmt, prev_stmt, body_node),
259
+ inline_comments: PrismUtils.inline_comments_for_node(parse_result, stmt),
260
+ }
261
+ end
262
+ end
263
+
264
+ def build_source_from_nodes(node_infos)
265
+ return "" if node_infos.empty?
266
+
267
+ lines = []
268
+ node_infos.each do |node_info|
269
+ # Add leading comments
270
+ node_info[:leading_comments].each do |comment|
271
+ lines << comment.slice.rstrip
272
+ end
273
+
274
+ # Add the node's source
275
+ node_source = PrismUtils.node_to_source(node_info[:node])
276
+
277
+ # Add inline comments on the same line
278
+ if node_info[:inline_comments].any?
279
+ inline = node_info[:inline_comments].map { |c| c.slice.strip }.join(" ")
280
+ node_source = node_source.rstrip + " " + inline
281
+ end
282
+
283
+ lines << node_source
284
+ end
285
+
286
+ lines.join("\n")
287
+ end
288
+
289
+ def node_signature(node)
290
+ return [:nil] unless node
291
+
292
+ case node
293
+ when Prism::CallNode
294
+ method_name = node.name
295
+ if node.block
296
+ # Block call
297
+ first_arg = PrismUtils.extract_literal_value(node.arguments&.arguments&.first)
298
+ receiver_name = PrismUtils.extract_const_name(node.receiver)
299
+
300
+ if receiver_name == "Gem::Specification" && method_name == :new
301
+ [:block, :gem_specification]
302
+ elsif method_name == :task
303
+ [:block, :task, first_arg]
304
+ elsif method_name == :git_source
305
+ [:block, :git_source, first_arg]
306
+ else
307
+ [:block, method_name, first_arg, node.slice]
308
+ end
309
+ elsif [:source, :git_source, :gem, :eval_gemfile].include?(method_name)
310
+ # Simple call
311
+ first_literal = PrismUtils.extract_literal_value(node.arguments&.arguments&.first)
312
+ [:send, method_name, first_literal]
313
+ else
314
+ [:send, method_name, node.slice]
315
+ end
316
+ else
317
+ # Other node types
318
+ [node.class.name.split("::").last.to_sym, node.slice]
319
+ end
320
+ end
321
+
322
+ def restore_custom_leading_comments(dest_content, merged_content)
323
+ block = leading_comment_block(dest_content)
324
+ return merged_content if block.strip.empty?
325
+ return merged_content if merged_content.start_with?(block)
326
+
327
+ # Insert after shebang / frozen string literal comments (same place reminder goes)
328
+ insertion_index = reminder_insertion_index(merged_content)
329
+ block = ensure_trailing_newline(block)
330
+ merged_content.dup.insert(insertion_index, block)
331
+ end
332
+
333
+ def leading_comment_block(content)
334
+ lines = content.to_s.lines
335
+ collected = []
336
+ lines.each do |line|
337
+ stripped = line.strip
338
+ break unless stripped.empty? || stripped.start_with?("#")
339
+ collected << line
340
+ end
341
+ collected.join
342
+ end
343
+ end
344
+ end
345
+ end
@@ -252,7 +252,8 @@ module Kettle
252
252
 
253
253
  # If a destination gemspec already exists, get metadata from GemSpecReader via helpers
254
254
  orig_meta = nil
255
- if File.exist?(dest_gemspec)
255
+ dest_existed = File.exist?(dest_gemspec)
256
+ if dest_existed
256
257
  begin
257
258
  orig_meta = helpers.gemspec_metadata(File.dirname(dest_gemspec))
258
259
  rescue StandardError => e
@@ -387,6 +388,15 @@ module Kettle
387
388
  # If anything goes wrong, keep the content as-is rather than failing the task
388
389
  end
389
390
 
391
+ if dest_existed
392
+ begin
393
+ merged = helpers.apply_strategy(c, dest_gemspec)
394
+ c = merged if merged.is_a?(String) && !merged.empty?
395
+ rescue StandardError => e
396
+ Kettle::Dev.debug_error(e, __method__)
397
+ end
398
+ end
399
+
390
400
  c
391
401
  end
392
402
  end