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,26 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "rcrewai"
|
|
4
|
+
|
|
5
|
+
module SpecAi
|
|
6
|
+
module Tools
|
|
7
|
+
class ReadSchemaTable < RCrewAI::Tools::Base
|
|
8
|
+
tool_name "read_schema_table"
|
|
9
|
+
description "Read the create_table excerpt for a table from db/schema.rb"
|
|
10
|
+
param :table_name, type: :string, required: true, description: "Database table name, e.g. users"
|
|
11
|
+
|
|
12
|
+
def initialize(sandbox: SpecAi::Sandbox.new)
|
|
13
|
+
@sandbox = sandbox
|
|
14
|
+
super()
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
def execute(table_name:)
|
|
18
|
+
dummy = Struct.new(:table_name, :class_name, :factory_name).new(table_name, nil, nil)
|
|
19
|
+
result = Analyzers::Schema.new(dummy, sandbox: @sandbox).analyze
|
|
20
|
+
result[:excerpt] || "No schema excerpt found for #{table_name}"
|
|
21
|
+
rescue SpecAi::Error => error
|
|
22
|
+
"ERROR: #{error.message}"
|
|
23
|
+
end
|
|
24
|
+
end
|
|
25
|
+
end
|
|
26
|
+
end
|
data/lib/spec_ai/ui.rb
ADDED
|
@@ -0,0 +1,469 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module SpecAi
|
|
4
|
+
class UI
|
|
5
|
+
CHECK = "✓"
|
|
6
|
+
WARN = "⚠"
|
|
7
|
+
EMPTY = "·"
|
|
8
|
+
FAIL = "✗"
|
|
9
|
+
SPINNER = %w[⠋ ⠙ ⠹ ⠸ ⠼ ⠴ ⠦ ⠧ ⠇ ⠏].freeze
|
|
10
|
+
|
|
11
|
+
def initialize(io: $stdout, verbose: SpecAi.configuration.verbose, color: nil)
|
|
12
|
+
@io = io
|
|
13
|
+
@verbose = verbose
|
|
14
|
+
@color = color.nil? ? default_color? : color
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
def header
|
|
18
|
+
cfg = SpecAi.configuration
|
|
19
|
+
say "#{magenta('✨')} #{paint('1;95', 'Spec AI')}"
|
|
20
|
+
say " #{dim("v#{VERSION}")} #{dim('·')} #{cyan("#{cfg.provider} / #{cfg.model}")}"
|
|
21
|
+
say ""
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
def analyzing(name)
|
|
25
|
+
say "#{cyan('🔍')} #{bold('Analyze')} #{bold(name)}"
|
|
26
|
+
say ""
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
def context_collected(snapshot)
|
|
30
|
+
status_line(true, "model", snapshot.target.model_path)
|
|
31
|
+
|
|
32
|
+
if snapshot.schema[:excerpt]
|
|
33
|
+
status_line(true, "schema", snapshot.schema[:table] || snapshot.target.table_name)
|
|
34
|
+
else
|
|
35
|
+
status_line(false, "schema", "none found")
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
associations = Array(snapshot.model[:associations])
|
|
39
|
+
if associations.any?
|
|
40
|
+
names = associations.map { |item| hash_value(item, :name) }.compact.join(", ")
|
|
41
|
+
status_line(true, "associations", names)
|
|
42
|
+
else
|
|
43
|
+
status_line(:empty, "associations", "none")
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
if snapshot.has_factory?
|
|
47
|
+
status_line(true, "factory", ":#{snapshot.factories[:name]}")
|
|
48
|
+
else
|
|
49
|
+
status_line(false, "factory", "none found")
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
if snapshot.existing_spec[:present]
|
|
53
|
+
status_line(true, "existing spec", snapshot.existing_spec[:path])
|
|
54
|
+
else
|
|
55
|
+
status_line(false, "existing spec", "will create #{snapshot.target.spec_path}")
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
if snapshot.routes[:excerpt]
|
|
59
|
+
status_line(true, "routes", first_line(snapshot.routes[:excerpt]))
|
|
60
|
+
else
|
|
61
|
+
status_line(:empty, "routes", "none")
|
|
62
|
+
end
|
|
63
|
+
say ""
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
def spin_planning
|
|
67
|
+
cfg = SpecAi.configuration
|
|
68
|
+
spin("Planning tests", hint: "#{cfg.provider} / #{cfg.model}") { yield }
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
def spin_repairing(attempt, max)
|
|
72
|
+
spin("Repairing spec", hint: "#{attempt} / #{max}") { yield }
|
|
73
|
+
end
|
|
74
|
+
|
|
75
|
+
def spin_running
|
|
76
|
+
spin("Running RSpec") { yield }
|
|
77
|
+
end
|
|
78
|
+
|
|
79
|
+
def spin(message, hint: nil)
|
|
80
|
+
started = monotonic
|
|
81
|
+
label = spin_label(message, hint)
|
|
82
|
+
|
|
83
|
+
unless tty?
|
|
84
|
+
say "#{cyan('⠋')} #{label}..."
|
|
85
|
+
result = yield
|
|
86
|
+
@last_elapsed = monotonic - started
|
|
87
|
+
say ""
|
|
88
|
+
return result
|
|
89
|
+
end
|
|
90
|
+
|
|
91
|
+
stop = false
|
|
92
|
+
index = 0
|
|
93
|
+
thread = Thread.new do
|
|
94
|
+
until stop
|
|
95
|
+
elapsed = format_elapsed(monotonic - started)
|
|
96
|
+
@io.print "\r\e[2K#{cyan(SPINNER[index % SPINNER.length])} #{label} #{dim(elapsed)}"
|
|
97
|
+
@io.flush
|
|
98
|
+
index += 1
|
|
99
|
+
sleep 0.08
|
|
100
|
+
end
|
|
101
|
+
end
|
|
102
|
+
|
|
103
|
+
yield
|
|
104
|
+
ensure
|
|
105
|
+
@last_elapsed = monotonic - started if started
|
|
106
|
+
stop = true
|
|
107
|
+
thread&.join
|
|
108
|
+
clear_line if thread && tty?
|
|
109
|
+
end
|
|
110
|
+
|
|
111
|
+
def llm_fallback(notice, preview: nil)
|
|
112
|
+
say " #{yellow('⚠')} #{notice}"
|
|
113
|
+
say " #{dim(preview)}" if preview && !preview.to_s.empty?
|
|
114
|
+
say " #{dim('Using a plan from the model schema and validations instead.')}"
|
|
115
|
+
say ""
|
|
116
|
+
end
|
|
117
|
+
|
|
118
|
+
def generating(plan)
|
|
119
|
+
count = plan.scenarios.size
|
|
120
|
+
categories = plan.scenarios.map(&:category).uniq.map { |item| item.to_s.tr("_", " ") }
|
|
121
|
+
say " #{cyan('🧠')} Planned #{bold(count.to_s)} #{plural(count, 'scenario')}#{elapsed_suffix}"
|
|
122
|
+
say " #{dim(categories.join(' · '))}" unless categories.empty?
|
|
123
|
+
say ""
|
|
124
|
+
end
|
|
125
|
+
|
|
126
|
+
def writing(path)
|
|
127
|
+
say " #{cyan('📝')} Wrote #{cyan(path.to_s)}"
|
|
128
|
+
say ""
|
|
129
|
+
end
|
|
130
|
+
|
|
131
|
+
def merging(path)
|
|
132
|
+
say " #{cyan('📝')} Added examples to #{cyan(path.to_s)}"
|
|
133
|
+
say " #{dim('Existing examples were kept. Pass --force to replace the spec.')}"
|
|
134
|
+
say ""
|
|
135
|
+
end
|
|
136
|
+
|
|
137
|
+
def dropped_unfounded(names)
|
|
138
|
+
list = Array(names)
|
|
139
|
+
return if list.empty?
|
|
140
|
+
|
|
141
|
+
say " #{yellow('⚠')} Dropped #{bold(list.size.to_s)} #{plural(list.size, 'example')} the model does not enforce"
|
|
142
|
+
list.first(8).each { |name| say " #{dim(name)}" }
|
|
143
|
+
say ""
|
|
144
|
+
end
|
|
145
|
+
|
|
146
|
+
def setup_actions(actions)
|
|
147
|
+
return if Array(actions).empty?
|
|
148
|
+
|
|
149
|
+
Array(actions).each do |action|
|
|
150
|
+
case action.kind
|
|
151
|
+
when :created
|
|
152
|
+
say " #{cyan('📦')} Created #{cyan(action.path)}"
|
|
153
|
+
say " #{dim(action.message)}" if action.message
|
|
154
|
+
when :updated
|
|
155
|
+
say " #{cyan('📦')} Updated #{cyan(action.path)}"
|
|
156
|
+
say " #{dim(action.message)}" if action.message
|
|
157
|
+
else
|
|
158
|
+
say " #{yellow('⚠')} #{action.message}"
|
|
159
|
+
end
|
|
160
|
+
end
|
|
161
|
+
say ""
|
|
162
|
+
end
|
|
163
|
+
|
|
164
|
+
def running
|
|
165
|
+
say " #{cyan('🧪')} #{bold('Run')} RSpec"
|
|
166
|
+
say ""
|
|
167
|
+
end
|
|
168
|
+
|
|
169
|
+
def rspec_result(result)
|
|
170
|
+
report = result.report
|
|
171
|
+
mark = result.passed? ? green(report.summary) : red(report.summary)
|
|
172
|
+
say " #{result.passed? ? '🧪' : '❌'} #{mark}#{elapsed_suffix}"
|
|
173
|
+
print_failures(report) if result.failed?
|
|
174
|
+
say ""
|
|
175
|
+
end
|
|
176
|
+
|
|
177
|
+
def repairing(attempt, max)
|
|
178
|
+
say " #{yellow('🔧')} Repair #{attempt}/#{max}"
|
|
179
|
+
say ""
|
|
180
|
+
end
|
|
181
|
+
|
|
182
|
+
def completed(success, result: nil)
|
|
183
|
+
if success
|
|
184
|
+
say " #{green('✅')} #{paint('1;92', 'Done')}"
|
|
185
|
+
return
|
|
186
|
+
end
|
|
187
|
+
|
|
188
|
+
count = result&.report&.failures&.size
|
|
189
|
+
label = count&.positive? ? "Finished with #{count} #{plural(count, 'failure')}" : "Finished with failures"
|
|
190
|
+
say " #{red('❌')} #{paint('1;91', label)}"
|
|
191
|
+
if result
|
|
192
|
+
say " Spec: #{result.spec_path}"
|
|
193
|
+
say " Re-run: bundle exec rspec #{result.spec_path}"
|
|
194
|
+
say " Repair: bundle exec spec-ai fix #{result.spec_path}"
|
|
195
|
+
end
|
|
196
|
+
end
|
|
197
|
+
|
|
198
|
+
def already_passing(path)
|
|
199
|
+
say " #{green('✅')} #{path} is already passing"
|
|
200
|
+
end
|
|
201
|
+
|
|
202
|
+
def snapshot(snapshot)
|
|
203
|
+
section("Target", "#{bold(snapshot.target.class_name)} #{dim(snapshot.target.model_path)}")
|
|
204
|
+
section("Table", snapshot.model[:table_name].to_s)
|
|
205
|
+
say ""
|
|
206
|
+
|
|
207
|
+
heading("Source")
|
|
208
|
+
say indent(dim(snapshot.model[:source].to_s.rstrip))
|
|
209
|
+
say ""
|
|
210
|
+
|
|
211
|
+
heading("Validations")
|
|
212
|
+
say format_validations(snapshot.model[:validations])
|
|
213
|
+
say ""
|
|
214
|
+
|
|
215
|
+
heading("Associations")
|
|
216
|
+
say format_associations(snapshot.model[:associations])
|
|
217
|
+
say ""
|
|
218
|
+
|
|
219
|
+
scopes = Array(snapshot.model[:scopes])
|
|
220
|
+
section("Scopes", scopes.any? ? scopes.join(", ") : dim("none"))
|
|
221
|
+
enums = Array(snapshot.model[:enums])
|
|
222
|
+
section("Enums", enums.any? ? enums.join(", ") : dim("none"))
|
|
223
|
+
say ""
|
|
224
|
+
|
|
225
|
+
if snapshot.schema[:excerpt]
|
|
226
|
+
heading("Schema", snapshot.schema[:path].to_s)
|
|
227
|
+
say indent(dim(snapshot.schema[:excerpt].to_s.rstrip))
|
|
228
|
+
say ""
|
|
229
|
+
end
|
|
230
|
+
|
|
231
|
+
heading("Factory")
|
|
232
|
+
if snapshot.has_factory?
|
|
233
|
+
say " #{cyan(":#{snapshot.factories[:name]}")} #{dim(snapshot.factories[:path].to_s)}"
|
|
234
|
+
say indent(dim(snapshot.factories[:excerpt].to_s.rstrip))
|
|
235
|
+
else
|
|
236
|
+
say " #{dim('none found')}"
|
|
237
|
+
end
|
|
238
|
+
say ""
|
|
239
|
+
|
|
240
|
+
heading("Existing spec")
|
|
241
|
+
if snapshot.existing_spec[:present]
|
|
242
|
+
say " #{snapshot.existing_spec[:path]}"
|
|
243
|
+
else
|
|
244
|
+
say " #{dim("none — will create #{snapshot.target.spec_path}")}"
|
|
245
|
+
end
|
|
246
|
+
say ""
|
|
247
|
+
|
|
248
|
+
if snapshot.routes[:excerpt]
|
|
249
|
+
heading("Routes")
|
|
250
|
+
say indent(snapshot.routes[:excerpt].to_s.rstrip)
|
|
251
|
+
say ""
|
|
252
|
+
end
|
|
253
|
+
|
|
254
|
+
Array(snapshot.related).each do |item|
|
|
255
|
+
heading("Related", "#{item[:class_name]} #{item[:path]}")
|
|
256
|
+
say indent(dim(item[:excerpt].to_s.rstrip))
|
|
257
|
+
say ""
|
|
258
|
+
end
|
|
259
|
+
end
|
|
260
|
+
|
|
261
|
+
def roadmap(command, version)
|
|
262
|
+
say "#{yellow('🚧')} spec-ai #{command} is planned for #{bold(version)}. This MVP supports generate, fix, and analyze."
|
|
263
|
+
end
|
|
264
|
+
|
|
265
|
+
def skip_repair(message)
|
|
266
|
+
say " #{yellow('⚠')} Repair skipped"
|
|
267
|
+
say " #{message}"
|
|
268
|
+
say ""
|
|
269
|
+
end
|
|
270
|
+
|
|
271
|
+
def error(message)
|
|
272
|
+
lines = message.to_s.strip.lines.map(&:rstrip)
|
|
273
|
+
say " #{red('❌')} #{paint('1;91', lines.first)}"
|
|
274
|
+
lines.drop(1).each do |line|
|
|
275
|
+
say(line.empty? ? "" : " #{line}")
|
|
276
|
+
end
|
|
277
|
+
say ""
|
|
278
|
+
end
|
|
279
|
+
|
|
280
|
+
def unexpected_error(error)
|
|
281
|
+
say " #{red('❌')} #{paint('1;91', 'Unexpected error')} #{error.class}"
|
|
282
|
+
say " #{explain_exception(error)}"
|
|
283
|
+
say " #{error.message}" unless explain_exception(error) == error.message
|
|
284
|
+
say ""
|
|
285
|
+
end
|
|
286
|
+
|
|
287
|
+
private
|
|
288
|
+
|
|
289
|
+
def spin_label(message, hint)
|
|
290
|
+
return message unless hint
|
|
291
|
+
|
|
292
|
+
"#{message} #{dim('·')} #{dim(hint)}"
|
|
293
|
+
end
|
|
294
|
+
|
|
295
|
+
def status_line(state, label, detail = nil)
|
|
296
|
+
mark = case state
|
|
297
|
+
when true then green(CHECK)
|
|
298
|
+
when false then yellow(WARN)
|
|
299
|
+
else dim(EMPTY)
|
|
300
|
+
end
|
|
301
|
+
line = " #{mark} #{label.ljust(14)}"
|
|
302
|
+
line += " #{dim(detail)}" if detail
|
|
303
|
+
say line
|
|
304
|
+
end
|
|
305
|
+
|
|
306
|
+
def section(title, value)
|
|
307
|
+
say " #{cyan(title.ljust(12))} #{value}"
|
|
308
|
+
end
|
|
309
|
+
|
|
310
|
+
def heading(title, detail = nil)
|
|
311
|
+
line = " #{paint('1;96', title)}"
|
|
312
|
+
line += " #{dim(detail)}" if detail
|
|
313
|
+
say line
|
|
314
|
+
end
|
|
315
|
+
|
|
316
|
+
def format_validations(items)
|
|
317
|
+
return " #{dim('none')}" if items.nil? || items.empty?
|
|
318
|
+
|
|
319
|
+
items.map do |item|
|
|
320
|
+
attrs = Array(hash_value(item, :attributes)).join(", ")
|
|
321
|
+
validators = Array(hash_value(item, :validators)).join(", ")
|
|
322
|
+
" #{bold(attrs.ljust(16))} #{dim(validators)}"
|
|
323
|
+
end.join("\n")
|
|
324
|
+
end
|
|
325
|
+
|
|
326
|
+
def format_associations(items)
|
|
327
|
+
return " #{dim('none')}" if items.nil? || items.empty?
|
|
328
|
+
|
|
329
|
+
items.map do |item|
|
|
330
|
+
name = hash_value(item, :name)
|
|
331
|
+
macro = hash_value(item, :macro)
|
|
332
|
+
extras = []
|
|
333
|
+
class_name = hash_value(item, :class_name)
|
|
334
|
+
extras << class_name if class_name && class_name.to_s != Inflector.camelize(Inflector.singularize(name.to_s))
|
|
335
|
+
opts = hash_value(item, :options) || {}
|
|
336
|
+
opts = opts.transform_keys(&:to_sym) if opts.is_a?(Hash)
|
|
337
|
+
extras << "optional" if opts[:optional]
|
|
338
|
+
extras << "dependent: #{opts[:dependent]}" if opts[:dependent]
|
|
339
|
+
suffix = extras.any? ? " #{dim(extras.join(' · '))}" : ""
|
|
340
|
+
" #{cyan(macro)} :#{name}#{suffix}"
|
|
341
|
+
end.join("\n")
|
|
342
|
+
end
|
|
343
|
+
|
|
344
|
+
def hash_value(item, key)
|
|
345
|
+
return item unless item.is_a?(Hash)
|
|
346
|
+
|
|
347
|
+
item[key] || item[key.to_s]
|
|
348
|
+
end
|
|
349
|
+
|
|
350
|
+
def first_line(text)
|
|
351
|
+
text.to_s.lines.map(&:strip).reject(&:empty?).first
|
|
352
|
+
end
|
|
353
|
+
|
|
354
|
+
def indent(text)
|
|
355
|
+
text.to_s.gsub(/^/, " ")
|
|
356
|
+
end
|
|
357
|
+
|
|
358
|
+
def plural(count, word)
|
|
359
|
+
count == 1 ? word : "#{word}s"
|
|
360
|
+
end
|
|
361
|
+
|
|
362
|
+
def elapsed_suffix
|
|
363
|
+
return "" unless @last_elapsed
|
|
364
|
+
|
|
365
|
+
" #{dim(format_elapsed(@last_elapsed))}"
|
|
366
|
+
ensure
|
|
367
|
+
@last_elapsed = nil
|
|
368
|
+
end
|
|
369
|
+
|
|
370
|
+
def format_elapsed(seconds)
|
|
371
|
+
seconds = seconds.to_f
|
|
372
|
+
return format("%.1fs", seconds) if seconds < 60
|
|
373
|
+
|
|
374
|
+
minutes = (seconds / 60).floor
|
|
375
|
+
rest = (seconds % 60).round
|
|
376
|
+
"#{minutes}m #{rest}s"
|
|
377
|
+
end
|
|
378
|
+
|
|
379
|
+
def monotonic
|
|
380
|
+
Process.clock_gettime(Process::CLOCK_MONOTONIC)
|
|
381
|
+
end
|
|
382
|
+
|
|
383
|
+
def clear_line
|
|
384
|
+
@io.print "\r\e[2K"
|
|
385
|
+
@io.flush
|
|
386
|
+
end
|
|
387
|
+
|
|
388
|
+
def say(message)
|
|
389
|
+
@io.puts(message)
|
|
390
|
+
end
|
|
391
|
+
|
|
392
|
+
def tty?
|
|
393
|
+
@io.respond_to?(:tty?) && @io.tty?
|
|
394
|
+
end
|
|
395
|
+
|
|
396
|
+
def default_color?
|
|
397
|
+
tty? && ENV["NO_COLOR"].to_s.empty? && ENV["TERM"] != "dumb"
|
|
398
|
+
end
|
|
399
|
+
|
|
400
|
+
def bold(text)
|
|
401
|
+
paint("1", text)
|
|
402
|
+
end
|
|
403
|
+
|
|
404
|
+
def dim(text)
|
|
405
|
+
paint("90", text)
|
|
406
|
+
end
|
|
407
|
+
|
|
408
|
+
def green(text)
|
|
409
|
+
paint("92", text)
|
|
410
|
+
end
|
|
411
|
+
|
|
412
|
+
def yellow(text)
|
|
413
|
+
paint("93", text)
|
|
414
|
+
end
|
|
415
|
+
|
|
416
|
+
def red(text)
|
|
417
|
+
paint("91", text)
|
|
418
|
+
end
|
|
419
|
+
|
|
420
|
+
def cyan(text)
|
|
421
|
+
paint("96", text)
|
|
422
|
+
end
|
|
423
|
+
|
|
424
|
+
def magenta(text)
|
|
425
|
+
paint("95", text)
|
|
426
|
+
end
|
|
427
|
+
|
|
428
|
+
def print_failures(report)
|
|
429
|
+
listed = @verbose ? report.failures : report.failures.first(8)
|
|
430
|
+
listed.each do |failure|
|
|
431
|
+
say " #{bold(short_example(failure.example))}"
|
|
432
|
+
say " #{dim(failure.detail)}" if failure.detail && !failure.detail.empty?
|
|
433
|
+
say " #{yellow(failure.hint)}" if failure.hint
|
|
434
|
+
say ""
|
|
435
|
+
end
|
|
436
|
+
extra = report.failures.size - listed.size
|
|
437
|
+
say " #{dim("and #{extra} more. Pass --verbose to see the full RSpec output.")}" if extra.positive?
|
|
438
|
+
say indent(dim(report.result.output.to_s.strip)) if @verbose
|
|
439
|
+
end
|
|
440
|
+
|
|
441
|
+
def short_example(example)
|
|
442
|
+
example.to_s.sub(/\A\S+\s+/, "")
|
|
443
|
+
end
|
|
444
|
+
|
|
445
|
+
def explain_exception(error)
|
|
446
|
+
case error
|
|
447
|
+
when Errno::ECONNREFUSED
|
|
448
|
+
"Could not reach the LLM server. If you use Ollama, start it with `ollama serve`."
|
|
449
|
+
when SocketError
|
|
450
|
+
"Network lookup failed. Check your connection and the provider in config/spec_ai.yml."
|
|
451
|
+
else
|
|
452
|
+
name = error.class.name
|
|
453
|
+
if name.match?(/Timeout/i)
|
|
454
|
+
"The LLM request timed out. Retry, or switch provider/model in config/spec_ai.yml."
|
|
455
|
+
elsif name.match?(/Unauthorized| Faraday::Unauthorized/i) || error.message.match?(/\b401\b/)
|
|
456
|
+
"The LLM provider rejected the request. Check the API key for this provider."
|
|
457
|
+
else
|
|
458
|
+
error.message.to_s
|
|
459
|
+
end
|
|
460
|
+
end
|
|
461
|
+
end
|
|
462
|
+
|
|
463
|
+
def paint(code, text)
|
|
464
|
+
return text.to_s unless @color
|
|
465
|
+
|
|
466
|
+
"\e[#{code}m#{text}\e[0m"
|
|
467
|
+
end
|
|
468
|
+
end
|
|
469
|
+
end
|
data/lib/spec_ai.rb
ADDED
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "json"
|
|
4
|
+
require "yaml"
|
|
5
|
+
require "fileutils"
|
|
6
|
+
require "pathname"
|
|
7
|
+
require "zeitwerk"
|
|
8
|
+
require_relative "spec_ai/version"
|
|
9
|
+
|
|
10
|
+
module SpecAi
|
|
11
|
+
class Error < StandardError; end
|
|
12
|
+
class TargetNotFound < Error; end
|
|
13
|
+
class ConfigurationError < Error; end
|
|
14
|
+
class InvalidPlan < Error; end
|
|
15
|
+
class PathNotAllowed < Error; end
|
|
16
|
+
|
|
17
|
+
class << self
|
|
18
|
+
def configuration
|
|
19
|
+
@configuration ||= Configuration.new
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
def configure
|
|
23
|
+
yield configuration
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
def reset_configuration!
|
|
27
|
+
@configuration = Configuration.new
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
def boot_rails!(root = configuration.app_root)
|
|
31
|
+
return if rails_booted?
|
|
32
|
+
|
|
33
|
+
env = File.join(root, "config", "environment.rb")
|
|
34
|
+
return unless File.file?(env)
|
|
35
|
+
|
|
36
|
+
require env
|
|
37
|
+
rescue LoadError, StandardError
|
|
38
|
+
nil
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
def rails_booted?
|
|
42
|
+
defined?(::Rails) && ::Rails.respond_to?(:application) && !::Rails.application.nil?
|
|
43
|
+
end
|
|
44
|
+
end
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
loader = Zeitwerk::Loader.for_gem
|
|
48
|
+
loader.inflector.inflect(
|
|
49
|
+
"cli" => "CLI",
|
|
50
|
+
"ui" => "UI"
|
|
51
|
+
)
|
|
52
|
+
loader.ignore("#{__dir__}/spec_ai/version.rb")
|
|
53
|
+
loader.ignore("#{__dir__}/generators")
|
|
54
|
+
loader.ignore("#{__dir__}/tasks")
|
|
55
|
+
loader.setup
|
|
56
|
+
|
|
57
|
+
require "spec_ai/railtie" if defined?(Rails::Railtie)
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
namespace :spec_ai do
|
|
4
|
+
desc "Generate RSpec tests for a Rails model (e.g. rails spec_ai:generate[User])"
|
|
5
|
+
task :generate, [:target] => :environment do |_task, args|
|
|
6
|
+
abort "Usage: rails spec_ai:generate[User]" if args[:target].to_s.strip.empty?
|
|
7
|
+
|
|
8
|
+
SpecAi.configuration.app_root = Rails.root.to_s if defined?(Rails) && Rails.respond_to?(:root)
|
|
9
|
+
SpecAi::CLI.start(["generate", args[:target]])
|
|
10
|
+
end
|
|
11
|
+
|
|
12
|
+
desc "Repair a failing spec with Spec AI"
|
|
13
|
+
task :fix, [:path] => :environment do |_task, args|
|
|
14
|
+
abort "Usage: rails spec_ai:fix[spec/models/user_spec.rb]" if args[:path].to_s.strip.empty?
|
|
15
|
+
|
|
16
|
+
SpecAi.configuration.app_root = Rails.root.to_s if defined?(Rails) && Rails.respond_to?(:root)
|
|
17
|
+
SpecAi::CLI.start(["fix", args[:path]])
|
|
18
|
+
end
|
|
19
|
+
end
|