typescript-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.
@@ -0,0 +1,953 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'digest'
4
+ require 'json'
5
+
6
+ module TypeScript
7
+ module Merge
8
+ # Base-aware, source-preserving provider for native TypeScript declarations.
9
+ # Multi-declarator and declaration-merging forms are whole owners. Any
10
+ # ambiguous identity or changing membership is rejected before rendering.
11
+ # rubocop:disable Metrics/AbcSize, Metrics/ClassLength, Metrics/CyclomaticComplexity, Metrics/MethodLength, Metrics/ParameterLists, Metrics/PerceivedComplexity -- provider decisions, source plans, and verification form one boundary
12
+ class Provider
13
+ DEFAULT_PROFILE = :source_preserving
14
+ Owner = Data.define(:id, :signature, :fingerprint, :start_line, :end_line, :role)
15
+ Document = Data.define(:source, :analysis, :owners, :by_id, :unmanaged_fingerprint)
16
+ ExactDocument = Data.define(:source, :analysis, :declarations, :issues, :role)
17
+ Decision = Data.define(:changes, :conflicts, :choices)
18
+
19
+ def provider_id = 'ruby.typescript'
20
+ def family = 'typescript'
21
+
22
+ def capabilities
23
+ {
24
+ operations: Ast::Merge::ProviderContract::OPERATIONS,
25
+ dialects: %i[typescript tsx],
26
+ backends: [TREE_SITTER_BACKEND.id.to_sym],
27
+ profiles: [DEFAULT_PROFILE],
28
+ role: :workflow,
29
+ compound_declaration_ownership: :whole_declaration_with_stable_member_identity,
30
+ source_preservation: %i[exact_source declaration_fragments line_provenance reparse semantic_verification]
31
+ }.freeze
32
+ end
33
+
34
+ def analyze(request)
35
+ document = parse_document(:analyze, request, :source)
36
+ return document if provider_failure?(document)
37
+
38
+ result(
39
+ :analyze,
40
+ request,
41
+ analysis: {
42
+ backend: TREE_SITTER_BACKEND.id,
43
+ valid: true,
44
+ declarations: document.owners.map { |owner| owner_description(owner) }
45
+ },
46
+ verification: { source_parsed: true }
47
+ )
48
+ end
49
+
50
+ def diff2(request)
51
+ before = parse_document(:diff2, request, :before)
52
+ return before if provider_failure?(before)
53
+
54
+ after = parse_document(:diff2, request, :after)
55
+ return after if provider_failure?(after)
56
+
57
+ changes = diff_documents(before, after)
58
+ result(
59
+ :diff2,
60
+ request,
61
+ diff: { changes: changes },
62
+ changes: changes,
63
+ verification: { before_parsed: true, after_parsed: true }
64
+ )
65
+ end
66
+
67
+ def merge2(request)
68
+ merge3_request = request.merge(
69
+ base_source: request.fetch(:current_source),
70
+ ours_source: request.fetch(:current_source),
71
+ theirs_source: request.fetch(:incoming_source)
72
+ )
73
+ merged = merge3(merge3_request)
74
+ merged.merge(
75
+ operation: :merge2,
76
+ verification: merged.fetch(:verification).except(:base_participated)
77
+ )
78
+ end
79
+
80
+ def merge3(request)
81
+ exact_role = exact_revision_role(request)
82
+ return merge3_exact(request, exact_role) if exact_role
83
+
84
+ documents = parse_merge3_documents(request)
85
+ return documents if provider_failure?(documents)
86
+
87
+ grouped_failure = grouped_ownership_failure(request, documents)
88
+ return grouped_failure if grouped_failure
89
+
90
+ decision = decide(documents, include_unmanaged_conflict: true)
91
+ return render_conflicts(request, documents, decision) unless decision.conflicts.empty?
92
+
93
+ render_composite(request, documents, decision)
94
+ end
95
+
96
+ private
97
+
98
+ def parse_merge3_documents(request)
99
+ %i[base ours theirs].each_with_object({}) do |role, documents|
100
+ source = request.fetch(:"#{role}_source")
101
+ document = parse_source(source, role, request[:dialect])
102
+ if document.is_a?(Hash) && document[:parse_error]
103
+ return parse_failure(:merge3, request, role, document)
104
+ elsif provider_failure?(document)
105
+ return unsafe_document_failure(request, role, document)
106
+ end
107
+
108
+ documents[role] = document
109
+ end
110
+ end
111
+
112
+ def parse_document(operation, request, role)
113
+ source = request.fetch(role == :source ? :source : :"#{role}_source")
114
+ parsed = parse_source(source, role, request[:dialect])
115
+ return parse_failure(operation, request, role, parsed) if provider_failure?(parsed)
116
+
117
+ parsed
118
+ end
119
+
120
+ def parse_source(source, role, dialect = nil)
121
+ analysis = FileAnalysis.new(source, dialect: dialect || :typescript)
122
+ unless analysis.errors.empty? && analysis.backend == TREE_SITTER_BACKEND.id.to_sym
123
+ return { parse_error: analysis.errors.map(&:to_s).join('; '), source_role: role }
124
+ end
125
+
126
+ owners = analysis.declarations.map do |wrapper|
127
+ return { unsafe_range: wrapper.signature, source_role: role } unless safe_range?(source, wrapper)
128
+
129
+ signature = wrapper.signature
130
+ Owner.new(
131
+ id: owner_id(signature),
132
+ signature: signature,
133
+ fingerprint: owner_fingerprint(wrapper),
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
+
142
+ declaration_merge = owners.group_by { |owner| declaration_merge_key(owner.signature) }
143
+ .find { |key, matches| key && matches.length > 1 }
144
+ return { ambiguous_owner: declaration_merge.first, source_role: role } if declaration_merge
145
+ return { unsafe_range: :overlapping_declarations, source_role: role } unless non_overlapping?(owners)
146
+
147
+ Document.new(
148
+ source: source,
149
+ analysis: analysis,
150
+ owners: owners.freeze,
151
+ by_id: owners.to_h { |owner| [owner.id, owner] }.freeze,
152
+ unmanaged_fingerprint: unmanaged_fingerprint(source, owners)
153
+ )
154
+ rescue StandardError => e
155
+ { parse_error: e.message, source_role: role }
156
+ end
157
+
158
+ def parse_exact_source(source, role, dialect = nil)
159
+ analysis = FileAnalysis.new(source, dialect: dialect || :typescript)
160
+ unless analysis.errors.empty? && analysis.backend == TREE_SITTER_BACKEND.id.to_sym
161
+ return { parse_error: analysis.errors.map(&:to_s).join('; '), source_role: role }
162
+ end
163
+
164
+ declarations = analysis.declarations.map do |wrapper|
165
+ [wrapper.signature, wrapper.semantic_tree]
166
+ end.freeze
167
+ duplicate_keys = declarations.group_by(&:first).filter_map do |signature, matches|
168
+ signature if matches.length > 1
169
+ end
170
+ merge_groups = declarations.group_by { |signature, _tree| declaration_merge_key(signature) }
171
+ merge_keys = merge_groups.filter_map do |key, matches|
172
+ key if key && matches.length > 1
173
+ end
174
+ issues = (duplicate_keys + merge_keys).uniq.map do |identity|
175
+ {
176
+ category: :ambiguous_owner,
177
+ declaration: identity,
178
+ message: duplicate_message(identity)
179
+ }.freeze
180
+ end
181
+ ExactDocument.new(
182
+ source: source,
183
+ analysis: analysis,
184
+ declarations: declarations,
185
+ issues: issues.freeze,
186
+ role: role
187
+ )
188
+ rescue StandardError => e
189
+ { parse_error: e.message, source_role: role }
190
+ end
191
+
192
+ def safe_range?(source, wrapper)
193
+ return false if wrapper.start_byte.negative? || wrapper.end_byte > source.bytesize
194
+ return false if wrapper.end_byte < wrapper.start_byte
195
+
196
+ line_start = source.rindex("\n", [wrapper.start_byte - 1, 0].max)
197
+ line_start = line_start ? line_start + 1 : 0
198
+ line_end = source.index("\n", wrapper.end_byte) || source.bytesize
199
+ horizontal_whitespace?(source.byteslice(line_start...wrapper.start_byte).to_s) &&
200
+ horizontal_whitespace?(source.byteslice(wrapper.end_byte...line_end).to_s)
201
+ end
202
+
203
+ def horizontal_whitespace?(value)
204
+ value.each_byte.all? { |byte| [9, 13, 32].include?(byte) }
205
+ end
206
+
207
+ def non_overlapping?(owners)
208
+ owners.each_cons(2).all? { |left, right| left.end_line < right.start_line }
209
+ end
210
+
211
+ def unmanaged_fingerprint(source, owners)
212
+ owned_lines = owners.each_with_object({}) do |owner, lines|
213
+ (owner.start_line..owner.end_line).each { |line| lines[line] = true }
214
+ end
215
+ source.lines.each_with_index.filter_map do |line, index|
216
+ line unless owned_lines[index + 1]
217
+ end.join
218
+ end
219
+
220
+ def owner_fingerprint(wrapper)
221
+ Digest::SHA256.hexdigest(
222
+ JSON.generate(Ast::Merge.json_ready([wrapper.semantic_tree, wrapper.source_text]))
223
+ )
224
+ end
225
+
226
+ def owner_id(signature)
227
+ Digest::SHA256.hexdigest(JSON.generate(Ast::Merge.json_ready(signature)))
228
+ end
229
+
230
+ def declaration_merge_key(signature)
231
+ value = signature.first == :export_declaration ? signature.fetch(1) : signature
232
+ return unless %i[class enum function interface namespace].include?(value.first)
233
+
234
+ value.last
235
+ end
236
+
237
+ def owner_path(owner)
238
+ owner ? owner.signature.inspect : '<document>'
239
+ end
240
+
241
+ def owner_description(owner)
242
+ {
243
+ path: owner_path(owner),
244
+ signature: owner.signature,
245
+ source_role: owner.role,
246
+ line_range: [owner.start_line, owner.end_line]
247
+ }
248
+ end
249
+
250
+ def diff_documents(before, after)
251
+ ordered_ids(before, after).filter_map do |id|
252
+ left = before.by_id[id]
253
+ right = after.by_id[id]
254
+ next if equivalent?(left, right)
255
+
256
+ {
257
+ path: owner_path(left || right),
258
+ before: change_side(left),
259
+ after: change_side(right),
260
+ change: change_kind(left, right)
261
+ }.freeze
262
+ end.freeze
263
+ end
264
+
265
+ def change_side(owner)
266
+ return { present: false } unless owner
267
+
268
+ { present: true, source_role: owner.role, line_range: [owner.start_line, owner.end_line] }
269
+ end
270
+
271
+ def ordered_ids(*documents)
272
+ documents.flat_map { |document| document.owners.map(&:id) }.uniq
273
+ end
274
+
275
+ def change_kind(before, after)
276
+ return :added unless before
277
+ return :deleted unless after
278
+
279
+ :edited
280
+ end
281
+
282
+ def decide(documents, include_unmanaged_conflict:)
283
+ changes = []
284
+ conflicts = []
285
+ choices = {}
286
+ append_unmanaged_conflict(changes, conflicts, documents) if include_unmanaged_conflict
287
+ ordered_ids(*documents.values).each do |id|
288
+ base = documents.fetch(:base).by_id[id]
289
+ ours = documents.fetch(:ours).by_id[id]
290
+ theirs = documents.fetch(:theirs).by_id[id]
291
+ ours_change = side_change(base, ours)
292
+ theirs_change = side_change(base, theirs)
293
+ next choices[id] = :ours if ours_change == :unchanged && theirs_change == :unchanged
294
+
295
+ change = {
296
+ path: owner_path(base || ours || theirs),
297
+ ours: ours_change,
298
+ theirs: theirs_change
299
+ }.freeze
300
+ changes << change
301
+ choice = owner_choice(base, ours, theirs)
302
+ if choice == :conflict
303
+ conflicts << conflict_for(id, base, ours, theirs, change)
304
+ else
305
+ choices[id] = choice
306
+ end
307
+ end
308
+ Decision.new(changes: changes.freeze, conflicts: conflicts.freeze, choices: choices.freeze)
309
+ end
310
+
311
+ def append_unmanaged_conflict(changes, conflicts, documents)
312
+ base = documents.fetch(:base).unmanaged_fingerprint
313
+ ours = documents.fetch(:ours).unmanaged_fingerprint
314
+ theirs = documents.fetch(:theirs).unmanaged_fingerprint
315
+ return if ours == theirs
316
+
317
+ change = {
318
+ path: '<unmanaged-source>',
319
+ ours: base == ours ? :unchanged : :edited,
320
+ theirs: base == theirs ? :unchanged : :edited
321
+ }.freeze
322
+ changes << change
323
+ conflicts << {
324
+ conflict_id: "typescript-unmanaged-#{Digest::SHA256.hexdigest([base, ours, theirs].join("\0"))[0, 16]}",
325
+ category: :unmanaged_source_change,
326
+ path: change.fetch(:path),
327
+ owner_id: nil,
328
+ change_classification: change
329
+ }.freeze
330
+ end
331
+
332
+ def grouped_ownership_failure(request, documents)
333
+ signatures = documents.transform_values do |document|
334
+ document.owners.filter_map { |owner| owner.signature if grouped_owner?(owner) }
335
+ .sort_by { |signature| JSON.generate(Ast::Merge.json_ready(signature)) }
336
+ end
337
+ return if signatures.values.uniq.length == 1
338
+
339
+ signature_digest = Digest::SHA256.hexdigest(JSON.generate(Ast::Merge.json_ready(signatures)))
340
+ conflict = {
341
+ conflict_id: "typescript-compound-#{signature_digest[0, 16]}",
342
+ category: :unproven_compound_ownership,
343
+ path: '<compound-declarations>',
344
+ owner_id: nil,
345
+ member_signatures: signatures
346
+ }.freeze
347
+ decision = Decision.new(changes: [], conflicts: [conflict], choices: {})
348
+ rendered = render_plan(request, [whole_document_conflict(request, decision)])
349
+ failure(
350
+ :merge3,
351
+ request,
352
+ category: :unproven_compound_ownership,
353
+ message: 'Compound TypeScript declaration membership differs across revisions; ' \
354
+ 'whole-owner identity is unproven.',
355
+ conflicts: [conflict],
356
+ conflicted_output: rendered.content,
357
+ render_report: render_report(rendered, :full_file_conflict),
358
+ verification: { base_participated: true },
359
+ fallbacks: [{
360
+ from: :compound_declaration_ownership,
361
+ to: :full_file_conflict,
362
+ reason: :compound_member_identity_changed
363
+ }]
364
+ )
365
+ end
366
+
367
+ def side_change(base, side)
368
+ return :unchanged if equivalent?(base, side)
369
+ return :added if !base && side
370
+ return :deleted if base && !side
371
+ return :unchanged unless base || side
372
+
373
+ :edited
374
+ end
375
+
376
+ def owner_choice(base, ours, theirs)
377
+ return :ours if equivalent?(ours, theirs)
378
+ return :theirs if equivalent?(base, ours)
379
+ return :ours if equivalent?(base, theirs)
380
+ return if ours.nil? && theirs.nil?
381
+
382
+ :conflict
383
+ end
384
+
385
+ def equivalent?(left, right)
386
+ return true if left.nil? && right.nil?
387
+ return false unless left && right
388
+
389
+ left.fingerprint == right.fingerprint
390
+ end
391
+
392
+ def conflict_for(id, base, ours, theirs, change)
393
+ {
394
+ conflict_id: "typescript-declaration-#{id[0, 16]}",
395
+ category: base && (!ours || !theirs) ? :delete_edit : :edit_edit,
396
+ path: change.fetch(:path),
397
+ owner_id: id,
398
+ base: owner_state(base),
399
+ ours: owner_state(ours),
400
+ theirs: owner_state(theirs),
401
+ change_classification: change
402
+ }.freeze
403
+ end
404
+
405
+ def owner_state(owner)
406
+ {
407
+ present: !owner.nil?,
408
+ fingerprint: owner&.fingerprint,
409
+ source_role: owner&.role,
410
+ line_range: owner && [owner.start_line, owner.end_line]
411
+ }
412
+ end
413
+
414
+ def exact_revision_role(request)
415
+ base = request.fetch(:base_source)
416
+ ours = request.fetch(:ours_source)
417
+ theirs = request.fetch(:theirs_source)
418
+ return :ours if ours == theirs || base == theirs
419
+
420
+ :theirs if base == ours
421
+ end
422
+
423
+ def merge3_exact(request, role)
424
+ documents = %i[base ours theirs].to_h do |source_role|
425
+ [source_role, parse_exact_source(request.fetch(:"#{source_role}_source"), source_role, request[:dialect])]
426
+ end
427
+ winner = documents.fetch(role)
428
+ return parse_failure(:merge3, request, role, winner) if provider_failure?(winner)
429
+
430
+ render_exact(request, documents, role)
431
+ end
432
+
433
+ def render_exact(request, documents, role)
434
+ winner = documents.fetch(role)
435
+ rendered = render_plan(request, [whole_source_fragment(role, request.fetch(:"#{role}_source"))])
436
+ verification = verify_exact_rendered(rendered.content, winner)
437
+ unless verification[:semantic_match] && verification[:byte_exact]
438
+ return failure(
439
+ :merge3,
440
+ request,
441
+ category: :render_failure,
442
+ message: 'TypeScript exact revision did not remain byte-exact and semantically identical ' \
443
+ 'after native reparse.',
444
+ source_role: role,
445
+ render_report: render_report(rendered, :exact_revision),
446
+ verification: verification.merge(base_participated: true)
447
+ )
448
+ end
449
+
450
+ result(
451
+ :merge3,
452
+ request,
453
+ output: rendered.content,
454
+ diagnostics: exact_diagnostics(documents),
455
+ render_report: render_report(rendered, :exact_revision),
456
+ verification: verification.merge(base_participated: true)
457
+ )
458
+ end
459
+
460
+ def verify_exact_rendered(output, expected)
461
+ parsed = parse_exact_source(output, :output)
462
+ if provider_failure?(parsed)
463
+ return {
464
+ output_reparsed: false,
465
+ byte_exact: output == expected.source,
466
+ semantic_match: false,
467
+ parse_error: failure_detail(parsed),
468
+ source_role: expected.role
469
+ }
470
+ end
471
+
472
+ actual_signatures = parsed.declarations.map(&:first)
473
+ expected_signatures = expected.declarations.map(&:first)
474
+ actual_attributes = parsed.declarations.map(&:last)
475
+ expected_attributes = expected.declarations.map(&:last)
476
+ {
477
+ output_reparsed: true,
478
+ byte_exact: output == expected.source,
479
+ semantic_match: parsed.declarations == expected.declarations,
480
+ ordered_declaration_signatures_verified: actual_signatures == expected_signatures,
481
+ ast_attributes_verified: actual_attributes == expected_attributes,
482
+ planned_declaration_count: expected.declarations.length,
483
+ output_declaration_count: parsed.declarations.length,
484
+ source_role: expected.role
485
+ }
486
+ end
487
+
488
+ def exact_diagnostics(documents)
489
+ documents.flat_map do |role, document|
490
+ if provider_failure?(document)
491
+ [{
492
+ category: :parse_error,
493
+ severity: :warning,
494
+ message: "#{role} parse error: #{failure_detail(document)}",
495
+ blocking: false,
496
+ source_role: role
497
+ }]
498
+ else
499
+ document.issues.map do |issue|
500
+ issue.merge(severity: :warning, blocking: false, source_role: role).freeze
501
+ end
502
+ end
503
+ end.freeze
504
+ end
505
+
506
+ def duplicate_message(signature)
507
+ "ambiguous duplicate declaration #{signature.inspect}"
508
+ end
509
+
510
+ def render_composite(request, documents, decision)
511
+ fragments = composite_fragments(documents, decision)
512
+ rendered = render_plan(request, fragments)
513
+ expected = expected_owners(documents, decision)
514
+ verification = verify_rendered(rendered.content, expected, request[:dialect])
515
+ unless verification[:semantic_match]
516
+ return failure(
517
+ :merge3,
518
+ request,
519
+ category: :render_failure,
520
+ message: 'TypeScript composite did not match the planned ordered declaration structure and AST attributes.',
521
+ changes: decision.changes,
522
+ render_report: render_report(rendered, :exact_declaration_composite),
523
+ verification: verification.merge(base_participated: true)
524
+ )
525
+ end
526
+
527
+ result(
528
+ :merge3,
529
+ request,
530
+ output: rendered.content,
531
+ changes: decision.changes,
532
+ render_report: render_report(rendered, :exact_declaration_composite),
533
+ verification: verification.merge(base_participated: true)
534
+ )
535
+ end
536
+
537
+ def composite_fragments(documents, decision)
538
+ ours = documents.fetch(:ours)
539
+ fragments = []
540
+ cursor = 1
541
+ edits = composite_edits(documents, decision).sort_by do |edit|
542
+ insertion = edit.fetch(:end_line) < edit.fetch(:start_line)
543
+ [edit.fetch(:start_line), insertion ? 0 : 1]
544
+ end
545
+ edits.each do |edit|
546
+ append_range(fragments, :ours, cursor, edit.fetch(:start_line) - 1)
547
+ edit.fetch(:owners).each_with_index do |owner, index|
548
+ append_owner(fragments, owner)
549
+ ensure_plan_boundary!(fragments, documents) if index < edit.fetch(:owners).length - 1
550
+ end
551
+ cursor = [cursor, edit.fetch(:end_line) + 1].max
552
+ end
553
+ append_range(fragments, :ours, cursor, ours.source.lines.length)
554
+ append_declaration_additions(fragments, documents, decision)
555
+ fragments
556
+ end
557
+
558
+ def composite_edits(documents, decision)
559
+ replacement_edits(documents, decision) + import_insertion_edits(documents, decision)
560
+ end
561
+
562
+ def replacement_edits(documents, decision)
563
+ ours = documents.fetch(:ours)
564
+ decision.choices.filter_map do |id, role|
565
+ ours_owner = ours.by_id[id]
566
+ next unless ours_owner
567
+ next if role == :ours
568
+
569
+ {
570
+ start_line: ours_owner.start_line,
571
+ end_line: ours_owner.end_line,
572
+ owners: [role && documents.fetch(role).by_id[id]].compact
573
+ }
574
+ end
575
+ end
576
+
577
+ def import_insertion_edits(documents, decision)
578
+ additions = added_theirs_owners(documents, decision).select { |owner| import_owner?(owner) }
579
+ return [] if additions.empty?
580
+
581
+ ours = documents.fetch(:ours)
582
+ anchor = ours.owners.reverse.find { |owner| package_or_import_owner?(owner) }
583
+ insertion_line = anchor ? anchor.end_line + 1 : 1
584
+ [{
585
+ start_line: insertion_line,
586
+ end_line: insertion_line - 1,
587
+ owners: additions
588
+ }]
589
+ end
590
+
591
+ def append_declaration_additions(fragments, documents, decision)
592
+ additions = added_theirs_owners(documents, decision).reject { |owner| import_owner?(owner) }
593
+ return if additions.empty?
594
+
595
+ ensure_plan_boundary!(fragments, documents)
596
+ additions.each_with_index do |owner, index|
597
+ append_owner(fragments, owner)
598
+ ensure_plan_boundary!(fragments, documents) if index < additions.length - 1
599
+ end
600
+ end
601
+
602
+ def added_theirs_owners(documents, decision)
603
+ ours = documents.fetch(:ours)
604
+ documents.fetch(:theirs).owners.select do |owner|
605
+ !ours.by_id.key?(owner.id) && decision.choices[owner.id] == :theirs
606
+ end
607
+ end
608
+
609
+ def ensure_plan_boundary!(fragments, documents)
610
+ fragment = fragments.last
611
+ return if fragment.nil? || fragment_content(fragment, documents).end_with?("\n")
612
+
613
+ if fragment.is_a?(Ast::Merge::SourceRender::SourceFragment) && fragment.start_line < fragment.end_line
614
+ fragments[-1] = source_range_fragment(fragment.revision, fragment.start_line, fragment.end_line - 1)
615
+ else
616
+ fragments.pop
617
+ end
618
+ content = if fragment.is_a?(Ast::Merge::SourceRender::SourceFragment)
619
+ documents.fetch(fragment.revision).source.lines.fetch(fragment.end_line - 1)
620
+ else
621
+ fragment.content
622
+ end
623
+ fragments << Ast::Merge::SourceRender::SynthesizedFragment.new(
624
+ content: "#{content}\n",
625
+ reason: :declaration_separator,
626
+ producer: provider_id,
627
+ metadata: { source_role: fragment.respond_to?(:revision) ? fragment.revision : nil, copied_source: true }
628
+ )
629
+ end
630
+
631
+ def fragment_content(fragment, documents)
632
+ return fragment.content if fragment.is_a?(Ast::Merge::SourceRender::SynthesizedFragment)
633
+
634
+ document = documents.fetch(fragment.revision)
635
+ document.source.lines.slice(fragment.start_line - 1, fragment.end_line - fragment.start_line + 1).join
636
+ end
637
+
638
+ def append_owner(fragments, owner)
639
+ fragments << source_range_fragment(owner.role, owner.start_line, owner.end_line)
640
+ end
641
+
642
+ def append_range(fragments, role, start_line, end_line)
643
+ return if start_line > end_line
644
+
645
+ fragments << source_range_fragment(role, start_line, end_line)
646
+ end
647
+
648
+ def expected_owners(documents, decision)
649
+ ours = documents.fetch(:ours)
650
+ expected = ours.owners.filter_map do |owner|
651
+ role = decision.choices[owner.id]
652
+ role && documents.fetch(role).by_id[owner.id]
653
+ end
654
+ import_offset = expected.rindex { |owner| package_or_import_owner?(owner) }
655
+ documents.fetch(:theirs).owners.each do |owner|
656
+ next if ours.by_id.key?(owner.id)
657
+ next unless decision.choices[owner.id] == :theirs
658
+
659
+ if import_owner?(owner)
660
+ import_offset = import_offset ? import_offset + 1 : 0
661
+ expected.insert(import_offset, owner)
662
+ else
663
+ expected << owner
664
+ end
665
+ end
666
+ expected
667
+ end
668
+
669
+ def grouped_owner?(owner)
670
+ signature = owner.signature.first == :export_declaration ? owner.signature.fetch(1) : owner.signature
671
+ signature.first == :variables && signature.last.length > 1
672
+ end
673
+
674
+ def import_owner?(owner)
675
+ owner.signature.first == :import
676
+ end
677
+
678
+ alias package_or_import_owner? import_owner?
679
+
680
+ def verify_rendered(output, expected, dialect)
681
+ parsed = parse_source(output, :output, dialect)
682
+ if provider_failure?(parsed)
683
+ return {
684
+ output_reparsed: false,
685
+ semantic_match: false,
686
+ parse_error: failure_detail(parsed)
687
+ }
688
+ end
689
+
690
+ actual = parsed.owners.map { |owner| [owner.signature, owner.fingerprint] }
691
+ planned = expected.map { |owner| [owner.signature, owner.fingerprint] }
692
+ {
693
+ output_reparsed: true,
694
+ semantic_match: actual == planned,
695
+ ordered_declaration_signatures_verified: actual.map(&:first) == planned.map(&:first),
696
+ ast_attributes_verified: actual.map(&:last) == planned.map(&:last),
697
+ planned_declaration_count: planned.length,
698
+ output_declaration_count: actual.length
699
+ }
700
+ end
701
+
702
+ def render_conflicts(request, documents, decision)
703
+ localized = localized_conflict_fragments(request, documents, decision)
704
+ if localized
705
+ rendered = render_plan(request, localized)
706
+ return conflict_failure(request, decision, rendered, :declaration_localized_conflict)
707
+ end
708
+
709
+ rendered = render_plan(request, [whole_document_conflict(request, decision)])
710
+ conflict_failure(
711
+ request,
712
+ decision,
713
+ rendered,
714
+ :full_file_conflict,
715
+ fallbacks: [{ from: :declaration_localization, to: :full_file_conflict, reason: :source_ownership_unproven }]
716
+ )
717
+ end
718
+
719
+ def localized_conflict_fragments(request, documents, decision)
720
+ return if decision.conflicts.any? { |conflict| conflict[:owner_id].nil? }
721
+
722
+ ours = documents.fetch(:ours)
723
+ return unless decision.conflicts.all? { |conflict| ours.by_id.key?(conflict.fetch(:owner_id)) }
724
+
725
+ ranges = decision.conflicts.map { |conflict| [conflict, ours.by_id.fetch(conflict.fetch(:owner_id))] }
726
+ return unless ranges.sort_by { |_conflict, owner| owner.start_line }.each_cons(2).none? do |left, right|
727
+ left.last.end_line >= right.last.start_line
728
+ end
729
+
730
+ fragments = []
731
+ cursor = 1
732
+ ranges.sort_by { |_conflict, owner| owner.start_line }.each do |conflict, owner|
733
+ append_range(fragments, :ours, cursor, owner.start_line - 1)
734
+ fragments << declaration_conflict_fragment(request, documents, conflict)
735
+ cursor = owner.end_line + 1
736
+ end
737
+ append_range(fragments, :ours, cursor, ours.source.lines.length)
738
+ fragments
739
+ end
740
+
741
+ def declaration_conflict_fragment(request, documents, conflict)
742
+ Ast::Merge::SourceRender::ConflictFragment.new(
743
+ conflict_id: conflict.fetch(:conflict_id),
744
+ base: conflict_declaration_side(documents, conflict, :base),
745
+ ours: conflict_declaration_side(documents, conflict, :ours),
746
+ theirs: conflict_declaration_side(documents, conflict, :theirs),
747
+ labels: request.fetch(:labels, {}),
748
+ marker_size: request.fetch(:conflict_marker_size, 7),
749
+ metadata: { path: conflict.fetch(:path), category: conflict.fetch(:category) }
750
+ )
751
+ end
752
+
753
+ def conflict_declaration_side(documents, conflict, role)
754
+ document = documents.fetch(role)
755
+ owner = document.by_id[conflict.fetch(:owner_id)]
756
+ owner ? [conflict_source_fragment(owner, document.source)] : []
757
+ end
758
+
759
+ def unsafe_document_failure(request, role, parsed)
760
+ return parsed unless parsed[:unsafe_range] || parsed[:ambiguous_owner]
761
+
762
+ conflict_id = "typescript-unsafe-#{Digest::SHA256.hexdigest([role, failure_detail(parsed)].join("\0"))[0, 16]}"
763
+ conflict = {
764
+ conflict_id: conflict_id,
765
+ category: parsed[:ambiguous_owner] ? :ambiguous_owner : :unsafe_source_range,
766
+ path: '<document>',
767
+ owner_id: nil,
768
+ source_role: role
769
+ }
770
+ rendered = render_plan(
771
+ request,
772
+ [whole_document_conflict(request, Decision.new(changes: [], conflicts: [conflict], choices: {}))]
773
+ )
774
+ failure(
775
+ :merge3,
776
+ request,
777
+ category: conflict.fetch(:category),
778
+ message: "#{role} cannot be merged safely: #{failure_detail(parsed)}",
779
+ conflicts: [conflict],
780
+ conflicted_output: rendered.content,
781
+ source_role: role,
782
+ render_report: render_report(rendered, :full_file_conflict),
783
+ verification: { base_participated: true }
784
+ )
785
+ end
786
+
787
+ def whole_document_conflict(request, decision)
788
+ Ast::Merge::SourceRender::ConflictFragment.new(
789
+ conflict_id: decision.conflicts.first.fetch(:conflict_id),
790
+ base: [conflict_whole_source_fragment(:base, request.fetch(:base_source))],
791
+ ours: [conflict_whole_source_fragment(:ours, request.fetch(:ours_source))],
792
+ theirs: [conflict_whole_source_fragment(:theirs, request.fetch(:theirs_source))],
793
+ labels: request.fetch(:labels, {}),
794
+ marker_size: request.fetch(:conflict_marker_size, 7),
795
+ metadata: { conflicts: decision.conflicts.map { |conflict| conflict[:conflict_id] } }
796
+ )
797
+ end
798
+
799
+ def conflict_source_fragment(owner, source)
800
+ fragment_source = source.lines.slice(owner.start_line - 1, owner.end_line - owner.start_line + 1).join
801
+ return source_range_fragment(owner.role, owner.start_line, owner.end_line) if fragment_source.end_with?("\n")
802
+
803
+ synthesized_copy("#{fragment_source}\n", :conflict_line_boundary, owner.role)
804
+ end
805
+
806
+ def conflict_whole_source_fragment(role, source)
807
+ return whole_source_fragment(role, source) if source.empty? || source.end_with?("\n")
808
+
809
+ synthesized_copy("#{source}\n", :conflict_line_boundary, role)
810
+ end
811
+
812
+ def synthesized_copy(content, reason, role)
813
+ Ast::Merge::SourceRender::SynthesizedFragment.new(
814
+ content: content,
815
+ reason: reason,
816
+ producer: provider_id,
817
+ metadata: { source_role: role, copied_source: true }
818
+ )
819
+ end
820
+
821
+ def whole_source_fragment(role, source)
822
+ return synthesized_copy('', :exact_empty_source, role) if source.empty?
823
+
824
+ source_range_fragment(role, 1, source.lines.length)
825
+ end
826
+
827
+ def source_range_fragment(role, start_line, end_line)
828
+ Ast::Merge::SourceRender::SourceFragment.new(
829
+ revision: role,
830
+ start_line: start_line,
831
+ end_line: end_line,
832
+ metadata: { source_role: role }
833
+ )
834
+ end
835
+
836
+ def render_plan(request, fragments)
837
+ Ast::Merge::SourceRender::Renderer.new.render(
838
+ Ast::Merge::SourceRender::Plan.new(
839
+ sources: {
840
+ base: request[:base_source].to_s,
841
+ ours: request[:ours_source].to_s,
842
+ theirs: request[:theirs_source].to_s
843
+ },
844
+ fragments: fragments,
845
+ metadata: { provider_id: provider_id }
846
+ )
847
+ )
848
+ end
849
+
850
+ def render_report(rendered, strategy)
851
+ {
852
+ strategy: strategy,
853
+ line_records: rendered.line_records,
854
+ synthesized_fragments: rendered.synthesized_fragments,
855
+ conflicts: rendered.conflicts,
856
+ verification_input: rendered.verification_input
857
+ }
858
+ end
859
+
860
+ def conflict_failure(request, decision, rendered, strategy, fallbacks: [])
861
+ failure(
862
+ :merge3,
863
+ request,
864
+ category: :merge_conflict,
865
+ message: 'TypeScript top-level declaration changed incompatibly on both sides.',
866
+ changes: decision.changes,
867
+ conflicts: decision.conflicts,
868
+ conflicted_output: rendered.content,
869
+ render_report: render_report(rendered, strategy),
870
+ verification: { base_participated: true },
871
+ fallbacks: fallbacks
872
+ )
873
+ end
874
+
875
+ def parse_failure(operation, request, role, parsed)
876
+ category = if parsed[:parse_error]
877
+ :parse_error
878
+ elsif parsed[:ambiguous_owner]
879
+ :ambiguous_owner
880
+ else
881
+ :unsafe_source_range
882
+ end
883
+ failure(
884
+ operation,
885
+ request,
886
+ category: category,
887
+ message: "#{role} parse error: #{failure_detail(parsed)}",
888
+ source_role: role
889
+ )
890
+ end
891
+
892
+ def failure_detail(parsed)
893
+ return parsed[:parse_error] if parsed[:parse_error]
894
+ return "ambiguous duplicate declaration #{parsed[:ambiguous_owner].inspect}" if parsed[:ambiguous_owner]
895
+
896
+ "unsafe declaration range #{parsed[:unsafe_range].inspect}"
897
+ end
898
+
899
+ def provider_failure?(value)
900
+ value.is_a?(Hash) &&
901
+ (value[:ok] == false || value.key?(:parse_error) || value.key?(:ambiguous_owner) || value.key?(:unsafe_range))
902
+ end
903
+
904
+ def result(operation, request, changes: [], diagnostics: [], render_report: {}, verification: {}, **payload)
905
+ Ast::Merge::ProviderResult.build(
906
+ operation: operation,
907
+ success: true,
908
+ envelope: envelope(
909
+ request,
910
+ changes: changes,
911
+ diagnostics: diagnostics,
912
+ render_report: render_report,
913
+ verification: verification
914
+ ),
915
+ **payload
916
+ )
917
+ end
918
+
919
+ def failure(operation, request, category:, message:, changes: [], conflicts: [], fallbacks: [],
920
+ render_report: {}, verification: {}, **payload)
921
+ Ast::Merge::ProviderResult.build(
922
+ operation: operation,
923
+ success: false,
924
+ envelope: envelope(
925
+ request,
926
+ changes: changes,
927
+ conflicts: conflicts,
928
+ fallbacks: fallbacks,
929
+ diagnostics: [{ category: category, severity: :error, message: message, blocking: true }],
930
+ render_report: render_report,
931
+ verification: verification
932
+ ),
933
+ **payload
934
+ )
935
+ end
936
+
937
+ def envelope(request, **fields)
938
+ {
939
+ provider: {
940
+ provider_id: provider_id,
941
+ family: family,
942
+ dialect: request[:dialect] || :typescript,
943
+ backend: request[:backend] || TREE_SITTER_BACKEND.id.to_sym,
944
+ package: 'typescript-merge',
945
+ package_version: TypeScript::Merge::Version::VERSION
946
+ },
947
+ profile: { profile_id: request[:profile_id] || DEFAULT_PROFILE }
948
+ }.merge(fields)
949
+ end
950
+ end
951
+ # rubocop:enable Metrics/AbcSize, Metrics/ClassLength, Metrics/CyclomaticComplexity, Metrics/MethodLength, Metrics/ParameterLists, Metrics/PerceivedComplexity
952
+ end
953
+ end