rails-ai-context 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +7 -0
- data/.rspec +3 -0
- data/.rubocop.yml +23 -0
- data/CHANGELOG.md +26 -0
- data/LICENSE +21 -0
- data/README.md +212 -0
- data/Rakefile +8 -0
- data/exe/rails-ai-context +67 -0
- data/lib/generators/rails_ai_context/install/install_generator.rb +85 -0
- data/lib/rails-ai-context.rb +5 -0
- data/lib/rails_ai_context/configuration.rb +52 -0
- data/lib/rails_ai_context/engine.rb +21 -0
- data/lib/rails_ai_context/introspector.rb +61 -0
- data/lib/rails_ai_context/introspectors/convention_detector.rb +125 -0
- data/lib/rails_ai_context/introspectors/gem_introspector.rb +128 -0
- data/lib/rails_ai_context/introspectors/job_introspector.rb +82 -0
- data/lib/rails_ai_context/introspectors/model_introspector.rb +163 -0
- data/lib/rails_ai_context/introspectors/route_introspector.rb +83 -0
- data/lib/rails_ai_context/introspectors/schema_introspector.rb +143 -0
- data/lib/rails_ai_context/serializers/context_file_serializer.rb +60 -0
- data/lib/rails_ai_context/serializers/json_serializer.rb +19 -0
- data/lib/rails_ai_context/serializers/markdown_serializer.rb +158 -0
- data/lib/rails_ai_context/server.rb +90 -0
- data/lib/rails_ai_context/tasks/rails_ai_context.rake +89 -0
- data/lib/rails_ai_context/tools/base_tool.rb +37 -0
- data/lib/rails_ai_context/tools/get_conventions.rb +87 -0
- data/lib/rails_ai_context/tools/get_gems.rb +49 -0
- data/lib/rails_ai_context/tools/get_model_details.rb +103 -0
- data/lib/rails_ai_context/tools/get_routes.rb +50 -0
- data/lib/rails_ai_context/tools/get_schema.rb +93 -0
- data/lib/rails_ai_context/tools/search_code.rb +111 -0
- data/lib/rails_ai_context/version.rb +5 -0
- data/lib/rails_ai_context.rb +74 -0
- data/rails-ai-context.gemspec +52 -0
- metadata +223 -0
|
@@ -0,0 +1,125 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module RailsAiContext
|
|
4
|
+
module Introspectors
|
|
5
|
+
# Detects high-level Rails conventions and patterns in use,
|
|
6
|
+
# giving AI assistants critical context about the app's architecture.
|
|
7
|
+
class ConventionDetector
|
|
8
|
+
attr_reader :app
|
|
9
|
+
|
|
10
|
+
def initialize(app)
|
|
11
|
+
@app = app
|
|
12
|
+
end
|
|
13
|
+
|
|
14
|
+
# @return [Hash] detected conventions and patterns
|
|
15
|
+
def call
|
|
16
|
+
{
|
|
17
|
+
architecture: detect_architecture,
|
|
18
|
+
patterns: detect_patterns,
|
|
19
|
+
directory_structure: scan_directory_structure,
|
|
20
|
+
config_files: detect_config_files
|
|
21
|
+
}
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
private
|
|
25
|
+
|
|
26
|
+
def root
|
|
27
|
+
app.root.to_s
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
def detect_architecture
|
|
31
|
+
arch = []
|
|
32
|
+
arch << "api_only" if app.config.api_only
|
|
33
|
+
arch << "hotwire" if dir_exists?("app/javascript/controllers") || gem_present?("turbo-rails")
|
|
34
|
+
arch << "graphql" if dir_exists?("app/graphql")
|
|
35
|
+
arch << "grape_api" if dir_exists?("app/api")
|
|
36
|
+
arch << "service_objects" if dir_exists?("app/services")
|
|
37
|
+
arch << "form_objects" if dir_exists?("app/forms")
|
|
38
|
+
arch << "query_objects" if dir_exists?("app/queries")
|
|
39
|
+
arch << "presenters" if dir_exists?("app/presenters") || dir_exists?("app/decorators")
|
|
40
|
+
arch << "view_components" if dir_exists?("app/components")
|
|
41
|
+
arch << "stimulus" if dir_exists?("app/javascript/controllers")
|
|
42
|
+
arch << "importmaps" if file_exists?("config/importmap.rb")
|
|
43
|
+
arch << "docker" if file_exists?("Dockerfile") || file_exists?("docker-compose.yml")
|
|
44
|
+
arch << "kamal" if file_exists?("config/deploy.yml")
|
|
45
|
+
arch << "ci_github_actions" if dir_exists?(".github/workflows")
|
|
46
|
+
arch
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
def detect_patterns
|
|
50
|
+
patterns = []
|
|
51
|
+
|
|
52
|
+
# Check for common Rails patterns in model files
|
|
53
|
+
model_dir = File.join(root, "app/models")
|
|
54
|
+
if Dir.exist?(model_dir)
|
|
55
|
+
model_files = Dir.glob(File.join(model_dir, "**/*.rb"))
|
|
56
|
+
content = model_files.first(50).map { |f| File.read(f) rescue "" }.join("\n")
|
|
57
|
+
|
|
58
|
+
patterns << "sti" if content.match?(/self\.inheritance_column|type.*string/)
|
|
59
|
+
patterns << "polymorphic" if content.match?(/polymorphic:\s*true/)
|
|
60
|
+
patterns << "soft_delete" if content.match?(/acts_as_paranoid|discard|deleted_at/)
|
|
61
|
+
patterns << "versioning" if content.match?(/has_paper_trail|audited/)
|
|
62
|
+
patterns << "state_machine" if content.match?(/aasm|state_machine|workflow/)
|
|
63
|
+
patterns << "multi_tenancy" if content.match?(/acts_as_tenant|apartment/)
|
|
64
|
+
patterns << "searchable" if content.match?(/searchkick|pg_search|ransack/)
|
|
65
|
+
patterns << "taggable" if content.match?(/acts_as_taggable/)
|
|
66
|
+
patterns << "sluggable" if content.match?(/friendly_id|sluggable/)
|
|
67
|
+
patterns << "nested_set" if content.match?(/acts_as_nested_set|ancestry|closure_tree/)
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
patterns
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
def scan_directory_structure
|
|
74
|
+
important_dirs = %w[
|
|
75
|
+
app/models app/controllers app/views app/jobs
|
|
76
|
+
app/mailers app/channels app/services app/forms
|
|
77
|
+
app/queries app/presenters app/decorators
|
|
78
|
+
app/components app/graphql app/api
|
|
79
|
+
app/javascript/controllers
|
|
80
|
+
config/initializers db/migrate
|
|
81
|
+
spec test
|
|
82
|
+
]
|
|
83
|
+
|
|
84
|
+
important_dirs.each_with_object({}) do |dir, hash|
|
|
85
|
+
full_path = File.join(root, dir)
|
|
86
|
+
next unless Dir.exist?(full_path)
|
|
87
|
+
|
|
88
|
+
count = Dir.glob(File.join(full_path, "**/*.rb")).size
|
|
89
|
+
count += Dir.glob(File.join(full_path, "**/*.js")).size if dir.include?("javascript")
|
|
90
|
+
|
|
91
|
+
hash[dir] = count if count > 0
|
|
92
|
+
end
|
|
93
|
+
end
|
|
94
|
+
|
|
95
|
+
def detect_config_files
|
|
96
|
+
configs = %w[
|
|
97
|
+
config/database.yml config/credentials.yml.enc
|
|
98
|
+
config/cable.yml config/storage.yml
|
|
99
|
+
config/sidekiq.yml config/deploy.yml
|
|
100
|
+
config/importmap.rb config/tailwind.config.js
|
|
101
|
+
Procfile Procfile.dev
|
|
102
|
+
.rubocop.yml .rspec
|
|
103
|
+
Dockerfile docker-compose.yml
|
|
104
|
+
.github/workflows/ci.yml
|
|
105
|
+
]
|
|
106
|
+
|
|
107
|
+
configs.select { |f| file_exists?(f) }
|
|
108
|
+
end
|
|
109
|
+
|
|
110
|
+
def dir_exists?(relative_path)
|
|
111
|
+
Dir.exist?(File.join(root, relative_path))
|
|
112
|
+
end
|
|
113
|
+
|
|
114
|
+
def file_exists?(relative_path)
|
|
115
|
+
File.exist?(File.join(root, relative_path))
|
|
116
|
+
end
|
|
117
|
+
|
|
118
|
+
def gem_present?(name)
|
|
119
|
+
lock_path = File.join(root, "Gemfile.lock")
|
|
120
|
+
return false unless File.exist?(lock_path)
|
|
121
|
+
File.read(lock_path).include?(" #{name} (")
|
|
122
|
+
end
|
|
123
|
+
end
|
|
124
|
+
end
|
|
125
|
+
end
|
|
@@ -0,0 +1,128 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module RailsAiContext
|
|
4
|
+
module Introspectors
|
|
5
|
+
# Analyzes Gemfile.lock to identify installed gems and
|
|
6
|
+
# map them to known patterns/frameworks the AI should know about.
|
|
7
|
+
class GemIntrospector
|
|
8
|
+
attr_reader :app
|
|
9
|
+
|
|
10
|
+
# Known gems that significantly affect how the app works.
|
|
11
|
+
# The AI needs to know about these to give accurate advice.
|
|
12
|
+
NOTABLE_GEMS = {
|
|
13
|
+
# Auth
|
|
14
|
+
"devise" => { category: :auth, note: "Authentication via Devise. Check User model for devise modules." },
|
|
15
|
+
"omniauth" => { category: :auth, note: "OAuth integration via OmniAuth." },
|
|
16
|
+
"pundit" => { category: :auth, note: "Authorization via Pundit policies in app/policies/." },
|
|
17
|
+
"cancancan" => { category: :auth, note: "Authorization via CanCanCan abilities." },
|
|
18
|
+
"rodauth-rails" => { category: :auth, note: "Authentication via Rodauth." },
|
|
19
|
+
|
|
20
|
+
# Background jobs
|
|
21
|
+
"sidekiq" => { category: :jobs, note: "Background jobs via Sidekiq. Check config/sidekiq.yml." },
|
|
22
|
+
"good_job" => { category: :jobs, note: "Background jobs via GoodJob (Postgres-backed)." },
|
|
23
|
+
"solid_queue" => { category: :jobs, note: "Background jobs via SolidQueue (Rails 8 default)." },
|
|
24
|
+
"delayed_job" => { category: :jobs, note: "Background jobs via DelayedJob." },
|
|
25
|
+
|
|
26
|
+
# Frontend
|
|
27
|
+
"turbo-rails" => { category: :frontend, note: "Hotwire Turbo for SPA-like navigation. Check Turbo Streams and Frames." },
|
|
28
|
+
"stimulus-rails" => { category: :frontend, note: "Stimulus.js controllers in app/javascript/controllers/." },
|
|
29
|
+
"importmap-rails" => { category: :frontend, note: "Import maps for JS (no bundler). Check config/importmap.rb." },
|
|
30
|
+
"jsbundling-rails" => { category: :frontend, note: "JS bundling (esbuild/webpack/rollup). Check package.json." },
|
|
31
|
+
"cssbundling-rails" => { category: :frontend, note: "CSS bundling. Check package.json for Tailwind/PostCSS/etc." },
|
|
32
|
+
"tailwindcss-rails" => { category: :frontend, note: "Tailwind CSS integration." },
|
|
33
|
+
"react-rails" => { category: :frontend, note: "React components in Rails views." },
|
|
34
|
+
"inertia_rails" => { category: :frontend, note: "Inertia.js for SPA with Rails backend." },
|
|
35
|
+
|
|
36
|
+
# API
|
|
37
|
+
"grape" => { category: :api, note: "API framework via Grape. Check app/api/." },
|
|
38
|
+
"graphql" => { category: :api, note: "GraphQL API. Check app/graphql/ for types and mutations." },
|
|
39
|
+
"jsonapi-serializer" => { category: :api, note: "JSON:API serialization." },
|
|
40
|
+
"jbuilder" => { category: :api, note: "JSON views via Jbuilder templates." },
|
|
41
|
+
|
|
42
|
+
# Database
|
|
43
|
+
"pg" => { category: :database, note: "PostgreSQL adapter." },
|
|
44
|
+
"mysql2" => { category: :database, note: "MySQL adapter." },
|
|
45
|
+
"sqlite3" => { category: :database, note: "SQLite adapter." },
|
|
46
|
+
"redis" => { category: :database, note: "Redis client. Used for caching/sessions/Action Cable." },
|
|
47
|
+
"solid_cache" => { category: :database, note: "Database-backed cache (Rails 8)." },
|
|
48
|
+
"solid_cable" => { category: :database, note: "Database-backed Action Cable (Rails 8)." },
|
|
49
|
+
|
|
50
|
+
# File handling
|
|
51
|
+
"activestorage" => { category: :files, note: "Active Storage for file uploads." },
|
|
52
|
+
"shrine" => { category: :files, note: "File uploads via Shrine." },
|
|
53
|
+
"carrierwave" => { category: :files, note: "File uploads via CarrierWave." },
|
|
54
|
+
|
|
55
|
+
# Testing
|
|
56
|
+
"rspec-rails" => { category: :testing, note: "RSpec test framework. Tests in spec/." },
|
|
57
|
+
"minitest" => { category: :testing, note: "Minitest framework. Tests in test/." },
|
|
58
|
+
"factory_bot_rails" => { category: :testing, note: "Test fixtures via FactoryBot in spec/factories/." },
|
|
59
|
+
"faker" => { category: :testing, note: "Fake data generation for tests." },
|
|
60
|
+
"capybara" => { category: :testing, note: "Integration/system tests with Capybara." },
|
|
61
|
+
|
|
62
|
+
# Deployment
|
|
63
|
+
"kamal" => { category: :deploy, note: "Deployment via Kamal. Check config/deploy.yml." },
|
|
64
|
+
"capistrano" => { category: :deploy, note: "Deployment via Capistrano. Check config/deploy/." }
|
|
65
|
+
}.freeze
|
|
66
|
+
|
|
67
|
+
def initialize(app)
|
|
68
|
+
@app = app
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
# @return [Hash] gem analysis
|
|
72
|
+
def call
|
|
73
|
+
lock_path = File.join(app.root, "Gemfile.lock")
|
|
74
|
+
return { error: "No Gemfile.lock found" } unless File.exist?(lock_path)
|
|
75
|
+
|
|
76
|
+
specs = parse_lockfile(lock_path)
|
|
77
|
+
|
|
78
|
+
{
|
|
79
|
+
total_gems: specs.size,
|
|
80
|
+
ruby_version: specs["ruby"]&.first,
|
|
81
|
+
notable_gems: detect_notable_gems(specs),
|
|
82
|
+
categories: categorize_gems(specs)
|
|
83
|
+
}
|
|
84
|
+
end
|
|
85
|
+
|
|
86
|
+
private
|
|
87
|
+
|
|
88
|
+
def parse_lockfile(path)
|
|
89
|
+
gems = {}
|
|
90
|
+
in_gems = false
|
|
91
|
+
|
|
92
|
+
File.readlines(path).each do |line|
|
|
93
|
+
if line.strip == "GEM"
|
|
94
|
+
in_gems = true
|
|
95
|
+
next
|
|
96
|
+
elsif line.strip.empty? || line.match?(/^\S/)
|
|
97
|
+
in_gems = false if in_gems && line.match?(/^\S/) && !line.strip.start_with?("remote:", "specs:")
|
|
98
|
+
end
|
|
99
|
+
|
|
100
|
+
if in_gems && (match = line.match(/^\s{4}(\S+)\s+\((.+)\)/))
|
|
101
|
+
gems[match[1]] = match[2]
|
|
102
|
+
end
|
|
103
|
+
end
|
|
104
|
+
|
|
105
|
+
gems
|
|
106
|
+
end
|
|
107
|
+
|
|
108
|
+
def detect_notable_gems(specs)
|
|
109
|
+
NOTABLE_GEMS.filter_map do |gem_name, info|
|
|
110
|
+
next unless specs.key?(gem_name)
|
|
111
|
+
|
|
112
|
+
{
|
|
113
|
+
name: gem_name,
|
|
114
|
+
version: specs[gem_name],
|
|
115
|
+
category: info[:category].to_s,
|
|
116
|
+
note: info[:note]
|
|
117
|
+
}
|
|
118
|
+
end
|
|
119
|
+
end
|
|
120
|
+
|
|
121
|
+
def categorize_gems(specs)
|
|
122
|
+
found = detect_notable_gems(specs)
|
|
123
|
+
found.group_by { |g| g[:category] }
|
|
124
|
+
.transform_values { |gems| gems.map { |g| g[:name] } }
|
|
125
|
+
end
|
|
126
|
+
end
|
|
127
|
+
end
|
|
128
|
+
end
|
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module RailsAiContext
|
|
4
|
+
module Introspectors
|
|
5
|
+
# Discovers background jobs (ActiveJob/Sidekiq), mailers,
|
|
6
|
+
# and Action Cable channels.
|
|
7
|
+
class JobIntrospector
|
|
8
|
+
attr_reader :app
|
|
9
|
+
|
|
10
|
+
def initialize(app)
|
|
11
|
+
@app = app
|
|
12
|
+
end
|
|
13
|
+
|
|
14
|
+
# @return [Hash] async workers, mailers, and channels
|
|
15
|
+
def call
|
|
16
|
+
{
|
|
17
|
+
jobs: extract_jobs,
|
|
18
|
+
mailers: extract_mailers,
|
|
19
|
+
channels: extract_channels
|
|
20
|
+
}
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
private
|
|
24
|
+
|
|
25
|
+
def extract_jobs
|
|
26
|
+
return [] unless defined?(ActiveJob::Base)
|
|
27
|
+
|
|
28
|
+
ActiveJob::Base.descendants.filter_map do |job|
|
|
29
|
+
next if job.name.nil? || job.name == "ApplicationJob" ||
|
|
30
|
+
job.name.start_with?("ActionMailer", "ActiveStorage::", "ActionMailbox::", "Turbo::", "Sentry::")
|
|
31
|
+
|
|
32
|
+
queue = job.queue_name
|
|
33
|
+
queue = queue.call rescue queue.to_s if queue.is_a?(Proc)
|
|
34
|
+
|
|
35
|
+
{
|
|
36
|
+
name: job.name,
|
|
37
|
+
queue: queue.to_s,
|
|
38
|
+
priority: job.priority
|
|
39
|
+
}.compact
|
|
40
|
+
end.sort_by { |j| j[:name] }
|
|
41
|
+
rescue
|
|
42
|
+
[]
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
def extract_mailers
|
|
46
|
+
return [] unless defined?(ActionMailer::Base)
|
|
47
|
+
|
|
48
|
+
ActionMailer::Base.descendants.filter_map do |mailer|
|
|
49
|
+
next if mailer.name.nil?
|
|
50
|
+
|
|
51
|
+
actions = mailer.instance_methods(false).map(&:to_s).sort
|
|
52
|
+
next if actions.empty?
|
|
53
|
+
|
|
54
|
+
{
|
|
55
|
+
name: mailer.name,
|
|
56
|
+
actions: actions,
|
|
57
|
+
delivery_method: mailer.delivery_method.to_s
|
|
58
|
+
}
|
|
59
|
+
end.sort_by { |m| m[:name] }
|
|
60
|
+
rescue
|
|
61
|
+
[]
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
def extract_channels
|
|
65
|
+
return [] unless defined?(ActionCable::Channel::Base)
|
|
66
|
+
|
|
67
|
+
ActionCable::Channel::Base.descendants.filter_map do |channel|
|
|
68
|
+
next if channel.name.nil? || channel.name == "ApplicationCable::Channel"
|
|
69
|
+
|
|
70
|
+
{
|
|
71
|
+
name: channel.name,
|
|
72
|
+
stream_methods: channel.instance_methods(false)
|
|
73
|
+
.select { |m| m.to_s.start_with?("stream_") || m == :subscribed }
|
|
74
|
+
.map(&:to_s)
|
|
75
|
+
}
|
|
76
|
+
end.sort_by { |c| c[:name] }
|
|
77
|
+
rescue
|
|
78
|
+
[]
|
|
79
|
+
end
|
|
80
|
+
end
|
|
81
|
+
end
|
|
82
|
+
end
|
|
@@ -0,0 +1,163 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module RailsAiContext
|
|
4
|
+
module Introspectors
|
|
5
|
+
# Extracts ActiveRecord model metadata: associations, validations,
|
|
6
|
+
# scopes, enums, callbacks, and class-level configuration.
|
|
7
|
+
class ModelIntrospector
|
|
8
|
+
attr_reader :app, :config
|
|
9
|
+
|
|
10
|
+
EXCLUDED_CALLBACKS = %w[autosave_associated_records_for].freeze
|
|
11
|
+
|
|
12
|
+
def initialize(app)
|
|
13
|
+
@app = app
|
|
14
|
+
@config = RailsAiContext.configuration
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
# @return [Hash] model metadata keyed by model name
|
|
18
|
+
def call
|
|
19
|
+
eager_load_models!
|
|
20
|
+
models = discover_models
|
|
21
|
+
|
|
22
|
+
models.each_with_object({}) do |model, hash|
|
|
23
|
+
hash[model.name] = extract_model_details(model)
|
|
24
|
+
rescue => e
|
|
25
|
+
hash[model.name] = { error: e.message }
|
|
26
|
+
end
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
private
|
|
30
|
+
|
|
31
|
+
def eager_load_models!
|
|
32
|
+
Rails.application.eager_load! unless Rails.application.config.eager_load
|
|
33
|
+
rescue
|
|
34
|
+
# In some environments (CI, Claude Code) eager_load may partially fail
|
|
35
|
+
nil
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
def discover_models
|
|
39
|
+
return [] unless defined?(ActiveRecord::Base)
|
|
40
|
+
|
|
41
|
+
ActiveRecord::Base.descendants.reject do |model|
|
|
42
|
+
model.abstract_class? ||
|
|
43
|
+
model.name.nil? ||
|
|
44
|
+
config.excluded_models.include?(model.name)
|
|
45
|
+
end.sort_by(&:name)
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
def extract_model_details(model)
|
|
49
|
+
{
|
|
50
|
+
table_name: model.table_name,
|
|
51
|
+
associations: extract_associations(model),
|
|
52
|
+
validations: extract_validations(model),
|
|
53
|
+
scopes: extract_scopes(model),
|
|
54
|
+
enums: extract_enums(model),
|
|
55
|
+
callbacks: extract_callbacks(model),
|
|
56
|
+
concerns: extract_concerns(model),
|
|
57
|
+
class_methods: extract_public_class_methods(model),
|
|
58
|
+
instance_methods: extract_public_instance_methods(model)
|
|
59
|
+
}.compact
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
def extract_associations(model)
|
|
63
|
+
model.reflect_on_all_associations.map do |assoc|
|
|
64
|
+
detail = {
|
|
65
|
+
name: assoc.name.to_s,
|
|
66
|
+
type: assoc.macro.to_s, # :has_many, :belongs_to, :has_one, :has_and_belongs_to_many
|
|
67
|
+
class_name: assoc.class_name,
|
|
68
|
+
foreign_key: assoc.foreign_key.to_s
|
|
69
|
+
}
|
|
70
|
+
detail[:through] = assoc.options[:through].to_s if assoc.options[:through]
|
|
71
|
+
detail[:polymorphic] = true if assoc.options[:polymorphic]
|
|
72
|
+
detail[:dependent] = assoc.options[:dependent].to_s if assoc.options[:dependent]
|
|
73
|
+
detail[:optional] = assoc.options[:optional] if assoc.options.key?(:optional)
|
|
74
|
+
detail.compact
|
|
75
|
+
end
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
def extract_validations(model)
|
|
79
|
+
model.validators.map do |validator|
|
|
80
|
+
{
|
|
81
|
+
kind: validator.kind.to_s,
|
|
82
|
+
attributes: validator.attributes.map(&:to_s),
|
|
83
|
+
options: sanitize_options(validator.options)
|
|
84
|
+
}
|
|
85
|
+
end
|
|
86
|
+
end
|
|
87
|
+
|
|
88
|
+
def extract_scopes(model)
|
|
89
|
+
model.defined_enums.keys # Enums create scopes too, but we track those separately
|
|
90
|
+
# Get actual named scopes
|
|
91
|
+
if model.respond_to?(:scope_names)
|
|
92
|
+
model.scope_names.map(&:to_s)
|
|
93
|
+
else
|
|
94
|
+
# Fallback: check for scope definitions via reflection
|
|
95
|
+
model.methods.grep(/^_scope_/).map { |m| m.to_s.sub("_scope_", "") }
|
|
96
|
+
end
|
|
97
|
+
rescue
|
|
98
|
+
[]
|
|
99
|
+
end
|
|
100
|
+
|
|
101
|
+
def extract_enums(model)
|
|
102
|
+
return {} unless model.respond_to?(:defined_enums)
|
|
103
|
+
|
|
104
|
+
model.defined_enums.transform_values do |mapping|
|
|
105
|
+
mapping.keys
|
|
106
|
+
end
|
|
107
|
+
end
|
|
108
|
+
|
|
109
|
+
def extract_callbacks(model)
|
|
110
|
+
callback_types = %i[
|
|
111
|
+
before_validation after_validation
|
|
112
|
+
before_save after_save
|
|
113
|
+
before_create after_create
|
|
114
|
+
before_update after_update
|
|
115
|
+
before_destroy after_destroy
|
|
116
|
+
after_commit after_rollback
|
|
117
|
+
]
|
|
118
|
+
|
|
119
|
+
callback_types.each_with_object({}) do |type, hash|
|
|
120
|
+
callbacks = model.send(:"_#{type}_callbacks").reject do |cb|
|
|
121
|
+
cb.filter.to_s.start_with?(*EXCLUDED_CALLBACKS) || cb.filter.is_a?(Proc)
|
|
122
|
+
end
|
|
123
|
+
|
|
124
|
+
next if callbacks.empty?
|
|
125
|
+
|
|
126
|
+
hash[type.to_s] = callbacks.map { |cb| cb.filter.to_s }
|
|
127
|
+
end
|
|
128
|
+
rescue
|
|
129
|
+
{}
|
|
130
|
+
end
|
|
131
|
+
|
|
132
|
+
def extract_concerns(model)
|
|
133
|
+
model.ancestors
|
|
134
|
+
.select { |mod| mod.is_a?(Module) && !mod.is_a?(Class) }
|
|
135
|
+
.reject { |mod| mod.name&.start_with?("ActiveRecord", "ActiveModel", "ActiveSupport") }
|
|
136
|
+
.map(&:name)
|
|
137
|
+
.compact
|
|
138
|
+
end
|
|
139
|
+
|
|
140
|
+
def extract_public_class_methods(model)
|
|
141
|
+
(model.methods - ActiveRecord::Base.methods - Object.methods)
|
|
142
|
+
.reject { |m| m.to_s.start_with?("_", "autosave") }
|
|
143
|
+
.sort
|
|
144
|
+
.first(30) # Cap to avoid noise
|
|
145
|
+
.map(&:to_s)
|
|
146
|
+
end
|
|
147
|
+
|
|
148
|
+
def extract_public_instance_methods(model)
|
|
149
|
+
(model.instance_methods - ActiveRecord::Base.instance_methods - Object.instance_methods)
|
|
150
|
+
.reject { |m| m.to_s.start_with?("_", "autosave", "validate_associated") }
|
|
151
|
+
.sort
|
|
152
|
+
.first(30)
|
|
153
|
+
.map(&:to_s)
|
|
154
|
+
end
|
|
155
|
+
|
|
156
|
+
def sanitize_options(options)
|
|
157
|
+
# Remove procs and complex objects that don't serialize well
|
|
158
|
+
options.reject { |_k, v| v.is_a?(Proc) || v.is_a?(Regexp) }
|
|
159
|
+
.transform_values(&:to_s)
|
|
160
|
+
end
|
|
161
|
+
end
|
|
162
|
+
end
|
|
163
|
+
end
|
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module RailsAiContext
|
|
4
|
+
module Introspectors
|
|
5
|
+
# Extracts route information from the Rails router including
|
|
6
|
+
# HTTP verb, path, controller#action, and route constraints.
|
|
7
|
+
class RouteIntrospector
|
|
8
|
+
attr_reader :app
|
|
9
|
+
|
|
10
|
+
def initialize(app)
|
|
11
|
+
@app = app
|
|
12
|
+
end
|
|
13
|
+
|
|
14
|
+
# @return [Hash] routes grouped by controller
|
|
15
|
+
def call
|
|
16
|
+
routes = extract_routes
|
|
17
|
+
|
|
18
|
+
{
|
|
19
|
+
total_routes: routes.size,
|
|
20
|
+
by_controller: group_by_controller(routes),
|
|
21
|
+
api_namespaces: detect_api_namespaces(routes),
|
|
22
|
+
mounted_engines: detect_mounted_engines
|
|
23
|
+
}
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
private
|
|
27
|
+
|
|
28
|
+
def extract_routes
|
|
29
|
+
app.routes.routes.filter_map do |route|
|
|
30
|
+
next if route.internal? # Skip Rails internal routes
|
|
31
|
+
next if route.defaults[:controller].blank?
|
|
32
|
+
|
|
33
|
+
{
|
|
34
|
+
verb: route.verb.presence || "ANY",
|
|
35
|
+
path: route.path.spec.to_s.gsub("(.:format)", ""),
|
|
36
|
+
controller: route.defaults[:controller],
|
|
37
|
+
action: route.defaults[:action],
|
|
38
|
+
name: route.name,
|
|
39
|
+
constraints: extract_constraints(route)
|
|
40
|
+
}.compact
|
|
41
|
+
end
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
def extract_constraints(route)
|
|
45
|
+
constraints = route.constraints.to_s
|
|
46
|
+
constraints.empty? ? nil : constraints
|
|
47
|
+
rescue
|
|
48
|
+
nil
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
def group_by_controller(routes)
|
|
52
|
+
routes.group_by { |r| r[:controller] }.transform_values do |controller_routes|
|
|
53
|
+
controller_routes.map do |r|
|
|
54
|
+
{ verb: r[:verb], path: r[:path], action: r[:action], name: r[:name] }.compact
|
|
55
|
+
end
|
|
56
|
+
end
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
def detect_api_namespaces(routes)
|
|
60
|
+
routes
|
|
61
|
+
.select { |r| r[:path].match?(%r{/api/}) }
|
|
62
|
+
.map { |r| r[:path].match(%r{(/api/v?\d*)})&.captures&.first }
|
|
63
|
+
.compact
|
|
64
|
+
.uniq
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
def detect_mounted_engines
|
|
68
|
+
app.routes.routes
|
|
69
|
+
.select { |r| r.app.respond_to?(:app) && r.app.app.is_a?(Class) }
|
|
70
|
+
.filter_map do |r|
|
|
71
|
+
engine_class = r.app.app
|
|
72
|
+
next unless engine_class < Rails::Engine
|
|
73
|
+
{
|
|
74
|
+
engine: engine_class.name,
|
|
75
|
+
path: r.path.spec.to_s
|
|
76
|
+
}
|
|
77
|
+
rescue
|
|
78
|
+
nil
|
|
79
|
+
end
|
|
80
|
+
end
|
|
81
|
+
end
|
|
82
|
+
end
|
|
83
|
+
end
|