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.
@@ -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
@@ -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