aidp 0.25.0 → 0.26.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.
@@ -168,12 +168,47 @@ module Aidp
168
168
 
169
169
  # Get test commands
170
170
  def test_commands
171
- work_loop_config[:test_commands] || []
171
+ normalize_commands(work_loop_config[:test_commands] || [])
172
172
  end
173
173
 
174
174
  # Get lint commands
175
175
  def lint_commands
176
- work_loop_config[:lint_commands] || []
176
+ normalize_commands(work_loop_config[:lint_commands] || [])
177
+ end
178
+
179
+ # Get formatter commands
180
+ def formatter_commands
181
+ normalize_commands(work_loop_config[:formatter_commands] || [])
182
+ end
183
+
184
+ # Get build commands
185
+ def build_commands
186
+ normalize_commands(work_loop_config[:build_commands] || [])
187
+ end
188
+
189
+ # Get documentation commands
190
+ def documentation_commands
191
+ normalize_commands(work_loop_config[:documentation_commands] || [])
192
+ end
193
+
194
+ # Get test output mode
195
+ def test_output_mode
196
+ work_loop_config.dig(:test, :output_mode) || :full
197
+ end
198
+
199
+ # Get max output lines for tests
200
+ def test_max_output_lines
201
+ work_loop_config.dig(:test, :max_output_lines) || 500
202
+ end
203
+
204
+ # Get lint output mode
205
+ def lint_output_mode
206
+ work_loop_config.dig(:lint, :output_mode) || :full
207
+ end
208
+
209
+ # Get max output lines for linters
210
+ def lint_max_output_lines
211
+ work_loop_config.dig(:lint, :max_output_lines) || 300
177
212
  end
178
213
 
179
214
  # Get guards configuration
@@ -704,6 +739,42 @@ module Aidp
704
739
 
705
740
  private
706
741
 
742
+ # Normalize command configuration to consistent format
743
+ # Supports both string format and object format with required flag
744
+ # Examples:
745
+ # "bundle exec rspec" -> {command: "bundle exec rspec", required: true}
746
+ # {command: "rubocop", required: false} -> {command: "rubocop", required: false}
747
+ def normalize_commands(commands)
748
+ return [] if commands.nil? || commands.empty?
749
+
750
+ commands.map do |cmd|
751
+ case cmd
752
+ when String
753
+ {command: cmd, required: true}
754
+ when Hash
755
+ # Handle both symbol and string keys
756
+ command_value = cmd[:command] || cmd["command"]
757
+ required_value = if cmd.key?(:required)
758
+ cmd[:required]
759
+ else
760
+ (cmd.key?("required") ? cmd["required"] : true)
761
+ end
762
+
763
+ unless command_value.is_a?(String) && !command_value.empty?
764
+ raise ConfigurationError, "Command must be a non-empty string, got: #{command_value.inspect}"
765
+ end
766
+
767
+ unless [true, false].include?(required_value)
768
+ raise ConfigurationError, "Required flag must be boolean, got: #{required_value.inspect}"
769
+ end
770
+
771
+ {command: command_value, required: required_value}
772
+ else
773
+ raise ConfigurationError, "Command must be a string or hash, got: #{cmd.class}"
774
+ end
775
+ end
776
+ end
777
+
707
778
  def validate_configuration!
708
779
  errors = Aidp::Config.validate_harness_config(@config, @project_dir)
709
780
 
