codebase_index 0.1.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 (171) hide show
  1. checksums.yaml +7 -0
  2. data/CHANGELOG.md +29 -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 +481 -0
  7. data/exe/codebase-console-mcp +22 -0
  8. data/exe/codebase-index-mcp +61 -0
  9. data/exe/codebase-index-mcp-http +64 -0
  10. data/exe/codebase-index-mcp-start +58 -0
  11. data/lib/codebase_index/ast/call_site_extractor.rb +106 -0
  12. data/lib/codebase_index/ast/method_extractor.rb +76 -0
  13. data/lib/codebase_index/ast/node.rb +88 -0
  14. data/lib/codebase_index/ast/parser.rb +653 -0
  15. data/lib/codebase_index/ast.rb +6 -0
  16. data/lib/codebase_index/builder.rb +137 -0
  17. data/lib/codebase_index/chunking/chunk.rb +84 -0
  18. data/lib/codebase_index/chunking/semantic_chunker.rb +290 -0
  19. data/lib/codebase_index/console/adapters/cache_adapter.rb +58 -0
  20. data/lib/codebase_index/console/adapters/good_job_adapter.rb +66 -0
  21. data/lib/codebase_index/console/adapters/sidekiq_adapter.rb +66 -0
  22. data/lib/codebase_index/console/adapters/solid_queue_adapter.rb +66 -0
  23. data/lib/codebase_index/console/audit_logger.rb +75 -0
  24. data/lib/codebase_index/console/bridge.rb +170 -0
  25. data/lib/codebase_index/console/confirmation.rb +90 -0
  26. data/lib/codebase_index/console/connection_manager.rb +173 -0
  27. data/lib/codebase_index/console/console_response_renderer.rb +78 -0
  28. data/lib/codebase_index/console/model_validator.rb +81 -0
  29. data/lib/codebase_index/console/safe_context.rb +82 -0
  30. data/lib/codebase_index/console/server.rb +557 -0
  31. data/lib/codebase_index/console/sql_validator.rb +172 -0
  32. data/lib/codebase_index/console/tools/tier1.rb +118 -0
  33. data/lib/codebase_index/console/tools/tier2.rb +117 -0
  34. data/lib/codebase_index/console/tools/tier3.rb +110 -0
  35. data/lib/codebase_index/console/tools/tier4.rb +79 -0
  36. data/lib/codebase_index/coordination/pipeline_lock.rb +109 -0
  37. data/lib/codebase_index/cost_model/embedding_cost.rb +88 -0
  38. data/lib/codebase_index/cost_model/estimator.rb +128 -0
  39. data/lib/codebase_index/cost_model/provider_pricing.rb +67 -0
  40. data/lib/codebase_index/cost_model/storage_cost.rb +52 -0
  41. data/lib/codebase_index/cost_model.rb +22 -0
  42. data/lib/codebase_index/db/migrations/001_create_units.rb +38 -0
  43. data/lib/codebase_index/db/migrations/002_create_edges.rb +35 -0
  44. data/lib/codebase_index/db/migrations/003_create_embeddings.rb +37 -0
  45. data/lib/codebase_index/db/migrations/004_create_snapshots.rb +45 -0
  46. data/lib/codebase_index/db/migrations/005_create_snapshot_units.rb +40 -0
  47. data/lib/codebase_index/db/migrator.rb +71 -0
  48. data/lib/codebase_index/db/schema_version.rb +73 -0
  49. data/lib/codebase_index/dependency_graph.rb +227 -0
  50. data/lib/codebase_index/embedding/indexer.rb +130 -0
  51. data/lib/codebase_index/embedding/openai.rb +105 -0
  52. data/lib/codebase_index/embedding/provider.rb +135 -0
  53. data/lib/codebase_index/embedding/text_preparer.rb +112 -0
  54. data/lib/codebase_index/evaluation/baseline_runner.rb +115 -0
  55. data/lib/codebase_index/evaluation/evaluator.rb +146 -0
  56. data/lib/codebase_index/evaluation/metrics.rb +79 -0
  57. data/lib/codebase_index/evaluation/query_set.rb +148 -0
  58. data/lib/codebase_index/evaluation/report_generator.rb +90 -0
  59. data/lib/codebase_index/extracted_unit.rb +145 -0
  60. data/lib/codebase_index/extractor.rb +956 -0
  61. data/lib/codebase_index/extractors/action_cable_extractor.rb +228 -0
  62. data/lib/codebase_index/extractors/ast_source_extraction.rb +46 -0
  63. data/lib/codebase_index/extractors/behavioral_profile.rb +309 -0
  64. data/lib/codebase_index/extractors/caching_extractor.rb +261 -0
  65. data/lib/codebase_index/extractors/callback_analyzer.rb +232 -0
  66. data/lib/codebase_index/extractors/concern_extractor.rb +253 -0
  67. data/lib/codebase_index/extractors/configuration_extractor.rb +219 -0
  68. data/lib/codebase_index/extractors/controller_extractor.rb +494 -0
  69. data/lib/codebase_index/extractors/database_view_extractor.rb +278 -0
  70. data/lib/codebase_index/extractors/decorator_extractor.rb +260 -0
  71. data/lib/codebase_index/extractors/engine_extractor.rb +204 -0
  72. data/lib/codebase_index/extractors/event_extractor.rb +211 -0
  73. data/lib/codebase_index/extractors/factory_extractor.rb +289 -0
  74. data/lib/codebase_index/extractors/graphql_extractor.rb +917 -0
  75. data/lib/codebase_index/extractors/i18n_extractor.rb +117 -0
  76. data/lib/codebase_index/extractors/job_extractor.rb +369 -0
  77. data/lib/codebase_index/extractors/lib_extractor.rb +249 -0
  78. data/lib/codebase_index/extractors/mailer_extractor.rb +339 -0
  79. data/lib/codebase_index/extractors/manager_extractor.rb +202 -0
  80. data/lib/codebase_index/extractors/middleware_extractor.rb +133 -0
  81. data/lib/codebase_index/extractors/migration_extractor.rb +469 -0
  82. data/lib/codebase_index/extractors/model_extractor.rb +960 -0
  83. data/lib/codebase_index/extractors/phlex_extractor.rb +252 -0
  84. data/lib/codebase_index/extractors/policy_extractor.rb +214 -0
  85. data/lib/codebase_index/extractors/poro_extractor.rb +246 -0
  86. data/lib/codebase_index/extractors/pundit_extractor.rb +223 -0
  87. data/lib/codebase_index/extractors/rails_source_extractor.rb +473 -0
  88. data/lib/codebase_index/extractors/rake_task_extractor.rb +343 -0
  89. data/lib/codebase_index/extractors/route_extractor.rb +181 -0
  90. data/lib/codebase_index/extractors/scheduled_job_extractor.rb +331 -0
  91. data/lib/codebase_index/extractors/serializer_extractor.rb +334 -0
  92. data/lib/codebase_index/extractors/service_extractor.rb +254 -0
  93. data/lib/codebase_index/extractors/shared_dependency_scanner.rb +91 -0
  94. data/lib/codebase_index/extractors/shared_utility_methods.rb +99 -0
  95. data/lib/codebase_index/extractors/state_machine_extractor.rb +398 -0
  96. data/lib/codebase_index/extractors/test_mapping_extractor.rb +225 -0
  97. data/lib/codebase_index/extractors/validator_extractor.rb +225 -0
  98. data/lib/codebase_index/extractors/view_component_extractor.rb +310 -0
  99. data/lib/codebase_index/extractors/view_template_extractor.rb +261 -0
  100. data/lib/codebase_index/feedback/gap_detector.rb +89 -0
  101. data/lib/codebase_index/feedback/store.rb +119 -0
  102. data/lib/codebase_index/flow_analysis/operation_extractor.rb +209 -0
  103. data/lib/codebase_index/flow_analysis/response_code_mapper.rb +154 -0
  104. data/lib/codebase_index/flow_assembler.rb +290 -0
  105. data/lib/codebase_index/flow_document.rb +191 -0
  106. data/lib/codebase_index/flow_precomputer.rb +102 -0
  107. data/lib/codebase_index/formatting/base.rb +40 -0
  108. data/lib/codebase_index/formatting/claude_adapter.rb +98 -0
  109. data/lib/codebase_index/formatting/generic_adapter.rb +56 -0
  110. data/lib/codebase_index/formatting/gpt_adapter.rb +64 -0
  111. data/lib/codebase_index/formatting/human_adapter.rb +78 -0
  112. data/lib/codebase_index/graph_analyzer.rb +374 -0
  113. data/lib/codebase_index/mcp/index_reader.rb +394 -0
  114. data/lib/codebase_index/mcp/renderers/claude_renderer.rb +81 -0
  115. data/lib/codebase_index/mcp/renderers/json_renderer.rb +17 -0
  116. data/lib/codebase_index/mcp/renderers/markdown_renderer.rb +352 -0
  117. data/lib/codebase_index/mcp/renderers/plain_renderer.rb +240 -0
  118. data/lib/codebase_index/mcp/server.rb +935 -0
  119. data/lib/codebase_index/mcp/tool_response_renderer.rb +62 -0
  120. data/lib/codebase_index/model_name_cache.rb +51 -0
  121. data/lib/codebase_index/notion/client.rb +217 -0
  122. data/lib/codebase_index/notion/exporter.rb +219 -0
  123. data/lib/codebase_index/notion/mapper.rb +39 -0
  124. data/lib/codebase_index/notion/mappers/column_mapper.rb +65 -0
  125. data/lib/codebase_index/notion/mappers/migration_mapper.rb +39 -0
  126. data/lib/codebase_index/notion/mappers/model_mapper.rb +164 -0
  127. data/lib/codebase_index/notion/rate_limiter.rb +68 -0
  128. data/lib/codebase_index/observability/health_check.rb +81 -0
  129. data/lib/codebase_index/observability/instrumentation.rb +34 -0
  130. data/lib/codebase_index/observability/structured_logger.rb +75 -0
  131. data/lib/codebase_index/operator/error_escalator.rb +81 -0
  132. data/lib/codebase_index/operator/pipeline_guard.rb +99 -0
  133. data/lib/codebase_index/operator/status_reporter.rb +80 -0
  134. data/lib/codebase_index/railtie.rb +26 -0
  135. data/lib/codebase_index/resilience/circuit_breaker.rb +99 -0
  136. data/lib/codebase_index/resilience/index_validator.rb +185 -0
  137. data/lib/codebase_index/resilience/retryable_provider.rb +108 -0
  138. data/lib/codebase_index/retrieval/context_assembler.rb +249 -0
  139. data/lib/codebase_index/retrieval/query_classifier.rb +131 -0
  140. data/lib/codebase_index/retrieval/ranker.rb +273 -0
  141. data/lib/codebase_index/retrieval/search_executor.rb +327 -0
  142. data/lib/codebase_index/retriever.rb +160 -0
  143. data/lib/codebase_index/ruby_analyzer/class_analyzer.rb +190 -0
  144. data/lib/codebase_index/ruby_analyzer/dataflow_analyzer.rb +78 -0
  145. data/lib/codebase_index/ruby_analyzer/fqn_builder.rb +18 -0
  146. data/lib/codebase_index/ruby_analyzer/mermaid_renderer.rb +275 -0
  147. data/lib/codebase_index/ruby_analyzer/method_analyzer.rb +143 -0
  148. data/lib/codebase_index/ruby_analyzer/trace_enricher.rb +139 -0
  149. data/lib/codebase_index/ruby_analyzer.rb +87 -0
  150. data/lib/codebase_index/session_tracer/file_store.rb +111 -0
  151. data/lib/codebase_index/session_tracer/middleware.rb +143 -0
  152. data/lib/codebase_index/session_tracer/redis_store.rb +112 -0
  153. data/lib/codebase_index/session_tracer/session_flow_assembler.rb +263 -0
  154. data/lib/codebase_index/session_tracer/session_flow_document.rb +223 -0
  155. data/lib/codebase_index/session_tracer/solid_cache_store.rb +145 -0
  156. data/lib/codebase_index/session_tracer/store.rb +67 -0
  157. data/lib/codebase_index/storage/graph_store.rb +120 -0
  158. data/lib/codebase_index/storage/metadata_store.rb +169 -0
  159. data/lib/codebase_index/storage/pgvector.rb +163 -0
  160. data/lib/codebase_index/storage/qdrant.rb +172 -0
  161. data/lib/codebase_index/storage/vector_store.rb +156 -0
  162. data/lib/codebase_index/temporal/snapshot_store.rb +341 -0
  163. data/lib/codebase_index/version.rb +5 -0
  164. data/lib/codebase_index.rb +223 -0
  165. data/lib/generators/codebase_index/install_generator.rb +32 -0
  166. data/lib/generators/codebase_index/pgvector_generator.rb +37 -0
  167. data/lib/generators/codebase_index/templates/add_pgvector_to_codebase_index.rb.erb +15 -0
  168. data/lib/generators/codebase_index/templates/create_codebase_index_tables.rb.erb +43 -0
  169. data/lib/tasks/codebase_index.rake +583 -0
  170. data/lib/tasks/codebase_index_evaluation.rake +115 -0
  171. metadata +252 -0
