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
|
@@ -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,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
|