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
checksums.yaml ADDED
@@ -0,0 +1,7 @@
1
+ ---
2
+ SHA256:
3
+ metadata.gz: 01a7c9667ce149d1f966604521278c69b09ecba63493b3763afd299604cf6814
4
+ data.tar.gz: d99d5769469e4b638cc9f592bfa90cc36658bbd3274dce88dd884c6593ef7ab3
5
+ SHA512:
6
+ metadata.gz: d087b04a2b130b94b097e438d88fe70084287acbfe23753b6eca495d737ec4c9c51425065bcd9b231b54580a68347153f7d976fe0274a8d732ff77d81d9a7990
7
+ data.tar.gz: 74f124d98ab7fe42fb08bdf3da092d26d110d8cc8d9e9af938bfd22c996bb4f4e264c421c06c598bce5922b15ea5f84ab858d8eb1345282da3ca0f4032582c3b
data/CHANGELOG.md ADDED
@@ -0,0 +1,13 @@
1
+ # Changelog
2
+
3
+ ## 0.1.0
4
+
5
+ - Initial release: generate model specs from Rails context, run RSpec, and repair failures with an RCrewAI crew.
6
+ - Terminal UI: timed loaders, color, and a few phase emojis. RCrewAI/HTTP logs stay hidden unless `--verbose`.
7
+ - Stop the repair loop when RSpec is missing from the application bundle and print install instructions.
8
+ - Present RSpec failures in plain language (what broke, why, how to re-run) instead of dumping the raw log.
9
+ - Create a missing FactoryBot factory (and add `factory_bot_rails` to the Gemfile when needed), then tell the user what changed.
10
+ - Create missing `spec/rails_helper.rb` and `spec/spec_helper.rb` when the spec folder was deleted, and show the load error instead of a bare `1 error`.
11
+ - If the LLM returns no JSON test plan, explain the reply and generate from the model schema and validations instead.
12
+ - Replace the model spec on each generate (pass `--merge` to append). Drop examples that assert Devise behavior the model does not enforce.
13
+ - Ruby, not the LLM, decides which examples to keep: a deterministic plan from schema/validations is the base, and invented rules (password special characters, extra email formats) are dropped.
data/LICENSE.txt ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Karthick Nagarajan
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
data/README.md ADDED
@@ -0,0 +1,212 @@
1
+ # 🤖 Spec AI
2
+
3
+ **AI-powered test generation for Ruby on Rails applications.**
4
+
5
+ Spec AI analyzes your Rails application, understands the model, schema, factory, and routes context, generates test scenarios with an LLM, writes the RSpec file, and runs the generated tests.
6
+
7
+ > Generate better Rails tests with AI — locally or using hosted LLMs.
8
+
9
+ It is not a raw “LLM writes an RSpec file” wrapper. Ruby owns control flow. An [RCrewAI](https://github.com/gkosmo/rcrewAI) crew reasons about the app; a deterministic generator writes the spec.
10
+
11
+ ## ✨ Features
12
+
13
+ - 🤖 AI-powered test case generation
14
+ - 💎 Built specifically for Ruby on Rails
15
+ - 🧠 Rails-aware application context
16
+ - 📋 Automatic test scenario planning
17
+ - 🧪 RSpec test generation
18
+ - 🚀 Automatic test execution
19
+ - 🔧 Local LLMs with Ollama
20
+ - ☁️ Hosted LLMs such as OpenAI
21
+ - 🔍 Analyzes models, schema, factories, associations, and routes
22
+ - ⚡ Compare different AI models for test generation
23
+ - 🔐 Local AI option for privacy-sensitive projects
24
+
25
+ ## 🚀 How it works
26
+
27
+ Spec AI collects relevant context from your Rails application before asking the model to generate tests.
28
+
29
+ ![How Spec AI works](images/how-it-works.png)
30
+
31
+ 1. **Context collector** reads the model, `db/schema.rb`, associations, FactoryBot factory, existing spec, and matching routes.
32
+ 2. **RailsAnalyst** (RCrewAI) may fetch extra related files through sandboxed tools.
33
+ 3. **TestArchitect** returns a **JSON test plan** (not Ruby). Ruby then keeps only scenarios the model actually enforces and fills any gaps from the schema and validations. The LLM can suggest extras; it cannot invent columns, associations, or Devise rules.
34
+ 4. **RspecGenerator** (plain Ruby) writes `spec/models/user_spec.rb`.
35
+ 5. **RspecRunner** executes the file.
36
+ 6. On failure, Spec AI drops examples the model does not enforce, then **QADebugger** may return a corrected plan; Ruby regenerates and reruns, up to `max_repair_attempts`.
37
+
38
+ Agents can only read `app/`, `spec/`, `db/`, `config/`, and `test/` inside the application root. They cannot execute application code or write files directly.
39
+
40
+ ## ⚡ Model comparison
41
+
42
+ A single generate run against the same Devise `User` model, local Ollama vs hosted OpenAI:
43
+
44
+ ![Ollama llama3.1 vs OpenAI gpt-4o-mini](images/model-comparison.png)
45
+
46
+ | Metric | Ollama / llama3.1 | OpenAI / gpt-4o-mini |
47
+ | ------------------------ | ----------------: | -------------------: |
48
+ | Provider | Local | Hosted |
49
+ | Test scenarios planned | 17 | 21 |
50
+ | RSpec examples generated | 17 | 21 |
51
+ | AI generation time | 3m 8s | 9.1s |
52
+ | Test execution time | 3.0s | 1.4s |
53
+ | Failures | 0 | 0 |
54
+ | Result | ✅ Passed | ✅ Passed |
55
+
56
+ Both models generated executable RSpec tests with **0 failures** for the tested `User` model.
57
+
58
+ The hosted `gpt-4o-mini` run planned more scenarios in this test and finished generation substantially faster. The local `llama3.1` run shows that Rails test generation can also stay on your machine.
59
+
60
+ > These numbers are a single comparison run, not a general benchmark. Results vary with the Rails app, model complexity, prompt, hardware, model version, and configuration.
61
+
62
+ Generate with a local Ollama model:
63
+
64
+ ![Generate User with Ollama llama3.1](images/generate-ollama.png)
65
+
66
+ The same flow with OpenAI:
67
+
68
+ ![Generate User with OpenAI](images/generate-openai.png)
69
+
70
+ ## Installation
71
+
72
+ Add the gem to your Rails app:
73
+
74
+ ```ruby
75
+ # Gemfile
76
+ group :development, :test do
77
+ gem "spec_ai", github: "karthick965938/spec-ai"
78
+ end
79
+ ```
80
+
81
+ ```bash
82
+ bundle install
83
+ bin/rails generate spec_ai:install
84
+ ```
85
+
86
+ That creates `config/spec_ai.yml`.
87
+
88
+ ## Configuration
89
+
90
+ ```yaml
91
+ # config/spec_ai.yml
92
+ provider: ollama
93
+ model: llama3.1
94
+ temperature: 0.1
95
+
96
+ testing:
97
+ framework: rspec
98
+
99
+ generation:
100
+ include_edge_cases: true
101
+ include_security_cases: true
102
+ include_authorization: true
103
+
104
+ execution:
105
+ run_after_generation: true
106
+ auto_fix: true
107
+ max_repair_attempts: 2
108
+ ```
109
+
110
+ Set `verbose: true` (or pass `--verbose`) to print RCrewAI and HTTP logs.
111
+
112
+ API keys come from the environment, not the YAML file:
113
+
114
+ | Provider | Environment variables |
115
+ | ---------- | ---------------------------------------------------------- |
116
+ | OpenAI | `OPENAI_API_KEY` |
117
+ | Anthropic | `ANTHROPIC_API_KEY` |
118
+ | Gemini | `GOOGLE_API_KEY` or `GEMINI_API_KEY` |
119
+ | Azure | `AZURE_OPENAI_API_KEY`, `AZURE_OPENAI_ENDPOINT` |
120
+ | Ollama | `OLLAMA_URL` (defaults to `http://localhost:11434`) |
121
+
122
+ RCrewAI is the only LLM layer. You can switch providers without changing the gem:
123
+
124
+ ```bash
125
+ bundle exec spec-ai generate User --provider ollama --model llama3.1
126
+ bundle exec spec-ai generate User --provider openai --model gpt-4o-mini
127
+ ```
128
+
129
+ Local Ollama is the path to keep application source on the developer’s machine.
130
+
131
+ ## Usage
132
+
133
+ ```bash
134
+ bundle exec spec-ai generate User
135
+ bundle exec spec-ai generate app/models/user.rb
136
+ bin/rails spec_ai:generate[User]
137
+ ```
138
+
139
+ If FactoryBot or RSpec bootstrap files are missing, Spec AI creates them and stops when you need to `bundle install`:
140
+
141
+ ![Missing factory and spec helpers](images/setup-missing.png)
142
+
143
+ ```bash
144
+ bundle install
145
+ bundle exec spec-ai generate User
146
+ ```
147
+
148
+ Inspect Rails context without calling an LLM:
149
+
150
+ ```bash
151
+ bundle exec spec-ai analyze User
152
+ ```
153
+
154
+ ![Analyze User](images/analyze.png)
155
+
156
+ Re-run the generated spec the usual way:
157
+
158
+ ```bash
159
+ bundle exec rspec
160
+ bundle exec rspec spec/models/user_spec.rb
161
+ ```
162
+
163
+ ![bundle exec rspec](images/rspec.png)
164
+
165
+ RCrewAI HTTP logs are hidden by default. Pass `-v` to show them.
166
+
167
+ Other commands:
168
+
169
+ ```bash
170
+ spec-ai analyze User # print collected context, no LLM
171
+ spec-ai fix spec/models/user_spec.rb # run + repair
172
+ spec-ai generate User --no-run # write spec only
173
+ spec-ai generate User --no-fix # do not auto-repair
174
+ spec-ai generate User --force # replace an existing spec (this is the default)
175
+ spec-ai generate User --merge # add examples instead of replacing the spec
176
+ spec-ai generate User --verbose # show RCrewAI / HTTP logs
177
+ ```
178
+
179
+ `spec-ai missing` and `spec-ai improve` are reserved for 0.3 / 0.5.
180
+
181
+ ## Requirements
182
+
183
+ - Ruby 3.1+
184
+ - Rails 7+
185
+ - RSpec (`rspec-rails` in the Rails app Gemfile; this version generates model specs only)
186
+
187
+ If FactoryBot is missing, Spec AI adds `factory_bot_rails` to the Gemfile, writes a factory from the schema, and asks you to `bundle install` before generating.
188
+
189
+ If the `spec/` folder is missing (`rails_helper`, `spec_helper`), Spec AI creates those bootstrap files so generated specs can load.
190
+
191
+ ## Development
192
+
193
+ ```bash
194
+ bundle install
195
+ bundle exec rspec
196
+ ```
197
+
198
+ Gem tests stub RCrewAI crews. They never call a live LLM provider.
199
+
200
+ ## Author
201
+
202
+ Built by **[Karthick Nagarajan](https://www.linkedin.com/in/karthick-nagarajan-44800710b/)**.
203
+
204
+ ## Star the project
205
+
206
+ If you find Spec AI useful, consider giving the project a ⭐ on GitHub.
207
+
208
+ More features and AI-powered Rails testing capabilities are coming soon.
209
+
210
+ ## License
211
+
212
+ MIT. See [LICENSE.txt](LICENSE.txt).
data/exe/spec-ai ADDED
@@ -0,0 +1,9 @@
1
+ #!/usr/bin/env ruby
2
+ # frozen_string_literal: true
3
+
4
+ lib = File.expand_path("../lib", __dir__)
5
+ $LOAD_PATH.unshift(lib) unless $LOAD_PATH.include?(lib)
6
+
7
+ require "spec_ai"
8
+
9
+ SpecAi::CLI.start(ARGV)
@@ -0,0 +1,16 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "rails/generators"
4
+
5
+ module SpecAi
6
+ module Generators
7
+ class InstallGenerator < Rails::Generators::Base
8
+ source_root File.expand_path("templates", __dir__)
9
+ desc "Install Spec AI configuration"
10
+
11
+ def copy_config
12
+ template "spec_ai.yml", "config/spec_ai.yml"
13
+ end
14
+ end
15
+ end
16
+ end
@@ -0,0 +1,23 @@
1
+ # config/spec_ai.yml
2
+ #
3
+ # API keys are read from the environment:
4
+ # OPENAI_API_KEY, ANTHROPIC_API_KEY, GOOGLE_API_KEY / GEMINI_API_KEY,
5
+ # AZURE_OPENAI_API_KEY, AZURE_OPENAI_ENDPOINT, OLLAMA_URL
6
+
7
+ provider: openai
8
+ model: gpt-4o
9
+ temperature: 0.1
10
+ # verbose: true # show RCrewAI / HTTP logs
11
+
12
+ testing:
13
+ framework: rspec
14
+
15
+ generation:
16
+ include_edge_cases: true
17
+ include_security_cases: true
18
+ include_authorization: true
19
+
20
+ execution:
21
+ run_after_generation: true
22
+ auto_fix: true
23
+ max_repair_attempts: 2
@@ -0,0 +1,29 @@
1
+ # frozen_string_literal: true
2
+
3
+ module SpecAi
4
+ module Analyzers
5
+ class ExistingSpecs
6
+ EXAMPLE_RE = /\bit\s+(?:["']([^"']+)["']|%q[{\(]([^})\n]+))/
7
+
8
+ def initialize(target, sandbox: Sandbox.new)
9
+ @target = target
10
+ @sandbox = sandbox
11
+ end
12
+
13
+ def analyze
14
+ path = @target.spec_path
15
+ unless @sandbox.exist?(path)
16
+ return { path: path, present: false, excerpt: nil, example_names: [] }
17
+ end
18
+
19
+ excerpt = @sandbox.read(path)
20
+ {
21
+ path: path,
22
+ present: true,
23
+ excerpt: excerpt,
24
+ example_names: excerpt.scan(EXAMPLE_RE).flatten.compact
25
+ }
26
+ end
27
+ end
28
+ end
29
+ end
@@ -0,0 +1,47 @@
1
+ # frozen_string_literal: true
2
+
3
+ module SpecAi
4
+ module Analyzers
5
+ class Factories
6
+ def initialize(target, sandbox: Sandbox.new)
7
+ @target = target
8
+ @sandbox = sandbox
9
+ end
10
+
11
+ def analyze
12
+ path = factory_path
13
+ return { path: nil, name: @target.factory_name, excerpt: nil, present: false } unless path
14
+
15
+ contents = @sandbox.read(path)
16
+ excerpt = extract_factory(contents, @target.factory_name) || contents
17
+ { path: path, name: @target.factory_name, excerpt: excerpt, present: true }
18
+ end
19
+
20
+ private
21
+
22
+ def factory_path
23
+ name = @target.factory_name
24
+ table = @target.table_name
25
+ candidates = [
26
+ "spec/factories/#{table}.rb",
27
+ "spec/factories/#{name}.rb",
28
+ "spec/factories.rb",
29
+ "test/factories/#{table}.rb",
30
+ "test/factories/#{name}.rb",
31
+ "test/factories.rb"
32
+ ]
33
+ globbed = @sandbox.glob("spec/factories/**/*.rb") + @sandbox.glob("test/factories/**/*.rb")
34
+ (candidates + globbed).find do |path|
35
+ next unless @sandbox.exist?(path)
36
+
37
+ contents = @sandbox.read(path)
38
+ contents.match?(/factory\s+:#{Regexp.escape(name)}\b/) || path.end_with?("/#{name}.rb", "/#{table}.rb")
39
+ end
40
+ end
41
+
42
+ def extract_factory(contents, name)
43
+ contents[/factory\s+:#{Regexp.escape(name)}\b.*?^ end/m]
44
+ end
45
+ end
46
+ end
47
+ end
@@ -0,0 +1,169 @@
1
+ # frozen_string_literal: true
2
+
3
+ module SpecAi
4
+ module Analyzers
5
+ class Model
6
+ ASSOCIATION_RE = /\b(has_many|has_one|belongs_to|has_and_belongs_to_many)\s+:(\w+)(.*)$/
7
+ VALIDATES_RE = /^\s*validates(?:_presence_of|_uniqueness_of|_length_of|_inclusion_of|_numericality_of)?\s+(.+)$/
8
+ SCOPE_RE = /^\s*scope\s+:(\w+)/
9
+ ENUM_RE = /^\s*enum\s+(?:(\w+):|:(\w+)\s*,|\s+(\w+)\s*:)/
10
+
11
+ def initialize(target, sandbox: Sandbox.new)
12
+ @target = target
13
+ @sandbox = sandbox
14
+ end
15
+
16
+ def analyze
17
+ source = @sandbox.read(@target.model_path)
18
+ live = live_analysis
19
+
20
+ {
21
+ class_name: @target.class_name,
22
+ path: @target.model_path,
23
+ source: source,
24
+ table_name: live[:table_name] || @target.table_name,
25
+ associations: live[:associations] || parse_associations(source),
26
+ validations: live[:validations] || parse_validations(source),
27
+ scopes: live[:scopes] || parse_scopes(source),
28
+ enums: live[:enums] || parse_enums(source)
29
+ }
30
+ end
31
+
32
+ private
33
+
34
+ def live_analysis
35
+ return {} unless SpecAi.rails_booted?
36
+
37
+ klass = constantize(@target.class_name)
38
+ return {} unless klass
39
+
40
+ {
41
+ table_name: klass.respond_to?(:table_name) ? klass.table_name : nil,
42
+ associations: live_associations(klass),
43
+ validations: live_validations(klass),
44
+ scopes: live_scopes(klass),
45
+ enums: live_enums(klass)
46
+ }
47
+ rescue StandardError
48
+ {}
49
+ end
50
+
51
+ def live_associations(klass)
52
+ return [] unless klass.respond_to?(:reflect_on_all_associations)
53
+
54
+ klass.reflect_on_all_associations.map do |reflection|
55
+ {
56
+ name: reflection.name.to_s,
57
+ macro: reflection.macro.to_s,
58
+ class_name: reflection.class_name.to_s,
59
+ options: compact_options(reflection.options)
60
+ }
61
+ end
62
+ end
63
+
64
+ def live_validations(klass)
65
+ return [] unless klass.respond_to?(:validators)
66
+
67
+ klass.validators.group_by { |validator| Array(validator.attributes).map(&:to_s) }.map do |attributes, validators|
68
+ {
69
+ attributes: attributes,
70
+ validators: validators.map { |validator| validator.kind.to_s }.uniq
71
+ }
72
+ end
73
+ end
74
+
75
+ def live_scopes(klass)
76
+ return [] unless klass.respond_to?(:send)
77
+
78
+ (klass.respond_to?(:defined_scopes) ? klass.defined_scopes.keys : [])
79
+ rescue StandardError
80
+ []
81
+ end
82
+
83
+ def live_enums(klass)
84
+ return [] unless klass.respond_to?(:defined_enums)
85
+
86
+ klass.defined_enums.keys
87
+ rescue StandardError
88
+ []
89
+ end
90
+
91
+ def parse_associations(source)
92
+ source.each_line.filter_map do |line|
93
+ match = line.match(ASSOCIATION_RE)
94
+ next unless match
95
+
96
+ options = {}
97
+ class_name_match = match[3].to_s.match(/class_name:\s*["']([^"']+)["']/)
98
+ options[:class_name] = class_name_match[1] if class_name_match
99
+ dependent_match = match[3].to_s.match(/dependent:\s*:(\w+)/)
100
+ options[:dependent] = dependent_match[1] if dependent_match
101
+
102
+ {
103
+ name: match[2],
104
+ macro: match[1],
105
+ class_name: options[:class_name] || Inflector.camelize(Inflector.singularize(match[2])),
106
+ options: options
107
+ }
108
+ end
109
+ end
110
+
111
+ def parse_validations(source)
112
+ grouped = Hash.new { |hash, key| hash[key] = [] }
113
+
114
+ source.each_line do |line|
115
+ match = line.match(VALIDATES_RE)
116
+ next unless match
117
+
118
+ rest = match[1]
119
+ attributes = rest.scan(/:(\w+)/).flatten
120
+ next if attributes.empty?
121
+
122
+ validators = []
123
+ validators << "presence" if line.include?("presence:") || line.include?("validates_presence_of")
124
+ validators << "uniqueness" if line.include?("uniqueness:") || line.include?("validates_uniqueness_of")
125
+ validators << "length" if line.include?("length:") || line.include?("validates_length_of")
126
+ validators << "format" if line.include?("format:")
127
+ validators << "inclusion" if line.include?("inclusion:") || line.include?("validates_inclusion_of")
128
+ validators << "numericality" if line.include?("numericality:") || line.include?("validates_numericality_of")
129
+ validators << "confirmation" if line.include?("confirmation:")
130
+ validators = ["presence"] if validators.empty? && line.include?("validates_presence_of")
131
+
132
+ key = attributes.sort
133
+ grouped[key].concat(validators)
134
+ end
135
+
136
+ grouped.map do |attributes, validators|
137
+ { attributes: attributes, validators: validators.uniq }
138
+ end
139
+ end
140
+
141
+ def parse_scopes(source)
142
+ source.each_line.filter_map { |line| line[SCOPE_RE, 1] }
143
+ end
144
+
145
+ def parse_enums(source)
146
+ source.each_line.filter_map do |line|
147
+ match = line.match(ENUM_RE)
148
+ next unless match
149
+
150
+ match.captures.compact.first
151
+ end
152
+ end
153
+
154
+ def constantize(name)
155
+ name.split("::").reduce(Object) { |mod, part| mod.const_get(part) }
156
+ rescue NameError
157
+ nil
158
+ end
159
+
160
+ def compact_options(options)
161
+ return {} unless options.respond_to?(:each_pair)
162
+
163
+ options.each_with_object({}) do |(key, value), memo|
164
+ memo[key] = value unless value.nil?
165
+ end
166
+ end
167
+ end
168
+ end
169
+ end
@@ -0,0 +1,23 @@
1
+ # frozen_string_literal: true
2
+
3
+ module SpecAi
4
+ module Analyzers
5
+ class Routes
6
+ def initialize(target, sandbox: Sandbox.new)
7
+ @target = target
8
+ @sandbox = sandbox
9
+ end
10
+
11
+ def analyze
12
+ path = "config/routes.rb"
13
+ return { path: nil, excerpt: nil } unless @sandbox.exist?(path)
14
+
15
+ contents = @sandbox.read(path)
16
+ token = Inflector.pluralize(@target.factory_name)
17
+ matching = contents.each_line.grep(/#{Regexp.escape(token)}|#{Regexp.escape(@target.factory_name)}/i)
18
+ excerpt = matching.any? ? matching.join : contents.lines.first(40).join
19
+ { path: path, excerpt: excerpt.strip }
20
+ end
21
+ end
22
+ end
23
+ end
@@ -0,0 +1,53 @@
1
+ # frozen_string_literal: true
2
+
3
+ module SpecAi
4
+ module Analyzers
5
+ class Schema
6
+ def initialize(target, sandbox: Sandbox.new)
7
+ @target = target
8
+ @sandbox = sandbox
9
+ end
10
+
11
+ def analyze
12
+ path = schema_path
13
+ return { path: nil, table: @target.table_name, excerpt: nil } unless path
14
+
15
+ contents = @sandbox.read(path)
16
+ excerpt = extract_table(contents, @target.table_name)
17
+ { path: path, table: @target.table_name, excerpt: excerpt, columns: parse_columns(excerpt) }
18
+ end
19
+
20
+ private
21
+
22
+ def schema_path
23
+ %w[db/schema.rb db/structure.sql].find { |path| @sandbox.exist?(path) }
24
+ end
25
+
26
+ def extract_table(contents, table_name)
27
+ ruby = contents[create_table_regex(table_name), 0]
28
+ return ruby if ruby
29
+
30
+ extract_sql_table(contents, table_name)
31
+ end
32
+
33
+ def create_table_regex(table_name)
34
+ /
35
+ create_table\s+["']#{Regexp.escape(table_name)}["'][^\n]*\s+do\s+\|t\|
36
+ .*?
37
+ ^\s*end
38
+ /mx
39
+ end
40
+
41
+ def extract_sql_table(contents, table_name)
42
+ match = contents.match(/CREATE TABLE(?: IF NOT EXISTS)? ["'`]?#{Regexp.escape(table_name)}["'`]?[^;]*;/i)
43
+ match && match[0]
44
+ end
45
+
46
+ def parse_columns(excerpt)
47
+ return [] if excerpt.to_s.empty?
48
+
49
+ excerpt.scan(/t\.\w+\s+"([^"]+)"/).flatten
50
+ end
51
+ end
52
+ end
53
+ end