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.
Files changed (46) hide show
  1. checksums.yaml +7 -0
  2. data/.ace-defaults/bundle/config.yml +28 -0
  3. data/.ace-defaults/bundle/presets/base.md +15 -0
  4. data/.ace-defaults/bundle/presets/code-review.md +61 -0
  5. data/.ace-defaults/bundle/presets/development.md +16 -0
  6. data/.ace-defaults/bundle/presets/documentation-review.md +52 -0
  7. data/.ace-defaults/bundle/presets/mixed-content-example.md +94 -0
  8. data/.ace-defaults/bundle/presets/project-context.md +79 -0
  9. data/.ace-defaults/bundle/presets/project.md +35 -0
  10. data/.ace-defaults/bundle/presets/section-example-simple.md +27 -0
  11. data/.ace-defaults/bundle/presets/security-review.md +53 -0
  12. data/.ace-defaults/bundle/presets/simple-project.md +43 -0
  13. data/.ace-defaults/bundle/presets/team.md +18 -0
  14. data/.ace-defaults/nav/protocols/wfi-sources/ace-bundle.yml +19 -0
  15. data/CHANGELOG.md +384 -0
  16. data/LICENSE +21 -0
  17. data/README.md +40 -0
  18. data/Rakefile +22 -0
  19. data/exe/ace-bundle +14 -0
  20. data/handbook/skills/as-bundle/SKILL.md +28 -0
  21. data/handbook/skills/as-onboard/SKILL.md +33 -0
  22. data/handbook/workflow-instructions/bundle.wf.md +111 -0
  23. data/handbook/workflow-instructions/onboard.wf.md +20 -0
  24. data/lib/ace/bundle/atoms/boundary_finder.rb +122 -0
  25. data/lib/ace/bundle/atoms/bundle_normalizer.rb +128 -0
  26. data/lib/ace/bundle/atoms/content_checker.rb +46 -0
  27. data/lib/ace/bundle/atoms/line_counter.rb +37 -0
  28. data/lib/ace/bundle/atoms/preset_list_formatter.rb +44 -0
  29. data/lib/ace/bundle/atoms/preset_validator.rb +69 -0
  30. data/lib/ace/bundle/atoms/section_validator.rb +215 -0
  31. data/lib/ace/bundle/atoms/typo_detector.rb +76 -0
  32. data/lib/ace/bundle/cli/commands/load.rb +347 -0
  33. data/lib/ace/bundle/cli.rb +26 -0
  34. data/lib/ace/bundle/models/bundle_data.rb +75 -0
  35. data/lib/ace/bundle/molecules/bundle_chunker.rb +280 -0
  36. data/lib/ace/bundle/molecules/bundle_file_writer.rb +269 -0
  37. data/lib/ace/bundle/molecules/bundle_merger.rb +248 -0
  38. data/lib/ace/bundle/molecules/preset_manager.rb +331 -0
  39. data/lib/ace/bundle/molecules/section_compressor.rb +249 -0
  40. data/lib/ace/bundle/molecules/section_formatter.rb +580 -0
  41. data/lib/ace/bundle/molecules/section_processor.rb +460 -0
  42. data/lib/ace/bundle/organisms/bundle_loader.rb +1436 -0
  43. data/lib/ace/bundle/organisms/pr_bundle_loader.rb +147 -0
  44. data/lib/ace/bundle/version.rb +7 -0
  45. data/lib/ace/bundle.rb +251 -0
  46. 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