kettle-dev 1.1.59 → 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.
Files changed (55) hide show
  1. checksums.yaml +4 -4
  2. checksums.yaml.gz.sig +1 -2
  3. data/.envrc +1 -0
  4. data/.envrc.example +1 -0
  5. data/.envrc.no-osc.example +1 -0
  6. data/.github/workflows/ancient.yml +1 -1
  7. data/.github/workflows/ancient.yml.example +1 -1
  8. data/.github/workflows/codeql-analysis.yml +1 -1
  9. data/.github/workflows/coverage.yml +1 -1
  10. data/.github/workflows/coverage.yml.example +1 -1
  11. data/.github/workflows/current.yml +1 -1
  12. data/.github/workflows/current.yml.example +1 -1
  13. data/.github/workflows/dep-heads.yml +1 -1
  14. data/.github/workflows/dependency-review.yml +1 -1
  15. data/.github/workflows/heads.yml +1 -1
  16. data/.github/workflows/heads.yml.example +1 -1
  17. data/.github/workflows/jruby.yml +1 -1
  18. data/.github/workflows/jruby.yml.example +1 -1
  19. data/.github/workflows/legacy.yml +1 -1
  20. data/.github/workflows/license-eye.yml +1 -1
  21. data/.github/workflows/locked_deps.yml +1 -1
  22. data/.github/workflows/opencollective.yml +1 -1
  23. data/.github/workflows/style.yml +1 -1
  24. data/.github/workflows/supported.yml +1 -1
  25. data/.github/workflows/truffle.yml +1 -1
  26. data/.github/workflows/unlocked_deps.yml +1 -1
  27. data/.github/workflows/unsupported.yml +1 -1
  28. data/CHANGELOG.md +35 -1
  29. data/Gemfile +3 -0
  30. data/Gemfile.example +3 -0
  31. data/README.md +90 -37
  32. data/README.md.example +16 -12
  33. data/README.md.no-osc.example +16 -12
  34. data/Rakefile.example +1 -1
  35. data/gemfiles/modular/style.gemfile.example +1 -1
  36. data/gemfiles/modular/templating.gemfile +3 -0
  37. data/lib/kettle/dev/appraisals_ast_merger.rb +383 -0
  38. data/lib/kettle/dev/changelog_cli.rb +13 -0
  39. data/lib/kettle/dev/modular_gemfiles.rb +11 -3
  40. data/lib/kettle/dev/prism_utils.rb +188 -0
  41. data/lib/kettle/dev/rakelib/spec_test.rake +70 -20
  42. data/lib/kettle/dev/source_merger.rb +345 -0
  43. data/lib/kettle/dev/tasks/template_task.rb +11 -1
  44. data/lib/kettle/dev/template_helpers.rb +70 -226
  45. data/lib/kettle/dev/version.rb +1 -1
  46. data/lib/kettle/dev.rb +2 -0
  47. data/sig/kettle/dev/appraisals_ast_merger.rbs +72 -0
  48. data/sig/kettle/dev/changelog_cli.rbs +64 -0
  49. data/sig/kettle/dev/prism_utils.rbs +56 -0
  50. data/sig/kettle/dev/source_merger.rbs +86 -0
  51. data/sig/kettle/dev/versioning.rbs +21 -0
  52. data.tar.gz.sig +0 -0
  53. metadata +14 -5
  54. metadata.gz.sig +0 -0
  55. /data/sig/kettle/dev/{dvcscli.rbs → dvcs_cli.rbs} +0 -0
@@ -12,7 +12,9 @@ begin
12
12
  t.test_files = FileList["test/**/*test*.rb"]
13
13
  t.verbose = true
14
14
  end
15
- Kettle::Dev.register_default("test")
15
+ # The test task is invoked by the coverage task, so of the two, (i.e., when outside CI),
16
+ # only coverage should be registered as default.
17
+ Kettle::Dev.register_default("test") unless Kettle::Dev.default_registered?("coverage")
16
18
  rescue LoadError
17
19
  warn("[kettle-dev][spec_test.rake] failed to load rake/testtask") if Kettle::Dev::DEBUGGING
18
20
  desc("test task stub")
@@ -21,34 +23,82 @@ rescue LoadError
21
23
  end
22
24
  end
23
25
 
