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,614 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative 'node'
4
+
5
+ module Woods
6
+ class Error < StandardError; end unless defined?(Woods::Error)
7
+ class ExtractionError < Error; end unless defined?(Woods::ExtractionError)
8
+ module Ast
9
+ # Parser adapter that normalizes Prism and parser gem ASTs to a common
10
+ # {Ast::Node} structure. Auto-detects parser availability at load time.
11
+ #
12
+ # @example Parsing Ruby source
13
+ # parser = Ast::Parser.new
14
+ # root = parser.parse("class Foo; def bar; end; end")
15
+ # root.find_all(:def).first.method_name #=> "bar"
16
+ #
17
+ class Parser
18
+ include SourceSpan
19
+
20
+ # Parse Ruby source into a normalized AST.
21
+ #
22
+ # @param source [String] Ruby source code
23
+ # @return [Ast::Node] Root node of the normalized tree
24
+ # @raise [Woods::ExtractionError] if parsing fails
25
+ def parse(source)
26
+ if prism_available?
27
+ parse_with_prism(source)
28
+ else
29
+ parse_with_parser_gem(source)
30
+ end
31
+ rescue StandardError => e
32
+ raise Woods::ExtractionError, "Failed to parse source: #{e.message}"
33
+ end
34
+
35
+ # Check if Prism is available.
36
+ #
37
+ # @return [Boolean]
38
+ def prism_available?
39
+ return @prism_available unless @prism_available.nil?
40
+
41
+ require 'prism'
42
+ @prism_available = true
43
+ rescue LoadError
44
+ @prism_available = false
45
+ end
46
+
47
+ private
48
+
49
+ # Parse using Prism (Ruby 3.3+ stdlib or backport gem).
50
+ def parse_with_prism(source)
51
+ require 'prism' unless defined?(Prism)
52
+
53
+ result = Prism.parse(source)
54
+
55
+ unless result.success?
56
+ errors = result.errors.map(&:message).join(', ')
57
+ raise Woods::ExtractionError, "Parse error: #{errors}"
58
+ end
59
+
60
+ convert_prism_node(result.value, source)
61
+ end
62
+
63
+ # Parse using the parser gem (fallback for older Ruby).
64
+ def parse_with_parser_gem(source)
65
+ require 'parser/current' unless defined?(::Parser::CurrentRuby)
66
+
67
+ buffer = ::Parser::Source::Buffer.new('(source)', source: source)
68
+ ast = ::Parser::CurrentRuby.parse(buffer.source)
69
+
70
+ raise Woods::ExtractionError, 'Parse returned nil' unless ast
71
+
72
+ convert_parser_node(ast, source)
73
+ end
74
+
75
+ # Convert a Prism node tree to Ast::Node.
76
+ #
77
+ # @param prism_node [Prism::Node] A Prism AST node
78
+ # @param source [String] Original source for extracting text spans
79
+ # @return [Ast::Node]
80
+ def convert_prism_node(prism_node, source)
81
+ case prism_node
82
+ when Prism::ProgramNode
83
+ children = convert_prism_children(prism_node.statements, source)
84
+ Node.new(type: :program, children: children, line: line_for_prism(prism_node))
85
+ when Prism::StatementsNode
86
+ children = prism_node.body.map { |child| convert_prism_node(child, source) }
87
+ Node.new(type: :begin, children: children, line: line_for_prism(prism_node))
88
+ when Prism::ClassNode
89
+ convert_prism_class(prism_node, source)
90
+ when Prism::ModuleNode
91
+ convert_prism_module(prism_node, source)
92
+ when Prism::DefNode
93
+ convert_prism_def(prism_node, source)
94
+ when Prism::CallNode
95
+ convert_prism_call(prism_node, source)
96
+ when Prism::ConstantReadNode
97
+ Node.new(
98
+ type: :const,
99
+ children: [],
100
+ line: line_for_prism(prism_node),
101
+ method_name: prism_node.name.to_s
102
+ )
103
+ when Prism::ConstantPathNode
104
+ convert_prism_constant_path(prism_node, source)
105
+ when Prism::IfNode, Prism::UnlessNode
106
+ convert_prism_if(prism_node, source)
107
+ when Prism::CaseNode
108
+ convert_prism_case(prism_node, source)
109
+ when Prism::BeginNode
110
+ children = []
111
+ children += prism_node.statements.body.map { |c| convert_prism_node(c, source) } if prism_node.statements
112
+ children << convert_prism_node(prism_node.rescue_clause, source) if prism_node.rescue_clause
113
+ children << convert_prism_node(prism_node.ensure_clause, source) if prism_node.ensure_clause
114
+ Node.new(type: :begin, children: children, line: line_for_prism(prism_node))
115
+ when Prism::RescueNode
116
+ children = prism_node.statements ? prism_node.statements.body.map { |c| convert_prism_node(c, source) } : []
117
+ Node.new(type: :rescue, children: children, line: line_for_prism(prism_node))
118
+ when Prism::EnsureNode
119
+ children = prism_node.statements ? prism_node.statements.body.map { |c| convert_prism_node(c, source) } : []
120
+ Node.new(type: :ensure, children: children, line: line_for_prism(prism_node))
121
+ when Prism::SymbolNode
122
+ prism_node.value.to_s
123
+ when Prism::StringNode
124
+ prism_node.unescaped
125
+ when Prism::IntegerNode, Prism::FloatNode
126
+ prism_node.value
127
+ when Prism::NilNode
128
+ nil
129
+ when Prism::TrueNode
130
+ Node.new(type: true, children: [], line: line_for_prism(prism_node))
131
+ when Prism::FalseNode
132
+ Node.new(type: false, children: [], line: line_for_prism(prism_node))
133
+ when Prism::SelfNode
134
+ Node.new(type: :self, children: [], line: line_for_prism(prism_node), source: 'self')
135
+ when Prism::LocalVariableReadNode
136
+ Node.new(
137
+ type: :lvar,
138
+ children: [],
139
+ line: line_for_prism(prism_node),
140
+ method_name: prism_node.name.to_s,
141
+ source: prism_node.name.to_s
142
+ )
143
+ when Prism::LocalVariableWriteNode
144
+ value = prism_node.value ? convert_prism_node(prism_node.value, source) : nil
145
+ Node.new(
146
+ type: :lvasgn,
147
+ children: [value].compact,
148
+ line: line_for_prism(prism_node),
149
+ method_name: prism_node.name.to_s,
150
+ source: prism_node.name.to_s
151
+ )
152
+ when Prism::InstanceVariableReadNode
153
+ Node.new(
154
+ type: :ivar,
155
+ children: [],
156
+ line: line_for_prism(prism_node),
157
+ method_name: prism_node.name.to_s,
158
+ source: prism_node.name.to_s
159
+ )
160
+ when Prism::InstanceVariableWriteNode
161
+ value = prism_node.value ? convert_prism_node(prism_node.value, source) : nil
162
+ Node.new(
163
+ type: :ivasgn,
164
+ children: [value].compact,
165
+ line: line_for_prism(prism_node),
166
+ method_name: prism_node.name.to_s,
167
+ source: prism_node.name.to_s
168
+ )
169
+ when Prism::BlockNode
170
+ children = prism_node.body ? [convert_prism_node(prism_node.body, source)] : []
171
+ Node.new(type: :block_body, children: children, line: line_for_prism(prism_node))
172
+ when Prism::LambdaNode
173
+ children = prism_node.body ? [convert_prism_node(prism_node.body, source)] : []
174
+ Node.new(type: :lambda, children: children, line: line_for_prism(prism_node))
175
+ when Prism::ReturnNode
176
+ children = if prism_node.arguments
177
+ prism_node.arguments.arguments.map do |a|
178
+ convert_prism_node(a, source)
179
+ end
180
+ else
181
+ []
182
+ end
183
+ Node.new(type: :return, children: children, line: line_for_prism(prism_node))
184
+ when Prism::YieldNode
185
+ Node.new(type: :yield, children: [], line: line_for_prism(prism_node))
186
+ when Prism::ArrayNode
187
+ children = prism_node.elements.map { |e| convert_prism_node(e, source) }
188
+ Node.new(type: :array, children: children, line: line_for_prism(prism_node))
189
+ when Prism::HashNode
190
+ Node.new(type: :hash, children: [], line: line_for_prism(prism_node))
191
+ when Prism::ParenthesesNode
192
+ convert_prism_node(prism_node.body, source)
193
+ when Prism::InterpolatedStringNode
194
+ Node.new(type: :dstr, children: [], line: line_for_prism(prism_node))
195
+ when Prism::SingletonClassNode
196
+ children = prism_node.body ? [convert_prism_node(prism_node.body, source)] : []
197
+ Node.new(type: :sclass, children: children, line: line_for_prism(prism_node))
198
+ when Prism::ConstantWriteNode
199
+ value = prism_node.value ? convert_prism_node(prism_node.value, source) : nil
200
+ Node.new(
201
+ type: :casgn,
202
+ children: [value].compact,
203
+ line: line_for_prism(prism_node),
204
+ method_name: prism_node.name.to_s
205
+ )
206
+ else
207
+ # Generic fallback: convert children we can find
208
+ children = extract_prism_generic_children(prism_node, source)
209
+ Node.new(
210
+ type: prism_node.class.name.split('::').last.sub(/Node$/, '').gsub(/([a-z])([A-Z])/,
211
+ '\1_\2').downcase.to_sym,
212
+ children: children,
213
+ line: line_for_prism(prism_node)
214
+ )
215
+ end
216
+ end
217
+
218
+ def convert_prism_class(prism_node, source)
219
+ name_node = convert_prism_node(prism_node.constant_path, source)
220
+ superclass = prism_node.superclass ? convert_prism_node(prism_node.superclass, source) : nil
221
+ body_children = extract_prism_body_children(prism_node, source)
222
+
223
+ children = [name_node, superclass] + body_children
224
+
225
+ Node.new(
226
+ type: :class,
227
+ children: children,
228
+ line: line_for_prism(prism_node),
229
+ end_line: end_line_for_prism(prism_node),
230
+ method_name: extract_const_name(prism_node.constant_path)
231
+ )
232
+ end
233
+
234
+ def convert_prism_module(prism_node, source)
235
+ name_node = convert_prism_node(prism_node.constant_path, source)
236
+ body_children = extract_prism_body_children(prism_node, source)
237
+
238
+ children = [name_node] + body_children
239
+
240
+ Node.new(
241
+ type: :module,
242
+ children: children,
243
+ line: line_for_prism(prism_node),
244
+ end_line: end_line_for_prism(prism_node),
245
+ method_name: extract_const_name(prism_node.constant_path)
246
+ )
247
+ end
248
+
249
+ def convert_prism_def(prism_node, source)
250
+ body_children = extract_prism_body_children(prism_node, source)
251
+
252
+ is_class_method = prism_node.respond_to?(:receiver) && prism_node.receiver
253
+ receiver_text = if is_class_method
254
+ src = prism_node.receiver
255
+ src.is_a?(Prism::SelfNode) ? 'self' : extract_prism_source_text(src, source)
256
+ end
257
+
258
+ Node.new(
259
+ type: is_class_method ? :defs : :def,
260
+ children: body_children,
261
+ line: line_for_prism(prism_node),
262
+ end_line: end_line_for_prism(prism_node),
263
+ method_name: prism_node.name.to_s,
264
+ receiver: receiver_text,
265
+ source: extract_prism_source_span(prism_node, source)
266
+ )
267
+ end
268
+
269
+ def convert_prism_call(prism_node, source)
270
+ receiver_text = (extract_prism_receiver_text(prism_node.receiver, source) if prism_node.receiver)
271
+
272
+ args = if prism_node.arguments
273
+ prism_node.arguments.arguments.map { |a| extract_prism_source_text(a, source) }
274
+ else
275
+ []
276
+ end
277
+
278
+ # Convert receiver node so tree walking finds nested calls/constants
279
+ receiver_node = prism_node.receiver ? convert_prism_node(prism_node.receiver, source) : nil
280
+ children = [receiver_node].compact
281
+
282
+ # If there's a block, create a :block node wrapping this send
283
+ if prism_node.block.is_a?(Prism::BlockNode)
284
+ send_node = Node.new(
285
+ type: :send,
286
+ children: children,
287
+ line: line_for_prism(prism_node),
288
+ receiver: receiver_text,
289
+ method_name: prism_node.name.to_s,
290
+ arguments: args
291
+ )
292
+
293
+ block_body = (convert_prism_node(prism_node.block.body, source) if prism_node.block.body)
294
+
295
+ return Node.new(
296
+ type: :block,
297
+ children: [send_node, block_body].compact,
298
+ line: line_for_prism(prism_node),
299
+ end_line: end_line_for_prism(prism_node.block)
300
+ )
301
+ end
302
+
303
+ Node.new(
304
+ type: :send,
305
+ children: children,
306
+ line: line_for_prism(prism_node),
307
+ end_line: end_line_for_prism(prism_node),
308
+ receiver: receiver_text,
309
+ method_name: prism_node.name.to_s,
310
+ arguments: args
311
+ )
312
+ end
313
+
314
+ def convert_prism_constant_path(prism_node, _source)
315
+ parent_text = (extract_const_path_text(prism_node.parent) if prism_node.parent)
316
+ const_name = prism_node.respond_to?(:name) ? prism_node.name.to_s : prism_node.child.name.to_s
317
+
318
+ Node.new(
319
+ type: :const,
320
+ children: [],
321
+ line: line_for_prism(prism_node),
322
+ receiver: parent_text,
323
+ method_name: const_name
324
+ )
325
+ end
326
+
327
+ def convert_prism_if(prism_node, source)
328
+ condition = convert_prism_node(prism_node.predicate, source)
329
+ condition_source = extract_prism_source_text(prism_node.predicate, source)
330
+ if condition.is_a?(Node) && condition.source.nil?
331
+ condition = Node.new(**condition.to_h, source: condition_source)
332
+ end
333
+
334
+ then_body = prism_node.statements ? convert_prism_node(prism_node.statements, source) : nil
335
+ else_clause = prism_else_clause(prism_node)
336
+ else_body = else_clause ? convert_prism_node(else_clause, source) : nil
337
+
338
+ Node.new(
339
+ type: :if,
340
+ children: [condition, then_body, else_body].compact,
341
+ line: line_for_prism(prism_node),
342
+ end_line: end_line_for_prism(prism_node),
343
+ source: condition_source
344
+ )
345
+ end
346
+
347
+ def convert_prism_case(prism_node, source)
348
+ children = []
349
+ children << convert_prism_node(prism_node.predicate, source) if prism_node.predicate
350
+ prism_node.conditions.each { |c| children << convert_prism_node(c, source) }
351
+ else_clause = prism_else_clause(prism_node)
352
+ children << convert_prism_node(else_clause, source) if else_clause
353
+ Node.new(type: :case, children: children, line: line_for_prism(prism_node))
354
+ end
355
+
356
+ def extract_prism_body_children(prism_node, source)
357
+ return [] unless prism_node.body
358
+
359
+ if prism_node.body.is_a?(Prism::StatementsNode)
360
+ prism_node.body.body.map { |c| convert_prism_node(c, source) }
361
+ else
362
+ [convert_prism_node(prism_node.body, source)]
363
+ end
364
+ end
365
+
366
+ def convert_prism_children(statements_node, source)
367
+ return [] unless statements_node
368
+
369
+ if statements_node.is_a?(Prism::StatementsNode)
370
+ statements_node.body.map { |c| convert_prism_node(c, source) }
371
+ else
372
+ [convert_prism_node(statements_node, source)]
373
+ end
374
+ end
375
+
376
+ def extract_prism_generic_children(prism_node, source)
377
+ prism_node.child_nodes.compact.filter_map { |child| convert_prism_node(child, source) }
378
+ end
379
+
380
+ # Portable accessor for the else/consequent clause of if/unless/case nodes.
381
+ # Prism < 1.0 uses :consequent, Prism >= 1.0 uses :else_clause/:subsequent.
382
+ def prism_else_clause(node)
383
+ if node.respond_to?(:consequent)
384
+ node.consequent
385
+ elsif node.respond_to?(:else_clause)
386
+ node.else_clause
387
+ elsif node.respond_to?(:subsequent)
388
+ node.subsequent
389
+ end
390
+ end
391
+
392
+ def line_for_prism(node)
393
+ node.location.start_line
394
+ end
395
+
396
+ def end_line_for_prism(node)
397
+ node.location.end_line
398
+ end
399
+
400
+ def extract_prism_source_span(node, source)
401
+ extract_source_span(source, node.location.start_line, node.location.end_line)
402
+ end
403
+
404
+ def extract_prism_source_text(node, source)
405
+ source.byteslice(node.location.start_offset, node.location.length) || ''
406
+ rescue StandardError
407
+ ''
408
+ end
409
+
410
+ def extract_prism_receiver_text(receiver_node, source)
411
+ case receiver_node
412
+ when Prism::SelfNode
413
+ 'self'
414
+ when Prism::ConstantReadNode, Prism::LocalVariableReadNode, Prism::InstanceVariableReadNode
415
+ receiver_node.name.to_s
416
+ when Prism::ConstantPathNode
417
+ extract_const_path_text(receiver_node)
418
+ when Prism::CallNode
419
+ text = extract_prism_receiver_text(receiver_node.receiver, source) if receiver_node.receiver
420
+ [text, receiver_node.name.to_s].compact.join('.')
421
+ else
422
+ extract_prism_source_text(receiver_node, source)
423
+ end
424
+ end
425
+
426
+ def extract_const_path_text(node)
427
+ case node
428
+ when Prism::ConstantReadNode
429
+ node.name.to_s
430
+ when Prism::ConstantPathNode
431
+ parent = node.parent ? extract_const_path_text(node.parent) : nil
432
+ const_name = node.respond_to?(:name) ? node.name.to_s : node.child.name.to_s
433
+ [parent, const_name].compact.join('::')
434
+ end
435
+ end
436
+
437
+ def extract_const_name(node)
438
+ extract_const_path_text(node)
439
+ end
440
+
441
+ # ── Parser gem fallback ──────────────────────────────────────────────
442
+
443
+ def convert_parser_node(parser_node, source)
444
+ return nil unless parser_node
445
+ return nil unless parser_node.is_a?(::Parser::AST::Node)
446
+
447
+ case parser_node.type
448
+ when :begin
449
+ children = parser_node.children.map { |c| convert_parser_node(c, source) }.compact
450
+ Node.new(type: :begin, children: children, line: parser_node.loc&.line || 1)
451
+ when :class
452
+ name_node = convert_parser_node(parser_node.children[0], source)
453
+ superclass = parser_node.children[1] ? convert_parser_node(parser_node.children[1], source) : nil
454
+ body = parser_node.children[2] ? convert_parser_node(parser_node.children[2], source) : nil
455
+ body_children = parser_body_children(body)
456
+ children = [name_node, superclass] + body_children
457
+ Node.new(
458
+ type: :class,
459
+ children: children,
460
+ line: parser_node.loc.line,
461
+ end_line: parser_node.loc.expression.last_line,
462
+ method_name: extract_parser_const_name(parser_node.children[0])
463
+ )
464
+ when :module
465
+ name_node = convert_parser_node(parser_node.children[0], source)
466
+ body = parser_node.children[1] ? convert_parser_node(parser_node.children[1], source) : nil
467
+ body_children = parser_body_children(body)
468
+ children = [name_node] + body_children
469
+ Node.new(
470
+ type: :module,
471
+ children: children,
472
+ line: parser_node.loc.line,
473
+ end_line: parser_node.loc.expression.last_line,
474
+ method_name: extract_parser_const_name(parser_node.children[0])
475
+ )
476
+ when :def
477
+ body = parser_node.children[2] ? convert_parser_node(parser_node.children[2], source) : nil
478
+ body_children = parser_body_children(body)
479
+ Node.new(
480
+ type: :def,
481
+ children: body_children,
482
+ line: parser_node.loc.line,
483
+ end_line: parser_node.loc.expression.last_line,
484
+ method_name: parser_node.children[0].to_s,
485
+ source: extract_parser_source_span(parser_node, source)
486
+ )
487
+ when :defs
488
+ body = parser_node.children[3] ? convert_parser_node(parser_node.children[3], source) : nil
489
+ body_children = parser_body_children(body)
490
+ receiver = parser_node.children[0].type == :self ? 'self' : parser_node.children[0].to_s
491
+ Node.new(
492
+ type: :defs,
493
+ children: body_children,
494
+ line: parser_node.loc.line,
495
+ end_line: parser_node.loc.expression.last_line,
496
+ method_name: parser_node.children[1].to_s,
497
+ receiver: receiver,
498
+ source: extract_parser_source_span(parser_node, source)
499
+ )
500
+ when :send
501
+ receiver_text = parser_node.children[0] ? extract_parser_receiver_text(parser_node.children[0], source) : nil
502
+ method_name = parser_node.children[1].to_s
503
+ args = parser_node.children[2..].compact.map { |a| extract_parser_source_text(a, source) }
504
+ Node.new(
505
+ type: :send,
506
+ children: [],
507
+ line: parser_node.loc.line,
508
+ end_line: parser_node.loc.expression&.last_line,
509
+ receiver: receiver_text,
510
+ method_name: method_name,
511
+ arguments: args
512
+ )
513
+ when :block
514
+ send_child = convert_parser_node(parser_node.children[0], source)
515
+ body = parser_node.children[2] ? convert_parser_node(parser_node.children[2], source) : nil
516
+ Node.new(
517
+ type: :block,
518
+ children: [send_child, body].compact,
519
+ line: parser_node.loc.line,
520
+ end_line: parser_node.loc.expression.last_line
521
+ )
522
+ when :if
523
+ condition = convert_parser_node(parser_node.children[0], source)
524
+ condition_source = extract_parser_source_text(parser_node.children[0], source)
525
+ if condition.is_a?(Node) && condition.source.nil?
526
+ condition = Node.new(**condition.to_h, source: condition_source)
527
+ end
528
+ then_body = parser_node.children[1] ? convert_parser_node(parser_node.children[1], source) : nil
529
+ else_body = parser_node.children[2] ? convert_parser_node(parser_node.children[2], source) : nil
530
+ Node.new(
531
+ type: :if,
532
+ children: [condition, then_body, else_body].compact,
533
+ line: parser_node.loc.line,
534
+ end_line: parser_node.loc.expression&.last_line,
535
+ source: condition_source
536
+ )
537
+ when :const
538
+ parent = parser_node.children[0] ? extract_parser_const_name(parser_node.children[0]) : nil
539
+ Node.new(
540
+ type: :const,
541
+ children: [],
542
+ line: parser_node.loc.line,
543
+ receiver: parent,
544
+ method_name: parser_node.children[1].to_s
545
+ )
546
+ when :sym
547
+ parser_node.children[0].to_s
548
+ when :str, :int, :float
549
+ parser_node.children[0]
550
+ when :nil
551
+ nil
552
+ when true
553
+ Node.new(type: true, children: [], line: parser_node.loc.line)
554
+ when false
555
+ Node.new(type: false, children: [], line: parser_node.loc.line)
556
+ when :self
557
+ Node.new(type: :self, children: [], line: parser_node.loc.line, source: 'self')
558
+ else
559
+ children = parser_node.children.filter_map do |child|
560
+ child.is_a?(::Parser::AST::Node) ? convert_parser_node(child, source) : nil
561
+ end
562
+ Node.new(
563
+ type: parser_node.type,
564
+ children: children,
565
+ line: parser_node.loc&.line || 1
566
+ )
567
+ end
568
+ end
569
+
570
+ def parser_body_children(body_node)
571
+ body_node&.type == :begin ? body_node.children : [body_node].compact
572
+ end
573
+
574
+ def extract_parser_source_span(node, source)
575
+ extract_source_span(source, node.loc.line, node.loc.expression.last_line)
576
+ end
577
+
578
+ def extract_parser_source_text(node, source)
579
+ return node.to_s unless node.is_a?(::Parser::AST::Node) && node.loc&.expression
580
+
581
+ loc = node.loc.expression
582
+ source[loc.begin_pos...loc.end_pos] || ''
583
+ rescue StandardError
584
+ ''
585
+ end
586
+
587
+ def extract_parser_receiver_text(node, source)
588
+ case node.type
589
+ when :self
590
+ 'self'
591
+ when :const
592
+ extract_parser_const_name(node)
593
+ when :send
594
+ recv = node.children[0] ? extract_parser_receiver_text(node.children[0], source) : nil
595
+ [recv, node.children[1].to_s].compact.join('.')
596
+ when :lvar, :ivar
597
+ node.children[0].to_s
598
+ else
599
+ extract_parser_source_text(node, source)
600
+ end
601
+ end
602
+
603
+ def extract_parser_const_name(node)
604
+ return nil unless node.is_a?(::Parser::AST::Node)
605
+
606
+ case node.type
607
+ when :const
608
+ parent = node.children[0] ? extract_parser_const_name(node.children[0]) : nil
609
+ [parent, node.children[1].to_s].compact.join('::')
610
+ end
611
+ end
612
+ end
613
+ end
614
+ end
data/lib/woods/ast.rb ADDED
@@ -0,0 +1,6 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative 'ast/node'
4
+ require_relative 'ast/parser'
5
+ require_relative 'ast/method_extractor'
6
+ require_relative 'ast/call_site_extractor'