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,252 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative 'shared_utility_methods'
4
+ require_relative 'shared_dependency_scanner'
5
+
6
+ module CodebaseIndex
7
+ module Extractors
8
+ # PhlexExtractor handles Phlex component extraction.
9
+ #
10
+ # Phlex components are Ruby classes, making them more introspectable
11
+ # than ERB templates. We can extract:
12
+ # - Slot definitions (renders_one, renders_many)
13
+ # - Initialize parameters (the component's API)
14
+ # - Component dependencies (what other components it renders)
15
+ # - Helper usage
16
+ # - Stimulus controller references
17
+ #
18
+ # @example
19
+ # extractor = PhlexExtractor.new
20
+ # units = extractor.extract_all
21
+ # card = units.find { |u| u.identifier == "Components::CardComponent" }
22
+ #
23
+ class PhlexExtractor
24
+ include SharedUtilityMethods
25
+ include SharedDependencyScanner
26
+
27
+ # Common Phlex base classes to look for
28
+ PHLEX_BASES = %w[
29
+ Phlex::HTML
30
+ Phlex::Component
31
+ ApplicationComponent
32
+ ].freeze
33
+
34
+ def initialize
35
+ @component_base = find_component_base
36
+ end
37
+
38
+ # Extract all Phlex/ViewComponent components
39
+ #
40
+ # @return [Array<ExtractedUnit>] List of component units
41
+ def extract_all
42
+ return [] unless @component_base
43
+
44
+ @component_base.descendants.map do |component|
45
+ extract_component(component)
46
+ end.compact
47
+ end
48
+
49
+ # Extract a single component
50
+ #
51
+ # @param component [Class] The component class
52
+ # @return [ExtractedUnit] The extracted unit
53
+ def extract_component(component)
54
+ return nil if component.name.nil?
55
+
56
+ unit = ExtractedUnit.new(
57
+ type: :component,
58
+ identifier: component.name,
59
+ file_path: source_file_for(component)
60
+ )
61
+
62
+ unit.namespace = extract_namespace(component)
63
+ unit.source_code = read_source(unit.file_path)
64
+ unit.metadata = extract_metadata(component, unit.source_code)
65
+ unit.dependencies = extract_dependencies(component, unit.source_code)
66
+
67
+ unit
68
+ rescue StandardError => e
69
+ Rails.logger.error("Failed to extract component #{component.name}: #{e.message}")
70
+ nil
71
+ end
72
+
73
+ private
74
+
75
+ # Find the base component class used in the application.
76
+ # Skips ApplicationComponent if it's actually a ViewComponent subclass
77
+ # to avoid extracting ViewComponent classes with Phlex-specific metadata.
78
+ #
79
+ # @return [Class, nil]
80
+ def find_component_base
81
+ PHLEX_BASES.each do |base_name|
82
+ klass = base_name.safe_constantize
83
+ next unless klass
84
+ next if base_name == 'ApplicationComponent' && view_component_subclass?(klass)
85
+
86
+ return klass
87
+ end
88
+ nil
89
+ end
90
+
91
+ # Check if a class descends from ViewComponent::Base.
92
+ #
93
+ # @param klass [Class]
94
+ # @return [Boolean]
95
+ def view_component_subclass?(klass)
96
+ defined?(ViewComponent::Base) && klass < ViewComponent::Base
97
+ end
98
+
99
+ def source_file_for(component)
100
+ # Try common locations
101
+ possible_paths = [
102
+ Rails.root.join("app/views/components/#{component.name.underscore}.rb"),
103
+ Rails.root.join("app/components/#{component.name.underscore}.rb"),
104
+ Rails.root.join("app/views/#{component.name.underscore}.rb")
105
+ ]
106
+
107
+ found = possible_paths.find { |p| File.exist?(p) }
108
+ return found.to_s if found
109
+
110
+ # Try to get from method source location
111
+ if component.instance_methods(false).any?
112
+ method = component.instance_methods(false).first
113
+ component.instance_method(method).source_location&.first
114
+ end
115
+ rescue StandardError
116
+ nil
117
+ end
118
+
119
+ def read_source(file_path)
120
+ return '' unless file_path && File.exist?(file_path)
121
+
122
+ File.read(file_path)
123
+ end
124
+
125
+ # ──────────────────────────────────────────────────────────────────────
126
+ # Metadata Extraction
127
+ # ──────────────────────────────────────────────────────────────────────
128
+
129
+ def extract_metadata(component, source)
130
+ {
131
+ # Component API
132
+ slots: extract_slots(component, source),
133
+ initialize_params: extract_initialize_params(component),
134
+
135
+ # Public interface
136
+ public_methods: component.public_instance_methods(false),
137
+
138
+ # Hierarchy
139
+ parent_component: component.superclass.name,
140
+
141
+ # Phlex-specific
142
+ has_view_template: component.method_defined?(:view_template),
143
+
144
+ # For rendering context
145
+ renders_many: extract_renders_many(source),
146
+ renders_one: extract_renders_one(source),
147
+
148
+ # Metrics
149
+ loc: source.lines.count { |l| l.strip.present? && !l.strip.start_with?('#') }
150
+ }
151
+ end
152
+
153
+ # Extract slot definitions from Phlex components
154
+ def extract_slots(_component, source)
155
+ slots = []
156
+
157
+ # Phlex 1.x style: renders_one, renders_many
158
+ source.scan(/renders_one\s+:(\w+)(?:,\s*(\w+))?/) do |name, klass|
159
+ slots << { name: name, type: :one, class: klass }
160
+ end
161
+
162
+ source.scan(/renders_many\s+:(\w+)(?:,\s*(\w+))?/) do |name, klass|
163
+ slots << { name: name, type: :many, class: klass }
164
+ end
165
+
166
+ # Also check for slot method definitions
167
+ source.scan(/def\s+(\w+)_slot/) do |name|
168
+ slots << { name: name[0], type: :method }
169
+ end
170
+
171
+ slots
172
+ end
173
+
174
+ def extract_renders_many(source)
175
+ source.scan(/renders_many\s+:(\w+)/).flatten
176
+ end
177
+
178
+ def extract_renders_one(source)
179
+ source.scan(/renders_one\s+:(\w+)/).flatten
180
+ end
181
+
182
+ # Extract initialize parameters to understand component's data requirements
183
+ def extract_initialize_params(component)
184
+ method = component.instance_method(:initialize)
185
+ params = method.parameters
186
+
187
+ params.map do |type, name|
188
+ param_type = case type
189
+ when :req then :required
190
+ when :opt then :optional
191
+ when :keyreq then :keyword_required
192
+ when :key then :keyword_optional
193
+ when :rest then :splat
194
+ when :keyrest then :double_splat
195
+ when :block then :block
196
+ else type
197
+ end
198
+ { name: name, type: param_type }
199
+ end
200
+ rescue StandardError
201
+ []
202
+ end
203
+
204
+ # ──────────────────────────────────────────────────────────────────────
205
+ # Dependency Extraction
206
+ # ──────────────────────────────────────────────────────────────────────
207
+
208
+ def extract_dependencies(component, source)
209
+ deps = []
210
+
211
+ # Other components rendered
212
+ # Phlex style: render ComponentName.new(...)
213
+ source.scan(/render\s+(\w+(?:::\w+)*)(?:\.new|\()/).flatten.uniq.each do |comp|
214
+ next if comp == component.name # Skip self-references
215
+
216
+ deps << { type: :component, target: comp, via: :render }
217
+ end
218
+
219
+ # ViewComponent style: render(ComponentName.new(...))
220
+ source.scan(/render\((\w+(?:::\w+)*)\.new/).flatten.uniq.each do |comp|
221
+ next if comp == component.name
222
+
223
+ deps << { type: :component, target: comp, via: :render }
224
+ end
225
+
226
+ # Model references (often passed as props)
227
+ deps.concat(scan_model_dependencies(source, via: :data_dependency))
228
+
229
+ # Helper modules
230
+ source.scan(/include\s+(\w+Helper)/).flatten.uniq.each do |helper|
231
+ deps << { type: :helper, target: helper, via: :include }
232
+ end
233
+
234
+ source.scan(/helpers\.(\w+)/).flatten.uniq.each do |method|
235
+ deps << { type: :helper_method, target: method, via: :call }
236
+ end
237
+
238
+ # Stimulus controllers (from data-controller attributes)
239
+ source.scan(/data[_-]controller[=:]\s*["']([^"']+)["']/).flatten.uniq.each do |controller|
240
+ deps << { type: :stimulus_controller, target: controller, via: :html_attribute }
241
+ end
242
+
243
+ # URL helpers
244
+ source.scan(/(\w+)_(?:path|url)/).flatten.uniq.each do |route|
245
+ deps << { type: :route, target: route, via: :url_helper }
246
+ end
247
+
248
+ deps.uniq { |d| [d[:type], d[:target]] }
249
+ end
250
+ end
251
+ end
252
+ end
@@ -0,0 +1,214 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative 'shared_utility_methods'
4
+ require_relative 'shared_dependency_scanner'
5
+
6
+ module CodebaseIndex
7
+ module Extractors
8
+ # PolicyExtractor handles domain policy class extraction.
9
+ #
10
+ # Policy classes encode business eligibility rules — "can this user
11
+ # upgrade?", "is this order refundable?". These are NOT Pundit
12
+ # authorization policies. They live in `app/policies/`.
13
+ #
14
+ # We extract:
15
+ # - Policy name and namespace
16
+ # - Decision methods (allowed?, eligible?, valid?, etc.)
17
+ # - Models they evaluate (from initializer params and method bodies)
18
+ # - Dependencies (what models/services they reference)
19
+ #
20
+ # @example
21
+ # extractor = PolicyExtractor.new
22
+ # units = extractor.extract_all
23
+ # refund = units.find { |u| u.identifier == "RefundPolicy" }
24
+ #
25
+ class PolicyExtractor
26
+ include SharedUtilityMethods
27
+ include SharedDependencyScanner
28
+
29
+ # Directories to scan for policy classes
30
+ POLICY_DIRECTORIES = %w[
31
+ app/policies
32
+ ].freeze
33
+
34
+ # Method name patterns that indicate decision/eligibility logic
35
+ DECISION_METHOD_PATTERN = /\b(allowed|eligible|valid|permitted|can_\w+|should_\w+|qualifies|meets_\w+|satisfies)\?/
36
+
37
+ def initialize
38
+ @directories = POLICY_DIRECTORIES.map { |d| Rails.root.join(d) }
39
+ .select(&:directory?)
40
+ end
41
+
42
+ # Extract all policy classes
43
+ #
44
+ # @return [Array<ExtractedUnit>] List of policy units
45
+ def extract_all
46
+ @directories.flat_map do |dir|
47
+ Dir[dir.join('**/*.rb')].filter_map do |file|
48
+ extract_policy_file(file)
49
+ end
50
+ end
51
+ end
52
+
53
+ # Extract a single policy file
54
+ #
55
+ # @param file_path [String] Path to the policy file
56
+ # @return [ExtractedUnit, nil] The extracted unit or nil if not a policy
57
+ def extract_policy_file(file_path)
58
+ source = File.read(file_path)
59
+ class_name = extract_class_name(file_path, source)
60
+
61
+ return nil unless class_name
62
+ return nil if skip_file?(source)
63
+
64
+ unit = ExtractedUnit.new(
65
+ type: :policy,
66
+ identifier: class_name,
67
+ file_path: file_path
68
+ )
69
+
70
+ unit.namespace = extract_namespace(class_name)
71
+ unit.source_code = annotate_source(source, class_name)
72
+ unit.metadata = extract_metadata(source, class_name)
73
+ unit.dependencies = extract_dependencies(source, class_name)
74
+
75
+ unit
76
+ rescue StandardError => e
77
+ Rails.logger.error("Failed to extract policy #{file_path}: #{e.message}")
78
+ nil
79
+ end
80
+
81
+ private
82
+
83
+ # ──────────────────────────────────────────────────────────────────────
84
+ # Class Discovery
85
+ # ──────────────────────────────────────────────────────────────────────
86
+
87
+ def extract_class_name(file_path, source)
88
+ return ::Regexp.last_match(1) if source =~ /^\s*class\s+([\w:]+)/
89
+
90
+ file_path
91
+ .sub("#{Rails.root}/", '')
92
+ .sub(%r{^app/policies/}, '')
93
+ .sub('.rb', '')
94
+ .camelize
95
+ end
96
+
97
+ def skip_file?(source)
98
+ # Skip module-only files (concerns, base modules)
99
+ source.match?(/^\s*module\s+\w+\s*$/) && !source.match?(/^\s*class\s+/)
100
+ end
101
+
102
+ # ──────────────────────────────────────────────────────────────────────
103
+ # Source Annotation
104
+ # ──────────────────────────────────────────────────────────────────────
105
+
106
+ def annotate_source(source, class_name)
107
+ decisions = detect_decision_methods(source)
108
+ evaluated = detect_evaluated_models(source, class_name)
109
+
110
+ <<~ANNOTATION
111
+ # ╔═══════════════════════════════════════════════════════════════════════╗
112
+ # ║ Policy: #{class_name.ljust(60)}║
113
+ # ║ Evaluates: #{evaluated.join(', ').ljust(57)}║
114
+ # ║ Decisions: #{decisions.join(', ').ljust(57)}║
115
+ # ╚═══════════════════════════════════════════════════════════════════════╝
116
+
117
+ #{source}
118
+ ANNOTATION
119
+ end
120
+
121
+ # ──────────────────────────────────────────────────────────────────────
122
+ # Metadata Extraction
123
+ # ──────────────────────────────────────────────────────────────────────
124
+
125
+ def extract_metadata(source, class_name)
126
+ {
127
+ evaluated_models: detect_evaluated_models(source, class_name),
128
+ decision_methods: detect_decision_methods(source),
129
+ public_methods: extract_public_methods(source),
130
+ class_methods: extract_class_methods(source),
131
+ initialize_params: extract_initialize_params(source),
132
+ is_pundit: pundit_policy?(source),
133
+ custom_errors: extract_custom_errors(source),
134
+ loc: source.lines.count { |l| l.strip.length.positive? && !l.strip.start_with?('#') },
135
+ method_count: source.scan(/def\s+(?:self\.)?\w+/).size
136
+ }
137
+ end
138
+
139
+ def detect_decision_methods(source)
140
+ methods = []
141
+ in_private = false
142
+ in_protected = false
143
+
144
+ source.each_line do |line|
145
+ stripped = line.strip
146
+
147
+ in_private = true if stripped == 'private'
148
+ in_protected = true if stripped == 'protected'
149
+ in_private = false if stripped == 'public'
150
+ in_protected = false if stripped == 'public'
151
+
152
+ next if in_private || in_protected
153
+
154
+ if stripped =~ /def\s+((?:self\.)?\w+\?)/
155
+ method_name = ::Regexp.last_match(1)
156
+ methods << method_name
157
+ end
158
+ end
159
+
160
+ methods.uniq
161
+ end
162
+
163
+ def detect_evaluated_models(source, class_name)
164
+ models = []
165
+
166
+ # From initialize params
167
+ if source =~ /def\s+initialize\s*\(([^)]*)\)/
168
+ params = ::Regexp.last_match(1)
169
+ params.scan(/(\w+)/).flatten.each do |param|
170
+ # Skip generic param names
171
+ next if %w[args options params attributes context].include?(param)
172
+
173
+ capitalized = param.sub(/\A\w/, &:upcase).gsub(/_(\w)/) { ::Regexp.last_match(1).upcase }
174
+ models << capitalized
175
+ end
176
+ end
177
+
178
+ # Infer from class name: RefundPolicy -> Refund
179
+ stripped = class_name.split('::').last
180
+ inferred = stripped.sub(/Policy\z/, '')
181
+ models << inferred if !inferred.nil? && !inferred.empty? && !models.include?(inferred)
182
+
183
+ models.uniq
184
+ end
185
+
186
+ def pundit_policy?(source)
187
+ source.match?(/< ApplicationPolicy/) ||
188
+ source.match?(/def\s+initialize\s*\(\s*user\s*,/) ||
189
+ source.match?(/attr_reader\s+:user\s*,\s*:record/)
190
+ end
191
+
192
+ def extract_custom_errors(source)
193
+ source.scan(/class\s+(\w+(?:Error|Exception))\s*</).flatten
194
+ end
195
+
196
+ # ──────────────────────────────────────────────────────────────────────
197
+ # Dependency Extraction
198
+ # ──────────────────────────────────────────────────────────────────────
199
+
200
+ def extract_dependencies(source, class_name)
201
+ # Evaluated model dependencies (specific :via)
202
+ deps = detect_evaluated_models(source, class_name).map do |model|
203
+ { type: :model, target: model, via: :policy_evaluation }
204
+ end
205
+
206
+ deps.concat(scan_model_dependencies(source))
207
+ deps.concat(scan_service_dependencies(source))
208
+ deps.concat(scan_job_dependencies(source))
209
+
210
+ deps.uniq { |d| [d[:type], d[:target]] }
211
+ end
212
+ end
213
+ end
214
+ end