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
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
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
|
+

|
|
6
|
+
[](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,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
|