ace-support-config 0.9.0

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,419 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "pathname"
4
+
5
+ module Ace
6
+ module Support
7
+ module Config
8
+ module Molecules
9
+ # Resolve effective config for a given file path using distributed configs and path rules
10
+ class FileConfigResolver
11
+ attr_reader :config_dir, :defaults_dir, :gem_path, :merge_strategy
12
+
13
+ def initialize(
14
+ config_dir: ".ace",
15
+ defaults_dir: ".ace-defaults",
16
+ gem_path: nil,
17
+ merge_strategy: :replace
18
+ )
19
+ @config_dir = config_dir
20
+ @defaults_dir = defaults_dir
21
+ @gem_path = gem_path
22
+ @merge_strategy = merge_strategy
23
+ end
24
+
25
+ # Resolve config for a file
26
+ # @param file_path [String] File path (relative or absolute)
27
+ # @param namespace [String] Config namespace (default: "git")
28
+ # @param filename [String] Config filename without extension (default: "commit")
29
+ # @param project_root [String, nil] Explicit project root (overrides auto-detection)
30
+ # @return [Models::ConfigGroup] Resolved config group for the file
31
+ def resolve(file_path, namespace: "git", filename: "commit", project_root: nil)
32
+ raise ArgumentError, "file_path cannot be nil or empty" if file_path.nil? || file_path.to_s.empty?
33
+
34
+ start_path = resolve_start_path(file_path)
35
+ project_root = normalize_path(project_root || detect_project_root(start_path) || start_path)
36
+ relative_path = to_relative_path(file_path, project_root)
37
+
38
+ distributed_config_path = find_distributed_config(start_path, project_root, namespace, filename)
39
+ if distributed_config_path && project_root
40
+ root_config_dir = File.join(project_root, config_dir.to_s)
41
+ root_prefix = normalize_path(root_config_dir) + File::SEPARATOR
42
+ if normalize_path(distributed_config_path).start_with?(root_prefix)
43
+ distributed_config_path = nil
44
+ end
45
+ end
46
+ config = load_cascade_config(start_path, project_root, namespace, filename)
47
+ base_data = extract_config_data(config)
48
+
49
+ # Check path rules FIRST (before distributed config scope)
50
+ matched = match_path_rule(base_data, relative_path, project_root)
51
+ if matched
52
+ resolved = merge_rule_overrides(strip_paths_section(base_data), matched.config)
53
+ return Models::ConfigGroup.new(
54
+ name: matched.name,
55
+ source: primary_source_path(config, namespace, filename, start_path, project_root),
56
+ config: resolved,
57
+ rule_config: matched.config, # Raw rule config for grouping (ignores cascade differences)
58
+ files: [relative_path]
59
+ )
60
+ end
61
+
62
+ # Fall back to distributed config scope if present
63
+ if distributed_config_path
64
+ resolved = strip_paths_section(base_data)
65
+ scope_name = scope_name_from_config_path(distributed_config_path, project_root)
66
+ return Models::ConfigGroup.new(
67
+ name: scope_name,
68
+ source: distributed_config_path,
69
+ config: resolved,
70
+ files: [relative_path]
71
+ )
72
+ end
73
+
74
+ Models::ConfigGroup.new(
75
+ name: Models::ConfigGroup::DEFAULT_SCOPE_NAME,
76
+ source: primary_source_path(config, namespace, filename, start_path, project_root),
77
+ config: strip_paths_section(base_data),
78
+ files: [relative_path]
79
+ )
80
+ end
81
+
82
+ private
83
+
84
+ def resolve_start_path(file_path)
85
+ path = file_path.to_s
86
+ absolute = Pathname.new(path).absolute? ? path : File.expand_path(path, Dir.pwd)
87
+ File.directory?(absolute) ? absolute : File.dirname(absolute)
88
+ end
89
+
90
+ def to_relative_path(file_path, project_root)
91
+ path = file_path.to_s.sub(%r{\A\./}, "")
92
+ return path unless project_root
93
+
94
+ absolute = Pathname.new(path).absolute? ? path : File.expand_path(path, project_root)
95
+ root = File.expand_path(project_root)
96
+ if absolute.start_with?(root + File::SEPARATOR)
97
+ absolute.sub("#{root}/", "")
98
+ else
99
+ path
100
+ end
101
+ end
102
+
103
+ def find_distributed_config(start_path, project_root, namespace, filename)
104
+ patterns = config_patterns(namespace, filename)
105
+ root = project_root || start_path
106
+ current = File.expand_path(start_path.to_s)
107
+ root = File.expand_path(root.to_s)
108
+
109
+ loop do
110
+ config_path = File.join(current, config_dir)
111
+ if project_root
112
+ root_config = File.join(File.expand_path(project_root.to_s), config_dir.to_s)
113
+ config_path = nil if File.expand_path(config_path) == File.expand_path(root_config)
114
+ end
115
+
116
+ if config_path && Dir.exist?(config_path)
117
+ patterns.each do |pattern|
118
+ candidate = File.join(config_path, pattern)
119
+ return candidate if File.exist?(candidate)
120
+ end
121
+ end
122
+
123
+ break if current == root
124
+
125
+ parent = File.dirname(current)
126
+ break if parent == current
127
+
128
+ current = parent
129
+ end
130
+
131
+ nil
132
+ end
133
+
134
+ def config_patterns(namespace, filename)
135
+ [
136
+ File.join(namespace, "#{filename}.yml"),
137
+ File.join(namespace, "#{filename}.yaml")
138
+ ]
139
+ end
140
+
141
+ def load_cascade_config(start_path, project_root, namespace, filename)
142
+ patterns = config_patterns(namespace, filename)
143
+ cascade_paths = find_cascade_paths(start_path, project_root, patterns)
144
+ if cascade_paths.empty?
145
+ return Models::Config.new({}, source: "no_config_found", merge_strategy: merge_strategy)
146
+ end
147
+
148
+ configs = cascade_paths.map do |path|
149
+ Molecules::YamlLoader.load_file(path)
150
+ end
151
+
152
+ # Merge configs in reverse order (root first, then nested)
153
+ merged_data = configs.reverse.reduce({}) do |result, config|
154
+ Atoms::DeepMerger.merge(
155
+ result,
156
+ config.data,
157
+ array_strategy: merge_strategy
158
+ )
159
+ end
160
+
161
+ # Collect paths with source tracking
162
+ # Process in reverse (root first) so more specific configs override
163
+ paths_with_sources = collect_paths_with_sources(cascade_paths.reverse, project_root)
164
+ if paths_with_sources.any?
165
+ # Replace merged scopes/paths with source-tracked paths
166
+ merged_data = merged_data.dup
167
+ merged_data.delete("scopes")
168
+ merged_data.delete(:scopes)
169
+ merged_data.delete("paths")
170
+ merged_data.delete(:paths)
171
+ merged_data.delete("path_rules")
172
+ merged_data.delete(:path_rules)
173
+
174
+ # Check if scopes/paths were in git section
175
+ if merged_data["git"].is_a?(Hash)
176
+ merged_data["git"] = merged_data["git"].dup
177
+ merged_data["git"].delete("scopes")
178
+ merged_data["git"].delete(:scopes)
179
+ merged_data["git"].delete("paths")
180
+ merged_data["git"].delete(:paths)
181
+ merged_data["git"].delete("path_rules")
182
+ merged_data["git"].delete(:path_rules)
183
+ end
184
+
185
+ merged_data["scopes"] = paths_with_sources
186
+ end
187
+
188
+ sources = cascade_paths.join(" -> ")
189
+ Models::Config.new(
190
+ merged_data,
191
+ source: sources,
192
+ merge_strategy: merge_strategy
193
+ )
194
+ end
195
+
196
+ # Collect path rules from all configs, tracking source directory
197
+ # @param cascade_paths [Array<String>] Config paths from root to nested
198
+ # @param project_root [String, nil] Project root directory
199
+ # @return [Hash] Path rules with _config_root metadata
200
+ def collect_paths_with_sources(cascade_paths, project_root)
201
+ all_paths = {}
202
+
203
+ cascade_paths.each do |config_path|
204
+ config = Molecules::YamlLoader.load_file(config_path)
205
+ data = config.data || {}
206
+
207
+ # Config root is the directory containing the .ace/ folder
208
+ # e.g., /project/ace-bundle/.ace/git/commit.yml → /project/ace-bundle
209
+ config_root = config_root_from_path(config_path)
210
+
211
+ # Look for scopes/paths in both git section and root
212
+ paths = data.dig("git", "scopes") || data.dig("git", :scopes) ||
213
+ data.dig("git", "paths") || data.dig("git", :paths) ||
214
+ data["scopes"] || data[:scopes] ||
215
+ data["paths"] || data[:paths] ||
216
+ data.dig("git", "path_rules") || data.dig("git", :path_rules) ||
217
+ data["path_rules"] || data[:path_rules]
218
+
219
+ next unless paths.is_a?(Hash)
220
+
221
+ paths.each do |name, rule|
222
+ next unless rule.is_a?(Hash)
223
+
224
+ # Clone rule and add source tracking
225
+ tracked_rule = rule.dup
226
+ tracked_rule["_config_root"] = config_root
227
+ all_paths[name.to_s] = tracked_rule
228
+ end
229
+ end
230
+
231
+ # All inherited rules should be relative to closest (most nested) config
232
+ # This ensures inherited path rules like ".ace/**" match from the nested config's location
233
+ if cascade_paths.any? && all_paths.any?
234
+ closest_root = config_root_from_path(cascade_paths.last)
235
+ all_paths.each_value { |rule| rule["_config_root"] = closest_root }
236
+ end
237
+
238
+ all_paths
239
+ end
240
+
241
+ # Extract config root directory from config file path
242
+ # @param config_path [String] Path to config file (e.g., /project/.ace/git/commit.yml)
243
+ # @return [String] Directory containing .ace/ folder
244
+ def config_root_from_path(config_path)
245
+ # Split on .ace/ and take the first part
246
+ parts = config_path.to_s.split("#{File::SEPARATOR}#{config_dir}#{File::SEPARATOR}")
247
+ if parts.length >= 2
248
+ normalize_path(parts.first)
249
+ else
250
+ # Fallback: go up from config file
251
+ normalize_path(File.dirname(config_path, 3))
252
+ end
253
+ end
254
+
255
+ def extract_config_data(config)
256
+ data = config.data || {}
257
+ git_data = data["git"] || data[:git]
258
+ return data unless git_data.is_a?(Hash)
259
+
260
+ root_overrides = data.reject { |key, _| key.to_s == "git" }
261
+ Atoms::DeepMerger.merge(
262
+ git_data,
263
+ root_overrides,
264
+ array_strategy: merge_strategy
265
+ )
266
+ end
267
+
268
+ def match_path_rule(base_data, relative_path, project_root)
269
+ rules = base_data["scopes"] || base_data[:scopes] ||
270
+ base_data["paths"] || base_data[:paths] ||
271
+ base_data["path_rules"] || base_data[:path_rules]
272
+ matcher = Atoms::PathRuleMatcher.new(normalize_path_rules(rules), project_root: project_root)
273
+ matcher.match(relative_path)
274
+ end
275
+
276
+ def merge_rule_overrides(base_data, overrides)
277
+ Atoms::DeepMerger.merge(
278
+ base_data,
279
+ overrides || {},
280
+ array_strategy: merge_strategy
281
+ )
282
+ end
283
+
284
+ def strip_paths_section(base_data)
285
+ return {} if base_data.nil?
286
+
287
+ stripped = base_data.dup
288
+ stripped.delete("scopes")
289
+ stripped.delete(:scopes)
290
+ stripped.delete("paths")
291
+ stripped.delete(:paths)
292
+ stripped.delete("path_rules")
293
+ stripped.delete(:path_rules)
294
+ stripped
295
+ end
296
+
297
+ def normalize_path_rules(rules)
298
+ return {} if rules.nil?
299
+ return rules if rules.is_a?(Hash)
300
+
301
+ Array(rules).each_with_index.each_with_object({}) do |(rule, index), acc|
302
+ next unless rule.is_a?(Hash)
303
+
304
+ name = rule["name"] || rule[:name] || "rule-#{index + 1}"
305
+ acc[name.to_s] = rule
306
+ end
307
+ end
308
+
309
+ def scope_name_from_config_path(config_path, project_root)
310
+ config_path_str = config_path.to_s
311
+ scope_root = config_path_str.split("#{File::SEPARATOR}#{config_dir}#{File::SEPARATOR}").first
312
+ scope_root = File.dirname(config_path_str) if scope_root.nil? || scope_root.empty?
313
+
314
+ if project_root
315
+ root = File.expand_path(project_root.to_s)
316
+ scope = File.expand_path(scope_root.to_s)
317
+ return Models::ConfigGroup::DEFAULT_SCOPE_NAME if scope == root
318
+ return scope.sub("#{root}/", "") if scope.start_with?(root + File::SEPARATOR)
319
+
320
+ root_real = normalize_path(project_root)
321
+ scope_real = normalize_path(scope_root)
322
+ return Models::ConfigGroup::DEFAULT_SCOPE_NAME if scope_real == root_real
323
+ return scope_real.sub("#{root_real}/", "") if scope_real.start_with?(root_real + File::SEPARATOR)
324
+ end
325
+
326
+ scope_root
327
+ end
328
+
329
+ def find_cascade_paths(start_path, project_root, patterns)
330
+ paths = []
331
+ root = project_root || start_path
332
+ current = File.expand_path(start_path.to_s)
333
+ root = File.expand_path(root.to_s)
334
+
335
+ loop do
336
+ config_path = File.join(current, config_dir)
337
+ if Dir.exist?(config_path)
338
+ patterns.each do |pattern|
339
+ candidate = File.join(config_path, pattern)
340
+ paths << candidate if File.exist?(candidate)
341
+ end
342
+ end
343
+
344
+ break if current == root
345
+
346
+ parent = File.dirname(current)
347
+ break if parent == current
348
+
349
+ current = parent
350
+ end
351
+
352
+ home_config = File.expand_path("~/#{config_dir}")
353
+ if Dir.exist?(home_config)
354
+ patterns.each do |pattern|
355
+ candidate = File.join(home_config, pattern)
356
+ paths << candidate if File.exist?(candidate)
357
+ end
358
+ end
359
+
360
+ if gem_path
361
+ defaults_path = File.join(gem_path, defaults_dir)
362
+ if Dir.exist?(defaults_path)
363
+ patterns.each do |pattern|
364
+ candidate = File.join(defaults_path, pattern)
365
+ paths << candidate if File.exist?(candidate)
366
+ end
367
+ end
368
+ end
369
+
370
+ paths
371
+ end
372
+
373
+ def normalize_path(path)
374
+ File.realpath(path)
375
+ rescue Errno::ENOENT, Errno::EACCES
376
+ File.expand_path(path.to_s)
377
+ end
378
+
379
+ def detect_project_root(start_path)
380
+ markers = Ace::Support::Fs::Molecules::ProjectRootFinder::DEFAULT_MARKERS
381
+ current = File.expand_path(start_path.to_s)
382
+ fallback_root = nil
383
+
384
+ # .git is definitive - keep searching for it even after finding weaker markers
385
+ loop do
386
+ return current if File.exist?(File.join(current, ".git"))
387
+
388
+ # Remember first match of any marker as fallback
389
+ if fallback_root.nil?
390
+ markers.each do |marker|
391
+ next if marker == ".git"
392
+ if File.exist?(File.join(current, marker))
393
+ fallback_root = current
394
+ break
395
+ end
396
+ end
397
+ end
398
+
399
+ parent = File.dirname(current)
400
+ break if parent == current
401
+
402
+ current = parent
403
+ end
404
+
405
+ fallback_root
406
+ end
407
+
408
+ def primary_source_path(config, namespace, filename, start_path, project_root)
409
+ return config.source if config.source && config.source != "no_config_found"
410
+
411
+ patterns = config_patterns(namespace, filename)
412
+ found = find_cascade_paths(start_path, project_root, patterns).first
413
+ found || "no_config_found"
414
+ end
415
+ end
416
+ end
417
+ end
418
+ end
419
+ end
@@ -0,0 +1,164 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Ace
4
+ module Support
5
+ module Config
6
+ module Molecules
7
+ # Scan project tree downward to find all config folders (.ace)
8
+ #
9
+ # Complements ConfigFinder's upward traversal by scanning the entire project
10
+ # tree for all config directories. Useful for monorepos where multiple packages
11
+ # have distributed configurations.
12
+ #
13
+ # @example Scan all .ace folders
14
+ # scanner = ProjectConfigScanner.new(project_root: Dir.pwd)
15
+ # scanner.scan
16
+ # # => { "." => ["git/commit.yml"], "ace-bundle" => ["git/commit.yml"] }
17
+ #
18
+ # @example Find specific config across project
19
+ # scanner.find_all(namespace: "git", filename: "commit")
20
+ # # => { "." => "/project/.ace/git/commit.yml" }
21
+ class ProjectConfigScanner
22
+ # Directories to skip during traversal
23
+ SKIP_DIRS = %w[.git .cache vendor node_modules tmp coverage
24
+ .bundle _legacy .ace-local .ace-tasks .ace-taskflow].freeze
25
+
26
+ # @param project_root [String, nil] Root directory to scan (default: Dir.pwd)
27
+ # @param config_dir [String] Config folder name (default: ".ace")
28
+ def initialize(project_root: nil, config_dir: ".ace")
29
+ @project_root = File.expand_path(project_root || Dir.pwd)
30
+ @config_dir = config_dir
31
+ end
32
+
33
+ # Scan project tree for all config folders and their files
34
+ #
35
+ # Results are memoized so multiple calls reuse one traversal.
36
+ #
37
+ # @return [Hash{String => Array<String>}] Map of relative location => config file list
38
+ def scan
39
+ @scan_result ||= perform_scan
40
+ end
41
+
42
+ # Find all instances of a specific config file across the project
43
+ #
44
+ # @param namespace [String] Config namespace (e.g., "git")
45
+ # @param filename [String] Config filename without extension (e.g., "commit")
46
+ # @return [Hash{String => String}] Map of relative location => absolute file path
47
+ def find_all(namespace:, filename:)
48
+ result = {}
49
+
50
+ scan.each do |location, files|
51
+ yml_target = "#{namespace}/#{filename}.yml"
52
+ yaml_target = "#{namespace}/#{filename}.yaml"
53
+
54
+ matched = if files.include?(yml_target)
55
+ yml_target
56
+ elsif files.include?(yaml_target)
57
+ yaml_target
58
+ end
59
+
60
+ next unless matched
61
+
62
+ ace_dir = location_to_ace_dir(location)
63
+ result[location] = File.join(ace_dir, matched)
64
+ end
65
+
66
+ result
67
+ end
68
+
69
+ private
70
+
71
+ # Perform the actual filesystem scan (called once; result memoized by scan)
72
+ def perform_scan
73
+ return {} unless Dir.exist?(@project_root)
74
+
75
+ result = {}
76
+
77
+ find_ace_dirs.each do |ace_dir_abs|
78
+ location = relative_location(ace_dir_abs)
79
+ result[location] = enumerate_config_files(ace_dir_abs)
80
+ end
81
+
82
+ result
83
+ end
84
+
85
+ # Find all .ace directories in the project tree
86
+ def find_ace_dirs
87
+ seen_real = {}
88
+ dirs = []
89
+
90
+ # Check root config dir first
91
+ root_ace = File.join(@project_root, @config_dir)
92
+ if Dir.exist?(root_ace)
93
+ real = File.realpath(root_ace)
94
+ seen_real[real] = true
95
+ dirs << root_ace
96
+ end
97
+
98
+ # Find nested config dirs (Dir.glob with FNM_DOTMATCH to match hidden dirs)
99
+ glob_results = begin
100
+ Dir.glob("**/#{@config_dir}", File::FNM_DOTMATCH, base: @project_root).sort
101
+ rescue Errno::EACCES
102
+ []
103
+ end
104
+
105
+ glob_results.each do |rel|
106
+ next if rel == @config_dir # already handled root
107
+ next if skip_path?(rel)
108
+
109
+ begin
110
+ abs = File.join(@project_root, rel)
111
+ next unless Dir.exist?(abs)
112
+
113
+ real = File.realpath(abs)
114
+ next if seen_real[real]
115
+
116
+ seen_real[real] = true
117
+ dirs << abs
118
+ rescue Errno::EACCES
119
+ next # skip this one path, continue scanning
120
+ end
121
+ end
122
+
123
+ dirs
124
+ end
125
+
126
+ # Enumerate config files within an .ace directory
127
+ def enumerate_config_files(ace_dir_abs)
128
+ return [] unless Dir.exist?(ace_dir_abs)
129
+
130
+ begin
131
+ Dir.glob("**/*", base: ace_dir_abs).select do |f|
132
+ File.file?(File.join(ace_dir_abs, f))
133
+ end.sort
134
+ rescue Errno::EACCES
135
+ []
136
+ end
137
+ end
138
+
139
+ # Convert absolute .ace dir path to relative location key
140
+ def relative_location(ace_dir_abs)
141
+ parent = File.dirname(ace_dir_abs)
142
+ rel = parent.delete_prefix(@project_root).delete_prefix("/")
143
+ rel.empty? ? "." : rel
144
+ end
145
+
146
+ # Convert relative location key back to absolute .ace dir path
147
+ def location_to_ace_dir(location)
148
+ if location == "."
149
+ File.join(@project_root, @config_dir)
150
+ else
151
+ File.join(@project_root, location, @config_dir)
152
+ end
153
+ end
154
+
155
+ # Check if a relative path should be skipped
156
+ def skip_path?(rel_path)
157
+ components = rel_path.split(File::SEPARATOR)
158
+ components.any? { |c| SKIP_DIRS.include?(c) }
159
+ end
160
+ end
161
+ end
162
+ end
163
+ end
164
+ end
@@ -0,0 +1,81 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "fileutils"
4
+
5
+ module Ace
6
+ module Support
7
+ module Config
8
+ module Molecules
9
+ # YAML file loading with error handling
10
+ class YamlLoader
11
+ # Load YAML from file
12
+ # @param filepath [String] Path to YAML file
13
+ # @return [Models::Config] Loaded configuration
14
+ # @raise [ConfigNotFoundError] if file doesn't exist
15
+ # @raise [YamlParseError] if YAML is invalid
16
+ def self.load_file(filepath)
17
+ unless File.exist?(filepath)
18
+ raise ConfigNotFoundError, "Configuration file not found: #{filepath}"
19
+ end
20
+
21
+ content = File.read(filepath)
22
+ data = Atoms::YamlParser.parse(content)
23
+
24
+ Models::Config.new(data, source: filepath)
25
+ rescue IOError, SystemCallError => e
26
+ raise ConfigNotFoundError, "Failed to read file #{filepath}: #{e.message}"
27
+ end
28
+
29
+ # Load YAML from file, return empty config if not found
30
+ # @param filepath [String] Path to YAML file
31
+ # @return [Models::Config] Loaded configuration or empty config
32
+ def self.load_file_safe(filepath)
33
+ load_file(filepath)
34
+ rescue ConfigNotFoundError
35
+ Models::Config.new({}, source: "#{filepath} (not found)")
36
+ end
37
+
38
+ # Save configuration to YAML file
39
+ # @param config [Models::Config, Hash] Configuration to save
40
+ # @param filepath [String] Path to save to
41
+ # @raise [IOError] if save fails
42
+ def self.save_file(config, filepath)
43
+ data = config.is_a?(Models::Config) ? config.data : config
44
+ yaml_content = Atoms::YamlParser.dump(data)
45
+
46
+ # Create directory if it doesn't exist
47
+ dir = File.dirname(filepath)
48
+ FileUtils.mkdir_p(dir) unless File.directory?(dir)
49
+
50
+ File.write(filepath, yaml_content)
51
+ rescue IOError, SystemCallError => e
52
+ raise IOError, "Failed to save file #{filepath}: #{e.message}"
53
+ end
54
+
55
+ # Load and merge multiple YAML files
56
+ # @param filepaths [Array<String>] Paths to YAML files
57
+ # @param merge_strategy [Symbol] How to merge arrays (:replace, :concat, :union)
58
+ # @return [Models::Config] Merged configuration
59
+ def self.load_and_merge(*filepaths, merge_strategy: :replace)
60
+ configs = filepaths.flatten.map do |filepath|
61
+ load_file_safe(filepath)
62
+ end
63
+
64
+ return Models::Config.new({}, source: "empty") if configs.empty?
65
+
66
+ # Merge all configs using the specified strategy
67
+ merged_data = configs.map(&:data).reduce({}) do |result, data|
68
+ Atoms::DeepMerger.merge(result, data, array_strategy: merge_strategy)
69
+ end
70
+
71
+ Models::Config.new(
72
+ merged_data,
73
+ source: "merged(#{filepaths.join(", ")})",
74
+ merge_strategy: merge_strategy
75
+ )
76
+ end
77
+ end
78
+ end
79
+ end
80
+ end
81
+ end