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,218 @@
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
+ # LibExtractor handles extraction of Ruby files from lib/.
9
+ #
10
+ # The lib/ directory contains application infrastructure that sits outside
11
+ # Rails' app/ convention: custom middleware, client wrappers, utility classes,
12
+ # domain-specific libraries, and framework extensions. These are often heavily
13
+ # referenced but invisible to app/-only extractors.
14
+ #
15
+ # Excludes:
16
+ # - lib/tasks/ — handled by RakeTaskExtractor
17
+ # - lib/generators/ — Rails generator scaffolding, not application code
18
+ #
19
+ # Handles:
20
+ # - Plain Ruby classes (with or without inheritance)
21
+ # - Module-only files (standalone modules without a class)
22
+ # - Namespaced classes (e.g., lib/external/analytics.rb → External::Analytics)
23
+ # - Files with multiple class definitions
24
+ #
25
+ # @example
26
+ # extractor = LibExtractor.new
27
+ # units = extractor.extract_all
28
+ # analytics = units.find { |u| u.identifier == "External::Analytics" }
29
+ # analytics.metadata[:entry_points] # => ["call"]
30
+ # analytics.metadata[:parent_class] # => nil
31
+ #
32
+ class LibExtractor
33
+ include SharedUtilityMethods
34
+ include SharedDependencyScanner
35
+
36
+ # Root directory to scan
37
+ LIB_DIRECTORY = 'lib'
38
+
39
+ # Subdirectories to exclude from extraction
40
+ EXCLUDED_SEGMENTS = %w[/tasks/ /generators/].freeze
41
+
42
+ def initialize
43
+ @lib_dir = Rails.root.join(LIB_DIRECTORY)
44
+ end
45
+
46
+ # Extract all lib units from lib/**/*.rb (excluding tasks and generators).
47
+ #
48
+ # @return [Array<ExtractedUnit>] List of lib units
49
+ def extract_all
50
+ return [] unless @lib_dir.directory?
51
+
52
+ Dir[@lib_dir.join('**/*.rb')].filter_map do |file|
53
+ next if excluded_path?(file)
54
+
55
+ extract_lib_file(file)
56
+ end
57
+ end
58
+
59
+ # Extract a single lib file.
60
+ #
61
+ # Returns nil if the file cannot be read or yields no extractable unit.
62
+ # Module-only files are extracted (unlike some other extractors) since
63
+ # lib/ commonly contains standalone utility modules.
64
+ #
65
+ # @param file_path [String] Absolute path to the Ruby file
66
+ # @return [ExtractedUnit, nil] The extracted unit or nil on failure
67
+ def extract_lib_file(file_path)
68
+ source = File.read(file_path)
69
+
70
+ class_name = infer_class_name(file_path, source)
71
+ return nil unless class_name
72
+
73
+ unit = ExtractedUnit.new(
74
+ type: :lib,
75
+ identifier: class_name,
76
+ file_path: file_path
77
+ )
78
+
79
+ unit.namespace = extract_namespace(class_name)
80
+ unit.source_code = annotate_source(source, class_name)
81
+ unit.metadata = extract_metadata(source, class_name)
82
+ unit.dependencies = extract_dependencies(source)
83
+
84
+ unit
85
+ rescue StandardError => e
86
+ Rails.logger.error("Failed to extract lib file #{file_path}: #{e.message}")
87
+ nil
88
+ end
89
+
90
+ private
91
+
92
+ # ──────────────────────────────────────────────────────────────────────
93
+ # Path Filtering
94
+ # ──────────────────────────────────────────────────────────────────────
95
+
96
+ # Return true when the file path falls inside an excluded subdirectory.
97
+ #
98
+ # @param file_path [String] Absolute path to the file
99
+ # @return [Boolean]
100
+ def excluded_path?(file_path)
101
+ EXCLUDED_SEGMENTS.any? { |seg| file_path.include?(seg) }
102
+ end
103
+
104
+ # ──────────────────────────────────────────────────────────────────────
105
+ # Class / Module Name Inference
106
+ # ──────────────────────────────────────────────────────────────────────
107
+
108
+ # Infer the primary constant name from source or fall back to file path.
109
+ #
110
+ # For files with a class definition, combines outer module namespaces
111
+ # with the class name. For module-only files, uses the outermost module
112
+ # name (joined with inner modules). Falls back to path-based camelize
113
+ # when neither is present.
114
+ #
115
+ # @param file_path [String] Absolute path to the file
116
+ # @param source [String] Ruby source code
117
+ # @return [String, nil] The inferred constant name, or nil for empty files
118
+ def infer_class_name(file_path, source)
119
+ return nil if source.strip.empty?
120
+
121
+ # Class definition — combine outer modules + class name
122
+ class_match = source.match(/^\s*class\s+([\w:]+)/)
123
+ if class_match
124
+ base = class_match[1]
125
+ return base if base.include?('::')
126
+
127
+ namespaces = source.scan(/^\s*module\s+([\w:]+)/).flatten
128
+ return namespaces.any? ? "#{namespaces.join('::')}::#{base}" : base
129
+ end
130
+
131
+ # Module-only file — use the outermost module chain
132
+ modules = source.scan(/^\s*module\s+([\w:]+)/).flatten
133
+ return modules.join('::') if modules.any?
134
+
135
+ # Fall back to path-based naming
136
+ path_based_class_name(file_path)
137
+ end
138
+
139
+ # Derive a constant name from a lib/ file path.
140
+ #
141
+ # lib/external/analytics.rb => External::Analytics
142
+ # lib/json_api/serializer.rb => JsonApi::Serializer
143
+ # lib/my_gem.rb => MyGem
144
+ #
145
+ # @param file_path [String] Absolute path to the file
146
+ # @return [String] Camelize-derived constant name
147
+ def path_based_class_name(file_path)
148
+ relative = file_path.sub("#{Rails.root}/", '')
149
+ relative
150
+ .sub(%r{^lib/}, '')
151
+ .sub('.rb', '')
152
+ .split('/')
153
+ .map(&:camelize)
154
+ .join('::')
155
+ end
156
+
157
+ # ──────────────────────────────────────────────────────────────────────
158
+ # Source Annotation
159
+ # ──────────────────────────────────────────────────────────────────────
160
+
161
+ # Prepend a summary annotation header to the source.
162
+ #
163
+ # @param source [String] Ruby source code
164
+ # @param class_name [String] The inferred constant name
165
+ # @return [String] Annotated source
166
+ def annotate_source(source, class_name)
167
+ parent = extract_parent_class(source)
168
+ entry_points = detect_entry_points(source)
169
+ parent_label = parent || 'none'
170
+
171
+ annotation = <<~ANNOTATION
172
+ # ╔═══════════════════════════════════════════════════════════════════════╗
173
+ # ║ Lib: #{class_name.ljust(65)}║
174
+ # ║ Parent: #{parent_label.ljust(61)}║
175
+ # ║ Entry Points: #{entry_points.join(', ').ljust(55)}║
176
+ # ╚═══════════════════════════════════════════════════════════════════════╝
177
+
178
+ ANNOTATION
179
+
180
+ annotation + source
181
+ end
182
+
183
+ # ──────────────────────────────────────────────────────────────────────
184
+ # Metadata Extraction
185
+ # ──────────────────────────────────────────────────────────────────────
186
+
187
+ # Build the metadata hash for a lib unit.
188
+ #
189
+ # @param source [String] Ruby source code
190
+ # @param class_name [String] The inferred constant name
191
+ # @return [Hash] Lib unit metadata
192
+ def extract_metadata(source, _class_name)
193
+ {
194
+ public_methods: extract_public_methods(source),
195
+ class_methods: extract_class_methods(source),
196
+ initialize_params: extract_initialize_params(source),
197
+ parent_class: extract_parent_class(source),
198
+ loc: count_loc(source),
199
+ method_count: source.scan(/def\s+(?:self\.)?\w+/).size,
200
+ entry_points: detect_entry_points(source)
201
+ }
202
+ end
203
+
204
+ # ──────────────────────────────────────────────────────────────────────
205
+ # Dependency Extraction
206
+ # ──────────────────────────────────────────────────────────────────────
207
+
208
+ # Build the dependency array using common dependency scanners.
209
+ #
210
+ # @param source [String] Ruby source code
211
+ # @return [Array<Hash>] Dependency hashes with :type, :target, :via
212
+ def extract_dependencies(source)
213
+ deps = scan_common_dependencies(source)
214
+ deps.uniq { |d| [d[:type], d[:target]] }
215
+ end
216
+ end
217
+ end
218
+ end
@@ -0,0 +1,269 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'digest'
4
+ require_relative 'ast_source_extraction'
5
+ require_relative 'shared_utility_methods'
6
+ require_relative 'shared_dependency_scanner'
7
+
8
+ module Woods
9
+ module Extractors
10
+ # MailerExtractor handles ActionMailer extraction.
11
+ #
12
+ # Mailers are important for understanding:
13
+ # - What triggers emails (traced via dependencies)
14
+ # - What data flows into emails
15
+ # - Template associations
16
+ #
17
+ # @example
18
+ # extractor = MailerExtractor.new
19
+ # units = extractor.extract_all
20
+ # user_mailer = units.find { |u| u.identifier == "UserMailer" }
21
+ #
22
+ class MailerExtractor
23
+ include AstSourceExtraction
24
+ include SharedUtilityMethods
25
+ include SharedDependencyScanner
26
+
27
+ def initialize
28
+ @mailer_base = defined?(ApplicationMailer) ? ApplicationMailer : ActionMailer::Base
29
+ end
30
+
31
+ # Extract all mailers in the application
32
+ #
33
+ # @return [Array<ExtractedUnit>] List of mailer units
34
+ def extract_all
35
+ @mailer_base.descendants.map do |mailer|
36
+ extract_mailer(mailer)
37
+ end.compact
38
+ end
39
+
40
+ # Extract a single mailer
41
+ #
42
+ # @param mailer [Class] The mailer class
43
+ # @return [ExtractedUnit] The extracted unit
44
+ def extract_mailer(mailer)
45
+ return nil if mailer.name.nil?
46
+ return nil if mailer == ActionMailer::Base
47
+
48
+ file_path = source_file_for(mailer)
49
+
50
+ unit = ExtractedUnit.new(
51
+ type: :mailer,
52
+ identifier: mailer.name,
53
+ file_path: file_path
54
+ )
55
+
56
+ source = file_path && File.exist?(file_path) ? File.read(file_path) : ''
57
+
58
+ unit.namespace = extract_namespace(mailer)
59
+ unit.source_code = annotate_source(source, mailer)
60
+ unit.metadata = extract_metadata(mailer, source)
61
+ unit.dependencies = extract_dependencies(source)
62
+
63
+ # Create chunks for each mail action
64
+ unit.chunks = build_action_chunks(mailer, source)
65
+
66
+ unit
67
+ rescue StandardError => e
68
+ Rails.logger.error("Failed to extract mailer #{mailer.name}: #{e.message}")
69
+ nil
70
+ end
71
+
72
+ private
73
+
74
+ # Locate the source file for a mailer class.
75
+ #
76
+ # Convention path first, then introspection via {#resolve_source_location}
77
+ # which filters out vendor/node_modules paths.
78
+ #
79
+ # @param mailer [Class]
80
+ # @return [String]
81
+ def source_file_for(mailer)
82
+ convention_path = Rails.root.join("app/mailers/#{mailer.name.underscore}.rb").to_s
83
+ return convention_path if File.exist?(convention_path)
84
+
85
+ resolve_source_location(mailer, app_root: Rails.root.to_s, fallback: convention_path)
86
+ end
87
+
88
+ # ──────────────────────────────────────────────────────────────────────
89
+ # Source Annotation
90
+ # ──────────────────────────────────────────────────────────────────────
91
+
92
+ def annotate_source(source, mailer)
93
+ actions = mailer.action_methods.to_a
94
+ default_from = begin
95
+ mailer.default[:from]
96
+ rescue StandardError
97
+ nil
98
+ end
99
+
100
+ <<~ANNOTATION
101
+ # ╔═══════════════════════════════════════════════════════════════════════╗
102
+ # ║ Mailer: #{mailer.name.ljust(60)}║
103
+ # ║ Actions: #{actions.first(5).join(', ').ljust(59)}║
104
+ # ║ Default From: #{(default_from || 'not set').to_s.ljust(54)}║
105
+ # ╚═══════════════════════════════════════════════════════════════════════╝
106
+
107
+ #{source}
108
+ ANNOTATION
109
+ end
110
+
111
+ # ──────────────────────────────────────────────────────────────────────
112
+ # Metadata Extraction
113
+ # ──────────────────────────────────────────────────────────────────────
114
+
115
+ def extract_metadata(mailer, source)
116
+ actions = mailer.action_methods.to_a
117
+
118
+ {
119
+ # Actions (mail methods)
120
+ actions: actions,
121
+
122
+ # Default settings
123
+ defaults: extract_defaults(mailer),
124
+
125
+ # Delivery configuration
126
+ delivery_method: mailer.delivery_method,
127
+
128
+ # Callbacks
129
+ callbacks: extract_callbacks(mailer),
130
+
131
+ # Layout
132
+ layout: extract_layout(mailer, source),
133
+
134
+ # Helper modules
135
+ helpers: extract_helpers(source),
136
+
137
+ # Templates (if discoverable)
138
+ templates: discover_templates(mailer, actions),
139
+
140
+ # Metrics
141
+ action_count: actions.size,
142
+ loc: source.lines.count { |l| l.strip.present? && !l.strip.start_with?('#') }
143
+ }
144
+ end
145
+
146
+ def extract_defaults(mailer)
147
+ mailer_defaults = mailer.default
148
+ mailer_defaults.slice(:from, :reply_to, :cc, :bcc).compact
149
+ rescue StandardError
150
+ {}
151
+ end
152
+
153
+ def extract_callbacks(mailer)
154
+ mailer._process_action_callbacks.map do |cb|
155
+ only, except, if_conds, unless_conds = extract_callback_conditions(cb)
156
+
157
+ result = {
158
+ type: :"#{cb.kind}_action",
159
+ filter: cb.filter.to_s
160
+ }
161
+ result[:only] = only if only.any?
162
+ result[:except] = except if except.any?
163
+ result[:if] = if_conds.join(', ') if if_conds.any?
164
+ result[:unless] = unless_conds.join(', ') if unless_conds.any?
165
+ result
166
+ end
167
+ rescue StandardError
168
+ []
169
+ end
170
+
171
+ def extract_layout(mailer, source)
172
+ # From class definition
173
+ return ::Regexp.last_match(1) if source =~ /layout\s+['":](\w+)/
174
+
175
+ # From class method
176
+ begin
177
+ mailer._layout
178
+ rescue StandardError
179
+ nil
180
+ end
181
+ end
182
+
183
+ def extract_helpers(source)
184
+ helpers = []
185
+
186
+ source.scan(/helper\s+[:\s]?(\w+)/) do |helper|
187
+ helpers << helper[0]
188
+ end
189
+
190
+ source.scan(/include\s+(\w+Helper)/) do |helper|
191
+ helpers << helper[0]
192
+ end
193
+
194
+ helpers.uniq
195
+ end
196
+
197
+ def discover_templates(mailer, actions)
198
+ templates = {}
199
+ mailer_path = mailer.name.underscore
200
+
201
+ actions.each do |action|
202
+ view_paths = [
203
+ Rails.root.join("app/views/#{mailer_path}/#{action}.html.erb"),
204
+ Rails.root.join("app/views/#{mailer_path}/#{action}.text.erb"),
205
+ Rails.root.join("app/views/#{mailer_path}/#{action}.html.slim"),
206
+ Rails.root.join("app/views/#{mailer_path}/#{action}.text.slim")
207
+ ]
208
+
209
+ found = view_paths.select { |p| File.exist?(p) }
210
+ .map { |p| p.to_s.sub("#{Rails.root}/", '') }
211
+
212
+ templates[action] = found if found.any?
213
+ end
214
+
215
+ templates
216
+ end
217
+
218
+ # ──────────────────────────────────────────────────────────────────────
219
+ # Dependency Extraction
220
+ # ──────────────────────────────────────────────────────────────────────
221
+
222
+ def extract_dependencies(source)
223
+ deps = []
224
+ deps.concat(scan_model_dependencies(source))
225
+ deps.concat(scan_service_dependencies(source))
226
+
227
+ # URL helpers (indicates what resources emails link to)
228
+ source.scan(/(\w+)_(?:url|path)/).flatten.uniq.each do |route|
229
+ deps << { type: :route, target: route, via: :url_helper }
230
+ end
231
+
232
+ deps.uniq { |d| [d[:type], d[:target]] }
233
+ end
234
+
235
+ # ──────────────────────────────────────────────────────────────────────
236
+ # Action Chunks
237
+ # ──────────────────────────────────────────────────────────────────────
238
+
239
+ def build_action_chunks(mailer, _source)
240
+ mailer.action_methods.filter_map do |action|
241
+ action_source = extract_action_source(mailer, action)
242
+ next if action_source.nil? || action_source.strip.empty?
243
+
244
+ templates = discover_templates(mailer, [action.to_s])[action.to_s] || []
245
+
246
+ chunk_content = <<~ACTION
247
+ # Mailer: #{mailer.name}
248
+ # Action: #{action}
249
+ # Templates: #{templates.any? ? templates.join(', ') : 'none found'}
250
+
251
+ #{action_source}
252
+ ACTION
253
+
254
+ {
255
+ chunk_type: :mail_action,
256
+ identifier: "#{mailer.name}##{action}",
257
+ content: chunk_content,
258
+ content_hash: Digest::SHA256.hexdigest(chunk_content),
259
+ metadata: {
260
+ parent: mailer.name,
261
+ action: action.to_s,
262
+ templates: templates
263
+ }
264
+ }
265
+ end
266
+ end
267
+ end
268
+ end
269
+ end
@@ -0,0 +1,188 @@
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
+ # ManagerExtractor handles SimpleDelegator subclass extraction.
9
+ #
10
+ # Manager/delegator objects wrap a model and provide a richer interface
11
+ # for specific contexts (e.g., OrderManager wrapping Order with
12
+ # checkout-specific methods). They live in `app/managers/`.
13
+ #
14
+ # We extract:
15
+ # - Wrapped model (via SimpleDelegator superclass or initializer)
16
+ # - Public methods (the manager's added interface)
17
+ # - Delegation chain (what gets delegated vs overridden)
18
+ # - Dependencies (what models/services they reference)
19
+ #
20
+ # @example
21
+ # extractor = ManagerExtractor.new
22
+ # units = extractor.extract_all
23
+ # order_mgr = units.find { |u| u.identifier == "OrderManager" }
24
+ #
25
+ class ManagerExtractor
26
+ include SharedUtilityMethods
27
+ include SharedDependencyScanner
28
+
29
+ # Directories to scan for manager/delegator objects
30
+ MANAGER_DIRECTORIES = %w[
31
+ app/managers
32
+ ].freeze
33
+
34
+ def initialize
35
+ @directories = MANAGER_DIRECTORIES.map { |d| Rails.root.join(d) }
36
+ .select(&:directory?)
37
+ end
38
+
39
+ # Extract all manager/delegator objects
40
+ #
41
+ # @return [Array<ExtractedUnit>] List of manager units
42
+ def extract_all
43
+ @directories.flat_map do |dir|
44
+ Dir[dir.join('**/*.rb')].filter_map do |file|
45
+ extract_manager_file(file)
46
+ end
47
+ end
48
+ end
49
+
50
+ # Extract a single manager file
51
+ #
52
+ # @param file_path [String] Path to the manager file
53
+ # @return [ExtractedUnit, nil] The extracted unit or nil if not a manager
54
+ def extract_manager_file(file_path)
55
+ source = File.read(file_path)
56
+ class_name = extract_class_name(file_path, source, 'managers')
57
+
58
+ return nil unless class_name
59
+ return nil unless manager_file?(source)
60
+
61
+ unit = ExtractedUnit.new(
62
+ type: :manager,
63
+ identifier: class_name,
64
+ file_path: file_path
65
+ )
66
+
67
+ unit.namespace = extract_namespace(class_name)
68
+ unit.source_code = annotate_source(source, class_name)
69
+ unit.metadata = extract_metadata(source, class_name)
70
+ unit.dependencies = extract_dependencies(source, class_name)
71
+
72
+ unit
73
+ rescue StandardError => e
74
+ Rails.logger.error("Failed to extract manager #{file_path}: #{e.message}")
75
+ nil
76
+ end
77
+
78
+ private
79
+
80
+ # ──────────────────────────────────────────────────────────────────────
81
+ # Class Discovery
82
+ # ──────────────────────────────────────────────────────────────────────
83
+
84
+ def manager_file?(source)
85
+ source.match?(/< SimpleDelegator/) ||
86
+ source.match?(/< DelegateClass\(/) ||
87
+ source.match?(/include Delegator/)
88
+ end
89
+
90
+ # ──────────────────────────────────────────────────────────────────────
91
+ # Source Annotation
92
+ # ──────────────────────────────────────────────────────────────────────
93
+
94
+ def annotate_source(source, class_name)
95
+ wrapped = detect_wrapped_model(source, class_name)
96
+
97
+ <<~ANNOTATION
98
+ # ╔═══════════════════════════════════════════════════════════════════════╗
99
+ # ║ Manager: #{class_name.ljust(60)}║
100
+ # ║ Wraps: #{(wrapped || 'unknown').ljust(61)}║
101
+ # ╚═══════════════════════════════════════════════════════════════════════╝
102
+
103
+ #{source}
104
+ ANNOTATION
105
+ end
106
+
107
+ # ──────────────────────────────────────────────────────────────────────
108
+ # Metadata Extraction
109
+ # ──────────────────────────────────────────────────────────────────────
110
+
111
+ def extract_metadata(source, class_name)
112
+ {
113
+ wrapped_model: detect_wrapped_model(source, class_name),
114
+ delegation_type: detect_delegation_type(source),
115
+ public_methods: extract_public_methods(source),
116
+ class_methods: extract_class_methods(source),
117
+ initialize_params: extract_initialize_params(source),
118
+ delegated_methods: extract_delegated_methods(source),
119
+ overridden_methods: extract_overridden_methods(source),
120
+ custom_errors: extract_custom_errors(source),
121
+ loc: source.lines.count { |l| l.strip.length.positive? && !l.strip.start_with?('#') },
122
+ method_count: source.scan(/def\s+(?:self\.)?\w+/).size
123
+ }
124
+ end
125
+
126
+ def detect_wrapped_model(source, class_name)
127
+ # DelegateClass(ModelName) pattern
128
+ return ::Regexp.last_match(1) if source =~ /< DelegateClass\((\w+)\)/
129
+
130
+ # super(model) in initialize
131
+ return ::Regexp.last_match(1).capitalize if source =~ /super\((\w+)\)/
132
+
133
+ # @model = model; super(model) — look for param name
134
+ if source =~ /def\s+initialize\s*\((\w+)/
135
+ param = ::Regexp.last_match(1)
136
+ return param.capitalize unless %w[args options params attributes].include?(param)
137
+ end
138
+
139
+ # Infer from class name: OrderManager -> Order
140
+ stripped = class_name.split('::').last
141
+ inferred = stripped.sub(/Manager\z/, '')
142
+ # Return nil if no suffix was removed (not a FooManager pattern)
143
+ return nil if inferred == stripped || inferred.empty?
144
+
145
+ inferred
146
+ end
147
+
148
+ def detect_delegation_type(source)
149
+ return :delegate_class if source.match?(/< DelegateClass\(/)
150
+ return :simple_delegator if source.match?(/< SimpleDelegator/)
151
+
152
+ :unknown
153
+ end
154
+
155
+ def extract_delegated_methods(source)
156
+ methods = []
157
+
158
+ # delegate :foo, :bar, to: :something
159
+ source.scan(/delegate\s+(.+?)(?:,\s*to:)/) do |match|
160
+ match[0].scan(/:(\w+)/).flatten.each { |m| methods << m }
161
+ end
162
+
163
+ methods
164
+ end
165
+
166
+ def extract_overridden_methods(source)
167
+ # Methods that call super — these override delegated behavior
168
+ source.scan(/def\s+(\w+[?!=]?).*?\n.*?super/m).flatten
169
+ end
170
+
171
+ # ──────────────────────────────────────────────────────────────────────
172
+ # Dependency Extraction
173
+ # ──────────────────────────────────────────────────────────────────────
174
+
175
+ def extract_dependencies(source, class_name)
176
+ deps = []
177
+
178
+ # Wrapped model dependency (specific :via)
179
+ wrapped = detect_wrapped_model(source, class_name)
180
+ deps << { type: :model, target: wrapped, via: :delegation } if wrapped
181
+
182
+ deps.concat(scan_common_dependencies(source))
183
+
184
+ deps.uniq { |d| [d[:type], d[:target]] }
185
+ end
186
+ end
187
+ end
188
+ end