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.
- checksums.yaml +4 -4
- checksums.yaml.gz.sig +1 -2
- data/.envrc +1 -0
- data/.envrc.example +1 -0
- data/.envrc.no-osc.example +1 -0
- data/.github/workflows/ancient.yml +1 -1
- data/.github/workflows/ancient.yml.example +1 -1
- data/.github/workflows/codeql-analysis.yml +1 -1
- data/.github/workflows/coverage.yml +1 -1
- data/.github/workflows/coverage.yml.example +1 -1
- data/.github/workflows/current.yml +1 -1
- data/.github/workflows/current.yml.example +1 -1
- data/.github/workflows/dep-heads.yml +1 -1
- data/.github/workflows/dependency-review.yml +1 -1
- data/.github/workflows/heads.yml +1 -1
- data/.github/workflows/heads.yml.example +1 -1
- data/.github/workflows/jruby.yml +1 -1
- data/.github/workflows/jruby.yml.example +1 -1
- data/.github/workflows/legacy.yml +1 -1
- data/.github/workflows/license-eye.yml +1 -1
- data/.github/workflows/locked_deps.yml +1 -1
- data/.github/workflows/opencollective.yml +1 -1
- data/.github/workflows/style.yml +1 -1
- data/.github/workflows/supported.yml +1 -1
- data/.github/workflows/truffle.yml +1 -1
- data/.github/workflows/unlocked_deps.yml +1 -1
- data/.github/workflows/unsupported.yml +1 -1
- data/CHANGELOG.md +35 -1
- data/Gemfile +3 -0
- data/Gemfile.example +3 -0
- data/README.md +90 -37
- data/README.md.example +16 -12
- data/README.md.no-osc.example +16 -12
- data/Rakefile.example +1 -1
- data/gemfiles/modular/style.gemfile.example +1 -1
- data/gemfiles/modular/templating.gemfile +3 -0
- data/lib/kettle/dev/appraisals_ast_merger.rb +383 -0
- data/lib/kettle/dev/changelog_cli.rb +13 -0
- data/lib/kettle/dev/modular_gemfiles.rb +11 -3
- data/lib/kettle/dev/prism_utils.rb +188 -0
- data/lib/kettle/dev/rakelib/spec_test.rake +70 -20
- data/lib/kettle/dev/source_merger.rb +345 -0
- data/lib/kettle/dev/tasks/template_task.rb +11 -1
- data/lib/kettle/dev/template_helpers.rb +70 -226
- data/lib/kettle/dev/version.rb +1 -1
- data/lib/kettle/dev.rb +2 -0
- data/sig/kettle/dev/appraisals_ast_merger.rbs +72 -0
- data/sig/kettle/dev/changelog_cli.rbs +64 -0
- data/sig/kettle/dev/prism_utils.rbs +56 -0
- data/sig/kettle/dev/source_merger.rbs +86 -0
- data/sig/kettle/dev/versioning.rbs +21 -0
- data.tar.gz.sig +0 -0
- metadata +14 -5
- metadata.gz.sig +0 -0
- /data/sig/kettle/dev/{dvcscli.rbs → dvcs_cli.rbs} +0 -0
data/Rakefile.example
CHANGED
|
@@ -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,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")
|
|
@@ -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
|
-
|
|
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
|