prism-merge 1.0.3 → 1.1.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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: e450acf315100305aa2d3b2c0d76989d937c82a85b2b7643afa97ae198ea4b2a
4
- data.tar.gz: '09e455e508c6d91722e8c73d04c7086dad434f95e8fc6a1447a5c094ecc307ef'
3
+ metadata.gz: edeb460ce4645e2e6a8caf390cca9b23abeb88df9b3c314f6f86b42bc4ed68f5
4
+ data.tar.gz: 2f2fb56d28d1d8ddb2563b8b312186f06bd96b26bdf89753e1382724faac9332
5
5
  SHA512:
6
- metadata.gz: 9ee4b24c5967da3e42de67f344aa4abeeb62b7f255bf05a33fb0194c3932628e95755c2dd0cb68d74b2d8ad2a19ba7fc88dd2b6e5614088dcfd5eb93aee64d13
7
- data.tar.gz: 1c7a7c6e0ba69a2435f153e3f60252b7e65a332b163406bac663f95385680be2d885814f8d7564d3fb3b60dc859f91bbcbba8f1a0108037cc892f7dd189e87cd
6
+ metadata.gz: 1a5fa5900b24ba063df2de14c8772fda7b4dcebacfd2529dc41b465550b2baad3f8b8597d2c9759eac69b4025218843c35a41c14273c4ce69da294469b09b166
7
+ data.tar.gz: ac7e0ba8364617dcc2e9c67955b7c5cf303028c54cfaee272eb86a4f26378bd7c0eeed585db714b851bdbd42aae710b24a1fe9a6d629d880d0e26482e222cd9d
checksums.yaml.gz.sig CHANGED
Binary file
data/CHANGELOG.md CHANGED
@@ -30,6 +30,37 @@ Please file a bug if you notice a violation of semantic versioning.
30
30
 
31
31
  ### Security
32
32
 
33
+ ## [1.1.0] - 2025-12-04
34
+
35
+ - TAG: [v1.1.0][1.1.0t]
36
+ - COVERAGE: 95.65% -- 770/805 lines in 9 files
37
+ - BRANCH COVERAGE: 81.13% -- 245/302 branches in 9 files
38
+ - 100.00% documented
39
+
40
+ ### Added
41
+
42
+ - Recursive merge support for class and module bodies - nested structures are now merged intelligently
43
+ - Conditional signature matching for `if`/`unless` blocks based on condition expression
44
+ - Freeze block validation for partial/incomplete nodes and freeze blocks inside non-class/module contexts
45
+ - Freeze blocks now match by position/order when both files have multiple freeze blocks
46
+ - `add_template_only_nodes` option now properly respected in recursive merges and boundary processing
47
+ - `DebugLogger`, controlled by `ENV["PRISM_MERGE_DEBUG"]` set to true or false
48
+ - more specs
49
+
50
+ ### Changed
51
+
52
+ - Migrated to Prism v1.6.0 native comment attachment (removed custom comment association logic)
53
+ - Simplified FileAnalysis implementation using Prism's built-in features
54
+ - Improved node lookup to handle anchors with leading comments (e.g., magic comments)
55
+
56
+ ### Fixed
57
+
58
+ - Template-only nodes are now correctly excluded in all contexts when `add_template_only_nodes: false`
59
+ - Freeze blocks inside methods now properly raise InvalidStructureError (only class/module-level freeze blocks allowed)
60
+ - Freeze block matching now works correctly with multiple consecutive freeze blocks (matches by index/order)
61
+ - Duplicate freeze blocks from template no longer appear when destination has matching freeze blocks
62
+ - Magic comments at file top no longer prevent node lookup in recursive merges
63
+
33
64
  ## [1.0.3] - 2025-12-03
34
65
 
35
66
  - TAG: [v1.0.3][1.0.3t]
@@ -85,7 +116,9 @@ Please file a bug if you notice a violation of semantic versioning.
85
116
 
86
117
  - Initial release
87
118
 
88
- [Unreleased]: https://github.com/kettle-rb/prism-merge/compare/v1.0.3...HEAD
119
+ [Unreleased]: https://github.com/kettle-rb/prism-merge/compare/v1.1.0...HEAD
120
+ [1.1.0]: https://github.com/kettle-rb/prism-merge/compare/v1.0.3...v1.1.0
121
+ [1.1.0t]: https://github.com/kettle-rb/prism-merge/releases/tag/v1.1.0
89
122
  [1.0.3]: https://github.com/kettle-rb/prism-merge/compare/v1.0.2...v1.0.3
