codebase_index 0.2.1 → 0.3.1

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 (89) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +60 -0
  3. data/README.md +95 -300
  4. data/exe/codebase-index-mcp +3 -31
  5. data/exe/codebase-index-mcp-http +3 -31
  6. data/lib/codebase_index/ast/method_extractor.rb +3 -8
  7. data/lib/codebase_index/ast/node.rb +28 -0
  8. data/lib/codebase_index/ast/parser.rb +53 -92
  9. data/lib/codebase_index/builder.rb +67 -4
  10. data/lib/codebase_index/cache/cache_middleware.rb +199 -0
  11. data/lib/codebase_index/cache/cache_store.rb +264 -0
  12. data/lib/codebase_index/cache/redis_cache_store.rb +116 -0
  13. data/lib/codebase_index/cache/solid_cache_store.rb +111 -0
  14. data/lib/codebase_index/chunking/semantic_chunker.rb +29 -24
  15. data/lib/codebase_index/console/adapters/good_job_adapter.rb +7 -40
  16. data/lib/codebase_index/console/adapters/job_adapter.rb +68 -0
  17. data/lib/codebase_index/console/adapters/sidekiq_adapter.rb +7 -40
  18. data/lib/codebase_index/console/adapters/solid_queue_adapter.rb +7 -40
  19. data/lib/codebase_index/console/bridge.rb +7 -0
  20. data/lib/codebase_index/console/console_response_renderer.rb +3 -7
  21. data/lib/codebase_index/console/embedded_executor.rb +2 -1
  22. data/lib/codebase_index/console/server.rb +1 -4
  23. data/lib/codebase_index/dependency_graph.rb +28 -19
  24. data/lib/codebase_index/embedding/indexer.rb +18 -8
  25. data/lib/codebase_index/embedding/openai.rb +27 -6
  26. data/lib/codebase_index/embedding/provider.rb +29 -2
  27. data/lib/codebase_index/evaluation/evaluator.rb +5 -12
  28. data/lib/codebase_index/extractor.rb +40 -44
  29. data/lib/codebase_index/extractors/action_cable_extractor.rb +9 -36
  30. data/lib/codebase_index/extractors/callback_analyzer.rb +22 -8
  31. data/lib/codebase_index/extractors/controller_extractor.rb +3 -93
  32. data/lib/codebase_index/extractors/decorator_extractor.rb +7 -14
  33. data/lib/codebase_index/extractors/engine_extractor.rb +20 -1
  34. data/lib/codebase_index/extractors/graphql_extractor.rb +4 -29
  35. data/lib/codebase_index/extractors/job_extractor.rb +11 -6
  36. data/lib/codebase_index/extractors/lib_extractor.rb +0 -31
  37. data/lib/codebase_index/extractors/mailer_extractor.rb +15 -85
  38. data/lib/codebase_index/extractors/manager_extractor.rb +1 -15
  39. data/lib/codebase_index/extractors/model_extractor.rb +20 -53
  40. data/lib/codebase_index/extractors/phlex_extractor.rb +8 -8
  41. data/lib/codebase_index/extractors/policy_extractor.rb +1 -24
  42. data/lib/codebase_index/extractors/poro_extractor.rb +0 -17
  43. data/lib/codebase_index/extractors/serializer_extractor.rb +12 -7
  44. data/lib/codebase_index/extractors/service_extractor.rb +1 -38
  45. data/lib/codebase_index/extractors/shared_utility_methods.rb +183 -1
  46. data/lib/codebase_index/extractors/validator_extractor.rb +3 -17
  47. data/lib/codebase_index/extractors/view_component_extractor.rb +10 -9
  48. data/lib/codebase_index/filename_utils.rb +32 -0
  49. data/lib/codebase_index/flow_analysis/operation_extractor.rb +1 -4
  50. data/lib/codebase_index/formatting/base.rb +0 -10
  51. data/lib/codebase_index/graph_analyzer.rb +1 -1
  52. data/lib/codebase_index/mcp/bootstrapper.rb +58 -0
  53. data/lib/codebase_index/mcp/renderers/markdown_renderer.rb +35 -34
  54. data/lib/codebase_index/mcp/renderers/plain_renderer.rb +29 -29
  55. data/lib/codebase_index/mcp/server.rb +59 -68
  56. data/lib/codebase_index/mcp/tool_response_renderer.rb +23 -0
  57. data/lib/codebase_index/notion/client.rb +2 -2
  58. data/lib/codebase_index/notion/mapper.rb +1 -0
  59. data/lib/codebase_index/notion/mappers/column_mapper.rb +3 -11
  60. data/lib/codebase_index/notion/mappers/model_mapper.rb +20 -23
  61. data/lib/codebase_index/notion/mappers/shared.rb +22 -0
  62. data/lib/codebase_index/observability/health_check.rb +0 -2
  63. data/lib/codebase_index/observability/structured_logger.rb +12 -30
  64. data/lib/codebase_index/operator/pipeline_guard.rb +0 -7
  65. data/lib/codebase_index/resilience/index_validator.rb +3 -21
  66. data/lib/codebase_index/retrieval/context_assembler.rb +19 -7
  67. data/lib/codebase_index/retrieval/query_classifier.rb +14 -12
  68. data/lib/codebase_index/retrieval/ranker.rb +6 -2
  69. data/lib/codebase_index/retrieval/search_executor.rb +8 -19
  70. data/lib/codebase_index/retriever.rb +1 -9
  71. data/lib/codebase_index/ruby_analyzer/class_analyzer.rb +5 -25
  72. data/lib/codebase_index/ruby_analyzer/dataflow_analyzer.rb +6 -7
  73. data/lib/codebase_index/ruby_analyzer/mermaid_renderer.rb +58 -53
  74. data/lib/codebase_index/ruby_analyzer/trace_enricher.rb +11 -7
  75. data/lib/codebase_index/session_tracer/file_store.rb +1 -8
  76. data/lib/codebase_index/session_tracer/redis_store.rb +1 -7
  77. data/lib/codebase_index/session_tracer/session_flow_assembler.rb +4 -13
  78. data/lib/codebase_index/session_tracer/solid_cache_store.rb +1 -7
  79. data/lib/codebase_index/session_tracer/store.rb +14 -0
  80. data/lib/codebase_index/storage/metadata_store.rb +37 -10
  81. data/lib/codebase_index/storage/pgvector.rb +37 -5
  82. data/lib/codebase_index/storage/qdrant.rb +39 -6
  83. data/lib/codebase_index/storage/vector_store.rb +11 -0
  84. data/lib/codebase_index/temporal/snapshot_store.rb +14 -10
  85. data/lib/codebase_index/token_utils.rb +19 -0
  86. data/lib/codebase_index/version.rb +1 -1
  87. data/lib/codebase_index.rb +25 -6
  88. data/lib/tasks/codebase_index.rake +2 -2
  89. metadata +11 -2
