aidp 0.32.0 → 0.33.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 (49) hide show
  1. checksums.yaml +4 -4
  2. data/lib/aidp/analyze/feature_analyzer.rb +322 -320
  3. data/lib/aidp/auto_update/coordinator.rb +97 -7
  4. data/lib/aidp/auto_update.rb +0 -12
  5. data/lib/aidp/cli/devcontainer_commands.rb +0 -5
  6. data/lib/aidp/cli.rb +2 -1
  7. data/lib/aidp/comment_consolidator.rb +78 -0
  8. data/lib/aidp/concurrency.rb +0 -3
  9. data/lib/aidp/config.rb +0 -1
  10. data/lib/aidp/config_paths.rb +71 -0
  11. data/lib/aidp/execute/work_loop_runner.rb +324 -15
  12. data/lib/aidp/harness/ai_filter_factory.rb +285 -0
  13. data/lib/aidp/harness/config_schema.rb +97 -1
  14. data/lib/aidp/harness/config_validator.rb +1 -1
  15. data/lib/aidp/harness/configuration.rb +61 -5
  16. data/lib/aidp/harness/filter_definition.rb +212 -0
  17. data/lib/aidp/harness/generated_filter_strategy.rb +197 -0
  18. data/lib/aidp/harness/output_filter.rb +50 -25
  19. data/lib/aidp/harness/output_filter_config.rb +129 -0
  20. data/lib/aidp/harness/provider_manager.rb +90 -2
  21. data/lib/aidp/harness/runner.rb +0 -11
  22. data/lib/aidp/harness/test_runner.rb +179 -41
  23. data/lib/aidp/harness/thinking_depth_manager.rb +16 -0
  24. data/lib/aidp/harness/ui/navigation/submenu.rb +0 -2
  25. data/lib/aidp/loader.rb +195 -0
  26. data/lib/aidp/metadata/compiler.rb +29 -17
  27. data/lib/aidp/metadata/query.rb +1 -1
  28. data/lib/aidp/metadata/scanner.rb +8 -1
  29. data/lib/aidp/metadata/tool_metadata.rb +13 -13
  30. data/lib/aidp/metadata/validator.rb +10 -0
  31. data/lib/aidp/metadata.rb +16 -0
  32. data/lib/aidp/pr_worktree_manager.rb +2 -2
  33. data/lib/aidp/provider_manager.rb +1 -7
  34. data/lib/aidp/setup/wizard.rb +279 -9
  35. data/lib/aidp/skills.rb +0 -5
  36. data/lib/aidp/storage/csv_storage.rb +3 -0
  37. data/lib/aidp/style_guide/selector.rb +360 -0
  38. data/lib/aidp/tooling_detector.rb +283 -16
  39. data/lib/aidp/version.rb +1 -1
  40. data/lib/aidp/watch/change_request_processor.rb +152 -14
  41. data/lib/aidp/watch/repository_client.rb +41 -0
  42. data/lib/aidp/watch/runner.rb +29 -18
  43. data/lib/aidp/watch.rb +5 -7
  44. data/lib/aidp/workstream_cleanup.rb +0 -2
  45. data/lib/aidp/workstream_executor.rb +0 -4
  46. data/lib/aidp/worktree.rb +0 -1
  47. data/lib/aidp.rb +21 -106
  48. metadata +72 -36
  49. data/lib/aidp/config/paths.rb +0 -131
