rails-ai-context 0.5.2 → 0.6.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: 8378145ec19882ed7d2eb8c088da05bca9df2cdea61f566d4690310f50d3825a
4
- data.tar.gz: 53e3377b30f932baa6ed0d42e3b91dc2b20a5e5b05f459a6315716dbe48a7161
3
+ metadata.gz: 03fa54ba8f83f42735ee47382cfac1c5007486dbe1287f6e06a5f5ba1c884dce
4
+ data.tar.gz: '082eb0840ce4d1208d2acf33ebdede21437fe743709e404b3d3d87bba53038f0'
5
5
  SHA512:
6
- metadata.gz: 629a78fe135ff955a71ceb870baf6a49c3d178e7358254ac9b88143cad6212e7b3190745835754b3b726148f0469080fd1a71d12c6c1eb1ab9b31a7bd095e3e8
7
- data.tar.gz: aeb99f7ca9638fd00066b1f4a21758ad4cbcd1f447dae77c26245b55eb9f9954165a06f1114cf0cc2091a60925e9dc42ad747d9155c2e08bb070ca5b4247f47d
6
+ metadata.gz: ab1c6d28c824bfcfb022178a385d158ecebcb5858eb6d2ee2e65d4b0cf032d33c314dfd9d614545c30deb20bb4d047be1f6db2e22a86818ef66ffb6fc294cdbe
7
+ data.tar.gz: d4318f848e29b00ef5e4f19bc918e5be03d149ec2a59597e1c7ac7497628fef8a443fefc6e96bf117fbac7d548d38c3dcd62020b4d163a7c0860f390ad38eabf
data/CHANGELOG.md CHANGED
@@ -5,6 +5,27 @@ All notable changes to this project will be documented in this file.
5
5
  The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
6
6
  and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
7
7
 
8
+ ## [0.6.0] - 2026-03-18
9
+
10
+ ### Added
11
+
12
+ - **Migrations introspector** — Discovers migration files, pending migrations, recent history, schema version, and migration statistics. Works without DB connection.
13
+ - **Seeds introspector** — Analyzes db/seeds.rb structure, discovers seed files in db/seeds/, detects which models are seeded, and identifies patterns (Faker, environment conditionals, find_or_create_by).
14
+ - **Middleware introspector** — Discovers custom Rack middleware in app/middleware/, detects patterns (auth, rate limiting, tenant isolation, logging), and categorizes the full middleware stack.
15
+ - **Engine introspector** — Discovers mounted Rails engines from routes.rb with paths and descriptions for 23+ known engines (Sidekiq::Web, Flipper::UI, PgHero, ActiveAdmin, etc.).
16
+ - **Multi-database introspector** — Discovers multiple databases, replicas, sharding config, and model-specific `connects_to` declarations. Works with database.yml parsing fallback.
17
+ - **2 new MCP resources** — `rails://migrations`, `rails://engines`
18
+ - **Migrations added to :standard preset** — AI tools now see migration context by default
19
+ - **Doctor check** — New `check_migrations` diagnostic
20
+ - **Fingerprinter** — Now watches `db/migrate/`, `app/middleware/`, and `config/database.yml`
21
+
22
+ ### Changed
23
+
24
+ - Default `:standard` preset expanded from 8 to 9 introspectors (added `:migrations`)
25
+ - Default `:full` preset expanded from 21 to 26 introspectors
26
+ - Doctor checks expanded from 11 to 12
27
+ - Static MCP resources expanded from 7 to 9
28
+
8
29
  ## [0.5.2] - 2026-03-18
9
30
 
10
31
  ### Fixed
data/CLAUDE.md CHANGED
@@ -8,7 +8,7 @@ structure to AI assistants via the Model Context Protocol (MCP).
8
8
  - `lib/rails_ai_context.rb` — Main entry point, public API (Zeitwerk autoloaded)
9
9
  - `lib/rails_ai_context/configuration.rb` — User-facing config with presets (:standard, :full)
10
10
  - `lib/rails_ai_context/introspector.rb` — Orchestrates sub-introspectors
11
- - `lib/rails_ai_context/introspectors/` — 22 introspectors (schema, models, routes, jobs, gems, conventions, stimulus, database_stats, controllers, views, turbo, i18n, config, active_storage, action_text, auth, api, tests, rake_tasks, assets, devops, action_mailbox)
11
+ - `lib/rails_ai_context/introspectors/` — 27 introspectors (schema, models, routes, jobs, gems, conventions, stimulus, database_stats, controllers, views, turbo, i18n, config, active_storage, action_text, auth, api, tests, rake_tasks, assets, devops, action_mailbox, migrations, seeds, middleware, engines, multi_database)
12
12
  - `lib/rails_ai_context/tools/` — 9 MCP tools using the official mcp SDK
13
13
  - `lib/rails_ai_context/serializers/` — Output formatters (claude, rules, copilot, markdown, JSON)
14
14
  - `lib/rails_ai_context/resources.rb` — MCP resources (static data AI clients read directly)
@@ -30,13 +30,13 @@ structure to AI assistants via the Model Context Protocol (MCP).
30
30
  6. **Diff-aware** — context regeneration skips unchanged files
