json-merge 1.1.2 → 7.1.1
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 +4 -4
- checksums.yaml.gz.sig +0 -0
- data/LICENSE.md +13 -0
- data/README.md +190 -618
- data/lib/json/merge/comment_tracker.rb +27 -0
- data/lib/json/merge/conflict_resolver.rb +1156 -113
- data/lib/json/merge/debug_logger.rb +8 -8
- data/lib/json/merge/emitter.rb +101 -37
- data/lib/json/merge/file_analysis.rb +261 -96
- data/lib/json/merge/freeze_node.rb +62 -0
- data/lib/json/merge/merge_result.rb +25 -5
- data/lib/json/merge/node_wrapper.rb +82 -131
- data/lib/json/merge/object_match_refiner.rb +13 -14
- data/lib/json/merge/smart_merger.rb +136 -10
- data/lib/json/merge/version.rb +5 -4
- data/lib/json/merge.rb +498 -101
- data/lib/json-merge.rb +7 -4
- data/sig/json/merge.rbs +3 -196
- data.tar.gz.sig +0 -0
- metadata +99 -81
- metadata.gz.sig +0 -0
- data/CHANGELOG.md +0 -149
- data/CITATION.cff +0 -20
- data/CODE_OF_CONDUCT.md +0 -134
- data/CONTRIBUTING.md +0 -227
- data/FUNDING.md +0 -74
- data/LICENSE.txt +0 -21
- data/REEK +0 -0
- data/RUBOCOP.md +0 -71
- data/SECURITY.md +0 -21
|
@@ -2,137 +2,95 @@
|
|
|
2
2
|
|
|
3
3
|
module Json
|
|
4
4
|
module Merge
|
|
5
|
-
# Wraps TreeHaver nodes with
|
|
6
|
-
#
|
|
7
|
-
#
|
|
8
|
-
# Inherits common functionality from Ast::Merge::NodeWrapperBase:
|
|
9
|
-
# - Source context (lines, source, comments)
|
|
10
|
-
# - Line info extraction
|
|
11
|
-
# - Basic methods: #type, #type?, #text, #content, #signature
|
|
12
|
-
#
|
|
13
|
-
# @example Basic usage
|
|
14
|
-
# parser = TreeHaver::Parser.new
|
|
15
|
-
# parser.language = TreeHaver::Language.json
|
|
16
|
-
# tree = parser.parse(source)
|
|
17
|
-
# wrapper = NodeWrapper.new(tree.root_node, lines: source.lines, source: source)
|
|
18
|
-
# wrapper.signature # => [:object, ...]
|
|
19
|
-
#
|
|
20
|
-
# @see Ast::Merge::NodeWrapperBase
|
|
5
|
+
# Wraps TreeHaver nodes with line information and signatures for JSON /
|
|
6
|
+
# JSONC merging.
|
|
21
7
|
class NodeWrapper < Ast::Merge::NodeWrapperBase
|
|
22
|
-
|
|
23
|
-
|
|
8
|
+
attr_reader :dialect
|
|
9
|
+
|
|
10
|
+
def initialize(node, lines:, source: nil, dialect: :jsonc, **options)
|
|
11
|
+
@dialect = dialect.to_sym
|
|
12
|
+
super(node, lines: lines, source: source, **options)
|
|
13
|
+
end
|
|
14
|
+
|
|
24
15
|
def object?
|
|
25
|
-
@node.type.to_s ==
|
|
16
|
+
@node.type.to_s == 'object'
|
|
26
17
|
end
|
|
27
18
|
|
|
28
|
-
# Check if this is a JSON array
|
|
29
|
-
# @return [Boolean]
|
|
30
19
|
def array?
|
|
31
|
-
@node.type.to_s ==
|
|
20
|
+
@node.type.to_s == 'array'
|
|
32
21
|
end
|
|
33
22
|
|
|
34
|
-
# Check if this is a JSON string
|
|
35
|
-
# @return [Boolean]
|
|
36
23
|
def string?
|
|
37
|
-
@node.type.to_s ==
|
|
24
|
+
@node.type.to_s == 'string'
|
|
38
25
|
end
|
|
39
26
|
|
|
40
|
-
# Check if this is a JSON number
|
|
41
|
-
# @return [Boolean]
|
|
42
27
|
def number?
|
|
43
|
-
@node.type.to_s ==
|
|
28
|
+
@node.type.to_s == 'number'
|
|
44
29
|
end
|
|
45
30
|
|
|
46
|
-
# Check if this is a JSON boolean (true/false)
|
|
47
|
-
# @return [Boolean]
|
|
48
31
|
def boolean?
|
|
49
32
|
%w[true false].include?(@node.type.to_s)
|
|
50
33
|
end
|
|
51
34
|
|
|
52
|
-
# Check if this is a JSON null
|
|
53
|
-
# @return [Boolean]
|
|
54
35
|
def null?
|
|
55
|
-
@node.type.to_s ==
|
|
36
|
+
@node.type.to_s == 'null'
|
|
56
37
|
end
|
|
57
38
|
|
|
58
|
-
# Check if this is a key-value pair
|
|
59
|
-
# @return [Boolean]
|
|
60
39
|
def pair?
|
|
61
|
-
@node.type.to_s ==
|
|
40
|
+
@node.type.to_s == 'pair'
|
|
62
41
|
end
|
|
63
42
|
|
|
64
|
-
# Check if this is a comment
|
|
65
|
-
# @return [Boolean]
|
|
66
43
|
def comment?
|
|
67
|
-
@node.type.to_s ==
|
|
44
|
+
@node.type.to_s == 'comment'
|
|
68
45
|
end
|
|
69
46
|
|
|
70
|
-
# Get the key name if this is a pair node
|
|
71
|
-
# @return [String, nil]
|
|
72
47
|
def key_name
|
|
73
48
|
return unless pair?
|
|
74
49
|
|
|
75
|
-
|
|
76
|
-
key_node = find_child_by_field("key")
|
|
77
|
-
|
|
50
|
+
key_node = find_child_by_field('key') || semantic_children.first
|
|
78
51
|
return unless key_node
|
|
79
52
|
|
|
80
|
-
|
|
81
|
-
key_text = node_text(key_node)
|
|
82
|
-
# Remove surrounding quotes if present
|
|
83
|
-
key_text&.gsub(/\A"|"\z/, "")
|
|
53
|
+
Json::Merge.object_key_for_literal(node_text(key_node), dialect: dialect)
|
|
84
54
|
end
|
|
85
55
|
|
|
86
|
-
# Get the value node if this is a pair
|
|
87
|
-
# @return [NodeWrapper, nil]
|
|
88
56
|
def value_node
|
|
89
57
|
return unless pair?
|
|
90
58
|
|
|
91
|
-
value = find_child_by_field(
|
|
92
|
-
|
|
59
|
+
value = find_child_by_field('value') || semantic_children[1]
|
|
93
60
|
return unless value
|
|
94
61
|
|
|
95
|
-
NodeWrapper.new(value, lines: @lines, source: @source)
|
|
62
|
+
NodeWrapper.new(value, lines: @lines, source: @source, dialect: dialect)
|
|
96
63
|
end
|
|
97
64
|
|
|
98
|
-
# Get key-value pairs if this is an object
|
|
99
|
-
# @return [Array<NodeWrapper>]
|
|
100
65
|
def pairs
|
|
101
66
|
return [] unless object?
|
|
102
67
|
|
|
103
68
|
result = []
|
|
104
69
|
@node.each do |child|
|
|
105
|
-
next if child.type.to_s ==
|
|
106
|
-
next unless child.type.to_s ==
|
|
70
|
+
next if child.type.to_s == 'comment'
|
|
71
|
+
next unless child.type.to_s == 'pair'
|
|
107
72
|
|
|
108
|
-
result << NodeWrapper.new(child, lines: @lines, source: @source)
|
|
73
|
+
result << NodeWrapper.new(child, lines: @lines, source: @source, dialect: dialect)
|
|
109
74
|
end
|
|
110
75
|
result
|
|
111
76
|
end
|
|
112
77
|
|
|
113
|
-
# Get array elements if this is an array
|
|
114
|
-
# @return [Array<NodeWrapper>]
|
|
115
78
|
def elements
|
|
116
79
|
return [] unless array?
|
|
117
80
|
|
|
118
81
|
result = []
|
|
119
82
|
@node.each do |child|
|
|
120
83
|
child_type = child.type.to_s
|
|
121
|
-
|
|
122
|
-
next if child_type ==
|
|
123
|
-
next if child_type ==
|
|
124
|
-
next if child_type ==
|
|
125
|
-
next if child_type == "]"
|
|
84
|
+
next if child_type == 'comment'
|
|
85
|
+
next if child_type == ','
|
|
86
|
+
next if child_type == '['
|
|
87
|
+
next if child_type == ']'
|
|
126
88
|
|
|
127
|
-
result << NodeWrapper.new(child, lines: @lines, source: @source)
|
|
89
|
+
result << NodeWrapper.new(child, lines: @lines, source: @source, dialect: dialect)
|
|
128
90
|
end
|
|
129
91
|
result
|
|
130
92
|
end
|
|
131
93
|
|
|
132
|
-
# Get mergeable children - the semantically meaningful children for tree merging
|
|
133
|
-
# For objects, returns pairs. For arrays, returns elements.
|
|
134
|
-
# For other node types, returns empty array (leaf nodes).
|
|
135
|
-
# @return [Array<NodeWrapper>]
|
|
136
94
|
def mergeable_children
|
|
137
95
|
case type
|
|
138
96
|
when :object
|
|
@@ -144,73 +102,55 @@ module Json
|
|
|
144
102
|
end
|
|
145
103
|
end
|
|
146
104
|
|
|
147
|
-
# Check if this node is a container (has mergeable children)
|
|
148
|
-
# @return [Boolean]
|
|
149
105
|
def container?
|
|
150
106
|
object? || array?
|
|
151
107
|
end
|
|
152
108
|
|
|
153
|
-
# Check if this is a root-level container (direct child of document)
|
|
154
|
-
# Root-level containers get a generic signature so they always match.
|
|
155
|
-
# @return [Boolean]
|
|
156
109
|
def root_level_container?
|
|
157
110
|
return false unless container?
|
|
158
111
|
|
|
159
|
-
# Check if parent is a document node
|
|
160
112
|
parent_node = @node.parent if @node.respond_to?(:parent)
|
|
161
113
|
return false unless parent_node
|
|
162
114
|
|
|
163
|
-
parent_node.type.to_s ==
|
|
115
|
+
parent_node.type.to_s == 'document'
|
|
164
116
|
end
|
|
165
117
|
|
|
166
|
-
# Get the opening line for a container node (the line with { or [)
|
|
167
|
-
# Returns the full line content including any leading whitespace
|
|
168
|
-
# @return [String, nil]
|
|
169
118
|
def opening_line
|
|
170
119
|
return unless container? && @start_line
|
|
171
120
|
|
|
121
|
+
return opening_bracket if @start_line == @end_line
|
|
122
|
+
|
|
172
123
|
@lines[@start_line - 1]
|
|
173
124
|
end
|
|
174
125
|
|
|
175
|
-
# Get the closing line for a container node (the line with } or ])
|
|
176
|
-
# Returns the full line content including any leading whitespace
|
|
177
|
-
# @return [String, nil]
|
|
178
126
|
def closing_line
|
|
179
127
|
return unless container? && @end_line
|
|
180
128
|
|
|
129
|
+
return closing_bracket if @start_line == @end_line
|
|
130
|
+
|
|
181
131
|
@lines[@end_line - 1]
|
|
182
132
|
end
|
|
183
133
|
|
|
184
|
-
# Get the opening bracket character for this container
|
|
185
|
-
# @return [String, nil]
|
|
186
134
|
def opening_bracket
|
|
187
|
-
return
|
|
188
|
-
return
|
|
135
|
+
return '{' if object?
|
|
136
|
+
return '[' if array?
|
|
189
137
|
|
|
190
138
|
nil
|
|
191
139
|
end
|
|
192
140
|
|
|
193
|
-
# Get the closing bracket character for this container
|
|
194
|
-
# @return [String, nil]
|
|
195
141
|
def closing_bracket
|
|
196
|
-
return
|
|
197
|
-
return
|
|
142
|
+
return '}' if object?
|
|
143
|
+
return ']' if array?
|
|
198
144
|
|
|
199
145
|
nil
|
|
200
146
|
end
|
|
201
147
|
|
|
202
|
-
# Find a child by field name
|
|
203
|
-
# @param field_name [String] Field name to look for
|
|
204
|
-
# @return [TreeSitter::Node, nil]
|
|
205
148
|
def find_child_by_field(field_name)
|
|
206
149
|
return unless @node.respond_to?(:child_by_field_name)
|
|
207
150
|
|
|
208
151
|
@node.child_by_field_name(field_name)
|
|
209
152
|
end
|
|
210
153
|
|
|
211
|
-
# Find a child by type
|
|
212
|
-
# @param type_name [String] Type name to look for
|
|
213
|
-
# @return [TreeSitter::Node, nil]
|
|
214
154
|
def find_child_by_type(type_name)
|
|
215
155
|
return unless @node.respond_to?(:each)
|
|
216
156
|
|
|
@@ -220,67 +160,63 @@ module Json
|
|
|
220
160
|
nil
|
|
221
161
|
end
|
|
222
162
|
|
|
163
|
+
def semantic_children
|
|
164
|
+
return [] unless @node.respond_to?(:each)
|
|
165
|
+
|
|
166
|
+
@node.each.filter_map do |child|
|
|
167
|
+
child_type = child.type.to_s
|
|
168
|
+
next if %w[comment , : { } [ ]].include?(child_type)
|
|
169
|
+
|
|
170
|
+
child
|
|
171
|
+
end
|
|
172
|
+
end
|
|
173
|
+
|
|
223
174
|
protected
|
|
224
175
|
|
|
225
|
-
# Override wrap_child to use Json::Merge::NodeWrapper
|
|
226
176
|
def wrap_child(child)
|
|
227
|
-
NodeWrapper.new(child, lines: @lines, source: @source)
|
|
177
|
+
NodeWrapper.new(child, lines: @lines, source: @source, dialect: dialect)
|
|
228
178
|
end
|
|
229
179
|
|
|
230
180
|
def compute_signature(node)
|
|
231
181
|
node_type = node.type.to_s
|
|
232
182
|
|
|
233
183
|
case node_type
|
|
234
|
-
when
|
|
235
|
-
# Root document - signature based on root content type
|
|
184
|
+
when 'document'
|
|
236
185
|
child = nil
|
|
237
|
-
node.each
|
|
238
|
-
child =
|
|
186
|
+
node.each do |candidate|
|
|
187
|
+
child = candidate unless candidate.type.to_s == 'comment'
|
|
239
188
|
break if child
|
|
240
|
-
|
|
189
|
+
end
|
|
241
190
|
child_type = child&.type&.to_s
|
|
242
191
|
[:document, child_type]
|
|
243
|
-
when
|
|
244
|
-
# For root-level objects (direct child of document), use a generic signature
|
|
245
|
-
# that always matches so merging happens at the pair level.
|
|
246
|
-
# This is critical for JSON merging - there's typically only one root object/array.
|
|
192
|
+
when 'object'
|
|
247
193
|
if root_level_container?
|
|
248
194
|
[:root_object]
|
|
249
195
|
else
|
|
250
|
-
# Nested objects identified by their keys
|
|
251
196
|
keys = extract_object_keys(node)
|
|
252
197
|
[:object, keys.sort]
|
|
253
198
|
end
|
|
254
|
-
when
|
|
255
|
-
# For root-level arrays (direct child of document), use a generic signature
|
|
199
|
+
when 'array'
|
|
256
200
|
if root_level_container?
|
|
257
201
|
[:root_array]
|
|
258
202
|
else
|
|
259
|
-
# Nested arrays identified by their length and first few elements
|
|
260
203
|
elements_count = 0
|
|
261
|
-
node.each { |
|
|
204
|
+
node.each { |candidate| elements_count += 1 unless %w[comment , \[ \]].include?(candidate.type.to_s) }
|
|
262
205
|
[:array, elements_count]
|
|
263
206
|
end
|
|
264
|
-
when
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
[:pair, key]
|
|
268
|
-
when "string"
|
|
269
|
-
# Strings identified by their content
|
|
207
|
+
when 'pair'
|
|
208
|
+
[:pair, key_name]
|
|
209
|
+
when 'string'
|
|
270
210
|
[:string, node_text(node)]
|
|
271
|
-
when
|
|
272
|
-
# Numbers identified by their value
|
|
211
|
+
when 'number'
|
|
273
212
|
[:number, node_text(node)]
|
|
274
|
-
when
|
|
275
|
-
# Booleans
|
|
213
|
+
when 'true', 'false'
|
|
276
214
|
[:boolean, node.type.to_s]
|
|
277
|
-
when
|
|
215
|
+
when 'null'
|
|
278
216
|
[:null]
|
|
279
|
-
when
|
|
280
|
-
# Comments identified by their content
|
|
217
|
+
when 'comment'
|
|
281
218
|
[:comment, node_text(node)&.strip]
|
|
282
219
|
else
|
|
283
|
-
# Generic fallback
|
|
284
220
|
content_preview = node_text(node)&.slice(0, 50)&.strip
|
|
285
221
|
[node_type.to_sym, content_preview]
|
|
286
222
|
end
|
|
@@ -291,13 +227,28 @@ module Json
|
|
|
291
227
|
def extract_object_keys(object_node)
|
|
292
228
|
keys = []
|
|
293
229
|
object_node.each do |child|
|
|
294
|
-
next unless child.type.to_s ==
|
|
295
|
-
|
|
296
|
-
key_node = child.respond_to?(:child_by_field_name) ? child.child_by_field_name(
|
|
230
|
+
next unless child.type.to_s == 'pair'
|
|
231
|
+
|
|
232
|
+
key_node = child.respond_to?(:child_by_field_name) ? child.child_by_field_name('key') : nil
|
|
233
|
+
|
|
234
|
+
unless key_node
|
|
235
|
+
begin
|
|
236
|
+
child_each = child.method(:each)
|
|
237
|
+
child_each.call do |pair_child|
|
|
238
|
+
pair_child_type = pair_child.type.to_s
|
|
239
|
+
next if [':', 'comment'].include?(pair_child_type)
|
|
240
|
+
|
|
241
|
+
key_node = pair_child
|
|
242
|
+
break
|
|
243
|
+
end
|
|
244
|
+
rescue StandardError
|
|
245
|
+
next
|
|
246
|
+
end
|
|
247
|
+
end
|
|
297
248
|
|
|
298
249
|
next unless key_node
|
|
299
250
|
|
|
300
|
-
key_text = node_text(key_node)
|
|
251
|
+
key_text = Json::Merge.object_key_for_literal(node_text(key_node), dialect: dialect)
|
|
301
252
|
keys << key_text if key_text
|
|
302
253
|
end
|
|
303
254
|
keys
|
|
@@ -44,7 +44,8 @@ module Json
|
|
|
44
44
|
# @param threshold [Float] Minimum score to accept a match (default: 0.5)
|
|
45
45
|
# @param key_weight [Float] Weight for key similarity (default: 0.7)
|
|
46
46
|
# @param value_weight [Float] Weight for value similarity (default: 0.3)
|
|
47
|
-
def initialize(threshold: DEFAULT_THRESHOLD, key_weight: DEFAULT_KEY_WEIGHT, value_weight: DEFAULT_VALUE_WEIGHT,
|
|
47
|
+
def initialize(threshold: DEFAULT_THRESHOLD, key_weight: DEFAULT_KEY_WEIGHT, value_weight: DEFAULT_VALUE_WEIGHT,
|
|
48
|
+
**options)
|
|
48
49
|
super(threshold: threshold, **options)
|
|
49
50
|
@key_weight = key_weight
|
|
50
51
|
@value_weight = value_weight
|
|
@@ -58,7 +59,7 @@ module Json
|
|
|
58
59
|
# @param dest_nodes [Array] Unmatched nodes from destination
|
|
59
60
|
# @param context [Hash] Additional context
|
|
60
61
|
# @return [Array<MatchResult>] Array of node matches
|
|
61
|
-
def call(template_nodes, dest_nodes,
|
|
62
|
+
def call(template_nodes, dest_nodes, _context = {})
|
|
62
63
|
# Match object pairs (key-value entries)
|
|
63
64
|
pair_matches = match_pairs(template_nodes, dest_nodes)
|
|
64
65
|
|
|
@@ -212,9 +213,9 @@ module Json
|
|
|
212
213
|
# @return [String] Normalized key
|
|
213
214
|
def normalize_key(key)
|
|
214
215
|
# Convert camelCase to snake_case first, then normalize
|
|
215
|
-
key.gsub(/([A-Z])/) { "_#{
|
|
216
|
-
|
|
217
|
-
|
|
216
|
+
key.gsub(/([A-Z])/) { "_#{::Regexp.last_match(1).downcase}" }
|
|
217
|
+
.downcase
|
|
218
|
+
.gsub(/[-_]/, '')
|
|
218
219
|
end
|
|
219
220
|
|
|
220
221
|
# Compute similarity between two values.
|
|
@@ -260,8 +261,8 @@ module Json
|
|
|
260
261
|
# @return [String]
|
|
261
262
|
def extract_string_value(node)
|
|
262
263
|
# String nodes include quotes, remove them
|
|
263
|
-
text = node.respond_to?(:text) ? node.text :
|
|
264
|
-
text.gsub(/\A"|"\z/,
|
|
264
|
+
text = node.respond_to?(:text) ? node.text : ''
|
|
265
|
+
text.gsub(/\A"|"\z/, '')
|
|
265
266
|
end
|
|
266
267
|
|
|
267
268
|
# Compute similarity between two arrays.
|
|
@@ -273,8 +274,8 @@ module Json
|
|
|
273
274
|
len1 = arr1.respond_to?(:elements) ? arr1.elements.size : 0
|
|
274
275
|
len2 = arr2.respond_to?(:elements) ? arr2.elements.size : 0
|
|
275
276
|
|
|
276
|
-
return 1.0 if len1
|
|
277
|
-
return 0.0 if len1
|
|
277
|
+
return 1.0 if len1.zero? && len2.zero?
|
|
278
|
+
return 0.0 if len1.zero? || len2.zero?
|
|
278
279
|
|
|
279
280
|
[len1, len2].min.to_f / [len1, len2].max
|
|
280
281
|
end
|
|
@@ -306,9 +307,7 @@ module Json
|
|
|
306
307
|
return str1.length if str2.empty?
|
|
307
308
|
|
|
308
309
|
# Ensure str1 is the shorter string for space optimization
|
|
309
|
-
if str1.length > str2.length
|
|
310
|
-
str1, str2 = str2, str1
|
|
311
|
-
end
|
|
310
|
+
str1, str2 = str2, str1 if str1.length > str2.length
|
|
312
311
|
|
|
313
312
|
m = str1.length
|
|
314
313
|
n = str2.length
|
|
@@ -321,11 +320,11 @@ module Json
|
|
|
321
320
|
curr_row[0] = j
|
|
322
321
|
|
|
323
322
|
(1..m).each do |i|
|
|
324
|
-
cost =
|
|
323
|
+
cost = str1[i - 1] == str2[j - 1] ? 0 : 1
|
|
325
324
|
curr_row[i] = [
|
|
326
325
|
prev_row[i] + 1, # deletion
|
|
327
326
|
curr_row[i - 1] + 1, # insertion
|
|
328
|
-
prev_row[i - 1] + cost
|
|
327
|
+
prev_row[i - 1] + cost # substitution
|
|
329
328
|
].min
|
|
330
329
|
end
|
|
331
330
|
|
|
@@ -2,8 +2,7 @@
|
|
|
2
2
|
|
|
3
3
|
module Json
|
|
4
4
|
module Merge
|
|
5
|
-
# High-level merger for JSON/JSONC content.
|
|
6
|
-
# Orchestrates parsing, analysis, and conflict resolution.
|
|
5
|
+
# High-level merger for JSON / JSONC content.
|
|
7
6
|
#
|
|
8
7
|
# @example Basic usage
|
|
9
8
|
# merger = SmartMerger.new(template_content, dest_content)
|
|
@@ -23,6 +22,10 @@ module Json
|
|
|
23
22
|
# merger = SmartMerger.new(template, dest,
|
|
24
23
|
# regions: [{ detector: SomeDetector.new, merger_class: SomeMerger }])
|
|
25
24
|
class SmartMerger < ::Ast::Merge::SmartMergerBase
|
|
25
|
+
include ::Ast::Merge::Runtime::RootSessionSupport
|
|
26
|
+
|
|
27
|
+
attr_reader :runtime_session, :corruption_handling, :dialect
|
|
28
|
+
|
|
26
29
|
# Creates a new SmartMerger
|
|
27
30
|
#
|
|
28
31
|
# @param template_content [String] Template JSON content
|
|
@@ -30,6 +33,7 @@ module Json
|
|
|
30
33
|
# @param signature_generator [Proc, nil] Custom signature generator
|
|
31
34
|
# @param preference [Symbol, Hash] :destination, :template, or per-type Hash
|
|
32
35
|
# @param add_template_only_nodes [Boolean] Whether to add nodes only found in template
|
|
36
|
+
# @param remove_template_missing_nodes [Boolean] Whether to remove nodes missing from template
|
|
33
37
|
# @param freeze_token [String, nil] Token for freeze block markers
|
|
34
38
|
# @param match_refiner [#call, nil] Match refiner for fuzzy matching
|
|
35
39
|
# @param regions [Array<Hash>, nil] Region configurations for nested merging
|
|
@@ -43,24 +47,42 @@ module Json
|
|
|
43
47
|
signature_generator: nil,
|
|
44
48
|
preference: :destination,
|
|
45
49
|
add_template_only_nodes: false,
|
|
50
|
+
remove_template_missing_nodes: false,
|
|
51
|
+
corruption_handling: :heal,
|
|
46
52
|
freeze_token: nil,
|
|
47
53
|
match_refiner: nil,
|
|
48
54
|
regions: nil,
|
|
49
55
|
region_placeholder: nil,
|
|
50
56
|
node_typing: nil,
|
|
57
|
+
merge_arrays: true,
|
|
58
|
+
preserve_atomic_formatting: false,
|
|
59
|
+
dialect: :jsonc,
|
|
51
60
|
**options
|
|
52
61
|
)
|
|
62
|
+
@remove_template_missing_nodes = remove_template_missing_nodes
|
|
63
|
+
@corruption_handling = ::Ast::Merge::Healer.normalize_mode(corruption_handling)
|
|
64
|
+
@merge_arrays = merge_arrays
|
|
65
|
+
@preserve_atomic_formatting = preserve_atomic_formatting
|
|
66
|
+
@dialect = dialect.to_sym
|
|
67
|
+
unless %i[json jsonc json5].include?(@dialect)
|
|
68
|
+
raise ArgumentError, "Unsupported JSON dialect #{dialect.inspect}. Expected json, jsonc, or json5."
|
|
69
|
+
end
|
|
70
|
+
|
|
53
71
|
super(
|
|
54
72
|
template_content,
|
|
55
73
|
dest_content,
|
|
56
74
|
signature_generator: signature_generator,
|
|
57
75
|
preference: preference,
|
|
58
76
|
add_template_only_nodes: add_template_only_nodes,
|
|
77
|
+
remove_template_missing_nodes: remove_template_missing_nodes,
|
|
59
78
|
freeze_token: freeze_token,
|
|
60
79
|
match_refiner: match_refiner,
|
|
61
80
|
regions: regions,
|
|
62
81
|
region_placeholder: region_placeholder,
|
|
63
82
|
node_typing: node_typing,
|
|
83
|
+
merge_arrays: merge_arrays,
|
|
84
|
+
preserve_atomic_formatting: preserve_atomic_formatting,
|
|
85
|
+
dialect: @dialect,
|
|
64
86
|
**options
|
|
65
87
|
)
|
|
66
88
|
end
|
|
@@ -72,7 +94,64 @@ module Json
|
|
|
72
94
|
{
|
|
73
95
|
preference: @preference,
|
|
74
96
|
add_template_only_nodes: @add_template_only_nodes,
|
|
97
|
+
remove_template_missing_nodes: @remove_template_missing_nodes,
|
|
98
|
+
resolution_mode: @resolution_mode,
|
|
99
|
+
unresolved_policy: @unresolved_policy.to_h,
|
|
100
|
+
corruption_handling: @corruption_handling,
|
|
75
101
|
match_refiner: @match_refiner,
|
|
102
|
+
dialect: @dialect
|
|
103
|
+
}
|
|
104
|
+
end
|
|
105
|
+
|
|
106
|
+
# Perform the merge operation and return the full MergeResult object.
|
|
107
|
+
#
|
|
108
|
+
# @return [MergeResult] The merge result containing merged JSON content and metadata
|
|
109
|
+
def merge_result
|
|
110
|
+
return @merge_result if @merge_result
|
|
111
|
+
|
|
112
|
+
root_operation = start_runtime_session!
|
|
113
|
+
@merge_result = super
|
|
114
|
+
complete_runtime_session!(root_operation, @merge_result)
|
|
115
|
+
@merge_result
|
|
116
|
+
rescue StandardError => e
|
|
117
|
+
fail_runtime_session!(root_operation, e)
|
|
118
|
+
raise
|
|
119
|
+
end
|
|
120
|
+
|
|
121
|
+
# Perform the merge and return detailed runtime-aware debug information.
|
|
122
|
+
#
|
|
123
|
+
# @return [Hash] Hash containing :content, :debug, :runtime, :statistics, and :decisions
|
|
124
|
+
def merge_with_debug
|
|
125
|
+
result_obj = merge_result
|
|
126
|
+
template_analysis_debug = {
|
|
127
|
+
valid: @template_analysis.valid?,
|
|
128
|
+
nodes: @template_analysis.nodes.size,
|
|
129
|
+
freeze_blocks: @template_analysis.freeze_blocks.size
|
|
130
|
+
}
|
|
131
|
+
dest_analysis_debug = {
|
|
132
|
+
valid: @dest_analysis.valid?,
|
|
133
|
+
nodes: @dest_analysis.nodes.size,
|
|
134
|
+
freeze_blocks: @dest_analysis.freeze_blocks.size
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
{
|
|
138
|
+
content: result_obj.to_json,
|
|
139
|
+
debug: {
|
|
140
|
+
template_nodes: template_analysis_debug[:nodes],
|
|
141
|
+
dest_nodes: dest_analysis_debug[:nodes],
|
|
142
|
+
preference: @preference,
|
|
143
|
+
add_template_only_nodes: @add_template_only_nodes,
|
|
144
|
+
remove_template_missing_nodes: @remove_template_missing_nodes,
|
|
145
|
+
corruption_handling: @corruption_handling,
|
|
146
|
+
freeze_token: @freeze_token,
|
|
147
|
+
runtime_operation_count: runtime_session&.operations&.size || 0,
|
|
148
|
+
runtime_diagnostic_count: runtime_session&.diagnostics&.size || 0
|
|
149
|
+
},
|
|
150
|
+
runtime: runtime_session&.to_h,
|
|
151
|
+
statistics: result_obj.statistics,
|
|
152
|
+
decisions: result_obj.decision_summary,
|
|
153
|
+
template_analysis: template_analysis_debug,
|
|
154
|
+
dest_analysis: dest_analysis_debug
|
|
76
155
|
}
|
|
77
156
|
end
|
|
78
157
|
|
|
@@ -85,7 +164,7 @@ module Json
|
|
|
85
164
|
|
|
86
165
|
# @return [String] The default freeze token (not used for JSON)
|
|
87
166
|
def default_freeze_token
|
|
88
|
-
|
|
167
|
+
'json-merge'
|
|
89
168
|
end
|
|
90
169
|
|
|
91
170
|
# @return [Class] The resolver class for JSON files
|
|
@@ -104,10 +183,10 @@ module Json
|
|
|
104
183
|
def perform_merge
|
|
105
184
|
@resolver.resolve(@result)
|
|
106
185
|
|
|
107
|
-
DebugLogger.debug(
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
186
|
+
DebugLogger.debug('Merge complete', {
|
|
187
|
+
lines: @result.line_count,
|
|
188
|
+
decisions: @result.statistics
|
|
189
|
+
})
|
|
111
190
|
|
|
112
191
|
@result
|
|
113
192
|
end
|
|
@@ -119,8 +198,13 @@ module Json
|
|
|
119
198
|
@dest_analysis,
|
|
120
199
|
preference: @preference,
|
|
121
200
|
add_template_only_nodes: @add_template_only_nodes,
|
|
201
|
+
remove_template_missing_nodes: @remove_template_missing_nodes,
|
|
202
|
+
resolution_mode: @resolution_mode,
|
|
203
|
+
corruption_handling: @corruption_handling,
|
|
122
204
|
match_refiner: @match_refiner,
|
|
123
205
|
node_typing: @node_typing,
|
|
206
|
+
merge_arrays: @merge_arrays,
|
|
207
|
+
preserve_atomic_formatting: @preserve_atomic_formatting
|
|
124
208
|
)
|
|
125
209
|
end
|
|
126
210
|
|
|
@@ -141,9 +225,51 @@ module Json
|
|
|
141
225
|
|
|
142
226
|
private
|
|
143
227
|
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
228
|
+
def start_runtime_session!
|
|
229
|
+
start_runtime_root_session!(
|
|
230
|
+
surface_kind: :json_document,
|
|
231
|
+
declared_language: :json,
|
|
232
|
+
effective_language: :json,
|
|
233
|
+
operation_id: 'json-document-root',
|
|
234
|
+
delegate_name: 'json-runtime',
|
|
235
|
+
policy_context: {
|
|
236
|
+
preference: @preference,
|
|
237
|
+
add_template_only_nodes: @add_template_only_nodes,
|
|
238
|
+
remove_template_missing_nodes: @remove_template_missing_nodes,
|
|
239
|
+
resolution_mode: @resolution_mode,
|
|
240
|
+
unresolved_policy: @unresolved_policy.to_h
|
|
241
|
+
},
|
|
242
|
+
metadata: { merger: self.class.name },
|
|
243
|
+
options: {
|
|
244
|
+
preference: @preference,
|
|
245
|
+
add_template_only_nodes: @add_template_only_nodes,
|
|
246
|
+
remove_template_missing_nodes: @remove_template_missing_nodes,
|
|
247
|
+
resolution_mode: @resolution_mode,
|
|
248
|
+
unresolved_policy: @unresolved_policy.to_h
|
|
249
|
+
},
|
|
250
|
+
language_chain: [:json],
|
|
251
|
+
delegate_metadata: { merger: self.class.name }
|
|
252
|
+
)
|
|
253
|
+
end
|
|
254
|
+
|
|
255
|
+
def complete_runtime_session!(root_operation, merge_result)
|
|
256
|
+
complete_runtime_root_session!(
|
|
257
|
+
root_operation: root_operation,
|
|
258
|
+
replacement_text: merge_result.to_json,
|
|
259
|
+
unresolved_cases: merge_result.unresolved_cases,
|
|
260
|
+
metadata: {
|
|
261
|
+
stats: merge_result.statistics,
|
|
262
|
+
decisions: merge_result.decision_summary
|
|
263
|
+
}
|
|
264
|
+
)
|
|
265
|
+
end
|
|
266
|
+
|
|
267
|
+
def fail_runtime_session!(root_operation, error)
|
|
268
|
+
fail_runtime_root_session!(
|
|
269
|
+
root_operation: root_operation,
|
|
270
|
+
error: error,
|
|
271
|
+
kind: :merge_failed
|
|
272
|
+
)
|
|
147
273
|
end
|
|
148
274
|
end
|
|
149
275
|
end
|