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,460 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "../atoms/section_validator"
4
+ require_relative "../atoms/content_checker"
5
+
6
+ module Ace
7
+ module Bundle
8
+ module Molecules
9
+ # Processes and manages section definitions and composition
10
+ class SectionProcessor
11
+ def initialize
12
+ @validator = Atoms::SectionValidator.new
13
+ end
14
+
15
+ # Processes section definitions from configuration
16
+ # @param config [Hash] configuration hash containing sections
17
+ # @param preset_manager [PresetManager] preset manager for loading referenced presets
18
+ # @return [Hash] processed sections hash
19
+ def process_sections(config, preset_manager = nil)
20
+ sections_config = extract_sections_config(config)
21
+ return {} if sections_config.nil? || sections_config.empty?
22
+
23
+ unless @validator.validate_sections(sections_config)
24
+ errors = @validator.errors
25
+ raise Ace::Bundle::SectionValidationError, "Section validation failed:\n #{errors.join("\n ")}\n\nPlease check your sections configuration and ensure all required fields are properly formatted."
26
+ end
27
+
28
+ processed_sections = normalize_sections(sections_config)
29
+
30
+ # Process preset references within sections if preset manager is available
31
+ if preset_manager
32
+ process_section_presets(processed_sections, preset_manager)
33
+ end
34
+ end
35
+
36
+ # Checks if configuration already has sections
37
+ # @param config [Hash] configuration to check
38
+ # @return [Boolean] true if has sections
39
+ def has_sections?(config)
40
+ # Use 'bundle' key for configuration
41
+ bundle = config["bundle"] || config[:bundle]
42
+ return false unless bundle
43
+
44
+ sections = bundle["sections"] || bundle[:sections]
45
+ sections && !sections.empty?
46
+ end
47
+
48
+ # Merges sections from multiple configurations
49
+ # @param sections_list [Array<Hash>] list of sections hashes
50
+ # @return [Hash] merged sections
51
+ def merge_sections(*sections_list)
52
+ merged = {}
53
+
54
+ sections_list.compact.each do |sections|
55
+ next if sections.empty?
56
+
57
+ sections.each do |name, section|
58
+ merged[name] = if merged.key?(name)
59
+ merge_section_data(merged[name], section)
60
+ else
61
+ deep_copy(section)
62
+ end
63
+ end
64
+ end
65
+
66
+ # Validate final merged sections
67
+ unless @validator.validate_sections(merged)
68
+ errors = @validator.errors
69
+ raise Ace::Bundle::SectionValidationError, "Merged sections validation failed after processing:\n #{errors.join("\n ")}\n\nThis error occurred after merging preset content. Please check if referenced presets are compatible."
70
+ end
71
+
72
+ merged
73
+ end
74
+
75
+ # Gets sections sorted by YAML insertion order
76
+ # @param sections [Hash] sections hash
77
+ # @return [Array] array of [name, section] pairs in YAML order
78
+ def sorted_sections(sections)
79
+ # In Ruby 3.2+, hash insertion order is preserved
80
+ # This returns sections in the order they appear in the YAML file
81
+ sections.to_a
82
+ end
83
+
84
+ # Filters sections by content type (based on actual content, not content_type field)
85
+ # @param sections [Hash] sections hash
86
+ # @param content_type [String] content type to filter by (files, commands, diffs, content)
87
+ # @return [Hash] filtered sections
88
+ def filter_sections_by_type(sections, content_type)
89
+ sections.select { |_, section|
90
+ has_content_type?(section, content_type)
91
+ }
92
+ end
93
+
94
+ # Processes preset references within sections
95
+ # @param sections [Hash] sections hash
96
+ # @param preset_manager [PresetManager] preset manager for loading referenced presets
97
+ # @return [Hash] sections with preset content merged in
98
+ def process_section_presets(sections, preset_manager)
99
+ processed = deep_copy(sections)
100
+
101
+ processed.each do |section_name, section_data|
102
+ presets = section_data[:presets] || section_data["presets"]
103
+ next unless presets&.any?
104
+
105
+ # Load all referenced presets
106
+ merged_preset_content = merge_section_presets(presets, preset_manager, section_name)
107
+
108
+ # Merge preset content into section
109
+ processed[section_name] = merge_preset_content_into_section(section_data, merged_preset_content)
110
+
111
+ # Remove the presets reference after processing (normalized to symbol)
112
+ processed[section_name].delete(:presets)
113
+ end
114
+
115
+ processed
116
+ end
117
+
118
+ # Gets section names for a content type
119
+ # @param sections [Hash] sections hash
120
+ # @param content_type [String] content type
121
+ # @return [Array<String>] section names
122
+ def get_section_names_by_type(sections, content_type)
123
+ filter_sections_by_type(sections, content_type).keys
124
+ end
125
+
126
+ # Merges multiple presets for use within a section
127
+ # @param preset_names [Array<String>] array of preset names
128
+ # @param preset_manager [PresetManager] preset manager
129
+ # @param section_name [String] section name for error reporting
130
+ # @return [Hash] merged preset content
131
+ def merge_section_presets(preset_names, preset_manager, section_name)
132
+ all_presets = []
133
+ errors = []
134
+
135
+ preset_names.each do |preset_name|
136
+ preset = preset_manager.load_preset_with_composition(preset_name)
137
+ if preset[:success]
138
+ all_presets << preset
139
+ else
140
+ errors << "Failed to load preset '#{preset_name}' for section '#{section_name}': #{preset[:error]}"
141
+ end
142
+ end
143
+
144
+ if errors.any?
145
+ raise Ace::Bundle::SectionValidationError, "Section preset loading failed for section '#{section_name}':\n #{errors.join("\n ")}\n\nPlease ensure all referenced presets exist and are accessible. Check preset names for typos and verify preset files are in the correct location."
146
+ end
147
+
148
+ # Extract bundle content from presets
149
+ preset_contents = all_presets.map { |preset| preset[:bundle] || {} }
150
+ merge_preset_content(*preset_contents)
151
+ end
152
+
153
+ # Merges preset content into a section
154
+ # @param section_data [Hash] original section data
155
+ # @param preset_content [Hash] merged preset content
156
+ # @return [Hash] section with preset content merged
157
+ def merge_preset_content_into_section(section_data, preset_content)
158
+ merged = deep_copy(section_data)
159
+
160
+ # Merge content from preset bundle (handle both string and symbol keys)
161
+ %w[files commands ranges diffs].each do |content_type|
162
+ preset_files = preset_content[content_type] || preset_content[content_type.to_sym]
163
+ if preset_files&.any?
164
+ # Get existing files from both string and symbol keys
165
+ existing_files = merged[content_type] || merged[content_type.to_sym] || []
166
+ merged[content_type] = (existing_files + preset_files).uniq
167
+ end
168
+ end
169
+
170
+ # Merge sections from preset bundle (flatten into current section)
171
+ if preset_content["sections"]&.any?
172
+ preset_content["sections"].each do |_, preset_section|
173
+ merged = merge_section_data(merged, preset_section)
174
+ end
175
+ end
176
+
177
+ # Merge other content
178
+ if preset_content["content"] && !preset_content["content"].empty?
179
+ existing_content = merged["content"] || merged[:content] || ""
180
+ merged["content"] = if existing_content.empty?
181
+ preset_content["content"]
182
+ else
183
+ existing_content + "\n\n#{preset_content["content"]}"
184
+ end
185
+ end
186
+
187
+ merged
188
+ end
189
+
190
+ # Merges preset content structures (similar to PresetManager but focused on bundles)
191
+ # @param preset_contents [Array<Hash>] array of preset content hashes
192
+ # @return [Hash] merged preset content
193
+ def merge_preset_content(*preset_contents)
194
+ return {} if preset_contents.empty?
195
+ return preset_contents.first if preset_contents.size == 1
196
+
197
+ merged = {
198
+ "files" => [],
199
+ "commands" => [],
200
+ "ranges" => [],
201
+ "diffs" => [],
202
+ "sections" => {},
203
+ "content" => ""
204
+ }
205
+
206
+ preset_contents.each do |content|
207
+ next unless content
208
+
209
+ # Merge arrays (concatenate and deduplicate)
210
+ %w[files commands ranges diffs].each do |array_key|
211
+ if content[array_key]&.any?
212
+ merged[array_key].concat(content[array_key])
213
+ merged[array_key].uniq!
214
+ end
215
+ end
216
+
217
+ # Merge sections
218
+ if content["sections"]&.any?
219
+ content["sections"].each do |section_name, section_data|
220
+ merged["sections"][section_name] = if merged["sections"].key?(section_name)
221
+ merge_section_data(
222
+ merged["sections"][section_name],
223
+ section_data
224
+ )
225
+ else
226
+ deep_copy(section_data)
227
+ end
228
+ end
229
+ end
230
+
231
+ # Concatenate content
232
+ if content["content"] && !content["content"].empty?
233
+ if merged["content"].empty?
234
+ merged["content"] = content["content"]
235
+ else
236
+ merged["content"] += "\n\n#{content["content"]}"
237
+ end
238
+ end
239
+ end
240
+
241
+ merged
242
+ end
243
+
244
+ # Public access for testing content type detection
245
+ # @param section [Hash] section definition
246
+ # @param content_type [String] content type to check for
247
+ # @return [Boolean] true if section has the specified content type
248
+ def has_content_type?(section, content_type)
249
+ case content_type
250
+ when "files"
251
+ !!(section["files"] || section[:files])
252
+ when "commands"
253
+ !!(section["commands"] || section[:commands])
254
+ when "diffs"
255
+ !!(section["ranges"] || section[:ranges] || section["diffs"] || section[:diffs])
256
+ when "presets"
257
+ !!(section["presets"] || section[:presets])
258
+ when "content"
259
+ !!(section["content"] || section[:content])
260
+ else
261
+ false
262
+ end
263
+ end
264
+
265
+ private
266
+
267
+ # Recursively converts all string keys to symbols
268
+ # @param obj [Hash, Array, Object] object to symbolize
269
+ # @return [Hash, Array, Object] object with symbolized keys
270
+ def symbolize_keys_deep(obj)
271
+ case obj
272
+ when Hash
273
+ obj.each_with_object({}) do |(key, value), result|
274
+ sym_key = key.respond_to?(:to_sym) ? key.to_sym : key
275
+ result[sym_key] = symbolize_keys_deep(value)
276
+ end
277
+ when Array
278
+ obj.map { |item| symbolize_keys_deep(item) }
279
+ else
280
+ obj
281
+ end
282
+ end
283
+
284
+ # Extracts sections configuration from the main config
285
+ def extract_sections_config(config)
286
+ # Use 'bundle' key for configuration
287
+ bundle = config["bundle"] || config[:bundle]
288
+ return {} unless bundle
289
+
290
+ bundle["sections"] || bundle[:sections] || {}
291
+ end
292
+
293
+ # Normalizes sections configuration (string keys to symbols, defaults, etc.)
294
+ def normalize_sections(sections)
295
+ normalized = {}
296
+
297
+ sections.each do |name, section|
298
+ normalized[name] = normalize_section(name, section)
299
+ end
300
+
301
+ normalized
302
+ end
303
+
304
+ # Normalizes a single section
305
+ def normalize_section(name, section)
306
+ normalized = {}
307
+
308
+ # Convert string keys to symbols for consistency
309
+ section.each do |key, value|
310
+ normalized[key.to_sym] = value
311
+ end
312
+
313
+ # Normalize field names for backward compatibility
314
+ if normalized[:desciription]
315
+ normalized[:description] = normalized[:desciription]
316
+ normalized.delete(:desciription)
317
+ end
318
+
319
+ # Normalize diff/diffs to ranges format
320
+ # Supports two formats:
321
+ # 1. diffs: [...] - simple array of range strings (legacy)
322
+ # 2. diff: { ranges: [...], paths: [...], since: "..." } - complex format with options
323
+ if normalized[:diff]
324
+ diff_config = normalized[:diff]
325
+ if diff_config.is_a?(Hash)
326
+ # Extract ranges from complex diff config
327
+ if diff_config[:ranges] || diff_config["ranges"]
328
+ normalized[:ranges] = diff_config[:ranges] || diff_config["ranges"]
329
+ elsif diff_config[:since] || diff_config["since"]
330
+ # Convert 'since' to range format
331
+ since_ref = diff_config[:since] || diff_config["since"]
332
+ normalized[:ranges] = ["#{since_ref}...HEAD"]
333
+ end
334
+ # Note: paths filtering will be handled by ace-git when implemented
335
+ elsif diff_config.is_a?(String)
336
+ # Single range string
337
+ normalized[:ranges] = [diff_config]
338
+ elsif diff_config.is_a?(Array)
339
+ # Array of ranges
340
+ normalized[:ranges] = diff_config
341
+ end
342
+ # Remove the diff key after normalization
343
+ normalized.delete(:diff)
344
+ elsif normalized[:diffs]
345
+ # Legacy diffs format - just rename to ranges
346
+ normalized[:ranges] = normalized[:diffs]
347
+ normalized.delete(:diffs)
348
+ end
349
+
350
+ # Ensure title is set with robust title generation
351
+ normalized[:title] ||= generate_title_from_name(name) if name
352
+
353
+ normalized
354
+ end
355
+
356
+ # Merges two section data hashes
357
+ # Normalizes keys to symbols for consistent internal access
358
+ def merge_section_data(existing, new_section)
359
+ # Normalize keys for consistent internal access
360
+ existing_normalized = symbolize_keys_deep(existing)
361
+ new_normalized = symbolize_keys_deep(new_section)
362
+ merged = deep_copy(existing_normalized)
363
+
364
+ # Merge files arrays
365
+ if merged[:files] || new_normalized[:files]
366
+ merged[:files] = ((merged[:files] || []) + (new_normalized[:files] || [])).uniq
367
+ end
368
+
369
+ # Merge commands arrays
370
+ if merged[:commands] || new_normalized[:commands]
371
+ merged[:commands] = ((merged[:commands] || []) + (new_normalized[:commands] || [])).uniq
372
+ end
373
+
374
+ # Merge ranges arrays (skip if only _processed_diffs present)
375
+ if merged[:ranges] || new_normalized[:ranges]
376
+ merged[:ranges] = ((merged[:ranges] || []) + (new_normalized[:ranges] || [])).uniq
377
+ end
378
+
379
+ # Merge diffs arrays (legacy format)
380
+ if merged[:diffs] || new_normalized[:diffs]
381
+ merged[:diffs] = ((merged[:diffs] || []) + (new_normalized[:diffs] || [])).uniq
382
+ end
383
+
384
+ # Merge processed diffs with source-based deduplication
385
+ merged_processed = merged[:_processed_diffs] || []
386
+ new_processed = new_normalized[:_processed_diffs] || []
387
+ if merged_processed.any? || new_processed.any?
388
+ merged[:_processed_diffs] = (merged_processed + new_processed).uniq { |d|
389
+ d.is_a?(Hash) ? (d[:source] || d[:range] || d) : d
390
+ }
391
+ end
392
+
393
+ # Merge content (concatenate)
394
+ if merged[:content] || new_normalized[:content]
395
+ existing_content = merged[:content] || ""
396
+ new_content = new_normalized[:content] || ""
397
+
398
+ if !new_content.empty?
399
+ merged[:content] = existing_content.empty? ? new_content : "#{existing_content}\n\n#{new_content}"
400
+ end
401
+ end
402
+
403
+ # Merge exclude patterns
404
+ if merged[:exclude] || new_normalized[:exclude]
405
+ merged[:exclude] = ((merged[:exclude] || []) + (new_normalized[:exclude] || [])).uniq
406
+ end
407
+
408
+ # Override non-array fields with new values
409
+ %i[title priority description template content_type].each do |field|
410
+ merged[field] = new_normalized[field] if new_normalized[field]
411
+ end
412
+
413
+ merged
414
+ end
415
+
416
+ # Creates deep copy of object
417
+ def deep_copy(obj)
418
+ case obj
419
+ when Hash
420
+ obj.each_with_object({}) { |(k, v), h| h[k] = deep_copy(v) }
421
+ when Array
422
+ obj.map { |item| deep_copy(item) }
423
+ else
424
+ obj
425
+ end
426
+ end
427
+
428
+ # Generates human-readable title from section name
429
+ def generate_title_from_name(name)
430
+ return nil unless name
431
+
432
+ # Convert underscores and hyphens to spaces, capitalize each word
433
+ name.to_s.gsub(/[_-]/, " ")
434
+ .split
435
+ .map(&:capitalize)
436
+ .join(" ")
437
+ end
438
+
439
+ # Helper methods to detect content types in sections
440
+ # Delegates to shared ContentChecker atom for consistency
441
+
442
+ def has_files_content?(section_data)
443
+ Atoms::ContentChecker.has_files_content?(section_data)
444
+ end
445
+
446
+ def has_commands_content?(section_data)
447
+ Atoms::ContentChecker.has_commands_content?(section_data)
448
+ end
449
+
450
+ def has_diffs_content?(section_data)
451
+ Atoms::ContentChecker.has_diffs_content?(section_data)
452
+ end
453
+
454
+ def has_content_content?(section_data)
455
+ Atoms::ContentChecker.has_content_content?(section_data)
456
+ end
457
+ end
458
+ end
459
+ end
460
+ end