markdown-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,814 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'digest'
4
+ require 'json'
5
+
6
+ module Markdown
7
+ module Merge
8
+ # Parser adapter used by the conservative Markdown provider. Backends supply
9
+ # native AST facts; the provider owns all source-range and merge policy.
10
+ # rubocop:disable Metrics/AbcSize, Metrics/ClassLength, Metrics/CyclomaticComplexity, Metrics/MethodLength, Metrics/PerceivedComplexity -- backend ASTs expose materially different heading facts
11
+ class ProviderBackend
12
+ Heading = Data.define(:level, :text, :start_line, :end_line, :style)
13
+
14
+ attr_reader :id, :package, :dialects
15
+
16
+ def initialize(id:, package:, dialects:, parser:, headings:)
17
+ @id = id.to_sym
18
+ @package = package
19
+ @dialects = dialects.map(&:to_sym).freeze
20
+ @parser = parser
21
+ @headings = headings
22
+ end
23
+
24
+ def parse(source)
25
+ tree = @parser.call(source)
26
+ root = tree.root_node
27
+ raise TreeHaver::NotAvailable, 'Markdown parser returned no root node' unless root
28
+ if root.respond_to?(:has_error?) && root.has_error?
29
+ raise TreeHaver::NotAvailable, 'Markdown parser reported a syntax error'
30
+ end
31
+
32
+ [root, @headings.call(root, source)]
33
+ end
34
+
35
+ class << self
36
+ def native_headings(root, source, kramdown: false)
37
+ root.children.filter_map do |node|
38
+ next unless node.type.to_s == 'heading'
39
+
40
+ start_line = point_row(node.start_point)
41
+ end_line = point_row(node.end_point)
42
+ level = node.header_level.to_i
43
+ text = node.text.to_s.chomp
44
+ style = if kramdown
45
+ kramdown_atx?(node, source, start_line, level, text) ? :atx : :setext
46
+ else
47
+ start_line == end_line ? :atx : :setext
48
+ end
49
+ Heading.new(level: level, text: text, start_line: start_line, end_line: end_line, style: style)
50
+ end
51
+ end
52
+
53
+ def tree_sitter_headings(root, _source)
54
+ document_section_headings(root).filter_map do |node|
55
+ type = node.type.to_s
56
+ level = atx_level(node)
57
+ if level
58
+ Heading.new(
59
+ level: level,
60
+ text: heading_inline_text(node),
61
+ start_line: point_row(node.start_point),
62
+ end_line: point_row(node.end_point),
63
+ style: :atx
64
+ )
65
+ elsif type.start_with?('setext_') && type.end_with?('_heading')
66
+ Heading.new(
67
+ level: type.include?('h1') ? 1 : 2,
68
+ text: heading_inline_text(node),
69
+ start_line: point_row(node.start_point),
70
+ end_line: point_row(node.end_point),
71
+ style: :setext
72
+ )
73
+ end
74
+ end
75
+ end
76
+
77
+ private
78
+
79
+ def document_section_headings(root)
80
+ output = []
81
+ visit = lambda do |node|
82
+ node.children.each do |child|
83
+ type = child.type.to_s
84
+ if type == 'atx_heading' || (type.start_with?('setext_') && type.end_with?('_heading'))
85
+ output << child
86
+ elsif type == 'section'
87
+ visit.call(child)
88
+ end
89
+ end
90
+ end
91
+ visit.call(root)
92
+ output
93
+ end
94
+
95
+ def atx_level(node)
96
+ return unless node.type.to_s == 'atx_heading'
97
+
98
+ marker = node.children.first&.type.to_s
99
+ {
100
+ 'atx_h1_marker' => 1,
101
+ 'atx_h2_marker' => 2,
102
+ 'atx_h3_marker' => 3,
103
+ 'atx_h4_marker' => 4,
104
+ 'atx_h5_marker' => 5,
105
+ 'atx_h6_marker' => 6
106
+ }[marker]
107
+ end
108
+
109
+ def heading_inline_text(node)
110
+ inline = node.children.find { |child| child.type.to_s == 'inline' }
111
+ inline&.text.to_s
112
+ end
113
+
114
+ def point_row(point)
115
+ point.respond_to?(:row) ? point.row : point.fetch(:row)
116
+ end
117
+
118
+ def kramdown_atx?(node, source, line, level, text)
119
+ options = node.inner_node.options
120
+ return false unless options[:location].to_i == line + 1
121
+ return false unless options[:raw_text].to_s == text
122
+
123
+ source_line = source.each_line.with_index.find { |_value, index| index == line }&.first.to_s
124
+ bytes = source_line.bytes
125
+ return false unless level.between?(1, 6) && bytes.first(level).all? { |byte| byte == 35 }
126
+ return false unless [9, 32].include?(bytes[level])
127
+
128
+ true
129
+ end
130
+ end
131
+ end
132
+ # rubocop:enable Metrics/AbcSize, Metrics/ClassLength, Metrics/CyclomaticComplexity, Metrics/MethodLength, Metrics/PerceivedComplexity
133
+
134
+ # Base-aware source-preserving provider for a deliberately small Markdown
135
+ # subset: complete documents partitioned by unique, same-level ATX headings.
136
+ # rubocop:disable Metrics/AbcSize, Metrics/ClassLength, Metrics/CyclomaticComplexity, Metrics/MethodLength, Metrics/ParameterLists, Metrics/PerceivedComplexity -- parsing, ownership, rendering, and verification are one safety boundary
137
+ class SourcePreservingProvider
138
+ DEFAULT_PROFILE = :source_preserving
139
+ Owner = Data.define(:id, :signature, :fingerprint, :start_byte, :end_byte, :role, :source_text)
140
+ Document = Data.define(:source, :owners, :by_id, :role)
141
+ ExactDocument = Data.define(:source, :headings, :issues, :role)
142
+ Decision = Data.define(:changes, :conflicts, :choices)
143
+
144
+ attr_reader :backend, :provider_id
145
+
146
+ def initialize(provider_id:, role:, backend:)
147
+ @provider_id = provider_id
148
+ @role = role.to_sym
149
+ @backend = backend
150
+ end
151
+
152
+ def family = 'markdown'
153
+
154
+ def capabilities
155
+ {
156
+ operations: Ast::Merge::ProviderContract::OPERATIONS,
157
+ dialects: backend.dialects,
158
+ backends: [backend.id],
159
+ profiles: [DEFAULT_PROFILE],
160
+ role: @role,
161
+ ast_ownership: :unique_same_level_atx_heading_sections,
162
+ source_preservation: %i[exact_source exact_fragments byte_provenance reparse semantic_verification]
163
+ }.freeze
164
+ end
165
+
166
+ def analyze(request)
167
+ document = parse_document(:analyze, request, :source)
168
+ return document if provider_failure?(document)
169
+
170
+ result(
171
+ :analyze,
172
+ request,
173
+ analysis: {
174
+ backend: backend.id,
175
+ delegated_backend: backend.id,
176
+ valid: true,
177
+ owners: document.owners.map { |owner| owner_description(owner) }
178
+ },
179
+ verification: { source_parsed: true, ast_headings_verified: true }
180
+ )
181
+ end
182
+
183
+ def diff2(request)
184
+ before = parse_document(:diff2, request, :before)
185
+ return before if provider_failure?(before)
186
+
187
+ after = parse_document(:diff2, request, :after)
188
+ return after if provider_failure?(after)
189
+
190
+ changes = diff_documents(before, after)
191
+ result(
192
+ :diff2,
193
+ request,
194
+ diff: { changes: changes },
195
+ changes: changes,
196
+ verification: { before_parsed: true, after_parsed: true, ast_headings_verified: true }
197
+ )
198
+ end
199
+
200
+ def merge2(request)
201
+ merged = merge3(
202
+ request.merge(
203
+ base_source: request.fetch(:current_source),
204
+ ours_source: request.fetch(:current_source),
205
+ theirs_source: request.fetch(:incoming_source)
206
+ )
207
+ )
208
+ merged.merge(operation: :merge2, verification: merged.fetch(:verification).except(:base_participated))
209
+ end
210
+
211
+ def merge3(request)
212
+ exact_role = exact_revision_role(request)
213
+ return merge3_exact(request, exact_role) if exact_role
214
+
215
+ documents = parse_merge3_documents(request)
216
+ return documents if provider_failure?(documents)
217
+
218
+ identity_failure = changed_identity_failure(request, documents)
219
+ return identity_failure if identity_failure
220
+
221
+ decision = decide(documents)
222
+ return render_conflicts(request, documents, decision) unless decision.conflicts.empty?
223
+
224
+ render_composite(request, documents, decision)
225
+ end
226
+
227
+ private
228
+
229
+ def parse_merge3_documents(request)
230
+ %i[base ours theirs].each_with_object({}) do |role, documents|
231
+ parsed = parse_source(request.fetch(:"#{role}_source"), role)
232
+ return unsafe_document_failure(request, role, parsed) if provider_failure?(parsed)
233
+
234
+ documents[role] = parsed
235
+ end
236
+ end
237
+
238
+ def parse_document(operation, request, role)
239
+ key = role == :source ? :source : :"#{role}_source"
240
+ parsed = parse_source(request.fetch(key), role)
241
+ return parse_failure(operation, request, role, parsed) if provider_failure?(parsed)
242
+
243
+ parsed
244
+ end
245
+
246
+ def parse_source(source, role)
247
+ parsed = parse_ast(source, role)
248
+ return parsed if provider_failure?(parsed)
249
+
250
+ headings = parsed.fetch(:headings)
251
+ return { unsafe: :headingless_document, source_role: role } if headings.empty?
252
+ return { unsafe: :setext_heading, source_role: role } unless headings.all? { |heading| heading.style == :atx }
253
+ return { unsafe: :nested_heading_hierarchy, source_role: role } unless headings.map(&:level).uniq.one?
254
+
255
+ starts = headings.map(&:start_line).map { |line| line_start_byte(source, line) }
256
+ if starts.any?(&:nil?) || starts != starts.sort.uniq
257
+ return { unsafe: :invalid_heading_start, source_role: role }
258
+ end
259
+ return { unsafe: :source_before_first_section, source_role: role } unless starts.first.zero?
260
+
261
+ owners = headings.each_with_index.map do |heading, index|
262
+ signature = [heading.level, heading.text].freeze
263
+ start_byte = starts.fetch(index)
264
+ end_byte = starts[index + 1] || source.bytesize
265
+ return { unsafe: :overlapping_heading_sections, source_role: role } unless end_byte > start_byte
266
+
267
+ source_text = source.byteslice(start_byte...end_byte).to_s
268
+ Owner.new(
269
+ id: owner_id(signature),
270
+ signature: signature,
271
+ fingerprint: fingerprint(source_text),
272
+ start_byte: start_byte,
273
+ end_byte: end_byte,
274
+ role: role,
275
+ source_text: source_text
276
+ )
277
+ end
278
+ duplicate = owners.group_by(&:id).find { |_id, matches| matches.length > 1 }
279
+ return { ambiguous: duplicate.first, source_role: role } if duplicate
280
+
281
+ Document.new(
282
+ source: source,
283
+ owners: owners.freeze,
284
+ by_id: owners.to_h { |owner| [owner.id, owner] }.freeze,
285
+ role: role
286
+ )
287
+ rescue StandardError => e
288
+ { parse_error: e.message, source_role: role }
289
+ end
290
+
291
+ def parse_exact_source(source, role)
292
+ parsed = parse_ast(source, role)
293
+ return parsed if provider_failure?(parsed)
294
+
295
+ headings = parsed.fetch(:headings)
296
+ issues = []
297
+ duplicates = headings.group_by { |heading| [heading.level, heading.text] }
298
+ .select { |_signature, matches| matches.length > 1 }
299
+ duplicates.each_key do |signature|
300
+ issues << { category: :ambiguous_owner, heading: signature,
301
+ message: "Duplicate Markdown heading #{signature.inspect}" }.freeze
302
+ end
303
+ issues << { category: :unsafe_source_range, message: 'Setext heading is not independently ownable.' }.freeze if
304
+ headings.any? { |heading| heading.style != :atx }
305
+ ExactDocument.new(source: source, headings: headings.freeze, issues: issues.freeze, role: role)
306
+ rescue StandardError => e
307
+ { parse_error: e.message, source_role: role }
308
+ end
309
+
310
+ def parse_ast(source, role)
311
+ return { parse_error: 'Markdown source must be a String', source_role: role } unless source.is_a?(String)
312
+
313
+ parser_source = source.dup.force_encoding(Encoding::UTF_8)
314
+ return { parse_error: 'Markdown source must be valid UTF-8', source_role: role } unless
315
+ parser_source.valid_encoding?
316
+ return { parse_error: 'Markdown source contains a NUL byte', source_role: role } if source.include?("\0")
317
+
318
+ _root, headings = backend.parse(parser_source)
319
+ { headings: headings }
320
+ rescue StandardError => e
321
+ { parse_error: e.message, source_role: role }
322
+ end
323
+
324
+ def line_start_byte(source, line)
325
+ return if line.negative?
326
+ return 0 if line.zero?
327
+
328
+ offset = 0
329
+ source.each_line.with_index do |text, index|
330
+ return offset if index == line
331
+
332
+ offset += text.bytesize
333
+ end
334
+ nil
335
+ end
336
+
337
+ def owner_id(signature)
338
+ fingerprint(JSON.generate(Ast::Merge.json_ready(signature)))
339
+ end
340
+
341
+ def fingerprint(value)
342
+ Digest::SHA256.hexdigest(value)
343
+ end
344
+
345
+ def diff_documents(before, after)
346
+ ordered_ids(before, after).filter_map do |id|
347
+ left = before.by_id[id]
348
+ right = after.by_id[id]
349
+ next if equivalent?(left, right)
350
+
351
+ {
352
+ path: owner_path(left || right),
353
+ before: owner_state(left),
354
+ after: owner_state(right),
355
+ change: change_kind(left, right)
356
+ }.freeze
357
+ end.freeze
358
+ end
359
+
360
+ def ordered_ids(*documents)
361
+ documents.flat_map { |document| document.owners.map(&:id) }.uniq
362
+ end
363
+
364
+ def equivalent?(left, right)
365
+ return true if left.nil? && right.nil?
366
+ return false unless left && right
367
+
368
+ left.fingerprint == right.fingerprint
369
+ end
370
+
371
+ def change_kind(before, after)
372
+ return :added unless before
373
+ return :deleted unless after
374
+
375
+ :edited
376
+ end
377
+
378
+ def changed_identity_failure(request, documents)
379
+ base_ids = documents.fetch(:base).by_id.keys
380
+ %i[ours theirs].each do |role|
381
+ side_ids = documents.fetch(role).by_id.keys
382
+ removed = base_ids - side_ids
383
+ added = side_ids - base_ids
384
+ next if removed.empty? || added.empty?
385
+
386
+ return failure(
387
+ :merge3,
388
+ request,
389
+ category: :unstable_owner_identity,
390
+ message: "#{role} both removes and adds heading identities; rename or level change cannot be excluded.",
391
+ conflicts: [{ category: :unstable_owner_identity, source_role: role, path: '<document>' }],
392
+ conflicted_output: whole_document_conflict(request),
393
+ source_role: role,
394
+ render_report: { strategy: :full_file_conflict },
395
+ verification: { base_participated: true }
396
+ )
397
+ end
398
+ nil
399
+ end
400
+
401
+ def decide(documents)
402
+ changes = []
403
+ conflicts = []
404
+ choices = {}
405
+ ordered_ids(*documents.values).each do |id|
406
+ base = documents.fetch(:base).by_id[id]
407
+ ours = documents.fetch(:ours).by_id[id]
408
+ theirs = documents.fetch(:theirs).by_id[id]
409
+ ours_change = side_change_kind(base, ours)
410
+ theirs_change = side_change_kind(base, theirs)
411
+ choice = owner_choice(base, ours, theirs)
412
+ choices[id] = choice unless choice == :conflict
413
+ next if ours_change == :unchanged && theirs_change == :unchanged
414
+
415
+ change = { path: owner_path(base || ours || theirs), ours: ours_change, theirs: theirs_change }.freeze
416
+ changes << change
417
+ conflicts << conflict_for(id, base, ours, theirs, change) if choice == :conflict
418
+ end
419
+ Decision.new(changes: changes.freeze, conflicts: conflicts.freeze, choices: choices.freeze)
420
+ end
421
+
422
+ def side_change_kind(base, side)
423
+ return :unchanged if equivalent?(base, side)
424
+ return :added if !base && side
425
+ return :deleted if base && !side
426
+
427
+ :edited
428
+ end
429
+
430
+ def owner_choice(base, ours, theirs)
431
+ return :ours if equivalent?(ours, theirs)
432
+ return :theirs if equivalent?(base, ours)
433
+ return :ours if equivalent?(base, theirs)
434
+ return nil if ours.nil? && theirs.nil?
435
+
436
+ :conflict
437
+ end
438
+
439
+ def conflict_for(id, base, ours, theirs, change)
440
+ {
441
+ conflict_id: "markdown-owner-#{id[0, 16]}",
442
+ category: base && (!ours || !theirs) ? :delete_edit : :edit_edit,
443
+ path: change.fetch(:path),
444
+ owner_id: id,
445
+ base: owner_state(base),
446
+ ours: owner_state(ours),
447
+ theirs: owner_state(theirs),
448
+ change_classification: change
449
+ }.freeze
450
+ end
451
+
452
+ def owner_state(owner)
453
+ { present: !owner.nil?, fingerprint: owner&.fingerprint, source_role: owner&.role }
454
+ end
455
+
456
+ def exact_revision_role(request)
457
+ base = request.fetch(:base_source)
458
+ ours = request.fetch(:ours_source)
459
+ theirs = request.fetch(:theirs_source)
460
+ return :ours if ours == theirs || base == theirs
461
+
462
+ :theirs if base == ours
463
+ end
464
+
465
+ def merge3_exact(request, role)
466
+ winner = parse_exact_source(request.fetch(:"#{role}_source"), role)
467
+ return parse_failure(:merge3, request, role, winner) if provider_failure?(winner)
468
+
469
+ output = winner.source
470
+ verification = verify_exact(output, winner)
471
+ return render_failure(request, verification, :exact_revision) unless verification[:semantic_match]
472
+
473
+ result(
474
+ :merge3,
475
+ request,
476
+ output: output,
477
+ diagnostics: winner.issues.map { |issue| nonblocking_issue(issue, role) },
478
+ render_report: {
479
+ strategy: :exact_revision,
480
+ provenance: [{ source_role: role, byte_range: [0, output.bytesize], copied_source: true }],
481
+ synthesized_fragments: []
482
+ },
483
+ verification: verification.merge(base_participated: true)
484
+ )
485
+ end
486
+
487
+ def verify_exact(output, expected)
488
+ parsed = parse_exact_source(output, :output)
489
+ return { output_reparsed: false, byte_exact: output == expected.source, semantic_match: false } if
490
+ provider_failure?(parsed)
491
+
492
+ {
493
+ output_reparsed: true,
494
+ byte_exact: output == expected.source,
495
+ semantic_match: heading_semantics(parsed.headings) == heading_semantics(expected.headings),
496
+ ordered_heading_semantics_verified: true,
497
+ delegated_backend_verified: true,
498
+ backend: backend.id,
499
+ source_role: expected.role
500
+ }
501
+ end
502
+
503
+ def heading_semantics(headings)
504
+ headings.map { |heading| [heading.level, heading.text, heading.style] }
505
+ end
506
+
507
+ def nonblocking_issue(issue, role)
508
+ issue.merge(severity: :warning, blocking: false, source_role: role).freeze
509
+ end
510
+
511
+ def render_composite(request, documents, decision)
512
+ ordered = merged_order(documents, decision)
513
+ return unsafe_composite_failure(request, decision, :incompatible_section_order) unless ordered
514
+
515
+ fragments = ordered.filter_map do |id|
516
+ role = decision.choices[id]
517
+ owner = role && documents.fetch(role).by_id[id]
518
+ owner && [owner, role]
519
+ end
520
+ output = fragments.map { |owner, _role| owner.source_text }.join
521
+ expected = fragments.map { |owner, _role| [owner.signature, owner.fingerprint] }
522
+ verification = verify_composite(output, expected)
523
+ return render_failure(request, verification, :exact_markdown_composite, decision.changes) unless
524
+ verification[:semantic_match]
525
+
526
+ result(
527
+ :merge3,
528
+ request,
529
+ output: output,
530
+ changes: decision.changes,
531
+ render_report: composite_render_report(fragments, output),
532
+ verification: verification.merge(base_participated: true)
533
+ )
534
+ end
535
+
536
+ def merged_order(documents, decision)
537
+ selected = decision.choices.select { |_id, role| role }.keys
538
+ edges = Hash.new { |hash, key| hash[key] = [] }
539
+ indegree = selected.to_h { |id| [id, 0] }
540
+ documents.each_value do |document|
541
+ ids = document.owners.map(&:id).select { |id| indegree.key?(id) }
542
+ ids.each_cons(2) do |left, right|
543
+ next if edges[left].include?(right)
544
+
545
+ edges[left] << right
546
+ indegree[right] += 1
547
+ end
548
+ end
549
+ rank = ordered_ids(*documents.values).each_with_index.to_h
550
+ ready = indegree.select { |_id, count| count.zero? }.keys.sort_by { |id| rank.fetch(id) }
551
+ output = []
552
+ until ready.empty?
553
+ id = ready.shift
554
+ output << id
555
+ edges[id].each do |target|
556
+ indegree[target] -= 1
557
+ ready << target if indegree[target].zero?
558
+ end
559
+ ready.sort_by! { |candidate| rank.fetch(candidate) }
560
+ end
561
+ output.length == selected.length ? output : nil
562
+ end
563
+
564
+ def verify_composite(output, expected)
565
+ return verify_empty_composite(output) if expected.empty?
566
+
567
+ parsed = parse_source(output, :output)
568
+ return { output_reparsed: false, semantic_match: false, parse_error: failure_detail(parsed) } if
569
+ provider_failure?(parsed)
570
+
571
+ actual = parsed.owners.map { |owner| [owner.signature, owner.fingerprint] }
572
+ {
573
+ output_reparsed: true,
574
+ semantic_match: actual == expected,
575
+ ordered_heading_semantics_verified: actual == expected,
576
+ byte_provenance_verified: actual == expected,
577
+ delegated_backend_verified: true,
578
+ backend: backend.id,
579
+ planned_owner_count: expected.length,
580
+ output_owner_count: actual.length
581
+ }
582
+ end
583
+
584
+ def verify_empty_composite(output)
585
+ parsed = parse_exact_source(output, :output)
586
+ valid = !provider_failure?(parsed) && parsed.headings.empty?
587
+ {
588
+ output_reparsed: valid,
589
+ semantic_match: valid,
590
+ ordered_heading_semantics_verified: valid,
591
+ byte_provenance_verified: valid,
592
+ delegated_backend_verified: valid,
593
+ backend: backend.id,
594
+ planned_owner_count: 0,
595
+ output_owner_count: valid ? 0 : nil
596
+ }
597
+ end
598
+
599
+ def composite_render_report(fragments, output)
600
+ {
601
+ strategy: :exact_markdown_composite,
602
+ provenance: fragments.map do |owner, role|
603
+ {
604
+ source_role: role,
605
+ owner_id: owner.id,
606
+ source_byte_range: [owner.start_byte, owner.end_byte],
607
+ copied_source: true
608
+ }
609
+ end,
610
+ output_bytes: output.bytesize,
611
+ line_records: fragments.map.with_index do |(owner, role), index|
612
+ { output_index: index, source_role: role, owner_id: owner.id }
613
+ end,
614
+ synthesized_fragments: []
615
+ }
616
+ end
617
+
618
+ def render_conflicts(request, documents, decision)
619
+ localized = localized_conflict_output(request, documents, decision)
620
+ strategy = localized ? :section_localized_conflict : :full_file_conflict
621
+ conflict_failure(
622
+ request,
623
+ decision,
624
+ localized || whole_document_conflict(request),
625
+ strategy,
626
+ localized ? [] : [{ from: :section_localization, to: :full_file_conflict,
627
+ reason: :source_ownership_unproven }]
628
+ )
629
+ end
630
+
631
+ def localized_conflict_output(request, documents, decision)
632
+ ours = documents.fetch(:ours)
633
+ return unless decision.conflicts.all? { |conflict| ours.by_id.key?(conflict.fetch(:owner_id)) }
634
+
635
+ output = ours.source.dup
636
+ edits = decision.conflicts.map do |conflict|
637
+ owner = ours.by_id.fetch(conflict.fetch(:owner_id))
638
+ [owner.start_byte, owner.end_byte, conflict_text(request, documents, conflict)]
639
+ end
640
+ edits.sort_by(&:first).reverse_each { |start_byte, end_byte, text| output[start_byte...end_byte] = text }
641
+ output
642
+ end
643
+
644
+ def conflict_text(request, documents, conflict)
645
+ marker = request.fetch(:conflict_marker_size, 7).to_i
646
+ marker = 7 unless marker.positive?
647
+ labels = { ours: 'ours', base: 'base', theirs: 'theirs' }.merge(request.fetch(:labels, {}))
648
+ sides = %i[ours base theirs].to_h do |role|
649
+ owner = documents.fetch(role).by_id[conflict.fetch(:owner_id)]
650
+ [role, owner&.source_text.to_s]
651
+ end
652
+ [
653
+ "#{'<' * marker} #{labels[:ours]}\n", sides[:ours],
654
+ "#{'|' * marker} #{labels[:base]}\n", sides[:base],
655
+ "#{'=' * marker}\n", sides[:theirs],
656
+ "#{'>' * marker} #{labels[:theirs]}\n"
657
+ ].join
658
+ end
659
+
660
+ def whole_document_conflict(request)
661
+ marker = request.fetch(:conflict_marker_size, 7).to_i
662
+ marker = 7 unless marker.positive?
663
+ [
664
+ "#{'<' * marker} ours\n", request.fetch(:ours_source),
665
+ "#{'|' * marker} base\n", request.fetch(:base_source),
666
+ "#{'=' * marker}\n", request.fetch(:theirs_source),
667
+ "#{'>' * marker} theirs\n"
668
+ ].join
669
+ end
670
+
671
+ def conflict_failure(request, decision, output, strategy, fallbacks)
672
+ failure(
673
+ :merge3,
674
+ request,
675
+ category: :merge_conflict,
676
+ message: 'Markdown heading section changed incompatibly on both sides.',
677
+ changes: decision.changes,
678
+ conflicts: decision.conflicts,
679
+ conflicted_output: output,
680
+ fallbacks: fallbacks,
681
+ render_report: { strategy: strategy, provenance: :exact_source_bytes },
682
+ verification: { base_participated: true }
683
+ )
684
+ end
685
+
686
+ def unsafe_document_failure(request, role, parsed)
687
+ return parse_failure(:merge3, request, role, parsed) if parsed[:parse_error]
688
+
689
+ category = parsed[:ambiguous] ? :ambiguous_owner : :unsafe_source_range
690
+ failure(
691
+ :merge3,
692
+ request,
693
+ category: category,
694
+ message: "#{role} Markdown ownership is unsafe: #{failure_detail(parsed)}",
695
+ conflicts: [{ category: category, source_role: role, path: '<document>' }],
696
+ conflicted_output: whole_document_conflict(request),
697
+ source_role: role,
698
+ render_report: { strategy: :full_file_conflict, provenance: :exact_source_bytes },
699
+ verification: { base_participated: true }
700
+ )
701
+ end
702
+
703
+ def unsafe_composite_failure(request, decision, reason)
704
+ failure(
705
+ :merge3,
706
+ request,
707
+ category: :unsafe_source_range,
708
+ message: 'Markdown section order cannot be proven across revisions.',
709
+ changes: decision.changes,
710
+ conflicts: [{ category: reason, path: '<document>' }],
711
+ conflicted_output: whole_document_conflict(request),
712
+ render_report: { strategy: :full_file_conflict },
713
+ verification: { base_participated: true }
714
+ )
715
+ end
716
+
717
+ def render_failure(request, verification, strategy, changes = [])
718
+ failure(
719
+ :merge3,
720
+ request,
721
+ category: :render_failure,
722
+ message: 'Markdown output failed backend reparse and ordered semantic verification.',
723
+ changes: changes,
724
+ render_report: { strategy: strategy },
725
+ verification: verification.merge(base_participated: true)
726
+ )
727
+ end
728
+
729
+ def owner_path(owner)
730
+ owner ? owner.signature.inspect : '<document>'
731
+ end
732
+
733
+ def owner_description(owner)
734
+ {
735
+ path: owner_path(owner),
736
+ signature: owner.signature,
737
+ source_role: owner.role,
738
+ byte_range: [owner.start_byte, owner.end_byte]
739
+ }
740
+ end
741
+
742
+ def parse_failure(operation, request, role, parsed)
743
+ category = if parsed[:parse_error]
744
+ :parse_error
745
+ else
746
+ parsed[:ambiguous] ? :ambiguous_owner : :unsafe_source_range
747
+ end
748
+ failure(
749
+ operation,
750
+ request,
751
+ category: category,
752
+ message: "#{role} Markdown parse error: #{failure_detail(parsed)}",
753
+ source_role: role
754
+ )
755
+ end
756
+
757
+ def failure_detail(parsed)
758
+ parsed[:parse_error] || parsed[:ambiguous]&.inspect || parsed[:unsafe]&.inspect
759
+ end
760
+
761
+ def provider_failure?(value)
762
+ value.is_a?(Hash) &&
763
+ (value[:ok] == false || value.key?(:parse_error) || value.key?(:ambiguous) || value.key?(:unsafe))
764
+ end
765
+
766
+ def result(operation, request, changes: [], diagnostics: [], render_report: {}, verification: {}, **payload)
767
+ Ast::Merge::ProviderResult.build(
768
+ operation: operation,
769
+ success: true,
770
+ envelope: envelope(
771
+ request,
772
+ changes: changes,
773
+ diagnostics: diagnostics,
774
+ render_report: render_report,
775
+ verification: verification
776
+ ),
777
+ **payload
778
+ )
779
+ end
780
+
781
+ def failure(operation, request, category:, message:, changes: [], conflicts: [], fallbacks: [],
782
+ render_report: {}, verification: {}, **payload)
783
+ Ast::Merge::ProviderResult.build(
784
+ operation: operation,
785
+ success: false,
786
+ envelope: envelope(
787
+ request,
788
+ changes: changes,
789
+ conflicts: conflicts,
790
+ fallbacks: fallbacks,
791
+ diagnostics: [{ category: category, severity: :error, message: message, blocking: true }],
792
+ render_report: render_report,
793
+ verification: verification
794
+ ),
795
+ **payload
796
+ )
797
+ end
798
+
799
+ def envelope(request, **fields)
800
+ {
801
+ provider: {
802
+ provider_id: provider_id,
803
+ family: family,
804
+ dialect: request[:dialect] || backend.dialects.first,
805
+ backend: request[:backend] || backend.id,
806
+ package: backend.package
807
+ },
808
+ profile: { profile_id: request[:profile_id] || DEFAULT_PROFILE }
809
+ }.merge(fields)
810
+ end
811
+ end
812
+ # rubocop:enable Metrics/AbcSize, Metrics/ClassLength, Metrics/CyclomaticComplexity, Metrics/MethodLength, Metrics/ParameterLists, Metrics/PerceivedComplexity
813
+ end
814
+ end