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.
- checksums.yaml +7 -0
- data/CHANGELOG.md +29 -0
- data/CODE_OF_CONDUCT.md +83 -0
- data/CONTRIBUTING.md +65 -0
- data/LICENSE.txt +21 -0
- data/README.md +481 -0
- data/exe/codebase-console-mcp +22 -0
- data/exe/codebase-index-mcp +61 -0
- data/exe/codebase-index-mcp-http +64 -0
- data/exe/codebase-index-mcp-start +58 -0
- data/lib/codebase_index/ast/call_site_extractor.rb +106 -0
- data/lib/codebase_index/ast/method_extractor.rb +76 -0
- data/lib/codebase_index/ast/node.rb +88 -0
- data/lib/codebase_index/ast/parser.rb +653 -0
- data/lib/codebase_index/ast.rb +6 -0
- data/lib/codebase_index/builder.rb +137 -0
- data/lib/codebase_index/chunking/chunk.rb +84 -0
- data/lib/codebase_index/chunking/semantic_chunker.rb +290 -0
- data/lib/codebase_index/console/adapters/cache_adapter.rb +58 -0
- data/lib/codebase_index/console/adapters/good_job_adapter.rb +66 -0
- data/lib/codebase_index/console/adapters/sidekiq_adapter.rb +66 -0
- data/lib/codebase_index/console/adapters/solid_queue_adapter.rb +66 -0
- data/lib/codebase_index/console/audit_logger.rb +75 -0
- data/lib/codebase_index/console/bridge.rb +170 -0
- data/lib/codebase_index/console/confirmation.rb +90 -0
- data/lib/codebase_index/console/connection_manager.rb +173 -0
- data/lib/codebase_index/console/console_response_renderer.rb +78 -0
- data/lib/codebase_index/console/model_validator.rb +81 -0
- data/lib/codebase_index/console/safe_context.rb +82 -0
- data/lib/codebase_index/console/server.rb +557 -0
- data/lib/codebase_index/console/sql_validator.rb +172 -0
- data/lib/codebase_index/console/tools/tier1.rb +118 -0
- data/lib/codebase_index/console/tools/tier2.rb +117 -0
- data/lib/codebase_index/console/tools/tier3.rb +110 -0
- data/lib/codebase_index/console/tools/tier4.rb +79 -0
- data/lib/codebase_index/coordination/pipeline_lock.rb +109 -0
- data/lib/codebase_index/cost_model/embedding_cost.rb +88 -0
- data/lib/codebase_index/cost_model/estimator.rb +128 -0
- data/lib/codebase_index/cost_model/provider_pricing.rb +67 -0
- data/lib/codebase_index/cost_model/storage_cost.rb +52 -0
- data/lib/codebase_index/cost_model.rb +22 -0
- data/lib/codebase_index/db/migrations/001_create_units.rb +38 -0
- data/lib/codebase_index/db/migrations/002_create_edges.rb +35 -0
- data/lib/codebase_index/db/migrations/003_create_embeddings.rb +37 -0
- data/lib/codebase_index/db/migrations/004_create_snapshots.rb +45 -0
- data/lib/codebase_index/db/migrations/005_create_snapshot_units.rb +40 -0
- data/lib/codebase_index/db/migrator.rb +71 -0
- data/lib/codebase_index/db/schema_version.rb +73 -0
- data/lib/codebase_index/dependency_graph.rb +227 -0
- data/lib/codebase_index/embedding/indexer.rb +130 -0
- data/lib/codebase_index/embedding/openai.rb +105 -0
- data/lib/codebase_index/embedding/provider.rb +135 -0
- data/lib/codebase_index/embedding/text_preparer.rb +112 -0
- data/lib/codebase_index/evaluation/baseline_runner.rb +115 -0
- data/lib/codebase_index/evaluation/evaluator.rb +146 -0
- data/lib/codebase_index/evaluation/metrics.rb +79 -0
- data/lib/codebase_index/evaluation/query_set.rb +148 -0
- data/lib/codebase_index/evaluation/report_generator.rb +90 -0
- data/lib/codebase_index/extracted_unit.rb +145 -0
- data/lib/codebase_index/extractor.rb +956 -0
- data/lib/codebase_index/extractors/action_cable_extractor.rb +228 -0
- data/lib/codebase_index/extractors/ast_source_extraction.rb +46 -0
- data/lib/codebase_index/extractors/behavioral_profile.rb +309 -0
- data/lib/codebase_index/extractors/caching_extractor.rb +261 -0
- data/lib/codebase_index/extractors/callback_analyzer.rb +232 -0
- data/lib/codebase_index/extractors/concern_extractor.rb +253 -0
- data/lib/codebase_index/extractors/configuration_extractor.rb +219 -0
- data/lib/codebase_index/extractors/controller_extractor.rb +494 -0
- data/lib/codebase_index/extractors/database_view_extractor.rb +278 -0
- data/lib/codebase_index/extractors/decorator_extractor.rb +260 -0
- data/lib/codebase_index/extractors/engine_extractor.rb +204 -0
- data/lib/codebase_index/extractors/event_extractor.rb +211 -0
- data/lib/codebase_index/extractors/factory_extractor.rb +289 -0
- data/lib/codebase_index/extractors/graphql_extractor.rb +917 -0
- data/lib/codebase_index/extractors/i18n_extractor.rb +117 -0
- data/lib/codebase_index/extractors/job_extractor.rb +369 -0
- data/lib/codebase_index/extractors/lib_extractor.rb +249 -0
- data/lib/codebase_index/extractors/mailer_extractor.rb +339 -0
- data/lib/codebase_index/extractors/manager_extractor.rb +202 -0
- data/lib/codebase_index/extractors/middleware_extractor.rb +133 -0
- data/lib/codebase_index/extractors/migration_extractor.rb +469 -0
- data/lib/codebase_index/extractors/model_extractor.rb +960 -0
- data/lib/codebase_index/extractors/phlex_extractor.rb +252 -0
- data/lib/codebase_index/extractors/policy_extractor.rb +214 -0
- data/lib/codebase_index/extractors/poro_extractor.rb +246 -0
- data/lib/codebase_index/extractors/pundit_extractor.rb +223 -0
- data/lib/codebase_index/extractors/rails_source_extractor.rb +473 -0
- data/lib/codebase_index/extractors/rake_task_extractor.rb +343 -0
- data/lib/codebase_index/extractors/route_extractor.rb +181 -0
- data/lib/codebase_index/extractors/scheduled_job_extractor.rb +331 -0
- data/lib/codebase_index/extractors/serializer_extractor.rb +334 -0
- data/lib/codebase_index/extractors/service_extractor.rb +254 -0
- data/lib/codebase_index/extractors/shared_dependency_scanner.rb +91 -0
- data/lib/codebase_index/extractors/shared_utility_methods.rb +99 -0
- data/lib/codebase_index/extractors/state_machine_extractor.rb +398 -0
- data/lib/codebase_index/extractors/test_mapping_extractor.rb +225 -0
- data/lib/codebase_index/extractors/validator_extractor.rb +225 -0
- data/lib/codebase_index/extractors/view_component_extractor.rb +310 -0
- data/lib/codebase_index/extractors/view_template_extractor.rb +261 -0
- data/lib/codebase_index/feedback/gap_detector.rb +89 -0
- data/lib/codebase_index/feedback/store.rb +119 -0
- data/lib/codebase_index/flow_analysis/operation_extractor.rb +209 -0
- data/lib/codebase_index/flow_analysis/response_code_mapper.rb +154 -0
- data/lib/codebase_index/flow_assembler.rb +290 -0
- data/lib/codebase_index/flow_document.rb +191 -0
- data/lib/codebase_index/flow_precomputer.rb +102 -0
- data/lib/codebase_index/formatting/base.rb +40 -0
- data/lib/codebase_index/formatting/claude_adapter.rb +98 -0
- data/lib/codebase_index/formatting/generic_adapter.rb +56 -0
- data/lib/codebase_index/formatting/gpt_adapter.rb +64 -0
- data/lib/codebase_index/formatting/human_adapter.rb +78 -0
- data/lib/codebase_index/graph_analyzer.rb +374 -0
- data/lib/codebase_index/mcp/index_reader.rb +394 -0
- data/lib/codebase_index/mcp/renderers/claude_renderer.rb +81 -0
- data/lib/codebase_index/mcp/renderers/json_renderer.rb +17 -0
- data/lib/codebase_index/mcp/renderers/markdown_renderer.rb +352 -0
- data/lib/codebase_index/mcp/renderers/plain_renderer.rb +240 -0
- data/lib/codebase_index/mcp/server.rb +935 -0
- data/lib/codebase_index/mcp/tool_response_renderer.rb +62 -0
- data/lib/codebase_index/model_name_cache.rb +51 -0
- data/lib/codebase_index/notion/client.rb +217 -0
- data/lib/codebase_index/notion/exporter.rb +219 -0
- data/lib/codebase_index/notion/mapper.rb +39 -0
- data/lib/codebase_index/notion/mappers/column_mapper.rb +65 -0
- data/lib/codebase_index/notion/mappers/migration_mapper.rb +39 -0
- data/lib/codebase_index/notion/mappers/model_mapper.rb +164 -0
- data/lib/codebase_index/notion/rate_limiter.rb +68 -0
- data/lib/codebase_index/observability/health_check.rb +81 -0
- data/lib/codebase_index/observability/instrumentation.rb +34 -0
- data/lib/codebase_index/observability/structured_logger.rb +75 -0
- data/lib/codebase_index/operator/error_escalator.rb +81 -0
- data/lib/codebase_index/operator/pipeline_guard.rb +99 -0
- data/lib/codebase_index/operator/status_reporter.rb +80 -0
- data/lib/codebase_index/railtie.rb +26 -0
- data/lib/codebase_index/resilience/circuit_breaker.rb +99 -0
- data/lib/codebase_index/resilience/index_validator.rb +185 -0
- data/lib/codebase_index/resilience/retryable_provider.rb +108 -0
- data/lib/codebase_index/retrieval/context_assembler.rb +249 -0
- data/lib/codebase_index/retrieval/query_classifier.rb +131 -0
- data/lib/codebase_index/retrieval/ranker.rb +273 -0
- data/lib/codebase_index/retrieval/search_executor.rb +327 -0
- data/lib/codebase_index/retriever.rb +160 -0
- data/lib/codebase_index/ruby_analyzer/class_analyzer.rb +190 -0
- data/lib/codebase_index/ruby_analyzer/dataflow_analyzer.rb +78 -0
- data/lib/codebase_index/ruby_analyzer/fqn_builder.rb +18 -0
- data/lib/codebase_index/ruby_analyzer/mermaid_renderer.rb +275 -0
- data/lib/codebase_index/ruby_analyzer/method_analyzer.rb +143 -0
- data/lib/codebase_index/ruby_analyzer/trace_enricher.rb +139 -0
- data/lib/codebase_index/ruby_analyzer.rb +87 -0
- data/lib/codebase_index/session_tracer/file_store.rb +111 -0
- data/lib/codebase_index/session_tracer/middleware.rb +143 -0
- data/lib/codebase_index/session_tracer/redis_store.rb +112 -0
- data/lib/codebase_index/session_tracer/session_flow_assembler.rb +263 -0
- data/lib/codebase_index/session_tracer/session_flow_document.rb +223 -0
- data/lib/codebase_index/session_tracer/solid_cache_store.rb +145 -0
- data/lib/codebase_index/session_tracer/store.rb +67 -0
- data/lib/codebase_index/storage/graph_store.rb +120 -0
- data/lib/codebase_index/storage/metadata_store.rb +169 -0
- data/lib/codebase_index/storage/pgvector.rb +163 -0
- data/lib/codebase_index/storage/qdrant.rb +172 -0
- data/lib/codebase_index/storage/vector_store.rb +156 -0
- data/lib/codebase_index/temporal/snapshot_store.rb +341 -0
- data/lib/codebase_index/version.rb +5 -0
- data/lib/codebase_index.rb +223 -0
- data/lib/generators/codebase_index/install_generator.rb +32 -0
- data/lib/generators/codebase_index/pgvector_generator.rb +37 -0
- data/lib/generators/codebase_index/templates/add_pgvector_to_codebase_index.rb.erb +15 -0
- data/lib/generators/codebase_index/templates/create_codebase_index_tables.rb.erb +43 -0
- data/lib/tasks/codebase_index.rake +583 -0
- data/lib/tasks/codebase_index_evaluation.rake +115 -0
- 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
|