jsonc-merge 1.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
data/REEK ADDED
File without changes
data/RUBOCOP.md ADDED
@@ -0,0 +1,71 @@
1
+ # RuboCop Usage Guide
2
+
3
+ ## Overview
4
+
5
+ A tale of two RuboCop plugin gems.
6
+
7
+ ### RuboCop Gradual
8
+
9
+ This project uses `rubocop_gradual` instead of vanilla RuboCop for code style checking. The `rubocop_gradual` tool allows for gradual adoption of RuboCop rules by tracking violations in a lock file.
10
+
11
+ ### RuboCop LTS
12
+
13
+ This project uses `rubocop-lts` to ensure, on a best-effort basis, compatibility with Ruby >= 1.9.2.
14
+ RuboCop rules are meticulously configured by the `rubocop-lts` family of gems to ensure that a project is compatible with a specific version of Ruby. See: https://rubocop-lts.gitlab.io for more.
15
+
16
+ ## Checking RuboCop Violations
17
+
18
+ To check for RuboCop violations in this project, always use:
19
+
20
+ ```bash
21
+ bundle exec rake rubocop_gradual:check
22
+ ```
23
+
24
+ **Do not use** the standard RuboCop commands like:
25
+ - `bundle exec rubocop`
26
+ - `rubocop`
27
+
28
+ ## Understanding the Lock File
29
+
30
+ The `.rubocop_gradual.lock` file tracks all current RuboCop violations in the project. This allows the team to:
31
+
32
+ 1. Prevent new violations while gradually fixing existing ones
33
+ 2. Track progress on code style improvements
34
+ 3. Ensure CI builds don't fail due to pre-existing violations
35
+
36
+ ## Common Commands
37
+
38
+ - **Check violations**
39
+ - `bundle exec rake rubocop_gradual`
40
+ - `bundle exec rake rubocop_gradual:check`
41
+ - **(Safe) Autocorrect violations, and update lockfile if no new violations**
42
+ - `bundle exec rake rubocop_gradual:autocorrect`
43
+ - **Force update the lock file (w/o autocorrect) to match violations present in code**
44
+ - `bundle exec rake rubocop_gradual:force_update`
45
+
46
+ ## Workflow
47
+
48
+ 1. Before submitting a PR, run `bundle exec rake rubocop_gradual:autocorrect`
49
+ a. or just the default `bundle exec rake`, as autocorrection is a pre-requisite of the default task.
50
+ 2. If there are new violations, either:
51
+ - Fix them in your code
52
+ - Run `bundle exec rake rubocop_gradual:force_update` to update the lock file (only for violations you can't fix immediately)
53
+ 3. Commit the updated `.rubocop_gradual.lock` file along with your changes
54
+
55
+ ## Never add inline RuboCop disables
56
+
57
+ Do not add inline `rubocop:disable` / `rubocop:enable` comments anywhere in the codebase (including specs, except when following the few existing `rubocop:disable` patterns for a rule already being disabled elsewhere in the code). We handle exceptions in two supported ways:
58
+
59
+ - Permanent/structural exceptions: prefer adjusting the RuboCop configuration (e.g., in `.rubocop.yml`) to exclude a rule for a path or file pattern when it makes sense project-wide.
60
+ - Temporary exceptions while improving code: record the current violations in `.rubocop_gradual.lock` via the gradual workflow:
61
+ - `bundle exec rake rubocop_gradual:autocorrect` (preferred; will autocorrect what it can and update the lock only if no new violations were introduced)
62
+ - If needed, `bundle exec rake rubocop_gradual:force_update` (as a last resort when you cannot fix the newly reported violations immediately)
63
+
64
+ In general, treat the rules as guidance to follow; fix violations rather than ignore them. For example, RSpec conventions in this project expect `described_class` to be used in specs that target a specific class under test.
65
+
66
+ ## Benefits of rubocop_gradual
67
+
68
+ - Allows incremental adoption of code style rules
69
+ - Prevents CI failures due to pre-existing violations
70
+ - Provides a clear record of code style debt
71
+ - Enables focused efforts on improving code quality over time
data/SECURITY.md ADDED
@@ -0,0 +1,21 @@
1
+ # Security Policy
2
+
3
+ ## Supported Versions
4
+
5
+ | Version | Supported |
6
+ |----------|-----------|
7
+ | 1.latest | ✅ |
8
+
9
+ ## Security contact information
10
+
11
+ To report a security vulnerability, please use the
12
+ [Tidelift security contact](https://tidelift.com/security).
13
+ Tidelift will coordinate the fix and disclosure.
14
+
15
+ ## Additional Support
16
+
17
+ If you are interested in support for versions older than the latest release,
18
+ please consider sponsoring the project / maintainer @ https://liberapay.com/pboling/donate,
19
+ or find other sponsorship links in the [README].
20
+
21
+ [README]: README.md
@@ -0,0 +1,195 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Jsonc
4
+ module Merge
5
+ # Extracts and tracks comments with their line numbers from JSONC source.
6
+ # JSONC supports both single-line (//) and multi-line (/* */) comments.
7
+ #
8
+ # @example Basic usage
9
+ # tracker = CommentTracker.new(jsonc_source)
10
+ # tracker.comments # => [{line: 1, indent: 0, text: "This is a comment"}]
11
+ # tracker.comment_at(1) # => {line: 1, indent: 0, text: "This is a comment"}
12
+ #
13
+ # @example Comment types
14
+ # // Single-line comment
15
+ # /* Block comment */
16
+ # "key": "value" // Inline comment
17
+ class CommentTracker
18
+ # Regex to match full-line single-line comments
19
+ SINGLE_LINE_COMMENT_REGEX = %r{\A(\s*)//\s?(.*)\z}
20
+
21
+ # Regex to match full-line block comments (single line)
22
+ BLOCK_COMMENT_SINGLE_REGEX = %r{\A(\s*)/\*\s?(.*?)\s?\*/\s*\z}
23
+
24
+ # Regex to match inline single-line comments
25
+ INLINE_COMMENT_REGEX = %r{\s+//\s?(.*)$}
26
+
27
+ # @return [Array<Hash>] All extracted comments with metadata
28
+ attr_reader :comments
29
+
30
+ # @return [Array<String>] Source lines
31
+ attr_reader :lines
32
+
33
+ # Initialize comment tracker by scanning the source
34
+ #
35
+ # @param source [String] JSONC source code
36
+ def initialize(source)
37
+ @source = source
38
+ @lines = source.lines.map(&:chomp)
39
+ @comments = extract_comments
40
+ @comments_by_line = @comments.group_by { |c| c[:line] }
41
+ end
42
+
43
+ # Get comment at a specific line
44
+ #
45
+ # @param line_num [Integer] 1-based line number
46
+ # @return [Hash, nil] Comment info or nil
47
+ def comment_at(line_num)
48
+ @comments_by_line[line_num]&.first
49
+ end
50
+
51
+ # Get all comments in a line range
52
+ #
53
+ # @param range [Range] Range of 1-based line numbers
54
+ # @return [Array<Hash>] Comments in the range
55
+ def comments_in_range(range)
56
+ @comments.select { |c| range.cover?(c[:line]) }
57
+ end
58
+
59
+ # Get leading comments before a line (consecutive comment lines immediately above)
60
+ #
61
+ # @param line_num [Integer] 1-based line number
62
+ # @return [Array<Hash>] Leading comments
63
+ def leading_comments_before(line_num)
64
+ leading = []
65
+ current = line_num - 1
66
+
67
+ while current >= 1
68
+ comment = comment_at(current)
69
+ break unless comment && comment[:full_line]
70
+
71
+ leading.unshift(comment)
72
+ current -= 1
73
+ end
74
+
75
+ leading
76
+ end
77
+
78
+ # Get trailing comment on the same line (inline comment)
79
+ #
80
+ # @param line_num [Integer] 1-based line number
81
+ # @return [Hash, nil] Inline comment or nil
82
+ def inline_comment_at(line_num)
83
+ comment = comment_at(line_num)
84
+ comment if comment && !comment[:full_line]
85
+ end
86
+
87
+ # Check if a line is a full-line comment
88
+ #
89
+ # @param line_num [Integer] 1-based line number
90
+ # @return [Boolean]
91
+ def full_line_comment?(line_num)
92
+ comment = comment_at(line_num)
93
+ comment&.dig(:full_line) || false
94
+ end
95
+
96
+ # Check if a line is blank
97
+ #
98
+ # @param line_num [Integer] 1-based line number
99
+ # @return [Boolean]
100
+ def blank_line?(line_num)
101
+ return false if line_num < 1 || line_num > @lines.length
102
+
103
+ @lines[line_num - 1].strip.empty?
104
+ end
105
+
106
+ private
107
+
108
+ def extract_comments
109
+ comments = []
110
+ in_block_comment = false
111
+ block_comment_indent = 0
112
+
113
+ @lines.each_with_index do |line, idx|
114
+ line_num = idx + 1
115
+
116
+ # Handle multi-line block comments
117
+ if in_block_comment
118
+ if line.include?("*/")
119
+ in_block_comment = false
120
+ # Multi-line block comment ends - we already captured the start
121
+ end
122
+ next
123
+ end
124
+
125
+ # Check for block comment start
126
+ if line.include?("/*") && !line.include?("*/")
127
+ in_block_comment = true
128
+ match = line.match(/\A(\s*)/)
129
+ block_comment_indent = match ? match[1].length : 0
130
+ comments << {
131
+ line: line_num,
132
+ indent: block_comment_indent,
133
+ text: line.sub(/\A\s*\/\*\s?/, "").strip,
134
+ full_line: true,
135
+ block: true,
136
+ raw: line,
137
+ }
138
+ next
139
+ end
140
+
141
+ # Check for single-line block comment
142
+ if (match = line.match(BLOCK_COMMENT_SINGLE_REGEX))
143
+ comments << {
144
+ line: line_num,
145
+ indent: match[1].length,
146
+ text: match[2],
147
+ full_line: true,
148
+ block: true,
149
+ raw: line,
150
+ }
151
+ next
152
+ end
153
+
154
+ # Check for full-line single-line comment
155
+ if (match = line.match(SINGLE_LINE_COMMENT_REGEX))
156
+ comments << {
157
+ line: line_num,
158
+ indent: match[1].length,
159
+ text: match[2],
160
+ full_line: true,
161
+ block: false,
162
+ raw: line,
163
+ }
164
+ next
165
+ end
166
+
167
+ # Check for inline comment (after JSON content)
168
+ # Be careful not to match // inside strings
169
+ if line.include?("//")
170
+ # Simple heuristic: if there's content before //, it might be inline
171
+ # This doesn't handle all edge cases with strings containing //
172
+ parts = line.split("//", 2)
173
+ if parts.length == 2 && !parts[0].strip.empty?
174
+ # Verify it's not inside a string by checking quote balance
175
+ before_comment = parts[0]
176
+ quote_count = before_comment.count('"') - before_comment.scan('\\"').count
177
+ if quote_count.even?
178
+ comments << {
179
+ line: line_num,
180
+ indent: 0,
181
+ text: parts[1].strip,
182
+ full_line: false,
183
+ block: false,
184
+ raw: "// #{parts[1].strip}",
185
+ }
186
+ end
187
+ end
188
+ end
189
+ end
190
+
191
+ comments
192
+ end
193
+ end
194
+ end
195
+ end
@@ -0,0 +1,373 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Jsonc
4
+ module Merge
5
+ # Resolves conflicts between template and destination JSON content
6
+ # using structural signatures and configurable preferences.
7
+ #
8
+ # Inherits from Ast::Merge::ConflictResolverBase using the :batch strategy,
9
+ # which resolves all conflicts at once using signature maps.
10
+ #
11
+ # @example Basic usage
12
+ # resolver = ConflictResolver.new(template_analysis, dest_analysis)
13
+ # resolver.resolve(result)
14
+ #
15
+ # @see Ast::Merge::ConflictResolverBase
16
+ class ConflictResolver < Ast::Merge::ConflictResolverBase
17
+ # Creates a new ConflictResolver
18
+ #
19
+ # @param template_analysis [FileAnalysis] Analyzed template file
20
+ # @param dest_analysis [FileAnalysis] Analyzed destination file
21
+ # @param preference [Symbol, Hash] Which version to prefer when
22
+ # nodes have matching signatures:
23
+ # - :destination (default) - Keep destination version (customizations)
24
+ # - :template - Use template version (updates)
25
+ # @param add_template_only_nodes [Boolean] Whether to add nodes only in template
26
+ # @param match_refiner [#call, nil] Optional match refiner for fuzzy matching
27
+ # @param options [Hash] Additional options for forward compatibility
28
+ # @param node_typing [Hash{Symbol,String => #call}, nil] Node typing configuration
29
+ # for per-node-type preferences
30
+ def initialize(template_analysis, dest_analysis, preference: :destination, add_template_only_nodes: false, match_refiner: nil, node_typing: nil, **options)
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
+ match_refiner: match_refiner,
38
+ **options
39
+ )
40
+ @node_typing = node_typing
41
+ @emitter = Emitter.new
42
+ end
43
+
44
+ protected
45
+
46
+ # Resolve conflicts and populate the result using tree-based merging
47
+ #
48
+ # @param result [MergeResult] Result object to populate
49
+ def resolve_batch(result)
50
+ DebugLogger.time("ConflictResolver#resolve") do
51
+ template_statements = @template_analysis.statements
52
+ dest_statements = @dest_analysis.statements
53
+
54
+ # Clear emitter for fresh merge
55
+ @emitter.clear
56
+
57
+ # Merge root-level statements via emitter
58
+ merge_node_lists_to_emitter(
59
+ template_statements,
60
+ dest_statements,
61
+ @template_analysis,
62
+ @dest_analysis,
63
+ )
64
+
65
+ # Transfer emitter output to result
66
+ # For now, add as single content block - we'll improve decision tracking later
67
+ emitted_content = @emitter.to_s
68
+ unless emitted_content.empty?
69
+ emitted_content.lines.each do |line|
70
+ result.add_line(line.chomp, decision: MergeResult::DECISION_MERGED, source: :merged)
71
+ end
72
+ end
73
+
74
+ DebugLogger.debug("Conflict resolution complete", {
75
+ template_statements: template_statements.size,
76
+ dest_statements: dest_statements.size,
77
+ result_lines: result.line_count,
78
+ })
79
+ end
80
+ end
81
+
82
+ private
83
+
84
+ # Recursively merge two lists of nodes, emitting to emitter
85
+ # @param template_nodes [Array<NodeWrapper>] Template nodes
86
+ # @param dest_nodes [Array<NodeWrapper>] Destination nodes
87
+ # @param template_analysis [FileAnalysis] Template analysis for line access
88
+ # @param dest_analysis [FileAnalysis] Destination analysis for line access
89
+ def merge_node_lists_to_emitter(template_nodes, dest_nodes, template_analysis, dest_analysis)
90
+ # Build signature maps for matching
91
+ template_by_sig = build_signature_map(template_nodes, template_analysis)
92
+ dest_by_sig = build_signature_map(dest_nodes, dest_analysis)
93
+
94
+ # Build refined matches for nodes that don't match by signature
95
+ refined_matches = build_refined_matches(template_nodes, dest_nodes, template_by_sig, dest_by_sig)
96
+ refined_dest_to_template = refined_matches.invert
97
+
98
+ # Track which nodes have been processed
99
+ processed_template_sigs = ::Set.new
100
+ processed_dest_sigs = ::Set.new
101
+
102
+ # First pass: Process destination nodes
103
+ dest_nodes.each do |dest_node|
104
+ dest_sig = dest_analysis.generate_signature(dest_node)
105
+
106
+ # Freeze blocks from destination are always preserved
107
+ if freeze_node?(dest_node)
108
+ emit_freeze_block(dest_node)
109
+ processed_dest_sigs << dest_sig if dest_sig
110
+ next
111
+ end
112
+
113
+ # Check for signature match
114
+ if dest_sig && template_by_sig[dest_sig]
115
+ template_info = template_by_sig[dest_sig].first
116
+ template_node = template_info[:node]
117
+
118
+ # Both have this node - merge them (recursively if containers)
119
+ merge_matched_nodes_to_emitter(template_node, dest_node, template_analysis, dest_analysis)
120
+
121
+ processed_dest_sigs << dest_sig
122
+ processed_template_sigs << dest_sig
123
+ elsif refined_dest_to_template.key?(dest_node)
124
+ # Found refined match
125
+ template_node = refined_dest_to_template[dest_node]
126
+ template_sig = template_analysis.generate_signature(template_node)
127
+
128
+ # Merge matched nodes
129
+ merge_matched_nodes_to_emitter(template_node, dest_node, template_analysis, dest_analysis)
130
+
131
+ processed_dest_sigs << dest_sig if dest_sig
132
+ processed_template_sigs << template_sig if template_sig
133
+ else
134
+ # Destination-only node - always keep
135
+ emit_node(dest_node, dest_analysis)
136
+ processed_dest_sigs << dest_sig if dest_sig
137
+ end
138
+ end
139
+
140
+ # Second pass: Add template-only nodes if configured
141
+ return unless @add_template_only_nodes
142
+
143
+ template_nodes.each do |template_node|
144
+ template_sig = template_analysis.generate_signature(template_node)
145
+
146
+ # Skip if already processed
147
+ next if template_sig && processed_template_sigs.include?(template_sig)
148
+
149
+ # Skip freeze blocks from template
150
+ next if freeze_node?(template_node)
151
+
152
+ # Add template-only node
153
+ emit_node(template_node, template_analysis)
154
+ processed_template_sigs << template_sig if template_sig
155
+ end
156
+ end
157
+
158
+ # Keep old merge_node_lists for now (will be removed later)
159
+ # This allows gradual migration
160
+
161
+ # Merge two matched nodes - for containers, recursively merge children
162
+ # Emits to emitter instead of result
163
+ # @param template_node [NodeWrapper] Template node
164
+ # @param dest_node [NodeWrapper] Destination node
165
+ # @param template_analysis [FileAnalysis] Template analysis
166
+ # @param dest_analysis [FileAnalysis] Destination analysis
167
+ def merge_matched_nodes_to_emitter(template_node, dest_node, template_analysis, dest_analysis)
168
+ if dest_node.container? && template_node.container?
169
+ # Both are containers - recursively merge their children
170
+ merge_container_to_emitter(template_node, dest_node, template_analysis, dest_analysis)
171
+ elsif dest_node.pair? && template_node.pair?
172
+ # Both are pairs - check if their values are OBJECTS (not arrays) that need recursive merge
173
+ template_value = template_node.value_node
174
+ dest_value = dest_node.value_node
175
+
176
+ # Only recursively merge if BOTH values are objects (not arrays)
177
+ # Arrays are replaced atomically based on preference
178
+ if template_value&.type == :object && dest_value&.type == :object &&
179
+ template_value.container? && dest_value.container?
180
+ # Both values are objects - recursively merge
181
+ @emitter.emit_nested_object_start(dest_node.key_name)
182
+
183
+ # Recursively merge the value objects
184
+ merge_node_lists_to_emitter(
185
+ template_value.mergeable_children,
186
+ dest_value.mergeable_children,
187
+ template_analysis,
188
+ dest_analysis,
189
+ )
190
+
191
+ # Emit closing brace
192
+ @emitter.emit_nested_object_end
193
+ elsif preference_for_pair(template_node, dest_node) == :destination
194
+ # Values are not both objects, or one/both are arrays - use preference and emit
195
+ # Arrays are always replaced, not merged
196
+ emit_node(dest_node, dest_analysis)
197
+ else
198
+ emit_node(template_node, template_analysis)
199
+ end
200
+ elsif preference_for_pair(template_node, dest_node) == :destination
201
+ # Leaf nodes or mismatched types - use preference
202
+ emit_node(dest_node, dest_analysis)
203
+ else
204
+ emit_node(template_node, template_analysis)
205
+ end
206
+ end
207
+
208
+ # Merge container nodes by emitting via emitter
209
+ # @param template_node [NodeWrapper] Template container node
210
+ # @param dest_node [NodeWrapper] Destination container node
211
+ # @param template_analysis [FileAnalysis] Template analysis
212
+ # @param dest_analysis [FileAnalysis] Destination analysis
213
+ def merge_container_to_emitter(template_node, dest_node, template_analysis, dest_analysis)
214
+ # Emit opening bracket
215
+ if dest_node.object?
216
+ @emitter.emit_object_start
217
+ elsif dest_node.array?
218
+ @emitter.emit_array_start
219
+ end
220
+
221
+ # Recursively merge the children
222
+ template_children = template_node.mergeable_children
223
+ dest_children = dest_node.mergeable_children
224
+
225
+ merge_node_lists_to_emitter(
226
+ template_children,
227
+ dest_children,
228
+ template_analysis,
229
+ dest_analysis,
230
+ )
231
+
232
+ # Emit closing bracket
233
+ if dest_node.object?
234
+ @emitter.emit_object_end
235
+ elsif dest_node.array?
236
+ @emitter.emit_array_end
237
+ end
238
+ end
239
+
240
+ def preference_for_pair(template_node, dest_node)
241
+ return @preference unless @preference.is_a?(Hash)
242
+
243
+ typed_template = apply_node_typing(template_node)
244
+ typed_dest = apply_node_typing(dest_node)
245
+
246
+ if Ast::Merge::NodeTyping.typed_node?(typed_template)
247
+ merge_type = Ast::Merge::NodeTyping.merge_type_for(typed_template)
248
+ return @preference.fetch(merge_type) { default_preference } if merge_type
249
+ end
250
+
251
+ if Ast::Merge::NodeTyping.typed_node?(typed_dest)
252
+ merge_type = Ast::Merge::NodeTyping.merge_type_for(typed_dest)
253
+ return @preference.fetch(merge_type) { default_preference } if merge_type
254
+ end
255
+
256
+ default_preference
257
+ end
258
+
259
+ def apply_node_typing(node)
260
+ return node unless @node_typing
261
+ return node unless node
262
+
263
+ Ast::Merge::NodeTyping.process(node, @node_typing)
264
+ end
265
+
266
+ # Emit a single node to the emitter
267
+ # @param node [NodeWrapper] Node to emit
268
+ # @param analysis [FileAnalysis] Analysis for accessing source
269
+ def emit_node(node, analysis)
270
+ return if freeze_node?(node) # Freeze nodes handled separately
271
+
272
+ # Emit leading comments
273
+ if node.start_line
274
+ leading = analysis.comment_tracker.leading_comments_before(node.start_line)
275
+ leading.each do |comment|
276
+ @emitter.emit_tracked_comment(comment)
277
+ end
278
+ end
279
+
280
+ # Emit the node content
281
+ if node.pair?
282
+ # Emit as pair
283
+ key = node.key_name
284
+ value_node = node.value_node
285
+
286
+ if value_node
287
+ # Check if value is an object (not array) and needs recursive emission
288
+ if value_node.type == :object && value_node.container?
289
+ # Object value - emit structure recursively
290
+ @emitter.emit_nested_object_start(key)
291
+ # Recursively emit object children
292
+ value_node.mergeable_children.each do |child|
293
+ emit_node(child, analysis)
294
+ end
295
+ @emitter.emit_nested_object_end
296
+ else
297
+ # Leaf value or array - get its text and emit as simple pair
298
+ # Arrays are emitted as raw text (not recursively) because Emitter doesn't have emit_array_start(key)
299
+ value_text = if value_node.start_line == value_node.end_line
300
+ value_node.text
301
+ else
302
+ # Multi-line value - get all lines
303
+ lines = []
304
+ (value_node.start_line..value_node.end_line).each do |ln|
305
+ lines << analysis.line_at(ln)
306
+ end
307
+ lines.join("\n")
308
+ end
309
+
310
+ @emitter.emit_pair(key, value_text) if key && value_text
311
+ end
312
+ end
313
+ elsif node.start_line && node.end_line
314
+ # Emit raw content for non-pair nodes
315
+ if node.start_line == node.end_line
316
+ # Single line - add directly
317
+ @emitter.lines << node.text
318
+ else
319
+ # Multi-line - collect and emit
320
+ lines = []
321
+ (node.start_line..node.end_line).each do |ln|
322
+ line = analysis.line_at(ln)
323
+ lines << line if line
324
+ end
325
+ @emitter.emit_raw_lines(lines)
326
+ end
327
+ end
328
+ end
329
+
330
+ # Emit a freeze block
331
+ # @param freeze_node [FreezeNode] Freeze block to emit
332
+ def emit_freeze_block(freeze_node)
333
+ @emitter.emit_raw_lines(freeze_node.lines)
334
+ end
335
+
336
+ # Build a map of refined matches using match_refiner
337
+ # @param template_nodes [Array<NodeWrapper>] Template nodes
338
+ # @param dest_nodes [Array<NodeWrapper>] Destination nodes
339
+ # @param template_by_sig [Hash] Template signature map
340
+ # @param dest_by_sig [Hash] Destination signature map
341
+ # @return [Hash] Map of template_node => dest_node for refined matches
342
+ def build_refined_matches(template_nodes, dest_nodes, template_by_sig, dest_by_sig)
343
+ return {} unless @match_refiner
344
+
345
+ # Find unmatched nodes
346
+ matched_sigs = template_by_sig.keys & dest_by_sig.keys
347
+
348
+ unmatched_template = template_nodes.reject do |node|
349
+ sig = @template_analysis.generate_signature(node)
350
+ sig && matched_sigs.include?(sig)
351
+ end
352
+
353
+ unmatched_dest = dest_nodes.reject do |node|
354
+ sig = @dest_analysis.generate_signature(node)
355
+ sig && matched_sigs.include?(sig)
356
+ end
357
+
358
+ return {} if unmatched_template.empty? || unmatched_dest.empty?
359
+
360
+ # Call the match refiner
361
+ matches = @match_refiner.call(unmatched_template, unmatched_dest, {
362
+ template_analysis: @template_analysis,
363
+ dest_analysis: @dest_analysis,
364
+ })
365
+
366
+ # Build result map: template node -> dest node
367
+ matches.each_with_object({}) do |match, hash|
368
+ hash[match.template_node] = match.dest_node
369
+ end
370
+ end
371
+ end
372
+ end
373
+ end