markdown-merge 7.0.0 → 7.1.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (49) hide show
  1. checksums.yaml +4 -4
  2. checksums.yaml.gz.sig +0 -0
  3. data/LICENSE.md +13 -0
  4. data/README.md +673 -0
  5. data/lib/markdown/merge/backend_support.rb +200 -0
  6. data/lib/markdown/merge/cleanse/block_spacing.rb +248 -0
  7. data/lib/markdown/merge/cleanse/code_fence_spacing.rb +294 -0
  8. data/lib/markdown/merge/cleanse/condensed_link_refs.rb +411 -0
  9. data/lib/markdown/merge/cleanse/list_marker_duplication.rb +66 -0
  10. data/lib/markdown/merge/cleanse/templating_corruption.rb +86 -0
  11. data/lib/markdown/merge/cleanse.rb +44 -0
  12. data/lib/markdown/merge/code_block_match_refiner.rb +111 -0
  13. data/lib/markdown/merge/code_block_merger.rb +742 -0
  14. data/lib/markdown/merge/comment_tracker.rb +42 -0
  15. data/lib/markdown/merge/conflict_resolver.rb +199 -0
  16. data/lib/markdown/merge/debug_logger.rb +26 -0
  17. data/lib/markdown/merge/document_problems.rb +190 -0
  18. data/lib/markdown/merge/file_aligner.rb +496 -0
  19. data/lib/markdown/merge/file_analysis.rb +689 -0
  20. data/lib/markdown/merge/file_analysis_base.rb +766 -0
  21. data/lib/markdown/merge/freeze_node.rb +93 -0
  22. data/lib/markdown/merge/gap_line_node.rb +142 -0
  23. data/lib/markdown/merge/link_definition_formatter.rb +49 -0
  24. data/lib/markdown/merge/link_definition_node.rb +157 -0
  25. data/lib/markdown/merge/link_parser.rb +421 -0
  26. data/lib/markdown/merge/link_reference_rehydrator.rb +320 -0
  27. data/lib/markdown/merge/list_match_refiner.rb +98 -0
  28. data/lib/markdown/merge/list_merger.rb +322 -0
  29. data/lib/markdown/merge/markdown_structure.rb +123 -0
  30. data/lib/markdown/merge/merge_result.rb +483 -0
  31. data/lib/markdown/merge/node_type_normalizer.rb +126 -0
  32. data/lib/markdown/merge/output_builder.rb +248 -0
  33. data/lib/markdown/merge/partial_template_merger.rb +555 -0
  34. data/lib/markdown/merge/preservation_support.rb +291 -0
  35. data/lib/markdown/merge/rspec/shared_examples/source_preserving_provider.rb +338 -0
  36. data/lib/markdown/merge/smart_merger.rb +269 -0
  37. data/lib/markdown/merge/smart_merger_base.rb +1490 -0
  38. data/lib/markdown/merge/source_preserving_provider.rb +814 -0
  39. data/lib/markdown/merge/table_match_algorithm.rb +499 -0
  40. data/lib/markdown/merge/table_match_refiner.rb +132 -0
  41. data/lib/markdown/merge/version.rb +5 -3
  42. data/lib/markdown/merge/whitespace_normalizer.rb +243 -0
  43. data/lib/markdown/merge/wrapper_support.rb +194 -0
  44. data/lib/markdown/merge.rb +271 -87
  45. data/lib/markdown-merge.rb +7 -1
  46. data/sig/markdown/merge.rbs +62 -0
  47. data.tar.gz.sig +0 -0
  48. metadata +289 -15
  49. metadata.gz.sig +0 -0
