rubik 0.3.1 → 0.3.2

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.
@@ -42,6 +42,8 @@ module Rubik
42
42
  @context = context || runtime_manager&.context
43
43
  @clients = {}
44
44
  @crash_stats = {}
45
+ @recovery_errors = {}
46
+ @registered_disposable_langs = {}
45
47
  @monitor = Monitor.new
46
48
  end
47
49
 
@@ -93,82 +95,20 @@ module Rubik
93
95
  def ensure_healthy!(language_or_client)
94
96
  return nil unless enabled?
95
97
 
96
- client = language_or_client.is_a?(Client) ? language_or_client : client_for(language_or_client)
97
- return nil unless client
98
+ lang = language_for(language_or_client)
99
+ return nil unless lang
98
100
 
99
- if client.running && client.respond_to?(:alive?) && !client.alive?
100
- lang = client.language
101
- record_crash(lang)
102
- @monitor.synchronize do
103
- begin
104
- client.stop
105
- rescue
106
- nil
107
- end
108
- @clients.delete(lang)
109
- end
110
- start_client(lang) unless circuit_open?(lang)
111
- client = @clients[lang]
112
- elsif !client.running && @config.fetch("lsp.auto_start", true) && client.available? && !circuit_open?(client.language)
113
- start_client(client.language)
114
- client = @clients[client.language]
101
+ @monitor.synchronize do
102
+ preferred = language_or_client if language_or_client.is_a?(Client)
103
+ client_for_unlocked(lang, preferred)
115
104
  end
116
-
117
- client
118
105
  end
119
106
 
120
107
  def client_for(language_or_file)
121
- lang = if language_or_file.to_s.include?(".")
122
- detect_language(language_or_file)
123
- else
124
- language_or_file.to_s.downcase
125
- end
126
-
108
+ lang = language_for(language_or_file)
127
109
  return nil unless lang
128
110
 
129
- @monitor.synchronize do
130
- if @clients.key?(lang)
131
- client = @clients[lang]
132
- if client.running && client.respond_to?(:alive?) && !client.alive?
133
- record_crash(lang)
134
- begin
135
- client.stop
136
- rescue
137
- nil
138
- end
139
- @clients.delete(lang)
140
- nil # Will trigger recreate / start below
141
- else
142
- client
143
- end
144
- elsif enabled?
145
- configured_cmd = @config.fetch("lsp.servers.#{lang}") || Client::SERVERS[lang]
146
- client = Client.new(lang, root_dir: @root_dir, server_cmd: configured_cmd)
147
- unless client.available?
148
- probed_cmd = Runtime::Discovery.probe_lsp(lang, root_dir: @root_dir, custom_cmd: configured_cmd)
149
- client = Client.new(lang, root_dir: @root_dir, server_cmd: probed_cmd) if probed_cmd
150
- end
151
-
152
- if client.available? && @config.fetch("lsp.auto_start", true) && !circuit_open?(lang)
153
- if client.start
154
- reset_circuit!(lang)
155
- effective_ctx = @context || @runtime_manager&.context
156
- if effective_ctx
157
- effective_ctx.provide("lsp:#{lang}", client)
158
- effective_ctx.register_disposable { client.stop if client.running }
159
- end
160
- else
161
- record_crash(lang)
162
- begin
163
- client.stop
164
- rescue StandardError
165
- nil
166
- end
167
- end
168
- end
169
- @clients[lang] = client
170
- end
171
- end
111
+ @monitor.synchronize { client_for_unlocked(lang) }
172
112
  end
173
113
 
174
114
  def start_client(lang)
@@ -204,19 +144,12 @@ module Rubik
204
144
  @clients[lang_str] = client
205
145
  if res
206
146
  reset_circuit!(lang_str)
207
- effective_ctx = @context || @runtime_manager&.context
208
- if effective_ctx
209
- effective_ctx.provide("lsp:#{lang_str}", client)
210
- effective_ctx.register_disposable { client.stop if client.running }
211
- end
147
+ @recovery_errors.delete(lang_str)
148
+ register_client_unlocked(lang_str, client)
212
149
  { ok: true, message: "Started LSP server for '#{lang_str}' (#{client.server_cmd})" }
213
150
  else
214
151
  record_crash(lang_str)
