psych-merge 7.1.1 → 7.1.4

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 5489a19762de99f5b8b99c8b3ad8252a3c6d5fbc4acb2973c8c7bc0a19628d64
4
- data.tar.gz: 9445a554b835f145246c5360232072dcbab1202a1d900a805b7a65bd5028426e
3
+ metadata.gz: 241fd3480963a5313594945da604ff94f0294d19da35131b69768dcccfd4f1c7
4
+ data.tar.gz: 366cfd19de7c2e043def63041fc419735320f90ebeebfcb109db81692b07c2f2
5
5
  SHA512:
6
- metadata.gz: eccc4b1be4ca2ddaabd2b7ce4925fc776eb55b2b768608618e3aa580674c0c0d6a09f99463dc94934d79f0600aa5a430a7a299539c6336b4a534ae02a7267b8e
7
- data.tar.gz: 45f58cdb0b7448e8975ba2d006a4227225d7d594e9e18a19b036974582048f8fe2405f3733d5efb45b3f881aad422ba6fdf068332323be7421b261334b93086e
6
+ metadata.gz: 27ff6f39e528910b4a17eb24f78d01fb0ef40482fdb5d8f44917f11f4a3afa687e8c83ad2f8cabc03079152eb2cb9b400df1c6fe5d845d9756e1cc6df2447dc1
7
+ data.tar.gz: 78faf0e4bd85a5a24ec39feeb1f76258f6d50f28555454790083b7fd2a98c6b753c39bacfb72837b36edc1ae2b9ed4c5ff1a4e6dde1d6246b53e25935492cfc0
checksums.yaml.gz.sig CHANGED
Binary file
data/README.md CHANGED
@@ -94,7 +94,7 @@ File.write("merged.yml", result.to_yaml)
94
94
 
95
95
  ### Compatibility
96
96
 
97
- Compatible with MRI Ruby 4.0.0+, and concordant releases of JRuby, and TruffleRuby.
97
+ Compatible with MRI Ruby 4.0.0+, JRuby, and TruffleRuby.
98
98
  CI workflows and Appraisals are generated for MRI Ruby 4.0.0+.
99
99
  This test floor is configured by `ruby.test_minimum` in `.kettle-jem.yml` and
100
100
  may be higher than the gem's runtime compatibility floor when legacy Rubies are
@@ -323,20 +323,12 @@ See [SECURITY.md][🔐security].
323
323
  ## 🤝 Contributing
324
324
 
325
325
  If you need some ideas of where to help, you could work on adding more code coverage,
