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.
@@ -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,9 @@
1
+ module Testgenai
2
+ if Gem::Version.new(RUBY_VERSION) >= Gem::Version.new("3.4")
3
+ require "prism"
4
+ CurrentParser = Prism::Translation::ParserCurrent
5
+ else
6
+ require "parser/current"
7
+ CurrentParser = Parser::CurrentRuby
8
+ end
9
+ 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
@@ -0,0 +1,3 @@
1
+ module Testgenai
2
+ VERSION = "0.1.0"
3
+ 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