bash-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/bash/merge/node_wrapper.rb +27 -0
- data/lib/bash/merge/provider.rb +983 -0
- data/lib/bash/merge/version.rb +1 -1
- data/lib/bash/merge.rb +4 -0
- data/sig/bash/merge.rbs +22 -0
- data.tar.gz.sig +0 -0
- metadata +17 -16
- 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: 63912e4ed1e5c589b747fe8c4bff43704efb8011c051c738c140a3a9b8372610
|
|
4
|
+
data.tar.gz: c37449f1e387c5beeb1f4bcd062a5c748ec2cbeae76c1b3dd8e1559c8f3f8404
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 05c63cd77818e9091f81afdc9f33212c0c66f40b266f4d9a57f7426ca4ef9c39b4a07da65d599f06f8bd8987ac12695cfbf05158d26dec7bf500243ade3a9e5b
|
|
7
|
+
data.tar.gz: ca5966709c53a0ebac627228ebf76b3bdc1541efef4cc6f40399cef04c71870b0f5fd3c0d8a325fc46a428388851d78c52e1cd919c7c82b6f49d0f25d42b0221
|
checksums.yaml.gz.sig
CHANGED
|
Binary file
|
data/README.md
CHANGED
|
@@ -85,7 +85,7 @@ File.write("merged.sh", result.to_bash)
|
|
|
85
85
|
|
|
86
86
|
### Compatibility
|
|
87
87
|
|
|
88
|
-
Compatible with MRI Ruby 4.0.0+,
|
|
88
|
+
Compatible with MRI Ruby 4.0.0+, JRuby, and TruffleRuby.
|
|
89
89
|
CI workflows and Appraisals are generated for MRI Ruby 4.0.0+.
|
|
90
90
|
This test floor is configured by `ruby.test_minimum` in `.kettle-jem.yml` and
|
|
91
91
|
may be higher than the gem's runtime compatibility floor when legacy Rubies are
|
|
@@ -258,20 +258,12 @@ See [SECURITY.md][🔐security].
|
|
|
258
258
|
## 🤝 Contributing
|
|
259
259
|
|
|
260
260
|
If you need some ideas of where to help, you could work on adding more code coverage,
|
|
261
|
-
|
|
262
|
-
or use the gem and think about how it could be better.
|
|
261
|
+
check [issues][🤝gh-issues] or [PRs][🤝gh-pulls], or use the gem and think about how it could be better.
|
|
263
262
|
|
|
264
263
|
We [![Keep A Changelog][📗keep-changelog-img]][📗keep-changelog] so if you make changes, remember to update it.
|
|
265
264
|
|
|
266
265
|
See [CONTRIBUTING.md][🤝contributing] for more detailed instructions.
|
|
267
266
|
|
|
268
|
-
### Code Coverage
|
|
269
|
-
|
|
270
|
-
<details markdown="1">
|
|
271
|
-
<summary>Coverage service badges</summary>
|
|
272
|
-
|
|
273
|
-
</details>
|
|
274
|
-
|
|
275
267
|
## 📌 Versioning
|
|
276
268
|
|
|
277
269
|
This library follows [![Semantic Versioning 2.0.0][📌semver-img]][📌semver] for its public API where practical.
|
|
@@ -19,6 +19,8 @@ module Bash
|
|
|
19
19
|
#
|
|
20
20
|
# @see Ast::Merge::NodeWrapperBase
|
|
21
21
|
class NodeWrapper < Ast::Merge::NodeWrapperBase
|
|
22
|
+
attr_reader :node
|
|
23
|
+
|
|
22
24
|
# Check if this is a function definition
|
|
23
25
|
# @return [Boolean]
|
|
24
26
|
def function_definition?
|
|
@@ -107,6 +109,18 @@ module Bash
|
|
|
107
109
|
nil
|
|
108
110
|
end
|
|
109
111
|
|
|
112
|
+
def start_byte = node.start_byte
|
|
113
|
+
def end_byte = node.end_byte
|
|
114
|
+
def source_text = @source.byteslice(start_byte...end_byte).to_s
|
|
115
|
+
|
|
116
|
+
def semantic_tree
|
|
117
|
+
semantic_node(node)
|
|
118
|
+
end
|
|
119
|
+
|
|
120
|
+
def heredoc?
|
|
121
|
+
descendant_type?(node, %w[heredoc_redirect heredoc_body])
|
|
122
|
+
end
|
|
123
|
+
|
|
110
124
|
# Find a child by field name
|
|
111
125
|
# @param field_name [String] Field name to look for
|
|
112
126
|
# @return [TreeSitter::Node, nil]
|
|
@@ -193,6 +207,19 @@ module Bash
|
|
|
193
207
|
|
|
194
208
|
private
|
|
195
209
|
|
|
210
|
+
def semantic_node(value)
|
|
211
|
+
children = value.children
|
|
212
|
+
return [value.type.to_s, @source.byteslice(value.start_byte...value.end_byte).to_s] if children.empty?
|
|
213
|
+
|
|
214
|
+
[value.type.to_s, children.select(&:named?).map { |child| semantic_node(child) }]
|
|
215
|
+
end
|
|
216
|
+
|
|
217
|
+
def descendant_type?(value, types)
|
|
218
|
+
return true if types.include?(value.type.to_s)
|
|
219
|
+
|
|
220
|
+
value.children.any? { |child| descendant_type?(child, types) }
|
|
221
|
+
end
|
|
222
|
+
|
|
196
223
|
def extract_command_signature_context(node)
|
|
197
224
|
# Extract additional context like redirections
|
|
198
225
|
redirections = []
|
|
@@ -0,0 +1,983 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'digest'
|
|
4
|
+
require 'json'
|
|
5
|
+
require 'ast/merge/source_render'
|
|
6
|
+
require_relative 'node_wrapper'
|
|
7
|
+
require_relative 'file_analysis'
|
|
8
|
+
|
|
9
|
+
module Bash
|
|
10
|
+
# Registration and source-preserving workflow implementation for Bash.
|
|
11
|
+
module Merge
|
|
12
|
+
class << self
|
|
13
|
+
def merge_provider
|
|
14
|
+
@merge_provider ||= Provider.new
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
def register_provider!(replace: false)
|
|
18
|
+
return unless Ast::Merge.respond_to?(:register_provider)
|
|
19
|
+
|
|
20
|
+
Ast::Merge.register_provider(merge_provider, replace: replace)
|
|
21
|
+
end
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
# Base-aware, source-preserving provider for native top-level Bash owners.
|
|
25
|
+
# Functions and assignments have stable AST identities. Every other
|
|
26
|
+
# top-level form must remain identical across all revisions in a composite.
|
|
27
|
+
# rubocop:disable Metrics/AbcSize, Metrics/ClassLength, Metrics/CyclomaticComplexity, Metrics/MethodLength, Metrics/ParameterLists, Metrics/PerceivedComplexity -- provider decisions, source plans, and verification form one boundary
|
|
28
|
+
class Provider
|
|
29
|
+
DEFAULT_PROFILE = :source_preserving
|
|
30
|
+
Owner = Data.define(:id, :signature, :membership, :compound, :fingerprint, :start_line, :end_line, :role)
|
|
31
|
+
Document = Data.define(:source, :analysis, :owners, :by_id, :unmanaged_fingerprint)
|
|
32
|
+
ExactDocument = Data.define(:source, :analysis, :declarations, :issues, :role)
|
|
33
|
+
Decision = Data.define(:changes, :conflicts, :choices)
|
|
34
|
+
|
|
35
|
+
def provider_id = 'ruby.bash'
|
|
36
|
+
def family = 'bash'
|
|
37
|
+
|
|
38
|
+
def capabilities
|
|
39
|
+
{
|
|
40
|
+
operations: Ast::Merge::ProviderContract::OPERATIONS,
|
|
41
|
+
dialects: %i[bash],
|
|
42
|
+
backends: [TREE_SITTER_BACKEND.id.to_sym],
|
|
43
|
+
profiles: [DEFAULT_PROFILE],
|
|
44
|
+
role: :workflow,
|
|
45
|
+
ast_ownership: :stable_functions_and_assignments,
|
|
46
|
+
source_preservation: %i[exact_source declaration_fragments line_provenance reparse semantic_verification]
|
|
47
|
+
}.freeze
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
def analyze(request)
|
|
51
|
+
document = parse_document(:analyze, request, :source)
|
|
52
|
+
return document if provider_failure?(document)
|
|
53
|
+
|
|
54
|
+
result(
|
|
55
|
+
:analyze,
|
|
56
|
+
request,
|
|
57
|
+
analysis: {
|
|
58
|
+
backend: TREE_SITTER_BACKEND.id,
|
|
59
|
+
valid: true,
|
|
60
|
+
declarations: document.owners.map { |owner| owner_description(owner) }
|
|
61
|
+
},
|
|
62
|
+
verification: { source_parsed: true }
|
|
63
|
+
)
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
def diff2(request)
|
|
67
|
+
before = parse_document(:diff2, request, :before)
|
|
68
|
+
return before if provider_failure?(before)
|
|
69
|
+
|
|
70
|
+
after = parse_document(:diff2, request, :after)
|
|
71
|
+
return after if provider_failure?(after)
|
|
72
|
+
|
|
73
|
+
changes = diff_documents(before, after)
|
|
74
|
+
result(
|
|
75
|
+
:diff2,
|
|
76
|
+
request,
|
|
77
|
+
diff: { changes: changes },
|
|
78
|
+
changes: changes,
|
|
79
|
+
verification: { before_parsed: true, after_parsed: true }
|
|
80
|
+
)
|
|
81
|
+
end
|
|
82
|
+
|
|
83
|
+
def merge2(request)
|
|
84
|
+
merge3_request = request.merge(
|
|
85
|
+
base_source: request.fetch(:current_source),
|
|
86
|
+
ours_source: request.fetch(:current_source),
|
|
87
|
+
theirs_source: request.fetch(:incoming_source)
|
|
88
|
+
)
|
|
89
|
+
merged = merge3(merge3_request)
|
|
90
|
+
merged.merge(
|
|
91
|
+
operation: :merge2,
|
|
92
|
+
verification: merged.fetch(:verification).except(:base_participated)
|
|
93
|
+
)
|
|
94
|
+
end
|
|
95
|
+
|
|
96
|
+
def merge3(request)
|
|
97
|
+
exact_role = exact_revision_role(request)
|
|
98
|
+
return merge3_exact(request, exact_role) if exact_role
|
|
99
|
+
|
|
100
|
+
documents = parse_merge3_documents(request)
|
|
101
|
+
return documents if provider_failure?(documents)
|
|
102
|
+
|
|
103
|
+
ownership_failure = unstable_ownership_failure(request, documents)
|
|
104
|
+
return ownership_failure if ownership_failure
|
|
105
|
+
|
|
106
|
+
decision = decide(documents, include_unmanaged_conflict: true)
|
|
107
|
+
return render_conflicts(request, documents, decision) unless decision.conflicts.empty?
|
|
108
|
+
|
|
109
|
+
render_composite(request, documents, decision)
|
|
110
|
+
end
|
|
111
|
+
|
|
112
|
+
private
|
|
113
|
+
|
|
114
|
+
def parse_merge3_documents(request)
|
|
115
|
+
%i[base ours theirs].each_with_object({}) do |role, documents|
|
|
116
|
+
source = request.fetch(:"#{role}_source")
|
|
117
|
+
document = parse_source(source, role, request[:dialect])
|
|
118
|
+
if document.is_a?(Hash) && document[:parse_error]
|
|
119
|
+
return parse_failure(:merge3, request, role, document)
|
|
120
|
+
elsif provider_failure?(document)
|
|
121
|
+
return unsafe_document_failure(request, role, document)
|
|
122
|
+
end
|
|
123
|
+
|
|
124
|
+
documents[role] = document
|
|
125
|
+
end
|
|
126
|
+
end
|
|
127
|
+
|
|
128
|
+
def parse_document(operation, request, role)
|
|
129
|
+
source = request.fetch(role == :source ? :source : :"#{role}_source")
|
|
130
|
+
parsed = parse_source(source, role, request[:dialect])
|
|
131
|
+
return parse_failure(operation, request, role, parsed) if provider_failure?(parsed)
|
|
132
|
+
|
|
133
|
+
parsed
|
|
134
|
+
end
|
|
135
|
+
|
|
136
|
+
def parse_source(source, role, _dialect = nil)
|
|
137
|
+
analysis = FileAnalysis.new(source)
|
|
138
|
+
return { parse_error: analysis.errors.map(&:to_s).join('; '), source_role: role } unless analysis.valid?
|
|
139
|
+
|
|
140
|
+
unstable_occurrences = Hash.new(0)
|
|
141
|
+
owners = analysis.top_level_statements.map do |wrapper|
|
|
142
|
+
return { unsafe_range: :heredoc, source_role: role } if wrapper.heredoc?
|
|
143
|
+
return { unsafe_range: wrapper.signature, source_role: role } unless safe_range?(source, wrapper)
|
|
144
|
+
|
|
145
|
+
signature = provider_signature(wrapper)
|
|
146
|
+
return { unsafe_range: wrapper.type, source_role: role } unless signature
|
|
147
|
+
|
|
148
|
+
occurrence = if stable_signature?(signature)
|
|
149
|
+
nil
|
|
150
|
+
else
|
|
151
|
+
unstable_occurrences[signature] += 1
|
|
152
|
+
end
|
|
153
|
+
Owner.new(
|
|
154
|
+
id: owner_id(occurrence ? [signature, occurrence] : signature),
|
|
155
|
+
signature: signature,
|
|
156
|
+
membership: signature,
|
|
157
|
+
compound: false,
|
|
158
|
+
fingerprint: owner_fingerprint(wrapper),
|
|
159
|
+
start_line: wrapper.start_line,
|
|
160
|
+
end_line: wrapper.end_line,
|
|
161
|
+
role: role
|
|
162
|
+
)
|
|
163
|
+
end
|
|
164
|
+
duplicate = owners.group_by(&:id).find { |_id, matches| matches.length > 1 }
|
|
165
|
+
return { ambiguous_owner: duplicate.first, source_role: role } if duplicate
|
|
166
|
+
|
|
167
|
+
return { unsafe_range: :overlapping_declarations, source_role: role } unless non_overlapping?(owners)
|
|
168
|
+
|
|
169
|
+
Document.new(
|
|
170
|
+
source: source,
|
|
171
|
+
analysis: analysis,
|
|
172
|
+
owners: owners.freeze,
|
|
173
|
+
by_id: owners.to_h { |owner| [owner.id, owner] }.freeze,
|
|
174
|
+
unmanaged_fingerprint: unmanaged_fingerprint(source, owners)
|
|
175
|
+
)
|
|
176
|
+
rescue StandardError => e
|
|
177
|
+
{ parse_error: e.message, source_role: role }
|
|
178
|
+
end
|
|
179
|
+
|
|
180
|
+
def parse_exact_source(source, role, _dialect = nil)
|
|
181
|
+
analysis = FileAnalysis.new(source)
|
|
182
|
+
return { parse_error: analysis.errors.map(&:to_s).join('; '), source_role: role } unless analysis.valid?
|
|
183
|
+
|
|
184
|
+
declarations = analysis.top_level_statements.map do |wrapper|
|
|
185
|
+
[provider_signature(wrapper), wrapper.semantic_tree]
|
|
186
|
+
end.freeze
|
|
187
|
+
duplicate_keys = declarations.group_by(&:first).filter_map do |signature, matches|
|
|
188
|
+
signature if matches.length > 1
|
|
189
|
+
end
|
|
190
|
+
issues = duplicate_keys.uniq.map do |identity|
|
|
191
|
+
{
|
|
192
|
+
category: :ambiguous_owner,
|
|
193
|
+
declaration: identity,
|
|
194
|
+
message: duplicate_message(identity)
|
|
195
|
+
}.freeze
|
|
196
|
+
end
|
|
197
|
+
ExactDocument.new(
|
|
198
|
+
source: source,
|
|
199
|
+
analysis: analysis,
|
|
200
|
+
declarations: declarations,
|
|
201
|
+
issues: issues.freeze,
|
|
202
|
+
role: role
|
|
203
|
+
)
|
|
204
|
+
rescue StandardError => e
|
|
205
|
+
{ parse_error: e.message, source_role: role }
|
|
206
|
+
end
|
|
207
|
+
|
|
208
|
+
def provider_signature(wrapper)
|
|
209
|
+
if wrapper.function_definition?
|
|
210
|
+
name = wrapper.function_name
|
|
211
|
+
name && [:function, name]
|
|
212
|
+
elsif wrapper.variable_assignment?
|
|
213
|
+
name = wrapper.variable_name
|
|
214
|
+
name && [:variable_assignment, name]
|
|
215
|
+
else
|
|
216
|
+
wrapper.signature
|
|
217
|
+
end
|
|
218
|
+
end
|
|
219
|
+
|
|
220
|
+
def safe_range?(source, wrapper)
|
|
221
|
+
return false if wrapper.start_byte.negative? || wrapper.end_byte > source.bytesize
|
|
222
|
+
return false if wrapper.end_byte < wrapper.start_byte
|
|
223
|
+
|
|
224
|
+
line_start = source.rindex("\n", [wrapper.start_byte - 1, 0].max)
|
|
225
|
+
line_start = line_start ? line_start + 1 : 0
|
|
226
|
+
line_end = source.index("\n", wrapper.end_byte) || source.bytesize
|
|
227
|
+
horizontal_whitespace?(source.byteslice(line_start...wrapper.start_byte).to_s) &&
|
|
228
|
+
horizontal_whitespace?(source.byteslice(wrapper.end_byte...line_end).to_s)
|
|
229
|
+
end
|
|
230
|
+
|
|
231
|
+
def horizontal_whitespace?(value)
|
|
232
|
+
value.each_byte.all? { |byte| [9, 13, 32].include?(byte) }
|
|
233
|
+
end
|
|
234
|
+
|
|
235
|
+
def non_overlapping?(owners)
|
|
236
|
+
owners.each_cons(2).all? { |left, right| left.end_line < right.start_line }
|
|
237
|
+
end
|
|
238
|
+
|
|
239
|
+
def unmanaged_fingerprint(source, owners)
|
|
240
|
+
owned_lines = owners.each_with_object({}) do |owner, lines|
|
|
241
|
+
(owner.start_line..owner.end_line).each { |line| lines[line] = true }
|
|
242
|
+
end
|
|
243
|
+
source.lines.each_with_index.filter_map do |line, index|
|
|
244
|
+
line unless owned_lines[index + 1]
|
|
245
|
+
end.join
|
|
246
|
+
end
|
|
247
|
+
|
|
248
|
+
def owner_fingerprint(wrapper)
|
|
249
|
+
Digest::SHA256.hexdigest(
|
|
250
|
+
JSON.generate(Ast::Merge.json_ready([wrapper.semantic_tree, wrapper.source_text]))
|
|
251
|
+
)
|
|
252
|
+
end
|
|
253
|
+
|
|
254
|
+
def owner_id(signature)
|
|
255
|
+
Digest::SHA256.hexdigest(JSON.generate(Ast::Merge.json_ready(signature)))
|
|
256
|
+
end
|
|
257
|
+
|
|
258
|
+
def owner_path(owner)
|
|
259
|
+
owner ? owner.signature.inspect : '<document>'
|
|
260
|
+
end
|
|
261
|
+
|
|
262
|
+
def owner_description(owner)
|
|
263
|
+
{
|
|
264
|
+
path: owner_path(owner),
|
|
265
|
+
signature: owner.signature,
|
|
266
|
+
source_role: owner.role,
|
|
267
|
+
line_range: [owner.start_line, owner.end_line]
|
|
268
|
+
}
|
|
269
|
+
end
|
|
270
|
+
|
|
271
|
+
def diff_documents(before, after)
|
|
272
|
+
ordered_ids(before, after).filter_map do |id|
|
|
273
|
+
left = before.by_id[id]
|
|
274
|
+
right = after.by_id[id]
|
|
275
|
+
next if equivalent?(left, right)
|
|
276
|
+
|
|
277
|
+
{
|
|
278
|
+
path: owner_path(left || right),
|
|
279
|
+
before: change_side(left),
|
|
280
|
+
after: change_side(right),
|
|
281
|
+
change: change_kind(left, right)
|
|
282
|
+
}.freeze
|
|
283
|
+
end.freeze
|
|
284
|
+
end
|
|
285
|
+
|
|
286
|
+
def change_side(owner)
|
|
287
|
+
return { present: false } unless owner
|
|
288
|
+
|
|
289
|
+
{ present: true, source_role: owner.role, line_range: [owner.start_line, owner.end_line] }
|
|
290
|
+
end
|
|
291
|
+
|
|
292
|
+
def ordered_ids(*documents)
|
|
293
|
+
documents.flat_map { |document| document.owners.map(&:id) }.uniq
|
|
294
|
+
end
|
|
295
|
+
|
|
296
|
+
def change_kind(before, after)
|
|
297
|
+
return :added unless before
|
|
298
|
+
return :deleted unless after
|
|
299
|
+
|
|
300
|
+
:edited
|
|
301
|
+
end
|
|
302
|
+
|
|
303
|
+
def decide(documents, include_unmanaged_conflict:)
|
|
304
|
+
changes = []
|
|
305
|
+
conflicts = []
|
|
306
|
+
choices = {}
|
|
307
|
+
append_unmanaged_conflict(changes, conflicts, documents) if include_unmanaged_conflict
|
|
308
|
+
ordered_ids(*documents.values).each do |id|
|
|
309
|
+
base = documents.fetch(:base).by_id[id]
|
|
310
|
+
ours = documents.fetch(:ours).by_id[id]
|
|
311
|
+
theirs = documents.fetch(:theirs).by_id[id]
|
|
312
|
+
ours_change = side_change(base, ours)
|
|
313
|
+
theirs_change = side_change(base, theirs)
|
|
314
|
+
next choices[id] = :ours if ours_change == :unchanged && theirs_change == :unchanged
|
|
315
|
+
|
|
316
|
+
change = {
|
|
317
|
+
path: owner_path(base || ours || theirs),
|
|
318
|
+
ours: ours_change,
|
|
319
|
+
theirs: theirs_change
|
|
320
|
+
}.freeze
|
|
321
|
+
changes << change
|
|
322
|
+
choice = owner_choice(base, ours, theirs)
|
|
323
|
+
if choice == :conflict
|
|
324
|
+
conflicts << conflict_for(id, base, ours, theirs, change)
|
|
325
|
+
else
|
|
326
|
+
choices[id] = choice
|
|
327
|
+
end
|
|
328
|
+
end
|
|
329
|
+
Decision.new(changes: changes.freeze, conflicts: conflicts.freeze, choices: choices.freeze)
|
|
330
|
+
end
|
|
331
|
+
|
|
332
|
+
def append_unmanaged_conflict(changes, conflicts, documents)
|
|
333
|
+
base = documents.fetch(:base).unmanaged_fingerprint
|
|
334
|
+
ours = documents.fetch(:ours).unmanaged_fingerprint
|
|
335
|
+
theirs = documents.fetch(:theirs).unmanaged_fingerprint
|
|
336
|
+
return if ours == theirs
|
|
337
|
+
|
|
338
|
+
change = {
|
|
339
|
+
path: '<unmanaged-source>',
|
|
340
|
+
ours: base == ours ? :unchanged : :edited,
|
|
341
|
+
theirs: base == theirs ? :unchanged : :edited
|
|
342
|
+
}.freeze
|
|
343
|
+
changes << change
|
|
344
|
+
conflicts << {
|
|
345
|
+
conflict_id: "bash-unmanaged-#{Digest::SHA256.hexdigest([base, ours, theirs].join("\0"))[0, 16]}",
|
|
346
|
+
category: :unmanaged_source_change,
|
|
347
|
+
path: change.fetch(:path),
|
|
348
|
+
owner_id: nil,
|
|
349
|
+
change_classification: change
|
|
350
|
+
}.freeze
|
|
351
|
+
end
|
|
352
|
+
|
|
353
|
+
def unstable_ownership_failure(request, documents)
|
|
354
|
+
sequences = documents.transform_values do |document|
|
|
355
|
+
document.owners.filter_map do |owner|
|
|
356
|
+
[owner.signature, owner.fingerprint] unless stable_signature?(owner.signature)
|
|
357
|
+
end
|
|
358
|
+
end
|
|
359
|
+
return if sequences.values.uniq.one?
|
|
360
|
+
|
|
361
|
+
mismatch_index = (0...sequences.values.map(&:length).max).find do |index|
|
|
362
|
+
sequences.values.map { |sequence| sequence[index] }.uniq.length > 1
|
|
363
|
+
end
|
|
364
|
+
unstable = documents.values.filter_map do |document|
|
|
365
|
+
unstable_owners = document.owners.reject { |owner| stable_signature?(owner.signature) }
|
|
366
|
+
unstable_owners[mismatch_index]
|
|
367
|
+
end.first
|
|
368
|
+
identity = sequences.transform_values { |sequence| sequence[mismatch_index] }
|
|
369
|
+
signature_digest = Digest::SHA256.hexdigest(JSON.generate(Ast::Merge.json_ready(sequences)))
|
|
370
|
+
conflict = {
|
|
371
|
+
conflict_id: "bash-ownership-#{signature_digest[0, 16]}",
|
|
372
|
+
category: :unproven_ast_ownership,
|
|
373
|
+
path: owner_path(unstable),
|
|
374
|
+
owner_id: nil,
|
|
375
|
+
signature: unstable&.signature,
|
|
376
|
+
unstable_index: mismatch_index,
|
|
377
|
+
identities: identity
|
|
378
|
+
}.freeze
|
|
379
|
+
decision = Decision.new(changes: [], conflicts: [conflict], choices: {})
|
|
380
|
+
rendered = render_plan(request, [whole_document_conflict(request, decision)])
|
|
381
|
+
failure(
|
|
382
|
+
:merge3,
|
|
383
|
+
request,
|
|
384
|
+
category: :unproven_ast_ownership,
|
|
385
|
+
message: 'A non-stable Bash top-level AST identity changed; whole-file fallback is required.',
|
|
386
|
+
conflicts: [conflict],
|
|
387
|
+
conflicted_output: rendered.content,
|
|
388
|
+
render_report: render_report(rendered, :full_file_conflict),
|
|
389
|
+
verification: { base_participated: true },
|
|
390
|
+
fallbacks: [{
|
|
391
|
+
from: :top_level_ast_ownership,
|
|
392
|
+
to: :full_file_conflict,
|
|
393
|
+
reason: :identity_changed
|
|
394
|
+
}]
|
|
395
|
+
)
|
|
396
|
+
end
|
|
397
|
+
|
|
398
|
+
def stable_signature?(signature)
|
|
399
|
+
%i[function variable_assignment].include?(signature.first)
|
|
400
|
+
end
|
|
401
|
+
|
|
402
|
+
def side_change(base, side)
|
|
403
|
+
return :unchanged if equivalent?(base, side)
|
|
404
|
+
return :added if !base && side
|
|
405
|
+
return :deleted if base && !side
|
|
406
|
+
return :unchanged unless base || side
|
|
407
|
+
|
|
408
|
+
:edited
|
|
409
|
+
end
|
|
410
|
+
|
|
411
|
+
def owner_choice(base, ours, theirs)
|
|
412
|
+
return :ours if equivalent?(ours, theirs)
|
|
413
|
+
return :theirs if equivalent?(base, ours)
|
|
414
|
+
return :ours if equivalent?(base, theirs)
|
|
415
|
+
return if ours.nil? && theirs.nil?
|
|
416
|
+
|
|
417
|
+
:conflict
|
|
418
|
+
end
|
|
419
|
+
|
|
420
|
+
def equivalent?(left, right)
|
|
421
|
+
return true if left.nil? && right.nil?
|
|
422
|
+
return false unless left && right
|
|
423
|
+
|
|
424
|
+
left.fingerprint == right.fingerprint
|
|
425
|
+
end
|
|
426
|
+
|
|
427
|
+
def conflict_for(id, base, ours, theirs, change)
|
|
428
|
+
{
|
|
429
|
+
conflict_id: "bash-declaration-#{id[0, 16]}",
|
|
430
|
+
category: base && (!ours || !theirs) ? :delete_edit : :edit_edit,
|
|
431
|
+
path: change.fetch(:path),
|
|
432
|
+
owner_id: id,
|
|
433
|
+
base: owner_state(base),
|
|
434
|
+
ours: owner_state(ours),
|
|
435
|
+
theirs: owner_state(theirs),
|
|
436
|
+
change_classification: change
|
|
437
|
+
}.freeze
|
|
438
|
+
end
|
|
439
|
+
|
|
440
|
+
def owner_state(owner)
|
|
441
|
+
{
|
|
442
|
+
present: !owner.nil?,
|
|
443
|
+
fingerprint: owner&.fingerprint,
|
|
444
|
+
source_role: owner&.role,
|
|
445
|
+
line_range: owner && [owner.start_line, owner.end_line]
|
|
446
|
+
}
|
|
447
|
+
end
|
|
448
|
+
|
|
449
|
+
def exact_revision_role(request)
|
|
450
|
+
base = request.fetch(:base_source)
|
|
451
|
+
ours = request.fetch(:ours_source)
|
|
452
|
+
theirs = request.fetch(:theirs_source)
|
|
453
|
+
return :ours if ours == theirs || base == theirs
|
|
454
|
+
|
|
455
|
+
:theirs if base == ours
|
|
456
|
+
end
|
|
457
|
+
|
|
458
|
+
def merge3_exact(request, role)
|
|
459
|
+
documents = %i[base ours theirs].to_h do |source_role|
|
|
460
|
+
[source_role, parse_exact_source(request.fetch(:"#{source_role}_source"), source_role, request[:dialect])]
|
|
461
|
+
end
|
|
462
|
+
winner = documents.fetch(role)
|
|
463
|
+
return parse_failure(:merge3, request, role, winner) if provider_failure?(winner)
|
|
464
|
+
|
|
465
|
+
render_exact(request, documents, role)
|
|
466
|
+
end
|
|
467
|
+
|
|
468
|
+
def render_exact(request, documents, role)
|
|
469
|
+
winner = documents.fetch(role)
|
|
470
|
+
rendered = render_plan(request, [whole_source_fragment(role, request.fetch(:"#{role}_source"))])
|
|
471
|
+
verification = verify_exact_rendered(rendered.content, winner)
|
|
472
|
+
unless verification[:semantic_match] && verification[:byte_exact]
|
|
473
|
+
return failure(
|
|
474
|
+
:merge3,
|
|
475
|
+
request,
|
|
476
|
+
category: :render_failure,
|
|
477
|
+
message: 'Bash exact revision did not remain byte-exact and semantically identical ' \
|
|
478
|
+
'after native reparse.',
|
|
479
|
+
source_role: role,
|
|
480
|
+
render_report: render_report(rendered, :exact_revision),
|
|
481
|
+
verification: verification.merge(base_participated: true)
|
|
482
|
+
)
|
|
483
|
+
end
|
|
484
|
+
|
|
485
|
+
result(
|
|
486
|
+
:merge3,
|
|
487
|
+
request,
|
|
488
|
+
output: rendered.content,
|
|
489
|
+
diagnostics: exact_diagnostics(documents),
|
|
490
|
+
render_report: render_report(rendered, :exact_revision),
|
|
491
|
+
verification: verification.merge(base_participated: true)
|
|
492
|
+
)
|
|
493
|
+
end
|
|
494
|
+
|
|
495
|
+
def verify_exact_rendered(output, expected)
|
|
496
|
+
parsed = parse_exact_source(output, :output)
|
|
497
|
+
if provider_failure?(parsed)
|
|
498
|
+
return {
|
|
499
|
+
output_reparsed: false,
|
|
500
|
+
byte_exact: output == expected.source,
|
|
501
|
+
semantic_match: false,
|
|
502
|
+
parse_error: failure_detail(parsed),
|
|
503
|
+
source_role: expected.role
|
|
504
|
+
}
|
|
505
|
+
end
|
|
506
|
+
|
|
507
|
+
actual_signatures = parsed.declarations.map(&:first)
|
|
508
|
+
expected_signatures = expected.declarations.map(&:first)
|
|
509
|
+
actual_attributes = parsed.declarations.map(&:last)
|
|
510
|
+
expected_attributes = expected.declarations.map(&:last)
|
|
511
|
+
{
|
|
512
|
+
output_reparsed: true,
|
|
513
|
+
byte_exact: output == expected.source,
|
|
514
|
+
semantic_match: parsed.declarations == expected.declarations,
|
|
515
|
+
ordered_declaration_signatures_verified: actual_signatures == expected_signatures,
|
|
516
|
+
ast_attributes_verified: actual_attributes == expected_attributes,
|
|
517
|
+
planned_declaration_count: expected.declarations.length,
|
|
518
|
+
output_declaration_count: parsed.declarations.length,
|
|
519
|
+
source_role: expected.role
|
|
520
|
+
}
|
|
521
|
+
end
|
|
522
|
+
|
|
523
|
+
def exact_diagnostics(documents)
|
|
524
|
+
documents.flat_map do |role, document|
|
|
525
|
+
if provider_failure?(document)
|
|
526
|
+
[{
|
|
527
|
+
category: :parse_error,
|
|
528
|
+
severity: :warning,
|
|
529
|
+
message: "#{role} parse error: #{failure_detail(document)}",
|
|
530
|
+
blocking: false,
|
|
531
|
+
source_role: role
|
|
532
|
+
}]
|
|
533
|
+
else
|
|
534
|
+
document.issues.map do |issue|
|
|
535
|
+
issue.merge(severity: :warning, blocking: false, source_role: role).freeze
|
|
536
|
+
end
|
|
537
|
+
end
|
|
538
|
+
end.freeze
|
|
539
|
+
end
|
|
540
|
+
|
|
541
|
+
def duplicate_message(signature)
|
|
542
|
+
"ambiguous duplicate declaration #{signature.inspect}"
|
|
543
|
+
end
|
|
544
|
+
|
|
545
|
+
def render_composite(request, documents, decision)
|
|
546
|
+
fragments = composite_fragments(documents, decision)
|
|
547
|
+
rendered = render_plan(request, fragments)
|
|
548
|
+
expected = expected_owners(documents, decision)
|
|
549
|
+
verification = verify_rendered(rendered.content, expected, request[:dialect])
|
|
550
|
+
unless verification[:semantic_match]
|
|
551
|
+
return failure(
|
|
552
|
+
:merge3,
|
|
553
|
+
request,
|
|
554
|
+
category: :render_failure,
|
|
555
|
+
message: 'Bash composite did not match the planned ordered declaration structure and AST attributes.',
|
|
556
|
+
changes: decision.changes,
|
|
557
|
+
render_report: render_report(rendered, :exact_declaration_composite),
|
|
558
|
+
verification: verification.merge(base_participated: true)
|
|
559
|
+
)
|
|
560
|
+
end
|
|
561
|
+
|
|
562
|
+
result(
|
|
563
|
+
:merge3,
|
|
564
|
+
request,
|
|
565
|
+
output: rendered.content,
|
|
566
|
+
changes: decision.changes,
|
|
567
|
+
render_report: render_report(rendered, :exact_declaration_composite),
|
|
568
|
+
verification: verification.merge(base_participated: true)
|
|
569
|
+
)
|
|
570
|
+
end
|
|
571
|
+
|
|
572
|
+
def composite_fragments(documents, decision)
|
|
573
|
+
ours = documents.fetch(:ours)
|
|
574
|
+
fragments = []
|
|
575
|
+
cursor = 1
|
|
576
|
+
edits = composite_edits(documents, decision).sort_by do |edit|
|
|
577
|
+
insertion = edit.fetch(:end_line) < edit.fetch(:start_line)
|
|
578
|
+
[edit.fetch(:start_line), insertion ? 0 : 1]
|
|
579
|
+
end
|
|
580
|
+
edits.each do |edit|
|
|
581
|
+
append_range(fragments, :ours, cursor, edit.fetch(:start_line) - 1)
|
|
582
|
+
edit.fetch(:owners).each_with_index do |owner, index|
|
|
583
|
+
append_owner(fragments, owner)
|
|
584
|
+
ensure_plan_boundary!(fragments, documents) if index < edit.fetch(:owners).length - 1
|
|
585
|
+
end
|
|
586
|
+
cursor = [cursor, edit.fetch(:end_line) + 1].max
|
|
587
|
+
end
|
|
588
|
+
append_range(fragments, :ours, cursor, ours.source.lines.length)
|
|
589
|
+
append_declaration_additions(fragments, documents, decision)
|
|
590
|
+
fragments
|
|
591
|
+
end
|
|
592
|
+
|
|
593
|
+
def composite_edits(documents, decision)
|
|
594
|
+
replacement_edits(documents, decision) + import_insertion_edits(documents, decision)
|
|
595
|
+
end
|
|
596
|
+
|
|
597
|
+
def replacement_edits(documents, decision)
|
|
598
|
+
ours = documents.fetch(:ours)
|
|
599
|
+
decision.choices.filter_map do |id, role|
|
|
600
|
+
ours_owner = ours.by_id[id]
|
|
601
|
+
next unless ours_owner
|
|
602
|
+
next if role == :ours
|
|
603
|
+
|
|
604
|
+
{
|
|
605
|
+
start_line: ours_owner.start_line,
|
|
606
|
+
end_line: ours_owner.end_line,
|
|
607
|
+
owners: [role && documents.fetch(role).by_id[id]].compact
|
|
608
|
+
}
|
|
609
|
+
end
|
|
610
|
+
end
|
|
611
|
+
|
|
612
|
+
def import_insertion_edits(documents, decision)
|
|
613
|
+
additions = added_theirs_owners(documents, decision).select { |owner| import_owner?(owner) }
|
|
614
|
+
return [] if additions.empty?
|
|
615
|
+
|
|
616
|
+
ours = documents.fetch(:ours)
|
|
617
|
+
anchor = ours.owners.reverse.find { |owner| package_or_import_owner?(owner) }
|
|
618
|
+
insertion_line = anchor ? anchor.end_line + 1 : ours.owners.first&.start_line || ours.source.lines.length + 1
|
|
619
|
+
[{
|
|
620
|
+
start_line: insertion_line,
|
|
621
|
+
end_line: insertion_line - 1,
|
|
622
|
+
owners: additions
|
|
623
|
+
}]
|
|
624
|
+
end
|
|
625
|
+
|
|
626
|
+
def append_declaration_additions(fragments, documents, decision)
|
|
627
|
+
additions = added_theirs_owners(documents, decision).reject { |owner| import_owner?(owner) }
|
|
628
|
+
return if additions.empty?
|
|
629
|
+
|
|
630
|
+
ensure_plan_boundary!(fragments, documents)
|
|
631
|
+
additions.each_with_index do |owner, index|
|
|
632
|
+
append_owner(fragments, owner)
|
|
633
|
+
ensure_plan_boundary!(fragments, documents) if index < additions.length - 1
|
|
634
|
+
end
|
|
635
|
+
end
|
|
636
|
+
|
|
637
|
+
def added_theirs_owners(documents, decision)
|
|
638
|
+
ours = documents.fetch(:ours)
|
|
639
|
+
documents.fetch(:theirs).owners.select do |owner|
|
|
640
|
+
!ours.by_id.key?(owner.id) && decision.choices[owner.id] == :theirs
|
|
641
|
+
end
|
|
642
|
+
end
|
|
643
|
+
|
|
644
|
+
def ensure_plan_boundary!(fragments, documents)
|
|
645
|
+
fragment = fragments.last
|
|
646
|
+
return if fragment.nil? || fragment_content(fragment, documents).end_with?("\n")
|
|
647
|
+
|
|
648
|
+
if fragment.is_a?(Ast::Merge::SourceRender::SourceFragment) && fragment.start_line < fragment.end_line
|
|
649
|
+
fragments[-1] = source_range_fragment(fragment.revision, fragment.start_line, fragment.end_line - 1)
|
|
650
|
+
else
|
|
651
|
+
fragments.pop
|
|
652
|
+
end
|
|
653
|
+
content = if fragment.is_a?(Ast::Merge::SourceRender::SourceFragment)
|
|
654
|
+
documents.fetch(fragment.revision).source.lines.fetch(fragment.end_line - 1)
|
|
655
|
+
else
|
|
656
|
+
fragment.content
|
|
657
|
+
end
|
|
658
|
+
fragments << Ast::Merge::SourceRender::SynthesizedFragment.new(
|
|
659
|
+
content: "#{content}\n",
|
|
660
|
+
reason: :declaration_separator,
|
|
661
|
+
producer: provider_id,
|
|
662
|
+
metadata: { source_role: fragment.respond_to?(:revision) ? fragment.revision : nil, copied_source: true }
|
|
663
|
+
)
|
|
664
|
+
end
|
|
665
|
+
|
|
666
|
+
def fragment_content(fragment, documents)
|
|
667
|
+
return fragment.content if fragment.is_a?(Ast::Merge::SourceRender::SynthesizedFragment)
|
|
668
|
+
|
|
669
|
+
document = documents.fetch(fragment.revision)
|
|
670
|
+
document.source.lines.slice(fragment.start_line - 1, fragment.end_line - fragment.start_line + 1).join
|
|
671
|
+
end
|
|
672
|
+
|
|
673
|
+
def append_owner(fragments, owner)
|
|
674
|
+
fragments << source_range_fragment(owner.role, owner.start_line, owner.end_line)
|
|
675
|
+
end
|
|
676
|
+
|
|
677
|
+
def append_range(fragments, role, start_line, end_line)
|
|
678
|
+
return if start_line > end_line
|
|
679
|
+
|
|
680
|
+
fragments << source_range_fragment(role, start_line, end_line)
|
|
681
|
+
end
|
|
682
|
+
|
|
683
|
+
def expected_owners(documents, decision)
|
|
684
|
+
ours = documents.fetch(:ours)
|
|
685
|
+
expected = ours.owners.filter_map do |owner|
|
|
686
|
+
role = decision.choices[owner.id]
|
|
687
|
+
role && documents.fetch(role).by_id[owner.id]
|
|
688
|
+
end
|
|
689
|
+
import_offset = expected.rindex { |owner| package_or_import_owner?(owner) }
|
|
690
|
+
documents.fetch(:theirs).owners.each do |owner|
|
|
691
|
+
next if ours.by_id.key?(owner.id)
|
|
692
|
+
next unless decision.choices[owner.id] == :theirs
|
|
693
|
+
|
|
694
|
+
if import_owner?(owner)
|
|
695
|
+
import_offset = import_offset ? import_offset + 1 : 0
|
|
696
|
+
expected.insert(import_offset, owner)
|
|
697
|
+
else
|
|
698
|
+
expected << owner
|
|
699
|
+
end
|
|
700
|
+
end
|
|
701
|
+
expected
|
|
702
|
+
end
|
|
703
|
+
|
|
704
|
+
def import_owner?(_owner)
|
|
705
|
+
false
|
|
706
|
+
end
|
|
707
|
+
|
|
708
|
+
alias package_or_import_owner? import_owner?
|
|
709
|
+
|
|
710
|
+
def verify_rendered(output, expected, dialect)
|
|
711
|
+
parsed = parse_source(output, :output, dialect)
|
|
712
|
+
if provider_failure?(parsed)
|
|
713
|
+
return {
|
|
714
|
+
output_reparsed: false,
|
|
715
|
+
semantic_match: false,
|
|
716
|
+
parse_error: failure_detail(parsed)
|
|
717
|
+
}
|
|
718
|
+
end
|
|
719
|
+
|
|
720
|
+
actual = parsed.owners.map { |owner| [owner.signature, owner.fingerprint] }
|
|
721
|
+
planned = expected.map { |owner| [owner.signature, owner.fingerprint] }
|
|
722
|
+
{
|
|
723
|
+
output_reparsed: true,
|
|
724
|
+
semantic_match: actual == planned,
|
|
725
|
+
ordered_declaration_signatures_verified: actual.map(&:first) == planned.map(&:first),
|
|
726
|
+
ast_attributes_verified: actual.map(&:last) == planned.map(&:last),
|
|
727
|
+
planned_declaration_count: planned.length,
|
|
728
|
+
output_declaration_count: actual.length
|
|
729
|
+
}
|
|
730
|
+
end
|
|
731
|
+
|
|
732
|
+
def render_conflicts(request, documents, decision)
|
|
733
|
+
localized = localized_conflict_fragments(request, documents, decision)
|
|
734
|
+
if localized
|
|
735
|
+
rendered = render_plan(request, localized)
|
|
736
|
+
return conflict_failure(request, decision, rendered, :declaration_localized_conflict)
|
|
737
|
+
end
|
|
738
|
+
|
|
739
|
+
rendered = render_plan(request, [whole_document_conflict(request, decision)])
|
|
740
|
+
conflict_failure(
|
|
741
|
+
request,
|
|
742
|
+
decision,
|
|
743
|
+
rendered,
|
|
744
|
+
:full_file_conflict,
|
|
745
|
+
fallbacks: [{ from: :declaration_localization, to: :full_file_conflict, reason: :source_ownership_unproven }]
|
|
746
|
+
)
|
|
747
|
+
end
|
|
748
|
+
|
|
749
|
+
def localized_conflict_fragments(request, documents, decision)
|
|
750
|
+
return if decision.conflicts.any? { |conflict| conflict[:owner_id].nil? }
|
|
751
|
+
|
|
752
|
+
ours = documents.fetch(:ours)
|
|
753
|
+
return unless decision.conflicts.all? { |conflict| ours.by_id.key?(conflict.fetch(:owner_id)) }
|
|
754
|
+
|
|
755
|
+
ranges = decision.conflicts.map { |conflict| [conflict, ours.by_id.fetch(conflict.fetch(:owner_id))] }
|
|
756
|
+
return unless ranges.sort_by { |_conflict, owner| owner.start_line }.each_cons(2).none? do |left, right|
|
|
757
|
+
left.last.end_line >= right.last.start_line
|
|
758
|
+
end
|
|
759
|
+
|
|
760
|
+
fragments = []
|
|
761
|
+
cursor = 1
|
|
762
|
+
ranges.sort_by { |_conflict, owner| owner.start_line }.each do |conflict, owner|
|
|
763
|
+
append_range(fragments, :ours, cursor, owner.start_line - 1)
|
|
764
|
+
fragments << declaration_conflict_fragment(request, documents, conflict)
|
|
765
|
+
cursor = owner.end_line + 1
|
|
766
|
+
end
|
|
767
|
+
append_range(fragments, :ours, cursor, ours.source.lines.length)
|
|
768
|
+
fragments
|
|
769
|
+
end
|
|
770
|
+
|
|
771
|
+
def declaration_conflict_fragment(request, documents, conflict)
|
|
772
|
+
Ast::Merge::SourceRender::ConflictFragment.new(
|
|
773
|
+
conflict_id: conflict.fetch(:conflict_id),
|
|
774
|
+
base: conflict_declaration_side(documents, conflict, :base),
|
|
775
|
+
ours: conflict_declaration_side(documents, conflict, :ours),
|
|
776
|
+
theirs: conflict_declaration_side(documents, conflict, :theirs),
|
|
777
|
+
labels: request.fetch(:labels, {}),
|
|
778
|
+
marker_size: request.fetch(:conflict_marker_size, 7),
|
|
779
|
+
metadata: { path: conflict.fetch(:path), category: conflict.fetch(:category) }
|
|
780
|
+
)
|
|
781
|
+
end
|
|
782
|
+
|
|
783
|
+
def conflict_declaration_side(documents, conflict, role)
|
|
784
|
+
document = documents.fetch(role)
|
|
785
|
+
owner = document.by_id[conflict.fetch(:owner_id)]
|
|
786
|
+
owner ? [conflict_source_fragment(owner, document.source)] : []
|
|
787
|
+
end
|
|
788
|
+
|
|
789
|
+
def unsafe_document_failure(request, role, parsed)
|
|
790
|
+
return parsed unless parsed[:unsafe_range] || parsed[:ambiguous_owner]
|
|
791
|
+
|
|
792
|
+
conflict_id = "bash-unsafe-#{Digest::SHA256.hexdigest([role, failure_detail(parsed)].join("\0"))[0, 16]}"
|
|
793
|
+
conflict = {
|
|
794
|
+
conflict_id: conflict_id,
|
|
795
|
+
category: parsed[:ambiguous_owner] ? :ambiguous_owner : :unsafe_source_range,
|
|
796
|
+
path: '<document>',
|
|
797
|
+
owner_id: nil,
|
|
798
|
+
source_role: role
|
|
799
|
+
}
|
|
800
|
+
rendered = render_plan(
|
|
801
|
+
request,
|
|
802
|
+
[whole_document_conflict(request, Decision.new(changes: [], conflicts: [conflict], choices: {}))]
|
|
803
|
+
)
|
|
804
|
+
failure(
|
|
805
|
+
:merge3,
|
|
806
|
+
request,
|
|
807
|
+
category: conflict.fetch(:category),
|
|
808
|
+
message: "#{role} cannot be merged safely: #{failure_detail(parsed)}",
|
|
809
|
+
conflicts: [conflict],
|
|
810
|
+
conflicted_output: rendered.content,
|
|
811
|
+
source_role: role,
|
|
812
|
+
render_report: render_report(rendered, :full_file_conflict),
|
|
813
|
+
verification: { base_participated: true }
|
|
814
|
+
)
|
|
815
|
+
end
|
|
816
|
+
|
|
817
|
+
def whole_document_conflict(request, decision)
|
|
818
|
+
Ast::Merge::SourceRender::ConflictFragment.new(
|
|
819
|
+
conflict_id: decision.conflicts.first.fetch(:conflict_id),
|
|
820
|
+
base: [conflict_whole_source_fragment(:base, request.fetch(:base_source))],
|
|
821
|
+
ours: [conflict_whole_source_fragment(:ours, request.fetch(:ours_source))],
|
|
822
|
+
theirs: [conflict_whole_source_fragment(:theirs, request.fetch(:theirs_source))],
|
|
823
|
+
labels: request.fetch(:labels, {}),
|
|
824
|
+
marker_size: request.fetch(:conflict_marker_size, 7),
|
|
825
|
+
metadata: { conflicts: decision.conflicts.map { |conflict| conflict[:conflict_id] } }
|
|
826
|
+
)
|
|
827
|
+
end
|
|
828
|
+
|
|
829
|
+
def conflict_source_fragment(owner, source)
|
|
830
|
+
fragment_source = source.lines.slice(owner.start_line - 1, owner.end_line - owner.start_line + 1).join
|
|
831
|
+
return source_range_fragment(owner.role, owner.start_line, owner.end_line) if fragment_source.end_with?("\n")
|
|
832
|
+
|
|
833
|
+
synthesized_copy("#{fragment_source}\n", :conflict_line_boundary, owner.role)
|
|
834
|
+
end
|
|
835
|
+
|
|
836
|
+
def conflict_whole_source_fragment(role, source)
|
|
837
|
+
return whole_source_fragment(role, source) if source.empty? || source.end_with?("\n")
|
|
838
|
+
|
|
839
|
+
synthesized_copy("#{source}\n", :conflict_line_boundary, role)
|
|
840
|
+
end
|
|
841
|
+
|
|
842
|
+
def synthesized_copy(content, reason, role)
|
|
843
|
+
Ast::Merge::SourceRender::SynthesizedFragment.new(
|
|
844
|
+
content: content,
|
|
845
|
+
reason: reason,
|
|
846
|
+
producer: provider_id,
|
|
847
|
+
metadata: { source_role: role, copied_source: true }
|
|
848
|
+
)
|
|
849
|
+
end
|
|
850
|
+
|
|
851
|
+
def whole_source_fragment(role, source)
|
|
852
|
+
return synthesized_copy('', :exact_empty_source, role) if source.empty?
|
|
853
|
+
|
|
854
|
+
source_range_fragment(role, 1, source.lines.length)
|
|
855
|
+
end
|
|
856
|
+
|
|
857
|
+
def source_range_fragment(role, start_line, end_line)
|
|
858
|
+
Ast::Merge::SourceRender::SourceFragment.new(
|
|
859
|
+
revision: role,
|
|
860
|
+
start_line: start_line,
|
|
861
|
+
end_line: end_line,
|
|
862
|
+
metadata: { source_role: role }
|
|
863
|
+
)
|
|
864
|
+
end
|
|
865
|
+
|
|
866
|
+
def render_plan(request, fragments)
|
|
867
|
+
Ast::Merge::SourceRender::Renderer.new.render(
|
|
868
|
+
Ast::Merge::SourceRender::Plan.new(
|
|
869
|
+
sources: {
|
|
870
|
+
base: request[:base_source].to_s,
|
|
871
|
+
ours: request[:ours_source].to_s,
|
|
872
|
+
theirs: request[:theirs_source].to_s
|
|
873
|
+
},
|
|
874
|
+
fragments: fragments,
|
|
875
|
+
metadata: { provider_id: provider_id }
|
|
876
|
+
)
|
|
877
|
+
)
|
|
878
|
+
end
|
|
879
|
+
|
|
880
|
+
def render_report(rendered, strategy)
|
|
881
|
+
{
|
|
882
|
+
strategy: strategy,
|
|
883
|
+
line_records: rendered.line_records,
|
|
884
|
+
synthesized_fragments: rendered.synthesized_fragments,
|
|
885
|
+
conflicts: rendered.conflicts,
|
|
886
|
+
verification_input: rendered.verification_input
|
|
887
|
+
}
|
|
888
|
+
end
|
|
889
|
+
|
|
890
|
+
def conflict_failure(request, decision, rendered, strategy, fallbacks: [])
|
|
891
|
+
failure(
|
|
892
|
+
:merge3,
|
|
893
|
+
request,
|
|
894
|
+
category: :merge_conflict,
|
|
895
|
+
message: 'Bash top-level declaration changed incompatibly on both sides.',
|
|
896
|
+
changes: decision.changes,
|
|
897
|
+
conflicts: decision.conflicts,
|
|
898
|
+
conflicted_output: rendered.content,
|
|
899
|
+
render_report: render_report(rendered, strategy),
|
|
900
|
+
verification: { base_participated: true },
|
|
901
|
+
fallbacks: fallbacks
|
|
902
|
+
)
|
|
903
|
+
end
|
|
904
|
+
|
|
905
|
+
def parse_failure(operation, request, role, parsed)
|
|
906
|
+
category = if parsed[:parse_error]
|
|
907
|
+
:parse_error
|
|
908
|
+
elsif parsed[:ambiguous_owner]
|
|
909
|
+
:ambiguous_owner
|
|
910
|
+
else
|
|
911
|
+
:unsafe_source_range
|
|
912
|
+
end
|
|
913
|
+
failure(
|
|
914
|
+
operation,
|
|
915
|
+
request,
|
|
916
|
+
category: category,
|
|
917
|
+
message: "#{role} parse error: #{failure_detail(parsed)}",
|
|
918
|
+
source_role: role
|
|
919
|
+
)
|
|
920
|
+
end
|
|
921
|
+
|
|
922
|
+
def failure_detail(parsed)
|
|
923
|
+
return parsed[:parse_error] if parsed[:parse_error]
|
|
924
|
+
return "ambiguous duplicate declaration #{parsed[:ambiguous_owner].inspect}" if parsed[:ambiguous_owner]
|
|
925
|
+
|
|
926
|
+
"unsafe declaration range #{parsed[:unsafe_range].inspect}"
|
|
927
|
+
end
|
|
928
|
+
|
|
929
|
+
def provider_failure?(value)
|
|
930
|
+
value.is_a?(Hash) &&
|
|
931
|
+
(value[:ok] == false || value.key?(:parse_error) || value.key?(:ambiguous_owner) || value.key?(:unsafe_range))
|
|
932
|
+
end
|
|
933
|
+
|
|
934
|
+
def result(operation, request, changes: [], diagnostics: [], render_report: {}, verification: {}, **payload)
|
|
935
|
+
Ast::Merge::ProviderResult.build(
|
|
936
|
+
operation: operation,
|
|
937
|
+
success: true,
|
|
938
|
+
envelope: envelope(
|
|
939
|
+
request,
|
|
940
|
+
changes: changes,
|
|
941
|
+
diagnostics: diagnostics,
|
|
942
|
+
render_report: render_report,
|
|
943
|
+
verification: verification
|
|
944
|
+
),
|
|
945
|
+
**payload
|
|
946
|
+
)
|
|
947
|
+
end
|
|
948
|
+
|
|
949
|
+
def failure(operation, request, category:, message:, changes: [], conflicts: [], fallbacks: [],
|
|
950
|
+
render_report: {}, verification: {}, **payload)
|
|
951
|
+
Ast::Merge::ProviderResult.build(
|
|
952
|
+
operation: operation,
|
|
953
|
+
success: false,
|
|
954
|
+
envelope: envelope(
|
|
955
|
+
request,
|
|
956
|
+
changes: changes,
|
|
957
|
+
conflicts: conflicts,
|
|
958
|
+
fallbacks: fallbacks,
|
|
959
|
+
diagnostics: [{ category: category, severity: :error, message: message, blocking: true }],
|
|
960
|
+
render_report: render_report,
|
|
961
|
+
verification: verification
|
|
962
|
+
),
|
|
963
|
+
**payload
|
|
964
|
+
)
|
|
965
|
+
end
|
|
966
|
+
|
|
967
|
+
def envelope(request, **fields)
|
|
968
|
+
{
|
|
969
|
+
provider: {
|
|
970
|
+
provider_id: provider_id,
|
|
971
|
+
family: family,
|
|
972
|
+
dialect: request[:dialect] || :bash,
|
|
973
|
+
backend: request[:backend] || TREE_SITTER_BACKEND.id.to_sym,
|
|
974
|
+
package: 'bash-merge',
|
|
975
|
+
package_version: Bash::Merge::Version::VERSION
|
|
976
|
+
},
|
|
977
|
+
profile: { profile_id: request[:profile_id] || DEFAULT_PROFILE }
|
|
978
|
+
}.merge(fields)
|
|
979
|
+
end
|
|
980
|
+
end
|
|
981
|
+
# rubocop:enable Metrics/AbcSize, Metrics/ClassLength, Metrics/CyclomaticComplexity, Metrics/MethodLength, Metrics/ParameterLists, Metrics/PerceivedComplexity
|
|
982
|
+
end
|
|
983
|
+
end
|
data/lib/bash/merge/version.rb
CHANGED
|
@@ -5,7 +5,7 @@ module Bash
|
|
|
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/bash/merge.rb
CHANGED
|
@@ -119,6 +119,7 @@ module Bash
|
|
|
119
119
|
autoload :FileAnalysis, 'bash/merge/file_analysis'
|
|
120
120
|
autoload :MergeResult, 'bash/merge/merge_result'
|
|
121
121
|
autoload :NodeWrapper, 'bash/merge/node_wrapper'
|
|
122
|
+
autoload :Provider, 'bash/merge/provider'
|
|
122
123
|
autoload :SmartMerger, 'bash/merge/smart_merger'
|
|
123
124
|
|
|
124
125
|
class << self
|
|
@@ -208,6 +209,9 @@ TreeHaver::BackendRegistry.register_tag(:bash_grammar, category: :grammar, backe
|
|
|
208
209
|
Bash::Merge.available?
|
|
209
210
|
end
|
|
210
211
|
|
|
212
|
+
require_relative 'merge/provider'
|
|
213
|
+
Bash::Merge.register_provider!
|
|
214
|
+
|
|
211
215
|
# Register with ast-merge's MergeGemRegistry for RSpec dependency tags
|
|
212
216
|
# Only register if MergeGemRegistry is loaded (i.e., in test environment)
|
|
213
217
|
if defined?(Ast::Merge::RSpec::MergeGemRegistry)
|
data/sig/bash/merge.rbs
CHANGED
|
@@ -1,5 +1,27 @@
|
|
|
1
1
|
module Bash
|
|
2
2
|
module Merge
|
|
3
|
+
class NodeWrapper
|
|
4
|
+
attr_reader node: untyped
|
|
5
|
+
def start_byte: () -> Integer
|
|
6
|
+
def end_byte: () -> Integer
|
|
7
|
+
def source_text: () -> String
|
|
8
|
+
def semantic_tree: () -> Array[untyped]
|
|
9
|
+
def heredoc?: () -> bool
|
|
10
|
+
end
|
|
11
|
+
|
|
12
|
+
class Provider
|
|
13
|
+
def provider_id: () -> String
|
|
14
|
+
def family: () -> String
|
|
15
|
+
def capabilities: () -> Hash[Symbol, untyped]
|
|
16
|
+
def analyze: (Hash[Symbol, untyped] request) -> Hash[Symbol, untyped]
|
|
17
|
+
def diff2: (Hash[Symbol, untyped] request) -> Hash[Symbol, untyped]
|
|
18
|
+
def merge2: (Hash[Symbol, untyped] request) -> Hash[Symbol, untyped]
|
|
19
|
+
def merge3: (Hash[Symbol, untyped] request) -> Hash[Symbol, untyped]
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
def self.merge_provider: () -> Provider
|
|
23
|
+
def self.register_provider!: (?replace: bool) -> untyped
|
|
24
|
+
|
|
3
25
|
module Version
|
|
4
26
|
VERSION: String
|
|
5
27
|
end
|
data.tar.gz.sig
CHANGED
|
Binary file
|
metadata
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
|
2
2
|
name: bash-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
|
|
@@ -91,20 +91,20 @@ dependencies:
|
|
|
91
91
|
requirements:
|
|
92
92
|
- - "~>"
|
|
93
93
|
- !ruby/object:Gem::Version
|
|
94
|
-
version: '
|
|
94
|
+
version: '3.0'
|
|
95
95
|
- - ">="
|
|
96
96
|
- !ruby/object:Gem::Version
|
|
97
|
-
version:
|
|
97
|
+
version: 3.0.0
|
|
98
98
|
type: :development
|
|
99
99
|
prerelease: false
|
|
100
100
|
version_requirements: !ruby/object:Gem::Requirement
|
|
101
101
|
requirements:
|
|
102
102
|
- - "~>"
|
|
103
103
|
- !ruby/object:Gem::Version
|
|
104
|
-
version: '
|
|
104
|
+
version: '3.0'
|
|
105
105
|
- - ">="
|
|
106
106
|
- !ruby/object:Gem::Version
|
|
107
|
-
version:
|
|
107
|
+
version: 3.0.0
|
|
108
108
|
- !ruby/object:Gem::Dependency
|
|
109
109
|
name: bundler-audit
|
|
110
110
|
requirement: !ruby/object:Gem::Requirement
|
|
@@ -182,7 +182,7 @@ dependencies:
|
|
|
182
182
|
version: '3.2'
|
|
183
183
|
- - ">="
|
|
184
184
|
- !ruby/object:Gem::Version
|
|
185
|
-
version: 3.2.
|
|
185
|
+
version: 3.2.2
|
|
186
186
|
type: :development
|
|
187
187
|
prerelease: false
|
|
188
188
|
version_requirements: !ruby/object:Gem::Requirement
|
|
@@ -192,7 +192,7 @@ dependencies:
|
|
|
192
192
|
version: '3.2'
|
|
193
193
|
- - ">="
|
|
194
194
|
- !ruby/object:Gem::Version
|
|
195
|
-
version: 3.2.
|
|
195
|
+
version: 3.2.2
|
|
196
196
|
- !ruby/object:Gem::Dependency
|
|
197
197
|
name: kettle-test
|
|
198
198
|
requirement: !ruby/object:Gem::Requirement
|
|
@@ -222,7 +222,7 @@ dependencies:
|
|
|
222
222
|
version: '3.2'
|
|
223
223
|
- - ">="
|
|
224
224
|
- !ruby/object:Gem::Version
|
|
225
|
-
version: 3.2.
|
|
225
|
+
version: 3.2.5
|
|
226
226
|
type: :development
|
|
227
227
|
prerelease: false
|
|
228
228
|
version_requirements: !ruby/object:Gem::Requirement
|
|
@@ -232,7 +232,7 @@ dependencies:
|
|
|
232
232
|
version: '3.2'
|
|
233
233
|
- - ">="
|
|
234
234
|
- !ruby/object:Gem::Version
|
|
235
|
-
version: 3.2.
|
|
235
|
+
version: 3.2.5
|
|
236
236
|
- !ruby/object:Gem::Dependency
|
|
237
237
|
name: ruby-progressbar
|
|
238
238
|
requirement: !ruby/object:Gem::Requirement
|
|
@@ -306,6 +306,7 @@ files:
|
|
|
306
306
|
- lib/bash/merge/freeze_node.rb
|
|
307
307
|
- lib/bash/merge/merge_result.rb
|
|
308
308
|
- lib/bash/merge/node_wrapper.rb
|
|
309
|
+
- lib/bash/merge/provider.rb
|
|
309
310
|
- lib/bash/merge/smart_merger.rb
|
|
310
311
|
- lib/bash/merge/version.rb
|
|
311
312
|
- sig/bash/merge.rbs
|
|
@@ -315,10 +316,10 @@ licenses:
|
|
|
315
316
|
- PolyForm-Small-Business-1.0.0
|
|
316
317
|
metadata:
|
|
317
318
|
homepage_uri: https://structuredmerge.org
|
|
318
|
-
source_code_uri: https://github.com/structuredmerge/structuredmerge-ruby/tree/v7.1.
|
|
319
|
-
changelog_uri: https://github.com/structuredmerge/structuredmerge-ruby/blob/v7.1.
|
|
319
|
+
source_code_uri: https://github.com/structuredmerge/structuredmerge-ruby/tree/v7.1.3
|
|
320
|
+
changelog_uri: https://github.com/structuredmerge/structuredmerge-ruby/blob/v7.1.3/CHANGELOG.md
|
|
320
321
|
bug_tracker_uri: https://github.com/structuredmerge/structuredmerge-ruby/issues
|
|
321
|
-
documentation_uri: https://www.rubydoc.info/gems/bash-merge/7.1.
|
|
322
|
+
documentation_uri: https://www.rubydoc.info/gems/bash-merge/7.1.3
|
|
322
323
|
funding_uri: https://github.com/sponsors/pboling
|
|
323
324
|
wiki_uri: https://github.com/structuredmerge/structuredmerge-ruby/wiki
|
|
324
325
|
news_uri: https://www.railsbling.com/tags/bash-merge
|
metadata.gz.sig
CHANGED
|
Binary file
|