codebase_index 0.1.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 (171) hide show
  1. checksums.yaml +7 -0
  2. data/CHANGELOG.md +29 -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 +481 -0
  7. data/exe/codebase-console-mcp +22 -0
  8. data/exe/codebase-index-mcp +61 -0
  9. data/exe/codebase-index-mcp-http +64 -0
  10. data/exe/codebase-index-mcp-start +58 -0
  11. data/lib/codebase_index/ast/call_site_extractor.rb +106 -0
  12. data/lib/codebase_index/ast/method_extractor.rb +76 -0
  13. data/lib/codebase_index/ast/node.rb +88 -0
  14. data/lib/codebase_index/ast/parser.rb +653 -0
  15. data/lib/codebase_index/ast.rb +6 -0
  16. data/lib/codebase_index/builder.rb +137 -0
  17. data/lib/codebase_index/chunking/chunk.rb +84 -0
  18. data/lib/codebase_index/chunking/semantic_chunker.rb +290 -0
  19. data/lib/codebase_index/console/adapters/cache_adapter.rb +58 -0
  20. data/lib/codebase_index/console/adapters/good_job_adapter.rb +66 -0
  21. data/lib/codebase_index/console/adapters/sidekiq_adapter.rb +66 -0
  22. data/lib/codebase_index/console/adapters/solid_queue_adapter.rb +66 -0
  23. data/lib/codebase_index/console/audit_logger.rb +75 -0
  24. data/lib/codebase_index/console/bridge.rb +170 -0
  25. data/lib/codebase_index/console/confirmation.rb +90 -0
  26. data/lib/codebase_index/console/connection_manager.rb +173 -0
  27. data/lib/codebase_index/console/console_response_renderer.rb +78 -0
  28. data/lib/codebase_index/console/model_validator.rb +81 -0
  29. data/lib/codebase_index/console/safe_context.rb +82 -0
  30. data/lib/codebase_index/console/server.rb +557 -0
  31. data/lib/codebase_index/console/sql_validator.rb +172 -0
  32. data/lib/codebase_index/console/tools/tier1.rb +118 -0
  33. data/lib/codebase_index/console/tools/tier2.rb +117 -0
  34. data/lib/codebase_index/console/tools/tier3.rb +110 -0
  35. data/lib/codebase_index/console/tools/tier4.rb +79 -0
  36. data/lib/codebase_index/coordination/pipeline_lock.rb +109 -0
  37. data/lib/codebase_index/cost_model/embedding_cost.rb +88 -0
  38. data/lib/codebase_index/cost_model/estimator.rb +128 -0
  39. data/lib/codebase_index/cost_model/provider_pricing.rb +67 -0
  40. data/lib/codebase_index/cost_model/storage_cost.rb +52 -0
  41. data/lib/codebase_index/cost_model.rb +22 -0
  42. data/lib/codebase_index/db/migrations/001_create_units.rb +38 -0
  43. data/lib/codebase_index/db/migrations/002_create_edges.rb +35 -0
  44. data/lib/codebase_index/db/migrations/003_create_embeddings.rb +37 -0
  45. data/lib/codebase_index/db/migrations/004_create_snapshots.rb +45 -0
  46. data/lib/codebase_index/db/migrations/005_create_snapshot_units.rb +40 -0
  47. data/lib/codebase_index/db/migrator.rb +71 -0
  48. data/lib/codebase_index/db/schema_version.rb +73 -0
  49. data/lib/codebase_index/dependency_graph.rb +227 -0
  50. data/lib/codebase_index/embedding/indexer.rb +130 -0
  51. data/lib/codebase_index/embedding/openai.rb +105 -0
  52. data/lib/codebase_index/embedding/provider.rb +135 -0
  53. data/lib/codebase_index/embedding/text_preparer.rb +112 -0
  54. data/lib/codebase_index/evaluation/baseline_runner.rb +115 -0
  55. data/lib/codebase_index/evaluation/evaluator.rb +146 -0
  56. data/lib/codebase_index/evaluation/metrics.rb +79 -0
  57. data/lib/codebase_index/evaluation/query_set.rb +148 -0
  58. data/lib/codebase_index/evaluation/report_generator.rb +90 -0
  59. data/lib/codebase_index/extracted_unit.rb +145 -0
  60. data/lib/codebase_index/extractor.rb +956 -0
  61. data/lib/codebase_index/extractors/action_cable_extractor.rb +228 -0
  62. data/lib/codebase_index/extractors/ast_source_extraction.rb +46 -0
  63. data/lib/codebase_index/extractors/behavioral_profile.rb +309 -0
  64. data/lib/codebase_index/extractors/caching_extractor.rb +261 -0
  65. data/lib/codebase_index/extractors/callback_analyzer.rb +232 -0
  66. data/lib/codebase_index/extractors/concern_extractor.rb +253 -0
  67. data/lib/codebase_index/extractors/configuration_extractor.rb +219 -0
  68. data/lib/codebase_index/extractors/controller_extractor.rb +494 -0
  69. data/lib/codebase_index/extractors/database_view_extractor.rb +278 -0
  70. data/lib/codebase_index/extractors/decorator_extractor.rb +260 -0
  71. data/lib/codebase_index/extractors/engine_extractor.rb +204 -0
  72. data/lib/codebase_index/extractors/event_extractor.rb +211 -0
  73. data/lib/codebase_index/extractors/factory_extractor.rb +289 -0
  74. data/lib/codebase_index/extractors/graphql_extractor.rb +917 -0
  75. data/lib/codebase_index/extractors/i18n_extractor.rb +117 -0
  76. data/lib/codebase_index/extractors/job_extractor.rb +369 -0
  77. data/lib/codebase_index/extractors/lib_extractor.rb +249 -0
  78. data/lib/codebase_index/extractors/mailer_extractor.rb +339 -0
  79. data/lib/codebase_index/extractors/manager_extractor.rb +202 -0
  80. data/lib/codebase_index/extractors/middleware_extractor.rb +133 -0
  81. data/lib/codebase_index/extractors/migration_extractor.rb +469 -0
  82. data/lib/codebase_index/extractors/model_extractor.rb +960 -0
  83. data/lib/codebase_index/extractors/phlex_extractor.rb +252 -0
  84. data/lib/codebase_index/extractors/policy_extractor.rb +214 -0
  85. data/lib/codebase_index/extractors/poro_extractor.rb +246 -0
  86. data/lib/codebase_index/extractors/pundit_extractor.rb +223 -0
  87. data/lib/codebase_index/extractors/rails_source_extractor.rb +473 -0
  88. data/lib/codebase_index/extractors/rake_task_extractor.rb +343 -0
  89. data/lib/codebase_index/extractors/route_extractor.rb +181 -0
  90. data/lib/codebase_index/extractors/scheduled_job_extractor.rb +331 -0
  91. data/lib/codebase_index/extractors/serializer_extractor.rb +334 -0
  92. data/lib/codebase_index/extractors/service_extractor.rb +254 -0
  93. data/lib/codebase_index/extractors/shared_dependency_scanner.rb +91 -0
  94. data/lib/codebase_index/extractors/shared_utility_methods.rb +99 -0
  95. data/lib/codebase_index/extractors/state_machine_extractor.rb +398 -0
  96. data/lib/codebase_index/extractors/test_mapping_extractor.rb +225 -0
  97. data/lib/codebase_index/extractors/validator_extractor.rb +225 -0
  98. data/lib/codebase_index/extractors/view_component_extractor.rb +310 -0
  99. data/lib/codebase_index/extractors/view_template_extractor.rb +261 -0
  100. data/lib/codebase_index/feedback/gap_detector.rb +89 -0
  101. data/lib/codebase_index/feedback/store.rb +119 -0
  102. data/lib/codebase_index/flow_analysis/operation_extractor.rb +209 -0
  103. data/lib/codebase_index/flow_analysis/response_code_mapper.rb +154 -0
  104. data/lib/codebase_index/flow_assembler.rb +290 -0
  105. data/lib/codebase_index/flow_document.rb +191 -0
  106. data/lib/codebase_index/flow_precomputer.rb +102 -0
  107. data/lib/codebase_index/formatting/base.rb +40 -0
  108. data/lib/codebase_index/formatting/claude_adapter.rb +98 -0
  109. data/lib/codebase_index/formatting/generic_adapter.rb +56 -0
  110. data/lib/codebase_index/formatting/gpt_adapter.rb +64 -0
  111. data/lib/codebase_index/formatting/human_adapter.rb +78 -0
  112. data/lib/codebase_index/graph_analyzer.rb +374 -0
  113. data/lib/codebase_index/mcp/index_reader.rb +394 -0
  114. data/lib/codebase_index/mcp/renderers/claude_renderer.rb +81 -0
  115. data/lib/codebase_index/mcp/renderers/json_renderer.rb +17 -0
  116. data/lib/codebase_index/mcp/renderers/markdown_renderer.rb +352 -0
  117. data/lib/codebase_index/mcp/renderers/plain_renderer.rb +240 -0
  118. data/lib/codebase_index/mcp/server.rb +935 -0
  119. data/lib/codebase_index/mcp/tool_response_renderer.rb +62 -0
  120. data/lib/codebase_index/model_name_cache.rb +51 -0
  121. data/lib/codebase_index/notion/client.rb +217 -0
  122. data/lib/codebase_index/notion/exporter.rb +219 -0
  123. data/lib/codebase_index/notion/mapper.rb +39 -0
  124. data/lib/codebase_index/notion/mappers/column_mapper.rb +65 -0
  125. data/lib/codebase_index/notion/mappers/migration_mapper.rb +39 -0
  126. data/lib/codebase_index/notion/mappers/model_mapper.rb +164 -0
  127. data/lib/codebase_index/notion/rate_limiter.rb +68 -0
  128. data/lib/codebase_index/observability/health_check.rb +81 -0
  129. data/lib/codebase_index/observability/instrumentation.rb +34 -0
  130. data/lib/codebase_index/observability/structured_logger.rb +75 -0
  131. data/lib/codebase_index/operator/error_escalator.rb +81 -0
  132. data/lib/codebase_index/operator/pipeline_guard.rb +99 -0
  133. data/lib/codebase_index/operator/status_reporter.rb +80 -0
  134. data/lib/codebase_index/railtie.rb +26 -0
  135. data/lib/codebase_index/resilience/circuit_breaker.rb +99 -0
  136. data/lib/codebase_index/resilience/index_validator.rb +185 -0
  137. data/lib/codebase_index/resilience/retryable_provider.rb +108 -0
  138. data/lib/codebase_index/retrieval/context_assembler.rb +249 -0
  139. data/lib/codebase_index/retrieval/query_classifier.rb +131 -0
  140. data/lib/codebase_index/retrieval/ranker.rb +273 -0
  141. data/lib/codebase_index/retrieval/search_executor.rb +327 -0
  142. data/lib/codebase_index/retriever.rb +160 -0
  143. data/lib/codebase_index/ruby_analyzer/class_analyzer.rb +190 -0
  144. data/lib/codebase_index/ruby_analyzer/dataflow_analyzer.rb +78 -0
  145. data/lib/codebase_index/ruby_analyzer/fqn_builder.rb +18 -0
  146. data/lib/codebase_index/ruby_analyzer/mermaid_renderer.rb +275 -0
  147. data/lib/codebase_index/ruby_analyzer/method_analyzer.rb +143 -0
  148. data/lib/codebase_index/ruby_analyzer/trace_enricher.rb +139 -0
  149. data/lib/codebase_index/ruby_analyzer.rb +87 -0
  150. data/lib/codebase_index/session_tracer/file_store.rb +111 -0
  151. data/lib/codebase_index/session_tracer/middleware.rb +143 -0
  152. data/lib/codebase_index/session_tracer/redis_store.rb +112 -0
  153. data/lib/codebase_index/session_tracer/session_flow_assembler.rb +263 -0
  154. data/lib/codebase_index/session_tracer/session_flow_document.rb +223 -0
  155. data/lib/codebase_index/session_tracer/solid_cache_store.rb +145 -0
  156. data/lib/codebase_index/session_tracer/store.rb +67 -0
  157. data/lib/codebase_index/storage/graph_store.rb +120 -0
  158. data/lib/codebase_index/storage/metadata_store.rb +169 -0
  159. data/lib/codebase_index/storage/pgvector.rb +163 -0
  160. data/lib/codebase_index/storage/qdrant.rb +172 -0
  161. data/lib/codebase_index/storage/vector_store.rb +156 -0
  162. data/lib/codebase_index/temporal/snapshot_store.rb +341 -0
  163. data/lib/codebase_index/version.rb +5 -0
  164. data/lib/codebase_index.rb +223 -0
  165. data/lib/generators/codebase_index/install_generator.rb +32 -0
  166. data/lib/generators/codebase_index/pgvector_generator.rb +37 -0
  167. data/lib/generators/codebase_index/templates/add_pgvector_to_codebase_index.rb.erb +15 -0
  168. data/lib/generators/codebase_index/templates/create_codebase_index_tables.rb.erb +43 -0
  169. data/lib/tasks/codebase_index.rake +583 -0
  170. data/lib/tasks/codebase_index_evaluation.rake +115 -0
  171. metadata +252 -0
