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,960 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'digest'
|
|
4
|
+
require_relative '../ast/parser'
|
|
5
|
+
require_relative 'shared_dependency_scanner'
|
|
6
|
+
require_relative 'callback_analyzer'
|
|
7
|
+
|
|
8
|
+
module CodebaseIndex
|
|
9
|
+
module Extractors
|
|
10
|
+
# ModelExtractor handles ActiveRecord model extraction with:
|
|
11
|
+
# - Inline concern resolution (concerns are embedded, not referenced)
|
|
12
|
+
# - Full callback chain extraction
|
|
13
|
+
# - Association mapping with target models
|
|
14
|
+
# - Schema information as header comments
|
|
15
|
+
# - Automatic chunking for large models
|
|
16
|
+
#
|
|
17
|
+
# This is typically the most important extractor as models represent
|
|
18
|
+
# the core domain and have the most implicit behavior (callbacks, validations, etc.)
|
|
19
|
+
#
|
|
20
|
+
# @example
|
|
21
|
+
# extractor = ModelExtractor.new
|
|
22
|
+
# units = extractor.extract_all
|
|
23
|
+
# user_unit = units.find { |u| u.identifier == "User" }
|
|
24
|
+
#
|
|
25
|
+
class ModelExtractor
|
|
26
|
+
include SharedDependencyScanner
|
|
27
|
+
|
|
28
|
+
AR_INTERNAL_METHOD_PATTERNS = [
|
|
29
|
+
/\A_/, # _run_save_callbacks, _validators, etc.
|
|
30
|
+
/\Aautosave_associated_records_for_/, # autosave_associated_records_for_comments
|
|
31
|
+
/\Avalidate_associated_records_for_/, # validate_associated_records_for_comments
|
|
32
|
+
/\Aafter_(?:add|remove)_for_/, # collection callbacks
|
|
33
|
+
/\Abefore_(?:add|remove)_for_/ # collection callbacks
|
|
34
|
+
].freeze
|
|
35
|
+
|
|
36
|
+
def initialize
|
|
37
|
+
@concern_cache = {}
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
# Extract all ActiveRecord models in the application
|
|
41
|
+
#
|
|
42
|
+
# @return [Array<ExtractedUnit>] List of model units
|
|
43
|
+
def extract_all
|
|
44
|
+
ActiveRecord::Base.descendants
|
|
45
|
+
.reject(&:abstract_class?)
|
|
46
|
+
.reject { |m| m.name.nil? } # Skip anonymous classes
|
|
47
|
+
.reject { |m| habtm_join_model?(m) }
|
|
48
|
+
.map { |model| extract_model(model) }
|
|
49
|
+
.compact
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
# Extract a single model
|
|
53
|
+
#
|
|
54
|
+
# @param model [Class] The ActiveRecord model class
|
|
55
|
+
# @return [ExtractedUnit] The extracted unit
|
|
56
|
+
def extract_model(model)
|
|
57
|
+
unit = ExtractedUnit.new(
|
|
58
|
+
type: :model,
|
|
59
|
+
identifier: model.name,
|
|
60
|
+
file_path: source_file_for(model)
|
|
61
|
+
)
|
|
62
|
+
|
|
63
|
+
source_path = unit.file_path
|
|
64
|
+
source = source_path && File.exist?(source_path) ? File.read(source_path) : nil
|
|
65
|
+
|
|
66
|
+
unit.namespace = model.module_parent.name unless model.module_parent == Object
|
|
67
|
+
unit.source_code = build_composite_source(model, source)
|
|
68
|
+
unit.metadata = extract_metadata(model, source)
|
|
69
|
+
unit.dependencies = extract_dependencies(model, source)
|
|
70
|
+
|
|
71
|
+
# Enrich callbacks with side-effect analysis
|
|
72
|
+
enrich_callbacks_with_side_effects(unit, source)
|
|
73
|
+
|
|
74
|
+
# Build semantic chunks for all models (summary, associations, callbacks, validations)
|
|
75
|
+
unit.chunks = build_chunks(unit)
|
|
76
|
+
|
|
77
|
+
unit
|
|
78
|
+
rescue StandardError => e
|
|
79
|
+
Rails.logger.error("Failed to extract model #{model.name}: #{e.message}")
|
|
80
|
+
nil
|
|
81
|
+
end
|
|
82
|
+
|
|
83
|
+
private
|
|
84
|
+
|
|
85
|
+
# Find the source file for a model, handling STI and namespacing.
|
|
86
|
+
#
|
|
87
|
+
# Falls back to convention-based path when reflection points outside
|
|
88
|
+
# the app (e.g., ActiveRecord::Core#initialize for models that don't
|
|
89
|
+
# override initialize).
|
|
90
|
+
def source_file_for(model)
|
|
91
|
+
app_root = Rails.root.to_s
|
|
92
|
+
convention_path = Rails.root.join("app/models/#{model.name.underscore}.rb").to_s
|
|
93
|
+
|
|
94
|
+
# Tier 1: Instance methods defined directly on this model
|
|
95
|
+
model.instance_methods(false).each do |method_name|
|
|
96
|
+
loc = model.instance_method(method_name).source_location&.first
|
|
97
|
+
return loc if loc&.start_with?(app_root)
|
|
98
|
+
end
|
|
99
|
+
|
|
100
|
+
# Tier 2: Class/singleton methods (catches models with only scopes)
|
|
101
|
+
model.methods(false).each do |method_name|
|
|
102
|
+
loc = model.method(method_name).source_location&.first
|
|
103
|
+
return loc if loc&.start_with?(app_root)
|
|
104
|
+
end
|
|
105
|
+
|
|
106
|
+
# Tier 3: Convention path if file exists
|
|
107
|
+
return convention_path if File.exist?(convention_path)
|
|
108
|
+
|
|
109
|
+
# Tier 4: const_source_location (Ruby 3.0+)
|
|
110
|
+
if Object.respond_to?(:const_source_location)
|
|
111
|
+
loc = Object.const_source_location(model.name)&.first
|
|
112
|
+
return loc if loc&.start_with?(app_root)
|
|
113
|
+
end
|
|
114
|
+
|
|
115
|
+
# Tier 5: Always return convention path — never a gem path
|
|
116
|
+
convention_path
|
|
117
|
+
rescue StandardError
|
|
118
|
+
convention_path
|
|
119
|
+
end
|
|
120
|
+
|
|
121
|
+
# Detect Rails-generated HABTM join models (e.g., Product::HABTM_Categories)
|
|
122
|
+
#
|
|
123
|
+
# @param model [Class] The ActiveRecord model class
|
|
124
|
+
# @return [Boolean] true if the model is an auto-generated HABTM join class
|
|
125
|
+
def habtm_join_model?(model)
|
|
126
|
+
model.name.demodulize.start_with?('HABTM_')
|
|
127
|
+
end
|
|
128
|
+
|
|
129
|
+
# Build composite source with schema header and inlined concerns
|
|
130
|
+
def build_composite_source(model, source = nil)
|
|
131
|
+
parts = []
|
|
132
|
+
|
|
133
|
+
# Schema information as a header comment
|
|
134
|
+
parts << build_schema_comment(model)
|
|
135
|
+
|
|
136
|
+
# Main model source with concerns inlined
|
|
137
|
+
parts << build_model_source_with_concerns(model, source)
|
|
138
|
+
|
|
139
|
+
parts.compact.join("\n\n")
|
|
140
|
+
end
|
|
141
|
+
|
|
142
|
+
# Generate schema comment block with columns, indexes, and foreign keys
|
|
143
|
+
def build_schema_comment(model)
|
|
144
|
+
return nil unless model.table_exists?
|
|
145
|
+
|
|
146
|
+
parts = []
|
|
147
|
+
parts << '# == Schema Information'
|
|
148
|
+
parts << '#'
|
|
149
|
+
parts << "# Table: #{model.table_name}"
|
|
150
|
+
parts << '#'
|
|
151
|
+
parts << '# Columns:'
|
|
152
|
+
parts.concat(format_columns_comment(model))
|
|
153
|
+
parts << '#'
|
|
154
|
+
|
|
155
|
+
indexes = format_indexes_comment(model)
|
|
156
|
+
if indexes.any?
|
|
157
|
+
parts << '# Indexes:'
|
|
158
|
+
parts.concat(indexes)
|
|
159
|
+
parts << '#'
|
|
160
|
+
end
|
|
161
|
+
|
|
162
|
+
foreign_keys = format_foreign_keys_comment(model)
|
|
163
|
+
if foreign_keys.any?
|
|
164
|
+
parts << '# Foreign Keys:'
|
|
165
|
+
parts.concat(foreign_keys)
|
|
166
|
+
end
|
|
167
|
+
|
|
168
|
+
parts.join("\n")
|
|
169
|
+
end
|
|
170
|
+
|
|
171
|
+
def format_columns_comment(model)
|
|
172
|
+
model.columns.map do |col|
|
|
173
|
+
type_info = col.type.to_s
|
|
174
|
+
type_info += "(#{col.limit})" if col.limit
|
|
175
|
+
constraints = []
|
|
176
|
+
constraints << 'NOT NULL' unless col.null
|
|
177
|
+
constraints << "DEFAULT #{col.default.inspect}" if col.default
|
|
178
|
+
constraints << 'PRIMARY KEY' if col.name == model.primary_key
|
|
179
|
+
"# #{col.name.ljust(25)} #{type_info.ljust(15)} #{constraints.join(' ')}"
|
|
180
|
+
end
|
|
181
|
+
end
|
|
182
|
+
|
|
183
|
+
def format_indexes_comment(model)
|
|
184
|
+
ActiveRecord::Base.connection.indexes(model.table_name).map do |idx|
|
|
185
|
+
unique = idx.unique ? ' (unique)' : ''
|
|
186
|
+
"# #{idx.name}: [#{idx.columns.join(', ')}]#{unique}"
|
|
187
|
+
end
|
|
188
|
+
rescue StandardError
|
|
189
|
+
[]
|
|
190
|
+
end
|
|
191
|
+
|
|
192
|
+
def format_foreign_keys_comment(model)
|
|
193
|
+
ActiveRecord::Base.connection.foreign_keys(model.table_name).map do |fk|
|
|
194
|
+
"# #{fk.from_table}.#{fk.column} → #{fk.to_table}"
|
|
195
|
+
end
|
|
196
|
+
rescue StandardError
|
|
197
|
+
[]
|
|
198
|
+
end
|
|
199
|
+
|
|
200
|
+
# Read model source and inline all included concerns
|
|
201
|
+
def build_model_source_with_concerns(model, source = nil)
|
|
202
|
+
if source.nil?
|
|
203
|
+
source_path = source_file_for(model)
|
|
204
|
+
return '' unless source_path && File.exist?(source_path)
|
|
205
|
+
|
|
206
|
+
source = File.read(source_path)
|
|
207
|
+
end
|
|
208
|
+
|
|
209
|
+
# Find all included concerns and inline them
|
|
210
|
+
included_modules = extract_included_modules(model)
|
|
211
|
+
concern_sources = included_modules.filter_map { |mod| concern_source(mod) }
|
|
212
|
+
|
|
213
|
+
if concern_sources.any?
|
|
214
|
+
# Insert concern code as comments showing what's mixed in
|
|
215
|
+
concern_block = concern_sources.map do |name, code|
|
|
216
|
+
indented = code.lines.map { |l| " # #{l.rstrip}" }.join("\n")
|
|
217
|
+
<<~CONCERN
|
|
218
|
+
# ┌─────────────────────────────────────────────────────────────────────┐
|
|
219
|
+
# │ Included from: #{name.ljust(54)}│
|
|
220
|
+
# └─────────────────────────────────────────────────────────────────────┘
|
|
221
|
+
#{indented}
|
|
222
|
+
# ─────────────────────────── End #{name} ───────────────────────────
|
|
223
|
+
CONCERN
|
|
224
|
+
end.join("\n\n")
|
|
225
|
+
|
|
226
|
+
# Insert after class declaration line
|
|
227
|
+
source.sub(/(class\s+#{Regexp.escape(model.name.demodulize)}.*$)/) do
|
|
228
|
+
"#{::Regexp.last_match(1)}\n\n#{concern_block}"
|
|
229
|
+
end
|
|
230
|
+
else
|
|
231
|
+
source
|
|
232
|
+
end
|
|
233
|
+
end
|
|
234
|
+
|
|
235
|
+
# Get modules included specifically in this model (not inherited)
|
|
236
|
+
def extract_included_modules(model)
|
|
237
|
+
app_root = Rails.root.to_s
|
|
238
|
+
model.included_modules.select do |mod|
|
|
239
|
+
next false unless mod.name
|
|
240
|
+
|
|
241
|
+
# Skip obvious non-app modules (from gems/stdlib)
|
|
242
|
+
if Object.respond_to?(:const_source_location)
|
|
243
|
+
loc = Object.const_source_location(mod.name)
|
|
244
|
+
next false if loc && !loc.first&.start_with?(app_root)
|
|
245
|
+
end
|
|
246
|
+
|
|
247
|
+
# Include if it's in app/models/concerns or app/controllers/concerns
|
|
248
|
+
mod.name.include?('Concerns') ||
|
|
249
|
+
# Or if it's namespaced under the model's parent
|
|
250
|
+
mod.name.start_with?("#{model.module_parent}::") ||
|
|
251
|
+
# Or if it's defined within the application
|
|
252
|
+
defined_in_app?(mod)
|
|
253
|
+
end
|
|
254
|
+
end
|
|
255
|
+
|
|
256
|
+
# Check if a module is defined within the Rails application
|
|
257
|
+
#
|
|
258
|
+
# @param mod [Module] The module to check
|
|
259
|
+
# @return [Boolean] true if the module is defined within Rails.root
|
|
260
|
+
def defined_in_app?(mod)
|
|
261
|
+
# Fast path: const_source_location is cheaper than iterating methods
|
|
262
|
+
if mod.respond_to?(:const_source_location) || Object.respond_to?(:const_source_location)
|
|
263
|
+
loc = Object.const_source_location(mod.name)
|
|
264
|
+
return loc.first.start_with?(Rails.root.to_s) if loc
|
|
265
|
+
end
|
|
266
|
+
|
|
267
|
+
# Slow path: check instance method source locations
|
|
268
|
+
mod.instance_methods(false).any? do |method|
|
|
269
|
+
loc = mod.instance_method(method).source_location&.first
|
|
270
|
+
loc&.start_with?(Rails.root.to_s)
|
|
271
|
+
end
|
|
272
|
+
rescue StandardError
|
|
273
|
+
false
|
|
274
|
+
end
|
|
275
|
+
|
|
276
|
+
# Get the source code for a concern, with caching
|
|
277
|
+
def concern_source(mod)
|
|
278
|
+
return @concern_cache[mod.name] if @concern_cache.key?(mod.name)
|
|
279
|
+
|
|
280
|
+
path = concern_path_for(mod)
|
|
281
|
+
return nil unless path && File.exist?(path)
|
|
282
|
+
|
|
283
|
+
@concern_cache[mod.name] = [mod.name, File.read(path)]
|
|
284
|
+
end
|
|
285
|
+
|
|
286
|
+
# Find the file path for a concern
|
|
287
|
+
def concern_path_for(mod)
|
|
288
|
+
possible_paths = [
|
|
289
|
+
Rails.root.join("app/models/concerns/#{mod.name.underscore}.rb"),
|
|
290
|
+
Rails.root.join("app/controllers/concerns/#{mod.name.underscore}.rb"),
|
|
291
|
+
Rails.root.join("lib/#{mod.name.underscore}.rb")
|
|
292
|
+
]
|
|
293
|
+
possible_paths.find { |p| File.exist?(p) }
|
|
294
|
+
end
|
|
295
|
+
|
|
296
|
+
# ──────────────────────────────────────────────────────────────────────
|
|
297
|
+
# Metadata Extraction
|
|
298
|
+
# ──────────────────────────────────────────────────────────────────────
|
|
299
|
+
|
|
300
|
+
# Extract comprehensive metadata for retrieval and filtering
|
|
301
|
+
def extract_metadata(model, source = nil)
|
|
302
|
+
{
|
|
303
|
+
# Core identifiers
|
|
304
|
+
table_name: model.table_name,
|
|
305
|
+
primary_key: model.primary_key,
|
|
306
|
+
|
|
307
|
+
# Relationships and behaviors
|
|
308
|
+
associations: extract_associations(model),
|
|
309
|
+
validations: extract_validations(model),
|
|
310
|
+
callbacks: extract_callbacks(model),
|
|
311
|
+
scopes: extract_scopes(model, source),
|
|
312
|
+
enums: extract_enums(model),
|
|
313
|
+
|
|
314
|
+
# API surface
|
|
315
|
+
class_methods: model.methods(false).sort,
|
|
316
|
+
instance_methods: filter_instance_methods(model.instance_methods(false)).sort,
|
|
317
|
+
|
|
318
|
+
# Inheritance
|
|
319
|
+
sti_column: model.inheritance_column,
|
|
320
|
+
is_sti_base: sti_base?(model),
|
|
321
|
+
is_sti_child: sti_child?(model),
|
|
322
|
+
parent_class: model.superclass.name,
|
|
323
|
+
|
|
324
|
+
# Metrics for retrieval ranking
|
|
325
|
+
loc: count_loc(model, source),
|
|
326
|
+
callback_count: callback_count(model),
|
|
327
|
+
association_count: model.reflect_on_all_associations.size,
|
|
328
|
+
validation_count: model._validators.values.flatten.size,
|
|
329
|
+
|
|
330
|
+
# Schema info
|
|
331
|
+
table_exists: model.table_exists?,
|
|
332
|
+
column_count: model.table_exists? ? model.columns.size : 0,
|
|
333
|
+
column_names: model.table_exists? ? model.column_names : [],
|
|
334
|
+
|
|
335
|
+
# ActiveStorage / ActionText
|
|
336
|
+
active_storage_attachments: extract_active_storage_attachments(source),
|
|
337
|
+
action_text_fields: extract_action_text_fields(source),
|
|
338
|
+
variant_definitions: extract_variant_definitions(source),
|
|
339
|
+
|
|
340
|
+
# Multi-database topology
|
|
341
|
+
database_roles: extract_database_roles(source),
|
|
342
|
+
shard_config: extract_shard_config(source)
|
|
343
|
+
}
|
|
344
|
+
end
|
|
345
|
+
|
|
346
|
+
# Extract ActiveStorage attachment declarations from source.
|
|
347
|
+
#
|
|
348
|
+
# Scans for +has_one_attached+ and +has_many_attached+ declarations.
|
|
349
|
+
#
|
|
350
|
+
# @param source [String, nil] The model source code
|
|
351
|
+
# @return [Array<Hash>] Attachment declarations with :name and :type
|
|
352
|
+
def extract_active_storage_attachments(source)
|
|
353
|
+
return [] unless source
|
|
354
|
+
|
|
355
|
+
attachments = []
|
|
356
|
+
source.scan(/has_one_attached\s+:(\w+)/) { |m| attachments << { name: m.first, type: :has_one_attached } }
|
|
357
|
+
source.scan(/has_many_attached\s+:(\w+)/) { |m| attachments << { name: m.first, type: :has_many_attached } }
|
|
358
|
+
attachments
|
|
359
|
+
end
|
|
360
|
+
|
|
361
|
+
# Extract ActionText rich text field declarations from source.
|
|
362
|
+
#
|
|
363
|
+
# Scans for +has_rich_text+ declarations.
|
|
364
|
+
#
|
|
365
|
+
# @param source [String, nil] The model source code
|
|
366
|
+
# @return [Array<String>] Rich text field names
|
|
367
|
+
def extract_action_text_fields(source)
|
|
368
|
+
return [] unless source
|
|
369
|
+
|
|
370
|
+
source.scan(/has_rich_text\s+:(\w+)/).flatten
|
|
371
|
+
end
|
|
372
|
+
|
|
373
|
+
# Extract ActiveStorage variant definitions from source.
|
|
374
|
+
#
|
|
375
|
+
# Scans for +variant+ declarations inside +with_attached+ blocks.
|
|
376
|
+
#
|
|
377
|
+
# @param source [String, nil] The model source code
|
|
378
|
+
# @return [Array<Hash>] Variant declarations with :name and :options
|
|
379
|
+
def extract_variant_definitions(source)
|
|
380
|
+
return [] unless source
|
|
381
|
+
|
|
382
|
+
source.scan(/variant\s+:(\w+),\s*(.+)/).map do |name, options|
|
|
383
|
+
{ name: name, options: options.strip }
|
|
384
|
+
end
|
|
385
|
+
end
|
|
386
|
+
|
|
387
|
+
# Extract database role configuration from connects_to database: { ... }.
|
|
388
|
+
#
|
|
389
|
+
# Parses +connects_to database:+ declarations and returns a hash of
|
|
390
|
+
# role names to database keys (e.g. +{ writing: :primary, reading: :replica }+).
|
|
391
|
+
#
|
|
392
|
+
# @param source [String, nil] The model source code
|
|
393
|
+
# @return [Hash, nil] Database role map or nil when not configured
|
|
394
|
+
def extract_database_roles(source)
|
|
395
|
+
return nil unless source
|
|
396
|
+
|
|
397
|
+
match = source.match(/connects_to\s+database:\s*\{([^}]+)\}/)
|
|
398
|
+
return nil unless match
|
|
399
|
+
|
|
400
|
+
parse_role_hash(match[1])
|
|
401
|
+
end
|
|
402
|
+
|
|
403
|
+
# Extract shard configuration from connects_to shards: { ... }.
|
|
404
|
+
#
|
|
405
|
+
# Parses +connects_to shards:+ declarations and returns a hash of
|
|
406
|
+
# shard names to their nested database role maps.
|
|
407
|
+
# Uses a nested-brace-aware pattern to capture the full shard hash.
|
|
408
|
+
#
|
|
409
|
+
# @param source [String, nil] The model source code
|
|
410
|
+
# @return [Hash, nil] Shard config map or nil when not configured
|
|
411
|
+
def extract_shard_config(source)
|
|
412
|
+
return nil unless source
|
|
413
|
+
|
|
414
|
+
# Pattern handles one level of inner braces: { shard: { role: :db }, ... }
|
|
415
|
+
match = source.match(/connects_to\s+shards:\s*\{((?:[^{}]|\{[^}]*\})*)\}/)
|
|
416
|
+
return nil unless match
|
|
417
|
+
|
|
418
|
+
shards = {}
|
|
419
|
+
match[1].scan(/(\w+):\s*\{([^}]+)\}/) do |shard_name, roles_str|
|
|
420
|
+
shards[shard_name.to_sym] = parse_role_hash(roles_str)
|
|
421
|
+
end
|
|
422
|
+
shards.empty? ? nil : shards
|
|
423
|
+
end
|
|
424
|
+
|
|
425
|
+
# Parse a key: :value hash string into a symbol-keyed hash.
|
|
426
|
+
#
|
|
427
|
+
# @param hash_str [String] Contents of a Ruby hash literal
|
|
428
|
+
# @return [Hash] Parsed key-value pairs as symbol keys
|
|
429
|
+
def parse_role_hash(hash_str)
|
|
430
|
+
result = {}
|
|
431
|
+
hash_str.scan(/(\w+):\s*:(\w+)/) do |key, value|
|
|
432
|
+
result[key.to_sym] = value.to_sym
|
|
433
|
+
end
|
|
434
|
+
result
|
|
435
|
+
end
|
|
436
|
+
|
|
437
|
+
# Extract all associations with full details
|
|
438
|
+
def extract_associations(model)
|
|
439
|
+
model.reflect_on_all_associations.map do |assoc|
|
|
440
|
+
{
|
|
441
|
+
name: assoc.name,
|
|
442
|
+
type: assoc.macro, # :belongs_to, :has_many, :has_one, :has_and_belongs_to_many
|
|
443
|
+
target: assoc.class_name,
|
|
444
|
+
options: extract_association_options(assoc),
|
|
445
|
+
through: assoc.options[:through],
|
|
446
|
+
polymorphic: assoc.polymorphic?,
|
|
447
|
+
foreign_key: assoc.foreign_key,
|
|
448
|
+
inverse_of: assoc.inverse_of&.name
|
|
449
|
+
}
|
|
450
|
+
end
|
|
451
|
+
end
|
|
452
|
+
|
|
453
|
+
def extract_association_options(assoc)
|
|
454
|
+
assoc.options.slice(
|
|
455
|
+
:dependent, :through, :source, :source_type,
|
|
456
|
+
:foreign_key, :primary_key, :inverse_of,
|
|
457
|
+
:counter_cache, :touch, :optional, :required,
|
|
458
|
+
:class_name, :as, :foreign_type
|
|
459
|
+
)
|
|
460
|
+
end
|
|
461
|
+
|
|
462
|
+
# Extract all validations
|
|
463
|
+
def extract_validations(model)
|
|
464
|
+
model._validators.flat_map do |attribute, validators|
|
|
465
|
+
validators.map do |v|
|
|
466
|
+
entry = {
|
|
467
|
+
attribute: attribute,
|
|
468
|
+
type: v.class.name.demodulize.underscore.sub(/_validator$/, ''),
|
|
469
|
+
options: v.options.except(:if, :unless, :on),
|
|
470
|
+
conditions: format_validation_conditions(v)
|
|
471
|
+
}
|
|
472
|
+
entry[:implicit_belongs_to] = true if implicit_belongs_to_validator?(v)
|
|
473
|
+
entry
|
|
474
|
+
end
|
|
475
|
+
end
|
|
476
|
+
end
|
|
477
|
+
|
|
478
|
+
# Extract all callbacks with their full chain
|
|
479
|
+
def extract_callbacks(model)
|
|
480
|
+
callback_types = %i[
|
|
481
|
+
before_validation after_validation
|
|
482
|
+
before_save after_save around_save
|
|
483
|
+
before_create after_create around_create
|
|
484
|
+
before_update after_update around_update
|
|
485
|
+
before_destroy after_destroy around_destroy
|
|
486
|
+
after_commit after_rollback
|
|
487
|
+
after_initialize after_find
|
|
488
|
+
after_touch
|
|
489
|
+
]
|
|
490
|
+
|
|
491
|
+
callback_types.flat_map do |type|
|
|
492
|
+
callbacks = model.send("_#{type}_callbacks")
|
|
493
|
+
callbacks.map do |cb|
|
|
494
|
+
{
|
|
495
|
+
type: type,
|
|
496
|
+
filter: cb.filter.to_s,
|
|
497
|
+
kind: cb.kind, # :before, :after, :around
|
|
498
|
+
conditions: format_callback_conditions(cb)
|
|
499
|
+
}
|
|
500
|
+
end
|
|
501
|
+
rescue NoMethodError
|
|
502
|
+
[]
|
|
503
|
+
end.compact
|
|
504
|
+
end
|
|
505
|
+
|
|
506
|
+
# Extract scopes with their source if available.
|
|
507
|
+
# Parses the full source with the AST layer to get accurate scope
|
|
508
|
+
# boundaries, falling back to regex line-scanning on parse failure.
|
|
509
|
+
#
|
|
510
|
+
# @param model [Class]
|
|
511
|
+
# @param source [String, nil]
|
|
512
|
+
# @return [Array<Hash>]
|
|
513
|
+
def extract_scopes(model, source = nil)
|
|
514
|
+
if source.nil?
|
|
515
|
+
source_path = source_file_for(model)
|
|
516
|
+
return [] unless source_path && File.exist?(source_path)
|
|
517
|
+
|
|
518
|
+
source = File.read(source_path)
|
|
519
|
+
end
|
|
520
|
+
|
|
521
|
+
lines = source.lines
|
|
522
|
+
|
|
523
|
+
begin
|
|
524
|
+
parser = Ast::Parser.new
|
|
525
|
+
root = parser.parse(source)
|
|
526
|
+
extract_scopes_from_ast(root, lines)
|
|
527
|
+
rescue StandardError
|
|
528
|
+
extract_scopes_by_regex(lines)
|
|
529
|
+
end
|
|
530
|
+
end
|
|
531
|
+
|
|
532
|
+
# Extract scopes using AST node line spans for accurate boundaries.
|
|
533
|
+
#
|
|
534
|
+
# @param root [Ast::Node] Parsed AST root
|
|
535
|
+
# @param lines [Array<String>] Source lines
|
|
536
|
+
# @return [Array<Hash>]
|
|
537
|
+
def extract_scopes_from_ast(root, lines)
|
|
538
|
+
scope_nodes = root.find_all(:send).select { |n| n.method_name == 'scope' }
|
|
539
|
+
|
|
540
|
+
scope_nodes.filter_map do |node|
|
|
541
|
+
name = node.arguments&.first&.to_s&.delete_prefix(':')&.strip
|
|
542
|
+
next if name.nil? || name.empty?
|
|
543
|
+
|
|
544
|
+
if node.line && node.end_line
|
|
545
|
+
start_idx = node.line - 1
|
|
546
|
+
end_idx = node.end_line - 1
|
|
547
|
+
scope_source = lines[start_idx..end_idx].join
|
|
548
|
+
elsif node.line
|
|
549
|
+
scope_source = lines[node.line - 1]
|
|
550
|
+
else
|
|
551
|
+
next
|
|
552
|
+
end
|
|
553
|
+
|
|
554
|
+
{ name: name, source: scope_source }
|
|
555
|
+
end
|
|
556
|
+
end
|
|
557
|
+
|
|
558
|
+
# Fallback: extract scopes by regex when AST parsing fails.
|
|
559
|
+
#
|
|
560
|
+
# @param lines [Array<String>] Source lines
|
|
561
|
+
# @return [Array<Hash>]
|
|
562
|
+
def extract_scopes_by_regex(lines)
|
|
563
|
+
scopes = []
|
|
564
|
+
lines.each do |line|
|
|
565
|
+
scopes << { name: ::Regexp.last_match(1), source: line } if line =~ /\A\s*scope\s+:(\w+)/
|
|
566
|
+
end
|
|
567
|
+
scopes
|
|
568
|
+
end
|
|
569
|
+
|
|
570
|
+
# Extract enum definitions
|
|
571
|
+
def extract_enums(model)
|
|
572
|
+
return {} unless model.respond_to?(:defined_enums)
|
|
573
|
+
|
|
574
|
+
model.defined_enums.transform_values(&:to_h)
|
|
575
|
+
end
|
|
576
|
+
|
|
577
|
+
# ──────────────────────────────────────────────────────────────────────
|
|
578
|
+
# Dependency Extraction
|
|
579
|
+
# ──────────────────────────────────────────────────────────────────────
|
|
580
|
+
|
|
581
|
+
# Extract what this model depends on
|
|
582
|
+
def extract_dependencies(model, source = nil)
|
|
583
|
+
# Associations point to other models
|
|
584
|
+
deps = model.reflect_on_all_associations.map do |assoc|
|
|
585
|
+
{ type: :model, target: assoc.class_name, via: :association }
|
|
586
|
+
end
|
|
587
|
+
|
|
588
|
+
# Parse source for service/mailer/job references
|
|
589
|
+
if source.nil?
|
|
590
|
+
source_path = source_file_for(model)
|
|
591
|
+
source = File.read(source_path) if source_path && File.exist?(source_path)
|
|
592
|
+
end
|
|
593
|
+
|
|
594
|
+
if source
|
|
595
|
+
deps.concat(scan_service_dependencies(source))
|
|
596
|
+
deps.concat(scan_mailer_dependencies(source))
|
|
597
|
+
deps.concat(scan_job_dependencies(source))
|
|
598
|
+
|
|
599
|
+
# Other models (direct references in code, not already captured via association)
|
|
600
|
+
scan_model_dependencies(source).each do |dep|
|
|
601
|
+
next if dep[:target] == model.name
|
|
602
|
+
|
|
603
|
+
deps << dep
|
|
604
|
+
end
|
|
605
|
+
end
|
|
606
|
+
|
|
607
|
+
deps.uniq { |d| [d[:type], d[:target]] }
|
|
608
|
+
end
|
|
609
|
+
|
|
610
|
+
# Enrich callback metadata with side-effect analysis.
|
|
611
|
+
#
|
|
612
|
+
# Uses CallbackAnalyzer to find each callback's method body and
|
|
613
|
+
# classify its side effects (column writes, job enqueues, etc.).
|
|
614
|
+
#
|
|
615
|
+
# @param unit [ExtractedUnit] The model unit with metadata[:callbacks] set
|
|
616
|
+
# @param source [String, nil] The model source code
|
|
617
|
+
def enrich_callbacks_with_side_effects(unit, source)
|
|
618
|
+
return unless source && unit.metadata[:callbacks]&.any?
|
|
619
|
+
|
|
620
|
+
analyzer = CallbackAnalyzer.new(
|
|
621
|
+
source_code: unit.source_code,
|
|
622
|
+
column_names: unit.metadata[:column_names] || []
|
|
623
|
+
)
|
|
624
|
+
|
|
625
|
+
unit.metadata[:callbacks] = unit.metadata[:callbacks].map do |cb|
|
|
626
|
+
analyzer.analyze(cb)
|
|
627
|
+
end
|
|
628
|
+
end
|
|
629
|
+
|
|
630
|
+
# ──────────────────────────────────────────────────────────────────────
|
|
631
|
+
# Chunking (for large models)
|
|
632
|
+
# ──────────────────────────────────────────────────────────────────────
|
|
633
|
+
|
|
634
|
+
# Build semantic chunks for large models
|
|
635
|
+
def build_chunks(unit)
|
|
636
|
+
chunks = []
|
|
637
|
+
|
|
638
|
+
add_chunk(chunks, :summary, unit, build_summary_chunk(unit), :overview)
|
|
639
|
+
if unit.metadata[:associations].any?
|
|
640
|
+
add_chunk(chunks, :associations, unit, build_associations_chunk(unit), :relationships)
|
|
641
|
+
end
|
|
642
|
+
add_chunk(chunks, :callbacks, unit, build_callbacks_chunk(unit), :behavior) if unit.metadata[:callbacks].any?
|
|
643
|
+
if unit.metadata[:callbacks]&.any? { |cb| cb[:side_effects] }
|
|
644
|
+
add_chunk(chunks, :callback_effects, unit, build_callback_effects_chunk(unit), :behavior_analysis)
|
|
645
|
+
end
|
|
646
|
+
if unit.metadata[:validations].any?
|
|
647
|
+
add_chunk(chunks, :validations, unit, build_validations_chunk(unit), :constraints)
|
|
648
|
+
end
|
|
649
|
+
|
|
650
|
+
chunks
|
|
651
|
+
end
|
|
652
|
+
|
|
653
|
+
def add_chunk(chunks, type, unit, content, purpose)
|
|
654
|
+
return if content.nil? || content.empty?
|
|
655
|
+
|
|
656
|
+
chunks << {
|
|
657
|
+
chunk_type: type,
|
|
658
|
+
identifier: "#{unit.identifier}:#{type}",
|
|
659
|
+
content: content,
|
|
660
|
+
content_hash: Digest::SHA256.hexdigest(content),
|
|
661
|
+
metadata: { parent: unit.identifier, purpose: purpose }
|
|
662
|
+
}
|
|
663
|
+
end
|
|
664
|
+
|
|
665
|
+
def build_summary_chunk(unit)
|
|
666
|
+
meta = unit.metadata
|
|
667
|
+
|
|
668
|
+
<<~SUMMARY
|
|
669
|
+
# #{unit.identifier} - Model Summary
|
|
670
|
+
|
|
671
|
+
Table: #{meta[:table_name]}
|
|
672
|
+
Primary Key: #{meta[:primary_key]}
|
|
673
|
+
Columns: #{meta[:column_names].join(', ')}
|
|
674
|
+
|
|
675
|
+
## Associations (#{meta[:associations].size})
|
|
676
|
+
#{meta[:associations].map { |a| "- #{a[:type]} :#{a[:name]} → #{a[:target]}" }.join("\n")}
|
|
677
|
+
|
|
678
|
+
## Key Behaviors
|
|
679
|
+
- Callbacks: #{meta[:callback_count]}
|
|
680
|
+
- Validations: #{meta[:validation_count]}
|
|
681
|
+
- Scopes: #{meta[:scopes].size}
|
|
682
|
+
|
|
683
|
+
## Instance Methods
|
|
684
|
+
#{meta[:instance_methods].first(10).join(', ')}#{'...' if meta[:instance_methods].size > 10}
|
|
685
|
+
SUMMARY
|
|
686
|
+
end
|
|
687
|
+
|
|
688
|
+
def build_associations_chunk(unit)
|
|
689
|
+
meta = unit.metadata
|
|
690
|
+
|
|
691
|
+
lines = meta[:associations].map do |a|
|
|
692
|
+
opts = a[:options].map { |k, v| "#{k}: #{v}" }.join(', ')
|
|
693
|
+
"#{a[:type]} :#{a[:name]}, class: #{a[:target]}#{", #{opts}" unless opts.empty?}"
|
|
694
|
+
end
|
|
695
|
+
|
|
696
|
+
<<~ASSOC
|
|
697
|
+
# #{unit.identifier} - Associations
|
|
698
|
+
|
|
699
|
+
#{lines.join("\n")}
|
|
700
|
+
ASSOC
|
|
701
|
+
end
|
|
702
|
+
|
|
703
|
+
def build_callbacks_chunk(unit)
|
|
704
|
+
meta = unit.metadata
|
|
705
|
+
|
|
706
|
+
grouped = meta[:callbacks].group_by { |c| c[:type] }
|
|
707
|
+
|
|
708
|
+
sections = grouped.map do |type, callbacks|
|
|
709
|
+
callback_lines = callbacks.map { |c| format_callback_line(c) }
|
|
710
|
+
"#{type}:\n#{callback_lines.join("\n")}"
|
|
711
|
+
end
|
|
712
|
+
|
|
713
|
+
<<~CALLBACKS
|
|
714
|
+
# #{unit.identifier} - Callbacks
|
|
715
|
+
|
|
716
|
+
#{sections.join("\n\n")}
|
|
717
|
+
CALLBACKS
|
|
718
|
+
end
|
|
719
|
+
|
|
720
|
+
# Format a single callback line with optional side-effect annotations.
|
|
721
|
+
#
|
|
722
|
+
# @param callback [Hash] Callback hash, optionally with :side_effects
|
|
723
|
+
# @return [String]
|
|
724
|
+
def format_callback_line(callback)
|
|
725
|
+
line = " #{callback[:filter]}"
|
|
726
|
+
|
|
727
|
+
effects = callback[:side_effects]
|
|
728
|
+
return line unless effects
|
|
729
|
+
|
|
730
|
+
annotations = []
|
|
731
|
+
annotations << "writes: #{effects[:columns_written].join(', ')}" if effects[:columns_written]&.any?
|
|
732
|
+
annotations << "enqueues: #{effects[:jobs_enqueued].join(', ')}" if effects[:jobs_enqueued]&.any?
|
|
733
|
+
annotations << "calls: #{effects[:services_called].join(', ')}" if effects[:services_called]&.any?
|
|
734
|
+
annotations << "mails: #{effects[:mailers_triggered].join(', ')}" if effects[:mailers_triggered]&.any?
|
|
735
|
+
annotations << "reads: #{effects[:database_reads].join(', ')}" if effects[:database_reads]&.any?
|
|
736
|
+
|
|
737
|
+
return line if annotations.empty?
|
|
738
|
+
|
|
739
|
+
"#{line} [#{annotations.join('; ')}]"
|
|
740
|
+
end
|
|
741
|
+
|
|
742
|
+
# Build a narrative chunk summarizing callback side effects by lifecycle phase.
|
|
743
|
+
#
|
|
744
|
+
# Groups callbacks with detected side effects by lifecycle event and
|
|
745
|
+
# produces a numbered, human-readable summary of what each callback does.
|
|
746
|
+
#
|
|
747
|
+
# @param unit [ExtractedUnit]
|
|
748
|
+
# @return [String]
|
|
749
|
+
def build_callback_effects_chunk(unit)
|
|
750
|
+
callbacks_with_effects = unit.metadata[:callbacks].select do |cb|
|
|
751
|
+
effects = cb[:side_effects]
|
|
752
|
+
effects && (
|
|
753
|
+
effects[:columns_written]&.any? ||
|
|
754
|
+
effects[:jobs_enqueued]&.any? ||
|
|
755
|
+
effects[:services_called]&.any? ||
|
|
756
|
+
effects[:mailers_triggered]&.any? ||
|
|
757
|
+
effects[:database_reads]&.any?
|
|
758
|
+
)
|
|
759
|
+
end
|
|
760
|
+
|
|
761
|
+
return '' if callbacks_with_effects.empty?
|
|
762
|
+
|
|
763
|
+
grouped = callbacks_with_effects.group_by { |cb| callback_lifecycle_group(cb[:type]) }
|
|
764
|
+
|
|
765
|
+
sections = grouped.map do |group_name, callbacks|
|
|
766
|
+
lines = callbacks.map { |cb| describe_callback_effects(cb) }
|
|
767
|
+
"## #{group_name}\n#{lines.join("\n")}"
|
|
768
|
+
end
|
|
769
|
+
|
|
770
|
+
<<~EFFECTS
|
|
771
|
+
# #{unit.identifier} - Callback Side Effects
|
|
772
|
+
|
|
773
|
+
#{sections.join("\n\n")}
|
|
774
|
+
EFFECTS
|
|
775
|
+
end
|
|
776
|
+
|
|
777
|
+
# Map a callback type to a lifecycle group name.
|
|
778
|
+
#
|
|
779
|
+
# @param type [Symbol]
|
|
780
|
+
# @return [String]
|
|
781
|
+
def callback_lifecycle_group(type)
|
|
782
|
+
case type
|
|
783
|
+
when :before_validation, :after_validation
|
|
784
|
+
'Validation'
|
|
785
|
+
when :before_save, :after_save, :around_save
|
|
786
|
+
'Save Lifecycle'
|
|
787
|
+
when :before_create, :after_create, :around_create
|
|
788
|
+
'Create Lifecycle'
|
|
789
|
+
when :before_update, :after_update, :around_update
|
|
790
|
+
'Update Lifecycle'
|
|
791
|
+
when :before_destroy, :after_destroy, :around_destroy
|
|
792
|
+
'Destroy Lifecycle'
|
|
793
|
+
when :after_commit, :after_rollback
|
|
794
|
+
'After Commit'
|
|
795
|
+
when :after_initialize, :after_find, :after_touch
|
|
796
|
+
'Initialization'
|
|
797
|
+
else
|
|
798
|
+
'Other'
|
|
799
|
+
end
|
|
800
|
+
end
|
|
801
|
+
|
|
802
|
+
# Describe a single callback's side effects in natural language.
|
|
803
|
+
#
|
|
804
|
+
# @param callback [Hash]
|
|
805
|
+
# @return [String]
|
|
806
|
+
def describe_callback_effects(callback)
|
|
807
|
+
effects = callback[:side_effects]
|
|
808
|
+
parts = []
|
|
809
|
+
parts << "writes #{effects[:columns_written].join(', ')}" if effects[:columns_written]&.any?
|
|
810
|
+
parts << "enqueues #{effects[:jobs_enqueued].join(', ')}" if effects[:jobs_enqueued]&.any?
|
|
811
|
+
parts << "calls #{effects[:services_called].join(', ')}" if effects[:services_called]&.any?
|
|
812
|
+
parts << "triggers #{effects[:mailers_triggered].join(', ')}" if effects[:mailers_triggered]&.any?
|
|
813
|
+
parts << "reads via #{effects[:database_reads].join(', ')}" if effects[:database_reads]&.any?
|
|
814
|
+
|
|
815
|
+
"- #{callback[:kind]} #{callback[:type]}: #{callback[:filter]} → #{parts.join(', ')}"
|
|
816
|
+
end
|
|
817
|
+
|
|
818
|
+
def build_validations_chunk(unit)
|
|
819
|
+
meta = unit.metadata
|
|
820
|
+
|
|
821
|
+
grouped = meta[:validations].group_by { |v| v[:attribute] }
|
|
822
|
+
|
|
823
|
+
sections = grouped.map do |attr, validations|
|
|
824
|
+
types = validations.map { |v| v[:type] }.join(', ')
|
|
825
|
+
"#{attr}: #{types}"
|
|
826
|
+
end
|
|
827
|
+
|
|
828
|
+
<<~VALIDATIONS
|
|
829
|
+
# #{unit.identifier} - Validations
|
|
830
|
+
|
|
831
|
+
#{sections.join("\n")}
|
|
832
|
+
VALIDATIONS
|
|
833
|
+
end
|
|
834
|
+
|
|
835
|
+
# ──────────────────────────────────────────────────────────────────────
|
|
836
|
+
# Condition & Filter Helpers
|
|
837
|
+
# ──────────────────────────────────────────────────────────────────────
|
|
838
|
+
|
|
839
|
+
# Human-readable label for a condition (Symbol, Proc, String, etc.)
|
|
840
|
+
#
|
|
841
|
+
# @param condition [Object] A proc, symbol, or other condition
|
|
842
|
+
# @return [String]
|
|
843
|
+
def condition_label(condition)
|
|
844
|
+
case condition
|
|
845
|
+
when Symbol then ":#{condition}"
|
|
846
|
+
when Proc then 'Proc'
|
|
847
|
+
when String then condition
|
|
848
|
+
else condition.class.name
|
|
849
|
+
end
|
|
850
|
+
end
|
|
851
|
+
|
|
852
|
+
# Build conditions hash from validator options, converting Procs to labels
|
|
853
|
+
#
|
|
854
|
+
# @param validator [ActiveModel::Validator]
|
|
855
|
+
# @return [Hash]
|
|
856
|
+
def format_validation_conditions(validator)
|
|
857
|
+
conditions = {}
|
|
858
|
+
conditions[:if] = Array(validator.options[:if]).map { |c| condition_label(c) } if validator.options[:if]
|
|
859
|
+
if validator.options[:unless]
|
|
860
|
+
conditions[:unless] = Array(validator.options[:unless]).map do |c|
|
|
861
|
+
condition_label(c)
|
|
862
|
+
end
|
|
863
|
+
end
|
|
864
|
+
conditions[:on] = validator.options[:on] if validator.options[:on]
|
|
865
|
+
conditions
|
|
866
|
+
end
|
|
867
|
+
|
|
868
|
+
# Build conditions hash from callback ivars (not .options, which doesn't exist)
|
|
869
|
+
#
|
|
870
|
+
# @param callback [ActiveSupport::Callbacks::Callback]
|
|
871
|
+
# @return [Hash]
|
|
872
|
+
def format_callback_conditions(callback)
|
|
873
|
+
conditions = {}
|
|
874
|
+
|
|
875
|
+
if callback.instance_variable_defined?(:@if)
|
|
876
|
+
if_conds = Array(callback.instance_variable_get(:@if))
|
|
877
|
+
conditions[:if] = if_conds.map { |c| condition_label(c) } if if_conds.any?
|
|
878
|
+
end
|
|
879
|
+
|
|
880
|
+
if callback.instance_variable_defined?(:@unless)
|
|
881
|
+
unless_conds = Array(callback.instance_variable_get(:@unless))
|
|
882
|
+
conditions[:unless] = unless_conds.map { |c| condition_label(c) } if unless_conds.any?
|
|
883
|
+
end
|
|
884
|
+
|
|
885
|
+
conditions
|
|
886
|
+
end
|
|
887
|
+
|
|
888
|
+
# Detect Rails-generated implicit belongs_to presence validators
|
|
889
|
+
#
|
|
890
|
+
# @param validator [ActiveModel::Validator]
|
|
891
|
+
# @return [Boolean]
|
|
892
|
+
def implicit_belongs_to_validator?(validator)
|
|
893
|
+
if defined?(ActiveRecord::Validations::PresenceValidator) && !validator.is_a?(ActiveRecord::Validations::PresenceValidator)
|
|
894
|
+
return false
|
|
895
|
+
end
|
|
896
|
+
|
|
897
|
+
loc = validator.class.instance_method(:validate).source_location&.first
|
|
898
|
+
loc && !loc.start_with?(Rails.root.to_s)
|
|
899
|
+
rescue StandardError
|
|
900
|
+
false
|
|
901
|
+
end
|
|
902
|
+
|
|
903
|
+
# Filter out ActiveRecord-internal generated instance methods
|
|
904
|
+
#
|
|
905
|
+
# @param methods [Array<Symbol>]
|
|
906
|
+
# @return [Array<Symbol>]
|
|
907
|
+
def filter_instance_methods(methods)
|
|
908
|
+
methods.reject do |method_name|
|
|
909
|
+
name = method_name.to_s
|
|
910
|
+
AR_INTERNAL_METHOD_PATTERNS.any? { |pattern| pattern.match?(name) }
|
|
911
|
+
end
|
|
912
|
+
end
|
|
913
|
+
|
|
914
|
+
# True STI base detection: requires both descends_from_active_record? AND
|
|
915
|
+
# the inheritance column actually exists in the table
|
|
916
|
+
#
|
|
917
|
+
# @param model [Class]
|
|
918
|
+
# @return [Boolean]
|
|
919
|
+
def sti_base?(model)
|
|
920
|
+
return false unless model.descends_from_active_record?
|
|
921
|
+
return false unless model.table_exists?
|
|
922
|
+
|
|
923
|
+
model.column_names.include?(model.inheritance_column)
|
|
924
|
+
end
|
|
925
|
+
|
|
926
|
+
# Detect STI child classes (superclass is a concrete AR model, not AR::Base)
|
|
927
|
+
#
|
|
928
|
+
# @param model [Class]
|
|
929
|
+
# @return [Boolean]
|
|
930
|
+
def sti_child?(model)
|
|
931
|
+
return false if model.descends_from_active_record?
|
|
932
|
+
|
|
933
|
+
model.superclass < ActiveRecord::Base && model.superclass != ActiveRecord::Base
|
|
934
|
+
end
|
|
935
|
+
|
|
936
|
+
# ──────────────────────────────────────────────────────────────────────
|
|
937
|
+
# Helper methods
|
|
938
|
+
# ──────────────────────────────────────────────────────────────────────
|
|
939
|
+
|
|
940
|
+
def callback_count(model)
|
|
941
|
+
%i[validation save create update destroy commit rollback].sum do |type|
|
|
942
|
+
model.send("_#{type}_callbacks").size
|
|
943
|
+
rescue StandardError
|
|
944
|
+
0
|
|
945
|
+
end
|
|
946
|
+
end
|
|
947
|
+
|
|
948
|
+
def count_loc(model, source = nil)
|
|
949
|
+
if source
|
|
950
|
+
source.lines.count { |l| l.strip.present? && !l.strip.start_with?('#') }
|
|
951
|
+
else
|
|
952
|
+
path = source_file_for(model)
|
|
953
|
+
return 0 unless path && File.exist?(path)
|
|
954
|
+
|
|
955
|
+
File.readlines(path).count { |l| l.strip.present? && !l.strip.start_with?('#') }
|
|
956
|
+
end
|
|
957
|
+
end
|
|
958
|
+
end
|
|
959
|
+
end
|
|
960
|
+
end
|