testgenai 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/Gemfile +10 -0
- data/README.md +155 -0
- data/bin/testgenai +8 -0
- data/lib/testgenai/batch_pipeline.rb +39 -0
- data/lib/testgenai/cli.rb +126 -0
- data/lib/testgenai/code_extractor.rb +22 -0
- data/lib/testgenai/configuration.rb +30 -0
- data/lib/testgenai/context_builder.rb +91 -0
- data/lib/testgenai/conventions_extractor.rb +115 -0
- data/lib/testgenai/conventions_synthesizer.rb +76 -0
- data/lib/testgenai/generator/base.rb +70 -0
- data/lib/testgenai/generator/minitest_generator.rb +52 -0
- data/lib/testgenai/generator/rspec_generator.rb +52 -0
- data/lib/testgenai/parser_support.rb +9 -0
- data/lib/testgenai/pipeline.rb +57 -0
- data/lib/testgenai/reporter.rb +54 -0
- data/lib/testgenai/scanner/base.rb +52 -0
- data/lib/testgenai/scanner/file_existence_scanner.rb +45 -0
- data/lib/testgenai/scanner/simplecov_scanner.rb +69 -0
- data/lib/testgenai/validator/base.rb +22 -0
- data/lib/testgenai/validator/minitest_validator.rb +52 -0
- data/lib/testgenai/validator/rspec_validator.rb +60 -0
- data/lib/testgenai/version.rb +3 -0
- data/lib/testgenai.rb +24 -0
- data/testgenai.gemspec +22 -0
- metadata +121 -0
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
require "ruby_llm"
|
|
2
|
+
|
|
3
|
+
module Testgenai
|
|
4
|
+
module Generator
|
|
5
|
+
class Base
|
|
6
|
+
def initialize(config)
|
|
7
|
+
@config = config
|
|
8
|
+
configure_llm
|
|
9
|
+
end
|
|
10
|
+
|
|
11
|
+
def generate(method_info, context, feedback: nil)
|
|
12
|
+
prompt = build_prompt(method_info, context, feedback: feedback)
|
|
13
|
+
response = call_llm(prompt)
|
|
14
|
+
CodeExtractor.extract(response)
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
def output_path_for(method_info)
|
|
18
|
+
raise NotImplementedError, "#{self.class} must implement #output_path_for"
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
def injection_fallback_path_for(method_info)
|
|
22
|
+
primary = output_path_for(method_info)
|
|
23
|
+
method_part = method_info[:method].to_s.gsub(/\Aself\./, "").tr(".", "_")
|
|
24
|
+
primary.sub(/_(spec|test)\.rb\z/, "_#{method_part}_\\1.rb")
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
private
|
|
28
|
+
|
|
29
|
+
def configure_llm
|
|
30
|
+
return unless @config.api_key && @config.provider
|
|
31
|
+
RubyLLM.configure do |c|
|
|
32
|
+
c.public_send(:"#{@config.provider}_api_key=", @config.api_key)
|
|
33
|
+
end
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
def call_llm(prompt)
|
|
37
|
+
if @config.provider.nil? && @config.model.nil?
|
|
38
|
+
raise ConfigurationError,
|
|
39
|
+
"provider and model must be configured. " \
|
|
40
|
+
"Set TESTGENAI_PROVIDER and TESTGENAI_MODEL, or use --provider and --model flags."
|
|
41
|
+
end
|
|
42
|
+
if @config.provider.nil?
|
|
43
|
+
raise ConfigurationError,
|
|
44
|
+
"provider must be configured. Set TESTGENAI_PROVIDER or use --provider flag."
|
|
45
|
+
end
|
|
46
|
+
if @config.model.nil?
|
|
47
|
+
raise ConfigurationError,
|
|
48
|
+
"model must be configured. Set TESTGENAI_MODEL or use --model flag."
|
|
49
|
+
end
|
|
50
|
+
chat = RubyLLM.chat(model: @config.model)
|
|
51
|
+
chat.ask(prompt).content
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
def build_prompt(method_info, context, feedback: nil)
|
|
55
|
+
raise NotImplementedError, "#{self.class} must implement #build_prompt"
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
def conventions_preamble(context)
|
|
59
|
+
return "" unless context[:conventions]
|
|
60
|
+
"## Project conventions\n#{context[:conventions]}\n\nApply these conventions to all generated tests.\n\n"
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
def custom_output_path(method_info, suffix)
|
|
64
|
+
class_part = method_info[:class]&.downcase&.gsub("::", "/") || "unknown"
|
|
65
|
+
method_part = method_info[:method].to_s.gsub(/\Aself\./, "").tr(".", "_")
|
|
66
|
+
File.join(@config.output_dir, "#{class_part}_#{method_part}#{suffix}")
|
|
67
|
+
end
|
|
68
|
+
end
|
|
69
|
+
end
|
|
70
|
+
end
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
module Testgenai
|
|
2
|
+
module Generator
|
|
3
|
+
class MinitestGenerator < Base
|
|
4
|
+
def output_path_for(method_info)
|
|
5
|
+
return custom_output_path(method_info, "_test.rb") if @config.output_dir
|
|
6
|
+
|
|
7
|
+
rel = method_info[:file].sub("#{Dir.pwd}/", "")
|
|
8
|
+
base = rel.sub(/\A(?:lib|app)\//, "").sub(/\.rb\z/, "")
|
|
9
|
+
File.join(Dir.pwd, "test", "#{base}_test.rb")
|
|
10
|
+
end
|
|
11
|
+
|
|
12
|
+
private
|
|
13
|
+
|
|
14
|
+
def build_prompt(method_info, context, feedback: nil)
|
|
15
|
+
prompt = conventions_preamble(context) + <<~PROMPT
|
|
16
|
+
You are an expert Ruby developer. Write Minitest tests for the following method.
|
|
17
|
+
|
|
18
|
+
## Method to test
|
|
19
|
+
Class: #{method_info[:class]}
|
|
20
|
+
Method: #{method_info[:method]}
|
|
21
|
+
Location: #{method_info[:file]}:#{method_info[:start_line]}-#{method_info[:end_line]}
|
|
22
|
+
|
|
23
|
+
## Source file
|
|
24
|
+
```ruby
|
|
25
|
+
#{context[:target_file]}
|
|
26
|
+
```
|
|
27
|
+
PROMPT
|
|
28
|
+
|
|
29
|
+
unless context[:dependencies].to_a.empty?
|
|
30
|
+
prompt += "\n## Dependencies\n"
|
|
31
|
+
prompt += context[:dependencies].map { |d| "- #{d}" }.join("\n")
|
|
32
|
+
prompt += "\n"
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
context[:example_usage].to_a.each_with_index do |usage, i|
|
|
36
|
+
prompt += "\n## Example usage #{i + 1}\n```ruby\n#{usage}\n```\n"
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
if context[:related_tests]
|
|
40
|
+
prompt += "\n## Existing tests (match this style)\n```ruby\n#{context[:related_tests]}\n```\n"
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
if feedback
|
|
44
|
+
prompt += "\n## Previous attempt failed — fix these issues\n#{feedback}\n"
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
prompt + "\nWrite comprehensive Minitest tests using test/setup methods and Minitest assertions. " \
|
|
48
|
+
"Subclass Minitest::Test. Return ONLY the test code in a ```ruby code block."
|
|
49
|
+
end
|
|
50
|
+
end
|
|
51
|
+
end
|
|
52
|
+
end
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
module Testgenai
|
|
2
|
+
module Generator
|
|
3
|
+
class RspecGenerator < Base
|
|
4
|
+
def output_path_for(method_info)
|
|
5
|
+
return custom_output_path(method_info, "_spec.rb") if @config.output_dir
|
|
6
|
+
|
|
7
|
+
rel = method_info[:file].sub("#{Dir.pwd}/", "")
|
|
8
|
+
base = rel.sub(/\A(?:lib|app)\//, "").sub(/\.rb\z/, "")
|
|
9
|
+
File.join(Dir.pwd, "spec", "#{base}_spec.rb")
|
|
10
|
+
end
|
|
11
|
+
|
|
12
|
+
private
|
|
13
|
+
|
|
14
|
+
def build_prompt(method_info, context, feedback: nil)
|
|
15
|
+
prompt = conventions_preamble(context) + <<~PROMPT
|
|
16
|
+
You are an expert Ruby developer. Write RSpec tests for the following method.
|
|
17
|
+
|
|
18
|
+
## Method to test
|
|
19
|
+
Class: #{method_info[:class]}
|
|
20
|
+
Method: #{method_info[:method]}
|
|
21
|
+
Location: #{method_info[:file]}:#{method_info[:start_line]}-#{method_info[:end_line]}
|
|
22
|
+
|
|
23
|
+
## Source file
|
|
24
|
+
```ruby
|
|
25
|
+
#{context[:target_file]}
|
|
26
|
+
```
|
|
27
|
+
PROMPT
|
|
28
|
+
|
|
29
|
+
unless context[:dependencies].to_a.empty?
|
|
30
|
+
prompt += "\n## Dependencies\n"
|
|
31
|
+
prompt += context[:dependencies].map { |d| "- #{d}" }.join("\n")
|
|
32
|
+
prompt += "\n"
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
context[:example_usage].to_a.each_with_index do |usage, i|
|
|
36
|
+
prompt += "\n## Example usage #{i + 1}\n```ruby\n#{usage}\n```\n"
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
if context[:related_tests]
|
|
40
|
+
prompt += "\n## Existing tests (match this style)\n```ruby\n#{context[:related_tests]}\n```\n"
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
if feedback
|
|
44
|
+
prompt += "\n## Previous attempt failed — fix these issues\n#{feedback}\n"
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
prompt + "\nWrite comprehensive RSpec tests using describe/context/let/before blocks. " \
|
|
48
|
+
"Return ONLY the test code in a ```ruby code block."
|
|
49
|
+
end
|
|
50
|
+
end
|
|
51
|
+
end
|
|
52
|
+
end
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
require "fileutils"
|
|
2
|
+
|
|
3
|
+
module Testgenai
|
|
4
|
+
class Pipeline
|
|
5
|
+
MAX_ATTEMPTS = 3
|
|
6
|
+
|
|
7
|
+
def initialize(generator, validator)
|
|
8
|
+
@generator = generator
|
|
9
|
+
@validator = validator
|
|
10
|
+
end
|
|
11
|
+
|
|
12
|
+
def run(method_info, context)
|
|
13
|
+
output_path = @generator.output_path_for(method_info)
|
|
14
|
+
original_content = File.exist?(output_path) ? File.read(output_path) : nil
|
|
15
|
+
feedback = nil
|
|
16
|
+
last_generated = nil
|
|
17
|
+
|
|
18
|
+
MAX_ATTEMPTS.times do |i|
|
|
19
|
+
last_generated = @generator.generate(method_info, context, feedback: feedback)
|
|
20
|
+
combined = original_content ? "#{original_content}\n#{last_generated}" : last_generated
|
|
21
|
+
result = @validator.validate(combined, output_path)
|
|
22
|
+
|
|
23
|
+
if result[:runs] && result[:passes]
|
|
24
|
+
return {success: true, output_path: output_path, attempts: i + 1, errors: []}
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
feedback = build_feedback(result)
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
fallback_path = cleanup_after_failure(output_path, original_content, last_generated, method_info)
|
|
31
|
+
{success: false, output_path: output_path, fallback_path: fallback_path, attempts: MAX_ATTEMPTS, errors: [feedback]}
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
private
|
|
35
|
+
|
|
36
|
+
def cleanup_after_failure(output_path, original_content, last_generated, method_info)
|
|
37
|
+
if original_content
|
|
38
|
+
File.write(output_path, original_content)
|
|
39
|
+
fallback_path = @generator.injection_fallback_path_for(method_info)
|
|
40
|
+
FileUtils.mkdir_p(File.dirname(fallback_path))
|
|
41
|
+
File.write(fallback_path, last_generated)
|
|
42
|
+
fallback_path
|
|
43
|
+
else
|
|
44
|
+
File.delete(output_path) if File.exist?(output_path)
|
|
45
|
+
nil
|
|
46
|
+
end
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
def build_feedback(result)
|
|
50
|
+
if result[:runs]
|
|
51
|
+
"The following tests failed: #{result[:errors].join(", ")}"
|
|
52
|
+
else
|
|
53
|
+
"The following errors prevented the tests from running: #{result[:errors].join(", ")}"
|
|
54
|
+
end
|
|
55
|
+
end
|
|
56
|
+
end
|
|
57
|
+
end
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
module Testgenai
|
|
2
|
+
class Reporter
|
|
3
|
+
def scan_results(methods, files_scanned: nil)
|
|
4
|
+
if methods.empty?
|
|
5
|
+
if files_scanned
|
|
6
|
+
puts "Scanned #{files_scanned} source file(s) — no untested methods found."
|
|
7
|
+
else
|
|
8
|
+
puts "No untested methods found."
|
|
9
|
+
end
|
|
10
|
+
return
|
|
11
|
+
end
|
|
12
|
+
puts "Found #{methods.size} untested method(s):"
|
|
13
|
+
methods.each do |m|
|
|
14
|
+
puts " #{m[:class]}##{m[:method]} #{m[:file]}:#{m[:start_line]}-#{m[:end_line]}"
|
|
15
|
+
end
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
def context_result(method_info, context)
|
|
19
|
+
puts "=== Context for #{method_info[:class]}##{method_info[:method]} ==="
|
|
20
|
+
puts " File: #{method_info[:file]}"
|
|
21
|
+
unless context[:dependencies].to_a.empty?
|
|
22
|
+
puts " Dependencies:"
|
|
23
|
+
context[:dependencies].each { |d| puts " #{d}" }
|
|
24
|
+
end
|
|
25
|
+
unless context[:example_usage].to_a.empty?
|
|
26
|
+
puts " Example usages found: #{context[:example_usage].size}"
|
|
27
|
+
end
|
|
28
|
+
puts " Related tests: #{context[:related_tests] ? "yes" : "none"}"
|
|
29
|
+
puts
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
def success(method_info, result)
|
|
33
|
+
puts " ✓ #{method_info[:class]}##{method_info[:method]} → #{result[:output_path]} (#{result[:attempts]} attempt(s))"
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
def failure(method_info, result)
|
|
37
|
+
puts " ✗ #{method_info[:class]}##{method_info[:method]} failed after #{result[:attempts]} attempt(s)"
|
|
38
|
+
puts " → Generated tests saved to #{result[:fallback_path]} for manual review" if result[:fallback_path]
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
def skipped(method_info, error)
|
|
42
|
+
puts " - #{method_info[:class]}##{method_info[:method]} skipped: #{error.message}"
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
def summary(results)
|
|
46
|
+
puts "\nSummary: #{results[:successful].size} generated, " \
|
|
47
|
+
"#{results[:failed].size} failed, #{results[:skipped].size} skipped"
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
def fatal_error(error)
|
|
51
|
+
puts "Fatal error: #{error.message}"
|
|
52
|
+
end
|
|
53
|
+
end
|
|
54
|
+
end
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
require_relative "../parser_support"
|
|
2
|
+
|
|
3
|
+
module Testgenai
|
|
4
|
+
module Scanner
|
|
5
|
+
class Base
|
|
6
|
+
attr_reader :files_scanned
|
|
7
|
+
|
|
8
|
+
def scan
|
|
9
|
+
raise NotImplementedError, "#{self.class} must implement #scan"
|
|
10
|
+
end
|
|
11
|
+
|
|
12
|
+
private
|
|
13
|
+
|
|
14
|
+
def collect_methods(node, file, class_name:)
|
|
15
|
+
return [] unless node.is_a?(Parser::AST::Node)
|
|
16
|
+
|
|
17
|
+
case node.type
|
|
18
|
+
when :class, :module
|
|
19
|
+
current = [class_name, const_name(node.children[0])].compact.join("::")
|
|
20
|
+
node.children.flat_map { |c| collect_methods(c, file, class_name: current) }
|
|
21
|
+
when :def
|
|
22
|
+
[method_descriptor(node, file, class_name, node.children[0].to_s)]
|
|
23
|
+
when :defs
|
|
24
|
+
[method_descriptor(node, file, class_name, "self.#{node.children[1]}")]
|
|
25
|
+
else
|
|
26
|
+
node.children.flat_map { |c| collect_methods(c, file, class_name: class_name) }
|
|
27
|
+
end
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
def method_descriptor(node, file, class_name, method_name)
|
|
31
|
+
{
|
|
32
|
+
file: file,
|
|
33
|
+
class: class_name,
|
|
34
|
+
method: method_name,
|
|
35
|
+
start_line: node.loc.line,
|
|
36
|
+
end_line: node.loc.end.line
|
|
37
|
+
}
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
def const_name(node)
|
|
41
|
+
return nil unless node.is_a?(Parser::AST::Node) && node.type == :const
|
|
42
|
+
parts = []
|
|
43
|
+
current = node
|
|
44
|
+
while current.is_a?(Parser::AST::Node) && current.type == :const
|
|
45
|
+
parts.unshift(current.children[1].to_s)
|
|
46
|
+
current = current.children[0]
|
|
47
|
+
end
|
|
48
|
+
parts.join("::")
|
|
49
|
+
end
|
|
50
|
+
end
|
|
51
|
+
end
|
|
52
|
+
end
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
module Testgenai
|
|
2
|
+
module Scanner
|
|
3
|
+
class FileExistenceScanner < Base
|
|
4
|
+
def initialize(root: Dir.pwd)
|
|
5
|
+
@root = root
|
|
6
|
+
end
|
|
7
|
+
|
|
8
|
+
def scan
|
|
9
|
+
files = source_files
|
|
10
|
+
@files_scanned = files.size
|
|
11
|
+
files.reject { |f| test_exists?(f) }.flat_map { |f| extract_methods(f) }
|
|
12
|
+
end
|
|
13
|
+
|
|
14
|
+
private
|
|
15
|
+
|
|
16
|
+
def source_files
|
|
17
|
+
%w[lib app].flat_map do |dir|
|
|
18
|
+
full = File.join(@root, dir)
|
|
19
|
+
Dir.exist?(full) ? Dir.glob(File.join(full, "**", "*.rb")) : []
|
|
20
|
+
end
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
def test_exists?(source_file)
|
|
24
|
+
rel = relative(source_file)
|
|
25
|
+
base = rel.sub(/\A(?:lib|app)\//, "").sub(/\.rb\z/, "")
|
|
26
|
+
File.exist?(File.join(@root, "spec", "#{base}_spec.rb")) ||
|
|
27
|
+
File.exist?(File.join(@root, "test", "#{base}_test.rb"))
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
def relative(file)
|
|
31
|
+
file.sub("#{@root}/", "")
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
def extract_methods(file)
|
|
35
|
+
source = File.read(file)
|
|
36
|
+
ast = CurrentParser.parse(source)
|
|
37
|
+
return [] unless ast
|
|
38
|
+
collect_methods(ast, file, class_name: nil)
|
|
39
|
+
rescue Parser::SyntaxError, EncodingError => e
|
|
40
|
+
warn "Warning: could not parse #{file}: #{e.message}"
|
|
41
|
+
[]
|
|
42
|
+
end
|
|
43
|
+
end
|
|
44
|
+
end
|
|
45
|
+
end
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
require "json"
|
|
2
|
+
|
|
3
|
+
module Testgenai
|
|
4
|
+
module Scanner
|
|
5
|
+
class SimplecovScanner < Base
|
|
6
|
+
RESULTSET_PATH = "coverage/.resultset.json"
|
|
7
|
+
|
|
8
|
+
def initialize(root: Dir.pwd)
|
|
9
|
+
@root = root
|
|
10
|
+
end
|
|
11
|
+
|
|
12
|
+
def scan
|
|
13
|
+
resultset_path = File.join(@root, RESULTSET_PATH)
|
|
14
|
+
unless File.exist?(resultset_path)
|
|
15
|
+
warn "Warning: #{RESULTSET_PATH} not found"
|
|
16
|
+
return []
|
|
17
|
+
end
|
|
18
|
+
coverage = merged_coverage
|
|
19
|
+
source_coverage = coverage.reject { |file, _| test_file?(file) || !File.exist?(file) }
|
|
20
|
+
@files_scanned = source_coverage.size
|
|
21
|
+
source_coverage.flat_map { |file, lines| extract_untested_methods(file, lines) }
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
private
|
|
25
|
+
|
|
26
|
+
def merged_coverage
|
|
27
|
+
resultset = JSON.parse(File.read(File.join(@root, RESULTSET_PATH)))
|
|
28
|
+
result = {}
|
|
29
|
+
resultset.each_value do |runner_data|
|
|
30
|
+
(runner_data["coverage"] || {}).each do |file, data|
|
|
31
|
+
lines = data["lines"]
|
|
32
|
+
result[file] = merge_lines(result[file], lines)
|
|
33
|
+
end
|
|
34
|
+
end
|
|
35
|
+
result
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
def merge_lines(existing, new_lines)
|
|
39
|
+
return new_lines unless existing
|
|
40
|
+
existing.zip(new_lines).map do |a, b|
|
|
41
|
+
next nil if a.nil? && b.nil?
|
|
42
|
+
(a || 0) + (b || 0)
|
|
43
|
+
end
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
def test_file?(file)
|
|
47
|
+
relative = file.sub("#{@root}/", "")
|
|
48
|
+
relative.start_with?("spec/", "test/")
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
def extract_untested_methods(file, coverage_lines)
|
|
52
|
+
source = File.read(file)
|
|
53
|
+
ast = CurrentParser.parse(source)
|
|
54
|
+
return [] unless ast
|
|
55
|
+
methods = collect_methods(ast, file, class_name: nil)
|
|
56
|
+
methods.select { |m| untested?(coverage_lines, m[:start_line], m[:end_line]) }
|
|
57
|
+
rescue Parser::SyntaxError, EncodingError => e
|
|
58
|
+
warn "Warning: could not parse #{file}: #{e.message}"
|
|
59
|
+
[]
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
def untested?(coverage_lines, start_line, end_line)
|
|
63
|
+
method_lines = coverage_lines[(start_line - 1)..(end_line - 1)] || []
|
|
64
|
+
executable = method_lines.compact
|
|
65
|
+
executable.any? && executable.all?(&:zero?)
|
|
66
|
+
end
|
|
67
|
+
end
|
|
68
|
+
end
|
|
69
|
+
end
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
require "fileutils"
|
|
2
|
+
|
|
3
|
+
module Testgenai
|
|
4
|
+
module Validator
|
|
5
|
+
class Base
|
|
6
|
+
def validate(test_code, output_path)
|
|
7
|
+
raise NotImplementedError, "#{self.class} must implement #validate"
|
|
8
|
+
end
|
|
9
|
+
|
|
10
|
+
private
|
|
11
|
+
|
|
12
|
+
def write_test_file(test_code, output_path)
|
|
13
|
+
FileUtils.mkdir_p(File.dirname(output_path))
|
|
14
|
+
File.write(output_path, test_code)
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
def cleanup(output_path)
|
|
18
|
+
File.delete(output_path) if File.exist?(output_path)
|
|
19
|
+
end
|
|
20
|
+
end
|
|
21
|
+
end
|
|
22
|
+
end
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
require "shellwords"
|
|
2
|
+
|
|
3
|
+
module Testgenai
|
|
4
|
+
module Validator
|
|
5
|
+
class MinitestValidator < Base
|
|
6
|
+
def validate(test_code, output_path)
|
|
7
|
+
write_test_file(test_code, output_path)
|
|
8
|
+
output, exit_status = run_minitest(output_path)
|
|
9
|
+
parse_result(output, exit_status, output_path)
|
|
10
|
+
end
|
|
11
|
+
|
|
12
|
+
private
|
|
13
|
+
|
|
14
|
+
def run_minitest(path)
|
|
15
|
+
output = `bundle exec ruby -Ilib -Itest #{Shellwords.escape(path)} 2>&1`
|
|
16
|
+
[output, $?.exitstatus]
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
def parse_result(output, exit_status, output_path)
|
|
20
|
+
if load_error?(output)
|
|
21
|
+
errors = extract_load_errors(output)
|
|
22
|
+
cleanup(output_path)
|
|
23
|
+
{valid: false, runs: false, passes: false, errors: errors}
|
|
24
|
+
elsif exit_status == 0
|
|
25
|
+
{valid: true, runs: true, passes: true, errors: []}
|
|
26
|
+
else
|
|
27
|
+
errors = extract_failures(output)
|
|
28
|
+
{valid: true, runs: true, passes: false, errors: errors}
|
|
29
|
+
end
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
def load_error?(output)
|
|
33
|
+
output.match?(/LoadError|SyntaxError|cannot load such file/)
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
def extract_load_errors(output)
|
|
37
|
+
output.lines
|
|
38
|
+
.select { |l| l.match?(/LoadError|SyntaxError|cannot load/) }
|
|
39
|
+
.map(&:strip)
|
|
40
|
+
.first(3)
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
def extract_failures(output)
|
|
44
|
+
output.lines
|
|
45
|
+
.select { |l| l.match?(/Failure:|Error:|\d+\) /) }
|
|
46
|
+
.map(&:strip)
|
|
47
|
+
.reject(&:empty?)
|
|
48
|
+
.first(5)
|
|
49
|
+
end
|
|
50
|
+
end
|
|
51
|
+
end
|
|
52
|
+
end
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
require "shellwords"
|
|
2
|
+
|
|
3
|
+
module Testgenai
|
|
4
|
+
module Validator
|
|
5
|
+
class RspecValidator < Base
|
|
6
|
+
def validate(test_code, output_path)
|
|
7
|
+
write_test_file(test_code, output_path)
|
|
8
|
+
output, exit_status = run_rspec(output_path)
|
|
9
|
+
parse_result(output, exit_status, output_path)
|
|
10
|
+
end
|
|
11
|
+
|
|
12
|
+
private
|
|
13
|
+
|
|
14
|
+
def run_rspec(path)
|
|
15
|
+
output = `bundle exec rspec #{Shellwords.escape(path)} --format documentation 2>&1`
|
|
16
|
+
[output, $?.exitstatus]
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
def parse_result(output, exit_status, output_path)
|
|
20
|
+
if load_error?(output)
|
|
21
|
+
errors = extract_load_errors(output)
|
|
22
|
+
cleanup(output_path)
|
|
23
|
+
{valid: false, runs: false, passes: false, errors: errors}
|
|
24
|
+
elsif exit_status == 0
|
|
25
|
+
{valid: true, runs: true, passes: true, errors: []}
|
|
26
|
+
else
|
|
27
|
+
errors = extract_failures(output)
|
|
28
|
+
{valid: true, runs: true, passes: false, errors: errors}
|
|
29
|
+
end
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
def load_error?(output)
|
|
33
|
+
output.match?(/LoadError|SyntaxError|NameError.*uninitialized constant|An error occurred while loading/)
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
def extract_load_errors(output)
|
|
37
|
+
lines = output.lines
|
|
38
|
+
lines.select { |l| l.match?(/LoadError|SyntaxError|NameError|cannot load/) }
|
|
39
|
+
.map(&:strip)
|
|
40
|
+
.first(3)
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
def extract_failures(output)
|
|
44
|
+
failures = []
|
|
45
|
+
in_failure = false
|
|
46
|
+
output.each_line do |line|
|
|
47
|
+
if line.match?(/^\s+\d+\)/)
|
|
48
|
+
in_failure = true
|
|
49
|
+
failures << line.strip
|
|
50
|
+
elsif in_failure && line.match?(/^\s+(Failure|Error):/)
|
|
51
|
+
failures.last << " #{line.strip}"
|
|
52
|
+
elsif in_failure && line.strip.empty?
|
|
53
|
+
in_failure = false
|
|
54
|
+
end
|
|
55
|
+
end
|
|
56
|
+
failures.first(5)
|
|
57
|
+
end
|
|
58
|
+
end
|
|
59
|
+
end
|
|
60
|
+
end
|
data/lib/testgenai.rb
ADDED
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
require "testgenai/version"
|
|
2
|
+
require "testgenai/configuration"
|
|
3
|
+
require "testgenai/code_extractor"
|
|
4
|
+
require "testgenai/context_builder"
|
|
5
|
+
require "testgenai/reporter"
|
|
6
|
+
require "testgenai/pipeline"
|
|
7
|
+
require "testgenai/batch_pipeline"
|
|
8
|
+
require "testgenai/scanner/base"
|
|
9
|
+
require "testgenai/scanner/simplecov_scanner"
|
|
10
|
+
require "testgenai/scanner/file_existence_scanner"
|
|
11
|
+
require "testgenai/generator/base"
|
|
12
|
+
require "testgenai/generator/rspec_generator"
|
|
13
|
+
require "testgenai/generator/minitest_generator"
|
|
14
|
+
require "testgenai/validator/base"
|
|
15
|
+
require "testgenai/validator/rspec_validator"
|
|
16
|
+
require "testgenai/validator/minitest_validator"
|
|
17
|
+
require "testgenai/conventions_extractor"
|
|
18
|
+
require "testgenai/conventions_synthesizer"
|
|
19
|
+
require "testgenai/cli"
|
|
20
|
+
|
|
21
|
+
module Testgenai
|
|
22
|
+
class Error < StandardError; end
|
|
23
|
+
class ConfigurationError < Error; end
|
|
24
|
+
end
|
data/testgenai.gemspec
ADDED
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
require_relative "lib/testgenai/version"
|
|
2
|
+
|
|
3
|
+
Gem::Specification.new do |spec|
|
|
4
|
+
spec.name = "testgenai"
|
|
5
|
+
spec.version = Testgenai::VERSION
|
|
6
|
+
spec.authors = ["Tracy Atteberry"]
|
|
7
|
+
spec.email = ["tracy@magicbydesign.com"]
|
|
8
|
+
spec.summary = "Find untested Ruby code and generate tests with AI"
|
|
9
|
+
spec.homepage = "https://github.com/grymoire7/testgenai"
|
|
10
|
+
spec.license = "MIT"
|
|
11
|
+
spec.required_ruby_version = ">= 3.1.0"
|
|
12
|
+
|
|
13
|
+
spec.files = Dir["lib/**/*.rb", "bin/**/*", "*.md", "*.gemspec", "Gemfile"].reject { |f| f.match?(/\A(CLAUDE|AGENTS)\.md\z/) }
|
|
14
|
+
spec.bindir = "bin"
|
|
15
|
+
spec.executables = ["testgenai"]
|
|
16
|
+
spec.require_paths = ["lib"]
|
|
17
|
+
|
|
18
|
+
spec.add_dependency "thor", "~> 1.3"
|
|
19
|
+
spec.add_dependency "ruby_llm", "~> 1.14"
|
|
20
|
+
spec.add_dependency "parser", "~> 3.3"
|
|
21
|
+
spec.add_dependency "prism", ">= 1.4.0"
|
|
22
|
+
end
|