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.
- checksums.yaml +4 -4
- data/README.md +45 -6
- data/lib/aidp/analyze/error_handler.rb +11 -0
- data/lib/aidp/execute/work_loop_runner.rb +225 -55
- data/lib/aidp/harness/config_loader.rb +20 -11
- data/lib/aidp/harness/config_schema.rb +30 -8
- data/lib/aidp/harness/configuration.rb +73 -2
- data/lib/aidp/harness/filter_strategy.rb +45 -0
- data/lib/aidp/harness/generic_filter_strategy.rb +63 -0
- data/lib/aidp/harness/output_filter.rb +136 -0
- data/lib/aidp/harness/provider_manager.rb +18 -3
- data/lib/aidp/harness/rspec_filter_strategy.rb +82 -0
- data/lib/aidp/harness/test_runner.rb +165 -27
- data/lib/aidp/harness/ui/enhanced_tui.rb +4 -1
- data/lib/aidp/logger.rb +35 -5
- data/lib/aidp/message_display.rb +46 -0
- data/lib/aidp/safe_directory.rb +10 -3
- data/lib/aidp/storage/csv_storage.rb +9 -3
- data/lib/aidp/storage/file_manager.rb +8 -2
- data/lib/aidp/storage/json_storage.rb +9 -3
- data/lib/aidp/version.rb +1 -1
- data/lib/aidp/watch/build_processor.rb +40 -1
- data/lib/aidp/watch/change_request_processor.rb +659 -0
- data/lib/aidp/watch/plan_processor.rb +71 -8
- data/lib/aidp/watch/repository_client.rb +85 -20
- data/lib/aidp/watch/runner.rb +37 -0
- data/lib/aidp/watch/state_store.rb +46 -1
- data/lib/aidp/workstream_executor.rb +5 -2
- data/lib/aidp.rb +4 -0
- data/templates/aidp.yml.example +53 -0
- metadata +6 -1
|
@@ -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,
|
|
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
|