rbs-merge 7.1.1 → 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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 124ffb8f50edbb602b5748527d2ac661d0a75a742e83ec7e6790970719d905ab
4
- data.tar.gz: 727ce4ec649df9bc2514d965d35bb600e5ab6f6a46e7d368214c67788777ed30
3
+ metadata.gz: 3ff859ca9d5b341aeafadc243b34eb38be17976d5508c472d34357512790b6f2
4
+ data.tar.gz: 1cb7bb66b2472e9bae12c4a773cefb9d9123572f56f1bfbdb8db05fabb796947
5
5
  SHA512:
6
- metadata.gz: 561f34a7ec7ea11b00df476423bd6c42f6b2e31daa84acbf3a8a24eaf05e94adc297134ece3ea1a83797dcb30f6d3d6a2758ebfc15c3d6245495b81bbffff047
7
- data.tar.gz: 87968e5f4d3bdcb51b1f5a9d0a64106a265225de49361e8921f9bc97b7486537fe4c1d0fda854c79489c44406a407859bf35ae83d5392faaca1d09e5a258d67c
6
+ metadata.gz: 58b8b4f4fee83df915c21f86c8617f28fa3ec9b8fb4400993eb29336ac751a4141db444e9ce547e930042276d293b0bfb80859a781e672562c2b67e5fc1500bf
7
+ data.tar.gz: 5a2948e0f81c006710f73cfc52e15be93ee00c08ba97d6cb17a1bc67f00374d77e59a9de0efa0b0d6faf635b7f70d6b32bd53024a16889989cae0728bf74e437
checksums.yaml.gz.sig CHANGED
Binary file
data/README.md CHANGED
@@ -93,7 +93,7 @@ File.write("merged.rbs", result.to_s)
93
93
 
94
94
  ### Compatibility
95
95
 
96
- Compatible with MRI Ruby 4.0.0+, and concordant releases of JRuby, and TruffleRuby.
96
+ Compatible with MRI Ruby 4.0.0+, JRuby, and TruffleRuby.
97
97
  CI workflows and Appraisals are generated for MRI Ruby 4.0.0+.
98
98
  This test floor is configured by `ruby.test_minimum` in `.kettle-jem.yml` and
99
99
  may be higher than the gem's runtime compatibility floor when legacy Rubies are
@@ -442,20 +442,12 @@ See [SECURITY.md][🔐security].
442
442
  ## 🤝 Contributing
443
443
 
444
444
  If you need some ideas of where to help, you could work on adding more code coverage,
