rails-agent-kit 0.2.0 → 0.3.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 +4 -4
- data/CHANGELOG.md +48 -1
- data/README.md +9 -5
- data/lib/rails_agent_kit/project_info.rb +256 -0
- data/lib/rails_agent_kit/routes_parser.rb +31 -5
- data/lib/rails_agent_kit/ruby_env.rb +5 -0
- data/lib/rails_agent_kit/schema_inspector.rb +6 -2
- data/lib/rails_agent_kit/schema_parser.rb +282 -72
- data/lib/rails_agent_kit/server.rb +23 -2
- data/lib/rails_agent_kit/version.rb +1 -1
- data/lib/rails_agent_kit.rb +2 -1
- data/skills/rails-8-conventions/SKILL.md +3 -1
- metadata +2 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: d9ca6b38fb66d82ec0da3f7b0db4177e7c6757155488948b7502519ba40cc7ff
|
|
4
|
+
data.tar.gz: 6d265f395ab75b55bc0568136d9b9aa507b4f2608fef99917ed9dd00d6350eb8
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: ffee9ca2d65c332c37c099445da8da27628808a1d034f7632c72b15d3c9157735b87ddd48abdfe6698915bcb62cec76a790b168e4330945d74d9b7166d3d0051
|
|
7
|
+
data.tar.gz: 031ca8d4042bbd2df644fb6a8c84317e3d2a5d91e9cf9ad45ea805f89341c159d6453c1c9e29cbc26fa274278875a12206f31054595861cf144b488ac2a72100
|
data/CHANGELOG.md
CHANGED
|
@@ -7,6 +7,52 @@ All notable changes to this project are documented here. The format follows
|
|
|
7
7
|
|
|
8
8
|
## [Unreleased]
|
|
9
9
|
|
|
10
|
+
## [0.3.0] - 2026-10-26
|
|
11
|
+
|
|
12
|
+
rails-agent-kit 0.3.0 adds `rails_project_info`, one small call that tells your agent which stack the app uses,
|
|
13
|
+
and makes `rails_schema` read real-world schemas: enums, constraints, composite keys, views and other schemas,
|
|
14
|
+
with warnings instead of silent gaps. Both parsers are now tested against `rails new` apps on Rails 7.2, 8.0 and
|
|
15
|
+
8.1 and the Mastodon and Forem schemas.
|
|
16
|
+
|
|
17
|
+
### Upgrading
|
|
18
|
+
|
|
19
|
+
No action needed. A few `rails_schema` answers change shape, which agents read without changes: virtual
|
|
20
|
+
columns report their real `type` (with `virtual: true`) instead of `"virtual"`, tables dumped as `public.users`
|
|
21
|
+
are named `users`, and check constraints are listed under `check_constraints` instead of as phantom columns.
|
|
22
|
+
Run `rails-agent-kit install-skills` again to get the skill's "call `rails_project_info` first" step.
|
|
23
|
+
|
|
24
|
+
### Added
|
|
25
|
+
|
|
26
|
+
- **`rails_project_info`, the first call of a session.** One answer of about 1 KB says what kind of Rails app
|
|
27
|
+
this is: Rails, Ruby and `load_defaults` versions, `api_only`, database adapter and multi-DB layout, test
|
|
28
|
+
framework and test data, auth and authorization, jobs, cache and cable backends, frontend, serializers, lint
|
|
29
|
+
and deploy tools, mounted engines, notable gems and where tests, factories and policies live. It reads
|
|
30
|
+
`Gemfile.lock` and config files without booting the app, and unknown values are `null`. The server
|
|
31
|
+
instructions and the Rails 8 skill tell the agent to call it first. By @afurm. (#10)
|
|
32
|
+
- **`rails_schema` reads the rest of a real schema.** Postgres enums (`create_enum`, with each `t.enum` column's
|
|
33
|
+
`enum_type` and `values`), check, exclusion and unique constraints, composite primary keys, `create_schema` and
|
|
34
|
+
tables in other schemas, expression indexes, scenic views (with indexes on materialized views) and SQLite
|
|
35
|
+
full-text search tables. Virtual columns report their real type with `virtual`, `as` and `stored`. By @afurm. (#9)
|
|
36
|
+
- **Warnings instead of silent gaps.** A line inside `create_table` the parser doesn't understand, or a top-level
|
|
37
|
+
statement it skips (a trigger, an `execute`), is listed in `warnings` with its line number, in every
|
|
38
|
+
`rails_schema` answer. By @afurm. (#9)
|
|
39
|
+
- **Engine routes say where they're mounted.** Routes listed under an engine in `bin/rails routes --expanded`
|
|
40
|
+
carry `engine` and `mounted_at`, since their paths are relative to the mount point. By @afurm. (#9)
|
|
41
|
+
- **Tested against real apps.** A test corpus of `rails new` apps on Rails 7.2, 8.0 and 8.1 (SQLite and
|
|
42
|
+
PostgreSQL, with Devise, Sidekiq::Web, Active Storage and a mounted engine) plus the Mastodon and Forem schemas.
|
|
43
|
+
Table, column and route counts must match what the booted app and PostgreSQL report. By @afurm. (#9)
|
|
44
|
+
|
|
45
|
+
### Fixed
|
|
46
|
+
|
|
47
|
+
- **`t.check_constraint` is no longer read as a column** named after its expression, which added phantom columns
|
|
48
|
+
to tables with check constraints. By @afurm. (#9)
|
|
49
|
+
- **Expression indexes are no longer dropped.** `t.index "lower(email)"` and `add_index` with an SQL expression
|
|
50
|
+
containing quotes were skipped; they're now listed with the expression. By @afurm. (#9)
|
|
51
|
+
- **Tables dumped as `public.users`** (apps with several schemas on the search path) are reported as `users`,
|
|
52
|
+
so `table: "users"` finds them; enums get the same treatment. By @afurm. (#9)
|
|
53
|
+
- **Statements that span several lines** (long `create_table` options, multi-line defaults) and heredocs are
|
|
54
|
+
parsed as one statement, and escaped characters in string defaults (`\n`, `\"`) are unescaped. By @afurm. (#9)
|
|
55
|
+
|
|
10
56
|
## [0.2.0] - 2026-10-23
|
|
11
57
|
|
|
12
58
|
rails-agent-kit 0.2.0 keeps large schemas out of your agent's context and makes setup one command:
|
|
@@ -79,6 +125,7 @@ you can keep that config; `rails-agent-kit init` only adds what's missing.
|
|
|
79
125
|
- `rails-agent-kit install-skills [--agent cursor|claude] [--force]`, which installs the bundled skills into an app.
|
|
80
126
|
- CI on Ruby 3.2, 3.3 and 3.4.
|
|
81
127
|
|
|
82
|
-
[Unreleased]: https://github.com/afurm/rails-agent-kit/compare/v0.
|
|
128
|
+
[Unreleased]: https://github.com/afurm/rails-agent-kit/compare/v0.3.0...HEAD
|
|
129
|
+
[0.3.0]: https://github.com/afurm/rails-agent-kit/compare/v0.2.0...v0.3.0
|
|
83
130
|
[0.2.0]: https://github.com/afurm/rails-agent-kit/compare/v0.1.0...v0.2.0
|
|
84
131
|
[0.1.0]: https://github.com/afurm/rails-agent-kit/releases/tag/v0.1.0
|
data/README.md
CHANGED
|
@@ -18,7 +18,7 @@ routes, plus a Rails 8 skill that tells the agent to check them before it writes
|
|
|
18
18
|
</div>
|
|
19
19
|
|
|
20
20
|
> [!NOTE]
|
|
21
|
-
> rails-agent-kit is early (v0.
|
|
21
|
+
> rails-agent-kit is early (v0.3.0), and the API may change before 1.0. Using it? Tell us how it went in
|
|
22
22
|
> [Discussions](https://github.com/afurm/rails-agent-kit/discussions) or [open an issue](https://github.com/afurm/rails-agent-kit/issues).
|
|
23
23
|
|
|
24
24
|
---
|
|
@@ -29,6 +29,8 @@ Agents writing Rails code guess all the time. *Is it `user.full_name` or `user.n
|
|
|
29
29
|
the helper `post_comments_path` or `comments_path`?* Each wrong guess is broken code and another review round.
|
|
30
30
|
rails-agent-kit lets the agent ask your app.
|
|
31
31
|
|
|
32
|
+
* **Stack:** Rails and Ruby versions, database, test framework, auth, jobs, frontend and deploy tools in one
|
|
33
|
+
small answer, read from `Gemfile.lock` and config files, so the agent uses what the app already uses.
|
|
32
34
|
* **Schema:** tables, columns, indexes and foreign keys from `db/schema.rb`, parsed statically. It never boots
|
|
33
35
|
the app or connects to a database. `db/structure.sql` gets a best-effort parse.
|
|
34
36
|
* **Schema overview and search:** a compact overview by default, several tables
|
|
@@ -219,14 +221,16 @@ rails-agent-kit install-skills --root /path/to/your/rails/app --agent gemini #
|
|
|
219
221
|
|
|
220
222
|
## Tools
|
|
221
223
|
|
|
222
|
-
|
|
224
|
+
All three tools are read-only and return JSON.
|
|
223
225
|
|
|
224
226
|
| Tool | Parameters | Returns | Boots the app? |
|
|
225
227
|
| --- | --- | --- | --- |
|
|
228
|
+
| `rails_project_info` | None | Rails, Ruby and `load_defaults` versions, `api_only`, database adapter and databases, test framework and data, auth, authorization, jobs/cache/cable backends, frontend, serializers, lint and deploy tools, mounted engines, notable gems and key paths. Unknown values are `null` | **No.** Reads `Gemfile.lock` and config files |
|
|
226
229
|
| `rails_schema` | All optional. `table`: one exact name, an array or a comma-separated list. `search`: case-insensitive part of a table or column name. `detail`: `overview` or `full` | With no arguments, a compact overview (each table's column count, non-default primary key and foreign key targets). With `table`, full columns (type, null, default, limit, …), indexes and foreign keys. With `search`, the matching tables and columns | **No.** Parses `db/schema.rb` statically, with a best-effort fallback to `db/structure.sql` |
|
|
227
230
|
| `rails_routes` | `filter` (optional, case-insensitive substring of name, path, verb or `controller#action`) | Each route's name, verb, path, `controller#action` and source location | **Yes.** Runs `bin/rails routes --expanded`. If that fails or times out, it explains why and returns the raw `config/routes.rb` |
|
|
228
231
|
|
|
229
|
-
Responses are compact (single-line) JSON. Agents
|
|
232
|
+
Responses are compact (single-line) JSON. Agents call `rails_project_info` first (about 1 KB), then usually
|
|
233
|
+
`rails_schema` with no arguments, so that
|
|
230
234
|
answer stays small even on large apps: a generated 300-table schema gives about 23 KB, where the full dump is
|
|
231
235
|
over 1 MB. Any `rails_schema` answer over 60 KB comes back as the overview plus a note on how to narrow the
|
|
232
236
|
request (`truncated: true`).
|
|
@@ -269,8 +273,8 @@ rails-agent-kit --root . --routes-file tmp/routes.txt
|
|
|
269
273
|
|
|
270
274
|
## Agent skill: Rails 8 conventions
|
|
271
275
|
|
|
272
|
-
[`skills/rails-8-conventions/SKILL.md`](skills/rails-8-conventions/SKILL.md) makes the agent check `
|
|
273
|
-
`
|
|
276
|
+
[`skills/rails-8-conventions/SKILL.md`](skills/rails-8-conventions/SKILL.md) makes the agent check `rails_project_info`,
|
|
277
|
+
`rails_schema` and `rails_routes` before it edits code. It then follows the Rails 8 defaults: `params.expect`, the
|
|
274
278
|
built-in authentication generator, `rate_limit`, Solid Queue/Cache/Cable, Hotwire with importmap/Propshaft,
|
|
275
279
|
reversible migrations, and tests in the framework the app already uses. It also covers a paired Next.js
|
|
276
280
|
frontend.
|
|
@@ -0,0 +1,256 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "erb"
|
|
4
|
+
require "yaml"
|
|
5
|
+
|
|
6
|
+
module RailsAgentKit
|
|
7
|
+
# Answers "what kind of Rails app is this?" from Gemfile.lock, config files and the directory layout, without
|
|
8
|
+
# booting the app. Every value comes from a file in the app; anything that can't be read from one is nil.
|
|
9
|
+
class ProjectInfo
|
|
10
|
+
# [field, gem or detector => reported name]. The first matching entries win, in this order.
|
|
11
|
+
CHOICES = {
|
|
12
|
+
js: { "importmap-rails" => "importmap", "jsbundling-rails" => "jsbundling", "vite_rails" => "vite",
|
|
13
|
+
"vite_ruby" => "vite", "shakapacker" => "shakapacker", "webpacker" => "webpacker" },
|
|
14
|
+
assets: { "propshaft" => "propshaft", "sprockets-rails" => "sprockets", "sprockets" => "sprockets" },
|
|
15
|
+
css: { "tailwindcss-rails" => "tailwind", "cssbundling-rails" => "cssbundling", "dartsass-rails" => "sass",
|
|
16
|
+
"sassc-rails" => "sass", "bootstrap" => "bootstrap" },
|
|
17
|
+
views: { "view_component" => "view_component", "phlex-rails" => "phlex" },
|
|
18
|
+
serializers: { "jbuilder" => "jbuilder", "alba" => "alba", "blueprinter" => "blueprinter",
|
|
19
|
+
"active_model_serializers" => "active_model_serializers",
|
|
20
|
+
"jsonapi-serializer" => "jsonapi-serializer", "oj_serializers" => "oj_serializers" },
|
|
21
|
+
authorization: { "pundit" => "pundit", "cancancan" => "cancancan", "action_policy" => "action_policy" },
|
|
22
|
+
auth_gems: { "devise" => "devise", "rodauth-rails" => "rodauth", "sorcery" => "sorcery",
|
|
23
|
+
"clearance" => "clearance" },
|
|
24
|
+
job_gems: { "solid_queue" => "solid_queue", "sidekiq" => "sidekiq", "good_job" => "good_job",
|
|
25
|
+
"resque" => "resque", "delayed_job_active_record" => "delayed_job" },
|
|
26
|
+
factories: { "factory_bot_rails" => "factory_bot", "factory_bot" => "factory_bot", "fabrication" => "fabrication" }
|
|
27
|
+
}.freeze
|
|
28
|
+
HOTWIRE = { "turbo-rails" => "turbo", "stimulus-rails" => "stimulus" }.freeze
|
|
29
|
+
QUALITY = %w[rubocop-rails-omakase standard rubocop rubocop-rails brakeman bundler-audit erb_lint herb].freeze
|
|
30
|
+
NOTABLE = %w[
|
|
31
|
+
pagy kaminari will_paginate ransack pg_search searchkick paper_trail audited logidze friendly_id aasm
|
|
32
|
+
money-rails noticed flipper rack-attack strong_migrations annotaterb annotate image_processing aws-sdk-s3
|
|
33
|
+
stripe pay mission_control-jobs activeadmin administrate avo rolify discard paranoia acts_as_list ancestry
|
|
34
|
+
store_model sentry-ruby honeybadger rollbar appsignal skylight lograge solid_errors
|
|
35
|
+
].freeze
|
|
36
|
+
PATHS = {
|
|
37
|
+
tests: %w[spec test], factories: %w[spec/factories test/factories], fixtures: %w[spec/fixtures test/fixtures],
|
|
38
|
+
policies: %w[app/policies], components: %w[app/components app/views/components],
|
|
39
|
+
serializers: %w[app/serializers app/blueprints app/resources], services: %w[app/services],
|
|
40
|
+
graphql: %w[app/graphql], javascript: %w[app/javascript]
|
|
41
|
+
}.freeze
|
|
42
|
+
|
|
43
|
+
def initialize(root)
|
|
44
|
+
@root = File.expand_path(root)
|
|
45
|
+
@sources = []
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
def call
|
|
49
|
+
unless file?("Gemfile") || file?("config/application.rb")
|
|
50
|
+
return { error: "No Gemfile or config/application.rb in #{@root}, so this doesn't look like a Rails app." }
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
@specs, @deps = read_lockfile
|
|
54
|
+
info = {
|
|
55
|
+
rails: @specs["rails"] || @specs["railties"],
|
|
56
|
+
ruby: ruby,
|
|
57
|
+
load_defaults: application_rb&.[](/^\s*config\.load_defaults\s+["']?([\d.]+)/, 1),
|
|
58
|
+
api_only: application_rb ? application_rb.match?(/^\s*config\.api_only\s*=\s*true/) : nil,
|
|
59
|
+
database: database,
|
|
60
|
+
test: test_stack,
|
|
61
|
+
auth: auth,
|
|
62
|
+
authorization: pick(:authorization),
|
|
63
|
+
jobs: jobs,
|
|
64
|
+
cache: cache,
|
|
65
|
+
cable: cable,
|
|
66
|
+
frontend: frontend,
|
|
67
|
+
serializers: pick(:serializers),
|
|
68
|
+
graphql: gem?("graphql"),
|
|
69
|
+
quality: presence(QUALITY.select { |g| gem?(g) }),
|
|
70
|
+
deploy: deploy,
|
|
71
|
+
engines: engines,
|
|
72
|
+
gems: presence(NOTABLE.select { |g| gem?(g) }.to_h { |g| [g, @specs[g]] }),
|
|
73
|
+
paths: presence(paths)
|
|
74
|
+
}
|
|
75
|
+
info[:sources] = @sources.uniq
|
|
76
|
+
info
|
|
77
|
+
end
|
|
78
|
+
|
|
79
|
+
private
|
|
80
|
+
|
|
81
|
+
# Gemfile.lock specs (name => version) and direct dependencies. Without a lockfile, the Gemfile's gem lines.
|
|
82
|
+
def read_lockfile
|
|
83
|
+
text = read("Gemfile.lock")
|
|
84
|
+
if text.nil?
|
|
85
|
+
gemfile = read("Gemfile").to_s
|
|
86
|
+
names = gemfile.scan(/^\s*gem\s+["']([^"']+)["']/).flatten
|
|
87
|
+
return [names.to_h { |n| [n, nil] }, names]
|
|
88
|
+
end
|
|
89
|
+
|
|
90
|
+
specs = {}
|
|
91
|
+
deps = []
|
|
92
|
+
section = nil
|
|
93
|
+
text.each_line do |line|
|
|
94
|
+
section = line.strip if line.match?(/\A[A-Z]/)
|
|
95
|
+
if section == "GEM" && (m = line.match(/\A {4}([^\s(]+) \(([^)]+)\)\s*\z/))
|
|
96
|
+
specs[m[1]] ||= m[2].sub(/-(?:x86|arm|aarch|universal|java|x64|mingw).*\z/, "")
|
|
97
|
+
elsif section == "DEPENDENCIES" && (m = line.match(/\A {2}([^\s(!]+)/))
|
|
98
|
+
deps << m[1]
|
|
99
|
+
end
|
|
100
|
+
end
|
|
101
|
+
[specs, deps]
|
|
102
|
+
end
|
|
103
|
+
|
|
104
|
+
def gem?(name)
|
|
105
|
+
@deps.include?(name) || (@deps.empty? && @specs.key?(name))
|
|
106
|
+
end
|
|
107
|
+
|
|
108
|
+
def pick(field, choices = CHOICES.fetch(field))
|
|
109
|
+
found = choices.select { |gem, _| gem?(gem) }.values.uniq
|
|
110
|
+
found.size > 1 ? found : found.first
|
|
111
|
+
end
|
|
112
|
+
|
|
113
|
+
def ruby
|
|
114
|
+
env = RubyEnv.new(@root)
|
|
115
|
+
required, source = env.requirement
|
|
116
|
+
if required.nil? && (lock = read("Gemfile.lock")) && (m = lock.match(/^RUBY VERSION\n\s+ruby ([\d.]+)/))
|
|
117
|
+
required = m[1]
|
|
118
|
+
source = "Gemfile.lock"
|
|
119
|
+
end
|
|
120
|
+
return nil if required.nil?
|
|
121
|
+
|
|
122
|
+
@sources << source
|
|
123
|
+
{ version: required, source: source }
|
|
124
|
+
end
|
|
125
|
+
|
|
126
|
+
def application_rb
|
|
127
|
+
return @application_rb if defined?(@application_rb)
|
|
128
|
+
|
|
129
|
+
@application_rb = read("config/application.rb")
|
|
130
|
+
end
|
|
131
|
+
|
|
132
|
+
def production_rb
|
|
133
|
+
return @production_rb if defined?(@production_rb)
|
|
134
|
+
|
|
135
|
+
@production_rb = read("config/environments/production.rb")
|
|
136
|
+
end
|
|
137
|
+
|
|
138
|
+
def database
|
|
139
|
+
config = yaml("config/database.yml")
|
|
140
|
+
return nil unless config.is_a?(Hash)
|
|
141
|
+
|
|
142
|
+
adapters = []
|
|
143
|
+
multi = {}
|
|
144
|
+
config.each do |env, value|
|
|
145
|
+
next unless value.is_a?(Hash) && env != "default"
|
|
146
|
+
|
|
147
|
+
if value.key?("adapter") || value.key?("database") || value.key?("url")
|
|
148
|
+
adapters << value["adapter"]
|
|
149
|
+
else
|
|
150
|
+
dbs = value.select { |_, db| db.is_a?(Hash) }
|
|
151
|
+
adapters.concat(dbs.values.map { |db| db["adapter"] })
|
|
152
|
+
multi[env] = dbs.keys if dbs.size > 1
|
|
153
|
+
end
|
|
154
|
+
end
|
|
155
|
+
adapters = adapters.compact.uniq
|
|
156
|
+
{ adapter: adapters.size > 1 ? adapters : adapters.first, databases: presence(multi) }.compact
|
|
157
|
+
end
|
|
158
|
+
|
|
159
|
+
def test_stack
|
|
160
|
+
framework = if file?("spec/rails_helper.rb") || file?("spec/spec_helper.rb") then "rspec"
|
|
161
|
+
elsif file?("test/test_helper.rb") then "minitest"
|
|
162
|
+
elsif gem?("rspec-rails") then "rspec"
|
|
163
|
+
end
|
|
164
|
+
data = pick(:factories)
|
|
165
|
+
data ||= "fixtures" if Dir.glob(File.join(@root, "{test,spec}/fixtures/**/*.yml")).any?
|
|
166
|
+
system = gem?("capybara") ? "capybara" : nil
|
|
167
|
+
system ||= "cuprite" if gem?("cuprite")
|
|
168
|
+
{ framework: framework, data: data, system: system }
|
|
169
|
+
end
|
|
170
|
+
|
|
171
|
+
def auth
|
|
172
|
+
found = []
|
|
173
|
+
if file?("app/controllers/concerns/authentication.rb") && file?("app/models/session.rb")
|
|
174
|
+
found << "rails_authentication"
|
|
175
|
+
end
|
|
176
|
+
found.concat(CHOICES[:auth_gems].select { |gem, _| gem?(gem) }.values)
|
|
177
|
+
found.size > 1 ? found : found.first
|
|
178
|
+
end
|
|
179
|
+
|
|
180
|
+
def jobs
|
|
181
|
+
configured = [production_rb, application_rb].compact.filter_map do |text|
|
|
182
|
+
text[/^\s*config\.active_job\.queue_adapter\s*=\s*:(\w+)/, 1]
|
|
183
|
+
end.first
|
|
184
|
+
configured || pick(:job_gems)
|
|
185
|
+
end
|
|
186
|
+
|
|
187
|
+
def cache
|
|
188
|
+
production_rb&.[](/^\s*config\.cache_store\s*=\s*:(\w+?)(?:_store)?\b/, 1)
|
|
189
|
+
end
|
|
190
|
+
|
|
191
|
+
def cable
|
|
192
|
+
config = yaml("config/cable.yml")
|
|
193
|
+
config.is_a?(Hash) && config["production"].is_a?(Hash) ? config["production"]["adapter"] : nil
|
|
194
|
+
end
|
|
195
|
+
|
|
196
|
+
def frontend
|
|
197
|
+
return nil if application_rb&.match?(/^\s*config\.api_only\s*=\s*true/) && pick(:js).nil?
|
|
198
|
+
|
|
199
|
+
hotwire = HOTWIRE.select { |gem, _| gem?(gem) }.values
|
|
200
|
+
@sources << "config/importmap.rb" if file?("config/importmap.rb")
|
|
201
|
+
{ js: pick(:js), assets: pick(:assets), hotwire: presence(hotwire), css: pick(:css), views: pick(:views) }
|
|
202
|
+
end
|
|
203
|
+
|
|
204
|
+
def deploy
|
|
205
|
+
found = []
|
|
206
|
+
found << "kamal" if file?("config/deploy.yml") || gem?("kamal")
|
|
207
|
+
found << "docker" if file?("Dockerfile")
|
|
208
|
+
found << "procfile" if file?("Procfile")
|
|
209
|
+
found << "fly" if file?("fly.toml")
|
|
210
|
+
found << "render" if file?("render.yaml")
|
|
211
|
+
found << "heroku" if file?("app.json")
|
|
212
|
+
presence(found)
|
|
213
|
+
end
|
|
214
|
+
|
|
215
|
+
# `mount Engine => "/path"` and `mount Engine, at: "/path"` lines in config/routes.rb.
|
|
216
|
+
def engines
|
|
217
|
+
routes = read("config/routes.rb")
|
|
218
|
+
return nil if routes.nil?
|
|
219
|
+
|
|
220
|
+
mounts = routes.scan(/^\s*mount\s+([A-Z][\w:]*(?:\.\w+)?)\s*(?:=>|,\s*at:)\s*["']([^"']+)["']/)
|
|
221
|
+
presence(mounts.map { |engine, at| { engine: engine, at: at } })
|
|
222
|
+
end
|
|
223
|
+
|
|
224
|
+
def paths
|
|
225
|
+
PATHS.filter_map do |key, candidates|
|
|
226
|
+
dir = candidates.find { |path| File.directory?(File.join(@root, path)) }
|
|
227
|
+
[key, dir] if dir
|
|
228
|
+
end.to_h
|
|
229
|
+
end
|
|
230
|
+
|
|
231
|
+
def yaml(path)
|
|
232
|
+
text = read(path)
|
|
233
|
+
return nil if text.nil?
|
|
234
|
+
|
|
235
|
+
YAML.safe_load(text.gsub(/<%.*?%>/m, "erb"), aliases: true)
|
|
236
|
+
rescue Psych::Exception
|
|
237
|
+
nil
|
|
238
|
+
end
|
|
239
|
+
|
|
240
|
+
def file?(path)
|
|
241
|
+
File.file?(File.join(@root, path))
|
|
242
|
+
end
|
|
243
|
+
|
|
244
|
+
def read(path)
|
|
245
|
+
full = File.join(@root, path)
|
|
246
|
+
return nil unless File.file?(full)
|
|
247
|
+
|
|
248
|
+
@sources << path
|
|
249
|
+
File.read(full)
|
|
250
|
+
end
|
|
251
|
+
|
|
252
|
+
def presence(value)
|
|
253
|
+
value.nil? || value.empty? ? nil : value
|
|
254
|
+
end
|
|
255
|
+
end
|
|
256
|
+
end
|
|
@@ -3,8 +3,13 @@
|
|
|
3
3
|
module RailsAgentKit
|
|
4
4
|
# Parses the output of `bin/rails routes --expanded` (preferred) or the
|
|
5
5
|
# default table output of `bin/rails routes`.
|
|
6
|
+
#
|
|
7
|
+
# Routes of a mounted engine are listed after a `[ Routes for Blorgh::Engine ]` (expanded) or
|
|
8
|
+
# `Routes for Blorgh::Engine:` (table) line and get `engine` and, when the mount is found, `mounted_at`,
|
|
9
|
+
# because their paths are relative to where the engine is mounted.
|
|
6
10
|
class RoutesParser
|
|
7
11
|
HEADER = /\A--\[\s*Route\s+\d+\s*\]-*\s*\z/
|
|
12
|
+
SECTION = /\A\[?\s*Routes for (\S+?)\s*(?:\]|:)\s*\z/
|
|
8
13
|
FIELD = /\A([^|]+?)\s*\|\s?(.*)\z/
|
|
9
14
|
KEYS = {
|
|
10
15
|
"Prefix" => :name,
|
|
@@ -38,7 +43,12 @@ module RailsAgentKit
|
|
|
38
43
|
verb_at = header.index("Verb")
|
|
39
44
|
uri_at = header.index("URI Pattern")
|
|
40
45
|
action_at = header.index("Controller#Action")
|
|
41
|
-
|
|
46
|
+
engine = nil
|
|
47
|
+
routes = lines.drop(lines.index(header) + 1).filter_map do |line|
|
|
48
|
+
if (m = SECTION.match(line.strip))
|
|
49
|
+
engine = m[1] == "application" ? nil : m[1]
|
|
50
|
+
next
|
|
51
|
+
end
|
|
42
52
|
next if line.strip.empty? || line.length <= uri_at
|
|
43
53
|
|
|
44
54
|
# Long values can push later columns right, so split the tail on whitespace.
|
|
@@ -47,18 +57,26 @@ module RailsAgentKit
|
|
|
47
57
|
path, action = line[uri_at..].strip.split(/\s+/, 2)
|
|
48
58
|
next if path.nil? || !path.start_with?("/")
|
|
49
59
|
|
|
50
|
-
|
|
60
|
+
route = { name: name.empty? ? nil : name, verb: verb, path: path, action: action&.strip }
|
|
61
|
+
route[:engine] = engine if engine
|
|
62
|
+
normalize(route)
|
|
51
63
|
end
|
|
64
|
+
add_mount_points(routes)
|
|
52
65
|
end
|
|
53
66
|
|
|
54
67
|
def parse_expanded
|
|
55
68
|
routes = []
|
|
56
69
|
current = nil
|
|
70
|
+
engine = nil
|
|
57
71
|
@output.each_line do |raw|
|
|
58
72
|
line = raw.chomp
|
|
59
|
-
if
|
|
73
|
+
if (m = SECTION.match(line.strip))
|
|
74
|
+
routes << current if current
|
|
75
|
+
current = nil
|
|
76
|
+
engine = m[1] == "application" ? nil : m[1]
|
|
77
|
+
elsif HEADER.match?(line.strip)
|
|
60
78
|
routes << current if current
|
|
61
|
-
current = {}
|
|
79
|
+
current = engine ? { engine: engine } : {}
|
|
62
80
|
elsif current && (m = FIELD.match(line))
|
|
63
81
|
key = KEYS[m[1].strip] || m[1].strip.downcase.gsub(/\W+/, "_").to_sym
|
|
64
82
|
value = m[2].strip
|
|
@@ -66,7 +84,15 @@ module RailsAgentKit
|
|
|
66
84
|
end
|
|
67
85
|
end
|
|
68
86
|
routes << current if current
|
|
69
|
-
routes.map { |r| normalize(r) }
|
|
87
|
+
add_mount_points(routes.map { |r| normalize(r) })
|
|
88
|
+
end
|
|
89
|
+
|
|
90
|
+
# The app route that mounts an engine has the engine's class name as its action, e.g. `Blorgh::Engine`.
|
|
91
|
+
def add_mount_points(routes)
|
|
92
|
+
mounts = routes.each_with_object({}) do |r, h|
|
|
93
|
+
h[r[:action]] ||= r[:path] if !r[:engine] && r[:action]&.end_with?("::Engine")
|
|
94
|
+
end
|
|
95
|
+
routes.each { |r| r[:mounted_at] = mounts[r[:engine]] if r[:engine] && mounts[r[:engine]] }
|
|
70
96
|
end
|
|
71
97
|
|
|
72
98
|
def normalize(route)
|
|
@@ -56,6 +56,11 @@ module RailsAgentKit
|
|
|
56
56
|
end
|
|
57
57
|
end
|
|
58
58
|
|
|
59
|
+
# The version the app asks for and the file that says so (relative to the app root), or nil.
|
|
60
|
+
def requirement
|
|
61
|
+
required_version
|
|
62
|
+
end
|
|
63
|
+
|
|
59
64
|
# A one-line explanation for a Result that wasn't found.
|
|
60
65
|
def self.missing_message(result)
|
|
61
66
|
hint = format(INSTALL_HINTS.fetch(result.manager || "rbenv"), result.required_version.sub(/\A[~><=\s]+/, ""))
|
|
@@ -106,7 +106,10 @@ module RailsAgentKit
|
|
|
106
106
|
entry[:references] = refs if refs.any?
|
|
107
107
|
entry
|
|
108
108
|
end
|
|
109
|
-
meta(result).merge(detail: "overview", table_count: tables.size, hint: OVERVIEW_HINT, tables: tables)
|
|
109
|
+
out = meta(result).merge(detail: "overview", table_count: tables.size, hint: OVERVIEW_HINT, tables: tables)
|
|
110
|
+
out[:enums] = result[:enums] if result[:enums]
|
|
111
|
+
out[:views] = result[:views].map { |v| v[:name] } if result[:views]
|
|
112
|
+
out
|
|
110
113
|
end
|
|
111
114
|
|
|
112
115
|
def search_result(result, search)
|
|
@@ -131,8 +134,9 @@ module RailsAgentKit
|
|
|
131
134
|
guard_size(out, result)
|
|
132
135
|
end
|
|
133
136
|
|
|
137
|
+
# Parser warnings travel with every answer: they mark statements that are missing from the result.
|
|
134
138
|
def meta(result)
|
|
135
|
-
result.slice(:source, :version, :note)
|
|
139
|
+
result.slice(:source, :version, :note, :warnings)
|
|
136
140
|
end
|
|
137
141
|
|
|
138
142
|
def guard_size(out, result)
|
|
@@ -1,22 +1,45 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
3
|
module RailsAgentKit
|
|
4
|
-
# Parses db/schema.rb statically (no eval, no app boot) into tables, columns,
|
|
5
|
-
#
|
|
4
|
+
# Parses db/schema.rb statically (no eval, no app boot) into tables, columns, indexes, constraints,
|
|
5
|
+
# foreign keys, enums and views.
|
|
6
|
+
#
|
|
7
|
+
# The parser works on statements rather than raw lines: a statement that spans several lines (open
|
|
8
|
+
# brackets, a trailing comma, a method chain or a heredoc) is joined first. It never drops anything
|
|
9
|
+
# silently: a line inside `create_table` or a top-level statement it doesn't understand is reported in
|
|
10
|
+
# `warnings` with its line number, so callers know the result may be incomplete.
|
|
6
11
|
class SchemaParser
|
|
7
|
-
Result = Struct.new(:version, :extensions, :tables, :foreign_keys,
|
|
12
|
+
Result = Struct.new(:version, :extensions, :schemas, :enums, :tables, :foreign_keys, :views, :warnings,
|
|
13
|
+
keyword_init: true) do
|
|
8
14
|
def to_h
|
|
9
|
-
{ version: version, extensions: extensions
|
|
15
|
+
out = { version: version, extensions: extensions }
|
|
16
|
+
out[:schemas] = schemas if schemas.any?
|
|
17
|
+
out[:enums] = enums if enums.any?
|
|
18
|
+
out[:tables] = tables
|
|
19
|
+
out[:foreign_keys] = foreign_keys
|
|
20
|
+
out[:views] = views if views.any?
|
|
21
|
+
out[:warnings] = warnings if warnings.any?
|
|
22
|
+
out
|
|
10
23
|
end
|
|
11
24
|
end
|
|
12
25
|
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
26
|
+
Statement = Struct.new(:text, :line, :heredoc, keyword_init: true)
|
|
27
|
+
|
|
28
|
+
CREATE_TABLE = /\Acreate_table\s+["']([^"']+)["'](?:\s*,\s*(.*?))?\s+do\s*\|(\w+)\|\z/m
|
|
29
|
+
COLUMN = /\A(\w+)\.(\w+)\s+["']([^"']+)["']\s*(?:,\s*(.*))?\z/m
|
|
30
|
+
T_INDEX = /\A(\w+)\.index\s+(.*)\z/m
|
|
31
|
+
T_CONSTRAINT = /\A(\w+)\.(check|exclusion|unique)_constraint\s+(.*)\z/m
|
|
32
|
+
ADD_INDEX = /\Aadd_index\s+["']([^"']+)["']\s*,\s*(.*)\z/m
|
|
33
|
+
ADD_FK = /\Aadd_foreign_key\s+["']([^"']+)["']\s*,\s*["']([^"']+)["']\s*(?:,\s*(.*))?\z/m
|
|
34
|
+
ADD_CHECK = /\Aadd_check_constraint\s+["']([^"']+)["']\s*,\s*(.*)\z/m
|
|
35
|
+
EXTENSION = /\Aenable_extension\s+["']([^"']+)["']/
|
|
36
|
+
CREATE_SCHEMA = /\Acreate_schema\s+["']([^"']+)["']/
|
|
37
|
+
CREATE_ENUM = /\Acreate_enum\s+["']([^"']+)["']\s*,\s*(\[.*\])/m
|
|
38
|
+
CREATE_VIEW = /\Acreate_view\s+["']([^"']+)["'](?:\s*,\s*(.*))?\z/m
|
|
39
|
+
VIRTUAL_TABLE = /\Acreate_virtual_table\s+["']([^"']+)["']\s*,\s*["']([^"']+)["']\s*,\s*(\[.*\])/m
|
|
40
|
+
VERSION_RE = /define\(\s*version:\s*([\d_]+)\s*\)/
|
|
41
|
+
DEFINE = /\AActiveRecord::Schema(?:\[[\d.]+\])?\.define\b/
|
|
42
|
+
HEREDOC = /<<([~-]?)(["'`]?)([A-Za-z_]\w*)\2/
|
|
20
43
|
|
|
21
44
|
def self.parse_file(path)
|
|
22
45
|
new(File.read(path)).parse
|
|
@@ -27,86 +50,191 @@ module RailsAgentKit
|
|
|
27
50
|
end
|
|
28
51
|
|
|
29
52
|
def parse
|
|
30
|
-
tables = []
|
|
31
|
-
foreign_keys = []
|
|
32
|
-
extensions = []
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
@
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
if current
|
|
42
|
-
if line.strip == "end"
|
|
43
|
-
tables << current
|
|
44
|
-
current = nil
|
|
45
|
-
elsif (m = T_INDEX.match(line)) && m[1] == block_var
|
|
46
|
-
current[:indexes] << build_index(current[:name], m[2], m[3])
|
|
47
|
-
elsif (m = COLUMN.match(line)) && m[1] == block_var
|
|
48
|
-
current[:columns] << build_column(m[2], m[3], m[4])
|
|
49
|
-
end
|
|
50
|
-
next
|
|
51
|
-
end
|
|
53
|
+
@tables = []
|
|
54
|
+
@foreign_keys = []
|
|
55
|
+
@extensions = []
|
|
56
|
+
@schemas = []
|
|
57
|
+
@enums = {}
|
|
58
|
+
@views = []
|
|
59
|
+
@warnings = []
|
|
60
|
+
@table = nil
|
|
61
|
+
@block_var = nil
|
|
62
|
+
@depth = 0 # open `do` blocks outside create_table (other than the define block)
|
|
52
63
|
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
block_var = m[3]
|
|
56
|
-
current = { name: m[1], primary_key: primary_key_for(opts), columns: [], indexes: [] }
|
|
57
|
-
current[:comment] = opts[:comment] if opts[:comment]
|
|
58
|
-
if current[:primary_key]
|
|
59
|
-
current[:columns] << { name: current[:primary_key], type: id_type(opts), null: false, primary_key: true }
|
|
60
|
-
end
|
|
61
|
-
elsif (m = ADD_INDEX.match(line))
|
|
62
|
-
index = build_index(m[1], m[2], m[3])
|
|
63
|
-
table = tables.find { |t| t[:name] == m[1] }
|
|
64
|
-
table ? table[:indexes] << index : nil
|
|
65
|
-
elsif (m = ADD_FK.match(line))
|
|
66
|
-
opts = parse_options(m[3])
|
|
67
|
-
fk = { from_table: m[1], to_table: m[2] }
|
|
68
|
-
fk[:column] = opts[:column] || "#{singularize(m[2])}_id"
|
|
69
|
-
fk[:primary_key] = opts[:primary_key] if opts[:primary_key]
|
|
70
|
-
fk[:on_delete] = opts[:on_delete] if opts[:on_delete]
|
|
71
|
-
fk[:on_update] = opts[:on_update] if opts[:on_update]
|
|
72
|
-
fk[:name] = opts[:name] if opts[:name]
|
|
73
|
-
foreign_keys << fk
|
|
74
|
-
elsif (m = EXTENSION.match(line))
|
|
75
|
-
extensions << m[1]
|
|
76
|
-
end
|
|
77
|
-
end
|
|
64
|
+
statements.each { |st| @table ? table_statement(st) : top_level_statement(st) }
|
|
65
|
+
finish_table(@table) if @table
|
|
78
66
|
|
|
79
|
-
|
|
67
|
+
enums = @enums.map { |name, values| { name: name, values: values } }
|
|
68
|
+
Result.new(version: @source[VERSION_RE, 1]&.delete("_"), extensions: @extensions, schemas: @schemas,
|
|
69
|
+
enums: enums, tables: @tables, foreign_keys: @foreign_keys, views: @views, warnings: @warnings)
|
|
80
70
|
end
|
|
81
71
|
|
|
82
72
|
private
|
|
83
73
|
|
|
74
|
+
# --- statements -----------------------------------------------------------------------------------
|
|
75
|
+
|
|
76
|
+
def table_statement(st)
|
|
77
|
+
text = st.text
|
|
78
|
+
if text == "end"
|
|
79
|
+
finish_table(@table)
|
|
80
|
+
@table = nil
|
|
81
|
+
elsif (m = T_INDEX.match(text)) && m[1] == @block_var
|
|
82
|
+
@table[:indexes] << build_index(@table[:name], m[2])
|
|
83
|
+
elsif (m = T_CONSTRAINT.match(text)) && m[1] == @block_var
|
|
84
|
+
add_constraint(@table, m[2], m[3])
|
|
85
|
+
elsif (m = COLUMN.match(text)) && m[1] == @block_var
|
|
86
|
+
@table[:columns] << build_column(m[2], m[3], m[4])
|
|
87
|
+
else
|
|
88
|
+
warn(st, "not understood inside create_table #{@table[:name].inspect}, so it is missing from the result")
|
|
89
|
+
end
|
|
90
|
+
end
|
|
91
|
+
|
|
92
|
+
def top_level_statement(st)
|
|
93
|
+
text = st.text
|
|
94
|
+
case text
|
|
95
|
+
when CREATE_TABLE then start_table(Regexp.last_match)
|
|
96
|
+
when ADD_INDEX then add_index(Regexp.last_match, st)
|
|
97
|
+
when ADD_FK then add_foreign_key(Regexp.last_match)
|
|
98
|
+
when ADD_CHECK then add_check_constraint(Regexp.last_match, st)
|
|
99
|
+
when EXTENSION then @extensions << Regexp.last_match(1)
|
|
100
|
+
when CREATE_SCHEMA then @schemas << Regexp.last_match(1)
|
|
101
|
+
when CREATE_ENUM then @enums[table_name(Regexp.last_match(1))] = Array(parse_value(Regexp.last_match(2))).map(&:to_s)
|
|
102
|
+
when CREATE_VIEW then add_view(Regexp.last_match)
|
|
103
|
+
when VIRTUAL_TABLE then add_virtual_table(Regexp.last_match)
|
|
104
|
+
when DEFINE then nil
|
|
105
|
+
when "end" then @depth -= 1 if @depth.positive?
|
|
106
|
+
else
|
|
107
|
+
# A statement with its own block (e.g. hair_trigger's create_trigger ... do) ends with its own `end`.
|
|
108
|
+
@depth += 1 if text.match?(/\bdo(\s*\|[^|]*\|)?\z/)
|
|
109
|
+
warn(st, "skipped: only tables, indexes, constraints, foreign keys, enums and views are read")
|
|
110
|
+
end
|
|
111
|
+
end
|
|
112
|
+
|
|
113
|
+
def start_table(match)
|
|
114
|
+
opts = parse_options(match[2])
|
|
115
|
+
@block_var = match[3]
|
|
116
|
+
@table = { name: table_name(match[1]), primary_key: primary_key_for(opts), columns: [], indexes: [] }
|
|
117
|
+
@table[:comment] = opts[:comment] if opts[:comment]
|
|
118
|
+
return unless @table[:primary_key].is_a?(String)
|
|
119
|
+
|
|
120
|
+
@table[:columns] << { name: @table[:primary_key], type: id_type(opts), null: false, primary_key: true }
|
|
121
|
+
end
|
|
122
|
+
|
|
123
|
+
def finish_table(table)
|
|
124
|
+
if table[:primary_key].is_a?(Array)
|
|
125
|
+
table[:columns].each { |c| c[:primary_key] = true if table[:primary_key].include?(c[:name]) }
|
|
126
|
+
end
|
|
127
|
+
table[:columns].each do |c|
|
|
128
|
+
c[:values] = @enums[c[:enum_type]] if c[:enum_type] && @enums.key?(c[:enum_type]) && !c.key?(:values)
|
|
129
|
+
end
|
|
130
|
+
@tables << table
|
|
131
|
+
end
|
|
132
|
+
|
|
133
|
+
# Indexes can also belong to a materialized view (scenic's `create_view ..., materialized: true`).
|
|
134
|
+
def add_index(match, st)
|
|
135
|
+
name = table_name(match[1])
|
|
136
|
+
index = build_index(name, match[2])
|
|
137
|
+
target = find_table(name) || @views.find { |v| v[:name] == name }
|
|
138
|
+
return warn(st, "index on unknown table #{match[1].inspect}") unless target
|
|
139
|
+
|
|
140
|
+
(target[:indexes] ||= []) << index
|
|
141
|
+
end
|
|
142
|
+
|
|
143
|
+
def add_foreign_key(match)
|
|
144
|
+
opts = parse_options(match[3])
|
|
145
|
+
fk = { from_table: table_name(match[1]), to_table: table_name(match[2]) }
|
|
146
|
+
fk[:column] = opts[:column] || "#{singularize(match[2].split('.').last)}_id"
|
|
147
|
+
%i[primary_key on_delete on_update name].each { |k| fk[k] = opts[k] if opts[k] }
|
|
148
|
+
fk[:deferrable] = opts[:deferrable] if opts.key?(:deferrable)
|
|
149
|
+
@foreign_keys << fk
|
|
150
|
+
end
|
|
151
|
+
|
|
152
|
+
def add_check_constraint(match, st)
|
|
153
|
+
table = find_table(table_name(match[1]))
|
|
154
|
+
return warn(st, "check constraint on unknown table #{match[1].inspect}") unless table
|
|
155
|
+
|
|
156
|
+
add_constraint(table, "check", match[2])
|
|
157
|
+
end
|
|
158
|
+
|
|
159
|
+
def add_constraint(table, kind, src)
|
|
160
|
+
args = split_top_level(src)
|
|
161
|
+
constraint = { expression: parse_value(args.shift.to_s) }
|
|
162
|
+
constraint.merge!(parse_options(args.join(", ")).slice(:name, :using, :where, :deferrable, :nulls_not_distinct))
|
|
163
|
+
constraint[:columns] = Array(constraint.delete(:expression)) if kind == "unique" && src.start_with?("[")
|
|
164
|
+
(table[:"#{kind}_constraints"] ||= []) << constraint
|
|
165
|
+
end
|
|
166
|
+
|
|
167
|
+
def add_view(match)
|
|
168
|
+
opts = parse_options(match[2])
|
|
169
|
+
view = { name: table_name(match[1]) }
|
|
170
|
+
view[:materialized] = true if opts[:materialized] == true
|
|
171
|
+
@views << view
|
|
172
|
+
end
|
|
173
|
+
|
|
174
|
+
# SQLite full-text search tables (Rails 8 `create_virtual_table "posts_search", "fts5", ["title"]`).
|
|
175
|
+
def add_virtual_table(match)
|
|
176
|
+
columns = Array(parse_value(match[3])).map { |name| { name: name.to_s, type: "text", null: true } }
|
|
177
|
+
@tables << { name: match[1], primary_key: nil, virtual: match[2], columns: columns, indexes: [] }
|
|
178
|
+
end
|
|
179
|
+
|
|
180
|
+
# With several schemas on the search path Rails dumps every table and enum qualified ("public.users").
|
|
181
|
+
# Names in the default `public` schema are reported plain; other schemas keep the prefix.
|
|
182
|
+
def table_name(name)
|
|
183
|
+
name.delete_prefix("public.")
|
|
184
|
+
end
|
|
185
|
+
|
|
186
|
+
def find_table(name)
|
|
187
|
+
@tables.find { |t| t[:name] == name } || (@table if @table && @table[:name] == name)
|
|
188
|
+
end
|
|
189
|
+
|
|
190
|
+
def warn(st, message)
|
|
191
|
+
@warnings << { line: st.line, statement: abbreviate(st.text), message: message }
|
|
192
|
+
end
|
|
193
|
+
|
|
194
|
+
def abbreviate(text)
|
|
195
|
+
one_line = text.gsub(/\s+/, " ")
|
|
196
|
+
one_line.length > 120 ? "#{one_line[0, 117]}..." : one_line
|
|
197
|
+
end
|
|
198
|
+
|
|
199
|
+
# --- building ---------------------------------------------------------------------------------------
|
|
200
|
+
|
|
84
201
|
def build_column(type, name, opts_src)
|
|
85
202
|
opts = parse_options(opts_src)
|
|
86
|
-
|
|
87
|
-
|
|
203
|
+
if type == "virtual"
|
|
204
|
+
col = { name: name, type: opts[:type]&.to_s || "virtual", null: opts.key?(:null) ? opts[:null] : true }
|
|
205
|
+
col[:virtual] = true
|
|
206
|
+
col[:as] = opts[:as] if opts.key?(:as)
|
|
207
|
+
col[:stored] = opts[:stored] if opts.key?(:stored)
|
|
208
|
+
else
|
|
209
|
+
col = { name: name, type: type, null: opts.key?(:null) ? opts[:null] : true }
|
|
210
|
+
end
|
|
211
|
+
%i[default limit precision scale array comment enum_type collation].each { |k| col[k] = opts[k] if opts.key?(k) }
|
|
212
|
+
col[:enum_type] = table_name(col[:enum_type].to_s) if col[:enum_type]
|
|
88
213
|
col
|
|
89
214
|
end
|
|
90
215
|
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
216
|
+
# `src` is the column list (an array, one name, or an SQL expression string) followed by options.
|
|
217
|
+
def build_index(table, src)
|
|
218
|
+
args = split_top_level(src)
|
|
219
|
+
columns = parse_value(args.shift.to_s)
|
|
220
|
+
opts = parse_options(args.join(", "))
|
|
94
221
|
index = { table: table, name: opts[:name], columns: Array(columns), unique: opts[:unique] == true }
|
|
95
|
-
%i[where using order].each { |k| index[k] = opts[k] if opts.key?(k) }
|
|
222
|
+
%i[where using order opclass include nulls_not_distinct].each { |k| index[k] = opts[k] if opts.key?(k) }
|
|
96
223
|
index
|
|
97
224
|
end
|
|
98
225
|
|
|
99
226
|
def primary_key_for(opts)
|
|
100
227
|
return nil if opts[:id] == false
|
|
101
|
-
return
|
|
228
|
+
return opts[:primary_key].map(&:to_s) if opts[:primary_key].is_a?(Array)
|
|
102
229
|
|
|
103
230
|
opts[:primary_key] || "id"
|
|
104
231
|
end
|
|
105
232
|
|
|
106
233
|
def id_type(opts)
|
|
107
|
-
case opts[:id]
|
|
234
|
+
case (id = opts[:id])
|
|
108
235
|
when nil, true then "bigint"
|
|
109
|
-
|
|
236
|
+
when /\A\{(.*)\}\z/m then parse_options(Regexp.last_match(1))[:type]&.to_s || "bigint"
|
|
237
|
+
else id.to_s
|
|
110
238
|
end
|
|
111
239
|
end
|
|
112
240
|
|
|
@@ -118,12 +246,84 @@ module RailsAgentKit
|
|
|
118
246
|
end
|
|
119
247
|
end
|
|
120
248
|
|
|
249
|
+
# --- lexing -----------------------------------------------------------------------------------------
|
|
250
|
+
|
|
251
|
+
# Splits the source into statements: comments removed, continuation lines joined, heredoc bodies attached
|
|
252
|
+
# to the statement that opened them.
|
|
253
|
+
def statements
|
|
254
|
+
lines = @source.lines
|
|
255
|
+
out = []
|
|
256
|
+
i = 0
|
|
257
|
+
while i < lines.size
|
|
258
|
+
start = i
|
|
259
|
+
text = strip_comment(lines[i]).strip
|
|
260
|
+
i += 1
|
|
261
|
+
next if text.empty?
|
|
262
|
+
|
|
263
|
+
heredoc = +""
|
|
264
|
+
loop do
|
|
265
|
+
# Heredoc bodies follow the line that opens them; skip to their terminators.
|
|
266
|
+
text.scan(HEREDOC).each do |_, _, id|
|
|
267
|
+
while i < lines.size
|
|
268
|
+
body_line = lines[i]
|
|
269
|
+
i += 1
|
|
270
|
+
break if body_line.strip == id
|
|
271
|
+
|
|
272
|
+
heredoc << body_line
|
|
273
|
+
end
|
|
274
|
+
end
|
|
275
|
+
break unless continues?(text) && i < lines.size
|
|
276
|
+
|
|
277
|
+
nxt = strip_comment(lines[i]).strip
|
|
278
|
+
i += 1
|
|
279
|
+
text = "#{text} #{nxt}".strip
|
|
280
|
+
end
|
|
281
|
+
if text.match?(/\A#{HEREDOC}\z/) && out.any?
|
|
282
|
+
# A heredoc on its own line is the body of the block the previous statement opened.
|
|
283
|
+
out.last.heredoc = "#{out.last.heredoc}#{heredoc}"
|
|
284
|
+
next
|
|
285
|
+
end
|
|
286
|
+
out << Statement.new(text: text, line: start + 1, heredoc: heredoc.empty? ? nil : heredoc)
|
|
287
|
+
end
|
|
288
|
+
out
|
|
289
|
+
end
|
|
290
|
+
|
|
291
|
+
# True while brackets are open, or the line ends with `,`, `.` or `\`, or opens a lambda/hash.
|
|
292
|
+
def continues?(text)
|
|
293
|
+
depth = 0
|
|
294
|
+
in_str = nil
|
|
295
|
+
text.each_char.with_index do |ch, idx|
|
|
296
|
+
if in_str
|
|
297
|
+
in_str = nil if ch == in_str && !escaped?(text, idx)
|
|
298
|
+
next
|
|
299
|
+
end
|
|
300
|
+
case ch
|
|
301
|
+
when '"', "'" then in_str = ch
|
|
302
|
+
when "[", "{", "(" then depth += 1
|
|
303
|
+
when "]", "}", ")" then depth -= 1
|
|
304
|
+
end
|
|
305
|
+
end
|
|
306
|
+
return true if in_str || depth.positive?
|
|
307
|
+
|
|
308
|
+
text.end_with?(",", ".", "\\", "||", "&&")
|
|
309
|
+
end
|
|
310
|
+
|
|
311
|
+
def escaped?(text, idx)
|
|
312
|
+
backslashes = 0
|
|
313
|
+
j = idx - 1
|
|
314
|
+
while j >= 0 && text[j] == "\\"
|
|
315
|
+
backslashes += 1
|
|
316
|
+
j -= 1
|
|
317
|
+
end
|
|
318
|
+
backslashes.odd?
|
|
319
|
+
end
|
|
320
|
+
|
|
121
321
|
# Removes a trailing `# comment` that is not inside a string literal.
|
|
122
322
|
def strip_comment(line)
|
|
123
323
|
in_str = nil
|
|
124
324
|
line.each_char.with_index do |ch, i|
|
|
125
325
|
if in_str
|
|
126
|
-
in_str = nil if ch == in_str && line
|
|
326
|
+
in_str = nil if ch == in_str && !escaped?(line, i)
|
|
127
327
|
elsif ch == '"' || ch == "'"
|
|
128
328
|
in_str = ch
|
|
129
329
|
elsif ch == "#"
|
|
@@ -140,7 +340,7 @@ module RailsAgentKit
|
|
|
140
340
|
split_top_level(src).each_with_object({}) do |part, h|
|
|
141
341
|
if (m = part.match(/\A\s*(\w+):\s*(.*)\z/m))
|
|
142
342
|
h[m[1].to_sym] = parse_value(m[2].strip)
|
|
143
|
-
elsif (m = part.match(/\A\s
|
|
343
|
+
elsif (m = part.match(/\A\s*:?["']?(\w+)["']?\s*=>\s*(.*)\z/m))
|
|
144
344
|
h[m[1].to_sym] = parse_value(m[2].strip)
|
|
145
345
|
end
|
|
146
346
|
end
|
|
@@ -154,7 +354,7 @@ module RailsAgentKit
|
|
|
154
354
|
when "nil" then nil
|
|
155
355
|
when /\A-?\d+\z/ then s.to_i
|
|
156
356
|
when /\A-?\d+\.\d+\z/ then s.to_f
|
|
157
|
-
when /\A"(.*)"\z/m then Regexp.last_match(1)
|
|
357
|
+
when /\A"(.*)"\z/m then unescape(Regexp.last_match(1))
|
|
158
358
|
when /\A'(.*)'\z/m then Regexp.last_match(1)
|
|
159
359
|
when /\A:(\w+)\z/ then Regexp.last_match(1)
|
|
160
360
|
when /\A\[(.*)\]\z/m then split_top_level(Regexp.last_match(1)).map { |v| parse_value(v) }
|
|
@@ -162,6 +362,16 @@ module RailsAgentKit
|
|
|
162
362
|
end
|
|
163
363
|
end
|
|
164
364
|
|
|
365
|
+
def unescape(str)
|
|
366
|
+
str.gsub(/\\(.)/) do
|
|
367
|
+
case Regexp.last_match(1)
|
|
368
|
+
when "n" then "\n"
|
|
369
|
+
when "t" then "\t"
|
|
370
|
+
else Regexp.last_match(1)
|
|
371
|
+
end
|
|
372
|
+
end
|
|
373
|
+
end
|
|
374
|
+
|
|
165
375
|
def split_top_level(src)
|
|
166
376
|
parts = []
|
|
167
377
|
depth = 0
|
|
@@ -169,7 +379,7 @@ module RailsAgentKit
|
|
|
169
379
|
buf = +""
|
|
170
380
|
src.each_char.with_index do |ch, i|
|
|
171
381
|
if in_str
|
|
172
|
-
in_str = nil if ch == in_str && src
|
|
382
|
+
in_str = nil if ch == in_str && !escaped?(src, i)
|
|
173
383
|
buf << ch
|
|
174
384
|
next
|
|
175
385
|
end
|
|
@@ -14,11 +14,12 @@ module RailsAgentKit
|
|
|
14
14
|
name: "rails-agent-kit",
|
|
15
15
|
version: RailsAgentKit::VERSION,
|
|
16
16
|
instructions: "Ground truth about the Rails app at #{context[:root]}. Check it before guessing model " \
|
|
17
|
-
"attributes
|
|
17
|
+
"attributes, URL helpers or libraries. Call rails_project_info first: Rails and Ruby " \
|
|
18
|
+
"versions, database, test framework, auth, jobs and frontend stack. rails_schema with no arguments lists every table (column counts " \
|
|
18
19
|
"and foreign key targets); then pass table: (one name or an array) for columns, indexes " \
|
|
19
20
|
"and foreign keys, or search: to find a column by name. rails_routes lists HTTP routes; " \
|
|
20
21
|
"pass filter: to narrow them.",
|
|
21
|
-
tools: [Tools::RailsSchema, Tools::RailsRoutes],
|
|
22
|
+
tools: [Tools::RailsProjectInfo, Tools::RailsSchema, Tools::RailsRoutes],
|
|
22
23
|
server_context: context
|
|
23
24
|
)
|
|
24
25
|
end
|
|
@@ -41,6 +42,26 @@ module RailsAgentKit
|
|
|
41
42
|
end
|
|
42
43
|
end
|
|
43
44
|
|
|
45
|
+
class RailsProjectInfo < MCP::Tool
|
|
46
|
+
extend Helpers
|
|
47
|
+
|
|
48
|
+
tool_name "rails_project_info"
|
|
49
|
+
description "What kind of Rails app this is, read from Gemfile.lock and config files without booting it: " \
|
|
50
|
+
"Rails, Ruby and load_defaults versions, api_only, database adapter and databases, test " \
|
|
51
|
+
"framework and test data, auth and authorization, jobs, cache and cable backends, frontend " \
|
|
52
|
+
"(importmap or bundler, assets, Hotwire, CSS, components), serializers, lint and deploy tools, " \
|
|
53
|
+
"mounted engines, notable gems and where tests, factories, policies and components live. " \
|
|
54
|
+
"Unknown values are null. Call it first."
|
|
55
|
+
input_schema(properties: {})
|
|
56
|
+
annotations(read_only_hint: true, destructive_hint: false, idempotent_hint: true, open_world_hint: false)
|
|
57
|
+
|
|
58
|
+
def self.call(server_context:)
|
|
59
|
+
root = context_value(server_context, :root) || Dir.pwd
|
|
60
|
+
result = ProjectInfo.new(root).call
|
|
61
|
+
json_response(result, error: result.key?(:error))
|
|
62
|
+
end
|
|
63
|
+
end
|
|
64
|
+
|
|
44
65
|
class RailsSchema < MCP::Tool
|
|
45
66
|
extend Helpers
|
|
46
67
|
|
data/lib/rails_agent_kit.rb
CHANGED
|
@@ -7,10 +7,11 @@ require_relative "rails_agent_kit/schema_inspector"
|
|
|
7
7
|
require_relative "rails_agent_kit/routes_parser"
|
|
8
8
|
require_relative "rails_agent_kit/ruby_env"
|
|
9
9
|
require_relative "rails_agent_kit/routes_inspector"
|
|
10
|
+
require_relative "rails_agent_kit/project_info"
|
|
10
11
|
require_relative "rails_agent_kit/skill_installer"
|
|
11
12
|
require_relative "rails_agent_kit/client_config"
|
|
12
13
|
|
|
13
|
-
# Gives coding agents ground truth about a Rails app (schema, routes) over MCP.
|
|
14
|
+
# Gives coding agents ground truth about a Rails app (stack, schema, routes) over MCP.
|
|
14
15
|
module RailsAgentKit
|
|
15
16
|
class Error < StandardError; end
|
|
16
17
|
|
|
@@ -28,7 +28,9 @@ change the code the way this app already does things.
|
|
|
28
28
|
- **Routes:** call the `rails_routes` MCP tool (pass `filter:`) before you use a
|
|
29
29
|
`*_path`/`*_url` helper, link, form `url:` or redirect. Without it, read
|
|
30
30
|
`config/routes.rb` or run `bin/rails routes -g <term>`.
|
|
31
|
-
- **Stack:**
|
|
31
|
+
- **Stack:** call the `rails_project_info` MCP tool first, before you pick a library or
|
|
32
|
+
write a test. It reports the Rails and Ruby versions, database, test framework, auth,
|
|
33
|
+
jobs and frontend stack. Without it, read `Gemfile.lock`. Use what the app already
|
|
32
34
|
uses (RSpec or Minitest, Sidekiq or Solid Queue, Pundit, Devise or the built-in
|
|
33
35
|
auth generator, importmap or jsbundling). Don't add a gem when the app or Rails
|
|
34
36
|
already covers it.
|
metadata
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
|
2
2
|
name: rails-agent-kit
|
|
3
3
|
version: !ruby/object:Gem::Version
|
|
4
|
-
version: 0.
|
|
4
|
+
version: 0.3.0
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- Andrii Furmanets
|
|
@@ -39,6 +39,7 @@ files:
|
|
|
39
39
|
- exe/rails-agent-kit
|
|
40
40
|
- lib/rails_agent_kit.rb
|
|
41
41
|
- lib/rails_agent_kit/client_config.rb
|
|
42
|
+
- lib/rails_agent_kit/project_info.rb
|
|
42
43
|
- lib/rails_agent_kit/routes_inspector.rb
|
|
43
44
|
- lib/rails_agent_kit/routes_parser.rb
|
|
44
45
|
- lib/rails_agent_kit/ruby_env.rb
|