woods 1.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +7 -0
- data/CHANGELOG.md +89 -0
- data/CODE_OF_CONDUCT.md +83 -0
- data/CONTRIBUTING.md +65 -0
- data/LICENSE.txt +21 -0
- data/README.md +406 -0
- data/exe/woods-console +59 -0
- data/exe/woods-console-mcp +22 -0
- data/exe/woods-mcp +34 -0
- data/exe/woods-mcp-http +37 -0
- data/exe/woods-mcp-start +58 -0
- data/lib/generators/woods/install_generator.rb +32 -0
- data/lib/generators/woods/pgvector_generator.rb +37 -0
- data/lib/generators/woods/templates/add_pgvector_to_woods.rb.erb +15 -0
- data/lib/generators/woods/templates/create_woods_tables.rb.erb +43 -0
- data/lib/tasks/woods.rake +621 -0
- data/lib/tasks/woods_evaluation.rake +115 -0
- data/lib/woods/ast/call_site_extractor.rb +106 -0
- data/lib/woods/ast/method_extractor.rb +71 -0
- data/lib/woods/ast/node.rb +116 -0
- data/lib/woods/ast/parser.rb +614 -0
- data/lib/woods/ast.rb +6 -0
- data/lib/woods/builder.rb +200 -0
- data/lib/woods/cache/cache_middleware.rb +199 -0
- data/lib/woods/cache/cache_store.rb +264 -0
- data/lib/woods/cache/redis_cache_store.rb +116 -0
- data/lib/woods/cache/solid_cache_store.rb +111 -0
- data/lib/woods/chunking/chunk.rb +84 -0
- data/lib/woods/chunking/semantic_chunker.rb +295 -0
- data/lib/woods/console/adapters/cache_adapter.rb +58 -0
- data/lib/woods/console/adapters/good_job_adapter.rb +33 -0
- data/lib/woods/console/adapters/job_adapter.rb +68 -0
- data/lib/woods/console/adapters/sidekiq_adapter.rb +33 -0
- data/lib/woods/console/adapters/solid_queue_adapter.rb +33 -0
- data/lib/woods/console/audit_logger.rb +75 -0
- data/lib/woods/console/bridge.rb +177 -0
- data/lib/woods/console/confirmation.rb +90 -0
- data/lib/woods/console/connection_manager.rb +173 -0
- data/lib/woods/console/console_response_renderer.rb +74 -0
- data/lib/woods/console/embedded_executor.rb +373 -0
- data/lib/woods/console/model_validator.rb +81 -0
- data/lib/woods/console/rack_middleware.rb +87 -0
- data/lib/woods/console/safe_context.rb +82 -0
- data/lib/woods/console/server.rb +612 -0
- data/lib/woods/console/sql_validator.rb +172 -0
- data/lib/woods/console/tools/tier1.rb +118 -0
- data/lib/woods/console/tools/tier2.rb +117 -0
- data/lib/woods/console/tools/tier3.rb +110 -0
- data/lib/woods/console/tools/tier4.rb +79 -0
- data/lib/woods/coordination/pipeline_lock.rb +109 -0
- data/lib/woods/cost_model/embedding_cost.rb +88 -0
- data/lib/woods/cost_model/estimator.rb +128 -0
- data/lib/woods/cost_model/provider_pricing.rb +67 -0
- data/lib/woods/cost_model/storage_cost.rb +52 -0
- data/lib/woods/cost_model.rb +22 -0
- data/lib/woods/db/migrations/001_create_units.rb +38 -0
- data/lib/woods/db/migrations/002_create_edges.rb +35 -0
- data/lib/woods/db/migrations/003_create_embeddings.rb +37 -0
- data/lib/woods/db/migrations/004_create_snapshots.rb +45 -0
- data/lib/woods/db/migrations/005_create_snapshot_units.rb +40 -0
- data/lib/woods/db/migrations/006_rename_tables.rb +34 -0
- data/lib/woods/db/migrator.rb +73 -0
- data/lib/woods/db/schema_version.rb +73 -0
- data/lib/woods/dependency_graph.rb +236 -0
- data/lib/woods/embedding/indexer.rb +140 -0
- data/lib/woods/embedding/openai.rb +126 -0
- data/lib/woods/embedding/provider.rb +162 -0
- data/lib/woods/embedding/text_preparer.rb +112 -0
- data/lib/woods/evaluation/baseline_runner.rb +115 -0
- data/lib/woods/evaluation/evaluator.rb +139 -0
- data/lib/woods/evaluation/metrics.rb +79 -0
- data/lib/woods/evaluation/query_set.rb +148 -0
- data/lib/woods/evaluation/report_generator.rb +90 -0
- data/lib/woods/extracted_unit.rb +145 -0
- data/lib/woods/extractor.rb +1028 -0
- data/lib/woods/extractors/action_cable_extractor.rb +201 -0
- data/lib/woods/extractors/ast_source_extraction.rb +46 -0
- data/lib/woods/extractors/behavioral_profile.rb +309 -0
- data/lib/woods/extractors/caching_extractor.rb +261 -0
- data/lib/woods/extractors/callback_analyzer.rb +246 -0
- data/lib/woods/extractors/concern_extractor.rb +292 -0
- data/lib/woods/extractors/configuration_extractor.rb +219 -0
- data/lib/woods/extractors/controller_extractor.rb +404 -0
- data/lib/woods/extractors/database_view_extractor.rb +278 -0
- data/lib/woods/extractors/decorator_extractor.rb +253 -0
- data/lib/woods/extractors/engine_extractor.rb +223 -0
- data/lib/woods/extractors/event_extractor.rb +211 -0
- data/lib/woods/extractors/factory_extractor.rb +289 -0
- data/lib/woods/extractors/graphql_extractor.rb +892 -0
- data/lib/woods/extractors/i18n_extractor.rb +117 -0
- data/lib/woods/extractors/job_extractor.rb +374 -0
- data/lib/woods/extractors/lib_extractor.rb +218 -0
- data/lib/woods/extractors/mailer_extractor.rb +269 -0
- data/lib/woods/extractors/manager_extractor.rb +188 -0
- data/lib/woods/extractors/middleware_extractor.rb +133 -0
- data/lib/woods/extractors/migration_extractor.rb +469 -0
- data/lib/woods/extractors/model_extractor.rb +988 -0
- data/lib/woods/extractors/phlex_extractor.rb +252 -0
- data/lib/woods/extractors/policy_extractor.rb +191 -0
- data/lib/woods/extractors/poro_extractor.rb +229 -0
- data/lib/woods/extractors/pundit_extractor.rb +223 -0
- data/lib/woods/extractors/rails_source_extractor.rb +473 -0
- data/lib/woods/extractors/rake_task_extractor.rb +343 -0
- data/lib/woods/extractors/route_extractor.rb +181 -0
- data/lib/woods/extractors/scheduled_job_extractor.rb +331 -0
- data/lib/woods/extractors/serializer_extractor.rb +339 -0
- data/lib/woods/extractors/service_extractor.rb +217 -0
- data/lib/woods/extractors/shared_dependency_scanner.rb +91 -0
- data/lib/woods/extractors/shared_utility_methods.rb +281 -0
- data/lib/woods/extractors/state_machine_extractor.rb +398 -0
- data/lib/woods/extractors/test_mapping_extractor.rb +225 -0
- data/lib/woods/extractors/validator_extractor.rb +211 -0
- data/lib/woods/extractors/view_component_extractor.rb +311 -0
- data/lib/woods/extractors/view_template_extractor.rb +261 -0
- data/lib/woods/feedback/gap_detector.rb +89 -0
- data/lib/woods/feedback/store.rb +119 -0
- data/lib/woods/filename_utils.rb +32 -0
- data/lib/woods/flow_analysis/operation_extractor.rb +206 -0
- data/lib/woods/flow_analysis/response_code_mapper.rb +154 -0
- data/lib/woods/flow_assembler.rb +290 -0
- data/lib/woods/flow_document.rb +191 -0
- data/lib/woods/flow_precomputer.rb +102 -0
- data/lib/woods/formatting/base.rb +30 -0
- data/lib/woods/formatting/claude_adapter.rb +98 -0
- data/lib/woods/formatting/generic_adapter.rb +56 -0
- data/lib/woods/formatting/gpt_adapter.rb +64 -0
- data/lib/woods/formatting/human_adapter.rb +78 -0
- data/lib/woods/graph_analyzer.rb +374 -0
- data/lib/woods/mcp/bootstrapper.rb +96 -0
- data/lib/woods/mcp/index_reader.rb +394 -0
- data/lib/woods/mcp/renderers/claude_renderer.rb +81 -0
- data/lib/woods/mcp/renderers/json_renderer.rb +17 -0
- data/lib/woods/mcp/renderers/markdown_renderer.rb +353 -0
- data/lib/woods/mcp/renderers/plain_renderer.rb +240 -0
- data/lib/woods/mcp/server.rb +962 -0
- data/lib/woods/mcp/tool_response_renderer.rb +85 -0
- data/lib/woods/model_name_cache.rb +51 -0
- data/lib/woods/notion/client.rb +217 -0
- data/lib/woods/notion/exporter.rb +219 -0
- data/lib/woods/notion/mapper.rb +40 -0
- data/lib/woods/notion/mappers/column_mapper.rb +57 -0
- data/lib/woods/notion/mappers/migration_mapper.rb +39 -0
- data/lib/woods/notion/mappers/model_mapper.rb +161 -0
- data/lib/woods/notion/mappers/shared.rb +22 -0
- data/lib/woods/notion/rate_limiter.rb +68 -0
- data/lib/woods/observability/health_check.rb +79 -0
- data/lib/woods/observability/instrumentation.rb +34 -0
- data/lib/woods/observability/structured_logger.rb +57 -0
- data/lib/woods/operator/error_escalator.rb +81 -0
- data/lib/woods/operator/pipeline_guard.rb +92 -0
- data/lib/woods/operator/status_reporter.rb +80 -0
- data/lib/woods/railtie.rb +38 -0
- data/lib/woods/resilience/circuit_breaker.rb +99 -0
- data/lib/woods/resilience/index_validator.rb +167 -0
- data/lib/woods/resilience/retryable_provider.rb +108 -0
- data/lib/woods/retrieval/context_assembler.rb +261 -0
- data/lib/woods/retrieval/query_classifier.rb +133 -0
- data/lib/woods/retrieval/ranker.rb +277 -0
- data/lib/woods/retrieval/search_executor.rb +316 -0
- data/lib/woods/retriever.rb +152 -0
- data/lib/woods/ruby_analyzer/class_analyzer.rb +170 -0
- data/lib/woods/ruby_analyzer/dataflow_analyzer.rb +77 -0
- data/lib/woods/ruby_analyzer/fqn_builder.rb +18 -0
- data/lib/woods/ruby_analyzer/mermaid_renderer.rb +280 -0
- data/lib/woods/ruby_analyzer/method_analyzer.rb +143 -0
- data/lib/woods/ruby_analyzer/trace_enricher.rb +143 -0
- data/lib/woods/ruby_analyzer.rb +87 -0
- data/lib/woods/session_tracer/file_store.rb +104 -0
- data/lib/woods/session_tracer/middleware.rb +143 -0
- data/lib/woods/session_tracer/redis_store.rb +106 -0
- data/lib/woods/session_tracer/session_flow_assembler.rb +254 -0
- data/lib/woods/session_tracer/session_flow_document.rb +223 -0
- data/lib/woods/session_tracer/solid_cache_store.rb +139 -0
- data/lib/woods/session_tracer/store.rb +81 -0
- data/lib/woods/storage/graph_store.rb +120 -0
- data/lib/woods/storage/metadata_store.rb +196 -0
- data/lib/woods/storage/pgvector.rb +195 -0
- data/lib/woods/storage/qdrant.rb +205 -0
- data/lib/woods/storage/vector_store.rb +167 -0
- data/lib/woods/temporal/json_snapshot_store.rb +245 -0
- data/lib/woods/temporal/snapshot_store.rb +345 -0
- data/lib/woods/token_utils.rb +19 -0
- data/lib/woods/version.rb +5 -0
- data/lib/woods.rb +246 -0
- metadata +270 -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 Woods
|
|
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
|
+
# Locate the source file for a Phlex component class.
|
|
100
|
+
#
|
|
101
|
+
# Convention paths first, then introspection via {#resolve_source_location}
|
|
102
|
+
# which filters out vendor/node_modules paths.
|
|
103
|
+
#
|
|
104
|
+
# @param component [Class]
|
|
105
|
+
# @return [String, nil]
|
|
106
|
+
def source_file_for(component)
|
|
107
|
+
possible_paths = [
|
|
108
|
+
Rails.root.join("app/views/components/#{component.name.underscore}.rb"),
|
|
109
|
+
Rails.root.join("app/components/#{component.name.underscore}.rb"),
|
|
110
|
+
Rails.root.join("app/views/#{component.name.underscore}.rb")
|
|
111
|
+
]
|
|
112
|
+
|
|
113
|
+
found = possible_paths.find { |p| File.exist?(p) }
|
|
114
|
+
return found.to_s if found
|
|
115
|
+
|
|
116
|
+
resolve_source_location(component, app_root: Rails.root.to_s, fallback: 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,191 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative 'shared_utility_methods'
|
|
4
|
+
require_relative 'shared_dependency_scanner'
|
|
5
|
+
|
|
6
|
+
module Woods
|
|
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, 'policies')
|
|
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
|
+
# Source Annotation
|
|
85
|
+
# ──────────────────────────────────────────────────────────────────────
|
|
86
|
+
|
|
87
|
+
def annotate_source(source, class_name)
|
|
88
|
+
decisions = detect_decision_methods(source)
|
|
89
|
+
evaluated = detect_evaluated_models(source, class_name)
|
|
90
|
+
|
|
91
|
+
<<~ANNOTATION
|
|
92
|
+
# ╔═══════════════════════════════════════════════════════════════════════╗
|
|
93
|
+
# ║ Policy: #{class_name.ljust(60)}║
|
|
94
|
+
# ║ Evaluates: #{evaluated.join(', ').ljust(57)}║
|
|
95
|
+
# ║ Decisions: #{decisions.join(', ').ljust(57)}║
|
|
96
|
+
# ╚═══════════════════════════════════════════════════════════════════════╝
|
|
97
|
+
|
|
98
|
+
#{source}
|
|
99
|
+
ANNOTATION
|
|
100
|
+
end
|
|
101
|
+
|
|
102
|
+
# ──────────────────────────────────────────────────────────────────────
|
|
103
|
+
# Metadata Extraction
|
|
104
|
+
# ──────────────────────────────────────────────────────────────────────
|
|
105
|
+
|
|
106
|
+
def extract_metadata(source, class_name)
|
|
107
|
+
{
|
|
108
|
+
evaluated_models: detect_evaluated_models(source, class_name),
|
|
109
|
+
decision_methods: detect_decision_methods(source),
|
|
110
|
+
public_methods: extract_public_methods(source),
|
|
111
|
+
class_methods: extract_class_methods(source),
|
|
112
|
+
initialize_params: extract_initialize_params(source),
|
|
113
|
+
is_pundit: pundit_policy?(source),
|
|
114
|
+
custom_errors: extract_custom_errors(source),
|
|
115
|
+
loc: source.lines.count { |l| l.strip.length.positive? && !l.strip.start_with?('#') },
|
|
116
|
+
method_count: source.scan(/def\s+(?:self\.)?\w+/).size
|
|
117
|
+
}
|
|
118
|
+
end
|
|
119
|
+
|
|
120
|
+
def detect_decision_methods(source)
|
|
121
|
+
methods = []
|
|
122
|
+
in_private = false
|
|
123
|
+
in_protected = false
|
|
124
|
+
|
|
125
|
+
source.each_line do |line|
|
|
126
|
+
stripped = line.strip
|
|
127
|
+
|
|
128
|
+
in_private = true if stripped == 'private'
|
|
129
|
+
in_protected = true if stripped == 'protected'
|
|
130
|
+
in_private = false if stripped == 'public'
|
|
131
|
+
in_protected = false if stripped == 'public'
|
|
132
|
+
|
|
133
|
+
next if in_private || in_protected
|
|
134
|
+
|
|
135
|
+
if stripped =~ /def\s+((?:self\.)?\w+\?)/
|
|
136
|
+
method_name = ::Regexp.last_match(1)
|
|
137
|
+
methods << method_name
|
|
138
|
+
end
|
|
139
|
+
end
|
|
140
|
+
|
|
141
|
+
methods.uniq
|
|
142
|
+
end
|
|
143
|
+
|
|
144
|
+
def detect_evaluated_models(source, class_name)
|
|
145
|
+
models = []
|
|
146
|
+
|
|
147
|
+
# From initialize params
|
|
148
|
+
if source =~ /def\s+initialize\s*\(([^)]*)\)/
|
|
149
|
+
params = ::Regexp.last_match(1)
|
|
150
|
+
params.scan(/(\w+)/).flatten.each do |param|
|
|
151
|
+
# Skip generic param names
|
|
152
|
+
next if %w[args options params attributes context].include?(param)
|
|
153
|
+
|
|
154
|
+
capitalized = param.sub(/\A\w/, &:upcase).gsub(/_(\w)/) { ::Regexp.last_match(1).upcase }
|
|
155
|
+
models << capitalized
|
|
156
|
+
end
|
|
157
|
+
end
|
|
158
|
+
|
|
159
|
+
# Infer from class name: RefundPolicy -> Refund
|
|
160
|
+
stripped = class_name.split('::').last
|
|
161
|
+
inferred = stripped.sub(/Policy\z/, '')
|
|
162
|
+
models << inferred if !inferred.nil? && !inferred.empty? && !models.include?(inferred)
|
|
163
|
+
|
|
164
|
+
models.uniq
|
|
165
|
+
end
|
|
166
|
+
|
|
167
|
+
def pundit_policy?(source)
|
|
168
|
+
source.match?(/< ApplicationPolicy/) ||
|
|
169
|
+
source.match?(/def\s+initialize\s*\(\s*user\s*,/) ||
|
|
170
|
+
source.match?(/attr_reader\s+:user\s*,\s*:record/)
|
|
171
|
+
end
|
|
172
|
+
|
|
173
|
+
# ──────────────────────────────────────────────────────────────────────
|
|
174
|
+
# Dependency Extraction
|
|
175
|
+
# ──────────────────────────────────────────────────────────────────────
|
|
176
|
+
|
|
177
|
+
def extract_dependencies(source, class_name)
|
|
178
|
+
# Evaluated model dependencies (specific :via)
|
|
179
|
+
deps = detect_evaluated_models(source, class_name).map do |model|
|
|
180
|
+
{ type: :model, target: model, via: :policy_evaluation }
|
|
181
|
+
end
|
|
182
|
+
|
|
183
|
+
deps.concat(scan_model_dependencies(source))
|
|
184
|
+
deps.concat(scan_service_dependencies(source))
|
|
185
|
+
deps.concat(scan_job_dependencies(source))
|
|
186
|
+
|
|
187
|
+
deps.uniq { |d| [d[:type], d[:target]] }
|
|
188
|
+
end
|
|
189
|
+
end
|
|
190
|
+
end
|
|
191
|
+
end
|
|
@@ -0,0 +1,229 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative 'shared_utility_methods'
|
|
4
|
+
require_relative 'shared_dependency_scanner'
|
|
5
|
+
|
|
6
|
+
module Woods
|
|
7
|
+
module Extractors
|
|
8
|
+
# PoroExtractor handles plain Ruby object extraction from app/models/.
|
|
9
|
+
#
|
|
10
|
+
# Scans app/models/ for Ruby files that define classes which are NOT
|
|
11
|
+
# ActiveRecord descendants (those are handled by ModelExtractor). Captures
|
|
12
|
+
# value objects, form objects, CurrentAttributes subclasses, Struct.new
|
|
13
|
+
# wrappers, and any other non-AR class living alongside AR models.
|
|
14
|
+
#
|
|
15
|
+
# Files under app/models/concerns/ are excluded — those are handled by
|
|
16
|
+
# ConcernExtractor. Module-only files are also excluded.
|
|
17
|
+
#
|
|
18
|
+
# @example
|
|
19
|
+
# extractor = PoroExtractor.new
|
|
20
|
+
# units = extractor.extract_all
|
|
21
|
+
# money = units.find { |u| u.identifier == "Money" }
|
|
22
|
+
# money.metadata[:parent_class] # => nil
|
|
23
|
+
# money.metadata[:method_count] # => 3
|
|
24
|
+
#
|
|
25
|
+
class PoroExtractor
|
|
26
|
+
include SharedUtilityMethods
|
|
27
|
+
include SharedDependencyScanner
|
|
28
|
+
|
|
29
|
+
# Glob pattern for all Ruby files in app/models/ (recursive).
|
|
30
|
+
MODELS_GLOB = 'app/models/**/*.rb'
|
|
31
|
+
|
|
32
|
+
# Subdirectory to exclude — handled by ConcernExtractor.
|
|
33
|
+
CONCERNS_SEGMENT = '/concerns/'
|
|
34
|
+
|
|
35
|
+
def initialize
|
|
36
|
+
@models_dir = Rails.root.join('app/models')
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
# Extract all PORO units from app/models/.
|
|
40
|
+
#
|
|
41
|
+
# Filters out ActiveRecord descendants by name so we don't duplicate
|
|
42
|
+
# what ModelExtractor already produces. Concerns/ subdir is also skipped.
|
|
43
|
+
#
|
|
44
|
+
# @return [Array<ExtractedUnit>] List of PORO units
|
|
45
|
+
def extract_all
|
|
46
|
+
return [] unless @models_dir.directory?
|
|
47
|
+
|
|
48
|
+
ar_names = ActiveRecord::Base.descendants.filter_map(&:name).to_set
|
|
49
|
+
|
|
50
|
+
Dir[Rails.root.join(MODELS_GLOB)].filter_map do |file|
|
|
51
|
+
next if file.include?(CONCERNS_SEGMENT)
|
|
52
|
+
|
|
53
|
+
extract_poro_file(file, ar_names: ar_names)
|
|
54
|
+
end
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
# Extract a single PORO file.
|
|
58
|
+
#
|
|
59
|
+
# Returns nil if the file is not a PORO (e.g., module-only, no class
|
|
60
|
+
# or PORO pattern found, or the inferred class is an AR descendant).
|
|
61
|
+
#
|
|
62
|
+
# @param file_path [String] Absolute path to the Ruby file
|
|
63
|
+
# @param ar_names [Set<String>] Set of AR descendant names to skip
|
|
64
|
+
# @return [ExtractedUnit, nil] The extracted unit or nil
|
|
65
|
+
def extract_poro_file(file_path, ar_names: Set.new)
|
|
66
|
+
source = File.read(file_path)
|
|
67
|
+
|
|
68
|
+
return nil unless poro_file?(source)
|
|
69
|
+
return nil if module_only?(source)
|
|
70
|
+
|
|
71
|
+
class_name = infer_class_name(file_path, source)
|
|
72
|
+
return nil unless class_name
|
|
73
|
+
return nil if ar_names.include?(class_name)
|
|
74
|
+
|
|
75
|
+
unit = ExtractedUnit.new(
|
|
76
|
+
type: :poro,
|
|
77
|
+
identifier: class_name,
|
|
78
|
+
file_path: file_path
|
|
79
|
+
)
|
|
80
|
+
|
|
81
|
+
unit.namespace = extract_namespace(class_name)
|
|
82
|
+
unit.source_code = annotate_source(source, class_name)
|
|
83
|
+
unit.metadata = extract_metadata(source, class_name)
|
|
84
|
+
unit.dependencies = extract_dependencies(source)
|
|
85
|
+
|
|
86
|
+
unit
|
|
87
|
+
rescue StandardError => e
|
|
88
|
+
Rails.logger.error("Failed to extract PORO #{file_path}: #{e.message}")
|
|
89
|
+
nil
|
|
90
|
+
end
|
|
91
|
+
|
|
92
|
+
private
|
|
93
|
+
|
|
94
|
+
# ──────────────────────────────────────────────────────────────────────
|
|
95
|
+
# File Classification
|
|
96
|
+
# ──────────────────────────────────────────────────────────────────────
|
|
97
|
+
|
|
98
|
+
# Determine whether a file is worth examining as a PORO.
|
|
99
|
+
#
|
|
100
|
+
# A file qualifies if it contains a class definition OR uses one of the
|
|
101
|
+
# common PORO-without-class patterns (Struct.new, Data.define).
|
|
102
|
+
# Plain constant assignments and module-only files are excluded upstream.
|
|
103
|
+
#
|
|
104
|
+
# @param source [String] Ruby source code
|
|
105
|
+
# @return [Boolean]
|
|
106
|
+
def poro_file?(source)
|
|
107
|
+
source.match?(/^\s*class\s+/) ||
|
|
108
|
+
source.match?(/\bStruct\.new\b/) ||
|
|
109
|
+
source.match?(/\bData\.define\b/)
|
|
110
|
+
end
|
|
111
|
+
|
|
112
|
+
# Return true when the file defines only modules, no class keyword.
|
|
113
|
+
#
|
|
114
|
+
# @param source [String] Ruby source code
|
|
115
|
+
# @return [Boolean]
|
|
116
|
+
def module_only?(source)
|
|
117
|
+
source.match?(/^\s*module\s+\w+/) && !source.match?(/^\s*class\s+/)
|
|
118
|
+
end
|
|
119
|
+
|
|
120
|
+
# ──────────────────────────────────────────────────────────────────────
|
|
121
|
+
# Class Name Inference
|
|
122
|
+
# ──────────────────────────────────────────────────────────────────────
|
|
123
|
+
|
|
124
|
+
# Infer the primary class name from source or fall back to file path.
|
|
125
|
+
#
|
|
126
|
+
# For regular class definitions we parse the first `class Foo` line,
|
|
127
|
+
# joining outer module namespaces when present. For Struct.new / Data.define
|
|
128
|
+
# patterns we read the constant assignment name. Falls back to the
|
|
129
|
+
# Rails camelize convention on the relative path.
|
|
130
|
+
#
|
|
131
|
+
# @param file_path [String] Absolute path to the file
|
|
132
|
+
# @param source [String] Ruby source code
|
|
133
|
+
# @return [String, nil] The inferred class name
|
|
134
|
+
def infer_class_name(file_path, source)
|
|
135
|
+
# Explicit class keyword — combine outer module namespaces + class name
|
|
136
|
+
class_match = source.match(/^\s*class\s+([\w:]+)/)
|
|
137
|
+
if class_match
|
|
138
|
+
base = class_match[1]
|
|
139
|
+
# If already fully qualified (e.g., Order::Update), use as-is
|
|
140
|
+
return base if base.include?('::')
|
|
141
|
+
|
|
142
|
+
namespaces = source.scan(/^\s*module\s+([\w:]+)/).flatten
|
|
143
|
+
return namespaces.any? ? "#{namespaces.join('::')}::#{base}" : base
|
|
144
|
+
end
|
|
145
|
+
|
|
146
|
+
# Struct.new / Data.define: ConstantName = Struct.new(...)
|
|
147
|
+
struct_match = source.match(/^(\w[\w:]*)\s*=\s*(?:Struct\.new|Data\.define)/)
|
|
148
|
+
return struct_match[1] if struct_match
|
|
149
|
+
|
|
150
|
+
# Fall back: derive from file path using Rails naming convention
|
|
151
|
+
path_based_class_name(file_path)
|
|
152
|
+
end
|
|
153
|
+
|
|
154
|
+
# Derive a class name from a file path using Rails camelize convention.
|
|
155
|
+
#
|
|
156
|
+
# app/models/order/update.rb => Order::Update
|
|
157
|
+
# app/models/money.rb => Money
|
|
158
|
+
#
|
|
159
|
+
# @param file_path [String] Absolute path to the file
|
|
160
|
+
# @return [String] Camelize-derived class name
|
|
161
|
+
def path_based_class_name(file_path)
|
|
162
|
+
relative = file_path.sub("#{Rails.root}/", '')
|
|
163
|
+
relative
|
|
164
|
+
.sub(%r{^app/models/}, '')
|
|
165
|
+
.sub('.rb', '')
|
|
166
|
+
.split('/')
|
|
167
|
+
.map(&:camelize)
|
|
168
|
+
.join('::')
|
|
169
|
+
end
|
|
170
|
+
|
|
171
|
+
# ──────────────────────────────────────────────────────────────────────
|
|
172
|
+
# Source Annotation
|
|
173
|
+
# ──────────────────────────────────────────────────────────────────────
|
|
174
|
+
|
|
175
|
+
# Prepend a summary annotation header to the source.
|
|
176
|
+
#
|
|
177
|
+
# @param source [String] Ruby source code
|
|
178
|
+
# @param class_name [String] The class name
|
|
179
|
+
# @return [String] Annotated source
|
|
180
|
+
def annotate_source(source, class_name)
|
|
181
|
+
parent = extract_parent_class(source)
|
|
182
|
+
parent_label = parent || 'none'
|
|
183
|
+
|
|
184
|
+
annotation = <<~ANNOTATION
|
|
185
|
+
# ╔═══════════════════════════════════════════════════════════════════════╗
|
|
186
|
+
# ║ PORO: #{class_name.ljust(63)}║
|
|
187
|
+
# ║ Parent: #{parent_label.ljust(61)}║
|
|
188
|
+
# ╚═══════════════════════════════════════════════════════════════════════╝
|
|
189
|
+
|
|
190
|
+
ANNOTATION
|
|
191
|
+
|
|
192
|
+
annotation + source
|
|
193
|
+
end
|
|
194
|
+
|
|
195
|
+
# ──────────────────────────────────────────────────────────────────────
|
|
196
|
+
# Metadata Extraction
|
|
197
|
+
# ──────────────────────────────────────────────────────────────────────
|
|
198
|
+
|
|
199
|
+
# Build the metadata hash for a PORO unit.
|
|
200
|
+
#
|
|
201
|
+
# @param source [String] Ruby source code
|
|
202
|
+
# @param class_name [String] The class name
|
|
203
|
+
# @return [Hash] PORO metadata
|
|
204
|
+
def extract_metadata(source, _class_name)
|
|
205
|
+
{
|
|
206
|
+
public_methods: extract_public_methods(source),
|
|
207
|
+
class_methods: extract_class_methods(source),
|
|
208
|
+
initialize_params: extract_initialize_params(source),
|
|
209
|
+
parent_class: extract_parent_class(source),
|
|
210
|
+
loc: count_loc(source),
|
|
211
|
+
method_count: source.scan(/def\s+(?:self\.)?\w+/).size
|
|
212
|
+
}
|
|
213
|
+
end
|
|
214
|
+
|
|
215
|
+
# ──────────────────────────────────────────────────────────────────────
|
|
216
|
+
# Dependency Extraction
|
|
217
|
+
# ──────────────────────────────────────────────────────────────────────
|
|
218
|
+
|
|
219
|
+
# Build the dependency array for a PORO unit using common scanners.
|
|
220
|
+
#
|
|
221
|
+
# @param source [String] Ruby source code
|
|
222
|
+
# @return [Array<Hash>] Dependency hashes with :type, :target, :via
|
|
223
|
+
def extract_dependencies(source)
|
|
224
|
+
deps = scan_common_dependencies(source)
|
|
225
|
+
deps.uniq { |d| [d[:type], d[:target]] }
|
|
226
|
+
end
|
|
227
|
+
end
|
|
228
|
+
end
|
|
229
|
+
end
|