woods 1.0.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 (185) hide show
  1. checksums.yaml +7 -0
  2. data/CHANGELOG.md +89 -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 +406 -0
  7. data/exe/woods-console +59 -0
  8. data/exe/woods-console-mcp +22 -0
  9. data/exe/woods-mcp +34 -0
  10. data/exe/woods-mcp-http +37 -0
  11. data/exe/woods-mcp-start +58 -0
  12. data/lib/generators/woods/install_generator.rb +32 -0
  13. data/lib/generators/woods/pgvector_generator.rb +37 -0
  14. data/lib/generators/woods/templates/add_pgvector_to_woods.rb.erb +15 -0
  15. data/lib/generators/woods/templates/create_woods_tables.rb.erb +43 -0
  16. data/lib/tasks/woods.rake +621 -0
  17. data/lib/tasks/woods_evaluation.rake +115 -0
  18. data/lib/woods/ast/call_site_extractor.rb +106 -0
  19. data/lib/woods/ast/method_extractor.rb +71 -0
  20. data/lib/woods/ast/node.rb +116 -0
  21. data/lib/woods/ast/parser.rb +614 -0
  22. data/lib/woods/ast.rb +6 -0
  23. data/lib/woods/builder.rb +200 -0
  24. data/lib/woods/cache/cache_middleware.rb +199 -0
  25. data/lib/woods/cache/cache_store.rb +264 -0
  26. data/lib/woods/cache/redis_cache_store.rb +116 -0
  27. data/lib/woods/cache/solid_cache_store.rb +111 -0
  28. data/lib/woods/chunking/chunk.rb +84 -0
  29. data/lib/woods/chunking/semantic_chunker.rb +295 -0
  30. data/lib/woods/console/adapters/cache_adapter.rb +58 -0
  31. data/lib/woods/console/adapters/good_job_adapter.rb +33 -0
  32. data/lib/woods/console/adapters/job_adapter.rb +68 -0
  33. data/lib/woods/console/adapters/sidekiq_adapter.rb +33 -0
  34. data/lib/woods/console/adapters/solid_queue_adapter.rb +33 -0
  35. data/lib/woods/console/audit_logger.rb +75 -0
  36. data/lib/woods/console/bridge.rb +177 -0
  37. data/lib/woods/console/confirmation.rb +90 -0
  38. data/lib/woods/console/connection_manager.rb +173 -0
  39. data/lib/woods/console/console_response_renderer.rb +74 -0
  40. data/lib/woods/console/embedded_executor.rb +373 -0
  41. data/lib/woods/console/model_validator.rb +81 -0
  42. data/lib/woods/console/rack_middleware.rb +87 -0
  43. data/lib/woods/console/safe_context.rb +82 -0
  44. data/lib/woods/console/server.rb +612 -0
  45. data/lib/woods/console/sql_validator.rb +172 -0
  46. data/lib/woods/console/tools/tier1.rb +118 -0
  47. data/lib/woods/console/tools/tier2.rb +117 -0
  48. data/lib/woods/console/tools/tier3.rb +110 -0
  49. data/lib/woods/console/tools/tier4.rb +79 -0
  50. data/lib/woods/coordination/pipeline_lock.rb +109 -0
  51. data/lib/woods/cost_model/embedding_cost.rb +88 -0
  52. data/lib/woods/cost_model/estimator.rb +128 -0
  53. data/lib/woods/cost_model/provider_pricing.rb +67 -0
  54. data/lib/woods/cost_model/storage_cost.rb +52 -0
  55. data/lib/woods/cost_model.rb +22 -0
  56. data/lib/woods/db/migrations/001_create_units.rb +38 -0
  57. data/lib/woods/db/migrations/002_create_edges.rb +35 -0
  58. data/lib/woods/db/migrations/003_create_embeddings.rb +37 -0
  59. data/lib/woods/db/migrations/004_create_snapshots.rb +45 -0
  60. data/lib/woods/db/migrations/005_create_snapshot_units.rb +40 -0
  61. data/lib/woods/db/migrations/006_rename_tables.rb +34 -0
  62. data/lib/woods/db/migrator.rb +73 -0
  63. data/lib/woods/db/schema_version.rb +73 -0
  64. data/lib/woods/dependency_graph.rb +236 -0
  65. data/lib/woods/embedding/indexer.rb +140 -0
  66. data/lib/woods/embedding/openai.rb +126 -0
  67. data/lib/woods/embedding/provider.rb +162 -0
  68. data/lib/woods/embedding/text_preparer.rb +112 -0
  69. data/lib/woods/evaluation/baseline_runner.rb +115 -0
  70. data/lib/woods/evaluation/evaluator.rb +139 -0
  71. data/lib/woods/evaluation/metrics.rb +79 -0
  72. data/lib/woods/evaluation/query_set.rb +148 -0
  73. data/lib/woods/evaluation/report_generator.rb +90 -0
  74. data/lib/woods/extracted_unit.rb +145 -0
  75. data/lib/woods/extractor.rb +1028 -0
  76. data/lib/woods/extractors/action_cable_extractor.rb +201 -0
  77. data/lib/woods/extractors/ast_source_extraction.rb +46 -0
  78. data/lib/woods/extractors/behavioral_profile.rb +309 -0
  79. data/lib/woods/extractors/caching_extractor.rb +261 -0
  80. data/lib/woods/extractors/callback_analyzer.rb +246 -0
  81. data/lib/woods/extractors/concern_extractor.rb +292 -0
  82. data/lib/woods/extractors/configuration_extractor.rb +219 -0
  83. data/lib/woods/extractors/controller_extractor.rb +404 -0
  84. data/lib/woods/extractors/database_view_extractor.rb +278 -0
  85. data/lib/woods/extractors/decorator_extractor.rb +253 -0
  86. data/lib/woods/extractors/engine_extractor.rb +223 -0
  87. data/lib/woods/extractors/event_extractor.rb +211 -0
  88. data/lib/woods/extractors/factory_extractor.rb +289 -0
  89. data/lib/woods/extractors/graphql_extractor.rb +892 -0
  90. data/lib/woods/extractors/i18n_extractor.rb +117 -0
  91. data/lib/woods/extractors/job_extractor.rb +374 -0
  92. data/lib/woods/extractors/lib_extractor.rb +218 -0
  93. data/lib/woods/extractors/mailer_extractor.rb +269 -0
  94. data/lib/woods/extractors/manager_extractor.rb +188 -0
  95. data/lib/woods/extractors/middleware_extractor.rb +133 -0
  96. data/lib/woods/extractors/migration_extractor.rb +469 -0
  97. data/lib/woods/extractors/model_extractor.rb +988 -0
  98. data/lib/woods/extractors/phlex_extractor.rb +252 -0
  99. data/lib/woods/extractors/policy_extractor.rb +191 -0
  100. data/lib/woods/extractors/poro_extractor.rb +229 -0
  101. data/lib/woods/extractors/pundit_extractor.rb +223 -0
  102. data/lib/woods/extractors/rails_source_extractor.rb +473 -0
  103. data/lib/woods/extractors/rake_task_extractor.rb +343 -0
  104. data/lib/woods/extractors/route_extractor.rb +181 -0
  105. data/lib/woods/extractors/scheduled_job_extractor.rb +331 -0
  106. data/lib/woods/extractors/serializer_extractor.rb +339 -0
  107. data/lib/woods/extractors/service_extractor.rb +217 -0
  108. data/lib/woods/extractors/shared_dependency_scanner.rb +91 -0
  109. data/lib/woods/extractors/shared_utility_methods.rb +281 -0
  110. data/lib/woods/extractors/state_machine_extractor.rb +398 -0
  111. data/lib/woods/extractors/test_mapping_extractor.rb +225 -0
  112. data/lib/woods/extractors/validator_extractor.rb +211 -0
  113. data/lib/woods/extractors/view_component_extractor.rb +311 -0
  114. data/lib/woods/extractors/view_template_extractor.rb +261 -0
  115. data/lib/woods/feedback/gap_detector.rb +89 -0
  116. data/lib/woods/feedback/store.rb +119 -0
  117. data/lib/woods/filename_utils.rb +32 -0
  118. data/lib/woods/flow_analysis/operation_extractor.rb +206 -0
  119. data/lib/woods/flow_analysis/response_code_mapper.rb +154 -0
  120. data/lib/woods/flow_assembler.rb +290 -0
  121. data/lib/woods/flow_document.rb +191 -0
  122. data/lib/woods/flow_precomputer.rb +102 -0
  123. data/lib/woods/formatting/base.rb +30 -0
  124. data/lib/woods/formatting/claude_adapter.rb +98 -0
  125. data/lib/woods/formatting/generic_adapter.rb +56 -0
  126. data/lib/woods/formatting/gpt_adapter.rb +64 -0
  127. data/lib/woods/formatting/human_adapter.rb +78 -0
  128. data/lib/woods/graph_analyzer.rb +374 -0
  129. data/lib/woods/mcp/bootstrapper.rb +96 -0
  130. data/lib/woods/mcp/index_reader.rb +394 -0
  131. data/lib/woods/mcp/renderers/claude_renderer.rb +81 -0
  132. data/lib/woods/mcp/renderers/json_renderer.rb +17 -0
  133. data/lib/woods/mcp/renderers/markdown_renderer.rb +353 -0
  134. data/lib/woods/mcp/renderers/plain_renderer.rb +240 -0
  135. data/lib/woods/mcp/server.rb +962 -0
  136. data/lib/woods/mcp/tool_response_renderer.rb +85 -0
  137. data/lib/woods/model_name_cache.rb +51 -0
  138. data/lib/woods/notion/client.rb +217 -0
  139. data/lib/woods/notion/exporter.rb +219 -0
  140. data/lib/woods/notion/mapper.rb +40 -0
  141. data/lib/woods/notion/mappers/column_mapper.rb +57 -0
  142. data/lib/woods/notion/mappers/migration_mapper.rb +39 -0
  143. data/lib/woods/notion/mappers/model_mapper.rb +161 -0
  144. data/lib/woods/notion/mappers/shared.rb +22 -0
  145. data/lib/woods/notion/rate_limiter.rb +68 -0
  146. data/lib/woods/observability/health_check.rb +79 -0
  147. data/lib/woods/observability/instrumentation.rb +34 -0
  148. data/lib/woods/observability/structured_logger.rb +57 -0
  149. data/lib/woods/operator/error_escalator.rb +81 -0
  150. data/lib/woods/operator/pipeline_guard.rb +92 -0
  151. data/lib/woods/operator/status_reporter.rb +80 -0
  152. data/lib/woods/railtie.rb +38 -0
  153. data/lib/woods/resilience/circuit_breaker.rb +99 -0
  154. data/lib/woods/resilience/index_validator.rb +167 -0
  155. data/lib/woods/resilience/retryable_provider.rb +108 -0
  156. data/lib/woods/retrieval/context_assembler.rb +261 -0
  157. data/lib/woods/retrieval/query_classifier.rb +133 -0
  158. data/lib/woods/retrieval/ranker.rb +277 -0
  159. data/lib/woods/retrieval/search_executor.rb +316 -0
  160. data/lib/woods/retriever.rb +152 -0
  161. data/lib/woods/ruby_analyzer/class_analyzer.rb +170 -0
  162. data/lib/woods/ruby_analyzer/dataflow_analyzer.rb +77 -0
  163. data/lib/woods/ruby_analyzer/fqn_builder.rb +18 -0
  164. data/lib/woods/ruby_analyzer/mermaid_renderer.rb +280 -0
  165. data/lib/woods/ruby_analyzer/method_analyzer.rb +143 -0
  166. data/lib/woods/ruby_analyzer/trace_enricher.rb +143 -0
  167. data/lib/woods/ruby_analyzer.rb +87 -0
  168. data/lib/woods/session_tracer/file_store.rb +104 -0
  169. data/lib/woods/session_tracer/middleware.rb +143 -0
  170. data/lib/woods/session_tracer/redis_store.rb +106 -0
  171. data/lib/woods/session_tracer/session_flow_assembler.rb +254 -0
  172. data/lib/woods/session_tracer/session_flow_document.rb +223 -0
  173. data/lib/woods/session_tracer/solid_cache_store.rb +139 -0
  174. data/lib/woods/session_tracer/store.rb +81 -0
  175. data/lib/woods/storage/graph_store.rb +120 -0
  176. data/lib/woods/storage/metadata_store.rb +196 -0
  177. data/lib/woods/storage/pgvector.rb +195 -0
  178. data/lib/woods/storage/qdrant.rb +205 -0
  179. data/lib/woods/storage/vector_store.rb +167 -0
  180. data/lib/woods/temporal/json_snapshot_store.rb +245 -0
  181. data/lib/woods/temporal/snapshot_store.rb +345 -0
  182. data/lib/woods/token_utils.rb +19 -0
  183. data/lib/woods/version.rb +5 -0
  184. data/lib/woods.rb +246 -0
  185. metadata +270 -0