@@ -0,0 +1,58 @@
1
+ #!/usr/bin/env bash
2
+ # Self-healing wrapper for the CodebaseIndex MCP server.
3
+ # Ensures Ruby dependencies are installed, validates the index directory,
4
+ # and starts the stdio MCP server.
5
+ #
6
+ # Usage (direct):
7
+ # codebase-index-mcp-start /path/to/index_dir
8
+ #
9
+ # Usage (.mcp.json):
10
+ # {
11
+ # "command": "${HOME}/work/codebase_index/exe/codebase-index-mcp-start",
12
+ # "args": ["${HOME}/my-rails-app/tmp/codebase_index"]
13
+ # }
14
+ #
15
+ # All diagnostic output goes to stderr to keep stdio clean for MCP protocol.
16
+
17
+ set -euo pipefail
18
+
19
+ SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
20
+ GEM_DIR="$(cd "$SCRIPT_DIR/.." && pwd)"
21
+ INDEX_DIR="${1:-${CODEBASE_INDEX_DIR:-}}"
22
+
23
+ # --- Validate index directory ---
24
+ if [[ -z "$INDEX_DIR" ]]; then
25
+ echo "Error: No index directory specified." >&2
26
+ echo "Usage: codebase-index-mcp-start /path/to/index_dir" >&2
27
+ exit 1
28
+ fi
29
+
30
+ if [[ ! -d "$INDEX_DIR" ]]; then
31
+ echo "Error: Index directory does not exist: $INDEX_DIR" >&2
32
+ echo "Run extraction first: bundle exec rake codebase_index:extract" >&2
33
+ exit 1
34
+ fi
35
+
36
+ if [[ ! -f "$INDEX_DIR/manifest.json" ]]; then
37
+ echo "Error: No manifest.json in: $INDEX_DIR" >&2
38
+ echo "Run extraction first: bundle exec rake codebase_index:extract" >&2
39
+ exit 1
40
+ fi
41
+
42
+ # --- Ensure Ruby dependencies are installed ---
43
+ export BUNDLE_GEMFILE="${GEM_DIR}/Gemfile"
44
+
45
+ if ! bundle check > /dev/null 2>&1; then
46
+ echo "Installing codebase_index dependencies..." >&2
47
+ if ! bundle install --quiet >&2 2>&1; then
48
+ echo "Error: bundle install failed. Check Ruby version and network." >&2
49
+ exit 1
50
+ fi
51
+ echo "Dependencies installed." >&2
52
+ fi
53
+
54
+ # --- Pin MCP protocol version for Claude Code compatibility ---
55
+ export MCP_PROTOCOL_VERSION="${MCP_PROTOCOL_VERSION:-2024-11-05}"
56
+
57
+ # --- Start the MCP server ---
58
+ exec bundle exec ruby "${GEM_DIR}/exe/codebase-index-mcp" "$INDEX_DIR"
@@ -0,0 +1,106 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'set'
4
+ require_relative 'node'
5
+
6
+ module CodebaseIndex
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,76 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative 'parser'
4
+ require_relative 'node'
5
+
6
+ module CodebaseIndex
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
+ # @param parser [Ast::Parser, nil] Parser instance (creates default if nil)
20
+ def initialize(parser: nil)
21
+ @parser = parser || Parser.new
22
+ end
23
+
24
+ # Extract a method definition node by name.
25
+ #
26
+ # @param source [String] Ruby source code
27
+ # @param method_name [String] Method name to find
28
+ # @param class_method [Boolean] If true, look for `def self.method_name`
29
+ # @return [Ast::Node, nil] The :def or :defs node, or nil if not found
30
+ def extract_method(source, method_name, class_method: false)
31
+ root = @parser.parse(source)
32
+ target_type = class_method ? :defs : :def
33
+
34
+ root.find_all(target_type).find do |node|
35
+ node.method_name == method_name.to_s
36
+ end
37
+ end
38
+
39
+ # Extract all method definition nodes from source.
40
+ #
41
+ # @param source [String] Ruby source code
42
+ # @return [Array<Ast::Node>] All :def and :defs nodes
43
+ def extract_all_methods(source)
44
+ root = @parser.parse(source)
45
+ root.find_all(:def) + root.find_all(:defs)
46
+ end
47
+
48
+ # Extract the raw source text of a method, including def...end.
49
+ #
50
+ # This is the key replacement for `extract_action_source` in the controller
51
+ # and mailer extractors. Uses AST line tracking instead of indentation heuristics.
52
+ #
53
+ # @param source [String] Ruby source code
54
+ # @param method_name [String] Method name to find
55
+ # @param class_method [Boolean] If true, look for `def self.method_name`
56
+ # @return [String, nil] The method source text, or nil if not found
57
+ def extract_method_source(source, method_name, class_method: false)
58
+ node = extract_method(source, method_name, class_method: class_method)
59
+ return nil unless node
60
+
61
+ # If the node has a source field populated by the parser, use it
62
+ return node.source if node.source
63
+
64
+ # Fallback: extract by line range
65
+ return nil unless node.line && node.end_line
66
+
67
+ lines = source.lines
68
+ start_idx = node.line - 1
69
+ end_idx = node.end_line - 1
70
+ return nil if start_idx.negative? || end_idx >= lines.length
71
+
72
+ lines[start_idx..end_idx].join
73
+ end
74
+ end
75
+ end
76
+ end
@@ -0,0 +1,88 @@
1
+ # frozen_string_literal: true
2
+
3
+ module CodebaseIndex
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
+ end
88
+ end