yaml-merge 7.1.6 → 7.1.8
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 +4 -4
- checksums.yaml.gz.sig +0 -0
- data/README.md +4 -2
- data/lib/yaml/merge/provider.rb +168 -0
- data/lib/yaml/merge/smart_merger.rb +7 -3
- data/lib/yaml/merge/source_preserving_provider.rb +799 -0
- data/lib/yaml/merge/version.rb +1 -1
- data/lib/yaml/merge.rb +4 -0
- data/sig/yaml/merge.rbs +16 -0
- data.tar.gz.sig +0 -0
- metadata +16 -14
- metadata.gz.sig +0 -0
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: a4fc343ed5a3cdb282f7e215a62d0754a4b0e1908959582f09f871af039771f2
|
|
4
|
+
data.tar.gz: 39f6895dc4d2066f488437744156357ad40cdfb2334030bc37ed652e6c7a2b56
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 077125af08b9a5547bbd434776db9ca73f0d88fd706583bb1d296e213b77456ab1760abd0cca5f6b060dc87b735becc1963b49d28bcf6a4ae8de90d78a54d16c
|
|
7
|
+
data.tar.gz: 197d2a7cbc7d0dda453d1ab1676ca4e91013c6f74f8378e23113376d7c66a3269be3544373df6be5e62d88cbad8b152a94658a22838e8c4485ab6889ec39f316
|
checksums.yaml.gz.sig
CHANGED
|
Binary file
|
data/README.md
CHANGED
|
@@ -22,8 +22,9 @@ I've summarized my thoughts in [this blog post](https://dev.to/galtzo/hostile-ta
|
|
|
22
22
|
## 🌻 Synopsis <a href="https://discord.gg/3qme4XHNKN"><img alt="Galtzo FLOSS Logo by Aboling0, CC BY-SA 4.0" src="https://logos.galtzo.com/assets/images/galtzo-floss/avatar-128px.svg" width="8%" align="right"/></a> <a href="https://ruby-toolbox.com"><img alt="ruby-lang Logo, Yukihiro Matsumoto, Ruby Visual Identity Team, CC BY-SA 2.5" src="https://logos.galtzo.com/assets/images/ruby-lang/avatar-128px.svg" width="8%" align="right"/></a>
|
|
23
23
|
|
|
24
24
|
`Yaml::Merge` is the canonical YAML family package. It is the YAML substrate for
|
|
25
|
-
StructuredMerge Ruby: it registers the
|
|
26
|
-
|
|
25
|
+
StructuredMerge Ruby: it registers the TSLP YAML path and owns the shared
|
|
26
|
+
source-preserving provider behavior used by parser-specific implementations.
|
|
27
|
+
YAML parsing and
|
|
27
28
|
backend selection enter through `tree_haver`; source-preserving
|
|
28
29
|
partial document insertion, replacement, and removal should enter through
|
|
29
30
|
`ast-crispr`; merge orchestration should use `ast-merge`.
|
|
@@ -39,6 +40,7 @@ Hash/Array round-trips.
|
|
|
39
40
|
- Backend feature profiles for the shared language-pack parser.
|
|
40
41
|
- Path-based owner matching for mapping entries.
|
|
41
42
|
- Shared YAML-family merge semantics for provider gems.
|
|
43
|
+
- A TSLP workflow provider and a reusable provider engine for native parsers.
|
|
42
44
|
- Destination-wins array policy.
|
|
43
45
|
- Hash result API for fixture runners and higher-level tools.
|
|
44
46
|
|
|
@@ -0,0 +1,168 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Yaml
|
|
4
|
+
# YAML workflow provider integration.
|
|
5
|
+
module Merge
|
|
6
|
+
# TSLP projection for the shared YAML source-preserving provider.
|
|
7
|
+
# rubocop:disable Metrics/AbcSize, Metrics/ClassLength, Metrics/CyclomaticComplexity, Metrics/MethodLength, Metrics/ParameterLists, Metrics/PerceivedComplexity -- tree-sitter YAML validation and projection remain one adapter boundary
|
|
8
|
+
class Provider < SourcePreservingProvider
|
|
9
|
+
PROVIDER_ID = 'ruby.yaml'
|
|
10
|
+
BACKEND = BACKEND_REFERENCE
|
|
11
|
+
PACKAGE_NAME = Merge::PACKAGE_NAME
|
|
12
|
+
PACKAGE_VERSION = Version::VERSION
|
|
13
|
+
ROLE = :workflow
|
|
14
|
+
|
|
15
|
+
private
|
|
16
|
+
|
|
17
|
+
def analyze_role(role, source)
|
|
18
|
+
analysis = TreeHaver.with_backend(backend_id) { FileAnalysis.new(source) }
|
|
19
|
+
return invalid_analysis(role, source, analysis) unless analysis.valid?
|
|
20
|
+
|
|
21
|
+
documents = analysis.documents
|
|
22
|
+
issues = []
|
|
23
|
+
issues << issue(:multiple_documents, 'YAML input must contain exactly one document') unless documents.one?
|
|
24
|
+
document = documents.first
|
|
25
|
+
root = document&.body_node
|
|
26
|
+
issues << issue(:non_mapping_root, 'YAML document root must be a mapping') unless root&.mapping?
|
|
27
|
+
issues.concat(node_issues(root)) if root
|
|
28
|
+
|
|
29
|
+
parsed = Yaml::Merge.parse_yaml(source, 'yaml', backend: backend_id)
|
|
30
|
+
unless parsed[:ok]
|
|
31
|
+
diagnostic = parsed.fetch(:diagnostics).first || {}
|
|
32
|
+
return invalid_document(role, source, diagnostic[:category] || :parse_error, diagnostic[:message])
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
entries = root&.mapping? ? mapping_entries(analysis, root, parsed.dig(:analysis, :document), source, role) : []
|
|
36
|
+
duplicate = entries.group_by(&:key).find { |_key, matches| matches.length > 1 }
|
|
37
|
+
issues << issue(:duplicate_key, "Duplicate YAML key #{duplicate.first}", key: duplicate.first) if duplicate
|
|
38
|
+
|
|
39
|
+
Document.new(
|
|
40
|
+
role: role,
|
|
41
|
+
source: source,
|
|
42
|
+
analysis: analysis,
|
|
43
|
+
entries: entries.freeze,
|
|
44
|
+
by_key: entries.to_h { |entry| [entry.key, entry] }.freeze,
|
|
45
|
+
issues: issues.uniq.freeze,
|
|
46
|
+
document_attributes: document ? node_attributes(document) : {}
|
|
47
|
+
)
|
|
48
|
+
rescue TreeHaver::Error => e
|
|
49
|
+
invalid_document(role, source, :parse_error, e.message)
|
|
50
|
+
rescue StandardError => e
|
|
51
|
+
invalid_document(role, source, :structural_error, e.message)
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
def invalid_analysis(role, source, analysis)
|
|
55
|
+
message = analysis.errors.map(&:to_s).join('; ')
|
|
56
|
+
invalid_document(role, source, :parse_error, message.empty? ? 'YAML parse failed' : message)
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
def node_issues(root)
|
|
60
|
+
issues = []
|
|
61
|
+
walk_nodes(root.node) do |node|
|
|
62
|
+
type = node.type.to_s
|
|
63
|
+
issues << issue(:alias, 'YAML aliases require an exact one-sided merge') if type == 'alias'
|
|
64
|
+
issues << issue(:anchor, 'YAML anchors require an exact one-sided merge') if type == 'anchor'
|
|
65
|
+
issues << issue(:directive, 'YAML directives require an exact one-sided merge') if type.include?('directive')
|
|
66
|
+
if %w[flow_mapping flow_sequence].include?(type)
|
|
67
|
+
issues << issue(:flow_collection, 'Flow-style collections require an exact one-sided merge')
|
|
68
|
+
end
|
|
69
|
+
end
|
|
70
|
+
issues.uniq.freeze
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
def walk_nodes(node, &block)
|
|
74
|
+
yield node
|
|
75
|
+
node.children.each { |child| walk_nodes(child, &block) }
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
def mapping_entries(analysis, root, semantic_document, source, role)
|
|
79
|
+
root.mapping_pairs.filter_map.with_index do |pair, index|
|
|
80
|
+
key = pair.key_name
|
|
81
|
+
next invalid_top_level_entry(pair, source, role, index) unless key
|
|
82
|
+
|
|
83
|
+
build_entry(analysis, pair, key, semantic_document[key], source, role, index)
|
|
84
|
+
end
|
|
85
|
+
end
|
|
86
|
+
|
|
87
|
+
def invalid_top_level_entry(pair, source, role, index)
|
|
88
|
+
Entry.new(
|
|
89
|
+
key: "<complex-key-#{index}>",
|
|
90
|
+
start_line: pair.start_line,
|
|
91
|
+
end_line: pair.end_line,
|
|
92
|
+
fragment: source_lines(source, pair.start_line, pair.end_line),
|
|
93
|
+
semantic: nil,
|
|
94
|
+
attributes: node_attributes(pair.value_node),
|
|
95
|
+
role: role,
|
|
96
|
+
ownership_provable: false
|
|
97
|
+
)
|
|
98
|
+
end
|
|
99
|
+
|
|
100
|
+
def build_entry(analysis, pair, key, semantic, source, role, index)
|
|
101
|
+
key_line = pair.start_line
|
|
102
|
+
value_end = pair.end_line
|
|
103
|
+
leading_start = owned_leading_start(analysis, pair, index, key_line)
|
|
104
|
+
Entry.new(
|
|
105
|
+
key: key,
|
|
106
|
+
start_line: leading_start,
|
|
107
|
+
end_line: value_end,
|
|
108
|
+
fragment: source_lines(source, leading_start, value_end),
|
|
109
|
+
semantic: semantic,
|
|
110
|
+
attributes: node_attributes(pair.value_node),
|
|
111
|
+
role: role,
|
|
112
|
+
ownership_provable: ownership_provable?(analysis, pair, index, key_line, leading_start)
|
|
113
|
+
)
|
|
114
|
+
end
|
|
115
|
+
|
|
116
|
+
def owned_leading_start(analysis, pair, index, key_line)
|
|
117
|
+
return key_line if index.zero?
|
|
118
|
+
|
|
119
|
+
region = analysis.comment_attachment_for(pair)&.leading_region
|
|
120
|
+
return key_line unless region && region.end_line == key_line - 1
|
|
121
|
+
return key_line if region.metadata[:floating]
|
|
122
|
+
|
|
123
|
+
region.start_line
|
|
124
|
+
end
|
|
125
|
+
|
|
126
|
+
def ownership_provable?(analysis, pair, index, key_line, leading_start)
|
|
127
|
+
return true if leading_start == key_line
|
|
128
|
+
return false if index.zero?
|
|
129
|
+
|
|
130
|
+
region = analysis.comment_attachment_for(pair)&.leading_region
|
|
131
|
+
region && !region.metadata[:floating] && region.start_line == leading_start
|
|
132
|
+
end
|
|
133
|
+
|
|
134
|
+
def node_attributes(node)
|
|
135
|
+
return {} unless node
|
|
136
|
+
|
|
137
|
+
{
|
|
138
|
+
type: node.type.to_s,
|
|
139
|
+
canonical_type: node.type.to_s,
|
|
140
|
+
signature: public_value(node.signature),
|
|
141
|
+
children: node.semantic_children.map do |child|
|
|
142
|
+
node_attributes(NodeWrapper.new(child, lines: node.lines, source: node.source))
|
|
143
|
+
end
|
|
144
|
+
}
|
|
145
|
+
end
|
|
146
|
+
|
|
147
|
+
def public_value(value)
|
|
148
|
+
case value
|
|
149
|
+
when Array then value.map { |item| public_value(item) }
|
|
150
|
+
when Hash then value.to_h { |key, item| [key.to_s, public_value(item)] }
|
|
151
|
+
when String, Integer, Float, TrueClass, FalseClass, NilClass then value
|
|
152
|
+
else value.to_s
|
|
153
|
+
end
|
|
154
|
+
end
|
|
155
|
+
end
|
|
156
|
+
# rubocop:enable Metrics/AbcSize, Metrics/ClassLength, Metrics/CyclomaticComplexity, Metrics/MethodLength, Metrics/ParameterLists, Metrics/PerceivedComplexity
|
|
157
|
+
|
|
158
|
+
class << self
|
|
159
|
+
def merge_provider
|
|
160
|
+
@merge_provider ||= Provider.new
|
|
161
|
+
end
|
|
162
|
+
|
|
163
|
+
def register_provider!(replace: false)
|
|
164
|
+
Ast::Merge.register_provider(merge_provider, replace: replace)
|
|
165
|
+
end
|
|
166
|
+
end
|
|
167
|
+
end
|
|
168
|
+
end
|
|
@@ -125,7 +125,7 @@ module Yaml
|
|
|
125
125
|
|
|
126
126
|
def merge_matched_nodes(template_node, dest_node, template_analysis, dest_analysis, template_owners: nil,
|
|
127
127
|
dest_owners: nil, template_boundary_line: nil, dest_boundary_line: nil)
|
|
128
|
-
if template_node
|
|
128
|
+
if node_kind?(template_node, :document?) && node_kind?(dest_node, :document?)
|
|
129
129
|
dest_header_end = emit_document_header(dest_node, dest_analysis)
|
|
130
130
|
template_header_end = document_header_end_line(template_node)
|
|
131
131
|
merge_node_lists(
|
|
@@ -136,12 +136,12 @@ module Yaml
|
|
|
136
136
|
template_boundary_line: template_header_end,
|
|
137
137
|
dest_boundary_line: dest_header_end
|
|
138
138
|
)
|
|
139
|
-
elsif template_node
|
|
139
|
+
elsif node_kind?(template_node, :mapping_pair?) && node_kind?(dest_node, :mapping_pair?)
|
|
140
140
|
merge_mapping_pair(template_node, dest_node, template_analysis, dest_analysis, template_owners: template_owners,
|
|
141
141
|
dest_owners: dest_owners,
|
|
142
142
|
template_boundary_line: template_boundary_line,
|
|
143
143
|
dest_boundary_line: dest_boundary_line)
|
|
144
|
-
elsif template_node
|
|
144
|
+
elsif node_kind?(template_node, :mapping?) && node_kind?(dest_node, :mapping?)
|
|
145
145
|
merge_node_lists(
|
|
146
146
|
template_node.mergeable_children,
|
|
147
147
|
dest_node.mergeable_children,
|
|
@@ -167,6 +167,10 @@ module Yaml
|
|
|
167
167
|
end
|
|
168
168
|
end
|
|
169
169
|
|
|
170
|
+
def node_kind?(node, predicate)
|
|
171
|
+
node.respond_to?(predicate) && node.public_send(predicate)
|
|
172
|
+
end
|
|
173
|
+
|
|
170
174
|
def merge_mapping_pair(template_node, dest_node, template_analysis, dest_analysis, template_owners: nil,
|
|
171
175
|
dest_owners: nil, template_boundary_line: nil, dest_boundary_line: nil)
|
|
172
176
|
template_value = template_node.value_node&.unwrap_value_node
|
|
@@ -0,0 +1,799 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'digest'
|
|
4
|
+
|
|
5
|
+
module Yaml
|
|
6
|
+
# Parser-neutral 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 SourcePreservingProvider
|
|
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 = self.class::PROVIDER_ID
|
|
36
|
+
def family = 'yaml'
|
|
37
|
+
def backend_reference = self.class::BACKEND
|
|
38
|
+
def backend_id = backend_reference.id.to_sym
|
|
39
|
+
def package_name = self.class::PACKAGE_NAME
|
|
40
|
+
def package_version = self.class::PACKAGE_VERSION
|
|
41
|
+
def role = self.class.const_defined?(:ROLE, false) ? self.class::ROLE : :backend
|
|
42
|
+
|
|
43
|
+
def capabilities
|
|
44
|
+
{
|
|
45
|
+
operations: Ast::Merge::ProviderContract::OPERATIONS,
|
|
46
|
+
dialects: DIALECTS,
|
|
47
|
+
backends: [backend_id],
|
|
48
|
+
profiles: [DEFAULT_PROFILE],
|
|
49
|
+
role: role,
|
|
50
|
+
source_preservation: %i[
|
|
51
|
+
exact_source
|
|
52
|
+
mapping_entry_fragments
|
|
53
|
+
comment_ownership
|
|
54
|
+
line_provenance
|
|
55
|
+
reparse
|
|
56
|
+
semantic_verification
|
|
57
|
+
ast_attribute_verification
|
|
58
|
+
]
|
|
59
|
+
}.freeze
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
def analyze(request)
|
|
63
|
+
document = analyze_role(:source, request.fetch(:source))
|
|
64
|
+
return invalid_document_failure(:analyze, request, [document]) unless document.issues.empty?
|
|
65
|
+
|
|
66
|
+
result(
|
|
67
|
+
:analyze,
|
|
68
|
+
request,
|
|
69
|
+
analysis: analysis_payload(document),
|
|
70
|
+
extensions: analysis_extensions(document),
|
|
71
|
+
verification: { source_parsed: true, structurally_unambiguous: true }
|
|
72
|
+
)
|
|
73
|
+
end
|
|
74
|
+
|
|
75
|
+
def diff2(request)
|
|
76
|
+
before = analyze_role(:before, request.fetch(:before_source))
|
|
77
|
+
after = analyze_role(:after, request.fetch(:after_source))
|
|
78
|
+
unsafe = [before, after].reject { |document| document.issues.empty? }
|
|
79
|
+
return invalid_document_failure(:diff2, request, unsafe) unless unsafe.empty?
|
|
80
|
+
|
|
81
|
+
changes = mapping_changes(before, after)
|
|
82
|
+
result(
|
|
83
|
+
:diff2,
|
|
84
|
+
request,
|
|
85
|
+
diff: { before: analysis_payload(before), after: analysis_payload(after), changes: changes },
|
|
86
|
+
changes: changes,
|
|
87
|
+
verification: { before_parsed: true, after_parsed: true }
|
|
88
|
+
)
|
|
89
|
+
end
|
|
90
|
+
|
|
91
|
+
def merge2(request)
|
|
92
|
+
documents = {
|
|
93
|
+
incoming: analyze_role(:incoming, request.fetch(:incoming_source)),
|
|
94
|
+
current: analyze_role(:current, request.fetch(:current_source))
|
|
95
|
+
}
|
|
96
|
+
unsafe = documents.values.reject { |document| document.issues.empty? }
|
|
97
|
+
return invalid_document_failure(:merge2, request, unsafe) unless unsafe.empty?
|
|
98
|
+
return render_exact_merge2(request, documents) if documents[:incoming].source == documents[:current].source
|
|
99
|
+
|
|
100
|
+
merged = recursive_merge2(documents)
|
|
101
|
+
rendered = render_recursive_merge2(request, documents, merged)
|
|
102
|
+
verification = verify_recursive_merge2(rendered, documents)
|
|
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, :recursive_mapping_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
|
+
raise NotImplementedError, "#{self.class} must implement #analyze_role"
|
|
137
|
+
end
|
|
138
|
+
|
|
139
|
+
def invalid_document(role, source, category, message)
|
|
140
|
+
Document.new(
|
|
141
|
+
role: role,
|
|
142
|
+
source: source,
|
|
143
|
+
analysis: nil,
|
|
144
|
+
entries: [].freeze,
|
|
145
|
+
by_key: {}.freeze,
|
|
146
|
+
issues: [issue(category, message)].freeze,
|
|
147
|
+
document_attributes: {}
|
|
148
|
+
)
|
|
149
|
+
end
|
|
150
|
+
|
|
151
|
+
def analysis_payload(document)
|
|
152
|
+
{
|
|
153
|
+
dialect: 'yaml',
|
|
154
|
+
backend: backend_id.to_s,
|
|
155
|
+
document_attributes: document.document_attributes,
|
|
156
|
+
entries: document.entries.map do |entry|
|
|
157
|
+
{
|
|
158
|
+
key: entry.key,
|
|
159
|
+
value: entry.semantic,
|
|
160
|
+
attributes: entry.attributes,
|
|
161
|
+
source_lines: [entry.start_line, entry.end_line]
|
|
162
|
+
}
|
|
163
|
+
end,
|
|
164
|
+
structurally_unambiguous: document.issues.empty?
|
|
165
|
+
}
|
|
166
|
+
end
|
|
167
|
+
|
|
168
|
+
def analysis_extensions(_document)
|
|
169
|
+
[]
|
|
170
|
+
end
|
|
171
|
+
|
|
172
|
+
def render_exact_merge2(request, documents)
|
|
173
|
+
current = documents.fetch(:current)
|
|
174
|
+
rendered = render_plan(request, [whole_source_fragment(:ours, current.source)])
|
|
175
|
+
verification = verify_exact(rendered.content, current, :current)
|
|
176
|
+
result(
|
|
177
|
+
:merge2,
|
|
178
|
+
request,
|
|
179
|
+
output: rendered.content,
|
|
180
|
+
changes: [],
|
|
181
|
+
render_report: render_report(rendered, :exact_revision),
|
|
182
|
+
verification: verification
|
|
183
|
+
)
|
|
184
|
+
end
|
|
185
|
+
|
|
186
|
+
def recursive_merge2(documents)
|
|
187
|
+
result = MergeResult.new(
|
|
188
|
+
template_analysis: documents[:incoming].analysis,
|
|
189
|
+
dest_analysis: documents[:current].analysis
|
|
190
|
+
)
|
|
191
|
+
SmartMerger::Resolver.new(
|
|
192
|
+
documents[:incoming].analysis,
|
|
193
|
+
documents[:current].analysis,
|
|
194
|
+
preference: :destination,
|
|
195
|
+
add_template_only_nodes: true,
|
|
196
|
+
merge_sequences: false
|
|
197
|
+
).resolve(result)
|
|
198
|
+
result
|
|
199
|
+
end
|
|
200
|
+
|
|
201
|
+
def render_recursive_merge2(request, documents, merged)
|
|
202
|
+
fragments = merged.lines.map do |line|
|
|
203
|
+
recursive_merge2_line_fragment(line, documents)
|
|
204
|
+
end
|
|
205
|
+
render_plan(request, fragments)
|
|
206
|
+
end
|
|
207
|
+
|
|
208
|
+
def recursive_merge2_line_fragment(line, documents)
|
|
209
|
+
role = line.fetch(:source) == :template ? :theirs : :ours
|
|
210
|
+
original_line = line[:original_line]
|
|
211
|
+
content = "#{line.fetch(:content)}\n"
|
|
212
|
+
source = documents.fetch(role == :theirs ? :incoming : :current).source
|
|
213
|
+
if original_line && source.lines[original_line - 1] == content
|
|
214
|
+
return range_fragment(role, original_line, original_line)
|
|
215
|
+
end
|
|
216
|
+
|
|
217
|
+
Ast::Merge::SourceRender::SynthesizedFragment.new(
|
|
218
|
+
content: content,
|
|
219
|
+
reason: :structured_emitter_line,
|
|
220
|
+
producer: provider_id,
|
|
221
|
+
metadata: { source_role: role, original_line: original_line }.compact
|
|
222
|
+
)
|
|
223
|
+
end
|
|
224
|
+
|
|
225
|
+
def verify_recursive_merge2(rendered, documents)
|
|
226
|
+
parsed = analyze_role(:output, rendered.content)
|
|
227
|
+
expected = Yaml::Merge.send(
|
|
228
|
+
:merge_yaml_mappings,
|
|
229
|
+
document_semantics(documents[:incoming]),
|
|
230
|
+
document_semantics(documents[:current])
|
|
231
|
+
)
|
|
232
|
+
actual = document_semantics(parsed)
|
|
233
|
+
{
|
|
234
|
+
output_reparsed: true,
|
|
235
|
+
semantic_match: parsed.issues.empty? && actual == expected,
|
|
236
|
+
ast_attributes_match: parsed.issues.empty?,
|
|
237
|
+
source_match: rendered.synthesized_fragments.empty?,
|
|
238
|
+
ordered_entries: actual.to_a,
|
|
239
|
+
line_sources: rendered.line_records.map { |record| record.slice(:source_role, :original_line) }
|
|
240
|
+
}
|
|
241
|
+
end
|
|
242
|
+
|
|
243
|
+
def document_semantics(document)
|
|
244
|
+
document.entries.to_h { |entry| [entry.key, entry.semantic] }
|
|
245
|
+
end
|
|
246
|
+
|
|
247
|
+
def entry_state(entry)
|
|
248
|
+
entry && [entry.semantic, entry.attributes, entry.fragment]
|
|
249
|
+
end
|
|
250
|
+
|
|
251
|
+
def exact_revision_role(documents)
|
|
252
|
+
return :ours if documents[:ours].source == documents[:theirs].source
|
|
253
|
+
return :ours if documents[:base].source == documents[:theirs].source
|
|
254
|
+
|
|
255
|
+
:theirs if documents[:base].source == documents[:ours].source
|
|
256
|
+
end
|
|
257
|
+
|
|
258
|
+
def render_exact(request, documents, role)
|
|
259
|
+
winner = documents.fetch(role)
|
|
260
|
+
return structural_conflict(request, documents, [winner]) unless fatal_issues(winner).empty?
|
|
261
|
+
|
|
262
|
+
rendered = render_plan(request, [whole_source_fragment(role, winner.source)])
|
|
263
|
+
verification = verify_exact(rendered.content, winner, role)
|
|
264
|
+
return verification_failure(:merge3, request, rendered, [], verification) unless verification[:semantic_match]
|
|
265
|
+
|
|
266
|
+
result(
|
|
267
|
+
:merge3,
|
|
268
|
+
request,
|
|
269
|
+
output: rendered.content,
|
|
270
|
+
changes: mapping_changes(documents[:base], winner),
|
|
271
|
+
diagnostics: diagnostics_for(*documents.values),
|
|
272
|
+
render_report: render_report(rendered, :exact_revision),
|
|
273
|
+
verification: verification.merge(base_participated: true)
|
|
274
|
+
)
|
|
275
|
+
end
|
|
276
|
+
|
|
277
|
+
def verify_exact(output, expected, role)
|
|
278
|
+
parsed = analyze_role(:output, output)
|
|
279
|
+
{
|
|
280
|
+
output_reparsed: true,
|
|
281
|
+
byte_exact: output == expected.source,
|
|
282
|
+
source_match: output == expected.source,
|
|
283
|
+
semantic_match: fatal_issues(parsed).empty? && document_signature(parsed) == document_signature(expected),
|
|
284
|
+
ast_attributes_match: parsed.document_attributes == expected.document_attributes &&
|
|
285
|
+
parsed.entries.map(&:attributes) == expected.entries.map(&:attributes),
|
|
286
|
+
ordered_entries: ordered_semantics(parsed),
|
|
287
|
+
source_role: role
|
|
288
|
+
}
|
|
289
|
+
end
|
|
290
|
+
|
|
291
|
+
def mapping_changes(before, after)
|
|
292
|
+
ordered_keys(before, after).filter_map do |key|
|
|
293
|
+
left = before.by_key[key]
|
|
294
|
+
right = after.by_key[key]
|
|
295
|
+
next if semantic_entry_state(left) == semantic_entry_state(right)
|
|
296
|
+
|
|
297
|
+
{ path: "/#{key}", ours: :unchanged, theirs: change_kind(left, right) }.freeze
|
|
298
|
+
end.freeze
|
|
299
|
+
end
|
|
300
|
+
|
|
301
|
+
def semantic_entry_state(entry)
|
|
302
|
+
entry && [entry.semantic, entry.attributes]
|
|
303
|
+
end
|
|
304
|
+
|
|
305
|
+
def ordered_keys(*documents)
|
|
306
|
+
documents.flat_map { |document| document.entries.map(&:key) }.uniq
|
|
307
|
+
end
|
|
308
|
+
|
|
309
|
+
def change_kind(before, after)
|
|
310
|
+
return :added unless before
|
|
311
|
+
return :deleted unless after
|
|
312
|
+
|
|
313
|
+
:edited
|
|
314
|
+
end
|
|
315
|
+
|
|
316
|
+
def three_way_decisions(documents)
|
|
317
|
+
ordered_keys(documents[:ours], documents[:theirs], documents[:base]).map do |key|
|
|
318
|
+
base = documents[:base].by_key[key]
|
|
319
|
+
ours = documents[:ours].by_key[key]
|
|
320
|
+
theirs = documents[:theirs].by_key[key]
|
|
321
|
+
selected_role =
|
|
322
|
+
if entry_state(ours) == entry_state(theirs)
|
|
323
|
+
:ours
|
|
324
|
+
elsif entry_state(ours) == entry_state(base)
|
|
325
|
+
:theirs
|
|
326
|
+
elsif entry_state(theirs) == entry_state(base)
|
|
327
|
+
:ours
|
|
328
|
+
end
|
|
329
|
+
classification = {
|
|
330
|
+
path: "/#{key}",
|
|
331
|
+
ours: side_change(base, ours),
|
|
332
|
+
theirs: side_change(base, theirs)
|
|
333
|
+
}.freeze
|
|
334
|
+
Decision.new(
|
|
335
|
+
key: key,
|
|
336
|
+
base: base,
|
|
337
|
+
ours: ours,
|
|
338
|
+
theirs: theirs,
|
|
339
|
+
selected_role: selected_role,
|
|
340
|
+
classification: classification,
|
|
341
|
+
conflict: selected_role.nil?
|
|
342
|
+
)
|
|
343
|
+
end.freeze
|
|
344
|
+
end
|
|
345
|
+
|
|
346
|
+
def side_change(base, side)
|
|
347
|
+
entry_state(base) == entry_state(side) ? :unchanged : change_kind(base, side)
|
|
348
|
+
end
|
|
349
|
+
|
|
350
|
+
def unmanaged_unchanged?(documents)
|
|
351
|
+
anchor_keys = documents.values.map { |document| document.by_key.keys }.reduce(&:intersection)
|
|
352
|
+
signatures = documents.transform_values { |document| unmanaged_signature(document, anchor_keys) }
|
|
353
|
+
signatures[:base] == signatures[:ours] && signatures[:base] == signatures[:theirs]
|
|
354
|
+
end
|
|
355
|
+
|
|
356
|
+
def unmanaged_signature(document, anchor_keys)
|
|
357
|
+
entries = document.entries.sort_by(&:start_line)
|
|
358
|
+
signature = []
|
|
359
|
+
cursor = 1
|
|
360
|
+
entries.each do |entry|
|
|
361
|
+
if cursor < entry.start_line
|
|
362
|
+
signature << [:source, source_lines(document.source, cursor, entry.start_line - 1)]
|
|
363
|
+
end
|
|
364
|
+
signature << [:entry, entry.key] if anchor_keys.include?(entry.key)
|
|
365
|
+
cursor = entry.end_line + 1
|
|
366
|
+
end
|
|
367
|
+
signature << [:source, source_lines(document.source, cursor, document.source.lines.length)]
|
|
368
|
+
signature
|
|
369
|
+
end
|
|
370
|
+
|
|
371
|
+
def render_composite(request, documents, decisions)
|
|
372
|
+
by_key = decisions.to_h { |decision| [decision.key, decision] }
|
|
373
|
+
fragments = []
|
|
374
|
+
selected = []
|
|
375
|
+
cursor = 1
|
|
376
|
+
documents[:ours].entries.sort_by(&:start_line).each do |ours_entry|
|
|
377
|
+
fragments << range_fragment(:ours, cursor, ours_entry.start_line - 1) if cursor < ours_entry.start_line
|
|
378
|
+
decision = by_key.fetch(ours_entry.key)
|
|
379
|
+
selected_entry_value = decision.selected_role && decision.public_send(decision.selected_role)
|
|
380
|
+
if selected_entry_value
|
|
381
|
+
fragments << entry_fragment(decision.selected_role, selected_entry_value)
|
|
382
|
+
selected << selected_entry(selected_entry_value, decision.selected_role)
|
|
383
|
+
end
|
|
384
|
+
cursor = ours_entry.end_line + 1
|
|
385
|
+
end
|
|
386
|
+
ours_line_count = documents[:ours].source.lines.length
|
|
387
|
+
fragments << range_fragment(:ours, cursor, ours_line_count) if cursor <= ours_line_count
|
|
388
|
+
decisions.each do |decision|
|
|
389
|
+
next if decision.ours || decision.selected_role != :theirs || !decision.theirs
|
|
390
|
+
|
|
391
|
+
fragments << entry_fragment(:theirs, decision.theirs)
|
|
392
|
+
selected << selected_entry(decision.theirs, :theirs)
|
|
393
|
+
end
|
|
394
|
+
|
|
395
|
+
rendered = render_plan(request, separated_fragments(fragments.compact, documents))
|
|
396
|
+
verification = verify_composite(rendered.content, selected)
|
|
397
|
+
unless verification[:semantic_match]
|
|
398
|
+
return verification_failure(:merge3, request, rendered, decisions, verification)
|
|
399
|
+
end
|
|
400
|
+
|
|
401
|
+
result(
|
|
402
|
+
:merge3,
|
|
403
|
+
request,
|
|
404
|
+
output: rendered.content,
|
|
405
|
+
changes: decisions.map(&:classification),
|
|
406
|
+
render_report: render_report(rendered, :exact_mapping_entry_composite),
|
|
407
|
+
verification: verification.merge(base_participated: true)
|
|
408
|
+
)
|
|
409
|
+
end
|
|
410
|
+
|
|
411
|
+
def selected_entry(entry, role)
|
|
412
|
+
{
|
|
413
|
+
key: entry.key,
|
|
414
|
+
semantic: entry.semantic,
|
|
415
|
+
attributes: entry.attributes,
|
|
416
|
+
source_role: role,
|
|
417
|
+
source_lines: [entry.start_line, entry.end_line],
|
|
418
|
+
fragment: entry.fragment
|
|
419
|
+
}.freeze
|
|
420
|
+
end
|
|
421
|
+
|
|
422
|
+
def verify_composite(output, selected)
|
|
423
|
+
parsed = analyze_role(:output, output)
|
|
424
|
+
expected_semantics = selected.map { |entry| [entry[:key], entry[:semantic]] }
|
|
425
|
+
expected_attributes = selected.map { |entry| [entry[:key], entry[:attributes]] }
|
|
426
|
+
actual_semantics = parsed.entries.map { |entry| [entry.key, entry.semantic] }
|
|
427
|
+
actual_attributes = parsed.entries.map { |entry| [entry.key, entry.attributes] }
|
|
428
|
+
source_match = parsed.entries.zip(selected).all? do |actual, expected|
|
|
429
|
+
actual.fragment == expected[:fragment] ||
|
|
430
|
+
(!expected[:fragment].end_with?("\n") && actual.fragment == "#{expected[:fragment]}\n")
|
|
431
|
+
end
|
|
432
|
+
{
|
|
433
|
+
output_reparsed: true,
|
|
434
|
+
semantic_match: parsed.issues.empty? && actual_semantics == expected_semantics &&
|
|
435
|
+
actual_attributes == expected_attributes && source_match,
|
|
436
|
+
ast_attributes_match: actual_attributes == expected_attributes,
|
|
437
|
+
source_match: source_match,
|
|
438
|
+
ordered_entries: actual_semantics,
|
|
439
|
+
entry_sources: selected.map { |entry| entry.slice(:key, :source_role, :source_lines) }
|
|
440
|
+
}
|
|
441
|
+
end
|
|
442
|
+
|
|
443
|
+
def ordered_semantics(document)
|
|
444
|
+
document.entries.map { |entry| [entry.key, entry.semantic] }
|
|
445
|
+
end
|
|
446
|
+
|
|
447
|
+
def document_signature(document)
|
|
448
|
+
[ordered_semantics(document), document.entries.map(&:attributes)]
|
|
449
|
+
end
|
|
450
|
+
|
|
451
|
+
def fatal_issues(document)
|
|
452
|
+
document.issues.select { |item| %i[parse_error structural_error].include?(item[:category]) }
|
|
453
|
+
end
|
|
454
|
+
|
|
455
|
+
def render_conflicts(request, documents, decisions, conflicts)
|
|
456
|
+
unless conflicts.all? { |conflict| conflict.ours&.ownership_provable }
|
|
457
|
+
return full_file_conflict(request, documents, decisions, conflicts, :ownership_unproven)
|
|
458
|
+
end
|
|
459
|
+
unless unmanaged_unchanged?(documents)
|
|
460
|
+
return full_file_conflict(request, documents, decisions, conflicts, :unmanaged_source_change)
|
|
461
|
+
end
|
|
462
|
+
|
|
463
|
+
by_key = conflicts.to_h { |conflict| [conflict.key, conflict] }
|
|
464
|
+
fragments = []
|
|
465
|
+
cursor = 1
|
|
466
|
+
documents[:ours].entries.sort_by(&:start_line).each do |entry|
|
|
467
|
+
fragments << range_fragment(:ours, cursor, entry.start_line - 1) if cursor < entry.start_line
|
|
468
|
+
conflict = by_key[entry.key]
|
|
469
|
+
fragments << (conflict ? conflict_fragment(request, conflict) : entry_fragment(:ours, entry))
|
|
470
|
+
cursor = entry.end_line + 1
|
|
471
|
+
end
|
|
472
|
+
ours_line_count = documents[:ours].source.lines.length
|
|
473
|
+
fragments << range_fragment(:ours, cursor, ours_line_count) if cursor <= ours_line_count
|
|
474
|
+
rendered = render_plan(request, fragments.compact)
|
|
475
|
+
conflict_failure(request, decisions, conflicts, rendered, :mapping_entry_localized_conflict)
|
|
476
|
+
end
|
|
477
|
+
|
|
478
|
+
def conflict_fragment(request, decision)
|
|
479
|
+
Ast::Merge::SourceRender::ConflictFragment.new(
|
|
480
|
+
conflict_id: conflict_id(decision.key),
|
|
481
|
+
base: conflict_side(:base, decision.base),
|
|
482
|
+
ours: conflict_side(:ours, decision.ours),
|
|
483
|
+
theirs: conflict_side(:theirs, decision.theirs),
|
|
484
|
+
labels: request.fetch(:labels, {}),
|
|
485
|
+
marker_size: request.fetch(:conflict_marker_size, 7),
|
|
486
|
+
metadata: { path: decision.classification[:path], category: conflict_category(decision) }
|
|
487
|
+
)
|
|
488
|
+
end
|
|
489
|
+
|
|
490
|
+
def conflict_side(role, entry)
|
|
491
|
+
return [] unless entry
|
|
492
|
+
|
|
493
|
+
[entry_fragment(role, entry, line_bounded: true)]
|
|
494
|
+
end
|
|
495
|
+
|
|
496
|
+
def conflict_category(decision)
|
|
497
|
+
decision.ours.nil? || decision.theirs.nil? ? :delete_edit : :edit_edit
|
|
498
|
+
end
|
|
499
|
+
|
|
500
|
+
def conflict_id(key)
|
|
501
|
+
"yaml-mapping-#{Digest::SHA256.hexdigest(key)[0, 16]}"
|
|
502
|
+
end
|
|
503
|
+
|
|
504
|
+
def conflict_failure(request, decisions, conflicts, rendered, strategy)
|
|
505
|
+
failure(
|
|
506
|
+
:merge3,
|
|
507
|
+
request,
|
|
508
|
+
category: :merge_conflict,
|
|
509
|
+
message: 'YAML mapping entries changed incompatibly.',
|
|
510
|
+
changes: decisions.map(&:classification),
|
|
511
|
+
conflicts: conflicts.map { |decision| conflict_payload(decision) },
|
|
512
|
+
conflicted_output: rendered.content,
|
|
513
|
+
conflicted_source: rendered.content,
|
|
514
|
+
render_report: render_report(rendered, strategy),
|
|
515
|
+
verification: { base_participated: true }
|
|
516
|
+
)
|
|
517
|
+
end
|
|
518
|
+
|
|
519
|
+
def structural_conflict(request, documents, unsafe)
|
|
520
|
+
source_role = unsafe.first.role
|
|
521
|
+
full_file_conflict(
|
|
522
|
+
request,
|
|
523
|
+
documents,
|
|
524
|
+
safe_decisions(documents),
|
|
525
|
+
[],
|
|
526
|
+
:structural_ambiguity,
|
|
527
|
+
diagnostics: [
|
|
528
|
+
{
|
|
529
|
+
category: unsafe.first.issues.first[:category],
|
|
530
|
+
severity: :error,
|
|
531
|
+
message: "#{source_role} YAML input is unsafe for structural merge.",
|
|
532
|
+
blocking: true,
|
|
533
|
+
source_role: source_role
|
|
534
|
+
},
|
|
535
|
+
*diagnostics_for(*unsafe)
|
|
536
|
+
]
|
|
537
|
+
)
|
|
538
|
+
end
|
|
539
|
+
|
|
540
|
+
def safe_decisions(documents)
|
|
541
|
+
return [] unless documents.values.all? { |document| document.issues.empty? }
|
|
542
|
+
|
|
543
|
+
three_way_decisions(documents)
|
|
544
|
+
end
|
|
545
|
+
|
|
546
|
+
def unmanaged_conflict(request, documents)
|
|
547
|
+
full_file_conflict(
|
|
548
|
+
request,
|
|
549
|
+
documents,
|
|
550
|
+
three_way_decisions(documents),
|
|
551
|
+
[],
|
|
552
|
+
:unmanaged_source_change,
|
|
553
|
+
diagnostics: [{
|
|
554
|
+
category: :unmanaged_source_change,
|
|
555
|
+
severity: :error,
|
|
556
|
+
message: 'Unmanaged YAML layout or comments changed independently.',
|
|
557
|
+
blocking: true
|
|
558
|
+
}]
|
|
559
|
+
)
|
|
560
|
+
end
|
|
561
|
+
|
|
562
|
+
def full_file_conflict(request, documents, decisions, conflicts, reason, diagnostics: nil)
|
|
563
|
+
conflict = { conflict_id: 'yaml-conflict-root', category: reason, path: '<document>' }.freeze
|
|
564
|
+
rendered = render_plan(
|
|
565
|
+
request,
|
|
566
|
+
[
|
|
567
|
+
Ast::Merge::SourceRender::ConflictFragment.new(
|
|
568
|
+
conflict_id: conflict[:conflict_id],
|
|
569
|
+
base: conflict_source(:base, documents[:base].source),
|
|
570
|
+
ours: conflict_source(:ours, documents[:ours].source),
|
|
571
|
+
theirs: conflict_source(:theirs, documents[:theirs].source),
|
|
572
|
+
labels: request.fetch(:labels, {}),
|
|
573
|
+
marker_size: request.fetch(:conflict_marker_size, 7),
|
|
574
|
+
metadata: { category: reason }
|
|
575
|
+
)
|
|
576
|
+
]
|
|
577
|
+
)
|
|
578
|
+
failure(
|
|
579
|
+
:merge3,
|
|
580
|
+
request,
|
|
581
|
+
category: reason,
|
|
582
|
+
message: 'YAML source cannot be safely combined structurally.',
|
|
583
|
+
diagnostics: diagnostics,
|
|
584
|
+
changes: decisions.map(&:classification),
|
|
585
|
+
conflicts: conflicts.empty? ? [conflict] : conflicts.map { |item| conflict_payload(item) },
|
|
586
|
+
fallbacks: [{ from: :mapping_entry_composite, to: :full_file_conflict, reason: reason }],
|
|
587
|
+
conflicted_output: rendered.content,
|
|
588
|
+
conflicted_source: rendered.content,
|
|
589
|
+
render_report: render_report(rendered, :full_file_conflict),
|
|
590
|
+
verification: { base_participated: true },
|
|
591
|
+
source_role: diagnostics&.first&.fetch(:source_role, nil)
|
|
592
|
+
)
|
|
593
|
+
end
|
|
594
|
+
|
|
595
|
+
def conflict_payload(decision)
|
|
596
|
+
{
|
|
597
|
+
conflict_id: conflict_id(decision.key),
|
|
598
|
+
category: conflict_category(decision),
|
|
599
|
+
path: decision.classification[:path],
|
|
600
|
+
change_classification: decision.classification
|
|
601
|
+
}.freeze
|
|
602
|
+
end
|
|
603
|
+
|
|
604
|
+
def conflict_source(role, source)
|
|
605
|
+
return [] if source.empty?
|
|
606
|
+
|
|
607
|
+
[whole_source_fragment(role, source, line_bounded: true)]
|
|
608
|
+
end
|
|
609
|
+
|
|
610
|
+
def separated_fragments(fragments, documents)
|
|
611
|
+
fragments.each_with_index.flat_map do |fragment, index|
|
|
612
|
+
next [fragment] if index == fragments.length - 1
|
|
613
|
+
next [fragment] if fragment.is_a?(Ast::Merge::SourceRender::ConflictFragment)
|
|
614
|
+
|
|
615
|
+
content = fragment_content(fragment, documents)
|
|
616
|
+
next [fragment] if content.empty? || content.end_with?("\n")
|
|
617
|
+
|
|
618
|
+
[
|
|
619
|
+
Ast::Merge::SourceRender::SynthesizedFragment.new(
|
|
620
|
+
content: "#{content}\n",
|
|
621
|
+
reason: :mapping_entry_separator,
|
|
622
|
+
producer: provider_id,
|
|
623
|
+
metadata: fragment.metadata.merge(copied_source: true)
|
|
624
|
+
)
|
|
625
|
+
]
|
|
626
|
+
end
|
|
627
|
+
end
|
|
628
|
+
|
|
629
|
+
def fragment_content(fragment, documents)
|
|
630
|
+
return fragment.content if fragment.is_a?(Ast::Merge::SourceRender::SynthesizedFragment)
|
|
631
|
+
|
|
632
|
+
document = documents.fetch(fragment.revision)
|
|
633
|
+
source_lines(document.source, fragment.start_line, fragment.end_line)
|
|
634
|
+
end
|
|
635
|
+
|
|
636
|
+
def whole_source_fragment(role, source, line_bounded: false)
|
|
637
|
+
if source.empty?
|
|
638
|
+
return Ast::Merge::SourceRender::SynthesizedFragment.new(
|
|
639
|
+
content: '',
|
|
640
|
+
reason: :exact_empty_source,
|
|
641
|
+
producer: provider_id,
|
|
642
|
+
metadata: { source_role: role }
|
|
643
|
+
)
|
|
644
|
+
end
|
|
645
|
+
fragment = range_fragment(role, 1, source.lines.length)
|
|
646
|
+
return fragment unless line_bounded && !source.end_with?("\n")
|
|
647
|
+
|
|
648
|
+
Ast::Merge::SourceRender::SynthesizedFragment.new(
|
|
649
|
+
content: "#{source}\n",
|
|
650
|
+
reason: :conflict_line_boundary,
|
|
651
|
+
producer: provider_id,
|
|
652
|
+
metadata: { source_role: role, copied_source: true }
|
|
653
|
+
)
|
|
654
|
+
end
|
|
655
|
+
|
|
656
|
+
def entry_fragment(role, entry, line_bounded: false)
|
|
657
|
+
unless line_bounded && !entry.fragment.end_with?("\n")
|
|
658
|
+
return range_fragment(role, entry.start_line, entry.end_line, yaml_key: entry.key)
|
|
659
|
+
end
|
|
660
|
+
|
|
661
|
+
Ast::Merge::SourceRender::SynthesizedFragment.new(
|
|
662
|
+
content: "#{entry.fragment}\n",
|
|
663
|
+
reason: :conflict_line_boundary,
|
|
664
|
+
producer: provider_id,
|
|
665
|
+
metadata: { source_role: role, yaml_key: entry.key, copied_source: true }
|
|
666
|
+
)
|
|
667
|
+
end
|
|
668
|
+
|
|
669
|
+
def range_fragment(role, start_line, end_line, **metadata)
|
|
670
|
+
return if end_line < start_line
|
|
671
|
+
|
|
672
|
+
Ast::Merge::SourceRender::SourceFragment.new(
|
|
673
|
+
revision: role,
|
|
674
|
+
start_line: start_line,
|
|
675
|
+
end_line: end_line,
|
|
676
|
+
metadata: { source_role: role }.merge(metadata)
|
|
677
|
+
)
|
|
678
|
+
end
|
|
679
|
+
|
|
680
|
+
def source_lines(source, start_line, end_line)
|
|
681
|
+
return '' if end_line < start_line
|
|
682
|
+
|
|
683
|
+
source.lines.slice(start_line - 1, end_line - start_line + 1).to_a.join
|
|
684
|
+
end
|
|
685
|
+
|
|
686
|
+
def render_plan(request, fragments)
|
|
687
|
+
Ast::Merge::SourceRender::Renderer.new.render(
|
|
688
|
+
Ast::Merge::SourceRender::Plan.new(
|
|
689
|
+
sources: {
|
|
690
|
+
base: request[:base_source].to_s,
|
|
691
|
+
ours: request[:ours_source] || request[:current_source].to_s,
|
|
692
|
+
theirs: request[:theirs_source] || request[:incoming_source].to_s
|
|
693
|
+
},
|
|
694
|
+
fragments: fragments,
|
|
695
|
+
metadata: { provider_id: provider_id }
|
|
696
|
+
)
|
|
697
|
+
)
|
|
698
|
+
end
|
|
699
|
+
|
|
700
|
+
def render_report(rendered, strategy)
|
|
701
|
+
{
|
|
702
|
+
strategy: strategy,
|
|
703
|
+
line_records: rendered.line_records,
|
|
704
|
+
synthesized_fragments: rendered.synthesized_fragments,
|
|
705
|
+
conflicts: rendered.conflicts,
|
|
706
|
+
verification_input: rendered.verification_input
|
|
707
|
+
}
|
|
708
|
+
end
|
|
709
|
+
|
|
710
|
+
def issue(category, message, **metadata)
|
|
711
|
+
{ category: category, message: message, **metadata }.freeze
|
|
712
|
+
end
|
|
713
|
+
|
|
714
|
+
def diagnostics_for(*documents)
|
|
715
|
+
documents.flat_map do |document|
|
|
716
|
+
document.issues.map do |item|
|
|
717
|
+
item.merge(severity: :warning, blocking: false, source_role: document.role).freeze
|
|
718
|
+
end
|
|
719
|
+
end.freeze
|
|
720
|
+
end
|
|
721
|
+
|
|
722
|
+
def invalid_document_failure(operation, request, documents)
|
|
723
|
+
first = documents.first
|
|
724
|
+
failure(
|
|
725
|
+
operation,
|
|
726
|
+
request,
|
|
727
|
+
category: first.issues.first[:category],
|
|
728
|
+
message: "#{first.role} YAML input is unsafe",
|
|
729
|
+
diagnostics: diagnostics_for(*documents).map { |diagnostic| diagnostic.merge(blocking: true) },
|
|
730
|
+
source_role: first.role
|
|
731
|
+
)
|
|
732
|
+
end
|
|
733
|
+
|
|
734
|
+
def verification_failure(operation, request, rendered, decisions, verification)
|
|
735
|
+
failure(
|
|
736
|
+
operation,
|
|
737
|
+
request,
|
|
738
|
+
category: :verification_failure,
|
|
739
|
+
message: 'Rendered YAML did not preserve planned ordered semantics, AST attributes, and source fragments.',
|
|
740
|
+
changes: decisions.map(&:classification),
|
|
741
|
+
render_report: render_report(rendered, :exact_mapping_entry_composite),
|
|
742
|
+
verification: operation == :merge3 ? verification.merge(base_participated: true) : verification
|
|
743
|
+
)
|
|
744
|
+
end
|
|
745
|
+
|
|
746
|
+
def result(operation, request, diagnostics: [], changes: [], conflicts: [], fallbacks: [],
|
|
747
|
+
render_report: {}, verification: {}, **payload)
|
|
748
|
+
Ast::Merge::ProviderResult.build(
|
|
749
|
+
operation: operation,
|
|
750
|
+
success: true,
|
|
751
|
+
envelope: envelope(
|
|
752
|
+
request,
|
|
753
|
+
diagnostics: diagnostics,
|
|
754
|
+
changes: changes,
|
|
755
|
+
conflicts: conflicts,
|
|
756
|
+
fallbacks: fallbacks,
|
|
757
|
+
render_report: render_report,
|
|
758
|
+
verification: verification
|
|
759
|
+
),
|
|
760
|
+
**payload
|
|
761
|
+
)
|
|
762
|
+
end
|
|
763
|
+
|
|
764
|
+
def failure(operation, request, category:, message:, diagnostics: nil, changes: [], conflicts: [],
|
|
765
|
+
fallbacks: [], render_report: {}, verification: {}, **payload)
|
|
766
|
+
diagnostics ||= [{ category: category, severity: :error, message: message, blocking: true }]
|
|
767
|
+
Ast::Merge::ProviderResult.build(
|
|
768
|
+
operation: operation,
|
|
769
|
+
success: false,
|
|
770
|
+
envelope: envelope(
|
|
771
|
+
request,
|
|
772
|
+
diagnostics: diagnostics,
|
|
773
|
+
changes: changes,
|
|
774
|
+
conflicts: conflicts,
|
|
775
|
+
fallbacks: fallbacks,
|
|
776
|
+
render_report: render_report,
|
|
777
|
+
verification: verification
|
|
778
|
+
),
|
|
779
|
+
**payload
|
|
780
|
+
)
|
|
781
|
+
end
|
|
782
|
+
|
|
783
|
+
def envelope(request, **fields)
|
|
784
|
+
{
|
|
785
|
+
provider: {
|
|
786
|
+
provider_id: provider_id,
|
|
787
|
+
family: family,
|
|
788
|
+
dialect: request[:dialect] || :yaml,
|
|
789
|
+
backend: request[:backend] || backend_id,
|
|
790
|
+
package: package_name,
|
|
791
|
+
package_version: package_version
|
|
792
|
+
},
|
|
793
|
+
profile: { profile_id: request[:profile_id] || DEFAULT_PROFILE }
|
|
794
|
+
}.merge(fields)
|
|
795
|
+
end
|
|
796
|
+
end
|
|
797
|
+
# rubocop:enable Metrics/AbcSize, Metrics/ClassLength, Metrics/CyclomaticComplexity, Metrics/MethodLength, Metrics/ParameterLists, Metrics/PerceivedComplexity
|
|
798
|
+
end
|
|
799
|
+
end
|
data/lib/yaml/merge/version.rb
CHANGED
|
@@ -5,7 +5,7 @@ module Yaml
|
|
|
5
5
|
# Version namespace for this gem.
|
|
6
6
|
module Version
|
|
7
7
|
# Current gem version.
|
|
8
|
-
VERSION = '7.1.
|
|
8
|
+
VERSION = '7.1.8'
|
|
9
9
|
end
|
|
10
10
|
# Current gem version exposed at the traditional constant location.
|
|
11
11
|
VERSION = Version::VERSION # Traditional Constant Location
|
data/lib/yaml/merge.rb
CHANGED
|
@@ -45,6 +45,8 @@ module Yaml
|
|
|
45
45
|
autoload :MergeResult, 'yaml/merge/merge_result'
|
|
46
46
|
autoload :NodeWrapper, 'yaml/merge/node_wrapper'
|
|
47
47
|
autoload :SmartMerger, 'yaml/merge/smart_merger'
|
|
48
|
+
autoload :SourcePreservingProvider, 'yaml/merge/source_preserving_provider'
|
|
49
|
+
autoload :Provider, 'yaml/merge/provider'
|
|
48
50
|
|
|
49
51
|
module_function
|
|
50
52
|
|
|
@@ -589,3 +591,5 @@ module Yaml
|
|
|
589
591
|
end
|
|
590
592
|
|
|
591
593
|
Yaml::Merge.register_backend!
|
|
594
|
+
require_relative 'merge/provider'
|
|
595
|
+
Yaml::Merge.register_provider!
|
data/sig/yaml/merge.rbs
CHANGED
|
@@ -1,5 +1,21 @@
|
|
|
1
1
|
module Yaml
|
|
2
2
|
module Merge
|
|
3
|
+
class SourcePreservingProvider
|
|
4
|
+
def provider_id: () -> String
|
|
5
|
+
def family: () -> String
|
|
6
|
+
def capabilities: () -> Hash[Symbol, untyped]
|
|
7
|
+
def analyze: (Hash[Symbol, untyped] request) -> Hash[Symbol, untyped]
|
|
8
|
+
def diff2: (Hash[Symbol, untyped] request) -> Hash[Symbol, untyped]
|
|
9
|
+
def merge2: (Hash[Symbol, untyped] request) -> Hash[Symbol, untyped]
|
|
10
|
+
def merge3: (Hash[Symbol, untyped] request) -> Hash[Symbol, untyped]
|
|
11
|
+
end
|
|
12
|
+
|
|
13
|
+
class Provider < SourcePreservingProvider
|
|
14
|
+
end
|
|
15
|
+
|
|
16
|
+
def self.merge_provider: () -> Provider
|
|
17
|
+
def self.register_provider!: (?replace: bool) -> untyped
|
|
18
|
+
|
|
3
19
|
module Version
|
|
4
20
|
VERSION: String
|
|
5
21
|
end
|
data.tar.gz.sig
CHANGED
|
Binary file
|
metadata
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
|
2
2
|
name: yaml-merge
|
|
3
3
|
version: !ruby/object:Gem::Version
|
|
4
|
-
version: 7.1.
|
|
4
|
+
version: 7.1.8
|
|
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.
|
|
46
|
+
version: 7.1.8
|
|
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.
|
|
53
|
+
version: 7.1.8
|
|
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.
|
|
60
|
+
version: 7.1.8
|
|
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.
|
|
67
|
+
version: 7.1.8
|
|
68
68
|
- !ruby/object:Gem::Dependency
|
|
69
69
|
name: version_gem
|
|
70
70
|
requirement: !ruby/object:Gem::Requirement
|
|
@@ -94,7 +94,7 @@ dependencies:
|
|
|
94
94
|
version: '3.0'
|
|
95
95
|
- - ">="
|
|
96
96
|
- !ruby/object:Gem::Version
|
|
97
|
-
version: 3.0.
|
|
97
|
+
version: 3.0.31
|
|
98
98
|
type: :development
|
|
99
99
|
prerelease: false
|
|
100
100
|
version_requirements: !ruby/object:Gem::Requirement
|
|
@@ -104,7 +104,7 @@ dependencies:
|
|
|
104
104
|
version: '3.0'
|
|
105
105
|
- - ">="
|
|
106
106
|
- !ruby/object:Gem::Version
|
|
107
|
-
version: 3.0.
|
|
107
|
+
version: 3.0.31
|
|
108
108
|
- !ruby/object:Gem::Dependency
|
|
109
109
|
name: bundler-audit
|
|
110
110
|
requirement: !ruby/object:Gem::Requirement
|
|
@@ -182,7 +182,7 @@ dependencies:
|
|
|
182
182
|
version: '3.2'
|
|
183
183
|
- - ">="
|
|
184
184
|
- !ruby/object:Gem::Version
|
|
185
|
-
version: 3.2.
|
|
185
|
+
version: 3.2.3
|
|
186
186
|
type: :development
|
|
187
187
|
prerelease: false
|
|
188
188
|
version_requirements: !ruby/object:Gem::Requirement
|
|
@@ -192,7 +192,7 @@ dependencies:
|
|
|
192
192
|
version: '3.2'
|
|
193
193
|
- - ">="
|
|
194
194
|
- !ruby/object:Gem::Version
|
|
195
|
-
version: 3.2.
|
|
195
|
+
version: 3.2.3
|
|
196
196
|
- !ruby/object:Gem::Dependency
|
|
197
197
|
name: kettle-test
|
|
198
198
|
requirement: !ruby/object:Gem::Requirement
|
|
@@ -222,7 +222,7 @@ dependencies:
|
|
|
222
222
|
version: '3.2'
|
|
223
223
|
- - ">="
|
|
224
224
|
- !ruby/object:Gem::Version
|
|
225
|
-
version: 3.2.
|
|
225
|
+
version: 3.2.7
|
|
226
226
|
type: :development
|
|
227
227
|
prerelease: false
|
|
228
228
|
version_requirements: !ruby/object:Gem::Requirement
|
|
@@ -232,7 +232,7 @@ dependencies:
|
|
|
232
232
|
version: '3.2'
|
|
233
233
|
- - ">="
|
|
234
234
|
- !ruby/object:Gem::Version
|
|
235
|
-
version: 3.2.
|
|
235
|
+
version: 3.2.7
|
|
236
236
|
- !ruby/object:Gem::Dependency
|
|
237
237
|
name: ruby-progressbar
|
|
238
238
|
requirement: !ruby/object:Gem::Requirement
|
|
@@ -304,7 +304,9 @@ files:
|
|
|
304
304
|
- lib/yaml/merge/file_analysis.rb
|
|
305
305
|
- lib/yaml/merge/merge_result.rb
|
|
306
306
|
- lib/yaml/merge/node_wrapper.rb
|
|
307
|
+
- lib/yaml/merge/provider.rb
|
|
307
308
|
- lib/yaml/merge/smart_merger.rb
|
|
309
|
+
- lib/yaml/merge/source_preserving_provider.rb
|
|
308
310
|
- lib/yaml/merge/version.rb
|
|
309
311
|
- sig/yaml/merge.rbs
|
|
310
312
|
homepage: https://github.com/structuredmerge/structuredmerge-ruby
|
|
@@ -313,10 +315,10 @@ licenses:
|
|
|
313
315
|
- PolyForm-Small-Business-1.0.0
|
|
314
316
|
metadata:
|
|
315
317
|
homepage_uri: https://structuredmerge.org
|
|
316
|
-
source_code_uri: https://github.com/structuredmerge/structuredmerge-ruby/tree/v7.1.
|
|
317
|
-
changelog_uri: https://github.com/structuredmerge/structuredmerge-ruby/blob/v7.1.
|
|
318
|
+
source_code_uri: https://github.com/structuredmerge/structuredmerge-ruby/tree/v7.1.8
|
|
319
|
+
changelog_uri: https://github.com/structuredmerge/structuredmerge-ruby/blob/v7.1.8/CHANGELOG.md
|
|
318
320
|
bug_tracker_uri: https://github.com/structuredmerge/structuredmerge-ruby/issues
|
|
319
|
-
documentation_uri: https://www.rubydoc.info/gems/yaml-merge/7.1.
|
|
321
|
+
documentation_uri: https://www.rubydoc.info/gems/yaml-merge/7.1.8
|
|
320
322
|
funding_uri: https://github.com/sponsors/pboling
|
|
321
323
|
wiki_uri: https://github.com/structuredmerge/structuredmerge-ruby/wiki
|
|
322
324
|
news_uri: https://www.railsbling.com/tags/yaml-merge
|
metadata.gz.sig
CHANGED
|
Binary file
|