@@ -0,0 +1,212 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Aidp
4
+ module Harness
5
+ # Value object representing a generated filter definition
6
+ # Created by AI during configuration, applied deterministically at runtime
7
+ #
8
+ # @example Usage
9
+ # definition = FilterDefinition.new(
10
+ # tool_name: "pytest",
11
+ # summary_patterns: ["\\d+ passed", "\\d+ failed"],
12
+ # failure_section_start: "=+ FAILURES =+",
13
+ # failure_section_end: "=+ short test summary",
14
+ # error_patterns: ["AssertionError", "Error:"],
15
+ # location_patterns: ["\\S+\\.py:\\d+"],
16
+ # noise_patterns: ["^\\s*$", "^platform "]
17
+ # )
18
+ #
19
+ # @see AIFilterFactory for generation
20
+ # @see GeneratedFilterStrategy for runtime application
21
+ class FilterDefinition
22
+ attr_reader :tool_name, :tool_command, :summary_patterns, :failure_section_start,
23
+ :failure_section_end, :error_section_start, :error_section_end,
24
+ :error_patterns, :location_patterns, :noise_patterns,
25
+ :important_patterns, :context_lines, :created_at
26
+
27
+ # Initialize a filter definition
28
+ #
29
+ # @param tool_name [String] Human-readable tool name (e.g., "pytest", "eslint")
30
+ # @param tool_command [String, nil] The command used to run the tool
31
+ # @param summary_patterns [Array<String>] Regex patterns that match summary lines
32
+ # @param failure_section_start [String, nil] Regex pattern marking start of failures section
33
+ # @param failure_section_end [String, nil] Regex pattern marking end of failures section
34
+ # @param error_section_start [String, nil] Regex pattern marking start of errors section
35
+ # @param error_section_end [String, nil] Regex pattern marking end of errors section
36
+ # @param error_patterns [Array<String>] Regex patterns that match error indicators
37
+ # @param location_patterns [Array<String>] Regex patterns that extract file:line locations
38
+ # @param noise_patterns [Array<String>] Regex patterns for lines to filter out
39
+ # @param important_patterns [Array<String>] Regex patterns for lines to always keep
40
+ # @param context_lines [Integer] Number of context lines around failures
41
+ # @param created_at [Time, nil] When this definition was generated
42
+ def initialize(
43
+ tool_name:,
44
+ tool_command: nil,
45
+ summary_patterns: [],
46
+ failure_section_start: nil,
47
+ failure_section_end: nil,
48
+ error_section_start: nil,
49
+ error_section_end: nil,
50
+ error_patterns: [],
51
+ location_patterns: [],
52
+ noise_patterns: [],
53
+ important_patterns: [],
54
+ context_lines: 3,
55
+ created_at: nil
56
+ )
57
+ @tool_name = tool_name
58
+ @tool_command = tool_command
59
+ @summary_patterns = compile_patterns(summary_patterns)
60
+ @failure_section_start = compile_pattern(failure_section_start)
61
+ @failure_section_end = compile_pattern(failure_section_end)
62
+ @error_section_start = compile_pattern(error_section_start)
63
+ @error_section_end = compile_pattern(error_section_end)
64
+ @error_patterns = compile_patterns(error_patterns)
65
+ @location_patterns = compile_patterns(location_patterns)
66
+ @noise_patterns = compile_patterns(noise_patterns)
67
+ @important_patterns = compile_patterns(important_patterns)
68
+ @context_lines = context_lines
69
+ @created_at = created_at || Time.now
70
+
71
+ freeze
72
+ end
73
+
74
+ # Create from a hash (e.g., loaded from YAML config)
75
+ #
76
+ # @param hash [Hash] Definition data with string or symbol keys
77
+ # @return [FilterDefinition]
78
+ def self.from_hash(hash)
79
+ hash = hash.transform_keys(&:to_sym)
80
+
81
+ new(
82
+ tool_name: hash[:tool_name] || "unknown",
83
+ tool_command: hash[:tool_command],
84
+ summary_patterns: Array(hash[:summary_patterns]),
85
+ failure_section_start: hash[:failure_section_start],
86
+ failure_section_end: hash[:failure_section_end],
87
+ error_section_start: hash[:error_section_start],
88
+ error_section_end: hash[:error_section_end],
89
+ error_patterns: Array(hash[:error_patterns]),
90
+ location_patterns: Array(hash[:location_patterns]),
91
+ noise_patterns: Array(hash[:noise_patterns]),
92
+ important_patterns: Array(hash[:important_patterns]),
93
+ context_lines: hash[:context_lines] || 3,
94
+ created_at: hash[:created_at] ? Time.parse(hash[:created_at].to_s) : nil
95
+ )
96
+ end
97
+
98
+ # Convert to hash for serialization (e.g., saving to YAML)
99
+ #
100
+ # @return [Hash] Serializable representation
101
+ def to_h
102
+ {
103
+ tool_name: @tool_name,
104
+ tool_command: @tool_command,
105
+ summary_patterns: patterns_to_strings(@summary_patterns),
106
+ failure_section_start: pattern_to_string(@failure_section_start),
107
+ failure_section_end: pattern_to_string(@failure_section_end),
108
+ error_section_start: pattern_to_string(@error_section_start),
109
+ error_section_end: pattern_to_string(@error_section_end),
110
+ error_patterns: patterns_to_strings(@error_patterns),
111
+ location_patterns: patterns_to_strings(@location_patterns),
112
+ noise_patterns: patterns_to_strings(@noise_patterns),
113
+ important_patterns: patterns_to_strings(@important_patterns),
114
+ context_lines: @context_lines,
115
+ created_at: @created_at&.iso8601
116
+ }.compact
117
+ end
118
+
119
+ # Check if this definition has failure section markers
120
+ #
121
+ # @return [Boolean]
122
+ def has_failure_section?
123
+ !@failure_section_start.nil?
124
+ end
125
+
126
+ # Check if this definition has error section markers
127
+ #
128
+ # @return [Boolean]
129
+ def has_error_section?
130
+ !@error_section_start.nil?
131
+ end
132
+
133
+ # Check if a line matches any summary pattern
134
+ #
135
+ # @param line [String] Line to check
136
+ # @return [Boolean]
137
+ def summary_line?(line)
138
+ @summary_patterns.any? { |pattern| line.match?(pattern) }
139
+ end
140
+
141
+ # Check if a line matches any error pattern
142
+ #
143
+ # @param line [String] Line to check
144
+ # @return [Boolean]
145
+ def error_line?(line)
146
+ @error_patterns.any? { |pattern| line.match?(pattern) }
147
+ end
148
+
149
+ # Check if a line should be filtered as noise
150
+ #
151
+ # @param line [String] Line to check
152
+ # @return [Boolean]
153
+ def noise_line?(line)
154
+ @noise_patterns.any? { |pattern| line.match?(pattern) }
155
+ end
156
+
157
+ # Check if a line should always be kept
158
+ #
159
+ # @param line [String] Line to check
160
+ # @return [Boolean]
161
+ def important_line?(line)
162
+ @important_patterns.any? { |pattern| line.match?(pattern) }
163
+ end
164
+
165
+ # Extract file locations from a line
166
+ #
167
+ # @param line [String] Line to extract locations from
168
+ # @return [Array<String>] Extracted locations
169
+ def extract_locations(line)
170
+ @location_patterns.flat_map do |pattern|
171
+ line.scan(pattern).flatten
172
+ end.compact.uniq
173
+ end
174
+
175
+ # Equality based on all attributes
176
+ def ==(other)
177
+ return false unless other.is_a?(FilterDefinition)
178
+
179
+ to_h == other.to_h
180
+ end
181
+
182
+ alias_method :eql?, :==
183
+
184
+ def hash
185
+ to_h.hash
186
+ end
187
+
188
+ private
189
+
190
+ def compile_patterns(strings)
191
+ Array(strings).map { |s| compile_pattern(s) }.compact
192
+ end
193
+
194
+ def compile_pattern(string)
195
+ return nil if string.nil? || string.empty?
196
+ Regexp.new(string, Regexp::IGNORECASE)
197
+ rescue RegexpError => e
198
+ Aidp.log_warn("filter_definition", "Invalid regex pattern",
199
+ pattern: string, error: e.message)
200
+ nil
201
+ end
202
+
203
+ def patterns_to_strings(patterns)
204
+ patterns.map { |p| p&.source }.compact
205
+ end
206
+
207
+ def pattern_to_string(pattern)
208
+ pattern&.source
209
+ end
210
+ end
211
+ end
212
+ end
@@ -0,0 +1,197 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "filter_strategy"
4
+ require_relative "filter_definition"
5
+
6
+ module Aidp
7
+ module Harness
8
+ # Deterministic filter strategy using AI-generated patterns
9
+ #
10
+ # This strategy applies a FilterDefinition at runtime without any AI calls.
11
+ # The patterns were generated by AIFilterFactory during configuration.
12
+ #
13
+ # @example Usage
14
+ # definition = FilterDefinition.from_hash(config[:filter_definitions][:pytest])
15
+ # strategy = GeneratedFilterStrategy.new(definition)
16
+ # filtered = strategy.filter(raw_output, filter_instance)
17
+ #
18
+ # @see FilterDefinition for the pattern specification
19
+ # @see AIFilterFactory for how definitions are generated
20
+ class GeneratedFilterStrategy < FilterStrategy
21
+ attr_reader :definition
22
+
23
+ # Initialize with a filter definition
24
+ #
25
+ # @param definition [FilterDefinition] The AI-generated filter definition
26
+ def initialize(definition)
27
+ @definition = definition
28
+ end
29
+
30
+ # Filter output using the definition's patterns
31
+ #
32
+ # @param output [String] Raw output to filter
33
+ # @param filter_instance [OutputFilter] Filter instance with mode and config
34
+ # @return [String] Filtered output
35
+ def filter(output, filter_instance)
36
+ return output if output.nil? || output.empty?
37
+
38
+ case filter_instance.mode
39
+ when :failures_only
40
+ extract_failures_only(output)
41
+ when :minimal
42
+ extract_minimal(output)
43
+ else
44
+ output
45
+ end
46
+ end
47
+
48
+ private
49
+
50
+ # Extract failures and summary for failures_only mode
51
+ def extract_failures_only(output)
52
+ lines = output.lines
53
+ parts = []
54
+
55
+ # Extract summary lines
56
+ summary_lines = extract_summary_lines(lines)
57
+ if summary_lines.any?
58
+ parts << "#{@definition.tool_name} Summary:"
59
+ parts.concat(summary_lines.map(&:strip))
60
+ parts << ""
61
+ end
62
+
63
+ # Extract failure section if markers defined
64
+ if @definition.has_failure_section?
65
+ failure_content = extract_section(lines,
66
+ @definition.failure_section_start,
67
+ @definition.failure_section_end)
68
+ if failure_content.any?
69
+ parts << "Failures:"
70
+ parts.concat(failure_content)
71
+ parts << ""
72
+ end
73
+ end
74
+
75
+ # Extract error section if different from failures
76
+ if @definition.has_error_section?
77
+ error_content = extract_section(lines,
78
+ @definition.error_section_start,
79
+ @definition.error_section_end)
80
+ if error_content.any?
81
+ parts << "Errors:"
82
+ parts.concat(error_content)
83
+ parts << ""
84
+ end
85
+ end
86
+
87
+ # If no section markers are defined, extract lines with error patterns
88
+ # This handles tools that don't have structured failure sections
89
+ unless @definition.has_failure_section? || @definition.has_error_section?
90
+ error_lines = extract_error_lines(lines)
91
+ if error_lines.any?
92
+ parts << "Issues:"
93
+ parts.concat(error_lines)
94
+ parts << ""
95
+ end
96
+ end
97
+
98
+ result = parts.join("\n")
99
+ result.empty? ? output : result
100
+ end
101
+
102
+ # Extract minimal information (summary + locations only)
103
+ def extract_minimal(output)
104
+ lines = output.lines
105
+ parts = []
106
+
107
+ # Extract summary
108
+ summary_lines = extract_summary_lines(lines)
109
+ parts.concat(summary_lines.map(&:strip))
110
+
111
+ # Extract all file locations
112
+ locations = extract_all_locations(lines)
113
+ if locations.any?
114
+ parts << ""
115
+ parts << "Locations:"
116
+ parts.concat(locations.uniq.map { |loc| " #{loc}" })
117
+ end
118
+
119
+ parts.join("\n")
120
+ end
121
+
122
+ # Extract lines matching summary patterns
123
+ def extract_summary_lines(lines)
124
+ lines.select { |line| @definition.summary_line?(line) }
125
+ end
126
+
127
+ # Extract content between section markers
128
+ def extract_section(lines, start_pattern, end_pattern)
129
+ return [] unless start_pattern
130
+
131
+ content = []
132
+ in_section = false
133
+
134
+ lines.each do |line|
135
+ if !in_section && line.match?(start_pattern)
136
+ in_section = true
137
+ content << line unless @definition.noise_line?(line)
138
+ next
139
+ end
140
+
141
+ if in_section
142
+ # Check for section end
143
+ if end_pattern && line.match?(end_pattern)
144
+ in_section = false
145
+ next
146
+ end
147
+
148
+ # Skip noise lines but keep important ones
149
+ if @definition.important_line?(line) || !@definition.noise_line?(line)
150
+ content << line
151
+ end
152
+ end
153
+ end
154
+
155
+ content.map(&:rstrip)
156
+ end
157
+
158
+ # Extract lines with error patterns (when no sections defined)
159
+ def extract_error_lines(lines)
160
+ result = []
161
+ context_buffer = []
162
+ context_lines = @definition.context_lines
163
+
164
+ lines.each_with_index do |line, index|
165
+ # Keep track of context
166
+ context_buffer << line
167
+ context_buffer.shift if context_buffer.size > context_lines
168
+
169
+ # Check for error indicators
170
+ if @definition.error_line?(line) || @definition.important_line?(line)
171
+ # Add preceding context
172
+ result.concat(context_buffer[0...-1]) if context_buffer.size > 1
173
+
174
+ # Add the error line
175
+ result << line
176
+
177
+ # Add following context
178
+ following = lines[index + 1, context_lines] || []
179
+ following.each do |following_line|
180
+ break if @definition.summary_line?(following_line)
181
+ result << following_line unless @definition.noise_line?(following_line)
182
+ end
183
+
184
+ context_buffer.clear
185
+ end
186
+ end
187
+
188
+ result.uniq.map(&:rstrip)
189
+ end
190
+
191
+ # Extract all file locations from output
192
+ def extract_all_locations(lines)
193
+ lines.flat_map { |line| @definition.extract_locations(line) }
194
+ end
195
+ end
196
+ end
197
+ end
@@ -1,9 +1,23 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ require_relative "output_filter_config"
4
+
3
5
  module Aidp