@@ -7,6 +7,7 @@ require 'open3'
7
7
  require 'pathname'
8
8
  require 'set'
9
9
 
10
+ require_relative 'filename_utils'
10
11
  require_relative 'extracted_unit'
11
12
  require_relative 'dependency_graph'
12
13
  require_relative 'extractors/model_extractor'
@@ -62,6 +63,8 @@ module CodebaseIndex
62
63
  # extractor.extract_changed(["app/models/user.rb", "app/services/checkout.rb"])
63
64
  #
64
65
  class Extractor
66
+ include FilenameUtils
67
+
65
68
  # Directories under app/ that contain classes we need to extract.
66
69
  # Used by eager_load_extraction_directories as a fallback when
67
70
  # Rails.application.eager_load! fails (e.g., NameError from graphql/).
@@ -443,19 +446,24 @@ module CodebaseIndex
443
446
  # ──────────────────────────────────────────────────────────────────────
444
447
 
445
448
  def resolve_dependents
446
- all_units = @results.values.flatten
447
- unit_map = all_units.index_by(&:identifier)
448
-
449
- all_units.each do |unit|
450
- unit.dependencies.each do |dep|
451
- target_unit = unit_map[dep[:target]]
452
- next unless target_unit
453
-
454
- target_unit.dependents ||= []
455
- target_unit.dependents << {
456
- type: unit.type,
457
- identifier: unit.identifier
458
- }
449
+ # Build complete unit map first (cross-type dependencies require all units indexed).
450
+ unit_map = @results.each_with_object({}) do |(_type, units), map|
451
+ units.each { |u| map[u.identifier] = u }
452
+ end
453
+
454
+ # Resolve dependents using the complete map.
455
+ @results.each_value do |units|
456
+ units.each do |unit|
457
+ unit.dependencies.each do |dep|
458
+ target_unit = unit_map[dep[:target]]
459
+ next unless target_unit
460
+
461
+ target_unit.dependents ||= []
462
+ target_unit.dependents << {
463
+ type: unit.type,
464
+ identifier: unit.identifier
465
+ }
466
+ end
459
467
  end
