Skip to content

Commit 6bea0b0

Browse files
committed
fix(indexing): include TypeScript limiter callbacks in call graph
treat exported const limiters as symbols and capture middleware arguments in the TypeScript strategy add TypeScript sample regression to verify rateLimiter.ts called_by coverage
1 parent 66c6218 commit 6bea0b0

File tree

2 files changed

+312
-38
lines changed

2 files changed

+312
-38
lines changed

src/code_index_mcp/indexing/strategies/typescript_strategy.py

Lines changed: 274 additions & 38 deletions
Original file line numberDiff line numberDiff line change
@@ -35,6 +35,9 @@ def parse_file(self, file_path: str, content: str) -> Tuple[Dict[str, SymbolInfo
3535

3636
# Symbol lookup index for O(1) access
3737
symbol_lookup = {} # name -> symbol_id mapping
38+
pending_calls: List[Tuple[str, str]] = []
39+
pending_call_set: Set[Tuple[str, str]] = set()
40+
variable_scopes: List[Dict[str, str]] = [{}]
3841

3942
parser = tree_sitter.Parser(self.ts_language)
4043
tree = parser.parse(content.encode('utf8'))
@@ -48,7 +51,10 @@ def parse_file(self, file_path: str, content: str) -> Tuple[Dict[str, SymbolInfo
4851
classes=classes,
4952
imports=imports,
5053
exports=exports,
51-
symbol_lookup=symbol_lookup
54+
symbol_lookup=symbol_lookup,
55+
pending_calls=pending_calls,
56+
pending_call_set=pending_call_set,
57+
variable_scopes=variable_scopes,
5258
)
5359

5460
self._traverse_node_single_pass(tree.root_node, context)
@@ -61,15 +67,20 @@ def parse_file(self, file_path: str, content: str) -> Tuple[Dict[str, SymbolInfo
6167
exports=exports
6268
)
6369

70+
if context.pending_calls:
71+
file_info.pending_calls = context.pending_calls
72+
6473
return symbols, file_info
6574

6675
def _traverse_node_single_pass(self, node, context: 'TraversalContext',
6776
current_function: Optional[str] = None,
6877
current_class: Optional[str] = None):
6978
"""Single-pass traversal that extracts symbols and analyzes calls."""
7079

80+
node_type = node.type
81+
7182
# Handle function declarations
72-
if node.type == 'function_declaration':
83+
if node_type == 'function_declaration':
7384
name = self._get_function_name(node, context.content)
7485
if name:
7586
symbol_id = self._create_symbol_id(context.file_path, name)
@@ -92,7 +103,7 @@ def _traverse_node_single_pass(self, node, context: 'TraversalContext',
92103
return
93104

94105
# Handle class declarations
95-
elif node.type == 'class_declaration':
106+
elif node_type == 'class_declaration':
96107
name = self._get_class_name(node, context.content)
97108
if name:
98109
symbol_id = self._create_symbol_id(context.file_path, name)
@@ -112,7 +123,7 @@ def _traverse_node_single_pass(self, node, context: 'TraversalContext',
112123
return
113124

114125
# Handle interface declarations
115-
elif node.type == 'interface_declaration':
126+
elif node_type == 'interface_declaration':
116127
name = self._get_interface_name(node, context.content)
117128
if name:
118129
symbol_id = self._create_symbol_id(context.file_path, name)
@@ -132,7 +143,7 @@ def _traverse_node_single_pass(self, node, context: 'TraversalContext',
132143
return
133144

134145
# Handle method definitions
135-
elif node.type == 'method_definition':
146+
elif node_type == 'method_definition':
136147
method_name = self._get_method_name(node, context.content)
137148
if method_name and current_class:
138149
full_name = f"{current_class}.{method_name}"
@@ -156,37 +167,68 @@ def _traverse_node_single_pass(self, node, context: 'TraversalContext',
156167
current_class=current_class)
157168
return
158169

170+
# Handle variable declarations that define callable exports
171+
elif node_type in ['lexical_declaration', 'variable_statement']:
172+
handled = False
173+
for child in node.children:
174+
if child.type != 'variable_declarator':
175+
continue
176+
name_node = child.child_by_field_name('name')
177+
value_node = child.child_by_field_name('value')
178+
if not name_node or not value_node:
179+
continue
180+
181+
if current_function is not None:
182+
continue
183+
184+
value_type = value_node.type
185+
if value_type not in [
186+
'arrow_function',
187+
'function',
188+
'function_expression',
189+
'call_expression',
190+
'new_expression',
191+
'identifier',
192+
'member_expression',
193+
]:
194+
continue
195+
196+
name = context.content[name_node.start_byte:name_node.end_byte]
197+
symbol_id = self._create_symbol_id(context.file_path, name)
198+
signature = context.content[child.start_byte:child.end_byte].split('\n')[0].strip()
199+
symbol_info = SymbolInfo(
200+
type="function",
201+
file=context.file_path,
202+
line=child.start_point[0] + 1,
203+
signature=signature
204+
)
205+
context.symbols[symbol_id] = symbol_info
206+
context.symbol_lookup[name] = symbol_id
207+
context.functions.append(name)
208+
handled = True
209+
210+
if value_type in ['arrow_function', 'function', 'function_expression']:
211+
func_context = f"{context.file_path}::{name}"
212+
context.variable_scopes.append({})
213+
self._traverse_node_single_pass(
214+
value_node,
215+
context,
216+
current_function=func_context,
217+
current_class=current_class
218+
)
219+
context.variable_scopes.pop()
220+
221+
if handled:
222+
return
223+
159224
# Handle function calls
160-
elif node.type == 'call_expression' and current_function:
161-
# Extract the function being called
162-
called_function = None
163-
if node.children:
164-
func_node = node.children[0]
165-
if func_node.type == 'identifier':
166-
# Direct function call
167-
called_function = context.content[func_node.start_byte:func_node.end_byte]
168-
elif func_node.type == 'member_expression':
169-
# Method call (obj.method or this.method)
170-
for child in func_node.children:
171-
if child.type == 'property_identifier':
172-
called_function = context.content[child.start_byte:child.end_byte]
173-
break
174-
175-
# Add relationship using O(1) lookup
176-
if called_function:
177-
if called_function in context.symbol_lookup:
178-
symbol_id = context.symbol_lookup[called_function]
179-
symbol_info = context.symbols[symbol_id]
180-
if current_function not in symbol_info.called_by:
181-
symbol_info.called_by.append(current_function)
182-
else:
183-
# Try to find method with class prefix
184-
for name, sid in context.symbol_lookup.items():
185-
if name.endswith(f".{called_function}"):
186-
symbol_info = context.symbols[sid]
187-
if current_function not in symbol_info.called_by:
188-
symbol_info.called_by.append(current_function)
189-
break
225+
elif node_type == 'call_expression':
226+
caller = current_function or f"{context.file_path}:{node.start_point[0] + 1}"
227+
called_function = self._resolve_called_function(node, context, current_class)
228+
if caller and called_function:
229+
self._register_call(context, caller, called_function)
230+
if caller:
231+
self._collect_callback_arguments(node, context, caller, current_class, current_function)
190232

191233
# Handle import declarations
192234
elif node.type == 'import_statement':
@@ -203,6 +245,185 @@ def _traverse_node_single_pass(self, node, context: 'TraversalContext',
203245
self._traverse_node_single_pass(child, context, current_function=current_function,
204246
current_class=current_class)
205247

248+
def _register_call(self, context: 'TraversalContext', caller: str, called: str) -> None:
249+
if called in context.symbol_lookup:
250+
symbol_id = context.symbol_lookup[called]
251+
symbol_info = context.symbols[symbol_id]
252+
if caller not in symbol_info.called_by:
253+
symbol_info.called_by.append(caller)
254+
return
255+
256+
key = (caller, called)
257+
if key not in context.pending_call_set:
258+
context.pending_call_set.add(key)
259+
context.pending_calls.append(key)
260+
261+
def _collect_callback_arguments(
262+
self,
263+
node,
264+
context: 'TraversalContext',
265+
caller: str,
266+
current_class: Optional[str],
267+
current_function: Optional[str]
268+
) -> None:
269+
arguments_node = node.child_by_field_name('arguments')
270+
if not arguments_node:
271+
return
272+
273+
for argument in arguments_node.children:
274+
if not getattr(argument, "is_named", False):
275+
continue
276+
callback_name = self._resolve_argument_reference(argument, context, current_class)
277+
if callback_name:
278+
call_site = caller
279+
if current_function is None:
280+
call_site = f"{context.file_path}:{argument.start_point[0] + 1}"
281+
self._register_call(context, call_site, callback_name)
282+
283+
def _resolve_argument_reference(
284+
self,
285+
node,
286+
context: 'TraversalContext',
287+
current_class: Optional[str]
288+
) -> Optional[str]:
289+
node_type = node.type
290+
291+
if node_type == 'identifier':
292+
return context.content[node.start_byte:node.end_byte]
293+
294+
if node_type == 'member_expression':
295+
property_node = node.child_by_field_name('property')
296+
if property_node is None:
297+
for child in node.children:
298+
if child.type in ['property_identifier', 'identifier']:
299+
property_node = child
300+
break
301+
if property_node is None:
302+
return None
303+
304+
property_name = context.content[property_node.start_byte:property_node.end_byte]
305+
qualifier_node = node.child_by_field_name('object')
306+
qualifier = self._resolve_member_qualifier(
307+
qualifier_node,
308+
context,
309+
current_class
310+
)
311+
if not qualifier:
312+
for child in node.children:
313+
if child is property_node:
314+
continue
315+
qualifier = self._resolve_member_qualifier(
316+
child,
317+
context,
318+
current_class
319+
)
320+
if qualifier:
321+
break
322+
if qualifier:
323+
return f"{qualifier}.{property_name}"
324+
return property_name
325+
326+
return None
327+
328+
def _resolve_called_function(
329+
self,
330+
node,
331+
context: 'TraversalContext',
332+
current_class: Optional[str]
333+
) -> Optional[str]:
334+
function_node = node.child_by_field_name('function')
335+
if function_node is None and node.children:
336+
function_node = node.children[0]
337+
if function_node is None:
338+
return None
339+
340+
if function_node.type == 'identifier':
341+
return context.content[function_node.start_byte:function_node.end_byte]
342+
343+
if function_node.type == 'member_expression':
344+
property_node = function_node.child_by_field_name('property')
345+
if property_node is None:
346+
for child in function_node.children:
347+
if child.type in ['property_identifier', 'identifier']:
348+
property_node = child
349+
break
350+
if property_node is None:
351+
return None
352+
353+
property_name = context.content[property_node.start_byte:property_node.end_byte]
354+
qualifier_node = function_node.child_by_field_name('object')
355+
qualifier = self._resolve_member_qualifier(
356+
qualifier_node,
357+
context,
358+
current_class
359+
)
360+
if not qualifier:
361+
for child in function_node.children:
362+
if child is property_node:
363+
continue
364+
qualifier = self._resolve_member_qualifier(
365+
child,
366+
context,
367+
current_class
368+
)
369+
if qualifier:
370+
break
371+
if qualifier:
372+
return f"{qualifier}.{property_name}"
373+
return property_name
374+
375+
return None
376+
377+
def _resolve_member_qualifier(
378+
self,
379+
node,
380+
context: 'TraversalContext',
381+
current_class: Optional[str]
382+
) -> Optional[str]:
383+
if node is None:
384+
return None
385+
386+
node_type = node.type
387+
if node_type == 'this':
388+
return current_class
389+
390+
if node_type == 'identifier':
391+
return context.content[node.start_byte:node.end_byte]
392+
393+
if node_type == 'member_expression':
394+
property_node = node.child_by_field_name('property')
395+
if property_node is None:
396+
for child in node.children:
397+
if child.type in ['property_identifier', 'identifier']:
398+
property_node = child
399+
break
400+
if property_node is None:
401+
return None
402+
403+
qualifier = self._resolve_member_qualifier(
404+
node.child_by_field_name('object'),
405+
context,
406+
current_class
407+
)
408+
if not qualifier:
409+
for child in node.children:
410+
if child is property_node:
411+
continue
412+
qualifier = self._resolve_member_qualifier(
413+
child,
414+
context,
415+
current_class
416+
)
417+
if qualifier:
418+
break
419+
420+
property_name = context.content[property_node.start_byte:property_node.end_byte]
421+
if qualifier:
422+
return f"{qualifier}.{property_name}"
423+
return property_name
424+
425+
return None
426+
206427
def _get_function_name(self, node, content: str) -> Optional[str]:
207428
"""Extract function name from tree-sitter node."""
208429
for child in node.children:
@@ -239,13 +460,28 @@ def _get_ts_function_signature(self, node, content: str) -> str:
239460
class TraversalContext:
240461
"""Context object to pass state during single-pass traversal."""
241462

242-
def __init__(self, content: str, file_path: str, symbols: Dict,
243-
functions: List, classes: List, imports: List, exports: List, symbol_lookup: Dict):
463+
def __init__(
464+
self,
465+
content: str,
466+
file_path: str,
467+
symbols: Dict,
468+
functions: List,
469+
classes: List,
470+
imports: List,
471+
exports: List,
472+
symbol_lookup: Dict,
473+
pending_calls: List[Tuple[str, str]],
474+
pending_call_set: Set[Tuple[str, str]],
475+
variable_scopes: List[Dict[str, str]],
476+
):
244477
self.content = content
245478
self.file_path = file_path
246479
self.symbols = symbols
247480
self.functions = functions
248481
self.classes = classes
249482
self.imports = imports
250483
self.exports = exports
251-
self.symbol_lookup = symbol_lookup
484+
self.symbol_lookup = symbol_lookup
485+
self.pending_calls = pending_calls
486+
self.pending_call_set = pending_call_set
487+
self.variable_scopes = variable_scopes

0 commit comments

Comments
 (0)