@@ -0,0 +1,291 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Markdown
4
+ module Merge
5
+ # Shared Markdown-local helper methods for statement classification,
6
+ # standalone comment fragment preservation, and remove-plan-backed range filtering.
7
+ #
8
+ # Provides the canonical gap-line / blank-gap-line / non-blank-gap-line /
9
+ # structural-for-preservation predicate set used by both SmartMergerBase
10
+ # (removal mode) and PartialTemplateMerger (replace-mode insertion indexing).
11
+ # Both classes should call these predicates rather than performing direct
12
+ # is_a?(GapLineNode) checks.
13
+ module PreservationSupport
14
+ private
15
+
16
+ def normalized_preserved_fragment_text(text)
17
+ text.to_s.sub(/\n+\z/, '')
18
+ end
19
+
20
+ def standalone_comment_text?(text)
21
+ normalized_preserved_fragment_text(text).strip.match?(CommentTracker::STANDALONE_HTML_COMMENT_REGEX)
22
+ end
23
+
24
+ def standalone_comment_node?(node, analysis)
25
+ return false unless node.respond_to?(:source_position)
26
+ return false unless analysis.respond_to?(:comment_tracker)
27
+ return false unless analysis.respond_to?(:comment_node_at)
28
+ return false unless analysis.comment_tracker
29
+
30
+ pos = node.source_position
31
+ start_line = pos&.dig(:start_line)
32
+ end_line = pos&.dig(:end_line)
33
+ return false unless start_line && end_line
34
+ return false unless start_line == end_line
35
+
36
+ !!analysis.comment_node_at(start_line)
37
+ end
38
+
39
+ def link_definition_node?(node)
40
+ node.is_a?(LinkDefinitionNode) || (node.respond_to?(:merge_type) && node.merge_type == :link_definition)
41
+ end
42
+
43
+ def gap_line_node?(node)
44
+ node.is_a?(GapLineNode) ||
45
+ (node.respond_to?(:merge_type) && node.merge_type == :gap_line) ||
46
+ (node.respond_to?(:type) && node.type == :gap_line)
47
+ end
48
+
49
+ def blank_gap_line_node?(node)
50
+ return false unless gap_line_node?(node)
51
+ return node.blank? if node.respond_to?(:blank?)
52
+
53
+ text = if node.respond_to?(:text)
54
+ node.text
55
+ elsif node.respond_to?(:content)
56
+ node.content
57
+ else
58
+ ''
59
+ end
60
+
61
+ text.to_s.strip.empty?
62
+ end
63
+
64
+ # Returns true when the node is a gap line whose content is non-blank
65
+ # (e.g. a consumed link-reference definition or similar inline content
66
+ # that was mapped to a GapLineNode).
67
+ #
68
+ # This is the canonical replacement for `node.is_a?(GapLineNode) && !node.blank?`
69
+ # and handles wrapped gap-line statement nodes uniformly alongside real
70
+ # GapLineNode instances.
71
+ #
72
+ # @param node [Object] The node to classify
73
+ # @return [Boolean] true when the node is a gap line with non-blank content
74
+ def non_blank_gap_line_node?(node)
75
+ gap_line_node?(node) && !blank_gap_line_node?(node)
76
+ end
77
+
78
+ def structural_preservation_statement?(statement, analysis)
79
+ !gap_line_node?(statement) &&
80
+ !standalone_comment_node?(statement, analysis) &&
81
+ !link_definition_node?(statement)
82
+ end
83
+
84
+ def standalone_comment_region?(region)
85
+ standalone_comment_text?(region.respond_to?(:text) ? region.text : nil)
86
+ end
87
+
88
+ def preserved_comment_region_key(region)
89
+ [
90
+ region.respond_to?(:start_line) ? region.start_line : nil,
91
+ region.respond_to?(:end_line) ? region.end_line : nil,
92
+ normalized_preserved_fragment_text(region.respond_to?(:text) ? region.text : nil)
93
+ ]
94
+ end
95
+
96
+ def preserved_comment_node_key(node, analysis, text: nil)
97
+ pos = node.respond_to?(:source_position) ? node.source_position : nil
98
+
99
+ [
100
+ pos&.dig(:start_line),
101
+ pos&.dig(:end_line),
102
+ normalized_preserved_fragment_text(text || source_text_for_preserved_node(node, analysis))
103
+ ]
104
+ end
105
+
106
+ def region_within_removed_range?(region, remove_plan)
107
+ return false unless region
108
+
109
+ start_line = region.respond_to?(:start_line) ? region.start_line : nil
110
+ end_line = region.respond_to?(:end_line) ? region.end_line : nil
111
+ return true unless start_line && end_line
112
+
113
+ start_line >= remove_plan.remove_start_line && end_line <= remove_plan.remove_end_line
114
+ end
115
+
116
+ def remove_plan_preserved_comment_regions(remove_plan)
117
+ return [] unless remove_plan
118
+
119
+ regions = Array(remove_plan.promoted_comment_regions)
120
+ regions << remove_plan.trailing_boundary&.comment_attachment&.leading_region
121
+
122
+ seen = Set.new
123
+ regions.each_with_object([]) do |region, preserved_regions|
124
+ next unless standalone_comment_region?(region)
125
+ next unless region_within_removed_range?(region, remove_plan)
126
+
127
+ region_key = preserved_comment_region_key(region)
128
+ next if seen.include?(region_key)
129
+
130
+ seen << region_key
131
+ preserved_regions << region
132
+ end
133
+ end
134
+
135
+ def remove_plan_preserved_comment_keys(remove_plan)
136
+ remove_plan_preserved_comment_regions(remove_plan).each_with_object(Set.new) do |region, keys|
137
+ keys << preserved_comment_region_key(region)
138
+ end
139
+ end
140
+
141
+ def rebase_preserved_comment_keys(keys, line_offset:)
142
+ Array(keys).each_with_object(Set.new) do |key, rebased_keys|
143
+ start_line, end_line, text = Array(key)
144
+ rebased_keys << [
145
+ start_line ? start_line - line_offset : nil,
146
+ end_line ? end_line - line_offset : nil,
147
+ text
148
+ ]
149
+ end
150
+ end
151
+
152
+ def remove_plan_owns_comment_node?(node, analysis, remove_plan, preserved_comment_keys: nil)
153
+ return false unless remove_plan
154
+ return false unless standalone_comment_node?(node, analysis)
155
+
156
+ keys = preserved_comment_keys || remove_plan_preserved_comment_keys(remove_plan)
157
+ region = comment_region_for_node(node, analysis, kind: :orphan)
158
+ return false unless region_within_removed_range?(region, remove_plan)
159
+
160
+ keys.include?(preserved_comment_region_key(region))
161
+ end
162
+
163
+ def remove_plan_preserved_comment_keys_for_nodes(remove_plan, nodes:, analysis:)
164
+ keys = remove_plan_preserved_comment_keys(remove_plan)
165
+
166
+ Array(nodes).each do |node|
167
+ next unless standalone_comment_node?(node, analysis)
168
+
169
+ region = comment_region_for_node(node, analysis, kind: :orphan)
170
+ next unless region_within_removed_range?(region, remove_plan)
171
+
172
+ keys << preserved_comment_node_key(node, analysis)
173
+ end
174
+
175
+ keys
176
+ end
177
+
178
+ def remove_plan_comment_insertion_specs(remove_plan, insertion_index_by_owner:, final_insertion_index:)
179
+ return [] unless remove_plan
180
+
181
+ allowed_region_keys = remove_plan_preserved_comment_keys(remove_plan)
182
+ seen_region_keys = Set.new
183
+
184
+ Array(remove_plan.removed_attachments).each_with_object([]) do |attachment, specs|
185
+ append_remove_plan_comment_insertion_spec(
186
+ specs,
187
+ region: attachment.respond_to?(:leading_region) ? attachment.leading_region : nil,
188
+ insertion_index: insertion_index_by_owner[attachment_owner_key(attachment)],
189
+ gap_count: blank_gap_count(attachment.respond_to?(:leading_gap) ? attachment.leading_gap : nil),
190
+ allowed_region_keys: allowed_region_keys,
191
+ seen_region_keys: seen_region_keys
192
+ )
193
+ end.tap do |specs|
194
+ append_remove_plan_comment_insertion_spec(
195
+ specs,
196
+ region: remove_plan.trailing_boundary&.comment_attachment&.leading_region,
197
+ insertion_index: final_insertion_index,
198
+ gap_count: blank_gap_count(remove_plan.trailing_boundary&.comment_attachment&.leading_gap),
199
+ allowed_region_keys: allowed_region_keys,
200
+ seen_region_keys: seen_region_keys
201
+ )
202
+ end
203
+ end
204
+
205
+ def comment_region_for_node(node, analysis, kind: :orphan, full_line_only: true)
206
+ return unless node.respond_to?(:source_position)
207
+ return unless analysis.respond_to?(:comment_region_for_range)
208
+
209
+ pos = node.source_position
210
+ start_line = pos&.dig(:start_line)
211
+ end_line = pos&.dig(:end_line)
212
+ return unless start_line && end_line
213
+
214
+ analysis.comment_region_for_range(start_line..end_line, kind: kind, full_line_only: full_line_only)
215
+ end
216
+
217
+ def preserved_fragment_for_node(
218
+ node,
219
+ analysis,
220
+ template_has_standalone_comments:,
221
+ template_link_definition_signatures:
222
+ )
223
+ if standalone_comment_node?(node, analysis)
224
+ return if template_has_standalone_comments
225
+
226
+ {
227
+ kind: :standalone_comment,
228
+ text: normalized_preserved_fragment_text(source_text_for_preserved_node(node, analysis))
229
+ }
230
+ elsif link_definition_node?(node)
231
+ return if template_link_definition_signatures.include?(node.signature)
232
+
233
+ {
234
+ kind: :link_definition,
235
+ text: normalized_preserved_fragment_text(source_text_for_preserved_node(node, analysis))
236
+ }
237
+ end
238
+ end
239
+
240
+ def preserved_fragment_separator(gap_count:, previous_kind:, current_kind:)
241
+ return "\n\n" if gap_count.positive?
242
+ return "\n" if previous_kind == :link_definition && current_kind == :link_definition
243
+
244
+ "\n\n"
245
+ end
246
+
247
+ def blank_gap_count(gap)
248
+ return 0 unless gap&.respond_to?(:blank_line_count)
249
+
250
+ gap.blank_line_count
251
+ end
252
+
253
+ def attachment_owner_key(owner_or_attachment)
254
+ owner = if owner_or_attachment.respond_to?(:owner)
255
+ owner_or_attachment.owner
256
+ else
257
+ owner_or_attachment
258
+ end
259
+
260
+ owner&.object_id
261
+ end
262
+
263
+ def source_text_for_preserved_node(node, analysis)
264
+ if respond_to?(:node_to_source, true)
265
+ node_to_source(node, analysis)
266
+ else
267
+ ''
268
+ end
269
+ end
270
+
271
+ def append_remove_plan_comment_insertion_spec(specs, region:, insertion_index:, gap_count:, allowed_region_keys:,
272
+ seen_region_keys:)
273
+ return unless region && insertion_index
274
+
275
+ region_key = preserved_comment_region_key(region)
276
+ return unless allowed_region_keys.include?(region_key)
277
+ return if seen_region_keys.include?(region_key)
278
+
279
+ seen_region_keys << region_key
280
+ specs << {
281
+ insertion_index: insertion_index,
282
+ fragment: {
283
+ kind: :standalone_comment,
284
+ text: normalized_preserved_fragment_text(region.text)
285
+ },
286
+ gap_count: gap_count
287
+ }
288
+ end
289
+ end
290
+ end
291
+ end
@@ -0,0 +1,338 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'json'
4
+ require 'ast/merge/rspec/shared_examples'
5
+
6
+ # rubocop:disable Metrics/BlockLength -- the conservative provider safety contract is intentionally executable as one matrix
7
+ RSpec.shared_examples 'Markdown::Merge::SourcePreservingProvider' do
8
+ let(:base) { "# Alpha\n\nalpha\n\n# Beta\n\nbeta\n" }
9
+ let(:stable) { "# Stable\n\nstable\n" }
10
+ let(:provider_conformance) do
11
+ {
12
+ dialect: provider_dialect,
13
+ backend: provider_backend,
14
+ profile_id: :source_preserving,
15
+ role: provider_role,
16
+ requests: {
17
+ analyze: { source: stable },
18
+ diff2: { before_source: stable, after_source: stable.sub('stable', 'changed') },
19
+ merge2: { current_source: stable, incoming_source: "#{stable}# Added\n\nadded\n" },
20
+ merge3: {
21
+ base_source: base,
22
+ ours_source: base.sub('alpha', 'ours'),
23
+ theirs_source: base.sub('beta', 'theirs')
24
+ }
25
+ },
26
+ invalid_merge3: {
27
+ base_source: stable,
28
+ ours_source: "# Broken\0\n",
29
+ theirs_source: stable,
30
+ source_role: :ours
31
+ },
32
+ base_adversarial_merge3: {
33
+ request: {
34
+ base_source: "# Obsolete\n\nold\n\n#{stable}",
35
+ ours_source: "# Obsolete\n\nold\n\n#{stable}",
36
+ theirs_source: stable
37
+ },
38
+ expected_value: [[1, 'Stable']]
39
+ },
40
+ parse_output: lambda { |source|
41
+ provider.analyze(source: source).dig(:analysis, :owners).map { |owner| owner.fetch(:signature) }
42
+ }
43
+ }
44
+ end
45
+
46
+ it 'passes the shared provider contract for every operation and exact selector' do
47
+ Ast::Merge.register_provider(provider, replace: true)
48
+ registration = Ast::Merge::ProviderContract.validate_provider!(provider)
49
+ selectors = {
50
+ provider_id: provider.provider_id,
51
+ family: provider.family,
52
+ dialect: provider_dialect,
53
+ backend: provider_backend,
54
+ profile_id: :source_preserving
55
+ }
56
+
57
+ expect(registration.dig(:capabilities, :role)).to eq(provider_role)
58
+ expect(Ast::Merge.resolve_provider(**selectors, operation: :merge3)).to equal(provider)
59
+ provider_conformance.fetch(:requests).each do |operation, request|
60
+ result = Ast::Merge.dispatch_provider(operation, selectors.merge(request))
61
+ expect(Ast::Merge::ProviderContract.validate_result!(operation, result)).to include(ok: true)
62
+ end
63
+ unsupported = Ast::Merge.dispatch_provider(
64
+ :merge3,
65
+ selectors.merge(provider_conformance.dig(:requests, :merge3), dialect: :unadvertised)
66
+ )
67
+ expect(unsupported).to include(
68
+ ok: false,
69
+ diagnostics: contain_exactly(hash_including(category: :unsupported_capability))
70
+ )
71
+ end
72
+
73
+ it 'advertises and resolves its exact selector' do
74
+ expect(provider.capabilities).to include(
75
+ operations: %i[analyze diff2 merge2 merge3],
76
+ dialects: include(provider_dialect),
77
+ backends: [provider_backend],
78
+ profiles: %i[source_preserving],
79
+ role: provider_role
80
+ )
81
+ expect(
82
+ Ast::Merge.resolve_provider(
83
+ provider_id: provider.provider_id,
84
+ family: :markdown,
85
+ dialect: provider_dialect,
86
+ backend: provider_backend,
87
+ profile_id: :source_preserving,
88
+ operation: :merge3
89
+ )
90
+ ).to equal(provider)
91
+ end
92
+
93
+ it 'analyzes unique parser-proven heading identities and emits JSON-ready results' do
94
+ result = provider.analyze(source: base)
95
+
96
+ expect(result).to include(ok: true)
97
+ expect(result.dig(:analysis, :owners).map { |owner| owner.fetch(:signature) }).to eq(
98
+ [[1, 'Alpha'], [1, 'Beta']]
99
+ )
100
+ expect { JSON.generate(Ast::Merge.json_ready(result)) }.not_to raise_error
101
+ end
102
+
103
+ it 'matches the shared safe-subset semantics at its explicit backend boundary' do
104
+ result = provider.analyze(source: "# One\n\nbody\n\n# Two\n\nbody\n")
105
+
106
+ expect(result.dig(:analysis, :backend)).to eq(provider_backend)
107
+ expect(result.dig(:analysis, :delegated_backend)).to eq(provider_backend)
108
+ expect(result.dig(:analysis, :owners).map { |owner| owner.fetch(:signature) }).to eq(
109
+ [[1, 'One'], [1, 'Two']]
110
+ )
111
+ end
112
+
113
+ it 'diffs and merges independent section edits, additions, and deletions' do
114
+ ours = base.sub('alpha', 'ours alpha')
115
+ theirs = base.sub("# Beta\n\nbeta\n", '')
116
+ deletion = provider.merge3(base_source: base, ours_source: ours, theirs_source: theirs)
117
+
118
+ addition_base = "#{base}\n"
119
+ ours_addition = "#{addition_base}# Ours\n\nours addition\n\n"
120
+ theirs_addition = "#{addition_base}# Theirs\n\ntheirs addition\n\n"
121
+ additions = provider.merge3(
122
+ base_source: addition_base,
123
+ ours_source: ours_addition,
124
+ theirs_source: theirs_addition
125
+ )
126
+ edit_diff = provider.diff2(before_source: base, after_source: ours)
127
+ addition_diff = provider.diff2(before_source: addition_base, after_source: ours_addition)
128
+
129
+ expect(edit_diff.fetch(:changes).map { |change| change.fetch(:change) }).to contain_exactly(:edited)
130
+ expect(addition_diff.fetch(:changes).map { |change| change.fetch(:change) }).to contain_exactly(:added)
131
+ expect(deletion).to include(ok: true)
132
+ expect(deletion.fetch(:output)).to include('ours alpha')
133
+ expect(deletion.fetch(:output)).not_to include('# Beta')
134
+ expect(additions).to include(ok: true)
135
+ expect(additions.fetch(:output)).to include('# Ours', '# Theirs')
136
+ expect(additions.fetch(:verification)).to include(
137
+ base_participated: true,
138
+ output_reparsed: true,
139
+ semantic_match: true,
140
+ byte_provenance_verified: true,
141
+ delegated_backend_verified: true,
142
+ backend: provider_backend
143
+ )
144
+ end
145
+
146
+ it 'localizes a same-section conflict to the parser-proven owner range' do
147
+ result = provider.merge3(
148
+ base_source: base,
149
+ ours_source: base.sub('alpha', 'ours'),
150
+ theirs_source: base.sub('alpha', 'theirs')
151
+ )
152
+
153
+ expect(result).to include(ok: false)
154
+ expect(result.fetch(:conflicts)).to contain_exactly(hash_including(category: :edit_edit))
155
+ expect(result.dig(:render_report, :strategy)).to eq(:section_localized_conflict)
156
+ expect(result.fetch(:conflicted_output)).to include('# Beta', 'beta')
157
+ end
158
+
159
+ it 'fails closed for duplicate, renamed, level-changed, nested, and headingless ownership' do
160
+ duplicate = "# Alpha\n\none\n\n# Alpha\n\ntwo\n"
161
+ expect(provider.analyze(source: duplicate)).to include(
162
+ ok: false,
163
+ diagnostics: include(hash_including(category: :ambiguous_owner))
164
+ )
165
+
166
+ {
167
+ rename: base.sub('# Alpha', '# Renamed'),
168
+ rename_with_body_edit: base.sub("# Alpha\n\nalpha", "# Renamed\n\nrewritten"),
169
+ level: base.sub('# Alpha', '## Alpha')
170
+ }.each_value do |ours|
171
+ result = provider.merge3(base_source: base, ours_source: ours, theirs_source: base.sub('beta', 'theirs'))
172
+ expect(result).to include(ok: false)
173
+ expect(result.fetch(:diagnostics)).to include(hash_including(blocking: true))
174
+ expect(result.dig(:render_report, :strategy)).to eq(:full_file_conflict)
175
+ end
176
+
177
+ expect(provider.analyze(source: "# Alpha\n\n## Nested\n\ntext\n")).to include(
178
+ ok: false,
179
+ diagnostics: include(hash_including(category: :unsafe_source_range))
180
+ )
181
+ expect(provider.analyze(source: "headingless prose\n")).to include(
182
+ ok: false,
183
+ diagnostics: include(hash_including(category: :unsafe_source_range))
184
+ )
185
+ end
186
+
187
+ it 'fails closed for setext headings and non-whitespace prose outside sections' do
188
+ expect(provider.analyze(source: "Alpha\n=====\n\ntext\n")).to include(
189
+ ok: false,
190
+ diagnostics: include(hash_including(category: :unsafe_source_range))
191
+ )
192
+ expect(provider.analyze(source: "preamble\n\n# Alpha\n\ntext\n")).to include(
193
+ ok: false,
194
+ diagnostics: include(hash_including(category: :unsafe_source_range))
195
+ )
196
+ expect(provider.analyze(source: "\n# Alpha\n\ntext\n")).to include(
197
+ ok: false,
198
+ diagnostics: include(hash_including(category: :unsafe_source_range))
199
+ )
200
+ end
201
+
202
+ it 'does not promote headings nested inside block containers to document section owners' do
203
+ quoted = "# Alpha\n\n> # Quoted\n> body\n\n# Beta\n\nbeta\n"
204
+ result = provider.analyze(source: quoted)
205
+
206
+ expect(result).to include(ok: true)
207
+ expect(result.dig(:analysis, :owners).map { |owner| owner.fetch(:signature) }).to eq(
208
+ [[1, 'Alpha'], [1, 'Beta']]
209
+ )
210
+ end
211
+
212
+ it 'cleanly composes independent deletions that remove every owned section' do
213
+ ours = "# Beta\n\nbeta\n"
214
+ theirs = "# Alpha\n\nalpha\n\n"
215
+ result = provider.merge3(base_source: base, ours_source: ours, theirs_source: theirs)
216
+
217
+ expect(result).to include(ok: true, output: '')
218
+ expect(result.fetch(:verification)).to include(
219
+ output_reparsed: true,
220
+ semantic_match: true,
221
+ planned_owner_count: 0,
222
+ output_owner_count: 0
223
+ )
224
+ end
225
+
226
+ it 'exactly preserves complex Markdown and a missing final newline for one-sided winners' do
227
+ exact = <<~'MARKDOWN'.chomp.sub('<HARD_BREAK>', ' ')
228
+ ---
229
+ title: Exact
230
+ ---
231
+ # Alpha
232
+
233
+ - list
234
+ - item
235
+ 3. numbered
236
+ - [x] task
237
+
238
+ > quoted<HARD_BREAK>
239
+ > hard break &amp; \*escaped\*
240
+
241
+ ```ruby
242
+ puts "# not a heading"
243
+ ```
244
+
245
+ | a | b |
246
+ |---|---|
247
+ | 1 | 2 |
248
+
249
+ <div data-x='1'>inline HTML</div>
250
+
251
+ [ref]: https://example.test "Title"
252
+ [use][ref]
253
+
254
+ Footnote[^1].
255
+ [^1]: exact footnote
256
+
257
+ :::note
258
+ extension directive
259
+ :::
260
+ MARKDOWN
261
+ result = provider.merge3(base_source: base, ours_source: base, theirs_source: exact)
262
+
263
+ expect(result).to include(ok: true, output: exact)
264
+ expect(result.dig(:render_report, :strategy)).to eq(:exact_revision)
265
+ expect(result.fetch(:verification)).to include(byte_exact: true, semantic_match: true, base_participated: true)
266
+ end
267
+
268
+ it 'copies complex independently selected section fragments byte-for-byte in a composite' do
269
+ alpha = <<~'MARKDOWN'
270
+ # Alpha
271
+
272
+ - [ ] task
273
+ 7. numbered
274
+
275
+ > quoted
276
+
277
+ ~~~~ruby extra
278
+ puts "&amp;"
279
+ ~~~~
280
+
281
+ | a | b |
282
+ |---|---|
283
+ | 1 | 2 |
284
+
285
+ <span data-x='1'>html</span>
286
+
287
+ [ref]: https://example.test
288
+ [use][ref] and footnote[^1].
289
+ [^1]: exact
290
+ MARKDOWN
291
+ beta = "# Beta\n\nbeta\n"
292
+ composite_base = "#{alpha}\n#{beta}"
293
+ ours_alpha = alpha.sub('- [ ] task', '- [x] task')
294
+ theirs_beta = beta.sub('beta', 'theirs')
295
+ result = provider.merge3(
296
+ base_source: composite_base,
297
+ ours_source: "#{ours_alpha}\n#{beta}",
298
+ theirs_source: "#{alpha}\n#{theirs_beta}"
299
+ )
300
+
301
+ expect(result).to include(ok: true, output: "#{ours_alpha}\n#{theirs_beta}")
302
+ expect(result.dig(:render_report, :provenance)).to all(include(copied_source: true))
303
+ end
304
+
305
+ it 'accepts parser-valid ambiguous exact winners with nonblocking diagnostics' do
306
+ duplicate = "# Same\n\none\n\n# Same\n\ntwo\n"
307
+ result = provider.merge3(base_source: base, ours_source: base, theirs_source: duplicate)
308
+
309
+ expect(result).to include(ok: true, output: duplicate)
310
+ expect(result.fetch(:diagnostics)).to include(
311
+ hash_including(category: :ambiguous_owner, blocking: false, source_role: :theirs)
312
+ )
313
+ end
314
+
315
+ it 'rejects malformed exact winners with selected-role attribution' do
316
+ malformed = "# Broken\0\n"
317
+ result = provider.merge3(base_source: base, ours_source: base, theirs_source: malformed)
318
+
319
+ expect(result).to include(ok: false, source_role: :theirs)
320
+ expect(result.fetch(:diagnostics)).to contain_exactly(
321
+ hash_including(category: :parse_error, blocking: true)
322
+ )
323
+ end
324
+
325
+ it 'uses the base in adversarial deletion merges' do
326
+ old_base = "# Obsolete\n\nold\n\n#{base}"
327
+ result = provider.merge3(base_source: old_base, ours_source: old_base, theirs_source: base)
328
+
329
+ expect(result).to include(ok: true, output: base)
330
+ expect(result.fetch(:output)).not_to include('Obsolete')
331
+ expect(result.dig(:verification, :base_participated)).to be(true)
332
+ end
333
+
334
+ it 'preserves standalone and legacy APIs' do
335
+ expect(legacy_assertion.call).to be(true)
336
+ end
337
+ end
338
+ # rubocop:enable Metrics/BlockLength