codebase_index 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (171) hide show
  1. checksums.yaml +7 -0
  2. data/CHANGELOG.md +29 -0
  3. data/CODE_OF_CONDUCT.md +83 -0
  4. data/CONTRIBUTING.md +65 -0
  5. data/LICENSE.txt +21 -0
  6. data/README.md +481 -0
  7. data/exe/codebase-console-mcp +22 -0
  8. data/exe/codebase-index-mcp +61 -0
  9. data/exe/codebase-index-mcp-http +64 -0
  10. data/exe/codebase-index-mcp-start +58 -0
  11. data/lib/codebase_index/ast/call_site_extractor.rb +106 -0
  12. data/lib/codebase_index/ast/method_extractor.rb +76 -0
  13. data/lib/codebase_index/ast/node.rb +88 -0
  14. data/lib/codebase_index/ast/parser.rb +653 -0
  15. data/lib/codebase_index/ast.rb +6 -0
  16. data/lib/codebase_index/builder.rb +137 -0
  17. data/lib/codebase_index/chunking/chunk.rb +84 -0
  18. data/lib/codebase_index/chunking/semantic_chunker.rb +290 -0
  19. data/lib/codebase_index/console/adapters/cache_adapter.rb +58 -0
  20. data/lib/codebase_index/console/adapters/good_job_adapter.rb +66 -0
  21. data/lib/codebase_index/console/adapters/sidekiq_adapter.rb +66 -0
  22. data/lib/codebase_index/console/adapters/solid_queue_adapter.rb +66 -0
  23. data/lib/codebase_index/console/audit_logger.rb +75 -0
  24. data/lib/codebase_index/console/bridge.rb +170 -0
  25. data/lib/codebase_index/console/confirmation.rb +90 -0
  26. data/lib/codebase_index/console/connection_manager.rb +173 -0
  27. data/lib/codebase_index/console/console_response_renderer.rb +78 -0
  28. data/lib/codebase_index/console/model_validator.rb +81 -0
  29. data/lib/codebase_index/console/safe_context.rb +82 -0
  30. data/lib/codebase_index/console/server.rb +557 -0
  31. data/lib/codebase_index/console/sql_validator.rb +172 -0
  32. data/lib/codebase_index/console/tools/tier1.rb +118 -0
  33. data/lib/codebase_index/console/tools/tier2.rb +117 -0
  34. data/lib/codebase_index/console/tools/tier3.rb +110 -0
  35. data/lib/codebase_index/console/tools/tier4.rb +79 -0
  36. data/lib/codebase_index/coordination/pipeline_lock.rb +109 -0
  37. data/lib/codebase_index/cost_model/embedding_cost.rb +88 -0
  38. data/lib/codebase_index/cost_model/estimator.rb +128 -0
  39. data/lib/codebase_index/cost_model/provider_pricing.rb +67 -0
  40. data/lib/codebase_index/cost_model/storage_cost.rb +52 -0
  41. data/lib/codebase_index/cost_model.rb +22 -0
  42. data/lib/codebase_index/db/migrations/001_create_units.rb +38 -0
  43. data/lib/codebase_index/db/migrations/002_create_edges.rb +35 -0
  44. data/lib/codebase_index/db/migrations/003_create_embeddings.rb +37 -0
  45. data/lib/codebase_index/db/migrations/004_create_snapshots.rb +45 -0
  46. data/lib/codebase_index/db/migrations/005_create_snapshot_units.rb +40 -0
  47. data/lib/codebase_index/db/migrator.rb +71 -0
  48. data/lib/codebase_index/db/schema_version.rb +73 -0
  49. data/lib/codebase_index/dependency_graph.rb +227 -0
  50. data/lib/codebase_index/embedding/indexer.rb +130 -0
  51. data/lib/codebase_index/embedding/openai.rb +105 -0
  52. data/lib/codebase_index/embedding/provider.rb +135 -0
  53. data/lib/codebase_index/embedding/text_preparer.rb +112 -0
  54. data/lib/codebase_index/evaluation/baseline_runner.rb +115 -0
  55. data/lib/codebase_index/evaluation/evaluator.rb +146 -0
  56. data/lib/codebase_index/evaluation/metrics.rb +79 -0
  57. data/lib/codebase_index/evaluation/query_set.rb +148 -0
  58. data/lib/codebase_index/evaluation/report_generator.rb +90 -0
  59. data/lib/codebase_index/extracted_unit.rb +145 -0
  60. data/lib/codebase_index/extractor.rb +956 -0
  61. data/lib/codebase_index/extractors/action_cable_extractor.rb +228 -0
  62. data/lib/codebase_index/extractors/ast_source_extraction.rb +46 -0
  63. data/lib/codebase_index/extractors/behavioral_profile.rb +309 -0
  64. data/lib/codebase_index/extractors/caching_extractor.rb +261 -0
  65. data/lib/codebase_index/extractors/callback_analyzer.rb +232 -0
  66. data/lib/codebase_index/extractors/concern_extractor.rb +253 -0
  67. data/lib/codebase_index/extractors/configuration_extractor.rb +219 -0
  68. data/lib/codebase_index/extractors/controller_extractor.rb +494 -0
  69. data/lib/codebase_index/extractors/database_view_extractor.rb +278 -0
  70. data/lib/codebase_index/extractors/decorator_extractor.rb +260 -0
  71. data/lib/codebase_index/extractors/engine_extractor.rb +204 -0
  72. data/lib/codebase_index/extractors/event_extractor.rb +211 -0
  73. data/lib/codebase_index/extractors/factory_extractor.rb +289 -0
  74. data/lib/codebase_index/extractors/graphql_extractor.rb +917 -0
  75. data/lib/codebase_index/extractors/i18n_extractor.rb +117 -0
  76. data/lib/codebase_index/extractors/job_extractor.rb +369 -0
  77. data/lib/codebase_index/extractors/lib_extractor.rb +249 -0
  78. data/lib/codebase_index/extractors/mailer_extractor.rb +339 -0
  79. data/lib/codebase_index/extractors/manager_extractor.rb +202 -0
  80. data/lib/codebase_index/extractors/middleware_extractor.rb +133 -0
  81. data/lib/codebase_index/extractors/migration_extractor.rb +469 -0
  82. data/lib/codebase_index/extractors/model_extractor.rb +960 -0
  83. data/lib/codebase_index/extractors/phlex_extractor.rb +252 -0
  84. data/lib/codebase_index/extractors/policy_extractor.rb +214 -0
  85. data/lib/codebase_index/extractors/poro_extractor.rb +246 -0
  86. data/lib/codebase_index/extractors/pundit_extractor.rb +223 -0
  87. data/lib/codebase_index/extractors/rails_source_extractor.rb +473 -0
  88. data/lib/codebase_index/extractors/rake_task_extractor.rb +343 -0
  89. data/lib/codebase_index/extractors/route_extractor.rb +181 -0
  90. data/lib/codebase_index/extractors/scheduled_job_extractor.rb +331 -0
  91. data/lib/codebase_index/extractors/serializer_extractor.rb +334 -0
  92. data/lib/codebase_index/extractors/service_extractor.rb +254 -0
  93. data/lib/codebase_index/extractors/shared_dependency_scanner.rb +91 -0
  94. data/lib/codebase_index/extractors/shared_utility_methods.rb +99 -0
  95. data/lib/codebase_index/extractors/state_machine_extractor.rb +398 -0
  96. data/lib/codebase_index/extractors/test_mapping_extractor.rb +225 -0
  97. data/lib/codebase_index/extractors/validator_extractor.rb +225 -0
  98. data/lib/codebase_index/extractors/view_component_extractor.rb +310 -0
  99. data/lib/codebase_index/extractors/view_template_extractor.rb +261 -0
  100. data/lib/codebase_index/feedback/gap_detector.rb +89 -0
  101. data/lib/codebase_index/feedback/store.rb +119 -0
  102. data/lib/codebase_index/flow_analysis/operation_extractor.rb +209 -0
  103. data/lib/codebase_index/flow_analysis/response_code_mapper.rb +154 -0
  104. data/lib/codebase_index/flow_assembler.rb +290 -0
  105. data/lib/codebase_index/flow_document.rb +191 -0
  106. data/lib/codebase_index/flow_precomputer.rb +102 -0
  107. data/lib/codebase_index/formatting/base.rb +40 -0
  108. data/lib/codebase_index/formatting/claude_adapter.rb +98 -0
  109. data/lib/codebase_index/formatting/generic_adapter.rb +56 -0
  110. data/lib/codebase_index/formatting/gpt_adapter.rb +64 -0
  111. data/lib/codebase_index/formatting/human_adapter.rb +78 -0
  112. data/lib/codebase_index/graph_analyzer.rb +374 -0
  113. data/lib/codebase_index/mcp/index_reader.rb +394 -0
  114. data/lib/codebase_index/mcp/renderers/claude_renderer.rb +81 -0
  115. data/lib/codebase_index/mcp/renderers/json_renderer.rb +17 -0
  116. data/lib/codebase_index/mcp/renderers/markdown_renderer.rb +352 -0
  117. data/lib/codebase_index/mcp/renderers/plain_renderer.rb +240 -0
  118. data/lib/codebase_index/mcp/server.rb +935 -0
  119. data/lib/codebase_index/mcp/tool_response_renderer.rb +62 -0
  120. data/lib/codebase_index/model_name_cache.rb +51 -0
  121. data/lib/codebase_index/notion/client.rb +217 -0
  122. data/lib/codebase_index/notion/exporter.rb +219 -0
  123. data/lib/codebase_index/notion/mapper.rb +39 -0
  124. data/lib/codebase_index/notion/mappers/column_mapper.rb +65 -0
  125. data/lib/codebase_index/notion/mappers/migration_mapper.rb +39 -0
  126. data/lib/codebase_index/notion/mappers/model_mapper.rb +164 -0
  127. data/lib/codebase_index/notion/rate_limiter.rb +68 -0
  128. data/lib/codebase_index/observability/health_check.rb +81 -0
  129. data/lib/codebase_index/observability/instrumentation.rb +34 -0
  130. data/lib/codebase_index/observability/structured_logger.rb +75 -0
  131. data/lib/codebase_index/operator/error_escalator.rb +81 -0
  132. data/lib/codebase_index/operator/pipeline_guard.rb +99 -0
  133. data/lib/codebase_index/operator/status_reporter.rb +80 -0
  134. data/lib/codebase_index/railtie.rb +26 -0
  135. data/lib/codebase_index/resilience/circuit_breaker.rb +99 -0
  136. data/lib/codebase_index/resilience/index_validator.rb +185 -0
  137. data/lib/codebase_index/resilience/retryable_provider.rb +108 -0
  138. data/lib/codebase_index/retrieval/context_assembler.rb +249 -0
  139. data/lib/codebase_index/retrieval/query_classifier.rb +131 -0
  140. data/lib/codebase_index/retrieval/ranker.rb +273 -0
  141. data/lib/codebase_index/retrieval/search_executor.rb +327 -0
  142. data/lib/codebase_index/retriever.rb +160 -0
  143. data/lib/codebase_index/ruby_analyzer/class_analyzer.rb +190 -0
  144. data/lib/codebase_index/ruby_analyzer/dataflow_analyzer.rb +78 -0
  145. data/lib/codebase_index/ruby_analyzer/fqn_builder.rb +18 -0
  146. data/lib/codebase_index/ruby_analyzer/mermaid_renderer.rb +275 -0
  147. data/lib/codebase_index/ruby_analyzer/method_analyzer.rb +143 -0
  148. data/lib/codebase_index/ruby_analyzer/trace_enricher.rb +139 -0
  149. data/lib/codebase_index/ruby_analyzer.rb +87 -0
  150. data/lib/codebase_index/session_tracer/file_store.rb +111 -0
  151. data/lib/codebase_index/session_tracer/middleware.rb +143 -0
  152. data/lib/codebase_index/session_tracer/redis_store.rb +112 -0
  153. data/lib/codebase_index/session_tracer/session_flow_assembler.rb +263 -0
  154. data/lib/codebase_index/session_tracer/session_flow_document.rb +223 -0
  155. data/lib/codebase_index/session_tracer/solid_cache_store.rb +145 -0
  156. data/lib/codebase_index/session_tracer/store.rb +67 -0
  157. data/lib/codebase_index/storage/graph_store.rb +120 -0
  158. data/lib/codebase_index/storage/metadata_store.rb +169 -0
  159. data/lib/codebase_index/storage/pgvector.rb +163 -0
  160. data/lib/codebase_index/storage/qdrant.rb +172 -0
  161. data/lib/codebase_index/storage/vector_store.rb +156 -0
  162. data/lib/codebase_index/temporal/snapshot_store.rb +341 -0
  163. data/lib/codebase_index/version.rb +5 -0
  164. data/lib/codebase_index.rb +223 -0
  165. data/lib/generators/codebase_index/install_generator.rb +32 -0
  166. data/lib/generators/codebase_index/pgvector_generator.rb +37 -0
  167. data/lib/generators/codebase_index/templates/add_pgvector_to_codebase_index.rb.erb +15 -0
  168. data/lib/generators/codebase_index/templates/create_codebase_index_tables.rb.erb +43 -0
  169. data/lib/tasks/codebase_index.rake +583 -0
  170. data/lib/tasks/codebase_index_evaluation.rake +115 -0
  171. metadata +252 -0
@@ -0,0 +1,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