@@ -0,0 +1,45 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Aidp
4
+ module Harness
5
+ # Base class for framework-specific filtering strategies
6
+ class FilterStrategy
7
+ # @param output [String] Raw output
8
+ # @param filter [OutputFilter] Filter instance for config access
9
+ # @return [String] Filtered output
10
+ def filter(output, filter_instance)
11
+ raise NotImplementedError, "Subclasses must implement #filter"
12
+ end
13
+
14
+ protected
15
+
16
+ # Extract lines around a match (for context)
17
+ def extract_with_context(lines, index, context_lines)
18
+ start_idx = [0, index - context_lines].max
19
+ end_idx = [lines.length - 1, index + context_lines].min
20
+
21
+ lines[start_idx..end_idx]
22
+ end
23
+
24
+ # Find failure markers in output
25
+ def find_failure_markers(output)
26
+ lines = output.lines
27
+ markers = []
28
+
29
+ lines.each_with_index do |line, index|
30
+ # Check for failure patterns using safe string methods
31
+ if line.match?(/FAILED/i) ||
32
+ line.match?(/ERROR/i) ||
33
+ line.match?(/FAIL:/i) ||
34
+ line.match?(/failures?:/i) ||
35
+ line.match?(/^\s*\d{1,4}\)\s/) || # Numbered failures (limit digits to prevent ReDoS)
36
+ line.include?(") ") # Additional simple check for numbered patterns
37
+ markers << index
38
+ end
39
+ end
40
+
41
+ markers
42
+ end
43
+ end
44
+ end
45
+ end
@@ -0,0 +1,63 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "filter_strategy"
4
+
5
+ module Aidp
6
+ module Harness
7
+ # Generic filtering for unknown frameworks
8
+ class GenericFilterStrategy < FilterStrategy
9
+ def filter(output, filter_instance)
10
+ case filter_instance.mode
11
+ when :failures_only
12
+ extract_failure_lines(output, filter_instance)
13
+ when :minimal
14
+ extract_summary(output, filter_instance)
15
+ else
16
+ output
17
+ end
18
+ end
19
+
20
+ private
21
+
22
+ def extract_failure_lines(output, filter_instance)
23
+ lines = output.lines
24
+ failure_indices = find_failure_markers(output)
25
+
26
+ return output if failure_indices.empty?
27
+
28
+ # Extract failures with context
29
+ relevant_lines = Set.new
30
+ failure_indices.each do |index|
31
+ if filter_instance.include_context
32
+ extract_with_context(lines, index, filter_instance.context_lines)
33
+ start_idx = [0, index - filter_instance.context_lines].max
34
+ end_idx = [lines.length - 1, index + filter_instance.context_lines].min
35
+ (start_idx..end_idx).each { |idx| relevant_lines.add(idx) }
36
+ else
37
+ relevant_lines.add(index)
38
+ end
39
+ end
40
+
41
+ selected = relevant_lines.to_a.sort.map { |idx| lines[idx] }
42
+ selected.join
43
+ end
44
+
45
+ def extract_summary(output, filter_instance)
46
+ lines = output.lines
47
+
48
+ # Take first line, last line, and any lines with numbers/statistics
49
+ parts = []
50
+ parts << lines.first if lines.first
51
+
52
+ summary_lines = lines.select do |line|
53
+ line.match?(/\d+/) || line.match?(/summary|total|passed|failed/i)
54
+ end
55
+
56
+ parts.concat(summary_lines.uniq)
57
+ parts << lines.last if lines.last && !parts.include?(lines.last)
58
+
59
+ parts.join("\n")
60
+ end
61
+ end
62
+ end
63
+ end
@@ -0,0 +1,136 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Aidp
4
+ module Harness
5
+ # Filters test and linter output to reduce token consumption
6
+ # Uses framework-specific strategies to extract relevant information
7
+ class OutputFilter
8
+ # Output modes
9
+ MODES = {
10
+ full: :full, # No filtering (default for first run)
11
+ failures_only: :failures_only, # Only failure information
12
+ minimal: :minimal # Minimal failure info + summary
13
+ }.freeze
14
+
15
+ # @param config [Hash] Configuration options
16
+ # @option config [Symbol] :mode Output mode (:full, :failures_only, :minimal)
17
+ # @option config [Boolean] :include_context Include surrounding lines
18
+ # @option config [Integer] :context_lines Number of context lines
19
+ # @option config [Integer] :max_lines Maximum output lines
20
+ def initialize(config = {})
21
+ @mode = config[:mode] || :full
22
+ @include_context = config.fetch(:include_context, true)
23
+ @context_lines = config.fetch(:context_lines, 3)
24
+ @max_lines = config.fetch(:max_lines, 500)
25
+
26
+ validate_mode!
27
+
28
+ Aidp.log_debug("output_filter", "initialized",
29
+ mode: @mode,
30
+ include_context: @include_context,
31
+ max_lines: @max_lines)
32
+ rescue NameError
33
+ # Logging infrastructure not available in some tests
34
+ end
35
+
36
+ # Filter output based on framework and mode
37
+ # @param output [String] Raw output
38
+ # @param framework [Symbol] Framework identifier
39
+ # @return [String] Filtered output
40
+ def filter(output, framework: :unknown)
41
+ return output if @mode == :full
42
+ return "" if output.nil? || output.empty?
43
+
44
+ Aidp.log_debug("output_filter", "filtering_start",
45
+ framework: framework,
46
+ input_lines: output.lines.count)
47
+
48
+ strategy = strategy_for_framework(framework)
49
+ filtered = strategy.filter(output, self)
50
+
51
+ truncated = truncate_if_needed(filtered)
52
+
53
+ Aidp.log_debug("output_filter", "filtering_complete",
54
+ output_lines: truncated.lines.count,
55
+ reduction: reduction_stats(output, truncated))
56
+
57
+ truncated
58
+ rescue NameError
59
+ # Logging infrastructure not available
60
+ return output if @mode == :full
61
+ return "" if output.nil? || output.empty?
62
+
63
+ strategy = strategy_for_framework(framework)
64
+ filtered = strategy.filter(output, self)
65
+ truncate_if_needed(filtered)
66
+ rescue => e
67
+ # External failure - graceful degradation
68
+ begin
69
+ Aidp.log_error("output_filter", "filtering_failed",
70
+ framework: framework,
71
+ mode: @mode,
72
+ error: e.message,
73
+ error_class: e.class.name)
74
+ rescue NameError
75
+ # Logging not available
76
+ end
77
+
78
+ # Return original output as fallback
79
+ output
80
+ end
81
+
82
+ # Accessors for strategy use
83
+ attr_reader :mode, :include_context, :context_lines, :max_lines
84
+
85
+ private
86
+
87
+ def validate_mode!
88
+ unless MODES.key?(@mode)
89
+ raise ArgumentError, "Invalid mode: #{@mode}. Must be one of #{MODES.keys}"
90
+ end
91
+ end
92
+
93
+ def strategy_for_framework(framework)
94
+ case framework
95
+ when :rspec
96
+ require_relative "rspec_filter_strategy"
97
+ RSpecFilterStrategy.new
98
+ when :minitest
99
+ require_relative "generic_filter_strategy"
100
+ GenericFilterStrategy.new
101
+ when :jest
102
+ require_relative "generic_filter_strategy"
103
+ GenericFilterStrategy.new
104
+ when :pytest
105
+ require_relative "generic_filter_strategy"
106
+ GenericFilterStrategy.new
107
+ else
108
+ require_relative "generic_filter_strategy"
109
+ GenericFilterStrategy.new
110
+ end
111
+ end
112
+
113
+ def truncate_if_needed(output)
114
+ lines = output.lines
115
+ return output if lines.count <= @max_lines
116
+
117
+ truncated = lines.first(@max_lines).join
118
+ # Only add newline if truncated doesn't already end with one
119
+ separator = truncated.end_with?("\n") ? "" : "\n"
120
+ truncated + separator + "[Output truncated - #{lines.count - @max_lines} more lines omitted]"
121
+ end
122
+
123
+ def reduction_stats(input, output)
124
+ input_size = input.bytesize
125
+ output_size = output.bytesize
126
+ reduction = ((input_size - output_size).to_f / input_size * 100).round(1)
127
+
128
+ {
129
+ input_bytes: input_size,
130
+ output_bytes: output_size,
131
+ reduction_percent: reduction
132
+ }
133
+ end
134
+ end
135
+ end
136
+ end
@@ -1394,14 +1394,27 @@ module Aidp
1394
1394
 