4
6
  module Harness
5
7
  # Filters test and linter output to reduce token consumption
6
- # Uses framework-specific strategies to extract relevant information
8
+ #
9
+ # Supports two filtering approaches:
10
+ # 1. AI-generated FilterDefinitions (preferred) - created once during config,
11
+ # applied deterministically at runtime for ANY tool
12
+ # 2. Built-in RSpec strategy (fallback) - for backward compatibility
13
+ #
14
+ # @example Using AI-generated filter
15
+ # definition = FilterDefinition.from_hash(config[:filter_definitions][:pytest])
16
+ # filter = OutputFilter.new(mode: :failures_only, filter_definition: definition)
17
+ # filtered = filter.filter(output)
18
+ #
19
+ # @see AIFilterFactory for generating filter definitions
20
+ # @see FilterDefinition for the definition format
7
21
  class OutputFilter
8
22
  # Output modes
9
23
  MODES = {
@@ -12,30 +26,32 @@ module Aidp
12
26
  minimal: :minimal # Minimal failure info + summary
13
27
  }.freeze
14
28
 
15
- # @param config [Hash] Configuration options
29
+ # @param config [OutputFilterConfig, Hash] Configuration options
16
30
  # @option config [Symbol] :mode Output mode (:full, :failures_only, :minimal)