90
123
  [1.0.3t]: https://github.com/kettle-rb/prism-merge/releases/tag/v1.0.3
91
124
  [1.0.2]: https://github.com/kettle-rb/prism-merge/compare/v1.0.1...v1.0.2
data/README.md CHANGED
@@ -60,6 +60,7 @@ Prism::Merge is a standalone Ruby module that intelligently merges two versions
60
60
 
61
61
  - **AST-Aware**: Uses Prism parser to understand Ruby structure
62
62
  - **Intelligent**: Matches nodes by structural signatures
63
+ - **Recursive Merge**: Automatically merges class and module bodies recursively, intelligently combining nested methods and constants
63
64
  - **Comment-Preserving**: Comments are properly attached to relevant nodes and/or placement
64
65
  - **Freeze Block Support**: Respects `kettle-dev:freeze` markers for template merge control
65
66
  - **Full Provenance**: Tracks origin of every line
@@ -972,7 +973,7 @@ Thanks for RTFM. ☺️
972
973
  [📌gitmoji]: https://gitmoji.dev
973
974
  [📌gitmoji-img]: https://img.shields.io/badge/gitmoji_commits-%20%F0%9F%98%9C%20%F0%9F%98%8D-34495e.svg?style=flat-square
974
975
  [🧮kloc]: https://www.youtube.com/watch?v=dQw4w9WgXcQ
975
- [🧮kloc-img]: https://img.shields.io/badge/KLOC-0.642-FFDD67.svg?style=for-the-badge&logo=YouTube&logoColor=blue
976
+ [🧮kloc-img]: https://img.shields.io/badge/KLOC-0.805-FFDD67.svg?style=for-the-badge&logo=YouTube&logoColor=blue
976
977
  [🔐security]: SECURITY.md
977
978
  [🔐security-img]: https://img.shields.io/badge/security-policy-259D6C.svg?style=flat
978
979
  [📄copyright-notice-explainer]: https://opensource.stackexchange.com/questions/5778/why-do-licenses-such-as-the-mit-license-specify-a-single-year
@@ -91,7 +91,10 @@ module Prism
91
91
  end
92
92
 
93
93
  if dest_content[:lines].empty?
94
- add_content_to_result(template_content, result, :template, MergeResult::DECISION_KEPT_TEMPLATE)
94
+ # Only add template-only content if the flag allows it
95
+ if @add_template_only_nodes
96
+ add_content_to_result(template_content, result, :template, MergeResult::DECISION_KEPT_TEMPLATE)
97
+ end
95
98
  return
96
99
  end
97
100
 
@@ -143,8 +146,8 @@ module Prism
143
146
  end
144
147
 
145
148
  def merge_boundary_content(template_content, dest_content, _boundary, result)
146
- # Strategy: Process template content in order, replacing matched nodes with template version
147
- # and appending dest-only nodes at the end
149
+ # Strategy: Process nodes in order using signature matching.
150
+ # FreezeNodes from destination are always preferred to preserve customizations.
148
151
 
149
152
  template_nodes = template_content[:nodes]
150
153
  dest_nodes = dest_content[:nodes]
@@ -152,25 +155,34 @@ module Prism
152
155
  # Track which dest nodes have been matched
153
156
  matched_dest_indices = Set.new
154
157
 
155
- # Handle freeze blocks first
156
- if dest_content[:has_freeze_block]
157
- line_in_freeze = dest_content[:line_range].find { |ln| @dest_analysis.in_freeze_block?(ln) }
158
- if line_in_freeze
159
- freeze_block = @dest_analysis.freeze_block_at(line_in_freeze)
160
- freeze_content = extract_boundary_content(@dest_analysis, freeze_block[:line_range])
161
- add_content_to_result(freeze_content, result, :destination, MergeResult::DECISION_FREEZE_BLOCK)
162
-
163
- # Mark all nodes within the freeze block as matched
164
- dest_nodes.each do |d_node_info|
165
- if freeze_block[:line_range].cover?(d_node_info[:line_range].begin)
166
- matched_dest_indices << d_node_info[:index]
158
+ # Check if there are any FreezeNodes in destination - they always win
159
+ dest_freeze_nodes = dest_nodes.select { |n| n[:node].is_a?(FreezeNode) }
160
+
161
+ if dest_freeze_nodes.any?
162
+ # Add all destination freeze blocks as-is
163
+ dest_freeze_nodes.each do |freeze_node_info|
164
+ result.add_node(
165
+ freeze_node_info,
166
+ decision: MergeResult::DECISION_FREEZE_BLOCK,
167
+ source: :destination,
168
+ source_analysis: @dest_analysis,
169
+ )
170
+ matched_dest_indices << freeze_node_info[:index]
171
+
172
+ # Mark any template nodes within this freeze block range as processed
173
+ freeze_range = freeze_node_info[:line_range]
174
+ template_nodes.each do |t_node_info|
175
+ if freeze_range.cover?(t_node_info[:line_range].begin) &&
176
+ freeze_range.cover?(t_node_info[:line_range].end)
177
+ # Template node is inside freeze block, skip it
178
+ # (we'll handle this by checking if it overlaps with a freeze block)
167
179
  end
