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 ADDED
@@ -0,0 +1,7 @@
1
+ ---
2
+ SHA256:
3
+ metadata.gz: 74836504f416d8c5df7893889c12e585be26efe4fd8888e0ab12822b3a89dee3
4
+ data.tar.gz: 32061d2ab3f3185f8534416bea5ee77a556247b7a877cc75387dbc7116e3739d
5
+ SHA512:
6
+ metadata.gz: a06c49acfec49bdc87f26074ed6fd5a09250690a20fa69759eee4d31802576c52ae0c0bb0b9d676d40323e71ffe02640ad0d81bd05f39bd4a97004c1d25b4e89
7
+ data.tar.gz: 452c2e9b2e674cc8235dfbafd81a8cd0ab9844f7c4e737144b3244c5ecd1da12199037ac7eadbdb99cdc3edf1c4ecb69df31d4b7a3051047576a6ae85b733870
@@ -0,0 +1,23 @@
1
+ # ace-config default configuration
2
+ # This file provides defaults for the ace-config gem itself
3
+
4
+ config:
5
+ # Default configuration folder name
6
+ config_dir: ".ace"
7
+
8
+ # Default gem defaults folder name
9
+ defaults_dir: ".ace-defaults"
10
+
11
+ # Cascade settings
12
+ cascade:
13
+ enabled: true
14
+ merge_strategy: replace
15
+
16
+ # Default project root markers
17
+ project_markers:
18
+ - .git
19
+ - Gemfile
20
+ - package.json
21
+ - Cargo.toml
22
+ - pyproject.toml
23
+ - go.mod
data/CHANGELOG.md ADDED
@@ -0,0 +1,224 @@
1
+ # Changelog
2
+
3
+ All notable changes to this project will be documented in this file.
4
+
5
+ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
6
+ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
7
+
8
+ ## [Unreleased]
9
+
10
+ ## [0.9.0] - 2026-03-23
11
+
12
+ ### Technical
13
+ - Removed phantom `handbook/**/*` glob from gemspec (no handbook directory exists).
14
+
15
+ ## [0.8.5] - 2026-03-22
16
+
17
+ ### Technical
18
+ - Updated README examples to use `resolve_file` instead of deprecated `resolve_for`.
19
+
20
+ ## [0.8.4] - 2026-03-22
21
+
22
+ ### Technical
23
+ - Refreshed README structure with consistent tagline, corrected package naming, installation, basic usage, API overview, and ACE project footer
24
+
25
+ ## [0.8.3] - 2026-03-05
26
+
27
+ ### Technical
28
+ - Document `ProjectConfigScanner` in README with molecule list and comparison table vs `ConfigFinder`
29
+
30
+ ## [0.8.2] - 2026-03-05
31
+
32
+ ### Fixed
33
+ - Narrow `Errno::EACCES` rescue in `ProjectConfigScanner#find_ace_dirs` to per-path scope so a permission error on one directory does not abort the entire scan
34
+
35
+ ### Technical
36
+ - Add test for graceful degradation when a subdirectory is permission-restricted
37
+
38
+ ## [0.8.1] - 2026-03-05
39
+
40
+ ### Fixed
41
+ - Expand `SKIP_DIRS` in `ProjectConfigScanner` to include `.bundle`, `_legacy`, `.ace-local`, `.ace-tasks`, `.ace-taskflow` preventing false-positive config discovery in monorepo-ignored paths
42
+ - Memoize `scan` results to avoid repeated full filesystem traversals on multiple calls
43
+ - Deduplicate symlinked `.ace` directories using `File.realpath` tracking
44
+ - Use portable positional flags form for `Dir.glob` (`File::FNM_DOTMATCH` as positional arg)
45
+
46
+ ## [0.8.0] - 2026-03-05
47
+
48
+ ### Added
49
+ - `ProjectConfigScanner` molecule for downward project tree traversal to discover all `.ace` config folders across a monorepo
50
+
51
+ ## [0.7.2] - 2026-02-23
52
+
53
+ ### Technical
54
+ - Updated internal dependency version constraints to current releases
55
+
56
+ ## [0.7.1] - 2026-02-12
57
+
58
+ ### Fixed
59
+ - Stabilize performance test threshold for `resolve_namespace` overhead (2.0x → 3.0x) to reduce CI flakiness
60
+
61
+ ## [0.7.0] - 2026-01-27
62
+
63
+ ### Added
64
+ - Path rules support for configuration resolution with glob pattern matching
65
+ - Project scanning capability to discover nested package configurations
66
+ - `PathRuleMatcher` atom for matching file paths against glob patterns
67
+ - Support for glob arrays in path rules configuration
68
+
69
+ ### Changed
70
+ - Enhanced `ConfigResolver` to support path-based configuration splitting
71
+ - Refactored config resolution to enable scoped configuration per file path
72
+
73
+ ## [0.6.0] - 2026-01-11
74
+
75
+ ### Breaking Changes
76
+ - **Gem renamed** from `ace-config` to `ace-support-config`
77
+ - **Namespace changed** from `Ace::Config` to `Ace::Support::Config`
78
+ - Update gemspec dependency from `ace-config ~> 0.5` to `ace-support-config ~> 0.6`
79
+ - Update require statements from `require "ace/config"` to `require "ace/support/config"`
80
+ - Update class references from `Ace::Config` to `Ace::Support::Config`
81
+
82
+ ### Migration Guide
83
+ ```ruby
84
+ # Before
85
+ require 'ace/config'
86
+ config = Ace::Config.create
87
+ Ace::Config.test_mode = true
88
+
89
+ # After
90
+ require 'ace/support/config'
91
+ config = Ace::Support::Config.create
92
+ Ace::Support::Config.test_mode = true
93
+ ```
94
+
95
+ For gem maintainers:
96
+ ```ruby
97
+ # In your gemspec, change:
98
+ spec.add_dependency 'ace-config', '~> 0.5'
99
+ # To:
100
+ spec.add_dependency 'ace-support-config', '~> 0.6'
101
+
102
+ # In your code, change:
103
+ require 'ace/config'
104
+ # To:
105
+ require 'ace/support/config'
106
+
107
+ # And update class references:
108
+ Ace::Config.create → Ace::Support::Config.create
109
+ Ace::Config.test_mode = → Ace::Support::Config.test_mode =
110
+ ```
111
+
112
+ ## [0.5.1] - 2026-01-05
113
+
114
+ ### Fixed
115
+ - Stabilize performance tests and adjust thresholds for CI consistency
116
+ - Improve command default behavior and fix flaky test
117
+
118
+ ## [0.5.0] - 2026-01-03
119
+
120
+ ### Changed
121
+ - **BREAKING**: Minimum Ruby version raised to 3.3.0 (was 3.2.0)
122
+ - Standardized gemspec file patterns with deterministic Dir.glob
123
+ - Added MIT LICENSE file
124
+
125
+ ## [0.4.3] - 2026-01-03
126
+
127
+ ### Changed
128
+ - Optimized performance test execution time from 11.77s to 1.64s (85% improvement)
129
+ - Reduced loop iterations in performance tests (100-1000 → 10-50)
130
+ - Reduced cascade depth from 5 to 2 levels for faster tests
131
+ - Reduced file count from 50 to 10 in file-based tests
132
+ - Extracted iteration count constants (CASCADE_ITERATIONS, GLOB_ITERATIONS, FINDER_ITERATIONS, TEST_MODE_ITERATIONS)
133
+ - Implemented median-based timing metrics instead of average for robustness with small sample sizes
134
+ - Added deep cascade correctness test to maintain coverage at depth 5
135
+
136
+ ### Technical
137
+ - Added performance measurement helpers (`measure_iterations`, `median_time`, `format_time`)
138
+ - Separated constants for I/O-bound vs CPU-bound operations tuning
139
+
140
+ ## [0.4.2] - 2026-01-02
141
+
142
+ ### Added
143
+ - Test mode for faster test execution (`Ace::Config.test_mode = true`)
144
+ - `ACE_CONFIG_TEST_MODE` environment variable for CI/test runner integration (case-insensitive)
145
+ - `mock_config` parameter to `Ace::Config.create` for providing mock data in tests
146
+ - `test_mode` parameter to `Ace::Config.create` for explicit test mode control
147
+ - Thread-safe test mode state using `Thread.current` for parallel test environments
148
+ - Test mode short-circuit in `resolve_type` and `find_configs` methods
149
+
150
+ ## [0.4.1] - 2025-12-31
151
+
152
+ ### Technical
153
+ - Add comprehensive edge case and custom path tests (Task 157.10)
154
+
155
+ ## [0.4.0] - 2025-12-30
156
+
157
+ ### Added
158
+ - `merge()` method on Config model as the primary method for merging configuration data
159
+ - `with()` remains as an alias for backward compatibility
160
+
161
+ ## [0.3.0] - 2025-12-30
162
+
163
+ ### Added
164
+ - `resolve_namespace(*segments, filename: "config")` method to ConfigResolver for simplified namespace-based config resolution
165
+ - Uses `File.join` for cross-platform path construction
166
+ - Sanitizes segments (flatten, compact, stringify, strip whitespace, reject empty)
167
+ - Documented in README and usage.md
168
+ - Runtime dependency on `ace-support-fs` for filesystem utilities (PathExpander, ProjectRootFinder, DirectoryTraverser)
169
+ - `class_get_env` class method on PathExpander for consistent ENV access pattern across class and instance methods
170
+ - Documentation section on directory naming conventions (`.ace-defaults/` vs `.ace/` vs `.ace.example/`)
171
+ - `glob_to_regex` now supports bracket character classes (`[a-z]`, `[abc]`)
172
+ - Documentation for `resolve_for` clarifying it's intentionally not memoized
173
+ - `Date` class to permitted YAML classes for parsing date values in config files
174
+
175
+ ### Changed
176
+ - Reorganized ConfigResolver methods: all public methods grouped together before private section
177
+
178
+ ### Breaking Changes
179
+ - None
180
+
181
+ ### Fixed
182
+ - Gemfile.lock version mismatch (was 0.1.0, now correctly shows 0.2.0)
183
+
184
+ ## [0.2.0] - 2025-12-28
185
+
186
+ ### Added
187
+ - Initial release of ace-config gem
188
+ - Generic configuration cascade with customizable folder names
189
+ - `Ace::Config.create` factory method for creating resolvers
190
+ - `Ace::Config.virtual_resolver` factory method for virtual filesystem view
191
+ - Configurable `config_dir` and `defaults_dir` parameters
192
+ - Support for gem defaults via `gem_path` parameter
193
+ - Deep merging with configurable array strategies (:replace, :concat, :union)
194
+ - Project root detection with customizable markers
195
+ - Path expansion with environment variable and protocol support
196
+ - YAML parsing with error handling
197
+ - Virtual config resolver for cascade filesystem view
198
+ - Memoization for `resolve()` and `get()` methods in ConfigResolver
199
+ - Windows compatibility via `File::ALT_SEPARATOR` support
200
+
201
+ ### Fixed
202
+ - ConfigFinder uses stable `start_path` instead of mutable `Dir.pwd`
203
+ - `find_file`/`find_all_files` now respect `use_traversal` parameter
204
+ - YamlLoader `merge_strategy` parameter properly applied
205
+ - PathExpander raises exception instead of returning error hash
206
+
207
+ ### Changed
208
+ - Gemspec excludes `test/` directory from built gem
209
+ - ENV access extracted to protected `get_env` method for testability
210
+
211
+ ### Components Extracted from ace-support-core
212
+ - **Atoms**: DeepMerger, YamlParser, PathExpander
213
+ - **Molecules**: ConfigFinder, DirectoryTraverser, ProjectRootFinder, YamlLoader
214
+ - **Organisms**: ConfigResolver, VirtualConfigResolver
215
+ - **Models**: Config, CascadePath
216
+ - **Errors**: ConfigNotFoundError, YamlParseError, PathError, MergeStrategyError
217
+
218
+ ## [0.1.0] - 2025-12-28
219
+
220
+ ### Added
221
+ - Initial gem structure
222
+ - Public API design with `Ace::Config.create` factory
223
+ - Full configuration cascade implementation
224
+ - Zero runtime dependencies (stdlib only)
data/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ The MIT License (MIT)
2
+
3
+ Copyright (c) 2025 ACE Team
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in
13
+ all copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
21
+ THE SOFTWARE.
data/README.md ADDED
@@ -0,0 +1,35 @@
1
+ <div align="center">
2
+ <h1> ACE - Support Config </h1>
3
+
4
+ Shared configuration cascade primitives for ACE libraries and tools.
5
+
6
+ <img src="https://raw.githubusercontent.com/cs3b/ace/main/docs/brand/AgenticCodingEnvironment.Logo.XS.jpg" alt="ACE Logo" width="480">
7
+ <br><br>
8
+
9
+ <a href="https://rubygems.org/gems/ace-support-config"><img alt="Gem Version" src="https://img.shields.io/gem/v/ace-support-config.svg" /></a>
10
+ <a href="https://www.ruby-lang.org"><img alt="Ruby" src="https://img.shields.io/badge/Ruby-3.2+-CC342D?logo=ruby" /></a>
11
+ <a href="https://opensource.org/licenses/MIT"><img alt="License: MIT" src="https://img.shields.io/badge/License-MIT-blue.svg" /></a>
12
+
13
+ </div>
14
+
15
+ > Works with: Claude Code, Codex CLI, OpenCode, Gemini CLI, pi-agent, and more.
16
+
17
+ [Usage Guide](docs/usage.md)
18
+ `ace-support-config` provides layered configuration loading and merging for ACE, resolving values from `.ace` project files, user home defaults, and gem-bundled defaults with deterministic precedence. Used by [ace-llm](../ace-llm), [ace-search](../ace-search), [ace-review](../ace-review), and most other ACE packages.
19
+
20
+ ## How It Works
21
+
22
+ 1. A resolver builds a configuration cascade from the nearest `.ace` directory up to user-home and gem-default layers.
23
+ 2. Resolved values are merged using configurable merge strategies with deterministic precedence.
24
+ 3. Consumers access resolved config by namespace, file path, or direct lookup.
25
+
26
+ ## Use Cases
27
+
28
+ **Load layered configuration safely** - combine project, user, and default values with deterministic precedence for any ACE package.
29
+
30
+ **Support project-specific overrides** - place `.ace` files near the execution context to customize behavior while keeping defaults stable across tools like [ace-llm](../ace-llm) and [ace-review](../ace-review).
31
+
32
+ **Resolve namespaces consistently** - access configuration across tools using shared resolver methods, so [ace-llm-providers-cli](../ace-llm-providers-cli) and [ace-search](../ace-search) get the same cascade behavior.
33
+
34
+ ---
35
+ [Usage Guide](docs/usage.md) | Part of [ACE](https://github.com/cs3b/ace)
data/Rakefile ADDED
@@ -0,0 +1,15 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "bundler/gem_tasks"
4
+ require "rake/testtask"
5
+
6
+ Rake::TestTask.new(:test) do |t|
7
+ t.libs << "test"
8
+ t.libs << "lib"
9
+ t.test_files = FileList["test/**/*_test.rb"]
10
+ end
11
+
12
+ # Alias for CI compatibility
13
+ task spec: :test
14
+
15
+ task default: :test
@@ -0,0 +1,126 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Ace
4
+ module Support
5
+ module Config
6
+ module Atoms
7
+ # Pure deep merge functions for hashes
8
+ module DeepMerger
9
+ module_function
10
+
11
+ # Deep merge two hashes
12
+ #
13
+ # @param base [Hash] Base hash
14
+ # @param other [Hash] Hash to merge into base
15
+ # @param options [Hash] Merge options
16
+ # @option options [Symbol] :array_strategy How to handle arrays
17
+ # :replace - Replace base array with overlay (default)
18
+ # :concat - Concatenate arrays
19
+ # :union - Set union (dedupe by value)
20
+ # :coerce_union - Coerce scalars to arrays, union, filter blanks
21
+ # @return [Hash] Merged hash (new object)
22
+ #
23
+ # @note Uses shallow dup at top level (standard Ruby pattern). Nested hashes
24
+ # are recursively merged into new objects, so mutation risk is minimal.
25
+ # For a completely isolated deep copy, use: `merge({}, original_hash)`
26
+ def merge(base, other, options = {})
27
+ return other.dup if base.nil?
28
+ return base.dup if other.nil?
29
+
30
+ array_strategy = options[:array_strategy] || :replace
31
+
32
+ result = base.dup
33
+
34
+ other.each do |key, other_value|
35
+ base_value = result[key]
36
+
37
+ result[key] = if base_value.is_a?(Hash) && other_value.is_a?(Hash)
38
+ merge(base_value, other_value, options)
39
+ elsif array_strategy == :coerce_union
40
+ merge_with_coercion(base_value, other_value)
41
+ elsif base_value.is_a?(Array) && other_value.is_a?(Array)
42
+ merge_arrays(base_value, other_value, array_strategy)
43
+ else
44
+ other_value
45
+ end
46
+ end
47
+
48
+ result
49
+ end
50
+
51
+ # Deep merge multiple hashes in order
52
+ # @param hashes [Array<Hash>] Hashes to merge
53
+ # @param options [Hash] Merge options
54
+ # @return [Hash] Merged result
55
+ def merge_all(*hashes, **options)
56
+ hashes = hashes.flatten.compact
57
+ return {} if hashes.empty?
58
+
59
+ hashes.reduce({}) do |result, hash|
60
+ merge(result, hash, options)
61
+ end
62
+ end
63
+
64
+ # Check if value is mergeable
65
+ # @param value [Object] Value to check
66
+ # @return [Boolean] true if value can be deep merged
67
+ def mergeable?(value)
68
+ value.is_a?(Hash) || value.is_a?(Array)
69
+ end
70
+
71
+ # Merge two arrays based on strategy
72
+ # @param base_array [Array] Base array
73
+ # @param other_array [Array] Array to merge
74
+ # @param strategy [Symbol] Merge strategy
75
+ # @return [Array] Merged array
76
+ def merge_arrays(base_array, other_array, strategy)
77
+ case strategy
78
+ when :concat
79
+ base_array + other_array
80
+ when :union
81
+ base_array | other_array
82
+ when :replace
83
+ other_array
84
+ else
85
+ raise MergeStrategyError, "Unknown array merge strategy: #{strategy}"
86
+ end
87
+ end
88
+
89
+ # Merge values with scalar-to-array coercion for :coerce_union strategy
90
+ # @param base_value [Object] Base value (may be array, scalar, or nil)
91
+ # @param other_value [Object] Overlay value
92
+ # @return [Object] Merged result
93
+ def merge_with_coercion(base_value, other_value)
94
+ base_arr = coerce_to_array(base_value)
95
+ other_arr = coerce_to_array(other_value)
96
+
97
+ # New key with scalar: keep as scalar
98
+ return other_value if base_arr.nil? && !other_value.is_a?(Array)
99
+ # New key with array: normalize
100
+ return normalize_array(other_arr) if base_arr.nil?
101
+ # Existing key, new value nil: keep existing normalized
102
+ return normalize_array(base_arr) if other_arr.nil?
103
+
104
+ # Both have values: union and normalize
105
+ normalize_array(base_arr | other_arr)
106
+ end
107
+
108
+ # Coerce value to array if not nil
109
+ # @param value [Object] Value to coerce
110
+ # @return [Array, nil] Array or nil if value was nil
111
+ def coerce_to_array(value)
112
+ return nil if value.nil?
113
+ value.is_a?(Array) ? value : [value]
114
+ end
115
+
116
+ # Normalize array: remove nil/empty, deduplicate
117
+ # @param arr [Array] Array to normalize
118
+ # @return [Array] Normalized array
119
+ def normalize_array(arr)
120
+ arr.reject { |v| v.nil? || (v.respond_to?(:empty?) && v.empty?) }.uniq
121
+ end
122
+ end
123
+ end
124
+ end
125
+ end
126
+ end
@@ -0,0 +1,118 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Ace
4
+ module Support
5
+ module Config
6
+ module Atoms
7
+ # Match file paths against path-based config rules
8
+ class PathRuleMatcher
9
+ MatchResult = Struct.new(:name, :config, keyword_init: true)
10
+
11
+ # @param path_rules [Hash] Path rules with optional _config_root metadata
12
+ # @param project_root [String, nil] Project root for relative path calculation
13
+ def initialize(path_rules, project_root: nil)
14
+ @path_rules = path_rules || {}
15
+ @project_root = project_root
16
+ end
17
+
18
+ # Match file path against configured rules
19
+ # @param file_path [String] File path relative to project root
20
+ # @return [MatchResult, nil] Match result or nil
21
+ def match(file_path)
22
+ return nil if @path_rules.nil? || @path_rules.empty?
23
+ return nil if file_path.nil? || file_path.to_s.empty?
24
+
25
+ normalized = normalize_path(file_path)
26
+
27
+ @path_rules.each do |name, rule|
28
+ next unless rule.is_a?(Hash)
29
+
30
+ glob = rule["glob"] || rule[:glob]
31
+ globs = Array(glob).compact.map(&:to_s).reject(&:empty?)
32
+ next if globs.empty?
33
+
34
+ # Calculate path relative to rule's config root
35
+ # Returns nil if file is outside config root's scope
36
+ path_to_match = path_relative_to_config(normalized, rule)
37
+ next if path_to_match.nil?
38
+
39
+ globs.each do |glob_pattern|
40
+ next unless File.fnmatch?(glob_pattern, path_to_match, match_flags(glob_pattern))
41
+
42
+ return MatchResult.new(
43
+ name: name.to_s,
44
+ config: extract_config(rule)
45
+ )
46
+ end
47
+ end
48
+
49
+ nil
50
+ end
51
+
52
+ private
53
+
54
+ def normalize_path(path)
55
+ path.to_s.sub(%r{\A\./}, "")
56
+ end
57
+
58
+ # Convert file path to be relative to the rule's config root
59
+ # @param file_path [String] Path relative to project root
60
+ # @param rule [Hash] Rule with optional _config_root
61
+ # @return [String, nil] Path relative to config root, or nil if file is outside scope
62
+ def path_relative_to_config(file_path, rule)
63
+ config_root = rule["_config_root"]
64
+ return file_path unless config_root && @project_root
65
+
66
+ # Config root is absolute, project root is absolute
67
+ # File path is relative to project root
68
+ # We need to make file path relative to config root
69
+ project_root_normalized = normalize_directory(@project_root)
70
+ config_root_normalized = normalize_directory(config_root)
71
+
72
+ # If config root equals project root, no adjustment needed
73
+ return file_path if config_root_normalized == project_root_normalized
74
+
75
+ # Get config root as relative path from project root
76
+ config_relative = relative_path(config_root_normalized, project_root_normalized)
77
+ return file_path if config_relative.nil? || config_relative.empty?
78
+
79
+ # If file path starts with config relative, strip it
80
+ # Otherwise, file is outside this config's scope - return nil to skip this rule
81
+ prefix = config_relative + "/"
82
+ if file_path.start_with?(prefix)
83
+ file_path.sub(prefix, "")
84
+ end
85
+ end
86
+
87
+ # Get relative path from base to target
88
+ def relative_path(target, base)
89
+ return nil if target == base
90
+ return nil unless target.start_with?(base + "/")
91
+
92
+ target.sub("#{base}/", "")
93
+ end
94
+
95
+ def normalize_directory(path)
96
+ File.expand_path(path.to_s)
97
+ end
98
+
99
+ def extract_config(rule)
100
+ rule.each_with_object({}) do |(key, value), acc|
101
+ # Skip internal metadata and glob
102
+ next if key.to_s == "glob"
103
+ next if key.to_s.start_with?("_")
104
+
105
+ acc[key.to_s] = value
106
+ end
107
+ end
108
+
109
+ def match_flags(glob)
110
+ flags = File::FNM_DOTMATCH | File::FNM_EXTGLOB
111
+ flags |= File::FNM_PATHNAME unless glob.to_s.include?("**")
112
+ flags
113
+ end
114
+ end
115
+ end
116
+ end
117
+ end
118
+ end
@@ -0,0 +1,76 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Ace
4
+ module Support
5
+ module Config
6
+ module Atoms
7
+ # Validates path segments for security
8
+ # Prevents path traversal attacks via ".." and absolute paths (Unix and Windows)
9
+ #
10
+ # This module provides pure validation functions that check path segments
11
+ # for potentially dangerous patterns without any side effects.
12
+ #
13
+ # @example Validate a namespace segment
14
+ # PathValidator.validate_segment!("valid_name") # => true
15
+ # PathValidator.validate_segment!("..") # => raises ArgumentError
16
+ # PathValidator.validate_segment!("/absolute") # => raises ArgumentError
17
+ # PathValidator.validate_segment!("C:\\path") # => raises ArgumentError
18
+ #
19
+ # @example Validate multiple segments
20
+ # PathValidator.validate_segments!(["config", "nested", "file"]) # => true
21
+ # PathValidator.validate_segments!(["config", "..", "secret"]) # => raises ArgumentError
22
+ #
23
+ module PathValidator
24
+ class << self
25
+ # Validate a single path segment for security
26
+ # @param segment [String] Segment to validate
27
+ # @raise [ArgumentError] If segment contains invalid characters
28
+ # @return [true] If validation passes
29
+ def validate_segment!(segment)
30
+ if segment.include?("..")
31
+ raise ArgumentError, "Invalid path segment: #{segment.inspect} (path traversal not allowed)"
32
+ end
33
+ if segment.start_with?("/")
34
+ raise ArgumentError, "Invalid path segment: #{segment.inspect} (absolute paths not allowed)"
35
+ end
36
+ # Windows-style absolute paths: drive letters (C:) or UNC paths (\\server)
37
+ if segment.start_with?("\\") || segment.match?(/\A[A-Za-z]:/)
38
+ raise ArgumentError, "Invalid path segment: #{segment.inspect} (absolute paths not allowed)"
39
+ end
40
+ true
41
+ end
42
+
43
+ # Validate multiple path segments for security
44
+ # @param segments [Array<String>] Segments to validate
45
+ # @raise [ArgumentError] If any segment contains invalid characters
46
+ # @return [true] If validation passes
47
+ def validate_segments!(segments)
48
+ segments.each { |segment| validate_segment!(segment) }
49
+ true
50
+ end
51
+
52
+ # Check if a segment is valid (non-raising version)
53
+ # @param segment [String] Segment to check
54
+ # @return [Boolean] true if valid, false otherwise
55
+ def valid_segment?(segment)
56
+ validate_segment!(segment)
57
+ true
58
+ rescue ArgumentError
59
+ false
60
+ end
61
+
62
+ # Check if all segments are valid (non-raising version)
63
+ # @param segments [Array<String>] Segments to check
64
+ # @return [Boolean] true if all valid, false otherwise
65
+ def valid_segments?(segments)
66
+ validate_segments!(segments)
67
+ true
68
+ rescue ArgumentError
69
+ false
70
+ end
71
+ end
72
+ end
73
+ end
74
+ end
75
+ end
76
+ end