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,494 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'digest'
4
+ require_relative 'ast_source_extraction'
5
+ require_relative 'shared_utility_methods'
6
+ require_relative 'shared_dependency_scanner'
7
+
8
+ module CodebaseIndex
9
+ module Extractors
10
+ # ControllerExtractor handles ActionController extraction with:
11
+ # - Route mapping (which HTTP endpoints hit which actions)
12
+ # - Before/after action filter chain resolution
13
+ # - Per-action chunking for precise retrieval
14
+ # - Concern inlining
15
+ #
16
+ # Controllers are chunked more aggressively than models because
17
+ # queries are often action-specific ("how does the create action work").
18
+ #
19
+ # @example
20
+ # extractor = ControllerExtractor.new
21
+ # units = extractor.extract_all
22
+ # registrations = units.find { |u| u.identifier == "Users::RegistrationsController" }
23
+ #
24
+ class ControllerExtractor
25
+ include AstSourceExtraction
26
+ include SharedUtilityMethods
27
+ include SharedDependencyScanner
28
+
29
+ def initialize
30
+ @routes_map = build_routes_map
31
+ end
32
+
33
+ # Extract all controllers in the application
34
+ #
35
+ # @return [Array<ExtractedUnit>] List of controller units
36
+ def extract_all
37
+ controllers = ApplicationController.descendants
38
+
39
+ controllers = (controllers + ActionController::API.descendants).uniq if defined?(ActionController::API)
40
+
41
+ controllers.map do |controller|
42
+ extract_controller(controller)
43
+ end.compact
44
+ end
45
+
46
+ # Extract a single controller
47
+ #
48
+ # @param controller [Class] The controller class
49
+ # @return [ExtractedUnit] The extracted unit
50
+ def extract_controller(controller)
51
+ unit = ExtractedUnit.new(
52
+ type: :controller,
53
+ identifier: controller.name,
54
+ file_path: source_file_for(controller)
55
+ )
56
+
57
+ source_path = unit.file_path
58
+ source = source_path && File.exist?(source_path) ? File.read(source_path) : ''
59
+
60
+ unit.namespace = extract_namespace(controller)
61
+ unit.source_code = build_composite_source(controller, source)
62
+ unit.metadata = extract_metadata(controller, source)
63
+ unit.dependencies = extract_dependencies(controller, source)
64
+
65
+ # Controllers benefit from per-action chunks
66
+ unit.chunks = build_action_chunks(controller, unit)
67
+
68
+ unit
69
+ rescue StandardError => e
70
+ Rails.logger.error("[CodebaseIndex] Failed to extract controller #{controller.name}: #{e.class}: #{e.message}")
71
+ Rails.logger.error("[CodebaseIndex] #{e.backtrace&.first(5)&.join("\n ")}")
72
+ nil
73
+ end
74
+
75
+ private
76
+
77
+ # ──────────────────────────────────────────────────────────────────────
78
+ # Route Mapping
79
+ # ──────────────────────────────────────────────────────────────────────
80
+
81
+ # Build a map of controller -> action -> route info from Rails routes
82
+ def build_routes_map
83
+ routes = {}
84
+
85
+ Rails.application.routes.routes.each do |route|
86
+ next unless route.defaults[:controller]
87
+
88
+ controller = "#{route.defaults[:controller].camelize}Controller"
89
+ action = route.defaults[:action]
90
+
91
+ routes[controller] ||= {}
92
+ routes[controller][action] ||= []
93
+ routes[controller][action] << {
94
+ verb: extract_verb(route),
95
+ path: route.path.spec.to_s.gsub('(.:format)', ''),
96
+ name: route.name,
97
+ constraints: route.constraints.except(:request_method)
98
+ }
99
+ end
100
+
101
+ routes
102
+ end
103
+
104
+ def extract_verb(route)
105
+ verb = route.verb
106
+ return verb if verb.is_a?(String)
107
+ return verb.source.gsub(/[\^$]/, '') if verb.respond_to?(:source)
108
+
109
+ verb.to_s
110
+ end
111
+
112
+ # ──────────────────────────────────────────────────────────────────────
113
+ # Source Building
114
+ # ──────────────────────────────────────────────────────────────────────
115
+
116
+ # Find the source file for a controller, validating paths are within Rails.root.
117
+ #
118
+ # Uses a multi-tier strategy to avoid returning gem/vendor paths that appear
119
+ # when controllers include modules from gems (e.g., decent_exposure, appsignal).
120
+ #
121
+ # @param controller [Class] The controller class
122
+ # @return [String] Absolute path to the controller source file
123
+ def source_file_for(controller)
124
+ app_root = Rails.root.to_s
125
+ convention_path = Rails.root.join("app/controllers/#{controller.name.underscore}.rb").to_s
126
+
127
+ # Tier 1: Instance methods defined directly on this controller
128
+ controller.instance_methods(false).each do |method_name|
129
+ loc = controller.instance_method(method_name).source_location&.first
130
+ return loc if loc&.start_with?(app_root)
131
+ end
132
+
133
+ # Tier 2: Class/singleton methods defined on this controller
134
+ controller.methods(false).each do |method_name|
135
+ loc = controller.method(method_name).source_location&.first
136
+ return loc if loc&.start_with?(app_root)
137
+ end
138
+
139
+ # Tier 3: Convention path if file exists
140
+ return convention_path if File.exist?(convention_path)
141
+
142
+ # Tier 4: const_source_location (Ruby 3.0+)
143
+ if Object.respond_to?(:const_source_location)
144
+ loc = Object.const_source_location(controller.name)&.first
145
+ return loc if loc&.start_with?(app_root)
146
+ end
147
+
148
+ # Tier 5: Always return convention path — never a gem path
149
+ convention_path
150
+ rescue StandardError
151
+ Rails.root.join("app/controllers/#{controller.name.underscore}.rb").to_s
152
+ end
153
+
154
+ # Build composite source with routes and filters as headers
155
+ def build_composite_source(controller, source = nil)
156
+ if source.nil?
157
+ source_path = source_file_for(controller)
158
+ return '' unless source_path && File.exist?(source_path)
159
+
160
+ source = File.read(source_path)
161
+ end
162
+
163
+ # Prepend route information
164
+ routes_comment = build_routes_comment(controller)
165
+
166
+ # Prepend before_action chain
167
+ filters_comment = build_filters_comment(controller)
168
+
169
+ "#{routes_comment}\n#{filters_comment}\n#{source}"
170
+ end
171
+
172
+ def build_routes_comment(controller)
173
+ routes = @routes_map[controller.name] || {}
174
+ return '' if routes.empty?
175
+
176
+ lines = routes.flat_map do |action, route_list|
177
+ route_list.map do |info|
178
+ verb = info[:verb].to_s.ljust(7)
179
+ path = info[:path].ljust(45)
180
+ " #{verb} #{path} → ##{action}"
181
+ end
182
+ end
183
+
184
+ <<~ROUTES
185
+ # ╔═══════════════════════════════════════════════════════════════════════╗
186
+ # ║ Routes ║
187
+ # ╚═══════════════════════════════════════════════════════════════════════╝
188
+ #
189
+ #{lines.map { |l| "# #{l}" }.join("\n")}
190
+ #
191
+ ROUTES
192
+ end
193
+
194
+ def build_filters_comment(controller)
195
+ filters = extract_filter_chain(controller)
196
+ return '' if filters.empty?
197
+
198
+ lines = filters.map do |f|
199
+ opts = []
200
+ opts << "only: [#{f[:only].map { |a| ":#{a}" }.join(', ')}]" if f[:only]&.any?
201
+ opts << "except: [#{f[:except].map { |a| ":#{a}" }.join(', ')}]" if f[:except]&.any?
202
+ opts << "if: #{f[:if]}" if f[:if]
203
+
204
+ opts_str = opts.any? ? " (#{opts.join('; ')})" : ''
205
+ " #{f[:kind].to_s.ljust(8)} :#{f[:filter]}#{opts_str}"
206
+ end
207
+
208
+ <<~FILTERS
209
+ # ╔═══════════════════════════════════════════════════════════════════════╗
210
+ # ║ Filter Chain ║
211
+ # ╚═══════════════════════════════════════════════════════════════════════╝
212
+ #
213
+ #{lines.map { |l| "# #{l}" }.join("\n")}
214
+ #
215
+ FILTERS
216
+ end
217
+
218
+ def extract_filter_chain(controller)
219
+ controller._process_action_callbacks.map do |callback|
220
+ only, except, if_conds, unless_conds = extract_callback_conditions(callback)
221
+
222
+ result = { kind: callback.kind, filter: callback.filter }
223
+ result[:only] = only if only.any?
224
+ result[:except] = except if except.any?
225
+ result[:if] = if_conds.join(', ') if if_conds.any?
226
+ result[:unless] = unless_conds.join(', ') if unless_conds.any?
227
+ result
228
+ end
229
+ end
230
+
231
+ # Extract :only/:except action lists and :if/:unless conditions from a callback.
232
+ #
233
+ # Modern Rails (4.2+) stores conditions in @if/@unless ivar arrays.
234
+ # ActionFilter objects hold action Sets; other conditions are procs/symbols.
235
+ #
236
+ # @param callback [ActiveSupport::Callbacks::Callback]
237
+ # @return [Array(Array<String>, Array<String>, Array<String>, Array<String>)]
238
+ # [only_actions, except_actions, if_labels, unless_labels]
239
+ def extract_callback_conditions(callback)
240
+ if_conditions = callback.instance_variable_get(:@if) || []
241
+ unless_conditions = callback.instance_variable_get(:@unless) || []
242
+
243
+ only = []
244
+ except = []
245
+ if_labels = []
246
+ unless_labels = []
247
+
248
+ if_conditions.each do |cond|
249
+ actions = extract_action_filter_actions(cond)
250
+ if actions
251
+ only.concat(actions)
252
+ else
253
+ if_labels << condition_label(cond)
254
+ end
255
+ end
256
+
257
+ unless_conditions.each do |cond|
258
+ actions = extract_action_filter_actions(cond)
259
+ if actions
260
+ except.concat(actions)
261
+ else
262
+ unless_labels << condition_label(cond)
263
+ end
264
+ end
265
+
266
+ [only, except, if_labels, unless_labels]
267
+ end
268
+
269
+ # Extract action names from an ActionFilter-like condition object.
270
+ # Duck-types on the @actions ivar being a Set, avoiding dependence
271
+ # on private class names across Rails versions.
272
+ #
273
+ # @param condition [Object] A condition from the callback's @if/@unless array
274
+ # @return [Array<String>, nil] Action names, or nil if not an ActionFilter
275
+ def extract_action_filter_actions(condition)
276
+ return nil unless condition.instance_variable_defined?(:@actions)
277
+
278
+ actions = condition.instance_variable_get(:@actions)
279
+ return nil unless actions.is_a?(Set)
280
+
281
+ actions.to_a
282
+ end
283
+
284
+ # Human-readable label for a non-ActionFilter condition.
285
+ #
286
+ # @param condition [Object] A proc, symbol, or other condition
287
+ # @return [String]
288
+ def condition_label(condition)
289
+ case condition
290
+ when Symbol then ":#{condition}"
291
+ when Proc then 'Proc'
292
+ when String then condition
293
+ else condition.class.name
294
+ end
295
+ end
296
+
297
+ # ──────────────────────────────────────────────────────────────────────
298
+ # Metadata Extraction
299
+ # ──────────────────────────────────────────────────────────────────────
300
+
301
+ # Extract comprehensive metadata
302
+ def extract_metadata(controller, source = nil)
303
+ own_methods = controller.instance_methods(false).to_set(&:to_s)
304
+ actions = controller.action_methods.select { |m| own_methods.include?(m) }.to_a
305
+
306
+ {
307
+ # Actions and routes
308
+ actions: actions,
309
+ routes: @routes_map[controller.name] || {},
310
+
311
+ # Filter chain
312
+ filters: extract_filter_chain(controller),
313
+
314
+ # Parent chain for understanding inherited behavior
315
+ ancestors: controller.ancestors
316
+ .take_while { |a| a != ActionController::Base && a != ActionController::API }
317
+ .grep(Class)
318
+ .map(&:name)
319
+ .compact,
320
+
321
+ # Concerns included
322
+ included_concerns: extract_included_concerns(controller),
323
+
324
+ # Response formats
325
+ responds_to: extract_respond_formats(controller, source),
326
+
327
+ # Metrics
328
+ action_count: actions.size,
329
+ filter_count: controller._process_action_callbacks.count,
330
+
331
+ # Strong parameters if definable
332
+ permitted_params: extract_permitted_params(controller, source)
333
+ }
334
+ end
335
+
336
+ def extract_included_concerns(controller)
337
+ controller.included_modules
338
+ .select { |m| m.name&.include?('Concern') || m.name&.include?('Concerns') }
339
+ .map(&:name)
340
+ end
341
+
342
+ def extract_respond_formats(controller, source = nil)
343
+ if source.nil?
344
+ source_path = source_file_for(controller)
345
+ return [] unless source_path && File.exist?(source_path)
346
+
347
+ source = File.read(source_path)
348
+ end
349
+
350
+ formats = []
351
+
352
+ formats << :html if source.include?('respond_to do') || !source.include?('respond_to')
353
+ formats << :json if source.include?(':json') || source.include?('render json:')
354
+ formats << :xml if source.include?(':xml') || source.include?('render xml:')
355
+ formats << :turbo_stream if source.include?('turbo_stream')
356
+
357
+ formats.uniq
358
+ end
359
+
360
+ def extract_permitted_params(controller, source = nil)
361
+ if source.nil?
362
+ source_path = source_file_for(controller)
363
+ return {} unless source_path && File.exist?(source_path)
364
+
365
+ source = File.read(source_path)
366
+ end
367
+
368
+ params = {}
369
+
370
+ # Match params.require(:x).permit(...) patterns
371
+ source.scan(/def\s+(\w+_params).*?params\.require\(:(\w+)\)\.permit\((.*?)\)/m) do |method, model, permitted|
372
+ params[method] = {
373
+ model: model,
374
+ permitted: permitted.scan(/:(\w+)/).flatten
375
+ }
376
+ end
377
+
378
+ params
379
+ end
380
+
381
+ # ──────────────────────────────────────────────────────────────────────
382
+ # Dependency Extraction
383
+ # ──────────────────────────────────────────────────────────────────────
384
+
385
+ def extract_dependencies(controller, source = nil)
386
+ deps = []
387
+
388
+ if source.nil?
389
+ source_path = source_file_for(controller)
390
+ source = File.read(source_path) if source_path && File.exist?(source_path)
391
+ end
392
+
393
+ if source
394
+ deps.concat(scan_common_dependencies(source))
395
+
396
+ # Phlex component references
397
+ source.scan(/render\s+(\w+(?:::\w+)*Component)/).flatten.uniq.each do |component|
398
+ deps << { type: :component, target: component, via: :render }
399
+ end
400
+
401
+ # Other view renders
402
+ source.scan(%r{render\s+["'](\w+/\w+)["']}).flatten.uniq.each do |template|
403
+ deps << { type: :view, target: template, via: :render }
404
+ end
405
+ end
406
+
407
+ deps.uniq { |d| [d[:type], d[:target]] }
408
+ end
409
+
410
+ # ──────────────────────────────────────────────────────────────────────
411
+ # Per-Action Chunking
412
+ # ──────────────────────────────────────────────────────────────────────
413
+
414
+ # Build per-action chunks for precise retrieval
415
+ def build_action_chunks(controller, unit)
416
+ controller.action_methods.filter_map do |action|
417
+ route_info = @routes_map.dig(controller.name, action.to_s)
418
+ filters = applicable_filters(controller, action)
419
+
420
+ # Extract just this action's source
421
+ action_source = extract_action_source(controller, action)
422
+ next if action_source.nil? || action_source.strip.empty?
423
+
424
+ route_desc = if route_info&.any?
425
+ route_info.map { |r| "#{r[:verb]} #{r[:path]}" }.join(', ')
426
+ else
427
+ 'No direct route'
428
+ end
429
+
430
+ chunk_content = <<~ACTION
431
+ # Controller: #{controller.name}
432
+ # Action: #{action}
433
+ # Route: #{route_desc}
434
+ # Filters: #{filters.map { |f| "#{f[:kind]}(:#{f[:filter]})" }.join(', ').presence || 'none'}
435
+
436
+ #{action_source}
437
+ ACTION
438
+
439
+ {
440
+ chunk_type: :action,
441
+ identifier: "#{controller.name}##{action}",
442
+ content: chunk_content,
443
+ content_hash: Digest::SHA256.hexdigest(chunk_content),
444
+ metadata: {
445
+ parent: unit.identifier,
446
+ action: action.to_s,
447
+ route: route_info,
448
+ filters: filters,
449
+ http_methods: route_info&.map { |r| r[:verb] }&.uniq || []
450
+ }
451
+ }
452
+ end
453
+ end
454
+
455
+ def applicable_filters(controller, action)
456
+ action_name = action.to_s
457
+
458
+ applicable = controller._process_action_callbacks.select do |cb|
459
+ callback_applies_to_action?(cb, action_name)
460
+ end
461
+ applicable.map { |cb| { kind: cb.kind, filter: cb.filter } }
462
+ end
463
+
464
+ # Determine if a callback applies to a given action name.
465
+ #
466
+ # Checks ActionFilter objects in @if (only) and @unless (except).
467
+ # Non-ActionFilter conditions (procs, symbols) are assumed true.
468
+ #
469
+ # @param callback [ActiveSupport::Callbacks::Callback]
470
+ # @param action_name [String]
471
+ # @return [Boolean]
472
+ def callback_applies_to_action?(callback, action_name)
473
+ if_conditions = callback.instance_variable_get(:@if) || []
474
+ unless_conditions = callback.instance_variable_get(:@unless) || []
475
+
476
+ # Check @if conditions — all must pass for the callback to apply
477
+ if_conditions.each do |cond|
478
+ actions = extract_action_filter_actions(cond)
479
+ next unless actions # skip non-ActionFilter conditions (assume true)
480
+ return false unless actions.include?(action_name)
481
+ end
482
+
483
+ # Check @unless conditions — if any match, callback doesn't apply
484
+ unless_conditions.each do |cond|
485
+ actions = extract_action_filter_actions(cond)
486
+ next unless actions
487
+ return false if actions.include?(action_name)
488
+ end
489
+
490
+ true
491
+ end
492
+ end
493
+ end
494
+ end