17
31
  # @option config [Boolean] :include_context Include surrounding lines
18
32
  # @option config [Integer] :context_lines Number of context lines
19
33
  # @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!
34
+ # @param filter_definition [FilterDefinition, nil] AI-generated filter patterns
35
+ def initialize(config = {}, filter_definition: nil)
36
+ @config = normalize_config(config)
37
+ @mode = @config.mode
38
+ @include_context = @config.include_context
39
+ @context_lines = @config.context_lines
40
+ @max_lines = @config.max_lines
41
+ @filter_definition = filter_definition
27
42
 
28
43
  Aidp.log_debug("output_filter", "initialized",
29
44
  mode: @mode,
30
45
  include_context: @include_context,
31
- max_lines: @max_lines)
46
+ max_lines: @max_lines,
47
+ has_definition: !@filter_definition.nil?)
32
48
  rescue NameError
33
49
  # Logging infrastructure not available in some tests
34
50
  end
35
51
 
36
52
  # Filter output based on framework and mode
37
53
  # @param output [String] Raw output
38
- # @param framework [Symbol] Framework identifier
54
+ # @param framework [Symbol] Framework identifier (:rspec, :minitest, :jest, :pytest, :unknown)
39
55
  # @return [String] Filtered output
40
56
  def filter(output, framework: :unknown)
