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,117 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'yaml'
4
+
5
+ module Woods
6
+ module Extractors
7
+ # I18nExtractor handles internationalization locale file extraction.
8
+ #
9
+ # Parses YAML files from `config/locales/` to extract translation keys,
10
+ # locale information, and key structure. Each locale file becomes one
11
+ # ExtractedUnit.
12
+ #
13
+ # @example
14
+ # extractor = I18nExtractor.new
15
+ # units = extractor.extract_all
16
+ # en = units.find { |u| u.identifier == "en.yml" }
17
+ #
18
+ class I18nExtractor
19
+ # Directories to scan for locale files
20
+ I18N_DIRECTORIES = %w[
21
+ config/locales
22
+ ].freeze
23
+
24
+ def initialize
25
+ @directories = I18N_DIRECTORIES.map { |d| Rails.root.join(d) }
26
+ .select(&:directory?)
27
+ end
28
+
29
+ # Extract all locale files
30
+ #
31
+ # @return [Array<ExtractedUnit>] List of i18n units
32
+ def extract_all
33
+ @directories.flat_map do |dir|
34
+ Dir[dir.join('**/*.yml')].filter_map do |file|
35
+ extract_i18n_file(file)
36
+ end
37
+ end
38
+ end
39
+
40
+ # Extract a single locale file
41
+ #
42
+ # @param file_path [String] Path to the YAML locale file
43
+ # @return [ExtractedUnit, nil] The extracted unit or nil on failure
44
+ def extract_i18n_file(file_path)
45
+ source = File.read(file_path)
46
+ data = YAML.safe_load(source, permitted_classes: [Symbol, Date, Time, Regexp])
47
+
48
+ return nil unless data.is_a?(Hash) && data.any?
49
+
50
+ identifier = build_identifier(file_path)
51
+ locale = data.keys.first
52
+
53
+ unit = ExtractedUnit.new(
54
+ type: :i18n,
55
+ identifier: identifier,
56
+ file_path: file_path
57
+ )
58
+
59
+ unit.namespace = locale
60
+ unit.source_code = source
61
+ unit.metadata = build_metadata(data, locale)
62
+ unit.dependencies = []
63
+
64
+ unit
65
+ rescue StandardError => e
66
+ Rails.logger.error("Failed to extract i18n #{file_path}: #{e.message}")
67
+ nil
68
+ end
69
+
70
+ private
71
+
72
+ # Build a readable identifier from the file path.
73
+ #
74
+ # @param file_path [String] Absolute path
75
+ # @return [String] Relative identifier like "en.yml" or "models/en.yml"
76
+ def build_identifier(file_path)
77
+ relative = file_path.sub("#{Rails.root}/", '')
78
+ relative.sub(%r{^config/locales/}, '')
79
+ end
80
+
81
+ # Build metadata for the locale file.
82
+ #
83
+ # @param data [Hash] Parsed YAML data
84
+ # @param locale [String] The locale key (e.g., "en")
85
+ # @return [Hash]
86
+ def build_metadata(data, locale)
87
+ locale_data = data[locale] || {}
88
+ key_paths = flatten_keys(locale_data)
89
+
90
+ {
91
+ locale: locale,
92
+ key_count: key_paths.size,
93
+ top_level_keys: locale_data.is_a?(Hash) ? locale_data.keys : [],
94
+ key_paths: key_paths
95
+ }
96
+ end
97
+
98
+ # Flatten a nested hash into dot-notation key paths.
99
+ #
100
+ # @param hash [Hash] Nested hash to flatten
101
+ # @param prefix [String] Current key prefix
102
+ # @return [Array<String>] Flattened key paths
103
+ def flatten_keys(hash, prefix = '')
104
+ return ["#{prefix}(leaf)"] unless hash.is_a?(Hash)
105
+
106
+ hash.flat_map do |key, value|
107
+ full_key = prefix.empty? ? key.to_s : "#{prefix}.#{key}"
108
+ if value.is_a?(Hash)
109
+ flatten_keys(value, full_key)
110
+ else
111
+ [full_key]
112
+ end
113
+ end
114
+ end
115
+ end
116
+ end
117
+ end
@@ -0,0 +1,374 @@
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
+ # JobExtractor handles ActiveJob and Sidekiq job extraction.
9
+ #
10
+ # Background jobs are critical for understanding async behavior.
11
+ # They often perform important business logic that would otherwise
12
+ # be unclear from just looking at models and controllers.
13
+ #
14
+ # We extract:
15
+ # - Queue configuration
16
+ # - Retry/error handling configuration
17
+ # - Arguments (the job's interface)
18
+ # - What the job calls (dependencies)
19
+ # - What triggers this job (reverse lookup via dependencies)
20
+ #
21
+ # @example
22
+ # extractor = JobExtractor.new
23
+ # units = extractor.extract_all
24
+ # order_job = units.find { |u| u.identifier == "ProcessOrderJob" }
25
+ #
26
+ class JobExtractor
27
+ include SharedUtilityMethods
28
+ include SharedDependencyScanner
29
+
30
+ # Directories to scan for jobs
31
+ JOB_DIRECTORIES = %w[
32
+ app/jobs
33
+ app/workers
34
+ app/sidekiq
35
+ ].freeze
36
+
37
+ def initialize
38
+ @directories = JOB_DIRECTORIES.map { |d| Rails.root.join(d) }
39
+ .select(&:directory?)
40
+ end
41
+
42
+ # Extract all jobs in the application
43
+ #
44
+ # @return [Array<ExtractedUnit>] List of job units
45
+ def extract_all
46
+ units = []
47
+
48
+ # File-based discovery (catches everything)
49
+ @directories.each do |dir|
50
+ Dir[dir.join('**/*.rb')].each do |file|
51
+ unit = extract_job_file(file)
52
+ units << unit if unit
53
+ end
54
+ end
55
+
56
+ # Also try class-based discovery for ActiveJob
57
+ if defined?(ApplicationJob)
58
+ seen = units.to_set(&:identifier)
59
+ ApplicationJob.descendants.each do |job_class|
60
+ next if seen.include?(job_class.name)
61
+
62
+ unit = extract_job_class(job_class)
63
+ if unit
64
+ units << unit
65
+ seen << unit.identifier
66
+ end
67
+ end
68
+ end
69
+
70
+ units.compact
71
+ end
72
+
73
+ # Extract a job from its file
74
+ #
75
+ # @param file_path [String] Path to the job file
76
+ # @return [ExtractedUnit, nil] The extracted unit
77
+ def extract_job_file(file_path)
78
+ source = File.read(file_path)
79
+ class_name = extract_class_name(file_path, source)
80
+
81
+ return nil unless class_name
82
+ return nil unless job_file?(source)
83
+
84
+ unit = ExtractedUnit.new(
85
+ type: :job,
86
+ identifier: class_name,
87
+ file_path: file_path
88
+ )
89
+
90
+ unit.namespace = extract_namespace(class_name)
91
+ unit.source_code = annotate_source(source, class_name)
92
+ unit.metadata = extract_metadata_from_source(source, class_name)
93
+ unit.dependencies = extract_dependencies(source, class_name)
94
+
95
+ unit
96
+ rescue StandardError => e
97
+ Rails.logger.error("Failed to extract job #{file_path}: #{e.message}")
98
+ nil
99
+ end
100
+
101
+ # Extract a job from its class (runtime introspection)
102
+ #
103
+ # @param job_class [Class] The job class
104
+ # @return [ExtractedUnit, nil] The extracted unit
105
+ def extract_job_class(job_class)
106
+ return nil if job_class.name.nil?
107
+
108
+ file_path = source_file_for(job_class)
109
+ source = file_path && File.exist?(file_path) ? File.read(file_path) : ''
110
+
111
+ unit = ExtractedUnit.new(
112
+ type: :job,
113
+ identifier: job_class.name,
114
+ file_path: file_path
115
+ )
116
+
117
+ unit.namespace = extract_namespace(job_class.name)
118
+ unit.source_code = annotate_source(source, job_class.name)
119
+ unit.metadata = extract_metadata_from_class(job_class, source)
120
+ unit.dependencies = extract_dependencies(source, job_class.name)
121
+
122
+ unit
123
+ rescue StandardError => e
124
+ Rails.logger.error("Failed to extract job #{job_class.name}: #{e.message}")
125
+ nil
126
+ end
127
+
128
+ private
129
+
130
+ # ──────────────────────────────────────────────────────────────────────
131
+ # Class Discovery
132
+ # ──────────────────────────────────────────────────────────────────────
133
+
134
+ def extract_class_name(file_path, source)
135
+ # Try to extract from source
136
+ return ::Regexp.last_match(1) if source =~ /^\s*class\s+([\w:]+)/
137
+
138
+ # Fall back to convention
139
+ file_path
140
+ .sub("#{Rails.root}/", '')
141
+ .sub(%r{^app/(jobs|workers|sidekiq)/}, '')
142
+ .sub('.rb', '')
143
+ .camelize
144
+ end
145
+
146
+ def job_file?(source)
147
+ # Check if this looks like a job/worker file
148
+ source.match?(/< ApplicationJob/) ||
149
+ source.match?(/< ActiveJob::Base/) ||
150
+ source.match?(/include Sidekiq::Worker/) ||
151
+ source.match?(/include Sidekiq::Job/) ||
152
+ source.match?(/def perform/)
153
+ end
154
+
155
+ # Locate the source file for a job class.
156
+ #
157
+ # Convention path first, then introspection via {#resolve_source_location}
158
+ # which filters out vendor/node_modules paths.
159
+ #
160
+ # @param job_class [Class]
161
+ # @return [String, nil]
162
+ def source_file_for(job_class)
163
+ convention_path = Rails.root.join("app/jobs/#{job_class.name.underscore}.rb").to_s
164
+ return convention_path if File.exist?(convention_path)
165
+
166
+ resolve_source_location(job_class, app_root: Rails.root.to_s, fallback: convention_path)
167
+ end
168
+
169
+ # ──────────────────────────────────────────────────────────────────────
170
+ # Source Annotation
171
+ # ──────────────────────────────────────────────────────────────────────
172
+
173
+ def annotate_source(source, class_name)
174
+ job_type = detect_job_type(source)
175
+ queue = extract_queue(source)
176
+
177
+ <<~ANNOTATION
178
+ # ╔═══════════════════════════════════════════════════════════════════════╗
179
+ # ║ Job: #{class_name.ljust(62)}║
180
+ # ║ Type: #{job_type.to_s.ljust(61)}║
181
+ # ║ Queue: #{(queue || 'default').ljust(60)}║
182
+ # ╚═══════════════════════════════════════════════════════════════════════╝
183
+
184
+ #{source}
185
+ ANNOTATION
186
+ end
187
+
188
+ def detect_job_type(source)
189
+ return :sidekiq if source.match?(/include Sidekiq::(Worker|Job)/)
190
+ return :active_job if source.match?(/< (ApplicationJob|ActiveJob::Base)/)
191
+ return :good_job if source.match?(/include GoodJob/)
192
+ return :delayed_job if source.match?(/delay|handle_asynchronously/)
193
+
194
+ :unknown
195
+ end
196
+
197
+ def extract_queue(source)
198
+ # ActiveJob style
199
+ return ::Regexp.last_match(1) if source =~ /queue_as\s+[:"'](\w+)/
200
+
201
+ # Sidekiq style
202
+ return ::Regexp.last_match(1) if source =~ /sidekiq_options.*queue:\s*[:"'](\w+)/
203
+
204
+ nil
205
+ end
206
+
207
+ # ──────────────────────────────────────────────────────────────────────
208
+ # Metadata Extraction (from source)
209
+ # ──────────────────────────────────────────────────────────────────────
210
+
211
+ def extract_metadata_from_source(source, class_name)
212
+ {
213
+ job_type: detect_job_type(source),
214
+ queue: extract_queue(source),
215
+
216
+ # Configuration
217
+ sidekiq_options: extract_sidekiq_options(source),
218
+ retry_config: extract_retry_config(source),
219
+ concurrency_controls: extract_concurrency(source),
220
+
221
+ # Interface
222
+ perform_params: extract_perform_params(source),
223
+ scheduled: source.match?(/perform_later|perform_in|perform_at/),
224
+
225
+ # Error handling
226
+ discard_on: extract_discard_on(source),
227
+ retry_on: extract_retry_on(source),
228
+
229
+ # Callbacks
230
+ callbacks: extract_callbacks(source),
231
+
232
+ # Job chaining
233
+ enqueues_jobs: extract_enqueued_jobs(source, class_name),
234
+
235
+ # Metrics
236
+ loc: source.lines.count { |l| l.strip.present? && !l.strip.start_with?('#') }
237
+ }
238
+ end
239
+
240
+ def extract_metadata_from_class(job_class, source)
241
+ base_metadata = extract_metadata_from_source(source, job_class.name)
242
+
243
+ # Enhance with runtime introspection if available
244
+ base_metadata[:queue] ||= job_class.queue_name if job_class.respond_to?(:queue_name)
245
+
246
+ base_metadata[:sidekiq_options] = job_class.sidekiq_options_hash if job_class.respond_to?(:sidekiq_options_hash)
247
+
248
+ base_metadata
249
+ end
250
+
251
+ def extract_sidekiq_options(source)
252
+ options = {}
253
+
254
+ if source =~ /sidekiq_options\s+(.+)/
255
+ opts_str = ::Regexp.last_match(1)
256
+ opts_str.scan(/(\w+):\s*([^,\n]+)/) do |key, value|
257
+ options[key.to_sym] = value.strip
258
+ end
259
+ end
260
+
261
+ options
262
+ end
263
+
264
+ def extract_retry_config(source)
265
+ config = {}
266
+
267
+ # ActiveJob retry_on
268
+ source.scan(/retry_on\s+(\w+)(?:,\s*wait:\s*([^,\n]+))?(?:,\s*attempts:\s*(\d+))?/) do |error, wait, attempts|
269
+ config[:retry_on] ||= []
270
+ config[:retry_on] << {
271
+ error: error,
272
+ wait: wait,
273
+ attempts: attempts&.to_i
274
+ }
275
+ end
276
+
277
+ # Sidekiq retries
278
+ config[:sidekiq_retries] = ::Regexp.last_match(1) if source =~ /sidekiq_options.*retry:\s*(\d+|false|true)/
279
+
280
+ config
281
+ end
282
+
283
+ def extract_concurrency(source)
284
+ controls = {}
285
+
286
+ # Sidekiq unique jobs
287
+ controls[:unique_for] = ::Regexp.last_match(1).to_i if source =~ /unique_for:\s*(\d+)/
288
+
289
+ # Sidekiq rate limiting
290
+ controls[:rate_limit] = ::Regexp.last_match(1) if source =~ /rate_limit:\s*\{([^}]+)\}/
291
+
292
+ controls
293
+ end
294
+
295
+ def extract_perform_params(source)
296
+ return [] unless source =~ /def\s+perform\s*\(([^)]*)\)/
297
+
298
+ params_str = ::Regexp.last_match(1)
299
+ params = []
300
+
301
+ params_str.scan(/(\*?\*?\w+)(?:\s*=\s*([^,]+))?/) do |name, default|
302
+ params << {
303
+ name: name.gsub(/^\*+/, ''),
304
+ splat: if name.start_with?('**')
305
+ :double
306
+ else
307
+ (name.start_with?('*') ? :single : nil)
308
+ end,
309
+ has_default: !default.nil?
310
+ }
311
+ end
312
+
313
+ params
314
+ end
315
+
316
+ def extract_discard_on(source)
317
+ source.scan(/discard_on\s+(\w+(?:::\w+)*)/).flatten
318
+ end
319
+
320
+ def extract_retry_on(source)
321
+ source.scan(/retry_on\s+(\w+(?:::\w+)*)/).flatten
322
+ end
323
+
324
+ def extract_callbacks(source)
325
+ callbacks = []
326
+
327
+ %w[before_enqueue after_enqueue before_perform after_perform around_perform].each do |cb|
328
+ source.scan(/#{cb}\s+(?::(\w+)|do)/) do |method|
329
+ callbacks << { type: cb, method: method&.first }
330
+ end
331
+ end
332
+
333
+ callbacks
334
+ end
335
+
336
+ # ──────────────────────────────────────────────────────────────────────
337
+ # Dependency Extraction
338
+ # ──────────────────────────────────────────────────────────────────────
339
+
340
+ def extract_dependencies(source, current_class_name = nil)
341
+ # Scan standard dep types individually (not scan_common_dependencies) so we can
342
+ # handle job deps with the richer :job_enqueue via and self-reference exclusion.
343
+ deps = scan_model_dependencies(source)
344
+ deps.concat(scan_service_dependencies(source))
345
+ deps.concat(scan_mailer_dependencies(source))
346
+
347
+ # Job-to-job dependencies with specific :job_enqueue via and self-reference exclusion
348
+ extract_enqueued_jobs(source, current_class_name).each do |job_name|
349
+ deps << { type: :job, target: job_name, via: :job_enqueue }
350
+ end
351
+
352
+ # External services
353
+ if source.match?(/HTTParty|Faraday|RestClient|Net::HTTP/)
354
+ deps << { type: :external, target: :http_api, via: :code_reference }
355
+ end
356
+
357
+ deps << { type: :infrastructure, target: :redis, via: :code_reference } if source.match?(/Redis\.current|REDIS/)
358
+
359
+ deps.uniq { |d| [d[:type], d[:target]] }
360
+ end
361
+
362
+ # Scan source for job class enqueue calls and return the list of enqueued job names.
363
+ #
364
+ # @param source [String] The job source code
365
+ # @param current_class_name [String, nil] The current job class name (excluded from results)
366
+ # @return [Array<String>] Unique list of enqueued job class names
367
+ def extract_enqueued_jobs(source, current_class_name = nil)
368
+ pattern = /(\w+Job)\.(?:perform_later|perform_async|perform_in|perform_at|set\b)/
369
+ job_names = source.scan(pattern).flatten.uniq
370
+ job_names.reject { |name| name == current_class_name }
371
+ end
372
+ end
373
+ end
374
+ end