31
31
  7. **Per-assistant serializers** — each AI tool gets tailored output format
32
32
  8. **Zeitwerk autoloading** — files loaded on-demand, not all upfront
33
- 9. **Introspector presets** — `:standard` (8 core) default, `:full` (21) for power users
33
+ 9. **Introspector presets** — `:standard` (9 core) default, `:full` (26) for power users
34
34
  10. **MCP auto-discovery** — `.mcp.json` generated by install generator
35
35
 
36
36
  ## Testing
37
37
 
38
38
  ```bash
39
- bundle exec rspec # Run specs (253 examples)
39
+ bundle exec rspec # Run specs (282 examples)
40
40
  bundle exec rubocop # Lint
41
41
  ```
42
42
 
data/README.md CHANGED
@@ -5,7 +5,7 @@
5
5
  [![CI](https://github.com/crisnahine/rails-ai-context/actions/workflows/ci.yml/badge.svg)](https://github.com/crisnahine/rails-ai-context/actions)
6
6
  [![License: MIT](https://img.shields.io/badge/License-MIT-blue.svg)](LICENSE)
7
7
 
8
- ![Demo](demo.gif?v=0.5.2)
8
+ ![Demo](https://raw.githubusercontent.com/crisnahine/rails-ai-context/main/demo.gif)
9
9
 
10
10
  `rails-ai-context` automatically introspects your Rails application and exposes your models, routes, schema, controllers, views, jobs, gems, auth, API, tests, config, and conventions to AI assistants through the [Model Context Protocol (MCP)](https://modelcontextprotocol.io).
11
11
 
@@ -126,6 +126,8 @@ In addition to tools, the gem registers MCP resources that AI clients can read d
126
126
  | `rails://controllers` | All controllers with actions and filters (JSON) |
127
127
  | `rails://config` | Application configuration (JSON) |
128
128
  | `rails://tests` | Test infrastructure details (JSON) |
129
+ | `rails://migrations` | Migration history and statistics (JSON) |
130
+ | `rails://engines` | Mounted engines with paths and descriptions (JSON) |
129
131
  | `rails://models/{name}` | Per-model details (resource template) |
130
132
 
131
133
  ---
@@ -169,7 +171,7 @@ In addition to tools, the gem registers MCP resources that AI clients can read d
169
171
  RailsAiContext.configure do |config|
170
172
  # Introspector presets:
171
173
  # :standard — 8 core introspectors (default, fast)
172
- # :full — all 21 introspectors (thorough)
174
+ # :full — all 26 introspectors (thorough)
173
175
  config.preset = :standard
174
176
 
175
177
  # Or cherry-pick on top of a preset:
data/demo.gif CHANGED
Binary file
data/demo_script.sh CHANGED
@@ -8,7 +8,7 @@ echo 'Fetching gem metadata from https://rubygems.org...'
8
8
  sleep 0.3
9
9
  echo 'Resolving dependencies...'
10
10
  sleep 0.3
11
- echo 'Installing rails-ai-context 0.5.2'
11
+ echo 'Installing rails-ai-context 0.6.0'
12
12
  echo ''
13
13
  sleep 1
14
14
 
@@ -3,8 +3,10 @@
3
3
  module RailsAiContext
4
4
  class Configuration
5
5
  PRESETS = {
6
- standard: %i[schema models routes jobs gems conventions controllers tests],
7
- full: %i[schema models routes jobs gems conventions stimulus controllers views turbo i18n config active_storage action_text auth api tests rake_tasks assets devops action_mailbox]
6
+ standard: %i[schema models routes jobs gems conventions controllers tests migrations],
7
+ full: %i[schema models routes jobs gems conventions stimulus controllers views turbo
8
+ i18n config active_storage action_text auth api tests rake_tasks assets
9
+ devops action_mailbox migrations seeds middleware engines multi_database]
8
10
  }.freeze
9
11
 
10
12
  # MCP server settings
@@ -28,9 +30,6 @@ module RailsAiContext
28
30
  # Models/tables to exclude from introspection
29
31
  attr_accessor :excluded_models
30
32
 
31
- # Maximum depth for association traversal
32
- attr_accessor :max_association_depth
33
-
34
33
  # TTL in seconds for cached introspection (default: 30)
35
34
  attr_accessor :cache_ttl
36
35
 
@@ -50,7 +49,6 @@ module RailsAiContext
50
49
  ActionText::RichText ActionText::EncryptedRichText
51
50
  ActionMailbox::InboundEmail ActionMailbox::Record
52
51
  ]
53
- @max_association_depth = 2
54
52
  @cache_ttl = 30
55
53
  end
56
54
 
@@ -15,6 +15,7 @@ module RailsAiContext
15
15
  check_views
16
16
  check_i18n
17
17
  check_tests
18
+ check_migrations
18
19
  check_context_files
19
20
  check_mcp_buildable
20
21
  check_ripgrep
@@ -110,6 +111,16 @@ module RailsAiContext
110
111
  end
111
112
  end
112
113
 
114
+ def check_migrations
115
+ migrate_dir = File.join(app.root, "db/migrate")
116
+ if Dir.exist?(migrate_dir) && Dir.glob(File.join(migrate_dir, "*.rb")).any?
117
+ count = Dir.glob(File.join(migrate_dir, "*.rb")).size
118
+ Check.new(name: "Migrations", status: :pass, message: "#{count} migration files found", fix: nil)
119
+ else
120
+ Check.new(name: "Migrations", status: :warn, message: "No migrations found in db/migrate/", fix: "Run `rails generate migration` to create one")
121
+ end
122
+ end
123
+
113
124
  def check_context_files
114
125
  claude_path = File.join(app.root, "CLAUDE.md")
115
126
  if File.exist?(claude_path)
@@ -9,6 +9,7 @@ module RailsAiContext
9
9
  WATCHED_FILES = %w[
10
10
  db/schema.rb
11
11
  config/routes.rb
12
+ config/database.yml
12
13
  Gemfile.lock
13
14
  ].freeze
14
15
 
@@ -20,7 +21,9 @@ module RailsAiContext
20
21
  app/mailers
21
22
  app/channels
22
23
  app/javascript/controllers
24
+ app/middleware
23
25
  config/initializers
26
+ db/migrate
24
27
  lib/tasks
25
28
  ].freeze
26
29
 
@@ -69,6 +69,11 @@ module RailsAiContext
69
69
  when :assets then Introspectors::AssetPipelineIntrospector.new(app)
70
70
  when :devops then Introspectors::DevOpsIntrospector.new(app)
71
71
  when :action_mailbox then Introspectors::ActionMailboxIntrospector.new(app)
72
+ when :migrations then Introspectors::MigrationIntrospector.new(app)
73
+ when :seeds then Introspectors::SeedsIntrospector.new(app)
74
+ when :middleware then Introspectors::MiddlewareIntrospector.new(app)
75
+ when :engines then Introspectors::EngineIntrospector.new(app)
76
+ when :multi_database then Introspectors::MultiDatabaseIntrospector.new(app)
72
77
  else
73
78
  raise ConfigurationError, "Unknown introspector: #{name}"
74
79
  end
@@ -0,0 +1,111 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RailsAiContext
4
+ module Introspectors
5
+ # Discovers mounted Rails engines and Rack apps from config/routes.rb.
6
+ # Identifies well-known engines and provides context about what each does.
7
+ class EngineIntrospector # rubocop:disable Metrics/ClassLength
8
+ attr_reader :app
9
+
10
+ KNOWN_ENGINES = {
11
+ "Sidekiq::Web" => { category: :admin, description: "Sidekiq background job dashboard" },
12
+ "GoodJob::Engine" => { category: :admin, description: "GoodJob dashboard for background jobs" },
13
+ "MissionControl::Jobs::Engine" => { category: :admin, description: "Rails Mission Control for SolidQueue jobs" },
14
+ "ActiveAdmin::Engine" => { category: :admin, description: "ActiveAdmin administration framework" },
15
+ "RailsAdmin::Engine" => { category: :admin, description: "Rails Admin dashboard" },
16
+ "Administrate::Engine" => { category: :admin, description: "Thoughtbot Administrate dashboard" },
17
+ "Avo::Engine" => { category: :admin, description: "Avo admin panel" },
18
+ "Madmin::Engine" => { category: :admin, description: "Madmin admin interface" },
19
+ "Flipper::UI" => { category: :feature_flags, description: "Flipper feature flag dashboard" },
20
+ "Flipper::Api" => { category: :feature_flags, description: "Flipper feature flag API" },
21
+ "PgHero::Engine" => { category: :monitoring, description: "PgHero PostgreSQL performance dashboard" },
22
+ "Blazer::Engine" => { category: :monitoring, description: "Blazer SQL query dashboard" },
23
+ "Coverband::Engine" => { category: :monitoring, description: "Coverband code coverage in production" },
24
+ "Rswag::Api::Engine" => { category: :api_docs, description: "Rswag API documentation (Swagger)" },
25
+ "Rswag::Ui::Engine" => { category: :api_docs, description: "Rswag Swagger UI" },
26
+ "GraphiQL::Rails::Engine" => { category: :api_docs, description: "GraphiQL in-browser IDE for GraphQL" },
27
+ "Lookbook::Engine" => { category: :ui, description: "Lookbook ViewComponent previews" },
28
+ "LetterOpenerWeb::Engine" => { category: :dev_tools, description: "Letter Opener Web email preview" },
29
+ "ActionCable.server" => { category: :realtime, description: "Action Cable WebSocket server" },
30
+ "Devise::Engine" => { category: :auth, description: "Devise authentication engine" },
31
+ "Doorkeeper::Engine" => { category: :auth, description: "Doorkeeper OAuth 2 provider" },
32
+ "ActionMailbox::Engine" => { category: :mail, description: "Action Mailbox inbound email processing" },
33
+ "ActiveStorage::Engine" => { category: :storage, description: "Active Storage file uploads" }
34
+ }.freeze
35
+
36
+ def initialize(app)
37
+ @app = app
38
+ end
39
+
40
+ # @return [Hash] mounted engines with paths and descriptions
41
+ def call
42
+ {
43
+ mounted_engines: discover_mounted_engines,
44
+ rails_engines: discover_rails_engines
45
+ }
46
+ rescue => e
47
+ { error: e.message }
48
+ end
49
+
50
+ private
51
+
52
+ def root
53
+ app.root.to_s
54
+ end
55
+
56
+ def discover_mounted_engines
57
+ routes_path = File.join(root, "config/routes.rb")
58
+ return [] unless File.exist?(routes_path)
59
+
60
+ content = File.read(routes_path)
61
+ engines = []
62
+
63
+ # Match: mount Sidekiq::Web => "/sidekiq"
64
+ # Match: mount Sidekiq::Web, at: "/sidekiq"
65
+ content.scan(/mount\s+([\w:]+(?:\.\w+)?)\s*(?:=>|,\s*at:\s*)?\s*["']([^"']+)["']/).each do |engine_name, path|
66
+ info = { engine: engine_name, path: path }
67
+ known = KNOWN_ENGINES[engine_name]
68
+ if known
69
+ info[:category] = known[:category].to_s
70
+ info[:description] = known[:description]
71
+ end
72
+ engines << info
73
+ end
74
+
75
+ # Fallback: match mount without captured path
76
+ content.scan(/mount\s+([\w:]+(?:\.\w+)?)[\s,]/).each do |match|
77
+ engine_name = match[0]
78
+ next if engines.any? { |e| e[:engine] == engine_name }
79
+
80
+ known = KNOWN_ENGINES[engine_name]
81
+ next unless known
82
+
83
+ engines << {
84
+ engine: engine_name,
85
+ path: "unknown",
86
+ category: known[:category].to_s,
87
+ description: known[:description]
88
+ }
89
+ end
90
+
91
+ engines.sort_by { |e| e[:engine] }
92
+ end
93
+
94
+ def discover_rails_engines
95
+ return [] unless defined?(Rails::Engine)
96
+
97
+ Rails::Engine.subclasses.filter_map do |engine|
98
+ next if engine.name.nil?
99
+ next if engine.name == "RailsAiContext::Engine"
100
+ next if engine.name.start_with?("Rails::", "ActionPack::", "ActionView::", "ActiveModel::")
101
+
102
+ { name: engine.name, root: engine.root.to_s.sub("#{Gem.dir}/gems/", "") }
103
+ rescue
104
+ nil
105
+ end.sort_by { |e| e[:name] }
106
+ rescue
107
+ []
108
+ end
109
+ end
110
+ end
111
+ end
@@ -0,0 +1,98 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RailsAiContext
4
+ module Introspectors
5
+ # Discovers custom Rack middleware in app/middleware/ and detects
6
+ # middleware inserted via initializers.
7
+ class MiddlewareIntrospector
8
+ attr_reader :app
9
+
10
+ def initialize(app)
11
+ @app = app
12
+ end
13
+
14
+ # @return [Hash] custom middleware files and middleware stack analysis
15
+ def call
16
+ custom = discover_custom_middleware
17
+ {
18
+ custom_middleware: custom,
19
+ middleware_stack: extract_middleware_stack,
20
+ middleware_count: middleware_count(custom)
21
+ }
22
+ rescue => e
23
+ { error: e.message }
24
+ end
25
+
26
+ private
27
+
28
+ def root
29
+ app.root.to_s
30
+ end
31
+
32
+ def discover_custom_middleware
33
+ middleware_dir = File.join(root, "app/middleware")
34
+ return [] unless Dir.exist?(middleware_dir)
35
+
36
+ Dir.glob(File.join(middleware_dir, "**/*.rb")).sort.map do |path|
37
+ content = File.read(path)
38
+ class_name = File.basename(path, ".rb").camelize
39
+
40
+ info = {
41
+ file: path.sub("#{root}/", ""),
42
+ class_name: class_name,
43
+ has_call_method: content.match?(/def\s+call\b/),
44
+ initializes_app: content.match?(/def\s+initialize\s*\(\s*app/)
45
+ }
46
+
47
+ patterns = []
48
+ patterns << "authentication" if content.match?(/auth|token|session|jwt/i)
49
+ patterns << "rate_limiting" if content.match?(/rate.?limit|throttl/i)
50
+ patterns << "logging" if content.match?(/log|Logger/i)
51
+ patterns << "cors" if content.match?(/cors|origin|Access-Control/i)
52
+ patterns << "caching" if content.match?(/cache|Cache-Control|etag/i)
53
+ patterns << "error_handling" if content.match?(/rescue|error|exception/i)
54
+ patterns << "tenant" if content.match?(/tenant|subdomain|account/i)
55
+ info[:detected_patterns] = patterns if patterns.any?
56
+
57
+ info
58
+ rescue => e
59
+ { file: path.sub("#{root}/", ""), error: e.message }
60
+ end
61
+ end
62
+
63
+ def extract_middleware_stack
64
+ app.middleware.map do |middleware|
65
+ name = middleware.name || middleware.klass.to_s
66
+ { name: name, category: categorize_middleware(name) }
67
+ end
68
+ rescue
69
+ []
70
+ end
71
+
72
+ def middleware_count(custom)
73
+ {
74
+ total: app.middleware.size,
75
+ custom: custom.size
76
+ }
77
+ rescue
78
+ {}
79
+ end
80
+
81
+ def categorize_middleware(name)
82
+ case name
83
+ when /ActionDispatch::SSL|ForceSSL/ then "security"
84
+ when /Session|Cookie/ then "session"
85
+ when /Cache|ETag|Conditional/ then "caching"
86
+ when /Logger|RequestId/ then "logging"
87
+ when /Static|Files/ then "static_files"
88
+ when /Rack::Attack/ then "rate_limiting"
89
+ when /Cors|CORS/ then "cors"
90
+ when /Executor|Reloader/ then "rails_internal"
91
+ when /ActionDispatch/ then "request_handling"
92
+ when /ActiveRecord/ then "database"
93
+ else "other"
94
+ end
95
+ end
96
+ end
97
+ end
98
+ end
@@ -0,0 +1,127 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RailsAiContext
4
+ module Introspectors
5
+ # Discovers migration files, pending migrations, and recent migration history.
6
+ # Works without a database connection by parsing db/migrate/ filenames.
7
+ class MigrationIntrospector
8
+ attr_reader :app
9
+
10
+ def initialize(app)
11
+ @app = app
12
+ end
13
+
14
+ # @return [Hash] migration info including recent, pending, and stats
15
+ def call
16
+ {
17
+ total: all_migrations.size,
18
+ recent: recent_migrations(10),
19
+ pending: pending_migrations,
20
+ schema_version: current_schema_version,
21
+ migration_stats: migration_stats
22
+ }
23
+ rescue => e
24
+ { error: e.message }
25
+ end
26
+
27
+ private
28
+
29
+ def root
30
+ app.root.to_s
31
+ end
32
+
33
+ def migrate_dir
34
+ File.join(root, "db/migrate")
35
+ end
36
+
37
+ # Parse all migration files from db/migrate/
38
+ def all_migrations
39
+ @all_migrations ||= begin
40
+ return [] unless Dir.exist?(migrate_dir)
41
+
42
+ Dir.glob(File.join(migrate_dir, "*.rb")).sort.map do |path|
43
+ filename = File.basename(path, ".rb")
44
+ version = filename.split("_").first
45
+ name = filename.sub(/\A\d+_/, "").tr("_", " ").capitalize
46
+
47
+ content = File.read(path)
48
+ actions = detect_migration_actions(content)
49
+
50
+ {
51
+ version: version,
52
+ name: name,
53
+ filename: File.basename(path),
54
+ actions: actions
55
+ }
56
+ rescue => e
57
+ { version: "unknown", name: File.basename(path), error: e.message }
58
+ end
59
+ end
60
+ end
61
+
62
+ def recent_migrations(count)
63
+ all_migrations.last(count).reverse
64
+ end
65
+
66
+ # Detect pending migrations by comparing against schema version
67
+ def pending_migrations
68
+ if defined?(ActiveRecord::Base) && ActiveRecord::Base.connection_pool.connected?
69
+ begin
70
+ context = ActiveRecord::MigrationContext.new(migrate_dir)
71
+ if context.respond_to?(:pending_migrations)
72
+ return context.pending_migrations.map do |m|
73
+ { version: m.version.to_s, name: m.name }
74
+ end
75
+ end
76
+ rescue => _e
77
+ # Fall through to file-based detection
78
+ end
79
+ end
80
+
81
+ schema_ver = current_schema_version
82
+ return [] unless schema_ver
83
+
84
+ all_migrations.select { |m| m[:version].to_i > schema_ver.to_i }.map do |m|
85
+ { version: m[:version], name: m[:name] }
86
+ end
87
+ end
88
+
89
+ def current_schema_version
90
+ schema_path = File.join(root, "db/schema.rb")
91
+ return nil unless File.exist?(schema_path)
92
+
93
+ content = File.read(schema_path)
94
+ match = content.match(/version:\s*([\d_]+)/)
95
+ match ? match[1].delete("_") : nil
96
+ rescue
97
+ nil
98
+ end
99
+
100
+ def migration_stats
101
+ return {} if all_migrations.empty?
102
+
103
+ by_year = all_migrations.group_by do |m|
104
+ version = m[:version].to_s
105
+ version.length >= 4 ? version[0..3] : "unknown"
106
+ end
107
+
108
+ {
109
+ by_year: by_year.transform_values(&:count),
110
+ total_create_table: all_migrations.count { |m| m[:actions]&.include?("create_table") },
111
+ total_add_column: all_migrations.count { |m| m[:actions]&.include?("add_column") },
112
+ total_add_index: all_migrations.count { |m| m[:actions]&.include?("add_index") }
113
+ }
114
+ end
115
+
116
+ def detect_migration_actions(content)
117
+ %w[
118
+ create_table drop_table rename_table
119
+ add_column remove_column rename_column change_column
120
+ add_index remove_index add_reference remove_reference
121
+ add_foreign_key remove_foreign_key
122
+ add_timestamps create_join_table enable_extension execute
123
+ ].select { |action| content.include?(action) }
124
+ end
125
+ end
126
+ end
127
+ end
@@ -0,0 +1,144 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RailsAiContext
4
+ module Introspectors
5
+ # Discovers multi-database configuration: multiple databases, replicas,
6
+ # sharding, and database-specific model assignments.
7
+ class MultiDatabaseIntrospector
8
+ attr_reader :app
9
+
10
+ def initialize(app)
11
+ @app = app
12
+ end
13
+
14
+ # @return [Hash] multi-database configuration
15
+ def call
16
+ dbs = discover_databases
17
+ {
18
+ databases: dbs,
19
+ replicas: discover_replicas,
20
+ sharding: detect_sharding,
21
+ model_connections: detect_model_connections,
22
+ multi_db: dbs.size > 1
23
+ }
24
+ rescue => e
25
+ { error: e.message }
26
+ end
27
+
28
+ private
29
+
30
+ def root
31
+ app.root.to_s
32
+ end
33
+
34
+ def discover_databases
35
+ if defined?(ActiveRecord::Base)
36
+ configs = ActiveRecord::Base.configurations.configs_for(env_name: Rails.env)
37
+ configs.map do |config|
38
+ info = { name: config.name, adapter: config.adapter }
39
+ info[:database] = anonymize_db_name(config.database) if config.database
40
+ info[:replica] = true if config.respond_to?(:replica?) && config.replica?
41
+ info
42
+ end
43
+ else
44
+ parse_database_yml
45
+ end
46
+ rescue
47
+ parse_database_yml
48
+ end
49
+
50
+ def discover_replicas
51
+ if defined?(ActiveRecord::Base)
52
+ configs = ActiveRecord::Base.configurations.configs_for(env_name: Rails.env)
53
+ configs.select { |c| c.respond_to?(:replica?) && c.replica? }.map do |config|
54
+ { name: config.name, adapter: config.adapter }
55
+ end
56
+ else
57
+ []
58
+ end
59
+ rescue
60
+ []
61
+ end
62
+
63
+ def detect_sharding
64
+ database_yml = File.join(root, "config/database.yml")
65
+ return nil unless File.exist?(database_yml)
66
+
67
+ content = File.read(database_yml)
68
+ { detected: true, note: "Sharding configuration found in database.yml" } if content.match?(/shard/i)
69
+ rescue
70
+ nil
71
+ end
72
+
73
+ def detect_model_connections
74
+ models_dir = File.join(root, "app/models")
75
+ return [] unless Dir.exist?(models_dir)
76
+
77
+ connections = []
78
+ Dir.glob(File.join(models_dir, "**/*.rb")).each do |path|
79
+ content = File.read(path)
80
+ model_name = File.basename(path, ".rb").camelize
81
+
82
+ if (match = content.match(/connects_to\s+(.*?\n(?:\s+.*\n)*)/m))
83
+ connects_to_text = match[1].strip.gsub(/\s+/, " ")
84
+ connections << {
85
+ model: model_name,
86
+ connects_to: connects_to_text
87
+ }
88
+ end
89
+
90
+ if content.match?(/connected_to\b/)
91
+ connections << { model: model_name, uses_connected_to: true } unless connections.any? { |c| c[:model] == model_name }
92
+ end
93
+ rescue
94
+ next
95
+ end
96
+
97
+ connections.sort_by { |c| c[:model] }
98
+ end
99
+
100
+ def parse_database_yml
101
+ path = File.join(root, "config/database.yml")
102
+ return [] unless File.exist?(path)
103
+
104
+ content = File.read(path)
105
+ databases = []
106
+ current_env = defined?(Rails) ? Rails.env : "development"
107
+ in_env = false
108
+ skip_keys = %w[adapter database host port username password encoding pool timeout socket url]
109
+
110
+ content.each_line do |line|
111
+ if line.match?(/\A#{current_env}:/)
112
+ in_env = true
113
+ next
114
+ elsif line.match?(/\A\w+:/) && in_env
115
+ break
116
+ end
117
+
118
+ next unless in_env
119
+
120
+ if line.match?(/\A\s{2}(\w+):/) && !line.include?("<<")
121
+ db_name = line.strip.chomp(":")
122
+ databases << { name: db_name } unless skip_keys.include?(db_name)
123
+ end
124
+ end
125
+
126
+ databases
127
+ rescue
128
+ []
129
+ end
130
+
131
+ def anonymize_db_name(name)
132
+ return name unless name
133
+
134
+ if name.start_with?("postgres://", "mysql://", "sqlite://")
135
+ URI.parse(name).path.sub("/", "")
136
+ else
137
+ name
138
+ end
139
+ rescue
140
+ "external"
141
+ end
142
+ end
143
+ end
144
+ end
@@ -0,0 +1,89 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RailsAiContext
4
+ module Introspectors
5
+ # Discovers database seed configuration: db/seeds.rb structure,
6
+ # seed files in db/seeds/ directory, and what models they populate.
7
+ class SeedsIntrospector
8
+ attr_reader :app
9
+
10
+ def initialize(app)
11
+ @app = app
12
+ end
13
+
14
+ # @return [Hash] seed file info and detected models
15
+ def call
16
+ {
17
+ seeds_file: analyze_seeds_file,
18
+ seed_files: discover_seed_files,
19
+ models_seeded: detect_seeded_models
20
+ }
21
+ rescue => e
22
+ { error: e.message }
23
+ end
24
+
25
+ private
26
+
27
+ def root
28
+ app.root.to_s
29
+ end
30
+
31
+ def analyze_seeds_file
32
+ path = File.join(root, "db/seeds.rb")
33
+ return nil unless File.exist?(path)
34
+
35
+ content = File.read(path)
36
+ {
37
+ exists: true,
38
+ lines: content.lines.count,
39
+ uses_find_or_create: content.match?(/find_or_create_by/),
40
+ uses_create: content.match?(/\.create[!(]?/),
41
+ uses_upsert: content.match?(/\.upsert/),
42
+ uses_insert_all: content.match?(/\.insert_all/),
43
+ uses_faker: content.match?(/Faker::/),
44
+ uses_factory_bot: content.match?(/FactoryBot/),
45
+ loads_directory: content.match?(/Dir\[|Dir\.glob|load.*seeds/),
46
+ environment_conditional: content.match?(/Rails\.env/)
47
+ }
48
+ rescue => e
49
+ { exists: false, error: e.message }
50
+ end
51
+
52
+ def discover_seed_files
53
+ seeds_dir = File.join(root, "db/seeds")
54
+ return [] unless Dir.exist?(seeds_dir)
55
+
56
+ Dir.glob(File.join(seeds_dir, "**/*.rb")).sort.map do |path|
57
+ {
58
+ file: path.sub("#{root}/", ""),
59
+ name: File.basename(path, ".rb")
60
+ }
61
+ end
62
+ end
63
+
64
+ def detect_seeded_models
65
+ models = Set.new
66
+ seed_files = [ File.join(root, "db/seeds.rb") ]
67
+
68
+ seeds_dir = File.join(root, "db/seeds")
69
+ seed_files += Dir.glob(File.join(seeds_dir, "**/*.rb")) if Dir.exist?(seeds_dir)
70
+
71
+ non_models = %w[File Dir ENV Rails Faker FactoryBot ActiveRecord IO Pathname YAML JSON CSV]
72
+
73
+ seed_files.each do |path|
74
+ next unless File.exist?(path)
75
+ content = File.read(path)
76
+
77
+ content.scan(/\b([A-Z][A-Za-z0-9]+(?:::[A-Z][A-Za-z0-9]+)*)\s*\.\s*(?:create|find_or_create_by|upsert|insert_all|new|first_or_create|seed)/).each do |match|
78
+ model_name = match[0]
79
+ models << model_name unless non_models.include?(model_name)
80
+ end
81
+ rescue
82
+ next
83
+ end
84
+
85
+ models.sort.to_a
86
+ end
87
+ end
88
+ end
89
+ end
@@ -48,6 +48,18 @@ module RailsAiContext
48
48
  description: "Test framework, factories, fixtures, CI, and coverage configuration",
49
49
  mime_type: "application/json",
50
50
  key: :tests
51
+ },
52
+ "rails://migrations" => {
53
+ name: "Migrations",
54
+ description: "Migration history, pending migrations, and migration statistics",
55
+ mime_type: "application/json",
56
+ key: :migrations
57
+ },
58
+ "rails://engines" => {
59
+ name: "Mounted Engines",
60
+ description: "Mounted Rails engines and Rack apps with paths and descriptions",
61
+ mime_type: "application/json",
62
+ key: :engines
51
63
  }
52
64
  }.freeze
53
65
 
@@ -36,6 +36,11 @@ module RailsAiContext
36
36
  sections << rake_tasks_section if context[:rake_tasks]
37
37
  sections << devops_section if context[:devops]
38
38
  sections << action_mailbox_section if context[:action_mailbox]
39
+ sections << migrations_section if context[:migrations]
40
+ sections << seeds_section if context[:seeds]
41
+ sections << middleware_section if context[:middleware]
42
+ sections << engines_section if context[:engines]
43
+ sections << multi_database_section if context[:multi_database]
39
44
  sections << footer
40
45
  sections.compact.join("\n\n")
41
46
  end
@@ -387,6 +392,108 @@ module RailsAiContext
387
392
  lines.join("\n")
388
393
  end
389
394
 
395
+ def migrations_section
396
+ data = context[:migrations]
397
+ return if data[:error]
398
+
399
+ lines = [ "## Migrations" ]
400
+ lines << "- Total: #{data[:total]}"
401
+ lines << "- Schema version: #{data[:schema_version]}" if data[:schema_version]
402
+
403
+ if data[:pending]&.any?
404
+ lines << "### Pending Migrations (#{data[:pending].size})"
405
+ data[:pending].each { |m| lines << "- `#{m[:version]}` #{m[:name]}" }
406
+ end
407
+
408
+ if data[:recent]&.any?
409
+ lines << "### Recent Migrations"
410
+ data[:recent].each do |m|
411
+ actions = m[:actions]&.any? ? " (#{m[:actions].join(', ')})" : ""
412
+ lines << "- `#{m[:version]}` #{m[:name]}#{actions}"
413
+ end
414
+ end
415
+
416
+ lines.join("\n")
417
+ end
418
+
419
+ def seeds_section
420
+ data = context[:seeds]
421
+ return if data[:error]
422
+
423
+ lines = [ "## Database Seeds" ]
424
+ if data[:seeds_file]
425
+ lines << "- Seeds file: #{data[:seeds_file][:exists] ? 'exists' : 'missing'}"
426
+ lines << "- Uses Faker: yes" if data[:seeds_file][:uses_faker]
427
+ lines << "- Environment-conditional: yes" if data[:seeds_file][:environment_conditional]
428
+ end
429
+ lines << "- Models seeded: #{data[:models_seeded].join(', ')}" if data[:models_seeded]&.any?
430
+
431
+ if data[:seed_files]&.any?
432
+ lines << "### Seed Files"
433
+ data[:seed_files].each { |f| lines << "- `#{f[:file]}`" }
434
+ end
435
+
436
+ lines.join("\n")
437
+ end
438
+
439
+ def middleware_section
440
+ data = context[:middleware]
441
+ return if data[:error]
442
+
443
+ lines = [ "## Custom Middleware" ]
444
+ if data[:custom_middleware]&.any?
445
+ data[:custom_middleware].each do |m|
446
+ detail = "- `#{m[:class_name]}` (#{m[:file]})"
447
+ detail += " — #{m[:detected_patterns].join(', ')}" if m[:detected_patterns]&.any?
448
+ lines << detail
449
+ end
450
+ else
451
+ lines << "- No custom middleware in app/middleware/"
452
+ end
453
+
454
+ lines.join("\n")
455
+ end
456
+
457
+ def engines_section
458
+ data = context[:engines]
459
+ return if data[:error]
460
+
461
+ lines = [ "## Mounted Engines" ]
462
+ if data[:mounted_engines]&.any?
463
+ data[:mounted_engines].each do |e|
464
+ desc = e[:description] ? " — #{e[:description]}" : ""
465
+ lines << "- `#{e[:engine]}` at `#{e[:path]}`#{desc}"
466
+ end
467
+ else
468
+ lines << "- No mounted engines"
469
+ end
470
+
471
+ lines.join("\n")
472
+ end
473
+
474
+ def multi_database_section
475
+ data = context[:multi_database]
476
+ return if data[:error]
477
+ return unless data[:multi_db]
478
+
479
+ lines = [ "## Multi-Database" ]
480
+ if data[:databases]&.any?
481
+ data[:databases].each do |db|
482
+ replica = db[:replica] ? " (replica)" : ""
483
+ lines << "- `#{db[:name]}` — #{db[:adapter]}#{replica}"
484
+ end
485
+ end
486
+
487
+ if data[:model_connections]&.any?
488
+ lines << "### Model Connections"
489
+ data[:model_connections].each do |c|
490
+ lines << "- `#{c[:model]}` → #{c[:connects_to] || 'custom connection'}"
491
+ end
492
+ end
493
+
494
+ lines.join("\n")
495
+ end
496
+
390
497
  def footer
391
498
  <<~MD
392
499
  ---
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module RailsAiContext
4
- VERSION = "0.5.2"
4
+ VERSION = "0.6.0"
5
5
  end
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: rails-ai-context
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.5.2
4
+ version: 0.6.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - crisnahine
@@ -208,13 +208,18 @@ files:
208
208
  - lib/rails_ai_context/introspectors/convention_detector.rb
209
209
  - lib/rails_ai_context/introspectors/database_stats_introspector.rb
210
210
  - lib/rails_ai_context/introspectors/devops_introspector.rb
211
+ - lib/rails_ai_context/introspectors/engine_introspector.rb
211
212
  - lib/rails_ai_context/introspectors/gem_introspector.rb
212
213
  - lib/rails_ai_context/introspectors/i18n_introspector.rb
213
214
  - lib/rails_ai_context/introspectors/job_introspector.rb
215
+ - lib/rails_ai_context/introspectors/middleware_introspector.rb
216
+ - lib/rails_ai_context/introspectors/migration_introspector.rb
214
217
  - lib/rails_ai_context/introspectors/model_introspector.rb
218
+ - lib/rails_ai_context/introspectors/multi_database_introspector.rb
215
219
  - lib/rails_ai_context/introspectors/rake_task_introspector.rb
216
220
  - lib/rails_ai_context/introspectors/route_introspector.rb
217
221
  - lib/rails_ai_context/introspectors/schema_introspector.rb
222
+ - lib/rails_ai_context/introspectors/seeds_introspector.rb
218
223
  - lib/rails_ai_context/introspectors/stimulus_introspector.rb
219
224
  - lib/rails_ai_context/introspectors/test_introspector.rb
220
225
  - lib/rails_ai_context/introspectors/turbo_introspector.rb