psych-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: 5489a19762de99f5b8b99c8b3ad8252a3c6d5fbc4acb2973c8c7bc0a19628d64
4
- data.tar.gz: 9445a554b835f145246c5360232072dcbab1202a1d900a805b7a65bd5028426e
3
+ metadata.gz: effeefd9ca35015e0fb306e5e2f22f48d774cb21e8cadfb7e1e49b7a28343015
4
+ data.tar.gz: bdc4795f96a7903ac928a5e5a376f8ecb9040aa10eac9f11569b9bc256e36356
5
5
  SHA512:
6
- metadata.gz: eccc4b1be4ca2ddaabd2b7ce4925fc776eb55b2b768608618e3aa580674c0c0d6a09f99463dc94934d79f0600aa5a430a7a299539c6336b4a534ae02a7267b8e
7
- data.tar.gz: 45f58cdb0b7448e8975ba2d006a4227225d7d594e9e18a19b036974582048f8fe2405f3733d5efb45b3f881aad422ba6fdf068332323be7421b261334b93086e
6
+ metadata.gz: 5a7606d1f679163a5f268cb5e3b6eb504496882b9c41d9e5a24bafa64b07cbdf2f78337d6f58d07384fab1c50910a5a3b2424dca20c03baaa7c951096db1e204
7
+ data.tar.gz: 9516a9cd5d40d6ad876b34015a3bc0a0e37d678f7c44de0aec4f1bfde3f9f949ed55b01bbbad75d34e0937d7196114eeaee5f13b547a4f20a4aa5e231d9adcbc
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.
@@ -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.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/psych/merge.rb CHANGED
@@ -3,7 +3,6 @@
3
3
  # External gems
4
4
  require 'psych'
5
5
  require 'tree_haver'
6
- require 'yaml/merge'
7
6
 
8
7
  # Shared merge infrastructure
9
8
  require 'ast/merge'
@@ -158,13 +157,13 @@ module Psych
158
157
  return unsupported_feature_parse_result("Unsupported YAML dialect #{dialect}.") unless dialect == 'yaml'
159
158
 
160
159
  parsed = yaml_value_for_source(source)
161
- Yaml::Merge.analyze_yaml_document(parsed, dialect)
160
+ Ast::Merge::YamlDocument.analyze(parsed, dialect)
162
161
  rescue TreeHaver::Error, StandardError => e
163
162
  parse_error_result(e.message)
164
163
  end
165
164
 
166
165
  def match_yaml_owners(template, destination)
167
- Yaml::Merge.match_yaml_owners(template, destination)
166
+ Ast::Merge::YamlDocument.match_owners(template, destination)
168
167
  end
169
168
 
170
169
  def merge_yaml(
@@ -306,7 +305,10 @@ module Psych
306
305
  end
307
306
  end
308
307
 
308
+ require_relative 'merge/provider'
309
+
309
310
  Psych::Merge.register_backend!
311
+ Psych::Merge.register_provider!
310
312
 
311
313
  # Register with ast-merge's MergeGemRegistry for RSpec dependency tags
312
314
  # 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.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
@@ -85,40 +85,26 @@ dependencies:
85
85
  - - ">="
86
86
  - !ruby/object:Gem::Version
87
87
  version: 1.1.15
88
- - !ruby/object:Gem::Dependency
89
- name: yaml-merge
90
- requirement: !ruby/object:Gem::Requirement
91
- requirements:
92
- - - '='
93
- - !ruby/object:Gem::Version
94
- version: 7.1.1
95
- type: :runtime
96
- prerelease: false
97
- version_requirements: !ruby/object:Gem::Requirement
98
- requirements:
99
- - - '='
100
- - !ruby/object:Gem::Version
101
- version: 7.1.1
102
88
  - !ruby/object:Gem::Dependency
103
89
  name: kettle-dev
104
90
  requirement: !ruby/object:Gem::Requirement
105
91
  requirements:
106
92
  - - "~>"
107
93
  - !ruby/object:Gem::Version
108
- version: '2.5'
94
+ version: '3.0'
109
95
  - - ">="
110
96
  - !ruby/object:Gem::Version
111
- version: 2.5.17
97
+ version: 3.0.0
112
98
  type: :development
113
99
  prerelease: false
114
100
  version_requirements: !ruby/object:Gem::Requirement
115
101
  requirements:
116
102
  - - "~>"
117
103
  - !ruby/object:Gem::Version
118
- version: '2.5'
104
+ version: '3.0'
119
105
  - - ">="
120
106
  - !ruby/object:Gem::Version
121
- version: 2.5.17
107
+ version: 3.0.0
122
108
  - !ruby/object:Gem::Dependency
123
109
  name: bundler-audit
124
110
  requirement: !ruby/object:Gem::Requirement
@@ -196,7 +182,7 @@ dependencies:
196
182
  version: '3.2'
197
183
  - - ">="
198
184
  - !ruby/object:Gem::Version
199
- version: 3.2.0
185
+ version: 3.2.2
200
186
  type: :development
201
187
  prerelease: false
202
188
  version_requirements: !ruby/object:Gem::Requirement
@@ -206,7 +192,7 @@ dependencies:
206
192
  version: '3.2'
207
193
  - - ">="
208
194
  - !ruby/object:Gem::Version
209
- version: 3.2.0
195
+ version: 3.2.2
210
196
  - !ruby/object:Gem::Dependency
211
197
  name: kettle-test
212
198
  requirement: !ruby/object:Gem::Requirement
@@ -236,7 +222,7 @@ dependencies:
236
222
  version: '3.2'
237
223
  - - ">="
238
224
  - !ruby/object:Gem::Version
239
- version: 3.2.4
225
+ version: 3.2.5
240
226
  type: :development
241
227
  prerelease: false
242
228
  version_requirements: !ruby/object:Gem::Requirement
@@ -246,7 +232,7 @@ dependencies:
246
232
  version: '3.2'
247
233
  - - ">="
248
234
  - !ruby/object:Gem::Version
249
- version: 3.2.4
235
+ version: 3.2.5
250
236
  - !ruby/object:Gem::Dependency
251
237
  name: ruby-progressbar
252
238
  requirement: !ruby/object:Gem::Requirement
@@ -324,6 +310,7 @@ files:
324
310
  - lib/psych/merge/node_type_normalizer.rb
325
311
  - lib/psych/merge/node_wrapper.rb
326
312
  - lib/psych/merge/partial_template_merger.rb
313
+ - lib/psych/merge/provider.rb
327
314
  - lib/psych/merge/smart_merger.rb
328
315
  - lib/psych/merge/version.rb
329
316
  - sig/psych/merge.rbs
@@ -333,10 +320,10 @@ licenses:
333
320
  - PolyForm-Small-Business-1.0.0
334
321
  metadata:
335
322
  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
323
+ source_code_uri: https://github.com/structuredmerge/structuredmerge-ruby/tree/v7.1.3
324
+ changelog_uri: https://github.com/structuredmerge/structuredmerge-ruby/blob/v7.1.3/CHANGELOG.md
338
325
  bug_tracker_uri: https://github.com/structuredmerge/structuredmerge-ruby/issues
339
- documentation_uri: https://www.rubydoc.info/gems/psych-merge/7.1.1
326
+ documentation_uri: https://www.rubydoc.info/gems/psych-merge/7.1.3
340
327
  funding_uri: https://github.com/sponsors/pboling
341
328
  wiki_uri: https://github.com/structuredmerge/structuredmerge-ruby/wiki
342
329
  news_uri: https://www.railsbling.com/tags/psych-merge
metadata.gz.sig CHANGED
Binary file