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