@@ -0,0 +1,201 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative 'shared_utility_methods'
4
+ require_relative 'shared_dependency_scanner'
5
+
6
+ module Woods
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 = source_file_for(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
+ # Locate the source file for a channel class.
93
+ #
94
+ # Convention path first, then introspection via {#resolve_source_location}
95
+ # which filters out vendor/node_modules paths.
96
+ #
97
+ # @param klass [Class] The channel class
98
+ # @param name [String] The channel class name
99
+ # @return [String, nil]
100
+ def source_file_for(klass, name)
101
+ return nil unless defined?(Rails) && Rails.respond_to?(:root) && Rails.root
102
+
103
+ convention_path = Rails.root.join('app', 'channels', "#{name.underscore}.rb").to_s
104
+ return convention_path if File.exist?(convention_path)
105
+
106
+ resolve_source_location(klass, app_root: Rails.root.to_s, fallback: nil)
107
+ end
108
+
109
+ # Read source code from a file path.
110
+ #
111
+ # @param file_path [String, nil]
112
+ # @return [String]
113
+ def read_source(file_path)
114
+ return '' unless file_path && File.exist?(file_path)
115
+
116
+ File.read(file_path)
117
+ rescue StandardError
118
+ ''
119
+ end
120
+
121
+ # Build metadata hash for a channel.
122
+ #
123
+ # @param source [String] Channel source code
124
+ # @param own_methods [Array<Symbol>] Methods defined directly on the channel
125
+ # @return [Hash]
126
+ def build_metadata(source, own_methods)
127
+ {
128
+ stream_names: detect_stream_names(source),
129
+ actions: detect_actions(own_methods),
130
+ has_subscribed: own_methods.include?(:subscribed),
131
+ has_unsubscribed: own_methods.include?(:unsubscribed),
132
+ broadcasts_to: detect_broadcasts(source),
133
+ loc: count_loc(source)
134
+ }
135
+ end
136
+
137
+ # Detect stream names from stream_from and stream_for calls.
138
+ #
139
+ # @param source [String] Channel source code
140
+ # @return [Array<String>]
141
+ def detect_stream_names(source)
142
+ streams = []
143
+
144
+ # stream_from "string" or stream_from 'string' (also catches interpolated strings)
145
+ streams.concat(source.scan(/stream_from\s+["']([^"']+)["']/).flatten)
146
+
147
+ # stream_for model
148
+ streams.concat(source.scan(/stream_for\s+(\w+)/).map { |m| "stream_for:#{m[0]}" })
149
+
150
+ streams.uniq
151
+ end
152
+
153
+ # Detect action methods (public instance methods minus lifecycle methods).
154
+ #
155
+ # @param own_methods [Array<Symbol>] Methods defined directly on the channel
156
+ # @return [Array<String>]
157
+ def detect_actions(own_methods)
158
+ (own_methods - LIFECYCLE_METHODS).map(&:to_s)
159
+ end
160
+
161
+ # Detect broadcast patterns in source code.
162
+ #
163
+ # @param source [String] Channel source code
164
+ # @return [Array<String>]
165
+ def detect_broadcasts(source)
166
+ broadcasts = []
167
+
168
+ # ActionCable.server.broadcast("channel_name", ...)
169
+ broadcasts.concat(source.scan(/ActionCable\.server\.broadcast\(\s*["']([^"']+)["']/).flatten)
170
+
171
+ # SomeChannel.broadcast_to(target, ...)
172
+ broadcasts.concat(source.scan(/\w+\.broadcast_to\(\s*(\w+)/).map { |m| "broadcast_to:#{m[0]}" })
173
+
174
+ broadcasts.uniq
175
+ end
176
+
177
+ # Count non-blank, non-comment lines.
178
+ #
179
+ # @param source [String]
180
+ # @return [Integer]
181
+ def count_loc(source)
182
+ return 0 if source.empty?
183
+
184
+ source.each_line.count do |line|
185
+ stripped = line.strip
186
+ !stripped.empty? && !stripped.start_with?('#')
187
+ end
188
+ end
189
+
190
+ # Log a channel extraction error.
191
+ #
192
+ # @param name [String] Channel class name
193
+ # @param error [StandardError]
194
+ def log_extraction_error(name, error)
195
+ return unless defined?(Rails) && Rails.respond_to?(:logger) && Rails.logger
196
+
197
+ Rails.logger.error("Failed to extract channel #{name}: #{error.message}")
198
+ end
199
+ end
200
+ end
201
+ end
@@ -0,0 +1,46 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative '../ast/method_extractor'
4
+
5
+ module Woods
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 Woods
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