psych-merge 7.1.7 → 7.1.9

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: 0d7693e02bcaa5818040a787426c323aa61a590dd91cc4da79e0e96e687e94f7
4
- data.tar.gz: 847a38b6f50dfaf0bf9e5640a99856813f8c395dac98d384209665a93fe79ad6
3
+ metadata.gz: 202682ca8764a5e677a7ac26a4cbc538e09fb1a85ade593182f85736bafc8569
4
+ data.tar.gz: be6561d077cfbbb0d772085ee4a2b3b11cd08a1cc3c1238da6cfa1c9723ef73f
5
5
  SHA512:
6
- metadata.gz: 0cddc4debbc9f284027db59284e7f5e748cce340abbc2a9f8958f1ba6103e47d5dd231c036eaac5b2decbb8317ab73cd8c994c7e7237e7e09adebdd98175dafd
7
- data.tar.gz: 2aae813c8285c42db17f5a93da7c1f01a7e2f946b14c436f4f8b56e734c584a9e704cf5e4c0069bd6bd46c249164a840ab366999a0dd32d13f052f8fb932f73c
6
+ metadata.gz: 673a4f220ebe4b0fb129d61561506d0fd140069888e0beb02b5a69c04d6b4fb1dd1974fff01317a5a24dc5dc491c479792c912c025507252f74eb65e20a36bfd
7
+ data.tar.gz: 9294200c6d988e31aa46e87b3a66a159d184a7670a6ddd91b62fd67abe04dc08c0acbe1f433fa213543648966b5e15d74e4bf7bc83fec0b290acbef5eed484f4
checksums.yaml.gz.sig CHANGED
Binary file
data/README.md CHANGED
@@ -26,9 +26,8 @@ AST analysis. It is built on [ast-merge][ast-merge] and [tree_haver][tree_haver]
26
26
  and shares YAML-family behavior with [yaml-merge][yaml-merge].
27
27
 
28
28
  `psych-merge` is a YAML provider gem, not an alternate home for YAML merge
29
- semantics. Psych-specific code should be isolated behind its TreeHaver backend
30
- adapter. Merge behavior should consume normalized YAML-family owners and
31
- source-preserving edit plans supplied by the StructuredMerge stack. Partial YAML
29
+ semantics. Its provider subclasses `Yaml::Merge::SourcePreservingProvider` and
30
+ supplies only Psych AST validation and projection. Partial YAML
32
31
  insertion, replacement, and removal should route through `ast-crispr` rather
33
32
  than converting documents to Ruby objects and serializing them with `Psych.dump`.
34
33
 
@@ -36,7 +35,7 @@ than converting documents to Ruby objects and serializing them with `Psych.dump`
36
35
 
37
36
  - **Psych-Powered**: Uses Ruby's built-in Psych parser for YAML AST analysis
38
37
  - **YAML-Aware**: Understands YAML structure including mappings, sequences, and scalars
39
- - **YAML Family Behavior**: Delegates shared YAML merge semantics to `yaml-merge`
38
+ - **YAML Family Behavior**: Inherits shared YAML merge semantics from `yaml-merge`
40
39
  - **Intelligent**: Matches nodes by structural signatures
41
40
  - **Fuzzy Key Matching**: `MappingMatchRefiner` matches similar keys (e.g., `database_url` ↔ `db_url`)
42
41
  using Levenshtein distance for typos and naming convention differences
@@ -0,0 +1,49 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Psych
4
+ module Merge
5
+ # Shared native Psych projection used by semantic verification and contract
6
+ # snapshot extensions. Location and ownership remain separate concerns.
7
+ module NativeProjection
8
+ ATTRIBUTE_NAMES = %i[anchor tag style plain quoted implicit implicit_end version tag_directives].freeze
9
+
10
+ module_function
11
+
12
+ def semantic(node)
13
+ {
14
+ type: node.class.name.delete_prefix('Psych::Nodes::'),
15
+ value: (node.value if node.respond_to?(:value)),
16
+ tag: (node.tag if node.respond_to?(:tag)),
17
+ children: children(node).map { |child| semantic(child) }
18
+ }.compact.freeze
19
+ end
20
+
21
+ def attribute_tree(node)
22
+ {
23
+ attributes: attributes(node),
24
+ children: children(node).map { |child| attribute_tree(child) }
25
+ }.freeze
26
+ end
27
+
28
+ def attributes(node)
29
+ ATTRIBUTE_NAMES.each_with_object({}) do |name, result|
30
+ result[name] = public_value(node.public_send(name)) if node.respond_to?(name)
31
+ end.freeze
32
+ end
33
+
34
+ def public_value(value)
35
+ case value
36
+ when Array then value.map { |item| public_value(item) }
37
+ when Hash then value.to_h { |key, item| [key.to_s, public_value(item)] }
38
+ when String, Integer, Float, TrueClass, FalseClass, NilClass then value
39
+ else value.to_s
40
+ end
41
+ end
42
+
43
+ def children(node)
44
+ Array(node.respond_to?(:children) ? node.children : [])
45
+ end
46
+ private_class_method :children
47
+ end
48
+ end
49
+ end
@@ -1,134 +1,16 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- require 'digest'
4
-
5
3
  module Psych
