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 +4 -4
- data/CHANGELOG.md +21 -0
- data/CLAUDE.md +3 -3
- data/README.md +4 -2
- data/demo.gif +0 -0
- data/demo_script.sh +1 -1
- data/lib/rails_ai_context/configuration.rb +4 -6
- data/lib/rails_ai_context/doctor.rb +11 -0
- data/lib/rails_ai_context/fingerprinter.rb +3 -0
- data/lib/rails_ai_context/introspector.rb +5 -0
- data/lib/rails_ai_context/introspectors/engine_introspector.rb +111 -0
- data/lib/rails_ai_context/introspectors/middleware_introspector.rb +98 -0
- data/lib/rails_ai_context/introspectors/migration_introspector.rb +127 -0
- data/lib/rails_ai_context/introspectors/multi_database_introspector.rb +144 -0
- data/lib/rails_ai_context/introspectors/seeds_introspector.rb +89 -0
- data/lib/rails_ai_context/resources.rb +12 -0
- data/lib/rails_ai_context/serializers/markdown_serializer.rb +107 -0
- data/lib/rails_ai_context/version.rb +1 -1
- metadata +6 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 03fa54ba8f83f42735ee47382cfac1c5007486dbe1287f6e06a5f5ba1c884dce
|
|
4
|
+
data.tar.gz: '082eb0840ce4d1208d2acf33ebdede21437fe743709e404b3d3d87bba53038f0'
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
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/` —
|
|
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` (
|
|
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 (
|
|
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
|
[](https://github.com/crisnahine/rails-ai-context/actions)
|
|
6
6
|
[](LICENSE)
|
|
7
7
|
|
|
8
|
-

|
|
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
|
|
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
|
@@ -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
|
|
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
|
---
|
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.
|
|
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
|