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.
- checksums.yaml +7 -0
- data/.ace-defaults/config/config.yml +23 -0
- data/CHANGELOG.md +224 -0
- data/LICENSE +21 -0
- data/README.md +35 -0
- data/Rakefile +15 -0
- data/lib/ace/support/config/atoms/deep_merger.rb +126 -0
- data/lib/ace/support/config/atoms/path_rule_matcher.rb +118 -0
- data/lib/ace/support/config/atoms/path_validator.rb +76 -0
- data/lib/ace/support/config/atoms/yaml_parser.rb +50 -0
- data/lib/ace/support/config/errors.rb +24 -0
- data/lib/ace/support/config/models/cascade_path.rb +94 -0
- data/lib/ace/support/config/models/config.rb +134 -0
- data/lib/ace/support/config/models/config_group.rb +57 -0
- data/lib/ace/support/config/molecules/config_finder.rb +230 -0
- data/lib/ace/support/config/molecules/file_config_resolver.rb +419 -0
- data/lib/ace/support/config/molecules/project_config_scanner.rb +164 -0
- data/lib/ace/support/config/molecules/yaml_loader.rb +81 -0
- data/lib/ace/support/config/organisms/config_resolver.rb +349 -0
- data/lib/ace/support/config/organisms/virtual_config_resolver.rb +141 -0
- data/lib/ace/support/config/version.rb +9 -0
- data/lib/ace/support/config.rb +277 -0
- data/lib/ace/support.rb +4 -0
- metadata +109 -0
|
@@ -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
|