460
468
  end
461
469
  end
@@ -480,7 +488,7 @@ module CodebaseIndex
480
488
  # ──────────────────────────────────────────────────────────────────────
481
489
 
482
490
  def precompute_flows
483
- all_units = @results.values.flatten
491
+ all_units = @results.values.flatten(1)
484
492
  precomputer = FlowPrecomputer.new(units: all_units, graph: @dependency_graph, output_dir: @output_dir.to_s)
485
493
  flow_map = precomputer.precompute
486
494
  Rails.logger.info "[CodebaseIndex] Precomputed #{flow_map.size} request flows"
@@ -585,6 +593,7 @@ module CodebaseIndex
585
593
  result = {}
586
594
  relative_paths.each { |rp| result[rp] = {} }
587
595
 
596
+ path_set = relative_paths.to_set
588
597
  relative_paths.each_slice(500) do |batch|
589
598
  log_output = run_git(
590
599
  'log', '--all', '--name-only',
@@ -592,7 +601,7 @@ module CodebaseIndex
592
601
  '--since=365 days ago',
593
602
  '--', *batch
594
603
  )
595
- parse_git_log_output(log_output, relative_paths.to_set, result)
604
+ parse_git_log_output(log_output, path_set, result)
596
605
  end
597
606
 
598
607
  ninety_days_ago = (Time.current - 90.days).iso8601
@@ -708,9 +717,16 @@ module CodebaseIndex
708
717
  def write_graph_analysis
709
718
  return unless @graph_analysis
710
719
 
720
+ enriched = @graph_analysis.merge(
721
+ generated_at: Time.current.iso8601,
722
+ graph_sha: Digest::SHA256.hexdigest(
723
+ File.read(@output_dir.join('dependency_graph.json'))
724
+ )
725
+ )
726
+
711
727
  File.write(
712
728
  @output_dir.join('graph_analysis.json'),
713
- json_serialize(@graph_analysis)
729
+ json_serialize(enriched)
714
730
  )
715
731
  end
716
732
 
@@ -725,7 +741,7 @@ module CodebaseIndex
725
741
 
726
742
  # Total stats
727
743
  total_units: @results.values.sum(&:size),
728
- total_chunks: @results.values.flatten.sum { |u| u.chunks.size },
744
+ total_chunks: @results.sum { |_, units| units.sum { |u| u.chunks.size } },
729
745
 
730
746
  # Git info
731
747
  git_sha: run_git('rev-parse', 'HEAD').presence,
@@ -753,7 +769,7 @@ module CodebaseIndex
753
769
  return if @results.empty?
754
770
 
755
771
  total_units = @results.values.sum(&:size)
756
- total_chunks = @results.values.flatten.sum { |u| [u.chunks.size, 1].max }
772
+ total_chunks = @results.sum { |_, units| units.sum { |u| [u.chunks.size, 1].max } }
757
773
  category_count = @results.count { |_, units| units.any? }
758
774
 
759
775
  summary = []
@@ -841,31 +857,11 @@ module CodebaseIndex
841
857
  end
842
858
 
843
859
  def schema_sha
844
- schema_path = Rails.root.join('db/schema.rb')
845
- return nil unless schema_path.exist?
846
-
847
- Digest::SHA256.file(schema_path).hexdigest
848
- end
849
-
850
- # Generate a safe JSON filename from a unit identifier.
851
- #
852
- # @param identifier [String] Unit identifier (e.g., "Admin::UsersController")
853
- # @return [String] Safe filename (e.g., "Admin__UsersController.json")
854
- def safe_filename(identifier)
855
- "#{identifier.gsub('::', '__').gsub(/[^a-zA-Z0-9_-]/, '_')}.json"
856
- end
857
-
858
- # Generate a collision-safe JSON filename by appending a short digest.
859
- # Unlike safe_filename, this guarantees distinct filenames even when two
860
- # identifiers differ only in characters that safe_filename normalizes
861
- # (e.g., "GET /foo/bar" vs "GET /foo_bar" both become "GET__foo_bar.json").
862
- #
863
- # @param identifier [String] Unit identifier
864
- # @return [String] Collision-safe filename (e.g., "GET__foo_bar_a1b2c3d4.json")
865
- def collision_safe_filename(identifier)
866
- base = identifier.gsub('::', '__').gsub(/[^a-zA-Z0-9_-]/, '_')
867
- digest = ::Digest::SHA256.hexdigest(identifier)[0, 8]
868
- "#{base}_#{digest}.json"
860
+ %w[db/schema.rb db/structure.sql].each do |path|
861
+ full = Rails.root.join(path)
862
+ return Digest::SHA256.file(full).hexdigest if full.exist?
863
+ end
864
+ nil
869
865
  end
