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,249 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative 'shared_utility_methods'
|
|
4
|
+
require_relative 'shared_dependency_scanner'
|
|
5
|
+
|
|
6
|
+
module CodebaseIndex
|
|
7
|
+
module Extractors
|
|
8
|
+
# LibExtractor handles extraction of Ruby files from lib/.
|
|
9
|
+
#
|
|
10
|
+
# The lib/ directory contains application infrastructure that sits outside
|
|
11
|
+
# Rails' app/ convention: custom middleware, client wrappers, utility classes,
|
|
12
|
+
# domain-specific libraries, and framework extensions. These are often heavily
|
|
13
|
+
# referenced but invisible to app/-only extractors.
|
|
14
|
+
#
|
|
15
|
+
# Excludes:
|
|
16
|
+
# - lib/tasks/ — handled by RakeTaskExtractor
|
|
17
|
+
# - lib/generators/ — Rails generator scaffolding, not application code
|
|
18
|
+
#
|
|
19
|
+
# Handles:
|
|
20
|
+
# - Plain Ruby classes (with or without inheritance)
|
|
21
|
+
# - Module-only files (standalone modules without a class)
|
|
22
|
+
# - Namespaced classes (e.g., lib/external/analytics.rb → External::Analytics)
|
|
23
|
+
# - Files with multiple class definitions
|
|
24
|
+
#
|
|
25
|
+
# @example
|
|
26
|
+
# extractor = LibExtractor.new
|
|
27
|
+
# units = extractor.extract_all
|
|
28
|
+
# analytics = units.find { |u| u.identifier == "External::Analytics" }
|
|
29
|
+
# analytics.metadata[:entry_points] # => ["call"]
|
|
30
|
+
# analytics.metadata[:parent_class] # => nil
|
|
31
|
+
#
|
|
32
|
+
class LibExtractor
|
|
33
|
+
include SharedUtilityMethods
|
|
34
|
+
include SharedDependencyScanner
|
|
35
|
+
|
|
36
|
+
# Root directory to scan
|
|
37
|
+
LIB_DIRECTORY = 'lib'
|
|
38
|
+
|
|
39
|
+
# Subdirectories to exclude from extraction
|
|
40
|
+
EXCLUDED_SEGMENTS = %w[/tasks/ /generators/].freeze
|
|
41
|
+
|
|
42
|
+
def initialize
|
|
43
|
+
@lib_dir = Rails.root.join(LIB_DIRECTORY)
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
# Extract all lib units from lib/**/*.rb (excluding tasks and generators).
|
|
47
|
+
#
|
|
48
|
+
# @return [Array<ExtractedUnit>] List of lib units
|
|
49
|
+
def extract_all
|
|
50
|
+
return [] unless @lib_dir.directory?
|
|
51
|
+
|
|
52
|
+
Dir[@lib_dir.join('**/*.rb')].filter_map do |file|
|
|
53
|
+
next if excluded_path?(file)
|
|
54
|
+
|
|
55
|
+
extract_lib_file(file)
|
|
56
|
+
end
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
# Extract a single lib file.
|
|
60
|
+
#
|
|
61
|
+
# Returns nil if the file cannot be read or yields no extractable unit.
|
|
62
|
+
# Module-only files are extracted (unlike some other extractors) since
|
|
63
|
+
# lib/ commonly contains standalone utility modules.
|
|
64
|
+
#
|
|
65
|
+
# @param file_path [String] Absolute path to the Ruby file
|
|
66
|
+
# @return [ExtractedUnit, nil] The extracted unit or nil on failure
|
|
67
|
+
def extract_lib_file(file_path)
|
|
68
|
+
source = File.read(file_path)
|
|
69
|
+
|
|
70
|
+
class_name = infer_class_name(file_path, source)
|
|
71
|
+
return nil unless class_name
|
|
72
|
+
|
|
73
|
+
unit = ExtractedUnit.new(
|
|
74
|
+
type: :lib,
|
|
75
|
+
identifier: class_name,
|
|
76
|
+
file_path: file_path
|
|
77
|
+
)
|
|
78
|
+
|
|
79
|
+
unit.namespace = extract_namespace(class_name)
|
|
80
|
+
unit.source_code = annotate_source(source, class_name)
|
|
81
|
+
unit.metadata = extract_metadata(source, class_name)
|
|
82
|
+
unit.dependencies = extract_dependencies(source)
|
|
83
|
+
|
|
84
|
+
unit
|
|
85
|
+
rescue StandardError => e
|
|
86
|
+
Rails.logger.error("Failed to extract lib file #{file_path}: #{e.message}")
|
|
87
|
+
nil
|
|
88
|
+
end
|
|
89
|
+
|
|
90
|
+
private
|
|
91
|
+
|
|
92
|
+
# ──────────────────────────────────────────────────────────────────────
|
|
93
|
+
# Path Filtering
|
|
94
|
+
# ──────────────────────────────────────────────────────────────────────
|
|
95
|
+
|
|
96
|
+
# Return true when the file path falls inside an excluded subdirectory.
|
|
97
|
+
#
|
|
98
|
+
# @param file_path [String] Absolute path to the file
|
|
99
|
+
# @return [Boolean]
|
|
100
|
+
def excluded_path?(file_path)
|
|
101
|
+
EXCLUDED_SEGMENTS.any? { |seg| file_path.include?(seg) }
|
|
102
|
+
end
|
|
103
|
+
|
|
104
|
+
# ──────────────────────────────────────────────────────────────────────
|
|
105
|
+
# Class / Module Name Inference
|
|
106
|
+
# ──────────────────────────────────────────────────────────────────────
|
|
107
|
+
|
|
108
|
+
# Infer the primary constant name from source or fall back to file path.
|
|
109
|
+
#
|
|
110
|
+
# For files with a class definition, combines outer module namespaces
|
|
111
|
+
# with the class name. For module-only files, uses the outermost module
|
|
112
|
+
# name (joined with inner modules). Falls back to path-based camelize
|
|
113
|
+
# when neither is present.
|
|
114
|
+
#
|
|
115
|
+
# @param file_path [String] Absolute path to the file
|
|
116
|
+
# @param source [String] Ruby source code
|
|
117
|
+
# @return [String, nil] The inferred constant name, or nil for empty files
|
|
118
|
+
def infer_class_name(file_path, source)
|
|
119
|
+
return nil if source.strip.empty?
|
|
120
|
+
|
|
121
|
+
# Class definition — combine outer modules + class name
|
|
122
|
+
class_match = source.match(/^\s*class\s+([\w:]+)/)
|
|
123
|
+
if class_match
|
|
124
|
+
base = class_match[1]
|
|
125
|
+
return base if base.include?('::')
|
|
126
|
+
|
|
127
|
+
namespaces = source.scan(/^\s*module\s+([\w:]+)/).flatten
|
|
128
|
+
return namespaces.any? ? "#{namespaces.join('::')}::#{base}" : base
|
|
129
|
+
end
|
|
130
|
+
|
|
131
|
+
# Module-only file — use the outermost module chain
|
|
132
|
+
modules = source.scan(/^\s*module\s+([\w:]+)/).flatten
|
|
133
|
+
return modules.join('::') if modules.any?
|
|
134
|
+
|
|
135
|
+
# Fall back to path-based naming
|
|
136
|
+
path_based_class_name(file_path)
|
|
137
|
+
end
|
|
138
|
+
|
|
139
|
+
# Derive a constant name from a lib/ file path.
|
|
140
|
+
#
|
|
141
|
+
# lib/external/analytics.rb => External::Analytics
|
|
142
|
+
# lib/json_api/serializer.rb => JsonApi::Serializer
|
|
143
|
+
# lib/my_gem.rb => MyGem
|
|
144
|
+
#
|
|
145
|
+
# @param file_path [String] Absolute path to the file
|
|
146
|
+
# @return [String] Camelize-derived constant name
|
|
147
|
+
def path_based_class_name(file_path)
|
|
148
|
+
relative = file_path.sub("#{Rails.root}/", '')
|
|
149
|
+
relative
|
|
150
|
+
.sub(%r{^lib/}, '')
|
|
151
|
+
.sub('.rb', '')
|
|
152
|
+
.split('/')
|
|
153
|
+
.map(&:camelize)
|
|
154
|
+
.join('::')
|
|
155
|
+
end
|
|
156
|
+
|
|
157
|
+
# ──────────────────────────────────────────────────────────────────────
|
|
158
|
+
# Source Annotation
|
|
159
|
+
# ──────────────────────────────────────────────────────────────────────
|
|
160
|
+
|
|
161
|
+
# Prepend a summary annotation header to the source.
|
|
162
|
+
#
|
|
163
|
+
# @param source [String] Ruby source code
|
|
164
|
+
# @param class_name [String] The inferred constant name
|
|
165
|
+
# @return [String] Annotated source
|
|
166
|
+
def annotate_source(source, class_name)
|
|
167
|
+
parent = extract_parent_class(source)
|
|
168
|
+
entry_points = detect_entry_points(source)
|
|
169
|
+
parent_label = parent || 'none'
|
|
170
|
+
|
|
171
|
+
annotation = <<~ANNOTATION
|
|
172
|
+
# ╔═══════════════════════════════════════════════════════════════════════╗
|
|
173
|
+
# ║ Lib: #{class_name.ljust(65)}║
|
|
174
|
+
# ║ Parent: #{parent_label.ljust(61)}║
|
|
175
|
+
# ║ Entry Points: #{entry_points.join(', ').ljust(55)}║
|
|
176
|
+
# ╚═══════════════════════════════════════════════════════════════════════╝
|
|
177
|
+
|
|
178
|
+
ANNOTATION
|
|
179
|
+
|
|
180
|
+
annotation + source
|
|
181
|
+
end
|
|
182
|
+
|
|
183
|
+
# ──────────────────────────────────────────────────────────────────────
|
|
184
|
+
# Metadata Extraction
|
|
185
|
+
# ──────────────────────────────────────────────────────────────────────
|
|
186
|
+
|
|
187
|
+
# Build the metadata hash for a lib unit.
|
|
188
|
+
#
|
|
189
|
+
# @param source [String] Ruby source code
|
|
190
|
+
# @param class_name [String] The inferred constant name
|
|
191
|
+
# @return [Hash] Lib unit metadata
|
|
192
|
+
def extract_metadata(source, _class_name)
|
|
193
|
+
{
|
|
194
|
+
public_methods: extract_public_methods(source),
|
|
195
|
+
class_methods: extract_class_methods(source),
|
|
196
|
+
initialize_params: extract_initialize_params(source),
|
|
197
|
+
parent_class: extract_parent_class(source),
|
|
198
|
+
loc: count_loc(source),
|
|
199
|
+
method_count: source.scan(/def\s+(?:self\.)?\w+/).size,
|
|
200
|
+
entry_points: detect_entry_points(source)
|
|
201
|
+
}
|
|
202
|
+
end
|
|
203
|
+
|
|
204
|
+
# Extract the parent class name from a class definition.
|
|
205
|
+
#
|
|
206
|
+
# @param source [String] Ruby source code
|
|
207
|
+
# @return [String, nil] Parent class name or nil
|
|
208
|
+
def extract_parent_class(source)
|
|
209
|
+
match = source.match(/^\s*class\s+[\w:]+\s*<\s*([\w:]+)/)
|
|
210
|
+
match ? match[1] : nil
|
|
211
|
+
end
|
|
212
|
+
|
|
213
|
+
# Count non-blank, non-comment lines of code.
|
|
214
|
+
#
|
|
215
|
+
# @param source [String] Ruby source code
|
|
216
|
+
# @return [Integer] LOC count
|
|
217
|
+
def count_loc(source)
|
|
218
|
+
source.lines.count { |l| l.strip.length.positive? && !l.strip.start_with?('#') }
|
|
219
|
+
end
|
|
220
|
+
|
|
221
|
+
# Detect common entry point methods.
|
|
222
|
+
#
|
|
223
|
+
# @param source [String] Ruby source code
|
|
224
|
+
# @return [Array<String>] Entry point method names
|
|
225
|
+
def detect_entry_points(source)
|
|
226
|
+
points = []
|
|
227
|
+
points << 'call' if source.match?(/def (self\.)?call\b/)
|
|
228
|
+
points << 'perform' if source.match?(/def (self\.)?perform\b/)
|
|
229
|
+
points << 'execute' if source.match?(/def (self\.)?execute\b/)
|
|
230
|
+
points << 'run' if source.match?(/def (self\.)?run\b/)
|
|
231
|
+
points << 'process' if source.match?(/def (self\.)?process\b/)
|
|
232
|
+
points.empty? ? ['unknown'] : points
|
|
233
|
+
end
|
|
234
|
+
|
|
235
|
+
# ──────────────────────────────────────────────────────────────────────
|
|
236
|
+
# Dependency Extraction
|
|
237
|
+
# ──────────────────────────────────────────────────────────────────────
|
|
238
|
+
|
|
239
|
+
# Build the dependency array using common dependency scanners.
|
|
240
|
+
#
|
|
241
|
+
# @param source [String] Ruby source code
|
|
242
|
+
# @return [Array<Hash>] Dependency hashes with :type, :target, :via
|
|
243
|
+
def extract_dependencies(source)
|
|
244
|
+
deps = scan_common_dependencies(source)
|
|
245
|
+
deps.uniq { |d| [d[:type], d[:target]] }
|
|
246
|
+
end
|
|
247
|
+
end
|
|
248
|
+
end
|
|
249
|
+
end
|
|
@@ -0,0 +1,339 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'digest'
|
|
4
|
+
require_relative 'ast_source_extraction'
|
|
5
|
+
require_relative 'shared_utility_methods'
|
|
6
|
+
require_relative 'shared_dependency_scanner'
|
|
7
|
+
|
|
8
|
+
module CodebaseIndex
|
|
9
|
+
module Extractors
|
|
10
|
+
# MailerExtractor handles ActionMailer extraction.
|
|
11
|
+
#
|
|
12
|
+
# Mailers are important for understanding:
|
|
13
|
+
# - What triggers emails (traced via dependencies)
|
|
14
|
+
# - What data flows into emails
|
|
15
|
+
# - Template associations
|
|
16
|
+
#
|
|
17
|
+
# @example
|
|
18
|
+
# extractor = MailerExtractor.new
|
|
19
|
+
# units = extractor.extract_all
|
|
20
|
+
# user_mailer = units.find { |u| u.identifier == "UserMailer" }
|
|
21
|
+
#
|
|
22
|
+
class MailerExtractor
|
|
23
|
+
include AstSourceExtraction
|
|
24
|
+
include SharedUtilityMethods
|
|
25
|
+
include SharedDependencyScanner
|
|
26
|
+
|
|
27
|
+
def initialize
|
|
28
|
+
@mailer_base = defined?(ApplicationMailer) ? ApplicationMailer : ActionMailer::Base
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
# Extract all mailers in the application
|
|
32
|
+
#
|
|
33
|
+
# @return [Array<ExtractedUnit>] List of mailer units
|
|
34
|
+
def extract_all
|
|
35
|
+
@mailer_base.descendants.map do |mailer|
|
|
36
|
+
extract_mailer(mailer)
|
|
37
|
+
end.compact
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
# Extract a single mailer
|
|
41
|
+
#
|
|
42
|
+
# @param mailer [Class] The mailer class
|
|
43
|
+
# @return [ExtractedUnit] The extracted unit
|
|
44
|
+
def extract_mailer(mailer)
|
|
45
|
+
return nil if mailer.name.nil?
|
|
46
|
+
return nil if mailer == ActionMailer::Base
|
|
47
|
+
|
|
48
|
+
file_path = source_file_for(mailer)
|
|
49
|
+
|
|
50
|
+
unit = ExtractedUnit.new(
|
|
51
|
+
type: :mailer,
|
|
52
|
+
identifier: mailer.name,
|
|
53
|
+
file_path: file_path
|
|
54
|
+
)
|
|
55
|
+
|
|
56
|
+
source = file_path && File.exist?(file_path) ? File.read(file_path) : ''
|
|
57
|
+
|
|
58
|
+
unit.namespace = extract_namespace(mailer)
|
|
59
|
+
unit.source_code = annotate_source(source, mailer)
|
|
60
|
+
unit.metadata = extract_metadata(mailer, source)
|
|
61
|
+
unit.dependencies = extract_dependencies(source)
|
|
62
|
+
|
|
63
|
+
# Create chunks for each mail action
|
|
64
|
+
unit.chunks = build_action_chunks(mailer, source)
|
|
65
|
+
|
|
66
|
+
unit
|
|
67
|
+
rescue StandardError => e
|
|
68
|
+
Rails.logger.error("Failed to extract mailer #{mailer.name}: #{e.message}")
|
|
69
|
+
nil
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
private
|
|
73
|
+
|
|
74
|
+
def source_file_for(mailer)
|
|
75
|
+
if mailer.instance_methods(false).any?
|
|
76
|
+
method = mailer.instance_methods(false).first
|
|
77
|
+
mailer.instance_method(method).source_location&.first
|
|
78
|
+
end || Rails.root.join("app/mailers/#{mailer.name.underscore}.rb").to_s
|
|
79
|
+
rescue StandardError
|
|
80
|
+
Rails.root.join("app/mailers/#{mailer.name.underscore}.rb").to_s
|
|
81
|
+
end
|
|
82
|
+
|
|
83
|
+
# ──────────────────────────────────────────────────────────────────────
|
|
84
|
+
# Source Annotation
|
|
85
|
+
# ──────────────────────────────────────────────────────────────────────
|
|
86
|
+
|
|
87
|
+
def annotate_source(source, mailer)
|
|
88
|
+
actions = mailer.action_methods.to_a
|
|
89
|
+
default_from = begin
|
|
90
|
+
mailer.default[:from]
|
|
91
|
+
rescue StandardError
|
|
92
|
+
nil
|
|
93
|
+
end
|
|
94
|
+
|
|
95
|
+
<<~ANNOTATION
|
|
96
|
+
# ╔═══════════════════════════════════════════════════════════════════════╗
|
|
97
|
+
# ║ Mailer: #{mailer.name.ljust(60)}║
|
|
98
|
+
# ║ Actions: #{actions.first(5).join(', ').ljust(59)}║
|
|
99
|
+
# ║ Default From: #{(default_from || 'not set').to_s.ljust(54)}║
|
|
100
|
+
# ╚═══════════════════════════════════════════════════════════════════════╝
|
|
101
|
+
|
|
102
|
+
#{source}
|
|
103
|
+
ANNOTATION
|
|
104
|
+
end
|
|
105
|
+
|
|
106
|
+
# ──────────────────────────────────────────────────────────────────────
|
|
107
|
+
# Metadata Extraction
|
|
108
|
+
# ──────────────────────────────────────────────────────────────────────
|
|
109
|
+
|
|
110
|
+
def extract_metadata(mailer, source)
|
|
111
|
+
actions = mailer.action_methods.to_a
|
|
112
|
+
|
|
113
|
+
{
|
|
114
|
+
# Actions (mail methods)
|
|
115
|
+
actions: actions,
|
|
116
|
+
|
|
117
|
+
# Default settings
|
|
118
|
+
defaults: extract_defaults(mailer),
|
|
119
|
+
|
|
120
|
+
# Delivery configuration
|
|
121
|
+
delivery_method: mailer.delivery_method,
|
|
122
|
+
|
|
123
|
+
# Callbacks
|
|
124
|
+
callbacks: extract_callbacks(mailer),
|
|
125
|
+
|
|
126
|
+
# Layout
|
|
127
|
+
layout: extract_layout(mailer, source),
|
|
128
|
+
|
|
129
|
+
# Helper modules
|
|
130
|
+
helpers: extract_helpers(source),
|
|
131
|
+
|
|
132
|
+
# Templates (if discoverable)
|
|
133
|
+
templates: discover_templates(mailer, actions),
|
|
134
|
+
|
|
135
|
+
# Metrics
|
|
136
|
+
action_count: actions.size,
|
|
137
|
+
loc: source.lines.count { |l| l.strip.present? && !l.strip.start_with?('#') }
|
|
138
|
+
}
|
|
139
|
+
end
|
|
140
|
+
|
|
141
|
+
def extract_defaults(mailer)
|
|
142
|
+
defaults = {}
|
|
143
|
+
|
|
144
|
+
begin
|
|
145
|
+
mailer_defaults = mailer.default
|
|
146
|
+
defaults[:from] = mailer_defaults[:from] if mailer_defaults[:from]
|
|
147
|
+
defaults[:reply_to] = mailer_defaults[:reply_to] if mailer_defaults[:reply_to]
|
|
148
|
+
defaults[:cc] = mailer_defaults[:cc] if mailer_defaults[:cc]
|
|
149
|
+
defaults[:bcc] = mailer_defaults[:bcc] if mailer_defaults[:bcc]
|
|
150
|
+
rescue StandardError
|
|
151
|
+
# Defaults not accessible
|
|
152
|
+
end
|
|
153
|
+
|
|
154
|
+
defaults
|
|
155
|
+
end
|
|
156
|
+
|
|
157
|
+
def extract_callbacks(mailer)
|
|
158
|
+
mailer._process_action_callbacks.map do |cb|
|
|
159
|
+
only, except, if_conds, unless_conds = extract_callback_conditions(cb)
|
|
160
|
+
|
|
161
|
+
result = {
|
|
162
|
+
type: :"#{cb.kind}_action",
|
|
163
|
+
filter: cb.filter.to_s
|
|
164
|
+
}
|
|
165
|
+
result[:only] = only if only.any?
|
|
166
|
+
result[:except] = except if except.any?
|
|
167
|
+
result[:if] = if_conds.join(', ') if if_conds.any?
|
|
168
|
+
result[:unless] = unless_conds.join(', ') if unless_conds.any?
|
|
169
|
+
result
|
|
170
|
+
end
|
|
171
|
+
rescue StandardError
|
|
172
|
+
[]
|
|
173
|
+
end
|
|
174
|
+
|
|
175
|
+
# Extract :only/:except action lists and :if/:unless conditions from a callback.
|
|
176
|
+
#
|
|
177
|
+
# Modern Rails (4.2+) stores conditions in @if/@unless ivar arrays.
|
|
178
|
+
# ActionFilter objects hold action Sets; other conditions are procs/symbols.
|
|
179
|
+
#
|
|
180
|
+
# @param callback [ActiveSupport::Callbacks::Callback]
|
|
181
|
+
# @return [Array(Array<String>, Array<String>, Array<String>, Array<String>)]
|
|
182
|
+
# [only_actions, except_actions, if_labels, unless_labels]
|
|
183
|
+
def extract_callback_conditions(callback)
|
|
184
|
+
if_conditions = callback.instance_variable_get(:@if) || []
|
|
185
|
+
unless_conditions = callback.instance_variable_get(:@unless) || []
|
|
186
|
+
|
|
187
|
+
only = []
|
|
188
|
+
except = []
|
|
189
|
+
if_labels = []
|
|
190
|
+
unless_labels = []
|
|
191
|
+
|
|
192
|
+
if_conditions.each do |cond|
|
|
193
|
+
actions = extract_action_filter_actions(cond)
|
|
194
|
+
if actions
|
|
195
|
+
only.concat(actions)
|
|
196
|
+
else
|
|
197
|
+
if_labels << condition_label(cond)
|
|
198
|
+
end
|
|
199
|
+
end
|
|
200
|
+
|
|
201
|
+
unless_conditions.each do |cond|
|
|
202
|
+
actions = extract_action_filter_actions(cond)
|
|
203
|
+
if actions
|
|
204
|
+
except.concat(actions)
|
|
205
|
+
else
|
|
206
|
+
unless_labels << condition_label(cond)
|
|
207
|
+
end
|
|
208
|
+
end
|
|
209
|
+
|
|
210
|
+
[only, except, if_labels, unless_labels]
|
|
211
|
+
end
|
|
212
|
+
|
|
213
|
+
# Extract action names from an ActionFilter-like condition object.
|
|
214
|
+
# Duck-types on the @actions ivar being a Set, avoiding dependence
|
|
215
|
+
# on private class names across Rails versions.
|
|
216
|
+
#
|
|
217
|
+
# @param condition [Object] A condition from the callback's @if/@unless array
|
|
218
|
+
# @return [Array<String>, nil] Action names, or nil if not an ActionFilter
|
|
219
|
+
def extract_action_filter_actions(condition)
|
|
220
|
+
return nil unless condition.instance_variable_defined?(:@actions)
|
|
221
|
+
|
|
222
|
+
actions = condition.instance_variable_get(:@actions)
|
|
223
|
+
return nil unless actions.is_a?(Set)
|
|
224
|
+
|
|
225
|
+
actions.to_a
|
|
226
|
+
end
|
|
227
|
+
|
|
228
|
+
# Human-readable label for a non-ActionFilter condition.
|
|
229
|
+
#
|
|
230
|
+
# @param condition [Object] A proc, symbol, or other condition
|
|
231
|
+
# @return [String]
|
|
232
|
+
def condition_label(condition)
|
|
233
|
+
case condition
|
|
234
|
+
when Symbol then ":#{condition}"
|
|
235
|
+
when Proc then 'Proc'
|
|
236
|
+
when String then condition
|
|
237
|
+
else condition.class.name
|
|
238
|
+
end
|
|
239
|
+
end
|
|
240
|
+
|
|
241
|
+
def extract_layout(mailer, source)
|
|
242
|
+
# From class definition
|
|
243
|
+
return ::Regexp.last_match(1) if source =~ /layout\s+['":](\w+)/
|
|
244
|
+
|
|
245
|
+
# From class method
|
|
246
|
+
begin
|
|
247
|
+
mailer._layout
|
|
248
|
+
rescue StandardError
|
|
249
|
+
nil
|
|
250
|
+
end
|
|
251
|
+
end
|
|
252
|
+
|
|
253
|
+
def extract_helpers(source)
|
|
254
|
+
helpers = []
|
|
255
|
+
|
|
256
|
+
source.scan(/helper\s+[:\s]?(\w+)/) do |helper|
|
|
257
|
+
helpers << helper[0]
|
|
258
|
+
end
|
|
259
|
+
|
|
260
|
+
source.scan(/include\s+(\w+Helper)/) do |helper|
|
|
261
|
+
helpers << helper[0]
|
|
262
|
+
end
|
|
263
|
+
|
|
264
|
+
helpers.uniq
|
|
265
|
+
end
|
|
266
|
+
|
|
267
|
+
def discover_templates(mailer, actions)
|
|
268
|
+
templates = {}
|
|
269
|
+
mailer_path = mailer.name.underscore
|
|
270
|
+
|
|
271
|
+
actions.each do |action|
|
|
272
|
+
view_paths = [
|
|
273
|
+
Rails.root.join("app/views/#{mailer_path}/#{action}.html.erb"),
|
|
274
|
+
Rails.root.join("app/views/#{mailer_path}/#{action}.text.erb"),
|
|
275
|
+
Rails.root.join("app/views/#{mailer_path}/#{action}.html.slim"),
|
|
276
|
+
Rails.root.join("app/views/#{mailer_path}/#{action}.text.slim")
|
|
277
|
+
]
|
|
278
|
+
|
|
279
|
+
found = view_paths.select { |p| File.exist?(p) }
|
|
280
|
+
.map { |p| p.to_s.sub("#{Rails.root}/", '') }
|
|
281
|
+
|
|
282
|
+
templates[action] = found if found.any?
|
|
283
|
+
end
|
|
284
|
+
|
|
285
|
+
templates
|
|
286
|
+
end
|
|
287
|
+
|
|
288
|
+
# ──────────────────────────────────────────────────────────────────────
|
|
289
|
+
# Dependency Extraction
|
|
290
|
+
# ──────────────────────────────────────────────────────────────────────
|
|
291
|
+
|
|
292
|
+
def extract_dependencies(source)
|
|
293
|
+
deps = []
|
|
294
|
+
deps.concat(scan_model_dependencies(source))
|
|
295
|
+
deps.concat(scan_service_dependencies(source))
|
|
296
|
+
|
|
297
|
+
# URL helpers (indicates what resources emails link to)
|
|
298
|
+
source.scan(/(\w+)_(?:url|path)/).flatten.uniq.each do |route|
|
|
299
|
+
deps << { type: :route, target: route, via: :url_helper }
|
|
300
|
+
end
|
|
301
|
+
|
|
302
|
+
deps.uniq { |d| [d[:type], d[:target]] }
|
|
303
|
+
end
|
|
304
|
+
|
|
305
|
+
# ──────────────────────────────────────────────────────────────────────
|
|
306
|
+
# Action Chunks
|
|
307
|
+
# ──────────────────────────────────────────────────────────────────────
|
|
308
|
+
|
|
309
|
+
def build_action_chunks(mailer, _source)
|
|
310
|
+
mailer.action_methods.filter_map do |action|
|
|
311
|
+
action_source = extract_action_source(mailer, action)
|
|
312
|
+
next if action_source.nil? || action_source.strip.empty?
|
|
313
|
+
|
|
314
|
+
templates = discover_templates(mailer, [action.to_s])[action.to_s] || []
|
|
315
|
+
|
|
316
|
+
chunk_content = <<~ACTION
|
|
317
|
+
# Mailer: #{mailer.name}
|
|
318
|
+
# Action: #{action}
|
|
319
|
+
# Templates: #{templates.any? ? templates.join(', ') : 'none found'}
|
|
320
|
+
|
|
321
|
+
#{action_source}
|
|
322
|
+
ACTION
|
|
323
|
+
|
|
324
|
+
{
|
|
325
|
+
chunk_type: :mail_action,
|
|
326
|
+
identifier: "#{mailer.name}##{action}",
|
|
327
|
+
content: chunk_content,
|
|
328
|
+
content_hash: Digest::SHA256.hexdigest(chunk_content),
|
|
329
|
+
metadata: {
|
|
330
|
+
parent: mailer.name,
|
|
331
|
+
action: action.to_s,
|
|
332
|
+
templates: templates
|
|
333
|
+
}
|
|
334
|
+
}
|
|
335
|
+
end
|
|
336
|
+
end
|
|
337
|
+
end
|
|
338
|
+
end
|
|
339
|
+
end
|