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,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
@@ -0,0 +1,5 @@
1
+ # frozen_string_literal: true
2
+
3
+ module SpecAi
4
+ VERSION = "0.1.0"
5
+ 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