ast-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.
Files changed (62) hide show
  1. checksums.yaml +7 -0
  2. checksums.yaml.gz.sig +0 -0
  3. data/CHANGELOG.md +46 -0
  4. data/CITATION.cff +20 -0
  5. data/CODE_OF_CONDUCT.md +134 -0
  6. data/CONTRIBUTING.md +227 -0
  7. data/FUNDING.md +74 -0
  8. data/LICENSE.txt +21 -0
  9. data/README.md +852 -0
  10. data/REEK +0 -0
  11. data/RUBOCOP.md +71 -0
  12. data/SECURITY.md +21 -0
  13. data/lib/ast/merge/ast_node.rb +87 -0
  14. data/lib/ast/merge/comment/block.rb +195 -0
  15. data/lib/ast/merge/comment/empty.rb +78 -0
  16. data/lib/ast/merge/comment/line.rb +138 -0
  17. data/lib/ast/merge/comment/parser.rb +278 -0
  18. data/lib/ast/merge/comment/style.rb +282 -0
  19. data/lib/ast/merge/comment.rb +36 -0
  20. data/lib/ast/merge/conflict_resolver_base.rb +399 -0
  21. data/lib/ast/merge/debug_logger.rb +271 -0
  22. data/lib/ast/merge/fenced_code_block_detector.rb +211 -0
  23. data/lib/ast/merge/file_analyzable.rb +307 -0
  24. data/lib/ast/merge/freezable.rb +82 -0
  25. data/lib/ast/merge/freeze_node_base.rb +434 -0
  26. data/lib/ast/merge/match_refiner_base.rb +312 -0
  27. data/lib/ast/merge/match_score_base.rb +135 -0
  28. data/lib/ast/merge/merge_result_base.rb +169 -0
  29. data/lib/ast/merge/merger_config.rb +258 -0
  30. data/lib/ast/merge/node_typing.rb +373 -0
  31. data/lib/ast/merge/region.rb +124 -0
  32. data/lib/ast/merge/region_detector_base.rb +114 -0
  33. data/lib/ast/merge/region_mergeable.rb +364 -0
  34. data/lib/ast/merge/rspec/shared_examples/conflict_resolver_base.rb +416 -0
  35. data/lib/ast/merge/rspec/shared_examples/debug_logger.rb +174 -0
  36. data/lib/ast/merge/rspec/shared_examples/file_analyzable.rb +193 -0
  37. data/lib/ast/merge/rspec/shared_examples/freeze_node_base.rb +219 -0
  38. data/lib/ast/merge/rspec/shared_examples/merge_result_base.rb +106 -0
  39. data/lib/ast/merge/rspec/shared_examples/merger_config.rb +202 -0
  40. data/lib/ast/merge/rspec/shared_examples/reproducible_merge.rb +115 -0
  41. data/lib/ast/merge/rspec/shared_examples.rb +26 -0
  42. data/lib/ast/merge/rspec.rb +4 -0
  43. data/lib/ast/merge/section_typing.rb +303 -0
  44. data/lib/ast/merge/smart_merger_base.rb +417 -0
  45. data/lib/ast/merge/text/conflict_resolver.rb +161 -0
  46. data/lib/ast/merge/text/file_analysis.rb +168 -0
  47. data/lib/ast/merge/text/line_node.rb +142 -0
  48. data/lib/ast/merge/text/merge_result.rb +42 -0
  49. data/lib/ast/merge/text/section.rb +93 -0
  50. data/lib/ast/merge/text/section_splitter.rb +397 -0
  51. data/lib/ast/merge/text/smart_merger.rb +141 -0
  52. data/lib/ast/merge/text/word_node.rb +86 -0
  53. data/lib/ast/merge/text.rb +35 -0
  54. data/lib/ast/merge/toml_frontmatter_detector.rb +88 -0
  55. data/lib/ast/merge/version.rb +12 -0
  56. data/lib/ast/merge/yaml_frontmatter_detector.rb +108 -0
  57. data/lib/ast/merge.rb +165 -0
  58. data/lib/ast-merge.rb +4 -0
  59. data/sig/ast/merge.rbs +195 -0
  60. data.tar.gz.sig +0 -0
  61. metadata +326 -0
  62. metadata.gz.sig +0 -0
