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