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.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: b71f1262ecc5dbd53e712da9df276b2fd744a1c797a05917ad4c103ead720b4c
4
- data.tar.gz: 9c7d57397bfb5a8dfa4b34846bd51ec7ade3fcade0462cbf7f03d9d00ecb576c
3
+ metadata.gz: fc6b8b3ff7cb67e302bbb3a74237615515318acde17b8c41193a293dadef7576
4
+ data.tar.gz: 3c0616b8f9b6544619903234c4595242b95c1998b2602aca584334eaf19fcc5d
5
5
  SHA512:
6
- metadata.gz: 5994b70d91d9673cb1ea08f1eb39f9f04aa446ea9edcb61bc558d1b81f79825e205a12690dec3b652901d3176061b74012f48bd7a34d6f1b49c6d181219ada33
7
- data.tar.gz: 79037bc56238a6eca4f6017e0d23c9b79af836fa5593281cb9005c31750a4f08513d0c78c8ca97c3f83b6cb41643757852a4021778b6c6e2da7a9187e9ea2120
6
+ metadata.gz: e714d3f5fb612958caba5ed92829af4262b55ac50390d5ed9262cdb8c81143886d613424c13f1fe585c4debead0758469b4aeadea855edcc02d14ef5b9eb5d9b
7
+ data.tar.gz: cef0f0645353fe8c6f4413c39f6adff50c810c51720022774db779bce54de99a2555396f211a62af336be0be5c4a534f1c14c616a180c3c3344790ca56235772
data/ATTRIBUTION.md CHANGED
@@ -121,7 +121,7 @@ SOFTWARE.
121
121
  | **`strings-ansi`** | Piotr Murach | MIT | ANSI-aware string truncation and alignment for REPL formatters. |
122
122
  | **`reline`** | Hiroshi SHIBATA & Ruby Team | Ruby License / BSD-2-Clause | Line-editing, history navigation, autocompletion hooks, and headless terminal adaptation. |
123
123
  | **`prism`** | Kevin Newton & Ruby Core Team | MIT | High-performance Ruby parsing and AST extraction ([`Rubik::AST::RubyExtractor`](lib/rubik/ast/ruby_extractor.rb)). |
124
- | **`minitest`** | Ryan Davis & seattlerb | MIT | Fast, modular test suite powering Rubik's 3,700+ unit, integration, and contract tests. |
124
+ | **`minitest`** | Ryan Davis & seattlerb | MIT | Fast, modular test suite powering Rubik's 3,800+ unit, integration, and contract tests. |
125
125
  | **`rubocop`** | Bozhidar Batsov & contributors | MIT | Static code analysis, style enforcement, and architectural hygiene gates. |
126
126
  | **`benchmark`, `logger`, `pstore`, `reline`, `fiddle`** | Ruby core / rubygems teams | Ruby License / BSD-2-Clause | Ruby stdlib gems pinned explicitly in `rubik.gemspec` as they are unbundled from the default set in Ruby 3.4/3.5. |
127
127
 
data/CHANGELOG.md CHANGED
@@ -10,6 +10,56 @@ The format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/).
10
10
 
11
11
  Nothing yet.
12
12
 
