rubik 0.3.1 → 0.3.3

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 (38) hide show
  1. checksums.yaml +4 -4
  2. data/ATTRIBUTION.md +1 -1
  3. data/CHANGELOG.md +79 -0
  4. data/lib/rubik/ast/cache_store.rb +4 -2
  5. data/lib/rubik/ast/graph.rb +49 -94
  6. data/lib/rubik/ast/graph_extensions.rb +202 -0
  7. data/lib/rubik/ast/ruby_extractor.rb +8 -3
  8. data/lib/rubik/ast/symbol.rb +7 -0
  9. data/lib/rubik/ast/treesitter/base_walker.rb +4 -3
  10. data/lib/rubik/ast/treesitter/c_walker.rb +10 -8
  11. data/lib/rubik/ast/treesitter/go_walker.rb +4 -2
  12. data/lib/rubik/ast/treesitter/rust_walker.rb +9 -7
  13. data/lib/rubik/ast/treesitter/typescript_walker.rb +7 -5
  14. data/lib/rubik/ast/treesitter/zig_walker.rb +44 -6
  15. data/lib/rubik/config/defaults.rb +3 -6
  16. data/lib/rubik/config/store.rb +1 -1
  17. data/lib/rubik/lsp/client.rb +102 -24
  18. data/lib/rubik/lsp/manager.rb +136 -83
  19. data/lib/rubik/orchestrator/subagent/budget.rb +17 -10
  20. data/lib/rubik/orchestrator/subagent/instance.rb +14 -2
  21. data/lib/rubik/orchestrator/subagent/journal.rb +56 -13
  22. data/lib/rubik/orchestrator/subagent/reuse.rb +1 -1
  23. data/lib/rubik/orchestrator/subagent/run_cycle.rb +8 -5
  24. data/lib/rubik/orchestrator/subagent/supervisor.rb +7 -1
  25. data/lib/rubik/orchestrator/tool_circuit_breaker.rb +15 -5
  26. data/lib/rubik/platform/process.rb +1 -1
  27. data/lib/rubik/platform/sanitize.rb +4 -4
  28. data/lib/rubik/platform.rb +2 -2
  29. data/lib/rubik/prompt/token_estimator.rb +15 -3
  30. data/lib/rubik/repl/ast_warmup.rb +35 -4
  31. data/lib/rubik/repl/commands/subagents.rb +12 -11
  32. data/lib/rubik/repl/interrupt_handler.rb +10 -10
  33. data/lib/rubik/repl/shell.rb +1 -1
  34. data/lib/rubik/tools/builtins/subagent.rb +7 -2
  35. data/lib/rubik/tools/builtins/symbols.rb +56 -20
  36. data/lib/rubik/tools/symbol_tool_support.rb +113 -0
  37. data/lib/rubik/version.rb +1 -1
  38. metadata +3 -1
@@ -87,7 +87,7 @@ module Rubik
87
87
 
88
88
  def home_dir
89
89
  Dir.home