215
- begin
216
- client.stop
217
- rescue StandardError
218
- nil
219
- end
152
+ stop_client_safely(client)
220
153
  failure = Runtime::DiagnosticEngine.diagnose(
221
154
  cmd: client.server_cmd,
222
155
  exit_code: 1,
@@ -241,13 +174,19 @@ module Rubik
241
174
 
242
175
  client.stop
243
176
  @clients.delete(lang_str)
177
+ effective_ctx = @context || @runtime_manager&.context
178
+ effective_ctx&.unprovide("lsp:#{lang_str}") if effective_ctx.respond_to?(:unprovide)
244
179
  { ok: true, message: "Stopped LSP server for '#{lang_str}'" }
245
180
  end
246
181
  end
247
182
 
248
183
  def stop_all
249
184
  @monitor.synchronize do
250
- @clients.each_value(&:stop)
185
+ effective_ctx = @context || @runtime_manager&.context
186
+ @clients.each do |lang_str, client|
187
+ client.stop
188
+ effective_ctx&.unprovide("lsp:#{lang_str}") if effective_ctx.respond_to?(:unprovide)
189
+ end
251
190
  @clients.clear
252
191
  end
253
192
  end
@@ -317,21 +256,21 @@ module Rubik
317
256
 
318
257
  def hover(file_path, line, character)
319
258
  client = client_for(file_path)
320
- return { ok: false, error: "LSP not active for '#{file_path}'" } unless client&.running
259
+ return { ok: false, error: inactive_client_error(file_path, client) } unless client&.running
321
260
 
322
261
  client.hover(file_path, line, character)
323
262
  end
324
263
 
325
264
  def definition(file_path, line, character)
326
265
  client = client_for(file_path)
327
- return { ok: false, error: "LSP not active for '#{file_path}'" } unless client&.running
266
+ return { ok: false, error: inactive_client_error(file_path, client) } unless client&.running
328
267
 
329
268
  client.definition(file_path, line, character)
330
269
  end
331
270
 
332
271
  def references(file_path, line, character)
333
272
  client = client_for(file_path)
334
- return { ok: false, error: "LSP not active for '#{file_path}'" } unless client&.running
273
+ return { ok: false, error: inactive_client_error(file_path, client) } unless client&.running
335
274
 
336
275
  client.references(file_path, line, character)
337
276
  end
@@ -361,6 +300,120 @@ module Rubik
361
300
  servers: client_statuses
362
301
  }
363
302
  end
