ace-review 0.49.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/nav/protocols/guide-sources/ace-review.yml +10 -0
- data/.ace-defaults/nav/protocols/prompt-sources/ace-review.yml +36 -0
- data/.ace-defaults/nav/protocols/tmpl-sources/ace-review.yml +10 -0
- data/.ace-defaults/nav/protocols/wfi-sources/ace-review.yml +19 -0
- data/.ace-defaults/review/config.yml +79 -0
- data/.ace-defaults/review/presets/code-fit.yml +64 -0
- data/.ace-defaults/review/presets/code-shine.yml +44 -0
- data/.ace-defaults/review/presets/code-valid.yml +39 -0
- data/.ace-defaults/review/presets/docs.yml +42 -0
- data/.ace-defaults/review/presets/spec.yml +37 -0
- data/CHANGELOG.md +1780 -0
- data/LICENSE +21 -0
- data/README.md +42 -0
- data/Rakefile +14 -0
- data/exe/ace-review +27 -0
- data/exe/ace-review-feedback +17 -0
- data/handbook/guides/code-review-process.g.md +234 -0
- data/handbook/prompts/base/sections.md +23 -0
- data/handbook/prompts/base/system.md +60 -0
- data/handbook/prompts/focus/architecture/atom.md +30 -0
- data/handbook/prompts/focus/architecture/reflection.md +60 -0
- data/handbook/prompts/focus/frameworks/rails.md +40 -0
- data/handbook/prompts/focus/frameworks/vue-firebase.md +45 -0
- data/handbook/prompts/focus/languages/ruby.md +50 -0
- data/handbook/prompts/focus/phase/correctness.md +51 -0
- data/handbook/prompts/focus/phase/polish.md +43 -0
- data/handbook/prompts/focus/phase/quality.md +42 -0
- data/handbook/prompts/focus/quality/performance.md +48 -0
- data/handbook/prompts/focus/quality/security.md +47 -0
- data/handbook/prompts/focus/scope/docs.md +38 -0
- data/handbook/prompts/focus/scope/spec.md +58 -0
- data/handbook/prompts/focus/scope/tests.md +36 -0
- data/handbook/prompts/format/compact.md +12 -0
- data/handbook/prompts/format/detailed.md +39 -0
- data/handbook/prompts/format/standard.md +16 -0
- data/handbook/prompts/guidelines/icons.md +19 -0
- data/handbook/prompts/guidelines/tone.md +21 -0
- data/handbook/prompts/synthesis-review-reports.system.md +318 -0
- data/handbook/prompts/synthesize-feedback.system.md +147 -0
- data/handbook/skills/as-review-apply-feedback/SKILL.md +39 -0
- data/handbook/skills/as-review-package/SKILL.md +36 -0
- data/handbook/skills/as-review-pr/SKILL.md +38 -0
- data/handbook/skills/as-review-run/SKILL.md +30 -0
- data/handbook/skills/as-review-verify-feedback/SKILL.md +31 -0
- data/handbook/templates/review-tasks/task-review-summary.template.md +148 -0
- data/handbook/workflow-instructions/review/apply-feedback.wf.md +212 -0
- data/handbook/workflow-instructions/review/package.wf.md +16 -0
- data/handbook/workflow-instructions/review/pr.wf.md +284 -0
- data/handbook/workflow-instructions/review/run.wf.md +262 -0
- data/handbook/workflow-instructions/review/verify-feedback.wf.md +286 -0
- data/lib/ace/review/atoms/context_limit_resolver.rb +162 -0
- data/lib/ace/review/atoms/diff_boundary_finder.rb +133 -0
- data/lib/ace/review/atoms/feedback_id_generator.rb +66 -0
- data/lib/ace/review/atoms/feedback_slug_generator.rb +61 -0
- data/lib/ace/review/atoms/feedback_state_validator.rb +98 -0
- data/lib/ace/review/atoms/pr_comment_formatter.rb +325 -0
- data/lib/ace/review/atoms/preset_validator.rb +103 -0
- data/lib/ace/review/atoms/priority_filter.rb +115 -0
- data/lib/ace/review/atoms/retry_with_backoff.rb +75 -0
- data/lib/ace/review/atoms/slug_generator.rb +50 -0
- data/lib/ace/review/atoms/token_estimator.rb +86 -0
- data/lib/ace/review/cli/commands/feedback/create.rb +173 -0
- data/lib/ace/review/cli/commands/feedback/list.rb +280 -0
- data/lib/ace/review/cli/commands/feedback/resolve.rb +109 -0
- data/lib/ace/review/cli/commands/feedback/session_discovery.rb +70 -0
- data/lib/ace/review/cli/commands/feedback/show.rb +177 -0
- data/lib/ace/review/cli/commands/feedback/skip.rb +125 -0
- data/lib/ace/review/cli/commands/feedback/verify.rb +149 -0
- data/lib/ace/review/cli/commands/feedback.rb +79 -0
- data/lib/ace/review/cli/commands/review.rb +378 -0
- data/lib/ace/review/cli/feedback_cli.rb +71 -0
- data/lib/ace/review/cli.rb +103 -0
- data/lib/ace/review/errors.rb +146 -0
- data/lib/ace/review/models/feedback_item.rb +216 -0
- data/lib/ace/review/models/review_options.rb +208 -0
- data/lib/ace/review/models/reviewer.rb +181 -0
- data/lib/ace/review/molecules/context_composer.rb +123 -0
- data/lib/ace/review/molecules/context_extractor.rb +159 -0
- data/lib/ace/review/molecules/feedback_directory_manager.rb +183 -0
- data/lib/ace/review/molecules/feedback_file_reader.rb +178 -0
- data/lib/ace/review/molecules/feedback_file_writer.rb +210 -0
- data/lib/ace/review/molecules/feedback_synthesizer.rb +588 -0
- data/lib/ace/review/molecules/gh_cli_executor.rb +124 -0
- data/lib/ace/review/molecules/gh_comment_poster.rb +205 -0
- data/lib/ace/review/molecules/gh_comment_resolver.rb +199 -0
- data/lib/ace/review/molecules/gh_pr_comment_fetcher.rb +408 -0
- data/lib/ace/review/molecules/gh_pr_fetcher.rb +240 -0
- data/lib/ace/review/molecules/llm_executor.rb +142 -0
- data/lib/ace/review/molecules/multi_model_executor.rb +278 -0
- data/lib/ace/review/molecules/nav_prompt_resolver.rb +145 -0
- data/lib/ace/review/molecules/pr_task_spec_resolver.rb +58 -0
- data/lib/ace/review/molecules/preset_manager.rb +494 -0
- data/lib/ace/review/molecules/prompt_composer.rb +76 -0
- data/lib/ace/review/molecules/prompt_resolver.rb +168 -0
- data/lib/ace/review/molecules/strategies/adaptive_strategy.rb +193 -0
- data/lib/ace/review/molecules/strategies/chunked_strategy.rb +459 -0
- data/lib/ace/review/molecules/strategies/full_strategy.rb +114 -0
- data/lib/ace/review/molecules/subject_extractor.rb +315 -0
- data/lib/ace/review/molecules/subject_filter.rb +199 -0
- data/lib/ace/review/molecules/subject_strategy.rb +96 -0
- data/lib/ace/review/molecules/task_report_saver.rb +161 -0
- data/lib/ace/review/molecules/task_resolver.rb +48 -0
- data/lib/ace/review/organisms/feedback_manager.rb +386 -0
- data/lib/ace/review/organisms/review_manager.rb +1059 -0
- data/lib/ace/review/version.rb +7 -0
- data/lib/ace/review.rb +135 -0
- metadata +351 -0
|
@@ -0,0 +1,494 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "yaml"
|
|
4
|
+
require "pathname"
|
|
5
|
+
require_relative "../atoms/preset_validator"
|
|
6
|
+
|
|
7
|
+
module Ace
|
|
8
|
+
module Review
|
|
9
|
+
module Molecules
|
|
10
|
+
# Manages loading and resolving review presets from configuration
|
|
11
|
+
class PresetManager
|
|
12
|
+
attr_reader :config_path, :config, :project_root
|
|
13
|
+
|
|
14
|
+
# Metadata keys that are added during composition and should be stripped before use
|
|
15
|
+
COMPOSITION_METADATA_KEYS = %w[success composed composed_from].freeze
|
|
16
|
+
|
|
17
|
+
def initialize(config_path: nil, project_root: nil)
|
|
18
|
+
@project_root = project_root || find_project_root
|
|
19
|
+
@config_path = resolve_config_path(config_path)
|
|
20
|
+
@config = load_configuration
|
|
21
|
+
@preset_cache = {} # Final preset cache (after merging with defaults)
|
|
22
|
+
@composition_cache = {} # Intermediate composition cache (before defaults merge)
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
# Load a specific preset by name
|
|
26
|
+
# Cached results are returned immediately to avoid redundant composition
|
|
27
|
+
def load_preset(preset_name)
|
|
28
|
+
return nil unless preset_name
|
|
29
|
+
|
|
30
|
+
# Check cache first (composition can be expensive for deeply nested presets)
|
|
31
|
+
return @preset_cache[preset_name] if @preset_cache.key?(preset_name)
|
|
32
|
+
|
|
33
|
+
# Load with composition support
|
|
34
|
+
result = load_preset_with_composition(preset_name)
|
|
35
|
+
|
|
36
|
+
# Handle composition errors
|
|
37
|
+
unless result && result["success"]
|
|
38
|
+
# Log composition failure for debugging
|
|
39
|
+
if result && result["error"]
|
|
40
|
+
warn "Failed to compose preset '#{preset_name}': #{result["error"]}" if Ace::Review.debug?
|
|
41
|
+
end
|
|
42
|
+
return nil
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
# Extract preset data (remove composition metadata)
|
|
46
|
+
preset = strip_composition_metadata(result)
|
|
47
|
+
|
|
48
|
+
# Merge with defaults and cache
|
|
49
|
+
@preset_cache[preset_name] = merge_with_defaults(preset)
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
# Get list of available preset names
|
|
53
|
+
def available_presets
|
|
54
|
+
presets = []
|
|
55
|
+
|
|
56
|
+
# Add presets from main config
|
|
57
|
+
presets.concat(config_presets) if config
|
|
58
|
+
|
|
59
|
+
# Add presets from preset directory
|
|
60
|
+
presets.concat(file_presets)
|
|
61
|
+
|
|
62
|
+
presets.uniq.sort
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
# Check if a preset exists
|
|
66
|
+
def preset_exists?(preset_name)
|
|
67
|
+
available_presets.include?(preset_name.to_s)
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
# Get the default model from configuration
|
|
71
|
+
def default_model
|
|
72
|
+
config&.dig("defaults", "model") ||
|
|
73
|
+
Ace::Review.get("defaults", "model")
|
|
74
|
+
end
|
|
75
|
+
|
|
76
|
+
# Get the default context from configuration
|
|
77
|
+
def default_context
|
|
78
|
+
config&.dig("defaults", "bundle") ||
|
|
79
|
+
Ace::Review.get("defaults", "bundle")
|
|
80
|
+
end
|
|
81
|
+
|
|
82
|
+
# Get the default output format
|
|
83
|
+
def default_output_format
|
|
84
|
+
config&.dig("defaults", "output_format") ||
|
|
85
|
+
Ace::Review.get("defaults", "output_format") ||
|
|
86
|
+
"markdown"
|
|
87
|
+
end
|
|
88
|
+
|
|
89
|
+
# Resolve a preset configuration into actionable components
|
|
90
|
+
def resolve_preset(preset_name, overrides = {})
|
|
91
|
+
preset = load_preset(preset_name)
|
|
92
|
+
return nil unless preset
|
|
93
|
+
|
|
94
|
+
models_config = resolve_models_config(preset, overrides)
|
|
95
|
+
|
|
96
|
+
# Support both bundle: and context: for context resolution
|
|
97
|
+
preset_context = preset["bundle"] || preset["context"]
|
|
98
|
+
|
|
99
|
+
{
|
|
100
|
+
description: preset["description"],
|
|
101
|
+
# Extract prompt composition for ace-bundle frontmatter (but let ace-bundle process it)
|
|
102
|
+
system_prompt: preset["system_prompt"] || preset["prompt_composition"],
|
|
103
|
+
# Preserve instructions field for section-based context generation
|
|
104
|
+
instructions: preset["instructions"],
|
|
105
|
+
context: resolve_context_config(preset_context, overrides[:context]),
|
|
106
|
+
subject: resolve_subject_config(preset["subject"], overrides[:subject]),
|
|
107
|
+
models: models_config,
|
|
108
|
+
output_format: overrides[:output_format] || preset["output_format"] || default_output_format
|
|
109
|
+
}
|
|
110
|
+
end
|
|
111
|
+
|
|
112
|
+
# Get storage configuration (user config only, no defaults)
|
|
113
|
+
def storage_config
|
|
114
|
+
config&.dig("storage") || {}
|
|
115
|
+
end
|
|
116
|
+
|
|
117
|
+
# Get the base path for storing reviews
|
|
118
|
+
def review_base_path
|
|
119
|
+
# 1. Check for configured path first (user config only)
|
|
120
|
+
configured_path = storage_config["base_path"]
|
|
121
|
+
return expand_path_template(configured_path) if configured_path
|
|
122
|
+
|
|
123
|
+
# 2. Fallback to cache directory
|
|
124
|
+
File.join(project_root, ".ace-local/review/sessions")
|
|
125
|
+
end
|
|
126
|
+
|
|
127
|
+
# Load a preset with composition support
|
|
128
|
+
# Returns fully composed preset data with all dependent presets merged
|
|
129
|
+
# Composition order: base presets first, then composing preset (last wins for scalars)
|
|
130
|
+
# Uses intermediate caching to avoid redundant composition of shared dependencies
|
|
131
|
+
def load_preset_with_composition(name, visited = Set.new)
|
|
132
|
+
start_time = Time.now if Ace::Review.debug?
|
|
133
|
+
|
|
134
|
+
# Check circular dependency first (before cache to prevent caching incomplete compositions)
|
|
135
|
+
validation = Atoms::PresetValidator.check_circular_dependency(name, visited.to_a)
|
|
136
|
+
unless validation[:success]
|
|
137
|
+
return {
|
|
138
|
+
"error" => validation[:error],
|
|
139
|
+
"success" => false
|
|
140
|
+
}
|
|
141
|
+
end
|
|
142
|
+
|
|
143
|
+
# Check composition cache (enables intermediate caching for shared base presets)
|
|
144
|
+
if @composition_cache.key?(name)
|
|
145
|
+
warn "[COMPOSITION] Cache hit for '#{name}'" if Ace::Review.debug?
|
|
146
|
+
return @composition_cache[name].dup
|
|
147
|
+
end
|
|
148
|
+
|
|
149
|
+
# Load preset from file or config
|
|
150
|
+
preset = load_preset_from_file(name) || load_preset_from_config(name)
|
|
151
|
+
unless preset
|
|
152
|
+
return {
|
|
153
|
+
"error" => "Preset '#{name}' not found. Available presets: #{available_presets.join(", ")}",
|
|
154
|
+
"success" => false
|
|
155
|
+
}
|
|
156
|
+
end
|
|
157
|
+
|
|
158
|
+
# Mark this preset as visited
|
|
159
|
+
new_visited = visited.dup.add(name)
|
|
160
|
+
|
|
161
|
+
# Extract preset references
|
|
162
|
+
preset_refs = Atoms::PresetValidator.extract_preset_references(preset)
|
|
163
|
+
|
|
164
|
+
# If no references, return preset as-is
|
|
165
|
+
if preset_refs.empty?
|
|
166
|
+
# Ensure consistent string keys
|
|
167
|
+
result = deep_stringify_keys(preset)
|
|
168
|
+
result["success"] = true
|
|
169
|
+
return result
|
|
170
|
+
end
|
|
171
|
+
|
|
172
|
+
# Load all referenced presets recursively
|
|
173
|
+
composed_presets = []
|
|
174
|
+
errors = []
|
|
175
|
+
|
|
176
|
+
preset_refs.each do |ref_name|
|
|
177
|
+
composed = load_preset_with_composition(ref_name, new_visited)
|
|
178
|
+
if composed["success"]
|
|
179
|
+
composed_presets << composed
|
|
180
|
+
else
|
|
181
|
+
errors << composed["error"]
|
|
182
|
+
end
|
|
183
|
+
end
|
|
184
|
+
|
|
185
|
+
# If there were errors loading dependencies, return error
|
|
186
|
+
if errors.any?
|
|
187
|
+
return {
|
|
188
|
+
"error" => "Failed to load preset dependencies: #{errors.join(", ")}",
|
|
189
|
+
"success" => false,
|
|
190
|
+
"partial_presets" => composed_presets
|
|
191
|
+
}
|
|
192
|
+
end
|
|
193
|
+
|
|
194
|
+
# Strip metadata from composed presets before merging
|
|
195
|
+
clean_composed = composed_presets.map { |p| strip_composition_metadata(p) }
|
|
196
|
+
|
|
197
|
+
# Merge all composed presets with current preset
|
|
198
|
+
# Order: dependencies first, then current preset (last wins for scalars)
|
|
199
|
+
merged = merge_preset_data(clean_composed + [preset])
|
|
200
|
+
|
|
201
|
+
# Ensure consistent string keys and add composition metadata
|
|
202
|
+
merged = deep_stringify_keys(merged)
|
|
203
|
+
merged["success"] = true
|
|
204
|
+
merged["composed"] = true
|
|
205
|
+
merged["composed_from"] = preset_refs + [name]
|
|
206
|
+
|
|
207
|
+
# Cache the composed result for future reuse (enables intermediate caching)
|
|
208
|
+
@composition_cache[name] = merged.dup
|
|
209
|
+
|
|
210
|
+
# Log composition performance metrics in debug mode
|
|
211
|
+
if Ace::Review.debug?
|
|
212
|
+
elapsed = Time.now - start_time
|
|
213
|
+
depth = visited.size + 1
|
|
214
|
+
ref_count = preset_refs.size
|
|
215
|
+
warn "[COMPOSITION] Composed '#{name}' in #{(elapsed * 1000).round(2)}ms (depth: #{depth}, refs: #{ref_count})"
|
|
216
|
+
end
|
|
217
|
+
|
|
218
|
+
merged
|
|
219
|
+
end
|
|
220
|
+
|
|
221
|
+
private
|
|
222
|
+
|
|
223
|
+
# Strip composition metadata from a preset hash (deep recursive)
|
|
224
|
+
# Removes internal keys used for composition tracking: success, composed, composed_from
|
|
225
|
+
# Recursively processes nested hashes and arrays to ensure complete metadata removal
|
|
226
|
+
# @param preset_hash [Hash] Preset data with potential metadata
|
|
227
|
+
# @return [Hash] Preset data without composition metadata at any nesting level
|
|
228
|
+
def strip_composition_metadata(preset_hash)
|
|
229
|
+
result = preset_hash.reject { |k, _| COMPOSITION_METADATA_KEYS.include?(k) }
|
|
230
|
+
|
|
231
|
+
# Recursively strip metadata from nested structures
|
|
232
|
+
result.transform_values do |value|
|
|
233
|
+
case value
|
|
234
|
+
when Hash
|
|
235
|
+
strip_composition_metadata(value)
|
|
236
|
+
when Array
|
|
237
|
+
value.map { |item| item.is_a?(Hash) ? strip_composition_metadata(item) : item }
|
|
238
|
+
else
|
|
239
|
+
value
|
|
240
|
+
end
|
|
241
|
+
end
|
|
242
|
+
end
|
|
243
|
+
|
|
244
|
+
# Merge multiple preset data structures
|
|
245
|
+
# Arrays are concatenated and deduplicated (first occurrence wins)
|
|
246
|
+
# Hashes are deep merged recursively
|
|
247
|
+
# Scalars follow "last wins" strategy
|
|
248
|
+
# Uses centralized DeepMerger from ace-support-config for consistency
|
|
249
|
+
def merge_preset_data(presets)
|
|
250
|
+
return presets.first if presets.size == 1
|
|
251
|
+
|
|
252
|
+
# Use DeepMerger with :union strategy to concatenate and deduplicate arrays
|
|
253
|
+
Ace::Support::Config::Atoms::DeepMerger.merge_all(presets, array_strategy: :union)
|
|
254
|
+
end
|
|
255
|
+
|
|
256
|
+
def find_project_root
|
|
257
|
+
# Use ace-config for project root discovery
|
|
258
|
+
Ace::Support::Config.find_project_root || Dir.pwd
|
|
259
|
+
end
|
|
260
|
+
|
|
261
|
+
def resolve_config_path(custom_path)
|
|
262
|
+
if custom_path
|
|
263
|
+
path = Pathname.new(custom_path)
|
|
264
|
+
return path.absolute? ? custom_path : File.join(project_root, custom_path)
|
|
265
|
+
end
|
|
266
|
+
|
|
267
|
+
# Try review/config.yml first, then fallbacks
|
|
268
|
+
config_patterns = [
|
|
269
|
+
"review/config.yml",
|
|
270
|
+
"review.yml" # Fallback to old naming
|
|
271
|
+
]
|
|
272
|
+
|
|
273
|
+
config_patterns.each do |pattern|
|
|
274
|
+
path = config_finder.find_file(pattern)
|
|
275
|
+
return path if path
|
|
276
|
+
end
|
|
277
|
+
|
|
278
|
+
# Fallback to .ace/review/config.yml for tests and standalone usage
|
|
279
|
+
fallback_path = File.join(project_root, ".ace/review/config.yml")
|
|
280
|
+
return fallback_path if File.exist?(fallback_path)
|
|
281
|
+
|
|
282
|
+
nil
|
|
283
|
+
end
|
|
284
|
+
|
|
285
|
+
def load_configuration
|
|
286
|
+
return {} unless config_path && File.exist?(config_path)
|
|
287
|
+
|
|
288
|
+
content = File.read(config_path)
|
|
289
|
+
config_data = YAML.safe_load(content, permitted_classes: [Symbol]) || {}
|
|
290
|
+
deep_stringify_keys(config_data)
|
|
291
|
+
rescue => e
|
|
292
|
+
warn "Failed to load configuration from #{config_path}: #{e.message}" if Ace::Review.debug?
|
|
293
|
+
{}
|
|
294
|
+
end
|
|
295
|
+
|
|
296
|
+
def load_preset_from_file(preset_name)
|
|
297
|
+
# Validate preset name for security before any filesystem access
|
|
298
|
+
validation = Atoms::PresetValidator.validate_preset_name(preset_name)
|
|
299
|
+
unless validation[:success]
|
|
300
|
+
raise ArgumentError, validation[:error]
|
|
301
|
+
end
|
|
302
|
+
|
|
303
|
+
# Use ace-config ConfigFinder to find preset in cascade
|
|
304
|
+
preset_file = config_finder.find_file("review/presets/#{preset_name}.yml")
|
|
305
|
+
|
|
306
|
+
if preset_file && File.exist?(preset_file)
|
|
307
|
+
content = File.read(preset_file)
|
|
308
|
+
preset_data = YAML.safe_load(content, permitted_classes: [Symbol])
|
|
309
|
+
return deep_stringify_keys(preset_data)
|
|
310
|
+
end
|
|
311
|
+
|
|
312
|
+
# Fallback to .ace/review/presets for tests and standalone usage
|
|
313
|
+
preset_dir = File.join(project_root, ".ace/review/presets")
|
|
314
|
+
preset_file = File.join(preset_dir, "#{preset_name}.yml")
|
|
315
|
+
|
|
316
|
+
if File.exist?(preset_file)
|
|
317
|
+
content = File.read(preset_file)
|
|
318
|
+
preset_data = YAML.safe_load(content, permitted_classes: [Symbol])
|
|
319
|
+
return deep_stringify_keys(preset_data)
|
|
320
|
+
end
|
|
321
|
+
|
|
322
|
+
nil
|
|
323
|
+
rescue ArgumentError
|
|
324
|
+
# Re-raise validation errors (don't suppress security checks)
|
|
325
|
+
raise
|
|
326
|
+
rescue => e
|
|
327
|
+
warn "Failed to load preset from #{preset_name}: #{e.message}" if Ace::Review.debug?
|
|
328
|
+
nil
|
|
329
|
+
end
|
|
330
|
+
|
|
331
|
+
def load_preset_from_config(preset_name)
|
|
332
|
+
return nil unless config && config["presets"]
|
|
333
|
+
config["presets"][preset_name.to_s]
|
|
334
|
+
end
|
|
335
|
+
|
|
336
|
+
def config_presets
|
|
337
|
+
config["presets"]&.keys || []
|
|
338
|
+
end
|
|
339
|
+
|
|
340
|
+
def file_presets
|
|
341
|
+
presets = []
|
|
342
|
+
|
|
343
|
+
preset_dirs.each do |preset_dir|
|
|
344
|
+
next unless Dir.exist?(preset_dir)
|
|
345
|
+
|
|
346
|
+
Dir.glob(File.join(preset_dir, "*.yml")).sort.each do |file|
|
|
347
|
+
presets << File.basename(file, ".yml")
|
|
348
|
+
end
|
|
349
|
+
end
|
|
350
|
+
|
|
351
|
+
presets.uniq
|
|
352
|
+
rescue => e
|
|
353
|
+
warn "Failed to find preset files: #{e.message}" if Ace::Review.debug?
|
|
354
|
+
[]
|
|
355
|
+
end
|
|
356
|
+
|
|
357
|
+
def config_finder
|
|
358
|
+
@config_finder ||= Ace::Support::Config::Molecules::ConfigFinder.new(
|
|
359
|
+
config_dir: ".ace",
|
|
360
|
+
defaults_dir: ".ace-defaults",
|
|
361
|
+
gem_path: gem_root,
|
|
362
|
+
start_path: project_root
|
|
363
|
+
)
|
|
364
|
+
end
|
|
365
|
+
|
|
366
|
+
def preset_dirs
|
|
367
|
+
dirs = config_finder.search_paths.map { |dir| File.join(dir, "review/presets") }
|
|
368
|
+
dirs << File.join(gem_root, ".ace-defaults", "review", "presets")
|
|
369
|
+
dirs.uniq
|
|
370
|
+
end
|
|
371
|
+
|
|
372
|
+
def gem_root
|
|
373
|
+
@gem_root ||= Gem.loaded_specs["ace-review"]&.gem_dir ||
|
|
374
|
+
File.expand_path("../../../..", __dir__)
|
|
375
|
+
end
|
|
376
|
+
|
|
377
|
+
def merge_with_defaults(preset)
|
|
378
|
+
defaults = config&.dig("defaults") || {}
|
|
379
|
+
deep_merge(defaults, preset)
|
|
380
|
+
end
|
|
381
|
+
|
|
382
|
+
def deep_merge(base, override)
|
|
383
|
+
return override unless base.is_a?(Hash) && override.is_a?(Hash)
|
|
384
|
+
|
|
385
|
+
base.merge(override) do |_key, base_val, override_val|
|
|
386
|
+
deep_merge(base_val, override_val)
|
|
387
|
+
end
|
|
388
|
+
end
|
|
389
|
+
|
|
390
|
+
def resolve_system_prompt_composition(composition, overrides)
|
|
391
|
+
return {} unless composition
|
|
392
|
+
|
|
393
|
+
result = composition.dup
|
|
394
|
+
|
|
395
|
+
# Apply overrides
|
|
396
|
+
result["base"] = overrides[:prompt_base] if overrides[:prompt_base]
|
|
397
|
+
result["format"] = overrides[:prompt_format] if overrides[:prompt_format]
|
|
398
|
+
|
|
399
|
+
if overrides[:prompt_focus]
|
|
400
|
+
result["focus"] = overrides[:prompt_focus].split(",").map(&:strip)
|
|
401
|
+
elsif overrides[:add_focus]
|
|
402
|
+
result["focus"] ||= []
|
|
403
|
+
result["focus"].concat(overrides[:add_focus].split(",").map(&:strip))
|
|
404
|
+
result["focus"].uniq!
|
|
405
|
+
end
|
|
406
|
+
|
|
407
|
+
if overrides[:prompt_guidelines]
|
|
408
|
+
result["guidelines"] = overrides[:prompt_guidelines].split(",").map(&:strip)
|
|
409
|
+
end
|
|
410
|
+
|
|
411
|
+
result
|
|
412
|
+
end
|
|
413
|
+
|
|
414
|
+
def resolve_context_config(preset_context, override_context)
|
|
415
|
+
return override_context if override_context
|
|
416
|
+
preset_context || default_context
|
|
417
|
+
end
|
|
418
|
+
|
|
419
|
+
def resolve_subject_config(preset_subject, override_subject)
|
|
420
|
+
return override_subject if override_subject
|
|
421
|
+
preset_subject
|
|
422
|
+
end
|
|
423
|
+
|
|
424
|
+
# Resolve models configuration
|
|
425
|
+
# Priority: override models > preset models > default
|
|
426
|
+
def resolve_models_config(preset, overrides)
|
|
427
|
+
# If override provides models array, use it
|
|
428
|
+
if overrides[:models].is_a?(Array) && overrides[:models].any?
|
|
429
|
+
return overrides[:models]
|
|
430
|
+
end
|
|
431
|
+
|
|
432
|
+
# If preset has models array, use it
|
|
433
|
+
if preset["models"].is_a?(Array) && preset["models"].any?
|
|
434
|
+
return preset["models"]
|
|
435
|
+
end
|
|
436
|
+
|
|
437
|
+
# Fallback to default model
|
|
438
|
+
[default_model]
|
|
439
|
+
end
|
|
440
|
+
|
|
441
|
+
def current_release
|
|
442
|
+
"v.0.0.0"
|
|
443
|
+
end
|
|
444
|
+
|
|
445
|
+
def get_release_path
|
|
446
|
+
nil
|
|
447
|
+
end
|
|
448
|
+
|
|
449
|
+
def expand_path_template(template)
|
|
450
|
+
return template unless template
|
|
451
|
+
|
|
452
|
+
# Keep existing %{release} expansion if user configured it
|
|
453
|
+
release = current_release
|
|
454
|
+
template.gsub("%{release}", release)
|
|
455
|
+
end
|
|
456
|
+
|
|
457
|
+
# Recursively convert all hash keys to strings
|
|
458
|
+
#
|
|
459
|
+
# YAML.safe_load with permitted_classes: [Symbol] can return hashes with
|
|
460
|
+
# both string and symbol keys. This normalizes all keys to strings for
|
|
461
|
+
# consistent access patterns throughout the codebase.
|
|
462
|
+
#
|
|
463
|
+
# @param value [Object] Value to stringify (Hash, Array, or other)
|
|
464
|
+
# @return [Object] Value with all hash keys stringified
|
|
465
|
+
#
|
|
466
|
+
# @example Simple hash
|
|
467
|
+
# deep_stringify_keys({a: 1, b: 2})
|
|
468
|
+
# #=> {"a" => 1, "b" => 2}
|
|
469
|
+
#
|
|
470
|
+
# @example Nested hash
|
|
471
|
+
# deep_stringify_keys({a: {b: {c: 1}}})
|
|
472
|
+
# #=> {"a" => {"b" => {"c" => 1}}}
|
|
473
|
+
#
|
|
474
|
+
# @example Hash in array
|
|
475
|
+
# deep_stringify_keys([{a: 1}, {b: 2}])
|
|
476
|
+
# #=> [{"a" => 1}, {"b" => 2}]
|
|
477
|
+
#
|
|
478
|
+
# @api private
|
|
479
|
+
def deep_stringify_keys(value)
|
|
480
|
+
case value
|
|
481
|
+
when Hash
|
|
482
|
+
value.each_with_object({}) do |(k, v), result|
|
|
483
|
+
result[k.to_s] = deep_stringify_keys(v)
|
|
484
|
+
end
|
|
485
|
+
when Array
|
|
486
|
+
value.map { |v| deep_stringify_keys(v) }
|
|
487
|
+
else
|
|
488
|
+
value
|
|
489
|
+
end
|
|
490
|
+
end
|
|
491
|
+
end
|
|
492
|
+
end
|
|
493
|
+
end
|
|
494
|
+
end
|
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Ace
|
|
4
|
+
module Review
|
|
5
|
+
module Molecules
|
|
6
|
+
# Composes final prompt from modular components
|
|
7
|
+
class PromptComposer
|
|
8
|
+
attr_reader :resolver
|
|
9
|
+
|
|
10
|
+
def initialize(resolver: nil)
|
|
11
|
+
@resolver = resolver || NavPromptResolver.new
|
|
12
|
+
end
|
|
13
|
+
|
|
14
|
+
# Compose a full prompt from composition configuration
|
|
15
|
+
# @param composition [Hash] prompt composition with base, format, focus, guidelines
|
|
16
|
+
# @param config_dir [String] directory for relative path resolution
|
|
17
|
+
# @return [String] composed prompt
|
|
18
|
+
def compose(composition, config_dir: nil)
|
|
19
|
+
return "" unless composition
|
|
20
|
+
|
|
21
|
+
sections = []
|
|
22
|
+
|
|
23
|
+
# Add base prompt (required)
|
|
24
|
+
if composition["base"]
|
|
25
|
+
base_content = resolver.resolve(composition["base"], config_dir: config_dir)
|
|
26
|
+
sections << base_content if base_content
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
# Add format section
|
|
30
|
+
if composition["format"]
|
|
31
|
+
format_content = resolver.resolve(composition["format"], config_dir: config_dir)
|
|
32
|
+
sections << wrap_section("Output Format", format_content) if format_content
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
# Add focus modules (can be multiple)
|
|
36
|
+
if composition["focus"] && !composition["focus"].empty?
|
|
37
|
+
focus_contents = composition["focus"].map do |focus_ref|
|
|
38
|
+
resolver.resolve(focus_ref, config_dir: config_dir)
|
|
39
|
+
end.compact
|
|
40
|
+
|
|
41
|
+
unless focus_contents.empty?
|
|
42
|
+
combined_focus = focus_contents.join("\n\n---\n\n")
|
|
43
|
+
sections << wrap_section("Review Focus", combined_focus)
|
|
44
|
+
end
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
# Add guidelines
|
|
48
|
+
if composition["guidelines"] && !composition["guidelines"].empty?
|
|
49
|
+
guideline_contents = composition["guidelines"].map do |guideline_ref|
|
|
50
|
+
resolver.resolve(guideline_ref, config_dir: config_dir)
|
|
51
|
+
end.compact
|
|
52
|
+
|
|
53
|
+
unless guideline_contents.empty?
|
|
54
|
+
combined_guidelines = guideline_contents.join("\n\n")
|
|
55
|
+
sections << wrap_section("Guidelines", combined_guidelines)
|
|
56
|
+
end
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
sections.join("\n\n")
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
private
|
|
63
|
+
|
|
64
|
+
def wrap_section(title, content)
|
|
65
|
+
return "" unless content && !content.strip.empty?
|
|
66
|
+
|
|
67
|
+
<<~SECTION
|
|
68
|
+
## #{title}
|
|
69
|
+
|
|
70
|
+
#{content}
|
|
71
|
+
SECTION
|
|
72
|
+
end
|
|
73
|
+
end
|
|
74
|
+
end
|
|
75
|
+
end
|
|
76
|
+
end
|