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,217 @@
|
|
|
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
|
+
# ServiceExtractor handles service object extraction.
|
|
9
|
+
#
|
|
10
|
+
# Service objects often contain the most important business logic.
|
|
11
|
+
# Unlike models (which are discovered via ActiveRecord), services
|
|
12
|
+
# are discovered by scanning conventional directories.
|
|
13
|
+
#
|
|
14
|
+
# We extract:
|
|
15
|
+
# - Public interface (call/perform/execute methods)
|
|
16
|
+
# - Dependencies (what models/services/jobs they use)
|
|
17
|
+
# - Error classes (custom exceptions defined)
|
|
18
|
+
# - Input/output patterns
|
|
19
|
+
#
|
|
20
|
+
# @example
|
|
21
|
+
# extractor = ServiceExtractor.new
|
|
22
|
+
# units = extractor.extract_all
|
|
23
|
+
# checkout = units.find { |u| u.identifier == "CheckoutService" }
|
|
24
|
+
#
|
|
25
|
+
class ServiceExtractor
|
|
26
|
+
include SharedUtilityMethods
|
|
27
|
+
include SharedDependencyScanner
|
|
28
|
+
|
|
29
|
+
# Directories to scan for service objects
|
|
30
|
+
SERVICE_DIRECTORIES = %w[
|
|
31
|
+
app/services
|
|
32
|
+
app/interactors
|
|
33
|
+
app/operations
|
|
34
|
+
app/commands
|
|
35
|
+
app/use_cases
|
|
36
|
+
].freeze
|
|
37
|
+
|
|
38
|
+
def initialize
|
|
39
|
+
@directories = SERVICE_DIRECTORIES.map { |d| Rails.root.join(d) }
|
|
40
|
+
.select(&:directory?)
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
# Extract all service objects
|
|
44
|
+
#
|
|
45
|
+
# @return [Array<ExtractedUnit>] List of service units
|
|
46
|
+
def extract_all
|
|
47
|
+
@directories.flat_map do |dir|
|
|
48
|
+
Dir[dir.join('**/*.rb')].filter_map do |file|
|
|
49
|
+
extract_service_file(file)
|
|
50
|
+
end
|
|
51
|
+
end
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
# Extract a single service file
|
|
55
|
+
#
|
|
56
|
+
# @param file_path [String] Path to the service file
|
|
57
|
+
# @return [ExtractedUnit, nil] The extracted unit or nil if not a service
|
|
58
|
+
def extract_service_file(file_path)
|
|
59
|
+
source = File.read(file_path)
|
|
60
|
+
class_name = extract_class_name(file_path, source, '(?:services|interactors|operations|commands|use_cases)')
|
|
61
|
+
|
|
62
|
+
return nil unless class_name
|
|
63
|
+
return nil if skip_file?(source)
|
|
64
|
+
|
|
65
|
+
unit = ExtractedUnit.new(
|
|
66
|
+
type: :service,
|
|
67
|
+
identifier: class_name,
|
|
68
|
+
file_path: file_path
|
|
69
|
+
)
|
|
70
|
+
|
|
71
|
+
unit.namespace = extract_namespace(class_name)
|
|
72
|
+
unit.source_code = annotate_source(source, class_name)
|
|
73
|
+
unit.metadata = extract_metadata(source, class_name, file_path)
|
|
74
|
+
unit.dependencies = extract_dependencies(source)
|
|
75
|
+
|
|
76
|
+
unit
|
|
77
|
+
rescue StandardError => e
|
|
78
|
+
Rails.logger.error("Failed to extract service #{file_path}: #{e.message}")
|
|
79
|
+
nil
|
|
80
|
+
end
|
|
81
|
+
|
|
82
|
+
private
|
|
83
|
+
|
|
84
|
+
# ──────────────────────────────────────────────────────────────────────
|
|
85
|
+
# Source Annotation
|
|
86
|
+
# ──────────────────────────────────────────────────────────────────────
|
|
87
|
+
|
|
88
|
+
# Add annotations to help with retrieval
|
|
89
|
+
def annotate_source(source, class_name)
|
|
90
|
+
entry_points = detect_entry_points(source)
|
|
91
|
+
|
|
92
|
+
annotation = <<~ANNOTATION
|
|
93
|
+
# ╔═══════════════════════════════════════════════════════════════════════╗
|
|
94
|
+
# ║ Service: #{class_name.ljust(60)}║
|
|
95
|
+
# ║ Entry Points: #{entry_points.join(', ').ljust(55)}║
|
|
96
|
+
# ╚═══════════════════════════════════════════════════════════════════════╝
|
|
97
|
+
|
|
98
|
+
ANNOTATION
|
|
99
|
+
|
|
100
|
+
annotation + source
|
|
101
|
+
end
|
|
102
|
+
|
|
103
|
+
# ──────────────────────────────────────────────────────────────────────
|
|
104
|
+
# Metadata Extraction
|
|
105
|
+
# ──────────────────────────────────────────────────────────────────────
|
|
106
|
+
|
|
107
|
+
def extract_metadata(source, _class_name, file_path)
|
|
108
|
+
{
|
|
109
|
+
# Entry points
|
|
110
|
+
public_methods: extract_public_methods(source),
|
|
111
|
+
entry_points: detect_entry_points(source),
|
|
112
|
+
class_methods: extract_class_methods(source),
|
|
113
|
+
|
|
114
|
+
# Patterns
|
|
115
|
+
is_callable: source.match?(/def (self\.)?call\b/),
|
|
116
|
+
is_interactor: source.match?(/include\s+Interactor/),
|
|
117
|
+
uses_dry_monads: source.match?(/include\s+Dry::Monads/),
|
|
118
|
+
|
|
119
|
+
# Dependency injection
|
|
120
|
+
initialize_params: extract_initialize_params(source),
|
|
121
|
+
injected_dependencies: extract_injected_deps(source),
|
|
122
|
+
|
|
123
|
+
# Error handling
|
|
124
|
+
custom_errors: extract_custom_errors(source),
|
|
125
|
+
rescues: extract_rescue_handlers(source),
|
|
126
|
+
|
|
127
|
+
# Return patterns
|
|
128
|
+
return_type: infer_return_type(source),
|
|
129
|
+
|
|
130
|
+
# Metrics
|
|
131
|
+
loc: source.lines.count { |l| l.strip.present? && !l.strip.start_with?('#') },
|
|
132
|
+
method_count: source.scan(/def\s+(?:self\.)?\w+/).size,
|
|
133
|
+
complexity: estimate_complexity(source),
|
|
134
|
+
|
|
135
|
+
# Directory context (what kind of service pattern)
|
|
136
|
+
service_type: infer_service_type(file_path)
|
|
137
|
+
}
|
|
138
|
+
end
|
|
139
|
+
|
|
140
|
+
def extract_injected_deps(source)
|
|
141
|
+
# Look for attr_reader/accessor that match common dependency patterns
|
|
142
|
+
deps = []
|
|
143
|
+
|
|
144
|
+
source.scan(/attr_(?:reader|accessor)\s+(.+)/) do |match|
|
|
145
|
+
match[0].scan(/:(\w+)/).flatten.each do |attr|
|
|
146
|
+
deps << attr if attr.match?(/service|repository|client|adapter|gateway|notifier|mailer/)
|
|
147
|
+
end
|
|
148
|
+
end
|
|
149
|
+
|
|
150
|
+
# Also look for initialize assignments
|
|
151
|
+
source.scan(/@(\w+)\s*=\s*(\w+)/) do |ivar, value|
|
|
152
|
+
deps << ivar if value.match?(/Service|Client|Repository|Adapter|Gateway/)
|
|
153
|
+
end
|
|
154
|
+
|
|
155
|
+
deps.uniq
|
|
156
|
+
end
|
|
157
|
+
|
|
158
|
+
def extract_rescue_handlers(source)
|
|
159
|
+
source.scan(/rescue\s+([\w:]+)/).flatten.uniq
|
|
160
|
+
end
|
|
161
|
+
|
|
162
|
+
def infer_return_type(source)
|
|
163
|
+
return :dry_monad if source.match?(/Success\(|Failure\(/)
|
|
164
|
+
return :result_object if source.match?(/Result\.new|OpenStruct\.new/)
|
|
165
|
+
return :boolean if source.match?(/def call.*?(?:true|false)\s*$/m)
|
|
166
|
+
|
|
167
|
+
:unknown
|
|
168
|
+
end
|
|
169
|
+
|
|
170
|
+
def estimate_complexity(source)
|
|
171
|
+
# Simple cyclomatic complexity estimate
|
|
172
|
+
branches = source.scan(/\b(?:if|unless|elsif|when|while|until|for|rescue|&&|\|\|)\b/).size
|
|
173
|
+
branches + 1
|
|
174
|
+
end
|
|
175
|
+
|
|
176
|
+
def infer_service_type(file_path)
|
|
177
|
+
case file_path
|
|
178
|
+
when /interactors/ then :interactor
|
|
179
|
+
when /operations/ then :operation
|
|
180
|
+
when /commands/ then :command
|
|
181
|
+
when /use_cases/ then :use_case
|
|
182
|
+
else :service
|
|
183
|
+
end
|
|
184
|
+
end
|
|
185
|
+
|
|
186
|
+
# ──────────────────────────────────────────────────────────────────────
|
|
187
|
+
# Dependency Extraction
|
|
188
|
+
# ──────────────────────────────────────────────────────────────────────
|
|
189
|
+
|
|
190
|
+
def extract_dependencies(source)
|
|
191
|
+
deps = scan_common_dependencies(source)
|
|
192
|
+
|
|
193
|
+
# Interactors
|
|
194
|
+
source.scan(/(\w+Interactor)(?:\.|::)/).flatten.uniq.each do |interactor|
|
|
195
|
+
deps << { type: :interactor, target: interactor, via: :code_reference }
|
|
196
|
+
end
|
|
197
|
+
|
|
198
|
+
# External API clients
|
|
199
|
+
source.scan(/(\w+Client)(?:\.|::new)/).flatten.uniq.each do |client|
|
|
200
|
+
deps << { type: :api_client, target: client, via: :code_reference }
|
|
201
|
+
end
|
|
202
|
+
|
|
203
|
+
# HTTP calls
|
|
204
|
+
if source.match?(/HTTParty|Faraday|RestClient|Net::HTTP/)
|
|
205
|
+
deps << { type: :external, target: :http_api, via: :code_reference }
|
|
206
|
+
end
|
|
207
|
+
|
|
208
|
+
# Redis
|
|
209
|
+
if source.match?(/Redis\.current|REDIS|Sidekiq\.redis/)
|
|
210
|
+
deps << { type: :infrastructure, target: :redis, via: :code_reference }
|
|
211
|
+
end
|
|
212
|
+
|
|
213
|
+
deps.uniq { |d| [d[:type], d[:target]] }
|
|
214
|
+
end
|
|
215
|
+
end
|
|
216
|
+
end
|
|
217
|
+
end
|
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative '../model_name_cache'
|
|
4
|
+
|
|
5
|
+
module Woods
|
|
6
|
+
module Extractors
|
|
7
|
+
# Common dependency scanning patterns shared across extractors.
|
|
8
|
+
#
|
|
9
|
+
# Most extractors scan source code for the same four dependency types:
|
|
10
|
+
# model references (via ModelNameCache), service objects, background jobs,
|
|
11
|
+
# and mailers. This module centralizes those scanning patterns.
|
|
12
|
+
#
|
|
13
|
+
# Individual scan methods accept an optional +:via+ parameter so
|
|
14
|
+
# extractors can customize the relationship label (e.g., +:serialization+
|
|
15
|
+
# instead of the default +:code_reference+).
|
|
16
|
+
#
|
|
17
|
+
# @example
|
|
18
|
+
# class FooExtractor
|
|
19
|
+
# include SharedDependencyScanner
|
|
20
|
+
#
|
|
21
|
+
# def extract_dependencies(source)
|
|
22
|
+
# deps = scan_common_dependencies(source)
|
|
23
|
+
# deps << { type: :custom, target: "Bar", via: :special }
|
|
24
|
+
# deps.uniq { |d| [d[:type], d[:target]] }
|
|
25
|
+
# end
|
|
26
|
+
# end
|
|
27
|
+
#
|
|
28
|
+
module SharedDependencyScanner
|
|
29
|
+
# Scan for ActiveRecord model references using the precomputed regex.
|
|
30
|
+
#
|
|
31
|
+
# @param source [String] Ruby source code to scan
|
|
32
|
+
# @param via [Symbol] Relationship label (default: :code_reference)
|
|
33
|
+
# @return [Array<Hash>] Dependency hashes with :type, :target, :via
|
|
34
|
+
def scan_model_dependencies(source, via: :code_reference)
|
|
35
|
+
source.scan(ModelNameCache.model_names_regex).uniq.map do |model_name|
|
|
36
|
+
{ type: :model, target: model_name, via: via }
|
|
37
|
+
end
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
# Scan for service object references (e.g., FooService.call, FooService::new).
|
|
41
|
+
#
|
|
42
|
+
# @param source [String] Ruby source code to scan
|
|
43
|
+
# @param via [Symbol] Relationship label (default: :code_reference)
|
|
44
|
+
# @return [Array<Hash>] Dependency hashes
|
|
45
|
+
def scan_service_dependencies(source, via: :code_reference)
|
|
46
|
+
source.scan(/(\w+Service)(?:\.|::)/).flatten.uniq.map do |service|
|
|
47
|
+
{ type: :service, target: service, via: via }
|
|
48
|
+
end
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
# Scan for background job references (e.g., FooJob.perform_later).
|
|
52
|
+
#
|
|
53
|
+
# @param source [String] Ruby source code to scan
|
|
54
|
+
# @param via [Symbol] Relationship label (default: :code_reference)
|
|
55
|
+
# @return [Array<Hash>] Dependency hashes
|
|
56
|
+
def scan_job_dependencies(source, via: :code_reference)
|
|
57
|
+
source.scan(/(\w+Job)\.perform/).flatten.uniq.map do |job|
|
|
58
|
+
{ type: :job, target: job, via: via }
|
|
59
|
+
end
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
# Scan for mailer references (e.g., UserMailer.welcome_email).
|
|
63
|
+
#
|
|
64
|
+
# @param source [String] Ruby source code to scan
|
|
65
|
+
# @param via [Symbol] Relationship label (default: :code_reference)
|
|
66
|
+
# @return [Array<Hash>] Dependency hashes
|
|
67
|
+
def scan_mailer_dependencies(source, via: :code_reference)
|
|
68
|
+
source.scan(/(\w+Mailer)\./).flatten.uniq.map do |mailer|
|
|
69
|
+
{ type: :mailer, target: mailer, via: via }
|
|
70
|
+
end
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
# Scan for all common dependency types and return a deduplicated array.
|
|
74
|
+
#
|
|
75
|
+
# Combines model, service, job, and mailer scans. Use this when an
|
|
76
|
+
# extractor needs all four standard dependency types with the default
|
|
77
|
+
# +:code_reference+ via label.
|
|
78
|
+
#
|
|
79
|
+
# @param source [String] Ruby source code to scan
|
|
80
|
+
# @return [Array<Hash>] Deduplicated dependency hashes
|
|
81
|
+
def scan_common_dependencies(source)
|
|
82
|
+
deps = []
|
|
83
|
+
deps.concat(scan_model_dependencies(source))
|
|
84
|
+
deps.concat(scan_service_dependencies(source))
|
|
85
|
+
deps.concat(scan_job_dependencies(source))
|
|
86
|
+
deps.concat(scan_mailer_dependencies(source))
|
|
87
|
+
deps.uniq { |d| [d[:type], d[:target]] }
|
|
88
|
+
end
|
|
89
|
+
end
|
|
90
|
+
end
|
|
91
|
+
end
|
|
@@ -0,0 +1,281 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Woods
|
|
4
|
+
module Extractors
|
|
5
|
+
# Utility methods shared across multiple extractors.
|
|
6
|
+
#
|
|
7
|
+
# Provides common helpers for namespace extraction, public method
|
|
8
|
+
# scanning, class method scanning, and initialize parameter parsing.
|
|
9
|
+
# These methods are duplicated across 4-11 extractors; this module
|
|
10
|
+
# centralizes them.
|
|
11
|
+
#
|
|
12
|
+
# @example
|
|
13
|
+
# class FooExtractor
|
|
14
|
+
# include SharedUtilityMethods
|
|
15
|
+
#
|
|
16
|
+
# def extract_foo(klass)
|
|
17
|
+
# namespace = extract_namespace(klass)
|
|
18
|
+
# # ...
|
|
19
|
+
# end
|
|
20
|
+
# end
|
|
21
|
+
#
|
|
22
|
+
module SharedUtilityMethods
|
|
23
|
+
# Check whether a path points to application source (under app_root, but
|
|
24
|
+
# not inside vendor/ or node_modules/ directories).
|
|
25
|
+
#
|
|
26
|
+
# In Docker environments where Rails.root is `/app`, a naive
|
|
27
|
+
# `start_with?(app_root)` also matches vendor bundle paths like
|
|
28
|
+
# `/app/vendor/bundle/ruby/…`. This helper rejects those.
|
|
29
|
+
#
|
|
30
|
+
# @param path [String, nil] Absolute file path
|
|
31
|
+
# @param app_root [String] Rails.root.to_s
|
|
32
|
+
# @return [Boolean]
|
|
33
|
+
def app_source?(path, app_root)
|
|
34
|
+
return false unless path
|
|
35
|
+
|
|
36
|
+
path.start_with?(app_root) && !path.include?('/vendor/') && !path.include?('/node_modules/')
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
# Resolve the source file for a class using reliable introspection,
|
|
40
|
+
# filtered through {#app_source?} to reject vendor/gem paths.
|
|
41
|
+
#
|
|
42
|
+
# Tier order:
|
|
43
|
+
# 1. +const_source_location+ (returns the class definition site)
|
|
44
|
+
# 2. Instance method source locations (first match wins)
|
|
45
|
+
# 3. Class/singleton method source locations (first match wins)
|
|
46
|
+
#
|
|
47
|
+
# @param klass [Class, Module] The class to resolve
|
|
48
|
+
# @param app_root [String] Rails.root.to_s
|
|
49
|
+
# @param fallback [String] Path to return when resolution fails
|
|
50
|
+
# @return [String] Resolved source path or fallback
|
|
51
|
+
def resolve_source_location(klass, app_root:, fallback:)
|
|
52
|
+
# Tier 1: const_source_location (most reliable — returns class definition site)
|
|
53
|
+
if Object.respond_to?(:const_source_location) && klass.name
|
|
54
|
+
loc = Object.const_source_location(klass.name)&.first
|
|
55
|
+
return loc if app_source?(loc, app_root)
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
# Tier 2: Instance methods defined directly on this class
|
|
59
|
+
klass.instance_methods(false).each do |method_name|
|
|
60
|
+
loc = klass.instance_method(method_name).source_location&.first
|
|
61
|
+
return loc if app_source?(loc, app_root)
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
# Tier 3: Class/singleton methods defined on this class
|
|
65
|
+
klass.methods(false).each do |method_name|
|
|
66
|
+
loc = klass.method(method_name).source_location&.first
|
|
67
|
+
return loc if app_source?(loc, app_root)
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
fallback
|
|
71
|
+
rescue StandardError
|
|
72
|
+
fallback
|
|
73
|
+
end
|
|
74
|
+
|
|
75
|
+
# Extract the primary class name from source or fall back to a file path convention.
|
|
76
|
+
#
|
|
77
|
+
# @param file_path [String] Absolute path to the Ruby file
|
|
78
|
+
# @param source [String] Ruby source code
|
|
79
|
+
# @param dir_prefix [String] Regex fragment matching the app/ subdirectory to strip
|
|
80
|
+
# (e.g., "policies", "validators", "(?:services|interactors|operations|commands|use_cases)")
|
|
81
|
+
# @return [String] The class name
|
|
82
|
+
def extract_class_name(file_path, source, dir_prefix)
|
|
83
|
+
return ::Regexp.last_match(1) if source =~ /^\s*class\s+([\w:]+)/
|
|
84
|
+
|
|
85
|
+
file_path.sub("#{Rails.root}/", '').sub(%r{^app/#{dir_prefix}/}, '').sub('.rb', '').camelize
|
|
86
|
+
end
|
|
87
|
+
|
|
88
|
+
# Extract the parent class name from a class definition.
|
|
89
|
+
#
|
|
90
|
+
# @param source [String] Ruby source code
|
|
91
|
+
# @return [String, nil] Parent class name or nil
|
|
92
|
+
def extract_parent_class(source)
|
|
93
|
+
match = source.match(/^\s*class\s+[\w:]+\s*<\s*([\w:]+)/)
|
|
94
|
+
match ? match[1] : nil
|
|
95
|
+
end
|
|
96
|
+
|
|
97
|
+
# Count non-blank, non-comment lines of code.
|
|
98
|
+
#
|
|
99
|
+
# @param source [String] Ruby source code
|
|
100
|
+
# @return [Integer] LOC count
|
|
101
|
+
def count_loc(source)
|
|
102
|
+
source.lines.count { |l| l.strip.length.positive? && !l.strip.start_with?('#') }
|
|
103
|
+
end
|
|
104
|
+
|
|
105
|
+
# Skip module-only files (concerns, base modules without a class).
|
|
106
|
+
#
|
|
107
|
+
# @param source [String] Ruby source code
|
|
108
|
+
# @return [Boolean]
|
|
109
|
+
def skip_file?(source)
|
|
110
|
+
source.match?(/^\s*module\s+[\w:]+\s*$/) && !source.match?(/^\s*class\s+/)
|
|
111
|
+
end
|
|
112
|
+
|
|
113
|
+
# Extract custom error/exception class names defined inline.
|
|
114
|
+
#
|
|
115
|
+
# @param source [String] Ruby source code
|
|
116
|
+
# @return [Array<String>] Custom error class names
|
|
117
|
+
def extract_custom_errors(source)
|
|
118
|
+
source.scan(/class\s+(\w+(?:Error|Exception))\s*</).flatten
|
|
119
|
+
end
|
|
120
|
+
|
|
121
|
+
# Detect common entry point methods in a source file.
|
|
122
|
+
#
|
|
123
|
+
# @param source [String] Ruby source code
|
|
124
|
+
# @return [Array<String>] Entry point method names
|
|
125
|
+
def detect_entry_points(source)
|
|
126
|
+
points = []
|
|
127
|
+
points << 'call' if source.match?(/def (self\.)?call\b/)
|
|
128
|
+
points << 'perform' if source.match?(/def (self\.)?perform\b/)
|
|
129
|
+
points << 'execute' if source.match?(/def (self\.)?execute\b/)
|
|
130
|
+
points << 'run' if source.match?(/def (self\.)?run\b/)
|
|
131
|
+
points << 'process' if source.match?(/def (self\.)?process\b/)
|
|
132
|
+
points.empty? ? ['unknown'] : points
|
|
133
|
+
end
|
|
134
|
+
|
|
135
|
+
# Extract :only/:except action lists and :if/:unless conditions from a callback.
|
|
136
|
+
#
|
|
137
|
+
# Modern Rails (4.2+) stores conditions in @if/@unless ivar arrays.
|
|
138
|
+
# ActionFilter objects hold action Sets; other conditions are procs/symbols.
|
|
139
|
+
#
|
|
140
|
+
# @param callback [ActiveSupport::Callbacks::Callback]
|
|
141
|
+
# @return [Array(Array<String>, Array<String>, Array<String>, Array<String>)]
|
|
142
|
+
# [only_actions, except_actions, if_labels, unless_labels]
|
|
143
|
+
def extract_callback_conditions(callback)
|
|
144
|
+
if_conditions = callback.instance_variable_get(:@if) || []
|
|
145
|
+
unless_conditions = callback.instance_variable_get(:@unless) || []
|
|
146
|
+
|
|
147
|
+
only = []
|
|
148
|
+
except = []
|
|
149
|
+
if_labels = []
|
|
150
|
+
unless_labels = []
|
|
151
|
+
|
|
152
|
+
if_conditions.each do |cond|
|
|
153
|
+
actions = extract_action_filter_actions(cond)
|
|
154
|
+
if actions
|
|
155
|
+
only.concat(actions)
|
|
156
|
+
else
|
|
157
|
+
if_labels << condition_label(cond)
|
|
158
|
+
end
|
|
159
|
+
end
|
|
160
|
+
|
|
161
|
+
unless_conditions.each do |cond|
|
|
162
|
+
actions = extract_action_filter_actions(cond)
|
|
163
|
+
if actions
|
|
164
|
+
except.concat(actions)
|
|
165
|
+
else
|
|
166
|
+
unless_labels << condition_label(cond)
|
|
167
|
+
end
|
|
168
|
+
end
|
|
169
|
+
|
|
170
|
+
[only, except, if_labels, unless_labels]
|
|
171
|
+
end
|
|
172
|
+
|
|
173
|
+
# Extract action names from an ActionFilter-like condition object.
|
|
174
|
+
# Duck-types on the @actions ivar being a Set, avoiding dependence
|
|
175
|
+
# on private class names across Rails versions.
|
|
176
|
+
#
|
|
177
|
+
# @param condition [Object] A condition from the callback's @if/@unless array
|
|
178
|
+
# @return [Array<String>, nil] Action names, or nil if not an ActionFilter
|
|
179
|
+
def extract_action_filter_actions(condition)
|
|
180
|
+
return nil unless condition.instance_variable_defined?(:@actions)
|
|
181
|
+
|
|
182
|
+
actions = condition.instance_variable_get(:@actions)
|
|
183
|
+
return nil unless actions.is_a?(Set)
|
|
184
|
+
|
|
185
|
+
actions.to_a
|
|
186
|
+
end
|
|
187
|
+
|
|
188
|
+
# Human-readable label for a non-ActionFilter condition.
|
|
189
|
+
#
|
|
190
|
+
# @param condition [Object] A proc, symbol, or other condition
|
|
191
|
+
# @return [String]
|
|
192
|
+
def condition_label(condition)
|
|
193
|
+
case condition
|
|
194
|
+
when Symbol then ":#{condition}"
|
|
195
|
+
when Proc then 'Proc'
|
|
196
|
+
when String then condition
|
|
197
|
+
else condition.class.name
|
|
198
|
+
end
|
|
199
|
+
end
|
|
200
|
+
|
|
201
|
+
# Extract namespace from a class name string or class object.
|
|
202
|
+
#
|
|
203
|
+
# Handles both string input (e.g., "Payments::StripeService")
|
|
204
|
+
# and class object input (e.g., a Controller class).
|
|
205
|
+
#
|
|
206
|
+
# @param name_or_object [String, Class, Module] A class name or class object
|
|
207
|
+
# @return [String, nil] The namespace, or nil if top-level
|
|
208
|
+
def extract_namespace(name_or_object)
|
|
209
|
+
name = name_or_object.is_a?(String) ? name_or_object : name_or_object.name
|
|
210
|
+
parts = name.split('::')
|
|
211
|
+
parts.size > 1 ? parts[0..-2].join('::') : nil
|
|
212
|
+
end
|
|
213
|
+
|
|
214
|
+
# Extract public instance and class methods from source code.
|
|
215
|
+
#
|
|
216
|
+
# Walks source line-by-line tracking private/protected visibility.
|
|
217
|
+
# Returns method names that are in public scope and don't start with underscore.
|
|
218
|
+
#
|
|
219
|
+
# @param source [String] Ruby source code
|
|
220
|
+
# @return [Array<String>] Public method names
|
|
221
|
+
def extract_public_methods(source)
|
|
222
|
+
methods = []
|
|
223
|
+
in_private = false
|
|
224
|
+
in_protected = false
|
|
225
|
+
|
|
226
|
+
source.each_line do |line|
|
|
227
|
+
stripped = line.strip
|
|
228
|
+
|
|
229
|
+
in_private = true if stripped == 'private'
|
|
230
|
+
in_protected = true if stripped == 'protected'
|
|
231
|
+
in_private = false if stripped == 'public'
|
|
232
|
+
in_protected = false if stripped == 'public'
|
|
233
|
+
|
|
234
|
+
if !in_private && !in_protected && stripped =~ /def\s+((?:self\.)?\w+[?!=]?)/
|
|
235
|
+
method_name = ::Regexp.last_match(1)
|
|
236
|
+
methods << method_name unless method_name.start_with?('_')
|
|
237
|
+
end
|
|
238
|
+
end
|
|
239
|
+
|
|
240
|
+
methods
|
|
241
|
+
end
|
|
242
|
+
|
|
243
|
+
# Extract class-level (self.) method names from source code.
|
|
244
|
+
#
|
|
245
|
+
# @param source [String] Ruby source code
|
|
246
|
+
# @return [Array<String>] Class method names
|
|
247
|
+
def extract_class_methods(source)
|
|
248
|
+
source.scan(/def\s+self\.(\w+[?!=]?)/).flatten
|
|
249
|
+
end
|
|
250
|
+
|
|
251
|
+
# Extract initialize parameters from source code via regex.
|
|
252
|
+
#
|
|
253
|
+
# Parses the parameter list of the initialize method to determine
|
|
254
|
+
# parameter names, defaults, and whether they are keyword arguments.
|
|
255
|
+
#
|
|
256
|
+
# Note: PhlexExtractor and ViewComponentExtractor override this with a
|
|
257
|
+
# runtime-introspection version that takes a Class object instead of source
|
|
258
|
+
# text, providing richer type information (:req, :opt, :keyreq, :rest, etc.).
|
|
259
|
+
#
|
|
260
|
+
# @param source [String] Ruby source code
|
|
261
|
+
# @return [Array<Hash>] Parameter info hashes with :name, :has_default, :keyword
|
|
262
|
+
def extract_initialize_params(source)
|
|
263
|
+
init_match = source.match(/def\s+initialize\s*\((.*?)\)/m)
|
|
264
|
+
return [] unless init_match
|
|
265
|
+
|
|
266
|
+
params_str = init_match[1]
|
|
267
|
+
params = []
|
|
268
|
+
|
|
269
|
+
params_str.scan(/(\w+)(?::\s*([^,\n]+))?/) do |name, default|
|
|
270
|
+
params << {
|
|
271
|
+
name: name,
|
|
272
|
+
has_default: !default.nil?,
|
|
273
|
+
keyword: params_str.include?("#{name}:")
|
|
274
|
+
}
|
|
275
|
+
end
|
|
276
|
+
|
|
277
|
+
params
|
|
278
|
+
end
|
|
279
|
+
end
|
|
280
|
+
end
|
|
281
|
+
end
|