dotenv-merge 1.0.3 → 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/LICENSE.md +13 -0
- data/README.md +94 -490
- data/lib/dotenv/merge/backend.rb +70 -0
- data/lib/dotenv/merge/comment_tracker.rb +112 -0
- data/lib/dotenv/merge/debug_logger.rb +2 -2
- data/lib/dotenv/merge/env_line.rb +10 -14
- data/lib/dotenv/merge/file_analysis.rb +150 -21
- data/lib/dotenv/merge/freeze_node.rb +1 -1
- data/lib/dotenv/merge/merge_result.rb +6 -6
- data/lib/dotenv/merge/provider.rb +690 -0
- data/lib/dotenv/merge/smart_merger.rb +360 -137
- data/lib/dotenv/merge/version.rb +5 -4
- data/lib/dotenv/merge.rb +34 -18
- data/lib/dotenv-merge.rb +9 -1
- data/sig/dotenv/merge.rbs +15 -200
- data.tar.gz.sig +0 -0
- metadata +113 -80
- metadata.gz.sig +0 -0
- data/CHANGELOG.md +0 -120
- data/CITATION.cff +0 -20
- data/CODE_OF_CONDUCT.md +0 -134
- data/CONTRIBUTING.md +0 -227
- data/FUNDING.md +0 -74
- data/LICENSE.txt +0 -21
- data/REEK +0 -0
- data/RUBOCOP.md +0 -71
- data/SECURITY.md +0 -21
- data/sig/dotenv/merge/env_line.rbs +0 -90
|
@@ -0,0 +1,690 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'digest'
|
|
4
|
+
|
|
5
|
+
module Dotenv
|
|
6
|
+
module Merge
|
|
7
|
+
# Source-preserving, base-aware provider for dotenv assignment documents.
|
|
8
|
+
# rubocop:disable Metrics/AbcSize, Metrics/BlockLength, Metrics/ClassLength, Metrics/CyclomaticComplexity, Metrics/MethodLength, Metrics/ParameterLists, Metrics/PerceivedComplexity -- provider planning, rendering, and reporting form one boundary
|
|
9
|
+
class Provider
|
|
10
|
+
DEFAULT_PROFILE = :source_preserving
|
|
11
|
+
DIALECTS = %i[dotenv].freeze
|
|
12
|
+
|
|
13
|
+
Analysis = Data.define(:role, :source, :file, :lines, :assignments, :by_key, :unowned, :issues)
|
|
14
|
+
Decision = Data.define(:key, :base, :ours, :theirs, :selected_role, :classification, :conflict)
|
|
15
|
+
|
|
16
|
+
def provider_id = 'ruby.dotenv'
|
|
17
|
+
def family = 'dotenv'
|
|
18
|
+
|
|
19
|
+
def capabilities
|
|
20
|
+
{
|
|
21
|
+
operations: Ast::Merge::ProviderContract::OPERATIONS,
|
|
22
|
+
dialects: DIALECTS,
|
|
23
|
+
backends: [Dotenv::Merge::BACKEND_REFERENCE.id.to_sym],
|
|
24
|
+
profiles: [DEFAULT_PROFILE],
|
|
25
|
+
role: :workflow,
|
|
26
|
+
source_preservation: %i[exact_source assignment_fragments line_provenance reparse]
|
|
27
|
+
}.freeze
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
def analyze(request)
|
|
31
|
+
analysis = analyze_role(:source, request.fetch(:source))
|
|
32
|
+
result(
|
|
33
|
+
:analyze,
|
|
34
|
+
request,
|
|
35
|
+
analysis: analysis_payload(analysis),
|
|
36
|
+
diagnostics: diagnostics_for(analysis),
|
|
37
|
+
verification: { source_parsed: true, structurally_unambiguous: analysis.issues.empty? }
|
|
38
|
+
)
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
def diff2(request)
|
|
42
|
+
before = analyze_role(:before, request.fetch(:before_source))
|
|
43
|
+
after = analyze_role(:after, request.fetch(:after_source))
|
|
44
|
+
changes = assignment_changes(before, after)
|
|
45
|
+
result(
|
|
46
|
+
:diff2,
|
|
47
|
+
request,
|
|
48
|
+
diff: { before: analysis_payload(before), after: analysis_payload(after), changes: changes },
|
|
49
|
+
changes: changes,
|
|
50
|
+
diagnostics: diagnostics_for(before, after),
|
|
51
|
+
verification: { before_parsed: true, after_parsed: true }
|
|
52
|
+
)
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
def merge2(request)
|
|
56
|
+
incoming = request.fetch(:incoming_source)
|
|
57
|
+
current = request.fetch(:current_source)
|
|
58
|
+
inputs = [
|
|
59
|
+
analyze_role(:incoming, incoming),
|
|
60
|
+
analyze_role(:current, current)
|
|
61
|
+
]
|
|
62
|
+
unsafe = inputs.reject { |analysis| analysis.issues.empty? }
|
|
63
|
+
unless unsafe.empty?
|
|
64
|
+
return unsafe_failure(
|
|
65
|
+
:merge2,
|
|
66
|
+
request,
|
|
67
|
+
unsafe,
|
|
68
|
+
message: 'dotenv merge2 input is structurally ambiguous'
|
|
69
|
+
)
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
merged = SmartMerger.new(incoming, current, add_template_only_nodes: true).merge.to_s
|
|
73
|
+
output = analyze_role(:output, merged)
|
|
74
|
+
unless output.issues.empty?
|
|
75
|
+
return unsafe_failure(
|
|
76
|
+
:merge2,
|
|
77
|
+
request,
|
|
78
|
+
[output],
|
|
79
|
+
message: 'dotenv merge2 output is structurally ambiguous'
|
|
80
|
+
)
|
|
81
|
+
end
|
|
82
|
+
|
|
83
|
+
result(
|
|
84
|
+
:merge2,
|
|
85
|
+
request,
|
|
86
|
+
output: merged,
|
|
87
|
+
render_report: { strategy: :dotenv_smart_merger, synthesis: :family_emission },
|
|
88
|
+
verification: { output_reparsed: true, ordered_assignments: semantic_assignments(output) }
|
|
89
|
+
)
|
|
90
|
+
rescue Dotenv::Merge::ParseError => e
|
|
91
|
+
failure(:merge2, request, category: :parse_error, message: e.message)
|
|
92
|
+
end
|
|
93
|
+
|
|
94
|
+
def merge3(request)
|
|
95
|
+
analyses = %i[base ours theirs].to_h do |role|
|
|
96
|
+
[role, analyze_role(role, request.fetch(:"#{role}_source"))]
|
|
97
|
+
end
|
|
98
|
+
exact_role = exact_revision_role(analyses)
|
|
99
|
+
if exact_role
|
|
100
|
+
winner = analyses.fetch(exact_role)
|
|
101
|
+
return unsafe_conflict(request, analyses, [winner]) unless winner.issues.empty?
|
|
102
|
+
|
|
103
|
+
return render_exact(request, analyses, exact_role)
|
|
104
|
+
end
|
|
105
|
+
|
|
106
|
+
unsafe = analyses.values.reject { |analysis| analysis.issues.empty? }
|
|
107
|
+
return unsafe_conflict(request, analyses, unsafe) unless unsafe.empty?
|
|
108
|
+
return unowned_conflict(request, analyses) unless unowned_unchanged?(analyses)
|
|
109
|
+
|
|
110
|
+
decisions = three_way_decisions(analyses)
|
|
111
|
+
conflicts = decisions.select(&:conflict)
|
|
112
|
+
return render_conflicts(request, analyses, decisions, conflicts) unless conflicts.empty?
|
|
113
|
+
|
|
114
|
+
render_composite(request, analyses, decisions)
|
|
115
|
+
end
|
|
116
|
+
|
|
117
|
+
private
|
|
118
|
+
|
|
119
|
+
def analyze_role(role, source)
|
|
120
|
+
file = FileAnalysis.new(source)
|
|
121
|
+
lines = source.lines.each_index.map { |index| file.line_at(index + 1) }
|
|
122
|
+
assignments = file.all_assignments
|
|
123
|
+
grouped = assignments.group_by(&:key)
|
|
124
|
+
issues = file.structural_diagnostics.dup
|
|
125
|
+
grouped.each do |key, values|
|
|
126
|
+
next if values.one?
|
|
127
|
+
|
|
128
|
+
issues << { category: :duplicate_key, key: key, message: "Duplicate dotenv key #{key}" }.freeze
|
|
129
|
+
end
|
|
130
|
+
lines.select(&:invalid?).each do |line|
|
|
131
|
+
issues << {
|
|
132
|
+
category: :invalid_line,
|
|
133
|
+
line: line.line_number,
|
|
134
|
+
message: "Invalid dotenv line #{line.line_number}"
|
|
135
|
+
}.freeze
|
|
136
|
+
end
|
|
137
|
+
if file.structural_owners.any? { |owner| owner.is_a?(FreezeNode) }
|
|
138
|
+
issues << { category: :freeze_block, message: 'Freeze blocks require an exact one-sided merge' }.freeze
|
|
139
|
+
end
|
|
140
|
+
|
|
141
|
+
Analysis.new(
|
|
142
|
+
role: role,
|
|
143
|
+
source: source,
|
|
144
|
+
file: file,
|
|
145
|
+
lines: lines.freeze,
|
|
146
|
+
assignments: assignments.freeze,
|
|
147
|
+
by_key: grouped.transform_values(&:first).freeze,
|
|
148
|
+
unowned: lines.reject(&:assignment?).map(&:raw).freeze,
|
|
149
|
+
issues: issues.freeze
|
|
150
|
+
)
|
|
151
|
+
end
|
|
152
|
+
|
|
153
|
+
def analysis_payload(analysis)
|
|
154
|
+
{
|
|
155
|
+
dialect: 'dotenv',
|
|
156
|
+
backend: Dotenv::Merge::BACKEND_REFERENCE.id,
|
|
157
|
+
assignments: semantic_assignments(analysis),
|
|
158
|
+
comment_capability: analysis.file.comment_capability.to_h,
|
|
159
|
+
comment_support_style: analysis.file.comment_support_style.to_h,
|
|
160
|
+
structurally_unambiguous: analysis.issues.empty?
|
|
161
|
+
}
|
|
162
|
+
end
|
|
163
|
+
|
|
164
|
+
def semantic_assignments(analysis)
|
|
165
|
+
analysis.assignments.map do |line|
|
|
166
|
+
{ key: line.key, value: line.value, export: line.export? }.freeze
|
|
167
|
+
end.freeze
|
|
168
|
+
end
|
|
169
|
+
|
|
170
|
+
def line_state(line)
|
|
171
|
+
line && [line.key, line.value, line.export?, line.raw]
|
|
172
|
+
end
|
|
173
|
+
|
|
174
|
+
def exact_revision_role(analyses)
|
|
175
|
+
return :ours if analyses[:ours].source == analyses[:theirs].source
|
|
176
|
+
return :ours if analyses[:base].source == analyses[:theirs].source
|
|
177
|
+
|
|
178
|
+
:theirs if analyses[:base].source == analyses[:ours].source
|
|
179
|
+
end
|
|
180
|
+
|
|
181
|
+
def render_exact(request, analyses, role)
|
|
182
|
+
rendered = render_plan(request, [whole_source_fragment(role, analyses.fetch(role).source)])
|
|
183
|
+
verification = verify_output(rendered.content, analyses.fetch(role).assignments, role)
|
|
184
|
+
result(
|
|
185
|
+
:merge3,
|
|
186
|
+
request,
|
|
187
|
+
output: rendered.content,
|
|
188
|
+
changes: assignment_changes(analyses[:base], analyses.fetch(role)),
|
|
189
|
+
diagnostics: diagnostics_for(*analyses.values),
|
|
190
|
+
render_report: render_report(rendered, :exact_revision),
|
|
191
|
+
verification: verification.merge(base_participated: true)
|
|
192
|
+
)
|
|
193
|
+
end
|
|
194
|
+
|
|
195
|
+
def assignment_changes(before, after)
|
|
196
|
+
ordered_keys(before, after).filter_map do |key|
|
|
197
|
+
left = before.by_key[key]
|
|
198
|
+
right = after.by_key[key]
|
|
199
|
+
next if line_state(left) == line_state(right)
|
|
200
|
+
|
|
201
|
+
{
|
|
202
|
+
path: "/assignments/#{key}",
|
|
203
|
+
ours: :unchanged,
|
|
204
|
+
theirs: change_kind(left, right)
|
|
205
|
+
}.freeze
|
|
206
|
+
end.freeze
|
|
207
|
+
end
|
|
208
|
+
|
|
209
|
+
def ordered_keys(*analyses)
|
|
210
|
+
analyses.flat_map { |analysis| analysis.assignments.map(&:key) }.uniq
|
|
211
|
+
end
|
|
212
|
+
|
|
213
|
+
def change_kind(before, after)
|
|
214
|
+
return :added unless before
|
|
215
|
+
return :deleted unless after
|
|
216
|
+
|
|
217
|
+
:edited
|
|
218
|
+
end
|
|
219
|
+
|
|
220
|
+
def three_way_decisions(analyses)
|
|
221
|
+
ordered_keys(analyses[:ours], analyses[:theirs], analyses[:base]).map do |key|
|
|
222
|
+
base = analyses[:base].by_key[key]
|
|
223
|
+
ours = analyses[:ours].by_key[key]
|
|
224
|
+
theirs = analyses[:theirs].by_key[key]
|
|
225
|
+
base_semantic = line_state(base)
|
|
226
|
+
ours_semantic = line_state(ours)
|
|
227
|
+
theirs_semantic = line_state(theirs)
|
|
228
|
+
selected_role =
|
|
229
|
+
if ours_semantic == theirs_semantic
|
|
230
|
+
:ours
|
|
231
|
+
elsif ours_semantic == base_semantic
|
|
232
|
+
:theirs
|
|
233
|
+
elsif theirs_semantic == base_semantic
|
|
234
|
+
:ours
|
|
235
|
+
end
|
|
236
|
+
classification = {
|
|
237
|
+
path: "/assignments/#{key}",
|
|
238
|
+
ours: side_change(base, ours),
|
|
239
|
+
theirs: side_change(base, theirs)
|
|
240
|
+
}.freeze
|
|
241
|
+
Decision.new(
|
|
242
|
+
key: key,
|
|
243
|
+
base: base,
|
|
244
|
+
ours: ours,
|
|
245
|
+
theirs: theirs,
|
|
246
|
+
selected_role: selected_role,
|
|
247
|
+
classification: classification,
|
|
248
|
+
conflict: selected_role.nil?
|
|
249
|
+
)
|
|
250
|
+
end.freeze
|
|
251
|
+
end
|
|
252
|
+
|
|
253
|
+
def side_change(base, side)
|
|
254
|
+
return :unchanged if line_state(base) == line_state(side)
|
|
255
|
+
|
|
256
|
+
change_kind(base, side)
|
|
257
|
+
end
|
|
258
|
+
|
|
259
|
+
def unowned_unchanged?(analyses)
|
|
260
|
+
anchor_keys = analyses.values.map { |analysis| analysis.by_key.keys }.reduce(&:intersection)
|
|
261
|
+
signatures = analyses.transform_values { |analysis| unmanaged_signature(analysis, anchor_keys) }
|
|
262
|
+
signatures[:base] == signatures[:ours] && signatures[:base] == signatures[:theirs]
|
|
263
|
+
end
|
|
264
|
+
|
|
265
|
+
def unmanaged_signature(analysis, anchor_keys)
|
|
266
|
+
analysis.lines.filter_map do |line|
|
|
267
|
+
if line.assignment?
|
|
268
|
+
[:assignment, line.key] if anchor_keys.include?(line.key)
|
|
269
|
+
else
|
|
270
|
+
[:unmanaged, line.raw]
|
|
271
|
+
end
|
|
272
|
+
end
|
|
273
|
+
end
|
|
274
|
+
|
|
275
|
+
def render_composite(request, analyses, decisions)
|
|
276
|
+
by_key = decisions.to_h { |decision| [decision.key, decision] }
|
|
277
|
+
fragments = []
|
|
278
|
+
selected = []
|
|
279
|
+
analyses[:ours].lines.each do |line|
|
|
280
|
+
if line.assignment?
|
|
281
|
+
decision = by_key.fetch(line.key)
|
|
282
|
+
next unless decision.selected_role
|
|
283
|
+
|
|
284
|
+
selected_line = decision.public_send(decision.selected_role)
|
|
285
|
+
next unless selected_line
|
|
286
|
+
|
|
287
|
+
fragments << line_fragment(decision.selected_role, selected_line.line_number, decision.key)
|
|
288
|
+
selected << selected_assignment(decision, selected_line)
|
|
289
|
+
else
|
|
290
|
+
fragments << line_fragment(:ours, line.line_number)
|
|
291
|
+
end
|
|
292
|
+
end
|
|
293
|
+
decisions.each do |decision|
|
|
294
|
+
next if decision.ours || decision.selected_role != :theirs || !decision.theirs
|
|
295
|
+
|
|
296
|
+
fragments << line_fragment(:theirs, decision.theirs.line_number, decision.key)
|
|
297
|
+
selected << selected_assignment(decision, decision.theirs)
|
|
298
|
+
end
|
|
299
|
+
|
|
300
|
+
rendered = render_plan(request, line_bounded_fragments(fragments, analyses))
|
|
301
|
+
expected = selected.map { |item| item.fetch(:semantic) }
|
|
302
|
+
verification = verify_composite(rendered.content, expected, selected)
|
|
303
|
+
return verification_failure(request, rendered, decisions, verification) unless verification[:semantic_match]
|
|
304
|
+
|
|
305
|
+
result(
|
|
306
|
+
:merge3,
|
|
307
|
+
request,
|
|
308
|
+
output: rendered.content,
|
|
309
|
+
changes: decisions.map(&:classification),
|
|
310
|
+
render_report: render_report(rendered, :exact_assignment_composite),
|
|
311
|
+
verification: verification.merge(base_participated: true)
|
|
312
|
+
)
|
|
313
|
+
end
|
|
314
|
+
|
|
315
|
+
def selected_assignment(decision, line)
|
|
316
|
+
{
|
|
317
|
+
key: decision.key,
|
|
318
|
+
source_role: decision.selected_role,
|
|
319
|
+
source_line: line.line_number,
|
|
320
|
+
source: line.raw,
|
|
321
|
+
semantic: { key: line.key, value: line.value, export: line.export? }.freeze
|
|
322
|
+
}.freeze
|
|
323
|
+
end
|
|
324
|
+
|
|
325
|
+
def fragment_content(fragment, analyses)
|
|
326
|
+
return fragment.content if fragment.is_a?(Ast::Merge::SourceRender::SynthesizedFragment)
|
|
327
|
+
|
|
328
|
+
analyses.fetch(fragment.revision).source.lines[fragment.start_line - 1].to_s
|
|
329
|
+
end
|
|
330
|
+
|
|
331
|
+
def line_bounded_fragments(fragments, analyses)
|
|
332
|
+
fragments.each_with_index.flat_map do |fragment, index|
|
|
333
|
+
next [fragment] if index == fragments.length - 1
|
|
334
|
+
next [fragment] if fragment.is_a?(Ast::Merge::SourceRender::ConflictFragment)
|
|
335
|
+
next [fragment] if fragment_content(fragment, analyses).end_with?("\n")
|
|
336
|
+
|
|
337
|
+
content = fragment_content(fragment, analyses)
|
|
338
|
+
[
|
|
339
|
+
Ast::Merge::SourceRender::SynthesizedFragment.new(
|
|
340
|
+
content: "#{content}\n",
|
|
341
|
+
reason: :assignment_separator,
|
|
342
|
+
producer: provider_id,
|
|
343
|
+
metadata: fragment.metadata.merge(copied_source: true)
|
|
344
|
+
)
|
|
345
|
+
]
|
|
346
|
+
end
|
|
347
|
+
end
|
|
348
|
+
|
|
349
|
+
def verify_composite(output, expected, selected)
|
|
350
|
+
parsed = analyze_role(:output, output)
|
|
351
|
+
actual = semantic_assignments(parsed)
|
|
352
|
+
source_match = parsed.assignments.map(&:raw) == selected.map { |item| item.fetch(:source) }
|
|
353
|
+
{
|
|
354
|
+
output_reparsed: true,
|
|
355
|
+
semantic_match: parsed.issues.empty? && actual == expected && source_match,
|
|
356
|
+
source_match: source_match,
|
|
357
|
+
ordered_assignments: actual,
|
|
358
|
+
assignment_sources: selected.map { |item| item.except(:semantic, :source) }
|
|
359
|
+
}
|
|
360
|
+
end
|
|
361
|
+
|
|
362
|
+
def verify_output(output, expected_lines, role)
|
|
363
|
+
parsed = analyze_role(:output, output)
|
|
364
|
+
expected = expected_lines.map { |line| { key: line.key, value: line.value, export: line.export? }.freeze }
|
|
365
|
+
{
|
|
366
|
+
output_reparsed: true,
|
|
367
|
+
semantic_match: semantic_assignments(parsed) == expected,
|
|
368
|
+
structurally_unambiguous: parsed.issues.empty?,
|
|
369
|
+
ordered_assignments: semantic_assignments(parsed),
|
|
370
|
+
source_role: role
|
|
371
|
+
}
|
|
372
|
+
end
|
|
373
|
+
|
|
374
|
+
def render_conflicts(request, analyses, decisions, conflicts)
|
|
375
|
+
if conflicts.any? { |item| item.ours.nil? }
|
|
376
|
+
return full_file_conflict(
|
|
377
|
+
request,
|
|
378
|
+
analyses,
|
|
379
|
+
decisions,
|
|
380
|
+
conflicts,
|
|
381
|
+
:conflict_not_line_addressable
|
|
382
|
+
)
|
|
383
|
+
end
|
|
384
|
+
|
|
385
|
+
by_key = conflicts.to_h { |conflict| [conflict.key, conflict] }
|
|
386
|
+
fragments = analyses[:ours].lines.map do |line|
|
|
387
|
+
conflict = line.assignment? && by_key[line.key]
|
|
388
|
+
conflict ? conflict_fragment(request, conflict) : line_fragment(:ours, line.line_number)
|
|
389
|
+
end
|
|
390
|
+
rendered = render_plan(request, fragments)
|
|
391
|
+
conflict_failure(request, decisions, conflicts, rendered, :assignment_localized_conflict)
|
|
392
|
+
end
|
|
393
|
+
|
|
394
|
+
def conflict_fragment(request, decision)
|
|
395
|
+
Ast::Merge::SourceRender::ConflictFragment.new(
|
|
396
|
+
conflict_id: conflict_id(decision.key),
|
|
397
|
+
base: conflict_side(request, :base, decision.base),
|
|
398
|
+
ours: conflict_side(request, :ours, decision.ours),
|
|
399
|
+
theirs: conflict_side(request, :theirs, decision.theirs),
|
|
400
|
+
labels: request.fetch(:labels, {}),
|
|
401
|
+
marker_size: request.fetch(:conflict_marker_size, 7),
|
|
402
|
+
metadata: { path: decision.classification.fetch(:path), category: conflict_category(decision) }
|
|
403
|
+
)
|
|
404
|
+
end
|
|
405
|
+
|
|
406
|
+
def conflict_side(request, role, line)
|
|
407
|
+
return [] unless line
|
|
408
|
+
|
|
409
|
+
fragment = line_fragment(role, line.line_number, line.key)
|
|
410
|
+
source_line = request.fetch(:"#{role}_source").lines[line.line_number - 1].to_s
|
|
411
|
+
return [fragment] if source_line.end_with?("\n")
|
|
412
|
+
|
|
413
|
+
[
|
|
414
|
+
Ast::Merge::SourceRender::SynthesizedFragment.new(
|
|
415
|
+
content: "#{source_line}\n",
|
|
416
|
+
reason: :conflict_line_boundary,
|
|
417
|
+
producer: provider_id,
|
|
418
|
+
metadata: { source_role: role, assignment_key: line.key, copied_source: true }
|
|
419
|
+
)
|
|
420
|
+
]
|
|
421
|
+
end
|
|
422
|
+
|
|
423
|
+
def conflict_category(decision)
|
|
424
|
+
return :delete_edit if decision.ours.nil? || decision.theirs.nil?
|
|
425
|
+
|
|
426
|
+
:edit_edit
|
|
427
|
+
end
|
|
428
|
+
|
|
429
|
+
def conflict_id(key)
|
|
430
|
+
"dotenv-assignment-#{Digest::SHA256.hexdigest(key)[0, 16]}"
|
|
431
|
+
end
|
|
432
|
+
|
|
433
|
+
def conflict_failure(request, decisions, conflicts, rendered, strategy)
|
|
434
|
+
failure(
|
|
435
|
+
:merge3,
|
|
436
|
+
request,
|
|
437
|
+
category: :merge_conflict,
|
|
438
|
+
message: 'Dotenv assignments changed incompatibly.',
|
|
439
|
+
changes: decisions.map(&:classification),
|
|
440
|
+
conflicts: conflicts.map do |decision|
|
|
441
|
+
{
|
|
442
|
+
conflict_id: conflict_id(decision.key),
|
|
443
|
+
category: conflict_category(decision),
|
|
444
|
+
path: decision.classification.fetch(:path),
|
|
445
|
+
change_classification: decision.classification
|
|
446
|
+
}.freeze
|
|
447
|
+
end,
|
|
448
|
+
conflicted_output: rendered.content,
|
|
449
|
+
conflicted_source: rendered.content,
|
|
450
|
+
render_report: render_report(rendered, strategy),
|
|
451
|
+
verification: { base_participated: true }
|
|
452
|
+
)
|
|
453
|
+
end
|
|
454
|
+
|
|
455
|
+
def unsafe_conflict(request, analyses, unsafe)
|
|
456
|
+
source_role = unsafe.first.role
|
|
457
|
+
diagnostics = [{
|
|
458
|
+
category: :parse_error,
|
|
459
|
+
severity: :error,
|
|
460
|
+
message: "#{source_role} structural parse is ambiguous.",
|
|
461
|
+
blocking: true,
|
|
462
|
+
source_role: source_role
|
|
463
|
+
}.freeze, *diagnostics_for(*unsafe)]
|
|
464
|
+
full_file_conflict(
|
|
465
|
+
request,
|
|
466
|
+
analyses,
|
|
467
|
+
three_way_decisions(analyses),
|
|
468
|
+
[],
|
|
469
|
+
:structural_ambiguity,
|
|
470
|
+
diagnostics: diagnostics
|
|
471
|
+
)
|
|
472
|
+
end
|
|
473
|
+
|
|
474
|
+
def unowned_conflict(request, analyses)
|
|
475
|
+
full_file_conflict(
|
|
476
|
+
request,
|
|
477
|
+
analyses,
|
|
478
|
+
three_way_decisions(analyses),
|
|
479
|
+
[],
|
|
480
|
+
:unmanaged_source_change,
|
|
481
|
+
diagnostics: [{
|
|
482
|
+
category: :unmanaged_source_change,
|
|
483
|
+
severity: :error,
|
|
484
|
+
message: 'Non-assignment dotenv source changed independently.',
|
|
485
|
+
blocking: true
|
|
486
|
+
}]
|
|
487
|
+
)
|
|
488
|
+
end
|
|
489
|
+
|
|
490
|
+
def full_file_conflict(request, analyses, decisions, conflicts, reason, diagnostics: nil)
|
|
491
|
+
conflict = {
|
|
492
|
+
conflict_id: 'dotenv-conflict-root',
|
|
493
|
+
category: reason,
|
|
494
|
+
path: '<unmanaged-source>'
|
|
495
|
+
}.freeze
|
|
496
|
+
rendered = render_plan(
|
|
497
|
+
request,
|
|
498
|
+
[
|
|
499
|
+
Ast::Merge::SourceRender::ConflictFragment.new(
|
|
500
|
+
conflict_id: conflict.fetch(:conflict_id),
|
|
501
|
+
base: conflict_source(:base, analyses[:base].source),
|
|
502
|
+
ours: conflict_source(:ours, analyses[:ours].source),
|
|
503
|
+
theirs: conflict_source(:theirs, analyses[:theirs].source),
|
|
504
|
+
labels: request.fetch(:labels, {}),
|
|
505
|
+
marker_size: request.fetch(:conflict_marker_size, 7),
|
|
506
|
+
metadata: { category: reason }
|
|
507
|
+
)
|
|
508
|
+
]
|
|
509
|
+
)
|
|
510
|
+
failure(
|
|
511
|
+
:merge3,
|
|
512
|
+
request,
|
|
513
|
+
category: reason,
|
|
514
|
+
message: 'Dotenv source cannot be safely combined structurally.',
|
|
515
|
+
diagnostics: diagnostics,
|
|
516
|
+
changes: decisions.map(&:classification),
|
|
517
|
+
conflicts: conflicts.empty? ? [conflict] : conflicts.map { |item| conflict_payload(item) },
|
|
518
|
+
fallbacks: [{ from: :assignment_composite, to: :full_file_conflict, reason: reason }],
|
|
519
|
+
conflicted_output: rendered.content,
|
|
520
|
+
conflicted_source: rendered.content,
|
|
521
|
+
render_report: render_report(rendered, :full_file_conflict),
|
|
522
|
+
verification: { base_participated: true },
|
|
523
|
+
source_role: diagnostics&.first&.fetch(:source_role, nil)
|
|
524
|
+
)
|
|
525
|
+
end
|
|
526
|
+
|
|
527
|
+
def conflict_source(role, source)
|
|
528
|
+
return [] if source.empty?
|
|
529
|
+
return [whole_source_fragment(role, source)] if source.end_with?("\n")
|
|
530
|
+
|
|
531
|
+
[
|
|
532
|
+
Ast::Merge::SourceRender::SynthesizedFragment.new(
|
|
533
|
+
content: "#{source}\n",
|
|
534
|
+
reason: :conflict_line_boundary,
|
|
535
|
+
producer: provider_id,
|
|
536
|
+
metadata: { source_role: role, copied_source: true }
|
|
537
|
+
)
|
|
538
|
+
]
|
|
539
|
+
end
|
|
540
|
+
|
|
541
|
+
def conflict_payload(decision)
|
|
542
|
+
return decision if decision.is_a?(Hash)
|
|
543
|
+
|
|
544
|
+
{
|
|
545
|
+
conflict_id: conflict_id(decision.key),
|
|
546
|
+
category: conflict_category(decision),
|
|
547
|
+
path: decision.classification.fetch(:path),
|
|
548
|
+
change_classification: decision.classification
|
|
549
|
+
}.freeze
|
|
550
|
+
end
|
|
551
|
+
|
|
552
|
+
def whole_source_fragment(role, source)
|
|
553
|
+
if source.empty?
|
|
554
|
+
return Ast::Merge::SourceRender::SynthesizedFragment.new(
|
|
555
|
+
content: '',
|
|
556
|
+
reason: :exact_empty_source,
|
|
557
|
+
producer: provider_id,
|
|
558
|
+
metadata: { source_role: role }
|
|
559
|
+
)
|
|
560
|
+
end
|
|
561
|
+
|
|
562
|
+
Ast::Merge::SourceRender::SourceFragment.new(
|
|
563
|
+
revision: role,
|
|
564
|
+
start_line: 1,
|
|
565
|
+
end_line: source.lines.length,
|
|
566
|
+
metadata: { source_role: role }
|
|
567
|
+
)
|
|
568
|
+
end
|
|
569
|
+
|
|
570
|
+
def line_fragment(role, line_number, key = nil)
|
|
571
|
+
Ast::Merge::SourceRender::SourceFragment.new(
|
|
572
|
+
revision: role,
|
|
573
|
+
start_line: line_number,
|
|
574
|
+
end_line: line_number,
|
|
575
|
+
metadata: { source_role: role, assignment_key: key }.compact
|
|
576
|
+
)
|
|
577
|
+
end
|
|
578
|
+
|
|
579
|
+
def render_plan(request, fragments)
|
|
580
|
+
Ast::Merge::SourceRender::Renderer.new.render(
|
|
581
|
+
Ast::Merge::SourceRender::Plan.new(
|
|
582
|
+
sources: {
|
|
583
|
+
base: request[:base_source].to_s,
|
|
584
|
+
ours: request[:ours_source].to_s,
|
|
585
|
+
theirs: request[:theirs_source].to_s
|
|
586
|
+
},
|
|
587
|
+
fragments: fragments,
|
|
588
|
+
metadata: { provider_id: provider_id }
|
|
589
|
+
)
|
|
590
|
+
)
|
|
591
|
+
end
|
|
592
|
+
|
|
593
|
+
def render_report(rendered, strategy)
|
|
594
|
+
{
|
|
595
|
+
strategy: strategy,
|
|
596
|
+
line_records: rendered.line_records,
|
|
597
|
+
synthesized_fragments: rendered.synthesized_fragments,
|
|
598
|
+
conflicts: rendered.conflicts,
|
|
599
|
+
verification_input: rendered.verification_input
|
|
600
|
+
}
|
|
601
|
+
end
|
|
602
|
+
|
|
603
|
+
def diagnostics_for(*analyses)
|
|
604
|
+
analyses.flat_map do |analysis|
|
|
605
|
+
analysis.issues.map do |issue|
|
|
606
|
+
issue.merge(
|
|
607
|
+
severity: :warning,
|
|
608
|
+
blocking: false,
|
|
609
|
+
source_role: analysis.role
|
|
610
|
+
).freeze
|
|
611
|
+
end
|
|
612
|
+
end.freeze
|
|
613
|
+
end
|
|
614
|
+
|
|
615
|
+
def unsafe_failure(operation, request, analyses, message:)
|
|
616
|
+
failure(
|
|
617
|
+
operation,
|
|
618
|
+
request,
|
|
619
|
+
category: :structural_ambiguity,
|
|
620
|
+
message: message,
|
|
621
|
+
diagnostics: diagnostics_for(*analyses)
|
|
622
|
+
)
|
|
623
|
+
end
|
|
624
|
+
|
|
625
|
+
def verification_failure(request, rendered, decisions, verification)
|
|
626
|
+
failure(
|
|
627
|
+
:merge3,
|
|
628
|
+
request,
|
|
629
|
+
category: :verification_failure,
|
|
630
|
+
message: 'Rendered dotenv assignments did not match the planned ordered semantics.',
|
|
631
|
+
changes: decisions.map(&:classification),
|
|
632
|
+
render_report: render_report(rendered, :exact_assignment_composite),
|
|
633
|
+
verification: verification.merge(base_participated: true)
|
|
634
|
+
)
|
|
635
|
+
end
|
|
636
|
+
|
|
637
|
+
def result(operation, request, diagnostics: [], changes: [], conflicts: [], fallbacks: [],
|
|
638
|
+
render_report: {}, verification: {}, **payload)
|
|
639
|
+
Ast::Merge::ProviderResult.build(
|
|
640
|
+
operation: operation,
|
|
641
|
+
success: true,
|
|
642
|
+
envelope: envelope(
|
|
643
|
+
request,
|
|
644
|
+
diagnostics: diagnostics,
|
|
645
|
+
changes: changes,
|
|
646
|
+
conflicts: conflicts,
|
|
647
|
+
fallbacks: fallbacks,
|
|
648
|
+
render_report: render_report,
|
|
649
|
+
verification: verification
|
|
650
|
+
),
|
|
651
|
+
**payload
|
|
652
|
+
)
|
|
653
|
+
end
|
|
654
|
+
|
|
655
|
+
def failure(operation, request, category:, message:, diagnostics: nil, changes: [], conflicts: [],
|
|
656
|
+
fallbacks: [], render_report: {}, verification: {}, **payload)
|
|
657
|
+
diagnostics ||= [{ category: category, severity: :error, message: message, blocking: true }]
|
|
658
|
+
Ast::Merge::ProviderResult.build(
|
|
659
|
+
operation: operation,
|
|
660
|
+
success: false,
|
|
661
|
+
envelope: envelope(
|
|
662
|
+
request,
|
|
663
|
+
diagnostics: diagnostics,
|
|
664
|
+
changes: changes,
|
|
665
|
+
conflicts: conflicts,
|
|
666
|
+
fallbacks: fallbacks,
|
|
667
|
+
render_report: render_report,
|
|
668
|
+
verification: verification
|
|
669
|
+
),
|
|
670
|
+
**payload
|
|
671
|
+
)
|
|
672
|
+
end
|
|
673
|
+
|
|
674
|
+
def envelope(request, **fields)
|
|
675
|
+
{
|
|
676
|
+
provider: {
|
|
677
|
+
provider_id: provider_id,
|
|
678
|
+
family: family,
|
|
679
|
+
dialect: request[:dialect] || :dotenv,
|
|
680
|
+
backend: request[:backend] || Dotenv::Merge::BACKEND_REFERENCE.id,
|
|
681
|
+
package: 'dotenv-merge',
|
|
682
|
+
package_version: Dotenv::Merge::Version::VERSION
|
|
683
|
+
},
|
|
684
|
+
profile: { profile_id: request[:profile_id] || DEFAULT_PROFILE }
|
|
685
|
+
}.merge(fields)
|
|
686
|
+
end
|
|
687
|
+
end
|
|
688
|
+
# rubocop:enable Metrics/AbcSize, Metrics/BlockLength, Metrics/ClassLength, Metrics/CyclomaticComplexity, Metrics/MethodLength, Metrics/ParameterLists, Metrics/PerceivedComplexity
|
|
689
|
+
end
|
|
690
|
+
end
|