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,225 @@
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
+ # ValidatorExtractor handles custom validator class extraction.
9
+ #
10
+ # Custom validators encapsulate reusable validation logic that applies
11
+ # across multiple models. They inherit from `ActiveModel::Validator`
12
+ # or `ActiveModel::EachValidator` and live in `app/validators/`.
13
+ #
14
+ # We extract:
15
+ # - Validator name and namespace
16
+ # - Base class (Validator vs EachValidator)
17
+ # - Validation rules (what they check)
18
+ # - Models they operate on (from source references)
19
+ # - Dependencies (what models/services they reference)
20
+ #
21
+ # @example
22
+ # extractor = ValidatorExtractor.new
23
+ # units = extractor.extract_all
24
+ # email = units.find { |u| u.identifier == "EmailFormatValidator" }
25
+ #
26
+ class ValidatorExtractor
27
+ include SharedUtilityMethods
28
+ include SharedDependencyScanner
29
+
30
+ # Directories to scan for custom validators
31
+ VALIDATOR_DIRECTORIES = %w[
32
+ app/validators
33
+ ].freeze
34
+
35
+ def initialize
36
+ @directories = VALIDATOR_DIRECTORIES.map { |d| Rails.root.join(d) }
37
+ .select(&:directory?)
38
+ end
39
+
40
+ # Extract all custom validators
41
+ #
42
+ # @return [Array<ExtractedUnit>] List of validator units
43
+ def extract_all
44
+ @directories.flat_map do |dir|
45
+ Dir[dir.join('**/*.rb')].filter_map do |file|
46
+ extract_validator_file(file)
47
+ end
48
+ end
49
+ end
50
+
51
+ # Extract a single validator file
52
+ #
53
+ # @param file_path [String] Path to the validator file
54
+ # @return [ExtractedUnit, nil] The extracted unit or nil if not a validator
55
+ def extract_validator_file(file_path)
56
+ source = File.read(file_path)
57
+ class_name = extract_class_name(file_path, source)
58
+
59
+ return nil unless class_name
60
+ return nil unless validator_file?(source)
61
+
62
+ unit = ExtractedUnit.new(
63
+ type: :validator,
64
+ identifier: class_name,
65
+ file_path: file_path
66
+ )
67
+
68
+ unit.namespace = extract_namespace(class_name)
69
+ unit.source_code = annotate_source(source, class_name)
70
+ unit.metadata = extract_metadata(source, class_name)
71
+ unit.dependencies = extract_dependencies(source)
72
+
73
+ unit
74
+ rescue StandardError => e
75
+ Rails.logger.error("Failed to extract validator #{file_path}: #{e.message}")
76
+ nil
77
+ end
78
+
79
+ private
80
+
81
+ # ──────────────────────────────────────────────────────────────────────
82
+ # Class Discovery
83
+ # ──────────────────────────────────────────────────────────────────────
84
+
85
+ def extract_class_name(file_path, source)
86
+ return ::Regexp.last_match(1) if source =~ /^\s*class\s+([\w:]+)/
87
+
88
+ file_path
89
+ .sub("#{Rails.root}/", '')
90
+ .sub(%r{^app/validators/}, '')
91
+ .sub('.rb', '')
92
+ .camelize
93
+ end
94
+
95
+ def validator_file?(source)
96
+ source.match?(/< ActiveModel::Validator/) ||
97
+ source.match?(/< ActiveModel::EachValidator/) ||
98
+ source.match?(/def\s+validate_each\b/) ||
99
+ source.match?(/def\s+validate\(/)
100
+ end
101
+
102
+ # ──────────────────────────────────────────────────────────────────────
103
+ # Source Annotation
104
+ # ──────────────────────────────────────────────────────────────────────
105
+
106
+ def annotate_source(source, class_name)
107
+ validator_type = detect_validator_type(source)
108
+ validated_attrs = extract_validated_attributes(source)
109
+
110
+ <<~ANNOTATION
111
+ # ╔═══════════════════════════════════════════════════════════════════════╗
112
+ # ║ Validator: #{class_name.ljust(57)}║
113
+ # ║ Type: #{validator_type.to_s.ljust(62)}║
114
+ # ║ Attributes: #{validated_attrs.join(', ').ljust(56)}║
115
+ # ╚═══════════════════════════════════════════════════════════════════════╝
116
+
117
+ #{source}
118
+ ANNOTATION
119
+ end
120
+
121
+ # ──────────────────────────────────────────────────────────────────────
122
+ # Metadata Extraction
123
+ # ──────────────────────────────────────────────────────────────────────
124
+
125
+ def extract_metadata(source, class_name)
126
+ {
127
+ validator_type: detect_validator_type(source),
128
+ validated_attributes: extract_validated_attributes(source),
129
+ validation_rules: extract_validation_rules(source),
130
+ error_messages: extract_error_messages(source),
131
+ public_methods: extract_public_methods(source),
132
+ class_methods: extract_class_methods(source),
133
+ options_used: extract_options(source),
134
+ inferred_models: infer_models_from_name(class_name),
135
+ custom_errors: extract_custom_errors(source),
136
+ loc: source.lines.count { |l| l.strip.length.positive? && !l.strip.start_with?('#') },
137
+ method_count: source.scan(/def\s+(?:self\.)?\w+/).size
138
+ }
139
+ end
140
+
141
+ def detect_validator_type(source)
142
+ return :each_validator if source.match?(/< ActiveModel::EachValidator/)
143
+ return :validator if source.match?(/< ActiveModel::Validator/)
144
+ return :each_validator if source.match?(/def\s+validate_each\b/)
145
+ return :validator if source.match?(/def\s+validate\(/)
146
+
147
+ :unknown
148
+ end
149
+
150
+ def extract_validated_attributes(source)
151
+ attrs = []
152
+
153
+ # EachValidator: the attribute param in validate_each
154
+ attrs << ::Regexp.last_match(1) if source =~ /def\s+validate_each\s*\(\s*\w+\s*,\s*(\w+)/
155
+
156
+ # From error.add calls: record.errors.add(:attribute, ...)
157
+ source.scan(/errors\.add\s*\(\s*:(\w+)/).flatten.each { |a| attrs << a }
158
+
159
+ # From validates_each blocks
160
+ source.scan(/validates_each\s*\(\s*:(\w+)/).flatten.each { |a| attrs << a }
161
+
162
+ attrs.uniq
163
+ end
164
+
165
+ def extract_validation_rules(source)
166
+ # Conditional checks in validate/validate_each body
167
+ rules = source.scan(/unless\s+(.+)$/).flatten.map(&:strip)
168
+ source.scan(/if\s+(.+?)(?:\s*$|\s*then)/).flatten.each { |r| rules << r.strip }
169
+
170
+ # Regex validations
171
+ source.scan(%r{=~\s*(/[^/]+/)}).flatten.each { |r| rules << "matches #{r}" }
172
+ source.scan(%r{match\?\s*\((/[^/]+/)\)}).flatten.each { |r| rules << "matches #{r}" }
173
+
174
+ rules.first(10) # Cap at 10 to avoid noise
175
+ end
176
+
177
+ def extract_error_messages(source)
178
+ # errors.add(:attr, "message") or errors.add(variable, "message")
179
+ messages = source.scan(/errors\.add\s*\(\s*:?\w+\s*,\s*["']([^"']+)["']/).flatten.map { |m| m }
180
+
181
+ # errors.add(:attr, :symbol) or errors.add(variable, :symbol)
182
+ source.scan(/errors\.add\s*\(\s*:?\w+\s*,\s*:(\w+)/).flatten.each { |m| messages << ":#{m}" }
183
+
184
+ messages
185
+ end
186
+
187
+ def extract_options(source)
188
+ # options[:key] access
189
+ options = source.scan(/options\[:(\w+)\]/).flatten.map { |o| o }
190
+
191
+ options.uniq
192
+ end
193
+
194
+ def infer_models_from_name(class_name)
195
+ # EmailFormatValidator -> might validate email on many models
196
+ # No reliable way to infer specific models from name alone
197
+ # Return the validator's conceptual domain
198
+ stripped = class_name.split('::').last
199
+ inferred = stripped.sub(/Validator\z/, '')
200
+ inferred.empty? ? [] : [inferred]
201
+ end
202
+
203
+ def extract_custom_errors(source)
204
+ source.scan(/class\s+(\w+(?:Error|Exception))\s*</).flatten
205
+ end
206
+
207
+ # ──────────────────────────────────────────────────────────────────────
208
+ # Dependency Extraction
209
+ # ──────────────────────────────────────────────────────────────────────
210
+
211
+ def extract_dependencies(source)
212
+ deps = []
213
+ deps.concat(scan_model_dependencies(source, via: :validation))
214
+ deps.concat(scan_service_dependencies(source))
215
+
216
+ # Other validators referenced
217
+ source.scan(/(\w+Validator)(?:\.|::new)/).flatten.uniq.each do |validator|
218
+ deps << { type: :validator, target: validator, via: :code_reference }
219
+ end
220
+
221
+ deps.uniq { |d| [d[:type], d[:target]] }
222
+ end
223
+ end
224
+ end
225
+ end
@@ -0,0 +1,310 @@
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
+ # ViewComponentExtractor handles ViewComponent extraction.
9
+ #
10
+ # ViewComponent components are Ruby classes that encapsulate view logic.
11
+ # We can extract:
12
+ # - Slot definitions (renders_one, renders_many)
13
+ # - Sidecar template paths (.html.erb files next to the .rb file)
14
+ # - Initialize parameters (the component's API)
15
+ # - Preview classes (ViewComponent::Preview subclasses)
16
+ # - Collection support
17
+ # - Callbacks (before_render, after_render)
18
+ # - Content areas (legacy API)
19
+ # - Component dependencies (rendered sub-components, model references)
20
+ #
21
+ # @example
22
+ # extractor = ViewComponentExtractor.new
23
+ # units = extractor.extract_all
24
+ # card = units.find { |u| u.identifier == "CardComponent" }
25
+ #
26
+ class ViewComponentExtractor
27
+ include SharedUtilityMethods
28
+ include SharedDependencyScanner
29
+
30
+ def initialize
31
+ @component_base = find_component_base
32
+ end
33
+
34
+ # Extract all ViewComponent components
35
+ #
36
+ # @return [Array<ExtractedUnit>] List of view component units
37
+ def extract_all
38
+ return [] unless @component_base
39
+
40
+ @component_base.descendants.map do |component|
41
+ extract_component(component)
42
+ end.compact
43
+ end
44
+
45
+ # Extract a single ViewComponent component
46
+ #
47
+ # @param component [Class] The component class
48
+ # @return [ExtractedUnit, nil] The extracted unit, or nil on failure
49
+ def extract_component(component)
50
+ return nil if component.name.nil?
51
+ return nil if preview_class?(component)
52
+
53
+ unit = ExtractedUnit.new(
54
+ type: :view_component,
55
+ identifier: component.name,
56
+ file_path: source_file_for(component)
57
+ )
58
+
59
+ unit.namespace = extract_namespace(component)
60
+ unit.source_code = read_source(unit.file_path)
61
+ unit.metadata = extract_metadata(component, unit.source_code)
62
+ unit.dependencies = extract_dependencies(component, unit.source_code)
63
+
64
+ unit
65
+ rescue StandardError => e
66
+ Rails.logger.error("Failed to extract view component #{component.name}: #{e.message}")
67
+ nil
68
+ end
69
+
70
+ private
71
+
72
+ # Find the ViewComponent::Base class if the gem is loaded
73
+ #
74
+ # @return [Class, nil]
75
+ def find_component_base
76
+ return nil unless defined?(ViewComponent::Base)
77
+
78
+ ViewComponent::Base
79
+ end
80
+
81
+ # Check if a class is a preview class (not a component itself)
82
+ #
83
+ # @param klass [Class]
84
+ # @return [Boolean]
85
+ def preview_class?(klass)
86
+ defined?(ViewComponent::Preview) && klass < ViewComponent::Preview
87
+ end
88
+
89
+ # Locate the source file for a component class
90
+ #
91
+ # @param component [Class]
92
+ # @return [String, nil]
93
+ def source_file_for(component)
94
+ possible_paths = [
95
+ Rails.root.join("app/components/#{component.name.underscore}.rb"),
96
+ Rails.root.join("app/views/components/#{component.name.underscore}.rb")
97
+ ]
98
+
99
+ found = possible_paths.find { |p| File.exist?(p) }
100
+ return found.to_s if found
101
+
102
+ # Fall back to method source location
103
+ if component.instance_methods(false).any?
104
+ method = component.instance_methods(false).first
105
+ component.instance_method(method).source_location&.first
106
+ end
107
+ rescue StandardError
108
+ nil
109
+ end
110
+
111
+ # @param file_path [String, nil]
112
+ # @return [String]
113
+ def read_source(file_path)
114
+ return '' unless file_path && File.exist?(file_path)
115
+
116
+ File.read(file_path)
117
+ end
118
+
119
+ # ──────────────────────────────────────────────────────────────────────
120
+ # Metadata Extraction
121
+ # ──────────────────────────────────────────────────────────────────────
122
+
123
+ def extract_metadata(component, source)
124
+ {
125
+ slots: extract_slots(source),
126
+ initialize_params: extract_initialize_params(component),
127
+ public_methods: component.public_instance_methods(false),
128
+ parent_component: component.superclass.name,
129
+ sidecar_template: detect_sidecar_template(component),
130
+ preview_class: detect_preview_class(component),
131
+ collection_support: detect_collection_support(source),
132
+ callbacks: extract_callbacks(source),
133
+ content_areas: extract_content_areas(source),
134
+ renders_many: extract_renders_many(source),
135
+ renders_one: extract_renders_one(source),
136
+ loc: source.lines.count { |l| l.strip.length.positive? && !l.strip.start_with?('#') }
137
+ }
138
+ end
139
+
140
+ # Extract slot definitions from renders_one / renders_many
141
+ #
142
+ # @param source [String]
143
+ # @return [Array<Hash>]
144
+ def extract_slots(source)
145
+ slots = []
146
+
147
+ source.scan(/renders_one\s+:(\w+)(?:,\s*(\w+(?:::\w+)*))?/) do |name, klass|
148
+ slots << { name: name, type: :one, class: klass }
149
+ end
150
+
151
+ source.scan(/renders_many\s+:(\w+)(?:,\s*(\w+(?:::\w+)*))?/) do |name, klass|
152
+ slots << { name: name, type: :many, class: klass }
153
+ end
154
+
155
+ slots
156
+ end
157
+
158
+ def extract_renders_many(source)
159
+ source.scan(/renders_many\s+:(\w+)/).flatten
160
+ end
161
+
162
+ def extract_renders_one(source)
163
+ source.scan(/renders_one\s+:(\w+)/).flatten
164
+ end
165
+
166
+ # Extract initialize parameters to understand the component's data requirements
167
+ #
168
+ # @param component [Class]
169
+ # @return [Array<Hash>]
170
+ def extract_initialize_params(component)
171
+ method = component.instance_method(:initialize)
172
+ params = method.parameters
173
+
174
+ params.map do |type, name|
175
+ param_type = case type
176
+ when :req then :required
177
+ when :opt then :optional
178
+ when :keyreq then :keyword_required
179
+ when :key then :keyword_optional
180
+ when :rest then :splat
181
+ when :keyrest then :double_splat
182
+ when :block then :block
183
+ else type
184
+ end
185
+ { name: name, type: param_type }
186
+ end
187
+ rescue StandardError
188
+ []
189
+ end
190
+
191
+ # Detect sidecar template file (.html.erb next to the .rb file)
192
+ #
193
+ # @param component [Class]
194
+ # @return [String, nil] Path to sidecar template if found
195
+ def detect_sidecar_template(component)
196
+ base_path = Rails.root.join("app/components/#{component.name.underscore}")
197
+
198
+ # Check common sidecar template patterns
199
+ candidates = [
200
+ "#{base_path}.html.erb",
201
+ "#{base_path}.html.haml",
202
+ "#{base_path}.html.slim",
203
+ "#{base_path}/#{component.name.demodulize.underscore}.html.erb"
204
+ ]
205
+
206
+ candidates.find { |path| File.exist?(path) }
207
+ rescue StandardError
208
+ nil
209
+ end
210
+
211
+ # Detect if a preview class exists for this component
212
+ #
213
+ # @param component [Class]
214
+ # @return [String, nil] Preview class name if found
215
+ def detect_preview_class(component)
216
+ return nil unless defined?(ViewComponent::Preview)
217
+
218
+ preview_name = "#{component.name}Preview"
219
+ klass = preview_name.safe_constantize
220
+ klass&.name if klass && klass < ViewComponent::Preview
221
+ rescue StandardError
222
+ nil
223
+ end
224
+
225
+ # Detect if the component supports collection rendering
226
+ #
227
+ # @param source [String]
228
+ # @return [Boolean]
229
+ def detect_collection_support(source)
230
+ source.match?(/with_collection_parameter/) ||
231
+ source.match?(/def\s+self\.collection_parameter/)
232
+ end
233
+
234
+ # Extract before_render / after_render callbacks
235
+ #
236
+ # @param source [String]
237
+ # @return [Array<Hash>]
238
+ def extract_callbacks(source)
239
+ callbacks = []
240
+
241
+ source.scan(/before_render\s+:(\w+)/) do |name|
242
+ callbacks << { kind: :before_render, method: name[0] }
243
+ end
244
+
245
+ source.scan(/after_render\s+:(\w+)/) do |name|
246
+ callbacks << { kind: :after_render, method: name[0] }
247
+ end
248
+
249
+ # Also detect inline before_render method override
250
+ callbacks << { kind: :before_render, method: :inline } if source.match?(/def\s+before_render\b/)
251
+
252
+ callbacks
253
+ end
254
+
255
+ # Extract legacy content_areas definitions
256
+ #
257
+ # @param source [String]
258
+ # @return [Array<String>]
259
+ def extract_content_areas(source)
260
+ source.scan(/with_content_areas\s+(.+)$/).flatten.flat_map do |area_list|
261
+ area_list.scan(/:(\w+)/).flatten
262
+ end
263
+ end
264
+
265
+ # ──────────────────────────────────────────────────────────────────────
266
+ # Dependency Extraction
267
+ # ──────────────────────────────────────────────────────────────────────
268
+
269
+ def extract_dependencies(component, source)
270
+ deps = []
271
+
272
+ # Other components rendered via render()
273
+ source.scan(/render\s*\(?\s*(\w+(?:::\w+)*)\.new/).flatten.uniq.each do |comp|
274
+ next if comp == component.name
275
+
276
+ deps << { type: :component, target: comp, via: :render }
277
+ end
278
+
279
+ # Components rendered via slot classes
280
+ source.scan(/renders_one\s+:\w+,\s*(\w+(?:::\w+)*)/).flatten.uniq.each do |comp|
281
+ deps << { type: :component, target: comp, via: :slot }
282
+ end
283
+
284
+ source.scan(/renders_many\s+:\w+,\s*(\w+(?:::\w+)*)/).flatten.uniq.each do |comp|
285
+ deps << { type: :component, target: comp, via: :slot }
286
+ end
287
+
288
+ # Model references
289
+ deps.concat(scan_model_dependencies(source, via: :data_dependency))
290
+
291
+ # Helper modules
292
+ source.scan(/include\s+(\w+Helper)/).flatten.uniq.each do |helper|
293
+ deps << { type: :helper, target: helper, via: :include }
294
+ end
295
+
296
+ # Stimulus controllers (from data-controller attributes in templates/source)
297
+ source.scan(/data[_-]controller[=:]\s*["']([^"']+)["']/).flatten.uniq.each do |controller|
298
+ deps << { type: :stimulus_controller, target: controller, via: :html_attribute }
299
+ end
300
+
301
+ # URL helpers
302
+ source.scan(/(\w+)_(?:path|url)/).flatten.uniq.each do |route|
303
+ deps << { type: :route, target: route, via: :url_helper }
304
+ end
305
+
306
+ deps.uniq { |d| [d[:type], d[:target]] }
307
+ end
308
+ end
309
+ end
310
+ end