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,278 @@
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
+ # DatabaseViewExtractor handles SQL view file extraction.
9
+ #
10
+ # Scans `db/views/` for Scenic gem convention SQL files
11
+ # (e.g., `db/views/active_users_v01.sql`). Extracts one unit per
12
+ # view name using the latest version only, parsing basic SQL metadata
13
+ # (materialized flag, referenced tables, selected columns) via regex.
14
+ #
15
+ # @example
16
+ # extractor = DatabaseViewExtractor.new
17
+ # units = extractor.extract_all
18
+ # view = units.find { |u| u.identifier == "active_users" }
19
+ # view.metadata[:is_materialized] # => false
20
+ # view.metadata[:tables_referenced] # => ["users", "orders"]
21
+ #
22
+ class DatabaseViewExtractor
23
+ include SharedUtilityMethods
24
+ include SharedDependencyScanner
25
+
26
+ # Rails internal tables that should not generate model dependencies
27
+ INTERNAL_TABLES = %w[
28
+ schema_migrations
29
+ ar_internal_metadata
30
+ active_storage_blobs
31
+ active_storage_attachments
32
+ active_storage_variant_records
33
+ action_text_rich_texts
34
+ action_mailbox_inbound_emails
35
+ ].freeze
36
+
37
+ # SQL keywords that are not table names
38
+ SQL_KEYWORDS = %w[
39
+ select from where join inner outer left right full cross
40
+ on and or not in is null true false as with having group by
41
+ order limit offset union intersect except distinct all case when
42
+ then else end between like ilike similar to cast values lateral
43
+ returning exists any some
44
+ ].freeze
45
+
46
+ def initialize
47
+ @views_dir = Rails.root.join('db/views')
48
+ @has_directory = @views_dir.directory?
49
+ end
50
+
51
+ # Extract all database view units from db/views/.
52
+ #
53
+ # Only the latest version of each view is extracted.
54
+ #
55
+ # @return [Array<ExtractedUnit>] List of database view units
56
+ def extract_all
57
+ return [] unless @has_directory
58
+
59
+ latest_view_files.filter_map do |file|
60
+ extract_view_file(file)
61
+ end
62
+ end
63
+
64
+ # Extract a single SQL view file.
65
+ #
66
+ # @param file_path [String] Absolute path to the SQL file
67
+ # @return [ExtractedUnit, nil] The extracted unit or nil on failure
68
+ def extract_view_file(file_path)
69
+ source = File.read(file_path)
70
+ view_name = extract_view_name(file_path)
71
+ version = extract_version(file_path)
72
+
73
+ return nil unless view_name
74
+
75
+ unit = ExtractedUnit.new(
76
+ type: :database_view,
77
+ identifier: view_name,
78
+ file_path: file_path
79
+ )
80
+
81
+ unit.namespace = nil
82
+ unit.source_code = annotate_source(source, view_name, version)
83
+ unit.metadata = extract_metadata(source, view_name, version)
84
+ unit.dependencies = extract_dependencies(source, unit.metadata)
85
+
86
+ unit
87
+ rescue StandardError => e
88
+ Rails.logger.error("Failed to extract database view #{file_path}: #{e.message}")
89
+ nil
90
+ end
91
+
92
+ private
93
+
94
+ # ──────────────────────────────────────────────────────────────────────
95
+ # File Discovery
96
+ # ──────────────────────────────────────────────────────────────────────
97
+
98
+ # Return only the latest-version SQL file for each view name.
99
+ #
100
+ # Scenic filenames: <view_name>_v<NN>.sql (e.g., active_users_v02.sql)
101
+ # Groups by view name, picks the file with the highest version number.
102
+ #
103
+ # @return [Array<String>] Paths to latest-version files
104
+ def latest_view_files
105
+ all_files = Dir[@views_dir.join('*.sql')].select do |f|
106
+ File.basename(f).match?(/\A\w+_v\d+\.sql\z/)
107
+ end
108
+
109
+ grouped = all_files.group_by { |f| extract_view_name(f) }
110
+ grouped.values.map do |files|
111
+ files.max_by { |f| extract_version(f) }
112
+ end
113
+ end
114
+
115
+ # ──────────────────────────────────────────────────────────────────────
116
+ # Name and Version Parsing
117
+ # ──────────────────────────────────────────────────────────────────────
118
+
119
+ # Extract the view name (without version suffix) from the filename.
120
+ #
121
+ # @param file_path [String] Path to the SQL file
122
+ # @return [String, nil] The view name (e.g., "active_users") or nil
123
+ def extract_view_name(file_path)
124
+ basename = File.basename(file_path, '.sql')
125
+ match = basename.match(/\A(.+?)_v(\d+)\z/)
126
+ match ? match[1] : nil
127
+ end
128
+
129
+ # Extract the integer version number from the filename.
130
+ #
131
+ # @param file_path [String] Path to the SQL file
132
+ # @return [Integer] The version number (e.g., 1 for "_v01")
133
+ def extract_version(file_path)
134
+ basename = File.basename(file_path, '.sql')
135
+ match = basename.match(/_v(\d+)\z/)
136
+ match ? match[1].to_i : 0
137
+ end
138
+
139
+ # ──────────────────────────────────────────────────────────────────────
140
+ # Source Annotation
141
+ # ──────────────────────────────────────────────────────────────────────
142
+
143
+ # Prepend a summary annotation to the SQL source.
144
+ #
145
+ # @param source [String] SQL source
146
+ # @param view_name [String] The view name
147
+ # @param version [Integer] The version number
148
+ # @return [String] Annotated SQL
149
+ def annotate_source(source, view_name, version)
150
+ materialized = materialized_view?(source) ? 'YES' : 'NO'
151
+
152
+ annotation = <<~ANNOTATION
153
+ -- ╔═══════════════════════════════════════════════════════════════════════╗
154
+ -- ║ Database View: #{view_name.ljust(52)}║
155
+ -- ║ Version: #{version.to_s.ljust(59)}║
156
+ -- ║ Materialized: #{materialized.ljust(54)}║
157
+ -- ╚═══════════════════════════════════════════════════════════════════════╝
158
+
159
+ ANNOTATION
160
+
161
+ annotation + source
162
+ end
163
+
164
+ # ──────────────────────────────────────────────────────────────────────
165
+ # Metadata Extraction
166
+ # ──────────────────────────────────────────────────────────────────────
167
+
168
+ # Build the metadata hash for a database view unit.
169
+ #
170
+ # @param source [String] SQL source
171
+ # @param view_name [String] The view name
172
+ # @param version [Integer] The version number
173
+ # @return [Hash] View metadata
174
+ def extract_metadata(source, view_name, version)
175
+ {
176
+ view_name: view_name,
177
+ version: version,
178
+ is_materialized: materialized_view?(source),
179
+ tables_referenced: extract_referenced_tables(source),
180
+ columns_selected: extract_selected_columns(source),
181
+ loc: source.lines.count { |l| l.strip.length.positive? && !l.strip.start_with?('--') }
182
+ }
183
+ end
184
+
185
+ # Detect whether this is a materialized view.
186
+ #
187
+ # @param source [String] SQL source
188
+ # @return [Boolean]
189
+ def materialized_view?(source)
190
+ source.match?(/\bMATERIALIZED\b/i)
191
+ end
192
+
193
+ # Extract table names referenced in FROM and JOIN clauses.
194
+ #
195
+ # Uses a simple regex approach. Handles basic FROM/JOIN patterns
196
+ # and filters out SQL keywords and subqueries.
197
+ #
198
+ # @param source [String] SQL source
199
+ # @return [Array<String>] Deduplicated table names (lowercase)
200
+ def extract_referenced_tables(source)
201
+ tables = []
202
+
203
+ # FROM clause: FROM table_name [alias]
204
+ source.scan(/\bFROM\s+([a-zA-Z_][a-zA-Z0-9_]*)/i).flatten.each do |t|
205
+ tables << t.downcase unless sql_keyword?(t)
206
+ end
207
+
208
+ # JOIN clauses: [INNER|LEFT|RIGHT|...] JOIN table_name
209
+ source.scan(/\bJOIN\s+([a-zA-Z_][a-zA-Z0-9_]*)/i).flatten.each do |t|
210
+ tables << t.downcase unless sql_keyword?(t)
211
+ end
212
+
213
+ tables.uniq
214
+ end
215
+
216
+ # Extract column names from the SELECT clause.
217
+ #
218
+ # Handles simple column names and table.column patterns.
219
+ # Returns '*' for SELECT * queries.
220
+ #
221
+ # @param source [String] SQL source
222
+ # @return [Array<String>] Column names
223
+ def extract_selected_columns(source)
224
+ # Find the SELECT ... FROM block
225
+ select_match = source.match(/\bSELECT\s+(.+?)\s+FROM\b/im)
226
+ return [] unless select_match
227
+
228
+ select_clause = select_match[1].strip
229
+ return ['*'] if select_clause == '*'
230
+
231
+ # Split on commas, strip whitespace and aliases, handle table.column
232
+ select_clause.split(',').filter_map do |col|
233
+ col = col.strip
234
+ # Remove AS alias: "col AS alias" or "table.col alias" → take first token
235
+ col = col.split(/\s+AS\s+/i).first.strip
236
+ # For table.column, take the column part
237
+ col = col.split('.').last.strip
238
+ # Skip expressions, subqueries, and empty strings
239
+ next if col.empty? || col.include?('(') || col.include?(')')
240
+
241
+ col.delete('"').delete("'")
242
+ end.uniq
243
+ end
244
+
245
+ # Check if a token is a SQL keyword.
246
+ #
247
+ # @param token [String] The token to check
248
+ # @return [Boolean]
249
+ def sql_keyword?(token)
250
+ SQL_KEYWORDS.include?(token.downcase)
251
+ end
252
+
253
+ # ──────────────────────────────────────────────────────────────────────
254
+ # Dependency Extraction
255
+ # ──────────────────────────────────────────────────────────────────────
256
+
257
+ # Build the dependency array by linking referenced tables to models.
258
+ #
259
+ # Uses the same table → model classify pattern as MigrationExtractor.
260
+ #
261
+ # @param source [String] SQL source
262
+ # @param metadata [Hash] Extracted metadata
263
+ # @return [Array<Hash>] Dependency hashes with :type, :target, :via
264
+ def extract_dependencies(_source, metadata)
265
+ deps = []
266
+
267
+ metadata[:tables_referenced].each do |table|
268
+ next if INTERNAL_TABLES.include?(table)
269
+
270
+ model_name = table.classify
271
+ deps << { type: :model, target: model_name, via: :table_name }
272
+ end
273
+
274
+ deps.uniq { |d| [d[:type], d[:target]] }
275
+ end
276
+ end
277
+ end
278
+ end
@@ -0,0 +1,260 @@
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
+ # DecoratorExtractor handles decorator, presenter, and form object extraction.
9
+ #
10
+ # Scans conventional directories for view-layer wrapper objects:
11
+ # decorators (Draper-style or PORO), presenters, and form objects.
12
+ # Extracts the decorated model relationship, delegation chains, and
13
+ # whether the Draper gem is in use.
14
+ #
15
+ # @example
16
+ # extractor = DecoratorExtractor.new
17
+ # units = extractor.extract_all
18
+ # user_dec = units.find { |u| u.identifier == "UserDecorator" }
19
+ # user_dec.metadata[:decorated_model] # => "User"
20
+ # user_dec.metadata[:uses_draper] # => true
21
+ #
22
+ class DecoratorExtractor
23
+ include SharedUtilityMethods
24
+ include SharedDependencyScanner
25
+
26
+ # Directories to scan for decorator-style objects
27
+ DECORATOR_DIRECTORIES = %w[
28
+ app/decorators
29
+ app/presenters
30
+ app/form_objects
31
+ ].freeze
32
+
33
+ # Maps directory segment to decorator_type symbol
34
+ DIRECTORY_TYPE_MAP = {
35
+ 'decorators' => :decorator,
36
+ 'presenters' => :presenter,
37
+ 'form_objects' => :form_object
38
+ }.freeze
39
+
40
+ # Suffixes used to infer the decorated model name
41
+ DECORATOR_SUFFIXES = %w[Decorator Presenter Form].freeze
42
+
43
+ def initialize
44
+ @directories = DECORATOR_DIRECTORIES.map { |d| Rails.root.join(d) }
45
+ .select(&:directory?)
46
+ end
47
+
48
+ # Extract all decorator, presenter, and form object units.
49
+ #
50
+ # @return [Array<ExtractedUnit>] List of decorator units
51
+ def extract_all
52
+ @directories.flat_map do |dir|
53
+ Dir[dir.join('**/*.rb')].filter_map do |file|
54
+ extract_decorator_file(file)
55
+ end
56
+ end
57
+ end
58
+
59
+ # Extract a single decorator file.
60
+ #
61
+ # @param file_path [String] Absolute path to the Ruby file
62
+ # @return [ExtractedUnit, nil] The extracted unit or nil if not a decorator
63
+ def extract_decorator_file(file_path)
64
+ source = File.read(file_path)
65
+ class_name = extract_class_name(file_path, source)
66
+
67
+ return nil unless class_name
68
+ return nil if skip_file?(source)
69
+
70
+ unit = ExtractedUnit.new(
71
+ type: :decorator,
72
+ identifier: class_name,
73
+ file_path: file_path
74
+ )
75
+
76
+ unit.namespace = extract_namespace(class_name)
77
+ unit.source_code = annotate_source(source, class_name, file_path)
78
+ unit.metadata = extract_metadata(source, class_name, file_path)
79
+ unit.dependencies = extract_dependencies(source, class_name)
80
+
81
+ unit
82
+ rescue StandardError => e
83
+ Rails.logger.error("Failed to extract decorator #{file_path}: #{e.message}")
84
+ nil
85
+ end
86
+
87
+ private
88
+
89
+ # ──────────────────────────────────────────────────────────────────────
90
+ # Class Discovery
91
+ # ──────────────────────────────────────────────────────────────────────
92
+
93
+ # Extract the class name from source or fall back to filename convention.
94
+ #
95
+ # Handles namespaced classes defined inside module blocks by combining
96
+ # outer module names with the class name (e.g., module Admin / class
97
+ # UserDecorator → "Admin::UserDecorator").
98
+ #
99
+ # @param file_path [String] Path to the file
100
+ # @param source [String] Ruby source code
101
+ # @return [String, nil] The class name or nil
102
+ def extract_class_name(file_path, source)
103
+ namespaces = source.scan(/^\s*module\s+([\w:]+)/).flatten
104
+ class_match = source.match(/^\s*class\s+([\w:]+)/)
105
+
106
+ if class_match
107
+ base_class = class_match[1]
108
+ if namespaces.any? && !base_class.include?('::')
109
+ "#{namespaces.join('::')}::#{base_class}"
110
+ else
111
+ base_class
112
+ end
113
+ else
114
+ relative = file_path.sub("#{Rails.root}/", '')
115
+ relative
116
+ .sub(%r{^app/(decorators|presenters|form_objects)/}, '')
117
+ .sub('.rb', '')
118
+ .camelize
119
+ end
120
+ end
121
+
122
+ # Skip module-only files (concerns, base modules without a class).
123
+ #
124
+ # @param source [String] Ruby source code
125
+ # @return [Boolean]
126
+ def skip_file?(source)
127
+ source.match?(/^\s*module\s+\w+\s*$/) && !source.match?(/^\s*class\s+/)
128
+ end
129
+
130
+ # ──────────────────────────────────────────────────────────────────────
131
+ # Source Annotation
132
+ # ──────────────────────────────────────────────────────────────────────
133
+
134
+ # Prepend a summary annotation header to the source.
135
+ #
136
+ # @param source [String] Ruby source code
137
+ # @param class_name [String] The class name
138
+ # @param file_path [String] Path to the file
139
+ # @return [String] Annotated source
140
+ def annotate_source(source, class_name, file_path)
141
+ decorator_type = infer_decorator_type(file_path)
142
+ decorated_model = infer_decorated_model(class_name)
143
+
144
+ annotation = <<~ANNOTATION
145
+ # ╔═══════════════════════════════════════════════════════════════════════╗
146
+ # ║ Decorator: #{class_name.ljust(57)}║
147
+ # ║ Type: #{decorator_type.to_s.ljust(62)}║
148
+ # ║ Decorates: #{(decorated_model || 'unknown').ljust(57)}║
149
+ # ╚═══════════════════════════════════════════════════════════════════════╝
150
+
151
+ ANNOTATION
152
+
153
+ annotation + source
154
+ end
155
+
156
+ # ──────────────────────────────────────────────────────────────────────
157
+ # Metadata Extraction
158
+ # ──────────────────────────────────────────────────────────────────────
159
+
160
+ # Build the metadata hash for a decorator unit.
161
+ #
162
+ # @param source [String] Ruby source code
163
+ # @param class_name [String] The class name
164
+ # @param file_path [String] Path to the file
165
+ # @return [Hash] Decorator metadata
166
+ def extract_metadata(source, class_name, file_path)
167
+ {
168
+ decorator_type: infer_decorator_type(file_path),
169
+ decorated_model: infer_decorated_model(class_name),
170
+ uses_draper: draper?(source),
171
+ delegated_methods: extract_delegated_methods(source),
172
+ public_methods: extract_public_methods(source),
173
+ entry_points: detect_entry_points(source),
174
+ class_methods: extract_class_methods(source),
175
+ initialize_params: extract_initialize_params(source),
176
+ loc: source.lines.count { |l| l.strip.length.positive? && !l.strip.start_with?('#') }
177
+ }
178
+ end
179
+
180
+ # Infer the decorator_type symbol from the file path.
181
+ #
182
+ # @param file_path [String] Absolute path to the file
183
+ # @return [Symbol] :decorator, :presenter, or :form_object
184
+ def infer_decorator_type(file_path)
185
+ DIRECTORY_TYPE_MAP.each do |dir_segment, type|
186
+ return type if file_path.include?("/#{dir_segment}/")
187
+ end
188
+ :decorator
189
+ end
190
+
191
+ # Infer the decorated model name by stripping known suffixes.
192
+ #
193
+ # @param class_name [String] e.g. "UserDecorator", "ProductPresenter"
194
+ # @return [String, nil] e.g. "User", "Product", or nil if not inferable
195
+ def infer_decorated_model(class_name)
196
+ base = class_name.split('::').last
197
+ DECORATOR_SUFFIXES.each do |suffix|
198
+ return base.delete_suffix(suffix) if base.end_with?(suffix) && base.length > suffix.length
199
+ end
200
+ nil
201
+ end
202
+
203
+ # Detect whether the class uses the Draper gem.
204
+ #
205
+ # @param source [String] Ruby source code
206
+ # @return [Boolean]
207
+ def draper?(source)
208
+ source.match?(/Draper::Decorator/)
209
+ end
210
+
211
+ # Extract method names passed to `delegate` calls.
212
+ #
213
+ # @param source [String] Ruby source code
214
+ # @return [Array<String>] Delegated method names
215
+ def extract_delegated_methods(source)
216
+ methods = []
217
+ source.scan(/\bdelegate\s+(.*?)(?:,\s*to:|$)/m) do |match|
218
+ match[0].scan(/:(\w+)/).flatten.each { |m| methods << m }
219
+ end
220
+ methods.uniq
221
+ end
222
+
223
+ # Detect common entry points for decorator invocation.
224
+ #
225
+ # @param source [String] Ruby source code
226
+ # @return [Array<String>] Entry point method names
227
+ def detect_entry_points(source)
228
+ points = []
229
+ points << 'call' if source.match?(/def (self\.)?call\b/)
230
+ points << 'decorate' if source.match?(/def (self\.)?decorate\b/)
231
+ points << 'present' if source.match?(/def (self\.)?present\b/)
232
+ points << 'to_partial_path' if source.match?(/def to_partial_path\b/)
233
+ points.empty? ? ['unknown'] : points
234
+ end
235
+
236
+ # ──────────────────────────────────────────────────────────────────────
237
+ # Dependency Extraction
238
+ # ──────────────────────────────────────────────────────────────────────
239
+
240
+ # Build the dependency array for a decorator unit.
241
+ #
242
+ # Links to the decorated model via :decoration and scans the source
243
+ # for common code references (models, services, jobs, mailers).
244
+ #
245
+ # @param source [String] Ruby source code
246
+ # @param class_name [String] The class name
247
+ # @return [Array<Hash>] Dependency hashes with :type, :target, :via
248
+ def extract_dependencies(source, class_name)
249
+ deps = []
250
+
251
+ decorated_model = infer_decorated_model(class_name)
252
+ deps << { type: :model, target: decorated_model, via: :decoration } if decorated_model
253
+
254
+ deps.concat(scan_common_dependencies(source))
255
+
256
+ deps.uniq { |d| [d[:type], d[:target]] }
257
+ end
258
+ end
259
+ end
260
+ end