445
- or if it is already 💯 (see [below](#code-coverage)) check [issues][🤝gh-issues] or [PRs][🤝gh-pulls],
446
- or use the gem and think about how it could be better.
445
+ check [issues][🤝gh-issues] or [PRs][🤝gh-pulls], or use the gem and think about how it could be better.
447
446
 
448
447
  We [![Keep A Changelog][📗keep-changelog-img]][📗keep-changelog] so if you make changes, remember to update it.
449
448
 
450
449
  See [CONTRIBUTING.md][🤝contributing] for more detailed instructions.
451
450
 
452
- ### Code Coverage
453
-
454
- <details markdown="1">
455
- <summary>Coverage service badges</summary>
456
-
457
- </details>
458
-
459
451
  ## 📌 Versioning
460
452
 
461
453
  This library follows [![Semantic Versioning 2.0.0][📌semver-img]][📌semver] for its public API where practical.
@@ -0,0 +1,891 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'digest'
4
+ require 'json'
5
+
6
+ module Rbs
7
+ module Merge
8
+ # Base-aware, source-preserving provider for native RBS declarations.
9
+ # rubocop:disable Metrics/AbcSize, Metrics/ClassLength, Metrics/CyclomaticComplexity, Metrics/MethodLength, Metrics/ParameterLists, Metrics/PerceivedComplexity -- provider decisions, source plans, and verification form one boundary
10
+ class Provider
11
+ DEFAULT_PROFILE = :source_preserving
12
+ Owner = Data.define(:id, :signature, :fingerprint, :start_line, :end_line, :role)
13
+ Document = Data.define(:source, :analysis, :owners, :by_id, :unmanaged_fingerprint)
14
+ ExactDocument = Data.define(:source, :analysis, :declarations, :issues, :role)
15
+ Decision = Data.define(:changes, :conflicts, :choices)
16
+
17
+ def provider_id = 'ruby.rbs'
18
+ def family = 'rbs'
19
+
20
+ def capabilities
21
+ {
22
+ operations: Ast::Merge::ProviderContract::OPERATIONS,
23
+ dialects: %i[rbs],
24
+ backends: %i[rbs],
25
+ profiles: [DEFAULT_PROFILE],
26
+ role: :workflow,
27
+ source_preservation: %i[exact_source declaration_fragments line_provenance reparse semantic_verification]
28
+ }.freeze
29
+ end
30
+
31
+ def analyze(request)
32
+ document = parse_document(:analyze, request, :source)
33
+ return document if provider_failure?(document)
34
+
35
+ result(
36
+ :analyze,
37
+ request,
38
+ analysis: {
39
+ backend: 'rbs',
40
+ valid: true,
41
+ declarations: document.owners.map { |owner| owner_description(owner) }
42
+ },
43
+ verification: { source_parsed: true }
44
+ )
45
+ end
46
+
47
+ def diff2(request)
48
+ before = parse_document(:diff2, request, :before)
49
+ return before if provider_failure?(before)
50
+
51
+ after = parse_document(:diff2, request, :after)
52
+ return after if provider_failure?(after)
53
+
54
+ changes = diff_documents(before, after)
55
+ result(
56
+ :diff2,
57
+ request,
58
+ diff: { changes: changes },
59
+ changes: changes,
60
+ verification: { before_parsed: true, after_parsed: true }
61
+ )
62
+ end
63
+
64
+ def merge2(request)
65
+ merge3_request = request.merge(
66
+ base_source: request.fetch(:current_source),
67
+ ours_source: request.fetch(:current_source),
68
+ theirs_source: request.fetch(:incoming_source)
69
+ )
70
+ merged = merge3(merge3_request)
71
+ merged.merge(
72
+ operation: :merge2,
73
+ verification: merged.fetch(:verification).except(:base_participated)
74
+ )
75
+ end
76
+
77
+ def merge3(request)
78
+ exact_role = exact_revision_role(request)
79
+ return merge3_exact(request, exact_role) if exact_role
80
+
81
+ documents = parse_merge3_documents(request)
82
+ return documents if provider_failure?(documents)
83
+
84
+ decision = decide(documents, include_unmanaged_conflict: true)
85
+ return render_conflicts(request, documents, decision) unless decision.conflicts.empty?
86
+
87
+ render_composite(request, documents, decision)
88
+ end
89
+
90
+ private
91
+
92
+ def parse_merge3_documents(request)
93
+ %i[base ours theirs].each_with_object({}) do |role, documents|
94
+ source = request.fetch(:"#{role}_source")
95
+ document = parse_source(source, role)
96
+ if document.is_a?(Hash) && document[:parse_error]
97
+ return parse_failure(:merge3, request, role, document)
98
+ elsif provider_failure?(document)
99
+ return unsafe_document_failure(request, role, document)
100
+ end
101
+
102
+ documents[role] = document
103
+ end
104
+ end
105
+
106
+ def parse_document(operation, request, role)
107
+ source = request.fetch(role == :source ? :source : :"#{role}_source")
108
+ parsed = parse_source(source, role)
109
+ return parse_failure(operation, request, role, parsed) if provider_failure?(parsed)
110
+
111
+ parsed
112
+ end
113
+
114
+ def parse_source(source, role)
115
+ analysis = TreeHaver.with_backend(:rbs) { FileAnalysis.new(source) }
116
+ unless analysis.errors.empty? && analysis.backend == :rbs
117
+ return { parse_error: analysis.errors.map(&:to_s).join('; '), source_role: role }
118
+ end
119
+
120
+ owners = analysis.declarations.map do |declaration|
121
+ wrapper = NodeWrapper.new(
122
+ declaration,
123
+ lines: source.split("\n", -1),
124
+ source: source,
125
+ backend: :rbs
126
+ )
127
+ return { unsafe_range: wrapper.signature, source_role: role } unless safe_range?(source, declaration.location)
128
+
129
+ signature = wrapper.signature
130
+ Owner.new(
131
+ id: owner_id(signature),
132
+ signature: signature,
133
+ fingerprint: owner_fingerprint(source, declaration),
134
+ start_line: wrapper.start_line,
135
+ end_line: wrapper.end_line,
136
+ role: role
137
+ )
138
+ end
139
+ duplicate = owners.group_by(&:id).find { |_id, matches| matches.length > 1 }
140
+ return { ambiguous_owner: duplicate.first, source_role: role } if duplicate
141
+ return { unsafe_range: :overlapping_declarations, source_role: role } unless non_overlapping?(owners)
142
+
143
+ Document.new(
144
+ source: source,
145
+ analysis: analysis,
146
+ owners: owners.freeze,
147
+ by_id: owners.to_h { |owner| [owner.id, owner] }.freeze,
148
+ unmanaged_fingerprint: unmanaged_fingerprint(source, owners)
149
+ )
150
+ rescue StandardError => e
151
+ { parse_error: e.message, source_role: role }
152
+ end
153
+
154
+ def parse_exact_source(source, role)
155
+ analysis = TreeHaver.with_backend(:rbs) { FileAnalysis.new(source) }
156
+ unless analysis.errors.empty? && analysis.backend == :rbs
157
+ return { parse_error: analysis.errors.map(&:to_s).join('; '), source_role: role }
158
+ end
159
+
160
+ declarations = analysis.declarations.map do |declaration|
161
+ wrapper = NodeWrapper.new(
162
+ declaration,
163
+ lines: source.split("\n", -1),
164
+ source: source,
165
+ backend: :rbs
166
+ )
167
+ [wrapper.signature, semantic_attributes(declaration)]
168
+ end.freeze
169
+ duplicate = declarations.group_by(&:first).find { |_signature, matches| matches.length > 1 }
170
+ issues = if duplicate
171
+ [{
172
+ category: :ambiguous_owner,
173
+ declaration: duplicate.first,
174
+ message: duplicate_message(duplicate.first)
175
+ }]
176
+ else
177
+ []
178
+ end
179
+ ExactDocument.new(
180
+ source: source,
181
+ analysis: analysis,
182
+ declarations: declarations,
183
+ issues: issues.freeze,
184
+ role: role
185
+ )
186
+ rescue StandardError => e
187
+ { parse_error: e.message, source_role: role }
188
+ end
189
+
190
+ def safe_range?(source, location)
191
+ return false unless location
192
+ return false unless location.start_line.positive? && location.end_line >= location.start_line
193
+ return false unless location.start_column.zero?
194
+ return false if location.start_pos.negative? || location.end_pos > source.bytesize
195
+
196
+ line_end = source.index("\n", location.end_pos) || source.bytesize
197
+ source.byteslice(location.end_pos...line_end).to_s.each_byte.all? { |byte| [9, 13, 32].include?(byte) }
198
+ end
199
+
200
+ def non_overlapping?(owners)
201
+ owners.each_cons(2).all? { |left, right| left.end_line < right.start_line }
202
+ end
203
+
204
+ def unmanaged_fingerprint(source, owners)
205
+ owned_lines = owners.each_with_object({}) do |owner, lines|
206
+ (owner.start_line..owner.end_line).each { |line| lines[line] = true }
207
+ end
208
+ source.lines.each_with_index.filter_map do |line, index|
209
+ line unless owned_lines[index + 1]
210
+ end.join
211
+ end
212
+
213
+ def semantic_attributes(value)
214
+ case value
215
+ when Array
216
+ value.map { |item| semantic_attributes(item) }
217
+ when Hash
218
+ value.map { |key, item| [semantic_attributes(key), semantic_attributes(item)] }
219
+ when String, Symbol, Numeric, true, false, nil
220
+ value
221
+ else
222
+ attributes = value.instance_variables.reject { |name| %i[@location @comment].include?(name) }
223
+ [
224
+ value.class.name,
225
+ attributes.map { |name| [name, semantic_attributes(value.instance_variable_get(name))] }
226
+ ]
227
+ end
228
+ end
229
+
230
+ def owner_fingerprint(source, declaration)
231
+ location = declaration.location
232
+ exact_source = source.byteslice(location.start_pos...location.end_pos)
233
+ Digest::SHA256.hexdigest(JSON.generate(Ast::Merge.json_ready([semantic_attributes(declaration), exact_source])))
234
+ end
235
+
236
+ def owner_id(signature)
237
+ Digest::SHA256.hexdigest(JSON.generate(Ast::Merge.json_ready(signature)))
238
+ end
239
+
240
+ def owner_path(owner)
241
+ owner ? owner.signature.inspect : '<document>'
242
+ end
243
+
244
+ def owner_description(owner)
245
+ {
246
+ path: owner_path(owner),
247
+ signature: owner.signature,
248
+ source_role: owner.role,
249
+ line_range: [owner.start_line, owner.end_line]
250
+ }
251
+ end
252
+
253
+ def diff_documents(before, after)
254
+ ordered_ids(before, after).filter_map do |id|
255
+ left = before.by_id[id]
256
+ right = after.by_id[id]
257
+ next if equivalent?(left, right)
258
+
259
+ {
260
+ path: owner_path(left || right),
261
+ before: change_side(left),
262
+ after: change_side(right),
263
+ change: change_kind(left, right)
264
+ }.freeze
265
+ end.freeze
266
+ end
267
+
268
+ def change_side(owner)
269
+ return { present: false } unless owner
270
+
271
+ { present: true, source_role: owner.role, line_range: [owner.start_line, owner.end_line] }
272
+ end
273
+
274
+ def ordered_ids(*documents)
275
+ documents.flat_map { |document| document.owners.map(&:id) }.uniq
276
+ end
277
+
278
+ def change_kind(before, after)
279
+ return :added unless before
280
+ return :deleted unless after
281
+
282
+ :edited
283
+ end
284
+
285
+ def decide(documents, include_unmanaged_conflict:)
286
+ changes = []
287
+ conflicts = []
288
+ choices = {}
289
+ if include_unmanaged_conflict
290
+ append_unmanaged_conflict(changes, conflicts, documents)
291
+ append_directive_conflict(changes, conflicts, documents)
292
+ end
293
+ ordered_ids(*documents.values).each do |id|
294
+ base = documents.fetch(:base).by_id[id]
295
+ ours = documents.fetch(:ours).by_id[id]
296
+ theirs = documents.fetch(:theirs).by_id[id]
297
+ ours_change = side_change(base, ours)
298
+ theirs_change = side_change(base, theirs)
299
+ next choices[id] = :ours if ours_change == :unchanged && theirs_change == :unchanged
300
+
301
+ change = {
302
+ path: owner_path(base || ours || theirs),
303
+ ours: ours_change,
304
+ theirs: theirs_change
305
+ }.freeze
306
+ changes << change
307
+ choice = owner_choice(base, ours, theirs)
308
+ if choice == :conflict
309
+ conflicts << conflict_for(id, base, ours, theirs, change)
310
+ else
311
+ choices[id] = choice
312
+ end
313
+ end
314
+ Decision.new(changes: changes.freeze, conflicts: conflicts.freeze, choices: choices.freeze)
315
+ end
316
+
317
+ def append_unmanaged_conflict(changes, conflicts, documents)
318
+ base = documents.fetch(:base).unmanaged_fingerprint
319
+ ours = documents.fetch(:ours).unmanaged_fingerprint
320
+ theirs = documents.fetch(:theirs).unmanaged_fingerprint
321
+ return if ours == theirs
322
+
323
+ change = {
324
+ path: '<unmanaged-source>',
325
+ ours: base == ours ? :unchanged : :edited,
326
+ theirs: base == theirs ? :unchanged : :edited
327
+ }.freeze
328
+ changes << change
329
+ conflicts << {
330
+ conflict_id: "rbs-unmanaged-#{Digest::SHA256.hexdigest([base, ours, theirs].join("\0"))[0, 16]}",
331
+ category: :unmanaged_source_change,
332
+ path: change.fetch(:path),
333
+ owner_id: nil,
334
+ change_classification: change
335
+ }.freeze
336
+ end
337
+
338
+ def append_directive_conflict(changes, conflicts, documents)
339
+ return unless documents.values.any? { |document| document.analysis.directives.any? }
340
+
341
+ change = { path: '<directives>', ours: :unsafe, theirs: :unsafe }.freeze
342
+ changes << change
343
+ conflicts << {
344
+ conflict_id: "rbs-directives-#{Digest::SHA256.hexdigest(documents.values.map(&:source).join("\0"))[0, 16]}",
345
+ category: :unsafe_directives,
346
+ path: change.fetch(:path),
347
+ owner_id: nil,
348
+ change_classification: change
349
+ }.freeze
350
+ end
351
+
352
+ def side_change(base, side)
353
+ return :unchanged if equivalent?(base, side)
354
+ return :added if !base && side
355
+ return :deleted if base && !side
356
+ return :unchanged unless base || side
357
+
358
+ :edited
359
+ end
360
+
361
+ def owner_choice(base, ours, theirs)
362
+ return :ours if equivalent?(ours, theirs)
363
+ return :theirs if equivalent?(base, ours)
364
+ return :ours if equivalent?(base, theirs)
365
+ return if ours.nil? && theirs.nil?
366
+
367
+ :conflict
368
+ end
369
+
370
+ def equivalent?(left, right)
371
+ return true if left.nil? && right.nil?
372
+ return false unless left && right
373
+
374
+ left.fingerprint == right.fingerprint
375
+ end
376
+
377
+ def conflict_for(id, base, ours, theirs, change)
378
+ {
379
+ conflict_id: "rbs-declaration-#{id[0, 16]}",
380
+ category: base && (!ours || !theirs) ? :delete_edit : :edit_edit,
381
+ path: change.fetch(:path),
382
+ owner_id: id,
383
+ base: owner_state(base),
384
+ ours: owner_state(ours),
385
+ theirs: owner_state(theirs),
386
+ change_classification: change
387
+ }.freeze
388
+ end
389
+
390
+ def owner_state(owner)
391
+ {
392
+ present: !owner.nil?,
393
+ fingerprint: owner&.fingerprint,
394
+ source_role: owner&.role,
395
+ line_range: owner && [owner.start_line, owner.end_line]
396
+ }
397
+ end
398
+
399
+ def exact_revision_role(request)
400
+ base = request.fetch(:base_source)
401
+ ours = request.fetch(:ours_source)
402
+ theirs = request.fetch(:theirs_source)
403
+ return :ours if ours == theirs || base == theirs
404
+
405
+ :theirs if base == ours
406
+ end
407
+
408
+ def merge3_exact(request, role)
409
+ documents = %i[base ours theirs].to_h do |source_role|
410
+ [source_role, parse_exact_source(request.fetch(:"#{source_role}_source"), source_role)]
411
+ end
412
+ winner = documents.fetch(role)
413
+ return parse_failure(:merge3, request, role, winner) if provider_failure?(winner)
414
+
415
+ render_exact(request, documents, role)
416
+ end
417
+
418
+ def render_exact(request, documents, role)
419
+ winner = documents.fetch(role)
420
+ rendered = render_plan(request, [whole_source_fragment(role, request.fetch(:"#{role}_source"))])
421
+ verification = verify_exact_rendered(rendered.content, winner)
422
+ unless verification[:semantic_match] && verification[:byte_exact]
423
+ return failure(
424
+ :merge3,
425
+ request,
426
+ category: :render_failure,
427
+ message: 'RBS exact revision did not remain byte-exact and semantically identical after native reparse.',
428
+ source_role: role,
429
+ render_report: render_report(rendered, :exact_revision),
430
+ verification: verification.merge(base_participated: true)
431
+ )
432
+ end
433
+
434
+ result(
435
+ :merge3,
436
+ request,
437
+ output: rendered.content,
438
+ diagnostics: exact_diagnostics(documents),
439
+ render_report: render_report(rendered, :exact_revision),
440
+ verification: verification.merge(base_participated: true)
441
+ )
442
+ end
443
+
444
+ def verify_exact_rendered(output, expected)
445
+ parsed = parse_exact_source(output, :output)
446
+ if provider_failure?(parsed)
447
+ return {
448
+ output_reparsed: false,
449
+ byte_exact: output == expected.source,
450
+ semantic_match: false,
451
+ parse_error: failure_detail(parsed),
452
+ source_role: expected.role
453
+ }
454
+ end
455
+
456
+ actual_signatures = parsed.declarations.map(&:first)
457
+ expected_signatures = expected.declarations.map(&:first)
458
+ actual_attributes = parsed.declarations.map(&:last)
459
+ expected_attributes = expected.declarations.map(&:last)
460
+ {
461
+ output_reparsed: true,
462
+ byte_exact: output == expected.source,
463
+ semantic_match: parsed.declarations == expected.declarations,
464
+ ordered_declaration_signatures_verified: actual_signatures == expected_signatures,
465
+ ast_attributes_verified: actual_attributes == expected_attributes,
466
+ planned_declaration_count: expected.declarations.length,
467
+ output_declaration_count: parsed.declarations.length,
468
+ source_role: expected.role
469
+ }
470
+ end
471
+
472
+ def exact_diagnostics(documents)
473
+ documents.flat_map do |role, document|
474
+ if provider_failure?(document)
475
+ [{
476
+ category: :parse_error,
477
+ severity: :warning,
478
+ message: "#{role} parse error: #{failure_detail(document)}",
479
+ blocking: false,
480
+ source_role: role
481
+ }]
482
+ else
483
+ document.issues.map do |issue|
484
+ issue.merge(severity: :warning, blocking: false, source_role: role).freeze
485
+ end
486
+ end
487
+ end.freeze
488
+ end
489
+
490
+ def duplicate_message(signature)
491
+ "ambiguous duplicate declaration #{signature.inspect}"
492
+ end
493
+
494
+ def render_composite(request, documents, decision)
495
+ fragments = composite_fragments(documents, decision)
496
+ rendered = render_plan(request, fragments)
497
+ expected = expected_owners(documents, decision)
498
+ verification = verify_rendered(rendered.content, expected)
499
+ unless verification[:semantic_match]
500
+ return failure(
501
+ :merge3,
502
+ request,
503
+ category: :render_failure,
504
+ message: 'RBS composite did not match the planned ordered declaration structure and AST attributes.',
505
+ changes: decision.changes,
506
+ render_report: render_report(rendered, :exact_declaration_composite),
507
+ verification: verification.merge(base_participated: true)
508
+ )
509
+ end
510
+
511
+ result(
512
+ :merge3,
513
+ request,
514
+ output: rendered.content,
515
+ changes: decision.changes,
516
+ render_report: render_report(rendered, :exact_declaration_composite),
517
+ verification: verification.merge(base_participated: true)
518
+ )
519
+ end
520
+
521
+ def composite_fragments(documents, decision)
522
+ ours = documents.fetch(:ours)
523
+ fragments = []
524
+ cursor = 1
525
+ replacement_edits(documents, decision).sort_by { |edit| edit.fetch(:start_line) }.each do |edit|
526
+ append_range(fragments, :ours, cursor, edit.fetch(:start_line) - 1)
527
+ append_owner(fragments, edit[:owner]) if edit[:owner]
528
+ cursor = edit.fetch(:end_line) + 1
529
+ end
530
+ append_range(fragments, :ours, cursor, ours.source.lines.length)
531
+ append_additions(fragments, documents, decision)
532
+ fragments
533
+ end
534
+
535
+ def replacement_edits(documents, decision)
536
+ ours = documents.fetch(:ours)
537
+ decision.choices.filter_map do |id, role|
538
+ ours_owner = ours.by_id[id]
539
+ next unless ours_owner
540
+ next if role == :ours
541
+
542
+ {
543
+ start_line: ours_owner.start_line,
544
+ end_line: ours_owner.end_line,
545
+ owner: role && documents.fetch(role).by_id[id]
546
+ }
547
+ end
548
+ end
549
+
550
+ def append_additions(fragments, documents, decision)
551
+ ours = documents.fetch(:ours)
552
+ additions = documents.fetch(:theirs).owners.select do |owner|
553
+ !ours.by_id.key?(owner.id) && decision.choices[owner.id] == :theirs
554
+ end
555
+ return if additions.empty?
556
+
557
+ ensure_plan_boundary!(fragments, documents)
558
+ additions.each_with_index do |owner, index|
559
+ append_owner(fragments, owner)
560
+ ensure_plan_boundary!(fragments, documents) if index < additions.length - 1
561
+ end
562
+ end
563
+
564
+ def ensure_plan_boundary!(fragments, documents)
565
+ fragment = fragments.last
566
+ return if fragment.nil? || fragment_content(fragment, documents).end_with?("\n")
567
+
568
+ if fragment.is_a?(Ast::Merge::SourceRender::SourceFragment) && fragment.start_line < fragment.end_line
569
+ fragments[-1] = source_range_fragment(fragment.revision, fragment.start_line, fragment.end_line - 1)
570
+ else
571
+ fragments.pop
572
+ end
573
+ content = if fragment.is_a?(Ast::Merge::SourceRender::SourceFragment)
574
+ documents.fetch(fragment.revision).source.lines.fetch(fragment.end_line - 1)
575
+ else
576
+ fragment.content
577
+ end
578
+ fragments << Ast::Merge::SourceRender::SynthesizedFragment.new(
579
+ content: "#{content}\n",
580
+ reason: :declaration_separator,
581
+ producer: provider_id,
582
+ metadata: { source_role: fragment.respond_to?(:revision) ? fragment.revision : nil, copied_source: true }
583
+ )
584
+ end
585
+
586
+ def fragment_content(fragment, documents)
587
+ return fragment.content if fragment.is_a?(Ast::Merge::SourceRender::SynthesizedFragment)
588
+
589
+ document = documents.fetch(fragment.revision)
590
+ document.source.lines.slice(fragment.start_line - 1, fragment.end_line - fragment.start_line + 1).join
591
+ end
592
+
593
+ def append_owner(fragments, owner)
594
+ fragments << source_range_fragment(owner.role, owner.start_line, owner.end_line)
595
+ end
596
+
597
+ def append_range(fragments, role, start_line, end_line)
598
+ return if start_line > end_line
599
+
600
+ fragments << source_range_fragment(role, start_line, end_line)
601
+ end
602
+
603
+ def expected_owners(documents, decision)
604
+ ours = documents.fetch(:ours)
605
+ expected = ours.owners.filter_map do |owner|
606
+ role = decision.choices[owner.id]
607
+ role && documents.fetch(role).by_id[owner.id]
608
+ end
609
+ documents.fetch(:theirs).owners.each do |owner|
610
+ next if ours.by_id.key?(owner.id)
611
+ next unless decision.choices[owner.id] == :theirs
612
+
613
+ expected << owner
614
+ end
615
+ expected
616
+ end
617
+
618
+ def verify_rendered(output, expected)
619
+ parsed = parse_source(output, :output)
620
+ if provider_failure?(parsed)
621
+ return {
622
+ output_reparsed: false,
623
+ semantic_match: false,
624
+ parse_error: failure_detail(parsed)
625
+ }
626
+ end
627
+
628
+ actual = parsed.owners.map { |owner| [owner.signature, owner.fingerprint] }
629
+ planned = expected.map { |owner| [owner.signature, owner.fingerprint] }
630
+ {
631
+ output_reparsed: true,
632
+ semantic_match: actual == planned,
633
+ ordered_declaration_signatures_verified: actual.map(&:first) == planned.map(&:first),
634
+ ast_attributes_verified: actual.map(&:last) == planned.map(&:last),
635
+ planned_declaration_count: planned.length,
636
+ output_declaration_count: actual.length
637
+ }
638
+ end
639
+
640
+ def render_conflicts(request, documents, decision)
641
+ localized = localized_conflict_fragments(request, documents, decision)
642
+ if localized
643
+ rendered = render_plan(request, localized)
644
+ return conflict_failure(request, decision, rendered, :declaration_localized_conflict)
645
+ end
646
+
647
+ rendered = render_plan(request, [whole_document_conflict(request, decision)])
648
+ conflict_failure(
649
+ request,
650
+ decision,
651
+ rendered,
652
+ :full_file_conflict,
653
+ fallbacks: [{ from: :declaration_localization, to: :full_file_conflict, reason: :source_ownership_unproven }]
654
+ )
655
+ end
656
+
657
+ def localized_conflict_fragments(request, documents, decision)
658
+ return if decision.conflicts.any? { |conflict| conflict[:owner_id].nil? }
659
+
660
+ ours = documents.fetch(:ours)
661
+ return unless decision.conflicts.all? { |conflict| ours.by_id.key?(conflict.fetch(:owner_id)) }
662
+
663
+ ranges = decision.conflicts.map { |conflict| [conflict, ours.by_id.fetch(conflict.fetch(:owner_id))] }
664
+ return unless ranges.sort_by { |_conflict, owner| owner.start_line }.each_cons(2).none? do |left, right|
665
+ left.last.end_line >= right.last.start_line
666
+ end
667
+
668
+ fragments = []
669
+ cursor = 1
670
+ ranges.sort_by { |_conflict, owner| owner.start_line }.each do |conflict, owner|
671
+ append_range(fragments, :ours, cursor, owner.start_line - 1)
672
+ fragments << declaration_conflict_fragment(request, documents, conflict)
673
+ cursor = owner.end_line + 1
674
+ end
675
+ append_range(fragments, :ours, cursor, ours.source.lines.length)
676
+ fragments
677
+ end
678
+
679
+ def declaration_conflict_fragment(request, documents, conflict)
680
+ Ast::Merge::SourceRender::ConflictFragment.new(
681
+ conflict_id: conflict.fetch(:conflict_id),
682
+ base: conflict_declaration_side(documents, conflict, :base),
683
+ ours: conflict_declaration_side(documents, conflict, :ours),
684
+ theirs: conflict_declaration_side(documents, conflict, :theirs),
685
+ labels: request.fetch(:labels, {}),
686
+ marker_size: request.fetch(:conflict_marker_size, 7),
687
+ metadata: { path: conflict.fetch(:path), category: conflict.fetch(:category) }
688
+ )
689
+ end
690
+
691
+ def conflict_declaration_side(documents, conflict, role)
692
+ document = documents.fetch(role)
693
+ owner = document.by_id[conflict.fetch(:owner_id)]
694
+ owner ? [conflict_source_fragment(owner, document.source)] : []
695
+ end
696
+
697
+ def unsafe_document_failure(request, role, parsed)
698
+ return parsed unless parsed[:unsafe_range] || parsed[:ambiguous_owner]
699
+
700
+ conflict_id = "rbs-unsafe-#{Digest::SHA256.hexdigest([role, failure_detail(parsed)].join("\0"))[0, 16]}"
701
+ conflict = {
702
+ conflict_id: conflict_id,
703
+ category: parsed[:ambiguous_owner] ? :ambiguous_owner : :unsafe_source_range,
704
+ path: '<document>',
705
+ owner_id: nil,
706
+ source_role: role
707
+ }
708
+ rendered = render_plan(
709
+ request,
710
+ [whole_document_conflict(request, Decision.new(changes: [], conflicts: [conflict], choices: {}))]
711
+ )
712
+ failure(
713
+ :merge3,
714
+ request,
715
+ category: conflict.fetch(:category),
716
+ message: "#{role} cannot be merged safely: #{failure_detail(parsed)}",
717
+ conflicts: [conflict],
718
+ conflicted_output: rendered.content,
719
+ source_role: role,
720
+ render_report: render_report(rendered, :full_file_conflict),
721
+ verification: { base_participated: true }
722
+ )
723
+ end
724
+
725
+ def whole_document_conflict(request, decision)
726
+ Ast::Merge::SourceRender::ConflictFragment.new(
727
+ conflict_id: decision.conflicts.first.fetch(:conflict_id),
728
+ base: [conflict_whole_source_fragment(:base, request.fetch(:base_source))],
729
+ ours: [conflict_whole_source_fragment(:ours, request.fetch(:ours_source))],
730
+ theirs: [conflict_whole_source_fragment(:theirs, request.fetch(:theirs_source))],
731
+ labels: request.fetch(:labels, {}),
732
+ marker_size: request.fetch(:conflict_marker_size, 7),
733
+ metadata: { conflicts: decision.conflicts.map { |conflict| conflict[:conflict_id] } }
734
+ )
735
+ end
736
+
737
+ def conflict_source_fragment(owner, source)
738
+ fragment_source = source.lines.slice(owner.start_line - 1, owner.end_line - owner.start_line + 1).join
739
+ return source_range_fragment(owner.role, owner.start_line, owner.end_line) if fragment_source.end_with?("\n")
740
+
741
+ synthesized_copy("#{fragment_source}\n", :conflict_line_boundary, owner.role)
742
+ end
743
+
744
+ def conflict_whole_source_fragment(role, source)
745
+ return whole_source_fragment(role, source) if source.empty? || source.end_with?("\n")
746
+
747
+ synthesized_copy("#{source}\n", :conflict_line_boundary, role)
748
+ end
749
+
750
+ def synthesized_copy(content, reason, role)
751
+ Ast::Merge::SourceRender::SynthesizedFragment.new(
752
+ content: content,
753
+ reason: reason,
754
+ producer: provider_id,
755
+ metadata: { source_role: role, copied_source: true }
756
+ )
757
+ end
758
+
759
+ def whole_source_fragment(role, source)
760
+ return synthesized_copy('', :exact_empty_source, role) if source.empty?
761
+
762
+ source_range_fragment(role, 1, source.lines.length)
763
+ end
764
+
765
+ def source_range_fragment(role, start_line, end_line)
766
+ Ast::Merge::SourceRender::SourceFragment.new(
767
+ revision: role,
768
+ start_line: start_line,
769
+ end_line: end_line,
770
+ metadata: { source_role: role }
771
+ )
772
+ end
773
+
774
+ def render_plan(request, fragments)
775
+ Ast::Merge::SourceRender::Renderer.new.render(
776
+ Ast::Merge::SourceRender::Plan.new(
777
+ sources: {
778
+ base: request[:base_source].to_s,
779
+ ours: request[:ours_source].to_s,
780
+ theirs: request[:theirs_source].to_s
781
+ },
782
+ fragments: fragments,
783
+ metadata: { provider_id: provider_id }
784
+ )
785
+ )
786
+ end
787
+
788
+ def render_report(rendered, strategy)
789
+ {
790
+ strategy: strategy,
791
+ line_records: rendered.line_records,
792
+ synthesized_fragments: rendered.synthesized_fragments,
793
+ conflicts: rendered.conflicts,
794
+ verification_input: rendered.verification_input
795
+ }
796
+ end
797
+
798
+ def conflict_failure(request, decision, rendered, strategy, fallbacks: [])
799
+ failure(
800
+ :merge3,
801
+ request,
802
+ category: :merge_conflict,
803
+ message: 'RBS top-level declaration changed incompatibly on both sides.',
804
+ changes: decision.changes,
805
+ conflicts: decision.conflicts,
806
+ conflicted_output: rendered.content,
807
+ render_report: render_report(rendered, strategy),
808
+ verification: { base_participated: true },
809
+ fallbacks: fallbacks
810
+ )
811
+ end
812
+
813
+ def parse_failure(operation, request, role, parsed)
814
+ category = if parsed[:parse_error]
815
+ :parse_error
816
+ elsif parsed[:ambiguous_owner]
817
+ :ambiguous_owner
818
+ else
819
+ :unsafe_source_range
820
+ end
821
+ failure(
822
+ operation,
823
+ request,
824
+ category: category,
825
+ message: "#{role} parse error: #{failure_detail(parsed)}",
826
+ source_role: role
827
+ )
828
+ end
829
+
830
+ def failure_detail(parsed)
831
+ return parsed[:parse_error] if parsed[:parse_error]
832
+ return "ambiguous duplicate declaration #{parsed[:ambiguous_owner].inspect}" if parsed[:ambiguous_owner]
833
+
834
+ "unsafe declaration range #{parsed[:unsafe_range].inspect}"
835
+ end
836
+
837
+ def provider_failure?(value)
838
+ value.is_a?(Hash) &&
839
+ (value[:ok] == false || value.key?(:parse_error) || value.key?(:ambiguous_owner) || value.key?(:unsafe_range))
840
+ end
841
+
842
+ def result(operation, request, changes: [], diagnostics: [], render_report: {}, verification: {}, **payload)
843
+ Ast::Merge::ProviderResult.build(
844
+ operation: operation,
845
+ success: true,
846
+ envelope: envelope(
847
+ request,
848
+ changes: changes,
849
+ diagnostics: diagnostics,
850
+ render_report: render_report,
851
+ verification: verification
852
+ ),
853
+ **payload
854
+ )
855
+ end
856
+
857
+ def failure(operation, request, category:, message:, changes: [], conflicts: [], fallbacks: [],
858
+ render_report: {}, verification: {}, **payload)
859
+ Ast::Merge::ProviderResult.build(
860
+ operation: operation,
861
+ success: false,
862
+ envelope: envelope(
863
+ request,
864
+ changes: changes,
865
+ conflicts: conflicts,
866
+ fallbacks: fallbacks,
867
+ diagnostics: [{ category: category, severity: :error, message: message, blocking: true }],
868
+ render_report: render_report,
869
+ verification: verification
870
+ ),
871
+ **payload
872
+ )
873
+ end
874
+
875
+ def envelope(request, **fields)
876
+ {
877
+ provider: {
878
+ provider_id: provider_id,
879
+ family: family,
880
+ dialect: request[:dialect] || :rbs,
881
+ backend: request[:backend] || :rbs,
882
+ package: 'rbs-merge',
883
+ package_version: Rbs::Merge::Version::VERSION
884
+ },
885
+ profile: { profile_id: request[:profile_id] || DEFAULT_PROFILE }
886
+ }.merge(fields)
887
+ end
888
+ end
889
+ # rubocop:enable Metrics/AbcSize, Metrics/ClassLength, Metrics/CyclomaticComplexity, Metrics/MethodLength, Metrics/ParameterLists, Metrics/PerceivedComplexity
890
+ end
891
+ end
@@ -5,7 +5,7 @@ module Rbs
5
5
  # Version namespace for this gem.
6
6
  module Version
7
7
  # Current gem version.
8
- VERSION = '7.1.1'
8
+ VERSION = '7.1.3'
9
9
  end
10
10
  # Current gem version exposed at the traditional constant location.
11
11
  VERSION = Version::VERSION # Traditional Constant Location
data/lib/rbs/merge.rb CHANGED
@@ -137,12 +137,23 @@ module Rbs
137
137
  backends << TREE_SITTER_BACKEND_REFERENCE if registrations.key?(:tree_sitter) || registrations.key?(:tslp)
138
138
  backends
139
139
  end
140
+
141
+ def merge_provider
142
+ @merge_provider ||= Provider.new
143
+ end
144
+
145
+ def register_provider!(replace: false)
146
+ Ast::Merge.register_provider(merge_provider, replace: replace)
147
+ end
140
148
  end
141
149
  end
142
150
  end
143
151
 
152
+ Rbs::Merge.autoload :Provider, 'rbs/merge/provider'
153
+
144
154
  # Register the RBS backend with TreeHaver when this gem is loaded
145
155
  Rbs::Merge.register_backend!
156
+ Rbs::Merge.register_provider!
146
157
 
147
158
  # Register with ast-merge's MergeGemRegistry for RSpec dependency tags
148
159
  # Only register if MergeGemRegistry is loaded (i.e., in test environment)
data/sig/rbs/merge.rbs CHANGED
@@ -1,5 +1,18 @@
1
1
  module Rbs
2
2
  module Merge
3
+ class Provider
4
+ def provider_id: () -> String
5
+ def family: () -> String
6
+ def capabilities: () -> Hash[Symbol, untyped]
7
+ def analyze: (Hash[Symbol, untyped] request) -> Hash[Symbol, untyped]
8
+ def diff2: (Hash[Symbol, untyped] request) -> Hash[Symbol, untyped]
9
+ def merge2: (Hash[Symbol, untyped] request) -> Hash[Symbol, untyped]
10
+ def merge3: (Hash[Symbol, untyped] request) -> Hash[Symbol, untyped]
11
+ end
12
+
13
+ def self.merge_provider: () -> Provider
14
+ def self.register_provider!: (?replace: bool) -> untyped
15
+
3
16
  module Version
4
17
  VERSION: String
5
18
  end
data.tar.gz.sig CHANGED
Binary file
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: rbs-merge
3
3
  version: !ruby/object:Gem::Version
4
- version: 7.1.1
4
+ version: 7.1.3
5
5
  platform: ruby
6
6
  authors:
7
7
  - Peter H. Boling
@@ -43,14 +43,14 @@ dependencies:
43
43
  requirements:
44
44
  - - '='
45
45
  - !ruby/object:Gem::Version
46
- version: 7.1.1
46
+ version: 7.1.3
47
47
  type: :runtime
48
48
  prerelease: false
49
49
  version_requirements: !ruby/object:Gem::Requirement
50
50
  requirements:
51
51
  - - '='
52
52
  - !ruby/object:Gem::Version
53
- version: 7.1.1
53
+ version: 7.1.3
54
54
  - !ruby/object:Gem::Dependency
55
55
  name: rbs
56
56
  requirement: !ruby/object:Gem::Requirement
@@ -71,14 +71,14 @@ dependencies:
71
71
  requirements:
72
72
  - - '='
73
73
  - !ruby/object:Gem::Version
74
- version: 7.1.1
74
+ version: 7.1.3
75
75
  type: :runtime
76
76
  prerelease: false
77
77
  version_requirements: !ruby/object:Gem::Requirement
78
78
  requirements:
79
79
  - - '='
80
80
  - !ruby/object:Gem::Version
81
- version: 7.1.1
81
+ version: 7.1.3
82
82
  - !ruby/object:Gem::Dependency
83
83
  name: version_gem
84
84
  requirement: !ruby/object:Gem::Requirement
@@ -105,20 +105,20 @@ dependencies:
105
105
  requirements:
106
106
  - - "~>"
107
107
  - !ruby/object:Gem::Version
108
- version: '2.5'
108
+ version: '3.0'
109
109
  - - ">="
110
110
  - !ruby/object:Gem::Version
111
- version: 2.5.17
111
+ version: 3.0.0
112
112
  type: :development
113
113
  prerelease: false
114
114
  version_requirements: !ruby/object:Gem::Requirement
115
115
  requirements:
116
116
  - - "~>"
117
117
  - !ruby/object:Gem::Version
118
- version: '2.5'
118
+ version: '3.0'
119
119
  - - ">="
120
120
  - !ruby/object:Gem::Version
121
- version: 2.5.17
121
+ version: 3.0.0
122
122
  - !ruby/object:Gem::Dependency
123
123
  name: bundler-audit
124
124
  requirement: !ruby/object:Gem::Requirement
@@ -196,7 +196,7 @@ dependencies:
196
196
  version: '3.2'
197
197
  - - ">="
198
198
  - !ruby/object:Gem::Version
199
- version: 3.2.0
199
+ version: 3.2.2
200
200
  type: :development
201
201
  prerelease: false
202
202
  version_requirements: !ruby/object:Gem::Requirement
@@ -206,7 +206,7 @@ dependencies:
206
206
  version: '3.2'
207
207
  - - ">="
208
208
  - !ruby/object:Gem::Version
209
- version: 3.2.0
209
+ version: 3.2.2
210
210
  - !ruby/object:Gem::Dependency
211
211
  name: kettle-test
212
212
  requirement: !ruby/object:Gem::Requirement
@@ -236,7 +236,7 @@ dependencies:
236
236
  version: '3.2'
237
237
  - - ">="
238
238
  - !ruby/object:Gem::Version
239
- version: 3.2.4
239
+ version: 3.2.5
240
240
  type: :development
241
241
  prerelease: false
242
242
  version_requirements: !ruby/object:Gem::Requirement
@@ -246,7 +246,7 @@ dependencies:
246
246
  version: '3.2'
247
247
  - - ">="
248
248
  - !ruby/object:Gem::Version
249
- version: 3.2.4
249
+ version: 3.2.5
250
250
  - !ruby/object:Gem::Dependency
251
251
  name: ruby-progressbar
252
252
  requirement: !ruby/object:Gem::Requirement
@@ -323,6 +323,7 @@ files:
323
323
  - lib/rbs/merge/merge_result.rb
324
324
  - lib/rbs/merge/node_type_normalizer.rb
325
325
  - lib/rbs/merge/node_wrapper.rb
326
+ - lib/rbs/merge/provider.rb
326
327
  - lib/rbs/merge/smart_merger.rb
327
328
  - lib/rbs/merge/version.rb
328
329
  - sig/rbs/merge.rbs
@@ -332,10 +333,10 @@ licenses:
332
333
  - PolyForm-Small-Business-1.0.0
333
334
  metadata:
334
335
  homepage_uri: https://structuredmerge.org
335
- source_code_uri: https://github.com/structuredmerge/structuredmerge-ruby/tree/v7.1.1
336
- changelog_uri: https://github.com/structuredmerge/structuredmerge-ruby/blob/v7.1.1/CHANGELOG.md
336
+ source_code_uri: https://github.com/structuredmerge/structuredmerge-ruby/tree/v7.1.3
337
+ changelog_uri: https://github.com/structuredmerge/structuredmerge-ruby/blob/v7.1.3/CHANGELOG.md
337
338
  bug_tracker_uri: https://github.com/structuredmerge/structuredmerge-ruby/issues
338
- documentation_uri: https://www.rubydoc.info/gems/rbs-merge/7.1.1
339
+ documentation_uri: https://www.rubydoc.info/gems/rbs-merge/7.1.3
339
340
  funding_uri: https://github.com/sponsors/pboling
340
341
  wiki_uri: https://github.com/structuredmerge/structuredmerge-ruby/wiki
341
342
  news_uri: https://www.railsbling.com/tags/rbs-merge
metadata.gz.sig CHANGED
Binary file