90
- rescue
90
+ rescue StandardError
91
91
  (ENV["USERPROFILE"] || (ENV.fetch("HOMEDRIVE",
92
92
  nil) && ENV.fetch("HOMEPATH",
93
93
  nil) && "#{ENV.fetch('HOMEDRIVE',
@@ -96,7 +96,7 @@ module Rubik
96
96
 
97
97
  def tmp_dir
98
98
  Dir.tmpdir
99
- rescue
99
+ rescue StandardError
100
100
  (ENV["TEMP"] || ENV["TMP"] || "/tmp")
101
101
  end
102
102
  end
@@ -34,7 +34,7 @@ module Rubik
34
34
 
35
35
  tool_calls = msg[:tool_calls] || msg["tool_calls"]
36
36
  if tool_calls && !tool_calls.empty?
37
- tool_calls_str = tool_calls.is_a?(String) ? Rubik::Platform.clean_utf8(tool_calls) : Rubik::Platform.safe_json_generate(tool_calls)
37
+ tool_calls_str = serialize_for_estimation(tool_calls)
38
38
  tokens += estimate_text(tool_calls_str)
39
39
  end
40
40
 
@@ -57,8 +57,20 @@ module Rubik
57
57
  return 0 if schemas.nil?
58
58
  return 0 if schemas.respond_to?(:empty?) && schemas.empty?
59
59
 
60
- json_str = schemas.is_a?(String) ? Rubik::Platform.clean_utf8(schemas) : Rubik::Platform.safe_json_generate(schemas)
61
- estimate_text(json_str)
60
+ estimate_text(serialize_for_estimation(schemas))
61
+ end
62
+
63
+ # PERF: Fast-path JSON generation for tool_calls and tool_schemas estimation.
64
+ # Uses direct JSON.generate to avoid deep clean_utf8 traversal, falling back
65
+ # to Rubik::Platform.safe_json_generate only when malformed UTF-8 raises.
66
+ def serialize_for_estimation(obj)
67
+ return Rubik::Platform.clean_utf8(obj) if obj.is_a?(String)
68
+
69
+ begin
70
+ JSON.generate(obj)
71
+ rescue StandardError
72
+ Rubik::Platform.safe_json_generate(obj)
73
+ end
62
74
  end
63
75
  end
64
76
  end
@@ -22,10 +22,15 @@ module Rubik
22
22
  # Reset the cooperative-stop flag so repeated warm cycles behave the
23
23
  # same as the first (per-turn scopes, MCP reconnects, tool epochs).
24
24
  @indexer_stop_requested = false
25
+ warmup_token = @ast_graph.begin_warmup if @ast_graph.respond_to?(:begin_warmup)
25
26
  @indexer_thread = Thread.new do
27
+ Thread.current[:rubik_ast_warmup_token] = warmup_token
26
28
  perform_warm_ast_graph
27
29
  rescue StandardError => e
30
+ @ast_graph.finish_warmup(warmup_token, state: :failed) if warmup_token
28
31
  @logger&.warn("AST graph background indexer thread error: #{e.class}: #{e.message}")
32
+ ensure
33
+ Thread.current[:rubik_ast_warmup_token] = nil
29
34
  end
30
35
  ctx = @runtime_manager&.context || (respond_to?(:context) ? context : nil)
31
36
  if ctx.respond_to?(:register_disposable) && ctx != @indexer_disposable_ctx
@@ -33,7 +38,16 @@ module Rubik
33
38
  ctx.register_disposable { stop_background_indexer }
34
39
  end
35
40
  else
36
- perform_warm_ast_graph
41
+ @indexer_stop_requested = false
42
+ warmup_token = @ast_graph.begin_warmup if @ast_graph.respond_to?(:begin_warmup)
43
+ previous_token = Thread.current[:rubik_ast_warmup_token]
44
+ Thread.current[:rubik_ast_warmup_token] = warmup_token
45
+ begin
46
+ perform_warm_ast_graph
47
+ ensure
48
+ Thread.current[:rubik_ast_warmup_token] = previous_token
49
+ @ast_graph.finish_warmup(warmup_token) if warmup_token
50
+ end
37
51
  end
38
52
  end
39
53
 
@@ -82,6 +96,9 @@ module Rubik
82
96
  end
83
97
 
84
98
  def perform_warm_ast_graph
99
+ warmup_token = Thread.current[:rubik_ast_warmup_token]
100
+ warmup_token ||= @ast_graph.begin_warmup if @ast_graph.respond_to?(:begin_warmup)
101
+ warmup_state = :ready
85
102
  root_d = @buffer_manager&.root_dir || Dir.pwd
86
103
  delta = @context_index.incremental_scan
87
104
  indexed_files = @ast_graph.file_symbols.keys
@@ -96,12 +113,21 @@ module Rubik
96
113
  files_to_index << rel_file if Rubik::AST::Extractor.supported_extensions.include?(ext) && !(indexed_files.include?(full_path) || indexed_files.include?(rel_file))
97
114
  end
98
115
 
99
- files_to_index.uniq.each do |rel_file|
116
+ files_to_process = files_to_index.uniq.select do |rel_file|
117
+ ext = File.extname(rel_file).downcase
118
+ Rubik::AST::Extractor.supported_extensions.include?(ext) && File.exist?(File.join(root_d, rel_file))
119
+ end
120
+ total_operations = files_to_process.length + delta[:deleted].length
121
+ @ast_graph.update_warmup(warmup_token, indexed: 0, total: total_operations) if warmup_token
122
+ completed_operations = 0
123
+
124
+ files_to_process.each do |rel_file|
100
125
  break if @indexer_stop_requested
101
126
 
102
127
  full_path = File.join(root_d, rel_file)
103
- ext = File.extname(rel_file).downcase
104
- @ast_graph.index_file(full_path) if Rubik::AST::Extractor.supported_extensions.include?(ext) && File.exist?(full_path)
128
+ @ast_graph.index_file(full_path)
129
+ completed_operations += 1
130
+ @ast_graph.update_warmup(warmup_token, indexed: completed_operations, total: total_operations) if warmup_token
105
131
  end
106
132
 
107
133
  delta[:deleted].each do |rel_file|
@@ -109,6 +135,8 @@ module Rubik
109
135
 
110
136
  full_path = File.join(root_d, rel_file)
111
137
  @ast_graph.remove_file(full_path)
138
+ completed_operations += 1
139
+ @ast_graph.update_warmup(warmup_token, indexed: completed_operations, total: total_operations) if warmup_token
112
140
  end
113
141
 
114
142
  return if @indexer_stop_requested
@@ -120,9 +148,12 @@ module Rubik
120
148
  @ast_graph.save_to_cache(@cache_store)
121
149
  end
122
150
  rescue StandardError => e
151
+ warmup_state = :failed
123
152
  @logger&.warn("AST graph warm failed: #{e.message}")
124
153
  @event_bus&.emit(:ast_warm_failed, error: e.message)
125
154
  nil
155
+ ensure
156
+ @ast_graph.finish_warmup(warmup_token, state: warmup_state) if warmup_token
126
157
  end
127
158
  end
128
159
  end
@@ -113,10 +113,7 @@ module Rubik
113
113
  end
114
114
 
115
115
  def self.handle_kill(ctx, sup, id)
116
- if id.to_s.strip.empty?
117
- ctx.out.puts "Usage: !subagents kill <subagent_id>"
118
- return
119
- end
116
+ return ctx.out.puts "Usage: !subagents kill <subagent_id>" if id.to_s.strip.empty?
120
117
 
121
118
  if sup.kill(id)
122
119
  ctx.out.puts "Subagent '#{id}' and child tree killed."
@@ -126,17 +123,21 @@ module Rubik
126
123
  end
127
124
 
128
125
  def self.handle_extend(ctx, sup, id, steps_arg)
129
- if id.to_s.strip.empty?
130
- ctx.out.puts "Usage: !subagents extend <subagent_id> [additional_steps]"
131
- return
132
- end
126
+ return ctx.out.puts "Usage: !subagents extend <subagent_id> [additional_steps]" if id.to_s.strip.empty?
133
127
 
134
128
  steps = Rubik::Orchestrator::Subagent::Budget.normalize_grant(steps_arg)
129
+ inst = sup.get(id)
135
130
  if sup.extend_budget(id, steps)
136
- inst = sup.get(id)
137
- ctx.out.puts "Extended budget for #{id} by #{steps} steps (total: #{inst.max_steps}, state: #{inst.state.to_s.upcase})."
131
+ ceiling = inst.respond_to?(:max_total_steps) ? "/#{inst.max_total_steps}" : ""
132
+ ctx.out.puts "Extended budget for #{id} by #{steps} steps (total: #{inst.max_steps}#{ceiling}, " \
133
+ "state: #{inst.state.to_s.upcase})."
138
134
  else
139
- ctx.out.puts "Could not extend budget for #{id} (not found or inactive)."
135
+ msg = if inst.respond_to?(:max_total_steps) && inst.max_steps >= inst.max_total_steps
136
+ "Could not extend budget for #{id}: lifetime step limit of #{inst.max_total_steps} reached."
137
+ else
138
+ "Could not extend budget for #{id} (not found or inactive)."
139
+ end
140
+ ctx.out.puts msg
140
141
  end
141
142
  end
142
143
 
@@ -89,14 +89,14 @@ module Rubik
89
89
  if get_std_handle
90
90
  new_in = begin
91
91
  get_std_handle.call(Reline::Windows::STD_INPUT_HANDLE)
92
- rescue
92
+ rescue StandardError
93
93
  nil
94
94
  end
95
95
  io.instance_variable_set(:@hConsoleInputHandle, new_in) if new_in && new_in != 0
96
96
 
97
97
  new_out = begin
98
98
  get_std_handle.call(Reline::Windows::STD_OUTPUT_HANDLE)
99
- rescue
99
+ rescue StandardError
100
100
  nil
101
101
  end
102
102
  io.instance_variable_set(:@hConsoleHandle, new_out) if new_out && new_out != 0
@@ -105,14 +105,14 @@ module Rubik
105
105
  flush_func = io.instance_variable_get(:@FlushConsoleInputBuffer) ||
106
106
  begin
107
107
  Reline::Windows::Win32API.new("kernel32", "FlushConsoleInputBuffer", ["L"], "I")
108
- rescue
108
+ rescue StandardError
109
109
  nil
110
110
  end
111
111
  if flush_func
112
112
  io.instance_variable_set(:@FlushConsoleInputBuffer, flush_func)
113
113
  begin
114
114
  flush_func.call(new_in)
115
- rescue
115
+ rescue StandardError
116
116
  nil
117
117
  end
118
118
  end
@@ -143,7 +143,7 @@ if defined?(Reline::Windows)
143
143
  if @hConsoleInputHandle.nil? || @hConsoleInputHandle.zero? || @hConsoleInputHandle == -1
144
144
  @hConsoleInputHandle = begin # rubocop:disable Naming/VariableName -- Win32 FFI SDK adlandırması; yalnızca Windows konsol yolu
145
145
  @GetStdHandle.call(STD_INPUT_HANDLE)
146
- rescue
146
+ rescue StandardError
147
147
  nil
148
148
  end
149
149
  end
@@ -156,18 +156,18 @@ if defined?(Reline::Windows)
156
156
  if [0xFFFFFFFF, 4_294_967_295, -1].include?(wait_res)
157
157
  @hConsoleInputHandle = begin # rubocop:disable Naming/VariableName -- Win32 FFI SDK adlandırması; yalnızca Windows konsol yolu
158
158
  @GetStdHandle.call(STD_INPUT_HANDLE)
159
- rescue
159
+ rescue StandardError
160
160
  nil
161
161
  end
162
162
  @hConsoleHandle = begin # rubocop:disable Naming/VariableName -- Win32 FFI SDK adlandırması; yalnızca Windows konsol yolu
163
163
  @GetStdHandle.call(STD_OUTPUT_HANDLE)
164
- rescue
164
+ rescue StandardError
165
165
  nil
166
166
  end
167
167
  end
168
168
  @legacy_console = begin
169
169
  getconsolemode.nobits?(ENABLE_VIRTUAL_TERMINAL_PROCESSING)
170
- rescue
170
+ rescue StandardError
171
171
  false
172
172
  end
173
173
  next
@@ -175,7 +175,7 @@ if defined?(Reline::Windows)
175
175
  if @GetNumberOfConsoleInputEvents.call(@hConsoleInputHandle, num_of_events).zero? || num_of_events.unpack1("L").zero?
176
176
  @hConsoleInputHandle = begin # rubocop:disable Naming/VariableName -- Win32 FFI SDK adlandırması; yalnızca Windows konsol yolu
177
177
  @GetStdHandle.call(STD_INPUT_HANDLE)
178
- rescue
178
+ rescue StandardError
179
179
  nil
180
180
  end
181
181
  next
@@ -185,7 +185,7 @@ if defined?(Reline::Windows)
185
185
  if @ReadConsoleInputW.call(@hConsoleInputHandle, input_records, 80, read_event).zero?
186
186
  @hConsoleInputHandle = begin # rubocop:disable Naming/VariableName -- Win32 FFI SDK adlandırması; yalnızca Windows konsol yolu
187
187
  @GetStdHandle.call(STD_INPUT_HANDLE)
188
- rescue
188
+ rescue StandardError
189
189
  nil
190
190
  end
191
191
  else
@@ -119,7 +119,7 @@ module Rubik
119
119
 
120
120
  def search_symbols(query)
121
121
  join_indexer_safely(0.2) if @ast_graph.symbols.empty? && @indexer_thread&.alive?
122
- @ast_graph.symbols.values.select { |sym| sym.name.downcase.include?(query.downcase) }
122
+ @ast_graph.search_symbols(query)
123
123
  end
124
124
 
125
125
  def register_command(name, description:, usage: nil, custom: false, audience: nil, &handler)
@@ -251,10 +251,15 @@ module Rubik
251
251
  return ToolOutput.error("Missing required parameter 'conversation_id' for action 'extend_budget'.") if id.empty?
252
252
 
253
253
  steps = Rubik::Orchestrator::Subagent::Budget.normalize_grant(args["steps"] || args[:steps])
254
+ inst = sup.get(id)
254
255
  if sup.extend_budget(id, steps)
255
- "Step budget for subagent '#{id}' successfully extended by #{steps} steps (total budget: #{sup.get(id).max_steps}, state: #{sup.get(id).state.to_s.upcase})."
256
+ ceiling = inst.respond_to?(:max_total_steps) ? "/#{inst.max_total_steps}" : ""
257
+ "Step budget for subagent '#{id}' successfully extended by #{steps} steps (total budget: #{inst.max_steps}#{ceiling}, " \
258
+ "state: #{inst.state.to_s.upcase})."
259
+ elsif inst.respond_to?(:max_total_steps) && inst.max_steps >= inst.max_total_steps
260
+ ToolOutput.error("Subagent '#{id}' reached its lifetime step limit (#{inst.max_total_steps}); no more steps can be granted.")
256
261
  else
257
- ToolOutput.error("Subagent '#{id}' not found or cannot extend budget (current state: #{sup.get(id)&.state || 'not found'}).")
262
+ ToolOutput.error("Subagent '#{id}' not found or cannot extend budget (current state: #{inst&.state || 'not found'}).")
258
263
  end
259
264
  when "resume"
260
265
  id = resolve_id(args)
@@ -2,9 +2,7 @@
2
2
 
3
3
  require_relative "../base"
4
4
  require_relative "../../coeffects"
5
- require_relative "../output"
6
- require_relative "../diagnostic_reporter"
7
- require_relative "../fuzzy_matcher"
5
+ require_relative "../symbol_tool_support"
8
6
 
9
7
  module Rubik
10
8
  module Tools
@@ -36,13 +34,22 @@ module Rubik
36
34
  end
37
35
 
38
36
  def execute(args = {})
39
- query = (args["query"] || args[:query]).to_s.strip
40
- return ToolOutput.error("Query parameter is required.") if query.empty?
37
+ query, error = SymbolToolSupport.string_argument(args, "query", label: "query")
38
+ return error if error
39
+ return ToolOutput.error("Query parameter is required.") if query.nil? || query.strip.empty?
40
+
41
+ query = query.strip
42
+
43
+ smart_context, error = SymbolToolSupport.boolean_argument(args, "smart_context", label: "smart_context")
44
+ return error if error
41
45
 
42
46
  graph = ast_graph
43
47
  return ToolOutput.error("AST graph is not available. Run '!context scan' to build the symbol index.") unless graph
44
48
 
45
- if args["smart_context"] || args[:smart_context]
49
+ warming = SymbolToolSupport.warmup_output(graph)
50
+ return warming if warming
51
+
52
+ if smart_context
46
53
  ctx = graph.smart_context_for(query)
47
54
  return ctx.empty? ? "No smart context found for '#{query}'." : ctx
48
55
  end
@@ -54,14 +61,12 @@ module Rubik
54
61
  end
55
62
 
56
63
  if matches.empty?
57
- all_sym_names = graph.respond_to?(:symbols) ? graph.symbols.keys : []
64
+ all_sym_names = SymbolToolSupport.fuzzy_candidate_names(graph)
58
65
  candidates = FuzzyMatcher.best_matches(query, all_sym_names)
59
- DiagnosticReporter.render_symbol_not_found(query, candidate_symbols: candidates)
66
+ ToolOutput.error(DiagnosticReporter.render_symbol_not_found(query, candidate_symbols: candidates))
60
67
  else
61
- matches.map do |sym|
62
- "• #{sym.kind.to_s.upcase} #{sym.name} (#{sym.file}:#{sym.line_start}-#{sym.line_end})\n " \
63
- "Signature: #{sym.signature}\n Calls: #{sym.calls.join(', ')}"
64
- end.join("\n\n")
68
+ ranked = SymbolToolSupport.rank_matches(matches, query, graph)
69
+ SymbolToolSupport.render_search(query, ranked)
65
70
  end
66
71
  end
67
72
  end
@@ -95,13 +100,24 @@ module Rubik
95
100
  end
96
101
 
97
102
  def execute(args = {})
98
- path = (args["file_path"] || args[:file_path] || args["path"] || args[:path]).to_s.strip
99
- return ToolOutput.error("File path is required.") if path.empty?
103
+ path, error = SymbolToolSupport.string_argument(args, "file_path", aliases: ["path"], label: "file_path")
104
+ return error if error
105
+ return ToolOutput.error("File path is required.") if path.nil? || path.strip.empty?
106
+
107
+ path = path.strip
100
108
 
101
109
  graph = ast_graph
102
110
  return ToolOutput.error("AST graph is not available. Run '!context scan' to build the symbol index.") unless graph
103
111
 
104
- graph.outline_for(path)
112
+ warming = SymbolToolSupport.warmup_output(graph)
113
+ return warming if warming
114
+
115
+ outline = graph.outline_for(path)
116
+ status = graph.extraction_status(path) if graph.respond_to?(:extraction_status)
117
+ return ToolOutput.error("Outline extraction failed for '#{path}'.") if status == :failed
118
+ return "#{outline}\n[Partial extraction: some symbols may be missing.]" if status == :partial
119
+
120
+ outline
105
121
  end
106
122
  end
107
123
 
@@ -134,12 +150,18 @@ module Rubik
134
150
  end
135
151
 
136
152
  def execute(args = {})
137
- sym_name = (args["symbol_name"] || args[:symbol_name] || args["symbol"] || args[:symbol]).to_s.strip
138
- return ToolOutput.error("Symbol name is required.") if sym_name.empty?
153
+ sym_name, error = SymbolToolSupport.string_argument(args, "symbol_name", aliases: ["symbol"], label: "symbol_name")
154
+ return error if error
155
+ return ToolOutput.error("Symbol name is required.") if sym_name.nil? || sym_name.strip.empty?
156
+
157
+ sym_name = sym_name.strip
139
158
 
140
159
  graph = ast_graph
141
160
  return ToolOutput.error("AST graph is not available. Run '!context scan' to build the symbol index.") unless graph
142
161
 
162
+ warming = SymbolToolSupport.warmup_output(graph)
163
+ return warming if warming
164
+
143
165
  callers = graph.callers_of(sym_name)
144
166
  if callers.empty?
145
167
  "No references or callers found for '#{sym_name}' in AST graph."
@@ -150,6 +172,8 @@ module Rubik
150
172
  end
151
173
  lines.join("\n")
152
174
  end
175
+ rescue Rubik::AST::Graph::AmbiguousSymbolError => e
176
+ ToolOutput.error(e.message)
153
177
  end
154
178
  end
155
179
 
@@ -181,15 +205,27 @@ module Rubik
181
205
  end
182
206
 
183
207
  def execute(args = {})
184
- sym_name = (args["symbol_name"] || args[:symbol_name] || args["symbol"] || args[:symbol]).to_s.strip
185
- return ToolOutput.error("Symbol name is required.") if sym_name.empty?
208
+ sym_name, error = SymbolToolSupport.string_argument(args, "symbol_name", aliases: ["symbol"], label: "symbol_name")
209
+ return error if error
210
+ return ToolOutput.error("Symbol name is required.") if sym_name.nil? || sym_name.strip.empty?
211
+
212
+ sym_name = sym_name.strip
186
213
 
187
214
  graph = ast_graph
188
215
  return ToolOutput.error("AST graph is not available. Run '!context scan' to build the symbol index.") unless graph
189
216
 
217
+ warming = SymbolToolSupport.warmup_output(graph)
218
+ return warming if warming
219
+
220
+ candidates = graph.symbol_candidates(sym_name) if graph.respond_to?(:symbol_candidates)
221
+ if candidates && candidates.length > 1
222
+ message = "Ambiguous symbol '#{sym_name}'. Use a qualified key. Candidates:\n- #{candidates.join("\n- ")}"
223
+ return ToolOutput.error(message)
224
+ end
225
+
190
226
  info = graph.symbol_definition(sym_name)
191
227
  unless info
192
- all_sym_names = graph.respond_to?(:symbols) ? graph.symbols.keys : []
228
+ all_sym_names = SymbolToolSupport.fuzzy_candidate_names(graph)
193
229
  candidates = FuzzyMatcher.best_matches(sym_name, all_sym_names)
194
230
  return ToolOutput.error(DiagnosticReporter.render_symbol_not_found(sym_name, candidate_symbols: candidates))
195
231
  end
@@ -0,0 +1,113 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "output"
4
+ require_relative "diagnostic_reporter"
5
+ require_relative "fuzzy_matcher"
6
+ require_relative "truncator"
7
+
8
+ module Rubik
9
+ module Tools
10
+ module SymbolToolSupport
11
+ def self.string_argument(args, primary, aliases: [], label: primary)
12
+ return [nil, ToolOutput.error("Tool arguments must be an object.")] unless args.is_a?(Hash)
13
+
14
+ values = [primary, *aliases].flat_map do |key|
15
+ [key.to_s, key.to_sym].uniq.filter_map { |actual| args[actual] if args.key?(actual) }
16
+ end
17
+ values.compact!
18
+ return [nil, nil] if values.empty?
19
+ return [nil, ToolOutput.error("#{label} must be a string.")] unless values.all?(String)
20
+
21
+ [values.first, nil]
22
+ end
23
+
24
+ def self.boolean_argument(args, key, label: key)
25
+ return [nil, ToolOutput.error("Tool arguments must be an object.")] unless args.is_a?(Hash)
26
+
27
+ values = [key.to_s, key.to_sym].uniq.filter_map { |actual| args[actual] if args.key?(actual) }
28
+ values.compact!
29
+ return [false, nil] if values.empty?
30
+ return [nil, ToolOutput.error("#{label} must be a boolean.")] unless values.all? { |value| [true, false].include?(value) }
31
+
32
+ [values.first, nil]
33
+ end
34
+
35
+ def self.warmup_output(graph)
36
+ return nil unless graph.respond_to?(:warmup_status)
37
+
38
+ status = graph.warmup_status
39
+ return nil unless status
40
+
41
+ state = status[:state] || status["state"]
42
+ if [:warming, "warming"].include?(state)
43
+ indexed = status[:indexed] || status["indexed"]
44
+ total = status[:total] || status["total"]
45
+ progress = if indexed.is_a?(Numeric) && total.is_a?(Numeric)
46
+ " (#{indexed}/#{total} files indexed)"
47
+ else
48
+ ""
49
+ end
50
+ ToolOutput.error("AST index warming; retry this request shortly#{progress}.")
51
+ elsif [:failed, "failed"].include?(state)
52
+ ToolOutput.error("AST index warmup failed; retry after running '!context scan'.")
53
+ end
54
+ end
55
+
56
+ def self.rank_matches(matches, query, graph)
57
+ symbol_map = graph.respond_to?(:symbols) ? graph.symbols : nil
58
+ qualified = symbol_map.respond_to?(:qualified_symbols) ? symbol_map.qualified_symbols : {}
59
+ keys_by_id = {}.compare_by_identity
60
+ qualified.sort_by { |key, _symbol| key }.each do |(key, symbol)|
61
+ keys_by_id[symbol] ||= key
62
+ end
63
+
64
+ matches.each_with_index.sort_by do |symbol, index|
65
+ name = symbol.name.to_s.downcase
66
+ lowered_query = query.downcase
67
+ rank = if name == lowered_query
68
+ 0
69
+ elsif name.start_with?(lowered_query)
70
+ 1
71
+ else
72
+ 2
73
+ end
74
+ key = keys_by_id[symbol] || "#{symbol.file}##{symbol.name}"
75
+ [rank, key, index]
76
+ end.map(&:first)
77
+ end
78
+
79
+ def self.render_search(query, matches)
80
+ shown_count = [matches.length, 50].min
81
+ loop do
82
+ omitted = matches.length - shown_count
83
+ lines = ["Found #{matches.length} matching symbol(s) for '#{query}':"]
84
+ matches.first(shown_count).each do |sym|
85
+ lines << "• #{sym.kind.to_s.upcase} #{sym.name} (#{sym.file}:#{sym.line_start}-#{sym.line_end})"
86
+ lines << " Signature: #{sym.signature}"
87
+ lines << " Calls: #{sym.calls.join(', ')}"
88
+ end
89
+ lines << "... #{omitted} additional matches omitted." if omitted.positive?
90
+
91
+ truncated = Truncator.truncate(lines.join("\n"))
92
+ return truncated[:text] if !truncated[:truncated] || shown_count <= 1
93
+
94
+ shown_count -= 1
95
+ end
96
+ end
97
+
98
+ def self.fuzzy_candidate_names(graph)
99
+ symbols = graph.respond_to?(:symbols) ? graph.symbols : nil
100
+ return [] unless symbols
101
+
102
+ names = if symbols.respond_to?(:values)
103
+ symbols.values.filter_map { |sym| sym.name if sym.respond_to?(:name) }.uniq
104
+ else
105
+ []
106
+ end
107
+ return names unless names.empty?
108
+
109
+ symbols.respond_to?(:keys) ? symbols.keys : []
110
+ end
111
+ end
112
+ end
113
+ end
data/lib/rubik/version.rb CHANGED
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Rubik
4
- VERSION = "0.3.1"
4
+ VERSION = "0.3.3"
5
5
  end
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: rubik
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.3.1
4
+ version: 0.3.3
5
5
  platform: ruby
6
6
  authors:
7
7
  - Özgür Ulukır
@@ -249,6 +249,7 @@ files:
249
249
  - lib/rubik/ast/fallback_cleaner.rb
250
250
  - lib/rubik/ast/go_extractor.rb
251
251
  - lib/rubik/ast/graph.rb
252
+ - lib/rubik/ast/graph_extensions.rb
252
253
  - lib/rubik/ast/python_extractor.rb
253
254
  - lib/rubik/ast/ruby_extractor.rb
254
255
  - lib/rubik/ast/rust_extractor.rb
@@ -757,6 +758,7 @@ files:
757
758
  - lib/rubik/tools/registry.rb
758
759
  - lib/rubik/tools/result.rb
759
760
  - lib/rubik/tools/services.rb
761
+ - lib/rubik/tools/symbol_tool_support.rb
760
762
  - lib/rubik/tools/truncator.rb
761
763
  - lib/rubik/tools/uri_resolver.rb
762
764
  - lib/rubik/ui.rb