Class: Ace::Review::Molecules::PresetManager

Inherits:
Object
  • Object
show all
Defined in:
lib/ace/review/molecules/preset_manager.rb

Overview

Manages loading and resolving review presets from configuration

Constant Summary collapse

COMPOSITION_METADATA_KEYS =

Metadata keys that are added during composition and should be stripped before use

%w[success composed composed_from].freeze

Instance Attribute Summary collapse

Instance Method Summary collapse

Constructor Details

#initialize(config_path: nil, project_root: nil) ⇒ PresetManager

Returns a new instance of PresetManager.



18
19
20
21
22
23
24
25
# File 'lib/ace/review/molecules/preset_manager.rb', line 18

def initialize(config_path: nil, project_root: nil)
  @project_root = project_root || find_project_root
  @source_files = []
  @config_path = resolve_config_path(config_path)
  @config = load_configuration
  @preset_cache = {}  # Final preset cache (after merging with defaults)
  @composition_cache = {}  # Intermediate composition cache (before defaults merge)
end

Instance Attribute Details

#config ⇒ Object (readonly)

Returns the value of attribute config.



13
14
15
# File 'lib/ace/review/molecules/preset_manager.rb', line 13

def config
  @config
end

#config_path ⇒ Object (readonly)

Returns the value of attribute config_path.



13
14
15
# File 'lib/ace/review/molecules/preset_manager.rb', line 13

def config_path
  @config_path
end

#project_root ⇒ Object (readonly)

Returns the value of attribute project_root.



13
14
15
# File 'lib/ace/review/molecules/preset_manager.rb', line 13

def project_root
  @project_root
end

#source_files ⇒ Object (readonly)

Returns the value of attribute source_files.



13
14
15
# File 'lib/ace/review/molecules/preset_manager.rb', line 13

def source_files
  @source_files
end

Instance Method Details

#available_presets ⇒ Object

Get list of available preset names



55
56
57
58
59
60
61
62
63
64
65
# File 'lib/ace/review/molecules/preset_manager.rb', line 55

def available_presets
  presets = []

  # Add presets from main config
  presets.concat(config_presets) if config

  # Add presets from preset directory
  presets.concat(file_presets)

  presets.uniq.sort
end

#default_context ⇒ Object

Get the default context from configuration



79
80
81
82
# File 'lib/ace/review/molecules/preset_manager.rb', line 79

def default_context
  config&.dig("defaults", "bundle") ||
    Ace::Review.get("defaults", "bundle")
end

#default_model ⇒ Object

Get the default model from configuration



73
74
75
76
# File 'lib/ace/review/molecules/preset_manager.rb', line 73

def default_model
  config&.dig("defaults", "model") ||
    Ace::Review.get("defaults", "model")
end

#default_output_format ⇒ Object

Get the default output format



85
86
87
88
89
# File 'lib/ace/review/molecules/preset_manager.rb', line 85

def default_output_format
  config&.dig("defaults", "output_format") ||
    Ace::Review.get("defaults", "output_format") ||
    "markdown"
end

#load_preset(preset_name) ⇒ Object

Load a specific preset by name Cached results are returned immediately to avoid redundant composition



29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
# File 'lib/ace/review/molecules/preset_manager.rb', line 29

def load_preset(preset_name)
  return nil unless preset_name

  # Check cache first (composition can be expensive for deeply nested presets)
  return @preset_cache[preset_name] if @preset_cache.key?(preset_name)

  # Load with composition support
  result = load_preset_with_composition(preset_name)

  # Handle composition errors
  unless result && result["success"]
    # Log composition failure for debugging
    if result && result["error"]
      warn "Failed to compose preset '#{preset_name}': #{result["error"]}" if Ace::Review.debug?
    end
    return nil
  end

  # Extract preset data (remove composition metadata)
  preset = (result)

  # Merge with defaults and cache
  @preset_cache[preset_name] = merge_with_defaults(preset)
end

#load_preset_with_composition(name, visited = Set.new) ⇒ Object

Load a preset with composition support Returns fully composed preset data with all dependent presets merged Composition order: base presets first, then composing preset (last wins for scalars) Uses intermediate caching to avoid redundant composition of shared dependencies



140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
# File 'lib/ace/review/molecules/preset_manager.rb', line 140

