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