41
57
  return output if @mode == :full
@@ -43,6 +59,7 @@ module Aidp
43
59
 
44
60
  Aidp.log_debug("output_filter", "filtering_start",
45
61
  framework: framework,
62
+ mode: @mode,
46
63
  input_lines: output.lines.count)
47
64
 
48
65
  strategy = strategy_for_framework(framework)
@@ -51,6 +68,7 @@ module Aidp
51
68
  truncated = truncate_if_needed(filtered)
52
69
 
53
70
  Aidp.log_debug("output_filter", "filtering_complete",
71
+ framework: framework,
54
72
  output_lines: truncated.lines.count,
55
73
  reduction: reduction_stats(output, truncated))
56
74
 
@@ -80,31 +98,38 @@ module Aidp
80
98
  end
81
99
 
82
100
  # Accessors for strategy use
83
- attr_reader :mode, :include_context, :context_lines, :max_lines
101
+ attr_reader :mode, :include_context, :context_lines, :max_lines, :config, :filter_definition
84
102
 
85
103
  private
86
104
 
87
- def validate_mode!
88
- unless MODES.key?(@mode)
89
- raise ArgumentError, "Invalid mode: #{@mode}. Must be one of #{MODES.keys}"
105
+ def normalize_config(config)
106
+ case config
107
+ when OutputFilterConfig
108
+ config
109
+ when Hash
110
+ OutputFilterConfig.from_hash(config)
111
+ when nil
112
+ OutputFilterConfig.new
113
+ else
114
+ raise ArgumentError, "Config must be an OutputFilterConfig or Hash, got: #{config.class}"
90
115
  end
