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