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.
- checksums.yaml +4 -4
- checksums.yaml.gz.sig +0 -0
- data/README.md +3 -11
- data/lib/markdown/merge/rspec/shared_examples/source_preserving_provider.rb +338 -0
- data/lib/markdown/merge/source_preserving_provider.rb +814 -0
- data/lib/markdown/merge/version.rb +1 -1
- data/lib/markdown/merge.rb +38 -0
- data/sig/markdown/merge.rbs +54 -0
- data.tar.gz.sig +0 -0
- metadata +22 -20
- metadata.gz.sig +0 -0
|
@@ -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
|