woods 1.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +7 -0
- data/CHANGELOG.md +89 -0
- data/CODE_OF_CONDUCT.md +83 -0
- data/CONTRIBUTING.md +65 -0
- data/LICENSE.txt +21 -0
- data/README.md +406 -0
- data/exe/woods-console +59 -0
- data/exe/woods-console-mcp +22 -0
- data/exe/woods-mcp +34 -0
- data/exe/woods-mcp-http +37 -0
- data/exe/woods-mcp-start +58 -0
- data/lib/generators/woods/install_generator.rb +32 -0
- data/lib/generators/woods/pgvector_generator.rb +37 -0
- data/lib/generators/woods/templates/add_pgvector_to_woods.rb.erb +15 -0
- data/lib/generators/woods/templates/create_woods_tables.rb.erb +43 -0
- data/lib/tasks/woods.rake +621 -0
- data/lib/tasks/woods_evaluation.rake +115 -0
- data/lib/woods/ast/call_site_extractor.rb +106 -0
- data/lib/woods/ast/method_extractor.rb +71 -0
- data/lib/woods/ast/node.rb +116 -0
- data/lib/woods/ast/parser.rb +614 -0
- data/lib/woods/ast.rb +6 -0
- data/lib/woods/builder.rb +200 -0
- data/lib/woods/cache/cache_middleware.rb +199 -0
- data/lib/woods/cache/cache_store.rb +264 -0
- data/lib/woods/cache/redis_cache_store.rb +116 -0
- data/lib/woods/cache/solid_cache_store.rb +111 -0
- data/lib/woods/chunking/chunk.rb +84 -0
- data/lib/woods/chunking/semantic_chunker.rb +295 -0
- data/lib/woods/console/adapters/cache_adapter.rb +58 -0
- data/lib/woods/console/adapters/good_job_adapter.rb +33 -0
- data/lib/woods/console/adapters/job_adapter.rb +68 -0
- data/lib/woods/console/adapters/sidekiq_adapter.rb +33 -0
- data/lib/woods/console/adapters/solid_queue_adapter.rb +33 -0
- data/lib/woods/console/audit_logger.rb +75 -0
- data/lib/woods/console/bridge.rb +177 -0
- data/lib/woods/console/confirmation.rb +90 -0
- data/lib/woods/console/connection_manager.rb +173 -0
- data/lib/woods/console/console_response_renderer.rb +74 -0
- data/lib/woods/console/embedded_executor.rb +373 -0
- data/lib/woods/console/model_validator.rb +81 -0
- data/lib/woods/console/rack_middleware.rb +87 -0
- data/lib/woods/console/safe_context.rb +82 -0
- data/lib/woods/console/server.rb +612 -0
- data/lib/woods/console/sql_validator.rb +172 -0
- data/lib/woods/console/tools/tier1.rb +118 -0
- data/lib/woods/console/tools/tier2.rb +117 -0
- data/lib/woods/console/tools/tier3.rb +110 -0
- data/lib/woods/console/tools/tier4.rb +79 -0
- data/lib/woods/coordination/pipeline_lock.rb +109 -0
- data/lib/woods/cost_model/embedding_cost.rb +88 -0
- data/lib/woods/cost_model/estimator.rb +128 -0
- data/lib/woods/cost_model/provider_pricing.rb +67 -0
- data/lib/woods/cost_model/storage_cost.rb +52 -0
- data/lib/woods/cost_model.rb +22 -0
- data/lib/woods/db/migrations/001_create_units.rb +38 -0
- data/lib/woods/db/migrations/002_create_edges.rb +35 -0
- data/lib/woods/db/migrations/003_create_embeddings.rb +37 -0
- data/lib/woods/db/migrations/004_create_snapshots.rb +45 -0
- data/lib/woods/db/migrations/005_create_snapshot_units.rb +40 -0
- data/lib/woods/db/migrations/006_rename_tables.rb +34 -0
- data/lib/woods/db/migrator.rb +73 -0
- data/lib/woods/db/schema_version.rb +73 -0
- data/lib/woods/dependency_graph.rb +236 -0
- data/lib/woods/embedding/indexer.rb +140 -0
- data/lib/woods/embedding/openai.rb +126 -0
- data/lib/woods/embedding/provider.rb +162 -0
- data/lib/woods/embedding/text_preparer.rb +112 -0
- data/lib/woods/evaluation/baseline_runner.rb +115 -0
- data/lib/woods/evaluation/evaluator.rb +139 -0
- data/lib/woods/evaluation/metrics.rb +79 -0
- data/lib/woods/evaluation/query_set.rb +148 -0
- data/lib/woods/evaluation/report_generator.rb +90 -0
- data/lib/woods/extracted_unit.rb +145 -0
- data/lib/woods/extractor.rb +1028 -0
- data/lib/woods/extractors/action_cable_extractor.rb +201 -0
- data/lib/woods/extractors/ast_source_extraction.rb +46 -0
- data/lib/woods/extractors/behavioral_profile.rb +309 -0
- data/lib/woods/extractors/caching_extractor.rb +261 -0
- data/lib/woods/extractors/callback_analyzer.rb +246 -0
- data/lib/woods/extractors/concern_extractor.rb +292 -0
- data/lib/woods/extractors/configuration_extractor.rb +219 -0
- data/lib/woods/extractors/controller_extractor.rb +404 -0
- data/lib/woods/extractors/database_view_extractor.rb +278 -0
- data/lib/woods/extractors/decorator_extractor.rb +253 -0
- data/lib/woods/extractors/engine_extractor.rb +223 -0
- data/lib/woods/extractors/event_extractor.rb +211 -0
- data/lib/woods/extractors/factory_extractor.rb +289 -0
- data/lib/woods/extractors/graphql_extractor.rb +892 -0
- data/lib/woods/extractors/i18n_extractor.rb +117 -0
- data/lib/woods/extractors/job_extractor.rb +374 -0
- data/lib/woods/extractors/lib_extractor.rb +218 -0
- data/lib/woods/extractors/mailer_extractor.rb +269 -0
- data/lib/woods/extractors/manager_extractor.rb +188 -0
- data/lib/woods/extractors/middleware_extractor.rb +133 -0
- data/lib/woods/extractors/migration_extractor.rb +469 -0
- data/lib/woods/extractors/model_extractor.rb +988 -0
- data/lib/woods/extractors/phlex_extractor.rb +252 -0
- data/lib/woods/extractors/policy_extractor.rb +191 -0
- data/lib/woods/extractors/poro_extractor.rb +229 -0
- data/lib/woods/extractors/pundit_extractor.rb +223 -0
- data/lib/woods/extractors/rails_source_extractor.rb +473 -0
- data/lib/woods/extractors/rake_task_extractor.rb +343 -0
- data/lib/woods/extractors/route_extractor.rb +181 -0
- data/lib/woods/extractors/scheduled_job_extractor.rb +331 -0
- data/lib/woods/extractors/serializer_extractor.rb +339 -0
- data/lib/woods/extractors/service_extractor.rb +217 -0
- data/lib/woods/extractors/shared_dependency_scanner.rb +91 -0
- data/lib/woods/extractors/shared_utility_methods.rb +281 -0
- data/lib/woods/extractors/state_machine_extractor.rb +398 -0
- data/lib/woods/extractors/test_mapping_extractor.rb +225 -0
- data/lib/woods/extractors/validator_extractor.rb +211 -0
- data/lib/woods/extractors/view_component_extractor.rb +311 -0
- data/lib/woods/extractors/view_template_extractor.rb +261 -0
- data/lib/woods/feedback/gap_detector.rb +89 -0
- data/lib/woods/feedback/store.rb +119 -0
- data/lib/woods/filename_utils.rb +32 -0
- data/lib/woods/flow_analysis/operation_extractor.rb +206 -0
- data/lib/woods/flow_analysis/response_code_mapper.rb +154 -0
- data/lib/woods/flow_assembler.rb +290 -0
- data/lib/woods/flow_document.rb +191 -0
- data/lib/woods/flow_precomputer.rb +102 -0
- data/lib/woods/formatting/base.rb +30 -0
- data/lib/woods/formatting/claude_adapter.rb +98 -0
- data/lib/woods/formatting/generic_adapter.rb +56 -0
- data/lib/woods/formatting/gpt_adapter.rb +64 -0
- data/lib/woods/formatting/human_adapter.rb +78 -0
- data/lib/woods/graph_analyzer.rb +374 -0
- data/lib/woods/mcp/bootstrapper.rb +96 -0
- data/lib/woods/mcp/index_reader.rb +394 -0
- data/lib/woods/mcp/renderers/claude_renderer.rb +81 -0
- data/lib/woods/mcp/renderers/json_renderer.rb +17 -0
- data/lib/woods/mcp/renderers/markdown_renderer.rb +353 -0
- data/lib/woods/mcp/renderers/plain_renderer.rb +240 -0
- data/lib/woods/mcp/server.rb +962 -0
- data/lib/woods/mcp/tool_response_renderer.rb +85 -0
- data/lib/woods/model_name_cache.rb +51 -0
- data/lib/woods/notion/client.rb +217 -0
- data/lib/woods/notion/exporter.rb +219 -0
- data/lib/woods/notion/mapper.rb +40 -0
- data/lib/woods/notion/mappers/column_mapper.rb +57 -0
- data/lib/woods/notion/mappers/migration_mapper.rb +39 -0
- data/lib/woods/notion/mappers/model_mapper.rb +161 -0
- data/lib/woods/notion/mappers/shared.rb +22 -0
- data/lib/woods/notion/rate_limiter.rb +68 -0
- data/lib/woods/observability/health_check.rb +79 -0
- data/lib/woods/observability/instrumentation.rb +34 -0
- data/lib/woods/observability/structured_logger.rb +57 -0
- data/lib/woods/operator/error_escalator.rb +81 -0
- data/lib/woods/operator/pipeline_guard.rb +92 -0
- data/lib/woods/operator/status_reporter.rb +80 -0
- data/lib/woods/railtie.rb +38 -0
- data/lib/woods/resilience/circuit_breaker.rb +99 -0
- data/lib/woods/resilience/index_validator.rb +167 -0
- data/lib/woods/resilience/retryable_provider.rb +108 -0
- data/lib/woods/retrieval/context_assembler.rb +261 -0
- data/lib/woods/retrieval/query_classifier.rb +133 -0
- data/lib/woods/retrieval/ranker.rb +277 -0
- data/lib/woods/retrieval/search_executor.rb +316 -0
- data/lib/woods/retriever.rb +152 -0
- data/lib/woods/ruby_analyzer/class_analyzer.rb +170 -0
- data/lib/woods/ruby_analyzer/dataflow_analyzer.rb +77 -0
- data/lib/woods/ruby_analyzer/fqn_builder.rb +18 -0
- data/lib/woods/ruby_analyzer/mermaid_renderer.rb +280 -0
- data/lib/woods/ruby_analyzer/method_analyzer.rb +143 -0
- data/lib/woods/ruby_analyzer/trace_enricher.rb +143 -0
- data/lib/woods/ruby_analyzer.rb +87 -0
- data/lib/woods/session_tracer/file_store.rb +104 -0
- data/lib/woods/session_tracer/middleware.rb +143 -0
- data/lib/woods/session_tracer/redis_store.rb +106 -0
- data/lib/woods/session_tracer/session_flow_assembler.rb +254 -0
- data/lib/woods/session_tracer/session_flow_document.rb +223 -0
- data/lib/woods/session_tracer/solid_cache_store.rb +139 -0
- data/lib/woods/session_tracer/store.rb +81 -0
- data/lib/woods/storage/graph_store.rb +120 -0
- data/lib/woods/storage/metadata_store.rb +196 -0
- data/lib/woods/storage/pgvector.rb +195 -0
- data/lib/woods/storage/qdrant.rb +205 -0
- data/lib/woods/storage/vector_store.rb +167 -0
- data/lib/woods/temporal/json_snapshot_store.rb +245 -0
- data/lib/woods/temporal/snapshot_store.rb +345 -0
- data/lib/woods/token_utils.rb +19 -0
- data/lib/woods/version.rb +5 -0
- data/lib/woods.rb +246 -0
- metadata +270 -0
|
@@ -0,0 +1,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
|