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.
data/REEK ADDED
File without changes
data/RUBOCOP.md ADDED
@@ -0,0 +1,71 @@
1
+ # RuboCop Usage Guide
2
+
3
+ ## Overview
4
+
5
+ A tale of two RuboCop plugin gems.
6
+
7
+ ### RuboCop Gradual
8
+
9
+ This project uses `rubocop_gradual` instead of vanilla RuboCop for code style checking. The `rubocop_gradual` tool allows for gradual adoption of RuboCop rules by tracking violations in a lock file.
10
+
11
+ ### RuboCop LTS
12
+
13
+ This project uses `rubocop-lts` to ensure, on a best-effort basis, compatibility with Ruby >= 1.9.2.
14
+ RuboCop rules are meticulously configured by the `rubocop-lts` family of gems to ensure that a project is compatible with a specific version of Ruby. See: https://rubocop-lts.gitlab.io for more.
15
+
16
+ ## Checking RuboCop Violations
17
+
18
+ To check for RuboCop violations in this project, always use:
19
+
20
+ ```bash
21
+ bundle exec rake rubocop_gradual:check
22
+ ```
23
+
24
+ **Do not use** the standard RuboCop commands like:
25
+ - `bundle exec rubocop`
26
+ - `rubocop`
27
+
28
+ ## Understanding the Lock File
29
+
30
+ The `.rubocop_gradual.lock` file tracks all current RuboCop violations in the project. This allows the team to:
31
+
32
+ 1. Prevent new violations while gradually fixing existing ones
33
+ 2. Track progress on code style improvements
34
+ 3. Ensure CI builds don't fail due to pre-existing violations
35
+
36
+ ## Common Commands
37
+
38
+ - **Check violations**
39
+ - `bundle exec rake rubocop_gradual`
40
+ - `bundle exec rake rubocop_gradual:check`
41
+ - **(Safe) Autocorrect violations, and update lockfile if no new violations**
42
+ - `bundle exec rake rubocop_gradual:autocorrect`
43
+ - **Force update the lock file (w/o autocorrect) to match violations present in code**
44
+ - `bundle exec rake rubocop_gradual:force_update`
45
+
46
+ ## Workflow
47
+
48
+ 1. Before submitting a PR, run `bundle exec rake rubocop_gradual:autocorrect`
49
+ a. or just the default `bundle exec rake`, as autocorrection is a pre-requisite of the default task.
50
+ 2. If there are new violations, either:
51
+ - Fix them in your code
52
+ - Run `bundle exec rake rubocop_gradual:force_update` to update the lock file (only for violations you can't fix immediately)
53
+ 3. Commit the updated `.rubocop_gradual.lock` file along with your changes
54
+
55
+ ## Never add inline RuboCop disables
56
+
57
+ Do not add inline `rubocop:disable` / `rubocop:enable` comments anywhere in the codebase (including specs, except when following the few existing `rubocop:disable` patterns for a rule already being disabled elsewhere in the code). We handle exceptions in two supported ways:
58
+
59
+ - Permanent/structural exceptions: prefer adjusting the RuboCop configuration (e.g., in `.rubocop.yml`) to exclude a rule for a path or file pattern when it makes sense project-wide.
60
+ - Temporary exceptions while improving code: record the current violations in `.rubocop_gradual.lock` via the gradual workflow:
61
+ - `bundle exec rake rubocop_gradual:autocorrect` (preferred; will autocorrect what it can and update the lock only if no new violations were introduced)
62
+ - If needed, `bundle exec rake rubocop_gradual:force_update` (as a last resort when you cannot fix the newly reported violations immediately)
63
+
64
+ In general, treat the rules as guidance to follow; fix violations rather than ignore them. For example, RSpec conventions in this project expect `described_class` to be used in specs that target a specific class under test.
65
+
66
+ ## Benefits of rubocop_gradual
67
+
68
+ - Allows incremental adoption of code style rules
69
+ - Prevents CI failures due to pre-existing violations
70
+ - Provides a clear record of code style debt
71
+ - Enables focused efforts on improving code quality over time
data/SECURITY.md ADDED
@@ -0,0 +1,21 @@
1
+ # Security Policy
2
+
3
+ ## Supported Versions
4
+
5
+ | Version | Supported |
6
+ |----------|-----------|
7
+ | 1.latest | ✅ |
8
+
9
+ ## Security contact information
10
+
11
+ To report a security vulnerability, please use the
12
+ [Tidelift security contact](https://tidelift.com/security).
13
+ Tidelift will coordinate the fix and disclosure.
14
+
15
+ ## Additional Support
16
+
17
+ If you are interested in support for versions older than the latest release,
18
+ please consider sponsoring the project / maintainer @ https://liberapay.com/pboling/donate,
19
+ or find other sponsorship links in the [README].
20
+
21
+ [README]: README.md
@@ -0,0 +1,463 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "set"
4
+
5
+ module Prism
6
+ module Merge
7
+ # Resolves conflicts in boundaries between anchors using structural
8
+ # signatures and comment preservation strategies.
9
+ #
10
+ # ConflictResolver is responsible for the core merge logic within boundaries
11
+ # (sections where template and destination differ). It:
12
+ # - Matches nodes by structural signature
13
+ # - Decides which version to keep based on preference
14
+ # - Preserves trailing blank lines for proper spacing
15
+ # - Handles template-only and destination-only nodes
16
+ #
17
+ # @example Basic usage (via SmartMerger)
18
+ # resolver = ConflictResolver.new(template_analysis, dest_analysis)
19
+ # resolver.resolve(boundary, result)
20
+ #
21
+ # @see SmartMerger
22
+ # @see FileAnalysis
23
+ # @see MergeResult
24
+ class ConflictResolver
25
+ # @return [FileAnalysis] Analysis of the template file
26
+ attr_reader :template_analysis
27
+
28
+ # @return [FileAnalysis] Analysis of the destination file
29
+ attr_reader :dest_analysis
30
+
31
+ # @return [Symbol] Preference for signature matches (:template or :destination)
32
+ attr_reader :signature_match_preference
33
+
34
+ # @return [Boolean] Whether to add template-only nodes
35
+ attr_reader :add_template_only_nodes
36
+
37
+ # Creates a new ConflictResolver for handling merge conflicts.
38
+ #
39
+ # @param template_analysis [FileAnalysis] Analyzed template file
40
+ # @param dest_analysis [FileAnalysis] Analyzed destination file
41
+ #
42
+ # @param signature_match_preference [Symbol] Which version to prefer when
43
+ # nodes have matching signatures but different content:
44
+ # - `:destination` (default) - Keep destination version (customizations)
45
+ # - `:template` - Use template version (updates)
46
+ #
47
+ # @param add_template_only_nodes [Boolean] Whether to add nodes that only
48
+ # exist in template:
49
+ # - `false` (default) - Skip template-only nodes
50
+ # - `true` - Add template-only nodes to result
51
+ #
52
+ # @example Create resolver for Appraisals (destination wins)
53
+ # resolver = ConflictResolver.new(
54
+ # template_analysis,
55
+ # dest_analysis,
56
+ # signature_match_preference: :destination,
57
+ # add_template_only_nodes: false
58
+ # )
59
+ #
60
+ # @example Create resolver for version files (template wins)
61
+ # resolver = ConflictResolver.new(
62
+ # template_analysis,
63
+ # dest_analysis,
64
+ # signature_match_preference: :template,
65
+ # add_template_only_nodes: true
66
+ # )
67
+ def initialize(template_analysis, dest_analysis, signature_match_preference: :destination, add_template_only_nodes: false)
68
+ @template_analysis = template_analysis
69
+ @dest_analysis = dest_analysis
70
+ @signature_match_preference = signature_match_preference
71
+ @add_template_only_nodes = add_template_only_nodes
72
+ end
73
+
74
+ # Resolve a boundary by deciding which content to keep
75
+ # @param boundary [FileAligner::Boundary] Boundary to resolve
76
+ # @param result [MergeResult] Result object to populate
77
+ def resolve(boundary, result)
78
+ # Extract content from both sides
79
+ template_content = extract_boundary_content(@template_analysis, boundary.template_range)
80
+ dest_content = extract_boundary_content(@dest_analysis, boundary.dest_range)
81
+
82
+ # If destination is in freeze block, always keep destination
83
+ if boundary.dest_range && dest_content[:has_freeze_block]
84
+ add_content_to_result(dest_content, result, :destination, MergeResult::DECISION_FREEZE_BLOCK)
85
+ return
86
+ end
87
+
88
+ # If both sides are empty, nothing to do
89
+ return if template_content[:lines].empty? && dest_content[:lines].empty?
90
+
91
+ # If one side is empty, use the other
92
+ if template_content[:lines].empty?
93
+ add_content_to_result(dest_content, result, :destination, MergeResult::DECISION_KEPT_DEST)
94
+ return
95
+ end
96
+
97
+ if dest_content[:lines].empty?
98
+ add_content_to_result(template_content, result, :template, MergeResult::DECISION_KEPT_TEMPLATE)
99
+ return
100
+ end
101
+
102
+ # Both sides have content - perform intelligent merge
103
+ merge_boundary_content(template_content, dest_content, boundary, result)
104
+ end
105
+
106
+ private
107
+
108
+ def extract_boundary_content(analysis, line_range)
109
+ return {lines: [], nodes: [], has_freeze_block: false, line_range: nil} unless line_range
110
+
111
+ lines = []
112
+ line_range.each do |line_num|
113
+ lines << analysis.line_at(line_num)
114
+ end
115
+
116
+ # Find nodes that intersect with this range
117
+ nodes = analysis.nodes_with_comments.select do |node_info|
118
+ node_range = node_info[:line_range]
119
+ ranges_overlap?(node_range, line_range)
120
+ end
121
+
122
+ # Check for freeze blocks
123
+ has_freeze_block = line_range.any? { |line_num| analysis.in_freeze_block?(line_num) }
124
+
125
+ {
126
+ lines: lines.map(&:chomp),
127
+ nodes: nodes,
128
+ has_freeze_block: has_freeze_block,
129
+ line_range: line_range,
130
+ }
131
+ end
132
+
133
+ def ranges_overlap?(range1, range2)
134
+ range1.begin <= range2.end && range2.begin <= range1.end
135
+ end
136
+
137
+ def add_content_to_result(content, result, source, decision)
138
+ return if content[:lines].empty?
139
+
140
+ start_line = content[:line_range].begin
141
+ result.add_lines_from(
142
+ content[:lines],
143
+ decision: decision,
144
+ source: source,
145
+ start_line: start_line,
146
+ )
147
+ end
148
+
149
+ def merge_boundary_content(template_content, dest_content, _boundary, result)
150
+ # Strategy: Process template content in order, replacing matched nodes with template version
151
+ # and appending dest-only nodes at the end
152
+
153
+ template_nodes = template_content[:nodes]
154
+ dest_nodes = dest_content[:nodes]
155
+
156
+ # Build signature map for destination nodes
157
+ dest_sig_map = build_signature_map(dest_nodes)
158
+
159
+ # Track which dest nodes have been matched
160
+ matched_dest_indices = Set.new
161
+
162
+ # Build a set of line numbers that are covered by leading comments of nodes
163
+ # so we don't duplicate them when processing non-node lines
164
+ leading_comment_lines = Set.new
165
+ template_nodes.each do |node_info|
166
+ node_info[:leading_comments].each do |comment|
167
+ leading_comment_lines << comment.location.start_line
168
+ end
169
+ end
170
+
171
+ # Process template line by line, adding nodes and non-node lines in order
172
+ template_line_range = template_content[:line_range]
173
+ return unless template_line_range
174
+
175
+ current_line = template_line_range.begin
176
+ # Track if we're in a sequence of template-only nodes
177
+ in_template_only_sequence = false
178
+ last_added_line = nil
179
+
180
+ sorted_nodes = template_nodes.sort_by { |n| n[:line_range].begin }
181
+
182
+ sorted_nodes.each_with_index do |t_node_info, idx|
183
+ node_start = t_node_info[:line_range].begin
184
+ node_end = t_node_info[:line_range].end
185
+
186
+ # Check if this node will be matched or is template-only
187
+ sig = t_node_info[:signature]
188
+ is_matched = dest_sig_map[sig]&.any?
189
+
190
+ # Calculate the range that includes trailing blank lines up to the next node
191
+ # This way, blank lines "belong" to the preceding node
192
+ next_node_start = (idx + 1 < sorted_nodes.length) ? sorted_nodes[idx + 1][:line_range].begin : template_line_range.end + 1
193
+
194
+ # Find trailing blank lines after this node
195
+ trailing_blank_end = node_end
196
+ (node_end + 1...next_node_start).each do |line_num|
197
+ break if !template_line_range.cover?(line_num)
198
+ line = @template_analysis.line_at(line_num)
199
+ break if !line.strip.empty? # Stop at first non-blank line
200
+ trailing_blank_end = line_num
201
+ end
202
+
203
+ node_start..trailing_blank_end
204
+
205
+ # Add any non-node, non-blank lines before this node (e.g., comments not attached to nodes)
206
+ if in_template_only_sequence && !is_matched
207
+ # Skip lines before template-only nodes in a sequence
208
+ current_line = node_start
209
+ else
210
+ while current_line < node_start
211
+ if template_line_range.cover?(current_line) && !leading_comment_lines.include?(current_line)
212
+ line = @template_analysis.line_at(current_line)
213
+ # Only add non-blank lines here (blank lines belong to preceding node)
214
+ unless line.strip.empty?
215
+ add_line_safe(result, line.chomp, decision: MergeResult::DECISION_KEPT_TEMPLATE, template_line: current_line)
216
+ end
217
+ end
218
+ current_line += 1
219
+ end
220
+ end
221
+
222
+ # Add the node (use configured preference when signatures match)
223
+ # Include trailing blank lines with the node
224
+ if is_matched
225
+ # Match found - use preference to decide which version
226
+ if @signature_match_preference == :template
227
+ # Use template version (it's the canonical/updated version)
228
+ result.add_node(
229
+ t_node_info,
230
+ decision: MergeResult::DECISION_REPLACED,
231
+ source: :template,
232
+ source_analysis: @template_analysis,
233
+ )
234
+ else
235
+ # Use destination version (it has the customizations)
236
+ dest_matches = dest_sig_map[sig]
237
+ result.add_node(
238
+ dest_matches.first,
239
+ decision: MergeResult::DECISION_REPLACED,
240
+ source: :destination,
241
+ source_analysis: @dest_analysis,
242
+ )
243
+ end
244
+
245
+ # Mark matching dest nodes as processed
246
+ dest_matches = dest_sig_map[sig]
247
+ dest_matches.each do |d_node_info|
248
+ matched_dest_indices << d_node_info[:index]
249
+ end
250
+
251
+ # Calculate trailing blank lines from destination to preserve original spacing
252
+ # Use the first matching dest node to determine blank line spacing
253
+ d_node_info = dest_matches.first
254
+ d_node = d_node_info[:node]
255
+ d_node_end = d_node.location.end_line
256
+ # Find how many blank lines follow this node in destination
257
+ d_trailing_blank_end = d_node_end
258
+ if d_node_info[:index] < dest_nodes.size - 1
259
+ next_dest_info = dest_nodes[d_node_info[:index] + 1]
260
+ # Find where next node's content actually starts (first leading comment or node itself)
261
+ next_content_start = if next_dest_info[:leading_comments].any?
262
+ next_dest_info[:leading_comments].first.location.start_line
263
+ else
264
+ next_dest_info[:node].location.start_line
265
+ end
266
+
267
+ # Find all blank lines between this node end and next node's content
268
+ (d_node_end + 1...next_content_start).each do |line_num|
269
+ line_content = @dest_analysis.line_at(line_num)
270
+ if line_content.strip.empty?
271
+ d_trailing_blank_end = line_num
272
+ else
273
+ # Stop at first non-blank line
274
+ break
275
+ end
276
+ end
277
+ end
278
+
279
+ # Add trailing blank lines from destination (preserving destination spacing)
280
+ (d_node_end + 1..d_trailing_blank_end).each do |line_num|
281
+ line = @dest_analysis.line_at(line_num)
282
+ add_line_safe(result, line.chomp, decision: MergeResult::DECISION_KEPT_DEST, dest_line: line_num)
283
+ end
284
+
285
+ in_template_only_sequence = false
286
+ last_added_line = trailing_blank_end
287
+ elsif @add_template_only_nodes
288
+ # No match - this is a template-only node
289
+ result.add_node(
290
+ t_node_info,
291
+ decision: MergeResult::DECISION_KEPT_TEMPLATE,
292
+ source: :template,
293
+ source_analysis: @template_analysis,
294
+ )
295
+
296
+ # Add trailing blank lines from template
297
+ (node_end + 1..trailing_blank_end).each do |line_num|
298
+ line = @template_analysis.line_at(line_num)
299
+ add_line_safe(result, line.chomp, decision: MergeResult::DECISION_KEPT_TEMPLATE, template_line: line_num)
300
+ end
301
+
302
+ in_template_only_sequence = false
303
+ last_added_line = trailing_blank_end
304
+ # Add the template-only node
305
+ else
306
+ # Skip template-only nodes (don't add template nodes that don't exist in destination)
307
+ in_template_only_sequence = true
308
+ end
309
+
310
+ current_line = trailing_blank_end + 1
311
+ end
312
+
313
+ # Add any remaining template lines after the last node
314
+ # But skip if we ended in a template-only sequence
315
+ unless in_template_only_sequence
316
+ while current_line <= template_line_range.end
317
+ if !leading_comment_lines.include?(current_line)
318
+ line = @template_analysis.line_at(current_line)
319
+ add_line_safe(result, line.chomp, decision: MergeResult::DECISION_KEPT_TEMPLATE, template_line: current_line)
320
+ end
321
+ current_line += 1
322
+ end
323
+ end
324
+
325
+ # Add dest-only nodes (nodes that weren't matched)
326
+ dest_only_nodes = dest_nodes.select { |d| !matched_dest_indices.include?(d[:index]) }
327
+
328
+ unless dest_only_nodes.empty?
329
+ # Add a blank line before appending dest-only nodes if the result doesn't already end with one
330
+ if result.lines.any? && !result.lines.last.strip.empty?
331
+ add_line_safe(result, "", decision: MergeResult::DECISION_KEPT_TEMPLATE)
332
+ end
333
+
334
+ dest_only_nodes.each_with_index do |d_node_info, idx|
335
+ result.add_node(
336
+ d_node_info,
337
+ decision: MergeResult::DECISION_APPENDED,
338
+ source: :destination,
339
+ source_analysis: @dest_analysis,
340
+ )
341
+
342
+ # Add trailing blank lines for each dest-only node
343
+ d_node = d_node_info[:node]
344
+ d_node_end = d_node.location.end_line
345
+ d_trailing_blank_end = d_node_end
346
+
347
+ # Find trailing blank lines up to the next node or end of boundary
348
+ if idx < dest_only_nodes.size - 1
349
+ # Not the last dest-only node - look for next dest-only node
350
+ next_dest_info = dest_only_nodes[idx + 1]
351
+ # Find where next node's content actually starts (first leading comment or node itself)
352
+ next_content_start = if next_dest_info[:leading_comments].any?
353
+ next_dest_info[:leading_comments].first.location.start_line
354
+ else
355
+ next_dest_info[:node].location.start_line
356
+ end
357
+
358
+ # Collect blank lines between this node end and next node's content
359
+ (d_node_end + 1...next_content_start).each do |line_num|
360
+ line_content = @dest_analysis.line_at(line_num)
361
+ if line_content.strip.empty?
362
+ d_trailing_blank_end = line_num
363
+ else
364
+ break
365
+ end
366
+ end
367
+ else
368
+ # This is the last dest-only node - look for trailing blanks up to boundary end
369
+ # Check lines after this node for blank lines
370
+ boundary_end = dest_content[:line_range].end
371
+ line_num = d_node_end + 1
372
+ while line_num <= boundary_end
373
+ line_content = @dest_analysis.line_at(line_num)
374
+ if line_content.strip.empty?
375
+ d_trailing_blank_end = line_num
376
+ line_num += 1
377
+ else
378
+ break
379
+ end
380
+ end
381
+ end
382
+
383
+ # Add trailing blank lines from destination
384
+ (d_node_end + 1..d_trailing_blank_end).each do |line_num|
385
+ line = @dest_analysis.line_at(line_num)
386
+ add_line_safe(result, line.chomp, decision: MergeResult::DECISION_KEPT_DEST, dest_line: line_num)
387
+ end
388
+ end
389
+ end
390
+ end
391
+
392
+ def build_signature_map(nodes)
393
+ map = Hash.new { |h, k| h[k] = [] }
394
+ nodes.each do |node_info|
395
+ sig = node_info[:signature]
396
+ map[sig] << node_info if sig
397
+ end
398
+ map
399
+ end
400
+
401
+ # Add a line to result but avoid adding multiple consecutive blank lines.
402
+ def add_line_safe(result, content, **kwargs)
403
+ if content.strip.empty?
404
+ # If last line is also blank, skip adding to collapse runs of blank lines
405
+ return if result.lines.any? && result.lines.last.strip.empty?
406
+ end
407
+
408
+ result.add_line(content, **kwargs)
409
+ end
410
+
411
+ def handle_orphan_lines(template_content, dest_content, result)
412
+ # Find lines that aren't part of any node (pure comments, blank lines)
413
+ template_orphans = find_orphan_lines(@template_analysis, template_content[:line_range], template_content[:nodes])
414
+ dest_orphans = find_orphan_lines(@dest_analysis, dest_content[:line_range], dest_content[:nodes])
415
+
416
+ # For simplicity, prefer template orphans but add unique dest orphans
417
+ # This could be enhanced with more sophisticated comment merging
418
+ template_orphan_content = Set.new(template_orphans.map { |ln| @template_analysis.normalized_line(ln) })
419
+
420
+ dest_orphans.each do |line_num|
421
+ content = @dest_analysis.normalized_line(line_num)
422
+ next if template_orphan_content.include?(content)
423
+
424
+ # Add unique destination orphan
425
+ line = @dest_analysis.line_at(line_num)
426
+ result.add_line(
427
+ line.chomp,
428
+ decision: MergeResult::DECISION_APPENDED,
429
+ dest_line: line_num,
430
+ )
431
+ end
432
+ end
433
+
434
+ def find_orphan_lines(analysis, line_range, nodes)
435
+ return [] unless line_range
436
+
437
+ # Get all lines covered by nodes
438
+ covered_lines = Set.new
439
+ nodes.each do |node_info|
440
+ node_range = node_info[:line_range]
441
+ node_range.each { |ln| covered_lines << ln }
442
+
443
+ # Also cover comment lines
444
+ node_info[:leading_comments].each do |comment|
445
+ covered_lines << comment.location.start_line
446
+ end
447
+ end
448
+
449
+ # Find uncovered lines
450
+ orphans = []
451
+ line_range.each do |line_num|
452
+ next if covered_lines.include?(line_num)
453
+
454
+ # Check if this line has content (not just blank)
455
+ line = analysis.line_at(line_num)
456
+ orphans << line_num if line && !line.strip.empty?
457
+ end
458
+
459
+ orphans
460
+ end
461
+ end
462
+ end
463
+ end