json-merge 7.1.1 → 7.1.3

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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 8464cc00eee820f30fa56722aba993b0f661a3521c185da21e90035cc9dc967f
4
- data.tar.gz: b70e0d898ba77416611c0b58280b68ec08431119c07ca154cf6e0e565b086b37
3
+ metadata.gz: ff5d2c1fa09edee00c444aedc35377b040f6dc6e07ae160e31243ffa07ebbe70
4
+ data.tar.gz: 8601741a2313c127bb861081460a4ae6e817f247f804e78721a162dfeeb2010b
5
5
  SHA512:
6
- metadata.gz: cd3daa1695fff2cb7fab9287253f714efc90a5eb4502d6d545394326c44fa151e507375cb302bb0ddce1e998287c348d7feaa724b2aaf8835a6e175639dfe35c
7
- data.tar.gz: 4314f63422ccb37ecceb31d576d9593195a1f2ace7ba0ddc019eb96419841a52ae816732ec208d1776465c4ee961b4cdf363d8b21e5735ebb25061413a4a0361
6
+ metadata.gz: ce19e05de329325e569cca1b0fbb80a4933ff2b8a690bcabf8d68111202793f46fc16f6c1434c01497b607d1e5d1440bb13d4e923ab4b1f941a08faf99706037
7
+ data.tar.gz: 0eba7ac69972809823b4478640a66e49b068829a830066fb9c00465298cba8d0b9c23b7cfb4ebd04e582f3aa0f086289fe84efaa4da777d7b4c3026df7d3199d
checksums.yaml.gz.sig CHANGED
Binary file
data/README.md CHANGED
@@ -107,7 +107,7 @@ File.write("merged.json", result.to_json)
107
107
 
108
108
  ### Compatibility
109
109
 
110
- Compatible with MRI Ruby 4.0.0+, and concordant releases of JRuby, and TruffleRuby.
110
+ Compatible with MRI Ruby 4.0.0+, JRuby, and TruffleRuby.
111
111
  CI workflows and Appraisals are generated for MRI Ruby 4.0.0+.
112
112
  This test floor is configured by `ruby.test_minimum` in `.kettle-jem.yml` and
113
113
  may be higher than the gem's runtime compatibility floor when legacy Rubies are
@@ -433,20 +433,12 @@ See [SECURITY.md][🔐security].
433
433
  ## 🤝 Contributing
434
434
 
435
435
  If you need some ideas of where to help, you could work on adding more code coverage,
