dotenv-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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 9fede421615f3ff3429b4cf53e1cc546ddc9baad1c8d45188568519d0b2d8e2d
4
- data.tar.gz: 47d52ac38e22ea37ec80b688d754a56b6b35738272bedb09a874aaa3c5e43b89
3
+ metadata.gz: 46e142ff15dc1e96e72670426fb814afdef0bcc45ad9debb6053e0bec292470f
4
+ data.tar.gz: 74b5ab3adb662871181f0127296814062dcb8eb5754a26aeca939431439261c4
5
5
  SHA512:
6
- metadata.gz: 9dbbb5092feeb59fbc406d3df3153258a5f17999ba51078c3d4e418fd4897d5e7bd92028b3bae6b10ec93f5b4941b225d46a017e83a57b9652e62a4ed9c04edb
7
- data.tar.gz: 0a1e1f19518106ad1d08b2e0097a2fc0b3e928a0816db6fde0cd0abae13ed651f3422d9845464b65cee6b7f3ca3f0cbafdb90aca1970a4606c7a34bc1b1b38c6
6
+ metadata.gz: 0cb95e7ceb06abc054212ceb2889924fd7d7f922c58e06803dd1d2d2c15c08142bffb1172352397b3b5c0acf2e36dbcd4bf4101fa6212752bff6572a64c4023e
7
+ data.tar.gz: afaef8d76571e4c56f8479bb4d787e35a2fe5a9648bf5f77995ab11ea8823dface498f83431ee5617fc68d3fee19f54b7904da163e7b097dbe9e707bcebacce2
checksums.yaml.gz.sig CHANGED
Binary file
data/README.md CHANGED
@@ -85,7 +85,7 @@ File.write("merged.env", result.to_s)
85
85
 
86
86
  ### Compatibility
87
87
 
88
- Compatible with MRI Ruby 4.0.0+, and concordant releases of JRuby, and TruffleRuby.
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
@@ -223,20 +223,12 @@ See [SECURITY.md][🔐security].
223
223
  ## 🤝 Contributing
224
224
 
225
225
  If you need some ideas of where to help, you could work on adding more code coverage,
