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.
@@ -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