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