436
- or if it is already 💯 (see [below](#code-coverage)) check [issues][🤝gh-issues] or [PRs][🤝gh-pulls],
437
- or use the gem and think about how it could be better.
436
+ check [issues][🤝gh-issues] or [PRs][🤝gh-pulls], or use the gem and think about how it could be better.
438
437
 
439
438
  We [![Keep A Changelog][📗keep-changelog-img]][📗keep-changelog] so if you make changes, remember to update it.
440
439
 
441
440
  See [CONTRIBUTING.md][🤝contributing] for more detailed instructions.
442
441
 
443
- ### Code Coverage
444
-
445
- <details markdown="1">
446
- <summary>Coverage service badges</summary>
447
-
448
- </details>
449
-
450
442
  ## 📌 Versioning
451
443
 
452
444
  This library follows [![Semantic Versioning 2.0.0][📌semver-img]][📌semver] for its public API where practical.
@@ -0,0 +1,696 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Json
4
+ module Merge
5
+ # Portable JSON-family provider for Ast::Merge dispatch.
6
+ # rubocop:disable Metrics/AbcSize, Metrics/ClassLength, Metrics/CyclomaticComplexity, Metrics/MethodLength, Metrics/ParameterLists, Metrics/PerceivedComplexity -- provider operations keep request, rendering, and result-envelope logic together
7
+ class Provider
8
+ DIALECTS = %i[json jsonc json5].freeze
9
+ DEFAULT_PROFILE = :source_preserving
10
+
11
+ def provider_id
12
+ 'ruby.json'
13
+ end
14
+
15
+ def family
16
+ 'json'
17
+ end
18
+
19
+ def capabilities
20
+ {
21
+ operations: Ast::Merge::ProviderContract::OPERATIONS,
22
+ dialects: DIALECTS,
23
+ backends: [Json::Merge::TREE_SITTER_BACKEND.id.to_sym],
24
+ profiles: [DEFAULT_PROFILE],
25
+ role: :workflow,
26
+ source_preservation: %i[exact_source family_synthesis line_provenance reparse]
27
+ }.freeze
28
+ end
29
+
30
+ def analyze(request)
31
+ dialect = dialect_for(request)
32
+ parsed = Json::Merge.parse_json(request.fetch(:source), dialect, backend: request[:backend])
33
+ return parse_failure(:analyze, :source, parsed, request) unless parsed[:ok]
34
+
35
+ result(
36
+ :analyze,
37
+ request,
38
+ analysis: parsed.fetch(:analysis),
39
+ verification: { source_parsed: true }
40
+ )
41
+ end
42
+
43
+ def diff2(request)
44
+ before = parse_role!(:diff2, request, :before)
45
+ return before if provider_failure?(before)
46
+
47
+ after = parse_role!(:diff2, request, :after)
48
+ return after if provider_failure?(after)
49
+
50
+ changes = diff_values(before, after, path: '')
51
+ result(
52
+ :diff2,
53
+ request,
54
+ diff: { changes: changes, before: before, after: after },
55
+ changes: changes,
56
+ verification: { before_parsed: true, after_parsed: true }
57
+ )
58
+ end
59
+
60
+ def merge2(request)
61
+ dialect = dialect_for(request)
62
+ merged = Json::Merge.merge_json(
63
+ request.fetch(:incoming_source),
64
+ request.fetch(:current_source),
65
+ dialect,
66
+ backend: request[:backend]
67
+ )
68
+ return merge2_failure(merged, request) unless merged[:ok]
69
+
70
+ output = merged.fetch(:output)
71
+ verified = parse_output(output, request)
72
+ result(
73
+ :merge2,
74
+ request,
75
+ output: output,
76
+ changes: [],
77
+ render_report: { strategy: :json_smart_merger, synthesis: :family_emission },
78
+ verification: { output_reparsed: true, semantic_value: verified }
79
+ )
80
+ rescue Json::Merge::ParseError => e
81
+ failure(
82
+ :merge2,
83
+ request,
84
+ category: :render_failure,
85
+ message: "JSON family emitter produced invalid output: #{e.message}"
86
+ )
87
+ end
88
+
89
+ def merge3(request)
90
+ values = parse_merge3_roles(request)
91
+ return values if provider_failure?(values)
92
+
93
+ decision = ThreeWayDecision.new.call(**values)
94
+ rendered = render_merge3(decision, request, values)
95
+ return rendered if provider_failure?(rendered)
96
+
97
+ result(
98
+ :merge3,
99
+ request,
100
+ **rendered,
101
+ changes: decision.changes,
102
+ conflicts: decision.conflicts,
103
+ verification: rendered.fetch(:verification).merge(base_participated: true)
104
+ )
105
+ end
106
+
107
+ private
108
+
109
+ def parse_merge3_roles(request)
110
+ %i[base ours theirs].each_with_object({}) do |role, values|
111
+ parsed = parse_role!(:merge3, request, role)
112
+ return parsed if provider_failure?(parsed)
113
+
114
+ values[role] = parsed
115
+ end
116
+ end
117
+
118
+ def parse_role!(operation, request, role)
119
+ Json::Merge.json_value_for_source(
120
+ request.fetch(:"#{role}_source"),
121
+ dialect: dialect_for(request),
122
+ backend: request[:backend]
123
+ )
124
+ rescue Json::Merge::ParseError => e
125
+ failure(
126
+ operation,
127
+ request,
128
+ category: :parse_error,
129
+ message: "#{role} parse error: #{e.message}",
130
+ source_role: role
131
+ )
132
+ end
133
+
134
+ def render_merge3(decision, request, values)
135
+ return render_conflict(request, decision) if decision.conflicted?
136
+
137
+ exact_role = %i[ours theirs base].find { |role| values.fetch(role) == decision.value }
138
+ return render_exact_role(exact_role, request, decision) if exact_role
139
+
140
+ render_composite(request, decision)
141
+ end
142
+
143
+ def diff_values(before, after, path:)
144
+ return [] if before == after
145
+ return diff_objects(before, after, path: path) if before.is_a?(Hash) && after.is_a?(Hash)
146
+
147
+ [{ path: path, ours: :unchanged, theirs: :edited }.freeze]
148
+ end
149
+
150
+ def diff_objects(before, after, path:)
151
+ ordered_diff_keys(before, after).flat_map do |key|
152
+ child_path = "#{path}/#{escape_path_segment(key)}"
153
+ if !before.key?(key)
154
+ [{ path: child_path, ours: :unchanged, theirs: :added }.freeze]
155
+ elsif !after.key?(key)
156
+ [{ path: child_path, ours: :unchanged, theirs: :deleted }.freeze]
157
+ else
158
+ diff_values(before.fetch(key), after.fetch(key), path: child_path)
159
+ end
160
+ end.freeze
161
+ end
162
+
163
+ def ordered_diff_keys(before, after)
164
+ [*before.keys, *after.keys].uniq
165
+ end
166
+
167
+ def escape_path_segment(key)
168
+ key.to_s.gsub('~', '~0').gsub('/', '~1')
169
+ end
170
+
171
+ def render_exact_role(role, request, decision)
172
+ rendered = render_plan(
173
+ request,
174
+ [
175
+ source_fragment(role, request.fetch(:"#{role}_source"))
176
+ ]
177
+ )
178
+ {
179
+ output: rendered.content,
180
+ render_report: render_report(rendered, strategy: :exact_revision),
181
+ verification: verify_rendered(rendered.content, decision.value, request)
182
+ }
183
+ end
184
+
185
+ def render_composite(request, decision)
186
+ candidate = Json::Merge.merge_json(
187
+ request.fetch(:theirs_source),
188
+ request.fetch(:ours_source),
189
+ dialect_for(request),
190
+ backend: request[:backend]
191
+ )
192
+ emitted = verified_family_composite(candidate, request, decision)
193
+ return emitted if emitted
194
+
195
+ exact = render_exact_composite(request, decision)
196
+ return exact if exact
197
+
198
+ composite_failure(request, 'JSON family renderers did not reproduce the planned three-way value.')
199
+ end
200
+
201
+ def verified_family_composite(candidate, request, decision)
202
+ return unless candidate[:ok]
203
+
204
+ output = candidate.fetch(:output)
205
+ verification = verify_rendered(output, decision.value, request)
206
+ return unless verification[:semantic_match]
207
+
208
+ rendered = render_plan(
209
+ request,
210
+ [
211
+ Ast::Merge::SourceRender::SynthesizedFragment.new(
212
+ content: output,
213
+ reason: :family_emission,
214
+ producer: provider_id
215
+ )
216
+ ]
217
+ )
218
+ {
219
+ output: rendered.content,
220
+ render_report: render_report(rendered, strategy: :family_composite),
221
+ verification: verification
222
+ }
223
+ end
224
+
225
+ def render_exact_composite(request, decision)
226
+ replacements = exact_composite_replacements(request, decision)
227
+ return unless replacements
228
+
229
+ rendered = render_plan(request, replacement_fragments(request, replacements))
230
+ verification = verify_rendered(rendered.content, decision.value, request)
231
+ return unless verification[:semantic_match]
232
+
233
+ {
234
+ output: rendered.content,
235
+ render_report: render_report(rendered, strategy: :exact_owner_composite),
236
+ fallbacks: [
237
+ {
238
+ from: :family_composite,
239
+ to: :exact_owner_composite,
240
+ reason: :semantic_verification_failed
241
+ }
242
+ ],
243
+ verification: verification
244
+ }
245
+ end
246
+
247
+ def exact_composite_replacements(request, decision)
248
+ locators = conflict_locators(request)
249
+ changes = decision.changes.select do |change|
250
+ change[:ours] == :unchanged && change[:theirs] != :unchanged
251
+ end
252
+ replacements = []
253
+ until changes.empty?
254
+ change = changes.shift
255
+ ours_range = locators.fetch(:ours).pair_range(change.fetch(:path))
256
+ return unless ours_range
257
+
258
+ theirs_range = if change[:theirs] == :deleted
259
+ nil
260
+ else
261
+ locators.fetch(:theirs).pair_range(change.fetch(:path))
262
+ end
263
+ return if change[:theirs] != :deleted && theirs_range.nil?
264
+
265
+ replacements << { ours: ours_range, theirs: theirs_range }
266
+ end
267
+ return unless non_overlapping_ours_ranges?(replacements)
268
+
269
+ replacements
270
+ end
271
+
272
+ def replacement_fragments(request, replacements)
273
+ fragments = []
274
+ cursor = 1
275
+ replacements.sort_by { |item| item.fetch(:ours).start_line }.each do |item|
276
+ ours_range = item.fetch(:ours)
277
+ fragments << source_range_fragment(:ours, cursor, ours_range.start_line - 1) if cursor < ours_range.start_line
278
+ theirs_range = item[:theirs]
279
+ fragments << source_range_fragment(:theirs, theirs_range.start_line, theirs_range.end_line) if theirs_range
280
+ cursor = ours_range.end_line + 1
281
+ end
282
+ final_line = request.fetch(:ours_source).lines.length
283
+ fragments << source_range_fragment(:ours, cursor, final_line) if cursor <= final_line
284
+ fragments
285
+ end
286
+
287
+ def render_conflict(request, decision)
288
+ localized = localized_conflict_plan(request, decision)
289
+ return localized if localized
290
+
291
+ synthesized = synthesized_conflict_plan(request, decision)
292
+ return synthesized if synthesized
293
+
294
+ render_full_file_conflict(request, decision)
295
+ end
296
+
297
+ def localized_conflict_plan(request, decision)
298
+ ranges = localized_conflict_ranges(request, decision)
299
+ return if ranges.nil?
300
+
301
+ rendered = render_plan(request, localized_conflict_fragments(request, decision, ranges))
302
+ conflict_failure(request, decision, rendered, strategy: :owner_localized_conflict)
303
+ end
304
+
305
+ def localized_conflict_ranges(request, decision)
306
+ locators = conflict_locators(request)
307
+ ranges = decision.conflicts.map do |conflict|
308
+ conflict_ranges(conflict, locators)
309
+ end
310
+ return if ranges.any?(&:nil?)
311
+ return unless non_overlapping_ours_ranges?(ranges)
312
+
313
+ ranges
314
+ end
315
+
316
+ def synthesized_conflict_plan(request, decision)
317
+ candidate = Json::Merge.merge_json(
318
+ request.fetch(:theirs_source),
319
+ request.fetch(:ours_source),
320
+ dialect_for(request),
321
+ backend: request[:backend]
322
+ )
323
+ return unless candidate[:ok]
324
+ return unless verify_rendered(candidate.fetch(:output), decision.value, request)[:semantic_match]
325
+
326
+ ranges = synthesized_conflict_ranges(request, decision, candidate.fetch(:output))
327
+ return unless ranges
328
+
329
+ rendered = render_plan(
330
+ request,
331
+ synthesized_conflict_fragments(request, decision, candidate.fetch(:output), ranges)
332
+ )
333
+ conflict_failure(
334
+ request,
335
+ decision,
336
+ rendered,
337
+ strategy: :synthesized_owner_localized_conflict,
338
+ fallbacks: [
339
+ {
340
+ from: :exact_owner_localization,
341
+ to: :synthesized_conflict_context,
342
+ reason: :missing_side_owner
343
+ }
344
+ ]
345
+ )
346
+ end
347
+
348
+ def synthesized_conflict_ranges(request, decision, candidate)
349
+ locators = conflict_locators(request)
350
+ candidate_locator = SourceLocator.new(
351
+ candidate,
352
+ dialect: dialect_for(request),
353
+ backend: request[:backend]
354
+ )
355
+ conflicts = decision.conflicts.dup
356
+ ranges = []
357
+ until conflicts.empty?
358
+ conflict = conflicts.shift
359
+ sides = conflict_source_ranges(conflict, locators)
360
+ context = candidate_locator.pair_range(conflict.fetch(:path))
361
+ return unless sides && context
362
+
363
+ ranges << sides.merge(context: context)
364
+ end
365
+ return unless non_overlapping_ranges?(ranges.map { |item| item.fetch(:context) })
366
+
367
+ ranges
368
+ end
369
+
370
+ def conflict_source_ranges(conflict, locators)
371
+ ranges = {}
372
+ roles = %i[base ours theirs]
373
+ until roles.empty?
374
+ role = roles.shift
375
+ state = conflict.fetch(role)
376
+ range = state.fetch(:present) ? locators.fetch(role).pair_range(conflict.fetch(:path)) : nil
377
+ return if state.fetch(:present) && range.nil?
378
+
379
+ ranges[role] = range
380
+ end
381
+ ranges
382
+ end
383
+
384
+ def synthesized_conflict_fragments(request, decision, candidate, ranges)
385
+ fragments = []
386
+ cursor = 1
387
+ decision.conflicts
388
+ .zip(ranges)
389
+ .sort_by { |_conflict, item| item.fetch(:context).start_line }
390
+ .each do |conflict, item|
391
+ context = item.fetch(:context)
392
+ append_synthesized_context(fragments, candidate, cursor, context.start_line - 1)
393
+ fragments << conflict_fragment(request, conflict, item)
394
+ cursor = context.end_line + 1
395
+ end
396
+ append_synthesized_context(fragments, candidate, cursor, candidate.lines.length)
397
+ fragments
398
+ end
399
+
400
+ def append_synthesized_context(fragments, source, start_line, end_line)
401
+ return if start_line > end_line
402
+
403
+ content = source.lines.slice(start_line - 1, end_line - start_line + 1).join
404
+ fragments << Ast::Merge::SourceRender::SynthesizedFragment.new(
405
+ content: content,
406
+ reason: :conflict_context,
407
+ producer: provider_id,
408
+ metadata: { candidate_line_start: start_line }
409
+ )
410
+ end
411
+
412
+ def conflict_locators(request)
413
+ %i[base ours theirs].to_h do |role|
414
+ [
415
+ role,
416
+ SourceLocator.new(
417
+ request.fetch(:"#{role}_source"),
418
+ dialect: dialect_for(request),
419
+ backend: request[:backend]
420
+ )
421
+ ]
422
+ end
423
+ end
424
+
425
+ def conflict_ranges(conflict, locators)
426
+ ranges = {}
427
+ roles = %i[base ours theirs]
428
+ until roles.empty?
429
+ role = roles.shift
430
+ state = conflict.fetch(role)
431
+ range = state.fetch(:present) ? locators.fetch(role).pair_range(conflict.fetch(:path)) : nil
432
+ return if state.fetch(:present) && range.nil?
433
+
434
+ ranges[role] = range
435
+ end
436
+ return if ranges[:ours].nil?
437
+
438
+ ranges
439
+ end
440
+
441
+ def non_overlapping_ours_ranges?(ranges)
442
+ non_overlapping_ranges?(ranges.map { |item| item.fetch(:ours) })
443
+ end
444
+
445
+ def non_overlapping_ranges?(ranges)
446
+ ranges
447
+ .sort_by(&:start_line)
448
+ .each_cons(2)
449
+ .none? { |left, right| left.end_line >= right.start_line }
450
+ end
451
+
452
+ def localized_conflict_fragments(request, decision, ranges)
453
+ fragments = []
454
+ cursor = 1
455
+ decision.conflicts
456
+ .zip(ranges)
457
+ .sort_by { |_conflict, item| item.fetch(:ours).start_line }
458
+ .each do |conflict, item|
459
+ ours_range = item.fetch(:ours)
460
+ if cursor < ours_range.start_line
461
+ fragments << source_range_fragment(:ours, cursor, ours_range.start_line - 1)
462
+ end
463
+ fragments << conflict_fragment(request, conflict, item)
464
+ cursor = ours_range.end_line + 1
465
+ end
466
+ final_line = request.fetch(:ours_source).lines.length
467
+ fragments << source_range_fragment(:ours, cursor, final_line) if cursor <= final_line
468
+ fragments
469
+ end
470
+
471
+ def conflict_fragment(request, conflict, ranges)
472
+ Ast::Merge::SourceRender::ConflictFragment.new(
473
+ conflict_id: conflict.fetch(:conflict_id),
474
+ base: conflict_side_fragment(:base, ranges[:base]),
475
+ ours: conflict_side_fragment(:ours, ranges[:ours]),
476
+ theirs: conflict_side_fragment(:theirs, ranges[:theirs]),
477
+ labels: request.fetch(:labels, {}),
478
+ marker_size: request.fetch(:conflict_marker_size, 7),
479
+ metadata: { path: conflict.fetch(:path), category: conflict.fetch(:category) }
480
+ )
481
+ end
482
+
483
+ def conflict_side_fragment(role, range)
484
+ range ? [source_range_fragment(role, range.start_line, range.end_line)] : []
485
+ end
486
+
487
+ def source_range_fragment(role, start_line, end_line)
488
+ Ast::Merge::SourceRender::SourceFragment.new(
489
+ revision: role,
490
+ start_line: start_line,
491
+ end_line: end_line,
492
+ metadata: { source_role: role }
493
+ )
494
+ end
495
+
496
+ def render_full_file_conflict(request, decision)
497
+ rendered = render_plan(
498
+ request,
499
+ [
500
+ Ast::Merge::SourceRender::ConflictFragment.new(
501
+ conflict_id: decision.conflicts.first.fetch(:conflict_id),
502
+ base: [conflict_source_fragment(:base, request.fetch(:base_source))],
503
+ ours: [conflict_source_fragment(:ours, request.fetch(:ours_source))],
504
+ theirs: [conflict_source_fragment(:theirs, request.fetch(:theirs_source))],
505
+ labels: request.fetch(:labels, {}),
506
+ marker_size: request.fetch(:conflict_marker_size, 7),
507
+ metadata: { conflicts: decision.conflicts.map { |conflict| conflict[:conflict_id] } }
508
+ )
509
+ ]
510
+ )
511
+ conflict_failure(
512
+ request,
513
+ decision,
514
+ rendered,
515
+ strategy: :full_file_conflict,
516
+ fallbacks: [
517
+ {
518
+ from: :owner_localization,
519
+ to: :full_file_conflict,
520
+ reason: :owner_not_whole_line_addressable
521
+ }
522
+ ]
523
+ )
524
+ end
525
+
526
+ def conflict_failure(request, decision, rendered, strategy:, fallbacks: [])
527
+ failure(
528
+ :merge3,
529
+ request,
530
+ category: :merge_conflict,
531
+ message: "#{decision.conflicts.length} unresolved JSON merge conflict(s).",
532
+ changes: decision.changes,
533
+ conflicts: decision.conflicts,
534
+ fallbacks: fallbacks,
535
+ conflicted_output: rendered.content,
536
+ render_report: render_report(rendered, strategy: strategy)
537
+ )
538
+ end
539
+
540
+ def render_plan(request, fragments)
541
+ plan = Ast::Merge::SourceRender::Plan.new(
542
+ sources: {
543
+ base: request[:base_source].to_s,
544
+ ours: request[:ours_source].to_s,
545
+ theirs: request[:theirs_source].to_s
546
+ },
547
+ fragments: fragments,
548
+ metadata: { provider_id: provider_id, dialect: dialect_for(request) }
549
+ )
550
+ Ast::Merge::SourceRender::Renderer.new.render(plan)
551
+ end
552
+
553
+ def source_fragment(role, source)
554
+ line_count = source.lines.length
555
+ raise Ast::Merge::SourceRender::InvalidPlanError, "#{role} source has no renderable lines" if line_count.zero?
556
+
557
+ Ast::Merge::SourceRender::SourceFragment.new(
558
+ revision: role,
559
+ start_line: 1,
560
+ end_line: line_count,
561
+ metadata: { source_role: role }
562
+ )
563
+ end
564
+
565
+ def conflict_source_fragment(role, source)
566
+ return source_fragment(role, source) if source.end_with?("\n")
567
+
568
+ Ast::Merge::SourceRender::SynthesizedFragment.new(
569
+ content: "#{source}\n",
570
+ reason: :conflict_line_boundary,
571
+ producer: provider_id,
572
+ metadata: { source_role: role, copied_source: true }
573
+ )
574
+ end
575
+
576
+ def verify_rendered(output, expected_value, request)
577
+ actual = parse_output(output, request)
578
+ {
579
+ output_reparsed: true,
580
+ semantic_match: actual == expected_value
581
+ }
582
+ rescue Json::Merge::ParseError
583
+ {
584
+ output_reparsed: false,
585
+ semantic_match: false
586
+ }
587
+ end
588
+
589
+ def parse_output(output, request)
590
+ Json::Merge.json_value_for_source(
591
+ output,
592
+ dialect: dialect_for(request),
593
+ backend: request[:backend]
594
+ )
595
+ end
596
+
597
+ def result(
598
+ operation, request, verification:, changes: [], conflicts: [], fallbacks: [], render_report: {}, **payload
599
+ )
600
+ Ast::Merge::ProviderResult.build(
601
+ operation: operation,
602
+ success: true,
603
+ envelope: {
604
+ provider: provider_metadata(request),
605
+ profile: profile_metadata(request),
606
+ changes: changes,
607
+ conflicts: conflicts,
608
+ fallbacks: fallbacks,
609
+ render_report: render_report,
610
+ verification: verification
611
+ },
612
+ **payload
613
+ )
614
+ end
615
+
616
+ def failure(
617
+ operation, request, category:, message:, changes: [], conflicts: [], fallbacks: [], render_report: {}, **payload
618
+ )
619
+ Ast::Merge::ProviderResult.build(
620
+ operation: operation,
621
+ success: false,
622
+ envelope: {
623
+ provider: provider_metadata(request),
624
+ profile: profile_metadata(request),
625
+ diagnostics: [{ category: category, severity: :error, message: message, blocking: true }],
626
+ changes: changes,
627
+ conflicts: conflicts,
628
+ fallbacks: fallbacks,
629
+ render_report: render_report,
630
+ verification: { base_participated: operation == :merge3 }
631
+ },
632
+ **payload
633
+ )
634
+ end
635
+
636
+ def parse_failure(operation, role, parsed, request)
637
+ diagnostic = parsed.fetch(:diagnostics).first || {}
638
+ failure(
639
+ operation,
640
+ request,
641
+ category: :parse_error,
642
+ message: "#{role} parse error: #{diagnostic[:message]}",
643
+ source_role: role
644
+ )
645
+ end
646
+
647
+ def merge2_failure(merged, request)
648
+ diagnostic = merged.fetch(:diagnostics).first || {}
649
+ failure(
650
+ :merge2,
651
+ request,
652
+ category: diagnostic[:category] || :merge_failed,
653
+ message: diagnostic[:message] || 'JSON two-way merge failed.'
654
+ )
655
+ end
656
+
657
+ def composite_failure(request, message)
658
+ failure(:merge3, request, category: :render_failure, message: message)
659
+ end
660
+
661
+ def provider_failure?(value)
662
+ value.is_a?(Hash) && value[:ok] == false
663
+ end
664
+
665
+ def render_report(rendered, strategy:)
666
+ {
667
+ strategy: strategy,
668
+ line_records: rendered.line_records,
669
+ synthesized_fragments: rendered.synthesized_fragments,
670
+ conflicts: rendered.conflicts,
671
+ verification_input: rendered.verification_input
672
+ }
673
+ end
674
+
675
+ def provider_metadata(request)
676
+ {
677
+ provider_id: provider_id,
678
+ family: family,
679
+ dialect: dialect_for(request),
680
+ backend: request[:backend],
681
+ package: Json::Merge::PACKAGE_NAME,
682
+ package_version: Json::Merge::Version::VERSION
683
+ }.compact
684
+ end
685
+
686
+ def profile_metadata(request)
687
+ { profile_id: request[:profile_id] || DEFAULT_PROFILE }
688
+ end
689
+
690
+ def dialect_for(request)
691
+ (request[:dialect] || :json).to_sym
692
+ end
693
+ end
694
+ # rubocop:enable Metrics/AbcSize, Metrics/ClassLength, Metrics/CyclomaticComplexity, Metrics/MethodLength, Metrics/ParameterLists, Metrics/PerceivedComplexity
695
+ end
696
+ end
@@ -0,0 +1,68 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Json
4
+ module Merge
5
+ # Locates JSON object-pair owners that can be copied as whole source lines.
6
+ class SourceLocator
7
+ Range = Data.define(:start_line, :end_line)
8
+
9
+ def initialize(source, dialect:, backend: nil)
10
+ @source = source
11
+ @lines = source.lines
12
+ Json::Merge.register_backend!
13
+ backend_id = backend.to_s.empty? ? Json::Merge::TREE_SITTER_BACKEND.id : backend.to_s
14
+ @analysis = TreeHaver.with_backend(backend_id) do
15
+ FileAnalysis.new(source, dialect: dialect)
16
+ end
17
+ end
18
+
19
+ def pair_range(path)
20
+ pair = pair_for(path)
21
+ return unless pair
22
+ return unless whole_line_owner?(pair)
23
+
24
+ Range.new(start_line: pair.start_line, end_line: pair.end_line)
25
+ end
26
+
27
+ private
28
+
29
+ def pair_for(path)
30
+ current = @analysis.root_object
31
+ pair = nil
32
+ segments = pointer_segments(path)
33
+ until segments.empty?
34
+ segment = segments.shift
35
+ pair = pair_with_key(current, segment)
36
+ return unless pair
37
+
38
+ current = pair.value_node
39
+ end
40
+ pair
41
+ end
42
+
43
+ def pair_with_key(object, key)
44
+ return unless object&.object?
45
+
46
+ object.pairs.find { |candidate| candidate.key_name == key }
47
+ end
48
+
49
+ def pointer_segments(path)
50
+ return [] if path.to_s.empty?
51
+
52
+ path.to_s.split('/').drop(1).map do |segment|
53
+ segment.gsub('~1', '/').gsub('~0', '~')
54
+ end
55
+ end
56
+
57
+ def whole_line_owner?(pair)
58
+ start_line = pair.start_line
59
+ end_line = pair.end_line
60
+ return false unless start_line && end_line
61
+ return false unless @lines.fetch(end_line - 1, '').end_with?("\n")
62
+
63
+ start_column = pair.node.start_point.fetch(:column)
64
+ @lines.fetch(start_line - 1, '').slice(0, start_column).to_s.strip.empty?
65
+ end
66
+ end
67
+ end
68
+ end
@@ -0,0 +1,168 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Json
4
+ module Merge
5
+ # Computes base-aware JSON-family semantic decisions without rendering.
6
+ # rubocop:disable Metrics/ClassLength -- recursive decisions and classifications form one algorithm
7
+ class ThreeWayDecision
8
+ State = Data.define(:present, :value) do
9
+ def self.present(value)
10
+ new(present: true, value: value)
11
+ end
12
+
13
+ def self.missing
14
+ @missing ||= new(present: false, value: nil)
15
+ end
16
+
17
+ def to_h
18
+ present ? { present: true, value: value } : { present: false }
19
+ end
20
+ end
21
+
22
+ Result = Data.define(:state, :conflicts, :changes) do
23
+ def present?
24
+ state.present
25
+ end
26
+
27
+ def value
28
+ state.value
29
+ end
30
+
31
+ def conflicted?
32
+ !conflicts.empty?
33
+ end
34
+ end
35
+
36
+ def call(base:, ours:, theirs:)
37
+ merge_states(
38
+ State.present(base),
39
+ State.present(ours),
40
+ State.present(theirs),
41
+ path: ''
42
+ )
43
+ end
44
+
45
+ private
46
+
47
+ def merge_states(base, ours, theirs, path:)
48
+ return success(ours) if ours == theirs
49
+ return success(theirs, change: classify(path, base, ours, theirs)) if base == ours
50
+ return success(ours, change: classify(path, base, ours, theirs)) if base == theirs
51
+ return merge_added_objects(ours, theirs, path: path) if added_object_states?(base, ours, theirs)
52
+ return merge_objects(base, ours, theirs, path: path) if object_states?(base, ours, theirs)
53
+
54
+ conflict(base, ours, theirs, path: path)
55
+ end
56
+
57
+ def merge_objects(base, ours, theirs, path:)
58
+ aggregate = { value: {}, conflicts: [], changes: [] }
59
+ states = [base, ours, theirs]
60
+ ordered_keys(base.value, ours.value, theirs.value).each do |key|
61
+ merge_object_key(aggregate, key, states, path: path)
62
+ end
63
+
64
+ Result.new(
65
+ state: State.present(aggregate[:value]),
66
+ conflicts: aggregate[:conflicts].freeze,
67
+ changes: aggregate[:changes].freeze
68
+ )
69
+ end
70
+
71
+ def merge_added_objects(ours, theirs, path:)
72
+ merge_objects(State.present({}), ours, theirs, path: path)
73
+ end
74
+
75
+ def merge_object_key(aggregate, key, states, path:)
76
+ child_states = states.map { |state| state_for(state.value, key) }
77
+ child = merge_states(*child_states, path: child_path(path, key))
78
+ aggregate[:value][key] = child.value if child.present?
79
+ aggregate[:conflicts].concat(child.conflicts)
80
+ aggregate[:changes].concat(child.changes)
81
+ end
82
+
83
+ def success(state, change: nil)
84
+ Result.new(
85
+ state: state,
86
+ conflicts: [].freeze,
87
+ changes: change ? [change].freeze : [].freeze
88
+ )
89
+ end
90
+
91
+ def conflict(base, ours, theirs, path:)
92
+ classification = classify(path, base, ours, theirs)
93
+ selected = ours.present ? ours : theirs
94
+ Result.new(
95
+ state: selected,
96
+ conflicts: [conflict_payload(base, ours, theirs, path, classification)].freeze,
97
+ changes: [classification].freeze
98
+ )
99
+ end
100
+
101
+ def conflict_payload(base, ours, theirs, path, classification)
102
+ {
103
+ conflict_id: conflict_id(path),
104
+ category: conflict_category(ours, theirs),
105
+ path: path,
106
+ base: base.to_h,
107
+ ours: ours.to_h,
108
+ theirs: theirs.to_h,
109
+ change_classification: classification
110
+ }.freeze
111
+ end
112
+
113
+ def classify(path, base, ours, theirs)
114
+ {
115
+ path: path,
116
+ ours: change_state(base, ours),
117
+ theirs: change_state(base, theirs)
118
+ }.freeze
119
+ end
120
+
121
+ def change_state(base, side)
122
+ return :unchanged if base == side
123
+ return :added unless base.present
124
+ return :deleted unless side.present
125
+
126
+ :edited
127
+ end
128
+
129
+ def conflict_category(ours, theirs)
130
+ ours.present && theirs.present ? :edit_edit : :delete_edit
131
+ end
132
+
133
+ def object_states?(*states)
134
+ states.all? { |state| state.present && state.value.is_a?(Hash) }
135
+ end
136
+
137
+ def added_object_states?(base, ours, theirs)
138
+ !base.present &&
139
+ ours.present && ours.value.is_a?(Hash) &&
140
+ theirs.present && theirs.value.is_a?(Hash)
141
+ end
142
+
143
+ def state_for(object, key)
144
+ object.key?(key) ? State.present(object.fetch(key)) : State.missing
145
+ end
146
+
147
+ def ordered_keys(*objects)
148
+ objects.each_with_object([]) do |object, keys|
149
+ object.each_key { |key| keys << key unless keys.include?(key) }
150
+ end
151
+ end
152
+
153
+ def child_path(path, key)
154
+ "#{path}/#{escape_path_segment(key)}"
155
+ end
156
+
157
+ def escape_path_segment(key)
158
+ key.to_s.gsub('~', '~0').gsub('/', '~1')
159
+ end
160
+
161
+ def conflict_id(path)
162
+ suffix = path.delete_prefix('/').gsub(/[^a-zA-Z0-9_-]+/, '-')
163
+ "json-conflict-#{suffix.empty? ? 'root' : suffix}"
164
+ end
165
+ end
166
+ # rubocop:enable Metrics/ClassLength
167
+ end
168
+ end
@@ -5,7 +5,7 @@ module Json
5
5
  # Version namespace for this gem.
6
6
  module Version
7
7
  # Current gem version.
8
- VERSION = '7.1.1'
8
+ VERSION = '7.1.3'
9
9
  end
10
10
  # Current gem version exposed at the traditional constant location.
11
11
  VERSION = Version::VERSION # Traditional Constant Location
data/lib/json/merge.rb CHANGED
@@ -38,6 +38,9 @@ module Json
38
38
  autoload :ConflictResolver, 'json/merge/conflict_resolver'
39
39
  autoload :SmartMerger, 'json/merge/smart_merger'
40
40
  autoload :ObjectMatchRefiner, 'json/merge/object_match_refiner'
41
+ autoload :Provider, 'json/merge/provider'
42
+ autoload :SourceLocator, 'json/merge/source_locator'
43
+ autoload :ThreeWayDecision, 'json/merge/three_way_decision'
41
44
 
42
45
  class << self
43
46
  def register_backend!
@@ -67,6 +70,14 @@ module Json
67
70
  }
68
71
  end
69
72
 
73
+ def merge_provider
74
+ @merge_provider ||= Provider.new
75
+ end
76
+
77
+ def register_provider!(replace: false)
78
+ Ast::Merge.register_provider(merge_provider, replace: replace)
79
+ end
80
+
70
81
  def available_json_backends
71
82
  json_backend_available_for_analysis?(TREE_SITTER_BACKEND.id) ? [TREE_SITTER_BACKEND] : []
72
83
  end
@@ -510,6 +521,7 @@ module Json
510
521
  end
511
522
 
512
523
  Json::Merge.register_backend!
524
+ Json::Merge.register_provider!
513
525
 
514
526
  if defined?(Ast::Merge::RSpec::MergeGemRegistry)
515
527
  Ast::Merge::RSpec::MergeGemRegistry.register(
data/sig/json/merge.rbs CHANGED
@@ -1,5 +1,51 @@
1
1
  module Json
2
2
  module Merge
3
+ class Provider
4
+ def provider_id: () -> String
5
+ def family: () -> String
6
+ def capabilities: () -> Ast::Merge::provider_capabilities
7
+ def analyze: (Ast::Merge::provider_request request) -> Ast::Merge::provider_result
8
+ def diff2: (Ast::Merge::provider_request request) -> Ast::Merge::provider_result
9
+ def merge2: (Ast::Merge::provider_request request) -> Ast::Merge::provider_result
10
+ def merge3: (Ast::Merge::provider_request request) -> Ast::Merge::provider_result
11
+ end
12
+
13
+ class SourceLocator
14
+ class Range < Data
15
+ attr_reader start_line: Integer
16
+ attr_reader end_line: Integer
17
+ end
18
+
19
+ def initialize: (String source, dialect: Symbol, ?backend: String | Symbol?) -> void
20
+ def pair_range: (String path) -> Range?
21
+ end
22
+
23
+ def self.merge_provider: () -> Provider
24
+ def self.register_provider!: (?replace: bool) -> Ast::Merge::_MergeProvider
25
+
26
+ class ThreeWayDecision
27
+ class State < Data
28
+ attr_reader present: bool
29
+ attr_reader value: untyped
30
+
31
+ def self.present: (untyped value) -> State
32
+ def self.missing: () -> State
33
+ def to_h: () -> Hash[Symbol, untyped]
34
+ end
35
+
36
+ class Result < Data
37
+ attr_reader state: State
38
+ attr_reader conflicts: Array[Hash[Symbol, untyped]]
39
+ attr_reader changes: Array[Hash[Symbol, untyped]]
40
+
41
+ def present?: () -> bool
42
+ def value: () -> untyped
43
+ def conflicted?: () -> bool
44
+ end
45
+
46
+ def call: (base: untyped, ours: untyped, theirs: untyped) -> Result
47
+ end
48
+
3
49
  module Version
4
50
  VERSION: String
5
51
  end
data.tar.gz.sig CHANGED
Binary file
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: json-merge
3
3
  version: !ruby/object:Gem::Version
4
- version: 7.1.1
4
+ version: 7.1.3
5
5
  platform: ruby
6
6
  authors:
7
7
  - Peter H. Boling
@@ -43,28 +43,28 @@ dependencies:
43
43
  requirements:
44
44
  - - '='
45
45
  - !ruby/object:Gem::Version
46
- version: 7.1.1
46
+ version: 7.1.3
47
47
  type: :runtime
48
48
  prerelease: false
49
49
  version_requirements: !ruby/object:Gem::Requirement
50
50
  requirements:
51
51
  - - '='
52
52
  - !ruby/object:Gem::Version
53
- version: 7.1.1
53
+ version: 7.1.3
54
54
  - !ruby/object:Gem::Dependency
55
55
  name: tree_haver
56
56
  requirement: !ruby/object:Gem::Requirement
57
57
  requirements:
58
58
  - - '='
59
59
  - !ruby/object:Gem::Version
60
- version: 7.1.1
60
+ version: 7.1.3
61
61
  type: :runtime
62
62
  prerelease: false
63
63
  version_requirements: !ruby/object:Gem::Requirement
64
64
  requirements:
65
65
  - - '='
66
66
  - !ruby/object:Gem::Version
67
- version: 7.1.1
67
+ version: 7.1.3
68
68
  - !ruby/object:Gem::Dependency
69
69
  name: version_gem
70
70
  requirement: !ruby/object:Gem::Requirement
@@ -91,20 +91,20 @@ dependencies:
91
91
  requirements:
92
92
  - - "~>"
93
93
  - !ruby/object:Gem::Version
94
- version: '2.5'
94
+ version: '3.0'
95
95
  - - ">="
96
96
  - !ruby/object:Gem::Version
97
- version: 2.5.17
97
+ version: 3.0.0
98
98
  type: :development
99
99
  prerelease: false
100
100
  version_requirements: !ruby/object:Gem::Requirement
101
101
  requirements:
102
102
  - - "~>"
103
103
  - !ruby/object:Gem::Version
104
- version: '2.5'
104
+ version: '3.0'
105
105
  - - ">="
106
106
  - !ruby/object:Gem::Version
107
- version: 2.5.17
107
+ version: 3.0.0
108
108
  - !ruby/object:Gem::Dependency
109
109
  name: bundler-audit
110
110
  requirement: !ruby/object:Gem::Requirement
@@ -182,7 +182,7 @@ dependencies:
182
182
  version: '3.2'
183
183
  - - ">="
184
184
  - !ruby/object:Gem::Version
185
- version: 3.2.0
185
+ version: 3.2.2
186
186
  type: :development
187
187
  prerelease: false
188
188
  version_requirements: !ruby/object:Gem::Requirement
@@ -192,7 +192,7 @@ dependencies:
192
192
  version: '3.2'
193
193
  - - ">="
194
194
  - !ruby/object:Gem::Version
195
- version: 3.2.0
195
+ version: 3.2.2
196
196
  - !ruby/object:Gem::Dependency
197
197
  name: kettle-test
198
198
  requirement: !ruby/object:Gem::Requirement
@@ -222,7 +222,7 @@ dependencies:
222
222
  version: '3.2'
223
223
  - - ">="
224
224
  - !ruby/object:Gem::Version
225
- version: 3.2.4
225
+ version: 3.2.5
226
226
  type: :development
227
227
  prerelease: false
228
228
  version_requirements: !ruby/object:Gem::Requirement
@@ -232,7 +232,7 @@ dependencies:
232
232
  version: '3.2'
233
233
  - - ">="
234
234
  - !ruby/object:Gem::Version
235
- version: 3.2.4
235
+ version: 3.2.5
236
236
  - !ruby/object:Gem::Dependency
237
237
  name: ruby-progressbar
238
238
  requirement: !ruby/object:Gem::Requirement
@@ -308,7 +308,10 @@ files:
308
308
  - lib/json/merge/merge_result.rb
309
309
  - lib/json/merge/node_wrapper.rb
310
310
  - lib/json/merge/object_match_refiner.rb
311
+ - lib/json/merge/provider.rb
311
312
  - lib/json/merge/smart_merger.rb
313
+ - lib/json/merge/source_locator.rb
314
+ - lib/json/merge/three_way_decision.rb
312
315
  - lib/json/merge/version.rb
313
316
  - sig/json/merge.rbs
314
317
  homepage: https://github.com/structuredmerge/structuredmerge-ruby
@@ -317,10 +320,10 @@ licenses:
317
320
  - PolyForm-Small-Business-1.0.0
318
321
  metadata:
319
322
  homepage_uri: https://structuredmerge.org
320
- source_code_uri: https://github.com/structuredmerge/structuredmerge-ruby/tree/v7.1.1
321
- changelog_uri: https://github.com/structuredmerge/structuredmerge-ruby/blob/v7.1.1/CHANGELOG.md
323
+ source_code_uri: https://github.com/structuredmerge/structuredmerge-ruby/tree/v7.1.3
324
+ changelog_uri: https://github.com/structuredmerge/structuredmerge-ruby/blob/v7.1.3/CHANGELOG.md
322
325
  bug_tracker_uri: https://github.com/structuredmerge/structuredmerge-ruby/issues
323
- documentation_uri: https://www.rubydoc.info/gems/json-merge/7.1.1
326
+ documentation_uri: https://www.rubydoc.info/gems/json-merge/7.1.3
324
327
  funding_uri: https://github.com/sponsors/pboling
325
328
  wiki_uri: https://github.com/structuredmerge/structuredmerge-ruby/wiki
326
329
  news_uri: https://www.railsbling.com/tags/json-merge
metadata.gz.sig CHANGED
Binary file