@@ -0,0 +1,417 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Ast
4
+ module Merge
5
+ # Abstract base class for SmartMerger implementations across all *-merge gems.
6
+ #
7
+ # SmartMergerBase provides the standard interface and common functionality
8
+ # for intelligent file merging. Subclasses implement format-specific parsing,
9
+ # analysis, and merge logic while inheriting the common API.
10
+ #
11
+ # ## Standard Options
12
+ #
13
+ # All SmartMerger implementations support these common options:
14
+ #
15
+ # - `preference` - `:destination` (default) or `:template`
16
+ # - `add_template_only_nodes` - `false` (default) or `true`
17
+ # - `signature_generator` - Custom signature proc or `nil`
18
+ # - `freeze_token` - Token for freeze block markers
19
+ # - `match_refiner` - Fuzzy match refiner or `nil`
20
+ # - `regions` - Region configurations for nested merging
21
+ # - `region_placeholder` - Custom placeholder for regions
22
+ #
23
+ # ## Implementing a SmartMerger
24
+ #
25
+ # Subclasses must implement:
26
+ # - `analysis_class` - Returns the FileAnalysis class for this format
27
+ # - `perform_merge` - Performs the format-specific merge logic
28
+ #
29
+ # Subclasses may override:
30
+ # - `default_freeze_token` - Format-specific default freeze token
31
+ # - `resolver_class` - Returns the ConflictResolver class (if different)
32
+ # - `result_class` - Returns the MergeResult class (if different)
33
+ # - `aligner_class` - Returns the FileAligner class (if used)
34
+ # - `parse_content` - Custom parsing logic
35
+ # - `build_analysis_options` - Additional analysis options
36
+ # - `build_resolver_options` - Additional resolver options
37
+ #
38
+ # @example Implementing a custom SmartMerger
39
+ # class MyFormat::SmartMerger < Ast::Merge::SmartMergerBase
40
+ # def analysis_class
41
+ # MyFormat::FileAnalysis
42
+ # end
43
+ #
44
+ # def default_freeze_token
45
+ # "myformat-merge"
46
+ # end
47
+ #
48
+ # private
49
+ #
50
+ # def perform_merge
51
+ # alignment = @aligner.align
52
+ # process_alignment(alignment)
53
+ # @result
54
+ # end
55
+ # end
56
+ #
57
+ # @abstract Subclass and implement {#analysis_class} and {#perform_merge}
58
+ # @api public
59
+ class SmartMergerBase
60
+ include RegionMergeable
61
+
62
+ # @return [String] Template source content
63
+ attr_reader :template_content
64
+
65
+ # @return [String] Destination source content
66
+ attr_reader :dest_content
67
+
68
+ # @return [Object] Analysis of the template file
69
+ attr_reader :template_analysis
70
+
71
+ # @return [Object] Analysis of the destination file
72
+ attr_reader :dest_analysis
73
+
74
+ # @return [Object, nil] Aligner for finding matches (if applicable)
75
+ attr_reader :aligner
76
+
77
+ # @return [Object] Resolver for handling conflicts
78
+ attr_reader :resolver
79
+
80
+ # @return [Object] Result object tracking merged content
81
+ attr_reader :result
82
+
83
+ # @return [Symbol, Hash] Preference for signature matches
84
+ attr_reader :preference
85
+
86
+ # @return [Boolean] Whether to add template-only nodes
87
+ attr_reader :add_template_only_nodes
88
+
89
+ # @return [String] Token for freeze block markers
90
+ attr_reader :freeze_token
91
+
92
+ # @return [Proc, nil] Custom signature generator
93
+ attr_reader :signature_generator
94
+
95
+ # @return [Object, nil] Match refiner for fuzzy matching
96
+ attr_reader :match_refiner
97
+
98
+ # Creates a new SmartMerger for intelligent file merging.
99
+ #
100
+ # @param template_content [String] Template source content
101
+ # @param dest_content [String] Destination source content
102
+ #
103
+ # @param signature_generator [Proc, nil] Optional proc to generate custom signatures.
104
+ # The proc receives a node and should return one of:
105
+ # - An array representing the node's signature
106
+ # - `nil` to indicate the node should have no signature
107
+ # - The original node to fall through to default signature computation
108
+ #
109
+ # @param preference [Symbol, Hash] Controls which version to use
110
+ # when nodes have matching signatures but different content:
111
+ # - `:destination` (default) - Use destination version (preserves customizations)
112
+ # - `:template` - Use template version (applies updates)
113
+ # - Hash for per-type preferences: `{ default: :destination, special: :template }`
114
+ #
115
+ # @param add_template_only_nodes [Boolean] Controls whether to add nodes that only
116
+ # exist in template:
117
+ # - `false` (default) - Skip template-only nodes
118
+ # - `true` - Add template-only nodes to result
119
+ #
120
+ # @param freeze_token [String, nil] Token to use for freeze block markers.
121
+ # Default varies by format (e.g., "prism-merge", "markly-merge")
122
+ #
123
+ # @param match_refiner [#call, nil] Optional match refiner for fuzzy matching.
124
+ # Default: nil (fuzzy matching disabled)
125
+ #
126
+ # @param regions [Array<Hash>, nil] Region configurations for nested merging.
127
+ # Each hash should contain:
128
+ # - `:detector` - RegionDetectorBase instance
129
+ # - `:merger_class` - SmartMerger class for the region (optional)
130
+ # - `:merger_options` - Options for the region merger (optional)
131
+ # - `:regions` - Nested region configs (optional, for recursive regions)
132
+ #
133
+ # @param region_placeholder [String, nil] Custom placeholder prefix for regions.
134
+ # Default: "<<<AST_MERGE_REGION_"
135
+ #
136
+ # @param format_options [Hash] Format-specific parser options passed to FileAnalysis.
137
+ # These are merged with freeze_token and signature_generator in build_full_analysis_options.
138
+ # Examples:
139
+ # - Markly: `flags: Markly::FOOTNOTES, extensions: [:table, :strikethrough]`
140
+ # - Commonmarker: `options: { parse: { smart: true } }`
141
+ # - Prism: (no additional parser options needed)
142
+ #
143
+ # @raise [Ast::Merge::TemplateParseError] If template has syntax errors
144
+ # @raise [Ast::Merge::DestinationParseError] If destination has syntax errors
145
+ def initialize(
146
+ template_content,
147
+ dest_content,
148
+ signature_generator: nil,
149
+ preference: :destination,
150
+ add_template_only_nodes: false,
151
+ freeze_token: nil,
152
+ match_refiner: nil,
153
+ regions: nil,
154
+ region_placeholder: nil,
155
+ **format_options
156
+ )
157
+ @template_content = template_content
158
+ @dest_content = dest_content
159
+ @signature_generator = signature_generator
160
+ @preference = preference
161
+ @add_template_only_nodes = add_template_only_nodes
162
+ @freeze_token = freeze_token || default_freeze_token
163
+ @match_refiner = match_refiner
164
+ @format_options = format_options
165
+
166
+ # Set up region support
167
+ setup_regions(regions: regions || [], region_placeholder: region_placeholder)
168
+
169
+ # Extract regions before parsing (if configured)
170
+ template_for_parsing = extract_template_regions(@template_content)
171
+ dest_for_parsing = extract_dest_regions(@dest_content)
172
+
173
+ # Parse and analyze both files
174
+ @template_analysis = parse_and_analyze(template_for_parsing, :template)
175
+ @dest_analysis = parse_and_analyze(dest_for_parsing, :destination)
176
+
177
+ # Set up aligner (if applicable)
178
+ @aligner = build_aligner if respond_to?(:aligner_class, true) && aligner_class
179
+
180
+ # Set up resolver
181
+ @resolver = build_resolver
182
+
183
+ # Set up result
184
+ @result = build_result
185
+ end
186
+
187
+ # Perform the merge operation and return the merged content as a string.
188
+ #
189
+ # @return [String] The merged content
190
+ def merge
191
+ merge_result.to_s
192
+ end
193
+
194
+ # Perform the merge operation and return the full result object.
195
+ #
196
+ # This method is memoized - subsequent calls return the cached result.
197
+ #
198
+ # @return [Object] The merge result (format-specific MergeResult subclass)
199
+ def merge_result
200
+ return @merge_result if @merge_result
201
+
202
+ @merge_result = DebugLogger.time("#{self.class.name}#merge") do
203
+ result = perform_merge
204
+
205
+ # Substitute merged regions back into the result if configured
206
+ if regions_configured? && (merged_content = result.to_s)
207
+ final_content = substitute_merged_regions(merged_content)
208
+ update_result_content(result, final_content)
209
+ end
210
+
211
+ result
212
+ end
213
+ end
214
+
215
+ # Perform the merge and return detailed debug information.
216
+ #
217
+ # @return [Hash] Hash containing:
218
+ # - `:content` [String] - Final merged content
219
+ # - `:statistics` [Hash] - Merge decision counts
220
+ def merge_with_debug
221
+ content = merge
222
+
223
+ {
224
+ content: content,
225
+ statistics: @result.decision_summary,
226
+ }
227
+ end
228
+
229
+ # Get merge statistics.
230
+ #
231
+ # @return [Hash] Statistics about the merge
232
+ def stats
233
+ merge_result # Ensure merge has run
234
+ @result.decision_summary
235
+ end
236
+
237
+ protected
238
+
239
+ # Returns the FileAnalysis class for this format.
240
+ #
241
+ # @return [Class] The analysis class
242
+ # @abstract Subclasses must implement this method
243
+ def analysis_class
244
+ raise NotImplementedError, "#{self.class}#analysis_class must be implemented"
245
+ end
246
+
247
+ # Returns the default freeze token for this format.
248
+ #
249
+ # @return [String] The default freeze token (e.g., "prism-merge")
250
+ def default_freeze_token
251
+ "ast-merge"
252
+ end
253
+
254
+ # Returns the ConflictResolver class for this format.
255
+ #
256
+ # Override if your format uses a custom resolver.
257
+ #
258
+ # @return [Class, nil] The resolver class, or nil to skip resolver creation
259
+ def resolver_class
260
+ nil
261
+ end
262
+
263
+ # Returns the MergeResult class for this format.
264
+ #
265
+ # Override if your format uses a custom result class.
266
+ #
267
+ # @return [Class, nil] The result class, or nil to skip result creation
268
+ def result_class
269
+ nil
270
+ end
271
+
272
+ # Returns the FileAligner class for this format.
273
+ #
274
+ # Override if your format uses an aligner.
275
+ #
276
+ # @return [Class, nil] The aligner class, or nil if not used
277
+ def aligner_class
278
+ nil
279
+ end
280
+
281
+ # Performs the format-specific merge logic.
282
+ #
283
+ # This method should use @template_analysis, @dest_analysis, @resolver, etc.
284
+ # to perform the merge and populate @result.
285
+ #
286
+ # @return [Object] The merge result (typically @result)
287
+ # @abstract Subclasses must implement this method
288
+ def perform_merge
289
+ raise NotImplementedError, "#{self.class}#perform_merge must be implemented"
290
+ end
291
+
292
+ # Build additional options for FileAnalysis.
293
+ #
294
+ # Override to add format-specific options.
295
+ #
296
+ # @return [Hash] Additional options for the analysis class
297
+ def build_analysis_options
298
+ {}
299
+ end
300
+
301
+ # Build additional options for ConflictResolver.
302
+ #
303
+ # Override to add format-specific options.
304
+ #
305
+ # @return [Hash] Additional options for the resolver class
306
+ def build_resolver_options
307
+ {}
308
+ end
309
+
310
+ # Update the result content after region substitution.
311
+ #
312
+ # Override if your result class needs special handling.
313
+ #
314
+ # @param result [Object] The merge result
315
+ # @param content [String] The final content with regions substituted
316
+ def update_result_content(result, content)
317
+ result.content = content
318
+ end
319
+
320
+ private
321
+
322
+ # Parse and analyze content, raising appropriate errors.
323
+ #
324
+ # @param content [String] Content to parse
325
+ # @param source [Symbol] :template or :destination
326
+ # @return [Object] The analysis result
327
+ def parse_and_analyze(content, source)
328
+ options = build_full_analysis_options
329
+
330
+ DebugLogger.time("#{self.class.name}#analyze_#{source}") do
331
+ analysis_class.new(content, **options)
332
+ end
333
+ rescue StandardError => e
334
+ # Don't re-wrap our own parse errors
335
+ raise if e.is_a?(template_parse_error_class) || e.is_a?(destination_parse_error_class)
336
+
337
+ error_class = (source == :template) ? template_parse_error_class : destination_parse_error_class
338
+ raise error_class.new(errors: [e], content: content)
339
+ end
340
+
341
+ # Returns the TemplateParseError class for this merger.
342
+ # Override in subclasses to use format-specific error classes.
343
+ #
344
+ # @return [Class] The template parse error class
345
+ def template_parse_error_class
346
+ TemplateParseError
347
+ end
348
+
349
+ # Returns the DestinationParseError class for this merger.
350
+ # Override in subclasses to use format-specific error classes.
351
+ #
352
+ # @return [Class] The destination parse error class
353
+ def destination_parse_error_class
354
+ DestinationParseError
355
+ end
356
+
357
+ # Build the complete options hash for FileAnalysis.
358
+ #
359
+ # Override this method to completely control what options are passed.
360
+ # By default, includes freeze_token, signature_generator, and format_options.
361
+ #
362
+ # @return [Hash] Options for the analysis class
363
+ def build_full_analysis_options
364
+ {
365
+ freeze_token: @freeze_token,
366
+ signature_generator: @signature_generator,
367
+ }.merge(build_analysis_options).merge(@format_options)
368
+ end
369
+
370
+ # Build the aligner instance.
371
+ #
372
+ # Override if your aligner has a different constructor signature.
373
+ #
374
+ # @return [Object] The aligner instance
375
+ def build_aligner
376
+ aligner_class.new(@template_analysis, @dest_analysis, match_refiner: @match_refiner)
377
+ end
378
+
379
+ # Build the resolver instance.
380
+ #
381
+ # Override if your resolver has a different constructor signature.
382
+ #
383
+ # @return [Object, nil] The resolver instance
384
+ def build_resolver
385
+ return unless resolver_class
386
+
387
+ options = {
388
+ preference: @preference,
389
+ template_analysis: @template_analysis,
390
+ dest_analysis: @dest_analysis,
391
+ add_template_only_nodes: @add_template_only_nodes,
392
+ match_refiner: @match_refiner,
393
+ }.merge(build_resolver_options)
394
+
395
+ resolver_class.new(**options)
396
+ end
397
+
398
+ # Build the result instance.
399
+ #
400
+ # Override if your result class has a different constructor signature.
401
+ #
402
+ # @return [Object, nil] The result instance
403
+ def build_result
404
+ return unless result_class
405
+
406
+ if result_class.instance_method(:initialize).arity == 0
407
+ result_class.new
408
+ else
409
+ result_class.new(
410
+ template_analysis: @template_analysis,
411
+ dest_analysis: @dest_analysis,
412
+ )
413
+ end
414
+ end
415
+ end
416
+ end
417
+ end
@@ -0,0 +1,161 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Ast
4
+ module Merge
5
+ module Text
6
+ # Conflict resolver for text-based AST merging.
7
+ #
8
+ # Uses content-based matching with destination-order preservation:
9
+ # 1. Lines are matched by normalized content (whitespace-trimmed)
10
+ # 2. Destination order is preserved (destination is source of truth for structure)
11
+ # 3. Template-only lines are optionally added at the end
12
+ # 4. Freeze blocks are always preserved from destination
13
+ #
14
+ # @example
15
+ # resolver = ConflictResolver.new(template_analysis, dest_analysis)
16
+ # result = MergeResult.new
17
+ # resolver.resolve(result)
18
+ class ConflictResolver < ConflictResolverBase
19
+ # Initialize the conflict resolver
20
+ #
21
+ # @param template_analysis [FileAnalysis] Analysis of template
22
+ # @param dest_analysis [FileAnalysis] Analysis of destination
23
+ # @param preference [Symbol] :destination or :template
24
+ # @param add_template_only_nodes [Boolean] Whether to add template-only lines
25
+ def initialize(
26
+ template_analysis,
27
+ dest_analysis,
28
+ preference: :destination,
29
+ add_template_only_nodes: false
30
+ )
31
+ super(
32
+ strategy: :batch,
33
+ preference: preference,
34
+ template_analysis: template_analysis,
35
+ dest_analysis: dest_analysis,
36
+ add_template_only_nodes: add_template_only_nodes
37
+ )
38
+ end
39
+
40
+ protected
41
+
42
+ # Resolve using content-based matching with destination order preservation
43
+ #
44
+ # @param result [MergeResult] Result object to populate
45
+ # @return [void]
46
+ def resolve_batch(result)
47
+ template_statements = @template_analysis.statements
48
+ dest_statements = @dest_analysis.statements
49
+
50
+ # Build content index for matching
51
+ template_by_content = build_content_index(template_statements)
52
+
53
+ # Track matched template indices
54
+ matched_template_indices = Set.new
55
+
56
+ # Process destination in order - destination structure is preserved
57
+ dest_statements.each do |dest_node|
58
+ if freeze_node?(dest_node)
59
+ # Freeze blocks are always preserved from destination
60
+ add_freeze_block(result, dest_node)
61
+ next
62
+ end
63
+
64
+ # Find matching template line by normalized content
65
+ normalized = dest_node.normalized_content
66
+ template_match = find_unmatched(template_by_content[normalized], matched_template_indices)
67
+
68
+ if template_match
69
+ matched_template_indices << template_match[:index]
70
+ resolve_matched_pair(result, template_match[:node], dest_node)
71
+ else
72
+ # Destination-only content - always preserve
73
+ result.add_line(dest_node.content)
74
+ result.record_decision(DECISION_APPENDED, nil, dest_node)
75
+ end
76
+ end
77
+
78
+ # Add template-only lines if configured
79
+ if @add_template_only_nodes
80
+ add_unmatched_template_lines(result, template_statements, matched_template_indices)
81
+ end
82
+ end
83
+
84
+ private
85
+
86
+ # Build an index of statements by normalized content
87
+ #
88
+ # @param statements [Array] Statements to index
89
+ # @return [Hash] Map of normalized content => [{node:, index:}, ...]
90
+ def build_content_index(statements)
91
+ index = Hash.new { |h, k| h[k] = [] }
92
+ statements.each_with_index do |node, idx|
93
+ next if freeze_node?(node)
94
+
95
+ normalized = node.normalized_content
96
+ index[normalized] << {node: node, index: idx}
97
+ end
98
+ index
99
+ end
100
+
101
+ # Find first unmatched entry from a list
102
+ #
103
+ # @param entries [Array, nil] List of {node:, index:} hashes
104
+ # @param matched_indices [Set] Already matched indices
105
+ # @return [Hash, nil] First unmatched entry or nil
106
+ def find_unmatched(entries, matched_indices)
107
+ return unless entries
108
+
109
+ entries.find { |e| !matched_indices.include?(e[:index]) }
110
+ end
111
+
112
+ # Add a freeze block to the result
113
+ #
114
+ # @param result [MergeResult] Result to populate
115
+ # @param freeze_node [FreezeNodeBase] Freeze block node
116
+ def add_freeze_block(result, freeze_node)
117
+ freeze_node.content.split("\n").each do |line|
118
+ result.add_line(line)
119
+ end
120
+ result.record_decision(DECISION_FROZEN, nil, freeze_node)
121
+ end
122
+
123
+ # Add unmatched template lines in their original order
124
+ #
125
+ # @param result [MergeResult] Result to populate
126
+ # @param template_statements [Array] All template statements
127
+ # @param matched_indices [Set] Indices of matched template nodes
128
+ def add_unmatched_template_lines(result, template_statements, matched_indices)
129
+ template_statements.each_with_index do |template_node, idx|
130
+ next if matched_indices.include?(idx)
131
+ next if freeze_node?(template_node)
132
+
133
+ result.add_line(template_node.content)
134
+ result.record_decision(DECISION_ADDED, template_node, nil)
135
+ end
136
+ end
137
+
138
+ # Resolve a matched pair of nodes
139
+ #
140
+ # @param result [MergeResult] Result to populate
141
+ # @param template_node [LineNode] Template node
142
+ # @param dest_node [LineNode] Destination node
143
+ def resolve_matched_pair(result, template_node, dest_node)
144
+ if template_node.content == dest_node.content
145
+ # Identical content
146
+ result.add_line(dest_node.content)
147
+ result.record_decision(DECISION_IDENTICAL, template_node, dest_node)
148
+ elsif @preference == :template
149
+ # Template wins - use template content
150
+ result.add_line(template_node.content)
151
+ result.record_decision(DECISION_KEPT_TEMPLATE, template_node, dest_node)
152
+ else
153
+ # Destination wins (default) - use destination content
154
+ result.add_line(dest_node.content)
155
+ result.record_decision(DECISION_KEPT_DEST, template_node, dest_node)
156
+ end
157
+ end
158
+ end
159
+ end
160
+ end
161
+ end