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,216 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "yaml"
4
+
5
+ module Ace
6
+ module Review
7
+ module Models
8
+ # Represents a single feedback item from a code review.
9
+ #
10
+ # FeedbackItem is an immutable data structure containing all information
11
+ # about a review finding, including its status, priority, and resolution.
12
+ #
13
+ # @example Creating a feedback item (multiple reviewers with consensus)
14
+ # item = FeedbackItem.new(
15
+ # id: "8o7abcd123",
16
+ # title: "Missing error handling",
17
+ # files: ["src/handlers/user.rb:42-55"],
18
+ # reviewers: ["google:gemini-2.5-flash", "anthropic:claude-3.5-sonnet", "openai:gpt-4"],
19
+ # status: "pending",
20
+ # priority: "high",
21
+ # consensus: true,
22
+ # finding: "The error handling is incomplete..."
23
+ # )
24
+ #
25
+ # @example Creating a modified copy
26
+ # resolved_item = item.dup_with(status: "done", resolution: "Added try-catch block")
27
+ #
28
+ class FeedbackItem
29
+ # Valid status values for feedback items
30
+ VALID_STATUSES = %w[draft pending invalid skip done].freeze
31
+
32
+ # Valid priority values for feedback items
33
+ VALID_PRIORITIES = %w[critical high medium low].freeze
34
+
35
+ # Minimum number of reviewers for consensus
36
+ CONSENSUS_THRESHOLD = 3
37
+
38
+ attr_reader :id, :title, :files, :reviewers, :status, :priority,
39
+ :created, :updated, :finding, :context, :research, :resolution, :consensus
40
+
41
+ # Initialize a new FeedbackItem from a hash of attributes
42
+ #
43
+ # @param attrs [Hash] Attributes for the feedback item
44
+ # @option attrs [String] :id 10-character Base36 ID (6-char timestamp + 4-char random)
45
+ # @option attrs [String] :title Short description of the finding
46
+ # @option attrs [Array<String>] :files File references (path:line-range format)
47
+ # @option attrs [Array<String>] :reviewers LLM models that found this
48
+ # @option attrs [String] :reviewer Single reviewer string (converted to reviewers array)
49
+ # @option attrs [String] :status One of: draft, pending, invalid, skip, done
50
+ # @option attrs [String] :priority One of: critical, high, medium, low
51
+ # @option attrs [Boolean] :consensus True if 3+ models agree on this finding
52
+ # @option attrs [String] :created ISO8601 timestamp
53
+ # @option attrs [String] :updated ISO8601 timestamp
54
+ # @option attrs [String] :finding Original finding text
55
+ # @option attrs [String, nil] :context Additional context (optional)
56
+ # @option attrs [String, nil] :research Verification research (optional)
57
+ # @option attrs [String, nil] :resolution How it was resolved (optional)
58
+ # @raise [ArgumentError] If status or priority values are invalid
59
+ def initialize(attrs = {})
60
+ # Support both symbol and string keys
61
+ attrs = symbolize_keys(attrs)
62
+
63
+ @id = attrs[:id]
64
+ @title = attrs[:title]
65
+ @files = Array(attrs[:files])
66
+
67
+ @reviewers = normalize_reviewers(attrs[:reviewers], attrs[:reviewer])
68
+
69
+ @status = attrs[:status] || "draft"
70
+ @priority = attrs[:priority] || "medium"
71
+ @created = attrs[:created] || Time.now.utc.iso8601
72
+ @updated = attrs[:updated] || @created
73
+ @finding = attrs[:finding]
74
+ @context = attrs[:context]
75
+ @research = attrs[:research]
76
+ @resolution = attrs[:resolution]
77
+
78
+ # Consensus: true if 3+ models agree (can be explicitly set or computed)
79
+ @consensus = attrs.key?(:consensus) ? attrs[:consensus] : (@reviewers.length >= CONSENSUS_THRESHOLD)
80
+
81
+ validate!
82
+ freeze_arrays
83
+ end
84
+
85
+ # Accessor for single reviewer (returns first reviewer)
86
+ # @return [String, nil] First reviewer or nil if none
87
+ def reviewer
88
+ @reviewers.first
89
+ end
90
+
91
+ # Convert the feedback item to a hash
92
+ #
93
+ # @return [Hash] Hash representation with string keys
94
+ def to_h
95
+ hash = {
96
+ "id" => id,
97
+ "title" => title,
98
+ "files" => files.dup,
99
+ "status" => status,
100
+ "priority" => priority,
101
+ "created" => created,
102
+ "updated" => updated,
103
+ "finding" => finding,
104
+ "context" => context,
105
+ "research" => research,
106
+ "resolution" => resolution
107
+ }
108
+
109
+ # Use reviewers array if multiple reviewers, otherwise use singular reviewer key
110
+ if @reviewers.length > 1
111
+ hash["reviewers"] = @reviewers.dup
112
+ hash["consensus"] = consensus if consensus
113
+ else
114
+ hash["reviewer"] = reviewer
115
+ end
116
+
117
+ hash.compact
118
+ end
119
+
120
+ # Convert the feedback item to YAML
121
+ #
122
+ # @return [String] YAML representation
123
+ def to_yaml
124
+ to_h.to_yaml
125
+ end
126
+
127
+ # Create a new FeedbackItem with modified attributes
128
+ #
129
+ # @param changes [Hash] Attributes to change
130
+ # @return [FeedbackItem] New instance with merged attributes
131
+ def dup_with(**changes)
132
+ # Auto-update the updated timestamp when making changes
133
+ changes[:updated] ||= Time.now.utc.iso8601
134
+ FeedbackItem.new(to_h.merge(stringify_keys(changes)))
135
+ end
136
+
137
+ # Check if two feedback items are equal
138
+ #
139
+ # @param other [FeedbackItem] Other item to compare
140
+ # @return [Boolean] True if equal
141
+ def ==(other)
142
+ return false unless other.is_a?(FeedbackItem)
143
+
144
+ id == other.id &&
145
+ title == other.title &&
146
+ files == other.files &&
147
+ reviewers == other.reviewers &&
148
+ status == other.status &&
149
+ priority == other.priority &&
150
+ finding == other.finding
151
+ end
152
+
153
+ alias_method :eql?, :==
154
+
155
+ # Hash code for use in hash tables
156
+ #
157
+ # @return [Integer] Hash code
158
+ def hash
159
+ [id, title, files, reviewers, status, priority, finding].hash
160
+ end
161
+
162
+ private
163
+
164
+ # Validate status and priority values
165
+ #
166
+ # @raise [ArgumentError] If values are invalid
167
+ def validate!
168
+ unless VALID_STATUSES.include?(status)
169
+ raise ArgumentError, "Invalid status '#{status}'. Must be one of: #{VALID_STATUSES.join(", ")}"
170
+ end
171
+
172
+ unless VALID_PRIORITIES.include?(priority)
173
+ raise ArgumentError, "Invalid priority '#{priority}'. Must be one of: #{VALID_PRIORITIES.join(", ")}"
174
+ end
175
+ end
176
+
177
+ # Freeze array attributes to ensure immutability
178
+ def freeze_arrays
179
+ @files.freeze
180
+ @reviewers.freeze
181
+ end
182
+
183
+ # Normalize reviewers from various input formats
184
+ #
185
+ # @param reviewers [Array<String>, nil] Reviewers array
186
+ # @param reviewer [String, nil] Single reviewer string
187
+ # @return [Array<String>] Normalized reviewers array
188
+ def normalize_reviewers(reviewers, reviewer)
189
+ if reviewers.is_a?(Array) && reviewers.any?
190
+ reviewers.map(&:to_s).reject(&:empty?)
191
+ elsif reviewer && !reviewer.to_s.empty?
192
+ [reviewer.to_s]
193
+ else
194
+ []
195
+ end
196
+ end
197
+
198
+ # Convert hash keys to symbols
199
+ #
200
+ # @param hash [Hash] Hash with string or symbol keys
201
+ # @return [Hash] Hash with symbol keys
202
+ def symbolize_keys(hash)
203
+ hash.transform_keys(&:to_sym)
204
+ end
205
+
206
+ # Convert hash keys to strings
207
+ #
208
+ # @param hash [Hash] Hash with string or symbol keys
209
+ # @return [Hash] Hash with string keys
210
+ def stringify_keys(hash)
211
+ hash.transform_keys(&:to_s)
212
+ end
213
+ end
214
+ end
215
+ end
216
+ end
@@ -0,0 +1,208 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Ace
4
+ module Review
5
+ module Models
6
+ # Options for code review execution
7
+ class ReviewOptions
8
+ attr_accessor :preset, :output_dir, :output, :context, :subject,
9
+ :prompt_base, :prompt_format, :prompt_focus, :add_focus,
10
+ :prompt_guidelines, :model, :models, :dry_run, :verbose,
11
+ :auto_execute, :save_session, :session_dir,
12
+ :pr, :post_comment, :pr_metadata, :gh_timeout,
13
+ :pr_comments, :pr_comment_data,
14
+ :no_feedback, :feedback_model,
15
+ :list_presets, :list_prompts, :help
16
+
17
+ def initialize(hash = {})
18
+ # Core options
19
+ @preset = hash[:preset] || Ace::Review.get("defaults", "preset")
20
+ @output_dir = hash[:output_dir]
21
+ @output = hash[:output]
22
+
23
+ # Context and subject
24
+ @context = hash[:context]
25
+ @subject = hash[:subject]
26
+
27
+ # Prompt composition overrides
28
+ @prompt_base = hash[:prompt_base]
29
+ @prompt_format = hash[:prompt_format]
30
+ @prompt_focus = hash[:prompt_focus]
31
+ @add_focus = hash[:add_focus]
32
+ @prompt_guidelines = hash[:prompt_guidelines]
33
+
34
+ # Execution options
35
+ @model = hash[:model]
36
+ @models = hash[:models]
37
+ @dry_run = hash[:dry_run] || false
38
+ @verbose = hash[:verbose] || false
39
+ @auto_execute = if @dry_run
40
+ false
41
+ elsif hash[:auto_execute].nil?
42
+ Ace::Review.get("defaults", "auto_execute") || false
43
+ else
44
+ hash[:auto_execute]
45
+ end
46
+
47
+ # Session options
48
+ @save_session = hash.fetch(:save_session, true)
49
+ @session_dir = hash[:session_dir]
50
+
51
+ # PR review options
52
+ @pr = hash[:pr]
53
+ @post_comment = hash[:post_comment] || false
54
+ @pr_metadata = hash[:pr_metadata]
55
+ @gh_timeout = hash[:gh_timeout]
56
+
57
+ # PR comment options
58
+ @pr_comments = hash[:pr_comments] # nil = use default, true/false = explicit
59
+ @pr_comment_data = nil # Populated during execution
60
+
61
+ # Feedback extraction options
62
+ @no_feedback = hash[:no_feedback] || false
63
+ @feedback_model = hash[:feedback_model]
64
+
65
+ # List commands
66
+ @list_presets = hash[:list_presets] || false
67
+ @list_prompts = hash[:list_prompts] || false
68
+ @help = hash[:help] || false
69
+ end
70
+
71
+ # Convert back to hash for compatibility
72
+ def to_h
73
+ instance_variables.each_with_object({}) do |var, hash|
74
+ key = var.to_s.delete_prefix("@").to_sym
75
+ value = instance_variable_get(var)
76
+ hash[key] = value unless value.nil?
77
+ end
78
+ end
79
+
80
+ # Check if this is a list command
81
+ def list_command?
82
+ list_presets || list_prompts || help
83
+ end
84
+
85
+ # Check if this is a PR review
86
+ def pr_review?
87
+ !pr.nil? && !pr.to_s.strip.empty?
88
+ end
89
+
90
+ # Check if comment posting should be triggered (includes dry-run preview)
91
+ def should_post_comment?
92
+ pr_review? && post_comment
93
+ end
94
+
95
+ # Check if PR comments should be included as feedback source
96
+ # Enabled by default for PR reviews, can be disabled with --no-pr-comments
97
+ def include_pr_comments?
98
+ return false unless pr_review?
99
+
100
+ # Explicit flag overrides everything
101
+ return pr_comments unless pr_comments.nil?
102
+
103
+ # Check config default (defaults to true for PR reviews)
104
+ config_default = Ace::Review.get("defaults", "pr_comments")
105
+ config_default.nil? || config_default
106
+ end
107
+
108
+ # Check if output should be saved
109
+ def save_output?
110
+ !dry_run && save_session
111
+ end
112
+
113
+ # Check if feedback extraction is enabled
114
+ def feedback_enabled?
115
+ !@no_feedback
116
+ end
117
+
118
+ # Get effective model (single model)
119
+ # Priority: model scalar > first model in models array > config_model > default
120
+ def effective_model(config_model = nil)
121
+ return model if model
122
+ return models.first if models&.any?
123
+ config_model || "google:gemini-2.5-flash"
124
+ end
125
+
126
+ # Get effective models array
127
+ # Returns array of models, handling both single model and multi-model cases
128
+ # Priority: models array > model scalar > config_models > default
129
+ def effective_models(config_models = nil)
130
+ # If models array is set (from CLI), use it
131
+ return models if models&.any?
132
+
133
+ # If model scalar is set, wrap in array
134
+ return [model] if model
135
+
136
+ # If config provides models array, use it
137
+ if config_models.is_a?(Array) && config_models.any?
138
+ return config_models
139
+ end
140
+
141
+ # If config provides single model, wrap in array
142
+ if config_models.is_a?(String) && !config_models.empty?
143
+ return [config_models]
144
+ end
145
+
146
+ # Default to single model
147
+ ["google:gemini-2.5-flash"]
148
+ end
149
+
150
+ # Merge with config values
151
+ def merge_config(config)
152
+ return if config.nil?
153
+
154
+ # Merge system prompt configuration if not overridden
155
+ @prompt_base ||= config.dig("system_prompt", "base")
156
+ @prompt_format ||= config.dig("system_prompt", "format")
157
+
158
+ # Handle focus modules
159
+ if @add_focus && config.dig("system_prompt", "focus")
160
+ existing_focus = config.dig("system_prompt", "focus") || []
161
+ additional_focus = @add_focus.split(",").map(&:strip)
162
+ @prompt_focus = (existing_focus + additional_focus).uniq.join(",")
163
+ elsif !@prompt_focus && config.dig("system_prompt", "focus")
164
+ @prompt_focus = Array(config.dig("system_prompt", "focus")).join(",")
165
+ end
166
+
167
+ @prompt_guidelines ||= Array(config.dig("system_prompt", "guidelines")).join(",") if config.dig("system_prompt", "guidelines")
168
+
169
+ # Merge other config values
170
+ @context ||= config["context"]
171
+ @subject ||= config["subject"]
172
+
173
+ # Handle models from config
174
+ # CLI models override preset models
175
+ unless @models&.any?
176
+ if config["models"].is_a?(Array) && config["models"].any?
177
+ @models = config["models"]
178
+ elsif config["model"]
179
+ @model ||= config["model"]
180
+ end
181
+ end
182
+
183
+ @gh_timeout ||= config["gh_timeout"]
184
+
185
+ self
186
+ end
187
+
188
+ # Build system prompt composition hash
189
+ def system_prompt_composition
190
+ composition = {}
191
+
192
+ composition["base"] = prompt_base if prompt_base
193
+ composition["format"] = prompt_format if prompt_format
194
+
195
+ if prompt_focus
196
+ composition["focus"] = prompt_focus.split(",").map(&:strip)
197
+ end
198
+
199
+ if prompt_guidelines
200
+ composition["guidelines"] = prompt_guidelines.split(",").map(&:strip)
201
+ end
202
+
203
+ composition.empty? ? nil : composition
204
+ end
205
+ end
206
+ end
207
+ end
208
+ end
@@ -0,0 +1,181 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Ace
4
+ module Review
5
+ module Models
6
+ # Represents a configured reviewer entity with focus areas and filtering capabilities.
7
+ #
8
+ # Reviewers are configured entities that transform from simple model names to
9
+ # full-featured review participants with focus areas, file filtering, and
10
+ # weighted contributions.
11
+ #
12
+ # @example Creating a reviewer from config
13
+ # reviewer = Reviewer.new(
14
+ # name: "code-fit",
15
+ # model: "google:gemini-2.5-pro",
16
+ # focus: "code_quality",
17
+ # system_prompt_additions: "Focus on SOLID principles...",
18
+ # file_patterns: { include: ["lib/**/*.rb"], exclude: ["**/*_test.rb"] },
19
+ # weight: 1.0,
20
+ # critical: false
21
+ # )
22
+ #
23
+ class Reviewer
24
+ # Default weight for reviewers (1.0 = full contribution)
25
+ DEFAULT_WEIGHT = 1.0
26
+
27
+ attr_reader :name, :model, :focus, :system_prompt_additions,
28
+ :file_patterns, :weight, :critical
29
+
30
+ # Initialize a new Reviewer from a configuration hash
31
+ #
32
+ # @param config [Hash] Configuration hash with reviewer settings
33
+ # @option config [String] :name Human-readable name for the reviewer
34
+ # @option config [String] :model LLM model identifier (e.g., "google:gemini-2.5-pro")
35
+ # @option config [String] :focus Review focus area (e.g., "code_quality", "security")
36
+ # @option config [String] :system_prompt_additions Additional system prompt text
37
+ # @option config [Hash] :file_patterns File filtering patterns
38
+ # @option config [Float] :weight Contribution weight (0.0-1.0, default: 1.0)
39
+ # @option config [Boolean] :critical Whether findings are always highlighted
40
+ def initialize(config = {})
41
+ # Support both symbol and string keys
42
+ config = normalize_keys(config)
43
+
44
+ @name = config["name"]
45
+ @model = config["model"]
46
+ @focus = config["focus"]
47
+ @system_prompt_additions = config["system_prompt_additions"]
48
+ @file_patterns = normalize_file_patterns(config["file_patterns"])
49
+ @weight = (config["weight"] || DEFAULT_WEIGHT).to_f
50
+ @critical = config["critical"] || false
51
+
52
+ validate!
53
+ end
54
+
55
+ # Create Reviewers from preset config (new reviewers array format)
56
+ #
57
+ # @param config [Hash] Preset configuration
58
+ # @return [Array<Reviewer>] Array of reviewer instances
59
+ def self.from_preset_config(config)
60
+ config = normalize_hash_keys(config)
61
+
62
+ # New format: reviewers array
63
+ if config["reviewers"].is_a?(Array) && config["reviewers"].any?
64
+ return config["reviewers"].map { |r| new(r) }
65
+ end
66
+
67
+ []
68
+ end
69
+
70
+ # Filter subject content based on reviewer's file patterns
71
+ #
72
+ # Delegates to SubjectFilter molecule for actual filtering logic.
73
+ #
74
+ # @param subject [Hash] Subject configuration with files/diff/content
75
+ # @return [Hash] Filtered subject (deep copy with only matching content)
76
+ def filter_subject(subject)
77
+ Molecules::SubjectFilter.filter(subject, file_patterns)
78
+ end
79
+
80
+ # Enhance system prompt with reviewer's additions
81
+ #
82
+ # @param base_prompt [String] Original system prompt
83
+ # @return [String] Enhanced prompt with reviewer additions
84
+ def enhance_system_prompt(base_prompt)
85
+ return base_prompt unless system_prompt_additions
86
+ return system_prompt_additions if base_prompt.nil? || base_prompt.empty?
87
+
88
+ "#{base_prompt}\n\n#{system_prompt_additions}"
89
+ end
90
+
91
+ # Check if this reviewer has file patterns configured
92
+ #
93
+ # Delegates to SubjectFilter molecule.
94
+ #
95
+ # @return [Boolean] True if file patterns are configured
96
+ def has_file_patterns?
97
+ Molecules::SubjectFilter.has_patterns?(file_patterns)
98
+ end
99
+
100
+ # Check if a file path matches this reviewer's patterns
101
+ #
102
+ # Delegates to SubjectFilter molecule.
103
+ #
104
+ # @param file_path [String] File path to check
105
+ # @return [Boolean] True if file matches (or no patterns configured)
106
+ def matches_file?(file_path)
107
+ Molecules::SubjectFilter.matches_file?(file_path, file_patterns)
108
+ end
109
+
110
+ # Convert to hash representation
111
+ #
112
+ # @return [Hash] Hash with string keys
113
+ def to_h
114
+ {
115
+ "name" => name,
116
+ "model" => model,
117
+ "focus" => focus,
118
+ "system_prompt_additions" => system_prompt_additions,
119
+ "file_patterns" => file_patterns,
120
+ "weight" => weight,
121
+ "critical" => critical
122
+ }.compact
123
+ end
124
+
125
+ # Check equality with another reviewer
126
+ #
127
+ # @param other [Reviewer] Other reviewer to compare
128
+ # @return [Boolean] True if equal
129
+ def ==(other)
130
+ return false unless other.is_a?(Reviewer)
131
+
132
+ name == other.name &&
133
+ model == other.model &&
134
+ focus == other.focus &&
135
+ weight == other.weight &&
136
+ critical == other.critical
137
+ end
138
+
139
+ alias_method :eql?, :==
140
+
141
+ # Hash code for use in hash tables
142
+ #
143
+ # @return [Integer] Hash code
144
+ def hash
145
+ [name, model, focus, weight, critical].hash
146
+ end
147
+
148
+ private
149
+
150
+ # Validate required fields
151
+ def validate!
152
+ raise ArgumentError, "Reviewer model is required" if model.nil? || model.to_s.strip.empty?
153
+ raise ArgumentError, "Reviewer weight must be between 0 and 1" if weight < 0 || weight > 1
154
+ end
155
+
156
+ # Normalize hash keys to strings (supports both symbol and string keys)
157
+ def normalize_keys(hash)
158
+ return {} unless hash.is_a?(Hash)
159
+ hash.transform_keys(&:to_s)
160
+ end
161
+
162
+ # Class method for key normalization
163
+ def self.normalize_hash_keys(hash)
164
+ return {} unless hash.is_a?(Hash)
165
+ hash.transform_keys(&:to_s)
166
+ end
167
+
168
+ # Normalize file patterns structure
169
+ def normalize_file_patterns(patterns)
170
+ return nil unless patterns.is_a?(Hash)
171
+
172
+ normalized = normalize_keys(patterns)
173
+ {
174
+ "include" => Array(normalized["include"]),
175
+ "exclude" => Array(normalized["exclude"])
176
+ }
177
+ end
178
+ end
179
+ end
180
+ end
181
+ end