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.
- checksums.yaml +7 -0
- checksums.yaml.gz.sig +0 -0
- data/CHANGELOG.md +46 -0
- data/CITATION.cff +20 -0
- data/CODE_OF_CONDUCT.md +134 -0
- data/CONTRIBUTING.md +227 -0
- data/FUNDING.md +74 -0
- data/LICENSE.txt +21 -0
- data/README.md +987 -0
- data/REEK +0 -0
- data/RUBOCOP.md +71 -0
- data/SECURITY.md +21 -0
- data/lib/prism/merge/conflict_resolver.rb +463 -0
- data/lib/prism/merge/file_aligner.rb +381 -0
- data/lib/prism/merge/file_analysis.rb +298 -0
- data/lib/prism/merge/merge_result.rb +176 -0
- data/lib/prism/merge/smart_merger.rb +347 -0
- data/lib/prism/merge/version.rb +12 -0
- data/lib/prism/merge.rb +93 -0
- data/lib/prism-merge.rb +4 -0
- data/sig/prism/merge.rbs +265 -0
- data.tar.gz.sig +6 -0
- metadata +303 -0
- metadata.gz.sig +0 -0
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
|