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,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
|