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.
Files changed (108) hide show
  1. checksums.yaml +7 -0
  2. data/.ace-defaults/nav/protocols/guide-sources/ace-review.yml +10 -0
  3. data/.ace-defaults/nav/protocols/prompt-sources/ace-review.yml +36 -0
  4. data/.ace-defaults/nav/protocols/tmpl-sources/ace-review.yml +10 -0
  5. data/.ace-defaults/nav/protocols/wfi-sources/ace-review.yml +19 -0
  6. data/.ace-defaults/review/config.yml +79 -0
  7. data/.ace-defaults/review/presets/code-fit.yml +64 -0
  8. data/.ace-defaults/review/presets/code-shine.yml +44 -0
  9. data/.ace-defaults/review/presets/code-valid.yml +39 -0
  10. data/.ace-defaults/review/presets/docs.yml +42 -0
  11. data/.ace-defaults/review/presets/spec.yml +37 -0
  12. data/CHANGELOG.md +1780 -0
  13. data/LICENSE +21 -0
  14. data/README.md +42 -0
  15. data/Rakefile +14 -0
  16. data/exe/ace-review +27 -0
  17. data/exe/ace-review-feedback +17 -0
  18. data/handbook/guides/code-review-process.g.md +234 -0
  19. data/handbook/prompts/base/sections.md +23 -0
  20. data/handbook/prompts/base/system.md +60 -0
  21. data/handbook/prompts/focus/architecture/atom.md +30 -0
  22. data/handbook/prompts/focus/architecture/reflection.md +60 -0
  23. data/handbook/prompts/focus/frameworks/rails.md +40 -0
  24. data/handbook/prompts/focus/frameworks/vue-firebase.md +45 -0
  25. data/handbook/prompts/focus/languages/ruby.md +50 -0
  26. data/handbook/prompts/focus/phase/correctness.md +51 -0
  27. data/handbook/prompts/focus/phase/polish.md +43 -0
  28. data/handbook/prompts/focus/phase/quality.md +42 -0
  29. data/handbook/prompts/focus/quality/performance.md +48 -0
  30. data/handbook/prompts/focus/quality/security.md +47 -0
  31. data/handbook/prompts/focus/scope/docs.md +38 -0
  32. data/handbook/prompts/focus/scope/spec.md +58 -0
  33. data/handbook/prompts/focus/scope/tests.md +36 -0
  34. data/handbook/prompts/format/compact.md +12 -0
  35. data/handbook/prompts/format/detailed.md +39 -0
  36. data/handbook/prompts/format/standard.md +16 -0
  37. data/handbook/prompts/guidelines/icons.md +19 -0
  38. data/handbook/prompts/guidelines/tone.md +21 -0
  39. data/handbook/prompts/synthesis-review-reports.system.md +318 -0
  40. data/handbook/prompts/synthesize-feedback.system.md +147 -0
  41. data/handbook/skills/as-review-apply-feedback/SKILL.md +39 -0
  42. data/handbook/skills/as-review-package/SKILL.md +36 -0
  43. data/handbook/skills/as-review-pr/SKILL.md +38 -0
  44. data/handbook/skills/as-review-run/SKILL.md +30 -0
  45. data/handbook/skills/as-review-verify-feedback/SKILL.md +31 -0
  46. data/handbook/templates/review-tasks/task-review-summary.template.md +148 -0
  47. data/handbook/workflow-instructions/review/apply-feedback.wf.md +212 -0
  48. data/handbook/workflow-instructions/review/package.wf.md +16 -0
  49. data/handbook/workflow-instructions/review/pr.wf.md +284 -0
  50. data/handbook/workflow-instructions/review/run.wf.md +262 -0
  51. data/handbook/workflow-instructions/review/verify-feedback.wf.md +286 -0
  52. data/lib/ace/review/atoms/context_limit_resolver.rb +162 -0
  53. data/lib/ace/review/atoms/diff_boundary_finder.rb +133 -0
  54. data/lib/ace/review/atoms/feedback_id_generator.rb +66 -0
  55. data/lib/ace/review/atoms/feedback_slug_generator.rb +61 -0
  56. data/lib/ace/review/atoms/feedback_state_validator.rb +98 -0
  57. data/lib/ace/review/atoms/pr_comment_formatter.rb +325 -0
  58. data/lib/ace/review/atoms/preset_validator.rb +103 -0
  59. data/lib/ace/review/atoms/priority_filter.rb +115 -0
  60. data/lib/ace/review/atoms/retry_with_backoff.rb +75 -0
  61. data/lib/ace/review/atoms/slug_generator.rb +50 -0
  62. data/lib/ace/review/atoms/token_estimator.rb +86 -0
  63. data/lib/ace/review/cli/commands/feedback/create.rb +173 -0
  64. data/lib/ace/review/cli/commands/feedback/list.rb +280 -0
  65. data/lib/ace/review/cli/commands/feedback/resolve.rb +109 -0
  66. data/lib/ace/review/cli/commands/feedback/session_discovery.rb +70 -0
  67. data/lib/ace/review/cli/commands/feedback/show.rb +177 -0
  68. data/lib/ace/review/cli/commands/feedback/skip.rb +125 -0
  69. data/lib/ace/review/cli/commands/feedback/verify.rb +149 -0
  70. data/lib/ace/review/cli/commands/feedback.rb +79 -0
  71. data/lib/ace/review/cli/commands/review.rb +378 -0
  72. data/lib/ace/review/cli/feedback_cli.rb +71 -0
  73. data/lib/ace/review/cli.rb +103 -0
  74. data/lib/ace/review/errors.rb +146 -0
  75. data/lib/ace/review/models/feedback_item.rb +216 -0
  76. data/lib/ace/review/models/review_options.rb +208 -0
  77. data/lib/ace/review/models/reviewer.rb +181 -0
  78. data/lib/ace/review/molecules/context_composer.rb +123 -0
  79. data/lib/ace/review/molecules/context_extractor.rb +159 -0
  80. data/lib/ace/review/molecules/feedback_directory_manager.rb +183 -0
  81. data/lib/ace/review/molecules/feedback_file_reader.rb +178 -0
  82. data/lib/ace/review/molecules/feedback_file_writer.rb +210 -0
  83. data/lib/ace/review/molecules/feedback_synthesizer.rb +588 -0
  84. data/lib/ace/review/molecules/gh_cli_executor.rb +124 -0
  85. data/lib/ace/review/molecules/gh_comment_poster.rb +205 -0
  86. data/lib/ace/review/molecules/gh_comment_resolver.rb +199 -0
  87. data/lib/ace/review/molecules/gh_pr_comment_fetcher.rb +408 -0
  88. data/lib/ace/review/molecules/gh_pr_fetcher.rb +240 -0
  89. data/lib/ace/review/molecules/llm_executor.rb +142 -0
  90. data/lib/ace/review/molecules/multi_model_executor.rb +278 -0
  91. data/lib/ace/review/molecules/nav_prompt_resolver.rb +145 -0
  92. data/lib/ace/review/molecules/pr_task_spec_resolver.rb +58 -0
  93. data/lib/ace/review/molecules/preset_manager.rb +494 -0
  94. data/lib/ace/review/molecules/prompt_composer.rb +76 -0
  95. data/lib/ace/review/molecules/prompt_resolver.rb +168 -0
  96. data/lib/ace/review/molecules/strategies/adaptive_strategy.rb +193 -0
  97. data/lib/ace/review/molecules/strategies/chunked_strategy.rb +459 -0
  98. data/lib/ace/review/molecules/strategies/full_strategy.rb +114 -0
  99. data/lib/ace/review/molecules/subject_extractor.rb +315 -0
  100. data/lib/ace/review/molecules/subject_filter.rb +199 -0
  101. data/lib/ace/review/molecules/subject_strategy.rb +96 -0
  102. data/lib/ace/review/molecules/task_report_saver.rb +161 -0
  103. data/lib/ace/review/molecules/task_resolver.rb +48 -0
  104. data/lib/ace/review/organisms/feedback_manager.rb +386 -0
  105. data/lib/ace/review/organisms/review_manager.rb +1059 -0
  106. data/lib/ace/review/version.rb +7 -0
  107. data/lib/ace/review.rb +135 -0
  108. 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