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