870
866
 
871
867
  def json_serialize(data)
@@ -878,7 +874,7 @@ module CodebaseIndex
878
874
 
879
875
  def log_summary
880
876
  total = @results.values.sum(&:size)
881
- chunks = @results.values.flatten.sum { |u| u.chunks.size }
877
+ chunks = @results.sum { |_, units| units.sum { |u| u.chunks.size } }
882
878
 
883
879
  Rails.logger.info '[CodebaseIndex] ═══════════════════════════════════════════'
884
880
  Rails.logger.info '[CodebaseIndex] Extraction Complete'
@@ -50,7 +50,7 @@ module CodebaseIndex
50
50
  # @return [ExtractedUnit, nil]
51
51
  def extract_channel(klass)
52
52
  name = klass.name
53
- file_path = discover_source_path(klass, name)
53
+ file_path = source_file_for(klass, name)
54
54
  source = read_source(file_path)
55
55
  own_methods = klass.instance_methods(false)
56
56
 
@@ -89,48 +89,21 @@ module CodebaseIndex
89
89
  end
90
90
  end
91
91
 
92
- # Discover the source file path for a channel class.
92
+ # Locate the source file for a channel class.
93
93
  #
94
- # Tries source_location on instance methods, then falls back to
95
- # the Rails convention path.
94
+ # Convention path first, then introspection via {#resolve_source_location}
95
+ # which filters out vendor/node_modules paths.
96
96
  #
97
97
  # @param klass [Class] The channel class
98
98
  # @param name [String] The channel class name
99
99
  # @return [String, nil]
100
- def discover_source_path(klass, name)
101
- path = source_location_from_methods(klass)
102
- return path if path
103
-
104
- convention_fallback(name)
105
- end
106
-
107
- # Try to get source_location from the channel's instance methods.
108
- # Tries subscribed first, then any other instance method.
109
- #
110
- # @param klass [Class] The channel class
111
- # @return [String, nil]
112
- def source_location_from_methods(klass)
113
- try_methods = [:subscribed] + (klass.instance_methods(false) - [:subscribed])
114
- try_methods.each do |method_name|
115
- location = klass.instance_method(method_name).source_location
116
- return location[0] if location
117
- rescue NameError, TypeError
118
- next
119
- end
120
- nil
121
- rescue StandardError
122
- nil
123
- end
124
-
125
- # Fall back to Rails convention path for channel files.
126
- #
127
- # @param name [String] Channel class name
128
- # @return [String, nil]
129
- def convention_fallback(name)
100
+ def source_file_for(klass, name)
130
101
  return nil unless defined?(Rails) && Rails.respond_to?(:root) && Rails.root
131
102
 
132
- path = Rails.root.join('app', 'channels', "#{name.underscore}.rb").to_s
133
- File.exist?(path) ? path : nil
103
+ convention_path = Rails.root.join('app', 'channels', "#{name.underscore}.rb").to_s
104
+ return convention_path if File.exist?(convention_path)
105
+
106
+ resolve_source_location(klass, app_root: Rails.root.to_s, fallback: nil)
134
107
  end
135
108
 
136
109
  # Read source code from a file path.
@@ -34,6 +34,21 @@ module CodebaseIndex
34
34
  # Async enqueue methods that indicate a job is being dispatched.
35
35
  ASYNC_METHODS = %w[perform_later perform_async perform_in perform_at].freeze
36
36
 
