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,217 @@
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
+ # ServiceExtractor handles service object extraction.
9
+ #
10
+ # Service objects often contain the most important business logic.
11
+ # Unlike models (which are discovered via ActiveRecord), services
12
+ # are discovered by scanning conventional directories.
13
+ #
14
+ # We extract:
15
+ # - Public interface (call/perform/execute methods)
16
+ # - Dependencies (what models/services/jobs they use)
17
+ # - Error classes (custom exceptions defined)
18
+ # - Input/output patterns
19
+ #
20
+ # @example
21
+ # extractor = ServiceExtractor.new
22
+ # units = extractor.extract_all
23
+ # checkout = units.find { |u| u.identifier == "CheckoutService" }
24
+ #
25
+ class ServiceExtractor
26
+ include SharedUtilityMethods
27
+ include SharedDependencyScanner
28
+
29
+ # Directories to scan for service objects
30
+ SERVICE_DIRECTORIES = %w[
31
+ app/services
32
+ app/interactors
33
+ app/operations
34
+ app/commands
35
+ app/use_cases
36
+ ].freeze
37
+
38
+ def initialize
39
+ @directories = SERVICE_DIRECTORIES.map { |d| Rails.root.join(d) }
40
+ .select(&:directory?)
41
+ end
42
+
43
+ # Extract all service objects
44
+ #
45
+ # @return [Array<ExtractedUnit>] List of service units
46
+ def extract_all
47
+ @directories.flat_map do |dir|
48
+ Dir[dir.join('**/*.rb')].filter_map do |file|
49
+ extract_service_file(file)
50
+ end
51
+ end
52
+ end
53
+
54
+ # Extract a single service file
55
+ #
56
+ # @param file_path [String] Path to the service file
57
+ # @return [ExtractedUnit, nil] The extracted unit or nil if not a service
58
+ def extract_service_file(file_path)
59
+ source = File.read(file_path)
60
+ class_name = extract_class_name(file_path, source, '(?:services|interactors|operations|commands|use_cases)')
61
+
62
+ return nil unless class_name
63
+ return nil if skip_file?(source)
64
+
65
+ unit = ExtractedUnit.new(
66
+ type: :service,
67
+ identifier: class_name,
68
+ file_path: file_path
69
+ )
70
+
71
+ unit.namespace = extract_namespace(class_name)
72
+ unit.source_code = annotate_source(source, class_name)
73
+ unit.metadata = extract_metadata(source, class_name, file_path)
74
+ unit.dependencies = extract_dependencies(source)
75
+
76
+ unit
77
+ rescue StandardError => e
78
+ Rails.logger.error("Failed to extract service #{file_path}: #{e.message}")
79
+ nil
80
+ end
81
+
82
+ private
83
+
84
+ # ──────────────────────────────────────────────────────────────────────
85
+ # Source Annotation
86
+ # ──────────────────────────────────────────────────────────────────────
87
+
88
+ # Add annotations to help with retrieval
89
+ def annotate_source(source, class_name)
90
+ entry_points = detect_entry_points(source)
91
+
92
+ annotation = <<~ANNOTATION
93
+ # ╔═══════════════════════════════════════════════════════════════════════╗
94
+ # ║ Service: #{class_name.ljust(60)}║
95
+ # ║ Entry Points: #{entry_points.join(', ').ljust(55)}║
96
+ # ╚═══════════════════════════════════════════════════════════════════════╝
97
+
98
+ ANNOTATION
99
+
100
+ annotation + source
101
+ end
102
+
103
+ # ──────────────────────────────────────────────────────────────────────
104
+ # Metadata Extraction
105
+ # ──────────────────────────────────────────────────────────────────────
106
+
107
+ def extract_metadata(source, _class_name, file_path)
108
+ {
109
+ # Entry points
110
+ public_methods: extract_public_methods(source),
111
+ entry_points: detect_entry_points(source),
112
+ class_methods: extract_class_methods(source),
113
+
114
+ # Patterns
115
+ is_callable: source.match?(/def (self\.)?call\b/),
116
+ is_interactor: source.match?(/include\s+Interactor/),
117
+ uses_dry_monads: source.match?(/include\s+Dry::Monads/),
118
+
119
+ # Dependency injection
120
+ initialize_params: extract_initialize_params(source),
121
+ injected_dependencies: extract_injected_deps(source),
122
+
123
+ # Error handling
124
+ custom_errors: extract_custom_errors(source),
125
+ rescues: extract_rescue_handlers(source),
126
+
127
+ # Return patterns
128
+ return_type: infer_return_type(source),
129
+
130
+ # Metrics
131
+ loc: source.lines.count { |l| l.strip.present? && !l.strip.start_with?('#') },
132
+ method_count: source.scan(/def\s+(?:self\.)?\w+/).size,
133
+ complexity: estimate_complexity(source),
134
+
135
+ # Directory context (what kind of service pattern)
136
+ service_type: infer_service_type(file_path)
137
+ }
138
+ end
139
+
140
+ def extract_injected_deps(source)
141
+ # Look for attr_reader/accessor that match common dependency patterns
142
+ deps = []
143
+
144
+ source.scan(/attr_(?:reader|accessor)\s+(.+)/) do |match|
145
+ match[0].scan(/:(\w+)/).flatten.each do |attr|
146
+ deps << attr if attr.match?(/service|repository|client|adapter|gateway|notifier|mailer/)
147
+ end
148
+ end
149
+
150
+ # Also look for initialize assignments
151
+ source.scan(/@(\w+)\s*=\s*(\w+)/) do |ivar, value|
152
+ deps << ivar if value.match?(/Service|Client|Repository|Adapter|Gateway/)
153
+ end
154
+
155
+ deps.uniq
156
+ end
157
+
158
+ def extract_rescue_handlers(source)
159
+ source.scan(/rescue\s+([\w:]+)/).flatten.uniq
160
+ end
161
+
162
+ def infer_return_type(source)
163
+ return :dry_monad if source.match?(/Success\(|Failure\(/)
164
+ return :result_object if source.match?(/Result\.new|OpenStruct\.new/)
165
+ return :boolean if source.match?(/def call.*?(?:true|false)\s*$/m)
166
+
167
+ :unknown
168
+ end
169
+
170
+ def estimate_complexity(source)
171
+ # Simple cyclomatic complexity estimate
172
+ branches = source.scan(/\b(?:if|unless|elsif|when|while|until|for|rescue|&&|\|\|)\b/).size
173
+ branches + 1
174
+ end
175
+
176
+ def infer_service_type(file_path)
177
+ case file_path
178
+ when /interactors/ then :interactor
179
+ when /operations/ then :operation
180
+ when /commands/ then :command
181
+ when /use_cases/ then :use_case
182
+ else :service
183
+ end
184
+ end
185
+
186
+ # ──────────────────────────────────────────────────────────────────────
187
+ # Dependency Extraction
188
+ # ──────────────────────────────────────────────────────────────────────
189
+
190
+ def extract_dependencies(source)
191
+ deps = scan_common_dependencies(source)
192
+
193
+ # Interactors
194
+ source.scan(/(\w+Interactor)(?:\.|::)/).flatten.uniq.each do |interactor|
195
+ deps << { type: :interactor, target: interactor, via: :code_reference }
196
+ end
197
+
198
+ # External API clients
199
+ source.scan(/(\w+Client)(?:\.|::new)/).flatten.uniq.each do |client|
200
+ deps << { type: :api_client, target: client, via: :code_reference }
201
+ end
202
+
203
+ # HTTP calls
204
+ if source.match?(/HTTParty|Faraday|RestClient|Net::HTTP/)
205
+ deps << { type: :external, target: :http_api, via: :code_reference }
206
+ end
207
+
208
+ # Redis
209
+ if source.match?(/Redis\.current|REDIS|Sidekiq\.redis/)
210
+ deps << { type: :infrastructure, target: :redis, via: :code_reference }
211
+ end
212
+
213
+ deps.uniq { |d| [d[:type], d[:target]] }
214
+ end
215
+ end
216
+ end
217
+ end
@@ -0,0 +1,91 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative '../model_name_cache'
4
+
5
+ module Woods
6
+ module Extractors
7
+ # Common dependency scanning patterns shared across extractors.
8
+ #
9
+ # Most extractors scan source code for the same four dependency types:
10
+ # model references (via ModelNameCache), service objects, background jobs,
11
+ # and mailers. This module centralizes those scanning patterns.
12
+ #
13
+ # Individual scan methods accept an optional +:via+ parameter so
14
+ # extractors can customize the relationship label (e.g., +:serialization+
15
+ # instead of the default +:code_reference+).
16
+ #
17
+ # @example
18
+ # class FooExtractor
19
+ # include SharedDependencyScanner
20
+ #
21
+ # def extract_dependencies(source)
22
+ # deps = scan_common_dependencies(source)
23
+ # deps << { type: :custom, target: "Bar", via: :special }
24
+ # deps.uniq { |d| [d[:type], d[:target]] }
25
+ # end
26
+ # end
27
+ #
28
+ module SharedDependencyScanner
29
+ # Scan for ActiveRecord model references using the precomputed regex.
30
+ #
31
+ # @param source [String] Ruby source code to scan
32
+ # @param via [Symbol] Relationship label (default: :code_reference)
33
+ # @return [Array<Hash>] Dependency hashes with :type, :target, :via
34
+ def scan_model_dependencies(source, via: :code_reference)
35
+ source.scan(ModelNameCache.model_names_regex).uniq.map do |model_name|
36
+ { type: :model, target: model_name, via: via }
37
+ end
38
+ end
39
+
40
+ # Scan for service object references (e.g., FooService.call, FooService::new).
41
+ #
42
+ # @param source [String] Ruby source code to scan
43
+ # @param via [Symbol] Relationship label (default: :code_reference)
44
+ # @return [Array<Hash>] Dependency hashes
45
+ def scan_service_dependencies(source, via: :code_reference)
46
+ source.scan(/(\w+Service)(?:\.|::)/).flatten.uniq.map do |service|
47
+ { type: :service, target: service, via: via }
48
+ end
49
+ end
50
+
51
+ # Scan for background job references (e.g., FooJob.perform_later).
52
+ #
53
+ # @param source [String] Ruby source code to scan
54
+ # @param via [Symbol] Relationship label (default: :code_reference)
55
+ # @return [Array<Hash>] Dependency hashes
56
+ def scan_job_dependencies(source, via: :code_reference)
57
+ source.scan(/(\w+Job)\.perform/).flatten.uniq.map do |job|
58
+ { type: :job, target: job, via: via }
59
+ end
60
+ end
61
+
62
+ # Scan for mailer references (e.g., UserMailer.welcome_email).
63
+ #
64
+ # @param source [String] Ruby source code to scan
65
+ # @param via [Symbol] Relationship label (default: :code_reference)
66
+ # @return [Array<Hash>] Dependency hashes
67
+ def scan_mailer_dependencies(source, via: :code_reference)
68
+ source.scan(/(\w+Mailer)\./).flatten.uniq.map do |mailer|
69
+ { type: :mailer, target: mailer, via: via }
70
+ end
71
+ end
72
+
73
+ # Scan for all common dependency types and return a deduplicated array.
74
+ #
75
+ # Combines model, service, job, and mailer scans. Use this when an
76
+ # extractor needs all four standard dependency types with the default
77
+ # +:code_reference+ via label.
78
+ #
79
+ # @param source [String] Ruby source code to scan
80
+ # @return [Array<Hash>] Deduplicated dependency hashes
81
+ def scan_common_dependencies(source)
82
+ deps = []
83
+ deps.concat(scan_model_dependencies(source))
84
+ deps.concat(scan_service_dependencies(source))
85
+ deps.concat(scan_job_dependencies(source))
86
+ deps.concat(scan_mailer_dependencies(source))
87
+ deps.uniq { |d| [d[:type], d[:target]] }
88
+ end
89
+ end
90
+ end
91
+ end
@@ -0,0 +1,281 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Woods
4
+ module Extractors
5
+ # Utility methods shared across multiple extractors.
6
+ #
7
+ # Provides common helpers for namespace extraction, public method
8
+ # scanning, class method scanning, and initialize parameter parsing.
9
+ # These methods are duplicated across 4-11 extractors; this module
10
+ # centralizes them.
11
+ #
12
+ # @example
13
+ # class FooExtractor
14
+ # include SharedUtilityMethods
15
+ #
16
+ # def extract_foo(klass)
17
+ # namespace = extract_namespace(klass)
18
+ # # ...
19
+ # end
20
+ # end
21
+ #
22
+ module SharedUtilityMethods
23
+ # Check whether a path points to application source (under app_root, but
24
+ # not inside vendor/ or node_modules/ directories).
25
+ #
26
+ # In Docker environments where Rails.root is `/app`, a naive
27
+ # `start_with?(app_root)` also matches vendor bundle paths like
28
+ # `/app/vendor/bundle/ruby/…`. This helper rejects those.
29
+ #
30
+ # @param path [String, nil] Absolute file path
31
+ # @param app_root [String] Rails.root.to_s
32
+ # @return [Boolean]
33
+ def app_source?(path, app_root)
34
+ return false unless path
35
+
36
+ path.start_with?(app_root) && !path.include?('/vendor/') && !path.include?('/node_modules/')
37
+ end
38
+
39
+ # Resolve the source file for a class using reliable introspection,
40
+ # filtered through {#app_source?} to reject vendor/gem paths.
41
+ #
42
+ # Tier order:
43
+ # 1. +const_source_location+ (returns the class definition site)
44
+ # 2. Instance method source locations (first match wins)
45
+ # 3. Class/singleton method source locations (first match wins)
46
+ #
47
+ # @param klass [Class, Module] The class to resolve
48
+ # @param app_root [String] Rails.root.to_s
49
+ # @param fallback [String] Path to return when resolution fails
50
+ # @return [String] Resolved source path or fallback
51
+ def resolve_source_location(klass, app_root:, fallback:)
52
+ # Tier 1: const_source_location (most reliable — returns class definition site)
53
+ if Object.respond_to?(:const_source_location) && klass.name
54
+ loc = Object.const_source_location(klass.name)&.first
55
+ return loc if app_source?(loc, app_root)
56
+ end
57
+
58
+ # Tier 2: Instance methods defined directly on this class
59
+ klass.instance_methods(false).each do |method_name|
60
+ loc = klass.instance_method(method_name).source_location&.first
61
+ return loc if app_source?(loc, app_root)
62
+ end
63
+
64
+ # Tier 3: Class/singleton methods defined on this class
65
+ klass.methods(false).each do |method_name|
66
+ loc = klass.method(method_name).source_location&.first
67
+ return loc if app_source?(loc, app_root)
68
+ end
69
+
70
+ fallback
71
+ rescue StandardError
72
+ fallback
73
+ end
74
+
75
+ # Extract the primary class name from source or fall back to a file path convention.
76
+ #
77
+ # @param file_path [String] Absolute path to the Ruby file
78
+ # @param source [String] Ruby source code
79
+ # @param dir_prefix [String] Regex fragment matching the app/ subdirectory to strip
80
+ # (e.g., "policies", "validators", "(?:services|interactors|operations|commands|use_cases)")
81
+ # @return [String] The class name
82
+ def extract_class_name(file_path, source, dir_prefix)
83
+ return ::Regexp.last_match(1) if source =~ /^\s*class\s+([\w:]+)/
84
+
85
+ file_path.sub("#{Rails.root}/", '').sub(%r{^app/#{dir_prefix}/}, '').sub('.rb', '').camelize
86
+ end
87
+
88
+ # Extract the parent class name from a class definition.
89
+ #
90
+ # @param source [String] Ruby source code
91
+ # @return [String, nil] Parent class name or nil
92
+ def extract_parent_class(source)
93
+ match = source.match(/^\s*class\s+[\w:]+\s*<\s*([\w:]+)/)
94
+ match ? match[1] : nil
95
+ end
96
+
97
+ # Count non-blank, non-comment lines of code.
98
+ #
99
+ # @param source [String] Ruby source code
100
+ # @return [Integer] LOC count
101
+ def count_loc(source)
102
+ source.lines.count { |l| l.strip.length.positive? && !l.strip.start_with?('#') }
103
+ end
104
+
105
+ # Skip module-only files (concerns, base modules without a class).
106
+ #
107
+ # @param source [String] Ruby source code
108
+ # @return [Boolean]
109
+ def skip_file?(source)
110
+ source.match?(/^\s*module\s+[\w:]+\s*$/) && !source.match?(/^\s*class\s+/)
111
+ end
112
+
113
+ # Extract custom error/exception class names defined inline.
114
+ #
115
+ # @param source [String] Ruby source code
116
+ # @return [Array<String>] Custom error class names
117
+ def extract_custom_errors(source)
118
+ source.scan(/class\s+(\w+(?:Error|Exception))\s*</).flatten
119
+ end
120
+
121
+ # Detect common entry point methods in a source file.
122
+ #
123
+ # @param source [String] Ruby source code
124
+ # @return [Array<String>] Entry point method names
125
+ def detect_entry_points(source)
126
+ points = []
127
+ points << 'call' if source.match?(/def (self\.)?call\b/)
128
+ points << 'perform' if source.match?(/def (self\.)?perform\b/)
129
+ points << 'execute' if source.match?(/def (self\.)?execute\b/)
130
+ points << 'run' if source.match?(/def (self\.)?run\b/)
131
+ points << 'process' if source.match?(/def (self\.)?process\b/)
132
+ points.empty? ? ['unknown'] : points
133
+ end
134
+
135
+ # Extract :only/:except action lists and :if/:unless conditions from a callback.
136
+ #
137
+ # Modern Rails (4.2+) stores conditions in @if/@unless ivar arrays.
138
+ # ActionFilter objects hold action Sets; other conditions are procs/symbols.
139
+ #
140
+ # @param callback [ActiveSupport::Callbacks::Callback]
141
+ # @return [Array(Array<String>, Array<String>, Array<String>, Array<String>)]
142
+ # [only_actions, except_actions, if_labels, unless_labels]
143
+ def extract_callback_conditions(callback)
144
+ if_conditions = callback.instance_variable_get(:@if) || []
145
+ unless_conditions = callback.instance_variable_get(:@unless) || []
146
+
147
+ only = []
148
+ except = []
149
+ if_labels = []
150
+ unless_labels = []
151
+
152
+ if_conditions.each do |cond|
153
+ actions = extract_action_filter_actions(cond)
154
+ if actions
155
+ only.concat(actions)
156
+ else
157
+ if_labels << condition_label(cond)
158
+ end
159
+ end
160
+
161
+ unless_conditions.each do |cond|
162
+ actions = extract_action_filter_actions(cond)
163
+ if actions
164
+ except.concat(actions)
165
+ else
166
+ unless_labels << condition_label(cond)
167
+ end
168
+ end
169
+
170
+ [only, except, if_labels, unless_labels]
171
+ end
172
+
173
+ # Extract action names from an ActionFilter-like condition object.
174
+ # Duck-types on the @actions ivar being a Set, avoiding dependence
175
+ # on private class names across Rails versions.
176
+ #
177
+ # @param condition [Object] A condition from the callback's @if/@unless array
178
+ # @return [Array<String>, nil] Action names, or nil if not an ActionFilter
179
+ def extract_action_filter_actions(condition)
180
+ return nil unless condition.instance_variable_defined?(:@actions)
181
+
182
+ actions = condition.instance_variable_get(:@actions)
183
+ return nil unless actions.is_a?(Set)
184
+
185
+ actions.to_a
186
+ end
187
+
188
+ # Human-readable label for a non-ActionFilter condition.
189
+ #
190
+ # @param condition [Object] A proc, symbol, or other condition
191
+ # @return [String]
192
+ def condition_label(condition)
193
+ case condition
194
+ when Symbol then ":#{condition}"
195
+ when Proc then 'Proc'
196
+ when String then condition
197
+ else condition.class.name
198
+ end
199
+ end
200
+
201
+ # Extract namespace from a class name string or class object.
202
+ #
203
+ # Handles both string input (e.g., "Payments::StripeService")
204
+ # and class object input (e.g., a Controller class).
205
+ #
206
+ # @param name_or_object [String, Class, Module] A class name or class object
207
+ # @return [String, nil] The namespace, or nil if top-level
208
+ def extract_namespace(name_or_object)
209
+ name = name_or_object.is_a?(String) ? name_or_object : name_or_object.name
210
+ parts = name.split('::')
211
+ parts.size > 1 ? parts[0..-2].join('::') : nil
212
+ end
213
+
214
+ # Extract public instance and class methods from source code.
215
+ #
216
+ # Walks source line-by-line tracking private/protected visibility.
217
+ # Returns method names that are in public scope and don't start with underscore.
218
+ #
219
+ # @param source [String] Ruby source code
220
+ # @return [Array<String>] Public method names
221
+ def extract_public_methods(source)
222
+ methods = []
223
+ in_private = false
224
+ in_protected = false
225
+
226
+ source.each_line do |line|
227
+ stripped = line.strip
228
+
229
+ in_private = true if stripped == 'private'
230
+ in_protected = true if stripped == 'protected'
231
+ in_private = false if stripped == 'public'
232
+ in_protected = false if stripped == 'public'
233
+
234
+ if !in_private && !in_protected && stripped =~ /def\s+((?:self\.)?\w+[?!=]?)/
235
+ method_name = ::Regexp.last_match(1)
236
+ methods << method_name unless method_name.start_with?('_')
237
+ end
238
+ end
239
+
240
+ methods
241
+ end
242
+
243
+ # Extract class-level (self.) method names from source code.
244
+ #
245
+ # @param source [String] Ruby source code
246
+ # @return [Array<String>] Class method names
247
+ def extract_class_methods(source)
248
+ source.scan(/def\s+self\.(\w+[?!=]?)/).flatten
249
+ end
250
+
251
+ # Extract initialize parameters from source code via regex.
252
+ #
253
+ # Parses the parameter list of the initialize method to determine
254
+ # parameter names, defaults, and whether they are keyword arguments.
255
+ #
256
+ # Note: PhlexExtractor and ViewComponentExtractor override this with a
257
+ # runtime-introspection version that takes a Class object instead of source
258
+ # text, providing richer type information (:req, :opt, :keyreq, :rest, etc.).
259
+ #
260
+ # @param source [String] Ruby source code
261
+ # @return [Array<Hash>] Parameter info hashes with :name, :has_default, :keyword
262
+ def extract_initialize_params(source)
263
+ init_match = source.match(/def\s+initialize\s*\((.*?)\)/m)
264
+ return [] unless init_match
265
+
266
+ params_str = init_match[1]
267
+ params = []
268
+
269
+ params_str.scan(/(\w+)(?::\s*([^,\n]+))?/) do |name, default|
270
+ params << {
271
+ name: name,
272
+ has_default: !default.nil?,
273
+ keyword: params_str.include?("#{name}:")
274
+ }
275
+ end
276
+
277
+ params
278
+ end
279
+ end
280
+ end
281
+ end