json-merge 7.0.0 → 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.
@@ -0,0 +1,338 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Json
4
+ module Merge
5
+ # Match refiner for JSON objects and array elements that didn't match by exact signature.
6
+ #
7
+ # This refiner uses fuzzy matching to pair JSON nodes that have:
8
+ # - Similar key names in objects (e.g., `databaseUrl` vs `database_url`)
9
+ # - Array elements with similar structure or content
10
+ # - Objects with overlapping keys but different values
11
+ #
12
+ # The matching algorithm considers:
13
+ # - Key name similarity for object pairs (Levenshtein distance)
14
+ # - Value type and content similarity
15
+ # - Structural overlap for nested objects
16
+ #
17
+ # @example Basic usage
18
+ # refiner = ObjectMatchRefiner.new(threshold: 0.6)
19
+ # matches = refiner.call(template_nodes, dest_nodes)
20
+ #
21
+ # @example With custom weights
22
+ # refiner = ObjectMatchRefiner.new(
23
+ # threshold: 0.5,
24
+ # key_weight: 0.6,
25
+ # value_weight: 0.4
26
+ # )
27
+ #
28
+ # @see Ast::Merge::MatchRefinerBase
29
+ class ObjectMatchRefiner < Ast::Merge::MatchRefinerBase
30
+ # Default weight for key similarity
31
+ DEFAULT_KEY_WEIGHT = 0.7
32
+
33
+ # Default weight for value similarity
34
+ DEFAULT_VALUE_WEIGHT = 0.3
35
+
36
+ # @return [Float] Weight for key similarity (0.0-1.0)
37
+ attr_reader :key_weight
38
+
39
+ # @return [Float] Weight for value similarity (0.0-1.0)
40
+ attr_reader :value_weight
41
+
42
+ # Initialize an object match refiner.
43
+ #
44
+ # @param threshold [Float] Minimum score to accept a match (default: 0.5)
45
+ # @param key_weight [Float] Weight for key similarity (default: 0.7)
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,
48
+ **options)
49
+ super(threshold: threshold, **options)
50
+ @key_weight = key_weight
51
+ @value_weight = value_weight
52
+ end
53
+
54
+ # Find matches between unmatched JSON nodes.
55
+ #
56
+ # Handles both object key-value pairs and array elements.
57
+ #
58
+ # @param template_nodes [Array] Unmatched nodes from template
59
+ # @param dest_nodes [Array] Unmatched nodes from destination
60
+ # @param context [Hash] Additional context
61
+ # @return [Array<MatchResult>] Array of node matches
62
+ def call(template_nodes, dest_nodes, _context = {})
63
+ # Match object pairs (key-value entries)
64
+ pair_matches = match_pairs(template_nodes, dest_nodes)
65
+
66
+ # Match array elements (objects within arrays)
67
+ array_matches = match_array_objects(template_nodes, dest_nodes)
68
+
69
+ pair_matches + array_matches
70
+ end
71
+
72
+ private
73
+
74
+ # Match key-value pairs from JSON objects.
75
+ #
76
+ # @param template_nodes [Array] Template nodes
77
+ # @param dest_nodes [Array] Destination nodes
78
+ # @return [Array<MatchResult>]
79
+ def match_pairs(template_nodes, dest_nodes)
80
+ template_pairs = template_nodes.select { |n| pair_node?(n) }
81
+ dest_pairs = dest_nodes.select { |n| pair_node?(n) }
82
+
83
+ return [] if template_pairs.empty? || dest_pairs.empty?
84
+
85
+ greedy_match(template_pairs, dest_pairs) do |t_node, d_node|
86
+ compute_pair_similarity(t_node, d_node)
87
+ end
88
+ end
89
+
90
+ # Match object elements from JSON arrays.
91
+ #
92
+ # @param template_nodes [Array] Template nodes
93
+ # @param dest_nodes [Array] Destination nodes
94
+ # @return [Array<MatchResult>]
95
+ def match_array_objects(template_nodes, dest_nodes)
96
+ template_objects = template_nodes.select { |n| object_node?(n) && !pair_node?(n) }
97
+ dest_objects = dest_nodes.select { |n| object_node?(n) && !pair_node?(n) }
98
+
99
+ return [] if template_objects.empty? || dest_objects.empty?
100
+
101
+ greedy_match(template_objects, dest_objects) do |t_node, d_node|
102
+ compute_object_similarity(t_node, d_node)
103
+ end
104
+ end
105
+
106
+ # Check if a node is a key-value pair.
107
+ #
108
+ # @param node [Object] Node to check
109
+ # @return [Boolean]
110
+ def pair_node?(node)
111
+ return false unless node.respond_to?(:pair?)
112
+
113
+ node.pair?
114
+ end
115
+
116
+ # Check if a node is a JSON object.
117
+ #
118
+ # @param node [Object] Node to check
119
+ # @return [Boolean]
120
+ def object_node?(node)
121
+ return false unless node.respond_to?(:object?)
122
+
123
+ node.object?
124
+ end
125
+
126
+ # Compute similarity score between two key-value pairs.
127
+ #
128
+ # @param t_pair [NodeWrapper] Template pair
129
+ # @param d_pair [NodeWrapper] Destination pair
130
+ # @return [Float] Similarity score (0.0-1.0)
131
+ def compute_pair_similarity(t_pair, d_pair)
132
+ t_key = t_pair.key_name
133
+ d_key = d_pair.key_name
134
+
135
+ return 0.0 unless t_key && d_key
136
+
137
+ key_score = key_similarity(t_key, d_key)
138
+ value_score = value_similarity(t_pair.value_node, d_pair.value_node)
139
+
140
+ (key_score * key_weight) + (value_score * value_weight)
141
+ end
142
+
143
+ # Compute similarity score between two JSON objects.
144
+ #
145
+ # @param t_obj [NodeWrapper] Template object
146
+ # @param d_obj [NodeWrapper] Destination object
147
+ # @return [Float] Similarity score (0.0-1.0)
148
+ def compute_object_similarity(t_obj, d_obj)
149
+ t_keys = extract_keys(t_obj)
150
+ d_keys = extract_keys(d_obj)
151
+
152
+ return 1.0 if t_keys.empty? && d_keys.empty?
153
+ return 0.0 if t_keys.empty? || d_keys.empty?
154
+
155
+ # Compute key overlap
156
+ common_keys = (t_keys & d_keys).size
157
+ total_keys = (t_keys | d_keys).size
158
+ key_overlap = common_keys.to_f / total_keys
159
+
160
+ # Compute fuzzy key similarity for non-exact matches
161
+ fuzzy_score = compute_fuzzy_key_matches(t_keys - d_keys, d_keys - t_keys)
162
+
163
+ # Combine exact overlap and fuzzy matching
164
+ (key_overlap * 0.7) + (fuzzy_score * 0.3)
165
+ end
166
+
167
+ # Compute fuzzy matches between two sets of keys.
168
+ #
169
+ # @param keys1 [Array<String>] First set of keys
170
+ # @param keys2 [Array<String>] Second set of keys
171
+ # @return [Float] Fuzzy match score (0.0-1.0)
172
+ def compute_fuzzy_key_matches(keys1, keys2)
173
+ return 1.0 if keys1.empty? && keys2.empty?
174
+ return 0.0 if keys1.empty? || keys2.empty?
175
+
176
+ total_similarity = 0.0
177
+ keys1.each do |k1|
178
+ best_match = keys2.map { |k2| key_similarity(k1, k2) }.max || 0.0
179
+ total_similarity += best_match
180
+ end
181
+
182
+ total_similarity / keys1.size
183
+ end
184
+
185
+ # Extract keys from a JSON object.
186
+ #
187
+ # @param obj [NodeWrapper] Object node
188
+ # @return [Array<String>] Keys
189
+ def extract_keys(obj)
190
+ return [] unless obj.respond_to?(:pairs)
191
+
192
+ obj.pairs.map(&:key_name).compact
193
+ end
194
+
195
+ # Compute similarity between two keys.
196
+ #
197
+ # @param key1 [String] First key
198
+ # @param key2 [String] Second key
199
+ # @return [Float] Key similarity (0.0-1.0)
200
+ def key_similarity(key1, key2)
201
+ return 1.0 if key1 == key2
202
+
203
+ str1 = normalize_key(key1.to_s)
204
+ str2 = normalize_key(key2.to_s)
205
+
206
+ string_similarity(str1, str2)
207
+ end
208
+
209
+ # Normalize a key for comparison.
210
+ # Converts to lowercase and normalizes common naming conventions.
211
+ #
212
+ # @param key [String] Key to normalize
213
+ # @return [String] Normalized key
214
+ def normalize_key(key)
215
+ # Convert camelCase to snake_case first, then normalize
216
+ key.gsub(/([A-Z])/) { "_#{::Regexp.last_match(1).downcase}" }
217
+ .downcase
218
+ .gsub(/[-_]/, '')
219
+ end
220
+
221
+ # Compute similarity between two values.
222
+ #
223
+ # @param t_value [NodeWrapper, nil] Template value
224
+ # @param d_value [NodeWrapper, nil] Destination value
225
+ # @return [Float] Value similarity (0.0-1.0)
226
+ def value_similarity(t_value, d_value)
227
+ return 0.5 unless t_value && d_value
228
+
229
+ # Check if they're the same type
230
+ return 0.0 unless same_value_type?(t_value, d_value)
231
+
232
+ if t_value.string?
233
+ # Compare string values
234
+ t_text = extract_string_value(t_value)
235
+ d_text = extract_string_value(d_value)
236
+ string_similarity(t_text, d_text)
237
+ elsif t_value.object?
238
+ # Compare object structures
239
+ compute_object_similarity(t_value, d_value)
240
+ elsif t_value.array?
241
+ # Compare array lengths
242
+ array_similarity(t_value, d_value)
243
+ else
244
+ # Same type, consider similar
245
+ 0.5
246
+ end
247
+ end
248
+
249
+ # Check if two values have the same JSON type.
250
+ #
251
+ # @param val1 [NodeWrapper] First value
252
+ # @param val2 [NodeWrapper] Second value
253
+ # @return [Boolean]
254
+ def same_value_type?(val1, val2)
255
+ val1.type == val2.type
256
+ end
257
+
258
+ # Extract string content from a string node.
259
+ #
260
+ # @param node [NodeWrapper] String node
261
+ # @return [String]
262
+ def extract_string_value(node)
263
+ # String nodes include quotes, remove them
264
+ text = node.respond_to?(:text) ? node.text : ''
265
+ text.gsub(/\A"|"\z/, '')
266
+ end
267
+
268
+ # Compute similarity between two arrays.
269
+ #
270
+ # @param arr1 [NodeWrapper] First array
271
+ # @param arr2 [NodeWrapper] Second array
272
+ # @return [Float] Array similarity (0.0-1.0)
273
+ def array_similarity(arr1, arr2)
274
+ len1 = arr1.respond_to?(:elements) ? arr1.elements.size : 0
275
+ len2 = arr2.respond_to?(:elements) ? arr2.elements.size : 0
276
+
277
+ return 1.0 if len1.zero? && len2.zero?
278
+ return 0.0 if len1.zero? || len2.zero?
279
+
280
+ [len1, len2].min.to_f / [len1, len2].max
281
+ end
282
+
283
+ # Compute string similarity using Levenshtein distance.
284
+ #
285
+ # @param str1 [String] First string
286
+ # @param str2 [String] Second string
287
+ # @return [Float] Similarity score (0.0-1.0)
288
+ def string_similarity(str1, str2)
289
+ return 1.0 if str1 == str2
290
+ return 0.0 if str1.to_s.empty? || str2.to_s.empty?
291
+
292
+ distance = levenshtein_distance(str1.to_s, str2.to_s)
293
+ max_len = [str1.to_s.length, str2.to_s.length].max
294
+
295
+ 1.0 - (distance.to_f / max_len)
296
+ end
297
+
298
+ # Compute Levenshtein distance between two strings.
299
+ #
300
+ # Uses Wagner-Fischer algorithm with O(min(m,n)) space.
301
+ #
302
+ # @param str1 [String] First string
303
+ # @param str2 [String] Second string
304
+ # @return [Integer] Edit distance
305
+ def levenshtein_distance(str1, str2)
306
+ return str2.length if str1.empty?
307
+ return str1.length if str2.empty?
308
+
309
+ # Ensure str1 is the shorter string for space optimization
310
+ str1, str2 = str2, str1 if str1.length > str2.length
311
+
312
+ m = str1.length
313
+ n = str2.length
314
+
315
+ # Use two rows instead of full matrix
316
+ prev_row = (0..m).to_a
317
+ curr_row = Array.new(m + 1, 0)
318
+
319
+ (1..n).each do |j|
320
+ curr_row[0] = j
321
+
322
+ (1..m).each do |i|
323
+ cost = str1[i - 1] == str2[j - 1] ? 0 : 1
324
+ curr_row[i] = [
325
+ prev_row[i] + 1, # deletion
326
+ curr_row[i - 1] + 1, # insertion
327
+ prev_row[i - 1] + cost # substitution
328
+ ].min
329
+ end
330
+
331
+ prev_row, curr_row = curr_row, prev_row
332
+ end
333
+
334
+ prev_row[m]
335
+ end
336
+ end
337
+ end
338
+ end
@@ -0,0 +1,276 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Json
4
+ module Merge
5
+ # High-level merger for JSON / JSONC content.
6
+ #
7
+ # @example Basic usage
8
+ # merger = SmartMerger.new(template_content, dest_content)
9
+ # result = merger.merge
10
+ # File.write("merged.json", result.output)
11
+ #
12
+ # @example With options
13
+ # merger = SmartMerger.new(template, dest,
14
+ # preference: :template,
15
+ # add_template_only_nodes: true)
16
+ # result = merger.merge
17
+ #
18
+ # @example Enable fuzzy matching
19
+ # merger = SmartMerger.new(template, dest, match_refiner: ObjectMatchRefiner.new)
20
+ #
21
+ # @example With regions (embedded content)
22
+ # merger = SmartMerger.new(template, dest,
23
+ # regions: [{ detector: SomeDetector.new, merger_class: SomeMerger }])
24
+ class SmartMerger < ::Ast::Merge::SmartMergerBase
25
+ include ::Ast::Merge::Runtime::RootSessionSupport
26
+
27
+ attr_reader :runtime_session, :corruption_handling, :dialect
28
+
29
+ # Creates a new SmartMerger
30
+ #
31
+ # @param template_content [String] Template JSON content
32
+ # @param dest_content [String] Destination JSON content
33
+ # @param signature_generator [Proc, nil] Custom signature generator
34
+ # @param preference [Symbol, Hash] :destination, :template, or per-type Hash
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
37
+ # @param freeze_token [String, nil] Token for freeze block markers
38
+ # @param match_refiner [#call, nil] Match refiner for fuzzy matching
39
+ # @param regions [Array<Hash>, nil] Region configurations for nested merging
40
+ # @param region_placeholder [String, nil] Custom placeholder for regions
41
+ # @param node_typing [Hash{Symbol,String => #call}, nil] Node typing configuration
42
+ # for per-node-type merge preferences
43
+ # @param options [Hash] Additional options for forward compatibility
44
+ def initialize(
45
+ template_content,
46
+ dest_content,
47
+ signature_generator: nil,
48
+ preference: :destination,
49
+ add_template_only_nodes: false,
50
+ remove_template_missing_nodes: false,
51
+ corruption_handling: :heal,
52
+ freeze_token: nil,
53
+ match_refiner: nil,
54
+ regions: nil,
55
+ region_placeholder: nil,
56
+ node_typing: nil,
57
+ merge_arrays: true,
58
+ preserve_atomic_formatting: false,
59
+ dialect: :jsonc,
60
+ **options
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
+
71
+ super(
72
+ template_content,
73
+ dest_content,
74
+ signature_generator: signature_generator,
75
+ preference: preference,
76
+ add_template_only_nodes: add_template_only_nodes,
77
+ remove_template_missing_nodes: remove_template_missing_nodes,
78
+ freeze_token: freeze_token,
79
+ match_refiner: match_refiner,
80
+ regions: regions,
81
+ region_placeholder: region_placeholder,
82
+ node_typing: node_typing,
83
+ merge_arrays: merge_arrays,
84
+ preserve_atomic_formatting: preserve_atomic_formatting,
85
+ dialect: @dialect,
86
+ **options
87
+ )
88
+ end
89
+
90
+ # Backward-compatible options hash
91
+ #
92
+ # @return [Hash] The merge options
93
+ def options
94
+ {
95
+ preference: @preference,
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,
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
155
+ }
156
+ end
157
+
158
+ protected
159
+
160
+ # @return [Class] The analysis class for JSON files
161
+ def analysis_class
162
+ FileAnalysis
163
+ end
164
+
165
+ # @return [String] The default freeze token (not used for JSON)
166
+ def default_freeze_token
167
+ 'json-merge'
168
+ end
169
+
170
+ # @return [Class] The resolver class for JSON files
171
+ def resolver_class
172
+ ConflictResolver
173
+ end
174
+
175
+ # @return [Class] The result class for JSON files
176
+ def result_class
177
+ MergeResult
178
+ end
179
+
180
+ # Perform the JSON-specific merge
181
+ #
182
+ # @return [MergeResult] The merge result
183
+ def perform_merge
184
+ @resolver.resolve(@result)
185
+
186
+ DebugLogger.debug('Merge complete', {
187
+ lines: @result.line_count,
188
+ decisions: @result.statistics
189
+ })
190
+
191
+ @result
192
+ end
193
+
194
+ # Build the resolver with JSON-specific signature
195
+ def build_resolver
196
+ ConflictResolver.new(
197
+ @template_analysis,
198
+ @dest_analysis,
199
+ preference: @preference,
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,
204
+ match_refiner: @match_refiner,
205
+ node_typing: @node_typing,
206
+ merge_arrays: @merge_arrays,
207
+ preserve_atomic_formatting: @preserve_atomic_formatting
208
+ )
209
+ end
210
+
211
+ # Build the result (no-arg constructor for JSON)
212
+ def build_result
213
+ MergeResult.new
214
+ end
215
+
216
+ # @return [Class] The template parse error class for JSON
217
+ def template_parse_error_class
218
+ TemplateParseError
219
+ end
220
+
221
+ # @return [Class] The destination parse error class for JSON
222
+ def destination_parse_error_class
223
+ DestinationParseError
224
+ end
225
+
226
+ private
227
+
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
+ )
273
+ end
274
+ end
275
+ end
276
+ end
@@ -2,10 +2,12 @@
2
2
 
3
3
  module Json
4
4
  module Merge
5
+ # Version namespace for this gem.
5
6
  module Version
6
- VERSION = "7.0.0"
7
+ # Current gem version.
8
+ VERSION = '7.1.1'
7
9
  end
8
-
9
- VERSION = Version::VERSION
10
+ # Current gem version exposed at the traditional constant location.
11
+ VERSION = Version::VERSION # Traditional Constant Location
10
12
  end
11
13
  end