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 ADDED
@@ -0,0 +1,7 @@
1
+ ---
2
+ SHA256:
3
+ metadata.gz: d777fd0eeb2312efcfcf966ec80867bcd8eeb0d468b6d71038a8d855cb331cb0
4
+ data.tar.gz: 9a1aac24a5e315eb90b721990d23e7c409193b09863d83d57f99f20e41b1b27d
5
+ SHA512:
6
+ metadata.gz: cddad336eb03ef1d9f577e0ecd494274124d612e7225188d77695a1b185c9d022912f6a4f0b967f86cca080e877a2b8dd360bf5c03a4fb4f5cab6bb250aced06
7
+ data.tar.gz: eb200c2862a0d37485f29cd070c92dd2cd132ed3e18ef1bd45683810a1c8c0cc520fddd46d84bb9b95c5ad78d9d42bf75ee3c82657737188d6444b62e568df3b
data/Gemfile ADDED
@@ -0,0 +1,10 @@
1
+ source "https://rubygems.org"
2
+
3
+ gemspec
4
+
5
+ group :development, :test do
6
+ gem "rspec", "~> 3.13"
7
+ gem "standard", "~> 1.40"
8
+ gem "minitest"
9
+ gem "simplecov", require: false
10
+ end
data/README.md ADDED
@@ -0,0 +1,155 @@
1
+ # TestGenAI
2
+
3
+ ✨ A CLI gem that adds tests for untested methods in your code ✨
4
+
5
+ ![Ruby Version](https://img.shields.io/badge/Ruby-3.4.5-green?logo=Ruby&logoColor=red&label=Ruby%20version&color=green)
6
+ [![License](https://img.shields.io/badge/license-MIT-green.svg)](https://github.com/grymoire7/testgenai/blob/main/LICENSE)
7
+
8
+ TestGenAI is a Ruby CLI gem for development environments that finds untested
9
+ methods in your codebase and generates tests for them using an AI service.
10
+
11
+ > [!CAUTION]
12
+ > TestGenAI is a work in progress and should not be used in production
13
+ > environments. While is it works locally for me, it has not been tested in a
14
+ > wide variety of projects yet and may have many edge cases or bugs. Use with
15
+ > caution and please report any issues you encounter.
16
+
17
+
18
+ See `docs/article.md` for the concepts this is based on. The article code is
19
+ illustrative only and has never been run; this repository is the working
20
+ implementation.
21
+
22
+ ## Installation
23
+
24
+ Add to your project's `Gemfile` in the development group, or install the gem directly:
25
+
26
+ ```bash
27
+ gem install testgenai
28
+ ```
29
+
30
+ ## Setup
31
+
32
+ TestGenAI needs an LLM provider to generate tests. Configure it via environment variables or CLI flags:
33
+
34
+ | Env var | Flag | Description |
35
+ | ---------------------- | ------------------------ | --------------------------------------------------- |
36
+ | `TESTGENAI_PROVIDER` | `--provider` | LLM provider (e.g. `anthropic`, `openai`) |
37
+ | `TESTGENAI_MODEL` | `--model` | Model name (e.g. `claude-opus-4-7`) |
38
+ | — | `--api-key` | API key (overrides env var set by the provider SDK) |
39
+ | `TESTGENAI_FRAMEWORK` | `--test-framework`, `-t` | Test framework: `rspec` (default) or `minitest` |
40
+ | `TESTGENAI_OUTPUT_DIR` | `--output-dir`, `-o` | Output directory for generated tests |
41
+ | `TESTGENAI_PAUSE` | `--pause`, `-p` | Seconds to pause between API calls (default: 1) |
42
+
43
+ ## Commands
44
+
45
+ ```
46
+ testgenai scan Scan for untested methods and report them
47
+ testgenai context Scan and show the LLM context that would be sent (diagnostic)
48
+ testgenai generate Full pipeline: scan → context → generate → validate
49
+ testgenai version Show version
50
+ testgenai help Show help
51
+ ```
52
+
53
+ ### scan
54
+
55
+ Finds untested methods and prints them. No API calls are made.
56
+
57
+ ```bash
58
+ bin/testgenai scan
59
+ bin/testgenai scan --test-framework minitest
60
+ ```
61
+
62
+ ### context
63
+
64
+ Like `scan`, but also prints the context snippet that would be sent to the LLM
65
+ for each method. Useful for inspecting what the generator will see before
66
+ running a full `generate`.
67
+
68
+ ### generate
69
+
70
+ Runs the full pipeline: scan → build context → call LLM → validate generated tests → report results.
71
+
72
+ ```bash
73
+ bin/testgenai generate --provider anthropic --model claude-opus-4-7
74
+ ```
75
+
76
+ Generated test files are written to `spec/` (rspec) or `test/` (minitest) by
77
+ default, mirroring the source file's path under `lib/` or `app/`.
78
+
79
+ ## How scanning works
80
+
81
+ TestGenAI uses one of two scanners depending on whether SimpleCov is available
82
+ in your project.
83
+
84
+ ### SimpleCov scanner (accurate)
85
+
86
+ If your `Gemfile` or gemspec contains `simplecov`, TestGenAI runs your test
87
+ suite with `COVERAGE=true` to generate `coverage/.resultset.json`, then uses
88
+ that data to find methods where every executable line has zero hits.
89
+
90
+ This scanner correctly handles **partially-tested files** — it only reports
91
+ methods that were never exercised, even if other methods in the same file are
92
+ tested.
93
+
94
+ To use the SimpleCov scanner, add SimpleCov to your project and configure it to
95
+ start when `COVERAGE=true` is set:
96
+
97
+ ```ruby
98
+ # Gemfile
99
+ gem "simplecov", require: false, group: [:development, :test]
100
+ ```
101
+
102
+ ```ruby
103
+ # spec/spec_helper.rb or test/test_helper.rb
104
+ if ENV["COVERAGE"]
105
+ require "simplecov"
106
+ SimpleCov.start
107
+ end
108
+ ```
109
+
110
+ If `coverage/.resultset.json` already exists from a previous run, TestGenAI
111
+ uses it directly without re-running the test suite.
112
+
113
+ ### File-existence scanner (fallback)
114
+
115
+ If SimpleCov is not detected, TestGenAI falls back to checking whether a
116
+ corresponding spec/test file exists for each source file. Any source file with
117
+ no matching test file has all its methods reported as untested.
118
+
119
+ This scanner **cannot detect untested methods in partially-tested files**. A
120
+ file tested only through integration tests or through specs for its subclasses
121
+ will appear fully untested even though its methods are exercised.
122
+
123
+ ## Troubleshooting
124
+
125
+ ### "SimpleCov not found in Gemfile or gemspec. Using file-existence scanner."
126
+
127
+ SimpleCov is not declared in your `Gemfile` or gemspec. The fallback scanner is
128
+ being used. See [Setup](#setup-simplecov) above to enable the accurate scanner.
129
+
130
+ ### The file-existence scanner reports many false positives
131
+
132
+ Base classes, modules, and shared utilities that are only tested indirectly
133
+ (through subclass specs or integration tests) will be reported as untested by
134
+ the file-existence scanner. This is expected — it can only see whether a
135
+ matching test file exists, not whether code was actually executed. Use the
136
+ SimpleCov scanner for accurate results.
137
+
138
+ ### "Coverage generation failed. Using file-existence scanner."
139
+
140
+ SimpleCov was found in the Gemfile, but running the test suite with `COVERAGE=true` did not produce `coverage/.resultset.json`. Check that:
141
+
142
+ 1. SimpleCov is configured to start when `COVERAGE=true` is set (see above)
143
+ 2. Your test suite runs successfully with `COVERAGE=true bundle exec rspec` (or equivalent for minitest)
144
+
145
+ ### Parser warning about ruby34
146
+
147
+ ```
148
+ warning: parser/current is loading parser/ruby34, which recognizes 3.4.0-dev-compliant syntax,
149
+ but you are running 3.4.5.
150
+ ```
151
+
152
+ This warning comes from the `parser` gem (a dependency). It is harmless — the
153
+ parser handles Ruby 3.4.x syntax correctly. A 3.4.x release of the parser gem
154
+ is not yet available to silence it.
155
+
data/bin/testgenai ADDED
@@ -0,0 +1,8 @@
1
+ #!/usr/bin/env ruby
2
+
3
+ $LOAD_PATH.unshift("#{File.dirname(__FILE__)}/../lib")
4
+
5
+ require 'bundler/setup'
6
+ require "testgenai"
7
+
8
+ Testgenai::CLI.start(ARGV)
@@ -0,0 +1,39 @@
1
+ module Testgenai
2
+ class BatchPipeline
3
+ def initialize(config, context_builder, pipeline, reporter, conventions: nil)
4
+ @config = config
5
+ @context_builder = context_builder
6
+ @pipeline = pipeline
7
+ @reporter = reporter
8
+ @conventions = conventions
9
+ end
10
+
11
+ def run(untested_methods)
12
+ results = {successful: [], failed: [], skipped: []}
13
+
14
+ untested_methods.each_with_index do |method_info, i|
15
+ sleep @config.pause if i > 0
16
+
17
+ context = @context_builder.build(method_info)
18
+ context = context.merge(conventions: @conventions) if @conventions
19
+ result = @pipeline.run(method_info, context)
20
+
21
+ if result[:success]
22
+ results[:successful] << result
23
+ @reporter.success(method_info, result)
24
+ else
25
+ results[:failed] << result
26
+ @reporter.failure(method_info, result)
27
+ end
28
+ rescue ConfigurationError => e
29
+ @reporter.fatal_error(e)
30
+ raise
31
+ rescue => e
32
+ results[:skipped] << {method_info: method_info, error: e.message}
33
+ @reporter.skipped(method_info, e)
34
+ end
35
+
36
+ results
37
+ end
38
+ end
39
+ end
@@ -0,0 +1,126 @@
1
+ require "thor"
2
+
3
+ module Testgenai
4
+ class CLI < Thor
5
+ package_name "testgenai"
6
+
7
+ class_option :output_dir, aliases: "-o", desc: "Output directory for generated tests"
8
+ class_option :test_framework, aliases: "-t", default: "rspec", desc: "Test framework: rspec or minitest"
9
+ class_option :provider, desc: "LLM provider (e.g. anthropic, openai)"
10
+ class_option :model, desc: "LLM model name"
11
+ class_option :api_key, desc: "API key (overrides env var)"
12
+ class_option :pause, aliases: "-p", type: :numeric, default: 1, desc: "Pause between API calls in seconds"
13
+
14
+ def self.exit_on_failure?
15
+ true
16
+ end
17
+
18
+ map "--version" => :version
19
+
20
+ desc "version", "Show version"
21
+ def version
22
+ puts Testgenai::VERSION
23
+ end
24
+
25
+ desc "scan", "Scan for untested code and report it"
26
+ def scan
27
+ config = build_config
28
+ scanner = build_scanner(config)
29
+ warn "Scanning source files..."
30
+ methods = scanner.scan
31
+ reporter.scan_results(methods, files_scanned: scanner.files_scanned)
32
+ end
33
+
34
+ desc "context", "Scan, build LLM context, and report it (diagnostic)"
35
+ def context
36
+ config = build_config
37
+ scanner = build_scanner(config)
38
+ methods = scanner.scan
39
+ ctx_builder = ContextBuilder.new
40
+ methods.each do |method_info|
41
+ ctx = ctx_builder.build(method_info)
42
+ reporter.context_result(method_info, ctx)
43
+ end
44
+ end
45
+
46
+ desc "generate", "Full pipeline: scan → context → generate → validate"
47
+ method_option :conventions, type: :boolean, default: false,
48
+ desc: "Synthesize project conventions from existing tests (one LLM call; result cached to spec/conventions.md — add to .gitignore)"
49
+ def generate
50
+ config = build_config
51
+ scanner = build_scanner(config)
52
+ methods = scanner.scan
53
+
54
+ if methods.empty?
55
+ reporter.scan_results(methods)
56
+ exit 0
57
+ end
58
+
59
+ conventions = options[:conventions] ? ConventionsSynthesizer.new(config).synthesize : nil
60
+
61
+ generator = config.generator_class.new(config)
62
+ validator = config.validator_class.new
63
+ single_pipeline = Pipeline.new(generator, validator)
64
+ ctx_builder = ContextBuilder.new
65
+ batch = BatchPipeline.new(config, ctx_builder, single_pipeline, reporter, conventions: conventions)
66
+
67
+ results = batch.run(methods)
68
+ reporter.summary(results)
69
+ exit(results[:successful].empty? ? 1 : 0)
70
+ rescue ConfigurationError => e
71
+ warn "Error: #{e.message}"
72
+ exit 2
73
+ end
74
+
75
+ private
76
+
77
+ def build_config
78
+ Configuration.new(
79
+ provider: options[:provider],
80
+ model: options[:model],
81
+ api_key: options[:api_key],
82
+ framework: options[:test_framework],
83
+ output_dir: options[:output_dir],
84
+ pause: options[:pause]
85
+ )
86
+ rescue ConfigurationError => e
87
+ warn "Configuration error: #{e.message}"
88
+ exit 2
89
+ end
90
+
91
+ def build_scanner(config)
92
+ resultset = File.join(Dir.pwd, "coverage", ".resultset.json")
93
+
94
+ if File.exist?(resultset)
95
+ warn "Using SimpleCov coverage data..."
96
+ return Scanner::SimplecovScanner.new
97
+ end
98
+
99
+ unless simplecov_in_gemfile?
100
+ warn "Warning: SimpleCov not found in Gemfile or gemspec. Using file-existence scanner."
101
+ warn "Note: file-existence scanner cannot detect untested methods in partially-tested files."
102
+ return Scanner::FileExistenceScanner.new
103
+ end
104
+
105
+ warn "Running test suite to generate coverage data..."
106
+ cmd = (config.framework == "minitest") ? "bundle exec ruby -Itest test/**/*_test.rb" : "bundle exec rspec"
107
+ system({"COVERAGE" => "true"}, cmd, out: File::NULL, err: File::NULL)
108
+
109
+ if File.exist?(resultset)
110
+ Scanner::SimplecovScanner.new
111
+ else
112
+ warn "Warning: Coverage generation failed. Using file-existence scanner."
113
+ Scanner::FileExistenceScanner.new
114
+ end
115
+ end
116
+
117
+ def simplecov_in_gemfile?
118
+ gemfiles = Dir.glob(File.join(Dir.pwd, "{Gemfile,*.gemspec}"))
119
+ gemfiles.any? { |f| File.read(f).include?("simplecov") }
120
+ end
121
+
122
+ def reporter
123
+ @reporter ||= Reporter.new
124
+ end
125
+ end
126
+ end
@@ -0,0 +1,22 @@
1
+ require_relative "parser_support"
2
+
3
+ module Testgenai
4
+ class CodeExtractor
5
+ def self.extract(response)
6
+ if (match = response.match(/```ruby\n(.*?)```/m))
7
+ match[1]
8
+ elsif (match = response.match(/```\w*\n(.*?)```/m))
9
+ match[1]
10
+ else
11
+ response
12
+ end
13
+ end
14
+
15
+ def self.valid_ruby?(code)
16
+ Testgenai::CurrentParser.parse(code)
17
+ true
18
+ rescue Parser::SyntaxError
19
+ false
20
+ end
21
+ end
22
+ end
@@ -0,0 +1,30 @@
1
+ module Testgenai
2
+ class Configuration
3
+ attr_reader :provider, :model, :api_key, :framework, :output_dir, :pause
4
+
5
+ def initialize(options = {})
6
+ @provider = options[:provider] || ENV["TESTGENAI_PROVIDER"]
7
+ @model = options[:model] || ENV["TESTGENAI_MODEL"]
8
+ @api_key = options[:api_key]
9
+ @framework = options[:framework] || ENV["TESTGENAI_FRAMEWORK"] || "rspec"
10
+ @output_dir = options[:output_dir] || ENV["TESTGENAI_OUTPUT_DIR"]
11
+ @pause = (options[:pause] || ENV["TESTGENAI_PAUSE"] || 1).to_f
12
+ end
13
+
14
+ def generator_class
15
+ case framework
16
+ when "rspec" then Generator::RspecGenerator
17
+ when "minitest" then Generator::MinitestGenerator
18
+ else raise ConfigurationError, "Unknown framework: #{framework}"
19
+ end
20
+ end
21
+
22
+ def validator_class
23
+ case framework
24
+ when "rspec" then Validator::RspecValidator
25
+ when "minitest" then Validator::MinitestValidator
26
+ else raise ConfigurationError, "Unknown framework: #{framework}"
27
+ end
28
+ end
29
+ end
30
+ end
@@ -0,0 +1,91 @@
1
+ require_relative "parser_support"
2
+
3
+ module Testgenai
4
+ class ContextBuilder
5
+ def initialize(root: Dir.pwd)
6
+ @root = root
7
+ end
8
+
9
+ def build(method_info)
10
+ {
11
+ target_file: File.read(method_info[:file]),
12
+ dependencies: extract_dependencies(method_info[:file]),
13
+ example_usage: find_usages(method_info[:method]),
14
+ related_tests: find_related_tests(method_info[:file])
15
+ }
16
+ rescue => e
17
+ raise Error, "Could not build context for #{method_info[:file]}: #{e.message}"
18
+ end
19
+
20
+ private
21
+
22
+ def extract_dependencies(file)
23
+ source = File.read(file)
24
+ ast = Testgenai::CurrentParser.parse(source)
25
+ return [] unless ast
26
+ find_requires(ast, File.dirname(file))
27
+ rescue Parser::SyntaxError
28
+ []
29
+ end
30
+
31
+ def find_requires(node, dir, results = [])
32
+ return results unless node.is_a?(Parser::AST::Node)
33
+
34
+ if node.type == :send && node.children[0].nil? && node.children[2]&.type == :str
35
+ path = node.children[2].children[0]
36
+ case node.children[1]
37
+ when :require_relative
38
+ resolved = File.expand_path("#{path}.rb", dir)
39
+ results << resolved if File.exist?(resolved)
40
+ when :require
41
+ resolved = File.join(@root, "lib", "#{path}.rb")
42
+ results << resolved if File.exist?(resolved)
43
+ end
44
+ end
45
+
46
+ node.children.each { |c| find_requires(c, dir, results) }
47
+ results
48
+ end
49
+
50
+ def find_usages(method_name)
51
+ name = method_name.to_s.sub(/\Aself\./, "")
52
+ pattern = /\.#{Regexp.escape(name)}[\s(]/
53
+ usages = []
54
+
55
+ source_files.each do |file|
56
+ lines = File.readlines(file)
57
+ lines.each_with_index do |line, i|
58
+ next unless line.match?(pattern)
59
+ start = [0, i - 2].max
60
+ finish = [lines.size - 1, i + 2].min
61
+ usages << lines[start..finish].join
62
+ break if usages.size >= 3
63
+ end
64
+ break if usages.size >= 3
65
+ end
66
+
67
+ usages
68
+ end
69
+
70
+ def find_related_tests(source_file)
71
+ rel = source_file.sub("#{@root}/", "")
72
+ base = rel.sub(/\A(?:lib|app)\//, "").sub(/\.rb\z/, "")
73
+
74
+ spec_path = File.join(@root, "spec", "#{base}_spec.rb")
75
+ test_path = File.join(@root, "test", "#{base}_test.rb")
76
+
77
+ if File.exist?(spec_path)
78
+ File.read(spec_path)
79
+ elsif File.exist?(test_path)
80
+ File.read(test_path)
81
+ end
82
+ end
83
+
84
+ def source_files
85
+ %w[lib app].flat_map do |dir|
86
+ full = File.join(@root, dir)
87
+ Dir.exist?(full) ? Dir.glob(File.join(full, "**", "*.rb")) : []
88
+ end
89
+ end
90
+ end
91
+ end
@@ -0,0 +1,115 @@
1
+ module Testgenai
2
+ class ConventionsExtractor
3
+ TEST_DIRS = %w[support controllers requests models jobs services].freeze
4
+
5
+ def initialize(root: Dir.pwd)
6
+ @root = root
7
+ end
8
+
9
+ def extract
10
+ file_pairs = test_files_with_paths
11
+ return nil if file_pairs.empty?
12
+
13
+ contents = file_pairs.map { |_, c| c }
14
+
15
+ facts = [
16
+ auth_pattern(contents),
17
+ unavailable_helpers,
18
+ factory_traits,
19
+ common_stubs(contents),
20
+ transactional_specs(file_pairs)
21
+ ].compact
22
+
23
+ facts.empty? ? nil : facts.join("\n")
24
+ end
25
+
26
+ private
27
+
28
+ def auth_pattern(contents)
29
+ auth_lines = contents.flat_map do |content|
30
+ content.scan(/^[^#\n]*(?:session\[|authenticate|login|sign_in)[^\n]+/).map(&:strip)
31
+ end
32
+ return nil if auth_lines.empty?
33
+
34
+ top, count = auth_lines.tally.max_by { |_, n| n }
35
+ return nil if count < 3
36
+
37
+ "Authentication setup: `#{top}` appears in #{count} test setup blocks."
38
+ end
39
+
40
+ def unavailable_helpers
41
+ return nil unless File.exist?(File.join(@root, "Gemfile"))
42
+
43
+ gemfile_content = gemfile_source
44
+ absent = []
45
+ unless gemfile_content.include?("rails-controller-testing")
46
+ absent << "`assigns` and `assert_template` (rails-controller-testing gem is not present -- use response body assertions instead)"
47
+ end
48
+ absent.empty? ? nil : "Unavailable helpers: #{absent.join("; ")}."
49
+ end
50
+
51
+ def factory_traits
52
+ factory_files = %w[spec/factories test/factories].flat_map do |dir|
53
+ Dir.glob("#{@root}/#{dir}/**/*.rb")
54
+ end
55
+ traits = factory_files.flat_map do |f|
56
+ File.read(f).scan(/trait\s+:(\w+)/).flatten
57
+ rescue
58
+ []
59
+ end
60
+ return nil if traits.empty?
61
+
62
+ "Factory traits available: #{traits.uniq.map { |t| ":#{t}" }.join(", ")}."
63
+ end
64
+
65
+ def common_stubs(contents)
66
+ stub_lines = contents.flat_map do |content|
67
+ content.scan(/allow\([^\n]+/)
68
+ end
69
+ frequent = stub_lines.tally.select { |_, n| n >= 3 }.keys.first(5)
70
+ return nil if frequent.empty?
71
+
72
+ "Commonly stubbed in tests:\n" + frequent.map { |l| " #{l}" }.join("\n")
73
+ end
74
+
75
+ def transactional_specs(file_pairs)
76
+ disabling = file_pairs.select do |_path, content|
77
+ content.include?("use_transactional_tests = false") ||
78
+ content.include?("use_transactional_fixtures = false")
79
+ end
80
+ return nil if disabling.empty?
81
+
82
+ names = disabling.map { |path, _| path.sub("#{@root}/", "") }.join(", ")
83
+
84
+ cleanup_lines = disabling.flat_map do |_path, content|
85
+ content.scan(/\b\w[\w:.]*\.(?:destroy_all|delete_all|truncate)\b/).map(&:strip)
86
+ end.uniq
87
+
88
+ result = "Transactional fixtures are disabled in: #{names}."
89
+ if cleanup_lines.any?
90
+ result += " Cleanup patterns: #{cleanup_lines.map { |l| "`#{l}`" }.join(", ")}."
91
+ else
92
+ result += " These tests clean up manually in after blocks."
93
+ end
94
+ result
95
+ end
96
+
97
+ def test_files_with_paths
98
+ TEST_DIRS.flat_map do |sub|
99
+ %w[spec test].flat_map do |dir|
100
+ Dir.glob("#{@root}/#{dir}/#{sub}/**/*.rb")
101
+ end
102
+ end.uniq.filter_map do |f|
103
+ [f, File.read(f)]
104
+ rescue
105
+ nil
106
+ end
107
+ end
108
+
109
+ def gemfile_source
110
+ File.read(File.join(@root, "Gemfile"))
111
+ rescue
112
+ ""
113
+ end
114
+ end
115
+ end
@@ -0,0 +1,76 @@
1
+ require "ruby_llm"
2
+ require "fileutils"
3
+
4
+ module Testgenai
5
+ class ConventionsSynthesizer
6
+ def initialize(config, root: Dir.pwd, cache_path: nil)
7
+ @config = config
8
+ @root = root
9
+ @cache_path = cache_path || default_cache_path
10
+ end
11
+
12
+ def synthesize
13
+ return File.read(@cache_path) if cache_valid?
14
+
15
+ facts = ConventionsExtractor.new(root: @root).extract
16
+ return nil if facts.nil?
17
+
18
+ conventions = call_llm(facts)
19
+ FileUtils.mkdir_p(File.dirname(@cache_path))
20
+ File.write(@cache_path, conventions)
21
+ conventions
22
+ end
23
+
24
+ private
25
+
26
+ def default_cache_path
27
+ test_dir = %w[spec test].find { |d| Dir.exist?(File.join(@root, d)) } || "spec"
28
+ File.join(@root, test_dir, "conventions.md")
29
+ end
30
+
31
+ def cache_valid?
32
+ return false unless File.exist?(@cache_path)
33
+
34
+ cache_mtime = File.mtime(@cache_path)
35
+ watched_files.none? { |f| File.mtime(f) > cache_mtime rescue false }
36
+ end
37
+
38
+ def watched_files
39
+ spec_files = Dir.glob("#{@root}/{spec,test}/**/*.rb")
40
+ gemfiles = Dir.glob("#{@root}/{Gemfile,Gemfile.lock,*.gemspec}")
41
+ spec_files + gemfiles
42
+ end
43
+
44
+ def call_llm(facts)
45
+ if @config.provider.nil? && @config.model.nil?
46
+ raise ConfigurationError,
47
+ "provider and model must be configured. " \
48
+ "Set TESTGENAI_PROVIDER and TESTGENAI_MODEL, or use --provider and --model flags."
49
+ end
50
+ raise ConfigurationError, "provider must be configured. Set TESTGENAI_PROVIDER or use --provider flag." if @config.provider.nil?
51
+ raise ConfigurationError, "model must be configured. Set TESTGENAI_MODEL or use --model flag." if @config.model.nil?
52
+
53
+ configure_llm
54
+ chat = RubyLLM.chat(model: @config.model)
55
+ chat.ask(synthesis_prompt(facts)).content
56
+ end
57
+
58
+ def configure_llm
59
+ return unless @config.api_key && @config.provider
60
+ RubyLLM.configure do |c|
61
+ c.public_send(:"#{@config.provider}_api_key=", @config.api_key)
62
+ end
63
+ end
64
+
65
+ def synthesis_prompt(facts)
66
+ <<~PROMPT
67
+ The following patterns were extracted mechanically from a Ruby test suite.
68
+
69
+ #{facts}
70
+
71
+ Write a concise conventions guide (under 300 words) explaining the rule behind each pattern, so a developer generating new tests knows what to do and why.
72
+ Return plain text, no code blocks.
73
+ PROMPT
74
+ end
75
+ end
76
+ end