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,315 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "yaml"
|
|
4
|
+
require "open3"
|
|
5
|
+
require "timeout"
|
|
6
|
+
require "ace/support/config"
|
|
7
|
+
require "ace/core/atoms/process_terminator" # Keep from ace-support-core for process cleanup
|
|
8
|
+
require "ace/git"
|
|
9
|
+
require_relative "../errors"
|
|
10
|
+
|
|
11
|
+
module Ace
|
|
12
|
+
module Review
|
|
13
|
+
module Molecules
|
|
14
|
+
# Parses review subjects and returns ace-bundle configuration
|
|
15
|
+
# Delegates actual content extraction to ace-bundle
|
|
16
|
+
#
|
|
17
|
+
# == Config Passthrough API
|
|
18
|
+
#
|
|
19
|
+
# The primary API returns ace-bundle config hashes that ReviewManager
|
|
20
|
+
# passes directly to ace-bundle via user.context.md:
|
|
21
|
+
#
|
|
22
|
+
# - {#parse_typed_subject_config} - Single typed subject (pr:, diff:, files:, task:)
|
|
23
|
+
# - {#merge_typed_subject_configs} - Multiple subjects merged into one config
|
|
24
|
+
#
|
|
25
|
+
# This avoids extracting content only to save it to a file and re-read it.
|
|
26
|
+
#
|
|
27
|
+
class SubjectExtractor
|
|
28
|
+
# @param options [Hash] Configuration options
|
|
29
|
+
# @option options [Integer] :taskflow_timeout Timeout for ace-task subprocess (default: 10s)
|
|
30
|
+
def initialize(options = {})
|
|
31
|
+
@taskflow_timeout = options[:taskflow_timeout] || TASKFLOW_TIMEOUT
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
# Extract subject from configuration
|
|
35
|
+
# @param subject_config [String, Hash] subject configuration
|
|
36
|
+
# @return [String] extracted subject content
|
|
37
|
+
# @note Prefer parse_typed_subject_config or merge_typed_subject_configs for new code
|
|
38
|
+
def extract(subject_config)
|
|
39
|
+
return "" unless subject_config
|
|
40
|
+
|
|
41
|
+
case subject_config
|
|
42
|
+
when String
|
|
43
|
+
extract_from_string(subject_config)
|
|
44
|
+
when Hash
|
|
45
|
+
extract_from_hash(subject_config)
|
|
46
|
+
else
|
|
47
|
+
""
|
|
48
|
+
end
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
# Parse typed subject string and return ace-bundle config
|
|
52
|
+
# Does NOT extract content - returns config for direct use with ace-bundle
|
|
53
|
+
# @param input [String] typed subject like "pr:77", "files:*.rb", "diff:HEAD~3"
|
|
54
|
+
# @return [Hash, nil] ace-bundle config hash or nil if not a typed subject
|
|
55
|
+
def parse_typed_subject_config(input)
|
|
56
|
+
return nil unless input.is_a?(String)
|
|
57
|
+
|
|
58
|
+
parse_typed_subject(input)
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
# Merge multiple subjects into unified ace-bundle config
|
|
62
|
+
# Does NOT extract content - returns merged config for direct use with ace-bundle
|
|
63
|
+
# Uses Config.merge() with :coerce_union strategy for consistent merge behavior
|
|
64
|
+
# @param subjects [Array<String, Hash>] array of subject configurations
|
|
65
|
+
# @return [Hash, nil] merged ace-bundle config hash or nil if empty
|
|
66
|
+
def merge_typed_subject_configs(subjects)
|
|
67
|
+
return nil unless subjects.is_a?(Array) && subjects.any?
|
|
68
|
+
|
|
69
|
+
# Use Config objects with :coerce_union strategy to progressively merge subjects
|
|
70
|
+
# This enables future per-key merge strategies via _merge directive
|
|
71
|
+
initial_config = Ace::Support::Config::Models::Config.new({}, merge_strategy: :coerce_union)
|
|
72
|
+
|
|
73
|
+
merged_config = subjects.reduce(initial_config) do |acc, subject|
|
|
74
|
+
config_hash = resolve_single_subject(subject)
|
|
75
|
+
acc.merge(config_hash)
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
merged_config.empty? ? nil : merged_config.to_h
|
|
79
|
+
end
|
|
80
|
+
|
|
81
|
+
private
|
|
82
|
+
|
|
83
|
+
# Deep merge configs with array concatenation, dedup, and recursive hash merging
|
|
84
|
+
# Uses Config.merge() with :coerce_union strategy for consistent merge behavior
|
|
85
|
+
#
|
|
86
|
+
# @param base [Hash] base configuration hash
|
|
87
|
+
# @param overlay [Hash] overlay configuration hash
|
|
88
|
+
# @return [Hash] merged configuration (new hash, does not mutate inputs)
|
|
89
|
+
def deep_merge_arrays(base, overlay)
|
|
90
|
+
Ace::Support::Config::Models::Config.new(base, merge_strategy: :coerce_union)
|
|
91
|
+
.merge(overlay)
|
|
92
|
+
.to_h
|
|
93
|
+
end
|
|
94
|
+
|
|
95
|
+
# Resolve a single subject to ace-bundle config
|
|
96
|
+
# @param subject [String, Hash] single subject configuration
|
|
97
|
+
# @return [Hash] ace-bundle config hash
|
|
98
|
+
def resolve_single_subject(subject)
|
|
99
|
+
case subject
|
|
100
|
+
when String
|
|
101
|
+
parse_typed_subject(subject) || parse_keyword_or_pattern(subject)
|
|
102
|
+
when Hash
|
|
103
|
+
subject
|
|
104
|
+
else
|
|
105
|
+
{}
|
|
106
|
+
end
|
|
107
|
+
end
|
|
108
|
+
|
|
109
|
+
def extract_from_string(input)
|
|
110
|
+
# Try typed subject first (new)
|
|
111
|
+
if (typed_config = parse_typed_subject(input))
|
|
112
|
+
return use_ace_bundle(typed_config)
|
|
113
|
+
end
|
|
114
|
+
|
|
115
|
+
# Try to parse as YAML first
|
|
116
|
+
begin
|
|
117
|
+
parsed = YAML.safe_load(input)
|
|
118
|
+
return extract_from_hash(parsed) if parsed.is_a?(Hash)
|
|
119
|
+
rescue Psych::SyntaxError
|
|
120
|
+
# Continue with string processing
|
|
121
|
+
end
|
|
122
|
+
|
|
123
|
+
use_ace_bundle(parse_keyword_or_pattern(input))
|
|
124
|
+
end
|
|
125
|
+
|
|
126
|
+
# Parse special keywords and auto-detect patterns
|
|
127
|
+
# @param input [String] input string to parse
|
|
128
|
+
# @return [Hash] ace-bundle config hash
|
|
129
|
+
def parse_keyword_or_pattern(input)
|
|
130
|
+
case input.downcase
|
|
131
|
+
when "staged"
|
|
132
|
+
{"diffs" => ["--staged"]}
|
|
133
|
+
when "working", "unstaged"
|
|
134
|
+
{"diffs" => [""]}
|
|
135
|
+
when "pr", "pull-request"
|
|
136
|
+
tracking = Ace::Git::Molecules::BranchReader.tracking_branch
|
|
137
|
+
range = tracking ? "#{tracking}...HEAD" : "origin/main...HEAD"
|
|
138
|
+
{"diffs" => [range]}
|
|
139
|
+
else
|
|
140
|
+
auto_detect_pattern(input)
|
|
141
|
+
end
|
|
142
|
+
end
|
|
143
|
+
|
|
144
|
+
# Auto-detect whether input is a git range or file pattern
|
|
145
|
+
# @param input [String] input string to analyze
|
|
146
|
+
# @return [Hash] ace-bundle config hash
|
|
147
|
+
def auto_detect_pattern(input)
|
|
148
|
+
if looks_like_git_range?(input)
|
|
149
|
+
{"diffs" => [input]}
|
|
150
|
+
elsif input.include?("*") || input.include?("/")
|
|
151
|
+
{"files" => [input]}
|
|
152
|
+
else
|
|
153
|
+
# Default to git diff
|
|
154
|
+
{"diffs" => [input]}
|
|
155
|
+
end
|
|
156
|
+
end
|
|
157
|
+
|
|
158
|
+
def extract_from_hash(config)
|
|
159
|
+
# Pass configuration directly to ace-bundle without transformation
|
|
160
|
+
use_ace_bundle(config)
|
|
161
|
+
end
|
|
162
|
+
|
|
163
|
+
def use_ace_bundle(config)
|
|
164
|
+
# Use ace-bundle for unified content extraction
|
|
165
|
+
# Pass config directly as inline YAML - ace-bundle's load_inline_yaml
|
|
166
|
+
# supports both flat keys (files, diffs, commands, pr) and nested
|
|
167
|
+
# structure (bundle: { diffs: [...] }) for typed subject compatibility
|
|
168
|
+
context_md = "#{YAML.dump(config).strip}\n---\n\n"
|
|
169
|
+
|
|
170
|
+
result = Ace::Bundle.load_auto(context_md, format: "markdown")
|
|
171
|
+
result.content
|
|
172
|
+
rescue => e
|
|
173
|
+
warn "ace-bundle extraction failed: #{e.message}" if Ace::Review.debug?
|
|
174
|
+
""
|
|
175
|
+
end
|
|
176
|
+
|
|
177
|
+
def parse_typed_subject(input)
|
|
178
|
+
case input
|
|
179
|
+
when /^diff:(.+)$/
|
|
180
|
+
{"bundle" => {"diffs" => [::Regexp.last_match(1)]}}
|
|
181
|
+
when /^diff:$/
|
|
182
|
+
raise ArgumentError, "Empty value for diff: subject. Usage: diff:RANGE (e.g., diff:HEAD~3...HEAD)"
|
|
183
|
+
when /^pr:(.+)$/
|
|
184
|
+
pr_refs = ::Regexp.last_match(1).split(",").map(&:strip).reject(&:empty?).uniq
|
|
185
|
+
if pr_refs.empty?
|
|
186
|
+
raise ArgumentError, "No valid PR references provided. Usage: pr:REF (e.g., pr:123, pr:owner/repo#456)"
|
|
187
|
+
end
|
|
188
|
+
# Pre-validate PR refs for early error feedback using ace-git's parser
|
|
189
|
+
# Supports: simple numbers (123), qualified refs (owner/repo#456), GitHub URLs
|
|
190
|
+
pr_refs.each do |ref|
|
|
191
|
+
Ace::Git::Atoms::PrIdentifierParser.parse(ref)
|
|
192
|
+
end
|
|
193
|
+
{"bundle" => {"pr" => pr_refs}}
|
|
194
|
+
when /^pr:$/
|
|
195
|
+
raise ArgumentError, "Empty value for pr: subject. Usage: pr:NUMBER (e.g., pr:123)"
|
|
196
|
+
when /^files:(.+)$/
|
|
197
|
+
file_patterns = ::Regexp.last_match(1).split(",").map(&:strip).reject(&:empty?)
|
|
198
|
+
if file_patterns.empty?
|
|
199
|
+
raise ArgumentError, "No valid file patterns provided. Usage: files:PATTERN (e.g., files:src/**/*.rb)"
|
|
200
|
+
end
|
|
201
|
+
{"bundle" => {"files" => file_patterns}}
|
|
202
|
+
when /^files:$/
|
|
203
|
+
raise ArgumentError, "Empty value for files: subject. Usage: files:PATTERN (e.g., files:src/**/*.rb)"
|
|
204
|
+
when /^commit:(.+)$/
|
|
205
|
+
commit_hash = ::Regexp.last_match(1).strip
|
|
206
|
+
if commit_hash.empty?
|
|
207
|
+
raise ArgumentError, "Empty value for commit: subject. Usage: commit:HASH"
|
|
208
|
+
end
|
|
209
|
+
# Normalize to lowercase for validation (git hashes are lowercase)
|
|
210
|
+
commit_hash = commit_hash.downcase
|
|
211
|
+
# Validate hash format: 6-40 hexadecimal characters
|
|
212
|
+
unless commit_hash.match?(/\A[a-f0-9]{6,40}\z/)
|
|
213
|
+
raise ArgumentError, "Invalid commit hash format: '#{commit_hash}'. Must be 6-40 hexadecimal characters."
|
|
214
|
+
end
|
|
215
|
+
{"bundle" => {"diffs" => ["#{commit_hash}~1..#{commit_hash}"]}}
|
|
216
|
+
when /^commit:$/
|
|
217
|
+
raise ArgumentError, "Empty value for commit: subject. Usage: commit:HASH"
|
|
218
|
+
when /^task:(.+)$/
|
|
219
|
+
resolve_task_subject(::Regexp.last_match(1), timeout: @taskflow_timeout)
|
|
220
|
+
when /^task:$/
|
|
221
|
+
raise ArgumentError, "Empty value for task: subject. Usage: task:REF (e.g., task:145)"
|
|
222
|
+
else
|
|
223
|
+
nil # Fall through to existing parsing
|
|
224
|
+
end
|
|
225
|
+
end
|
|
226
|
+
|
|
227
|
+
# Default timeout for ace-task subprocess (in seconds)
|
|
228
|
+
# Can be overridden via options for environments with slow I/O
|
|
229
|
+
TASKFLOW_TIMEOUT = 10
|
|
230
|
+
|
|
231
|
+
def resolve_task_subject(ref, timeout: TASKFLOW_TIMEOUT)
|
|
232
|
+
# Validate ref format to prevent injection (alphanumeric, dots, dashes, plus only)
|
|
233
|
+
# Plus is needed for qualified task refs like v.0.9.0+task.145
|
|
234
|
+
unless ref.match?(/\A[\w.\-+]+\z/)
|
|
235
|
+
raise ArgumentError, "Invalid task reference format: #{ref}"
|
|
236
|
+
end
|
|
237
|
+
|
|
238
|
+
# Use Open3.popen3 with PID tracking for proper timeout handling
|
|
239
|
+
# Ensures child process is terminated on timeout (prevents orphaned processes)
|
|
240
|
+
stdout, status = execute_taskflow_with_timeout(ref, timeout)
|
|
241
|
+
|
|
242
|
+
unless status&.success?
|
|
243
|
+
raise Errors::TaskNotFoundError, ref
|
|
244
|
+
end
|
|
245
|
+
|
|
246
|
+
task_path = stdout.strip
|
|
247
|
+
if task_path.empty?
|
|
248
|
+
raise Errors::TaskPathNotFoundError, ref
|
|
249
|
+
end
|
|
250
|
+
|
|
251
|
+
# ace-task 'task REF --path' returns the path to the main task file
|
|
252
|
+
# (e.g., .ace-task/v.0.9.0/tasks/145-feature/145.s.md).
|
|
253
|
+
# We use File.dirname to get the task's directory and glob for all
|
|
254
|
+
# solution files (*.s.md) within it - this includes the main task
|
|
255
|
+
# and any subtasks (145.01.s.md, 145.02.s.md, etc.)
|
|
256
|
+
{"bundle" => {"files" => ["#{File.dirname(task_path)}/**/*.s.md"]}}
|
|
257
|
+
end
|
|
258
|
+
|
|
259
|
+
# Execute ace-task with timeout and proper process cleanup
|
|
260
|
+
# @param ref [String] Task reference
|
|
261
|
+
# @param timeout_seconds [Integer] Timeout in seconds
|
|
262
|
+
# @return [Array] stdout, status
|
|
263
|
+
# @raise [Errors::MissingDependencyError] if ace-task not installed
|
|
264
|
+
# @raise [Errors::CommandTimeoutError] if command exceeds timeout
|
|
265
|
+
def execute_taskflow_with_timeout(ref, timeout_seconds)
|
|
266
|
+
pid = nil
|
|
267
|
+
stdout_str = ""
|
|
268
|
+
status = nil
|
|
269
|
+
|
|
270
|
+
begin
|
|
271
|
+
Timeout.timeout(timeout_seconds) do
|
|
272
|
+
stdout_str, _stderr, status, pid = run_taskflow_command(ref)
|
|
273
|
+
end
|
|
274
|
+
rescue Errno::ENOENT
|
|
275
|
+
raise Errors::MissingDependencyError.new("ace-task", install_command: "gem install ace-task")
|
|
276
|
+
rescue Timeout::Error
|
|
277
|
+
# Ensure child process is terminated on timeout
|
|
278
|
+
Ace::Core::Atoms::ProcessTerminator.terminate(pid) if pid
|
|
279
|
+
raise Errors::CommandTimeoutError.new("ace-task show #{ref} --path", timeout_seconds)
|
|
280
|
+
end
|
|
281
|
+
|
|
282
|
+
[stdout_str, status]
|
|
283
|
+
end
|
|
284
|
+
|
|
285
|
+
# Run ace-task command - can be stubbed in tests
|
|
286
|
+
# Uses Open3.popen3 with PID tracking for proper process cleanup on timeout
|
|
287
|
+
# @param ref [String] Task reference
|
|
288
|
+
# @return [Array] [stdout, stderr, status, pid]
|
|
289
|
+
def run_taskflow_command(ref)
|
|
290
|
+
stdout_str = ""
|
|
291
|
+
stderr_str = ""
|
|
292
|
+
status = nil
|
|
293
|
+
pid = nil
|
|
294
|
+
|
|
295
|
+
Open3.popen3("ace-task", "show", ref, "--path") do |_stdin, stdout, stderr, wait_thr|
|
|
296
|
+
pid = wait_thr.pid
|
|
297
|
+
stdout_str = stdout.read
|
|
298
|
+
stderr_str = stderr.read
|
|
299
|
+
status = wait_thr.value
|
|
300
|
+
end
|
|
301
|
+
|
|
302
|
+
[stdout_str, stderr_str, status, pid]
|
|
303
|
+
end
|
|
304
|
+
|
|
305
|
+
def looks_like_git_range?(input)
|
|
306
|
+
input.include?("..") ||
|
|
307
|
+
input.include?("HEAD") ||
|
|
308
|
+
input.include?("~") ||
|
|
309
|
+
input.include?("^") ||
|
|
310
|
+
input.match?(/^[a-f0-9]{6,40}/)
|
|
311
|
+
end
|
|
312
|
+
end
|
|
313
|
+
end
|
|
314
|
+
end
|
|
315
|
+
end
|
|
@@ -0,0 +1,199 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Ace
|
|
4
|
+
module Review
|
|
5
|
+
module Molecules
|
|
6
|
+
# Pure module for filtering review subjects based on file patterns.
|
|
7
|
+
#
|
|
8
|
+
# Provides file pattern matching logic for filtering diff content,
|
|
9
|
+
# file lists, and bundle sections. Uses glob patterns with include/exclude
|
|
10
|
+
# semantics following the standard: include patterns whitelist files,
|
|
11
|
+
# exclude patterns blacklist them.
|
|
12
|
+
#
|
|
13
|
+
# @example Filtering a diff
|
|
14
|
+
# patterns = { "include" => ["lib/**/*.rb"], "exclude" => ["**/*_test.rb"] }
|
|
15
|
+
# SubjectFilter.filter(diff_string, patterns)
|
|
16
|
+
#
|
|
17
|
+
# @example Checking file match
|
|
18
|
+
# SubjectFilter.matches_file?("lib/models/user.rb", patterns)
|
|
19
|
+
# #=> true
|
|
20
|
+
#
|
|
21
|
+
module SubjectFilter
|
|
22
|
+
# File.fnmatch flags for glob pattern matching
|
|
23
|
+
FNMATCH_FLAGS = File::FNM_PATHNAME | File::FNM_EXTGLOB
|
|
24
|
+
|
|
25
|
+
# Filter subject content based on file patterns
|
|
26
|
+
#
|
|
27
|
+
# Dispatches to appropriate filter method based on subject type.
|
|
28
|
+
# Returns subject unchanged if no patterns configured.
|
|
29
|
+
#
|
|
30
|
+
# @param subject [String, Hash] Subject to filter (diff string or hash)
|
|
31
|
+
# @param file_patterns [Hash, nil] File patterns with include/exclude arrays
|
|
32
|
+
# @return [String, Hash] Filtered subject
|
|
33
|
+
def self.filter(subject, file_patterns)
|
|
34
|
+
return subject unless has_patterns?(file_patterns)
|
|
35
|
+
|
|
36
|
+
case subject
|
|
37
|
+
when String
|
|
38
|
+
filter_diff(subject, file_patterns)
|
|
39
|
+
when Hash
|
|
40
|
+
filter_hash(subject, file_patterns)
|
|
41
|
+
else
|
|
42
|
+
subject
|
|
43
|
+
end
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
# Filter diff content based on file patterns
|
|
47
|
+
#
|
|
48
|
+
# Splits diff into per-file chunks, filters by patterns, and rejoins.
|
|
49
|
+
# Uses the destination (b/) path for consistency with renamed files.
|
|
50
|
+
#
|
|
51
|
+
# @param diff_content [String] Git diff content
|
|
52
|
+
# @param file_patterns [Hash] File patterns with include/exclude arrays
|
|
53
|
+
# @return [String] Filtered diff with only matching files
|
|
54
|
+
def self.filter_diff(diff_content, file_patterns)
|
|
55
|
+
return diff_content unless has_patterns?(file_patterns)
|
|
56
|
+
|
|
57
|
+
# Split diff into file chunks
|
|
58
|
+
chunks = split_diff_into_chunks(diff_content)
|
|
59
|
+
|
|
60
|
+
# Filter chunks based on file patterns
|
|
61
|
+
filtered_chunks = chunks.select do |chunk|
|
|
62
|
+
file_path = extract_file_path_from_chunk(chunk)
|
|
63
|
+
file_path ? matches_file?(file_path, file_patterns) : true
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
filtered_chunks.join
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
# Filter subject hash based on file patterns
|
|
70
|
+
#
|
|
71
|
+
# Filters files arrays and bundle sections within the hash.
|
|
72
|
+
#
|
|
73
|
+
# @param subject [Hash] Subject hash with files or sections
|
|
74
|
+
# @param file_patterns [Hash] File patterns with include/exclude arrays
|
|
75
|
+
# @return [Hash] Filtered subject
|
|
76
|
+
def self.filter_hash(subject, file_patterns)
|
|
77
|
+
return subject unless has_patterns?(file_patterns)
|
|
78
|
+
|
|
79
|
+
result = normalize_keys(subject.dup)
|
|
80
|
+
|
|
81
|
+
# Filter files array if present
|
|
82
|
+
if result["files"].is_a?(Array)
|
|
83
|
+
result["files"] = result["files"].select { |f| matches_file?(f.to_s, file_patterns) }
|
|
84
|
+
end
|
|
85
|
+
|
|
86
|
+
# Filter bundle sections if present
|
|
87
|
+
if result["bundle"].is_a?(Hash) && result["bundle"]["sections"].is_a?(Hash)
|
|
88
|
+
result["bundle"] = normalize_keys(result["bundle"].dup)
|
|
89
|
+
result["bundle"]["sections"] = filter_bundle_sections(result["bundle"]["sections"], file_patterns)
|
|
90
|
+
end
|
|
91
|
+
|
|
92
|
+
result
|
|
93
|
+
end
|
|
94
|
+
|
|
95
|
+
# Filter bundle sections based on file patterns
|
|
96
|
+
#
|
|
97
|
+
# Recursively filters files arrays within each section.
|
|
98
|
+
#
|
|
99
|
+
# @param sections [Hash] Bundle sections
|
|
100
|
+
# @param file_patterns [Hash] File patterns with include/exclude arrays
|
|
101
|
+
# @return [Hash] Filtered sections
|
|
102
|
+
def self.filter_bundle_sections(sections, file_patterns)
|
|
103
|
+
filtered = {}
|
|
104
|
+
|
|
105
|
+
sections.each do |name, section|
|
|
106
|
+
section = normalize_keys(section) if section.is_a?(Hash)
|
|
107
|
+
|
|
108
|
+
if section.is_a?(Hash) && section["files"].is_a?(Array)
|
|
109
|
+
filtered_files = section["files"].select { |f| matches_file?(f.to_s, file_patterns) }
|
|
110
|
+
next if filtered_files.empty?
|
|
111
|
+
|
|
112
|
+
filtered[name] = section.merge("files" => filtered_files)
|
|
113
|
+
else
|
|
114
|
+
filtered[name] = section
|
|
115
|
+
end
|
|
116
|
+
end
|
|
117
|
+
|
|
118
|
+
filtered
|
|
119
|
+
end
|
|
120
|
+
|
|
121
|
+
# Check if a file path matches the given patterns
|
|
122
|
+
#
|
|
123
|
+
# Include patterns: file must match at least one (if any exist)
|
|
124
|
+
# Exclude patterns: file must not match any
|
|
125
|
+
#
|
|
126
|
+
# @param file_path [String] File path to check
|
|
127
|
+
# @param file_patterns [Hash] File patterns with include/exclude arrays
|
|
128
|
+
# @return [Boolean] True if file matches patterns
|
|
129
|
+
def self.matches_file?(file_path, file_patterns)
|
|
130
|
+
return true unless has_patterns?(file_patterns)
|
|
131
|
+
|
|
132
|
+
includes = file_patterns["include"] || []
|
|
133
|
+
excludes = file_patterns["exclude"] || []
|
|
134
|
+
|
|
135
|
+
# If include patterns exist, file must match at least one
|
|
136
|
+
if includes.any?
|
|
137
|
+
return false unless includes.any? { |pattern| File.fnmatch?(pattern, file_path, FNMATCH_FLAGS) }
|
|
138
|
+
end
|
|
139
|
+
|
|
140
|
+
# File must not match any exclude pattern
|
|
141
|
+
return false if excludes.any? { |pattern| File.fnmatch?(pattern, file_path, FNMATCH_FLAGS) }
|
|
142
|
+
|
|
143
|
+
true
|
|
144
|
+
end
|
|
145
|
+
|
|
146
|
+
# Check if file patterns are configured and non-empty
|
|
147
|
+
#
|
|
148
|
+
# @param file_patterns [Hash, nil] File patterns hash
|
|
149
|
+
# @return [Boolean] True if patterns are configured
|
|
150
|
+
def self.has_patterns?(file_patterns)
|
|
151
|
+
return false unless file_patterns.is_a?(Hash)
|
|
152
|
+
|
|
153
|
+
(file_patterns["include"].is_a?(Array) && file_patterns["include"].any?) ||
|
|
154
|
+
(file_patterns["exclude"].is_a?(Array) && file_patterns["exclude"].any?)
|
|
155
|
+
end
|
|
156
|
+
|
|
157
|
+
# Split diff content into per-file chunks
|
|
158
|
+
#
|
|
159
|
+
# @param diff_content [String] Git diff content
|
|
160
|
+
# @return [Array<String>] Array of diff chunks
|
|
161
|
+
def self.split_diff_into_chunks(diff_content)
|
|
162
|
+
chunks = diff_content.split(/(?=^diff --git )/m)
|
|
163
|
+
chunks.reject(&:empty?)
|
|
164
|
+
end
|
|
165
|
+
private_class_method :split_diff_into_chunks
|
|
166
|
+
|
|
167
|
+
# Extract file path from a diff chunk
|
|
168
|
+
#
|
|
169
|
+
# Uses the destination (b/) path for consistency with DiffBoundaryFinder.
|
|
170
|
+
# For renamed files, this ensures filtering uses the new name, not the old.
|
|
171
|
+
#
|
|
172
|
+
# @param chunk [String] Diff chunk
|
|
173
|
+
# @return [String, nil] File path or nil if not found
|
|
174
|
+
def self.extract_file_path_from_chunk(chunk)
|
|
175
|
+
# Match "diff --git a/path b/path" - use b/ side (destination path)
|
|
176
|
+
if chunk =~ /^diff --git a\/.+? b\/(.+?)$/m
|
|
177
|
+
return Regexp.last_match(1)
|
|
178
|
+
end
|
|
179
|
+
# Fallback to +++ header for edge cases
|
|
180
|
+
if chunk =~ /^\+\+\+ b\/(.+)$/
|
|
181
|
+
return Regexp.last_match(1)
|
|
182
|
+
end
|
|
183
|
+
nil
|
|
184
|
+
end
|
|
185
|
+
private_class_method :extract_file_path_from_chunk
|
|
186
|
+
|
|
187
|
+
# Normalize hash keys to strings
|
|
188
|
+
#
|
|
189
|
+
# @param hash [Hash] Hash with symbol or string keys
|
|
190
|
+
# @return [Hash] Hash with string keys
|
|
191
|
+
def self.normalize_keys(hash)
|
|
192
|
+
return {} unless hash.is_a?(Hash)
|
|
193
|
+
hash.transform_keys(&:to_s)
|
|
194
|
+
end
|
|
195
|
+
private_class_method :normalize_keys
|
|
196
|
+
end
|
|
197
|
+
end
|
|
198
|
+
end
|
|
199
|
+
end
|
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Ace
|
|
4
|
+
module Review
|
|
5
|
+
module Molecules
|
|
6
|
+
# Factory and interface for subject splitting strategies
|
|
7
|
+
#
|
|
8
|
+
# The SubjectStrategy module provides a factory method for creating
|
|
9
|
+
# strategies that determine how review subjects are split or processed
|
|
10
|
+
# before being sent to an LLM for review.
|
|
11
|
+
#
|
|
12
|
+
# Available strategies:
|
|
13
|
+
# - :full - Pass-through strategy, no splitting (default)
|
|
14
|
+
# - :chunked - Split by logical boundaries (future)
|
|
15
|
+
# - :adaptive - Auto-select based on size (future)
|
|
16
|
+
#
|
|
17
|
+
# @example Factory usage
|
|
18
|
+
# strategy = SubjectStrategy.for(:full, config)
|
|
19
|
+
# strategy.can_handle?(subject_text, 128_000)
|
|
20
|
+
# #=> true
|
|
21
|
+
#
|
|
22
|
+
# @example Strategy lifecycle
|
|
23
|
+
# strategy = SubjectStrategy.for(:full)
|
|
24
|
+
# if strategy.can_handle?(subject, model_limit)
|
|
25
|
+
# units = strategy.prepare(subject, context)
|
|
26
|
+
# units.each { |unit| execute_review(unit) }
|
|
27
|
+
# end
|
|
28
|
+
module SubjectStrategy
|
|
29
|
+
# Registry of available strategy classes
|
|
30
|
+
STRATEGIES = {
|
|
31
|
+
full: "Ace::Review::Molecules::Strategies::FullStrategy",
|
|
32
|
+
chunked: "Ace::Review::Molecules::Strategies::ChunkedStrategy",
|
|
33
|
+
adaptive: "Ace::Review::Molecules::Strategies::AdaptiveStrategy"
|
|
34
|
+
}.freeze
|
|
35
|
+
|
|
36
|
+
# Factory method to create a strategy instance
|
|
37
|
+
#
|
|
38
|
+
# @param type [Symbol] Strategy type (:full, :chunked, :adaptive)
|
|
39
|
+
# @param config [Hash] Optional configuration for the strategy
|
|
40
|
+
# @return [Object] Strategy instance that responds to #can_handle? and #prepare
|
|
41
|
+
# @raise [UnknownStrategyError] if strategy type is not recognized
|
|
42
|
+
#
|
|
43
|
+
# @example
|
|
44
|
+
# strategy = SubjectStrategy.for(:full)
|
|
45
|
+
# strategy = SubjectStrategy.for(:chunked, chunk_size: 50_000)
|
|
46
|
+
def self.for(type, config = {})
|
|
47
|
+
type_sym = type.to_sym
|
|
48
|
+
class_name = STRATEGIES[type_sym]
|
|
49
|
+
|
|
50
|
+
unless class_name
|
|
51
|
+
available = STRATEGIES.keys.join(", ")
|
|
52
|
+
raise Ace::Review::Errors::UnknownStrategyError,
|
|
53
|
+
"Unknown strategy type '#{type}'. Available strategies: #{available}"
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
# Lazy require the strategy class
|
|
57
|
+
require_strategy(type_sym)
|
|
58
|
+
|
|
59
|
+
# Get the class and instantiate
|
|
60
|
+
klass = Object.const_get(class_name)
|
|
61
|
+
klass.new(config)
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
# Check if a strategy type is available
|
|
65
|
+
#
|
|
66
|
+
# @param type [Symbol, String] Strategy type to check
|
|
67
|
+
# @return [Boolean] true if strategy is available
|
|
68
|
+
def self.available?(type)
|
|
69
|
+
STRATEGIES.key?(type.to_sym)
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
# List available strategy types
|
|
73
|
+
#
|
|
74
|
+
# @return [Array<Symbol>] List of available strategy types
|
|
75
|
+
def self.available_strategies
|
|
76
|
+
STRATEGIES.keys
|
|
77
|
+
end
|
|
78
|
+
|
|
79
|
+
# Require the strategy file for a given type
|
|
80
|
+
# @param type [Symbol] Strategy type
|
|
81
|
+
# @api private
|
|
82
|
+
def self.require_strategy(type)
|
|
83
|
+
case type
|
|
84
|
+
when :full
|
|
85
|
+
require_relative "strategies/full_strategy"
|
|
86
|
+
when :chunked
|
|
87
|
+
require_relative "strategies/chunked_strategy"
|
|
88
|
+
when :adaptive
|
|
89
|
+
require_relative "strategies/adaptive_strategy"
|
|
90
|
+
end
|
|
91
|
+
end
|
|
92
|
+
private_class_method :require_strategy
|
|
93
|
+
end
|
|
94
|
+
end
|
|
95
|
+
end
|
|
96
|
+
end
|