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