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,312 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Ast
4
+ module Merge
5
+ # Base class for match refiners that pair unmatched nodes after signature matching.
6
+ #
7
+ # Match refiners run after initial signature-based matching to find additional
8
+ # pairings between nodes that didn't match by signature. This is useful when
9
+ # you want more nuanced matching than exact signatures provide - for example,
10
+ # matching tables with similar (but not identical) headers, or finding the
11
+ # closest match among several candidates using multi-factor scoring.
12
+ #
13
+ # By default, most node types use content-based signatures (including tables,
14
+ # which match on row count + header content). Refiners let you override this
15
+ # to implement fuzzy matching, positional matching, or any custom logic.
16
+ #
17
+ # Refiners use a callable interface (`#call`) so simple lambdas/procs can
18
+ # also be used where a full class isn't needed.
19
+ #
20
+ # @example Markdown: Table matching with multi-factor scoring
21
+ # # Tables may have similar but not identical headers
22
+ # # See Commonmarker::Merge::TableMatchRefiner
23
+ # class TableMatchRefiner < Ast::Merge::MatchRefinerBase
24
+ # def initialize(algorithm: nil, **options)
25
+ # super(**options)
26
+ # @algorithm = algorithm || TableMatchAlgorithm.new
27
+ # end
28
+ #
29
+ # def call(template_nodes, dest_nodes, context = {})
30
+ # template_tables = filter_by_type(template_nodes, :table)
31
+ # dest_tables = filter_by_type(dest_nodes, :table)
32
+ #
33
+ # greedy_match(template_tables, dest_tables) do |t_node, d_node|
34
+ # @algorithm.call(t_node, d_node)
35
+ # end
36
+ # end
37
+ # end
38
+ #
39
+ # @example Ruby: Method matching with fuzzy name/signature scoring
40
+ # # Methods may have similar names (process_user vs process_users)
41
+ # # or same name with different parameters
42
+ # # See Prism::Merge::MethodMatchRefiner
43
+ # class MethodMatchRefiner < Ast::Merge::MatchRefinerBase
44
+ # def call(template_nodes, dest_nodes, context = {})
45
+ # template_methods = template_nodes.select { |n| n.is_a?(Prism::DefNode) }
46
+ # dest_methods = dest_nodes.select { |n| n.is_a?(Prism::DefNode) }
47
+ #
48
+ # greedy_match(template_methods, dest_methods) do |t_node, d_node|
49
+ # compute_method_similarity(t_node, d_node)
50
+ # end
51
+ # end
52
+ #
53
+ # private
54
+ #
55
+ # def compute_method_similarity(t_method, d_method)
56
+ # name_score = string_similarity(t_method.name.to_s, d_method.name.to_s)
57
+ # param_score = param_similarity(t_method, d_method)
58
+ # name_score * 0.7 + param_score * 0.3
59
+ # end
60
+ # end
61
+ #
62
+ # @example YAML: Mapping key matching with fuzzy scoring
63
+ # # YAML keys may be renamed or have typos
64
+ # # See Psych::Merge::MappingMatchRefiner
65
+ # class MappingMatchRefiner < Ast::Merge::MatchRefinerBase
66
+ # def call(template_nodes, dest_nodes, context = {})
67
+ # template_mappings = template_nodes.select { |n| n.respond_to?(:key) }
68
+ # dest_mappings = dest_nodes.select { |n| n.respond_to?(:key) }
69
+ #
70
+ # greedy_match(template_mappings, dest_mappings) do |t_node, d_node|
71
+ # key_similarity(t_node.key, d_node.key)
72
+ # end
73
+ # end
74
+ # end
75
+ #
76
+ # @example JSON: Object property matching for arrays of objects
77
+ # # JSON arrays may contain objects that should match by content
78
+ # # See Json::Merge::ObjectMatchRefiner
79
+ # class ObjectMatchRefiner < Ast::Merge::MatchRefinerBase
80
+ # def call(template_nodes, dest_nodes, context = {})
81
+ # template_objects = template_nodes.select { |n| n.type == :object }
82
+ # dest_objects = dest_nodes.select { |n| n.type == :object }
83
+ #
84
+ # greedy_match(template_objects, dest_objects) do |t_node, d_node|
85
+ # compute_object_similarity(t_node, d_node)
86
+ # end
87
+ # end
88
+ # end
89
+ #
90
+ # @example Using find_best_match with manual tracking (alternative approach)
91
+ # class TableMatchRefiner < Ast::Merge::MatchRefinerBase
92
+ # def call(template_nodes, dest_nodes, context = {})
93
+ # matches = []
94
+ # used_dest_nodes = Set.new
95
+ # template_tables = filter_by_type(template_nodes, :table)
96
+ # dest_tables = filter_by_type(dest_nodes, :table)
97
+ #
98
+ # template_tables.each do |t_node|
99
+ # best = find_best_match(t_node, dest_tables, used_dest_nodes: used_dest_nodes) do |t, d|
100
+ # compute_table_score(t, d)
101
+ # end
102
+ # if best
103
+ # matches << best
104
+ # used_dest_nodes << best.dest_node
105
+ # end
106
+ # end
107
+ #
108
+ # matches
109
+ # end
110
+ # end
111
+ #
112
+ # @example Using a simple lambda refiner
113
+ # simple_refiner = ->(template, dest, ctx) do
114
+ # # Return array of MatchResult objects
115
+ # []
116
+ # end
117
+ #
118
+ # @example Using refiners with a merger
119
+ # merger = SmartMerger.new(
120
+ # template,
121
+ # destination,
122
+ # match_refiners: [
123
+ # TableMatchRefiner.new(threshold: 0.6),
124
+ # CustomRefiner.new
125
+ # ]
126
+ # )
127
+ #
128
+ # @api public
129
+ class MatchRefinerBase
130
+ # Result of a match refinement operation.
131
+ #
132
+ # @!attribute [r] template_node
133
+ # @return [Object] The node from the template
134
+ # @!attribute [r] dest_node
135
+ # @return [Object] The node from the destination
136
+ # @!attribute [r] score
137
+ # @return [Float] Match score between 0.0 and 1.0
138
+ # @!attribute [r] metadata
139
+ # @return [Hash] Optional metadata about the match
140
+ MatchResult = Struct.new(:template_node, :dest_node, :score, :metadata, keyword_init: true) do
141
+ # Check if this is a high-confidence match.
142
+ #
143
+ # @param threshold [Float] Minimum score for high confidence (default: 0.8)
144
+ # @return [Boolean]
145
+ def high_confidence?(threshold: 0.8)
146
+ score >= threshold
147
+ end
148
+
149
+ # Compare match results by score for sorting.
150
+ #
151
+ # @param other [MatchResult]
152
+ # @return [Integer] -1, 0, or 1
153
+ def <=>(other)
154
+ score <=> other.score
155
+ end
156
+ end
157
+
158
+ # Default minimum score threshold for accepting a match
159
+ DEFAULT_THRESHOLD = 0.5
160
+
161
+ # @return [Float] Minimum score to accept a match
162
+ attr_reader :threshold
163
+
164
+ # @return [Array<Symbol>] Node types this refiner handles (empty = all types)
165
+ attr_reader :node_types
166
+
167
+ # Initialize a new match refiner.
168
+ #
169
+ # @param threshold [Float] Minimum score to accept a match (0.0-1.0)
170
+ # @param node_types [Array<Symbol>] Node types to process (empty = all)
171
+ def initialize(threshold: DEFAULT_THRESHOLD, node_types: [])
172
+ @threshold = [[threshold.to_f, 0.0].max, 1.0].min
173
+ @node_types = Array(node_types)
174
+ end
175
+
176
+ # Refine matches between unmatched template and destination nodes.
177
+ #
178
+ # This is the main entry point. Override in subclasses to implement
179
+ # custom matching logic.
180
+ #
181
+ # @param template_nodes [Array] Unmatched nodes from template
182
+ # @param dest_nodes [Array] Unmatched nodes from destination
183
+ # @param context [Hash] Additional context (e.g., file analyses)
184
+ # @return [Array<MatchResult>] Array of match results
185
+ # @raise [NotImplementedError] If not overridden in subclass
186
+ def call(template_nodes, dest_nodes, context = {})
187
+ raise NotImplementedError, "#{self.class}#call must be implemented"
188
+ end
189
+
190
+ # Check if this refiner handles a given node type.
191
+ #
192
+ # @param node_type [Symbol] The node type to check
193
+ # @return [Boolean] True if this refiner handles the type
194
+ def handles_type?(node_type)
195
+ node_types.empty? || node_types.include?(node_type)
196
+ end
197
+
198
+ protected
199
+
200
+ # Filter nodes by type.
201
+ #
202
+ # @param nodes [Array] Nodes to filter
203
+ # @param type [Symbol] Node type to select
204
+ # @return [Array] Filtered nodes
205
+ def filter_by_type(nodes, type)
206
+ nodes.select { |n| node_type(n) == type }
207
+ end
208
+
209
+ # Get the type of a node.
210
+ #
211
+ # Override in subclasses for parser-specific type extraction.
212
+ #
213
+ # @param node [Object] The node
214
+ # @return [Symbol, nil] The node type
215
+ def node_type(node)
216
+ if node.respond_to?(:type)
217
+ node.type
218
+ elsif node.respond_to?(:class)
219
+ node.class.name.split("::").last.to_sym
220
+ end
221
+ end
222
+
223
+ # Create a match result.
224
+ #
225
+ # @param template_node [Object] Template node
226
+ # @param dest_node [Object] Destination node
227
+ # @param score [Float] Match score
228
+ # @param metadata [Hash] Optional metadata
229
+ # @return [MatchResult]
230
+ def match_result(template_node, dest_node, score, metadata = {})
231
+ MatchResult.new(
232
+ template_node: template_node,
233
+ dest_node: dest_node,
234
+ score: score,
235
+ metadata: metadata,
236
+ )
237
+ end
238
+
239
+ # Find the best matching destination node for a template node.
240
+ #
241
+ # Uses a scoring algorithm to find the best match above the threshold.
242
+ #
243
+ # @param template_node [Object] The template node to match
244
+ # @param dest_nodes [Array] Candidate destination nodes
245
+ # @param used_dest_nodes [Set] Already-matched destination nodes to skip
246
+ # @yield [template_node, dest_node] Block that returns a score (0.0-1.0)
247
+ # @return [MatchResult, nil] Best match or nil if none above threshold
248
+ def find_best_match(template_node, dest_nodes, used_dest_nodes: Set.new)
249
+ best_match = nil
250
+ best_score = threshold
251
+
252
+ dest_nodes.each do |dest_node|
253
+ next if used_dest_nodes.include?(dest_node)
254
+
255
+ score = yield(template_node, dest_node)
256
+ next unless score && score > best_score
257
+
258
+ best_score = score
259
+ best_match = dest_node
260
+ end
261
+
262
+ return unless best_match
263
+
264
+ match_result(template_node, best_match, best_score)
265
+ end
266
+
267
+ # Perform greedy matching between template and destination nodes.
268
+ #
269
+ # Matches are made greedily by score, with each node matched at most once.
270
+ #
271
+ # @param template_nodes [Array] Template nodes to match
272
+ # @param dest_nodes [Array] Destination nodes to match against
273
+ # @yield [template_node, dest_node] Block that returns a score (0.0-1.0)
274
+ # @return [Array<MatchResult>] Array of matches
275
+ def greedy_match(template_nodes, dest_nodes)
276
+ matches = []
277
+ used_dest_nodes = Set.new
278
+
279
+ # Collect all potential matches with scores
280
+ candidates = []
281
+ template_nodes.each do |t_node|
282
+ dest_nodes.each do |d_node|
283
+ score = yield(t_node, d_node)
284
+ next unless score && score >= threshold
285
+
286
+ candidates << {template: t_node, dest: d_node, score: score}
287
+ end
288
+ end
289
+
290
+ # Sort by score descending
291
+ candidates.sort_by! { |c| -c[:score] }
292
+
293
+ # Greedily assign matches
294
+ used_template_nodes = Set.new
295
+ candidates.each do |candidate|
296
+ next if used_template_nodes.include?(candidate[:template])
297
+ next if used_dest_nodes.include?(candidate[:dest])
298
+
299
+ matches << match_result(
300
+ candidate[:template],
301
+ candidate[:dest],
302
+ candidate[:score],
303
+ )
304
+ used_template_nodes << candidate[:template]
305
+ used_dest_nodes << candidate[:dest]
306
+ end
307
+
308
+ matches
309
+ end
310
+ end
311
+ end
312
+ end
@@ -0,0 +1,135 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Ast
4
+ module Merge
5
+ # Base class for computing match scores between two nodes.
6
+ #
7
+ # Match scores help determine which nodes from a template should be linked
8
+ # to which nodes in a destination document. This is particularly useful for
9
+ # complex nodes like tables where simple signature matching is insufficient.
10
+ #
11
+ # The scoring algorithm is provided as a callable object (lambda, Proc, or
12
+ # any object responding to :call) which receives the two nodes and returns
13
+ # a score between 0.0 (no match) and 1.0 (perfect match).
14
+ #
15
+ # Includes Comparable for sorting and comparison operations.
16
+ #
17
+ # @example Basic usage with a lambda
18
+ # algorithm = ->(node_a, node_b) { node_a.type == node_b.type ? 1.0 : 0.0 }
19
+ # scorer = MatchScoreBase.new(template_node, dest_node, algorithm: algorithm)
20
+ # puts scorer.score # => 1.0 if types match
21
+ #
22
+ # @example With a custom algorithm class
23
+ # class TableMatcher
24
+ # def call(table_a, table_b)
25
+ # # Complex matching logic
26
+ # compute_similarity(table_a, table_b)
27
+ # end
28
+ # end
29
+ #
30
+ # scorer = MatchScoreBase.new(table1, table2, algorithm: TableMatcher.new)
31
+ #
32
+ # @example Comparing and sorting scorers
33
+ # scorers = [scorer1, scorer2, scorer3]
34
+ # best = scorers.max
35
+ # sorted = scorers.sort
36
+ #
37
+ # @api public
38
+ class MatchScoreBase
39
+ include Comparable
40
+
41
+ # Minimum score threshold for considering two nodes as a potential match
42
+ # @return [Float]
43
+ DEFAULT_THRESHOLD = 0.5
44
+
45
+ # @return [Object] The first node to compare (typically from template)
46
+ attr_reader :node_a
47
+
48
+ # @return [Object] The second node to compare (typically from destination)
49
+ attr_reader :node_b
50
+
51
+ # @return [#call] The algorithm used to compute the match score
52
+ attr_reader :algorithm
53
+
54
+ # @return [Float] The minimum score to consider a match
55
+ attr_reader :threshold
56
+
57
+ # Initialize a match scorer.
58
+ #
59
+ # @param node_a [Object] First node to compare
60
+ # @param node_b [Object] Second node to compare
61
+ # @param algorithm [#call] Callable that computes the score (receives node_a, node_b)
62
+ # @param threshold [Float] Minimum score to consider a match (default: 0.5)
63
+ # @raise [ArgumentError] If algorithm doesn't respond to :call
64
+ def initialize(node_a, node_b, algorithm:, threshold: DEFAULT_THRESHOLD)
65
+ raise ArgumentError, "algorithm must respond to :call" unless algorithm.respond_to?(:call)
66
+
67
+ @node_a = node_a
68
+ @node_b = node_b
69
+ @algorithm = algorithm
70
+ @threshold = threshold
71
+ @score = nil
72
+ end
73
+
74
+ # Compute and return the match score.
75
+ #
76
+ # The score is cached after first computation.
77
+ #
78
+ # @return [Float] Score between 0.0 and 1.0
79
+ def score
80
+ @score ||= compute_score
81
+ end
82
+
83
+ # Check if the score meets the threshold for a match.
84
+ #
85
+ # @return [Boolean] True if score >= threshold
86
+ def match?
87
+ score >= threshold
88
+ end
89
+
90
+ # Compare two scorers by their scores.
91
+ #
92
+ # Required by Comparable. Enables <, <=, ==, >=, >, and between? operators.
93
+ #
94
+ # @param other [MatchScoreBase] Another scorer to compare
95
+ # @return [Integer] -1, 0, or 1 for comparison
96
+ def <=>(other)
97
+ score <=> other.score
98
+ end
99
+
100
+ # Generate a hash code for this scorer.
101
+ #
102
+ # Required for Hash key compatibility. Two scorers with the same
103
+ # node_a, node_b, and score should have the same hash.
104
+ #
105
+ # @return [Integer] Hash code
106
+ def hash
107
+ [node_a, node_b, score].hash
108
+ end
109
+
110
+ # Check equality for Hash key compatibility.
111
+ #
112
+ # Two scorers are eql? if they have the same node_a, node_b, and score.
113
+ # This is stricter than == from Comparable (which only compares scores).
114
+ #
115
+ # @param other [MatchScoreBase] Another scorer to compare
116
+ # @return [Boolean] True if equivalent
117
+ def eql?(other)
118
+ return false unless other.is_a?(MatchScoreBase)
119
+
120
+ node_a == other.node_a && node_b == other.node_b && score == other.score
121
+ end
122
+
123
+ private
124
+
125
+ # Compute the score using the algorithm.
126
+ #
127
+ # @return [Float] Score between 0.0 and 1.0
128
+ def compute_score
129
+ result = algorithm.call(node_a, node_b)
130
+ # Clamp to valid range
131
+ [[result.to_f, 0.0].max, 1.0].min
132
+ end
133
+ end
134
+ end
135
+ end
@@ -0,0 +1,169 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Ast
4
+ module Merge
5
+ # Base class for tracking merge results in AST merge libraries.
6
+ # Provides shared decision constants and base functionality for
7
+ # file-type-specific implementations.
8
+ #
9
+ # @example Basic usage in a subclass
10
+ # class MyMergeResult < Ast::Merge::MergeResultBase
11
+ # def add_node(node, decision:, source:)
12
+ # # File-type-specific node handling
13
+ # end
14
+ # end
15
+ class MergeResultBase
16
+ # Decision constants for tracking merge choices
17
+
18
+ # Line was kept from template (no conflict or template preferred).
19
+ # Used when template content is included without modification.
20
+ DECISION_KEPT_TEMPLATE = :kept_template
21
+
22
+ # Line was kept from destination (no conflict or destination preferred).
23
+ # Used when destination content is included without modification.
24
+ DECISION_KEPT_DEST = :kept_destination
25
+
26
+ # Line was merged from both sources.
27
+ # Used when content was combined from template and destination.
28
+ DECISION_MERGED = :merged
29
+
30
+ # Line was added from template (template-only content).
31
+ # Used for content that exists only in template and is added to result.
32
+ DECISION_ADDED = :added
33
+
34
+ # Line from destination freeze block (always preserved).
35
+ # Used for content within freeze markers that must be kept
36
+ # from destination regardless of template content.
37
+ DECISION_FREEZE_BLOCK = :freeze_block
38
+
39
+ # Line replaced matching content (signature match with preference applied).
40
+ # Used when template and destination have nodes with same signature but
41
+ # different content, and one version replaced the other based on preference.
42
+ DECISION_REPLACED = :replaced
43
+
44
+ # Line was appended from destination (destination-only content).
45
+ # Used for content that exists only in destination and is added to result.
46
+ DECISION_APPENDED = :appended
47
+
48
+ # @return [Array<String>] Lines in the result (canonical storage for line-by-line merging)
49
+ attr_reader :lines
50
+
51
+ # @return [Array<Hash>] Decisions made during merge
52
+ attr_reader :decisions
53
+
54
+ # @return [Object, nil] Analysis of the template file
55
+ attr_reader :template_analysis
56
+
57
+ # @return [Object, nil] Analysis of the destination file
58
+ attr_reader :dest_analysis
59
+
60
+ # @return [Array<Hash>] Conflicts detected during merge
61
+ attr_reader :conflicts
62
+
63
+ # @return [Array] Frozen blocks preserved during merge
64
+ attr_reader :frozen_blocks
65
+
66
+ # @return [Hash] Statistics about the merge
67
+ attr_reader :stats
68
+
69
+ # Initialize a new merge result.
70
+ #
71
+ # This unified constructor accepts all parameters that any *-merge gem might need.
72
+ # Subclasses should call super with the parameters they use.
73
+ #
74
+ # @param template_analysis [Object, nil] Analysis of the template file
75
+ # @param dest_analysis [Object, nil] Analysis of the destination file
76
+ # @param conflicts [Array<Hash>] Conflicts detected during merge
77
+ # @param frozen_blocks [Array] Frozen blocks preserved during merge
78
+ # @param stats [Hash] Statistics about the merge
79
+ def initialize(
80
+ template_analysis: nil,
81
+ dest_analysis: nil,
82
+ conflicts: [],
83
+ frozen_blocks: [],
84
+ stats: {}
85
+ )
86
+ @template_analysis = template_analysis
87
+ @dest_analysis = dest_analysis
88
+ @lines = []
89
+ @decisions = []
90
+ @conflicts = conflicts
91
+ @frozen_blocks = frozen_blocks
92
+ @stats = stats
93
+ end
94
+
95
+ # Get content - returns @lines array for most gems.
96
+ # Subclasses may override for different content models (e.g., string).
97
+ #
98
+ # @return [Array<String>] The merged content as array of lines
99
+ def content
100
+ @lines
101
+ end
102
+
103
+ # Set content from a string (splits on newlines).
104
+ # Used when region substitution replaces the merged content.
105
+ #
106
+ # @param value [String] The new content
107
+ def content=(value)
108
+ @lines = value.to_s.split("\n", -1)
109
+ end
110
+
111
+ # Get content as a string.
112
+ # This is the canonical method for converting the merge result to a string.
113
+ # Subclasses may override to customize string output (e.g., adding trailing newline).
114
+ #
115
+ # @return [String] Content as string joined with newlines
116
+ def to_s
117
+ @lines.join("\n")
118
+ end
119
+
120
+ # Check if content has been built (has any lines).
121
+ #
122
+ # @return [Boolean]
123
+ def content?
124
+ !@lines.empty?
125
+ end
126
+
127
+ # Check if the result is empty
128
+ # @return [Boolean]
129
+ def empty?
130
+ @lines.empty?
131
+ end
132
+
133
+ # Get the number of lines
134
+ # @return [Integer]
135
+ def line_count
136
+ @lines.length
137
+ end
138
+
139
+ # Get summary of decisions made
140
+ # @return [Hash<Symbol, Integer>]
141
+ def decision_summary
142
+ summary = Hash.new(0)
143
+ @decisions.each { |d| summary[d[:decision]] += 1 }
144
+ summary
145
+ end
146
+
147
+ # String representation
148
+ # @return [String]
149
+ def inspect
150
+ "#<#{self.class.name} lines=#{line_count} decisions=#{@decisions.length}>"
151
+ end
152
+
153
+ protected
154
+
155
+ # Track a decision
156
+ # @param decision [Symbol] The decision made
157
+ # @param source [Symbol] The source (:template, :destination, :merged)
158
+ # @param line [Integer, nil] The line number
159
+ def track_decision(decision, source, line: nil)
160
+ @decisions << {
161
+ decision: decision,
162
+ source: source,
163
+ line: line,
164
+ timestamp: Time.now,
165
+ }
166
+ end
167
+ end
168
+ end
169
+ end