303
+
304
+ private
305
+
306
+ def language_for(language_or_file)
307
+ if language_or_file.is_a?(Client)
308
+ language_or_file.language.to_s.downcase
309
+ elsif language_or_file.to_s.include?(".")
310
+ detect_language(language_or_file)
311
+ else
312
+ language_or_file.to_s.downcase
313
+ end
314
+ end
315
+
316
+ # Caller MUST hold @monitor. Dead-client removal and replacement startup
317
+ # happen in one critical section so concurrent requests share one client.
318
+ def client_for_unlocked(lang, preferred = nil)
319
+ stored = @clients[lang]
320
+ client = stored || preferred
321
+ recovering = false
322
+
323
+ if client && client_dead?(client)
324
+ recovering = true
325
+ record_crash(lang)
326
+ stop_client_safely(client)
327
+ @clients.delete(lang) if stored.equal?(client)
328
+ client = nil
329
+ end
330
+
331
+ if recovering && circuit_open?(lang)
332
+ @recovery_errors[lang] = "LSP server restart for '#{lang}' is blocked by the circuit breaker"
333
+ return nil
334
+ end
335
+ if recovering && !@config.fetch("lsp.auto_start", true)
336
+ @recovery_errors[lang] = "LSP server restart for '#{lang}' is disabled"
337
+ return nil
338
+ end
339
+
340
+ return nil unless client || enabled?
341
+
342
+ if client.nil?
343
+ configured_cmd = @config.fetch("lsp.servers.#{lang}") || Client::SERVERS[lang]
344
+ client = Client.new(lang, root_dir: @root_dir, server_cmd: configured_cmd)
345
+ unless client.available?
346
+ probed_cmd = Runtime::Discovery.probe_lsp(lang, root_dir: @root_dir, custom_cmd: configured_cmd)
347
+ client = Client.new(lang, root_dir: @root_dir, server_cmd: probed_cmd) if probed_cmd
348
+ end
349
+
350
+ if client.available? && @config.fetch("lsp.auto_start", true) && !circuit_open?(lang)
351
+ if client.start
352
+ reset_circuit!(lang)
353
+ @recovery_errors.delete(lang)
354
+ register_client_unlocked(lang, client)
355
+ else
356
+ record_crash(lang)
357
+ stop_client_safely(client)
358
+ @recovery_errors[lang] = "LSP server restart failed for '#{lang}'" if recovering
359
+ end
360
+ elsif recovering
361
+ @recovery_errors[lang] = if circuit_open?(lang)
362
+ "LSP server restart for '#{lang}' is blocked by the circuit breaker"
363
+ elsif !@config.fetch("lsp.auto_start", true)
364
+ "LSP server restart for '#{lang}' is disabled"
365
+ else
366
+ "LSP server restart failed for '#{lang}': server unavailable"
367
+ end
368
+ end
369
+ @clients[lang] = client
370
+ elsif !client.running && @config.fetch("lsp.auto_start", true) &&
371
+ client.available? && !circuit_open?(lang)
372
+ if client.start
373
+ reset_circuit!(lang)
374
+ @recovery_errors.delete(lang)
375
+ register_client_unlocked(lang, client)
376
+ else
377
+ record_crash(lang)
378
+ stop_client_safely(client)
379
+ @recovery_errors[lang] = "LSP server restart failed for '#{lang}'" if recovering
380
+ end
381
+ end
382
+
383
+ client
384
+ end
385
+
386
+ def client_dead?(client)
387
+ (client.running && client.respond_to?(:alive?) && !client.alive?) ||
388
+ (client.respond_to?(:crashed?) && client.crashed?)
389
+ end
390
+
391
+ def register_client_unlocked(lang, client)
392
+ effective_ctx = @context || @runtime_manager&.context
393
+ return unless effective_ctx
394
+
395
+ effective_ctx.provide("lsp:#{lang}", client)
396
+ return if @registered_disposable_langs[lang] == effective_ctx.object_id
397
+
398
+ @registered_disposable_langs[lang] = effective_ctx.object_id
399
+ effective_ctx.register_disposable do
400
+ current = @clients[lang]
401
+ current.stop if current&.running
402
+ end
403
+ end
404
+
405
+ def stop_client_safely(client)
406
+ client.stop
407
+ rescue StandardError
408
+ nil
409
+ end
410
+
411
+ def inactive_client_error(file_path, client)
412
+ lang = client&.language || detect_language(file_path)
413
+ @monitor.synchronize do
414
+ @recovery_errors[lang] || "LSP not active for '#{file_path}'"
415
+ end
416
+ end
364
417
  end
365
418
  end
366
419
  end
@@ -35,15 +35,25 @@ module Rubik
35
35
  when :open
36
36
  return false unless cooldown_elapsed?(circuit)
37
37
 
38
- # one probe through the half-open gate
38
+ # Cooldown elapsed: enter half-open state and grant single probe
39
39
  circuit[:state] = :half_open
40
40
  circuit[:probing] = true
41
+ circuit[:opened_at] = now
41
42
  true
42
43
  when :half_open
43
44
  if circuit[:probing]
44
- true
45
+ # Probe is currently in flight. If probe wedged past cooldown,
46
+ # allow a new probe to prevent permanent deadlock.
47
+ if cooldown_elapsed?(circuit)
48
+ circuit[:opened_at] = now
49
+ true
50
+ else
51
+ false
52
+ end
45
53
  else
46
- cooldown_elapsed?
54
+ circuit[:probing] = true
55
+ circuit[:opened_at] = now
56
+ true
47
57
  end
48
58
  else
49
59
  true
@@ -53,7 +63,7 @@ module Rubik
53
63
 
54
64
  def record_success(tool_name)
55
65
  @monitor.synchronize do
56
- @tools.delete(tool_name)
66
+ @tools.delete(tool_name.to_s)
57
67
  end
58
68
  end
59
69
 
@@ -76,7 +86,7 @@ module Rubik
76
86
  private
77
87
 
78
88
  def circuit(tool_name)
79
- @tools[tool_name] ||= { failures: 0, opened_at: nil, probing: false, state: :closed }
89
+ @tools[tool_name.to_s] ||= { failures: 0, opened_at: nil, probing: false, state: :closed }
80
90
  end
81
91
 
82
92
  def cooldown_elapsed?(circuit)
@@ -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
@@ -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)
@@ -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