config-merge 7.1.13
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 +7 -0
- checksums.yaml.gz.sig +0 -0
- data/LICENSE.md +13 -0
- data/README.md +307 -0
- data/lib/config/merge/built_in_policies.rb +30 -0
- data/lib/config/merge/flag_file/option.rb +29 -0
- data/lib/config/merge/flag_file/parser.rb +104 -0
- data/lib/config/merge/flag_file/result.rb +32 -0
- data/lib/config/merge/flag_file/three_way_merger.rb +244 -0
- data/lib/config/merge/normalizer_registry.rb +57 -0
- data/lib/config/merge/policies/rspec.yml +24 -0
- data/lib/config/merge/policy.rb +110 -0
- data/lib/config/merge/provider_adapter.rb +39 -0
- data/lib/config/merge/version.rb +13 -0
- data/lib/config/merge.rb +25 -0
- data/sig/config/merge.rbs +90 -0
- data.tar.gz.sig +0 -0
- metadata +340 -0
- metadata.gz.sig +0 -0
|
@@ -0,0 +1,244 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "parser"
|
|
4
|
+
require_relative "result"
|
|
5
|
+
|
|
6
|
+
module Config
|
|
7
|
+
module Merge
|
|
8
|
+
module FlagFile
|
|
9
|
+
# Merges option records using base-aware per-flag policy.
|
|
10
|
+
#
|
|
11
|
+
# The conventional roles are base, ours (the destination/current side), and
|
|
12
|
+
# theirs (the incoming side). Rules compare normalized values while output
|
|
13
|
+
# retains original option spelling, argument text, comments, and blank lines.
|
|
14
|
+
class ThreeWayMerger
|
|
15
|
+
attr_reader :base, :ours, :theirs, :policy
|
|
16
|
+
|
|
17
|
+
def initialize(base:, ours:, theirs:, policy:)
|
|
18
|
+
@parser = Parser.new
|
|
19
|
+
@base = parse(base)
|
|
20
|
+
@ours = parse(ours)
|
|
21
|
+
@theirs = parse(theirs)
|
|
22
|
+
@policy = policy
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
def merge
|
|
26
|
+
decisions = merge_decisions
|
|
27
|
+
partial = render(decisions.fetch(:selected))
|
|
28
|
+
conflicts = decisions.fetch(:conflicts)
|
|
29
|
+
Result.new(output: conflicts.empty? ? partial : nil, conflicts: conflicts, partial_output: partial)
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
private
|
|
33
|
+
|
|
34
|
+
def merge_decisions
|
|
35
|
+
DecisionEngine.new(base: base, ours: ours, theirs: theirs, policy: policy).merge
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
def render(selected)
|
|
39
|
+
output = OutputRenderer.new(selected, base: base, ours: ours, theirs: theirs, policy: policy).render
|
|
40
|
+
output += "\n" if !output.empty? && trailing_newline?
|
|
41
|
+
output
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
def parse(value)
|
|
45
|
+
value.is_a?(Document) ? value : @parser.parse(value)
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
def trailing_newline?
|
|
49
|
+
return ours.trailing_newline if ours.trailing_newline == theirs.trailing_newline
|
|
50
|
+
return theirs.trailing_newline if ours.trailing_newline == base.trailing_newline
|
|
51
|
+
|
|
52
|
+
ours.trailing_newline
|
|
53
|
+
end
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
# Makes per-flag base/ours/theirs decisions.
|
|
57
|
+
class DecisionEngine
|
|
58
|
+
def initialize(base:, ours:, theirs:, policy:)
|
|
59
|
+
@base = base
|
|
60
|
+
@ours = ours
|
|
61
|
+
@theirs = theirs
|
|
62
|
+
@policy = policy
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
def merge
|
|
66
|
+
selected = {}
|
|
67
|
+
conflicts = []
|
|
68
|
+
flags.each do |flag|
|
|
69
|
+
chosen, conflict = choose(flag, options(@base, flag), options(@ours, flag), options(@theirs, flag))
|
|
70
|
+
selected[flag] = chosen if chosen
|
|
71
|
+
conflicts << conflict if conflict
|
|
72
|
+
end
|
|
73
|
+
{ selected: selected, conflicts: conflicts }
|
|
74
|
+
end
|
|
75
|
+
|
|
76
|
+
private
|
|
77
|
+
|
|
78
|
+
def flags
|
|
79
|
+
(@base.options + @ours.options + @theirs.options).map(&:flag).uniq
|
|
80
|
+
end
|
|
81
|
+
|
|
82
|
+
def options(document, flag)
|
|
83
|
+
document.options.select { |option| option.flag == flag }
|
|
84
|
+
end
|
|
85
|
+
|
|
86
|
+
def choose(flag, base, ours, theirs)
|
|
87
|
+
rule = @policy.rule_for(flag)
|
|
88
|
+
base_key = semantic_options(base, rule)
|
|
89
|
+
ours_key = semantic_options(ours, rule)
|
|
90
|
+
theirs_key = semantic_options(theirs, rule)
|
|
91
|
+
return [sourced(ours, :ours), nil] if ours_key == theirs_key || theirs_key == base_key
|
|
92
|
+
return [sourced(theirs, :theirs), nil] if ours_key == base_key
|
|
93
|
+
if rule.cardinality == :repeatable && rule.on_conflict == :union
|
|
94
|
+
return [merge_repeatable(base, ours, theirs, rule), nil]
|
|
95
|
+
end
|
|
96
|
+
|
|
97
|
+
resolve_conflict(flag, base, ours, theirs, rule)
|
|
98
|
+
end
|
|
99
|
+
|
|
100
|
+
def resolve_conflict(flag, base, ours, theirs, rule)
|
|
101
|
+
case rule.on_conflict
|
|
102
|
+
when :ours then [sourced(ours, :ours), nil]
|
|
103
|
+
when :theirs then [sourced(theirs, :theirs), nil]
|
|
104
|
+
when :base then [sourced(base, :base), nil]
|
|
105
|
+
when :union then [stable_union(ours, theirs, rule), nil]
|
|
106
|
+
when :conflict then [nil, Conflict.new(flag: flag, base: base, ours: ours, theirs: theirs)]
|
|
107
|
+
end
|
|
108
|
+
end
|
|
109
|
+
|
|
110
|
+
def merge_repeatable(base, ours, theirs, rule)
|
|
111
|
+
counts = [base, ours, theirs].map { |options| option_counts(options, rule) }
|
|
112
|
+
keys = counts.flat_map(&:keys).uniq
|
|
113
|
+
targets = keys.to_h { |key| [key, target_count(key, counts)] }
|
|
114
|
+
select_records(targets, [[ours, :ours], [theirs, :theirs], [base, :base]], rule)
|
|
115
|
+
end
|
|
116
|
+
|
|
117
|
+
def target_count(key, counts)
|
|
118
|
+
base_count, ours_count, theirs_count = counts.map { |count| count.fetch(key, 0) }
|
|
119
|
+
removed = [base_count - ours_count, base_count - theirs_count, 0].max
|
|
120
|
+
added = [ours_count - base_count, theirs_count - base_count, 0].max
|
|
121
|
+
[base_count - removed, 0].max + added
|
|
122
|
+
end
|
|
123
|
+
|
|
124
|
+
def stable_union(ours, theirs, rule)
|
|
125
|
+
seen = {}
|
|
126
|
+
sourced(ours, :ours).concat(sourced(theirs, :theirs)).each_with_object([]) do |(option, source), selected|
|
|
127
|
+
key = semantic_key(option, rule)
|
|
128
|
+
next if seen[key]
|
|
129
|
+
|
|
130
|
+
seen[key] = true
|
|
131
|
+
selected << [option, source]
|
|
132
|
+
end
|
|
133
|
+
end
|
|
134
|
+
|
|
135
|
+
def select_records(targets, sources, rule)
|
|
136
|
+
remaining = targets.dup
|
|
137
|
+
sources.each_with_object([]) do |(options, source), selected|
|
|
138
|
+
options.each do |option|
|
|
139
|
+
key = semantic_key(option, rule)
|
|
140
|
+
next unless remaining.fetch(key, 0).positive?
|
|
141
|
+
|
|
142
|
+
selected << [option, source]
|
|
143
|
+
remaining[key] -= 1
|
|
144
|
+
end
|
|
145
|
+
end
|
|
146
|
+
end
|
|
147
|
+
|
|
148
|
+
def sourced(options, source)
|
|
149
|
+
options.map { |option| [option, source] }
|
|
150
|
+
end
|
|
151
|
+
|
|
152
|
+
def option_counts(options, rule)
|
|
153
|
+
options.each_with_object(Hash.new(0)) { |option, counts| counts[semantic_key(option, rule)] += 1 }
|
|
154
|
+
end
|
|
155
|
+
|
|
156
|
+
def semantic_options(options, rule)
|
|
157
|
+
options.map { |option| semantic_key(option, rule) }
|
|
158
|
+
end
|
|
159
|
+
|
|
160
|
+
def semantic_key(option, rule)
|
|
161
|
+
[option.flag, NormalizerRegistry.normalize(rule.normalizer, option.value)]
|
|
162
|
+
end
|
|
163
|
+
end
|
|
164
|
+
|
|
165
|
+
# Renders selected records in destination order and retains opaque source lines.
|
|
166
|
+
class OutputRenderer
|
|
167
|
+
def initialize(selected, base:, ours:, theirs:, policy:)
|
|
168
|
+
@selected = selected
|
|
169
|
+
@documents = [[ours, 0], [theirs, 1], [base, 2]]
|
|
170
|
+
@policy = policy
|
|
171
|
+
end
|
|
172
|
+
|
|
173
|
+
def render
|
|
174
|
+
records.sort_by do |line_number, source_rank, position, sequence, _raw|
|
|
175
|
+
[line_number, source_rank, position, sequence]
|
|
176
|
+
end
|
|
177
|
+
.map(&:last).join("\n")
|
|
178
|
+
end
|
|
179
|
+
|
|
180
|
+
private
|
|
181
|
+
|
|
182
|
+
def records
|
|
183
|
+
@opaque_remaining = opaque_counts
|
|
184
|
+
@sequence = 0
|
|
185
|
+
selected_records + @documents.flat_map do |document, source_rank|
|
|
186
|
+
opaque_records(document, source_rank)
|
|
187
|
+
end
|
|
188
|
+
end
|
|
189
|
+
|
|
190
|
+
def selected_records
|
|
191
|
+
@selected.flat_map do |_flag, entries|
|
|
192
|
+
entries.map do |option, source|
|
|
193
|
+
record(option.line_number, source_rank(source), option.position, option.raw)
|
|
194
|
+
end
|
|
195
|
+
end
|
|
196
|
+
end
|
|
197
|
+
|
|
198
|
+
def source_rank(source)
|
|
199
|
+
{ ours: 0, theirs: 1, base: 2 }.fetch(source)
|
|
200
|
+
end
|
|
201
|
+
|
|
202
|
+
def opaque_records(document, source_rank)
|
|
203
|
+
document.opaque_lines.filter_map do |line_number, position, raw|
|
|
204
|
+
next unless @opaque_remaining.fetch(raw, 0).positive?
|
|
205
|
+
|
|
206
|
+
@opaque_remaining[raw] -= 1
|
|
207
|
+
record(line_number, source_rank, position, raw)
|
|
208
|
+
end
|
|
209
|
+
end
|
|
210
|
+
|
|
211
|
+
def record(line_number, source_rank, position, raw)
|
|
212
|
+
value = [line_number, source_rank, position, @sequence, raw]
|
|
213
|
+
@sequence += 1
|
|
214
|
+
value
|
|
215
|
+
end
|
|
216
|
+
|
|
217
|
+
def opaque_counts
|
|
218
|
+
base_counts = line_counts(@documents[2].first)
|
|
219
|
+
ours_counts = line_counts(@documents[0].first)
|
|
220
|
+
theirs_counts = line_counts(@documents[1].first)
|
|
221
|
+
keys = base_counts.keys | ours_counts.keys | theirs_counts.keys
|
|
222
|
+
keys.to_h { |raw| [raw, merged_line_count(raw, base_counts, ours_counts, theirs_counts)] }
|
|
223
|
+
end
|
|
224
|
+
|
|
225
|
+
def line_counts(document)
|
|
226
|
+
document.opaque_lines.each_with_object(Hash.new(0)) do |(_line, _position, raw), result|
|
|
227
|
+
result[raw] += 1
|
|
228
|
+
end
|
|
229
|
+
end
|
|
230
|
+
|
|
231
|
+
def merged_line_count(raw, base_counts, ours_counts, theirs_counts)
|
|
232
|
+
base_count = base_counts.fetch(raw, 0)
|
|
233
|
+
ours_count = ours_counts.fetch(raw, 0)
|
|
234
|
+
theirs_count = theirs_counts.fetch(raw, 0)
|
|
235
|
+
return ours_count if ours_count == theirs_count
|
|
236
|
+
return theirs_count if ours_count == base_count
|
|
237
|
+
return ours_count if theirs_count == base_count
|
|
238
|
+
|
|
239
|
+
[ours_count, theirs_count].max
|
|
240
|
+
end
|
|
241
|
+
end
|
|
242
|
+
end
|
|
243
|
+
end
|
|
244
|
+
end
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "monitor"
|
|
4
|
+
|
|
5
|
+
module Config
|
|
6
|
+
module Merge
|
|
7
|
+
# Thread-safe registry of named value normalizers used for semantic comparison.
|
|
8
|
+
# Normalizers change comparison values only; rendering keeps the original text.
|
|
9
|
+
class NormalizerRegistry
|
|
10
|
+
class UnknownNormalizerError < ArgumentError; end
|
|
11
|
+
|
|
12
|
+
@monitor = Monitor.new
|
|
13
|
+
@normalizers = {
|
|
14
|
+
identity: ->(value) { value },
|
|
15
|
+
strip: ->(value) { value&.strip },
|
|
16
|
+
downcase: ->(value) { value&.downcase }
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
class << self
|
|
20
|
+
def register(name, callable = nil, replace: false, &block)
|
|
21
|
+
normalizer = callable || block
|
|
22
|
+
raise ArgumentError, "normalizer must respond to #call" unless normalizer.respond_to?(:call)
|
|
23
|
+
|
|
24
|
+
key = normalize_name(name)
|
|
25
|
+
@monitor.synchronize do
|
|
26
|
+
raise ArgumentError, "Normalizer already registered: #{key}" if @normalizers.key?(key) && !replace
|
|
27
|
+
|
|
28
|
+
@normalizers[key] = normalizer
|
|
29
|
+
end
|
|
30
|
+
key
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
def normalize(name, value)
|
|
34
|
+
key = normalize_name(name)
|
|
35
|
+
normalizer = @monitor.synchronize { @normalizers[key] }
|
|
36
|
+
raise UnknownNormalizerError, "Unknown normalizer: #{key}" unless normalizer
|
|
37
|
+
|
|
38
|
+
normalizer.call(value)
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
def registered?(name)
|
|
42
|
+
@monitor.synchronize { @normalizers.key?(normalize_name(name)) }
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
def names
|
|
46
|
+
@monitor.synchronize { @normalizers.keys.sort.freeze }
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
private
|
|
50
|
+
|
|
51
|
+
def normalize_name(name)
|
|
52
|
+
name.to_s.downcase.tr("-", "_").to_sym
|
|
53
|
+
end
|
|
54
|
+
end
|
|
55
|
+
end
|
|
56
|
+
end
|
|
57
|
+
end
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
name: rspec
|
|
2
|
+
format: flag_file
|
|
3
|
+
defaults:
|
|
4
|
+
cardinality: repeatable
|
|
5
|
+
on_conflict: conflict
|
|
6
|
+
flags:
|
|
7
|
+
--require:
|
|
8
|
+
cardinality: single
|
|
9
|
+
on_conflict: ours
|
|
10
|
+
--order:
|
|
11
|
+
cardinality: single
|
|
12
|
+
on_conflict: ours
|
|
13
|
+
--out:
|
|
14
|
+
cardinality: single
|
|
15
|
+
on_conflict: ours
|
|
16
|
+
--format:
|
|
17
|
+
cardinality: repeatable
|
|
18
|
+
on_conflict: union
|
|
19
|
+
--exclude-pattern:
|
|
20
|
+
cardinality: repeatable
|
|
21
|
+
on_conflict: union
|
|
22
|
+
--env:
|
|
23
|
+
cardinality: repeatable
|
|
24
|
+
on_conflict: union
|
|
@@ -0,0 +1,110 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "yaml"
|
|
4
|
+
require_relative "normalizer_registry"
|
|
5
|
+
|
|
6
|
+
module Config
|
|
7
|
+
module Merge
|
|
8
|
+
# Immutable policy for resolving conflicts in a line-oriented flag file.
|
|
9
|
+
class Policy
|
|
10
|
+
Rule = Struct.new(:cardinality, :on_conflict, :normalizer, keyword_init: true) do
|
|
11
|
+
def initialize(cardinality:, on_conflict:, normalizer:)
|
|
12
|
+
super(cardinality: cardinality.to_sym, on_conflict: on_conflict.to_sym, normalizer: normalizer.to_sym)
|
|
13
|
+
freeze
|
|
14
|
+
end
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
CARDINALITIES = %i[single repeatable].freeze
|
|
18
|
+
CONFLICT_ACTIONS = %i[conflict ours theirs base union].freeze
|
|
19
|
+
ROOT_KEYS = %w[name format defaults flags].freeze
|
|
20
|
+
RULE_KEYS = %w[cardinality on_conflict normalizer].freeze
|
|
21
|
+
|
|
22
|
+
attr_reader :name, :format, :defaults, :flags
|
|
23
|
+
|
|
24
|
+
def self.load(path)
|
|
25
|
+
raise ArgumentError, "Policy file not found: #{path}" unless File.file?(path)
|
|
26
|
+
|
|
27
|
+
new(YAML.safe_load_file(path, permitted_classes: [], aliases: false))
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
def initialize(config)
|
|
31
|
+
normalized = validate_config(config)
|
|
32
|
+
@name = normalized["name"]&.to_s
|
|
33
|
+
@format = normalized["format"]&.to_s || "flag_file"
|
|
34
|
+
validate_format!
|
|
35
|
+
@defaults = build_rule(normalized["defaults"] || {}, default: true)
|
|
36
|
+
@flags = parse_flags(normalized["flags"] || {}).freeze
|
|
37
|
+
freeze
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
def rule_for(flag)
|
|
41
|
+
flags.fetch(normalize_flag(flag), defaults)
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
private
|
|
45
|
+
|
|
46
|
+
def validate_config(config)
|
|
47
|
+
normalized = stringify_keys(config)
|
|
48
|
+
reject_unknown_keys!(normalized, ROOT_KEYS, "policy")
|
|
49
|
+
normalized
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
def validate_format!
|
|
53
|
+
return if format == "flag_file"
|
|
54
|
+
|
|
55
|
+
raise ArgumentError, "unsupported policy format: #{format}"
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
def parse_flags(config)
|
|
59
|
+
raise ArgumentError, "flags must be a mapping" unless config.is_a?(Hash)
|
|
60
|
+
|
|
61
|
+
config.to_h do |flag, values|
|
|
62
|
+
raise ArgumentError, "flag policy for #{flag.inspect} must be a mapping" unless values.is_a?(Hash)
|
|
63
|
+
|
|
64
|
+
[normalize_flag(flag), build_rule(stringify_keys(values), default: false)]
|
|
65
|
+
end
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
def build_rule(values, default:)
|
|
69
|
+
values = stringify_keys(values)
|
|
70
|
+
reject_unknown_keys!(values, RULE_KEYS, "rule")
|
|
71
|
+
cardinality = rule_value(values, "cardinality", default ? "repeatable" : "single").to_sym
|
|
72
|
+
on_conflict = rule_value(values, "on_conflict", "conflict").to_sym
|
|
73
|
+
normalizer = rule_value(values, "normalizer", "identity").to_sym
|
|
74
|
+
validate_rule!(cardinality, on_conflict, normalizer)
|
|
75
|
+
Rule.new(cardinality: cardinality, on_conflict: on_conflict, normalizer: normalizer)
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
def rule_value(values, key, fallback)
|
|
79
|
+
values.fetch(key, fallback)
|
|
80
|
+
end
|
|
81
|
+
|
|
82
|
+
def validate_rule!(cardinality, on_conflict, normalizer)
|
|
83
|
+
raise ArgumentError, "invalid cardinality: #{cardinality}" unless CARDINALITIES.include?(cardinality)
|
|
84
|
+
raise ArgumentError, "invalid conflict action: #{on_conflict}" unless CONFLICT_ACTIONS.include?(on_conflict)
|
|
85
|
+
if cardinality == :single && on_conflict == :union
|
|
86
|
+
raise ArgumentError, "single-valued flags cannot use the union conflict action"
|
|
87
|
+
end
|
|
88
|
+
raise ArgumentError, "unknown normalizer: #{normalizer}" unless NormalizerRegistry.registered?(normalizer)
|
|
89
|
+
end
|
|
90
|
+
|
|
91
|
+
def reject_unknown_keys!(hash, allowed, context)
|
|
92
|
+
unknown = hash.keys - allowed
|
|
93
|
+
raise ArgumentError, "unknown #{context} key(s): #{unknown.join(", ")}" unless unknown.empty?
|
|
94
|
+
end
|
|
95
|
+
|
|
96
|
+
def stringify_keys(hash)
|
|
97
|
+
raise ArgumentError, "expected a mapping" unless hash.is_a?(Hash)
|
|
98
|
+
|
|
99
|
+
hash.each_with_object({}) { |(key, value), result| result[key.to_s] = value }
|
|
100
|
+
end
|
|
101
|
+
|
|
102
|
+
def normalize_flag(flag)
|
|
103
|
+
value = flag.to_s
|
|
104
|
+
raise ArgumentError, "flag names must not be empty" if value.empty?
|
|
105
|
+
|
|
106
|
+
value
|
|
107
|
+
end
|
|
108
|
+
end
|
|
109
|
+
end
|
|
110
|
+
end
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Config
|
|
4
|
+
module Merge
|
|
5
|
+
# Reuses an ast-merge provider for formats with an existing structural parser.
|
|
6
|
+
#
|
|
7
|
+
# The provider result is returned unchanged, including diagnostics,
|
|
8
|
+
# verification details, and source-preserving output.
|
|
9
|
+
class ProviderAdapter
|
|
10
|
+
def initialize(family:, dialect: nil, backend: nil, profile_id: nil, provider_id: nil)
|
|
11
|
+
@selectors = {
|
|
12
|
+
family: family,
|
|
13
|
+
dialect: dialect,
|
|
14
|
+
backend: backend,
|
|
15
|
+
profile_id: profile_id,
|
|
16
|
+
provider_id: provider_id
|
|
17
|
+
}.compact.freeze
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
# Dispatches a base-aware structural merge to the selected provider.
|
|
21
|
+
#
|
|
22
|
+
# @param base [String] common ancestor source
|
|
23
|
+
# @param ours [String] current or destination source
|
|
24
|
+
# @param theirs [String] incoming source
|
|
25
|
+
# @param metadata [Hash, nil] provider-specific request metadata
|
|
26
|
+
# @return [Hash] validated ast-merge provider result
|
|
27
|
+
def merge3(base:, ours:, theirs:, metadata: nil)
|
|
28
|
+
request = @selectors.merge(
|
|
29
|
+
base_source: base,
|
|
30
|
+
ours_source: ours,
|
|
31
|
+
theirs_source: theirs
|
|
32
|
+
)
|
|
33
|
+
request[:metadata] = metadata if metadata
|
|
34
|
+
|
|
35
|
+
Ast::Merge.dispatch_provider(:merge3, request)
|
|
36
|
+
end
|
|
37
|
+
end
|
|
38
|
+
end
|
|
39
|
+
end
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Config
|
|
4
|
+
module Merge
|
|
5
|
+
# Version namespace for this gem.
|
|
6
|
+
module Version
|
|
7
|
+
# Current gem version.
|
|
8
|
+
VERSION = "7.1.13"
|
|
9
|
+
end
|
|
10
|
+
# Current gem version exposed at the traditional constant location.
|
|
11
|
+
VERSION = Version::VERSION # Traditional Constant Location
|
|
12
|
+
end
|
|
13
|
+
end
|
data/lib/config/merge.rb
ADDED
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "ast/merge"
|
|
4
|
+
require "version_gem"
|
|
5
|
+
require_relative "merge/version"
|
|
6
|
+
require_relative "merge/normalizer_registry"
|
|
7
|
+
require_relative "merge/policy"
|
|
8
|
+
require_relative "merge/built_in_policies"
|
|
9
|
+
require_relative "merge/flag_file/option"
|
|
10
|
+
require_relative "merge/flag_file/parser"
|
|
11
|
+
require_relative "merge/flag_file/result"
|
|
12
|
+
require_relative "merge/flag_file/three_way_merger"
|
|
13
|
+
|
|
14
|
+
module Config
|
|
15
|
+
module Merge
|
|
16
|
+
# Base error class for config-merge.
|
|
17
|
+
class Error < Ast::Merge::Error; end
|
|
18
|
+
end
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
Config::Merge::Version.class_eval do
|
|
22
|
+
extend VersionGem::Basic
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
require_relative "merge/provider_adapter"
|
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
module Config
|
|
2
|
+
module Merge
|
|
3
|
+
class Error < StandardError
|
|
4
|
+
end
|
|
5
|
+
|
|
6
|
+
class NormalizerRegistry
|
|
7
|
+
class UnknownNormalizerError < ArgumentError
|
|
8
|
+
end
|
|
9
|
+
|
|
10
|
+
def self.register: (String | Symbol name, ?Proc callable, ?replace: bool) -> Symbol
|
|
11
|
+
| (String | Symbol name, ?replace: bool) { (untyped) -> untyped } -> Symbol
|
|
12
|
+
def self.normalize: (String | Symbol name, untyped value) -> untyped
|
|
13
|
+
def self.registered?: (String | Symbol name) -> bool
|
|
14
|
+
def self.names: () -> Array[Symbol]
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
class Policy
|
|
18
|
+
class Rule
|
|
19
|
+
attr_reader cardinality: Symbol
|
|
20
|
+
attr_reader on_conflict: Symbol
|
|
21
|
+
attr_reader normalizer: Symbol
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
attr_reader name: String?
|
|
25
|
+
attr_reader format: String
|
|
26
|
+
attr_reader defaults: Rule
|
|
27
|
+
attr_reader flags: Hash[String, Rule]
|
|
28
|
+
|
|
29
|
+
def self.load: (String path) -> Policy
|
|
30
|
+
def initialize: (Hash[String, untyped] config) -> void
|
|
31
|
+
def rule_for: (String flag) -> Rule
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
module BuiltInPolicies
|
|
35
|
+
def self.names: () -> Array[Symbol]
|
|
36
|
+
def self.fetch: (String | Symbol name) -> Policy
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
module FlagFile
|
|
40
|
+
class Option
|
|
41
|
+
attr_reader flag: String
|
|
42
|
+
attr_reader value: String?
|
|
43
|
+
attr_reader raw: String
|
|
44
|
+
attr_reader line_number: Integer
|
|
45
|
+
attr_reader position: Integer
|
|
46
|
+
attr_reader binding: Symbol
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
class Document
|
|
50
|
+
attr_reader options: Array[Option]
|
|
51
|
+
attr_reader opaque_lines: Array[[Integer, Integer, String]]
|
|
52
|
+
attr_reader trailing_newline: bool
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
class Parser
|
|
56
|
+
class ParseError < ArgumentError
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
def parse: (String source) -> Document
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
class Conflict
|
|
63
|
+
attr_reader flag: String
|
|
64
|
+
attr_reader base: Array[Option]
|
|
65
|
+
attr_reader ours: Array[Option]
|
|
66
|
+
attr_reader theirs: Array[Option]
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
class Result
|
|
70
|
+
attr_reader output: String?
|
|
71
|
+
attr_reader conflicts: Array[Conflict]
|
|
72
|
+
attr_reader partial_output: String
|
|
73
|
+
def clean?: () -> bool
|
|
74
|
+
def success?: () -> bool
|
|
75
|
+
end
|
|
76
|
+
|
|
77
|
+
class ThreeWayMerger
|
|
78
|
+
def initialize: (base: String | Document, ours: String | Document, theirs: String | Document, policy: Policy) -> void
|
|
79
|
+
def merge: () -> Result
|
|
80
|
+
end
|
|
81
|
+
end
|
|
82
|
+
end
|
|
83
|
+
end
|
|
84
|
+
|
|
85
|
+
module Config::Merge
|
|
86
|
+
class ProviderAdapter
|
|
87
|
+
def initialize: (family: String | Symbol, ?dialect: String | Symbol, ?backend: String | Symbol, ?profile_id: String | Symbol, ?provider_id: String | Symbol) -> void
|
|
88
|
+
def merge3: (base: String, ours: String, theirs: String, ?metadata: Hash[Symbol, untyped]?) -> Hash[Symbol, untyped]
|
|
89
|
+
end
|
|
90
|
+
end
|
data.tar.gz.sig
ADDED
|
Binary file
|