6
- # Psych-backed YAML structural merge provider integration.
4
+ # Psych-backed YAML provider integration.
7
5
  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
6
+ # Native Psych projection for the shared YAML source-preserving provider.
7
+ # rubocop:disable Metrics/AbcSize, Metrics/ClassLength, Metrics/CyclomaticComplexity, Metrics/MethodLength, Metrics/ParameterLists, Metrics/PerceivedComplexity -- native AST validation and projection remain one adapter boundary
8
+ class Provider < ::Yaml::Merge::SourcePreservingProvider
9
+ PROVIDER_ID = 'ruby.yaml.psych'
10
+ BACKEND = BACKEND_REFERENCE
11
+ PACKAGE_NAME = Merge::PACKAGE_NAME
12
+ PACKAGE_VERSION = Version::VERSION
13
+ ROLE = :backend
132
14
 
133
15
  private
134
16
 
@@ -165,16 +47,8 @@ module Psych
165
47
  invalid_document(role, source, :structural_error, e.message)
166
48
  end
167
49
 
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
- )
50
+ def analysis_extensions(document)
51
+ [ProviderExtension.call(analysis: document.analysis)]
178
52
  end
179
53
 
180
54
  def document_issues(document)
@@ -253,14 +127,13 @@ module Psych
253
127
  key_line = key.start_line || 1
254
128
  value_end = [value.end_line || key.end_line || key_line, key_line].max
255
129
  leading_start = owned_leading_start(analysis, mapping, index, key_line)
256
- fragment = source_lines(source, leading_start, value_end)
257
130
  Entry.new(
258
131
  key: key.node.value.to_s,
259
132
  start_line: leading_start,
260
133
  end_line: value_end,
261
- fragment: fragment,
262
- semantic: ast_semantic(value.node),
263
- attributes: ast_attribute_tree(value.node),
134
+ fragment: source_lines(source, leading_start, value_end),
135
+ semantic: NativeProjection.semantic(value.node),
136
+ attributes: NativeProjection.attribute_tree(value.node),
264
137
  role: role,
265
138
  ownership_provable: ownership_provable?(analysis, mapping, index, key_line, leading_start)
266
139
  )
@@ -285,603 +158,13 @@ module Psych
285
158
  end
286
159
 
287
160
  def semantic_key(node)
288
- return [:complex, ast_semantic(node)] unless node.is_a?(::Psych::Nodes::Scalar)
161
+ return [:complex, NativeProjection.semantic(node)] unless node.is_a?(::Psych::Nodes::Scalar)
289
162
 
290
163
  [node.value, node.tag]
291
164
  end
292
165
 
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
166
  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)
167
+ NativeProjection.attributes(node)
885
168
  end
886
169
  end
887
170
  # rubocop:enable Metrics/AbcSize, Metrics/ClassLength, Metrics/CyclomaticComplexity, Metrics/MethodLength, Metrics/ParameterLists, Metrics/PerceivedComplexity
@@ -0,0 +1,29 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Psych
4
+ module Merge
5
+ # Portable native Psych AST facts retained across workflow host boundaries.
6
+ module ProviderExtension
7
+ SCHEMA = 'structuredmerge.extension/ruby-psych/v1'
8
+ CAPABILITIES = %w[aliases anchors documents scalar-styles tags].freeze
9
+
10
+ module_function
11
+
12
+ def call(analysis: nil, tree: nil, **)
13
+ native_tree = analysis&.ast || tree&.inner_tree
14
+ raise ArgumentError, 'Psych provider extension requires an analysis or TreeHaver tree' unless native_tree
15
+
16
+ {
17
+ schema: SCHEMA,
18
+ namespace: 'ruby-psych',
19
+ capabilities: CAPABILITIES,
20
+ payload: {
21
+ ast: NativeProjection.attribute_tree(native_tree),
22
+ semantic: NativeProjection.semantic(native_tree),
23
+ native_tree_visibility: 'provider_internal'
24
+ }
25
+ }
26
+ end
27
+ end
28
+ end
29
+ end
@@ -0,0 +1,18 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'psych/merge'
4
+
5
+ module Psych
6
+ module Merge
7
+ module RSpec
8
+ # Native Psych AST attributes retained by the workflow provider.
9
+ module ProviderSnapshotExtension
10
+ module_function
11
+
12
+ def call(**arguments)
13
+ ProviderExtension.call(**arguments)
14
+ end
15
+ end
16
+ end
17
+ end
18
+ 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.7'
8
+ VERSION = '7.1.9'
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
@@ -104,12 +104,14 @@ module Psych
104
104
  autoload :FileAnalysis, 'psych/merge/file_analysis'
105
105
  autoload :MappingEntry, 'psych/merge/file_analysis'