1395
1395
  # Execute a prompt with a specific provider
1396
1396
  def execute_with_provider(provider_type, prompt, options = {})
1397
+ # Extract model from options if provided
1398
+ model_name = options.delete(:model)
1399
+
1397
1400
  # Create provider factory instance
1398
1401
  provider_factory = ProviderFactory.new
1399
1402
 
1403
+ # Add model to provider options if specified
1404
+ provider_options = options.dup
1405
+ provider_options[:model] = model_name if model_name
1406
+
1400
1407
  # Create provider instance
1401
- provider = provider_factory.create_provider(provider_type, options)
1408
+ provider = provider_factory.create_provider(provider_type, provider_options)
1402
1409
 
1403
- # Set current provider
1410
+ # Set current provider and model
1404
1411
  @current_provider = provider_type
1412
+ @current_model = model_name if model_name
1413
+
1414
+ Aidp.logger.debug("provider_manager", "Executing with provider",
1415
+ provider: provider_type,
1416
+ model: model_name,
1417
+ prompt_length: prompt.length)
1405
1418
 
1406
1419
  # Execute the prompt with the provider
1407
1420
  result = provider.send_message(prompt: prompt, session: nil)
@@ -1410,15 +1423,17 @@ module Aidp
1410
1423
  {
1411
1424
  status: "completed",
1412
1425
  provider: provider_type,
1426
+ model: model_name,
1413
1427
  output: result,
1414
1428
  metadata: {
1415
1429
  provider_type: provider_type,
1430
+ model: model_name,
1416
1431
  prompt_length: prompt.length,
1417
1432
  timestamp: Time.now.strftime("%Y-%m-%dT%H:%M:%S.%3N%z")
1418
1433
  }
1419
1434
  }
