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.
- checksums.yaml +4 -4
- checksums.yaml.gz.sig +0 -0
- data/LICENSE.md +13 -0
- data/README.md +673 -0
- data/lib/markdown/merge/backend_support.rb +200 -0
- data/lib/markdown/merge/cleanse/block_spacing.rb +248 -0
- data/lib/markdown/merge/cleanse/code_fence_spacing.rb +294 -0
- data/lib/markdown/merge/cleanse/condensed_link_refs.rb +411 -0
- data/lib/markdown/merge/cleanse/list_marker_duplication.rb +66 -0
- data/lib/markdown/merge/cleanse/templating_corruption.rb +86 -0
- data/lib/markdown/merge/cleanse.rb +44 -0
- data/lib/markdown/merge/code_block_match_refiner.rb +111 -0
- data/lib/markdown/merge/code_block_merger.rb +742 -0
- data/lib/markdown/merge/comment_tracker.rb +42 -0
- data/lib/markdown/merge/conflict_resolver.rb +199 -0
- data/lib/markdown/merge/debug_logger.rb +26 -0
- data/lib/markdown/merge/document_problems.rb +190 -0
- data/lib/markdown/merge/file_aligner.rb +496 -0
- data/lib/markdown/merge/file_analysis.rb +689 -0
- data/lib/markdown/merge/file_analysis_base.rb +766 -0
- data/lib/markdown/merge/freeze_node.rb +93 -0
- data/lib/markdown/merge/gap_line_node.rb +142 -0
- data/lib/markdown/merge/link_definition_formatter.rb +49 -0
- data/lib/markdown/merge/link_definition_node.rb +157 -0
- data/lib/markdown/merge/link_parser.rb +421 -0
- data/lib/markdown/merge/link_reference_rehydrator.rb +320 -0
- data/lib/markdown/merge/list_match_refiner.rb +98 -0
- data/lib/markdown/merge/list_merger.rb +322 -0
- data/lib/markdown/merge/markdown_structure.rb +123 -0
- data/lib/markdown/merge/merge_result.rb +483 -0
- data/lib/markdown/merge/node_type_normalizer.rb +126 -0
- data/lib/markdown/merge/output_builder.rb +248 -0
- data/lib/markdown/merge/partial_template_merger.rb +555 -0
- data/lib/markdown/merge/preservation_support.rb +291 -0
- data/lib/markdown/merge/rspec/shared_examples/source_preserving_provider.rb +338 -0
- data/lib/markdown/merge/smart_merger.rb +269 -0
- data/lib/markdown/merge/smart_merger_base.rb +1490 -0
- data/lib/markdown/merge/source_preserving_provider.rb +814 -0
- data/lib/markdown/merge/table_match_algorithm.rb +499 -0
- data/lib/markdown/merge/table_match_refiner.rb +132 -0
- data/lib/markdown/merge/version.rb +5 -3
- data/lib/markdown/merge/whitespace_normalizer.rb +243 -0
- data/lib/markdown/merge/wrapper_support.rb +194 -0
- data/lib/markdown/merge.rb +271 -87
- data/lib/markdown-merge.rb +7 -1
- data/sig/markdown/merge.rbs +62 -0
- data.tar.gz.sig +0 -0
- metadata +289 -15
- 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 & \*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 "&"
|
|
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
|