prism-merge 1.0.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.
@@ -0,0 +1,381 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "set"
4
+
5
+ module Prism
6
+ module Merge
7
+ # Identifies sequential "anchor sections" where template and destination
8
+ # have identical or equivalent lines, defining merge boundaries.
9
+ # Similar to diff algorithm but AST-aware.
10
+ class FileAligner
11
+ # Represents a section of matching lines between files
12
+ Anchor = Struct.new(:template_start, :template_end, :dest_start, :dest_end, :match_type, :score) do
13
+ # @return [Range] Line range in template file covered by this anchor
14
+ def template_range
15
+ template_start..template_end
16
+ end
17
+
18
+ # @return [Range] Line range in destination file covered by this anchor
19
+ def dest_range
20
+ dest_start..dest_end
21
+ end
22
+
23
+ # @return [Integer] Number of lines covered by this anchor
24
+ def length
25
+ template_end - template_start + 1
26
+ end
27
+ end
28
+
29
+ # Represents a boundary where files differ
30
+ Boundary = Struct.new(:template_range, :dest_range, :prev_anchor, :next_anchor) do
31
+ # @return [Array<Integer>] Array of line numbers in template file within this boundary
32
+ def template_lines
33
+ return [] unless template_range
34
+ (template_range.begin..template_range.end).to_a
35
+ end
36
+
37
+ # @return [Array<Integer>] Array of line numbers in destination file within this boundary
38
+ def dest_lines
39
+ return [] unless dest_range
40
+ (dest_range.begin..dest_range.end).to_a
41
+ end
42
+ end
43
+
44
+ attr_reader :template_analysis, :dest_analysis, :anchors, :boundaries
45
+
46
+ # @param template_analysis [FileAnalysis] Template file analysis
47
+ # @param dest_analysis [FileAnalysis] Destination file analysis
48
+ def initialize(template_analysis, dest_analysis)
49
+ @template_analysis = template_analysis
50
+ @dest_analysis = dest_analysis
51
+ @anchors = []
52
+ @boundaries = []
53
+ end
54
+
55
+ # Perform alignment and identify anchors and boundaries
56
+ # @return [Array<Boundary>] Boundaries requiring conflict resolution
57
+ def align
58
+ find_anchors
59
+ compute_boundaries
60
+ @boundaries
61
+ end
62
+
63
+ private
64
+
65
+ # Find matching sections between template and destination
66
+ def find_anchors
67
+ @anchors = []
68
+
69
+ # Special case: if files are identical, create one anchor for entire file
70
+ if @template_analysis.lines == @dest_analysis.lines
71
+ @anchors << Anchor.new(
72
+ 1,
73
+ @template_analysis.lines.length,
74
+ 1,
75
+ @dest_analysis.lines.length,
76
+ :exact_match,
77
+ @template_analysis.lines.length,
78
+ )
79
+ return
80
+ end
81
+
82
+ # Strategy: Find exact line matches and structural matches
83
+ # 1. Structural node matches (same signature) - FIRST to ensure matching blocks become anchors
84
+ # 2. Exact line matches (including comments)
85
+ # 3. Freeze blocks (always anchors)
86
+
87
+ # Start with node signature-based anchors (highest priority)
88
+ @anchors = []
89
+ add_node_signature_anchors
90
+
91
+ # Build mapping of normalized lines for quick lookup
92
+ template_line_map = build_line_map(@template_analysis)
93
+ dest_line_map = build_line_map(@dest_analysis)
94
+
95
+ # Find exact line matches using longest common subsequence approach
96
+ exact_matches = find_exact_line_matches(template_line_map, dest_line_map)
97
+
98
+ # Convert matches to anchors, merging consecutive matches
99
+ line_anchors = merge_consecutive_matches(exact_matches)
100
+
101
+ # Add line anchors that don't overlap with signature anchors
102
+ line_anchors.each do |anchor|
103
+ overlaps = @anchors.any? do |existing|
104
+ ranges_overlap?(existing.template_range, anchor.template_range) ||
105
+ ranges_overlap?(existing.dest_range, anchor.dest_range)
106
+ end
107
+ @anchors << anchor unless overlaps
108
+ end
109
+
110
+ # Add freeze block anchors
111
+ add_freeze_block_anchors
112
+
113
+ # Sort anchors by position
114
+ @anchors.sort_by! { |a| [a.template_start, a.dest_start] }
115
+ end
116
+
117
+ def build_line_map(analysis)
118
+ map = {}
119
+ # Keywords that are too generic to use as anchors on their own
120
+ generic_keywords = %w[end else elsif when rescue ensure]
121
+
122
+ # Get line numbers covered by statement nodes - these shouldn't be matched line-by-line
123
+ statement_lines = Set.new
124
+ analysis.statements.each do |stmt|
125
+ (stmt.location.start_line..stmt.location.end_line).each do |line_num|
126
+ statement_lines << line_num
127
+ end
128
+ end
129
+
130
+ analysis.lines.each_with_index do |line, idx|
131
+ line_num = idx + 1
132
+ normalized = line.strip
133
+ next if normalized.empty?
134
+ # Skip overly generic lines that appear in many contexts
135
+ next if generic_keywords.include?(normalized)
136
+ # Skip lines that are part of statement nodes - they should be matched by signature
137
+ next if statement_lines.include?(line_num)
138
+ map[line_num] = normalized
139
+ end
140
+ map
141
+ end
142
+
143
+ def find_exact_line_matches(template_map, dest_map)
144
+ matches = []
145
+
146
+ # Build reverse mapping from normalized content to line numbers
147
+ dest_content_to_lines = {}
148
+ dest_map.each do |line_num, content|
149
+ dest_content_to_lines[content] ||= []
150
+ dest_content_to_lines[content] << line_num
151
+ end
152
+
153
+ # Find matches - for each template line, find first matching dest line
154
+ used_dest_lines = Set.new
155
+
156
+ template_map.keys.sort.each do |t_line|
157
+ content = template_map[t_line]
158
+ next unless dest_content_to_lines[content]
159
+
160
+ # Find first unused destination line with matching content
161
+ d_line = dest_content_to_lines[content].find { |dl| !used_dest_lines.include?(dl) }
162
+ next unless d_line
163
+
164
+ matches << {
165
+ template_line: t_line,
166
+ dest_line: d_line,
167
+ content: content,
168
+ }
169
+
170
+ used_dest_lines << d_line
171
+ end
172
+
173
+ matches
174
+ end
175
+
176
+ def merge_consecutive_matches(matches)
177
+ return [] if matches.empty?
178
+
179
+ anchors = []
180
+ current_start_t = matches[0][:template_line]
181
+ current_start_d = matches[0][:dest_line]
182
+ current_end_t = current_start_t
183
+ current_end_d = current_start_d
184
+
185
+ matches[1..-1].each do |match|
186
+ t_line = match[:template_line]
187
+ d_line = match[:dest_line]
188
+
189
+ # Check if this extends the current sequence
190
+ if t_line == current_end_t + 1 && d_line == current_end_d + 1
191
+ # Sequence continues
192
+ else
193
+ # Save current anchor if it's substantial (at least 1 line)
194
+ if current_end_t - current_start_t >= 0
195
+ anchors << Anchor.new(
196
+ current_start_t,
197
+ current_end_t,
198
+ current_start_d,
199
+ current_end_d,
200
+ :exact_match,
201
+ current_end_t - current_start_t + 1,
202
+ )
203
+ end
204
+
205
+ # Start new sequence
206
+ current_start_t = t_line
207
+ current_start_d = d_line
208
+ end
209
+ current_end_t = t_line
210
+ current_end_d = d_line
211
+ end
212
+
213
+ # Don't forget the last anchor
214
+ if current_end_t - current_start_t >= 0
215
+ anchors << Anchor.new(
216
+ current_start_t,
217
+ current_end_t,
218
+ current_start_d,
219
+ current_end_d,
220
+ :exact_match,
221
+ current_end_t - current_start_t + 1,
222
+ )
223
+ end
224
+
225
+ anchors
226
+ end
227
+
228
+ def add_node_signature_anchors
229
+ # Match nodes with identical signatures to create anchors
230
+ # This helps recognize blocks like appraise "name" with different contents as the same
231
+ template_nodes = @template_analysis.nodes_with_comments
232
+ dest_nodes = @dest_analysis.nodes_with_comments
233
+
234
+ # Build signature map for dest nodes
235
+ dest_sig_map = {}
236
+ dest_nodes.each do |node_info|
237
+ sig = node_info[:signature]
238
+ next unless sig
239
+
240
+ dest_sig_map[sig] ||= []
241
+ dest_sig_map[sig] << node_info
242
+ end
243
+
244
+ # Track which dest nodes have been matched
245
+ matched_dest = Set.new
246
+
247
+ # Find matching template nodes
248
+ template_nodes.each do |t_node_info|
249
+ sig = t_node_info[:signature]
250
+ next unless sig
251
+ next unless dest_sig_map[sig]
252
+
253
+ # Find first unmatched dest node with this signature
254
+ d_node_info = dest_sig_map[sig].find { |d| !matched_dest.include?(d[:index]) }
255
+ next unless d_node_info
256
+
257
+ # Create anchor for this matched node (including its leading comments)
258
+ t_start = t_node_info[:leading_comments].any? ? t_node_info[:leading_comments].first.location.start_line : t_node_info[:line_range].begin
259
+ t_end = t_node_info[:line_range].end
260
+ d_start = d_node_info[:leading_comments].any? ? d_node_info[:leading_comments].first.location.start_line : d_node_info[:line_range].begin
261
+ d_end = d_node_info[:line_range].end
262
+
263
+ # Check if this would completely overlap with existing anchors
264
+ # Only skip if an anchor already covers the EXACT same range
265
+ overlaps = @anchors.any? do |a|
266
+ a.template_start == t_start && a.template_end == t_end &&
267
+ a.dest_start == d_start && a.dest_end == d_end
268
+ end
269
+
270
+ unless overlaps
271
+ @anchors << Anchor.new(
272
+ t_start,
273
+ t_end,
274
+ d_start,
275
+ d_end,
276
+ :signature_match,
277
+ t_end - t_start + 1,
278
+ )
279
+ matched_dest << d_node_info[:index]
280
+ end
281
+ end
282
+ end
283
+
284
+ def add_freeze_block_anchors
285
+ # Freeze blocks in destination should always be preserved as anchors
286
+ @dest_analysis.freeze_blocks.each do |block|
287
+ line_range = block[:line_range]
288
+
289
+ # Check if there's a corresponding freeze block in template
290
+ template_block = @template_analysis.freeze_blocks.find do |tb|
291
+ tb[:start_marker] == block[:start_marker]
292
+ end
293
+
294
+ if template_block
295
+ # Check if there's already an anchor covering this range
296
+ # (from exact line matches)
297
+ existing_anchor = @anchors.find do |a|
298
+ a.template_start <= template_block[:line_range].begin &&
299
+ a.template_end >= template_block[:line_range].end &&
300
+ a.dest_start <= line_range.begin &&
301
+ a.dest_end >= line_range.end
302
+ end
303
+
304
+ # Only create freeze block anchor if not already covered
305
+ unless existing_anchor
306
+ # Both files have this freeze block - create anchor
307
+ @anchors << Anchor.new(
308
+ template_block[:line_range].begin,
309
+ template_block[:line_range].end,
310
+ line_range.begin,
311
+ line_range.end,
312
+ :freeze_block,
313
+ 100, # High score for freeze blocks
314
+ )
315
+ end
316
+ end
317
+ end
318
+ end
319
+
320
+ def compute_boundaries
321
+ @boundaries = []
322
+
323
+ # Special case: no anchors means entire files are boundaries
324
+ if @anchors.empty?
325
+ @boundaries << Boundary.new(
326
+ 1..@template_analysis.lines.length,
327
+ 1..@dest_analysis.lines.length,
328
+ nil,
329
+ nil,
330
+ )
331
+ return
332
+ end
333
+
334
+ # Boundary before first anchor
335
+ first_anchor = @anchors.first
336
+ if first_anchor.template_start > 1 || first_anchor.dest_start > 1
337
+ template_range = (first_anchor.template_start > 1) ? (1..first_anchor.template_start - 1) : nil
338
+ dest_range = (first_anchor.dest_start > 1) ? (1..first_anchor.dest_start - 1) : nil
339
+
340
+ if template_range || dest_range
341
+ @boundaries << Boundary.new(template_range, dest_range, nil, first_anchor)
342
+ end
343
+ end
344
+
345
+ # Boundaries between consecutive anchors
346
+ @anchors.each_cons(2) do |prev_anchor, next_anchor|
347
+ template_gap_start = prev_anchor.template_end + 1
348
+ template_gap_end = next_anchor.template_start - 1
349
+ dest_gap_start = prev_anchor.dest_end + 1
350
+ dest_gap_end = next_anchor.dest_start - 1
351
+
352
+ template_range = (template_gap_end >= template_gap_start) ? (template_gap_start..template_gap_end) : nil
353
+ dest_range = (dest_gap_end >= dest_gap_start) ? (dest_gap_start..dest_gap_end) : nil
354
+
355
+ if template_range || dest_range
356
+ @boundaries << Boundary.new(template_range, dest_range, prev_anchor, next_anchor)
357
+ end
358
+ end
359
+
360
+ # Boundary after last anchor
361
+ last_anchor = @anchors.last
362
+ template_remaining = last_anchor.template_end < @template_analysis.lines.length
363
+ dest_remaining = last_anchor.dest_end < @dest_analysis.lines.length
364
+
365
+ if template_remaining || dest_remaining
366
+ template_range = template_remaining ? (last_anchor.template_end + 1..@template_analysis.lines.length) : nil
367
+ dest_range = dest_remaining ? (last_anchor.dest_end + 1..@dest_analysis.lines.length) : nil
368
+
369
+ if template_range || dest_range
370
+ @boundaries << Boundary.new(template_range, dest_range, last_anchor, nil)
371
+ end
372
+ end
373
+ end
374
+
375
+ def ranges_overlap?(range1, range2)
376
+ return false if range1.nil? || range2.nil?
377
+ range1.begin <= range2.end && range2.begin <= range1.end
378
+ end
379
+ end
380
+ end
381
+ end
@@ -0,0 +1,298 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "prism"
4
+
5
+ module Prism
6
+ module Merge
7
+ # Comprehensive metadata capture for a Ruby file being merged.
8
+ # Tracks Prism parse result, line-to-node mapping, comment associations,
9
+ # structural signatures, and sequential anchor lines for merge alignment.
10
+ class FileAnalysis
11
+ # Regex pattern for freeze block start marker.
12
+ # Matches comments like: # kettle-dev:freeze
13
+ # Case-insensitive to allow variations like FREEZE or Freeze
14
+ FREEZE_START = /#\s*kettle-dev:freeze/i
15
+
16
+ # Regex pattern for freeze block end marker.
17
+ # Matches comments like: # kettle-dev:unfreeze
18
+ # Case-insensitive to allow variations like UNFREEZE or Unfreeze
19
+ FREEZE_END = /#\s*kettle-dev:unfreeze/i
20
+
21
+ # Combined regex pattern for matching complete freeze blocks.
22
+ # Captures content between freeze/unfreeze markers (inclusive).
23
+ # Used to identify sections that should always be preserved from destination.
24
+ #
25
+ # @example Freeze block in Ruby code
26
+ # # kettle-dev:freeze
27
+ # CUSTOM_CONFIG = { key: "secret" }
28
+ # # kettle-dev:unfreeze
29
+ FREEZE_BLOCK = Regexp.new("(#{FREEZE_START.source}).*?(#{FREEZE_END.source})", Regexp::IGNORECASE | Regexp::MULTILINE)
30
+
31
+ attr_reader :content, :parse_result, :lines, :statements, :freeze_blocks
32
+
33
+ # @param content [String] Ruby source code to analyze
34
+ # @param signature_generator [Proc, nil] Optional proc to generate node signatures
35
+ def initialize(content, signature_generator: nil)
36
+ @content = content
37
+ @lines = content.lines
38
+ @parse_result = Prism.parse(content)
39
+ @statements = extract_statements
40
+ @freeze_blocks = extract_freeze_blocks
41
+ @signature_generator = signature_generator
42
+ @line_to_node_map = nil
43
+ @node_to_line_map = nil
44
+ @comment_map = nil
45
+ end
46
+
47
+ # Check if parsing was successful
48
+ # @return [Boolean]
49
+ def valid?
50
+ @parse_result.success?
51
+ end
52
+
53
+ # Get all top-level statement nodes
54
+ # @return [Array<Prism::Node>]
55
+ def extract_statements
56
+ return [] unless valid?
57
+ body = @parse_result.value.statements
58
+ return [] unless body
59
+
60
+ if body.is_a?(Prism::StatementsNode)
61
+ body.body.compact
62
+ else
63
+ [body].compact
64
+ end
65
+ end
66
+
67
+ # Extract freeze block information
68
+ # @return [Array<Hash>] Array of freeze block metadata
69
+ def extract_freeze_blocks
70
+ return [] unless content.match?(FREEZE_START)
71
+
72
+ blocks = []
73
+ content.to_enum(:scan, FREEZE_BLOCK).each do
74
+ match = Regexp.last_match
75
+ next unless match
76
+
77
+ start_idx = match.begin(0)
78
+ end_idx = match.end(0)
79
+ segment = match[0]
80
+ start_line = content[0...start_idx].count("\n") + 1
81
+ end_line = content[0...end_idx].count("\n") + 1
82
+
83
+ blocks << {
84
+ range: start_idx...end_idx,
85
+ line_range: start_line..end_line,
86
+ text: segment,
87
+ start_marker: segment&.lines&.first&.strip,
88
+ }
89
+ end
90
+
91
+ blocks
92
+ end
93
+
94
+ # Build mapping from line numbers to AST nodes
95
+ # @return [Hash<Integer, Array<Prism::Node>>] Line number => nodes on that line
96
+ def line_to_node_map
97
+ @line_to_node_map ||= build_line_to_node_map
98
+ end
99
+
100
+ # Build mapping from nodes to line ranges
101
+ # @return [Hash<Prism::Node, Range>] Node => line range
102
+ def node_to_line_map
103
+ @node_to_line_map ||= build_node_to_line_map
104
+ end
105
+
106
+ # Get nodes with their associated comments and metadata
107
+ # @return [Array<Hash>] Array of node info hashes
108
+ def nodes_with_comments
109
+ @nodes_with_comments ||= extract_nodes_with_comments
110
+ end
111
+
112
+ # Get comment map by line number
113
+ # @return [Hash<Integer, Array<Prism::Comment>>] Line number => comments
114
+ def comment_map
115
+ @comment_map ||= build_comment_map
116
+ end
117
+
118
+ # Get structural signature for a statement at given index
119
+ # @param index [Integer] Statement index
120
+ # @return [Array, nil] Signature array
121
+ def signature_at(index)
122
+ return if index < 0 || index >= statements.length
123
+ generate_signature(statements[index])
124
+ end
125
+
126
+ # Generate signature for a node
127
+ # @param node [Prism::Node] Node to generate signature for
128
+ # @return [Array, nil] Signature array
129
+ def generate_signature(node)
130
+ if @signature_generator
131
+ @signature_generator.call(node)
132
+ else
133
+ default_signature(node)
134
+ end
135
+ end
136
+
137
+ # Check if a line is within a freeze block
138
+ # @param line_num [Integer] 1-based line number
139
+ # @return [Boolean]
140
+ def in_freeze_block?(line_num)
141
+ freeze_blocks.any? { |block| block[:line_range].cover?(line_num) }
142
+ end
143
+
144
+ # Get the freeze block containing the given line, if any
145
+ # @param line_num [Integer] 1-based line number
146
+ # @return [Hash, nil] Freeze block metadata or nil
147
+ def freeze_block_at(line_num)
148
+ freeze_blocks.find { |block| block[:line_range].cover?(line_num) }
149
+ end
150
+
151
+ # Get normalized line content (stripped)
152
+ # @param line_num [Integer] 1-based line number
153
+ # @return [String, nil]
154
+ def normalized_line(line_num)
155
+ return if line_num < 1 || line_num > lines.length
156
+ lines[line_num - 1].strip
157
+ end
158
+
159
+ # Get raw line content
160
+ # @param line_num [Integer] 1-based line number
161
+ # @return [String, nil]
162
+ def line_at(line_num)
163
+ return if line_num < 1 || line_num > lines.length
164
+ lines[line_num - 1]
165
+ end
166
+
167
+ private
168
+
169
+ def build_line_to_node_map
170
+ map = Hash.new { |h, k| h[k] = [] }
171
+ return map unless valid?
172
+
173
+ statements.each do |node|
174
+ start_line = node.location.start_line
175
+ end_line = node.location.end_line
176
+ (start_line..end_line).each do |line_num|
177
+ map[line_num] << node
178
+ end
179
+ end
180
+
181
+ map
182
+ end
183
+
184
+ def build_node_to_line_map
185
+ map = {}
186
+ return map unless valid?
187
+
188
+ statements.each do |node|
189
+ map[node] = node.location.start_line..node.location.end_line
190
+ end
191
+
192
+ map
193
+ end
194
+
195
+ def extract_nodes_with_comments
196
+ return [] unless valid?
197
+
198
+ statements.map.with_index do |stmt, idx|
199
+ prev_stmt = (idx > 0) ? statements[idx - 1] : nil
200
+ body_node = @parse_result.value.statements
201
+
202
+ {
203
+ node: stmt,
204
+ index: idx,
205
+ leading_comments: find_leading_comments(stmt, prev_stmt, body_node),
206
+ inline_comments: inline_comments_for_node(stmt),
207
+ signature: generate_signature(stmt),
208
+ line_range: stmt.location.start_line..stmt.location.end_line,
209
+ }
210
+ end
211
+ end
212
+
213
+ def find_leading_comments(current_stmt, prev_stmt, body_node)
214
+ start_line = prev_stmt ? prev_stmt.location.end_line : 0
215
+ end_line = current_stmt.location.start_line
216
+
217
+ # Find all comments in the range
218
+ candidates = @parse_result.comments.select do |comment|
219
+ comment.location.start_line > start_line &&
220
+ comment.location.start_line < end_line
221
+ end
222
+
223
+ # Only include comments that are immediately adjacent to the statement
224
+ # (no blank lines between the comment and the statement)
225
+ adjacent_comments = []
226
+ expected_line = end_line - 1
227
+
228
+ candidates.reverse_each do |comment|
229
+ comment_line = comment.location.start_line
230
+
231
+ # Only include if this comment is immediately adjacent (no gaps)
232
+ if comment_line == expected_line
233
+ adjacent_comments.unshift(comment)
234
+ expected_line = comment_line - 1
235
+ else
236
+ # Gap found (blank line or code), stop looking
237
+ break
238
+ end
239
+ end
240
+
241
+ adjacent_comments
242
+ end
243
+
244
+ def inline_comments_for_node(stmt)
245
+ @parse_result.comments.select do |comment|
246
+ # Check if comment is on the same line as the start of the statement
247
+ # and appears after the statement text begins
248
+ comment.location.start_line == stmt.location.start_line &&
249
+ comment.location.start_offset > stmt.location.start_offset
250
+ end
251
+ end
252
+
253
+ def build_comment_map
254
+ map = Hash.new { |h, k| h[k] = [] }
255
+ return map unless valid?
256
+
257
+ @parse_result.comments.each do |comment|
258
+ line = comment.location.start_line
259
+ map[line] << comment
260
+ end
261
+
262
+ map
263
+ end
264
+
265
+ # Default signature generation
266
+ def default_signature(node)
267
+ return [:nil] unless node
268
+
269
+ # For conditional nodes, signature should be based on the condition only,
270
+ # not the body, so conditionals with same condition but different bodies
271
+ # are recognized as matching
272
+ case node
273
+ when Prism::IfNode, Prism::UnlessNode
274
+ condition_slice = node.predicate&.slice || ""
275
+ [node.class.name.split("::").last.to_sym, condition_slice]
276
+ when Prism::ConstantWriteNode, Prism::GlobalVariableWriteNode,
277
+ Prism::InstanceVariableWriteNode, Prism::ClassVariableWriteNode,
278
+ Prism::LocalVariableWriteNode
279
+ # For variable/constant assignments, signature based on name only,
280
+ # not the value, so assignments with same name but different values
281
+ # are recognized as matching
282
+ name = node.respond_to?(:name) ? node.name.to_s : node.slice.split("=").first.strip
283
+ [node.class.name.split("::").last.to_sym, name]
284
+ when Prism::CallNode
285
+ # For method calls with blocks, signature based on method name and arguments only,
286
+ # not the block body, so calls with same name/args but different blocks
287
+ # are recognized as matching
288
+ method_name = node.name.to_s
289
+ # Extract just the arguments (not the block)
290
+ arg_signature = node.arguments&.arguments&.map { |arg| arg.slice }&.join(", ") || ""
291
+ [node.class.name.split("::").last.to_sym, method_name, arg_signature]
292
+ else
293
+ [node.class.name.split("::").last.to_sym, node.slice]
294
+ end
295
+ end
296
+ end
297
+ end
298
+ end