91
116
  end
92
117
 
93
118
  def strategy_for_framework(framework)
119
+ # Use AI-generated filter definition if available (preferred)
120
+ if @filter_definition
121
+ require_relative "generated_filter_strategy"
122
+ return GeneratedFilterStrategy.new(@filter_definition)
123
+ end
124
+
125
+ # Fall back to built-in strategies for backward compatibility
94
126
  case framework
95
127
  when :rspec
96
128
  require_relative "rspec_filter_strategy"
97
129
  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
130
  else
131
+ # Use generic strategy for all other frameworks
132
+ # Users should generate a FilterDefinition for better results
108
133
  require_relative "generic_filter_strategy"
109
134
  GenericFilterStrategy.new
110
135
  end
@@ -123,7 +148,7 @@ module Aidp
123
148
  def reduction_stats(input, output)
124
149
  input_size = input.bytesize
125
150
  output_size = output.bytesize
126
- reduction = ((input_size - output_size).to_f / input_size * 100).round(1)
151
+ reduction = input_size.zero? ? 0 : ((input_size - output_size).to_f / input_size * 100).round(1)
127
152
 
128
153
  {
129
154
  input_bytes: input_size,
@@ -0,0 +1,129 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Aidp
4
+ module Harness
5
+ # Value object for output filter configuration
6
+ # Provides validation and type-safe access to filtering options
7
+ class OutputFilterConfig
8
+ VALID_MODES = %i[full failures_only minimal].freeze
9
+ DEFAULT_MODE = :full
10
+ DEFAULT_INCLUDE_CONTEXT = true
11
+ DEFAULT_CONTEXT_LINES = 3
12
+ DEFAULT_MAX_LINES = 500
13
+ MIN_CONTEXT_LINES = 0
14
+ MAX_CONTEXT_LINES = 20
15
+ MIN_MAX_LINES = 10
16
+ MAX_MAX_LINES = 10_000
17
+
18
+ attr_reader :mode, :include_context, :context_lines, :max_lines
19
+
20
+ # Create a new OutputFilterConfig
21
+ # @param mode [Symbol] Output mode (:full, :failures_only, :minimal)
22
+ # @param include_context [Boolean] Include surrounding lines
23
+ # @param context_lines [Integer] Number of context lines (0-20)
24
+ # @param max_lines [Integer] Maximum output lines (10-10000)
25
+ # @raise [ArgumentError] If any parameter is invalid
26
+ def initialize(mode: DEFAULT_MODE, include_context: DEFAULT_INCLUDE_CONTEXT,
27
+ context_lines: DEFAULT_CONTEXT_LINES, max_lines: DEFAULT_MAX_LINES)
28
+ @mode = validate_mode(mode)
29
+ @include_context = validate_boolean(include_context, "include_context")
30
+ @context_lines = validate_context_lines(context_lines)
31
+ @max_lines = validate_max_lines(max_lines)
32
+
33
+ freeze
34
+ end
35
+
36
+ # Create from a hash (useful for configuration loading)
37
+ # @param hash [Hash] Configuration hash
38
+ # @return [OutputFilterConfig] New config instance
39
+ def self.from_hash(hash)
40
+ hash = hash.transform_keys(&:to_sym) if hash.respond_to?(:transform_keys)
41
+
42
+ new(
43
+ mode: hash[:mode] || DEFAULT_MODE,
44
+ include_context: hash.fetch(:include_context, DEFAULT_INCLUDE_CONTEXT),
45
+ context_lines: hash[:context_lines] || DEFAULT_CONTEXT_LINES,
46
+ max_lines: hash[:max_lines] || DEFAULT_MAX_LINES
47
+ )
48
+ end
49
+
50
+ # Convert to hash (useful for serialization)
51
+ # @return [Hash] Configuration as hash
52
+ def to_h
53
+ {
54
+ mode: @mode,
55
+ include_context: @include_context,
56
+ context_lines: @context_lines,
57
+ max_lines: @max_lines
58
+ }
59
+ end
60
+
61
+ # Check if filtering is enabled
62
+ # @return [Boolean] True if mode is not :full
63
+ def filtering_enabled?
64
+ @mode != :full
65
+ end
66
+
67
+ # Compare with another config
68
+ # @param other [OutputFilterConfig] Other config to compare
69
+ # @return [Boolean] True if equal
70
+ def ==(other)
71
+ return false unless other.is_a?(OutputFilterConfig)
72
+
73
+ @mode == other.mode &&
74
+ @include_context == other.include_context &&
75
+ @context_lines == other.context_lines &&
76
+ @max_lines == other.max_lines
77
+ end
78
+ alias_method :eql?, :==
79
+
80
+ # Hash for use in Hash/Set
81
+ def hash
82
+ [@mode, @include_context, @context_lines, @max_lines].hash
83
+ end
84
+
85
+ private
86
+
87
+ def validate_mode(mode)
88
+ mode = mode.to_sym if mode.respond_to?(:to_sym)
89
+
90
+ unless VALID_MODES.include?(mode)
91
+ raise ArgumentError,
92
+ "Invalid mode: #{mode.inspect}. Must be one of #{VALID_MODES.join(", ")}"
93
+ end
94
+
95
+ mode
96
+ end
97
+
98
+ def validate_boolean(value, name)
99
+ unless [true, false].include?(value)
100
+ raise ArgumentError, "#{name} must be a boolean, got: #{value.inspect}"
101
+ end
102
+
103
+ value
104
+ end
105
+
106
+ def validate_context_lines(value)
107
+ value = value.to_i if value.respond_to?(:to_i) && !value.is_a?(Integer)
108
+
109
+ unless value.is_a?(Integer) && value >= MIN_CONTEXT_LINES && value <= MAX_CONTEXT_LINES
110
+ raise ArgumentError,
111
+ "context_lines must be an integer between #{MIN_CONTEXT_LINES} and #{MAX_CONTEXT_LINES}, got: #{value.inspect}"
112
+ end
113
+
114
+ value
115
+ end
116
+
117
+ def validate_max_lines(value)
118
+ value = value.to_i if value.respond_to?(:to_i) && !value.is_a?(Integer)
119
+
120
+ unless value.is_a?(Integer) && value >= MIN_MAX_LINES && value <= MAX_MAX_LINES
121
+ raise ArgumentError,
122
+ "max_lines must be an integer between #{MIN_MAX_LINES} and #{MAX_MAX_LINES}, got: #{value.inspect}"
123
+ end
124
+
125
+ value
126
+ end
127
+ end
128
+ end
129
+ end