codebase_index 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 (171) hide show
  1. checksums.yaml +7 -0
  2. data/CHANGELOG.md +29 -0
  3. data/CODE_OF_CONDUCT.md +83 -0
  4. data/CONTRIBUTING.md +65 -0
  5. data/LICENSE.txt +21 -0
  6. data/README.md +481 -0
  7. data/exe/codebase-console-mcp +22 -0
  8. data/exe/codebase-index-mcp +61 -0
  9. data/exe/codebase-index-mcp-http +64 -0
  10. data/exe/codebase-index-mcp-start +58 -0
  11. data/lib/codebase_index/ast/call_site_extractor.rb +106 -0
  12. data/lib/codebase_index/ast/method_extractor.rb +76 -0
  13. data/lib/codebase_index/ast/node.rb +88 -0
  14. data/lib/codebase_index/ast/parser.rb +653 -0
  15. data/lib/codebase_index/ast.rb +6 -0
  16. data/lib/codebase_index/builder.rb +137 -0
  17. data/lib/codebase_index/chunking/chunk.rb +84 -0
  18. data/lib/codebase_index/chunking/semantic_chunker.rb +290 -0
  19. data/lib/codebase_index/console/adapters/cache_adapter.rb +58 -0
  20. data/lib/codebase_index/console/adapters/good_job_adapter.rb +66 -0
  21. data/lib/codebase_index/console/adapters/sidekiq_adapter.rb +66 -0
  22. data/lib/codebase_index/console/adapters/solid_queue_adapter.rb +66 -0
  23. data/lib/codebase_index/console/audit_logger.rb +75 -0
  24. data/lib/codebase_index/console/bridge.rb +170 -0
  25. data/lib/codebase_index/console/confirmation.rb +90 -0
  26. data/lib/codebase_index/console/connection_manager.rb +173 -0
  27. data/lib/codebase_index/console/console_response_renderer.rb +78 -0
  28. data/lib/codebase_index/console/model_validator.rb +81 -0
  29. data/lib/codebase_index/console/safe_context.rb +82 -0
  30. data/lib/codebase_index/console/server.rb +557 -0
  31. data/lib/codebase_index/console/sql_validator.rb +172 -0
  32. data/lib/codebase_index/console/tools/tier1.rb +118 -0
  33. data/lib/codebase_index/console/tools/tier2.rb +117 -0
  34. data/lib/codebase_index/console/tools/tier3.rb +110 -0
  35. data/lib/codebase_index/console/tools/tier4.rb +79 -0
  36. data/lib/codebase_index/coordination/pipeline_lock.rb +109 -0
  37. data/lib/codebase_index/cost_model/embedding_cost.rb +88 -0
  38. data/lib/codebase_index/cost_model/estimator.rb +128 -0
  39. data/lib/codebase_index/cost_model/provider_pricing.rb +67 -0
  40. data/lib/codebase_index/cost_model/storage_cost.rb +52 -0
  41. data/lib/codebase_index/cost_model.rb +22 -0
  42. data/lib/codebase_index/db/migrations/001_create_units.rb +38 -0
  43. data/lib/codebase_index/db/migrations/002_create_edges.rb +35 -0
  44. data/lib/codebase_index/db/migrations/003_create_embeddings.rb +37 -0
  45. data/lib/codebase_index/db/migrations/004_create_snapshots.rb +45 -0
  46. data/lib/codebase_index/db/migrations/005_create_snapshot_units.rb +40 -0
  47. data/lib/codebase_index/db/migrator.rb +71 -0
  48. data/lib/codebase_index/db/schema_version.rb +73 -0
  49. data/lib/codebase_index/dependency_graph.rb +227 -0
  50. data/lib/codebase_index/embedding/indexer.rb +130 -0
  51. data/lib/codebase_index/embedding/openai.rb +105 -0
  52. data/lib/codebase_index/embedding/provider.rb +135 -0
  53. data/lib/codebase_index/embedding/text_preparer.rb +112 -0
  54. data/lib/codebase_index/evaluation/baseline_runner.rb +115 -0
  55. data/lib/codebase_index/evaluation/evaluator.rb +146 -0
  56. data/lib/codebase_index/evaluation/metrics.rb +79 -0
  57. data/lib/codebase_index/evaluation/query_set.rb +148 -0
  58. data/lib/codebase_index/evaluation/report_generator.rb +90 -0
  59. data/lib/codebase_index/extracted_unit.rb +145 -0
  60. data/lib/codebase_index/extractor.rb +956 -0
  61. data/lib/codebase_index/extractors/action_cable_extractor.rb +228 -0
  62. data/lib/codebase_index/extractors/ast_source_extraction.rb +46 -0
  63. data/lib/codebase_index/extractors/behavioral_profile.rb +309 -0
  64. data/lib/codebase_index/extractors/caching_extractor.rb +261 -0
  65. data/lib/codebase_index/extractors/callback_analyzer.rb +232 -0
  66. data/lib/codebase_index/extractors/concern_extractor.rb +253 -0
  67. data/lib/codebase_index/extractors/configuration_extractor.rb +219 -0
  68. data/lib/codebase_index/extractors/controller_extractor.rb +494 -0
  69. data/lib/codebase_index/extractors/database_view_extractor.rb +278 -0
  70. data/lib/codebase_index/extractors/decorator_extractor.rb +260 -0
  71. data/lib/codebase_index/extractors/engine_extractor.rb +204 -0
  72. data/lib/codebase_index/extractors/event_extractor.rb +211 -0
  73. data/lib/codebase_index/extractors/factory_extractor.rb +289 -0
  74. data/lib/codebase_index/extractors/graphql_extractor.rb +917 -0
  75. data/lib/codebase_index/extractors/i18n_extractor.rb +117 -0
  76. data/lib/codebase_index/extractors/job_extractor.rb +369 -0
  77. data/lib/codebase_index/extractors/lib_extractor.rb +249 -0
  78. data/lib/codebase_index/extractors/mailer_extractor.rb +339 -0
  79. data/lib/codebase_index/extractors/manager_extractor.rb +202 -0
  80. data/lib/codebase_index/extractors/middleware_extractor.rb +133 -0
  81. data/lib/codebase_index/extractors/migration_extractor.rb +469 -0
  82. data/lib/codebase_index/extractors/model_extractor.rb +960 -0
  83. data/lib/codebase_index/extractors/phlex_extractor.rb +252 -0
  84. data/lib/codebase_index/extractors/policy_extractor.rb +214 -0
  85. data/lib/codebase_index/extractors/poro_extractor.rb +246 -0
  86. data/lib/codebase_index/extractors/pundit_extractor.rb +223 -0
  87. data/lib/codebase_index/extractors/rails_source_extractor.rb +473 -0
  88. data/lib/codebase_index/extractors/rake_task_extractor.rb +343 -0
  89. data/lib/codebase_index/extractors/route_extractor.rb +181 -0
  90. data/lib/codebase_index/extractors/scheduled_job_extractor.rb +331 -0
  91. data/lib/codebase_index/extractors/serializer_extractor.rb +334 -0
  92. data/lib/codebase_index/extractors/service_extractor.rb +254 -0
  93. data/lib/codebase_index/extractors/shared_dependency_scanner.rb +91 -0
  94. data/lib/codebase_index/extractors/shared_utility_methods.rb +99 -0
  95. data/lib/codebase_index/extractors/state_machine_extractor.rb +398 -0
  96. data/lib/codebase_index/extractors/test_mapping_extractor.rb +225 -0
  97. data/lib/codebase_index/extractors/validator_extractor.rb +225 -0
  98. data/lib/codebase_index/extractors/view_component_extractor.rb +310 -0
  99. data/lib/codebase_index/extractors/view_template_extractor.rb +261 -0
  100. data/lib/codebase_index/feedback/gap_detector.rb +89 -0
  101. data/lib/codebase_index/feedback/store.rb +119 -0
  102. data/lib/codebase_index/flow_analysis/operation_extractor.rb +209 -0
  103. data/lib/codebase_index/flow_analysis/response_code_mapper.rb +154 -0
  104. data/lib/codebase_index/flow_assembler.rb +290 -0
  105. data/lib/codebase_index/flow_document.rb +191 -0
  106. data/lib/codebase_index/flow_precomputer.rb +102 -0
  107. data/lib/codebase_index/formatting/base.rb +40 -0
  108. data/lib/codebase_index/formatting/claude_adapter.rb +98 -0
  109. data/lib/codebase_index/formatting/generic_adapter.rb +56 -0
  110. data/lib/codebase_index/formatting/gpt_adapter.rb +64 -0
  111. data/lib/codebase_index/formatting/human_adapter.rb +78 -0
  112. data/lib/codebase_index/graph_analyzer.rb +374 -0
  113. data/lib/codebase_index/mcp/index_reader.rb +394 -0
  114. data/lib/codebase_index/mcp/renderers/claude_renderer.rb +81 -0
  115. data/lib/codebase_index/mcp/renderers/json_renderer.rb +17 -0
  116. data/lib/codebase_index/mcp/renderers/markdown_renderer.rb +352 -0
  117. data/lib/codebase_index/mcp/renderers/plain_renderer.rb +240 -0
  118. data/lib/codebase_index/mcp/server.rb +935 -0
  119. data/lib/codebase_index/mcp/tool_response_renderer.rb +62 -0
  120. data/lib/codebase_index/model_name_cache.rb +51 -0
  121. data/lib/codebase_index/notion/client.rb +217 -0
  122. data/lib/codebase_index/notion/exporter.rb +219 -0
  123. data/lib/codebase_index/notion/mapper.rb +39 -0
  124. data/lib/codebase_index/notion/mappers/column_mapper.rb +65 -0
  125. data/lib/codebase_index/notion/mappers/migration_mapper.rb +39 -0
  126. data/lib/codebase_index/notion/mappers/model_mapper.rb +164 -0
  127. data/lib/codebase_index/notion/rate_limiter.rb +68 -0
  128. data/lib/codebase_index/observability/health_check.rb +81 -0
  129. data/lib/codebase_index/observability/instrumentation.rb +34 -0
  130. data/lib/codebase_index/observability/structured_logger.rb +75 -0
  131. data/lib/codebase_index/operator/error_escalator.rb +81 -0
  132. data/lib/codebase_index/operator/pipeline_guard.rb +99 -0
  133. data/lib/codebase_index/operator/status_reporter.rb +80 -0
  134. data/lib/codebase_index/railtie.rb +26 -0
  135. data/lib/codebase_index/resilience/circuit_breaker.rb +99 -0
  136. data/lib/codebase_index/resilience/index_validator.rb +185 -0
  137. data/lib/codebase_index/resilience/retryable_provider.rb +108 -0
  138. data/lib/codebase_index/retrieval/context_assembler.rb +249 -0
  139. data/lib/codebase_index/retrieval/query_classifier.rb +131 -0
  140. data/lib/codebase_index/retrieval/ranker.rb +273 -0
  141. data/lib/codebase_index/retrieval/search_executor.rb +327 -0
  142. data/lib/codebase_index/retriever.rb +160 -0
  143. data/lib/codebase_index/ruby_analyzer/class_analyzer.rb +190 -0
  144. data/lib/codebase_index/ruby_analyzer/dataflow_analyzer.rb +78 -0
  145. data/lib/codebase_index/ruby_analyzer/fqn_builder.rb +18 -0
  146. data/lib/codebase_index/ruby_analyzer/mermaid_renderer.rb +275 -0
  147. data/lib/codebase_index/ruby_analyzer/method_analyzer.rb +143 -0
  148. data/lib/codebase_index/ruby_analyzer/trace_enricher.rb +139 -0
  149. data/lib/codebase_index/ruby_analyzer.rb +87 -0
  150. data/lib/codebase_index/session_tracer/file_store.rb +111 -0
  151. data/lib/codebase_index/session_tracer/middleware.rb +143 -0
  152. data/lib/codebase_index/session_tracer/redis_store.rb +112 -0
  153. data/lib/codebase_index/session_tracer/session_flow_assembler.rb +263 -0
  154. data/lib/codebase_index/session_tracer/session_flow_document.rb +223 -0
  155. data/lib/codebase_index/session_tracer/solid_cache_store.rb +145 -0
  156. data/lib/codebase_index/session_tracer/store.rb +67 -0
  157. data/lib/codebase_index/storage/graph_store.rb +120 -0
  158. data/lib/codebase_index/storage/metadata_store.rb +169 -0
  159. data/lib/codebase_index/storage/pgvector.rb +163 -0
  160. data/lib/codebase_index/storage/qdrant.rb +172 -0
  161. data/lib/codebase_index/storage/vector_store.rb +156 -0
  162. data/lib/codebase_index/temporal/snapshot_store.rb +341 -0
  163. data/lib/codebase_index/version.rb +5 -0
  164. data/lib/codebase_index.rb +223 -0
  165. data/lib/generators/codebase_index/install_generator.rb +32 -0
  166. data/lib/generators/codebase_index/pgvector_generator.rb +37 -0
  167. data/lib/generators/codebase_index/templates/add_pgvector_to_codebase_index.rb.erb +15 -0
  168. data/lib/generators/codebase_index/templates/create_codebase_index_tables.rb.erb +43 -0
  169. data/lib/tasks/codebase_index.rake +583 -0
  170. data/lib/tasks/codebase_index_evaluation.rake +115 -0
  171. metadata +252 -0
