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

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

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

|
|
65
|
+
|
|
66
|
+
The same flow with OpenAI:
|
|
67
|
+
|
|
68
|
+

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

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

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

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