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.
- 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 +852 -0
- data/REEK +0 -0
- data/RUBOCOP.md +71 -0
- data/SECURITY.md +21 -0
- data/lib/ast/merge/ast_node.rb +87 -0
- data/lib/ast/merge/comment/block.rb +195 -0
- data/lib/ast/merge/comment/empty.rb +78 -0
- data/lib/ast/merge/comment/line.rb +138 -0
- data/lib/ast/merge/comment/parser.rb +278 -0
- data/lib/ast/merge/comment/style.rb +282 -0
- data/lib/ast/merge/comment.rb +36 -0
- data/lib/ast/merge/conflict_resolver_base.rb +399 -0
- data/lib/ast/merge/debug_logger.rb +271 -0
- data/lib/ast/merge/fenced_code_block_detector.rb +211 -0
- data/lib/ast/merge/file_analyzable.rb +307 -0
- data/lib/ast/merge/freezable.rb +82 -0
- data/lib/ast/merge/freeze_node_base.rb +434 -0
- data/lib/ast/merge/match_refiner_base.rb +312 -0
- data/lib/ast/merge/match_score_base.rb +135 -0
- data/lib/ast/merge/merge_result_base.rb +169 -0
- data/lib/ast/merge/merger_config.rb +258 -0
- data/lib/ast/merge/node_typing.rb +373 -0
- data/lib/ast/merge/region.rb +124 -0
- data/lib/ast/merge/region_detector_base.rb +114 -0
- data/lib/ast/merge/region_mergeable.rb +364 -0
- data/lib/ast/merge/rspec/shared_examples/conflict_resolver_base.rb +416 -0
- data/lib/ast/merge/rspec/shared_examples/debug_logger.rb +174 -0
- data/lib/ast/merge/rspec/shared_examples/file_analyzable.rb +193 -0
- data/lib/ast/merge/rspec/shared_examples/freeze_node_base.rb +219 -0
- data/lib/ast/merge/rspec/shared_examples/merge_result_base.rb +106 -0
- data/lib/ast/merge/rspec/shared_examples/merger_config.rb +202 -0
- data/lib/ast/merge/rspec/shared_examples/reproducible_merge.rb +115 -0
- data/lib/ast/merge/rspec/shared_examples.rb +26 -0
- data/lib/ast/merge/rspec.rb +4 -0
- data/lib/ast/merge/section_typing.rb +303 -0
- data/lib/ast/merge/smart_merger_base.rb +417 -0
- data/lib/ast/merge/text/conflict_resolver.rb +161 -0
- data/lib/ast/merge/text/file_analysis.rb +168 -0
- data/lib/ast/merge/text/line_node.rb +142 -0
- data/lib/ast/merge/text/merge_result.rb +42 -0
- data/lib/ast/merge/text/section.rb +93 -0
- data/lib/ast/merge/text/section_splitter.rb +397 -0
- data/lib/ast/merge/text/smart_merger.rb +141 -0
- data/lib/ast/merge/text/word_node.rb +86 -0
- data/lib/ast/merge/text.rb +35 -0
- data/lib/ast/merge/toml_frontmatter_detector.rb +88 -0
- data/lib/ast/merge/version.rb +12 -0
- data/lib/ast/merge/yaml_frontmatter_detector.rb +108 -0
- data/lib/ast/merge.rb +165 -0
- data/lib/ast-merge.rb +4 -0
- data/sig/ast/merge.rbs +195 -0
- data.tar.gz.sig +0 -0
- metadata +326 -0
- 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
|