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,261 @@
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
+ # CachingExtractor detects caching usage across controllers, models, and views.
9
+ #
10
+ # Scans `app/controllers/**/*.rb`, `app/models/**/*.rb`, and
11
+ # `app/views/**/*.erb` for cache-related patterns: Rails.cache.*,
12
+ # caches_action, fragment cache blocks, cache_key, cache_version,
13
+ # and expires_in. Produces one unit per file that contains any
14
+ # cache calls, identifying the strategy and TTL patterns.
15
+ #
16
+ # @example
17
+ # extractor = CachingExtractor.new
18
+ # units = extractor.extract_all
19
+ # ctrl = units.find { |u| u.identifier == "app/controllers/products_controller.rb" }
20
+ # ctrl.metadata[:cache_strategy] # => :low_level
21
+ # ctrl.metadata[:cache_calls].size # => 3
22
+ #
23
+ class CachingExtractor
24
+ include SharedUtilityMethods
25
+ include SharedDependencyScanner
26
+
27
+ # File glob patterns to scan
28
+ SCAN_PATTERNS = {
29
+ controller: 'app/controllers/**/*.rb',
30
+ model: 'app/models/**/*.rb',
31
+ view: 'app/views/**/*.erb'
32
+ }.freeze
33
+
34
+ # Patterns that indicate cache usage, grouped by type
35
+ CACHE_PATTERNS = {
36
+ fetch: /Rails\.cache\.fetch\s*[(\[]/,
37
+ read: /Rails\.cache\.read\s*[(\[]/,
38
+ write: /Rails\.cache\.write\s*[(\[]/,
39
+ delete: /Rails\.cache\.delete\s*[(\[]/,
40
+ exist: /Rails\.cache\.exist\?\s*[(\[]/,
41
+ caches_action: /\bcaches_action\b/,
42
+ fragment: /\bcache\s+.*?\bdo\b|\bcache\s+do\b|\bcache\s*\(/,
43
+ cache_key: /\bcache_key\b/,
44
+ cache_version: /\bcache_version\b/
45
+ }.freeze
46
+
47
+ # Patterns for extracting TTL values
48
+ TTL_PATTERN = /expires_in:\s*([^,\n)]+)/
49
+
50
+ # Key-pattern regex (first argument to Rails.cache.*)
51
+ KEY_PATTERN = /Rails\.cache\.(?:fetch|read|write|delete|exist\?)\s*[(\[]?\s*([^,\n)\]]+)/
52
+
53
+ def initialize
54
+ @rails_root = Rails.root
55
+ end
56
+
57
+ # Extract caching units from all scanned files.
58
+ #
59
+ # @return [Array<ExtractedUnit>] One unit per file with cache calls
60
+ def extract_all
61
+ units = []
62
+
63
+ SCAN_PATTERNS.each do |file_type, pattern|
64
+ Dir[@rails_root.join(pattern)].each do |file|
65
+ unit = extract_caching_file(file, file_type)
66
+ units << unit if unit
67
+ end
68
+ end
69
+
70
+ units
71
+ end
72
+
73
+ # Extract a single file for caching patterns.
74
+ #
75
+ # Returns nil if the file contains no cache calls.
76
+ #
77
+ # @param file_path [String] Absolute path to the file
78
+ # @param file_type [Symbol] :controller, :model, or :view
79
+ # @return [ExtractedUnit, nil] The unit or nil if no cache usage
80
+ def extract_caching_file(file_path, file_type = nil)
81
+ source = File.read(file_path)
82
+
83
+ return nil unless cache_usage?(source)
84
+
85
+ file_type ||= infer_file_type(file_path)
86
+ identifier = relative_path(file_path)
87
+
88
+ unit = ExtractedUnit.new(
89
+ type: :caching,
90
+ identifier: identifier,
91
+ file_path: file_path
92
+ )
93
+
94
+ unit.namespace = nil
95
+ unit.source_code = annotate_source(source, identifier, file_type)
96
+ unit.metadata = extract_metadata(source, file_type)
97
+ unit.dependencies = extract_dependencies(source)
98
+
99
+ unit
100
+ rescue StandardError => e
101
+ Rails.logger.error("Failed to extract caching info from #{file_path}: #{e.message}")
102
+ nil
103
+ end
104
+
105
+ private
106
+
107
+ # ──────────────────────────────────────────────────────────────────────
108
+ # Detection
109
+ # ──────────────────────────────────────────────────────────────────────
110
+
111
+ # Check whether the source contains any cache calls.
112
+ #
113
+ # @param source [String] Ruby or ERB source
114
+ # @return [Boolean]
115
+ def cache_usage?(source)
116
+ CACHE_PATTERNS.values.any? { |pattern| source.match?(pattern) }
117
+ end
118
+
119
+ # ──────────────────────────────────────────────────────────────────────
120
+ # Source Annotation
121
+ # ──────────────────────────────────────────────────────────────────────
122
+
123
+ # Prepend a summary annotation header to the source.
124
+ #
125
+ # @param source [String] Source code
126
+ # @param identifier [String] Relative file path identifier
127
+ # @param file_type [Symbol] :controller, :model, or :view
128
+ # @return [String] Annotated source
129
+ def annotate_source(source, identifier, file_type)
130
+ annotation = <<~ANNOTATION
131
+ # ╔═══════════════════════════════════════════════════════════════════════╗
132
+ # ║ Caching: #{identifier.ljust(59)}║
133
+ # ║ File type: #{file_type.to_s.ljust(57)}║
134
+ # ╚═══════════════════════════════════════════════════════════════════════╝
135
+
136
+ ANNOTATION
137
+
138
+ annotation + source
139
+ end
140
+
141
+ # ──────────────────────────────────────────────────────────────────────
142
+ # Metadata Extraction
143
+ # ──────────────────────────────────────────────────────────────────────
144
+
145
+ # Build the metadata hash for a caching unit.
146
+ #
147
+ # @param source [String] Source code
148
+ # @param file_type [Symbol] :controller, :model, or :view
149
+ # @return [Hash] Caching metadata
150
+ def extract_metadata(source, file_type)
151
+ cache_calls = extract_cache_calls(source)
152
+ {
153
+ cache_calls: cache_calls,
154
+ cache_strategy: infer_cache_strategy(source, cache_calls),
155
+ file_type: file_type,
156
+ loc: source.lines.count { |l| l.strip.length.positive? && !l.strip.start_with?('#') }
157
+ }
158
+ end
159
+
160
+ # Extract individual cache call entries from source.
161
+ #
162
+ # Each entry has :type, :key_pattern, and :ttl.
163
+ #
164
+ # @param source [String] Source code
165
+ # @return [Array<Hash>] Cache call descriptors
166
+ def extract_cache_calls(source)
167
+ calls = []
168
+
169
+ CACHE_PATTERNS.each do |type, pattern|
170
+ source.scan(pattern) do
171
+ key = extract_key_pattern(source, type)
172
+ ttl = extract_ttl(source)
173
+ calls << { type: type, key_pattern: key, ttl: ttl }
174
+ end
175
+ end
176
+
177
+ calls
178
+ end
179
+
180
+ # Extract the key pattern for a Rails.cache call.
181
+ #
182
+ # Returns a simplified string representation of the first argument.
183
+ #
184
+ # @param source [String] Source code
185
+ # @param type [Symbol] The cache call type
186
+ # @return [String, nil] The key pattern or nil
187
+ def extract_key_pattern(source, type)
188
+ return nil unless %i[fetch read write delete exist].include?(type)
189
+
190
+ match = source.match(KEY_PATTERN)
191
+ match ? match[1].strip[0, 60] : nil
192
+ end
193
+
194
+ # Extract TTL value from expires_in option.
195
+ #
196
+ # @param source [String] Source code
197
+ # @return [String, nil] The TTL expression or nil
198
+ def extract_ttl(source)
199
+ match = source.match(TTL_PATTERN)
200
+ match ? match[1].strip : nil
201
+ end
202
+
203
+ # Infer the caching strategy from the call types present.
204
+ #
205
+ # @param source [String] Source code
206
+ # @param cache_calls [Array<Hash>] Extracted cache calls
207
+ # @return [Symbol] :fragment, :action, :low_level, or :mixed
208
+ def infer_cache_strategy(source, _cache_calls)
209
+ has_action = source.match?(CACHE_PATTERNS[:caches_action])
210
+ has_fragment = source.match?(CACHE_PATTERNS[:fragment])
211
+ has_low_level = source.match?(/Rails\.cache\.(?:fetch|read|write)/)
212
+
213
+ active_strategies = [has_action, has_fragment, has_low_level].count(true)
214
+
215
+ return :mixed if active_strategies > 1
216
+ return :action if has_action
217
+ return :fragment if has_fragment
218
+ return :low_level if has_low_level
219
+
220
+ :unknown
221
+ end
222
+
223
+ # ──────────────────────────────────────────────────────────────────────
224
+ # Helpers
225
+ # ──────────────────────────────────────────────────────────────────────
226
+
227
+ # Infer the file type from the file path.
228
+ #
229
+ # @param file_path [String] Absolute path to the file
230
+ # @return [Symbol] :controller, :model, or :view
231
+ def infer_file_type(file_path)
232
+ case file_path
233
+ when %r{app/controllers/} then :controller
234
+ when %r{app/models/} then :model
235
+ when %r{app/views/} then :view
236
+ else :unknown
237
+ end
238
+ end
239
+
240
+ # Compute the relative path from Rails root.
241
+ #
242
+ # @param file_path [String] Absolute path
243
+ # @return [String] Relative path (e.g., "app/controllers/products_controller.rb")
244
+ def relative_path(file_path)
245
+ file_path.sub("#{@rails_root}/", '')
246
+ end
247
+
248
+ # ──────────────────────────────────────────────────────────────────────
249
+ # Dependency Extraction
250
+ # ──────────────────────────────────────────────────────────────────────
251
+
252
+ # Build the dependency array by scanning source for common references.
253
+ #
254
+ # @param source [String] Source code
255
+ # @return [Array<Hash>] Dependency hashes with :type, :target, :via
256
+ def extract_dependencies(source)
257
+ scan_common_dependencies(source)
258
+ end
259
+ end
260
+ end
261
+ end
@@ -0,0 +1,246 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'set'
4
+ require_relative '../ast/parser'
5
+ require_relative '../flow_analysis/operation_extractor'
6
+
7
+ module Woods
8
+ module Extractors
9
+ # Analyzes callback method bodies to detect side effects.
10
+ #
11
+ # Given a model's composite source code (with inlined concerns) and its
12
+ # callback metadata, this analyzer finds each callback method body and
13
+ # classifies its side effects: column writes, job enqueues, service calls,
14
+ # mailer triggers, and database reads.
15
+ #
16
+ # @example
17
+ # analyzer = CallbackAnalyzer.new(
18
+ # source_code: model_source,
19
+ # column_names: %w[email status name]
20
+ # )
21
+ # enriched = analyzer.analyze(callback_hash)
22
+ # enriched[:side_effects][:columns_written] #=> ["email"]
23
+ #
24
+ class CallbackAnalyzer
25
+ # Database query methods that indicate a read operation.
26
+ DB_READ_METHODS = %w[find where pluck first last].freeze
27
+
28
+ # Methods that write a single column, taking column name as first argument.
29
+ SINGLE_COLUMN_WRITERS = %w[update_column write_attribute].freeze
30
+
31
+ # Methods that write multiple columns via keyword arguments.
32
+ MULTI_COLUMN_WRITERS = %w[update_columns assign_attributes].freeze
33
+
34
+ # Async enqueue methods that indicate a job is being dispatched.
35
+ ASYNC_METHODS = %w[perform_later perform_async perform_in perform_at].freeze
36
+
37
+ # Pre-compiled regex patterns (avoid dynamic regex construction in hot loops)
38
+ SINGLE_COLUMN_WRITER_PATTERNS = SINGLE_COLUMN_WRITERS.to_h do |w|
39
+ [w, /\b#{Regexp.escape(w)}\s*\(?\s*[:'"](\w+)/]
40
+ end.freeze
41
+
42
+ MULTI_COLUMN_WRITER_PATTERNS = MULTI_COLUMN_WRITERS.to_h do |w|
43
+ [w, /\b#{Regexp.escape(w)}\s*\(([^)]+)\)/m]
44
+ end.freeze
45
+
46
+ ASYNC_PATTERN = /(\w+(?:Job|Worker))\.(?:#{ASYNC_METHODS.map { |m| Regexp.escape(m) }.join('|')})/
47
+
48
+ DB_READ_PATTERNS = DB_READ_METHODS.to_h do |m|
49
+ [m, /\.#{Regexp.escape(m)}\b/]
50
+ end.freeze
51
+
52
+ # @param source_code [String] Composite model source (with inlined concerns)
53
+ # @param column_names [Array<String>] Model's database column names
54
+ def initialize(source_code:, column_names: [])
55
+ @source_code = source_code
56
+ @column_names = column_names.map(&:to_s)
57
+ @parser = Ast::Parser.new
58
+ @operation_extractor = FlowAnalysis::OperationExtractor.new
59
+ @parsed_root = safe_parse
60
+ end
61
+
62
+ # Analyze a single callback and enrich it with side-effect data.
63
+ #
64
+ # Finds the callback's method body in the source, scans it for
65
+ # side effects, and returns the original callback hash with an
66
+ # added :side_effects key.
67
+ #
68
+ # @param callback_hash [Hash] Callback metadata from ModelExtractor:
69
+ # { type:, filter:, kind:, conditions: }
70
+ # @return [Hash] The callback hash with an added :side_effects key
71
+ def analyze(callback_hash)
72
+ filter = callback_hash[:filter].to_s
73
+ method_node = find_method_node(filter)
74
+
75
+ return callback_hash.merge(side_effects: empty_side_effects) if method_node.nil?
76
+
77
+ method_source = method_source_from_node(method_node)
78
+ return callback_hash.merge(side_effects: empty_side_effects) if method_source.nil?
79
+
80
+ callback_hash.merge(
81
+ side_effects: {
82
+ columns_written: detect_columns_written(method_source),
83
+ jobs_enqueued: detect_jobs_enqueued(method_source),
84
+ services_called: detect_services_called(method_source),
85
+ mailers_triggered: detect_mailers_triggered(method_source),
86
+ database_reads: detect_database_reads(method_source),
87
+ operations: extract_operations(method_node)
88
+ }
89
+ )
90
+ end
91
+
92
+ private
93
+
94
+ # Parse source code safely, returning nil on failure.
95
+ #
96
+ # @return [Ast::Node, nil]
97
+ def safe_parse
98
+ @parser.parse(@source_code)
99
+ rescue StandardError
100
+ nil
101
+ end
102
+
103
+ # Find a method definition node by name in the cached AST.
104
+ #
105
+ # @param method_name [String]
106
+ # @return [Ast::Node, nil]
107
+ def find_method_node(method_name)
108
+ return nil unless @parsed_root
109
+ return nil if method_name.empty? || !valid_method_name?(method_name)
110
+
111
+ @parsed_root.find_all(:def).find do |node|
112
+ node.method_name == method_name
113
+ end
114
+ end
115
+
116
+ # Extract the raw source text of a method from its AST node.
117
+ #
118
+ # @param node [Ast::Node]
119
+ # @return [String, nil]
120
+ def method_source_from_node(node)
121
+ return node.source if node.source
122
+
123
+ return nil unless node.line && node.end_line
124
+
125
+ lines = @source_code.lines
126
+ start_idx = node.line - 1
127
+ end_idx = node.end_line - 1
128
+ return nil if start_idx.negative? || end_idx >= lines.length
129
+
130
+ lines[start_idx..end_idx].join
131
+ end
132
+
133
+ # Check if a filter string looks like a valid Ruby method name.
134
+ # Rejects proc/lambda string representations and other non-method filters.
135
+ #
136
+ # @param name [String]
137
+ # @return [Boolean]
138
+ def valid_method_name?(name)
139
+ name.match?(/\A[a-z_]\w*[!?=]?\z/i)
140
+ end
141
+
142
+ # Detect columns written by the callback method.
143
+ #
144
+ # Scans for self.col= assignments, update_column, update_columns,
145
+ # write_attribute, and assign_attributes calls, cross-referencing
146
+ # against the model's known column_names.
147
+ #
148
+ # @param method_source [String]
149
+ # @return [Array<String>]
150
+ def detect_columns_written(method_source)
151
+ columns = Set.new
152
+
153
+ # Pattern: self.col = value (direct assignment, not ==)
154
+ method_source.scan(/self\.(\w+)\s*=(?!=)/).flatten.each do |col|
155
+ columns << col if @column_names.include?(col)
156
+ end
157
+
158
+ # Pattern: update_column(:col, ...) / write_attribute(:col, ...)
159
+ SINGLE_COLUMN_WRITER_PATTERNS.each_value do |pattern|
160
+ method_source.scan(pattern).flatten.each do |col|
161
+ columns << col if @column_names.include?(col)
162
+ end
163
+ end
164
+
165
+ # Pattern: update_columns(col: ...) / assign_attributes(col: ...)
166
+ MULTI_COLUMN_WRITER_PATTERNS.each_value do |pattern|
167
+ method_source.scan(pattern).each do |match|
168
+ match[0].scan(/\b(\w+)\s*:(?!:)/).flatten.each do |col|
169
+ columns << col if @column_names.include?(col)
170
+ end
171
+ end
172
+ end
173
+
174
+ columns.to_a.sort
175
+ end
176
+
177
+ # Detect jobs enqueued by the callback method.
178
+ #
179
+ # Matches Job/Worker classes calling async dispatch methods.
180
+ #
181
+ # @param method_source [String]
182
+ # @return [Array<String>]
183
+ def detect_jobs_enqueued(method_source)
184
+ method_source.scan(ASYNC_PATTERN).flatten.uniq.sort
185
+ end
186
+
187
+ # Detect service objects called by the callback method.
188
+ #
189
+ # Matches classes ending in Service followed by a method call.
190
+ #
191
+ # @param method_source [String]
192
+ # @return [Array<String>]
193
+ def detect_services_called(method_source)
194
+ method_source.scan(/(\w+Service)(?:\.|::)/).flatten.uniq.sort
195
+ end
196
+
197
+ # Detect mailers triggered by the callback method.
198
+ #
199
+ # Matches classes ending in Mailer followed by a method call.
200
+ #
201
+ # @param method_source [String]
202
+ # @return [Array<String>]
203
+ def detect_mailers_triggered(method_source)
204
+ method_source.scan(/(\w+Mailer)\./).flatten.uniq.sort
205
+ end
206
+
207
+ # Detect database read operations in the callback method.
208
+ #
209
+ # Checks for common ActiveRecord query methods called via dot notation.
210
+ #
211
+ # @param method_source [String]
212
+ # @return [Array<String>]
213
+ def detect_database_reads(method_source)
214
+ DB_READ_PATTERNS.filter_map do |method, pattern|
215
+ method if method_source.match?(pattern)
216
+ end
217
+ end
218
+
219
+ # Extract operations using OperationExtractor from the method's AST node.
220
+ #
221
+ # @param method_node [Ast::Node, nil]
222
+ # @return [Array<Hash>]
223
+ def extract_operations(method_node)
224
+ return [] unless method_node
225
+
226
+ @operation_extractor.extract(method_node)
227
+ rescue StandardError
228
+ []
229
+ end
230
+
231
+ # Return an empty side-effects structure.
232
+ #
233
+ # @return [Hash]
234
+ def empty_side_effects
235
+ {
236
+ columns_written: [],
237
+ jobs_enqueued: [],
238
+ services_called: [],
239
+ mailers_triggered: [],
240
+ database_reads: [],
241
+ operations: []
242
+ }
243
+ end
244
+ end
245
+ end
246
+ end