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,115 @@
1
+ # frozen_string_literal: true
2
+
3
+ # lib/tasks/woods_evaluation.rake
4
+ #
5
+ # Rake tasks for evaluating retrieval quality.
6
+ #
7
+ # Usage:
8
+ # bundle exec rake woods:evaluate # Run evaluation
9
+ # bundle exec rake woods:evaluate:baseline[grep] # Run baseline comparison
10
+
11
+ namespace :woods do
12
+ desc 'Run evaluation queries against the retrieval pipeline'
13
+ task evaluate: :environment do
14
+ require 'woods/retriever'
15
+ require 'woods/evaluation/query_set'
16
+ require 'woods/evaluation/evaluator'
17
+ require 'woods/evaluation/report_generator'
18
+
19
+ run_evaluation
20
+ end
21
+
22
+ namespace :evaluate do
23
+ desc 'Run baseline comparison'
24
+ task :baseline, [:strategy] => :environment do |_t, args|
25
+ require 'woods/evaluation/query_set'
26
+ require 'woods/evaluation/baseline_runner'
27
+ require 'woods/evaluation/metrics'
28
+
29
+ run_baseline(args)
30
+ end
31
+ end
32
+ end
33
+
34
+ def run_evaluation
35
+ query_set_path = ENV.fetch('EVAL_QUERY_SET', 'config/eval_queries.json')
36
+ output_path = ENV.fetch('EVAL_OUTPUT', 'tmp/eval_report.json')
37
+ budget = ENV.fetch('EVAL_BUDGET', '8000').to_i
38
+
39
+ puts "Loading query set from: #{query_set_path}"
40
+ query_set = Woods::Evaluation::QuerySet.load(query_set_path)
41
+ puts "Loaded #{query_set.size} queries — building retriever..."
42
+
43
+ evaluator = Woods::Evaluation::Evaluator.new(
44
+ retriever: build_eval_retriever, query_set: query_set, budget: budget
45
+ )
46
+ report = evaluator.evaluate
47
+
48
+ Woods::Evaluation::ReportGenerator.new
49
+ .save(report, output_path, metadata: { 'query_set' => query_set_path })
50
+
51
+ print_eval_report(report, output_path)
52
+ end
53
+
54
+ def run_baseline(args)
55
+ strategy = (args[:strategy] || ENV.fetch('EVAL_BASELINE_STRATEGY', 'grep')).to_sym
56
+ query_set_path = ENV.fetch('EVAL_QUERY_SET', 'config/eval_queries.json')
57
+ limit = ENV.fetch('EVAL_BASELINE_LIMIT', '10').to_i
58
+
59
+ puts "Loading query set from: #{query_set_path}"
60
+ query_set = Woods::Evaluation::QuerySet.load(query_set_path)
61
+ puts "Running #{strategy} baseline (limit: #{limit})..."
62
+
63
+ runner = Woods::Evaluation::BaselineRunner.new(
64
+ metadata_store: Woods.metadata_store
65
+ )
66
+
67
+ totals = compute_baseline_totals(query_set, runner, strategy, limit)
68
+ print_baseline_report(strategy, query_set.size, totals)
69
+ end
70
+
71
+ def compute_baseline_totals(query_set, runner, strategy, limit)
72
+ total_mrr = 0.0
73
+ total_recall = 0.0
74
+
75
+ query_set.queries.each do |query|
76
+ results = runner.run(query.query, strategy: strategy, limit: limit)
77
+ total_mrr += Woods::Evaluation::Metrics.mrr(results, query.expected_units)
78
+ total_recall += Woods::Evaluation::Metrics.recall(results, query.expected_units)
79
+ end
80
+
81
+ { mrr: total_mrr, recall: total_recall }
82
+ end
83
+
84
+ def print_eval_report(report, output_path)
85
+ puts
86
+ puts 'Evaluation complete!'
87
+ puts '=' * 50
88
+ report.aggregates.each do |key, value|
89
+ formatted = value.is_a?(Float) ? format('%.4f', value) : value.to_s
90
+ puts " #{key.to_s.ljust(25)}: #{formatted}"
91
+ end
92
+ puts '=' * 50
93
+ puts "Report saved to: #{output_path}"
94
+ end
95
+
96
+ def print_baseline_report(strategy, count, totals)
97
+ puts
98
+ puts "Baseline: #{strategy}"
99
+ puts '=' * 50
100
+ puts " Mean MRR: #{format('%.4f', count.positive? ? totals[:mrr] / count : 0.0)}"
101
+ puts " Mean Recall: #{format('%.4f', count.positive? ? totals[:recall] / count : 0.0)}"
102
+ puts '=' * 50
103
+ end
104
+
105
+ # Build a retriever for evaluation (requires Rails environment with stores configured).
106
+ #
107
+ # @return [Woods::Retriever]
108
+ def build_eval_retriever
109
+ Woods::Retriever.new(
110
+ vector_store: Woods.vector_store,
111
+ metadata_store: Woods.metadata_store,
112
+ graph_store: Woods.graph_store,
113
+ embedding_provider: Woods.embedding_provider
114
+ )
115
+ end
@@ -0,0 +1,106 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'set'
4
+ require_relative 'node'
5
+
6
+ module Woods
7
+ module Ast
8
+ # Method names that are too common to be useful in call graphs.
9
+ INSIGNIFICANT_METHODS = Set.new(%w[
10
+ to_s to_i to_f to_a to_h to_sym to_r to_c to_str to_proc
11
+ nil? present? blank? empty? any? none? frozen? is_a? kind_of?
12
+ respond_to? respond_to_missing? instance_of? equal?
13
+ == != eql? <=> === =~ !~ >= <= > <
14
+ ! & | ^ ~ + - * / % **
15
+ freeze dup clone inspect hash object_id class
16
+ send __send__ method tap then yield_self itself
17
+ new allocate
18
+ [] []=
19
+ length size count
20
+ first last
21
+ map each select reject flat_map collect detect find_index
22
+ merge merge! update
23
+ keys values
24
+ push pop shift unshift
25
+ strip chomp chop downcase upcase
26
+ puts print p pp warn raise fail
27
+ require require_relative load autoload
28
+ attr_reader attr_writer attr_accessor
29
+ private protected public
30
+ include extend prepend
31
+ ]).freeze
32
+
33
+ # Extracts call sites from an AST node tree.
34
+ #
35
+ # Returns method calls found in the tree, ordered by source line number.
36
+ # Used by both RubyAnalyzer (call graph building) and FlowAssembler
37
+ # (execution flow ordering).
38
+ #
39
+ # @example Extracting calls from a method body
40
+ # parser = Ast::Parser.new
41
+ # root = parser.parse(source)
42
+ # calls = Ast::CallSiteExtractor.new.extract(root)
43
+ # calls.first #=> { receiver: "User", method_name: "find", arguments: ["id"], line: 3, block: false }
44
+ #
45
+ class CallSiteExtractor
46
+ # Extract all call sites from an AST node, ordered by line number.
47
+ #
48
+ # @param node [Ast::Node] The AST node to search
49
+ # @return [Array<Hash>] Call site hashes ordered by line ascending
50
+ def extract(node)
51
+ calls = []
52
+ collect_calls(node, calls)
53
+ calls.sort_by { |c| c[:line] }
54
+ end
55
+
56
+ # Extract only significant call sites, filtering out noise.
57
+ #
58
+ # @param node [Ast::Node] The AST node to search
59
+ # @param known_units [Array<String>] Known unit identifiers for relevance filtering
60
+ # @return [Array<Hash>] Filtered call site hashes
61
+ def extract_significant(node, known_units: [])
62
+ calls = extract(node)
63
+ known_set = Set.new(known_units)
64
+
65
+ calls.reject do |call|
66
+ INSIGNIFICANT_METHODS.include?(call[:method_name]) &&
67
+ (known_units.empty? || !known_set.include?(call[:receiver]))
68
+ end
69
+ end
70
+
71
+ private
72
+
73
+ def collect_calls(node, calls)
74
+ return unless node.is_a?(Ast::Node)
75
+
76
+ case node.type
77
+ when :send
78
+ calls << {
79
+ receiver: node.receiver,
80
+ method_name: node.method_name,
81
+ arguments: node.arguments || [],
82
+ line: node.line,
83
+ block: false
84
+ }
85
+ when :block
86
+ # The send node in a block gets block: true
87
+ send_child = node.children&.first
88
+ if send_child.is_a?(Ast::Node) && send_child.type == :send
89
+ calls << {
90
+ receiver: send_child.receiver,
91
+ method_name: send_child.method_name,
92
+ arguments: send_child.arguments || [],
93
+ line: send_child.line,
94
+ block: true
95
+ }
96
+ end
97
+ # Also recurse into block body (children[1])
98
+ node.children&.drop(1)&.each { |child| collect_calls(child, calls) }
99
+ return # Don't double-recurse into children
100
+ end
101
+
102
+ (node.children || []).each { |child| collect_calls(child, calls) }
103
+ end
104
+ end
105
+ end
106
+ end
@@ -0,0 +1,71 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative 'parser'
4
+ require_relative 'node'
5
+
6
+ module Woods
7
+ module Ast
8
+ # Extracts method definitions and their source from Ruby source code.
9
+ #
10
+ # Replaces the fragile ~240 lines of `nesting_delta` / `neutralize_strings_and_comments`
11
+ # / `detect_heredoc_start` indentation heuristics in controller and mailer extractors.
12
+ #
13
+ # @example Extracting a method's source
14
+ # extractor = Ast::MethodExtractor.new
15
+ # source = extractor.extract_method_source(code, "create")
16
+ # # => "def create\n @user = User.find(params[:id])\nend\n"
17
+ #
18
+ class MethodExtractor
19
+ include SourceSpan
20
+
21
+ # @param parser [Ast::Parser, nil] Parser instance (creates default if nil)
22
+ def initialize(parser: nil)
23
+ @parser = parser || Parser.new
24
+ end
25
+
26
+ # Extract a method definition node by name.
27
+ #
28
+ # @param source [String] Ruby source code
29
+ # @param method_name [String] Method name to find
30
+ # @param class_method [Boolean] If true, look for `def self.method_name`
31
+ # @return [Ast::Node, nil] The :def or :defs node, or nil if not found
32
+ def extract_method(source, method_name, class_method: false)
33
+ root = @parser.parse(source)
34
+ target_type = class_method ? :defs : :def
35
+
36
+ root.find_all(target_type).find do |node|
37
+ node.method_name == method_name.to_s
38
+ end
39
+ end
40
+
41
+ # Extract all method definition nodes from source.
42
+ #
43
+ # @param source [String] Ruby source code
44
+ # @return [Array<Ast::Node>] All :def and :defs nodes
45
+ def extract_all_methods(source)
46
+ root = @parser.parse(source)
47
+ root.find_all(:def) + root.find_all(:defs)
48
+ end
49
+
50
+ # Extract the raw source text of a method, including def...end.
51
+ #
52
+ # This is the key replacement for `extract_action_source` in the controller
53
+ # and mailer extractors. Uses AST line tracking instead of indentation heuristics.
54
+ #
55
+ # @param source [String] Ruby source code
56
+ # @param method_name [String] Method name to find
57
+ # @param class_method [Boolean] If true, look for `def self.method_name`
58
+ # @return [String, nil] The method source text, or nil if not found
59
+ def extract_method_source(source, method_name, class_method: false)
60
+ node = extract_method(source, method_name, class_method: class_method)
61
+ return nil unless node
62
+
63
+ # If the node has a source field populated by the parser, use it
64
+ return node.source if node.source
65
+
66
+ # Fallback: extract by line range
67
+ extract_source_span(source, node.line, node.end_line)
68
+ end
69
+ end
70
+ end
71
+ end
@@ -0,0 +1,116 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Woods
4
+ module Ast
5
+ # Normalized AST node struct used by all consumers.
6
+ #
7
+ # Provides a parser-independent representation of Ruby AST nodes.
8
+ # Both Prism and the parser gem are normalized to this common structure.
9
+ #
10
+ # @example Creating a send node
11
+ # node = Ast::Node.new(
12
+ # type: :send,
13
+ # children: [],
14
+ # line: 42,
15
+ # receiver: "User",
16
+ # method_name: "find",
17
+ # arguments: ["id"]
18
+ # )
19
+ #
20
+ Node = Struct.new(
21
+ :type, # Symbol: :send, :block, :if, :def, :defs, :class, :module, :const, :begin, etc.
22
+ :children, # Array<Ast::Node | String | Symbol | Integer | nil>
23
+ :line, # Integer: 1-based source line number
24
+ :receiver, # String | nil: method call receiver (for :send)
25
+ :method_name, # String | nil: method name (for :send, :def, :defs)
26
+ :arguments, # Array<String>: argument representations (for :send)
27
+ :source, # String | nil: raw source text of this node
28
+ :end_line, # Integer | nil: 1-based end line number (when available)
29
+ keyword_init: true
30
+ ) do
31
+ # Find all descendant nodes matching a type.
32
+ #
33
+ # @param target_type [Symbol] The node type to search for
34
+ # @return [Array<Ast::Node>] All matching descendant nodes
35
+ def find_all(target_type)
36
+ results = []
37
+ queue = [self]
38
+ while (current = queue.shift)
39
+ results << current if current.type == target_type
40
+ (current.children || []).each do |child|
41
+ queue << child if child.is_a?(Ast::Node)
42
+ end
43
+ end
44
+ results
45
+ end
46
+
47
+ # Find the first descendant node matching a type (depth-first).
48
+ #
49
+ # @param target_type [Symbol] The node type to search for
50
+ # @return [Ast::Node, nil] The first matching node or nil
51
+ def find_first(target_type)
52
+ return self if type == target_type
53
+
54
+ (children || []).each do |child|
55
+ next unless child.is_a?(Ast::Node)
56
+
57
+ result = child.find_first(target_type)
58
+ return result if result
59
+ end
60
+ nil
61
+ end
62
+
63
+ # Return source text representation.
64
+ #
65
+ # @return [String] The source field if present, otherwise a reconstruction
66
+ def to_source
67
+ return source if source
68
+
69
+ case type
70
+ when :send
71
+ parts = []
72
+ parts << receiver if receiver
73
+ parts << method_name if method_name
74
+ parts.join('.')
75
+ when :const
76
+ parts = []
77
+ parts << receiver if receiver
78
+ parts << method_name if method_name
79
+ parts.join('::')
80
+ when :def, :defs
81
+ "def #{method_name}"
82
+ else
83
+ type.to_s
84
+ end
85
+ end
86
+ end
87
+
88
+ # Mixin for line-range source extraction, shared across Parser, MethodExtractor,
89
+ # and ClassAnalyzer.
90
+ #
91
+ # @example
92
+ # include Ast::SourceSpan
93
+ # extract_source_span(source, node.line, node.end_line)
94
+ #
95
+ module SourceSpan
96
+ private
97
+
98
+ # Extract source lines for a 1-based start/end line range.
99
+ #
100
+ # @param source [String] Full source text
101
+ # @param start_line [Integer, nil] 1-based start line
102
+ # @param end_line [Integer, nil] 1-based end line
103
+ # @return [String, nil] Extracted lines joined, or nil if out of range
104
+ def extract_source_span(source, start_line, end_line)
105
+ return nil unless start_line && end_line
106
+
107
+ lines = source.lines
108
+ start_idx = start_line - 1
109
+ end_idx = end_line - 1
110
+ return nil if start_idx.negative? || end_idx >= lines.length
111
+
112
+ lines[start_idx..end_idx].join
113
+ end
114
+ end
115
+ end
116
+ end