def load_preset_with_composition(name, visited = Set.new)
  start_time = Time.now if Ace::Review.debug?

  # Check circular dependency first (before cache to prevent caching incomplete compositions)
  validation = Atoms::PresetValidator.check_circular_dependency(name, visited.to_a)
  unless validation[:success]
    return {
      "error" => validation[:error],
      "success" => false
    }
  end

  # Check composition cache (enables intermediate caching for shared base presets)
  if @composition_cache.key?(name)
    warn "[COMPOSITION] Cache hit for '#{name}'" if Ace::Review.debug?
    return @composition_cache[name].dup
  end

  # Load preset from file or config
  preset = load_preset_from_file(name) || load_preset_from_config(name)
  unless preset
    return {
      "error" => "Preset '#{name}' not found. Available presets: #{available_presets.join(", ")}",
      "success" => false
    }
  end

  # Mark this preset as visited
  new_visited = visited.dup.add(name)

  # Extract preset references
  preset_refs = Atoms::PresetValidator.extract_preset_references(preset)

  # If no references, return preset as-is
  if preset_refs.empty?
    # Ensure consistent string keys
    result = deep_stringify_keys(preset)
    result["success"] = true
    return result
  end

  # Load all referenced presets recursively
  composed_presets = []
  errors = []

  preset_refs.each do |ref_name|
    composed = load_preset_with_composition(ref_name, new_visited)
    if composed["success"]
      composed_presets << composed
    else
      errors << composed["error"]
    end
  end

  # If there were errors loading dependencies, return error
  if errors.any?
    return {
      "error" => "Failed to load preset dependencies: #{errors.join(", ")}",
      "success" => false,
      "partial_presets" => composed_presets
    }
  end

  # Strip metadata from composed presets before merging
  clean_composed = composed_presets.map { |p| (p) }

  # Merge all composed presets with current preset
  # Order: dependencies first, then current preset (last wins for scalars)
  merged = merge_preset_data(clean_composed + [preset])

  # Ensure consistent string keys and add composition metadata
  merged = deep_stringify_keys(merged)
  merged["success"] = true
  merged["composed"] = true
  merged["composed_from"] = preset_refs + [name]

  # Cache the composed result for future reuse (enables intermediate caching)
  @composition_cache[name] = merged.dup

  # Log composition performance metrics in debug mode
  if Ace::Review.debug?
    elapsed = Time.now - start_time
    depth = visited.size + 1
    ref_count = preset_refs.size
    warn "[COMPOSITION] Composed '#{name}' in #{(elapsed * 1000).round(2)}ms (depth: #{depth}, refs: #{ref_count})"
  end

  merged
end

#preset_exists?(preset_name) ⇒ Boolean

Check if a preset exists

Returns:

  • (Boolean)


68
69
70
# File 'lib/ace/review/molecules/preset_manager.rb', line 68

def preset_exists?(preset_name)
  available_presets.include?(preset_name.to_s)
end

#resolve_preset(preset_name, overrides = {}) ⇒ Object

Resolve a preset configuration into actionable components



92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
# File 'lib/ace/review/molecules/preset_manager.rb', line 92

def resolve_preset(preset_name, overrides = {})
  preset = load_preset(preset_name)
  return nil unless preset

  models_config = resolve_models_config(preset, overrides)

  # Support both bundle: and context: for context resolution
  preset_context = preset["bundle"] || preset["context"]

  {
    description: preset["description"],
    # Extract prompt composition for ace-bundle frontmatter (but let ace-bundle process it)
    system_prompt: preset["system_prompt"] || preset["prompt_composition"],
    # Preserve instructions field for section-based context generation
    instructions: preset["instructions"],
    context: resolve_context_config(preset_context, overrides[:context]),
    subject: resolve_subject_config(preset["subject"], overrides[:subject]),
    models: models_config,
    reviewers: preset["reviewers"],
    file_patterns: preset["file_patterns"],
    file_pattern_groups: preset["file_pattern_groups"],
    exempt_paths: preset["exempt_paths"],
    budget: preset["budget"],
    goals_brief: preset["goals_brief"],
    review_role: preset["review_role"] || "scope",
    output_format: overrides[:output_format] || preset["output_format"] || default_output_format
  }
end

#review_base_path ⇒ Object

Get the base path for storing reviews



127
128
129
130
131
132
133
134
# File 'lib/ace/review/molecules/preset_manager.rb', line 127

def review_base_path
  # 1. Check for configured path first (user config only)
  configured_path = storage_config["base_path"]
  return expand_path_template(configured_path) if configured_path

  # 2. Fallback to cache directory
  File.join(project_root, ".ace-local/review/sessions")
end

#storage_config ⇒ Object

Get storage configuration (user config only, no defaults)



122
123
124
# File 'lib/ace/review/molecules/preset_manager.rb', line 122

def storage_config
  config&.dig("storage") || {}
end