168
180
  end
169
181
  end
170
182
  end
171
183
 
172
- # Build signature map for destination nodes
173
- dest_sig_map = build_signature_map(dest_nodes)
184
+ # Build signature map for destination nodes (excluding already-matched freeze nodes)
185
+ dest_sig_map = build_signature_map(dest_nodes.reject { |n| matched_dest_indices.include?(n[:index]) })
174
186
 
175
187
  # Build a set of line numbers that are covered by leading comments of nodes
176
188
  # so we don't duplicate them when processing non-node lines
@@ -188,7 +200,6 @@ module Prism
188
200
  current_line = template_line_range.begin
189
201
  # Track if we're in a sequence of template-only nodes
190
202
  in_template_only_sequence = false
191
- last_added_line = nil
192
203
 
193
204
  sorted_nodes = template_nodes.sort_by { |n| n[:line_range].begin }
194
205
 
@@ -196,6 +207,18 @@ module Prism
196
207
  node_start = t_node_info[:line_range].begin
197
208
  node_end = t_node_info[:line_range].end
198
209
 
210
+ # Skip template nodes that overlap with destination freeze blocks
211
+ overlaps_freeze = dest_freeze_nodes.any? do |freeze_info|
212
+ freeze_range = freeze_info[:line_range]
213
+ node_start.between?(freeze_range.begin, freeze_range.end) ||
214
+ node_end.between?(freeze_range.begin, freeze_range.end)
215
+ end
216
+
217
+ if overlaps_freeze
218
+ current_line = node_end + 1
219
+ next
220
+ end
221
+
199
222
  # Check if this node will be matched or is template-only
200
223
  sig = t_node_info[:signature]
201
224
  is_matched = dest_sig_map[sig]&.any?
@@ -213,11 +236,9 @@ module Prism
213
236
  trailing_blank_end = line_num
214
237
  end
215
238
 
216
- node_start..trailing_blank_end
217
-
218
239
  # Add any non-node, non-blank lines before this node (e.g., comments not attached to nodes)
219
- if in_template_only_sequence && !is_matched
220
- # Skip lines before template-only nodes in a sequence
240
+ if (in_template_only_sequence && !is_matched) || (!is_matched && !@add_template_only_nodes)
241
+ # Skip lines before template-only nodes in a sequence OR when add_template_only_nodes is false
221
242
  current_line = node_start
222
243
  else
223
244
  while current_line < node_start
@@ -296,7 +317,6 @@ module Prism
296
317
  end
297
318
 
298
319
  in_template_only_sequence = false
299
- last_added_line = trailing_blank_end
300
320
  elsif @add_template_only_nodes
301
321
  # No match - this is a template-only node
