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,253 @@
|
|
|
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
|
+
# DecoratorExtractor handles decorator, presenter, and form object extraction.
|
|
9
|
+
#
|
|
10
|
+
# Scans conventional directories for view-layer wrapper objects:
|
|
11
|
+
# decorators (Draper-style or PORO), presenters, and form objects.
|
|
12
|
+
# Extracts the decorated model relationship, delegation chains, and
|
|
13
|
+
# whether the Draper gem is in use.
|
|
14
|
+
#
|
|
15
|
+
# @example
|
|
16
|
+
# extractor = DecoratorExtractor.new
|
|
17
|
+
# units = extractor.extract_all
|
|
18
|
+
# user_dec = units.find { |u| u.identifier == "UserDecorator" }
|
|
19
|
+
# user_dec.metadata[:decorated_model] # => "User"
|
|
20
|
+
# user_dec.metadata[:uses_draper] # => true
|
|
21
|
+
#
|
|
22
|
+
class DecoratorExtractor
|
|
23
|
+
include SharedUtilityMethods
|
|
24
|
+
include SharedDependencyScanner
|
|
25
|
+
|
|
26
|
+
# Directories to scan for decorator-style objects
|
|
27
|
+
DECORATOR_DIRECTORIES = %w[
|
|
28
|
+
app/decorators
|
|
29
|
+
app/presenters
|
|
30
|
+
app/form_objects
|
|
31
|
+
].freeze
|
|
32
|
+
|
|
33
|
+
# Maps directory segment to decorator_type symbol
|
|
34
|
+
DIRECTORY_TYPE_MAP = {
|
|
35
|
+
'decorators' => :decorator,
|
|
36
|
+
'presenters' => :presenter,
|
|
37
|
+
'form_objects' => :form_object
|
|
38
|
+
}.freeze
|
|
39
|
+
|
|
40
|
+
# Suffixes used to infer the decorated model name
|
|
41
|
+
DECORATOR_SUFFIXES = %w[Decorator Presenter Form].freeze
|
|
42
|
+
|
|
43
|
+
def initialize
|
|
44
|
+
@directories = DECORATOR_DIRECTORIES.map { |d| Rails.root.join(d) }
|
|
45
|
+
.select(&:directory?)
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
# Extract all decorator, presenter, and form object units.
|
|
49
|
+
#
|
|
50
|
+
# @return [Array<ExtractedUnit>] List of decorator units
|
|
51
|
+
def extract_all
|
|
52
|
+
@directories.flat_map do |dir|
|
|
53
|
+
Dir[dir.join('**/*.rb')].filter_map do |file|
|
|
54
|
+
extract_decorator_file(file)
|
|
55
|
+
end
|
|
56
|
+
end
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
# Extract a single decorator file.
|
|
60
|
+
#
|
|
61
|
+
# @param file_path [String] Absolute path to the Ruby file
|
|
62
|
+
# @return [ExtractedUnit, nil] The extracted unit or nil if not a decorator
|
|
63
|
+
def extract_decorator_file(file_path)
|
|
64
|
+
source = File.read(file_path)
|
|
65
|
+
class_name = extract_class_name(file_path, source)
|
|
66
|
+
|
|
67
|
+
return nil unless class_name
|
|
68
|
+
return nil if skip_file?(source)
|
|
69
|
+
|
|
70
|
+
unit = ExtractedUnit.new(
|
|
71
|
+
type: :decorator,
|
|
72
|
+
identifier: class_name,
|
|
73
|
+
file_path: file_path
|
|
74
|
+
)
|
|
75
|
+
|
|
76
|
+
unit.namespace = extract_namespace(class_name)
|
|
77
|
+
unit.source_code = annotate_source(source, class_name, file_path)
|
|
78
|
+
unit.metadata = extract_metadata(source, class_name, file_path)
|
|
79
|
+
unit.dependencies = extract_dependencies(source, class_name)
|
|
80
|
+
|
|
81
|
+
unit
|
|
82
|
+
rescue StandardError => e
|
|
83
|
+
Rails.logger.error("Failed to extract decorator #{file_path}: #{e.message}")
|
|
84
|
+
nil
|
|
85
|
+
end
|
|
86
|
+
|
|
87
|
+
private
|
|
88
|
+
|
|
89
|
+
# ──────────────────────────────────────────────────────────────────────
|
|
90
|
+
# Class Discovery
|
|
91
|
+
# ──────────────────────────────────────────────────────────────────────
|
|
92
|
+
|
|
93
|
+
# Override SharedUtilityMethods#extract_class_name for decorator-specific
|
|
94
|
+
# namespace resolution. The shared version only matches `class Foo::Bar`
|
|
95
|
+
# (inline namespacing); this version also handles `module Admin / class
|
|
96
|
+
# UserDecorator` (block namespacing) by scanning for enclosing modules.
|
|
97
|
+
#
|
|
98
|
+
# @param file_path [String] Path to the file
|
|
99
|
+
# @param source [String] Ruby source code
|
|
100
|
+
# @return [String, nil] The class name or nil
|
|
101
|
+
def extract_class_name(file_path, source)
|
|
102
|
+
namespaces = source.scan(/^\s*module\s+([\w:]+)/).flatten
|
|
103
|
+
class_match = source.match(/^\s*class\s+([\w:]+)/)
|
|
104
|
+
|
|
105
|
+
if class_match
|
|
106
|
+
base_class = class_match[1]
|
|
107
|
+
if namespaces.any? && !base_class.include?('::')
|
|
108
|
+
"#{namespaces.join('::')}::#{base_class}"
|
|
109
|
+
else
|
|
110
|
+
base_class
|
|
111
|
+
end
|
|
112
|
+
else
|
|
113
|
+
relative = file_path.sub("#{Rails.root}/", '')
|
|
114
|
+
relative
|
|
115
|
+
.sub(%r{^app/(decorators|presenters|form_objects)/}, '')
|
|
116
|
+
.sub('.rb', '')
|
|
117
|
+
.camelize
|
|
118
|
+
end
|
|
119
|
+
end
|
|
120
|
+
|
|
121
|
+
# ──────────────────────────────────────────────────────────────────────
|
|
122
|
+
# Source Annotation
|
|
123
|
+
# ──────────────────────────────────────────────────────────────────────
|
|
124
|
+
|
|
125
|
+
# Prepend a summary annotation header to the source.
|
|
126
|
+
#
|
|
127
|
+
# @param source [String] Ruby source code
|
|
128
|
+
# @param class_name [String] The class name
|
|
129
|
+
# @param file_path [String] Path to the file
|
|
130
|
+
# @return [String] Annotated source
|
|
131
|
+
def annotate_source(source, class_name, file_path)
|
|
132
|
+
decorator_type = infer_decorator_type(file_path)
|
|
133
|
+
decorated_model = infer_decorated_model(class_name)
|
|
134
|
+
|
|
135
|
+
annotation = <<~ANNOTATION
|
|
136
|
+
# ╔═══════════════════════════════════════════════════════════════════════╗
|
|
137
|
+
# ║ Decorator: #{class_name.ljust(57)}║
|
|
138
|
+
# ║ Type: #{decorator_type.to_s.ljust(62)}║
|
|
139
|
+
# ║ Decorates: #{(decorated_model || 'unknown').ljust(57)}║
|
|
140
|
+
# ╚═══════════════════════════════════════════════════════════════════════╝
|
|
141
|
+
|
|
142
|
+
ANNOTATION
|
|
143
|
+
|
|
144
|
+
annotation + source
|
|
145
|
+
end
|
|
146
|
+
|
|
147
|
+
# ──────────────────────────────────────────────────────────────────────
|
|
148
|
+
# Metadata Extraction
|
|
149
|
+
# ──────────────────────────────────────────────────────────────────────
|
|
150
|
+
|
|
151
|
+
# Build the metadata hash for a decorator unit.
|
|
152
|
+
#
|
|
153
|
+
# @param source [String] Ruby source code
|
|
154
|
+
# @param class_name [String] The class name
|
|
155
|
+
# @param file_path [String] Path to the file
|
|
156
|
+
# @return [Hash] Decorator metadata
|
|
157
|
+
def extract_metadata(source, class_name, file_path)
|
|
158
|
+
{
|
|
159
|
+
decorator_type: infer_decorator_type(file_path),
|
|
160
|
+
decorated_model: infer_decorated_model(class_name),
|
|
161
|
+
uses_draper: draper?(source),
|
|
162
|
+
delegated_methods: extract_delegated_methods(source),
|
|
163
|
+
public_methods: extract_public_methods(source),
|
|
164
|
+
entry_points: detect_entry_points(source),
|
|
165
|
+
class_methods: extract_class_methods(source),
|
|
166
|
+
initialize_params: extract_initialize_params(source),
|
|
167
|
+
loc: source.lines.count { |l| l.strip.length.positive? && !l.strip.start_with?('#') }
|
|
168
|
+
}
|
|
169
|
+
end
|
|
170
|
+
|
|
171
|
+
# Infer the decorator_type symbol from the file path.
|
|
172
|
+
#
|
|
173
|
+
# @param file_path [String] Absolute path to the file
|
|
174
|
+
# @return [Symbol] :decorator, :presenter, or :form_object
|
|
175
|
+
def infer_decorator_type(file_path)
|
|
176
|
+
DIRECTORY_TYPE_MAP.each do |dir_segment, type|
|
|
177
|
+
return type if file_path.include?("/#{dir_segment}/")
|
|
178
|
+
end
|
|
179
|
+
:decorator
|
|
180
|
+
end
|
|
181
|
+
|
|
182
|
+
# Infer the decorated model name by stripping known suffixes.
|
|
183
|
+
#
|
|
184
|
+
# @param class_name [String] e.g. "UserDecorator", "ProductPresenter"
|
|
185
|
+
# @return [String, nil] e.g. "User", "Product", or nil if not inferable
|
|
186
|
+
def infer_decorated_model(class_name)
|
|
187
|
+
base = class_name.split('::').last
|
|
188
|
+
DECORATOR_SUFFIXES.each do |suffix|
|
|
189
|
+
return base.delete_suffix(suffix) if base.end_with?(suffix) && base.length > suffix.length
|
|
190
|
+
end
|
|
191
|
+
nil
|
|
192
|
+
end
|
|
193
|
+
|
|
194
|
+
# Detect whether the class uses the Draper gem.
|
|
195
|
+
#
|
|
196
|
+
# @param source [String] Ruby source code
|
|
197
|
+
# @return [Boolean]
|
|
198
|
+
def draper?(source)
|
|
199
|
+
source.match?(/Draper::Decorator/)
|
|
200
|
+
end
|
|
201
|
+
|
|
202
|
+
# Extract method names passed to `delegate` calls.
|
|
203
|
+
#
|
|
204
|
+
# @param source [String] Ruby source code
|
|
205
|
+
# @return [Array<String>] Delegated method names
|
|
206
|
+
def extract_delegated_methods(source)
|
|
207
|
+
methods = []
|
|
208
|
+
source.scan(/\bdelegate\s+(.*?)(?:,\s*to:|$)/m) do |match|
|
|
209
|
+
match[0].scan(/:(\w+)/).flatten.each { |m| methods << m }
|
|
210
|
+
end
|
|
211
|
+
methods.uniq
|
|
212
|
+
end
|
|
213
|
+
|
|
214
|
+
# Override SharedUtilityMethods#detect_entry_points with decorator-specific
|
|
215
|
+
# entry points (decorate, present, to_partial_path) instead of the generic
|
|
216
|
+
# service-oriented ones (perform, execute, run, process).
|
|
217
|
+
#
|
|
218
|
+
# @param source [String] Ruby source code
|
|
219
|
+
# @return [Array<String>] Entry point method names
|
|
220
|
+
def detect_entry_points(source)
|
|
221
|
+
points = []
|
|
222
|
+
points << 'call' if source.match?(/def (self\.)?call\b/)
|
|
223
|
+
points << 'decorate' if source.match?(/def (self\.)?decorate\b/)
|
|
224
|
+
points << 'present' if source.match?(/def (self\.)?present\b/)
|
|
225
|
+
points << 'to_partial_path' if source.match?(/def to_partial_path\b/)
|
|
226
|
+
points.empty? ? ['unknown'] : points
|
|
227
|
+
end
|
|
228
|
+
|
|
229
|
+
# ──────────────────────────────────────────────────────────────────────
|
|
230
|
+
# Dependency Extraction
|
|
231
|
+
# ──────────────────────────────────────────────────────────────────────
|
|
232
|
+
|
|
233
|
+
# Build the dependency array for a decorator unit.
|
|
234
|
+
#
|
|
235
|
+
# Links to the decorated model via :decoration and scans the source
|
|
236
|
+
# for common code references (models, services, jobs, mailers).
|
|
237
|
+
#
|
|
238
|
+
# @param source [String] Ruby source code
|
|
239
|
+
# @param class_name [String] The class name
|
|
240
|
+
# @return [Array<Hash>] Dependency hashes with :type, :target, :via
|
|
241
|
+
def extract_dependencies(source, class_name)
|
|
242
|
+
deps = []
|
|
243
|
+
|
|
244
|
+
decorated_model = infer_decorated_model(class_name)
|
|
245
|
+
deps << { type: :model, target: decorated_model, via: :decoration } if decorated_model
|
|
246
|
+
|
|
247
|
+
deps.concat(scan_common_dependencies(source))
|
|
248
|
+
|
|
249
|
+
deps.uniq { |d| [d[:type], d[:target]] }
|
|
250
|
+
end
|
|
251
|
+
end
|
|
252
|
+
end
|
|
253
|
+
end
|
|
@@ -0,0 +1,223 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative 'shared_utility_methods'
|
|
4
|
+
|
|
5
|
+
module Woods
|
|
6
|
+
module Extractors
|
|
7
|
+
# EngineExtractor handles Rails engine and mountable gem extraction via runtime introspection.
|
|
8
|
+
#
|
|
9
|
+
# Reads `Rails::Engine.subclasses` to discover engines, then inspects each engine's
|
|
10
|
+
# routes, mount point, and configuration. Each engine becomes one ExtractedUnit with
|
|
11
|
+
# metadata about its name, root path, mount point, route count, and isolation.
|
|
12
|
+
#
|
|
13
|
+
# @example
|
|
14
|
+
# extractor = EngineExtractor.new
|
|
15
|
+
# units = extractor.extract_all
|
|
16
|
+
# devise = units.find { |u| u.identifier == "Devise::Engine" }
|
|
17
|
+
#
|
|
18
|
+
class EngineExtractor
|
|
19
|
+
include SharedUtilityMethods
|
|
20
|
+
|
|
21
|
+
def initialize
|
|
22
|
+
# No directories to scan — this is runtime introspection
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
# Extract all Rails engines as ExtractedUnits
|
|
26
|
+
#
|
|
27
|
+
# @return [Array<ExtractedUnit>] List of engine units
|
|
28
|
+
def extract_all
|
|
29
|
+
return [] unless engines_available?
|
|
30
|
+
|
|
31
|
+
engines = engine_subclasses
|
|
32
|
+
return [] if engines.empty?
|
|
33
|
+
|
|
34
|
+
mount_map = build_mount_map
|
|
35
|
+
engines.filter_map { |engine| extract_engine(engine, mount_map) }
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
private
|
|
39
|
+
|
|
40
|
+
# Check if Rails::Engine and the application routing table are available.
|
|
41
|
+
#
|
|
42
|
+
# @return [Boolean]
|
|
43
|
+
def engines_available?
|
|
44
|
+
defined?(Rails::Engine) &&
|
|
45
|
+
Rails.respond_to?(:application) &&
|
|
46
|
+
Rails.application.respond_to?(:routes)
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
# Retrieve Engine subclasses, compatible with Ruby 3.0+.
|
|
50
|
+
# Class#subclasses was added in Ruby 3.1; fall back to descendants filtering.
|
|
51
|
+
#
|
|
52
|
+
# @return [Array<Class>]
|
|
53
|
+
def engine_subclasses
|
|
54
|
+
if Rails::Engine.respond_to?(:subclasses)
|
|
55
|
+
Rails::Engine.subclasses
|
|
56
|
+
else
|
|
57
|
+
ObjectSpace.each_object(Class).select { |klass| klass < Rails::Engine }
|
|
58
|
+
end
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
# Build a mapping from engine class to mounted path by scanning app routes.
|
|
62
|
+
#
|
|
63
|
+
# @return [Hash{Class => String}] Engine class to mount path
|
|
64
|
+
def build_mount_map
|
|
65
|
+
map = {}
|
|
66
|
+
Rails.application.routes.routes.each do |route|
|
|
67
|
+
app = route.app
|
|
68
|
+
next unless engine_class?(app)
|
|
69
|
+
|
|
70
|
+
path = extract_mount_path(route)
|
|
71
|
+
map[app] = path if path
|
|
72
|
+
rescue StandardError
|
|
73
|
+
next
|
|
74
|
+
end
|
|
75
|
+
map
|
|
76
|
+
rescue StandardError
|
|
77
|
+
{}
|
|
78
|
+
end
|
|
79
|
+
|
|
80
|
+
# Check if an object is a Rails::Engine subclass.
|
|
81
|
+
#
|
|
82
|
+
# Uses duck-typing: checks for engine_name method which is defined on all
|
|
83
|
+
# Rails::Engine subclasses. Falls back to class hierarchy check.
|
|
84
|
+
#
|
|
85
|
+
# @param app [Object] The route app object
|
|
86
|
+
# @return [Boolean]
|
|
87
|
+
def engine_class?(app)
|
|
88
|
+
return true if app.is_a?(Class) && defined?(Rails::Engine) && app < Rails::Engine
|
|
89
|
+
return true if app.respond_to?(:engine_name) && app.respond_to?(:routes)
|
|
90
|
+
|
|
91
|
+
false
|
|
92
|
+
end
|
|
93
|
+
|
|
94
|
+
# Extract the mount path string from a route object.
|
|
95
|
+
#
|
|
96
|
+
# @param route [ActionDispatch::Journey::Route]
|
|
97
|
+
# @return [String, nil]
|
|
98
|
+
def extract_mount_path(route)
|
|
99
|
+
return nil unless route.respond_to?(:path) && route.path
|
|
100
|
+
|
|
101
|
+
spec = route.path
|
|
102
|
+
spec = spec.spec if spec.respond_to?(:spec)
|
|
103
|
+
path = spec.to_s
|
|
104
|
+
path.empty? ? nil : path
|
|
105
|
+
end
|
|
106
|
+
|
|
107
|
+
# Extract a single engine into an ExtractedUnit.
|
|
108
|
+
#
|
|
109
|
+
# @param engine [Class] A Rails::Engine subclass
|
|
110
|
+
# @param mount_map [Hash] Engine-to-path mapping
|
|
111
|
+
# @return [ExtractedUnit, nil]
|
|
112
|
+
def extract_engine(engine, mount_map)
|
|
113
|
+
name = engine.name
|
|
114
|
+
engine_name = engine.engine_name
|
|
115
|
+
root_path = engine.root.to_s
|
|
116
|
+
route_count = count_engine_routes(engine)
|
|
117
|
+
mounted_path = mount_map[engine]
|
|
118
|
+
isolated = engine.respond_to?(:isolated?) ? engine.isolated? : false
|
|
119
|
+
controllers = extract_engine_controllers(engine)
|
|
120
|
+
|
|
121
|
+
unit = ExtractedUnit.new(
|
|
122
|
+
type: :engine,
|
|
123
|
+
identifier: name,
|
|
124
|
+
file_path: nil
|
|
125
|
+
)
|
|
126
|
+
|
|
127
|
+
unit.namespace = extract_namespace(name)
|
|
128
|
+
unit.source_code = build_engine_source(name, engine_name, root_path, mounted_path, route_count, isolated)
|
|
129
|
+
unit.metadata = {
|
|
130
|
+
engine_name: engine_name,
|
|
131
|
+
root_path: root_path,
|
|
132
|
+
mounted_path: mounted_path,
|
|
133
|
+
route_count: route_count,
|
|
134
|
+
isolate_namespace: isolated,
|
|
135
|
+
controllers: controllers,
|
|
136
|
+
engine_source: framework_engine?(engine) ? :framework : :application
|
|
137
|
+
}
|
|
138
|
+
unit.dependencies = build_engine_dependencies(controllers)
|
|
139
|
+
|
|
140
|
+
unit
|
|
141
|
+
rescue StandardError => e
|
|
142
|
+
Rails.logger.error("Failed to extract engine #{engine.name}: #{e.message}")
|
|
143
|
+
nil
|
|
144
|
+
end
|
|
145
|
+
|
|
146
|
+
# Check if an engine is a framework gem rather than an application engine.
|
|
147
|
+
#
|
|
148
|
+
# An engine is framework if it lives outside Rails.root, or inside
|
|
149
|
+
# Rails.root but under vendor/bundle or bundler/gems (common in Docker
|
|
150
|
+
# where Rails.root is /app and gems install to /app/vendor/bundle).
|
|
151
|
+
#
|
|
152
|
+
# @param engine [Class] A Rails::Engine subclass
|
|
153
|
+
# @return [Boolean]
|
|
154
|
+
def framework_engine?(engine)
|
|
155
|
+
root = engine.root.to_s
|
|
156
|
+
|
|
157
|
+
# Engine outside Rails.root is definitely framework
|
|
158
|
+
return true unless root.start_with?(Rails.root.to_s)
|
|
159
|
+
|
|
160
|
+
# Engine inside Rails.root but in vendor/bundler paths is framework
|
|
161
|
+
root.include?('/vendor/') || root.include?('/bundler/gems/')
|
|
162
|
+
end
|
|
163
|
+
|
|
164
|
+
# Count routes defined by an engine.
|
|
165
|
+
#
|
|
166
|
+
# @param engine [Class] A Rails::Engine subclass
|
|
167
|
+
# @return [Integer]
|
|
168
|
+
def count_engine_routes(engine)
|
|
169
|
+
engine.routes.routes.count
|
|
170
|
+
rescue StandardError
|
|
171
|
+
0
|
|
172
|
+
end
|
|
173
|
+
|
|
174
|
+
# Extract controller names from engine routes.
|
|
175
|
+
#
|
|
176
|
+
# @param engine [Class] A Rails::Engine subclass
|
|
177
|
+
# @return [Array<String>] Controller class names
|
|
178
|
+
def extract_engine_controllers(engine)
|
|
179
|
+
controllers = Set.new
|
|
180
|
+
engine.routes.routes.each do |route|
|
|
181
|
+
defaults = route.respond_to?(:defaults) ? route.defaults : {}
|
|
182
|
+
controller = defaults[:controller]
|
|
183
|
+
controllers << "#{controller.camelize}Controller" if controller
|
|
184
|
+
rescue StandardError
|
|
185
|
+
next
|
|
186
|
+
end
|
|
187
|
+
controllers.to_a
|
|
188
|
+
rescue StandardError
|
|
189
|
+
[]
|
|
190
|
+
end
|
|
191
|
+
|
|
192
|
+
# Build a human-readable source representation of the engine.
|
|
193
|
+
#
|
|
194
|
+
# @param name [String] Engine class name
|
|
195
|
+
# @param engine_name [String] Engine short name
|
|
196
|
+
# @param root_path [String] Engine root directory
|
|
197
|
+
# @param mounted_path [String, nil] Mount path in host app
|
|
198
|
+
# @param route_count [Integer] Number of routes
|
|
199
|
+
# @param isolated [Boolean] Whether engine uses isolate_namespace
|
|
200
|
+
# @return [String]
|
|
201
|
+
def build_engine_source(name, engine_name, root_path, mounted_path, route_count, isolated)
|
|
202
|
+
lines = []
|
|
203
|
+
lines << "# Engine: #{name}"
|
|
204
|
+
lines << "# Name: #{engine_name}"
|
|
205
|
+
lines << "# Root: #{root_path}"
|
|
206
|
+
lines << "# Mounted at: #{mounted_path || '(not mounted)'}"
|
|
207
|
+
lines << "# Routes: #{route_count}"
|
|
208
|
+
lines << "# Isolated namespace: #{isolated}"
|
|
209
|
+
lines.join("\n")
|
|
210
|
+
end
|
|
211
|
+
|
|
212
|
+
# Build dependencies linking engine to its controllers.
|
|
213
|
+
#
|
|
214
|
+
# @param controllers [Array<String>] Controller class names
|
|
215
|
+
# @return [Array<Hash>]
|
|
216
|
+
def build_engine_dependencies(controllers)
|
|
217
|
+
controllers.map do |controller|
|
|
218
|
+
{ type: :controller, target: controller, via: :engine_route }
|
|
219
|
+
end
|
|
220
|
+
end
|
|
221
|
+
end
|
|
222
|
+
end
|
|
223
|
+
end
|
|
@@ -0,0 +1,211 @@
|
|
|
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
|
+
# EventExtractor discovers event publishing and subscribing patterns across the app.
|
|
9
|
+
#
|
|
10
|
+
# Scans +app/**/*.rb+ for two event system conventions:
|
|
11
|
+
# - ActiveSupport::Notifications: +instrument+ (publish) and +subscribe+ (consume)
|
|
12
|
+
# - Wisper: +publish+/+broadcast+ (publish) and +on(:event_name)+ (subscribe)
|
|
13
|
+
#
|
|
14
|
+
# Uses a two-pass approach:
|
|
15
|
+
# 1. Scan all files, collecting publishers and subscribers per event name
|
|
16
|
+
# 2. Merge by event name → one ExtractedUnit per unique event
|
|
17
|
+
#
|
|
18
|
+
# @example
|
|
19
|
+
# extractor = EventExtractor.new
|
|
20
|
+
# units = extractor.extract_all
|
|
21
|
+
# event = units.find { |u| u.identifier == "order.completed" }
|
|
22
|
+
# event.metadata[:publishers] # => ["app/services/order_service.rb"]
|
|
23
|
+
# event.metadata[:subscribers] # => ["app/listeners/order_listener.rb"]
|
|
24
|
+
# event.metadata[:pattern] # => :active_support
|
|
25
|
+
#
|
|
26
|
+
class EventExtractor
|
|
27
|
+
include SharedUtilityMethods
|
|
28
|
+
include SharedDependencyScanner
|
|
29
|
+
|
|
30
|
+
APP_DIRECTORIES = %w[app].freeze
|
|
31
|
+
|
|
32
|
+
def initialize
|
|
33
|
+
@directories = APP_DIRECTORIES.map { |d| Rails.root.join(d) }.select(&:directory?)
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
# Extract all event units using a two-pass approach.
|
|
37
|
+
#
|
|
38
|
+
# Pass 1: Collect publish/subscribe references across all app files.
|
|
39
|
+
# Pass 2: Merge by event name — one ExtractedUnit per unique event.
|
|
40
|
+
#
|
|
41
|
+
# @return [Array<ExtractedUnit>] One unit per unique event name
|
|
42
|
+
def extract_all
|
|
43
|
+
event_map = {}
|
|
44
|
+
|
|
45
|
+
@directories.flat_map { |dir| Dir[dir.join('**/*.rb')] }.each do |file_path|
|
|
46
|
+
scan_file(file_path, event_map)
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
event_map.filter_map { |event_name, data| build_unit(event_name, data) }
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
# Scan a single file for event publishing and subscribing patterns.
|
|
53
|
+
#
|
|
54
|
+
# Mutates +event_map+ in place, registering publishers and subscribers.
|
|
55
|
+
#
|
|
56
|
+
# @param file_path [String] Path to the Ruby file
|
|
57
|
+
# @param event_map [Hash] Mutable map of event_name => {publishers:, subscribers:, pattern:}
|
|
58
|
+
# @return [void]
|
|
59
|
+
def scan_file(file_path, event_map)
|
|
60
|
+
source = File.read(file_path)
|
|
61
|
+
scan_active_support_notifications(source, file_path, event_map)
|
|
62
|
+
scan_wisper_patterns(source, file_path, event_map)
|
|
63
|
+
rescue StandardError => e
|
|
64
|
+
Rails.logger.error("Failed to scan #{file_path} for events: #{e.message}")
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
private
|
|
68
|
+
|
|
69
|
+
# ──────────────────────────────────────────────────────────────────────
|
|
70
|
+
# Pattern Detection
|
|
71
|
+
# ──────────────────────────────────────────────────────────────────────
|
|
72
|
+
|
|
73
|
+
# Scan for ActiveSupport::Notifications instrument and subscribe patterns.
|
|
74
|
+
#
|
|
75
|
+
# @param source [String] Ruby source code
|
|
76
|
+
# @param file_path [String] File path
|
|
77
|
+
# @param event_map [Hash] Mutable event map
|
|
78
|
+
# @return [void]
|
|
79
|
+
def scan_active_support_notifications(source, file_path, event_map)
|
|
80
|
+
source.scan(/ActiveSupport::Notifications\.instrument\s*\(\s*["']([^"']+)["']/) do |m|
|
|
81
|
+
register_publisher(event_map, m[0], file_path, :active_support)
|
|
82
|
+
end
|
|
83
|
+
|
|
84
|
+
source.scan(/ActiveSupport::Notifications\.subscribe\s*\(\s*["']([^"']+)["']/) do |m|
|
|
85
|
+
register_subscriber(event_map, m[0], file_path, :active_support)
|
|
86
|
+
end
|
|
87
|
+
end
|
|
88
|
+
|
|
89
|
+
# Scan for Wisper event patterns.
|
|
90
|
+
#
|
|
91
|
+
# Publishers must have Wisper context in the file (include Wisper or use
|
|
92
|
+
# Wisper directly). Subscribers are detected via +.on(:event_name)+ chains.
|
|
93
|
+
#
|
|
94
|
+
# @param source [String] Ruby source code
|
|
95
|
+
# @param file_path [String] File path
|
|
96
|
+
# @param event_map [Hash] Mutable event map
|
|
97
|
+
# @return [void]
|
|
98
|
+
def scan_wisper_patterns(source, file_path, event_map)
|
|
99
|
+
if source.match?(/include\s+Wisper/)
|
|
100
|
+
source.scan(/\b(?:publish|broadcast)\s+:(\w+)/) do |m|
|
|
101
|
+
register_publisher(event_map, m[0], file_path, :wisper)
|
|
102
|
+
end
|
|
103
|
+
end
|
|
104
|
+
|
|
105
|
+
source.scan(/\.on\s*\(\s*:(\w+)/) do |m|
|
|
106
|
+
register_subscriber(event_map, m[0], file_path, :wisper)
|
|
107
|
+
end
|
|
108
|
+
end
|
|
109
|
+
|
|
110
|
+
# ──────────────────────────────────────────────────────────────────────
|
|
111
|
+
# Event Map Mutation
|
|
112
|
+
# ──────────────────────────────────────────────────────────────────────
|
|
113
|
+
|
|
114
|
+
# Register a publisher for an event name.
|
|
115
|
+
#
|
|
116
|
+
# @param event_map [Hash] Mutable event map
|
|
117
|
+
# @param event_name [String] Event name
|
|
118
|
+
# @param file_path [String] Publisher file path
|
|
119
|
+
# @param pattern [Symbol] :active_support or :wisper
|
|
120
|
+
# @return [void]
|
|
121
|
+
def register_publisher(event_map, event_name, file_path, pattern)
|
|
122
|
+
entry = event_map[event_name] ||= { publishers: [], subscribers: [], pattern: pattern }
|
|
123
|
+
entry[:publishers] << file_path unless entry[:publishers].include?(file_path)
|
|
124
|
+
end
|
|
125
|
+
|
|
126
|
+
# Register a subscriber for an event name.
|
|
127
|
+
#
|
|
128
|
+
# @param event_map [Hash] Mutable event map
|
|
129
|
+
# @param event_name [String] Event name
|
|
130
|
+
# @param file_path [String] Subscriber file path
|
|
131
|
+
# @param pattern [Symbol] :active_support or :wisper
|
|
132
|
+
# @return [void]
|
|
133
|
+
def register_subscriber(event_map, event_name, file_path, pattern)
|
|
134
|
+
entry = event_map[event_name] ||= { publishers: [], subscribers: [], pattern: pattern }
|
|
135
|
+
entry[:subscribers] << file_path unless entry[:subscribers].include?(file_path)
|
|
136
|
+
end
|
|
137
|
+
|
|
138
|
+
# ──────────────────────────────────────────────────────────────────────
|
|
139
|
+
# Unit Construction
|
|
140
|
+
# ──────────────────────────────────────────────────────────────────────
|
|
141
|
+
|
|
142
|
+
# Build an ExtractedUnit from accumulated event data.
|
|
143
|
+
#
|
|
144
|
+
# Returns nil if the event has neither publishers nor subscribers (no-op).
|
|
145
|
+
#
|
|
146
|
+
# @param event_name [String] Event name (used as the unit identifier)
|
|
147
|
+
# @param data [Hash] Accumulated publishers/subscribers/pattern
|
|
148
|
+
# @return [ExtractedUnit, nil]
|
|
149
|
+
def build_unit(event_name, data)
|
|
150
|
+
return nil if data[:publishers].empty? && data[:subscribers].empty?
|
|
151
|
+
|
|
152
|
+
file_path = data[:publishers].first || data[:subscribers].first
|
|
153
|
+
all_paths = (data[:publishers] + data[:subscribers]).uniq
|
|
154
|
+
combined_source = load_source_files(all_paths)
|
|
155
|
+
|
|
156
|
+
unit = ExtractedUnit.new(
|
|
157
|
+
type: :event,
|
|
158
|
+
identifier: event_name,
|
|
159
|
+
file_path: file_path
|
|
160
|
+
)
|
|
161
|
+
|
|
162
|
+
unit.source_code = build_source_annotation(event_name, data)
|
|
163
|
+
unit.metadata = {
|
|
164
|
+
event_name: event_name,
|
|
165
|
+
publishers: data[:publishers],
|
|
166
|
+
subscribers: data[:subscribers],
|
|
167
|
+
pattern: data[:pattern],
|
|
168
|
+
publisher_count: data[:publishers].size,
|
|
169
|
+
subscriber_count: data[:subscribers].size
|
|
170
|
+
}
|
|
171
|
+
unit.dependencies = build_dependencies(combined_source)
|
|
172
|
+
unit
|
|
173
|
+
end
|
|
174
|
+
|
|
175
|
+
# Load source from multiple files for dependency scanning.
|
|
176
|
+
#
|
|
177
|
+
# Silently skips files that cannot be read.
|
|
178
|
+
#
|
|
179
|
+
# @param file_paths [Array<String>] File paths to read
|
|
180
|
+
# @return [String] Combined source
|
|
181
|
+
def load_source_files(file_paths)
|
|
182
|
+
file_paths.filter_map do |path|
|
|
183
|
+
File.read(path)
|
|
184
|
+
rescue StandardError
|
|
185
|
+
nil
|
|
186
|
+
end.join("\n")
|
|
187
|
+
end
|
|
188
|
+
|
|
189
|
+
# Build annotated source annotation for the event unit.
|
|
190
|
+
#
|
|
191
|
+
# @param event_name [String] Event name
|
|
192
|
+
# @param data [Hash] Event data with publishers and subscribers
|
|
193
|
+
# @return [String]
|
|
194
|
+
def build_source_annotation(event_name, data)
|
|
195
|
+
lines = ["# Event: #{event_name} (#{data[:pattern]})"]
|
|
196
|
+
lines << "# Publishers: #{data[:publishers].join(', ')}" if data[:publishers].any?
|
|
197
|
+
lines << "# Subscribers: #{data[:subscribers].join(', ')}" if data[:subscribers].any?
|
|
198
|
+
lines.join("\n")
|
|
199
|
+
end
|
|
200
|
+
|
|
201
|
+
# Build dependencies by scanning combined source of publisher/subscriber files.
|
|
202
|
+
#
|
|
203
|
+
# @param combined_source [String] Combined source from all related files
|
|
204
|
+
# @return [Array<Hash>]
|
|
205
|
+
def build_dependencies(combined_source)
|
|
206
|
+
deps = scan_common_dependencies(combined_source)
|
|
207
|
+
deps.uniq { |d| [d[:type], d[:target]] }
|
|
208
|
+
end
|
|
209
|
+
end
|
|
210
|
+
end
|
|
211
|
+
end
|