326
- or if it is already 💯 (see [below](#code-coverage)) check [issues][🤝gh-issues] or [PRs][🤝gh-pulls],
327
- or use the gem and think about how it could be better.
326
+ check [issues][🤝gh-issues] or [PRs][🤝gh-pulls], or use the gem and think about how it could be better.
328
327
 
329
328
  We [![Keep A Changelog][📗keep-changelog-img]][📗keep-changelog] so if you make changes, remember to update it.
330
329
 
331
330
  See [CONTRIBUTING.md][🤝contributing] for more detailed instructions.
332
331
 
333
- ### Code Coverage
334
-
335
- <details markdown="1">
336
- <summary>Coverage service badges</summary>
337
-
338
- </details>
339
-
340
332
  ## 📌 Versioning
341
333
 
342
334
  This library follows [![Semantic Versioning 2.0.0][📌semver-img]][📌semver] for its public API where practical.
@@ -476,7 +468,7 @@ If none of the available licenses suit your use case, please [contact us](mailto
476
468
  [📌gitmoji]: https://gitmoji.dev
477
469
  [📌gitmoji-img]: https://img.shields.io/badge/gitmoji_commits-%20%F0%9F%98%9C%20%F0%9F%98%8D-34495e.svg?style=flat-square
478
470
  [🧮kloc]: https://www.youtube.com/watch?v=dQw4w9WgXcQ
479
- [🧮kloc-img]: https://img.shields.io/badge/KLOC-2.008-FFDD67.svg?style=for-the-badge&logo=YouTube&logoColor=blue
471
+ [🧮kloc-img]: https://img.shields.io/badge/KLOC-2.371-FFDD67.svg?style=for-the-badge&logo=YouTube&logoColor=blue
480
472
  [🔐security]: https://github.com/structuredmerge/structuredmerge-ruby/blob/main/SECURITY.md
481
473
  [🔐security-img]: https://img.shields.io/badge/security-policy-259D6C.svg?style=flat
482
474
  [📄copyright-notice-explainer]: https://opensource.stackexchange.com/questions/5778/why-do-licenses-such-as-the-mit-license-specify-a-single-year
@@ -0,0 +1,899 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'digest'
4
+
5
+ module Psych
6
+ # Psych-backed YAML structural merge provider integration.
7
+ module Merge
8
+ # Conservative source-preserving provider for top-level YAML mappings.
9
+ # rubocop:disable Metrics/AbcSize, Metrics/ClassLength, Metrics/CyclomaticComplexity, Metrics/MethodLength, Metrics/ParameterLists, Metrics/PerceivedComplexity -- provider validation, planning, rendering, and verification form one safety boundary
10
+ class Provider
11
+ DEFAULT_PROFILE = :source_preserving
12
+ DIALECTS = %i[yaml].freeze
13
+
14
+ Entry = Data.define(
15
+ :key,
16
+ :start_line,
17
+ :end_line,
18
+ :fragment,
19
+ :semantic,
20
+ :attributes,
21
+ :role,
22
+ :ownership_provable
23
+ )
24
+ Document = Data.define(
25
+ :role,
26
+ :source,
27
+ :analysis,
28
+ :entries,
29
+ :by_key,
30
+ :issues,
31
+ :document_attributes
32
+ )
33
+ Decision = Data.define(:key, :base, :ours, :theirs, :selected_role, :classification, :conflict)
34
+
35
+ def provider_id = 'ruby.yaml.psych'
36
+ def family = 'yaml'
37
+
38
+ def capabilities
39
+ {
40
+ operations: Ast::Merge::ProviderContract::OPERATIONS,
41
+ dialects: DIALECTS,
42
+ backends: %i[psych],
43
+ profiles: [DEFAULT_PROFILE],
44
+ role: :backend,
45
+ source_preservation: %i[
46
+ exact_source
47
+ mapping_entry_fragments
48
+ comment_ownership
49
+ line_provenance
50
+ reparse
51
+ semantic_verification
52
+ ast_attribute_verification
53
+ ]
54
+ }.freeze
55
+ end
56
+
57
+ def analyze(request)
58
+ document = analyze_role(:source, request.fetch(:source))
59
+ return invalid_document_failure(:analyze, request, [document]) unless document.issues.empty?
60
+
61
+ result(
62
+ :analyze,
63
+ request,
64
+ analysis: analysis_payload(document),
65
+ verification: { source_parsed: true, structurally_unambiguous: true }
66
+ )
67
+ end
68
+
69
+ def diff2(request)
70
+ before = analyze_role(:before, request.fetch(:before_source))
71
+ after = analyze_role(:after, request.fetch(:after_source))
72
+ unsafe = [before, after].reject { |document| document.issues.empty? }
73
+ return invalid_document_failure(:diff2, request, unsafe) unless unsafe.empty?
74
+
75
+ changes = mapping_changes(before, after)
76
+ result(
77
+ :diff2,
78
+ request,
79
+ diff: { before: analysis_payload(before), after: analysis_payload(after), changes: changes },
80
+ changes: changes,
81
+ verification: { before_parsed: true, after_parsed: true }
82
+ )
83
+ end
84
+
85
+ def merge2(request)
86
+ documents = {
87
+ incoming: analyze_role(:incoming, request.fetch(:incoming_source)),
88
+ current: analyze_role(:current, request.fetch(:current_source))
89
+ }
90
+ unsafe = documents.values.reject { |document| document.issues.empty? }
91
+ return invalid_document_failure(:merge2, request, unsafe) unless unsafe.empty?
92
+
93
+ additions = documents[:incoming].entries.reject { |entry| documents[:current].by_key.key?(entry.key) }
94
+ render_documents = { ours: documents[:current], theirs: documents[:incoming] }
95
+ fragments = [whole_source_fragment(:ours, documents[:current].source)]
96
+ fragments.concat(additions.map { |entry| entry_fragment(:theirs, entry) })
97
+ rendered = render_plan(request, separated_fragments(fragments, render_documents))
98
+ expected = [
99
+ *documents[:current].entries.map { |entry| selected_entry(entry, :current) },
100
+ *additions.map { |entry| selected_entry(entry, :incoming) }
101
+ ]
102
+ verification = verify_composite(rendered.content, expected)
103
+ return verification_failure(:merge2, request, rendered, [], verification) unless verification[:semantic_match]
104
+
105
+ result(
106
+ :merge2,
107
+ request,
108
+ output: rendered.content,
109
+ changes: mapping_changes(documents[:current], analyze_role(:output, rendered.content)),
110
+ render_report: render_report(rendered, :exact_mapping_entry_composite),
111
+ verification: verification
112
+ )
113
+ end
114
+
115
+ def merge3(request)
116
+ documents = %i[base ours theirs].to_h do |role|
117
+ [role, analyze_role(role, request.fetch(:"#{role}_source"))]
118
+ end
119
+ exact_role = exact_revision_role(documents)
120
+ return render_exact(request, documents, exact_role) if exact_role
121
+
122
+ unsafe = documents.values.reject { |document| document.issues.empty? }
123
+ return structural_conflict(request, documents, unsafe) unless unsafe.empty?
124
+ return unmanaged_conflict(request, documents) unless unmanaged_unchanged?(documents)
125
+
126
+ decisions = three_way_decisions(documents)
127
+ conflicts = decisions.select(&:conflict)
128
+ return render_conflicts(request, documents, decisions, conflicts) unless conflicts.empty?
129
+
130
+ render_composite(request, documents, decisions)
131
+ end
132
+
133
+ private
134
+
135
+ def analyze_role(role, source)
136
+ analysis = FileAnalysis.new(source)
137
+ stream = analysis.ast
138
+ documents = stream&.children || []
139
+ issues = []
140
+ issues << issue(:multiple_documents, 'YAML input must contain exactly one document') unless documents.one?
141
+ document = documents.first
142
+ issues.concat(document_issues(document))
143
+ root = document&.children&.first
144
+ unless root.is_a?(::Psych::Nodes::Mapping)
145
+ issues << issue(:non_mapping_root, 'YAML document root must be a mapping')
146
+ end
147
+ issues.concat(node_issues(root)) if root
148
+
149
+ entries = root.is_a?(::Psych::Nodes::Mapping) ? mapping_entries(analysis, source, role) : []
150
+ duplicate = entries.group_by(&:key).find { |_key, matches| matches.length > 1 }
151
+ issues << issue(:duplicate_key, "Duplicate YAML key #{duplicate.first}", key: duplicate.first) if duplicate
152
+
153
+ Document.new(
154
+ role: role,
155
+ source: source,
156
+ analysis: analysis,
157
+ entries: entries.freeze,
158
+ by_key: entries.to_h { |entry| [entry.key, entry] }.freeze,
159
+ issues: issues.freeze,
160
+ document_attributes: document ? ast_attributes(document) : {}
161
+ )
162
+ rescue ::Psych::SyntaxError, Psych::Merge::ParseError => e
163
+ invalid_document(role, source, :parse_error, e.message)
164
+ rescue StandardError => e
165
+ invalid_document(role, source, :structural_error, e.message)
166
+ end
167
+
168
+ def invalid_document(role, source, category, message)
169
+ Document.new(
170
+ role: role,
171
+ source: source,
172
+ analysis: nil,
173
+ entries: [].freeze,
174
+ by_key: {}.freeze,
175
+ issues: [issue(category, message)].freeze,
176
+ document_attributes: {}
177
+ )
178
+ end
179
+
180
+ def document_issues(document)
181
+ return [] unless document
182
+
183
+ issues = []
184
+ unless Array(document.version).empty? && Array(document.tag_directives).empty?
185
+ issues << issue(:directive, 'YAML directives require an exact one-sided merge')
186
+ end
187
+ issues
188
+ end
189
+
190
+ def node_issues(node)
191
+ issues = []
192
+ walk_nodes(node) do |current|
193
+ if current.is_a?(::Psych::Nodes::Alias)
194
+ issues << issue(:alias, 'YAML aliases require an exact one-sided merge')
195
+ end
196
+ if current.respond_to?(:anchor) && !current.anchor.to_s.empty?
197
+ issues << issue(:anchor, 'YAML anchors require an exact one-sided merge')
198
+ end
199
+ if flow_collection?(current)
200
+ issues << issue(:flow_collection, 'Flow-style collections require an exact one-sided merge')
201
+ end
202
+ next unless current.is_a?(::Psych::Nodes::Mapping)
203
+
204
+ keys = current.children.each_slice(2).map(&:first).compact
205
+ if current.equal?(node) && keys.any? { |key| !key.is_a?(::Psych::Nodes::Scalar) }
206
+ issues << issue(:complex_key, 'Top-level YAML keys must be scalars')
207
+ end
208
+ if keys.any? { |key| key.is_a?(::Psych::Nodes::Scalar) && key.value == '<<' }
209
+ issues << issue(:merge_key, 'YAML merge keys require an exact one-sided merge')
210
+ end
211
+ duplicate = keys.group_by { |key| semantic_key(key) }.find { |_key, matches| matches.length > 1 }
212
+ issues << issue(:duplicate_key, 'Duplicate YAML mapping key') if duplicate
213
+ end
214
+ issues.uniq.freeze
215
+ end
216
+
217
+ def walk_nodes(node, &block)
218
+ yield node
219
+ Array(node.respond_to?(:children) ? node.children : []).each { |child| walk_nodes(child, &block) }
220
+ end
221
+
222
+ def flow_collection?(node)
223
+ case node
224
+ when ::Psych::Nodes::Mapping
225
+ node.style == ::Psych::Nodes::Mapping::FLOW
226
+ when ::Psych::Nodes::Sequence
227
+ node.style == ::Psych::Nodes::Sequence::FLOW
228
+ else
229
+ false
230
+ end
231
+ end
232
+
233
+ def mapping_entries(analysis, source, role)
234
+ analysis.root_mapping_entries.filter_map.with_index do |(key, value), index|
235
+ next unless key && value
236
+
237
+ unless key.node.is_a?(::Psych::Nodes::Scalar)
238
+ next invalid_top_level_entry(analysis, key, value, source, role, index)
239
+ end
240
+
241
+ build_entry(analysis, key, value, source, role, index)
242
+ end
243
+ end
244
+
245
+ def invalid_top_level_entry(analysis, key, value, source, role, index)
246
+ entry = build_entry(analysis, key, value, source, role, index)
247
+ Entry.new(**entry.to_h.merge(key: "<complex-key-#{index}>", ownership_provable: false))
248
+ end
249
+
250
+ def build_entry(analysis, key, value, source, role, index)
251
+ lines = source.lines
252
+ mapping = MappingEntry.new(key: key, value: value, lines: lines, comment_tracker: analysis.comment_tracker)
253
+ key_line = key.start_line || 1
254
+ value_end = [value.end_line || key.end_line || key_line, key_line].max
255
+ leading_start = owned_leading_start(analysis, mapping, index, key_line)
256
+ fragment = source_lines(source, leading_start, value_end)
257
+ Entry.new(
258
+ key: key.node.value.to_s,
259
+ start_line: leading_start,
260
+ end_line: value_end,
261
+ fragment: fragment,
262
+ semantic: ast_semantic(value.node),
263
+ attributes: ast_attribute_tree(value.node),
264
+ role: role,
265
+ ownership_provable: ownership_provable?(analysis, mapping, index, key_line, leading_start)
266
+ )
267
+ end
268
+
269
+ def owned_leading_start(analysis, mapping, index, key_line)
270
+ return key_line if index.zero?
271
+
272
+ region = analysis.comment_attachment_for(mapping)&.leading_region
273
+ return key_line unless region && region.end_line == key_line - 1
274
+ return key_line if region.metadata[:floating]
275
+
276
+ region.start_line
277
+ end
278
+
279
+ def ownership_provable?(analysis, mapping, index, key_line, leading_start)
280
+ return true if leading_start == key_line
281
+ return false if index.zero?
282
+
283
+ region = analysis.comment_attachment_for(mapping)&.leading_region
284
+ region && !region.metadata[:floating] && region.start_line == leading_start
285
+ end
286
+
287
+ def semantic_key(node)
288
+ return [:complex, ast_semantic(node)] unless node.is_a?(::Psych::Nodes::Scalar)
289
+
290
+ [node.value, node.tag]
291
+ end
292
+
293
+ def ast_semantic(node)
294
+ {
295
+ type: node.class.name.delete_prefix('Psych::Nodes::'),
296
+ value: (node.value if node.respond_to?(:value)),
297
+ tag: (node.tag if node.respond_to?(:tag)),
298
+ children: Array(node.respond_to?(:children) ? node.children : []).map { |child| ast_semantic(child) }
299
+ }.compact.freeze
300
+ end
301
+
302
+ def ast_attribute_tree(node)
303
+ {
304
+ attributes: ast_attributes(node),
305
+ children: Array(node.respond_to?(:children) ? node.children : []).map { |child| ast_attribute_tree(child) }
306
+ }.freeze
307
+ end
308
+
309
+ def ast_attributes(node)
310
+ attribute_names = %i[anchor tag style plain quoted implicit implicit_end version tag_directives]
311
+ attribute_names.each_with_object({}) do |name, attrs|
312
+ attrs[name] = public_value(node.public_send(name)) if node.respond_to?(name)
313
+ end.freeze
314
+ end
315
+
316
+ def public_value(value)
317
+ case value
318
+ when Array then value.map { |item| public_value(item) }
319
+ when Hash then value.to_h { |key, item| [key.to_s, public_value(item)] }
320
+ when String, Integer, Float, TrueClass, FalseClass, NilClass then value
321
+ else value.to_s
322
+ end
323
+ end
324
+
325
+ def analysis_payload(document)
326
+ {
327
+ dialect: 'yaml',
328
+ backend: 'psych',
329
+ document_attributes: document.document_attributes,
330
+ entries: document.entries.map do |entry|
331
+ {
332
+ key: entry.key,
333
+ value: entry.semantic,
334
+ attributes: entry.attributes,
335
+ source_lines: [entry.start_line, entry.end_line]
336
+ }
337
+ end,
338
+ structurally_unambiguous: document.issues.empty?
339
+ }
340
+ end
341
+
342
+ def entry_state(entry)
343
+ entry && [entry.semantic, entry.attributes, entry.fragment]
344
+ end
345
+
346
+ def exact_revision_role(documents)
347
+ return :ours if documents[:ours].source == documents[:theirs].source
348
+ return :ours if documents[:base].source == documents[:theirs].source
349
+
350
+ :theirs if documents[:base].source == documents[:ours].source
351
+ end
352
+
353
+ def render_exact(request, documents, role)
354
+ winner = documents.fetch(role)
355
+ return structural_conflict(request, documents, [winner]) unless fatal_issues(winner).empty?
356
+
357
+ rendered = render_plan(request, [whole_source_fragment(role, winner.source)])
358
+ verification = verify_exact(rendered.content, winner, role)
359
+ return verification_failure(:merge3, request, rendered, [], verification) unless verification[:semantic_match]
360
+
361
+ result(
362
+ :merge3,
363
+ request,
364
+ output: rendered.content,
365
+ changes: mapping_changes(documents[:base], winner),
366
+ diagnostics: diagnostics_for(*documents.values),
367
+ render_report: render_report(rendered, :exact_revision),
368
+ verification: verification.merge(base_participated: true)
369
+ )
370
+ end
371
+
372
+ def verify_exact(output, expected, role)
373
+ parsed = analyze_role(:output, output)
374
+ {
375
+ output_reparsed: true,
376
+ byte_exact: output == expected.source,
377
+ semantic_match: fatal_issues(parsed).empty? && document_signature(parsed) == document_signature(expected),
378
+ ast_attributes_match: parsed.document_attributes == expected.document_attributes &&
379
+ parsed.entries.map(&:attributes) == expected.entries.map(&:attributes),
380
+ ordered_entries: ordered_semantics(parsed),
381
+ source_role: role
382
+ }
383
+ end
384
+
385
+ def mapping_changes(before, after)
386
+ ordered_keys(before, after).filter_map do |key|
387
+ left = before.by_key[key]
388
+ right = after.by_key[key]
389
+ next if entry_state(left) == entry_state(right)
390
+
391
+ { path: "/#{key}", ours: :unchanged, theirs: change_kind(left, right) }.freeze
392
+ end.freeze
393
+ end
394
+
395
+ def ordered_keys(*documents)
396
+ documents.flat_map { |document| document.entries.map(&:key) }.uniq
397
+ end
398
+
399
+ def change_kind(before, after)
400
+ return :added unless before
401
+ return :deleted unless after
402
+
403
+ :edited
404
+ end
405
+
406
+ def three_way_decisions(documents)
407
+ ordered_keys(documents[:ours], documents[:theirs], documents[:base]).map do |key|
408
+ base = documents[:base].by_key[key]
409
+ ours = documents[:ours].by_key[key]
410
+ theirs = documents[:theirs].by_key[key]
411
+ selected_role =
412
+ if entry_state(ours) == entry_state(theirs)
413
+ :ours
414
+ elsif entry_state(ours) == entry_state(base)
415
+ :theirs
416
+ elsif entry_state(theirs) == entry_state(base)
417
+ :ours
418
+ end
419
+ classification = {
420
+ path: "/#{key}",
421
+ ours: side_change(base, ours),
422
+ theirs: side_change(base, theirs)
423
+ }.freeze
424
+ Decision.new(
425
+ key: key,
426
+ base: base,
427
+ ours: ours,
428
+ theirs: theirs,
429
+ selected_role: selected_role,
430
+ classification: classification,
431
+ conflict: selected_role.nil?
432
+ )
433
+ end.freeze
434
+ end
435
+
436
+ def side_change(base, side)
437
+ entry_state(base) == entry_state(side) ? :unchanged : change_kind(base, side)
438
+ end
439
+
440
+ def unmanaged_unchanged?(documents)
441
+ anchor_keys = documents.values.map { |document| document.by_key.keys }.reduce(&:intersection)
442
+ signatures = documents.transform_values { |document| unmanaged_signature(document, anchor_keys) }
443
+ signatures[:base] == signatures[:ours] && signatures[:base] == signatures[:theirs]
444
+ end
445
+
446
+ def unmanaged_signature(document, anchor_keys)
447
+ entries = document.entries.sort_by(&:start_line)
448
+ signature = []
449
+ cursor = 1
450
+ entries.each do |entry|
451
+ if cursor < entry.start_line
452
+ signature << [:source, source_lines(document.source, cursor, entry.start_line - 1)]
453
+ end
454
+ signature << [:entry, entry.key] if anchor_keys.include?(entry.key)
455
+ cursor = entry.end_line + 1
456
+ end
457
+ signature << [:source, source_lines(document.source, cursor, document.source.lines.length)]
458
+ signature
459
+ end
460
+
461
+ def render_composite(request, documents, decisions)
462
+ by_key = decisions.to_h { |decision| [decision.key, decision] }
463
+ fragments = []
464
+ selected = []
465
+ cursor = 1
466
+ documents[:ours].entries.sort_by(&:start_line).each do |ours_entry|
467
+ fragments << range_fragment(:ours, cursor, ours_entry.start_line - 1) if cursor < ours_entry.start_line
468
+ decision = by_key.fetch(ours_entry.key)
469
+ selected_entry_value = decision.selected_role && decision.public_send(decision.selected_role)
470
+ if selected_entry_value
471
+ fragments << entry_fragment(decision.selected_role, selected_entry_value)
472
+ selected << selected_entry(selected_entry_value, decision.selected_role)
473
+ end
474
+ cursor = ours_entry.end_line + 1
475
+ end
476
+ ours_line_count = documents[:ours].source.lines.length
477
+ fragments << range_fragment(:ours, cursor, ours_line_count) if cursor <= ours_line_count
478
+ decisions.each do |decision|
479
+ next if decision.ours || decision.selected_role != :theirs || !decision.theirs
480
+
481
+ fragments << entry_fragment(:theirs, decision.theirs)
482
+ selected << selected_entry(decision.theirs, :theirs)
483
+ end
484
+
485
+ rendered = render_plan(request, separated_fragments(fragments.compact, documents))
486
+ verification = verify_composite(rendered.content, selected)
487
+ unless verification[:semantic_match]
488
+ return verification_failure(:merge3, request, rendered, decisions, verification)
489
+ end
490
+
491
+ result(
492
+ :merge3,
493
+ request,
494
+ output: rendered.content,
495
+ changes: decisions.map(&:classification),
496
+ render_report: render_report(rendered, :exact_mapping_entry_composite),
497
+ verification: verification.merge(base_participated: true)
498
+ )
499
+ end
500
+
501
+ def selected_entry(entry, role)
502
+ {
503
+ key: entry.key,
504
+ semantic: entry.semantic,
505
+ attributes: entry.attributes,
506
+ source_role: role,
507
+ source_lines: [entry.start_line, entry.end_line],
508
+ fragment: entry.fragment
509
+ }.freeze
510
+ end
511
+
512
+ def verify_composite(output, selected)
513
+ parsed = analyze_role(:output, output)
514
+ expected_semantics = selected.map { |entry| [entry[:key], entry[:semantic]] }
515
+ expected_attributes = selected.map { |entry| [entry[:key], entry[:attributes]] }
516
+ actual_semantics = parsed.entries.map { |entry| [entry.key, entry.semantic] }
517
+ actual_attributes = parsed.entries.map { |entry| [entry.key, entry.attributes] }
518
+ source_match = parsed.entries.zip(selected).all? do |actual, expected|
519
+ actual.fragment == expected[:fragment] ||
520
+ (!expected[:fragment].end_with?("\n") && actual.fragment == "#{expected[:fragment]}\n")
521
+ end
522
+ {
523
+ output_reparsed: true,
524
+ semantic_match: parsed.issues.empty? && actual_semantics == expected_semantics &&
525
+ actual_attributes == expected_attributes && source_match,
526
+ ast_attributes_match: actual_attributes == expected_attributes,
527
+ source_match: source_match,
528
+ ordered_entries: actual_semantics,
529
+ entry_sources: selected.map { |entry| entry.slice(:key, :source_role, :source_lines) }
530
+ }
531
+ end
532
+
533
+ def ordered_semantics(document)
534
+ document.entries.map { |entry| [entry.key, entry.semantic] }
535
+ end
536
+
537
+ def document_signature(document)
538
+ [ordered_semantics(document), document.entries.map(&:attributes)]
539
+ end
540
+
541
+ def fatal_issues(document)
542
+ document.issues.select { |item| %i[parse_error structural_error].include?(item[:category]) }
543
+ end
544
+
545
+ def render_conflicts(request, documents, decisions, conflicts)
546
+ unless conflicts.all? { |conflict| conflict.ours&.ownership_provable }
547
+ return full_file_conflict(request, documents, decisions, conflicts, :ownership_unproven)
548
+ end
549
+ unless unmanaged_unchanged?(documents)
550
+ return full_file_conflict(request, documents, decisions, conflicts, :unmanaged_source_change)
551
+ end
552
+
553
+ by_key = conflicts.to_h { |conflict| [conflict.key, conflict] }
554
+ fragments = []
555
+ cursor = 1
556
+ documents[:ours].entries.sort_by(&:start_line).each do |entry|
557
+ fragments << range_fragment(:ours, cursor, entry.start_line - 1) if cursor < entry.start_line
558
+ conflict = by_key[entry.key]
559
+ fragments << (conflict ? conflict_fragment(request, conflict) : entry_fragment(:ours, entry))
560
+ cursor = entry.end_line + 1
561
+ end
562
+ ours_line_count = documents[:ours].source.lines.length
563
+ fragments << range_fragment(:ours, cursor, ours_line_count) if cursor <= ours_line_count
564
+ rendered = render_plan(request, fragments.compact)
565
+ conflict_failure(request, decisions, conflicts, rendered, :mapping_entry_localized_conflict)
566
+ end
567
+
568
+ def conflict_fragment(request, decision)
569
+ Ast::Merge::SourceRender::ConflictFragment.new(
570
+ conflict_id: conflict_id(decision.key),
571
+ base: conflict_side(:base, decision.base),
572
+ ours: conflict_side(:ours, decision.ours),
573
+ theirs: conflict_side(:theirs, decision.theirs),
574
+ labels: request.fetch(:labels, {}),
575
+ marker_size: request.fetch(:conflict_marker_size, 7),
576
+ metadata: { path: decision.classification[:path], category: conflict_category(decision) }
577
+ )
578
+ end
579
+
580
+ def conflict_side(role, entry)
581
+ return [] unless entry
582
+
583
+ [entry_fragment(role, entry, line_bounded: true)]
584
+ end
585
+
586
+ def conflict_category(decision)
587
+ decision.ours.nil? || decision.theirs.nil? ? :delete_edit : :edit_edit
588
+ end
589
+
590
+ def conflict_id(key)
591
+ "yaml-mapping-#{Digest::SHA256.hexdigest(key)[0, 16]}"
592
+ end
593
+
594
+ def conflict_failure(request, decisions, conflicts, rendered, strategy)
595
+ failure(
596
+ :merge3,
597
+ request,
598
+ category: :merge_conflict,
599
+ message: 'YAML mapping entries changed incompatibly.',
600
+ changes: decisions.map(&:classification),
601
+ conflicts: conflicts.map { |decision| conflict_payload(decision) },
602
+ conflicted_output: rendered.content,
603
+ conflicted_source: rendered.content,
604
+ render_report: render_report(rendered, strategy),
605
+ verification: { base_participated: true }
606
+ )
607
+ end
608
+
609
+ def structural_conflict(request, documents, unsafe)
610
+ source_role = unsafe.first.role
611
+ full_file_conflict(
612
+ request,
613
+ documents,
614
+ safe_decisions(documents),
615
+ [],
616
+ :structural_ambiguity,
617
+ diagnostics: [
618
+ {
619
+ category: unsafe.first.issues.first[:category],
620
+ severity: :error,
621
+ message: "#{source_role} YAML input is unsafe for structural merge.",
622
+ blocking: true,
623
+ source_role: source_role
624
+ },
625
+ *diagnostics_for(*unsafe)
626
+ ]
627
+ )
628
+ end
629
+
630
+ def safe_decisions(documents)
631
+ return [] unless documents.values.all? { |document| document.issues.empty? }
632
+
633
+ three_way_decisions(documents)
634
+ end
635
+
636
+ def unmanaged_conflict(request, documents)
637
+ full_file_conflict(
638
+ request,
639
+ documents,
640
+ three_way_decisions(documents),
641
+ [],
642
+ :unmanaged_source_change,
643
+ diagnostics: [{
644
+ category: :unmanaged_source_change,
645
+ severity: :error,
646
+ message: 'Unmanaged YAML layout or comments changed independently.',
647
+ blocking: true
648
+ }]
649
+ )
650
+ end
651
+
652
+ def full_file_conflict(request, documents, decisions, conflicts, reason, diagnostics: nil)
653
+ conflict = { conflict_id: 'yaml-conflict-root', category: reason, path: '<document>' }.freeze
654
+ rendered = render_plan(
655
+ request,
656
+ [
657
+ Ast::Merge::SourceRender::ConflictFragment.new(
658
+ conflict_id: conflict[:conflict_id],
659
+ base: conflict_source(:base, documents[:base].source),
660
+ ours: conflict_source(:ours, documents[:ours].source),
661
+ theirs: conflict_source(:theirs, documents[:theirs].source),
662
+ labels: request.fetch(:labels, {}),
663
+ marker_size: request.fetch(:conflict_marker_size, 7),
664
+ metadata: { category: reason }
665
+ )
666
+ ]
667
+ )
668
+ failure(
669
+ :merge3,
670
+ request,
671
+ category: reason,
672
+ message: 'YAML source cannot be safely combined structurally.',
673
+ diagnostics: diagnostics,
674
+ changes: decisions.map(&:classification),
675
+ conflicts: conflicts.empty? ? [conflict] : conflicts.map { |item| conflict_payload(item) },
676
+ fallbacks: [{ from: :mapping_entry_composite, to: :full_file_conflict, reason: reason }],
677
+ conflicted_output: rendered.content,
678
+ conflicted_source: rendered.content,
679
+ render_report: render_report(rendered, :full_file_conflict),
680
+ verification: { base_participated: true },
681
+ source_role: diagnostics&.first&.fetch(:source_role, nil)
682
+ )
683
+ end
684
+
685
+ def conflict_payload(decision)
686
+ {
687
+ conflict_id: conflict_id(decision.key),
688
+ category: conflict_category(decision),
689
+ path: decision.classification[:path],
690
+ change_classification: decision.classification
691
+ }.freeze
692
+ end
693
+
694
+ def conflict_source(role, source)
695
+ return [] if source.empty?
696
+
697
+ [whole_source_fragment(role, source, line_bounded: true)]
698
+ end
699
+
700
+ def separated_fragments(fragments, documents)
701
+ fragments.each_with_index.flat_map do |fragment, index|
702
+ next [fragment] if index == fragments.length - 1
703
+ next [fragment] if fragment.is_a?(Ast::Merge::SourceRender::ConflictFragment)
704
+
705
+ content = fragment_content(fragment, documents)
706
+ next [fragment] if content.empty? || content.end_with?("\n")
707
+
708
+ [
709
+ Ast::Merge::SourceRender::SynthesizedFragment.new(
710
+ content: "#{content}\n",
711
+ reason: :mapping_entry_separator,
712
+ producer: provider_id,
713
+ metadata: fragment.metadata.merge(copied_source: true)
714
+ )
715
+ ]
716
+ end
717
+ end
718
+
719
+ def fragment_content(fragment, documents)
720
+ return fragment.content if fragment.is_a?(Ast::Merge::SourceRender::SynthesizedFragment)
721
+
722
+ document = documents.fetch(fragment.revision)
723
+ source_lines(document.source, fragment.start_line, fragment.end_line)
724
+ end
725
+
726
+ def whole_source_fragment(role, source, line_bounded: false)
727
+ if source.empty?
728
+ return Ast::Merge::SourceRender::SynthesizedFragment.new(
729
+ content: '',
730
+ reason: :exact_empty_source,
731
+ producer: provider_id,
732
+ metadata: { source_role: role }
733
+ )
734
+ end
735
+ fragment = range_fragment(role, 1, source.lines.length)
736
+ return fragment unless line_bounded && !source.end_with?("\n")
737
+
738
+ Ast::Merge::SourceRender::SynthesizedFragment.new(
739
+ content: "#{source}\n",
740
+ reason: :conflict_line_boundary,
741
+ producer: provider_id,
742
+ metadata: { source_role: role, copied_source: true }
743
+ )
744
+ end
745
+
746
+ def entry_fragment(role, entry, line_bounded: false)
747
+ unless line_bounded && !entry.fragment.end_with?("\n")
748
+ return range_fragment(role, entry.start_line, entry.end_line, yaml_key: entry.key)
749
+ end
750
+
751
+ Ast::Merge::SourceRender::SynthesizedFragment.new(
752
+ content: "#{entry.fragment}\n",
753
+ reason: :conflict_line_boundary,
754
+ producer: provider_id,
755
+ metadata: { source_role: role, yaml_key: entry.key, copied_source: true }
756
+ )
757
+ end
758
+
759
+ def range_fragment(role, start_line, end_line, **metadata)
760
+ return if end_line < start_line
761
+
762
+ Ast::Merge::SourceRender::SourceFragment.new(
763
+ revision: role,
764
+ start_line: start_line,
765
+ end_line: end_line,
766
+ metadata: { source_role: role }.merge(metadata)
767
+ )
768
+ end
769
+
770
+ def source_lines(source, start_line, end_line)
771
+ return '' if end_line < start_line
772
+
773
+ source.lines.slice(start_line - 1, end_line - start_line + 1).to_a.join
774
+ end
775
+
776
+ def render_plan(request, fragments)
777
+ Ast::Merge::SourceRender::Renderer.new.render(
778
+ Ast::Merge::SourceRender::Plan.new(
779
+ sources: {
780
+ base: request[:base_source].to_s,
781
+ ours: request[:ours_source] || request[:current_source].to_s,
782
+ theirs: request[:theirs_source] || request[:incoming_source].to_s
783
+ },
784
+ fragments: fragments,
785
+ metadata: { provider_id: provider_id }
786
+ )
787
+ )
788
+ end
789
+
790
+ def render_report(rendered, strategy)
791
+ {
792
+ strategy: strategy,
793
+ line_records: rendered.line_records,
794
+ synthesized_fragments: rendered.synthesized_fragments,
795
+ conflicts: rendered.conflicts,
796
+ verification_input: rendered.verification_input
797
+ }
798
+ end
799
+
800
+ def issue(category, message, **metadata)
801
+ { category: category, message: message, **metadata }.freeze
802
+ end
803
+
804
+ def diagnostics_for(*documents)
805
+ documents.flat_map do |document|
806
+ document.issues.map do |item|
807
+ item.merge(severity: :warning, blocking: false, source_role: document.role).freeze
808
+ end
809
+ end.freeze
810
+ end
811
+
812
+ def invalid_document_failure(operation, request, documents)
813
+ first = documents.first
814
+ failure(
815
+ operation,
816
+ request,
817
+ category: first.issues.first[:category],
818
+ message: "#{first.role} YAML input is unsafe",
819
+ diagnostics: diagnostics_for(*documents).map { |diagnostic| diagnostic.merge(blocking: true) },
820
+ source_role: first.role
821
+ )
822
+ end
823
+
824
+ def verification_failure(operation, request, rendered, decisions, verification)
825
+ failure(
826
+ operation,
827
+ request,
828
+ category: :verification_failure,
829
+ message: 'Rendered YAML did not preserve planned ordered semantics, AST attributes, and source fragments.',
830
+ changes: decisions.map(&:classification),
831
+ render_report: render_report(rendered, :exact_mapping_entry_composite),
832
+ verification: operation == :merge3 ? verification.merge(base_participated: true) : verification
833
+ )
834
+ end
835
+
836
+ def result(operation, request, diagnostics: [], changes: [], conflicts: [], fallbacks: [],
837
+ render_report: {}, verification: {}, **payload)
838
+ Ast::Merge::ProviderResult.build(
839
+ operation: operation,
840
+ success: true,
841
+ envelope: envelope(
842
+ request,
843
+ diagnostics: diagnostics,
844
+ changes: changes,
845
+ conflicts: conflicts,
846
+ fallbacks: fallbacks,
847
+ render_report: render_report,
848
+ verification: verification
849
+ ),
850
+ **payload
851
+ )
852
+ end
853
+
854
+ def failure(operation, request, category:, message:, diagnostics: nil, changes: [], conflicts: [],
855
+ fallbacks: [], render_report: {}, verification: {}, **payload)
856
+ diagnostics ||= [{ category: category, severity: :error, message: message, blocking: true }]
857
+ Ast::Merge::ProviderResult.build(
858
+ operation: operation,
859
+ success: false,
860
+ envelope: envelope(
861
+ request,
862
+ diagnostics: diagnostics,
863
+ changes: changes,
864
+ conflicts: conflicts,
865
+ fallbacks: fallbacks,
866
+ render_report: render_report,
867
+ verification: verification
868
+ ),
869
+ **payload
870
+ )
871
+ end
872
+
873
+ def envelope(request, **fields)
874
+ {
875
+ provider: {
876
+ provider_id: provider_id,
877
+ family: family,
878
+ dialect: request[:dialect] || :yaml,
879
+ backend: request[:backend] || :psych,
880
+ package: PACKAGE_NAME,
881
+ package_version: Psych::Merge::Version::VERSION
882
+ },
883
+ profile: { profile_id: request[:profile_id] || DEFAULT_PROFILE }
884
+ }.merge(fields)
885
+ end
886
+ end
887
+ # rubocop:enable Metrics/AbcSize, Metrics/ClassLength, Metrics/CyclomaticComplexity, Metrics/MethodLength, Metrics/ParameterLists, Metrics/PerceivedComplexity
888
+
889
+ class << self
890
+ def merge_provider
891
+ @merge_provider ||= Provider.new
892
+ end
893
+
894
+ def register_provider!(replace: false)
895
+ Ast::Merge.register_provider(merge_provider, replace: replace)
896
+ end
897
+ end
898
+ end
899
+ end
@@ -5,7 +5,7 @@ module Psych
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.4'
9
9
  end
10
10
  # Current gem version exposed at the traditional constant location.
11
11
  VERSION = Version::VERSION # Traditional Constant Location
data/lib/psych/merge.rb CHANGED
@@ -306,7 +306,10 @@ module Psych
306
306
  end
307
307
  end
308
308
 
309
+ require_relative 'merge/provider'
310
+
309
311
  Psych::Merge.register_backend!
312
+ Psych::Merge.register_provider!
310
313
 
311
314
  # Register with ast-merge's MergeGemRegistry for RSpec dependency tags
312
315
  # Only register if MergeGemRegistry is loaded (i.e., in test environment)
data/sig/psych/merge.rbs CHANGED
@@ -1,5 +1,20 @@
1
1
  module Psych
2
2
  module Merge
3
+ BACKEND_REFERENCE: untyped
4
+
5
+ class Provider
6
+ def provider_id: () -> String
7
+ def family: () -> String
8
+ def capabilities: () -> Hash[Symbol, untyped]
9
+ def analyze: (Hash[Symbol, untyped] request) -> Hash[Symbol, untyped]
10
+ def diff2: (Hash[Symbol, untyped] request) -> Hash[Symbol, untyped]
11
+ def merge2: (Hash[Symbol, untyped] request) -> Hash[Symbol, untyped]
12
+ def merge3: (Hash[Symbol, untyped] request) -> Hash[Symbol, untyped]
13
+ end
14
+
15
+ def self.merge_provider: () -> Provider
16
+ def self.register_provider!: (?replace: bool) -> untyped
17
+
3
18
  module Version
4
19
  VERSION: String
5
20
  end
data.tar.gz.sig CHANGED
Binary file
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: psych-merge
3
3
  version: !ruby/object:Gem::Version
4
- version: 7.1.1
4
+ version: 7.1.4
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.4
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.4
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.4
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.4
68
68
  - !ruby/object:Gem::Dependency
69
69
  name: version_gem
70
70
  requirement: !ruby/object:Gem::Requirement
@@ -91,34 +91,34 @@ dependencies:
91
91
  requirements:
92
92
  - - '='
93
93
  - !ruby/object:Gem::Version
94
- version: 7.1.1
94
+ version: 7.1.4
95
95
  type: :runtime
96
96
  prerelease: false
97
97
  version_requirements: !ruby/object:Gem::Requirement
98
98
  requirements:
99
99
  - - '='
100
100
  - !ruby/object:Gem::Version
101
- version: 7.1.1
101
+ version: 7.1.4
102
102
  - !ruby/object:Gem::Dependency
103
103
  name: kettle-dev
104
104
  requirement: !ruby/object:Gem::Requirement
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.7
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.7
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
@@ -216,7 +216,7 @@ dependencies:
216
216
  version: '2.0'
217
217
  - - ">="
218
218
  - !ruby/object:Gem::Version
219
- version: 2.0.19
219
+ version: 2.0.20
220
220
  type: :development
221
221
  prerelease: false
222
222
  version_requirements: !ruby/object:Gem::Requirement
@@ -226,7 +226,7 @@ dependencies:
226
226
  version: '2.0'
227
227
  - - ">="
228
228
  - !ruby/object:Gem::Version
229
- version: 2.0.19
229
+ version: 2.0.20
230
230
  - !ruby/object:Gem::Dependency
231
231
  name: turbo_tests2
232
232
  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.6
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.6
250
250
  - !ruby/object:Gem::Dependency
251
251
  name: ruby-progressbar
252
252
  requirement: !ruby/object:Gem::Requirement
@@ -290,7 +290,7 @@ dependencies:
290
290
  version: '2.0'
291
291
  - - ">="
292
292
  - !ruby/object:Gem::Version
293
- version: 2.0.11
293
+ version: 2.0.12
294
294
  type: :development
295
295
  prerelease: false
296
296
  version_requirements: !ruby/object:Gem::Requirement
@@ -300,7 +300,7 @@ dependencies:
300
300
  version: '2.0'
301
301
  - - ">="
302
302
  - !ruby/object:Gem::Version
303
- version: 2.0.11
303
+ version: 2.0.12
304
304
  description: "☯️ Psych-backed YAML provider gem for the Structured Merge YAML family."
305
305
  email:
306
306
  - floss@galtzo.com
@@ -324,6 +324,7 @@ files:
324
324
  - lib/psych/merge/node_type_normalizer.rb
325
325
  - lib/psych/merge/node_wrapper.rb
326
326
  - lib/psych/merge/partial_template_merger.rb
327
+ - lib/psych/merge/provider.rb
327
328
  - lib/psych/merge/smart_merger.rb
328
329
  - lib/psych/merge/version.rb
329
330
  - sig/psych/merge.rbs
@@ -333,10 +334,10 @@ licenses:
333
334
  - PolyForm-Small-Business-1.0.0
334
335
  metadata:
335
336
  homepage_uri: https://structuredmerge.org
336
- source_code_uri: https://github.com/structuredmerge/structuredmerge-ruby/tree/v7.1.1
337
- changelog_uri: https://github.com/structuredmerge/structuredmerge-ruby/blob/v7.1.1/CHANGELOG.md
337
+ source_code_uri: https://github.com/structuredmerge/structuredmerge-ruby/tree/v7.1.4
338
+ changelog_uri: https://github.com/structuredmerge/structuredmerge-ruby/blob/v7.1.4/CHANGELOG.md
338
339
  bug_tracker_uri: https://github.com/structuredmerge/structuredmerge-ruby/issues
339
- documentation_uri: https://www.rubydoc.info/gems/psych-merge/7.1.1
340
+ documentation_uri: https://www.rubydoc.info/gems/psych-merge/7.1.4
340
341
  funding_uri: https://github.com/sponsors/pboling
341
342
  wiki_uri: https://github.com/structuredmerge/structuredmerge-ruby/wiki
342
343
  news_uri: https://www.railsbling.com/tags/psych-merge
metadata.gz.sig CHANGED
Binary file