bash-merge 7.1.1 → 7.1.4
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/README.md +3 -11
- data/lib/bash/merge/node_wrapper.rb +27 -0
- data/lib/bash/merge/provider.rb +1040 -0
- data/lib/bash/merge/test_harness_identity.rb +63 -0
- data/lib/bash/merge/version.rb +1 -1
- data/lib/bash/merge.rb +4 -0
- data/sig/bash/merge.rbs +22 -0
- data.tar.gz.sig +0 -0
- metadata +22 -20
- metadata.gz.sig +0 -0
|
@@ -0,0 +1,1040 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'digest'
|
|
4
|
+
require 'json'
|
|
5
|
+
require 'ast/merge/source_render'
|
|
6
|
+
require_relative 'node_wrapper'
|
|
7
|
+
require_relative 'file_analysis'
|
|
8
|
+
require_relative 'test_harness_identity'
|
|
9
|
+
|
|
10
|
+
module Bash
|
|
11
|
+
# Registration and source-preserving workflow implementation for Bash.
|
|
12
|
+
module Merge
|
|
13
|
+
class << self
|
|
14
|
+
def merge_provider
|
|
15
|
+
@merge_provider ||= Provider.new
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
def register_provider!(replace: false)
|
|
19
|
+
return unless Ast::Merge.respond_to?(:register_provider)
|
|
20
|
+
|
|
21
|
+
Ast::Merge.register_provider(merge_provider, replace: replace)
|
|
22
|
+
end
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
# Base-aware, source-preserving provider for native top-level Bash owners.
|
|
26
|
+
# Functions, assignments, and literal-title test_expect_success calls have
|
|
27
|
+
# stable AST identities. Every other top-level form must remain identical
|
|
28
|
+
# across all revisions in a composite.
|
|
29
|
+
# rubocop:disable Metrics/AbcSize, Metrics/ClassLength, Metrics/CyclomaticComplexity, Metrics/MethodLength, Metrics/ParameterLists, Metrics/PerceivedComplexity -- provider decisions, source plans, and verification form one boundary
|
|
30
|
+
class Provider
|
|
31
|
+
DEFAULT_PROFILE = :source_preserving
|
|
32
|
+
Owner = Data.define(:id, :signature, :membership, :compound, :fingerprint, :start_line, :end_line, :role)
|
|
33
|
+
Document = Data.define(:source, :analysis, :owners, :by_id, :unmanaged_fingerprint)
|
|
34
|
+
ExactDocument = Data.define(:source, :analysis, :declarations, :issues, :role)
|
|
35
|
+
Decision = Data.define(:changes, :conflicts, :choices)
|
|
36
|
+
|
|
37
|
+
def provider_id = 'ruby.bash'
|
|
38
|
+
def family = 'bash'
|
|
39
|
+
|
|
40
|
+
def capabilities
|
|
41
|
+
{
|
|
42
|
+
operations: Ast::Merge::ProviderContract::OPERATIONS,
|
|
43
|
+
dialects: %i[bash],
|
|
44
|
+
backends: [TREE_SITTER_BACKEND.id.to_sym],
|
|
45
|
+
profiles: [DEFAULT_PROFILE],
|
|
46
|
+
role: :workflow,
|
|
47
|
+
ast_ownership: :stable_functions_assignments_and_literal_test_titles,
|
|
48
|
+
source_preservation: %i[exact_source declaration_fragments line_provenance reparse semantic_verification]
|
|
49
|
+
}.freeze
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
def analyze(request)
|
|
53
|
+
document = parse_document(:analyze, request, :source)
|
|
54
|
+
return document if provider_failure?(document)
|
|
55
|
+
|
|
56
|
+
result(
|
|
57
|
+
:analyze,
|
|
58
|
+
request,
|
|
59
|
+
analysis: {
|
|
60
|
+
backend: TREE_SITTER_BACKEND.id,
|
|
61
|
+
valid: true,
|
|
62
|
+
declarations: document.owners.map { |owner| owner_description(owner) }
|
|
63
|
+
},
|
|
64
|
+
verification: { source_parsed: true }
|
|
65
|
+
)
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
def diff2(request)
|
|
69
|
+
before = parse_document(:diff2, request, :before)
|
|
70
|
+
return before if provider_failure?(before)
|
|
71
|
+
|
|
72
|
+
after = parse_document(:diff2, request, :after)
|
|
73
|
+
return after if provider_failure?(after)
|
|
74
|
+
|
|
75
|
+
changes = diff_documents(before, after)
|
|
76
|
+
result(
|
|
77
|
+
:diff2,
|
|
78
|
+
request,
|
|
79
|
+
diff: { changes: changes },
|
|
80
|
+
changes: changes,
|
|
81
|
+
verification: { before_parsed: true, after_parsed: true }
|
|
82
|
+
)
|
|
83
|
+
end
|
|
84
|
+
|
|
85
|
+
def merge2(request)
|
|
86
|
+
merge3_request = request.merge(
|
|
87
|
+
base_source: request.fetch(:current_source),
|
|
88
|
+
ours_source: request.fetch(:current_source),
|
|
89
|
+
theirs_source: request.fetch(:incoming_source)
|
|
90
|
+
)
|
|
91
|
+
merged = merge3(merge3_request)
|
|
92
|
+
merged.merge(
|
|
93
|
+
operation: :merge2,
|
|
94
|
+
verification: merged.fetch(:verification).except(:base_participated)
|
|
95
|
+
)
|
|
96
|
+
end
|
|
97
|
+
|
|
98
|
+
def merge3(request)
|
|
99
|
+
exact_role = exact_revision_role(request)
|
|
100
|
+
return merge3_exact(request, exact_role) if exact_role
|
|
101
|
+
|
|
102
|
+
documents = parse_merge3_documents(request)
|
|
103
|
+
return documents if provider_failure?(documents)
|
|
104
|
+
|
|
105
|
+
ownership_failure = unstable_ownership_failure(request, documents)
|
|
106
|
+
return ownership_failure if ownership_failure
|
|
107
|
+
|
|
108
|
+
ownership_failure = test_harness_ownership_failure(request, documents)
|
|
109
|
+
return ownership_failure if ownership_failure
|
|
110
|
+
|
|
111
|
+
decision = decide(documents, include_unmanaged_conflict: true)
|
|
112
|
+
return render_conflicts(request, documents, decision) unless decision.conflicts.empty?
|
|
113
|
+
|
|
114
|
+
render_composite(request, documents, decision)
|
|
115
|
+
end
|
|
116
|
+
|
|
117
|
+
private
|
|
118
|
+
|
|
119
|
+
def parse_merge3_documents(request)
|
|
120
|
+
%i[base ours theirs].each_with_object({}) do |role, documents|
|
|
121
|
+
source = request.fetch(:"#{role}_source")
|
|
122
|
+
document = parse_source(source, role, request[:dialect])
|
|
123
|
+
if document.is_a?(Hash) && document[:parse_error]
|
|
124
|
+
return parse_failure(:merge3, request, role, document)
|
|
125
|
+
elsif provider_failure?(document)
|
|
126
|
+
return unsafe_document_failure(request, role, document)
|
|
127
|
+
end
|
|
128
|
+
|
|
129
|
+
documents[role] = document
|
|
130
|
+
end
|
|
131
|
+
end
|
|
132
|
+
|
|
133
|
+
def parse_document(operation, request, role)
|
|
134
|
+
source = request.fetch(role == :source ? :source : :"#{role}_source")
|
|
135
|
+
parsed = parse_source(source, role, request[:dialect])
|
|
136
|
+
return parse_failure(operation, request, role, parsed) if provider_failure?(parsed)
|
|
137
|
+
|
|
138
|
+
parsed
|
|
139
|
+
end
|
|
140
|
+
|
|
141
|
+
def parse_source(source, role, _dialect = nil)
|
|
142
|
+
analysis = FileAnalysis.new(source)
|
|
143
|
+
return { parse_error: analysis.errors.map(&:to_s).join('; '), source_role: role } unless analysis.valid?
|
|
144
|
+
|
|
145
|
+
unstable_occurrences = Hash.new(0)
|
|
146
|
+
owners = analysis.top_level_statements.map do |wrapper|
|
|
147
|
+
return { unsafe_range: :heredoc, source_role: role } if wrapper.heredoc?
|
|
148
|
+
return { unsafe_range: wrapper.signature, source_role: role } unless safe_range?(source, wrapper)
|
|
149
|
+
|
|
150
|
+
signature = provider_signature(wrapper)
|
|
151
|
+
return { unsafe_range: wrapper.type, source_role: role } unless signature
|
|
152
|
+
|
|
153
|
+
occurrence = if stable_signature?(signature)
|
|
154
|
+
nil
|
|
155
|
+
else
|
|
156
|
+
unstable_occurrences[signature] += 1
|
|
157
|
+
end
|
|
158
|
+
Owner.new(
|
|
159
|
+
id: owner_id(occurrence ? [signature, occurrence] : signature),
|
|
160
|
+
signature: signature,
|
|
161
|
+
membership: signature,
|
|
162
|
+
compound: false,
|
|
163
|
+
fingerprint: owner_fingerprint(wrapper),
|
|
164
|
+
start_line: wrapper.start_line,
|
|
165
|
+
end_line: wrapper.end_line,
|
|
166
|
+
role: role
|
|
167
|
+
)
|
|
168
|
+
end
|
|
169
|
+
duplicate = owners.group_by(&:id).find { |_id, matches| matches.length > 1 }
|
|
170
|
+
return { ambiguous_owner: duplicate.first, source_role: role } if duplicate
|
|
171
|
+
|
|
172
|
+
return { unsafe_range: :overlapping_declarations, source_role: role } unless non_overlapping?(owners)
|
|
173
|
+
|
|
174
|
+
Document.new(
|
|
175
|
+
source: source,
|
|
176
|
+
analysis: analysis,
|
|
177
|
+
owners: owners.freeze,
|
|
178
|
+
by_id: owners.to_h { |owner| [owner.id, owner] }.freeze,
|
|
179
|
+
unmanaged_fingerprint: unmanaged_fingerprint(source, owners)
|
|
180
|
+
)
|
|
181
|
+
rescue StandardError => e
|
|
182
|
+
{ parse_error: e.message, source_role: role }
|
|
183
|
+
end
|
|
184
|
+
|
|
185
|
+
def parse_exact_source(source, role, _dialect = nil)
|
|
186
|
+
analysis = FileAnalysis.new(source)
|
|
187
|
+
return { parse_error: analysis.errors.map(&:to_s).join('; '), source_role: role } unless analysis.valid?
|
|
188
|
+
|
|
189
|
+
declarations = analysis.top_level_statements.map do |wrapper|
|
|
190
|
+
[provider_signature(wrapper), wrapper.semantic_tree]
|
|
191
|
+
end.freeze
|
|
192
|
+
duplicate_keys = declarations.group_by(&:first).filter_map do |signature, matches|
|
|
193
|
+
signature if matches.length > 1
|
|
194
|
+
end
|
|
195
|
+
issues = duplicate_keys.uniq.map do |identity|
|
|
196
|
+
{
|
|
197
|
+
category: :ambiguous_owner,
|
|
198
|
+
declaration: identity,
|
|
199
|
+
message: duplicate_message(identity)
|
|
200
|
+
}.freeze
|
|
201
|
+
end
|
|
202
|
+
ExactDocument.new(
|
|
203
|
+
source: source,
|
|
204
|
+
analysis: analysis,
|
|
205
|
+
declarations: declarations,
|
|
206
|
+
issues: issues.freeze,
|
|
207
|
+
role: role
|
|
208
|
+
)
|
|
209
|
+
rescue StandardError => e
|
|
210
|
+
{ parse_error: e.message, source_role: role }
|
|
211
|
+
end
|
|
212
|
+
|
|
213
|
+
def provider_signature(wrapper)
|
|
214
|
+
if wrapper.function_definition?
|
|
215
|
+
name = wrapper.function_name
|
|
216
|
+
name && [:function, name]
|
|
217
|
+
elsif wrapper.variable_assignment?
|
|
218
|
+
name = wrapper.variable_name
|
|
219
|
+
name && [:variable_assignment, name]
|
|
220
|
+
elsif (identity = TestHarnessIdentity.for(wrapper))
|
|
221
|
+
[:test_harness_call, *identity]
|
|
222
|
+
else
|
|
223
|
+
wrapper.signature
|
|
224
|
+
end
|
|
225
|
+
end
|
|
226
|
+
|
|
227
|
+
def safe_range?(source, wrapper)
|
|
228
|
+
return false if wrapper.start_byte.negative? || wrapper.end_byte > source.bytesize
|
|
229
|
+
return false if wrapper.end_byte < wrapper.start_byte
|
|
230
|
+
|
|
231
|
+
line_start = source.rindex("\n", [wrapper.start_byte - 1, 0].max)
|
|
232
|
+
line_start = line_start ? line_start + 1 : 0
|
|
233
|
+
line_end = source.index("\n", wrapper.end_byte) || source.bytesize
|
|
234
|
+
horizontal_whitespace?(source.byteslice(line_start...wrapper.start_byte).to_s) &&
|
|
235
|
+
horizontal_whitespace?(source.byteslice(wrapper.end_byte...line_end).to_s)
|
|
236
|
+
end
|
|
237
|
+
|
|
238
|
+
def horizontal_whitespace?(value)
|
|
239
|
+
value.each_byte.all? { |byte| [9, 13, 32].include?(byte) }
|
|
240
|
+
end
|
|
241
|
+
|
|
242
|
+
def non_overlapping?(owners)
|
|
243
|
+
owners.each_cons(2).all? { |left, right| left.end_line < right.start_line }
|
|
244
|
+
end
|
|
245
|
+
|
|
246
|
+
def unmanaged_fingerprint(source, owners)
|
|
247
|
+
owned_lines = owners.each_with_object({}) do |owner, lines|
|
|
248
|
+
(owner.start_line..owner.end_line).each { |line| lines[line] = true }
|
|
249
|
+
end
|
|
250
|
+
source.lines.each_with_index.filter_map do |line, index|
|
|
251
|
+
line unless owned_lines[index + 1]
|
|
252
|
+
end.join
|
|
253
|
+
end
|
|
254
|
+
|
|
255
|
+
def owner_fingerprint(wrapper)
|
|
256
|
+
Digest::SHA256.hexdigest(
|
|
257
|
+
JSON.generate(Ast::Merge.json_ready([wrapper.semantic_tree, wrapper.source_text]))
|
|
258
|
+
)
|
|
259
|
+
end
|
|
260
|
+
|
|
261
|
+
def owner_id(signature)
|
|
262
|
+
Digest::SHA256.hexdigest(JSON.generate(Ast::Merge.json_ready(signature)))
|
|
263
|
+
end
|
|
264
|
+
|
|
265
|
+
def owner_path(owner)
|
|
266
|
+
owner ? owner.signature.inspect : '<document>'
|
|
267
|
+
end
|
|
268
|
+
|
|
269
|
+
def owner_description(owner)
|
|
270
|
+
{
|
|
271
|
+
path: owner_path(owner),
|
|
272
|
+
signature: owner.signature,
|
|
273
|
+
source_role: owner.role,
|
|
274
|
+
line_range: [owner.start_line, owner.end_line]
|
|
275
|
+
}
|
|
276
|
+
end
|
|
277
|
+
|
|
278
|
+
def diff_documents(before, after)
|
|
279
|
+
ordered_ids(before, after).filter_map do |id|
|
|
280
|
+
left = before.by_id[id]
|
|
281
|
+
right = after.by_id[id]
|
|
282
|
+
next if equivalent?(left, right)
|
|
283
|
+
|
|
284
|
+
{
|
|
285
|
+
path: owner_path(left || right),
|
|
286
|
+
before: change_side(left),
|
|
287
|
+
after: change_side(right),
|
|
288
|
+
change: change_kind(left, right)
|
|
289
|
+
}.freeze
|
|
290
|
+
end.freeze
|
|
291
|
+
end
|
|
292
|
+
|
|
293
|
+
def change_side(owner)
|
|
294
|
+
return { present: false } unless owner
|
|
295
|
+
|
|
296
|
+
{ present: true, source_role: owner.role, line_range: [owner.start_line, owner.end_line] }
|
|
297
|
+
end
|
|
298
|
+
|
|
299
|
+
def ordered_ids(*documents)
|
|
300
|
+
documents.flat_map { |document| document.owners.map(&:id) }.uniq
|
|
301
|
+
end
|
|
302
|
+
|
|
303
|
+
def change_kind(before, after)
|
|
304
|
+
return :added unless before
|
|
305
|
+
return :deleted unless after
|
|
306
|
+
|
|
307
|
+
:edited
|
|
308
|
+
end
|
|
309
|
+
|
|
310
|
+
def decide(documents, include_unmanaged_conflict:)
|
|
311
|
+
changes = []
|
|
312
|
+
conflicts = []
|
|
313
|
+
choices = {}
|
|
314
|
+
append_unmanaged_conflict(changes, conflicts, documents) if include_unmanaged_conflict
|
|
315
|
+
ordered_ids(*documents.values).each do |id|
|
|
316
|
+
base = documents.fetch(:base).by_id[id]
|
|
317
|
+
ours = documents.fetch(:ours).by_id[id]
|
|
318
|
+
theirs = documents.fetch(:theirs).by_id[id]
|
|
319
|
+
ours_change = side_change(base, ours)
|
|
320
|
+
theirs_change = side_change(base, theirs)
|
|
321
|
+
next choices[id] = :ours if ours_change == :unchanged && theirs_change == :unchanged
|
|
322
|
+
|
|
323
|
+
change = {
|
|
324
|
+
path: owner_path(base || ours || theirs),
|
|
325
|
+
ours: ours_change,
|
|
326
|
+
theirs: theirs_change
|
|
327
|
+
}.freeze
|
|
328
|
+
changes << change
|
|
329
|
+
choice = owner_choice(base, ours, theirs)
|
|
330
|
+
if choice == :conflict
|
|
331
|
+
conflicts << conflict_for(id, base, ours, theirs, change)
|
|
332
|
+
else
|
|
333
|
+
choices[id] = choice
|
|
334
|
+
end
|
|
335
|
+
end
|
|
336
|
+
Decision.new(changes: changes.freeze, conflicts: conflicts.freeze, choices: choices.freeze)
|
|
337
|
+
end
|
|
338
|
+
|
|
339
|
+
def append_unmanaged_conflict(changes, conflicts, documents)
|
|
340
|
+
base = documents.fetch(:base).unmanaged_fingerprint
|
|
341
|
+
ours = documents.fetch(:ours).unmanaged_fingerprint
|
|
342
|
+
theirs = documents.fetch(:theirs).unmanaged_fingerprint
|
|
343
|
+
return if ours == theirs
|
|
344
|
+
|
|
345
|
+
change = {
|
|
346
|
+
path: '<unmanaged-source>',
|
|
347
|
+
ours: base == ours ? :unchanged : :edited,
|
|
348
|
+
theirs: base == theirs ? :unchanged : :edited
|
|
349
|
+
}.freeze
|
|
350
|
+
changes << change
|
|
351
|
+
conflicts << {
|
|
352
|
+
conflict_id: "bash-unmanaged-#{Digest::SHA256.hexdigest([base, ours, theirs].join("\0"))[0, 16]}",
|
|
353
|
+
category: :unmanaged_source_change,
|
|
354
|
+
path: change.fetch(:path),
|
|
355
|
+
owner_id: nil,
|
|
356
|
+
change_classification: change
|
|
357
|
+
}.freeze
|
|
358
|
+
end
|
|
359
|
+
|
|
360
|
+
def unstable_ownership_failure(request, documents)
|
|
361
|
+
sequences = documents.transform_values do |document|
|
|
362
|
+
document.owners.filter_map do |owner|
|
|
363
|
+
[owner.signature, owner.fingerprint] unless stable_signature?(owner.signature)
|
|
364
|
+
end
|
|
365
|
+
end
|
|
366
|
+
return if sequences.values.uniq.one?
|
|
367
|
+
|
|
368
|
+
mismatch_index = (0...sequences.values.map(&:length).max).find do |index|
|
|
369
|
+
sequences.values.map { |sequence| sequence[index] }.uniq.length > 1
|
|
370
|
+
end
|
|
371
|
+
unstable = documents.values.filter_map do |document|
|
|
372
|
+
unstable_owners = document.owners.reject { |owner| stable_signature?(owner.signature) }
|
|
373
|
+
unstable_owners[mismatch_index]
|
|
374
|
+
end.first
|
|
375
|
+
identity = sequences.transform_values { |sequence| sequence[mismatch_index] }
|
|
376
|
+
signature_digest = Digest::SHA256.hexdigest(JSON.generate(Ast::Merge.json_ready(sequences)))
|
|
377
|
+
conflict = {
|
|
378
|
+
conflict_id: "bash-ownership-#{signature_digest[0, 16]}",
|
|
379
|
+
category: :unproven_ast_ownership,
|
|
380
|
+
path: owner_path(unstable),
|
|
381
|
+
owner_id: nil,
|
|
382
|
+
signature: unstable&.signature,
|
|
383
|
+
unstable_index: mismatch_index,
|
|
384
|
+
identities: identity
|
|
385
|
+
}.freeze
|
|
386
|
+
decision = Decision.new(changes: [], conflicts: [conflict], choices: {})
|
|
387
|
+
rendered = render_plan(request, [whole_document_conflict(request, decision)])
|
|
388
|
+
failure(
|
|
389
|
+
:merge3,
|
|
390
|
+
request,
|
|
391
|
+
category: :unproven_ast_ownership,
|
|
392
|
+
message: 'A non-stable Bash top-level AST identity changed; whole-file fallback is required.',
|
|
393
|
+
conflicts: [conflict],
|
|
394
|
+
conflicted_output: rendered.content,
|
|
395
|
+
render_report: render_report(rendered, :full_file_conflict),
|
|
396
|
+
verification: { base_participated: true },
|
|
397
|
+
fallbacks: [{
|
|
398
|
+
from: :top_level_ast_ownership,
|
|
399
|
+
to: :full_file_conflict,
|
|
400
|
+
reason: :identity_changed
|
|
401
|
+
}]
|
|
402
|
+
)
|
|
403
|
+
end
|
|
404
|
+
|
|
405
|
+
def stable_signature?(signature)
|
|
406
|
+
%i[function variable_assignment test_harness_call].include?(signature.first)
|
|
407
|
+
end
|
|
408
|
+
|
|
409
|
+
def test_harness_ownership_failure(request, documents)
|
|
410
|
+
sequences = documents.transform_values do |document|
|
|
411
|
+
document.owners.filter_map do |owner|
|
|
412
|
+
owner.id if owner.signature.first == :test_harness_call
|
|
413
|
+
end
|
|
414
|
+
end
|
|
415
|
+
base = sequences.fetch(:base)
|
|
416
|
+
invalid_role = %i[ours theirs].find do |role|
|
|
417
|
+
sequence = sequences.fetch(role)
|
|
418
|
+
sequence.length < base.length || sequence.first(base.length) != base
|
|
419
|
+
end
|
|
420
|
+
changed_role = %i[ours theirs].find { |role| sequences.fetch(role) != base }
|
|
421
|
+
return unless invalid_role || changed_role
|
|
422
|
+
|
|
423
|
+
reason = if invalid_role
|
|
424
|
+
:non_append_only_test_harness_change
|
|
425
|
+
else
|
|
426
|
+
:test_harness_addition_requires_ordered_rendering
|
|
427
|
+
end
|
|
428
|
+
source_role = invalid_role || changed_role
|
|
429
|
+
|
|
430
|
+
signature_digest = Digest::SHA256.hexdigest(JSON.generate(Ast::Merge.json_ready(sequences)))
|
|
431
|
+
conflict = {
|
|
432
|
+
conflict_id: "bash-test-ownership-#{signature_digest[0, 16]}",
|
|
433
|
+
category: :unproven_ast_ownership,
|
|
434
|
+
path: '<test_expect_success-sequence>',
|
|
435
|
+
owner_id: nil,
|
|
436
|
+
source_role: source_role,
|
|
437
|
+
identities: sequences,
|
|
438
|
+
reason: reason
|
|
439
|
+
}.freeze
|
|
440
|
+
decision = Decision.new(changes: [], conflicts: [conflict], choices: {})
|
|
441
|
+
rendered = render_plan(request, [whole_document_conflict(request, decision)])
|
|
442
|
+
failure(
|
|
443
|
+
:merge3,
|
|
444
|
+
request,
|
|
445
|
+
category: :unproven_ast_ownership,
|
|
446
|
+
message: 'Literal-title test_expect_success membership changes require ordered insertion rendering.',
|
|
447
|
+
conflicts: [conflict],
|
|
448
|
+
conflicted_output: rendered.content,
|
|
449
|
+
render_report: render_report(rendered, :full_file_conflict),
|
|
450
|
+
verification: { base_participated: true },
|
|
451
|
+
fallbacks: [{
|
|
452
|
+
from: :test_harness_ast_ownership,
|
|
453
|
+
to: :full_file_conflict,
|
|
454
|
+
reason: reason
|
|
455
|
+
}]
|
|
456
|
+
)
|
|
457
|
+
end
|
|
458
|
+
|
|
459
|
+
def side_change(base, side)
|
|
460
|
+
return :unchanged if equivalent?(base, side)
|
|
461
|
+
return :added if !base && side
|
|
462
|
+
return :deleted if base && !side
|
|
463
|
+
return :unchanged unless base || side
|
|
464
|
+
|
|
465
|
+
:edited
|
|
466
|
+
end
|
|
467
|
+
|
|
468
|
+
def owner_choice(base, ours, theirs)
|
|
469
|
+
return :ours if equivalent?(ours, theirs)
|
|
470
|
+
return :theirs if equivalent?(base, ours)
|
|
471
|
+
return :ours if equivalent?(base, theirs)
|
|
472
|
+
return if ours.nil? && theirs.nil?
|
|
473
|
+
|
|
474
|
+
:conflict
|
|
475
|
+
end
|
|
476
|
+
|
|
477
|
+
def equivalent?(left, right)
|
|
478
|
+
return true if left.nil? && right.nil?
|
|
479
|
+
return false unless left && right
|
|
480
|
+
|
|
481
|
+
left.fingerprint == right.fingerprint
|
|
482
|
+
end
|
|
483
|
+
|
|
484
|
+
def conflict_for(id, base, ours, theirs, change)
|
|
485
|
+
{
|
|
486
|
+
conflict_id: "bash-declaration-#{id[0, 16]}",
|
|
487
|
+
category: base && (!ours || !theirs) ? :delete_edit : :edit_edit,
|
|
488
|
+
path: change.fetch(:path),
|
|
489
|
+
owner_id: id,
|
|
490
|
+
base: owner_state(base),
|
|
491
|
+
ours: owner_state(ours),
|
|
492
|
+
theirs: owner_state(theirs),
|
|
493
|
+
change_classification: change
|
|
494
|
+
}.freeze
|
|
495
|
+
end
|
|
496
|
+
|
|
497
|
+
def owner_state(owner)
|
|
498
|
+
{
|
|
499
|
+
present: !owner.nil?,
|
|
500
|
+
fingerprint: owner&.fingerprint,
|
|
501
|
+
source_role: owner&.role,
|
|
502
|
+
line_range: owner && [owner.start_line, owner.end_line]
|
|
503
|
+
}
|
|
504
|
+
end
|
|
505
|
+
|
|
506
|
+
def exact_revision_role(request)
|
|
507
|
+
base = request.fetch(:base_source)
|
|
508
|
+
ours = request.fetch(:ours_source)
|
|
509
|
+
theirs = request.fetch(:theirs_source)
|
|
510
|
+
return :ours if ours == theirs || base == theirs
|
|
511
|
+
|
|
512
|
+
:theirs if base == ours
|
|
513
|
+
end
|
|
514
|
+
|
|
515
|
+
def merge3_exact(request, role)
|
|
516
|
+
documents = %i[base ours theirs].to_h do |source_role|
|
|
517
|
+
[source_role, parse_exact_source(request.fetch(:"#{source_role}_source"), source_role, request[:dialect])]
|
|
518
|
+
end
|
|
519
|
+
winner = documents.fetch(role)
|
|
520
|
+
return parse_failure(:merge3, request, role, winner) if provider_failure?(winner)
|
|
521
|
+
|
|
522
|
+
render_exact(request, documents, role)
|
|
523
|
+
end
|
|
524
|
+
|
|
525
|
+
def render_exact(request, documents, role)
|
|
526
|
+
winner = documents.fetch(role)
|
|
527
|
+
rendered = render_plan(request, [whole_source_fragment(role, request.fetch(:"#{role}_source"))])
|
|
528
|
+
verification = verify_exact_rendered(rendered.content, winner)
|
|
529
|
+
unless verification[:semantic_match] && verification[:byte_exact]
|
|
530
|
+
return failure(
|
|
531
|
+
:merge3,
|
|
532
|
+
request,
|
|
533
|
+
category: :render_failure,
|
|
534
|
+
message: 'Bash exact revision did not remain byte-exact and semantically identical ' \
|
|
535
|
+
'after native reparse.',
|
|
536
|
+
source_role: role,
|
|
537
|
+
render_report: render_report(rendered, :exact_revision),
|
|
538
|
+
verification: verification.merge(base_participated: true)
|
|
539
|
+
)
|
|
540
|
+
end
|
|
541
|
+
|
|
542
|
+
result(
|
|
543
|
+
:merge3,
|
|
544
|
+
request,
|
|
545
|
+
output: rendered.content,
|
|
546
|
+
diagnostics: exact_diagnostics(documents),
|
|
547
|
+
render_report: render_report(rendered, :exact_revision),
|
|
548
|
+
verification: verification.merge(base_participated: true)
|
|
549
|
+
)
|
|
550
|
+
end
|
|
551
|
+
|
|
552
|
+
def verify_exact_rendered(output, expected)
|
|
553
|
+
parsed = parse_exact_source(output, :output)
|
|
554
|
+
if provider_failure?(parsed)
|
|
555
|
+
return {
|
|
556
|
+
output_reparsed: false,
|
|
557
|
+
byte_exact: output == expected.source,
|
|
558
|
+
semantic_match: false,
|
|
559
|
+
parse_error: failure_detail(parsed),
|
|
560
|
+
source_role: expected.role
|
|
561
|
+
}
|
|
562
|
+
end
|
|
563
|
+
|
|
564
|
+
actual_signatures = parsed.declarations.map(&:first)
|
|
565
|
+
expected_signatures = expected.declarations.map(&:first)
|
|
566
|
+
actual_attributes = parsed.declarations.map(&:last)
|
|
567
|
+
expected_attributes = expected.declarations.map(&:last)
|
|
568
|
+
{
|
|
569
|
+
output_reparsed: true,
|
|
570
|
+
byte_exact: output == expected.source,
|
|
571
|
+
semantic_match: parsed.declarations == expected.declarations,
|
|
572
|
+
ordered_declaration_signatures_verified: actual_signatures == expected_signatures,
|
|
573
|
+
ast_attributes_verified: actual_attributes == expected_attributes,
|
|
574
|
+
planned_declaration_count: expected.declarations.length,
|
|
575
|
+
output_declaration_count: parsed.declarations.length,
|
|
576
|
+
source_role: expected.role
|
|
577
|
+
}
|
|
578
|
+
end
|
|
579
|
+
|
|
580
|
+
def exact_diagnostics(documents)
|
|
581
|
+
documents.flat_map do |role, document|
|
|
582
|
+
if provider_failure?(document)
|
|
583
|
+
[{
|
|
584
|
+
category: :parse_error,
|
|
585
|
+
severity: :warning,
|
|
586
|
+
message: "#{role} parse error: #{failure_detail(document)}",
|
|
587
|
+
blocking: false,
|
|
588
|
+
source_role: role
|
|
589
|
+
}]
|
|
590
|
+
else
|
|
591
|
+
document.issues.map do |issue|
|
|
592
|
+
issue.merge(severity: :warning, blocking: false, source_role: role).freeze
|
|
593
|
+
end
|
|
594
|
+
end
|
|
595
|
+
end.freeze
|
|
596
|
+
end
|
|
597
|
+
|
|
598
|
+
def duplicate_message(signature)
|
|
599
|
+
"ambiguous duplicate declaration #{signature.inspect}"
|
|
600
|
+
end
|
|
601
|
+
|
|
602
|
+
def render_composite(request, documents, decision)
|
|
603
|
+
fragments = composite_fragments(documents, decision)
|
|
604
|
+
rendered = render_plan(request, fragments)
|
|
605
|
+
expected = expected_owners(documents, decision)
|
|
606
|
+
verification = verify_rendered(rendered.content, expected, request[:dialect])
|
|
607
|
+
unless verification[:semantic_match]
|
|
608
|
+
return failure(
|
|
609
|
+
:merge3,
|
|
610
|
+
request,
|
|
611
|
+
category: :render_failure,
|
|
612
|
+
message: 'Bash composite did not match the planned ordered declaration structure and AST attributes.',
|
|
613
|
+
changes: decision.changes,
|
|
614
|
+
render_report: render_report(rendered, :exact_declaration_composite),
|
|
615
|
+
verification: verification.merge(base_participated: true)
|
|
616
|
+
)
|
|
617
|
+
end
|
|
618
|
+
|
|
619
|
+
result(
|
|
620
|
+
:merge3,
|
|
621
|
+
request,
|
|
622
|
+
output: rendered.content,
|
|
623
|
+
changes: decision.changes,
|
|
624
|
+
render_report: render_report(rendered, :exact_declaration_composite),
|
|
625
|
+
verification: verification.merge(base_participated: true)
|
|
626
|
+
)
|
|
627
|
+
end
|
|
628
|
+
|
|
629
|
+
def composite_fragments(documents, decision)
|
|
630
|
+
ours = documents.fetch(:ours)
|
|
631
|
+
fragments = []
|
|
632
|
+
cursor = 1
|
|
633
|
+
edits = composite_edits(documents, decision).sort_by do |edit|
|
|
634
|
+
insertion = edit.fetch(:end_line) < edit.fetch(:start_line)
|
|
635
|
+
[edit.fetch(:start_line), insertion ? 0 : 1]
|
|
636
|
+
end
|
|
637
|
+
edits.each do |edit|
|
|
638
|
+
append_range(fragments, :ours, cursor, edit.fetch(:start_line) - 1)
|
|
639
|
+
edit.fetch(:owners).each_with_index do |owner, index|
|
|
640
|
+
append_owner(fragments, owner)
|
|
641
|
+
ensure_plan_boundary!(fragments, documents) if index < edit.fetch(:owners).length - 1
|
|
642
|
+
end
|
|
643
|
+
cursor = [cursor, edit.fetch(:end_line) + 1].max
|
|
644
|
+
end
|
|
645
|
+
append_range(fragments, :ours, cursor, ours.source.lines.length)
|
|
646
|
+
append_declaration_additions(fragments, documents, decision)
|
|
647
|
+
fragments
|
|
648
|
+
end
|
|
649
|
+
|
|
650
|
+
def composite_edits(documents, decision)
|
|
651
|
+
replacement_edits(documents, decision) + import_insertion_edits(documents, decision)
|
|
652
|
+
end
|
|
653
|
+
|
|
654
|
+
def replacement_edits(documents, decision)
|
|
655
|
+
ours = documents.fetch(:ours)
|
|
656
|
+
decision.choices.filter_map do |id, role|
|
|
657
|
+
ours_owner = ours.by_id[id]
|
|
658
|
+
next unless ours_owner
|
|
659
|
+
next if role == :ours
|
|
660
|
+
|
|
661
|
+
{
|
|
662
|
+
start_line: ours_owner.start_line,
|
|
663
|
+
end_line: ours_owner.end_line,
|
|
664
|
+
owners: [role && documents.fetch(role).by_id[id]].compact
|
|
665
|
+
}
|
|
666
|
+
end
|
|
667
|
+
end
|
|
668
|
+
|
|
669
|
+
def import_insertion_edits(documents, decision)
|
|
670
|
+
additions = added_theirs_owners(documents, decision).select { |owner| import_owner?(owner) }
|
|
671
|
+
return [] if additions.empty?
|
|
672
|
+
|
|
673
|
+
ours = documents.fetch(:ours)
|
|
674
|
+
anchor = ours.owners.reverse.find { |owner| package_or_import_owner?(owner) }
|
|
675
|
+
insertion_line = anchor ? anchor.end_line + 1 : ours.owners.first&.start_line || ours.source.lines.length + 1
|
|
676
|
+
[{
|
|
677
|
+
start_line: insertion_line,
|
|
678
|
+
end_line: insertion_line - 1,
|
|
679
|
+
owners: additions
|
|
680
|
+
}]
|
|
681
|
+
end
|
|
682
|
+
|
|
683
|
+
def append_declaration_additions(fragments, documents, decision)
|
|
684
|
+
additions = added_theirs_owners(documents, decision).reject { |owner| import_owner?(owner) }
|
|
685
|
+
return if additions.empty?
|
|
686
|
+
|
|
687
|
+
ensure_plan_boundary!(fragments, documents)
|
|
688
|
+
additions.each_with_index do |owner, index|
|
|
689
|
+
append_owner(fragments, owner)
|
|
690
|
+
ensure_plan_boundary!(fragments, documents) if index < additions.length - 1
|
|
691
|
+
end
|
|
692
|
+
end
|
|
693
|
+
|
|
694
|
+
def added_theirs_owners(documents, decision)
|
|
695
|
+
ours = documents.fetch(:ours)
|
|
696
|
+
documents.fetch(:theirs).owners.select do |owner|
|
|
697
|
+
!ours.by_id.key?(owner.id) && decision.choices[owner.id] == :theirs
|
|
698
|
+
end
|
|
699
|
+
end
|
|
700
|
+
|
|
701
|
+
def ensure_plan_boundary!(fragments, documents)
|
|
702
|
+
fragment = fragments.last
|
|
703
|
+
return if fragment.nil? || fragment_content(fragment, documents).end_with?("\n")
|
|
704
|
+
|
|
705
|
+
if fragment.is_a?(Ast::Merge::SourceRender::SourceFragment) && fragment.start_line < fragment.end_line
|
|
706
|
+
fragments[-1] = source_range_fragment(fragment.revision, fragment.start_line, fragment.end_line - 1)
|
|
707
|
+
else
|
|
708
|
+
fragments.pop
|
|
709
|
+
end
|
|
710
|
+
content = if fragment.is_a?(Ast::Merge::SourceRender::SourceFragment)
|
|
711
|
+
documents.fetch(fragment.revision).source.lines.fetch(fragment.end_line - 1)
|
|
712
|
+
else
|
|
713
|
+
fragment.content
|
|
714
|
+
end
|
|
715
|
+
fragments << Ast::Merge::SourceRender::SynthesizedFragment.new(
|
|
716
|
+
content: "#{content}\n",
|
|
717
|
+
reason: :declaration_separator,
|
|
718
|
+
producer: provider_id,
|
|
719
|
+
metadata: { source_role: fragment.respond_to?(:revision) ? fragment.revision : nil, copied_source: true }
|
|
720
|
+
)
|
|
721
|
+
end
|
|
722
|
+
|
|
723
|
+
def fragment_content(fragment, documents)
|
|
724
|
+
return fragment.content if fragment.is_a?(Ast::Merge::SourceRender::SynthesizedFragment)
|
|
725
|
+
|
|
726
|
+
document = documents.fetch(fragment.revision)
|
|
727
|
+
document.source.lines.slice(fragment.start_line - 1, fragment.end_line - fragment.start_line + 1).join
|
|
728
|
+
end
|
|
729
|
+
|
|
730
|
+
def append_owner(fragments, owner)
|
|
731
|
+
fragments << source_range_fragment(owner.role, owner.start_line, owner.end_line)
|
|
732
|
+
end
|
|
733
|
+
|
|
734
|
+
def append_range(fragments, role, start_line, end_line)
|
|
735
|
+
return if start_line > end_line
|
|
736
|
+
|
|
737
|
+
fragments << source_range_fragment(role, start_line, end_line)
|
|
738
|
+
end
|
|
739
|
+
|
|
740
|
+
def expected_owners(documents, decision)
|
|
741
|
+
ours = documents.fetch(:ours)
|
|
742
|
+
expected = ours.owners.filter_map do |owner|
|
|
743
|
+
role = decision.choices[owner.id]
|
|
744
|
+
role && documents.fetch(role).by_id[owner.id]
|
|
745
|
+
end
|
|
746
|
+
import_offset = expected.rindex { |owner| package_or_import_owner?(owner) }
|
|
747
|
+
documents.fetch(:theirs).owners.each do |owner|
|
|
748
|
+
next if ours.by_id.key?(owner.id)
|
|
749
|
+
next unless decision.choices[owner.id] == :theirs
|
|
750
|
+
|
|
751
|
+
if import_owner?(owner)
|
|
752
|
+
import_offset = import_offset ? import_offset + 1 : 0
|
|
753
|
+
expected.insert(import_offset, owner)
|
|
754
|
+
else
|
|
755
|
+
expected << owner
|
|
756
|
+
end
|
|
757
|
+
end
|
|
758
|
+
expected
|
|
759
|
+
end
|
|
760
|
+
|
|
761
|
+
def import_owner?(_owner)
|
|
762
|
+
false
|
|
763
|
+
end
|
|
764
|
+
|
|
765
|
+
alias package_or_import_owner? import_owner?
|
|
766
|
+
|
|
767
|
+
def verify_rendered(output, expected, dialect)
|
|
768
|
+
parsed = parse_source(output, :output, dialect)
|
|
769
|
+
if provider_failure?(parsed)
|
|
770
|
+
return {
|
|
771
|
+
output_reparsed: false,
|
|
772
|
+
semantic_match: false,
|
|
773
|
+
parse_error: failure_detail(parsed)
|
|
774
|
+
}
|
|
775
|
+
end
|
|
776
|
+
|
|
777
|
+
actual = parsed.owners.map { |owner| [owner.signature, owner.fingerprint] }
|
|
778
|
+
planned = expected.map { |owner| [owner.signature, owner.fingerprint] }
|
|
779
|
+
{
|
|
780
|
+
output_reparsed: true,
|
|
781
|
+
semantic_match: actual == planned,
|
|
782
|
+
ordered_declaration_signatures_verified: actual.map(&:first) == planned.map(&:first),
|
|
783
|
+
ast_attributes_verified: actual.map(&:last) == planned.map(&:last),
|
|
784
|
+
planned_declaration_count: planned.length,
|
|
785
|
+
output_declaration_count: actual.length
|
|
786
|
+
}
|
|
787
|
+
end
|
|
788
|
+
|
|
789
|
+
def render_conflicts(request, documents, decision)
|
|
790
|
+
localized = localized_conflict_fragments(request, documents, decision)
|
|
791
|
+
if localized
|
|
792
|
+
rendered = render_plan(request, localized)
|
|
793
|
+
return conflict_failure(request, decision, rendered, :declaration_localized_conflict)
|
|
794
|
+
end
|
|
795
|
+
|
|
796
|
+
rendered = render_plan(request, [whole_document_conflict(request, decision)])
|
|
797
|
+
conflict_failure(
|
|
798
|
+
request,
|
|
799
|
+
decision,
|
|
800
|
+
rendered,
|
|
801
|
+
:full_file_conflict,
|
|
802
|
+
fallbacks: [{ from: :declaration_localization, to: :full_file_conflict, reason: :source_ownership_unproven }]
|
|
803
|
+
)
|
|
804
|
+
end
|
|
805
|
+
|
|
806
|
+
def localized_conflict_fragments(request, documents, decision)
|
|
807
|
+
return if decision.conflicts.any? { |conflict| conflict[:owner_id].nil? }
|
|
808
|
+
|
|
809
|
+
ours = documents.fetch(:ours)
|
|
810
|
+
return unless decision.conflicts.all? { |conflict| ours.by_id.key?(conflict.fetch(:owner_id)) }
|
|
811
|
+
|
|
812
|
+
ranges = decision.conflicts.map { |conflict| [conflict, ours.by_id.fetch(conflict.fetch(:owner_id))] }
|
|
813
|
+
return unless ranges.sort_by { |_conflict, owner| owner.start_line }.each_cons(2).none? do |left, right|
|
|
814
|
+
left.last.end_line >= right.last.start_line
|
|
815
|
+
end
|
|
816
|
+
|
|
817
|
+
fragments = []
|
|
818
|
+
cursor = 1
|
|
819
|
+
ranges.sort_by { |_conflict, owner| owner.start_line }.each do |conflict, owner|
|
|
820
|
+
append_range(fragments, :ours, cursor, owner.start_line - 1)
|
|
821
|
+
fragments << declaration_conflict_fragment(request, documents, conflict)
|
|
822
|
+
cursor = owner.end_line + 1
|
|
823
|
+
end
|
|
824
|
+
append_range(fragments, :ours, cursor, ours.source.lines.length)
|
|
825
|
+
fragments
|
|
826
|
+
end
|
|
827
|
+
|
|
828
|
+
def declaration_conflict_fragment(request, documents, conflict)
|
|
829
|
+
Ast::Merge::SourceRender::ConflictFragment.new(
|
|
830
|
+
conflict_id: conflict.fetch(:conflict_id),
|
|
831
|
+
base: conflict_declaration_side(documents, conflict, :base),
|
|
832
|
+
ours: conflict_declaration_side(documents, conflict, :ours),
|
|
833
|
+
theirs: conflict_declaration_side(documents, conflict, :theirs),
|
|
834
|
+
labels: request.fetch(:labels, {}),
|
|
835
|
+
marker_size: request.fetch(:conflict_marker_size, 7),
|
|
836
|
+
metadata: { path: conflict.fetch(:path), category: conflict.fetch(:category) }
|
|
837
|
+
)
|
|
838
|
+
end
|
|
839
|
+
|
|
840
|
+
def conflict_declaration_side(documents, conflict, role)
|
|
841
|
+
document = documents.fetch(role)
|
|
842
|
+
owner = document.by_id[conflict.fetch(:owner_id)]
|
|
843
|
+
owner ? [conflict_source_fragment(owner, document.source)] : []
|
|
844
|
+
end
|
|
845
|
+
|
|
846
|
+
def unsafe_document_failure(request, role, parsed)
|
|
847
|
+
return parsed unless parsed[:unsafe_range] || parsed[:ambiguous_owner]
|
|
848
|
+
|
|
849
|
+
conflict_id = "bash-unsafe-#{Digest::SHA256.hexdigest([role, failure_detail(parsed)].join("\0"))[0, 16]}"
|
|
850
|
+
conflict = {
|
|
851
|
+
conflict_id: conflict_id,
|
|
852
|
+
category: parsed[:ambiguous_owner] ? :ambiguous_owner : :unsafe_source_range,
|
|
853
|
+
path: '<document>',
|
|
854
|
+
owner_id: nil,
|
|
855
|
+
source_role: role
|
|
856
|
+
}
|
|
857
|
+
rendered = render_plan(
|
|
858
|
+
request,
|
|
859
|
+
[whole_document_conflict(request, Decision.new(changes: [], conflicts: [conflict], choices: {}))]
|
|
860
|
+
)
|
|
861
|
+
failure(
|
|
862
|
+
:merge3,
|
|
863
|
+
request,
|
|
864
|
+
category: conflict.fetch(:category),
|
|
865
|
+
message: "#{role} cannot be merged safely: #{failure_detail(parsed)}",
|
|
866
|
+
conflicts: [conflict],
|
|
867
|
+
conflicted_output: rendered.content,
|
|
868
|
+
source_role: role,
|
|
869
|
+
render_report: render_report(rendered, :full_file_conflict),
|
|
870
|
+
verification: { base_participated: true }
|
|
871
|
+
)
|
|
872
|
+
end
|
|
873
|
+
|
|
874
|
+
def whole_document_conflict(request, decision)
|
|
875
|
+
Ast::Merge::SourceRender::ConflictFragment.new(
|
|
876
|
+
conflict_id: decision.conflicts.first.fetch(:conflict_id),
|
|
877
|
+
base: [conflict_whole_source_fragment(:base, request.fetch(:base_source))],
|
|
878
|
+
ours: [conflict_whole_source_fragment(:ours, request.fetch(:ours_source))],
|
|
879
|
+
theirs: [conflict_whole_source_fragment(:theirs, request.fetch(:theirs_source))],
|
|
880
|
+
labels: request.fetch(:labels, {}),
|
|
881
|
+
marker_size: request.fetch(:conflict_marker_size, 7),
|
|
882
|
+
metadata: { conflicts: decision.conflicts.map { |conflict| conflict[:conflict_id] } }
|
|
883
|
+
)
|
|
884
|
+
end
|
|
885
|
+
|
|
886
|
+
def conflict_source_fragment(owner, source)
|
|
887
|
+
fragment_source = source.lines.slice(owner.start_line - 1, owner.end_line - owner.start_line + 1).join
|
|
888
|
+
return source_range_fragment(owner.role, owner.start_line, owner.end_line) if fragment_source.end_with?("\n")
|
|
889
|
+
|
|
890
|
+
synthesized_copy("#{fragment_source}\n", :conflict_line_boundary, owner.role)
|
|
891
|
+
end
|
|
892
|
+
|
|
893
|
+
def conflict_whole_source_fragment(role, source)
|
|
894
|
+
return whole_source_fragment(role, source) if source.empty? || source.end_with?("\n")
|
|
895
|
+
|
|
896
|
+
synthesized_copy("#{source}\n", :conflict_line_boundary, role)
|
|
897
|
+
end
|
|
898
|
+
|
|
899
|
+
def synthesized_copy(content, reason, role)
|
|
900
|
+
Ast::Merge::SourceRender::SynthesizedFragment.new(
|
|
901
|
+
content: content,
|
|
902
|
+
reason: reason,
|
|
903
|
+
producer: provider_id,
|
|
904
|
+
metadata: { source_role: role, copied_source: true }
|
|
905
|
+
)
|
|
906
|
+
end
|
|
907
|
+
|
|
908
|
+
def whole_source_fragment(role, source)
|
|
909
|
+
return synthesized_copy('', :exact_empty_source, role) if source.empty?
|
|
910
|
+
|
|
911
|
+
source_range_fragment(role, 1, source.lines.length)
|
|
912
|
+
end
|
|
913
|
+
|
|
914
|
+
def source_range_fragment(role, start_line, end_line)
|
|
915
|
+
Ast::Merge::SourceRender::SourceFragment.new(
|
|
916
|
+
revision: role,
|
|
917
|
+
start_line: start_line,
|
|
918
|
+
end_line: end_line,
|
|
919
|
+
metadata: { source_role: role }
|
|
920
|
+
)
|
|
921
|
+
end
|
|
922
|
+
|
|
923
|
+
def render_plan(request, fragments)
|
|
924
|
+
Ast::Merge::SourceRender::Renderer.new.render(
|
|
925
|
+
Ast::Merge::SourceRender::Plan.new(
|
|
926
|
+
sources: {
|
|
927
|
+
base: request[:base_source].to_s,
|
|
928
|
+
ours: request[:ours_source].to_s,
|
|
929
|
+
theirs: request[:theirs_source].to_s
|
|
930
|
+
},
|
|
931
|
+
fragments: fragments,
|
|
932
|
+
metadata: { provider_id: provider_id }
|
|
933
|
+
)
|
|
934
|
+
)
|
|
935
|
+
end
|
|
936
|
+
|
|
937
|
+
def render_report(rendered, strategy)
|
|
938
|
+
{
|
|
939
|
+
strategy: strategy,
|
|
940
|
+
line_records: rendered.line_records,
|
|
941
|
+
synthesized_fragments: rendered.synthesized_fragments,
|
|
942
|
+
conflicts: rendered.conflicts,
|
|
943
|
+
verification_input: rendered.verification_input
|
|
944
|
+
}
|
|
945
|
+
end
|
|
946
|
+
|
|
947
|
+
def conflict_failure(request, decision, rendered, strategy, fallbacks: [])
|
|
948
|
+
failure(
|
|
949
|
+
:merge3,
|
|
950
|
+
request,
|
|
951
|
+
category: :merge_conflict,
|
|
952
|
+
message: 'Bash top-level declaration changed incompatibly on both sides.',
|
|
953
|
+
changes: decision.changes,
|
|
954
|
+
conflicts: decision.conflicts,
|
|
955
|
+
conflicted_output: rendered.content,
|
|
956
|
+
render_report: render_report(rendered, strategy),
|
|
957
|
+
verification: { base_participated: true },
|
|
958
|
+
fallbacks: fallbacks
|
|
959
|
+
)
|
|
960
|
+
end
|
|
961
|
+
|
|
962
|
+
def parse_failure(operation, request, role, parsed)
|
|
963
|
+
category = if parsed[:parse_error]
|
|
964
|
+
:parse_error
|
|
965
|
+
elsif parsed[:ambiguous_owner]
|
|
966
|
+
:ambiguous_owner
|
|
967
|
+
else
|
|
968
|
+
:unsafe_source_range
|
|
969
|
+
end
|
|
970
|
+
failure(
|
|
971
|
+
operation,
|
|
972
|
+
request,
|
|
973
|
+
category: category,
|
|
974
|
+
message: "#{role} parse error: #{failure_detail(parsed)}",
|
|
975
|
+
source_role: role
|
|
976
|
+
)
|
|
977
|
+
end
|
|
978
|
+
|
|
979
|
+
def failure_detail(parsed)
|
|
980
|
+
return parsed[:parse_error] if parsed[:parse_error]
|
|
981
|
+
return "ambiguous duplicate declaration #{parsed[:ambiguous_owner].inspect}" if parsed[:ambiguous_owner]
|
|
982
|
+
|
|
983
|
+
"unsafe declaration range #{parsed[:unsafe_range].inspect}"
|
|
984
|
+
end
|
|
985
|
+
|
|
986
|
+
def provider_failure?(value)
|
|
987
|
+
value.is_a?(Hash) &&
|
|
988
|
+
(value[:ok] == false || value.key?(:parse_error) || value.key?(:ambiguous_owner) || value.key?(:unsafe_range))
|
|
989
|
+
end
|
|
990
|
+
|
|
991
|
+
def result(operation, request, changes: [], diagnostics: [], render_report: {}, verification: {}, **payload)
|
|
992
|
+
Ast::Merge::ProviderResult.build(
|
|
993
|
+
operation: operation,
|
|
994
|
+
success: true,
|
|
995
|
+
envelope: envelope(
|
|
996
|
+
request,
|
|
997
|
+
changes: changes,
|
|
998
|
+
diagnostics: diagnostics,
|
|
999
|
+
render_report: render_report,
|
|
1000
|
+
verification: verification
|
|
1001
|
+
),
|
|
1002
|
+
**payload
|
|
1003
|
+
)
|
|
1004
|
+
end
|
|
1005
|
+
|
|
1006
|
+
def failure(operation, request, category:, message:, changes: [], conflicts: [], fallbacks: [],
|
|
1007
|
+
render_report: {}, verification: {}, **payload)
|
|
1008
|
+
Ast::Merge::ProviderResult.build(
|
|
1009
|
+
operation: operation,
|
|
1010
|
+
success: false,
|
|
1011
|
+
envelope: envelope(
|
|
1012
|
+
request,
|
|
1013
|
+
changes: changes,
|
|
1014
|
+
conflicts: conflicts,
|
|
1015
|
+
fallbacks: fallbacks,
|
|
1016
|
+
diagnostics: [{ category: category, severity: :error, message: message, blocking: true }],
|
|
1017
|
+
render_report: render_report,
|
|
1018
|
+
verification: verification
|
|
1019
|
+
),
|
|
1020
|
+
**payload
|
|
1021
|
+
)
|
|
1022
|
+
end
|
|
1023
|
+
|
|
1024
|
+
def envelope(request, **fields)
|
|
1025
|
+
{
|
|
1026
|
+
provider: {
|
|
1027
|
+
provider_id: provider_id,
|
|
1028
|
+
family: family,
|
|
1029
|
+
dialect: request[:dialect] || :bash,
|
|
1030
|
+
backend: request[:backend] || TREE_SITTER_BACKEND.id.to_sym,
|
|
1031
|
+
package: 'bash-merge',
|
|
1032
|
+
package_version: Bash::Merge::Version::VERSION
|
|
1033
|
+
},
|
|
1034
|
+
profile: { profile_id: request[:profile_id] || DEFAULT_PROFILE }
|
|
1035
|
+
}.merge(fields)
|
|
1036
|
+
end
|
|
1037
|
+
end
|
|
1038
|
+
# rubocop:enable Metrics/AbcSize, Metrics/ClassLength, Metrics/CyclomaticComplexity, Metrics/MethodLength, Metrics/ParameterLists, Metrics/PerceivedComplexity
|
|
1039
|
+
end
|
|
1040
|
+
end
|