1420
1435
  rescue => e
1421
- log_rescue(e, component: "provider_manager", action: "execute_with_provider", fallback: "error_result", provider: provider_type, prompt_length: prompt.length)
1436
+ log_rescue(e, component: "provider_manager", action: "execute_with_provider", fallback: "error_result", provider: provider_type, model: model_name, prompt_length: prompt.length)
1422
1437
  # Return error result
1423
1438
  {
1424
1439
  status: "error",
@@ -0,0 +1,82 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "filter_strategy"
4
+
5
+ module Aidp
6
+ module Harness
7
+ # RSpec-specific output filtering
8
+ class RSpecFilterStrategy < FilterStrategy
9
+ def filter(output, filter_instance)
10
+ case filter_instance.mode
11
+ when :failures_only
12
+ extract_failures_only(output, filter_instance)
13
+ when :minimal
14
+ extract_minimal(output, filter_instance)
15
+ else
16
+ output
17
+ end
18
+ end
19
+
20
+ private
21
+
22
+ def extract_failures_only(output, filter_instance)
23
+ lines = output.lines
24
+ parts = []
25
+
26
+ # Extract summary line
27
+ if (summary = lines.find { |l| l.match?(/^\d+ examples?, \d+ failures?/) })
28
+ parts << "RSpec Summary:"
29
+ parts << summary
30
+ parts << ""
31
+ end
32
+
33
+ # Extract failed examples
34
+ in_failure = false
35
+ failure_lines = []
36
+
37
+ lines.each_with_index do |line, index|
38
+ # Start of failure section
39
+ if line.match?(/^Failures:/)
40
+ in_failure = true
41
+ failure_lines << line
42
+ next
43
+ end
44
+
45
+ # End of failure section (start of pending/seed info)
46
+ if in_failure && (line.match?(/^Finished in/) || line.match?(/^Pending:/))
47
+ in_failure = false
48
+ break
49
+ end
50
+
51
+ failure_lines << line if in_failure
52
+ end
53
+
54
+ if failure_lines.any?
55
+ parts << failure_lines.join
56
+ end
57
+
58
+ parts.join("\n")
59
+ end
60
+
61
+ def extract_minimal(output, filter_instance)
62
+ lines = output.lines
63
+ parts = []
64
+
65
+ # Extract only summary and failure locations
66
+ if (summary = lines.find { |l| l.match?(/^\d+ examples?, \d+ failures?/) })
67
+ parts << summary
68
+ end
69
+
70
+ # Extract failure locations (file:line references)
71
+ failure_locations = lines.select { |l| l.match?(/# \.\/\S+:\d+/) }
72
+ if failure_locations.any?
73
+ parts << ""
74
+ parts << "Failed examples:"
75
+ parts.concat(failure_locations.map(&:strip))
76
+ end
77
+
78
+ parts.join("\n")
79
+ end
80
+ end
81
+ end
82
+ end