spec_ai 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +7 -0
- data/CHANGELOG.md +13 -0
- data/LICENSE.txt +21 -0
- data/README.md +212 -0
- data/exe/spec-ai +9 -0
- data/lib/generators/spec_ai/install/install_generator.rb +16 -0
- data/lib/generators/spec_ai/install/templates/spec_ai.yml +23 -0
- data/lib/spec_ai/analyzers/existing_specs.rb +29 -0
- data/lib/spec_ai/analyzers/factories.rb +47 -0
- data/lib/spec_ai/analyzers/model.rb +169 -0
- data/lib/spec_ai/analyzers/routes.rb +23 -0
- data/lib/spec_ai/analyzers/schema.rb +53 -0
- data/lib/spec_ai/cli.rb +124 -0
- data/lib/spec_ai/configuration.rb +104 -0
- data/lib/spec_ai/context/builder.rb +44 -0
- data/lib/spec_ai/context/snapshot.rb +108 -0
- data/lib/spec_ai/crews/generate_crew.rb +152 -0
- data/lib/spec_ai/crews/repair_crew.rb +97 -0
- data/lib/spec_ai/crews/silence.rb +21 -0
- data/lib/spec_ai/crews/toolset.rb +19 -0
- data/lib/spec_ai/generators/factory_generator.rb +78 -0
- data/lib/spec_ai/generators/rspec_generator.rb +301 -0
- data/lib/spec_ai/generators/rspec_install.rb +71 -0
- data/lib/spec_ai/inflector.rb +63 -0
- data/lib/spec_ai/knowledge/playbook.rb +11 -0
- data/lib/spec_ai/knowledge/rails_testing.md +42 -0
- data/lib/spec_ai/pipeline/fix.rb +77 -0
- data/lib/spec_ai/pipeline/generate.rb +116 -0
- data/lib/spec_ai/pipeline/result.rb +21 -0
- data/lib/spec_ai/railtie.rb +11 -0
- data/lib/spec_ai/repair/prune.rb +49 -0
- data/lib/spec_ai/runners/report.rb +144 -0
- data/lib/spec_ai/runners/rspec_runner.rb +99 -0
- data/lib/spec_ai/sandbox.rb +63 -0
- data/lib/spec_ai/setup/ensure.rb +159 -0
- data/lib/spec_ai/snapshot_plan.rb +166 -0
- data/lib/spec_ai/target.rb +52 -0
- data/lib/spec_ai/test_plan.rb +342 -0
- data/lib/spec_ai/tools/list_related_files.rb +33 -0
- data/lib/spec_ai/tools/read_app_file.rb +24 -0
- data/lib/spec_ai/tools/read_existing_spec.rb +28 -0
- data/lib/spec_ai/tools/read_factory.rb +28 -0
- data/lib/spec_ai/tools/read_schema_table.rb +26 -0
- data/lib/spec_ai/ui.rb +469 -0
- data/lib/spec_ai/version.rb +5 -0
- data/lib/spec_ai.rb +57 -0
- data/lib/tasks/spec_ai.rake +19 -0
- metadata +185 -0
data/lib/spec_ai/cli.rb
ADDED
|
@@ -0,0 +1,124 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "thor"
|
|
4
|
+
|
|
5
|
+
module SpecAi
|
|
6
|
+
class CLI < Thor
|
|
7
|
+
class_option :root, type: :string, desc: "Rails application root"
|
|
8
|
+
class_option :provider, type: :string, desc: "LLM provider (openai, anthropic, google, azure, ollama)"
|
|
9
|
+
class_option :model, type: :string, desc: "LLM model name"
|
|
10
|
+
class_option :force, type: :boolean, default: false, aliases: "-f", desc: "Replace an existing spec (default for generate)"
|
|
11
|
+
class_option :merge, type: :boolean, default: false, desc: "Add examples to an existing spec instead of replacing it"
|
|
12
|
+
class_option :no_run, type: :boolean, default: false, desc: "Skip running RSpec after generation"
|
|
13
|
+
class_option :no_fix, type: :boolean, default: false, desc: "Skip the AI repair loop"
|
|
14
|
+
class_option :verbose, type: :boolean, aliases: "-v", desc: "Show RCrewAI and HTTP logs"
|
|
15
|
+
|
|
16
|
+
def self.exit_on_failure?
|
|
17
|
+
true
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
desc "generate TARGET", "Analyze a Rails model and generate RSpec tests"
|
|
21
|
+
def generate(target)
|
|
22
|
+
configure_from_options!
|
|
23
|
+
result = Pipeline::Generate.new(target, force: replace_spec?, ui: ui).run
|
|
24
|
+
exit(1) unless result.success?
|
|
25
|
+
rescue Interrupt
|
|
26
|
+
ui.error("Interrupted.")
|
|
27
|
+
exit(130)
|
|
28
|
+
rescue SpecAi::Error => error
|
|
29
|
+
ui.error(error.message)
|
|
30
|
+
exit(1)
|
|
31
|
+
rescue StandardError => error
|
|
32
|
+
ui.unexpected_error(error)
|
|
33
|
+
exit(1)
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
desc "fix PATH", "Run a spec and repair failures with AI"
|
|
37
|
+
def fix(path)
|
|
38
|
+
configure_from_options!
|
|
39
|
+
result = Pipeline::Fix.new(path, ui: ui).run
|
|
40
|
+
exit(1) unless result.success?
|
|
41
|
+
rescue Interrupt
|
|
42
|
+
ui.error("Interrupted.")
|
|
43
|
+
exit(130)
|
|
44
|
+
rescue SpecAi::Error => error
|
|
45
|
+
ui.error(error.message)
|
|
46
|
+
exit(1)
|
|
47
|
+
rescue StandardError => error
|
|
48
|
+
ui.unexpected_error(error)
|
|
49
|
+
exit(1)
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
desc "analyze TARGET", "Print collected Rails context without calling an LLM"
|
|
53
|
+
def analyze(target)
|
|
54
|
+
configure_from_options!
|
|
55
|
+
resolved = Target.new(target)
|
|
56
|
+
snapshot = Context::Builder.new(resolved).build
|
|
57
|
+
ui.header
|
|
58
|
+
ui.analyzing(resolved.class_name)
|
|
59
|
+
ui.context_collected(snapshot)
|
|
60
|
+
ui.snapshot(snapshot)
|
|
61
|
+
rescue Interrupt
|
|
62
|
+
ui.error("Interrupted.")
|
|
63
|
+
exit(130)
|
|
64
|
+
rescue SpecAi::Error => error
|
|
65
|
+
ui.error(error.message)
|
|
66
|
+
exit(1)
|
|
67
|
+
rescue StandardError => error
|
|
68
|
+
ui.unexpected_error(error)
|
|
69
|
+
exit(1)
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
desc "missing", "Find important behavior that is not tested (coming in 0.3)"
|
|
73
|
+
def missing
|
|
74
|
+
ui.roadmap("missing", "0.3")
|
|
75
|
+
end
|
|
76
|
+
|
|
77
|
+
desc "improve PATH", "Improve an existing spec (coming in 0.5)"
|
|
78
|
+
def improve(_path = nil)
|
|
79
|
+
ui.roadmap("improve", "0.5")
|
|
80
|
+
end
|
|
81
|
+
|
|
82
|
+
map "g" => :generate
|
|
83
|
+
|
|
84
|
+
no_commands do
|
|
85
|
+
def ui
|
|
86
|
+
@ui ||= UI.new
|
|
87
|
+
end
|
|
88
|
+
end
|
|
89
|
+
|
|
90
|
+
private
|
|
91
|
+
|
|
92
|
+
def configure_from_options!
|
|
93
|
+
root = File.expand_path(options[:root] || detect_root)
|
|
94
|
+
SpecAi.reset_configuration!
|
|
95
|
+
SpecAi.configuration.app_root = root
|
|
96
|
+
config_file = File.join(root, "config", "spec_ai.yml")
|
|
97
|
+
SpecAi.configuration.load_from_file!(config_file) if File.file?(config_file)
|
|
98
|
+
SpecAi.configuration.provider = options[:provider] if options[:provider]
|
|
99
|
+
SpecAi.configuration.model = options[:model] if options[:model]
|
|
100
|
+
SpecAi.configuration.verbose = true if options[:verbose]
|
|
101
|
+
SpecAi.configuration.run_after_generation = false if options[:no_run]
|
|
102
|
+
SpecAi.configuration.auto_fix = false if options[:no_fix]
|
|
103
|
+
SpecAi.boot_rails!(root)
|
|
104
|
+
end
|
|
105
|
+
|
|
106
|
+
def replace_spec?
|
|
107
|
+
return true if options[:force]
|
|
108
|
+
return false if options[:merge]
|
|
109
|
+
|
|
110
|
+
true
|
|
111
|
+
end
|
|
112
|
+
|
|
113
|
+
def detect_root
|
|
114
|
+
dir = Dir.pwd
|
|
115
|
+
while dir != File.dirname(dir)
|
|
116
|
+
return dir if File.file?(File.join(dir, "config", "environment.rb")) ||
|
|
117
|
+
File.file?(File.join(dir, "config", "spec_ai.yml"))
|
|
118
|
+
|
|
119
|
+
dir = File.dirname(dir)
|
|
120
|
+
end
|
|
121
|
+
Dir.pwd
|
|
122
|
+
end
|
|
123
|
+
end
|
|
124
|
+
end
|
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module SpecAi
|
|
4
|
+
class Configuration
|
|
5
|
+
ATTRS = %i[
|
|
6
|
+
provider
|
|
7
|
+
model
|
|
8
|
+
temperature
|
|
9
|
+
app_root
|
|
10
|
+
testing_framework
|
|
11
|
+
run_after_generation
|
|
12
|
+
auto_fix
|
|
13
|
+
max_repair_attempts
|
|
14
|
+
include_edge_cases
|
|
15
|
+
include_security_cases
|
|
16
|
+
include_authorization
|
|
17
|
+
verbose
|
|
18
|
+
].freeze
|
|
19
|
+
|
|
20
|
+
attr_accessor(*ATTRS)
|
|
21
|
+
|
|
22
|
+
def initialize
|
|
23
|
+
@provider = "openai"
|
|
24
|
+
@model = "gpt-4o"
|
|
25
|
+
@temperature = 0.1
|
|
26
|
+
@app_root = Dir.pwd
|
|
27
|
+
@testing_framework = "rspec"
|
|
28
|
+
@run_after_generation = true
|
|
29
|
+
@auto_fix = true
|
|
30
|
+
@max_repair_attempts = 2
|
|
31
|
+
@include_edge_cases = true
|
|
32
|
+
@include_security_cases = true
|
|
33
|
+
@include_authorization = true
|
|
34
|
+
@verbose = false
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
def load_from_file!(path)
|
|
38
|
+
data = YAML.safe_load_file(path, permitted_classes: [Symbol]) || {}
|
|
39
|
+
apply_hash!(data)
|
|
40
|
+
self
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
def apply_to_rcrewai!
|
|
44
|
+
require "rcrewai"
|
|
45
|
+
|
|
46
|
+
ollama = provider.to_s == "ollama"
|
|
47
|
+
RCrewAI.configure(validate: !ollama) do |config|
|
|
48
|
+
config.llm_provider = provider.to_sym
|
|
49
|
+
assign_rcrewai(config, :model, model)
|
|
50
|
+
assign_rcrewai(config, :temperature, temperature)
|
|
51
|
+
assign_rcrewai(config, "#{provider}_model", model)
|
|
52
|
+
assign_rcrewai(config, :openai_api_key, ENV["OPENAI_API_KEY"])
|
|
53
|
+
assign_rcrewai(config, :anthropic_api_key, ENV["ANTHROPIC_API_KEY"])
|
|
54
|
+
assign_rcrewai(config, :google_api_key, ENV["GOOGLE_API_KEY"] || ENV["GEMINI_API_KEY"])
|
|
55
|
+
assign_rcrewai(config, :azure_api_key, ENV["AZURE_OPENAI_API_KEY"])
|
|
56
|
+
assign_rcrewai(config, :base_url, ENV["AZURE_OPENAI_ENDPOINT"]) if provider.to_s == "azure"
|
|
57
|
+
if ollama
|
|
58
|
+
assign_rcrewai(config, :base_url, ENV["OLLAMA_URL"] || "http://localhost:11434")
|
|
59
|
+
assign_rcrewai(config, :api_key, ENV["OLLAMA_API_KEY"] || "ollama")
|
|
60
|
+
end
|
|
61
|
+
end
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
private
|
|
65
|
+
|
|
66
|
+
def apply_hash!(data)
|
|
67
|
+
data = stringify_keys(data)
|
|
68
|
+
|
|
69
|
+
self.provider = data["provider"] if data["provider"]
|
|
70
|
+
self.model = data["model"] if data["model"]
|
|
71
|
+
self.temperature = data["temperature"] if data.key?("temperature")
|
|
72
|
+
self.verbose = data["verbose"] unless data["verbose"].nil?
|
|
73
|
+
|
|
74
|
+
if (testing = data["testing"]).is_a?(Hash)
|
|
75
|
+
self.testing_framework = testing["framework"] if testing["framework"]
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
if (generation = data["generation"]).is_a?(Hash)
|
|
79
|
+
self.include_edge_cases = generation["include_edge_cases"] unless generation["include_edge_cases"].nil?
|
|
80
|
+
self.include_security_cases = generation["include_security_cases"] unless generation["include_security_cases"].nil?
|
|
81
|
+
self.include_authorization = generation["include_authorization"] unless generation["include_authorization"].nil?
|
|
82
|
+
end
|
|
83
|
+
|
|
84
|
+
return unless (execution = data["execution"]).is_a?(Hash)
|
|
85
|
+
|
|
86
|
+
self.run_after_generation = execution["run_after_generation"] unless execution["run_after_generation"].nil?
|
|
87
|
+
self.auto_fix = execution["auto_fix"] unless execution["auto_fix"].nil?
|
|
88
|
+
self.max_repair_attempts = execution["max_repair_attempts"] if execution["max_repair_attempts"]
|
|
89
|
+
end
|
|
90
|
+
|
|
91
|
+
def stringify_keys(hash)
|
|
92
|
+
hash.each_with_object({}) do |(key, value), memo|
|
|
93
|
+
memo[key.to_s] = value.is_a?(Hash) ? stringify_keys(value) : value
|
|
94
|
+
end
|
|
95
|
+
end
|
|
96
|
+
|
|
97
|
+
def assign_rcrewai(config, setter, value)
|
|
98
|
+
return if value.nil? || value.to_s.empty?
|
|
99
|
+
return unless config.respond_to?("#{setter}=")
|
|
100
|
+
|
|
101
|
+
config.public_send("#{setter}=", value)
|
|
102
|
+
end
|
|
103
|
+
end
|
|
104
|
+
end
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module SpecAi
|
|
4
|
+
module Context
|
|
5
|
+
class Builder
|
|
6
|
+
MAX_RELATED = 3
|
|
7
|
+
MAX_EXCERPT = 1200
|
|
8
|
+
|
|
9
|
+
def initialize(target, sandbox: Sandbox.new)
|
|
10
|
+
@target = target.is_a?(Target) ? target : Target.new(target)
|
|
11
|
+
@sandbox = sandbox
|
|
12
|
+
end
|
|
13
|
+
|
|
14
|
+
def build
|
|
15
|
+
model = Analyzers::Model.new(@target, sandbox: @sandbox).analyze
|
|
16
|
+
Snapshot.new(
|
|
17
|
+
target: @target,
|
|
18
|
+
model: model,
|
|
19
|
+
schema: Analyzers::Schema.new(@target, sandbox: @sandbox).analyze,
|
|
20
|
+
factories: Analyzers::Factories.new(@target, sandbox: @sandbox).analyze,
|
|
21
|
+
existing_spec: Analyzers::ExistingSpecs.new(@target, sandbox: @sandbox).analyze,
|
|
22
|
+
routes: Analyzers::Routes.new(@target, sandbox: @sandbox).analyze,
|
|
23
|
+
related: related_models(model)
|
|
24
|
+
)
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
private
|
|
28
|
+
|
|
29
|
+
def related_models(model)
|
|
30
|
+
Array(model[:associations]).first(MAX_RELATED).filter_map do |association|
|
|
31
|
+
class_name = association[:class_name].to_s
|
|
32
|
+
next if class_name.empty? || class_name == @target.class_name
|
|
33
|
+
|
|
34
|
+
path = Inflector.model_path(class_name)
|
|
35
|
+
next unless @sandbox.exist?(path)
|
|
36
|
+
|
|
37
|
+
excerpt = @sandbox.read(path)
|
|
38
|
+
excerpt = "#{excerpt[0, MAX_EXCERPT]}\n..." if excerpt.length > MAX_EXCERPT
|
|
39
|
+
{ class_name: class_name, path: path, excerpt: excerpt }
|
|
40
|
+
end
|
|
41
|
+
end
|
|
42
|
+
end
|
|
43
|
+
end
|
|
44
|
+
end
|
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module SpecAi
|
|
4
|
+
module Context
|
|
5
|
+
class Snapshot
|
|
6
|
+
attr_reader :target, :model, :schema, :factories, :existing_spec, :routes, :related
|
|
7
|
+
|
|
8
|
+
def initialize(target:, model:, schema:, factories:, existing_spec:, routes:, related: [])
|
|
9
|
+
@target = target
|
|
10
|
+
@model = model
|
|
11
|
+
@schema = schema
|
|
12
|
+
@factories = factories
|
|
13
|
+
@existing_spec = existing_spec
|
|
14
|
+
@routes = routes
|
|
15
|
+
@related = related
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
def has_factory?
|
|
19
|
+
factories[:present]
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
def devise?
|
|
23
|
+
model[:source].to_s.match?(/\bdevise\b/) ||
|
|
24
|
+
Array(schema[:columns]).map(&:to_s).include?("encrypted_password")
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
def known_attributes
|
|
28
|
+
skip = %w[id created_at updated_at encrypted_password reset_password_token reset_password_sent_at remember_created_at]
|
|
29
|
+
columns = Array(schema[:columns]).map(&:to_s) - skip
|
|
30
|
+
columns.reject! { |column| column.end_with?("_id") }
|
|
31
|
+
from_validations = Array(model[:validations]).flat_map { |item| Array(item[:attributes] || item["attributes"]) }.map(&:to_s)
|
|
32
|
+
virtual = model[:source].to_s.match?(/\bdevise\b/) ? %w[password password_confirmation] : []
|
|
33
|
+
(columns + from_validations + virtual).uniq
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
def association_names
|
|
37
|
+
Array(model[:associations]).map { |item| (item[:name] || item["name"]).to_s }.reject(&:empty?)
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
def validators_for(attribute)
|
|
41
|
+
name = attribute.to_s
|
|
42
|
+
return [] if name.empty?
|
|
43
|
+
|
|
44
|
+
Array(model[:validations]).each_with_object([]) do |item, memo|
|
|
45
|
+
attrs = Array(item[:attributes] || item["attributes"]).map(&:to_s)
|
|
46
|
+
next unless attrs.include?(name)
|
|
47
|
+
|
|
48
|
+
memo.concat(Array(item[:validators] || item["validators"]).map(&:to_s))
|
|
49
|
+
end.uniq
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
def existing_example_names
|
|
53
|
+
Array(existing_spec[:example_names])
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
def to_h
|
|
57
|
+
{
|
|
58
|
+
target: target.class_name,
|
|
59
|
+
model: model,
|
|
60
|
+
schema: schema,
|
|
61
|
+
factories: factories,
|
|
62
|
+
existing_spec: existing_spec,
|
|
63
|
+
routes: routes,
|
|
64
|
+
related: related
|
|
65
|
+
}
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
def to_prompt
|
|
69
|
+
sections = []
|
|
70
|
+
sections << "Target: #{target.class_name} (#{target.model_path})"
|
|
71
|
+
sections << "Table: #{model[:table_name]}"
|
|
72
|
+
sections << "Source:\n#{model[:source]}"
|
|
73
|
+
sections << "Associations:\n#{format_list(model[:associations])}"
|
|
74
|
+
sections << "Validations:\n#{format_list(model[:validations])}"
|
|
75
|
+
sections << "Scopes: #{Array(model[:scopes]).join(', ')}"
|
|
76
|
+
sections << "Enums: #{Array(model[:enums]).join(', ')}"
|
|
77
|
+
sections << "Schema (#{schema[:path]}):\n#{schema[:excerpt]}" if schema[:excerpt]
|
|
78
|
+
if factories[:present]
|
|
79
|
+
sections << "Factory (#{factories[:path]}, :#{factories[:name]}):\n#{factories[:excerpt]}"
|
|
80
|
+
else
|
|
81
|
+
sections << "Factory: none found"
|
|
82
|
+
end
|
|
83
|
+
if existing_spec[:present]
|
|
84
|
+
sections << "Existing spec (#{existing_spec[:path]}):\n#{existing_spec[:excerpt]}"
|
|
85
|
+
else
|
|
86
|
+
sections << "Existing spec: none (will create #{target.spec_path})"
|
|
87
|
+
end
|
|
88
|
+
sections << "Routes:\n#{routes[:excerpt]}" if routes[:excerpt]
|
|
89
|
+
related.each do |item|
|
|
90
|
+
sections << "Related #{item[:class_name]} (#{item[:path]}):\n#{item[:excerpt]}"
|
|
91
|
+
end
|
|
92
|
+
sections.join("\n\n")
|
|
93
|
+
end
|
|
94
|
+
|
|
95
|
+
def to_s
|
|
96
|
+
to_prompt
|
|
97
|
+
end
|
|
98
|
+
|
|
99
|
+
private
|
|
100
|
+
|
|
101
|
+
def format_list(items)
|
|
102
|
+
return "(none)" if items.nil? || items.empty?
|
|
103
|
+
|
|
104
|
+
items.map { |item| "- #{item.inspect}" }.join("\n")
|
|
105
|
+
end
|
|
106
|
+
end
|
|
107
|
+
end
|
|
108
|
+
end
|
|
@@ -0,0 +1,152 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "rcrewai"
|
|
4
|
+
|
|
5
|
+
module SpecAi
|
|
6
|
+
module Crews
|
|
7
|
+
class GenerateCrew
|
|
8
|
+
attr_reader :fallback_notice, :fallback_preview
|
|
9
|
+
|
|
10
|
+
def initialize(snapshot, sandbox: Sandbox.new)
|
|
11
|
+
@snapshot = snapshot
|
|
12
|
+
@sandbox = sandbox
|
|
13
|
+
end
|
|
14
|
+
|
|
15
|
+
def plan
|
|
16
|
+
Silence.call { llm_plan }
|
|
17
|
+
rescue InvalidPlan => error
|
|
18
|
+
fallback!(error)
|
|
19
|
+
rescue StandardError => error
|
|
20
|
+
raise unless recoverable?(error)
|
|
21
|
+
|
|
22
|
+
fallback!(error)
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
def fallback?
|
|
26
|
+
!@fallback_notice.nil?
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
private
|
|
30
|
+
|
|
31
|
+
def llm_plan
|
|
32
|
+
SpecAi.configuration.apply_to_rcrewai!
|
|
33
|
+
crew = RCrewAI::Crew.new("spec_ai_generate", verbose: SpecAi.configuration.verbose)
|
|
34
|
+
analyst = build_analyst
|
|
35
|
+
architect = build_architect
|
|
36
|
+
crew.add_agent(analyst)
|
|
37
|
+
crew.add_agent(architect)
|
|
38
|
+
|
|
39
|
+
analyze_task = build_analyze_task(analyst)
|
|
40
|
+
plan_task = build_plan_task(architect, analyze_task)
|
|
41
|
+
crew.add_task(analyze_task)
|
|
42
|
+
crew.add_task(plan_task)
|
|
43
|
+
crew.execute
|
|
44
|
+
TestPlan.from_task(plan_task)
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
def build_analyst
|
|
48
|
+
RCrewAI::Agent.new(
|
|
49
|
+
name: "rails_analyst",
|
|
50
|
+
role: "Senior Rails Application Analyst",
|
|
51
|
+
goal: "Collect and summarize Rails model context needed to write accurate tests",
|
|
52
|
+
backstory: "You know Rails conventions, ActiveRecord, and how schema, factories, and specs fit together.",
|
|
53
|
+
tools: Toolset.build(@sandbox),
|
|
54
|
+
verbose: SpecAi.configuration.verbose,
|
|
55
|
+
max_iterations: 4
|
|
56
|
+
)
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
def build_architect
|
|
60
|
+
RCrewAI::Agent.new(
|
|
61
|
+
name: "test_architect",
|
|
62
|
+
role: "Rails Test Architect",
|
|
63
|
+
goal: "Produce a complete structured RSpec test plan for the target model",
|
|
64
|
+
backstory: "You design focused model specs. You never invent columns or associations that are not in the context.",
|
|
65
|
+
verbose: SpecAi.configuration.verbose,
|
|
66
|
+
max_iterations: 3
|
|
67
|
+
)
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
def build_analyze_task(agent)
|
|
71
|
+
RCrewAI::Task.new(
|
|
72
|
+
name: "analyze_model",
|
|
73
|
+
agent: agent,
|
|
74
|
+
description: <<~DESC,
|
|
75
|
+
Analyze #{@snapshot.target.class_name} for test generation.
|
|
76
|
+
|
|
77
|
+
Pre-collected context:
|
|
78
|
+
#{@snapshot.to_prompt}
|
|
79
|
+
|
|
80
|
+
Use tools only if you need a missing related file. Summarize:
|
|
81
|
+
1. Validations and attributes
|
|
82
|
+
2. Associations and dependent behavior
|
|
83
|
+
3. Scopes / enums
|
|
84
|
+
4. Factory coverage
|
|
85
|
+
5. Gaps in the existing spec
|
|
86
|
+
DESC
|
|
87
|
+
expected_output: "A concise analysis of behaviors that should be tested, including gaps in the existing spec."
|
|
88
|
+
)
|
|
89
|
+
end
|
|
90
|
+
|
|
91
|
+
def build_plan_task(agent, analyze_task)
|
|
92
|
+
extras = []
|
|
93
|
+
extras << "Include edge cases." if SpecAi.configuration.include_edge_cases
|
|
94
|
+
extras << "Include security-relevant cases when the model has tokens, passwords, or roles." if SpecAi.configuration.include_security_cases
|
|
95
|
+
extras << "Include authorization-adjacent cases when admin/role flags exist." if SpecAi.configuration.include_authorization
|
|
96
|
+
|
|
97
|
+
RCrewAI::Task.new(
|
|
98
|
+
name: "plan_tests",
|
|
99
|
+
agent: agent,
|
|
100
|
+
context: [analyze_task],
|
|
101
|
+
output_schema: TestPlan::SCHEMA,
|
|
102
|
+
guardrail: method(:plan_guardrail),
|
|
103
|
+
guardrail_max_retries: 2,
|
|
104
|
+
description: <<~DESC,
|
|
105
|
+
Create a structured test plan for #{@snapshot.target.class_name}.
|
|
106
|
+
|
|
107
|
+
Rails testing playbook:
|
|
108
|
+
#{Knowledge::Playbook.text}
|
|
109
|
+
|
|
110
|
+
#{extras.join("\n")}
|
|
111
|
+
|
|
112
|
+
Return JSON only. Start with { and end with }. No markdown, no prose, no tool traces.
|
|
113
|
+
Each scenario needs name, category, priority, expected_behavior.
|
|
114
|
+
Set attribute/value for validations and association/association_macro for associations.
|
|
115
|
+
Do not duplicate these existing examples: #{@snapshot.existing_example_names.join(', ')}
|
|
116
|
+
DESC
|
|
117
|
+
expected_output: "JSON only: an object with target, test_type, and scenarios. No other text."
|
|
118
|
+
)
|
|
119
|
+
end
|
|
120
|
+
|
|
121
|
+
def plan_guardrail(output)
|
|
122
|
+
plan = TestPlan.from(output)
|
|
123
|
+
[true, plan.to_h]
|
|
124
|
+
rescue InvalidPlan => error
|
|
125
|
+
[false, error.message]
|
|
126
|
+
end
|
|
127
|
+
|
|
128
|
+
def fallback!(error)
|
|
129
|
+
plan = SnapshotPlan.build(@snapshot)
|
|
130
|
+
raise rewrite_error(error) unless plan
|
|
131
|
+
|
|
132
|
+
cfg = SpecAi.configuration
|
|
133
|
+
@fallback_notice = "#{cfg.provider} / #{cfg.model} did not return a valid test plan"
|
|
134
|
+
@fallback_preview = TestPlan.preview(error.message)
|
|
135
|
+
plan
|
|
136
|
+
end
|
|
137
|
+
|
|
138
|
+
def rewrite_error(error)
|
|
139
|
+
return error if error.is_a?(InvalidPlan) && error.message.include?("did not return a valid test plan")
|
|
140
|
+
|
|
141
|
+
InvalidPlan.new(TestPlan.unreadable(error.message))
|
|
142
|
+
end
|
|
143
|
+
|
|
144
|
+
def recoverable?(error)
|
|
145
|
+
return true if error.is_a?(Errno::ECONNREFUSED) || error.is_a?(SocketError)
|
|
146
|
+
|
|
147
|
+
name = error.class.name.to_s
|
|
148
|
+
name.match?(/RCrewAI|Faraday|Timeout|HTTP|OpenAI|Anthropic/i)
|
|
149
|
+
end
|
|
150
|
+
end
|
|
151
|
+
end
|
|
152
|
+
end
|
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "rcrewai"
|
|
4
|
+
|
|
5
|
+
module SpecAi
|
|
6
|
+
module Crews
|
|
7
|
+
class RepairCrew
|
|
8
|
+
def initialize(snapshot, run_result, spec_contents, previous_plan: nil, sandbox: Sandbox.new)
|
|
9
|
+
@snapshot = snapshot
|
|
10
|
+
@run_result = run_result
|
|
11
|
+
@spec_contents = spec_contents
|
|
12
|
+
@previous_plan = previous_plan
|
|
13
|
+
@sandbox = sandbox
|
|
14
|
+
end
|
|
15
|
+
|
|
16
|
+
def plan
|
|
17
|
+
Silence.call { plan! }
|
|
18
|
+
rescue InvalidPlan
|
|
19
|
+
raise
|
|
20
|
+
rescue StandardError => error
|
|
21
|
+
raise unless recoverable?(error)
|
|
22
|
+
|
|
23
|
+
raise InvalidPlan, TestPlan.unreadable(error.message)
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
private
|
|
27
|
+
|
|
28
|
+
def plan!
|
|
29
|
+
SpecAi.configuration.apply_to_rcrewai!
|
|
30
|
+
crew = RCrewAI::Crew.new("spec_ai_repair", verbose: SpecAi.configuration.verbose)
|
|
31
|
+
debugger_agent = build_debugger
|
|
32
|
+
crew.add_agent(debugger_agent)
|
|
33
|
+
repair_task = build_repair_task(debugger_agent)
|
|
34
|
+
crew.add_task(repair_task)
|
|
35
|
+
crew.execute
|
|
36
|
+
TestPlan.from_task(repair_task)
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
def build_debugger
|
|
40
|
+
RCrewAI::Agent.new(
|
|
41
|
+
name: "qa_debugger",
|
|
42
|
+
role: "RSpec Failure Debugger",
|
|
43
|
+
goal: "Fix a structured test plan so generated model specs pass without weakening assertions",
|
|
44
|
+
backstory: "You read RSpec failures, compare them to Rails source, and correct setup, attributes, and expectations. You never drop coverage just to make a spec pass.",
|
|
45
|
+
tools: Toolset.build(@sandbox),
|
|
46
|
+
verbose: SpecAi.configuration.verbose,
|
|
47
|
+
max_iterations: 4
|
|
48
|
+
)
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
def build_repair_task(agent)
|
|
52
|
+
previous = @previous_plan ? JSON.pretty_generate(@previous_plan.to_h) : "(none)"
|
|
53
|
+
|
|
54
|
+
RCrewAI::Task.new(
|
|
55
|
+
name: "repair_plan",
|
|
56
|
+
agent: agent,
|
|
57
|
+
output_schema: TestPlan::SCHEMA,
|
|
58
|
+
guardrail: method(:plan_guardrail),
|
|
59
|
+
guardrail_max_retries: 2,
|
|
60
|
+
description: <<~DESC,
|
|
61
|
+
The generated test failed. Return a corrected structured test plan for #{@snapshot.target.class_name}.
|
|
62
|
+
Do not return Ruby. Keep useful scenarios; fix setup, attributes, factories, and expectations.
|
|
63
|
+
|
|
64
|
+
Rails testing playbook:
|
|
65
|
+
#{Knowledge::Playbook.text}
|
|
66
|
+
|
|
67
|
+
Application context:
|
|
68
|
+
#{@snapshot.to_prompt}
|
|
69
|
+
|
|
70
|
+
Previous plan:
|
|
71
|
+
#{previous}
|
|
72
|
+
|
|
73
|
+
Current spec (#{@snapshot.target.spec_path}):
|
|
74
|
+
#{@spec_contents}
|
|
75
|
+
|
|
76
|
+
RSpec output:
|
|
77
|
+
#{@run_result.output}
|
|
78
|
+
DESC
|
|
79
|
+
expected_output: "A corrected JSON test plan matching the schema."
|
|
80
|
+
)
|
|
81
|
+
end
|
|
82
|
+
|
|
83
|
+
def plan_guardrail(output)
|
|
84
|
+
plan = TestPlan.from(output)
|
|
85
|
+
[true, plan.to_h]
|
|
86
|
+
rescue InvalidPlan => error
|
|
87
|
+
[false, error.message]
|
|
88
|
+
end
|
|
89
|
+
|
|
90
|
+
def recoverable?(error)
|
|
91
|
+
return true if error.is_a?(Errno::ECONNREFUSED) || error.is_a?(SocketError)
|
|
92
|
+
|
|
93
|
+
error.class.name.to_s.match?(/RCrewAI|Faraday|Timeout|HTTP|OpenAI|Anthropic/i)
|
|
94
|
+
end
|
|
95
|
+
end
|
|
96
|
+
end
|
|
97
|
+
end
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "stringio"
|
|
4
|
+
|
|
5
|
+
module SpecAi
|
|
6
|
+
module Crews
|
|
7
|
+
module Silence
|
|
8
|
+
module_function
|
|
9
|
+
|
|
10
|
+
def call
|
|
11
|
+
return yield if SpecAi.configuration.verbose
|
|
12
|
+
|
|
13
|
+
original = $stdout
|
|
14
|
+
$stdout = StringIO.new
|
|
15
|
+
yield
|
|
16
|
+
ensure
|
|
17
|
+
$stdout = original if original
|
|
18
|
+
end
|
|
19
|
+
end
|
|
20
|
+
end
|
|
21
|
+
end
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "rcrewai"
|
|
4
|
+
|
|
5
|
+
module SpecAi
|
|
6
|
+
module Crews
|
|
7
|
+
module Toolset
|
|
8
|
+
def self.build(sandbox = Sandbox.new)
|
|
9
|
+
[
|
|
10
|
+
Tools::ReadAppFile.new(sandbox: sandbox),
|
|
11
|
+
Tools::ReadSchemaTable.new(sandbox: sandbox),
|
|
12
|
+
Tools::ReadFactory.new(sandbox: sandbox),
|
|
13
|
+
Tools::ReadExistingSpec.new(sandbox: sandbox),
|
|
14
|
+
Tools::ListRelatedFiles.new(sandbox: sandbox)
|
|
15
|
+
]
|
|
16
|
+
end
|
|
17
|
+
end
|
|
18
|
+
end
|
|
19
|
+
end
|