37
+ # Pre-compiled regex patterns (avoid dynamic regex construction in hot loops)
38
+ SINGLE_COLUMN_WRITER_PATTERNS = SINGLE_COLUMN_WRITERS.to_h do |w|
39
+ [w, /\b#{Regexp.escape(w)}\s*\(?\s*[:'"](\w+)/]
40
+ end.freeze
41
+
42
+ MULTI_COLUMN_WRITER_PATTERNS = MULTI_COLUMN_WRITERS.to_h do |w|
43
+ [w, /\b#{Regexp.escape(w)}\s*\(([^)]+)\)/m]
44
+ end.freeze
45
+
46
+ ASYNC_PATTERN = /(\w+(?:Job|Worker))\.(?:#{ASYNC_METHODS.map { |m| Regexp.escape(m) }.join('|')})/
47
+
48
+ DB_READ_PATTERNS = DB_READ_METHODS.to_h do |m|
49
+ [m, /\.#{Regexp.escape(m)}\b/]
50
+ end.freeze
51
+
37
52
  # @param source_code [String] Composite model source (with inlined concerns)
38
53
  # @param column_names [Array<String>] Model's database column names
39
54
  def initialize(source_code:, column_names: [])
@@ -141,15 +156,15 @@ module CodebaseIndex
141
156
  end
142
157
 
143
158
  # Pattern: update_column(:col, ...) / write_attribute(:col, ...)
144
- SINGLE_COLUMN_WRITERS.each do |writer|
145
- method_source.scan(/\b#{Regexp.escape(writer)}\s*\(?\s*[:'"](\w+)/).flatten.each do |col|
159
+ SINGLE_COLUMN_WRITER_PATTERNS.each_value do |pattern|
160
+ method_source.scan(pattern).flatten.each do |col|
146
161
  columns << col if @column_names.include?(col)
147
162
  end
148
163
  end
149
164
 
150
165
  # Pattern: update_columns(col: ...) / assign_attributes(col: ...)
151
- MULTI_COLUMN_WRITERS.each do |writer|
152
- method_source.scan(/\b#{Regexp.escape(writer)}\s*\(([^)]+)\)/m).each do |match|
166
+ MULTI_COLUMN_WRITER_PATTERNS.each_value do |pattern|
167
+ method_source.scan(pattern).each do |match|
153
168
  match[0].scan(/\b(\w+)\s*:(?!:)/).flatten.each do |col|
154
169
  columns << col if @column_names.include?(col)
155
170
  end
@@ -166,8 +181,7 @@ module CodebaseIndex
166
181
  # @param method_source [String]
167
182
  # @return [Array<String>]
168
183
  def detect_jobs_enqueued(method_source)
169
- async_pattern = ASYNC_METHODS.map { |m| Regexp.escape(m) }.join('|')
170
- method_source.scan(/(\w+(?:Job|Worker))\.(?:#{async_pattern})/).flatten.uniq.sort
184
+ method_source.scan(ASYNC_PATTERN).flatten.uniq.sort
171
185
  end
172
186
 
173
187
  # Detect service objects called by the callback method.
@@ -197,8 +211,8 @@ module CodebaseIndex
197
211
  # @param method_source [String]
198
212
  # @return [Array<String>]
199
213
  def detect_database_reads(method_source)
200
- DB_READ_METHODS.select do |method|
201
- method_source.match?(/\.#{Regexp.escape(method)}\b/)
214
+ DB_READ_PATTERNS.filter_map do |method, pattern|
215
+ method if method_source.match?(pattern)
202
216
  end
203
217
  end
204
218
 
@@ -115,40 +115,16 @@ module CodebaseIndex
115
115
 
116
116
  # Find the source file for a controller, validating paths are within Rails.root.
117
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).
118
+ # Convention path first, then introspection via {#resolve_source_location}
119
+ # which filters out vendor/node_modules paths.
120
120
  #
121
121
  # @param controller [Class] The controller class
122
122
  # @return [String] Absolute path to the controller source file
123
123
  def source_file_for(controller)
124
- app_root = Rails.root.to_s
125
124
  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
125
  return convention_path if File.exist?(convention_path)
141
126
 
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
127
+ resolve_source_location(controller, app_root: Rails.root.to_s, fallback: convention_path)
152
128
  end
153
129
 
154
130
  # Build composite source with routes and filters as headers
@@ -228,72 +204,6 @@ module CodebaseIndex
228
204
  end
229
205
  end
230
206
 
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
207
  # ──────────────────────────────────────────────────────────────────────
298
208
  # Metadata Extraction
299
209
  # ──────────────────────────────────────────────────────────────────────
@@ -90,11 +90,10 @@ module CodebaseIndex
90
90
  # Class Discovery
91
91
  # ──────────────────────────────────────────────────────────────────────
92
92
 
93
- # Extract the class name from source or fall back to filename convention.
94
- #
95
- # Handles namespaced classes defined inside module blocks by combining
96
- # outer module names with the class name (e.g., module Admin / class
97
- # UserDecorator → "Admin::UserDecorator").
93
+ # Override SharedUtilityMethods#extract_class_name for decorator-specific
94
+ # namespace resolution. The shared version only matches `class Foo::Bar`
95
+ # (inline namespacing); this version also handles `module Admin / class
96
+ # UserDecorator` (block namespacing) by scanning for enclosing modules.
98
97
  #
99
98
  # @param file_path [String] Path to the file
100
99
  # @param source [String] Ruby source code
@@ -119,14 +118,6 @@ module CodebaseIndex
119
118
  end
120
119
  end
121
120
 
122
- # Skip module-only files (concerns, base modules without a class).
123
- #
124
- # @param source [String] Ruby source code
125
- # @return [Boolean]
126
- def skip_file?(source)
127
- source.match?(/^\s*module\s+\w+\s*$/) && !source.match?(/^\s*class\s+/)
128
- end
129
-
130
121
  # ──────────────────────────────────────────────────────────────────────
131
122
  # Source Annotation
132
123
  # ──────────────────────────────────────────────────────────────────────
@@ -220,7 +211,9 @@ module CodebaseIndex
220
211
  methods.uniq
221
212
  end
222
213
 
223
- # Detect common entry points for decorator invocation.
214
+ # Override SharedUtilityMethods#detect_entry_points with decorator-specific
215
+ # entry points (decorate, present, to_partial_path) instead of the generic
216
+ # service-oriented ones (perform, execute, run, process).
224
217
  #
225
218
  # @param source [String] Ruby source code
226
219
  # @return [Array<String>] Entry point method names
@@ -132,7 +132,8 @@ module CodebaseIndex
132
132
  mounted_path: mounted_path,
133
133
  route_count: route_count,
134
134
  isolate_namespace: isolated,
135
- controllers: controllers
135
+ controllers: controllers,
136
+ engine_source: framework_engine?(engine) ? :framework : :application
136
137
  }
137
138
  unit.dependencies = build_engine_dependencies(controllers)
138
139
 
@@ -142,6 +143,24 @@ module CodebaseIndex
142
143
  nil
143
144
  end
144
145
 
146
+ # Check if an engine is a framework gem rather than an application engine.
147
+ #
148
+ # An engine is framework if it lives outside Rails.root, or inside
149
+ # Rails.root but under vendor/bundle or bundler/gems (common in Docker
150
+ # where Rails.root is /app and gems install to /app/vendor/bundle).
151
+ #
152
+ # @param engine [Class] A Rails::Engine subclass
153
+ # @return [Boolean]
154
+ def framework_engine?(engine)
155
+ root = engine.root.to_s
156
+
157
+ # Engine outside Rails.root is definitely framework
158
+ return true unless root.start_with?(Rails.root.to_s)
159
+
160
+ # Engine inside Rails.root but in vendor/bundler paths is framework
161
+ root.include?('/vendor/') || root.include?('/bundler/gems/')
162
+ end
163
+
145
164
  # Count routes defined by an engine.
146
165
  #
147
166
  # @param engine [Class] A Rails::Engine subclass
@@ -202,39 +202,16 @@ module CodebaseIndex
202
202
  # Determine the source file for a runtime-loaded class, validating that
203
203
  # paths are within Rails.root to avoid returning graphql gem internals.
204
204
  #
205
- # Uses a multi-tier strategy matching the model extractor's pattern.
205
+ # Convention path first, then introspection via {#resolve_source_location}
206
+ # which filters out vendor/node_modules paths.
206
207
  #
207
208
  # @param klass [Class]
208
209
  # @return [String] Absolute path to the source file
209
210
  def source_file_for_class(klass)
210
- app_root = Rails.root.to_s
211
211
  convention_path = Rails.root.join("#{GRAPHQL_DIRECTORY}/#{klass.name.underscore}.rb").to_s
212
-
213
- # Tier 1: Instance methods defined directly on this class
214
- klass.instance_methods(false).each do |method_name|
215
- loc = klass.instance_method(method_name).source_location&.first
216
- return loc if loc&.start_with?(app_root)
217
- end
218
-
219
- # Tier 2: Singleton methods defined on this class
220
- klass.singleton_methods(false).each do |method_name|
221
- loc = klass.method(method_name).source_location&.first
222
- return loc if loc&.start_with?(app_root)
223
- end
224
-
225
- # Tier 3: Convention path if file exists
226
212
  return convention_path if File.exist?(convention_path)
227
213
 
228
- # Tier 4: const_source_location (Ruby 3.0+)
229
- if Object.respond_to?(:const_source_location)
230
- loc = Object.const_source_location(klass.name)&.first
231
- return loc if loc&.start_with?(app_root)
232
- end
233
-
234
- # Tier 5: Always return convention path — never a gem path
235
- convention_path
236
- rescue StandardError
237
- Rails.root.join("#{GRAPHQL_DIRECTORY}/#{klass.name.underscore}.rb").to_s
214
+ resolve_source_location(klass, app_root: Rails.root.to_s, fallback: convention_path)
238
215
  end
239
216
 
240
217
  # ──────────────────────────────────────────────────────────────────────
@@ -619,9 +596,7 @@ module CodebaseIndex
619
596
  # @return [Array<String>]
620
597
  def extract_connections(source)
621
598
  # field :items, Types::ItemType.connection_type
622
- connections = source.scan(/([\w:]+)\.connection_type/).flatten.map do |type|
623
- type
624
- end
599
+ connections = source.scan(/([\w:]+)\.connection_type/).flatten
625
600
 
626
601
  # connection_type_class ConnectionType
627
602
  source.scan(/connection_type_class\s+([\w:]+)/).flatten.each do |type|
@@ -152,13 +152,18 @@ module CodebaseIndex
152
152
  source.match?(/def perform/)
153
153
  end
154
154
 
155
+ # Locate the source file for a job class.
156
+ #
157
+ # Convention path first, then introspection via {#resolve_source_location}
158
+ # which filters out vendor/node_modules paths.
159
+ #
160
+ # @param job_class [Class]
161
+ # @return [String, nil]
155
162
  def source_file_for(job_class)
156
- # Try to get from method source location
157
- if job_class.method_defined?(:perform, false)
158
- job_class.instance_method(:perform).source_location&.first
159
- end || Rails.root.join("app/jobs/#{job_class.name.underscore}.rb").to_s
160
- rescue StandardError
161
- nil
163
+ convention_path = Rails.root.join("app/jobs/#{job_class.name.underscore}.rb").to_s
164
+ return convention_path if File.exist?(convention_path)
165
+
166
+ resolve_source_location(job_class, app_root: Rails.root.to_s, fallback: convention_path)
162
167
  end
163
168
 
164
169
  # ──────────────────────────────────────────────────────────────────────
@@ -201,37 +201,6 @@ module CodebaseIndex
201
201
  }
202
202
  end
203
203
 
204
- # Extract the parent class name from a class definition.
205
- #
206
- # @param source [String] Ruby source code
207
- # @return [String, nil] Parent class name or nil
208
- def extract_parent_class(source)
209
- match = source.match(/^\s*class\s+[\w:]+\s*<\s*([\w:]+)/)
210
- match ? match[1] : nil
211
- end
212
-
213
- # Count non-blank, non-comment lines of code.
214
- #
215
- # @param source [String] Ruby source code
216
- # @return [Integer] LOC count
217
- def count_loc(source)
218
- source.lines.count { |l| l.strip.length.positive? && !l.strip.start_with?('#') }
219
- end
220
-
221
- # Detect common entry point methods.
222
- #
223
- # @param source [String] Ruby source code
224
- # @return [Array<String>] Entry point method names
225
- def detect_entry_points(source)
226
- points = []
227
- points << 'call' if source.match?(/def (self\.)?call\b/)
228
- points << 'perform' if source.match?(/def (self\.)?perform\b/)
229
- points << 'execute' if source.match?(/def (self\.)?execute\b/)
230
- points << 'run' if source.match?(/def (self\.)?run\b/)
231
- points << 'process' if source.match?(/def (self\.)?process\b/)
232
- points.empty? ? ['unknown'] : points
233
- end
234
-
235
204
  # ──────────────────────────────────────────────────────────────────────
236
205
  # Dependency Extraction
237
206
  # ──────────────────────────────────────────────────────────────────────