roast-ai 0.1.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 +7 -0
- data/.claude/settings.json +12 -0
- data/.github/workflows/ci.yaml +29 -0
- data/.github/workflows/cla.yml +22 -0
- data/.gitignore +13 -0
- data/.rspec +1 -0
- data/.rubocop.yml +12 -0
- data/.ruby-version +1 -0
- data/CHANGELOG.md +0 -0
- data/CLAUDE.md +31 -0
- data/CODE_OF_CONDUCT.md +133 -0
- data/CONTRIBUTING.md +35 -0
- data/Gemfile +19 -0
- data/Gemfile.lock +194 -0
- data/LICENSE.md +21 -0
- data/README.md +27 -0
- data/Rakefile +24 -0
- data/bin/console +11 -0
- data/examples/grading/analyze_coverage/prompt.md +52 -0
- data/examples/grading/calculate_final_grade.rb +67 -0
- data/examples/grading/format_result.rb +48 -0
- data/examples/grading/generate_grades/prompt.md +105 -0
- data/examples/grading/generate_recommendations/output.txt +17 -0
- data/examples/grading/generate_recommendations/prompt.md +60 -0
- data/examples/grading/run_coverage.rb +47 -0
- data/examples/grading/verify_mocks_and_stubs/prompt.md +12 -0
- data/examples/grading/verify_test_helpers/prompt.md +53 -0
- data/examples/grading/workflow.md +8 -0
- data/examples/grading/workflow.rb.md +6 -0
- data/examples/grading/workflow.ts+tsx.md +6 -0
- data/examples/grading/workflow.yml +46 -0
- data/exe/roast +17 -0
- data/lib/roast/helpers/function_caching_interceptor.rb +27 -0
- data/lib/roast/helpers/logger.rb +104 -0
- data/lib/roast/helpers/minitest_coverage_runner.rb +244 -0
- data/lib/roast/helpers/path_resolver.rb +148 -0
- data/lib/roast/helpers/prompt_loader.rb +97 -0
- data/lib/roast/helpers.rb +12 -0
- data/lib/roast/tools/cmd.rb +72 -0
- data/lib/roast/tools/grep.rb +43 -0
- data/lib/roast/tools/read_file.rb +49 -0
- data/lib/roast/tools/search_file.rb +51 -0
- data/lib/roast/tools/write_file.rb +60 -0
- data/lib/roast/tools.rb +50 -0
- data/lib/roast/version.rb +5 -0
- data/lib/roast/workflow/base_step.rb +94 -0
- data/lib/roast/workflow/base_workflow.rb +79 -0
- data/lib/roast/workflow/configuration.rb +117 -0
- data/lib/roast/workflow/configuration_parser.rb +92 -0
- data/lib/roast/workflow/validator.rb +37 -0
- data/lib/roast/workflow/workflow_executor.rb +119 -0
- data/lib/roast/workflow.rb +13 -0
- data/lib/roast.rb +40 -0
- data/roast.gemspec +44 -0
- data/schema/workflow.json +92 -0
- data/shipit.rubygems.yml +0 -0
- metadata +171 -0
@@ -0,0 +1,244 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
require "coverage"
|
4
|
+
require "minitest"
|
5
|
+
require_relative "logger"
|
6
|
+
|
7
|
+
# Disable the built-in `at_exit` hook for Minitest before anything else
|
8
|
+
module Minitest
|
9
|
+
class << self
|
10
|
+
alias_method :original_at_exit, :at_exit
|
11
|
+
def at_exit(*)
|
12
|
+
# Do nothing to prevent autorun hooks
|
13
|
+
end
|
14
|
+
end
|
15
|
+
end
|
16
|
+
|
17
|
+
module Roast
|
18
|
+
module Helpers
|
19
|
+
class TestStatsCollector
|
20
|
+
attr_reader :tests_count, :assertions_count
|
21
|
+
|
22
|
+
def initialize
|
23
|
+
@tests_count = 0
|
24
|
+
@assertions_count = 0
|
25
|
+
|
26
|
+
# Install our hook into Minitest's before and after hooks
|
27
|
+
Minitest.after_run { @reported = true }
|
28
|
+
|
29
|
+
# Install a custom hook to count tests
|
30
|
+
Minitest::Test.class_eval do
|
31
|
+
original_run = instance_method(:run)
|
32
|
+
|
33
|
+
define_method(:run) do |*args|
|
34
|
+
result = original_run.bind(self).call(*args)
|
35
|
+
TestStatsCollector.instance.count_test(result)
|
36
|
+
result
|
37
|
+
end
|
38
|
+
end
|
39
|
+
end
|
40
|
+
|
41
|
+
def count_test(result)
|
42
|
+
@tests_count += 1
|
43
|
+
@assertions_count += result.assertions
|
44
|
+
end
|
45
|
+
|
46
|
+
class << self
|
47
|
+
def instance
|
48
|
+
@instance ||= new
|
49
|
+
end
|
50
|
+
end
|
51
|
+
end
|
52
|
+
|
53
|
+
class MinitestCoverageRunner
|
54
|
+
def initialize(test_file_path, subject_file_path)
|
55
|
+
@test_file = File.expand_path(test_file_path)
|
56
|
+
@subject_file = File.expand_path(subject_file_path)
|
57
|
+
|
58
|
+
# Detect Rails vs Gem by checking for config/environment.rb or a .gemspec
|
59
|
+
@rails_app = File.exist?(File.join(Dir.pwd, "config", "environment.rb"))
|
60
|
+
@gem_project = Dir.glob(File.join(Dir.pwd, "*.gemspec")).any?
|
61
|
+
end
|
62
|
+
|
63
|
+
def run
|
64
|
+
# Make sure the test dir (and possibly the test file's dir) is on the LOAD_PATH,
|
65
|
+
# so that 'require "test_helper"' from inside the test works in plain IRB.
|
66
|
+
ensure_load_path_for_test
|
67
|
+
|
68
|
+
# Start coverage
|
69
|
+
Coverage.start(lines: true, branches: true, methods: true)
|
70
|
+
|
71
|
+
# If Rails app, load Rails environment & test_help
|
72
|
+
if @rails_app
|
73
|
+
ENV["RAILS_ENV"] = "test"
|
74
|
+
ENV["DISABLE_SPRING"] = "1" # ensure we don't use Spring, so coverage is captured
|
75
|
+
require File.expand_path("config/environment", Dir.pwd)
|
76
|
+
require "rails/test_help"
|
77
|
+
else
|
78
|
+
require "bundler/setup"
|
79
|
+
end
|
80
|
+
|
81
|
+
# Now require the test file directly
|
82
|
+
require @test_file
|
83
|
+
|
84
|
+
# Require the source file to make sure it's loaded for coverage
|
85
|
+
require @subject_file
|
86
|
+
|
87
|
+
# Initialize our test stats collector - must happen before tests run
|
88
|
+
stats_collector = TestStatsCollector.instance
|
89
|
+
|
90
|
+
# Run Minitest tests
|
91
|
+
# Redirect stdout to stderr for test output so that it doesn't pollute
|
92
|
+
# the JSON output of the coverage runner
|
93
|
+
original_stdout = $stdout.dup
|
94
|
+
$stdout.reopen($stderr)
|
95
|
+
test_passed = Minitest.run([])
|
96
|
+
$stdout.reopen(original_stdout) # Restore original stdout
|
97
|
+
|
98
|
+
# Report test stats
|
99
|
+
test_count = stats_collector.tests_count
|
100
|
+
assertion_count = stats_collector.assertions_count
|
101
|
+
|
102
|
+
coverage_data = Coverage.result(stop: false)
|
103
|
+
|
104
|
+
file_data = coverage_data[@subject_file]
|
105
|
+
coverage_result =
|
106
|
+
if file_data.nil?
|
107
|
+
# If file never got loaded, coverage is effectively zero for that file
|
108
|
+
{ line: 0.0, branch: 0.0, method: 0.0, tests: test_count, assertions: assertion_count }
|
109
|
+
else
|
110
|
+
result = compute_coverage_stats(file_data)
|
111
|
+
result.merge(tests: test_count, assertions: assertion_count)
|
112
|
+
end
|
113
|
+
|
114
|
+
# If the test run failed (returned false), exit 1
|
115
|
+
unless test_passed
|
116
|
+
Roast::Helpers::Logger.error("\nTest failures detected. Exiting with status 1.")
|
117
|
+
exit(1)
|
118
|
+
end
|
119
|
+
|
120
|
+
puts coverage_result.to_json
|
121
|
+
end
|
122
|
+
|
123
|
+
private
|
124
|
+
|
125
|
+
# Ensures that your test directory (and possibly the directory of the test file)
|
126
|
+
# is added to the load path so `require 'test_helper'` works from IRB context.
|
127
|
+
def ensure_load_path_for_test
|
128
|
+
test_dir = File.join(Dir.pwd, "test")
|
129
|
+
$LOAD_PATH.unshift(test_dir) if File.directory?(test_dir) && !$LOAD_PATH.include?(test_dir)
|
130
|
+
|
131
|
+
# Also add the directory of the specific test file; sometimes test files are in subdirs like `test/models`
|
132
|
+
test_file_dir = File.dirname(@test_file)
|
133
|
+
unless $LOAD_PATH.include?(test_file_dir)
|
134
|
+
$LOAD_PATH.unshift(test_file_dir)
|
135
|
+
end
|
136
|
+
end
|
137
|
+
|
138
|
+
def compute_coverage_stats(file_data)
|
139
|
+
lines_info = file_data[:lines] || []
|
140
|
+
branches_info = file_data[:branches] || {}
|
141
|
+
methods_info = file_data[:methods] || {}
|
142
|
+
source_code_lines = File.readlines(@subject_file).map(&:chomp)
|
143
|
+
|
144
|
+
# --- Line Coverage ---
|
145
|
+
executable_lines = lines_info.count { |count| !count.nil? }
|
146
|
+
covered_lines = lines_info.count { |count| count && count > 0 }
|
147
|
+
line_percent = if executable_lines.zero?
|
148
|
+
100.0
|
149
|
+
else
|
150
|
+
(covered_lines.to_f / executable_lines * 100).round(2)
|
151
|
+
end
|
152
|
+
|
153
|
+
# --- Branch Coverage ---
|
154
|
+
total_branches = 0
|
155
|
+
covered_branches = 0
|
156
|
+
uncovered_branches = []
|
157
|
+
|
158
|
+
# Track line numbers with branches for reporting
|
159
|
+
branches_info.each do |line_number, branch_group|
|
160
|
+
# Convert line_number from symbol/array to integer if needed
|
161
|
+
# Ruby Coverage module can return different formats for line numbers
|
162
|
+
line_num = if line_number.is_a?(Array)
|
163
|
+
line_number[2] # Usually the line number is the 3rd element in the array
|
164
|
+
elsif line_number.is_a?(Symbol)
|
165
|
+
# Try to extract line number from a symbol like :line_10
|
166
|
+
begin
|
167
|
+
line_number.to_s.match(/\d+/)&.[](0).to_i
|
168
|
+
rescue
|
169
|
+
0
|
170
|
+
end
|
171
|
+
else
|
172
|
+
line_number.to_i
|
173
|
+
end
|
174
|
+
|
175
|
+
# Adjust for zero-based line numbering if needed
|
176
|
+
actual_line_num = [line_num, 0].max
|
177
|
+
|
178
|
+
# Get the actual code from that line if available
|
179
|
+
line_code = begin
|
180
|
+
source_code_lines[actual_line_num - 1]&.strip
|
181
|
+
rescue
|
182
|
+
"Unknown code"
|
183
|
+
end
|
184
|
+
|
185
|
+
branch_group.each do |_branch_id, count|
|
186
|
+
total_branches += 1
|
187
|
+
if count && count > 0
|
188
|
+
covered_branches += 1
|
189
|
+
else
|
190
|
+
# Add uncovered branch to the result with line number and code
|
191
|
+
uncovered_branches << "Line #{actual_line_num}: #{line_code}"
|
192
|
+
end
|
193
|
+
end
|
194
|
+
end
|
195
|
+
|
196
|
+
branch_percent = if total_branches.zero?
|
197
|
+
100.0
|
198
|
+
else
|
199
|
+
(covered_branches.to_f / total_branches * 100).round(2)
|
200
|
+
end
|
201
|
+
|
202
|
+
# --- Method Coverage ---
|
203
|
+
total_methods = methods_info.size
|
204
|
+
covered_methods = 0
|
205
|
+
uncovered_methods = []
|
206
|
+
|
207
|
+
methods_info.each do |method_id, count|
|
208
|
+
# Method IDs in Coverage are usually in the format: [class_name, method_name, start_line, end_line]
|
209
|
+
if method_id.is_a?(Array) && method_id.size >= 4
|
210
|
+
class_name, method_name, start_line, end_line = method_id
|
211
|
+
# Construct a user-friendly representation of the method
|
212
|
+
method_signature = "#{class_name}##{method_name} (lines #{start_line}-#{end_line})"
|
213
|
+
|
214
|
+
if count && count > 0
|
215
|
+
covered_methods += 1
|
216
|
+
else
|
217
|
+
# Add uncovered method to the result
|
218
|
+
uncovered_methods << method_signature
|
219
|
+
end
|
220
|
+
elsif count && count > 0
|
221
|
+
# Handle any other format the Coverage module might return
|
222
|
+
covered_methods += 1
|
223
|
+
else
|
224
|
+
uncovered_methods << method_id.to_s
|
225
|
+
end
|
226
|
+
end
|
227
|
+
|
228
|
+
method_percent = if total_methods.zero?
|
229
|
+
100.0
|
230
|
+
else
|
231
|
+
(covered_methods.to_f / total_methods * 100).round(2)
|
232
|
+
end
|
233
|
+
|
234
|
+
{
|
235
|
+
line: line_percent,
|
236
|
+
branch: branch_percent,
|
237
|
+
method: method_percent,
|
238
|
+
uncovered_branches: uncovered_branches,
|
239
|
+
uncovered_methods: uncovered_methods,
|
240
|
+
}
|
241
|
+
end
|
242
|
+
end
|
243
|
+
end
|
244
|
+
end
|
@@ -0,0 +1,148 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
module Roast
|
4
|
+
module Helpers
|
5
|
+
# Utility class for resolving file paths with directory structure issues
|
6
|
+
class PathResolver
|
7
|
+
class << self
|
8
|
+
# Intelligently resolves a path considering possible directory structure issues
|
9
|
+
def resolve(path)
|
10
|
+
# Store original path for logging if needed
|
11
|
+
original_path = path
|
12
|
+
|
13
|
+
# Early return if the path is nil or empty
|
14
|
+
return path if path.nil? || path.empty?
|
15
|
+
|
16
|
+
# First try standard path expansion
|
17
|
+
expanded_path = File.expand_path(path)
|
18
|
+
|
19
|
+
# Return early if the file exists at the expanded path
|
20
|
+
return expanded_path if File.exist?(expanded_path)
|
21
|
+
|
22
|
+
# Get current directory and possible project root paths
|
23
|
+
current_dir = Dir.pwd
|
24
|
+
possible_roots = [
|
25
|
+
current_dir,
|
26
|
+
File.expand_path(File.join(current_dir, "..")),
|
27
|
+
File.expand_path(File.join(current_dir, "../..")),
|
28
|
+
File.expand_path(File.join(current_dir, "../../..")),
|
29
|
+
File.expand_path(File.join(current_dir, "../../../..")),
|
30
|
+
File.expand_path(File.join(current_dir, "../../../../..")),
|
31
|
+
]
|
32
|
+
|
33
|
+
# Check for directory name duplications anywhere in the path
|
34
|
+
path_parts = expanded_path.split(File::SEPARATOR).reject(&:empty?)
|
35
|
+
|
36
|
+
# Try removing each duplicate segment individually and check if the resulting path exists
|
37
|
+
path_parts.each_with_index do |part, i|
|
38
|
+
next if i == 0 # Skip the first segment
|
39
|
+
|
40
|
+
# Check if this segment appears earlier in the path
|
41
|
+
next unless path_parts[0...i].include?(part)
|
42
|
+
|
43
|
+
# Create a new path without this segment
|
44
|
+
test_parts = path_parts.dup
|
45
|
+
test_parts.delete_at(i)
|
46
|
+
|
47
|
+
test_path = if original_path.start_with?("/")
|
48
|
+
File.join("/", *test_parts)
|
49
|
+
else
|
50
|
+
File.join(test_parts)
|
51
|
+
end
|
52
|
+
|
53
|
+
# If this path exists, return it
|
54
|
+
return test_path if File.exist?(test_path)
|
55
|
+
|
56
|
+
# Also try removing all future occurrences of this segment name
|
57
|
+
duplicate_indices = []
|
58
|
+
path_parts.each_with_index do |segment, idx|
|
59
|
+
if idx > 0 && segment == part && idx >= i
|
60
|
+
duplicate_indices << idx
|
61
|
+
end
|
62
|
+
end
|
63
|
+
|
64
|
+
next if duplicate_indices.none?
|
65
|
+
|
66
|
+
filtered_parts = path_parts.dup
|
67
|
+
# Remove from end to beginning to keep indices valid
|
68
|
+
duplicate_indices.reverse_each { |idx| filtered_parts.delete_at(idx) }
|
69
|
+
|
70
|
+
test_path = if original_path.start_with?("/")
|
71
|
+
File.join("/", *filtered_parts)
|
72
|
+
else
|
73
|
+
File.join(filtered_parts)
|
74
|
+
end
|
75
|
+
|
76
|
+
return test_path if File.exist?(test_path)
|
77
|
+
end
|
78
|
+
|
79
|
+
# Try detecting all duplicates at once
|
80
|
+
seen_segments = {}
|
81
|
+
duplicate_indices = []
|
82
|
+
|
83
|
+
path_parts.each_with_index do |part, i|
|
84
|
+
if seen_segments[part]
|
85
|
+
duplicate_indices << i
|
86
|
+
else
|
87
|
+
seen_segments[part] = true
|
88
|
+
end
|
89
|
+
end
|
90
|
+
|
91
|
+
if duplicate_indices.any?
|
92
|
+
# Try removing all duplicates
|
93
|
+
unique_parts = path_parts.dup
|
94
|
+
# Remove from end to beginning to keep indices valid
|
95
|
+
duplicate_indices.reverse_each { |i| unique_parts.delete_at(i) }
|
96
|
+
|
97
|
+
test_path = if original_path.start_with?("/")
|
98
|
+
File.join("/", *unique_parts)
|
99
|
+
else
|
100
|
+
File.join(unique_parts)
|
101
|
+
end
|
102
|
+
|
103
|
+
return test_path if File.exist?(test_path)
|
104
|
+
end
|
105
|
+
|
106
|
+
# Try relative path resolution from various possible roots
|
107
|
+
relative_path = path.sub(%r{^\./}, "")
|
108
|
+
possible_roots.each do |root|
|
109
|
+
# Try the path as-is from this root
|
110
|
+
candidate = File.join(root, relative_path)
|
111
|
+
return candidate if File.exist?(candidate)
|
112
|
+
|
113
|
+
# Try with a leading slash removed
|
114
|
+
if relative_path.start_with?("/")
|
115
|
+
candidate = File.join(root, relative_path.sub(%r{^/}, ""))
|
116
|
+
return candidate if File.exist?(candidate)
|
117
|
+
end
|
118
|
+
end
|
119
|
+
|
120
|
+
# Try extracting the path after a potential project root
|
121
|
+
if expanded_path.include?("/src/") || expanded_path.include?("/lib/") || expanded_path.include?("/test/")
|
122
|
+
# Potential project markers
|
123
|
+
markers = ["/src/", "/lib/", "/test/", "/app/", "/config/"]
|
124
|
+
markers.each do |marker|
|
125
|
+
next unless expanded_path.include?(marker)
|
126
|
+
|
127
|
+
# Get the part after the marker
|
128
|
+
parts = expanded_path.split(marker, 2)
|
129
|
+
next unless parts.size == 2
|
130
|
+
|
131
|
+
marker_dir = marker.gsub("/", "")
|
132
|
+
relative_from_marker = parts[1]
|
133
|
+
|
134
|
+
# Try each possible root with this marker
|
135
|
+
possible_roots.each do |root|
|
136
|
+
candidate = File.join(root, marker_dir, relative_from_marker)
|
137
|
+
return candidate if File.exist?(candidate)
|
138
|
+
end
|
139
|
+
end
|
140
|
+
end
|
141
|
+
|
142
|
+
# Default to the original expanded path if all else fails
|
143
|
+
expanded_path
|
144
|
+
end
|
145
|
+
end
|
146
|
+
end
|
147
|
+
end
|
148
|
+
end
|
@@ -0,0 +1,97 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
require "active_support/core_ext/string"
|
4
|
+
require "erb"
|
5
|
+
|
6
|
+
module Roast
|
7
|
+
module Helpers
|
8
|
+
class PromptLoader
|
9
|
+
class << self
|
10
|
+
# Loads a sidecar prompt file for a given context (workflow or step) and target file
|
11
|
+
#
|
12
|
+
# @param context [Object] The workflow or step instance
|
13
|
+
# @param target_file [String] The path to the target file
|
14
|
+
# @return [String, nil] The processed prompt content, or nil if no prompt is found
|
15
|
+
def load_prompt(context, target_file)
|
16
|
+
new(context, target_file).load
|
17
|
+
end
|
18
|
+
end
|
19
|
+
|
20
|
+
def initialize(context, target_file)
|
21
|
+
@context = context
|
22
|
+
@name = context.name
|
23
|
+
@context_path = context.context_path
|
24
|
+
@target_file = target_file
|
25
|
+
end
|
26
|
+
|
27
|
+
def load
|
28
|
+
prompt_content = read_prompt_file(find_prompt_path)
|
29
|
+
return unless prompt_content
|
30
|
+
|
31
|
+
process_erb_if_needed(prompt_content)
|
32
|
+
end
|
33
|
+
|
34
|
+
private
|
35
|
+
|
36
|
+
attr_reader :context, :name, :context_path, :target_file
|
37
|
+
|
38
|
+
def read_prompt_file(path)
|
39
|
+
if path && File.exist?(path)
|
40
|
+
File.read(path)
|
41
|
+
else
|
42
|
+
$stderr.puts "Prompt file for #{name} not found: #{path}"
|
43
|
+
end
|
44
|
+
end
|
45
|
+
|
46
|
+
def find_prompt_path
|
47
|
+
find_specialized_prompt_path(name, extract_file_extensions)
|
48
|
+
end
|
49
|
+
|
50
|
+
def find_specialized_prompt_path(base_name, extensions)
|
51
|
+
context_dir = File.expand_path(context_path)
|
52
|
+
|
53
|
+
# Try each extension to find a specialized prompt
|
54
|
+
extensions.each do |ext|
|
55
|
+
path = File.join(context_dir, "#{base_name}.#{ext}.md")
|
56
|
+
path = File.join(context_dir, "prompt.#{ext}.md") unless File.exist?(path)
|
57
|
+
return path if File.exist?(path)
|
58
|
+
end
|
59
|
+
|
60
|
+
# Check for combined format patterns (like ts+tsx)
|
61
|
+
glob_pattern = File.join(context_dir, "{#{base_name},prompt}.*+*.md")
|
62
|
+
Dir.glob(glob_pattern).each do |combined_path|
|
63
|
+
basename = File.basename(combined_path, ".md")
|
64
|
+
combined_exts = basename.split(".", 2)[1].split("+")
|
65
|
+
|
66
|
+
# Return the first matching combined format
|
67
|
+
return combined_path if extensions.intersect?(combined_exts)
|
68
|
+
end
|
69
|
+
|
70
|
+
# Fall back to the general prompt
|
71
|
+
general_path = File.join(context_dir, "#{base_name}.md")
|
72
|
+
general_path = File.join(context_dir, "prompt.md") unless File.exist?(general_path)
|
73
|
+
general_path if File.exist?(general_path)
|
74
|
+
end
|
75
|
+
|
76
|
+
def extract_file_extensions
|
77
|
+
file_basename = File.basename(target_file)
|
78
|
+
|
79
|
+
if file_basename.end_with?(".md") && file_basename.count(".") > 1
|
80
|
+
without_md = file_basename[0...-3] # Remove .md
|
81
|
+
without_md.split(".", 2)[1]&.split("+") || []
|
82
|
+
else
|
83
|
+
ext = File.extname(target_file)[1..]
|
84
|
+
ext&.empty? ? [] : [ext]
|
85
|
+
end
|
86
|
+
end
|
87
|
+
|
88
|
+
def process_erb_if_needed(content)
|
89
|
+
if content.include?("<%")
|
90
|
+
ERB.new(content, trim_mode: "-").result(context.instance_eval { binding })
|
91
|
+
else
|
92
|
+
content
|
93
|
+
end
|
94
|
+
end
|
95
|
+
end
|
96
|
+
end
|
97
|
+
end
|
@@ -0,0 +1,12 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
require "roast/helpers/logger"
|
4
|
+
require "roast/helpers/path_resolver"
|
5
|
+
require "roast/helpers/prompt_loader"
|
6
|
+
require "roast/helpers/minitest_coverage_runner"
|
7
|
+
require "roast/helpers/function_caching_interceptor"
|
8
|
+
|
9
|
+
module Roast
|
10
|
+
module Helpers
|
11
|
+
end
|
12
|
+
end
|
@@ -0,0 +1,72 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
require "English"
|
4
|
+
require "roast/helpers/logger"
|
5
|
+
|
6
|
+
module Roast
|
7
|
+
module Tools
|
8
|
+
module Cmd
|
9
|
+
extend self
|
10
|
+
|
11
|
+
class << self
|
12
|
+
# Add this method to be included in other classes
|
13
|
+
def included(base)
|
14
|
+
base.class_eval do
|
15
|
+
function(
|
16
|
+
:cmd,
|
17
|
+
'Run a command in the current working directory (e.g. "ls", "rake", "ruby"). ' \
|
18
|
+
"You may use this tool to execute tests and verify if they pass.",
|
19
|
+
command: { type: "string", description: "The command to run in a bash shell." },
|
20
|
+
) do |params|
|
21
|
+
Roast::Tools::Cmd.call(params[:command])
|
22
|
+
end
|
23
|
+
end
|
24
|
+
end
|
25
|
+
end
|
26
|
+
|
27
|
+
def call(command)
|
28
|
+
Roast::Helpers::Logger.info("🔧 Running command: #{command}\n")
|
29
|
+
|
30
|
+
# Validate the command starts with one of the allowed prefixes
|
31
|
+
allowed_prefixes = ["pwd", "find", "ls", "rake", "ruby", "dev"]
|
32
|
+
command_prefix = command.split(" ").first
|
33
|
+
|
34
|
+
err = "Error: Command not allowed. Only commands starting with #{allowed_prefixes.join(", ")} are permitted."
|
35
|
+
return err unless allowed_prefixes.any? do |prefix|
|
36
|
+
command_prefix == prefix
|
37
|
+
end
|
38
|
+
|
39
|
+
# Execute the command in the current working directory
|
40
|
+
result = ""
|
41
|
+
|
42
|
+
# Use a full shell environment for commands, especially for 'dev'
|
43
|
+
if command_prefix == "dev"
|
44
|
+
# Use bash -l -c to ensure we get a login shell with all environment variables
|
45
|
+
full_command = "bash -l -c '#{command.gsub("'", "\\'")}'"
|
46
|
+
IO.popen(full_command, chdir: Dir.pwd) do |io|
|
47
|
+
result = io.read
|
48
|
+
end
|
49
|
+
else
|
50
|
+
# For other commands, use the original approach
|
51
|
+
IO.popen(command, chdir: Dir.pwd) do |io|
|
52
|
+
result = io.read
|
53
|
+
end
|
54
|
+
end
|
55
|
+
|
56
|
+
exit_status = $CHILD_STATUS.exitstatus
|
57
|
+
|
58
|
+
# Return the command output along with exit status information
|
59
|
+
output = "Command: #{command}\n"
|
60
|
+
output += "Exit status: #{exit_status}\n"
|
61
|
+
output += "Output:\n#{result}"
|
62
|
+
|
63
|
+
output
|
64
|
+
rescue StandardError => e
|
65
|
+
"Error running command: #{e.message}".tap do |error_message|
|
66
|
+
Roast::Helpers::Logger.error(error_message + "\n")
|
67
|
+
Roast::Helpers::Logger.debug(e.backtrace.join("\n") + "\n") if ENV["DEBUG"]
|
68
|
+
end
|
69
|
+
end
|
70
|
+
end
|
71
|
+
end
|
72
|
+
end
|
@@ -0,0 +1,43 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
require "roast/helpers/logger"
|
4
|
+
|
5
|
+
module Roast
|
6
|
+
module Tools
|
7
|
+
module Grep
|
8
|
+
extend self
|
9
|
+
|
10
|
+
MAX_RESULT_LINES = 100
|
11
|
+
|
12
|
+
class << self
|
13
|
+
# Add this method to be included in other classes
|
14
|
+
def included(base)
|
15
|
+
base.class_eval do
|
16
|
+
function(
|
17
|
+
:grep,
|
18
|
+
'Search for a string in the project using `grep -rni "#{@search_string}" .` in the project root',
|
19
|
+
string: { type: "string", description: "The string to search for" },
|
20
|
+
) do |params|
|
21
|
+
Roast::Tools::Grep.call(params[:string]).tap do |result|
|
22
|
+
Roast::Helpers::Logger.debug(result) if ENV["DEBUG"]
|
23
|
+
end
|
24
|
+
end
|
25
|
+
end
|
26
|
+
end
|
27
|
+
end
|
28
|
+
|
29
|
+
def call(string)
|
30
|
+
Roast::Helpers::Logger.info("🔍 Grepping for string: #{string}\n")
|
31
|
+
# Escape regex special characters in strings with curly braces
|
32
|
+
# Example: "import {render}" becomes "import \{render\}"
|
33
|
+
escaped_string = string.gsub(/(\{|\})/, '\\\\\\1')
|
34
|
+
%x(rg -C 4 --trim --color=never --heading -F -- "#{escaped_string}" . | head -n #{MAX_RESULT_LINES})
|
35
|
+
rescue StandardError => e
|
36
|
+
"Error grepping for string: #{e.message}".tap do |error_message|
|
37
|
+
Roast::Helpers::Logger.error(error_message + "\n")
|
38
|
+
Roast::Helpers::Logger.debug(e.backtrace.join("\n") + "\n") if ENV["DEBUG"]
|
39
|
+
end
|
40
|
+
end
|
41
|
+
end
|
42
|
+
end
|
43
|
+
end
|
@@ -0,0 +1,49 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
require "roast/helpers/logger"
|
4
|
+
|
5
|
+
module Roast
|
6
|
+
module Tools
|
7
|
+
module ReadFile
|
8
|
+
extend self
|
9
|
+
|
10
|
+
class << self
|
11
|
+
def included(base)
|
12
|
+
base.class_eval do
|
13
|
+
function(
|
14
|
+
:read_file,
|
15
|
+
"Read the contents of a file. (If the path is a directory, list the contents.) " \
|
16
|
+
"NOTE: Do not use for .rbi files, they are not useful.",
|
17
|
+
path: { type: "string", description: "The path to the file to read" },
|
18
|
+
) do |params|
|
19
|
+
Roast::Tools::ReadFile.call(params[:path]).tap do |result|
|
20
|
+
if ENV["DEBUG"]
|
21
|
+
result_lines = result.lines
|
22
|
+
if result_lines.size > 20
|
23
|
+
Roast::Helpers::Logger.debug(result_lines.first(20).join + "\n...")
|
24
|
+
else
|
25
|
+
Roast::Helpers::Logger.debug(result)
|
26
|
+
end
|
27
|
+
end
|
28
|
+
end
|
29
|
+
end
|
30
|
+
end
|
31
|
+
end
|
32
|
+
end
|
33
|
+
|
34
|
+
def call(path)
|
35
|
+
Roast::Helpers::Logger.info("📖 Reading file: #{path}\n")
|
36
|
+
if File.directory?(path)
|
37
|
+
%x(ls -la #{path})
|
38
|
+
else
|
39
|
+
File.read(File.join(Dir.pwd, path))
|
40
|
+
end
|
41
|
+
rescue StandardError => e
|
42
|
+
"Error reading file: #{e.message}".tap do |error_message|
|
43
|
+
Roast::Helpers::Logger.error(error_message + "\n")
|
44
|
+
Roast::Helpers::Logger.debug(e.backtrace.join("\n") + "\n") if ENV["DEBUG"]
|
45
|
+
end
|
46
|
+
end
|
47
|
+
end
|
48
|
+
end
|
49
|
+
end
|