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