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