@@ -0,0 +1,228 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative 'shared_utility_methods'
4
+ require_relative 'shared_dependency_scanner'
5
+
6
+ module CodebaseIndex
7
+ module Extractors
8
+ # ActionCableExtractor handles ActionCable channel extraction via runtime introspection.
9
+ #
10
+ # Reads `ActionCable::Channel::Base.descendants` to discover channels, then inspects
11
+ # each channel's stream subscriptions, actions, broadcast patterns, and source code.
12
+ # Each channel becomes one ExtractedUnit with metadata about streams, actions, and
13
+ # broadcast patterns.
14
+ #
15
+ # @example
16
+ # extractor = ActionCableExtractor.new
17
+ # units = extractor.extract_all
18
+ # chat = units.find { |u| u.identifier == "ChatChannel" }
19
+ # chat.metadata[:stream_names] #=> ["chat_room_#{params[:room_id]}"]
20
+ # chat.metadata[:actions] #=> ["speak", "typing"]
21
+ #
22
+ class ActionCableExtractor
23
+ include SharedUtilityMethods
24
+ include SharedDependencyScanner
25
+
26
+ # Lifecycle methods that are not user-defined actions
27
+ LIFECYCLE_METHODS = %i[subscribed unsubscribed].freeze
28
+
29
+ def initialize
30
+ # No directories to scan — this is runtime introspection
31
+ end
32
+
33
+ # Extract all ActionCable channels as ExtractedUnits.
34
+ #
35
+ # @return [Array<ExtractedUnit>] List of channel units
36
+ def extract_all
37
+ return [] unless action_cable_available?
38
+
39
+ channels = channel_descendants
40
+ return [] if channels.empty?
41
+
42
+ channels.filter_map { |klass| extract_channel(klass) }
43
+ end
44
+
45
+ # Extract a single channel class into an ExtractedUnit.
46
+ #
47
+ # Public for incremental re-extraction via CLASS_BASED dispatch.
48
+ #
49
+ # @param klass [Class] A channel subclass
50
+ # @return [ExtractedUnit, nil]
51
+ def extract_channel(klass)
52
+ name = klass.name
53
+ file_path = discover_source_path(klass, name)
54
+ source = read_source(file_path)
55
+ own_methods = klass.instance_methods(false)
56
+
57
+ unit = ExtractedUnit.new(
58
+ type: :action_cable_channel,
59
+ identifier: name,
60
+ file_path: file_path
61
+ )
62
+
63
+ unit.namespace = extract_namespace(name)
64
+ unit.source_code = source
65
+ unit.metadata = build_metadata(source, own_methods)
66
+ unit.dependencies = source.empty? ? [] : scan_common_dependencies(source)
67
+
68
+ unit
69
+ rescue StandardError => e
70
+ log_extraction_error(name, e)
71
+ nil
72
+ end
73
+
74
+ private
75
+
76
+ # Check if ActionCable::Channel::Base is defined.
77
+ #
78
+ # @return [Boolean]
79
+ def action_cable_available?
80
+ defined?(ActionCable::Channel::Base)
81
+ end
82
+
83
+ # Retrieve channel descendants, filtering out abstract bases and anonymous classes.
84
+ #
85
+ # @return [Array<Class>]
86
+ def channel_descendants
87
+ ActionCable::Channel::Base.descendants.reject do |klass|
88
+ klass.name.nil? || klass.name == 'ApplicationCable::Channel'
89
+ end
90
+ end
91
+
92
+ # Discover the source file path for a channel class.
93
+ #
94
+ # Tries source_location on instance methods, then falls back to
95
+ # the Rails convention path.
96
+ #
97
+ # @param klass [Class] The channel class
98
+ # @param name [String] The channel class name
99
+ # @return [String, nil]
100
+ def discover_source_path(klass, name)
101
+ path = source_location_from_methods(klass)
102
+ return path if path
103
+
104
+ convention_fallback(name)
105
+ end
106
+
107
+ # Try to get source_location from the channel's instance methods.
108
+ # Tries subscribed first, then any other instance method.
109
+ #
110
+ # @param klass [Class] The channel class
111
+ # @return [String, nil]
112
+ def source_location_from_methods(klass)
113
+ try_methods = [:subscribed] + (klass.instance_methods(false) - [:subscribed])
114
+ try_methods.each do |method_name|
115
+ location = klass.instance_method(method_name).source_location
116
+ return location[0] if location
117
+ rescue NameError, TypeError
118
+ next
119
+ end
120
+ nil
121
+ rescue StandardError
122
+ nil
123
+ end
124
+
125
+ # Fall back to Rails convention path for channel files.
126
+ #
127
+ # @param name [String] Channel class name
128
+ # @return [String, nil]
129
+ def convention_fallback(name)
130
+ return nil unless defined?(Rails) && Rails.respond_to?(:root) && Rails.root
131
+
132
+ path = Rails.root.join('app', 'channels', "#{name.underscore}.rb").to_s
133
+ File.exist?(path) ? path : nil
134
+ end
135
+
136
+ # Read source code from a file path.
137
+ #
138
+ # @param file_path [String, nil]
139
+ # @return [String]
140
+ def read_source(file_path)
141
+ return '' unless file_path && File.exist?(file_path)
142
+
143
+ File.read(file_path)
144
+ rescue StandardError
145
+ ''
146
+ end
147
+
148
+ # Build metadata hash for a channel.
149
+ #
150
+ # @param source [String] Channel source code
151
+ # @param own_methods [Array<Symbol>] Methods defined directly on the channel
152
+ # @return [Hash]
153
+ def build_metadata(source, own_methods)
154
+ {
155
+ stream_names: detect_stream_names(source),
156
+ actions: detect_actions(own_methods),
157
+ has_subscribed: own_methods.include?(:subscribed),
158
+ has_unsubscribed: own_methods.include?(:unsubscribed),
159
+ broadcasts_to: detect_broadcasts(source),
160
+ loc: count_loc(source)
161
+ }
162
+ end
163
+
164
+ # Detect stream names from stream_from and stream_for calls.
165
+ #
166
+ # @param source [String] Channel source code
167
+ # @return [Array<String>]
168
+ def detect_stream_names(source)
169
+ streams = []
170
+
171
+ # stream_from "string" or stream_from 'string' (also catches interpolated strings)
172
+ streams.concat(source.scan(/stream_from\s+["']([^"']+)["']/).flatten)
173
+
174
+ # stream_for model
175
+ streams.concat(source.scan(/stream_for\s+(\w+)/).map { |m| "stream_for:#{m[0]}" })
176
+
177
+ streams.uniq
178
+ end
179
+
180
+ # Detect action methods (public instance methods minus lifecycle methods).
181
+ #
182
+ # @param own_methods [Array<Symbol>] Methods defined directly on the channel
183
+ # @return [Array<String>]
184
+ def detect_actions(own_methods)
185
+ (own_methods - LIFECYCLE_METHODS).map(&:to_s)
186
+ end
187
+
188
+ # Detect broadcast patterns in source code.
189
+ #
190
+ # @param source [String] Channel source code
191
+ # @return [Array<String>]
192
+ def detect_broadcasts(source)
193
+ broadcasts = []
194
+
195
+ # ActionCable.server.broadcast("channel_name", ...)
196
+ broadcasts.concat(source.scan(/ActionCable\.server\.broadcast\(\s*["']([^"']+)["']/).flatten)
197
+
198
+ # SomeChannel.broadcast_to(target, ...)
199
+ broadcasts.concat(source.scan(/\w+\.broadcast_to\(\s*(\w+)/).map { |m| "broadcast_to:#{m[0]}" })
200
+
201
+ broadcasts.uniq
202
+ end
203
+
204
+ # Count non-blank, non-comment lines.
205
+ #
206
+ # @param source [String]
207
+ # @return [Integer]
208
+ def count_loc(source)
209
+ return 0 if source.empty?
210
+
211
+ source.each_line.count do |line|
212
+ stripped = line.strip
213
+ !stripped.empty? && !stripped.start_with?('#')
214
+ end
215
+ end
216
+
217
+ # Log a channel extraction error.
218
+ #
219
+ # @param name [String] Channel class name
220
+ # @param error [StandardError]
221
+ def log_extraction_error(name, error)
222
+ return unless defined?(Rails) && Rails.respond_to?(:logger) && Rails.logger
223
+
224
+ Rails.logger.error("Failed to extract channel #{name}: #{error.message}")
225
+ end
226
+ end
227
+ end
228
+ end
@@ -0,0 +1,46 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative '../ast/method_extractor'
4
+
5
+ module CodebaseIndex
6
+ module Extractors
7
+ # Shared extraction of individual method source code via the AST layer.
8
+ #
9
+ # Included by extractors that need to pull a single method's source from
10
+ # a class (e.g., ControllerExtractor, MailerExtractor).
11
+ #
12
+ # @example
13
+ # class FooExtractor
14
+ # include AstSourceExtraction
15
+ #
16
+ # def build_chunk(klass, action)
17
+ # source = extract_action_source(klass, action)
18
+ # # ...
19
+ # end
20
+ # end
21
+ #
22
+ module AstSourceExtraction
23
+ private
24
+
25
+ # Extract the source code of a single action method using the AST layer.
26
+ #
27
+ # @param klass [Class] The class that defines the method
28
+ # @param action [String, Symbol] The method name to extract
29
+ # @return [String, nil] The method source, or nil if not extractable
30
+ def extract_action_source(klass, action)
31
+ method = klass.instance_method(action)
32
+ source_location = method.source_location
33
+ return nil unless source_location
34
+
35
+ file, _line = source_location
36
+ return nil unless File.exist?(file)
37
+
38
+ source = File.read(file)
39
+ Ast::MethodExtractor.new.extract_method_source(source, action.to_s)
40
+ rescue StandardError => e
41
+ Rails.logger.debug("Could not extract action source for #{klass}##{action}: #{e.message}")
42
+ nil
43
+ end
44
+ end
45
+ end
46
+ end
@@ -0,0 +1,309 @@
1
+ # frozen_string_literal: true
2
+
3
+ module CodebaseIndex
4
+ module Extractors
5
+ # BehavioralProfile introspects resolved Rails.application.config values
6
+ # to produce a single ExtractedUnit summarizing the app's runtime
7
+ # behavioral configuration.
8
+ #
9
+ # Sections extracted (each independently guarded):
10
+ # - Database: adapter, schema_format, belongs_to_required, has_many_inversing
11
+ # - Frameworks: ActionCable, ActiveStorage, ActionMailbox, ActionText, Turbo, Stimulus, SolidQueue, SolidCache
12
+ # - Behavior flags: api_only, eager_load, time_zone, strong params action, session store, filter params
13
+ # - Background processing: active_job queue_adapter
14
+ # - Caching: cache_store type
15
+ # - Email: delivery_method
16
+ #
17
+ # @example
18
+ # profile = BehavioralProfile.new
19
+ # unit = profile.extract
20
+ # unit.metadata[:database][:adapter] #=> "postgresql"
21
+ #
22
+ class BehavioralProfile
23
+ # Frameworks to detect via `defined?` checks
24
+ FRAMEWORK_CHECKS = {
25
+ action_cable: 'ActionCable',
26
+ active_storage: 'ActiveStorage',
27
+ action_mailbox: 'ActionMailbox',
28
+ action_text: 'ActionText',
29
+ turbo: 'Turbo',
30
+ stimulus_reflex: 'StimulusReflex',
31
+ solid_queue: 'SolidQueue',
32
+ solid_cache: 'SolidCache'
33
+ }.freeze
34
+
35
+ # Extract a behavioral profile from the current Rails application.
36
+ #
37
+ # @return [ExtractedUnit, nil] A single configuration unit, or nil on catastrophic failure
38
+ def extract
39
+ config = Rails.application.config
40
+
41
+ profile = {
42
+ config_type: 'behavioral_profile',
43
+ rails_version: Rails.version,
44
+ ruby_version: RUBY_VERSION,
45
+ database: extract_database(config),
46
+ frameworks_active: extract_frameworks,
47
+ behavior_flags: extract_behavior_flags(config),
48
+ background_processing: extract_background(config),
49
+ caching: extract_caching(config),
50
+ email: extract_email(config)
51
+ }
52
+
53
+ build_unit(profile)
54
+ rescue StandardError => e
55
+ Rails.logger.error("BehavioralProfile extraction failed: #{e.message}")
56
+ nil
57
+ end
58
+
59
+ private
60
+
61
+ # ──────────────────────────────────────────────────────────────────────
62
+ # Database
63
+ # ──────────────────────────────────────────────────────────────────────
64
+
65
+ # Extract database configuration from ActiveRecord.
66
+ #
67
+ # @param config [Rails::Application::Configuration]
68
+ # @return [Hash]
69
+ def extract_database(config)
70
+ return {} unless defined?(ActiveRecord::Base)
71
+
72
+ result = {}
73
+
74
+ if ActiveRecord::Base.respond_to?(:connection_db_config)
75
+ result[:adapter] = ActiveRecord::Base.connection_db_config.adapter
76
+ end
77
+
78
+ if config.respond_to?(:active_record)
79
+ ar = config.active_record
80
+ result[:schema_format] = ar.schema_format if ar.respond_to?(:schema_format)
81
+ if ar.respond_to?(:belongs_to_required_by_default)
82
+ result[:belongs_to_required_by_default] = ar.belongs_to_required_by_default
83
+ end
84
+ result[:has_many_inversing] = ar.has_many_inversing if ar.respond_to?(:has_many_inversing)
85
+ end
86
+
87
+ result
88
+ rescue StandardError => e
89
+ Rails.logger.error("BehavioralProfile database section failed: #{e.message}")
90
+ {}
91
+ end
92
+
93
+ # ──────────────────────────────────────────────────────────────────────
94
+ # Frameworks
95
+ # ──────────────────────────────────────────────────────────────────────
96
+
97
+ # Detect which optional frameworks are loaded.
98
+ #
99
+ # @return [Hash]
100
+ def extract_frameworks
101
+ FRAMEWORK_CHECKS.transform_values do |constant_name|
102
+ Object.const_defined?(constant_name)
103
+ end
104
+ rescue StandardError => e
105
+ Rails.logger.error("BehavioralProfile frameworks section failed: #{e.message}")
106
+ {}
107
+ end
108
+
109
+ # ──────────────────────────────────────────────────────────────────────
110
+ # Behavior flags
111
+ # ──────────────────────────────────────────────────────────────────────
112
+
113
+ # Extract behavior flags from Rails config.
114
+ #
115
+ # @param config [Rails::Application::Configuration]
116
+ # @return [Hash]
117
+ def extract_behavior_flags(config)
118
+ flags = {}
119
+
120
+ safe_read(config, :api_only) { |v| flags[:api_only] = v }
121
+ safe_read(config, :eager_load) { |v| flags[:eager_load] = v }
122
+ safe_read(config, :time_zone) { |v| flags[:time_zone] = v }
123
+ safe_read(config, :session_store) { |v| flags[:session_store] = v }
124
+ safe_read(config, :filter_parameters) { |v| flags[:filter_parameters] = v }
125
+
126
+ if config.respond_to?(:action_controller)
127
+ ac = config.action_controller
128
+ if ac.respond_to?(:action_on_unpermitted_parameters)
129
+ flags[:action_on_unpermitted_parameters] = ac.action_on_unpermitted_parameters
130
+ end
131
+ end
132
+
133
+ flags
134
+ rescue StandardError => e
135
+ Rails.logger.error("BehavioralProfile behavior_flags section failed: #{e.message}")
136
+ {}
137
+ end
138
+
139
+ # ──────────────────────────────────────────────────────────────────────
140
+ # Background processing
141
+ # ──────────────────────────────────────────────────────────────────────
142
+
143
+ # Extract background processing configuration.
144
+ #
145
+ # @param config [Rails::Application::Configuration]
146
+ # @return [Hash]
147
+ def extract_background(config)
148
+ return {} unless config.respond_to?(:active_job)
149
+
150
+ aj = config.active_job
151
+ return {} unless aj.respond_to?(:queue_adapter)
152
+
153
+ { adapter: aj.queue_adapter }
154
+ rescue StandardError => e
155
+ Rails.logger.error("BehavioralProfile background section failed: #{e.message}")
156
+ {}
157
+ end
158
+
159
+ # ──────────────────────────────────────────────────────────────────────
160
+ # Caching
161
+ # ──────────────────────────────────────────────────────────────────────
162
+
163
+ # Extract caching configuration.
164
+ #
165
+ # @param config [Rails::Application::Configuration]
166
+ # @return [Hash]
167
+ def extract_caching(config)
168
+ return {} unless config.respond_to?(:cache_store)
169
+
170
+ raw = config.cache_store
171
+ store = raw.is_a?(Array) ? raw.first : raw
172
+
173
+ { store: store }
174
+ rescue StandardError => e
175
+ Rails.logger.error("BehavioralProfile caching section failed: #{e.message}")
176
+ {}
177
+ end
178
+
179
+ # ──────────────────────────────────────────────────────────────────────
180
+ # Email
181
+ # ──────────────────────────────────────────────────────────────────────
182
+
183
+ # Extract email delivery configuration.
184
+ #
185
+ # @param config [Rails::Application::Configuration]
186
+ # @return [Hash]
187
+ def extract_email(config)
188
+ return {} unless config.respond_to?(:action_mailer)
189
+
190
+ am = config.action_mailer
191
+ return {} unless am.respond_to?(:delivery_method)
192
+
193
+ { delivery_method: am.delivery_method }
194
+ rescue StandardError => e
195
+ Rails.logger.error("BehavioralProfile email section failed: #{e.message}")
196
+ {}
197
+ end
198
+
199
+ # ──────────────────────────────────────────────────────────────────────
200
+ # Unit construction
201
+ # ──────────────────────────────────────────────────────────────────────
202
+
203
+ # Build the ExtractedUnit from the assembled profile hash.
204
+ #
205
+ # @param profile [Hash]
206
+ # @return [ExtractedUnit]
207
+ def build_unit(profile)
208
+ unit = ExtractedUnit.new(
209
+ type: :configuration,
210
+ identifier: 'BehavioralProfile',
211
+ file_path: Rails.root.join('config/application.rb').to_s
212
+ )
213
+
214
+ unit.namespace = 'behavioral_profile'
215
+ unit.metadata = profile
216
+ unit.source_code = build_narrative(profile)
217
+ unit.dependencies = build_dependencies(profile)
218
+
219
+ unit
220
+ end
221
+
222
+ # Generate a human-readable narrative summary.
223
+ #
224
+ # @param profile [Hash]
225
+ # @return [String]
226
+ def build_narrative(profile)
227
+ lines = []
228
+ lines << '# Behavioral Profile'
229
+ lines << "# Rails #{profile[:rails_version]} / Ruby #{profile[:ruby_version]}"
230
+ lines << '#'
231
+
232
+ # Database
233
+ db = profile[:database]
234
+ if db.any?
235
+ lines << "# Database: #{db[:adapter] || 'unknown'}"
236
+ lines << "# schema_format: #{db[:schema_format]}" if db[:schema_format]
237
+ unless db[:belongs_to_required_by_default].nil?
238
+ lines << "# belongs_to_required: #{db[:belongs_to_required_by_default]}"
239
+ end
240
+ lines << "# has_many_inversing: #{db[:has_many_inversing]}" unless db[:has_many_inversing].nil?
241
+ end
242
+
243
+ # Frameworks
244
+ active = profile[:frameworks_active].select { |_, v| v }
245
+ if active.any?
246
+ lines << '#'
247
+ lines << "# Active frameworks: #{active.keys.map { |k| FRAMEWORK_CHECKS[k] || k.to_s }.join(', ')}"
248
+ end
249
+
250
+ # Behavior flags
251
+ flags = profile[:behavior_flags]
252
+ if flags.any?
253
+ lines << '#'
254
+ lines << '# Behavior flags:'
255
+ flags.each { |k, v| lines << "# #{k}: #{v}" }
256
+ end
257
+
258
+ # Background
259
+ bg = profile[:background_processing]
260
+ if bg.any?
261
+ lines << '#'
262
+ lines << "# Background: #{bg[:adapter]}"
263
+ end
264
+
265
+ # Caching
266
+ cache = profile[:caching]
267
+ if cache.any?
268
+ lines << '#'
269
+ lines << "# Cache store: #{cache[:store]}"
270
+ end
271
+
272
+ # Email
273
+ email = profile[:email]
274
+ if email.any?
275
+ lines << '#'
276
+ lines << "# Email delivery: #{email[:delivery_method]}"
277
+ end
278
+
279
+ lines.join("\n")
280
+ end
281
+
282
+ # Build dependency list from detected frameworks and adapters.
283
+ #
284
+ # @param profile [Hash]
285
+ # @return [Array<Hash>]
286
+ def build_dependencies(profile)
287
+ deps = []
288
+
289
+ profile[:frameworks_active].each do |key, active|
290
+ next unless active
291
+
292
+ constant_name = FRAMEWORK_CHECKS[key] || key.to_s
293
+ deps << { type: :framework, target: constant_name, via: :behavioral_profile }
294
+ end
295
+
296
+ deps
297
+ end
298
+
299
+ # Safely read a config attribute if it responds to it.
300
+ #
301
+ # @param obj [Object]
302
+ # @param method [Symbol]
303
+ # @yield [value] Yields the value if available
304
+ def safe_read(obj, method)
305
+ yield obj.public_send(method) if obj.respond_to?(method)
306
+ end
307
+ end
308
+ end
309
+ end