106
106
  autoload :MergeResult, 'psych/merge/merge_result'
107
+ autoload :NativeProjection, 'psych/merge/native_projection'
107
108
  autoload :NodeTypeNormalizer, 'psych/merge/node_type_normalizer'
108
109
  autoload :NodeWrapper, 'psych/merge/node_wrapper'
109
110
  autoload :ConflictResolver, 'psych/merge/conflict_resolver'
110
111
  autoload :PartialTemplateMerger, 'psych/merge/partial_template_merger'
111
112
  autoload :SmartMerger, 'psych/merge/smart_merger'
112
113
  autoload :MappingMatchRefiner, 'psych/merge/mapping_match_refiner'
114
+ autoload :ProviderExtension, 'psych/merge/provider_extension'
113
115
 
114
116
  class << self
115
117
  def yaml_feature_profile
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.7
4
+ version: 7.1.9
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.7
46
+ version: 7.1.9
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.7
53
+ version: 7.1.9
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.7
60
+ version: 7.1.9
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.7
67
+ version: 7.1.9
68
68
  - !ruby/object:Gem::Dependency
69
69
  name: version_gem
70
70
  requirement: !ruby/object:Gem::Requirement
@@ -91,14 +91,14 @@ dependencies:
91
91
  requirements:
92
92
  - - '='
93
93
  - !ruby/object:Gem::Version
94
- version: 7.1.7
94
+ version: 7.1.9
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.7
101
+ version: 7.1.9
102
102
  - !ruby/object:Gem::Dependency
103
103
  name: kettle-dev
104
104
  requirement: !ruby/object:Gem::Requirement
@@ -108,7 +108,7 @@ dependencies:
108
108
  version: '3.0'
109
109
  - - ">="
110
110
  - !ruby/object:Gem::Version
111
- version: 3.0.17
111
+ version: 3.0.34
112
112
  type: :development
113
113
  prerelease: false
114
114
  version_requirements: !ruby/object:Gem::Requirement
@@ -118,7 +118,7 @@ dependencies:
118
118
  version: '3.0'
119
119
  - - ">="
120
120
  - !ruby/object:Gem::Version
121
- version: 3.0.17
121
+ version: 3.0.34
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.2
199
+ version: 3.2.3
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.2
209
+ version: 3.2.3
210
210
  - !ruby/object:Gem::Dependency
211
211
  name: kettle-test
212
212
  requirement: !ruby/object:Gem::Requirement
@@ -236,7 +236,7 @@ dependencies:
236
236
  version: '3.2'
237
237
  - - ">="
238
238
  - !ruby/object:Gem::Version
239
- version: 3.2.6
239
+ version: 3.2.7
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.6
249
+ version: 3.2.7
250
250
  - !ruby/object:Gem::Dependency
251
251
  name: ruby-progressbar
252
252
  requirement: !ruby/object:Gem::Requirement
@@ -321,10 +321,13 @@ files:
321
321
  - lib/psych/merge/freeze_node.rb
322
322
  - lib/psych/merge/mapping_match_refiner.rb
323
323
  - lib/psych/merge/merge_result.rb
324
+ - lib/psych/merge/native_projection.rb
324
325
  - lib/psych/merge/node_type_normalizer.rb
325
326
  - lib/psych/merge/node_wrapper.rb
326
327
  - lib/psych/merge/partial_template_merger.rb
327
328
  - lib/psych/merge/provider.rb
329
+ - lib/psych/merge/provider_extension.rb
330
+ - lib/psych/merge/rspec/provider_snapshot_extension.rb
328
331
  - lib/psych/merge/smart_merger.rb
329
332
  - lib/psych/merge/version.rb
330
333
  - sig/psych/merge.rbs
@@ -334,10 +337,10 @@ licenses:
334
337
  - PolyForm-Small-Business-1.0.0
335
338
  metadata:
336
339
  homepage_uri: https://structuredmerge.org
337
- source_code_uri: https://github.com/structuredmerge/structuredmerge-ruby/tree/v7.1.7
338
- changelog_uri: https://github.com/structuredmerge/structuredmerge-ruby/blob/v7.1.7/CHANGELOG.md
340
+ source_code_uri: https://github.com/structuredmerge/structuredmerge-ruby/tree/v7.1.9
341
+ changelog_uri: https://github.com/structuredmerge/structuredmerge-ruby/blob/v7.1.9/CHANGELOG.md
339
342
  bug_tracker_uri: https://github.com/structuredmerge/structuredmerge-ruby/issues
340
- documentation_uri: https://www.rubydoc.info/gems/psych-merge/7.1.7
343
+ documentation_uri: https://www.rubydoc.info/gems/psych-merge/7.1.9
341
344
  funding_uri: https://github.com/sponsors/pboling
342
345
  wiki_uri: https://github.com/structuredmerge/structuredmerge-ruby/wiki
343
346
  news_uri: https://www.railsbling.com/tags/psych-merge
metadata.gz.sig CHANGED
Binary file