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,116 @@
1
+ # frozen_string_literal: true
2
+
3
+ module SpecAi
4
+ module Pipeline
5
+ class Generate
6
+ def initialize(input, force: false, ui: UI.new, sandbox: Sandbox.new)
7
+ @input = input
8
+ @force = force
9
+ @ui = ui
10
+ @sandbox = sandbox
11
+ end
12
+
13
+ def run
14
+ @ui.header
15
+ target = Target.new(@input, app_root: @sandbox.root)
16
+ @ui.analyzing(target.class_name)
17
+ snapshot = Context::Builder.new(target, sandbox: @sandbox).build
18
+ @ui.context_collected(snapshot)
19
+ Runners::RspecRunner.new(app_root: @sandbox.root).ensure_available! if SpecAi.configuration.run_after_generation
20
+ snapshot = prepare_app!(target, snapshot)
21
+
22
+ crew = Crews::GenerateCrew.new(snapshot, sandbox: @sandbox)
23
+ plan = @ui.spin_planning { crew.plan }
24
+ @ui.llm_fallback(crew.fallback_notice, preview: crew.fallback_preview) if crew.fallback?
25
+ plan = TestPlan.stabilize(plan, snapshot)
26
+ @ui.generating(plan)
27
+
28
+ spec_path = Generators::RspecGenerator.new(plan, snapshot, force: @force, sandbox: @sandbox).write
29
+ if @force
30
+ @ui.writing(relative(spec_path))
31
+ else
32
+ @ui.merging(relative(spec_path))
33
+ end
34
+
35
+ unless SpecAi.configuration.run_after_generation
36
+ @ui.completed(true)
37
+ return Result.new(target: target, spec_path: spec_path, plan: plan, run: nil)
38
+ end
39
+
40
+ run_and_repair(snapshot, plan, spec_path)
41
+ end
42
+
43
+ private
44
+
45
+ def run_and_repair(snapshot, plan, spec_path)
46
+ runner = Runners::RspecRunner.new(app_root: @sandbox.root)
47
+ result = @ui.spin_running { runner.run(spec_path) }
48
+ @ui.rspec_result(result)
49
+ result = rerun_after_helper_patch(runner, spec_path, result)
50
+
51
+ attempts = 0
52
+ prune_passes = 0
53
+ max = SpecAi.configuration.max_repair_attempts.to_i
54
+ while result.repairable? && SpecAi.configuration.auto_fix && attempts < max
55
+ if prune_passes < 2
56
+ pruned, dropped = Repair::Prune.call(plan, result)
57
+ next_plan = pruned || SnapshotPlan.build(snapshot)
58
+ if dropped.any? && next_plan && next_plan.scenarios.map(&:name) != plan.scenarios.map(&:name)
59
+ plan = next_plan
60
+ prune_passes += 1
61
+ @ui.dropped_unfounded(dropped)
62
+ spec_path = Generators::RspecGenerator.new(plan, snapshot, force: true, sandbox: @sandbox).write
63
+ @ui.writing(relative(spec_path))
64
+ result = @ui.spin_running { runner.run(spec_path) }
65
+ @ui.rspec_result(result)
66
+ next
67
+ end
68
+ end
69
+
70
+ attempts += 1
71
+ spec_contents = File.exist?(spec_path) ? File.read(spec_path) : @sandbox.read(snapshot.target.spec_path)
72
+ begin
73
+ plan = @ui.spin_repairing(attempts, max) do
74
+ Crews::RepairCrew.new(snapshot, result, spec_contents, previous_plan: plan, sandbox: @sandbox).plan
75
+ end
76
+ plan = plan.ground(snapshot) || SnapshotPlan.build(snapshot) || plan
77
+ rescue InvalidPlan
78
+ @ui.skip_repair("The repair model did not return a valid test plan. The spec was left as-is with the failures above.")
79
+ break
80
+ end
81
+ spec_path = Generators::RspecGenerator.new(plan, snapshot, force: true, sandbox: @sandbox).write
82
+ @ui.writing(relative(spec_path))
83
+ result = @ui.spin_running { runner.run(spec_path) }
84
+ @ui.rspec_result(result)
85
+ end
86
+
87
+ @ui.completed(result.passed?, result: result)
88
+ Result.new(target: snapshot.target, spec_path: spec_path, plan: plan, run: result, repair_attempts: attempts)
89
+ end
90
+
91
+ def relative(path)
92
+ path.to_s.sub(%r{\A#{Regexp.escape(@sandbox.root)}/?}, "")
93
+ end
94
+
95
+ def rerun_after_helper_patch(runner, spec_path, result)
96
+ return result unless result.failed?
97
+ return result unless Generators::RspecGenerator.patch_helper!(relative(spec_path), sandbox: @sandbox)
98
+
99
+ @ui.writing(relative(spec_path))
100
+ result = @ui.spin_running { runner.run(spec_path) }
101
+ @ui.rspec_result(result)
102
+ result
103
+ end
104
+
105
+ def prepare_app!(target, snapshot)
106
+ setup = Setup::Ensure.new(snapshot, sandbox: @sandbox).run
107
+ @ui.setup_actions(setup.actions)
108
+ raise ConfigurationError, setup.blocking_message if setup.blocking?
109
+
110
+ return snapshot unless setup.changed?
111
+
112
+ Context::Builder.new(target, sandbox: @sandbox).build
113
+ end
114
+ end
115
+ end
116
+ end
@@ -0,0 +1,21 @@
1
+ # frozen_string_literal: true
2
+
3
+ module SpecAi
4
+ module Pipeline
5
+ class Result
6
+ attr_reader :target, :spec_path, :plan, :run, :repair_attempts
7
+
8
+ def initialize(target:, spec_path:, plan:, run:, repair_attempts: 0)
9
+ @target = target
10
+ @spec_path = spec_path
11
+ @plan = plan
12
+ @run = run
13
+ @repair_attempts = repair_attempts
14
+ end
15
+
16
+ def success?
17
+ run.nil? || run.passed?
18
+ end
19
+ end
20
+ end
21
+ end
@@ -0,0 +1,11 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "rails/railtie"
4
+
5
+ module SpecAi
6
+ class Railtie < ::Rails::Railtie
7
+ rake_tasks do
8
+ load File.expand_path("../tasks/spec_ai.rake", __dir__)
9
+ end
10
+ end
11
+ end
@@ -0,0 +1,49 @@
1
+ # frozen_string_literal: true
2
+
3
+ module SpecAi
4
+ module Repair
5
+ class Prune
6
+ def self.call(plan, result)
7
+ new(plan, result).call
8
+ end
9
+
10
+ def initialize(plan, result)
11
+ @plan = plan
12
+ @result = result
13
+ end
14
+
15
+ def call
16
+ names = droppable_names
17
+ return [@plan, []] if names.empty?
18
+
19
+ [@plan.without_existing(names), names]
20
+ end
21
+
22
+ private
23
+
24
+ def droppable_names
25
+ failures = @result.report.failures
26
+ @plan.scenarios.filter_map do |scenario|
27
+ scenario.name if failures.any? { |failure| matches?(failure, scenario) && droppable?(failure) }
28
+ end
29
+ end
30
+
31
+ def matches?(failure, scenario)
32
+ failure.example.to_s.downcase.include?(scenario.name.to_s.downcase)
33
+ end
34
+
35
+ def droppable?(failure)
36
+ haystack = "#{failure.example} #{failure.snippet} #{failure.detail}"
37
+ return true if haystack.match?(/expected .+ not to be valid/i)
38
+ return true if haystack.match?(/nil\.present\?/i)
39
+ return true if empty_errors?(failure, haystack)
40
+
41
+ false
42
+ end
43
+
44
+ def empty_errors?(failure, haystack)
45
+ haystack.match?(/\[\]\.present\?/) && !failure.example.to_s.match?(/duplicate|unique/i)
46
+ end
47
+ end
48
+ end
49
+ end
@@ -0,0 +1,144 @@
1
+ # frozen_string_literal: true
2
+
3
+ module SpecAi
4
+ module Runners
5
+ class Report
6
+ Failure = Struct.new(:example, :snippet, :detail, :hint, keyword_init: true)
7
+
8
+ SUMMARY_RE = /(\d+)\s+examples?,\s+(\d+)\s+failures?(?:,\s+(\d+)\s+errors?)?/
9
+ FAILED_EXAMPLE_RE = /^rspec\s+\S+\s+#\s+(.+)$/
10
+ LOAD_ERROR_RE = /error occurred (?:outside of examples|while loading)/i
11
+ MISSING_FILE_RE = /cannot load such file -- (\S+)/
12
+
13
+ attr_reader :result
14
+
15
+ def initialize(result)
16
+ @result = result
17
+ end
18
+
19
+ def summary
20
+ if (match = @result.output.match(SUMMARY_RE))
21
+ text = "#{match[1]} #{match[1] == '1' ? 'example' : 'examples'}, #{match[2]} #{match[2] == '1' ? 'failure' : 'failures'}"
22
+ text += ", #{match[3]} #{match[3] == '1' ? 'error' : 'errors'}" if match[3]
23
+ return text
24
+ end
25
+
26
+ return "spec failed to load" if load_error?
27
+ return "passed" if @result.passed?
28
+
29
+ "examples failed"
30
+ end
31
+
32
+ def load_error?
33
+ @result.output.match?(LOAD_ERROR_RE) ||
34
+ @result.output.match?(MISSING_FILE_RE) ||
35
+ @result.output.match?(/uninitialized constant/) ||
36
+ (@result.failed? && @result.output.match?(/\b0 examples?, 0 failures?, \d+ errors?/))
37
+ end
38
+
39
+ def failures
40
+ @failures ||= parse_failures
41
+ end
42
+
43
+ private
44
+
45
+ def parse_failures
46
+ blocks = numbered_failures
47
+ return blocks unless blocks.empty?
48
+ return [load_failure] if load_error?
49
+
50
+ fallback_examples.map do |example|
51
+ Failure.new(example: example, snippet: nil, detail: nil, hint: hint_for(example, "", ""))
52
+ end
53
+ end
54
+
55
+ def load_failure
56
+ detail = load_error_detail
57
+ Failure.new(
58
+ example: "spec failed to load",
59
+ snippet: nil,
60
+ detail: detail,
61
+ hint: hint_for("spec failed to load", "", detail.to_s)
62
+ )
63
+ end
64
+
65
+ def load_error_detail
66
+ output = @result.output
67
+ if (match = output.match(MISSING_FILE_RE))
68
+ return "cannot load such file -- #{match[1]}"
69
+ end
70
+
71
+ line = output.lines.map(&:strip).find do |entry|
72
+ entry.match?(LOAD_ERROR_RE) || entry.match?(/uninitialized constant|LoadError/)
73
+ end
74
+ line || output.lines.map(&:strip).reject(&:empty?).first
75
+ end
76
+
77
+ def numbered_failures
78
+ chunks = @result.output.split(/^\s+\d+\)\s+/)
79
+ chunks.shift
80
+ chunks.filter_map do |chunk|
81
+ lines = chunk.lines.map(&:rstrip)
82
+ example = lines.first.to_s.strip
83
+ next if example.empty?
84
+
85
+ snippet_line = lines.find { |line| line.include?("Failure/Error:") }
86
+ snippet = snippet_line&.sub(/.*Failure\/Error:\s*/, "")&.strip
87
+ detail = detail_from(lines)
88
+ Failure.new(example: example, snippet: snippet, detail: detail, hint: hint_for(example, snippet.to_s, detail.to_s))
89
+ end
90
+ end
91
+
92
+ def detail_from(lines)
93
+ start = lines.index { |line| line.include?("Failure/Error:") }
94
+ return nil unless start
95
+
96
+ lines[(start + 1)..].take_while do |line|
97
+ stripped = line.strip
98
+ stripped != "" && !stripped.start_with?("#") && !stripped.start_with?("# ./")
99
+ end.map(&:strip).reject(&:empty?).join(" ")
100
+ end
101
+
102
+ def fallback_examples
103
+ @result.output.scan(FAILED_EXAMPLE_RE).flatten
104
+ end
105
+
106
+ def hint_for(example, snippet, detail)
107
+ haystack = "#{example} #{snippet} #{detail}"
108
+
109
+ if haystack.match?(/cannot load such file -- (?:spec_helper|rails_helper)/)
110
+ "RSpec's bootstrap files are missing. Spec AI creates spec/spec_helper.rb and spec/rails_helper.rb when the spec folder is gone."
111
+ elsif haystack.match?(/uninitialized constant/)
112
+ "Rails did not load this spec. Model specs need `require \"rails_helper\"`."
113
+ elsif haystack.match?(/can't find executable rspec|not currently included in the bundle/i)
114
+ "RSpec is not in the application's Gemfile. Add `gem \"rspec-rails\"` and run `bundle install`."
115
+ elsif missing_association?(haystack)
116
+ "The spec expects an association that is not defined on the model."
117
+ elsif uniqueness_miss?(example, haystack)
118
+ "The duplicate record was not saved, so uniqueness never ran. Create usually needs the same required fields as a valid record."
119
+ elsif haystack.match?(/\[\]\.present\?/)
120
+ "The model did not add an error on this attribute. The spec is testing a rule that is not in the model."
121
+ elsif missing_required_attrs?(haystack)
122
+ "The spec built a record without required attributes. Add a FactoryBot factory or set those fields in the example."
123
+ elsif haystack.match?(/expected .+ not to be valid/i)
124
+ "The model accepted this value. Devise strips email whitespace and does not require password_confirmation to be present."
125
+ elsif haystack.match?(/NameError|undefined (?:local variable|method)/i)
126
+ "The spec referenced a method or variable that does not exist."
127
+ end
128
+ end
129
+
130
+ def missing_association?(haystack)
131
+ haystack.match?(/reflect_on_association|nil\.present\?|expected `nil`/i) ||
132
+ haystack.match?(/has_many|belongs_to|has_one/i) && haystack.match?(/to be truthy, got false/i)
133
+ end
134
+
135
+ def uniqueness_miss?(example, haystack)
136
+ example.match?(/duplicate|unique/i) && haystack.match?(/\[\]\.present\?|to be truthy, got false/i)
137
+ end
138
+
139
+ def missing_required_attrs?(haystack)
140
+ haystack.match?(/to be valid/i) && haystack.match?(/can't be blank/i)
141
+ end
142
+ end
143
+ end
144
+ end
@@ -0,0 +1,99 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "open3"
4
+
5
+ module SpecAi
6
+ module Runners
7
+ class RspecRunner
8
+ MISSING_RSPEC = /
9
+ can't\ find\ executable\ rspec
10
+ |rspec-core\ is\ not\ currently\ included\ in\ the\ bundle
11
+ |Could\ not\ find\ gem\ ['"]rspec
12
+ /ix
13
+
14
+ Result = Struct.new(:exit_status, :stdout, :stderr, :spec_path, keyword_init: true) do
15
+ def passed?
16
+ exit_status.to_i.zero?
17
+ end
18
+
19
+ def failed?
20
+ !passed?
21
+ end
22
+
23
+ def output
24
+ [stdout, stderr].compact.join("\n")
25
+ end
26
+
27
+ def environment_error?
28
+ output.match?(MISSING_RSPEC)
29
+ end
30
+
31
+ def repairable?
32
+ failed? && !environment_error? && !report.load_error?
33
+ end
34
+
35
+ def report
36
+ @report ||= Report.new(self)
37
+ end
38
+ end
39
+
40
+ def initialize(app_root: SpecAi.configuration.app_root)
41
+ @app_root = app_root
42
+ end
43
+
44
+ def ensure_available!
45
+ return if bundled_rspec?
46
+
47
+ raise ConfigurationError, missing_rspec_message
48
+ end
49
+
50
+ def run(spec_path)
51
+ relative = spec_path.to_s.sub(%r{\A#{Regexp.escape(@app_root)}/?}, "")
52
+ env = {}
53
+ gemfile = File.join(@app_root, "Gemfile")
54
+ env["BUNDLE_GEMFILE"] = gemfile if File.file?(gemfile)
55
+
56
+ stdout, stderr, status = Open3.capture3(
57
+ env,
58
+ "bundle", "exec", "rspec", relative, "--format", "documentation",
59
+ chdir: @app_root
60
+ )
61
+ result = Result.new(exit_status: status.exitstatus, stdout: stdout, stderr: stderr, spec_path: relative)
62
+ raise ConfigurationError, missing_rspec_message if result.environment_error?
63
+
64
+ result
65
+ end
66
+
67
+ private
68
+
69
+ def bundled_rspec?
70
+ return true if File.executable?(File.join(@app_root, "bin", "rspec"))
71
+
72
+ lock = File.join(@app_root, "Gemfile.lock")
73
+ if File.file?(lock)
74
+ return true if File.read(lock).match?(/^\s{4}rspec(?:-core|-rails)? \(/)
75
+ end
76
+
77
+ gemfile = File.join(@app_root, "Gemfile")
78
+ File.file?(gemfile) && File.read(gemfile).match?(/\bgem\s+["']rspec(?:-rails|-core)?["']/)
79
+ end
80
+
81
+ def missing_rspec_message
82
+ <<~MSG.strip
83
+ RSpec is not in this application's bundle. Spec AI writes model specs, then runs them with `bundle exec rspec`.
84
+
85
+ Add this to the Gemfile:
86
+
87
+ group :development, :test do
88
+ gem "rspec-rails"
89
+ end
90
+
91
+ Then run:
92
+
93
+ bundle install
94
+ bin/rails generate rspec:install
95
+ MSG
96
+ end
97
+ end
98
+ end
99
+ end
@@ -0,0 +1,63 @@
1
+ # frozen_string_literal: true
2
+
3
+ module SpecAi
4
+ class Sandbox
5
+ ALLOWED_PREFIXES = %w[app/ spec/ db/ config/ test/].freeze
6
+
7
+ attr_reader :root
8
+
9
+ def initialize(root = SpecAi.configuration.app_root)
10
+ @root = File.expand_path(root)
11
+ end
12
+
13
+ def read(relative_path)
14
+ File.read(resolve!(relative_path))
15
+ end
16
+
17
+ def write(relative_path, content)
18
+ path = resolve!(relative_path)
19
+ FileUtils.mkdir_p(File.dirname(path))
20
+ File.write(path, content)
21
+ path
22
+ end
23
+
24
+ def exist?(relative_path)
25
+ File.file?(resolve!(relative_path))
26
+ rescue PathNotAllowed
27
+ false
28
+ end
29
+
30
+ def glob(pattern)
31
+ Dir.glob(File.join(@root, pattern)).filter_map do |full|
32
+ next unless full.start_with?(@root)
33
+
34
+ rel = full.delete_prefix(@root).delete_prefix("/")
35
+ rel if allowed?(rel)
36
+ end
37
+ end
38
+
39
+ def resolve!(relative_path)
40
+ rel = normalize(relative_path)
41
+ raise PathNotAllowed, "path is not allowed: #{relative_path}" unless allowed?(rel)
42
+
43
+ full = File.expand_path(rel, @root)
44
+ unless full == @root || full.start_with?(@root + File::SEPARATOR)
45
+ raise PathNotAllowed, "path escapes app root: #{relative_path}"
46
+ end
47
+
48
+ full
49
+ end
50
+
51
+ private
52
+
53
+ def normalize(relative_path)
54
+ relative_path.to_s.sub(%r{\A/+}, "")
55
+ end
56
+
57
+ def allowed?(rel)
58
+ ALLOWED_PREFIXES.any? do |prefix|
59
+ rel == prefix.delete_suffix("/") || rel.start_with?(prefix)
60
+ end
61
+ end
62
+ end
63
+ end
@@ -0,0 +1,159 @@
1
+ # frozen_string_literal: true
2
+
3
+ module SpecAi
4
+ module Setup
5
+ class Ensure
6
+ Action = Struct.new(:kind, :path, :message, :blocking, keyword_init: true)
7
+
8
+ def initialize(snapshot, sandbox: Sandbox.new)
9
+ @snapshot = snapshot
10
+ @sandbox = sandbox
11
+ @actions = []
12
+ end
13
+
14
+ def run
15
+ ensure_rspec_helpers!
16
+ ensure_factory_bot!
17
+ ensure_factory!
18
+ ensure_factory_syntax!
19
+ self
20
+ end
21
+
22
+ def actions
23
+ @actions
24
+ end
25
+
26
+ def changed?
27
+ @actions.any? { |action| %i[created updated].include?(action.kind) }
28
+ end
29
+
30
+ def blocking?
31
+ @actions.any?(&:blocking)
32
+ end
33
+
34
+ def blocking_message
35
+ blocked = @actions.select(&:blocking)
36
+ return if blocked.empty?
37
+
38
+ blocked.map(&:message).join("\n\n")
39
+ end
40
+
41
+ private
42
+
43
+ def ensure_rspec_helpers!
44
+ created = Generators::RspecInstall.new(sandbox: @sandbox).write
45
+ Array(created).each do |path|
46
+ @actions << Action.new(
47
+ kind: :created,
48
+ path: path,
49
+ message: "RSpec bootstrap so generated specs can load Rails",
50
+ blocking: false
51
+ )
52
+ end
53
+ ensure_dot_rspec!
54
+ end
55
+
56
+ def ensure_dot_rspec!
57
+ path = File.join(@sandbox.root, ".rspec")
58
+ return if File.file?(path)
59
+
60
+ File.write(path, "--require spec_helper\n")
61
+ @actions << Action.new(
62
+ kind: :created,
63
+ path: ".rspec",
64
+ message: "RSpec default options",
65
+ blocking: false
66
+ )
67
+ end
68
+
69
+ def ensure_factory_bot!
70
+ return if factory_bot_bundled?
71
+
72
+ added = add_factory_bot_gem!
73
+ message = if added
74
+ "Added factory_bot_rails to the Gemfile. Run `bundle install`, then re-run spec-ai generate #{@snapshot.target.class_name}."
75
+ else
76
+ <<~MSG.strip
77
+ FactoryBot is not in this application's bundle. Spec AI needs a factory to build valid #{@snapshot.target.class_name} records.
78
+
79
+ Add this to the Gemfile:
80
+
81
+ group :development, :test do
82
+ gem "factory_bot_rails"
83
+ end
84
+
85
+ Then run `bundle install` and re-run spec-ai generate #{@snapshot.target.class_name}.
86
+ MSG
87
+ end
88
+ @actions << Action.new(kind: added ? :updated : :needed, path: "Gemfile", message: message, blocking: true)
89
+ end
90
+
91
+ def ensure_factory!
92
+ return if @snapshot.has_factory?
93
+
94
+ path = Generators::FactoryGenerator.new(@snapshot, sandbox: @sandbox).write
95
+ relative = path.to_s.sub(%r{\A#{Regexp.escape(@sandbox.root)}/?}, "")
96
+ @actions << Action.new(
97
+ kind: :created,
98
+ path: relative,
99
+ message: "FactoryBot factory for :#{@snapshot.target.factory_name}",
100
+ blocking: false
101
+ )
102
+ end
103
+
104
+ def ensure_factory_syntax!
105
+ helper = "spec/rails_helper.rb"
106
+ return unless @sandbox.exist?(helper)
107
+
108
+ contents = @sandbox.read(helper)
109
+ return if contents.match?(/FactoryBot::Syntax::Methods/)
110
+
111
+ updated = contents.sub(/RSpec\.configure do \|config\|/) do |match|
112
+ "#{match}\n config.include FactoryBot::Syntax::Methods"
113
+ end
114
+ return if updated == contents
115
+
116
+ @sandbox.write(helper, updated)
117
+ @actions << Action.new(
118
+ kind: :updated,
119
+ path: helper,
120
+ message: "Enabled FactoryBot::Syntax::Methods so specs can call build/create",
121
+ blocking: false
122
+ )
123
+ end
124
+
125
+ def factory_bot_bundled?
126
+ lock = File.join(@sandbox.root, "Gemfile.lock")
127
+ if File.file?(lock)
128
+ return true if File.read(lock).match?(/^\s{4}factory_bot(?:_rails)? \(/)
129
+ end
130
+
131
+ gemfile = File.join(@sandbox.root, "Gemfile")
132
+ File.file?(gemfile) && File.read(gemfile).match?(/\bgem\s+["']factory_bot(?:_rails)?["']/)
133
+ end
134
+
135
+ def add_factory_bot_gem!
136
+ path = File.join(@sandbox.root, "Gemfile")
137
+ return false unless File.file?(path)
138
+
139
+ contents = File.read(path)
140
+ return false if contents.match?(/factory_bot_rails/)
141
+
142
+ if contents.match?(/group :development, :test do/)
143
+ contents = contents.sub(/group :development, :test do\n/) do |match|
144
+ "#{match} gem \"factory_bot_rails\"\n"
145
+ end
146
+ else
147
+ contents += <<~RUBY
148
+
149
+ group :development, :test do
150
+ gem "factory_bot_rails"
151
+ end
152
+ RUBY
153
+ end
154
+ File.write(path, contents)
155
+ true
156
+ end
157
+ end
158
+ end
159
+ end