ace-bundle 0.40.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/bundle/config.yml +28 -0
- data/.ace-defaults/bundle/presets/base.md +15 -0
- data/.ace-defaults/bundle/presets/code-review.md +61 -0
- data/.ace-defaults/bundle/presets/development.md +16 -0
- data/.ace-defaults/bundle/presets/documentation-review.md +52 -0
- data/.ace-defaults/bundle/presets/mixed-content-example.md +94 -0
- data/.ace-defaults/bundle/presets/project-context.md +79 -0
- data/.ace-defaults/bundle/presets/project.md +35 -0
- data/.ace-defaults/bundle/presets/section-example-simple.md +27 -0
- data/.ace-defaults/bundle/presets/security-review.md +53 -0
- data/.ace-defaults/bundle/presets/simple-project.md +43 -0
- data/.ace-defaults/bundle/presets/team.md +18 -0
- data/.ace-defaults/nav/protocols/wfi-sources/ace-bundle.yml +19 -0
- data/CHANGELOG.md +384 -0
- data/LICENSE +21 -0
- data/README.md +40 -0
- data/Rakefile +22 -0
- data/exe/ace-bundle +14 -0
- data/handbook/skills/as-bundle/SKILL.md +28 -0
- data/handbook/skills/as-onboard/SKILL.md +33 -0
- data/handbook/workflow-instructions/bundle.wf.md +111 -0
- data/handbook/workflow-instructions/onboard.wf.md +20 -0
- data/lib/ace/bundle/atoms/boundary_finder.rb +122 -0
- data/lib/ace/bundle/atoms/bundle_normalizer.rb +128 -0
- data/lib/ace/bundle/atoms/content_checker.rb +46 -0
- data/lib/ace/bundle/atoms/line_counter.rb +37 -0
- data/lib/ace/bundle/atoms/preset_list_formatter.rb +44 -0
- data/lib/ace/bundle/atoms/preset_validator.rb +69 -0
- data/lib/ace/bundle/atoms/section_validator.rb +215 -0
- data/lib/ace/bundle/atoms/typo_detector.rb +76 -0
- data/lib/ace/bundle/cli/commands/load.rb +347 -0
- data/lib/ace/bundle/cli.rb +26 -0
- data/lib/ace/bundle/models/bundle_data.rb +75 -0
- data/lib/ace/bundle/molecules/bundle_chunker.rb +280 -0
- data/lib/ace/bundle/molecules/bundle_file_writer.rb +269 -0
- data/lib/ace/bundle/molecules/bundle_merger.rb +248 -0
- data/lib/ace/bundle/molecules/preset_manager.rb +331 -0
- data/lib/ace/bundle/molecules/section_compressor.rb +249 -0
- data/lib/ace/bundle/molecules/section_formatter.rb +580 -0
- data/lib/ace/bundle/molecules/section_processor.rb +460 -0
- data/lib/ace/bundle/organisms/bundle_loader.rb +1436 -0
- data/lib/ace/bundle/organisms/pr_bundle_loader.rb +147 -0
- data/lib/ace/bundle/version.rb +7 -0
- data/lib/ace/bundle.rb +251 -0
- metadata +190 -0
|
@@ -0,0 +1,122 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Ace
|
|
4
|
+
module Bundle
|
|
5
|
+
module Atoms
|
|
6
|
+
# Pure functions to find semantic boundaries in XML-structured content
|
|
7
|
+
# Used by ContextChunker to split content at clean boundaries
|
|
8
|
+
# (between </file> and <file>, between </output> and <output>)
|
|
9
|
+
#
|
|
10
|
+
# ## Whitespace Handling
|
|
11
|
+
#
|
|
12
|
+
# Whitespace-only content between XML elements is intentionally dropped.
|
|
13
|
+
# This means the sum of block line counts may be less than the total
|
|
14
|
+
# content line count. This is acceptable because:
|
|
15
|
+
# - The primary goal is preserving XML element integrity, not exact line counting
|
|
16
|
+
# - Chunk limits are approximate; slightly exceeding is better than splitting elements
|
|
17
|
+
# - Typical variance is ~2-5% of content lines
|
|
18
|
+
#
|
|
19
|
+
# @example Whitespace between elements
|
|
20
|
+
# content = "<file>a</file>\n\n<file>b</file>"
|
|
21
|
+
# blocks = BoundaryFinder.parse_blocks(content)
|
|
22
|
+
# # => 2 blocks (whitespace between them is dropped)
|
|
23
|
+
# # Block line sum: 2, Content lines: 3
|
|
24
|
+
#
|
|
25
|
+
module BoundaryFinder
|
|
26
|
+
# XML element patterns for semantic blocks
|
|
27
|
+
# These elements should never be split in the middle
|
|
28
|
+
FILE_ELEMENT_PATTERN = %r{<file\s+[^>]*>.*?</file>}m
|
|
29
|
+
OUTPUT_ELEMENT_PATTERN = %r{<output\s+[^>]*>.*?</output>}m
|
|
30
|
+
|
|
31
|
+
module_function
|
|
32
|
+
|
|
33
|
+
# Parse content into semantic blocks
|
|
34
|
+
# Each block represents a unit that should not be split
|
|
35
|
+
#
|
|
36
|
+
# @param content [String] Content to parse
|
|
37
|
+
# @return [Array<Hash>] Array of blocks, each with :content, :type, :lines
|
|
38
|
+
#
|
|
39
|
+
# @example Parse content with file elements
|
|
40
|
+
# blocks = BoundaryFinder.parse_blocks("# Header\n<file path='a.rb'>code</file>")
|
|
41
|
+
# # => [{content: "# Header\n", type: :text, lines: 1},
|
|
42
|
+
# # {content: "<file path='a.rb'>code</file>", type: :file, lines: 1}]
|
|
43
|
+
def parse_blocks(content)
|
|
44
|
+
return [] if content.nil? || content.empty?
|
|
45
|
+
|
|
46
|
+
blocks = []
|
|
47
|
+
remaining = content
|
|
48
|
+
|
|
49
|
+
while remaining && !remaining.empty?
|
|
50
|
+
# Find the next XML element (file or output)
|
|
51
|
+
file_match = remaining.match(FILE_ELEMENT_PATTERN)
|
|
52
|
+
output_match = remaining.match(OUTPUT_ELEMENT_PATTERN)
|
|
53
|
+
|
|
54
|
+
# Determine which comes first
|
|
55
|
+
next_match = nil
|
|
56
|
+
match_type = nil
|
|
57
|
+
|
|
58
|
+
if file_match && output_match
|
|
59
|
+
if file_match.begin(0) <= output_match.begin(0)
|
|
60
|
+
next_match = file_match
|
|
61
|
+
match_type = :file
|
|
62
|
+
else
|
|
63
|
+
next_match = output_match
|
|
64
|
+
match_type = :output
|
|
65
|
+
end
|
|
66
|
+
elsif file_match
|
|
67
|
+
next_match = file_match
|
|
68
|
+
match_type = :file
|
|
69
|
+
elsif output_match
|
|
70
|
+
next_match = output_match
|
|
71
|
+
match_type = :output
|
|
72
|
+
end
|
|
73
|
+
|
|
74
|
+
if next_match
|
|
75
|
+
# Add text before the match as a text block (if non-whitespace)
|
|
76
|
+
if next_match.begin(0) > 0
|
|
77
|
+
text_content = remaining[0...next_match.begin(0)]
|
|
78
|
+
# Only add text blocks with actual content (not just whitespace)
|
|
79
|
+
blocks << create_block(text_content, :text) unless text_content.strip.empty?
|
|
80
|
+
end
|
|
81
|
+
|
|
82
|
+
# Add the XML element as a block
|
|
83
|
+
blocks << create_block(next_match[0], match_type)
|
|
84
|
+
|
|
85
|
+
# Move past this match
|
|
86
|
+
remaining = remaining[next_match.end(0)..]
|
|
87
|
+
else
|
|
88
|
+
# No more XML elements, add remaining as text (if non-whitespace)
|
|
89
|
+
blocks << create_block(remaining, :text) unless remaining.strip.empty?
|
|
90
|
+
break
|
|
91
|
+
end
|
|
92
|
+
end
|
|
93
|
+
|
|
94
|
+
blocks
|
|
95
|
+
end
|
|
96
|
+
|
|
97
|
+
# Check if content contains XML elements that require semantic chunking
|
|
98
|
+
# @param content [String] Content to check
|
|
99
|
+
# @return [Boolean] true if content has file or output elements
|
|
100
|
+
def has_semantic_elements?(content)
|
|
101
|
+
return false if content.nil? || content.empty?
|
|
102
|
+
|
|
103
|
+
content.match?(FILE_ELEMENT_PATTERN) || content.match?(OUTPUT_ELEMENT_PATTERN)
|
|
104
|
+
end
|
|
105
|
+
|
|
106
|
+
private_class_method
|
|
107
|
+
|
|
108
|
+
# Create a block hash
|
|
109
|
+
# @param content [String] Block content
|
|
110
|
+
# @param type [Symbol] Block type (:file, :output, :text)
|
|
111
|
+
# @return [Hash] Block with content, type, and line count
|
|
112
|
+
def create_block(content, type)
|
|
113
|
+
{
|
|
114
|
+
content: content,
|
|
115
|
+
type: type,
|
|
116
|
+
lines: content.lines.size
|
|
117
|
+
}
|
|
118
|
+
end
|
|
119
|
+
end
|
|
120
|
+
end
|
|
121
|
+
end
|
|
122
|
+
end
|
|
@@ -0,0 +1,128 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Ace
|
|
4
|
+
module Bundle
|
|
5
|
+
module Atoms
|
|
6
|
+
# Normalizes bundle configuration into ace-bundle compatible structure
|
|
7
|
+
#
|
|
8
|
+
# Handles various input formats and ensures proper structure for ace-bundle:
|
|
9
|
+
# - String inputs (preset names) wrapped in bundle.presets array
|
|
10
|
+
# - Hashes with top-level base: key moved to bundle.base
|
|
11
|
+
# - Hashes with both base: and bundle: keys properly merged
|
|
12
|
+
# - Properly structured configs passed through unchanged
|
|
13
|
+
# - Normalizes all input to bundle: key structure for ace-bundle compatibility
|
|
14
|
+
#
|
|
15
|
+
class BundleNormalizer
|
|
16
|
+
# Normalize various input types to proper ace-bundle structure
|
|
17
|
+
#
|
|
18
|
+
# @param input [String, Hash, nil] Bundle configuration input
|
|
19
|
+
# @return [Hash] Normalized bundle configuration
|
|
20
|
+
#
|
|
21
|
+
# @example String input (preset name)
|
|
22
|
+
# normalize_config("project")
|
|
23
|
+
# #=> { "bundle" => { "presets" => ["project"] } }
|
|
24
|
+
#
|
|
25
|
+
# @example Hash with top-level base key
|
|
26
|
+
# normalize_config({ "base" => "custom content", "files" => ["README.md"] })
|
|
27
|
+
# #=> { "bundle" => { "base" => "custom content", "files" => ["README.md"] } }
|
|
28
|
+
#
|
|
29
|
+
# @example Hash with both base and bundle keys
|
|
30
|
+
# normalize_config({ "base" => "content", "bundle" => { "presets" => ["project"] } })
|
|
31
|
+
# #=> { "bundle" => { "base" => "content", "presets" => ["project"] } }
|
|
32
|
+
#
|
|
33
|
+
# @example Properly structured config (unchanged)
|
|
34
|
+
# normalize_config({ "bundle" => { "base" => "content" } })
|
|
35
|
+
# #=> { "bundle" => { "base" => "content" } }
|
|
36
|
+
def self.normalize_config(input)
|
|
37
|
+
case input
|
|
38
|
+
when String
|
|
39
|
+
# String input (e.g., "project", "staged") -> wrap as preset
|
|
40
|
+
{"bundle" => {"presets" => [input]}}
|
|
41
|
+
when Hash
|
|
42
|
+
normalize_hash_config(input)
|
|
43
|
+
when NilClass
|
|
44
|
+
# Return empty config for nil
|
|
45
|
+
{}
|
|
46
|
+
else
|
|
47
|
+
# Fallback for unexpected types
|
|
48
|
+
{}
|
|
49
|
+
end
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
# Normalize hash-based bundle configuration
|
|
53
|
+
#
|
|
54
|
+
# @param input [Hash] Bundle configuration hash
|
|
55
|
+
# @return [Hash] Normalized configuration
|
|
56
|
+
# @api private
|
|
57
|
+
def self.normalize_hash_config(input)
|
|
58
|
+
# Check if this config has a top-level "base" key that needs to be moved
|
|
59
|
+
has_base = input.key?("base") || input.key?(:base)
|
|
60
|
+
# Check for bundle: configuration key
|
|
61
|
+
has_bundle_config = input.key?("bundle") || input.key?(:bundle)
|
|
62
|
+
|
|
63
|
+
if has_base && !has_bundle_config
|
|
64
|
+
# Case 1: Config has base: at top level but no bundle: key
|
|
65
|
+
# Need to move base under bundle.base and wrap other keys
|
|
66
|
+
wrap_base_in_bundle(input)
|
|
67
|
+
elsif has_base && has_bundle_config
|
|
68
|
+
# Case 2: Config has both base: and bundle: at top level
|
|
69
|
+
# Move base under bundle.base
|
|
70
|
+
merge_base_into_bundle(input)
|
|
71
|
+
else
|
|
72
|
+
# Case 3: Config already properly structured or doesn't need normalization
|
|
73
|
+
input
|
|
74
|
+
end
|
|
75
|
+
end
|
|
76
|
+
|
|
77
|
+
# Wrap top-level base and other keys under bundle
|
|
78
|
+
#
|
|
79
|
+
# @param input [Hash] Configuration with top-level base
|
|
80
|
+
# @return [Hash] Configuration with base under bundle.base
|
|
81
|
+
# @api private
|
|
82
|
+
def self.wrap_base_in_bundle(input)
|
|
83
|
+
normalized = {"bundle" => {}}
|
|
84
|
+
|
|
85
|
+
input.each do |key, value|
|
|
86
|
+
key_str = key.to_s
|
|
87
|
+
if key_str == "base"
|
|
88
|
+
# Move top-level base to bundle.base
|
|
89
|
+
normalized["bundle"]["base"] = value
|
|
90
|
+
else
|
|
91
|
+
# Other top-level keys go under bundle
|
|
92
|
+
normalized["bundle"][key_str] = value
|
|
93
|
+
end
|
|
94
|
+
end
|
|
95
|
+
|
|
96
|
+
normalized
|
|
97
|
+
end
|
|
98
|
+
|
|
99
|
+
# Merge top-level base into existing bundle.base
|
|
100
|
+
#
|
|
101
|
+
# @param input [Hash] Configuration with both base and bundle keys
|
|
102
|
+
# @return [Hash] Configuration with base merged into bundle
|
|
103
|
+
# @api private
|
|
104
|
+
def self.merge_base_into_bundle(input)
|
|
105
|
+
normalized = {}
|
|
106
|
+
base_value = input["base"] || input[:base]
|
|
107
|
+
|
|
108
|
+
input.each do |key, value|
|
|
109
|
+
key_str = key.to_s
|
|
110
|
+
if key_str == "base"
|
|
111
|
+
# Skip - will add under bundle.base below
|
|
112
|
+
next
|
|
113
|
+
elsif key_str == "bundle"
|
|
114
|
+
# Merge base value into existing bundle
|
|
115
|
+
bundle_hash = value.is_a?(Hash) ? value.dup : {}
|
|
116
|
+
bundle_hash["base"] = base_value
|
|
117
|
+
normalized["bundle"] = bundle_hash
|
|
118
|
+
else
|
|
119
|
+
normalized[key_str] = value
|
|
120
|
+
end
|
|
121
|
+
end
|
|
122
|
+
|
|
123
|
+
normalized
|
|
124
|
+
end
|
|
125
|
+
end
|
|
126
|
+
end
|
|
127
|
+
end
|
|
128
|
+
end
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Ace
|
|
4
|
+
module Bundle
|
|
5
|
+
module Atoms
|
|
6
|
+
# Pure functions for checking section content types
|
|
7
|
+
# Used by both SectionProcessor and ContextLoader
|
|
8
|
+
module ContentChecker
|
|
9
|
+
class << self
|
|
10
|
+
# Checks if section has diffs content
|
|
11
|
+
# Note: For _processed_diffs, we check for non-empty arrays to avoid treating
|
|
12
|
+
# empty arrays as valid diff content (which would trigger merge logic unnecessarily)
|
|
13
|
+
# @param section_data [Hash] section data with symbol or string keys
|
|
14
|
+
# @return [Boolean] true if section has ranges, diffs, or non-empty _processed_diffs
|
|
15
|
+
def has_diffs_content?(section_data)
|
|
16
|
+
ranges = section_data[:ranges] || section_data["ranges"]
|
|
17
|
+
diffs = section_data[:diffs] || section_data["diffs"]
|
|
18
|
+
processed_diffs = section_data[:_processed_diffs] || section_data["_processed_diffs"]
|
|
19
|
+
!!(ranges || diffs || (processed_diffs.is_a?(Array) && processed_diffs.any?))
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
# Checks if section has files content
|
|
23
|
+
# @param section_data [Hash] section data with symbol or string keys
|
|
24
|
+
# @return [Boolean] true if section has files
|
|
25
|
+
def has_files_content?(section_data)
|
|
26
|
+
!!(section_data[:files] || section_data["files"])
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
# Checks if section has commands content
|
|
30
|
+
# @param section_data [Hash] section data with symbol or string keys
|
|
31
|
+
# @return [Boolean] true if section has commands
|
|
32
|
+
def has_commands_content?(section_data)
|
|
33
|
+
!!(section_data[:commands] || section_data["commands"])
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
# Checks if section has inline content
|
|
37
|
+
# @param section_data [Hash] section data with symbol or string keys
|
|
38
|
+
# @return [Boolean] true if section has content
|
|
39
|
+
def has_content_content?(section_data)
|
|
40
|
+
!!(section_data[:content] || section_data["content"])
|
|
41
|
+
end
|
|
42
|
+
end
|
|
43
|
+
end
|
|
44
|
+
end
|
|
45
|
+
end
|
|
46
|
+
end
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Ace
|
|
4
|
+
module Bundle
|
|
5
|
+
module Atoms
|
|
6
|
+
# Pure function to count lines in content
|
|
7
|
+
# Follows ATOM architecture: no side effects, single purpose
|
|
8
|
+
module LineCounter
|
|
9
|
+
class << self
|
|
10
|
+
# Count the number of lines in content
|
|
11
|
+
# @param content [String, nil] Content to count lines in
|
|
12
|
+
# @return [Integer] Number of lines (0 for empty/nil content)
|
|
13
|
+
#
|
|
14
|
+
# @example Empty content
|
|
15
|
+
# LineCounter.count("") # => 0
|
|
16
|
+
#
|
|
17
|
+
# @example Single line
|
|
18
|
+
# LineCounter.count("hello") # => 1
|
|
19
|
+
#
|
|
20
|
+
# @example Multiple lines
|
|
21
|
+
# LineCounter.count("a\nb\nc") # => 3
|
|
22
|
+
#
|
|
23
|
+
# @example Trailing newline (does not add extra line)
|
|
24
|
+
# LineCounter.count("a\nb\n") # => 2
|
|
25
|
+
def count(content)
|
|
26
|
+
return 0 if content.nil? || content.empty?
|
|
27
|
+
|
|
28
|
+
# Count actual lines of content
|
|
29
|
+
# "a\nb\nc" => 3 lines
|
|
30
|
+
# "a\nb\n" => 2 lines (trailing newline doesn't add a line)
|
|
31
|
+
content.count("\n") + (content.end_with?("\n") ? 0 : 1)
|
|
32
|
+
end
|
|
33
|
+
end
|
|
34
|
+
end
|
|
35
|
+
end
|
|
36
|
+
end
|
|
37
|
+
end
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Ace
|
|
4
|
+
module Bundle
|
|
5
|
+
module Atoms
|
|
6
|
+
# Formats preset list for display
|
|
7
|
+
#
|
|
8
|
+
# Pure function that takes preset data and returns formatted strings.
|
|
9
|
+
# Used by the List command to display available presets.
|
|
10
|
+
module PresetListFormatter
|
|
11
|
+
# Format a list of presets for display
|
|
12
|
+
#
|
|
13
|
+
# @param presets [Array<Hash>] Array of preset data hashes
|
|
14
|
+
# @return [Array<String>] Formatted lines ready for output
|
|
15
|
+
def self.format(presets)
|
|
16
|
+
return empty_message if presets.empty?
|
|
17
|
+
|
|
18
|
+
lines = ["Available presets:"]
|
|
19
|
+
|
|
20
|
+
presets.each do |preset|
|
|
21
|
+
lines << " #{preset[:name]}"
|
|
22
|
+
lines << " Description: #{preset[:description]}" if preset[:description]
|
|
23
|
+
lines << " Default output: #{preset[:output] || "stdio"}"
|
|
24
|
+
lines << " Source: #{preset[:source_file]}" if preset[:source_file]
|
|
25
|
+
lines << ""
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
lines
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
# Message when no presets are found
|
|
32
|
+
#
|
|
33
|
+
# @return [Array<String>] Help message lines
|
|
34
|
+
def self.empty_message
|
|
35
|
+
[
|
|
36
|
+
"No presets found in .ace/bundle/presets/",
|
|
37
|
+
"Create markdown files with YAML frontmatter in .ace/bundle/presets/ to define presets.",
|
|
38
|
+
"Example presets are available in the ace-bundle gem at .ace-defaults/bundle/"
|
|
39
|
+
]
|
|
40
|
+
end
|
|
41
|
+
end
|
|
42
|
+
end
|
|
43
|
+
end
|
|
44
|
+
end
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Ace
|
|
4
|
+
module Bundle
|
|
5
|
+
module Atoms
|
|
6
|
+
# Validates preset references and detects circular dependencies
|
|
7
|
+
class PresetValidator
|
|
8
|
+
MAX_DEPTH = 10 # Maximum recursion depth for preset composition
|
|
9
|
+
|
|
10
|
+
# Check if a preset exists in the preset manager
|
|
11
|
+
def self.preset_exists?(preset_name, preset_manager)
|
|
12
|
+
preset_manager.preset_exists?(preset_name)
|
|
13
|
+
end
|
|
14
|
+
|
|
15
|
+
# Detect circular dependencies in preset composition
|
|
16
|
+
# Returns { success: true } if no circular dependency
|
|
17
|
+
# Returns { success: false, error: "..." } if circular dependency found
|
|
18
|
+
def self.check_circular_dependency(preset_name, preset_chain)
|
|
19
|
+
if preset_chain.include?(preset_name)
|
|
20
|
+
{
|
|
21
|
+
success: false,
|
|
22
|
+
error: "Circular dependency detected: #{(preset_chain + [preset_name]).join(" -> ")}"
|
|
23
|
+
}
|
|
24
|
+
elsif preset_chain.size >= MAX_DEPTH
|
|
25
|
+
{
|
|
26
|
+
success: false,
|
|
27
|
+
error: "Maximum preset nesting depth (#{MAX_DEPTH}) exceeded: #{preset_chain.join(" -> ")}"
|
|
28
|
+
}
|
|
29
|
+
else
|
|
30
|
+
{success: true}
|
|
31
|
+
end
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
# Validate a list of preset names
|
|
35
|
+
# Returns { success: true, valid: [], missing: [] }
|
|
36
|
+
def self.validate_presets(preset_names, preset_manager)
|
|
37
|
+
valid = []
|
|
38
|
+
missing = []
|
|
39
|
+
|
|
40
|
+
preset_names.each do |name|
|
|
41
|
+
if preset_exists?(name, preset_manager)
|
|
42
|
+
valid << name
|
|
43
|
+
else
|
|
44
|
+
missing << name
|
|
45
|
+
end
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
{
|
|
49
|
+
success: missing.empty?,
|
|
50
|
+
valid: valid,
|
|
51
|
+
missing: missing
|
|
52
|
+
}
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
# Extract preset references from a preset's configuration
|
|
56
|
+
# Returns array of preset names referenced in the 'presets:' key
|
|
57
|
+
def self.extract_preset_references(preset_data)
|
|
58
|
+
return [] unless preset_data
|
|
59
|
+
|
|
60
|
+
bundle_config = preset_data[:bundle] || preset_data["bundle"] || {}
|
|
61
|
+
presets = bundle_config["presets"] || bundle_config[:presets] || []
|
|
62
|
+
|
|
63
|
+
# Ensure we return an array of strings
|
|
64
|
+
Array(presets).map(&:to_s)
|
|
65
|
+
end
|
|
66
|
+
end
|
|
67
|
+
end
|
|
68
|
+
end
|
|
69
|
+
end
|
|
@@ -0,0 +1,215 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Ace
|
|
4
|
+
module Bundle
|
|
5
|
+
module Atoms
|
|
6
|
+
# Validates section definitions and ensures section integrity
|
|
7
|
+
class SectionValidator
|
|
8
|
+
SectionValidationError = Ace::Bundle::SectionValidationError
|
|
9
|
+
|
|
10
|
+
# Required section fields (none - all fields are optional)
|
|
11
|
+
REQUIRED_FIELDS = [].freeze
|
|
12
|
+
|
|
13
|
+
def initialize
|
|
14
|
+
@errors = []
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
# Validates section definitions from configuration
|
|
18
|
+
# @param sections [Hash] section definitions hash
|
|
19
|
+
# @return [Boolean] true if valid, false otherwise
|
|
20
|
+
def validate_sections(sections)
|
|
21
|
+
@errors.clear
|
|
22
|
+
return true if sections.nil? || sections.empty?
|
|
23
|
+
|
|
24
|
+
validate_section_names(sections)
|
|
25
|
+
validate_required_fields(sections)
|
|
26
|
+
validate_section_content(sections)
|
|
27
|
+
|
|
28
|
+
@errors.empty?
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
# Returns validation errors
|
|
32
|
+
# @return [Array<String>] list of validation errors
|
|
33
|
+
attr_reader :errors
|
|
34
|
+
|
|
35
|
+
# Validates a single section
|
|
36
|
+
# @param name [String] section name
|
|
37
|
+
# @param section [Hash] section definition
|
|
38
|
+
# @return [Boolean] true if valid, false otherwise
|
|
39
|
+
def validate_section(name, section)
|
|
40
|
+
@errors.clear
|
|
41
|
+
return true if section.nil? || section.empty?
|
|
42
|
+
|
|
43
|
+
validate_section_name(name)
|
|
44
|
+
validate_required_fields_for_section(name, section)
|
|
45
|
+
validate_content_for_section(name, section)
|
|
46
|
+
|
|
47
|
+
@errors.empty?
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
private
|
|
51
|
+
|
|
52
|
+
# Validates section names are unique and valid
|
|
53
|
+
def validate_section_names(sections)
|
|
54
|
+
section_names = sections.keys
|
|
55
|
+
duplicates = section_names.group_by(&:itself).select { |_, v| v.size > 1 }.keys
|
|
56
|
+
|
|
57
|
+
duplicates.each do |duplicate|
|
|
58
|
+
@errors << "Duplicate section name: #{duplicate}"
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
section_names.each do |name|
|
|
62
|
+
validate_section_name(name)
|
|
63
|
+
end
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
# Validates a single section name
|
|
67
|
+
def validate_section_name(name)
|
|
68
|
+
if name.nil? || name.to_s.strip.empty?
|
|
69
|
+
@errors << "Section name cannot be empty"
|
|
70
|
+
elsif !name.to_s.match?(/\A[a-zA-Z0-9_-]+\z/)
|
|
71
|
+
@errors << "Section name '#{name}' contains invalid characters. Use letters, numbers, underscores, and hyphens only."
|
|
72
|
+
end
|
|
73
|
+
end
|
|
74
|
+
|
|
75
|
+
# Validates required fields for all sections
|
|
76
|
+
def validate_required_fields(sections)
|
|
77
|
+
sections.each do |name, section|
|
|
78
|
+
validate_required_fields_for_section(name, section)
|
|
79
|
+
end
|
|
80
|
+
end
|
|
81
|
+
|
|
82
|
+
# Validates required fields for a single section
|
|
83
|
+
def validate_required_fields_for_section(name, section)
|
|
84
|
+
REQUIRED_FIELDS.each do |field|
|
|
85
|
+
unless section.key?(field) && !section[field].nil? && !section[field].to_s.strip.empty?
|
|
86
|
+
@errors << "Section '#{name}' missing required field: #{field}"
|
|
87
|
+
end
|
|
88
|
+
end
|
|
89
|
+
end
|
|
90
|
+
|
|
91
|
+
# Validates content for all sections
|
|
92
|
+
def validate_section_content(sections)
|
|
93
|
+
sections.each do |name, section|
|
|
94
|
+
validate_content_for_section(name, section)
|
|
95
|
+
end
|
|
96
|
+
end
|
|
97
|
+
|
|
98
|
+
# Validates content for a single section
|
|
99
|
+
def validate_content_for_section(name, section)
|
|
100
|
+
# Validate all content types that are present
|
|
101
|
+
validate_files_content(name, section)
|
|
102
|
+
validate_commands_content(name, section)
|
|
103
|
+
validate_diffs_content(name, section)
|
|
104
|
+
validate_presets_content(name, section)
|
|
105
|
+
validate_inline_content(name, section)
|
|
106
|
+
end
|
|
107
|
+
|
|
108
|
+
# Validates files content for a section
|
|
109
|
+
def validate_files_content(name, section)
|
|
110
|
+
files = section[:files] || section["files"]
|
|
111
|
+
|
|
112
|
+
# Only validate if files are present
|
|
113
|
+
return if files.nil? || files.empty?
|
|
114
|
+
|
|
115
|
+
unless files.is_a?(Array)
|
|
116
|
+
@errors << "Section '#{name}' files must be an array"
|
|
117
|
+
return
|
|
118
|
+
end
|
|
119
|
+
|
|
120
|
+
files.each_with_index do |file, index|
|
|
121
|
+
validate_file_item(name, file, index)
|
|
122
|
+
end
|
|
123
|
+
end
|
|
124
|
+
|
|
125
|
+
# Validates a single file item
|
|
126
|
+
def validate_file_item(section_name, file, index)
|
|
127
|
+
if file.is_a?(Hash)
|
|
128
|
+
path = file[:path] || file["path"]
|
|
129
|
+
if path.nil? || path.to_s.strip.empty?
|
|
130
|
+
@errors << "Section '#{section_name}' file at index #{index} missing path"
|
|
131
|
+
end
|
|
132
|
+
elsif file.is_a?(String)
|
|
133
|
+
if file.strip.empty?
|
|
134
|
+
@errors << "Section '#{section_name}' file at index #{index} cannot be empty string"
|
|
135
|
+
end
|
|
136
|
+
else
|
|
137
|
+
@errors << "Section '#{section_name}' file at index #{index} must be string or hash"
|
|
138
|
+
end
|
|
139
|
+
end
|
|
140
|
+
|
|
141
|
+
# Validates commands content for a section
|
|
142
|
+
def validate_commands_content(name, section)
|
|
143
|
+
commands = section[:commands] || section["commands"]
|
|
144
|
+
|
|
145
|
+
# Only validate if commands are present
|
|
146
|
+
return if commands.nil? || commands.empty?
|
|
147
|
+
|
|
148
|
+
unless commands.is_a?(Array)
|
|
149
|
+
@errors << "Section '#{name}' commands must be an array"
|
|
150
|
+
return
|
|
151
|
+
end
|
|
152
|
+
|
|
153
|
+
commands.each_with_index do |command, index|
|
|
154
|
+
if command.to_s.strip.empty?
|
|
155
|
+
@errors << "Section '#{name}' command at index #{index} cannot be empty"
|
|
156
|
+
end
|
|
157
|
+
end
|
|
158
|
+
end
|
|
159
|
+
|
|
160
|
+
# Validates diffs content for a section
|
|
161
|
+
def validate_diffs_content(name, section)
|
|
162
|
+
ranges = section[:ranges] || section["ranges"]
|
|
163
|
+
|
|
164
|
+
# Only validate if ranges are present
|
|
165
|
+
return if ranges.nil? || ranges.empty?
|
|
166
|
+
|
|
167
|
+
unless ranges.is_a?(Array)
|
|
168
|
+
@errors << "Section '#{name}' ranges must be an array"
|
|
169
|
+
return
|
|
170
|
+
end
|
|
171
|
+
|
|
172
|
+
ranges.each_with_index do |range, index|
|
|
173
|
+
if range.to_s.strip.empty?
|
|
174
|
+
@errors << "Section '#{name}' range at index #{index} cannot be empty"
|
|
175
|
+
end
|
|
176
|
+
end
|
|
177
|
+
end
|
|
178
|
+
|
|
179
|
+
# Validates presets content for a section
|
|
180
|
+
def validate_presets_content(name, section)
|
|
181
|
+
presets = section[:presets] || section["presets"]
|
|
182
|
+
|
|
183
|
+
# Only validate if presets are present
|
|
184
|
+
return if presets.nil? || presets.empty?
|
|
185
|
+
|
|
186
|
+
unless presets.is_a?(Array)
|
|
187
|
+
@errors << "Section '#{name}' presets must be an array"
|
|
188
|
+
return
|
|
189
|
+
end
|
|
190
|
+
|
|
191
|
+
presets.each_with_index do |preset, index|
|
|
192
|
+
if preset.is_a?(String)
|
|
193
|
+
if preset.strip.empty?
|
|
194
|
+
@errors << "Section '#{name}' preset at index #{index} cannot be empty string"
|
|
195
|
+
end
|
|
196
|
+
else
|
|
197
|
+
@errors << "Section '#{name}' preset at index #{index} must be a string"
|
|
198
|
+
end
|
|
199
|
+
end
|
|
200
|
+
end
|
|
201
|
+
|
|
202
|
+
# Validates inline content for a section
|
|
203
|
+
def validate_inline_content(name, section)
|
|
204
|
+
content = section[:content] || section["content"]
|
|
205
|
+
|
|
206
|
+
# Only validate if content is present
|
|
207
|
+
nil if content.nil? || content.to_s.strip.empty?
|
|
208
|
+
|
|
209
|
+
# Content validation (if needed in future)
|
|
210
|
+
# Currently just ensures content is present if specified
|
|
211
|
+
end
|
|
212
|
+
end
|
|
213
|
+
end
|
|
214
|
+
end
|
|
215
|
+
end
|