json-merge 7.0.0 → 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 +4 -4
- checksums.yaml.gz.sig +0 -0
- data/LICENSE.md +13 -0
- data/README.md +600 -0
- data/lib/json/merge/comment_tracker.rb +27 -0
- data/lib/json/merge/conflict_resolver.rb +1379 -0
- data/lib/json/merge/debug_logger.rb +41 -0
- data/lib/json/merge/emitter.rb +227 -0
- data/lib/json/merge/file_analysis.rb +355 -0
- data/lib/json/merge/freeze_node.rb +62 -0
- data/lib/json/merge/merge_result.rb +156 -0
- data/lib/json/merge/node_wrapper.rb +258 -0
- data/lib/json/merge/object_match_refiner.rb +338 -0
- data/lib/json/merge/provider.rb +696 -0
- data/lib/json/merge/smart_merger.rb +276 -0
- data/lib/json/merge/source_locator.rb +68 -0
- data/lib/json/merge/three_way_decision.rb +168 -0
- data/lib/json/merge/version.rb +5 -3
- data/lib/json/merge.rb +393 -266
- data/lib/json-merge.rb +7 -1
- data/sig/json/merge.rbs +54 -0
- data.tar.gz.sig +0 -0
- metadata +264 -15
- metadata.gz.sig +0 -0
|
@@ -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
|