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.
Files changed (48) hide show
  1. checksums.yaml +7 -0
  2. data/CHANGELOG.md +13 -0
  3. data/LICENSE.txt +21 -0
  4. data/README.md +212 -0
  5. data/exe/spec-ai +9 -0
  6. data/lib/generators/spec_ai/install/install_generator.rb +16 -0
  7. data/lib/generators/spec_ai/install/templates/spec_ai.yml +23 -0
  8. data/lib/spec_ai/analyzers/existing_specs.rb +29 -0
  9. data/lib/spec_ai/analyzers/factories.rb +47 -0
  10. data/lib/spec_ai/analyzers/model.rb +169 -0
  11. data/lib/spec_ai/analyzers/routes.rb +23 -0
  12. data/lib/spec_ai/analyzers/schema.rb +53 -0
  13. data/lib/spec_ai/cli.rb +124 -0
  14. data/lib/spec_ai/configuration.rb +104 -0
  15. data/lib/spec_ai/context/builder.rb +44 -0
  16. data/lib/spec_ai/context/snapshot.rb +108 -0
  17. data/lib/spec_ai/crews/generate_crew.rb +152 -0
  18. data/lib/spec_ai/crews/repair_crew.rb +97 -0
  19. data/lib/spec_ai/crews/silence.rb +21 -0
  20. data/lib/spec_ai/crews/toolset.rb +19 -0
  21. data/lib/spec_ai/generators/factory_generator.rb +78 -0
  22. data/lib/spec_ai/generators/rspec_generator.rb +301 -0
  23. data/lib/spec_ai/generators/rspec_install.rb +71 -0
  24. data/lib/spec_ai/inflector.rb +63 -0
  25. data/lib/spec_ai/knowledge/playbook.rb +11 -0
  26. data/lib/spec_ai/knowledge/rails_testing.md +42 -0
  27. data/lib/spec_ai/pipeline/fix.rb +77 -0
  28. data/lib/spec_ai/pipeline/generate.rb +116 -0
  29. data/lib/spec_ai/pipeline/result.rb +21 -0
  30. data/lib/spec_ai/railtie.rb +11 -0
  31. data/lib/spec_ai/repair/prune.rb +49 -0
  32. data/lib/spec_ai/runners/report.rb +144 -0
  33. data/lib/spec_ai/runners/rspec_runner.rb +99 -0
  34. data/lib/spec_ai/sandbox.rb +63 -0
  35. data/lib/spec_ai/setup/ensure.rb +159 -0
  36. data/lib/spec_ai/snapshot_plan.rb +166 -0
  37. data/lib/spec_ai/target.rb +52 -0
  38. data/lib/spec_ai/test_plan.rb +342 -0
  39. data/lib/spec_ai/tools/list_related_files.rb +33 -0
  40. data/lib/spec_ai/tools/read_app_file.rb +24 -0
  41. data/lib/spec_ai/tools/read_existing_spec.rb +28 -0
  42. data/lib/spec_ai/tools/read_factory.rb +28 -0
  43. data/lib/spec_ai/tools/read_schema_table.rb +26 -0
  44. data/lib/spec_ai/ui.rb +469 -0
  45. data/lib/spec_ai/version.rb +5 -0
  46. data/lib/spec_ai.rb +57 -0
  47. data/lib/tasks/spec_ai.rake +19 -0
  48. metadata +185 -0
@@ -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