302
322
  result.add_node(
@@ -313,7 +333,6 @@ module Prism
313
333
  end
314
334
 
315
335
  in_template_only_sequence = false
316
- last_added_line = trailing_blank_end
317
336
  # Add the template-only node
318
337
  else
319
338
  # Skip template-only nodes (don't add template nodes that don't exist in destination)
@@ -422,12 +441,25 @@ module Prism
422
441
  end
423
442
 
424
443
  def handle_orphan_lines(template_content, dest_content, result)
444
+ # With CommentNodes integrated into statements, there should be far fewer orphan lines
445
+ # Orphan lines are now only truly standalone content like blank lines or
446
+ # inline content not attached to nodes.
447
+
425
448
  # Find lines that aren't part of any node (pure comments, blank lines)
426
449
  template_orphans = find_orphan_lines(@template_analysis, template_content[:line_range], template_content[:nodes])
427
450
  dest_orphans = find_orphan_lines(@dest_analysis, dest_content[:line_range], dest_content[:nodes])
428
451
 
429
- # For simplicity, prefer template orphans but add unique dest orphans
430
- # This could be enhanced with more sophisticated comment merging
452
+ # Add template orphans first
453
+ template_orphans.each do |line_num|
454
+ line = @template_analysis.line_at(line_num)
455
+ result.add_line(
456
+ line.chomp,
457
+ decision: MergeResult::DECISION_KEPT_TEMPLATE,
458
+ template_line: line_num,
459
+ )
460
+ end
461
+
462
+ # Then add unique destination orphans (ones not in template)
431
463
  template_orphan_content = Set.new(template_orphans.map { |ln| @template_analysis.normalized_line(ln) })
432
464
 
433
465
  dest_orphans.each do |line_num|
@@ -0,0 +1,54 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Prism
4
+ module Merge
5
+ # Internal debug logging utility.
6
+ # Only logs when PRISM_MERGE_DEBUG environment variable is set.
7
+ # Optionally uses Ruby's Logger if available, otherwise falls back to simple puts.
8
+ # rubocop:disable ThreadSafety/ClassInstanceVariable
9
+ module DebugLogger
10
+ @logger = nil
11
+ @enabled = ENV.fetch("PRISM_MERGE_DEBUG", "false").casecmp?("true")
12
+
13
+ class << self
14
+ attr_reader :enabled
15
+
16
+ # Log a debug message if debugging is enabled
17
+ # @param message [String] The message to log
18
+ # @param context [Hash] Optional context information
19
+ def debug(message, context = {})
20
+ return unless @enabled
21
+
22
+ if logger_available?
23
+ ensure_logger
24
+ context_str = context.empty? ? "" : " #{context.inspect}"
25
+ @logger.debug("[prism-merge] #{message}#{context_str}")
26
+ else
27
+ context_str = context.empty? ? "" : " | #{context.inspect}"
28
+ puts "[DEBUG][prism-merge] #{message}#{context_str}"
29
+ end
30
+ end
31
+
32
+ private
33
+
34
+ # Check if Logger is available without raising an error
35
+ def logger_available?
36
+ defined?(Logger)
37
+ end
38
+
39
+ # Initialize logger if not already done
40
+ def ensure_logger
41
+ return if @logger
42
+
43
+ require "logger"
44
+ @logger = Logger.new($stdout)
45
+ @logger.level = Logger::DEBUG
46
+ rescue LoadError
47
+ # Logger not available, will fall back to puts
48
+ nil
49
+ end
50
+ end
51
+ end
52
+ # rubocop:enable ThreadSafety/ClassInstanceVariable
53
+ end
54
+ end
@@ -283,20 +283,21 @@ module Prism
283
283
 
284
284
  def add_freeze_block_anchors
285
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]
286
+ # Match freeze blocks by their index/order in the file
287
+ @dest_analysis.freeze_blocks.each_with_index do |freeze_node, index|
288
+ line_range = freeze_node.start_line..freeze_node.end_line
288
289
 
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
290
+ # Check if there's a corresponding freeze block in template at the same index
291
+ template_freeze = @template_analysis.freeze_blocks[index]
292
+
293
+ if template_freeze
294
+ template_range = template_freeze.start_line..template_freeze.end_line
293
295
 
294
- if template_block
295
296
  # Check if there's already an anchor covering this range
296
297
  # (from exact line matches)
297
298
  existing_anchor = @anchors.find do |a|
298
- a.template_start <= template_block[:line_range].begin &&
299
- a.template_end >= template_block[:line_range].end &&
299
+ a.template_start <= template_range.begin &&
300
+ a.template_end >= template_range.end &&
300
301
  a.dest_start <= line_range.begin &&
301
302
  a.dest_end >= line_range.end
302
303
  end
@@ -305,8 +306,8 @@ module Prism
305
306
  unless existing_anchor
306
307
  # Both files have this freeze block - create anchor
307
308
  @anchors << Anchor.new(
308
- template_block[:line_range].begin,
309
- template_block[:line_range].end,
309
+ template_range.begin,
310
+ template_range.end,
310
311
  line_range.begin,
311
312
  line_range.end,
312
313
  :freeze_block,