13
+ ## [0.3.2] - 2026-10-21
14
+
15
+ A reliability patch: LSP requests tell timeouts and crashes apart and
16
+ recover atomically, AST symbol lookup answers precisely, deep
17
+ tree-sitter input no longer overflows the stack, and the tool circuit
18
+ breaker's half-open state actually gates.
19
+
20
+ ### Fixed
21
+
22
+ - **LSP requests distinguish failures and recover (#214, closes
23
+ #206, #196):** a successful JSON `null` result is no longer
24
+ misread as a timeout — hover, definition and references return the
25
+ proper error outcome; a dead client's recovery is atomic, so
26
+ concurrent callers all land on the same new client or get a
27
+ specific recovery error; queries and
28
+ didOpen/didChange/didClose normalize URIs identically
29
+ (case-preserving document keys on Windows); server-to-client
30
+ requests are handled before response correlation;
31
+ `workspace/configuration` returns empty settings objects and
32
+ unsupported methods answer `-32601`.
33
+ - **AST symbol lookup is precise (#213, closes #204, #195, #197):**
34
+ qualified symbol identities are preserved and ambiguous bare names
35
+ come back with their candidate lists; tree-sitter and Ruby call
36
+ extraction keep receiver information, so self-recursive calls are
37
+ retained; symbol search output is sorted and bounded; warmup,
38
+ extraction errors and argument types are reported explicitly;
39
+ indexed file keys resolve through symlinked parent paths; the AST
40
+ cache validates its schema version and guards missing symbol files
41
+ instead of serving stale lookups.
42
+ - **Tree-sitter walkers cap recursion depth (#203, closes #202 and
43
+ #191):** Go, C/C++, TypeScript and Zig walkers stop at 50 levels —
44
+ Zig's `find_function`/`find_identifier` helpers included — so
45
+ 3,000-layer-deep inputs no longer overflow the stack; deeper
46
+ subtrees are deliberately treated as leaves.
47
+ - **The tool circuit breaker's half-open state is repaired (#215):**
48
+ half-open admits exactly one probe — the token is consumed on
49
+ entry and concurrent or subsequent calls are denied while the
50
+ probe is in flight — and a `cooldown_elapsed?` ArgumentError that
51
+ broke recovery paths is gone.
52
+
53
+ ### Changed
54
+
55
+ - **TokenEstimator is ~3.4x faster on tool-call-heavy histories
56
+ (#216):** `tool_calls` and `tool_schemas` estimation skips the
57
+ deep UTF-8 clean/re-serialize path, cutting allocations ~90% with
58
+ bit-for-bit identical output — statusline updates, loop iterations
59
+ and compaction checks stop paying that tax on every message.
60
+ - **`callers_of` lookups use a Set (#190):** reverse-call indexing
61
+ drops redundant membership scans.
62
+
13
63
  ## [0.3.1] - 2026-10-19
14
64
 
15
65
  A hygiene patch: the Truncator honors the budgets you give it across
@@ -9,6 +9,8 @@ require_relative "../platform"
9
9
  module Rubik
10
10
  module AST
11
11
  class CacheStore
12
+ SCHEMA_VERSION = 2
13
+
12
14
  LANGUAGE_MAP = {
13
15
  ".rb" => "ruby",
14
16
  ".py" => "python",
@@ -46,7 +48,7 @@ module Rubik
46
48
  return false unless file_symbols_hash.is_a?(Hash)
47
49
 
48
50
  data = {
49
- "version" => 1,
51
+ "version" => SCHEMA_VERSION,
50
52
  "last_scan" => Time.now.to_i,
51
53
  "files" => {}
52
54
  }
@@ -106,7 +108,7 @@ module Rubik
106
108
 
107
109
  content = File.read(target_path)
108
110
  data = JSON.parse(content)
109
- return {} unless data.is_a?(Hash) && data["files"].is_a?(Hash)
111
+ return {} unless data.is_a?(Hash) && data["version"] == SCHEMA_VERSION && data["files"].is_a?(Hash)
110
112
 
111
113
  result = {}
112
114
  data["files"].each do |rel_path, cached_info|
@@ -38,13 +38,15 @@ module Rubik
38
38
  @file_symbols = Hash.new { |h, k| h[k] = [] }
39
39
  @extraction_status = {}
40
40
  @mutex = Mutex.new
41
+ @warmup_generation = 0
42
+ @warmup_status = { state: :ready, indexed: 0, total: nil, generation: 0 }
41
43
  end
42
44
 
43
45
  # Last extraction status recorded for a file (issue #147): :ok,
44
46
  # :partial, :failed, or :unavailable. Lets callers tell "no symbols"
45
47
  # apart from "extraction failed" without re-running the extractor.
46
48
  def extraction_status(file_path)
47
- @mutex.synchronize { @extraction_status[file_path] }
49
+ @mutex.synchronize { _get_extraction_status_unlocked(file_path) }
48
50
  end
49
51
 
50
52
  def symbols
@@ -54,7 +56,11 @@ module Rubik
54
56
  end
55
57
 
56
58
  def file_symbols
57
- @mutex.synchronize { @file_symbols.dup }
59
+ @mutex.synchronize do
60
+ @file_symbols.each_with_object(Hash.new { |hash, path| hash[path] = [] }) do |(file_path, symbols), snapshot|
61
+ snapshot[file_path] = symbols.dup
62
+ end
63
+ end
58
64
  end
59
65
 
60
66
  def clear
@@ -63,6 +69,8 @@ module Rubik
63
69
  @qualified_symbols.clear
64
70
  @file_symbols.clear
65
71
  @extraction_status.clear
72
+ @warmup_generation += 1
73
+ @warmup_status = { state: :ready, indexed: 0, total: nil, generation: @warmup_generation }
66
74
  end
67
75
  end
68
76
 
@@ -74,8 +82,7 @@ module Rubik
74
82
 
75
83
  def remove_file(file_path)
76
84
  @mutex.synchronize do
77
- _remove_file_unlocked(file_path)
78
- @extraction_status.delete(file_path)
85
+ _remove_file_unlocked(file_path, clear_status: true)
79
86
  end
80
87
  end
81
88
 
@@ -106,57 +113,30 @@ module Rubik
106
113
  def save_to_cache(cache_store)
107
114
  return false unless cache_store
108
115
 
109
- snapshot = @mutex.synchronize { @file_symbols.dup }
110
- cache_store.save(snapshot)
111
- end
112
-
113
- # Duplicate-free symbol search: iterates the live map under the mutex
114
- # instead of copying it via #symbols (callers only read immutable
115
- # attributes, so handing out the Symbol objects is as safe as the
116
- # snapshot was — it shared the very same objects).
117
- def search_symbols(query)
118
- q = query.to_s.downcase
119
- @mutex.synchronize do
120
- @symbols.values.select { |sym| sym.name.downcase.include?(q) }
121
- end
122
- end
123
-
124
- # The callers of `symbol_name` are the symbols whose `calls` list
125
- # actually references it. `symbol_name` may arrive either as a plain
126
- # identifier (e.g. "add") or as a qualified key (e.g.
127
- # "rust:src/lib.rs##Config::new") — the `!refs` / callers flow does the
128
- # latter. We resolve it to the set of concrete callee spellings it maps
129
- # to (plain name plus every qualified key that owns that name), then keep
130
- # the symbols whose calls contain one of those spellings. This mirrors the
131
- # plain-then-qualified lookup used by #smart_context_for / #symbol_definition
132
- # and avoids the previous backwards test, which flagged any symbol merely
133
- # mentioning the substring as a caller.
134
- def callers_of(symbol_name)
135
- @mutex.synchronize do
136
- target_names = caller_targets(symbol_name)
137
- return [] if target_names.empty?
138
-
139
- @symbols.values.uniq.select do |sym|
140
- sym.calls.any? { |c| target_names.include?(c) }
116
+ snapshot = @mutex.synchronize do
117
+ @file_symbols.each_with_object({}) do |(file_path, symbols), copy|
118
+ copy[file_path] = symbols.dup
141
119
  end
142
120
  end
121
+ cache_store.save(snapshot)
143
122
  end
144
123
 
145
124
  def smart_context_for(symbol_name)
146
125
  @mutex.synchronize do
147
- target = @symbols[symbol_name] ||
148
- @qualified_symbols[symbol_name] ||
149
- @symbols.values.find { |s| s.name == symbol_name || s.name.end_with?("##{symbol_name}") }
150
- return "" unless target
126
+ candidates = _symbol_candidate_pairs_unlocked(symbol_name)
127
+ return "" unless candidates.length == 1
151
128
 
129
+ target = candidates.first.last
152
130
  context_blocks = []
153
131
  context_blocks << "# Target symbol: #{target.name} (#{target.file}:#{target.line_start}-#{target.line_end})"
154
132
  context_blocks << target.body if target.body
155
133
 
156
134
  target.calls.each do |called_name|
157
- matching = @symbols[called_name] ||
158
- @qualified_symbols[called_name] ||
159
- @symbols.values.find { |s| s.name == called_name || s.name.end_with?("##{called_name}") }
135
+ dep_candidates = _symbol_candidate_pairs_unlocked(called_name).map(&:last)
136
+ target_scope = _symbol_scope(target.name)
137
+ matching = dep_candidates.find { |s| s.file && target.file && Rubik::Platform.path_equal?(s.file, target.file) && _symbol_scope(s.name) == target_scope } ||
138
+ dep_candidates.find { |s| s.file && target.file && Rubik::Platform.path_equal?(s.file, target.file) } ||
139
+ (dep_candidates.first if dep_candidates.length == 1)
160
140
  if matching
161
141
  context_blocks << "# Dependency signature: #{matching.name}"
162
142
  context_blocks << matching.signature
@@ -183,50 +163,8 @@ module Rubik
183
163
  end
184
164
  end
185
165
 
186
- def symbol_definition(symbol_name)
187
- @mutex.synchronize do
188
- target = @symbols[symbol_name] ||
189
- @qualified_symbols[symbol_name] ||
190
- @symbols.values.find do |s|
191
- s.name.downcase == symbol_name.downcase ||
192
- s.name.end_with?("##{symbol_name}") ||
193
- s.name.end_with?(".#{symbol_name}")
194
- end
195
- return nil unless target
196
-
197
- {
198
- name: target.name,
199
- kind: target.kind,
200
- file: target.file,
201
- line_start: target.line_start,
202
- line_end: target.line_end,
203
- signature: target.signature,
204
- body: target.body,
205
- docstring: target.docstring,
206
- calls: target.calls
207
- }
208
- end
209
- end
210
-
211
166
  private
212
167
 
213
- # Concrete callee spellings that a reference to `symbol_name` might use.
214
- # `symbol_name` may be a bare identifier ("Config::new") or a qualified key
215
- # ("rust:src/lib.rs##Config::new"); we pull the trailing identifier in both
216
- # cases and collect every symbol that owns that name so a call site is
217
- # matched whether it wrote the bare identifier or the fully-qualified key.
218
- # Caller MUST hold @mutex.
219
- def caller_targets(symbol_name)
220
- bare = symbol_name.split("##").last.to_s
221
- return [] if bare.empty?
222
-
223
- names = [bare]
224
- @symbols.each_value do |sym|
225
- names << sym.name if sym.name == bare || sym.name.end_with?("##{bare}")
226
- end
227
- names.uniq
228
- end
229
-
230
168
  def _index_file_unlocked(file_path, content = nil)
231
169
  result = Extractor.extract_result(file_path, content)
232
170
  @extraction_status[file_path] = result.status
@@ -259,14 +197,16 @@ module Rubik
259
197
  extracted
260
198
  end
261
199
 
262
- def _remove_file_unlocked(file_path)
200
+ def _remove_file_unlocked(file_path, clear_status: false)
201
+ if clear_status
202
+ @extraction_status.delete(file_path)
203
+ status_key = _matching_key_for_path(@extraction_status, file_path)
204
+ @extraction_status.delete(status_key) if status_key
205
+ end
206
+
263
207
  existing = @file_symbols.delete(file_path)
264
208
  if existing.nil? || existing.empty?
265
- canonical_target = Rubik::Platform.normalize_path(File.expand_path(file_path))
266
- matching_key = @file_symbols.keys.find do |k|
267
- Rubik::Platform.path_equal?(k, file_path) ||
268
- Rubik::Platform.normalize_path(File.expand_path(k)) == canonical_target
269
- end
209
+ matching_key = _matching_key_for_path(@file_symbols, file_path)
270
210
  existing = @file_symbols.delete(matching_key) if matching_key
271
211
  end
272
212
 
@@ -286,13 +226,28 @@ module Rubik
286
226
  syms = @file_symbols[file_path]
287
227
  return syms if syms && !syms.empty?
288
228
 
229
+ matching_key = _matching_key_for_path(@file_symbols, file_path)
230
+ (matching_key && @file_symbols[matching_key]) || []
231
+ end
232
+
233
+ def _get_extraction_status_unlocked(file_path)
234
+ return @extraction_status[file_path] if @extraction_status.key?(file_path)
235
+
236
+ matching_key = _matching_key_for_path(@extraction_status, file_path)
237
+ matching_key ? @extraction_status[matching_key] : nil
238
+ end
239
+
240
+ def _matching_key_for_path(map, file_path)
289
241
  canonical_target = Rubik::Platform.normalize_path(File.expand_path(file_path))
290
- matching_key = @file_symbols.keys.find do |k|
242
+ resolved_target = Rubik::Platform.normalize_path(Rubik::Platform.resolved_path_best_effort(file_path))
243
+ map.keys.find do |k|
291
244
  Rubik::Platform.path_equal?(k, file_path) ||
292
- Rubik::Platform.normalize_path(File.expand_path(k)) == canonical_target
245
+ Rubik::Platform.normalize_path(File.expand_path(k)) == canonical_target ||
246
+ Rubik::Platform.normalize_path(Rubik::Platform.resolved_path_best_effort(k)) == resolved_target
293
247
  end
294
- (matching_key && @file_symbols[matching_key]) || []
295
248
  end
296
249
  end
297
250
  end
298
251
  end
252
+
253
+ require_relative "graph_extensions"
@@ -0,0 +1,202 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Rubik
4
+ module AST
5
+ class SymbolMap
6
+ def values
7
+ qualified_values = @qualified_symbols.sort_by { |key, _symbol| key }.map(&:last)
8
+ qualified_ids = {}.compare_by_identity
9
+ qualified_values.each { |symbol| qualified_ids[symbol] = true }
10
+ super.reject { |symbol| qualified_ids.key?(symbol) } + qualified_values
11
+ end
12
+
13
+ def size
14
+ values.length
15
+ end
16
+ alias length size
17
+
18
+ def empty?
19
+ super && @qualified_symbols.empty?
20
+ end
21
+ end
22
+
23
+ class Graph
24
+ class AmbiguousSymbolError < StandardError
25
+ attr_reader :query, :candidates
26
+
27
+ def initialize(query, candidates)
28
+ @query = query
29
+ @candidates = candidates
30
+ super("Ambiguous symbol '#{query}'. Use a qualified key. Candidates:\n- #{candidates.join("\n- ")}")
31
+ end
32
+ end
33
+
34
+ def begin_warmup(total: nil)
35
+ @mutex.synchronize do
36
+ @warmup_generation += 1
37
+ @warmup_status = { state: :warming, indexed: 0, total: total, generation: @warmup_generation }
38
+ @warmup_generation
39
+ end
40
+ end
41
+
42
+ def update_warmup(generation, indexed: nil, total: nil)
43
+ @mutex.synchronize do
44
+ return false unless generation == @warmup_status[:generation] && @warmup_status[:state] == :warming
45
+
46
+ @warmup_status[:indexed] = [indexed, 0].max unless indexed.nil?
47
+ @warmup_status[:total] = [total, 0].max unless total.nil?
48
+ true
49
+ end
50
+ end
51
+
52
+ def finish_warmup(generation, state: :ready)
53
+ @mutex.synchronize do
54
+ return false unless generation == @warmup_status[:generation]
55
+
56
+ @warmup_status[:state] = state
57
+ true
58
+ end
59
+ end
60
+
61
+ def warmup_status
62
+ @mutex.synchronize { @warmup_status.dup }
63
+ end
64
+
65
+ # Search each qualified definition once and return deterministic relevance order.
66
+ def search_symbols(query)
67
+ q = query.to_s.downcase
68
+ return [] if q.empty?
69
+
70
+ @mutex.synchronize do
71
+ entries = @qualified_symbols.sort_by { |key, _symbol| key }
72
+ entries = @symbols.map { |name, sym| ["#{sym.file}##{name}", sym] }.sort_by(&:first) if entries.empty?
73
+ entries.select { |_key, sym| sym.name.downcase.include?(q) }
74
+ .sort_by { |key, sym| [_search_rank(sym.name.downcase, q), key] }
75
+ .map(&:last)
76
+ end
77
+ end
78
+
79
+ def symbol_candidates(symbol_name)
80
+ @mutex.synchronize { _symbol_candidate_pairs_unlocked(symbol_name).map(&:first) }
81
+ end
82
+
83
+ # A bare query with multiple definitions is ambiguous; unresolved receiver
84
+ # dispatches remain in the call list without being assigned to a symbol.
85
+ def callers_of(symbol_name)
86
+ @mutex.synchronize do
87
+ candidates = _symbol_candidate_pairs_unlocked(symbol_name)
88
+ return [] if candidates.empty?
89
+ raise AmbiguousSymbolError.new(symbol_name, candidates.map(&:first)) if candidates.length > 1
90
+
91
+ target_key, target = candidates.first
92
+ target_names = caller_targets(symbol_name)
93
+ target_name_count = _symbol_candidate_pairs_unlocked(_symbol_leaf(target.name)).length
94
+ target_scoped_count = _symbol_candidate_pairs_unlocked(target.name).length
95
+ known_symbols = @qualified_symbols.values.uniq
96
+ known_symbols = @symbols.values.uniq if known_symbols.empty?
97
+ known_symbols.select do |caller|
98
+ caller.calls.any? do |call|
99
+ _call_matches_symbol?(call, caller, target, target_key, target_names, target_name_count, target_scoped_count)
100
+ end
101
+ end
102
+ end
103
+ end
104
+
105
+ def symbol_definition(symbol_name)
106
+ @mutex.synchronize do
107
+ candidates = _symbol_candidate_pairs_unlocked(symbol_name)
108
+ return nil unless candidates.length == 1
109
+
110
+ target = candidates.first.last
111
+ {
112
+ name: target.name,
113
+ kind: target.kind,
114
+ file: target.file,
115
+ line_start: target.line_start,
116
+ line_end: target.line_end,
117
+ signature: target.signature,
118
+ body: target.body,
119
+ docstring: target.docstring,
120
+ calls: target.calls
121
+ }
122
+ end
123
+ end
124
+
125
+ private
126
+
127
+ def _search_rank(name, query)
128
+ return 0 if name == query
129
+ return 1 if name.start_with?(query)
130
+
131
+ 2
132
+ end
133
+
134
+ def caller_targets(symbol_name)
135
+ candidates = _symbol_candidate_pairs_unlocked(symbol_name)
136
+ return [] unless candidates.length == 1
137
+
138
+ key, symbol = candidates.first
139
+ [key, symbol.name].uniq
140
+ end
141
+
142
+ def _symbol_candidate_pairs_unlocked(symbol_name)
143
+ query = symbol_name.to_s
144
+ return [] if query.empty?
145
+
146
+ exact = @qualified_symbols[query]
147
+ return [[query, exact]] if exact
148
+
149
+ pairs = @qualified_symbols.to_a
150
+ pairs = @symbols.map { |name, sym| ["#{sym.file}##{name}", sym] } if pairs.empty?
151
+ folded = query.downcase
152
+ matched = pairs.select do |_key, symbol|
153
+ name = symbol.name.to_s
154
+ leaf = _symbol_leaf(name)
155
+ name.downcase == folded || leaf.downcase == folded ||
156
+ name.downcase.end_with?("##{folded}", ".#{folded}", "::#{folded}")
157
+ end
158
+ matched.sort_by(&:first).uniq(&:first)
159
+ end
160
+
161
+ def _symbol_leaf(name)
162
+ name.to_s.split(/[#.:]/).last.to_s
163
+ end
164
+
165
+ def _symbol_scope(name)
166
+ value = name.to_s
167
+ separator = [value.rindex("#"), value.rindex("."), value.rindex("::")].compact.max
168
+ separator ? value[0...separator] : nil
169
+ end
170
+
171
+ def _normalized_call_name(name)
172
+ name.to_s.gsub("::", "#").gsub(".", "#").sub(/!\z/, "")
173
+ end
174
+
175
+ def _call_matches_symbol?(call, caller, target, target_key, target_names, target_name_count, target_scoped_count = target_name_count)
176
+ value = call.to_s.strip.sub(/!\z/, "")
177
+ normalized = _normalized_call_name(value)
178
+ target_name = _normalized_call_name(target.name)
179
+ same_file = caller.file && target.file && Rubik::Platform.path_equal?(caller.file, target.file)
180
+ return true if value == target_key
181
+ return true if normalized == target_name && (target_scoped_count == 1 || same_file)
182
+
183
+ leaf = _symbol_leaf(value)
184
+ return false unless leaf == _symbol_leaf(target.name)
185
+ return true if target_name_count == 1 && !value.match?(/[.#:]|->/)
186
+
187
+ call_scope = _symbol_scope(value)
188
+ target_scope = _symbol_scope(target.name)
189
+ caller_scope = _symbol_scope(caller.name)
190
+ return true if call_scope == target_scope && call_scope && same_file
191
+
192
+ caller_recv = caller_scope && caller_scope[/\A\(\s*([A-Za-z_]\w*)\b/, 1]
193
+ self_scopes = ["self", "this", caller_recv].compact
194
+ return true if self_scopes.include?(call_scope) && caller_scope == target_scope && same_file
195
+
196
+ # Bare calls are local to one lexical scope; unknown receiver calls stay unresolved.
197
+ lexical_scope_matches = target_scope ? caller_scope == target_scope : caller_scope.nil?
198
+ !value.match?(/[.#:]|->/) && target_names.include?(target.name) && same_file && lexical_scope_matches
199
+ end
200
+ end
201
+ end
202
+ end
@@ -141,14 +141,19 @@ module Rubik
141
141
  end
142
142
  end
143
143
 
144
- def extract_calls_from_node(node, self_name)
144
+ def extract_calls_from_node(node, _self_name)
145
145
  calls = []
146
146
  queue = [node]
147
147
 
148
148
  while (curr = queue.pop)
149
149
  if curr.is_a?(Prism::CallNode)
150
150
  cname = curr.name.to_s
151
- calls << cname unless cname == self_name || ["require", "require_relative", "raise", "puts", "p", "print"].include?(cname)
151
+ if curr.receiver
152
+ receiver = curr.receiver.slice.to_s
153
+ operator = curr.call_operator || "."
154
+ cname = "#{receiver}#{operator}#{cname}"
155
+ end
156
+ calls << cname unless ["require", "require_relative", "raise", "puts", "p", "print"].include?(cname)
152
157
  end
153
158
  queue.concat(curr.compact_child_nodes) if curr.respond_to?(:compact_child_nodes)
154
159
  end
@@ -187,7 +192,7 @@ module Rubik
187
192
  calls = []
188
193
  calls_src.scan(/\b([a-z_][a-zA-Z0-9_]*)\s*\(/) do |m|
189
194
  c = m[0]
190
- calls << c unless c == method_name || EXCLUDED_CALLS.include?(c) || calls.include?(c)
195
+ calls << c unless EXCLUDED_CALLS.include?(c) || calls.include?(c)
191
196
  end
192
197
 
193
198
  symbols << Symbol.new(
@@ -45,6 +45,13 @@ module Rubik
45
45
  end
46
46
  end
47
47
 
48
+ def body_available?
49
+ return true if @body || @body_loader
50
+ return false if @file.nil? || !File.exist?(@file)
51
+
52
+ true
53
+ end
54
+
48
55
  def self.read_source_range(file, l_start, l_end)
49
56
  return nil unless file && l_start && File.exist?(file)
50
57
 
@@ -79,9 +79,10 @@ module Rubik
79
79
  if call_kinds.include?(n.kind)
80
80
  fn = n.child_by_field_name("function") || n.child_by_field_name("name") || n.named_child(0)
81
81
  if fn
82
- fn_text = text_for(fn).strip
83
- # Extract clean identifier name (e.g. "fmt.Println" -> "Println", "obj.method" -> "method")
84
- fn_name = fn_text.split(".").last.to_s.split("::").last.to_s.strip
82
+ # Keep receiver and namespace text. Stripping this to a bare
83
+ # method name made dynamic dispatch look like a call to every
84
+ # same-named definition in the graph.
85
+ fn_name = text_for(fn).strip
85
86
  calls << fn_name if fn_name && !fn_name.empty?
86
87
  end
87
88
  end
@@ -49,7 +49,9 @@ module Rubik
49
49
  # `scope` is the enclosing declaration path (namespaces, then the
50
50
  # innermost class, outermost first); `member` marks a class body so
51
51
  # declarations are read as member declarations.
52
- def walk_children(node, symbols, scope, member:)
52
+ def walk_children(node, symbols, scope, member:, depth: 0)
53
+ return if depth > 50
54
+
53
55
  (0...node.named_child_count).each do |i|
54
56
  child = node.named_child(i)
55
57
  case child.kind
@@ -58,9 +60,9 @@ module Rubik
58
60
  when "type_definition"
59
61
  walk_typedef(child, symbols, scope)
60
62
  when *RECORD_KINDS.keys
61
- walk_record(child, symbols, scope)
63
+ walk_record(child, symbols, scope, depth: depth + 1)
62
64
  when "namespace_definition"
63
- walk_namespace(child, symbols, scope)
65
+ walk_namespace(child, symbols, scope, depth: depth + 1)
64
66
  when "function_definition"
65
67
  walk_function(child, symbols, scope, member: member)
66
68
  when "field_declaration", "declaration"
@@ -69,7 +71,7 @@ module Rubik
69
71
  # Plain recursion also unwraps template_declaration nodes (their
70
72
  # inner class/function is walked in place) and linkage
71
73
  # specifications.
72
- walk_children(child, symbols, scope, member: member)
74
+ walk_children(child, symbols, scope, member: member, depth: depth + 1)
73
75
  end
74
76
  end
75
77
  end
@@ -104,7 +106,7 @@ module Rubik
104
106
  )
105
107
  end
106
108
 
107
- def walk_record(node, symbols, scope)
109
+ def walk_record(node, symbols, scope, depth:)
108
110
  name_node = node.child_by_field_name("name")
109
111
  return unless name_node
110
112
 
@@ -122,16 +124,16 @@ module Rubik
122
124
  )
123
125
 
124
126
  body = node.child_by_field_name("body")
125
- walk_children(body, symbols, scope + [name], member: true) if body
127
+ walk_children(body, symbols, scope + [name], member: true, depth: depth + 1) if body
126
128
  end
127
129
 
128
130
  # Namespace names extend the qualification path; everything inside is
129
131
  # walked with the namespace prefix (app::run, app::Widget#run).
130
- def walk_namespace(node, symbols, scope)
132
+ def walk_namespace(node, symbols, scope, depth:)
131
133
  name_node = node.child_by_field_name("name")
132
134
  ns_scope = name_node ? scope + [text_for(name_node).strip] : scope
133
135
  body = node.child_by_field_name("body")
134
- walk_children(body, symbols, ns_scope, member: false) if body
136
+ walk_children(body, symbols, ns_scope, member: false, depth: depth + 1) if body
135
137
  end
136
138
 
137
139
  def walk_function(node, symbols, scope, member:)
@@ -16,7 +16,9 @@ module Rubik
16
16
 
17
17
  private
18
18
 
19
- def walk_children(node, symbols)
19
+ def walk_children(node, symbols, depth: 0)
20
+ return if depth > 50
21
+
20
22
  (0...node.named_child_count).each do |i|
21
23
  child = node.named_child(i)
22
24
  case child.kind
@@ -31,7 +33,7 @@ module Rubik
31
33
  when "import_declaration"
32
34
  walk_import(child, symbols)
33
35
  else
34
- walk_children(child, symbols)
36
+ walk_children(child, symbols, depth: depth + 1)
35
37
  end
36
38
  end
37
39
  end