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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: a76627b569e56f98f6e73636bcad9c04907c5b78a8efecec7989a65111ee1045
4
- data.tar.gz: e8330c54dec1898177123c346e237b6b0cc4265aeebc337208b8bdecc00d2d5e
3
+ metadata.gz: d9ca6b38fb66d82ec0da3f7b0db4177e7c6757155488948b7502519ba40cc7ff
4
+ data.tar.gz: 6d265f395ab75b55bc0568136d9b9aa507b4f2608fef99917ed9dd00d6350eb8
5
5
  SHA512:
6
- metadata.gz: cfe28fdedbe2391c946c06ce4c0265bc735572b6f61de61546c52d577c346faa5d4bdddf2bd23c97551c1a9b917760e2b0cd5acef6e0a231f71d8205eaff1e39
7
- data.tar.gz: 8d6978a64aa800220d23748be96bf1ba8ad219673b2d1e06311999df8f04a336270371fa1608bbf882ac5fc54dedcc6c9af794c79a99999fb98df80c0c5784d4
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.2.0...HEAD
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.2.0), and the API may change before 1.0. Using it? Tell us how it went in
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
- Both tools are read-only and return JSON.
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 usually call `rails_schema` with no arguments first, so that
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 `rails_schema`,
273
- `rails_routes` and `Gemfile.lock` before it edits code. It then follows the Rails 8 defaults: `params.expect`, the
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
- lines.drop(lines.index(header) + 1).filter_map do |line|
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
- normalize({ name: name.empty? ? nil : name, verb: verb, path: path, action: action&.strip })
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 HEADER.match?(line.strip)
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
- # indexes and foreign keys.
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, keyword_init: true) do
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, tables: tables, foreign_keys: foreign_keys }
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
- CREATE_TABLE = /\A\s*create_table\s+["']([^"']+)["'](?:\s*,\s*(.*?))?\s+do\s*\|(\w+)\|\s*\z/
14
- COLUMN = /\A\s*(\w+)\.(\w+)\s+["']([^"']+)["']\s*(?:,\s*(.*))?\z/
15
- T_INDEX = /\A\s*(\w+)\.index\s+(\[[^\]]*\]|["'][^"']*["'])\s*(?:,\s*(.*))?\z/
16
- ADD_INDEX = /\A\s*add_index\s+["']([^"']+)["']\s*,\s*(\[[^\]]*\]|["'][^"']*["'])\s*(?:,\s*(.*))?\z/
17
- ADD_FK = /\A\s*add_foreign_key\s+["']([^"']+)["']\s*,\s*["']([^"']+)["']\s*(?:,\s*(.*))?\z/
18
- EXTENSION = /\A\s*enable_extension\s+["']([^"']+)["']/
19
- VERSION_RE = /define\(\s*version:\s*([\d_]+)\s*\)/
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
- version = @source[VERSION_RE, 1]&.delete("_")
34
- current = nil
35
- block_var = nil
36
-
37
- @source.each_line do |raw|
38
- line = strip_comment(raw).rstrip
39
- next if line.strip.empty?
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
- if (m = CREATE_TABLE.match(line))
54
- opts = parse_options(m[2])
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
- Result.new(version: version, extensions: extensions, tables: tables, foreign_keys: foreign_keys)
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
- col = { name: name, type: type, null: opts.key?(:null) ? opts[:null] : true }
87
- %i[default limit precision scale array comment].each { |k| col[k] = opts[k] if opts.key?(k) }
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
- def build_index(table, cols_src, opts_src)
92
- opts = parse_options(opts_src)
93
- columns = parse_value(cols_src)
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 nil if opts[:primary_key].is_a?(Array)
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
- else opts[:id].to_s
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[i - 1] != "\\"
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*["'](\w+)["']\s*=>\s*(.*)\z/m))
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).gsub('\\"', '"')
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[i - 1] != "\\"
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 or URL helpers. rails_schema with no arguments lists every table (column counts " \
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
 
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module RailsAgentKit
4
- VERSION = "0.2.0"
4
+ VERSION = "0.3.0"
5
5
  end
@@ -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:** read `Gemfile.lock` before you pick a library. Use what the app already
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.2.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