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,292 @@
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
+ # ConcernExtractor handles ActiveSupport::Concern module extraction.
9
+ #
10
+ # Concerns are mixins that extend model and controller behavior.
11
+ # They live in `app/models/concerns/` and `app/controllers/concerns/`,
12
+ # as well as nested directories like `app/models/gateway/stripe/concerns/`.
13
+ #
14
+ # We extract:
15
+ # - Module name and namespace
16
+ # - Included/extended hooks and class methods block
17
+ # - Instance methods and class methods added by the concern
18
+ # - Dependencies on models and other concerns
19
+ #
20
+ # @example
21
+ # extractor = ConcernExtractor.new
22
+ # units = extractor.extract_all
23
+ # searchable = units.find { |u| u.identifier == "Searchable" }
24
+ #
25
+ class ConcernExtractor
26
+ include SharedUtilityMethods
27
+ include SharedDependencyScanner
28
+
29
+ # Canonical concern directories (used as fallback if glob finds nothing).
30
+ CONCERN_DIRECTORIES = %w[
31
+ app/models/concerns
32
+ app/controllers/concerns
33
+ ].freeze
34
+
35
+ def initialize
36
+ # Discover all concerns/ directories under app/, including deeply nested ones
37
+ # like app/models/gateway/stripe/webhook/concerns/.
38
+ @directories = Dir[Rails.root.join('app/**/concerns')].map { |d| Pathname.new(d) }
39
+ .select(&:directory?)
40
+ # Fall back to canonical directories if glob finds nothing.
41
+ return unless @directories.empty?
42
+
43
+ @directories = CONCERN_DIRECTORIES.map { |d| Rails.root.join(d) }
44
+ .select(&:directory?)
45
+ end
46
+
47
+ # Extract all concern modules
48
+ #
49
+ # @return [Array<ExtractedUnit>] List of concern units
50
+ def extract_all
51
+ @directories.flat_map do |dir|
52
+ Dir[dir.join('**/*.rb')].filter_map do |file|
53
+ extract_concern_file(file)
54
+ end
55
+ end
56
+ end
57
+
58
+ # Extract a single concern file
59
+ #
60
+ # @param file_path [String] Path to the concern file
61
+ # @return [ExtractedUnit, nil] The extracted unit or nil if not a concern
62
+ def extract_concern_file(file_path)
63
+ source = File.read(file_path)
64
+ module_name = extract_module_name(file_path, source)
65
+
66
+ return nil unless module_name
67
+ return nil unless concern_module?(source)
68
+
69
+ unit = ExtractedUnit.new(
70
+ type: :concern,
71
+ identifier: module_name,
72
+ file_path: file_path
73
+ )
74
+
75
+ unit.namespace = extract_namespace(module_name)
76
+ unit.source_code = annotate_source(source, module_name)
77
+ unit.metadata = extract_metadata(source, file_path)
78
+ unit.dependencies = extract_dependencies(source)
79
+
80
+ unit
81
+ rescue StandardError => e
82
+ Rails.logger.error("Failed to extract concern #{file_path}: #{e.message}")
83
+ nil
84
+ end
85
+
86
+ private
87
+
88
+ # ──────────────────────────────────────────────────────────────────────
89
+ # Module Discovery
90
+ # ──────────────────────────────────────────────────────────────────────
91
+
92
+ # Extract the module name from source or infer from file path.
93
+ #
94
+ # @param file_path [String] Path to the concern file
95
+ # @param source [String] Ruby source code
96
+ # @return [String, nil] The module name
97
+ def extract_module_name(file_path, source)
98
+ # Try to find the outermost module definition
99
+ modules = source.scan(/^\s*module\s+([\w:]+)/).flatten
100
+ return modules.last if modules.any?
101
+
102
+ # Infer from file path — strip everything up to and including the first concerns/ dir.
103
+ # Handles canonical (app/models/concerns/) and nested (app/models/foo/concerns/) paths.
104
+ relative = file_path.sub("#{Rails.root}/", '')
105
+ relative
106
+ .sub(%r{^app/.*?/concerns/}, '')
107
+ .sub('.rb', '')
108
+ .split('/')
109
+ .map { |segment| segment.split('_').map(&:capitalize).join }
110
+ .join('::')
111
+ end
112
+
113
+ # Detect whether source defines an ActiveSupport::Concern or a plain mixin.
114
+ #
115
+ # @param source [String] Ruby source code
116
+ # @return [Boolean]
117
+ def concern_module?(source)
118
+ # ActiveSupport::Concern usage or plain module with methods
119
+ source.match?(/^\s*module\s+/) &&
120
+ (source.match?(/extend\s+ActiveSupport::Concern/) ||
121
+ source.match?(/included\s+do/) ||
122
+ source.match?(/class_methods\s+do/) ||
123
+ source.match?(/def\s+\w+/))
124
+ end
125
+
126
+ # ──────────────────────────────────────────────────────────────────────
127
+ # Source Annotation
128
+ # ──────────────────────────────────────────────────────────────────────
129
+
130
+ # @param source [String] Ruby source code
131
+ # @param module_name [String] The concern module name
132
+ # @return [String] Annotated source
133
+ def annotate_source(source, module_name)
134
+ concern_type = detect_concern_type(source)
135
+ instance_methods = extract_instance_method_names(source)
136
+
137
+ <<~ANNOTATION
138
+ # ╔═══════════════════════════════════════════════════════════════════════╗
139
+ # ║ Concern: #{module_name.ljust(59)}║
140
+ # ║ Type: #{concern_type.ljust(62)}║
141
+ # ║ Methods: #{instance_methods.join(', ').ljust(59)}║
142
+ # ╚═══════════════════════════════════════════════════════════════════════╝
143
+
144
+ #{source}
145
+ ANNOTATION
146
+ end
147
+
148
+ # ──────────────────────────────────────────────────────────────────────
149
+ # Metadata Extraction
150
+ # ──────────────────────────────────────────────────────────────────────
151
+
152
+ # @param source [String] Ruby source code
153
+ # @param file_path [String] Path to the concern file
154
+ # @return [Hash] Concern metadata
155
+ def extract_metadata(source, file_path)
156
+ {
157
+ concern_type: detect_concern_type(source),
158
+ concern_scope: detect_concern_scope(file_path),
159
+ uses_active_support: source.match?(/extend\s+ActiveSupport::Concern/),
160
+ has_included_block: source.match?(/included\s+do/) || false,
161
+ has_class_methods_block: source.match?(/class_methods\s+do/) || false,
162
+ included_modules: detect_included_modules(source),
163
+ instance_methods: extract_instance_method_names(source),
164
+ class_methods: extract_class_methods(source),
165
+ public_methods: extract_public_methods(source),
166
+ callbacks_defined: detect_callbacks(source),
167
+ scopes_defined: detect_scopes(source),
168
+ validations_defined: detect_validations(source),
169
+ loc: source.lines.count { |l| l.strip.length.positive? && !l.strip.start_with?('#') },
170
+ method_count: source.scan(/def\s+(?:self\.)?\w+/).size
171
+ }
172
+ end
173
+
174
+ # Detect whether this is a model concern, controller concern, or generic.
175
+ #
176
+ # @param source [String] Ruby source code
177
+ # @return [String] One of "active_support", "plain_mixin"
178
+ def detect_concern_type(source)
179
+ if source.match?(/extend\s+ActiveSupport::Concern/)
180
+ 'active_support'
181
+ else
182
+ 'plain_mixin'
183
+ end
184
+ end
185
+
186
+ # Detect concern scope from file path (model vs controller).
187
+ #
188
+ # @param file_path [String] Path to the concern file
189
+ # @return [String] One of "model", "controller", "unknown"
190
+ def detect_concern_scope(file_path)
191
+ if file_path.include?('app/models/')
192
+ 'model'
193
+ elsif file_path.include?('app/controllers/')
194
+ 'controller'
195
+ else
196
+ 'unknown'
197
+ end
198
+ end
199
+
200
+ # Extract instance method names (not self. methods).
201
+ #
202
+ # @param source [String] Ruby source code
203
+ # @return [Array<String>] Instance method names
204
+ def extract_instance_method_names(source)
205
+ source.scan(/^\s*def\s+(\w+[?!=]?)/).flatten.reject { |m| m.start_with?('self.') }
206
+ end
207
+
208
+ # Detect other modules included or extended by this concern (for metadata display).
209
+ #
210
+ # Returns all module names found in include/extend calls, excluding
211
+ # ActiveSupport::Concern itself.
212
+ #
213
+ # @param source [String] Ruby source code
214
+ # @return [Array<String>] Module names
215
+ def detect_included_modules(source)
216
+ source.scan(/(?:include|extend)\s+([\w:]+)/).flatten
217
+ .reject { |m| m == 'ActiveSupport::Concern' }
218
+ end
219
+
220
+ # Detect modules explicitly included by this concern.
221
+ #
222
+ # Scans for bare +include ModuleName+ calls, excluding ActiveSupport::Concern.
223
+ #
224
+ # @param source [String] Ruby source code
225
+ # @return [Array<String>] Included module names
226
+ def detect_includes(source)
227
+ source.scan(/\binclude\s+([\w:]+)/).flatten
228
+ .reject { |m| m == 'ActiveSupport::Concern' }
229
+ end
230
+
231
+ # Detect modules explicitly extended by this concern.
232
+ #
233
+ # Scans for bare +extend ModuleName+ calls, excluding ActiveSupport::Concern.
234
+ #
235
+ # @param source [String] Ruby source code
236
+ # @return [Array<String>] Extended module names
237
+ def detect_extends(source)
238
+ source.scan(/\bextend\s+([\w:]+)/).flatten
239
+ .reject { |m| m == 'ActiveSupport::Concern' }
240
+ end
241
+
242
+ # Detect callback declarations.
243
+ #
244
+ # @param source [String] Ruby source code
245
+ # @return [Array<String>] Callback names
246
+ def detect_callbacks(source)
247
+ source.scan(/(before_\w+|after_\w+|around_\w+)\s/).flatten.uniq
248
+ end
249
+
250
+ # Detect scope declarations.
251
+ #
252
+ # @param source [String] Ruby source code
253
+ # @return [Array<String>] Scope names
254
+ def detect_scopes(source)
255
+ source.scan(/scope\s+:(\w+)/).flatten
256
+ end
257
+
258
+ # Detect validation declarations.
259
+ #
260
+ # @param source [String] Ruby source code
261
+ # @return [Array<String>] Validation types
262
+ def detect_validations(source)
263
+ source.scan(/(validates?(?:_\w+)?)\s/).flatten.uniq
264
+ end
265
+
266
+ # ──────────────────────────────────────────────────────────────────────
267
+ # Dependency Extraction
268
+ # ──────────────────────────────────────────────────────────────────────
269
+
270
+ # @param source [String] Ruby source code
271
+ # @return [Array<Hash>] Dependency hashes
272
+ def extract_dependencies(source)
273
+ # Concerns included by this concern (add instance-level behavior)
274
+ deps = detect_includes(source).map do |mod|
275
+ { type: :concern, target: mod, via: :include }
276
+ end
277
+
278
+ # Concerns extended by this concern (add class-level behavior)
279
+ detect_extends(source).each do |mod|
280
+ deps << { type: :concern, target: mod, via: :extend }
281
+ end
282
+
283
+ # Standard dependency scanning
284
+ deps.concat(scan_model_dependencies(source))
285
+ deps.concat(scan_service_dependencies(source))
286
+ deps.concat(scan_job_dependencies(source))
287
+
288
+ deps.uniq { |d| [d[:type], d[:target]] }
289
+ end
290
+ end
291
+ end
292
+ end
@@ -0,0 +1,219 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative 'shared_utility_methods'
4
+ require_relative 'shared_dependency_scanner'
5
+ require_relative 'behavioral_profile'
6
+
7
+ module Woods
8
+ module Extractors
9
+ # ConfigurationExtractor handles Rails configuration file extraction.
10
+ #
11
+ # Scans `config/initializers/` and `config/environments/` for Ruby
12
+ # configuration files. Each file becomes one ExtractedUnit with metadata
13
+ # about config type, gem references, and detected settings.
14
+ #
15
+ # @example
16
+ # extractor = ConfigurationExtractor.new
17
+ # units = extractor.extract_all
18
+ # devise = units.find { |u| u.identifier == "initializers/devise.rb" }
19
+ #
20
+ class ConfigurationExtractor
21
+ include SharedUtilityMethods
22
+ include SharedDependencyScanner
23
+
24
+ # Directories to scan for configuration files
25
+ CONFIG_DIRECTORIES = %w[
26
+ config/initializers
27
+ config/environments
28
+ ].freeze
29
+
30
+ def initialize
31
+ @directories = CONFIG_DIRECTORIES.map { |d| Rails.root.join(d) }
32
+ .select(&:directory?)
33
+ end
34
+
35
+ # Extract all configuration files and the behavioral profile.
36
+ #
37
+ # @return [Array<ExtractedUnit>] List of configuration units
38
+ def extract_all
39
+ units = @directories.flat_map do |dir|
40
+ Dir[dir.join('**/*.rb')].filter_map do |file|
41
+ extract_configuration_file(file)
42
+ end
43
+ end
44
+
45
+ profile = BehavioralProfile.new.extract
46
+ units << profile if profile
47
+
48
+ units
49
+ rescue StandardError => e
50
+ Rails.logger.error("BehavioralProfile integration failed: #{e.message}")
51
+ units || []
52
+ end
53
+
54
+ # Extract a single configuration file
55
+ #
56
+ # @param file_path [String] Path to the configuration file
57
+ # @return [ExtractedUnit, nil] The extracted unit or nil on failure
58
+ def extract_configuration_file(file_path)
59
+ source = File.read(file_path)
60
+ identifier = build_identifier(file_path)
61
+ config_type = detect_config_type(file_path)
62
+
63
+ unit = ExtractedUnit.new(
64
+ type: :configuration,
65
+ identifier: identifier,
66
+ file_path: file_path
67
+ )
68
+
69
+ unit.namespace = config_type
70
+ unit.source_code = annotate_source(source, identifier, config_type)
71
+ unit.metadata = extract_metadata(source, config_type)
72
+ unit.dependencies = extract_dependencies(source)
73
+
74
+ unit
75
+ rescue StandardError => e
76
+ Rails.logger.error("Failed to extract configuration #{file_path}: #{e.message}")
77
+ nil
78
+ end
79
+
80
+ private
81
+
82
+ # ──────────────────────────────────────────────────────────────────────
83
+ # Identification
84
+ # ──────────────────────────────────────────────────────────────────────
85
+
86
+ # Build a readable identifier from the file path.
87
+ #
88
+ # @param file_path [String]
89
+ # @return [String] e.g., "initializers/devise.rb" or "environments/production.rb"
90
+ def build_identifier(file_path)
91
+ relative = file_path.sub("#{Rails.root}/", '')
92
+ relative.sub(%r{^config/}, '')
93
+ end
94
+
95
+ # Detect whether this is an initializer or environment config.
96
+ #
97
+ # @param file_path [String]
98
+ # @return [String]
99
+ def detect_config_type(file_path)
100
+ if file_path.include?('config/initializers')
101
+ 'initializer'
102
+ elsif file_path.include?('config/environments')
103
+ 'environment'
104
+ else
105
+ 'configuration'
106
+ end
107
+ end
108
+
109
+ # ──────────────────────────────────────────────────────────────────────
110
+ # Source Annotation
111
+ # ──────────────────────────────────────────────────────────────────────
112
+
113
+ # @param source [String]
114
+ # @param identifier [String]
115
+ # @param config_type [String]
116
+ # @return [String]
117
+ def annotate_source(source, identifier, config_type)
118
+ gem_refs = detect_gem_references(source)
119
+
120
+ <<~ANNOTATION
121
+ # ╔═══════════════════════════════════════════════════════════════════════╗
122
+ # ║ Configuration: #{identifier.ljust(53)}║
123
+ # ║ Type: #{config_type.ljust(62)}║
124
+ # ║ Gems: #{gem_refs.join(', ').ljust(62)}║
125
+ # ╚═══════════════════════════════════════════════════════════════════════╝
126
+
127
+ #{source}
128
+ ANNOTATION
129
+ end
130
+
131
+ # ──────────────────────────────────────────────────────────────────────
132
+ # Metadata Extraction
133
+ # ──────────────────────────────────────────────────────────────────────
134
+
135
+ # @param source [String]
136
+ # @param config_type [String]
137
+ # @return [Hash]
138
+ def extract_metadata(source, config_type)
139
+ {
140
+ config_type: config_type,
141
+ gem_references: detect_gem_references(source),
142
+ config_settings: detect_config_settings(source),
143
+ rails_config_blocks: detect_rails_config_blocks(source),
144
+ loc: source.lines.count { |l| l.strip.length.positive? && !l.strip.start_with?('#') },
145
+ method_count: source.scan(/def\s+(?:self\.)?\w+/).size
146
+ }
147
+ end
148
+
149
+ # Detect gem/library references in configuration.
150
+ #
151
+ # @param source [String]
152
+ # @return [Array<String>]
153
+ def detect_gem_references(source)
154
+ refs = []
155
+
156
+ # Gem.configure style: Devise.setup, Sidekiq.configure_server
157
+ source.scan(/(\w+)\.(setup|configure\w*|config)\b/).each do |match|
158
+ name = match[0]
159
+ refs << name unless generic_config_name?(name)
160
+ end
161
+
162
+ # require statements for gems
163
+ source.scan(/require\s+['"]([^'"]+)['"]/).each do |match|
164
+ refs << match[0]
165
+ end
166
+
167
+ refs.uniq
168
+ end
169
+
170
+ # Detect configuration settings (key = value patterns).
171
+ #
172
+ # @param source [String]
173
+ # @return [Array<String>]
174
+ def detect_config_settings(source)
175
+ # config.something = value
176
+ settings = source.scan(/config\.(\w+(?:\.\w+)*)\s*=/).map { |match| match[0] }
177
+
178
+ # self.something = value (inside configure blocks)
179
+ settings.concat(source.scan(/(?:self|config)\.(\w+)\s*=/).map { |match| match[0] })
180
+
181
+ settings.uniq
182
+ end
183
+
184
+ # Detect Rails.application.configure or similar blocks.
185
+ #
186
+ # @param source [String]
187
+ # @return [Array<String>]
188
+ def detect_rails_config_blocks(source)
189
+ source.scan(/(Rails\.application\.configure|Rails\.application\.config\.\w+)/)
190
+ .map { |match| match[0] }
191
+ .uniq
192
+ end
193
+
194
+ # Check if a name is too generic to be a gem reference.
195
+ #
196
+ # @param name [String]
197
+ # @return [Boolean]
198
+ def generic_config_name?(name)
199
+ %w[Rails ActiveRecord ActiveJob ActionMailer ActionController ActiveStorage ActionCable].include?(name)
200
+ end
201
+
202
+ # ──────────────────────────────────────────────────────────────────────
203
+ # Dependency Extraction
204
+ # ──────────────────────────────────────────────────────────────────────
205
+
206
+ # @param source [String]
207
+ # @return [Array<Hash>]
208
+ def extract_dependencies(source)
209
+ deps = detect_gem_references(source).map do |gem_ref|
210
+ { type: :gem, target: gem_ref, via: :configuration }
211
+ end
212
+
213
+ deps.concat(scan_service_dependencies(source))
214
+
215
+ deps.uniq { |d| [d[:type], d[:target]] }
216
+ end
217
+ end
218
+ end
219
+ end