@@ -0,0 +1,249 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative 'shared_utility_methods'
4
+ require_relative 'shared_dependency_scanner'
5
+
6
+ module CodebaseIndex
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
+ # Extract the parent class name from a class definition.
205
+ #
206
+ # @param source [String] Ruby source code
207
+ # @return [String, nil] Parent class name or nil
208
+ def extract_parent_class(source)
209
+ match = source.match(/^\s*class\s+[\w:]+\s*<\s*([\w:]+)/)
210
+ match ? match[1] : nil
211
+ end
212
+
213
+ # Count non-blank, non-comment lines of code.
214
+ #
215
+ # @param source [String] Ruby source code
216
+ # @return [Integer] LOC count
217
+ def count_loc(source)
218
+ source.lines.count { |l| l.strip.length.positive? && !l.strip.start_with?('#') }
219
+ end
220
+
221
+ # Detect common entry point methods.
222
+ #
223
+ # @param source [String] Ruby source code
224
+ # @return [Array<String>] Entry point method names
225
+ def detect_entry_points(source)
226
+ points = []
227
+ points << 'call' if source.match?(/def (self\.)?call\b/)
228
+ points << 'perform' if source.match?(/def (self\.)?perform\b/)
229
+ points << 'execute' if source.match?(/def (self\.)?execute\b/)
230
+ points << 'run' if source.match?(/def (self\.)?run\b/)
231
+ points << 'process' if source.match?(/def (self\.)?process\b/)
232
+ points.empty? ? ['unknown'] : points
233
+ end
234
+
235
+ # ──────────────────────────────────────────────────────────────────────
236
+ # Dependency Extraction
237
+ # ──────────────────────────────────────────────────────────────────────
238
+
239
+ # Build the dependency array using common dependency scanners.
240
+ #
241
+ # @param source [String] Ruby source code
242
+ # @return [Array<Hash>] Dependency hashes with :type, :target, :via
243
+ def extract_dependencies(source)
244
+ deps = scan_common_dependencies(source)
245
+ deps.uniq { |d| [d[:type], d[:target]] }
246
+ end
247
+ end
248
+ end
249
+ end
@@ -0,0 +1,339 @@
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 CodebaseIndex
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
+ def source_file_for(mailer)
75
+ if mailer.instance_methods(false).any?
76
+ method = mailer.instance_methods(false).first
77
+ mailer.instance_method(method).source_location&.first
78
+ end || Rails.root.join("app/mailers/#{mailer.name.underscore}.rb").to_s
79
+ rescue StandardError
80
+ Rails.root.join("app/mailers/#{mailer.name.underscore}.rb").to_s
81
+ end
82
+
83
+ # ──────────────────────────────────────────────────────────────────────
84
+ # Source Annotation
85
+ # ──────────────────────────────────────────────────────────────────────
86
+
87
+ def annotate_source(source, mailer)
88
+ actions = mailer.action_methods.to_a
89
+ default_from = begin
90
+ mailer.default[:from]
91
+ rescue StandardError
92
+ nil
93
+ end
94
+
95
+ <<~ANNOTATION
96
+ # ╔═══════════════════════════════════════════════════════════════════════╗
97
+ # ║ Mailer: #{mailer.name.ljust(60)}║
98
+ # ║ Actions: #{actions.first(5).join(', ').ljust(59)}║
99
+ # ║ Default From: #{(default_from || 'not set').to_s.ljust(54)}║
100
+ # ╚═══════════════════════════════════════════════════════════════════════╝
101
+
102
+ #{source}
103
+ ANNOTATION
104
+ end
105
+
106
+ # ──────────────────────────────────────────────────────────────────────
107
+ # Metadata Extraction
108
+ # ──────────────────────────────────────────────────────────────────────
109
+
110
+ def extract_metadata(mailer, source)
111
+ actions = mailer.action_methods.to_a
112
+
113
+ {
114
+ # Actions (mail methods)
115
+ actions: actions,
116
+
117
+ # Default settings
118
+ defaults: extract_defaults(mailer),
119
+
120
+ # Delivery configuration
121
+ delivery_method: mailer.delivery_method,
122
+
123
+ # Callbacks
124
+ callbacks: extract_callbacks(mailer),
125
+
126
+ # Layout
127
+ layout: extract_layout(mailer, source),
128
+
129
+ # Helper modules
130
+ helpers: extract_helpers(source),
131
+
132
+ # Templates (if discoverable)
133
+ templates: discover_templates(mailer, actions),
134
+
135
+ # Metrics
136
+ action_count: actions.size,
137
+ loc: source.lines.count { |l| l.strip.present? && !l.strip.start_with?('#') }
138
+ }
139
+ end
140
+
141
+ def extract_defaults(mailer)
142
+ defaults = {}
143
+
144
+ begin
145
+ mailer_defaults = mailer.default
146
+ defaults[:from] = mailer_defaults[:from] if mailer_defaults[:from]
147
+ defaults[:reply_to] = mailer_defaults[:reply_to] if mailer_defaults[:reply_to]
148
+ defaults[:cc] = mailer_defaults[:cc] if mailer_defaults[:cc]
149
+ defaults[:bcc] = mailer_defaults[:bcc] if mailer_defaults[:bcc]
150
+ rescue StandardError
151
+ # Defaults not accessible
152
+ end
153
+
154
+ defaults
155
+ end
156
+
157
+ def extract_callbacks(mailer)
158
+ mailer._process_action_callbacks.map do |cb|
159
+ only, except, if_conds, unless_conds = extract_callback_conditions(cb)
160
+
161
+ result = {
162
+ type: :"#{cb.kind}_action",
163
+ filter: cb.filter.to_s
164
+ }
165
+ result[:only] = only if only.any?
166
+ result[:except] = except if except.any?
167
+ result[:if] = if_conds.join(', ') if if_conds.any?
168
+ result[:unless] = unless_conds.join(', ') if unless_conds.any?
169
+ result
170
+ end
171
+ rescue StandardError
172
+ []
173
+ end
174
+
175
+ # Extract :only/:except action lists and :if/:unless conditions from a callback.
176
+ #
177
+ # Modern Rails (4.2+) stores conditions in @if/@unless ivar arrays.
178
+ # ActionFilter objects hold action Sets; other conditions are procs/symbols.
179
+ #
180
+ # @param callback [ActiveSupport::Callbacks::Callback]
181
+ # @return [Array(Array<String>, Array<String>, Array<String>, Array<String>)]
182
+ # [only_actions, except_actions, if_labels, unless_labels]
183
+ def extract_callback_conditions(callback)
184
+ if_conditions = callback.instance_variable_get(:@if) || []
185
+ unless_conditions = callback.instance_variable_get(:@unless) || []
186
+
187
+ only = []
188
+ except = []
189
+ if_labels = []
190
+ unless_labels = []
191
+
192
+ if_conditions.each do |cond|
193
+ actions = extract_action_filter_actions(cond)
194
+ if actions
195
+ only.concat(actions)
196
+ else
197
+ if_labels << condition_label(cond)
198
+ end
199
+ end
200
+
201
+ unless_conditions.each do |cond|
202
+ actions = extract_action_filter_actions(cond)
203
+ if actions
204
+ except.concat(actions)
205
+ else
206
+ unless_labels << condition_label(cond)
207
+ end
208
+ end
209
+
210
+ [only, except, if_labels, unless_labels]
211
+ end
212
+
213
+ # Extract action names from an ActionFilter-like condition object.
214
+ # Duck-types on the @actions ivar being a Set, avoiding dependence
215
+ # on private class names across Rails versions.
216
+ #
217
+ # @param condition [Object] A condition from the callback's @if/@unless array
218
+ # @return [Array<String>, nil] Action names, or nil if not an ActionFilter
219
+ def extract_action_filter_actions(condition)
220
+ return nil unless condition.instance_variable_defined?(:@actions)
221
+
222
+ actions = condition.instance_variable_get(:@actions)
223
+ return nil unless actions.is_a?(Set)
224
+
225
+ actions.to_a
226
+ end
227
+
228
+ # Human-readable label for a non-ActionFilter condition.
229
+ #
230
+ # @param condition [Object] A proc, symbol, or other condition
231
+ # @return [String]
232
+ def condition_label(condition)
233
+ case condition
234
+ when Symbol then ":#{condition}"
235
+ when Proc then 'Proc'
236
+ when String then condition
237
+ else condition.class.name
238
+ end
239
+ end
240
+
241
+ def extract_layout(mailer, source)
242
+ # From class definition
243
+ return ::Regexp.last_match(1) if source =~ /layout\s+['":](\w+)/
244
+
245
+ # From class method
246
+ begin
247
+ mailer._layout
248
+ rescue StandardError
249
+ nil
250
+ end
251
+ end
252
+
253
+ def extract_helpers(source)
254
+ helpers = []
255
+
256
+ source.scan(/helper\s+[:\s]?(\w+)/) do |helper|
257
+ helpers << helper[0]
258
+ end
259
+
260
+ source.scan(/include\s+(\w+Helper)/) do |helper|
261
+ helpers << helper[0]
262
+ end
263
+
264
+ helpers.uniq
265
+ end
266
+
267
+ def discover_templates(mailer, actions)
268
+ templates = {}
269
+ mailer_path = mailer.name.underscore
270
+
271
+ actions.each do |action|
272
+ view_paths = [
273
+ Rails.root.join("app/views/#{mailer_path}/#{action}.html.erb"),
274
+ Rails.root.join("app/views/#{mailer_path}/#{action}.text.erb"),
275
+ Rails.root.join("app/views/#{mailer_path}/#{action}.html.slim"),
276
+ Rails.root.join("app/views/#{mailer_path}/#{action}.text.slim")
277
+ ]
278
+
279
+ found = view_paths.select { |p| File.exist?(p) }
280
+ .map { |p| p.to_s.sub("#{Rails.root}/", '') }
281
+
282
+ templates[action] = found if found.any?
283
+ end
284
+
285
+ templates
286
+ end
287
+
288
+ # ──────────────────────────────────────────────────────────────────────
289
+ # Dependency Extraction
290
+ # ──────────────────────────────────────────────────────────────────────
291
+
292
+ def extract_dependencies(source)
293
+ deps = []
294
+ deps.concat(scan_model_dependencies(source))
295
+ deps.concat(scan_service_dependencies(source))
296
+
297
+ # URL helpers (indicates what resources emails link to)
298
+ source.scan(/(\w+)_(?:url|path)/).flatten.uniq.each do |route|
299
+ deps << { type: :route, target: route, via: :url_helper }
300
+ end
301
+
302
+ deps.uniq { |d| [d[:type], d[:target]] }
303
+ end
304
+
305
+ # ──────────────────────────────────────────────────────────────────────
306
+ # Action Chunks
307
+ # ──────────────────────────────────────────────────────────────────────
308
+
309
+ def build_action_chunks(mailer, _source)
310
+ mailer.action_methods.filter_map do |action|
311
+ action_source = extract_action_source(mailer, action)
312
+ next if action_source.nil? || action_source.strip.empty?
313
+
314
+ templates = discover_templates(mailer, [action.to_s])[action.to_s] || []
315
+
316
+ chunk_content = <<~ACTION
317
+ # Mailer: #{mailer.name}
318
+ # Action: #{action}
319
+ # Templates: #{templates.any? ? templates.join(', ') : 'none found'}
320
+
321
+ #{action_source}
322
+ ACTION
323
+
324
+ {
325
+ chunk_type: :mail_action,
326
+ identifier: "#{mailer.name}##{action}",
327
+ content: chunk_content,
328
+ content_hash: Digest::SHA256.hexdigest(chunk_content),
329
+ metadata: {
330
+ parent: mailer.name,
331
+ action: action.to_s,
332
+ templates: templates
333
+ }
334
+ }
335
+ end
336
+ end
337
+ end
338
+ end
339
+ end