24
- # Setup RSpec
25
- begin
26
- require "rspec/core/rake_task"
26
+ setup_spec_task = ->(default:) {
27
+ begin
28
+ require "rspec/core/rake_task"
27
29
 
28
- RSpec::Core::RakeTask.new(:spec)
29
- # This takes the place of `coverage` task it hasn't been registered yet.
30
- Kettle::Dev.register_default("spec") unless Kettle::Dev.default_registered?("coverage")
31
- rescue LoadError
32
- warn("[kettle-dev][spec_test.rake] failed to load rspec/core/rake_task") if Kettle::Dev::DEBUGGING
33
- desc("spec task stub")
34
- task(:spec) do
35
- warn("NOTE: rspec isn't installed, or is disabled for #{RUBY_VERSION} in the current environment")
30
+ RSpec::Core::RakeTask.new(:spec)
31
+ if default
32
+ # This takes the place of the `coverage` task if/when it isn't already registered.
33
+ # This is because spec and coverage run the same tests
34
+ # (via the coverage task invoking the test task which invokes the spec task),
35
+ # so we can't have both in the default task.
36
+ Kettle::Dev.register_default("spec") unless Kettle::Dev.default_registered?("coverage")
37
+ end
38
+ rescue LoadError
39
+ warn("[kettle-dev][spec_test.rake] failed to load rspec/core/rake_task") if Kettle::Dev::DEBUGGING
40
+ desc("spec task stub")
41
+ task(:spec) do
42
+ warn("NOTE: rspec isn't installed, or is disabled for #{RUBY_VERSION} in the current environment")
43
+ end
44
+ end
45
+ }
46
+
47
+ # Setup RSpec
48
+ if defined?(Kettle::Dev::IS_CI)
49
+ if Kettle::Dev::IS_CI
50
+ # then we should not have a coverage task, but do want a spec test.
51
+ setup_spec_task.call(default: true)
52
+ else
53
+ # then we should have a coverage task.
54
+ # The coverage task will invoke the "test" task, which will invoke the spec task.
55
+ setup_spec_task.call(default: false)
36
56
  end
57
+ else
58
+ # then we do not have a coverage task setup by this gem, and are not in a coverage context.
59
+ # So setup a spec test.
60
+ setup_spec_task.call(default: true)
37
61
  end
38
62
 
39
63
  spec_registered = Kettle::Dev.default_registered?("spec")
40
64
  coverage_registered = Kettle::Dev.default_registered?("coverage")
41
65
  test_registered = Kettle::Dev.default_registered?("test")
66
+ spec_and_coverage = spec_registered && coverage_registered
42
67
  spec_or_coverage = spec_registered || coverage_registered
43
68
 
44
- if spec_or_coverage && !test_registered
45
- task test: :spec
46
- elsif test_registered && !spec_or_coverage
69
+ if test_registered && !spec_or_coverage
47
70
  task spec: :test
48
- elsif test_registered && spec_or_coverage
49
- # When we have both tasks registered, make spec run as part of the test task
50
- task test: :spec
51
- else
52
- puts "No test task is registered."
71
+ # elsif test_registered && spec_registered
72
+ # # When we have both tasks registered as default, making spec run as part of test would be redundant.
73
+ # # task test: :spec
74
+ # elsif test_registered && coverage_registered
75
+ # # When we have both tasks registered as default, making coverage run as part of test would be circular.
76
+ # # task test: :coverage
77
+ elsif !test_registered
78
+ if spec_registered && !coverage_registered
79
+ puts "Spec task is registered as default task. Creating test task with spec as pre-requisite" if Kettle::Dev::DEBUGGING
80
+ # If spec is registered as default, it should be invoked by the test task when test is not default,
81
+ # because some CI workflows will be configured to run bin/rake test.
82
+ desc "A test task with spec as prerequisite"
83
+ task test: :spec
84
+ elsif coverage_registered && !spec_registered
85
+ puts "Coverage task is registered as default task, and will call test task, with spec as pre-requisite." if Kettle::Dev::DEBUGGING
86
+ # If coverage is registered as default, it will invoke test.
87
+ # We need to make spec a prerequisite of test so that it runs as part of the test task,
88
+ # which will be invoked by the coverage task.
89
+ desc "A test task with spec as prerequisite"
90
+ task test: :spec
91
+ end
53
92
  end
54
93
  # rubocop:enable Rake/DuplicateTask
94
+
95
+ if spec_and_coverage
96
+ # They should not both be registered as default tasks, as they run the same tests.
97
+ warn("[kettle-dev][spec_test.rake] both spec and coverage are registered as default tasks!") if Kettle::Dev::DEBUGGING
98
+ elsif test_registered && spec_registered
99
+ # They should not both be registered as default tasks, as they will be setup to run the same tests.
100
+ warn("[kettle-dev][spec_test.rake] both test and spec are registered as default tasks!") if Kettle::Dev::DEBUGGING
101
+ elsif test_registered && coverage_registered
102
+ # They should not both be registered as default tasks, coverage invokes the test task.
103
+ warn("[kettle-dev][spec_test.rake] both test and coverage are registered as default tasks!") if Kettle::Dev::DEBUGGING
104
+ 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