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,218 @@
|
|
|
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
|
+
# LibExtractor handles extraction of Ruby files from lib/.
|
|
9
|
+
#
|
|
10
|
+
# The lib/ directory contains application infrastructure that sits outside
|
|
11
|
+
# Rails' app/ convention: custom middleware, client wrappers, utility classes,
|
|
12
|
+
# domain-specific libraries, and framework extensions. These are often heavily
|
|
13
|
+
# referenced but invisible to app/-only extractors.
|
|
14
|
+
#
|
|
15
|
+
# Excludes:
|
|
16
|
+
# - lib/tasks/ — handled by RakeTaskExtractor
|
|
17
|
+
# - lib/generators/ — Rails generator scaffolding, not application code
|
|
18
|
+
#
|
|
19
|
+
# Handles:
|
|
20
|
+
# - Plain Ruby classes (with or without inheritance)
|
|
21
|
+
# - Module-only files (standalone modules without a class)
|
|
22
|
+
# - Namespaced classes (e.g., lib/external/analytics.rb → External::Analytics)
|
|
23
|
+
# - Files with multiple class definitions
|
|
24
|
+
#
|
|
25
|
+
# @example
|
|
26
|
+
# extractor = LibExtractor.new
|
|
27
|
+
# units = extractor.extract_all
|
|
28
|
+
# analytics = units.find { |u| u.identifier == "External::Analytics" }
|
|
29
|
+
# analytics.metadata[:entry_points] # => ["call"]
|
|
30
|
+
# analytics.metadata[:parent_class] # => nil
|
|
31
|
+
#
|
|
32
|
+
class LibExtractor
|
|
33
|
+
include SharedUtilityMethods
|
|
34
|
+
include SharedDependencyScanner
|
|
35
|
+
|
|
36
|
+
# Root directory to scan
|
|
37
|
+
LIB_DIRECTORY = 'lib'
|
|
38
|
+
|
|
39
|
+
# Subdirectories to exclude from extraction
|
|
40
|
+
EXCLUDED_SEGMENTS = %w[/tasks/ /generators/].freeze
|
|
41
|
+
|
|
42
|
+
def initialize
|
|
43
|
+
@lib_dir = Rails.root.join(LIB_DIRECTORY)
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
# Extract all lib units from lib/**/*.rb (excluding tasks and generators).
|
|
47
|
+
#
|
|
48
|
+
# @return [Array<ExtractedUnit>] List of lib units
|
|
49
|
+
def extract_all
|
|
50
|
+
return [] unless @lib_dir.directory?
|
|
51
|
+
|
|
52
|
+
Dir[@lib_dir.join('**/*.rb')].filter_map do |file|
|
|
53
|
+
next if excluded_path?(file)
|
|
54
|
+
|
|
55
|
+
extract_lib_file(file)
|
|
56
|
+
end
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
# Extract a single lib file.
|
|
60
|
+
#
|
|
61
|
+
# Returns nil if the file cannot be read or yields no extractable unit.
|
|
62
|
+
# Module-only files are extracted (unlike some other extractors) since
|
|
63
|
+
# lib/ commonly contains standalone utility modules.
|
|
64
|
+
#
|
|
65
|
+
# @param file_path [String] Absolute path to the Ruby file
|
|
66
|
+
# @return [ExtractedUnit, nil] The extracted unit or nil on failure
|
|
67
|
+
def extract_lib_file(file_path)
|
|
68
|
+
source = File.read(file_path)
|
|
69
|
+
|
|
70
|
+
class_name = infer_class_name(file_path, source)
|
|
71
|
+
return nil unless class_name
|
|
72
|
+
|
|
73
|
+
unit = ExtractedUnit.new(
|
|
74
|
+
type: :lib,
|
|
75
|
+
identifier: class_name,
|
|
76
|
+
file_path: file_path
|
|
77
|
+
)
|
|
78
|
+
|
|
79
|
+
unit.namespace = extract_namespace(class_name)
|
|
80
|
+
unit.source_code = annotate_source(source, class_name)
|
|
81
|
+
unit.metadata = extract_metadata(source, class_name)
|
|
82
|
+
unit.dependencies = extract_dependencies(source)
|
|
83
|
+
|
|
84
|
+
unit
|
|
85
|
+
rescue StandardError => e
|
|
86
|
+
Rails.logger.error("Failed to extract lib file #{file_path}: #{e.message}")
|
|
87
|
+
nil
|
|
88
|
+
end
|
|
89
|
+
|
|
90
|
+
private
|
|
91
|
+
|
|
92
|
+
# ──────────────────────────────────────────────────────────────────────
|
|
93
|
+
# Path Filtering
|
|
94
|
+
# ──────────────────────────────────────────────────────────────────────
|
|
95
|
+
|
|
96
|
+
# Return true when the file path falls inside an excluded subdirectory.
|
|
97
|
+
#
|
|
98
|
+
# @param file_path [String] Absolute path to the file
|
|
99
|
+
# @return [Boolean]
|
|
100
|
+
def excluded_path?(file_path)
|
|
101
|
+
EXCLUDED_SEGMENTS.any? { |seg| file_path.include?(seg) }
|
|
102
|
+
end
|
|
103
|
+
|
|
104
|
+
# ──────────────────────────────────────────────────────────────────────
|
|
105
|
+
# Class / Module Name Inference
|
|
106
|
+
# ──────────────────────────────────────────────────────────────────────
|
|
107
|
+
|
|
108
|
+
# Infer the primary constant name from source or fall back to file path.
|
|
109
|
+
#
|
|
110
|
+
# For files with a class definition, combines outer module namespaces
|
|
111
|
+
# with the class name. For module-only files, uses the outermost module
|
|
112
|
+
# name (joined with inner modules). Falls back to path-based camelize
|
|
113
|
+
# when neither is present.
|
|
114
|
+
#
|
|
115
|
+
# @param file_path [String] Absolute path to the file
|
|
116
|
+
# @param source [String] Ruby source code
|
|
117
|
+
# @return [String, nil] The inferred constant name, or nil for empty files
|
|
118
|
+
def infer_class_name(file_path, source)
|
|
119
|
+
return nil if source.strip.empty?
|
|
120
|
+
|
|
121
|
+
# Class definition — combine outer modules + class name
|
|
122
|
+
class_match = source.match(/^\s*class\s+([\w:]+)/)
|
|
123
|
+
if class_match
|
|
124
|
+
base = class_match[1]
|
|
125
|
+
return base if base.include?('::')
|
|
126
|
+
|
|
127
|
+
namespaces = source.scan(/^\s*module\s+([\w:]+)/).flatten
|
|
128
|
+
return namespaces.any? ? "#{namespaces.join('::')}::#{base}" : base
|
|
129
|
+
end
|
|
130
|
+
|
|
131
|
+
# Module-only file — use the outermost module chain
|
|
132
|
+
modules = source.scan(/^\s*module\s+([\w:]+)/).flatten
|
|
133
|
+
return modules.join('::') if modules.any?
|
|
134
|
+
|
|
135
|
+
# Fall back to path-based naming
|
|
136
|
+
path_based_class_name(file_path)
|
|
137
|
+
end
|
|
138
|
+
|
|
139
|
+
# Derive a constant name from a lib/ file path.
|
|
140
|
+
#
|
|
141
|
+
# lib/external/analytics.rb => External::Analytics
|
|
142
|
+
# lib/json_api/serializer.rb => JsonApi::Serializer
|
|
143
|
+
# lib/my_gem.rb => MyGem
|
|
144
|
+
#
|
|
145
|
+
# @param file_path [String] Absolute path to the file
|
|
146
|
+
# @return [String] Camelize-derived constant name
|
|
147
|
+
def path_based_class_name(file_path)
|
|
148
|
+
relative = file_path.sub("#{Rails.root}/", '')
|
|
149
|
+
relative
|
|
150
|
+
.sub(%r{^lib/}, '')
|
|
151
|
+
.sub('.rb', '')
|
|
152
|
+
.split('/')
|
|
153
|
+
.map(&:camelize)
|
|
154
|
+
.join('::')
|
|
155
|
+
end
|
|
156
|
+
|
|
157
|
+
# ──────────────────────────────────────────────────────────────────────
|
|
158
|
+
# Source Annotation
|
|
159
|
+
# ──────────────────────────────────────────────────────────────────────
|
|
160
|
+
|
|
161
|
+
# Prepend a summary annotation header to the source.
|
|
162
|
+
#
|
|
163
|
+
# @param source [String] Ruby source code
|
|
164
|
+
# @param class_name [String] The inferred constant name
|
|
165
|
+
# @return [String] Annotated source
|
|
166
|
+
def annotate_source(source, class_name)
|
|
167
|
+
parent = extract_parent_class(source)
|
|
168
|
+
entry_points = detect_entry_points(source)
|
|
169
|
+
parent_label = parent || 'none'
|
|
170
|
+
|
|
171
|
+
annotation = <<~ANNOTATION
|
|
172
|
+
# ╔═══════════════════════════════════════════════════════════════════════╗
|
|
173
|
+
# ║ Lib: #{class_name.ljust(65)}║
|
|
174
|
+
# ║ Parent: #{parent_label.ljust(61)}║
|
|
175
|
+
# ║ Entry Points: #{entry_points.join(', ').ljust(55)}║
|
|
176
|
+
# ╚═══════════════════════════════════════════════════════════════════════╝
|
|
177
|
+
|
|
178
|
+
ANNOTATION
|
|
179
|
+
|
|
180
|
+
annotation + source
|
|
181
|
+
end
|
|
182
|
+
|
|
183
|
+
# ──────────────────────────────────────────────────────────────────────
|
|
184
|
+
# Metadata Extraction
|
|
185
|
+
# ──────────────────────────────────────────────────────────────────────
|
|
186
|
+
|
|
187
|
+
# Build the metadata hash for a lib unit.
|
|
188
|
+
#
|
|
189
|
+
# @param source [String] Ruby source code
|
|
190
|
+
# @param class_name [String] The inferred constant name
|
|
191
|
+
# @return [Hash] Lib unit metadata
|
|
192
|
+
def extract_metadata(source, _class_name)
|
|
193
|
+
{
|
|
194
|
+
public_methods: extract_public_methods(source),
|
|
195
|
+
class_methods: extract_class_methods(source),
|
|
196
|
+
initialize_params: extract_initialize_params(source),
|
|
197
|
+
parent_class: extract_parent_class(source),
|
|
198
|
+
loc: count_loc(source),
|
|
199
|
+
method_count: source.scan(/def\s+(?:self\.)?\w+/).size,
|
|
200
|
+
entry_points: detect_entry_points(source)
|
|
201
|
+
}
|
|
202
|
+
end
|
|
203
|
+
|
|
204
|
+
# ──────────────────────────────────────────────────────────────────────
|
|
205
|
+
# Dependency Extraction
|
|
206
|
+
# ──────────────────────────────────────────────────────────────────────
|
|
207
|
+
|
|
208
|
+
# Build the dependency array using common dependency scanners.
|
|
209
|
+
#
|
|
210
|
+
# @param source [String] Ruby source code
|
|
211
|
+
# @return [Array<Hash>] Dependency hashes with :type, :target, :via
|
|
212
|
+
def extract_dependencies(source)
|
|
213
|
+
deps = scan_common_dependencies(source)
|
|
214
|
+
deps.uniq { |d| [d[:type], d[:target]] }
|
|
215
|
+
end
|
|
216
|
+
end
|
|
217
|
+
end
|
|
218
|
+
end
|
|
@@ -0,0 +1,269 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'digest'
|
|
4
|
+
require_relative 'ast_source_extraction'
|
|
5
|
+
require_relative 'shared_utility_methods'
|
|
6
|
+
require_relative 'shared_dependency_scanner'
|
|
7
|
+
|
|
8
|
+
module Woods
|
|
9
|
+
module Extractors
|
|
10
|
+
# MailerExtractor handles ActionMailer extraction.
|
|
11
|
+
#
|
|
12
|
+
# Mailers are important for understanding:
|
|
13
|
+
# - What triggers emails (traced via dependencies)
|
|
14
|
+
# - What data flows into emails
|
|
15
|
+
# - Template associations
|
|
16
|
+
#
|
|
17
|
+
# @example
|
|
18
|
+
# extractor = MailerExtractor.new
|
|
19
|
+
# units = extractor.extract_all
|
|
20
|
+
# user_mailer = units.find { |u| u.identifier == "UserMailer" }
|
|
21
|
+
#
|
|
22
|
+
class MailerExtractor
|
|
23
|
+
include AstSourceExtraction
|
|
24
|
+
include SharedUtilityMethods
|
|
25
|
+
include SharedDependencyScanner
|
|
26
|
+
|
|
27
|
+
def initialize
|
|
28
|
+
@mailer_base = defined?(ApplicationMailer) ? ApplicationMailer : ActionMailer::Base
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
# Extract all mailers in the application
|
|
32
|
+
#
|
|
33
|
+
# @return [Array<ExtractedUnit>] List of mailer units
|
|
34
|
+
def extract_all
|
|
35
|
+
@mailer_base.descendants.map do |mailer|
|
|
36
|
+
extract_mailer(mailer)
|
|
37
|
+
end.compact
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
# Extract a single mailer
|
|
41
|
+
#
|
|
42
|
+
# @param mailer [Class] The mailer class
|
|
43
|
+
# @return [ExtractedUnit] The extracted unit
|
|
44
|
+
def extract_mailer(mailer)
|
|
45
|
+
return nil if mailer.name.nil?
|
|
46
|
+
return nil if mailer == ActionMailer::Base
|
|
47
|
+
|
|
48
|
+
file_path = source_file_for(mailer)
|
|
49
|
+
|
|
50
|
+
unit = ExtractedUnit.new(
|
|
51
|
+
type: :mailer,
|
|
52
|
+
identifier: mailer.name,
|
|
53
|
+
file_path: file_path
|
|
54
|
+
)
|
|
55
|
+
|
|
56
|
+
source = file_path && File.exist?(file_path) ? File.read(file_path) : ''
|
|
57
|
+
|
|
58
|
+
unit.namespace = extract_namespace(mailer)
|
|
59
|
+
unit.source_code = annotate_source(source, mailer)
|
|
60
|
+
unit.metadata = extract_metadata(mailer, source)
|
|
61
|
+
unit.dependencies = extract_dependencies(source)
|
|
62
|
+
|
|
63
|
+
# Create chunks for each mail action
|
|
64
|
+
unit.chunks = build_action_chunks(mailer, source)
|
|
65
|
+
|
|
66
|
+
unit
|
|
67
|
+
rescue StandardError => e
|
|
68
|
+
Rails.logger.error("Failed to extract mailer #{mailer.name}: #{e.message}")
|
|
69
|
+
nil
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
private
|
|
73
|
+
|
|
74
|
+
# Locate the source file for a mailer class.
|
|
75
|
+
#
|
|
76
|
+
# Convention path first, then introspection via {#resolve_source_location}
|
|
77
|
+
# which filters out vendor/node_modules paths.
|
|
78
|
+
#
|
|
79
|
+
# @param mailer [Class]
|
|
80
|
+
# @return [String]
|
|
81
|
+
def source_file_for(mailer)
|
|
82
|
+
convention_path = Rails.root.join("app/mailers/#{mailer.name.underscore}.rb").to_s
|
|
83
|
+
return convention_path if File.exist?(convention_path)
|
|
84
|
+
|
|
85
|
+
resolve_source_location(mailer, app_root: Rails.root.to_s, fallback: convention_path)
|
|
86
|
+
end
|
|
87
|
+
|
|
88
|
+
# ──────────────────────────────────────────────────────────────────────
|
|
89
|
+
# Source Annotation
|
|
90
|
+
# ──────────────────────────────────────────────────────────────────────
|
|
91
|
+
|
|
92
|
+
def annotate_source(source, mailer)
|
|
93
|
+
actions = mailer.action_methods.to_a
|
|
94
|
+
default_from = begin
|
|
95
|
+
mailer.default[:from]
|
|
96
|
+
rescue StandardError
|
|
97
|
+
nil
|
|
98
|
+
end
|
|
99
|
+
|
|
100
|
+
<<~ANNOTATION
|
|
101
|
+
# ╔═══════════════════════════════════════════════════════════════════════╗
|
|
102
|
+
# ║ Mailer: #{mailer.name.ljust(60)}║
|
|
103
|
+
# ║ Actions: #{actions.first(5).join(', ').ljust(59)}║
|
|
104
|
+
# ║ Default From: #{(default_from || 'not set').to_s.ljust(54)}║
|
|
105
|
+
# ╚═══════════════════════════════════════════════════════════════════════╝
|
|
106
|
+
|
|
107
|
+
#{source}
|
|
108
|
+
ANNOTATION
|
|
109
|
+
end
|
|
110
|
+
|
|
111
|
+
# ──────────────────────────────────────────────────────────────────────
|
|
112
|
+
# Metadata Extraction
|
|
113
|
+
# ──────────────────────────────────────────────────────────────────────
|
|
114
|
+
|
|
115
|
+
def extract_metadata(mailer, source)
|
|
116
|
+
actions = mailer.action_methods.to_a
|
|
117
|
+
|
|
118
|
+
{
|
|
119
|
+
# Actions (mail methods)
|
|
120
|
+
actions: actions,
|
|
121
|
+
|
|
122
|
+
# Default settings
|
|
123
|
+
defaults: extract_defaults(mailer),
|
|
124
|
+
|
|
125
|
+
# Delivery configuration
|
|
126
|
+
delivery_method: mailer.delivery_method,
|
|
127
|
+
|
|
128
|
+
# Callbacks
|
|
129
|
+
callbacks: extract_callbacks(mailer),
|
|
130
|
+
|
|
131
|
+
# Layout
|
|
132
|
+
layout: extract_layout(mailer, source),
|
|
133
|
+
|
|
134
|
+
# Helper modules
|
|
135
|
+
helpers: extract_helpers(source),
|
|
136
|
+
|
|
137
|
+
# Templates (if discoverable)
|
|
138
|
+
templates: discover_templates(mailer, actions),
|
|
139
|
+
|
|
140
|
+
# Metrics
|
|
141
|
+
action_count: actions.size,
|
|
142
|
+
loc: source.lines.count { |l| l.strip.present? && !l.strip.start_with?('#') }
|
|
143
|
+
}
|
|
144
|
+
end
|
|
145
|
+
|
|
146
|
+
def extract_defaults(mailer)
|
|
147
|
+
mailer_defaults = mailer.default
|
|
148
|
+
mailer_defaults.slice(:from, :reply_to, :cc, :bcc).compact
|
|
149
|
+
rescue StandardError
|
|
150
|
+
{}
|
|
151
|
+
end
|
|
152
|
+
|
|
153
|
+
def extract_callbacks(mailer)
|
|
154
|
+
mailer._process_action_callbacks.map do |cb|
|
|
155
|
+
only, except, if_conds, unless_conds = extract_callback_conditions(cb)
|
|
156
|
+
|
|
157
|
+
result = {
|
|
158
|
+
type: :"#{cb.kind}_action",
|
|
159
|
+
filter: cb.filter.to_s
|
|
160
|
+
}
|
|
161
|
+
result[:only] = only if only.any?
|
|
162
|
+
result[:except] = except if except.any?
|
|
163
|
+
result[:if] = if_conds.join(', ') if if_conds.any?
|
|
164
|
+
result[:unless] = unless_conds.join(', ') if unless_conds.any?
|
|
165
|
+
result
|
|
166
|
+
end
|
|
167
|
+
rescue StandardError
|
|
168
|
+
[]
|
|
169
|
+
end
|
|
170
|
+
|
|
171
|
+
def extract_layout(mailer, source)
|
|
172
|
+
# From class definition
|
|
173
|
+
return ::Regexp.last_match(1) if source =~ /layout\s+['":](\w+)/
|
|
174
|
+
|
|
175
|
+
# From class method
|
|
176
|
+
begin
|
|
177
|
+
mailer._layout
|
|
178
|
+
rescue StandardError
|
|
179
|
+
nil
|
|
180
|
+
end
|
|
181
|
+
end
|
|
182
|
+
|
|
183
|
+
def extract_helpers(source)
|
|
184
|
+
helpers = []
|
|
185
|
+
|
|
186
|
+
source.scan(/helper\s+[:\s]?(\w+)/) do |helper|
|
|
187
|
+
helpers << helper[0]
|
|
188
|
+
end
|
|
189
|
+
|
|
190
|
+
source.scan(/include\s+(\w+Helper)/) do |helper|
|
|
191
|
+
helpers << helper[0]
|
|
192
|
+
end
|
|
193
|
+
|
|
194
|
+
helpers.uniq
|
|
195
|
+
end
|
|
196
|
+
|
|
197
|
+
def discover_templates(mailer, actions)
|
|
198
|
+
templates = {}
|
|
199
|
+
mailer_path = mailer.name.underscore
|
|
200
|
+
|
|
201
|
+
actions.each do |action|
|
|
202
|
+
view_paths = [
|
|
203
|
+
Rails.root.join("app/views/#{mailer_path}/#{action}.html.erb"),
|
|
204
|
+
Rails.root.join("app/views/#{mailer_path}/#{action}.text.erb"),
|
|
205
|
+
Rails.root.join("app/views/#{mailer_path}/#{action}.html.slim"),
|
|
206
|
+
Rails.root.join("app/views/#{mailer_path}/#{action}.text.slim")
|
|
207
|
+
]
|
|
208
|
+
|
|
209
|
+
found = view_paths.select { |p| File.exist?(p) }
|
|
210
|
+
.map { |p| p.to_s.sub("#{Rails.root}/", '') }
|
|
211
|
+
|
|
212
|
+
templates[action] = found if found.any?
|
|
213
|
+
end
|
|
214
|
+
|
|
215
|
+
templates
|
|
216
|
+
end
|
|
217
|
+
|
|
218
|
+
# ──────────────────────────────────────────────────────────────────────
|
|
219
|
+
# Dependency Extraction
|
|
220
|
+
# ──────────────────────────────────────────────────────────────────────
|
|
221
|
+
|
|
222
|
+
def extract_dependencies(source)
|
|
223
|
+
deps = []
|
|
224
|
+
deps.concat(scan_model_dependencies(source))
|
|
225
|
+
deps.concat(scan_service_dependencies(source))
|
|
226
|
+
|
|
227
|
+
# URL helpers (indicates what resources emails link to)
|
|
228
|
+
source.scan(/(\w+)_(?:url|path)/).flatten.uniq.each do |route|
|
|
229
|
+
deps << { type: :route, target: route, via: :url_helper }
|
|
230
|
+
end
|
|
231
|
+
|
|
232
|
+
deps.uniq { |d| [d[:type], d[:target]] }
|
|
233
|
+
end
|
|
234
|
+
|
|
235
|
+
# ──────────────────────────────────────────────────────────────────────
|
|
236
|
+
# Action Chunks
|
|
237
|
+
# ──────────────────────────────────────────────────────────────────────
|
|
238
|
+
|
|
239
|
+
def build_action_chunks(mailer, _source)
|
|
240
|
+
mailer.action_methods.filter_map do |action|
|
|
241
|
+
action_source = extract_action_source(mailer, action)
|
|
242
|
+
next if action_source.nil? || action_source.strip.empty?
|
|
243
|
+
|
|
244
|
+
templates = discover_templates(mailer, [action.to_s])[action.to_s] || []
|
|
245
|
+
|
|
246
|
+
chunk_content = <<~ACTION
|
|
247
|
+
# Mailer: #{mailer.name}
|
|
248
|
+
# Action: #{action}
|
|
249
|
+
# Templates: #{templates.any? ? templates.join(', ') : 'none found'}
|
|
250
|
+
|
|
251
|
+
#{action_source}
|
|
252
|
+
ACTION
|
|
253
|
+
|
|
254
|
+
{
|
|
255
|
+
chunk_type: :mail_action,
|
|
256
|
+
identifier: "#{mailer.name}##{action}",
|
|
257
|
+
content: chunk_content,
|
|
258
|
+
content_hash: Digest::SHA256.hexdigest(chunk_content),
|
|
259
|
+
metadata: {
|
|
260
|
+
parent: mailer.name,
|
|
261
|
+
action: action.to_s,
|
|
262
|
+
templates: templates
|
|
263
|
+
}
|
|
264
|
+
}
|
|
265
|
+
end
|
|
266
|
+
end
|
|
267
|
+
end
|
|
268
|
+
end
|
|
269
|
+
end
|
|
@@ -0,0 +1,188 @@
|
|
|
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
|
+
# ManagerExtractor handles SimpleDelegator subclass extraction.
|
|
9
|
+
#
|
|
10
|
+
# Manager/delegator objects wrap a model and provide a richer interface
|
|
11
|
+
# for specific contexts (e.g., OrderManager wrapping Order with
|
|
12
|
+
# checkout-specific methods). They live in `app/managers/`.
|
|
13
|
+
#
|
|
14
|
+
# We extract:
|
|
15
|
+
# - Wrapped model (via SimpleDelegator superclass or initializer)
|
|
16
|
+
# - Public methods (the manager's added interface)
|
|
17
|
+
# - Delegation chain (what gets delegated vs overridden)
|
|
18
|
+
# - Dependencies (what models/services they reference)
|
|
19
|
+
#
|
|
20
|
+
# @example
|
|
21
|
+
# extractor = ManagerExtractor.new
|
|
22
|
+
# units = extractor.extract_all
|
|
23
|
+
# order_mgr = units.find { |u| u.identifier == "OrderManager" }
|
|
24
|
+
#
|
|
25
|
+
class ManagerExtractor
|
|
26
|
+
include SharedUtilityMethods
|
|
27
|
+
include SharedDependencyScanner
|
|
28
|
+
|
|
29
|
+
# Directories to scan for manager/delegator objects
|
|
30
|
+
MANAGER_DIRECTORIES = %w[
|
|
31
|
+
app/managers
|
|
32
|
+
].freeze
|
|
33
|
+
|
|
34
|
+
def initialize
|
|
35
|
+
@directories = MANAGER_DIRECTORIES.map { |d| Rails.root.join(d) }
|
|
36
|
+
.select(&:directory?)
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
# Extract all manager/delegator objects
|
|
40
|
+
#
|
|
41
|
+
# @return [Array<ExtractedUnit>] List of manager units
|
|
42
|
+
def extract_all
|
|
43
|
+
@directories.flat_map do |dir|
|
|
44
|
+
Dir[dir.join('**/*.rb')].filter_map do |file|
|
|
45
|
+
extract_manager_file(file)
|
|
46
|
+
end
|
|
47
|
+
end
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
# Extract a single manager file
|
|
51
|
+
#
|
|
52
|
+
# @param file_path [String] Path to the manager file
|
|
53
|
+
# @return [ExtractedUnit, nil] The extracted unit or nil if not a manager
|
|
54
|
+
def extract_manager_file(file_path)
|
|
55
|
+
source = File.read(file_path)
|
|
56
|
+
class_name = extract_class_name(file_path, source, 'managers')
|
|
57
|
+
|
|
58
|
+
return nil unless class_name
|
|
59
|
+
return nil unless manager_file?(source)
|
|
60
|
+
|
|
61
|
+
unit = ExtractedUnit.new(
|
|
62
|
+
type: :manager,
|
|
63
|
+
identifier: class_name,
|
|
64
|
+
file_path: file_path
|
|
65
|
+
)
|
|
66
|
+
|
|
67
|
+
unit.namespace = extract_namespace(class_name)
|
|
68
|
+
unit.source_code = annotate_source(source, class_name)
|
|
69
|
+
unit.metadata = extract_metadata(source, class_name)
|
|
70
|
+
unit.dependencies = extract_dependencies(source, class_name)
|
|
71
|
+
|
|
72
|
+
unit
|
|
73
|
+
rescue StandardError => e
|
|
74
|
+
Rails.logger.error("Failed to extract manager #{file_path}: #{e.message}")
|
|
75
|
+
nil
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
private
|
|
79
|
+
|
|
80
|
+
# ──────────────────────────────────────────────────────────────────────
|
|
81
|
+
# Class Discovery
|
|
82
|
+
# ──────────────────────────────────────────────────────────────────────
|
|
83
|
+
|
|
84
|
+
def manager_file?(source)
|
|
85
|
+
source.match?(/< SimpleDelegator/) ||
|
|
86
|
+
source.match?(/< DelegateClass\(/) ||
|
|
87
|
+
source.match?(/include Delegator/)
|
|
88
|
+
end
|
|
89
|
+
|
|
90
|
+
# ──────────────────────────────────────────────────────────────────────
|
|
91
|
+
# Source Annotation
|
|
92
|
+
# ──────────────────────────────────────────────────────────────────────
|
|
93
|
+
|
|
94
|
+
def annotate_source(source, class_name)
|
|
95
|
+
wrapped = detect_wrapped_model(source, class_name)
|
|
96
|
+
|
|
97
|
+
<<~ANNOTATION
|
|
98
|
+
# ╔═══════════════════════════════════════════════════════════════════════╗
|
|
99
|
+
# ║ Manager: #{class_name.ljust(60)}║
|
|
100
|
+
# ║ Wraps: #{(wrapped || 'unknown').ljust(61)}║
|
|
101
|
+
# ╚═══════════════════════════════════════════════════════════════════════╝
|
|
102
|
+
|
|
103
|
+
#{source}
|
|
104
|
+
ANNOTATION
|
|
105
|
+
end
|
|
106
|
+
|
|
107
|
+
# ──────────────────────────────────────────────────────────────────────
|
|
108
|
+
# Metadata Extraction
|
|
109
|
+
# ──────────────────────────────────────────────────────────────────────
|
|
110
|
+
|
|
111
|
+
def extract_metadata(source, class_name)
|
|
112
|
+
{
|
|
113
|
+
wrapped_model: detect_wrapped_model(source, class_name),
|
|
114
|
+
delegation_type: detect_delegation_type(source),
|
|
115
|
+
public_methods: extract_public_methods(source),
|
|
116
|
+
class_methods: extract_class_methods(source),
|
|
117
|
+
initialize_params: extract_initialize_params(source),
|
|
118
|
+
delegated_methods: extract_delegated_methods(source),
|
|
119
|
+
overridden_methods: extract_overridden_methods(source),
|
|
120
|
+
custom_errors: extract_custom_errors(source),
|
|
121
|
+
loc: source.lines.count { |l| l.strip.length.positive? && !l.strip.start_with?('#') },
|
|
122
|
+
method_count: source.scan(/def\s+(?:self\.)?\w+/).size
|
|
123
|
+
}
|
|
124
|
+
end
|
|
125
|
+
|
|
126
|
+
def detect_wrapped_model(source, class_name)
|
|
127
|
+
# DelegateClass(ModelName) pattern
|
|
128
|
+
return ::Regexp.last_match(1) if source =~ /< DelegateClass\((\w+)\)/
|
|
129
|
+
|
|
130
|
+
# super(model) in initialize
|
|
131
|
+
return ::Regexp.last_match(1).capitalize if source =~ /super\((\w+)\)/
|
|
132
|
+
|
|
133
|
+
# @model = model; super(model) — look for param name
|
|
134
|
+
if source =~ /def\s+initialize\s*\((\w+)/
|
|
135
|
+
param = ::Regexp.last_match(1)
|
|
136
|
+
return param.capitalize unless %w[args options params attributes].include?(param)
|
|
137
|
+
end
|
|
138
|
+
|
|
139
|
+
# Infer from class name: OrderManager -> Order
|
|
140
|
+
stripped = class_name.split('::').last
|
|
141
|
+
inferred = stripped.sub(/Manager\z/, '')
|
|
142
|
+
# Return nil if no suffix was removed (not a FooManager pattern)
|
|
143
|
+
return nil if inferred == stripped || inferred.empty?
|
|
144
|
+
|
|
145
|
+
inferred
|
|
146
|
+
end
|
|
147
|
+
|
|
148
|
+
def detect_delegation_type(source)
|
|
149
|
+
return :delegate_class if source.match?(/< DelegateClass\(/)
|
|
150
|
+
return :simple_delegator if source.match?(/< SimpleDelegator/)
|
|
151
|
+
|
|
152
|
+
:unknown
|
|
153
|
+
end
|
|
154
|
+
|
|
155
|
+
def extract_delegated_methods(source)
|
|
156
|
+
methods = []
|
|
157
|
+
|
|
158
|
+
# delegate :foo, :bar, to: :something
|
|
159
|
+
source.scan(/delegate\s+(.+?)(?:,\s*to:)/) do |match|
|
|
160
|
+
match[0].scan(/:(\w+)/).flatten.each { |m| methods << m }
|
|
161
|
+
end
|
|
162
|
+
|
|
163
|
+
methods
|
|
164
|
+
end
|
|
165
|
+
|
|
166
|
+
def extract_overridden_methods(source)
|
|
167
|
+
# Methods that call super — these override delegated behavior
|
|
168
|
+
source.scan(/def\s+(\w+[?!=]?).*?\n.*?super/m).flatten
|
|
169
|
+
end
|
|
170
|
+
|
|
171
|
+
# ──────────────────────────────────────────────────────────────────────
|
|
172
|
+
# Dependency Extraction
|
|
173
|
+
# ──────────────────────────────────────────────────────────────────────
|
|
174
|
+
|
|
175
|
+
def extract_dependencies(source, class_name)
|
|
176
|
+
deps = []
|
|
177
|
+
|
|
178
|
+
# Wrapped model dependency (specific :via)
|
|
179
|
+
wrapped = detect_wrapped_model(source, class_name)
|
|
180
|
+
deps << { type: :model, target: wrapped, via: :delegation } if wrapped
|
|
181
|
+
|
|
182
|
+
deps.concat(scan_common_dependencies(source))
|
|
183
|
+
|
|
184
|
+
deps.uniq { |d| [d[:type], d[:target]] }
|
|
185
|
+
end
|
|
186
|
+
end
|
|
187
|
+
end
|
|
188
|
+
end
|