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,166 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module SpecAi
|
|
4
|
+
class SnapshotPlan
|
|
5
|
+
def self.build(snapshot)
|
|
6
|
+
new(snapshot).plan
|
|
7
|
+
end
|
|
8
|
+
|
|
9
|
+
def initialize(snapshot)
|
|
10
|
+
@snapshot = snapshot
|
|
11
|
+
end
|
|
12
|
+
|
|
13
|
+
def plan
|
|
14
|
+
scenarios = []
|
|
15
|
+
scenarios << happy_path
|
|
16
|
+
scenarios.concat(validation_scenarios)
|
|
17
|
+
scenarios.concat(association_scenarios)
|
|
18
|
+
scenarios.concat(devise_scenarios)
|
|
19
|
+
scenarios = scenarios.compact.uniq { |scenario| scenario["name"] }
|
|
20
|
+
return if scenarios.empty?
|
|
21
|
+
|
|
22
|
+
TestPlan.new(
|
|
23
|
+
"target" => @snapshot.target.class_name,
|
|
24
|
+
"test_type" => "model",
|
|
25
|
+
"scenarios" => scenarios
|
|
26
|
+
)
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
private
|
|
30
|
+
|
|
31
|
+
def happy_path
|
|
32
|
+
scenario(
|
|
33
|
+
name: "is valid with complete attributes",
|
|
34
|
+
category: "happy_path",
|
|
35
|
+
expected_behavior: "valid"
|
|
36
|
+
)
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
def validation_scenarios
|
|
40
|
+
Array(@snapshot.model[:validations]).flat_map do |item|
|
|
41
|
+
attributes = Array(item[:attributes] || item["attributes"]).map(&:to_s)
|
|
42
|
+
validators = Array(item[:validators] || item["validators"]).map(&:to_s)
|
|
43
|
+
attributes.filter_map { |attribute| attribute if usable_attribute?(attribute) }.flat_map do |attribute|
|
|
44
|
+
scenarios_for(attribute, validators)
|
|
45
|
+
end
|
|
46
|
+
end
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
def scenarios_for(attribute, validators)
|
|
50
|
+
list = []
|
|
51
|
+
if validators.include?("presence")
|
|
52
|
+
list << scenario(
|
|
53
|
+
name: "requires #{attribute}",
|
|
54
|
+
category: "validation",
|
|
55
|
+
expected_behavior: "invalid",
|
|
56
|
+
attribute: attribute,
|
|
57
|
+
value: "nil",
|
|
58
|
+
setup: ["#{attribute} is nil"]
|
|
59
|
+
)
|
|
60
|
+
if SpecAi.configuration.include_edge_cases
|
|
61
|
+
list << scenario(
|
|
62
|
+
name: "rejects blank #{attribute}",
|
|
63
|
+
category: "edge_case",
|
|
64
|
+
expected_behavior: "invalid",
|
|
65
|
+
attribute: attribute,
|
|
66
|
+
value: "",
|
|
67
|
+
setup: ["#{attribute} is blank"]
|
|
68
|
+
)
|
|
69
|
+
end
|
|
70
|
+
end
|
|
71
|
+
if validators.include?("uniqueness")
|
|
72
|
+
list << scenario(
|
|
73
|
+
name: "requires a unique #{attribute}",
|
|
74
|
+
category: "validation",
|
|
75
|
+
expected_behavior: "invalid",
|
|
76
|
+
attribute: attribute,
|
|
77
|
+
value: unique_value(attribute),
|
|
78
|
+
setup: ["another record already has this #{attribute}"]
|
|
79
|
+
)
|
|
80
|
+
end
|
|
81
|
+
if validators.include?("format")
|
|
82
|
+
list << scenario(
|
|
83
|
+
name: "rejects an invalid #{attribute} format",
|
|
84
|
+
category: "validation",
|
|
85
|
+
expected_behavior: "invalid",
|
|
86
|
+
attribute: attribute,
|
|
87
|
+
value: format_value(attribute),
|
|
88
|
+
setup: ["#{attribute} is malformed"]
|
|
89
|
+
)
|
|
90
|
+
end
|
|
91
|
+
if validators.include?("length")
|
|
92
|
+
list << scenario(
|
|
93
|
+
name: "rejects a too-short #{attribute}",
|
|
94
|
+
category: "validation",
|
|
95
|
+
expected_behavior: "invalid",
|
|
96
|
+
attribute: attribute,
|
|
97
|
+
value: "short",
|
|
98
|
+
setup: ["#{attribute} is too short"]
|
|
99
|
+
)
|
|
100
|
+
end
|
|
101
|
+
if validators.include?("confirmation")
|
|
102
|
+
list << scenario(
|
|
103
|
+
name: "requires #{attribute} confirmation to match",
|
|
104
|
+
category: "validation",
|
|
105
|
+
expected_behavior: "invalid",
|
|
106
|
+
attribute: "#{attribute}_confirmation",
|
|
107
|
+
value: "mismatch",
|
|
108
|
+
setup: ["#{attribute}_confirmation does not match"]
|
|
109
|
+
)
|
|
110
|
+
end
|
|
111
|
+
list
|
|
112
|
+
end
|
|
113
|
+
|
|
114
|
+
def association_scenarios
|
|
115
|
+
Array(@snapshot.model[:associations]).filter_map do |item|
|
|
116
|
+
name = (item[:name] || item["name"]).to_s
|
|
117
|
+
macro = (item[:macro] || item["macro"]).to_s
|
|
118
|
+
next if name.empty? || macro.empty?
|
|
119
|
+
|
|
120
|
+
scenario(
|
|
121
|
+
name: "#{macro.tr('_', ' ')} #{name}",
|
|
122
|
+
category: "association",
|
|
123
|
+
expected_behavior: "has_association",
|
|
124
|
+
association: name,
|
|
125
|
+
association_macro: macro
|
|
126
|
+
)
|
|
127
|
+
end
|
|
128
|
+
end
|
|
129
|
+
|
|
130
|
+
def devise_scenarios
|
|
131
|
+
return [] unless @snapshot.model[:source].to_s.match?(/\bdevise\b/)
|
|
132
|
+
|
|
133
|
+
list = []
|
|
134
|
+
if usable_attribute?("password") && Array(@snapshot.model[:validations]).none? { |item| Array(item[:attributes] || item["attributes"]).map(&:to_s).include?("password") }
|
|
135
|
+
list.concat(scenarios_for("password", %w[presence length]))
|
|
136
|
+
end
|
|
137
|
+
list
|
|
138
|
+
end
|
|
139
|
+
|
|
140
|
+
def usable_attribute?(name)
|
|
141
|
+
@snapshot.known_attributes.include?(name.to_s)
|
|
142
|
+
end
|
|
143
|
+
|
|
144
|
+
def unique_value(attribute)
|
|
145
|
+
attribute == "email" ? "duplicate@example.com" : "duplicate"
|
|
146
|
+
end
|
|
147
|
+
|
|
148
|
+
def format_value(attribute)
|
|
149
|
+
attribute == "email" ? "not-an-email" : "invalid"
|
|
150
|
+
end
|
|
151
|
+
|
|
152
|
+
def scenario(name:, category:, expected_behavior:, attribute: nil, value: nil, association: nil, association_macro: nil, setup: [])
|
|
153
|
+
{
|
|
154
|
+
"name" => name,
|
|
155
|
+
"category" => category,
|
|
156
|
+
"priority" => "high",
|
|
157
|
+
"setup" => setup,
|
|
158
|
+
"expected_behavior" => expected_behavior,
|
|
159
|
+
"attribute" => attribute,
|
|
160
|
+
"value" => value,
|
|
161
|
+
"association" => association,
|
|
162
|
+
"association_macro" => association_macro
|
|
163
|
+
}
|
|
164
|
+
end
|
|
165
|
+
end
|
|
166
|
+
end
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module SpecAi
|
|
4
|
+
class Target
|
|
5
|
+
attr_reader :input, :class_name, :model_path, :spec_path, :table_name, :factory_name
|
|
6
|
+
|
|
7
|
+
def initialize(input, app_root: SpecAi.configuration.app_root)
|
|
8
|
+
@input = input.to_s.strip
|
|
9
|
+
@app_root = app_root
|
|
10
|
+
resolve!
|
|
11
|
+
end
|
|
12
|
+
|
|
13
|
+
def source_path
|
|
14
|
+
model_path
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
def name
|
|
18
|
+
class_name
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
private
|
|
22
|
+
|
|
23
|
+
def resolve!
|
|
24
|
+
if looks_like_path?
|
|
25
|
+
relative = @input.sub(%r{\A#{Regexp.escape(@app_root)}/?}, "")
|
|
26
|
+
relative = relative.sub(%r{\A(?:\./)+}, "")
|
|
27
|
+
@class_name = class_name_from_path(relative)
|
|
28
|
+
else
|
|
29
|
+
@class_name = @input.sub(/\.rb\z/, "")
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
@model_path = Inflector.model_path(@class_name)
|
|
33
|
+
@spec_path = Inflector.spec_path(@class_name)
|
|
34
|
+
@table_name = Inflector.table_name(@class_name)
|
|
35
|
+
@factory_name = Inflector.factory_name(@class_name)
|
|
36
|
+
|
|
37
|
+
sandbox = Sandbox.new(@app_root)
|
|
38
|
+
return if sandbox.exist?(@model_path)
|
|
39
|
+
|
|
40
|
+
raise TargetNotFound, "could not find model file #{@model_path} for #{@input}"
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
def looks_like_path?
|
|
44
|
+
@input.include?("/") || @input.end_with?(".rb")
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
def class_name_from_path(relative)
|
|
48
|
+
cleaned = relative.sub(%r{\Aapp/models/}, "").sub(%r{\Aspec/models/}, "").sub(/_spec\.rb\z/, "").sub(/\.rb\z/, "")
|
|
49
|
+
Inflector.camelize(cleaned)
|
|
50
|
+
end
|
|
51
|
+
end
|
|
52
|
+
end
|
|
@@ -0,0 +1,342 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module SpecAi
|
|
4
|
+
class TestPlan
|
|
5
|
+
SCHEMA = {
|
|
6
|
+
"type" => "object",
|
|
7
|
+
"properties" => {
|
|
8
|
+
"target" => { "type" => "string" },
|
|
9
|
+
"test_type" => { "type" => "string", "enum" => %w[model] },
|
|
10
|
+
"scenarios" => {
|
|
11
|
+
"type" => "array",
|
|
12
|
+
"minItems" => 1,
|
|
13
|
+
"items" => {
|
|
14
|
+
"type" => "object",
|
|
15
|
+
"properties" => {
|
|
16
|
+
"name" => { "type" => "string" },
|
|
17
|
+
"category" => {
|
|
18
|
+
"type" => "string",
|
|
19
|
+
"enum" => %w[happy_path validation association edge_case error_handling]
|
|
20
|
+
},
|
|
21
|
+
"priority" => { "type" => "string", "enum" => %w[high medium low] },
|
|
22
|
+
"setup" => { "type" => "array", "items" => { "type" => "string" } },
|
|
23
|
+
"expected_behavior" => { "type" => "string" },
|
|
24
|
+
"attribute" => { "type" => "string" },
|
|
25
|
+
"value" => { "type" => "string" },
|
|
26
|
+
"association" => { "type" => "string" },
|
|
27
|
+
"association_macro" => { "type" => "string" }
|
|
28
|
+
},
|
|
29
|
+
"required" => %w[name category priority expected_behavior]
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
},
|
|
33
|
+
"required" => %w[target test_type scenarios]
|
|
34
|
+
}.freeze
|
|
35
|
+
|
|
36
|
+
Scenario = Struct.new(
|
|
37
|
+
:name, :category, :priority, :setup, :expected_behavior,
|
|
38
|
+
:attribute, :value, :association, :association_macro,
|
|
39
|
+
keyword_init: true
|
|
40
|
+
)
|
|
41
|
+
|
|
42
|
+
attr_reader :target, :test_type, :scenarios
|
|
43
|
+
|
|
44
|
+
def self.from(data)
|
|
45
|
+
new(data)
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
def self.from_task(task)
|
|
49
|
+
from(payload_from_task(task))
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
def self.unreadable(raw)
|
|
53
|
+
cfg = SpecAi.configuration
|
|
54
|
+
<<~MSG.strip
|
|
55
|
+
#{cfg.provider} / #{cfg.model} did not return a valid test plan.
|
|
56
|
+
|
|
57
|
+
Reply: #{preview(raw)}
|
|
58
|
+
|
|
59
|
+
Check the API key for this provider, or switch provider/model in config/spec_ai.yml.
|
|
60
|
+
Re-run with --verbose to see the full LLM output.
|
|
61
|
+
MSG
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
def self.preview(raw)
|
|
65
|
+
text = raw.to_s.strip.gsub(/\s+/, " ")
|
|
66
|
+
return "(empty reply)" if text.empty?
|
|
67
|
+
|
|
68
|
+
text.length > 180 ? "#{text[0, 177]}..." : text
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
def self.payload_from_task(task)
|
|
72
|
+
if task.respond_to?(:structured_output)
|
|
73
|
+
data = task.structured_output
|
|
74
|
+
return data if usable_payload?(data)
|
|
75
|
+
end
|
|
76
|
+
if task.respond_to?(:result)
|
|
77
|
+
data = task.result
|
|
78
|
+
return data if usable_payload?(data)
|
|
79
|
+
end
|
|
80
|
+
task.respond_to?(:raw_result) ? task.raw_result : nil
|
|
81
|
+
end
|
|
82
|
+
|
|
83
|
+
def self.usable_payload?(data)
|
|
84
|
+
return false if data.nil?
|
|
85
|
+
return data.any? if data.is_a?(Hash)
|
|
86
|
+
return !data.strip.empty? if data.is_a?(String)
|
|
87
|
+
return data.any? if data.is_a?(Array)
|
|
88
|
+
|
|
89
|
+
true
|
|
90
|
+
end
|
|
91
|
+
|
|
92
|
+
def initialize(data)
|
|
93
|
+
hash = normalize(data)
|
|
94
|
+
@target = hash.fetch("target")
|
|
95
|
+
@test_type = hash["test_type"] || "model"
|
|
96
|
+
@scenarios = Array(hash["scenarios"]).map { |scenario| build_scenario(scenario) }
|
|
97
|
+
raise InvalidPlan, "test plan must include at least one scenario" if @scenarios.empty?
|
|
98
|
+
end
|
|
99
|
+
|
|
100
|
+
def to_h
|
|
101
|
+
{
|
|
102
|
+
"target" => target,
|
|
103
|
+
"test_type" => test_type,
|
|
104
|
+
"scenarios" => scenarios.map { |scenario| scenario.to_h.transform_keys(&:to_s) }
|
|
105
|
+
}
|
|
106
|
+
end
|
|
107
|
+
|
|
108
|
+
def without_existing(example_names)
|
|
109
|
+
names = Array(example_names).map { |name| name.to_s.downcase }
|
|
110
|
+
kept = scenarios.reject { |scenario| names.include?(scenario.name.to_s.downcase) }
|
|
111
|
+
return self if kept.size == scenarios.size
|
|
112
|
+
return nil if kept.empty?
|
|
113
|
+
|
|
114
|
+
duplicate = to_h.merge("scenarios" => kept.map { |scenario| scenario.to_h.transform_keys(&:to_s) })
|
|
115
|
+
self.class.new(duplicate)
|
|
116
|
+
end
|
|
117
|
+
|
|
118
|
+
def ground(snapshot)
|
|
119
|
+
kept = scenarios.select { |scenario| grounded?(scenario, snapshot) }
|
|
120
|
+
return self if kept.size == scenarios.size
|
|
121
|
+
return nil if kept.empty?
|
|
122
|
+
|
|
123
|
+
self.class.new(to_h.merge("scenarios" => kept.map { |scenario| scenario.to_h.transform_keys(&:to_s) }))
|
|
124
|
+
end
|
|
125
|
+
|
|
126
|
+
def self.merge(primary, extra)
|
|
127
|
+
return extra if primary.nil?
|
|
128
|
+
return primary if extra.nil?
|
|
129
|
+
|
|
130
|
+
names = primary.scenarios.map { |scenario| scenario.name.to_s.downcase }
|
|
131
|
+
added = extra.scenarios.reject { |scenario| names.include?(scenario.name.to_s.downcase) }
|
|
132
|
+
return primary if added.empty?
|
|
133
|
+
|
|
134
|
+
scenarios = primary.to_h["scenarios"] + added.map { |scenario| scenario.to_h.transform_keys(&:to_s) }
|
|
135
|
+
new(primary.to_h.merge("scenarios" => scenarios))
|
|
136
|
+
end
|
|
137
|
+
|
|
138
|
+
def self.stabilize(plan, snapshot)
|
|
139
|
+
base = SnapshotPlan.build(snapshot)
|
|
140
|
+
grounded = plan&.ground(snapshot)
|
|
141
|
+
merge(base, grounded) || grounded || base
|
|
142
|
+
end
|
|
143
|
+
|
|
144
|
+
private
|
|
145
|
+
|
|
146
|
+
def normalize(data)
|
|
147
|
+
original = data
|
|
148
|
+
data = unwrap(data)
|
|
149
|
+
raise InvalidPlan, self.class.unreadable(original) unless data.is_a?(Hash)
|
|
150
|
+
|
|
151
|
+
stringify(data)
|
|
152
|
+
rescue JSON::ParserError
|
|
153
|
+
raise InvalidPlan, self.class.unreadable(original)
|
|
154
|
+
end
|
|
155
|
+
|
|
156
|
+
def unwrap(data)
|
|
157
|
+
return data if data.is_a?(Hash)
|
|
158
|
+
|
|
159
|
+
if data.is_a?(Array)
|
|
160
|
+
match = data.find { |item| item.is_a?(Hash) && (item.key?("scenarios") || item.key?(:scenarios)) }
|
|
161
|
+
return match if match
|
|
162
|
+
return data.first if data.first.is_a?(Hash)
|
|
163
|
+
end
|
|
164
|
+
|
|
165
|
+
return unwrap_json(data) if data.is_a?(String)
|
|
166
|
+
|
|
167
|
+
data
|
|
168
|
+
end
|
|
169
|
+
|
|
170
|
+
def unwrap_json(raw)
|
|
171
|
+
text = raw.to_s.strip
|
|
172
|
+
fenced = text[/```(?:json)?\s*(\{.*?\})\s*```/m, 1]
|
|
173
|
+
text = fenced if fenced
|
|
174
|
+
parse_json_object(text)
|
|
175
|
+
end
|
|
176
|
+
|
|
177
|
+
def parse_json_object(text)
|
|
178
|
+
text = text.to_s.strip
|
|
179
|
+
return JSON.parse(text) if text.start_with?("{")
|
|
180
|
+
|
|
181
|
+
start = text.index("{")
|
|
182
|
+
raise JSON::ParserError, "no JSON object found" unless start
|
|
183
|
+
|
|
184
|
+
JSON.parse(extract_balanced_object(text, start))
|
|
185
|
+
end
|
|
186
|
+
|
|
187
|
+
def extract_balanced_object(text, start)
|
|
188
|
+
depth = 0
|
|
189
|
+
in_string = false
|
|
190
|
+
escape = false
|
|
191
|
+
|
|
192
|
+
text[start..].each_char.with_index do |char, index|
|
|
193
|
+
if in_string
|
|
194
|
+
if escape
|
|
195
|
+
escape = false
|
|
196
|
+
elsif char == "\\"
|
|
197
|
+
escape = true
|
|
198
|
+
elsif char == '"'
|
|
199
|
+
in_string = false
|
|
200
|
+
end
|
|
201
|
+
next
|
|
202
|
+
end
|
|
203
|
+
|
|
204
|
+
in_string = true if char == '"'
|
|
205
|
+
depth += 1 if char == "{"
|
|
206
|
+
depth -= 1 if char == "}"
|
|
207
|
+
return text[start, index + 1] if depth.zero?
|
|
208
|
+
end
|
|
209
|
+
|
|
210
|
+
raise JSON::ParserError, "unbalanced JSON object"
|
|
211
|
+
end
|
|
212
|
+
|
|
213
|
+
def stringify(value)
|
|
214
|
+
case value
|
|
215
|
+
when Hash
|
|
216
|
+
value.each_with_object({}) { |(key, item), memo| memo[key.to_s] = stringify(item) }
|
|
217
|
+
when Array
|
|
218
|
+
value.map { |item| stringify(item) }
|
|
219
|
+
else
|
|
220
|
+
value
|
|
221
|
+
end
|
|
222
|
+
end
|
|
223
|
+
|
|
224
|
+
def build_scenario(raw)
|
|
225
|
+
raw = stringify(raw)
|
|
226
|
+
Scenario.new(
|
|
227
|
+
name: raw.fetch("name"),
|
|
228
|
+
category: raw.fetch("category"),
|
|
229
|
+
priority: raw["priority"] || "medium",
|
|
230
|
+
setup: Array(raw["setup"]),
|
|
231
|
+
expected_behavior: raw.fetch("expected_behavior"),
|
|
232
|
+
attribute: raw["attribute"],
|
|
233
|
+
value: raw["value"],
|
|
234
|
+
association: raw["association"],
|
|
235
|
+
association_macro: raw["association_macro"]
|
|
236
|
+
)
|
|
237
|
+
rescue KeyError => error
|
|
238
|
+
raise InvalidPlan, "scenario is missing #{error.key}"
|
|
239
|
+
end
|
|
240
|
+
|
|
241
|
+
DEVISE_EMAIL = /\A[^@\s]+@[^@\s]+\z/.freeze
|
|
242
|
+
|
|
243
|
+
def grounded?(scenario, snapshot)
|
|
244
|
+
return false if invented_association?(scenario, snapshot)
|
|
245
|
+
return false if invented_attribute?(scenario, snapshot)
|
|
246
|
+
return false if unfounded_invalid?(scenario, snapshot)
|
|
247
|
+
|
|
248
|
+
true
|
|
249
|
+
end
|
|
250
|
+
|
|
251
|
+
def invented_association?(scenario, snapshot)
|
|
252
|
+
return false unless scenario.category == "association"
|
|
253
|
+
|
|
254
|
+
name = scenario.association.to_s
|
|
255
|
+
name.empty? || !snapshot.association_names.include?(name) || haystack(scenario).match?(/no associations?\b/i)
|
|
256
|
+
end
|
|
257
|
+
|
|
258
|
+
def invented_attribute?(scenario, snapshot)
|
|
259
|
+
return false unless %w[validation edge_case].include?(scenario.category.to_s)
|
|
260
|
+
|
|
261
|
+
attribute = scenario.attribute.to_s
|
|
262
|
+
!attribute.empty? && !snapshot.known_attributes.include?(attribute)
|
|
263
|
+
end
|
|
264
|
+
|
|
265
|
+
def unfounded_invalid?(scenario, snapshot)
|
|
266
|
+
return false unless invalid_expectation?(scenario)
|
|
267
|
+
|
|
268
|
+
text = haystack(scenario)
|
|
269
|
+
value = scenario.value.to_s
|
|
270
|
+
attribute = inferred_attribute(scenario, snapshot)
|
|
271
|
+
|
|
272
|
+
return false if text.match?(/unique|uniqueness|duplicate/i)
|
|
273
|
+
return true if text.match?(/no associations?\b/i)
|
|
274
|
+
return true if missing_confirmation?(snapshot, attribute, text, value)
|
|
275
|
+
return true if whitespace_email?(snapshot, attribute, text, value)
|
|
276
|
+
return true if whitespace_password?(attribute, text)
|
|
277
|
+
return true if devise_allows_email?(snapshot, attribute, text, value)
|
|
278
|
+
return true if invented_constraint?(snapshot, attribute, text)
|
|
279
|
+
|
|
280
|
+
false
|
|
281
|
+
end
|
|
282
|
+
|
|
283
|
+
def inferred_attribute(scenario, snapshot)
|
|
284
|
+
return scenario.attribute.to_s unless scenario.attribute.to_s.empty?
|
|
285
|
+
|
|
286
|
+
text = haystack(scenario)
|
|
287
|
+
snapshot.known_attributes.find { |name| text.match?(/\b#{Regexp.escape(name)}\b/i) }.to_s
|
|
288
|
+
end
|
|
289
|
+
|
|
290
|
+
COMPLEXITY_RE = /special[_\s-]*character|uppercase|lowercase|(?<![a-z])digit|symbol|complexity|strong[_\s-]*password|without[_\s-]*special/i
|
|
291
|
+
|
|
292
|
+
def invented_constraint?(snapshot, attribute, text)
|
|
293
|
+
return false if attribute.to_s.empty?
|
|
294
|
+
|
|
295
|
+
validators = snapshot.validators_for(attribute)
|
|
296
|
+
return true if text.match?(COMPLEXITY_RE) && !validators.include?("format")
|
|
297
|
+
return true if attribute == "password" && snapshot.devise? &&
|
|
298
|
+
text.match?(/format|pattern|regex/i) && !validators.include?("format")
|
|
299
|
+
|
|
300
|
+
false
|
|
301
|
+
end
|
|
302
|
+
|
|
303
|
+
def invalid_expectation?(scenario)
|
|
304
|
+
expected = scenario.expected_behavior.to_s
|
|
305
|
+
expected.match?(/invalid|not[_ ]valid/i)
|
|
306
|
+
end
|
|
307
|
+
|
|
308
|
+
def missing_confirmation?(snapshot, attribute, text, value)
|
|
309
|
+
return false unless snapshot.devise?
|
|
310
|
+
confirmation = attribute.end_with?("_confirmation") || text.match?(/password confirmation|password_confirmation/i)
|
|
311
|
+
return false unless confirmation
|
|
312
|
+
return false if text.match?(/mismatch|does not match|different/i)
|
|
313
|
+
|
|
314
|
+
value.empty? || value == "nil" || text.match?(/without|missing|blank|empty/i)
|
|
315
|
+
end
|
|
316
|
+
|
|
317
|
+
def whitespace_email?(snapshot, attribute, text, value)
|
|
318
|
+
return false unless snapshot.devise?
|
|
319
|
+
return false unless attribute == "email" || text.match?(/\bemail\b/i)
|
|
320
|
+
|
|
321
|
+
text.match?(/leading whitespace|trailing whitespace|whitespace in email/i) || value.match?(/\A\s|\s\z/)
|
|
322
|
+
end
|
|
323
|
+
|
|
324
|
+
def whitespace_password?(attribute, text)
|
|
325
|
+
(attribute == "password" || text.match?(/\bpassword\b/i)) &&
|
|
326
|
+
text.match?(/leading whitespace|trailing whitespace|whitespace in password/i)
|
|
327
|
+
end
|
|
328
|
+
|
|
329
|
+
def devise_allows_email?(snapshot, attribute, text, value)
|
|
330
|
+
return false unless snapshot.devise?
|
|
331
|
+
return false unless attribute == "email" || text.match?(/\bemail\b/i)
|
|
332
|
+
return false if value.empty? || value == "nil"
|
|
333
|
+
return false if text.match?(/unique|uniqueness|duplicate/i)
|
|
334
|
+
|
|
335
|
+
value.match?(DEVISE_EMAIL)
|
|
336
|
+
end
|
|
337
|
+
|
|
338
|
+
def haystack(scenario)
|
|
339
|
+
[scenario.name, scenario.expected_behavior, scenario.attribute, scenario.value, *Array(scenario.setup)].join(" ")
|
|
340
|
+
end
|
|
341
|
+
end
|
|
342
|
+
end
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "rcrewai"
|
|
4
|
+
|
|
5
|
+
module SpecAi
|
|
6
|
+
module Tools
|
|
7
|
+
class ListRelatedFiles < RCrewAI::Tools::Base
|
|
8
|
+
tool_name "list_related_files"
|
|
9
|
+
description "List model, spec, factory, and schema paths related to a Rails model"
|
|
10
|
+
param :class_name, type: :string, required: true, description: "Model class name, e.g. User"
|
|
11
|
+
|
|
12
|
+
def initialize(sandbox: SpecAi::Sandbox.new)
|
|
13
|
+
@sandbox = sandbox
|
|
14
|
+
super()
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
def execute(class_name:)
|
|
18
|
+
target = Target.new(class_name, app_root: @sandbox.root)
|
|
19
|
+
snapshot = Context::Builder.new(target, sandbox: @sandbox).build
|
|
20
|
+
lines = [
|
|
21
|
+
target.model_path,
|
|
22
|
+
target.spec_path,
|
|
23
|
+
snapshot.schema[:path],
|
|
24
|
+
snapshot.factories[:path]
|
|
25
|
+
]
|
|
26
|
+
snapshot.related.each { |item| lines << item[:path] }
|
|
27
|
+
lines.compact.uniq.join("\n")
|
|
28
|
+
rescue SpecAi::Error => error
|
|
29
|
+
"ERROR: #{error.message}"
|
|
30
|
+
end
|
|
31
|
+
end
|
|
32
|
+
end
|
|
33
|
+
end
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "rcrewai"
|
|
4
|
+
|
|
5
|
+
module SpecAi
|
|
6
|
+
module Tools
|
|
7
|
+
class ReadAppFile < RCrewAI::Tools::Base
|
|
8
|
+
tool_name "read_app_file"
|
|
9
|
+
description "Read a file inside the Rails app (app/, spec/, db/, config/, test/)"
|
|
10
|
+
param :path, type: :string, required: true, description: "Relative path from the application root"
|
|
11
|
+
|
|
12
|
+
def initialize(sandbox: SpecAi::Sandbox.new)
|
|
13
|
+
@sandbox = sandbox
|
|
14
|
+
super()
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
def execute(path:)
|
|
18
|
+
@sandbox.read(path)
|
|
19
|
+
rescue SpecAi::Error => error
|
|
20
|
+
"ERROR: #{error.message}"
|
|
21
|
+
end
|
|
22
|
+
end
|
|
23
|
+
end
|
|
24
|
+
end
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "rcrewai"
|
|
4
|
+
|
|
5
|
+
module SpecAi
|
|
6
|
+
module Tools
|
|
7
|
+
class ReadExistingSpec < RCrewAI::Tools::Base
|
|
8
|
+
tool_name "read_existing_spec"
|
|
9
|
+
description "Read the existing RSpec file for a model if it exists"
|
|
10
|
+
param :class_name, type: :string, required: true, description: "Model class name, e.g. User"
|
|
11
|
+
|
|
12
|
+
def initialize(sandbox: SpecAi::Sandbox.new)
|
|
13
|
+
@sandbox = sandbox
|
|
14
|
+
super()
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
def execute(class_name:)
|
|
18
|
+
target = Target.new(class_name, app_root: @sandbox.root)
|
|
19
|
+
result = Analyzers::ExistingSpecs.new(target, sandbox: @sandbox).analyze
|
|
20
|
+
return "No existing spec at #{result[:path]}" unless result[:present]
|
|
21
|
+
|
|
22
|
+
"#{result[:path]}\n#{result[:excerpt]}"
|
|
23
|
+
rescue SpecAi::Error => error
|
|
24
|
+
"ERROR: #{error.message}"
|
|
25
|
+
end
|
|
26
|
+
end
|
|
27
|
+
end
|
|
28
|
+
end
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "rcrewai"
|
|
4
|
+
|
|
5
|
+
module SpecAi
|
|
6
|
+
module Tools
|
|
7
|
+
class ReadFactory < RCrewAI::Tools::Base
|
|
8
|
+
tool_name "read_factory"
|
|
9
|
+
description "Read a FactoryBot factory for a model"
|
|
10
|
+
param :class_name, type: :string, required: true, description: "Model class name, e.g. User"
|
|
11
|
+
|
|
12
|
+
def initialize(sandbox: SpecAi::Sandbox.new)
|
|
13
|
+
@sandbox = sandbox
|
|
14
|
+
super()
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
def execute(class_name:)
|
|
18
|
+
target = Target.new(class_name, app_root: @sandbox.root)
|
|
19
|
+
result = Analyzers::Factories.new(target, sandbox: @sandbox).analyze
|
|
20
|
+
return "No factory found for #{class_name}" unless result[:present]
|
|
21
|
+
|
|
22
|
+
"#{result[:path]} (factory :#{result[:name]})\n#{result[:excerpt]}"
|
|
23
|
+
rescue SpecAi::Error => error
|
|
24
|
+
"ERROR: #{error.message}"
|
|
25
|
+
end
|
|
26
|
+
end
|
|
27
|
+
end
|
|
28
|
+
end
|