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.
Files changed (35) hide show
  1. checksums.yaml +7 -0
  2. data/.rspec +3 -0
  3. data/.rubocop.yml +23 -0
  4. data/CHANGELOG.md +26 -0
  5. data/LICENSE +21 -0
  6. data/README.md +212 -0
  7. data/Rakefile +8 -0
  8. data/exe/rails-ai-context +67 -0
  9. data/lib/generators/rails_ai_context/install/install_generator.rb +85 -0
  10. data/lib/rails-ai-context.rb +5 -0
  11. data/lib/rails_ai_context/configuration.rb +52 -0
  12. data/lib/rails_ai_context/engine.rb +21 -0
  13. data/lib/rails_ai_context/introspector.rb +61 -0
  14. data/lib/rails_ai_context/introspectors/convention_detector.rb +125 -0
  15. data/lib/rails_ai_context/introspectors/gem_introspector.rb +128 -0
  16. data/lib/rails_ai_context/introspectors/job_introspector.rb +82 -0
  17. data/lib/rails_ai_context/introspectors/model_introspector.rb +163 -0
  18. data/lib/rails_ai_context/introspectors/route_introspector.rb +83 -0
  19. data/lib/rails_ai_context/introspectors/schema_introspector.rb +143 -0
  20. data/lib/rails_ai_context/serializers/context_file_serializer.rb +60 -0
  21. data/lib/rails_ai_context/serializers/json_serializer.rb +19 -0
  22. data/lib/rails_ai_context/serializers/markdown_serializer.rb +158 -0
  23. data/lib/rails_ai_context/server.rb +90 -0
  24. data/lib/rails_ai_context/tasks/rails_ai_context.rake +89 -0
  25. data/lib/rails_ai_context/tools/base_tool.rb +37 -0
  26. data/lib/rails_ai_context/tools/get_conventions.rb +87 -0
  27. data/lib/rails_ai_context/tools/get_gems.rb +49 -0
  28. data/lib/rails_ai_context/tools/get_model_details.rb +103 -0
  29. data/lib/rails_ai_context/tools/get_routes.rb +50 -0
  30. data/lib/rails_ai_context/tools/get_schema.rb +93 -0
  31. data/lib/rails_ai_context/tools/search_code.rb +111 -0
  32. data/lib/rails_ai_context/version.rb +5 -0
  33. data/lib/rails_ai_context.rb +74 -0
  34. data/rails-ai-context.gemspec +52 -0
  35. 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