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,253 @@
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
+ # DecoratorExtractor handles decorator, presenter, and form object extraction.
9
+ #
10
+ # Scans conventional directories for view-layer wrapper objects:
11
+ # decorators (Draper-style or PORO), presenters, and form objects.
12
+ # Extracts the decorated model relationship, delegation chains, and
13
+ # whether the Draper gem is in use.
14
+ #
15
+ # @example
16
+ # extractor = DecoratorExtractor.new
17
+ # units = extractor.extract_all
18
+ # user_dec = units.find { |u| u.identifier == "UserDecorator" }
19
+ # user_dec.metadata[:decorated_model] # => "User"
20
+ # user_dec.metadata[:uses_draper] # => true
21
+ #
22
+ class DecoratorExtractor
23
+ include SharedUtilityMethods
24
+ include SharedDependencyScanner
25
+
26
+ # Directories to scan for decorator-style objects
27
+ DECORATOR_DIRECTORIES = %w[
28
+ app/decorators
29
+ app/presenters
30
+ app/form_objects
31
+ ].freeze
32
+
33
+ # Maps directory segment to decorator_type symbol
34
+ DIRECTORY_TYPE_MAP = {
35
+ 'decorators' => :decorator,
36
+ 'presenters' => :presenter,
37
+ 'form_objects' => :form_object
38
+ }.freeze
39
+
40
+ # Suffixes used to infer the decorated model name
41
+ DECORATOR_SUFFIXES = %w[Decorator Presenter Form].freeze
42
+
43
+ def initialize
44
+ @directories = DECORATOR_DIRECTORIES.map { |d| Rails.root.join(d) }
45
+ .select(&:directory?)
46
+ end
47
+
48
+ # Extract all decorator, presenter, and form object units.
49
+ #
50
+ # @return [Array<ExtractedUnit>] List of decorator units
51
+ def extract_all
52
+ @directories.flat_map do |dir|
53
+ Dir[dir.join('**/*.rb')].filter_map do |file|
54
+ extract_decorator_file(file)
55
+ end
56
+ end
57
+ end
58
+
59
+ # Extract a single decorator file.
60
+ #
61
+ # @param file_path [String] Absolute path to the Ruby file
62
+ # @return [ExtractedUnit, nil] The extracted unit or nil if not a decorator
63
+ def extract_decorator_file(file_path)
64
+ source = File.read(file_path)
65
+ class_name = extract_class_name(file_path, source)
66
+
67
+ return nil unless class_name
68
+ return nil if skip_file?(source)
69
+
70
+ unit = ExtractedUnit.new(
71
+ type: :decorator,
72
+ identifier: class_name,
73
+ file_path: file_path
74
+ )
75
+
76
+ unit.namespace = extract_namespace(class_name)
77
+ unit.source_code = annotate_source(source, class_name, file_path)
78
+ unit.metadata = extract_metadata(source, class_name, file_path)
79
+ unit.dependencies = extract_dependencies(source, class_name)
80
+
81
+ unit
82
+ rescue StandardError => e
83
+ Rails.logger.error("Failed to extract decorator #{file_path}: #{e.message}")
84
+ nil
85
+ end
86
+
87
+ private
88
+
89
+ # ──────────────────────────────────────────────────────────────────────
90
+ # Class Discovery
91
+ # ──────────────────────────────────────────────────────────────────────
92
+
93
+ # Override SharedUtilityMethods#extract_class_name for decorator-specific
94
+ # namespace resolution. The shared version only matches `class Foo::Bar`
95
+ # (inline namespacing); this version also handles `module Admin / class
96
+ # UserDecorator` (block namespacing) by scanning for enclosing modules.
97
+ #
98
+ # @param file_path [String] Path to the file
99
+ # @param source [String] Ruby source code
100
+ # @return [String, nil] The class name or nil
101
+ def extract_class_name(file_path, source)
102
+ namespaces = source.scan(/^\s*module\s+([\w:]+)/).flatten
103
+ class_match = source.match(/^\s*class\s+([\w:]+)/)
104
+
105
+ if class_match
106
+ base_class = class_match[1]
107
+ if namespaces.any? && !base_class.include?('::')
108
+ "#{namespaces.join('::')}::#{base_class}"
109
+ else
110
+ base_class
111
+ end
112
+ else
113
+ relative = file_path.sub("#{Rails.root}/", '')
114
+ relative
115
+ .sub(%r{^app/(decorators|presenters|form_objects)/}, '')
116
+ .sub('.rb', '')
117
+ .camelize
118
+ end
119
+ end
120
+
121
+ # ──────────────────────────────────────────────────────────────────────
122
+ # Source Annotation
123
+ # ──────────────────────────────────────────────────────────────────────
124
+
125
+ # Prepend a summary annotation header to the source.
126
+ #
127
+ # @param source [String] Ruby source code
128
+ # @param class_name [String] The class name
129
+ # @param file_path [String] Path to the file
130
+ # @return [String] Annotated source
131
+ def annotate_source(source, class_name, file_path)
132
+ decorator_type = infer_decorator_type(file_path)
133
+ decorated_model = infer_decorated_model(class_name)
134
+
135
+ annotation = <<~ANNOTATION
136
+ # ╔═══════════════════════════════════════════════════════════════════════╗
137
+ # ║ Decorator: #{class_name.ljust(57)}║
138
+ # ║ Type: #{decorator_type.to_s.ljust(62)}║
139
+ # ║ Decorates: #{(decorated_model || 'unknown').ljust(57)}║
140
+ # ╚═══════════════════════════════════════════════════════════════════════╝
141
+
142
+ ANNOTATION
143
+
144
+ annotation + source
145
+ end
146
+
147
+ # ──────────────────────────────────────────────────────────────────────
148
+ # Metadata Extraction
149
+ # ──────────────────────────────────────────────────────────────────────
150
+
151
+ # Build the metadata hash for a decorator unit.
152
+ #
153
+ # @param source [String] Ruby source code
154
+ # @param class_name [String] The class name
155
+ # @param file_path [String] Path to the file
156
+ # @return [Hash] Decorator metadata
157
+ def extract_metadata(source, class_name, file_path)
158
+ {
159
+ decorator_type: infer_decorator_type(file_path),
160
+ decorated_model: infer_decorated_model(class_name),
161
+ uses_draper: draper?(source),
162
+ delegated_methods: extract_delegated_methods(source),
163
+ public_methods: extract_public_methods(source),
164
+ entry_points: detect_entry_points(source),
165
+ class_methods: extract_class_methods(source),
166
+ initialize_params: extract_initialize_params(source),
167
+ loc: source.lines.count { |l| l.strip.length.positive? && !l.strip.start_with?('#') }
168
+ }
169
+ end
170
+
171
+ # Infer the decorator_type symbol from the file path.
172
+ #
173
+ # @param file_path [String] Absolute path to the file
174
+ # @return [Symbol] :decorator, :presenter, or :form_object
175
+ def infer_decorator_type(file_path)
176
+ DIRECTORY_TYPE_MAP.each do |dir_segment, type|
177
+ return type if file_path.include?("/#{dir_segment}/")
178
+ end
179
+ :decorator
180
+ end
181
+
182
+ # Infer the decorated model name by stripping known suffixes.
183
+ #
184
+ # @param class_name [String] e.g. "UserDecorator", "ProductPresenter"
185
+ # @return [String, nil] e.g. "User", "Product", or nil if not inferable
186
+ def infer_decorated_model(class_name)
187
+ base = class_name.split('::').last
188
+ DECORATOR_SUFFIXES.each do |suffix|
189
+ return base.delete_suffix(suffix) if base.end_with?(suffix) && base.length > suffix.length
190
+ end
191
+ nil
192
+ end
193
+
194
+ # Detect whether the class uses the Draper gem.
195
+ #
196
+ # @param source [String] Ruby source code
197
+ # @return [Boolean]
198
+ def draper?(source)
199
+ source.match?(/Draper::Decorator/)
200
+ end
201
+
202
+ # Extract method names passed to `delegate` calls.
203
+ #
204
+ # @param source [String] Ruby source code
205
+ # @return [Array<String>] Delegated method names
206
+ def extract_delegated_methods(source)
207
+ methods = []
208
+ source.scan(/\bdelegate\s+(.*?)(?:,\s*to:|$)/m) do |match|
209
+ match[0].scan(/:(\w+)/).flatten.each { |m| methods << m }
210
+ end
211
+ methods.uniq
212
+ end
213
+
214
+ # Override SharedUtilityMethods#detect_entry_points with decorator-specific
215
+ # entry points (decorate, present, to_partial_path) instead of the generic
216
+ # service-oriented ones (perform, execute, run, process).
217
+ #
218
+ # @param source [String] Ruby source code
219
+ # @return [Array<String>] Entry point method names
220
+ def detect_entry_points(source)
221
+ points = []
222
+ points << 'call' if source.match?(/def (self\.)?call\b/)
223
+ points << 'decorate' if source.match?(/def (self\.)?decorate\b/)
224
+ points << 'present' if source.match?(/def (self\.)?present\b/)
225
+ points << 'to_partial_path' if source.match?(/def to_partial_path\b/)
226
+ points.empty? ? ['unknown'] : points
227
+ end
228
+
229
+ # ──────────────────────────────────────────────────────────────────────
230
+ # Dependency Extraction
231
+ # ──────────────────────────────────────────────────────────────────────
232
+
233
+ # Build the dependency array for a decorator unit.
234
+ #
235
+ # Links to the decorated model via :decoration and scans the source
236
+ # for common code references (models, services, jobs, mailers).
237
+ #
238
+ # @param source [String] Ruby source code
239
+ # @param class_name [String] The class name
240
+ # @return [Array<Hash>] Dependency hashes with :type, :target, :via
241
+ def extract_dependencies(source, class_name)
242
+ deps = []
243
+
244
+ decorated_model = infer_decorated_model(class_name)
245
+ deps << { type: :model, target: decorated_model, via: :decoration } if decorated_model
246
+
247
+ deps.concat(scan_common_dependencies(source))
248
+
249
+ deps.uniq { |d| [d[:type], d[:target]] }
250
+ end
251
+ end
252
+ end
253
+ end
@@ -0,0 +1,223 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative 'shared_utility_methods'
4
+
5
+ module Woods
6
+ module Extractors
7
+ # EngineExtractor handles Rails engine and mountable gem extraction via runtime introspection.
8
+ #
9
+ # Reads `Rails::Engine.subclasses` to discover engines, then inspects each engine's
10
+ # routes, mount point, and configuration. Each engine becomes one ExtractedUnit with
11
+ # metadata about its name, root path, mount point, route count, and isolation.
12
+ #
13
+ # @example
14
+ # extractor = EngineExtractor.new
15
+ # units = extractor.extract_all
16
+ # devise = units.find { |u| u.identifier == "Devise::Engine" }
17
+ #
18
+ class EngineExtractor
19
+ include SharedUtilityMethods
20
+
21
+ def initialize
22
+ # No directories to scan — this is runtime introspection
23
+ end
24
+
25
+ # Extract all Rails engines as ExtractedUnits
26
+ #
27
+ # @return [Array<ExtractedUnit>] List of engine units
28
+ def extract_all
29
+ return [] unless engines_available?
30
+
31
+ engines = engine_subclasses
32
+ return [] if engines.empty?
33
+
34
+ mount_map = build_mount_map
35
+ engines.filter_map { |engine| extract_engine(engine, mount_map) }
36
+ end
37
+
38
+ private
39
+
40
+ # Check if Rails::Engine and the application routing table are available.
41
+ #
42
+ # @return [Boolean]
43
+ def engines_available?
44
+ defined?(Rails::Engine) &&
45
+ Rails.respond_to?(:application) &&
46
+ Rails.application.respond_to?(:routes)
47
+ end
48
+
49
+ # Retrieve Engine subclasses, compatible with Ruby 3.0+.
50
+ # Class#subclasses was added in Ruby 3.1; fall back to descendants filtering.
51
+ #
52
+ # @return [Array<Class>]
53
+ def engine_subclasses
54
+ if Rails::Engine.respond_to?(:subclasses)
55
+ Rails::Engine.subclasses
56
+ else
57
+ ObjectSpace.each_object(Class).select { |klass| klass < Rails::Engine }
58
+ end
59
+ end
60
+
61
+ # Build a mapping from engine class to mounted path by scanning app routes.
62
+ #
63
+ # @return [Hash{Class => String}] Engine class to mount path
64
+ def build_mount_map
65
+ map = {}
66
+ Rails.application.routes.routes.each do |route|
67
+ app = route.app
68
+ next unless engine_class?(app)
69
+
70
+ path = extract_mount_path(route)
71
+ map[app] = path if path
72
+ rescue StandardError
73
+ next
74
+ end
75
+ map
76
+ rescue StandardError
77
+ {}
78
+ end
79
+
80
+ # Check if an object is a Rails::Engine subclass.
81
+ #
82
+ # Uses duck-typing: checks for engine_name method which is defined on all
83
+ # Rails::Engine subclasses. Falls back to class hierarchy check.
84
+ #
85
+ # @param app [Object] The route app object
86
+ # @return [Boolean]
87
+ def engine_class?(app)
88
+ return true if app.is_a?(Class) && defined?(Rails::Engine) && app < Rails::Engine
89
+ return true if app.respond_to?(:engine_name) && app.respond_to?(:routes)
90
+
91
+ false
92
+ end
93
+
94
+ # Extract the mount path string from a route object.
95
+ #
96
+ # @param route [ActionDispatch::Journey::Route]
97
+ # @return [String, nil]
98
+ def extract_mount_path(route)
99
+ return nil unless route.respond_to?(:path) && route.path
100
+
101
+ spec = route.path
102
+ spec = spec.spec if spec.respond_to?(:spec)
103
+ path = spec.to_s
104
+ path.empty? ? nil : path
105
+ end
106
+
107
+ # Extract a single engine into an ExtractedUnit.
108
+ #
109
+ # @param engine [Class] A Rails::Engine subclass
110
+ # @param mount_map [Hash] Engine-to-path mapping
111
+ # @return [ExtractedUnit, nil]
112
+ def extract_engine(engine, mount_map)
113
+ name = engine.name
114
+ engine_name = engine.engine_name
115
+ root_path = engine.root.to_s
116
+ route_count = count_engine_routes(engine)
117
+ mounted_path = mount_map[engine]
118
+ isolated = engine.respond_to?(:isolated?) ? engine.isolated? : false
119
+ controllers = extract_engine_controllers(engine)
120
+
121
+ unit = ExtractedUnit.new(
122
+ type: :engine,
123
+ identifier: name,
124
+ file_path: nil
125
+ )
126
+
127
+ unit.namespace = extract_namespace(name)
128
+ unit.source_code = build_engine_source(name, engine_name, root_path, mounted_path, route_count, isolated)
129
+ unit.metadata = {
130
+ engine_name: engine_name,
131
+ root_path: root_path,
132
+ mounted_path: mounted_path,
133
+ route_count: route_count,
134
+ isolate_namespace: isolated,
135
+ controllers: controllers,
136
+ engine_source: framework_engine?(engine) ? :framework : :application
137
+ }
138
+ unit.dependencies = build_engine_dependencies(controllers)
139
+
140
+ unit
141
+ rescue StandardError => e
142
+ Rails.logger.error("Failed to extract engine #{engine.name}: #{e.message}")
143
+ nil
144
+ end
145
+
146
+ # Check if an engine is a framework gem rather than an application engine.
147
+ #
148
+ # An engine is framework if it lives outside Rails.root, or inside
149
+ # Rails.root but under vendor/bundle or bundler/gems (common in Docker
150
+ # where Rails.root is /app and gems install to /app/vendor/bundle).
151
+ #
152
+ # @param engine [Class] A Rails::Engine subclass
153
+ # @return [Boolean]
154
+ def framework_engine?(engine)
155
+ root = engine.root.to_s
156
+
157
+ # Engine outside Rails.root is definitely framework
158
+ return true unless root.start_with?(Rails.root.to_s)
159
+
160
+ # Engine inside Rails.root but in vendor/bundler paths is framework
161
+ root.include?('/vendor/') || root.include?('/bundler/gems/')
162
+ end
163
+
164
+ # Count routes defined by an engine.
165
+ #
166
+ # @param engine [Class] A Rails::Engine subclass
167
+ # @return [Integer]
168
+ def count_engine_routes(engine)
169
+ engine.routes.routes.count
170
+ rescue StandardError
171
+ 0
172
+ end
173
+
174
+ # Extract controller names from engine routes.
175
+ #
176
+ # @param engine [Class] A Rails::Engine subclass
177
+ # @return [Array<String>] Controller class names
178
+ def extract_engine_controllers(engine)
179
+ controllers = Set.new
180
+ engine.routes.routes.each do |route|
181
+ defaults = route.respond_to?(:defaults) ? route.defaults : {}
182
+ controller = defaults[:controller]
183
+ controllers << "#{controller.camelize}Controller" if controller
184
+ rescue StandardError
185
+ next
186
+ end
187
+ controllers.to_a
188
+ rescue StandardError
189
+ []
190
+ end
191
+
192
+ # Build a human-readable source representation of the engine.
193
+ #
194
+ # @param name [String] Engine class name
195
+ # @param engine_name [String] Engine short name
196
+ # @param root_path [String] Engine root directory
197
+ # @param mounted_path [String, nil] Mount path in host app
198
+ # @param route_count [Integer] Number of routes
199
+ # @param isolated [Boolean] Whether engine uses isolate_namespace
200
+ # @return [String]
201
+ def build_engine_source(name, engine_name, root_path, mounted_path, route_count, isolated)
202
+ lines = []
203
+ lines << "# Engine: #{name}"
204
+ lines << "# Name: #{engine_name}"
205
+ lines << "# Root: #{root_path}"
206
+ lines << "# Mounted at: #{mounted_path || '(not mounted)'}"
207
+ lines << "# Routes: #{route_count}"
208
+ lines << "# Isolated namespace: #{isolated}"
209
+ lines.join("\n")
210
+ end
211
+
212
+ # Build dependencies linking engine to its controllers.
213
+ #
214
+ # @param controllers [Array<String>] Controller class names
215
+ # @return [Array<Hash>]
216
+ def build_engine_dependencies(controllers)
217
+ controllers.map do |controller|
218
+ { type: :controller, target: controller, via: :engine_route }
219
+ end
220
+ end
221
+ end
222
+ end
223
+ end
@@ -0,0 +1,211 @@
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
+ # EventExtractor discovers event publishing and subscribing patterns across the app.
9
+ #
10
+ # Scans +app/**/*.rb+ for two event system conventions:
11
+ # - ActiveSupport::Notifications: +instrument+ (publish) and +subscribe+ (consume)
12
+ # - Wisper: +publish+/+broadcast+ (publish) and +on(:event_name)+ (subscribe)
13
+ #
14
+ # Uses a two-pass approach:
15
+ # 1. Scan all files, collecting publishers and subscribers per event name
16
+ # 2. Merge by event name → one ExtractedUnit per unique event
17
+ #
18
+ # @example
19
+ # extractor = EventExtractor.new
20
+ # units = extractor.extract_all
21
+ # event = units.find { |u| u.identifier == "order.completed" }
22
+ # event.metadata[:publishers] # => ["app/services/order_service.rb"]
23
+ # event.metadata[:subscribers] # => ["app/listeners/order_listener.rb"]
24
+ # event.metadata[:pattern] # => :active_support
25
+ #
26
+ class EventExtractor
27
+ include SharedUtilityMethods
28
+ include SharedDependencyScanner
29
+
30
+ APP_DIRECTORIES = %w[app].freeze
31
+
32
+ def initialize
33
+ @directories = APP_DIRECTORIES.map { |d| Rails.root.join(d) }.select(&:directory?)
34
+ end
35
+
36
+ # Extract all event units using a two-pass approach.
37
+ #
38
+ # Pass 1: Collect publish/subscribe references across all app files.
39
+ # Pass 2: Merge by event name — one ExtractedUnit per unique event.
40
+ #
41
+ # @return [Array<ExtractedUnit>] One unit per unique event name
42
+ def extract_all
43
+ event_map = {}
44
+
45
+ @directories.flat_map { |dir| Dir[dir.join('**/*.rb')] }.each do |file_path|
46
+ scan_file(file_path, event_map)
47
+ end
48
+
49
+ event_map.filter_map { |event_name, data| build_unit(event_name, data) }
50
+ end
51
+
52
+ # Scan a single file for event publishing and subscribing patterns.
53
+ #
54
+ # Mutates +event_map+ in place, registering publishers and subscribers.
55
+ #
56
+ # @param file_path [String] Path to the Ruby file
57
+ # @param event_map [Hash] Mutable map of event_name => {publishers:, subscribers:, pattern:}
58
+ # @return [void]
59
+ def scan_file(file_path, event_map)
60
+ source = File.read(file_path)
61
+ scan_active_support_notifications(source, file_path, event_map)
62
+ scan_wisper_patterns(source, file_path, event_map)
63
+ rescue StandardError => e
64
+ Rails.logger.error("Failed to scan #{file_path} for events: #{e.message}")
65
+ end
66
+
67
+ private
68
+
69
+ # ──────────────────────────────────────────────────────────────────────
70
+ # Pattern Detection
71
+ # ──────────────────────────────────────────────────────────────────────
72
+
73
+ # Scan for ActiveSupport::Notifications instrument and subscribe patterns.
74
+ #
75
+ # @param source [String] Ruby source code
76
+ # @param file_path [String] File path
77
+ # @param event_map [Hash] Mutable event map
78
+ # @return [void]
79
+ def scan_active_support_notifications(source, file_path, event_map)
80
+ source.scan(/ActiveSupport::Notifications\.instrument\s*\(\s*["']([^"']+)["']/) do |m|
81
+ register_publisher(event_map, m[0], file_path, :active_support)
82
+ end
83
+
84
+ source.scan(/ActiveSupport::Notifications\.subscribe\s*\(\s*["']([^"']+)["']/) do |m|
85
+ register_subscriber(event_map, m[0], file_path, :active_support)
86
+ end
87
+ end
88
+
89
+ # Scan for Wisper event patterns.
90
+ #
91
+ # Publishers must have Wisper context in the file (include Wisper or use
92
+ # Wisper directly). Subscribers are detected via +.on(:event_name)+ chains.
93
+ #
94
+ # @param source [String] Ruby source code
95
+ # @param file_path [String] File path
96
+ # @param event_map [Hash] Mutable event map
97
+ # @return [void]
98
+ def scan_wisper_patterns(source, file_path, event_map)
99
+ if source.match?(/include\s+Wisper/)
100
+ source.scan(/\b(?:publish|broadcast)\s+:(\w+)/) do |m|
101
+ register_publisher(event_map, m[0], file_path, :wisper)
102
+ end
103
+ end
104
+
105
+ source.scan(/\.on\s*\(\s*:(\w+)/) do |m|
106
+ register_subscriber(event_map, m[0], file_path, :wisper)
107
+ end
108
+ end
109
+
110
+ # ──────────────────────────────────────────────────────────────────────
111
+ # Event Map Mutation
112
+ # ──────────────────────────────────────────────────────────────────────
113
+
114
+ # Register a publisher for an event name.
115
+ #
116
+ # @param event_map [Hash] Mutable event map
117
+ # @param event_name [String] Event name
118
+ # @param file_path [String] Publisher file path
119
+ # @param pattern [Symbol] :active_support or :wisper
120
+ # @return [void]
121
+ def register_publisher(event_map, event_name, file_path, pattern)
122
+ entry = event_map[event_name] ||= { publishers: [], subscribers: [], pattern: pattern }
123
+ entry[:publishers] << file_path unless entry[:publishers].include?(file_path)
124
+ end
125
+
126
+ # Register a subscriber for an event name.
127
+ #
128
+ # @param event_map [Hash] Mutable event map
129
+ # @param event_name [String] Event name
130
+ # @param file_path [String] Subscriber file path
131
+ # @param pattern [Symbol] :active_support or :wisper
132
+ # @return [void]
133
+ def register_subscriber(event_map, event_name, file_path, pattern)
134
+ entry = event_map[event_name] ||= { publishers: [], subscribers: [], pattern: pattern }
135
+ entry[:subscribers] << file_path unless entry[:subscribers].include?(file_path)
136
+ end
137
+
138
+ # ──────────────────────────────────────────────────────────────────────
139
+ # Unit Construction
140
+ # ──────────────────────────────────────────────────────────────────────
141
+
142
+ # Build an ExtractedUnit from accumulated event data.
143
+ #
144
+ # Returns nil if the event has neither publishers nor subscribers (no-op).
145
+ #
146
+ # @param event_name [String] Event name (used as the unit identifier)
147
+ # @param data [Hash] Accumulated publishers/subscribers/pattern
148
+ # @return [ExtractedUnit, nil]
149
+ def build_unit(event_name, data)
150
+ return nil if data[:publishers].empty? && data[:subscribers].empty?
151
+
152
+ file_path = data[:publishers].first || data[:subscribers].first
153
+ all_paths = (data[:publishers] + data[:subscribers]).uniq
154
+ combined_source = load_source_files(all_paths)
155
+
156
+ unit = ExtractedUnit.new(
157
+ type: :event,
158
+ identifier: event_name,
159
+ file_path: file_path
160
+ )
161
+
162
+ unit.source_code = build_source_annotation(event_name, data)
163
+ unit.metadata = {
164
+ event_name: event_name,
165
+ publishers: data[:publishers],
166
+ subscribers: data[:subscribers],
167
+ pattern: data[:pattern],
168
+ publisher_count: data[:publishers].size,
169
+ subscriber_count: data[:subscribers].size
170
+ }
171
+ unit.dependencies = build_dependencies(combined_source)
172
+ unit
173
+ end
174
+
175
+ # Load source from multiple files for dependency scanning.
176
+ #
177
+ # Silently skips files that cannot be read.
178
+ #
179
+ # @param file_paths [Array<String>] File paths to read
180
+ # @return [String] Combined source
181
+ def load_source_files(file_paths)
182
+ file_paths.filter_map do |path|
183
+ File.read(path)
184
+ rescue StandardError
185
+ nil
186
+ end.join("\n")
187
+ end
188
+
189
+ # Build annotated source annotation for the event unit.
190
+ #
191
+ # @param event_name [String] Event name
192
+ # @param data [Hash] Event data with publishers and subscribers
193
+ # @return [String]
194
+ def build_source_annotation(event_name, data)
195
+ lines = ["# Event: #{event_name} (#{data[:pattern]})"]
196
+ lines << "# Publishers: #{data[:publishers].join(', ')}" if data[:publishers].any?
197
+ lines << "# Subscribers: #{data[:subscribers].join(', ')}" if data[:subscribers].any?
198
+ lines.join("\n")
199
+ end
200
+
201
+ # Build dependencies by scanning combined source of publisher/subscriber files.
202
+ #
203
+ # @param combined_source [String] Combined source from all related files
204
+ # @return [Array<Hash>]
205
+ def build_dependencies(combined_source)
206
+ deps = scan_common_dependencies(combined_source)
207
+ deps.uniq { |d| [d[:type], d[:target]] }
208
+ end
209
+ end
210
+ end
211
+ end