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.
- checksums.yaml +7 -0
- data/CHANGELOG.md +29 -0
- data/CODE_OF_CONDUCT.md +83 -0
- data/CONTRIBUTING.md +65 -0
- data/LICENSE.txt +21 -0
- data/README.md +481 -0
- data/exe/codebase-console-mcp +22 -0
- data/exe/codebase-index-mcp +61 -0
- data/exe/codebase-index-mcp-http +64 -0
- data/exe/codebase-index-mcp-start +58 -0
- data/lib/codebase_index/ast/call_site_extractor.rb +106 -0
- data/lib/codebase_index/ast/method_extractor.rb +76 -0
- data/lib/codebase_index/ast/node.rb +88 -0
- data/lib/codebase_index/ast/parser.rb +653 -0
- data/lib/codebase_index/ast.rb +6 -0
- data/lib/codebase_index/builder.rb +137 -0
- data/lib/codebase_index/chunking/chunk.rb +84 -0
- data/lib/codebase_index/chunking/semantic_chunker.rb +290 -0
- data/lib/codebase_index/console/adapters/cache_adapter.rb +58 -0
- data/lib/codebase_index/console/adapters/good_job_adapter.rb +66 -0
- data/lib/codebase_index/console/adapters/sidekiq_adapter.rb +66 -0
- data/lib/codebase_index/console/adapters/solid_queue_adapter.rb +66 -0
- data/lib/codebase_index/console/audit_logger.rb +75 -0
- data/lib/codebase_index/console/bridge.rb +170 -0
- data/lib/codebase_index/console/confirmation.rb +90 -0
- data/lib/codebase_index/console/connection_manager.rb +173 -0
- data/lib/codebase_index/console/console_response_renderer.rb +78 -0
- data/lib/codebase_index/console/model_validator.rb +81 -0
- data/lib/codebase_index/console/safe_context.rb +82 -0
- data/lib/codebase_index/console/server.rb +557 -0
- data/lib/codebase_index/console/sql_validator.rb +172 -0
- data/lib/codebase_index/console/tools/tier1.rb +118 -0
- data/lib/codebase_index/console/tools/tier2.rb +117 -0
- data/lib/codebase_index/console/tools/tier3.rb +110 -0
- data/lib/codebase_index/console/tools/tier4.rb +79 -0
- data/lib/codebase_index/coordination/pipeline_lock.rb +109 -0
- data/lib/codebase_index/cost_model/embedding_cost.rb +88 -0
- data/lib/codebase_index/cost_model/estimator.rb +128 -0
- data/lib/codebase_index/cost_model/provider_pricing.rb +67 -0
- data/lib/codebase_index/cost_model/storage_cost.rb +52 -0
- data/lib/codebase_index/cost_model.rb +22 -0
- data/lib/codebase_index/db/migrations/001_create_units.rb +38 -0
- data/lib/codebase_index/db/migrations/002_create_edges.rb +35 -0
- data/lib/codebase_index/db/migrations/003_create_embeddings.rb +37 -0
- data/lib/codebase_index/db/migrations/004_create_snapshots.rb +45 -0
- data/lib/codebase_index/db/migrations/005_create_snapshot_units.rb +40 -0
- data/lib/codebase_index/db/migrator.rb +71 -0
- data/lib/codebase_index/db/schema_version.rb +73 -0
- data/lib/codebase_index/dependency_graph.rb +227 -0
- data/lib/codebase_index/embedding/indexer.rb +130 -0
- data/lib/codebase_index/embedding/openai.rb +105 -0
- data/lib/codebase_index/embedding/provider.rb +135 -0
- data/lib/codebase_index/embedding/text_preparer.rb +112 -0
- data/lib/codebase_index/evaluation/baseline_runner.rb +115 -0
- data/lib/codebase_index/evaluation/evaluator.rb +146 -0
- data/lib/codebase_index/evaluation/metrics.rb +79 -0
- data/lib/codebase_index/evaluation/query_set.rb +148 -0
- data/lib/codebase_index/evaluation/report_generator.rb +90 -0
- data/lib/codebase_index/extracted_unit.rb +145 -0
- data/lib/codebase_index/extractor.rb +956 -0
- data/lib/codebase_index/extractors/action_cable_extractor.rb +228 -0
- data/lib/codebase_index/extractors/ast_source_extraction.rb +46 -0
- data/lib/codebase_index/extractors/behavioral_profile.rb +309 -0
- data/lib/codebase_index/extractors/caching_extractor.rb +261 -0
- data/lib/codebase_index/extractors/callback_analyzer.rb +232 -0
- data/lib/codebase_index/extractors/concern_extractor.rb +253 -0
- data/lib/codebase_index/extractors/configuration_extractor.rb +219 -0
- data/lib/codebase_index/extractors/controller_extractor.rb +494 -0
- data/lib/codebase_index/extractors/database_view_extractor.rb +278 -0
- data/lib/codebase_index/extractors/decorator_extractor.rb +260 -0
- data/lib/codebase_index/extractors/engine_extractor.rb +204 -0
- data/lib/codebase_index/extractors/event_extractor.rb +211 -0
- data/lib/codebase_index/extractors/factory_extractor.rb +289 -0
- data/lib/codebase_index/extractors/graphql_extractor.rb +917 -0
- data/lib/codebase_index/extractors/i18n_extractor.rb +117 -0
- data/lib/codebase_index/extractors/job_extractor.rb +369 -0
- data/lib/codebase_index/extractors/lib_extractor.rb +249 -0
- data/lib/codebase_index/extractors/mailer_extractor.rb +339 -0
- data/lib/codebase_index/extractors/manager_extractor.rb +202 -0
- data/lib/codebase_index/extractors/middleware_extractor.rb +133 -0
- data/lib/codebase_index/extractors/migration_extractor.rb +469 -0
- data/lib/codebase_index/extractors/model_extractor.rb +960 -0
- data/lib/codebase_index/extractors/phlex_extractor.rb +252 -0
- data/lib/codebase_index/extractors/policy_extractor.rb +214 -0
- data/lib/codebase_index/extractors/poro_extractor.rb +246 -0
- data/lib/codebase_index/extractors/pundit_extractor.rb +223 -0
- data/lib/codebase_index/extractors/rails_source_extractor.rb +473 -0
- data/lib/codebase_index/extractors/rake_task_extractor.rb +343 -0
- data/lib/codebase_index/extractors/route_extractor.rb +181 -0
- data/lib/codebase_index/extractors/scheduled_job_extractor.rb +331 -0
- data/lib/codebase_index/extractors/serializer_extractor.rb +334 -0
- data/lib/codebase_index/extractors/service_extractor.rb +254 -0
- data/lib/codebase_index/extractors/shared_dependency_scanner.rb +91 -0
- data/lib/codebase_index/extractors/shared_utility_methods.rb +99 -0
- data/lib/codebase_index/extractors/state_machine_extractor.rb +398 -0
- data/lib/codebase_index/extractors/test_mapping_extractor.rb +225 -0
- data/lib/codebase_index/extractors/validator_extractor.rb +225 -0
- data/lib/codebase_index/extractors/view_component_extractor.rb +310 -0
- data/lib/codebase_index/extractors/view_template_extractor.rb +261 -0
- data/lib/codebase_index/feedback/gap_detector.rb +89 -0
- data/lib/codebase_index/feedback/store.rb +119 -0
- data/lib/codebase_index/flow_analysis/operation_extractor.rb +209 -0
- data/lib/codebase_index/flow_analysis/response_code_mapper.rb +154 -0
- data/lib/codebase_index/flow_assembler.rb +290 -0
- data/lib/codebase_index/flow_document.rb +191 -0
- data/lib/codebase_index/flow_precomputer.rb +102 -0
- data/lib/codebase_index/formatting/base.rb +40 -0
- data/lib/codebase_index/formatting/claude_adapter.rb +98 -0
- data/lib/codebase_index/formatting/generic_adapter.rb +56 -0
- data/lib/codebase_index/formatting/gpt_adapter.rb +64 -0
- data/lib/codebase_index/formatting/human_adapter.rb +78 -0
- data/lib/codebase_index/graph_analyzer.rb +374 -0
- data/lib/codebase_index/mcp/index_reader.rb +394 -0
- data/lib/codebase_index/mcp/renderers/claude_renderer.rb +81 -0
- data/lib/codebase_index/mcp/renderers/json_renderer.rb +17 -0
- data/lib/codebase_index/mcp/renderers/markdown_renderer.rb +352 -0
- data/lib/codebase_index/mcp/renderers/plain_renderer.rb +240 -0
- data/lib/codebase_index/mcp/server.rb +935 -0
- data/lib/codebase_index/mcp/tool_response_renderer.rb +62 -0
- data/lib/codebase_index/model_name_cache.rb +51 -0
- data/lib/codebase_index/notion/client.rb +217 -0
- data/lib/codebase_index/notion/exporter.rb +219 -0
- data/lib/codebase_index/notion/mapper.rb +39 -0
- data/lib/codebase_index/notion/mappers/column_mapper.rb +65 -0
- data/lib/codebase_index/notion/mappers/migration_mapper.rb +39 -0
- data/lib/codebase_index/notion/mappers/model_mapper.rb +164 -0
- data/lib/codebase_index/notion/rate_limiter.rb +68 -0
- data/lib/codebase_index/observability/health_check.rb +81 -0
- data/lib/codebase_index/observability/instrumentation.rb +34 -0
- data/lib/codebase_index/observability/structured_logger.rb +75 -0
- data/lib/codebase_index/operator/error_escalator.rb +81 -0
- data/lib/codebase_index/operator/pipeline_guard.rb +99 -0
- data/lib/codebase_index/operator/status_reporter.rb +80 -0
- data/lib/codebase_index/railtie.rb +26 -0
- data/lib/codebase_index/resilience/circuit_breaker.rb +99 -0
- data/lib/codebase_index/resilience/index_validator.rb +185 -0
- data/lib/codebase_index/resilience/retryable_provider.rb +108 -0
- data/lib/codebase_index/retrieval/context_assembler.rb +249 -0
- data/lib/codebase_index/retrieval/query_classifier.rb +131 -0
- data/lib/codebase_index/retrieval/ranker.rb +273 -0
- data/lib/codebase_index/retrieval/search_executor.rb +327 -0
- data/lib/codebase_index/retriever.rb +160 -0
- data/lib/codebase_index/ruby_analyzer/class_analyzer.rb +190 -0
- data/lib/codebase_index/ruby_analyzer/dataflow_analyzer.rb +78 -0
- data/lib/codebase_index/ruby_analyzer/fqn_builder.rb +18 -0
- data/lib/codebase_index/ruby_analyzer/mermaid_renderer.rb +275 -0
- data/lib/codebase_index/ruby_analyzer/method_analyzer.rb +143 -0
- data/lib/codebase_index/ruby_analyzer/trace_enricher.rb +139 -0
- data/lib/codebase_index/ruby_analyzer.rb +87 -0
- data/lib/codebase_index/session_tracer/file_store.rb +111 -0
- data/lib/codebase_index/session_tracer/middleware.rb +143 -0
- data/lib/codebase_index/session_tracer/redis_store.rb +112 -0
- data/lib/codebase_index/session_tracer/session_flow_assembler.rb +263 -0
- data/lib/codebase_index/session_tracer/session_flow_document.rb +223 -0
- data/lib/codebase_index/session_tracer/solid_cache_store.rb +145 -0
- data/lib/codebase_index/session_tracer/store.rb +67 -0
- data/lib/codebase_index/storage/graph_store.rb +120 -0
- data/lib/codebase_index/storage/metadata_store.rb +169 -0
- data/lib/codebase_index/storage/pgvector.rb +163 -0
- data/lib/codebase_index/storage/qdrant.rb +172 -0
- data/lib/codebase_index/storage/vector_store.rb +156 -0
- data/lib/codebase_index/temporal/snapshot_store.rb +341 -0
- data/lib/codebase_index/version.rb +5 -0
- data/lib/codebase_index.rb +223 -0
- data/lib/generators/codebase_index/install_generator.rb +32 -0
- data/lib/generators/codebase_index/pgvector_generator.rb +37 -0
- data/lib/generators/codebase_index/templates/add_pgvector_to_codebase_index.rb.erb +15 -0
- data/lib/generators/codebase_index/templates/create_codebase_index_tables.rb.erb +43 -0
- data/lib/tasks/codebase_index.rake +583 -0
- data/lib/tasks/codebase_index_evaluation.rake +115 -0
- 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
|