226
- or if it is already 💯 (see [below](#code-coverage)) check [issues][🤝gh-issues] or [PRs][🤝gh-pulls],
227
- or use the gem and think about how it could be better.
226
+ check [issues][🤝gh-issues] or [PRs][🤝gh-pulls], or use the gem and think about how it could be better.
228
227
 
229
228
  We [![Keep A Changelog][📗keep-changelog-img]][📗keep-changelog] so if you make changes, remember to update it.
230
229
 
231
230
  See [CONTRIBUTING.md][🤝contributing] for more detailed instructions.
232
231
 
233
- ### Code Coverage
234
-
235
- <details markdown="1">
236
- <summary>Coverage service badges</summary>
237
-
238
- </details>
239
-
240
232
  ## 📌 Versioning
241
233
 
242
234
  This library follows [![Semantic Versioning 2.0.0][📌semver-img]][📌semver] for its public API where practical.
@@ -29,7 +29,7 @@ module Dotenv
29
29
  DEFAULT_FREEZE_TOKEN = 'dotenv-merge'
30
30
 
31
31
  # @return [CommentTracker] Comment tracker for this file
32
- attr_reader :comment_tracker
32
+ attr_reader :comment_tracker, :structural_diagnostics
33
33
 
34
34
  # Initialize file analysis with dotenv parser
35
35
  #
@@ -41,6 +41,7 @@ module Dotenv
41
41
  @source = source
42
42
  @freeze_token = freeze_token
43
43
  @signature_generator = signature_generator
44
+ @structural_diagnostics = []
44
45
  # **options captures any additional parameters (e.g., node_typing) for forward compatibility
45
46
 
46
47
  # Parse all lines
@@ -285,6 +286,11 @@ module Dotenv
285
286
  case marker[:type]
286
287
  when :freeze
287
288
  if open_marker
289
+ @structural_diagnostics << {
290
+ category: :freeze_ambiguity,
291
+ line: marker[:line],
292
+ message: 'Nested freeze marker'
293
+ }.freeze
288
294
  DebugLogger.warning("Nested freeze block at line #{marker[:line]}, ignoring")
289
295
  else
290
296
  open_marker = marker
@@ -299,12 +305,24 @@ module Dotenv
299
305
  )
300
306
  open_marker = nil
301
307
  else
308
+ @structural_diagnostics << {
309
+ category: :freeze_ambiguity,
310
+ line: marker[:line],
311
+ message: 'Unfreeze marker without matching freeze marker'
312
+ }.freeze
302
313
  DebugLogger.warning("Unfreeze without freeze at line #{marker[:line]}, ignoring")
303
314
  end
304
315
  end
305
316
  end
306
317
 
307
- DebugLogger.warning("Unclosed freeze block starting at line #{open_marker[:line]}") if open_marker
318
+ if open_marker
319
+ @structural_diagnostics << {
320
+ category: :freeze_ambiguity,
321
+ line: open_marker[:line],
322
+ message: 'Unclosed freeze marker'
323
+ }.freeze
324
+ DebugLogger.warning("Unclosed freeze block starting at line #{open_marker[:line]}")
325
+ end
308
326
 
309
327
  blocks
310
328
  end
@@ -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
@@ -5,7 +5,7 @@ module Dotenv
5
5
  # Version namespace for this gem.
6
6
  module Version
7
7
  # Current gem version.
8
- VERSION = '7.1.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/dotenv/merge.rb CHANGED
@@ -69,12 +69,24 @@ module Dotenv
69
69
  autoload :FreezeNode, 'dotenv/merge/freeze_node'
70
70
  autoload :FileAnalysis, 'dotenv/merge/file_analysis'
71
71
  autoload :MergeResult, 'dotenv/merge/merge_result'
72
+ autoload :Provider, 'dotenv/merge/provider'
72
73
  autoload :SmartMerger, 'dotenv/merge/smart_merger'
74
+
75
+ module_function
76
+
77
+ def merge_provider
78
+ @merge_provider ||= Provider.new
79
+ end
80
+
81
+ def register_provider!(replace: false)
82
+ Ast::Merge.register_provider(merge_provider, replace: replace)
83
+ end
73
84
  end
74
85
  end
75
86
 
76
87
  require_relative 'merge/backend'
77
88
  Dotenv::Merge.register_backend!
89
+ Dotenv::Merge.register_provider!
78
90
 
79
91
  # Register with ast-merge's MergeGemRegistry for RSpec dependency tags
80
92
  # Only register if MergeGemRegistry is loaded (i.e., in test environment)
data/sig/dotenv/merge.rbs CHANGED
@@ -4,5 +4,24 @@ module Dotenv
4
4
  VERSION: String
5
5
  end
6
6
  VERSION: String
7
+
8
+ BACKEND_REFERENCE: untyped
9
+
10
+ def self.merge_provider: () -> Provider
11
+ def self.register_provider!: (?replace: bool) -> untyped
12
+
13
+ class Provider
14
+ def provider_id: () -> String
15
+ def family: () -> String
16
+ def capabilities: () -> Hash[Symbol, untyped]
17
+ def analyze: (Hash[Symbol, untyped] request) -> Hash[Symbol, untyped]
18
+ def diff2: (Hash[Symbol, untyped] request) -> Hash[Symbol, untyped]
19
+ def merge2: (Hash[Symbol, untyped] request) -> Hash[Symbol, untyped]
20
+ def merge3: (Hash[Symbol, untyped] request) -> Hash[Symbol, untyped]
21
+ end
22
+
23
+ class FileAnalysis
24
+ attr_reader structural_diagnostics: Array[Hash[Symbol, untyped]]
25
+ end
7
26
  end
8
27
  end
data.tar.gz.sig CHANGED
Binary file
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: dotenv-merge
3
3
  version: !ruby/object:Gem::Version
4
- version: 7.1.1
4
+ version: 7.1.3
5
5
  platform: ruby
6
6
  authors:
7
7
  - Peter H. Boling
@@ -43,42 +43,42 @@ dependencies:
43
43
  requirements:
44
44
  - - '='
45
45
  - !ruby/object:Gem::Version
46
- version: 7.1.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.1
53
+ version: 7.1.3
54
54
  - !ruby/object:Gem::Dependency
55
55
  name: plain-merge
56
56
  requirement: !ruby/object:Gem::Requirement
57
57
  requirements:
58
58
  - - '='
59
59
  - !ruby/object:Gem::Version
60
- version: 7.1.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.1
67
+ version: 7.1.3
68
68
  - !ruby/object:Gem::Dependency
69
69
  name: tree_haver
70
70
  requirement: !ruby/object:Gem::Requirement
71
71
  requirements:
72
72
  - - '='
73
73
  - !ruby/object:Gem::Version
74
- version: 7.1.1
74
+ version: 7.1.3
75
75
  type: :runtime
76
76
  prerelease: false
77
77
  version_requirements: !ruby/object:Gem::Requirement
78
78
  requirements:
79
79
  - - '='
80
80
  - !ruby/object:Gem::Version
81
- version: 7.1.1
81
+ version: 7.1.3
82
82
  - !ruby/object:Gem::Dependency
83
83
  name: version_gem
84
84
  requirement: !ruby/object:Gem::Requirement
@@ -105,20 +105,20 @@ dependencies:
105
105
  requirements:
106
106
  - - "~>"
107
107
  - !ruby/object:Gem::Version
108
- version: '2.5'
108
+ version: '3.0'
109
109
  - - ">="
110
110
  - !ruby/object:Gem::Version
111
- version: 2.5.17
111
+ version: 3.0.0
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: '2.5'
118
+ version: '3.0'
119
119
  - - ">="
120
120
  - !ruby/object:Gem::Version
121
- version: 2.5.17
121
+ version: 3.0.0
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.0
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.0
209
+ version: 3.2.2
210
210
  - !ruby/object:Gem::Dependency
211
211
  name: kettle-test
212
212
  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.4
239
+ version: 3.2.5
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.4
249
+ version: 3.2.5
250
250
  - !ruby/object:Gem::Dependency
251
251
  name: ruby-progressbar
252
252
  requirement: !ruby/object:Gem::Requirement
@@ -320,6 +320,7 @@ files:
320
320
  - lib/dotenv/merge/file_analysis.rb
321
321
  - lib/dotenv/merge/freeze_node.rb
322
322
  - lib/dotenv/merge/merge_result.rb
323
+ - lib/dotenv/merge/provider.rb
323
324
  - lib/dotenv/merge/smart_merger.rb
324
325
  - lib/dotenv/merge/version.rb
325
326
  - sig/dotenv/merge.rbs
@@ -329,10 +330,10 @@ licenses:
329
330
  - PolyForm-Small-Business-1.0.0
330
331
  metadata:
331
332
  homepage_uri: https://structuredmerge.org
332
- source_code_uri: https://github.com/structuredmerge/structuredmerge-ruby/tree/v7.1.1
333
- changelog_uri: https://github.com/structuredmerge/structuredmerge-ruby/blob/v7.1.1/CHANGELOG.md
333
+ source_code_uri: https://github.com/structuredmerge/structuredmerge-ruby/tree/v7.1.3
334
+ changelog_uri: https://github.com/structuredmerge/structuredmerge-ruby/blob/v7.1.3/CHANGELOG.md
334
335
  bug_tracker_uri: https://github.com/structuredmerge/structuredmerge-ruby/issues
335
- documentation_uri: https://www.rubydoc.info/gems/dotenv-merge/7.1.1
336
+ documentation_uri: https://www.rubydoc.info/gems/dotenv-merge/7.1.3
336
337
  funding_uri: https://github.com/sponsors/pboling
337
338
  wiki_uri: https://github.com/structuredmerge/structuredmerge-ruby/wiki
338
339
  news_uri: https://www.railsbling.com/tags/dotenv-merge
metadata.gz.sig CHANGED
Binary file